@surenkid/acli-hub 1.0.5 → 1.0.6
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/package.json +1 -1
- package/server/public/assets/{PreviewDialog-CBFNM0bh.js → PreviewDialog-83b2IRO1.js} +1 -1
- package/server/public/assets/index-C_n2nSEM.css +1 -0
- package/server/public/assets/{index-DjL_u17Y.js → index-DhBFQikm.js} +5 -5
- package/server/public/index.html +2 -2
- package/server/services/session-activity.test.js +298 -0
- package/server/services/session-manager.js +55 -10
- package/server/public/assets/index-CtbQEC9E.css +0 -1
package/server/public/index.html
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
|
|
11
11
|
<link rel="manifest" href="/site.webmanifest" />
|
|
12
12
|
<title>ACLI Hub</title>
|
|
13
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
13
|
+
<script type="module" crossorigin src="/assets/index-DhBFQikm.js"></script>
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/assets/index-C_n2nSEM.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
17
17
|
<div id="app"></div>
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs/promises');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const childProcess = require('node:child_process');
|
|
7
|
+
|
|
8
|
+
const NOW = 1800000000000;
|
|
9
|
+
|
|
10
|
+
function pollingManager(t, getOutput, getNow = () => NOW) {
|
|
11
|
+
t.mock.method(childProcess, 'execSync', command => {
|
|
12
|
+
assert.match(command, /^tmux list-panes -a -F /);
|
|
13
|
+
assert.match(command, /#\{pane_last_output_time\}/);
|
|
14
|
+
return Buffer.from(getOutput());
|
|
15
|
+
});
|
|
16
|
+
t.mock.method(Date, 'now', getNow);
|
|
17
|
+
delete require.cache[require.resolve('./session-manager')];
|
|
18
|
+
const SessionManager = require('./session-manager');
|
|
19
|
+
const manager = new SessionManager();
|
|
20
|
+
manager.sessions.set('demo', {
|
|
21
|
+
name: 'demo', status: 'running', outputActive: false,
|
|
22
|
+
title: 'Working title', command: 'node', process: null
|
|
23
|
+
});
|
|
24
|
+
return manager;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test('three consecutive output updates turn active, with the same eight-second expiry', t => {
|
|
28
|
+
let now = NOW + 1000;
|
|
29
|
+
let lastOutput = NOW / 1000;
|
|
30
|
+
let queries = 0;
|
|
31
|
+
const manager = pollingManager(t, () => {
|
|
32
|
+
queries++;
|
|
33
|
+
return `demo\t1\t1\t%1\tnode\t${lastOutput}\tWorking\ttitle\n`;
|
|
34
|
+
}, () => now);
|
|
35
|
+
const events = [];
|
|
36
|
+
manager.on('session:title', value => events.push(value));
|
|
37
|
+
|
|
38
|
+
manager.pollPaneInfo();
|
|
39
|
+
assert.equal(manager.getSession('demo').outputActive, false, 'the first sample only establishes a baseline');
|
|
40
|
+
assert.equal(events.length, 0);
|
|
41
|
+
for (let sample = 1; sample <= 3; sample++) {
|
|
42
|
+
now += 2000;
|
|
43
|
+
lastOutput += 2;
|
|
44
|
+
manager.pollPaneInfo();
|
|
45
|
+
assert.equal(manager.getSession('demo').outputActive, sample === 3);
|
|
46
|
+
assert.equal(events.length, sample === 3 ? 1 : 0);
|
|
47
|
+
}
|
|
48
|
+
assert.equal(events.length, 1);
|
|
49
|
+
assert.equal(events[0].title, 'Working title');
|
|
50
|
+
assert.equal(events[0].status, 'running');
|
|
51
|
+
assert.equal('process' in events[0], false);
|
|
52
|
+
|
|
53
|
+
now += 2000;
|
|
54
|
+
lastOutput += 2;
|
|
55
|
+
manager.pollPaneInfo();
|
|
56
|
+
assert.equal(events.length, 1, 'continued output does not need another activity message');
|
|
57
|
+
|
|
58
|
+
now = lastOutput * 1000 + 7999;
|
|
59
|
+
manager.pollPaneInfo();
|
|
60
|
+
assert.equal(manager.getSession('demo').outputActive, true);
|
|
61
|
+
assert.equal(events.length, 1);
|
|
62
|
+
|
|
63
|
+
now++;
|
|
64
|
+
manager.pollPaneInfo();
|
|
65
|
+
assert.equal(manager.getSession('demo').outputActive, false);
|
|
66
|
+
assert.equal(events.length, 2, 'activity-only changes use the existing title event');
|
|
67
|
+
manager.pollPaneInfo();
|
|
68
|
+
assert.equal(events.length, 2);
|
|
69
|
+
|
|
70
|
+
for (let sample = 1; sample <= 3; sample++) {
|
|
71
|
+
now += 2000;
|
|
72
|
+
lastOutput = Math.floor(now / 1000);
|
|
73
|
+
manager.pollPaneInfo();
|
|
74
|
+
assert.equal(manager.getSession('demo').outputActive, sample === 3, 'idle sessions must qualify again');
|
|
75
|
+
}
|
|
76
|
+
assert.equal(manager.getSession('demo').outputActive, true);
|
|
77
|
+
assert.equal(events.length, 3);
|
|
78
|
+
assert.equal(manager.getSession('demo').status, 'running');
|
|
79
|
+
assert.equal(queries, 11, 'each poll still makes exactly one tmux query');
|
|
80
|
+
assert.equal(manager.titleTimer, null, 'activity detection does not add a timer');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('one-off redraws and repeated recent timestamps never turn active', t => {
|
|
84
|
+
let now = NOW;
|
|
85
|
+
let lastOutput = NOW / 1000;
|
|
86
|
+
const manager = pollingManager(t,
|
|
87
|
+
() => `demo\t1\t1\t%1\tnode\t${lastOutput}\tWorking title\n`, () => now);
|
|
88
|
+
const events = [];
|
|
89
|
+
manager.on('session:title', value => events.push(value));
|
|
90
|
+
manager.pollPaneInfo();
|
|
91
|
+
now += 2000;
|
|
92
|
+
lastOutput += 2;
|
|
93
|
+
manager.pollPaneInfo();
|
|
94
|
+
for (let sample = 0; sample < 5; sample++) {
|
|
95
|
+
now += 2000;
|
|
96
|
+
manager.pollPaneInfo();
|
|
97
|
+
assert.equal(manager.getSession('demo').outputActive, false);
|
|
98
|
+
}
|
|
99
|
+
assert.equal(events.length, 0, 'a short switch-triggered redraw causes no activity event');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a quiet sample breaks the consecutive output streak', t => {
|
|
103
|
+
let now = NOW;
|
|
104
|
+
let lastOutput = NOW / 1000;
|
|
105
|
+
const manager = pollingManager(t,
|
|
106
|
+
() => `demo\t1\t1\t%1\tnode\t${lastOutput}\tWorking title\n`, () => now);
|
|
107
|
+
const sample = changed => {
|
|
108
|
+
now += 2000;
|
|
109
|
+
if (changed) lastOutput = now / 1000;
|
|
110
|
+
manager.pollPaneInfo();
|
|
111
|
+
};
|
|
112
|
+
manager.pollPaneInfo();
|
|
113
|
+
sample(true);
|
|
114
|
+
sample(true);
|
|
115
|
+
assert.equal(manager.getSession('demo').outputActive, false);
|
|
116
|
+
sample(false);
|
|
117
|
+
for (let update = 1; update <= 3; update++) {
|
|
118
|
+
sample(true);
|
|
119
|
+
assert.equal(manager.getSession('demo').outputActive, update === 3);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('switching tmux panes establishes a new baseline instead of carrying activity across', t => {
|
|
124
|
+
let now = NOW;
|
|
125
|
+
let lastOutput = NOW / 1000;
|
|
126
|
+
let paneId = '%1';
|
|
127
|
+
const manager = pollingManager(t,
|
|
128
|
+
() => `demo\t1\t1\t${paneId}\tnode\t${lastOutput}\tWorking title\n`, () => now);
|
|
129
|
+
const sample = () => {
|
|
130
|
+
now += 2000;
|
|
131
|
+
lastOutput = now / 1000;
|
|
132
|
+
manager.pollPaneInfo();
|
|
133
|
+
};
|
|
134
|
+
manager.pollPaneInfo();
|
|
135
|
+
sample();
|
|
136
|
+
sample();
|
|
137
|
+
paneId = '%2';
|
|
138
|
+
sample();
|
|
139
|
+
assert.equal(manager.getSession('demo').outputActive, false);
|
|
140
|
+
for (let update = 1; update <= 3; update++) {
|
|
141
|
+
sample();
|
|
142
|
+
assert.equal(manager.getSession('demo').outputActive, update === 3);
|
|
143
|
+
}
|
|
144
|
+
paneId = '%1';
|
|
145
|
+
sample();
|
|
146
|
+
assert.equal(manager.getSession('demo').outputActive, false, 'a previously active pane also needs a fresh baseline');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('unsupported, invalid, old or future output timestamps fall back to inactive', t => {
|
|
150
|
+
let timestamp = '';
|
|
151
|
+
const manager = pollingManager(t, () => `demo\t1\t1\t%1\tnode\t${timestamp}\tWorking title\n`);
|
|
152
|
+
for (timestamp of ['', '0', '-1', 'invalid', 'Infinity', '1800000001', '1799999992']) {
|
|
153
|
+
manager.getSession('demo').outputActive = true;
|
|
154
|
+
manager.pollPaneInfo();
|
|
155
|
+
assert.equal(manager.getSession('demo').outputActive, false, `timestamp: ${timestamp}`);
|
|
156
|
+
assert.equal(manager.getSession('demo').status, 'running');
|
|
157
|
+
assert.equal(manager.getSession('demo').title, 'Working title');
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('only the active pane counts and stopped or missing sessions cannot be active', t => {
|
|
162
|
+
const manager = pollingManager(t, () => [
|
|
163
|
+
'demo\t1\t1\t%1\tnode\t1799999990\tWorking title',
|
|
164
|
+
'demo\t1\t0\t%2\tnode\t1800000000\tInactive pane',
|
|
165
|
+
'demo\t0\t1\t%3\tnode\t1800000000\tInactive window',
|
|
166
|
+
'stopped\t1\t1\t%4\tnode\t1800000000\tStopped title',
|
|
167
|
+
'other\t1\t1\t%5\tnode\t1800000000\tUnmanaged title'
|
|
168
|
+
].join('\n'));
|
|
169
|
+
manager.sessions.set('stopped', {
|
|
170
|
+
name: 'stopped', status: 'stopped', outputActive: true, title: null, command: null
|
|
171
|
+
});
|
|
172
|
+
manager.sessions.set('missing', {
|
|
173
|
+
name: 'missing', status: 'running', outputActive: true, title: null, command: null
|
|
174
|
+
});
|
|
175
|
+
manager.pollPaneInfo();
|
|
176
|
+
assert.equal(manager.getSession('demo').outputActive, false);
|
|
177
|
+
assert.equal(manager.getSession('demo').title, 'Working title');
|
|
178
|
+
assert.equal(manager.getSession('stopped').outputActive, false);
|
|
179
|
+
assert.equal(manager.getSession('stopped').status, 'stopped');
|
|
180
|
+
assert.equal(manager.getSession('missing').outputActive, false);
|
|
181
|
+
assert.equal(manager.sessions.has('other'), false);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('tmux query failures clear stale activity without losing existing titles', t => {
|
|
185
|
+
let unavailable = true;
|
|
186
|
+
let now = NOW;
|
|
187
|
+
let lastOutput = NOW / 1000;
|
|
188
|
+
const manager = pollingManager(t, () => {
|
|
189
|
+
if (unavailable) throw new Error('tmux unavailable');
|
|
190
|
+
return `demo\t1\t1\t%1\tnode\t${lastOutput}\tWorking title\n`;
|
|
191
|
+
}, () => now);
|
|
192
|
+
manager.getSession('demo').outputActive = true;
|
|
193
|
+
manager.outputSamples.set('demo', { paneId: '%1', lastOutputTime: NOW - 2000, consecutive: 3 });
|
|
194
|
+
const events = [];
|
|
195
|
+
manager.on('session:title', value => events.push(value));
|
|
196
|
+
manager.pollPaneInfo();
|
|
197
|
+
assert.equal(manager.getSession('demo').outputActive, false);
|
|
198
|
+
assert.equal(manager.getSession('demo').title, 'Working title');
|
|
199
|
+
assert.equal(manager.getSession('demo').command, 'node');
|
|
200
|
+
assert.equal(manager.getSession('demo').status, 'running');
|
|
201
|
+
assert.equal(manager.outputSamples.size, 0);
|
|
202
|
+
assert.equal(events.length, 1);
|
|
203
|
+
manager.pollPaneInfo();
|
|
204
|
+
assert.equal(events.length, 1);
|
|
205
|
+
unavailable = false;
|
|
206
|
+
manager.pollPaneInfo();
|
|
207
|
+
assert.equal(manager.getSession('demo').outputActive, false, 'recovery only establishes a new baseline');
|
|
208
|
+
assert.equal(events.length, 1);
|
|
209
|
+
for (let sample = 0; sample < 3; sample++) {
|
|
210
|
+
now += 2000;
|
|
211
|
+
lastOutput += 2;
|
|
212
|
+
manager.pollPaneInfo();
|
|
213
|
+
}
|
|
214
|
+
assert.equal(manager.getSession('demo').outputActive, true);
|
|
215
|
+
assert.equal(events.length, 2);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('stopping ttyd immediately clears output activity', t => {
|
|
219
|
+
const manager = pollingManager(t, () => '');
|
|
220
|
+
const session = manager.getSession('demo');
|
|
221
|
+
const kill = t.mock.fn();
|
|
222
|
+
Object.assign(session, { outputActive: true, process: { kill }, port: 7681, pid: 42 });
|
|
223
|
+
manager.outputSamples.set('demo', { paneId: '%1', lastOutputTime: NOW, consecutive: 3 });
|
|
224
|
+
manager.portManager.usedPorts.add(7681);
|
|
225
|
+
const stopped = manager.stop('demo');
|
|
226
|
+
assert.equal(stopped.status, 'stopped');
|
|
227
|
+
assert.equal(stopped.outputActive, false);
|
|
228
|
+
assert.equal(stopped.pid, null);
|
|
229
|
+
assert.equal(stopped.port, null);
|
|
230
|
+
assert.equal(manager.portManager.usedPorts.has(7681), false);
|
|
231
|
+
assert.equal(manager.outputSamples.has('demo'), false);
|
|
232
|
+
assert.deepEqual(kill.mock.calls[0].arguments, ['SIGTERM']);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('real tmux requires sustained output before turning active and retains eight-second expiry', async t => {
|
|
236
|
+
const runFile = childProcess.execFileSync;
|
|
237
|
+
const runShell = childProcess.execSync;
|
|
238
|
+
const realNow = Date.now;
|
|
239
|
+
try { runFile('tmux', ['-V'], { stdio: 'ignore' }); } catch (err) {
|
|
240
|
+
if (err.code === 'ENOENT') return t.skip('tmux is not installed');
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
243
|
+
const directory = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'hub-activity-test-')));
|
|
244
|
+
const socket = path.join(directory, 'tmux.sock');
|
|
245
|
+
const tmux = (...args) => runFile('tmux', ['-S', socket, '-f', '/dev/null', ...args], {
|
|
246
|
+
encoding: 'utf8', timeout: 5000
|
|
247
|
+
});
|
|
248
|
+
t.after(async () => {
|
|
249
|
+
try { tmux('kill-server'); } catch (_) { /* already stopped */ }
|
|
250
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
251
|
+
});
|
|
252
|
+
tmux('new-session', '-d', '-s', 'demo', '/bin/sh');
|
|
253
|
+
tmux('send-keys', '-t', '=demo:', "printf 'activity\\r'", 'Enter');
|
|
254
|
+
let lastOutput;
|
|
255
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
256
|
+
lastOutput = tmux('display-message', '-p', '-t', '=demo:', '#{pane_last_output_time}').trim();
|
|
257
|
+
if (Number(lastOutput) > 0) break;
|
|
258
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
259
|
+
}
|
|
260
|
+
if (!lastOutput) return t.skip('tmux does not expose pane_last_output_time');
|
|
261
|
+
assert.ok(Number(lastOutput) > 0, 'tmux returns the last output timestamp in seconds');
|
|
262
|
+
|
|
263
|
+
t.mock.method(childProcess, 'execSync', (command, options) =>
|
|
264
|
+
runShell(command.replace(/^tmux /, `tmux -S ${socket} -f /dev/null `), options));
|
|
265
|
+
let now = Number(lastOutput) * 1000 + 1000;
|
|
266
|
+
t.mock.method(Date, 'now', () => now);
|
|
267
|
+
delete require.cache[require.resolve('./session-manager')];
|
|
268
|
+
const SessionManager = require('./session-manager');
|
|
269
|
+
const manager = new SessionManager();
|
|
270
|
+
manager.sessions.set('demo', { name: 'demo', status: 'running', outputActive: false });
|
|
271
|
+
manager.pollPaneInfo();
|
|
272
|
+
assert.equal(manager.getSession('demo').outputActive, false, 'opening a pane does not immediately turn it active');
|
|
273
|
+
for (let sample = 1; sample <= 3; sample++) {
|
|
274
|
+
const previous = Number(lastOutput);
|
|
275
|
+
// tmux timestamps have second precision; produce each update in a new second.
|
|
276
|
+
while (Math.floor(realNow() / 1000) <= previous) {
|
|
277
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
278
|
+
}
|
|
279
|
+
tmux('send-keys', '-t', '=demo:', "printf 'activity\\r'", 'Enter');
|
|
280
|
+
for (let attempt = 0; attempt < 25; attempt++) {
|
|
281
|
+
lastOutput = tmux('display-message', '-p', '-t', '=demo:', '#{pane_last_output_time}').trim();
|
|
282
|
+
if (Number(lastOutput) > previous) break;
|
|
283
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
284
|
+
}
|
|
285
|
+
assert.ok(Number(lastOutput) > previous);
|
|
286
|
+
now = Number(lastOutput) * 1000 + 1000;
|
|
287
|
+
manager.pollPaneInfo();
|
|
288
|
+
assert.equal(manager.getSession('demo').outputActive, sample === 3);
|
|
289
|
+
}
|
|
290
|
+
assert.equal(manager.getSession('demo').status, 'running');
|
|
291
|
+
now = Number(lastOutput) * 1000 + 7999;
|
|
292
|
+
manager.pollPaneInfo();
|
|
293
|
+
assert.equal(manager.getSession('demo').outputActive, true);
|
|
294
|
+
now++;
|
|
295
|
+
manager.pollPaneInfo();
|
|
296
|
+
assert.equal(manager.getSession('demo').outputActive, false);
|
|
297
|
+
assert.equal(manager.getSession('demo').status, 'running');
|
|
298
|
+
});
|
|
@@ -4,6 +4,8 @@ const EventEmitter = require('events');
|
|
|
4
4
|
const PortManager = require('./port-manager');
|
|
5
5
|
|
|
6
6
|
const SESSION_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
|
7
|
+
const OUTPUT_ACTIVE_WINDOW_MS = 8000;
|
|
8
|
+
const OUTPUT_ACTIVE_MIN_SAMPLES = 3;
|
|
7
9
|
|
|
8
10
|
const KNOWN_SHELLS = [
|
|
9
11
|
{ id: 'zsh', name: 'Zsh', paths: ['/bin/zsh', '/usr/bin/zsh', '/usr/local/bin/zsh', '/opt/homebrew/bin/zsh'] },
|
|
@@ -58,6 +60,7 @@ class SessionManager extends EventEmitter {
|
|
|
58
60
|
this.shells = detectShells();
|
|
59
61
|
this.nameCounter = 0;
|
|
60
62
|
this.titleTimer = null;
|
|
63
|
+
this.outputSamples = new Map();
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
generateName(shell) {
|
|
@@ -120,11 +123,15 @@ class SessionManager extends EventEmitter {
|
|
|
120
123
|
session.port = port;
|
|
121
124
|
session.pid = proc.pid;
|
|
122
125
|
session.status = 'running';
|
|
126
|
+
session.outputActive = false;
|
|
127
|
+
this.outputSamples.delete(session.name);
|
|
123
128
|
session.process = proc;
|
|
124
129
|
|
|
125
130
|
proc.on('exit', (code) => {
|
|
126
131
|
if (session.status === 'running') {
|
|
127
132
|
session.status = 'stopped';
|
|
133
|
+
session.outputActive = false;
|
|
134
|
+
this.outputSamples.delete(session.name);
|
|
128
135
|
session.pid = null;
|
|
129
136
|
session.process = null;
|
|
130
137
|
this.portManager.release(port);
|
|
@@ -141,6 +148,8 @@ class SessionManager extends EventEmitter {
|
|
|
141
148
|
// The exit handler may already have cleaned up if ttyd died early.
|
|
142
149
|
if (session.process === proc) {
|
|
143
150
|
session.status = 'stopped';
|
|
151
|
+
session.outputActive = false;
|
|
152
|
+
this.outputSamples.delete(session.name);
|
|
144
153
|
session.pid = null;
|
|
145
154
|
session.process = null;
|
|
146
155
|
this.portManager.release(port);
|
|
@@ -214,6 +223,8 @@ class SessionManager extends EventEmitter {
|
|
|
214
223
|
}
|
|
215
224
|
session.process.kill('SIGTERM');
|
|
216
225
|
session.status = 'stopped';
|
|
226
|
+
session.outputActive = false;
|
|
227
|
+
this.outputSamples.delete(session.name);
|
|
217
228
|
session.pid = null;
|
|
218
229
|
session.process = null;
|
|
219
230
|
this.portManager.release(session.port);
|
|
@@ -235,6 +246,7 @@ class SessionManager extends EventEmitter {
|
|
|
235
246
|
// tmux session may not exist
|
|
236
247
|
}
|
|
237
248
|
this.sessions.delete(name);
|
|
249
|
+
this.outputSamples.delete(name);
|
|
238
250
|
this.emit('session:deleted', { name });
|
|
239
251
|
return { name };
|
|
240
252
|
}
|
|
@@ -363,40 +375,72 @@ class SessionManager extends EventEmitter {
|
|
|
363
375
|
return rest;
|
|
364
376
|
}
|
|
365
377
|
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
// sessions whose info actually changed. Titles are the status channel CLIs
|
|
370
|
-
// maintain (OSC 0/2); the command name is the fallback when a CLI does not
|
|
371
|
-
// set one.
|
|
378
|
+
// Read titles, commands and recent output activity in one tmux query.
|
|
379
|
+
// Reuse session:title for changes, including activity-only transitions.
|
|
380
|
+
// Output activity is only a visual hint, not a task-completion signal.
|
|
372
381
|
pollPaneInfo() {
|
|
373
382
|
let output;
|
|
374
383
|
try {
|
|
375
384
|
output = execSync(
|
|
376
|
-
`tmux list-panes -a -F '#{session_name}\t#{window_active}\t#{pane_active}\t#{pane_current_command}\t#{pane_title}'`,
|
|
385
|
+
`tmux list-panes -a -F '#{session_name}\t#{window_active}\t#{pane_active}\t#{pane_id}\t#{pane_current_command}\t#{pane_last_output_time}\t#{pane_title}'`,
|
|
377
386
|
{ stdio: ['ignore', 'pipe', 'ignore'], env: SPAWN_ENV }
|
|
378
387
|
).toString();
|
|
379
388
|
} catch (_) {
|
|
389
|
+
this.outputSamples.clear();
|
|
390
|
+
// Keep the existing title, but do not leave a stale yellow indicator.
|
|
391
|
+
for (const session of this.sessions.values()) {
|
|
392
|
+
if (!session.outputActive) continue;
|
|
393
|
+
session.outputActive = false;
|
|
394
|
+
this.emit('session:title', this.serialize(session));
|
|
395
|
+
}
|
|
380
396
|
return; // no tmux server running
|
|
381
397
|
}
|
|
382
398
|
|
|
383
399
|
const active = new Map();
|
|
384
400
|
for (const line of output.trim().split('\n')) {
|
|
385
401
|
if (!line) continue;
|
|
386
|
-
const [name, windowActive, paneActive, command, ...title] = line.split('\t');
|
|
402
|
+
const [name, windowActive, paneActive, paneId, command, lastOutputTime, ...title] = line.split('\t');
|
|
387
403
|
if (windowActive !== '1' || paneActive !== '1') continue;
|
|
388
|
-
active.set(name, {
|
|
404
|
+
active.set(name, {
|
|
405
|
+
paneId,
|
|
406
|
+
command: command || null,
|
|
407
|
+
title: title.join('\t') || null,
|
|
408
|
+
lastOutputTime: Number(lastOutputTime) * 1000
|
|
409
|
+
});
|
|
389
410
|
}
|
|
390
411
|
|
|
412
|
+
const now = Date.now();
|
|
391
413
|
for (const session of this.sessions.values()) {
|
|
392
414
|
const info = active.get(session.name);
|
|
393
415
|
const title = info?.title
|
|
394
416
|
? info.title.replace(/[\t\r\n]+/g, ' ').trim().slice(0, 200)
|
|
395
417
|
: null;
|
|
396
418
|
const command = info?.command || null;
|
|
397
|
-
|
|
419
|
+
// Unsupported or missing tmux metadata falls back to the green dot.
|
|
420
|
+
let outputActive = false;
|
|
421
|
+
if (session.status === 'running' && info?.paneId
|
|
422
|
+
&& Number.isFinite(info.lastOutputTime)
|
|
423
|
+
&& info.lastOutputTime >= 0 && info.lastOutputTime <= now) {
|
|
424
|
+
const previous = this.outputSamples.get(session.name);
|
|
425
|
+
const samePane = previous?.paneId === info.paneId;
|
|
426
|
+
const recent = info.lastOutputTime > 0 && now - info.lastOutputTime < OUTPUT_ACTIVE_WINDOW_MS;
|
|
427
|
+
// The first sample is only a baseline. Repeatedly seeing the same
|
|
428
|
+
// timestamp does not count as continued output, even while recent.
|
|
429
|
+
const consecutive = recent && samePane && info.lastOutputTime > previous.lastOutputTime
|
|
430
|
+
? Math.min(previous.consecutive + 1, OUTPUT_ACTIVE_MIN_SAMPLES)
|
|
431
|
+
: 0;
|
|
432
|
+
this.outputSamples.set(session.name, {
|
|
433
|
+
paneId: info.paneId, lastOutputTime: info.lastOutputTime, consecutive
|
|
434
|
+
});
|
|
435
|
+
outputActive = recent && samePane
|
|
436
|
+
&& (session.outputActive || consecutive >= OUTPUT_ACTIVE_MIN_SAMPLES);
|
|
437
|
+
} else {
|
|
438
|
+
this.outputSamples.delete(session.name);
|
|
439
|
+
}
|
|
440
|
+
if (session.title !== title || session.command !== command || session.outputActive !== outputActive) {
|
|
398
441
|
session.title = title;
|
|
399
442
|
session.command = command;
|
|
443
|
+
session.outputActive = outputActive;
|
|
400
444
|
this.emit('session:title', this.serialize(session));
|
|
401
445
|
}
|
|
402
446
|
}
|
|
@@ -409,6 +453,7 @@ class SessionManager extends EventEmitter {
|
|
|
409
453
|
}
|
|
410
454
|
|
|
411
455
|
stopTitlePolling() {
|
|
456
|
+
this.outputSamples.clear();
|
|
412
457
|
if (this.titleTimer) {
|
|
413
458
|
clearInterval(this.titleTimer);
|
|
414
459
|
this.titleTimer = null;
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
:root{--bg-primary: #0f172a;--bg-secondary: #1e293b;--bg-tertiary: #334155;--bg-card: #1e293b;--bg-card-hover: #334155;--border-color: #334155;--border-color-hover: #475569;--text-primary: #f8fafc;--text-secondary: #94a3b8;--text-tertiary: #64748b;--accent-primary: #3b82f6;--accent-hover: #2563eb;--accent-dim: rgba(59, 130, 246, .15);--success: #10b981;--warning: #f59e0b;--danger: #ef4444;--sidebar-width: 260px;--sidebar-collapsed-width: 0px;--toolbar-height: 56px;--radius-sm: 4px;--radius-md: 6px;--radius-lg: 8px;--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / .05);--shadow-md: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1)}*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol;background:var(--bg-primary);color:var(--text-primary);overflow:hidden;height:100vh;font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased}#app{height:100vh;display:flex;flex-direction:column}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--bg-tertiary);border-radius:3px}::-webkit-scrollbar-thumb:hover{background:var(--text-tertiary)}.glass{background:#1e293bb3;-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-bottom:1px solid var(--border-color)}.btn{display:inline-flex;align-items:center;justify-content:center;padding:8px 16px;border-radius:var(--radius-md);font-weight:500;cursor:pointer;transition:all .2s;border:1px solid transparent;outline:none}.btn-primary{background:var(--accent-primary);color:#fff}.btn-primary:hover{background:var(--accent-hover)}input{background:var(--bg-primary);border:1px solid var(--border-color);color:var(--text-primary);padding:8px 12px;border-radius:var(--radius-md);outline:none;transition:border-color .2s}input:focus{border-color:var(--accent-primary)}.session-card[data-v-c39ba5d7]{display:flex;align-items:center;gap:12px;padding:10px 12px;border-radius:var(--radius-md);cursor:pointer;transition:all .2s;border:1px solid transparent;color:var(--text-secondary)}.session-card[data-v-c39ba5d7]:hover{background:var(--bg-card-hover);color:var(--text-primary)}.session-card.active[data-v-c39ba5d7]{background:var(--bg-tertiary);color:var(--text-primary);border-color:var(--border-color)}.status-indicator[data-v-c39ba5d7]{width:8px;height:8px;border-radius:50%;background:var(--text-tertiary);flex-shrink:0}.status-indicator.running[data-v-c39ba5d7]{background:var(--success);box-shadow:0 0 8px #10b98166}.card-content[data-v-c39ba5d7]{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}.card-top[data-v-c39ba5d7]{display:flex;align-items:center;gap:4px}.session-name[data-v-c39ba5d7]{font-size:14px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0;display:flex;align-items:baseline;gap:6px}.alias-text[data-v-c39ba5d7]{overflow:hidden;text-overflow:ellipsis}.real-name[data-v-c39ba5d7]{flex-shrink:1;max-width:45%;overflow:hidden;text-overflow:ellipsis;font-size:11px;font-weight:400;color:var(--text-tertiary)}.alias-input[data-v-c39ba5d7]{flex:1;min-width:0;padding:2px 6px;font-size:13px;color:var(--text-primary);background:var(--bg-primary);border:1px solid var(--border-color);border-radius:4px;outline:none}.alias-input[data-v-c39ba5d7]:focus{border-color:var(--accent-primary, #3b82f6)}.close-btn[data-v-c39ba5d7]{display:none;align-items:center;justify-content:center;background:none;border:none;color:var(--text-tertiary);cursor:pointer;padding:2px;border-radius:4px;flex-shrink:0}.session-card:hover .close-btn[data-v-c39ba5d7]{display:flex}.close-btn[data-v-c39ba5d7]:hover{background:var(--bg-tertiary);color:var(--danger)}.session-info[data-v-c39ba5d7]{display:flex;align-items:baseline;gap:8px;font-size:11px;color:var(--text-tertiary)}.pane-title[data-v-c39ba5d7]{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.confirm-bar[data-v-c39ba5d7]{display:flex;align-items:center;gap:6px;font-size:12px}.confirm-text[data-v-c39ba5d7]{color:var(--danger);font-weight:500}.confirm-yes[data-v-c39ba5d7],.confirm-no[data-v-c39ba5d7]{background:none;border:1px solid var(--border-color);border-radius:4px;padding:1px 8px;font-size:11px;cursor:pointer;color:var(--text-secondary)}.confirm-yes[data-v-c39ba5d7]:hover{background:var(--danger);border-color:var(--danger);color:#fff}.confirm-no[data-v-c39ba5d7]:hover{background:var(--bg-tertiary);color:var(--text-primary)}.session-card.collapsed[data-v-c39ba5d7]{justify-content:center;padding:12px}.session-card.collapsed .status-indicator[data-v-c39ba5d7]{width:10px;height:10px}li[data-v-7822209d]{list-style:none}.tree-row[data-v-7822209d]{display:flex;align-items:center;gap:5px;min-height:31px;padding:2px 6px;cursor:pointer;border-radius:3px;outline-offset:-2px}.tree-row[data-v-7822209d]:hover,.tree-row[data-v-7822209d]:focus-visible{background:var(--bg-tertiary)}.chevron[data-v-7822209d]{flex:0 0 10px;color:var(--text-secondary)}.file-icon[data-v-7822209d]{color:var(--text-secondary);font-size:18px;width:15px}.filename[data-v-7822209d]{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;flex:1;font-size:13px}.dotfile[data-v-7822209d]{opacity:.55}.more[data-v-7822209d]{background:transparent;color:var(--text-secondary);border:none;cursor:pointer;padding:0 4px;font-size:18px;opacity:0}.tree-row:hover .more[data-v-7822209d],.tree-row:focus-within .more[data-v-7822209d]{opacity:1}.tree-children[data-v-7822209d]{margin-left:14px;padding-left:5px;border-left:1px solid var(--border-color)}.tree-status[data-v-7822209d]{padding:5px 10px;color:var(--text-secondary);font-size:12px;overflow-wrap:anywhere}.tree-status button[data-v-7822209d]{background:none;color:var(--text-primary);border:none;cursor:pointer;text-decoration:underline}.error[data-v-7822209d]{color:var(--danger)}@media(hover:none){.more[data-v-7822209d]{opacity:1}}.file-dialog[data-v-5ceee09a]{margin:auto;width:min(480px,calc(100vw - 32px));max-height:calc(100dvh - 40px);overflow:auto;padding:24px;background:var(--bg-secondary);color:var(--text-primary);border:1px solid var(--border-color);border-radius:var(--radius-lg)}.file-dialog[data-v-5ceee09a]::backdrop{background:#0009;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}p[data-v-5ceee09a]{margin:12px 0;color:var(--text-secondary)}.operation-path[data-v-5ceee09a]{overflow-wrap:anywhere;font:12px/1.6 monospace}label[data-v-5ceee09a]{display:flex;flex-direction:column;gap:8px}.dialog-actions[data-v-5ceee09a]{display:flex;justify-content:flex-end;gap:8px;margin-top:20px}.btn[data-v-5ceee09a]{background:var(--bg-tertiary);color:var(--text-primary)}.btn-primary[data-v-5ceee09a]{background:var(--accent-primary)}.delete-btn[data-v-5ceee09a]{background:var(--danger);color:#fff}.error[data-v-5ceee09a]{color:var(--danger)}button[data-v-5ceee09a]:disabled{opacity:.5;cursor:default}.clipboard-overlay[data-v-317cb3e2]{position:fixed;inset:0;z-index:1000;display:flex;align-items:center;justify-content:center;background:#0009;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}.clipboard-dialog[data-v-317cb3e2]{width:min(520px,calc(100vw - 32px));padding:20px;background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:var(--radius-lg);box-shadow:var(--shadow-md)}h3[data-v-317cb3e2]{font-size:16px}p[data-v-317cb3e2]{margin:12px 0;color:var(--text-secondary)}textarea[data-v-317cb3e2]{width:100%;height:180px;resize:vertical;padding:12px;background:var(--bg-primary);color:var(--text-primary);border:1px solid var(--border-color);border-radius:var(--radius-md);font:13px/1.5 monospace}textarea[data-v-317cb3e2]:focus{outline:2px solid var(--accent-primary);outline-offset:2px}.clipboard-actions[data-v-317cb3e2]{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}button[type=button][data-v-317cb3e2]{color:var(--text-primary);background:var(--bg-tertiary)}button[data-v-317cb3e2]:disabled{opacity:.5;cursor:default}.file-panel[data-v-b1b08d94]{display:flex;flex-direction:column;min-height:0;flex:1}.file-toolbar[data-v-b1b08d94]{display:flex;align-items:center;gap:4px;padding:10px 12px 4px}.project-name[data-v-b1b08d94]{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;font-weight:600}.file-toolbar button[data-v-b1b08d94],.panel-hint button[data-v-b1b08d94]{border:none;background:transparent;color:var(--text-secondary);padding:3px 5px;cursor:pointer;font-size:17px}.file-toolbar button[data-v-b1b08d94]:hover{background:var(--bg-tertiary);border-radius:4px}button[data-v-b1b08d94]:disabled{opacity:.4;cursor:default}.root-path[data-v-b1b08d94]{color:var(--text-secondary);font:11px/1.6 monospace;overflow-wrap:anywhere;padding:0 12px 10px;border-bottom:1px solid var(--border-color)}.file-tree[data-v-b1b08d94]{overflow:auto;padding:6px;flex:1;min-height:0}.panel-hint[data-v-b1b08d94]{padding:12px;color:var(--text-secondary);font-size:12px;list-style:none;overflow-wrap:anywhere}.error[data-v-b1b08d94]{color:var(--danger)}.file-menu[data-v-b1b08d94]{position:fixed;z-index:110;width:min(230px,calc(100vw - 16px));padding:6px;background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:var(--radius-md);box-shadow:var(--shadow-md)}.file-menu button[data-v-b1b08d94]{display:flex;justify-content:space-between;width:100%;text-align:left;padding:8px 10px;background:none;color:var(--text-primary);border:none;border-radius:4px;cursor:pointer}.file-menu button[data-v-b1b08d94]:hover:not(:disabled),.file-menu button[data-v-b1b08d94]:focus-visible{background:var(--bg-tertiary)}.file-menu span[data-v-b1b08d94]{color:var(--text-secondary)}.file-menu .danger[data-v-b1b08d94]{color:var(--danger)}.sidebar[data-v-b8faf56e]{width:var(--sidebar-width);border-right:1px solid var(--border-color);background:var(--bg-secondary);display:flex;flex-direction:column;flex-shrink:0;transition:all .3s cubic-bezier(.4,0,.2,1);overflow:hidden;height:100%;z-index:20}.sidebar.collapsed[data-v-b8faf56e]{width:72px}.sidebar-tabs[data-v-b8faf56e]{display:flex;flex-shrink:0;border-bottom:1px solid var(--border-color)}.sidebar-tabs button[data-v-b8faf56e]{flex:1;padding:12px 4px;border:none;border-bottom:2px solid transparent;background:none;color:var(--text-secondary);cursor:pointer;font:inherit;font-size:12px}.sidebar-tabs button[aria-selected=true][data-v-b8faf56e]{border-bottom-color:var(--accent-primary);color:var(--text-primary);background:var(--bg-primary)}.sessions-panel[data-v-b8faf56e],.files-panel[data-v-b8faf56e]{display:flex;flex-direction:column;min-height:0;flex:1}.sidebar-header[data-v-b8faf56e]{padding:16px;border-bottom:1px solid var(--border-color);display:flex;flex-direction:column;gap:12px}.sidebar-title[data-v-b8faf56e]{font-size:12px;text-transform:uppercase;letter-spacing:.1em;color:var(--text-tertiary);font-weight:600}.new-session-btn[data-v-b8faf56e]{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;background:var(--accent-primary);color:#fff;border:none;border-radius:var(--radius-md);padding:10px;cursor:pointer;font-weight:500;transition:all .2s}.new-session-btn[data-v-b8faf56e]:hover{background:var(--accent-hover)}.sidebar.collapsed .new-session-btn[data-v-b8faf56e]{padding:10px 0}.session-list[data-v-b8faf56e]{flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:4px}.empty-hint[data-v-b8faf56e]{text-align:center;padding:40px 10px;color:var(--text-tertiary)}.empty-hint p[data-v-b8faf56e]{font-size:14px;margin-bottom:4px}.empty-hint .sub-hint[data-v-b8faf56e]{font-size:12px;opacity:.7}@media(max-width:768px){.sidebar[data-v-b8faf56e]{position:fixed;top:0;left:0;bottom:0;width:var(--sidebar-width);transform:translate(0);box-shadow:var(--shadow-md);z-index:50}.sidebar.collapsed[data-v-b8faf56e]{transform:translate(-100%);width:var(--sidebar-width)}}.brand-mark[data-v-f54af403]{display:block;flex-shrink:0;object-fit:contain}.terminal-area[data-v-95819edf]{flex:1;display:flex;flex-direction:column;overflow:hidden;background:#000;position:relative}.terminal-frame[data-v-95819edf]{flex:1;width:100%;min-height:0;border:none;background:#000}.terminal-tools[data-v-95819edf]{display:flex;align-items:center;flex-shrink:0;gap:8px;padding:6px 12px;background:var(--bg-primary);border-bottom:1px solid var(--border-color);flex-wrap:wrap}.upload-progress[data-v-95819edf]{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-secondary)}.upload-progress progress[data-v-95819edf]{width:64px;height:4px;accent-color:var(--accent-primary)}.clipboard-btn[data-v-95819edf]{padding:4px 10px;background:var(--bg-secondary);color:var(--text-primary);border:1px solid var(--border-color);border-radius:var(--radius-sm);cursor:pointer}.clipboard-btn[data-v-95819edf]:hover:not(:disabled){background:var(--bg-tertiary)}.clipboard-btn[data-v-95819edf]:disabled,.clipboard-menu button[data-v-95819edf]:disabled{opacity:.45;cursor:default}.selection-hint[data-v-95819edf]{color:var(--text-secondary);font-size:12px}.clipboard-menu[data-v-95819edf]{position:fixed;z-index:100;width:min(280px,calc(100vw - 16px));padding:6px;background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:var(--radius-md);box-shadow:var(--shadow-md)}.clipboard-menu button[data-v-95819edf]{display:flex;justify-content:space-between;width:100%;padding:8px 10px;border:none;border-radius:var(--radius-sm);background:transparent;color:var(--text-primary);text-align:left;cursor:pointer}.clipboard-menu button[data-v-95819edf]:hover:not(:disabled),.clipboard-menu button[data-v-95819edf]:focus-visible{background:var(--bg-tertiary)}.clipboard-menu span[data-v-95819edf],.clipboard-menu p[data-v-95819edf]{color:var(--text-secondary);font-size:12px}.clipboard-menu p[data-v-95819edf]{padding:8px 10px 4px;border-top:1px solid var(--border-color)}@media(max-width:600px){.selection-hint[data-v-95819edf]{display:none}}.welcome__container[data-v-95819edf]{flex:1;display:flex;align-items:center;justify-content:center;background:var(--bg-primary);background-image:radial-gradient(circle at center,var(--bg-secondary) 0%,var(--bg-primary) 100%)}.welcome__content[data-v-95819edf]{text-align:center;max-width:400px;padding:40px}.welcome-brand[data-v-95819edf]{display:flex;flex-direction:column;align-items:center;gap:12px;margin-bottom:24px}.logo-text[data-v-95819edf]{font-size:32px;font-weight:700;color:var(--text-primary);letter-spacing:-1px}.welcome__text[data-v-95819edf]{color:var(--text-secondary);font-size:16px;margin-bottom:24px}.highlight[data-v-95819edf]{color:var(--text-primary);font-weight:600}.sub-text[data-v-95819edf]{color:var(--text-tertiary);font-size:14px}.modal-overlay[data-v-f9ae44fb]{position:fixed;inset:0;background:#0009;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;z-index:1000;animation:fadeIn-f9ae44fb .2s ease}.modal-content[data-v-f9ae44fb]{width:100%;max-width:400px;background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:var(--radius-lg);box-shadow:var(--shadow-md);margin:16px;overflow:hidden;animation:slideUp-f9ae44fb .3s cubic-bezier(.16,1,.3,1)}.modal-header[data-v-f9ae44fb]{padding:16px 20px;border-bottom:1px solid var(--border-color);display:flex;justify-content:space-between;align-items:center}.modal-header h3[data-v-f9ae44fb]{font-size:16px;font-weight:600;color:var(--text-primary)}.close-btn[data-v-f9ae44fb]{background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:18px;padding:4px;border-radius:4px}.close-btn[data-v-f9ae44fb]:hover{background:var(--bg-tertiary);color:var(--text-primary)}.modal-body[data-v-f9ae44fb]{padding:20px;display:flex;flex-direction:column;gap:16px}.form-group[data-v-f9ae44fb]{display:flex;flex-direction:column;gap:8px}.label-text[data-v-f9ae44fb]{font-size:13px;font-weight:500;color:var(--text-secondary)}select[data-v-f9ae44fb],input[data-v-f9ae44fb]{width:100%;background:var(--bg-primary);border:1px solid var(--border-color);color:var(--text-primary);padding:10px;border-radius:var(--radius-md);outline:none;font-size:14px;appearance:none}select[data-v-f9ae44fb]:focus,input[data-v-f9ae44fb]:focus{border-color:var(--accent-primary);box-shadow:0 0 0 2px var(--accent-dim)}.modal-footer[data-v-f9ae44fb]{padding:16px 20px;background:var(--bg-primary);border-top:1px solid var(--border-color);display:flex;justify-content:flex-end;gap:12px}@keyframes fadeIn-f9ae44fb{0%{opacity:0}to{opacity:1}}@keyframes slideUp-f9ae44fb{0%{transform:translateY(20px);opacity:0}to{transform:translateY(0);opacity:1}}.login-screen[data-v-86b6b672]{height:100vh;display:flex;align-items:center;justify-content:center;background:var(--bg-primary)}.login-card[data-v-86b6b672]{width:min(360px,calc(100vw - 48px));padding:32px;display:flex;flex-direction:column;align-items:center;gap:12px;background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:var(--radius-lg);box-shadow:var(--shadow-md)}.login-logo[data-v-86b6b672]{display:flex;align-items:center;justify-content:center;width:72px;height:72px}h1[data-v-86b6b672]{font-size:22px;font-weight:600;color:var(--text-primary);letter-spacing:-.5px}.login-hint[data-v-86b6b672]{margin:-4px 0 8px;font-size:13px;color:var(--text-secondary)}input[data-v-86b6b672]{width:100%;padding:10px 12px;font-size:14px;color:var(--text-primary);background:var(--bg-primary);border:1px solid var(--border-color);border-radius:var(--radius-md);outline:none}input[data-v-86b6b672]:focus{border-color:var(--accent-primary, #3b82f6)}.login-error[data-v-86b6b672]{width:100%;margin:0;font-size:13px;color:var(--danger)}.login-btn[data-v-86b6b672]{width:100%;margin-top:4px;padding:10px 12px;font-size:14px;font-weight:500;color:#fff;background:var(--accent-primary, #3b82f6);border:none;border-radius:var(--radius-md);cursor:pointer}.login-btn[data-v-86b6b672]:disabled{opacity:.5;cursor:default}.login-btn[data-v-86b6b672]:not(:disabled):hover{filter:brightness(1.1)}.toast-container[data-v-6bea03ca]{position:fixed;top:60px;right:16px;z-index:200;display:flex;flex-direction:column;gap:8px;max-width:min(520px,calc(100vw - 32px));max-height:calc(100vh - 80px);overflow:auto}.toast[data-v-6bea03ca]{padding:10px 16px;border-radius:8px;font-size:13px;-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border:1px solid var(--border-color);min-width:200px;background:var(--bg-secondary);display:flex;align-items:flex-start;gap:12px}.toast-message[data-v-6bea03ca]{white-space:pre-wrap;overflow-wrap:anywhere;max-height:35vh;overflow:auto;flex:1}.toast-actions[data-v-6bea03ca]{display:flex;gap:8px;flex-shrink:0}.toast-actions button[data-v-6bea03ca]{border:none;background:transparent;color:inherit;cursor:pointer;font:inherit}.toast.info[data-v-6bea03ca]{background:var(--bg-secondary);color:var(--text-primary)}.toast.error[data-v-6bea03ca]{background:var(--bg-secondary);color:var(--danger)}.toast.success[data-v-6bea03ca]{background:#4ade8026;color:#4ade80}.toast-enter-active[data-v-6bea03ca]{animation:slideIn-6bea03ca .3s ease}.toast-leave-active[data-v-6bea03ca]{animation:slideOut-6bea03ca .3s ease}@keyframes slideIn-6bea03ca{0%{transform:translate(100%);opacity:0}to{transform:translate(0);opacity:1}}@keyframes slideOut-6bea03ca{0%{transform:translate(0);opacity:1}to{transform:translate(100%);opacity:0}}.app-layout[data-v-1baffbd3]{height:100vh;display:flex;flex-direction:column}.toolbar[data-v-1baffbd3]{height:var(--toolbar-height);display:flex;align-items:center;justify-content:space-between;padding:0 16px;border-bottom:1px solid var(--border-color);flex-shrink:0;z-index:30;background:var(--bg-primary)}.toolbar-left[data-v-1baffbd3]{display:flex;align-items:center;gap:12px}.toggle-btn[data-v-1baffbd3]{background:transparent;border:none;color:var(--text-secondary);cursor:pointer;padding:4px;border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center}.toggle-btn[data-v-1baffbd3]:hover{color:var(--text-primary);background:var(--bg-tertiary)}.logo-wrapper[data-v-1baffbd3]{display:flex;align-items:center;justify-content:center;flex-shrink:0}.toolbar-title[data-v-1baffbd3]{font-size:16px;font-weight:600;color:var(--text-primary);letter-spacing:-.5px}.main-area[data-v-1baffbd3]{flex:1;display:flex;overflow:hidden;position:relative}.sidebar-overlay[data-v-1baffbd3]{display:none;position:absolute;inset:0;background:#00000080;-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);z-index:15;opacity:0;transition:opacity .3s ease;pointer-events:none}@media(max-width:768px){.sidebar-overlay[data-v-1baffbd3]{display:block}.sidebar-overlay.show[data-v-1baffbd3]{opacity:1;pointer-events:auto}}
|