Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions apps/server/src/terminal/Layers/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
DEFAULT_TERMINAL_ID,
type TerminalEvent,
type TerminalOpenInput,
type TerminalRenderedSnapshot,
type TerminalRestartInput,
} from "@t3tools/contracts";
import { afterEach, describe, expect, it } from "vitest";
Expand Down Expand Up @@ -363,6 +364,87 @@ describe("TerminalManager", () => {
manager.dispose();
});

it("reads a rendered tail snapshot from retained history", async () => {
const { manager, ptyAdapter } = makeManager(500);
await manager.open(openInput());
const process = ptyAdapter.processes[0];
expect(process).toBeDefined();
if (!process) return;

process.emitData("one\n");
process.emitData("\u001b[31mtwo\u001b[0m\n");
process.emitData("three\n\n");

const snapshot = await manager.read({
threadId: "thread-1",
terminalId: DEFAULT_TERMINAL_ID,
scope: "tail",
maxLines: 2,
});

expect(snapshot).toEqual<TerminalRenderedSnapshot>({
text: "two\nthree",
totalLines: 3,
returnedLineCount: 2,
});

manager.dispose();
});

it("renders carriage-return rewrites in tail snapshots", async () => {
const { manager, ptyAdapter } = makeManager(500);
await manager.open(openInput());
const process = ptyAdapter.processes[0];
expect(process).toBeDefined();
if (!process) return;

process.emitData("10%");
process.emitData("\r25%");
process.emitData("\r100%\n");
process.emitData("done\n");

const snapshot = await manager.read({
threadId: "thread-1",
terminalId: DEFAULT_TERMINAL_ID,
scope: "tail",
maxLines: 2,
});

expect(snapshot).toEqual<TerminalRenderedSnapshot>({
text: "100%\ndone",
totalLines: 2,
returnedLineCount: 2,
});

manager.dispose();
});

it("applies erase-in-line control sequences in tail snapshots", async () => {
const { manager, ptyAdapter } = makeManager(500);
await manager.open(openInput());
const process = ptyAdapter.processes[0];
expect(process).toBeDefined();
if (!process) return;

process.emitData("foobar");
process.emitData("\rbar\u001b[K\n");

const snapshot = await manager.read({
threadId: "thread-1",
terminalId: DEFAULT_TERMINAL_ID,
scope: "tail",
maxLines: 1,
});

expect(snapshot).toEqual<TerminalRenderedSnapshot>({
text: "bar",
totalLines: 1,
returnedLineCount: 1,
});

manager.dispose();
});

