feat: add Tauri desktop app support and terminal management

This commit is contained in:
Mohamed Boudra
2026-01-20 10:09:35 +07:00
parent 12f617dd10
commit d9c487dd54
125 changed files with 8906 additions and 1049 deletions
@@ -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" });
});
});
});
+276
View File
@@ -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,
};
}