zerogterm 0.2.0-alpha.1 → 0.2.0-alpha.2
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 +82 -29
- package/dist/main/main/ssh-inventory.js +116 -0
- package/dist/renderer/assets/index-5pgRaxBt.css +1 -0
- package/dist/renderer/assets/index-BWVjX0UX.js +17 -0
- package/dist/renderer/index.html +2 -2
- package/package.json +2 -2
- 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'); }
|
|
@@ -35,6 +35,34 @@ export function validateSshTarget(input) {
|
|
|
35
35
|
args.push(destination);
|
|
36
36
|
return { target: value, args };
|
|
37
37
|
}
|
|
38
|
+
export function parseWslDistributions(output) {
|
|
39
|
+
return output.split(/\r?\n/).slice(1).map((line) => line.replace(/^\*?\s*/, '').trim())
|
|
40
|
+
.map((line) => line.split(/\s{2,}/)[0]).filter((name) => /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$/.test(name));
|
|
41
|
+
}
|
|
42
|
+
export function shellBackendArgs(backend, distribution) {
|
|
43
|
+
if (backend === 'wsl') {
|
|
44
|
+
if (distribution && !/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$/.test(distribution.trim()))
|
|
45
|
+
throw new Error('Invalid WSL distribution name.');
|
|
46
|
+
return { backend, executable: process.platform === 'win32' ? 'wsl.exe' : 'wsl', args: distribution ? ['-d', distribution.trim()] : [], label: distribution ? `WSL · ${distribution.trim()}` : 'WSL', wslDistribution: distribution?.trim() };
|
|
47
|
+
}
|
|
48
|
+
if (backend === 'powershell')
|
|
49
|
+
return { backend, executable: process.platform === 'win32' ? 'pwsh.exe' : 'pwsh', args: [], label: 'PowerShell' };
|
|
50
|
+
if (backend === 'zsh')
|
|
51
|
+
return { backend, executable: 'zsh', args: [], label: 'zsh' };
|
|
52
|
+
return { backend: 'bash', executable: 'bash', args: [], label: 'bash' };
|
|
53
|
+
}
|
|
54
|
+
export async function discoverShellBackends() {
|
|
55
|
+
const result = [shellBackendArgs('bash')];
|
|
56
|
+
for (const backend of ['powershell', 'wsl']) {
|
|
57
|
+
const candidate = shellBackendArgs(backend);
|
|
58
|
+
try {
|
|
59
|
+
await execFileAsync(candidate.executable, backend === 'wsl' ? ['--status'] : ['-NoProfile', '-Command', '$PSVersionTable.PSVersion.ToString()']);
|
|
60
|
+
result.push(candidate);
|
|
61
|
+
}
|
|
62
|
+
catch { /* unavailable */ }
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
38
66
|
export function parseScreenList(output) {
|
|
39
67
|
return output
|
|
40
68
|
.split('\n')
|
|
@@ -54,7 +82,11 @@ export function parseScreenList(output) {
|
|
|
54
82
|
cwd: homedir(),
|
|
55
83
|
status: attached ? 'connected' : 'detached',
|
|
56
84
|
lastSeen: new Date().toISOString(),
|
|
57
|
-
persistence: 'screen'
|
|
85
|
+
persistence: 'screen',
|
|
86
|
+
backend: 'screen',
|
|
87
|
+
scope: 'local',
|
|
88
|
+
source: 'discovered',
|
|
89
|
+
screenName: name
|
|
58
90
|
}
|
|
59
91
|
];
|
|
60
92
|
});
|
|
@@ -71,6 +103,8 @@ export class ScreenService {
|
|
|
71
103
|
ptys = new Map();
|
|
72
104
|
sshSessions = new Map();
|
|
73
105
|
fallbackLocalSessions = new Map();
|
|
106
|
+
onEvent;
|
|
107
|
+
constructor(options = {}) { this.onEvent = options.onEvent; }
|
|
74
108
|
async available() {
|
|
75
109
|
try {
|
|
76
110
|
await execFileAsync('screen', ['--version']);
|
|
@@ -111,33 +145,24 @@ export class ScreenService {
|
|
|
111
145
|
return [];
|
|
112
146
|
}
|
|
113
147
|
}
|
|
114
|
-
async createLocal(
|
|
115
|
-
const
|
|
148
|
+
async createLocal(nameOrRequest, cwd = homedir()) {
|
|
149
|
+
const request = typeof nameOrRequest === 'string' ? { name: nameOrRequest, cwd } : nameOrRequest;
|
|
150
|
+
const safeName = validateSessionName(request.name);
|
|
151
|
+
const backend = request.backend ?? 'bash';
|
|
152
|
+
const shell = shellBackendArgs(backend, request.wslDistribution);
|
|
153
|
+
const requestedCwd = request.cwd ?? homedir();
|
|
154
|
+
if (backend !== 'bash' && !(await executableAvailable(shell.executable)))
|
|
155
|
+
throw new Error(`${shell.label} is not installed or unavailable.`);
|
|
116
156
|
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
|
-
};
|
|
157
|
+
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
158
|
this.fallbackLocalSessions.set(fallback.id, fallback);
|
|
159
|
+
this.onEvent?.('created', fallback, true);
|
|
128
160
|
return fallback;
|
|
129
161
|
}
|
|
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
|
-
};
|
|
162
|
+
await execFileAsync('screen', ['-dmS', safeName, shell.executable, ...shell.args], { cwd: requestedCwd });
|
|
163
|
+
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 };
|
|
164
|
+
this.onEvent?.('created', session, true);
|
|
165
|
+
return session;
|
|
141
166
|
}
|
|
142
167
|
async createSsh(targetInput, name) {
|
|
143
168
|
const { target } = validateSshTarget(targetInput);
|
|
@@ -150,9 +175,13 @@ export class ScreenService {
|
|
|
150
175
|
cwd: '~',
|
|
151
176
|
status: 'detached',
|
|
152
177
|
lastSeen: new Date().toISOString(),
|
|
153
|
-
sshTarget: target
|
|
178
|
+
sshTarget: target,
|
|
179
|
+
backend: 'ssh',
|
|
180
|
+
scope: 'remote',
|
|
181
|
+
source: 'active'
|
|
154
182
|
};
|
|
155
183
|
this.sshSessions.set(session.id, session);
|
|
184
|
+
this.onEvent?.('created', session, true);
|
|
156
185
|
return session;
|
|
157
186
|
}
|
|
158
187
|
attach(id, onData, onExit) {
|
|
@@ -164,11 +193,14 @@ export class ScreenService {
|
|
|
164
193
|
if (fallback) {
|
|
165
194
|
fallback.status = 'connected';
|
|
166
195
|
fallback.lastSeen = new Date().toISOString();
|
|
167
|
-
|
|
196
|
+
const shell = shellBackendArgs(fallback.backend ?? 'bash', fallback.wslDistribution);
|
|
197
|
+
this.spawnCommand(id, shell.executable, shell.args, onData, onExit, fallback.cwd);
|
|
198
|
+
this.onEvent?.('attached', fallback, true);
|
|
168
199
|
return { ...fallback };
|
|
169
200
|
}
|
|
170
201
|
const name = id.slice('local:'.length);
|
|
171
202
|
this.spawnCommand(id, 'screen', ['-x', name], onData, onExit);
|
|
203
|
+
this.onEvent?.('attached', existing, true);
|
|
172
204
|
return { ...existing, status: 'connected', persistence: 'screen' };
|
|
173
205
|
}
|
|
174
206
|
const session = this.sshSessions.get(id);
|
|
@@ -178,7 +210,15 @@ export class ScreenService {
|
|
|
178
210
|
const { args } = validateSshTarget(session.sshTarget);
|
|
179
211
|
session.status = 'connected';
|
|
180
212
|
session.lastSeen = new Date().toISOString();
|
|
181
|
-
|
|
213
|
+
try {
|
|
214
|
+
this.spawnCommand(id, 'ssh', args, onData, onExit);
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
session.status = 'error';
|
|
218
|
+
this.onEvent?.('reconnect-failed', session, false);
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
this.onEvent?.('attached', session, true);
|
|
182
222
|
return { ...session };
|
|
183
223
|
}
|
|
184
224
|
getSession(id) {
|
|
@@ -229,11 +269,15 @@ export class ScreenService {
|
|
|
229
269
|
proc.onExit(() => {
|
|
230
270
|
this.ptys.delete(sessionId);
|
|
231
271
|
const fallback = this.fallbackLocalSessions.get(sessionId);
|
|
232
|
-
if (fallback)
|
|
272
|
+
if (fallback) {
|
|
233
273
|
fallback.status = 'detached';
|
|
274
|
+
this.onEvent?.('detached', fallback, false);
|
|
275
|
+
}
|
|
234
276
|
const ssh = this.sshSessions.get(sessionId);
|
|
235
|
-
if (ssh)
|
|
277
|
+
if (ssh) {
|
|
236
278
|
ssh.status = 'detached';
|
|
279
|
+
this.onEvent?.('detached', ssh, false);
|
|
280
|
+
}
|
|
237
281
|
onExit(fallback
|
|
238
282
|
? 'Terminal detached. This process-only local session will not survive app exit; install screen for persistence.'
|
|
239
283
|
: 'Terminal detached. Local screen sessions remain alive; SSH sessions end with the connection.');
|
|
@@ -259,8 +303,17 @@ export class ScreenService {
|
|
|
259
303
|
* discoverable via `screen -ls`; SSH and process-only sessions end.
|
|
260
304
|
*/
|
|
261
305
|
close(sessionId) {
|
|
306
|
+
const session = this.getSession(sessionId);
|
|
262
307
|
this.detach(sessionId);
|
|
263
308
|
this.sshSessions.delete(sessionId);
|
|
264
309
|
this.fallbackLocalSessions.delete(sessionId);
|
|
310
|
+
this.onEvent?.('closed', session, false);
|
|
265
311
|
}
|
|
266
312
|
}
|
|
313
|
+
async function executableAvailable(executable) { try {
|
|
314
|
+
await execFileAsync(executable, ['--version']);
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
return false;
|
|
319
|
+
} }
|
|
@@ -0,0 +1,116 @@
|
|
|
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
|
+
const USER = /^[A-Za-z0-9._-]{1,64}$/;
|
|
6
|
+
const SCREEN = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,48}$/;
|
|
7
|
+
function words(value) {
|
|
8
|
+
const result = [];
|
|
9
|
+
const re = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^']*)'|(\S+)/g;
|
|
10
|
+
for (let match; (match = re.exec(value));)
|
|
11
|
+
result.push((match[1] ?? match[2] ?? match[3]).replace(/\\([\\"'])/g, '$1'));
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
export function parseSshConfig(text, source) {
|
|
15
|
+
const entries = [];
|
|
16
|
+
let current = [];
|
|
17
|
+
const flush = () => { entries.push(...current); current = []; };
|
|
18
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
19
|
+
const line = raw.replace(/\s+#.*$/, '').trim();
|
|
20
|
+
if (!line || line.startsWith('#'))
|
|
21
|
+
continue;
|
|
22
|
+
const match = line.match(/^([^\s=]+)\s*(?:=\s*|\s+)(.*)$/);
|
|
23
|
+
if (!match)
|
|
24
|
+
continue;
|
|
25
|
+
const key = match[1].toLowerCase();
|
|
26
|
+
const args = words(match[2].trim());
|
|
27
|
+
if (key === 'host') {
|
|
28
|
+
flush();
|
|
29
|
+
const aliases = args.filter((alias) => !alias.includes('*') && !alias.includes('?') && TOKEN.test(alias));
|
|
30
|
+
current = aliases.map((alias) => ({ alias, source }));
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (!current.length || !args[0])
|
|
34
|
+
continue;
|
|
35
|
+
const value = args[0];
|
|
36
|
+
for (const entry of current) {
|
|
37
|
+
if (key === 'hostname' && !entry.hostName)
|
|
38
|
+
entry.hostName = value;
|
|
39
|
+
else if (key === 'user' && !entry.user && USER.test(value))
|
|
40
|
+
entry.user = value;
|
|
41
|
+
else if (key === 'port' && !entry.port && /^\d+$/.test(value) && Number(value) >= 1 && Number(value) <= 65535)
|
|
42
|
+
entry.port = Number(value);
|
|
43
|
+
else if (key === 'identityfile' && !entry.identityFile)
|
|
44
|
+
entry.identityFile = value;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
flush();
|
|
48
|
+
return entries;
|
|
49
|
+
}
|
|
50
|
+
export async function listKnownConnections(path = `${homedir()}/.ssh/config`) {
|
|
51
|
+
try {
|
|
52
|
+
return parseSshConfig(await readFile(path, 'utf8'), path);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export function validateKnownConnection(input) {
|
|
59
|
+
if (!input || typeof input !== 'object')
|
|
60
|
+
throw new Error('Invalid SSH connection.');
|
|
61
|
+
const value = input;
|
|
62
|
+
if (typeof value.alias !== 'string' || !TOKEN.test(value.alias))
|
|
63
|
+
throw new Error('Invalid SSH alias.');
|
|
64
|
+
const result = { alias: value.alias };
|
|
65
|
+
if (value.hostName !== undefined) {
|
|
66
|
+
if (typeof value.hostName !== 'string' || !HOST.test(value.hostName))
|
|
67
|
+
throw new Error('Invalid SSH hostname.');
|
|
68
|
+
result.hostName = value.hostName;
|
|
69
|
+
}
|
|
70
|
+
if (value.user !== undefined) {
|
|
71
|
+
if (typeof value.user !== 'string' || !USER.test(value.user))
|
|
72
|
+
throw new Error('Invalid SSH user.');
|
|
73
|
+
result.user = value.user;
|
|
74
|
+
}
|
|
75
|
+
if (value.port !== undefined) {
|
|
76
|
+
if (!Number.isInteger(value.port) || value.port < 1 || value.port > 65535)
|
|
77
|
+
throw new Error('Invalid SSH port.');
|
|
78
|
+
result.port = value.port;
|
|
79
|
+
}
|
|
80
|
+
if (value.identityFile !== undefined) {
|
|
81
|
+
if (typeof value.identityFile !== 'string' || value.identityFile.length > 4096 || /[\0\r\n]/.test(value.identityFile))
|
|
82
|
+
throw new Error('Invalid identity file metadata.');
|
|
83
|
+
result.identityFile = value.identityFile;
|
|
84
|
+
}
|
|
85
|
+
if (value.source !== undefined && typeof value.source === 'string')
|
|
86
|
+
result.source = value.source;
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
function sshArgs(connection) {
|
|
90
|
+
const c = validateKnownConnection(connection);
|
|
91
|
+
const destination = c.user ? `${c.user}@${c.hostName ?? c.alias}` : (c.hostName ?? c.alias);
|
|
92
|
+
const args = [];
|
|
93
|
+
if (c.port)
|
|
94
|
+
args.push('-p', String(c.port));
|
|
95
|
+
args.push(destination, '--');
|
|
96
|
+
return args;
|
|
97
|
+
}
|
|
98
|
+
export function buildRemoteScreenDiscoveryArgs(connection) {
|
|
99
|
+
return { file: 'ssh', args: [...sshArgs(connection), 'screen', '-ls'] };
|
|
100
|
+
}
|
|
101
|
+
export function buildRemoteScreenAttachArgs(connection, screenName) {
|
|
102
|
+
if (!SCREEN.test(screenName))
|
|
103
|
+
throw new Error('Invalid screen session name.');
|
|
104
|
+
return { file: 'ssh', args: [...sshArgs(connection), 'screen', '-x', screenName] };
|
|
105
|
+
}
|
|
106
|
+
export function parseRemoteScreenList(output, host) {
|
|
107
|
+
if (!TOKEN.test(host))
|
|
108
|
+
throw new Error('Invalid remote host identity.');
|
|
109
|
+
return output.split(/\r?\n/).flatMap((line) => {
|
|
110
|
+
const match = line.trim().match(/^\d+\.(\S+)\s+\(([^)]+)\)/);
|
|
111
|
+
if (!match || !SCREEN.test(match[1]))
|
|
112
|
+
return [];
|
|
113
|
+
const name = match[1];
|
|
114
|
+
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 }];
|
|
115
|
+
});
|
|
116
|
+
}
|
|
@@ -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}
|