@sksoftofficial/ocduet 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,309 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { Show, createSignal, onCleanup } from "solid-js";
3
+ import os from "node:os";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import crypto from "node:crypto";
7
+ import { pathToFileURL } from "node:url";
8
+
9
+ const PLUGIN_ID = "ocduet-sidebar";
10
+ const SIDEBAR_ORDER = 160;
11
+ const POLL_MS = 2000;
12
+
13
+ const XDG_DATA = process.env.XDG_DATA_HOME && path.isAbsolute(process.env.XDG_DATA_HOME)
14
+ ? process.env.XDG_DATA_HOME
15
+ : path.join(os.homedir(), ".local", "share");
16
+ // Filled by the installer so different opencode XDG profiles share one bridge.
17
+ const INSTALLED_DATA_DIR = null;
18
+ const DATA_DIR = INSTALLED_DATA_DIR || process.env.OCDUET_DATA_DIR || path.join(XDG_DATA, "ocduet");
19
+ const TOKEN_PATH = path.join(DATA_DIR, "token");
20
+ const QR_LIB_PATH = path.join(DATA_DIR, "qrcodegen.js");
21
+
22
+ function slug(directory) {
23
+ const base = path.basename(String(directory || "").replace(/\/+$/, "")) || "project";
24
+ const safe = base.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[-.]+|[-.]+$/g, "") || "project";
25
+ const hash = crypto.createHash("sha256").update(String(directory || "")).digest("hex").slice(0, 8);
26
+ return `${safe}-${hash}`;
27
+ }
28
+
29
+ function pidAlive(pid) {
30
+ if (!pid || typeof pid !== "number") return false;
31
+ try {
32
+ process.kill(pid, 0);
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ function readToken() {
40
+ try {
41
+ return fs.readFileSync(TOKEN_PATH, "utf8").trim() || null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ function regenerateToken() {
48
+ const t = crypto.randomBytes(32).toString("base64url");
49
+ fs.mkdirSync(DATA_DIR, { recursive: true });
50
+ fs.writeFileSync(TOKEN_PATH, t + "\n", { mode: 0o600 });
51
+ try { fs.chmodSync(TOKEN_PATH, 0o600); } catch {}
52
+ return t;
53
+ }
54
+
55
+ function buildUrl(state, token) {
56
+ const ip = state?.lanIp;
57
+ const port = state?.port;
58
+ if (!ip || !port || !token) return null;
59
+ return `https://${ip}:${port}/pair#t=${token}`;
60
+ }
61
+
62
+ // Braille-dot QR: 2x4 modules per char -> roughly half the width/height of the
63
+ // half-block rendering, near-square modules. Lines are left-padded to center
64
+ // the code inside the sidebar panel.
65
+ const QR_PAD = 6;
66
+ const BRAILLE_DOTS = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]];
67
+
68
+ function renderBraille(qr) {
69
+ const at = (x, y) => (y < qr.size && x < qr.size && qr.data[y][x] ? 1 : 0);
70
+ const lines = [];
71
+ for (let y = 0; y < qr.size; y += 4) {
72
+ let line = "";
73
+ for (let x = 0; x < qr.size; x += 2) {
74
+ let bits = 0;
75
+ for (let r = 0; r < 4; r++) {
76
+ for (let c = 0; c < 2; c++) {
77
+ if (at(x + c, y + r)) bits |= BRAILLE_DOTS[r][c];
78
+ }
79
+ }
80
+ line += String.fromCharCode(0x2800 + bits);
81
+ }
82
+ lines.push(" ".repeat(QR_PAD) + line);
83
+ }
84
+ return lines;
85
+ }
86
+
87
+ const stores = new WeakMap();
88
+
89
+ function createStore(api) {
90
+ const [state, setState] = createSignal(null);
91
+ const [showQr, setShowQr] = createSignal(false);
92
+ const [qrLines, setQrLines] = createSignal(null);
93
+ const [error, setError] = createSignal(null);
94
+ let qrModule = null;
95
+ let currentUrl = null;
96
+ let refCount = 0;
97
+ let disposed = false;
98
+
99
+ const stateFile = path.join(DATA_DIR, "state.json");
100
+
101
+ import(pathToFileURL(QR_LIB_PATH).href)
102
+ .then((m) => {
103
+ if (!disposed) {
104
+ qrModule = m;
105
+ if (showQr()) refreshQr();
106
+ }
107
+ })
108
+ .catch(() => {});
109
+
110
+ const renderQr = (url) => {
111
+ currentUrl = url;
112
+ if (!url || !qrModule) {
113
+ setQrLines(null);
114
+ setError(url ? "Loading QR code…" : "Waiting for a LAN connection…");
115
+ return;
116
+ }
117
+ try {
118
+ const qr = qrModule.encode(url, { border: 6, ecc: "L" });
119
+ setQrLines(renderBraille(qr));
120
+ setError(null);
121
+ } catch (err) {
122
+ setQrLines(null);
123
+ setError(String(err?.message || err));
124
+ }
125
+ };
126
+
127
+ const refreshQr = () => {
128
+ const url = buildUrl(state(), readToken());
129
+ renderQr(url ?? null);
130
+ };
131
+
132
+ const poll = () => {
133
+ if (disposed) return;
134
+ let next = null;
135
+ try {
136
+ next = JSON.parse(fs.readFileSync(stateFile, "utf8"));
137
+ } catch {
138
+ next = null;
139
+ }
140
+ if (next && !pidAlive(next.pid)) next = null;
141
+ const prev = state();
142
+ setState(next);
143
+ if (showQr() && buildUrl(next, readToken()) !== currentUrl) refreshQr();
144
+ // auto-close the QR once a phone has paired (0 -> 1 client transition)
145
+ if (showQr() && (prev?.clients?.length ?? 0) === 0 && (next?.clients?.length ?? 0) > 0) setShowQr(false);
146
+ };
147
+
148
+ const interval = setInterval(poll, POLL_MS);
149
+ poll();
150
+
151
+ const dispose = () => {
152
+ if (disposed) return;
153
+ disposed = true;
154
+ clearInterval(interval);
155
+ stores.delete(api);
156
+ };
157
+
158
+ const store = {
159
+ state,
160
+ showQr,
161
+ qrLines,
162
+ error,
163
+ toggleQr: () => {
164
+ if (!showQr()) {
165
+ poll();
166
+ refreshQr();
167
+ setShowQr(true);
168
+ } else {
169
+ setShowQr(false);
170
+ }
171
+ },
172
+ newToken: () => {
173
+ regenerateToken();
174
+ poll();
175
+ refreshQr();
176
+ setShowQr(true);
177
+ },
178
+ retain: () => {
179
+ refCount += 1;
180
+ return store;
181
+ },
182
+ release: () => {
183
+ refCount -= 1;
184
+ if (refCount <= 0) dispose();
185
+ },
186
+ };
187
+ return store;
188
+ }
189
+
190
+ function acquireStore(api) {
191
+ const existing = stores.get(api);
192
+ if (existing) return existing.retain();
193
+ const next = createStore(api).retain();
194
+ stores.set(api, next);
195
+ return next;
196
+ }
197
+
198
+ function ConnectPanel(props) {
199
+ const store = acquireStore(props.api);
200
+ onCleanup(() => store.release());
201
+ const theme = () => props.api.theme.current;
202
+ const state = store.state;
203
+ const statusColor = () => {
204
+ const s = state();
205
+ if (!s || !s.port) return theme().error;
206
+ if ((s.clients?.length ?? 0) > 0) return theme().success;
207
+ return theme().warning;
208
+ };
209
+ const statusText = () => {
210
+ const s = state();
211
+ if (!s || !s.port) return "offline";
212
+ const n = s.clients?.length ?? 0;
213
+ const inst = s.instances?.length ?? 0;
214
+ const base = n > 0 ? `connected (${n})` : "listening";
215
+ return inst > 0 ? `${base} · ${inst} opencode` : base;
216
+ };
217
+
218
+ return (
219
+ <box gap={0}>
220
+ <box flexDirection="row" gap={1}>
221
+ <text fg={theme().text}>
222
+ <b>Duet</b>
223
+ </text>
224
+ <text fg={statusColor()} wrapMode="none">
225
+ {`● ${statusText()}`}
226
+ </text>
227
+ </box>
228
+
229
+ <Show
230
+ when={state()?.port}
231
+ fallback={
232
+ <text fg={theme().textMuted} wrapMode="none">
233
+ bridge not running
234
+ </text>
235
+ }
236
+ >
237
+ <box flexDirection="row" gap={1}>
238
+ <text fg={theme().textMuted} wrapMode="none">
239
+ {state().lanIp ? `${state().lanIp}:${state().port}` : "waiting for network"}
240
+ </text>
241
+ <text
242
+ fg={store.showQr() ? theme().textMuted : theme().text}
243
+ wrapMode="none"
244
+ onMouseDown={() => store.toggleQr()}
245
+ >
246
+ {store.showQr() ? "hide" : "connect"}
247
+ </text>
248
+ <Show when={store.showQr()}>
249
+ <text fg={theme().textMuted} wrapMode="none" onMouseDown={() => store.newToken()}>
250
+ new token
251
+ </text>
252
+ </Show>
253
+ </box>
254
+ </Show>
255
+
256
+ <Show when={store.showQr()}>
257
+ <Show
258
+ when={store.qrLines()}
259
+ fallback={
260
+ <text fg={theme().textMuted} wrapMode="none">
261
+ {store.error() ?? "generating…"}
262
+ </text>
263
+ }
264
+ >
265
+ <box gap={0}>
266
+ {(store.qrLines() ?? []).map((line) => (
267
+ <text fg={theme().text} wrapMode="none">
268
+ {line}
269
+ </text>
270
+ ))}
271
+ <text fg={theme().textMuted} wrapMode="none">
272
+ scan with phone camera
273
+ </text>
274
+ <Show when={state()?.words}>
275
+ <text fg={theme().text} wrapMode="none">
276
+ {`words: ${(state().words ?? []).join(" ")}`}
277
+ </text>
278
+ <text fg={theme().textMuted} wrapMode="none">
279
+ match them on the phone before pairing
280
+ </text>
281
+ </Show>
282
+ </box>
283
+ </Show>
284
+ </Show>
285
+ </box>
286
+ );
287
+ }
288
+
289
+ function registerSlots(api) {
290
+ api.slots.register({
291
+ order: SIDEBAR_ORDER,
292
+ slots: {
293
+ sidebar_content(_ctx, _props) {
294
+ return <ConnectPanel api={api} />;
295
+ },
296
+ },
297
+ });
298
+ }
299
+
300
+ const tui = async (api) => {
301
+ registerSlots(api);
302
+ };
303
+
304
+ const pluginModule = {
305
+ id: PLUGIN_ID,
306
+ tui,
307
+ };
308
+
309
+ export default pluginModule;
@@ -0,0 +1,290 @@
1
+ :root {
2
+ color-scheme: dark;
3
+ --bg: #151716;
4
+ --sidebar: #111312;
5
+ --surface: #1c1f1d;
6
+ --surface-hover: #252a26;
7
+ --line: #2b302c;
8
+ --fg: #e8ece7;
9
+ --muted: #949e96;
10
+ --accent: #b8e5c7;
11
+ --ok: #a9d9b6;
12
+ --warn: #e2bf83;
13
+ --err: #eaa59d;
14
+ font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
15
+ font-synthesis: none;
16
+ }
17
+ * { box-sizing: border-box; }
18
+ [hidden] { display: none !important; }
19
+ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg); font-size: 14px; line-height: 1.6; overscroll-behavior: none; }
20
+ body { display: flex; height: 100dvh; overflow: hidden; }
21
+ button, input, textarea { font: inherit; }
22
+ button { color: inherit; cursor: pointer; touch-action: manipulation; }
23
+ button, input, textarea { -webkit-tap-highlight-color: transparent; }
24
+ button { border: 0; }
25
+ button:disabled { cursor: default; }
26
+ button, button:focus, button:focus-visible { outline: none !important; }
27
+ input:focus-visible, textarea:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
28
+ button, a { transition: background .15s, color .15s; }
29
+ svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; flex-shrink: 0; }
30
+ .svg-defs { position: absolute; width: 0; height: 0; overflow: hidden; }
31
+ .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
32
+ ::selection { background: #43634c; color: white; }
33
+ ::-webkit-scrollbar { width: 5px; height: 5px; }
34
+ ::-webkit-scrollbar-thumb { background: #39413b; border-radius: 5px; }
35
+ #sessions { width: 288px; flex-shrink: 0; display: flex; flex-direction: column; background: var(--sidebar); border-right: 1px solid var(--line); padding: 0 16px; }
36
+ .brand { height: 88px; display: flex; align-items: center; gap: 10px; padding: 0 8px; flex-shrink: 0; font-size: 25px; letter-spacing: -1.2px; font-weight: 650; }
37
+ .brand-period { color: var(--accent); }
38
+ .brand-mark { width: 29px; height: 30px; display: inline-flex; gap: 4px; align-items: center; justify-content: center; }
39
+ .brand-mark i { width: 9px; height: 21px; background: var(--accent); border-radius: 2px; transform: skewY(-12deg); }
40
+ .brand-mark i + i { transform: skewY(12deg); height: 16px; }
41
+ .brand-tag { margin-left: auto; font-size: 8px; color: var(--muted); border: 1px solid var(--line); border-radius: 4px; letter-spacing: 1px; padding: 3px 5px; font-weight: 500; }
42
+ .status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); display: inline-block; flex-shrink: 0; }
43
+ .status-dot.on { background: #5fb877; }
44
+ .status-dot.wait, .status-dot.recon { background: var(--warn); }
45
+ .status-dot.off { background: var(--err); }
46
+ .sync-status { display: inline-flex; align-items: center; gap: 6px; }
47
+ .search { display: flex; align-items: center; gap: 8px; margin: 0 6px 10px; color: var(--muted); }
48
+ .search svg { width: 16px; height: 16px; }
49
+ .search input { width: 100%; min-width: 0; background: transparent; border: 0; color: var(--fg); font-size: 12px; padding: 4px 0; }
50
+ input::placeholder, textarea::placeholder { color: #8b958d; opacity: 1; }
51
+ kbd { font: 10px ui-monospace, monospace; }
52
+ .search kbd { border: 1px solid var(--line); border-radius: 3px; padding: 0 5px; }
53
+ .new-session { display: flex; align-items: center; justify-content: center; gap: 8px; width: calc(100% - 12px); margin: 12px 6px 14px; padding: 11px; background: #252e27; color: var(--fg); border: 0; border-radius: 10px; font: 600 13px Inter, -apple-system, sans-serif; cursor: pointer; -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
54
+ .new-session:focus { outline: none; }
55
+ .new-session svg { width: 15px; height: 15px; color: var(--accent); }
56
+ .new-session:active { background: #2c372f; }
57
+ .new-session:disabled { opacity: 0.55; cursor: default; }
58
+ .section-label { display: flex; align-items: center; justify-content: space-between; color: var(--muted); font-size: 10px; letter-spacing: 1.4px; padding: 0 10px 12px; }
59
+ #session-count { letter-spacing: 0; font-size: 10px; }
60
+ #session-list { flex: 1; overflow: auto; min-height: 0; margin: 0 -5px; padding: 0 5px 20px; }
61
+ .session { display: flex; align-items: flex-start; gap: 10px; width: 100%; padding: 8px 12px; border: 1px solid transparent; border-radius: 8px; background: transparent; text-align: left; margin: 0 0 5px; }
62
+ .session.active { background: #252e27; border-color: #344538; }
63
+ .session-icon { color: #839387; width: 16px; height: 16px; margin-top: 3px; }
64
+ .session.active .session-icon { color: var(--accent); }
65
+ .session-content { min-width: 0; flex: 1; }
66
+ .session .name { font-weight: 450; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #c4cdc6; }
67
+ .session.active .name { color: #e1efe4; }
68
+ .session .meta { display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: 11px; margin-top: 2px; }
69
+ .session .meta .project { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 145px; }
70
+ .session .meta time { margin-left: auto; white-space: nowrap; }
71
+ .session .dot { display: none; width: 5px; height: 5px; border-radius: 50%; background: var(--accent); }
72
+ .session.busy .dot { display: inline-block; animation: pulse 1.3s infinite; }
73
+ .session.offline .name { color: var(--muted); }
74
+ .session.offline .session-icon { color: var(--muted); }
75
+ .session .meta .offline-tag { color: var(--muted); opacity: 0.8; }
76
+ .list-empty { padding: 22px 12px; font-size: 12px; color: var(--muted); }
77
+ .list-empty strong { display: block; color: #c1cbc3; font-weight: 500; margin-bottom: 5px; }
78
+ #workspace { flex: 1; min-width: 0; display: flex; flex-direction: column; }
79
+ #topbar { min-height: 88px; padding: 18px 34px; display: flex; align-items: center; gap: 13px; border-bottom: 1px solid var(--line); flex-shrink: 0; }
80
+ .header-icon { display: grid; place-items: center; width: 35px; height: 35px; color: var(--muted); border: 1px solid var(--line); border-radius: 8px; }
81
+ .header-icon svg { width: 17px; height: 17px; }
82
+ #title { min-width: 0; flex: 1; }
83
+ #project-name { display: block; font-size: 13px; font-weight: 550; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; }
84
+ #session-subtitle { display: block; font-size: 11px; color: var(--muted); margin-top: 1px; }
85
+ .badge { flex-shrink: 0; display: inline-flex; gap: 7px; align-items: center; border: 1px solid var(--line); padding: 4px 9px; border-radius: 6px; font-size: 10px; color: var(--muted); }
86
+ .badge::before { content: ""; width: 5px; height: 5px; background: currentColor; border-radius: 50%; }
87
+ .badge.on { color: var(--ok); background: #a9d9b607; border-color: #344337; }
88
+ .badge.wait { color: var(--warn); }
89
+ .badge.off { color: var(--muted); }
90
+ .icon-button { display: grid; place-items: center; width: 40px; height: 40px; background: transparent; border-radius: 7px; color: var(--muted); }
91
+ #sessions-btn, #close-sessions { display: none; }
92
+ #main { flex: 1; overflow-y: auto; min-height: 0; padding: 36px max(30px, calc((100% - 790px) / 2)) 6px; position: relative; }
93
+ .empty { min-height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 30px 0; }
94
+ .connection-art { display: flex; align-items: center; justify-content: center; margin-bottom: 38px; height: 66px; }
95
+ .device { display: grid; place-items: center; border: 1px solid #4b6150; background: linear-gradient(140deg, #263029, #1b201d); color: var(--accent); }
96
+ .desktop { width: 76px; height: 55px; border-radius: 9px; position: relative; transform: rotate(-7deg); }
97
+ .desktop::after { content: ""; width: 34px; height: 1px; background: #4b6150; position: absolute; bottom: -7px; }
98
+ .desktop svg { width: 27px; height: 27px; }
99
+ .phone { width: 38px; height: 62px; border-radius: 9px; transform: rotate(8deg); }
100
+ .phone .brand-mark { transform: scale(.65); }
101
+ .connection-line { display: flex; gap: 7px; padding: 0 19px; }
102
+ .connection-line i { width: 3px; height: 3px; background: #7a9a82; border-radius: 50%; }
103
+ .eyebrow { font-size: 9px; letter-spacing: 2px; color: #a2b5a7; font-weight: 500; }
104
+ h1 { font-size: clamp(25px, 2.6vw, 34px); font-weight: 500; letter-spacing: -1.1px; line-height: 1.25; margin: 14px 0; }
105
+ .empty > p { color: var(--muted); font-size: 13px; line-height: 1.8; margin: 0; max-width: 400px; }
106
+ .empty-note { color: var(--muted); font-size: 10px; margin-top: 27px; display: flex; align-items: center; gap: 7px; }
107
+ .empty-note .status-dot { background: #87aa90; width: 4px; height: 4px; }
108
+ .pairing-steps { text-align: left; margin-top: 27px; padding: 8px 20px; border: 1px solid var(--line); border-radius: 10px; background: #191c1a; max-width: 100%; }
109
+ .pairing-steps > div { display: flex; align-items: center; gap: 16px; }
110
+ .pairing-steps > div > span { color: #7e9986; font: 10px ui-monospace, monospace; }
111
+ .pairing-steps p { font-size: 11px; color: var(--muted); }
112
+ .pairing-steps strong { font-weight: 500; color: #d4ded6; }
113
+ .msg { margin-bottom: 28px; min-width: 0; }
114
+ .msg .role { display: flex; align-items: center; gap: 8px; color: #c9d5cc; font-size: 11px; font-weight: 550; margin-bottom: 10px; }
115
+ .msg .role::before { content: "›_"; font: 10px ui-monospace, monospace; color: var(--accent); display: grid; place-items: center; width: 23px; height: 23px; background: #29362c; border: 1px solid #3a4d3e; border-radius: 6px; }
116
+ .msg.user .role::before { content: "Y"; background: #282b29; color: #c4cec6; border-color: #3e4540; font-size: 9px; }
117
+ .msg .role time { color: var(--muted); font-size: 10px; font-weight: 400; margin-left: 2px; }
118
+ .msg .bubble { color: #d5ddd6; font-size: 14px; line-height: 1.8; white-space: pre-wrap; overflow-wrap: anywhere; }
119
+ .msg.user .bubble { padding: 15px 18px; border: 1px solid #343d36; border-radius: 3px 12px 12px 12px; background: #222924; }
120
+ .msg .bubble + .bubble { margin-top: 12px; }
121
+ .msg pre { background: #101311; border: 1px solid var(--line); border-radius: 8px; padding: 15px; overflow-x: auto; white-space: pre; font: 12px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; }
122
+ .msg pre[data-lang]:not([data-lang=""])::before { content: attr(data-lang); display: block; color: #8fba9b; font-size: 10px; margin-bottom: 12px; }
123
+ .msg code { background: #29322b; color: #c3dfcb; padding: 2px 5px; border-radius: 4px; font: .88em ui-monospace, monospace; }
124
+ details.tool { margin: 9px 0; border: 1px solid var(--line); background: #191d1a; border-radius: 7px; padding: 10px 13px; color: var(--muted); }
125
+ details.tool summary { cursor: pointer; font: 11px/1.7 ui-monospace, monospace; overflow-wrap: anywhere; }
126
+ .tool-title { color: #c2d4c6; margin-right: 8px; }
127
+ .tool-status { color: #9dbca5; margin-right: 8px; font-size: 10px; }
128
+ .tool-detail { color: var(--muted); }
129
+ details.tool pre { margin: 12px 0 0; font-size: 11px; white-space: pre-wrap; overflow-wrap: anywhere; }
130
+ .part-step { color: var(--muted); font-size: 11px; margin: 12px 0; }
131
+ #activity { display: flex; align-items: center; gap: 11px; font-size: 12px; color: var(--accent); min-height: 40px; margin: 0 0 3px; }
132
+ #activity-time { font: 10px ui-monospace, monospace; color: var(--muted); }
133
+ .loop-loader { display: inline-flex; align-items: center; gap: 3px; height: 20px; }
134
+ .loop-loader i { width: 4px; height: 13px; border-radius: 1px; background: var(--accent); animation: loop 1.15s ease-in-out infinite; }
135
+ .loop-loader i:nth-child(2) { animation-delay: .12s; }
136
+ .loop-loader i:nth-child(3) { animation-delay: .24s; }
137
+ .loop-loader i:nth-child(4) { animation-delay: .36s; }
138
+ .loop-loader i:nth-child(5) { animation-delay: .48s; }
139
+ #stop-btn { display: flex; align-items: center; gap: 6px; margin-left: auto; padding: 8px 10px; border-radius: 6px; background: transparent; color: var(--muted); font-size: 11px; }
140
+ .stop-square { width: 7px; height: 7px; border: 1px solid currentColor; border-radius: 1px; }
141
+ @keyframes loop { 0%, 70%, 100% { transform: scaleY(.4); opacity: .35; } 35% { transform: scaleY(1); opacity: 1; } }
142
+ @keyframes pulse { 50% { opacity: .35; } }
143
+ #composer { padding: 3px max(30px, calc((100% - 790px) / 2)) 20px; flex-shrink: 0; }
144
+ .composer-box { border: 1px solid #3e4940; background: #1d221e; border-radius: 12px; padding: 15px 15px 11px; box-shadow: 0 4px 18px #00000015; transition: border-color .15s; }
145
+ .composer-box:focus-within { border-color: #7eab89; }
146
+ #input { display: block; width: 100%; background: transparent; color: var(--fg); border: 0; outline: none; padding: 0 2px; font: 16px/1.6 Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; resize: none; max-height: 25dvh; min-height: 48px; }
147
+ #input:disabled { opacity: .6; }
148
+ .composer-toolbar { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
149
+ .composer-context { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 10px; min-width: 0; }
150
+ .composer-context svg { width: 14px; height: 14px; }
151
+ #composer-context { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
152
+ #send-btn { display: grid; place-items: center; margin-left: auto; width: 33px; height: 33px; border-radius: 8px; background: var(--accent); color: #233d2b; flex-shrink: 0; }
153
+ #send-btn:disabled { background: #343e36; color: #78897d; }
154
+ .composer-caption { display: flex; justify-content: space-between; gap: 15px; font-size: 11px; color: var(--muted); padding: 11px 3px 0; }
155
+ .composer-caption kbd { font-size: 11px; }
156
+ #toast { position: fixed; bottom: 185px; left: 50%; transform: translateX(-50%); max-width: min(90vw, 450px); background: #2b352e; border: 1px solid #546959; color: var(--fg); border-radius: 9px; padding: 12px 18px; font-size: 12px; box-shadow: 0 5px 30px #0004; z-index: 60; }
157
+ @media (min-width: 1500px) { #main { padding-top: 50px; } }
158
+ @media (max-width: 900px) { #sessions { width: 252px; padding: 0 12px; } .brand-tag { display: none; } #topbar { padding-inline: 22px; } #main, #composer { padding-inline: 24px; } .keyboard-hint { display: none; } }
159
+ @media (max-width: 719px) {
160
+ #sessions { position: fixed; top: 0; bottom: 0; left: 0; z-index: 50; width: min(86vw, 310px); padding-top: env(safe-area-inset-top); transform: translateX(-100%); visibility: hidden; transition: transform .2s ease; }
161
+ body.sessions-open #sessions { transform: translateX(0); visibility: visible; }
162
+ #sidebar-backdrop { position: fixed; inset: 0; background: #0009; backdrop-filter: blur(3px); z-index: 45; }
163
+ #sessions-btn, #close-sessions { display: grid; }
164
+ #close-sessions { margin-left: auto; }
165
+ .brand { height: 76px; }
166
+ #topbar { min-height: calc(72px + env(safe-area-inset-top)); padding: calc(12px + env(safe-area-inset-top)) 16px 12px 8px; gap: 8px; }
167
+ .header-icon { display: none; }
168
+ #project-name { font-size: 12px; }
169
+ #session-subtitle { font-size: 10px; }
170
+ .badge { font-size: 9px; padding: 3px 7px; }
171
+ #main { padding: 24px 20px 4px; }
172
+ .empty { padding: 20px 0; }
173
+ .connection-art { margin-bottom: 28px; transform: scale(.9); }
174
+ h1 { font-size: 27px; max-width: 280px; }
175
+ .empty > p { font-size: 12px; max-width: 290px; }
176
+ .desktop-break { display: none; }
177
+ .eyebrow { font-size: 8px; letter-spacing: 1.6px; }
178
+ .pairing-steps { padding: 5px 12px; margin-top: 22px; }
179
+ .pairing-steps p { font-size: 10px; }
180
+ .empty-note { font-size: 9px; margin-top: 22px; }
181
+ #composer { padding: 2px 12px calc(12px + env(safe-area-inset-bottom)); }
182
+ .composer-box { padding: 12px 12px 9px; }
183
+ #input { min-height: 42px; }
184
+ .composer-caption { justify-content: center; padding-top: 8px; font-size: 10px; }
185
+ #send-btn { width: 36px; height: 36px; }
186
+ .msg .bubble { font-size: 14px; }
187
+ }
188
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
189
+
190
+ #questions, #permissions { display: grid; gap: 10px; margin: 0 0 3px; }
191
+ #questions:empty, #permissions:empty { display: none; }
192
+ .permission { border: 1px solid #4b6150; background: linear-gradient(140deg, #263029, #1b201d); border-radius: 10px; padding: 12px 14px; }
193
+ .permission-head { display: flex; align-items: center; gap: 8px; color: var(--warn); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; }
194
+ .permission-head svg { width: 15px; height: 15px; }
195
+ .permission-title { margin: 6px 0 2px; font-size: 13px; color: var(--fg); overflow-wrap: anywhere; }
196
+ .permission-pattern { font: 11px ui-monospace, monospace; color: var(--accent); background: #29362c; border: 1px solid #3a4d3e; border-radius: 6px; padding: 3px 7px; display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; }
197
+ .permission-actions { display: flex; gap: 8px; margin-top: 11px; }
198
+ .permission-actions button { flex: 1; padding: 9px 10px; border-radius: 7px; font-size: 12px; background: var(--surface-hover); color: var(--fg); }
199
+ .permission-actions .allow { background: var(--accent); color: #151716; font-weight: 600; }
200
+ .permission-actions .deny { background: transparent; border: 1px solid #513a36; color: var(--err); }
201
+ .permission-actions button:disabled { opacity: .5; }
202
+ .question-options { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
203
+ .question-option { display: flex; flex-direction: column; gap: 3px; text-align: left; padding: 9px 11px; border-radius: 8px; font-size: 12.5px; color: var(--fg); background: var(--surface-hover); border: 1px solid var(--line); }
204
+ .question-option small { color: var(--muted); font-size: 11px; }
205
+ .question-option.selected { border-color: var(--accent); background: #223028; }
206
+ .question-option:disabled { opacity: .5; }
207
+ .question-custom { margin-top: 8px; }
208
+ .question-custom-btn { padding: 7px 10px; border-radius: 7px; font-size: 12px; background: transparent; border: 1px dashed var(--line); color: var(--muted); }
209
+ .question-custom-row { display: flex; gap: 8px; }
210
+ .question-custom-row input { flex: 1; min-width: 0; background: var(--surface-hover); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; font-size: 13px; color: var(--fg); }
211
+ .question-custom-row .allow { padding: 8px 14px; border-radius: 8px; font-size: 12px; background: var(--accent); color: #151716; font-weight: 600; }
212
+ .session.pending .dot { display: inline-block; background: var(--warn); }
213
+
214
+ .pills { display: inline-flex; gap: 6px; align-items: center; min-width: 0; overflow-x: auto; scrollbar-width: none; }
215
+ .pills::-webkit-scrollbar { display: none; }
216
+ .pill { display: inline-flex; align-items: center; gap: 5px; height: 23px; padding: 0 9px; border-radius: 999px; background: var(--surface-hover); border: 1px solid var(--line); font-size: 11px; color: var(--fg); max-width: 150px; flex-shrink: 0; }
217
+ .pill span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
218
+ .pill svg { width: 13px; height: 13px; color: var(--muted); flex-shrink: 0; }
219
+ .pill:disabled { opacity: .45; }
220
+ .pill.auto { border-color: #4b6150; color: var(--accent); }
221
+ .pill.auto svg { color: var(--accent); }
222
+ .modal-backdrop { position: fixed; inset: 0; background: rgb(10 12 10 / .65); z-index: 50; display: grid; place-items: center; padding: 20px; }
223
+ .modal { width: min(480px, 100%); max-height: min(72dvh, 560px); display: flex; flex-direction: column; background: var(--surface); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; }
224
+ .modal-head { display: flex; align-items: center; gap: 8px; padding: 12px 12px 6px 16px; }
225
+ .modal-title { font-size: 13px; font-weight: 600; }
226
+ .modal-head .icon-button { margin-left: auto; width: 32px; height: 32px; }
227
+ .modal-search { margin: 4px 8px 10px; padding: 12px 10px 11px; background: #1d221e; border: 1px solid #3e4940; border-radius: 12px; color: var(--fg); font-size: 13px; width: calc(100% - 16px); transition: border-color .15s; }
228
+ .modal-search:focus { outline: none; border-color: var(--accent); }
229
+ .modal-body { overflow-y: auto; padding: 0 8px 12px; }
230
+ .modal-empty { padding: 24px 12px; text-align: center; color: var(--muted); font-size: 12px; }
231
+ .modal-group-label { font-size: 10px; letter-spacing: .08em; color: var(--muted); text-transform: uppercase; padding: 10px 10px 4px; }
232
+ .modal-option { display: flex; width: 100%; text-align: left; gap: 10px; align-items: center; padding: 4px 10px; margin-bottom: 8px; border-radius: 9px; font-size: 13px; }
233
+ .modal-option > svg { width: 15px; height: 15px; color: var(--muted); }
234
+ .modal-option.active { background: #a9d9b61c; }
235
+ .modal-option.active .option-title { color: var(--accent); }
236
+ .modal-option.active > svg { color: var(--accent); }
237
+ .option-body { display: grid; min-width: 0; }
238
+ .option-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
239
+ .modal-option .sub { color: var(--muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
240
+ .quota-provider { margin-bottom: 14px; }
241
+ .quota-provider:last-child { margin-bottom: 0; }
242
+ .quota-name { font-size: 12px; font-weight: 550; margin-bottom: 6px; }
243
+ .quota-resets { font-size: 11px; color: var(--muted); margin: -2px 0 6px; }
244
+ .quota-error { font-size: 11px; color: var(--muted); }
245
+ .quota-window { margin-bottom: 8px; }
246
+ .quota-row { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin-bottom: 4px; }
247
+ .quota-label { font-size: 11px; color: var(--muted); text-transform: capitalize; }
248
+ .quota-pct { font-size: 11px; }
249
+ .quota-pct.ok { color: var(--fg); }
250
+ .quota-pct.warn { color: var(--warn); }
251
+ .quota-pct.low { color: var(--err); }
252
+ .quota-track { height: 4px; border-radius: 2px; background: var(--surface-hover); overflow: hidden; }
253
+ .quota-track i { display: block; height: 100%; border-radius: 2px; transition: width .3s; }
254
+ .quota-track i.ok { background: #5fb877; }
255
+ .quota-track i.warn { background: var(--warn); }
256
+ .quota-track i.low { background: var(--err); }
257
+ .quota-updated { font-size: 10px; color: var(--muted); margin-top: 10px; text-align: center; }
258
+ .quota-instance { margin-bottom: 14px; }
259
+ .quota-instance:last-child { margin-bottom: 0; }
260
+ .quota-inst-name { font-size: 10px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); margin: 0 0 6px 2px; }
261
+ .modal-option .check { margin-left: auto; color: var(--accent); }
262
+
263
+ #topbar { position: relative; }
264
+ .menu { position: absolute; top: calc(100% + 8px); right: 12px; min-width: 190px; background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 8px 8px 0; z-index: 40; box-shadow: 0 14px 36px rgb(0 0 0 / .5); }
265
+ .menu button { display: flex; width: 100%; align-items: center; gap: 10px; padding: 4px 10px; margin-bottom: 8px; border-radius: 9px; font-size: 13px; color: var(--fg); }
266
+ .menu button svg { width: 15px; height: 15px; }
267
+ .menu button.danger { color: var(--err); }
268
+ .confirm-text { padding: 6px 10px 10px; color: var(--muted); font-size: 13px; line-height: 1.55; }
269
+ .modal-actions { display: flex; gap: 8px; padding: 6px 16px 16px; }
270
+ .modal-actions button { flex: 1; padding: 10px; border-radius: 8px; font-size: 13px; background: var(--surface-hover); color: var(--fg); }
271
+ .modal-actions button:disabled { opacity: .5; }
272
+ .modal-actions .primary-danger { background: var(--err); color: #151716; font-weight: 600; }
273
+
274
+ .context-pill { gap: 6px; cursor: default; background: none; border: none; padding: 0; color: var(--muted); }
275
+ .ring { width: 16px; height: 16px; transform: rotate(-90deg); flex-shrink: 0; }
276
+ .ring circle { fill: none; stroke-width: 3.2; }
277
+ .ring-bg { stroke: var(--line); }
278
+ .ring-fg { stroke: var(--accent); stroke-dasharray: 50.27; stroke-dashoffset: 50.27; stroke-linecap: round; transition: stroke-dashoffset .3s, stroke .3s; }
279
+ .context-pill.warn .ring-fg { stroke: var(--warn); }
280
+ .context-pill.full .ring-fg { stroke: var(--err); }
281
+
282
+ @media (hover: hover) {
283
+ .session:hover { background: #1b1f1c; }
284
+ .icon-button:hover { background: var(--surface-hover); color: var(--fg); }
285
+ #stop-btn:hover { background: #362724; color: var(--err); }
286
+ #send-btn:hover:not(:disabled) { background: #d0f4db; }
287
+ .modal-option:hover { background: var(--surface-hover); }
288
+ .menu button:hover { background: var(--surface-hover); }
289
+ .menu button.danger:hover { background: #362724; }
290
+ }