Skip to content

Add terminal + browser preview Electron app#6

Open
kertal wants to merge 2 commits into
mainfrom
claude/terminal-browser-preview-hdYvU
Open

Add terminal + browser preview Electron app#6
kertal wants to merge 2 commits into
mainfrom
claude/terminal-browser-preview-hdYvU

Conversation

@kertal

@kertal kertal commented Feb 20, 2026

Copy link
Copy Markdown
Owner

Chromium-based desktop application combining a real bash/zsh terminal
(via xterm.js + node-pty) with an integrated browser preview panel.
Features split-pane layout, tabbed browsing, URL navigation, DevTools
access, keyboard shortcuts, and a Catppuccin Mocha theme.

https://claude.ai/code/session_01JTLtTA9hANEmiUWwCoKNX3

Chromium-based desktop application combining a real bash/zsh terminal
(via xterm.js + node-pty) with an integrated browser preview panel.
Features split-pane layout, tabbed browsing, URL navigation, DevTools
access, keyboard shortcuts, and a Catppuccin Mocha theme.

https://claude.ai/code/session_01JTLtTA9hANEmiUWwCoKNX3
Redesigned architecture with 3 tab levels:
- Project tabs (top bar): independent workspaces, color-coded, renamable
- Terminal tabs (per project): each spawns its own PTY shell process
- Browser tabs (per project): each with its own Chromium webview

main.js now manages a PTY pool via ID-based IPC instead of a single
process. Each project maintains its own layout, divider position,
and focus state independently.

https://claude.ai/code/session_01JTLtTA9hANEmiUWwCoKNX3

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new Electron-based desktop application that combines terminal emulation with browser preview functionality in a split-pane interface. The application features a three-level tabbing system (projects > terminal tabs + browser tabs), using xterm.js for terminal rendering, node-pty for shell process management, and Electron's webview for browser functionality, all styled with the Catppuccin Mocha theme.

Changes:

  • Creates a complete Electron application with terminal emulation and browser preview capabilities
  • Implements multi-project workspace management with independent terminal and browser tab sets
  • Adds comprehensive keyboard shortcuts and layout management for split-pane views

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
package.json Defines dependencies for Electron, xterm.js, and node-pty
main.js Electron main process handling PTY process lifecycle and IPC communication
preload.js Context bridge exposing terminal API to renderer with security isolation
renderer.js Frontend logic managing three-level tab system and UI interactions
index.html UI structure and Catppuccin Mocha theme styling
README.md Documentation covering features, setup, keyboard shortcuts, and architecture

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +580 to +627
function setupDividerDrag(project, dom) {
let dragging = false;

dom.divider.addEventListener('mousedown', (e) => {
dragging = true;
dom.divider.classList.add('dragging');
const isVert = dom.workspace.classList.contains('layout-vertical');
document.body.style.cursor = isVert ? 'row-resize' : 'col-resize';
document.body.style.userSelect = 'none';
dom.webviewContainer.style.pointerEvents = 'none';
e.preventDefault();
});

document.addEventListener('mousemove', (e) => {
if (!dragging) return;
const rect = dom.workspace.getBoundingClientRect();

if (dom.workspace.classList.contains('layout-vertical')) {
const pct = ((e.clientY - rect.top) / rect.height) * 100;
const clamped = Math.max(15, Math.min(85, pct));
dom.termPanel.style.flex = 'none';
dom.termPanel.style.height = clamped + '%';
dom.browserPanel.style.flex = 'none';
dom.browserPanel.style.height = (100 - clamped) + '%';
} else {
const pct = ((e.clientX - rect.left) / rect.width) * 100;
const clamped = Math.max(15, Math.min(85, pct));
dom.termPanel.style.flex = 'none';
dom.termPanel.style.width = clamped + '%';
dom.browserPanel.style.flex = 'none';
dom.browserPanel.style.width = (100 - clamped) + '%';
}
});

document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
dom.divider.classList.remove('dragging');
document.body.style.cursor = '';
document.body.style.userSelect = '';
dom.webviewContainer.style.pointerEvents = '';
const tt = getActiveTerminalTab(project);
if (tt) {
tt.fitAddon.fit();
if (tt.alive) window.terminalAPI.resize(tt.ptyId, tt.term.cols, tt.term.rows);
}
});
}

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The divider drag event listeners are attached to the document but never cleaned up. If multiple projects are created and destroyed, these listeners will accumulate, leading to performance degradation and potential bugs where dragging affects the wrong project. Consider using AbortController or storing references to remove listeners when a project is closed.

