ciphermesh 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +251 -0
  3. package/README.pt-BR.md +253 -0
  4. package/bin/ciphermesh.js +34 -0
  5. package/docs/ARCHITECTURE.md +1188 -0
  6. package/docs/SETUP.md +305 -0
  7. package/docs/demo.svg +46 -0
  8. package/package.json +87 -0
  9. package/src/client/ChatController.js +2476 -0
  10. package/src/client/Connection.js +129 -0
  11. package/src/client/FileTransfer.js +488 -0
  12. package/src/client/ImagePreview.js +88 -0
  13. package/src/client/UI.js +1830 -0
  14. package/src/client/index.js +231 -0
  15. package/src/crypto/CertPinStore.js +79 -0
  16. package/src/crypto/DeniableEncrypt.js +53 -0
  17. package/src/crypto/DoubleRatchet.js +574 -0
  18. package/src/crypto/Handshake.js +219 -0
  19. package/src/crypto/HistoryStore.js +241 -0
  20. package/src/crypto/IdentityBackup.js +70 -0
  21. package/src/crypto/KeyManager.js +134 -0
  22. package/src/crypto/MessageCrypto.js +181 -0
  23. package/src/crypto/NonceManager.js +72 -0
  24. package/src/crypto/SealedSender.js +58 -0
  25. package/src/crypto/SenderKey.js +204 -0
  26. package/src/crypto/StateManager.js +138 -0
  27. package/src/crypto/TrustStore.js +216 -0
  28. package/src/p2p/Discovery.js +80 -0
  29. package/src/p2p/P2PChatController.js +1856 -0
  30. package/src/p2p/PeerConnectionManager.js +252 -0
  31. package/src/p2p/PeerServer.js +68 -0
  32. package/src/p2p/index.js +219 -0
  33. package/src/protocol/messages.js +138 -0
  34. package/src/protocol/validators.js +175 -0
  35. package/src/server/CertManager.js +173 -0
  36. package/src/server/MessageRouter.js +80 -0
  37. package/src/server/OfflineQueue.js +124 -0
  38. package/src/server/SessionManager.js +296 -0
  39. package/src/server/WebSocketServer.js +632 -0
  40. package/src/server/index.js +89 -0
  41. package/src/shared/AuditLog.js +91 -0
  42. package/src/shared/PluginManager.js +83 -0
  43. package/src/shared/banner.js +271 -0
  44. package/src/shared/commandSuggest.js +59 -0
  45. package/src/shared/config.js +90 -0
  46. package/src/shared/constants.js +126 -0
  47. package/src/shared/coverTraffic.js +34 -0
  48. package/src/shared/dnd.js +60 -0
  49. package/src/shared/emoji.js +17 -0
  50. package/src/shared/fuzzy.js +40 -0
  51. package/src/shared/invite.js +61 -0
  52. package/src/shared/keyArt.js +66 -0
  53. package/src/shared/logger.js +38 -0
  54. package/src/shared/panic.js +38 -0
  55. package/src/shared/prompt.js +31 -0
  56. package/src/shared/terminalGraphics.js +72 -0
  57. package/src/shared/themes.js +36 -0
  58. package/src/shared/voiceNote.js +128 -0
