feat: add Tauri desktop app support and terminal management
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
|
||||
|
||||
describe("TerminalManager", () => {
|
||||
let manager: TerminalManager;
|
||||
|
||||
afterEach(() => {
|
||||
if (manager) {
|
||||
manager.killAll();
|
||||
}
|
||||
});
|
||||
|
||||
describe("getTerminals", () => {
|
||||
it("auto-creates first terminal for new cwd", async () => {
|
||||
manager = createTerminalManager();
|
||||
const terminals = await manager.getTerminals("/tmp");
|
||||
|
||||
expect(terminals.length).toBe(1);
|
||||
expect(terminals[0].name).toBe("Terminal 1");
|
||||
expect(terminals[0].cwd).toBe("/tmp");
|
||||
});
|
||||
|
||||
it("returns existing terminals on subsequent calls", async () => {
|
||||
manager = createTerminalManager();
|
||||
const first = await manager.getTerminals("/tmp");
|
||||
const second = await manager.getTerminals("/tmp");
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(first.length).toBe(1);
|
||||
});
|
||||
|
||||
it("throws for relative paths", async () => {
|
||||
manager = createTerminalManager();
|
||||
await expect(manager.getTerminals("tmp")).rejects.toThrow("cwd must be absolute path");
|
||||
});
|
||||
|
||||
it("creates separate terminals for different cwds", async () => {
|
||||
manager = createTerminalManager();
|
||||
const tmpTerminals = await manager.getTerminals("/tmp");
|
||||
const homeTerminals = await manager.getTerminals("/home");
|
||||
|
||||
expect(tmpTerminals.length).toBe(1);
|
||||
expect(homeTerminals.length).toBe(1);
|
||||
expect(tmpTerminals[0].id).not.toBe(homeTerminals[0].id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTerminal", () => {
|
||||
it("creates additional terminal with auto-incrementing name", async () => {
|
||||
manager = createTerminalManager();
|
||||
await manager.getTerminals("/tmp");
|
||||
const second = await manager.createTerminal({ cwd: "/tmp" });
|
||||
|
||||
expect(second.name).toBe("Terminal 2");
|
||||
|
||||
const terminals = await manager.getTerminals("/tmp");
|
||||
expect(terminals.length).toBe(2);
|
||||
});
|
||||
|
||||
it("uses custom name when provided", async () => {
|
||||
manager = createTerminalManager();
|
||||
const session = await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" });
|
||||
|
||||
expect(session.name).toBe("Dev Server");
|
||||
});
|
||||
|
||||
it("creates first terminal if none exist", async () => {
|
||||
manager = createTerminalManager();
|
||||
const session = await manager.createTerminal({ cwd: "/tmp" });
|
||||
|
||||
expect(session.name).toBe("Terminal 1");
|
||||
|
||||
const terminals = await manager.getTerminals("/tmp");
|
||||
expect(terminals.length).toBe(1);
|
||||
expect(terminals[0].id).toBe(session.id);
|
||||
});
|
||||
|
||||
it("throws for relative paths", async () => {
|
||||
manager = createTerminalManager();
|
||||
await expect(manager.createTerminal({ cwd: "tmp" })).rejects.toThrow("cwd must be absolute path");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTerminal", () => {
|
||||
it("returns terminal by id", async () => {
|
||||
manager = createTerminalManager();
|
||||
const terminals = await manager.getTerminals("/tmp");
|
||||
const found = manager.getTerminal(terminals[0].id);
|
||||
|
||||
expect(found).toBe(terminals[0]);
|
||||
});
|
||||
|
||||
it("returns undefined for unknown id", () => {
|
||||
manager = createTerminalManager();
|
||||
const found = manager.getTerminal("unknown-id");
|
||||
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("killTerminal", () => {
|
||||
it("removes terminal from manager", async () => {
|
||||
manager = createTerminalManager();
|
||||
const terminals = await manager.getTerminals("/tmp");
|
||||
const id = terminals[0].id;
|
||||
|
||||
manager.killTerminal(id);
|
||||
|
||||
expect(manager.getTerminal(id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("removes cwd entry when last terminal is killed", async () => {
|
||||
manager = createTerminalManager();
|
||||
const terminals = await manager.getTerminals("/tmp");
|
||||
|
||||
manager.killTerminal(terminals[0].id);
|
||||
|
||||
expect(manager.listDirectories()).not.toContain("/tmp");
|
||||
});
|
||||
|
||||
it("keeps cwd entry when other terminals remain", async () => {
|
||||
manager = createTerminalManager();
|
||||
await manager.getTerminals("/tmp");
|
||||
const second = await manager.createTerminal({ cwd: "/tmp" });
|
||||
|
||||
const terminals = await manager.getTerminals("/tmp");
|
||||
manager.killTerminal(terminals[0].id);
|
||||
|
||||
expect(manager.listDirectories()).toContain("/tmp");
|
||||
const remaining = await manager.getTerminals("/tmp");
|
||||
expect(remaining.length).toBe(1);
|
||||
expect(remaining[0].id).toBe(second.id);
|
||||
});
|
||||
|
||||
it("is no-op for unknown id", () => {
|
||||
manager = createTerminalManager();
|
||||
expect(() => manager.killTerminal("unknown-id")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("listDirectories", () => {
|
||||
it("returns empty array initially", () => {
|
||||
manager = createTerminalManager();
|
||||
expect(manager.listDirectories()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns all cwds with active terminals", async () => {
|
||||
manager = createTerminalManager();
|
||||
await manager.getTerminals("/tmp");
|
||||
await manager.getTerminals("/home");
|
||||
|
||||
const dirs = manager.listDirectories();
|
||||
expect(dirs).toContain("/tmp");
|
||||
expect(dirs).toContain("/home");
|
||||
expect(dirs.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("killAll", () => {
|
||||
it("kills all terminals and clears state", async () => {
|
||||
manager = createTerminalManager();
|
||||
const tmpTerminals = await manager.getTerminals("/tmp");
|
||||
const homeTerminals = await manager.getTerminals("/home");
|
||||
|
||||
manager.killAll();
|
||||
|
||||
expect(manager.listDirectories()).toEqual([]);
|
||||
expect(manager.getTerminal(tmpTerminals[0].id)).toBeUndefined();
|
||||
expect(manager.getTerminal(homeTerminals[0].id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createTerminal, type TerminalSession } from "./terminal.js";
|
||||
|
||||
export interface TerminalManager {
|
||||
getTerminals(cwd: string): Promise<TerminalSession[]>;
|
||||
createTerminal(options: { cwd: string; name?: string }): Promise<TerminalSession>;
|
||||
getTerminal(id: string): TerminalSession | undefined;
|
||||
killTerminal(id: string): void;
|
||||
listDirectories(): string[];
|
||||
killAll(): void;
|
||||
}
|
||||
|
||||
export function createTerminalManager(): TerminalManager {
|
||||
const terminalsByCwd = new Map<string, TerminalSession[]>();
|
||||
const terminalsById = new Map<string, TerminalSession>();
|
||||
|
||||
function assertAbsolutePath(cwd: string): void {
|
||||
if (!cwd.startsWith("/")) {
|
||||
throw new Error("cwd must be absolute path");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async getTerminals(cwd: string): Promise<TerminalSession[]> {
|
||||
assertAbsolutePath(cwd);
|
||||
|
||||
let terminals = terminalsByCwd.get(cwd);
|
||||
if (!terminals || terminals.length === 0) {
|
||||
const session = await createTerminal({ cwd, name: "Terminal 1" });
|
||||
terminals = [session];
|
||||
terminalsByCwd.set(cwd, terminals);
|
||||
terminalsById.set(session.id, session);
|
||||
}
|
||||
return terminals;
|
||||
},
|
||||
|
||||
async createTerminal(options: { cwd: string; name?: string }): Promise<TerminalSession> {
|
||||
assertAbsolutePath(options.cwd);
|
||||
|
||||
const terminals = terminalsByCwd.get(options.cwd) ?? [];
|
||||
const defaultName = `Terminal ${terminals.length + 1}`;
|
||||
const session = await createTerminal({
|
||||
cwd: options.cwd,
|
||||
name: options.name ?? defaultName,
|
||||
});
|
||||
|
||||
terminals.push(session);
|
||||
terminalsByCwd.set(options.cwd, terminals);
|
||||
terminalsById.set(session.id, session);
|
||||
|
||||
return session;
|
||||
},
|
||||
|
||||
getTerminal(id: string): TerminalSession | undefined {
|
||||
return terminalsById.get(id);
|
||||
},
|
||||
|
||||
killTerminal(id: string): void {
|
||||
const session = terminalsById.get(id);
|
||||
if (!session) return;
|
||||
|
||||
session.kill();
|
||||
terminalsById.delete(id);
|
||||
|
||||
const terminals = terminalsByCwd.get(session.cwd);
|
||||
if (terminals) {
|
||||
const index = terminals.indexOf(session);
|
||||
if (index !== -1) {
|
||||
terminals.splice(index, 1);
|
||||
}
|
||||
if (terminals.length === 0) {
|
||||
terminalsByCwd.delete(session.cwd);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
listDirectories(): string[] {
|
||||
return Array.from(terminalsByCwd.keys());
|
||||
},
|
||||
|
||||
killAll(): void {
|
||||
for (const session of terminalsById.values()) {
|
||||
session.kill();
|
||||
}
|
||||
terminalsByCwd.clear();
|
||||
terminalsById.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { createTerminal, type TerminalSession } from "./terminal.js";
|
||||
|
||||
// Extract text from a single row
|
||||
function getRowText(state: ReturnType<TerminalSession["getState"]>, rowIndex: number): string {
|
||||
return state.grid[rowIndex].map((cell) => cell.char).join("").trimEnd();
|
||||
}
|
||||
|
||||
// Extract all visible lines as array (trimmed, empty lines included)
|
||||
function getLines(state: ReturnType<TerminalSession["getState"]>): string[] {
|
||||
return state.grid.map((row) => row.map((cell) => cell.char).join("").trimEnd());
|
||||
}
|
||||
|
||||
// Wait for terminal state to match expected lines
|
||||
async function waitForLines(
|
||||
session: TerminalSession,
|
||||
expectedLines: string[],
|
||||
timeoutMs = 5000
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const lines = getLines(session.getState());
|
||||
let matches = true;
|
||||
for (let i = 0; i < expectedLines.length; i++) {
|
||||
if (lines[i] !== expectedLines[i]) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matches) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
const actual = getLines(session.getState()).slice(0, expectedLines.length);
|
||||
throw new Error(
|
||||
`Timeout waiting for expected lines.\nExpected:\n${JSON.stringify(expectedLines, null, 2)}\nActual:\n${JSON.stringify(actual, null, 2)}`
|
||||
);
|
||||
}
|
||||
|
||||
describe("Terminal", () => {
|
||||
const sessions: TerminalSession[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const session of sessions) {
|
||||
session.kill();
|
||||
}
|
||||
sessions.length = 0;
|
||||
});
|
||||
|
||||
function trackSession(session: TerminalSession): TerminalSession {
|
||||
sessions.push(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
describe("createTerminal", () => {
|
||||
it("creates a terminal session with an id, name, and cwd", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
})
|
||||
);
|
||||
|
||||
expect(session.id).toBeDefined();
|
||||
expect(typeof session.id).toBe("string");
|
||||
expect(session.id.length).toBeGreaterThan(0);
|
||||
expect(session.name).toBe("Terminal");
|
||||
expect(session.cwd).toBe("/tmp");
|
||||
});
|
||||
|
||||
it("uses custom name when provided", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
name: "Dev Server",
|
||||
})
|
||||
);
|
||||
|
||||
expect(session.name).toBe("Dev Server");
|
||||
});
|
||||
|
||||
it("uses default shell if not specified", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
})
|
||||
);
|
||||
|
||||
expect(session.id).toBeDefined();
|
||||
});
|
||||
|
||||
it("uses default rows and cols", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
})
|
||||
);
|
||||
|
||||
const state = session.getState();
|
||||
expect(state.rows).toBe(24);
|
||||
expect(state.cols).toBe(80);
|
||||
});
|
||||
|
||||
it("respects custom rows and cols", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
rows: 40,
|
||||
cols: 120,
|
||||
})
|
||||
);
|
||||
|
||||
const state = session.getState();
|
||||
expect(state.rows).toBe(40);
|
||||
expect(state.cols).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
describe("send input", () => {
|
||||
it("executes a simple echo command", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
})
|
||||
);
|
||||
|
||||
// Wait for initial prompt, then send command
|
||||
await waitForLines(session, ["$"]);
|
||||
|
||||
session.send({ type: "input", data: "echo hello\r" });
|
||||
|
||||
// After running "echo hello", terminal should show:
|
||||
// Line 0: "$ echo hello"
|
||||
// Line 1: "hello"
|
||||
// Line 2: "$"
|
||||
await waitForLines(session, ["$ echo hello", "hello", "$"]);
|
||||
|
||||
const state = session.getState();
|
||||
expect(getRowText(state, 0)).toBe("$ echo hello");
|
||||
expect(getRowText(state, 1)).toBe("hello");
|
||||
expect(getRowText(state, 2)).toBe("$");
|
||||
});
|
||||
|
||||
it("executes multiple commands sequentially", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
})
|
||||
);
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
|
||||
session.send({ type: "input", data: "echo first\r" });
|
||||
await waitForLines(session, ["$ echo first", "first", "$"]);
|
||||
|
||||
session.send({ type: "input", data: "echo second\r" });
|
||||
await waitForLines(session, [
|
||||
"$ echo first",
|
||||
"first",
|
||||
"$ echo second",
|
||||
"second",
|
||||
"$",
|
||||
]);
|
||||
|
||||
const state = session.getState();
|
||||
expect(getRowText(state, 0)).toBe("$ echo first");
|
||||
expect(getRowText(state, 1)).toBe("first");
|
||||
expect(getRowText(state, 2)).toBe("$ echo second");
|
||||
expect(getRowText(state, 3)).toBe("second");
|
||||
expect(getRowText(state, 4)).toBe("$");
|
||||
});
|
||||
|
||||
it("captures output from pwd in specified cwd", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
})
|
||||
);
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
|
||||
session.send({ type: "input", data: "pwd\r" });
|
||||
|
||||
await waitForLines(session, ["$ pwd", "/tmp", "$"]);
|
||||
|
||||
const state = session.getState();
|
||||
expect(getRowText(state, 0)).toBe("$ pwd");
|
||||
expect(getRowText(state, 1)).toBe("/tmp");
|
||||
expect(getRowText(state, 2)).toBe("$");
|
||||
});
|
||||
});
|
||||
|
||||
describe("colors", () => {
|
||||
it("captures ANSI 16 color codes (mode 1)", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ ", TERM: "xterm-256color" },
|
||||
})
|
||||
);
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
|
||||
// \033[31m = ANSI red (color 1)
|
||||
session.send({ type: "input", data: "printf '\\033[31mRED\\033[0m'\r" });
|
||||
|
||||
await waitForLines(session, ["$ printf '\\033[31mRED\\033[0m'", "RED$"]);
|
||||
|
||||
const state = session.getState();
|
||||
const outputRow = state.grid[1];
|
||||
|
||||
expect(outputRow[0].char).toBe("R");
|
||||
expect(outputRow[0].fg).toBe(1); // ANSI red = 1
|
||||
expect(outputRow[0].fgMode).toBe(1); // Mode 1 = 16 ANSI colors
|
||||
|
||||
// The "$" after RED should have default color
|
||||
expect(outputRow[3].char).toBe("$");
|
||||
expect(outputRow[3].fg).toBe(undefined);
|
||||
expect(outputRow[3].fgMode).toBe(undefined);
|
||||
});
|
||||
|
||||
it("captures 256 color codes (mode 2)", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ ", TERM: "xterm-256color" },
|
||||
})
|
||||
);
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
|
||||
// \033[38;5;208m = 256-color orange (color 208)
|
||||
session.send({ type: "input", data: "printf '\\033[38;5;208mORG\\033[0m'\r" });
|
||||
|
||||
await waitForLines(session, ["$ printf '\\033[38;5;208mORG\\033[0m'", "ORG$"]);
|
||||
|
||||
const state = session.getState();
|
||||
const outputRow = state.grid[1];
|
||||
|
||||
// Check O cell
|
||||
expect(outputRow[0].char).toBe("O");
|
||||
expect(outputRow[0].fg).toBe(208); // 256-color index
|
||||
expect(outputRow[0].fgMode).toBe(2); // Mode 2 = 256 colors
|
||||
});
|
||||
|
||||
it("captures true color RGB (mode 3)", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ ", TERM: "xterm-256color" },
|
||||
})
|
||||
);
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
|
||||
// \033[38;2;255;128;64m = true color RGB(255, 128, 64)
|
||||
session.send({ type: "input", data: "printf '\\033[38;2;255;128;64mRGB\\033[0m'\r" });
|
||||
|
||||
await waitForLines(session, ["$ printf '\\033[38;2;255;128;64mRGB\\033[0m'", "RGB$"]);
|
||||
|
||||
const state = session.getState();
|
||||
const outputRow = state.grid[1];
|
||||
|
||||
// Check R cell
|
||||
expect(outputRow[0].char).toBe("R");
|
||||
expect(outputRow[0].fgMode).toBe(3); // Mode 3 = true color
|
||||
|
||||
// The color value should be packed RGB: (255 << 16) | (128 << 8) | 64
|
||||
const expectedPacked = (255 << 16) | (128 << 8) | 64;
|
||||
expect(outputRow[0].fg).toBe(expectedPacked);
|
||||
});
|
||||
|
||||
it("captures background colors", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ ", TERM: "xterm-256color" },
|
||||
})
|
||||
);
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
|
||||
// \033[41m = ANSI red background
|
||||
session.send({ type: "input", data: "printf '\\033[41mBG\\033[0m'\r" });
|
||||
|
||||
await waitForLines(session, ["$ printf '\\033[41mBG\\033[0m'", "BG$"]);
|
||||
|
||||
const state = session.getState();
|
||||
const outputRow = state.grid[1];
|
||||
|
||||
expect(outputRow[0].char).toBe("B");
|
||||
expect(outputRow[0].bg).toBe(1); // ANSI red = 1
|
||||
expect(outputRow[0].bgMode).toBe(1); // Mode 1 = 16 ANSI colors
|
||||
});
|
||||
});
|
||||
|
||||
describe("resize", () => {
|
||||
it("updates terminal dimensions on resize", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
})
|
||||
);
|
||||
|
||||
session.send({ type: "resize", rows: 40, cols: 120 });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const state = session.getState();
|
||||
expect(state.rows).toBe(40);
|
||||
expect(state.cols).toBe(120);
|
||||
});
|
||||
|
||||
it("grid reflects new dimensions after resize", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
})
|
||||
);
|
||||
|
||||
session.send({ type: "resize", rows: 10, cols: 40 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const state = session.getState();
|
||||
expect(state.grid.length).toBe(10);
|
||||
expect(state.grid[0].length).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscribe", () => {
|
||||
it("receives full state on initial subscription", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
})
|
||||
);
|
||||
|
||||
const messages: Array<{ type: string }> = [];
|
||||
const unsubscribe = session.subscribe((msg) => {
|
||||
messages.push(msg);
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
expect(messages[0].type).toBe("full");
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("receives messages on updates", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
})
|
||||
);
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
|
||||
const messages: Array<{ type: string }> = [];
|
||||
const unsubscribe = session.subscribe((msg) => {
|
||||
messages.push(msg);
|
||||
});
|
||||
|
||||
messages.length = 0;
|
||||
|
||||
session.send({ type: "input", data: "echo test\r" });
|
||||
|
||||
await waitForLines(session, ["$ echo test", "test", "$"]);
|
||||
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("unsubscribe stops receiving messages", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
})
|
||||
);
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
|
||||
const messages: Array<{ type: string }> = [];
|
||||
const unsubscribe = session.subscribe((msg) => {
|
||||
messages.push(msg);
|
||||
});
|
||||
|
||||
unsubscribe();
|
||||
messages.length = 0;
|
||||
|
||||
session.send({ type: "input", data: "echo after\r" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
expect(messages.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getState", () => {
|
||||
it("returns current terminal state with grid", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
})
|
||||
);
|
||||
|
||||
const state = session.getState();
|
||||
|
||||
expect(state.rows).toBe(24);
|
||||
expect(state.cols).toBe(80);
|
||||
expect(state.grid).toBeDefined();
|
||||
expect(state.grid.length).toBe(24);
|
||||
expect(state.grid[0].length).toBe(80);
|
||||
expect(state.cursor).toBeDefined();
|
||||
expect(typeof state.cursor.row).toBe("number");
|
||||
expect(typeof state.cursor.col).toBe("number");
|
||||
});
|
||||
|
||||
it("grid cells have char and color attributes", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
})
|
||||
);
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
|
||||
const state = session.getState();
|
||||
// First cell should be "$"
|
||||
expect(state.grid[0][0].char).toBe("$");
|
||||
expect(state.grid[0][0]).toHaveProperty("fg");
|
||||
expect(state.grid[0][0]).toHaveProperty("bg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scrollback", () => {
|
||||
it("preserves scrollback buffer", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
rows: 5,
|
||||
cols: 80,
|
||||
})
|
||||
);
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
|
||||
// seq 1 20 produces 20 lines of output
|
||||
// With 5 rows, we expect lines to scroll into scrollback
|
||||
session.send({ type: "input", data: "seq 1 20\r" });
|
||||
|
||||
// Wait for command to finish - final prompt appears after "20"
|
||||
// In a 5-row terminal, we'll see the last lines plus prompt
|
||||
// The visible area will show something like: 17, 18, 19, 20, $
|
||||
await waitForLines(session, ["17", "18", "19", "20", "$"]);
|
||||
|
||||
const state = session.getState();
|
||||
|
||||
// Scrollback should contain the earlier output
|
||||
expect(state.scrollback.length).toBeGreaterThan(0);
|
||||
|
||||
const scrollbackText = state.scrollback
|
||||
.map((row) => row.map((cell) => cell.char).join("").trimEnd())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
// The scrollback should contain the command and early numbers
|
||||
expect(scrollbackText).toContain("$ seq 1 20");
|
||||
expect(scrollbackText).toContain("1");
|
||||
expect(scrollbackText).toContain("2");
|
||||
expect(scrollbackText).toContain("3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("kill", () => {
|
||||
it("terminates the shell process", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
})
|
||||
);
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
|
||||
session.kill();
|
||||
|
||||
// Should not throw when trying to get state after kill
|
||||
const state = session.getState();
|
||||
expect(state).toBeDefined();
|
||||
});
|
||||
|
||||
it("send after kill is a no-op", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
})
|
||||
);
|
||||
|
||||
session.kill();
|
||||
|
||||
// Should not throw
|
||||
session.send({ type: "input", data: "echo test\r" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("mouse events", () => {
|
||||
it("accepts mouse events without throwing", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
})
|
||||
);
|
||||
|
||||
// Should not throw
|
||||
session.send({ type: "mouse", row: 0, col: 0, button: 0, action: "down" });
|
||||
session.send({ type: "mouse", row: 0, col: 0, button: 0, action: "up" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import * as pty from "node-pty";
|
||||
import xterm, { type Terminal as TerminalType } from "@xterm/headless";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
const { Terminal } = xterm;
|
||||
|
||||
export interface Cell {
|
||||
char: string;
|
||||
fg: number | undefined;
|
||||
bg: number | undefined;
|
||||
fgMode?: number; // 0=default, 1=16 ANSI, 2=256, 3=RGB
|
||||
bgMode?: number;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
}
|
||||
|
||||
export interface Pos {
|
||||
row: number;
|
||||
col: number;
|
||||
}
|
||||
|
||||
export interface TerminalState {
|
||||
rows: number;
|
||||
cols: number;
|
||||
grid: Cell[][];
|
||||
scrollback: Cell[][];
|
||||
cursor: Pos;
|
||||
}
|
||||
|
||||
export interface CellChange {
|
||||
row: number;
|
||||
col: number;
|
||||
cell: Cell;
|
||||
}
|
||||
|
||||
export type ClientMessage =
|
||||
| { type: "input"; data: string }
|
||||
| { type: "resize"; rows: number; cols: number }
|
||||
| { type: "mouse"; row: number; col: number; button: number; action: "down" | "up" | "move" };
|
||||
|
||||
export type ServerMessage =
|
||||
| { type: "full"; state: TerminalState }
|
||||
| { type: "diff"; changes: CellChange[]; cursor: Pos };
|
||||
|
||||
export interface TerminalSession {
|
||||
id: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
send(msg: ClientMessage): void;
|
||||
subscribe(listener: (msg: ServerMessage) => void): () => void;
|
||||
getState(): TerminalState;
|
||||
kill(): void;
|
||||
}
|
||||
|
||||
export interface CreateTerminalOptions {
|
||||
cwd: string;
|
||||
shell?: string;
|
||||
env?: Record<string, string>;
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
function extractCell(terminal: TerminalType, row: number, col: number): Cell {
|
||||
const buffer = terminal.buffer.active;
|
||||
const line = buffer.getLine(row);
|
||||
if (!line) {
|
||||
return { char: " ", fg: undefined, bg: undefined };
|
||||
}
|
||||
|
||||
const cell = line.getCell(col);
|
||||
if (!cell) {
|
||||
return { char: " ", fg: undefined, bg: undefined };
|
||||
}
|
||||
|
||||
// Color modes from xterm.js: 0=DEFAULT, 1=16 colors (ANSI), 2=256 colors, 3=RGB
|
||||
// getFgColorMode() returns packed value with mode in upper byte (e.g. 0x01000000 for mode 1)
|
||||
const fgModeRaw = cell.getFgColorMode();
|
||||
const bgModeRaw = cell.getBgColorMode();
|
||||
const fgMode = fgModeRaw >> 24;
|
||||
const bgMode = bgModeRaw >> 24;
|
||||
|
||||
// Only return color if not default (mode 0)
|
||||
const fg = fgMode !== 0 ? cell.getFgColor() : undefined;
|
||||
const bg = bgMode !== 0 ? cell.getBgColor() : undefined;
|
||||
|
||||
return {
|
||||
char: cell.getChars() || " ",
|
||||
fg,
|
||||
bg,
|
||||
fgMode: fgMode !== 0 ? fgMode : undefined,
|
||||
bgMode: bgMode !== 0 ? bgMode : undefined,
|
||||
bold: cell.isBold() !== 0,
|
||||
italic: cell.isItalic() !== 0,
|
||||
underline: cell.isUnderline() !== 0,
|
||||
};
|
||||
}
|
||||
|
||||
function extractGrid(terminal: TerminalType): Cell[][] {
|
||||
const grid: Cell[][] = [];
|
||||
const buffer = terminal.buffer.active;
|
||||
// Visible viewport starts at baseY
|
||||
const baseY = buffer.baseY;
|
||||
|
||||
for (let row = 0; row < terminal.rows; row++) {
|
||||
const rowCells: Cell[] = [];
|
||||
for (let col = 0; col < terminal.cols; col++) {
|
||||
rowCells.push(extractCell(terminal, baseY + row, col));
|
||||
}
|
||||
grid.push(rowCells);
|
||||
}
|
||||
|
||||
return grid;
|
||||
}
|
||||
|
||||
function extractScrollback(terminal: TerminalType): Cell[][] {
|
||||
const scrollback: Cell[][] = [];
|
||||
const buffer = terminal.buffer.active;
|
||||
// baseY is the first row of the visible viewport (0-indexed)
|
||||
// Lines 0 to baseY-1 are in scrollback, lines baseY onwards are visible
|
||||
const scrollbackLines = buffer.baseY;
|
||||
|
||||
for (let row = 0; row < scrollbackLines; row++) {
|
||||
const rowCells: Cell[] = [];
|
||||
const line = buffer.getLine(row);
|
||||
for (let col = 0; col < terminal.cols; col++) {
|
||||
if (line) {
|
||||
const cell = line.getCell(col);
|
||||
if (cell) {
|
||||
const fgModeRaw = cell.getFgColorMode();
|
||||
const bgModeRaw = cell.getBgColorMode();
|
||||
const fgMode = fgModeRaw >> 24;
|
||||
const bgMode = bgModeRaw >> 24;
|
||||
const fg = fgMode !== 0 ? cell.getFgColor() : undefined;
|
||||
const bg = bgMode !== 0 ? cell.getBgColor() : undefined;
|
||||
rowCells.push({
|
||||
char: cell.getChars() || " ",
|
||||
fg,
|
||||
bg,
|
||||
fgMode: fgMode !== 0 ? fgMode : undefined,
|
||||
bgMode: bgMode !== 0 ? bgMode : undefined,
|
||||
bold: cell.isBold() !== 0,
|
||||
italic: cell.isItalic() !== 0,
|
||||
underline: cell.isUnderline() !== 0,
|
||||
});
|
||||
} else {
|
||||
rowCells.push({ char: " ", fg: undefined, bg: undefined });
|
||||
}
|
||||
} else {
|
||||
rowCells.push({ char: " ", fg: undefined, bg: undefined });
|
||||
}
|
||||
}
|
||||
scrollback.push(rowCells);
|
||||
}
|
||||
|
||||
return scrollback;
|
||||
}
|
||||
|
||||
export async function createTerminal(options: CreateTerminalOptions): Promise<TerminalSession> {
|
||||
const {
|
||||
cwd,
|
||||
shell = process.env.SHELL || "/bin/sh",
|
||||
env = {},
|
||||
rows = 24,
|
||||
cols = 80,
|
||||
name = "Terminal",
|
||||
} = options;
|
||||
|
||||
const id = randomUUID();
|
||||
const listeners = new Set<(msg: ServerMessage) => void>();
|
||||
let killed = false;
|
||||
|
||||
// Create xterm.js headless terminal
|
||||
const terminal = new Terminal({
|
||||
rows,
|
||||
cols,
|
||||
scrollback: 1000,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
// Create PTY
|
||||
const ptyProcess = pty.spawn(shell, [], {
|
||||
name: "xterm-256color",
|
||||
cols,
|
||||
rows,
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
TERM: "xterm-256color",
|
||||
},
|
||||
});
|
||||
|
||||
// Pipe PTY output to terminal emulator
|
||||
ptyProcess.onData((data) => {
|
||||
if (killed) return;
|
||||
terminal.write(data, () => {
|
||||
// Notify listeners of changes
|
||||
const state = getState();
|
||||
for (const listener of listeners) {
|
||||
listener({ type: "full", state });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ptyProcess.onExit(() => {
|
||||
killed = true;
|
||||
});
|
||||
|
||||
function getState(): TerminalState {
|
||||
return {
|
||||
rows: terminal.rows,
|
||||
cols: terminal.cols,
|
||||
grid: extractGrid(terminal),
|
||||
scrollback: extractScrollback(terminal),
|
||||
cursor: {
|
||||
row: terminal.buffer.active.cursorY,
|
||||
col: terminal.buffer.active.cursorX,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function send(msg: ClientMessage): void {
|
||||
if (killed) return;
|
||||
|
||||
switch (msg.type) {
|
||||
case "input":
|
||||
ptyProcess.write(msg.data);
|
||||
break;
|
||||
case "resize":
|
||||
terminal.resize(msg.cols, msg.rows);
|
||||
ptyProcess.resize(msg.cols, msg.rows);
|
||||
break;
|
||||
case "mouse":
|
||||
// Mouse events can be sent as escape sequences if terminal supports it
|
||||
// For now, we'll just ignore them - can be implemented later
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe(listener: (msg: ServerMessage) => void): () => void {
|
||||
listeners.add(listener);
|
||||
|
||||
// Send initial full state
|
||||
queueMicrotask(() => {
|
||||
if (listeners.has(listener)) {
|
||||
listener({ type: "full", state: getState() });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function kill(): void {
|
||||
if (killed) return;
|
||||
killed = true;
|
||||
ptyProcess.kill();
|
||||
terminal.dispose();
|
||||
}
|
||||
|
||||
// Small delay to let shell initialize
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
cwd,
|
||||
send,
|
||||
subscribe,
|
||||
getState,
|
||||
kill,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user