Copilot uses AI. Check for mistakes.

const webview = document.createElement('webview');
webview.setAttribute('src', url);
webview.setAttribute('allowpopups', '');

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The webview is created with 'allowpopups' attribute without any additional security constraints. This allows any loaded page to open popup windows, which could be used for phishing attacks or unwanted advertising. Consider adding additional webview security attributes like 'disablewebsecurity=false' (explicitly), and implementing popup handling/blocking through webview events.

Copilot uses AI. Check for mistakes.
Comment on lines +536 to +539
if (url.startsWith('/') || url.startsWith('./') || url.startsWith('~')) {
url = 'file://' + url;
} else if (!url.match(/^[a-zA-Z]+:\/\//)) {
if (url.match(/^[\w.-]+\.\w{2,}/)) {

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The navigateBrowser function accepts arbitrary user input and constructs file:// URLs without validation or sanitization. A malicious user could potentially access sensitive files on the system. Consider implementing a whitelist of allowed file paths or validating that file paths are within safe directories before allowing file:// protocol navigation.

Suggested change
if (url.startsWith('/') || url.startsWith('./') || url.startsWith('~')) {
url = 'file://' + url;
} else if (!url.match(/^[a-zA-Z]+:\/\//)) {
if (url.match(/^[\w.-]+\.\w{2,}/)) {
const hasScheme = /^[a-zA-Z]+:\/\//.test(url);
const isDomainLike = /^[\w.-]+\.\w{2,}/.test(url);
// Do not construct file:// URLs from arbitrary user input to avoid exposing local files.
// Path-like inputs (starting with "/", "./", or "~") are treated as search queries instead.
if (!hasScheme && (url.startsWith('/') || url.startsWith('./') || url.startsWith('~'))) {
url = 'https://duckduckgo.com/?q=' + encodeURIComponent(url);
} else if (!hasScheme) {
if (isDomainLike) {

Copilot uses AI. Check for mistakes.
}

function renameProject(proj) {
const tabEl = projectTabList.querySelector(`.project-tab:nth-child(${projects.indexOf(proj) + 1}) .tab-title`);

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The querySelector uses a pseudo-selector based on indexOf which is fragile and will break if project ordering changes. If projects can be reordered or if the project is removed and re-added, this selector may target the wrong element. Consider adding a data attribute with the project ID to the tab element for more reliable selection.

Copilot uses AI. Check for mistakes.
const input = document.createElement('input');
input.type = 'text';
input.value = proj.name;
input.style.cssText = 'width:100px;background:var(--overlay);border:1px solid var(--accent);color:var(--text);font-size:12px;font-family:inherit;border-radius:3px;padding:0 4px;outline:none;';

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inline style string is overly complex and difficult to maintain. Consider defining this style in a CSS class in index.html or using a style object with proper key-value pairs. This would improve readability and make it easier to modify styles in the future.

Suggested change
input.style.cssText = 'width:100px;background:var(--overlay);border:1px solid var(--accent);color:var(--text);font-size:12px;font-family:inherit;border-radius:3px;padding:0 4px;outline:none;';
input.style.width = '100px';
input.style.background = 'var(--overlay)';
input.style.border = '1px solid var(--accent)';
input.style.color = 'var(--text)';
input.style.fontSize = '12px';
input.style.fontFamily = 'inherit';
input.style.borderRadius = '3px';
input.style.padding = '0 4px';
input.style.outline = 'none';

Copilot uses AI. Check for mistakes.
ipcMain.on('pty:resize', (_event, id, cols, rows) => {
const proc = ptyProcesses.get(id);
if (proc) {
try { proc.resize(cols, rows); } catch (_) {}

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The try-catch block silently swallows all errors when resizing the PTY. This can hide important issues like invalid dimensions or process failures. Consider logging the error to help with debugging, especially since terminal resize failures can cause display issues.

Copilot uses AI. Check for mistakes.
Comment on lines +33 to +99
// ── Create a new PTY process ──────────────────────────────────────────
ipcMain.handle('pty:create', (_event, opts = {}) => {
const id = nextPtyId++;
const shell = getShell();
const cwd = opts.cwd || process.env.HOME || os.homedir();

const proc = pty.spawn(shell, [], {
name: 'xterm-256color',
cols: opts.cols || 80,
rows: opts.rows || 24,
cwd,
env: {
...process.env,
TERM: 'xterm-256color',
COLORTERM: 'truecolor'
}
});

proc.onData((data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('pty:data', id, data);
}
});

proc.onExit(({ exitCode }) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('pty:exit', id, exitCode);
}
ptyProcesses.delete(id);
});

ptyProcesses.set(id, proc);

const shellName = path.basename(shell);
return { id, shell: shellName, pid: proc.pid };
});

// ── Write to a PTY ────────────────────────────────────────────────────
ipcMain.on('pty:write', (_event, id, data) => {
const proc = ptyProcesses.get(id);
if (proc) proc.write(data);
});

// ── Resize a PTY ──────────────────────────────────────────────────────
ipcMain.on('pty:resize', (_event, id, cols, rows) => {
const proc = ptyProcesses.get(id);
if (proc) {
try { proc.resize(cols, rows); } catch (_) {}
}
});

// ── Kill a PTY ────────────────────────────────────────────────────────
ipcMain.on('pty:kill', (_event, id) => {
const proc = ptyProcesses.get(id);
if (proc) {
proc.kill();
ptyProcesses.delete(id);
}
});

mainWindow.on('closed', () => {
mainWindow = null;
for (const proc of ptyProcesses.values()) proc.kill();
ptyProcesses.clear();
});
}

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The IPC handlers are registered inside createWindow(), which means they will be registered multiple times if the window is closed and reopened (via the activate event on macOS). This can lead to duplicate event handlers and unexpected behavior. Move the ipcMain handler registrations outside of createWindow() to ensure they're only registered once.

Suggested change
// ── Create a new PTY process ──────────────────────────────────────────
ipcMain.handle('pty:create', (_event, opts = {}) => {
const id = nextPtyId++;
const shell = getShell();
const cwd = opts.cwd || process.env.HOME || os.homedir();
const proc = pty.spawn(shell, [], {
name: 'xterm-256color',
cols: opts.cols || 80,
rows: opts.rows || 24,
cwd,
env: {
...process.env,
TERM: 'xterm-256color',
COLORTERM: 'truecolor'
}
});
proc.onData((data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('pty:data', id, data);
}
});
proc.onExit(({ exitCode }) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('pty:exit', id, exitCode);
}
ptyProcesses.delete(id);
});
ptyProcesses.set(id, proc);
const shellName = path.basename(shell);
return { id, shell: shellName, pid: proc.pid };
});
// ── Write to a PTY ────────────────────────────────────────────────────
ipcMain.on('pty:write', (_event, id, data) => {
const proc = ptyProcesses.get(id);
if (proc) proc.write(data);
});
// ── Resize a PTY ──────────────────────────────────────────────────────
ipcMain.on('pty:resize', (_event, id, cols, rows) => {
const proc = ptyProcesses.get(id);
if (proc) {
try { proc.resize(cols, rows); } catch (_) {}
}
});
// ── Kill a PTY ────────────────────────────────────────────────────────
ipcMain.on('pty:kill', (_event, id) => {
const proc = ptyProcesses.get(id);
if (proc) {
proc.kill();
ptyProcesses.delete(id);
}
});
mainWindow.on('closed', () => {
mainWindow = null;
for (const proc of ptyProcesses.values()) proc.kill();
ptyProcesses.clear();
});
}
mainWindow.on('closed', () => {
mainWindow = null;
for (const proc of ptyProcesses.values()) proc.kill();
ptyProcesses.clear();
});
}
// ── IPC handlers (registered once at the top level) ───────────────────
// Create a new PTY process
ipcMain.handle('pty:create', (_event, opts = {}) => {
const id = nextPtyId++;
const shell = getShell();
const cwd = opts.cwd || process.env.HOME || os.homedir();
const proc = pty.spawn(shell, [], {
name: 'xterm-256color',
cols: opts.cols || 80,
rows: opts.rows || 24,
cwd,
env: {
...process.env,
TERM: 'xterm-256color',
COLORTERM: 'truecolor'
}
});
proc.onData((data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('pty:data', id, data);
}
});
proc.onExit(({ exitCode }) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('pty:exit', id, exitCode);
}
ptyProcesses.delete(id);
});
ptyProcesses.set(id, proc);
const shellName = path.basename(shell);
return { id, shell: shellName, pid: proc.pid };
});
// Write to a PTY
ipcMain.on('pty:write', (_event, id, data) => {
const proc = ptyProcesses.get(id);
if (proc) proc.write(data);
});
// Resize a PTY
ipcMain.on('pty:resize', (_event, id, cols, rows) => {
const proc = ptyProcesses.get(id);
if (proc) {
try { proc.resize(cols, rows); } catch (_) {}
}
});
// Kill a PTY
ipcMain.on('pty:kill', (_event, id) => {
const proc = ptyProcesses.get(id);
if (proc) {
proc.kill();
ptyProcesses.delete(id);
}
});