@@ -0,0 +1,1830 @@
1
+ import blessed from 'blessed';
2
+ import { EventEmitter } from 'node:events';
3
+ import { shortcodeSuggestions } from '../shared/emoji.js';
4
+ import { nickPalette } from '../shared/themes.js';
5
+ import { fuzzyFilter } from '../shared/fuzzy.js';
6
+ import { EMOJI_MAP } from '../shared/constants.js';
7
+
8
+ const EMOJI_ENTRIES = Object.entries(EMOJI_MAP); // [':name:', '๐Ÿ˜€']
9
+
10
+ const NICK_AVATARS = ['๐Ÿ˜€', '๐Ÿ˜Ž', '๐Ÿค ', '๐Ÿค–', '๐Ÿ‘ป', '๐Ÿ‘ฝ', '๐ŸฆŠ', '๐Ÿผ', '๐Ÿธ', '๐Ÿฆ', '๐Ÿ™', '๐Ÿง'];
11
+ const TYPING_DOTS = ['', '.', '..', '...'];
12
+ const SPINNER_FRAMES = ['โ ‹', 'โ ™', 'โ น', 'โ ธ', 'โ ผ', 'โ ด', 'โ ฆ', 'โ ง', 'โ ‡', 'โ '];
13
+ const INPUT_MAX_LINES = 8; // input box grows up to this many text lines
14
+
15
+ // Command โ†’ one-line description for the Ctrl+K fuzzy command palette.
16
+ const COMMAND_INFO = [
17
+ ['/help', 'Show help'],
18
+ ['/users', 'List online users'],
19
+ ['/msg', 'Private message (DM)'],
20
+ ['/reply', 'Reply to the last message'],
21
+ ['/away', 'Mark yourself as away'],
22
+ ['/back', 'Clear away status'],
23
+ ['/status', 'Set a status'],
24
+ ['/join', 'Join a room'],
25
+ ['/rooms', 'List rooms'],
26
+ ['/room', 'Show the current room'],
27
+ ['/invite', 'Generate an invite with QR'],
28
+ ['/fingerprint', 'Show fingerprint'],
29
+ ['/verify', 'SAS verification code'],
30
+ ['/trust', "Accept a peer's new key"],
31
+ ['/trustlist', 'Trust status'],
32
+ ['/clear', 'Clear the chat'],
33
+ ['/file', 'Send a file'],
34
+ ['/voice', 'Record and send a voice note'],
35
+ ['/play', 'Play the last voice note'],
36
+ ['/img', 'Render a high-resolution image'],
37
+ ['/sound', 'Sound notifications'],
38
+ ['/notify', 'Desktop notifications'],
39
+ ['/search', 'Search history'],
40
+ ['/history', 'Recent messages from history'],
41
+ ['/export', 'Export history'],
42
+ ['/backup', 'Back up identity + trust'],
43
+ ['/retention', 'Local history retention'],
44
+ ['/audit', 'Audit events'],
45
+ ['/ephemeral', 'Ephemeral messages'],
46
+ ['/react', 'React to a message'],
47
+ ['/edit', 'Edit the last sent message'],
48
+ ['/delete', 'Delete the last sent message'],
49
+ ['/pin', 'Pin a message'],
50
+ ['/unpin', 'Unpin a message'],
51
+ ['/pins', 'List pinned messages'],
52
+ ['/deniable', 'Deniable mode'],
53
+ ['/receipts', 'Read receipts'],
54
+ ['/cover', 'Cover traffic (anti-metadata)'],
55
+ ['/theme', 'Nick color theme'],
56
+ ['/panic', 'Wipe everything from disk and exit (duress)'],
57
+ ['/kick', 'Kick a user (owner)'],
58
+ ['/mute', 'Mute a user (owner)'],
59
+ ['/ban', 'Ban a user (owner)'],
60
+ ['/owner', 'Current room owner'],
61
+ ['/plugins', 'List plugins'],
62
+ ['/quit', 'Quit the chat'],
63
+ ];
64
+
65
+ export const COMMANDS = [
66
+ '/help',
67
+ '/nick',
68
+ '/users',
69
+ '/fingerprint',
70
+ '/verify',
71
+ '/verify-confirm',
72
+ '/trust',
73
+ '/trustlist',
74
+ '/clear',
75
+ '/file',
76
+ '/voice',
77
+ '/play',
78
+ '/accept',
79
+ '/reject',
80
+ '/img',
81
+ '/sound',
82
+ '/msg',
83
+ '/reply',
84
+ '/away',
85
+ '/back',
86
+ '/autoaway',
87
+ '/status',
88
+ '/notify',
89
+ '/dnd',
90
+ '/join',
91
+ '/rooms',
92
+ '/room',
93
+ '/invite',
94
+ '/search',
95
+ '/history',
96
+ '/export',
97
+ '/backup',
98
+ '/retention',
99
+ '/audit',
100
+ '/ephemeral',
101
+ '/react',
102
+ '/edit',
103
+ '/delete',
104
+ '/pin',
105
+ '/unpin',
106
+ '/pins',
107
+ '/deniable',
108
+ '/receipts',
109
+ '/cover',
110
+ '/theme',
111
+ '/panic',
112
+ '/kick',
113
+ '/mute',
114
+ '/ban',
115
+ '/owner',
116
+ '/plugins',
117
+ '/quit',
118
+ ];
119
+ const NICK_COMMANDS = [
120
+ '/fingerprint',
121
+ '/verify',
122
+ '/verify-confirm',
123
+ '/trust',
124
+ '/msg',
125
+ '/kick',
126
+ '/mute',
127
+ '/ban',
128
+ ];
129
+
130
+ function nickHash(nickname) {
131
+ let hash = 0;
132
+ for (let i = 0; i < nickname.length; i++) {
133
+ hash = ((hash << 5) - hash + nickname.charCodeAt(i)) | 0;
134
+ }
135
+ return Math.abs(hash);
136
+ }
137
+
138
+ function nickColor(nickname) {
139
+ const palette = nickPalette();
140
+ return palette[nickHash(nickname) % palette.length];
141
+ }
142
+
143
+ // Deterministic avatar per nick โ€” emojis can't be color-tinted in the
144
+ // terminal, so the visual identity comes from the variety of the emoji itself
145
+ function nickAvatar(nickname) {
146
+ return NICK_AVATARS[nickHash(nickname) % NICK_AVATARS.length];
147
+ }
148
+
149
+ function time() {
150
+ return new Date().toLocaleTimeString('en-US', {
151
+ hour12: false,
152
+ hour: '2-digit',
153
+ minute: '2-digit',
154
+ });
155
+ }
156
+
157
+ // Inline markdown for a single line: `code`, **bold**, *italic*, links.
158
+ function renderInline(text) {
159
+ // Collect all markdown spans with their positions
160
+ const spans = [];
161
+
162
+ // Inline code: `code`
163
+ for (const m of text.matchAll(/`([^`]+)`/g)) {
164
+ spans.push({ start: m.index, end: m.index + m[0].length, inner: m[1], tag: 'yellow-fg' });
165
+ }
166
+
167
+ // Bold: **text**
168
+ for (const m of text.matchAll(/\*\*([^*]+)\*\*/g)) {
169
+ // Skip if overlaps with an existing span (inside code)
170
+ if (spans.some((s) => m.index >= s.start && m.index < s.end)) {
171
+ continue;
172
+ }
173
+ spans.push({ start: m.index, end: m.index + m[0].length, inner: m[1], tag: 'bold' });
174
+ }
175
+
176
+ // Italic: *text* (not preceded/followed by *)
177
+ for (const m of text.matchAll(/(?<!\*)\*([^*]+)\*(?!\*)/g)) {
178
+ if (spans.some((s) => m.index >= s.start && m.index < s.end)) {
179
+ continue;
180
+ }
181
+ spans.push({ start: m.index, end: m.index + m[0].length, inner: m[1], tag: 'underline' });
182
+ }
183
+
184
+ // Links: http(s):// โ€ฆ โ€” highlighted cyan + underline.
185
+ for (const m of text.matchAll(/https?:\/\/[^\s]+/g)) {
186
+ if (spans.some((s) => m.index >= s.start && m.index < s.end)) {
187
+ continue;
188
+ }
189
+ spans.push({
190
+ start: m.index,
191
+ end: m.index + m[0].length,
192
+ inner: m[0],
193
+ open: '{cyan-fg}{underline}',
194
+ close: '{/underline}{/cyan-fg}',
195
+ });
196
+ }
197
+
198
+ if (spans.length === 0) {
199
+ return blessed.escape(text);
200
+ }
201
+
202
+ // Sort by position
203
+ spans.sort((a, b) => a.start - b.start);
204
+
205
+ // Build result: escape plain segments, apply tags to markdown segments
206
+ let result = '';
207
+ let pos = 0;
208
+ for (const span of spans) {
209
+ if (span.start > pos) {
210
+ result += blessed.escape(text.slice(pos, span.start));
211
+ }
212
+ const open = span.open ?? `{${span.tag}}`;
213
+ const close = span.close ?? `{/${span.tag}}`;
214
+ result += `${open}${blessed.escape(span.inner)}${close}`;
215
+ pos = span.end;
216
+ }
217
+ if (pos < text.length) {
218
+ result += blessed.escape(text.slice(pos));
219
+ }
220
+
221
+ return result;
222
+ }
223
+
224
+ // A markdown table separator row, e.g. |---|:--:|.
225
+ function isTableSeparator(line) {
226
+ return /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line) && line.includes('-');
227
+ }
228
+
229
+ function splitRow(line) {
230
+ return line
231
+ .trim()
232
+ .replace(/^\||\|$/g, '')
233
+ .split('|')
234
+ .map((c) => c.trim());
235
+ }
236
+
237
+ // Render a buffered block of pipe-lines. If it's a real table (has a separator
238
+ // row) align the columns; otherwise fall back to inline rendering per line.
239
+ function renderTable(lines) {
240
+ const sepIdx = lines.findIndex(isTableSeparator);
241
+ if (sepIdx < 1) {
242
+ return lines.map(renderInline); // not a table โ€” just piped text
243
+ }
244
+ const header = splitRow(lines[sepIdx - 1]);
245
+ const bodyRows = lines.filter((l, i) => i !== sepIdx && i !== sepIdx - 1).map(splitRow);
246
+ const cols = header.length;
247
+ const widths = header.map((h, c) =>
248
+ Math.max(h.length, ...bodyRows.map((r) => (r[c] || '').length)),
249
+ );
250
+ const pad = (cells) =>
251
+ ' ' +
252
+ cells
253
+ .slice(0, cols)
254
+ .map((cell, c) => (cell || '').padEnd(widths[c]))
255
+ .join(' ');
256
+ const out = [`{bold}{cyan-fg}${blessed.escape(pad(header))}{/cyan-fg}{/bold}`];
257
+ out.push(`{#666666-fg}${blessed.escape(pad(widths.map((w) => 'โ”€'.repeat(w))))}{/#666666-fg}`);
258
+ for (const row of bodyRows) {
259
+ out.push(blessed.escape(pad(row)));
260
+ }
261
+ return out;
262
+ }
263
+
264
+ // Block-aware markdown: fenced ``` code blocks and | tables | on top of the
265
+ // inline formatting. Multi-line messages are processed line by line.
266
+ export function renderMarkdown(text) {
267
+ if (!text.includes('\n')) {
268
+ return renderInline(text); // single line โ€” the common, fast path
269
+ }
270
+ const lines = text.split('\n');
271
+ const out = [];
272
+ let inCode = false;
273
+ let tableBuf = [];
274
+ const flushTable = () => {
275
+ if (tableBuf.length) {
276
+ out.push(...renderTable(tableBuf));
277
+ tableBuf = [];
278
+ }
279
+ };
280
+
281
+ for (const line of lines) {
282
+ if (/^\s*```/.test(line)) {
283
+ flushTable();
284
+ inCode = !inCode;
285
+ out.push(`{#666666-fg}${blessed.escape(line)}{/#666666-fg}`);
286
+ continue;
287
+ }
288
+ if (inCode) {
289
+ out.push(`{#7fdbca-fg}${blessed.escape(line)}{/#7fdbca-fg}`); // code โ€” no inline md
290
+ continue;
291
+ }
292
+ if (line.includes('|')) {
293
+ tableBuf.push(line);
294
+ continue;
295
+ }
296
+ flushTable();
297
+ out.push(renderInline(line));
298
+ }
299
+ flushTable();
300
+ return out.join('\n');
301
+ }
302
+
303
+ // Builds the rendered content of the (possibly multi-line) input box with an
304
+ // inverse cursor cell, windowed so the cursor line is always visible. Pure and
305
+ // exported for testing. Returns { content, height } (height includes borders).
306
+ export function inputView(value, cursorPos, maxLines = 8) {
307
+ const segments = value.split('\n');
308
+ const built = [];
309
+ let lineStart = 0;
310
+ let cursorLine = 0;
311
+
312
+ for (let li = 0; li < segments.length; li++) {
313
+ const seg = segments[li];
314
+ const segEnd = lineStart + seg.length; // flat offset just before the '\n'
315
+ if (cursorPos >= lineStart && cursorPos <= segEnd) {
316
+ cursorLine = li;
317
+ const col = cursorPos - lineStart;
318
+ // grapheme under the cursor (extend across a surrogate pair)
319
+ let cur = seg.slice(col, col + 1);
320
+ const code = seg.charCodeAt(col);
321
+ if (code >= 0xd800 && code <= 0xdbff && col + 1 < seg.length) {
322
+ cur = seg.slice(col, col + 2);
323
+ }
324
+ const cursorCell = cur === '' ? ' ' : cur;
325
+ const before = blessed.escape(seg.slice(0, col));
326
+ const after = blessed.escape(seg.slice(col + (cur === '' ? 0 : cur.length)));
327
+ built.push(` ${before}{inverse}${blessed.escape(cursorCell)}{/inverse}${after}`);
328
+ } else {
329
+ built.push(` ${blessed.escape(seg)}`);
330
+ }
331
+ lineStart = segEnd + 1; // skip the '\n'
332
+ }
333
+
334
+ let lines = built;
335
+ if (built.length > maxLines) {
336
+ let start = Math.max(0, cursorLine - maxLines + 1);
337
+ if (start + maxLines > built.length) {
338
+ start = built.length - maxLines;
339
+ }
340
+ lines = built.slice(start, start + maxLines);
341
+ }
342
+
343
+ const height = Math.min(Math.max(segments.length, 1), maxLines) + 2; // + borders
344
+ return { content: lines.join('\n'), height };
345
+ }
346
+
347
+ // Extra frames appended after the flame reaches the last glyph so the tail
348
+ // (hot โ†’ ember โ†’ ash โ†’ gone) finishes burning out.
349
+ const BURN_TAIL = 5;
350
+
351
+ // One glyph of the burn animation. `phase` is how many frames the flame front
352
+ // has passed this position: <0 still intact, then it heats up and cools to ash.
353
+ function burnGlyph(ch, phase) {
354
+ if (phase <= 0) {
355
+ return blessed.escape(ch);
356
+ }
357
+ if (phase < 1) {
358
+ return '{#ffd000-fg}โ–“{/#ffd000-fg}'; // ignite
359
+ }
360
+ if (phase < 2) {
361
+ return '{#ff8c00-fg}โ–“{/#ff8c00-fg}'; // burning
362
+ }
363
+ if (phase < 3) {
364
+ return '{#ff2b00-fg}โ–’{/#ff2b00-fg}'; // hottest
365
+ }
366
+ if (phase < 4) {
367
+ return '{#7a2b00-fg}โ–‘{/#7a2b00-fg}'; // ember
368
+ }
369
+ if (phase < BURN_TAIL) {
370
+ return '{#555555-fg}ยท{/#555555-fg}'; // ash
371
+ }
372
+ return ' '; // gone
373
+ }
374
+
375
+ // Builds one frame of the burn effect for a tag-free string, given how far the
376
+ // flame front has advanced. Exported for testing.
377
+ export function burnFrame(text, front) {
378
+ const chars = [...text];
379
+ let out = '';
380
+ for (let i = 0; i < chars.length; i++) {
381
+ out += burnGlyph(chars[i], front - i);
382
+ }
383
+ return out;
384
+ }
385
+
386
+ // A blessed-tagged progress bar. `shimmerPos` moves a 2-cell bright band along
387
+ // the filled region; at 100% the whole bar goes solid green. Exported for
388
+ // testing.
389
+ export function progressBar(pct, shimmerPos = 0, width = 24) {
390
+ const p = Math.max(0, Math.min(100, pct));
391
+ const done = p >= 100;
392
+ const filled = Math.round((p / 100) * width);
393
+ let bar = '';
394
+ for (let i = 0; i < width; i++) {
395
+ if (i < filled) {
396
+ const lit = !done && ((shimmerPos % width) - i + width) % width < 2;
397
+ const color = done ? 'green' : lit ? '#ffffff' : '#00b8ff';
398
+ bar += `{${color}-fg}โ–ˆ{/${color}-fg}`;
399
+ } else {
400
+ bar += '{#333333-fg}โ–‘{/#333333-fg}';
401
+ }
402
+ }
403
+ return bar;
404
+ }
405
+
406
+ // Estimates remaining time from elapsed ms and percent done. Returns '' when
407
+ // there isn't enough signal yet (too early, at the ends). Exported for testing.
408
+ export function formatETA(elapsedMs, pct) {
409
+ if (pct <= 0 || pct >= 100 || elapsedMs < 600) {
410
+ return '';
411
+ }
412
+ const remaining = Math.max(0, (elapsedMs / pct) * 100 - elapsedMs);
413
+ const secs = Math.ceil(remaining / 1000);
414
+ return secs >= 60 ? `~${Math.ceil(secs / 60)}m remaining` : `~${secs}s remaining`;
415
+ }
416
+
417
+ export class UI extends EventEmitter {
418
+ #screen;
419
+ #header;
420
+ #chatLog;
421
+ #inputBox;
422
+ #nickname;
423
+ #onlineCount;
424
+ #inputValue;
425
+ #cursorPos;
426
+ #lastKeyEvent;
427
+ #typingPeers;
428
+ #typingAnimInterval;
429
+ #typingAnimFrame;
430
+ #soundEnabled;
431
+ #notifyEnabled;
432
+ #peerNames;
433
+ #tabState;
434
+ #lines;
435
+ #headerIndicators;
436
+ #scrolledUp;
437
+ #connState;
438
+ #lastMsgDate;
439
+ #lastSender;
440
+ #pasting;
441
+ #pasteBuffer;
442
+ #lastPaste;
443
+ #statusBar;
444
+ #statusFingerprint;
445
+ #statusRoom;
446
+ #connSpinner;
447
+ #spinnerFrame;
448
+ #reconnectFlashTimer;
449
+ #reconnectFlashFrame;
450
+ #reconnectFlashActive;
451
+ #progIndex;
452
+ #progPercent;
453
+ #progText;
454
+ #progStart;
455
+ #shimmerTimer;
456
+ #shimmerPos;
457
+ #unseenCount;
458
+ #pillTimer;
459
+ #pillFrame;
460
+ #palette;
461
+ #paletteOpen;
462
+ #paletteQuery;
463
+ #emojiPicker;
464
+ #emojiOpen;
465
+ #emojiQuery;
466
+
467
+ constructor(nickname) {
468
+ super();
469
+ this.#nickname = nickname;
470
+ this.#onlineCount = 1;
471
+ this.#connState = 'online';
472
+ this.#lastMsgDate = null;
473
+ this.#lastSender = null;
474
+ this.#pasting = false;
475
+ this.#pasteBuffer = '';
476
+ this.#lastPaste = { content: '', time: 0 };
477
+ this.#inputValue = '';
478
+ this.#cursorPos = 0;
479
+ this.#lastKeyEvent = { seq: '', time: 0 };
480
+ this.#typingPeers = new Set();
481
+ this.#soundEnabled = true;
482
+ this.#notifyEnabled = true;
483
+ this.#typingAnimInterval = null;
484
+ this.#typingAnimFrame = 0;
485
+ this.#peerNames = [];
486
+ this.#tabState = { suggestions: [], index: -1, original: '' };
487
+ this.#lines = [];
488
+ this.#headerIndicators = [];
489
+ this.#scrolledUp = false;
490
+ this.#statusFingerprint = '';
491
+ this.#statusRoom = 'general';
492
+ this.#connSpinner = null;
493
+ this.#spinnerFrame = 0;
494
+ this.#reconnectFlashTimer = null;
495
+ this.#reconnectFlashFrame = 0;
496
+ this.#reconnectFlashActive = false;
497
+ this.#progIndex = null;
498
+ this.#progPercent = 0;
499
+ this.#progText = '';
500
+ this.#progStart = 0;
501
+ this.#shimmerTimer = null;
502
+ this.#shimmerPos = 0;
503
+ this.#unseenCount = 0;
504
+ this.#pillTimer = null;
505
+ this.#pillFrame = 0;
506
+ this.#paletteOpen = false;
507
+ this.#paletteQuery = '';
508
+ this.#emojiOpen = false;
509
+ this.#emojiQuery = '';
510
+
511
+ this.#screen = blessed.screen({
512
+ smartCSR: true,
513
+ fullUnicode: true, // renders emojis and characters outside the BMP
514
+ title: 'CipherMesh',
515
+ });
516
+
517
+ // โ”€โ”€ Header โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
518
+ this.#header = blessed.box({
519
+ parent: this.#screen,
520
+ top: 0,
521
+ left: 0,
522
+ width: '100%',
523
+ height: 3,
524
+ tags: true,
525
+ style: {
526
+ fg: 'white',
527
+ bg: '#1a1a2e',
528
+ },
529
+ content: this.#headerContent(),
530
+ });
531
+
532
+ // โ”€โ”€ Chat log โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
533
+ this.#chatLog = blessed.log({
534
+ parent: this.#screen,
535
+ top: 3,
536
+ left: 0,
537
+ width: '100%',
538
+ bottom: 4, // leave room for the 1-line status bar above the input
539
+ tags: true,
540
+ scrollable: true,
541
+ alwaysScroll: true,
542
+ scrollbar: {
543
+ style: { bg: 'magenta' },
544
+ },
545
+ border: {
546
+ type: 'line',
547
+ },
548
+ style: {
549
+ border: { fg: 'cyan' },
550
+ },
551
+ });
552
+
553
+ // โ”€โ”€ Input (plain box, manual keypress) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
554
+ this.#inputBox = blessed.box({
555
+ parent: this.#screen,
556
+ bottom: 0,
557
+ left: 0,
558
+ width: '100%',
559
+ height: 3,
560
+ tags: true,
561
+ border: {
562
+ type: 'line',
563
+ },
564
+ style: {
565
+ fg: 'white',
566
+ border: { fg: 'green' },
567
+ },
568
+ });
569
+
570
+ // โ”€โ”€ Status bar (1 line, between chat and input) โ”€โ”€โ”€โ”€โ”€โ”€
571
+ this.#statusBar = blessed.box({
572
+ parent: this.#screen,
573
+ bottom: 3,
574
+ left: 0,
575
+ width: '100%',
576
+ height: 1,
577
+ tags: true,
578
+ style: {
579
+ fg: 'white',
580
+ bg: '#16213e',
581
+ },
582
+ content: this.#statusContent(),
583
+ });
584
+
585
+ // โ”€โ”€ Command palette (Ctrl+K) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
586
+ this.#palette = blessed.list({
587
+ parent: this.#screen,
588
+ hidden: true,
589
+ top: 'center',
590
+ left: 'center',
591
+ width: '70%',
592
+ height: '60%',
593
+ tags: true,
594
+ border: { type: 'line' },
595
+ label: ' Commands (Ctrl+K) ',
596
+ style: {
597
+ border: { fg: 'magenta' },
598
+ selected: { bg: 'magenta', fg: 'white' },
599
+ item: { fg: 'white' },
600
+ },
601
+ });
602
+
603
+ // โ”€โ”€ Emoji picker (Ctrl+E) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
604
+ this.#emojiPicker = blessed.list({
605
+ parent: this.#screen,
606
+ hidden: true,
607
+ top: 'center',
608
+ left: 'center',
609
+ width: '50%',
610
+ height: '55%',
611
+ tags: true,
612
+ border: { type: 'line' },
613
+ label: ' Emoji (Ctrl+E) ',
614
+ style: {
615
+ border: { fg: 'yellow' },
616
+ selected: { bg: 'yellow', fg: 'black' },
617
+ item: { fg: 'white' },
618
+ },
619
+ });
620
+
621
+ this.#renderInput();
622
+
623
+ // โ”€โ”€ Single keypress listener with dedup โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
624
+ // blessed on Windows fires every keypress twice through
625
+ // both program and screen pipelines. We listen ONLY on
626
+ // screen level and deduplicate by sequence + timestamp.
627
+ this.#screen.on('keypress', (ch, key) => {
628
+ if (!key) {
629
+ return;
630
+ }
631
+
632
+ const now = performance.now();
633
+ const seq = key.sequence || key.full || ch || '';
634
+
635
+ // Mouse tracking sequences (ex: \x1b[<35;10;20M) that leak past
636
+ // blessed's parser are not typing โ€” discard
637
+ if (seq.startsWith('\x1b[<') || seq.startsWith('\x1b[M')) {
638
+ return;
639
+ }
640
+
641
+ // Shift+Enter arrives as a distinct sequence only under the kitty keyboard
642
+ // protocol (\x1b[13;2u) or xterm modifyOtherKeys (\x1b[27;2;13~). When the
643
+ // terminal sends it, insert a newline instead of submitting.
644
+ if (seq === '\x1b[13;2u' || seq === '\x1b[27;2;13~') {
645
+ this.#insertNewline();
646
+ return;
647
+ }
648
+
649
+ // Bracketed paste: insert the whole pasted block atomically (avoids the
650
+ // double-pipeline duplicating a paste that spans the 25ms dedup window).
651
+ if (this.#handlePaste(seq)) {
652
+ return;
653
+ }
654
+
655
+ // Same raw sequence within 25ms = duplicate from blessed
656
+ if (seq === this.#lastKeyEvent.seq && now - this.#lastKeyEvent.time < 25) {
657
+ return;
658
+ }
659
+ this.#lastKeyEvent = { seq, time: now };
660
+
661
+ if (this.#paletteOpen) {
662
+ this.#handlePaletteKey(ch, key);
663
+ return;
664
+ }
665
+
666
+ if (this.#emojiOpen) {
667
+ this.#handleEmojiKey(ch, key);
668
+ return;
669
+ }
670
+
671
+ this.#handleKey(ch, key);
672
+ });
673
+
674
+ // Ask the terminal to bracket pasted text with \x1b[200~ โ€ฆ \x1b[201~.
675
+ try {
676
+ this.#screen.program.write('\x1b[?2004h');
677
+ process.on('exit', () => {
678
+ try {
679
+ process.stdout.write('\x1b[?2004l');
680
+ } catch {
681
+ /* ignore */
682
+ }
683
+ });
684
+ } catch {
685
+ /* terminals without bracketed paste just ignore this */
686
+ }
687
+
688
+ this.#screen.render();
689
+ }
690
+
691
+ #handleKey(ch, key) {
692
+ const name = key.name || '';
693
+
694
+ // Ctrl+C โ€” quit
695
+ if (key.ctrl && name === 'c') {
696
+ this.emit('quit');
697
+ return;
698
+ }
699
+
700
+ // Ctrl+K โ€” command palette
701
+ if (key.ctrl && name === 'k') {
702
+ this.#openPalette();
703
+ return;
704
+ }
705
+
706
+ // Ctrl+E โ€” emoji picker
707
+ if (key.ctrl && name === 'e') {
708
+ this.#openEmoji();
709
+ return;
710
+ }
711
+
712
+ // Tab โ€” autocomplete
713
+ if (name === 'tab') {
714
+ this.#handleTab();
715
+ return;
716
+ }
717
+
718
+ // Any non-tab key resets tab cycling
719
+ this.#tabState = { suggestions: [], index: -1, original: '' };
720
+
721
+ // Alt+Enter / Ctrl+J โ€” insert a newline (reliable across terminals, unlike
722
+ // bare Shift+Enter which most terminals don't distinguish from Enter).
723
+ if (((name === 'return' || name === 'enter') && key.meta) || (key.ctrl && name === 'j')) {
724
+ this.#insertNewline();
725
+ return;
726
+ }
727
+
728
+ // Enter โ€” submit (newlines inside the message are preserved)
729
+ if (name === 'return' || name === 'enter') {
730
+ const text = this.#inputValue.replace(/^\n+|\n+$/g, '').trim();
731
+ if (text) {
732
+ this.emit('input', text);
733
+ }
734
+ this.#inputValue = '';
735
+ this.#cursorPos = 0;
736
+ this.#renderInput();
737
+ return;
738
+ }
739
+
740
+ // Backspace โ€” deletes a whole code point (emojis are surrogate pairs)
741
+ if (name === 'backspace') {
742
+ if (this.#cursorPos > 0) {
743
+ const start = this.#prevCharBoundary(this.#cursorPos);
744
+ this.#inputValue =
745
+ this.#inputValue.slice(0, start) + this.#inputValue.slice(this.#cursorPos);
746
+ this.#cursorPos = start;
747
+ }
748
+ this.emit('activity');
749
+ this.#renderInput();
750
+ return;
751
+ }
752
+
753
+ // Delete
754
+ if (name === 'delete') {
755
+ if (this.#cursorPos < this.#inputValue.length) {
756
+ const end = this.#nextCharBoundary(this.#cursorPos);
757
+ this.#inputValue = this.#inputValue.slice(0, this.#cursorPos) + this.#inputValue.slice(end);
758
+ }
759
+ this.#renderInput();
760
+ return;
761
+ }
762
+
763
+ // Arrow left
764
+ if (name === 'left') {
765
+ if (this.#cursorPos > 0) {
766
+ this.#cursorPos = this.#prevCharBoundary(this.#cursorPos);
767
+ }
768
+ this.#renderInput();
769
+ return;
770
+ }
771
+
772
+ // Arrow right
773
+ if (name === 'right') {
774
+ if (this.#cursorPos < this.#inputValue.length) {
775
+ this.#cursorPos = this.#nextCharBoundary(this.#cursorPos);
776
+ }
777
+ this.#renderInput();
778
+ return;
779
+ }
780
+
781
+ // Home
782
+ if (name === 'home') {
783
+ this.#cursorPos = 0;
784
+ this.#renderInput();
785
+ return;
786
+ }
787
+
788
+ // End
789
+ if (name === 'end') {
790
+ this.#cursorPos = this.#inputValue.length;
791
+ this.#renderInput();
792
+ return;
793
+ }
794
+
795
+ // Ctrl+U โ€” clear line
796
+ if (key.ctrl && name === 'u') {
797
+ this.#inputValue = '';
798
+ this.#cursorPos = 0;
799
+ this.#renderInput();
800
+ return;
801
+ }
802
+
803
+ // Page Up / Page Down โ€” scroll chat
804
+ if (name === 'pageup' || name === 'pagedown') {
805
+ const page = Math.max(1, this.#chatLog.height - this.#chatLog.iheight);
806
+ this.#chatLog.scroll(name === 'pageup' ? -page : page);
807
+ this.#screen.render();
808
+ this.#syncScrollState();
809
+ return;
810
+ }
811
+
812
+ // Regular character โ€” length 2 = surrogate pair (emoji delivered whole
813
+ // in some terminals; in others it arrives as two halves of length 1)
814
+ const firstCode = ch ? ch.charCodeAt(0) : 0;
815
+ if (ch && ch.length <= 2 && !key.ctrl && !key.meta && firstCode > 0x1f && firstCode !== 0x7f) {
816
+ this.#inputValue =
817
+ this.#inputValue.slice(0, this.#cursorPos) + ch + this.#inputValue.slice(this.#cursorPos);
818
+ this.#cursorPos += ch.length;
819
+ this.emit('activity');
820
+ this.#renderInput();
821
+ }
822
+ }
823
+
824
+ // Bracketed-paste state machine. Returns true when the sequence is part of a
825
+ // paste (start / content / end) and must not be treated as normal keypresses.
826
+ #handlePaste(seq) {
827
+ const START = '\x1b[200~';
828
+ const END = '\x1b[201~';
829
+
830
+ if (this.#pasting) {
831
+ const endIdx = seq.indexOf(END);
832
+ if (endIdx !== -1) {
833
+ this.#pasteBuffer += seq.slice(0, endIdx);
834
+ this.#pasting = false;
835
+ const content = this.#pasteBuffer;
836
+ this.#pasteBuffer = '';
837
+ this.#insertPaste(content);
838
+ } else {
839
+ this.#pasteBuffer += seq;
840
+ }
841
+ return true;
842
+ }
843
+
844
+ const startIdx = seq.indexOf(START);
845
+ if (startIdx === -1) {
846
+ return false;
847
+ }
848
+
849
+ const rest = seq.slice(startIdx + START.length);
850
+ const endIdx = rest.indexOf(END);
851
+ if (endIdx !== -1) {
852
+ this.#insertPaste(rest.slice(0, endIdx)); // whole paste in one sequence
853
+ } else {
854
+ this.#pasting = true;
855
+ this.#pasteBuffer = rest;
856
+ }
857
+ return true;
858
+ }
859
+
860
+ #insertPaste(raw) {
861
+ // Strip any stray paste markers and control chars (a message is one line).
862
+ // eslint-disable-next-line no-control-regex
863
+ const clean = raw.replace(/\x1b\[20[01]~/g, '').replace(/[\x00-\x1f\x7f]/g, '');
864
+ if (!clean) {
865
+ return;
866
+ }
867
+ // Drop a replay of the same paste from the second input pipeline.
868
+ const now = performance.now();
869
+ if (clean === this.#lastPaste.content && now - this.#lastPaste.time < 400) {
870
+ return;
871
+ }
872
+ this.#lastPaste = { content: clean, time: now };
873
+
874
+ this.#inputValue =
875
+ this.#inputValue.slice(0, this.#cursorPos) + clean + this.#inputValue.slice(this.#cursorPos);
876
+ this.#cursorPos += clean.length;
877
+ this.emit('activity');
878
+ this.#renderInput();
879
+ }
880
+
881
+ // Code-point boundaries โ€” a surrogate pair moves as a whole
882
+ #prevCharBoundary(pos) {
883
+ if (pos <= 1) {
884
+ return 0;
885
+ }
886
+ const code = this.#inputValue.charCodeAt(pos - 1);
887
+ return code >= 0xdc00 && code <= 0xdfff ? pos - 2 : pos - 1;
888
+ }
889
+
890
+ #nextCharBoundary(pos) {
891
+ const len = this.#inputValue.length;
892
+ if (pos >= len) {
893
+ return len;
894
+ }
895
+ const code = this.#inputValue.charCodeAt(pos);
896
+ return code >= 0xd800 && code <= 0xdbff && pos + 1 < len ? pos + 2 : pos + 1;
897
+ }
898
+
899
+ #renderInput() {
900
+ const { content, height } = inputView(this.#inputValue, this.#cursorPos, INPUT_MAX_LINES);
901
+ this.#applyInputHeight(height);
902
+ this.#inputBox.setContent(content);
903
+ this.#screen.render();
904
+ }
905
+
906
+ // Grow/shrink the input box with the number of lines and reflow the status bar
907
+ // and chat log above it.
908
+ #applyInputHeight(height) {
909
+ if (this.#inputBox.height === height) {
910
+ return;
911
+ }
912
+ this.#inputBox.height = height;
913
+ if (this.#statusBar) {
914
+ this.#statusBar.bottom = height;
915
+ }
916
+ this.#chatLog.bottom = height + 1;
917
+ }
918
+
919
+ #insertNewline() {
920
+ this.#inputValue =
921
+ this.#inputValue.slice(0, this.#cursorPos) + '\n' + this.#inputValue.slice(this.#cursorPos);
922
+ this.#cursorPos += 1;
923
+ this.emit('activity');
924
+ this.#renderInput();
925
+ }
926
+
927
+ // โ”€โ”€ Command palette (Ctrl+K, fuzzy) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
928
+ #openPalette() {
929
+ this.#paletteOpen = true;
930
+ this.#paletteQuery = '';
931
+ this.#refreshPalette();
932
+ this.#palette.show();
933
+ this.#palette.setFront();
934
+ this.#screen.render();
935
+ }
936
+
937
+ #closePalette() {
938
+ this.#paletteOpen = false;
939
+ this.#palette.hide();
940
+ this.#screen.render();
941
+ }
942
+
943
+ #paletteMatches() {
944
+ return fuzzyFilter(COMMAND_INFO, this.#paletteQuery, (c) => `${c[0]} ${c[1]}`);
945
+ }
946
+
947
+ #refreshPalette() {
948
+ const matches = this.#paletteMatches();
949
+ this.#palette.setItems(
950
+ matches.map(([cmd, desc]) => ` {bold}${cmd}{/bold} {#8888aa-fg}${desc}{/#8888aa-fg}`),
951
+ );
952
+ this.#palette.select(0);
953
+ const q = this.#paletteQuery ? ` โ€บ ${this.#paletteQuery}` : '';
954
+ this.#palette.setLabel(` Commands (Ctrl+K)${q} `);
955
+ this.#screen.render();
956
+ }
957
+
958
+ #handlePaletteKey(ch, key) {
959
+ const name = key.name || '';
960
+ if (name === 'escape' || (key.ctrl && name === 'c')) {
961
+ this.#closePalette();
962
+ return;
963
+ }
964
+ if (name === 'up' || name === 'down') {
965
+ this.#palette[name === 'up' ? 'up' : 'down'](1);
966
+ this.#screen.render();
967
+ return;
968
+ }
969
+ if (name === 'return' || name === 'enter') {
970
+ const matches = this.#paletteMatches();
971
+ const chosen = matches[this.#palette.selected];
972
+ if (chosen) {
973
+ this.#inputValue = `${chosen[0]} `;
974
+ this.#cursorPos = this.#inputValue.length;
975
+ this.#renderInput();
976
+ }
977
+ this.#closePalette();
978
+ return;
979
+ }
980
+ if (name === 'backspace') {
981
+ this.#paletteQuery = this.#paletteQuery.slice(0, -1);
982
+ this.#refreshPalette();
983
+ return;
984
+ }
985
+ // printable char โ†’ extend the query
986
+ const code = ch ? ch.charCodeAt(0) : 0;
987
+ if (ch && ch.length === 1 && !key.ctrl && !key.meta && code > 0x1f && code !== 0x7f) {
988
+ this.#paletteQuery += ch;
989
+ this.#refreshPalette();
990
+ }
991
+ }
992
+
993
+ // โ”€โ”€ Emoji picker (Ctrl+E, fuzzy) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
994
+ #openEmoji() {
995
+ this.#emojiOpen = true;
996
+ this.#emojiQuery = '';
997
+ this.#refreshEmoji();
998
+ this.#emojiPicker.show();
999
+ this.#emojiPicker.setFront();
1000
+ this.#screen.render();
1001
+ }
1002
+
1003
+ #closeEmoji() {
1004
+ this.#emojiOpen = false;
1005
+ this.#emojiPicker.hide();
1006
+ this.#screen.render();
1007
+ }
1008
+
1009
+ #emojiMatches() {
1010
+ return fuzzyFilter(EMOJI_ENTRIES, this.#emojiQuery, (e) => e[0]);
1011
+ }
1012
+
1013
+ #refreshEmoji() {
1014
+ const matches = this.#emojiMatches();
1015
+ this.#emojiPicker.setItems(
1016
+ matches.map(([code, emoji]) => ` ${emoji} {#8888aa-fg}${code}{/#8888aa-fg}`),
1017
+ );
1018
+ this.#emojiPicker.select(0);
1019
+ const q = this.#emojiQuery ? ` โ€บ ${this.#emojiQuery}` : '';
1020
+ this.#emojiPicker.setLabel(` Emoji (Ctrl+E)${q} `);
1021
+ this.#screen.render();
1022
+ }
1023
+
1024
+ #handleEmojiKey(ch, key) {
1025
+ const name = key.name || '';
1026
+ if (name === 'escape' || (key.ctrl && name === 'c')) {
1027
+ this.#closeEmoji();
1028
+ return;
1029
+ }
1030
+ if (name === 'up' || name === 'down') {
1031
+ this.#emojiPicker[name === 'up' ? 'up' : 'down'](1);
1032
+ this.#screen.render();
1033
+ return;
1034
+ }
1035
+ if (name === 'return' || name === 'enter') {
1036
+ const chosen = this.#emojiMatches()[this.#emojiPicker.selected];
1037
+ if (chosen) {
1038
+ const emoji = chosen[1];
1039
+ this.#inputValue =
1040
+ this.#inputValue.slice(0, this.#cursorPos) +
1041
+ emoji +
1042
+ this.#inputValue.slice(this.#cursorPos);
1043
+ this.#cursorPos += emoji.length;
1044
+ this.#renderInput();
1045
+ }
1046
+ this.#closeEmoji();
1047
+ return;
1048
+ }
1049
+ if (name === 'backspace') {
1050
+ this.#emojiQuery = this.#emojiQuery.slice(0, -1);
1051
+ this.#refreshEmoji();
1052
+ return;
1053
+ }
1054
+ const code = ch ? ch.charCodeAt(0) : 0;
1055
+ if (ch && ch.length === 1 && !key.ctrl && !key.meta && code > 0x1f && code !== 0x7f) {
1056
+ this.#emojiQuery += ch;
1057
+ this.#refreshEmoji();
1058
+ }
1059
+ }
1060
+
1061
+ // โ”€โ”€ Tab autocomplete โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1062
+ #handleTab() {
1063
+ if (this.#tabState.suggestions.length === 0) {
1064
+ this.#tabState.original = this.#inputValue;
1065
+ this.#tabState.suggestions = this.#computeSuggestions(this.#inputValue);
1066
+ this.#tabState.index = -1;
1067
+ }
1068
+
1069
+ if (this.#tabState.suggestions.length === 0) {
1070
+ return;
1071
+ }
1072
+
1073
+ this.#tabState.index = (this.#tabState.index + 1) % this.#tabState.suggestions.length;
1074
+ this.#inputValue = this.#tabState.suggestions[this.#tabState.index];
1075
+ this.#cursorPos = this.#inputValue.length;
1076
+ this.#renderInput();
1077
+ }
1078
+
1079
+ #computeSuggestions(input) {
1080
+ // Emoji shortcode: last token starting with ':' (works in
1081
+ // any position, including inside commands like /status)
1082
+ const lastSpace = input.lastIndexOf(' ');
1083
+ const lastWord = input.slice(lastSpace + 1);
1084
+ if (/^:[a-z0-9_+-]+$/.test(lastWord)) {
1085
+ const head = input.slice(0, lastSpace + 1);
1086
+ return shortcodeSuggestions(lastWord).map((code) => head + code);
1087
+ }
1088
+
1089
+ if (!input.startsWith('/')) {
1090
+ return [];
1091
+ }
1092
+
1093
+ const spaceIdx = input.indexOf(' ');
1094
+
1095
+ // No space yet โ€” autocomplete command name
1096
+ if (spaceIdx === -1) {
1097
+ const prefix = input.toLowerCase();
1098
+ return COMMANDS.filter((cmd) => cmd.startsWith(prefix));
1099
+ }
1100
+
1101
+ // Has space โ€” autocomplete nickname argument
1102
+ const cmd = input.slice(0, spaceIdx).toLowerCase();
1103
+ if (!NICK_COMMANDS.includes(cmd)) {
1104
+ return [];
1105
+ }
1106
+
1107
+ const partial = input.slice(spaceIdx + 1).toLowerCase();
1108
+ return this.#peerNames
1109
+ .filter((name) => name.toLowerCase().startsWith(partial))
1110
+ .map((name) => `${cmd} ${name}`);
1111
+ }
1112
+
1113
+ setPeerNames(names) {
1114
+ this.#peerNames = names;
1115
+ }
1116
+
1117
+ // โ”€โ”€ Typing indicator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1118
+ #updateTypingLabel() {
1119
+ if (this.#typingPeers.size === 0) {
1120
+ this.#inputBox.setLabel('');
1121
+ this.#screen.render();
1122
+ return;
1123
+ }
1124
+
1125
+ const names = [...this.#typingPeers].join(', ');
1126
+ const dots = TYPING_DOTS[this.#typingAnimFrame % TYPING_DOTS.length];
1127
+ this.#inputBox.setLabel(` {yellow-fg}${names} typing${dots}{/yellow-fg} `);
1128
+ this.#screen.render();
1129
+ }
1130
+
1131
+ showTyping(nickname) {
1132
+ this.#typingPeers.add(nickname);
1133
+
1134
+ if (!this.#typingAnimInterval) {
1135
+ this.#typingAnimFrame = 0;
1136
+ this.#updateTypingLabel();
1137
+ this.#typingAnimInterval = setInterval(() => {
1138
+ this.#typingAnimFrame++;
1139
+ this.#updateTypingLabel();
1140
+ }, 400);
1141
+ } else {
1142
+ this.#updateTypingLabel();
1143
+ }
1144
+ }
1145
+
1146
+ hideTyping(nickname) {
1147
+ this.#typingPeers.delete(nickname);
1148
+
1149
+ if (this.#typingPeers.size === 0 && this.#typingAnimInterval) {
1150
+ clearInterval(this.#typingAnimInterval);
1151
+ this.#typingAnimInterval = null;
1152
+ this.#typingAnimFrame = 0;
1153
+ }
1154
+ this.#updateTypingLabel();
1155
+ }
1156
+
1157
+ #headerContent() {
1158
+ let dot;
1159
+ if (this.#connState === 'reconnecting') {
1160
+ // animated braille spinner while reconnecting
1161
+ dot = `{yellow-fg}${SPINNER_FRAMES[this.#spinnerFrame % SPINNER_FRAMES.length]}{/yellow-fg}`;
1162
+ } else if (this.#connState === 'online' && this.#reconnectFlashActive) {
1163
+ // brief green pulse when we come back online
1164
+ dot =
1165
+ this.#reconnectFlashFrame % 2 === 0
1166
+ ? '{#00ff9f-fg}\u25c9{/#00ff9f-fg}'
1167
+ : '{green-fg}\u25cf{/green-fg}';
1168
+ } else {
1169
+ const dotColor = this.#connState === 'online' ? 'green' : 'red';
1170
+ dot = `{${dotColor}-fg}\u25cf{/${dotColor}-fg}`;
1171
+ }
1172
+ const indicators =
1173
+ this.#headerIndicators.length > 0
1174
+ ? ' ' + this.#headerIndicators.map((i) => i.label).join(' ')
1175
+ : '';
1176
+ return ` ${dot} {bold}CipherMesh{/bold} {white-fg}\u2502{/white-fg} {bold}${this.#nickname}{/bold}${indicators} {|} ${dot} ${this.#onlineCount} online {white-fg}\u2502{/white-fg} {green-fg}E2E{/green-fg} `;
1177
+ }
1178
+
1179
+ setHeaderIndicator(key, label) {
1180
+ this.removeHeaderIndicator(key);
1181
+ this.#headerIndicators.push({ key, label });
1182
+ this.#updateHeader();
1183
+ }
1184
+
1185
+ removeHeaderIndicator(key) {
1186
+ this.#headerIndicators = this.#headerIndicators.filter((i) => i.key !== key);
1187
+ this.#updateHeader();
1188
+ }
1189
+
1190
+ #updateHeader() {
1191
+ this.#header.setContent(this.#headerContent());
1192
+ this.#screen.render();
1193
+ }
1194
+
1195
+ setOnlineCount(count) {
1196
+ this.#onlineCount = count;
1197
+ this.#updateHeader();
1198
+ }
1199
+
1200
+ // 'online' | 'reconnecting' | 'offline' โ€” recolors the header dot.
1201
+ setConnectionState(state) {
1202
+ const prev = this.#connState;
1203
+ this.#connState = state;
1204
+ if (state === 'reconnecting') {
1205
+ if (!this.#connSpinner) {
1206
+ this.#connSpinner = setInterval(() => {
1207
+ this.#spinnerFrame++;
1208
+ this.#updateHeader();
1209
+ }, 120);
1210
+ if (this.#connSpinner.unref) {
1211
+ this.#connSpinner.unref();
1212
+ }
1213
+ }
1214
+ } else if (this.#connSpinner) {
1215
+ clearInterval(this.#connSpinner);
1216
+ this.#connSpinner = null;
1217
+ }
1218
+ // Coming back online after being away โ†’ a brief green pulse.
1219
+ if (state === 'online' && prev && prev !== 'online') {
1220
+ this.#startReconnectFlash();
1221
+ }
1222
+ this.#updateHeader();
1223
+ }
1224
+
1225
+ #startReconnectFlash() {
1226
+ if (this.#reconnectFlashTimer) {
1227
+ clearInterval(this.#reconnectFlashTimer);
1228
+ }
1229
+ this.#reconnectFlashActive = true;
1230
+ this.#reconnectFlashFrame = 0;
1231
+ this.#reconnectFlashTimer = setInterval(() => {
1232
+ this.#reconnectFlashFrame++;
1233
+ if (this.#reconnectFlashFrame >= 8) {
1234
+ clearInterval(this.#reconnectFlashTimer);
1235
+ this.#reconnectFlashTimer = null;
1236
+ this.#reconnectFlashActive = false;
1237
+ }
1238
+ this.#updateHeader();
1239
+ }, 110);
1240
+ if (this.#reconnectFlashTimer.unref) {
1241
+ this.#reconnectFlashTimer.unref();
1242
+ }
1243
+ }
1244
+
1245
+ setNickname(nickname) {
1246
+ this.#nickname = nickname;
1247
+ this.#updateHeader();
1248
+ }
1249
+
1250
+ #statusContent() {
1251
+ const room = `{cyan-fg}#${this.#statusRoom}{/cyan-fg}`;
1252
+ const fp = this.#statusFingerprint
1253
+ ? ` {#8888aa-fg}๐Ÿ”‘ ${this.#statusFingerprint}{/#8888aa-fg}`
1254
+ : '';
1255
+ const hint =
1256
+ '{#7777aa-fg}Tab ยท Ctrl+K commands ยท Ctrl+E emoji ยท PgUp/PgDn scroll ยท /help ยท Ctrl+C quit{/#7777aa-fg}';
1257
+ return ` ${room}${fp} {|} ${hint} `;
1258
+ }
1259
+
1260
+ #updateStatusBar() {
1261
+ if (this.#statusBar) {
1262
+ this.#statusBar.setContent(this.#statusContent());
1263
+ this.#screen.render();
1264
+ }
1265
+ }
1266
+
1267
+ setFingerprint(fingerprint) {
1268
+ // short prefix of the fingerprint as a persistent identity anchor
1269
+ this.#statusFingerprint = (fingerprint || '').slice(0, 17);
1270
+ this.#updateStatusBar();
1271
+ }
1272
+
1273
+ setRoom(room) {
1274
+ this.#statusRoom = room || 'general';
1275
+ this.#updateStatusBar();
1276
+ }
1277
+
1278
+ // Render a real image inline by briefly leaving the TUI (kitty/iTerm2). Safe
1279
+ // best-effort: any keypress or a 30s timeout returns to the chat.
1280
+ showRealImage(escapeSeq) {
1281
+ let resumed = false;
1282
+ let timer = null;
1283
+ const onData = () => resume();
1284
+ const resume = () => {
1285
+ if (resumed) {
1286
+ return;
1287
+ }
1288
+ resumed = true;
1289
+ process.stdin.removeListener('data', onData);
1290
+ if (timer) {
1291
+ clearTimeout(timer);
1292
+ }
1293
+ try {
1294
+ this.#screen.enter();
1295
+ this.#screen.render();
1296
+ } catch {
1297
+ /* ignore */
1298
+ }
1299
+ };
1300
+
1301
+ try {
1302
+ this.#screen.leave();
1303
+ process.stdout.write(
1304
+ `\n${escapeSeq}\n\n [high-resolution image โ€” press Enter to return to chat]\n`,
1305
+ );
1306
+ process.stdin.on('data', onData);
1307
+ timer = setTimeout(resume, 30_000);
1308
+ if (timer.unref) {
1309
+ timer.unref();
1310
+ }
1311
+ } catch {
1312
+ resume();
1313
+ }
1314
+ }
1315
+
1316
+ addMessage(
1317
+ nickname,
1318
+ text,
1319
+ isDM = false,
1320
+ ephemeralLabel = null,
1321
+ deniable = false,
1322
+ mentioned = false,
1323
+ ) {
1324
+ this.#daySeparator();
1325
+
1326
+ const color = nickColor(nickname);
1327
+ const isSelf = nickname === this.#nickname || nickname.includes('\u2192');
1328
+ const tag = isSelf ? 'bold' : `${color}-fg`;
1329
+ const avatar = nickAvatar(isSelf ? this.#nickname : nickname);
1330
+ const dmLabel = isDM ? ' {magenta-fg}(DM){/magenta-fg}' : '';
1331
+ const ephLabel = ephemeralLabel ? ` {yellow-fg}[${ephemeralLabel}]{/yellow-fg}` : '';
1332
+ const denLabel = deniable ? ' {magenta-fg}[D]{/magenta-fg}' : '';
1333
+ const mentionMark = mentioned && !isSelf ? '{yellow-fg}\ud83d\udd14 {/yellow-fg}' : '';
1334
+
1335
+ // Consecutive messages from the same peer collapse the avatar/name into a
1336
+ // compact continuation bullet (cleaner layout).
1337
+ const grouped = !isSelf && !isDM && this.#lastSender === nickname;
1338
+ const core = grouped
1339
+ ? `{${tag}}\u00b7{/${tag}} ${renderMarkdown(text)}`
1340
+ : `${avatar} {${tag}}${nickname}{/${tag}}${dmLabel}: ${renderMarkdown(text)}`;
1341
+
1342
+ // My own messages on the right (timestamp at the end), others on the left
1343
+ const line = isSelf
1344
+ ? this.#alignRight(`${core}${ephLabel}${denLabel} {white-fg}[${time()}]{/white-fg}`)
1345
+ : ` {white-fg}[${time()}]{/white-fg}${ephLabel}${denLabel} ${mentionMark}${core}`;
1346
+
1347
+ this.#lines.push(line);
1348
+ this.#chatLog.log(line);
1349
+ this.#screen.render();
1350
+ this.#lastSender = isSelf ? 'self' : nickname;
1351
+ if (!isSelf) {
1352
+ this.#noteIncoming();
1353
+ }
1354
+ return { lineIndex: this.#lines.length - 1 };
1355
+ }
1356
+
1357
+ #daySeparator() {
1358
+ const today = new Date().toLocaleDateString('en-US');
1359
+ if (this.#lastMsgDate === today) {
1360
+ return;
1361
+ }
1362
+ this.#lastMsgDate = today;
1363
+ this.#lastSender = null;
1364
+ const sep = ` {#666666-fg}\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 ${today} \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500{/#666666-fg}`;
1365
+ this.#lines.push(sep);
1366
+ this.#chatLog.log(sep);
1367
+ }
1368
+
1369
+ // Visible width of a string with blessed tags (emoji ~2 columns)
1370
+ #visibleWidth(tagged) {
1371
+ const plain = tagged.replace(/\{[^{}]*\}/g, '');
1372
+ let width = 0;
1373
+ for (const chr of plain) {
1374
+ const cp = chr.codePointAt(0);
1375
+ width += cp > 0xffff || (cp >= 0x2600 && cp <= 0x27bf) ? 2 : 1;
1376
+ }
1377
+ return width;
1378
+ }
1379
+
1380
+ #alignRight(tagged) {
1381
+ const avail = (this.#chatLog.width || 0) - this.#chatLog.iwidth - 2;
1382
+ const visible = this.#visibleWidth(tagged);
1383
+ if (avail <= visible) {
1384
+ return ` ${tagged}`; // doesn't fit on one line \u2014 falls back to normal flow with wrap
1385
+ }
1386
+ return ' '.repeat(avail - visible) + tagged;
1387
+ }
1388
+
1389
+ // Appends a badge (e.g. \u2713\u2713) preserving right alignment:
1390
+ // shifts the padding instead of overflowing the width
1391
+ appendBadge(lineIndex, baseLine, badgeTagged) {
1392
+ const badgeWidth = this.#visibleWidth(badgeTagged) + 1;
1393
+ const leading = baseLine.match(/^ +/);
1394
+ const line =
1395
+ leading && leading[0].length > badgeWidth
1396
+ ? `${baseLine.slice(badgeWidth)} ${badgeTagged}`
1397
+ : `${baseLine} ${badgeTagged}`;
1398
+ this.updateLine(lineIndex, line);
1399
+ }
1400
+
1401
+ addSystemMessage(text) {
1402
+ this.#lastSender = null; // interrupts message grouping
1403
+ const line = ` {white-fg}[${time()}] * ${blessed.escape(text)}{/white-fg}`;
1404
+ this.#lines.push(line);
1405
+ this.#chatLog.log(line);
1406
+ this.#screen.render();
1407
+ this.#noteIncoming();
1408
+ }
1409
+
1410
+ addErrorMessage(text) {
1411
+ this.#lastSender = null;
1412
+ const line = ` {red-fg}[${time()}] ! ${blessed.escape(text)}{/red-fg}`;
1413
+ this.#lines.push(line);
1414
+ this.#chatLog.log(line);
1415
+ this.#screen.render();
1416
+ this.#noteIncoming();
1417
+ }
1418
+
1419
+ addInfoMessage(text) {
1420
+ this.#lastSender = null;
1421
+ const line = ` {cyan-fg}[${time()}] ${blessed.escape(text)}{/cyan-fg}`;
1422
+ this.#lines.push(line);
1423
+ this.#chatLog.log(line);
1424
+ this.#screen.render();
1425
+ }
1426
+
1427
+ addQuoteLine(nickname, excerpt, alignRight = false) {
1428
+ const quoted = `{#888888-fg}โ†ฉ ${blessed.escape(nickname)}: "${blessed.escape(excerpt)}"{/#888888-fg}`;
1429
+ const line = alignRight ? this.#alignRight(quoted) : ` ${quoted}`;
1430
+ this.#lines.push(line);
1431
+ this.#chatLog.log(line);
1432
+ this.#screen.render();
1433
+ }
1434
+
1435
+ addPlainLines(rawLines) {
1436
+ for (const raw of rawLines) {
1437
+ const line = ` ${blessed.escape(raw)}`;
1438
+ this.#lines.push(line);
1439
+ this.#chatLog.log(line);
1440
+ }
1441
+ this.#screen.render();
1442
+ }
1443
+
1444
+ // Lines already carry blessed color tags โ€” do not escape
1445
+ addImagePreview(taggedLines) {
1446
+ for (const raw of taggedLines) {
1447
+ const line = ` ${raw}`;
1448
+ this.#lines.push(line);
1449
+ this.#chatLog.log(line);
1450
+ }
1451
+ this.#screen.render();
1452
+ }
1453
+
1454
+ // blessed's Log wrapper wrongly resets _userScrolled (getScrollPerc runs
1455
+ // with the previous frame's layout and returns 100) โ€” without the flag, every
1456
+ // new line pulls the view to the bottom. Recompute with the already-rendered layout.
1457
+ #syncScrollState() {
1458
+ const visible = this.#chatLog.height - this.#chatLog.iheight;
1459
+ const canScroll = this.#chatLog.getScrollHeight() > visible;
1460
+ const scrolledUp = canScroll && this.#chatLog.getScrollPerc() < 100;
1461
+ this.#chatLog._userScrolled = scrolledUp;
1462
+
1463
+ if (scrolledUp !== this.#scrolledUp) {
1464
+ this.#scrolledUp = scrolledUp;
1465
+ if (!scrolledUp) {
1466
+ this.#unseenCount = 0; // back at the bottom โ€” everything is seen
1467
+ }
1468
+ this.#refreshScrollIndicator();
1469
+ }
1470
+ this.#scrolledUp = scrolledUp;
1471
+ }
1472
+
1473
+ // Count a fresh arrival while the user is reading history, and pulse the
1474
+ // "new messages" pill so they know to page down.
1475
+ #noteIncoming() {
1476
+ if (this.#scrolledUp) {
1477
+ this.#unseenCount++;
1478
+ this.#refreshScrollIndicator();
1479
+ }
1480
+ }
1481
+
1482
+ #refreshScrollIndicator() {
1483
+ if (!this.#scrolledUp) {
1484
+ this.#stopPill();
1485
+ this.removeHeaderIndicator('scroll');
1486
+ return;
1487
+ }
1488
+ if (this.#unseenCount > 0) {
1489
+ if (!this.#pillTimer && process.stdout.isTTY) {
1490
+ this.#pillTimer = setInterval(() => {
1491
+ this.#pillFrame++;
1492
+ this.#renderPill();
1493
+ }, 450);
1494
+ if (this.#pillTimer.unref) {
1495
+ this.#pillTimer.unref();
1496
+ }
1497
+ }
1498
+ this.#renderPill();
1499
+ } else {
1500
+ this.#stopPill();
1501
+ this.setHeaderIndicator('scroll', '{yellow-fg}[โ†‘ history โ€” PageDown to return]{/yellow-fg}');
1502
+ }
1503
+ }
1504
+
1505
+ #renderPill() {
1506
+ const n = this.#unseenCount;
1507
+ const plural = n === 1 ? 'new message' : 'new messages';
1508
+ const bright = this.#pillFrame % 2 === 0;
1509
+ const label = bright
1510
+ ? `{black-fg}{yellow-bg} โ†“ ${n} ${plural} โ€” PageDown {/yellow-bg}{/black-fg}`
1511
+ : `{yellow-fg}[โ†“ ${n} ${plural} โ€” PageDown]{/yellow-fg}`;
1512
+ this.setHeaderIndicator('scroll', label);
1513
+ }
1514
+
1515
+ #stopPill() {
1516
+ if (this.#pillTimer) {
1517
+ clearInterval(this.#pillTimer);
1518
+ this.#pillTimer = null;
1519
+ }
1520
+ }
1521
+
1522
+ getLine(lineIndex) {
1523
+ if (lineIndex < 0 || lineIndex >= this.#lines.length) {
1524
+ return null;
1525
+ }
1526
+ return this.#lines[lineIndex];
1527
+ }
1528
+
1529
+ updateLine(lineIndex, newLine) {
1530
+ if (lineIndex < 0 || lineIndex >= this.#lines.length) {
1531
+ return;
1532
+ }
1533
+ if (this.#lines[lineIndex] === null) {
1534
+ return;
1535
+ }
1536
+ this.#lines[lineIndex] = newLine;
1537
+ const content = this.#lines.filter((l) => l !== null).join('\n');
1538
+ this.#chatLog.setContent(content);
1539
+ if (!this.#scrolledUp) {
1540
+ this.#chatLog.setScrollPerc(100);
1541
+ }
1542
+ this.#screen.render();
1543
+ }
1544
+
1545
+ removeLine(lineIndex) {
1546
+ if (lineIndex < 0 || lineIndex >= this.#lines.length) {
1547
+ return;
1548
+ }
1549
+ this.#lines[lineIndex] = null;
1550
+ const content = this.#lines.filter((l) => l !== null).join('\n');
1551
+ this.#chatLog.setContent(content);
1552
+ if (!this.#scrolledUp) {
1553
+ this.#chatLog.setScrollPerc(100);
1554
+ }
1555
+ this.#screen.render();
1556
+ }
1557
+
1558
+ // Ephemeral messages don't just vanish โ€” they burn. A flame front sweeps the
1559
+ // text leftโ†’right (ignite โ†’ hot โ†’ ember โ†’ ash โ†’ gone), then the line is
1560
+ // removed. Non-TTY falls back to an instant removeLine.
1561
+ burnLine(lineIndex, onDone) {
1562
+ const orig = this.getLine(lineIndex);
1563
+ if (orig === null || orig === undefined) {
1564
+ onDone?.();
1565
+ return null;
1566
+ }
1567
+
1568
+ // Strip blessed tags to get the raw glyphs, preserving leading padding
1569
+ // (right-aligned self messages) so the flame stays under the text.
1570
+ const plain = orig.replace(/\{[^{}]*\}/g, '');
1571
+ const lead = (plain.match(/^ */) || [''])[0];
1572
+ const body = [...plain.slice(lead.length)];
1573
+ const len = body.length;
1574
+
1575
+ const bodyStr = plain.slice(lead.length);
1576
+ if (!process.stdout.isTTY || len === 0) {
1577
+ this.removeLine(lineIndex);
1578
+ onDone?.();
1579
+ return null;
1580
+ }
1581
+
1582
+ const TOTAL_FRAMES = 16;
1583
+ const advance = Math.max(1, (len + BURN_TAIL) / TOTAL_FRAMES);
1584
+ let front = 0;
1585
+
1586
+ const timer = setInterval(() => {
1587
+ front += advance;
1588
+ this.updateLine(lineIndex, lead + burnFrame(bodyStr, front));
1589
+ if (front >= len + BURN_TAIL) {
1590
+ clearInterval(timer);
1591
+ this.removeLine(lineIndex);
1592
+ onDone?.();
1593
+ }
1594
+ }, 45);
1595
+ if (timer.unref) {
1596
+ timer.unref();
1597
+ }
1598
+ return timer;
1599
+ }
1600
+
1601
+ clearChat() {
1602
+ this.#lines = [];
1603
+ this.#lastSender = null;
1604
+ this.#lastMsgDate = null;
1605
+ this.#chatLog.setContent('');
1606
+ this.#chatLog.setScroll(0);
1607
+ this.#syncScrollState();
1608
+ // Force a full repaint so no cells from the old (right-aligned, padded)
1609
+ // lines are left as artifacts on the screen.
1610
+ this.#screen.realloc();
1611
+ this.#screen.render();
1612
+ }
1613
+
1614
+ // A single, in-place progress line with a moving shimmer and a live ETA.
1615
+ // Successive calls update the same line; a new transfer (percent goes
1616
+ // backwards, or after a finish) starts a fresh line.
1617
+ updateProgress(text, percent) {
1618
+ const pct = Math.max(0, Math.min(100, Math.round(percent)));
1619
+ const restart = this.#progIndex === null || pct < this.#progPercent;
1620
+ if (restart) {
1621
+ this.#lines.push('');
1622
+ this.#chatLog.log('');
1623
+ this.#progIndex = this.#lines.length - 1;
1624
+ this.#progStart = Date.now();
1625
+ this.#ensureShimmer();
1626
+ }
1627
+ this.#progPercent = pct;
1628
+ this.#progText = text;
1629
+ this.#renderProgress();
1630
+ if (pct >= 100) {
1631
+ this.#progIndex = null;
1632
+ this.#stopShimmer();
1633
+ }
1634
+ }
1635
+
1636
+ // Guaranteed terminator โ€” call when a transfer ends (complete/reject/error)
1637
+ // so the bar settles and the shimmer stops even if 100% was never delivered.
1638
+ finishProgress() {
1639
+ if (this.#progIndex !== null) {
1640
+ this.#progPercent = 100;
1641
+ this.#renderProgress();
1642
+ this.#progIndex = null;
1643
+ }
1644
+ this.#stopShimmer();
1645
+ }
1646
+
1647
+ #ensureShimmer() {
1648
+ if (this.#shimmerTimer || !process.stdout.isTTY) {
1649
+ return;
1650
+ }
1651
+ this.#shimmerTimer = setInterval(() => {
1652
+ this.#shimmerPos++;
1653
+ this.#renderProgress();
1654
+ }, 90);
1655
+ if (this.#shimmerTimer.unref) {
1656
+ this.#shimmerTimer.unref();
1657
+ }
1658
+ }
1659
+
1660
+ #stopShimmer() {
1661
+ if (this.#shimmerTimer) {
1662
+ clearInterval(this.#shimmerTimer);
1663
+ this.#shimmerTimer = null;
1664
+ }
1665
+ }
1666
+
1667
+ #renderProgress() {
1668
+ if (this.#progIndex === null) {
1669
+ return;
1670
+ }
1671
+ const pct = this.#progPercent;
1672
+ const done = pct >= 100;
1673
+ const bar = progressBar(pct, this.#shimmerPos);
1674
+ const head = done ? '{green-fg}โœ“{/green-fg}' : '{#00b8ff-fg}โ‡…{/#00b8ff-fg}';
1675
+ const eta = formatETA(Date.now() - this.#progStart, pct);
1676
+ const tail = done
1677
+ ? '{green-fg}100% complete{/green-fg}'
1678
+ : `{white-fg}${pct}%{/white-fg}${eta ? ` {#888888-fg}${eta}{/#888888-fg}` : ''}`;
1679
+ const line = ` {white-fg}[${time()}]{/white-fg} ${head} ${blessed.escape(this.#progText)} [${bar}] ${tail}`;
1680
+ this.updateLine(this.#progIndex, line);
1681
+ }
1682
+
1683
+ // A brief "secure channel" flourish when a peer's E2E link is established:
1684
+ // a spark travels between the two nicks, then a lock snaps shut.
1685
+ handshakeConnect(peerNickname) {
1686
+ const me = blessed.escape(this.#nickname);
1687
+ const peer = blessed.escape(peerNickname);
1688
+ const label = (mid) =>
1689
+ ` {bold}{#00b8ff-fg}${me}{/#00b8ff-fg}{/bold} ${mid} {bold}{#7b2dff-fg}${peer}{/#7b2dff-fg}{/bold}`;
1690
+ const done = ` {green-fg}๐Ÿ”’ Secure channel with {bold}${peer}{/bold} โ€” E2E established{/green-fg}`;
1691
+
1692
+ if (!process.stdout.isTTY) {
1693
+ this.#lastSender = null;
1694
+ this.#lines.push(done);
1695
+ this.#chatLog.log(done);
1696
+ this.#screen.render();
1697
+ return;
1698
+ }
1699
+
1700
+ this.#lastSender = null;
1701
+ this.#lines.push('');
1702
+ const idx = this.#lines.length - 1;
1703
+ const W = 11;
1704
+ const total = W + 6;
1705
+ let f = 0;
1706
+ const timer = setInterval(() => {
1707
+ let mid;
1708
+ if (f < W) {
1709
+ let track = '';
1710
+ for (let i = 0; i < W; i++) {
1711
+ if (i === f) {
1712
+ track += '{#ffd000-fg}โ—†{/#ffd000-fg}';
1713
+ } else if (i < f) {
1714
+ track += '{#00b8ff-fg}โ”€{/#00b8ff-fg}';
1715
+ } else {
1716
+ track += '{#333333-fg}โ”€{/#333333-fg}';
1717
+ }
1718
+ }
1719
+ mid = track;
1720
+ } else {
1721
+ const lock = (f - W) % 2 === 0 ? '{#ffd000-fg}๐Ÿ”“{/#ffd000-fg}' : '{green-fg}๐Ÿ”’{/green-fg}';
1722
+ mid = `{#00b8ff-fg}โ•โ•โ•โ•โ•{/#00b8ff-fg}${lock}{#00b8ff-fg}โ•โ•โ•โ•โ•{/#00b8ff-fg}`;
1723
+ }
1724
+ this.updateLine(idx, label(mid));
1725
+ f++;
1726
+ if (f >= total) {
1727
+ clearInterval(timer);
1728
+ this.updateLine(idx, done);
1729
+ }
1730
+ }, 60);
1731
+ if (timer.unref) {
1732
+ timer.unref();
1733
+ }
1734
+ }
1735
+
1736
+ // The mirror of handshakeConnect: the secure link weakens and the lock opens
1737
+ // when a peer leaves.
1738
+ handshakeDisconnect(peerNickname) {
1739
+ const me = blessed.escape(this.#nickname);
1740
+ const peer = blessed.escape(peerNickname);
1741
+ const label = (mid) =>
1742
+ ` {bold}{#00b8ff-fg}${me}{/#00b8ff-fg}{/bold} ${mid} {bold}{#7b2dff-fg}${peer}{/#7b2dff-fg}{/bold}`;
1743
+ const done = ` {#888888-fg}๐Ÿ”“ {bold}${peer}{/bold} left โ€” channel closed{/#888888-fg}`;
1744
+
1745
+ if (!process.stdout.isTTY) {
1746
+ this.#lastSender = null;
1747
+ this.#lines.push(done);
1748
+ this.#chatLog.log(done);
1749
+ this.#screen.render();
1750
+ return;
1751
+ }
1752
+
1753
+ this.#lastSender = null;
1754
+ this.#lines.push('');
1755
+ const idx = this.#lines.length - 1;
1756
+ const W = 11;
1757
+ const mid_i = Math.floor(W / 2);
1758
+ const total = W + 6;
1759
+ let f = 0;
1760
+ const timer = setInterval(() => {
1761
+ let mid;
1762
+ if (f < W) {
1763
+ // The link dims from both ends inward; the lock stays shut for now.
1764
+ let track = '';
1765
+ for (let i = 0; i < W; i++) {
1766
+ if (i === mid_i) {
1767
+ track += '{green-fg}๐Ÿ”’{/green-fg}';
1768
+ } else {
1769
+ const distFromEnd = Math.min(i, W - 1 - i);
1770
+ track +=
1771
+ distFromEnd < f / 2 ? '{#333333-fg}ยท{/#333333-fg}' : '{#00b8ff-fg}โ”€{/#00b8ff-fg}';
1772
+ }
1773
+ }
1774
+ mid = track;
1775
+ } else {
1776
+ // The lock clicks open.
1777
+ const lock =
1778
+ (f - W) % 2 === 0 ? '{#ffd000-fg}๐Ÿ”“{/#ffd000-fg}' : '{#888888-fg}๐Ÿ”“{/#888888-fg}';
1779
+ mid = `{#333333-fg}ยทยทยทยทยท{/#333333-fg}${lock}{#333333-fg}ยทยทยทยทยท{/#333333-fg}`;
1780
+ }
1781
+ this.updateLine(idx, label(mid));
1782
+ f++;
1783
+ if (f >= total) {
1784
+ clearInterval(timer);
1785
+ this.updateLine(idx, done);
1786
+ }
1787
+ }, 60);
1788
+ if (timer.unref) {
1789
+ timer.unref();
1790
+ }
1791
+ }
1792
+
1793
+ // โ”€โ”€ Sound notifications โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1794
+ get soundEnabled() {
1795
+ return this.#soundEnabled;
1796
+ }
1797
+
1798
+ setSoundEnabled(enabled) {
1799
+ this.#soundEnabled = enabled;
1800
+ }
1801
+
1802
+ get notifyEnabled() {
1803
+ return this.#notifyEnabled;
1804
+ }
1805
+
1806
+ setNotifyEnabled(enabled) {
1807
+ this.#notifyEnabled = enabled;
1808
+ }
1809
+
1810
+ playNotification() {
1811
+ if (this.#soundEnabled) {
1812
+ process.stdout.write('\x07');
1813
+ }
1814
+ }
1815
+
1816
+ destroy() {
1817
+ if (this.#typingAnimInterval) {
1818
+ clearInterval(this.#typingAnimInterval);
1819
+ }
1820
+ if (this.#connSpinner) {
1821
+ clearInterval(this.#connSpinner);
1822
+ }
1823
+ if (this.#reconnectFlashTimer) {
1824
+ clearInterval(this.#reconnectFlashTimer);
1825
+ }
1826
+ this.#stopShimmer();
1827
+ this.#stopPill();
1828
+ this.#screen.destroy();
1829
+ }
1830
+ }