it("restarts terminal with empty transcript and respawns pty", async () => {
const { manager, ptyAdapter, logsDir } = makeManager();
await manager.open(openInput());
Expand Down
181 changes: 181 additions & 0 deletions apps/server/src/terminal/Layers/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
TerminalClearInput,
TerminalCloseInput,
TerminalOpenInput,
TerminalReadInput,
TerminalRenderedSnapshot,
TerminalResizeInput,
TerminalRestartInput,
TerminalWriteInput,
Expand Down Expand Up @@ -43,6 +45,8 @@ const decodeTerminalWriteInput = Schema.decodeUnknownSync(TerminalWriteInput);
const decodeTerminalResizeInput = Schema.decodeUnknownSync(TerminalResizeInput);
const decodeTerminalClearInput = Schema.decodeUnknownSync(TerminalClearInput);
const decodeTerminalCloseInput = Schema.decodeUnknownSync(TerminalCloseInput);
const decodeTerminalReadInput = Schema.decodeUnknownSync(TerminalReadInput);
const ANSI_ESCAPE = "\u001B";

type TerminalSubprocessChecker = (terminalPid: number) => Promise<boolean>;

Expand Down Expand Up @@ -254,6 +258,150 @@ function capHistory(history: string, maxLines: number): string {
return hasTrailingNewline ? `${capped}\n` : capped;
}

function trimTrailingEmptyRenderedLines(lines: string[]): string[] {
let end = lines.length;
while (end > 0 && lines[end - 1]?.length === 0) {
end -= 1;
}
return end === lines.length ? lines : lines.slice(0, end);
}

function readAnsiEscapeSequence(
history: string,
startIndex: number,
): { length: number; finalByte: string | null; parameters: string } | null {
if (history[startIndex] !== ANSI_ESCAPE) {
return null;
}

const nextChar = history[startIndex + 1];
if (!nextChar) {
return { length: 1, finalByte: null, parameters: "" };
}

if (nextChar === "]") {
let index = startIndex + 2;
while (index < history.length) {
const char = history[index];
if (char === "\u0007") {
return { length: index - startIndex + 1, finalByte: null, parameters: "" };
}
if (char === ANSI_ESCAPE && history[index + 1] === "\\") {
return { length: index - startIndex + 2, finalByte: null, parameters: "" };
}
index += 1;
}
return { length: history.length - startIndex, finalByte: null, parameters: "" };
}

if (nextChar === "[") {
let index = startIndex + 2;
while (index < history.length) {
const char = history[index];
if (!char) {
break;
}
const code = char.charCodeAt(0);
if (code >= 0x40 && code <= 0x7e) {
return {
length: index - startIndex + 1,
finalByte: char,
parameters: history.slice(startIndex + 2, index),
};
}
index += 1;
}
return { length: history.length - startIndex, finalByte: null, parameters: "" };
}

return { length: 2, finalByte: null, parameters: "" };
}

function parseEraseInLineMode(parameters: string): 0 | 1 | 2 {
if (parameters.length === 0) {
return 0;
}
const mode = Number(parameters.split(";").at(-1) ?? "0");
if (mode === 1 || mode === 2) {
return mode;
}
return 0;
}

function eraseRenderedLine(line: string[], cursor: number, mode: 0 | 1 | 2): string[] {
if (mode === 2) {
return [];
}
if (mode === 1) {
if (line.length === 0) {
return line;
}
const nextLine = [...line];
const end = Math.min(cursor, nextLine.length - 1);
for (let index = 0; index <= end; index += 1) {
nextLine[index] = " ";
}
return nextLine;
}
return line.slice(0, cursor);
}

function renderTerminalHistoryLines(history: string): string[] {
const lines: string[] = [];
let currentLine: string[] = [];
let cursor = 0;

const commitLine = () => {
lines.push(currentLine.join(""));
currentLine = [];
cursor = 0;
};

const normalizedHistory = history.replace(/\r\n/g, "\n");

for (let index = 0; index < normalizedHistory.length; index += 1) {
const char = normalizedHistory[index];
if (!char) {
continue;
}
if (char === ANSI_ESCAPE) {
const escapeSequence = readAnsiEscapeSequence(normalizedHistory, index);
if (!escapeSequence) {
continue;
}
if (escapeSequence.finalByte === "K") {
currentLine = eraseRenderedLine(
currentLine,
cursor,
parseEraseInLineMode(escapeSequence.parameters),
);
}
index += escapeSequence.length - 1;
continue;
}
if (char === "\n") {
commitLine();
continue;
}
if (char === "\r") {
cursor = 0;
continue;
}
if (cursor < currentLine.length) {
currentLine[cursor] = char;
} else {
while (currentLine.length < cursor) {
currentLine.push(" ");
}
currentLine.push(char);
}
cursor += 1;
}

lines.push(currentLine.join(""));
return trimTrailingEmptyRenderedLines(lines);
}

function legacySafeThreadId(threadId: string): string {
return threadId.replace(/[^a-zA-Z0-9._-]/g, "_");
}
Expand Down Expand Up @@ -480,6 +628,27 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
});
}

