zerogterm 0.2.0-alpha.1 → 0.3.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/README.md +2 -0
- package/dist/main/main/main.js +42 -3
- package/dist/main/main/preload.cjs +7 -0
- package/dist/main/main/session-history.js +105 -0
- package/dist/main/main/session-service.js +95 -34
- package/dist/main/main/ssh-inventory.js +119 -0
- package/dist/renderer/assets/index-5pgRaxBt.css +1 -0
- package/dist/renderer/assets/index-Cs9gfbAs.js +17 -0
- package/dist/renderer/assets/{voice-worker-Bde9NFkO.js → voice-worker-DWb1dVxY.js} +6 -6
- package/dist/renderer/index.html +2 -2
- package/package.json +7 -3
- package/dist/renderer/assets/index-BbPop_gF.js +0 -17
- package/dist/renderer/assets/index-CJwVZjfo.css +0 -1
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@ ZeroG Terminal is a Linux-first Electron workspace for persistent `screen` sessi
|
|
|
6
6
|
- Session sidebar with create, discover, refresh, and attach actions.
|
|
7
7
|
- Visible actions for opening a persistent local terminal and connecting to an SSH target (`host`, `user@host`, or `user@host:port`).
|
|
8
8
|
- xterm.js terminal renderer and a split workspace surface.
|
|
9
|
+
- Pane close button: closing detaches the session and removes the pane from the workspace; screen-backed sessions stay alive and remain discoverable.
|
|
10
|
+
- Newly opened terminal and SSH panes receive keyboard focus so typing works immediately.
|
|
9
11
|
- Persistent dark/light theme with an accessible sun/moon toggle.
|
|
10
12
|
- Safe session-name validation and argument-array `screen` invocation.
|
|
11
13
|
- AI command approval surface; suggestions are visible and require explicit approval before being sent to the terminal.
|
package/dist/main/main/main.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { app, BrowserWindow, clipboard, ipcMain, Menu, session } from 'electron';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { ScreenService } from './session-service.js';
|
|
4
|
+
import { ScreenService, discoverShellBackends, parseWslDistributions } from './session-service.js';
|
|
5
|
+
import { SessionHistoryStore, defaultHistoryPath } from './session-history.js';
|
|
6
|
+
import { buildRemoteScreenAttachArgs, buildRemoteScreenDiscoveryArgs, listKnownConnections, parseRemoteScreenList, validateKnownConnection } from './ssh-inventory.js';
|
|
5
7
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
6
|
-
const
|
|
8
|
+
const history = new SessionHistoryStore({ filePath: defaultHistoryPath(app.getPath('userData')) });
|
|
9
|
+
const service = new ScreenService({ onEvent: (event, session, available) => { void history.record(event, session, available); } });
|
|
7
10
|
let win;
|
|
8
11
|
// GPU is unstable under Toolbox/Wayland on this host; allow override.
|
|
9
12
|
if (process.env.ZEROG_ENABLE_GPU !== '1') {
|
|
@@ -53,12 +56,28 @@ function isRecord(value) {
|
|
|
53
56
|
return typeof value === 'object' && value !== null;
|
|
54
57
|
}
|
|
55
58
|
ipcMain.handle('sessions:list', () => service.list());
|
|
59
|
+
ipcMain.handle('sessions:history', () => history.list());
|
|
60
|
+
ipcMain.handle('sessions:historyRemove', (_event, entryId) => history.remove(entryId));
|
|
61
|
+
ipcMain.handle('sessions:backends', () => discoverShellBackends());
|
|
62
|
+
ipcMain.handle('sessions:wslDistributions', async () => {
|
|
63
|
+
try {
|
|
64
|
+
const { execFile } = await import('node:child_process');
|
|
65
|
+
const { promisify } = await import('node:util');
|
|
66
|
+
const { stdout } = await promisify(execFile)(process.platform === 'win32' ? 'wsl.exe' : 'wsl', ['--list', '--quiet']);
|
|
67
|
+
return parseWslDistributions(stdout);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
});
|
|
56
73
|
ipcMain.handle('sessions:createLocal', async (_event, request) => {
|
|
57
74
|
if (!isRecord(request) || typeof request.name !== 'string') {
|
|
58
75
|
throw new Error('createLocalSession requires { name: string }');
|
|
59
76
|
}
|
|
60
77
|
const cwd = typeof request.cwd === 'string' ? request.cwd : undefined;
|
|
61
|
-
|
|
78
|
+
const backend = request.backend === 'bash' || request.backend === 'zsh' || request.backend === 'powershell' || request.backend === 'wsl' ? request.backend : undefined;
|
|
79
|
+
const wslDistribution = typeof request.wslDistribution === 'string' ? request.wslDistribution : undefined;
|
|
80
|
+
return service.createLocal({ name: request.name, cwd, ...(backend ? { backend } : {}), wslDistribution });
|
|
62
81
|
});
|
|
63
82
|
ipcMain.handle('sessions:createSsh', async (_event, request) => {
|
|
64
83
|
if (!isRecord(request) || typeof request.target !== 'string') {
|
|
@@ -67,6 +86,26 @@ ipcMain.handle('sessions:createSsh', async (_event, request) => {
|
|
|
67
86
|
const name = typeof request.name === 'string' ? request.name : undefined;
|
|
68
87
|
return service.createSsh(request.target, name);
|
|
69
88
|
});
|
|
89
|
+
ipcMain.handle('connections:listKnown', () => listKnownConnections());
|
|
90
|
+
ipcMain.handle('screens:discoverRemote', async (_event, input) => {
|
|
91
|
+
const connection = validateKnownConnection(input);
|
|
92
|
+
const command = buildRemoteScreenDiscoveryArgs(connection);
|
|
93
|
+
const { execFile } = await import('node:child_process');
|
|
94
|
+
const { promisify } = await import('node:util');
|
|
95
|
+
try {
|
|
96
|
+
const { stdout } = await promisify(execFile)(command.file, command.args);
|
|
97
|
+
return parseRemoteScreenList(stdout, connection.alias);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
return { status: 'unavailable', host: connection.alias, reason: error?.code === 'ENOENT' ? 'ssh-unavailable' : 'host-unreachable', sessions: [] };
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
ipcMain.handle('screens:attachRemote', (_event, input, screenName) => {
|
|
104
|
+
const connection = validateKnownConnection(input);
|
|
105
|
+
if (typeof screenName !== 'string')
|
|
106
|
+
throw new Error('screenName is required');
|
|
107
|
+
return buildRemoteScreenAttachArgs(connection, screenName);
|
|
108
|
+
});
|
|
70
109
|
ipcMain.handle('sessions:attach', (_event, id) => {
|
|
71
110
|
if (typeof id !== 'string' || !id)
|
|
72
111
|
throw new Error('attachSession requires a session id');
|
|
@@ -2,8 +2,15 @@ const { contextBridge, ipcRenderer } = require('electron');
|
|
|
2
2
|
|
|
3
3
|
const api = {
|
|
4
4
|
listSessions: () => ipcRenderer.invoke('sessions:list'),
|
|
5
|
+
listHistory: () => ipcRenderer.invoke('sessions:history'),
|
|
6
|
+
removeHistory: (entryId) => ipcRenderer.invoke('sessions:historyRemove', entryId),
|
|
7
|
+
listBackends: () => ipcRenderer.invoke('sessions:backends'),
|
|
8
|
+
listWslDistributions: () => ipcRenderer.invoke('sessions:wslDistributions'),
|
|
5
9
|
createLocalSession: (request) => ipcRenderer.invoke('sessions:createLocal', request),
|
|
6
10
|
createSshSession: (request) => ipcRenderer.invoke('sessions:createSsh', request),
|
|
11
|
+
listKnownConnections: () => ipcRenderer.invoke('connections:listKnown'),
|
|
12
|
+
discoverRemoteScreens: (connection) => ipcRenderer.invoke('screens:discoverRemote', connection),
|
|
13
|
+
buildRemoteScreenAttach: (connection, screenName) => ipcRenderer.invoke('screens:attachRemote', connection, screenName),
|
|
7
14
|
attachSession: (id) => ipcRenderer.invoke('sessions:attach', id),
|
|
8
15
|
closeSession: (id) => ipcRenderer.invoke('sessions:close', id),
|
|
9
16
|
write: (sessionId, data) => ipcRenderer.send('terminal:write', sessionId, data),
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
const SCHEMA_VERSION = 1;
|
|
5
|
+
const DEFAULT_LIMIT = 100;
|
|
6
|
+
/** Main-process-only, best-effort history persistence. Never stores cwd, args, or credentials. */
|
|
7
|
+
export class SessionHistoryStore {
|
|
8
|
+
filePath;
|
|
9
|
+
limit;
|
|
10
|
+
now;
|
|
11
|
+
entries = [];
|
|
12
|
+
loaded = false;
|
|
13
|
+
writeQueue = Promise.resolve();
|
|
14
|
+
constructor(options) {
|
|
15
|
+
this.filePath = options.filePath;
|
|
16
|
+
this.limit = Math.max(1, Math.floor(options.limit ?? DEFAULT_LIMIT));
|
|
17
|
+
this.now = options.now ?? (() => new Date());
|
|
18
|
+
}
|
|
19
|
+
async list() {
|
|
20
|
+
await this.ensureLoaded();
|
|
21
|
+
return this.entries.map((entry) => ({ ...entry, session: { ...entry.session } }));
|
|
22
|
+
}
|
|
23
|
+
async record(event, session, available) {
|
|
24
|
+
await this.ensureLoaded();
|
|
25
|
+
const entry = {
|
|
26
|
+
id: randomUUID(),
|
|
27
|
+
timestamp: this.now().toISOString(),
|
|
28
|
+
event,
|
|
29
|
+
session: redactSession(session),
|
|
30
|
+
available: Boolean(available)
|
|
31
|
+
};
|
|
32
|
+
this.entries = [entry, ...this.entries].slice(0, this.limit);
|
|
33
|
+
await this.persist();
|
|
34
|
+
return entry;
|
|
35
|
+
}
|
|
36
|
+
async remove(entryId) {
|
|
37
|
+
await this.ensureLoaded();
|
|
38
|
+
const index = this.entries.findIndex((item) => item.id === entryId);
|
|
39
|
+
if (index < 0)
|
|
40
|
+
return false;
|
|
41
|
+
this.entries = this.entries.filter((item) => item.id !== entryId);
|
|
42
|
+
await this.persist();
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
async ensureLoaded() {
|
|
46
|
+
if (this.loaded)
|
|
47
|
+
return;
|
|
48
|
+
this.loaded = true;
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(await readFile(this.filePath, 'utf8'));
|
|
51
|
+
if (!isHistoryFile(parsed))
|
|
52
|
+
return;
|
|
53
|
+
this.entries = parsed.entries.slice(0, this.limit).map(normalizeEntry).filter((entry) => entry !== undefined);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
this.entries = [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async persist() {
|
|
60
|
+
const snapshot = { version: SCHEMA_VERSION, entries: this.entries };
|
|
61
|
+
this.writeQueue = this.writeQueue.then(async () => {
|
|
62
|
+
try {
|
|
63
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
64
|
+
const temp = join(dirname(this.filePath), `.history.tmp-${process.pid}-${randomUUID()}`);
|
|
65
|
+
await writeFile(temp, JSON.stringify(snapshot, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
66
|
+
await rename(temp, this.filePath);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// History must never affect terminal operation.
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
await this.writeQueue;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function redactSession(session) {
|
|
76
|
+
const result = {
|
|
77
|
+
id: safeText(session.id), name: safeText(session.name), kind: session.kind, host: safeText(session.host), backend: session.backend, scope: session.scope, screenName: session.screenName, sshTarget: session.sshTarget, wslDistribution: session.wslDistribution
|
|
78
|
+
};
|
|
79
|
+
if (typeof session.backend === 'string')
|
|
80
|
+
result.backend = safeText(session.backend);
|
|
81
|
+
if (typeof session.scope === 'string')
|
|
82
|
+
result.scope = safeText(session.scope);
|
|
83
|
+
if (typeof session.screenName === 'string')
|
|
84
|
+
result.screenName = safeText(session.screenName);
|
|
85
|
+
if (typeof session.sshTarget === 'string')
|
|
86
|
+
result.sshTarget = safeText(session.sshTarget);
|
|
87
|
+
if (typeof session.wslDistribution === 'string')
|
|
88
|
+
result.wslDistribution = safeText(session.wslDistribution);
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
function safeText(value) { return value.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, 256); }
|
|
92
|
+
function isHistoryFile(value) {
|
|
93
|
+
return typeof value === 'object' && value !== null && value.version === SCHEMA_VERSION && Array.isArray(value.entries);
|
|
94
|
+
}
|
|
95
|
+
function normalizeEntry(value) {
|
|
96
|
+
if (!value || typeof value !== 'object')
|
|
97
|
+
return undefined;
|
|
98
|
+
const item = value;
|
|
99
|
+
if (typeof item.id !== 'string' || typeof item.timestamp !== 'string' || !['created', 'attached', 'detached', 'closed', 'reconnect-failed'].includes(item.event) || typeof item.available !== 'boolean' || !item.session || typeof item.session !== 'object')
|
|
100
|
+
return undefined;
|
|
101
|
+
if (!['local', 'ssh'].includes(item.session.kind) || typeof item.session.id !== 'string' || typeof item.session.name !== 'string' || typeof item.session.host !== 'string')
|
|
102
|
+
return undefined;
|
|
103
|
+
return { id: safeText(item.id), timestamp: item.timestamp, event: item.event, available: item.available, session: redactSession(item.session) };
|
|
104
|
+
}
|
|
105
|
+
export function defaultHistoryPath(userDataPath) { return join(userDataPath, 'session-history.json'); }
|
|
@@ -6,8 +6,13 @@ import { promisify } from 'node:util';
|
|
|
6
6
|
const execFileAsync = promisify(execFile);
|
|
7
7
|
const require = createRequire(import.meta.url);
|
|
8
8
|
const NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,48}$/;
|
|
9
|
-
/**
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* host, user@host, host:port, user@host:port — no shell metacharacters.
|
|
11
|
+
* Host and user must start alphanumeric: a leading '-' would be parsed by
|
|
12
|
+
* ssh's getopt as an option, and `-Fsome.cfg` can point ssh at an attacker
|
|
13
|
+
* -chosen config file (hence ProxyCommand) without any shell involvement.
|
|
14
|
+
*/
|
|
15
|
+
const SSH_TARGET = /^(?:([A-Za-z0-9][A-Za-z0-9._-]*)@)?([A-Za-z0-9][A-Za-z0-9.-]*)(?::(\d{1,5}))?$/;
|
|
11
16
|
export function validateSessionName(name) {
|
|
12
17
|
const value = name.trim();
|
|
13
18
|
if (!NAME.test(value)) {
|
|
@@ -32,9 +37,38 @@ export function validateSshTarget(input) {
|
|
|
32
37
|
const args = ['-tt'];
|
|
33
38
|
if (portText)
|
|
34
39
|
args.push('-p', portText);
|
|
35
|
-
|
|
40
|
+
// '--' ends option parsing, so the destination can never be read as a flag.
|
|
41
|
+
args.push('--', destination);
|
|
36
42
|
return { target: value, args };
|
|
37
43
|
}
|
|
44
|
+
export function parseWslDistributions(output) {
|
|
45
|
+
return output.split(/\r?\n/).slice(1).map((line) => line.replace(/^\*?\s*/, '').trim())
|
|
46
|
+
.map((line) => line.split(/\s{2,}/)[0]).filter((name) => /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$/.test(name));
|
|
47
|
+
}
|
|
48
|
+
export function shellBackendArgs(backend, distribution) {
|
|
49
|
+
if (backend === 'wsl') {
|
|
50
|
+
if (distribution && !/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$/.test(distribution.trim()))
|
|
51
|
+
throw new Error('Invalid WSL distribution name.');
|
|
52
|
+
return { backend, executable: process.platform === 'win32' ? 'wsl.exe' : 'wsl', args: distribution ? ['-d', distribution.trim()] : [], label: distribution ? `WSL · ${distribution.trim()}` : 'WSL', wslDistribution: distribution?.trim() };
|
|
53
|
+
}
|
|
54
|
+
if (backend === 'powershell')
|
|
55
|
+
return { backend, executable: process.platform === 'win32' ? 'pwsh.exe' : 'pwsh', args: [], label: 'PowerShell' };
|
|
56
|
+
if (backend === 'zsh')
|
|
57
|
+
return { backend, executable: 'zsh', args: [], label: 'zsh' };
|
|
58
|
+
return { backend: 'bash', executable: 'bash', args: [], label: 'bash' };
|
|
59
|
+
}
|
|
60
|
+
export async function discoverShellBackends() {
|
|
61
|
+
const result = [shellBackendArgs('bash')];
|
|
62
|
+
for (const backend of ['powershell', 'wsl']) {
|
|
63
|
+
const candidate = shellBackendArgs(backend);
|
|
64
|
+
try {
|
|
65
|
+
await execFileAsync(candidate.executable, backend === 'wsl' ? ['--status'] : ['-NoProfile', '-Command', '$PSVersionTable.PSVersion.ToString()']);
|
|
66
|
+
result.push(candidate);
|
|
67
|
+
}
|
|
68
|
+
catch { /* unavailable */ }
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
38
72
|
export function parseScreenList(output) {
|
|
39
73
|
return output
|
|
40
74
|
.split('\n')
|
|
@@ -54,7 +88,11 @@ export function parseScreenList(output) {
|
|
|
54
88
|
cwd: homedir(),
|
|
55
89
|
status: attached ? 'connected' : 'detached',
|
|
56
90
|
lastSeen: new Date().toISOString(),
|
|
57
|
-
persistence: 'screen'
|
|
91
|
+
persistence: 'screen',
|
|
92
|
+
backend: 'screen',
|
|
93
|
+
scope: 'local',
|
|
94
|
+
source: 'discovered',
|
|
95
|
+
screenName: name
|
|
58
96
|
}
|
|
59
97
|
];
|
|
60
98
|
});
|
|
@@ -71,6 +109,8 @@ export class ScreenService {
|
|
|
71
109
|
ptys = new Map();
|
|
72
110
|
sshSessions = new Map();
|
|
73
111
|
fallbackLocalSessions = new Map();
|
|
112
|
+
onEvent;
|
|
113
|
+
constructor(options = {}) { this.onEvent = options.onEvent; }
|
|
74
114
|
async available() {
|
|
75
115
|
try {
|
|
76
116
|
await execFileAsync('screen', ['--version']);
|
|
@@ -111,33 +151,24 @@ export class ScreenService {
|
|
|
111
151
|
return [];
|
|
112
152
|
}
|
|
113
153
|
}
|
|
114
|
-
async createLocal(
|
|
115
|
-
const
|
|
154
|
+
async createLocal(nameOrRequest, cwd = homedir()) {
|
|
155
|
+
const request = typeof nameOrRequest === 'string' ? { name: nameOrRequest, cwd } : nameOrRequest;
|
|
156
|
+
const safeName = validateSessionName(request.name);
|
|
157
|
+
const backend = request.backend ?? 'bash';
|
|
158
|
+
const shell = shellBackendArgs(backend, request.wslDistribution);
|
|
159
|
+
const requestedCwd = request.cwd ?? homedir();
|
|
160
|
+
if (backend !== 'bash' && !(await executableAvailable(shell.executable)))
|
|
161
|
+
throw new Error(`${shell.label} is not installed or unavailable.`);
|
|
116
162
|
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
|
-
};
|
|
163
|
+
const fallback = { id: `local:${safeName}`, name: safeName, kind: 'local', host: 'local', cwd: requestedCwd, status: 'detached', lastSeen: new Date().toISOString(), persistence: 'process', backend, scope: 'local', source: 'active', wslDistribution: shell.wslDistribution };
|
|
127
164
|
this.fallbackLocalSessions.set(fallback.id, fallback);
|
|
165
|
+
this.onEvent?.('created', fallback, true);
|
|
128
166
|
return fallback;
|
|
129
167
|
}
|
|
130
|
-
await execFileAsync('screen', ['-dmS', safeName,
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
kind: 'local',
|
|
135
|
-
host: 'local',
|
|
136
|
-
cwd,
|
|
137
|
-
status: 'detached',
|
|
138
|
-
lastSeen: new Date().toISOString(),
|
|
139
|
-
persistence: 'screen'
|
|
140
|
-
};
|
|
168
|
+
await execFileAsync('screen', ['-dmS', safeName, shell.executable, ...shell.args], { cwd: requestedCwd });
|
|
169
|
+
const session = { id: `local:${safeName}`, name: safeName, kind: 'local', host: 'local', cwd: requestedCwd, status: 'detached', lastSeen: new Date().toISOString(), persistence: 'screen', backend: 'screen', scope: 'local', source: 'active', screenName: safeName, wslDistribution: shell.wslDistribution };
|
|
170
|
+
this.onEvent?.('created', session, true);
|
|
171
|
+
return session;
|
|
141
172
|
}
|
|
142
173
|
async createSsh(targetInput, name) {
|
|
143
174
|
const { target } = validateSshTarget(targetInput);
|
|
@@ -150,9 +181,13 @@ export class ScreenService {
|
|
|
150
181
|
cwd: '~',
|
|
151
182
|
status: 'detached',
|
|
152
183
|
lastSeen: new Date().toISOString(),
|
|
153
|
-
sshTarget: target
|
|
184
|
+
sshTarget: target,
|
|
185
|
+
backend: 'ssh',
|
|
186
|
+
scope: 'remote',
|
|
187
|
+
source: 'active'
|
|
154
188
|
};
|
|
155
189
|
this.sshSessions.set(session.id, session);
|
|
190
|
+
this.onEvent?.('created', session, true);
|
|
156
191
|
return session;
|
|
157
192
|
}
|
|
158
193
|
attach(id, onData, onExit) {
|
|
@@ -164,11 +199,16 @@ export class ScreenService {
|
|
|
164
199
|
if (fallback) {
|
|
165
200
|
fallback.status = 'connected';
|
|
166
201
|
fallback.lastSeen = new Date().toISOString();
|
|
167
|
-
|
|
202
|
+
const shell = shellBackendArgs(fallback.backend ?? 'bash', fallback.wslDistribution);
|
|
203
|
+
this.spawnCommand(id, shell.executable, shell.args, onData, onExit, fallback.cwd);
|
|
204
|
+
this.onEvent?.('attached', fallback, true);
|
|
168
205
|
return { ...fallback };
|
|
169
206
|
}
|
|
170
|
-
|
|
207
|
+
// Session ids cross the IPC boundary from the renderer, so re-validate
|
|
208
|
+
// rather than trusting that createLocal produced this one.
|
|
209
|
+
const name = validateSessionName(id.slice('local:'.length));
|
|
171
210
|
this.spawnCommand(id, 'screen', ['-x', name], onData, onExit);
|
|
211
|
+
this.onEvent?.('attached', existing, true);
|
|
172
212
|
return { ...existing, status: 'connected', persistence: 'screen' };
|
|
173
213
|
}
|
|
174
214
|
const session = this.sshSessions.get(id);
|
|
@@ -178,7 +218,15 @@ export class ScreenService {
|
|
|
178
218
|
const { args } = validateSshTarget(session.sshTarget);
|
|
179
219
|
session.status = 'connected';
|
|
180
220
|
session.lastSeen = new Date().toISOString();
|
|
181
|
-
|
|
221
|
+
try {
|
|
222
|
+
this.spawnCommand(id, 'ssh', args, onData, onExit);
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
session.status = 'error';
|
|
226
|
+
this.onEvent?.('reconnect-failed', session, false);
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
this.onEvent?.('attached', session, true);
|
|
182
230
|
return { ...session };
|
|
183
231
|
}
|
|
184
232
|
getSession(id) {
|
|
@@ -186,7 +234,7 @@ export class ScreenService {
|
|
|
186
234
|
const fallback = this.fallbackLocalSessions.get(id);
|
|
187
235
|
if (fallback)
|
|
188
236
|
return fallback;
|
|
189
|
-
const name = id.slice('local:'.length);
|
|
237
|
+
const name = validateSessionName(id.slice('local:'.length));
|
|
190
238
|
return {
|
|
191
239
|
id,
|
|
192
240
|
name,
|
|
@@ -229,11 +277,15 @@ export class ScreenService {
|
|
|
229
277
|
proc.onExit(() => {
|
|
230
278
|
this.ptys.delete(sessionId);
|
|
231
279
|
const fallback = this.fallbackLocalSessions.get(sessionId);
|
|
232
|
-
if (fallback)
|
|
280
|
+
if (fallback) {
|
|
233
281
|
fallback.status = 'detached';
|
|
282
|
+
this.onEvent?.('detached', fallback, false);
|
|
283
|
+
}
|
|
234
284
|
const ssh = this.sshSessions.get(sessionId);
|
|
235
|
-
if (ssh)
|
|
285
|
+
if (ssh) {
|
|
236
286
|
ssh.status = 'detached';
|
|
287
|
+
this.onEvent?.('detached', ssh, false);
|
|
288
|
+
}
|
|
237
289
|
onExit(fallback
|
|
238
290
|
? 'Terminal detached. This process-only local session will not survive app exit; install screen for persistence.'
|
|
239
291
|
: 'Terminal detached. Local screen sessions remain alive; SSH sessions end with the connection.');
|
|
@@ -259,8 +311,17 @@ export class ScreenService {
|
|
|
259
311
|
* discoverable via `screen -ls`; SSH and process-only sessions end.
|
|
260
312
|
*/
|
|
261
313
|
close(sessionId) {
|
|
314
|
+
const session = this.getSession(sessionId);
|
|
262
315
|
this.detach(sessionId);
|
|
263
316
|
this.sshSessions.delete(sessionId);
|
|
264
317
|
this.fallbackLocalSessions.delete(sessionId);
|
|
318
|
+
this.onEvent?.('closed', session, false);
|
|
265
319
|
}
|
|
266
320
|
}
|
|
321
|
+
async function executableAvailable(executable) { try {
|
|
322
|
+
await execFileAsync(executable, ['--version']);
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return false;
|
|
327
|
+
} }
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
4
|
+
const HOST = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,253}$/;
|
|
5
|
+
// Must start alphanumeric like TOKEN/HOST above: the user is concatenated into
|
|
6
|
+
// `user@host`, so a leading '-' makes the whole destination look like an option
|
|
7
|
+
// to ssh's getopt (`-Fevil.cfg@host` reads an attacker-chosen config file).
|
|
8
|
+
const USER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
9
|
+
const SCREEN = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,48}$/;
|
|
10
|
+
function words(value) {
|
|
11
|
+
const result = [];
|
|
12
|
+
const re = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^']*)'|(\S+)/g;
|
|
13
|
+
for (let match; (match = re.exec(value));)
|
|
14
|
+
result.push((match[1] ?? match[2] ?? match[3]).replace(/\\([\\"'])/g, '$1'));
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
export function parseSshConfig(text, source) {
|
|
18
|
+
const entries = [];
|
|
19
|
+
let current = [];
|
|
20
|
+
const flush = () => { entries.push(...current); current = []; };
|
|
21
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
22
|
+
const line = raw.replace(/\s+#.*$/, '').trim();
|
|
23
|
+
if (!line || line.startsWith('#'))
|
|
24
|
+
continue;
|
|
25
|
+
const match = line.match(/^([^\s=]+)\s*(?:=\s*|\s+)(.*)$/);
|
|
26
|
+
if (!match)
|
|
27
|
+
continue;
|
|
28
|
+
const key = match[1].toLowerCase();
|
|
29
|
+
const args = words(match[2].trim());
|
|
30
|
+
if (key === 'host') {
|
|
31
|
+
flush();
|
|
32
|
+
const aliases = args.filter((alias) => !alias.includes('*') && !alias.includes('?') && TOKEN.test(alias));
|
|
33
|
+
current = aliases.map((alias) => ({ alias, source }));
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (!current.length || !args[0])
|
|
37
|
+
continue;
|
|
38
|
+
const value = args[0];
|
|
39
|
+
for (const entry of current) {
|
|
40
|
+
if (key === 'hostname' && !entry.hostName)
|
|
41
|
+
entry.hostName = value;
|
|
42
|
+
else if (key === 'user' && !entry.user && USER.test(value))
|
|
43
|
+
entry.user = value;
|
|
44
|
+
else if (key === 'port' && !entry.port && /^\d+$/.test(value) && Number(value) >= 1 && Number(value) <= 65535)
|
|
45
|
+
entry.port = Number(value);
|
|
46
|
+
else if (key === 'identityfile' && !entry.identityFile)
|
|
47
|
+
entry.identityFile = value;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
flush();
|
|
51
|
+
return entries;
|
|
52
|
+
}
|
|
53
|
+
export async function listKnownConnections(path = `${homedir()}/.ssh/config`) {
|
|
54
|
+
try {
|
|
55
|
+
return parseSshConfig(await readFile(path, 'utf8'), path);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export function validateKnownConnection(input) {
|
|
62
|
+
if (!input || typeof input !== 'object')
|
|
63
|
+
throw new Error('Invalid SSH connection.');
|
|
64
|
+
const value = input;
|
|
65
|
+
if (typeof value.alias !== 'string' || !TOKEN.test(value.alias))
|
|
66
|
+
throw new Error('Invalid SSH alias.');
|
|
67
|
+
const result = { alias: value.alias };
|
|
68
|
+
if (value.hostName !== undefined) {
|
|
69
|
+
if (typeof value.hostName !== 'string' || !HOST.test(value.hostName))
|
|
70
|
+
throw new Error('Invalid SSH hostname.');
|
|
71
|
+
result.hostName = value.hostName;
|
|
72
|
+
}
|
|
73
|
+
if (value.user !== undefined) {
|
|
74
|
+
if (typeof value.user !== 'string' || !USER.test(value.user))
|
|
75
|
+
throw new Error('Invalid SSH user.');
|
|
76
|
+
result.user = value.user;
|
|
77
|
+
}
|
|
78
|
+
if (value.port !== undefined) {
|
|
79
|
+
if (!Number.isInteger(value.port) || value.port < 1 || value.port > 65535)
|
|
80
|
+
throw new Error('Invalid SSH port.');
|
|
81
|
+
result.port = value.port;
|
|
82
|
+
}
|
|
83
|
+
if (value.identityFile !== undefined) {
|
|
84
|
+
if (typeof value.identityFile !== 'string' || value.identityFile.length > 4096 || /[\0\r\n]/.test(value.identityFile))
|
|
85
|
+
throw new Error('Invalid identity file metadata.');
|
|
86
|
+
result.identityFile = value.identityFile;
|
|
87
|
+
}
|
|
88
|
+
if (value.source !== undefined && typeof value.source === 'string')
|
|
89
|
+
result.source = value.source;
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
function sshArgs(connection) {
|
|
93
|
+
const c = validateKnownConnection(connection);
|
|
94
|
+
const destination = c.user ? `${c.user}@${c.hostName ?? c.alias}` : (c.hostName ?? c.alias);
|
|
95
|
+
const args = [];
|
|
96
|
+
if (c.port)
|
|
97
|
+
args.push('-p', String(c.port));
|
|
98
|
+
args.push(destination, '--');
|
|
99
|
+
return args;
|
|
100
|
+
}
|
|
101
|
+
export function buildRemoteScreenDiscoveryArgs(connection) {
|
|
102
|
+
return { file: 'ssh', args: [...sshArgs(connection), 'screen', '-ls'] };
|
|
103
|
+
}
|
|
104
|
+
export function buildRemoteScreenAttachArgs(connection, screenName) {
|
|
105
|
+
if (!SCREEN.test(screenName))
|
|
106
|
+
throw new Error('Invalid screen session name.');
|
|
107
|
+
return { file: 'ssh', args: [...sshArgs(connection), 'screen', '-x', screenName] };
|
|
108
|
+
}
|
|
109
|
+
export function parseRemoteScreenList(output, host) {
|
|
110
|
+
if (!TOKEN.test(host))
|
|
111
|
+
throw new Error('Invalid remote host identity.');
|
|
112
|
+
return output.split(/\r?\n/).flatMap((line) => {
|
|
113
|
+
const match = line.trim().match(/^\d+\.(\S+)\s+\(([^)]+)\)/);
|
|
114
|
+
if (!match || !SCREEN.test(match[1]))
|
|
115
|
+
return [];
|
|
116
|
+
const name = match[1];
|
|
117
|
+
return [{ id: `remote:${host}:${name}`, name, kind: 'ssh', host, cwd: '~', status: /attached/i.test(match[2]) ? 'connected' : 'detached', lastSeen: new Date().toISOString(), persistence: 'screen', sshTarget: host, backend: 'screen', scope: 'remote', source: 'discovered', screenName: name }];
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -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}.session-group-label{padding:8px 10px 4px;color:#697386;font:10px JetBrains Mono,monospace;letter-spacing:.04em}.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,.pane-close,.pane-mic{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,.pane-mic:hover{color:#d8dee9;background:#1a202a;border-color:#2a3340}.pane-close:hover{color:#f7768e;background:#f7768e24;border-color:#f7768e66}.pane-mic:disabled{opacity:.4;cursor:default}.pane-mic.listening{color:#f7768e;background:#f7768e29;border-color:#f7768e8c;animation:mic-pulse 1.2s ease-in-out infinite}.pane-mic.transcribing{color:#e0af68;background:#e0af6824;border-color:#e0af6880;animation:mic-pulse .8s ease-in-out infinite}@keyframes mic-pulse{0%,to{transform:scale(1)}50%{transform:scale(1.15)}}.pane-nav .icon-svg,.pane-maximize .icon-svg,.pane-close .icon-svg,.pane-mic .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:visible;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)}}.history-button{color:#7aa2f7}.session-ghosted{opacity:.58;border:1px dashed #344050}.session-ghosted:hover{opacity:.9}.session-error b{color:#f7768e}.session-empty button{align-self:flex-start;padding:5px 8px;border:1px solid #2a3340;border-radius:4px;background:#151a22;color:#8b93a3;font-size:10px}.history-empty button:hover{border-color:#7aa2f7;color:#d8dee9}.history-popover{position:fixed;z-index:30;top:58px;left:245px;width:285px;max-height:min(440px,calc(100vh - 76px));overflow:auto;padding:10px;border:1px solid #303b4b;border-radius:7px;background:#11161e;box-shadow:0 18px 55px #0009}.history-layer{position:fixed;inset:0;z-index:25}.history-head{display:flex;justify-content:space-between;align-items:center;padding:3px 4px 9px;color:#d8dee9}.history-head button{background:transparent;color:#697386;font-size:18px}.history-item{width:100%;display:flex;align-items:center;justify-content:space-between;gap:9px;padding:8px 5px;text-align:left;border-top:1px solid #202630;background:transparent;color:#8b93a3}.history-main{display:flex;align-items:center;gap:9px;min-width:0}.history-text{display:flex;flex-direction:column;align-items:flex-start;min-width:0}.history-remove{margin-left:8px;background:transparent;color:#536176;border:none;cursor:pointer;font-size:16px;line-height:1}.history-item:hover .history-remove{color:#253044}:root[data-theme=light] .history-remove{color:#68768a}.history-item:hover{background:#1a202a;color:#d8dee9}.history-item b,.history-item small{display:block}.history-item small,.history-note,.history-empty{color:#697386;font-size:10px}.history-kind{min-width:43px;color:#7aa2f7;font:9px JetBrains Mono,monospace}.history-empty{padding:13px 5px}.history-note{display:block;padding:9px 4px 2px;line-height:1.4}:root[data-theme=light] .history-popover{background:#fff;border-color:#c7d2e1;color:#253044}:root[data-theme=light] .history-head{color:#253044}:root[data-theme=light] .history-head button{color:#536176}:root[data-theme=light] .history-item{border-top-color:#dbe2ec;color:#536176;background:transparent}:root[data-theme=light] .history-item:hover{background:#e1e9f4;color:#253044}:root[data-theme=light] .history-item small,:root[data-theme=light] .history-note,:root[data-theme=light] .history-empty{color:#68768a}:root[data-theme=light] .history-kind{color:#2459a6}: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] .pane-close:hover{color:#c53030}:root[data-theme=light] .pane-mic.listening{color:#c53030}:root[data-theme=light] .pane-mic.transcribing{color:#b7791f}: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}
|