Copilot uses AI. Check for mistakes.
Comment on lines +17 to +20
onData: (callback) => ipcRenderer.on('pty:data', (_event, id, data) => callback(id, data)),

// Listen for PTY exit (callback receives id, exitCode)
onExit: (callback) => ipcRenderer.on('pty:exit', (_event, id, code) => callback(id, code))

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The onData and onExit listeners don't provide cleanup methods to remove listeners. This can cause memory leaks if components are destroyed and recreated. Consider returning cleanup functions from these methods (e.g., return a function that calls ipcRenderer.removeListener) to allow proper cleanup.

Suggested change
onData: (callback) => ipcRenderer.on('pty:data', (_event, id, data) => callback(id, data)),
// Listen for PTY exit (callback receives id, exitCode)
onExit: (callback) => ipcRenderer.on('pty:exit', (_event, id, code) => callback(id, code))
onData: (callback) => {
const listener = (_event, id, data) => callback(id, data);
ipcRenderer.on('pty:data', listener);
// Return cleanup function to remove this listener
return () => ipcRenderer.removeListener('pty:data', listener);
},
// Listen for PTY exit (callback receives id, exitCode)
onExit: (callback) => {
const listener = (_event, id, code) => callback(id, code);
ipcRenderer.on('pty:exit', listener);
// Return cleanup function to remove this listener
return () => ipcRenderer.removeListener('pty:exit', listener);
}

