groupchat 0.0.6 → 0.0.8

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/dist/index.js CHANGED
@@ -1,11 +1,3358 @@
1
1
  #!/usr/bin/env node
2
- import "./chunk-EF672XXZ.js";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // src/auth/token-storage.ts
13
+ import keytar from "keytar";
14
+ async function storeToken(token, expiresAt) {
15
+ await keytar.setPassword(SERVICE_NAME, TOKEN_ACCOUNT, token);
16
+ await keytar.setPassword(SERVICE_NAME, EXPIRY_ACCOUNT, expiresAt.toISOString());
17
+ }
18
+ async function getToken() {
19
+ const token = await keytar.getPassword(SERVICE_NAME, TOKEN_ACCOUNT);
20
+ const expiryStr = await keytar.getPassword(SERVICE_NAME, EXPIRY_ACCOUNT);
21
+ if (!token || !expiryStr) {
22
+ return null;
23
+ }
24
+ const expiresAt = new Date(expiryStr);
25
+ if (expiresAt <= /* @__PURE__ */ new Date()) {
26
+ await clearToken();
27
+ return null;
28
+ }
29
+ return { token, expiresAt };
30
+ }
31
+ async function clearToken() {
32
+ await keytar.deletePassword(SERVICE_NAME, TOKEN_ACCOUNT);
33
+ await keytar.deletePassword(SERVICE_NAME, EXPIRY_ACCOUNT);
34
+ }
35
+ async function hasValidToken() {
36
+ const stored = await getToken();
37
+ return stored !== null;
38
+ }
39
+ var SERVICE_NAME, TOKEN_ACCOUNT, EXPIRY_ACCOUNT;
40
+ var init_token_storage = __esm({
41
+ "src/auth/token-storage.ts"() {
42
+ "use strict";
43
+ SERVICE_NAME = "groupchat";
44
+ TOKEN_ACCOUNT = "auth-token";
45
+ EXPIRY_ACCOUNT = "auth-token-expiry";
46
+ }
47
+ });
48
+
49
+ // src/lib/config.ts
50
+ import { config as loadEnv } from "dotenv";
51
+ function getConfig() {
52
+ return {
53
+ consoleUrl: process.env.GROUPCHAT_CONSOLE_URL || DEFAULT_CONFIG.consoleUrl,
54
+ wsUrl: process.env.GROUPCHAT_WS_URL || DEFAULT_CONFIG.wsUrl
55
+ };
56
+ }
57
+ var DEFAULT_CONFIG;
58
+ var init_config = __esm({
59
+ "src/lib/config.ts"() {
60
+ "use strict";
61
+ if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test") {
62
+ loadEnv();
63
+ }
64
+ DEFAULT_CONFIG = {
65
+ consoleUrl: "https://app.groupchatty.com",
66
+ wsUrl: "wss://api.groupchatty.com/socket"
67
+ };
68
+ }
69
+ });
70
+
71
+ // src/auth/auth-manager.ts
72
+ import crypto from "crypto";
73
+ import http from "http";
74
+ import open from "open";
75
+ function generateState() {
76
+ return crypto.randomBytes(32).toString("hex");
77
+ }
78
+ async function findAvailablePort(startPort, endPort) {
79
+ for (let port = startPort; port <= endPort; port++) {
80
+ const available = await new Promise((resolve) => {
81
+ const server = http.createServer();
82
+ server.once("error", () => resolve(false));
83
+ server.once("listening", () => {
84
+ server.close();
85
+ resolve(true);
86
+ });
87
+ server.listen(port, "127.0.0.1");
88
+ });
89
+ if (available) {
90
+ return port;
91
+ }
92
+ }
93
+ throw new Error(`No available port found in range ${startPort}-${endPort}`);
94
+ }
95
+ function startAuthServer(port, expectedState, timeoutMs = 5 * 60 * 1e3) {
96
+ return new Promise((resolve, reject) => {
97
+ let settled = false;
98
+ const server = http.createServer((req, res) => {
99
+ if (settled) {
100
+ res.writeHead(400);
101
+ res.end();
102
+ return;
103
+ }
104
+ if (!req.url?.startsWith("/callback")) {
105
+ res.writeHead(404);
106
+ res.end();
107
+ return;
108
+ }
109
+ const url = new URL(req.url, `http://localhost:${port}`);
110
+ const token = url.searchParams.get("token");
111
+ const state = url.searchParams.get("state");
112
+ const expiresAt = url.searchParams.get("expiresAt");
113
+ if (state !== expectedState) {
114
+ res.writeHead(400, { "Content-Type": "text/html" });
115
+ res.end(ERROR_HTML("Invalid state parameter. Please try again."));
116
+ return;
117
+ }
118
+ if (!token) {
119
+ res.writeHead(400, { "Content-Type": "text/html" });
120
+ res.end(ERROR_HTML("No token received. Please try again."));
121
+ return;
122
+ }
123
+ settled = true;
124
+ res.writeHead(200, { "Content-Type": "text/html" });
125
+ res.end(SUCCESS_HTML);
126
+ server.close();
127
+ clearTimeout(timeout);
128
+ resolve({
129
+ token,
130
+ state,
131
+ expiresAt: expiresAt || new Date(Date.now() + 30 * 24 * 60 * 60 * 1e3).toISOString()
132
+ });
133
+ });
134
+ const timeout = setTimeout(() => {
135
+ if (!settled) {
136
+ settled = true;
137
+ server.close();
138
+ reject(new Error("Authentication timed out. Please try again."));
139
+ }
140
+ }, timeoutMs);
141
+ server.on("error", (err) => {
142
+ if (!settled) {
143
+ settled = true;
144
+ clearTimeout(timeout);
145
+ reject(err);
146
+ }
147
+ });
148
+ server.listen(port, "127.0.0.1");
149
+ });
150
+ }
151
+ async function login(onStatusChange) {
152
+ const config = getConfig();
153
+ const state = generateState();
154
+ onStatusChange?.("Starting authentication server...");
155
+ let port;
156
+ try {
157
+ port = await findAvailablePort(8080, 8099);
158
+ } catch (err) {
159
+ return {
160
+ success: false,
161
+ error: `Failed to find available port: ${err}`
162
+ };
163
+ }
164
+ const serverPromise = startAuthServer(port, state);
165
+ const callbackUrl = `http://localhost:${port}/callback`;
166
+ const authUrl = `${config.consoleUrl}/auth/cli?state=${state}&redirect_uri=${encodeURIComponent(callbackUrl)}`;
167
+ onStatusChange?.("Opening browser for authentication...");
168
+ try {
169
+ await open(authUrl);
170
+ } catch (err) {
171
+ return {
172
+ success: false,
173
+ error: `Failed to open browser: ${err}`
174
+ };
175
+ }
176
+ onStatusChange?.("Waiting for authentication...");
177
+ try {
178
+ const result = await serverPromise;
179
+ onStatusChange?.("Storing credentials...");
180
+ await storeToken(result.token, new Date(result.expiresAt));
181
+ return { success: true };
182
+ } catch (err) {
183
+ return {
184
+ success: false,
185
+ error: err instanceof Error ? err.message : String(err)
186
+ };
187
+ }
188
+ }
189
+ async function logout() {
190
+ await clearToken();
191
+ }
192
+ async function isAuthenticated() {
193
+ return hasValidToken();
194
+ }
195
+ async function getCurrentToken() {
196
+ return getToken();
197
+ }
198
+ var SUCCESS_HTML, ERROR_HTML;
199
+ var init_auth_manager = __esm({
200
+ "src/auth/auth-manager.ts"() {
201
+ "use strict";
202
+ init_token_storage();
203
+ init_config();
204
+ SUCCESS_HTML = `
205
+ <!DOCTYPE html>
206
+ <html>
207
+ <head>
208
+ <meta charset="utf-8" />
209
+ <title>Terminal Chat - Authentication Successful</title>
210
+ <style>
211
+ body {
212
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
213
+ background: #1F1F1F;
214
+ color: #CCCCCC;
215
+ display: flex;
216
+ align-items: center;
217
+ justify-content: center;
218
+ min-height: 100vh;
219
+ margin: 0;
220
+ }
221
+ .container { text-align: center; padding: 2rem; }
222
+ .success { color: #4EC9B0; font-size: 1.5rem; margin-bottom: 1rem; }
223
+ .message { color: #9D9D9D; }
224
+ </style>
225
+ </head>
226
+ <body>
227
+ <div class="container">
228
+ <div class="success">&#10003; Authentication successful!</div>
229
+ <div class="message">You can close this window and return to the terminal.</div>
230
+ </div>
231
+ </body>
232
+ </html>
233
+ `;
234
+ ERROR_HTML = (message) => `
235
+ <!DOCTYPE html>
236
+ <html>
237
+ <head>
238
+ <meta charset="utf-8" />
239
+ <title>Terminal Chat - Authentication Failed</title>
240
+ <style>
241
+ body {
242
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
243
+ background: #1F1F1F;
244
+ color: #CCCCCC;
245
+ display: flex;
246
+ align-items: center;
247
+ justify-content: center;
248
+ min-height: 100vh;
249
+ margin: 0;
250
+ }
251
+ .container { text-align: center; padding: 2rem; }
252
+ .error { color: #F85149; font-size: 1.5rem; margin-bottom: 1rem; }
253
+ .message { color: #9D9D9D; }
254
+ </style>
255
+ </head>
256
+ <body>
257
+ <div class="container">
258
+ <div class="error">\u2717 Authentication failed</div>
259
+ <div class="message">${message}</div>
260
+ </div>
261
+ </body>
262
+ </html>
263
+ `;
264
+ }
265
+ });
266
+
267
+ // src/components/LoginScreen.tsx
268
+ import { useEffect } from "react";
269
+ import { Box as Box2, Text as Text2, useInput as useInput2, useStdout } from "ink";
270
+ import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
271
+ function LoginScreen({ onLogin, status, isLoading }) {
272
+ const { stdout } = useStdout();
273
+ useEffect(() => {
274
+ if (!stdout) return;
275
+ stdout.write("\x1B]0;Welcome to Groupchatty\x07");
276
+ }, [stdout]);
277
+ useInput2((input, key) => {
278
+ if (key.return && !isLoading) {
279
+ onLogin();
280
+ }
281
+ });
282
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", alignItems: "center", justifyContent: "center", padding: 2, children: [
283
+ /* @__PURE__ */ jsx2(Box2, { marginBottom: 2, children: /* @__PURE__ */ jsx2(Text2, { color: "redBright", bold: true, children: `
284
+ \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2557
285
+ \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u255A\u2550\u2550\u2588\u2588\u2554\u2550\u2550\u255D\u255A\u2550\u2550\u2588\u2588\u2554\u2550\u2550\u255D\u255A\u2588\u2588\u2557 \u2588\u2588\u2554\u255D
286
+ \u2588\u2588\u2551 \u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2554\u255D
287
+ \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u255D \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u255A\u2588\u2588\u2554\u255D
288
+ \u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2551 \u2588\u2588\u2551\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551
289
+ \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D
290
+ G R O U P C H A T
291
+ ` }) }),
292
+ /* @__PURE__ */ jsx2(
293
+ Box2,
294
+ {
295
+ borderStyle: "single",
296
+ borderColor: "redBright",
297
+ paddingX: 4,
298
+ paddingY: 1,
299
+ flexDirection: "column",
300
+ alignItems: "center",
301
+ children: isLoading ? /* @__PURE__ */ jsxs2(Fragment, { children: [
302
+ /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: status || "Authenticating..." }),
303
+ /* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsx2(Text2, { color: "gray", children: "Please complete login in your browser..." }) })
304
+ ] }) : status ? /* @__PURE__ */ jsxs2(Fragment, { children: [
305
+ /* @__PURE__ */ jsx2(Text2, { color: "red", children: status }),
306
+ /* @__PURE__ */ jsxs2(Box2, { marginTop: 1, children: [
307
+ /* @__PURE__ */ jsx2(Text2, { color: "gray", children: "Press " }),
308
+ /* @__PURE__ */ jsx2(Text2, { color: "cyan", bold: true, children: "Enter" }),
309
+ /* @__PURE__ */ jsx2(Text2, { color: "gray", children: " to try again" })
310
+ ] })
311
+ ] }) : /* @__PURE__ */ jsxs2(Fragment, { children: [
312
+ /* @__PURE__ */ jsx2(Text2, { color: "redBright", children: "Welcome to Groupchat!" }),
313
+ /* @__PURE__ */ jsxs2(Box2, { marginTop: 1, children: [
314
+ /* @__PURE__ */ jsx2(Text2, { color: "gray", children: "Press " }),
315
+ /* @__PURE__ */ jsx2(Text2, { color: "green", bold: true, children: "Enter" }),
316
+ /* @__PURE__ */ jsx2(Text2, { color: "gray", children: " to login with your browser" })
317
+ ] })
318
+ ] })
319
+ }
320
+ ),
321
+ /* @__PURE__ */ jsx2(Box2, { marginTop: 2, children: /* @__PURE__ */ jsx2(Text2, { color: "gray", children: "Ctrl+C to exit" }) })
322
+ ] });
323
+ }
324
+ var init_LoginScreen = __esm({
325
+ "src/components/LoginScreen.tsx"() {
326
+ "use strict";
327
+ }
328
+ });
329
+
330
+ // src/components/Header.tsx
331
+ import { Box as Box3, Text as Text3 } from "ink";
332
+ import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
333
+ function Header({
334
+ username,
335
+ roomName,
336
+ connectionStatus,
337
+ title,
338
+ showStatus = true
339
+ }) {
340
+ const statusColor = connectionStatus === "connected" ? "green" : connectionStatus === "connecting" ? "yellow" : "red";
341
+ const statusText = connectionStatus === "connected" ? "ONLINE" : connectionStatus === "connecting" ? "CONNECTING" : "OFFLINE";
342
+ return /* @__PURE__ */ jsxs3(
343
+ Box3,
344
+ {
345
+ borderStyle: "single",
346
+ borderColor: "gray",
347
+ paddingX: 1,
348
+ justifyContent: "space-between",
349
+ width: "100%",
350
+ flexShrink: 0,
351
+ children: [
352
+ /* @__PURE__ */ jsx3(Box3, { children: title || /* @__PURE__ */ jsxs3(Fragment2, { children: [
353
+ /* @__PURE__ */ jsxs3(Text3, { color: "cyan", bold: true, children: [
354
+ "$",
355
+ " "
356
+ ] }),
357
+ /* @__PURE__ */ jsx3(Text3, { color: "blue", bold: true, children: "groupchat" }),
358
+ /* @__PURE__ */ jsx3(Text3, { color: "gray", children: " --session " }),
359
+ /* @__PURE__ */ jsx3(Text3, { color: "yellow", children: username || "..." })
360
+ ] }) }),
361
+ /* @__PURE__ */ jsxs3(Box3, { children: [
362
+ showStatus && /* @__PURE__ */ jsxs3(Fragment2, { children: [
363
+ /* @__PURE__ */ jsxs3(Text3, { color: statusColor, children: [
364
+ "[",
365
+ statusText,
366
+ "]"
367
+ ] }),
368
+ /* @__PURE__ */ jsx3(Text3, { color: "gray", children: " " })
369
+ ] }),
370
+ /* @__PURE__ */ jsx3(Text3, { color: "gray", children: "[Ctrl+O: LOGOUT]" })
371
+ ] })
372
+ ]
373
+ }
374
+ );
375
+ }
376
+ var init_Header = __esm({
377
+ "src/components/Header.tsx"() {
378
+ "use strict";
379
+ }
380
+ });
381
+
382
+ // src/components/Layout.tsx
383
+ import { Children, isValidElement } from "react";
384
+ import { Box as Box4 } from "ink";
385
+ import { Fragment as Fragment3, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
386
+ function LayoutHeader({ children }) {
387
+ return /* @__PURE__ */ jsx4(Fragment3, { children });
388
+ }
389
+ function LayoutContent({ children }) {
390
+ return /* @__PURE__ */ jsx4(Fragment3, { children });
391
+ }
392
+ function extractSlot(children, SlotComponent) {
393
+ let slotContent = null;
394
+ Children.forEach(children, (child) => {
395
+ if (isValidElement(child) && child.type === SlotComponent) {
396
+ slotContent = child.props.children;
397
+ }
398
+ });
399
+ return slotContent;
400
+ }
401
+ function Layout({ width, height, topPadding = 0, children }) {
402
+ const header = extractSlot(children, LayoutHeader);
403
+ const content = extractSlot(children, LayoutContent);
404
+ return /* @__PURE__ */ jsxs4(
405
+ Box4,
406
+ {
407
+ flexDirection: "column",
408
+ width,
409
+ height,
410
+ overflow: "hidden",
411
+ paddingTop: topPadding,
412
+ children: [
413
+ header,
414
+ content
415
+ ]
416
+ }
417
+ );
418
+ }
419
+ var init_Layout = __esm({
420
+ "src/components/Layout.tsx"() {
421
+ "use strict";
422
+ Layout.Header = LayoutHeader;
423
+ Layout.Content = LayoutContent;
424
+ }
425
+ });
426
+
427
+ // src/lib/constants.ts
428
+ function getAgentDisplayName(agent) {
429
+ if (!agent) return "";
430
+ return AGENT_CONFIG[agent].displayName;
431
+ }
432
+ function getAgentColor(agent) {
433
+ if (!agent) return void 0;
434
+ return AGENT_CONFIG[agent].color;
435
+ }
436
+ var AGENT_CONFIG;
437
+ var init_constants = __esm({
438
+ "src/lib/constants.ts"() {
439
+ "use strict";
440
+ AGENT_CONFIG = {
441
+ claude: {
442
+ type: "claude",
443
+ displayName: "Claude Code",
444
+ color: "redBright"
445
+ },
446
+ codex: {
447
+ type: "codex",
448
+ displayName: "Codex",
449
+ color: "cyan"
450
+ },
451
+ cursor: {
452
+ type: "cursor",
453
+ displayName: "Cursor",
454
+ color: "blueBright"
455
+ },
456
+ windsurf: {
457
+ type: "windsurf",
458
+ displayName: "Windsurf",
459
+ color: "magenta"
460
+ }
461
+ };
462
+ }
463
+ });
464
+
465
+ // src/components/AtAGlance.tsx
466
+ import { Box as Box5, Text as Text4 } from "ink";
467
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
468
+ function AtAGlance({ presenceState }) {
469
+ const userStats = Object.values(presenceState).reduce(
470
+ (acc, userData) => {
471
+ acc.total++;
472
+ const agent = userData.metas[0]?.current_agent;
473
+ if (agent === "claude") {
474
+ acc.claude++;
475
+ } else if (agent === "codex") {
476
+ acc.codex++;
477
+ } else if (agent === "cursor") {
478
+ acc.cursor++;
479
+ } else if (agent === "windsurf") {
480
+ acc.windsurf++;
481
+ }
482
+ return acc;
483
+ },
484
+ { total: 0, claude: 0, codex: 0, cursor: 0, windsurf: 0 }
485
+ );
486
+ return /* @__PURE__ */ jsxs5(
487
+ Box5,
488
+ {
489
+ flexDirection: "column",
490
+ flexShrink: 0,
491
+ borderStyle: "single",
492
+ borderColor: "gray",
493
+ width: 26,
494
+ paddingX: 1,
495
+ children: [
496
+ /* @__PURE__ */ jsx5(Box5, { marginBottom: 1, children: /* @__PURE__ */ jsx5(Text4, { color: "white", children: "At A Glance" }) }),
497
+ /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", gap: 1, children: [
498
+ /* @__PURE__ */ jsxs5(Box5, { children: [
499
+ /* @__PURE__ */ jsx5(Text4, { color: "green", children: "\u25CF " }),
500
+ /* @__PURE__ */ jsxs5(Text4, { color: "white", children: [
501
+ userStats.total,
502
+ " Online"
503
+ ] })
504
+ ] }),
505
+ userStats.claude > 0 && /* @__PURE__ */ jsxs5(Box5, { children: [
506
+ /* @__PURE__ */ jsx5(Text4, { color: AGENT_CONFIG.claude.color, children: "\u25CF " }),
507
+ /* @__PURE__ */ jsxs5(Text4, { color: "white", children: [
508
+ userStats.claude,
509
+ " Using ",
510
+ AGENT_CONFIG.claude.displayName
511
+ ] })
512
+ ] }),
513
+ userStats.codex > 0 && /* @__PURE__ */ jsxs5(Box5, { children: [
514
+ /* @__PURE__ */ jsx5(Text4, { color: AGENT_CONFIG.codex.color, children: "\u25CF " }),
515
+ /* @__PURE__ */ jsxs5(Text4, { color: "white", children: [
516
+ userStats.codex,
517
+ " Using ",
518
+ AGENT_CONFIG.codex.displayName
519
+ ] })
520
+ ] }),
521
+ userStats.cursor > 0 && /* @__PURE__ */ jsxs5(Box5, { children: [
522
+ /* @__PURE__ */ jsx5(Text4, { color: AGENT_CONFIG.cursor.color, children: "\u25CF " }),
523
+ /* @__PURE__ */ jsxs5(Text4, { color: "white", children: [
524
+ userStats.cursor,
525
+ " Using ",
526
+ AGENT_CONFIG.cursor.displayName
527
+ ] })
528
+ ] }),
529
+ userStats.windsurf > 0 && /* @__PURE__ */ jsxs5(Box5, { children: [
530
+ /* @__PURE__ */ jsx5(Text4, { color: AGENT_CONFIG.windsurf.color, children: "\u25CF " }),
531
+ /* @__PURE__ */ jsxs5(Text4, { color: "white", children: [
532
+ userStats.windsurf,
533
+ " Using ",
534
+ AGENT_CONFIG.windsurf.displayName
535
+ ] })
536
+ ] }),
537
+ userStats.total === 0 && /* @__PURE__ */ jsx5(Box5, { children: /* @__PURE__ */ jsx5(Text4, { color: "gray", children: "No users online" }) })
538
+ ] })
539
+ ]
540
+ }
541
+ );
542
+ }
543
+ var init_AtAGlance = __esm({
544
+ "src/components/AtAGlance.tsx"() {
545
+ "use strict";
546
+ init_constants();
547
+ }
548
+ });
549
+
550
+ // src/routes/Router.tsx
551
+ import { createContext, useContext, useState as useState2, useCallback } from "react";
552
+ import { jsx as jsx6 } from "react/jsx-runtime";
553
+ function useNavigation() {
554
+ const context = useContext(NavigationContext);
555
+ if (!context) {
556
+ throw new Error("useNavigation must be used within a Router");
557
+ }
558
+ return context;
559
+ }
560
+ function Router({ initialRoute = "menu", children }) {
561
+ const [route, setRoute] = useState2(initialRoute);
562
+ const [history, setHistory] = useState2([initialRoute]);
563
+ const navigate = useCallback((to) => {
564
+ setRoute(to);
565
+ setHistory((prev) => [...prev, to]);
566
+ }, []);
567
+ const goBack = useCallback(() => {
568
+ setHistory((prev) => {
569
+ if (prev.length <= 1) return prev;
570
+ const newHistory = prev.slice(0, -1);
571
+ const previousRoute = newHistory[newHistory.length - 1];
572
+ setRoute(previousRoute);
573
+ return newHistory;
574
+ });
575
+ }, []);
576
+ return /* @__PURE__ */ jsx6(NavigationContext.Provider, { value: { route, navigate, goBack }, children });
577
+ }
578
+ var NavigationContext;
579
+ var init_Router = __esm({
580
+ "src/routes/Router.tsx"() {
581
+ "use strict";
582
+ NavigationContext = createContext(null);
583
+ }
584
+ });
585
+
586
+ // src/components/Menu.tsx
587
+ import { useState as useState3, useEffect as useEffect2, useMemo } from "react";
588
+ import { Box as Box6, Text as Text5, useInput as useInput3, useStdout as useStdout2 } from "ink";
589
+ import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
590
+ function Menu({
591
+ width,
592
+ height,
593
+ currentChannel,
594
+ onChannelSelect,
595
+ username,
596
+ connectionStatus,
597
+ onLogout,
598
+ topPadding = 0,
599
+ publicChannels,
600
+ privateChannels,
601
+ unreadCounts,
602
+ aggregatedPresence
603
+ }) {
604
+ const { stdout } = useStdout2();
605
+ const { navigate } = useNavigation();
606
+ const sortedPublicChannels = useMemo(() => {
607
+ return [...publicChannels].sort((a, b) => a.id.localeCompare(b.id));
608
+ }, [publicChannels]);
609
+ const allChannels = useMemo(() => {
610
+ return [...sortedPublicChannels, ...privateChannels];
611
+ }, [sortedPublicChannels, privateChannels]);
612
+ const menuItems = useMemo(() => {
613
+ const items = allChannels.map((channel) => ({
614
+ type: "channel",
615
+ channel
616
+ }));
617
+ items.push({
618
+ type: "action",
619
+ action: "create-channel",
620
+ label: "Create New Private Channel"
621
+ });
622
+ return items;
623
+ }, [allChannels]);
624
+ const [selectedIndex, setSelectedIndex] = useState3(0);
625
+ useEffect2(() => {
626
+ if (menuItems.length > 0) {
627
+ const currentIndex = menuItems.findIndex(
628
+ (item) => item.type === "channel" && item.channel.slug === currentChannel
629
+ );
630
+ setSelectedIndex(currentIndex >= 0 ? currentIndex : 0);
631
+ }
632
+ }, [menuItems, currentChannel]);
633
+ useEffect2(() => {
634
+ if (!stdout) return;
635
+ stdout.write(`\x1B]0;Menu\x07`);
636
+ }, [stdout]);
637
+ useInput3((input, key) => {
638
+ if (key.escape) {
639
+ navigate("chat");
640
+ return;
641
+ }
642
+ if (key.upArrow) {
643
+ setSelectedIndex((prev) => Math.max(0, prev - 1));
644
+ return;
645
+ }
646
+ if (key.downArrow) {
647
+ setSelectedIndex((prev) => Math.min(menuItems.length - 1, prev + 1));
648
+ return;
649
+ }
650
+ if (key.return && menuItems.length > 0) {
651
+ const selected = menuItems[selectedIndex];
652
+ if (selected) {
653
+ if (selected.type === "channel") {
654
+ onChannelSelect(selected.channel.slug);
655
+ navigate("chat");
656
+ } else if (selected.type === "action" && selected.action === "create-channel") {
657
+ navigate("create-channel");
658
+ }
659
+ }
660
+ }
661
+ });
662
+ const headerHeight = 3;
663
+ const contentHeight = height - topPadding - headerHeight;
664
+ const privateStartIndex = sortedPublicChannels.length;
665
+ return /* @__PURE__ */ jsxs6(Layout, { width, height, topPadding, children: [
666
+ /* @__PURE__ */ jsx7(Layout.Header, { children: /* @__PURE__ */ jsx7(
667
+ Header,
668
+ {
669
+ username,
670
+ roomName: "Menu",
671
+ connectionStatus,
672
+ onLogout,
673
+ title: /* @__PURE__ */ jsx7(Text5, { bold: true, color: "cyan", children: "Menu" }),
674
+ showStatus: false
675
+ }
676
+ ) }),
677
+ /* @__PURE__ */ jsx7(Layout.Content, { children: /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", height: contentHeight, children: [
678
+ /* @__PURE__ */ jsxs6(Box6, { flexDirection: "row", flexGrow: 1, children: [
679
+ /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", flexGrow: 1, padding: 2, children: [
680
+ sortedPublicChannels.length > 0 && /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", marginBottom: 1, children: [
681
+ /* @__PURE__ */ jsx7(Box6, { marginBottom: 1, children: /* @__PURE__ */ jsx7(Text5, { bold: true, color: "white", children: "Global Channels" }) }),
682
+ sortedPublicChannels.map((channel, idx) => {
683
+ const isSelected = selectedIndex === idx;
684
+ const unreadCount = unreadCounts[channel.slug] || 0;
685
+ return /* @__PURE__ */ jsx7(
686
+ ChannelItem,
687
+ {
688
+ channel,
689
+ isSelected,
690
+ unreadCount
691
+ },
692
+ channel.id
693
+ );
694
+ })
695
+ ] }),
696
+ privateChannels.length > 0 && /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", marginBottom: 1, children: [
697
+ /* @__PURE__ */ jsx7(Box6, { marginBottom: 1, children: /* @__PURE__ */ jsx7(Text5, { bold: true, color: "white", children: "Private Channels" }) }),
698
+ privateChannels.map((channel, idx) => {
699
+ const absoluteIndex = privateStartIndex + idx;
700
+ const isSelected = selectedIndex === absoluteIndex;
701
+ const unreadCount = unreadCounts[channel.slug] || 0;
702
+ return /* @__PURE__ */ jsx7(
703
+ ChannelItem,
704
+ {
705
+ channel,
706
+ isSelected,
707
+ isPrivate: true,
708
+ unreadCount
709
+ },
710
+ channel.id
711
+ );
712
+ })
713
+ ] }),
714
+ /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", marginBottom: 1, children: [
715
+ privateChannels.length === 0 && /* @__PURE__ */ jsx7(Box6, { marginBottom: 1, children: /* @__PURE__ */ jsx7(Text5, { bold: true, color: "white", children: "Private Channels" }) }),
716
+ /* @__PURE__ */ jsx7(
717
+ ActionItem,
718
+ {
719
+ label: "+ Create New Private Channel",
720
+ isSelected: selectedIndex === allChannels.length
721
+ }
722
+ )
723
+ ] }),
724
+ allChannels.length === 0 && /* @__PURE__ */ jsx7(Box6, { children: /* @__PURE__ */ jsx7(Text5, { color: "gray", children: "No channels available" }) })
725
+ ] }),
726
+ /* @__PURE__ */ jsx7(Box6, { paddingRight: 2, paddingTop: 2, children: /* @__PURE__ */ jsx7(AtAGlance, { presenceState: aggregatedPresence }) })
727
+ ] }),
728
+ /* @__PURE__ */ jsx7(Box6, { paddingX: 2, paddingBottom: 2, children: /* @__PURE__ */ jsxs6(
729
+ Box6,
730
+ {
731
+ flexDirection: "column",
732
+ borderStyle: "single",
733
+ borderColor: "gray",
734
+ paddingX: 1,
735
+ children: [
736
+ /* @__PURE__ */ jsxs6(Text5, { color: "gray", children: [
737
+ /* @__PURE__ */ jsx7(Text5, { color: "cyan", children: "Up/Down" }),
738
+ " Navigate channels"
739
+ ] }),
740
+ /* @__PURE__ */ jsxs6(Text5, { color: "gray", children: [
741
+ /* @__PURE__ */ jsx7(Text5, { color: "cyan", children: "Enter" }),
742
+ " Join selected channel"
743
+ ] }),
744
+ /* @__PURE__ */ jsxs6(Text5, { color: "gray", children: [
745
+ /* @__PURE__ */ jsx7(Text5, { color: "cyan", children: "ESC" }),
746
+ " Back to chat"
747
+ ] }),
748
+ /* @__PURE__ */ jsxs6(Text5, { color: "gray", children: [
749
+ /* @__PURE__ */ jsx7(Text5, { color: "cyan", children: "Ctrl+C" }),
750
+ " Exit the app"
751
+ ] })
752
+ ]
753
+ }
754
+ ) })
755
+ ] }) })
756
+ ] });
757
+ }
758
+ function ChannelItem({ channel, isSelected, isPrivate = false, unreadCount = 0 }) {
759
+ return /* @__PURE__ */ jsxs6(Box6, { marginLeft: 2, children: [
760
+ /* @__PURE__ */ jsxs6(Text5, { color: isSelected ? "green" : "white", bold: isSelected, children: [
761
+ isSelected ? "> " : " ",
762
+ isPrivate && /* @__PURE__ */ jsx7(Text5, { color: "yellow", children: "\u{1F512} " }),
763
+ "#",
764
+ channel.name || channel.slug,
765
+ unreadCount > 0 && /* @__PURE__ */ jsxs6(Text5, { color: "green", bold: true, children: [
766
+ " ",
767
+ "(",
768
+ unreadCount,
769
+ ")"
770
+ ] })
771
+ ] }),
772
+ isSelected && channel.description && /* @__PURE__ */ jsxs6(Text5, { color: "gray", dimColor: true, children: [
773
+ " ",
774
+ "- ",
775
+ channel.description
776
+ ] })
777
+ ] });
778
+ }
779
+ function ActionItem({ label, isSelected }) {
780
+ return /* @__PURE__ */ jsx7(Box6, { marginLeft: 2, children: /* @__PURE__ */ jsxs6(Text5, { color: isSelected ? "green" : "cyan", bold: isSelected, children: [
781
+ isSelected ? "> " : " ",
782
+ label
783
+ ] }) });
784
+ }
785
+ var init_Menu = __esm({
786
+ "src/components/Menu.tsx"() {
787
+ "use strict";
788
+ init_Header();
789
+ init_Layout();
790
+ init_AtAGlance();
791
+ init_Router();
792
+ }
793
+ });
794
+
795
+ // src/components/MessageItem.tsx
796
+ import { Box as Box7, Text as Text6 } from "ink";
797
+ import { jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
798
+ function getUsernameColor(username) {
799
+ const colors = [
800
+ "cyan",
801
+ "magenta",
802
+ "yellow",
803
+ "blue",
804
+ "green",
805
+ "red"
806
+ ];
807
+ let hash = 0;
808
+ for (let i = 0; i < username.length; i++) {
809
+ hash = username.charCodeAt(i) + ((hash << 5) - hash);
810
+ }
811
+ return colors[Math.abs(hash) % colors.length];
812
+ }
813
+ function formatTime(timestamp) {
814
+ const date = new Date(timestamp);
815
+ return date.toLocaleTimeString("en-US", {
816
+ hour: "2-digit",
817
+ minute: "2-digit",
818
+ hour12: true
819
+ });
820
+ }
821
+ function MessageItem({ message, isOwnMessage }) {
822
+ const time = formatTime(message.timestamp);
823
+ const usernameColor = getUsernameColor(message.username);
824
+ if (isOwnMessage) {
825
+ return /* @__PURE__ */ jsx8(Box7, { justifyContent: "flex-end", paddingY: 0, children: /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", alignItems: "flex-end", children: [
826
+ /* @__PURE__ */ jsxs7(Box7, { children: [
827
+ /* @__PURE__ */ jsxs7(Text6, { color: "gray", children: [
828
+ "[",
829
+ time,
830
+ "] "
831
+ ] }),
832
+ /* @__PURE__ */ jsx8(Text6, { color: usernameColor, bold: true, children: message.username }),
833
+ /* @__PURE__ */ jsx8(Text6, { color: "gray", children: " \u2192" })
834
+ ] }),
835
+ /* @__PURE__ */ jsx8(Box7, { paddingLeft: 2, children: /* @__PURE__ */ jsx8(Text6, { children: message.content }) })
836
+ ] }) });
837
+ }
838
+ return /* @__PURE__ */ jsx8(Box7, { justifyContent: "flex-start", paddingY: 0, children: /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
839
+ /* @__PURE__ */ jsxs7(Box7, { children: [
840
+ /* @__PURE__ */ jsx8(Text6, { color: "gray", children: "\u2190 " }),
841
+ /* @__PURE__ */ jsx8(Text6, { color: usernameColor, bold: true, children: message.username }),
842
+ /* @__PURE__ */ jsxs7(Text6, { color: "gray", children: [
843
+ " [",
844
+ time,
845
+ "]"
846
+ ] })
847
+ ] }),
848
+ /* @__PURE__ */ jsx8(Box7, { paddingLeft: 2, children: /* @__PURE__ */ jsx8(Text6, { children: message.content }) })
849
+ ] }) });
850
+ }
851
+ var init_MessageItem = __esm({
852
+ "src/components/MessageItem.tsx"() {
853
+ "use strict";
854
+ }
855
+ });
856
+
857
+ // src/components/MessageList.tsx
858
+ import { useMemo as useMemo2 } from "react";
859
+ import { Box as Box8, Text as Text7 } from "ink";
860
+ import { jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime";
861
+ function MessageList({
862
+ messages,
863
+ currentUsername,
864
+ typingUsers,
865
+ height,
866
+ scrollOffset,
867
+ isDetached
868
+ }) {
869
+ const othersTyping = typingUsers.filter((u) => u !== currentUsername);
870
+ const visibleMessages = useMemo2(() => {
871
+ const linesPerMessage = 2;
872
+ const maxMessages = Math.floor(height / linesPerMessage);
873
+ const endIndex = messages.length - scrollOffset;
874
+ const startIndex = Math.max(0, endIndex - maxMessages);
875
+ return messages.slice(startIndex, endIndex);
876
+ }, [messages, height, scrollOffset]);
877
+ return /* @__PURE__ */ jsxs8(
878
+ Box8,
879
+ {
880
+ flexDirection: "column",
881
+ height,
882
+ paddingX: 1,
883
+ overflow: "hidden",
884
+ children: [
885
+ /* @__PURE__ */ jsx9(Box8, { flexGrow: 1 }),
886
+ messages.length === 0 ? /* @__PURE__ */ jsx9(Box8, { justifyContent: "center", paddingY: 2, children: /* @__PURE__ */ jsx9(Text7, { color: "gray", children: "No messages yet. Say hello!" }) }) : visibleMessages.map((message) => /* @__PURE__ */ jsx9(
887
+ MessageItem,
888
+ {
889
+ message,
890
+ isOwnMessage: message.username === currentUsername
891
+ },
892
+ message.id
893
+ )),
894
+ isDetached && /* @__PURE__ */ jsx9(Box8, { justifyContent: "center", children: /* @__PURE__ */ jsxs8(Text7, { color: "yellow", bold: true, children: [
895
+ "-- ",
896
+ scrollOffset,
897
+ " more below (\u2193 to scroll down) --"
898
+ ] }) }),
899
+ othersTyping.length > 0 && !isDetached && /* @__PURE__ */ jsx9(Box8, { paddingTop: 1, children: /* @__PURE__ */ jsx9(Text7, { color: "gray", italic: true, children: othersTyping.length === 1 ? `${othersTyping[0]} is typing...` : `${othersTyping.join(", ")} are typing...` }) })
900
+ ]
901
+ }
902
+ );
903
+ }
904
+ var init_MessageList = __esm({
905
+ "src/components/MessageList.tsx"() {
906
+ "use strict";
907
+ init_MessageItem();
908
+ }
909
+ });
910
+
911
+ // src/components/UserList.tsx
912
+ import { Box as Box9, Text as Text8 } from "ink";
913
+ import { Fragment as Fragment4, jsx as jsx10, jsxs as jsxs9 } from "react/jsx-runtime";
914
+ function UserList({
915
+ users,
916
+ currentUsername,
917
+ height,
918
+ isPrivateChannel = false
919
+ }) {
920
+ const onlineCount = users.filter((u) => u.isOnline).length;
921
+ const offlineCount = users.filter((u) => !u.isOnline).length;
922
+ const sortedUsers = [...users].sort((a, b) => {
923
+ if (a.username === currentUsername) return -1;
924
+ if (b.username === currentUsername) return 1;
925
+ if (a.isOnline && !b.isOnline) return -1;
926
+ if (!a.isOnline && b.isOnline) return 1;
927
+ return 0;
928
+ });
929
+ return /* @__PURE__ */ jsxs9(
930
+ Box9,
931
+ {
932
+ flexDirection: "column",
933
+ flexShrink: 0,
934
+ borderStyle: "single",
935
+ borderColor: "gray",
936
+ width: 24,
937
+ height,
938
+ paddingX: 1,
939
+ marginBottom: 1,
940
+ overflow: "hidden",
941
+ children: [
942
+ /* @__PURE__ */ jsx10(Box9, { marginBottom: 1, children: isPrivateChannel ? /* @__PURE__ */ jsx10(Text8, { color: "white", bold: true, children: "MEMBERS" }) : /* @__PURE__ */ jsxs9(Fragment4, { children: [
943
+ /* @__PURE__ */ jsx10(Text8, { color: "green", bold: true, children: "\u25CF " }),
944
+ /* @__PURE__ */ jsx10(Text8, { color: "white", bold: true, children: "ONLINE USERS" })
945
+ ] }) }),
946
+ /* @__PURE__ */ jsx10(Box9, { marginBottom: 1, children: isPrivateChannel ? /* @__PURE__ */ jsxs9(Text8, { color: "cyan", children: [
947
+ "[",
948
+ onlineCount,
949
+ " online]"
950
+ ] }) : /* @__PURE__ */ jsxs9(Text8, { color: "cyan", children: [
951
+ "[",
952
+ onlineCount,
953
+ " connected]"
954
+ ] }) }),
955
+ /* @__PURE__ */ jsx10(Box9, { flexDirection: "column", children: sortedUsers.map((user) => {
956
+ const isTruncated = user.username.length > 8;
957
+ const displayName = isTruncated ? user.username.substring(0, 8) : user.username;
958
+ return /* @__PURE__ */ jsxs9(Box9, { flexDirection: "column", children: [
959
+ /* @__PURE__ */ jsxs9(Box9, { children: [
960
+ /* @__PURE__ */ jsx10(Text8, { color: user.isOnline ? "green" : "gray", children: "\u25CF" }),
961
+ /* @__PURE__ */ jsx10(Text8, { children: " " }),
962
+ /* @__PURE__ */ jsxs9(Text8, { color: user.username === currentUsername ? "yellow" : "white", children: [
963
+ displayName,
964
+ isTruncated && "\u2026"
965
+ ] }),
966
+ user.username === currentUsername && /* @__PURE__ */ jsx10(Text8, { color: "gray", children: " (you)" }),
967
+ user.role === "admin" && /* @__PURE__ */ jsx10(Text8, { color: "yellow", children: " \u2605" })
968
+ ] }),
969
+ user.currentAgent && /* @__PURE__ */ jsx10(Box9, { marginLeft: 2, children: /* @__PURE__ */ jsxs9(Text8, { color: getAgentColor(user.currentAgent), children: [
970
+ "\u2937 Using ",
971
+ getAgentDisplayName(user.currentAgent)
972
+ ] }) })
973
+ ] }, user.username);
974
+ }) })
975
+ ]
976
+ }
977
+ );
978
+ }
979
+ var init_UserList = __esm({
980
+ "src/components/UserList.tsx"() {
981
+ "use strict";
982
+ init_constants();
983
+ }
984
+ });
985
+
986
+ // src/components/InputBox.tsx
987
+ import { useState as useState4, useCallback as useCallback2, useRef, useEffect as useEffect3 } from "react";
988
+ import { Box as Box10, Text as Text9 } from "ink";
989
+ import TextInput from "ink-text-input";
990
+ import { jsx as jsx11, jsxs as jsxs10 } from "react/jsx-runtime";
991
+ function InputBox({
992
+ onSend,
993
+ onTypingStart,
994
+ onTypingStop,
995
+ disabled,
996
+ onInputChange
997
+ }) {
998
+ const [value, setValue] = useState4("");
999
+ const [isSending, setIsSending] = useState4(false);
1000
+ const typingTimeoutRef = useRef(null);
1001
+ const isTypingRef = useRef(false);
1002
+ const handleChange = useCallback2(
1003
+ (newValue) => {
1004
+ setValue(newValue);
1005
+ onInputChange?.(newValue);
1006
+ if (!isTypingRef.current && newValue.length > 0) {
1007
+ isTypingRef.current = true;
1008
+ onTypingStart();
1009
+ }
1010
+ if (typingTimeoutRef.current) {
1011
+ clearTimeout(typingTimeoutRef.current);
1012
+ }
1013
+ if (newValue.length > 0) {
1014
+ typingTimeoutRef.current = setTimeout(() => {
1015
+ isTypingRef.current = false;
1016
+ onTypingStop();
1017
+ }, 2e3);
1018
+ } else {
1019
+ isTypingRef.current = false;
1020
+ onTypingStop();
1021
+ }
1022
+ },
1023
+ [onTypingStart, onTypingStop, onInputChange]
1024
+ );
1025
+ const handleSubmit = useCallback2(async () => {
1026
+ const trimmed = value.trim();
1027
+ if (!trimmed || disabled || isSending) return;
1028
+ setIsSending(true);
1029
+ if (typingTimeoutRef.current) {
1030
+ clearTimeout(typingTimeoutRef.current);
1031
+ }
1032
+ isTypingRef.current = false;
1033
+ onTypingStop();
1034
+ try {
1035
+ await onSend(trimmed);
1036
+ setValue("");
1037
+ } catch {
1038
+ } finally {
1039
+ setIsSending(false);
1040
+ }
1041
+ }, [value, disabled, isSending, onSend, onTypingStop]);
1042
+ useEffect3(() => {
1043
+ return () => {
1044
+ if (typingTimeoutRef.current) {
1045
+ clearTimeout(typingTimeoutRef.current);
1046
+ }
1047
+ };
1048
+ }, []);
1049
+ return /* @__PURE__ */ jsxs10(
1050
+ Box10,
1051
+ {
1052
+ borderStyle: "single",
1053
+ borderColor: "gray",
1054
+ paddingX: 1,
1055
+ flexDirection: "column",
1056
+ width: "100%",
1057
+ flexShrink: 0,
1058
+ children: [
1059
+ /* @__PURE__ */ jsxs10(Box10, { children: [
1060
+ /* @__PURE__ */ jsx11(Text9, { color: "cyan", children: "$ " }),
1061
+ /* @__PURE__ */ jsx11(Box10, { flexGrow: 1, children: /* @__PURE__ */ jsx11(
1062
+ TextInput,
1063
+ {
1064
+ value,
1065
+ onChange: handleChange,
1066
+ onSubmit: handleSubmit,
1067
+ placeholder: disabled ? "Connecting..." : "Type a message..."
1068
+ }
1069
+ ) }),
1070
+ /* @__PURE__ */ jsxs10(Text9, { color: disabled || !value.trim() ? "gray" : "green", children: [
1071
+ " ",
1072
+ "[SEND]"
1073
+ ] })
1074
+ ] }),
1075
+ /* @__PURE__ */ jsx11(Box10, { children: /* @__PURE__ */ jsx11(Text9, { color: "gray", dimColor: true, children: "Enter to send" }) })
1076
+ ]
1077
+ }
1078
+ );
1079
+ }
1080
+ var init_InputBox = __esm({
1081
+ "src/components/InputBox.tsx"() {
1082
+ "use strict";
1083
+ }
1084
+ });
1085
+
1086
+ // src/components/StatusBar.tsx
1087
+ import { Box as Box11, Text as Text10 } from "ink";
1088
+ import { Fragment as Fragment5, jsx as jsx12, jsxs as jsxs11 } from "react/jsx-runtime";
1089
+ function StatusBar({
1090
+ connectionStatus,
1091
+ error,
1092
+ userCount
1093
+ }) {
1094
+ const presenceText = connectionStatus === "connected" ? "Active" : connectionStatus === "connecting" ? "Connecting" : "Disconnected";
1095
+ const presenceColor = connectionStatus === "connected" ? "green" : connectionStatus === "connecting" ? "yellow" : "red";
1096
+ return /* @__PURE__ */ jsxs11(
1097
+ Box11,
1098
+ {
1099
+ borderStyle: "single",
1100
+ borderColor: "gray",
1101
+ paddingX: 1,
1102
+ justifyContent: "space-between",
1103
+ width: "100%",
1104
+ flexShrink: 0,
1105
+ children: [
1106
+ /* @__PURE__ */ jsx12(Box11, { children: error ? /* @__PURE__ */ jsxs11(Text10, { color: "red", children: [
1107
+ "[Error: ",
1108
+ error,
1109
+ "]"
1110
+ ] }) : /* @__PURE__ */ jsxs11(Fragment5, { children: [
1111
+ /* @__PURE__ */ jsx12(Text10, { color: "gray", children: "\u2192 Presence: " }),
1112
+ /* @__PURE__ */ jsx12(Text10, { color: presenceColor, children: presenceText })
1113
+ ] }) }),
1114
+ /* @__PURE__ */ jsxs11(Box11, { children: [
1115
+ /* @__PURE__ */ jsx12(Text10, { color: "gray", children: "Users: " }),
1116
+ /* @__PURE__ */ jsx12(Text10, { color: "cyan", children: userCount }),
1117
+ /* @__PURE__ */ jsx12(Text10, { color: "gray", children: " | \u2191/\u2193 scroll | Ctrl+E users | Ctrl+C exit" })
1118
+ ] })
1119
+ ]
1120
+ }
1121
+ );
1122
+ }
1123
+ var init_StatusBar = __esm({
1124
+ "src/components/StatusBar.tsx"() {
1125
+ "use strict";
1126
+ }
1127
+ });
1128
+
1129
+ // src/components/ToolTip.tsx
1130
+ import { Box as Box12, Text as Text11 } from "ink";
1131
+ import { jsx as jsx13, jsxs as jsxs12 } from "react/jsx-runtime";
1132
+ var ToolTip;
1133
+ var init_ToolTip = __esm({
1134
+ "src/components/ToolTip.tsx"() {
1135
+ "use strict";
1136
+ ToolTip = ({ tips, type }) => {
1137
+ return /* @__PURE__ */ jsxs12(Box12, { flexDirection: "column", paddingX: 1, children: [
1138
+ /* @__PURE__ */ jsx13(Text11, { children: " " }),
1139
+ type === "Command" && tips.map((tip) => /* @__PURE__ */ jsxs12(Text11, { color: "gray", children: [
1140
+ /* @__PURE__ */ jsx13(Text11, { color: "cyan", children: tip.syntax }),
1141
+ " - ",
1142
+ tip.description
1143
+ ] }, tip.name)),
1144
+ type === "User" && tips.map((suggestion) => /* @__PURE__ */ jsx13(Text11, { color: "gray", children: /* @__PURE__ */ jsx13(Text11, { color: "cyan", children: suggestion }) }, suggestion))
1145
+ ] });
1146
+ };
1147
+ }
1148
+ });
1149
+
1150
+ // src/lib/commands.ts
1151
+ var parameterValidators, COMMANDS;
1152
+ var init_commands = __esm({
1153
+ "src/lib/commands.ts"() {
1154
+ "use strict";
1155
+ parameterValidators = {
1156
+ username: (value, param, ctx) => {
1157
+ const p = param;
1158
+ if (p.source === "search") {
1159
+ if (!value.startsWith(p.prefix)) {
1160
+ return {
1161
+ isValid: false,
1162
+ error: `Must start with ${p.prefix}`,
1163
+ suggestions: []
1164
+ };
1165
+ }
1166
+ const username2 = value.substring(p.prefix.length);
1167
+ const results = ctx.asyncSearchResults || [];
1168
+ if (!username2) {
1169
+ return {
1170
+ isValid: false,
1171
+ error: "Username required",
1172
+ suggestions: results.map((r) => `${p.prefix}${r.username}`)
1173
+ };
1174
+ }
1175
+ const matching = results.filter(
1176
+ (r) => r.username.toLowerCase().startsWith(username2.toLowerCase())
1177
+ );
1178
+ const exactMatch2 = results.find(
1179
+ (r) => r.username.toLowerCase() === username2.toLowerCase()
1180
+ );
1181
+ return {
1182
+ isValid: results.length > 0 && !!exactMatch2,
1183
+ error: results.length === 0 ? "User not found" : exactMatch2 ? void 0 : "User not found",
1184
+ suggestions: matching.map((r) => `${p.prefix}${r.username}`)
1185
+ };
1186
+ }
1187
+ const getUserList = () => {
1188
+ switch (p.source) {
1189
+ case "subscribed_without_self":
1190
+ return ctx.subscribedUsers.filter((u) => u.username !== ctx.currentUsername);
1191
+ case "not_subscribed": {
1192
+ const subscribedIds = new Set(ctx.subscribedUsers.map((u) => u.user_id));
1193
+ return ctx.presentUsers.filter((u) => !subscribedIds.has(u.user_id));
1194
+ }
1195
+ case "all":
1196
+ default:
1197
+ return ctx.presentUsers;
1198
+ }
1199
+ };
1200
+ const users = getUserList();
1201
+ if (!value.startsWith(p.prefix)) {
1202
+ return {
1203
+ isValid: false,
1204
+ error: `Must start with ${p.prefix}`,
1205
+ suggestions: users.map((u) => `${p.prefix}${u.username}`)
1206
+ };
1207
+ }
1208
+ const username = value.substring(p.prefix.length);
1209
+ if (!username) {
1210
+ return {
1211
+ isValid: false,
1212
+ error: "Username required",
1213
+ suggestions: users.map((u) => `${p.prefix}${u.username}`)
1214
+ };
1215
+ }
1216
+ const matchingUsers = users.filter(
1217
+ (u) => u.username.toLowerCase().startsWith(username.toLowerCase())
1218
+ );
1219
+ const exactMatch = users.find(
1220
+ (u) => u.username.toLowerCase() === username.toLowerCase()
1221
+ );
1222
+ return {
1223
+ isValid: !!exactMatch,
1224
+ error: exactMatch ? void 0 : "User not found",
1225
+ suggestions: matchingUsers.map((u) => `${p.prefix}${u.username}`)
1226
+ };
1227
+ },
1228
+ text: (value, param) => {
1229
+ const p = param;
1230
+ if (p.minLength && value.length < p.minLength) {
1231
+ return { isValid: false, error: `Minimum ${p.minLength} characters` };
1232
+ }
1233
+ if (p.maxLength && value.length > p.maxLength) {
1234
+ return { isValid: false, error: `Maximum ${p.maxLength} characters` };
1235
+ }
1236
+ return { isValid: true };
1237
+ },
1238
+ number: (value, param) => {
1239
+ const p = param;
1240
+ const num = Number(value);
1241
+ if (isNaN(num)) {
1242
+ return { isValid: false, error: "Must be a number" };
1243
+ }
1244
+ if (p.min !== void 0 && num < p.min) {
1245
+ return { isValid: false, error: `Minimum value is ${p.min}` };
1246
+ }
1247
+ if (p.max !== void 0 && num > p.max) {
1248
+ return { isValid: false, error: `Maximum value is ${p.max}` };
1249
+ }
1250
+ return { isValid: true };
1251
+ },
1252
+ choice: (value, param) => {
1253
+ const p = param;
1254
+ const matching = p.choices.filter(
1255
+ (c) => c.toLowerCase().startsWith(value.toLowerCase())
1256
+ );
1257
+ const exactMatch = p.choices.find(
1258
+ (c) => c.toLowerCase() === value.toLowerCase()
1259
+ );
1260
+ return {
1261
+ isValid: !!exactMatch,
1262
+ error: exactMatch ? void 0 : `Must be one of: ${p.choices.join(", ")}`,
1263
+ suggestions: matching
1264
+ };
1265
+ }
1266
+ };
1267
+ COMMANDS = [
1268
+ {
1269
+ name: "/invite",
1270
+ syntax: "/invite @user",
1271
+ description: "Invite a user to join the channel",
1272
+ privateOnly: true,
1273
+ adminOnly: true,
1274
+ parameters: [
1275
+ { name: "user", type: "username", required: true, prefix: "@", source: "search" }
1276
+ ],
1277
+ eventType: "invite_user"
1278
+ },
1279
+ {
1280
+ name: "/remove",
1281
+ syntax: "/remove @user",
1282
+ description: "Remove a user from the channel",
1283
+ privateOnly: true,
1284
+ adminOnly: true,
1285
+ parameters: [
1286
+ { name: "user", type: "username", required: true, prefix: "@", source: "subscribed_without_self" }
1287
+ ],
1288
+ eventType: "remove_user"
1289
+ }
1290
+ // Easy to add more commands:
1291
+ // {
1292
+ // name: "/topic",
1293
+ // syntax: "/topic <text>",
1294
+ // description: "Set the channel topic",
1295
+ // privateOnly: false,
1296
+ // parameters: [
1297
+ // { name: "topic", type: "text", required: true, minLength: 1, maxLength: 200 },
1298
+ // ],
1299
+ // eventType: "set_topic",
1300
+ // },
1301
+ ];
1302
+ }
1303
+ });
1304
+
1305
+ // src/lib/command-parser.ts
1306
+ function parseCommandInput(input, commands, ctx) {
1307
+ const empty = {
1308
+ command: null,
1309
+ phase: "none",
1310
+ parameterValues: /* @__PURE__ */ new Map(),
1311
+ parameterResults: /* @__PURE__ */ new Map(),
1312
+ isValid: true
1313
+ };
1314
+ if (!input.startsWith("/")) {
1315
+ return empty;
1316
+ }
1317
+ const spaceIndex = input.indexOf(" ");
1318
+ const commandText = spaceIndex === -1 ? input : input.substring(0, spaceIndex);
1319
+ const command = commands.find((cmd) => cmd.name === commandText);
1320
+ if (!command) {
1321
+ return {
1322
+ ...empty,
1323
+ phase: "command"
1324
+ };
1325
+ }
1326
+ if (spaceIndex === -1) {
1327
+ const hasRequiredParams = command.parameters.some((p) => p.required);
1328
+ return {
1329
+ command,
1330
+ phase: "command",
1331
+ parameterValues: /* @__PURE__ */ new Map(),
1332
+ parameterResults: /* @__PURE__ */ new Map(),
1333
+ isValid: !hasRequiredParams
1334
+ // Valid only if no required params
1335
+ };
1336
+ }
1337
+ const paramInput = input.substring(spaceIndex + 1);
1338
+ const parameterValues = /* @__PURE__ */ new Map();
1339
+ const parameterResults = /* @__PURE__ */ new Map();
1340
+ if (command.parameters.length > 0) {
1341
+ const param = command.parameters[0];
1342
+ parameterValues.set(param.name, paramInput);
1343
+ if (paramInput) {
1344
+ const result = parameterValidators[param.type](paramInput, param, ctx);
1345
+ parameterResults.set(param.name, result);
1346
+ }
1347
+ }
1348
+ let isValid = true;
1349
+ let error;
1350
+ for (const param of command.parameters) {
1351
+ const value = parameterValues.get(param.name) || "";
1352
+ const result = parameterResults.get(param.name);
1353
+ if (param.required && !value) {
1354
+ isValid = false;
1355
+ error = `${param.name} is required`;
1356
+ break;
1357
+ }
1358
+ if (value && result && !result.isValid) {
1359
+ isValid = false;
1360
+ error = result.error;
1361
+ break;
1362
+ }
1363
+ }
1364
+ return {
1365
+ command,
1366
+ phase: "parameter",
1367
+ parameterValues,
1368
+ parameterResults,
1369
+ isValid,
1370
+ error
1371
+ };
1372
+ }
1373
+ function getSuggestions(input, commands, parsed) {
1374
+ if (parsed.phase === "command" || input.startsWith("/") && !parsed.command) {
1375
+ const filtered = commands.filter((cmd) => {
1376
+ if (input.length > 1) {
1377
+ return cmd.name.startsWith(input.split(" ")[0]);
1378
+ }
1379
+ return true;
1380
+ });
1381
+ if (filtered.length > 0) {
1382
+ return { type: "commands", commands: filtered };
1383
+ }
1384
+ return null;
1385
+ }
1386
+ if (parsed.phase === "parameter" && parsed.command) {
1387
+ const param = parsed.command.parameters[0];
1388
+ if (param) {
1389
+ const result = parsed.parameterResults.get(param.name);
1390
+ if (result?.suggestions && result.suggestions.length > 0) {
1391
+ return {
1392
+ type: "parameter",
1393
+ parameterSuggestions: result.suggestions,
1394
+ parameterName: param.name
1395
+ };
1396
+ }
1397
+ }
1398
+ }
1399
+ return null;
1400
+ }
1401
+ function extractCommandPayload(parsed, ctx) {
1402
+ if (!parsed.command || !parsed.isValid) {
1403
+ return null;
1404
+ }
1405
+ const data = {};
1406
+ for (const param of parsed.command.parameters) {
1407
+ const value = parsed.parameterValues.get(param.name);
1408
+ if (!value) continue;
1409
+ switch (param.type) {
1410
+ case "username": {
1411
+ const p = param;
1412
+ const username = value.substring(p.prefix.length);
1413
+ let user;
1414
+ if (p.source === "search" && ctx.asyncSearchResults) {
1415
+ user = ctx.asyncSearchResults.find(
1416
+ (u) => u.username.toLowerCase() === username.toLowerCase()
1417
+ );
1418
+ }
1419
+ if (!user) {
1420
+ const presentUsers = [...ctx.presentUsers, ...ctx.subscribedUsers];
1421
+ user = presentUsers.find(
1422
+ (u) => u.username.toLowerCase() === username.toLowerCase()
1423
+ );
1424
+ }
1425
+ data.username = user?.username || username;
1426
+ data.user_id = user?.user_id;
1427
+ break;
1428
+ }
1429
+ case "text":
1430
+ data[param.name] = value;
1431
+ break;
1432
+ case "number":
1433
+ data[param.name] = Number(value);
1434
+ break;
1435
+ case "choice":
1436
+ data[param.name] = value;
1437
+ break;
1438
+ }
1439
+ }
1440
+ return {
1441
+ eventType: parsed.command.eventType,
1442
+ data
1443
+ };
1444
+ }
1445
+ var init_command_parser = __esm({
1446
+ "src/lib/command-parser.ts"() {
1447
+ "use strict";
1448
+ init_commands();
1449
+ }
1450
+ });
1451
+
1452
+ // src/lib/chat-client.ts
1453
+ import { Socket } from "phoenix";
1454
+ async function fetchChannels(wsUrl, token) {
1455
+ const backendUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:").replace(/\/socket$/, "");
1456
+ const url = `${backendUrl}/api/channels`;
1457
+ const response = await fetch(url, {
1458
+ headers: {
1459
+ Authorization: `Bearer ${token}`
1460
+ }
1461
+ });
1462
+ if (!response.ok) {
1463
+ throw new Error(`Failed to fetch channels: ${response.status}`);
1464
+ }
1465
+ return response.json();
1466
+ }
1467
+ async function fetchUnreadCounts(wsUrl, token) {
1468
+ const backendUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:").replace(/\/socket$/, "");
1469
+ const url = `${backendUrl}/api/unread-counts`;
1470
+ const response = await fetch(url, {
1471
+ headers: {
1472
+ Authorization: `Bearer ${token}`
1473
+ }
1474
+ });
1475
+ if (!response.ok) {
1476
+ throw new Error(`Failed to fetch unread counts: ${response.status}`);
1477
+ }
1478
+ const data = await response.json();
1479
+ return data.unread_counts || {};
1480
+ }
1481
+ async function searchUsers(wsUrl, token, startsWith, channelSlug, limit = 20) {
1482
+ const backendUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:").replace(/\/socket$/, "");
1483
+ const encodedStartsWith = encodeURIComponent(startsWith);
1484
+ const params = new URLSearchParams({
1485
+ startsWith: encodedStartsWith,
1486
+ limit: limit.toString()
1487
+ });
1488
+ if (channelSlug) {
1489
+ params.append("channel_slug", encodeURIComponent(channelSlug));
1490
+ }
1491
+ const url = `${backendUrl}/api/users/search?${params.toString()}`;
1492
+ const response = await fetch(url, {
1493
+ headers: {
1494
+ Authorization: `Bearer ${token}`
1495
+ }
1496
+ });
1497
+ if (!response.ok) {
1498
+ throw new Error(`Failed to search users: ${response.status}`);
1499
+ }
1500
+ return response.json();
1501
+ }
1502
+ async function createChannel(wsUrl, token, name, description) {
1503
+ const backendUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:").replace(/\/socket$/, "");
1504
+ const url = `${backendUrl}/api/channels`;
1505
+ const body = { name };
1506
+ if (description) {
1507
+ body.description = description;
1508
+ }
1509
+ const response = await fetch(url, {
1510
+ method: "POST",
1511
+ headers: {
1512
+ Authorization: `Bearer ${token}`,
1513
+ "Content-Type": "application/json"
1514
+ },
1515
+ body: JSON.stringify(body)
1516
+ });
1517
+ if (!response.ok) {
1518
+ const data = await response.json();
1519
+ throw new Error(data.error || `Failed to create channel: ${response.status}`);
1520
+ }
1521
+ return response.json();
1522
+ }
1523
+ var init_chat_client = __esm({
1524
+ "src/lib/chat-client.ts"() {
1525
+ "use strict";
1526
+ if (typeof globalThis.WebSocket === "undefined") {
1527
+ throw new Error(
1528
+ "WebSocket is not available. Load the ws polyfill before ChatClient."
1529
+ );
1530
+ }
1531
+ }
1532
+ });
1533
+
1534
+ // src/lib/debounce.ts
1535
+ function debounce(func, wait) {
1536
+ let timeout = null;
1537
+ return (...args) => {
1538
+ if (timeout) clearTimeout(timeout);
1539
+ timeout = setTimeout(() => func(...args), wait);
1540
+ };
1541
+ }
1542
+ var init_debounce = __esm({
1543
+ "src/lib/debounce.ts"() {
1544
+ "use strict";
1545
+ }
1546
+ });
1547
+
1548
+ // src/hooks/use-user-search.ts
1549
+ import { useState as useState5, useEffect as useEffect4, useMemo as useMemo3, useRef as useRef2 } from "react";
1550
+ function useUserSearch(token, query, channelSlug) {
1551
+ const [suggestions, setSuggestions] = useState5([]);
1552
+ const [results, setResults] = useState5([]);
1553
+ const [isLoading, setIsLoading] = useState5(false);
1554
+ const cacheRef = useRef2(/* @__PURE__ */ new Map());
1555
+ const { wsUrl } = getConfig();
1556
+ const debouncedSearch = useMemo3(
1557
+ () => debounce(async (q, slug) => {
1558
+ if (!token) return;
1559
+ const cacheKey = `${q}:${slug}`;
1560
+ if (cacheRef.current.has(cacheKey)) {
1561
+ const cached = cacheRef.current.get(cacheKey);
1562
+ setResults(cached);
1563
+ setSuggestions(cached.map((u) => `@${u.username}`));
1564
+ return;
1565
+ }
1566
+ setIsLoading(true);
1567
+ try {
1568
+ const result = await searchUsers(wsUrl, token, q, slug);
1569
+ cacheRef.current.set(cacheKey, result.users);
1570
+ setResults(result.users);
1571
+ setSuggestions(result.users.map((u) => `@${u.username}`));
1572
+ } catch (err) {
1573
+ console.error("User search failed:", err);
1574
+ setResults([]);
1575
+ setSuggestions([]);
1576
+ } finally {
1577
+ setIsLoading(false);
1578
+ }
1579
+ }, 300),
1580
+ [wsUrl, token]
1581
+ );
1582
+ useEffect4(() => {
1583
+ if (query && query.length > 0 && channelSlug) {
1584
+ debouncedSearch(query, channelSlug);
1585
+ } else {
1586
+ setResults([]);
1587
+ setSuggestions([]);
1588
+ cacheRef.current.clear();
1589
+ }
1590
+ }, [query, channelSlug, debouncedSearch]);
1591
+ return { suggestions, results, isLoading };
1592
+ }
1593
+ var init_use_user_search = __esm({
1594
+ "src/hooks/use-user-search.ts"() {
1595
+ "use strict";
1596
+ init_chat_client();
1597
+ init_config();
1598
+ init_debounce();
1599
+ }
1600
+ });
1601
+
1602
+ // src/hooks/use-command-input.ts
1603
+ import { useMemo as useMemo4, useState as useState6 } from "react";
1604
+ function useCommandInput({
1605
+ token,
1606
+ currentChannel,
1607
+ isPrivateChannel,
1608
+ connectionStatus,
1609
+ username,
1610
+ users,
1611
+ subscribers,
1612
+ onSendMessage,
1613
+ onCommandSend
1614
+ }) {
1615
+ const [inputValue, setInputValue] = useState6("");
1616
+ const isChannelAdmin = useMemo4(
1617
+ () => subscribers.some((s) => s.username === username && s.role === "admin"),
1618
+ [subscribers, username]
1619
+ );
1620
+ const availableCommands = useMemo4(
1621
+ () => COMMANDS.filter((cmd) => {
1622
+ if (cmd.privateOnly && !isPrivateChannel) return false;
1623
+ if (cmd.adminOnly && !isChannelAdmin) return false;
1624
+ return true;
1625
+ }),
1626
+ [isPrivateChannel, isChannelAdmin]
1627
+ );
1628
+ const baseContext = useMemo4(
1629
+ () => ({
1630
+ presentUsers: users.map((u) => ({ username: u.username, user_id: u.user_id })),
1631
+ subscribedUsers: subscribers.map((s) => ({ username: s.username, user_id: s.user_id })),
1632
+ currentUsername: username
1633
+ }),
1634
+ [users, subscribers, username]
1635
+ );
1636
+ const parsedWithoutAsync = useMemo4(
1637
+ () => parseCommandInput(inputValue, availableCommands, baseContext),
1638
+ [inputValue, availableCommands, baseContext]
1639
+ );
1640
+ const inviteQuery = useMemo4(() => {
1641
+ if (parsedWithoutAsync.command?.name === "/invite" && parsedWithoutAsync.phase === "parameter") {
1642
+ const raw = parsedWithoutAsync.parameterValues.get("user") || "";
1643
+ return raw.replace(/^@/, "");
1644
+ }
1645
+ return null;
1646
+ }, [parsedWithoutAsync]);
1647
+ const { suggestions: asyncSuggestions, results: asyncResults } = useUserSearch(
1648
+ token,
1649
+ inviteQuery,
1650
+ isPrivateChannel ? currentChannel : null
1651
+ );
1652
+ const validationContext = useMemo4(
1653
+ () => ({
1654
+ ...baseContext,
1655
+ asyncSearchResults: asyncResults.length > 0 ? asyncResults : void 0
1656
+ }),
1657
+ [baseContext, asyncResults]
1658
+ );
1659
+ const parsed = useMemo4(
1660
+ () => parseCommandInput(inputValue, availableCommands, validationContext),
1661
+ [inputValue, availableCommands, validationContext]
1662
+ );
1663
+ const suggestionResult = useMemo4(() => {
1664
+ const isCommandLike = inputValue.startsWith("/") || inputValue.startsWith("?");
1665
+ if (!isCommandLike) return null;
1666
+ if (parsed.command?.name === "/invite" && parsed.phase === "parameter" && asyncSuggestions.length) {
1667
+ return { type: "parameter", parameterSuggestions: asyncSuggestions };
1668
+ }
1669
+ return getSuggestions(inputValue, availableCommands, parsed);
1670
+ }, [inputValue, availableCommands, parsed, asyncSuggestions]);
1671
+ const tooltip = useMemo4(() => {
1672
+ if (!suggestionResult) {
1673
+ return { show: false, tips: [], type: "Command", height: 0 };
1674
+ }
1675
+ if (suggestionResult.type === "commands" && suggestionResult.commands) {
1676
+ const tips = suggestionResult.commands;
1677
+ return { show: true, tips, type: "Command", height: tips.length + 1 };
1678
+ }
1679
+ if (suggestionResult.type === "parameter" && suggestionResult.parameterSuggestions) {
1680
+ const tips = suggestionResult.parameterSuggestions;
1681
+ return { show: true, tips, type: "User", height: tips.length + 1 };
1682
+ }
1683
+ return { show: false, tips: [], type: "Command", height: 0 };
1684
+ }, [suggestionResult]);
1685
+ const isInputDisabled = connectionStatus !== "connected" || parsed.command !== null && !parsed.isValid;
1686
+ const handleInputChange = (value) => {
1687
+ setInputValue(value);
1688
+ };
1689
+ const handleSubmit = async (text) => {
1690
+ const parsedForSend = parseCommandInput(text, availableCommands, {
1691
+ ...validationContext,
1692
+ asyncSearchResults: asyncResults.length > 0 ? asyncResults : void 0
1693
+ });
1694
+ if (parsedForSend.command && parsedForSend.isValid) {
1695
+ const payload = extractCommandPayload(parsedForSend, validationContext);
1696
+ if (payload) {
1697
+ await onCommandSend(payload.eventType, payload.data);
1698
+ setInputValue("");
1699
+ return;
1700
+ }
1701
+ }
1702
+ await onSendMessage(text);
1703
+ setInputValue("");
1704
+ };
1705
+ return {
1706
+ inputValue,
1707
+ parsed,
1708
+ tooltip,
1709
+ isInputDisabled,
1710
+ handleInputChange,
1711
+ handleSubmit
1712
+ };
1713
+ }
1714
+ var init_use_command_input = __esm({
1715
+ "src/hooks/use-command-input.ts"() {
1716
+ "use strict";
1717
+ init_commands();
1718
+ init_command_parser();
1719
+ init_use_user_search();
1720
+ }
1721
+ });
1722
+
1723
+ // src/components/ChatView.tsx
1724
+ import { useEffect as useEffect5 } from "react";
1725
+ import { Box as Box13, Text as Text12, useStdout as useStdout3 } from "ink";
1726
+ import { Fragment as Fragment6, jsx as jsx14, jsxs as jsxs13 } from "react/jsx-runtime";
1727
+ function ChatView({
1728
+ terminalSize,
1729
+ currentChannel,
1730
+ channelName,
1731
+ channelDescription,
1732
+ connectionStatus,
1733
+ username,
1734
+ onLogout,
1735
+ messages,
1736
+ typingUsers,
1737
+ middleSectionHeight,
1738
+ scrollOffset,
1739
+ isDetached,
1740
+ showUserList,
1741
+ users,
1742
+ subscribers,
1743
+ isPrivateChannel = false,
1744
+ topPadding = 0,
1745
+ onSend,
1746
+ onTypingStart,
1747
+ onTypingStop,
1748
+ onCommandSend,
1749
+ error,
1750
+ token
1751
+ }) {
1752
+ const { stdout } = useStdout3();
1753
+ const { tooltip, isInputDisabled, handleInputChange, handleSubmit } = useCommandInput({
1754
+ token,
1755
+ currentChannel,
1756
+ isPrivateChannel,
1757
+ connectionStatus,
1758
+ username,
1759
+ users,
1760
+ subscribers,
1761
+ onSendMessage: onSend,
1762
+ onCommandSend
1763
+ });
1764
+ const displayName = channelName || currentChannel;
1765
+ const displayText = channelDescription ? `${displayName} - ${channelDescription}` : displayName;
1766
+ useEffect5(() => {
1767
+ if (!stdout) return;
1768
+ const prefix = connectionStatus === "connected" ? "\u2022 " : "";
1769
+ stdout.write(`\x1B]0;${prefix}#${displayName}\x07`);
1770
+ }, [stdout, connectionStatus, displayName]);
1771
+ return /* @__PURE__ */ jsxs13(Layout, { width: terminalSize.columns, height: terminalSize.rows, topPadding, children: [
1772
+ /* @__PURE__ */ jsx14(Layout.Header, { children: /* @__PURE__ */ jsx14(
1773
+ Header,
1774
+ {
1775
+ username,
1776
+ roomName: currentChannel,
1777
+ connectionStatus,
1778
+ onLogout,
1779
+ title: /* @__PURE__ */ jsxs13(Fragment6, { children: [
1780
+ /* @__PURE__ */ jsx14(Text12, { color: "gray", children: "\u2190 Menu " }),
1781
+ /* @__PURE__ */ jsx14(Text12, { color: "gray", dimColor: true, children: "[CTRL+Q]" }),
1782
+ /* @__PURE__ */ jsx14(Text12, { color: "gray", children: " | " }),
1783
+ /* @__PURE__ */ jsxs13(Text12, { color: "cyan", bold: true, children: [
1784
+ "#",
1785
+ displayText
1786
+ ] })
1787
+ ] })
1788
+ }
1789
+ ) }),
1790
+ /* @__PURE__ */ jsxs13(Layout.Content, { children: [
1791
+ /* @__PURE__ */ jsxs13(
1792
+ Box13,
1793
+ {
1794
+ flexDirection: "row",
1795
+ height: Math.max(1, middleSectionHeight - tooltip.height),
1796
+ overflow: "hidden",
1797
+ children: [
1798
+ /* @__PURE__ */ jsx14(Box13, { flexGrow: 1, flexDirection: "column", overflow: "hidden", children: /* @__PURE__ */ jsx14(
1799
+ MessageList,
1800
+ {
1801
+ messages,
1802
+ currentUsername: username,
1803
+ typingUsers,
1804
+ height: Math.max(1, middleSectionHeight - tooltip.height),
1805
+ scrollOffset,
1806
+ isDetached
1807
+ }
1808
+ ) }),
1809
+ showUserList && /* @__PURE__ */ jsx14(
1810
+ UserList,
1811
+ {
1812
+ users,
1813
+ currentUsername: username,
1814
+ height: Math.max(1, middleSectionHeight - tooltip.height - 2),
1815
+ isPrivateChannel
1816
+ }
1817
+ )
1818
+ ]
1819
+ }
1820
+ ),
1821
+ tooltip.show && tooltip.tips.length > 0 && /* @__PURE__ */ jsx14(ToolTip, { tips: tooltip.tips, type: tooltip.type }),
1822
+ /* @__PURE__ */ jsx14(
1823
+ InputBox,
1824
+ {
1825
+ onSend: handleSubmit,
1826
+ onTypingStart,
1827
+ onTypingStop,
1828
+ onInputChange: handleInputChange,
1829
+ disabled: isInputDisabled
1830
+ }
1831
+ ),
1832
+ /* @__PURE__ */ jsx14(
1833
+ StatusBar,
1834
+ {
1835
+ connectionStatus,
1836
+ error,
1837
+ userCount: users.length
1838
+ }
1839
+ )
1840
+ ] })
1841
+ ] });
1842
+ }
1843
+ var init_ChatView = __esm({
1844
+ "src/components/ChatView.tsx"() {
1845
+ "use strict";
1846
+ init_Header();
1847
+ init_Layout();
1848
+ init_MessageList();
1849
+ init_UserList();
1850
+ init_InputBox();
1851
+ init_StatusBar();
1852
+ init_ToolTip();
1853
+ init_use_command_input();
1854
+ }
1855
+ });
1856
+
1857
+ // src/components/CreateChannelScreen.tsx
1858
+ import { useState as useState7, useEffect as useEffect6 } from "react";
1859
+ import { Box as Box14, Text as Text13, useInput as useInput5, useStdout as useStdout4 } from "ink";
1860
+ import TextInput2 from "ink-text-input";
1861
+ import { jsx as jsx15, jsxs as jsxs14 } from "react/jsx-runtime";
1862
+ function CreateChannelScreen({
1863
+ width,
1864
+ height,
1865
+ username,
1866
+ connectionStatus,
1867
+ onLogout,
1868
+ onCreateChannel,
1869
+ topPadding = 0
1870
+ }) {
1871
+ const { stdout } = useStdout4();
1872
+ const { navigate } = useNavigation();
1873
+ const [name, setName] = useState7("");
1874
+ const [description, setDescription] = useState7("");
1875
+ const [activeField, setActiveField] = useState7("name");
1876
+ const [isSubmitting, setIsSubmitting] = useState7(false);
1877
+ const [error, setError] = useState7(null);
1878
+ useEffect6(() => {
1879
+ if (!stdout) return;
1880
+ stdout.write(`\x1B]0;Create Channel\x07`);
1881
+ }, [stdout]);
1882
+ useInput5((input, key) => {
1883
+ if (key.escape) {
1884
+ navigate("menu");
1885
+ return;
1886
+ }
1887
+ if (key.tab && !key.shift) {
1888
+ setActiveField((prev) => {
1889
+ if (prev === "name") return "description";
1890
+ if (prev === "description") return "submit";
1891
+ return "name";
1892
+ });
1893
+ return;
1894
+ }
1895
+ if (key.tab && key.shift) {
1896
+ setActiveField((prev) => {
1897
+ if (prev === "submit") return "description";
1898
+ if (prev === "description") return "name";
1899
+ return "submit";
1900
+ });
1901
+ return;
1902
+ }
1903
+ if (key.downArrow) {
1904
+ setActiveField((prev) => {
1905
+ if (prev === "name") return "description";
1906
+ if (prev === "description") return "submit";
1907
+ return prev;
1908
+ });
1909
+ return;
1910
+ }
1911
+ if (key.upArrow) {
1912
+ setActiveField((prev) => {
1913
+ if (prev === "submit") return "description";
1914
+ if (prev === "description") return "name";
1915
+ return prev;
1916
+ });
1917
+ return;
1918
+ }
1919
+ if (key.return && activeField === "submit" && !isSubmitting) {
1920
+ handleSubmit();
1921
+ }
1922
+ });
1923
+ const handleSubmit = async () => {
1924
+ if (!name.trim()) {
1925
+ setError("Channel name is required");
1926
+ return;
1927
+ }
1928
+ setError(null);
1929
+ setIsSubmitting(true);
1930
+ try {
1931
+ await onCreateChannel(name.trim(), description.trim());
1932
+ navigate("menu");
1933
+ } catch (err) {
1934
+ setError(err instanceof Error ? err.message : "Failed to create channel");
1935
+ setIsSubmitting(false);
1936
+ }
1937
+ };
1938
+ const headerHeight = 3;
1939
+ const contentHeight = height - topPadding - headerHeight;
1940
+ return /* @__PURE__ */ jsxs14(Layout, { width, height, topPadding, children: [
1941
+ /* @__PURE__ */ jsx15(Layout.Header, { children: /* @__PURE__ */ jsx15(
1942
+ Header,
1943
+ {
1944
+ username,
1945
+ roomName: "Create Channel",
1946
+ connectionStatus,
1947
+ onLogout,
1948
+ title: /* @__PURE__ */ jsx15(Text13, { bold: true, color: "cyan", children: "Create New Private Channel" }),
1949
+ showStatus: false
1950
+ }
1951
+ ) }),
1952
+ /* @__PURE__ */ jsx15(Layout.Content, { children: /* @__PURE__ */ jsxs14(Box14, { flexDirection: "column", height: contentHeight, padding: 2, children: [
1953
+ /* @__PURE__ */ jsxs14(Box14, { flexDirection: "column", marginBottom: 1, children: [
1954
+ /* @__PURE__ */ jsx15(Box14, { marginBottom: 0, children: /* @__PURE__ */ jsxs14(Text13, { bold: true, color: activeField === "name" ? "green" : "white", children: [
1955
+ "Channel Name ",
1956
+ activeField === "name" ? "(editing)" : ""
1957
+ ] }) }),
1958
+ /* @__PURE__ */ jsx15(
1959
+ Box14,
1960
+ {
1961
+ borderStyle: "single",
1962
+ borderColor: activeField === "name" ? "green" : "gray",
1963
+ paddingX: 1,
1964
+ children: activeField === "name" ? /* @__PURE__ */ jsx15(
1965
+ TextInput2,
1966
+ {
1967
+ value: name,
1968
+ onChange: setName,
1969
+ placeholder: "Enter channel name..."
1970
+ }
1971
+ ) : /* @__PURE__ */ jsx15(Text13, { color: name ? "white" : "gray", children: name || "Enter channel name..." })
1972
+ }
1973
+ )
1974
+ ] }),
1975
+ /* @__PURE__ */ jsxs14(Box14, { flexDirection: "column", marginBottom: 1, children: [
1976
+ /* @__PURE__ */ jsx15(Box14, { marginBottom: 0, children: /* @__PURE__ */ jsxs14(Text13, { bold: true, color: activeField === "description" ? "green" : "white", children: [
1977
+ "Description (optional) ",
1978
+ activeField === "description" ? "(editing)" : ""
1979
+ ] }) }),
1980
+ /* @__PURE__ */ jsx15(
1981
+ Box14,
1982
+ {
1983
+ borderStyle: "single",
1984
+ borderColor: activeField === "description" ? "green" : "gray",
1985
+ paddingX: 1,
1986
+ children: activeField === "description" ? /* @__PURE__ */ jsx15(
1987
+ TextInput2,
1988
+ {
1989
+ value: description,
1990
+ onChange: setDescription,
1991
+ placeholder: "Enter channel description..."
1992
+ }
1993
+ ) : /* @__PURE__ */ jsx15(Text13, { color: description ? "white" : "gray", children: description || "Enter channel description..." })
1994
+ }
1995
+ )
1996
+ ] }),
1997
+ /* @__PURE__ */ jsx15(Box14, { marginTop: 1, children: /* @__PURE__ */ jsxs14(
1998
+ Text13,
1999
+ {
2000
+ color: activeField === "submit" ? "green" : "white",
2001
+ bold: activeField === "submit",
2002
+ children: [
2003
+ activeField === "submit" ? "> " : " ",
2004
+ "[",
2005
+ isSubmitting ? "Creating..." : "Create Channel",
2006
+ "]"
2007
+ ]
2008
+ }
2009
+ ) }),
2010
+ error && /* @__PURE__ */ jsx15(Box14, { marginTop: 1, children: /* @__PURE__ */ jsx15(Text13, { color: "red", children: error }) }),
2011
+ /* @__PURE__ */ jsx15(Box14, { flexGrow: 1 }),
2012
+ /* @__PURE__ */ jsxs14(
2013
+ Box14,
2014
+ {
2015
+ flexDirection: "column",
2016
+ borderStyle: "single",
2017
+ borderColor: "gray",
2018
+ paddingX: 1,
2019
+ children: [
2020
+ /* @__PURE__ */ jsxs14(Text13, { color: "gray", children: [
2021
+ /* @__PURE__ */ jsx15(Text13, { color: "cyan", children: "Tab/Down" }),
2022
+ " Next field"
2023
+ ] }),
2024
+ /* @__PURE__ */ jsxs14(Text13, { color: "gray", children: [
2025
+ /* @__PURE__ */ jsx15(Text13, { color: "cyan", children: "Shift+Tab/Up" }),
2026
+ " Previous field"
2027
+ ] }),
2028
+ /* @__PURE__ */ jsxs14(Text13, { color: "gray", children: [
2029
+ /* @__PURE__ */ jsx15(Text13, { color: "cyan", children: "Enter" }),
2030
+ " Submit (when on button)"
2031
+ ] }),
2032
+ /* @__PURE__ */ jsxs14(Text13, { color: "gray", children: [
2033
+ /* @__PURE__ */ jsx15(Text13, { color: "cyan", children: "ESC" }),
2034
+ " Back to menu"
2035
+ ] })
2036
+ ]
2037
+ }
2038
+ )
2039
+ ] }) })
2040
+ ] });
2041
+ }
2042
+ var init_CreateChannelScreen = __esm({
2043
+ "src/components/CreateChannelScreen.tsx"() {
2044
+ "use strict";
2045
+ init_Header();
2046
+ init_Layout();
2047
+ init_Router();
2048
+ }
2049
+ });
2050
+
2051
+ // src/lib/channel-manager.ts
2052
+ import { Socket as Socket2 } from "phoenix";
2053
+ function extractTimestampFromUUIDv7(uuid) {
2054
+ const hex = uuid.replace(/-/g, "").slice(0, 12);
2055
+ const ms = parseInt(hex, 16);
2056
+ return new Date(ms).toISOString();
2057
+ }
2058
+ var MAX_REALTIME_MESSAGES_PER_CHANNEL, ChannelManager;
2059
+ var init_channel_manager = __esm({
2060
+ "src/lib/channel-manager.ts"() {
2061
+ "use strict";
2062
+ if (typeof globalThis.WebSocket === "undefined") {
2063
+ throw new Error(
2064
+ "WebSocket is not available. Load the ws polyfill before ChannelManager."
2065
+ );
2066
+ }
2067
+ MAX_REALTIME_MESSAGES_PER_CHANNEL = 100;
2068
+ ChannelManager = class {
2069
+ socket = null;
2070
+ channelStates = /* @__PURE__ */ new Map();
2071
+ callbacks;
2072
+ wsUrl;
2073
+ token;
2074
+ connectionStatus = "disconnected";
2075
+ currentActiveChannel = null;
2076
+ username = null;
2077
+ constructor(wsUrl, token, callbacks = {}) {
2078
+ this.wsUrl = wsUrl;
2079
+ this.token = token;
2080
+ this.callbacks = callbacks;
2081
+ }
2082
+ /**
2083
+ * Connect to the WebSocket and initialize the socket.
2084
+ * Does not subscribe to any channels yet - use subscribeToChannels() for that.
2085
+ */
2086
+ async connect() {
2087
+ this.setConnectionStatus("connecting");
2088
+ this.socket = new Socket2(this.wsUrl, {
2089
+ params: { token: this.token },
2090
+ reconnectAfterMs: (tries) => {
2091
+ return [1e3, 2e3, 5e3, 1e4][tries - 1] || 1e4;
2092
+ }
2093
+ });
2094
+ return new Promise((resolve, reject) => {
2095
+ if (!this.socket) {
2096
+ reject(new Error("Socket not initialized"));
2097
+ return;
2098
+ }
2099
+ this.socket.onOpen(() => {
2100
+ this.setConnectionStatus("connected");
2101
+ resolve();
2102
+ });
2103
+ this.socket.onError((error) => {
2104
+ this.setConnectionStatus("error");
2105
+ this.callbacks.onError?.("Connection error");
2106
+ reject(error);
2107
+ });
2108
+ this.socket.onClose(() => {
2109
+ this.setConnectionStatus("disconnected");
2110
+ });
2111
+ this.socket.connect();
2112
+ });
2113
+ }
2114
+ /**
2115
+ * Subscribe to multiple channels simultaneously.
2116
+ * Each channel will have its own ChannelState for tracking messages, presence, etc.
2117
+ */
2118
+ async subscribeToChannels(channels) {
2119
+ if (!this.socket) {
2120
+ throw new Error("Socket not connected. Call connect() first.");
2121
+ }
2122
+ const subscriptionPromises = channels.map(
2123
+ (channel) => this.subscribeToChannel(channel.slug)
2124
+ );
2125
+ const results = await Promise.allSettled(subscriptionPromises);
2126
+ results.forEach((result, index) => {
2127
+ if (result.status === "rejected") {
2128
+ const channelSlug = channels[index].slug;
2129
+ console.error(`Failed to subscribe to ${channelSlug}:`, result.reason);
2130
+ this.callbacks.onError?.(`Failed to join channel: ${channelSlug}`);
2131
+ }
2132
+ });
2133
+ }
2134
+ /**
2135
+ * Subscribe to a single channel and setup event handlers.
2136
+ */
2137
+ async subscribeToChannel(channelSlug) {
2138
+ if (!this.socket) {
2139
+ throw new Error("Socket not connected");
2140
+ }
2141
+ const channel = this.socket.channel(channelSlug, {});
2142
+ const channelState = {
2143
+ slug: channelSlug,
2144
+ channel,
2145
+ presence: {},
2146
+ typingUsers: /* @__PURE__ */ new Set(),
2147
+ realtimeMessages: [],
2148
+ subscribers: []
2149
+ };
2150
+ this.setupChannelHandlers(channel, channelSlug);
2151
+ return new Promise((resolve, reject) => {
2152
+ channel.join().receive("ok", (resp) => {
2153
+ const response = resp;
2154
+ if (response.username && !this.username) {
2155
+ this.username = response.username;
2156
+ }
2157
+ this.channelStates.set(channelSlug, channelState);
2158
+ this.callbacks.onChannelJoined?.(channelSlug, response.username || "");
2159
+ resolve();
2160
+ }).receive("error", (error) => {
2161
+ const errorMsg = `Failed to join channel: ${channelSlug}`;
2162
+ this.callbacks.onError?.(errorMsg);
2163
+ reject(error);
2164
+ }).receive("timeout", () => {
2165
+ const errorMsg = `Timeout joining channel: ${channelSlug}`;
2166
+ this.callbacks.onError?.(errorMsg);
2167
+ reject(new Error("timeout"));
2168
+ });
2169
+ });
2170
+ }
2171
+ /**
2172
+ * Setup event handlers for a specific channel.
2173
+ * Handlers route events to the correct channel state and callbacks.
2174
+ */
2175
+ setupChannelHandlers(channel, channelSlug) {
2176
+ channel.on("new_message", (payload) => {
2177
+ const msg = payload;
2178
+ const message = {
2179
+ ...msg,
2180
+ timestamp: extractTimestampFromUUIDv7(msg.id)
2181
+ };
2182
+ if (channelSlug === this.currentActiveChannel) {
2183
+ this.callbacks.onMessage?.(channelSlug, message);
2184
+ } else {
2185
+ const state = this.channelStates.get(channelSlug);
2186
+ if (state) {
2187
+ state.realtimeMessages.push(message);
2188
+ if (state.realtimeMessages.length > MAX_REALTIME_MESSAGES_PER_CHANNEL) {
2189
+ state.realtimeMessages.shift();
2190
+ }
2191
+ }
2192
+ }
2193
+ });
2194
+ channel.on("presence_state", (payload) => {
2195
+ const state = payload;
2196
+ const channelState = this.channelStates.get(channelSlug);
2197
+ if (channelState) {
2198
+ channelState.presence = state;
2199
+ }
2200
+ if (channelSlug === this.currentActiveChannel) {
2201
+ this.callbacks.onPresenceState?.(channelSlug, state);
2202
+ }
2203
+ });
2204
+ channel.on("presence_diff", (payload) => {
2205
+ const diff = payload;
2206
+ const channelState = this.channelStates.get(channelSlug);
2207
+ if (channelState) {
2208
+ const next = { ...channelState.presence };
2209
+ Object.keys(diff.leaves).forEach((username) => {
2210
+ delete next[username];
2211
+ });
2212
+ Object.entries(diff.joins).forEach(([username, data]) => {
2213
+ next[username] = data;
2214
+ });
2215
+ channelState.presence = next;
2216
+ }
2217
+ if (channelSlug === this.currentActiveChannel) {
2218
+ this.callbacks.onPresenceDiff?.(channelSlug, diff);
2219
+ }
2220
+ });
2221
+ channel.on("user_typing_start", (payload) => {
2222
+ const { username } = payload;
2223
+ const channelState = this.channelStates.get(channelSlug);
2224
+ if (channelState) {
2225
+ channelState.typingUsers.add(username);
2226
+ }
2227
+ if (channelSlug === this.currentActiveChannel) {
2228
+ this.callbacks.onUserTyping?.(channelSlug, username, true);
2229
+ }
2230
+ });
2231
+ channel.on("user_typing_stop", (payload) => {
2232
+ const { username } = payload;
2233
+ const channelState = this.channelStates.get(channelSlug);
2234
+ if (channelState) {
2235
+ channelState.typingUsers.delete(username);
2236
+ }
2237
+ if (channelSlug === this.currentActiveChannel) {
2238
+ this.callbacks.onUserTyping?.(channelSlug, username, false);
2239
+ }
2240
+ });
2241
+ channel.on("user_invited", (payload) => {
2242
+ const { user_id, username, role, invited_by } = payload;
2243
+ if (username === this.username) {
2244
+ this.callbacks.onInvitedToChannel?.(channelSlug, invited_by);
2245
+ } else {
2246
+ const channelState = this.channelStates.get(channelSlug);
2247
+ if (channelState) {
2248
+ const exists = channelState.subscribers.some((s) => s.user_id === user_id);
2249
+ if (!exists) {
2250
+ channelState.subscribers.push({ user_id, username, role });
2251
+ }
2252
+ }
2253
+ if (channelSlug === this.currentActiveChannel) {
2254
+ this.callbacks.onUserInvitedToChannel?.(channelSlug, username, user_id, invited_by);
2255
+ }
2256
+ }
2257
+ });
2258
+ channel.on("user_removed", (payload) => {
2259
+ const { user_id, username, removed_by } = payload;
2260
+ if (username === this.username) {
2261
+ channel.leave();
2262
+ this.channelStates.delete(channelSlug);
2263
+ this.callbacks.onRemovedFromChannel?.(channelSlug, removed_by);
2264
+ } else {
2265
+ const channelState = this.channelStates.get(channelSlug);
2266
+ if (channelState) {
2267
+ channelState.subscribers = channelState.subscribers.filter(
2268
+ (s) => s.user_id !== user_id
2269
+ );
2270
+ }
2271
+ if (channelSlug === this.currentActiveChannel) {
2272
+ this.callbacks.onUserRemovedFromChannel?.(channelSlug, username, removed_by);
2273
+ }
2274
+ }
2275
+ });
2276
+ }
2277
+ /**
2278
+ * Set the currently active channel.
2279
+ * This determines whether incoming messages are delivered immediately or buffered.
2280
+ */
2281
+ setActiveChannel(channelSlug) {
2282
+ this.currentActiveChannel = channelSlug;
2283
+ }
2284
+ /**
2285
+ * Fetch message history for a specific channel from the HTTP API.
2286
+ */
2287
+ async fetchHistory(channelSlug, limit = 50) {
2288
+ const backendUrl = this.wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:").replace(/\/socket$/, "");
2289
+ const encodedSlug = encodeURIComponent(channelSlug);
2290
+ const url = `${backendUrl}/api/messages/${encodedSlug}?limit=${limit}`;
2291
+ const response = await fetch(url, {
2292
+ headers: {
2293
+ Authorization: `Bearer ${this.token}`
2294
+ }
2295
+ });
2296
+ if (!response.ok) {
2297
+ throw new Error(`Failed to fetch message history: ${response.status}`);
2298
+ }
2299
+ const data = await response.json();
2300
+ return data.messages || [];
2301
+ }
2302
+ /**
2303
+ * Fetch and store subscriber list for a private channel.
2304
+ * Only applicable to private channels.
2305
+ */
2306
+ async fetchSubscribers(channelSlug) {
2307
+ if (!channelSlug.startsWith("private_room:")) {
2308
+ return [];
2309
+ }
2310
+ const backendUrl = this.wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:").replace(/\/socket$/, "");
2311
+ const encodedSlug = encodeURIComponent(channelSlug);
2312
+ const url = `${backendUrl}/api/channels/${encodedSlug}/subscribers`;
2313
+ const response = await fetch(url, {
2314
+ headers: {
2315
+ Authorization: `Bearer ${this.token}`
2316
+ }
2317
+ });
2318
+ if (!response.ok) {
2319
+ throw new Error(`Failed to fetch subscribers: ${response.status}`);
2320
+ }
2321
+ const data = await response.json();
2322
+ const subscribers = data.subscribers || [];
2323
+ const channelState = this.channelStates.get(channelSlug);
2324
+ if (channelState) {
2325
+ channelState.subscribers = subscribers;
2326
+ }
2327
+ return subscribers;
2328
+ }
2329
+ /**
2330
+ * Get subscriber list for a specific channel.
2331
+ */
2332
+ getSubscribers(channelSlug) {
2333
+ const channelState = this.channelStates.get(channelSlug);
2334
+ return channelState?.subscribers || [];
2335
+ }
2336
+ /**
2337
+ * Send a message to a specific channel.
2338
+ */
2339
+ async sendMessage(channelSlug, content) {
2340
+ const channelState = this.channelStates.get(channelSlug);
2341
+ if (!channelState) {
2342
+ throw new Error(`Not subscribed to channel: ${channelSlug}`);
2343
+ }
2344
+ if (!this.socket || this.connectionStatus !== "connected") {
2345
+ throw new Error("Connection lost");
2346
+ }
2347
+ const channel = channelState.channel;
2348
+ return new Promise((resolve, reject) => {
2349
+ channel.push("new_message", { content }).receive("ok", (resp) => {
2350
+ const response = resp;
2351
+ resolve(response);
2352
+ }).receive("error", (err) => {
2353
+ const error = err;
2354
+ const errorMsg = error.reason || "Failed to send message";
2355
+ this.callbacks.onError?.(errorMsg);
2356
+ reject(new Error(errorMsg));
2357
+ }).receive("timeout", () => {
2358
+ const errorMsg = "Message send timeout";
2359
+ this.callbacks.onError?.(errorMsg);
2360
+ reject(new Error("timeout"));
2361
+ });
2362
+ });
2363
+ }
2364
+ /**
2365
+ * Send a custom command event to a specific channel.
2366
+ */
2367
+ async sendCommand(channelSlug, eventType, data) {
2368
+ const channelState = this.channelStates.get(channelSlug);
2369
+ if (!channelState) {
2370
+ throw new Error(`Not subscribed to channel: ${channelSlug}`);
2371
+ }
2372
+ if (!this.socket || this.connectionStatus !== "connected") {
2373
+ throw new Error("Connection lost");
2374
+ }
2375
+ const channel = channelState.channel;
2376
+ return new Promise((resolve, reject) => {
2377
+ channel.push(eventType, data).receive("ok", (resp) => {
2378
+ const response = resp;
2379
+ resolve(response);
2380
+ }).receive("error", (err) => {
2381
+ const error = err;
2382
+ const errorMsg = error.reason || "Failed to send command";
2383
+ this.callbacks.onError?.(errorMsg);
2384
+ reject(new Error(errorMsg));
2385
+ }).receive("timeout", () => {
2386
+ const errorMsg = "Command send timeout";
2387
+ this.callbacks.onError?.(errorMsg);
2388
+ reject(new Error("timeout"));
2389
+ });
2390
+ });
2391
+ }
2392
+ /**
2393
+ * Send typing:start event to a specific channel.
2394
+ */
2395
+ startTyping(channelSlug) {
2396
+ if (this.connectionStatus !== "connected") return;
2397
+ const channelState = this.channelStates.get(channelSlug);
2398
+ if (!channelState) return;
2399
+ try {
2400
+ channelState.channel.push("typing:start", {});
2401
+ } catch {
2402
+ }
2403
+ }
2404
+ /**
2405
+ * Send typing:stop event to a specific channel.
2406
+ */
2407
+ stopTyping(channelSlug) {
2408
+ if (this.connectionStatus !== "connected") return;
2409
+ const channelState = this.channelStates.get(channelSlug);
2410
+ if (!channelState) return;
2411
+ try {
2412
+ channelState.channel.push("typing:stop", {});
2413
+ } catch {
2414
+ }
2415
+ }
2416
+ /**
2417
+ * Push an event to all subscribed channels.
2418
+ * Used for user-wide state updates like current_agent.
2419
+ */
2420
+ pushToAllChannels(eventType, payload) {
2421
+ if (this.connectionStatus !== "connected") return;
2422
+ this.channelStates.forEach((state) => {
2423
+ try {
2424
+ state.channel.push(eventType, payload);
2425
+ } catch {
2426
+ }
2427
+ });
2428
+ }
2429
+ /**
2430
+ * Get presence state for a specific channel.
2431
+ */
2432
+ getPresence(channelSlug) {
2433
+ const channelState = this.channelStates.get(channelSlug);
2434
+ return channelState?.presence || {};
2435
+ }
2436
+ /**
2437
+ * Get aggregated presence across all channels, deduplicated by user_id.
2438
+ * When a user appears in multiple channels, we prefer:
2439
+ * 1. Presence with current_agent set (if available)
2440
+ * 2. Most recent online_at timestamp
2441
+ */
2442
+ getAggregatedPresence() {
2443
+ const userMap = /* @__PURE__ */ new Map();
2444
+ this.channelStates.forEach((channelState) => {
2445
+ Object.entries(channelState.presence).forEach(([username, data]) => {
2446
+ const meta = data.metas[0];
2447
+ if (!meta) return;
2448
+ const userId = meta.user_id;
2449
+ const hasAgent = !!meta.current_agent;
2450
+ const onlineAt = meta.online_at;
2451
+ const existing = userMap.get(userId);
2452
+ if (!existing) {
2453
+ userMap.set(userId, {
2454
+ username,
2455
+ metas: data.metas,
2456
+ online_at: onlineAt,
2457
+ has_agent: hasAgent
2458
+ });
2459
+ } else {
2460
+ const shouldReplace = hasAgent && !existing.has_agent || hasAgent === existing.has_agent && onlineAt > existing.online_at;
2461
+ if (shouldReplace) {
2462
+ userMap.set(userId, {
2463
+ username,
2464
+ metas: data.metas,
2465
+ online_at: onlineAt,
2466
+ has_agent: hasAgent
2467
+ });
2468
+ }
2469
+ }
2470
+ });
2471
+ });
2472
+ const aggregated = {};
2473
+ userMap.forEach((data) => {
2474
+ aggregated[data.username] = {
2475
+ metas: data.metas
2476
+ };
2477
+ });
2478
+ return aggregated;
2479
+ }
2480
+ /**
2481
+ * Get buffered real-time messages for a specific channel.
2482
+ * These are messages that arrived while viewing other channels.
2483
+ */
2484
+ getRealtimeMessages(channelSlug) {
2485
+ const channelState = this.channelStates.get(channelSlug);
2486
+ return channelState?.realtimeMessages || [];
2487
+ }
2488
+ /**
2489
+ * Clear buffered real-time messages for a specific channel.
2490
+ * Called after merging with fetched history.
2491
+ */
2492
+ clearRealtimeMessages(channelSlug) {
2493
+ const channelState = this.channelStates.get(channelSlug);
2494
+ if (channelState) {
2495
+ channelState.realtimeMessages = [];
2496
+ }
2497
+ }
2498
+ /**
2499
+ * Get typing users for a specific channel.
2500
+ */
2501
+ getTypingUsers(channelSlug) {
2502
+ const channelState = this.channelStates.get(channelSlug);
2503
+ return channelState ? Array.from(channelState.typingUsers) : [];
2504
+ }
2505
+ /**
2506
+ * Get the current connection status.
2507
+ */
2508
+ getConnectionStatus() {
2509
+ return this.connectionStatus;
2510
+ }
2511
+ /**
2512
+ * Get the username (same across all channels).
2513
+ */
2514
+ getUsername() {
2515
+ return this.username;
2516
+ }
2517
+ /**
2518
+ * Check if connected.
2519
+ */
2520
+ isConnected() {
2521
+ return this.connectionStatus === "connected" && !!this.socket;
2522
+ }
2523
+ /**
2524
+ * Mark current channel as read via WebSocket.
2525
+ * Sends "mark_as_read" event to update last_seen to current seq_no.
2526
+ * Gracefully handles disconnected channels (returns silently during shutdown).
2527
+ */
2528
+ async markChannelAsRead(channelSlug) {
2529
+ const channelState = this.channelStates.get(channelSlug);
2530
+ if (!channelState || !channelState.channel) {
2531
+ return;
2532
+ }
2533
+ return new Promise((resolve, reject) => {
2534
+ channelState.channel.push("mark_as_read", {}).receive("ok", (response) => {
2535
+ console.log(`Marked ${channelSlug} as read`, response);
2536
+ resolve();
2537
+ }).receive("error", (err) => {
2538
+ console.error(`Failed to mark ${channelSlug} as read:`, err);
2539
+ reject(err);
2540
+ }).receive("timeout", () => {
2541
+ console.error(`Timeout marking ${channelSlug} as read`);
2542
+ reject(new Error("timeout"));
2543
+ });
2544
+ });
2545
+ }
2546
+ /**
2547
+ * Best-effort mark as read without waiting for an ack.
2548
+ * Useful during shutdown paths to avoid timeouts.
2549
+ */
2550
+ markChannelAsReadBestEffort(channelSlug) {
2551
+ const channelState = this.channelStates.get(channelSlug);
2552
+ if (!channelState || !channelState.channel) {
2553
+ return;
2554
+ }
2555
+ try {
2556
+ channelState.channel.push("mark_as_read", {});
2557
+ } catch {
2558
+ }
2559
+ }
2560
+ /**
2561
+ * Mark all messages in channel as read (used when first joining).
2562
+ * Gracefully handles disconnected channels (returns silently during shutdown).
2563
+ */
2564
+ async markAllMessagesAsRead(channelSlug) {
2565
+ const channelState = this.channelStates.get(channelSlug);
2566
+ if (!channelState || !channelState.channel) {
2567
+ return;
2568
+ }
2569
+ return new Promise((resolve, reject) => {
2570
+ channelState.channel.push("mark_all_read", {}).receive("ok", (response) => {
2571
+ console.log(`Marked all in ${channelSlug} as read`, response);
2572
+ resolve();
2573
+ }).receive("error", (err) => {
2574
+ console.error(`Failed to mark all as read in ${channelSlug}:`, err);
2575
+ reject(err);
2576
+ }).receive("timeout", () => {
2577
+ console.error(`Timeout marking all as read in ${channelSlug}`);
2578
+ reject(new Error("timeout"));
2579
+ });
2580
+ });
2581
+ }
2582
+ /**
2583
+ * Disconnect from all channels and close the socket.
2584
+ */
2585
+ disconnect() {
2586
+ this.channelStates.forEach((state) => {
2587
+ try {
2588
+ state.channel.leave();
2589
+ } catch {
2590
+ }
2591
+ });
2592
+ if (this.socket) {
2593
+ this.socket.disconnect();
2594
+ this.socket = null;
2595
+ }
2596
+ this.channelStates.clear();
2597
+ this.currentActiveChannel = null;
2598
+ this.username = null;
2599
+ this.setConnectionStatus("disconnected");
2600
+ }
2601
+ /**
2602
+ * Set connection status and notify callback.
2603
+ */
2604
+ setConnectionStatus(status) {
2605
+ this.connectionStatus = status;
2606
+ this.callbacks.onConnectionChange?.(status);
2607
+ }
2608
+ };
2609
+ }
2610
+ });
2611
+
2612
+ // src/hooks/use-multi-channel-chat.ts
2613
+ import { useState as useState8, useCallback as useCallback3, useRef as useRef3, useEffect as useEffect7 } from "react";
2614
+ function useMultiChannelChat(token, currentChannel, onChannelListChanged) {
2615
+ const [messages, setMessages] = useState8([]);
2616
+ const [connectionStatus, setConnectionStatus] = useState8("disconnected");
2617
+ const [username, setUsername] = useState8(null);
2618
+ const [error, setError] = useState8(null);
2619
+ const [typingUsers, setTypingUsers] = useState8([]);
2620
+ const [presenceState, setPresenceState] = useState8({});
2621
+ const [subscribers, setSubscribers] = useState8([]);
2622
+ const [channelsReady, setChannelsReady] = useState8(false);
2623
+ const managerRef = useRef3(null);
2624
+ const prevChannelRef = useRef3(null);
2625
+ const isLoadingHistory = useRef3(false);
2626
+ useEffect7(() => {
2627
+ if (!token) {
2628
+ if (managerRef.current) {
2629
+ managerRef.current.disconnect();
2630
+ managerRef.current = null;
2631
+ }
2632
+ setChannelsReady(false);
2633
+ return;
2634
+ }
2635
+ if (managerRef.current) {
2636
+ return;
2637
+ }
2638
+ const config = getConfig();
2639
+ const manager = new ChannelManager(
2640
+ config.wsUrl,
2641
+ token,
2642
+ {
2643
+ onMessage: (channelSlug, message) => {
2644
+ setMessages((prev) => [...prev, message]);
2645
+ },
2646
+ onPresenceState: (channelSlug, state) => {
2647
+ setPresenceState(state);
2648
+ },
2649
+ onPresenceDiff: (channelSlug, diff) => {
2650
+ setPresenceState((prev) => {
2651
+ const next = { ...prev };
2652
+ Object.keys(diff.leaves).forEach((username2) => {
2653
+ delete next[username2];
2654
+ });
2655
+ Object.entries(diff.joins).forEach(([username2, data]) => {
2656
+ next[username2] = data;
2657
+ });
2658
+ return next;
2659
+ });
2660
+ },
2661
+ onUserTyping: (channelSlug, username2, typing) => {
2662
+ setTypingUsers((prev) => {
2663
+ if (typing) {
2664
+ return prev.includes(username2) ? prev : [...prev, username2];
2665
+ } else {
2666
+ return prev.filter((u) => u !== username2);
2667
+ }
2668
+ });
2669
+ },
2670
+ onConnectionChange: (status) => {
2671
+ setConnectionStatus(status);
2672
+ if (status === "disconnected" || status === "error") {
2673
+ setError(null);
2674
+ }
2675
+ },
2676
+ onError: (err) => {
2677
+ setError(err);
2678
+ },
2679
+ onChannelJoined: (channelSlug, joinedUsername) => {
2680
+ if (!username) {
2681
+ setUsername(joinedUsername);
2682
+ }
2683
+ },
2684
+ onInvitedToChannel: (channelSlug, invitedBy) => {
2685
+ if (managerRef.current) {
2686
+ const authToken2 = token;
2687
+ async function joinNewChannel() {
2688
+ if (!authToken2 || !manager) return;
2689
+ try {
2690
+ const channelsResponse = await fetchChannels(config.wsUrl, authToken2);
2691
+ const allChannels = [
2692
+ ...channelsResponse.channels.public,
2693
+ ...channelsResponse.channels.private
2694
+ ];
2695
+ const newChannel = allChannels.find((ch) => ch.slug === channelSlug);
2696
+ if (newChannel) {
2697
+ await manager.subscribeToChannels([newChannel]);
2698
+ onChannelListChanged?.();
2699
+ }
2700
+ } catch (err) {
2701
+ console.error("Failed to join new channel:", err);
2702
+ }
2703
+ }
2704
+ joinNewChannel();
2705
+ }
2706
+ },
2707
+ onUserInvitedToChannel: (channelSlug, invitedUsername, invitedUserId, invitedBy) => {
2708
+ setSubscribers((prev) => {
2709
+ const exists = prev.some((s) => s.user_id === invitedUserId);
2710
+ if (!exists) {
2711
+ return [...prev, { username: invitedUsername, user_id: invitedUserId, role: "member" }];
2712
+ }
2713
+ return prev;
2714
+ });
2715
+ },
2716
+ onRemovedFromChannel: (channelSlug, removedBy) => {
2717
+ setError(`You were removed from ${channelSlug} by ${removedBy}`);
2718
+ },
2719
+ onUserRemovedFromChannel: (channelSlug, removedUsername, removedBy) => {
2720
+ setSubscribers((prev) => prev.filter((s) => s.username !== removedUsername));
2721
+ }
2722
+ }
2723
+ );
2724
+ managerRef.current = manager;
2725
+ const authToken = token;
2726
+ async function init() {
2727
+ if (!authToken) {
2728
+ return;
2729
+ }
2730
+ try {
2731
+ await manager.connect();
2732
+ const channelsResponse = await fetchChannels(config.wsUrl, authToken);
2733
+ const allChannels = [
2734
+ ...channelsResponse.channels.public,
2735
+ ...channelsResponse.channels.private
2736
+ ];
2737
+ await manager.subscribeToChannels(allChannels);
2738
+ setChannelsReady(true);
2739
+ setError(null);
2740
+ } catch (err) {
2741
+ setError(err instanceof Error ? err.message : "Connection failed");
2742
+ console.error("Failed to initialize multi-channel chat:", err);
2743
+ }
2744
+ }
2745
+ init();
2746
+ return () => {
2747
+ if (managerRef.current) {
2748
+ managerRef.current.disconnect();
2749
+ managerRef.current = null;
2750
+ }
2751
+ setChannelsReady(false);
2752
+ };
2753
+ }, [token]);
2754
+ useEffect7(() => {
2755
+ const manager = managerRef.current;
2756
+ if (!manager || !manager.isConnected() || !currentChannel) {
2757
+ return;
2758
+ }
2759
+ if (prevChannelRef.current && prevChannelRef.current !== currentChannel) {
2760
+ manager.stopTyping(prevChannelRef.current);
2761
+ }
2762
+ prevChannelRef.current = currentChannel;
2763
+ manager.setActiveChannel(currentChannel);
2764
+ async function loadHistory() {
2765
+ if (isLoadingHistory.current || !manager) return;
2766
+ isLoadingHistory.current = true;
2767
+ try {
2768
+ const history = await manager.fetchHistory(currentChannel);
2769
+ if (currentChannel.startsWith("private_room:")) {
2770
+ const subs = await manager.fetchSubscribers(currentChannel);
2771
+ setSubscribers(subs);
2772
+ } else {
2773
+ setSubscribers([]);
2774
+ }
2775
+ const realtimeMessages = manager.getRealtimeMessages(currentChannel);
2776
+ const merged = [...history, ...realtimeMessages];
2777
+ const seen = /* @__PURE__ */ new Set();
2778
+ const deduplicated = merged.filter((msg) => {
2779
+ if (seen.has(msg.id)) return false;
2780
+ seen.add(msg.id);
2781
+ return true;
2782
+ });
2783
+ deduplicated.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
2784
+ setMessages(deduplicated);
2785
+ manager.clearRealtimeMessages(currentChannel);
2786
+ const presence = manager.getPresence(currentChannel);
2787
+ setPresenceState(presence);
2788
+ const typing = manager.getTypingUsers(currentChannel);
2789
+ setTypingUsers(typing);
2790
+ setError(null);
2791
+ } catch (err) {
2792
+ setError(err instanceof Error ? err.message : "Failed to load history");
2793
+ console.error("Failed to load message history:", err);
2794
+ } finally {
2795
+ isLoadingHistory.current = false;
2796
+ }
2797
+ }
2798
+ loadHistory();
2799
+ }, [currentChannel, connectionStatus]);
2800
+ const sendMessage = useCallback3(
2801
+ async (content) => {
2802
+ if (!managerRef.current) {
2803
+ throw new Error("Not connected");
2804
+ }
2805
+ await managerRef.current.sendMessage(currentChannel, content);
2806
+ },
2807
+ [currentChannel]
2808
+ );
2809
+ const startTyping = useCallback3(() => {
2810
+ managerRef.current?.startTyping(currentChannel);
2811
+ }, [currentChannel]);
2812
+ const stopTyping = useCallback3(() => {
2813
+ managerRef.current?.stopTyping(currentChannel);
2814
+ }, [currentChannel]);
2815
+ const connect = useCallback3(() => {
2816
+ }, []);
2817
+ const disconnect = useCallback3(() => {
2818
+ }, []);
2819
+ return {
2820
+ messages,
2821
+ connectionStatus,
2822
+ username,
2823
+ error,
2824
+ sendMessage,
2825
+ startTyping,
2826
+ stopTyping,
2827
+ typingUsers,
2828
+ presenceState,
2829
+ subscribers,
2830
+ connect,
2831
+ // No-op for backward compatibility
2832
+ disconnect,
2833
+ // No-op for backward compatibility
2834
+ channelManager: channelsReady ? managerRef.current : null
2835
+ };
2836
+ }
2837
+ var init_use_multi_channel_chat = __esm({
2838
+ "src/hooks/use-multi-channel-chat.ts"() {
2839
+ "use strict";
2840
+ init_channel_manager();
2841
+ init_chat_client();
2842
+ init_config();
2843
+ }
2844
+ });
2845
+
2846
+ // src/hooks/use-presence.ts
2847
+ import { useMemo as useMemo5 } from "react";
2848
+ function presenceToUsers(presence) {
2849
+ return Object.entries(presence).map(([username, data]) => ({
2850
+ username,
2851
+ user_id: data.metas[0]?.user_id ?? 0,
2852
+ online_at: data.metas[0]?.online_at || "",
2853
+ currentAgent: data.metas[0]?.current_agent ?? null
2854
+ }));
2855
+ }
2856
+ function mergeSubscribersWithPresence(subscribers, presence, isPrivateChannel) {
2857
+ if (!isPrivateChannel) {
2858
+ const onlineUsers = presenceToUsers(presence);
2859
+ return onlineUsers.map((user) => ({
2860
+ ...user,
2861
+ isOnline: true,
2862
+ currentAgent: user.currentAgent
2863
+ }));
2864
+ }
2865
+ const onlineUsernames = new Set(Object.keys(presence));
2866
+ return subscribers.map((subscriber) => {
2867
+ const isOnline = onlineUsernames.has(subscriber.username);
2868
+ return {
2869
+ username: subscriber.username,
2870
+ user_id: subscriber.user_id,
2871
+ online_at: isOnline ? presence[subscriber.username].metas[0]?.online_at || "" : "",
2872
+ isOnline,
2873
+ role: subscriber.role,
2874
+ currentAgent: isOnline ? presence[subscriber.username].metas[0]?.current_agent ?? null : null
2875
+ };
2876
+ });
2877
+ }
2878
+ function usePresence(presenceState, subscribers = [], currentChannel = "") {
2879
+ const isPrivateChannel = currentChannel.startsWith("private_room:");
2880
+ const users = useMemo5(
2881
+ () => mergeSubscribersWithPresence(subscribers, presenceState, isPrivateChannel),
2882
+ [presenceState, subscribers, isPrivateChannel]
2883
+ );
2884
+ return { users };
2885
+ }
2886
+ var init_use_presence = __esm({
2887
+ "src/hooks/use-presence.ts"() {
2888
+ "use strict";
2889
+ }
2890
+ });
2891
+
2892
+ // src/hooks/use-agent-detection.ts
2893
+ import { useEffect as useEffect8, useRef as useRef4, useCallback as useCallback4 } from "react";
2894
+ import { execSync as execSync2 } from "child_process";
2895
+ function detectCurrentAgent() {
2896
+ try {
2897
+ const result = execSync2(`ps -p $(pgrep -x -n 'codex|claude|Cursor|Windsurf\\ Helper') -o comm=`, {
2898
+ stdio: "pipe",
2899
+ encoding: "utf-8"
2900
+ }).trim();
2901
+ if (result.includes("@openai/codex")) return "codex";
2902
+ if (result === "claude") return "claude";
2903
+ if (result.includes("Cursor.app")) return "cursor";
2904
+ if (result.includes("Windsurf.app")) return "windsurf";
2905
+ return null;
2906
+ } catch {
2907
+ return null;
2908
+ }
2909
+ }
2910
+ function useAgentDetection(channelManager, isConnected) {
2911
+ const lastSentAgentRef = useRef4(void 0);
2912
+ const broadcastAgentUpdate = useCallback4(
2913
+ (agent) => {
2914
+ if (!channelManager) return;
2915
+ channelManager.pushToAllChannels("update_current_agent", {
2916
+ current_agent: agent
2917
+ });
2918
+ },
2919
+ [channelManager]
2920
+ );
2921
+ useEffect8(() => {
2922
+ if (!channelManager || !isConnected) {
2923
+ lastSentAgentRef.current = void 0;
2924
+ return;
2925
+ }
2926
+ const initialAgent = detectCurrentAgent();
2927
+ if (lastSentAgentRef.current === void 0 || lastSentAgentRef.current !== initialAgent) {
2928
+ lastSentAgentRef.current = initialAgent;
2929
+ broadcastAgentUpdate(initialAgent);
2930
+ }
2931
+ const intervalId = setInterval(() => {
2932
+ const currentAgent = detectCurrentAgent();
2933
+ if (currentAgent !== lastSentAgentRef.current) {
2934
+ lastSentAgentRef.current = currentAgent;
2935
+ broadcastAgentUpdate(currentAgent);
2936
+ }
2937
+ }, POLL_INTERVAL_MS);
2938
+ return () => clearInterval(intervalId);
2939
+ }, [channelManager, isConnected, broadcastAgentUpdate]);
2940
+ }
2941
+ var POLL_INTERVAL_MS;
2942
+ var init_use_agent_detection = __esm({
2943
+ "src/hooks/use-agent-detection.ts"() {
2944
+ "use strict";
2945
+ POLL_INTERVAL_MS = 2e3;
2946
+ }
2947
+ });
2948
+
2949
+ // src/hooks/use-channels.ts
2950
+ import { useState as useState9, useCallback as useCallback5, useEffect as useEffect9 } from "react";
2951
+ function useChannels(token) {
2952
+ const [publicChannels, setPublicChannels] = useState9([]);
2953
+ const [privateChannels, setPrivateChannels] = useState9([]);
2954
+ const [unreadCounts, setUnreadCounts] = useState9({});
2955
+ const [loading, setLoading] = useState9(false);
2956
+ const [error, setError] = useState9(null);
2957
+ const fetchData = useCallback5(async () => {
2958
+ if (!token) return;
2959
+ setLoading(true);
2960
+ setError(null);
2961
+ try {
2962
+ const config = getConfig();
2963
+ const [channelsData, unreadData] = await Promise.all([
2964
+ fetchChannels(config.wsUrl, token),
2965
+ fetchUnreadCounts(config.wsUrl, token)
2966
+ ]);
2967
+ setPublicChannels(channelsData.channels.public);
2968
+ setPrivateChannels(channelsData.channels.private);
2969
+ setUnreadCounts(unreadData);
2970
+ } catch (err) {
2971
+ setError(err instanceof Error ? err.message : "Failed to fetch data");
2972
+ } finally {
2973
+ setLoading(false);
2974
+ }
2975
+ }, [token]);
2976
+ const refetchUnreadCounts = useCallback5(async () => {
2977
+ if (!token) return;
2978
+ try {
2979
+ const config = getConfig();
2980
+ const unreadData = await fetchUnreadCounts(config.wsUrl, token);
2981
+ setUnreadCounts(unreadData);
2982
+ } catch (err) {
2983
+ console.error("Failed to refetch unread counts:", err);
2984
+ }
2985
+ }, [token]);
2986
+ useEffect9(() => {
2987
+ if (token) {
2988
+ fetchData();
2989
+ }
2990
+ }, [token, fetchData]);
2991
+ return {
2992
+ publicChannels,
2993
+ privateChannels,
2994
+ unreadCounts,
2995
+ loading,
2996
+ error,
2997
+ refetch: fetchData,
2998
+ refetchUnreadCounts
2999
+ };
3000
+ }
3001
+ var init_use_channels = __esm({
3002
+ "src/hooks/use-channels.ts"() {
3003
+ "use strict";
3004
+ init_chat_client();
3005
+ init_config();
3006
+ }
3007
+ });
3008
+
3009
+ // src/components/App.tsx
3010
+ var App_exports = {};
3011
+ __export(App_exports, {
3012
+ App: () => App
3013
+ });
3014
+ import { useState as useState10, useEffect as useEffect10, useCallback as useCallback6, useRef as useRef5 } from "react";
3015
+ import { Box as Box15, useApp as useApp2, useInput as useInput6, useStdout as useStdout5 } from "ink";
3016
+ import { jsx as jsx16 } from "react/jsx-runtime";
3017
+ function App() {
3018
+ return /* @__PURE__ */ jsx16(Router, { initialRoute: "menu", children: /* @__PURE__ */ jsx16(AppContent, {}) });
3019
+ }
3020
+ function AppContent() {
3021
+ const { exit } = useApp2();
3022
+ const { stdout } = useStdout5();
3023
+ const { route, navigate } = useNavigation();
3024
+ const isWarp = process.env.TERM_PROGRAM === "WarpTerminal";
3025
+ const topPadding = isWarp ? 1 : 0;
3026
+ const [authState, setAuthState] = useState10("unauthenticated");
3027
+ const [authStatus, setAuthStatus] = useState10("");
3028
+ const [token, setToken] = useState10(null);
3029
+ const [terminalSize, setTerminalSize] = useState10({
3030
+ rows: stdout?.rows || 24,
3031
+ columns: stdout?.columns || 80
3032
+ });
3033
+ const [scrollOffset, setScrollOffset] = useState10(0);
3034
+ const [isScrollDetached, setIsScrollDetached] = useState10(false);
3035
+ const [showUserList, setShowUserList] = useState10(true);
3036
+ const [currentChannel, setCurrentChannel] = useState10("chat_room:global");
3037
+ const prevAuthStateRef = useRef5(null);
3038
+ useEffect10(() => {
3039
+ if (!stdout) return;
3040
+ const handleResize = () => {
3041
+ setTerminalSize({
3042
+ rows: stdout.rows || 24,
3043
+ columns: stdout.columns || 80
3044
+ });
3045
+ };
3046
+ stdout.on("resize", handleResize);
3047
+ return () => {
3048
+ stdout.off("resize", handleResize);
3049
+ };
3050
+ }, [stdout]);
3051
+ useEffect10(() => {
3052
+ if (!stdout) return;
3053
+ if (prevAuthStateRef.current === "authenticated" && authState !== "authenticated") {
3054
+ stdout.write("\x1B[2J\x1B[0f");
3055
+ }
3056
+ prevAuthStateRef.current = authState;
3057
+ }, [authState, stdout]);
3058
+ useEffect10(() => {
3059
+ async function checkAuth() {
3060
+ const authenticated = await isAuthenticated();
3061
+ if (authenticated) {
3062
+ const stored = await getCurrentToken();
3063
+ if (stored) {
3064
+ setToken(stored.token);
3065
+ setAuthState("authenticated");
3066
+ }
3067
+ }
3068
+ }
3069
+ checkAuth();
3070
+ }, []);
3071
+ const { publicChannels, privateChannels, unreadCounts, refetchUnreadCounts, refetch: refetchChannels } = useChannels(token);
3072
+ const {
3073
+ messages,
3074
+ connectionStatus,
3075
+ username,
3076
+ error,
3077
+ sendMessage,
3078
+ startTyping,
3079
+ stopTyping,
3080
+ typingUsers,
3081
+ presenceState,
3082
+ subscribers,
3083
+ connect,
3084
+ disconnect,
3085
+ channelManager
3086
+ } = useMultiChannelChat(token, currentChannel, refetchChannels);
3087
+ const { users } = usePresence(presenceState, subscribers, currentChannel);
3088
+ useAgentDetection(channelManager, connectionStatus === "connected");
3089
+ const sendCommand = useCallback6(
3090
+ async (eventType, data) => {
3091
+ if (!channelManager) {
3092
+ throw new Error("Not connected");
3093
+ }
3094
+ await channelManager.sendCommand(currentChannel, eventType, data);
3095
+ },
3096
+ [currentChannel, channelManager]
3097
+ );
3098
+ const allChannels = [...publicChannels, ...privateChannels];
3099
+ const currentChannelDetails = allChannels.find((ch) => ch.slug === currentChannel);
3100
+ const isPrivateChannel = currentChannel.startsWith("private_room:");
3101
+ const prevChannelForMarkAsReadRef = useRef5(null);
3102
+ const markedAsReadOnEntryRef = useRef5(/* @__PURE__ */ new Set());
3103
+ useEffect10(() => {
3104
+ const markChannelAsRead = async (channelSlug, isEntry) => {
3105
+ if (!channelManager) {
3106
+ return;
3107
+ }
3108
+ try {
3109
+ if (isEntry) {
3110
+ if (!markedAsReadOnEntryRef.current.has(channelSlug)) {
3111
+ await channelManager.markAllMessagesAsRead(channelSlug);
3112
+ markedAsReadOnEntryRef.current.add(channelSlug);
3113
+ } else {
3114
+ await channelManager.markChannelAsRead(channelSlug);
3115
+ }
3116
+ } else {
3117
+ await channelManager.markChannelAsRead(channelSlug);
3118
+ }
3119
+ await refetchUnreadCounts();
3120
+ } catch (err) {
3121
+ console.error(`Failed to mark ${channelSlug} as read:`, err);
3122
+ }
3123
+ };
3124
+ if (currentChannel !== prevChannelForMarkAsReadRef.current) {
3125
+ if (prevChannelForMarkAsReadRef.current) {
3126
+ markChannelAsRead(prevChannelForMarkAsReadRef.current, false);
3127
+ }
3128
+ if (currentChannel) {
3129
+ markChannelAsRead(currentChannel, true);
3130
+ }
3131
+ prevChannelForMarkAsReadRef.current = currentChannel;
3132
+ }
3133
+ }, [currentChannel, channelManager, refetchUnreadCounts]);
3134
+ useEffect10(() => {
3135
+ return () => {
3136
+ if (currentChannel && channelManager) {
3137
+ channelManager.markChannelAsReadBestEffort(currentChannel);
3138
+ }
3139
+ };
3140
+ }, [currentChannel, channelManager]);
3141
+ useEffect10(() => {
3142
+ if (route === "menu") {
3143
+ refetchUnreadCounts();
3144
+ }
3145
+ }, [route, refetchUnreadCounts]);
3146
+ const handleLogin = useCallback6(async () => {
3147
+ setAuthState("authenticating");
3148
+ setAuthStatus("Starting login...");
3149
+ const result = await login((status) => setAuthStatus(status));
3150
+ if (result.success) {
3151
+ const stored = await getCurrentToken();
3152
+ if (stored) {
3153
+ setToken(stored.token);
3154
+ setAuthState("authenticated");
3155
+ setAuthStatus("");
3156
+ }
3157
+ } else {
3158
+ setAuthState("unauthenticated");
3159
+ setAuthStatus(result.error || "Login failed");
3160
+ }
3161
+ }, []);
3162
+ const handleLogout = useCallback6(async () => {
3163
+ if (currentChannel && channelManager) {
3164
+ channelManager.markChannelAsReadBestEffort(currentChannel);
3165
+ }
3166
+ disconnect();
3167
+ setToken(null);
3168
+ setAuthState("unauthenticated");
3169
+ setAuthStatus("");
3170
+ try {
3171
+ await logout();
3172
+ } catch {
3173
+ setAuthStatus("Logged out locally; failed to clear credentials.");
3174
+ }
3175
+ }, [disconnect, currentChannel, channelManager]);
3176
+ const handleCreateChannel = useCallback6(async (name, description) => {
3177
+ if (!token) {
3178
+ throw new Error("Not authenticated");
3179
+ }
3180
+ const config = getConfig();
3181
+ await createChannel(config.wsUrl, token, name, description || void 0);
3182
+ await refetchChannels();
3183
+ }, [token, refetchChannels]);
3184
+ const headerHeight = 3;
3185
+ const inputBoxHeight = 4;
3186
+ const statusBarHeight = 1;
3187
+ const middleSectionHeight = Math.max(
3188
+ 5,
3189
+ terminalSize.rows - topPadding - headerHeight - inputBoxHeight - statusBarHeight
3190
+ );
3191
+ const linesPerMessage = 2;
3192
+ const maxVisibleMessages = Math.floor(middleSectionHeight / linesPerMessage);
3193
+ useInput6((input, key) => {
3194
+ if (input === "c" && key.ctrl) {
3195
+ if (currentChannel && channelManager) {
3196
+ channelManager.markChannelAsReadBestEffort(currentChannel);
3197
+ }
3198
+ disconnect();
3199
+ exit();
3200
+ }
3201
+ if (input === "o" && key.ctrl && authState === "authenticated") {
3202
+ handleLogout();
3203
+ }
3204
+ if (input === "e" && key.ctrl && authState === "authenticated") {
3205
+ setShowUserList((prev) => !prev);
3206
+ }
3207
+ if (input === "q" && key.ctrl && authState === "authenticated" && route === "chat") {
3208
+ navigate("menu");
3209
+ }
3210
+ if (authState === "authenticated") {
3211
+ const maxOffset = Math.max(0, messages.length - maxVisibleMessages);
3212
+ if (key.upArrow) {
3213
+ setScrollOffset((prev) => {
3214
+ const newOffset = Math.min(prev + 1, maxOffset);
3215
+ if (newOffset > 0) {
3216
+ setIsScrollDetached(true);
3217
+ }
3218
+ return newOffset;
3219
+ });
3220
+ }
3221
+ if (key.downArrow) {
3222
+ setScrollOffset((prev) => {
3223
+ const newOffset = Math.max(prev - 1, 0);
3224
+ if (newOffset === 0) {
3225
+ setIsScrollDetached(false);
3226
+ }
3227
+ return newOffset;
3228
+ });
3229
+ }
3230
+ }
3231
+ });
3232
+ if (authState !== "authenticated") {
3233
+ return /* @__PURE__ */ jsx16(
3234
+ Box15,
3235
+ {
3236
+ flexDirection: "column",
3237
+ width: terminalSize.columns,
3238
+ height: terminalSize.rows,
3239
+ overflow: "hidden",
3240
+ children: /* @__PURE__ */ jsx16(
3241
+ LoginScreen,
3242
+ {
3243
+ onLogin: handleLogin,
3244
+ status: authStatus,
3245
+ isLoading: authState === "authenticating"
3246
+ }
3247
+ )
3248
+ }
3249
+ );
3250
+ }
3251
+ if (route === "menu") {
3252
+ const aggregatedPresence = channelManager?.getAggregatedPresence() || {};
3253
+ return /* @__PURE__ */ jsx16(
3254
+ Box15,
3255
+ {
3256
+ flexDirection: "column",
3257
+ width: terminalSize.columns,
3258
+ height: terminalSize.rows,
3259
+ overflow: "hidden",
3260
+ children: /* @__PURE__ */ jsx16(
3261
+ Menu,
3262
+ {
3263
+ width: terminalSize.columns,
3264
+ height: terminalSize.rows,
3265
+ currentChannel,
3266
+ onChannelSelect: setCurrentChannel,
3267
+ username,
3268
+ connectionStatus,
3269
+ onLogout: handleLogout,
3270
+ topPadding,
3271
+ publicChannels,
3272
+ privateChannels,
3273
+ unreadCounts,
3274
+ aggregatedPresence
3275
+ }
3276
+ )
3277
+ }
3278
+ );
3279
+ }
3280
+ if (route === "create-channel") {
3281
+ return /* @__PURE__ */ jsx16(
3282
+ Box15,
3283
+ {
3284
+ flexDirection: "column",
3285
+ width: terminalSize.columns,
3286
+ height: terminalSize.rows,
3287
+ overflow: "hidden",
3288
+ children: /* @__PURE__ */ jsx16(
3289
+ CreateChannelScreen,
3290
+ {
3291
+ width: terminalSize.columns,
3292
+ height: terminalSize.rows,
3293
+ username,
3294
+ connectionStatus,
3295
+ onLogout: handleLogout,
3296
+ onCreateChannel: handleCreateChannel,
3297
+ topPadding
3298
+ }
3299
+ )
3300
+ }
3301
+ );
3302
+ }
3303
+ return /* @__PURE__ */ jsx16(
3304
+ ChatView,
3305
+ {
3306
+ terminalSize,
3307
+ currentChannel,
3308
+ channelName: currentChannelDetails?.name,
3309
+ channelDescription: currentChannelDetails?.description || void 0,
3310
+ connectionStatus,
3311
+ username,
3312
+ onLogout: handleLogout,
3313
+ messages,
3314
+ typingUsers,
3315
+ middleSectionHeight,
3316
+ scrollOffset,
3317
+ isDetached: isScrollDetached,
3318
+ showUserList,
3319
+ users,
3320
+ subscribers,
3321
+ isPrivateChannel,
3322
+ topPadding,
3323
+ onSend: sendMessage,
3324
+ onTypingStart: startTyping,
3325
+ onTypingStop: stopTyping,
3326
+ onCommandSend: sendCommand,
3327
+ error,
3328
+ token
3329
+ }
3330
+ );
3331
+ }
3332
+ var init_App = __esm({
3333
+ "src/components/App.tsx"() {
3334
+ "use strict";
3335
+ init_LoginScreen();
3336
+ init_Menu();
3337
+ init_ChatView();
3338
+ init_CreateChannelScreen();
3339
+ init_use_multi_channel_chat();
3340
+ init_use_presence();
3341
+ init_use_agent_detection();
3342
+ init_use_channels();
3343
+ init_auth_manager();
3344
+ init_Router();
3345
+ init_chat_client();
3346
+ init_config();
3347
+ }
3348
+ });
3
3349
 
4
3350
  // src/index.ts
3351
+ init_auth_manager();
5
3352
  import WebSocket from "ws";
6
3353
  import { program } from "commander";
7
3354
  import { render } from "ink";
8
- import React2 from "react";
3355
+ import React11 from "react";
9
3356
 
10
3357
  // src/lib/update-checker.ts
11
3358
  import { createRequire } from "module";
@@ -164,7 +3511,7 @@ function UpdatePrompt({ updateInfo, onComplete }) {
164
3511
  // package.json
165
3512
  var package_default = {
166
3513
  name: "groupchat",
167
- version: "0.0.6",
3514
+ version: "0.0.8",
168
3515
  description: "CLI chat client for Groupchat",
169
3516
  type: "module",
170
3517
  main: "./dist/index.js",
@@ -183,9 +3530,9 @@ var package_default = {
183
3530
  "README.md"
184
3531
  ],
185
3532
  scripts: {
186
- build: "tsup src/index.ts --format esm --dts --clean",
187
- dev: "tsup src/index.ts --format esm --watch",
188
- "dev:bun": "bunx tsup src/index.ts --format esm --watch",
3533
+ build: "tsup src/index.ts --format esm --dts --clean --no-splitting",
3534
+ dev: "tsup src/index.ts --format esm --watch --no-splitting",
3535
+ "dev:bun": "bunx tsup src/index.ts --format esm --watch --no-splitting",
189
3536
  start: "NODE_ENV=development node dist/index.js",
190
3537
  "start:bun": "NODE_ENV=development bun dist/index.js",
191
3538
  typecheck: "tsc --noEmit",
@@ -230,7 +3577,7 @@ if (typeof globalThis.WebSocket === "undefined") {
230
3577
  async function showUpdatePrompt(updateInfo) {
231
3578
  return new Promise((resolve) => {
232
3579
  const { unmount, waitUntilExit } = render(
233
- React2.createElement(UpdatePrompt, {
3580
+ React11.createElement(UpdatePrompt, {
234
3581
  updateInfo,
235
3582
  onComplete: () => {
236
3583
  unmount();
@@ -251,8 +3598,8 @@ async function startChat() {
251
3598
  await showUpdatePrompt(updateInfo);
252
3599
  }
253
3600
  process.stdout.write("\x1B[2J\x1B[0f");
254
- const { App } = await import("./App-BVDV6OLB.js");
255
- const { waitUntilExit } = render(React2.createElement(App), {
3601
+ const { App: App2 } = await Promise.resolve().then(() => (init_App(), App_exports));
3602
+ const { waitUntilExit } = render(React11.createElement(App2), {
256
3603
  exitOnCtrlC: false
257
3604
  // We handle Ctrl+C manually
258
3605
  });