handmux 0.12.3 → 0.14.0

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/public/index.html CHANGED
@@ -48,8 +48,8 @@
48
48
  @keyframes bootDot { 0%,100% { opacity: .22; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
49
49
  @media (prefers-reduced-motion: reduce) { .boot-glow, .boot-dots i { animation: none; opacity: .8; } }
50
50
  </style>
51
- <script type="module" crossorigin src="/assets/index-yrOcjnt0.js"></script>
52
- <link rel="stylesheet" crossorigin href="/assets/index-CpBDvLzi.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-CpBDvLzi.css"></noscript>
51
+ <script type="module" crossorigin src="/assets/index-CL5hFo5W.js"></script>
52
+ <link rel="stylesheet" crossorigin href="/assets/index-BIOe84T4.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-BIOe84T4.css"></noscript>
53
53
  </head>
54
54
  <body>
55
55
  <div id="boot-splash" aria-hidden="true">
package/public/sw.js CHANGED
@@ -96,20 +96,25 @@ self.addEventListener('notificationclick', (event) => {
96
96
  event.notification.close();
97
97
  const d = event.notification.data || {};
98
98
  const e = encodeURIComponent;
99
- const url = d.session
100
- ? `/#/s/${e(d.session)}/w/${e(d.window || '')}/p/${e(d.pane || '')}`
101
- : '/';
99
+ const url = d.url
100
+ ? d.url
101
+ : d.session
102
+ ? `/#/s/${e(d.session)}/w/${e(d.window || '')}/p/${e(d.pane || '')}`
103
+ : '/';
102
104
  event.waitUntil((async () => {
103
105
  const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
104
106
  const open = all.find((c) => 'focus' in c);
105
107
  if (open) {
106
108
  await open.focus();
107
- // Prefer navigate(): it changes the client URL, so the target survives even a backgrounded tab
108
- // the browser discarded (it reloads into the deep-link hash, which the boot effect reads).
109
- // postMessage is a fallback for engines without WindowClient.navigate. URL format MUST match
110
- // hashRoute.readRoute()/buildDeepLink.
111
- if ('navigate' in open) { try { await open.navigate(url); return; } catch { /* fall back ↓ */ } }
112
- open.postMessage({ type: 'navigate', session: d.session, window: d.window, pane: d.pane });
109
+ // A session deep-link opens IN PLACE via postMessage (the app switches sessions and writes the hash
110
+ // with replaceState). We deliberately do NOT navigate() an already-open client for this: a
111
+ // same-document navigation pushes a spurious history entry ABOVE the app, so Back then needed an
112
+ // extra press to leave (and could confuse the exit guard). A genuinely discarded tab isn't in
113
+ // matchAll → it falls through to openWindow below, where the fresh load carries the deep-link hash.
114
+ // (message shape MUST match App.jsx's onMsg / hashRoute.) The rare non-session `url` push
115
+ // (`handmux push --url`) can't be resolved in place, so it still navigate()s the client.
116
+ if (d.session) { open.postMessage({ type: 'navigate', session: d.session, window: d.window, pane: d.pane }); return; }
117
+ if ('navigate' in open) { try { await open.navigate(url); return; } catch { /* focus alone must do */ } }
113
118
  return;
114
119
  }
115
120
  return self.clients.openWindow(url);
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { getAgent } from './agents/index.js';
4
+ import { getAgent, agentForProc } from './agents/index.js';
5
5
  import { claude } from './agents/claude.js';
6
6
 
7
7
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -141,6 +141,24 @@ export function createClaudeEvents({ commands, push, file = DEFAULT_STATE_FILE,
141
141
  if (allow && !allow.has(loc.session)) continue;
142
142
  out[pane] = { ...loc, kind: c.kind, msg: c.msg || '', ts: rec.ts || 0, agent: agent.id };
143
143
  }
144
+
145
+ // (3) process presence — a pane whose FOREGROUND program IS a coding agent reads as "agent here" even
146
+ // with no active turn on record: a fresh session that hasn't prompted yet, or one right after `/clear`
147
+ // (whose SessionEnd dropped its roster entry above though the agent is still running). This is what the
148
+ // phone's per-window agent icon and the dock's default mode key off — process liveness, not the volatile
149
+ // activity roster. Emitted with kind:null so the inbox (inboxRows keys off VIEW[kind]) skips it and no
150
+ // push fires, and with NO ts so it stays inert to the done/read high-water marks (maxTs, the seen-mark
151
+ // effect). An active roster entry from (2) always wins (out[id] set → skip). agentForProc matches the
152
+ // canonical procName (never the ambiguous 'node'), so a plain node process is not mistaken for an agent.
153
+ if (live) {
154
+ for (const lp of live.values()) {
155
+ if (out[lp.id]) continue;
156
+ const a = agentForProc(lp.cmd);
157
+ if (!a) continue;
158
+ if (allow && !allow.has(lp.session)) continue;
159
+ out[lp.id] = { session: lp.session, window: lp.window, windowName: lp.windowName, kind: null, msg: '', agent: a.id };
160
+ }
161
+ }
144
162
  return out;
145
163
  }
146
164
 
@@ -142,6 +142,15 @@ export default {
142
142
  'setup.secConnection': 'Connection',
143
143
  'setup.secName': 'Name',
144
144
  'setup.secPort': 'Port',
145
+ 'setup.secToken': 'Token',
146
+ 'setup.tokenAuto': 'auto · new each start',
147
+ 'setup.tokenCustom': 'Set a custom token…',
148
+ 'setup.tokenRandom': 'Generate a random one',
149
+ 'setup.tokenReset': 'Reset to auto (new each start)',
150
+ 'setup.askToken': 'Access token — it appears in the URL you open on the phone',
151
+ 'setup.tokenGenerated': 'New token: {token}',
152
+ 'setup.valToken': 'enter a token',
153
+ 'setup.valTokenSpace': 'no spaces — the token goes in a URL',
145
154
  'setup.secLanguage': 'CLI language / 命令行语言',
146
155
  'setup.secPush': 'Push',
147
156
  'setup.secVoice': 'Voice',
@@ -238,6 +247,7 @@ export default {
238
247
  handmux setup configure tunnel / name / notifications (writes config; re-run to change)
239
248
  handmux stop | restart | status
240
249
  handmux logs [--follow] [--lines N]
250
+ handmux push <title> <body> notify your phone from a script (--session X · --device K · --tag T · --url U)
241
251
  handmux update upgrade to the latest published version (npm i -g handmux@latest)
242
252
 
243
253
  New here? run 'handmux setup' — it walks you through remote access + phone notifications.
@@ -141,6 +141,15 @@ export default {
141
141
  'setup.secConnection': '连接',
142
142
  'setup.secName': '名称',
143
143
  'setup.secPort': '端口',
144
+ 'setup.secToken': '令牌',
145
+ 'setup.tokenAuto': '自动 · 每次启动新生成',
146
+ 'setup.tokenCustom': '自定义令牌…',
147
+ 'setup.tokenRandom': '随机生成一个',
148
+ 'setup.tokenReset': '恢复自动(每次启动新生成)',
149
+ 'setup.askToken': '访问令牌 —— 会出现在手机打开的网址里',
150
+ 'setup.tokenGenerated': '新令牌:{token}',
151
+ 'setup.valToken': '请输入令牌',
152
+ 'setup.valTokenSpace': '不能有空格 —— 令牌会放进网址',
144
153
  'setup.secLanguage': '命令行语言 / CLI language',
145
154
  'setup.secPush': '推送',
146
155
  'setup.secVoice': '语音',
@@ -237,6 +246,7 @@ export default {
237
246
  handmux setup 配置隧道 / 名称 / 通知(写入配置;重跑即可修改)
238
247
  handmux stop | restart | status
239
248
  handmux logs [--follow] [--lines N]
249
+ handmux push <标题> <正文> 从脚本推一条通知到手机(--session 会话 · --device 设备key · --tag · --url)
240
250
  handmux update 升级到最新发布版本(npm i -g handmux@latest)
241
251
 
242
252
  第一次用?跑 'handmux setup' —— 它会带你配好外网访问 + 手机通知。
@@ -119,7 +119,7 @@ function resolvePublicUrl(flags, fileCfg, env, tunnel) {
119
119
  // so it's quick to thumb in on a phone. A user-supplied token (flag/config/env) is used verbatim — any
120
120
  // length, never regenerated. 8 chars over a 32-char alphabet ≈ 40 bits, fine for a single secret URL.
121
121
  const TOKEN_ALPHABET = '23456789abcdefghijkmnpqrstuvwxyz';
122
- function defaultGen() {
122
+ export function defaultGen() {
123
123
  let s = '';
124
124
  for (let i = 0; i < 8; i++) s += TOKEN_ALPHABET[crypto.randomInt(TOKEN_ALPHABET.length)];
125
125
  return s;
@@ -0,0 +1,51 @@
1
+ // `handmux push <title> <body> [--session X]... [--device K]... [--tag T] [--url U]` — fire one
2
+ // notification to the phone through the already-running server (loopback + the server's own token).
3
+ // Scope is mutually exclusive: --device (by key) or --session, else all. Pure parse + injectable
4
+ // runner so it unit-tests without spawning or real fetch.
5
+ import { readState } from './state.js';
6
+
7
+ const collect = (acc, v) => acc.concat(String(v).split(',').map((s) => s.trim()).filter(Boolean));
8
+
9
+ // argv is process.argv.slice(2), i.e. ['push', title, body, ...flags]. The shared parseArgs() drops bare
10
+ // words after the command, so title/body are taken positionally; --session/--device may repeat.
11
+ export function parsePushArgs(argv) {
12
+ const rest = argv.slice(1);
13
+ const positional = [];
14
+ let sessions = [], devices = [], tag, url;
15
+ for (let i = 0; i < rest.length; i++) {
16
+ const a = rest[i];
17
+ if (a === '--session') { sessions = collect(sessions, rest[++i]); }
18
+ else if (a === '--device') { devices = collect(devices, rest[++i]); }
19
+ else if (a === '--tag') { tag = rest[++i]; }
20
+ else if (a === '--url') { url = rest[++i]; }
21
+ else if (!a.startsWith('--')) positional.push(a);
22
+ }
23
+ const [title, body] = positional;
24
+ if (!title || !body) return { error: 'usage: handmux push <title> <body> [--session X]... [--device K]... [--tag T] [--url U]' };
25
+ if (sessions.length && devices.length) return { error: 'use --session or --device, not both' };
26
+ const out = { title, body };
27
+ if (sessions.length) out.sessions = sessions;
28
+ if (devices.length) out.devices = devices;
29
+ if (tag) out.tag = tag;
30
+ if (url) out.url = url;
31
+ return out;
32
+ }
33
+
34
+ export async function runPush({ argv, home, fetchImpl = globalThis.fetch, log = console.log, err = console.error }) {
35
+ const parsed = parsePushArgs(argv);
36
+ if (parsed.error) { err(parsed.error); return 1; }
37
+ const st = readState(home);
38
+ if (!st || !st.localUrl || !st.token) { err('handmux is not running — run `handmux start` first.'); return 1; }
39
+ try {
40
+ const res = await fetchImpl(`${st.localUrl}/api/push/send-local`, {
41
+ method: 'POST',
42
+ headers: { Authorization: `Bearer ${st.token}`, 'Content-Type': 'application/json' },
43
+ body: JSON.stringify(parsed),
44
+ });
45
+ if (!res.ok) { err(`push failed: ${res.status}`); return 1; }
46
+ const out = await res.json();
47
+ if (out.configured === false) { err('push is not configured (no VAPID keys) — run `handmux setup`.'); return 1; }
48
+ log(`pushed (sent: ${out.sent})`);
49
+ return 0;
50
+ } catch (e) { err(`push failed: ${e.message}`); return 1; }
51
+ }
@@ -10,6 +10,7 @@ import { homedir } from 'node:os';
10
10
  import { spawnSync } from 'node:child_process';
11
11
  import webpush from 'web-push';
12
12
  import { configPath, pocketHome } from './state.js';
13
+ import { defaultGen as genToken } from './options.js';
13
14
  import { resolveCloudflared } from './cloudflared.js';
14
15
  import { resolveTunlite, checkSshAuth } from './tunlite.js';
15
16
  import { resolveNatapp, resolveCpolar } from './tunnelClients.js';
@@ -51,10 +52,11 @@ export function findTunnelId(listJsonOut, name) {
51
52
 
52
53
  // The config keys the wizard owns: everything it can set. mergeConfig wipes these from the existing config
53
54
  // before re-applying the answers, so switching a tunnel (or clearing an optional field) cleanly drops the
54
- // old value instead of leaving a stale field behind. Anything NOT here (token, staticDir, previewDomain…)
55
- // is preserved untouched.
55
+ // old value instead of leaving a stale field behind. Anything NOT here (staticDir, previewDomain…) is
56
+ // preserved untouched. `token` IS owned so the Token row can pin one AND clear it back to auto — but it
57
+ // round-trips through answersFromConfig, so a re-run that never touches the row still writes it back.
56
58
  const WIZARD_KEYS = [
57
- 'lang', 'name', 'port', 'tunnel',
59
+ 'lang', 'name', 'port', 'tunnel', 'token',
58
60
  'sshHost', 'remotePort', 'sshJump', 'cfHostname', 'cfTunnelName', 'publicUrl',
59
61
  'authtoken', 'cpolarRegion',
60
62
  'vapid', 'xfyun',
@@ -68,6 +70,7 @@ export function configFromAnswers(a) {
68
70
  const cfg = { tunnel: a.tunnel, port: a.port };
69
71
  if (a.lang) cfg.lang = a.lang;
70
72
  if (a.name) cfg.name = a.name;
73
+ if (a.token) cfg.token = a.token; // blank = don't pin one → the server mints a fresh token each start
71
74
  if (a.tunnel === 'ssh') {
72
75
  cfg.sshHost = a.sshHost;
73
76
  cfg.remotePort = a.remotePort;
@@ -103,6 +106,7 @@ export function answersFromConfig(cfg = {}) {
103
106
  const a = {
104
107
  lang: cfg.lang || getLocale(),
105
108
  name: cfg.name || '',
109
+ token: cfg.token || '', // '' = not pinned (auto each start); seeded so an untouched re-run rewrites it
106
110
  tunnel: cfg.tunnel || 'none',
107
111
  port: Number(cfg.port) || 19999,
108
112
  };
@@ -144,6 +148,14 @@ export function validateContact(v) {
144
148
  if (!wellFormed || /\.local(?:[:/]|$)/i.test(s)) return t('setup.valContact');
145
149
  return undefined;
146
150
  }
151
+ // Access token: it rides in the phone's URL as ?token=…, so require something and reject whitespace (a space
152
+ // would break the link). Length/charset are otherwise up to the user — a pinned token is used verbatim.
153
+ export function validateToken(v) {
154
+ const s = String(v || '').trim();
155
+ if (!s) return t('setup.valToken');
156
+ if (/\s/.test(s)) return t('setup.valTokenSpace');
157
+ return undefined;
158
+ }
147
159
 
148
160
  function readExisting(file) {
149
161
  try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return {}; }
@@ -185,6 +197,7 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
185
197
  { value: 'connection', label: t('setup.secConnection'), hint: summarizeConnection(a) },
186
198
  { value: 'name', label: t('setup.secName'), hint: a.name || t('setup.default') },
187
199
  { value: 'port', label: t('setup.secPort'), hint: String(a.port) },
200
+ { value: 'token', label: t('setup.secToken'), hint: a.token ? maskSecret(a.token) : t('setup.tokenAuto') },
188
201
  { value: 'push', label: t('setup.secPush'), hint: a.vapid ? (a.vapid.subject || t('setup.on')) : t('setup.off') },
189
202
  { value: 'voice', label: t('setup.secVoice'), hint: a.xfyun ? (a.xfyun.appId || t('setup.on')) : t('setup.off') },
190
203
  // A CLI-tool preference (language of handmux's own terminal output), not an app setting — so it
@@ -212,6 +225,7 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
212
225
  if (choice === 'connection') a = await editConnection(a, { home, log });
213
226
  else if (choice === 'name') a.name = await editName(a);
214
227
  else if (choice === 'port') a.port = await editPort(a);
228
+ else if (choice === 'token') a.token = await editToken(a);
215
229
  else if (choice === 'language') a.lang = await editLanguage(a);
216
230
  else if (choice === 'push') a.vapid = await editPush(a);
217
231
  else if (choice === 'voice') a.xfyun = await editVoice(a);
@@ -250,6 +264,34 @@ async function editPort(a) {
250
264
  return Number(v);
251
265
  }
252
266
 
267
+ // The access token — the one secret in the phone's URL. Unset = the server mints a fresh one each start
268
+ // (printed + QR'd), so the link changes every restart; pinning one keeps the same URL. A mini-hub (like
269
+ // push/voice): type your own, generate + pin a strong random one, or reset back to auto. Editing custom
270
+ // pre-fills the current value (so you can read it off); the hub hint masks it. Esc returns to the main hub,
271
+ // keeping the choice. Returns the token string ('' = auto).
272
+ async function editToken(a) {
273
+ let token = a.token || '';
274
+ for (;;) {
275
+ let pick;
276
+ try {
277
+ pick = await ask(select({
278
+ message: withBack(t('setup.secToken')),
279
+ options: [
280
+ { value: 'custom', label: t('setup.tokenCustom'), hint: token ? maskSecret(token) : t('setup.tokenAuto') },
281
+ { value: 'random', label: t('setup.tokenRandom') },
282
+ { value: 'auto', label: t('setup.tokenReset'), hint: t('setup.tokenAuto') },
283
+ ],
284
+ initialValue: 'custom',
285
+ }));
286
+ } catch (e) { if (e === CANCELLED) return token; throw e; } // back to the main hub, keeping the choice
287
+ try {
288
+ if (pick === 'custom') token = (await ask(text({ message: t('setup.askToken'), initialValue: token, validate: validateToken }))).trim();
289
+ else if (pick === 'random') { token = genToken(); note(t('setup.tokenGenerated', { token })); }
290
+ else if (pick === 'auto') token = '';
291
+ } catch (e) { if (e !== CANCELLED) throw e; } // Esc in a sub-edit → back to the mini-hub
292
+ }
293
+ }
294
+
253
295
  const bareHost = (u) => String(u || '').replace(/^https?:\/\//, '');
254
296
  // cloudflare's two drivers are ONE family in the UI: 'cloudflare' (quick, no login) and 'cloudflare-named'
255
297
  // (your domain, needs a login) are picked as a mode INSIDE cloudflare, not as two separate tunnels.
package/src/httpApi.js CHANGED
@@ -412,12 +412,17 @@ export function createApiRouter({
412
412
  if (!isPaneId(req.query.pane)) return res.status(400).json({ error: 'bad pane id' });
413
413
  const lines = Math.min(Math.max(Number(req.query.lines) || 1000, 1), 5000);
414
414
  try {
415
- // Cap the empty grid below the cursor (a fresh shell is "prompt + a wall of blank rows") so the
416
- // phone's bottom-anchored render shows content instead of blank — and so the hash/body/render
417
- // all key off the same trimmed capture. See trimCapture.js.
418
- const raw = await commands.capturePane(req.query.pane, lines);
419
- const ansi = capTrailingBlankRows(raw);
415
+ // Read the pane's state FIRST — whether it's on the alternate screen decides how we capture it.
420
416
  const { width, height, cursorX, cursorY, cursorVisible, altScreen, mouseAware } = await commands.paneInfo(req.query.pane);
417
+ // An ALT-screen pane (a full-screen app: vim/less/htop) is a fixed height×width screen with NO
418
+ // scrollback. Asking tmux for history (-S -lines) there bleeds the MAIN screen's scrollback in
419
+ // ABOVE the app — you'd scroll up into terminal history that isn't the app's — and capping its
420
+ // trailing blank rows mangles the fixed screen (those blanks are real cells). So capture an alt
421
+ // pane as exactly its visible screen (lines=0) and skip the blank-trim. A normal pane still pulls
422
+ // `lines` of scrollback and caps the empty grid below the cursor (fresh shell = "prompt + a wall of
423
+ // blank rows") so the phone's bottom-anchored render shows content, not blank. See trimCapture.js.
424
+ const raw = await commands.capturePane(req.query.pane, altScreen ? 0 : lines);
425
+ const ansi = altScreen ? raw : capTrailingBlankRows(raw);
421
426
  // The cursor's row counted from the BOTTOM of the (trimmed) capture. The live screen is the
422
427
  // capture's last `height` rows, so the cursor sits `height-1-cursorY` rows above the bottom —
423
428
  // less however many trailing blank rows capTrailingBlankRows dropped (all of them below the
@@ -597,7 +602,7 @@ export function createApiRouter({
597
602
  try {
598
603
  push.addSubscription(sub, boundSessions);
599
604
  await push.sendToOne(sub, { title: '通知已开启 ✅', body: '会话「需要你」或「已完成」时提醒你', tag: 'handmux-welcome' }, { topic: 'handmux', urgency: 'high' });
600
- res.json({ ok: true, count: push.count() });
605
+ res.json({ ok: true, count: push.count(), pushKey: push.getPushKey(sub.endpoint) });
601
606
  } catch (e) { next(e); }
602
607
  });
603
608
 
@@ -626,6 +631,35 @@ export function createApiRouter({
626
631
  res.json({ ok: true });
627
632
  });
628
633
 
634
+ // Local script push (`handmux push`): loopback + server token. Scope is mutually exclusive —
635
+ // devices (by pushKey) > sessions > all. This is the ONLY push-send entry; no public/remote variant.
636
+ r.post('/push/send-local', async (req, res, next) => {
637
+ const { sessions, devices, title, body, tag, url } = req.body || {};
638
+ if (typeof title !== 'string' || !title.trim()) return res.status(400).json({ error: 'title required' });
639
+ if (typeof body !== 'string' || !body.trim()) return res.status(400).json({ error: 'body required' });
640
+ const hasSessions = Array.isArray(sessions) && sessions.length > 0;
641
+ const hasDevices = Array.isArray(devices) && devices.length > 0;
642
+ if (hasSessions && hasDevices) return res.status(400).json({ error: 'use --session or --device, not both' });
643
+ const payload = { title, body };
644
+ if (typeof tag === 'string' && tag) payload.tag = tag;
645
+ if (typeof url === 'string' && url) payload.data = { url };
646
+ const opts = { urgency: 'normal', ttl: 1800 };
647
+ if (payload.tag) opts.topic = payload.tag;
648
+ try {
649
+ const out = hasDevices ? await push.sendToDevices(devices, payload, opts)
650
+ : hasSessions ? await push.sendToSessions(sessions, payload, opts)
651
+ : await push.sendToAll(payload, opts);
652
+ res.json(out);
653
+ } catch (e) { next(e); }
654
+ });
655
+
656
+ // This device's addressing key (server-token auth) — the script push sheet reads it to show `--device`.
657
+ r.post('/push/key', (req, res) => {
658
+ const endpoint = req.body?.endpoint;
659
+ if (typeof endpoint !== 'string') return res.status(400).json({ error: 'endpoint required' });
660
+ res.json({ pushKey: push.getPushKey(endpoint) });
661
+ });
662
+
629
663
  // ?sessions=a,b scopes the roster to the session NAMES this device subscribed to (per-device inbox
630
664
  // isolation). Omitted → null → all (back-compat); present-but-empty → [] → nothing.
631
665
  r.get('/states', async (req, res, next) => {
package/src/push.js CHANGED
@@ -10,6 +10,7 @@ import webpush from 'web-push';
10
10
  import fs from 'node:fs';
11
11
  import path from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
+ import crypto from 'node:crypto';
13
14
 
14
15
  const here = path.dirname(fileURLToPath(import.meta.url));
15
16
  const STORE = process.env.PUSH_STORE || path.resolve(here, '../data/push-subs.json');
@@ -36,13 +37,15 @@ function ensureInit() {
36
37
  }
37
38
  }
38
39
 
40
+ const genKey = () => crypto.randomBytes(18).toString('base64url');
41
+
39
42
  let subs = load();
40
43
 
41
44
  function load() {
42
45
  try {
43
46
  const raw = JSON.parse(fs.readFileSync(STORE, 'utf8')) || [];
44
47
  return raw.map((e) => (e && e.subscription)
45
- ? { subscription: e.subscription, boundSessions: e.boundSessions || [] }
48
+ ? { subscription: e.subscription, boundSessions: e.boundSessions || [], pushKey: e.pushKey || undefined }
46
49
  : { subscription: e, boundSessions: [] }); // migrate old bare-subscription entries
47
50
  } catch { return []; }
48
51
  }
@@ -60,8 +63,8 @@ export function count() { return subs.length; }
60
63
  export function addSubscription(sub, boundSessions = []) {
61
64
  if (!sub || typeof sub.endpoint !== 'string') return false;
62
65
  const i = subs.findIndex((s) => s.subscription.endpoint === sub.endpoint);
63
- if (i === -1) subs.push({ subscription: sub, boundSessions });
64
- else subs[i] = { subscription: sub, boundSessions };
66
+ if (i === -1) subs.push({ subscription: sub, boundSessions, pushKey: genKey() });
67
+ else subs[i] = { subscription: sub, boundSessions, pushKey: subs[i].pushKey || genKey() };
65
68
  persist();
66
69
  return true;
67
70
  }
@@ -77,6 +80,15 @@ export function removeSubscription(endpoint) {
77
80
  if (subs.length !== before) persist();
78
81
  }
79
82
 
83
+ // The device-addressing id (NOT an auth credential — see /api/push/send-local). Lazy-generate for
84
+ // records stored before the feature existed so an already-subscribed device still has one.
85
+ export function getPushKey(endpoint) {
86
+ const rec = subs.find((s) => s.subscription.endpoint === endpoint);
87
+ if (!rec) return null;
88
+ if (!rec.pushKey) { rec.pushKey = genKey(); persist(); }
89
+ return rec.pushKey;
90
+ }
91
+
80
92
  // The push-service Topic header (RFC 8030) must be ≤32 URL/filename-safe base64 chars [A-Za-z0-9_-].
81
93
  // An invalid topic makes the service reject the ENTIRE send with a non-404/410 error that deliver()
82
94
  // swallows — a silent zero-delivery, no log, no prune. tmux pane ids carry a '%' (e.g. %4), so a
@@ -113,6 +125,14 @@ export const sendToAll = (payload, opts = {}) => deliver(subs, payload, opts);
113
125
  export const sendToSession = (session, payload, opts = {}) =>
114
126
  deliver(subs.filter((s) => s.boundSessions.includes(session)), payload, opts);
115
127
 
128
+ export const sendToDevices = (keys, payload, opts = {}) =>
129
+ deliver(subs.filter((s) => keys.includes(s.pushKey)), payload, opts);
130
+
131
+ // Union of the given sessions, deduped: a device bound to several of them is still delivered once
132
+ // (deliver() iterates the record list, so each record appears at most once here).
133
+ export const sendToSessions = (sessions, payload, opts = {}) =>
134
+ deliver(subs.filter((s) => s.boundSessions.some((x) => sessions.includes(x))), payload, opts);
135
+
116
136
  // Back-compat: the /push/subscribe welcome still pushes to a single just-added subscription.
117
137
  export async function sendToOne(sub, payload, opts = {}) {
118
138
  const rec = subs.find((s) => s.subscription.endpoint === sub.endpoint)
@@ -1,32 +0,0 @@
1
- /**
2
- * Copyright (c) 2014 The xterm.js authors. All rights reserved.
3
- * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
4
- * https://github.com/chjj/term.js
5
- * @license MIT
6
- *
7
- * Permission is hereby granted, free of charge, to any person obtaining a copy
8
- * of this software and associated documentation files (the "Software"), to deal
9
- * in the Software without restriction, including without limitation the rights
10
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
- * copies of the Software, and to permit persons to whom the Software is
12
- * furnished to do so, subject to the following conditions:
13
- *
14
- * The above copyright notice and this permission notice shall be included in
15
- * all copies or substantial portions of the Software.
16
- *
17
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23
- * THE SOFTWARE.
24
- *
25
- * Originally forked from (with the author's permission):
26
- * Fabrice Bellard's javascript vt100 for jslinux:
27
- * http://bellard.org/jslinux/
28
- * Copyright (c) 2011 Fabrice Bellard
29
- * The original design remains. The terminal itself
30
- * has been extended to include xterm CSI codes, among
31
- * other features.
32
- */.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;right:0;left:0;top:0;bottom: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;left:0;top:0;bottom:0;right: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}*{box-sizing:border-box}:root{--blue: #0a84ff;--blue-press: #0a6fd8;--blue-rgb: 10,132,255;--green: #34c759;--green-rgb: 52,199,89;--red: #ff453a}@font-face{font-family:JetBrainsMono Nerd Font;src:url(/fonts/JetBrainsMonoNerdFontMono-Regular.woff2) format("woff2");font-weight:400;font-style:normal;font-display:swap}@font-face{font-family:TW Unifont;src:url(/fonts/TWUnifont.woff2) format("woff2");font-weight:400;font-style:normal;font-display:swap;unicode-range:U+2190-21FF,U+2300-23FF,U+2400-243F,U+2500-257F,U+2580-259F,U+25A0-25FF,U+2600-26FF,U+2700-27BF,U+2800-28FF,U+2B00-2BFF}html,body,#root{height:100%;margin:0;overscroll-behavior:none}body{font-family:-apple-system,system-ui,sans-serif;background:#1a1b1e;color:#e6e6e6}.app{display:flex;flex-direction:column;height:100%;overflow:hidden}.topbar{display:flex;align-items:center;gap:6px;padding:4px 8px;background:#2a2b2e}.hamburger{font-size:20px;background:none;border:none;color:#e6e6e6;width:34px;height:34px;display:inline-flex;align-items:center;justify-content:center;padding:0;margin-left:-4px;touch-action:manipulation}.session-name{font-weight:600;margin-right:auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fontbtn{min-width:36px;padding:4px 10px;border:none;border-radius:8px;background:#33353a;color:#e6e6e6;font-size:14px;touch-action:manipulation}.fontbtn[aria-pressed=true]{background:#2a4060;color:#7ab4f5}a.settings-linkbtn{display:inline-flex;align-items:center;justify-content:center;text-decoration:none;box-sizing:border-box}.topbar-icon{display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;padding:0;border:none;background:none;color:#e6e6e6;touch-action:manipulation}.topbar-icon:active{color:var(--blue)}.topbar-icon svg{width:21px;height:21px;display:block}.settings-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;background:#00000080;z-index:19}.settings-card{position:fixed;z-index:20;left:50%;top:50%;transform:translate(-50%,-50%);width:calc(100% - 48px);max-width:360px;background:#232427;border-radius:12px;padding:16px;box-shadow:0 10px 40px #00000080}.settings-head{display:flex;align-items:center;margin-bottom:12px}.settings-title{font-size:16px;font-weight:600;margin-right:auto}.settings-close{flex:none;display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;padding:0;border:none;border-radius:50%;background:#78788029;color:#ebebf58c;font-size:15px;line-height:1;touch-action:manipulation}.settings-close:active{background:#7878804d;color:#fff}.settings-close svg{width:14px;height:14px;display:block}.settings-section{padding:10px 0;border-top:1px solid #33353a}.settings-label{font-size:12px;color:#888;letter-spacing:1px;margin-bottom:8px}.settings-btns{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.a2hs-banner{position:fixed;z-index:60;left:8px;right:8px;top:calc(env(safe-area-inset-top,0px) + 8px);display:flex;align-items:center;gap:10px;padding:9px 10px 9px 12px;background:#1e2026eb;-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);border:1px solid rgba(255,255,255,.09);border-radius:14px;box-shadow:0 8px 26px #00000061;animation:a2hs-in .28s ease}@keyframes a2hs-in{0%{opacity:0;transform:translateY(-12px)}to{opacity:1;transform:none}}@media (prefers-reduced-motion: reduce){.a2hs-banner{animation:none}}.a2hs-banner-icon{flex:none;font-size:19px;line-height:1;color:#7aa2f0}.a2hs-banner-txt{flex:1;min-width:0;display:grid;gap:2px}.a2hs-banner-title{font-size:13.5px;font-weight:600;color:#f2f3f5}.a2hs-banner-sub{font-size:12px;line-height:1.4;color:#b9bcc4}.a2hs-banner-cta{flex:none;padding:7px 13px;border:none;border-radius:9px;background:#2f6ad9;color:#fff;font-size:13px;font-weight:600;touch-action:manipulation}.a2hs-banner-cta:active{filter:brightness(1.12)}.a2hs-banner-x{flex:none;width:30px;height:30px;padding:0;border:none;background:transparent;color:#9aa0aa;font-size:15px;touch-action:manipulation}.a2hs-banner-x:active{color:#f2f3f5}.lang-drop{position:relative;display:inline-block}.lang-drop-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1}.lang-trigger{display:flex;align-items:center;gap:8px;padding:5px 10px;background:#33353a;border:none;border-radius:8px;color:#e6e6e6;font-size:14px;touch-action:manipulation;cursor:pointer}.lang-chevron{font-size:11px;color:#888}.lang-menu{position:absolute;top:calc(100% + 4px);left:0;min-width:100%;background:#2a2c30;border-radius:8px;box-shadow:0 4px 20px #00000080;z-index:2;overflow:hidden}.lang-option{display:block;width:100%;padding:9px 14px;border:none;background:none;color:#e6e6e6;font-size:14px;text-align:left;touch-action:manipulation;white-space:nowrap}.lang-option:active{background:#33353a}.lang-option.active{color:#7ab4f5}.settings-value{min-width:56px;text-align:center;font-size:14px;color:#e6e6e6}.cols-btns{flex-wrap:nowrap;gap:5px}.col-step{min-width:40px;padding:4px 6px;letter-spacing:.3px}.col-fine{color:#aaa}.settings-note{font-size:13px;color:#9aa0a6;flex:1 1 auto;min-width:0}.settings-hint{font-size:12px;color:#7c8088;line-height:1.5;margin-top:8px}.settings-update{margin-top:10px;padding:8px 10px;border-radius:8px;background:rgba(var(--blue-rgb),.12);border:.5px solid rgba(var(--blue-rgb),.35)}.settings-update-title{font-size:14px;color:var(--blue);font-weight:600}.settings-update-new{list-style:none;margin:6px 0 0;padding:0}.settings-update-new li{font-size:12.5px;color:#d6d9de;line-height:1.5;margin:2px 0}.settings-update-new-ver{color:#9db8ff;font-weight:700;font-variant-numeric:tabular-nums;margin-right:6px}.settings-update-how{font-size:12px;color:#9aa0a8;line-height:1.5;margin-top:6px}.settings-update-how code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:#e6e8ec;background:#78788038;padding:1px 5px;border-radius:5px}.settings-hint.preview-dynamic-disabled{color:#6b6f77;border-left:2px solid #34373d;padding-left:8px;margin-top:6px}@keyframes tw-spin{to{transform:rotate(360deg)}}.spinner{display:inline-block;width:14px;height:14px;flex:none;border:2px solid #4a4d52;border-top-color:#e6e6e6;border-radius:50%;animation:tw-spin .7s linear infinite}.bind-input{width:100%;padding:10px;border-radius:8px;border:1px solid #444;background:#1a1b1e;color:#fff;font-size:14px}.startup-custom{margin-top:6px}.opt+.opt{margin-top:14px}.field{width:100%;box-sizing:border-box;display:flex;align-items:center;gap:8px;padding:10px;border-radius:8px;border:1px solid #444;background:#1a1b1e;color:#fff;font-size:14px;text-align:left;touch-action:manipulation}.field:active{border-color:#555}.cwd-field{justify-content:space-between}.cwd-path{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;opacity:.9}.cwd-action{flex:none;color:var(--blue);font-size:13px}.cwd-reset{margin-top:6px;background:none;border:none;color:#888;font-size:12px;padding:2px 0;touch-action:manipulation}.dd{position:relative}.dd-trigger{justify-content:space-between}.dd-value{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dd-caret{flex:none;color:#8a8f98;font-size:12px;transition:transform .15s ease}.dd-caret.open{transform:rotate(180deg)}.dd-menu{position:absolute;top:calc(100% + 6px);left:0;right:0;z-index:30;background:#26282c;border:1px solid #3a3c42;border-radius:8px;padding:4px;box-shadow:0 10px 30px #0000008c;transform-origin:top;animation:dd-pop .12s ease}@keyframes dd-pop{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}.dd-option{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;padding:10px;border:none;border-radius:8px;background:none;color:#e6e6e6;font-size:14px;text-align:left;touch-action:manipulation}.dd-option:active{background:#33353a}.dd-option.is-selected{color:var(--green)}.dd-check{flex:none;color:var(--green);font-size:13px}.bind-error{margin-top:8px;color:var(--red);font-size:13px}.bind-hint{margin-top:8px;color:#888;font-size:13px}.bind-actions{margin-top:12px;justify-content:flex-end}.bind-confirm{background:var(--blue);color:#fff}.bind-confirm:disabled{opacity:.6}.windowbar{display:flex;gap:8px;align-items:center;padding:6px 8px;background:#222}.windowbar-scroll{flex:1;min-width:0;display:flex;gap:4px;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch;touch-action:pan-x;scrollbar-width:none}.windowbar-scroll::-webkit-scrollbar{display:none}.win-tab{white-space:nowrap;padding:4px 10px;border:none;border-radius:8px;background:#33353a;color:#bbb;font-size:13px;touch-action:manipulation}.win-tab.active{background:var(--blue);color:#fff}.win-new{border:1px dashed #555;color:#8aa;font-weight:700;background:transparent}.win-panes{margin-left:5px;font-size:11px;color:#889}.win-tab.active .win-panes{color:#d6e4ff}.wt-dd{position:relative;flex:none}.wt-trigger{display:inline-flex;align-items:center;gap:4px}.wt-trigger .agent-mark{margin-right:0}.wt-sep{margin:0 -1px;color:#cfe0ff;opacity:.4;font-weight:400}.wt-name,.wt-pane{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.wt-name{max-width:9ch}.wt-pane{font-weight:600;max-width:11ch}.wt-caret{flex:none;font-size:11px;color:#d6e4ff;transition:transform .15s ease}.wt-caret.open{transform:rotate(180deg)}.wt-menu{position:fixed;top:auto;right:auto;min-width:160px;max-width:240px}.terminal-wrap{position:relative;flex:1;min-height:0;overflow:hidden}.terminal{height:100%;overflow-x:auto;overflow-y:hidden;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;touch-action:pan-x pan-y}.terminal--loading{opacity:0}.terminal .xterm{display:flex;flex-direction:column;justify-content:flex-end;height:100%;width:max-content;min-width:100%}.terminal .xterm-screen{flex:none}.terminal .xterm-viewport{touch-action:pan-x pan-y;overscroll-behavior:contain}.terminal .xterm-helper-textarea{pointer-events:none;opacity:0}.new-output{position:absolute;right:12px;bottom:12px;z-index:5;touch-action:manipulation;padding:8px 14px;border:none;border-radius:16px;background:var(--blue);color:#fff;box-shadow:0 2px 8px #0006}.dbg{position:absolute;left:6px;top:6px;z-index:6;font:12px/1.4 monospace;color:#9fe;background:#0009;padding:2px 6px;border-radius:4px;pointer-events:none}.term-banner{position:absolute;left:0;right:0;top:0;z-index:6;font:12px/1.7 -apple-system,system-ui,sans-serif;text-align:center;padding:2px 10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none}.term-banner--hist{background:#e0a800;color:#1a1400}.term-banner--err{background:#d32f2f;color:#fff}.copy-bubble{position:absolute;z-index:7;padding:7px 16px;border:none;border-radius:16px;background:var(--blue);color:#fff;font-size:14px;font-weight:600;box-shadow:0 2px 10px #00000080;touch-action:manipulation}.sel-hint{position:absolute;left:50%;top:12px;transform:translate(-50%);z-index:8;max-width:92%;padding:7px 14px;border-radius:16px;background:rgba(var(--blue-rgb),.96);color:#fff;font-size:13px;font-weight:600;white-space:nowrap;box-shadow:0 2px 10px #00000080;pointer-events:none}.term-pager{position:absolute;z-index:6;right:calc(10px + env(safe-area-inset-right));top:50%;transform:translateY(-50%);display:flex;flex-direction:column;align-items:stretch;border-radius:21px;overflow:hidden;background:#1c1e2285;-webkit-backdrop-filter:blur(16px) saturate(1.4);backdrop-filter:blur(16px) saturate(1.4);border:.5px solid rgba(255,255,255,.14);box-shadow:0 4px 18px #0000004d}.term-pager-btn{width:42px;height:42px;display:flex;align-items:center;justify-content:center;padding:0;border:none;background:transparent;color:#ffffffeb;touch-action:manipulation;-webkit-tap-highlight-color:transparent;transition:background .12s ease}.term-pager-btn:active{background:#ffffff24}.term-pager-div{height:.5px;background:#ffffff21;margin:0 9px}@media (prefers-reduced-motion: reduce){.term-pager-btn{transition:none}}.input-text{flex:1;min-width:0;padding:7px 6px 7px 11px;border:none;background:transparent;color:#fff;font-family:inherit;font-size:16px;line-height:22px;max-height:156px;resize:none;overflow-y:auto;display:block}.input-text:focus{outline:none}.input-text::placeholder{color:#6b7280}.token-prompt button{padding:8px 14px;border:none;border-radius:8px;background:var(--blue);color:#fff}.drawer{position:fixed;top:0;left:0;width:78%;max-width:320px;height:100%;background:#232427;transform:translate(-100%);transition:transform .2s;z-index:10;padding:12px;display:flex;flex-direction:column;overflow:hidden}.drawer.open{transform:translate(0)}.drawer-list{flex:1;min-height:0;overflow-y:auto}.drawer-logout{flex:none;margin-top:8px;padding:10px;border:none;border-radius:8px;background:#2e2a2c;color:var(--red);font-size:14px;touch-action:manipulation}.drawer-logout:active{background:#4a2f33}.drawer-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;background:#0006;z-index:9}.drawer-title{font-size:12px;color:#888;letter-spacing:1px;margin-bottom:8px}.drawer-row{display:flex;align-items:center;gap:6px;border-radius:8px}.drawer-name{flex:1;min-width:0;padding:8px 4px;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.drawer-session.active{background:var(--blue);color:#fff}.drawer-unbind{flex:none;background:none;border:none;color:#888;font-size:14px;padding:6px 8px;touch-action:manipulation;display:inline-flex;align-items:center;justify-content:center;min-width:40px;min-height:44px}.drawer-session.active .drawer-unbind{color:#d6e4ff}.drawer-empty{padding:12px 4px;color:#888;font-size:13px}.drawer-bind{width:100%;margin-top:8px;padding:10px;border:none;border-radius:8px;background:#2b3550;color:#cdddff;font-size:14px;touch-action:manipulation}.drawer-bind:active{background:#344168}.token-prompt{max-width:360px;margin:80px auto;display:flex;flex-direction:column;gap:12px;padding:0 16px}.token-prompt input{padding:10px;border-radius:8px;border:1px solid #444;background:#1a1b1e;color:#fff}.loading{padding:40px;text-align:center;color:#888}.empty-cta{margin:auto;display:flex;flex-direction:column;gap:6px;align-items:center;padding:28px 32px;background:none;border:1px dashed #44464c;border-radius:12px;color:#aab;font-size:15px;touch-action:manipulation}.empty-cta span{font-size:13px;color:var(--blue)}.empty-cta:active{background:#232427}.bottom-dock{display:flex;gap:8px;padding:8px;background:#2a2b2e;align-items:stretch}.dock-left{flex:1;min-width:0;display:flex;flex-direction:column;gap:6px}.dock-dots{position:relative;display:flex;justify-content:center;align-items:center;gap:8px;padding:3px 0 4px;width:100%;border:none;background:transparent;-webkit-tap-highlight-color:transparent}.dock-dots:active .dock-dot{background:#5a5e66}.dock-dots:active .dock-dot.on{background:#6a93ff}.dock-dot{width:6px;height:6px;border-radius:50%;background:#4a4d54}.dock-dot.on{background:var(--blue)}.dock-mode-label{position:absolute;left:2px;top:50%;transform:translateY(-50%);font-size:10px;line-height:1;color:#8a93a0;white-space:nowrap;pointer-events:none}.dock-pager{overflow:hidden;min-width:0;contain:paint}.dock-track{display:flex;align-items:flex-start;width:200%;will-change:transform;transform:translateZ(0);transition:transform .3s cubic-bezier(.22,.61,.36,1)}.dock-track.at-chat{transform:translate3d(-50%,0,0)}.dock-track>.dock-page{flex:0 0 50%;width:50%;min-width:0}.dock-page.command{position:relative}.keybar-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:4px;min-width:0}.keybar-key{min-width:0;min-height:40px;padding:0 2px;display:inline-flex;align-items:center;justify-content:center;border:none;border-radius:8px;white-space:nowrap;background:#33353a;color:#e6e6e6;font:14px/1 ui-monospace,SF Mono,Menlo,monospace;touch-action:manipulation;-webkit-user-select:none;user-select:none}.keybar-key:active{background:var(--blue);color:#fff}.keybar-key svg{width:20px;height:20px;display:block}.keybar-mod.armed{background:var(--blue);color:#fff}.keybar-mod.locked{background:var(--blue);color:#fff;box-shadow:inset 0 -3px #ffd24a}.dock-input-row{display:flex;gap:6px;align-items:stretch}.input-wrap{position:relative;flex:1;min-width:0;display:flex;align-items:flex-end;gap:4px;margin-top:6px;padding:5px 6px 5px 5px;border:1px solid #3a3d44;border-radius:20px;background:#202227;box-shadow:0 1px 6px #00000059;transition:border-color .15s,box-shadow .15s}.dock-page.chat{padding-bottom:6px}.input-wrap:focus-within{border-color:var(--green);box-shadow:0 0 0 1px rgba(var(--green-rgb),.4),0 2px 12px #0006}.input-wrap.multi>.input-mic,.input-wrap.multi>.input-send{position:absolute;bottom:7px;margin:0;pointer-events:none}.input-wrap.multi>.input-send{right:6px}.input-wrap.multi>.input-mic{right:44px}.input-wrap.multi.crowd>.input-text{padding-bottom:29px}.input-mirror{position:absolute;left:-9999px;top:0;visibility:hidden;pointer-events:none;box-sizing:border-box;padding:7px 6px 7px 11px;border:none;font-family:inherit;font-size:16px;line-height:22px;white-space:pre-wrap;overflow-wrap:break-word}.input-wrap>.input-history,.input-wrap>.input-mic,.input-wrap>.input-send{margin-bottom:2px}.cmd-capture{position:absolute;left:0;bottom:0;width:2px;height:2px;padding:0;margin:0;border:0;opacity:0;background:transparent;color:transparent}.quick-bar{display:flex;align-items:stretch;gap:6px;padding:0 2px 6px}.quick-fixed{flex:none;display:flex;gap:6px;padding-right:7px;border-right:1px solid #33363d}.quick-fix{flex:none;display:inline-flex;align-items:center;gap:5px;border:1px solid #4a4f59;border-radius:9px;background:#383c44;color:#eaedf2;font:inherit;font-size:13.5px;font-weight:600;padding:7px 11px;line-height:1;touch-action:manipulation;-webkit-user-select:none;user-select:none}.quick-fix svg{width:15px;height:15px;display:block;color:#aeb6c2}.quick-fix:active{background:#454a54}.quick-fix:disabled{opacity:.4}.quick-scroll{flex:1;min-width:0;display:flex;align-items:stretch;gap:5px;overflow-x:auto;scrollbar-width:none;-webkit-overflow-scrolling:touch}.quick-scroll::-webkit-scrollbar{display:none}.quick-cmd{flex:none;display:inline-flex;align-items:center;white-space:nowrap;border:1px solid;border-radius:12px;color:rgb(var(--rgb));border-color:rgba(var(--rgb),.5);background:rgba(var(--rgb),.13);font:inherit;font-size:13.5px;padding:7px 12px;line-height:1;touch-action:manipulation;-webkit-user-select:none;user-select:none}.quick-cmd:active{background:rgba(var(--rgb),.26);border-color:rgb(var(--rgb))}.quick-cmd-add{--rgb: 150,157,167;border-style:dashed;padding:7px 11px}.quick-cmd-add svg{width:15px;height:15px;display:block}.qc-enter{margin-left:5px;opacity:.7;font-size:12px}.qc-esc{--rgb: 150,157,167}.qc-reply{--rgb: 95,208,138}.qc-cmd{--rgb: 110,168,255}.quick-cmd-plain{--rgb: 150,157,167}.quick-cmd-win{--rgb: 95,208,138}.dock-upload{padding:6px 4px 2px}.dock-upload-label{display:block;color:#9fb6d0;font-size:12px;margin-bottom:4px;font-variant-numeric:tabular-nums}.dock-upload.error .dock-upload-label{color:var(--red)}.dock-upload-track{display:block;height:4px;border-radius:2px;background:#1c2b3a;overflow:hidden}.dock-upload-fill{display:block;height:100%;background:#3a5a7e;transition:width .1s linear}.upload-overlay{position:fixed;top:0;right:0;bottom:0;left:0;z-index:200;display:grid;place-items:center;background:#0000009e;-webkit-backdrop-filter:blur(2px);backdrop-filter:blur(2px);padding:24px;padding-bottom:calc(24px + env(safe-area-inset-bottom))}.upload-card{width:min(340px,86vw);background:#141b24;border:1px solid #24303d;border-radius:14px;padding:22px 20px 18px;box-shadow:0 24px 70px #0009;text-align:center}.upload-card-label{color:#e6edf3;font-size:14px;margin-bottom:16px;word-break:break-all;font-variant-numeric:tabular-nums}.upload-bar{height:6px;border-radius:3px;background:#1c2b3a;overflow:hidden}.upload-bar-fill{height:100%;background:var(--green);transition:width .12s linear}.upload-card-sub{color:#9fb6d0;font-size:12px;margin-top:10px;font-variant-numeric:tabular-nums}.upload-spinner{width:34px;height:34px;margin:4px auto 0;border-radius:50%;border:3px solid #24303d;border-top-color:var(--green);animation:upload-spin .8s linear infinite}@keyframes upload-spin{to{transform:rotate(360deg)}}.upload-cancel{margin-top:18px;width:100%;height:40px;border:1px solid #33424f;border-radius:10px;background:#1c2530;color:#e6edf3;font-size:14px}.upload-cancel:active{background:#243040}@media (prefers-reduced-motion: reduce){.upload-spinner{animation-duration:1.6s}}.input-send{flex:none;width:34px;height:34px;display:inline-flex;align-items:center;justify-content:center;border:none;border-radius:50%;background:var(--blue);color:#fff;padding:0;box-shadow:0 1px 4px rgba(var(--blue-rgb),.45);touch-action:manipulation;-webkit-user-select:none;user-select:none}.input-send:active{background:var(--blue-press)}.input-send:disabled{background:#00000040;color:#b6c0cc;box-shadow:none}.input-send svg{width:20px;height:20px;display:block}.input-history{flex:none;width:34px;height:34px;display:inline-flex;align-items:center;justify-content:center;border:none;border-radius:8px;background:transparent;color:#8a93a0;padding:0;touch-action:manipulation;-webkit-user-select:none;user-select:none}.input-history svg{width:20px;height:20px;display:block}.input-history:active{color:var(--blue)}.input-wrap.recording{background:rgba(var(--green-rgb),.14);border:2px solid var(--green);animation:rec-breathe 1.3s ease-in-out infinite}.input-wrap.recording .input-text{color:#eafff3}@keyframes rec-breathe{0%,to{box-shadow:0 0 5px rgba(var(--green-rgb),.3);border-color:#2fd07e}50%{box-shadow:0 0 16px 4px rgba(var(--green-rgb),.8);border-color:var(--green)}}.input-mic{flex:none;width:34px;height:34px;display:inline-flex;align-items:center;justify-content:center;border:none;border-radius:50%;background:#00000040;color:#b6c0cc;padding:0;touch-action:manipulation;-webkit-user-select:none;user-select:none}.input-mic.on{color:#fff;background:var(--green)}.input-mic:disabled{opacity:.5}.input-mic svg{width:21px;height:21px;display:block}.cmd-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;background:#00000080;z-index:21}.cmd-panel{position:fixed;left:0;right:0;bottom:0;z-index:22;max-height:72vh;display:flex;flex-direction:column;background:#232427;border-radius:16px 16px 0 0;box-shadow:0 -8px 30px #00000080}.cmd-panel.history{min-height:40vh}.cmd-head{display:flex;align-items:center;gap:8px;padding:12px 14px;border-bottom:1px solid #33353a}.cmd-title{font-size:16px;font-weight:600;margin-right:auto;display:flex;align-items:center;gap:7px}.idea-head-count{font-size:11px;font-weight:600;color:#a8c4e0;background:#3d5068;border-radius:8px;padding:1px 6px}.cmd-close{flex:none;display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;padding:0;border:none;border-radius:50%;background:#78788029;color:#ebebf58c;font-size:15px;line-height:1;touch-action:manipulation}.cmd-close:active{background:#7878804d;color:#fff}.cmd-close svg{width:14px;height:14px;display:block}.cmd-add-open{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border:none;border-radius:9px;background:var(--blue);color:#fff;padding:0;touch-action:manipulation;box-shadow:0 1px 4px rgba(var(--blue-rgb),.4)}.cmd-add-open:active{background:var(--blue-press)}.cmd-add-open svg{width:18px;height:18px;display:block}.cmd-list{overflow-y:auto;-webkit-overflow-scrolling:touch;padding:6px 0 10px}.cmd-editor .cmd-list{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden;padding:0}.cmd-editor .cmd-esection{flex:1 1 0;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch;display:flex;flex-direction:column}.cmd-editor .cmd-esection .cmd-section{position:sticky;top:0;z-index:1;background:#1c1c1e}.cmd-editor .cmd-empty{flex:1;display:flex;align-items:center;justify-content:center;font-size:15px}.cmd-panel.cmd-editor{background:#1c1c1e}.cmd-editor .cmd-section{padding:12px 16px 6px}.cmd-editor .cmd-section-name{font-size:13px;letter-spacing:.4px}.cmd-editor .cmd-row{padding:0 10px 0 16px}.cmd-editor .cmd-row:not(:last-child){box-shadow:inset 0 -.5px #54545857}.cmd-editor .cmd-text{font-size:14px;padding:12px 0;color:#f2f2f7}.cmd-editor .cmd-enter{font-size:14px}.cmd-editor .cmd-move svg{width:18px;height:18px}.cmd-editor .cmd-del svg{width:16px;height:16px}.cmd-section{display:flex;align-items:baseline;gap:8px;padding:9px 14px 5px}.cmd-section.recent{margin-top:6px;border-top:1px solid #2c2e33;padding-top:12px}.cmd-section-name{font-size:12px;font-weight:700;letter-spacing:1px}.cmd-section.fav .cmd-section-name{color:#ffd27a}.cmd-section.recent .cmd-section-name{color:#9db8ff}.cmd-scope{margin-left:auto;font-size:10px;color:#9a9a9a;background:#2c2e33;padding:2px 8px;border-radius:10px;white-space:nowrap}.cmd-row{display:flex;align-items:center;gap:6px;padding:0 8px 0 14px}.cmd-text{flex:1;min-width:0;padding:9px 0;font:13px/1.3 ui-monospace,SF Mono,Menlo,monospace;color:#e6e6e6;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;touch-action:manipulation}button.cmd-text{background:none;border:none;text-align:left}.cmd-text:active{color:#9db8ff}.cmd-star{flex:none;background:none;border:none;color:#667;padding:6px 8px;touch-action:manipulation;display:inline-flex;align-items:center;justify-content:center;min-width:40px;min-height:44px}.cmd-star svg{width:16px;height:16px;display:block}.cmd-star.on{color:#ffd27a}.cmd-star.on svg{fill:currentColor}.cmd-copy{flex:none;background:none;border:none;color:#8a93a0;padding:6px 8px;touch-action:manipulation;display:inline-flex;align-items:center;justify-content:center;min-width:40px;min-height:44px}.cmd-copy svg{width:15px;height:15px;display:block}.cmd-copy:active{color:#9db8ff}.cmd-del{flex:none;background:none;border:none;color:#888;padding:6px 8px;touch-action:manipulation;display:inline-flex;align-items:center;justify-content:center;min-width:40px;min-height:44px}.cmd-del svg{width:14px;height:14px;display:block}.cmd-empty{padding:20px 14px;color:#888;font-size:13px;text-align:center}.cmd-hint{padding:2px 14px 8px;color:#7f8794;font-size:11px}.fav-chips{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:10px}.fav-chip{background:#2a3a2e;border:1px solid #3d5a44;color:#9fe0af;border-radius:16px;padding:8px 14px;font-size:14px}.fav-add{display:flex;gap:8px;margin-top:8px}.fav-add-input{flex:1;min-width:0;background:#16171c;border:1px solid #444;border-radius:8px;color:#fff;padding:8px;font-size:16px}.fav-add-btn{border:none;border-radius:8px;background:var(--blue);color:#fff;padding:0 14px}.cmd-panel.cmd-editor{min-height:58vh}.cmd-esection{padding-bottom:6px}.cmd-esection.win{margin-top:4px;border-top:1px solid #2c2e33}.cmd-section.global .cmd-section-name{color:#aab0ba}.cmd-section.win .cmd-section-name{color:#5fd08a}.cmd-fav-text{flex:1}.cmd-enter{margin-left:7px;opacity:.65;font-size:12px}.cmd-move{flex:none;background:none;border:none;color:#8a93a0;padding:6px 5px;touch-action:manipulation;display:inline-flex;align-items:center;justify-content:center;min-width:36px;min-height:44px}.cmd-move svg{width:16px;height:16px;display:block}.cmd-move.up svg{transform:rotate(180deg)}.cmd-move:active{color:#9db8ff}.cmd-move:disabled{opacity:.28}.cmd-add-backdrop{z-index:23;background:#0009}.cmd-addcard{position:fixed;left:50%;top:50%;z-index:24;width:min(360px,calc(100vw - 32px));display:flex;flex-direction:column;gap:16px;background:#1c1c1e;border-radius:20px;padding:18px 18px 20px;box-shadow:0 24px 60px #000000a8;-webkit-font-smoothing:antialiased}.cmd-addcard-head{display:flex;align-items:center}.cmd-addcard-head .cmd-title{margin-right:auto;font-size:17px;font-weight:700;letter-spacing:.2px}.cmd-modetabs{display:flex;gap:24px;border-bottom:1px solid rgba(84,84,88,.5);padding:0 2px}.cmd-modetab{position:relative;border:none;background:none;padding:2px 0 12px;font:inherit;font-size:17px;font-weight:600;color:#ebebf580;touch-action:manipulation;transition:color .15s}.cmd-modetab.on{color:#fff}.cmd-modetab.on:after{content:"";position:absolute;left:0;right:0;bottom:-1px;height:2.5px;border-radius:2px;background:var(--blue)}.cmd-seg{display:flex;gap:2px;background:#76768038;border-radius:9px;padding:2px}.cmd-seg-btn{flex:1;border:none;background:transparent;color:#ebebf59e;font:inherit;font-size:14px;font-weight:600;padding:8px 0;border-radius:7px;touch-action:manipulation;transition:color .15s}.cmd-seg-btn.on{background:#48484a;color:#fff;box-shadow:0 1px 3px #00000059,0 0 0 .5px #0003}.cmd-seg-btn.win.on{background:#2f6f4a;color:#d6f5e2}.cmd-seg-btn:active:not(.on){color:#fff}.cmd-seg-btn:disabled{opacity:.38}.cmd-field{display:flex;flex-direction:column;gap:8px}.cmd-field-label{font-size:12px;font-weight:600;color:#ebebf599;padding-left:2px}.cmd-add-input{width:100%;box-sizing:border-box;background:#2c2c2e;border:none;border-radius:10px;color:#f2f2f7;padding:12px 13px;font:16px/1.2 ui-monospace,SF Mono,Menlo,monospace;box-shadow:0 0 0 1px transparent;transition:box-shadow .15s}.cmd-add-input::placeholder{color:#ebebf54d}.cmd-add-input:focus{outline:none;box-shadow:0 0 0 2px var(--blue)}.cmd-chord-preview{align-self:flex-start;color:#64d2ff;font-size:13px;font-weight:700;letter-spacing:.3px}.cmd-toggle-row{display:flex;align-items:center;justify-content:space-between;gap:8px;background:#2c2c2e;border-radius:10px;padding:11px 13px;color:#f2f2f7;font-size:15px;touch-action:manipulation}.cmd-switch{position:relative;flex:none;width:51px;height:31px}.cmd-switch input{position:absolute;top:0;right:0;bottom:0;left:0;width:100%;height:100%;margin:0;opacity:0;z-index:1}.cmd-switch-track{position:absolute;top:0;right:0;bottom:0;left:0;background:#39393d;border-radius:16px;transition:background .22s ease}.cmd-switch input:checked~.cmd-switch-track{background:var(--green)}.cmd-switch-knob{position:absolute;top:2px;left:2px;width:27px;height:27px;border-radius:50%;background:#fff;box-shadow:0 2px 5px #00000059;transition:transform .22s ease}.cmd-switch input:checked~.cmd-switch-knob{transform:translate(20px)}.cmd-submit{border:none;border-radius:13px;background:var(--blue);color:#fff;font:inherit;font-size:17px;font-weight:600;padding:13px 0;margin-top:2px;touch-action:manipulation;transition:background .12s}.cmd-submit:active{background:var(--blue-press)}.cmd-submit:disabled{background:#2c2c2e;color:#ebebf54d}.cmd-dd{position:relative}.cmd-dd-btn{display:flex;align-items:center;justify-content:space-between;width:100%;box-sizing:border-box;background:#2c2c2e;border:none;border-radius:10px;color:#f2f2f7;font:inherit;font-size:16px;padding:12px 13px;box-shadow:0 0 0 2px transparent;transition:box-shadow .15s;touch-action:manipulation}.cmd-dd-btn.open{box-shadow:0 0 0 2px var(--blue)}.cmd-dd-btn svg{width:17px;height:17px;color:#ebebf580;transition:transform .18s}.cmd-dd-btn.open svg{transform:rotate(180deg)}.cmd-dd-ph{color:#ebebf54d}.cmd-dd-scrim{position:fixed;top:0;right:0;bottom:0;left:0;z-index:24}.cmd-dd-menu{position:absolute;left:0;right:0;top:calc(100% + 6px);z-index:25;background:#2c2c2e;border-radius:12px;padding:5px;max-height:216px;overflow-y:auto;-webkit-overflow-scrolling:touch;box-shadow:0 14px 36px #0009,0 0 0 .5px #0006}.cmd-dd-opt{display:flex;align-items:center;justify-content:space-between;width:100%;border:none;background:transparent;color:#f2f2f7;font:inherit;font-size:16px;text-align:left;padding:11px 12px;border-radius:8px;touch-action:manipulation}.cmd-dd-opt.on{color:var(--blue);font-weight:600}.cmd-dd-opt:active{background:#3a3a3c}.cmd-dd-opt svg{width:16px;height:16px}.idea-panel{position:fixed;left:0;right:0;bottom:0;z-index:22;min-height:50vh;max-height:80vh;display:flex;flex-direction:column;background:#232427;border-radius:16px 16px 0 0;box-shadow:0 -8px 30px #00000080}.idea-compose{display:flex;align-items:flex-end;gap:8px;padding:10px 14px;border-bottom:1px solid #33353a}.idea-input{flex:1;min-width:0;resize:none;max-height:96px;padding:8px 10px;border:1px solid #3a3c42;border-radius:8px;background:#1b1c1f;color:#e6e6e6;font:14px/1.4 -apple-system,system-ui,sans-serif}.idea-input:focus{outline:none;border-color:var(--blue)}.idea-compose .input-mic{position:static;right:auto;bottom:auto;flex:none}.idea-act{flex:none;width:34px;height:34px;display:inline-flex;align-items:center;justify-content:center;border:none;border-radius:50%;background:var(--blue);color:#fff;padding:0;touch-action:manipulation}.idea-act:disabled{background:#00000040;color:#b6c0cc;opacity:.55}.idea-act svg{width:20px;height:20px;display:block}.idea-cancel{flex:none;background:none;border:none;color:#aaa;font-size:13px;padding:9px 4px;touch-action:manipulation}.idea-list{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:6px 0 12px}.idea-list:has(.cmd-empty){display:flex;align-items:center;justify-content:center}.idea-row{display:flex;align-items:center;gap:6px;padding:0 8px 0 4px;background:#232427}.idea-row.editing{background:#262a31}.idea-row.dragging{background:#2c2f36;border-radius:8px;box-shadow:0 2px 12px #00000073}.idea-handle{flex:none;background:none;border:none;color:#777;font-size:20px;line-height:1;padding:11px 16px;cursor:grab;touch-action:none;-webkit-user-select:none;user-select:none}.idea-text{flex:1;min-width:0;padding:11px 0;font-size:14px;line-height:1.35;color:#e6e6e6;white-space:pre-wrap;overflow-wrap:anywhere;touch-action:manipulation}.idea-text:active{color:#9db8ff}.idea-send{flex:none;background:#2c2e33;color:#9db8ff;border:none;border-radius:8px;padding:6px 10px;font-size:13px;touch-action:manipulation}.idea-del{flex:none;background:none;border:none;color:#888;font-size:14px;padding:6px 8px;touch-action:manipulation;display:inline-flex;align-items:center;justify-content:center;min-width:40px;min-height:44px}.changelog-panel{position:fixed;left:0;right:0;bottom:0;z-index:22;max-height:80vh;display:flex;flex-direction:column;background:#232427;border-radius:16px 16px 0 0;box-shadow:0 -8px 30px #00000080}.changelog-list{overflow-y:auto;-webkit-overflow-scrolling:touch;padding:6px 16px 16px}.rel{padding:12px 0;border-bottom:1px solid #2c2e33}.rel:last-child{border-bottom:none}.rel-date{display:flex;align-items:baseline;gap:8px;margin-bottom:6px}.rel-ver{font-size:13px;font-weight:700;letter-spacing:.3px;color:#9db8ff;font-variant-numeric:tabular-nums}.rel-day{font-size:11px;font-weight:600;letter-spacing:.5px;color:#6b6f77;font-variant-numeric:tabular-nums}.rel-items{margin:0;padding-left:18px}.rel-items li{font-size:13px;line-height:1.5;color:#e6e6e6;margin:3px 0}.topbar-icon{position:relative}.topbar-dot{position:absolute;top:7px;right:7px;width:7px;height:7px;border-radius:50%;background:var(--red);box-shadow:0 0 0 2px #1b1c1f}.idea-badge{position:absolute;top:5px;right:4px;min-width:14px;height:14px;border-radius:7px;padding:0 3px;box-sizing:border-box;background:#3d5068;color:#a8c4e0;font-size:9px;font-weight:700;line-height:14px;text-align:center;pointer-events:none}.settings-dot{display:inline-block;width:7px;height:7px;margin-left:6px;border-radius:50%;background:var(--red);vertical-align:middle}.sheet-actions{display:flex;flex-direction:column;gap:8px}.sheet-action{width:100%;padding:12px;border:1px solid #33353a;border-radius:8px;background:#2a2c31;color:#ddd;font-size:15px;text-align:center;touch-action:manipulation}.sheet-action.danger{color:var(--red)}.sheet-action.armed{background:var(--red);color:#fff;border-color:var(--red)}.sheet-action:disabled{opacity:.4}.sheet-row{display:flex;gap:8px}.sheet-row .sheet-action{flex:1}.sheet-cancel{margin-top:4px}.win-tab,.session-name{-webkit-user-select:none;user-select:none;-webkit-touch-callout:none}.file-sheet{position:fixed;top:0;right:0;bottom:0;left:0;z-index:50;display:flex;flex-direction:column;background:#0b0f14;transform:translateY(100%);transition:transform .28s cubic-bezier(.22,.61,.36,1);pointer-events:none}.file-sheet.open{transform:translateY(0);pointer-events:auto}.file-tabs{display:flex;align-items:stretch;border-bottom:1px solid #1d2630;background:#11161d}.file-tabs-scroll{display:flex;overflow-x:auto;flex:1;-webkit-overflow-scrolling:touch}.file-tab{display:inline-flex;align-items:center;white-space:nowrap;border-right:1px solid #1d2630}.file-tab.active{background:#0b0f14;box-shadow:inset 0 -2px 0 var(--blue)}.file-tab-label{display:inline-flex;align-items:center;gap:5px;padding:10px 8px 10px 12px;background:transparent;border:none;color:#9fb0c0;font-size:13px}.file-tab-label svg{width:15px;height:15px}.file-tab.active .file-tab-label{color:#e6edf3}.file-tab-x{background:transparent;border:none;color:#6b7a89;font-size:11px;padding:0 10px 0 2px}.file-min{flex:0 0 auto;width:44px;display:inline-flex;align-items:center;justify-content:center;background:transparent;color:#9fb0c0;border:none;border-left:1px solid #1d2630}.file-min svg{width:20px;height:20px}.file-body{flex:1;overflow:hidden;display:flex}.preview-frame{flex:1;width:100%;border:0;background:#fff}.preview-body.pc{display:block;position:relative;overflow:auto;-webkit-overflow-scrolling:touch}.preview-scaler{position:relative}.preview-body.pc .preview-frame{position:absolute;top:0;left:0;flex:none}.preview-zoom{position:absolute;bottom:14px;left:50%;transform:translate(-50%);z-index:2;display:flex;align-items:center;gap:2px;padding:4px;background:#1c1e22eb;border:1px solid #3a3c42;border-radius:999px;box-shadow:0 6px 20px #00000073}.preview-zoom-btn{width:34px;height:30px;display:inline-flex;align-items:center;justify-content:center;background:transparent;border:none;color:#e6edf3;font-size:20px;line-height:1;border-radius:999px;touch-action:manipulation}.preview-zoom-btn:disabled{color:#5a626b}.preview-zoom-val{min-width:46px;text-align:center;color:#9fb0c0;font-size:13px;font-variant-numeric:tabular-nums}.preview-head{position:relative;align-items:center;gap:4px;min-height:44px;padding-left:10px}.preview-status{display:inline-flex;align-items:center;gap:5px;min-width:0;color:#e6edf3;font-size:12px}.preview-status svg{width:15px;height:15px;flex:0 0 auto}.preview-state{color:var(--green);font-weight:600;flex:0 0 auto}.preview-name{color:#9fb0c0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}.preview-detail{color:#6e7d8c;margin-left:6px}.preview-remain{margin-left:auto;flex:0 0 auto;font-variant-numeric:tabular-nums;color:#9fb0c0;font-size:12px;padding:4px 8px;background:transparent;border:1px solid #33353a;border-radius:8px;touch-action:manipulation}.preview-remain:active{color:var(--blue);border-color:var(--blue)}.preview-pop-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:5}.preview-pop{position:absolute;top:46px;right:8px;z-index:6;min-width:168px;display:flex;flex-direction:column;gap:2px;padding:6px;background:#232427;border:1px solid #33353a;border-radius:10px;box-shadow:0 10px 30px #00000080}.preview-pop-item{background:transparent;border:none;color:#e6edf3;text-align:left;padding:10px 12px;border-radius:8px;font-size:14px}.preview-pop-item:active{background:#2f3338}.preview-pop-danger{color:var(--red)}.preview-iconbtn{flex:0 0 auto;width:32px;display:inline-flex;align-items:center;justify-content:center;background:transparent;border:none;color:#9fb0c0;touch-action:manipulation}.preview-iconbtn svg{width:17px;height:17px}.preview-iconbtn:active{color:var(--blue)}.preview-head .file-min{width:34px}.preview-head .file-min svg{width:18px;height:18px}.preview-confirm-backdrop{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;display:flex;align-items:center;justify-content:center;background:#00000080}.preview-confirm{width:min(86vw,320px);background:#232427;border-radius:12px;padding:16px;box-shadow:0 10px 40px #00000080}.preview-confirm-title{font-size:16px;font-weight:600;color:#e6edf3;margin-bottom:8px}.preview-confirm-text{font-size:13px;color:#9aa3ad;line-height:1.5;margin-bottom:14px}.preview-confirm-btns{display:flex;justify-content:flex-end;gap:8px}.preview-confirm-danger{background:#5a1f1f;color:var(--red)}.settings-group{margin:16px -16px 0;padding:7px 16px;background:#1b1c1f;color:#9aa3ad;font-size:11px;font-weight:700;letter-spacing:1.2px;border-top:1px solid #33353a;border-bottom:1px solid #33353a}.settings-head+.settings-group{margin-top:0}.settings-group+.settings-section{border-top:none}.preview-running{display:inline-flex;align-items:center;gap:6px;color:var(--green);font-weight:600;font-size:13px}.live-dot{width:8px;height:8px;border-radius:50%;background:var(--green)}.preview-port{width:9em;padding:6px 10px;font-size:15px;color:#e8e8ea;background:#1b1c1f;border:1px solid #3a3c42;border-radius:8px}.preview-seg{display:inline-flex;align-self:flex-start;gap:2px;background:#76768038;border-radius:9px;padding:2px}.preview-seg button{background:transparent;border:none;color:#ebebf599;padding:7px 18px;min-width:60px;font-size:14px;font-weight:600;border-radius:7px;touch-action:manipulation;transition:color .15s}.preview-seg button.on{background:#48484a;color:#fff;box-shadow:0 1px 3px #00000059,0 0 0 .5px #0003}.preview-seg button:active:not(.on){color:#fff}.preview-start{display:flex;flex-direction:column;gap:12px}.preview-active{display:flex;flex-direction:column;gap:10px}.preview-active-head{display:flex;align-items:center;gap:10px}.preview-remain-s{margin-left:auto;font-variant-numeric:tabular-nums;color:#9aa3ad;font-size:13px}.settings-err{color:var(--red)}.preview-live{color:var(--green)}.home-pane{flex:1;display:flex;flex-direction:column;min-width:0}.file-seg{display:flex;gap:2px;margin:10px 12px;padding:2px;background:#76768033;border-radius:9px}.file-seg-btn{flex:1;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:7px;border:none;border-radius:7px;background:transparent;color:#ebebf599;font-size:14px;font-weight:600;touch-action:manipulation;transition:color .15s}.file-seg-btn.on{background:#48484a;color:#fff;box-shadow:0 1px 3px #00000059,0 0 0 .5px #0003}.file-seg-btn:active:not(.on){color:#cfe0f2}.file-seg-btn svg{width:16px;height:16px}.git-head{align-items:stretch;min-height:42px}.git-back{flex:0 0 auto;width:40px;align-self:stretch;display:inline-flex;align-items:center;justify-content:center;background:transparent;border:none;color:#9fb0c0;font-size:26px;line-height:1}.git-drill-head{flex:1;min-width:0;display:flex;align-items:center;gap:4px;overflow:hidden}.git-drill-title{display:inline-flex;align-items:center;gap:7px;min-width:0;color:#e6edf3;font-size:14px}.git-drill-title svg{width:16px;height:16px;color:#6b86a3;flex:0 0 auto}.git-title-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.git-tabs-scroll{display:flex;align-items:stretch;overflow-x:auto;flex:1;-webkit-overflow-scrolling:touch}.git-tab-add{flex:0 0 auto;width:40px;display:inline-flex;align-items:center;justify-content:center;background:transparent;border:none;border-right:1px solid #1d2630;color:#8fb2dc;font-size:20px;line-height:1}.git-tab{flex:0 0 auto;display:inline-flex;align-items:center;white-space:nowrap;border-right:1px solid #1d2630}.git-tab.active{background:#0b0f14;box-shadow:inset 0 -2px 0 var(--blue)}.git-tab-label{display:inline-flex;align-items:center;padding:10px 4px 10px 12px;background:transparent;border:none;color:#9fb0c0;font-size:13px}.git-tab.active .git-tab-label{color:#e6edf3}.git-tab-x{background:transparent;border:none;color:#6b7a89;font-size:11px;padding:0 10px 0 4px;align-self:stretch}.git-branch-bar{display:flex;align-items:center;gap:10px;padding:7px 12px 7px 14px;border-bottom:1px solid #1d2630;background:#0b0f14;font-size:13px}.git-branch-bar-name{flex:1;min-width:0;color:#cfe0f2;font-weight:600;display:flex;align-items:center;gap:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.git-branch-glyph{color:#8fb2dc}.git-branch-cur-tag{flex:0 0 auto;font-size:11px;font-weight:600;color:var(--green);background:rgba(var(--green-rgb),.12);border:1px solid rgba(var(--green-rgb),.4);border-radius:999px;padding:1px 7px}.git-branch-bar-track{flex:0 0 auto;font-size:12px;font-variant-numeric:tabular-nums;display:flex;gap:5px}.git-branch-dd{position:relative;flex:0 0 auto}.git-branch-trigger{max-width:46vw;display:flex;align-items:center;gap:5px;background:#11161d;border:1px solid #1d2630;border-radius:8px;color:#9fb0c0;font-size:12px;padding:5px 9px}.git-branch-trigger.other{border-color:#2f6fc0;background:#15212f;color:#cfe0f2}.git-branch-trigger-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.git-branch-caret{flex:0 0 auto;display:inline-flex;color:#6b7a89}.git-branch-caret svg{width:13px;height:13px}.git-dd-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:40}.git-dd-menu{position:absolute;top:calc(100% + 6px);right:0;z-index:41;min-width:56vw;max-width:calc(100vw - 24px);max-height:56vh;overflow:auto;-webkit-overflow-scrolling:touch;background:#0e141b;border:1px solid #243040;border-radius:10px;box-shadow:0 10px 28px #00000080;padding:4px}.git-dd-item{display:flex;align-items:center;gap:8px;width:100%;text-align:left;background:transparent;border:none;color:#e6edf3;padding:10px;font-size:13px;border-radius:7px}.git-dd-item:active{background:#15212f}.git-dd-item.viewing{background:#15212f;box-shadow:inset 3px 0 #2f6fc0}.git-dd-item.current .git-row-path{color:#cfe0f2;font-weight:600}.git-section{display:flex;flex-direction:column;min-height:0;border-bottom:1px solid #161d26}.git-section:not(.open){flex:0 0 auto}.git-section.open.git-section--top{flex:0 1 auto;min-height:25%;max-height:50%}.git-section.open.git-section--mid{flex:1 1 0;min-height:96px}.git-section-head{flex:0 0 auto;display:flex;align-items:center;gap:8px;width:100%;text-align:left;background:#0b0f14;border:none;color:#9fb0c0;padding:10px 14px;font-size:13px;font-weight:600}.git-section-head:active{background:#11161d}.git-section-caret{flex:0 0 auto;display:inline-flex;color:#6b7a89;transition:transform .15s;transform:rotate(-90deg)}.git-section.open .git-section-caret{transform:rotate(0)}.git-section-caret svg{width:14px;height:14px}.git-section-title{flex:0 0 auto}.git-section-sub{flex:1;min-width:0;color:#6b7a89;font-weight:400;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.git-section-count{flex:0 0 auto;margin-left:auto;color:#6b7a89;font-weight:400;font-size:12px;font-variant-numeric:tabular-nums}.git-section-sub+.git-section-count{margin-left:8px}.git-section-body{flex:1;min-height:0;overflow:auto;-webkit-overflow-scrolling:touch}.git-more{text-align:center;color:#6b7a89;font-size:12px;padding:12px}.git-body{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden;padding-bottom:var(--kb-inset, 0px)}.git-scroll{flex:1;min-height:0;overflow:auto;-webkit-overflow-scrolling:touch}.git-error{color:var(--red);font-size:13px;padding:14px 16px}.git-loading,.git-empty{color:#6b7a89;font-size:13px;padding:16px}.git-notice{color:#8b97a5;font-size:13px;line-height:1.5;padding:14px 16px}.git-list{display:flex;flex-direction:column}.git-row{display:flex;align-items:center;gap:9px;width:100%;text-align:left;background:transparent;border:none;border-bottom:1px solid #161d26;color:#e6edf3;padding:11px 14px;font-size:13px}.git-row:active{background:#11161d}.git-row-path{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1}.git-badge{flex:0 0 auto;width:20px;text-align:center;font-size:12px;font-weight:600;font-variant-numeric:tabular-nums;color:#9aa3ad}.git-badge-m{color:#d8a13a}.git-badge-a{color:var(--green)}.git-badge-d{color:#f06b6b}.git-badge-r{color:#8fb2dc}.git-badge-q{color:#6b7a89}.git-commit-row{align-items:flex-start}.git-hash{flex:0 0 auto;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#8fb2dc;font-size:12px;padding-top:1px}.git-commit-main{display:flex;flex-direction:column;gap:3px;min-width:0;flex:1}.git-commit-meta{color:#6b7a89;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.git-branch{gap:8px}.git-branch.current .git-row-path{color:#cfe0f2;font-weight:600}.git-branch.viewing{background:#11161d;box-shadow:inset 3px 0 #2f6fc0}.git-branch-dot{flex:0 0 auto;width:12px;text-align:center;color:var(--green);font-size:11px}.git-branch-up{flex:0 0 auto;color:#6b7a89;font-size:11px;font-variant-numeric:tabular-nums}.git-ahead{color:var(--green)}.git-behind{color:#f06b6b}.git-commit-detail{display:flex;flex-direction:column}.git-commit-msg{white-space:pre-wrap;word-break:break-word;margin:0;padding:14px 16px;color:#c9d6e2;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.5;border-bottom:1px solid #161d26;background:#0e131a}.git-diff-wrap{display:flex;flex-direction:column}.git-diff-zoom{position:sticky;top:0;z-index:2;display:flex;gap:6px;justify-content:flex-end;padding:8px 12px}.git-diff-trunc{position:sticky;top:0;z-index:1;background:#3a2a12;color:#f0c674;font-size:12px;padding:8px 14px;border-bottom:1px solid #5a4318}.git-diff-fname{padding:10px 14px 6px;color:#9fb0c0;font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.git-diff{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:inherit;line-height:1.5;white-space:pre;overflow-x:auto;-webkit-overflow-scrolling:touch}.git-diff-line{padding:0 14px;min-width:max-content}.git-diff-hunk{color:#6b86a3;background:#0e131a}.git-diff-add{background:#3fb95029;color:#c7f0cf}.git-diff-del{background:#f06b6b29;color:#f3cccc}.git-diff-ctx{color:#c9d6e2}.home-view{flex:1;overflow:auto;-webkit-overflow-scrolling:touch;padding:12px;color:#c9d6e2}.home-empty{color:#6b7a89;font-size:13px;display:flex;flex-direction:column;gap:4px;padding:16px 4px}.home-empty span{color:#4a5663;font-size:12px}.home-recent-row{display:flex;align-items:center;width:100%}.home-recent{flex:1;display:flex;flex-direction:column;align-items:flex-start;background:#11161d;border:1px solid #1d2630;border-radius:8px;padding:8px 10px;margin-bottom:6px;color:#e6edf3;text-align:left}.home-path{font-size:11px;color:#6b7a89;overflow:hidden;text-overflow:ellipsis;max-width:80vw}.home-x{background:transparent;border:none;color:#6b7a89;padding:0 8px}.browse-view{flex:1;display:flex;flex-direction:column;min-height:0;position:relative}.browse-bar{display:flex;align-items:center;gap:6px;padding:6px 8px;border-bottom:1px solid #1d2630;background:#0e131a}.browse-root-dd{flex:0 0 auto}.browse-root{display:inline-flex;align-items:center;gap:3px;padding:9px 4px 9px 10px;background:transparent;border:none;color:#6b86a3;font-size:13px}.browse-root .dd-caret{font-size:10px}.browse-root-dd .dd-menu{left:0;right:auto;min-width:120px}.browse-up{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:44px;height:44px;background:#11161d;border:1px solid #1d2630;border-radius:8px;color:#9fb0c0}.browse-up:disabled{opacity:.4}.browse-up svg{width:18px;height:18px}.browse-cwd{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:44px;height:44px;background:#11161d;border:1px solid #1d2630;border-radius:8px;color:#9fb0c0}.browse-cwd svg{width:18px;height:18px}.browse-path{flex:1;min-width:0;display:flex;align-items:center;border-radius:8px;border:1px solid #243446;background:#11161d}.browse-path:focus-within{border-color:#3a5a7e}.browse-input:focus{outline:none}.browse-home{flex:0 0 auto;padding:9px 2px 9px 10px;color:#6b86a3;font-size:13px;-webkit-user-select:none;user-select:none}.browse-input{flex:1;min-width:0;padding:9px 10px 9px 0;border:none;border-radius:8px;background:transparent;color:#e6edf3;font-size:13px}.browse-err{padding:8px 12px 0}.browse-notice{position:absolute;left:50%;bottom:16px;transform:translate(-50%);z-index:6;max-width:88%;width:max-content;padding:9px 14px;border-radius:10px;background:#1c1e22f7;color:#d3dbe4;font-size:13px;line-height:1.4;box-shadow:0 6px 24px #00000080;pointer-events:none;word-break:break-all}.browse-saved{display:flex;align-items:center;gap:10px;margin:8px 12px 0;padding:8px 10px;border:1px solid #2a3f56;border-radius:8px;background:#15212e}.browse-saved-text{flex:1;min-width:0;color:#cfe0f0;font-size:13px}.browse-saved-close{flex:0 0 auto;width:32px;height:32px;border:none;border-radius:8px;background:transparent;color:#8aa0b6;font-size:20px;line-height:1}.browse-pending{display:flex;align-items:center;gap:10px;margin:8px 12px 0;padding:8px 10px;border:1px solid #2a3f56;border-radius:8px;background:#15212e}.browse-pending-text{flex:1;min-width:0;color:#cfe0f0;font-size:13px;overflow:hidden;text-overflow:ellipsis}.browse-pending-hint{color:#8aa0b6;font-style:normal;font-size:12px}.browse-pending-btn{flex:0 0 auto;height:32px;padding:0 14px;border:none;border-radius:8px;background:#2f5fa0;color:#fff;font-size:13px}.browse-pending-btn:disabled{opacity:.4}.browse-list{flex:1;overflow:auto;-webkit-overflow-scrolling:touch;padding:4px 12px 16px}.browse-empty{color:#6b7a89;font-size:13px;padding:16px 4px}.browse-overflow{color:#6b7a89;font-size:12px;padding:12px 4px 16px;text-align:center}.browse-entry{display:flex;align-items:center;gap:9px;width:100%;background:transparent;border:none;border-bottom:1px solid #161d26;color:#e6edf3;padding:11px 4px;text-align:left}.browse-entry-icon{flex:0 0 auto;display:inline-flex;color:#6b86a3}.browse-entry-icon svg{width:17px;height:17px}.browse-entry-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.browse-upload{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:44px;height:44px;background:#11161d;border:1px solid #1d2630;border-radius:8px;color:#9fb0c0}.browse-upload:disabled{opacity:.4}.browse-upload svg{width:18px;height:18px}.browse-file-input{position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}.browse-mkdir{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:44px;height:44px;background:#11161d;border:1px solid #1d2630;border-radius:8px;color:#9fb0c0}.browse-mkdir:disabled{opacity:.4}.browse-mkdir svg{width:18px;height:18px}.browse-newfolder{display:flex;align-items:center;gap:8px;padding:8px 12px}.browse-newfolder-input{flex:1;min-width:0;padding:9px 10px;border-radius:8px;border:1px solid #243446;background:#11161d;color:#e6edf3;font-size:13px}.browse-newfolder-input:focus{outline:none;border-color:#3a5a7e}.browse-newfolder-ok{flex:0 0 auto;height:36px;padding:0 14px;border:none;border-radius:8px;background:#2f5fa0;color:#fff;font-size:13px;white-space:nowrap}.browse-newfolder-x{flex:0 0 auto;width:36px;height:36px;border:1px solid #1d2630;border-radius:8px;background:#11161d;color:#9fb0c0;font-size:15px;line-height:1}.browse-progress{padding:8px 12px 0}.browse-progress-label{display:block;color:#9fb6d0;font-size:12px;margin-bottom:4px;font-variant-numeric:tabular-nums}.browse-progress-track{display:block;height:4px;border-radius:2px;background:#1c2b3a;overflow:hidden}.browse-progress-fill{display:block;height:100%;background:#3a5a7e;transition:width .1s linear}.browse-entry-row{display:flex;align-items:center}.browse-entry-row .browse-entry{flex:1;min-width:0}.browse-entry-size{flex:0 0 auto;margin-left:auto;padding-left:8px;color:#6b7a89;font-size:12px;font-variant-numeric:tabular-nums}.browse-dl,.browse-copy{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;width:40px;height:44px;border:none;background:none;color:#6b86a3;margin-left:4px}.browse-dl svg,.browse-copy svg{width:17px;height:17px}.browse-copy:active{color:#9db8ff}.browse-pick-bar{padding:8px;border-top:1px solid #1d2630}.browse-pick-confirm{width:100%;padding:10px;border:none;border-radius:8px;background:var(--blue);color:#fff;font-size:14px}.dirpick-backdrop{z-index:30;background:#0000009e}.dirpick-card{width:min(92vw,520px);max-width:none;z-index:31;max-height:min(80vh,calc(100vh - var(--kb-inset, 0px) - 32px));display:flex;flex-direction:column}.dirpick-card .browse-view{flex:1;min-height:0}.dirpick-hint{font-size:12px;color:#9a9a9a;line-height:1.5;margin-bottom:10px;word-break:break-all}.dirpick-hint code{color:#d8c08a;background:#2c2e33;padding:1px 5px;border-radius:4px}.doc-md-wrap{position:relative;flex:1;min-width:0;overflow-y:auto;overflow-x:hidden;-webkit-overflow-scrolling:touch}.doc-zoom{position:sticky;top:0;z-index:1;display:flex;gap:6px;align-items:center;padding:8px 12px 0}.doc-tts{display:flex;gap:6px}.doc-fonts{display:flex;gap:6px;margin-left:auto}.doc-zoom-btn{display:inline-flex;align-items:center;justify-content:center;background:#11161d;border:1px solid #1d2630;color:#9fb0c0;border-radius:8px;padding:4px 12px;font-size:14px;touch-action:manipulation}.doc-zoom-btn:disabled{opacity:.4}.doc-zoom-icon{padding:4px 9px}.doc-zoom-icon svg{width:17px;height:17px;display:block}.tts-active{background:rgba(var(--blue-rgb),.3);border-radius:3px;box-decoration-break:clone;-webkit-box-decoration-break:clone}.doc-md{padding:16px;color:#e6edf3;line-height:1.6;word-wrap:break-word}.doc-md h1,.doc-md h2,.doc-md h3{border-bottom:1px solid #1d2630;padding-bottom:4px}.doc-md pre{background:#11161d;padding:12px;border-radius:8px;overflow:auto}.doc-md code{background:#11161d;padding:1px 4px;border-radius:4px}.doc-md a{color:var(--blue)}.doc-md img{max-width:100%}.doc-md table{display:block;max-width:100%;overflow-x:auto;border-collapse:collapse}.doc-text{margin:0;padding:16px;color:#e6edf3;line-height:1.5;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere}.doc-iframe{width:100%;height:100%;border:0;background:#fff}.doc-image-wrap{flex:1;min-height:0;overflow:hidden;position:relative;display:flex;align-items:center;justify-content:center;padding:8px;background:#1b1d22;touch-action:none;user-select:none;-webkit-user-select:none}.doc-image{max-width:100%;max-height:100%;object-fit:contain;display:block;will-change:transform;-webkit-touch-callout:none}.doc-image-msg{flex:1;display:flex;align-items:center;justify-content:center;color:#9fb0c0;font-size:14px}.doc-image-zoom{position:absolute;bottom:14px;left:50%;transform:translate(-50%);z-index:2;display:flex;align-items:center;gap:2px;padding:4px;background:#1c1e22eb;border:1px solid #3a3c42;border-radius:999px;box-shadow:0 6px 20px #00000073}.doc-image-zoom button{width:34px;height:30px;display:inline-flex;align-items:center;justify-content:center;background:transparent;border:none;color:#e6edf3;font-size:20px;line-height:1;border-radius:999px;touch-action:manipulation}.doc-image-zoom button:disabled{color:#5a626b}.doc-image-zoom-val{min-width:46px;text-align:center;color:#9fb0c0;font-size:13px;font-variant-numeric:tabular-nums}.doc-image-note{position:absolute;top:14px;left:50%;transform:translate(-50%);z-index:3;max-width:88%;padding:7px 14px;border-radius:999px;white-space:nowrap;background:#1c1e22f0;border:1px solid #3a3c42;color:#e6edf3;font-size:13px;box-shadow:0 6px 20px #00000073}.doclink-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:58}.doclink-pop{position:fixed;z-index:59;width:max-content;max-width:calc(100vw - 16px);background:#1b2330;border:1px solid #2c3a4e;border-radius:12px;padding:12px 14px;box-shadow:0 12px 32px #0000008c;color:#e6edf3}.doclink-name{display:flex;align-items:center;gap:7px;font-weight:600;font-size:14px}.doclink-name svg{width:17px;height:17px;color:#6cb4ff;flex:0 0 auto}.doclink-path{font-size:11px;color:#7d8da0;margin:5px 0 11px;word-break:break-all;max-width:72vw}.doclink-actions{display:flex;gap:8px;justify-content:flex-end}.doclink-actions button{border:1px solid #2c3a4e;background:#11161d;color:#c9d6e2;border-radius:8px;padding:7px 18px;font-size:14px}.doclink-open{background:#2563b0;border-color:#2f6fc0;color:#fff}.doc-deco{border-bottom:2px solid #6cb4ff;background:#4aa3ff38;border-radius:3px;box-sizing:border-box}.doc-toast{position:fixed;left:50%;bottom:24px;transform:translate(-50%);z-index:60;max-width:86vw;background:#2a1518;color:#ffd7d7;border:1px solid #5a2a2a;padding:10px 14px;border-radius:10px;font-size:13px;box-shadow:0 6px 20px #0006}.inbox-btn{position:relative}.inbox-dot{position:absolute;top:4px;right:4px;width:9px;height:9px;border-radius:50%;border:1.5px solid #2a2b2e}.inbox-dot.needs{background:#e0a020}.inbox-dot.done{background:var(--green)}.inbox-dot.working{background:var(--blue);animation:tw-blink 1s ease-in-out infinite}@media (prefers-reduced-motion: reduce){.inbox-dot.working{animation:none}}.inbox-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;background:#00000080;z-index:19}.inbox-panel{position:fixed;z-index:20;top:48px;right:8px;width:calc(100% - 32px);max-width:360px;max-height:70vh;overflow-y:auto;background:#232427;border-radius:12px;padding:8px;box-shadow:0 10px 40px #00000080}.inbox-group-title{font-size:12px;color:#888;letter-spacing:1px;padding:8px 8px 4px}.inbox-row{display:flex;flex-direction:column;gap:2px;width:100%;text-align:left;background:none;border:none;color:#e6e6e6;padding:8px;border-radius:8px;touch-action:manipulation}.inbox-row:active{background:#2f3034}.inbox-row-head{display:flex;align-items:center;gap:6px;min-width:0}.agent-mark{width:15px;height:15px;flex:none;display:inline-block}.agent-mark svg{display:block;width:100%;height:100%}.win-tab .agent-mark{vertical-align:-2px;margin-right:4px}.inbox-chip{font-size:11px;padding:1px 6px;border-radius:4px;flex:none}.inbox-chip.needs{background:#e0a020;color:#111}.inbox-chip.done{background:var(--green);color:#fff}.inbox-chip.working{background:var(--blue);color:#fff;animation:tw-blink 1s ease-in-out infinite}@keyframes tw-blink{50%{opacity:.4}}@media (prefers-reduced-motion: reduce){.inbox-chip.working{animation:none}}.inbox-head{display:flex;align-items:center;gap:8px;padding:2px 8px 8px;font-size:13px;color:#9aa0a6}.inbox-summary{display:flex;gap:4px;margin-left:auto}.inbox-summary .inbox-chip{animation:none;font-size:10px;padding:1px 5px}.inbox-readall{background:none;border:none;color:var(--blue);font-size:13px;padding:4px 6px;touch-action:manipulation}.inbox-loc{font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.inbox-time{margin-left:auto;font-size:11px;color:#888;flex:none}.inbox-msg{font-size:12px;color:#9aa0a6;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.inbox-empty{padding:24px 8px;text-align:center;color:#888;font-size:13px}.inbox-enable{display:flex;flex-direction:column;gap:8px;align-items:stretch;text-align:left}.inbox-enable-title{font-weight:600;color:#e8eaed;font-size:14px}.inbox-enable-hint{color:#9aa0a6;font-size:12px;line-height:1.4}.inbox-enable-btn{margin-top:2px;padding:8px 12px;border:none;border-radius:8px;background:var(--blue);color:#fff;font-size:13px;touch-action:manipulation}.inbox-enable-btn:disabled{opacity:.6}.inbox-enable-err{color:var(--red);font-size:12px}.drawer-orphans{border-top:1px solid #34353a;margin-top:10px;padding-top:6px}.drawer-orphans-head{display:flex;align-items:center;width:100%;gap:6px;background:none;border:none;color:#888;font-size:12px;letter-spacing:1px;padding:6px 4px;text-align:left;touch-action:manipulation}.drawer-orphans-caret{margin-left:auto}.drawer-orphans-hint{color:#777;font-size:11px;padding:0 4px 4px}.drawer-orphan-row{padding:6px 4px}.drawer-orphan-row+.drawer-orphan-row{border-top:1px solid #2a2b2f}.drawer-orphan-head{display:flex;align-items:center;gap:6px}.drawer-orphan-cwd{flex:1;min-width:0;color:#cdddff;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.drawer-orphan-btn{flex:none;padding:4px 10px;border:none;border-radius:8px;background:#2b3550;color:#cdddff;font-size:13px;touch-action:manipulation}.drawer-orphan-btn:active{background:#344168}.drawer-orphan-btn:disabled{opacity:.45}.drawer-orphan-meta{display:flex;align-items:center;gap:6px;margin-top:2px;min-width:0}.drawer-orphan-agent{flex:none;font-size:10px;padding:0 5px;border-radius:3px;background:#333a45;color:#c7ccd1}.drawer-orphan-state{flex:none;font-size:10px;padding:0 5px;border-radius:3px}.drawer-orphan-state.idle{background:var(--green);color:#fff}.drawer-orphan-state.busy{background:var(--blue);color:#fff}.drawer-orphan-time{flex:none;font-size:11px;color:#888}.drawer-orphan-msg{flex:1;min-width:0;font-size:11px;color:#9aa0a6;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.orphan-targets{display:flex;flex-wrap:wrap;gap:6px}.orphan-targets .fontbtn[aria-pressed=true]{background:var(--blue);color:#fff;border-color:var(--blue)}.orphan-reco{margin-left:6px;font-size:10px;color:#7ee0a0}.orphan-targets .fontbtn[aria-pressed=true] .orphan-reco{color:#d6f5e3}.orphan-help{font-size:11px;color:#9aa0a6;line-height:1.5;margin-top:6px}.orphan-cmd{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:#d7dbe0;background:#ffffff12;padding:1px 6px;border-radius:6px;white-space:nowrap}.usage-card{max-width:420px;max-height:80vh;overflow-y:auto}.usage-agent{padding:12px 0}.usage-agent+.usage-agent{border-top:1px solid #33353a}.usage-agent-head{display:flex;align-items:center;gap:8px;font-weight:600;font-size:14px;margin-bottom:8px}.usage-agent-head .agent-mark{width:18px;height:18px}.usage-row{margin:12px 0}.usage-row-head{display:flex;justify-content:space-between;align-items:baseline;font-size:13px;margin-bottom:5px}.usage-row-label{color:#ddd}.usage-row-sub{color:#888}.usage-row-pct{font-variant-numeric:tabular-nums;color:#ddd;font-weight:600}.usage-bar{position:relative;height:8px;background:#33353a;border-radius:5px;overflow:hidden}.usage-bar-time{position:absolute;top:-1px;bottom:-1px;width:2px;margin-left:-1px;background:#ffffffd9;box-shadow:0 0 2px #0009}.usage-bar-fill{height:100%;border-radius:5px;transition:width .3s ease}.usage-bar-fill.lvl-lo{background:#4ec98a}.usage-bar-fill.lvl-mid{background:#e0b341}.usage-bar-fill.lvl-hi{background:#ff6b6b}.usage-row-reset{margin-top:4px;font-size:11px;color:#888}.usage-empty{font-size:13px;color:#9aa0a6;padding:6px 0;line-height:1.5}.usage-code{display:inline-block;margin-top:6px;font-family:ui-monospace,SFMono-Regular,monospace;font-size:12px;background:#1a1b1e;color:#b7f0cd;padding:3px 8px;border-radius:8px}.usage-updated{margin-left:auto;font-size:11px;color:#777}.usage-card .settings-title{margin-right:8px}.usage-head-note{margin-right:auto;font-size:11px;color:#777}