async read(raw: TerminalReadInput): Promise<TerminalRenderedSnapshot> {
const input = decodeTerminalReadInput(raw);
if (input.scope !== "tail") {
throw new Error(`Unsupported terminal read scope: ${input.scope}`);
}

const session = this.sessions.get(toSessionKey(input.threadId, input.terminalId)) ?? null;
const history = session
? session.history
: await this.readHistory(input.threadId, input.terminalId);
const renderedLines = this.renderHistoryLines(history);
const totalLines = renderedLines.length;
const tailLines = renderedLines.slice(Math.max(0, totalLines - input.maxLines));

return {
text: tailLines.join("\n"),
totalLines,
returnedLineCount: tailLines.length,
};
}

async restart(raw: TerminalRestartInput): Promise<TerminalSessionSnapshot> {
const input = decodeTerminalRestartInput(raw);
return this.runWithThreadLock(input.threadId, async () => {
Expand Down Expand Up @@ -1089,6 +1258,13 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
return [...this.sessions.values()].filter((session) => session.threadId === threadId);
}

private renderHistoryLines(history: string): string[] {
if (history.length === 0) {
return [];
}
return renderTerminalHistoryLines(history);
}

private async deleteAllHistoryForThread(threadId: string): Promise<void> {
const threadPrefix = `${toSafeThreadId(threadId)}_`;
try {
Expand Down Expand Up @@ -1201,6 +1377,11 @@ export const TerminalManagerLive = Layer.effect(
try: () => runtime.clear(input),
catch: (cause) => new TerminalError({ message: "Failed to clear terminal", cause }),
}),
read: (input) =>
Effect.tryPromise({
try: () => runtime.read(input),
catch: (cause) => new TerminalError({ message: "Failed to read terminal", cause }),
}),
restart: (input) =>
Effect.tryPromise({
try: () => runtime.restart(input),
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/terminal/Services/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
TerminalCloseInput,
TerminalEvent,
TerminalOpenInput,
TerminalReadInput,
TerminalRenderedSnapshot,
TerminalResizeInput,
TerminalRestartInput,
TerminalSessionSnapshot,
Expand Down Expand Up @@ -83,6 +85,13 @@ export interface TerminalManagerShape {
*/
readonly clear: (input: TerminalClearInput) => Effect.Effect<void, TerminalError>;

/**
* Read a rendered terminal snapshot from retained history.
*/
readonly read: (
input: TerminalReadInput,
) => Effect.Effect<TerminalRenderedSnapshot, TerminalError>;

/**
* Restart a terminal session in place.
*
Expand Down
21 changes: 21 additions & 0 deletions apps/server/src/wsServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import type {
TerminalCloseInput,
TerminalEvent,
TerminalOpenInput,
TerminalRenderedSnapshot,
TerminalResizeInput,
TerminalSessionSnapshot,
TerminalWriteInput,
Expand Down Expand Up @@ -157,6 +158,13 @@ class MockTerminalManager implements TerminalManagerShape {
});
});

readonly read: TerminalManagerShape["read"] = () =>
Effect.succeed({
text: "tail line 1\ntail line 2",
totalLines: 42,
returnedLineCount: 2,
} satisfies TerminalRenderedSnapshot);

readonly restart: TerminalManagerShape["restart"] = (input: TerminalOpenInput) =>
Effect.sync(() => {
const now = new Date().toISOString();
Expand Down Expand Up @@ -1382,6 +1390,19 @@ describe("WebSocket Server", () => {
});
expect(clear.error).toBeUndefined();

const read = await sendRequest(ws, WS_METHODS.terminalRead, {
threadId: "thread-1",
terminalId: "default",
scope: "tail",
maxLines: 100,
});
expect(read.error).toBeUndefined();
expect(read.result).toEqual({
text: "tail line 1\ntail line 2",
totalLines: 42,
returnedLineCount: 2,
});

const restart = await sendRequest(ws, WS_METHODS.terminalRestart, {
threadId: "thread-1",
cwd,
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/wsServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
return yield* terminalManager.clear(body);
}

case WS_METHODS.terminalRead: {
const body = stripRequestTag(request.body);
return yield* terminalManager.read(body);
}

case WS_METHODS.terminalRestart: {
const body = stripRequestTag(request.body);
return yield* terminalManager.restart(body);
Expand Down
Loading