ciphermesh 2.13.0 → 2.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -0
- package/README.md +4 -3
- package/README.pt-BR.md +4 -3
- package/docs/ARCHITECTURE.md +321 -225
- package/docs/SETUP.md +18 -0
- package/docs/commands.json +2 -2
- package/package.json +2 -2
- package/src/client/ChatController.js +22 -4
- package/src/client/UI.js +617 -124
- package/src/client/keyboard.js +388 -0
- package/src/p2p/P2PChatController.js +21 -4
- package/src/shared/desktopNotify.js +204 -0
- package/src/shared/notifyWorker.js +46 -0
- package/src/shared/tips.js +1 -0
package/src/client/UI.js
CHANGED
|
@@ -4,6 +4,7 @@ import { shortcodeSuggestions } from '../shared/emoji.js';
|
|
|
4
4
|
import { nickPalette } from '../shared/themes.js';
|
|
5
5
|
import { fuzzyFilter } from '../shared/fuzzy.js';
|
|
6
6
|
import { EMOJI_MAP } from '../shared/constants.js';
|
|
7
|
+
import { EnhancedInput, KEY_PROTOCOL_ENABLE, KEY_PROTOCOL_DISABLE } from './keyboard.js';
|
|
7
8
|
|
|
8
9
|
const EMOJI_ENTRIES = Object.entries(EMOJI_MAP); // [':name:', '😀']
|
|
9
10
|
|
|
@@ -11,6 +12,8 @@ const NICK_AVATARS = ['😀', '😎', '🤠', '🤖', '👻', '👽', '🦊', '
|
|
|
11
12
|
const TYPING_DOTS = ['', '.', '..', '...'];
|
|
12
13
|
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
13
14
|
const INPUT_MAX_LINES = 8; // input box grows up to this many text lines
|
|
15
|
+
const MAX_TEXT_WIDTH = 78; // widest a message body is allowed to run
|
|
16
|
+
const BODY_WIDTH_RATIO = 0.65; // ...and never more than this share of the window
|
|
14
17
|
|
|
15
18
|
// Command → one-line description for the Ctrl+K fuzzy command palette.
|
|
16
19
|
const COMMAND_INFO = [
|
|
@@ -348,7 +351,12 @@ export function findInLines(lines, query, maxHits = 200) {
|
|
|
348
351
|
continue;
|
|
349
352
|
}
|
|
350
353
|
const start = Math.max(0, at - 24);
|
|
351
|
-
|
|
354
|
+
// An entry is a whole message block now, so the slice can straddle a line
|
|
355
|
+
// break — flatten it, or the picker's rows would run into each other.
|
|
356
|
+
const slice = plain
|
|
357
|
+
.slice(start, start + 76)
|
|
358
|
+
.replace(/\s+/g, ' ')
|
|
359
|
+
.trim();
|
|
352
360
|
hits.push({
|
|
353
361
|
lineIndex: i,
|
|
354
362
|
preview: `${start > 0 ? '…' : ''}${blessed.escape(slice)}`,
|
|
@@ -357,6 +365,192 @@ export function findInLines(lines, query, maxHits = 200) {
|
|
|
357
365
|
return hits;
|
|
358
366
|
}
|
|
359
367
|
|
|
368
|
+
// Code points that occupy two terminal cells: the East Asian Wide/Fullwidth
|
|
369
|
+
// blocks plus the handful of BMP symbols with emoji presentation by default.
|
|
370
|
+
// Listing them beats "everything in 0x2600–0x27bf is wide", which counted ✓, ✗,
|
|
371
|
+
// ▎, ✦ and ↩ as two cells each and left every line carrying one a few columns
|
|
372
|
+
// short of where it was aimed.
|
|
373
|
+
const WIDE_RANGES = [
|
|
374
|
+
[0x1100, 0x115f],
|
|
375
|
+
[0x231a, 0x231b],
|
|
376
|
+
[0x2329, 0x232a],
|
|
377
|
+
[0x23e9, 0x23ec],
|
|
378
|
+
[0x23f0, 0x23f0],
|
|
379
|
+
[0x23f3, 0x23f3],
|
|
380
|
+
[0x25fd, 0x25fe],
|
|
381
|
+
[0x2614, 0x2615],
|
|
382
|
+
[0x2648, 0x2653],
|
|
383
|
+
[0x267f, 0x267f],
|
|
384
|
+
[0x2693, 0x2693],
|
|
385
|
+
[0x26a1, 0x26a1],
|
|
386
|
+
[0x26aa, 0x26ab],
|
|
387
|
+
[0x26bd, 0x26be],
|
|
388
|
+
[0x26c4, 0x26c5],
|
|
389
|
+
[0x26ce, 0x26ce],
|
|
390
|
+
[0x26d4, 0x26d4],
|
|
391
|
+
[0x26ea, 0x26ea],
|
|
392
|
+
[0x26f2, 0x26f3],
|
|
393
|
+
[0x26f5, 0x26f5],
|
|
394
|
+
[0x26fa, 0x26fa],
|
|
395
|
+
[0x26fd, 0x26fd],
|
|
396
|
+
[0x2705, 0x2705],
|
|
397
|
+
[0x270a, 0x270b],
|
|
398
|
+
[0x2728, 0x2728],
|
|
399
|
+
[0x274c, 0x274c],
|
|
400
|
+
[0x274e, 0x274e],
|
|
401
|
+
[0x2753, 0x2755],
|
|
402
|
+
[0x2757, 0x2757],
|
|
403
|
+
[0x2795, 0x2797],
|
|
404
|
+
[0x27b0, 0x27b0],
|
|
405
|
+
[0x27bf, 0x27bf],
|
|
406
|
+
[0x2e80, 0x303e],
|
|
407
|
+
[0x3041, 0x4dbf],
|
|
408
|
+
[0x4e00, 0xa4cf],
|
|
409
|
+
[0xac00, 0xd7a3],
|
|
410
|
+
[0xf900, 0xfaff],
|
|
411
|
+
[0xfe10, 0xfe19],
|
|
412
|
+
[0xfe30, 0xfe6f],
|
|
413
|
+
[0xff00, 0xff60],
|
|
414
|
+
[0xffe0, 0xffe6],
|
|
415
|
+
];
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Visible width of one code point, in terminal cells. Shared by the wrapper and
|
|
419
|
+
* the alignment helpers so a line is measured the same way wherever it is
|
|
420
|
+
* measured. Pure and exported for testing.
|
|
421
|
+
*/
|
|
422
|
+
export function glyphWidth(codePoint) {
|
|
423
|
+
// Combining marks, variation selectors and the zero-width joiner ride along
|
|
424
|
+
// with the glyph before them.
|
|
425
|
+
if (
|
|
426
|
+
(codePoint >= 0x0300 && codePoint <= 0x036f) ||
|
|
427
|
+
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
|
|
428
|
+
codePoint === 0x200d
|
|
429
|
+
) {
|
|
430
|
+
return 0;
|
|
431
|
+
}
|
|
432
|
+
if (codePoint > 0xffff) {
|
|
433
|
+
return 2; // emoji and the astral CJK planes
|
|
434
|
+
}
|
|
435
|
+
for (const [lo, hi] of WIDE_RANGES) {
|
|
436
|
+
if (codePoint >= lo && codePoint <= hi) {
|
|
437
|
+
return 2;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return 1;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Split a blessed-tagged string into zero-width tag tokens and one-glyph text
|
|
444
|
+
// tokens. `{open}`/`{close}` are blessed's escapes for literal braces, so they
|
|
445
|
+
// look like tags but occupy a cell.
|
|
446
|
+
function tokenizeTagged(text) {
|
|
447
|
+
const tokens = [];
|
|
448
|
+
const pushText = (chunk) => {
|
|
449
|
+
for (const chr of chunk) {
|
|
450
|
+
tokens.push({ text: chr, width: glyphWidth(chr.codePointAt(0)) });
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
let pos = 0;
|
|
454
|
+
for (const match of String(text).matchAll(/\{[^{}]*\}/g)) {
|
|
455
|
+
pushText(String(text).slice(pos, match.index));
|
|
456
|
+
const inner = match[0].slice(1, -1);
|
|
457
|
+
if (inner === 'open' || inner === 'close') {
|
|
458
|
+
tokens.push({ text: match[0], width: 1 });
|
|
459
|
+
} else {
|
|
460
|
+
tokens.push({ text: match[0], width: 0, tag: inner });
|
|
461
|
+
}
|
|
462
|
+
pos = match.index + match[0].length;
|
|
463
|
+
}
|
|
464
|
+
pushText(String(text).slice(pos));
|
|
465
|
+
return tokens;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// The stack of open tags after each token, so a line can be cut anywhere and
|
|
469
|
+
// still be closed and reopened correctly.
|
|
470
|
+
function tagStacks(tokens) {
|
|
471
|
+
const stacks = [];
|
|
472
|
+
let open = [];
|
|
473
|
+
for (const token of tokens) {
|
|
474
|
+
if (token.tag) {
|
|
475
|
+
if (token.tag.startsWith('/')) {
|
|
476
|
+
const name = token.tag.slice(1);
|
|
477
|
+
const at = open.lastIndexOf(name);
|
|
478
|
+
open = at === -1 ? open.slice(0, -1) : open.filter((_, i) => i !== at);
|
|
479
|
+
} else {
|
|
480
|
+
open = [...open, token.tag];
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
stacks.push(open);
|
|
484
|
+
}
|
|
485
|
+
return stacks;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function sliceTagged(tokens, stacks, from, to) {
|
|
489
|
+
const opens = (from === 0 ? [] : stacks[from - 1]).map((t) => `{${t}}`).join('');
|
|
490
|
+
const closes = (to === 0 ? [] : stacks[to - 1])
|
|
491
|
+
.map((t) => `{/${t}}`)
|
|
492
|
+
.reverse()
|
|
493
|
+
.join('');
|
|
494
|
+
let body = '';
|
|
495
|
+
for (let i = from; i < to; i++) {
|
|
496
|
+
body += tokens[i].text;
|
|
497
|
+
}
|
|
498
|
+
return opens + body + closes;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Word-wrap a string that already carries blessed tags.
|
|
503
|
+
*
|
|
504
|
+
* Wrapping after the markdown pass rather than before it is deliberate: a
|
|
505
|
+
* `**bold**` span that straddles the wrap point would otherwise be split into
|
|
506
|
+
* two halves that no longer match, and the asterisks would show. The price is
|
|
507
|
+
* that tags have to be handled properly — they are zero-width, must never be
|
|
508
|
+
* cut in half, and blessed carries its attribute stack across the whole
|
|
509
|
+
* content, so a tag left open at a break would bleed into the next line's
|
|
510
|
+
* gutter. Every open tag is therefore closed at the break and reopened after it.
|
|
511
|
+
*
|
|
512
|
+
* Pure and exported for testing.
|
|
513
|
+
*
|
|
514
|
+
* @returns {string[]} one tagged string per visual line, never empty
|
|
515
|
+
*/
|
|
516
|
+
export function wrapTagged(tagged, width) {
|
|
517
|
+
const limit = Math.max(4, Math.floor(width) || 4);
|
|
518
|
+
const lines = [];
|
|
519
|
+
for (const paragraph of String(tagged).split('\n')) {
|
|
520
|
+
const tokens = tokenizeTagged(paragraph);
|
|
521
|
+
const stacks = tagStacks(tokens);
|
|
522
|
+
let start = 0;
|
|
523
|
+
let used = 0;
|
|
524
|
+
let lastSpace = -1;
|
|
525
|
+
|
|
526
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
527
|
+
const token = tokens[i];
|
|
528
|
+
if (token.width === 0) {
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
if (token.text === ' ') {
|
|
532
|
+
lastSpace = i;
|
|
533
|
+
}
|
|
534
|
+
if (used + token.width <= limit || used === 0) {
|
|
535
|
+
used += token.width;
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
// Break before this glyph, at the last space if the line has one — a word
|
|
539
|
+
// longer than the whole line is cut where it stands instead.
|
|
540
|
+
const cut = lastSpace > start ? lastSpace : i;
|
|
541
|
+
lines.push(sliceTagged(tokens, stacks, start, cut));
|
|
542
|
+
start = lastSpace > start ? lastSpace + 1 : i;
|
|
543
|
+
used = 0;
|
|
544
|
+
for (let j = start; j <= i; j++) {
|
|
545
|
+
used += tokens[j].width;
|
|
546
|
+
}
|
|
547
|
+
lastSpace = -1;
|
|
548
|
+
}
|
|
549
|
+
lines.push(sliceTagged(tokens, stacks, start, tokens.length));
|
|
550
|
+
}
|
|
551
|
+
return lines;
|
|
552
|
+
}
|
|
553
|
+
|
|
360
554
|
// Sanitizes pasted text while PRESERVING its line structure — the input box is
|
|
361
555
|
// multi-line and fenced code blocks render in markdown, so pasted code must
|
|
362
556
|
// keep its newlines. Normalizes CRLF/CR, turns tabs into spaces and strips the
|
|
@@ -503,14 +697,19 @@ export class UI extends EventEmitter {
|
|
|
503
697
|
#typingAnimFrame;
|
|
504
698
|
#soundEnabled;
|
|
505
699
|
#notifyEnabled;
|
|
700
|
+
#keyInput;
|
|
506
701
|
#peerNames;
|
|
507
702
|
#tabState;
|
|
508
703
|
#lines;
|
|
704
|
+
#specs; // per-entry recipe, index-aligned with #lines (null = as-drawn)
|
|
509
705
|
#headerIndicators;
|
|
510
706
|
#scrolledUp;
|
|
511
707
|
#connState;
|
|
512
708
|
#lastMsgDate;
|
|
513
709
|
#lastSender;
|
|
710
|
+
#lastStamp;
|
|
711
|
+
#suppressSeparator;
|
|
712
|
+
#resizeTimer;
|
|
514
713
|
#pasting;
|
|
515
714
|
#pasteBuffer;
|
|
516
715
|
#lastPaste;
|
|
@@ -544,6 +743,7 @@ export class UI extends EventEmitter {
|
|
|
544
743
|
#lockError;
|
|
545
744
|
#lockVerify;
|
|
546
745
|
#bufferLines; // Map<room, lines[]> — stored content of INACTIVE buffers
|
|
746
|
+
#bufferSpecs; // Map<room, specs[]> — their recipes, same indexes
|
|
547
747
|
#activeBuffer; // name of the buffer currently on screen
|
|
548
748
|
#redirecting; // true while add* calls are being written to an inactive buffer
|
|
549
749
|
#bufferBar; // [{ room, active, unread, private }] for the status bar
|
|
@@ -554,13 +754,24 @@ export class UI extends EventEmitter {
|
|
|
554
754
|
#finderHits; // [{ lineIndex, preview }]
|
|
555
755
|
#finderMark; // line index currently highlighted by a jump
|
|
556
756
|
|
|
557
|
-
|
|
757
|
+
/**
|
|
758
|
+
* @param {string} nickname
|
|
759
|
+
* @param {{ input?: NodeJS.ReadableStream, output?: NodeJS.WritableStream }} [io]
|
|
760
|
+
* Streams for blessed to drive instead of the real terminal. The only
|
|
761
|
+
* reason this exists is tests: with a writable that reports `isTTY` and a
|
|
762
|
+
* `columns`, the whole layout — wrapping, alignment, relayout on resize —
|
|
763
|
+
* can be exercised headlessly instead of only by eye.
|
|
764
|
+
*/
|
|
765
|
+
constructor(nickname, io = {}) {
|
|
558
766
|
super();
|
|
559
767
|
this.#nickname = nickname;
|
|
560
768
|
this.#onlineCount = 1;
|
|
561
769
|
this.#connState = 'online';
|
|
562
770
|
this.#lastMsgDate = null;
|
|
563
771
|
this.#lastSender = null;
|
|
772
|
+
this.#lastStamp = null;
|
|
773
|
+
this.#suppressSeparator = false;
|
|
774
|
+
this.#resizeTimer = null;
|
|
564
775
|
this.#pasting = false;
|
|
565
776
|
this.#pasteBuffer = '';
|
|
566
777
|
this.#lastPaste = { content: '', time: 0 };
|
|
@@ -575,6 +786,7 @@ export class UI extends EventEmitter {
|
|
|
575
786
|
this.#peerNames = [];
|
|
576
787
|
this.#tabState = { suggestions: [], index: -1, original: '' };
|
|
577
788
|
this.#lines = [];
|
|
789
|
+
this.#specs = [];
|
|
578
790
|
this.#headerIndicators = [];
|
|
579
791
|
this.#scrolledUp = false;
|
|
580
792
|
this.#statusFingerprint = '';
|
|
@@ -603,6 +815,7 @@ export class UI extends EventEmitter {
|
|
|
603
815
|
this.#lockError = false;
|
|
604
816
|
this.#lockVerify = null;
|
|
605
817
|
this.#bufferLines = new Map();
|
|
818
|
+
this.#bufferSpecs = new Map();
|
|
606
819
|
this.#activeBuffer = 'general';
|
|
607
820
|
this.#redirecting = false;
|
|
608
821
|
this.#bufferBar = [];
|
|
@@ -619,11 +832,23 @@ export class UI extends EventEmitter {
|
|
|
619
832
|
// use their own escape sequences, not blessed — so pin tput to xterm-256color
|
|
620
833
|
// and sidestep the broken capability.
|
|
621
834
|
const term = process.env.TERM || '';
|
|
835
|
+
|
|
836
|
+
// Shift+Enter only exists if the terminal is asked for it, and the reports
|
|
837
|
+
// that come back are unparseable by blessed (see ./keyboard.js). The shim
|
|
838
|
+
// takes them off the raw stream before blessed ever sees them; set
|
|
839
|
+
// CIPHERMESH_LEGACY_KEYS=1 to hand blessed the tty untouched.
|
|
840
|
+
this.#keyInput =
|
|
841
|
+
io.input || process.env.CIPHERMESH_LEGACY_KEYS === '1' || !process.stdin.isTTY
|
|
842
|
+
? null
|
|
843
|
+
: new EnhancedInput(process.stdin, () => this.#onEnhancedNewline());
|
|
844
|
+
|
|
622
845
|
this.#screen = blessed.screen({
|
|
623
846
|
smartCSR: true,
|
|
624
847
|
fullUnicode: true, // renders emojis and characters outside the BMP
|
|
625
848
|
title: 'CipherMesh',
|
|
626
849
|
terminal: /ghostty/i.test(term) ? 'xterm-256color' : undefined,
|
|
850
|
+
input: this.#keyInput || io.input || undefined,
|
|
851
|
+
output: io.output || undefined,
|
|
627
852
|
});
|
|
628
853
|
|
|
629
854
|
// ── Header ──────────────────────────────────────────
|
|
@@ -768,9 +993,10 @@ export class UI extends EventEmitter {
|
|
|
768
993
|
return;
|
|
769
994
|
}
|
|
770
995
|
|
|
771
|
-
//
|
|
772
|
-
//
|
|
773
|
-
//
|
|
996
|
+
// Normally the shim has already turned these into a newline upstream. This
|
|
997
|
+
// is the CIPHERMESH_LEGACY_KEYS path, where a terminal configured by hand
|
|
998
|
+
// to emit \x1b[13;2u (VS Code's sendSequence, say) still works — blessed
|
|
999
|
+
// only delivers them intact when it happens to keep the sequence whole.
|
|
774
1000
|
if (seq === '\x1b[13;2u' || seq === '\x1b[27;2;13~') {
|
|
775
1001
|
this.#insertNewline();
|
|
776
1002
|
return;
|
|
@@ -812,16 +1038,42 @@ export class UI extends EventEmitter {
|
|
|
812
1038
|
this.#handleKey(ch, key);
|
|
813
1039
|
});
|
|
814
1040
|
|
|
815
|
-
//
|
|
1041
|
+
// Dragging a window edge fires this continuously, and a rebuild touches
|
|
1042
|
+
// every entry in every buffer, so only the size it settles on is drawn.
|
|
1043
|
+
this.#screen.on('resize', () => {
|
|
1044
|
+
if (this.#resizeTimer) {
|
|
1045
|
+
clearTimeout(this.#resizeTimer);
|
|
1046
|
+
}
|
|
1047
|
+
this.#resizeTimer = setTimeout(() => {
|
|
1048
|
+
this.#resizeTimer = null;
|
|
1049
|
+
this.#relayout();
|
|
1050
|
+
}, 60);
|
|
1051
|
+
if (this.#resizeTimer.unref) {
|
|
1052
|
+
this.#resizeTimer.unref();
|
|
1053
|
+
}
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
// Ask the terminal to bracket pasted text with \x1b[200~ … \x1b[201~, and
|
|
1057
|
+
// — unless the shim is off — to report modified keys so Shift+Enter can be
|
|
1058
|
+
// told apart from Enter. Both requests are ignored by terminals that don't
|
|
1059
|
+
// implement them, and both are undone on the way out so the shell that
|
|
1060
|
+
// follows us is not left in an enhanced mode it never asked for.
|
|
1061
|
+
const enable = '\x1b[?2004h' + (this.#keyInput ? KEY_PROTOCOL_ENABLE : '');
|
|
1062
|
+
const restore = (this.#keyInput ? KEY_PROTOCOL_DISABLE : '') + '\x1b[?2004l';
|
|
816
1063
|
try {
|
|
817
|
-
this.#screen.program.write(
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
1064
|
+
this.#screen.program.write(enable);
|
|
1065
|
+
// Only when we own the real terminal: an injected output belongs to a
|
|
1066
|
+
// test, and an exit hook per instance would both leak listeners and
|
|
1067
|
+
// print escape codes into the test log.
|
|
1068
|
+
if (!io.output) {
|
|
1069
|
+
process.on('exit', () => {
|
|
1070
|
+
try {
|
|
1071
|
+
process.stdout.write(restore);
|
|
1072
|
+
} catch {
|
|
1073
|
+
/* ignore */
|
|
1074
|
+
}
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
825
1077
|
} catch {
|
|
826
1078
|
/* terminals without bracketed paste just ignore this */
|
|
827
1079
|
}
|
|
@@ -871,9 +1123,16 @@ export class UI extends EventEmitter {
|
|
|
871
1123
|
return;
|
|
872
1124
|
}
|
|
873
1125
|
|
|
874
|
-
// Alt+Enter / Ctrl+J — insert a newline
|
|
875
|
-
//
|
|
876
|
-
|
|
1126
|
+
// Alt+Enter / Ctrl+J — insert a newline. blessed's parser has no name for
|
|
1127
|
+
// \x1b\r, so the raw sequence is matched too: without it Alt+Enter sent the
|
|
1128
|
+
// message, exactly like the Shift+Enter it was documented as a fallback for.
|
|
1129
|
+
const seq = key.sequence || '';
|
|
1130
|
+
if (
|
|
1131
|
+
((name === 'return' || name === 'enter') && key.meta) ||
|
|
1132
|
+
seq === '\x1b\r' ||
|
|
1133
|
+
seq === '\x1b\n' ||
|
|
1134
|
+
(key.ctrl && name === 'j')
|
|
1135
|
+
) {
|
|
877
1136
|
this.#insertNewline();
|
|
878
1137
|
return;
|
|
879
1138
|
}
|
|
@@ -1151,6 +1410,15 @@ export class UI extends EventEmitter {
|
|
|
1151
1410
|
this.#chatLog.bottom = height + 1;
|
|
1152
1411
|
}
|
|
1153
1412
|
|
|
1413
|
+
// A newline request lifted off the raw stream by the keyboard shim. Overlays
|
|
1414
|
+
// own the keyboard while they are up, so it only reaches the composer.
|
|
1415
|
+
#onEnhancedNewline() {
|
|
1416
|
+
if (this.#locked || this.#paletteOpen || this.#emojiOpen || this.#finderOpen) {
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
this.#insertNewline();
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1154
1422
|
#insertNewline() {
|
|
1155
1423
|
this.#inputValue =
|
|
1156
1424
|
this.#inputValue.slice(0, this.#cursorPos) + '\n' + this.#inputValue.slice(this.#cursorPos);
|
|
@@ -1274,8 +1542,16 @@ export class UI extends EventEmitter {
|
|
|
1274
1542
|
this.#finderMark = { lineIndex, original };
|
|
1275
1543
|
this.#lines[lineIndex] = `{yellow-fg}▶{/yellow-fg}${original}`;
|
|
1276
1544
|
this.#chatLog.setContent(this.#lines.join('\n'));
|
|
1277
|
-
//
|
|
1278
|
-
|
|
1545
|
+
// An entry spans several rows now, so the scroll target is the row the
|
|
1546
|
+
// entry starts on, not its index. Put the hit a few rows from the top so
|
|
1547
|
+
// its context stays visible.
|
|
1548
|
+
let row = 0;
|
|
1549
|
+
for (let i = 0; i < lineIndex; i++) {
|
|
1550
|
+
if (this.#lines[i] !== null) {
|
|
1551
|
+
row += String(this.#lines[i]).split('\n').length;
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
this.#chatLog.scrollTo(Math.max(0, row - 3));
|
|
1279
1555
|
this.#screen.render();
|
|
1280
1556
|
this.#syncScrollState();
|
|
1281
1557
|
return true;
|
|
@@ -1659,13 +1935,18 @@ export class UI extends EventEmitter {
|
|
|
1659
1935
|
}
|
|
1660
1936
|
if (!this.#bufferLines.has(room)) {
|
|
1661
1937
|
this.#bufferLines.set(room, []);
|
|
1938
|
+
this.#bufferSpecs.set(room, []);
|
|
1662
1939
|
}
|
|
1663
1940
|
const liveLines = this.#lines;
|
|
1941
|
+
const liveSpecs = this.#specs;
|
|
1664
1942
|
const liveSender = this.#lastSender;
|
|
1943
|
+
const liveStamp = this.#lastStamp;
|
|
1665
1944
|
const liveLog = this.#chatLog;
|
|
1666
1945
|
const liveScreen = this.#screen;
|
|
1667
1946
|
this.#lines = this.#bufferLines.get(room);
|
|
1947
|
+
this.#specs = this.#bufferSpecs.get(room);
|
|
1668
1948
|
this.#lastSender = null;
|
|
1949
|
+
this.#lastStamp = null;
|
|
1669
1950
|
this.#redirecting = true;
|
|
1670
1951
|
this.#chatLog = new Proxy(liveLog, {
|
|
1671
1952
|
get: (t, p) => (p === 'log' ? () => {} : t[p]),
|
|
@@ -1683,7 +1964,9 @@ export class UI extends EventEmitter {
|
|
|
1683
1964
|
return fn();
|
|
1684
1965
|
} finally {
|
|
1685
1966
|
this.#lines = liveLines;
|
|
1967
|
+
this.#specs = liveSpecs;
|
|
1686
1968
|
this.#lastSender = liveSender;
|
|
1969
|
+
this.#lastStamp = liveStamp;
|
|
1687
1970
|
this.#chatLog = liveLog;
|
|
1688
1971
|
this.#screen = liveScreen;
|
|
1689
1972
|
this.#redirecting = false;
|
|
@@ -1696,10 +1979,14 @@ export class UI extends EventEmitter {
|
|
|
1696
1979
|
return;
|
|
1697
1980
|
}
|
|
1698
1981
|
this.#bufferLines.set(this.#activeBuffer, this.#lines);
|
|
1982
|
+
this.#bufferSpecs.set(this.#activeBuffer, this.#specs);
|
|
1699
1983
|
this.#lines = this.#bufferLines.get(room) || [];
|
|
1984
|
+
this.#specs = this.#bufferSpecs.get(room) || [];
|
|
1700
1985
|
this.#bufferLines.delete(room);
|
|
1986
|
+
this.#bufferSpecs.delete(room);
|
|
1701
1987
|
this.#activeBuffer = room;
|
|
1702
1988
|
this.#lastSender = null;
|
|
1989
|
+
this.#lastStamp = null;
|
|
1703
1990
|
this.#chatLog.setContent(this.#lines.join('\n'));
|
|
1704
1991
|
this.#chatLog.setScrollPerc(100);
|
|
1705
1992
|
this.setRoom(room);
|
|
@@ -1709,9 +1996,12 @@ export class UI extends EventEmitter {
|
|
|
1709
1996
|
/** Forget every buffer and start fresh in `room` (reconnect / legacy switch). */
|
|
1710
1997
|
resetBuffers(room) {
|
|
1711
1998
|
this.#bufferLines.clear();
|
|
1999
|
+
this.#bufferSpecs.clear();
|
|
1712
2000
|
this.#activeBuffer = room;
|
|
1713
2001
|
this.#lines = [];
|
|
2002
|
+
this.#specs = [];
|
|
1714
2003
|
this.#lastSender = null;
|
|
2004
|
+
this.#lastStamp = null;
|
|
1715
2005
|
this.#chatLog.setContent('');
|
|
1716
2006
|
this.setRoom(room);
|
|
1717
2007
|
this.#screen.render();
|
|
@@ -1719,6 +2009,7 @@ export class UI extends EventEmitter {
|
|
|
1719
2009
|
|
|
1720
2010
|
dropBuffer(room) {
|
|
1721
2011
|
this.#bufferLines.delete(room);
|
|
2012
|
+
this.#bufferSpecs.delete(room);
|
|
1722
2013
|
}
|
|
1723
2014
|
|
|
1724
2015
|
clearBuffer(room) {
|
|
@@ -1726,6 +2017,7 @@ export class UI extends EventEmitter {
|
|
|
1726
2017
|
this.clearChat();
|
|
1727
2018
|
} else if (this.#bufferLines.has(room)) {
|
|
1728
2019
|
this.#bufferLines.get(room).length = 0;
|
|
2020
|
+
this.#bufferSpecs.get(room)?.splice(0);
|
|
1729
2021
|
}
|
|
1730
2022
|
}
|
|
1731
2023
|
|
|
@@ -1784,24 +2076,32 @@ export class UI extends EventEmitter {
|
|
|
1784
2076
|
) {
|
|
1785
2077
|
this.#daySeparator();
|
|
1786
2078
|
const isSelfNow = nickname === this.#nickname || nickname.includes('\u2192');
|
|
2079
|
+
// The sentinel is written as an escape, not typed as a raw byte: a bare
|
|
2080
|
+
// NUL anywhere in the source makes this entire file count as binary, and
|
|
2081
|
+
// a binary file is skipped by grep and shown without a diff on GitHub.
|
|
2082
|
+
const senderKey = isSelfNow ? '\u0000self' : nickname;
|
|
2083
|
+
const stamp = time();
|
|
2084
|
+
// Runs from one sender collapse under a single header — but only inside the
|
|
2085
|
+
// same minute, so folding them never costs the reader a timestamp.
|
|
1787
2086
|
const opts = {
|
|
1788
2087
|
isDM,
|
|
1789
2088
|
ephemeralLabel,
|
|
1790
2089
|
deniable,
|
|
1791
2090
|
mentioned,
|
|
1792
2091
|
trust,
|
|
1793
|
-
grouped:
|
|
1794
|
-
stamp
|
|
2092
|
+
grouped: this.#lastSender === senderKey && this.#lastStamp === stamp,
|
|
2093
|
+
stamp,
|
|
1795
2094
|
};
|
|
2095
|
+
if (!opts.grouped && this.#lines.length > 0 && !this.#suppressSeparator) {
|
|
2096
|
+
this.#append('');
|
|
2097
|
+
}
|
|
2098
|
+
this.#suppressSeparator = false;
|
|
1796
2099
|
const line = this.#composeMessageLine(nickname, text, opts);
|
|
1797
2100
|
|
|
1798
|
-
this.#
|
|
1799
|
-
this.#chatLog.log(line);
|
|
2101
|
+
this.#append(line, { kind: 'message', nickname, text, opts });
|
|
1800
2102
|
this.#screen.render();
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
// a binary file is skipped by grep and shown without a diff on GitHub.
|
|
1804
|
-
this.#lastSender = isSelfNow ? '\u0000self' : nickname;
|
|
2103
|
+
this.#lastSender = senderKey;
|
|
2104
|
+
this.#lastStamp = stamp;
|
|
1805
2105
|
if (!isSelfNow) {
|
|
1806
2106
|
this.#noteIncoming(mentioned || isDM);
|
|
1807
2107
|
}
|
|
@@ -1814,25 +2114,48 @@ export class UI extends EventEmitter {
|
|
|
1814
2114
|
* reader has to mentally staple to the original.
|
|
1815
2115
|
*/
|
|
1816
2116
|
replaceMessageText(lineIndex, nickname, newText, opts) {
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
2117
|
+
const edited = { ...opts, edited: true };
|
|
2118
|
+
this.updateLine(lineIndex, this.#composeMessageLine(nickname, newText, edited), {
|
|
2119
|
+
kind: 'message',
|
|
2120
|
+
nickname,
|
|
2121
|
+
text: newText,
|
|
2122
|
+
opts: edited,
|
|
2123
|
+
});
|
|
1821
2124
|
}
|
|
1822
2125
|
|
|
1823
2126
|
/** Replace a message with a tombstone (used by /delete). */
|
|
1824
2127
|
tombstoneMessage(lineIndex, nickname) {
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
)} deleted a message
|
|
2128
|
+
const spec = {
|
|
2129
|
+
kind: 'meta',
|
|
2130
|
+
marker: '\ud83d\udeab',
|
|
2131
|
+
colorTag: '#666666-fg',
|
|
2132
|
+
body: `${blessed.escape(nickname)} deleted a message`,
|
|
2133
|
+
stamp: time(),
|
|
2134
|
+
};
|
|
2135
|
+
this.updateLine(lineIndex, this.#recompose(spec), spec);
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
// Columns the message body is inset by. The header puts the avatar at the
|
|
2139
|
+
// same column, so a message reads as one block: ` HH:MM ` and ` ▎ `
|
|
2140
|
+
// are both eight cells wide.
|
|
2141
|
+
static #GUTTER = 8;
|
|
2142
|
+
|
|
2143
|
+
// How wide the text itself may run. Full-terminal lines are hard to read and
|
|
2144
|
+
// were what made a long message look like a wall, so the body is capped well
|
|
2145
|
+
// short of the window and the remaining columns are left as breathing room
|
|
2146
|
+
// (and as the landing strip for a ✓✓ or a reaction).
|
|
2147
|
+
#bodyWidth() {
|
|
2148
|
+
const inner = (this.#chatLog.width || 80) - (this.#chatLog.iwidth || 2);
|
|
2149
|
+
return Math.max(
|
|
2150
|
+
12,
|
|
2151
|
+
Math.min(MAX_TEXT_WIDTH, Math.round(inner * BODY_WIDTH_RATIO), inner - UI.#GUTTER - 4),
|
|
1830
2152
|
);
|
|
1831
2153
|
}
|
|
1832
2154
|
|
|
1833
|
-
// Builds a message line
|
|
1834
|
-
//
|
|
1835
|
-
//
|
|
2155
|
+
// Builds a message: a header line naming the sender, then the wrapped body,
|
|
2156
|
+
// as one '\n'-joined entry so it stays a single addressable log line.
|
|
2157
|
+
// Shared by addMessage and replaceMessageText so an edited message keeps
|
|
2158
|
+
// exactly the layout it had instead of drifting into a different shape.
|
|
1836
2159
|
#composeMessageLine(nickname, text, opts) {
|
|
1837
2160
|
const {
|
|
1838
2161
|
isDM = false,
|
|
@@ -1859,21 +2182,129 @@ export class UI extends EventEmitter {
|
|
|
1859
2182
|
: trust === 'mismatch'
|
|
1860
2183
|
? ' {red-fg}✗{/red-fg}'
|
|
1861
2184
|
: '';
|
|
1862
|
-
// A yellow left rule makes a line that @-mentions you jump out of the log.
|
|
1863
|
-
const bar = mentioned && !isSelf ? '{yellow-fg}▏{/yellow-fg}' : ' ';
|
|
1864
|
-
const mentionMark = mentioned && !isSelf ? '{yellow-fg}\ud83d\udd14 {/yellow-fg}' : '';
|
|
1865
|
-
|
|
1866
|
-
// Consecutive messages from the same peer collapse the avatar/name into a
|
|
1867
|
-
// compact continuation bullet (cleaner layout).
|
|
1868
2185
|
const editedMark = edited ? ' {#8888aa-fg}(edited){/#8888aa-fg}' : '';
|
|
1869
|
-
const
|
|
1870
|
-
|
|
1871
|
-
|
|
2186
|
+
const mentionMark = mentioned && !isSelf ? ' {yellow-fg}\ud83d\udd14{/yellow-fg}' : '';
|
|
2187
|
+
|
|
2188
|
+
// A coloured rule down the left of the body is what tells the messages
|
|
2189
|
+
// apart now that they all start at the same column: yellow when the line
|
|
2190
|
+
// mentions you, magenta for a DM, the accent for your own, nothing for a
|
|
2191
|
+
// plain incoming message.
|
|
2192
|
+
const rule = mentioned && !isSelf ? 'yellow' : isDM ? 'magenta' : isSelf ? '#7b2dff' : null;
|
|
2193
|
+
const prefix = rule ? ` {${rule}-fg}▎{/${rule}-fg} ` : ' '.repeat(UI.#GUTTER);
|
|
1872
2194
|
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
2195
|
+
const body = wrapTagged(`${renderMarkdown(text)}${editedMark}`, this.#bodyWidth()).map(
|
|
2196
|
+
(segment) => prefix + segment,
|
|
2197
|
+
);
|
|
2198
|
+
if (grouped) {
|
|
2199
|
+
return body.join('\n');
|
|
2200
|
+
}
|
|
2201
|
+
const header =
|
|
2202
|
+
` {white-fg}${stamp}{/white-fg} ${avatar} {${tag}}${blessed.escape(nickname)}{/${tag}}` +
|
|
2203
|
+
`${trustGlyph}${dmLabel}${ephLabel}${denLabel}${mentionMark}`;
|
|
2204
|
+
return [header, ...body].join('\n');
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
/**
|
|
2208
|
+
* Append one entry to the active buffer.
|
|
2209
|
+
*
|
|
2210
|
+
* `spec` is the recipe that produced `line`, kept index-aligned with it so a
|
|
2211
|
+
* resize can lay the entry out again at the new width. Entries with no recipe
|
|
2212
|
+
* — day separators, welcome panels, image previews — keep the string they were
|
|
2213
|
+
* given, which for a rendered image is the only correct thing to do.
|
|
2214
|
+
*/
|
|
2215
|
+
#append(line, spec = null) {
|
|
2216
|
+
this.#lines.push(line);
|
|
2217
|
+
this.#specs.push(spec);
|
|
2218
|
+
this.#chatLog.log(line);
|
|
2219
|
+
return this.#lines.length - 1;
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
/** Rebuild one entry from its recipe, or null if it has none. */
|
|
2223
|
+
#recompose(spec) {
|
|
2224
|
+
if (!spec) {
|
|
2225
|
+
return null;
|
|
2226
|
+
}
|
|
2227
|
+
const line =
|
|
2228
|
+
spec.kind === 'message'
|
|
2229
|
+
? this.#composeMessageLine(spec.nickname, spec.text, spec.opts)
|
|
2230
|
+
: spec.kind === 'meta'
|
|
2231
|
+
? this.#composeMeta(spec.marker, spec.colorTag, spec.body, spec.stamp)
|
|
2232
|
+
: spec.kind === 'quote'
|
|
2233
|
+
? this.#composeQuote(spec.nickname, spec.excerpt)
|
|
2234
|
+
: spec.kind === 'tip'
|
|
2235
|
+
? this.#composeTip(spec.text)
|
|
2236
|
+
: null;
|
|
2237
|
+
return line === null || !spec.badge ? line : this.#withBadge(line, spec.badge);
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
/**
|
|
2241
|
+
* Lay every entry out again for the current width.
|
|
2242
|
+
*
|
|
2243
|
+
* Messages are wrapped and padded when they are composed, so without this a
|
|
2244
|
+
* resize left the whole scrollback measured for the old window: narrower, and
|
|
2245
|
+
* blessed re-wraps the leftovers into the gutter; wider, and old messages stay
|
|
2246
|
+
* narrow beside new ones. Inactive buffers are done too, or switching to one
|
|
2247
|
+
* after a resize would show the same drift a moment later.
|
|
2248
|
+
*/
|
|
2249
|
+
#relayout() {
|
|
2250
|
+
this.#clearJumpMark(); // its saved "original" is about to be replaced
|
|
2251
|
+
const rebuild = (lines, specs) => {
|
|
2252
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2253
|
+
if (lines[i] === null) {
|
|
2254
|
+
continue;
|
|
2255
|
+
}
|
|
2256
|
+
const line = this.#recompose(specs[i]);
|
|
2257
|
+
if (line !== null) {
|
|
2258
|
+
lines[i] = line;
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
};
|
|
2262
|
+
rebuild(this.#lines, this.#specs);
|
|
2263
|
+
for (const [room, lines] of this.#bufferLines) {
|
|
2264
|
+
rebuild(lines, this.#bufferSpecs.get(room) || []);
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
this.#chatLog.setContent(this.#lines.filter((l) => l !== null).join('\n'));
|
|
2268
|
+
if (!this.#scrolledUp) {
|
|
2269
|
+
this.#chatLog.setScrollPerc(100);
|
|
2270
|
+
}
|
|
2271
|
+
// A full repaint, not just a render: the old frame's padding leaves cells
|
|
2272
|
+
// behind that a diffed update has no reason to touch.
|
|
2273
|
+
this.#screen.realloc();
|
|
2274
|
+
this.#screen.render();
|
|
2275
|
+
this.#syncScrollState();
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
// The one-off lines that are not messages — system notices, errors, /me,
|
|
2279
|
+
// tombstones. They share the message gutter so the whole log lines up on one
|
|
2280
|
+
// column, and they wrap with a hanging indent instead of running past the
|
|
2281
|
+
// border the way an unwrapped line did.
|
|
2282
|
+
#composeMeta(marker, colorTag, taggedBody, stamp = time()) {
|
|
2283
|
+
const lines = wrapTagged(`{${colorTag}}${taggedBody}{/${colorTag}}`, this.#metaWidth());
|
|
2284
|
+
const head = ` {white-fg}${stamp}{/white-fg} {${colorTag}}${marker}{/${colorTag}} `;
|
|
2285
|
+
const indent = ' '.repeat(UI.#GUTTER + 2);
|
|
2286
|
+
return [head + lines[0], ...lines.slice(1).map((line) => indent + line)].join('\n');
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
// Notices are not conversation, so they are not held to the message body's
|
|
2290
|
+
// reading width — only to the box. /help's table would otherwise be folded in
|
|
2291
|
+
// half on a window with room to spare.
|
|
2292
|
+
#metaWidth() {
|
|
2293
|
+
const inner = (this.#chatLog.width || 80) - (this.#chatLog.iwidth || 2);
|
|
2294
|
+
return Math.max(20, inner - UI.#GUTTER - 4);
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
#pushMeta(spec, incoming = true) {
|
|
2298
|
+
const lineIndex = this.#append(this.#recompose(spec), spec);
|
|
2299
|
+
this.#screen.render();
|
|
2300
|
+
if (incoming) {
|
|
2301
|
+
this.#noteIncoming();
|
|
2302
|
+
}
|
|
2303
|
+
return { lineIndex };
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
#metaSpec(marker, colorTag, body) {
|
|
2307
|
+
return { kind: 'meta', marker, colorTag, body, stamp: time() };
|
|
1877
2308
|
}
|
|
1878
2309
|
|
|
1879
2310
|
#daySeparator() {
|
|
@@ -1883,100 +2314,109 @@ export class UI extends EventEmitter {
|
|
|
1883
2314
|
}
|
|
1884
2315
|
this.#lastMsgDate = today;
|
|
1885
2316
|
this.#lastSender = null;
|
|
2317
|
+
this.#lastStamp = null;
|
|
1886
2318
|
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}`;
|
|
1887
|
-
this.#
|
|
1888
|
-
this.#chatLog.log(sep);
|
|
2319
|
+
this.#append(sep);
|
|
1889
2320
|
}
|
|
1890
2321
|
|
|
1891
2322
|
// Visible width of a string with blessed tags (emoji ~2 columns)
|
|
1892
2323
|
#visibleWidth(tagged) {
|
|
1893
|
-
const plain = tagged.replace(/\{[^{}]*\}/g, '');
|
|
2324
|
+
const plain = String(tagged).replace(/\{[^{}]*\}/g, '');
|
|
1894
2325
|
let width = 0;
|
|
1895
2326
|
for (const chr of plain) {
|
|
1896
|
-
|
|
1897
|
-
width += cp > 0xffff || (cp >= 0x2600 && cp <= 0x27bf) ? 2 : 1;
|
|
2327
|
+
width += glyphWidth(chr.codePointAt(0));
|
|
1898
2328
|
}
|
|
1899
2329
|
return width;
|
|
1900
2330
|
}
|
|
1901
2331
|
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
return ` ${tagged}`; // doesn't fit on one line \u2014 falls back to normal flow with wrap
|
|
1907
|
-
}
|
|
1908
|
-
return ' '.repeat(avail - visible) + tagged;
|
|
1909
|
-
}
|
|
1910
|
-
|
|
1911
|
-
// Appends a badge (e.g. \u2713\u2713) preserving right alignment:
|
|
1912
|
-
// shifts the padding instead of overflowing the width
|
|
2332
|
+
// Hangs a badge (\u2713\u2713, a reaction) off the end of a message, flush right on
|
|
2333
|
+
// its last line so it terminates the block instead of running on from the
|
|
2334
|
+
// text. `baseLine` is the message as it was drawn, so repeated calls replace
|
|
2335
|
+
// the badge rather than stacking copies of it.
|
|
1913
2336
|
appendBadge(lineIndex, baseLine, badgeTagged) {
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
2337
|
+
// The recipe rebuilds the message without its badge, so preferring it over
|
|
2338
|
+
// the caller's copy is what lets the badge survive a resize: it is stored
|
|
2339
|
+
// and re-hung after the entry is laid out again, rather than baked into a
|
|
2340
|
+
// string measured for the old width.
|
|
2341
|
+
const spec = this.#specs[lineIndex];
|
|
2342
|
+
const base = spec ? this.#recompose({ ...spec, badge: null }) : String(baseLine);
|
|
2343
|
+
if (spec) {
|
|
2344
|
+
spec.badge = badgeTagged;
|
|
2345
|
+
}
|
|
2346
|
+
this.updateLine(lineIndex, this.#withBadge(base, badgeTagged), spec);
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
/** Hang a badge off the last line of an entry, flush right. */
|
|
2350
|
+
#withBadge(line, badgeTagged) {
|
|
2351
|
+
const segments = String(line).split('\n');
|
|
2352
|
+
const last = segments[segments.length - 1];
|
|
2353
|
+
// Two columns short of the inner width: one for the scrollbar blessed
|
|
2354
|
+
// reserves on the right, one so the badge never lands against the border.
|
|
2355
|
+
const avail = (this.#chatLog.width || 80) - (this.#chatLog.iwidth || 2) - 2;
|
|
2356
|
+
const gap = avail - this.#visibleWidth(last) - this.#visibleWidth(badgeTagged);
|
|
2357
|
+
segments[segments.length - 1] =
|
|
2358
|
+
gap > 1 ? last + ' '.repeat(gap) + badgeTagged : `${last} ${badgeTagged}`;
|
|
2359
|
+
return segments.join('\n');
|
|
1921
2360
|
}
|
|
1922
2361
|
|
|
1923
2362
|
// Third-person action (/me). Rendered as a distinct italic line so it never
|
|
1924
2363
|
// reads like someone quoting themselves.
|
|
1925
2364
|
addActionMessage(nickname, text) {
|
|
1926
2365
|
this.#lastSender = null; // an action breaks message grouping
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
2366
|
+
this.#lastStamp = null;
|
|
2367
|
+
return this.#pushMeta(
|
|
2368
|
+
this.#metaSpec(
|
|
2369
|
+
'✦',
|
|
2370
|
+
'magenta-fg',
|
|
2371
|
+
`{bold}${blessed.escape(nickname)}{/bold} ${renderMarkdown(text)}`,
|
|
2372
|
+
),
|
|
2373
|
+
);
|
|
1935
2374
|
}
|
|
1936
2375
|
|
|
1937
2376
|
addSystemMessage(text) {
|
|
1938
2377
|
this.#lastSender = null; // interrupts message grouping
|
|
1939
|
-
|
|
1940
|
-
this.#
|
|
1941
|
-
this.#chatLog.log(line);
|
|
1942
|
-
this.#screen.render();
|
|
1943
|
-
this.#noteIncoming();
|
|
2378
|
+
this.#lastStamp = null;
|
|
2379
|
+
this.#pushMeta(this.#metaSpec('*', 'white-fg', blessed.escape(text)));
|
|
1944
2380
|
}
|
|
1945
2381
|
|
|
1946
2382
|
addErrorMessage(text) {
|
|
1947
2383
|
this.#lastSender = null;
|
|
1948
|
-
|
|
1949
|
-
this.#
|
|
1950
|
-
this.#chatLog.log(line);
|
|
1951
|
-
this.#screen.render();
|
|
1952
|
-
this.#noteIncoming();
|
|
2384
|
+
this.#lastStamp = null;
|
|
2385
|
+
this.#pushMeta(this.#metaSpec('!', 'red-fg', blessed.escape(text)));
|
|
1953
2386
|
}
|
|
1954
2387
|
|
|
1955
2388
|
addInfoMessage(text) {
|
|
1956
2389
|
this.#lastSender = null;
|
|
1957
|
-
|
|
1958
|
-
this.#
|
|
1959
|
-
this.#chatLog.log(line);
|
|
1960
|
-
this.#screen.render();
|
|
2390
|
+
this.#lastStamp = null;
|
|
2391
|
+
this.#pushMeta(this.#metaSpec('\u00b7', 'cyan-fg', blessed.escape(text)), false);
|
|
1961
2392
|
}
|
|
1962
2393
|
|
|
1963
2394
|
// A one-line security/UX tip (💡). Plain text — no blessed tags interpreted.
|
|
1964
2395
|
addTip(text) {
|
|
1965
2396
|
this.#lastSender = null;
|
|
1966
|
-
|
|
1967
|
-
this.#
|
|
1968
|
-
this.#chatLog.log(line);
|
|
2397
|
+
this.#lastStamp = null;
|
|
2398
|
+
this.#append(this.#composeTip(text), { kind: 'tip', text });
|
|
1969
2399
|
this.#screen.render();
|
|
1970
2400
|
}
|
|
1971
2401
|
|
|
2402
|
+
#composeTip(text) {
|
|
2403
|
+
const wrapped = wrapTagged(
|
|
2404
|
+
`{#9a9ad0-fg}${blessed.escape(text)}{/#9a9ad0-fg}`,
|
|
2405
|
+
this.#metaWidth(),
|
|
2406
|
+
);
|
|
2407
|
+
const indent = ' '.repeat(3);
|
|
2408
|
+
return [
|
|
2409
|
+
` {yellow-fg}💡{/yellow-fg} ${wrapped[0]}`,
|
|
2410
|
+
...wrapped.slice(1).map((l) => indent + l),
|
|
2411
|
+
].join('\n');
|
|
2412
|
+
}
|
|
2413
|
+
|
|
1972
2414
|
// A framed "getting started" panel for the empty chat. `lines` may contain
|
|
1973
2415
|
// blessed tags (the caller styles them); the title is escaped.
|
|
1974
2416
|
addWelcome(title, lines) {
|
|
1975
2417
|
this.#lastSender = null;
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
this.#chatLog.log(l);
|
|
1979
|
-
};
|
|
2418
|
+
this.#lastStamp = null;
|
|
2419
|
+
const push = (l) => this.#append(l);
|
|
1980
2420
|
push('');
|
|
1981
2421
|
push(` {#7b2dff-fg}╭─{/#7b2dff-fg} {bold}${blessed.escape(title)}{/bold}`);
|
|
1982
2422
|
for (const l of lines) {
|
|
@@ -1987,19 +2427,33 @@ export class UI extends EventEmitter {
|
|
|
1987
2427
|
this.#screen.render();
|
|
1988
2428
|
}
|
|
1989
2429
|
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
this.#lines.
|
|
1994
|
-
|
|
2430
|
+
// The "replying to …" line that precedes a /reply. Indented to the message
|
|
2431
|
+
// gutter so it reads as part of the reply that follows it.
|
|
2432
|
+
addQuoteLine(nickname, excerpt) {
|
|
2433
|
+
if (this.#lines.length > 0) {
|
|
2434
|
+
this.#append('');
|
|
2435
|
+
}
|
|
2436
|
+
this.#append(this.#composeQuote(nickname, excerpt), { kind: 'quote', nickname, excerpt });
|
|
1995
2437
|
this.#screen.render();
|
|
2438
|
+
// The reply that follows belongs to this quote, so it needs its own header
|
|
2439
|
+
// (grouping it under an earlier message would strand the quote) but not a
|
|
2440
|
+
// second blank line between the two.
|
|
2441
|
+
this.#lastSender = null;
|
|
2442
|
+
this.#lastStamp = null;
|
|
2443
|
+
this.#suppressSeparator = true;
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
#composeQuote(nickname, excerpt) {
|
|
2447
|
+
const quoted = `↩ ${blessed.escape(nickname)}: "${blessed.escape(excerpt)}"`;
|
|
2448
|
+
const indent = ' '.repeat(UI.#GUTTER);
|
|
2449
|
+
return wrapTagged(`{#888888-fg}${quoted}{/#888888-fg}`, this.#metaWidth())
|
|
2450
|
+
.map((line) => indent + line)
|
|
2451
|
+
.join('\n');
|
|
1996
2452
|
}
|
|
1997
2453
|
|
|
1998
2454
|
addPlainLines(rawLines) {
|
|
1999
2455
|
for (const raw of rawLines) {
|
|
2000
|
-
|
|
2001
|
-
this.#lines.push(line);
|
|
2002
|
-
this.#chatLog.log(line);
|
|
2456
|
+
this.#append(` ${blessed.escape(raw)}`);
|
|
2003
2457
|
}
|
|
2004
2458
|
this.#screen.render();
|
|
2005
2459
|
}
|
|
@@ -2007,9 +2461,9 @@ export class UI extends EventEmitter {
|
|
|
2007
2461
|
// Lines already carry blessed color tags — do not escape
|
|
2008
2462
|
addImagePreview(taggedLines) {
|
|
2009
2463
|
for (const raw of taggedLines) {
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
this.#
|
|
2464
|
+
// No recipe: half-block pixels are laid out for the width they were
|
|
2465
|
+
// rendered at, and re-wrapping them would shred the picture.
|
|
2466
|
+
this.#append(` ${raw}`);
|
|
2013
2467
|
}
|
|
2014
2468
|
this.#screen.render();
|
|
2015
2469
|
}
|
|
@@ -2098,7 +2552,15 @@ export class UI extends EventEmitter {
|
|
|
2098
2552
|
return this.#lines[lineIndex];
|
|
2099
2553
|
}
|
|
2100
2554
|
|
|
2101
|
-
|
|
2555
|
+
/**
|
|
2556
|
+
* Replace one entry.
|
|
2557
|
+
*
|
|
2558
|
+
* `spec` is the recipe that produced `newLine`. Callers that hand over a
|
|
2559
|
+
* string they built some other way — a burn frame, most of all — pass none,
|
|
2560
|
+
* and the entry drops out of the resize rebuild rather than being redrawn
|
|
2561
|
+
* mid-animation from a recipe that no longer describes what is on screen.
|
|
2562
|
+
*/
|
|
2563
|
+
updateLine(lineIndex, newLine, spec = null) {
|
|
2102
2564
|
if (lineIndex < 0 || lineIndex >= this.#lines.length) {
|
|
2103
2565
|
return;
|
|
2104
2566
|
}
|
|
@@ -2106,6 +2568,7 @@ export class UI extends EventEmitter {
|
|
|
2106
2568
|
return;
|
|
2107
2569
|
}
|
|
2108
2570
|
this.#lines[lineIndex] = newLine;
|
|
2571
|
+
this.#specs[lineIndex] = spec;
|
|
2109
2572
|
const content = this.#lines.filter((l) => l !== null).join('\n');
|
|
2110
2573
|
this.#chatLog.setContent(content);
|
|
2111
2574
|
if (!this.#scrolledUp) {
|
|
@@ -2119,6 +2582,7 @@ export class UI extends EventEmitter {
|
|
|
2119
2582
|
return;
|
|
2120
2583
|
}
|
|
2121
2584
|
this.#lines[lineIndex] = null;
|
|
2585
|
+
this.#specs[lineIndex] = null;
|
|
2122
2586
|
const content = this.#lines.filter((l) => l !== null).join('\n');
|
|
2123
2587
|
this.#chatLog.setContent(content);
|
|
2124
2588
|
if (!this.#scrolledUp) {
|
|
@@ -2137,14 +2601,20 @@ export class UI extends EventEmitter {
|
|
|
2137
2601
|
return null;
|
|
2138
2602
|
}
|
|
2139
2603
|
|
|
2140
|
-
// Strip blessed tags to get the raw glyphs,
|
|
2141
|
-
//
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2604
|
+
// Strip blessed tags to get the raw glyphs, keeping each line's indent so
|
|
2605
|
+
// the flame stays under the text. A message is several lines now, and the
|
|
2606
|
+
// front runs through them in order — the block burns top-left to
|
|
2607
|
+
// bottom-right rather than every line igniting at once.
|
|
2608
|
+
// The frames are not a layout, so the entry leaves the resize rebuild for
|
|
2609
|
+
// as long as it is burning; it is removed at the end either way.
|
|
2610
|
+
this.#specs[lineIndex] = null;
|
|
2611
|
+
const rows = orig.split('\n').map((row) => {
|
|
2612
|
+
const plain = row.replace(/\{[^{}]*\}/g, '');
|
|
2613
|
+
const lead = (plain.match(/^ */) || [''])[0];
|
|
2614
|
+
return { lead, body: plain.slice(lead.length) };
|
|
2615
|
+
});
|
|
2616
|
+
const len = rows.reduce((total, row) => total + [...row.body].length, 0);
|
|
2146
2617
|
|
|
2147
|
-
const bodyStr = plain.slice(lead.length);
|
|
2148
2618
|
if (!process.stdout.isTTY || len === 0) {
|
|
2149
2619
|
this.removeLine(lineIndex);
|
|
2150
2620
|
onDone?.();
|
|
@@ -2157,7 +2627,13 @@ export class UI extends EventEmitter {
|
|
|
2157
2627
|
|
|
2158
2628
|
const timer = setInterval(() => {
|
|
2159
2629
|
front += advance;
|
|
2160
|
-
|
|
2630
|
+
let consumed = 0;
|
|
2631
|
+
const frame = rows.map((row) => {
|
|
2632
|
+
const rendered = row.lead + burnFrame(row.body, front - consumed);
|
|
2633
|
+
consumed += [...row.body].length;
|
|
2634
|
+
return rendered;
|
|
2635
|
+
});
|
|
2636
|
+
this.updateLine(lineIndex, frame.join('\n'));
|
|
2161
2637
|
if (front >= len + BURN_TAIL) {
|
|
2162
2638
|
clearInterval(timer);
|
|
2163
2639
|
this.removeLine(lineIndex);
|
|
@@ -2172,7 +2648,9 @@ export class UI extends EventEmitter {
|
|
|
2172
2648
|
|
|
2173
2649
|
clearChat() {
|
|
2174
2650
|
this.#lines = [];
|
|
2651
|
+
this.#specs = [];
|
|
2175
2652
|
this.#lastSender = null;
|
|
2653
|
+
this.#lastStamp = null;
|
|
2176
2654
|
this.#lastMsgDate = null;
|
|
2177
2655
|
this.#chatLog.setContent('');
|
|
2178
2656
|
this.#chatLog.setScroll(0);
|
|
@@ -2263,6 +2741,7 @@ export class UI extends EventEmitter {
|
|
|
2263
2741
|
|
|
2264
2742
|
if (!process.stdout.isTTY) {
|
|
2265
2743
|
this.#lastSender = null;
|
|
2744
|
+
this.#lastStamp = null;
|
|
2266
2745
|
this.#lines.push(done);
|
|
2267
2746
|
this.#chatLog.log(done);
|
|
2268
2747
|
this.#screen.render();
|
|
@@ -2270,6 +2749,7 @@ export class UI extends EventEmitter {
|
|
|
2270
2749
|
}
|
|
2271
2750
|
|
|
2272
2751
|
this.#lastSender = null;
|
|
2752
|
+
this.#lastStamp = null;
|
|
2273
2753
|
this.#lines.push('');
|
|
2274
2754
|
const idx = this.#lines.length - 1;
|
|
2275
2755
|
const W = 11;
|
|
@@ -2316,6 +2796,7 @@ export class UI extends EventEmitter {
|
|
|
2316
2796
|
|
|
2317
2797
|
if (!process.stdout.isTTY) {
|
|
2318
2798
|
this.#lastSender = null;
|
|
2799
|
+
this.#lastStamp = null;
|
|
2319
2800
|
this.#lines.push(done);
|
|
2320
2801
|
this.#chatLog.log(done);
|
|
2321
2802
|
this.#screen.render();
|
|
@@ -2323,6 +2804,7 @@ export class UI extends EventEmitter {
|
|
|
2323
2804
|
}
|
|
2324
2805
|
|
|
2325
2806
|
this.#lastSender = null;
|
|
2807
|
+
this.#lastStamp = null;
|
|
2326
2808
|
this.#lines.push('');
|
|
2327
2809
|
const idx = this.#lines.length - 1;
|
|
2328
2810
|
const W = 11;
|
|
@@ -2395,8 +2877,19 @@ export class UI extends EventEmitter {
|
|
|
2395
2877
|
if (this.#reconnectFlashTimer) {
|
|
2396
2878
|
clearInterval(this.#reconnectFlashTimer);
|
|
2397
2879
|
}
|
|
2880
|
+
if (this.#resizeTimer) {
|
|
2881
|
+
clearTimeout(this.#resizeTimer);
|
|
2882
|
+
}
|
|
2398
2883
|
this.#stopShimmer();
|
|
2399
2884
|
this.#stopPill();
|
|
2885
|
+
if (this.#keyInput) {
|
|
2886
|
+
try {
|
|
2887
|
+
this.#screen.program.write(KEY_PROTOCOL_DISABLE);
|
|
2888
|
+
} catch {
|
|
2889
|
+
/* the terminal may already be gone */
|
|
2890
|
+
}
|
|
2891
|
+
this.#keyInput.detach();
|
|
2892
|
+
}
|
|
2400
2893
|
this.#screen.destroy();
|
|
2401
2894
|
}
|
|
2402
2895
|
}
|