@gtchakama/wa-tui 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1095 @@
1
+ const blessed = require('neo-blessed');
2
+ const qrcode = require('qrcode-terminal');
3
+ const state = require('./state');
4
+ const waService = require('../whatsapp/service');
5
+ const { formatTimestamp, truncate, chatIdsMatch } = require('../utils/format');
6
+ const { augmentDisplayPlain } = require('../utils/messageFormat');
7
+ const { paginate } = require('../utils/pager');
8
+ const { playIncomingMessageSound } = require('../utils/notifySound');
9
+ const { loadSettings, saveSettings } = require('../config/userSettings');
10
+ const {
11
+ PALETTES,
12
+ PALETTE_ORDER,
13
+ buildTheme,
14
+ normalizePaletteId
15
+ } = require('../config/palettes');
16
+
17
+ /**
18
+ * Theme colours (mutable). Loaded from ~/.wa-tui/settings.json — change via F2 Settings.
19
+ * Terminal font is still chosen in the emulator, not here.
20
+ */
21
+ const theme = Object.assign(
22
+ {},
23
+ buildTheme(normalizePaletteId(loadSettings().palette))
24
+ );
25
+
26
+ const screen = blessed.screen({
27
+ smartCSR: true,
28
+ title: 'wa-tui',
29
+ // neo-blessed only applies cursor styling if `shape` is set (see screen.enter).
30
+ cursor: {
31
+ shape: 'block',
32
+ blink: true,
33
+ color: theme.accent
34
+ },
35
+ style: {
36
+ fg: theme.fg
37
+ }
38
+ });
39
+
40
+ const layout = {
41
+ header: null,
42
+ main: null,
43
+ footer: null,
44
+ chatList: null,
45
+ chatDetail: null,
46
+ replyBar: null,
47
+ settingsList: null,
48
+ qrBox: null
49
+ };
50
+
51
+ let bootLoaderInterval = null;
52
+
53
+ /** xterm-style window resize CSI (opt-in: WA_TUI_RESIZE=1). */
54
+ function tryResizeTerminal(rows, cols) {
55
+ if (process.env.WA_TUI_RESIZE !== '1' || !process.stdout.isTTY) return;
56
+ process.stdout.write(`\x1b[8;${rows};${cols}t`);
57
+ }
58
+
59
+ /** Usable rows between 1-line header (top 0) and 1-line footer. */
60
+ function innerRows() {
61
+ return Math.max(6, (screen.height || 24) - 2);
62
+ }
63
+
64
+ function layoutMainPanel(phase) {
65
+ if (!layout.main) return;
66
+ const inner = innerRows();
67
+ layout.main.top = 1;
68
+ layout.main.left = phase === 'loading' ? 'center' : 'center';
69
+ if (phase === 'qr') {
70
+ layout.main.width = '96%';
71
+ layout.main.height = inner;
72
+ } else if (phase === 'loading') {
73
+ layout.main.width = '74%';
74
+ const h = Math.max(12, Math.min(17, Math.floor(inner * 0.48)));
75
+ layout.main.height = h;
76
+ }
77
+ }
78
+
79
+ function stopBootLoader() {
80
+ if (bootLoaderInterval) {
81
+ clearInterval(bootLoaderInterval);
82
+ bootLoaderInterval = null;
83
+ }
84
+ }
85
+
86
+ function bootLoaderFrame(n) {
87
+ const spin = [' ◐ ', ' ◓ ', ' ◑ ', ' ◒ '];
88
+ const s = spin[n % spin.length];
89
+ const A = theme.accent.slice(1);
90
+ const D = theme.fgDim.slice(1);
91
+ const L = theme.fg.slice(1);
92
+ return (
93
+ `{#${A}-fg} ╭──────────────────────────╮{/}\n` +
94
+ `{#${A}-fg} │{/} {#${L}-fg}╦ ╦┌─┐┬┌┬┐┬ ┬{/} {#${A}-fg}│{/}\n` +
95
+ `{#${A}-fg} │{/} {#${L}-fg}║║║├─┤│ │ ├─┤{/} {#${A}-fg}│{/}\n` +
96
+ `{#${A}-fg} │{/} {#${L}-fg}╚╩╝┴ ┴┴ ┴ ┴ ┴{/} {#${A}-fg}│{/}\n` +
97
+ `{#${A}-fg} ╰──────────────────────────╯{/}\n` +
98
+ `\n` +
99
+ ` {#${A}-fg}${s}{/}{#${D}-fg} session handshake ${s}{/}\n` +
100
+ ` {#${D}-fg}· · · connecting to WhatsApp Web · · ·{/}`
101
+ );
102
+ }
103
+
104
+ function startBootLoader() {
105
+ stopBootLoader();
106
+ let n = 0;
107
+ bootLoaderInterval = setInterval(() => {
108
+ if (!layout.main || state.screen === 'qr' || state.screen === 'chats') return;
109
+ layout.main.setContent(bootLoaderFrame(n));
110
+ n++;
111
+ screen.render();
112
+ }, 160);
113
+ }
114
+
115
+ const LIVE_MSG_ID_CAP = 2000;
116
+ const seenLiveMessageIds = new Set();
117
+
118
+ const liveLineDedupTtlMs = 6000;
119
+ const liveLineFingerprints = new Map();
120
+
121
+ function rememberLiveMessageId(id) {
122
+ if (!id) return;
123
+ if (seenLiveMessageIds.size >= LIVE_MSG_ID_CAP) {
124
+ const oldest = seenLiveMessageIds.values().next().value;
125
+ seenLiveMessageIds.delete(oldest);
126
+ }
127
+ seenLiveMessageIds.add(id);
128
+ }
129
+
130
+ function clearLiveDedup() {
131
+ seenLiveMessageIds.clear();
132
+ liveLineFingerprints.clear();
133
+ }
134
+
135
+ function isDuplicateLiveLine(payload) {
136
+ const body =
137
+ (payload.displayBody != null && String(payload.displayBody).trim()) ||
138
+ (payload.body != null ? String(payload.body).trim() : '');
139
+ const ts = Number(payload.timestamp) || 0;
140
+ const fp = `${payload.fromMe ? '1' : '0'}|${ts}|${body}`;
141
+ const now = Date.now();
142
+ for (const [key, exp] of liveLineFingerprints) {
143
+ if (exp < now) liveLineFingerprints.delete(key);
144
+ }
145
+ if (liveLineFingerprints.has(fp)) return true;
146
+ liveLineFingerprints.set(fp, now + liveLineDedupTtlMs);
147
+ if (liveLineFingerprints.size > 120) {
148
+ const first = liveLineFingerprints.keys().next().value;
149
+ liveLineFingerprints.delete(first);
150
+ }
151
+ return false;
152
+ }
153
+
154
+ function appendMsgListLine(payload) {
155
+ const { fromMe, author, timestamp, id } = payload;
156
+ if (!layout.msgList) return;
157
+ const row = {
158
+ type: payload.type || 'chat',
159
+ hasMedia: Boolean(payload.hasMedia),
160
+ hasQuotedMsg: Boolean(payload.hasQuotedMsg),
161
+ quotedSnippet: payload.quotedSnippet || '',
162
+ body: payload.body,
163
+ localPath: (id && state.mediaPaths[id]) || payload.localPath
164
+ };
165
+ const nameColor = fromMe ? theme.selfMsg : theme.peerMsg;
166
+ const name = fromMe
167
+ ? `{bold}{${nameColor}-fg}You{/${nameColor}-fg}{/bold}`
168
+ : `{bold}{${nameColor}-fg}${author}{/${nameColor}-fg}{/bold}`;
169
+ const time = formatTimestamp(timestamp);
170
+ const text = augmentDisplayPlain(row).replace(/\{/g, '(');
171
+ layout.msgList.add(`[${time}] ${name}: ${text}`);
172
+ try {
173
+ layout.msgList.scrollTo(layout.msgList.getScrollHeight());
174
+ } catch (_) {}
175
+ screen.render();
176
+ }
177
+
178
+ function createHeader() {
179
+ layout.header = blessed.box({
180
+ top: 0,
181
+ left: 0,
182
+ width: '100%',
183
+ height: 1,
184
+ content: '{bold} wa-tui {/bold}',
185
+ tags: true,
186
+ transparent: true,
187
+ style: {
188
+ fg: theme.accent
189
+ },
190
+ padding: { left: 0 }
191
+ });
192
+ screen.append(layout.header);
193
+ }
194
+
195
+ function syncScreenTheme() {
196
+ // Screen is a Node, not an Element — neo-blessed may not define `style` until we set it.
197
+ if (!screen.style) screen.style = {};
198
+ delete screen.style.bg;
199
+ screen.style.fg = theme.fg;
200
+ if (screen.cursor) screen.cursor.color = theme.accent;
201
+ if (typeof screen.cursorColor === 'function') {
202
+ try {
203
+ screen.cursorColor(theme.accent);
204
+ } catch (_) {}
205
+ }
206
+ if (typeof screen.cursorShape === 'function') {
207
+ try {
208
+ screen.cursorShape('block', true);
209
+ } catch (_) {}
210
+ }
211
+ }
212
+
213
+ function refreshWidgetStyles() {
214
+ if (layout.header) {
215
+ layout.header.style.fg = theme.accent;
216
+ delete layout.header.style.bg;
217
+ }
218
+ if (layout.footer) {
219
+ layout.footer.style.fg = theme.fgDim;
220
+ delete layout.footer.style.bg;
221
+ }
222
+ if (layout.main) {
223
+ layout.main.style.fg = theme.fg;
224
+ delete layout.main.style.bg;
225
+ }
226
+ if (layout.chatList) {
227
+ layout.chatList.style.fg = theme.fg;
228
+ delete layout.chatList.style.bg;
229
+ if (layout.chatList.style.selected) {
230
+ layout.chatList.style.selected.fg = theme.accent;
231
+ layout.chatList.style.selected.bold = true;
232
+ layout.chatList.style.selected.underline = true;
233
+ delete layout.chatList.style.selected.bg;
234
+ }
235
+ if (layout.chatList.style.scrollbar) {
236
+ layout.chatList.style.scrollbar.fg = theme.fgDim;
237
+ delete layout.chatList.style.scrollbar.bg;
238
+ }
239
+ if (layout.chatList.style.track) delete layout.chatList.style.track.bg;
240
+ }
241
+ if (layout.chatDetail) {
242
+ layout.chatDetail.style.fg = theme.fg;
243
+ delete layout.chatDetail.style.bg;
244
+ }
245
+ if (layout.msgList) {
246
+ layout.msgList.style.fg = theme.fg;
247
+ delete layout.msgList.style.bg;
248
+ }
249
+ if (layout.input) {
250
+ layout.input.style.fg = theme.fg;
251
+ delete layout.input.style.bg;
252
+ }
253
+ if (layout.replyBar) {
254
+ layout.replyBar.style.fg = theme.fgDim;
255
+ delete layout.replyBar.style.bg;
256
+ }
257
+ if (layout.settingsList) {
258
+ layout.settingsList.style.fg = theme.fg;
259
+ delete layout.settingsList.style.bg;
260
+ if (layout.settingsList.style.selected) {
261
+ layout.settingsList.style.selected.fg = theme.accent;
262
+ layout.settingsList.style.selected.bold = true;
263
+ layout.settingsList.style.selected.underline = true;
264
+ delete layout.settingsList.style.selected.bg;
265
+ }
266
+ if (layout.settingsList.style.scrollbar) {
267
+ layout.settingsList.style.scrollbar.fg = theme.fgDim;
268
+ delete layout.settingsList.style.scrollbar.bg;
269
+ }
270
+ if (layout.settingsList.style.track) delete layout.settingsList.style.track.bg;
271
+ }
272
+ }
273
+
274
+ function applyPalette(paletteId) {
275
+ const id = normalizePaletteId(paletteId);
276
+ Object.assign(theme, buildTheme(id));
277
+ saveSettings({ palette: id });
278
+ syncScreenTheme();
279
+ refreshWidgetStyles();
280
+ }
281
+
282
+ function syncSettingsListGeometry() {
283
+ if (!layout.settingsList) return;
284
+ layout.settingsList.top = 1;
285
+ layout.settingsList.left = 0;
286
+ layout.settingsList.width = '100%';
287
+ layout.settingsList.height = innerRows();
288
+ }
289
+
290
+ function ensureSettingsList() {
291
+ if (layout.settingsList) return;
292
+ layout.settingsList = blessed.list({
293
+ top: 1,
294
+ left: 0,
295
+ width: '100%',
296
+ height: innerRows(),
297
+ keys: true,
298
+ vi: true,
299
+ mouse: true,
300
+ tags: true,
301
+ invertSelected: false,
302
+ transparent: true,
303
+ scrollbar: {
304
+ ch: '│',
305
+ style: { fg: theme.fgDim }
306
+ },
307
+ style: {
308
+ fg: theme.fg,
309
+ selected: {
310
+ fg: theme.accent,
311
+ bold: true,
312
+ underline: true
313
+ }
314
+ }
315
+ });
316
+ layout.settingsList.on('select', (el, index) => {
317
+ const id = PALETTE_ORDER[index];
318
+ if (!id) return;
319
+ applyPalette(id);
320
+ closeSettings();
321
+ });
322
+ screen.append(layout.settingsList);
323
+ }
324
+
325
+ function openSettings() {
326
+ if (state.screen !== 'chats' && state.screen !== 'chatDetail') return;
327
+ state.settingsReturnScreen = state.screen;
328
+ state.screen = 'settings';
329
+ if (layout.chatList) layout.chatList.hide();
330
+ if (layout.chatDetail) layout.chatDetail.hide();
331
+ ensureSettingsList();
332
+ syncSettingsListGeometry();
333
+ const items = PALETTE_ORDER.map((id) => {
334
+ const p = PALETTES[id];
335
+ const mark =
336
+ theme.paletteId === id
337
+ ? ` {${theme.fgDim}-fg}(current){/${theme.fgDim}-fg}`
338
+ : '';
339
+ const label = p.label.replace(/\{/g, '(').replace(/\}/g, ')');
340
+ return `${label} {${theme.fgDim}-fg}· ${id}{/${theme.fgDim}-fg}${mark}`;
341
+ });
342
+ layout.settingsList.setItems(items);
343
+ refreshWidgetStyles();
344
+ layout.settingsList.show();
345
+ layout.settingsList.focus();
346
+ updateTitle();
347
+ updateFooter();
348
+ screen.render();
349
+ }
350
+
351
+ function closeSettings() {
352
+ if (state.screen !== 'settings') return;
353
+ if (layout.settingsList) layout.settingsList.hide();
354
+ const back = state.settingsReturnScreen || 'chats';
355
+ state.screen = back;
356
+ state.settingsReturnScreen = null;
357
+
358
+ if (back === 'chatDetail') {
359
+ layout.chatDetail?.show();
360
+ refreshWidgetStyles();
361
+ redrawChatMessages();
362
+ updateReplyBarContent();
363
+ layout.input?.focus();
364
+ updateTitle();
365
+ updateFooter();
366
+ screen.render();
367
+ return;
368
+ }
369
+ if (back === 'chats' && state.chats && state.chats.length) {
370
+ showChats(state.chats);
371
+ return;
372
+ }
373
+ void refreshChats();
374
+ }
375
+
376
+ function createFooter() {
377
+ layout.footer = blessed.box({
378
+ bottom: 0,
379
+ left: 0,
380
+ width: '100%',
381
+ height: 1,
382
+ content: '',
383
+ transparent: true,
384
+ style: {
385
+ fg: theme.fgDim
386
+ }
387
+ });
388
+ screen.append(layout.footer);
389
+ updateFooter();
390
+ }
391
+
392
+ function updateFooter() {
393
+ if (!layout.footer) return;
394
+ let line;
395
+ if (state.screen === 'settings') {
396
+ line =
397
+ ' [Enter]: apply palette · [Esc]/[F2]: back · Saved: ~/.wa-tui/settings.json · [Q]: Quit';
398
+ } else if (state.screen === 'chatDetail') {
399
+ line =
400
+ ' [Esc]: clr quote / back · [B]: Back · [Ctrl+↑↓]: Quote msg · [Ctrl+D]: DL media · [F2]: Colours · [Ctrl+L]: Logout · [Q]: Quit';
401
+ } else if (state.screen === 'chats') {
402
+ line =
403
+ ' [Q]: Quit · [F2]: Colours · ctrl+L Logout · [R]efresh · [U]nread · [N]/[P] · [1-3] filter · [O] sort';
404
+ } else {
405
+ line = ' [Q]: Quit · [Ctrl+L]: Logout';
406
+ }
407
+ layout.footer.setContent(line);
408
+ }
409
+
410
+ async function performLogout() {
411
+ stopBootLoader();
412
+ if (layout.main && !layout.main.hidden) {
413
+ layout.main.setContent(
414
+ `{${theme.fgDim}-fg}Logging out…{/${theme.fgDim}-fg}`
415
+ );
416
+ }
417
+ if (layout.footer) layout.footer.setContent(' Closing session…');
418
+ screen.render();
419
+ await waService.logoutSession();
420
+ process.exit(0);
421
+ }
422
+
423
+ function updateTitle() {
424
+ let modeText = state.screen.toUpperCase();
425
+ if (state.screen === 'chats') {
426
+ const u = state.unreadOnly ? ' · unread' : '';
427
+ const sortLabel =
428
+ state.chatSort === 'unread'
429
+ ? 'unread⬆'
430
+ : state.chatSort === 'alpha'
431
+ ? 'A-Z'
432
+ : 'recent';
433
+ modeText = `CHATS (${state.filter}${u} · ${sortLabel}) - P${state.page}`;
434
+ } else if (state.screen === 'settings') {
435
+ modeText = 'SETTINGS · Colour palette';
436
+ } else if (state.screen === 'chatDetail') {
437
+ modeText = `CHAT: ${state.currentChatName || 'Unknown'}`;
438
+ }
439
+ layout.header.setContent(
440
+ `{bold}{${theme.accent}-fg}wa-tui{/${theme.accent}-fg}{/bold} | ${modeText} | Unread: ${state.unreadCount}`
441
+ );
442
+ updateFooter();
443
+ screen.render();
444
+ }
445
+
446
+ function syncListAndDetailHeights() {
447
+ const inner = innerRows();
448
+ if (layout.chatList) {
449
+ layout.chatList.top = 1;
450
+ layout.chatList.height = inner;
451
+ }
452
+ applyChatDetailLayout();
453
+ }
454
+
455
+ function applyChatDetailLayout() {
456
+ const inner = innerRows();
457
+ if (!layout.chatDetail || !layout.msgList || !layout.input) return;
458
+ const inputH = layout.input.height || 3;
459
+ const replyH = state.replyTo ? 1 : 0;
460
+ layout.chatDetail.top = 1;
461
+ layout.chatDetail.height = inner;
462
+ if (layout.replyBar) {
463
+ layout.replyBar.hidden = !state.replyTo;
464
+ layout.replyBar.bottom = inputH;
465
+ layout.replyBar.left = 0;
466
+ layout.replyBar.width = '100%';
467
+ layout.replyBar.height = 1;
468
+ }
469
+ layout.input.bottom = 0;
470
+ layout.msgList.top = 0;
471
+ layout.msgList.height = Math.max(4, inner - inputH - replyH);
472
+ }
473
+
474
+ function persistCurrentDraft() {
475
+ if (state.screen !== 'chatDetail' || !state.currentChatId || !layout.input) {
476
+ return;
477
+ }
478
+ const v = layout.input.getValue();
479
+ if (v && String(v).trim()) state.chatDrafts[state.currentChatId] = v;
480
+ else delete state.chatDrafts[state.currentChatId];
481
+ }
482
+
483
+ function clearReplyTarget() {
484
+ state.replyTo = null;
485
+ state.replyPickIndex = null;
486
+ }
487
+
488
+ function updateReplyBarContent() {
489
+ if (!layout.replyBar) return;
490
+ if (!state.replyTo) {
491
+ layout.replyBar.setContent('');
492
+ return;
493
+ }
494
+ const sn = state.replyTo.snippet || '';
495
+ const A = theme.accent.slice(1);
496
+ const D = theme.fgDim.slice(1);
497
+ layout.replyBar.setContent(
498
+ `{#${A}-fg}↪{/} ${state.replyTo.author}: ${sn.replace(/\{/g, '(')} {#${D}-fg}Esc clear · Ctrl+↑↓{/}`
499
+ );
500
+ }
501
+
502
+ function rowsWithPaths() {
503
+ return state.currentMessages.map((m) => ({
504
+ ...m,
505
+ localPath: state.mediaPaths[m.id] || m.localPath
506
+ }));
507
+ }
508
+
509
+ function redrawChatMessages() {
510
+ if (!layout.msgList) return;
511
+ if (!state.currentMessages.length) return;
512
+ const A = theme.accent.slice(1);
513
+ const rows = rowsWithPaths();
514
+ const content = rows
515
+ .map((m) => {
516
+ const nc = (m.fromMe ? theme.selfMsg : theme.peerMsg).slice(1);
517
+ const name = m.fromMe
518
+ ? `{bold}{#${nc}-fg}You{/#${nc}-fg}{/bold}`
519
+ : `{bold}{#${nc}-fg}${m.author}{/#${nc}-fg}{/bold}`;
520
+ const time = formatTimestamp(m.timestamp);
521
+ const mark =
522
+ state.replyTo && m.id === state.replyTo.id
523
+ ? `{#${A}-fg}▶ {/#${A}-fg}`
524
+ : '';
525
+ const plain = augmentDisplayPlain(m).replace(/\{/g, '(');
526
+ return `[${time}] ${mark}${name}: ${plain}`;
527
+ })
528
+ .join('\n');
529
+ layout.msgList.setContent(content);
530
+ }
531
+
532
+ function finishReplyUi() {
533
+ updateReplyBarContent();
534
+ applyChatDetailLayout();
535
+ redrawChatMessages();
536
+ screen.render();
537
+ }
538
+
539
+ /** newer = +1 (Ctrl+↑), older = -1 (Ctrl+↓) */
540
+ function adjustReplyPick(delta) {
541
+ if (state.screen !== 'chatDetail') return;
542
+ const msgs = state.currentMessages;
543
+ if (!msgs.length) return;
544
+ let idx = state.replyPickIndex;
545
+
546
+ if (idx == null) {
547
+ if (delta > 0) return;
548
+ idx = msgs.length - 1;
549
+ } else {
550
+ idx += delta;
551
+ if (idx >= msgs.length) {
552
+ clearReplyTarget();
553
+ updateReplyBarContent();
554
+ applyChatDetailLayout();
555
+ redrawChatMessages();
556
+ screen.render();
557
+ return;
558
+ }
559
+ if (idx < 0) idx = 0;
560
+ }
561
+
562
+ state.replyPickIndex = idx;
563
+ const m = msgs[idx];
564
+ state.replyTo = {
565
+ id: m.id,
566
+ author: m.author,
567
+ snippet: String(m.displayBody || m.body || '')
568
+ .replace(/\{/g, '(')
569
+ .slice(0, 48)
570
+ };
571
+ finishReplyUi();
572
+ }
573
+
574
+ function applyChatSort(chats) {
575
+ const out = [...chats];
576
+ if (state.chatSort === 'unread') {
577
+ out.sort((a, b) => {
578
+ const du = (b.unreadCount || 0) - (a.unreadCount || 0);
579
+ if (du !== 0) return du;
580
+ return (b.timestamp || 0) - (a.timestamp || 0);
581
+ });
582
+ } else if (state.chatSort === 'alpha') {
583
+ out.sort((a, b) =>
584
+ (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' })
585
+ );
586
+ }
587
+ return out;
588
+ }
589
+
590
+ async function downloadHighlightedMedia() {
591
+ if (state.screen !== 'chatDetail') return;
592
+ let targetId = state.replyTo?.id;
593
+ if (targetId) {
594
+ const sel = state.currentMessages.find((m) => m.id === targetId);
595
+ if (sel && !sel.hasMedia) targetId = null;
596
+ }
597
+ if (!targetId) {
598
+ const withMedia = [...state.currentMessages].reverse().find((m) => m.hasMedia);
599
+ targetId = withMedia?.id;
600
+ }
601
+ if (!targetId) {
602
+ const er = theme.error.slice(1);
603
+ layout.msgList.add(
604
+ `{#${er}-fg}No media to download — Ctrl+↓ to pick a message.{/#${er}-fg}`
605
+ );
606
+ screen.render();
607
+ return;
608
+ }
609
+ try {
610
+ const fpath = await waService.downloadMessageMedia(
611
+ targetId,
612
+ state.currentChatId,
613
+ state.currentRawChat
614
+ );
615
+ state.mediaPaths[targetId] = fpath;
616
+ redrawChatMessages();
617
+ const d = theme.fgDim.slice(1);
618
+ layout.msgList.add(`{#${d}-fg}Saved: ${fpath.replace(/\{/g, '(')}{/#${d}-fg}`);
619
+ } catch (e) {
620
+ const er = theme.error.slice(1);
621
+ layout.msgList.add(`{#${er}-fg}Download failed: ${e.message}{/#${er}-fg}`);
622
+ }
623
+ screen.render();
624
+ }
625
+
626
+ function init() {
627
+ state.screen = 'loading';
628
+ tryResizeTerminal(22, 100);
629
+
630
+ createHeader();
631
+ createFooter();
632
+
633
+ layout.main = blessed.box({
634
+ top: 1,
635
+ left: 'center',
636
+ width: '74%',
637
+ height: 12,
638
+ content: bootLoaderFrame(0),
639
+ align: 'center',
640
+ valign: 'middle',
641
+ scrollable: true,
642
+ alwaysScroll: true,
643
+ tags: true,
644
+ transparent: true,
645
+ style: {
646
+ fg: theme.fg
647
+ }
648
+ });
649
+ screen.append(layout.main);
650
+ layoutMainPanel('loading');
651
+ startBootLoader();
652
+
653
+ screen.key(['q', 'C-c'], () => {
654
+ return process.exit(0);
655
+ });
656
+
657
+ screen.on('resize', () => {
658
+ if (state.screen === 'loading') layoutMainPanel('loading');
659
+ else if (state.screen === 'qr') layoutMainPanel('qr');
660
+ syncListAndDetailHeights();
661
+ syncSettingsListGeometry();
662
+ screen.render();
663
+ });
664
+
665
+ syncScreenTheme();
666
+ refreshWidgetStyles();
667
+ screen.render();
668
+ }
669
+
670
+ function showQr(qr) {
671
+ stopBootLoader();
672
+ state.screen = 'qr';
673
+ state.qr = qr;
674
+ tryResizeTerminal(42, 110);
675
+ layout.main.show();
676
+ layoutMainPanel('qr');
677
+ const dim = theme.fgDim.slice(1);
678
+ const fg = theme.fg.slice(1);
679
+ layout.main.setContent(
680
+ `{#${dim}-fg}Scan this QR with WhatsApp →{/}\n\n{#${fg}-fg}Refreshing…{/}`
681
+ );
682
+ screen.render();
683
+
684
+ qrcode.generate(qr, { small: true }, (code) => {
685
+ layout.main.setContent(
686
+ `{#${dim}-fg}Scan this QR with WhatsApp →{/}\n\n{#${fg}-fg}${code}{/}`
687
+ );
688
+ screen.render();
689
+ });
690
+ updateTitle();
691
+ }
692
+
693
+ function showChats(chats) {
694
+ stopBootLoader();
695
+ tryResizeTerminal(30, 100);
696
+ state.screen = 'chats';
697
+ state.loading = false;
698
+ state.chats = chats;
699
+ state.unreadCount = chats.reduce((acc, c) => acc + c.unreadCount, 0);
700
+
701
+ if (layout.main) layout.main.hide();
702
+ if (layout.chatDetail) layout.chatDetail.hide();
703
+ if (layout.settingsList) layout.settingsList.hide();
704
+
705
+ if (!layout.chatList) {
706
+ layout.chatList = blessed.list({
707
+ top: 1,
708
+ left: 0,
709
+ width: '100%',
710
+ height: innerRows(),
711
+ keys: true,
712
+ vi: true,
713
+ mouse: true,
714
+ tags: true,
715
+ invertSelected: false,
716
+ transparent: true,
717
+ scrollbar: {
718
+ ch: '│',
719
+ style: {
720
+ fg: theme.fgDim
721
+ }
722
+ },
723
+ style: {
724
+ fg: theme.fg,
725
+ selected: {
726
+ fg: theme.accent,
727
+ bold: true,
728
+ underline: true
729
+ }
730
+ }
731
+ });
732
+
733
+ screen.append(layout.chatList);
734
+ }
735
+
736
+ const result = paginate(chats, state.page, state.pageSize);
737
+ state.page = result.page;
738
+ const pageItems = result.items;
739
+
740
+ syncListAndDetailHeights();
741
+ layout.chatList.show();
742
+ refreshWidgetStyles();
743
+
744
+ const items = pageItems.map((c) => {
745
+ const unread =
746
+ c.unreadCount > 0
747
+ ? ` {${theme.unread}-fg}[${c.unreadCount}]{/${theme.unread}-fg}`
748
+ : '';
749
+ const type = c.isGroup ? ` {${theme.fgDim}-fg}[Grp]{/${theme.fgDim}-fg}` : '';
750
+ const time = formatTimestamp(c.timestamp);
751
+ const lastMsg = truncate(c.lastMessage);
752
+ // Plain name (no inline fg tags) so the list row's item vs selected fg actually shows.
753
+ const name = String(c.name || '').replace(/\{/g, '(').replace(/\}/g, ')');
754
+
755
+ return `${name}${unread}${type} {${theme.fgDim}-fg}- ${time}{/${theme.fgDim}-fg}\n {${theme.fgDim}-fg}${lastMsg.replace(/\{/g, '(').replace(/\}/g, ')')}{/${theme.fgDim}-fg}`;
756
+ });
757
+
758
+ layout.chatList.setItems(items);
759
+
760
+ layout.chatList.removeAllListeners('select');
761
+ layout.chatList.on('select', async (item, index) => {
762
+ const chat = pageItems[index];
763
+ if (chat) {
764
+ await openChat(chat);
765
+ }
766
+ });
767
+
768
+ layout.chatList.focus();
769
+ updateTitle();
770
+ screen.render();
771
+ }
772
+
773
+ async function openChat(chatOrId) {
774
+ if (state.screen === 'chatDetail' && layout.input) {
775
+ persistCurrentDraft();
776
+ }
777
+
778
+ const chat =
779
+ typeof chatOrId === 'string'
780
+ ? state.chats?.find((c) => c.id === chatOrId)
781
+ : chatOrId;
782
+ if (!chat?.id) {
783
+ console.error('wa-tui: openChat missing chat id', chatOrId);
784
+ return;
785
+ }
786
+
787
+ state.screen = 'chatDetail';
788
+ state.currentChatId = chat.id;
789
+ state.currentChatName = chat.name;
790
+ state.currentRawChat = chat.raw;
791
+ state.loading = true;
792
+ clearLiveDedup();
793
+ clearReplyTarget();
794
+
795
+ if (layout.chatList) layout.chatList.hide();
796
+ if (layout.settingsList) layout.settingsList.hide();
797
+
798
+ if (!layout.chatDetail) {
799
+ layout.chatDetail = blessed.box({
800
+ top: 1,
801
+ left: 0,
802
+ width: '100%',
803
+ height: innerRows(),
804
+ transparent: true,
805
+ style: {
806
+ fg: theme.fg
807
+ }
808
+ });
809
+
810
+ layout.msgList = blessed.log({
811
+ top: 0,
812
+ left: 0,
813
+ width: '100%',
814
+ height: innerRows() - 3,
815
+ scrollable: true,
816
+ alwaysScroll: true,
817
+ tags: true,
818
+ padding: { left: 0, right: 0 },
819
+ transparent: true,
820
+ style: {
821
+ fg: theme.fg
822
+ }
823
+ });
824
+
825
+ layout.replyBar = blessed.box({
826
+ bottom: 3,
827
+ left: 0,
828
+ width: '100%',
829
+ height: 1,
830
+ hidden: true,
831
+ tags: true,
832
+ transparent: true,
833
+ style: {
834
+ fg: theme.fgDim
835
+ }
836
+ });
837
+
838
+ layout.input = blessed.textbox({
839
+ bottom: 0,
840
+ left: 0,
841
+ width: '100%',
842
+ height: 3,
843
+ keys: true,
844
+ inputOnFocus: true,
845
+ transparent: true,
846
+ style: {
847
+ fg: theme.fg
848
+ }
849
+ });
850
+
851
+ layout.input.on('submit', async (text) => {
852
+ if (text.trim()) {
853
+ try {
854
+ const sendOpts = state.replyTo
855
+ ? { quotedMessageId: state.replyTo.id }
856
+ : {};
857
+ await waService.sendMessage(
858
+ state.currentChatId,
859
+ text.trim(),
860
+ state.currentRawChat,
861
+ sendOpts
862
+ );
863
+ layout.input.clearValue();
864
+ delete state.chatDrafts[state.currentChatId];
865
+ clearReplyTarget();
866
+ updateReplyBarContent();
867
+ layout.input.focus();
868
+ screen.render();
869
+ } catch (e) {
870
+ const er = theme.error.slice(1);
871
+ layout.msgList.add(`{#${er}-fg}Failed to send: ${e.message}{/#${er}-fg}`);
872
+ screen.render();
873
+ }
874
+ }
875
+ });
876
+
877
+ layout.chatDetail.append(layout.msgList);
878
+ layout.chatDetail.append(layout.replyBar);
879
+ layout.chatDetail.append(layout.input);
880
+ screen.append(layout.chatDetail);
881
+ }
882
+
883
+ syncListAndDetailHeights();
884
+ layout.chatDetail.show();
885
+ layout.msgList.setContent(`{${theme.fgDim}-fg}Loading messages…{/${theme.fgDim}-fg}`);
886
+ updateTitle();
887
+ screen.render();
888
+
889
+ let messages = [];
890
+ try {
891
+ messages = await waService.getMessages(chat.id, 40, chat.raw);
892
+ } catch (e) {
893
+ const er = theme.error.slice(1);
894
+ layout.msgList.setContent(`{#${er}-fg}Error loading messages: ${e.message}{/#${er}-fg}`);
895
+ screen.render();
896
+ return;
897
+ }
898
+
899
+ for (const m of messages) rememberLiveMessageId(m.id);
900
+
901
+ state.currentMessages = messages;
902
+
903
+ if (!messages || messages.length === 0) {
904
+ layout.msgList.setContent(
905
+ `{${theme.fgDim}-fg}No messages found.{/${theme.fgDim}-fg}`
906
+ );
907
+ } else {
908
+ redrawChatMessages();
909
+ layout.msgList.scrollTo(layout.msgList.getScrollHeight());
910
+ }
911
+
912
+ updateReplyBarContent();
913
+ applyChatDetailLayout();
914
+
915
+ const draft = state.chatDrafts[chat.id];
916
+ if (draft) layout.input.setValue(draft);
917
+ else layout.input.clearValue();
918
+
919
+ layout.input.focus();
920
+ screen.render();
921
+ }
922
+
923
+ function handleReady() {
924
+ stopBootLoader();
925
+ layout.main.setContent(
926
+ `{${theme.fgDim}-fg}WhatsApp ready — loading chats…{/${theme.fgDim}-fg}`
927
+ );
928
+ screen.render();
929
+ refreshChats();
930
+ }
931
+
932
+ async function refreshChats() {
933
+ let chats = await waService.getChats();
934
+
935
+ if (state.filter === 'direct') {
936
+ chats = chats.filter((c) => !c.isGroup);
937
+ } else if (state.filter === 'groups') {
938
+ chats = chats.filter((c) => c.isGroup);
939
+ }
940
+ if (state.unreadOnly) {
941
+ chats = chats.filter((c) => (c.unreadCount || 0) > 0);
942
+ }
943
+
944
+ chats = applyChatSort(chats);
945
+
946
+ showChats(chats);
947
+ }
948
+
949
+ screen.key(['escape'], () => {
950
+ if (state.screen === 'settings') {
951
+ closeSettings();
952
+ return;
953
+ }
954
+ if (state.screen !== 'chatDetail') return;
955
+ if (state.replyTo) {
956
+ clearReplyTarget();
957
+ updateReplyBarContent();
958
+ applyChatDetailLayout();
959
+ redrawChatMessages();
960
+ screen.render();
961
+ return;
962
+ }
963
+ persistCurrentDraft();
964
+ refreshChats();
965
+ });
966
+
967
+ screen.key(['b'], () => {
968
+ if (state.screen !== 'chatDetail') return;
969
+ persistCurrentDraft();
970
+ clearReplyTarget();
971
+ refreshChats();
972
+ });
973
+
974
+ screen.key(['C-l'], () => {
975
+ void performLogout();
976
+ });
977
+
978
+ screen.key(['f2'], () => {
979
+ if (state.screen === 'settings') {
980
+ closeSettings();
981
+ return;
982
+ }
983
+ openSettings();
984
+ });
985
+
986
+ screen.key(['r'], () => {
987
+ if (state.screen === 'chats') {
988
+ refreshChats();
989
+ }
990
+ });
991
+
992
+ screen.key(['1'], () => {
993
+ state.filter = 'all';
994
+ state.page = 1;
995
+ refreshChats();
996
+ });
997
+
998
+ screen.key(['2'], () => {
999
+ state.filter = 'direct';
1000
+ state.page = 1;
1001
+ refreshChats();
1002
+ });
1003
+
1004
+ screen.key(['3'], () => {
1005
+ state.filter = 'groups';
1006
+ state.page = 1;
1007
+ refreshChats();
1008
+ });
1009
+
1010
+ screen.key(['u', 'U'], () => {
1011
+ if (state.screen !== 'chats') return;
1012
+ state.unreadOnly = !state.unreadOnly;
1013
+ state.page = 1;
1014
+ refreshChats();
1015
+ });
1016
+
1017
+ const CHAT_SORT_CYCLE = ['recent', 'unread', 'alpha'];
1018
+
1019
+ screen.key(['o', 'O'], () => {
1020
+ if (state.screen !== 'chats') return;
1021
+ const i = CHAT_SORT_CYCLE.indexOf(state.chatSort);
1022
+ state.chatSort = CHAT_SORT_CYCLE[(i + 1) % CHAT_SORT_CYCLE.length];
1023
+ state.page = 1;
1024
+ refreshChats();
1025
+ });
1026
+
1027
+ screen.key(['C-up'], () => adjustReplyPick(1));
1028
+ screen.key(['C-down'], () => adjustReplyPick(-1));
1029
+
1030
+ screen.key(['C-d'], () => {
1031
+ void downloadHighlightedMedia();
1032
+ });
1033
+
1034
+ waService.on('message', (msg) => {
1035
+ const viewingThisChat =
1036
+ state.screen === 'chatDetail' && chatIdsMatch(state.currentChatId, msg.chatId);
1037
+ if (!msg.fromMe && !viewingThisChat) {
1038
+ playIncomingMessageSound();
1039
+ }
1040
+
1041
+ if (state.screen === 'chats') {
1042
+ refreshChats();
1043
+ }
1044
+
1045
+ if (
1046
+ state.screen !== 'chatDetail' ||
1047
+ !layout.msgList ||
1048
+ !chatIdsMatch(state.currentChatId, msg.chatId)
1049
+ ) {
1050
+ return;
1051
+ }
1052
+
1053
+ if (seenLiveMessageIds.has(msg.id)) return;
1054
+ if (isDuplicateLiveLine(msg)) return;
1055
+
1056
+ rememberLiveMessageId(msg.id);
1057
+ const row = {
1058
+ id: msg.id,
1059
+ body: msg.body,
1060
+ displayBody: msg.displayBody,
1061
+ fromMe: msg.fromMe,
1062
+ author: msg.author,
1063
+ timestamp: msg.timestamp,
1064
+ type: msg.type,
1065
+ hasMedia: msg.hasMedia,
1066
+ hasQuotedMsg: msg.hasQuotedMsg,
1067
+ quotedSnippet: msg.quotedSnippet || ''
1068
+ };
1069
+ state.currentMessages.push(row);
1070
+ if (state.currentMessages.length > 200) {
1071
+ state.currentMessages.splice(0, state.currentMessages.length - 200);
1072
+ }
1073
+ appendMsgListLine(row);
1074
+ });
1075
+
1076
+ screen.key(['n'], () => {
1077
+ if (state.screen === 'chats') {
1078
+ state.page++;
1079
+ refreshChats();
1080
+ }
1081
+ });
1082
+
1083
+ screen.key(['p'], () => {
1084
+ if (state.screen === 'chats' && state.page > 1) {
1085
+ state.page--;
1086
+ refreshChats();
1087
+ }
1088
+ });
1089
+
1090
+ module.exports = {
1091
+ init,
1092
+ showQr,
1093
+ handleReady,
1094
+ applyPalette
1095
+ };