Copilot uses AI. Check for mistakes.
Comment on lines +536 to +537
if (url.startsWith('/') || url.startsWith('./') || url.startsWith('~')) {
url = 'file://' + url;

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The URL navigation logic treats any input starting with '' as a file path and prepends 'file://', but '' is a shell alias for the home directory that isn't automatically expanded in URLs. This will result in broken file:// URLs. Consider expanding the tilde to the actual home directory path or removing this pattern from file path detection.

Suggested change
if (url.startsWith('/') || url.startsWith('./') || url.startsWith('~')) {
url = 'file://' + url;
if (url.startsWith('/') || url.startsWith('./')) {
url = 'file://' + url;
} else if (url.startsWith('~')) {
const homeDir = (typeof process !== 'undefined' && process.env)
? (process.env.HOME || process.env.USERPROFILE)
: null;
if (homeDir) {
url = 'file://' + homeDir + url.slice(1);
} else {
// Fallback to previous behavior if home directory cannot be determined
url = 'file://' + url;
}

Copilot uses AI. Check for mistakes.
if (url.startsWith('/') || url.startsWith('./') || url.startsWith('~')) {
url = 'file://' + url;
} else if (!url.match(/^[a-zA-Z]+:\/\//)) {
if (url.match(/^[\w.-]+\.\w{2,}/)) {

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex pattern /^[\w.-]+.\w{2,}/ is too simplistic for domain detection and will produce false positives. For example, it would match "123.456" or "file.mp3" and incorrectly prepend "https://". Consider using a more robust domain validation pattern that checks for valid TLDs or at least requires alphabetic characters in the TLD portion.

Suggested change
if (url.match(/^[\w.-]+\.\w{2,}/)) {
if (url.match(/^(?!-)(?:[A-Za-z0-9-]{1,63}\.)+[A-Za-z]{2,}(?:\/|$)/)) {

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants