Skip to content

Terminal tab (xterm + socket.io PTY)

Source: atrium/frontend/src/components/TerminalTab.jsx Category: Composite component

Terminal tab — embedded pseudo-terminal using xterm.js + node-pty on the backend, wired through socket.io. One tab inside the task modal opens a shell scoped to the working directory configured in Atrium; from there the user can launch an agent session (e.g. opencode "review task ...") and watch the output stream in real time.

Frontend: xterm.js renders a fully-featured terminal emulator into a <div>. Each keystroke is sent to the server; each chunk of output is written to the terminal. Backend: node-pty spawns a real shell process, the socket pipes data between the frontend and the pty.

Clicking a task → “open terminal in this project’s directory” shortens the distance from browsing to doing. Agent output visible alongside the task description closes the feedback loop.

The render below is a static mock of what the tab looks like mid-agent-session — in the real app it’s a live xterm instance.

bug-port-009 · xterm · live
$ opencode "Please review task bug-port-009: Admin project link field rejects scheme-less URLs. Check the Atrium task markdown file for details."
 
✻ Thinking...
 
I'll look at the admin project link field bug.
 
Read ts/src/web-server.ts (admin routes, lines 1325-1375)
Read ts/src/web-server.ts (public renderer, lines 590-650)
 
Root cause found: input type="url" rejects scheme-less format, renderer prepends https://
to whatever's stored. Double-scheme results when full URLs are entered.
 
Proposed fix: normalize on save (strip http(s):// prefix), change input to type="text".
 
Edit ts/src/web-server.ts:1333 (input type)
Edit ts/src/web-server.ts:1341 (normalizeLink helper)
 
$
const pty = require('node-pty');
const os = require('os');
io.on('connection', socket => {
socket.on('start_terminal', ({ cwd, cols, rows }) => {
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
const term = pty.spawn(shell, [], { name: 'xterm-color', cols, rows, cwd });
term.onData(data => socket.emit('terminal_data', data));
socket.on('terminal_input', input => term.write(input));
socket.on('terminal_resize', ({ cols, rows }) => term.resize(cols, rows));
socket.on('disconnect', () => term.kill());
});
});

Frontend sketch:

import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
useEffect(() => {
const term = new Terminal();
const fit = new FitAddon();
term.loadAddon(fit);
term.open(containerRef.current);
fit.fit();
socket.emit('start_terminal', { cwd: task.workingDirectory, cols: term.cols, rows: term.rows });
const handleData = (d) => term.write(d);
socket.on('terminal_data', handleData);
term.onData(input => socket.emit('terminal_input', input));
return () => { term.dispose(); socket.off('terminal_data', handleData); };
}, [task.workingDirectory]);
  • Atrium — tab inside TaskModal; each open terminal runs in the project’s working directory as configured in settings
  • Pattern generalizes to any dev tool that wants inline shell access (CI-log tailing, deploy tooling, admin consoles)
  • PTY is heavy. Each open terminal is a real shell process on the server. Cap the number of concurrent terminals per user and kill on disconnect.
  • node-pty has platform differences. Windows spawns conpty or winpty; Unix spawns a real PTY. Command syntax, line endings, color capability all vary. Test both.
  • Resize events matter. Without terminal_resize, shell output wraps based on the original dimensions and looks broken when the user changes window size.
  • Security: a terminal is code execution. Require auth, restrict cwd to approved paths, never expose over an unauthenticated endpoint.
  • Output volume can flood the socket. yes | head -c 10MB emits enough data to freeze the browser. Throttle or drop frames if the buffer grows beyond a threshold.
  • Credential leakage. If the shell environment contains secrets (API keys, tokens), any user with terminal access sees them. Scrub env or use a dedicated service account for the spawned shell.
  • Scrollback isn’t free. xterm’s default scrollback is 1000 lines. For long agent sessions, bump to ~10000 — but that costs memory.