first commit
This commit is contained in:
71
apps/miniprogram/pages/terminal/codexCaptureFixture.js
Normal file
71
apps/miniprogram/pages/terminal/codexCaptureFixture.js
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 2026-03-11 使用本机 codex-cli v0.113.0 真实 PTY 抓取,
|
||||
* 并裁掉 `script` 头尾后的最小回放样本。
|
||||
* 这里保留了 `CSI ? 2026 h/l`、`Working (...)` 和底部 footer 等关键控制流。
|
||||
*/
|
||||
const CODEX_TTY_CAPTURE_20260311_BASE64 =
|
||||
"G1s/MjAwNGgbWz43dRtbPzEwMDRoG1s2bhtbP3UbW2MbXTEwOz8bXBtbPzIwMjZoG1sxOzJIG1swbRtbbRtbSxtbMjs0Mkgb" +
|
||||
"WzBtG1ttG1tLG1szOzQySBtbMG0bW20bW0sbWzQ7NDJIG1swbRtbbRtbSxtbNTs0MkgbWzBtG1ttG1tLG1s2OzQySBtbMG0b" +
|
||||
"W20bW0sbWzc7NDJIG1swbRtbbRtbSxtbODsySBtbMG0bW20bW0sbWzk7MkgbWzBtG1ttG1tLG1sxMDsyN0gbWzBtG1ttG1tL" +
|
||||
"G1sxMTsySBtbMG0bW20bW0sbWzEyOzc5SBtbMG0bW20bW0sbWzI7MUgbWzJt4pWt4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA" +
|
||||
"4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA" +
|
||||
"4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pWuG1szOzFI4pSCID5fIBtbMjJtG1sxbU9wZW5BSSBDb2RleBtbMjJtG1sybRtbMm0g" +
|
||||
"KHYwLjExMy4wKSAgICAgICAgICAgIOKUghtbNDsxSOKUgiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg" +
|
||||
"IOKUghtbNTsxSOKUgiBtb2RlbDogICAgIBtbM21sb2FkaW5nG1syM20gICAbWzIybRtbO20vbW9kZWwbWzJtG1s7bSB0byBj" +
|
||||
"aGFuZ2Ug4pSCG1s2OzFI4pSCIGRpcmVjdG9yeTogG1syMm1+L3JlbW90ZWNvbm4bWzJtICAgICAgICAgICAgICAg4pSCG1s3" +
|
||||
"OzFI4pWw4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA" +
|
||||
"4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pWvG1sxMDsxSBtbMjJtG1sxbeKA" +
|
||||
"uhtbMTA7M0gbWzIybRtbMm0bWzJtU3VtbWFyaXplIHJlY2VudCBjb21taXRzG1sxMjszSD8gZm9yIHNob3J0Y3V0cxtbMTI7" +
|
||||
"NjJIMTAwJSBjb250ZXh0IGxlZnQbW20bW20bWzBtG1s/MjVoG1sxMDszSBtbPzIwMjZsG1s/MjAyNmgbWzE7MUgbW0obWzE7" +
|
||||
"MkgbWzBtG1ttG1tLG1syOzUySBtbMG0bW20bW0sbWzM7MkgbWzBtG1ttG1tLG1s0OzJIG1swbRtbbRtbSxtbNTsyN0gbWzBt" +
|
||||
"G1ttG1tLG1s2OzJIG1swbRtbbRtbSxtbNzs0M0gbWzBtG1ttG1tLG1syOzFI4oCiG1syOzNIG1sybUJvb3RpbmcgTUNQIHNl" +
|
||||
"cnZlcjogZmlnbWEbWzI7MjlIKDBzIOKAoiBlc2MgdG8gaW50ZXJydXB0KRtbNTsxSBtbMjJtG1sxbeKAuhtbNTszSBtbMjJt" +
|
||||
"G1sybRtbMm1TdW1tYXJpemUgcmVjZW50IGNvbW1pdHMbWzc7MUggIGdwdC01LjQgeGhpZ2ggwrcgMTAwJSBsZWZ0IMK3IH4v" +
|
||||
"cmVtb3RlY29ubhtbbRtbbRtbMG0bWz8yNWgbWzU7M0gbWz8yMDI2bBtbPzIwMjZoG1sxOzJIG1swbRtbbRtbSxtbMjs1Mkgb" +
|
||||
"WzBtG1ttG1tLG1szOzJIG1swbRtbbRtbSxtbNDsySBtbMG0bW20bW0sbWzU7MjdIG1swbRtbbRtbSxtbNjsySBtbMG0bW20b" +
|
||||
"W0sbWzc7NDNIG1swbRtbbRtbSxtbMjsxMEggG1sxbU1DUCBzG1syMm1lG1syOzMwSBtbMm0yG1ttG1ttG1swbRtbPzI1aBtb" +
|
||||
"NTszSBtbPzIwMjZsG1s/MjAyNmgbWzE7MjRyG1sxOzFIG00bTRtNG00bTRtNG00bTRtNG00bTRtNG1tyG1sxOzEychtbMTsx" +
|
||||
"SA0NChtbO20bW0sbWzJt4pWt4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA" +
|
||||
"4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA" +
|
||||
"4pSA4pSA4pSA4pWuG1ttG1ttG1swbQ0NChtbO20bW0sbWzJt4pSCID5fIBtbMjJtG1sxbU9wZW5BSSBDb2RleBtbMjJtG1sy" +
|
||||
"bRtbMm0gKHYwLjExMy4wKSAgICAgICAgICAgICAgICAgIOKUghtbbRtbbRtbMG0NDQobWzttG1tLG1sybeKUgiAgICAgICAg" +
|
||||
"ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIOKUghtbbRtbbRtbMG0NDQobWzttG1tLG1sybeKUgiBtb2Rl" +
|
||||
"bDogICAgIBtbMjJtZ3B0LTUuNCB4aGlnaBtbMm0gICAbWzIybRtbO20vbW9kZWwbWzJtG1s7bSB0byBjaGFuZ2Ug4pSCG1tt" +
|
||||
"G1ttG1swbQ0NChtbO20bW0sbWzJt4pSCIGRpcmVjdG9yeTogG1syMm1+L3JlbW90ZWNvbm4bWzJtICAgICAgICAgICAgICAg" +
|
||||
"ICAgICAg4pSCG1ttG1ttG1swbQ0NChtbO20bW0sbWzJt4pWw4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA" +
|
||||
"4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA" +
|
||||
"4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pWvG1ttG1ttG1swbQ0NChtbO20bW0sbW20bW20bWzBtDQ0KG1s7bRtbSyAg" +
|
||||
"G1sxbVRpcDobWzIybSAbWzNtTmV3G1syM20gVXNlIBtbMW0vZmFzdBtbMjJtIHRvIGVuYWJsZSBvdXIgZmFzdGVzdCBpbmZl" +
|
||||
"cmVuY2UgYXQgMlggcGxhbiB1c2FnZS4bW20bW20bWzBtDQ0KG1s7bRtbSxtbbRtbbRtbMG0NDQobWzttG1tLG1ttG1ttG1sw" +
|
||||
"bQ0NChtbO20bW0sbWzFtG1sybeKAuiAbWzIybRtbMjJtUmVwbHkgd2l0aCB0aGUgc2luZ2xlIHdvcmQgT0sgYW5kIHRoZW4g" +
|
||||
"c3RvcC4bW20bW20bWzBtDQ0KG1s7bRtbSxtbbRtbbRtbMG0bW3IbWzU7M0gbWzEzOzJIG1swbRtbbRtbSxtbMTQ7NTJIG1sw" +
|
||||
"bRtbbRtbSxtbMTU7MkgbWzBtG1ttG1tLG1sxNjsySBtbMG0bW20bW0sbWzE3OzI3SBtbMG0bW20bW0sbWzE4OzJIG1swbRtb" +
|
||||
"bRtbSxtbMTk7NDNIG1swbRtbbRtbSxtbbRtbbRtbMG0bWz8yNWgbWzE3OzNIG1s/MjAyNmwbWz8yMDI2aBtbMTM7MUgbW0ob" +
|
||||
"WzEzOzI0chtbMTM7MUgbTRtNG00bW3IbWzE7MTVyG1sxMjsxSA0NChtbO20bW0sbW20bW20bWzBtDQ0KG1s7bRtbSxtbO23i" +
|
||||
"mqAgSGVhZHMgdXAsIHlvdSBoYXZlIGxlc3MgdGhhbiAyNSUgb2YgeW91ciB3ZWVrbHkgbGltaXQgbGVmdC4gUnVuIC9zdGF0" +
|
||||
"dXMgZm9yIGEbW20bW20bWzBtDQ0KG1s7bRtbSyAgG1s7bWJyZWFrZG93bi4bW20bW20bWzBtG1tyG1sxNzszSBtbMTY7Mkgb" +
|
||||
"WzBtG1ttG1tLG1sxNzsySBtbMG0bW20bW0sbWzE4OzI3SBtbMG0bW20bW0sbWzE5OzJIG1swbRtbbRtbSxtbMjA7NDNIG1sw" +
|
||||
"bRtbbRtbSxtbMTg7MUgbWzFt4oC6G1sxODszSBtbMjJtG1sybRtbMm1TdW1tYXJpemUgcmVjZW50IGNvbW1pdHMbWzIwOzFI" +
|
||||
"ICBncHQtNS40IHhoaWdoIMK3IDEwMCUgbGVmdCDCtyB+L3JlbW90ZWNvbm4bW20bW20bWzBtG1s/MjVoG1sxODszSBtbPzIw" +
|
||||
"MjZsG1s/MjAyNmgbWzE2OzFIG1tKG1sxNjsySBtbMG0bW20bW0sbWzE3OzM0SBtbMG0bW20bW0sbWzE4OzJIG1swbRtbbRtb" +
|
||||
"SxtbMTk7MkgbWzBtG1ttG1tLG1syMDsyN0gbWzBtG1ttG1tLG1syMTsySBtbMG0bW20bW0sbWzIyOzQzSBtbMG0bW20bW0sb" +
|
||||
"WzE3OzFI4oCiG1sxNzszSBtbMm1Xb3JraW5nG1sxNzsxMUgoMHMg4oCiIGVzYyB0byBpbnRlcnJ1cHQpG1syMDsxSBtbMjJt" +
|
||||
"G1sxbeKAuhtbMjA7M0gbWzIybRtbMm0bWzJtU3VtbWFyaXplIHJlY2VudCBjb21taXRzG1syMjsxSCAgZ3B0LTUuNCB4aGln" +
|
||||
"aCDCtyAxMDAlIGxlZnQgwrcgfi9yZW1vdGVjb25uG1ttG1ttG1swbRtbPzI1aBtbMjA7M0gbWz8yMDI2bBtbPzIwMjZoG1sx" +
|
||||
"NjsySBtbMG0bW20bW0sbWzE3OzM0SBtbMG0bW20bW0sbWzE4OzJIG1swbRtbbRtbSxtbMTk7MkgbWzBtG1ttG1tLG1syMDsy" +
|
||||
"N0gbWzBtG1ttG1tLG1syMTsySBtbMG0bW20bW0sbWzIyOzQzSBtbMG0bW20bW0sbW20bW20bWzBtG1s/MjVoG1syMDszSBtb" +
|
||||
"PzIwMjZsG1s/MjAyNmgbWzE2OzFIG1tKG1sxNjsyNHIbWzE2OzFIG00bTRtNG1tyG1sxOzE4chtbMTU7MUgNDQobWzttG1tL" +
|
||||
"G1ttG1ttG1swbQ0NChtbO20bW0sbWztt4pagIENvbnZlcnNhdGlvbiBpbnRlcnJ1cHRlZCAtIHRlbGwgdGhlIG1vZGVsIHdo" +
|
||||
"YXQgdG8gZG8gZGlmZmVyZW50bHkuIFNvbWV0aGluZxtbbRtbbRtbMG0NDQobWzttG1tLG1s7bXdlbnQgd3Jvbmc/IEhpdCBg" +
|
||||
"L2ZlZWRiYWNrYCB0byByZXBvcnQgdGhlIGlzc3VlLhtbbRtbbRtbMG0bW3IbWzIwOzNIG1sxOTsySBtbMG0bW20bW0sbWzIw" +
|
||||
"OzJIG1swbRtbbRtbSxtbMjE7MjdIG1swbRtbbRtbSxtbMjI7MkgbWzBtG1ttG1tLG1syMzs0M0gbWzBtG1ttG1tLG1syMTsx" +
|
||||
"SBtbMW3igLobWzIxOzNIG1syMm0bWzJtG1sybVN1bW1hcml6ZSByZWNlbnQgY29tbWl0cxtbMjM7MUggIGdwdC01LjQgeGhp" +
|
||||
"Z2ggwrcgMTAwJSBsZWZ0IMK3IH4vcmVtb3RlY29ubhtbbRtbbRtbMG0bWz8yNWgbWzIxOzNIG1s/MjAyNmw=";
|
||||
|
||||
function decodeCodexTtyCapture20260311() {
|
||||
return Buffer.from(CODEX_TTY_CAPTURE_20260311_BASE64, "base64").toString("utf8");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
decodeCodexTtyCapture20260311
|
||||
};
|
||||
121
apps/miniprogram/pages/terminal/codexCaptureReplay.test.ts
Normal file
121
apps/miniprogram/pages/terminal/codexCaptureReplay.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const { decodeCodexTtyCapture20260311 } = require("./codexCaptureFixture.js");
|
||||
const {
|
||||
consumeTerminalSyncUpdateFrames,
|
||||
createTerminalSyncUpdateState
|
||||
} = require("./vtParser.js");
|
||||
const {
|
||||
getActiveTerminalBuffer,
|
||||
rebuildTerminalBufferStateFromReplayText
|
||||
} = require("./terminalBufferState.js");
|
||||
const { buildTerminalViewportState } = require("./terminalViewportModel.js");
|
||||
|
||||
function splitTextIntoChunks(text: string, chunkSize: number) {
|
||||
const chunks = [];
|
||||
for (let index = 0; index < text.length; index += chunkSize) {
|
||||
chunks.push(text.slice(index, index + chunkSize));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function serializeViewportLines(replayText: string) {
|
||||
const state = rebuildTerminalBufferStateFromReplayText(replayText, {
|
||||
bufferCols: 80,
|
||||
bufferRows: 24
|
||||
});
|
||||
const active = getActiveTerminalBuffer(state);
|
||||
const viewport = buildTerminalViewportState({
|
||||
bufferRows: active.cells,
|
||||
cursorRow: active.cursorRow,
|
||||
activeBufferName: state.activeBufferName,
|
||||
visibleRows: 24,
|
||||
lineHeight: 20
|
||||
});
|
||||
return viewport.renderRows
|
||||
.map((line) =>
|
||||
(Array.isArray(line) ? line : [])
|
||||
.filter((cell) => cell && !cell.continuation)
|
||||
.map((cell) => cell.text || " ")
|
||||
.join("")
|
||||
.replace(/\s+$/, "")
|
||||
)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
describe("codexCaptureReplay", () => {
|
||||
it("真实 Codex 抓包回放时,会在交互进行中同时保留 Working 行和底部 footer", () => {
|
||||
const sample = decodeCodexTtyCapture20260311();
|
||||
|
||||
expect(sample).toContain("\u001b[?2026h");
|
||||
expect(sample).toContain("Working");
|
||||
expect(sample).toContain("gpt-5.4 xhigh");
|
||||
|
||||
let syncState = createTerminalSyncUpdateState();
|
||||
let replayText = "";
|
||||
let matchedLines: string[] | null = null;
|
||||
|
||||
splitTextIntoChunks(sample, 97).forEach((chunk) => {
|
||||
if (matchedLines) {
|
||||
return;
|
||||
}
|
||||
const result = consumeTerminalSyncUpdateFrames(chunk, syncState);
|
||||
syncState = result.state;
|
||||
if (!result.text) {
|
||||
return;
|
||||
}
|
||||
replayText += result.text;
|
||||
const lines = serializeViewportLines(replayText);
|
||||
const hasWorking = lines.some((line) => line.includes("Working"));
|
||||
const hasFooter = lines.some(
|
||||
(line) => line.includes("gpt-5.4 xhigh") && line.includes("~/remoteconn")
|
||||
);
|
||||
const hasConversation = lines.some(
|
||||
(line) =>
|
||||
line.includes("Reply with the single word OK and then stop.") ||
|
||||
line.includes("Summarize recent commits")
|
||||
);
|
||||
if (hasWorking && hasFooter && hasConversation) {
|
||||
matchedLines = lines;
|
||||
}
|
||||
});
|
||||
|
||||
expect(matchedLines).not.toBeNull();
|
||||
expect(matchedLines).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("Working"),
|
||||
expect.stringContaining("gpt-5.4 xhigh"),
|
||||
expect.stringContaining("Summarize recent commits")
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("真实 Codex 抓包完整回放后,normal buffer viewport 仍会保留底部 footer", () => {
|
||||
let syncState = createTerminalSyncUpdateState();
|
||||
let replayText = "";
|
||||
|
||||
splitTextIntoChunks(decodeCodexTtyCapture20260311(), 97).forEach((chunk) => {
|
||||
const result = consumeTerminalSyncUpdateFrames(chunk, syncState);
|
||||
syncState = result.state;
|
||||
replayText += result.text;
|
||||
});
|
||||
|
||||
expect(syncState).toEqual({
|
||||
depth: 0,
|
||||
carryText: "",
|
||||
bufferedText: ""
|
||||
});
|
||||
|
||||
const lines = serializeViewportLines(replayText);
|
||||
|
||||
expect(
|
||||
lines.some((line) => line.includes("gpt-5.4 xhigh") && line.includes("~/remoteconn"))
|
||||
).toBe(true);
|
||||
expect(lines).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("Conversation interrupted"),
|
||||
expect.stringContaining("Summarize recent commits")
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
/* global module, require */
|
||||
|
||||
const { toSvgDataUri } = require("../../utils/svgDataUri");
|
||||
|
||||
const DEFAULT_WIDTH = 640;
|
||||
const DEFAULT_HEIGHT = 212;
|
||||
const DEFAULT_PADDING = 0;
|
||||
const AXIS_GUTTER = 32;
|
||||
const TOP_INSET = 6;
|
||||
const BOTTOM_INSET = 6;
|
||||
|
||||
function normalizeSampleValue(value) {
|
||||
const numberValue = Number(value);
|
||||
if (!Number.isFinite(numberValue) || numberValue < 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.round(numberValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* 所有诊断曲线都只保留固定窗口内的最近采样点,
|
||||
* 这样卡片高度稳定,也避免历史峰值把当前波动压扁。
|
||||
*/
|
||||
function appendDiagnosticSample(samplesInput, value, maxPoints) {
|
||||
const normalizedValue = normalizeSampleValue(value);
|
||||
const next = Array.isArray(samplesInput) ? samplesInput.slice() : [];
|
||||
const sampleLimit = Math.max(1, Math.round(Number(maxPoints) || 30));
|
||||
if (normalizedValue == null) {
|
||||
return next.slice(-sampleLimit);
|
||||
}
|
||||
next.push(normalizedValue);
|
||||
return next.slice(-sampleLimit);
|
||||
}
|
||||
|
||||
function normalizeSeries(samplesInput) {
|
||||
return Array.isArray(samplesInput)
|
||||
? samplesInput.map((sample) => normalizeSampleValue(sample)).filter((sample) => sample != null)
|
||||
: [];
|
||||
}
|
||||
|
||||
function escapeXmlText(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 双轴图需要给左右刻度预留固定留白,
|
||||
* 否则数字标签会压到曲线和端点高亮。
|
||||
*/
|
||||
function buildPlotFrame(width, height, padding) {
|
||||
const left = padding + AXIS_GUTTER;
|
||||
const right = width - padding - AXIS_GUTTER;
|
||||
const top = padding + TOP_INSET;
|
||||
const bottom = height - padding - BOTTOM_INSET;
|
||||
return {
|
||||
left,
|
||||
right,
|
||||
top,
|
||||
bottom,
|
||||
width: Math.max(1, right - left),
|
||||
height: Math.max(1, bottom - top)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 两条曲线共用横轴,但保留各自真实量纲:
|
||||
* 左轴给“网关响应”,右轴给“网络时延”。
|
||||
*/
|
||||
function buildSeriesScale(samples) {
|
||||
if (samples.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const rawMin = Math.min(...samples);
|
||||
const rawMax = Math.max(...samples);
|
||||
const rawSpan = rawMax - rawMin;
|
||||
const padding = Math.max(4, rawSpan * 0.16, rawMax * 0.08, 1);
|
||||
const min = Math.max(0, rawMin - padding);
|
||||
const max = Math.max(min + 1, rawMax + padding);
|
||||
return {
|
||||
min,
|
||||
max
|
||||
};
|
||||
}
|
||||
|
||||
function buildAxisTicks(scale, plotFrame) {
|
||||
if (!scale) {
|
||||
return [];
|
||||
}
|
||||
const values = [scale.max, (scale.max + scale.min) / 2, scale.min];
|
||||
return values.map((value, index) => {
|
||||
const ratio = values.length <= 1 ? 0 : index / (values.length - 1);
|
||||
return {
|
||||
value,
|
||||
y: plotFrame.top + plotFrame.height * ratio
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function formatAxisTickLabel(value) {
|
||||
return `${Math.round(value)}ms`;
|
||||
}
|
||||
|
||||
function buildChartPoints(samples, plotFrame, totalSlots, scale) {
|
||||
if (samples.length === 0 || !scale) {
|
||||
return [];
|
||||
}
|
||||
const safeRange = Math.max(1, scale.max - scale.min);
|
||||
const safeSlots = Math.max(samples.length, Math.round(Number(totalSlots) || samples.length), 2);
|
||||
const slotOffset = Math.max(0, safeSlots - samples.length);
|
||||
return samples.map((sample, index) => {
|
||||
const ratio = safeSlots <= 1 ? 0 : (slotOffset + index) / (safeSlots - 1);
|
||||
const x = plotFrame.left + plotFrame.width * ratio;
|
||||
const y = plotFrame.bottom - plotFrame.height * ((sample - scale.min) / safeRange);
|
||||
return { x, y };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 Catmull-Rom 转三次贝塞尔,让时延曲线保持圆润,
|
||||
* 避免折线在采样点较少时显得生硬。
|
||||
*/
|
||||
function buildSmoothLinePath(points) {
|
||||
if (points.length === 0) return "";
|
||||
if (points.length === 1) {
|
||||
const point = points[0];
|
||||
return `M ${point.x.toFixed(2)} ${point.y.toFixed(2)} L ${point.x.toFixed(2)} ${point.y.toFixed(2)}`;
|
||||
}
|
||||
let path = `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`;
|
||||
for (let index = 0; index < points.length - 1; index += 1) {
|
||||
const p0 = points[index - 1] || points[index];
|
||||
const p1 = points[index];
|
||||
const p2 = points[index + 1];
|
||||
const p3 = points[index + 2] || p2;
|
||||
const cp1x = p1.x + (p2.x - p0.x) / 6;
|
||||
const cp1y = p1.y + (p2.y - p0.y) / 6;
|
||||
const cp2x = p2.x - (p3.x - p1.x) / 6;
|
||||
const cp2y = p2.y - (p3.y - p1.y) / 6;
|
||||
path += ` C ${cp1x.toFixed(2)} ${cp1y.toFixed(2)} ${cp2x.toFixed(2)} ${cp2y.toFixed(
|
||||
2
|
||||
)} ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function buildSmoothAreaPath(points, baselineY) {
|
||||
if (points.length === 0) return "";
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
return `${buildSmoothLinePath(points)} L ${last.x.toFixed(2)} ${baselineY.toFixed(2)} L ${first.x.toFixed(
|
||||
2
|
||||
)} ${baselineY.toFixed(2)} Z`;
|
||||
}
|
||||
|
||||
function buildGridLines(plotFrame, strokeColor) {
|
||||
const lines = [];
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
const y = plotFrame.top + (plotFrame.height / 3) * index;
|
||||
lines.push(
|
||||
`<line x1="${plotFrame.left.toFixed(2)}" y1="${y.toFixed(2)}" x2="${plotFrame.right.toFixed(
|
||||
2
|
||||
)}" y2="${y.toFixed(2)}" stroke="${strokeColor}" stroke-width="1" stroke-dasharray="6 10"/>`
|
||||
);
|
||||
}
|
||||
return lines.join("");
|
||||
}
|
||||
|
||||
function buildAxisLayer(side, ticks, plotFrame, lineColor, labelColor) {
|
||||
if (!Array.isArray(ticks) || ticks.length === 0) {
|
||||
return "";
|
||||
}
|
||||
const isLeft = side === "left";
|
||||
const axisX = isLeft ? plotFrame.left : plotFrame.right;
|
||||
const tickOuterX = isLeft ? axisX - 4 : axisX + 4;
|
||||
const labelX = isLeft ? axisX - 6 : axisX + 6;
|
||||
const anchor = isLeft ? "end" : "start";
|
||||
const tickMarks = ticks
|
||||
.map(
|
||||
(tick) => `
|
||||
<line x1="${axisX.toFixed(2)}" y1="${tick.y.toFixed(2)}" x2="${tickOuterX.toFixed(
|
||||
2
|
||||
)}" y2="${tick.y.toFixed(2)}" stroke="${lineColor}" stroke-opacity="0.38" stroke-width="1"/>
|
||||
<text x="${labelX.toFixed(2)}" y="${(tick.y + 4).toFixed(
|
||||
2
|
||||
)}" text-anchor="${anchor}" font-size="10" fill="${labelColor}" fill-opacity="0.88">${escapeXmlText(
|
||||
formatAxisTickLabel(tick.value)
|
||||
)}</text>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
return `
|
||||
<line x1="${axisX.toFixed(2)}" y1="${plotFrame.top.toFixed(2)}" x2="${axisX.toFixed(
|
||||
2
|
||||
)}" y2="${plotFrame.bottom.toFixed(2)}" stroke="${lineColor}" stroke-opacity="0.22" stroke-width="1"/>
|
||||
${tickMarks}
|
||||
`;
|
||||
}
|
||||
|
||||
function buildEmptySparklineSvg(width, height, plotFrame, colors) {
|
||||
const midY = (plotFrame.top + plotFrame.bottom) * 0.5;
|
||||
const leftY = midY - 18;
|
||||
const rightY = midY + 18;
|
||||
return `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="emptyBg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="${colors.cardGlow}" stop-opacity="0.22"/>
|
||||
<stop offset="100%" stop-color="${colors.cardGlow}" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="${width}" height="${height}" fill="url(#emptyBg)"/>
|
||||
${buildGridLines(plotFrame, colors.grid)}
|
||||
<line x1="${plotFrame.left.toFixed(2)}" y1="${plotFrame.top.toFixed(2)}" x2="${plotFrame.left.toFixed(
|
||||
2
|
||||
)}" y2="${plotFrame.bottom.toFixed(2)}" stroke="${colors.responseLine}" stroke-opacity="0.18" stroke-width="1"/>
|
||||
<line x1="${plotFrame.right.toFixed(2)}" y1="${plotFrame.top.toFixed(2)}" x2="${plotFrame.right.toFixed(
|
||||
2
|
||||
)}" y2="${plotFrame.bottom.toFixed(2)}" stroke="${colors.networkLine}" stroke-opacity="0.18" stroke-width="1"/>
|
||||
<line x1="${plotFrame.left.toFixed(2)}" y1="${midY.toFixed(2)}" x2="${plotFrame.right.toFixed(
|
||||
2
|
||||
)}" y2="${midY.toFixed(2)}" stroke="${colors.grid}" stroke-width="1" stroke-dasharray="8 12"/>
|
||||
<line x1="${plotFrame.left.toFixed(2)}" y1="${leftY.toFixed(2)}" x2="${plotFrame.right.toFixed(
|
||||
2
|
||||
)}" y2="${leftY.toFixed(2)}" stroke="${colors.responseLine}" stroke-width="1.25" stroke-linecap="round" opacity="0.38"/>
|
||||
<line x1="${plotFrame.left.toFixed(2)}" y1="${rightY.toFixed(2)}" x2="${plotFrame.right.toFixed(
|
||||
2
|
||||
)}" y2="${rightY.toFixed(2)}" stroke="${colors.networkLine}" stroke-width="1.25" stroke-linecap="round" opacity="0.38"/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
function buildSeriesDefs(prefix, colors) {
|
||||
return `
|
||||
<linearGradient id="${prefix}AreaFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="${colors.fill}" stop-opacity="0.18"/>
|
||||
<stop offset="100%" stop-color="${colors.fill}" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<filter id="${prefix}LineGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="1.2" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
`;
|
||||
}
|
||||
|
||||
function buildSeriesLayers(points, colors, prefix, baselineY) {
|
||||
if (points.length === 0) {
|
||||
return "";
|
||||
}
|
||||
const linePath = buildSmoothLinePath(points);
|
||||
const areaPath = buildSmoothAreaPath(points, baselineY);
|
||||
const lastPoint = points[points.length - 1];
|
||||
return `
|
||||
<path d="${areaPath}" fill="url(#${prefix}AreaFill)"/>
|
||||
<path d="${linePath}" stroke="${colors.line}" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" filter="url(#${prefix}LineGlow)"/>
|
||||
<circle cx="${lastPoint.x.toFixed(2)}" cy="${lastPoint.y.toFixed(2)}" r="2.6" fill="${colors.line}" fill-opacity="0.12"/>
|
||||
<circle cx="${lastPoint.x.toFixed(2)}" cy="${lastPoint.y.toFixed(2)}" r="1.4" fill="${colors.glow}"/>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 双序列图把“网关响应”和“网络时延”画在同一张坐标图里:
|
||||
* 1. 横轴按采样槽位右对齐,保证两个序列的最新点处在同一时间位置;
|
||||
* 2. 左轴保留“网关响应”自身量纲,右轴保留“网络时延”自身量纲;
|
||||
* 3. 两条曲线继续使用平滑贝塞尔,避免采样点少时退化成生硬折线。
|
||||
*/
|
||||
function buildCombinedDiagnosticSparkline(seriesInput, options) {
|
||||
const responseSamples = normalizeSeries(seriesInput && seriesInput.responseSamples);
|
||||
const networkSamples = normalizeSeries(seriesInput && seriesInput.networkSamples);
|
||||
const width = Math.max(120, Math.round(Number(options && options.width) || DEFAULT_WIDTH));
|
||||
const height = Math.max(80, Math.round(Number(options && options.height) || DEFAULT_HEIGHT));
|
||||
const padding = Math.max(0, Math.round(Number(options && options.padding) || DEFAULT_PADDING));
|
||||
const plotFrame = buildPlotFrame(width, height, padding);
|
||||
const colors = {
|
||||
responseLine: (options && options.responseLineColor) || "#67D1FF",
|
||||
responseFill: (options && options.responseFillColor) || "#67D1FF",
|
||||
responseGlow: (options && options.responseGlowColor) || "#B7F1FF",
|
||||
networkLine: (options && options.networkLineColor) || "#FFB35C",
|
||||
networkFill: (options && options.networkFillColor) || "#FFB35C",
|
||||
networkGlow: (options && options.networkGlowColor) || "#FFE0A3",
|
||||
cardGlow: (options && options.cardGlowColor) || "rgba(103, 209, 255, 0.28)",
|
||||
grid: (options && options.gridColor) || "rgba(255, 255, 255, 0.12)"
|
||||
};
|
||||
if (responseSamples.length === 0 && networkSamples.length === 0) {
|
||||
return toSvgDataUri(buildEmptySparklineSvg(width, height, plotFrame, colors));
|
||||
}
|
||||
|
||||
const responseScale = buildSeriesScale(responseSamples);
|
||||
const networkScale = buildSeriesScale(networkSamples);
|
||||
const responseTicks = buildAxisTicks(responseScale, plotFrame);
|
||||
const networkTicks = buildAxisTicks(networkScale, plotFrame);
|
||||
const totalSlots = Math.max(responseSamples.length, networkSamples.length, 2);
|
||||
const responsePoints = buildChartPoints(responseSamples, plotFrame, totalSlots, responseScale);
|
||||
const networkPoints = buildChartPoints(networkSamples, plotFrame, totalSlots, networkScale);
|
||||
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="cardGlow" cx="0.5" cy="0.08" r="0.82">
|
||||
<stop offset="0%" stop-color="${colors.cardGlow}" stop-opacity="0.55"/>
|
||||
<stop offset="100%" stop-color="${colors.cardGlow}" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
${buildSeriesDefs("response", {
|
||||
line: colors.responseLine,
|
||||
fill: colors.responseFill,
|
||||
glow: colors.responseGlow
|
||||
})}
|
||||
${buildSeriesDefs("network", {
|
||||
line: colors.networkLine,
|
||||
fill: colors.networkFill,
|
||||
glow: colors.networkGlow
|
||||
})}
|
||||
</defs>
|
||||
<rect x="0" y="0" width="${width}" height="${height}" fill="url(#cardGlow)"/>
|
||||
${buildGridLines(plotFrame, colors.grid)}
|
||||
${buildAxisLayer("left", responseTicks, plotFrame, colors.responseLine, colors.responseGlow)}
|
||||
${buildAxisLayer("right", networkTicks, plotFrame, colors.networkLine, colors.networkGlow)}
|
||||
${buildSeriesLayers(
|
||||
responsePoints,
|
||||
{
|
||||
line: colors.responseLine,
|
||||
fill: colors.responseFill,
|
||||
glow: colors.responseGlow
|
||||
},
|
||||
"response",
|
||||
plotFrame.bottom
|
||||
)}
|
||||
${buildSeriesLayers(
|
||||
networkPoints,
|
||||
{
|
||||
line: colors.networkLine,
|
||||
fill: colors.networkFill,
|
||||
glow: colors.networkGlow
|
||||
},
|
||||
"network",
|
||||
plotFrame.bottom
|
||||
)}
|
||||
</svg>
|
||||
`;
|
||||
|
||||
return toSvgDataUri(svg);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
appendDiagnosticSample,
|
||||
buildCombinedDiagnosticSparkline
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const { appendDiagnosticSample, buildCombinedDiagnosticSparkline } = require("./connectionDiagnosticsSparkline.js");
|
||||
|
||||
function decodeSvgDataUri(uri: string) {
|
||||
const base64 = uri.replace(/^data:image\/svg\+xml;base64,/, "");
|
||||
return Buffer.from(base64, "base64").toString("utf8");
|
||||
}
|
||||
|
||||
describe("connectionDiagnosticsSparkline", () => {
|
||||
it("限制最近 30 个采样点", () => {
|
||||
let samples: number[] = [];
|
||||
for (let index = 1; index <= 35; index += 1) {
|
||||
samples = appendDiagnosticSample(samples, index, 30);
|
||||
}
|
||||
expect(samples).toHaveLength(30);
|
||||
expect(samples[0]).toBe(6);
|
||||
expect(samples[29]).toBe(35);
|
||||
});
|
||||
|
||||
it("为双序列生成带平滑曲线与左右双轴的 SVG data URI", () => {
|
||||
const uri = buildCombinedDiagnosticSparkline(
|
||||
{
|
||||
responseSamples: [12, 22, 18, 30],
|
||||
networkSamples: [88, 76, 90, 94]
|
||||
},
|
||||
{
|
||||
responseLineColor: "#67D1FF",
|
||||
responseFillColor: "#67D1FF",
|
||||
networkLineColor: "#FFB35C",
|
||||
networkFillColor: "#FFB35C"
|
||||
}
|
||||
);
|
||||
const svg = decodeSvgDataUri(uri);
|
||||
|
||||
expect(uri.startsWith("data:image/svg+xml;base64,")).toBe(true);
|
||||
expect(svg).not.toContain("网关响应");
|
||||
expect(svg).not.toContain("网络时延");
|
||||
expect(svg).toContain('text-anchor="end"');
|
||||
expect(svg).toContain(" C ");
|
||||
expect(svg).not.toContain("<polyline");
|
||||
expect(svg).toContain('stroke-width="1.25"');
|
||||
expect(svg).toContain('stroke="#67D1FF"');
|
||||
expect(svg).toContain('stroke="#FFB35C"');
|
||||
expect(svg).not.toContain("clipPath");
|
||||
});
|
||||
|
||||
it("空数据时仍返回不带标题文字的占位图", () => {
|
||||
const uri = buildCombinedDiagnosticSparkline({
|
||||
responseSamples: [],
|
||||
networkSamples: []
|
||||
});
|
||||
const svg = decodeSvgDataUri(uri);
|
||||
|
||||
expect(uri.startsWith("data:image/svg+xml;base64,")).toBe(true);
|
||||
expect(svg).not.toContain("网关响应");
|
||||
expect(svg).not.toContain("网络时延");
|
||||
expect(svg).toContain("stroke-dasharray");
|
||||
});
|
||||
});
|
||||
7673
apps/miniprogram/pages/terminal/index.js
Normal file
7673
apps/miniprogram/pages/terminal/index.js
Normal file
File diff suppressed because it is too large
Load Diff
7
apps/miniprogram/pages/terminal/index.json
Normal file
7
apps/miniprogram/pages/terminal/index.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationBarTitleText": "终端",
|
||||
"disableScroll": true,
|
||||
"usingComponents": {
|
||||
"bottom-nav": "/components/bottom-nav/index"
|
||||
}
|
||||
}
|
||||
543
apps/miniprogram/pages/terminal/index.wxml
Normal file
543
apps/miniprogram/pages/terminal/index.wxml
Normal file
@@ -0,0 +1,543 @@
|
||||
<view class="page-root terminal-page" style="{{themeStyle}}">
|
||||
<view class="page-toolbar terminal-toolbar">
|
||||
<view class="toolbar-left">
|
||||
<button
|
||||
class="icon-btn toolbar-plain-btn terminal-toolbar-touch-btn svg-press-btn {{activeAiProvider ? 'is-connected' : ''}}"
|
||||
hover-class="svg-press-btn-hover"
|
||||
hover-start-time="0"
|
||||
hover-stay-time="80"
|
||||
data-press-key="terminal:ai"
|
||||
disabled="{{aiLaunchBusy}}"
|
||||
bindtouchstart="onSvgButtonTouchStart"
|
||||
bindtouchend="onSvgButtonTouchEnd"
|
||||
bindtouchcancel="onSvgButtonTouchEnd"
|
||||
bindtap="onOpenCodex"
|
||||
>
|
||||
<image
|
||||
class="icon-img svg-press-icon"
|
||||
src="{{pressedSvgButtonKey === 'terminal:ai' ? (activeAiProvider ? (uiButtonActiveIcons.codex || uiButtonIcons.codex || '/assets/icons/codex.svg') : (uiButtonAccentIcons.codex || uiButtonIcons.codex || '/assets/icons/codex.svg')) : (activeAiProvider ? (uiButtonActiveIcons.codex || uiButtonIcons.codex || '/assets/icons/codex.svg') : (uiButtonIcons.codex || '/assets/icons/codex.svg'))}}"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn toolbar-plain-btn terminal-toolbar-touch-btn svg-press-btn"
|
||||
hover-class="svg-press-btn-hover"
|
||||
hover-start-time="0"
|
||||
hover-stay-time="80"
|
||||
data-press-key="terminal:clear"
|
||||
disabled="{{statusText === 'connected' && activeAiProvider === 'codex'}}"
|
||||
bindtouchstart="onSvgButtonTouchStart"
|
||||
bindtouchend="onSvgButtonTouchEnd"
|
||||
bindtouchcancel="onSvgButtonTouchEnd"
|
||||
bindtap="onClearScreen"
|
||||
>
|
||||
<image
|
||||
class="icon-img svg-press-icon"
|
||||
src="{{pressedSvgButtonKey === 'terminal:clear' ? (uiButtonAccentIcons.clear || uiButtonIcons.clear || '/assets/icons/clear.svg') : (uiButtonIcons.clear || '/assets/icons/clear.svg')}}"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</button>
|
||||
</view>
|
||||
<view class="toolbar-spacer"></view>
|
||||
<view class="terminal-toolbar-actions">
|
||||
<button
|
||||
class="icon-btn terminal-toolbar-tts-btn svg-press-btn {{ttsEnabled ? 'is-enabled' : 'is-disabled'}} {{ttsState === 'playing' ? 'is-playing' : ''}} {{ttsState === 'preparing' ? 'is-preparing' : ''}}"
|
||||
hover-class="svg-press-btn-hover"
|
||||
hover-start-time="0"
|
||||
hover-stay-time="80"
|
||||
data-press-key="terminal:tts"
|
||||
bindtouchstart="onSvgButtonTouchStart"
|
||||
bindtouchend="onSvgButtonTouchEnd"
|
||||
bindtouchcancel="onSvgButtonTouchEnd"
|
||||
bindtap="onToggleTts"
|
||||
>
|
||||
<image
|
||||
class="icon-img terminal-toolbar-tts-icon svg-press-icon"
|
||||
src="{{pressedSvgButtonKey === 'terminal:tts' ? (ttsEnabled ? (terminalToolActiveIcons.reading || terminalToolIcons.reading || '/assets/icons/reading.svg') : (terminalToolActiveIcons.stopreading || terminalToolIcons.stopreading || '/assets/icons/stopreading.svg')) : (ttsEnabled ? (terminalToolActiveIcons.reading || terminalToolIcons.reading || '/assets/icons/reading.svg') : (terminalToolIcons.stopreading || '/assets/icons/stopreading.svg'))}}"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</button>
|
||||
<view class="state-chip state-{{statusClass}}" bindtap="onOpenSessionInfo">
|
||||
<text>{{statusLabel}}</text>
|
||||
</view>
|
||||
<text class="state-chip state-chip-action" bindtap="onOpenConnectionDiagnostics">{{latencyMs}}ms</text>
|
||||
<view class="terminal-toolbar-divider"></view>
|
||||
<button
|
||||
class="terminal-connection-switch {{connectionActionReconnect ? 'is-reconnect' : 'is-disconnect'}} {{statusClass === 'connected' ? 'is-connected' : ''}}"
|
||||
disabled="{{connectionActionDisabled}}"
|
||||
bindtap="onConnectionAction"
|
||||
>
|
||||
<text class="terminal-connection-switch-label">{{connectionActionText}}</text>
|
||||
<view class="terminal-connection-switch-knob"></view>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="page-content terminal-content">
|
||||
<view class="terminal-surface">
|
||||
<view class="surface-panel terminal-panel" bindtap="onPanelTap">
|
||||
<scroll-view
|
||||
class="terminal-output"
|
||||
scroll-y="true"
|
||||
scroll-top="{{outputScrollTop}}"
|
||||
bindscroll="onOutputScroll"
|
||||
catchlongpress="onOutputLongPress"
|
||||
catchtap="onOutputTap"
|
||||
>
|
||||
<view
|
||||
wx:if="{{outputTopSpacerPx > 0}}"
|
||||
class="output-viewport-spacer"
|
||||
style="height: {{outputTopSpacerPx}}px;"
|
||||
></view>
|
||||
<view
|
||||
wx:for="{{outputRenderLines}}"
|
||||
wx:key="index"
|
||||
class="output-line"
|
||||
style="min-height: {{outputLineHeightPx}}px; line-height: {{outputLineHeightPx}}px; {{item.lineStyle}}"
|
||||
data-line-index="{{item.bufferRow !== undefined ? item.bufferRow : index}}"
|
||||
catchlongpress="onOutputLongPress"
|
||||
catchtap="onOutputLineTap"
|
||||
>
|
||||
<text
|
||||
wx:for="{{item.segments}}"
|
||||
wx:key="index"
|
||||
class="output-segment {{item.fixed ? 'output-segment-fixed' : ''}}"
|
||||
style="{{item.style}}"
|
||||
user-select="true"
|
||||
>{{item.text}}</text
|
||||
>
|
||||
</view>
|
||||
<view
|
||||
wx:if="{{outputBottomSpacerPx > 0}}"
|
||||
class="output-viewport-spacer"
|
||||
style="height: {{outputBottomSpacerPx}}px;"
|
||||
></view>
|
||||
<view
|
||||
wx:if="{{outputKeyboardInsetPx > 0}}"
|
||||
class="output-keyboard-spacer"
|
||||
style="height: {{outputKeyboardInsetPx}}px;"
|
||||
></view>
|
||||
</scroll-view>
|
||||
|
||||
<text wx:if="{{disconnectedHintVisible}}" class="terminal-disconnected-hint"
|
||||
>{{disconnectedHintText}}</text
|
||||
>
|
||||
|
||||
<view class="shell-metrics-probe">
|
||||
<view class="shell-metrics-probe-line shell-metrics-probe-line-ascii"
|
||||
>{{shellMetricsAsciiProbeText}}</view
|
||||
>
|
||||
<view class="shell-metrics-probe-line shell-metrics-probe-line-wide"
|
||||
>{{shellMetricsWideProbeText}}</view
|
||||
>
|
||||
</view>
|
||||
|
||||
<view
|
||||
wx:if="{{activationDebugVisible}}"
|
||||
class="terminal-activation-debug"
|
||||
style="top: {{activationDebugTopPx}}px; height: {{activationDebugHeightPx}}px;"
|
||||
></view>
|
||||
|
||||
<view
|
||||
wx:if="{{terminalCaretVisible}}"
|
||||
class="terminal-caret"
|
||||
style="left: {{terminalCaretLeftPx}}px; top: {{terminalCaretTopPx}}px; height: {{terminalCaretHeightPx}}px;"
|
||||
></view>
|
||||
|
||||
<input
|
||||
class="terminal-shell-input-proxy"
|
||||
value="{{shellInputValue}}"
|
||||
focus="{{shellInputFocus}}"
|
||||
cursor="{{shellInputCursor}}"
|
||||
type="text"
|
||||
disabled="{{statusClass !== 'connected'}}"
|
||||
maxlength="4096"
|
||||
adjust-position="false"
|
||||
confirm-type="send"
|
||||
bindinput="onShellInputChange"
|
||||
bindfocus="onShellInputFocus"
|
||||
bindconfirm="onShellInputConfirm"
|
||||
bindkeyboardheightchange="onShellInputKeyboardHeightChange"
|
||||
bindblur="onShellInputBlur"
|
||||
/>
|
||||
|
||||
<view
|
||||
wx:if="{{touchToolsExpanded}}"
|
||||
class="terminal-touch-filter"
|
||||
catchtap="noop"
|
||||
catchtouchstart="noop"
|
||||
catchtouchmove="noop"
|
||||
catchtouchend="noop"
|
||||
catchtouchcancel="noop"
|
||||
></view>
|
||||
|
||||
<view class="terminal-touch-tools {{touchToolsExpanded ? 'is-expanded' : ''}}" catchtap="noop">
|
||||
<view wx:if="{{touchToolsExpanded}}" class="terminal-touch-tools-body">
|
||||
<view class="terminal-touch-direction-pad">
|
||||
<button
|
||||
wx:for="{{terminalTouchDirectionKeys}}"
|
||||
wx:key="key"
|
||||
class="terminal-touch-direction-btn svg-press-btn {{item.slotClass}}"
|
||||
hover-class="svg-press-btn-hover"
|
||||
hover-start-time="0"
|
||||
hover-stay-time="80"
|
||||
data-key="{{item.key}}"
|
||||
data-press-key="{{item.pressKey}}"
|
||||
bindtouchstart="onSvgButtonTouchStart"
|
||||
bindtouchend="onSvgButtonTouchEnd"
|
||||
bindtouchcancel="onSvgButtonTouchEnd"
|
||||
bindtap="onTouchKeyTap"
|
||||
>
|
||||
<image
|
||||
class="terminal-touch-direction-icon svg-press-icon"
|
||||
src="{{pressedSvgButtonKey === item.pressKey ? item.pressedIcon : item.icon}}"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</button>
|
||||
</view>
|
||||
<view class="terminal-touch-action-stack">
|
||||
<block wx:for="{{terminalTouchActionButtons}}" wx:key="key">
|
||||
<button
|
||||
wx:if="{{item.action === 'paste'}}"
|
||||
class="terminal-touch-action-btn svg-press-btn {{item.slotClass}}"
|
||||
hover-class="svg-press-btn-hover"
|
||||
hover-start-time="0"
|
||||
hover-stay-time="80"
|
||||
data-press-key="{{item.pressKey}}"
|
||||
bindtouchstart="onSvgButtonTouchStart"
|
||||
bindtouchend="onSvgButtonTouchEnd"
|
||||
bindtouchcancel="onSvgButtonTouchEnd"
|
||||
bindtap="onPasteFromClipboard"
|
||||
>
|
||||
<image
|
||||
class="terminal-touch-action-icon svg-press-icon"
|
||||
src="{{pressedSvgButtonKey === item.pressKey ? item.pressedIcon : item.icon}}"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
wx:else
|
||||
class="terminal-touch-action-btn svg-press-btn {{item.slotClass}} {{touchShiftMode !== 'off' && item.key === 'shift' ? 'is-active' : ''}} {{touchShiftMode === 'lock' && item.key === 'shift' ? 'is-locked' : ''}}"
|
||||
hover-class="svg-press-btn-hover"
|
||||
hover-start-time="0"
|
||||
hover-stay-time="80"
|
||||
data-key="{{item.key}}"
|
||||
data-press-key="{{item.pressKey}}"
|
||||
bindtouchstart="onSvgButtonTouchStart"
|
||||
bindtouchend="onSvgButtonTouchEnd"
|
||||
bindtouchcancel="onSvgButtonTouchEnd"
|
||||
bindtap="onTouchKeyTap"
|
||||
>
|
||||
<image
|
||||
class="terminal-touch-action-icon svg-press-icon"
|
||||
src="{{pressedSvgButtonKey === item.pressKey ? item.pressedIcon : item.icon}}"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</button>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
<button
|
||||
class="terminal-touch-toggle-btn svg-press-btn"
|
||||
hover-class="svg-press-btn-hover"
|
||||
hover-start-time="0"
|
||||
hover-stay-time="80"
|
||||
data-press-key="terminal:toggle-touch-tools"
|
||||
bindtouchstart="onSvgButtonTouchStart"
|
||||
bindtouchend="onSvgButtonTouchEnd"
|
||||
bindtouchcancel="onSvgButtonTouchEnd"
|
||||
bindtap="onToggleTouchTools"
|
||||
>
|
||||
<image
|
||||
class="terminal-touch-toggle-icon svg-press-icon"
|
||||
src="{{pressedSvgButtonKey === 'terminal:toggle-touch-tools' ? (terminalTouchTogglePressedIcon || terminalTouchToggleIcon || '/assets/icons/keyboard.svg') : (terminalTouchToggleIcon || '/assets/icons/keyboard.svg')}}"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view
|
||||
wx:if="{{showVoiceInputButton}}"
|
||||
class="voice-float-layer"
|
||||
style="left: {{voiceFloatLeft}}px; bottom: {{voiceFloatBottom}}px; width: {{voicePanelVisible ? voicePanelWidthPx : voiceButtonSizePx}}px;"
|
||||
catchtap="noop"
|
||||
bindtouchstart="onVoiceLayerTouchStart"
|
||||
bindtouchmove="onVoiceLayerTouchMove"
|
||||
bindtouchend="onVoiceLayerTouchEnd"
|
||||
bindtouchcancel="onVoiceLayerTouchEnd"
|
||||
>
|
||||
<button
|
||||
wx:if="{{!voicePanelVisible}}"
|
||||
class="voice-floating-btn voice-plain-btn svg-press-btn {{voiceHolding ? 'is-holding' : ''}}"
|
||||
hover-class="svg-press-btn-hover"
|
||||
hover-start-time="0"
|
||||
hover-stay-time="80"
|
||||
data-drag-handle="1"
|
||||
data-press-key="terminal:voice-floating"
|
||||
bindtouchstart="onVoicePressStart"
|
||||
catchtouchmove="onVoiceLayerTouchMove"
|
||||
bindtouchend="onVoicePressEnd"
|
||||
bindtouchcancel="onVoicePressEnd"
|
||||
>
|
||||
<image
|
||||
class="voice-action-icon svg-press-icon"
|
||||
src="{{pressedSvgButtonKey === 'terminal:voice-floating' ? (terminalToolActiveIcons.voice || terminalToolIcons.voice || '/assets/icons/voice.svg') : (terminalToolIcons.voice || '/assets/icons/voice.svg')}}"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<view wx:if="{{voicePanelVisible}}" class="voice-wrap" catchtap="noop">
|
||||
<view class="frame2256 {{voiceHolding ? 'is-recording' : ''}}" style="opacity: {{frameOpacity}};">
|
||||
<view class="terminal-input-wrap" data-drag-handle="1" catchtouchmove="onVoiceLayerTouchMove">
|
||||
<view wx:if="{{voiceHolding}}" class="voice-recording-hint">
|
||||
<view class="voice-recording-pulse">
|
||||
<view class="voice-recording-pulse-core" />
|
||||
<view class="voice-recording-pulse-ring" />
|
||||
<view class="voice-recording-pulse-ring voice-recording-pulse-ring-delay" />
|
||||
</view>
|
||||
<text class="voice-recording-hint-text">{{copy.voice.recordingHint}}</text>
|
||||
</view>
|
||||
<textarea
|
||||
class="terminal-voice-input"
|
||||
auto-height
|
||||
value="{{inputText}}"
|
||||
placeholder="{{copy.voice.inputPlaceholder}}"
|
||||
confirm-type="send"
|
||||
bindconfirm="onInputConfirm"
|
||||
bindinput="onInputText"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="voice-actions-row">
|
||||
<view
|
||||
class="voice-actions-left-track"
|
||||
bindtouchstart="onVoiceActionsTouchStart"
|
||||
catchtouchmove="onVoiceActionsTouchMove"
|
||||
bindtouchend="onVoiceActionsTouchEnd"
|
||||
bindtouchcancel="onVoiceActionsTouchEnd"
|
||||
>
|
||||
<view class="voice-actions-left" style="transform: translateX({{voiceActionsOffsetX}}px);">
|
||||
<button
|
||||
class="voice-action-btn voice-main-action-btn voice-plain-btn svg-press-btn {{voiceHolding ? 'is-holding' : ''}}"
|
||||
hover-class="svg-press-btn-hover"
|
||||
hover-start-time="0"
|
||||
hover-stay-time="80"
|
||||
data-drag-handle="1"
|
||||
data-press-key="terminal:voice-main"
|
||||
bindtouchstart="onVoicePressStart"
|
||||
catchtouchmove="onVoiceLayerTouchMove"
|
||||
bindtouchend="onVoicePressEnd"
|
||||
bindtouchcancel="onVoicePressEnd"
|
||||
>
|
||||
<image
|
||||
class="voice-action-icon voice-main-action-icon svg-press-icon"
|
||||
src="{{pressedSvgButtonKey === 'terminal:voice-main' ? (terminalToolActiveIcons.voice || terminalToolIcons.voice || '/assets/icons/voice.svg') : (terminalToolIcons.voice || '/assets/icons/voice.svg')}}"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="voice-action-btn voice-secondary-action-btn voice-plain-btn svg-press-btn"
|
||||
hover-class="svg-press-btn-hover"
|
||||
hover-start-time="0"
|
||||
hover-stay-time="80"
|
||||
data-press-key="terminal:voice-record"
|
||||
bindtouchstart="onSvgButtonTouchStart"
|
||||
bindtouchend="onSvgButtonTouchEnd"
|
||||
bindtouchcancel="onSvgButtonTouchEnd"
|
||||
bindtap="onRecordDraft"
|
||||
>
|
||||
<image
|
||||
class="voice-action-icon voice-secondary-action-icon svg-press-icon"
|
||||
src="{{pressedSvgButtonKey === 'terminal:voice-record' ? (terminalToolActiveIcons.record || terminalToolIcons.record || '/assets/icons/record.svg') : (terminalToolIcons.record || '/assets/icons/record.svg')}}"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="voice-action-btn voice-secondary-action-btn voice-plain-btn svg-press-btn"
|
||||
hover-class="svg-press-btn-hover"
|
||||
hover-start-time="0"
|
||||
hover-stay-time="80"
|
||||
data-press-key="terminal:voice-send"
|
||||
bindtouchstart="onSvgButtonTouchStart"
|
||||
bindtouchend="onSvgButtonTouchEnd"
|
||||
bindtouchcancel="onSvgButtonTouchEnd"
|
||||
bindtap="onSendDraft"
|
||||
>
|
||||
<image
|
||||
class="voice-action-icon voice-secondary-action-icon svg-press-icon"
|
||||
src="{{pressedSvgButtonKey === 'terminal:voice-send' ? (terminalToolActiveIcons.sent || terminalToolIcons.sent || '/assets/icons/sent.svg') : (terminalToolIcons.sent || '/assets/icons/sent.svg')}}"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
<view class="voice-actions-right">
|
||||
<button
|
||||
class="voice-action-btn voice-plain-btn svg-press-btn"
|
||||
hover-class="svg-press-btn-hover"
|
||||
hover-start-time="0"
|
||||
hover-stay-time="80"
|
||||
data-press-key="terminal:voice-clear-input"
|
||||
bindtouchstart="onSvgButtonTouchStart"
|
||||
bindtouchend="onSvgButtonTouchEnd"
|
||||
bindtouchcancel="onSvgButtonTouchEnd"
|
||||
bindtap="onClearDraft"
|
||||
>
|
||||
<image
|
||||
class="voice-action-icon svg-press-icon"
|
||||
src="{{pressedSvgButtonKey === 'terminal:voice-clear-input' ? (terminalToolActiveIcons.clearInput || terminalToolIcons.clearInput || '/assets/icons/clear-input.svg') : (terminalToolIcons.clearInput || '/assets/icons/clear-input.svg')}}"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="voice-action-btn voice-plain-btn svg-press-btn"
|
||||
hover-class="svg-press-btn-hover"
|
||||
hover-start-time="0"
|
||||
hover-stay-time="80"
|
||||
data-press-key="terminal:voice-cancel"
|
||||
bindtouchstart="onSvgButtonTouchStart"
|
||||
bindtouchend="onSvgButtonTouchEnd"
|
||||
bindtouchcancel="onSvgButtonTouchEnd"
|
||||
bindtap="onCancelDraft"
|
||||
>
|
||||
<image
|
||||
class="voice-action-icon svg-press-icon"
|
||||
src="{{pressedSvgButtonKey === 'terminal:voice-cancel' ? (terminalToolActiveIcons.cancel || terminalToolIcons.cancel || '/assets/icons/cancel.svg') : (terminalToolIcons.cancel || '/assets/icons/cancel.svg')}}"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view class="voice-category-scroll" scroll-x="true" show-scrollbar="false">
|
||||
<view class="voice-category-row">
|
||||
<view
|
||||
wx:for="{{voiceRecordCategories}}"
|
||||
wx:key="*this"
|
||||
class="voice-category-pill {{selectedRecordCategory === item ? 'active' : ''}}"
|
||||
data-category="{{item}}"
|
||||
bindtap="onSelectRecordCategory"
|
||||
>{{item}}</view
|
||||
>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{sessionInfoVisible}}" class="session-info-mask" bindtap="onCloseSessionInfo">
|
||||
<view class="session-info-panel" catchtap="noop">
|
||||
<view class="connection-diagnostics-card session-info-card" style="{{sessionInfoTheme.cardStyle}}">
|
||||
<text class="session-info-title" style="{{sessionInfoTheme.titleStyle}}">{{sessionInfoTitle}}</text>
|
||||
<view class="session-info-hero" style="{{sessionInfoTheme.heroStyle}}">
|
||||
<view class="session-info-hero-orb is-left"></view>
|
||||
<view class="session-info-hero-orb is-right"></view>
|
||||
<text class="session-info-hero-eyebrow">{{sessionInfoHero.eyebrow}}</text>
|
||||
<text class="session-info-hero-name">{{sessionInfoHero.name}}</text>
|
||||
<text class="session-info-hero-subtitle" user-select="true">{{sessionInfoHero.subtitle}}</text>
|
||||
<text wx:if="{{sessionInfoHero.route}}" class="session-info-hero-route" user-select="true"
|
||||
>{{sessionInfoHero.routeLabel}} · {{sessionInfoHero.route}}</text
|
||||
>
|
||||
</view>
|
||||
|
||||
<view class="session-info-status-grid">
|
||||
<view
|
||||
wx:for="{{sessionInfoStatusChips}}"
|
||||
wx:key="key"
|
||||
class="session-info-status-pill {{item.connected ? 'is-connected' : 'is-disconnected'}} {{((item.key === 'sshConnection' && !connectionActionDisabled) || (item.key === 'aiConnection' && !aiLaunchBusy)) ? 'is-actionable' : ''}}"
|
||||
data-key="{{item.key}}"
|
||||
bindtap="onSessionInfoStatusTap"
|
||||
>
|
||||
<view class="session-info-status-top">
|
||||
<text class="session-info-status-label">{{item.label}}</text>
|
||||
<text class="session-info-status-badge">{{item.badge}}</text>
|
||||
</view>
|
||||
<text class="session-info-status-value">{{item.value}}</text>
|
||||
<text class="session-info-status-note">{{item.note}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="session-info-detail-grid">
|
||||
<view
|
||||
wx:for="{{sessionInfoDetailItems}}"
|
||||
wx:key="key"
|
||||
class="session-info-detail-card {{item.wide ? 'is-wide' : ''}}"
|
||||
>
|
||||
<text class="session-info-detail-accent">{{item.accent}}</text>
|
||||
<text class="session-info-detail-label">{{item.label}}</text>
|
||||
<text class="session-info-detail-value" user-select="true">{{item.value}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
wx:if="{{connectionDiagnosticsVisible}}"
|
||||
class="connection-diagnostics-mask"
|
||||
bindtap="onCloseConnectionDiagnostics"
|
||||
>
|
||||
<view class="connection-diagnostics-panel" catchtap="noop">
|
||||
<view
|
||||
class="connection-diagnostics-card connection-diagnostics-chart-card is-combined"
|
||||
style="{{connectionDiagnosticCombinedChart.cardStyle}}"
|
||||
>
|
||||
<view class="connection-diagnostics-chart-head">
|
||||
<view
|
||||
class="connection-diagnostics-chart-metric is-response"
|
||||
style="{{connectionDiagnosticCombinedChart.responseMetricStyle}}"
|
||||
>
|
||||
<text class="connection-diagnostics-chart-axis-pill is-response"
|
||||
>{{connectionDiagnosticCombinedChart.responseCardLabel}}</text
|
||||
>
|
||||
<view class="connection-diagnostics-chart-stats-row">
|
||||
<view
|
||||
wx:for="{{connectionDiagnosticCombinedChart.responseStatItems}}"
|
||||
wx:key="key"
|
||||
class="connection-diagnostics-chart-stat-item"
|
||||
>
|
||||
<text class="connection-diagnostics-chart-stat-label">{{item.label}}</text>
|
||||
<text class="connection-diagnostics-chart-stat-value is-response">{{item.valueLabel}}</text>
|
||||
<text wx:if="{{item.divider}}" class="connection-diagnostics-chart-stat-divider">·</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
class="connection-diagnostics-chart-metric is-network"
|
||||
style="{{connectionDiagnosticCombinedChart.networkMetricStyle}}"
|
||||
>
|
||||
<text class="connection-diagnostics-chart-axis-pill is-network"
|
||||
>{{connectionDiagnosticCombinedChart.networkCardLabel}}</text
|
||||
>
|
||||
<view class="connection-diagnostics-chart-stats-row">
|
||||
<view
|
||||
wx:for="{{connectionDiagnosticCombinedChart.networkStatItems}}"
|
||||
wx:key="key"
|
||||
class="connection-diagnostics-chart-stat-item"
|
||||
>
|
||||
<text class="connection-diagnostics-chart-stat-label">{{item.label}}</text>
|
||||
<text class="connection-diagnostics-chart-stat-value is-network">{{item.valueLabel}}</text>
|
||||
<text wx:if="{{item.divider}}" class="connection-diagnostics-chart-stat-divider">·</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
class="connection-diagnostics-chart-image-shell"
|
||||
style="{{connectionDiagnosticCombinedChart.chartImageShellStyle}}"
|
||||
>
|
||||
<image
|
||||
class="connection-diagnostics-chart-image"
|
||||
src="{{connectionDiagnosticCombinedChart.imageUri}}"
|
||||
mode="widthFix"
|
||||
fade-show="{{false}}"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<bottom-nav page="terminal" />
|
||||
</view>
|
||||
1367
apps/miniprogram/pages/terminal/index.wxss
Normal file
1367
apps/miniprogram/pages/terminal/index.wxss
Normal file
File diff suppressed because it is too large
Load Diff
190
apps/miniprogram/pages/terminal/terminalAiForegroundLock.test.ts
Normal file
190
apps/miniprogram/pages/terminal/terminalAiForegroundLock.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type TerminalPageOptions = {
|
||||
data?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type TerminalPageInstance = TerminalPageOptions & {
|
||||
data: Record<string, unknown>;
|
||||
setData: (patch: Record<string, unknown>, callback?: () => void) => void;
|
||||
};
|
||||
|
||||
type MiniprogramGlobals = typeof globalThis & {
|
||||
Page?: (options: TerminalPageOptions) => void;
|
||||
wx?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function createTerminalPageHarness(initialStorage: Record<string, unknown>) {
|
||||
const globalState = globalThis as MiniprogramGlobals;
|
||||
let capturedPageOptions: TerminalPageOptions | null = null;
|
||||
const storage = new Map<string, unknown>(Object.entries(initialStorage));
|
||||
const noop = () => {};
|
||||
|
||||
vi.resetModules();
|
||||
delete require.cache[require.resolve("./index.js")];
|
||||
globalState.Page = vi.fn((options: TerminalPageOptions) => {
|
||||
capturedPageOptions = options;
|
||||
});
|
||||
globalState.wx = {
|
||||
env: {
|
||||
USER_DATA_PATH: "/tmp"
|
||||
},
|
||||
getStorageSync: vi.fn((key: string) => storage.get(key)),
|
||||
setStorageSync: vi.fn((key: string, value: unknown) => {
|
||||
storage.set(key, value);
|
||||
}),
|
||||
removeStorageSync: vi.fn((key: string) => {
|
||||
storage.delete(key);
|
||||
}),
|
||||
getRecorderManager: vi.fn(() => ({
|
||||
onStart: noop,
|
||||
onStop: noop,
|
||||
onError: noop,
|
||||
onFrameRecorded: noop,
|
||||
start: noop,
|
||||
stop: noop
|
||||
})),
|
||||
createInnerAudioContext: vi.fn(() => ({
|
||||
onCanplay: noop,
|
||||
onPlay: noop,
|
||||
onEnded: noop,
|
||||
onStop: noop,
|
||||
onError: noop,
|
||||
stop: noop,
|
||||
destroy: noop
|
||||
})),
|
||||
setInnerAudioOption: vi.fn(),
|
||||
createSelectorQuery: vi.fn(() => ({
|
||||
in: vi.fn(() => ({
|
||||
select: vi.fn(() => ({
|
||||
boundingClientRect: vi.fn(() => ({
|
||||
exec: noop
|
||||
}))
|
||||
}))
|
||||
}))
|
||||
})),
|
||||
nextTick: vi.fn((callback?: () => void) => {
|
||||
callback?.();
|
||||
}),
|
||||
getSystemInfoSync: vi.fn(() => ({})),
|
||||
canIUse: vi.fn(() => false),
|
||||
showToast: vi.fn()
|
||||
};
|
||||
|
||||
require("./index.js");
|
||||
|
||||
if (!capturedPageOptions) {
|
||||
throw new Error("terminal page not captured");
|
||||
}
|
||||
|
||||
const captured = capturedPageOptions as TerminalPageOptions;
|
||||
const page = {
|
||||
...captured,
|
||||
data: JSON.parse(JSON.stringify(captured.data || {})) as Record<string, unknown>,
|
||||
setData(patch: Record<string, unknown>, callback?: () => void) {
|
||||
Object.assign(this.data, patch);
|
||||
callback?.();
|
||||
}
|
||||
} as TerminalPageInstance;
|
||||
|
||||
return { page, storage };
|
||||
}
|
||||
|
||||
describe("terminal ai foreground lock", () => {
|
||||
const globalState = globalThis as MiniprogramGlobals;
|
||||
const originalPage = globalState.Page;
|
||||
const originalWx = globalState.wx;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
if (originalPage) {
|
||||
globalState.Page = originalPage;
|
||||
} else {
|
||||
delete globalState.Page;
|
||||
}
|
||||
if (originalWx) {
|
||||
globalState.wx = originalWx;
|
||||
} else {
|
||||
delete globalState.wx;
|
||||
}
|
||||
});
|
||||
|
||||
it("当前前台是 Codex 时,成功发出 Ctrl+C 会释放本地 AI 锁并同步快照", () => {
|
||||
const { page, storage } = createTerminalPageHarness({});
|
||||
const sendStdin = vi.fn();
|
||||
|
||||
page.client = { sendStdin };
|
||||
page.sessionKey = "mini-session-key";
|
||||
page.activeAiProvider = "codex";
|
||||
page.activeCodexSandboxMode = "danger-full-access";
|
||||
page.aiRuntimeExitCarry = "partial-marker";
|
||||
page.data.serverId = "srv-1";
|
||||
page.data.serverLabel = "server-1";
|
||||
page.data.sessionId = "mini-session";
|
||||
page.data.statusText = "connected";
|
||||
|
||||
const sent = page.sendControlSequence("\u0003");
|
||||
const snapshot = storage.get("remoteconn.terminal.session.v1") as Record<string, unknown>;
|
||||
|
||||
expect(sent).toBe(true);
|
||||
expect(sendStdin).toHaveBeenCalledWith("\u0003");
|
||||
expect(page.activeAiProvider).toBe("");
|
||||
expect(page.activeCodexSandboxMode).toBe("");
|
||||
expect(page.aiRuntimeExitCarry).toBe("");
|
||||
expect(snapshot.activeAiProvider).toBe("");
|
||||
expect(snapshot.codexSandboxMode).toBe("");
|
||||
});
|
||||
|
||||
it("Codex 前台态时,清屏操作应直接忽略", () => {
|
||||
const { page } = createTerminalPageHarness({});
|
||||
|
||||
page.activeAiProvider = "codex";
|
||||
page.data.statusText = "connected";
|
||||
page.captureTerminalBufferState = vi.fn();
|
||||
|
||||
const result = page.onClearScreen();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(page.captureTerminalBufferState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("home 快捷键会发送切回服务器工作目录的 cd 命令", () => {
|
||||
const { page } = createTerminalPageHarness({});
|
||||
const sendStdin = vi.fn();
|
||||
|
||||
page.client = { sendStdin };
|
||||
page.server = { projectPath: "~/workspace/remoteconn" };
|
||||
|
||||
page.onTouchKeyTap({
|
||||
currentTarget: {
|
||||
dataset: {
|
||||
key: "home"
|
||||
}
|
||||
}
|
||||
} as unknown as Parameters<typeof page.onTouchKeyTap>[0]);
|
||||
|
||||
expect(sendStdin).toHaveBeenCalledWith("cd \"$HOME\"/'workspace/remoteconn'\r");
|
||||
});
|
||||
|
||||
it("AI 前台态时,home 快捷键应静默忽略", () => {
|
||||
const { page } = createTerminalPageHarness({});
|
||||
const sendStdin = vi.fn();
|
||||
|
||||
page.client = { sendStdin };
|
||||
page.server = { projectPath: "~/workspace/remoteconn" };
|
||||
page.activeAiProvider = "copilot";
|
||||
|
||||
page.onTouchKeyTap({
|
||||
currentTarget: {
|
||||
dataset: {
|
||||
key: "home"
|
||||
}
|
||||
}
|
||||
} as unknown as Parameters<typeof page.onTouchKeyTap>[0]);
|
||||
|
||||
expect(sendStdin).not.toHaveBeenCalled();
|
||||
expect(globalState.wx?.showToast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
237
apps/miniprogram/pages/terminal/terminalAutoReconnect.test.ts
Normal file
237
apps/miniprogram/pages/terminal/terminalAutoReconnect.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type TerminalPageOptions = {
|
||||
data?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type TerminalPageInstance = TerminalPageOptions & {
|
||||
data: Record<string, unknown>;
|
||||
setData: (patch: Record<string, unknown>, callback?: () => void) => void;
|
||||
handleDisconnect: (reason: string) => void;
|
||||
connectGateway: ReturnType<typeof vi.fn>;
|
||||
logTerminalPerf: ReturnType<typeof vi.fn>;
|
||||
stopConnectionDiagnosticNetworkProbe: ReturnType<typeof vi.fn>;
|
||||
persistConnectionDiagnosticSamples: ReturnType<typeof vi.fn>;
|
||||
clearTerminalStdoutCarry: ReturnType<typeof vi.fn>;
|
||||
clearCodexBootstrapGuard: ReturnType<typeof vi.fn>;
|
||||
persistTerminalBufferSnapshot: ReturnType<typeof vi.fn>;
|
||||
stopVoiceRound: ReturnType<typeof vi.fn>;
|
||||
teardownAsrClient: ReturnType<typeof vi.fn>;
|
||||
syncActiveAiProvider: ReturnType<typeof vi.fn>;
|
||||
setStatus: ReturnType<typeof vi.fn>;
|
||||
autoReconnectTimer: ReturnType<typeof setTimeout> | null;
|
||||
autoReconnectAttempts: number;
|
||||
autoReconnectSuppressed: boolean;
|
||||
sessionSuspended: boolean;
|
||||
activeAiProvider: string;
|
||||
activeCodexSandboxMode: string;
|
||||
resumeGraceMs: number;
|
||||
sessionKey: string;
|
||||
server: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type MiniprogramGlobals = typeof globalThis & {
|
||||
Page?: (options: TerminalPageOptions) => void;
|
||||
wx?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function createWxStorage(initial: Record<string, unknown>) {
|
||||
const store = new Map<string, unknown>(Object.entries(initial));
|
||||
return {
|
||||
getStorageSync(key: string) {
|
||||
return store.get(key);
|
||||
},
|
||||
setStorageSync(key: string, value: unknown) {
|
||||
store.set(key, value);
|
||||
},
|
||||
removeStorageSync(key: string) {
|
||||
store.delete(key);
|
||||
},
|
||||
getStorageInfoSync() {
|
||||
return {
|
||||
keys: Array.from(store.keys())
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createTerminalPageHarness(initialStorage: Record<string, unknown> = {}) {
|
||||
const globalState = globalThis as MiniprogramGlobals;
|
||||
let capturedPageOptions: TerminalPageOptions | null = null;
|
||||
const noop = () => {};
|
||||
const wxStorage = createWxStorage(initialStorage);
|
||||
|
||||
vi.resetModules();
|
||||
delete require.cache[require.resolve("./index.js")];
|
||||
globalState.Page = vi.fn((options: TerminalPageOptions) => {
|
||||
capturedPageOptions = options;
|
||||
});
|
||||
globalState.wx = {
|
||||
...wxStorage,
|
||||
env: {
|
||||
USER_DATA_PATH: "/tmp"
|
||||
},
|
||||
getRecorderManager: vi.fn(() => ({
|
||||
onStart: noop,
|
||||
onStop: noop,
|
||||
onError: noop,
|
||||
onFrameRecorded: noop,
|
||||
start: noop,
|
||||
stop: noop
|
||||
})),
|
||||
createInnerAudioContext: vi.fn(() => ({
|
||||
onCanplay: noop,
|
||||
onPlay: noop,
|
||||
onEnded: noop,
|
||||
onStop: noop,
|
||||
onError: noop,
|
||||
stop: noop,
|
||||
destroy: noop
|
||||
})),
|
||||
setInnerAudioOption: vi.fn(),
|
||||
createSelectorQuery: vi.fn(() => ({
|
||||
in: vi.fn(() => ({
|
||||
select: vi.fn(() => ({
|
||||
boundingClientRect: vi.fn(() => ({
|
||||
exec: noop
|
||||
}))
|
||||
}))
|
||||
}))
|
||||
})),
|
||||
nextTick: vi.fn((callback?: () => void) => {
|
||||
callback?.();
|
||||
}),
|
||||
getSystemInfoSync: vi.fn(() => ({})),
|
||||
canIUse: vi.fn(() => false),
|
||||
showToast: vi.fn()
|
||||
};
|
||||
|
||||
require("./index.js");
|
||||
|
||||
if (!capturedPageOptions) {
|
||||
throw new Error("terminal page not captured");
|
||||
}
|
||||
|
||||
const captured = capturedPageOptions as TerminalPageOptions;
|
||||
const page = {
|
||||
...captured,
|
||||
data: JSON.parse(JSON.stringify(captured.data || {})) as Record<string, unknown>,
|
||||
setData(patch: Record<string, unknown>, callback?: () => void) {
|
||||
Object.assign(this.data, patch);
|
||||
callback?.();
|
||||
}
|
||||
} as TerminalPageInstance;
|
||||
|
||||
page.logTerminalPerf = vi.fn();
|
||||
page.stopConnectionDiagnosticNetworkProbe = vi.fn();
|
||||
page.persistConnectionDiagnosticSamples = vi.fn();
|
||||
page.clearTerminalStdoutCarry = vi.fn();
|
||||
page.clearCodexBootstrapGuard = vi.fn();
|
||||
page.persistTerminalBufferSnapshot = vi.fn();
|
||||
page.stopVoiceRound = vi.fn();
|
||||
page.teardownAsrClient = vi.fn();
|
||||
page.syncActiveAiProvider = vi.fn();
|
||||
page.setStatus = vi.fn(function (this: TerminalPageInstance, status: string) {
|
||||
this.data.statusText = status;
|
||||
});
|
||||
page.connectGateway = vi.fn().mockResolvedValue(undefined);
|
||||
page.autoReconnectTimer = null;
|
||||
page.autoReconnectAttempts = 0;
|
||||
page.autoReconnectSuppressed = false;
|
||||
page.sessionSuspended = false;
|
||||
page.activeAiProvider = "";
|
||||
page.activeCodexSandboxMode = "";
|
||||
page.resumeGraceMs = 5 * 60 * 1000;
|
||||
page.sessionKey = "session-key";
|
||||
page.server = {
|
||||
id: "srv-1",
|
||||
name: "srv-1",
|
||||
host: "127.0.0.1",
|
||||
port: 22,
|
||||
username: "root"
|
||||
};
|
||||
page.data.serverId = "srv-1";
|
||||
page.data.serverLabel = "srv-1";
|
||||
page.data.sessionId = "session-1";
|
||||
|
||||
return {
|
||||
page,
|
||||
wxRuntime: globalState.wx as Record<string, ReturnType<typeof vi.fn>>
|
||||
};
|
||||
}
|
||||
|
||||
describe("terminal auto reconnect", () => {
|
||||
const globalState = globalThis as MiniprogramGlobals;
|
||||
const originalPage = globalState.Page;
|
||||
const originalWx = globalState.wx;
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
if (originalPage) {
|
||||
globalState.Page = originalPage;
|
||||
} else {
|
||||
delete globalState.Page;
|
||||
}
|
||||
if (originalWx) {
|
||||
globalState.wx = originalWx;
|
||||
} else {
|
||||
delete globalState.wx;
|
||||
}
|
||||
});
|
||||
|
||||
it("ws_closed 且开启自动重连时,会按次数上限调度重连", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { page } = createTerminalPageHarness({
|
||||
"remoteconn.settings.v2": {
|
||||
autoReconnect: true,
|
||||
reconnectLimit: 2
|
||||
}
|
||||
});
|
||||
|
||||
page.handleDisconnect("ws_closed");
|
||||
|
||||
expect(page.autoReconnectAttempts).toBe(1);
|
||||
expect(page.setStatus).toHaveBeenCalledWith("disconnected");
|
||||
expect(page.setStatus).toHaveBeenCalledWith("reconnecting");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1200);
|
||||
|
||||
expect(page.connectGateway).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("手动断开不会进入自动重连", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { page } = createTerminalPageHarness({
|
||||
"remoteconn.settings.v2": {
|
||||
autoReconnect: true,
|
||||
reconnectLimit: 2
|
||||
}
|
||||
});
|
||||
|
||||
page.handleDisconnect("manual");
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
|
||||
expect(page.autoReconnectAttempts).toBe(0);
|
||||
expect(page.connectGateway).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("本地已抑制自动重连时,后续 ws_closed 不会误触发重连", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { page } = createTerminalPageHarness({
|
||||
"remoteconn.settings.v2": {
|
||||
autoReconnect: true,
|
||||
reconnectLimit: 2
|
||||
}
|
||||
});
|
||||
|
||||
page.autoReconnectSuppressed = true;
|
||||
page.handleDisconnect("ws_closed");
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
|
||||
expect(page.autoReconnectAttempts).toBe(0);
|
||||
expect(page.connectGateway).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
240
apps/miniprogram/pages/terminal/terminalBufferSet.js
Normal file
240
apps/miniprogram/pages/terminal/terminalBufferSet.js
Normal file
@@ -0,0 +1,240 @@
|
||||
/* global module, require */
|
||||
|
||||
const { cloneTerminalCell } = require("./terminalCursorModel.js");
|
||||
|
||||
const TERMINAL_BUFFER_STATE_VERSION = 2;
|
||||
const DEFAULT_ACTIVE_BUFFER = "normal";
|
||||
const DEFAULT_TERMINAL_MODES = Object.freeze({
|
||||
applicationCursorKeys: false,
|
||||
applicationKeypad: false,
|
||||
originMode: false,
|
||||
reverseWraparound: false,
|
||||
sendFocus: false,
|
||||
wraparound: true,
|
||||
cursorHidden: false,
|
||||
bracketedPasteMode: false,
|
||||
insertMode: false
|
||||
});
|
||||
|
||||
function cloneAnsiState(state) {
|
||||
const source = state && typeof state === "object" ? state : null;
|
||||
return {
|
||||
fg: source && source.fg ? String(source.fg) : "",
|
||||
bg: source && source.bg ? String(source.bg) : "",
|
||||
bold: !!(source && source.bold),
|
||||
underline: !!(source && source.underline)
|
||||
};
|
||||
}
|
||||
|
||||
function cloneTerminalBufferRows(rows) {
|
||||
const source = Array.isArray(rows) && rows.length > 0 ? rows : [[]];
|
||||
return source.map((lineCells) =>
|
||||
Array.isArray(lineCells) ? lineCells.map((cell) => cloneTerminalCell(cell)) : []
|
||||
);
|
||||
}
|
||||
|
||||
function clampNonNegativeInteger(value, fallback) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.max(0, Math.round(parsed));
|
||||
}
|
||||
|
||||
function clampBufferRows(value) {
|
||||
return Math.max(1, clampNonNegativeInteger(value, 24));
|
||||
}
|
||||
|
||||
function createEmptyRows(count) {
|
||||
const total = Math.max(1, clampNonNegativeInteger(count, 1));
|
||||
return Array.from({ length: total }, () => []);
|
||||
}
|
||||
|
||||
function normalizeScreenBuffer(input, options) {
|
||||
const source = input && typeof input === "object" ? input : null;
|
||||
const isAlt = !!(options && options.isAlt);
|
||||
const bufferRows = clampBufferRows(options && options.bufferRows);
|
||||
let cells = cloneTerminalBufferRows(source && source.cells);
|
||||
|
||||
if (isAlt) {
|
||||
if (cells.length > bufferRows) {
|
||||
cells = cells.slice(0, bufferRows);
|
||||
}
|
||||
while (cells.length < bufferRows) {
|
||||
cells.push([]);
|
||||
}
|
||||
} else if (cells.length === 0) {
|
||||
cells = [[]];
|
||||
}
|
||||
|
||||
const cursorRowFallback = 0;
|
||||
const cursorRow = clampNonNegativeInteger(source && source.cursorRow, cursorRowFallback);
|
||||
const cursorCol = clampNonNegativeInteger(source && source.cursorCol, 0);
|
||||
const maxRow = isAlt ? bufferRows - 1 : Math.max(0, Math.max(cells.length - 1, cursorRow));
|
||||
|
||||
if (!isAlt) {
|
||||
while (cells.length <= cursorRow) {
|
||||
cells.push([]);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedCursorRow = Math.max(0, Math.min(cursorRow, maxRow));
|
||||
const savedCursorRow = clampNonNegativeInteger(source && source.savedCursorRow, normalizedCursorRow);
|
||||
const normalizedSavedCursorRow = Math.max(0, Math.min(savedCursorRow, maxRow));
|
||||
const scrollTop = Math.max(
|
||||
0,
|
||||
Math.min(clampNonNegativeInteger(source && source.scrollTop, 0), bufferRows - 1)
|
||||
);
|
||||
const scrollBottom = Math.max(
|
||||
scrollTop,
|
||||
Math.min(clampNonNegativeInteger(source && source.scrollBottom, bufferRows - 1), bufferRows - 1)
|
||||
);
|
||||
|
||||
return {
|
||||
isAlt,
|
||||
cells,
|
||||
ansiState: cloneAnsiState(source && source.ansiState),
|
||||
cursorRow: normalizedCursorRow,
|
||||
cursorCol,
|
||||
savedCursorRow: normalizedSavedCursorRow,
|
||||
savedCursorCol: clampNonNegativeInteger(source && source.savedCursorCol, cursorCol),
|
||||
savedAnsiState: cloneAnsiState(
|
||||
source && source.savedAnsiState ? source.savedAnsiState : source && source.ansiState
|
||||
),
|
||||
scrollTop,
|
||||
scrollBottom
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTerminalModes(input) {
|
||||
const source = input && typeof input === "object" ? input : null;
|
||||
return {
|
||||
applicationCursorKeys:
|
||||
source && source.applicationCursorKeys !== undefined
|
||||
? !!source.applicationCursorKeys
|
||||
: DEFAULT_TERMINAL_MODES.applicationCursorKeys,
|
||||
applicationKeypad:
|
||||
source && source.applicationKeypad !== undefined
|
||||
? !!source.applicationKeypad
|
||||
: DEFAULT_TERMINAL_MODES.applicationKeypad,
|
||||
originMode:
|
||||
source && source.originMode !== undefined ? !!source.originMode : DEFAULT_TERMINAL_MODES.originMode,
|
||||
reverseWraparound:
|
||||
source && source.reverseWraparound !== undefined
|
||||
? !!source.reverseWraparound
|
||||
: DEFAULT_TERMINAL_MODES.reverseWraparound,
|
||||
sendFocus:
|
||||
source && source.sendFocus !== undefined ? !!source.sendFocus : DEFAULT_TERMINAL_MODES.sendFocus,
|
||||
wraparound:
|
||||
source && source.wraparound !== undefined ? !!source.wraparound : DEFAULT_TERMINAL_MODES.wraparound,
|
||||
cursorHidden:
|
||||
source && source.cursorHidden !== undefined
|
||||
? !!source.cursorHidden
|
||||
: DEFAULT_TERMINAL_MODES.cursorHidden,
|
||||
bracketedPasteMode:
|
||||
source && source.bracketedPasteMode !== undefined
|
||||
? !!source.bracketedPasteMode
|
||||
: DEFAULT_TERMINAL_MODES.bracketedPasteMode,
|
||||
insertMode:
|
||||
source && source.insertMode !== undefined ? !!source.insertMode : DEFAULT_TERMINAL_MODES.insertMode
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一把旧版单缓冲状态和新版双缓冲状态收敛成同一种结构:
|
||||
* 1. `normal/alt` 两套 buffer 永远同形;
|
||||
* 2. 页面层只消费 active buffer 的镜像字段;
|
||||
* 3. 未来补更多 VT 模式时,只在这里扩展,不再把状态分散在页面运行时里。
|
||||
*/
|
||||
function normalizeTerminalBufferState(input, options, runtimeOptions) {
|
||||
const source = input && typeof input === "object" ? input : null;
|
||||
const bufferRows = clampBufferRows(options && options.bufferRows);
|
||||
|
||||
const legacyBuffer =
|
||||
source &&
|
||||
(!source.version || !source.buffers) &&
|
||||
(source.cells || source.cursorRow !== undefined || source.cursorCol !== undefined)
|
||||
? source
|
||||
: null;
|
||||
|
||||
const normalSource =
|
||||
source && source.version === TERMINAL_BUFFER_STATE_VERSION && source.buffers
|
||||
? source.buffers.normal
|
||||
: legacyBuffer;
|
||||
const altSource =
|
||||
source && source.version === TERMINAL_BUFFER_STATE_VERSION && source.buffers ? source.buffers.alt : null;
|
||||
|
||||
const activeBuffer =
|
||||
source && source.version === TERMINAL_BUFFER_STATE_VERSION && source.activeBuffer === "alt"
|
||||
? "alt"
|
||||
: DEFAULT_ACTIVE_BUFFER;
|
||||
|
||||
const normalized = {
|
||||
version: TERMINAL_BUFFER_STATE_VERSION,
|
||||
buffers: {
|
||||
normal: normalizeScreenBuffer(normalSource, { isAlt: false, bufferRows }),
|
||||
alt: normalizeScreenBuffer(altSource, { isAlt: true, bufferRows })
|
||||
},
|
||||
activeBuffer,
|
||||
modes: normalizeTerminalModes(source && source.modes ? source.modes : source)
|
||||
};
|
||||
|
||||
return syncActiveBufferSnapshot(normalized, options, runtimeOptions);
|
||||
}
|
||||
|
||||
function cloneTerminalBufferState(input, options, runtimeOptions) {
|
||||
return normalizeTerminalBufferState(input, options, runtimeOptions);
|
||||
}
|
||||
|
||||
function getActiveTerminalBuffer(state) {
|
||||
const source = state && typeof state === "object" ? state : null;
|
||||
if (!source || !source.buffers) {
|
||||
return normalizeScreenBuffer(null, { isAlt: false, bufferRows: 24 });
|
||||
}
|
||||
return source.activeBuffer === "alt" ? source.buffers.alt : source.buffers.normal;
|
||||
}
|
||||
|
||||
function getTerminalModeState(state) {
|
||||
return normalizeTerminalModes(state && state.modes ? state.modes : state);
|
||||
}
|
||||
|
||||
function syncActiveBufferSnapshot(state, options, runtimeOptions) {
|
||||
const source = state && typeof state === "object" ? state : normalizeTerminalBufferState(null, options);
|
||||
const active = source.activeBuffer === "alt" ? source.buffers.alt : source.buffers.normal;
|
||||
const cloneRows = !(runtimeOptions && runtimeOptions.cloneRows === false);
|
||||
const activeCells =
|
||||
Array.isArray(active && active.cells) && active.cells.length > 0
|
||||
? active.cells
|
||||
: active && active.isAlt
|
||||
? createEmptyRows(clampBufferRows(options && options.bufferRows))
|
||||
: [[]];
|
||||
source.cells = cloneRows ? cloneTerminalBufferRows(activeCells) : activeCells;
|
||||
source.ansiState = cloneAnsiState(active && active.ansiState);
|
||||
source.cursorRow = clampNonNegativeInteger(active && active.cursorRow, 0);
|
||||
source.cursorCol = clampNonNegativeInteger(active && active.cursorCol, 0);
|
||||
source.cursorHidden = !!(source.modes && source.modes.cursorHidden);
|
||||
source.applicationCursorKeys = !!(source.modes && source.modes.applicationCursorKeys);
|
||||
source.applicationKeypad = !!(source.modes && source.modes.applicationKeypad);
|
||||
source.bracketedPasteMode = !!(source.modes && source.modes.bracketedPasteMode);
|
||||
source.reverseWraparound = !!(source.modes && source.modes.reverseWraparound);
|
||||
source.sendFocus = !!(source.modes && source.modes.sendFocus);
|
||||
source.insertMode = !!(source.modes && source.modes.insertMode);
|
||||
source.activeBufferName = source.activeBuffer === "alt" ? "alt" : "normal";
|
||||
return source;
|
||||
}
|
||||
|
||||
function createEmptyTerminalBufferState(options) {
|
||||
return normalizeTerminalBufferState(null, options);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_TERMINAL_MODES,
|
||||
TERMINAL_BUFFER_STATE_VERSION,
|
||||
cloneAnsiState,
|
||||
cloneTerminalBufferRows,
|
||||
cloneTerminalBufferState,
|
||||
createEmptyRows,
|
||||
createEmptyTerminalBufferState,
|
||||
getActiveTerminalBuffer,
|
||||
getTerminalModeState,
|
||||
normalizeTerminalBufferState,
|
||||
syncActiveBufferSnapshot
|
||||
};
|
||||
81
apps/miniprogram/pages/terminal/terminalBufferSet.test.ts
Normal file
81
apps/miniprogram/pages/terminal/terminalBufferSet.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const {
|
||||
cloneTerminalBufferState,
|
||||
createEmptyTerminalBufferState,
|
||||
getActiveTerminalBuffer,
|
||||
getTerminalModeState
|
||||
} = require("./terminalBufferSet.js");
|
||||
|
||||
describe("terminalBufferSet", () => {
|
||||
it("会把旧版单缓冲状态提升为 normal/alt 双缓冲结构", () => {
|
||||
const state = cloneTerminalBufferState(
|
||||
{
|
||||
cells: [[{ text: "A", width: 1, continuation: false, style: null }]],
|
||||
ansiState: { fg: "#fff", bg: "", bold: false, underline: false },
|
||||
cursorRow: 0,
|
||||
cursorCol: 1
|
||||
},
|
||||
{ bufferRows: 4 }
|
||||
);
|
||||
|
||||
expect(state.version).toBe(2);
|
||||
expect(state.activeBuffer).toBe("normal");
|
||||
expect(getActiveTerminalBuffer(state).isAlt).toBe(false);
|
||||
expect(state.buffers.alt.cells).toHaveLength(4);
|
||||
expect(state.cursorCol).toBe(1);
|
||||
});
|
||||
|
||||
it("active buffer 镜像字段会跟随 alt buffer 切换", () => {
|
||||
const state = createEmptyTerminalBufferState({ bufferRows: 3 });
|
||||
state.activeBuffer = "alt";
|
||||
state.buffers.alt.cursorRow = 2;
|
||||
state.buffers.alt.cursorCol = 5;
|
||||
|
||||
const cloned = cloneTerminalBufferState(state, { bufferRows: 3 });
|
||||
|
||||
expect(cloned.activeBufferName).toBe("alt");
|
||||
expect(cloned.cursorRow).toBe(2);
|
||||
expect(cloned.cursorCol).toBe(5);
|
||||
expect(getActiveTerminalBuffer(cloned).isAlt).toBe(true);
|
||||
});
|
||||
|
||||
it("模式位会按统一结构收敛,避免页面层分散保存", () => {
|
||||
const state = cloneTerminalBufferState(
|
||||
{
|
||||
modes: {
|
||||
applicationCursorKeys: true,
|
||||
applicationKeypad: true,
|
||||
cursorHidden: true,
|
||||
bracketedPasteMode: true,
|
||||
reverseWraparound: true,
|
||||
sendFocus: true,
|
||||
insertMode: true
|
||||
}
|
||||
},
|
||||
{ bufferRows: 2 }
|
||||
);
|
||||
|
||||
expect(getTerminalModeState(state)).toMatchObject({
|
||||
applicationCursorKeys: true,
|
||||
applicationKeypad: true,
|
||||
cursorHidden: true,
|
||||
bracketedPasteMode: true,
|
||||
reverseWraparound: true,
|
||||
sendFocus: true,
|
||||
insertMode: true,
|
||||
wraparound: true
|
||||
});
|
||||
});
|
||||
|
||||
it("运行态克隆可复用 active buffer 镜像引用,避免热路径重复深拷贝", () => {
|
||||
const state = createEmptyTerminalBufferState({ bufferRows: 3 });
|
||||
state.buffers.normal.cells = [[{ text: "A", width: 1, continuation: false, style: null }]];
|
||||
state.activeBuffer = "normal";
|
||||
|
||||
const cloned = cloneTerminalBufferState(state, { bufferRows: 3 }, { cloneRows: false });
|
||||
|
||||
expect(cloned.cells).toBe(cloned.buffers.normal.cells);
|
||||
expect(cloned.cells).not.toBe(state.buffers.normal.cells);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type TerminalPageOptions = {
|
||||
data?: Record<string, unknown>;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
type TerminalPageInstance = TerminalPageOptions & {
|
||||
data: Record<string, unknown>;
|
||||
setData: (patch: Record<string, unknown>, callback?: () => void) => void;
|
||||
};
|
||||
|
||||
type MiniprogramGlobals = typeof globalThis & {
|
||||
Page?: (options: TerminalPageOptions) => void;
|
||||
wx?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const { createTerminalCell, createContinuationCell } = require("./terminalCursorModel.js");
|
||||
const { serializeTerminalSnapshotRows } = require("./terminalSnapshotCodec.js");
|
||||
|
||||
function createTerminalPageHarness(initialStorage: Record<string, unknown>) {
|
||||
const globalState = globalThis as MiniprogramGlobals;
|
||||
let capturedPageOptions: TerminalPageOptions | null = null;
|
||||
const storage = new Map<string, unknown>(Object.entries(initialStorage));
|
||||
const noop = () => {};
|
||||
|
||||
vi.resetModules();
|
||||
delete require.cache[require.resolve("./index.js")];
|
||||
globalState.Page = vi.fn((options: TerminalPageOptions) => {
|
||||
capturedPageOptions = options;
|
||||
});
|
||||
globalState.wx = {
|
||||
env: {
|
||||
USER_DATA_PATH: "/tmp"
|
||||
},
|
||||
getStorageSync: vi.fn((key: string) => storage.get(key)),
|
||||
setStorageSync: vi.fn((key: string, value: unknown) => {
|
||||
storage.set(key, value);
|
||||
}),
|
||||
removeStorageSync: vi.fn((key: string) => {
|
||||
storage.delete(key);
|
||||
}),
|
||||
getRecorderManager: vi.fn(() => ({
|
||||
onStart: noop,
|
||||
onStop: noop,
|
||||
onError: noop,
|
||||
onFrameRecorded: noop,
|
||||
start: noop,
|
||||
stop: noop
|
||||
})),
|
||||
createInnerAudioContext: vi.fn(() => ({
|
||||
onCanplay: noop,
|
||||
onPlay: noop,
|
||||
onEnded: noop,
|
||||
onStop: noop,
|
||||
onError: noop,
|
||||
stop: noop,
|
||||
destroy: noop
|
||||
})),
|
||||
setInnerAudioOption: vi.fn(),
|
||||
createSelectorQuery: vi.fn(() => ({
|
||||
in: vi.fn(() => ({
|
||||
select: vi.fn(() => ({
|
||||
boundingClientRect: vi.fn(() => ({
|
||||
exec: noop
|
||||
}))
|
||||
}))
|
||||
}))
|
||||
})),
|
||||
nextTick: vi.fn((callback?: () => void) => {
|
||||
callback?.();
|
||||
}),
|
||||
getSystemInfoSync: vi.fn(() => ({})),
|
||||
canIUse: vi.fn(() => false)
|
||||
};
|
||||
|
||||
require("./index.js");
|
||||
|
||||
if (!capturedPageOptions) {
|
||||
throw new Error("terminal page not captured");
|
||||
}
|
||||
|
||||
const captured = capturedPageOptions as TerminalPageOptions;
|
||||
const page = {
|
||||
...captured,
|
||||
data: JSON.parse(JSON.stringify(captured.data || {})) as Record<string, unknown>,
|
||||
setData(patch: Record<string, unknown>, callback?: () => void) {
|
||||
Object.assign(this.data, patch);
|
||||
callback?.();
|
||||
}
|
||||
} as TerminalPageInstance;
|
||||
|
||||
return { page };
|
||||
}
|
||||
|
||||
describe("terminal snapshot restore", () => {
|
||||
const globalState = globalThis as MiniprogramGlobals;
|
||||
const originalPage = globalState.Page;
|
||||
const originalWx = globalState.wx;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
if (originalPage) {
|
||||
globalState.Page = originalPage;
|
||||
} else {
|
||||
delete globalState.Page;
|
||||
}
|
||||
if (originalWx) {
|
||||
globalState.wx = originalWx;
|
||||
} else {
|
||||
delete globalState.wx;
|
||||
}
|
||||
});
|
||||
|
||||
it("恢复第一页时会优先使用样式快照,保留 ANSI 颜色并继续保留 replayText", () => {
|
||||
const style = { fg: "#ff5f56", bg: "#1f2937", bold: true, underline: false };
|
||||
const rows = [
|
||||
[
|
||||
createTerminalCell("错", style, 2),
|
||||
createContinuationCell(style),
|
||||
createTerminalCell("误", style, 2),
|
||||
createContinuationCell(style),
|
||||
createTerminalCell(":", style, 1)
|
||||
]
|
||||
];
|
||||
const snapshotLines = serializeTerminalSnapshotRows(rows);
|
||||
const { page } = createTerminalPageHarness({
|
||||
"remoteconn.terminal.buffer.v1": {
|
||||
sessionKey: "mini-key-color",
|
||||
lines: ["错误:"],
|
||||
styleTable: snapshotLines.styleTable,
|
||||
styledLines: snapshotLines.styledLines,
|
||||
replayText: "\u001b[31;1m错误:\u001b[0m",
|
||||
bufferCols: 40,
|
||||
bufferRows: 12,
|
||||
cursorRow: 0,
|
||||
cursorCol: 5
|
||||
}
|
||||
});
|
||||
|
||||
page.sessionKey = "mini-key-color";
|
||||
page.restorePersistedTerminalBuffer();
|
||||
|
||||
expect(page.outputCells[0][0]?.style).toEqual(style);
|
||||
expect(page.outputCells[0][2]?.style).toEqual(style);
|
||||
expect(page.outputReplayText).toBe("\u001b[31;1m错误:\u001b[0m");
|
||||
expect(page.outputReplayBytes).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
1331
apps/miniprogram/pages/terminal/terminalBufferState.js
Normal file
1331
apps/miniprogram/pages/terminal/terminalBufferState.js
Normal file
File diff suppressed because it is too large
Load Diff
738
apps/miniprogram/pages/terminal/terminalBufferState.test.ts
Normal file
738
apps/miniprogram/pages/terminal/terminalBufferState.test.ts
Normal file
@@ -0,0 +1,738 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const {
|
||||
ANSI_RESET_STATE,
|
||||
applyTerminalOutput,
|
||||
cloneTerminalBufferState,
|
||||
getActiveTerminalBuffer,
|
||||
rebuildTerminalBufferStateFromReplayText,
|
||||
trimTerminalReplayTextToMaxBytes
|
||||
} = require("./terminalBufferState.js");
|
||||
const { lineCellsToText } = require("./terminalCursorModel.js");
|
||||
const { takeTerminalReplaySlice } = require("./vtParser.js");
|
||||
|
||||
describe("terminalBufferState", () => {
|
||||
it("宽字符和组合字符仍按 cell 列推进,不回退到字符串长度语义", () => {
|
||||
const result = applyTerminalOutput(
|
||||
{
|
||||
cells: [[]],
|
||||
ansiState: { ...ANSI_RESET_STATE },
|
||||
cursorRow: 0,
|
||||
cursorCol: 0
|
||||
},
|
||||
"中e\u0301A",
|
||||
{ bufferCols: 10, maxEntries: 20, maxBytes: 1024 }
|
||||
);
|
||||
|
||||
const row = result.state.cells[0];
|
||||
expect(lineCellsToText(row)).toBe("中e\u0301A");
|
||||
expect(result.state.cursorRow).toBe(0);
|
||||
expect(result.state.cursorCol).toBe(4);
|
||||
expect(row[0]).toMatchObject({ text: "中", width: 2, continuation: false });
|
||||
expect(row[1]).toMatchObject({ text: "", width: 0, continuation: true });
|
||||
expect(row[2]).toMatchObject({ text: "e\u0301", width: 1, continuation: false });
|
||||
expect(row[3]).toMatchObject({ text: "A", width: 1, continuation: false });
|
||||
});
|
||||
|
||||
it("覆盖宽字符 continuation 时会先清理 owner,避免留下脏半格", () => {
|
||||
const result = applyTerminalOutput(
|
||||
{
|
||||
cells: [[]],
|
||||
ansiState: { ...ANSI_RESET_STATE },
|
||||
cursorRow: 0,
|
||||
cursorCol: 0
|
||||
},
|
||||
"中\bA",
|
||||
{ bufferCols: 10, maxEntries: 20, maxBytes: 1024 }
|
||||
);
|
||||
|
||||
const row = result.state.cells[0];
|
||||
expect(lineCellsToText(row)).toBe(" A");
|
||||
expect(result.state.cursorCol).toBe(2);
|
||||
expect(row[0]).toMatchObject({ text: "", width: 1, continuation: false, placeholder: true });
|
||||
expect(row[1]).toMatchObject({ text: "A", width: 1, continuation: false });
|
||||
expect(row.some((cell: { continuation?: boolean }) => !!cell && !!cell.continuation)).toBe(false);
|
||||
});
|
||||
|
||||
it("在列数变化后可按重放文本重新排布旧输出", () => {
|
||||
const narrowResult = applyTerminalOutput(
|
||||
{
|
||||
cells: [[]],
|
||||
ansiState: { ...ANSI_RESET_STATE },
|
||||
cursorRow: 0,
|
||||
cursorCol: 0
|
||||
},
|
||||
"abcdef",
|
||||
{ bufferCols: 4, maxEntries: 20, maxBytes: 1024 }
|
||||
);
|
||||
const replayState = rebuildTerminalBufferStateFromReplayText(narrowResult.cleanText, {
|
||||
bufferCols: 8,
|
||||
maxEntries: 20,
|
||||
maxBytes: 1024
|
||||
});
|
||||
|
||||
expect(narrowResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["abcd", "ef"]);
|
||||
expect(replayState.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["abcdef"]);
|
||||
expect(replayState.cursorRow).toBe(0);
|
||||
expect(replayState.cursorCol).toBe(6);
|
||||
});
|
||||
|
||||
it("重放文本会保留 ANSI 清行后的最终屏幕状态", () => {
|
||||
const result = applyTerminalOutput(
|
||||
{
|
||||
cells: [[]],
|
||||
ansiState: { ...ANSI_RESET_STATE },
|
||||
cursorRow: 0,
|
||||
cursorCol: 0
|
||||
},
|
||||
"hello\r\u001b[0Kworld",
|
||||
{ bufferCols: 10, maxEntries: 20, maxBytes: 1024 }
|
||||
);
|
||||
const replayState = rebuildTerminalBufferStateFromReplayText(result.cleanText, {
|
||||
bufferCols: 10,
|
||||
maxEntries: 20,
|
||||
maxBytes: 1024
|
||||
});
|
||||
|
||||
expect(replayState.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["world"]);
|
||||
expect(replayState.cursorCol).toBe(5);
|
||||
});
|
||||
|
||||
it("快照裁剪时保留尾部重放文本,便于恢复后按新列宽重建", () => {
|
||||
expect(trimTerminalReplayTextToMaxBytes("ab中cd", 5)).toBe("中cd");
|
||||
expect(trimTerminalReplayTextToMaxBytes("abcd", 8)).toBe("abcd");
|
||||
});
|
||||
|
||||
it("宽字符输出在列数变化后仍可按 replay 文本重建", () => {
|
||||
const narrowResult = applyTerminalOutput(
|
||||
{
|
||||
cells: [[]],
|
||||
ansiState: { ...ANSI_RESET_STATE },
|
||||
cursorRow: 0,
|
||||
cursorCol: 0
|
||||
},
|
||||
"中AB",
|
||||
{ bufferCols: 3, maxEntries: 20, maxBytes: 1024 }
|
||||
);
|
||||
const replayState = rebuildTerminalBufferStateFromReplayText(narrowResult.cleanText, {
|
||||
bufferCols: 4,
|
||||
maxEntries: 20,
|
||||
maxBytes: 1024
|
||||
});
|
||||
|
||||
expect(narrowResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["中A", "B"]);
|
||||
expect(replayState.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["中AB"]);
|
||||
expect(replayState.cursorRow).toBe(0);
|
||||
expect(replayState.cursorCol).toBe(4);
|
||||
});
|
||||
|
||||
it("按安全切片连续推进后,最终终端状态应与整段推进一致", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const source = "ab\r\n\u001b[31mcd\u001b[0mZ";
|
||||
const whole = applyTerminalOutput(createEmptyState(), source, options);
|
||||
|
||||
let remaining = source;
|
||||
let state = createEmptyState();
|
||||
while (remaining) {
|
||||
const part = takeTerminalReplaySlice(remaining, 4);
|
||||
expect(part.slice.length).toBeGreaterThan(0);
|
||||
const partial = applyTerminalOutput(state, part.slice, options);
|
||||
state = partial.state;
|
||||
remaining = part.rest;
|
||||
}
|
||||
|
||||
expect(state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(
|
||||
whole.state.cells.map((row: unknown[]) => lineCellsToText(row))
|
||||
);
|
||||
expect(state.cursorRow).toBe(whole.state.cursorRow);
|
||||
expect(state.cursorCol).toBe(whole.state.cursorCol);
|
||||
expect(state.ansiState).toEqual(whole.state.ansiState);
|
||||
});
|
||||
|
||||
it("stdout 运行态复用时,最终状态应与常规不可变推进一致", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const base = applyTerminalOutput(createEmptyState(), "ab\r\ncd", options);
|
||||
const regularBase = cloneTerminalBufferState(base.state, options);
|
||||
const runtimeBase = cloneTerminalBufferState(base.state, options, { cloneRows: false });
|
||||
const regular = applyTerminalOutput(regularBase, "\u001b[31mZ", options);
|
||||
const reused = applyTerminalOutput(runtimeBase, "\u001b[31mZ", options, {
|
||||
reuseState: true,
|
||||
reuseRows: true
|
||||
});
|
||||
|
||||
expect(reused.state).toBe(runtimeBase);
|
||||
expect(reused.state.cells).toBe(reused.state.buffers.normal.cells);
|
||||
expect(reused.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(
|
||||
regular.state.cells.map((row: unknown[]) => lineCellsToText(row))
|
||||
);
|
||||
expect(reused.state.cursorRow).toBe(regular.state.cursorRow);
|
||||
expect(reused.state.cursorCol).toBe(regular.state.cursorCol);
|
||||
expect(reused.state.ansiState).toEqual(regular.state.ansiState);
|
||||
});
|
||||
|
||||
it("1049 alt screen 切换后会保留 normal buffer 历史,并在退出时恢复", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const base = applyTerminalOutput(createEmptyState(), "shell>", options);
|
||||
const entered = applyTerminalOutput(base.state, "\u001b[?1049hTOP", options);
|
||||
const restored = applyTerminalOutput(entered.state, "\u001b[?1049l", options);
|
||||
|
||||
expect(getActiveTerminalBuffer(entered.state).isAlt).toBe(true);
|
||||
expect(entered.state.activeBufferName).toBe("alt");
|
||||
expect(entered.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["TOP", "", "", ""]);
|
||||
expect(restored.state.activeBufferName).toBe("normal");
|
||||
expect(restored.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["shell>"]);
|
||||
});
|
||||
|
||||
it("私有模式会驱动 Codex 依赖的关键终端模式位", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const hidden = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001b[?25l\u001b[?1h\u001b[?45h\u001b[?66h\u001b[?1004h\u001b[?2004h\u001b[4h",
|
||||
options
|
||||
);
|
||||
const shown = applyTerminalOutput(
|
||||
hidden.state,
|
||||
"\u001b[?25h\u001b[?1l\u001b[?45l\u001b[?66l\u001b[?1004l\u001b[?2004l\u001b[4l",
|
||||
options
|
||||
);
|
||||
|
||||
expect(hidden.state.modes).toMatchObject({
|
||||
cursorHidden: true,
|
||||
applicationCursorKeys: true,
|
||||
applicationKeypad: true,
|
||||
reverseWraparound: true,
|
||||
sendFocus: true,
|
||||
bracketedPasteMode: true,
|
||||
insertMode: true
|
||||
});
|
||||
expect(shown.state.modes).toMatchObject({
|
||||
cursorHidden: false,
|
||||
applicationCursorKeys: false,
|
||||
applicationKeypad: false,
|
||||
reverseWraparound: false,
|
||||
sendFocus: false,
|
||||
bracketedPasteMode: false,
|
||||
insertMode: false
|
||||
});
|
||||
});
|
||||
|
||||
it("DSR/CPR 查询会生成响应,而不会污染屏幕内容", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const result = applyTerminalOutput(createEmptyState(), "ab\u001b[6n\u001b[5n", options);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["ab"]);
|
||||
expect(result.responses).toEqual(["\u001b[1;3R", "\u001b[0n"]);
|
||||
});
|
||||
|
||||
it("DA1/DA2 查询会分别返回 primary 和 secondary device attributes", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const result = applyTerminalOutput(createEmptyState(), "\u001b[c\u001b[>c", options);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([""]);
|
||||
expect(result.responses).toEqual(["\u001b[?1;2c", "\u001b[>0;276;0c"]);
|
||||
});
|
||||
|
||||
it("OSC 10/11/12 颜色查询会返回最小可用响应,而不会污染屏幕内容", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const result = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001b]10;?\u001b\\\u001b]11;?\u001b\\\u001b]12;?\u001b\\",
|
||||
options,
|
||||
{
|
||||
defaultForeground: "#112233",
|
||||
defaultBackground: "#445566",
|
||||
defaultCursor: "#778899"
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([""]);
|
||||
expect(result.responses).toEqual([
|
||||
"\u001b]10;rgb:1111/2222/3333\u001b\\",
|
||||
"\u001b]11;rgb:4444/5555/6666\u001b\\",
|
||||
"\u001b]12;rgb:7777/8888/9999\u001b\\"
|
||||
]);
|
||||
});
|
||||
|
||||
it("normal buffer 的绝对定位会以当前可视尾部为基准,而不是写回历史顶部", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const base = applyTerminalOutput(createEmptyState(), "1\r\n2\r\n3\r\n4\r\n5", options);
|
||||
const result = applyTerminalOutput(base.state, "\u001b[1;1HX", options);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
|
||||
"1",
|
||||
"X",
|
||||
"3",
|
||||
"4",
|
||||
"5"
|
||||
]);
|
||||
expect(result.responses).toEqual([]);
|
||||
});
|
||||
|
||||
it("CSI B 在 normal buffer 中下移时会真实扩出目标行,而不是钳死在 viewport 内", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 2, maxEntries: 20, maxBytes: 1024 };
|
||||
const result = applyTerminalOutput(createEmptyState(), "\u001b[3BZ", options);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["", "", "", "Z"]);
|
||||
expect(result.state.cursorRow).toBe(3);
|
||||
expect(result.state.cursorCol).toBe(1);
|
||||
});
|
||||
|
||||
it("origin mode 下的 VPA/CUP 会以滚动区顶部为基准定位", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const result = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001b[2;4r\u001b[?6h\u001b[2dA\u001b[1;3HZ",
|
||||
options
|
||||
);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["", " Z", "A"]);
|
||||
expect(result.state.cursorRow).toBe(1);
|
||||
expect(result.state.cursorCol).toBe(3);
|
||||
});
|
||||
|
||||
it("normal buffer 有历史时,开启 origin mode 会把光标归位到当前滚动区顶部", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const base = applyTerminalOutput(createEmptyState(), "1\r\n2\r\n3\r\n4\r\n5", options);
|
||||
const result = applyTerminalOutput(base.state, "\u001b[2;4r\u001b[?6hX", options);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"X",
|
||||
"4",
|
||||
"5"
|
||||
]);
|
||||
expect(result.state.cursorRow).toBe(2);
|
||||
expect(result.state.cursorCol).toBe(1);
|
||||
});
|
||||
|
||||
it("origin mode 下的 CUU/CUD/CNL/CPL 会被滚动区夹住,不越过固定区", () => {
|
||||
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const movedUpDown = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001b[?1049h1\n2\n3\n4\u001b[2;4r\u001b[?6h\u001b[1;1H\u001b[AZ\u001b[3;1H\u001b[B#",
|
||||
options
|
||||
);
|
||||
const movedPrevNextLine = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001b[?1049h1\n2\n3\n4\u001b[2;4r\u001b[?6h\u001b[1;3H\u001b[FZ\u001b[3;3H\u001b[EQ",
|
||||
options
|
||||
);
|
||||
|
||||
expect(movedUpDown.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "Z", "3", "#"]);
|
||||
expect(movedPrevNextLine.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "Z", "3", "Q"]);
|
||||
});
|
||||
|
||||
it("normal buffer 有历史时,CUU/CPL 不会越过当前视口顶部并写回隐藏历史区", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const base = applyTerminalOutput(createEmptyState(), "1\r\n2\r\n3\r\n4\r\n5", options);
|
||||
const movedUp = applyTerminalOutput(base.state, "\u001b[1;1H\u001b[AZ", options);
|
||||
const movedPrevLine = applyTerminalOutput(base.state, "\u001b[1;3H\u001b[FQ", options);
|
||||
|
||||
expect(movedUp.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
|
||||
"1",
|
||||
"Z",
|
||||
"3",
|
||||
"4",
|
||||
"5"
|
||||
]);
|
||||
expect(movedUp.state.cursorRow).toBe(1);
|
||||
expect(movedUp.state.cursorCol).toBe(1);
|
||||
|
||||
expect(movedPrevLine.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
|
||||
"1",
|
||||
"Q",
|
||||
"3",
|
||||
"4",
|
||||
"5"
|
||||
]);
|
||||
expect(movedPrevLine.state.cursorRow).toBe(1);
|
||||
expect(movedPrevLine.state.cursorCol).toBe(1);
|
||||
});
|
||||
|
||||
it("normal buffer 顶部滚动区上卷时会保留历史,并维持底部固定区", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const base = applyTerminalOutput(createEmptyState(), "1\r\n2\r\n3\r\n4", options);
|
||||
const result = applyTerminalOutput(base.state, "\u001b[1;3r\u001b[3;1H\n", options);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"",
|
||||
"4"
|
||||
]);
|
||||
expect(result.state.cells.slice(-4).map((row: unknown[]) => lineCellsToText(row))).toEqual([
|
||||
"2",
|
||||
"3",
|
||||
"",
|
||||
"4"
|
||||
]);
|
||||
});
|
||||
|
||||
it("normal buffer 的 ESC M 会在局部滚动区顶部插入空行", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 6, maxEntries: 20, maxBytes: 1024 };
|
||||
const base = applyTerminalOutput(createEmptyState(), "1\r\n2\r\n3\r\n4\r\n5\r\n6", options);
|
||||
const result = applyTerminalOutput(base.state, "\u001b[4;6r\u001b[4;1H\u001bM", options);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"",
|
||||
"4",
|
||||
"5"
|
||||
]);
|
||||
});
|
||||
|
||||
it("ESC D / ESC E / ESC M 在 alt buffer 固定头尾区域时,不会误滚中间滚动区", () => {
|
||||
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const indResult = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001b[?1049h1\n2\n3\n4\u001b[2;3r\u001b[4;1H\u001bD#",
|
||||
options
|
||||
);
|
||||
const nelResult = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001b[?1049h1\n2\n3\n4\u001b[2;3r\u001b[4;1H\u001bE#",
|
||||
options
|
||||
);
|
||||
const riResult = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001b[?1049h1\n2\n3\n4\u001b[2;3r\u001b[1;1H\u001bM#",
|
||||
options
|
||||
);
|
||||
|
||||
expect(indResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "2", "3", "#"]);
|
||||
expect(indResult.state.cursorRow).toBe(3);
|
||||
expect(indResult.state.cursorCol).toBe(1);
|
||||
expect(nelResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "2", "3", "#"]);
|
||||
expect(nelResult.state.cursorRow).toBe(3);
|
||||
expect(nelResult.state.cursorCol).toBe(1);
|
||||
expect(riResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["#", "2", "3", "4"]);
|
||||
expect(riResult.state.cursorRow).toBe(0);
|
||||
expect(riResult.state.cursorCol).toBe(1);
|
||||
});
|
||||
|
||||
it("ESC D / ESC M 在 normal buffer 固定头尾区域时,不会误滚正文区或回写历史区", () => {
|
||||
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const base = applyTerminalOutput(createEmptyState(), "1\r\n2\r\n3\r\n4\r\n5\r\n6", options);
|
||||
const indResult = applyTerminalOutput(base.state, "\u001b[2;3r\u001b[4;1H\u001bD#", options);
|
||||
const riResult = applyTerminalOutput(base.state, "\u001b[2;3r\u001b[1;1H\u001bM#", options);
|
||||
|
||||
expect(indResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"#"
|
||||
]);
|
||||
expect(indResult.state.cursorRow).toBe(5);
|
||||
expect(indResult.state.cursorCol).toBe(1);
|
||||
|
||||
expect(riResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"#",
|
||||
"4",
|
||||
"5",
|
||||
"6"
|
||||
]);
|
||||
expect(riResult.state.cursorRow).toBe(2);
|
||||
expect(riResult.state.cursorCol).toBe(1);
|
||||
});
|
||||
|
||||
it("normal buffer 的 CPR 会返回可视窗口内的行号,而不是历史绝对行号", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const base = applyTerminalOutput(createEmptyState(), "1\r\n2\r\n3\r\n4\r\n5", options);
|
||||
const result = applyTerminalOutput(base.state, "\u001b[1;1H\u001b[6n", options);
|
||||
|
||||
expect(result.responses).toEqual(["\u001b[1;1R"]);
|
||||
});
|
||||
|
||||
it("接近 codex /status 的 normal buffer 重排后,不会只停在 Permissions 这一行", () => {
|
||||
const options = { bufferCols: 80, bufferRows: 24, maxEntries: 200, maxBytes: 8192 };
|
||||
const historyText = Array.from({ length: 30 }, (_, index) => `pre${index + 1}`).join("\r\n");
|
||||
const base = applyTerminalOutput(createEmptyState(), historyText, options);
|
||||
const statusPayload =
|
||||
"\u001b[18;1H\u001b[J\u001b[18;24r\u001b[18;1H\u001bM\u001bM\u001b[r\u001b[1;19r\u001b[17;1H" +
|
||||
"\r\n/status\r\n\r\nStatusHeader\r\nModel\r\nDirectory\r\nPermissions\r\nAgents\r\nAccount\r\n" +
|
||||
"Collaboration\r\nSession\r\nLimit5h\r\nLimitReset\r\nWeekly\r\nWeeklyReset\r\n" +
|
||||
"\u001b[r\u001b[21;3H";
|
||||
const result = applyTerminalOutput(base.state, statusPayload, options);
|
||||
const visibleTail = result.state.cells.slice(-24).map((row: unknown[]) => lineCellsToText(row));
|
||||
|
||||
expect(visibleTail).toContain("Permissions");
|
||||
expect(visibleTail).toContain("Weekly");
|
||||
expect(visibleTail).toContain("WeeklyReset");
|
||||
});
|
||||
|
||||
it("DECRQM 会按当前实现真实维护的模式位返回状态", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const result = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001b[?1004h\u001b[4h\u001b[?1049$p\u001b[?1004$p\u001b[4$p\u001b[?2026$p",
|
||||
options
|
||||
);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([""]);
|
||||
expect(result.responses).toEqual([
|
||||
"\u001b[?1049;2$y",
|
||||
"\u001b[?1004;1$y",
|
||||
"\u001b[4;1$y",
|
||||
"\u001b[?2026;0$y"
|
||||
]);
|
||||
});
|
||||
|
||||
it("DCS $ q 状态字符串查询会返回最小可用响应", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const result = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001bP$qm\u001b\\\u001bP$qr\u001b\\\u001bP$q q\u001b\\\u001bP$qz\u001b\\",
|
||||
options
|
||||
);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([""]);
|
||||
expect(result.responses).toEqual([
|
||||
"\u001bP1$r0m\u001b\\",
|
||||
"\u001bP1$r1;4r\u001b\\",
|
||||
"\u001bP1$r2 q\u001b\\",
|
||||
"\u001bP0$r\u001b\\"
|
||||
]);
|
||||
});
|
||||
|
||||
it("DECSTR 会软重置模式位、样式和滚动区域,但保留现有屏幕内容", () => {
|
||||
const options = { bufferCols: 10, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const result = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001b[?1049h\u001b[2;3r\u001b[?25l\u001b[?1h\u001b[?2004h\u001b[31mX\u001b[!p",
|
||||
options
|
||||
);
|
||||
const active = getActiveTerminalBuffer(result.state);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["X", "", "", ""]);
|
||||
expect(result.state.ansiState).toEqual(ANSI_RESET_STATE);
|
||||
expect(result.state.modes).toMatchObject({
|
||||
applicationCursorKeys: false,
|
||||
originMode: false,
|
||||
wraparound: true,
|
||||
cursorHidden: false,
|
||||
bracketedPasteMode: false
|
||||
});
|
||||
expect(active.scrollTop).toBe(0);
|
||||
expect(active.scrollBottom).toBe(3);
|
||||
expect(active.savedCursorRow).toBe(0);
|
||||
expect(active.savedCursorCol).toBe(0);
|
||||
expect(active.savedAnsiState).toEqual(ANSI_RESET_STATE);
|
||||
});
|
||||
|
||||
it("CSI 2J 清屏后保留当前光标位置,后续输出不会错位到左上角", () => {
|
||||
const options = { bufferCols: 6, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const result = applyTerminalOutput(createEmptyState(), "ABCD\u001b[1;3H\u001b[2JX", options);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([" X", "", "", ""]);
|
||||
expect(result.state.cursorRow).toBe(0);
|
||||
expect(result.state.cursorCol).toBe(3);
|
||||
});
|
||||
|
||||
it("带背景色的清屏会把整屏空白位也染成当前擦除背景,而不是只给文字底部上色", () => {
|
||||
const options = { bufferCols: 6, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const result = applyTerminalOutput(createEmptyState(), "\u001b[100m\u001b[2JX", options);
|
||||
const firstRow = result.state.cells[0];
|
||||
const secondRow = result.state.cells[1];
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["X", "", "", ""]);
|
||||
expect(firstRow[1]).toMatchObject({
|
||||
placeholder: true,
|
||||
style: { bg: "#666666" }
|
||||
});
|
||||
expect(secondRow[0]).toMatchObject({
|
||||
placeholder: true,
|
||||
style: { bg: "#666666" }
|
||||
});
|
||||
});
|
||||
|
||||
it("CSI X 会从当前光标起按列擦除字符而不左移后续内容", () => {
|
||||
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const result = applyTerminalOutput(createEmptyState(), "abcdef\u001b[1;3H\u001b[2X", options);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["ab ef"]);
|
||||
expect(result.state.cursorRow).toBe(0);
|
||||
expect(result.state.cursorCol).toBe(2);
|
||||
});
|
||||
|
||||
it("CSI @ / P 会按当前光标插删行内字符,而不是重写整行", () => {
|
||||
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const inserted = applyTerminalOutput(createEmptyState(), "abcd\u001b[1;3H\u001b[@Z", options);
|
||||
const deleted = applyTerminalOutput(createEmptyState(), "abcdef\u001b[1;3H\u001b[2P", options);
|
||||
|
||||
expect(inserted.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["abZcd"]);
|
||||
expect(inserted.state.cursorRow).toBe(0);
|
||||
expect(inserted.state.cursorCol).toBe(3);
|
||||
expect(deleted.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["abef"]);
|
||||
expect(deleted.state.cursorRow).toBe(0);
|
||||
expect(deleted.state.cursorCol).toBe(2);
|
||||
});
|
||||
|
||||
it("CSI @ / P 切进宽字符中间时,不会留下悬空 continuation 或半个宽字符", () => {
|
||||
const options = { bufferCols: 5, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const inserted = applyTerminalOutput(createEmptyState(), "A中BC\u001b[1;3H\u001b[@", options);
|
||||
const deleted = applyTerminalOutput(createEmptyState(), "A中BC\u001b[1;2H\u001b[P", options);
|
||||
|
||||
expect(inserted.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["A B"]);
|
||||
expect(deleted.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["A BC"]);
|
||||
expect(
|
||||
inserted.state.cells[0].some((cell: { continuation?: boolean }) => !!cell && !!cell.continuation)
|
||||
).toBe(false);
|
||||
expect(
|
||||
deleted.state.cells[0].some((cell: { continuation?: boolean }) => !!cell && !!cell.continuation)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("normal buffer 有历史和固定头尾时,CSI L / M / S / T 只作用于当前滚动区", () => {
|
||||
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const base = applyTerminalOutput(createEmptyState(), "1\r\n2\r\n3\r\n4\r\n5\r\n6", options);
|
||||
const inserted = applyTerminalOutput(base.state, "\u001b[2;3r\u001b[2;1H\u001b[L", options);
|
||||
const deleted = applyTerminalOutput(base.state, "\u001b[2;3r\u001b[2;1H\u001b[M", options);
|
||||
const scrolledUp = applyTerminalOutput(base.state, "\u001b[2;3r\u001b[S", options);
|
||||
const scrolledDown = applyTerminalOutput(base.state, "\u001b[2;3r\u001b[T", options);
|
||||
|
||||
expect(inserted.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"",
|
||||
"4",
|
||||
"6"
|
||||
]);
|
||||
expect(deleted.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"5",
|
||||
"",
|
||||
"6"
|
||||
]);
|
||||
expect(scrolledUp.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"5",
|
||||
"",
|
||||
"6"
|
||||
]);
|
||||
expect(scrolledDown.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"",
|
||||
"4",
|
||||
"6"
|
||||
]);
|
||||
});
|
||||
|
||||
it("CSI L / M 会在 alt buffer 当前行插删整行", () => {
|
||||
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const inserted = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001b[?1049h1\n2\n3\n4\u001b[2;1H\u001b[L",
|
||||
options
|
||||
);
|
||||
const deleted = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001b[?1049h1\n2\n3\n4\u001b[2;1H\u001b[M",
|
||||
options
|
||||
);
|
||||
|
||||
expect(inserted.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "", "2", "3"]);
|
||||
expect(inserted.state.cursorRow).toBe(1);
|
||||
expect(inserted.state.cursorCol).toBe(0);
|
||||
expect(deleted.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "3", "4", ""]);
|
||||
expect(deleted.state.cursorRow).toBe(1);
|
||||
expect(deleted.state.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it("CSI S / T 会在当前滚动区内上卷和下卷,而不改动其它语义", () => {
|
||||
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const scrolledUp = applyTerminalOutput(createEmptyState(), "\u001b[?1049h1\n2\n3\n4\u001b[S", options);
|
||||
const scrolledDown = applyTerminalOutput(createEmptyState(), "\u001b[?1049h1\n2\n3\n4\u001b[T", options);
|
||||
|
||||
expect(scrolledUp.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["2", "3", "4", ""]);
|
||||
expect(scrolledUp.state.cursorRow).toBe(3);
|
||||
expect(scrolledUp.state.cursorCol).toBe(1);
|
||||
expect(scrolledDown.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["", "1", "2", "3"]);
|
||||
expect(scrolledDown.state.cursorRow).toBe(3);
|
||||
expect(scrolledDown.state.cursorCol).toBe(1);
|
||||
});
|
||||
|
||||
it("CSI r 只给顶部参数时会把底边默认到最后一行", () => {
|
||||
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const base = applyTerminalOutput(createEmptyState(), "1\r\n2\r\n3\r\n4", options);
|
||||
const result = applyTerminalOutput(base.state, "\u001b[2r\u001b[4;1H\n", options);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "3", "4", ""]);
|
||||
expect(result.state.cursorRow).toBe(3);
|
||||
expect(result.state.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it("insert mode 打开后,普通打印会按当前光标位置插入而不是覆盖", () => {
|
||||
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const result = applyTerminalOutput(createEmptyState(), "abcd\u001b[1;3H\u001b[4hZ", options);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["abZcd"]);
|
||||
expect(result.state.cursorRow).toBe(0);
|
||||
expect(result.state.cursorCol).toBe(3);
|
||||
expect(result.state.modes.insertMode).toBe(true);
|
||||
});
|
||||
|
||||
it("ESC D / ESC E / ESC M 会按滚动区域语义推进全屏缓冲", () => {
|
||||
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const indResult = applyTerminalOutput(
|
||||
createEmptyState(),
|
||||
"\u001b[?1049h1\n2\n3\n4\u001b[2;4r\u001b[4;1H\u001bD",
|
||||
options
|
||||
);
|
||||
const nelResult = applyTerminalOutput(indResult.state, "\u001bE#", options);
|
||||
const riResult = applyTerminalOutput(nelResult.state, "\u001b[2;1H\u001bM", options);
|
||||
|
||||
expect(indResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "3", "4", ""]);
|
||||
expect(nelResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "4", "", "#"]);
|
||||
expect(nelResult.state.cursorRow).toBe(3);
|
||||
expect(nelResult.state.cursorCol).toBe(1);
|
||||
expect(riResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["1", "", "4", ""]);
|
||||
expect(riResult.state.cursorRow).toBe(1);
|
||||
expect(riResult.state.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it("CSI s/u 与 ESC 7/8 会恢复之前保存的光标位置", () => {
|
||||
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const csiResult = applyTerminalOutput(createEmptyState(), "ab\u001b[scd\u001b[uZ", options);
|
||||
const escResult = applyTerminalOutput(createEmptyState(), "ab\u001b7cd\u001b8Z", options);
|
||||
|
||||
expect(csiResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["abZd"]);
|
||||
expect(csiResult.state.cursorRow).toBe(0);
|
||||
expect(csiResult.state.cursorCol).toBe(3);
|
||||
expect(escResult.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["abZd"]);
|
||||
expect(escResult.state.cursorRow).toBe(0);
|
||||
expect(escResult.state.cursorCol).toBe(3);
|
||||
});
|
||||
|
||||
it("带 > 私有标记的 CSI u 不应误当成光标恢复", () => {
|
||||
const options = { bufferCols: 8, bufferRows: 4, maxEntries: 20, maxBytes: 1024 };
|
||||
const result = applyTerminalOutput(createEmptyState(), "abc\u001b[>7uZ", options);
|
||||
|
||||
expect(result.state.cells.map((row: unknown[]) => lineCellsToText(row))).toEqual(["abcZ"]);
|
||||
expect(result.state.cursorRow).toBe(0);
|
||||
expect(result.state.cursorCol).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
function createEmptyState() {
|
||||
return {
|
||||
cells: [[]],
|
||||
ansiState: { ...ANSI_RESET_STATE },
|
||||
cursorRow: 0,
|
||||
cursorCol: 0
|
||||
};
|
||||
}
|
||||
296
apps/miniprogram/pages/terminal/terminalCaretStability.test.ts
Normal file
296
apps/miniprogram/pages/terminal/terminalCaretStability.test.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type TerminalPageOptions = {
|
||||
data?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type TerminalCaretSnapshot = {
|
||||
left: number;
|
||||
top: number;
|
||||
height: number;
|
||||
visible: boolean;
|
||||
cursorRow: number;
|
||||
cursorCol: number;
|
||||
scrollTop: number;
|
||||
rawTop: number;
|
||||
rawLeft: number;
|
||||
rectWidth: number;
|
||||
rectHeight: number;
|
||||
lineHeight: number;
|
||||
charWidth: number;
|
||||
};
|
||||
|
||||
type TerminalPageInstance = TerminalPageOptions & {
|
||||
data: Record<string, unknown>;
|
||||
activeTerminalStdoutTask: Record<string, unknown> | null;
|
||||
terminalStableCaretSnapshot: TerminalCaretSnapshot | null;
|
||||
terminalPendingCaretSnapshot: TerminalCaretSnapshot | null;
|
||||
terminalPendingCaretSince: number;
|
||||
shellLineHeightPx?: number;
|
||||
terminalPerf?: Record<string, unknown> | null;
|
||||
setData: (patch: Record<string, unknown>, callback?: () => void) => void;
|
||||
resolveStableTerminalCaret: (
|
||||
caret: TerminalCaretSnapshot,
|
||||
options?: Record<string, unknown>
|
||||
) => TerminalCaretSnapshot | null;
|
||||
resolveActivationBandFromCaretSnapshot: (
|
||||
rect: Record<string, unknown>,
|
||||
caret: TerminalCaretSnapshot | null,
|
||||
cursorMetrics?: Record<string, unknown> | null
|
||||
) => { top: number; height: number };
|
||||
resetTerminalCaretStabilityState: () => void;
|
||||
syncTerminalOverlay: (options?: Record<string, unknown>, callback?: (perf?: Record<string, unknown>) => void) => void;
|
||||
resolveOutputScrollTopForRect: ReturnType<typeof vi.fn>;
|
||||
getTerminalModes: ReturnType<typeof vi.fn>;
|
||||
shouldLogTerminalPerfFrame: ReturnType<typeof vi.fn>;
|
||||
logTerminalPerf: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
type MiniprogramGlobals = typeof globalThis & {
|
||||
Page?: (options: TerminalPageOptions) => void;
|
||||
wx?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function buildCaretSnapshot(top: number, left = 12): TerminalCaretSnapshot {
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
height: 21,
|
||||
visible: true,
|
||||
cursorRow: Math.max(0, Math.round(top / 21)),
|
||||
cursorCol: Math.max(0, Math.round(left / 9)),
|
||||
scrollTop: 0,
|
||||
rawTop: top,
|
||||
rawLeft: left,
|
||||
rectWidth: 320,
|
||||
rectHeight: 480,
|
||||
lineHeight: 21,
|
||||
charWidth: 9
|
||||
};
|
||||
}
|
||||
|
||||
function createTerminalPageHarness() {
|
||||
const globalState = globalThis as MiniprogramGlobals;
|
||||
let capturedPageOptions: TerminalPageOptions | null = null;
|
||||
const noop = () => {};
|
||||
|
||||
vi.resetModules();
|
||||
delete require.cache[require.resolve("./index.js")];
|
||||
globalState.Page = vi.fn((options: TerminalPageOptions) => {
|
||||
capturedPageOptions = options;
|
||||
});
|
||||
globalState.wx = {
|
||||
env: {
|
||||
USER_DATA_PATH: "/tmp"
|
||||
},
|
||||
getStorageSync: vi.fn(() => undefined),
|
||||
setStorageSync: vi.fn(),
|
||||
removeStorageSync: vi.fn(),
|
||||
getRecorderManager: vi.fn(() => ({
|
||||
onStart: noop,
|
||||
onStop: noop,
|
||||
onError: noop,
|
||||
onFrameRecorded: noop,
|
||||
start: noop,
|
||||
stop: noop
|
||||
})),
|
||||
createInnerAudioContext: vi.fn(() => ({
|
||||
onCanplay: noop,
|
||||
onPlay: noop,
|
||||
onEnded: noop,
|
||||
onStop: noop,
|
||||
onError: noop,
|
||||
stop: noop,
|
||||
destroy: noop
|
||||
})),
|
||||
setInnerAudioOption: vi.fn(),
|
||||
createSelectorQuery: vi.fn(() => ({
|
||||
in: vi.fn(() => ({
|
||||
select: vi.fn(() => ({
|
||||
boundingClientRect: vi.fn(() => ({
|
||||
exec: noop
|
||||
}))
|
||||
}))
|
||||
}))
|
||||
})),
|
||||
nextTick: vi.fn((callback?: () => void) => {
|
||||
callback?.();
|
||||
}),
|
||||
getSystemInfoSync: vi.fn(() => ({})),
|
||||
canIUse: vi.fn(() => false)
|
||||
};
|
||||
|
||||
require("./index.js");
|
||||
|
||||
if (!capturedPageOptions) {
|
||||
throw new Error("terminal page not captured");
|
||||
}
|
||||
|
||||
const captured = capturedPageOptions as TerminalPageOptions;
|
||||
const page = {
|
||||
...captured,
|
||||
data: JSON.parse(JSON.stringify(captured.data || {})) as Record<string, unknown>,
|
||||
activeTerminalStdoutTask: null,
|
||||
terminalStableCaretSnapshot: null,
|
||||
terminalPendingCaretSnapshot: null,
|
||||
terminalPendingCaretSince: 0,
|
||||
shellLineHeightPx: 21,
|
||||
terminalPerf: null,
|
||||
setData(patch: Record<string, unknown>, callback?: () => void) {
|
||||
Object.assign(this.data, patch);
|
||||
callback?.();
|
||||
}
|
||||
} as TerminalPageInstance;
|
||||
|
||||
page.resolveOutputScrollTopForRect = vi.fn(() => 0);
|
||||
page.getTerminalModes = vi.fn(() => ({ cursorHidden: false }));
|
||||
page.shouldLogTerminalPerfFrame = vi.fn(() => false);
|
||||
page.logTerminalPerf = vi.fn();
|
||||
|
||||
return { page };
|
||||
}
|
||||
|
||||
describe("terminal caret stability", () => {
|
||||
const globalState = globalThis as MiniprogramGlobals;
|
||||
const originalPage = globalState.Page;
|
||||
const originalWx = globalState.wx;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
if (originalPage) {
|
||||
globalState.Page = originalPage;
|
||||
} else {
|
||||
delete globalState.Page;
|
||||
}
|
||||
if (originalWx) {
|
||||
globalState.wx = originalWx;
|
||||
} else {
|
||||
delete globalState.wx;
|
||||
}
|
||||
});
|
||||
|
||||
it("stdout in-flight 时,短时间跳到新位置仍保留上一个稳定 caret", () => {
|
||||
let now = 1000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
const { page } = createTerminalPageHarness();
|
||||
page.activeTerminalStdoutTask = {
|
||||
remainingText: "working"
|
||||
};
|
||||
|
||||
const first = page.resolveStableTerminalCaret(buildCaretSnapshot(210), {
|
||||
stabilizeDuringStdout: true
|
||||
});
|
||||
now = 1040;
|
||||
const second = page.resolveStableTerminalCaret(buildCaretSnapshot(252), {
|
||||
stabilizeDuringStdout: true
|
||||
});
|
||||
|
||||
expect(first?.top).toBe(210);
|
||||
expect(second?.top).toBe(210);
|
||||
});
|
||||
|
||||
it("同一位置超过稳定窗口后,会提交新的 caret 位置", () => {
|
||||
let now = 2000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
const { page } = createTerminalPageHarness();
|
||||
page.activeTerminalStdoutTask = {
|
||||
remainingText: "working"
|
||||
};
|
||||
|
||||
page.resolveStableTerminalCaret(buildCaretSnapshot(210), {
|
||||
stabilizeDuringStdout: true
|
||||
});
|
||||
now = 2040;
|
||||
page.resolveStableTerminalCaret(buildCaretSnapshot(252), {
|
||||
stabilizeDuringStdout: true
|
||||
});
|
||||
now = 2180;
|
||||
const stabilized = page.resolveStableTerminalCaret(buildCaretSnapshot(252), {
|
||||
stabilizeDuringStdout: true
|
||||
});
|
||||
|
||||
expect(stabilized?.top).toBe(252);
|
||||
});
|
||||
|
||||
it("最终帧会强制提交最新 caret,不再继续冻结旧位置", () => {
|
||||
let now = 3000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
const { page } = createTerminalPageHarness();
|
||||
page.activeTerminalStdoutTask = {
|
||||
remainingText: "working"
|
||||
};
|
||||
|
||||
page.resolveStableTerminalCaret(buildCaretSnapshot(210), {
|
||||
stabilizeDuringStdout: true
|
||||
});
|
||||
now = 3040;
|
||||
page.resolveStableTerminalCaret(buildCaretSnapshot(252), {
|
||||
stabilizeDuringStdout: true
|
||||
});
|
||||
now = 3060;
|
||||
const finalCaret = page.resolveStableTerminalCaret(buildCaretSnapshot(252), {
|
||||
stabilizeDuringStdout: true,
|
||||
forceCommit: true
|
||||
});
|
||||
|
||||
expect(finalCaret?.top).toBe(252);
|
||||
});
|
||||
|
||||
it("stdout in-flight 时,激活框应跟随稳定 caret,而不是按实时位置单独跳动", () => {
|
||||
let now = 4000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
const { page } = createTerminalPageHarness();
|
||||
page.activeTerminalStdoutTask = {
|
||||
remainingText: "working"
|
||||
};
|
||||
page.data.statusClass = "connected";
|
||||
page.data.activationDebugEnabled = true;
|
||||
page.data.activationDebugVisible = true;
|
||||
page.data.terminalCaretVisible = false;
|
||||
page.data.terminalCaretTopPx = 0;
|
||||
page.data.activationDebugTopPx = 0;
|
||||
page.data.activationDebugHeightPx = 0;
|
||||
|
||||
const rect = {
|
||||
width: 320,
|
||||
height: 480
|
||||
};
|
||||
|
||||
page.syncTerminalOverlay({
|
||||
rect,
|
||||
cursorMetrics: {
|
||||
lineHeight: 21,
|
||||
charWidth: 9,
|
||||
paddingLeft: 12,
|
||||
paddingRight: 8,
|
||||
cursorRow: 10,
|
||||
cursorCol: 1,
|
||||
rows: 20
|
||||
},
|
||||
stabilizeCaretDuringStdout: true
|
||||
});
|
||||
|
||||
expect(page.data.terminalCaretTopPx).toBe(210);
|
||||
expect(page.data.activationDebugTopPx).toBe(168);
|
||||
|
||||
now = 4040;
|
||||
page.syncTerminalOverlay({
|
||||
rect,
|
||||
cursorMetrics: {
|
||||
lineHeight: 21,
|
||||
charWidth: 9,
|
||||
paddingLeft: 12,
|
||||
paddingRight: 8,
|
||||
cursorRow: 12,
|
||||
cursorCol: 1,
|
||||
rows: 20
|
||||
},
|
||||
stabilizeCaretDuringStdout: true
|
||||
});
|
||||
|
||||
expect(page.data.terminalCaretTopPx).toBe(210);
|
||||
expect(page.data.activationDebugTopPx).toBe(168);
|
||||
});
|
||||
});
|
||||
638
apps/miniprogram/pages/terminal/terminalCursorModel.js
Normal file
638
apps/miniprogram/pages/terminal/terminalCursorModel.js
Normal file
@@ -0,0 +1,638 @@
|
||||
/* global module */
|
||||
|
||||
/**
|
||||
* 终端 cell / 光标 / 视觉行的纯函数模型。
|
||||
*
|
||||
* 顶级约束(后续扩展 VT / DEC private mode 时不得回退):
|
||||
* 1. 逻辑光标始终按“列”推进,而不是按字符串长度推进。
|
||||
* 2. 宽字符占 2 列,并在缓冲区中留下一个 continuation 占位格。
|
||||
* 3. 组合字符只附着到前一个 owner cell,不额外推进列。
|
||||
* 4. 视觉行由 JS 统一切分,避免输出展示和光标锚点使用两套换行系统。
|
||||
* 5. 后续若补 alternate screen、scroll region 或更多 CSI/DECSET,也必须继续复用同一套
|
||||
* cell 契约,不能把“私有模式支持”做成字符串级补丁。
|
||||
*/
|
||||
|
||||
function isWideCodePoint(codePoint) {
|
||||
if (!Number.isFinite(codePoint) || codePoint <= 0) return false;
|
||||
return (
|
||||
(codePoint >= 0x1100 && codePoint <= 0x115f) ||
|
||||
(codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
|
||||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
|
||||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
||||
(codePoint >= 0xfe10 && codePoint <= 0xfe6f) ||
|
||||
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
|
||||
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
|
||||
(codePoint >= 0x1f300 && codePoint <= 0x1faff) ||
|
||||
(codePoint >= 0x20000 && codePoint <= 0x3fffd)
|
||||
);
|
||||
}
|
||||
|
||||
function isZeroWidthCodePoint(codePoint) {
|
||||
if (!Number.isFinite(codePoint) || codePoint <= 0) return false;
|
||||
return (
|
||||
(codePoint >= 0x0300 && codePoint <= 0x036f) ||
|
||||
(codePoint >= 0x0483 && codePoint <= 0x0489) ||
|
||||
(codePoint >= 0x0591 && codePoint <= 0x05bd) ||
|
||||
codePoint === 0x05bf ||
|
||||
(codePoint >= 0x05c1 && codePoint <= 0x05c2) ||
|
||||
(codePoint >= 0x05c4 && codePoint <= 0x05c5) ||
|
||||
codePoint === 0x05c7 ||
|
||||
(codePoint >= 0x0610 && codePoint <= 0x061a) ||
|
||||
(codePoint >= 0x064b && codePoint <= 0x065f) ||
|
||||
codePoint === 0x0670 ||
|
||||
(codePoint >= 0x06d6 && codePoint <= 0x06dc) ||
|
||||
(codePoint >= 0x06df && codePoint <= 0x06e4) ||
|
||||
(codePoint >= 0x06e7 && codePoint <= 0x06e8) ||
|
||||
(codePoint >= 0x06ea && codePoint <= 0x06ed) ||
|
||||
(codePoint >= 0x0711 && codePoint <= 0x0711) ||
|
||||
(codePoint >= 0x0730 && codePoint <= 0x074a) ||
|
||||
(codePoint >= 0x07a6 && codePoint <= 0x07b0) ||
|
||||
(codePoint >= 0x07eb && codePoint <= 0x07f3) ||
|
||||
(codePoint >= 0x0816 && codePoint <= 0x0819) ||
|
||||
(codePoint >= 0x081b && codePoint <= 0x0823) ||
|
||||
(codePoint >= 0x0825 && codePoint <= 0x0827) ||
|
||||
(codePoint >= 0x0829 && codePoint <= 0x082d) ||
|
||||
(codePoint >= 0x0859 && codePoint <= 0x085b) ||
|
||||
(codePoint >= 0x08d3 && codePoint <= 0x08e1) ||
|
||||
(codePoint >= 0x08e3 && codePoint <= 0x0903) ||
|
||||
(codePoint >= 0x093a && codePoint <= 0x093c) ||
|
||||
codePoint === 0x0941 ||
|
||||
codePoint === 0x0942 ||
|
||||
(codePoint >= 0x094d && codePoint <= 0x094d) ||
|
||||
(codePoint >= 0x0951 && codePoint <= 0x0957) ||
|
||||
(codePoint >= 0x0962 && codePoint <= 0x0963) ||
|
||||
(codePoint >= 0x0981 && codePoint <= 0x0981) ||
|
||||
(codePoint >= 0x09bc && codePoint <= 0x09bc) ||
|
||||
codePoint === 0x09cd ||
|
||||
(codePoint >= 0x09e2 && codePoint <= 0x09e3) ||
|
||||
codePoint === 0x0a01 ||
|
||||
codePoint === 0x0a02 ||
|
||||
codePoint === 0x0a3c ||
|
||||
codePoint === 0x0a41 ||
|
||||
codePoint === 0x0a42 ||
|
||||
codePoint === 0x0a47 ||
|
||||
codePoint === 0x0a48 ||
|
||||
codePoint === 0x0a4b ||
|
||||
codePoint === 0x0a4c ||
|
||||
codePoint === 0x0a4d ||
|
||||
(codePoint >= 0x0a51 && codePoint <= 0x0a51) ||
|
||||
(codePoint >= 0x0a70 && codePoint <= 0x0a71) ||
|
||||
(codePoint >= 0x0a75 && codePoint <= 0x0a75) ||
|
||||
codePoint === 0x0abc ||
|
||||
codePoint === 0x0ac1 ||
|
||||
codePoint === 0x0ac2 ||
|
||||
codePoint === 0x0acd ||
|
||||
(codePoint >= 0x0ae2 && codePoint <= 0x0ae3) ||
|
||||
(codePoint >= 0x0b01 && codePoint <= 0x0b01) ||
|
||||
codePoint === 0x0b3c ||
|
||||
codePoint === 0x0b3f ||
|
||||
codePoint === 0x0b41 ||
|
||||
codePoint === 0x0b42 ||
|
||||
codePoint === 0x0b4d ||
|
||||
(codePoint >= 0x0b56 && codePoint <= 0x0b56) ||
|
||||
(codePoint >= 0x0b62 && codePoint <= 0x0b63) ||
|
||||
(codePoint >= 0x0b82 && codePoint <= 0x0b82) ||
|
||||
codePoint === 0x0bc0 ||
|
||||
codePoint === 0x0bcd ||
|
||||
codePoint === 0x0c00 ||
|
||||
codePoint === 0x0c04 ||
|
||||
(codePoint >= 0x0c3e && codePoint <= 0x0c40) ||
|
||||
codePoint === 0x0c46 ||
|
||||
codePoint === 0x0c47 ||
|
||||
codePoint === 0x0c4a ||
|
||||
codePoint === 0x0c4b ||
|
||||
codePoint === 0x0c4d ||
|
||||
(codePoint >= 0x0c55 && codePoint <= 0x0c56) ||
|
||||
(codePoint >= 0x0c62 && codePoint <= 0x0c63) ||
|
||||
(codePoint >= 0x0c81 && codePoint <= 0x0c81) ||
|
||||
codePoint === 0x0cbc ||
|
||||
codePoint === 0x0cbf ||
|
||||
codePoint === 0x0cc6 ||
|
||||
codePoint === 0x0ccc ||
|
||||
codePoint === 0x0ccd ||
|
||||
(codePoint >= 0x0ce2 && codePoint <= 0x0ce3) ||
|
||||
codePoint === 0x0d00 ||
|
||||
codePoint === 0x0d01 ||
|
||||
codePoint === 0x0d3b ||
|
||||
codePoint === 0x0d3c ||
|
||||
codePoint === 0x0d41 ||
|
||||
codePoint === 0x0d42 ||
|
||||
codePoint === 0x0d4d ||
|
||||
(codePoint >= 0x0d62 && codePoint <= 0x0d63) ||
|
||||
codePoint === 0x0dca ||
|
||||
(codePoint >= 0x0dd2 && codePoint <= 0x0dd4) ||
|
||||
codePoint === 0x0dd6 ||
|
||||
codePoint === 0x0e31 ||
|
||||
(codePoint >= 0x0e34 && codePoint <= 0x0e3a) ||
|
||||
(codePoint >= 0x0e47 && codePoint <= 0x0e4e) ||
|
||||
codePoint === 0x0eb1 ||
|
||||
(codePoint >= 0x0eb4 && codePoint <= 0x0ebc) ||
|
||||
(codePoint >= 0x0ec8 && codePoint <= 0x0ece) ||
|
||||
codePoint === 0x0f18 ||
|
||||
codePoint === 0x0f19 ||
|
||||
codePoint === 0x0f35 ||
|
||||
codePoint === 0x0f37 ||
|
||||
codePoint === 0x0f39 ||
|
||||
(codePoint >= 0x0f71 && codePoint <= 0x0f7e) ||
|
||||
(codePoint >= 0x0f80 && codePoint <= 0x0f84) ||
|
||||
(codePoint >= 0x0f86 && codePoint <= 0x0f87) ||
|
||||
(codePoint >= 0x0f8d && codePoint <= 0x0f97) ||
|
||||
(codePoint >= 0x0f99 && codePoint <= 0x0fbc) ||
|
||||
codePoint === 0x0fc6 ||
|
||||
(codePoint >= 0x102d && codePoint <= 0x1030) ||
|
||||
codePoint === 0x1032 ||
|
||||
(codePoint >= 0x1036 && codePoint <= 0x1037) ||
|
||||
codePoint === 0x1039 ||
|
||||
codePoint === 0x103a ||
|
||||
(codePoint >= 0x103d && codePoint <= 0x103e) ||
|
||||
(codePoint >= 0x1058 && codePoint <= 0x1059) ||
|
||||
(codePoint >= 0x105e && codePoint <= 0x1060) ||
|
||||
(codePoint >= 0x1071 && codePoint <= 0x1074) ||
|
||||
codePoint === 0x1082 ||
|
||||
(codePoint >= 0x1085 && codePoint <= 0x1086) ||
|
||||
codePoint === 0x108d ||
|
||||
codePoint === 0x109d ||
|
||||
(codePoint >= 0x135d && codePoint <= 0x135f) ||
|
||||
(codePoint >= 0x1712 && codePoint <= 0x1714) ||
|
||||
(codePoint >= 0x1732 && codePoint <= 0x1734) ||
|
||||
(codePoint >= 0x1752 && codePoint <= 0x1753) ||
|
||||
(codePoint >= 0x1772 && codePoint <= 0x1773) ||
|
||||
(codePoint >= 0x17b4 && codePoint <= 0x17b5) ||
|
||||
(codePoint >= 0x17b7 && codePoint <= 0x17bd) ||
|
||||
codePoint === 0x17c6 ||
|
||||
(codePoint >= 0x17c9 && codePoint <= 0x17d3) ||
|
||||
codePoint === 0x17dd ||
|
||||
(codePoint >= 0x180b && codePoint <= 0x180f) ||
|
||||
(codePoint >= 0x1885 && codePoint <= 0x1886) ||
|
||||
codePoint === 0x18a9 ||
|
||||
(codePoint >= 0x1920 && codePoint <= 0x1922) ||
|
||||
(codePoint >= 0x1927 && codePoint <= 0x1928) ||
|
||||
codePoint === 0x1932 ||
|
||||
(codePoint >= 0x1939 && codePoint <= 0x193b) ||
|
||||
(codePoint >= 0x1a17 && codePoint <= 0x1a18) ||
|
||||
codePoint === 0x1a1b ||
|
||||
codePoint === 0x1a56 ||
|
||||
(codePoint >= 0x1a58 && codePoint <= 0x1a5e) ||
|
||||
codePoint === 0x1a60 ||
|
||||
codePoint === 0x1a62 ||
|
||||
(codePoint >= 0x1a65 && codePoint <= 0x1a6c) ||
|
||||
(codePoint >= 0x1a73 && codePoint <= 0x1a7c) ||
|
||||
codePoint === 0x1a7f ||
|
||||
(codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
|
||||
(codePoint >= 0x1b00 && codePoint <= 0x1b03) ||
|
||||
codePoint === 0x1b34 ||
|
||||
codePoint === 0x1b36 ||
|
||||
codePoint === 0x1b37 ||
|
||||
codePoint === 0x1b3c ||
|
||||
codePoint === 0x1b42 ||
|
||||
(codePoint >= 0x1b6b && codePoint <= 0x1b73) ||
|
||||
(codePoint >= 0x1b80 && codePoint <= 0x1b81) ||
|
||||
codePoint === 0x1ba2 ||
|
||||
codePoint === 0x1ba5 ||
|
||||
codePoint === 0x1ba8 ||
|
||||
codePoint === 0x1ba9 ||
|
||||
(codePoint >= 0x1bab && codePoint <= 0x1bad) ||
|
||||
codePoint === 0x1be6 ||
|
||||
codePoint === 0x1be8 ||
|
||||
codePoint === 0x1be9 ||
|
||||
codePoint === 0x1bed ||
|
||||
(codePoint >= 0x1bef && codePoint <= 0x1bf1) ||
|
||||
(codePoint >= 0x1c2c && codePoint <= 0x1c33) ||
|
||||
codePoint === 0x1c36 ||
|
||||
codePoint === 0x1c37 ||
|
||||
(codePoint >= 0x1cd0 && codePoint <= 0x1cd2) ||
|
||||
(codePoint >= 0x1cd4 && codePoint <= 0x1ce0) ||
|
||||
(codePoint >= 0x1ce2 && codePoint <= 0x1ce8) ||
|
||||
codePoint === 0x1ced ||
|
||||
codePoint === 0x1cf4 ||
|
||||
codePoint === 0x1cf8 ||
|
||||
codePoint === 0x1cf9 ||
|
||||
(codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
|
||||
(codePoint >= 0x200b && codePoint <= 0x200f) ||
|
||||
codePoint === 0x202a ||
|
||||
codePoint === 0x202b ||
|
||||
codePoint === 0x202c ||
|
||||
codePoint === 0x202d ||
|
||||
codePoint === 0x202e ||
|
||||
codePoint === 0x2060 ||
|
||||
(codePoint >= 0x2066 && codePoint <= 0x206f) ||
|
||||
codePoint === 0x200c ||
|
||||
codePoint === 0x200d ||
|
||||
(codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
|
||||
(codePoint >= 0x2cef && codePoint <= 0x2cf1) ||
|
||||
codePoint === 0x2d7f ||
|
||||
(codePoint >= 0x2de0 && codePoint <= 0x2dff) ||
|
||||
(codePoint >= 0x302a && codePoint <= 0x302f) ||
|
||||
codePoint === 0x3099 ||
|
||||
codePoint === 0x309a ||
|
||||
(codePoint >= 0xa66f && codePoint <= 0xa672) ||
|
||||
codePoint === 0xa674 ||
|
||||
codePoint === 0xa67d ||
|
||||
codePoint === 0xa69e ||
|
||||
codePoint === 0xa69f ||
|
||||
(codePoint >= 0xa6f0 && codePoint <= 0xa6f1) ||
|
||||
codePoint === 0xa802 ||
|
||||
codePoint === 0xa806 ||
|
||||
codePoint === 0xa80b ||
|
||||
(codePoint >= 0xa825 && codePoint <= 0xa826) ||
|
||||
codePoint === 0xa82c ||
|
||||
(codePoint >= 0xa8c4 && codePoint <= 0xa8c5) ||
|
||||
(codePoint >= 0xa8e0 && codePoint <= 0xa8f1) ||
|
||||
codePoint === 0xa8ff ||
|
||||
(codePoint >= 0xa926 && codePoint <= 0xa92d) ||
|
||||
(codePoint >= 0xa947 && codePoint <= 0xa951) ||
|
||||
(codePoint >= 0xa980 && codePoint <= 0xa982) ||
|
||||
codePoint === 0xa9b3 ||
|
||||
(codePoint >= 0xa9b6 && codePoint <= 0xa9b9) ||
|
||||
codePoint === 0xa9bc ||
|
||||
codePoint === 0xa9e5 ||
|
||||
(codePoint >= 0xaa29 && codePoint <= 0xaa2e) ||
|
||||
(codePoint >= 0xaa31 && codePoint <= 0xaa32) ||
|
||||
(codePoint >= 0xaa35 && codePoint <= 0xaa36) ||
|
||||
codePoint === 0xaa43 ||
|
||||
codePoint === 0xaa4c ||
|
||||
codePoint === 0xaa7c ||
|
||||
codePoint === 0xaab0 ||
|
||||
(codePoint >= 0xaab2 && codePoint <= 0xaab4) ||
|
||||
(codePoint >= 0xaab7 && codePoint <= 0xaab8) ||
|
||||
codePoint === 0xaabe ||
|
||||
codePoint === 0xaabf ||
|
||||
codePoint === 0xaac1 ||
|
||||
(codePoint >= 0xaaec && codePoint <= 0xaaed) ||
|
||||
codePoint === 0xaaf6 ||
|
||||
(codePoint >= 0xabe5 && codePoint <= 0xabe5) ||
|
||||
codePoint === 0xabe8 ||
|
||||
codePoint === 0xabed ||
|
||||
codePoint === 0xfb1e ||
|
||||
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
|
||||
(codePoint >= 0xfe20 && codePoint <= 0xfe2f) ||
|
||||
(codePoint >= 0xfeff && codePoint <= 0xfeff) ||
|
||||
(codePoint >= 0xfff9 && codePoint <= 0xfffb) ||
|
||||
(codePoint >= 0x101fd && codePoint <= 0x101fd) ||
|
||||
(codePoint >= 0x102e0 && codePoint <= 0x102e0) ||
|
||||
(codePoint >= 0x10376 && codePoint <= 0x1037a) ||
|
||||
(codePoint >= 0x10a01 && codePoint <= 0x10a03) ||
|
||||
(codePoint >= 0x10a05 && codePoint <= 0x10a06) ||
|
||||
(codePoint >= 0x10a0c && codePoint <= 0x10a0f) ||
|
||||
(codePoint >= 0x10a38 && codePoint <= 0x10a3a) ||
|
||||
codePoint === 0x10a3f ||
|
||||
(codePoint >= 0x10ae5 && codePoint <= 0x10ae6) ||
|
||||
(codePoint >= 0x11001 && codePoint <= 0x11001) ||
|
||||
(codePoint >= 0x11038 && codePoint <= 0x11046) ||
|
||||
(codePoint >= 0x1107f && codePoint <= 0x11081) ||
|
||||
(codePoint >= 0x110b3 && codePoint <= 0x110b6) ||
|
||||
(codePoint >= 0x110b9 && codePoint <= 0x110ba) ||
|
||||
(codePoint >= 0x11100 && codePoint <= 0x11102) ||
|
||||
(codePoint >= 0x11127 && codePoint <= 0x1112b) ||
|
||||
(codePoint >= 0x1112d && codePoint <= 0x11134) ||
|
||||
codePoint === 0x11173 ||
|
||||
(codePoint >= 0x11180 && codePoint <= 0x11181) ||
|
||||
(codePoint >= 0x111b6 && codePoint <= 0x111be) ||
|
||||
codePoint === 0x111c9 ||
|
||||
(codePoint >= 0x1122f && codePoint <= 0x11231) ||
|
||||
codePoint === 0x11234 ||
|
||||
(codePoint >= 0x11236 && codePoint <= 0x11237) ||
|
||||
codePoint === 0x112df ||
|
||||
(codePoint >= 0x112e3 && codePoint <= 0x112ea) ||
|
||||
(codePoint >= 0x11300 && codePoint <= 0x11301) ||
|
||||
codePoint === 0x1133c ||
|
||||
(codePoint >= 0x11340 && codePoint <= 0x11340) ||
|
||||
codePoint === 0x11366 ||
|
||||
codePoint === 0x11367 ||
|
||||
codePoint === 0x1136c ||
|
||||
codePoint === 0x11370 ||
|
||||
(codePoint >= 0x11438 && codePoint <= 0x1143f) ||
|
||||
(codePoint >= 0x11442 && codePoint <= 0x11444) ||
|
||||
codePoint === 0x11446 ||
|
||||
codePoint === 0x1145e ||
|
||||
(codePoint >= 0x114b3 && codePoint <= 0x114b8) ||
|
||||
codePoint === 0x114ba ||
|
||||
(codePoint >= 0x114bf && codePoint <= 0x114c0) ||
|
||||
codePoint === 0x114c2 ||
|
||||
(codePoint >= 0x115b2 && codePoint <= 0x115b5) ||
|
||||
(codePoint >= 0x115bc && codePoint <= 0x115bd) ||
|
||||
codePoint === 0x115bf ||
|
||||
codePoint === 0x115c0 ||
|
||||
(codePoint >= 0x11633 && codePoint <= 0x1163a) ||
|
||||
codePoint === 0x1163d ||
|
||||
codePoint === 0x1163f ||
|
||||
codePoint === 0x11640 ||
|
||||
(codePoint >= 0x116ab && codePoint <= 0x116ab) ||
|
||||
codePoint === 0x116ad ||
|
||||
(codePoint >= 0x116b0 && codePoint <= 0x116b5) ||
|
||||
codePoint === 0x116b7 ||
|
||||
(codePoint >= 0x1171d && codePoint <= 0x1171f) ||
|
||||
(codePoint >= 0x11722 && codePoint <= 0x11725) ||
|
||||
(codePoint >= 0x11727 && codePoint <= 0x1172b) ||
|
||||
(codePoint >= 0x1182f && codePoint <= 0x11837) ||
|
||||
codePoint === 0x11839 ||
|
||||
codePoint === 0x11a01 ||
|
||||
(codePoint >= 0x11a33 && codePoint <= 0x11a38) ||
|
||||
(codePoint >= 0x11a3b && codePoint <= 0x11a3e) ||
|
||||
codePoint === 0x11a47 ||
|
||||
(codePoint >= 0x11a51 && codePoint <= 0x11a56) ||
|
||||
codePoint === 0x11a59 ||
|
||||
codePoint === 0x11a5b ||
|
||||
codePoint === 0x11a8a ||
|
||||
(codePoint >= 0x11a91 && codePoint <= 0x11a96) ||
|
||||
codePoint === 0x11a98 ||
|
||||
codePoint === 0x11c30 ||
|
||||
(codePoint >= 0x11c38 && codePoint <= 0x11c3d) ||
|
||||
codePoint === 0x11c3f ||
|
||||
(codePoint >= 0x11c92 && codePoint <= 0x11ca7) ||
|
||||
codePoint === 0x11caa ||
|
||||
codePoint === 0x11cb0 ||
|
||||
codePoint === 0x11cb2 ||
|
||||
codePoint === 0x11cb3 ||
|
||||
codePoint === 0x11cb5 ||
|
||||
codePoint === 0x11cb6 ||
|
||||
(codePoint >= 0x11d31 && codePoint <= 0x11d36) ||
|
||||
codePoint === 0x11d3a ||
|
||||
codePoint === 0x11d3c ||
|
||||
codePoint === 0x11d3d ||
|
||||
codePoint === 0x11d3f ||
|
||||
codePoint === 0x11d40 ||
|
||||
codePoint === 0x11d42 ||
|
||||
(codePoint >= 0x11d44 && codePoint <= 0x11d45) ||
|
||||
codePoint === 0x11d47 ||
|
||||
(codePoint >= 0x16af0 && codePoint <= 0x16af4) ||
|
||||
(codePoint >= 0x16b30 && codePoint <= 0x16b36) ||
|
||||
(codePoint >= 0x16f8f && codePoint <= 0x16f92) ||
|
||||
(codePoint >= 0x1bc9d && codePoint <= 0x1bc9e) ||
|
||||
codePoint === 0x1d167 ||
|
||||
codePoint === 0x1d168 ||
|
||||
codePoint === 0x1d169 ||
|
||||
(codePoint >= 0x1d17b && codePoint <= 0x1d182) ||
|
||||
(codePoint >= 0x1d185 && codePoint <= 0x1d18b) ||
|
||||
(codePoint >= 0x1d1aa && codePoint <= 0x1d1ad) ||
|
||||
(codePoint >= 0x1d242 && codePoint <= 0x1d244) ||
|
||||
(codePoint >= 0x1da00 && codePoint <= 0x1da36) ||
|
||||
(codePoint >= 0x1da3b && codePoint <= 0x1da6c) ||
|
||||
codePoint === 0x1da75 ||
|
||||
codePoint === 0x1da84 ||
|
||||
(codePoint >= 0x1da9b && codePoint <= 0x1da9f) ||
|
||||
(codePoint >= 0x1daa1 && codePoint <= 0x1daaf) ||
|
||||
(codePoint >= 0x1e000 && codePoint <= 0x1e006) ||
|
||||
(codePoint >= 0x1e008 && codePoint <= 0x1e018) ||
|
||||
(codePoint >= 0x1e01b && codePoint <= 0x1e021) ||
|
||||
(codePoint >= 0x1e023 && codePoint <= 0x1e024) ||
|
||||
(codePoint >= 0x1e026 && codePoint <= 0x1e02a) ||
|
||||
(codePoint >= 0x1e130 && codePoint <= 0x1e136) ||
|
||||
(codePoint >= 0x1e2ae && codePoint <= 0x1e2ae) ||
|
||||
(codePoint >= 0x1e2ec && codePoint <= 0x1e2ef) ||
|
||||
(codePoint >= 0x1e8d0 && codePoint <= 0x1e8d6) ||
|
||||
(codePoint >= 0x1e944 && codePoint <= 0x1e94a) ||
|
||||
(codePoint >= 0xe0100 && codePoint <= 0xe01ef)
|
||||
);
|
||||
}
|
||||
|
||||
function measureCharDisplayColumns(ch) {
|
||||
const text = String(ch || "");
|
||||
if (!text) return 0;
|
||||
if (text === "\t") return 4;
|
||||
const codePoint = text.codePointAt(0);
|
||||
if (!Number.isFinite(codePoint)) return 1;
|
||||
if (codePoint <= 0x1f || codePoint === 0x7f) return 0;
|
||||
if (isZeroWidthCodePoint(codePoint)) return 0;
|
||||
return isWideCodePoint(codePoint) ? 2 : 1;
|
||||
}
|
||||
|
||||
function createTerminalCell(text, style, width) {
|
||||
return {
|
||||
text: String(text || ""),
|
||||
style: style || null,
|
||||
width: Math.max(0, Math.min(2, Math.round(Number(width) || 0))),
|
||||
continuation: false,
|
||||
placeholder: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 擦除/补位产生的“空白单元”需要占据一列,但不应在文本快照里永久变成尾随空格。
|
||||
* 因此这里单独打上 placeholder 标记,供渲染层保留宽度,文本层按需裁剪。
|
||||
*/
|
||||
function createBlankCell(style) {
|
||||
return {
|
||||
text: "",
|
||||
style: style || null,
|
||||
width: 1,
|
||||
continuation: false,
|
||||
placeholder: true
|
||||
};
|
||||
}
|
||||
|
||||
function createContinuationCell(style) {
|
||||
return {
|
||||
text: "",
|
||||
style: style || null,
|
||||
width: 0,
|
||||
continuation: true,
|
||||
placeholder: false
|
||||
};
|
||||
}
|
||||
|
||||
function cloneTerminalCell(cell) {
|
||||
return {
|
||||
text: String((cell && cell.text) || ""),
|
||||
style: cell && cell.style ? { ...cell.style } : null,
|
||||
width: Math.max(0, Math.min(2, Math.round(Number(cell && cell.width) || 0))),
|
||||
continuation: !!(cell && cell.continuation),
|
||||
placeholder: !!(cell && cell.placeholder)
|
||||
};
|
||||
}
|
||||
|
||||
function lineCellsToText(lineCells) {
|
||||
const cells = Array.isArray(lineCells) ? lineCells : [];
|
||||
if (cells.length === 0) return "";
|
||||
let lastMeaningfulIndex = -1;
|
||||
for (let index = cells.length - 1; index >= 0; index -= 1) {
|
||||
const cell = cells[index];
|
||||
const width = Math.max(0, Math.min(2, Math.round(Number(cell && cell.width) || 0)));
|
||||
if (width <= 0 || (cell && cell.continuation)) {
|
||||
continue;
|
||||
}
|
||||
if (cell && cell.placeholder) {
|
||||
continue;
|
||||
}
|
||||
lastMeaningfulIndex = index;
|
||||
break;
|
||||
}
|
||||
if (lastMeaningfulIndex < 0) {
|
||||
return "";
|
||||
}
|
||||
let result = "";
|
||||
for (let index = 0; index <= lastMeaningfulIndex; index += 1) {
|
||||
const cell = cells[index];
|
||||
const width = Math.max(0, Math.min(2, Math.round(Number(cell && cell.width) || 0)));
|
||||
if (width <= 0 || (cell && cell.continuation)) {
|
||||
continue;
|
||||
}
|
||||
if (cell && cell.placeholder) {
|
||||
result += " ";
|
||||
continue;
|
||||
}
|
||||
result += String((cell && cell.text) || "");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function measureLineCellsDisplayColumns(lineCells) {
|
||||
const cells = Array.isArray(lineCells) ? lineCells : [];
|
||||
if (cells.length === 0) return 0;
|
||||
let columns = 0;
|
||||
for (let i = 0; i < cells.length; i += 1) {
|
||||
const cell = cells[i];
|
||||
const width = Math.max(0, Math.round(Number(cell && cell.width) || 0));
|
||||
if (width > 0) {
|
||||
columns += width;
|
||||
}
|
||||
}
|
||||
return Math.max(0, columns);
|
||||
}
|
||||
|
||||
function buildTerminalStyleSignature(style) {
|
||||
const source = style || null;
|
||||
if (!source) return "||0|0";
|
||||
return `${source.fg || ""}|${source.bg || ""}|${source.bold ? 1 : 0}|${source.underline ? 1 : 0}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 若一整行的所有 render run 都共享同一个非空背景色,
|
||||
* 则允许把背景提升到 line 容器层绘制,避免 text 行盒之间露出底色细缝。
|
||||
*
|
||||
* 约束:
|
||||
* 1. 只在“整行统一背景”时返回颜色;
|
||||
* 2. 一旦某个 run 没有背景或背景不同,立即回退为空串,保持旧语义。
|
||||
*/
|
||||
function resolveUniformLineBackground(runs) {
|
||||
const source = Array.isArray(runs) ? runs : [];
|
||||
let background = "";
|
||||
let hasBackground = false;
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
const run = source[index];
|
||||
if (!run || Math.max(0, Math.round(Number(run.columns) || 0)) <= 0) {
|
||||
continue;
|
||||
}
|
||||
const runBackground = String((run.style && run.style.bg) || "");
|
||||
if (!runBackground) {
|
||||
return "";
|
||||
}
|
||||
if (!hasBackground) {
|
||||
background = runBackground;
|
||||
hasBackground = true;
|
||||
continue;
|
||||
}
|
||||
if (runBackground !== background) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return hasBackground ? background : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一行固定列缓冲区转换成更适合 UI 渲染的 run:
|
||||
* 1. continuation 占位格不再直接落回自然文本流;
|
||||
* 2. 宽字符 owner 独立成 fixed run,供视图层按 2 列宽渲染;
|
||||
* 3. 连续的窄字符仍按样式合并,避免节点数量爆炸。
|
||||
*/
|
||||
function buildLineCellRenderRuns(lineCells) {
|
||||
const cells = Array.isArray(lineCells) ? lineCells : [];
|
||||
if (cells.length === 0) return [];
|
||||
|
||||
const runs = [];
|
||||
let pendingTextRun = null;
|
||||
let pendingBlankRun = null;
|
||||
|
||||
const flushPendingTextRun = () => {
|
||||
if (!pendingTextRun) return;
|
||||
runs.push({
|
||||
text: pendingTextRun.text,
|
||||
style: pendingTextRun.style ? { ...pendingTextRun.style } : null,
|
||||
columns: pendingTextRun.columns,
|
||||
fixed: false
|
||||
});
|
||||
pendingTextRun = null;
|
||||
};
|
||||
|
||||
const flushPendingBlankRun = () => {
|
||||
if (!pendingBlankRun) return;
|
||||
runs.push({
|
||||
text: "",
|
||||
style: pendingBlankRun.style ? { ...pendingBlankRun.style } : null,
|
||||
columns: pendingBlankRun.columns,
|
||||
fixed: true
|
||||
});
|
||||
pendingBlankRun = null;
|
||||
};
|
||||
|
||||
for (let i = 0; i < cells.length; i += 1) {
|
||||
const cell = cells[i];
|
||||
const width = Math.max(0, Math.min(2, Math.round(Number(cell && cell.width) || 0)));
|
||||
if (width <= 0 || (cell && cell.continuation)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = String((cell && cell.text) || "");
|
||||
const style = cell && cell.style ? cell.style : null;
|
||||
const styleKey = buildTerminalStyleSignature(style);
|
||||
const isPlaceholder = !!(cell && cell.placeholder);
|
||||
|
||||
if (isPlaceholder) {
|
||||
flushPendingTextRun();
|
||||
if (!pendingBlankRun || pendingBlankRun.styleKey !== styleKey) {
|
||||
flushPendingBlankRun();
|
||||
pendingBlankRun = {
|
||||
style,
|
||||
styleKey,
|
||||
columns: width
|
||||
};
|
||||
continue;
|
||||
}
|
||||
pendingBlankRun.columns += width;
|
||||
continue;
|
||||
}
|
||||
|
||||
flushPendingBlankRun();
|
||||
|
||||
if (width > 1) {
|
||||
flushPendingTextRun();
|
||||
runs.push({
|
||||
text,
|
||||
style: style ? { ...style } : null,
|
||||
columns: width,
|
||||
fixed: true
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!pendingTextRun || pendingTextRun.styleKey !== styleKey) {
|
||||
flushPendingTextRun();
|
||||
pendingTextRun = {
|
||||
text,
|
||||
style,
|
||||
styleKey,
|
||||
columns: 1
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
pendingTextRun.text += text;
|
||||
pendingTextRun.columns += 1;
|
||||
}
|
||||
|
||||
flushPendingTextRun();
|
||||
flushPendingBlankRun();
|
||||
return runs;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildLineCellRenderRuns,
|
||||
createBlankCell,
|
||||
cloneTerminalCell,
|
||||
createContinuationCell,
|
||||
createTerminalCell,
|
||||
lineCellsToText,
|
||||
measureCharDisplayColumns,
|
||||
measureLineCellsDisplayColumns,
|
||||
resolveUniformLineBackground
|
||||
};
|
||||
111
apps/miniprogram/pages/terminal/terminalCursorModel.test.ts
Normal file
111
apps/miniprogram/pages/terminal/terminalCursorModel.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const {
|
||||
buildLineCellRenderRuns,
|
||||
createBlankCell,
|
||||
cloneTerminalCell,
|
||||
createContinuationCell,
|
||||
createTerminalCell,
|
||||
lineCellsToText,
|
||||
measureCharDisplayColumns,
|
||||
measureLineCellsDisplayColumns,
|
||||
resolveUniformLineBackground
|
||||
} = require("./terminalCursorModel.js");
|
||||
|
||||
describe("terminalCursorModel", () => {
|
||||
it("按 cell 列宽处理宽字符和组合字符", () => {
|
||||
expect(measureCharDisplayColumns("a")).toBe(1);
|
||||
expect(measureCharDisplayColumns("中")).toBe(2);
|
||||
expect(measureCharDisplayColumns("\u0301")).toBe(0);
|
||||
expect(measureCharDisplayColumns("😀")).toBe(2);
|
||||
});
|
||||
|
||||
it("按固定列缓冲区统计显示列宽,不把 continuation 重复计数", () => {
|
||||
const row = [
|
||||
createTerminalCell("A", null, 1),
|
||||
createTerminalCell("中", null, 2),
|
||||
createContinuationCell(null),
|
||||
createTerminalCell("B", null, 1)
|
||||
];
|
||||
|
||||
expect(lineCellsToText(row)).toBe("A中B");
|
||||
expect(measureLineCellsDisplayColumns(row)).toBe(4);
|
||||
});
|
||||
|
||||
it("渲染 run 会把宽字符 owner 独立出来,并跳过 continuation 占位格", () => {
|
||||
const styleA = { fg: "#fff", bg: "", bold: false, underline: false };
|
||||
const styleB = { fg: "#0f0", bg: "", bold: false, underline: false };
|
||||
const row = [
|
||||
createTerminalCell("A", styleA, 1),
|
||||
createTerminalCell("B", styleA, 1),
|
||||
createTerminalCell("中", styleA, 2),
|
||||
createContinuationCell(styleA),
|
||||
createTerminalCell("C", styleB, 1)
|
||||
];
|
||||
|
||||
expect(buildLineCellRenderRuns(row)).toEqual([
|
||||
{ text: "AB", style: styleA, columns: 2, fixed: false },
|
||||
{ text: "中", style: styleA, columns: 2, fixed: true },
|
||||
{ text: "C", style: styleB, columns: 1, fixed: false }
|
||||
]);
|
||||
});
|
||||
|
||||
it("placeholder blank cell 会保留列宽背景,但不会把尾随补位写进文本快照", () => {
|
||||
const style = { fg: "", bg: "#666666", bold: false, underline: false };
|
||||
const row = [
|
||||
createBlankCell(style),
|
||||
createBlankCell(style),
|
||||
createTerminalCell("X", style, 1),
|
||||
createBlankCell(style),
|
||||
createBlankCell(style)
|
||||
];
|
||||
|
||||
expect(lineCellsToText(row)).toBe(" X");
|
||||
expect(buildLineCellRenderRuns(row)).toEqual([
|
||||
{ text: "", style, columns: 2, fixed: true },
|
||||
{ text: "X", style, columns: 1, fixed: false },
|
||||
{ text: "", style, columns: 2, fixed: true }
|
||||
]);
|
||||
});
|
||||
|
||||
it("整行 run 若共享同一个非空背景色,会返回可提升到 line 层的背景", () => {
|
||||
const bg = "#203040";
|
||||
const styleA = { fg: "#ffffff", bg, bold: false, underline: false };
|
||||
const styleB = { fg: "#89c2ff", bg, bold: true, underline: false };
|
||||
const row = [
|
||||
createBlankCell(styleA),
|
||||
createTerminalCell(">", styleA, 1),
|
||||
createTerminalCell(" ", styleA, 1),
|
||||
createTerminalCell("U", styleB, 1),
|
||||
createTerminalCell("s", styleB, 1),
|
||||
createTerminalCell("e", styleB, 1),
|
||||
createBlankCell(styleB)
|
||||
];
|
||||
|
||||
expect(resolveUniformLineBackground(buildLineCellRenderRuns(row))).toBe(bg);
|
||||
});
|
||||
|
||||
it("只要一行里存在无背景或不同背景的 run,就不会提升到 line 层", () => {
|
||||
const rowWithGap = [
|
||||
createTerminalCell("A", { fg: "#fff", bg: "#111111", bold: false, underline: false }, 1),
|
||||
createTerminalCell("B", { fg: "#fff", bg: "", bold: false, underline: false }, 1)
|
||||
];
|
||||
const rowWithMixedBg = [
|
||||
createTerminalCell("A", { fg: "#fff", bg: "#111111", bold: false, underline: false }, 1),
|
||||
createTerminalCell("B", { fg: "#fff", bg: "#222222", bold: false, underline: false }, 1)
|
||||
];
|
||||
|
||||
expect(resolveUniformLineBackground(buildLineCellRenderRuns(rowWithGap))).toBe("");
|
||||
expect(resolveUniformLineBackground(buildLineCellRenderRuns(rowWithMixedBg))).toBe("");
|
||||
});
|
||||
|
||||
it("克隆 cell 时会复制样式对象,避免覆盖时污染原对象", () => {
|
||||
const original = createTerminalCell("A", { fg: "#fff", bold: true }, 1);
|
||||
const cloned = cloneTerminalCell(original);
|
||||
|
||||
cloned.style.fg = "#000";
|
||||
expect(original.style.fg).toBe("#fff");
|
||||
expect(cloned.style.fg).toBe("#000");
|
||||
expect(cloned.width).toBe(1);
|
||||
});
|
||||
});
|
||||
220
apps/miniprogram/pages/terminal/terminalKeyEncoder.js
Normal file
220
apps/miniprogram/pages/terminal/terminalKeyEncoder.js
Normal file
@@ -0,0 +1,220 @@
|
||||
/* global module */
|
||||
|
||||
const DEFAULT_TERMINAL_KEY_MODES = Object.freeze({
|
||||
applicationCursorKeys: false,
|
||||
applicationKeypad: false,
|
||||
bracketedPasteMode: false
|
||||
});
|
||||
|
||||
const DEFAULT_TERMINAL_KEY_MODIFIERS = Object.freeze({
|
||||
shift: false
|
||||
});
|
||||
|
||||
const CTRL_KEY_MAP = Object.freeze({
|
||||
ctrla: "\u0001",
|
||||
ctrlc: "\u0003",
|
||||
ctrld: "\u0004",
|
||||
ctrle: "\u0005",
|
||||
ctrlk: "\u000b",
|
||||
ctrll: "\u000c",
|
||||
ctrlu: "\u0015",
|
||||
ctrlw: "\u0017",
|
||||
ctrlz: "\u001a"
|
||||
});
|
||||
|
||||
/**
|
||||
* 触屏方向区直接对应 Figma frame 2250 内部的四向布局。
|
||||
* 这里保留静态资源路径作为单一真相源,页面层可按主题把路径再映射为 data URI。
|
||||
*/
|
||||
const TERMINAL_TOUCH_DIRECTION_KEYS = Object.freeze([
|
||||
Object.freeze({
|
||||
key: "up",
|
||||
icon: "/assets/icons/up.svg",
|
||||
slotClass: "terminal-touch-direction-btn-up"
|
||||
}),
|
||||
Object.freeze({
|
||||
key: "left",
|
||||
icon: "/assets/icons/left.svg",
|
||||
slotClass: "terminal-touch-direction-btn-left"
|
||||
}),
|
||||
Object.freeze({
|
||||
key: "down",
|
||||
icon: "/assets/icons/down.svg",
|
||||
slotClass: "terminal-touch-direction-btn-down"
|
||||
}),
|
||||
Object.freeze({
|
||||
key: "right",
|
||||
icon: "/assets/icons/right.svg",
|
||||
slotClass: "terminal-touch-direction-btn-right"
|
||||
})
|
||||
]);
|
||||
|
||||
/**
|
||||
* SH 键盘区只保留最常用的辅助键:
|
||||
* 1. 仅保留移动端高频键,`home` 复用为“回到服务器工作目录”快捷键;
|
||||
* 2. Figma 这一版左列示意用了 backspace/shift,带 backspace 图标的按钮发送真实退格序列;
|
||||
* 3. 页面层会把 shift 当成“输入框大写状态键”,这里仅保留图标和基础 key;
|
||||
* 4. 方向键承担基础导航,纵向操作区保留 enter/home/shift/backspace/paste/esc/ctrl+c/tab;
|
||||
* 5. paste 走独立剪贴板链路,home 走页面层 shell 命令,其余按钮统一映射为 VT 控制序列。
|
||||
*/
|
||||
const TERMINAL_TOUCH_ACTION_BUTTONS = Object.freeze([
|
||||
Object.freeze({
|
||||
key: "enter",
|
||||
icon: "/assets/icons/enter.svg",
|
||||
action: "control",
|
||||
slotClass: "terminal-touch-action-btn-enter"
|
||||
}),
|
||||
Object.freeze({
|
||||
key: "home",
|
||||
icon: "/assets/icons/home.svg",
|
||||
action: "control",
|
||||
slotClass: "terminal-touch-action-btn-home"
|
||||
}),
|
||||
Object.freeze({
|
||||
key: "shift",
|
||||
icon: "/assets/icons/shift.svg",
|
||||
action: "modifier",
|
||||
slotClass: "terminal-touch-action-btn-shift"
|
||||
}),
|
||||
Object.freeze({
|
||||
key: "backspace",
|
||||
icon: "/assets/icons/backspace.svg",
|
||||
action: "control",
|
||||
slotClass: "terminal-touch-action-btn-delete"
|
||||
}),
|
||||
Object.freeze({
|
||||
key: "paste",
|
||||
icon: "/assets/icons/paste.svg",
|
||||
action: "paste",
|
||||
slotClass: "terminal-touch-action-btn-paste"
|
||||
}),
|
||||
Object.freeze({
|
||||
key: "esc",
|
||||
icon: "/assets/icons/esc.svg",
|
||||
action: "control",
|
||||
slotClass: "terminal-touch-action-btn-esc"
|
||||
}),
|
||||
Object.freeze({
|
||||
key: "ctrlc",
|
||||
icon: "/assets/icons/ctrlc.svg",
|
||||
action: "control",
|
||||
slotClass: "terminal-touch-action-btn-ctrlc"
|
||||
}),
|
||||
Object.freeze({
|
||||
key: "tab",
|
||||
icon: "/assets/icons/tab.svg",
|
||||
action: "control",
|
||||
slotClass: "terminal-touch-action-btn-tab"
|
||||
})
|
||||
]);
|
||||
|
||||
function normalizeTerminalKeyModes(input) {
|
||||
const source = input && typeof input === "object" ? input : null;
|
||||
return {
|
||||
applicationCursorKeys:
|
||||
source && source.applicationCursorKeys !== undefined
|
||||
? !!source.applicationCursorKeys
|
||||
: DEFAULT_TERMINAL_KEY_MODES.applicationCursorKeys,
|
||||
applicationKeypad:
|
||||
source && source.applicationKeypad !== undefined
|
||||
? !!source.applicationKeypad
|
||||
: DEFAULT_TERMINAL_KEY_MODES.applicationKeypad,
|
||||
bracketedPasteMode:
|
||||
source && source.bracketedPasteMode !== undefined
|
||||
? !!source.bracketedPasteMode
|
||||
: DEFAULT_TERMINAL_KEY_MODES.bracketedPasteMode
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTerminalKeyModifiers(input) {
|
||||
const source = input && typeof input === "object" ? input : null;
|
||||
return {
|
||||
shift: source && source.shift !== undefined ? !!source.shift : DEFAULT_TERMINAL_KEY_MODIFIERS.shift
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Alt/Meta 在终端里通常表现为“额外前置一个 ESC”,
|
||||
* 因此这里把组合键统一编码成 `ESC + 基础键序列`。
|
||||
*/
|
||||
function encodeTerminalAltSequence(normalizedKey, modes) {
|
||||
const match = /^(alt|meta)(?:[+:-]|_)?(.+)$/.exec(normalizedKey);
|
||||
if (!match) return "";
|
||||
const nestedKey = String(match[2] || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!nestedKey) return "";
|
||||
if (nestedKey.length === 1) {
|
||||
return `\u001b${nestedKey}`;
|
||||
}
|
||||
const nestedSequence = encodeTerminalKey(nestedKey, modes);
|
||||
return nestedSequence ? `\u001b${nestedSequence}` : "";
|
||||
}
|
||||
|
||||
function encodeShiftModifiedKey(normalizedKey) {
|
||||
if (normalizedKey === "tab") return "\u001b[Z";
|
||||
if (normalizedKey === "up") return "\u001b[1;2A";
|
||||
if (normalizedKey === "down") return "\u001b[1;2B";
|
||||
if (normalizedKey === "right") return "\u001b[1;2C";
|
||||
if (normalizedKey === "left") return "\u001b[1;2D";
|
||||
if (normalizedKey === "home") return "\u001b[1;2H";
|
||||
if (normalizedKey === "end") return "\u001b[1;2F";
|
||||
if (normalizedKey === "insert") return "\u001b[2;2~";
|
||||
if (normalizedKey === "delete") return "\u001b[3;2~";
|
||||
if (normalizedKey === "pageup") return "\u001b[5;2~";
|
||||
if (normalizedKey === "pagedown") return "\u001b[6;2~";
|
||||
return "";
|
||||
}
|
||||
|
||||
function encodeTerminalKey(key, modes, modifiers) {
|
||||
const normalizedKey = String(key || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const normalizedModes = normalizeTerminalKeyModes(modes);
|
||||
const normalizedModifiers = normalizeTerminalKeyModifiers(modifiers);
|
||||
const applicationPrefix = normalizedModes.applicationCursorKeys ? "\u001bO" : "\u001b[";
|
||||
const altSequence = encodeTerminalAltSequence(normalizedKey, normalizedModes);
|
||||
if (altSequence) return altSequence;
|
||||
if (normalizedModifiers.shift) {
|
||||
const shiftedSequence = encodeShiftModifiedKey(normalizedKey);
|
||||
if (shiftedSequence) return shiftedSequence;
|
||||
}
|
||||
|
||||
if (normalizedKey === "up") return `${applicationPrefix}A`;
|
||||
if (normalizedKey === "down") return `${applicationPrefix}B`;
|
||||
if (normalizedKey === "right") return `${applicationPrefix}C`;
|
||||
if (normalizedKey === "left") return `${applicationPrefix}D`;
|
||||
if (normalizedKey === "home") return `${applicationPrefix}H`;
|
||||
if (normalizedKey === "end") return `${applicationPrefix}F`;
|
||||
if (normalizedKey === "enter") return "\r";
|
||||
if (normalizedKey === "tab") return "\t";
|
||||
if (normalizedKey === "esc") return "\u001b";
|
||||
if (normalizedKey === "backspace") return "\u007f";
|
||||
if (normalizedKey === "delete") return "\u001b[3~";
|
||||
if (normalizedKey === "insert") return "\u001b[2~";
|
||||
if (normalizedKey === "pageup") return "\u001b[5~";
|
||||
if (normalizedKey === "pagedown") return "\u001b[6~";
|
||||
if (CTRL_KEY_MAP[normalizedKey]) return CTRL_KEY_MAP[normalizedKey];
|
||||
return "";
|
||||
}
|
||||
|
||||
function encodeTerminalPaste(text, modes) {
|
||||
const value = String(text || "");
|
||||
if (!value) return "";
|
||||
const normalizedModes = normalizeTerminalKeyModes(modes);
|
||||
if (!normalizedModes.bracketedPasteMode) {
|
||||
return value;
|
||||
}
|
||||
return `\u001b[200~${value}\u001b[201~`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_TERMINAL_KEY_MODIFIERS,
|
||||
DEFAULT_TERMINAL_KEY_MODES,
|
||||
TERMINAL_TOUCH_DIRECTION_KEYS,
|
||||
TERMINAL_TOUCH_ACTION_BUTTONS,
|
||||
encodeTerminalKey,
|
||||
encodeTerminalPaste,
|
||||
normalizeTerminalKeyModifiers,
|
||||
normalizeTerminalKeyModes
|
||||
};
|
||||
82
apps/miniprogram/pages/terminal/terminalKeyEncoder.test.ts
Normal file
82
apps/miniprogram/pages/terminal/terminalKeyEncoder.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const {
|
||||
TERMINAL_TOUCH_ACTION_BUTTONS,
|
||||
TERMINAL_TOUCH_DIRECTION_KEYS,
|
||||
encodeTerminalKey,
|
||||
encodeTerminalPaste,
|
||||
normalizeTerminalKeyModes
|
||||
} = require("./terminalKeyEncoder.js");
|
||||
|
||||
describe("terminalKeyEncoder", () => {
|
||||
it("方向键和 Home/End 会按 application cursor mode 切换编码", () => {
|
||||
expect(encodeTerminalKey("up", { applicationCursorKeys: false })).toBe("\u001b[A");
|
||||
expect(encodeTerminalKey("up", { applicationCursorKeys: true })).toBe("\u001bOA");
|
||||
expect(encodeTerminalKey("home", { applicationCursorKeys: false })).toBe("\u001b[H");
|
||||
expect(encodeTerminalKey("home", { applicationCursorKeys: true })).toBe("\u001bOH");
|
||||
});
|
||||
|
||||
it("常用编辑键和 Ctrl 组合会编码成 VT 控制序列", () => {
|
||||
expect(encodeTerminalKey("esc")).toBe("\u001b");
|
||||
expect(encodeTerminalKey("backspace")).toBe("\u007f");
|
||||
expect(encodeTerminalKey("delete")).toBe("\u001b[3~");
|
||||
expect(encodeTerminalKey("insert")).toBe("\u001b[2~");
|
||||
expect(encodeTerminalKey("pageup")).toBe("\u001b[5~");
|
||||
expect(encodeTerminalKey("ctrla")).toBe("\u0001");
|
||||
expect(encodeTerminalKey("ctrlc")).toBe("\u0003");
|
||||
expect(encodeTerminalKey("ctrle")).toBe("\u0005");
|
||||
expect(encodeTerminalKey("ctrlw")).toBe("\u0017");
|
||||
expect(encodeTerminalKey("ctrlz")).toBe("\u001a");
|
||||
});
|
||||
|
||||
it("Alt/Meta 组合会编码为 ESC 前缀加基础键序列", () => {
|
||||
expect(encodeTerminalKey("alt-a")).toBe("\u001ba");
|
||||
expect(encodeTerminalKey("meta-z")).toBe("\u001bz");
|
||||
expect(encodeTerminalKey("alt-up", { applicationCursorKeys: false })).toBe("\u001b\u001b[A");
|
||||
expect(encodeTerminalKey("meta-home", { applicationCursorKeys: true })).toBe("\u001b\u001bOH");
|
||||
});
|
||||
|
||||
it("Shift 修饰键会编码常用的反向 tab 和方向键序列", () => {
|
||||
expect(encodeTerminalKey("tab", undefined, { shift: true })).toBe("\u001b[Z");
|
||||
expect(encodeTerminalKey("up", undefined, { shift: true })).toBe("\u001b[1;2A");
|
||||
expect(encodeTerminalKey("right", undefined, { shift: true })).toBe("\u001b[1;2C");
|
||||
expect(encodeTerminalKey("delete", undefined, { shift: true })).toBe("\u001b[3;2~");
|
||||
});
|
||||
|
||||
it("开启 bracketed paste 后,粘贴文本会自动包裹 2004 序列", () => {
|
||||
expect(encodeTerminalPaste("hello", { bracketedPasteMode: false })).toBe("hello");
|
||||
expect(encodeTerminalPaste("hello", { bracketedPasteMode: true })).toBe("\u001b[200~hello\u001b[201~");
|
||||
});
|
||||
|
||||
it("模式位归一化会补齐默认值", () => {
|
||||
expect(normalizeTerminalKeyModes({ applicationCursorKeys: true })).toEqual({
|
||||
applicationCursorKeys: true,
|
||||
applicationKeypad: false,
|
||||
bracketedPasteMode: false
|
||||
});
|
||||
});
|
||||
|
||||
it("触屏键盘区配置符合 SH 精简集", () => {
|
||||
expect(TERMINAL_TOUCH_DIRECTION_KEYS.map((item) => item.key)).toEqual(["up", "left", "down", "right"]);
|
||||
expect(TERMINAL_TOUCH_ACTION_BUTTONS.map((item) => item.key)).toEqual([
|
||||
"enter",
|
||||
"home",
|
||||
"shift",
|
||||
"backspace",
|
||||
"paste",
|
||||
"esc",
|
||||
"ctrlc",
|
||||
"tab"
|
||||
]);
|
||||
expect(TERMINAL_TOUCH_ACTION_BUTTONS.find((item) => item.key === "paste")?.action).toBe("paste");
|
||||
expect(TERMINAL_TOUCH_ACTION_BUTTONS.find((item) => item.key === "backspace")?.icon).toBe(
|
||||
"/assets/icons/backspace.svg"
|
||||
);
|
||||
expect(TERMINAL_TOUCH_ACTION_BUTTONS.find((item) => item.key === "shift")?.icon).toBe(
|
||||
"/assets/icons/shift.svg"
|
||||
);
|
||||
expect(TERMINAL_TOUCH_ACTION_BUTTONS.find((item) => item.key === "home")?.icon).toBe(
|
||||
"/assets/icons/home.svg"
|
||||
);
|
||||
});
|
||||
});
|
||||
500
apps/miniprogram/pages/terminal/terminalLayoutReuse.test.ts
Normal file
500
apps/miniprogram/pages/terminal/terminalLayoutReuse.test.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
ANSI_RESET_STATE,
|
||||
cloneAnsiState,
|
||||
createEmptyTerminalBufferState
|
||||
} = require("./terminalBufferState.js");
|
||||
|
||||
type TerminalPageOptions = {
|
||||
data?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type TerminalPageInstance = TerminalPageOptions & {
|
||||
data: Record<string, unknown>;
|
||||
outputCursorRow: number;
|
||||
outputCursorCol: number;
|
||||
outputCells: unknown[][];
|
||||
outputReplayText: string;
|
||||
outputReplayBytes: number;
|
||||
outputAnsiState: Record<string, unknown>;
|
||||
outputTerminalState: Record<string, unknown>;
|
||||
terminalCols: number;
|
||||
terminalRows: number;
|
||||
terminalBufferMaxEntries: number;
|
||||
terminalBufferMaxBytes: number;
|
||||
setData: (patch: Record<string, unknown>, callback?: () => void) => void;
|
||||
applyTerminalBufferState: (
|
||||
state: Record<string, unknown>,
|
||||
runtimeOptions?: Record<string, unknown>
|
||||
) => Record<string, unknown>;
|
||||
applyTerminalBufferRuntimeState: (
|
||||
state: Record<string, unknown>,
|
||||
runtimeOptions?: Record<string, unknown>
|
||||
) => Record<string, unknown>;
|
||||
syncTerminalReplayBuffer: (cleanText: string) => void;
|
||||
createQueuedTerminalOutputTask: (request: Record<string, unknown>) => Record<string, any>;
|
||||
applyQueuedTerminalOutputBatch: (request: Record<string, unknown>) => Record<string, any>;
|
||||
refreshOutputLayout: (options: Record<string, unknown>, callback?: (viewState: unknown) => void) => void;
|
||||
queryOutputRect: ReturnType<typeof vi.fn>;
|
||||
buildTerminalLayoutState: ReturnType<typeof vi.fn>;
|
||||
runAfterTerminalLayout: ReturnType<typeof vi.fn>;
|
||||
shouldLogTerminalPerfFrame: ReturnType<typeof vi.fn>;
|
||||
logTerminalPerf: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
type MiniprogramGlobals = typeof globalThis & {
|
||||
Page?: (options: TerminalPageOptions) => void;
|
||||
wx?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function createTerminalPageHarness() {
|
||||
const globalState = globalThis as MiniprogramGlobals;
|
||||
let capturedPageOptions: TerminalPageOptions | null = null;
|
||||
const noop = () => {};
|
||||
|
||||
vi.resetModules();
|
||||
delete require.cache[require.resolve("./index.js")];
|
||||
globalState.Page = vi.fn((options: TerminalPageOptions) => {
|
||||
capturedPageOptions = options;
|
||||
});
|
||||
globalState.wx = {
|
||||
env: {
|
||||
USER_DATA_PATH: "/tmp"
|
||||
},
|
||||
getStorageSync: vi.fn(() => undefined),
|
||||
setStorageSync: vi.fn(),
|
||||
removeStorageSync: vi.fn(),
|
||||
getRecorderManager: vi.fn(() => ({
|
||||
onStart: noop,
|
||||
onStop: noop,
|
||||
onError: noop,
|
||||
onFrameRecorded: noop,
|
||||
start: noop,
|
||||
stop: noop
|
||||
})),
|
||||
createInnerAudioContext: vi.fn(() => ({
|
||||
onCanplay: noop,
|
||||
onPlay: noop,
|
||||
onEnded: noop,
|
||||
onStop: noop,
|
||||
onError: noop,
|
||||
stop: noop,
|
||||
destroy: noop
|
||||
})),
|
||||
setInnerAudioOption: vi.fn(),
|
||||
createSelectorQuery: vi.fn(() => ({
|
||||
in: vi.fn(() => ({
|
||||
select: vi.fn(() => ({
|
||||
boundingClientRect: vi.fn(() => ({
|
||||
exec: noop
|
||||
}))
|
||||
}))
|
||||
}))
|
||||
})),
|
||||
nextTick: vi.fn((callback?: () => void) => {
|
||||
callback?.();
|
||||
}),
|
||||
getSystemInfoSync: vi.fn(() => ({})),
|
||||
canIUse: vi.fn(() => false)
|
||||
};
|
||||
|
||||
require("./index.js");
|
||||
|
||||
if (!capturedPageOptions) {
|
||||
throw new Error("terminal page not captured");
|
||||
}
|
||||
|
||||
const captured = capturedPageOptions as TerminalPageOptions;
|
||||
const page = {
|
||||
...captured,
|
||||
data: JSON.parse(JSON.stringify(captured.data || {})) as Record<string, unknown>,
|
||||
setData(patch: Record<string, unknown>, callback?: () => void) {
|
||||
Object.assign(this.data, patch);
|
||||
callback?.();
|
||||
}
|
||||
} as TerminalPageInstance;
|
||||
|
||||
return { page };
|
||||
}
|
||||
|
||||
function initTerminalPageOutputRuntime(page: TerminalPageInstance) {
|
||||
page.terminalCols = 80;
|
||||
page.terminalRows = 24;
|
||||
page.terminalBufferMaxEntries = 5000;
|
||||
page.terminalBufferMaxBytes = 4 * 1024 * 1024;
|
||||
page.outputCursorRow = 0;
|
||||
page.outputCursorCol = 0;
|
||||
page.outputCells = [[]];
|
||||
page.outputReplayText = "";
|
||||
page.outputReplayBytes = 0;
|
||||
page.outputAnsiState = cloneAnsiState(ANSI_RESET_STATE);
|
||||
page.outputTerminalState = createEmptyTerminalBufferState({
|
||||
bufferCols: page.terminalCols,
|
||||
bufferRows: page.terminalRows
|
||||
});
|
||||
page.applyTerminalBufferState(page.outputTerminalState);
|
||||
}
|
||||
|
||||
describe("terminal layout rect reuse", () => {
|
||||
const globalState = globalThis as MiniprogramGlobals;
|
||||
const originalPage = globalState.Page;
|
||||
const originalWx = globalState.wx;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
vi.resetModules();
|
||||
if (originalPage) {
|
||||
globalState.Page = originalPage;
|
||||
} else {
|
||||
delete globalState.Page;
|
||||
}
|
||||
if (originalWx) {
|
||||
globalState.wx = originalWx;
|
||||
} else {
|
||||
delete globalState.wx;
|
||||
}
|
||||
});
|
||||
|
||||
it("stdout 连续 slice 复用 rect 时不应重复查询输出区几何", () => {
|
||||
const { page } = createTerminalPageHarness();
|
||||
const cachedRect = {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 320,
|
||||
bottom: 480,
|
||||
width: 320,
|
||||
height: 480
|
||||
};
|
||||
page.queryOutputRect = vi.fn();
|
||||
page.buildTerminalLayoutState = vi.fn(() => ({
|
||||
lineHeight: 21,
|
||||
charWidth: 9,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 8,
|
||||
renderLines: [{ lineStyle: "", segments: [] }],
|
||||
renderStartRow: 24,
|
||||
renderEndRow: 25,
|
||||
contentRowCount: 120,
|
||||
topSpacerHeight: 504,
|
||||
bottomSpacerHeight: 1995,
|
||||
keyboardInsetHeight: 0,
|
||||
maxScrollTop: 2079,
|
||||
nextScrollTop: 0,
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
cols: 33,
|
||||
rows: 22,
|
||||
rect: cachedRect
|
||||
}));
|
||||
page.runAfterTerminalLayout = vi.fn((callback?: () => void) => {
|
||||
callback?.();
|
||||
});
|
||||
page.shouldLogTerminalPerfFrame = vi.fn(() => false);
|
||||
page.logTerminalPerf = vi.fn();
|
||||
|
||||
const done = vi.fn();
|
||||
page.refreshOutputLayout(
|
||||
{
|
||||
rect: cachedRect,
|
||||
reuseRect: true,
|
||||
skipPostLayoutRectQuery: true
|
||||
},
|
||||
done
|
||||
);
|
||||
|
||||
expect(page.queryOutputRect).not.toHaveBeenCalled();
|
||||
expect(page.buildTerminalLayoutState).toHaveBeenCalledWith(
|
||||
cachedRect,
|
||||
expect.objectContaining({
|
||||
rect: cachedRect,
|
||||
reuseRect: true,
|
||||
skipPostLayoutRectQuery: true
|
||||
})
|
||||
);
|
||||
expect(page.data.outputRenderLines).toEqual([{ lineStyle: "", segments: [] }]);
|
||||
expect(page.data.outputTopSpacerPx).toBe(504);
|
||||
expect(page.data.outputBottomSpacerPx).toBe(1995);
|
||||
expect(done).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rect: cachedRect
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("滚动补刷窗口时不应回写 outputScrollTop,避免打断手势滚动", () => {
|
||||
const { page } = createTerminalPageHarness();
|
||||
const cachedRect = {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 320,
|
||||
bottom: 480,
|
||||
width: 320,
|
||||
height: 480
|
||||
};
|
||||
const patches: Record<string, unknown>[] = [];
|
||||
const originalSetData = page.setData;
|
||||
page.setData = function setDataWithSpy(patch: Record<string, unknown>, callback?: () => void) {
|
||||
patches.push({ ...patch });
|
||||
originalSetData.call(this, patch, callback);
|
||||
};
|
||||
page.currentOutputScrollTop = 960;
|
||||
page.queryOutputRect = vi.fn();
|
||||
page.buildTerminalLayoutState = vi.fn(() => ({
|
||||
lineHeight: 21,
|
||||
charWidth: 9,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 8,
|
||||
renderLines: [{ lineStyle: "", segments: [] }],
|
||||
renderStartRow: 40,
|
||||
renderEndRow: 80,
|
||||
contentRowCount: 200,
|
||||
topSpacerHeight: 840,
|
||||
bottomSpacerHeight: 2520,
|
||||
keyboardInsetHeight: 0,
|
||||
maxScrollTop: 3129,
|
||||
nextScrollTop: 960,
|
||||
cursorRow: 50,
|
||||
cursorCol: 0,
|
||||
cols: 33,
|
||||
rows: 22,
|
||||
rect: cachedRect
|
||||
}));
|
||||
page.runAfterTerminalLayout = vi.fn((callback?: () => void) => {
|
||||
callback?.();
|
||||
});
|
||||
page.shouldLogTerminalPerfFrame = vi.fn(() => false);
|
||||
page.logTerminalPerf = vi.fn();
|
||||
|
||||
page.refreshOutputLayout({
|
||||
rect: cachedRect,
|
||||
reuseRect: true,
|
||||
skipPostLayoutRectQuery: true,
|
||||
preserveScrollTop: true
|
||||
});
|
||||
|
||||
expect(patches[0]).not.toHaveProperty("outputScrollTop");
|
||||
expect(page.currentOutputScrollTop).toBe(960);
|
||||
});
|
||||
|
||||
it("scroll 过程中不应立刻同步 overlay,而是走节流定时器", () => {
|
||||
vi.useFakeTimers();
|
||||
const { page } = createTerminalPageHarness();
|
||||
page.outputRectSnapshot = {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 320,
|
||||
bottom: 480,
|
||||
width: 320,
|
||||
height: 480
|
||||
};
|
||||
page.syncTerminalOverlay = vi.fn();
|
||||
page.refreshOutputLayout = vi.fn();
|
||||
|
||||
page.onOutputScroll({
|
||||
detail: {
|
||||
scrollTop: 480
|
||||
}
|
||||
});
|
||||
|
||||
expect(page.syncTerminalOverlay).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(31);
|
||||
expect(page.syncTerminalOverlay).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(page.syncTerminalOverlay).toHaveBeenCalledTimes(1);
|
||||
expect(page.refreshOutputLayout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("接近窗口边缘时会在滚动中提前补刷正文窗口", () => {
|
||||
vi.useFakeTimers();
|
||||
const { page } = createTerminalPageHarness();
|
||||
const cachedRect = {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 320,
|
||||
bottom: 480,
|
||||
width: 320,
|
||||
height: 480
|
||||
};
|
||||
page.outputRectSnapshot = cachedRect;
|
||||
page.outputViewportWindow = {
|
||||
renderStartRow: 40,
|
||||
renderEndRow: 100,
|
||||
contentRowCount: 200,
|
||||
lineHeight: 20,
|
||||
visibleRows: 20
|
||||
};
|
||||
page.syncTerminalOverlay = vi.fn();
|
||||
page.refreshOutputLayout = vi.fn(
|
||||
(options: Record<string, unknown>, callback?: (viewState: unknown) => void) => {
|
||||
callback?.({
|
||||
rect: cachedRect,
|
||||
lineHeight: 20,
|
||||
charWidth: 9,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 8,
|
||||
cursorRow: 80,
|
||||
cursorCol: 0,
|
||||
rows: 20
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
page.onOutputScroll({
|
||||
detail: {
|
||||
scrollTop: 1500
|
||||
}
|
||||
});
|
||||
|
||||
expect(page.refreshOutputLayout).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(15);
|
||||
expect(page.refreshOutputLayout).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(page.refreshOutputLayout).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
preserveScrollTop: true,
|
||||
scrollViewport: true,
|
||||
rect: cachedRect,
|
||||
reuseRect: true,
|
||||
skipPostLayoutRectQuery: true
|
||||
}),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it("可视区已经落进顶部 spacer 时会立刻补刷正文,避免先看到空白", () => {
|
||||
vi.useFakeTimers();
|
||||
const { page } = createTerminalPageHarness();
|
||||
const cachedRect = {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 320,
|
||||
bottom: 480,
|
||||
width: 320,
|
||||
height: 480
|
||||
};
|
||||
page.outputRectSnapshot = cachedRect;
|
||||
page.outputViewportWindow = {
|
||||
renderStartRow: 40,
|
||||
renderEndRow: 100,
|
||||
contentRowCount: 200,
|
||||
lineHeight: 20,
|
||||
visibleRows: 20
|
||||
};
|
||||
page.syncTerminalOverlay = vi.fn();
|
||||
page.refreshOutputLayout = vi.fn(
|
||||
(options: Record<string, unknown>, callback?: (viewState: unknown) => void) => {
|
||||
callback?.({
|
||||
rect: cachedRect,
|
||||
lineHeight: 20,
|
||||
charWidth: 9,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 8,
|
||||
cursorRow: 40,
|
||||
cursorCol: 0,
|
||||
rows: 20
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
page.onOutputScroll({
|
||||
detail: {
|
||||
scrollTop: 300
|
||||
}
|
||||
});
|
||||
|
||||
expect(page.refreshOutputLayout).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
preserveScrollTop: true,
|
||||
scrollViewport: true,
|
||||
rect: cachedRect,
|
||||
reuseRect: true,
|
||||
skipPostLayoutRectQuery: true
|
||||
}),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it("stdout defer slice 只更新运行态,不应立刻同步 replay 文本和可视 rows", () => {
|
||||
let now = 1000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => {
|
||||
now += 4;
|
||||
return now;
|
||||
});
|
||||
const { page } = createTerminalPageHarness();
|
||||
initTerminalPageOutputRuntime(page);
|
||||
const syncReplaySpy = vi.spyOn(page, "syncTerminalReplayBuffer");
|
||||
const applyStateSpy = vi.spyOn(page, "applyTerminalBufferState");
|
||||
const applyRuntimeSpy = vi.spyOn(page, "applyTerminalBufferRuntimeState");
|
||||
const text = "a".repeat(10 * 1024);
|
||||
const request = {
|
||||
options: {},
|
||||
stdoutSamples: [
|
||||
{
|
||||
text,
|
||||
appendStartedAt: 1,
|
||||
visibleBytes: text.length,
|
||||
visibleFrameCount: 1
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
request.stdoutTask = page.createQueuedTerminalOutputTask(request);
|
||||
request.stdoutTask.lastRenderCompletedAt = Date.now();
|
||||
|
||||
const result = page.applyQueuedTerminalOutputBatch(request);
|
||||
|
||||
expect(result.shouldRender).toBe(false);
|
||||
expect(syncReplaySpy).not.toHaveBeenCalled();
|
||||
expect(applyStateSpy).not.toHaveBeenCalled();
|
||||
expect(applyRuntimeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(page.outputReplayText).toBe("");
|
||||
expect(page.outputReplayBytes).toBe(0);
|
||||
expect(request.stdoutTask.pendingReplayBytes).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("stdout 真正 render 时会一次性提交 defer 期间累积的 replay 文本", () => {
|
||||
let now = 2000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => {
|
||||
now += 4;
|
||||
return now;
|
||||
});
|
||||
const { page } = createTerminalPageHarness();
|
||||
initTerminalPageOutputRuntime(page);
|
||||
const syncReplaySpy = vi.spyOn(page, "syncTerminalReplayBuffer");
|
||||
const applyStateSpy = vi.spyOn(page, "applyTerminalBufferState");
|
||||
const text = "a".repeat(10 * 1024);
|
||||
const request = {
|
||||
options: {},
|
||||
stdoutSamples: [
|
||||
{
|
||||
text,
|
||||
appendStartedAt: 1,
|
||||
visibleBytes: text.length,
|
||||
visibleFrameCount: 1
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
request.stdoutTask = page.createQueuedTerminalOutputTask(request);
|
||||
request.stdoutTask.lastRenderCompletedAt = Date.now();
|
||||
|
||||
const deferred = page.applyQueuedTerminalOutputBatch(request);
|
||||
expect(deferred.shouldRender).toBe(false);
|
||||
|
||||
request.stdoutTask.slicesSinceLastRender = 7;
|
||||
request.stdoutTask.lastRenderCompletedAt = Date.now() - 1000;
|
||||
|
||||
const rendered = page.applyQueuedTerminalOutputBatch(request);
|
||||
|
||||
expect(rendered.shouldRender).toBe(true);
|
||||
expect(syncReplaySpy).toHaveBeenCalledTimes(1);
|
||||
expect(syncReplaySpy).toHaveBeenCalledWith("a".repeat(2048));
|
||||
expect(applyStateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(page.outputReplayBytes).toBe(2048);
|
||||
expect(request.stdoutTask.pendingReplayBytes).toBe(0);
|
||||
expect(request.stdoutTask.pendingReplayText).toBe("");
|
||||
});
|
||||
});
|
||||
238
apps/miniprogram/pages/terminal/terminalPerfLogBuffer.js
Normal file
238
apps/miniprogram/pages/terminal/terminalPerfLogBuffer.js
Normal file
@@ -0,0 +1,238 @@
|
||||
/* global module, setTimeout, clearTimeout */
|
||||
|
||||
const PERF_SCORE_KEYS = [
|
||||
"totalCostMs",
|
||||
"costMs",
|
||||
"driftMs",
|
||||
"queueWaitMs",
|
||||
"schedulerWaitMs",
|
||||
"cloneCostMs",
|
||||
"setDataCostMs",
|
||||
"layoutCostMs",
|
||||
"overlayCostMs",
|
||||
"applyCostMs",
|
||||
"trimCostMs",
|
||||
"stateApplyCostMs",
|
||||
"buildCostMs",
|
||||
"renderBuildCostMs",
|
||||
"queryCostMs",
|
||||
"postLayoutCostMs",
|
||||
"waitMs",
|
||||
"batchWaitMs"
|
||||
];
|
||||
|
||||
function pickPerfScore(record) {
|
||||
const source = record && typeof record === "object" ? record : {};
|
||||
let scoreMs = 0;
|
||||
for (let index = 0; index < PERF_SCORE_KEYS.length; index += 1) {
|
||||
const key = PERF_SCORE_KEYS[index];
|
||||
const value = Number(source[key]);
|
||||
if (Number.isFinite(value) && value > scoreMs) {
|
||||
scoreMs = value;
|
||||
}
|
||||
}
|
||||
return scoreMs;
|
||||
}
|
||||
|
||||
function buildCompactRecord(record, scoreMs) {
|
||||
const source = record && typeof record === "object" ? record : {};
|
||||
const compact = {
|
||||
event: String(source.event || ""),
|
||||
scoreMs
|
||||
};
|
||||
for (let index = 0; index < PERF_SCORE_KEYS.length; index += 1) {
|
||||
const key = PERF_SCORE_KEYS[index];
|
||||
const value = Number(source[key]);
|
||||
if (Number.isFinite(value) && value > 0) {
|
||||
compact[key] = value;
|
||||
}
|
||||
}
|
||||
if (source.renderReason) {
|
||||
compact.renderReason = String(source.renderReason);
|
||||
}
|
||||
if (source.lastRenderDecisionReason) {
|
||||
compact.lastRenderDecisionReason = String(source.lastRenderDecisionReason);
|
||||
}
|
||||
if (source.lastRenderDecisionPolicy) {
|
||||
compact.lastRenderDecisionPolicy = String(source.lastRenderDecisionPolicy);
|
||||
}
|
||||
if (source.suspectedBottleneck) {
|
||||
compact.suspectedBottleneck = String(source.suspectedBottleneck);
|
||||
}
|
||||
if (Number.isFinite(Number(source.pendingStdoutSamples))) {
|
||||
compact.pendingStdoutSamples = Number(source.pendingStdoutSamples);
|
||||
}
|
||||
if (Number.isFinite(Number(source.pendingStdoutBytes))) {
|
||||
compact.pendingStdoutBytes = Number(source.pendingStdoutBytes);
|
||||
}
|
||||
if (Number.isFinite(Number(source.activeStdoutAgeMs))) {
|
||||
compact.activeStdoutAgeMs = Number(source.activeStdoutAgeMs);
|
||||
}
|
||||
if (Number.isFinite(Number(source.activeStdoutBytes))) {
|
||||
compact.activeStdoutBytes = Number(source.activeStdoutBytes);
|
||||
}
|
||||
if (Number.isFinite(Number(source.remainingBytes))) {
|
||||
compact.remainingBytes = Number(source.remainingBytes);
|
||||
}
|
||||
if (Number.isFinite(Number(source.sliceCount))) {
|
||||
compact.sliceCount = Number(source.sliceCount);
|
||||
}
|
||||
if (Number.isFinite(Number(source.chunkCount))) {
|
||||
compact.chunkCount = Number(source.chunkCount);
|
||||
}
|
||||
if (Number.isFinite(Number(source.renderRowCount))) {
|
||||
compact.renderRowCount = Number(source.renderRowCount);
|
||||
}
|
||||
if (Number.isFinite(Number(source.renderPassCount))) {
|
||||
compact.renderPassCount = Number(source.renderPassCount);
|
||||
}
|
||||
if (Number.isFinite(Number(source.layoutPassCount))) {
|
||||
compact.layoutPassCount = Number(source.layoutPassCount);
|
||||
}
|
||||
if (Number.isFinite(Number(source.overlayPassCount))) {
|
||||
compact.overlayPassCount = Number(source.overlayPassCount);
|
||||
}
|
||||
if (Number.isFinite(Number(source.deferredRenderPassCount))) {
|
||||
compact.deferredRenderPassCount = Number(source.deferredRenderPassCount);
|
||||
}
|
||||
if (Number.isFinite(Number(source.skippedOverlayPassCount))) {
|
||||
compact.skippedOverlayPassCount = Number(source.skippedOverlayPassCount);
|
||||
}
|
||||
if (Number.isFinite(Number(source.activeRowCount))) {
|
||||
compact.activeRowCount = Number(source.activeRowCount);
|
||||
}
|
||||
if (Number.isFinite(Number(source.activeCellCount))) {
|
||||
compact.activeCellCount = Number(source.activeCellCount);
|
||||
}
|
||||
if (Number.isFinite(Number(source.totalCellCount))) {
|
||||
compact.totalCellCount = Number(source.totalCellCount);
|
||||
}
|
||||
if (Number.isFinite(Number(source.layoutSeq))) {
|
||||
compact.layoutSeq = Number(source.layoutSeq);
|
||||
}
|
||||
if (Number.isFinite(Number(source.overlaySeq))) {
|
||||
compact.overlaySeq = Number(source.overlaySeq);
|
||||
}
|
||||
return compact;
|
||||
}
|
||||
|
||||
function buildTopEvents(eventCounts) {
|
||||
return Object.entries(eventCounts || {})
|
||||
.sort((left, right) => {
|
||||
if (right[1] !== left[1]) {
|
||||
return right[1] - left[1];
|
||||
}
|
||||
return String(left[0]).localeCompare(String(right[0]));
|
||||
})
|
||||
.slice(0, 5)
|
||||
.map(([event, count]) => ({ event, count }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 终端 perf 日志默认按窗口聚合:
|
||||
* 1. 高频 stdout / layout / overlay 事件只在窗口结束时输出 1 条摘要;
|
||||
* 2. 摘要保留“最常见事件 + 最慢事件 + 最新事件”,便于复盘卡顿;
|
||||
* 3. 这样既能在真机上抓现场,又不会让 console 自身成为性能噪声。
|
||||
*/
|
||||
function createTerminalPerfLogBuffer(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 write = typeof config.write === "function" ? config.write : null;
|
||||
const windowMs =
|
||||
Number.isFinite(Number(config.windowMs)) && Number(config.windowMs) >= 1000
|
||||
? Math.round(Number(config.windowMs))
|
||||
: 5000;
|
||||
|
||||
if (!write) {
|
||||
throw new TypeError("terminal perf log buffer 缺少 write");
|
||||
}
|
||||
|
||||
let flushTimer = null;
|
||||
let bucket = null;
|
||||
|
||||
function clearFlushTimer() {
|
||||
if (!flushTimer) {
|
||||
return;
|
||||
}
|
||||
clearTimer(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
|
||||
function ensureBucket(record) {
|
||||
if (bucket) {
|
||||
return bucket;
|
||||
}
|
||||
const startedAt = Number(record && record.at) || now();
|
||||
bucket = {
|
||||
startedAt,
|
||||
latestAt: startedAt,
|
||||
latestSinceLoadMs: Number(record && record.sinceLoadMs) || 0,
|
||||
latestStatus: String((record && record.status) || ""),
|
||||
count: 0,
|
||||
eventCounts: {},
|
||||
slowest: null,
|
||||
latest: null
|
||||
};
|
||||
flushTimer = setTimer(() => {
|
||||
flush("interval");
|
||||
}, windowMs);
|
||||
return bucket;
|
||||
}
|
||||
|
||||
function push(record) {
|
||||
const source = record && typeof record === "object" ? record : {};
|
||||
const scoreMs = pickPerfScore(source);
|
||||
const activeBucket = ensureBucket(source);
|
||||
const event = String(source.event || "unknown");
|
||||
activeBucket.count += 1;
|
||||
activeBucket.eventCounts[event] = (activeBucket.eventCounts[event] || 0) + 1;
|
||||
activeBucket.latestAt = Number(source.at) || now();
|
||||
activeBucket.latestSinceLoadMs = Number(source.sinceLoadMs) || activeBucket.latestSinceLoadMs;
|
||||
activeBucket.latestStatus = String(source.status || activeBucket.latestStatus || "");
|
||||
activeBucket.latest = buildCompactRecord(source, scoreMs);
|
||||
if (!activeBucket.slowest || scoreMs >= Number(activeBucket.slowest.scoreMs || 0)) {
|
||||
activeBucket.slowest = buildCompactRecord(source, scoreMs);
|
||||
}
|
||||
}
|
||||
|
||||
function flush(reason) {
|
||||
if (!bucket) {
|
||||
return null;
|
||||
}
|
||||
clearFlushTimer();
|
||||
const endedAt = now();
|
||||
const summary = {
|
||||
event: "perf.summary",
|
||||
reason: String(reason || "manual"),
|
||||
at: endedAt,
|
||||
sinceLoadMs: bucket.latestSinceLoadMs,
|
||||
status: bucket.latestStatus,
|
||||
windowMs: Math.max(0, endedAt - bucket.startedAt),
|
||||
count: bucket.count,
|
||||
topEvents: buildTopEvents(bucket.eventCounts),
|
||||
slowest: bucket.slowest,
|
||||
latest: bucket.latest
|
||||
};
|
||||
bucket = null;
|
||||
write(summary);
|
||||
return summary;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
clearFlushTimer();
|
||||
bucket = null;
|
||||
}
|
||||
|
||||
return {
|
||||
push,
|
||||
flush,
|
||||
clear
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createTerminalPerfLogBuffer,
|
||||
pickPerfScore
|
||||
};
|
||||
107
apps/miniprogram/pages/terminal/terminalPerfLogBuffer.test.ts
Normal file
107
apps/miniprogram/pages/terminal/terminalPerfLogBuffer.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { createTerminalPerfLogBuffer, pickPerfScore } = require("./terminalPerfLogBuffer.js");
|
||||
|
||||
describe("terminalPerfLogBuffer", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("会从常见耗时字段中选出最大的 score", () => {
|
||||
expect(
|
||||
pickPerfScore({
|
||||
totalCostMs: 120,
|
||||
setDataCostMs: 320,
|
||||
overlayCostMs: 180
|
||||
})
|
||||
).toBe(320);
|
||||
expect(
|
||||
pickPerfScore({
|
||||
applyCostMs: 220,
|
||||
trimCostMs: 380,
|
||||
cloneCostMs: 260
|
||||
})
|
||||
).toBe(380);
|
||||
});
|
||||
|
||||
it("会把 5 秒窗口内的高频事件聚合成 1 条摘要", () => {
|
||||
const writes = [];
|
||||
const buffer = createTerminalPerfLogBuffer({
|
||||
windowMs: 5000,
|
||||
write(summary) {
|
||||
writes.push(summary);
|
||||
}
|
||||
});
|
||||
|
||||
buffer.push({
|
||||
event: "stdout.slice",
|
||||
at: 1000,
|
||||
sinceLoadMs: 100,
|
||||
status: "connected",
|
||||
totalCostMs: 240
|
||||
});
|
||||
buffer.push({
|
||||
event: "layout.refresh.long",
|
||||
at: 1800,
|
||||
sinceLoadMs: 900,
|
||||
status: "connected",
|
||||
totalCostMs: 1400,
|
||||
setDataCostMs: 1200
|
||||
});
|
||||
buffer.push({
|
||||
event: "stdout.slice",
|
||||
at: 2200,
|
||||
sinceLoadMs: 1300,
|
||||
status: "connected",
|
||||
totalCostMs: 180
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(writes[0]).toMatchObject({
|
||||
event: "perf.summary",
|
||||
count: 3,
|
||||
status: "connected"
|
||||
});
|
||||
expect(writes[0].topEvents[0]).toEqual({ event: "stdout.slice", count: 2 });
|
||||
expect(writes[0].slowest).toMatchObject({
|
||||
event: "layout.refresh.long",
|
||||
scoreMs: 1400
|
||||
});
|
||||
expect(writes[0].latest).toMatchObject({
|
||||
event: "stdout.slice"
|
||||
});
|
||||
});
|
||||
|
||||
it("支持在页面收起前手动 flush,避免丢掉最后一个窗口", () => {
|
||||
const writes = [];
|
||||
const buffer = createTerminalPerfLogBuffer({
|
||||
windowMs: 5000,
|
||||
write(summary) {
|
||||
writes.push(summary);
|
||||
}
|
||||
});
|
||||
|
||||
buffer.push({
|
||||
event: "overlay.sync.long",
|
||||
at: 3000,
|
||||
sinceLoadMs: 2000,
|
||||
status: "connected",
|
||||
costMs: 65000
|
||||
});
|
||||
|
||||
const summary = buffer.flush("page_hide");
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
event: "perf.summary",
|
||||
reason: "page_hide",
|
||||
count: 1
|
||||
});
|
||||
expect(writes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
239
apps/miniprogram/pages/terminal/terminalRenderScheduler.js
Normal file
239
apps/miniprogram/pages/terminal/terminalRenderScheduler.js
Normal file
@@ -0,0 +1,239 @@
|
||||
/* 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
|
||||
};
|
||||
151
apps/miniprogram/pages/terminal/terminalRenderScheduler.test.ts
Normal file
151
apps/miniprogram/pages/terminal/terminalRenderScheduler.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { createTerminalRenderScheduler, mergeTerminalRenderOptions } = require("./terminalRenderScheduler.js");
|
||||
|
||||
describe("terminalRenderScheduler", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("会把渲染选项按单一真相源合并", () => {
|
||||
expect(mergeTerminalRenderOptions(null, null)).toEqual({ sendResize: false });
|
||||
expect(mergeTerminalRenderOptions({ sendResize: false }, { sendResize: true })).toEqual({
|
||||
sendResize: true
|
||||
});
|
||||
expect(mergeTerminalRenderOptions({ sendResize: true }, { sendResize: false })).toEqual({
|
||||
sendResize: true
|
||||
});
|
||||
});
|
||||
|
||||
it("stdout 高频输出会在一个批窗口内合并成一次渲染", () => {
|
||||
const runs = [];
|
||||
const scheduler = createTerminalRenderScheduler({
|
||||
batchWindowMs: 16,
|
||||
runRender(request, done) {
|
||||
runs.push(request);
|
||||
done({ ok: true });
|
||||
}
|
||||
});
|
||||
|
||||
scheduler.requestStdout({ appendStartedAt: 10, visibleBytes: 12 });
|
||||
scheduler.requestStdout({ appendStartedAt: 12, visibleBytes: 18 });
|
||||
|
||||
expect(runs).toHaveLength(0);
|
||||
|
||||
vi.advanceTimersByTime(16);
|
||||
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0].reason).toBe("stdout_batch");
|
||||
expect(runs[0].stdoutSamples).toHaveLength(2);
|
||||
expect(runs[0].stdoutSamples[0]).toMatchObject({ appendStartedAt: 10, visibleBytes: 12 });
|
||||
expect(runs[0].stdoutSamples[1]).toMatchObject({ appendStartedAt: 12, visibleBytes: 18 });
|
||||
});
|
||||
|
||||
it("进行中的渲染完成后,只会补跑一轮合并后的后续请求", () => {
|
||||
const runs = [];
|
||||
const finishes = [];
|
||||
const callbackMarks = [];
|
||||
const scheduler = createTerminalRenderScheduler({
|
||||
batchWindowMs: 16,
|
||||
runRender(request, done) {
|
||||
runs.push(request);
|
||||
finishes.push(done);
|
||||
}
|
||||
});
|
||||
|
||||
scheduler.requestImmediate({}, (_result, request) => {
|
||||
callbackMarks.push(`first:${request.reason}`);
|
||||
});
|
||||
expect(runs).toHaveLength(1);
|
||||
|
||||
scheduler.requestImmediate({ sendResize: true }, (_result, request) => {
|
||||
callbackMarks.push(`second:${request.reason}:${request.stdoutSamples.length}`);
|
||||
});
|
||||
scheduler.requestStdout({ appendStartedAt: 20, visibleBytes: 5 });
|
||||
|
||||
expect(runs).toHaveLength(1);
|
||||
|
||||
finishes.shift()({ ok: true });
|
||||
|
||||
expect(runs).toHaveLength(2);
|
||||
expect(runs[1].reason).toBe("pending_stdout");
|
||||
expect(runs[1].options).toEqual({ sendResize: true });
|
||||
expect(runs[1].stdoutSamples).toHaveLength(1);
|
||||
|
||||
finishes.shift()({ ok: true });
|
||||
|
||||
expect(callbackMarks).toEqual(["first:immediate", "second:pending_stdout:1"]);
|
||||
});
|
||||
|
||||
it("普通立即渲染会抢占尚未触发的 stdout 定时批处理", () => {
|
||||
const runs = [];
|
||||
const scheduler = createTerminalRenderScheduler({
|
||||
batchWindowMs: 16,
|
||||
runRender(request, done) {
|
||||
runs.push(request);
|
||||
done({ ok: true });
|
||||
}
|
||||
});
|
||||
|
||||
scheduler.requestStdout({ appendStartedAt: 10, visibleBytes: 3 });
|
||||
scheduler.requestImmediate({ sendResize: true });
|
||||
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0].reason).toBe("immediate");
|
||||
expect(runs[0].options).toEqual({ sendResize: true });
|
||||
expect(runs[0].stdoutSamples).toHaveLength(1);
|
||||
|
||||
vi.advanceTimersByTime(16);
|
||||
|
||||
expect(runs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("支持输出当前 pending 与 in-flight 的调度快照,便于慢场景诊断", () => {
|
||||
const finishes = [];
|
||||
let now = 100;
|
||||
const scheduler = createTerminalRenderScheduler({
|
||||
batchWindowMs: 16,
|
||||
now: () => now,
|
||||
runRender(request, done) {
|
||||
finishes.push(done);
|
||||
}
|
||||
});
|
||||
|
||||
scheduler.requestStdout({ text: "你好", appendStartedAt: 80, visibleBytes: 6 });
|
||||
now = 140;
|
||||
expect(scheduler.getSnapshot()).toMatchObject({
|
||||
inFlight: false,
|
||||
pending: {
|
||||
waitMs: 40,
|
||||
stdoutSampleCount: 1,
|
||||
stdoutRawBytes: 6
|
||||
},
|
||||
active: null
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(16);
|
||||
now = 180;
|
||||
expect(scheduler.getSnapshot()).toMatchObject({
|
||||
inFlight: true,
|
||||
pending: null,
|
||||
active: {
|
||||
reason: "stdout_batch",
|
||||
ageMs: 40,
|
||||
waitMs: 40,
|
||||
stdoutSampleCount: 1,
|
||||
stdoutRawBytes: 6
|
||||
}
|
||||
});
|
||||
|
||||
finishes.shift()({ ok: true });
|
||||
expect(scheduler.getSnapshot()).toMatchObject({
|
||||
inFlight: false,
|
||||
pending: null,
|
||||
active: null
|
||||
});
|
||||
});
|
||||
});
|
||||
136
apps/miniprogram/pages/terminal/terminalSessionInfo.js
Normal file
136
apps/miniprogram/pages/terminal/terminalSessionInfo.js
Normal file
@@ -0,0 +1,136 @@
|
||||
/* global module */
|
||||
|
||||
/**
|
||||
* 会话信息浮层只展示当前终端会话已经拥有的静态配置。
|
||||
* 这里保持纯函数,避免点击工具栏时再触发额外网络请求或依赖页面实例状态。
|
||||
*/
|
||||
function normalizeDisplayText(value) {
|
||||
const normalized = String(value || "").trim();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function buildTerminalServerAddress(serverInput) {
|
||||
const server = serverInput && typeof serverInput === "object" ? serverInput : {};
|
||||
const username = normalizeDisplayText(server.username);
|
||||
const host = normalizeDisplayText(server.host);
|
||||
const port = normalizeDisplayText(server.port);
|
||||
if (!host) {
|
||||
return "";
|
||||
}
|
||||
const authority = username ? `${username}@${host}` : host;
|
||||
return port ? `${authority}:${port}` : authority;
|
||||
}
|
||||
|
||||
function resolveSessionConnectionValue(sessionInfoCopy, connected) {
|
||||
const onValue = normalizeDisplayText(sessionInfoCopy.connectedValue) || "连接";
|
||||
const offValue = normalizeDisplayText(sessionInfoCopy.disconnectedValue) || "断开";
|
||||
return connected ? onValue : offValue;
|
||||
}
|
||||
|
||||
function resolveAiProviderLabel(activeAiProvider) {
|
||||
const normalized = normalizeDisplayText(activeAiProvider).toLowerCase();
|
||||
if (normalized === "copilot") return "Copilot";
|
||||
if (normalized === "codex") return "Codex";
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具栏浮层需要稳定输出静态会话信息:
|
||||
* 1. 顶部 hero 区聚焦“当前是哪台机器、通过哪条链路进入”;
|
||||
* 2. SSH / AI 连接态拆成两枚并排胶囊,便于一眼判断双通道状态;
|
||||
* 3. hero 已承载入口 / 跳转链路后,下方信息卡只保留不重复的目录信息;
|
||||
* 4. 缺省值统一在这里兜底,页面层只负责展示。
|
||||
*/
|
||||
function buildTerminalSessionInfoModel(input) {
|
||||
const source = input && typeof input === "object" ? input : {};
|
||||
const copy = source.copy && typeof source.copy === "object" ? source.copy : {};
|
||||
const sessionInfoCopy = copy.sessionInfo && typeof copy.sessionInfo === "object" ? copy.sessionInfo : {};
|
||||
const fallbackCopy = copy.fallback && typeof copy.fallback === "object" ? copy.fallback : {};
|
||||
const server = source.server && typeof source.server === "object" ? source.server : {};
|
||||
const jumpHost = server.jumpHost && typeof server.jumpHost === "object" ? server.jumpHost : null;
|
||||
const hasJumpHost = !!(jumpHost && jumpHost.enabled);
|
||||
const serverLabel = normalizeDisplayText(source.serverLabel);
|
||||
const statusText = normalizeDisplayText(source.statusText);
|
||||
const activeAiProvider = normalizeDisplayText(source.activeAiProvider);
|
||||
const emptyValue = normalizeDisplayText(sessionInfoCopy.emptyValue) || "-";
|
||||
const serverName =
|
||||
normalizeDisplayText(server.name) ||
|
||||
serverLabel ||
|
||||
normalizeDisplayText(fallbackCopy.unnamedServer) ||
|
||||
emptyValue;
|
||||
const projectPath =
|
||||
normalizeDisplayText(server.projectPath) || normalizeDisplayText(fallbackCopy.noProject) || emptyValue;
|
||||
const address =
|
||||
(hasJumpHost ? buildTerminalServerAddress(jumpHost) : buildTerminalServerAddress(server)) || emptyValue;
|
||||
const jumpTarget = hasJumpHost ? buildTerminalServerAddress(server) || emptyValue : "";
|
||||
const sshConnected = statusText === "connected";
|
||||
const aiConnected = !!activeAiProvider;
|
||||
const sshConnection = resolveSessionConnectionValue(sessionInfoCopy, statusText === "connected");
|
||||
const aiConnection = resolveSessionConnectionValue(sessionInfoCopy, aiConnected);
|
||||
const aiProviderLabel = resolveAiProviderLabel(activeAiProvider);
|
||||
const hero = {
|
||||
eyebrow: hasJumpHost ? "双跳通道" : "直连通道",
|
||||
name: serverName,
|
||||
subtitle: address,
|
||||
routeLabel: normalizeDisplayText(sessionInfoCopy.jumpTargetLabel) || "跳至服务器",
|
||||
route: hasJumpHost ? jumpTarget : ""
|
||||
};
|
||||
const statusChips = [
|
||||
{
|
||||
key: "sshConnection",
|
||||
label: normalizeDisplayText(sessionInfoCopy.sshConnectionLabel) || "SSH连接",
|
||||
value: sshConnection,
|
||||
badge: sshConnected ? "LIVE" : "IDLE",
|
||||
note: sshConnected ? "终端链路已就绪" : "等待重新建立",
|
||||
connected: sshConnected
|
||||
},
|
||||
{
|
||||
key: "aiConnection",
|
||||
label: normalizeDisplayText(sessionInfoCopy.aiConnectionLabel) || "AI连接",
|
||||
value: aiConnection,
|
||||
badge: aiConnected ? aiProviderLabel : "STANDBY",
|
||||
note: aiConnected ? `${aiProviderLabel} 正在前台` : "尚未接管终端",
|
||||
connected: aiConnected
|
||||
}
|
||||
];
|
||||
/**
|
||||
* 链路信息已经在 hero 区完整展示:
|
||||
* 1. 直连时 subtitle 就是目标服务器;
|
||||
* 2. 跳板时 subtitle + route 已同时覆盖入口与目标;
|
||||
* 3. 同一弹层里不再重复渲染“入口 / 目标”卡片,只留下工作目录。
|
||||
*/
|
||||
const detailItems = [
|
||||
{
|
||||
key: "project",
|
||||
accent: "目录",
|
||||
label: normalizeDisplayText(sessionInfoCopy.projectLabel) || "工作目录",
|
||||
value: projectPath,
|
||||
wide: true
|
||||
}
|
||||
];
|
||||
|
||||
return {
|
||||
title: normalizeDisplayText(sessionInfoCopy.title) || "会话信息",
|
||||
hero,
|
||||
statusChips,
|
||||
detailItems,
|
||||
items: detailItems
|
||||
.map((item) => ({
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
value: item.value
|
||||
}))
|
||||
.concat(
|
||||
statusChips.map((item) => ({
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
value: item.value
|
||||
}))
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildTerminalServerAddress,
|
||||
buildTerminalSessionInfoModel
|
||||
};
|
||||
130
apps/miniprogram/pages/terminal/terminalSessionInfo.test.ts
Normal file
130
apps/miniprogram/pages/terminal/terminalSessionInfo.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const { buildTerminalServerAddress, buildTerminalSessionInfoModel } = require("./terminalSessionInfo.js");
|
||||
|
||||
describe("terminalSessionInfo", () => {
|
||||
it("启用跳转主机后会把链路信息收敛到 hero 区并保留连接状态", () => {
|
||||
const model = buildTerminalSessionInfoModel({
|
||||
serverLabel: "prod-shell",
|
||||
statusText: "connected",
|
||||
activeAiProvider: "codex",
|
||||
server: {
|
||||
name: "生产环境",
|
||||
username: "deploy",
|
||||
host: "10.0.0.8",
|
||||
port: 22,
|
||||
projectPath: "/srv/apps/remoteconn",
|
||||
jumpHost: {
|
||||
enabled: true,
|
||||
username: "bastion",
|
||||
host: "10.0.0.2",
|
||||
port: 2222
|
||||
}
|
||||
},
|
||||
copy: {
|
||||
sessionInfo: {
|
||||
title: "会话信息",
|
||||
nameLabel: "服务器名称",
|
||||
projectLabel: "工作目录",
|
||||
addressLabel: "服务器地址",
|
||||
jumpTargetLabel: "跳至服务器",
|
||||
sshConnectionLabel: "SSH连接",
|
||||
aiConnectionLabel: "AI连接",
|
||||
connectedValue: "连接",
|
||||
disconnectedValue: "断开"
|
||||
},
|
||||
fallback: {
|
||||
noProject: "未设置项目",
|
||||
unnamedServer: "未命名服务器"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(model.title).toBe("会话信息");
|
||||
expect(model.hero).toEqual({
|
||||
eyebrow: "双跳通道",
|
||||
name: "生产环境",
|
||||
subtitle: "bastion@10.0.0.2:2222",
|
||||
routeLabel: "跳至服务器",
|
||||
route: "deploy@10.0.0.8:22"
|
||||
});
|
||||
expect(model.statusChips).toEqual([
|
||||
{
|
||||
key: "sshConnection",
|
||||
label: "SSH连接",
|
||||
value: "连接",
|
||||
badge: "LIVE",
|
||||
note: "终端链路已就绪",
|
||||
connected: true
|
||||
},
|
||||
{
|
||||
key: "aiConnection",
|
||||
label: "AI连接",
|
||||
value: "连接",
|
||||
badge: "Codex",
|
||||
note: "Codex 正在前台",
|
||||
connected: true
|
||||
}
|
||||
]);
|
||||
expect(model.detailItems).toEqual([
|
||||
{ key: "project", accent: "目录", label: "工作目录", value: "/srv/apps/remoteconn", wide: true }
|
||||
]);
|
||||
});
|
||||
|
||||
it("缺失配置时会回退到本地文案和断开状态", () => {
|
||||
const model = buildTerminalSessionInfoModel({
|
||||
serverLabel: "",
|
||||
statusText: "disconnected",
|
||||
activeAiProvider: "",
|
||||
server: {
|
||||
username: "",
|
||||
host: "",
|
||||
port: "",
|
||||
projectPath: ""
|
||||
},
|
||||
copy: {
|
||||
sessionInfo: {
|
||||
sshConnectionLabel: "SSH连接",
|
||||
aiConnectionLabel: "AI连接",
|
||||
connectedValue: "连接",
|
||||
disconnectedValue: "断开",
|
||||
emptyValue: "--"
|
||||
},
|
||||
fallback: {
|
||||
noProject: "未设置项目",
|
||||
unnamedServer: "未命名服务器"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(model.hero).toEqual({
|
||||
eyebrow: "直连通道",
|
||||
name: "未命名服务器",
|
||||
subtitle: "--",
|
||||
routeLabel: "跳至服务器",
|
||||
route: ""
|
||||
});
|
||||
expect(model.statusChips).toEqual([
|
||||
{
|
||||
key: "sshConnection",
|
||||
label: "SSH连接",
|
||||
value: "断开",
|
||||
badge: "IDLE",
|
||||
note: "等待重新建立",
|
||||
connected: false
|
||||
},
|
||||
{
|
||||
key: "aiConnection",
|
||||
label: "AI连接",
|
||||
value: "断开",
|
||||
badge: "STANDBY",
|
||||
note: "尚未接管终端",
|
||||
connected: false
|
||||
}
|
||||
]);
|
||||
expect(model.detailItems).toEqual([
|
||||
{ key: "project", accent: "目录", label: "工作目录", value: "未设置项目", wide: true }
|
||||
]);
|
||||
expect(buildTerminalServerAddress({ host: "srv.example.com", port: 2200 })).toBe("srv.example.com:2200");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type TerminalPageOptions = {
|
||||
data?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type TerminalPageInstance = TerminalPageOptions & {
|
||||
data: Record<string, unknown>;
|
||||
setData: (patch: Record<string, unknown>, callback?: () => void) => void;
|
||||
};
|
||||
|
||||
type MiniprogramGlobals = typeof globalThis & {
|
||||
Page?: (options: TerminalPageOptions) => void;
|
||||
wx?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function createTerminalPageHarness() {
|
||||
const globalState = globalThis as MiniprogramGlobals;
|
||||
let capturedPageOptions: TerminalPageOptions | null = null;
|
||||
const noop = () => {};
|
||||
|
||||
vi.resetModules();
|
||||
delete require.cache[require.resolve("./index.js")];
|
||||
globalState.Page = vi.fn((options: TerminalPageOptions) => {
|
||||
capturedPageOptions = options;
|
||||
});
|
||||
globalState.wx = {
|
||||
env: {
|
||||
USER_DATA_PATH: "/tmp"
|
||||
},
|
||||
getRecorderManager: vi.fn(() => ({
|
||||
onStart: noop,
|
||||
onStop: noop,
|
||||
onError: noop,
|
||||
onFrameRecorded: noop,
|
||||
start: noop,
|
||||
stop: noop
|
||||
})),
|
||||
createInnerAudioContext: vi.fn(() => ({
|
||||
onCanplay: noop,
|
||||
onPlay: noop,
|
||||
onEnded: noop,
|
||||
onStop: noop,
|
||||
onError: noop,
|
||||
stop: noop,
|
||||
destroy: noop
|
||||
})),
|
||||
setInnerAudioOption: vi.fn(),
|
||||
createSelectorQuery: vi.fn(() => ({
|
||||
in: vi.fn(() => ({
|
||||
select: vi.fn(() => ({
|
||||
boundingClientRect: vi.fn(() => ({
|
||||
exec: noop
|
||||
}))
|
||||
}))
|
||||
}))
|
||||
})),
|
||||
nextTick: vi.fn((callback?: () => void) => {
|
||||
callback?.();
|
||||
}),
|
||||
getSystemInfoSync: vi.fn(() => ({})),
|
||||
canIUse: vi.fn(() => false),
|
||||
showToast: vi.fn()
|
||||
};
|
||||
|
||||
require("./index.js");
|
||||
|
||||
if (!capturedPageOptions) {
|
||||
throw new Error("terminal page not captured");
|
||||
}
|
||||
|
||||
const captured = capturedPageOptions as TerminalPageOptions;
|
||||
const page = {
|
||||
...captured,
|
||||
data: JSON.parse(JSON.stringify(captured.data || {})) as Record<string, unknown>,
|
||||
setData(patch: Record<string, unknown>, callback?: () => void) {
|
||||
Object.assign(this.data, patch);
|
||||
callback?.();
|
||||
}
|
||||
} as TerminalPageInstance;
|
||||
|
||||
return { page };
|
||||
}
|
||||
|
||||
describe("terminal session info reconnect", () => {
|
||||
const globalState = globalThis as MiniprogramGlobals;
|
||||
const originalPage = globalState.Page;
|
||||
const originalWx = globalState.wx;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
if (originalPage) {
|
||||
globalState.Page = originalPage;
|
||||
} else {
|
||||
delete globalState.Page;
|
||||
}
|
||||
if (originalWx) {
|
||||
globalState.wx = originalWx;
|
||||
} else {
|
||||
delete globalState.wx;
|
||||
}
|
||||
});
|
||||
|
||||
it("点击 SSH 卡片会复用连接开关逻辑", () => {
|
||||
const { page } = createTerminalPageHarness();
|
||||
const onConnectionAction = vi.fn();
|
||||
|
||||
page.onConnectionAction = onConnectionAction;
|
||||
page.data.connectionActionDisabled = false;
|
||||
|
||||
page.onSessionInfoStatusTap({
|
||||
currentTarget: {
|
||||
dataset: {
|
||||
key: "sshConnection"
|
||||
}
|
||||
}
|
||||
} as unknown as Parameters<typeof page.onSessionInfoStatusTap>[0]);
|
||||
|
||||
expect(onConnectionAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("点击 AI 卡片会复用 AI 按钮逻辑", () => {
|
||||
const { page } = createTerminalPageHarness();
|
||||
const onOpenCodex = vi.fn();
|
||||
|
||||
page.onOpenCodex = onOpenCodex;
|
||||
page.data.aiLaunchBusy = false;
|
||||
|
||||
page.onSessionInfoStatusTap({
|
||||
currentTarget: {
|
||||
dataset: {
|
||||
key: "aiConnection"
|
||||
}
|
||||
}
|
||||
} as unknown as Parameters<typeof page.onSessionInfoStatusTap>[0]);
|
||||
|
||||
expect(onOpenCodex).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("连接开关禁用时点击 SSH 卡片不会触发连接动作", () => {
|
||||
const { page } = createTerminalPageHarness();
|
||||
const onConnectionAction = vi.fn();
|
||||
|
||||
page.onConnectionAction = onConnectionAction;
|
||||
page.data.connectionActionDisabled = true;
|
||||
|
||||
page.onSessionInfoStatusTap({
|
||||
currentTarget: {
|
||||
dataset: {
|
||||
key: "sshConnection"
|
||||
}
|
||||
}
|
||||
} as unknown as Parameters<typeof page.onSessionInfoStatusTap>[0]);
|
||||
|
||||
expect(onConnectionAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("AI 正在启动时点击 AI 卡片不会重复触发", () => {
|
||||
const { page } = createTerminalPageHarness();
|
||||
const onOpenCodex = vi.fn();
|
||||
|
||||
page.onOpenCodex = onOpenCodex;
|
||||
page.data.aiLaunchBusy = true;
|
||||
|
||||
page.onSessionInfoStatusTap({
|
||||
currentTarget: {
|
||||
dataset: {
|
||||
key: "aiConnection"
|
||||
}
|
||||
}
|
||||
} as unknown as Parameters<typeof page.onSessionInfoStatusTap>[0]);
|
||||
|
||||
expect(onOpenCodex).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type TerminalPageOptions = {
|
||||
data?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type TerminalPageInstance = TerminalPageOptions & {
|
||||
data: Record<string, unknown>;
|
||||
keyboardVisibleHeightPx: number;
|
||||
keyboardSessionActive: boolean;
|
||||
keyboardRestoreScrollTop: number | null;
|
||||
shellInputPassiveBlurPending: boolean;
|
||||
setData: (patch: Record<string, unknown>, callback?: () => void) => void;
|
||||
onShellInputBlur: () => void;
|
||||
onShellInputFocus: (event?: Record<string, unknown>) => void;
|
||||
handleShellKeyboardHeightChange: (height: number) => void;
|
||||
finalizeShellInputBlur: (options?: Record<string, unknown>) => void;
|
||||
restoreOutputScrollAfterKeyboard: ReturnType<typeof vi.fn>;
|
||||
adjustOutputScrollForKeyboard: ReturnType<typeof vi.fn>;
|
||||
sendFocusModeReport: ReturnType<typeof vi.fn>;
|
||||
clearTouchShiftState: ReturnType<typeof vi.fn>;
|
||||
syncTerminalOverlay: ReturnType<typeof vi.fn>;
|
||||
markTerminalUserInput: ReturnType<typeof vi.fn>;
|
||||
getOutputScrollTop: () => number;
|
||||
};
|
||||
|
||||
type MiniprogramGlobals = typeof globalThis & {
|
||||
Page?: (options: TerminalPageOptions) => void;
|
||||
wx?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function createTerminalPageHarness() {
|
||||
const globalState = globalThis as MiniprogramGlobals;
|
||||
let capturedPageOptions: TerminalPageOptions | null = null;
|
||||
const noop = () => {};
|
||||
|
||||
vi.resetModules();
|
||||
delete require.cache[require.resolve("./index.js")];
|
||||
globalState.Page = vi.fn((options: TerminalPageOptions) => {
|
||||
capturedPageOptions = options;
|
||||
});
|
||||
globalState.wx = {
|
||||
env: {
|
||||
USER_DATA_PATH: "/tmp"
|
||||
},
|
||||
getStorageSync: vi.fn(() => undefined),
|
||||
setStorageSync: vi.fn(),
|
||||
removeStorageSync: vi.fn(),
|
||||
getRecorderManager: vi.fn(() => ({
|
||||
onStart: noop,
|
||||
onStop: noop,
|
||||
onError: noop,
|
||||
onFrameRecorded: noop,
|
||||
start: noop,
|
||||
stop: noop
|
||||
})),
|
||||
createInnerAudioContext: vi.fn(() => ({
|
||||
onCanplay: noop,
|
||||
onPlay: noop,
|
||||
onEnded: noop,
|
||||
onStop: noop,
|
||||
onError: noop,
|
||||
stop: noop,
|
||||
destroy: noop
|
||||
})),
|
||||
setInnerAudioOption: vi.fn(),
|
||||
createSelectorQuery: vi.fn(() => ({
|
||||
in: vi.fn(() => ({
|
||||
select: vi.fn(() => ({
|
||||
boundingClientRect: vi.fn(() => ({
|
||||
exec: noop
|
||||
}))
|
||||
}))
|
||||
}))
|
||||
})),
|
||||
nextTick: vi.fn((callback?: () => void) => {
|
||||
callback?.();
|
||||
}),
|
||||
getSystemInfoSync: vi.fn(() => ({})),
|
||||
canIUse: vi.fn(() => false)
|
||||
};
|
||||
|
||||
require("./index.js");
|
||||
|
||||
if (!capturedPageOptions) {
|
||||
throw new Error("terminal page not captured");
|
||||
}
|
||||
|
||||
const captured = capturedPageOptions as TerminalPageOptions;
|
||||
const page = {
|
||||
...captured,
|
||||
data: JSON.parse(JSON.stringify(captured.data || {})) as Record<string, unknown>,
|
||||
keyboardVisibleHeightPx: 0,
|
||||
keyboardSessionActive: false,
|
||||
keyboardRestoreScrollTop: null,
|
||||
shellInputPassiveBlurPending: false,
|
||||
setData(patch: Record<string, unknown>, callback?: () => void) {
|
||||
Object.assign(this.data, patch);
|
||||
callback?.();
|
||||
}
|
||||
} as TerminalPageInstance;
|
||||
|
||||
page.restoreOutputScrollAfterKeyboard = vi.fn((callback?: () => void) => {
|
||||
callback?.();
|
||||
});
|
||||
page.adjustOutputScrollForKeyboard = vi.fn();
|
||||
page.sendFocusModeReport = vi.fn();
|
||||
page.clearTouchShiftState = vi.fn();
|
||||
page.syncTerminalOverlay = vi.fn();
|
||||
page.markTerminalUserInput = vi.fn();
|
||||
page.getOutputScrollTop = () => 0;
|
||||
|
||||
return { page };
|
||||
}
|
||||
|
||||
describe("terminal shell input blur guard", () => {
|
||||
const globalState = globalThis as MiniprogramGlobals;
|
||||
const originalPage = globalState.Page;
|
||||
const originalWx = globalState.wx;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
if (originalPage) {
|
||||
globalState.Page = originalPage;
|
||||
} else {
|
||||
delete globalState.Page;
|
||||
}
|
||||
if (originalWx) {
|
||||
globalState.wx = originalWx;
|
||||
} else {
|
||||
delete globalState.wx;
|
||||
}
|
||||
});
|
||||
|
||||
it("键盘仍可见时的被动 blur 不应立刻把 shell 输入框设为失焦", () => {
|
||||
const { page } = createTerminalPageHarness();
|
||||
page.data.statusClass = "connected";
|
||||
page.data.shellInputFocus = true;
|
||||
page.keyboardVisibleHeightPx = 240;
|
||||
|
||||
page.onShellInputBlur();
|
||||
|
||||
expect(page.shellInputPassiveBlurPending).toBe(true);
|
||||
expect(page.data.shellInputFocus).toBe(true);
|
||||
expect(page.restoreOutputScrollAfterKeyboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("被动 blur 后若键盘真正收起,才兑现为真实失焦", () => {
|
||||
const { page } = createTerminalPageHarness();
|
||||
page.data.statusClass = "connected";
|
||||
page.data.shellInputFocus = true;
|
||||
page.keyboardVisibleHeightPx = 240;
|
||||
|
||||
page.onShellInputBlur();
|
||||
page.handleShellKeyboardHeightChange(0);
|
||||
|
||||
expect(page.shellInputPassiveBlurPending).toBe(false);
|
||||
expect(page.data.shellInputFocus).toBe(false);
|
||||
expect(page.restoreOutputScrollAfterKeyboard).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("被动 blur 后若键盘继续保持可见,应清掉待定 blur 并继续保焦", () => {
|
||||
const { page } = createTerminalPageHarness();
|
||||
page.data.statusClass = "connected";
|
||||
page.data.shellInputFocus = true;
|
||||
page.keyboardVisibleHeightPx = 240;
|
||||
|
||||
page.onShellInputBlur();
|
||||
page.handleShellKeyboardHeightChange(260);
|
||||
|
||||
expect(page.shellInputPassiveBlurPending).toBe(false);
|
||||
expect(page.data.shellInputFocus).toBe(true);
|
||||
expect(page.adjustOutputScrollForKeyboard).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
159
apps/miniprogram/pages/terminal/terminalSnapshotCodec.js
Normal file
159
apps/miniprogram/pages/terminal/terminalSnapshotCodec.js
Normal file
@@ -0,0 +1,159 @@
|
||||
/* global module, require */
|
||||
|
||||
const {
|
||||
buildLineCellRenderRuns,
|
||||
createBlankCell,
|
||||
createContinuationCell,
|
||||
createTerminalCell,
|
||||
measureCharDisplayColumns
|
||||
} = require("./terminalCursorModel.js");
|
||||
|
||||
/**
|
||||
* 终端快照样式做最小化存储:
|
||||
* 1. 仅保留当前渲染真正需要的 fg/bg/bold/underline;
|
||||
* 2. 同一份样式进入 style table 去重,line runs 只保留索引;
|
||||
* 3. 这样比直接存整行 cell 更省空间,也避免恢复时回退成纯文本。
|
||||
*/
|
||||
function normalizeSnapshotStyle(style) {
|
||||
const source = style && typeof style === "object" ? style : null;
|
||||
if (!source) return null;
|
||||
const fg = String(source.fg || "").trim();
|
||||
const bg = String(source.bg || "").trim();
|
||||
const bold = source.bold === true;
|
||||
const underline = source.underline === true;
|
||||
if (!fg && !bg && !bold && !underline) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
fg,
|
||||
bg,
|
||||
bold,
|
||||
underline
|
||||
};
|
||||
}
|
||||
|
||||
function buildSnapshotStyleSignature(style) {
|
||||
const normalized = normalizeSnapshotStyle(style);
|
||||
if (!normalized) return "";
|
||||
return `${normalized.fg || ""}|${normalized.bg || ""}|${normalized.bold ? 1 : 0}|${normalized.underline ? 1 : 0}`;
|
||||
}
|
||||
|
||||
function cloneSnapshotStyle(style) {
|
||||
const normalized = normalizeSnapshotStyle(style);
|
||||
return normalized ? { ...normalized } : null;
|
||||
}
|
||||
|
||||
function measureTextDisplayColumns(text) {
|
||||
const value = String(text || "");
|
||||
if (!value) return 0;
|
||||
let columns = 0;
|
||||
for (let index = 0; index < value.length; ) {
|
||||
const codePoint = value.codePointAt(index);
|
||||
if (!Number.isFinite(codePoint)) break;
|
||||
const ch = String.fromCodePoint(codePoint);
|
||||
index += ch.length;
|
||||
const width = measureCharDisplayColumns(ch);
|
||||
if (width > 0) {
|
||||
columns += width;
|
||||
}
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
function appendStyledTextCells(cells, text, style) {
|
||||
const value = String(text || "");
|
||||
if (!value) return;
|
||||
for (let index = 0; index < value.length; ) {
|
||||
const codePoint = value.codePointAt(index);
|
||||
if (!Number.isFinite(codePoint)) break;
|
||||
const ch = String.fromCodePoint(codePoint);
|
||||
index += ch.length;
|
||||
const width = measureCharDisplayColumns(ch);
|
||||
if (width <= 0) {
|
||||
for (let ownerIndex = cells.length - 1; ownerIndex >= 0; ownerIndex -= 1) {
|
||||
if (cells[ownerIndex] && !cells[ownerIndex].continuation) {
|
||||
cells[ownerIndex].text = `${cells[ownerIndex].text || ""}${ch}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
cells.push(createTerminalCell(ch, cloneSnapshotStyle(style), width));
|
||||
for (let rest = width - 1; rest > 0; rest -= 1) {
|
||||
cells.push(createContinuationCell(cloneSnapshotStyle(style)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function serializeTerminalSnapshotRows(rowsInput) {
|
||||
const rows = Array.isArray(rowsInput) ? rowsInput : [];
|
||||
const styleTable = [];
|
||||
const styleIndexBySignature = new Map();
|
||||
const styledLines = rows.map((lineCells) => {
|
||||
const runs = buildLineCellRenderRuns(Array.isArray(lineCells) ? lineCells : []);
|
||||
return runs.map((run) => {
|
||||
const entry = {};
|
||||
const text = String((run && run.text) || "");
|
||||
const columns = Math.max(0, Math.round(Number(run && run.columns) || 0));
|
||||
if (text) {
|
||||
entry.t = text;
|
||||
}
|
||||
if (columns > 0) {
|
||||
entry.c = columns;
|
||||
}
|
||||
if (run && run.fixed) {
|
||||
entry.f = 1;
|
||||
}
|
||||
const styleSignature = buildSnapshotStyleSignature(run && run.style);
|
||||
if (styleSignature) {
|
||||
let styleIndex = styleIndexBySignature.get(styleSignature);
|
||||
if (!Number.isInteger(styleIndex)) {
|
||||
styleIndex = styleTable.length;
|
||||
styleIndexBySignature.set(styleSignature, styleIndex);
|
||||
styleTable.push(cloneSnapshotStyle(run.style));
|
||||
}
|
||||
entry.s = styleIndex;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
});
|
||||
return {
|
||||
styleTable,
|
||||
styledLines
|
||||
};
|
||||
}
|
||||
|
||||
function deserializeTerminalSnapshotRows(linesInput, styleTableInput) {
|
||||
const styleTable = Array.isArray(styleTableInput) ? styleTableInput.map((style) => cloneSnapshotStyle(style)) : [];
|
||||
const lines = Array.isArray(linesInput) ? linesInput : [];
|
||||
return lines.map((lineRuns) => {
|
||||
const cells = [];
|
||||
const runs = Array.isArray(lineRuns) ? lineRuns : [];
|
||||
runs.forEach((run) => {
|
||||
const source = run && typeof run === "object" ? run : {};
|
||||
const text = String(source.t || "");
|
||||
const columns = Math.max(
|
||||
0,
|
||||
Math.round(Number(source.c !== undefined ? source.c : measureTextDisplayColumns(text)) || 0)
|
||||
);
|
||||
const styleIndex = Number(source.s);
|
||||
const style =
|
||||
Number.isInteger(styleIndex) && styleIndex >= 0 && styleIndex < styleTable.length
|
||||
? styleTable[styleIndex]
|
||||
: null;
|
||||
if (!text) {
|
||||
for (let index = 0; index < columns; index += 1) {
|
||||
cells.push(createBlankCell(cloneSnapshotStyle(style)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
appendStyledTextCells(cells, text, style);
|
||||
});
|
||||
return cells;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
deserializeTerminalSnapshotRows,
|
||||
serializeTerminalSnapshotRows
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const {
|
||||
createBlankCell,
|
||||
createContinuationCell,
|
||||
createTerminalCell,
|
||||
lineCellsToText
|
||||
} = require("./terminalCursorModel.js");
|
||||
const {
|
||||
deserializeTerminalSnapshotRows,
|
||||
serializeTerminalSnapshotRows
|
||||
} = require("./terminalSnapshotCodec.js");
|
||||
|
||||
describe("terminalSnapshotCodec", () => {
|
||||
it("会用压缩 run 快照往返恢复 ANSI 样式与占位空白", () => {
|
||||
const styleA = { fg: "#ff5f56", bg: "#1f2937", bold: true, underline: false };
|
||||
const styleB = { fg: "#5bd2ff", bg: "", bold: false, underline: true };
|
||||
const rows = [
|
||||
[
|
||||
createTerminalCell("E", styleA, 1),
|
||||
createTerminalCell("R", styleA, 1),
|
||||
createTerminalCell("R", styleA, 1),
|
||||
createBlankCell(styleA),
|
||||
createTerminalCell("中", styleB, 2),
|
||||
createContinuationCell(styleB),
|
||||
createTerminalCell("A", styleB, 1)
|
||||
]
|
||||
];
|
||||
|
||||
const snapshot = serializeTerminalSnapshotRows(rows);
|
||||
const restored = deserializeTerminalSnapshotRows(snapshot.styledLines, snapshot.styleTable);
|
||||
const restoredRow = restored[0] || [];
|
||||
|
||||
expect(lineCellsToText(restoredRow)).toBe("ERR 中A");
|
||||
expect(restoredRow[0]?.style).toEqual(styleA);
|
||||
expect(restoredRow[3]?.placeholder).toBe(true);
|
||||
expect(restoredRow[3]?.style).toEqual(styleA);
|
||||
expect(restoredRow[4]?.style).toEqual(styleB);
|
||||
expect(restoredRow[5]?.continuation).toBe(true);
|
||||
expect(restoredRow[6]?.style).toEqual(styleB);
|
||||
});
|
||||
});
|
||||
284
apps/miniprogram/pages/terminal/terminalSpeakableText.js
Normal file
284
apps/miniprogram/pages/terminal/terminalSpeakableText.js
Normal file
@@ -0,0 +1,284 @@
|
||||
/* global module, require */
|
||||
|
||||
const {
|
||||
DEFAULT_TTS_SPEAKABLE_MAX_CHARS,
|
||||
TTS_SEGMENT_MAX_CHARS,
|
||||
TTS_SEGMENT_MAX_UTF8_BYTES,
|
||||
normalizeTtsSpeakableMaxChars,
|
||||
normalizeTtsSegmentMaxChars,
|
||||
resolveTtsSpeakableUtf8ByteLimit,
|
||||
resolveTtsSegmentUtf8ByteLimit
|
||||
} = require("../../utils/ttsSettings");
|
||||
|
||||
const ANSI_ESCAPE_PATTERN = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
|
||||
const BOX_DRAWING_PATTERN = /[┌┐└┘├┤┬┴┼│─╭╮╯╰═║╔╗╚╝]/;
|
||||
const COMMAND_PREFIX_PATTERN =
|
||||
/^\s*(?:[$#>]|>>>\s|(?:cd|ls|pwd|git|npm|pnpm|yarn|bun|node|npx|cat|grep|sed|awk|ssh|scp|rm|mv|cp|mkdir|touch|python|pip|cargo|go|java|docker|kubectl)\b)/i;
|
||||
const CODE_TOKEN_PATTERN = /(?:=>|::|===|!==|&&|\|\||\{|\}|\[|\]|<\/?|\/>|;)/g;
|
||||
const PATH_LINE_PATTERN =
|
||||
/^\s*(?:~?\/\S+|\.{1,2}\/\S+|[A-Za-z]:\\\S+|(?:[A-Za-z0-9._-]+\/){2,}[A-Za-z0-9._-]+|[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:[^\s]+)\s*$/;
|
||||
const URL_LINE_PATTERN = /^\s*https?:\/\/\S+\s*$/i;
|
||||
const PROGRESS_LINE_PATTERN = /(?:\b\d{1,3}%\b|\[[=>.\- ]{3,}\]|\bETA\b|\b\d+\/\d+\b|spinner|loading)/i;
|
||||
const CODEX_INPUT_LINE_PATTERN = /^\s*[›»❯➜]\s+/;
|
||||
const CODEX_FOOTER_LINE_PATTERN =
|
||||
/\b(?:gpt-\d(?:\.\d+)?|claude(?:-[a-z0-9.-]+)?|gemini(?:-[a-z0-9.-]+)?|deepseek(?:-[a-z0-9.-]+)?|o\d(?:-[a-z0-9.-]+)?|sonnet|haiku|opus)\b.*(?:\b\d{1,3}%\s+(?:left|context left)\b|~\/\S*)/i;
|
||||
const CODEX_FOOTER_FRAGMENT_PATTERN =
|
||||
/(?:\b\d{1,3}%\s+(?:left|context left)\b.*~\/\S*|~\/\S*.*\b\d{1,3}%\s+(?:left|context left)\b)/i;
|
||||
const CODEX_STATUS_LINE_PATTERN =
|
||||
/^\s*(?:[!!⚠■●•]\s*)?(?:Working(?:\s|\(|$)|Tip:|Tips?:|Heads up\b|Conversation interrupted\b|Something went wrong\b|Hit\s+`?\/feedback`?\b|Booting MCP server:|MCP server:)/i;
|
||||
const CHINESE_STATUS_LINE_PATTERN =
|
||||
/^\s*(?:正在(?:分析|处理|读取|扫描|生成|检查|加载|连接|收集|整理|搜索)|(?:分析|处理|读取|加载|连接|生成)(?:中|中\.\.\.|中…+))[^。!?!?]{0,80}(?:\.\.\.|…+)?\s*$/;
|
||||
const NATURAL_TEXT_PATTERN = /[\u3400-\u9fff]|[A-Za-z]{3,}/;
|
||||
const SYMBOL_CHAR_PATTERN = /[\\\/[\]{}()<>_=+*`|#@$%^~]/g;
|
||||
const MAX_SPEAKABLE_CHARS = DEFAULT_TTS_SPEAKABLE_MAX_CHARS;
|
||||
const MAX_SPEAKABLE_UTF8_BYTES = resolveTtsSpeakableUtf8ByteLimit(DEFAULT_TTS_SPEAKABLE_MAX_CHARS);
|
||||
|
||||
function stripTerminalAnsi(text) {
|
||||
return String(text || "")
|
||||
.replace(/\r/g, "")
|
||||
.replace(ANSI_ESCAPE_PATTERN, "");
|
||||
}
|
||||
|
||||
function normalizeSpeakableLine(line) {
|
||||
return stripTerminalAnsi(line)
|
||||
.replace(/[ \t\f\v]+/g, " ")
|
||||
.replace(/\u00a0/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function cleanSpeakableLine(line) {
|
||||
return String(line || "")
|
||||
.replace(/^\s*(?:(?:[-*+]\s+|[•●○◦▪■·]\s*|\d+[.)、]\s+))/, "")
|
||||
.replace(/`([^`]+)`/g, "$1")
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isCommandLikeLine(line) {
|
||||
return COMMAND_PREFIX_PATTERN.test(line);
|
||||
}
|
||||
|
||||
function isCodeLikeLine(line) {
|
||||
if (!line) return false;
|
||||
if (/^\s*```/.test(line)) return true;
|
||||
if (/^\s*(?:const|let|var|function|class|import|export|return|if|for|while)\b/.test(line)) return true;
|
||||
const codeTokenCount = (line.match(CODE_TOKEN_PATTERN) || []).length;
|
||||
return codeTokenCount >= 3;
|
||||
}
|
||||
|
||||
function hasHighSymbolDensity(line) {
|
||||
const visible = String(line || "").replace(/\s/g, "");
|
||||
if (!visible) return false;
|
||||
const symbols = (visible.match(SYMBOL_CHAR_PATTERN) || []).length;
|
||||
return symbols / visible.length >= 0.22;
|
||||
}
|
||||
|
||||
function isSpeakableLine(line) {
|
||||
if (!line) return false;
|
||||
if (!NATURAL_TEXT_PATTERN.test(line)) return false;
|
||||
if (BOX_DRAWING_PATTERN.test(line)) return false;
|
||||
if (/^[-=_*]{4,}$/.test(line)) return false;
|
||||
if (PROGRESS_LINE_PATTERN.test(line)) return false;
|
||||
if (CODEX_INPUT_LINE_PATTERN.test(line)) return false;
|
||||
if (CODEX_FOOTER_LINE_PATTERN.test(line)) return false;
|
||||
if (CODEX_FOOTER_FRAGMENT_PATTERN.test(line)) return false;
|
||||
if (CODEX_STATUS_LINE_PATTERN.test(line)) return false;
|
||||
if (CHINESE_STATUS_LINE_PATTERN.test(line)) return false;
|
||||
if (PATH_LINE_PATTERN.test(line) || URL_LINE_PATTERN.test(line)) return false;
|
||||
if (isCommandLikeLine(line) || isCodeLikeLine(line)) return false;
|
||||
if (hasHighSymbolDensity(line)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function collapseSpeakableText(text) {
|
||||
return String(text || "")
|
||||
.replace(/\s*\n\s*/g, " ")
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.replace(/([,。!?;:,.!?;:])\1{1,}/g, "$1")
|
||||
.replace(/([,。!?;:,.!?;:])\s+([A-Za-z\u3400-\u9fff])/g, "$1$2")
|
||||
.replace(/([\u3400-\u9fff])\s+([\u3400-\u9fff])/g, "$1$2")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function utf8ByteLength(text) {
|
||||
let total = 0;
|
||||
const source = String(text || "");
|
||||
for (const char of source) {
|
||||
const codePoint = char.codePointAt(0) || 0;
|
||||
if (codePoint <= 0x7f) {
|
||||
total += 1;
|
||||
} else if (codePoint <= 0x7ff) {
|
||||
total += 2;
|
||||
} else if (codePoint <= 0xffff) {
|
||||
total += 3;
|
||||
} else {
|
||||
total += 4;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function trimSpeakableText(text, maxChars, maxUtf8Bytes) {
|
||||
const source = String(text || "");
|
||||
const charLimit = normalizeTtsSpeakableMaxChars(maxChars);
|
||||
const utf8Limit = Math.max(1, Math.round(Number(maxUtf8Bytes) || resolveTtsSpeakableUtf8ByteLimit(charLimit)));
|
||||
if (source.length <= charLimit && utf8ByteLength(source) <= utf8Limit) {
|
||||
return source;
|
||||
}
|
||||
let result = "";
|
||||
let usedBytes = 0;
|
||||
for (const char of source) {
|
||||
if (result.length >= charLimit) {
|
||||
break;
|
||||
}
|
||||
const nextBytes = utf8ByteLength(char);
|
||||
if (usedBytes + nextBytes > utf8Limit) {
|
||||
break;
|
||||
}
|
||||
result += char;
|
||||
usedBytes += nextBytes;
|
||||
}
|
||||
return result
|
||||
.replace(/[,、;:,.!?;:\s]+$/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function splitSpeakableTextForTts(text, options) {
|
||||
const config = options && typeof options === "object" ? options : {};
|
||||
const source = collapseSpeakableText(text);
|
||||
if (!source) {
|
||||
return [];
|
||||
}
|
||||
const maxChars = normalizeTtsSegmentMaxChars(config.maxChars || TTS_SEGMENT_MAX_CHARS);
|
||||
const maxUtf8Bytes = Math.max(
|
||||
1,
|
||||
Math.round(Number(config.maxUtf8Bytes) || resolveTtsSegmentUtf8ByteLimit(maxChars))
|
||||
);
|
||||
const chars = Array.from(source);
|
||||
const segments = [];
|
||||
let cursor = 0;
|
||||
|
||||
/**
|
||||
* 分段策略优先找句号/问号/分号等强断点;
|
||||
* 如果当前窗口里没有完整句子,再退回逗号或空白,避免整段都卡到硬切。
|
||||
*/
|
||||
while (cursor < chars.length) {
|
||||
while (cursor < chars.length && /[\s,、;:,.!?;:]/.test(chars[cursor])) {
|
||||
cursor += 1;
|
||||
}
|
||||
if (cursor >= chars.length) {
|
||||
break;
|
||||
}
|
||||
let usedBytes = 0;
|
||||
let end = cursor;
|
||||
let lastStrongBreak = -1;
|
||||
let lastSoftBreak = -1;
|
||||
while (end < chars.length) {
|
||||
const char = chars[end];
|
||||
const nextBytes = utf8ByteLength(char);
|
||||
if (end - cursor >= maxChars || usedBytes + nextBytes > maxUtf8Bytes) {
|
||||
break;
|
||||
}
|
||||
usedBytes += nextBytes;
|
||||
end += 1;
|
||||
if (/[。!?!?;;::]/.test(char)) {
|
||||
lastStrongBreak = end;
|
||||
} else if (/[,、,.]/.test(char) || /\s/.test(char)) {
|
||||
lastSoftBreak = end;
|
||||
}
|
||||
}
|
||||
|
||||
let nextEnd = end;
|
||||
const consumedChars = end - cursor;
|
||||
const strongBreakFloor = Math.max(12, Math.floor(maxChars * 0.55));
|
||||
const softBreakFloor = Math.max(12, Math.floor(maxChars * 0.45));
|
||||
|
||||
if (end < chars.length) {
|
||||
if (lastStrongBreak >= cursor + strongBreakFloor) {
|
||||
nextEnd = lastStrongBreak;
|
||||
} else if (lastSoftBreak >= cursor + softBreakFloor) {
|
||||
nextEnd = lastSoftBreak;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextEnd <= cursor) {
|
||||
nextEnd = Math.max(cursor + 1, end);
|
||||
}
|
||||
|
||||
const segment = chars.slice(cursor, nextEnd).join("").trim();
|
||||
if (!segment && consumedChars > 0) {
|
||||
segments.push(chars.slice(cursor, end).join("").trim());
|
||||
cursor = end;
|
||||
continue;
|
||||
}
|
||||
if (segment) {
|
||||
segments.push(segment);
|
||||
}
|
||||
cursor = nextEnd;
|
||||
}
|
||||
|
||||
return segments.filter((segment) => !!segment);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从一轮终端可见输出中抽取“最近一批适合朗读的自然语言”:
|
||||
* 1. 仍然优先保留轮次尾部最近内容,但不再要求必须是单个连续段;
|
||||
* 2. 中间若夹杂代码、路径、状态行,直接跳过并继续向上回溯;
|
||||
* 3. 收口逻辑保持在短文本范围内,避免把整轮历史都送进 TTS。
|
||||
*/
|
||||
function buildSpeakableTerminalText(source, options) {
|
||||
const config = options && typeof options === "object" ? options : {};
|
||||
const maxChars = normalizeTtsSpeakableMaxChars(config.maxChars);
|
||||
const maxUtf8Bytes = Math.max(
|
||||
1,
|
||||
Math.round(Number(config.maxUtf8Bytes) || resolveTtsSpeakableUtf8ByteLimit(maxChars))
|
||||
);
|
||||
const text = Array.isArray(source) ? source.join("\n") : String(source || "");
|
||||
const normalized = stripTerminalAnsi(text);
|
||||
if (!normalized.trim()) {
|
||||
return "";
|
||||
}
|
||||
const lines = normalized.split(/\n+/).map(normalizeSpeakableLine);
|
||||
const collected = [];
|
||||
let collectedChars = 0;
|
||||
let collectedBytes = 0;
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const line = lines[index];
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
if (!isSpeakableLine(line)) {
|
||||
continue;
|
||||
}
|
||||
const cleaned = cleanSpeakableLine(line);
|
||||
if (!cleaned) {
|
||||
continue;
|
||||
}
|
||||
const separatorChars = collected.length > 0 ? 1 : 0;
|
||||
const nextChars = cleaned.length + separatorChars;
|
||||
const nextBytes = utf8ByteLength(cleaned) + separatorChars;
|
||||
if (collected.length > 0 && (collectedChars + nextChars > maxChars || collectedBytes + nextBytes > maxUtf8Bytes)) {
|
||||
break;
|
||||
}
|
||||
if (collected.length === 0 && (cleaned.length > maxChars || utf8ByteLength(cleaned) > maxUtf8Bytes)) {
|
||||
collected.unshift(trimSpeakableText(cleaned, maxChars, maxUtf8Bytes));
|
||||
break;
|
||||
}
|
||||
collected.unshift(cleaned);
|
||||
collectedChars += nextChars;
|
||||
collectedBytes += nextBytes;
|
||||
}
|
||||
return trimSpeakableText(collapseSpeakableText(collected.join("\n")), maxChars, maxUtf8Bytes);
|
||||
}
|
||||
|
||||
function isSpeakableTextLikelyComplete(text) {
|
||||
return /(?:[。!?!?::]|\.{1}|。{1})\s*$/.test(String(text || "").trim());
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_SPEAKABLE_CHARS,
|
||||
buildSpeakableTerminalText,
|
||||
isSpeakableTextLikelyComplete,
|
||||
splitSpeakableTextForTts,
|
||||
stripTerminalAnsi
|
||||
};
|
||||
123
apps/miniprogram/pages/terminal/terminalSpeakableText.test.ts
Normal file
123
apps/miniprogram/pages/terminal/terminalSpeakableText.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const {
|
||||
buildSpeakableTerminalText,
|
||||
isSpeakableTextLikelyComplete,
|
||||
splitSpeakableTextForTts,
|
||||
stripTerminalAnsi
|
||||
} = require("./terminalSpeakableText.js");
|
||||
|
||||
describe("terminalSpeakableText", () => {
|
||||
it("应提取最近一段自然语言并跳过命令与代码", () => {
|
||||
const source = [
|
||||
"$ codex ask",
|
||||
"正在分析项目结构...",
|
||||
"const answer = computeResult();",
|
||||
"",
|
||||
"请先检查 gateway 的环境变量配置。",
|
||||
"确认 TTS_SECRET_ID、TTS_SECRET_KEY 与 TTS_REGION 是否一致。"
|
||||
].join("\n");
|
||||
|
||||
expect(buildSpeakableTerminalText(source)).toBe(
|
||||
"请先检查 gateway 的环境变量配置。确认 TTS_SECRET_ID、TTS_SECRET_KEY 与 TTS_REGION 是否一致。"
|
||||
);
|
||||
});
|
||||
|
||||
it("只有命令、路径和进度条时应返回空文本", () => {
|
||||
const source = [
|
||||
"$ npm run build",
|
||||
"/Users/demo/project/src/index.ts",
|
||||
"[====== ] 60%",
|
||||
"spinner loading"
|
||||
].join("\n");
|
||||
|
||||
expect(buildSpeakableTerminalText(source)).toBe("");
|
||||
});
|
||||
|
||||
it("应跳过 Codex 输入行、状态行与底部 footer,只保留回答正文", () => {
|
||||
const source = [
|
||||
"› Summarize recent commits",
|
||||
"Working (0s · esc to interrupt)",
|
||||
"■ Conversation interrupted - tell the model what to do differently.",
|
||||
"Something went wrong? Hit `/feedback` to report the issue.",
|
||||
"本次修改主要收口了 TTS 播放链路。",
|
||||
"同时移除了小程序侧重复下载缓存文件的绕路实现。",
|
||||
"gpt-5.4 xhigh · 100% left · ~/remoteconn"
|
||||
].join("\n");
|
||||
|
||||
expect(buildSpeakableTerminalText(source)).toBe(
|
||||
"本次修改主要收口了 TTS 播放链路。同时移除了小程序侧重复下载缓存文件的绕路实现。"
|
||||
);
|
||||
});
|
||||
|
||||
it("应跳过被终端换行拆开的 footer 残片", () => {
|
||||
const source = [
|
||||
"本次修改已经完成。",
|
||||
"42% left · ~/remoteconn"
|
||||
].join("\n");
|
||||
|
||||
expect(buildSpeakableTerminalText(source)).toBe("本次修改已经完成。");
|
||||
});
|
||||
|
||||
it("应去掉列表项目符号,避免 TTS 在首句前卡住", () => {
|
||||
const source = [
|
||||
"• 第一条消息。",
|
||||
" 第二条消息。",
|
||||
" 第三条消息。",
|
||||
" 第四条消息。"
|
||||
].join("\n");
|
||||
|
||||
expect(buildSpeakableTerminalText(source)).toBe("第一条消息。第二条消息。第三条消息。第四条消息。");
|
||||
});
|
||||
|
||||
it("应去掉紧贴正文的项目符号前缀", () => {
|
||||
const source = ["•第一条消息。", "•第二条消息。"].join("\n");
|
||||
|
||||
expect(buildSpeakableTerminalText(source)).toBe("第一条消息。第二条消息。");
|
||||
});
|
||||
|
||||
it("中间夹杂代码块时应继续向上回收同轮正文,而不是只读最后一小段", () => {
|
||||
const source = [
|
||||
"第一段说明,先交代修复背景。",
|
||||
"第二段说明,描述影响范围。",
|
||||
"```ts",
|
||||
"const demo = true;",
|
||||
"```",
|
||||
"第三段说明,给出处理方式。",
|
||||
"第四段说明,提醒重启服务。"
|
||||
].join("\n");
|
||||
|
||||
expect(buildSpeakableTerminalText(source, { maxChars: 200 })).toBe(
|
||||
"第一段说明,先交代修复背景。第二段说明,描述影响范围。第三段说明,给出处理方式。第四段说明,提醒重启服务。"
|
||||
);
|
||||
});
|
||||
|
||||
it("长中文文本应保留到总量配置上限,并支持后续分段", () => {
|
||||
const source = "这是一个较长的测试输出。".repeat(40);
|
||||
const result = buildSpeakableTerminalText(source);
|
||||
const segments = splitSpeakableTextForTts(result);
|
||||
|
||||
expect(Buffer.byteLength(result, "utf8")).toBeLessThanOrEqual(1500);
|
||||
expect(result.length).toBeLessThanOrEqual(500);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(segments.length).toBeGreaterThan(1);
|
||||
segments.forEach((segment: string) => {
|
||||
expect(Buffer.byteLength(segment, "utf8")).toBeLessThanOrEqual(240);
|
||||
expect(segment.length).toBeLessThanOrEqual(80);
|
||||
});
|
||||
});
|
||||
|
||||
it("应支持按自定义总长度提取正文", () => {
|
||||
const source = "这是一个较长的测试输出。".repeat(40);
|
||||
const result = buildSpeakableTerminalText(source, { maxChars: 120 });
|
||||
|
||||
expect(result.length).toBeLessThanOrEqual(120);
|
||||
expect(Buffer.byteLength(result, "utf8")).toBeLessThanOrEqual(360);
|
||||
});
|
||||
|
||||
it("应支持去除 ANSI 并判断句子是否收束", () => {
|
||||
expect(stripTerminalAnsi("\u001b[32m请先检查配置。\u001b[0m")).toBe("请先检查配置。");
|
||||
expect(isSpeakableTextLikelyComplete("请先检查配置。")).toBe(true);
|
||||
expect(isSpeakableTextLikelyComplete("请先检查配置")).toBe(false);
|
||||
});
|
||||
});
|
||||
233
apps/miniprogram/pages/terminal/terminalStdoutRenderPolicy.js
Normal file
233
apps/miniprogram/pages/terminal/terminalStdoutRenderPolicy.js
Normal file
@@ -0,0 +1,233 @@
|
||||
/* global module */
|
||||
|
||||
const MEDIUM_BACKLOG_MIN_REMAINING_BYTES = 8 * 1024;
|
||||
const MEDIUM_BACKLOG_MIN_PENDING_BYTES = 8 * 1024;
|
||||
const MEDIUM_BACKLOG_MAX_SLICES = 8;
|
||||
const MEDIUM_BACKLOG_RENDER_COOLDOWN_MS = 220;
|
||||
|
||||
const LARGE_BACKLOG_MIN_REMAINING_BYTES = 64 * 1024;
|
||||
const LARGE_BACKLOG_MIN_PENDING_BYTES = 32 * 1024;
|
||||
const LARGE_BACKLOG_MAX_SLICES = 32;
|
||||
const LARGE_BACKLOG_RENDER_COOLDOWN_MS = 320;
|
||||
|
||||
const CRITICAL_BACKLOG_MIN_TOTAL_BYTES = 64 * 1024;
|
||||
const CRITICAL_BACKLOG_MIN_PENDING_BYTES = 24 * 1024;
|
||||
const CRITICAL_BACKLOG_MIN_PENDING_SAMPLES = 128;
|
||||
const CRITICAL_BACKLOG_MIN_SCHEDULER_WAIT_MS = 1200;
|
||||
const CRITICAL_BACKLOG_MIN_ACTIVE_AGE_MS = 1200;
|
||||
const CRITICAL_BACKLOG_FRAME_PENDING_BYTES = 24 * 1024;
|
||||
const CRITICAL_BACKLOG_FRAME_MAX_SLICES = 20;
|
||||
const CRITICAL_BACKLOG_RENDER_COOLDOWN_MS = 520;
|
||||
|
||||
const STDOUT_OVERLAY_SYNC_COOLDOWN_MS = 240;
|
||||
|
||||
function normalizeNonNegativeInteger(value, fallback) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return Math.max(0, Math.round(Number(fallback) || 0));
|
||||
}
|
||||
return Math.max(0, Math.round(numeric));
|
||||
}
|
||||
|
||||
function shouldUseCriticalBacklogPolicy(options) {
|
||||
const source = options && typeof options === "object" ? options : {};
|
||||
const totalRawBytes = normalizeNonNegativeInteger(source.totalRawBytes, 0);
|
||||
const pendingStdoutBytes = normalizeNonNegativeInteger(source.pendingStdoutBytes, 0);
|
||||
const pendingStdoutSamples = normalizeNonNegativeInteger(source.pendingStdoutSamples, 0);
|
||||
const schedulerWaitMs = normalizeNonNegativeInteger(source.schedulerWaitMs, 0);
|
||||
const activeStdoutAgeMs = normalizeNonNegativeInteger(source.activeStdoutAgeMs, 0);
|
||||
return (
|
||||
pendingStdoutBytes >= CRITICAL_BACKLOG_MIN_PENDING_BYTES ||
|
||||
pendingStdoutSamples >= CRITICAL_BACKLOG_MIN_PENDING_SAMPLES ||
|
||||
schedulerWaitMs >= CRITICAL_BACKLOG_MIN_SCHEDULER_WAIT_MS ||
|
||||
(totalRawBytes >= CRITICAL_BACKLOG_MIN_TOTAL_BYTES &&
|
||||
activeStdoutAgeMs >= CRITICAL_BACKLOG_MIN_ACTIVE_AGE_MS)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* stdout 的真正瓶颈不是 VT 解析,而是每个 slice 都把整份 `outputRenderLines`
|
||||
* 重新通过 `setData` 送去视图层。
|
||||
*
|
||||
* 这里按 backlog 做两档降频:
|
||||
* 1. 中等 backlog:累计到 8KB 或 8 个 slice 再提交一次;
|
||||
* 2. 大 backlog:累计到 32KB 或 32 个 slice 再提交一次。
|
||||
*
|
||||
* 若用户刚有输入,或当前 slice 触发了终端响应帧,则立即提交,避免交互被延后。
|
||||
*/
|
||||
function resolveTerminalStdoutRenderDecision(options) {
|
||||
const source = options && typeof options === "object" ? options : {};
|
||||
if (source.yieldedToUserInput) {
|
||||
return {
|
||||
defer: false,
|
||||
reason: "user_input",
|
||||
policy: "interactive"
|
||||
};
|
||||
}
|
||||
if (normalizeNonNegativeInteger(source.pendingResponseCount, 0) > 0) {
|
||||
return {
|
||||
defer: false,
|
||||
reason: "pending_response",
|
||||
policy: "interactive"
|
||||
};
|
||||
}
|
||||
|
||||
const remainingBytes = normalizeNonNegativeInteger(source.remainingBytes, 0);
|
||||
const pendingReplayBytes = normalizeNonNegativeInteger(source.pendingReplayBytes, 0);
|
||||
const nextSlicesSinceLastRender = normalizeNonNegativeInteger(source.nextSlicesSinceLastRender, 0);
|
||||
const timeSinceLastRenderMs = normalizeNonNegativeInteger(
|
||||
source.timeSinceLastRenderMs,
|
||||
Number.MAX_SAFE_INTEGER
|
||||
);
|
||||
const taskDone = !!source.taskDone;
|
||||
const usingCriticalPolicy = shouldUseCriticalBacklogPolicy(source);
|
||||
|
||||
if (usingCriticalPolicy) {
|
||||
if (taskDone) {
|
||||
return {
|
||||
defer: false,
|
||||
reason: "task_complete",
|
||||
policy: "critical_backlog"
|
||||
};
|
||||
}
|
||||
if (
|
||||
nextSlicesSinceLastRender > 0 &&
|
||||
timeSinceLastRenderMs < CRITICAL_BACKLOG_RENDER_COOLDOWN_MS
|
||||
) {
|
||||
return {
|
||||
defer: true,
|
||||
reason: "render_cooldown",
|
||||
policy: "critical_backlog"
|
||||
};
|
||||
}
|
||||
if (pendingReplayBytes >= CRITICAL_BACKLOG_FRAME_PENDING_BYTES) {
|
||||
return {
|
||||
defer: false,
|
||||
reason: "pending_bytes_threshold",
|
||||
policy: "critical_backlog"
|
||||
};
|
||||
}
|
||||
if (nextSlicesSinceLastRender >= CRITICAL_BACKLOG_FRAME_MAX_SLICES) {
|
||||
return {
|
||||
defer: false,
|
||||
reason: "slice_threshold",
|
||||
policy: "critical_backlog"
|
||||
};
|
||||
}
|
||||
return {
|
||||
defer: true,
|
||||
reason: "defer_critical_backlog",
|
||||
policy: "critical_backlog"
|
||||
};
|
||||
}
|
||||
|
||||
const usingLargePolicy =
|
||||
remainingBytes >= LARGE_BACKLOG_MIN_REMAINING_BYTES ||
|
||||
pendingReplayBytes >= LARGE_BACKLOG_MIN_PENDING_BYTES;
|
||||
const minRemainingBytes = usingLargePolicy
|
||||
? LARGE_BACKLOG_MIN_REMAINING_BYTES
|
||||
: MEDIUM_BACKLOG_MIN_REMAINING_BYTES;
|
||||
const minPendingBytes = usingLargePolicy
|
||||
? LARGE_BACKLOG_MIN_PENDING_BYTES
|
||||
: MEDIUM_BACKLOG_MIN_PENDING_BYTES;
|
||||
const maxSlicesSinceRender = usingLargePolicy ? LARGE_BACKLOG_MAX_SLICES : MEDIUM_BACKLOG_MAX_SLICES;
|
||||
const renderCooldownMs = usingLargePolicy
|
||||
? LARGE_BACKLOG_RENDER_COOLDOWN_MS
|
||||
: MEDIUM_BACKLOG_RENDER_COOLDOWN_MS;
|
||||
const policy = usingLargePolicy ? "large_backlog" : "medium_backlog";
|
||||
|
||||
if (taskDone) {
|
||||
return {
|
||||
defer: false,
|
||||
reason: "task_complete",
|
||||
policy
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
nextSlicesSinceLastRender > 0 &&
|
||||
timeSinceLastRenderMs < renderCooldownMs &&
|
||||
pendingReplayBytes < minPendingBytes &&
|
||||
nextSlicesSinceLastRender < maxSlicesSinceRender
|
||||
) {
|
||||
return {
|
||||
defer: true,
|
||||
reason: "render_cooldown",
|
||||
policy
|
||||
};
|
||||
}
|
||||
|
||||
if (remainingBytes < minRemainingBytes) {
|
||||
return {
|
||||
defer: false,
|
||||
reason: "remaining_below_threshold",
|
||||
policy
|
||||
};
|
||||
}
|
||||
if (pendingReplayBytes >= minPendingBytes) {
|
||||
return {
|
||||
defer: false,
|
||||
reason: "pending_bytes_threshold",
|
||||
policy
|
||||
};
|
||||
}
|
||||
if (nextSlicesSinceLastRender >= maxSlicesSinceRender) {
|
||||
return {
|
||||
defer: false,
|
||||
reason: "slice_threshold",
|
||||
policy
|
||||
};
|
||||
}
|
||||
return {
|
||||
defer: true,
|
||||
reason: "defer_backlog",
|
||||
policy
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTerminalStdoutOverlayDecision(options) {
|
||||
const source = options && typeof options === "object" ? options : {};
|
||||
if (source.isFinalRender) {
|
||||
return {
|
||||
sync: true,
|
||||
reason: "task_complete"
|
||||
};
|
||||
}
|
||||
if (source.yieldedToUserInput) {
|
||||
return {
|
||||
sync: true,
|
||||
reason: "user_input"
|
||||
};
|
||||
}
|
||||
const overlayPassCount = normalizeNonNegativeInteger(source.overlayPassCount, 0);
|
||||
if (overlayPassCount <= 0) {
|
||||
return {
|
||||
sync: true,
|
||||
reason: "first_render"
|
||||
};
|
||||
}
|
||||
const timeSinceLastOverlayMs = normalizeNonNegativeInteger(
|
||||
source.timeSinceLastOverlayMs,
|
||||
Number.MAX_SAFE_INTEGER
|
||||
);
|
||||
if (timeSinceLastOverlayMs >= STDOUT_OVERLAY_SYNC_COOLDOWN_MS) {
|
||||
return {
|
||||
sync: true,
|
||||
reason: "overlay_cooldown_elapsed"
|
||||
};
|
||||
}
|
||||
return {
|
||||
sync: false,
|
||||
reason: "overlay_throttled"
|
||||
};
|
||||
}
|
||||
|
||||
function shouldDeferTerminalStdoutRender(options) {
|
||||
return resolveTerminalStdoutRenderDecision(options).defer;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveTerminalStdoutOverlayDecision,
|
||||
resolveTerminalStdoutRenderDecision,
|
||||
shouldDeferTerminalStdoutRender
|
||||
};
|
||||
@@ -0,0 +1,292 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const {
|
||||
resolveTerminalStdoutOverlayDecision,
|
||||
resolveTerminalStdoutRenderDecision,
|
||||
shouldDeferTerminalStdoutRender
|
||||
} = require("./terminalStdoutRenderPolicy.js");
|
||||
|
||||
describe("terminalStdoutRenderPolicy", () => {
|
||||
it("小 backlog 不会延后视图提交", () => {
|
||||
expect(
|
||||
shouldDeferTerminalStdoutRender({
|
||||
remainingBytes: 4096,
|
||||
pendingReplayBytes: 1024,
|
||||
nextSlicesSinceLastRender: 1,
|
||||
pendingResponseCount: 0,
|
||||
yieldedToUserInput: false
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("中等 backlog 会先累计到阈值再提交", () => {
|
||||
expect(
|
||||
shouldDeferTerminalStdoutRender({
|
||||
remainingBytes: 24 * 1024,
|
||||
pendingReplayBytes: 3 * 1024,
|
||||
nextSlicesSinceLastRender: 3,
|
||||
pendingResponseCount: 0,
|
||||
yieldedToUserInput: false
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldDeferTerminalStdoutRender({
|
||||
remainingBytes: 24 * 1024,
|
||||
pendingReplayBytes: 8 * 1024,
|
||||
nextSlicesSinceLastRender: 3,
|
||||
pendingResponseCount: 0,
|
||||
yieldedToUserInput: false
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("大 backlog 会使用更高阈值,避免频繁整包 setData", () => {
|
||||
expect(
|
||||
shouldDeferTerminalStdoutRender({
|
||||
remainingBytes: 512 * 1024,
|
||||
pendingReplayBytes: 16 * 1024,
|
||||
nextSlicesSinceLastRender: 12,
|
||||
pendingResponseCount: 0,
|
||||
yieldedToUserInput: false
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldDeferTerminalStdoutRender({
|
||||
remainingBytes: 512 * 1024,
|
||||
pendingReplayBytes: 16 * 1024,
|
||||
nextSlicesSinceLastRender: 32,
|
||||
pendingResponseCount: 0,
|
||||
yieldedToUserInput: false
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("用户输入或终端响应存在时必须立即提交", () => {
|
||||
expect(
|
||||
shouldDeferTerminalStdoutRender({
|
||||
remainingBytes: 512 * 1024,
|
||||
pendingReplayBytes: 1024,
|
||||
nextSlicesSinceLastRender: 1,
|
||||
pendingResponseCount: 1,
|
||||
yieldedToUserInput: false
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldDeferTerminalStdoutRender({
|
||||
remainingBytes: 512 * 1024,
|
||||
pendingReplayBytes: 1024,
|
||||
nextSlicesSinceLastRender: 1,
|
||||
pendingResponseCount: 0,
|
||||
yieldedToUserInput: true
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("会给出本轮提交或延后的决策原因,便于诊断 render 频率", () => {
|
||||
expect(
|
||||
resolveTerminalStdoutRenderDecision({
|
||||
remainingBytes: 512 * 1024,
|
||||
pendingReplayBytes: 1024,
|
||||
nextSlicesSinceLastRender: 1,
|
||||
pendingResponseCount: 0,
|
||||
yieldedToUserInput: true
|
||||
})
|
||||
).toMatchObject({
|
||||
defer: false,
|
||||
reason: "user_input",
|
||||
policy: "interactive"
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveTerminalStdoutRenderDecision({
|
||||
remainingBytes: 24 * 1024,
|
||||
pendingReplayBytes: 3 * 1024,
|
||||
nextSlicesSinceLastRender: 3,
|
||||
pendingResponseCount: 0,
|
||||
yieldedToUserInput: false
|
||||
})
|
||||
).toMatchObject({
|
||||
defer: true,
|
||||
reason: "defer_backlog",
|
||||
policy: "medium_backlog"
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveTerminalStdoutRenderDecision({
|
||||
remainingBytes: 24 * 1024,
|
||||
pendingReplayBytes: 8 * 1024,
|
||||
nextSlicesSinceLastRender: 3,
|
||||
pendingResponseCount: 0,
|
||||
yieldedToUserInput: false
|
||||
})
|
||||
).toMatchObject({
|
||||
defer: false,
|
||||
reason: "pending_bytes_threshold",
|
||||
policy: "medium_backlog"
|
||||
});
|
||||
});
|
||||
|
||||
it("render 冷却期间即使进入尾段,也会继续延后视图提交", () => {
|
||||
expect(
|
||||
resolveTerminalStdoutRenderDecision({
|
||||
remainingBytes: 4096,
|
||||
pendingReplayBytes: 2048,
|
||||
nextSlicesSinceLastRender: 2,
|
||||
pendingResponseCount: 0,
|
||||
yieldedToUserInput: false,
|
||||
timeSinceLastRenderMs: 80,
|
||||
taskDone: false
|
||||
})
|
||||
).toMatchObject({
|
||||
defer: true,
|
||||
reason: "render_cooldown",
|
||||
policy: "medium_backlog"
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveTerminalStdoutRenderDecision({
|
||||
remainingBytes: 4096,
|
||||
pendingReplayBytes: 2048,
|
||||
nextSlicesSinceLastRender: 2,
|
||||
pendingResponseCount: 0,
|
||||
yieldedToUserInput: false,
|
||||
timeSinceLastRenderMs: 280,
|
||||
taskDone: false
|
||||
})
|
||||
).toMatchObject({
|
||||
defer: false,
|
||||
reason: "remaining_below_threshold",
|
||||
policy: "medium_backlog"
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveTerminalStdoutRenderDecision({
|
||||
remainingBytes: 0,
|
||||
pendingReplayBytes: 1024,
|
||||
nextSlicesSinceLastRender: 2,
|
||||
pendingResponseCount: 0,
|
||||
yieldedToUserInput: false,
|
||||
timeSinceLastRenderMs: 20,
|
||||
taskDone: true
|
||||
})
|
||||
).toMatchObject({
|
||||
defer: false,
|
||||
reason: "task_complete"
|
||||
});
|
||||
});
|
||||
|
||||
it("高 backlog 时会进入更激进的降级模式,优先丢弃中间帧", () => {
|
||||
expect(
|
||||
resolveTerminalStdoutRenderDecision({
|
||||
remainingBytes: 4096,
|
||||
pendingReplayBytes: 4096,
|
||||
nextSlicesSinceLastRender: 4,
|
||||
pendingResponseCount: 0,
|
||||
yieldedToUserInput: false,
|
||||
timeSinceLastRenderMs: 600,
|
||||
taskDone: false,
|
||||
totalRawBytes: 80 * 1024,
|
||||
pendingStdoutBytes: 48 * 1024,
|
||||
pendingStdoutSamples: 160,
|
||||
schedulerWaitMs: 2400,
|
||||
activeStdoutAgeMs: 1800
|
||||
})
|
||||
).toMatchObject({
|
||||
defer: true,
|
||||
reason: "defer_critical_backlog",
|
||||
policy: "critical_backlog"
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveTerminalStdoutRenderDecision({
|
||||
remainingBytes: 4096,
|
||||
pendingReplayBytes: 28 * 1024,
|
||||
nextSlicesSinceLastRender: 4,
|
||||
pendingResponseCount: 0,
|
||||
yieldedToUserInput: false,
|
||||
timeSinceLastRenderMs: 600,
|
||||
taskDone: false,
|
||||
totalRawBytes: 80 * 1024,
|
||||
pendingStdoutBytes: 48 * 1024,
|
||||
pendingStdoutSamples: 160,
|
||||
schedulerWaitMs: 2400,
|
||||
activeStdoutAgeMs: 1800
|
||||
})
|
||||
).toMatchObject({
|
||||
defer: false,
|
||||
reason: "pending_bytes_threshold",
|
||||
policy: "critical_backlog"
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveTerminalStdoutRenderDecision({
|
||||
remainingBytes: 4096,
|
||||
pendingReplayBytes: 4096,
|
||||
nextSlicesSinceLastRender: 24,
|
||||
pendingResponseCount: 0,
|
||||
yieldedToUserInput: false,
|
||||
timeSinceLastRenderMs: 600,
|
||||
taskDone: false,
|
||||
totalRawBytes: 80 * 1024,
|
||||
pendingStdoutBytes: 48 * 1024,
|
||||
pendingStdoutSamples: 160,
|
||||
schedulerWaitMs: 2400,
|
||||
activeStdoutAgeMs: 1800
|
||||
})
|
||||
).toMatchObject({
|
||||
defer: false,
|
||||
reason: "slice_threshold",
|
||||
policy: "critical_backlog"
|
||||
});
|
||||
});
|
||||
|
||||
it("stdout 持续输出时会对 overlay 做节流,但最终一帧仍会同步", () => {
|
||||
expect(
|
||||
resolveTerminalStdoutOverlayDecision({
|
||||
isFinalRender: false,
|
||||
yieldedToUserInput: false,
|
||||
overlayPassCount: 0,
|
||||
timeSinceLastOverlayMs: 0
|
||||
})
|
||||
).toMatchObject({
|
||||
sync: true,
|
||||
reason: "first_render"
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveTerminalStdoutOverlayDecision({
|
||||
isFinalRender: false,
|
||||
yieldedToUserInput: false,
|
||||
overlayPassCount: 2,
|
||||
timeSinceLastOverlayMs: 80
|
||||
})
|
||||
).toMatchObject({
|
||||
sync: false,
|
||||
reason: "overlay_throttled"
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveTerminalStdoutOverlayDecision({
|
||||
isFinalRender: false,
|
||||
yieldedToUserInput: false,
|
||||
overlayPassCount: 2,
|
||||
timeSinceLastOverlayMs: 260
|
||||
})
|
||||
).toMatchObject({
|
||||
sync: true,
|
||||
reason: "overlay_cooldown_elapsed"
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveTerminalStdoutOverlayDecision({
|
||||
isFinalRender: true,
|
||||
yieldedToUserInput: false,
|
||||
overlayPassCount: 2,
|
||||
timeSinceLastOverlayMs: 80
|
||||
})
|
||||
).toMatchObject({
|
||||
sync: true,
|
||||
reason: "task_complete"
|
||||
});
|
||||
});
|
||||
});
|
||||
280
apps/miniprogram/pages/terminal/terminalViewportModel.js
Normal file
280
apps/miniprogram/pages/terminal/terminalViewportModel.js
Normal file
@@ -0,0 +1,280 @@
|
||||
/* global module */
|
||||
|
||||
/**
|
||||
* 终端视口层只关心两件事:
|
||||
* 1. 当前到底应该渲染多少行,避免 normal buffer 在 prompt 下方保留虚假空白尾部;
|
||||
* 2. 当前内容理论上的最大滚动值,保证 scroll-view 与 overlay 使用同一套边界。
|
||||
*
|
||||
* 这里不改 VT buffer 本身,只做页面投影。
|
||||
*/
|
||||
|
||||
const TERMINAL_VIEWPORT_TARGET_RENDER_ROWS = 160;
|
||||
const TERMINAL_VIEWPORT_MIN_EDGE_BUFFER_ROWS = 24;
|
||||
const TERMINAL_VIEWPORT_SCROLL_TARGET_RENDER_ROWS = 224;
|
||||
const TERMINAL_VIEWPORT_SCROLL_MIN_EDGE_BUFFER_ROWS = 40;
|
||||
const TERMINAL_VIEWPORT_DIRECTIONAL_LEAD_RATIO = 0.7;
|
||||
|
||||
function normalizeNonNegativeInteger(value, fallback) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.max(0, Math.round(parsed));
|
||||
}
|
||||
|
||||
function normalizeRows(rows) {
|
||||
return Array.isArray(rows) && rows.length > 0 ? rows : [[]];
|
||||
}
|
||||
|
||||
function normalizeOptionalNonNegativeInteger(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
return Math.max(0, Math.round(parsed));
|
||||
}
|
||||
|
||||
function normalizeActiveBufferName(activeBufferName) {
|
||||
return activeBufferName === "alt" ? "alt" : "normal";
|
||||
}
|
||||
|
||||
function normalizeScrollDirection(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return 0;
|
||||
if (parsed > 0) return 1;
|
||||
if (parsed < 0) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function lineHasVisibleText(line) {
|
||||
if (!Array.isArray(line) || line.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return line.some((cell) => {
|
||||
if (!cell || cell.continuation) {
|
||||
return false;
|
||||
}
|
||||
return String(cell.text || "").length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveLastNonEmptyRow(rows) {
|
||||
for (let index = rows.length - 1; index >= 0; index -= 1) {
|
||||
if (lineHasVisibleText(rows[index])) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* normal buffer 的 live tail 应该以当前 cursor 行为上界。
|
||||
* 否则哪怕 prompt 已经到底,scroll-view 仍会因为尾部空行继续给出可滚动空间。
|
||||
*
|
||||
* alternate screen 维持整屏语义,不做裁剪。
|
||||
*/
|
||||
function resolveTerminalRenderRows(bufferRows, cursorRow, activeBufferName) {
|
||||
const rows = normalizeRows(bufferRows);
|
||||
if (normalizeActiveBufferName(activeBufferName) === "alt") {
|
||||
return rows;
|
||||
}
|
||||
const cursorExclusive = normalizeNonNegativeInteger(cursorRow, 0) + 1;
|
||||
const contentExclusive = resolveLastNonEmptyRow(rows) + 1;
|
||||
const tailExclusive = Math.max(1, Math.min(rows.length, Math.max(cursorExclusive, contentExclusive)));
|
||||
return rows.slice(0, tailExclusive);
|
||||
}
|
||||
|
||||
function resolveTerminalMaxScrollTop(renderRowCount, visibleRows, lineHeight) {
|
||||
const rows = Math.max(1, normalizeNonNegativeInteger(renderRowCount, 1));
|
||||
const viewportRows = Math.max(1, normalizeNonNegativeInteger(visibleRows, 1));
|
||||
const px = Math.max(1, normalizeNonNegativeInteger(lineHeight, 1));
|
||||
return Math.max(0, (rows - viewportRows) * px);
|
||||
}
|
||||
|
||||
function resolveTerminalTargetRenderRows(contentRowCount, visibleRows, minEdgeBufferRows, targetRenderRows) {
|
||||
const rows = Math.max(1, normalizeNonNegativeInteger(contentRowCount, 1));
|
||||
const viewportRows = Math.max(1, normalizeNonNegativeInteger(visibleRows, 1));
|
||||
const edgeBufferRows = Math.max(
|
||||
0,
|
||||
normalizeNonNegativeInteger(minEdgeBufferRows, TERMINAL_VIEWPORT_MIN_EDGE_BUFFER_ROWS)
|
||||
);
|
||||
const targetRows = Math.max(
|
||||
1,
|
||||
normalizeNonNegativeInteger(targetRenderRows, TERMINAL_VIEWPORT_TARGET_RENDER_ROWS)
|
||||
);
|
||||
const minimumRows = viewportRows + edgeBufferRows * 2;
|
||||
return Math.min(rows, Math.max(minimumRows, targetRows));
|
||||
}
|
||||
|
||||
function resolveTerminalDirectionalBuffers(extraRows, direction, minEdgeBufferRows) {
|
||||
const remainingRows = Math.max(0, normalizeNonNegativeInteger(extraRows, 0));
|
||||
const normalizedDirection = normalizeScrollDirection(direction);
|
||||
const edgeBufferRows = Math.max(
|
||||
0,
|
||||
normalizeNonNegativeInteger(minEdgeBufferRows, TERMINAL_VIEWPORT_MIN_EDGE_BUFFER_ROWS)
|
||||
);
|
||||
if (normalizedDirection === 0) {
|
||||
const backwardRows = Math.floor(remainingRows / 2);
|
||||
return {
|
||||
backwardRows,
|
||||
forwardRows: remainingRows - backwardRows
|
||||
};
|
||||
}
|
||||
const trailingRows = Math.min(
|
||||
remainingRows,
|
||||
Math.max(
|
||||
edgeBufferRows,
|
||||
Math.floor(remainingRows * (1 - TERMINAL_VIEWPORT_DIRECTIONAL_LEAD_RATIO))
|
||||
)
|
||||
);
|
||||
const leadingRows = Math.max(0, remainingRows - trailingRows);
|
||||
return normalizedDirection > 0
|
||||
? {
|
||||
backwardRows: trailingRows,
|
||||
forwardRows: leadingRows
|
||||
}
|
||||
: {
|
||||
backwardRows: leadingRows,
|
||||
forwardRows: trailingRows
|
||||
};
|
||||
}
|
||||
|
||||
function fillTerminalRenderWindow(renderStartRow, renderEndRow, contentRowCount, targetRenderRows) {
|
||||
let startRow = Math.max(0, normalizeNonNegativeInteger(renderStartRow, 0));
|
||||
let endRow = Math.max(startRow, normalizeNonNegativeInteger(renderEndRow, startRow));
|
||||
const rows = Math.max(1, normalizeNonNegativeInteger(contentRowCount, 1));
|
||||
const targetRows = Math.max(1, normalizeNonNegativeInteger(targetRenderRows, 1));
|
||||
let missingRows = Math.max(0, targetRows - (endRow - startRow));
|
||||
if (missingRows <= 0) {
|
||||
return {
|
||||
renderStartRow: startRow,
|
||||
renderEndRow: endRow
|
||||
};
|
||||
}
|
||||
const extendBackward = Math.min(startRow, missingRows);
|
||||
startRow -= extendBackward;
|
||||
missingRows -= extendBackward;
|
||||
if (missingRows > 0) {
|
||||
const extendForward = Math.min(rows - endRow, missingRows);
|
||||
endRow += extendForward;
|
||||
missingRows -= extendForward;
|
||||
}
|
||||
if (missingRows > 0) {
|
||||
const extendBackwardAgain = Math.min(startRow, missingRows);
|
||||
startRow -= extendBackwardAgain;
|
||||
}
|
||||
return {
|
||||
renderStartRow: startRow,
|
||||
renderEndRow: endRow
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序 scroll-view 一旦挂上几百行富文本,`setData` 和布局都会明显变重。
|
||||
* 这里基于“当前目标 scrollTop + 固定总预算”只截出一个窗口,靠上下 spacer 维持完整滚动高度。
|
||||
* 预算固定后,再根据最近滚动方向把更多余量分配到前方,兼顾性能与快速滑动时的预取需求。
|
||||
*/
|
||||
function resolveTerminalRenderWindow(contentRowCount, visibleRows, lineHeight, options) {
|
||||
const source = options && typeof options === "object" ? options : {};
|
||||
const rows = Math.max(1, normalizeNonNegativeInteger(contentRowCount, 1));
|
||||
const viewportRows = Math.max(1, normalizeNonNegativeInteger(visibleRows, 1));
|
||||
const px = Math.max(1, normalizeNonNegativeInteger(lineHeight, 1));
|
||||
const maxScrollTop = resolveTerminalMaxScrollTop(rows, viewportRows, px);
|
||||
const normalizedScrollTop = normalizeOptionalNonNegativeInteger(source.scrollTop);
|
||||
const followTail = source.followTail === true;
|
||||
const scrollDirection = normalizeScrollDirection(source.scrollDirection);
|
||||
const scrollViewport = source.scrollViewport === true;
|
||||
const minEdgeBufferRows = scrollViewport
|
||||
? TERMINAL_VIEWPORT_SCROLL_MIN_EDGE_BUFFER_ROWS
|
||||
: TERMINAL_VIEWPORT_MIN_EDGE_BUFFER_ROWS;
|
||||
const targetRenderRows = resolveTerminalTargetRenderRows(
|
||||
rows,
|
||||
viewportRows,
|
||||
minEdgeBufferRows,
|
||||
scrollViewport ? TERMINAL_VIEWPORT_SCROLL_TARGET_RENDER_ROWS : TERMINAL_VIEWPORT_TARGET_RENDER_ROWS
|
||||
);
|
||||
|
||||
let clampedScrollTop = 0;
|
||||
let windowed = false;
|
||||
if (followTail) {
|
||||
clampedScrollTop = maxScrollTop;
|
||||
windowed = true;
|
||||
} else if (normalizedScrollTop !== null) {
|
||||
clampedScrollTop = Math.min(maxScrollTop, normalizedScrollTop);
|
||||
windowed = true;
|
||||
}
|
||||
|
||||
if (!windowed) {
|
||||
return {
|
||||
clampedScrollTop,
|
||||
visibleStartRow: 0,
|
||||
visibleEndRow: rows,
|
||||
renderStartRow: 0,
|
||||
renderEndRow: rows,
|
||||
topSpacerHeight: 0,
|
||||
bottomSpacerHeight: 0,
|
||||
backwardBufferRows: 0,
|
||||
forwardBufferRows: 0
|
||||
};
|
||||
}
|
||||
|
||||
const maxVisibleStart = Math.max(0, rows - viewportRows);
|
||||
const visibleStartRow = Math.min(maxVisibleStart, Math.max(0, Math.floor(clampedScrollTop / px)));
|
||||
const visibleEndRow = Math.min(rows, visibleStartRow + viewportRows);
|
||||
const extraRows = Math.max(0, targetRenderRows - viewportRows);
|
||||
const directionalBuffers = resolveTerminalDirectionalBuffers(extraRows, scrollDirection, minEdgeBufferRows);
|
||||
const filledWindow = fillTerminalRenderWindow(
|
||||
Math.max(0, visibleStartRow - directionalBuffers.backwardRows),
|
||||
Math.min(rows, visibleEndRow + directionalBuffers.forwardRows),
|
||||
rows,
|
||||
targetRenderRows
|
||||
);
|
||||
const renderStartRow = filledWindow.renderStartRow;
|
||||
const renderEndRow = filledWindow.renderEndRow;
|
||||
return {
|
||||
clampedScrollTop,
|
||||
visibleStartRow,
|
||||
visibleEndRow,
|
||||
renderStartRow,
|
||||
renderEndRow,
|
||||
topSpacerHeight: renderStartRow * px,
|
||||
bottomSpacerHeight: Math.max(0, rows - renderEndRow) * px,
|
||||
backwardBufferRows: Math.max(0, visibleStartRow - renderStartRow),
|
||||
forwardBufferRows: Math.max(0, renderEndRow - visibleEndRow)
|
||||
};
|
||||
}
|
||||
|
||||
function buildTerminalViewportState(options) {
|
||||
const source = options && typeof options === "object" ? options : {};
|
||||
const contentRows = resolveTerminalRenderRows(source.bufferRows, source.cursorRow, source.activeBufferName);
|
||||
const contentRowCount = contentRows.length;
|
||||
const windowState = resolveTerminalRenderWindow(
|
||||
contentRowCount,
|
||||
source.visibleRows,
|
||||
source.lineHeight,
|
||||
source
|
||||
);
|
||||
const renderRows = contentRows.slice(windowState.renderStartRow, windowState.renderEndRow);
|
||||
const renderRowCount = renderRows.length;
|
||||
const maxScrollTop = resolveTerminalMaxScrollTop(contentRowCount, source.visibleRows, source.lineHeight);
|
||||
return {
|
||||
activeBufferName: normalizeActiveBufferName(source.activeBufferName),
|
||||
contentRows,
|
||||
contentRowCount,
|
||||
clampedScrollTop: Math.min(maxScrollTop, Math.max(0, windowState.clampedScrollTop)),
|
||||
renderRows,
|
||||
renderRowCount,
|
||||
visibleStartRow: windowState.visibleStartRow,
|
||||
visibleEndRow: windowState.visibleEndRow,
|
||||
renderStartRow: windowState.renderStartRow,
|
||||
renderEndRow: windowState.renderEndRow,
|
||||
topSpacerHeight: windowState.topSpacerHeight,
|
||||
bottomSpacerHeight: windowState.bottomSpacerHeight,
|
||||
backwardBufferRows: windowState.backwardBufferRows,
|
||||
forwardBufferRows: windowState.forwardBufferRows,
|
||||
maxScrollTop
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildTerminalViewportState,
|
||||
normalizeActiveBufferName,
|
||||
resolveTerminalMaxScrollTop,
|
||||
resolveTerminalRenderRows
|
||||
};
|
||||
136
apps/miniprogram/pages/terminal/terminalViewportModel.test.ts
Normal file
136
apps/miniprogram/pages/terminal/terminalViewportModel.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const {
|
||||
buildTerminalViewportState,
|
||||
resolveTerminalMaxScrollTop,
|
||||
resolveTerminalRenderRows
|
||||
} = require("./terminalViewportModel.js");
|
||||
|
||||
describe("terminalViewportModel", () => {
|
||||
it("normal buffer 会裁掉 cursor 行之后的虚假尾部,避免 prompt 下方继续可滚动", () => {
|
||||
const rows = [[{ text: "a" }], [{ text: "b" }], [], []];
|
||||
|
||||
const renderRows = resolveTerminalRenderRows(rows, 1, "normal");
|
||||
|
||||
expect(renderRows).toHaveLength(2);
|
||||
expect(renderRows).toEqual(rows.slice(0, 2));
|
||||
});
|
||||
|
||||
it("alternate screen 保留整屏行数,不裁掉底部空白", () => {
|
||||
const rows = [[{ text: "a" }], [], [], []];
|
||||
|
||||
const renderRows = resolveTerminalRenderRows(rows, 0, "alt");
|
||||
|
||||
expect(renderRows).toHaveLength(4);
|
||||
expect(renderRows).toEqual(rows);
|
||||
});
|
||||
|
||||
it("normal buffer 在 cursor 行之后若仍有真实 footer,会保留到最后一个非空行", () => {
|
||||
const rows = [[{ text: "prompt" }], [], [{ text: "footer" }], []];
|
||||
|
||||
const renderRows = resolveTerminalRenderRows(rows, 0, "normal");
|
||||
|
||||
expect(renderRows).toHaveLength(3);
|
||||
expect(renderRows).toEqual(rows.slice(0, 3));
|
||||
});
|
||||
|
||||
it("最大滚动值基于最终渲染行数,而不是旧尾部空行", () => {
|
||||
const viewport = buildTerminalViewportState({
|
||||
bufferRows: [[{ text: "a" }], [{ text: "b" }], [], []],
|
||||
cursorRow: 1,
|
||||
activeBufferName: "normal",
|
||||
visibleRows: 1,
|
||||
lineHeight: 20
|
||||
});
|
||||
|
||||
expect(viewport.renderRowCount).toBe(2);
|
||||
expect(viewport.maxScrollTop).toBe(20);
|
||||
expect(resolveTerminalMaxScrollTop(2, 1, 20)).toBe(20);
|
||||
});
|
||||
|
||||
it("最大滚动值会把 cursor 后的真实 footer 也算进去,而不是只看 cursor 行", () => {
|
||||
const viewport = buildTerminalViewportState({
|
||||
bufferRows: [[{ text: "prompt" }], [], [{ text: "footer" }], []],
|
||||
cursorRow: 0,
|
||||
activeBufferName: "normal",
|
||||
visibleRows: 1,
|
||||
lineHeight: 20
|
||||
});
|
||||
|
||||
expect(viewport.renderRowCount).toBe(3);
|
||||
expect(viewport.maxScrollTop).toBe(40);
|
||||
});
|
||||
|
||||
it("followTail 模式只渲染底部可视区附近窗口,并用 spacer 保留完整滚动高度", () => {
|
||||
const rows = Array.from({ length: 400 }, (_, index) => [{ text: `row-${index}` }]);
|
||||
|
||||
const viewport = buildTerminalViewportState({
|
||||
bufferRows: rows,
|
||||
cursorRow: 399,
|
||||
activeBufferName: "normal",
|
||||
visibleRows: 5,
|
||||
lineHeight: 10,
|
||||
followTail: true,
|
||||
scrollDirection: 1
|
||||
});
|
||||
|
||||
expect(viewport.contentRowCount).toBe(400);
|
||||
expect(viewport.maxScrollTop).toBe(3950);
|
||||
expect(viewport.clampedScrollTop).toBe(3950);
|
||||
expect(viewport.renderStartRow).toBe(240);
|
||||
expect(viewport.renderEndRow).toBe(400);
|
||||
expect(viewport.renderRowCount).toBe(160);
|
||||
expect(viewport.topSpacerHeight).toBe(2400);
|
||||
expect(viewport.bottomSpacerHeight).toBe(0);
|
||||
expect(viewport.backwardBufferRows).toBe(155);
|
||||
expect(viewport.forwardBufferRows).toBe(0);
|
||||
expect(viewport.renderRows[0]).toEqual(rows[240]);
|
||||
expect(viewport.renderRows.at(-1)).toEqual(rows[399]);
|
||||
});
|
||||
|
||||
it("传入 scrollTop 时,会围绕当前滚动窗口裁出中段正文", () => {
|
||||
const rows = Array.from({ length: 400 }, (_, index) => [{ text: `row-${index}` }]);
|
||||
|
||||
const viewport = buildTerminalViewportState({
|
||||
bufferRows: rows,
|
||||
cursorRow: 399,
|
||||
activeBufferName: "normal",
|
||||
visibleRows: 5,
|
||||
lineHeight: 10,
|
||||
scrollTop: 1000,
|
||||
scrollDirection: 1
|
||||
});
|
||||
|
||||
expect(viewport.clampedScrollTop).toBe(1000);
|
||||
expect(viewport.renderStartRow).toBe(54);
|
||||
expect(viewport.renderEndRow).toBe(214);
|
||||
expect(viewport.renderRowCount).toBe(160);
|
||||
expect(viewport.topSpacerHeight).toBe(540);
|
||||
expect(viewport.bottomSpacerHeight).toBe(1860);
|
||||
expect(viewport.backwardBufferRows).toBe(46);
|
||||
expect(viewport.forwardBufferRows).toBe(109);
|
||||
expect(viewport.renderRows[0]).toEqual(rows[54]);
|
||||
expect(viewport.renderRows.at(-1)).toEqual(rows[213]);
|
||||
});
|
||||
|
||||
it("滚动补刷模式会扩大窗口预算,减少快速滑动时频繁换窗", () => {
|
||||
const rows = Array.from({ length: 400 }, (_, index) => [{ text: `row-${index}` }]);
|
||||
|
||||
const viewport = buildTerminalViewportState({
|
||||
bufferRows: rows,
|
||||
cursorRow: 399,
|
||||
activeBufferName: "normal",
|
||||
visibleRows: 5,
|
||||
lineHeight: 10,
|
||||
scrollTop: 1000,
|
||||
scrollDirection: -1,
|
||||
scrollViewport: true
|
||||
});
|
||||
|
||||
expect(viewport.renderRowCount).toBe(224);
|
||||
expect(viewport.renderStartRow).toBe(0);
|
||||
expect(viewport.renderEndRow).toBe(224);
|
||||
expect(viewport.topSpacerHeight).toBe(0);
|
||||
expect(viewport.bottomSpacerHeight).toBe(1760);
|
||||
});
|
||||
});
|
||||
123
apps/miniprogram/pages/terminal/touchShiftState.js
Normal file
123
apps/miniprogram/pages/terminal/touchShiftState.js
Normal file
@@ -0,0 +1,123 @@
|
||||
/* global module */
|
||||
|
||||
const TOUCH_SHIFT_MODE_OFF = "off";
|
||||
const TOUCH_SHIFT_MODE_ONCE = "once";
|
||||
const TOUCH_SHIFT_MODE_LOCK = "lock";
|
||||
const TOUCH_SHIFT_DOUBLE_TAP_MS = 320;
|
||||
|
||||
function normalizeTouchShiftMode(value) {
|
||||
const normalized = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (normalized === TOUCH_SHIFT_MODE_ONCE) return TOUCH_SHIFT_MODE_ONCE;
|
||||
if (normalized === TOUCH_SHIFT_MODE_LOCK) return TOUCH_SHIFT_MODE_LOCK;
|
||||
return TOUCH_SHIFT_MODE_OFF;
|
||||
}
|
||||
|
||||
function isTouchShiftActive(mode) {
|
||||
const normalized = normalizeTouchShiftMode(mode);
|
||||
return normalized === TOUCH_SHIFT_MODE_ONCE || normalized === TOUCH_SHIFT_MODE_LOCK;
|
||||
}
|
||||
|
||||
function isAsciiLetter(value) {
|
||||
return /[A-Za-z]/.test(String(value || ""));
|
||||
}
|
||||
|
||||
function resolveTouchShiftModeOnTap(currentMode, lastTapAt, now, doubleTapWindowMs) {
|
||||
const normalizedMode = normalizeTouchShiftMode(currentMode);
|
||||
const currentTime = Number.isFinite(Number(now)) ? Number(now) : Date.now();
|
||||
const previousTapAt = Number(lastTapAt);
|
||||
const windowMs = Number.isFinite(Number(doubleTapWindowMs))
|
||||
? Math.max(0, Number(doubleTapWindowMs))
|
||||
: TOUCH_SHIFT_DOUBLE_TAP_MS;
|
||||
|
||||
if (normalizedMode === TOUCH_SHIFT_MODE_LOCK) {
|
||||
return TOUCH_SHIFT_MODE_OFF;
|
||||
}
|
||||
if (
|
||||
normalizedMode === TOUCH_SHIFT_MODE_ONCE &&
|
||||
Number.isFinite(previousTapAt) &&
|
||||
currentTime >= previousTapAt &&
|
||||
currentTime - previousTapAt <= windowMs
|
||||
) {
|
||||
return TOUCH_SHIFT_MODE_LOCK;
|
||||
}
|
||||
return TOUCH_SHIFT_MODE_ONCE;
|
||||
}
|
||||
|
||||
function findCommonPrefixLength(previousValue, nextValue) {
|
||||
const max = Math.min(previousValue.length, nextValue.length);
|
||||
let index = 0;
|
||||
while (index < max && previousValue[index] === nextValue[index]) {
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function findCommonSuffixLength(previousValue, nextValue, prefixLength) {
|
||||
const previousRemain = previousValue.length - prefixLength;
|
||||
const nextRemain = nextValue.length - prefixLength;
|
||||
const max = Math.min(previousRemain, nextRemain);
|
||||
let index = 0;
|
||||
while (
|
||||
index < max &&
|
||||
previousValue[previousValue.length - 1 - index] === nextValue[nextValue.length - 1 - index]
|
||||
) {
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function applyTouchShiftToValue(previousValue, nextValue, mode) {
|
||||
const previousText = String(previousValue || "");
|
||||
const nextText = String(nextValue || "");
|
||||
const normalizedMode = normalizeTouchShiftMode(mode);
|
||||
|
||||
if (!isTouchShiftActive(normalizedMode) || !nextText) {
|
||||
return {
|
||||
value: nextText,
|
||||
consumedOnce: false,
|
||||
touchedLetter: false,
|
||||
transformed: false
|
||||
};
|
||||
}
|
||||
|
||||
const prefixLength = findCommonPrefixLength(previousText, nextText);
|
||||
const suffixLength = findCommonSuffixLength(previousText, nextText, prefixLength);
|
||||
const insertedEnd = nextText.length - suffixLength;
|
||||
const insertedText = nextText.slice(prefixLength, insertedEnd);
|
||||
|
||||
if (!insertedText) {
|
||||
return {
|
||||
value: nextText,
|
||||
consumedOnce: false,
|
||||
touchedLetter: false,
|
||||
transformed: false
|
||||
};
|
||||
}
|
||||
|
||||
const touchedLetter = isAsciiLetter(insertedText);
|
||||
const transformedInserted = insertedText.replace(/[a-z]/g, (match) => match.toUpperCase());
|
||||
const transformed = transformedInserted !== insertedText;
|
||||
const value = transformed
|
||||
? `${nextText.slice(0, prefixLength)}${transformedInserted}${nextText.slice(insertedEnd)}`
|
||||
: nextText;
|
||||
|
||||
return {
|
||||
value,
|
||||
consumedOnce: normalizedMode === TOUCH_SHIFT_MODE_ONCE && touchedLetter,
|
||||
touchedLetter,
|
||||
transformed
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TOUCH_SHIFT_DOUBLE_TAP_MS,
|
||||
TOUCH_SHIFT_MODE_LOCK,
|
||||
TOUCH_SHIFT_MODE_OFF,
|
||||
TOUCH_SHIFT_MODE_ONCE,
|
||||
applyTouchShiftToValue,
|
||||
isTouchShiftActive,
|
||||
normalizeTouchShiftMode,
|
||||
resolveTouchShiftModeOnTap
|
||||
};
|
||||
61
apps/miniprogram/pages/terminal/touchShiftState.test.ts
Normal file
61
apps/miniprogram/pages/terminal/touchShiftState.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const {
|
||||
TOUCH_SHIFT_MODE_LOCK,
|
||||
TOUCH_SHIFT_MODE_OFF,
|
||||
TOUCH_SHIFT_MODE_ONCE,
|
||||
applyTouchShiftToValue,
|
||||
isTouchShiftActive,
|
||||
normalizeTouchShiftMode,
|
||||
resolveTouchShiftModeOnTap
|
||||
} = require("./touchShiftState.js");
|
||||
|
||||
describe("touchShiftState", () => {
|
||||
it("shift 单击进入单次大写,双击进入锁定,再点一次退出", () => {
|
||||
expect(resolveTouchShiftModeOnTap(TOUCH_SHIFT_MODE_OFF, 0, 1000, 320)).toBe(TOUCH_SHIFT_MODE_ONCE);
|
||||
expect(resolveTouchShiftModeOnTap(TOUCH_SHIFT_MODE_ONCE, 1000, 1200, 320)).toBe(TOUCH_SHIFT_MODE_LOCK);
|
||||
expect(resolveTouchShiftModeOnTap(TOUCH_SHIFT_MODE_LOCK, 0, 1500, 320)).toBe(TOUCH_SHIFT_MODE_OFF);
|
||||
});
|
||||
|
||||
it("状态归一化和激活判断正确", () => {
|
||||
expect(normalizeTouchShiftMode("once")).toBe(TOUCH_SHIFT_MODE_ONCE);
|
||||
expect(normalizeTouchShiftMode("lock")).toBe(TOUCH_SHIFT_MODE_LOCK);
|
||||
expect(normalizeTouchShiftMode("unknown")).toBe(TOUCH_SHIFT_MODE_OFF);
|
||||
expect(isTouchShiftActive(TOUCH_SHIFT_MODE_OFF)).toBe(false);
|
||||
expect(isTouchShiftActive(TOUCH_SHIFT_MODE_ONCE)).toBe(true);
|
||||
expect(isTouchShiftActive(TOUCH_SHIFT_MODE_LOCK)).toBe(true);
|
||||
});
|
||||
|
||||
it("单次大写只把下一次英文输入转成大写,并在命中字母后消费", () => {
|
||||
expect(applyTouchShiftToValue("", "a", TOUCH_SHIFT_MODE_ONCE)).toEqual({
|
||||
value: "A",
|
||||
consumedOnce: true,
|
||||
touchedLetter: true,
|
||||
transformed: true
|
||||
});
|
||||
expect(applyTouchShiftToValue("A", "A1", TOUCH_SHIFT_MODE_ONCE)).toEqual({
|
||||
value: "A1",
|
||||
consumedOnce: false,
|
||||
touchedLetter: false,
|
||||
transformed: false
|
||||
});
|
||||
});
|
||||
|
||||
it("锁定大写会持续转换后续英文输入", () => {
|
||||
expect(applyTouchShiftToValue("A", "Ab", TOUCH_SHIFT_MODE_LOCK)).toEqual({
|
||||
value: "AB",
|
||||
consumedOnce: false,
|
||||
touchedLetter: true,
|
||||
transformed: true
|
||||
});
|
||||
});
|
||||
|
||||
it("替换中间文本时只转换新增的英文片段", () => {
|
||||
expect(applyTouchShiftToValue("abZ", "acZ", TOUCH_SHIFT_MODE_ONCE)).toEqual({
|
||||
value: "aCZ",
|
||||
consumedOnce: true,
|
||||
touchedLetter: true,
|
||||
transformed: true
|
||||
});
|
||||
});
|
||||
});
|
||||
260
apps/miniprogram/pages/terminal/ttsPlayback.test.ts
Normal file
260
apps/miniprogram/pages/terminal/ttsPlayback.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type TerminalPageOptions = {
|
||||
data?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
type TtsQueueItem = {
|
||||
ready?: boolean;
|
||||
playbackUrl?: string;
|
||||
remoteAudioUrl?: string;
|
||||
useRemotePlayback?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
type TerminalPageRuntime = {
|
||||
playQueue: TtsQueueItem[];
|
||||
playingSegmentIndex: number;
|
||||
playbackPhase: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
type TerminalPageInstance = TerminalPageOptions & {
|
||||
data: Record<string, unknown>;
|
||||
ttsRuntime: TerminalPageRuntime;
|
||||
setData: (patch: Record<string, unknown>) => void;
|
||||
initTtsRuntime: () => void;
|
||||
createTtsPlaybackJob: (segments: string[]) => number;
|
||||
playTtsQueueSegment: (jobId: number, segmentIndex: number) => Promise<boolean>;
|
||||
prepareTtsQueueItem: ReturnType<typeof vi.fn>;
|
||||
localizeTerminalMessage: ReturnType<typeof vi.fn>;
|
||||
showLocalizedToast: ReturnType<typeof vi.fn>;
|
||||
applyTtsInnerAudioOptions: ReturnType<typeof vi.fn>;
|
||||
prefetchNextTtsQueueItem: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
type MiniprogramGlobals = typeof globalThis & {
|
||||
Page?: (options: TerminalPageOptions) => void;
|
||||
wx?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type AudioHandlerName = "canplay" | "play" | "ended" | "stop" | "error";
|
||||
|
||||
function createAudioContextMock() {
|
||||
const handlers: Partial<Record<AudioHandlerName, (payload?: unknown) => void>> = {};
|
||||
const audioContext = {
|
||||
src: "",
|
||||
autoplay: false,
|
||||
obeyMuteSwitch: false,
|
||||
onCanplay(callback: (payload?: unknown) => void) {
|
||||
handlers.canplay = callback;
|
||||
},
|
||||
onPlay(callback: (payload?: unknown) => void) {
|
||||
handlers.play = callback;
|
||||
},
|
||||
onEnded(callback: (payload?: unknown) => void) {
|
||||
handlers.ended = callback;
|
||||
},
|
||||
onStop(callback: (payload?: unknown) => void) {
|
||||
handlers.stop = callback;
|
||||
},
|
||||
onError(callback: (payload?: unknown) => void) {
|
||||
handlers.error = callback;
|
||||
},
|
||||
play: vi.fn(),
|
||||
stop: vi.fn(() => {
|
||||
handlers.stop?.();
|
||||
}),
|
||||
destroy: vi.fn(),
|
||||
emit(name: AudioHandlerName, payload?: unknown) {
|
||||
handlers[name]?.(payload);
|
||||
}
|
||||
};
|
||||
return audioContext;
|
||||
}
|
||||
|
||||
function flushMicrotasks(): Promise<void> {
|
||||
return Promise.resolve().then(() => undefined);
|
||||
}
|
||||
|
||||
function createTerminalPageHarness() {
|
||||
const globalState = globalThis as MiniprogramGlobals;
|
||||
let capturedPageOptions: TerminalPageOptions | null = null;
|
||||
const audioContexts: ReturnType<typeof createAudioContextMock>[] = [];
|
||||
const noop = () => {};
|
||||
|
||||
vi.resetModules();
|
||||
delete require.cache[require.resolve("./index.js")];
|
||||
globalState.Page = vi.fn((options: TerminalPageOptions) => {
|
||||
capturedPageOptions = options;
|
||||
});
|
||||
globalState.wx = {
|
||||
env: {
|
||||
USER_DATA_PATH: "/tmp"
|
||||
},
|
||||
getRecorderManager: vi.fn(() => ({
|
||||
onStart: noop,
|
||||
onStop: noop,
|
||||
onError: noop,
|
||||
onFrameRecorded: noop,
|
||||
start: noop,
|
||||
stop: noop
|
||||
})),
|
||||
createInnerAudioContext: vi.fn(() => {
|
||||
const audioContext = createAudioContextMock();
|
||||
audioContexts.push(audioContext);
|
||||
return audioContext;
|
||||
}),
|
||||
setInnerAudioOption: vi.fn(),
|
||||
createSelectorQuery: vi.fn(() => ({
|
||||
in: vi.fn(() => ({
|
||||
select: vi.fn(() => ({
|
||||
boundingClientRect: vi.fn(() => ({
|
||||
exec: noop
|
||||
}))
|
||||
}))
|
||||
}))
|
||||
})),
|
||||
nextTick: vi.fn((callback?: () => void) => {
|
||||
callback?.();
|
||||
}),
|
||||
getSystemInfoSync: vi.fn(() => ({})),
|
||||
canIUse: vi.fn(() => false)
|
||||
};
|
||||
|
||||
require("./index.js");
|
||||
|
||||
if (!capturedPageOptions) {
|
||||
throw new Error("terminal page not captured");
|
||||
}
|
||||
|
||||
const captured = capturedPageOptions as TerminalPageOptions;
|
||||
const page = {
|
||||
...captured,
|
||||
data: JSON.parse(JSON.stringify(captured.data || {})) as Record<string, unknown>,
|
||||
setData(patch: Record<string, unknown>) {
|
||||
Object.assign(this.data, patch);
|
||||
}
|
||||
} as TerminalPageInstance;
|
||||
|
||||
page.localizeTerminalMessage = vi.fn((message: string) => String(message || ""));
|
||||
page.showLocalizedToast = vi.fn();
|
||||
page.applyTtsInnerAudioOptions = vi.fn();
|
||||
page.prefetchNextTtsQueueItem = vi.fn();
|
||||
page.initTtsRuntime();
|
||||
page.setData({
|
||||
ttsEnabled: true,
|
||||
ttsState: "idle",
|
||||
ttsErrorMessage: ""
|
||||
});
|
||||
|
||||
return {
|
||||
page,
|
||||
audioContexts
|
||||
};
|
||||
}
|
||||
|
||||
describe("terminal ttsPlayback", () => {
|
||||
const globalState = globalThis as MiniprogramGlobals;
|
||||
const originalPage = globalState.Page;
|
||||
const originalWx = globalState.wx;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
if (originalPage) {
|
||||
globalState.Page = originalPage;
|
||||
} else {
|
||||
delete globalState.Page;
|
||||
}
|
||||
if (originalWx) {
|
||||
globalState.wx = originalWx;
|
||||
} else {
|
||||
delete globalState.wx;
|
||||
}
|
||||
});
|
||||
|
||||
it("本地缓存播放失败时应自动回退到远端音频地址", async () => {
|
||||
const { page, audioContexts } = createTerminalPageHarness();
|
||||
const jobId = page.createTtsPlaybackJob(["第一段"]);
|
||||
const item = page.ttsRuntime.playQueue[0];
|
||||
item.ready = true;
|
||||
item.playbackUrl = "/tmp/tts-cache-cache-1.mp3";
|
||||
item.remoteAudioUrl = "https://gateway.example.com/api/miniprogram/tts/audio/cache-1?ticket=demo";
|
||||
page.prepareTtsQueueItem = vi.fn().mockResolvedValue(item);
|
||||
|
||||
await page.playTtsQueueSegment(jobId, 0);
|
||||
const localAudioContext = audioContexts[0];
|
||||
|
||||
expect(localAudioContext.src).toBe("/tmp/tts-cache-cache-1.mp3");
|
||||
expect(page.data.ttsState).toBe("preparing");
|
||||
|
||||
localAudioContext.emit("error", { errCode: 10001 });
|
||||
const remoteAudioContext = audioContexts[1];
|
||||
|
||||
expect(item.useRemotePlayback).toBe(true);
|
||||
expect(localAudioContext.stop).toHaveBeenCalledTimes(1);
|
||||
expect(localAudioContext.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(remoteAudioContext.src).toBe(
|
||||
"https://gateway.example.com/api/miniprogram/tts/audio/cache-1?ticket=demo"
|
||||
);
|
||||
expect(page.data.ttsState).toBe("preparing");
|
||||
expect(page.showLocalizedToast).not.toHaveBeenCalled();
|
||||
|
||||
/**
|
||||
* 旧播放器实例的迟到错误事件不应把已经切到远端地址的新实例拉回失败态。
|
||||
*/
|
||||
localAudioContext.emit("error", { errCode: 10001 });
|
||||
expect(page.data.ttsState).toBe("preparing");
|
||||
expect(page.showLocalizedToast).not.toHaveBeenCalled();
|
||||
|
||||
remoteAudioContext.emit("play");
|
||||
|
||||
expect(page.data.ttsState).toBe("playing");
|
||||
expect(page.data.ttsErrorMessage).toBe("");
|
||||
});
|
||||
|
||||
it("旧播放器实例的迟到 stop/ended 事件不应打断下一段播放", async () => {
|
||||
const { page, audioContexts } = createTerminalPageHarness();
|
||||
const jobId = page.createTtsPlaybackJob(["第一段", "第二段", "第三段"]);
|
||||
page.ttsRuntime.playQueue.forEach((item: TtsQueueItem, index: number) => {
|
||||
item.ready = true;
|
||||
item.playbackUrl = `/tmp/seg-${index + 1}.mp3`;
|
||||
item.remoteAudioUrl = `https://gateway.example.com/seg-${index + 1}.mp3`;
|
||||
});
|
||||
page.prepareTtsQueueItem = vi.fn(
|
||||
async (_jobId: number, segmentIndex: number) => page.ttsRuntime.playQueue[segmentIndex]
|
||||
);
|
||||
|
||||
await page.playTtsQueueSegment(jobId, 0);
|
||||
const firstAudioContext = audioContexts[0];
|
||||
firstAudioContext.emit("play");
|
||||
|
||||
expect(page.ttsRuntime.playingSegmentIndex).toBe(0);
|
||||
expect(page.data.ttsState).toBe("playing");
|
||||
|
||||
firstAudioContext.emit("ended");
|
||||
await flushMicrotasks();
|
||||
const secondAudioContext = audioContexts[1];
|
||||
|
||||
expect(page.ttsRuntime.playingSegmentIndex).toBe(1);
|
||||
expect(page.ttsRuntime.playbackPhase).toBe("loading");
|
||||
expect(secondAudioContext.src).toBe("/tmp/seg-2.mp3");
|
||||
|
||||
firstAudioContext.emit("stop");
|
||||
firstAudioContext.emit("ended");
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(page.data.ttsState).toBe("preparing");
|
||||
expect(page.ttsRuntime.playbackPhase).toBe("loading");
|
||||
expect(page.ttsRuntime.playingSegmentIndex).toBe(1);
|
||||
expect(secondAudioContext.src).toBe("/tmp/seg-2.mp3");
|
||||
expect(page.prepareTtsQueueItem).toHaveBeenCalledTimes(2);
|
||||
|
||||
secondAudioContext.emit("play");
|
||||
expect(page.data.ttsState).toBe("playing");
|
||||
});
|
||||
});
|
||||
61
apps/miniprogram/pages/terminal/voiceGatewayError.js
Normal file
61
apps/miniprogram/pages/terminal/voiceGatewayError.js
Normal file
@@ -0,0 +1,61 @@
|
||||
/* global module, require */
|
||||
|
||||
const { resolveVoicePrivacyErrorMessage } = require("./voicePrivacy");
|
||||
|
||||
/**
|
||||
* 统一收敛语音网关错误:
|
||||
* 1. 隐私权限错误优先翻译,避免误报为网关问题;
|
||||
* 2. `url not in domain list` 单独归类为 socket 合法域名问题;
|
||||
* 3. 其他 `connectSocket:fail` 视为网络或网关配置问题,不再误导成域名问题。
|
||||
*/
|
||||
|
||||
function normalizeMessage(input, fallback) {
|
||||
if (typeof input === "string" && input.trim()) return input.trim();
|
||||
return typeof fallback === "string" ? fallback : "";
|
||||
}
|
||||
|
||||
function resolveVoiceGatewayErrorState(input, fallback) {
|
||||
const raw = normalizeMessage(input, fallback);
|
||||
if (!raw) {
|
||||
return { message: "", showSocketDomainModal: false };
|
||||
}
|
||||
|
||||
const privacyMessage = resolveVoicePrivacyErrorMessage(raw, raw);
|
||||
if (privacyMessage !== raw) {
|
||||
return { message: privacyMessage, showSocketDomainModal: false };
|
||||
}
|
||||
|
||||
if (/auth deny|scope\.record|authorize/i.test(raw)) {
|
||||
return {
|
||||
message: "麦克风权限未开启,请在设置中允许录音",
|
||||
showSocketDomainModal: false
|
||||
};
|
||||
}
|
||||
|
||||
if (/url not in domain list/i.test(raw)) {
|
||||
return {
|
||||
message: "语音网关连接失败,请检查小程序 socket 合法域名",
|
||||
showSocketDomainModal: true
|
||||
};
|
||||
}
|
||||
|
||||
if (/ready_timeout|连接超时/i.test(raw)) {
|
||||
return {
|
||||
message: "语音服务连接超时,请稍后重试",
|
||||
showSocketDomainModal: false
|
||||
};
|
||||
}
|
||||
|
||||
if (/connectSocket:fail/i.test(raw)) {
|
||||
return {
|
||||
message: "语音网关连接失败,请检查网络或网关配置",
|
||||
showSocketDomainModal: false
|
||||
};
|
||||
}
|
||||
|
||||
return { message: raw, showSocketDomainModal: false };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveVoiceGatewayErrorState
|
||||
};
|
||||
28
apps/miniprogram/pages/terminal/voiceGatewayError.test.ts
Normal file
28
apps/miniprogram/pages/terminal/voiceGatewayError.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const { resolveVoiceGatewayErrorState } = require("./voiceGatewayError.js");
|
||||
|
||||
describe("voiceGatewayError", () => {
|
||||
it("仅在域名白名单错误时提示 socket 合法域名", () => {
|
||||
expect(resolveVoiceGatewayErrorState("connectSocket:fail url not in domain list")).toEqual({
|
||||
message: "语音网关连接失败,请检查小程序 socket 合法域名",
|
||||
showSocketDomainModal: true
|
||||
});
|
||||
});
|
||||
|
||||
it("将其他 connectSocket 失败归类为网络或网关配置问题", () => {
|
||||
expect(
|
||||
resolveVoiceGatewayErrorState("语音网关连接失败: connectSocket:fail SSL handshake failed")
|
||||
).toEqual({
|
||||
message: "语音网关连接失败,请检查网络或网关配置",
|
||||
showSocketDomainModal: false
|
||||
});
|
||||
});
|
||||
|
||||
it("保留 ready_timeout 的独立超时提示", () => {
|
||||
expect(resolveVoiceGatewayErrorState("ready_timeout")).toEqual({
|
||||
message: "语音服务连接超时,请稍后重试",
|
||||
showSocketDomainModal: false
|
||||
});
|
||||
});
|
||||
});
|
||||
47
apps/miniprogram/pages/terminal/voicePrivacy.js
Normal file
47
apps/miniprogram/pages/terminal/voicePrivacy.js
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 统一收敛微信隐私相关错误,避免页面层散落字符串匹配。
|
||||
* 这里保持纯函数,便于 Vitest 直接覆盖,不依赖小程序运行时。
|
||||
*/
|
||||
|
||||
function normalizeMessage(input, fallback) {
|
||||
if (typeof input === "string" && input.trim()) return input.trim();
|
||||
return typeof fallback === "string" ? fallback : "";
|
||||
}
|
||||
|
||||
function isPrivacyApiBannedMessage(message) {
|
||||
return /appid privacy api banned/i.test(String(message || ""));
|
||||
}
|
||||
|
||||
function isPrivacyScopeUndeclaredMessage(message) {
|
||||
return /api scope is not declared in the privacy agreement/i.test(String(message || ""));
|
||||
}
|
||||
|
||||
function isPrivacyAuthorizationDeniedMessage(message) {
|
||||
return /privacy.*(deny|denied|disagree|reject|refuse)|errno["']?\s*[:=]\s*10[34]/i.test(
|
||||
String(message || "")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将微信侧原始隐私错误翻译成可执行的中文提示。
|
||||
*/
|
||||
function resolveVoicePrivacyErrorMessage(input, fallback) {
|
||||
const message = normalizeMessage(input, fallback);
|
||||
if (!message) return "";
|
||||
if (isPrivacyApiBannedMessage(message)) {
|
||||
return "小程序后台未完成隐私声明,录音接口已被微信平台禁用,请在微信公众平台补充用户隐私保护指引后重新提审并发布";
|
||||
}
|
||||
if (isPrivacyScopeUndeclaredMessage(message)) {
|
||||
return "小程序隐私指引未声明录音相关用途,请在微信公众平台“服务内容声明-用户隐私保护指引”补充麦克风采集说明";
|
||||
}
|
||||
if (isPrivacyAuthorizationDeniedMessage(message)) {
|
||||
return "未同意隐私协议,暂时无法使用录音";
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isPrivacyApiBannedMessage,
|
||||
isPrivacyScopeUndeclaredMessage,
|
||||
resolveVoicePrivacyErrorMessage
|
||||
};
|
||||
23
apps/miniprogram/pages/terminal/voicePrivacy.test.ts
Normal file
23
apps/miniprogram/pages/terminal/voicePrivacy.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const { resolveVoicePrivacyErrorMessage } = require("./voicePrivacy.js");
|
||||
|
||||
describe("voicePrivacy", () => {
|
||||
it("将平台回收权限错误翻译为后台配置提示", () => {
|
||||
expect(resolveVoicePrivacyErrorMessage("operateRecorder:fail appid privacy api banned")).toContain(
|
||||
"录音接口已被微信平台禁用"
|
||||
);
|
||||
});
|
||||
|
||||
it("将未声明隐私范围错误翻译为补充指引提示", () => {
|
||||
expect(
|
||||
resolveVoicePrivacyErrorMessage(
|
||||
"getRecorderManager:fail api scope is not declared in the privacy agreement"
|
||||
)
|
||||
).toContain("补充麦克风采集说明");
|
||||
});
|
||||
|
||||
it("保留非隐私错误原文,避免误伤其他录音异常", () => {
|
||||
expect(resolveVoicePrivacyErrorMessage("录音采集失败")).toBe("录音采集失败");
|
||||
});
|
||||
});
|
||||
558
apps/miniprogram/pages/terminal/vtInputHandler.js
Normal file
558
apps/miniprogram/pages/terminal/vtInputHandler.js
Normal file
@@ -0,0 +1,558 @@
|
||||
/* global module, require */
|
||||
|
||||
/**
|
||||
* 轻量 VT 输入处理层:
|
||||
* 1. 承接“模式位切换 / 查询响应 / 软重置”这类协议状态逻辑;
|
||||
* 2. 不直接持有 buffer 数据结构,只通过上下文回调读写运行态;
|
||||
* 3. 先把最容易继续膨胀的协议分支从 `terminalBufferState` 中拆出,为后续继续下沉 CSI/ESC 处理铺路。
|
||||
*/
|
||||
|
||||
const { DEFAULT_TERMINAL_MODES } = require("./terminalBufferSet.js");
|
||||
|
||||
function createTerminalVtInputHandler(options) {
|
||||
const source = options && typeof options === "object" ? options : {};
|
||||
const responses = Array.isArray(source.responses) ? source.responses : [];
|
||||
const runtimeColors = source.runtimeColors && typeof source.runtimeColors === "object" ? source.runtimeColors : {};
|
||||
const runtimeState = source.runtimeState && typeof source.runtimeState === "object" ? source.runtimeState : {};
|
||||
const bufferCols = Math.max(1, Math.round(Number(source.bufferCols) || 0));
|
||||
const bufferRows = Math.max(1, Math.round(Number(source.bufferRows) || 0));
|
||||
const cloneAnsiState =
|
||||
typeof source.cloneAnsiState === "function"
|
||||
? source.cloneAnsiState
|
||||
: (state) => ({ ...(state && typeof state === "object" ? state : {}) });
|
||||
const toOscRgbString =
|
||||
typeof source.toOscRgbString === "function" ? source.toOscRgbString : () => "";
|
||||
const getActiveBuffer =
|
||||
typeof source.getActiveBuffer === "function" ? source.getActiveBuffer : () => null;
|
||||
const setCursorRow = typeof source.setCursorRow === "function" ? source.setCursorRow : () => {};
|
||||
const getCursorCol = typeof source.getCursorCol === "function" ? source.getCursorCol : () => 0;
|
||||
const setCursorCol = typeof source.setCursorCol === "function" ? source.setCursorCol : () => {};
|
||||
const getScrollTop = typeof source.getScrollTop === "function" ? source.getScrollTop : () => 0;
|
||||
const setScrollTop = typeof source.setScrollTop === "function" ? source.setScrollTop : () => {};
|
||||
const getScrollBottom =
|
||||
typeof source.getScrollBottom === "function" ? source.getScrollBottom : () => Math.max(0, bufferRows - 1);
|
||||
const setScrollBottom =
|
||||
typeof source.setScrollBottom === "function" ? source.setScrollBottom : () => {};
|
||||
const setAnsiState = typeof source.setAnsiState === "function" ? source.setAnsiState : () => {};
|
||||
const moveCursorUp = typeof source.moveCursorUp === "function" ? source.moveCursorUp : () => {};
|
||||
const moveCursorDown = typeof source.moveCursorDown === "function" ? source.moveCursorDown : () => {};
|
||||
const moveCursorRight =
|
||||
typeof source.moveCursorRight === "function" ? source.moveCursorRight : () => {};
|
||||
const moveCursorLeft = typeof source.moveCursorLeft === "function" ? source.moveCursorLeft : () => {};
|
||||
const moveCursorNextLine =
|
||||
typeof source.moveCursorNextLine === "function" ? source.moveCursorNextLine : () => {};
|
||||
const moveCursorPreviousLine =
|
||||
typeof source.moveCursorPreviousLine === "function" ? source.moveCursorPreviousLine : () => {};
|
||||
const setCursorColumn1 =
|
||||
typeof source.setCursorColumn1 === "function" ? source.setCursorColumn1 : () => {};
|
||||
const setCursorRow1 = typeof source.setCursorRow1 === "function" ? source.setCursorRow1 : () => {};
|
||||
const setCursorPosition1 =
|
||||
typeof source.setCursorPosition1 === "function" ? source.setCursorPosition1 : () => {};
|
||||
const clearDisplayByMode =
|
||||
typeof source.clearDisplayByMode === "function" ? source.clearDisplayByMode : () => {};
|
||||
const clearLineByMode =
|
||||
typeof source.clearLineByMode === "function" ? source.clearLineByMode : () => {};
|
||||
const eraseChars = typeof source.eraseChars === "function" ? source.eraseChars : () => {};
|
||||
const insertChars = typeof source.insertChars === "function" ? source.insertChars : () => {};
|
||||
const deleteChars = typeof source.deleteChars === "function" ? source.deleteChars : () => {};
|
||||
const insertLines = typeof source.insertLines === "function" ? source.insertLines : () => {};
|
||||
const deleteLines = typeof source.deleteLines === "function" ? source.deleteLines : () => {};
|
||||
const scrollRegionUp =
|
||||
typeof source.scrollRegionUp === "function" ? source.scrollRegionUp : () => {};
|
||||
const scrollRegionDown =
|
||||
typeof source.scrollRegionDown === "function" ? source.scrollRegionDown : () => {};
|
||||
const setScrollRegion =
|
||||
typeof source.setScrollRegion === "function" ? source.setScrollRegion : () => {};
|
||||
const resetCursorForOriginMode =
|
||||
typeof source.resetCursorForOriginMode === "function" ? source.resetCursorForOriginMode : null;
|
||||
const indexDown = typeof source.indexDown === "function" ? source.indexDown : () => {};
|
||||
const nextLine = typeof source.nextLine === "function" ? source.nextLine : () => {};
|
||||
const reverseIndex =
|
||||
typeof source.reverseIndex === "function" ? source.reverseIndex : () => {};
|
||||
const getScreenCursorRow =
|
||||
typeof source.getScreenCursorRow === "function" ? source.getScreenCursorRow : () => 0;
|
||||
const saveCurrentCursor =
|
||||
typeof source.saveCurrentCursor === "function" ? source.saveCurrentCursor : () => {};
|
||||
const restoreCurrentCursor =
|
||||
typeof source.restoreCurrentCursor === "function" ? source.restoreCurrentCursor : () => {};
|
||||
const switchActiveBuffer =
|
||||
typeof source.switchActiveBuffer === "function" ? source.switchActiveBuffer : () => {};
|
||||
const ansiResetState =
|
||||
source.ansiResetState && typeof source.ansiResetState === "object" ? source.ansiResetState : {};
|
||||
|
||||
function pushDeviceStatusResponse(privateMarker, code) {
|
||||
if (code === 5) {
|
||||
responses.push(`${privateMarker === "?" ? "\u001b[?" : "\u001b["}0n`);
|
||||
return;
|
||||
}
|
||||
if (code === 6) {
|
||||
const row = getScreenCursorRow() + 1;
|
||||
const col = Math.min(bufferCols, getCursorCol()) + 1;
|
||||
responses.push(privateMarker === "?" ? `\u001b[?${row};${col}R` : `\u001b[${row};${col}R`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备属性查询只回报当前实现真实支持的“最小可用口径”,
|
||||
* 避免把不存在的终端特性伪装成已支持。
|
||||
*/
|
||||
function pushDeviceAttributesResponse(privateMarker, code) {
|
||||
if (code > 0) {
|
||||
return;
|
||||
}
|
||||
if (privateMarker === ">") {
|
||||
responses.push("\u001b[>0;276;0c");
|
||||
return;
|
||||
}
|
||||
responses.push("\u001b[?1;2c");
|
||||
}
|
||||
|
||||
function pushOscColorReport(ident) {
|
||||
let color = "";
|
||||
if (ident === "10") {
|
||||
color = runtimeColors.defaultForeground;
|
||||
} else if (ident === "11") {
|
||||
color = runtimeColors.defaultBackground;
|
||||
} else if (ident === "12") {
|
||||
color = runtimeColors.defaultCursor;
|
||||
}
|
||||
const rgb = toOscRgbString(color);
|
||||
if (!rgb) {
|
||||
return;
|
||||
}
|
||||
responses.push(`\u001b]${ident};${rgb}\u001b\\`);
|
||||
}
|
||||
|
||||
/**
|
||||
* OSC 10/11/12 允许把多个查询串在一条指令里,这里只对当前已实现的颜色槽位做查询响应。
|
||||
*/
|
||||
function handleOscSequence(ident, data) {
|
||||
const base = Number(ident);
|
||||
if (![10, 11, 12].includes(base)) {
|
||||
return;
|
||||
}
|
||||
const slots = String(data || "").split(";");
|
||||
for (let offset = 0; offset < slots.length; offset += 1) {
|
||||
const slotIdent = String(base + offset);
|
||||
if (!["10", "11", "12"].includes(slotIdent)) {
|
||||
break;
|
||||
}
|
||||
if (slots[offset] === "?") {
|
||||
pushOscColorReport(slotIdent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pushModeStatusResponse(privateMarker, mode, status) {
|
||||
const prefix = privateMarker === "?" ? "?" : "";
|
||||
responses.push(`\u001b[${prefix}${mode};${status}$y`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 这里只回报当前运行态里真实维护的模式位。
|
||||
* 未实现模式统一返回 0,避免协议层把“未知能力”冒充成“已支持”。
|
||||
*/
|
||||
function resolveModeStatus(privateMarker, value) {
|
||||
const mode = Math.round(Number(value) || 0);
|
||||
if (!mode) return 0;
|
||||
if (privateMarker !== "?") {
|
||||
if (mode === 4) {
|
||||
return runtimeState.modes.insertMode ? 1 : 2;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (mode === 1) return runtimeState.modes.applicationCursorKeys ? 1 : 2;
|
||||
if (mode === 6) return runtimeState.modes.originMode ? 1 : 2;
|
||||
if (mode === 7) return runtimeState.modes.wraparound ? 1 : 2;
|
||||
if (mode === 25) return runtimeState.modes.cursorHidden ? 2 : 1;
|
||||
if (mode === 45) return runtimeState.modes.reverseWraparound ? 1 : 2;
|
||||
if (mode === 66) return runtimeState.modes.applicationKeypad ? 1 : 2;
|
||||
if (mode === 47 || mode === 1047 || mode === 1049) {
|
||||
const activeBuffer = getActiveBuffer();
|
||||
return activeBuffer && activeBuffer.isAlt ? 1 : 2;
|
||||
}
|
||||
if (mode === 1048) return 1;
|
||||
if (mode === 1004) return runtimeState.modes.sendFocus ? 1 : 2;
|
||||
if (mode === 2004) return runtimeState.modes.bracketedPasteMode ? 1 : 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function pushModeReport(privateMarker, value) {
|
||||
const mode = Math.round(Number(value) || 0);
|
||||
if (!mode) return;
|
||||
pushModeStatusResponse(privateMarker, mode, resolveModeStatus(privateMarker, mode));
|
||||
}
|
||||
|
||||
function pushStatusStringResponse(payload) {
|
||||
const value = String(payload || "");
|
||||
if (value === "m") {
|
||||
responses.push("\u001bP1$r0m\u001b\\");
|
||||
return;
|
||||
}
|
||||
if (value === "r") {
|
||||
responses.push(`\u001bP1$r${getScrollTop() + 1};${getScrollBottom() + 1}r\u001b\\`);
|
||||
return;
|
||||
}
|
||||
if (value === " q") {
|
||||
responses.push("\u001bP1$r2 q\u001b\\");
|
||||
return;
|
||||
}
|
||||
if (value === '"q') {
|
||||
responses.push('\u001bP1$r0"q\u001b\\');
|
||||
return;
|
||||
}
|
||||
if (value === '"p') {
|
||||
responses.push('\u001bP1$r61;1"p\u001b\\');
|
||||
return;
|
||||
}
|
||||
responses.push("\u001bP0$r\u001b\\");
|
||||
}
|
||||
|
||||
/**
|
||||
* DECSTR 只做软重置,不清屏、不搬动当前 cursor。
|
||||
* 这部分逻辑必须保持和现有主链路一致,避免复杂 TUI 在软重置后发生可见跳变。
|
||||
*/
|
||||
function softResetTerminal() {
|
||||
runtimeState.modes = { ...DEFAULT_TERMINAL_MODES };
|
||||
setAnsiState(cloneAnsiState(ansiResetState));
|
||||
setScrollTop(0);
|
||||
setScrollBottom(Math.max(0, bufferRows - 1));
|
||||
|
||||
const activeBuffer = getActiveBuffer();
|
||||
if (activeBuffer) {
|
||||
activeBuffer.savedCursorRow = 0;
|
||||
activeBuffer.savedCursorCol = 0;
|
||||
activeBuffer.savedAnsiState = cloneAnsiState(ansiResetState);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePrivateMode(enabled, value) {
|
||||
const mode = Math.round(Number(value) || 0);
|
||||
if (!mode) return;
|
||||
if (mode === 1) {
|
||||
runtimeState.modes.applicationCursorKeys = enabled;
|
||||
return;
|
||||
}
|
||||
if (mode === 6) {
|
||||
runtimeState.modes.originMode = enabled;
|
||||
if (resetCursorForOriginMode) {
|
||||
resetCursorForOriginMode(enabled);
|
||||
return;
|
||||
}
|
||||
const activeBuffer = getActiveBuffer();
|
||||
setCursorRow(enabled && activeBuffer && activeBuffer.isAlt ? getScrollTop() : 0);
|
||||
setCursorCol(0);
|
||||
return;
|
||||
}
|
||||
if (mode === 45) {
|
||||
runtimeState.modes.reverseWraparound = enabled;
|
||||
return;
|
||||
}
|
||||
if (mode === 66) {
|
||||
runtimeState.modes.applicationKeypad = enabled;
|
||||
return;
|
||||
}
|
||||
if (mode === 7) {
|
||||
runtimeState.modes.wraparound = enabled;
|
||||
return;
|
||||
}
|
||||
if (mode === 25) {
|
||||
runtimeState.modes.cursorHidden = !enabled;
|
||||
return;
|
||||
}
|
||||
if (mode === 47 || mode === 1047) {
|
||||
switchActiveBuffer(enabled ? "alt" : "normal", enabled);
|
||||
return;
|
||||
}
|
||||
if (mode === 1048) {
|
||||
if (enabled) {
|
||||
saveCurrentCursor();
|
||||
} else {
|
||||
restoreCurrentCursor();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (mode === 1049) {
|
||||
if (enabled) {
|
||||
saveCurrentCursor();
|
||||
switchActiveBuffer("alt", true);
|
||||
} else {
|
||||
switchActiveBuffer("normal", false);
|
||||
restoreCurrentCursor();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (mode === 2004) {
|
||||
runtimeState.modes.bracketedPasteMode = enabled;
|
||||
return;
|
||||
}
|
||||
if (mode === 1004) {
|
||||
runtimeState.modes.sendFocus = enabled;
|
||||
}
|
||||
}
|
||||
|
||||
function handleAnsiMode(enabled, value) {
|
||||
const mode = Math.round(Number(value) || 0);
|
||||
if (!mode) return;
|
||||
if (mode === 4) {
|
||||
runtimeState.modes.insertMode = enabled;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCsiNumber(values, index, fallback) {
|
||||
const value = values && Number(values[index]);
|
||||
if (!Number.isFinite(value)) return fallback;
|
||||
const normalized = Math.round(value);
|
||||
if (normalized < 0) return 0;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 这里只下沉“CSI -> 光标动作”的协议解释:
|
||||
* 1. handler 负责默认参数、1-based 行列语义和 final byte 分派;
|
||||
* 2. bufferState 仍负责真实行列边界、history/alt buffer 与 origin mode 的具体落点。
|
||||
*/
|
||||
function handleCursorControl(final, values) {
|
||||
const code = String(final || "");
|
||||
if (code === "A") {
|
||||
moveCursorUp(Math.max(1, resolveCsiNumber(values, 0, 1)));
|
||||
return true;
|
||||
}
|
||||
if (code === "B") {
|
||||
moveCursorDown(Math.max(1, resolveCsiNumber(values, 0, 1)));
|
||||
return true;
|
||||
}
|
||||
if (code === "C") {
|
||||
moveCursorRight(Math.max(1, resolveCsiNumber(values, 0, 1)));
|
||||
return true;
|
||||
}
|
||||
if (code === "D") {
|
||||
moveCursorLeft(Math.max(1, resolveCsiNumber(values, 0, 1)));
|
||||
return true;
|
||||
}
|
||||
if (code === "E") {
|
||||
moveCursorNextLine(Math.max(1, resolveCsiNumber(values, 0, 1)));
|
||||
return true;
|
||||
}
|
||||
if (code === "F") {
|
||||
moveCursorPreviousLine(Math.max(1, resolveCsiNumber(values, 0, 1)));
|
||||
return true;
|
||||
}
|
||||
if (code === "G") {
|
||||
setCursorColumn1(Math.max(1, resolveCsiNumber(values, 0, 1)));
|
||||
return true;
|
||||
}
|
||||
if (code === "d") {
|
||||
setCursorRow1(Math.max(1, resolveCsiNumber(values, 0, 1)));
|
||||
return true;
|
||||
}
|
||||
if (code === "H" || code === "f") {
|
||||
setCursorPosition1(
|
||||
Math.max(1, resolveCsiNumber(values, 0, 1)),
|
||||
Math.max(1, resolveCsiNumber(values, 1, 1))
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 擦除类 CSI 只在这里解释参数语义:
|
||||
* 1. `J / K` 走“显示/行清除模式”;
|
||||
* 2. `X` 走“从当前光标起擦除 N 列”,默认值保持 1。
|
||||
*/
|
||||
function handleEraseControl(final, values) {
|
||||
const code = String(final || "");
|
||||
if (code === "J") {
|
||||
clearDisplayByMode(resolveCsiNumber(values, 0, 0));
|
||||
return true;
|
||||
}
|
||||
if (code === "K") {
|
||||
clearLineByMode(resolveCsiNumber(values, 0, 0));
|
||||
return true;
|
||||
}
|
||||
if (code === "X") {
|
||||
eraseChars(Math.max(1, resolveCsiNumber(values, 0, 1)));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑/滚动类 CSI 仍然只解释“协议参数 -> buffer 动作”的映射,
|
||||
* 真正的 cell 变更、滚动区约束和 viewport/history 处理继续留在 bufferState。
|
||||
*/
|
||||
function handleEditControl(final, values) {
|
||||
const code = String(final || "");
|
||||
if (code === "@") {
|
||||
insertChars(Math.max(1, resolveCsiNumber(values, 0, 1)));
|
||||
return true;
|
||||
}
|
||||
if (code === "P") {
|
||||
deleteChars(Math.max(1, resolveCsiNumber(values, 0, 1)));
|
||||
return true;
|
||||
}
|
||||
if (code === "L") {
|
||||
insertLines(Math.max(1, resolveCsiNumber(values, 0, 1)));
|
||||
return true;
|
||||
}
|
||||
if (code === "M") {
|
||||
deleteLines(Math.max(1, resolveCsiNumber(values, 0, 1)));
|
||||
return true;
|
||||
}
|
||||
if (code === "S") {
|
||||
scrollRegionUp(Math.max(1, resolveCsiNumber(values, 0, 1)));
|
||||
return true;
|
||||
}
|
||||
if (code === "T") {
|
||||
scrollRegionDown(Math.max(1, resolveCsiNumber(values, 0, 1)));
|
||||
return true;
|
||||
}
|
||||
if (code === "r") {
|
||||
const top = Math.max(1, resolveCsiNumber(values, 0, 1));
|
||||
const rawBottom = values && values.length > 1 ? resolveCsiNumber(values, 1, bufferRows) : bufferRows;
|
||||
const bottom = rawBottom > 0 ? rawBottom : bufferRows;
|
||||
setScrollRegion(top, bottom);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存/恢复光标这类协议动作目前同时存在于 CSI 与 ESC 两条入口:
|
||||
* 1. `CSI s / u`
|
||||
* 2. `ESC 7 / 8`
|
||||
* 这里统一只做协议语义分派,真实保存内容仍由 bufferState 维护。
|
||||
*/
|
||||
function handleCursorSaveRestoreControl(final) {
|
||||
const code = String(final || "");
|
||||
if (code === "s" || code === "7") {
|
||||
saveCurrentCursor();
|
||||
return true;
|
||||
}
|
||||
if (code === "u" || code === "8") {
|
||||
restoreCurrentCursor();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询类 CSI 统一在这里做协议分派,减少主循环里零散的条件判断:
|
||||
* 1. `DECRQM`:`CSI Ps $ p` / `CSI ? Ps $ p`
|
||||
* 2. `DSR / CPR`:`CSI n` / `CSI ? n`
|
||||
* 3. `DA1 / DA2`:`CSI c` / `CSI > c`
|
||||
*/
|
||||
function handleQueryControl(privateMarker, intermediates, final, values) {
|
||||
const marker = String(privateMarker || "");
|
||||
const middle = String(intermediates || "");
|
||||
const code = String(final || "");
|
||||
|
||||
if (middle === "$" && code === "p" && (marker === "" || marker === "?")) {
|
||||
(Array.isArray(values) && values.length > 0 ? values : [0]).forEach((mode) => {
|
||||
pushModeReport(marker, mode);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (code === "n" && (marker === "" || marker === "?")) {
|
||||
pushDeviceStatusResponse(marker, resolveCsiNumber(values, 0, 0));
|
||||
return true;
|
||||
}
|
||||
if (code === "c" && (marker === "" || marker === ">")) {
|
||||
pushDeviceAttributesResponse(marker, resolveCsiNumber(values, 0, 0));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* ESC 入口目前只保留真正的 ESC 协议语义分派:
|
||||
* 1. `ESC 7 / 8` 保存恢复光标
|
||||
* 2. `ESC D / E / M` index / next line / reverse index
|
||||
*/
|
||||
function handleEscControl(final) {
|
||||
const code = String(final || "");
|
||||
if (handleCursorSaveRestoreControl(code)) {
|
||||
return true;
|
||||
}
|
||||
if (code === "D") {
|
||||
indexDown();
|
||||
return true;
|
||||
}
|
||||
if (code === "E") {
|
||||
nextLine();
|
||||
return true;
|
||||
}
|
||||
if (code === "M") {
|
||||
reverseIndex();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* `CSI` 顶层分派只负责“协议入口 -> 已有子处理器”的路由:
|
||||
* 1. 查询类、模式切换、软重置优先处理,保持和现有协议优先级一致;
|
||||
* 2. 普通 ANSI 模式与无私有前缀的光标/擦除/编辑动作继续复用已有细分处理器;
|
||||
* 3. `SGR` 仍留在 bufferState,因为它直接作用于当前 ansiState 运行态。
|
||||
*/
|
||||
function handleCsiControl(privateMarker, intermediates, final, values) {
|
||||
const marker = String(privateMarker || "");
|
||||
const code = String(final || "");
|
||||
|
||||
if (handleQueryControl(marker, intermediates, code, values)) {
|
||||
return true;
|
||||
}
|
||||
if (marker === "?") {
|
||||
if (code === "h" || code === "l") {
|
||||
const enabled = code === "h";
|
||||
(Array.isArray(values) && values.length > 0 ? values : [0]).forEach((mode) =>
|
||||
handlePrivateMode(enabled, mode)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (marker === "!" && code === "p") {
|
||||
softResetTerminal();
|
||||
return true;
|
||||
}
|
||||
if (!marker && (code === "h" || code === "l")) {
|
||||
values.forEach((value) => {
|
||||
handleAnsiMode(code === "h", value);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (marker) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
handleCursorControl(code, values) ||
|
||||
handleEraseControl(code, values) ||
|
||||
handleEditControl(code, values) ||
|
||||
handleCursorSaveRestoreControl(code)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
handleCsiControl,
|
||||
handleCursorControl,
|
||||
handleEraseControl,
|
||||
handleEditControl,
|
||||
handleCursorSaveRestoreControl,
|
||||
handleQueryControl,
|
||||
handleEscControl,
|
||||
handleAnsiMode,
|
||||
handleOscSequence,
|
||||
handlePrivateMode,
|
||||
pushDeviceAttributesResponse,
|
||||
pushDeviceStatusResponse,
|
||||
pushModeReport,
|
||||
pushStatusStringResponse,
|
||||
softResetTerminal
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createTerminalVtInputHandler
|
||||
};
|
||||
436
apps/miniprogram/pages/terminal/vtInputHandler.test.ts
Normal file
436
apps/miniprogram/pages/terminal/vtInputHandler.test.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const { DEFAULT_TERMINAL_MODES } = require("./terminalBufferSet.js");
|
||||
const { createTerminalVtInputHandler } = require("./vtInputHandler.js");
|
||||
|
||||
describe("vtInputHandler", () => {
|
||||
it("OSC 10/11/12 查询会把响应写回队列", () => {
|
||||
const { handler, responses } = createHandler();
|
||||
|
||||
handler.handleOscSequence(10, "?;?;?");
|
||||
|
||||
expect(responses).toEqual([
|
||||
"\u001b]10;rgb:#112233\u001b\\",
|
||||
"\u001b]11;rgb:#445566\u001b\\",
|
||||
"\u001b]12;rgb:#778899\u001b\\"
|
||||
]);
|
||||
});
|
||||
|
||||
it("模式报告只回报当前真实维护的模式位", () => {
|
||||
const { handler, responses, runtimeState, setActiveBufferAlt } = createHandler();
|
||||
|
||||
runtimeState.modes.applicationCursorKeys = true;
|
||||
runtimeState.modes.sendFocus = true;
|
||||
runtimeState.modes.insertMode = true;
|
||||
setActiveBufferAlt(true);
|
||||
|
||||
handler.pushModeReport("?", 1);
|
||||
handler.pushModeReport("?", 1049);
|
||||
handler.pushModeReport("?", 1004);
|
||||
handler.pushModeReport("", 4);
|
||||
handler.pushModeReport("?", 9999);
|
||||
|
||||
expect(responses).toEqual([
|
||||
"\u001b[?1;1$y",
|
||||
"\u001b[?1049;1$y",
|
||||
"\u001b[?1004;1$y",
|
||||
"\u001b[4;1$y",
|
||||
"\u001b[?9999;0$y"
|
||||
]);
|
||||
});
|
||||
|
||||
it("1049 私有模式切换会先保存光标,再切屏并在退出时恢复", () => {
|
||||
const { handler, switchCalls, saveCalls, restoreCalls } = createHandler();
|
||||
|
||||
handler.handlePrivateMode(true, 1049);
|
||||
handler.handlePrivateMode(false, 1049);
|
||||
|
||||
expect(saveCalls.count).toBe(1);
|
||||
expect(restoreCalls.count).toBe(1);
|
||||
expect(switchCalls).toEqual([
|
||||
{ target: "alt", clearTarget: true },
|
||||
{ target: "normal", clearTarget: false }
|
||||
]);
|
||||
});
|
||||
|
||||
it("origin mode 私有模式切换会更新模式位,并委托运行态重置光标", () => {
|
||||
const { handler, runtimeState, originModeResets } = createHandler();
|
||||
|
||||
handler.handlePrivateMode(true, 6);
|
||||
handler.handlePrivateMode(false, 6);
|
||||
|
||||
expect(runtimeState.modes.originMode).toBe(false);
|
||||
expect(originModeResets).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it("DECSTR 软重置会收回模式位与保存光标,但不会改动当前 cursor", () => {
|
||||
const { handler, runtimeState, state, activeBuffer, defaults } = createHandler();
|
||||
|
||||
state.cursorRow = 7;
|
||||
state.cursorCol = 9;
|
||||
state.scrollTop = 3;
|
||||
state.scrollBottom = 8;
|
||||
state.ansiState = { fg: "#abcdef", bg: "#123456", bold: true, underline: true };
|
||||
activeBuffer.savedCursorRow = 5;
|
||||
activeBuffer.savedCursorCol = 6;
|
||||
activeBuffer.savedAnsiState = { fg: "#ffffff", bg: "#000000", bold: true, underline: false };
|
||||
runtimeState.modes = {
|
||||
...DEFAULT_TERMINAL_MODES,
|
||||
applicationCursorKeys: true,
|
||||
sendFocus: true,
|
||||
insertMode: true,
|
||||
cursorHidden: true
|
||||
};
|
||||
|
||||
handler.softResetTerminal();
|
||||
|
||||
expect(runtimeState.modes).toEqual(defaults);
|
||||
expect(state.cursorRow).toBe(7);
|
||||
expect(state.cursorCol).toBe(9);
|
||||
expect(state.scrollTop).toBe(0);
|
||||
expect(state.scrollBottom).toBe(23);
|
||||
expect(state.ansiState).toEqual({ fg: "", bg: "", bold: false, underline: false });
|
||||
expect(activeBuffer.savedCursorRow).toBe(0);
|
||||
expect(activeBuffer.savedCursorCol).toBe(0);
|
||||
expect(activeBuffer.savedAnsiState).toEqual({ fg: "", bg: "", bold: false, underline: false });
|
||||
});
|
||||
|
||||
it("光标移动类 CSI 会按默认参数分派到对应动作", () => {
|
||||
const { handler, cursorOps } = createHandler();
|
||||
|
||||
expect(handler.handleCursorControl("A", [])).toBe(true);
|
||||
expect(handler.handleCursorControl("B", [2])).toBe(true);
|
||||
expect(handler.handleCursorControl("C", [0])).toBe(true);
|
||||
expect(handler.handleCursorControl("D", [-3])).toBe(true);
|
||||
expect(handler.handleCursorControl("E", [])).toBe(true);
|
||||
expect(handler.handleCursorControl("F", [4])).toBe(true);
|
||||
|
||||
expect(cursorOps).toEqual([
|
||||
{ type: "up", value: 1 },
|
||||
{ type: "down", value: 2 },
|
||||
{ type: "right", value: 1 },
|
||||
{ type: "left", value: 1 },
|
||||
{ type: "nextLine", value: 1 },
|
||||
{ type: "previousLine", value: 4 }
|
||||
]);
|
||||
});
|
||||
|
||||
it("HPA/VPA/CUP 会保留 VT 的 1-based 语义交给运行态落点", () => {
|
||||
const { handler, cursorOps } = createHandler();
|
||||
|
||||
expect(handler.handleCursorControl("G", [5])).toBe(true);
|
||||
expect(handler.handleCursorControl("d", [])).toBe(true);
|
||||
expect(handler.handleCursorControl("H", [3, 7])).toBe(true);
|
||||
expect(handler.handleCursorControl("f", [0, -2])).toBe(true);
|
||||
expect(handler.handleCursorControl("J", [2])).toBe(false);
|
||||
|
||||
expect(cursorOps).toEqual([
|
||||
{ type: "column1", value: 5 },
|
||||
{ type: "row1", value: 1 },
|
||||
{ type: "position1", row: 3, column: 7 },
|
||||
{ type: "position1", row: 1, column: 1 }
|
||||
]);
|
||||
});
|
||||
|
||||
it("擦除类 CSI 会按各自默认参数分派到对应擦除动作", () => {
|
||||
const { handler, eraseOps } = createHandler();
|
||||
|
||||
expect(handler.handleEraseControl("J", [])).toBe(true);
|
||||
expect(handler.handleEraseControl("K", [2])).toBe(true);
|
||||
expect(handler.handleEraseControl("X", [0])).toBe(true);
|
||||
expect(handler.handleEraseControl("X", [-3])).toBe(true);
|
||||
expect(handler.handleEraseControl("P", [1])).toBe(false);
|
||||
|
||||
expect(eraseOps).toEqual([
|
||||
{ type: "display", value: 0 },
|
||||
{ type: "line", value: 2 },
|
||||
{ type: "chars", value: 1 },
|
||||
{ type: "chars", value: 1 }
|
||||
]);
|
||||
});
|
||||
|
||||
it("编辑与滚动类 CSI 会按默认参数分派到对应 buffer 动作", () => {
|
||||
const { handler, editOps } = createHandler();
|
||||
|
||||
expect(handler.handleEditControl("@", [])).toBe(true);
|
||||
expect(handler.handleEditControl("P", [2])).toBe(true);
|
||||
expect(handler.handleEditControl("L", [0])).toBe(true);
|
||||
expect(handler.handleEditControl("M", [-3])).toBe(true);
|
||||
expect(handler.handleEditControl("S", [])).toBe(true);
|
||||
expect(handler.handleEditControl("T", [4])).toBe(true);
|
||||
expect(handler.handleEditControl("r", [2])).toBe(true);
|
||||
expect(handler.handleEditControl("r", [3, 0])).toBe(true);
|
||||
expect(handler.handleEditControl("u", [])).toBe(false);
|
||||
|
||||
expect(editOps).toEqual([
|
||||
{ type: "insertChars", value: 1 },
|
||||
{ type: "deleteChars", value: 2 },
|
||||
{ type: "insertLines", value: 1 },
|
||||
{ type: "deleteLines", value: 1 },
|
||||
{ type: "scrollUp", value: 1 },
|
||||
{ type: "scrollDown", value: 4 },
|
||||
{ type: "scrollRegion", top: 2, bottom: 24 },
|
||||
{ type: "scrollRegion", top: 3, bottom: 24 }
|
||||
]);
|
||||
});
|
||||
|
||||
it("CSI s/u 与 ESC 7/8 会统一分派保存与恢复光标动作", () => {
|
||||
const { handler, saveCalls, restoreCalls } = createHandler();
|
||||
|
||||
expect(handler.handleCursorSaveRestoreControl("s")).toBe(true);
|
||||
expect(handler.handleCursorSaveRestoreControl("u")).toBe(true);
|
||||
expect(handler.handleCursorSaveRestoreControl("7")).toBe(true);
|
||||
expect(handler.handleCursorSaveRestoreControl("8")).toBe(true);
|
||||
expect(handler.handleCursorSaveRestoreControl("D")).toBe(false);
|
||||
|
||||
expect(saveCalls.count).toBe(2);
|
||||
expect(restoreCalls.count).toBe(2);
|
||||
});
|
||||
|
||||
it("查询类 CSI 会统一分派 mode report、DSR 和 DA", () => {
|
||||
const { handler, responses, runtimeState, setActiveBufferAlt } = createHandler();
|
||||
|
||||
runtimeState.modes.sendFocus = true;
|
||||
setActiveBufferAlt(true);
|
||||
|
||||
expect(handler.handleQueryControl("?", "$", "p", [1049])).toBe(true);
|
||||
expect(handler.handleQueryControl("?", "", "n", [6])).toBe(true);
|
||||
expect(handler.handleQueryControl("", "", "c", [0])).toBe(true);
|
||||
expect(handler.handleQueryControl(">", "", "c", [0])).toBe(true);
|
||||
expect(handler.handleQueryControl("!", "", "p", [0])).toBe(false);
|
||||
|
||||
expect(responses).toEqual([
|
||||
"\u001b[?1049;1$y",
|
||||
"\u001b[?2;3R",
|
||||
"\u001b[?1;2c",
|
||||
"\u001b[>0;276;0c"
|
||||
]);
|
||||
});
|
||||
|
||||
it("ESC D/E/M 与 ESC 7/8 会统一走 ESC 分派入口", () => {
|
||||
const { handler, saveCalls, restoreCalls, escOps } = createHandler();
|
||||
|
||||
expect(handler.handleEscControl("7")).toBe(true);
|
||||
expect(handler.handleEscControl("8")).toBe(true);
|
||||
expect(handler.handleEscControl("D")).toBe(true);
|
||||
expect(handler.handleEscControl("E")).toBe(true);
|
||||
expect(handler.handleEscControl("M")).toBe(true);
|
||||
expect(handler.handleEscControl("]")).toBe(false);
|
||||
|
||||
expect(saveCalls.count).toBe(1);
|
||||
expect(restoreCalls.count).toBe(1);
|
||||
expect(escOps).toEqual(["D", "E", "M"]);
|
||||
});
|
||||
|
||||
it("CSI 顶层分派会统一路由查询、模式切换和普通控制动作", () => {
|
||||
const { handler, responses, runtimeState, cursorOps, eraseOps, editOps, saveCalls, defaults } = createHandler();
|
||||
|
||||
runtimeState.modes.applicationCursorKeys = true;
|
||||
runtimeState.modes.insertMode = true;
|
||||
|
||||
expect(handler.handleCsiControl("!", "", "p", [0])).toBe(true);
|
||||
expect(runtimeState.modes).toEqual(defaults);
|
||||
|
||||
expect(handler.handleCsiControl("?", "", "h", [1])).toBe(true);
|
||||
expect(runtimeState.modes.applicationCursorKeys).toBe(true);
|
||||
|
||||
expect(handler.handleCsiControl("", "", "h", [4])).toBe(true);
|
||||
expect(runtimeState.modes.insertMode).toBe(true);
|
||||
|
||||
expect(handler.handleCsiControl("?", "$", "p", [1])).toBe(true);
|
||||
expect(handler.handleCsiControl("", "", "B", [2])).toBe(true);
|
||||
expect(handler.handleCsiControl("", "", "K", [])).toBe(true);
|
||||
expect(handler.handleCsiControl("", "", "@", [3])).toBe(true);
|
||||
expect(handler.handleCsiControl("", "", "s", [])).toBe(true);
|
||||
expect(handler.handleCsiControl(">", "", "u", [1])).toBe(false);
|
||||
|
||||
expect(responses).toEqual(["\u001b[?1;1$y"]);
|
||||
expect(cursorOps).toEqual([{ type: "down", value: 2 }]);
|
||||
expect(eraseOps).toEqual([{ type: "line", value: 0 }]);
|
||||
expect(editOps).toEqual([{ type: "insertChars", value: 3 }]);
|
||||
expect(saveCalls.count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
function createHandler() {
|
||||
const defaults = { ...DEFAULT_TERMINAL_MODES };
|
||||
const responses: string[] = [];
|
||||
const runtimeState = { modes: { ...defaults } };
|
||||
const normalBuffer = {
|
||||
isAlt: false,
|
||||
savedCursorRow: 0,
|
||||
savedCursorCol: 0,
|
||||
savedAnsiState: { fg: "", bg: "", bold: false, underline: false }
|
||||
};
|
||||
const altBuffer = {
|
||||
isAlt: true,
|
||||
savedCursorRow: 0,
|
||||
savedCursorCol: 0,
|
||||
savedAnsiState: { fg: "", bg: "", bold: false, underline: false }
|
||||
};
|
||||
const state = {
|
||||
cursorRow: 1,
|
||||
cursorCol: 2,
|
||||
scrollTop: 0,
|
||||
scrollBottom: 23,
|
||||
ansiState: { fg: "", bg: "", bold: false, underline: false }
|
||||
};
|
||||
let activeBuffer = normalBuffer;
|
||||
const switchCalls: Array<{ target: string; clearTarget: boolean }> = [];
|
||||
const saveCalls = { count: 0 };
|
||||
const restoreCalls = { count: 0 };
|
||||
const cursorOps: Array<
|
||||
| { type: "up" | "down" | "right" | "left" | "nextLine" | "previousLine"; value: number }
|
||||
| { type: "column1" | "row1"; value: number }
|
||||
| { type: "position1"; row: number; column: number }
|
||||
> = [];
|
||||
const eraseOps: Array<
|
||||
| { type: "display" | "line"; value: number }
|
||||
| { type: "chars"; value: number }
|
||||
> = [];
|
||||
const editOps: Array<
|
||||
| { type: "insertChars" | "deleteChars" | "insertLines" | "deleteLines" | "scrollUp" | "scrollDown"; value: number }
|
||||
| { type: "scrollRegion"; top: number; bottom: number }
|
||||
> = [];
|
||||
const escOps: string[] = [];
|
||||
const originModeResets: boolean[] = [];
|
||||
|
||||
const handler = createTerminalVtInputHandler({
|
||||
ansiResetState: { fg: "", bg: "", bold: false, underline: false },
|
||||
bufferCols: 80,
|
||||
bufferRows: 24,
|
||||
cloneAnsiState: (value: { fg?: string; bg?: string; bold?: boolean; underline?: boolean } | null) => ({
|
||||
fg: value && value.fg ? String(value.fg) : "",
|
||||
bg: value && value.bg ? String(value.bg) : "",
|
||||
bold: !!(value && value.bold),
|
||||
underline: !!(value && value.underline)
|
||||
}),
|
||||
getActiveBuffer: () => activeBuffer,
|
||||
getCursorCol: () => state.cursorCol,
|
||||
getScreenCursorRow: () => state.cursorRow,
|
||||
getScrollBottom: () => state.scrollBottom,
|
||||
getScrollTop: () => state.scrollTop,
|
||||
responses,
|
||||
restoreCurrentCursor: () => {
|
||||
restoreCalls.count += 1;
|
||||
},
|
||||
runtimeColors: {
|
||||
defaultForeground: "#112233",
|
||||
defaultBackground: "#445566",
|
||||
defaultCursor: "#778899"
|
||||
},
|
||||
runtimeState,
|
||||
saveCurrentCursor: () => {
|
||||
saveCalls.count += 1;
|
||||
},
|
||||
moveCursorUp: (value: number) => {
|
||||
cursorOps.push({ type: "up", value });
|
||||
},
|
||||
moveCursorDown: (value: number) => {
|
||||
cursorOps.push({ type: "down", value });
|
||||
},
|
||||
moveCursorRight: (value: number) => {
|
||||
cursorOps.push({ type: "right", value });
|
||||
},
|
||||
moveCursorLeft: (value: number) => {
|
||||
cursorOps.push({ type: "left", value });
|
||||
},
|
||||
moveCursorNextLine: (value: number) => {
|
||||
cursorOps.push({ type: "nextLine", value });
|
||||
},
|
||||
moveCursorPreviousLine: (value: number) => {
|
||||
cursorOps.push({ type: "previousLine", value });
|
||||
},
|
||||
setCursorColumn1: (value: number) => {
|
||||
cursorOps.push({ type: "column1", value });
|
||||
},
|
||||
setCursorRow1: (value: number) => {
|
||||
cursorOps.push({ type: "row1", value });
|
||||
},
|
||||
setCursorPosition1: (row: number, column: number) => {
|
||||
cursorOps.push({ type: "position1", row, column });
|
||||
},
|
||||
clearDisplayByMode: (value: number) => {
|
||||
eraseOps.push({ type: "display", value });
|
||||
},
|
||||
clearLineByMode: (value: number) => {
|
||||
eraseOps.push({ type: "line", value });
|
||||
},
|
||||
eraseChars: (value: number) => {
|
||||
eraseOps.push({ type: "chars", value });
|
||||
},
|
||||
insertChars: (value: number) => {
|
||||
editOps.push({ type: "insertChars", value });
|
||||
},
|
||||
deleteChars: (value: number) => {
|
||||
editOps.push({ type: "deleteChars", value });
|
||||
},
|
||||
insertLines: (value: number) => {
|
||||
editOps.push({ type: "insertLines", value });
|
||||
},
|
||||
deleteLines: (value: number) => {
|
||||
editOps.push({ type: "deleteLines", value });
|
||||
},
|
||||
scrollRegionUp: (value: number) => {
|
||||
editOps.push({ type: "scrollUp", value });
|
||||
},
|
||||
scrollRegionDown: (value: number) => {
|
||||
editOps.push({ type: "scrollDown", value });
|
||||
},
|
||||
setScrollRegion: (top: number, bottom: number) => {
|
||||
editOps.push({ type: "scrollRegion", top, bottom });
|
||||
},
|
||||
resetCursorForOriginMode: (enabled: boolean) => {
|
||||
originModeResets.push(enabled);
|
||||
},
|
||||
indexDown: () => {
|
||||
escOps.push("D");
|
||||
},
|
||||
nextLine: () => {
|
||||
escOps.push("E");
|
||||
},
|
||||
reverseIndex: () => {
|
||||
escOps.push("M");
|
||||
},
|
||||
setAnsiState: (value: { fg: string; bg: string; bold: boolean; underline: boolean }) => {
|
||||
state.ansiState = { ...value };
|
||||
},
|
||||
setCursorCol: (value: number) => {
|
||||
state.cursorCol = value;
|
||||
},
|
||||
setCursorRow: (value: number) => {
|
||||
state.cursorRow = value;
|
||||
},
|
||||
setScrollBottom: (value: number) => {
|
||||
state.scrollBottom = value;
|
||||
},
|
||||
setScrollTop: (value: number) => {
|
||||
state.scrollTop = value;
|
||||
},
|
||||
switchActiveBuffer: (target: string, clearTarget: boolean) => {
|
||||
switchCalls.push({ target, clearTarget });
|
||||
activeBuffer = target === "alt" ? altBuffer : normalBuffer;
|
||||
},
|
||||
toOscRgbString: (value: string) => `rgb:${value}`
|
||||
});
|
||||
|
||||
return {
|
||||
activeBuffer,
|
||||
defaults,
|
||||
handler,
|
||||
responses,
|
||||
restoreCalls,
|
||||
runtimeState,
|
||||
saveCalls,
|
||||
originModeResets,
|
||||
cursorOps,
|
||||
eraseOps,
|
||||
editOps,
|
||||
escOps,
|
||||
setActiveBufferAlt(value: boolean) {
|
||||
activeBuffer = value ? altBuffer : normalBuffer;
|
||||
},
|
||||
state,
|
||||
switchCalls
|
||||
};
|
||||
}
|
||||
530
apps/miniprogram/pages/terminal/vtParser.js
Normal file
530
apps/miniprogram/pages/terminal/vtParser.js
Normal file
@@ -0,0 +1,530 @@
|
||||
/* global module */
|
||||
|
||||
/**
|
||||
* 轻量 VT 解析层:
|
||||
* 1. 只负责把原始字节流样式文本切成 `CSI / OSC / DCS / ESC / 文本`;
|
||||
* 2. 不直接修改 buffer,也不参与页面几何/渲染;
|
||||
* 3. 当前目标是先把 Codex 已经用到的 prefix / intermediates / OSC / DCS 收口到统一入口,
|
||||
* 避免继续在 `terminalBufferState` 里散落正则补丁。
|
||||
*/
|
||||
|
||||
const ESC_CHAR = "\u001b";
|
||||
|
||||
function shouldStripTerminalControlChar(codePoint) {
|
||||
return (
|
||||
(codePoint >= 0x00 && codePoint <= 0x06) ||
|
||||
codePoint === 0x0b ||
|
||||
codePoint === 0x0c ||
|
||||
(codePoint >= 0x0e && codePoint <= 0x1a) ||
|
||||
(codePoint >= 0x1c && codePoint <= 0x1f) ||
|
||||
codePoint === 0x7f
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信小程序的 eslint 开了 `no-control-regex`,因此这里不用控制字符正则,
|
||||
* 改为显式扫描 `ESC ( X` / `ESC ) X` 这种 charset designator。
|
||||
*/
|
||||
function stripCharsetDesignators(text) {
|
||||
let result = "";
|
||||
let index = 0;
|
||||
while (index < text.length) {
|
||||
const current = text[index];
|
||||
const marker = text[index + 1];
|
||||
const final = text[index + 2];
|
||||
if (
|
||||
current === ESC_CHAR &&
|
||||
(marker === "(" || marker === ")") &&
|
||||
final &&
|
||||
/[0-9A-Za-z]/.test(final)
|
||||
) {
|
||||
index += 3;
|
||||
continue;
|
||||
}
|
||||
result += current;
|
||||
index += 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* replay 文本里会混入一批不参与终端渲染的控制字符。
|
||||
* 这里逐字符过滤,既能避开 lint 规则,也更容易精确保留其余可见文本。
|
||||
*/
|
||||
function stripDisallowedControlChars(text) {
|
||||
let result = "";
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const codePoint = text.codePointAt(index);
|
||||
if (!Number.isFinite(codePoint)) {
|
||||
continue;
|
||||
}
|
||||
const ch = String.fromCodePoint(codePoint);
|
||||
if (!shouldStripTerminalControlChar(codePoint)) {
|
||||
result += ch;
|
||||
}
|
||||
if (ch.length === 2) {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeTerminalReplayText(input) {
|
||||
const raw = String(input || "");
|
||||
if (!raw) return "";
|
||||
return stripDisallowedControlChars(
|
||||
stripCharsetDesignators(raw)
|
||||
.replace(/[\??[0-9;]*[mKJHfABCDsuhl]/g, "")
|
||||
.replace(/\r\n/g, "\n")
|
||||
);
|
||||
}
|
||||
|
||||
function createTerminalSyncUpdateState() {
|
||||
return {
|
||||
depth: 0,
|
||||
carryText: "",
|
||||
bufferedText: ""
|
||||
};
|
||||
}
|
||||
|
||||
function isTerminalSyncUpdateCsi(privateMarker, final, values) {
|
||||
if (String(privateMarker || "") !== "?") return false;
|
||||
if (!["h", "l"].includes(String(final || ""))) return false;
|
||||
return Math.round(Number(values && values[0]) || 0) === 2026;
|
||||
}
|
||||
|
||||
/**
|
||||
* web 端已经显式清洗 `DCS = 1 s / = 2 s`。
|
||||
* 小程序这里保持同口径,把它们也视为同步刷新窗口边界。
|
||||
*/
|
||||
function resolveTerminalSyncUpdateDcsAction(header, final, data) {
|
||||
if (String(final || "") !== "s") return "";
|
||||
if (String(data || "")) return "";
|
||||
const parsed = parseDcsHeader(header);
|
||||
if (parsed.privateMarker !== "=") return "";
|
||||
const mode = Math.round(Number(parsed.values && parsed.values[0]) || 0);
|
||||
if (mode === 1) return "start";
|
||||
if (mode === 2) return "end";
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Codex 这类 TUI 的“同步刷新窗口”从原始 stdout 中收口出来:
|
||||
* 1. 窗口外文本立即可见;
|
||||
* 2. 窗口内文本暂存,等结束标记到达后再一次性交给上层渲染;
|
||||
* 3. 若控制序列在 chunk 边界被截断,则把尾巴 carry 到下一帧继续拼。
|
||||
*
|
||||
* 这里的目标不是完整实现协议,而是避免把一整批重绘中间态逐帧暴露给用户。
|
||||
*/
|
||||
function consumeTerminalSyncUpdateFrames(input, previousState) {
|
||||
const source =
|
||||
previousState && typeof previousState === "object"
|
||||
? previousState
|
||||
: createTerminalSyncUpdateState();
|
||||
const text = `${String(source.carryText || "")}${String(input || "")}`;
|
||||
let depth = Math.max(0, Math.round(Number(source.depth) || 0));
|
||||
let currentText = depth > 0 ? String(source.bufferedText || "") : "";
|
||||
let readyText = "";
|
||||
let carryText = "";
|
||||
let index = 0;
|
||||
|
||||
const flushCurrentText = () => {
|
||||
if (!currentText) {
|
||||
return;
|
||||
}
|
||||
readyText += currentText;
|
||||
currentText = "";
|
||||
};
|
||||
|
||||
while (index < text.length) {
|
||||
if (text[index] === "\u001b") {
|
||||
const next = text[index + 1];
|
||||
if (next === "[") {
|
||||
const csi = extractAnsiCsi(text, index);
|
||||
if (!csi) {
|
||||
carryText = text.slice(index);
|
||||
break;
|
||||
}
|
||||
const parsed = parseCsiParams(csi.paramsRaw);
|
||||
if (isTerminalSyncUpdateCsi(parsed.privateMarker, csi.final, parsed.values)) {
|
||||
if (csi.final === "h") {
|
||||
if (depth === 0) {
|
||||
flushCurrentText();
|
||||
}
|
||||
depth += 1;
|
||||
} else if (depth > 0) {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
flushCurrentText();
|
||||
}
|
||||
}
|
||||
index = csi.end + 1;
|
||||
continue;
|
||||
}
|
||||
currentText += text.slice(index, csi.end + 1);
|
||||
index = csi.end + 1;
|
||||
continue;
|
||||
}
|
||||
if (next === "]") {
|
||||
const osc = extractOscSequence(text, index);
|
||||
if (!osc) {
|
||||
carryText = text.slice(index);
|
||||
break;
|
||||
}
|
||||
currentText += text.slice(index, osc.end + 1);
|
||||
index = osc.end + 1;
|
||||
continue;
|
||||
}
|
||||
if (next === "P") {
|
||||
const dcs = extractDcsSequence(text, index);
|
||||
if (!dcs) {
|
||||
carryText = text.slice(index);
|
||||
break;
|
||||
}
|
||||
const action = resolveTerminalSyncUpdateDcsAction(dcs.header, dcs.final, dcs.data);
|
||||
if (action === "start") {
|
||||
if (depth === 0) {
|
||||
flushCurrentText();
|
||||
}
|
||||
depth += 1;
|
||||
index = dcs.end + 1;
|
||||
continue;
|
||||
}
|
||||
if (action === "end") {
|
||||
if (depth > 0) {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
flushCurrentText();
|
||||
}
|
||||
}
|
||||
index = dcs.end + 1;
|
||||
continue;
|
||||
}
|
||||
currentText += text.slice(index, dcs.end + 1);
|
||||
index = dcs.end + 1;
|
||||
continue;
|
||||
}
|
||||
if (!next) {
|
||||
carryText = text.slice(index);
|
||||
break;
|
||||
}
|
||||
currentText += text.slice(index, index + 2);
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
const codePoint = text.codePointAt(index);
|
||||
if (!Number.isFinite(codePoint)) {
|
||||
break;
|
||||
}
|
||||
const ch = String.fromCodePoint(codePoint);
|
||||
currentText += ch;
|
||||
index += ch.length;
|
||||
}
|
||||
|
||||
let bufferedText = "";
|
||||
if (depth > 0) {
|
||||
bufferedText = currentText;
|
||||
} else {
|
||||
flushCurrentText();
|
||||
}
|
||||
|
||||
return {
|
||||
text: readyText,
|
||||
state: {
|
||||
depth,
|
||||
carryText,
|
||||
bufferedText
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将一段原始终端输出切成“可安全独立解析”的前缀:
|
||||
* 1. 不在 CSI / OSC / DCS / 两字符 ESC 序列中间截断;
|
||||
* 2. 不把 `\r\n` 从中间拆开,避免分片后被归一化成双重换行;
|
||||
* 3. 默认按 code point 推进,避免把代理对字符从中间截断。
|
||||
*
|
||||
* 说明:
|
||||
* - 如果上限恰好落在控制序列中间,且前面已经存在安全边界,则返回此前缀;
|
||||
* - 如果文本开头就是一个完整但较长的控制序列,则允许这一整个序列越过上限,保证最小前进。
|
||||
* - 如果文本前缀本身是不完整控制序列,则返回空 slice,由调用方把这段尾巴缓存到下一轮。
|
||||
*/
|
||||
function takeTerminalReplaySlice(input, maxChars) {
|
||||
const text = String(input || "");
|
||||
if (!text) {
|
||||
return { slice: "", rest: "" };
|
||||
}
|
||||
const limit = Math.max(1, Math.round(Number(maxChars) || 0));
|
||||
|
||||
let index = 0;
|
||||
let safeEnd = 0;
|
||||
while (index < text.length && index < limit) {
|
||||
if (text[index] === "\r" && text[index + 1] === "\n") {
|
||||
const nextIndex = index + 2;
|
||||
if (nextIndex > limit && safeEnd > 0) {
|
||||
break;
|
||||
}
|
||||
safeEnd = nextIndex;
|
||||
index = nextIndex;
|
||||
continue;
|
||||
}
|
||||
if (text[index] === "\u001b") {
|
||||
const next = text[index + 1];
|
||||
let nextIndex = 0;
|
||||
if (next === "[") {
|
||||
const csi = extractAnsiCsi(text, index);
|
||||
if (!csi) break;
|
||||
nextIndex = csi.end + 1;
|
||||
} else if (next === "]") {
|
||||
const osc = extractOscSequence(text, index);
|
||||
if (!osc) break;
|
||||
nextIndex = osc.end + 1;
|
||||
} else if (next === "P") {
|
||||
const dcs = extractDcsSequence(text, index);
|
||||
if (!dcs) break;
|
||||
nextIndex = dcs.end + 1;
|
||||
} else if (next) {
|
||||
nextIndex = index + 2;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
if (nextIndex > limit && safeEnd > 0) {
|
||||
break;
|
||||
}
|
||||
safeEnd = nextIndex;
|
||||
index = nextIndex;
|
||||
continue;
|
||||
}
|
||||
const codePoint = text.codePointAt(index);
|
||||
if (!Number.isFinite(codePoint)) {
|
||||
break;
|
||||
}
|
||||
const ch = String.fromCodePoint(codePoint);
|
||||
const nextIndex = index + ch.length;
|
||||
if (nextIndex > limit && safeEnd > 0) {
|
||||
break;
|
||||
}
|
||||
safeEnd = nextIndex;
|
||||
index = nextIndex;
|
||||
}
|
||||
|
||||
if (safeEnd <= 0) {
|
||||
return { slice: "", rest: text };
|
||||
}
|
||||
return {
|
||||
slice: text.slice(0, safeEnd),
|
||||
rest: text.slice(safeEnd)
|
||||
};
|
||||
}
|
||||
|
||||
function extractAnsiCsi(text, startIndex) {
|
||||
if (text[startIndex] !== "\u001b" || text[startIndex + 1] !== "[") return null;
|
||||
let index = startIndex + 2;
|
||||
let buffer = "";
|
||||
while (index < text.length) {
|
||||
const ch = text[index];
|
||||
if (ch >= "@" && ch <= "~") {
|
||||
return {
|
||||
end: index,
|
||||
final: ch,
|
||||
paramsRaw: buffer
|
||||
};
|
||||
}
|
||||
buffer += ch;
|
||||
index += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCsiParams(paramsRaw) {
|
||||
const raw = String(paramsRaw || "");
|
||||
const privateMarker = raw && /^[?<>=!]/.test(raw) ? raw[0] : "";
|
||||
const body = privateMarker ? raw.slice(1) : raw;
|
||||
const intermediateMatch = /[\u0020-\u002f]+$/.exec(body);
|
||||
const intermediates = intermediateMatch ? intermediateMatch[0] : "";
|
||||
const paramsBody = intermediates ? body.slice(0, -intermediates.length) : body;
|
||||
const values = paramsBody.length
|
||||
? paramsBody.split(";").map((part) => {
|
||||
if (!part) return NaN;
|
||||
const parsed = Number(part);
|
||||
return Number.isFinite(parsed) ? parsed : NaN;
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
privateMarker,
|
||||
intermediates,
|
||||
values
|
||||
};
|
||||
}
|
||||
|
||||
function extractOscSequence(text, startIndex) {
|
||||
if (text[startIndex] !== "\u001b" || text[startIndex + 1] !== "]") return null;
|
||||
let index = startIndex + 2;
|
||||
while (index < text.length) {
|
||||
const ch = text[index];
|
||||
if (ch === "\u0007") {
|
||||
return {
|
||||
content: text.slice(startIndex + 2, index),
|
||||
end: index
|
||||
};
|
||||
}
|
||||
if (ch === "\u001b" && text[index + 1] === "\\") {
|
||||
return {
|
||||
content: text.slice(startIndex + 2, index),
|
||||
end: index + 1
|
||||
};
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseOscContent(content) {
|
||||
const raw = String(content || "");
|
||||
const separator = raw.indexOf(";");
|
||||
if (separator < 0) {
|
||||
return {
|
||||
ident: Number.NaN,
|
||||
data: raw
|
||||
};
|
||||
}
|
||||
const ident = Number(raw.slice(0, separator));
|
||||
return {
|
||||
ident: Number.isFinite(ident) ? ident : Number.NaN,
|
||||
data: raw.slice(separator + 1)
|
||||
};
|
||||
}
|
||||
|
||||
function extractDcsSequence(text, startIndex) {
|
||||
if (text[startIndex] !== "\u001b" || text[startIndex + 1] !== "P") return null;
|
||||
let index = startIndex + 2;
|
||||
let header = "";
|
||||
while (index < text.length) {
|
||||
const ch = text[index];
|
||||
if (ch >= "@" && ch <= "~") {
|
||||
const final = ch;
|
||||
const contentStart = index + 1;
|
||||
let cursor = contentStart;
|
||||
while (cursor < text.length) {
|
||||
if (text[cursor] === "\u001b" && text[cursor + 1] === "\\") {
|
||||
return {
|
||||
header,
|
||||
final,
|
||||
data: text.slice(contentStart, cursor),
|
||||
end: cursor + 1
|
||||
};
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
header += ch;
|
||||
index += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseDcsHeader(header) {
|
||||
const parsed = parseCsiParams(header);
|
||||
return {
|
||||
privateMarker: parsed.privateMarker,
|
||||
intermediates: parsed.intermediates,
|
||||
values: parsed.values
|
||||
};
|
||||
}
|
||||
|
||||
function isLikelySgrCode(code) {
|
||||
const value = Number(code);
|
||||
if (!Number.isFinite(value)) return false;
|
||||
if (
|
||||
value === 0 ||
|
||||
value === 1 ||
|
||||
value === 4 ||
|
||||
value === 22 ||
|
||||
value === 24 ||
|
||||
value === 39 ||
|
||||
value === 49
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (value === 38 || value === 48) return true;
|
||||
if (value >= 30 && value <= 37) return true;
|
||||
if (value >= 40 && value <= 47) return true;
|
||||
if (value >= 90 && value <= 97) return true;
|
||||
if (value >= 100 && value <= 107) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 某些录屏/replay 文本会把 `ESC[` 吃掉,只留下裸的 `31m` / `[31m` 片段。
|
||||
* 这里保留一个“松散 SGR”兜底解析,但仍限制在可信 SGR 编码集合内,避免把普通文本误吞成样式。
|
||||
*/
|
||||
function extractLooseAnsiSgr(text, startIndex) {
|
||||
let index = startIndex;
|
||||
let tokenCount = 0;
|
||||
let sawBracket = false;
|
||||
const allCodes = [];
|
||||
|
||||
while (index < text.length) {
|
||||
const tokenStart = index;
|
||||
if (text[index] === "[" || text[index] === "[") {
|
||||
sawBracket = true;
|
||||
index += 1;
|
||||
}
|
||||
let body = "";
|
||||
while (index < text.length) {
|
||||
const ch = text[index];
|
||||
if ((ch >= "0" && ch <= "9") || ch === ";") {
|
||||
body += ch;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (body.length === 0 || text[index] !== "m") {
|
||||
index = tokenStart;
|
||||
break;
|
||||
}
|
||||
const codes = body
|
||||
.split(";")
|
||||
.filter((part) => part.length > 0)
|
||||
.map((part) => {
|
||||
const parsed = Number(part);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
});
|
||||
if (codes.length === 0) {
|
||||
codes.push(0);
|
||||
}
|
||||
allCodes.push(...codes);
|
||||
tokenCount += 1;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
if (tokenCount === 0) return null;
|
||||
if (!allCodes.some((code) => isLikelySgrCode(code))) return null;
|
||||
if (tokenCount === 1 && !sawBracket) {
|
||||
const single = allCodes.length === 1 ? allCodes[0] : Number.NaN;
|
||||
if (!Number.isFinite(single) || ![0, 22, 24, 39, 49].includes(single)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
end: index - 1,
|
||||
codes: allCodes
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
consumeTerminalSyncUpdateFrames,
|
||||
createTerminalSyncUpdateState,
|
||||
extractAnsiCsi,
|
||||
extractDcsSequence,
|
||||
extractLooseAnsiSgr,
|
||||
extractOscSequence,
|
||||
normalizeTerminalReplayText,
|
||||
takeTerminalReplaySlice,
|
||||
parseCsiParams,
|
||||
parseDcsHeader,
|
||||
parseOscContent
|
||||
};
|
||||
132
apps/miniprogram/pages/terminal/vtParser.test.ts
Normal file
132
apps/miniprogram/pages/terminal/vtParser.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const {
|
||||
consumeTerminalSyncUpdateFrames,
|
||||
createTerminalSyncUpdateState,
|
||||
extractLooseAnsiSgr,
|
||||
takeTerminalReplaySlice
|
||||
} = require("./vtParser.js");
|
||||
|
||||
describe("vtParser", () => {
|
||||
it("不会把 CSI 控制序列从中间切开", () => {
|
||||
const result = takeTerminalReplaySlice("ab\u001b[31mcd", 4);
|
||||
|
||||
expect(result).toEqual({
|
||||
slice: "ab",
|
||||
rest: "\u001b[31mcd"
|
||||
});
|
||||
});
|
||||
|
||||
it("不会把 CRLF 从中间拆开", () => {
|
||||
expect(takeTerminalReplaySlice("ab\r\ncd", 3)).toEqual({
|
||||
slice: "ab",
|
||||
rest: "\r\ncd"
|
||||
});
|
||||
expect(takeTerminalReplaySlice("ab\r\ncd", 4)).toEqual({
|
||||
slice: "ab\r\n",
|
||||
rest: "cd"
|
||||
});
|
||||
});
|
||||
|
||||
it("文本开头是完整长控制序列时会整段前进,避免卡死在零进度", () => {
|
||||
const result = takeTerminalReplaySlice("\u001b]10;?\u001b\\X", 3);
|
||||
|
||||
expect(result).toEqual({
|
||||
slice: "\u001b]10;?\u001b\\",
|
||||
rest: "X"
|
||||
});
|
||||
});
|
||||
|
||||
it("文本前缀是不完整控制序列时会返回空 slice,交由上层缓存尾巴", () => {
|
||||
const result = takeTerminalReplaySlice("\u001b[31", 16);
|
||||
|
||||
expect(result).toEqual({
|
||||
slice: "",
|
||||
rest: "\u001b[31"
|
||||
});
|
||||
});
|
||||
|
||||
it("会识别不带 ESC 的松散 SGR 复位片段", () => {
|
||||
expect(extractLooseAnsiSgr("39mtext", 0)).toEqual({
|
||||
end: 2,
|
||||
codes: [39]
|
||||
});
|
||||
expect(extractLooseAnsiSgr("[1;31mtext", 0)).toEqual({
|
||||
end: 5,
|
||||
codes: [1, 31]
|
||||
});
|
||||
});
|
||||
|
||||
it("非可信 SGR 数字串不会被误识别成样式序列", () => {
|
||||
expect(extractLooseAnsiSgr("123mtext", 0)).toBeNull();
|
||||
});
|
||||
|
||||
it("会把 `CSI ? 2026 h/l` 包裹的同步刷新窗口延后到结束时一次性吐出", () => {
|
||||
const first = consumeTerminalSyncUpdateFrames(
|
||||
"ab\u001b[?2026hcd",
|
||||
createTerminalSyncUpdateState()
|
||||
);
|
||||
|
||||
expect(first.text).toBe("ab");
|
||||
expect(first.state).toEqual({
|
||||
depth: 1,
|
||||
carryText: "",
|
||||
bufferedText: "cd"
|
||||
});
|
||||
|
||||
const second = consumeTerminalSyncUpdateFrames("ef\u001b[?2026lg", first.state);
|
||||
|
||||
expect(second.text).toBe("cdefg");
|
||||
expect(second.state).toEqual({
|
||||
depth: 0,
|
||||
carryText: "",
|
||||
bufferedText: ""
|
||||
});
|
||||
});
|
||||
|
||||
it("同步刷新起始序列若被 chunk 边界截断,会保留到下一帧继续拼", () => {
|
||||
const first = consumeTerminalSyncUpdateFrames(
|
||||
"ab\u001b[?2026",
|
||||
createTerminalSyncUpdateState()
|
||||
);
|
||||
|
||||
expect(first.text).toBe("ab");
|
||||
expect(first.state).toEqual({
|
||||
depth: 0,
|
||||
carryText: "\u001b[?2026",
|
||||
bufferedText: ""
|
||||
});
|
||||
|
||||
const second = consumeTerminalSyncUpdateFrames("hcd\u001b[?2026l", first.state);
|
||||
|
||||
expect(second.text).toBe("cd");
|
||||
expect(second.state).toEqual({
|
||||
depth: 0,
|
||||
carryText: "",
|
||||
bufferedText: ""
|
||||
});
|
||||
});
|
||||
|
||||
it("会按和 web 端一致的口径收口 `DCS = 1 s / = 2 s` 同步刷新窗口", () => {
|
||||
const first = consumeTerminalSyncUpdateFrames(
|
||||
"ab\u001bP=1s\u001b\\cd",
|
||||
createTerminalSyncUpdateState()
|
||||
);
|
||||
|
||||
expect(first.text).toBe("ab");
|
||||
expect(first.state).toEqual({
|
||||
depth: 1,
|
||||
carryText: "",
|
||||
bufferedText: "cd"
|
||||
});
|
||||
|
||||
const second = consumeTerminalSyncUpdateFrames("ef\u001bP=2s\u001b\\g", first.state);
|
||||
|
||||
expect(second.text).toBe("cdefg");
|
||||
expect(second.state).toEqual({
|
||||
depth: 0,
|
||||
carryText: "",
|
||||
bufferedText: ""
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user