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/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
- const slice = plain.slice(start, start + 76).trim();
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
- constructor(nickname) {
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
- // Shift+Enter arrives as a distinct sequence only under the kitty keyboard
772
- // protocol (\x1b[13;2u) or xterm modifyOtherKeys (\x1b[27;2;13~). When the
773
- // terminal sends it, insert a newline instead of submitting.
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
- // Ask the terminal to bracket pasted text with \x1b[200~ \x1b[201~.
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('\x1b[?2004h');
818
- process.on('exit', () => {
819
- try {
820
- process.stdout.write('\x1b[?2004l');
821
- } catch {
822
- /* ignore */
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 (reliable across terminals, unlike
875
- // bare Shift+Enter which most terminals don't distinguish from Enter).
876
- if (((name === 'return' || name === 'enter') && key.meta) || (key.ctrl && name === 'j')) {
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
- // Put the hit a few lines from the top so its context stays visible.
1278
- this.#chatLog.scrollTo(Math.max(0, lineIndex - 3));
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: !isSelfNow && !isDM && this.#lastSender === nickname,
1794
- stamp: time(),
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.#lines.push(line);
1799
- this.#chatLog.log(line);
2101
+ this.#append(line, { kind: 'message', nickname, text, opts });
1800
2102
  this.#screen.render();
1801
- // The sentinel is written as an escape, not typed as a raw byte: a bare
1802
- // NUL anywhere in the source makes this entire file count as binary, and
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
- this.updateLine(
1818
- lineIndex,
1819
- this.#composeMessageLine(nickname, newText, { ...opts, edited: true }),
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
- this.updateLine(
1826
- lineIndex,
1827
- ` {white-fg}[${time()}]{/white-fg} {#666666-fg}\ud83d\udeab ${blessed.escape(
1828
- nickname,
1829
- )} deleted a message{/#666666-fg}`,
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. Shared by addMessage and replaceMessageText so an
1834
- // edited message keeps exactly the layout it had (alignment, grouping,
1835
- // badges) instead of drifting into a different shape.
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 core = grouped
1870
- ? `{${tag}}\u00b7{/${tag}} ${renderMarkdown(text)}${editedMark}`
1871
- : `${avatar} {${tag}}${nickname}{/${tag}}${trustGlyph}${dmLabel}: ${renderMarkdown(text)}${editedMark}`;
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
- // My own messages on the right (timestamp at the end), others on the left
1874
- return isSelf
1875
- ? this.#alignRight(`${core}${ephLabel}${denLabel} {white-fg}[${stamp}]{/white-fg}`)
1876
- : `${bar}{white-fg}[${stamp}]{/white-fg}${ephLabel}${denLabel} ${mentionMark}${core}`;
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.#lines.push(sep);
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
- const cp = chr.codePointAt(0);
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
- #alignRight(tagged) {
1903
- const avail = (this.#chatLog.width || 0) - this.#chatLog.iwidth - 2;
1904
- const visible = this.#visibleWidth(tagged);
1905
- if (avail <= visible) {
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
- const badgeWidth = this.#visibleWidth(badgeTagged) + 1;
1915
- const leading = baseLine.match(/^ +/);
1916
- const line =
1917
- leading && leading[0].length > badgeWidth
1918
- ? `${baseLine.slice(badgeWidth)} ${badgeTagged}`
1919
- : `${baseLine} ${badgeTagged}`;
1920
- this.updateLine(lineIndex, line);
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
- const line = ` {white-fg}[${time()}]{/white-fg} {magenta-fg}✦ {bold}${blessed.escape(
1928
- nickname,
1929
- )}{/bold} ${renderMarkdown(text)}{/magenta-fg}`;
1930
- this.#lines.push(line);
1931
- this.#chatLog.log(line);
1932
- this.#screen.render();
1933
- this.#noteIncoming();
1934
- return { lineIndex: this.#lines.length - 1 };
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
- const line = ` {white-fg}[${time()}] * ${blessed.escape(text)}{/white-fg}`;
1940
- this.#lines.push(line);
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
- const line = ` {red-fg}[${time()}] ! ${blessed.escape(text)}{/red-fg}`;
1949
- this.#lines.push(line);
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
- const line = ` {cyan-fg}[${time()}] ${blessed.escape(text)}{/cyan-fg}`;
1958
- this.#lines.push(line);
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
- const line = ` {yellow-fg}💡{/yellow-fg} {#9a9ad0-fg}${blessed.escape(text)}{/#9a9ad0-fg}`;
1967
- this.#lines.push(line);
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
- const push = (l) => {
1977
- this.#lines.push(l);
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
- addQuoteLine(nickname, excerpt, alignRight = false) {
1991
- const quoted = `{#888888-fg}↩ ${blessed.escape(nickname)}: "${blessed.escape(excerpt)}"{/#888888-fg}`;
1992
- const line = alignRight ? this.#alignRight(quoted) : ` ${quoted}`;
1993
- this.#lines.push(line);
1994
- this.#chatLog.log(line);
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
- const line = ` ${blessed.escape(raw)}`;
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
- const line = ` ${raw}`;
2011
- this.#lines.push(line);
2012
- this.#chatLog.log(line);
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
- updateLine(lineIndex, newLine) {
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, preserving leading padding
2141
- // (right-aligned self messages) so the flame stays under the text.
2142
- const plain = orig.replace(/\{[^{}]*\}/g, '');
2143
- const lead = (plain.match(/^ */) || [''])[0];
2144
- const body = [...plain.slice(lead.length)];
2145
- const len = body.length;
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
- this.updateLine(lineIndex, lead + burnFrame(bodyStr, front));
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
  }