first commit

This commit is contained in:
douboer
2026-03-21 18:57:10 +08:00
commit c49aa1a5e9
570 changed files with 107167 additions and 0 deletions

View 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
};
}