diff --git a/package-lock.json b/package-lock.json
index a175893..6cab135 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,8 @@
"version": "0.1.0",
"license": "MIT",
"dependencies": {
- "electron-squirrel-startup": "^1.0.1"
+ "electron-squirrel-startup": "^1.0.1",
+ "node-pty": "^1.0.0"
},
"devDependencies": {
"@codemirror/language-data": "^6.5.2",
@@ -8895,6 +8896,12 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
+ "node_modules/nan": {
+ "version": "2.23.1",
+ "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.1.tgz",
+ "integrity": "sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw==",
+ "license": "MIT"
+ },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -8989,6 +8996,16 @@
}
}
},
+ "node_modules/node-pty": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
+ "integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "nan": "^2.17.0"
+ }
+ },
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
diff --git a/package.json b/package.json
index 4b32522..fa4101d 100644
--- a/package.json
+++ b/package.json
@@ -53,6 +53,7 @@
"vite": "^7.2.2"
},
"dependencies": {
- "electron-squirrel-startup": "^1.0.1"
+ "electron-squirrel-startup": "^1.0.1",
+ "node-pty": "^1.0.0"
}
}
diff --git a/src/app/editorgrid.ts b/src/app/editorgrid.ts
index eb0c1b9..209fadb 100644
--- a/src/app/editorgrid.ts
+++ b/src/app/editorgrid.ts
@@ -94,7 +94,6 @@ vanX.list(EditorTabs, editors, EditorGrid);
document.addEventListener("keyup", (e) => {
if (e.key === "t" && e.altKey) {
- console.log("Opening terminal");
addTerminal();
}
});
diff --git a/src/app/terminal.ts b/src/app/terminal.ts
index 4da9f9a..6ce15ac 100644
--- a/src/app/terminal.ts
+++ b/src/app/terminal.ts
@@ -9,6 +9,11 @@ export class Terminal implements Displayable {
currentTitle: string = "Terminal";
del: () => void;
dom: HTMLElement;
+ private terminalId: string | null = null;
+ private fitAddon: FitAddon;
+ private resizeObserver: ResizeObserver;
+ private unsubTerminalData?: () => void;
+ private unsubTerminalExit?: () => void;
setDeleteFunction(del: () => void): void {
this.del = del;
@@ -19,28 +24,97 @@ export class Terminal implements Displayable {
}
constructor() {
- this.term = new xterm.Terminal();
+ this.term = new xterm.Terminal({
+ // cursorBlink: true,
+ // fontSize: 14,
+ // fontFamily: 'Menlo, Monaco, "Courier New", monospace',
+ });
- const fitAddon = new FitAddon();
- this.term.loadAddon(fitAddon);
+ this.fitAddon = new FitAddon();
+ this.term.loadAddon(this.fitAddon);
- this.dom = v.div({ class: "h-full w-full" });
+ this.dom = v.div({ class: "h-full w-lg resize-x overflow-x-hidden" });
const loaded = van.state(false);
+
van.derive(() => {
if (loaded.val) {
- this.term.open(this.dom);
- fitAddon.fit();
- this.term.writeln("Welcome to the terminal!");
+ this.initializeTerminal();
}
});
loaded.val = true;
}
+ private async initializeTerminal() {
+ this.term.open(this.dom);
+
+ // Create terminal in main process
+ try {
+ this.terminalId = await window.electronAPI.createTerminal();
+
+ // Set up data handling (subscribe/unsubscribe)
+ this.unsubTerminalData = window.electronAPI.onTerminalData(
+ this.terminalId,
+ (data) => this.term.write(data),
+ );
+
+ this.unsubTerminalExit = window.electronAPI.onTerminalExit(
+ this.terminalId,
+ (exitCode) =>
+ this.term.writeln(
+ `\r\n[Process exited with code ${exitCode}]`,
+ ),
+ );
+
+ // Handle user input
+ this.term.onData((data) => {
+ if (this.terminalId) {
+ window.electronAPI.writeToTerminal(this.terminalId, data);
+ }
+ });
+
+ // Set up resize handling
+ this.resizeObserver = new ResizeObserver(() => {
+ this.handleResize();
+ });
+ this.resizeObserver.observe(this.dom);
+
+ // Initial fit
+ setTimeout(() => {
+ this.handleResize();
+ }, 100);
+ } catch (error) {
+ console.error("Failed to initialize terminal:", error);
+ this.term.writeln(
+ "Failed to initialize terminal. Check console for details.",
+ );
+ }
+ }
+
+ private handleResize() {
+ if (
+ this.terminalId &&
+ this.dom.clientWidth > 0 &&
+ this.dom.clientHeight > 0
+ ) {
+ this.fitAddon.fit();
+ const { cols, rows } = this.term;
+ window.electronAPI.resizeTerminal(this.terminalId, cols, rows);
+ }
+ }
+
focus() {
this.term.focus();
}
close() {
+ if (this.terminalId) {
+ window.electronAPI.closeTerminal(this.terminalId);
+ }
+ if (this.resizeObserver) {
+ this.resizeObserver.disconnect();
+ }
+ if (this.unsubTerminalData) this.unsubTerminalData();
+ if (this.unsubTerminalExit) this.unsubTerminalExit();
this.term.dispose();
this.del();
}
diff --git a/src/main/forge-vite-env.d.ts b/src/main/forge-vite-env.d.ts
new file mode 100644
index 0000000..89410b3
--- /dev/null
+++ b/src/main/forge-vite-env.d.ts
@@ -0,0 +1,4 @@
+///
+
+declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string;
+declare const MAIN_WINDOW_VITE_NAME: string;
diff --git a/src/main/main.ts b/src/main/main.ts
index fc056e7..5b0384a 100644
--- a/src/main/main.ts
+++ b/src/main/main.ts
@@ -8,8 +8,10 @@ import {
getOpenedFiles,
showConfirmDialog,
} from "./fileOperations";
+import { terminalManager } from "./pty";
import path from "node:path";
import started from "electron-squirrel-startup";
+///
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (started) {
@@ -94,6 +96,32 @@ app.whenReady().then(() => {
},
);
+ // Terminal handlers
+ ipcMain.handle(
+ "terminal:create",
+ async (event, shell?: string, args?: string[]) => {
+ return terminalManager.createTerminal(event, shell, args);
+ },
+ );
+
+ ipcMain.handle(
+ "terminal:resize",
+ async (event, id: string, cols: number, rows: number) => {
+ return terminalManager.resizeTerminal(id, cols, rows);
+ },
+ );
+
+ ipcMain.handle(
+ "terminal:write",
+ async (event, id: string, data: string) => {
+ return terminalManager.writeToTerminal(id, data);
+ },
+ );
+
+ ipcMain.handle("terminal:close", async (event, id: string) => {
+ return terminalManager.closeTerminal(id);
+ });
+
createWindow();
if (process.platform === "darwin") {
app.on("activate", function () {
diff --git a/src/main/pty.ts b/src/main/pty.ts
new file mode 100644
index 0000000..02f27aa
--- /dev/null
+++ b/src/main/pty.ts
@@ -0,0 +1,78 @@
+import * as pty from "node-pty";
+
+export interface TerminalInstance {
+ ptyProcess: pty.IPty;
+}
+
+export class TerminalManager {
+ private terminals: Map = new Map();
+ private nextId: number = 1;
+
+ createTerminal(
+ event: Electron.IpcMainInvokeEvent,
+ shell?: string,
+ args?: string[],
+ ): string {
+ const id = `terminal-${this.nextId++}`;
+
+ // Default shell based on platform
+ const defaultShell =
+ process.platform === "win32" ? "powershell.exe" : "/bin/bash";
+ const shellToUse = shell || defaultShell;
+
+ const ptyProcess = pty.spawn(shellToUse, args || [], {
+ name: "xterm-color",
+ cols: 80,
+ rows: 24,
+ cwd: process.cwd(),
+ env: process.env,
+ });
+
+ const terminal: TerminalInstance = {
+ ptyProcess,
+ };
+
+ ptyProcess.onData((data) => {
+ console.log(`Terminal ${id} data:`, data);
+ event.sender.send("terminal:data", id, data);
+ });
+
+ ptyProcess.onExit(({ exitCode }) => {
+ event.sender.send("terminal:exit", id, exitCode);
+ this.terminals.delete(id);
+ });
+
+ this.terminals.set(id, terminal);
+ return id;
+ }
+
+ resizeTerminal(id: string, cols: number, rows: number): boolean {
+ const terminal = this.terminals.get(id);
+ if (terminal) {
+ terminal.ptyProcess.resize(cols, rows);
+ return true;
+ }
+ return false;
+ }
+
+ writeToTerminal(id: string, data: string): boolean {
+ const terminal = this.terminals.get(id);
+ if (terminal) {
+ terminal.ptyProcess.write(data);
+ return true;
+ }
+ return false;
+ }
+
+ closeTerminal(id: string): boolean {
+ const terminal = this.terminals.get(id);
+ if (terminal) {
+ terminal.ptyProcess.kill();
+ this.terminals.delete(id);
+ return true;
+ }
+ return false;
+ }
+}
+
+export const terminalManager = new TerminalManager();
diff --git a/src/preload.ts b/src/preload.ts
index bf4b3f4..56b24dd 100644
--- a/src/preload.ts
+++ b/src/preload.ts
@@ -4,6 +4,25 @@
import { contextBridge, ipcRenderer } from "electron";
import type { FolderTree } from "./types/global";
+// Centralized routing for terminal events: keep a single ipcRenderer listener
+// and forward events to subscribed callbacks. Each `onTerminal*` returns an
+// unsubscribe function so individual renderer components can remove only
+// their own listeners.
+const terminalDataCallbacks = new Map void>();
+const terminalExitCallbacks = new Map void>();
+
+ipcRenderer.on("terminal:data", (_ev, id: string, data: string) => {
+ const cb = terminalDataCallbacks.get(id);
+ if (cb) cb(data);
+ else console.warn(`No data callback for terminal ${id}`);
+});
+
+ipcRenderer.on("terminal:exit", (_ev, id: string, exitCode: number) => {
+ const cb = terminalExitCallbacks.get(id);
+ if (cb) cb(exitCode);
+ else console.warn(`No exit callback for terminal ${id}`);
+});
+
contextBridge.exposeInMainWorld("electronAPI", {
openFolder: () =>
ipcRenderer.invoke("dialog:openFolder") as Promise,
@@ -43,4 +62,33 @@ contextBridge.exposeInMainWorld("electronAPI", {
title,
buttons,
) as Promise,
+
+ // Terminal operations
+ createTerminal: (shell?: string, args?: string[]) =>
+ ipcRenderer.invoke("terminal:create", shell, args) as Promise,
+
+ resizeTerminal: (id: string, cols: number, rows: number) =>
+ ipcRenderer.invoke(
+ "terminal:resize",
+ id,
+ cols,
+ rows,
+ ) as Promise,
+
+ writeToTerminal: (id: string, data: string) =>
+ ipcRenderer.invoke("terminal:write", id, data) as Promise,
+
+ closeTerminal: (id: string) =>
+ ipcRenderer.invoke("terminal:close", id) as Promise,
+
+ // Terminal events (subscribe/unsubscribe)
+ onTerminalData: (id: string, callback: (data: string) => void) => {
+ terminalDataCallbacks.set(id, callback);
+ return () => terminalDataCallbacks.delete(id);
+ },
+
+ onTerminalExit: (id: string, callback: (exitCode: number) => void) => {
+ terminalExitCallbacks.set(id, callback);
+ return () => terminalExitCallbacks.delete(id);
+ },
});
diff --git a/src/types/global.d.ts b/src/types/global.d.ts
index 1e12101..6470c2c 100644
--- a/src/types/global.d.ts
+++ b/src/types/global.d.ts
@@ -34,6 +34,28 @@ declare global {
title: string,
buttons: string[],
) => Promise;
+
+ // Terminal operations
+ createTerminal: (
+ shell?: string,
+ args?: string[],
+ ) => Promise;
+ resizeTerminal: (
+ id: string,
+ cols: number,
+ rows: number,
+ ) => Promise;
+ writeToTerminal: (id: string, data: string) => Promise;
+ closeTerminal: (id: string) => Promise;
+ onTerminalData: (
+ id: string,
+ callback: (data: string) => void,
+ ) => () => void;
+ onTerminalExit: (
+ id: string,
+ callback: (exitCode: number) => void,
+ ) => () => void;
+ removeAllTerminalListeners: () => void;
};
}
}
diff --git a/vite.config.mts b/vite.config.mts
index a1d8c39..a162315 100644
--- a/vite.config.mts
+++ b/vite.config.mts
@@ -4,4 +4,12 @@ import tailwindcss from "@tailwindcss/vite";
// https://vitejs.dev/config
export default defineConfig({
plugins: [tailwindcss()],
+ optimizeDeps: {
+ exclude: ["node-pty"],
+ },
+ build: {
+ rollupOptions: {
+ external: ["node-pty"],
+ },
+ },
});