zerogterm 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stephen Phillips
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,69 @@
1
+ # ZeroG Terminal
2
+
3
+ ZeroG Terminal is a Linux-first Electron workspace for persistent `screen` sessions. This first vertical slice includes:
4
+
5
+ - Electron security boundary: sandboxed renderer, context isolation, disabled Node integration, narrow typed preload API.
6
+ - Session sidebar with create, discover, refresh, and attach actions.
7
+ - Visible actions for opening a persistent local terminal and connecting to an SSH target (`host`, `user@host`, or `user@host:port`).
8
+ - xterm.js terminal renderer and a split workspace surface.
9
+ - Persistent dark/light theme with an accessible sun/moon toggle.
10
+ - Safe session-name validation and argument-array `screen` invocation.
11
+ - AI command approval surface; suggestions are visible and require explicit approval before being sent to the terminal.
12
+
13
+ ## Release status
14
+
15
+ ZeroG Terminal is currently a public alpha. The initial npm release is `0.1.0-alpha.1` and is configured for the `alpha` dist-tag. The version history is tracked in [versions.txt](versions.txt).
16
+
17
+ The npm package contains the built Electron application and project documentation. It is intended for early adopters and testing rather than production use.
18
+
19
+ ## Terminal shortcuts
20
+
21
+ - `Ctrl+Shift+C` — copy selected terminal text
22
+ - `Ctrl+Shift+V` — paste into the active terminal
23
+ - `Ctrl+C` remains the interrupt signal (not copy)
24
+ - `Ctrl+Shift+N` — new workspace
25
+ - `Ctrl+Shift+T` — new local terminal in the current workspace
26
+ - `Ctrl+Shift+O` — session overview
27
+ - `Ctrl+Shift+B` — toggle sessions sidebar
28
+ - `Esc` — close overview / dialogs
29
+
30
+ ## Development
31
+
32
+ ```bash
33
+ npm install
34
+ npm run typecheck
35
+ npm test
36
+ npm run build
37
+ npm start
38
+ ```
39
+
40
+ Runtime prerequisites on the host:
41
+
42
+ ```bash
43
+ sudo dnf install screen make gcc-c++ python3
44
+ npm install node-pty
45
+ ```
46
+
47
+ `node-pty` is required for terminal I/O. When `screen` is installed, local sessions are persistent and discoverable after relaunch. Without `screen`, ZeroG now falls back to a direct `bash` PTY and labels the session as process-only; that shell is lost when the application exits and the dependency warning remains visible. Install `screen` for full persistence:
48
+
49
+ ```bash
50
+ sudo dnf install screen
51
+ ```
52
+
53
+ ## Current verification
54
+
55
+ - `npm run typecheck`: passes.
56
+ - `npm test`: passes (3 tests covering name validation, `screen -ls` parsing, and SSH argument validation).
57
+ - `npm run build`: passes and writes `dist/main` plus `dist/renderer`.
58
+ - Electron is configured to disable hardware acceleration by default for the Fedora Toolbox/Wayland runtime; set `ZEROG_ENABLE_GPU=1` only when GPU launch is stable on the host.
59
+ - `npm audit --omit=dev`: reports no known production vulnerabilities.
60
+
61
+ The live screen + node-pty smoke test creates a temporary named session, writes a marker through the PTY, observes it, and cleans up the session.
62
+
63
+ ## License
64
+
65
+ ZeroG Terminal is released under the MIT License. See [LICENSE](LICENSE).
66
+
67
+ ## Contributing and security
68
+
69
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development, testing, and pull-request guidance. Please report security vulnerabilities privately through GitHub; see [SECURITY.md](SECURITY.md).
@@ -0,0 +1,107 @@
1
+ import { app, BrowserWindow, clipboard, ipcMain, Menu } from 'electron';
2
+ import { join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { ScreenService } from './session-service.js';
5
+ const __dirname = fileURLToPath(new URL('.', import.meta.url));
6
+ const service = new ScreenService();
7
+ let win;
8
+ // GPU is unstable under Toolbox/Wayland on this host; allow override.
9
+ if (process.env.ZEROG_ENABLE_GPU !== '1') {
10
+ app.disableHardwareAcceleration();
11
+ }
12
+ function createWindow() {
13
+ win = new BrowserWindow({
14
+ width: 1440,
15
+ height: 900,
16
+ minWidth: 900,
17
+ minHeight: 560,
18
+ backgroundColor: '#0B0F14',
19
+ // Do not gate visibility on ready-to-show: that event is unreliable with
20
+ // Electron sandbox + Toolbox/Wayland and otherwise leaves a live hidden app.
21
+ show: true,
22
+ webPreferences: {
23
+ // CommonJS preload is required; ESM preload fails under sandbox.
24
+ preload: join(__dirname, 'preload.cjs'),
25
+ contextIsolation: true,
26
+ nodeIntegration: false,
27
+ sandbox: true
28
+ }
29
+ });
30
+ win.webContents.on('did-fail-load', (_event, code, desc, url) => {
31
+ console.error('[zerog] did-fail-load', { code, desc, url });
32
+ });
33
+ win.webContents.on('preload-error', (_event, path, error) => {
34
+ console.error('[zerog] preload-error', path, error);
35
+ });
36
+ win.webContents.on('console-message', (_event, _level, message) => {
37
+ console.log('[renderer]', message);
38
+ });
39
+ const devUrl = process.env.VITE_DEV_SERVER_URL;
40
+ if (devUrl) {
41
+ void win.loadURL(devUrl);
42
+ }
43
+ else {
44
+ const indexPath = join(__dirname, '../../renderer/index.html');
45
+ void win.loadFile(indexPath);
46
+ }
47
+ win.on('closed', () => {
48
+ win = undefined;
49
+ service.detachAll();
50
+ });
51
+ }
52
+ function isRecord(value) {
53
+ return typeof value === 'object' && value !== null;
54
+ }
55
+ ipcMain.handle('sessions:list', () => service.list());
56
+ ipcMain.handle('sessions:createLocal', async (_event, request) => {
57
+ if (!isRecord(request) || typeof request.name !== 'string') {
58
+ throw new Error('createLocalSession requires { name: string }');
59
+ }
60
+ const cwd = typeof request.cwd === 'string' ? request.cwd : undefined;
61
+ return service.createLocal(request.name, cwd);
62
+ });
63
+ ipcMain.handle('sessions:createSsh', async (_event, request) => {
64
+ if (!isRecord(request) || typeof request.target !== 'string') {
65
+ throw new Error('createSshSession requires { target: string }');
66
+ }
67
+ const name = typeof request.name === 'string' ? request.name : undefined;
68
+ return service.createSsh(request.target, name);
69
+ });
70
+ ipcMain.handle('sessions:attach', (_event, id) => {
71
+ if (typeof id !== 'string' || !id)
72
+ throw new Error('attachSession requires a session id');
73
+ return service.attach(id, (data) => win?.webContents.send('terminal:data', id, data), (message) => win?.webContents.send('terminal:status', id, message));
74
+ });
75
+ ipcMain.on('terminal:write', (_event, sessionId, data) => {
76
+ if (typeof sessionId === 'string' && typeof data === 'string')
77
+ service.write(sessionId, data);
78
+ });
79
+ ipcMain.on('terminal:resize', (_event, sessionId, cols, rows) => {
80
+ if (typeof sessionId === 'string' && Number.isInteger(cols) && Number.isInteger(rows)) {
81
+ service.resize(sessionId, cols, rows);
82
+ }
83
+ });
84
+ ipcMain.handle('clipboard:writeText', (_event, text) => {
85
+ if (typeof text !== 'string')
86
+ throw new Error('clipboard:writeText requires a string');
87
+ clipboard.writeText(text);
88
+ });
89
+ ipcMain.handle('clipboard:readText', () => clipboard.readText());
90
+ ipcMain.handle('ai:suggest', () => ({
91
+ command: 'git status --short',
92
+ explanation: 'Read-only preview of changed files in the active workspace.'
93
+ }));
94
+ app.whenReady().then(() => {
95
+ // Keep the normal window chrome, but let the ZeroG UI occupy the full
96
+ // client area instead of showing Electron's default File/Edit/etc. menu.
97
+ Menu.setApplicationMenu(null);
98
+ createWindow();
99
+ app.on('activate', () => {
100
+ if (!BrowserWindow.getAllWindows().length)
101
+ createWindow();
102
+ });
103
+ });
104
+ app.on('window-all-closed', () => {
105
+ if (process.platform !== 'darwin')
106
+ app.quit();
107
+ });
@@ -0,0 +1,25 @@
1
+ const { contextBridge, ipcRenderer } = require('electron');
2
+
3
+ const api = {
4
+ listSessions: () => ipcRenderer.invoke('sessions:list'),
5
+ createLocalSession: (request) => ipcRenderer.invoke('sessions:createLocal', request),
6
+ createSshSession: (request) => ipcRenderer.invoke('sessions:createSsh', request),
7
+ attachSession: (id) => ipcRenderer.invoke('sessions:attach', id),
8
+ write: (sessionId, data) => ipcRenderer.send('terminal:write', sessionId, data),
9
+ resize: (sessionId, cols, rows) => ipcRenderer.send('terminal:resize', sessionId, cols, rows),
10
+ copyText: (text) => ipcRenderer.invoke('clipboard:writeText', text),
11
+ readText: () => ipcRenderer.invoke('clipboard:readText'),
12
+ onData: (callback) => {
13
+ const listener = (_event, sessionId, data) => callback(sessionId, data);
14
+ ipcRenderer.on('terminal:data', listener);
15
+ return () => ipcRenderer.removeListener('terminal:data', listener);
16
+ },
17
+ onStatus: (callback) => {
18
+ const listener = (_event, sessionId, message) => callback(sessionId, message);
19
+ ipcRenderer.on('terminal:status', listener);
20
+ return () => ipcRenderer.removeListener('terminal:status', listener);
21
+ },
22
+ requestAiCommand: () => ipcRenderer.invoke('ai:suggest')
23
+ };
24
+
25
+ contextBridge.exposeInMainWorld('zerog', api);
@@ -0,0 +1,12 @@
1
+ import { contextBridge, ipcRenderer } from 'electron';
2
+ const api = {
3
+ listSessions: () => ipcRenderer.invoke('sessions:list'),
4
+ createSession: (name, cwd) => ipcRenderer.invoke('sessions:create', name, cwd),
5
+ attachSession: (id) => ipcRenderer.invoke('sessions:attach', id),
6
+ write: (data) => ipcRenderer.send('terminal:write', data),
7
+ resize: (cols, rows) => ipcRenderer.send('terminal:resize', cols, rows),
8
+ onData: (callback) => { const listener = (_event, data) => callback(data); ipcRenderer.on('terminal:data', listener); return () => ipcRenderer.removeListener('terminal:data', listener); },
9
+ onStatus: (callback) => { const listener = (_event, message) => callback(message); ipcRenderer.on('terminal:status', listener); return () => ipcRenderer.removeListener('terminal:status', listener); },
10
+ requestAiCommand: () => ipcRenderer.invoke('ai:suggest')
11
+ };
12
+ contextBridge.exposeInMainWorld('zerog', api);
@@ -0,0 +1,256 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { createRequire } from 'node:module';
4
+ import { homedir } from 'node:os';
5
+ import { promisify } from 'node:util';
6
+ const execFileAsync = promisify(execFile);
7
+ const require = createRequire(import.meta.url);
8
+ const NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,48}$/;
9
+ /** host, user@host, host:port, user@host:port — no shell metacharacters. */
10
+ const SSH_TARGET = /^(?:([A-Za-z0-9._-]+)@)?([A-Za-z0-9.-]+)(?::(\d{1,5}))?$/;
11
+ export function validateSessionName(name) {
12
+ const value = name.trim();
13
+ if (!NAME.test(value)) {
14
+ throw new Error('Session names may contain letters, numbers, _, ., and - only.');
15
+ }
16
+ return value;
17
+ }
18
+ export function validateSshTarget(input) {
19
+ const value = input.trim();
20
+ const match = value.match(SSH_TARGET);
21
+ if (!match) {
22
+ throw new Error('SSH target must look like host, user@host, or user@host:port.');
23
+ }
24
+ const [, user, host, portText] = match;
25
+ if (portText) {
26
+ const port = Number(portText);
27
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
28
+ throw new Error('SSH port must be between 1 and 65535.');
29
+ }
30
+ }
31
+ const destination = user ? `${user}@${host}` : host;
32
+ const args = ['-tt'];
33
+ if (portText)
34
+ args.push('-p', portText);
35
+ args.push(destination);
36
+ return { target: value, args };
37
+ }
38
+ export function parseScreenList(output) {
39
+ return output
40
+ .split('\n')
41
+ .map((line) => line.trim())
42
+ .flatMap((line) => {
43
+ const match = line.match(/^(\d+)\.(\S+)\s+\(([^)]+)\)/);
44
+ if (!match)
45
+ return [];
46
+ const [, , name, state] = match;
47
+ const attached = /attached/i.test(state);
48
+ return [
49
+ {
50
+ id: `local:${name}`,
51
+ name,
52
+ kind: 'local',
53
+ host: 'local',
54
+ cwd: homedir(),
55
+ status: attached ? 'connected' : 'detached',
56
+ lastSeen: new Date().toISOString(),
57
+ persistence: 'screen'
58
+ }
59
+ ];
60
+ });
61
+ }
62
+ function loadPty() {
63
+ try {
64
+ return require('node-pty');
65
+ }
66
+ catch {
67
+ throw new Error('PTY support is not installed. Run: npm install node-pty (needs make/gcc-c++).');
68
+ }
69
+ }
70
+ export class ScreenService {
71
+ ptys = new Map();
72
+ sshSessions = new Map();
73
+ fallbackLocalSessions = new Map();
74
+ async available() {
75
+ try {
76
+ await execFileAsync('screen', ['--version']);
77
+ return true;
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ }
83
+ async list() {
84
+ const local = (await this.available()) ? await this.listLocal() : [];
85
+ const fallback = [...this.fallbackLocalSessions.values()];
86
+ const ssh = [...this.sshSessions.values()];
87
+ return [
88
+ ...local.map((session) => ({
89
+ ...session,
90
+ status: this.ptys.has(session.id) ? 'connected' : session.status
91
+ })),
92
+ ...fallback.map((session) => ({
93
+ ...session,
94
+ status: this.ptys.has(session.id) ? 'connected' : session.status
95
+ })),
96
+ ...ssh.map((session) => ({
97
+ ...session,
98
+ status: this.ptys.has(session.id) ? 'connected' : session.status
99
+ }))
100
+ ];
101
+ }
102
+ async listLocal() {
103
+ try {
104
+ const { stdout } = await execFileAsync('screen', ['-ls']);
105
+ return parseScreenList(stdout);
106
+ }
107
+ catch (error) {
108
+ if (typeof error?.stdout === 'string' && error.stdout.trim()) {
109
+ return parseScreenList(error.stdout);
110
+ }
111
+ return [];
112
+ }
113
+ }
114
+ async createLocal(name, cwd = homedir()) {
115
+ const safeName = validateSessionName(name);
116
+ if (!(await this.available())) {
117
+ const fallback = {
118
+ id: `local:${safeName}`,
119
+ name: safeName,
120
+ kind: 'local',
121
+ host: 'local',
122
+ cwd,
123
+ status: 'detached',
124
+ lastSeen: new Date().toISOString(),
125
+ persistence: 'process'
126
+ };
127
+ this.fallbackLocalSessions.set(fallback.id, fallback);
128
+ return fallback;
129
+ }
130
+ await execFileAsync('screen', ['-dmS', safeName, 'bash'], { cwd });
131
+ return {
132
+ id: `local:${safeName}`,
133
+ name: safeName,
134
+ kind: 'local',
135
+ host: 'local',
136
+ cwd,
137
+ status: 'detached',
138
+ lastSeen: new Date().toISOString(),
139
+ persistence: 'screen'
140
+ };
141
+ }
142
+ async createSsh(targetInput, name) {
143
+ const { target } = validateSshTarget(targetInput);
144
+ const safeName = validateSessionName(name?.trim() || target.replace(/[^a-zA-Z0-9_.-]+/g, '-').slice(0, 40));
145
+ const session = {
146
+ id: `ssh:${randomUUID()}`,
147
+ name: safeName,
148
+ kind: 'ssh',
149
+ host: target,
150
+ cwd: '~',
151
+ status: 'detached',
152
+ lastSeen: new Date().toISOString(),
153
+ sshTarget: target
154
+ };
155
+ this.sshSessions.set(session.id, session);
156
+ return session;
157
+ }
158
+ attach(id, onData, onExit) {
159
+ const existing = this.getSession(id);
160
+ if (this.ptys.has(id))
161
+ return { ...existing, status: 'connected' };
162
+ if (id.startsWith('local:')) {
163
+ const fallback = this.fallbackLocalSessions.get(id);
164
+ if (fallback) {
165
+ fallback.status = 'connected';
166
+ fallback.lastSeen = new Date().toISOString();
167
+ this.spawnCommand(id, 'bash', [], onData, onExit, fallback.cwd);
168
+ return { ...fallback };
169
+ }
170
+ const name = id.slice('local:'.length);
171
+ this.spawnCommand(id, 'screen', ['-x', name], onData, onExit);
172
+ return { ...existing, status: 'connected', persistence: 'screen' };
173
+ }
174
+ const session = this.sshSessions.get(id);
175
+ if (!session?.sshTarget) {
176
+ throw new Error(`Unknown session: ${id}`);
177
+ }
178
+ const { args } = validateSshTarget(session.sshTarget);
179
+ session.status = 'connected';
180
+ session.lastSeen = new Date().toISOString();
181
+ this.spawnCommand(id, 'ssh', args, onData, onExit);
182
+ return { ...session };
183
+ }
184
+ getSession(id) {
185
+ if (id.startsWith('local:')) {
186
+ const fallback = this.fallbackLocalSessions.get(id);
187
+ if (fallback)
188
+ return fallback;
189
+ const name = id.slice('local:'.length);
190
+ return {
191
+ id,
192
+ name,
193
+ kind: 'local',
194
+ host: 'local',
195
+ cwd: homedir(),
196
+ status: 'detached',
197
+ lastSeen: new Date().toISOString(),
198
+ persistence: 'screen'
199
+ };
200
+ }
201
+ const session = this.sshSessions.get(id);
202
+ if (!session)
203
+ throw new Error(`Unknown session: ${id}`);
204
+ return session;
205
+ }
206
+ spawnCommand(sessionId, file, args, onData, onExit, cwd = homedir()) {
207
+ const pty = loadPty();
208
+ const proc = pty.spawn(file, args, {
209
+ name: 'xterm-256color',
210
+ cols: 120,
211
+ rows: 32,
212
+ cwd,
213
+ env: process.env
214
+ });
215
+ const handle = {
216
+ write: (data) => proc.write(data),
217
+ resize: (cols, rows) => proc.resize(cols, rows),
218
+ kill: () => {
219
+ try {
220
+ proc.kill();
221
+ }
222
+ catch {
223
+ /* ignore */
224
+ }
225
+ }
226
+ };
227
+ this.ptys.set(sessionId, { handle, kind: sessionId.startsWith('ssh:') ? 'ssh' : 'local' });
228
+ proc.onData(onData);
229
+ proc.onExit(() => {
230
+ this.ptys.delete(sessionId);
231
+ const fallback = this.fallbackLocalSessions.get(sessionId);
232
+ if (fallback)
233
+ fallback.status = 'detached';
234
+ const ssh = this.sshSessions.get(sessionId);
235
+ if (ssh)
236
+ ssh.status = 'detached';
237
+ onExit(fallback
238
+ ? 'Terminal detached. This process-only local session will not survive app exit; install screen for persistence.'
239
+ : 'Terminal detached. Local screen sessions remain alive; SSH sessions end with the connection.');
240
+ });
241
+ }
242
+ write(sessionId, data) {
243
+ this.ptys.get(sessionId)?.handle.write(data);
244
+ }
245
+ resize(sessionId, cols, rows) {
246
+ this.ptys.get(sessionId)?.handle.resize(cols, rows);
247
+ }
248
+ detach(sessionId) {
249
+ this.ptys.get(sessionId)?.handle.kill();
250
+ this.ptys.delete(sessionId);
251
+ }
252
+ detachAll() {
253
+ for (const sessionId of this.ptys.keys())
254
+ this.detach(sessionId);
255
+ }
256
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ .xterm{cursor:text;position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}.xterm .composition-view{background:#000;color:#fff;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{background-color:#000;overflow-y:scroll;cursor:default;position:absolute;inset:0}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;left:0;top:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal}.xterm.enable-mouse-events{cursor:default}.xterm.xterm-cursor-pointer,.xterm .xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{position:absolute;inset:0;z-index:10;color:transparent;pointer-events:none}.xterm .xterm-accessibility-tree:not(.debug) *::selection{color:transparent}.xterm .xterm-accessibility-tree{-webkit-user-select:text;user-select:text;white-space:pre}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{text-decoration:double underline}.xterm-underline-3{text-decoration:wavy underline}.xterm-underline-4{text-decoration:dotted underline}.xterm-underline-5{text-decoration:dashed underline}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:overline underline}.xterm-overline.xterm-underline-2{text-decoration:overline double underline}.xterm-overline.xterm-underline-3{text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;position:absolute;top:0;right:0;pointer-events:none}.xterm-decoration-top{z-index:2;position:relative}:root{font-family:Inter,ui-sans-serif,system-ui,sans-serif;color:#d8dee9;background:#0a0c10;font-size:13px;line-height:1.4;color-scheme:dark}*{box-sizing:border-box}body{margin:0;min-width:980px;overflow:hidden;background:#0a0c10}button,input{font:inherit}button{color:inherit;border:0;cursor:pointer}button:focus-visible,input:focus-visible{outline:1px solid #7aa2f7;outline-offset:2px}.app-shell{height:100vh;background:#0a0c10}.window-bar{height:48px;display:flex;align-items:center;border-bottom:1px solid #202630;background:#0f1218;padding:0 12px;gap:14px}.window-brand{width:120px;display:flex;align-items:center;gap:9px;flex:none}.brand-mark{display:grid;place-items:center;width:25px;height:25px;border:1px solid #3b82f6;border-radius:5px;color:#7aa2f7;font:700 9px JetBrains Mono,monospace}.brand-name{font-size:13px;font-weight:650;letter-spacing:.01em}.workspace-tabs{display:flex;align-items:center;gap:6px;min-width:0;overflow:auto}.workspace-tab{height:32px;display:flex;align-items:center;gap:8px;padding:0 12px;border:1px solid transparent;border-radius:5px;color:#8b93a3;background:transparent;white-space:nowrap}.workspace-tab.active{background:#151a22;border-color:#252d39;color:#d8dee9}.tab-dot,.connection-dot,.pane-live{width:6px;height:6px;border-radius:50%;background:#9ece6a;display:inline-block;flex:none}.tab-meta{font:11px JetBrains Mono,monospace;color:#697386;margin-left:2px}.window-actions{margin-left:auto;display:flex;align-items:center;gap:4px;flex:none}.bar-button,.avatar{height:30px;display:flex;align-items:center;gap:7px;padding:0 9px;border-radius:4px;color:#8b93a3;background:transparent}.bar-button:hover{background:#1a202a;color:#d8dee9}.icon{font-size:14px;line-height:1;color:currentColor}.icon-svg{width:14px;height:14px;display:block;flex:none}.square-button .icon-svg,.rail-button .icon-svg,.layout-button .icon-svg,.inline-restore .icon-svg{width:15px;height:15px}.avatar{width:28px;justify-content:center;border:1px solid #2a3340;color:#9ece6a;padding:0;font-size:11px;margin-left:6px}.body-shell{height:calc(100vh - 48px);display:flex}.rail{width:48px;display:flex;flex-direction:column;align-items:center;gap:5px;padding:10px 0;border-right:1px solid #202630;background:#0d1015;flex:none}.rail-button{width:32px;height:32px;display:grid;place-items:center;border-radius:5px;color:#697386;background:transparent}.rail-button:hover,.rail-button.active{background:#1a202a;color:#d8dee9}.rail-button.active{box-shadow:inset 2px 0 #7aa2f7}.rail-spacer{flex:1}.session-drawer{width:238px;display:flex;flex-direction:column;border-right:1px solid #202630;background:#0f1218;flex:none}.drawer-head{display:flex;align-items:center;justify-content:space-between;padding:18px 14px 12px;gap:8px}.drawer-head-actions{display:flex;align-items:center;gap:6px}.eyebrow{display:block;color:#697386;font-size:10px;font-weight:700;letter-spacing:.1em}.drawer-head h1{margin:3px 0 0;font-size:16px;font-weight:600;letter-spacing:-.01em}.square-button{width:27px;height:27px;display:grid;place-items:center;border:1px solid #2a3340;border-radius:4px;background:#151a22;color:#9aa4b2}.square-button:hover{border-color:#7aa2f7;color:#d8dee9}.drawer-tools{display:flex;gap:2px;padding:0 10px 10px;border-bottom:1px solid #202630}.drawer-tool{padding:5px 7px;border-radius:4px;color:#697386;background:transparent;font-size:11px}.drawer-tool span{margin-left:5px;color:#4e5969}.drawer-tool:hover,.drawer-tool.active{background:#1a202a;color:#d8dee9}.session-list{padding:8px 7px;overflow:auto;flex:1}.session-row{width:100%;display:flex;align-items:center;gap:9px;padding:9px 8px;text-align:left;border-radius:5px;background:transparent;color:#8b93a3}.session-row:hover{background:#151a22}.session-row.active{background:#1c2531;color:#d8dee9;box-shadow:inset 2px 0 #7aa2f7}.status-dot{width:6px;height:6px;border-radius:50%;background:#4e5969;flex:none}.status-dot.connected{background:#9ece6a}.status-dot.detached{background:#e0af68}.status-dot.error{background:#f7768e}.session-copy{min-width:0;display:flex;flex-direction:column;gap:2px;flex:1}.session-copy b{font-size:12px;font-weight:550;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.session-copy small{font:10px JetBrains Mono,monospace;color:#697386;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.session-state{font:11px JetBrains Mono,monospace;color:#697386}.session-empty{display:flex;flex-direction:column;gap:6px;padding:18px 10px;color:#697386}.session-empty b{color:#8b93a3;font-size:12px;font-weight:550}.session-empty small{font-size:11px;line-height:1.45}.drawer-bottom{margin-top:auto;padding:10px 9px;border-top:1px solid #202630}.drawer-action{width:100%;display:flex;align-items:center;gap:8px;padding:7px 5px;color:#8b93a3;background:transparent;text-align:left;border-radius:4px;font-size:11px}.drawer-action:hover{background:#151a22;color:#d8dee9}.drawer-action kbd{margin-left:auto;color:#4e5969;font:9px JetBrains Mono,monospace}.drawer-status{display:flex;align-items:center;gap:8px;margin-top:13px;padding:5px;color:#697386;font:10px JetBrains Mono,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.workspace{display:flex;flex:1;flex-direction:column;min-width:0;background:#0a0c10}.workspace-head{height:42px;display:flex;align-items:center;justify-content:space-between;padding:0 14px;border-bottom:1px solid #202630;background:#0d1015}.location{display:flex;align-items:center;gap:8px;color:#8b93a3;font:11px JetBrains Mono,monospace;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.inline-restore{width:24px;height:24px;display:grid;place-items:center;border-radius:4px;color:#8b93a3;background:#151a22;border:1px solid #2a3340}.inline-restore:hover{color:#d8dee9;border-color:#7aa2f7}.slash{color:#3d4654}.muted-text{color:#697386}.layout-controls{display:flex;align-items:center;gap:2px}.control-label{font-size:9px;color:#4e5969;letter-spacing:.08em;margin-right:6px}.layout-button{width:26px;height:25px;display:grid;place-items:center;border-radius:4px;background:transparent;color:#697386}.layout-button:hover,.layout-button.active{background:#1a202a;color:#9ece6a}.layout-button .icon-svg{width:15px;height:15px}.pane-grid{display:grid;flex:1;min-height:0;gap:1px;background:#202630;padding:0}.pane-grid.split-v{grid-template-columns:1fr 1fr}.pane-grid.split-h{grid-template-rows:1fr 1fr}.pane-grid.grid{grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr}.pane-grid.maximized-pane-grid{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr)}.pane-grid:not(.maximized-pane-grid) .overflow-pane,.pane-grid.maximized-pane-grid .pane:not(.maximized-pane){display:none}.pane{min-width:0;min-height:0;display:flex;flex-direction:column;background:#0a0c10}.terminal-pane{outline:1px solid #202630;outline-offset:-1px}.terminal-pane.focused{outline:1px solid #7aa2f7;outline-offset:-1px}.pane-title{height:29px;display:flex;align-items:center;justify-content:space-between;padding:0 9px;border-bottom:1px solid #202630;color:#8b93a3;font:10px JetBrains Mono,monospace}.pane-actions,.pane-dim{color:#4e5969;font-size:9px}.pane-actions{display:flex;align-items:center;gap:5px}.pane-nav,.pane-maximize{width:19px;height:19px;display:grid;place-items:center;padding:0;border:1px solid transparent;border-radius:3px;color:#697386;background:transparent}.pane-nav:hover,.pane-maximize:hover{color:#d8dee9;background:#1a202a;border-color:#2a3340}.pane-nav .icon-svg,.pane-maximize .icon-svg{width:13px;height:13px}.pane-live{width:5px;height:5px;margin-right:5px}.terminal{flex:1;min-height:0;min-width:0;padding:0;overflow:hidden;position:relative;background:#0a0c10;line-height:1.2;font-variant-ligatures:none}.terminal .xterm{position:absolute;inset:0;height:100%!important;width:100%!important;padding:0;box-sizing:border-box}.terminal .xterm-viewport{overflow-y:auto!important}.terminal .xterm-screen{position:relative}.terminal .xterm-rows{line-height:inherit}.empty-pane{background:#0d1015}.empty-pane-body{display:flex;align-items:center;justify-content:center;flex-direction:column;gap:6px;height:100%;color:#697386}.empty-glyph{display:grid;place-items:center;width:30px;height:30px;border:1px dashed #344050;border-radius:5px;color:#697386;font-size:18px}.empty-pane-body strong{font-size:11px;color:#8b93a3;font-weight:550}.empty-pane-body small{font-size:10px}.empty-pane-body button{margin-top:8px;padding:6px 9px;border:1px solid #2a3340;border-radius:4px;background:#151a22;color:#8b93a3;font-size:10px}.empty-pane-body button:hover{border-color:#7aa2f7;color:#d8dee9}.status-bar{height:25px;display:flex;align-items:center;gap:8px;padding:0 10px;border-top:1px solid #202630;background:#0f1218;color:#697386;font:10px JetBrains Mono,monospace}.status-separator{width:1px;height:11px;background:#2a3340}.status-spacer{flex:1}.overview-layer,.modal-layer{position:fixed;inset:0;z-index:20;display:grid;place-items:center;background:#07090dcc}.overview{width:min(780px,calc(100vw - 100px));background:#11161e;border:1px solid #303b4b;border-radius:8px;box-shadow:0 20px 80px #0009}.overview-head,.modal-head{display:flex;justify-content:space-between;align-items:flex-start;padding:18px 20px 14px;border-bottom:1px solid #202630}.overview-head h2,.modal-head h2{margin:4px 0 0;font-size:17px;font-weight:600}.close-button{padding:4px 7px;border:1px solid #2a3340;border-radius:4px;background:#151a22;color:#697386;font:10px JetBrains Mono,monospace}.close-button:hover{color:#d8dee9;border-color:#7aa2f7}.thumbnail-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;padding:18px 20px}.thumbnail{padding:0;text-align:left;background:#0d1015;border:1px solid #252f3c;border-radius:6px;overflow:hidden}.thumbnail:hover,.thumbnail.active{border-color:#7aa2f7}.thumbnail.active{box-shadow:0 0 0 1px #7aa2f733}.thumbnail-preview{height:100px;padding:13px;background:#0a0c10;border-bottom:1px solid #202630;display:flex;flex-direction:column;gap:6px;position:relative}.thumbnail-line{height:3px;width:70%;background:#4e5969;border-radius:2px}.thumbnail-line.short{width:43%}.thumbnail-line.cyan{background:#7dcfff}.thumbnail-line.green{background:#9ece6a;width:56%}.thumbnail-cursor{position:absolute;left:13px;bottom:15px;width:5px;height:9px;background:#9ece6a}.thumbnail-label{display:flex;flex-direction:column;gap:3px;padding:9px 10px}.thumbnail-label b{font-size:11px;font-weight:550;color:#d8dee9}.thumbnail-label small{font:10px JetBrains Mono,monospace;color:#697386}.overview-empty{grid-column:1 / -1;display:flex;flex-direction:column;align-items:center;gap:7px;padding:36px;color:#697386}.overview-empty span{font-size:24px}.overview-empty b{color:#d8dee9;font-size:12px}.overview-empty small{font-size:11px}.overview-foot{display:flex;align-items:center;gap:8px;padding:12px 20px;border-top:1px solid #202630}.overview-foot button{padding:7px 9px;border:1px solid #2a3340;border-radius:4px;background:#151a22;color:#8b93a3;font-size:10px}.overview-foot button:hover{color:#d8dee9;border-color:#7aa2f7}.overview-hint{margin-left:auto;color:#4e5969;font:10px JetBrains Mono,monospace}.modal-card{width:390px;padding:0 20px 18px;background:#11161e;border:1px solid #303b4b;border-radius:8px;box-shadow:0 20px 80px #0009}.modal-card p{color:#8b93a3;font-size:12px;line-height:1.5;margin:17px 0}.modal-card label{display:block;margin-top:14px;color:#8b93a3;font-size:11px}.modal-card input{display:block;width:100%;margin-top:6px;padding:9px 10px;border:1px solid #2a3340;border-radius:4px;background:#0a0c10;color:#d8dee9;font:12px JetBrains Mono,monospace}.modal-card input:focus{border-color:#7aa2f7;outline:0}.modal-actions{display:flex;justify-content:flex-end;gap:7px;margin-top:20px}.modal-actions button{padding:8px 11px;border:1px solid #2a3340;border-radius:4px;background:#151a22;color:#8b93a3;font-size:11px}.modal-actions button:hover{color:#d8dee9;border-color:#7aa2f7}.primary-button{background:#9ece6a!important;border-color:#9ece6a!important;color:#0a0c10!important;font-weight:600}.primary-button:disabled{opacity:.55;cursor:wait}.approval-card code{display:block;padding:11px;border:1px solid #2a3340;border-radius:4px;background:#0a0c10;color:#7dcfff;font:12px JetBrains Mono,monospace}.warning-text{color:#e0af68}@media(max-width:1100px){.session-drawer{width:210px}.window-brand{width:90px}.brand-name,.bar-button span{display:none}.thumbnail-grid{grid-template-columns:repeat(2,1fr)}}:root[data-theme=light]{color:#253044;background:#f7f9fc;color-scheme:light}:root[data-theme=light] body,:root[data-theme=light] .app-shell,:root[data-theme=light] .workspace,:root[data-theme=light] .pane,:root[data-theme=light] .terminal,:root[data-theme=light] .thumbnail-preview{background:#f7f9fc}:root[data-theme=light] .window-bar,:root[data-theme=light] .session-drawer,:root[data-theme=light] .status-bar{background:#fff;border-color:#dbe2ec}:root[data-theme=light] .rail,:root[data-theme=light] .workspace-head,:root[data-theme=light] .empty-pane{background:#eef2f7;border-color:#dbe2ec}:root[data-theme=light] .pane-grid{background:#dbe2ec}:root[data-theme=light] .workspace-tab,:root[data-theme=light] .bar-button,:root[data-theme=light] .avatar,:root[data-theme=light] .rail-button,:root[data-theme=light] .drawer-tool,:root[data-theme=light] .drawer-action,:root[data-theme=light] .location,:root[data-theme=light] .layout-button{color:#5c687b}:root[data-theme=light] .workspace-tab.active,:root[data-theme=light] .bar-button:hover,:root[data-theme=light] .rail-button:hover,:root[data-theme=light] .rail-button.active,:root[data-theme=light] .drawer-tool:hover,:root[data-theme=light] .drawer-tool.active,:root[data-theme=light] .session-row:hover,:root[data-theme=light] .inline-restore,:root[data-theme=light] .layout-button:hover,:root[data-theme=light] .layout-button.active{background:#e1e9f4;color:#253044}:root[data-theme=light] .workspace-tab.active,:root[data-theme=light] .session-row.active{border-color:#c7d5e8;background:#dbe7f5;color:#253044}:root[data-theme=light] .session-row,:root[data-theme=light] .pane-title,:root[data-theme=light] .drawer-head h1,:root[data-theme=light] .empty-pane-body strong,:root[data-theme=light] .thumbnail-label b,:root[data-theme=light] .overview-empty b{color:#253044}:root[data-theme=light] .eyebrow,:root[data-theme=light] .tab-meta,:root[data-theme=light] .session-copy small,:root[data-theme=light] .session-state,:root[data-theme=light] .session-empty,:root[data-theme=light] .drawer-status,:root[data-theme=light] .muted-text,:root[data-theme=light] .pane-actions,:root[data-theme=light] .pane-dim,:root[data-theme=light] .status-bar,:root[data-theme=light] .overview-hint,:root[data-theme=light] .thumbnail-label small,:root[data-theme=light] .overview-empty,:root[data-theme=light] .modal-card p,:root[data-theme=light] .modal-card label{color:#68768a}:root[data-theme=light] .brand-mark{color:#2459a6;border-color:#6d9bd6}:root[data-theme=light] .avatar,:root[data-theme=light] .square-button,:root[data-theme=light] .inline-restore,:root[data-theme=light] .empty-pane-body button,:root[data-theme=light] .overview-foot button,:root[data-theme=light] .close-button,:root[data-theme=light] .modal-actions button,:root[data-theme=light] .modal-card input,:root[data-theme=light] .approval-card code{background:#fff;border-color:#c7d2e1;color:#536176}:root[data-theme=light] .session-row.active{box-shadow:inset 2px 0 #2459a6}:root[data-theme=light] .rail-button.active{box-shadow:inset 2px 0 #2459a6}:root[data-theme=light] .layout-button.active{color:#16803c}:root[data-theme=light] .overview,:root[data-theme=light] .modal-card{background:#fff;border-color:#c7d2e1;box-shadow:0 20px 80px #53617633}:root[data-theme=light] .overview-head,:root[data-theme=light] .modal-head,:root[data-theme=light] .overview-foot,:root[data-theme=light] .pane-title{border-color:#dbe2ec}:root[data-theme=light] .thumbnail{background:#eef2f7;border-color:#c7d2e1}:root[data-theme=light] .thumbnail-preview,:root[data-theme=light] .approval-card code,:root[data-theme=light] .modal-card input{background:#f7f9fc;border-color:#dbe2ec}:root[data-theme=light] .modal-layer,:root[data-theme=light] .overview-layer{background:#25304433}:root[data-theme=light] .empty-glyph{border-color:#b8c6d8}:root[data-theme=light] .primary-button{background:#16803c!important;border-color:#16803c!important;color:#fff!important}