Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"test": "vitest run"
},
"dependencies": {
"node-pty": "^1.1.0",
"open": "^10.1.0",
"ws": "^8.18.0"
},
Expand Down
20 changes: 16 additions & 4 deletions apps/server/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,22 @@ import type {
GitListBranchesInput,
GitListBranchesResult,
GitRemoveWorktreeInput,
TerminalCommandInput,
TerminalCommandResult,
} from "@t3tools/contracts";

export interface TerminalCommandInput {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
command: string;
cwd: string;
timeoutMs?: number;
}

export interface TerminalCommandResult {
stdout: string;
stderr: string;
code: number | null;
signal: NodeJS.Signals | null;
timedOut: boolean;
}

/** Spawn git directly with an argv array — no shell, no quoting needed. */
function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise<TerminalCommandResult> {
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -138,8 +150,8 @@ export async function listGitBranches(input: GitListBranchesInput): Promise<GitL
let currentPath: string | null = null;
for (const line of worktreeList.stdout.split("\n")) {
if (line.startsWith("worktree ")) {
currentPath = line.slice("worktree ".length);
if (!fs.existsSync(currentPath)) currentPath = null;
const candidatePath = line.slice("worktree ".length);
currentPath = fs.existsSync(candidatePath) ? candidatePath : null;
} else if (line.startsWith("branch refs/heads/") && currentPath) {
worktreeMap.set(line.slice("branch refs/heads/".length), currentPath);
} else if (line === "") {
Expand Down
79 changes: 79 additions & 0 deletions apps/server/src/ptyAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import * as nodePty from "node-pty";

export interface PtyExitEvent {
exitCode: number;
signal: number | null;
}

export interface PtyProcess {
readonly pid: number;
write(data: string): void;
resize(cols: number, rows: number): void;
kill(signal?: string): void;
onData(callback: (data: string) => void): () => void;
onExit(callback: (event: PtyExitEvent) => void): () => void;
}

export interface PtySpawnInput {
shell: string;
cwd: string;
cols: number;
rows: number;
env: NodeJS.ProcessEnv;
}

export interface PtyAdapter {
spawn(input: PtySpawnInput): PtyProcess;
}

class NodePtyProcess implements PtyProcess {
constructor(private readonly process: nodePty.IPty) {}

get pid(): number {
return this.process.pid;
}

write(data: string): void {
this.process.write(data);
}

resize(cols: number, rows: number): void {
this.process.resize(cols, rows);
}

kill(signal?: string): void {
this.process.kill(signal);
}

onData(callback: (data: string) => void): () => void {
const disposable = this.process.onData(callback);
return () => {
disposable.dispose();
};
}

onExit(callback: (event: PtyExitEvent) => void): () => void {
const disposable = this.process.onExit((event) => {
callback({
exitCode: event.exitCode,
signal: event.signal ?? null,
});
});
return () => {
disposable.dispose();
};
}
}

export class NodePtyAdapter implements PtyAdapter {
spawn(input: PtySpawnInput): PtyProcess {
const ptyProcess = nodePty.spawn(input.shell, [], {
cwd: input.cwd,
cols: input.cols,
rows: input.rows,
env: input.env,
name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color",
});
return new NodePtyProcess(ptyProcess);
}
}
247 changes: 247 additions & 0 deletions apps/server/src/terminalManager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import type {
TerminalEvent,
TerminalOpenInput,
} from "@t3tools/contracts";
import { afterEach, describe, expect, it } from "vitest";

import type { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter";
import { TerminalManager } from "./terminalManager";

class FakePtyProcess implements PtyProcess {
readonly writes: string[] = [];
readonly resizeCalls: Array<{ cols: number; rows: number }> = [];
private readonly dataListeners = new Set<(data: string) => void>();
private readonly exitListeners = new Set<(event: PtyExitEvent) => void>();
killed = false;

constructor(readonly pid: number) {}

write(data: string): void {
this.writes.push(data);
}

resize(cols: number, rows: number): void {
this.resizeCalls.push({ cols, rows });
}

kill(): void {
this.killed = true;
}

onData(callback: (data: string) => void): () => void {
this.dataListeners.add(callback);
return () => {
this.dataListeners.delete(callback);
};
}

onExit(callback: (event: PtyExitEvent) => void): () => void {
this.exitListeners.add(callback);
return () => {
this.exitListeners.delete(callback);
};
}

emitData(data: string): void {
for (const listener of this.dataListeners) {
listener(data);
}
}

emitExit(event: PtyExitEvent): void {
for (const listener of this.exitListeners) {
listener(event);
}
}
}

class FakePtyAdapter implements PtyAdapter {
readonly spawnInputs: PtySpawnInput[] = [];
readonly processes: FakePtyProcess[] = [];
private nextPid = 9000;

spawn(input: PtySpawnInput): PtyProcess {
this.spawnInputs.push(input);
const process = new FakePtyProcess(this.nextPid++);
this.processes.push(process);
return process;
}
}

function waitFor(predicate: () => boolean, timeoutMs = 800): Promise<void> {
const started = Date.now();
return new Promise((resolve, reject) => {
const poll = () => {
if (predicate()) {
resolve();
return;
}
if (Date.now() - started > timeoutMs) {
reject(new Error("Timed out waiting for condition"));
return;
}
setTimeout(poll, 15);
};
poll();
});
}

function openInput(overrides: Partial<TerminalOpenInput> = {}): TerminalOpenInput {
return {
threadId: "thread-1",
cwd: process.cwd(),
cols: 100,
rows: 24,
...overrides,
};
}

describe("TerminalManager", () => {
const tempDirs: string[] = [];

afterEach(() => {
for (const dir of tempDirs.splice(0, tempDirs.length)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});

function makeManager(historyLineLimit = 5) {
const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-"));
tempDirs.push(logsDir);
const ptyAdapter = new FakePtyAdapter();
const manager = new TerminalManager({
logsDir,
ptyAdapter,
historyLineLimit,
shellResolver: () => "/bin/bash",
});
return { logsDir, ptyAdapter, manager };
}

it("spawns lazily and reuses running terminal per thread", async () => {
const { manager, ptyAdapter } = makeManager();
const first = await manager.open(openInput());
const second = await manager.open(openInput());

expect(first.threadId).toBe("thread-1");
expect(second.threadId).toBe("thread-1");
expect(ptyAdapter.spawnInputs).toHaveLength(1);

manager.dispose();
});

it("forwards write and resize to active pty process", async () => {
const { manager, ptyAdapter } = makeManager();
await manager.open(openInput());
const process = ptyAdapter.processes[0];
expect(process).toBeDefined();
if (!process) return;

await manager.write({ threadId: "thread-1", data: "ls\n" });
await manager.resize({ threadId: "thread-1", cols: 120, rows: 30 });

expect(process.writes).toEqual(["ls\n"]);
expect(process.resizeCalls).toEqual([{ cols: 120, rows: 30 }]);

manager.dispose();
});

it("clears transcript and emits cleared event", async () => {
const { manager, ptyAdapter, logsDir } = makeManager();
const events: TerminalEvent[] = [];
manager.on("event", (event) => {
events.push(event);
});
await manager.open(openInput());
const process = ptyAdapter.processes[0];
expect(process).toBeDefined();
if (!process) return;

process.emitData("hello\n");
await waitFor(() => fs.existsSync(path.join(logsDir, "thread-1.log")));
await manager.clear({ threadId: "thread-1" });
await waitFor(() => fs.readFileSync(path.join(logsDir, "thread-1.log"), "utf8") === "");

expect(events.some((event) => event.type === "cleared")).toBe(true);

manager.dispose();
});

it("restarts terminal with empty transcript and respawns pty", async () => {
const { manager, ptyAdapter, logsDir } = makeManager();
await manager.open(openInput());
const firstProcess = ptyAdapter.processes[0];
expect(firstProcess).toBeDefined();
if (!firstProcess) return;
firstProcess.emitData("before restart\n");
await waitFor(() => fs.existsSync(path.join(logsDir, "thread-1.log")));

const snapshot = await manager.restart(openInput());
expect(snapshot.history).toBe("");
expect(snapshot.status).toBe("running");
expect(ptyAdapter.spawnInputs).toHaveLength(2);
await waitFor(() => fs.readFileSync(path.join(logsDir, "thread-1.log"), "utf8") === "");

manager.dispose();
});

it("emits exited event and reopens with clean transcript after exit", async () => {
const { manager, ptyAdapter, logsDir } = makeManager();
const events: TerminalEvent[] = [];
manager.on("event", (event) => {
events.push(event);
});
await manager.open(openInput());
const process = ptyAdapter.processes[0];
expect(process).toBeDefined();
if (!process) return;
process.emitData("old data\n");
await waitFor(() => fs.existsSync(path.join(logsDir, "thread-1.log")));
process.emitExit({ exitCode: 0, signal: 0 });

await waitFor(() => events.some((event) => event.type === "exited"));
const reopened = await manager.open(openInput());

expect(reopened.history).toBe("");
expect(ptyAdapter.spawnInputs).toHaveLength(2);
expect(fs.readFileSync(path.join(logsDir, "thread-1.log"), "utf8")).toBe("");

manager.dispose();
});

it("caps persisted history to configured line limit", async () => {
const { manager, ptyAdapter } = makeManager(3);
await manager.open(openInput());
const process = ptyAdapter.processes[0];
expect(process).toBeDefined();
if (!process) return;

process.emitData("line1\nline2\nline3\nline4\n");
await manager.close({ threadId: "thread-1" });

const reopened = await manager.open(openInput());
const nonEmptyLines = reopened.history.split("\n").filter((line) => line.length > 0);
expect(nonEmptyLines).toEqual(["line2", "line3", "line4"]);

manager.dispose();
});

it("deletes history file when close(deleteHistory=true)", async () => {
const { manager, ptyAdapter, logsDir } = makeManager();
await manager.open(openInput());
const process = ptyAdapter.processes[0];
expect(process).toBeDefined();
if (!process) return;
process.emitData("bye\n");
await waitFor(() => fs.existsSync(path.join(logsDir, "thread-1.log")));

await manager.close({ threadId: "thread-1", deleteHistory: true });
expect(fs.existsSync(path.join(logsDir, "thread-1.log"))).toBe(false);

manager.dispose();
});
});
Loading