Update files
This commit is contained in:
@@ -429,6 +429,91 @@ describe("Terminal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("raw output stream", () => {
|
||||
it("streams raw output chunks with byte offsets", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
})
|
||||
);
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
|
||||
const chunks: Array<{ data: string; startOffset: number; endOffset: number }> = [];
|
||||
const sub = session.subscribeRaw((chunk) => {
|
||||
chunks.push({
|
||||
data: chunk.data,
|
||||
startOffset: chunk.startOffset,
|
||||
endOffset: chunk.endOffset,
|
||||
});
|
||||
});
|
||||
|
||||
session.send({ type: "input", data: "echo raw-stream\r" });
|
||||
await waitForLines(session, ["$ echo raw-stream", "raw-stream", "$"]);
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0);
|
||||
expect(chunks[chunks.length - 1].endOffset).toBe(session.getOutputOffset());
|
||||
for (let i = 1; i < chunks.length; i++) {
|
||||
expect(chunks[i].startOffset).toBe(chunks[i - 1].endOffset);
|
||||
}
|
||||
|
||||
sub.unsubscribe();
|
||||
});
|
||||
|
||||
it("replays buffered output from offset", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
})
|
||||
);
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
|
||||
const seen: Array<{ data: string; endOffset: number; replay: boolean }> = [];
|
||||
const firstSub = session.subscribeRaw((chunk) => {
|
||||
seen.push({
|
||||
data: chunk.data,
|
||||
endOffset: chunk.endOffset,
|
||||
replay: chunk.replay,
|
||||
});
|
||||
});
|
||||
|
||||
session.send({ type: "input", data: "echo before-detach\r" });
|
||||
await waitForLines(session, ["$ echo before-detach", "before-detach", "$"]);
|
||||
|
||||
const resumeOffset = seen[seen.length - 1]?.endOffset ?? 0;
|
||||
firstSub.unsubscribe();
|
||||
|
||||
session.send({ type: "input", data: "echo after-detach\r" });
|
||||
await waitForLines(session, [
|
||||
"$ echo before-detach",
|
||||
"before-detach",
|
||||
"$ echo after-detach",
|
||||
"after-detach",
|
||||
"$",
|
||||
]);
|
||||
|
||||
const replayed: string[] = [];
|
||||
const secondSub = session.subscribeRaw(
|
||||
(chunk) => {
|
||||
if (chunk.replay) {
|
||||
replayed.push(chunk.data);
|
||||
}
|
||||
},
|
||||
{ fromOffset: resumeOffset }
|
||||
);
|
||||
|
||||
const replayText = replayed.join("");
|
||||
expect(replayText).toContain("after-detach");
|
||||
expect(secondSub.replayedFrom).toBeGreaterThanOrEqual(resumeOffset);
|
||||
secondSub.unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getState", () => {
|
||||
it("returns current terminal state with grid", async () => {
|
||||
const session = trackSession(
|
||||
|
||||
@@ -34,6 +34,21 @@ export interface CellChange {
|
||||
cell: Cell;
|
||||
}
|
||||
|
||||
export interface TerminalRawChunk {
|
||||
data: string;
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
replay: boolean;
|
||||
}
|
||||
|
||||
export interface TerminalRawSubscriptionResult {
|
||||
unsubscribe: () => void;
|
||||
replayedFrom: number;
|
||||
currentOffset: number;
|
||||
earliestAvailableOffset: number;
|
||||
reset: boolean;
|
||||
}
|
||||
|
||||
export type ClientMessage =
|
||||
| { type: "input"; data: string }
|
||||
| { type: "resize"; rows: number; cols: number }
|
||||
@@ -49,6 +64,11 @@ export interface TerminalSession {
|
||||
cwd: string;
|
||||
send(msg: ClientMessage): void;
|
||||
subscribe(listener: (msg: ServerMessage) => void): () => void;
|
||||
subscribeRaw(
|
||||
listener: (chunk: TerminalRawChunk) => void,
|
||||
options?: { fromOffset?: number }
|
||||
): TerminalRawSubscriptionResult;
|
||||
getOutputOffset(): number;
|
||||
getState(): TerminalState;
|
||||
kill(): void;
|
||||
}
|
||||
@@ -169,6 +189,15 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
|
||||
const id = randomUUID();
|
||||
const listeners = new Set<(msg: ServerMessage) => void>();
|
||||
const rawListeners = new Set<(chunk: TerminalRawChunk) => void>();
|
||||
const rawOutputChunks: Array<{
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
data: string;
|
||||
}> = [];
|
||||
const maxRawBufferBytes = 8 * 1024 * 1024;
|
||||
let rawBufferBytes = 0;
|
||||
let outputOffset = 0;
|
||||
let killed = false;
|
||||
|
||||
// Create xterm.js headless terminal
|
||||
@@ -192,9 +221,66 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
},
|
||||
});
|
||||
|
||||
function appendRawOutputChunk(data: string): void {
|
||||
const chunkBytes = Buffer.byteLength(data);
|
||||
if (chunkBytes <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startOffset = outputOffset;
|
||||
const endOffset = startOffset + chunkBytes;
|
||||
outputOffset = endOffset;
|
||||
|
||||
rawOutputChunks.push({
|
||||
startOffset,
|
||||
endOffset,
|
||||
data,
|
||||
});
|
||||
rawBufferBytes += chunkBytes;
|
||||
|
||||
while (rawBufferBytes > maxRawBufferBytes && rawOutputChunks.length > 0) {
|
||||
const removed = rawOutputChunks.shift();
|
||||
if (!removed) {
|
||||
break;
|
||||
}
|
||||
rawBufferBytes -= removed.endOffset - removed.startOffset;
|
||||
}
|
||||
|
||||
const chunk: TerminalRawChunk = {
|
||||
data,
|
||||
startOffset,
|
||||
endOffset,
|
||||
replay: false,
|
||||
};
|
||||
for (const listener of rawListeners) {
|
||||
listener(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
function getEarliestAvailableOffset(): number {
|
||||
const earliest = rawOutputChunks[0];
|
||||
return earliest ? earliest.startOffset : outputOffset;
|
||||
}
|
||||
|
||||
function alignOffsetToChunkBoundary(offset: number): number {
|
||||
if (offset === outputOffset) {
|
||||
return offset;
|
||||
}
|
||||
for (const chunk of rawOutputChunks) {
|
||||
if (offset === chunk.startOffset || offset === chunk.endOffset) {
|
||||
return offset;
|
||||
}
|
||||
if (offset > chunk.startOffset && offset < chunk.endOffset) {
|
||||
return chunk.startOffset;
|
||||
}
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
// Pipe PTY output to terminal emulator
|
||||
ptyProcess.onData((data) => {
|
||||
if (killed) return;
|
||||
appendRawOutputChunk(data);
|
||||
terminal.write(data, () => {
|
||||
// Notify listeners of changes
|
||||
const state = getState();
|
||||
@@ -254,11 +340,51 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
};
|
||||
}
|
||||
|
||||
function subscribeRaw(
|
||||
listener: (chunk: TerminalRawChunk) => void,
|
||||
options?: { fromOffset?: number }
|
||||
): TerminalRawSubscriptionResult {
|
||||
const requestedOffset = Math.max(0, Math.floor(options?.fromOffset ?? 0));
|
||||
const earliestAvailableOffset = getEarliestAvailableOffset();
|
||||
const clampedOffset = Math.max(requestedOffset, earliestAvailableOffset);
|
||||
const replayedFrom = alignOffsetToChunkBoundary(clampedOffset);
|
||||
const reset = replayedFrom !== requestedOffset;
|
||||
|
||||
for (const chunk of rawOutputChunks) {
|
||||
if (chunk.endOffset <= replayedFrom) {
|
||||
continue;
|
||||
}
|
||||
listener({
|
||||
data: chunk.data,
|
||||
startOffset: chunk.startOffset,
|
||||
endOffset: chunk.endOffset,
|
||||
replay: true,
|
||||
});
|
||||
}
|
||||
|
||||
rawListeners.add(listener);
|
||||
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
rawListeners.delete(listener);
|
||||
},
|
||||
replayedFrom,
|
||||
currentOffset: outputOffset,
|
||||
earliestAvailableOffset,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
function getOutputOffset(): number {
|
||||
return outputOffset;
|
||||
}
|
||||
|
||||
function kill(): void {
|
||||
if (killed) return;
|
||||
killed = true;
|
||||
ptyProcess.kill();
|
||||
terminal.dispose();
|
||||
rawListeners.clear();
|
||||
}
|
||||
|
||||
// Small delay to let shell initialize
|
||||
@@ -268,9 +394,11 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
id,
|
||||
name,
|
||||
cwd,
|
||||
send,
|
||||
subscribe,
|
||||
getState,
|
||||
kill,
|
||||
send,
|
||||
subscribe,
|
||||
subscribeRaw,
|
||||
getOutputOffset,
|
||||
getState,
|
||||
kill,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user