cli-whatsapp 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/bin/wa.js +4 -3
  2. package/dist/cli/commands/auth.js +220 -0
  3. package/dist/cli/commands/export.js +51 -0
  4. package/dist/cli/commands/filepicker.js +232 -0
  5. package/dist/cli/commands/group.js +139 -0
  6. package/dist/cli/commands/manage.js +41 -0
  7. package/dist/cli/commands/media.js +105 -0
  8. package/dist/cli/commands/misc.js +112 -0
  9. package/dist/cli/commands/read.js +79 -0
  10. package/dist/cli/commands/send.js +137 -0
  11. package/dist/cli/commands/shell.js +655 -0
  12. package/dist/cli/commands/sync.js +67 -0
  13. package/dist/cli/index.js +174 -0
  14. package/dist/config/index.js +59 -0
  15. package/{src/db/schema.ts → dist/db/schema.js} +0 -1
  16. package/dist/db/sqlite.js +237 -0
  17. package/dist/utils/format.js +89 -0
  18. package/dist/utils/jid.js +40 -0
  19. package/dist/utils/notify.js +29 -0
  20. package/dist/utils/ui.js +29 -0
  21. package/dist/whatsapp/client.js +157 -0
  22. package/dist/whatsapp/events.js +161 -0
  23. package/dist/whatsapp/logger.js +52 -0
  24. package/package.json +5 -3
  25. package/src/cli/commands/auth.ts +0 -236
  26. package/src/cli/commands/export.ts +0 -60
  27. package/src/cli/commands/filepicker.ts +0 -247
  28. package/src/cli/commands/group.ts +0 -139
  29. package/src/cli/commands/manage.ts +0 -46
  30. package/src/cli/commands/media.ts +0 -125
  31. package/src/cli/commands/misc.ts +0 -113
  32. package/src/cli/commands/read.ts +0 -99
  33. package/src/cli/commands/send.ts +0 -201
  34. package/src/cli/commands/shell.ts +0 -596
  35. package/src/cli/commands/sync.ts +0 -80
  36. package/src/cli/index.ts +0 -198
  37. package/src/config/index.ts +0 -92
  38. package/src/db/sqlite.ts +0 -354
  39. package/src/utils/format.ts +0 -102
  40. package/src/utils/jid.ts +0 -51
  41. package/src/utils/notify.ts +0 -29
  42. package/src/utils/ui.ts +0 -35
  43. package/src/whatsapp/client.ts +0 -207
  44. package/src/whatsapp/events.ts +0 -166
  45. package/src/whatsapp/logger.ts +0 -71
@@ -0,0 +1,655 @@
1
+ import readline, { createInterface } from 'node:readline';
2
+ import { readdirSync, statSync, existsSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { chats, contacts, search, history } from "./read.js";
6
+ import { reply, buildMediaContent, sendContent } from "./send.js";
7
+ import { media } from "./media.js";
8
+ import { pickFile, pickFromList } from "./filepicker.js";
9
+ import { recent, pin, unpin, archive, unarchive } from "./manage.js";
10
+ import { status } from "./auth.js";
11
+ import { connect, hasSession } from "../../whatsapp/client.js";
12
+ import { registerPersistence, extractText, isGroupJid } from "../../whatsapp/events.js";
13
+ import { listChats, findChat, getMessages, countMessages, } from "../../db/sqlite.js";
14
+ import { resolveTarget } from "../../utils/jid.js";
15
+ import { loadConfig, saveConfig } from "../../config/index.js";
16
+ import { c, displayName, formatTime, truncate, renderMessages } from "../../utils/format.js";
17
+ import { print, warn, printError } from "../../utils/ui.js";
18
+ import { notify } from "../../utils/notify.js";
19
+ // ---- tab completion --------------------------------------------------------
20
+ const SLASH_ITEMS = [
21
+ { value: '/open', desc: 'open a chat — then just type to send', args: true },
22
+ { value: '/close', desc: 'leave the current chat' },
23
+ { value: '/chats', desc: 'list conversations' },
24
+ { value: '/recent', desc: 'recently active chats' },
25
+ { value: '/contacts', desc: 'list contacts' },
26
+ { value: '/history', desc: 'extended message history', args: true },
27
+ { value: '/search', desc: 'search messages', args: true },
28
+ { value: '/send', desc: 'send a message to a chat', args: true },
29
+ { value: '/sendfile', desc: 'send a file (media)', args: true },
30
+ { value: '/reply', desc: 'reply to a message by #id', args: true },
31
+ { value: '/media', desc: 'list cached media' },
32
+ { value: '/pin', desc: 'pin a chat to the top', args: true },
33
+ { value: '/unpin', desc: 'unpin a chat', args: true },
34
+ { value: '/archive', desc: 'archive (hide) a chat', args: true },
35
+ { value: '/unarchive', desc: 'unarchive a chat', args: true },
36
+ { value: '/connect', desc: 'go live — receive messages' },
37
+ { value: '/disconnect', desc: 'drop the live connection' },
38
+ { value: '/status', desc: 'session & cache status' },
39
+ { value: '/settings', desc: 'view settings' },
40
+ { value: '/set', desc: 'change a setting', args: true },
41
+ { value: '/clear', desc: 'clear the screen' },
42
+ { value: '/help', desc: 'list commands' },
43
+ { value: '/quit', desc: 'exit' },
44
+ ];
45
+ const SLASH = SLASH_ITEMS.map((it) => it.value);
46
+ const CHAT_CMDS = new Set([
47
+ '/open', '/send', '/sendfile', '/pin', '/unpin', '/archive', '/unarchive', '/history',
48
+ ]);
49
+ let nameCache = [];
50
+ function refreshNames() {
51
+ nameCache = listChats(1000, true).map(displayName);
52
+ }
53
+ function completer(line) {
54
+ // `@…` anywhere in the line → complete a filesystem path to attach.
55
+ const lastTok = line.slice(line.lastIndexOf(' ') + 1);
56
+ if (lastTok.startsWith('@'))
57
+ return completeAtPath(lastTok);
58
+ const parts = line.split(/\s+/);
59
+ if (parts.length <= 1) {
60
+ if (!line.startsWith('/'))
61
+ return [[], line];
62
+ const hits = SLASH.filter((cmd) => cmd.startsWith(line));
63
+ return [hits.length ? hits : SLASH, line];
64
+ }
65
+ if (CHAT_CMDS.has(parts[0])) {
66
+ const partial = parts[parts.length - 1].toLowerCase();
67
+ const hits = nameCache.filter((n) => n.toLowerCase().startsWith(partial)).slice(0, 25);
68
+ return [hits, parts[parts.length - 1]];
69
+ }
70
+ return [[], line];
71
+ }
72
+ /** Expand a leading ~ to the home directory. */
73
+ function expandHome(p) {
74
+ return p.replace(/^~(?=\/|$)/, homedir());
75
+ }
76
+ /**
77
+ * Tab-completion for an `@<path>` token: lists matching files/dirs so you can
78
+ * browse the filesystem to attach a file (directories get a trailing /).
79
+ */
80
+ function completeAtPath(token) {
81
+ const raw = token.slice(1); // drop the leading '@'
82
+ const slash = raw.lastIndexOf('/');
83
+ const dirPart = slash >= 0 ? raw.slice(0, slash + 1) : ''; // keeps trailing /
84
+ const basePart = slash >= 0 ? raw.slice(slash + 1) : raw;
85
+ const fsDir = expandHome(dirPart || '.');
86
+ let entries;
87
+ try {
88
+ entries = readdirSync(fsDir);
89
+ }
90
+ catch {
91
+ return [[token], token];
92
+ }
93
+ const showHidden = basePart.startsWith('.');
94
+ const hits = entries
95
+ .filter((e) => e.startsWith(basePart) && (showHidden || !e.startsWith('.')))
96
+ .sort()
97
+ .slice(0, 100)
98
+ .map((e) => {
99
+ let suffix = '';
100
+ try {
101
+ if (statSync(join(fsDir, e)).isDirectory())
102
+ suffix = '/';
103
+ }
104
+ catch {
105
+ /* ignore unstatable entries */
106
+ }
107
+ return '@' + dirPart + e + suffix;
108
+ });
109
+ return [hits.length ? hits : [token], token];
110
+ }
111
+ /**
112
+ * Find an `@<path>` attachment in a composed message, if the file exists.
113
+ * Supports `@"quoted paths with spaces"` as well as bare `@path` tokens.
114
+ */
115
+ function extractAttachment(line) {
116
+ const m = line.match(/(?:^|\s)@(?:"([^"]+)"|(\S+))/);
117
+ if (!m || m.index === undefined)
118
+ return null;
119
+ const file = expandHome(m[1] ?? m[2]);
120
+ if (!existsSync(file) || !statSync(file).isFile())
121
+ return null;
122
+ const caption = (line.slice(0, m.index) + line.slice(m.index + m[0].length)).trim();
123
+ return { file, caption };
124
+ }
125
+ // ---- rendering -------------------------------------------------------------
126
+ function banner() {
127
+ const linked = hasSession();
128
+ const dot = linked ? c.green('●') : c.red('○');
129
+ const state = linked ? c.green('linked') : c.red('not linked');
130
+ const stats = c.dim(`${listChats(5000).length} chats · ${countMessages()} messages`);
131
+ const lines = [
132
+ `${c.green('✆')} ${c.bold(c.green('WhatsApp'))} ${c.bold('CLI')} ${c.dim('interactive')}`,
133
+ `${dot} ${state} ${stats}`,
134
+ linked
135
+ ? c.dim('Type /open <chat> then just type to chat. /help for commands.')
136
+ : c.dim('Run /connect after `wa login` to link this device.'),
137
+ ];
138
+ const width = Math.min(64, Math.max(...lines.map((l) => stripAnsi(l).length)) + 2);
139
+ const bar = (l, r) => c.teal(l + '─'.repeat(width) + r);
140
+ print(bar('╭', '╮'));
141
+ for (const l of lines) {
142
+ const pad = ' '.repeat(Math.max(0, width - 1 - stripAnsi(l).length));
143
+ print(c.teal('│') + ' ' + l + pad + c.teal('│'));
144
+ }
145
+ print(bar('╰', '╯'));
146
+ }
147
+ function stripAnsi(s) {
148
+ // eslint-disable-next-line no-control-regex
149
+ return s.replace(/\x1b\[[0-9;]*m/g, '');
150
+ }
151
+ function statusDot(s) {
152
+ return s === 'live' ? c.green('●') : s === 'connecting' ? c.yellow('◐') : c.dim('○');
153
+ }
154
+ function setPrompt(state) {
155
+ const where = state.active ? c.bold(displayName(state.active)) : c.dim('wa');
156
+ state.rl.setPrompt(`${statusDot(state.status)} ${where} ${c.dim('›')} `);
157
+ }
158
+ /** Print a line above the input prompt without disturbing what's being typed. */
159
+ function printAbove(state, text) {
160
+ readline.cursorTo(process.stdout, 0);
161
+ readline.clearLine(process.stdout, 0);
162
+ process.stdout.write(text + '\n');
163
+ state.rl.prompt(true);
164
+ }
165
+ function bubble(who, text, incoming) {
166
+ const time = c.gray(formatTime(Date.now(), loadConfig().preferences.time24h));
167
+ const name = incoming ? c.cyan(who) : c.green('me');
168
+ return `${time} ${name}: ${text}`;
169
+ }
170
+ // ---- connection ------------------------------------------------------------
171
+ function onIncoming(state, msg) {
172
+ if (msg.key.fromMe)
173
+ return;
174
+ const jid = msg.key.remoteJid;
175
+ if (!jid || jid === 'status@broadcast')
176
+ return;
177
+ const text = extractText(msg.message) || c.dim('[media]');
178
+ const chat = findChat(jid);
179
+ const who = isGroupJid(jid) && msg.pushName
180
+ ? `${chat ? displayName(chat) : jid.split('@')[0]}/${msg.pushName}`
181
+ : chat
182
+ ? displayName(chat)
183
+ : msg.pushName ?? jid.split('@')[0];
184
+ if (state.active && jid === state.active.jid) {
185
+ printAbove(state, bubble(who, text, true));
186
+ }
187
+ else {
188
+ printAbove(state, c.dim(`🔔 ${who}: ${truncate(stripAnsi(text), 60)}`));
189
+ }
190
+ if (loadConfig().preferences.notifications) {
191
+ notify(who, extractText(msg.message) || 'sent media');
192
+ }
193
+ }
194
+ async function ensureConnected(state) {
195
+ if (state.conn && state.status === 'live')
196
+ return state.conn;
197
+ if (!hasSession()) {
198
+ warn('Not linked. Run `wa login` in another terminal first.');
199
+ return null;
200
+ }
201
+ state.status = 'connecting';
202
+ setPrompt(state);
203
+ printAbove(state, c.dim('connecting…'));
204
+ try {
205
+ const conn = await connect({ requireSession: true });
206
+ registerPersistence(conn.sock, { onMessage: (m) => onIncoming(state, m) });
207
+ conn.sock.ev.on('connection.update', (u) => {
208
+ if (u.connection !== 'close' || state.quitting)
209
+ return;
210
+ if (state.manualClose) {
211
+ // User-initiated (/disconnect) — that handler already reported it.
212
+ state.manualClose = false;
213
+ return;
214
+ }
215
+ state.status = 'offline';
216
+ state.conn = null;
217
+ setPrompt(state);
218
+ printAbove(state, c.dim('disconnected. Type /connect to reconnect.'));
219
+ });
220
+ await conn.ready;
221
+ state.conn = conn;
222
+ state.status = 'live';
223
+ setPrompt(state);
224
+ printAbove(state, c.green('● live — receiving messages'));
225
+ return conn;
226
+ }
227
+ catch (err) {
228
+ state.status = 'offline';
229
+ state.conn = null;
230
+ setPrompt(state);
231
+ warn(`Could not connect: ${err.message}`);
232
+ return null;
233
+ }
234
+ }
235
+ // ---- sending ---------------------------------------------------------------
236
+ async function sendTo(state, jid, name, content, meta) {
237
+ const conn = await ensureConnected(state);
238
+ if (!conn)
239
+ return;
240
+ try {
241
+ await sendContent(conn.sock, jid, content, meta);
242
+ const echo = meta.type === 'text' ? meta.previewText : `[${meta.type}] ${meta.previewText}`;
243
+ printAbove(state, bubble(name, echo, false));
244
+ refreshNames();
245
+ }
246
+ catch (err) {
247
+ warn(`Send failed: ${err.message}`);
248
+ }
249
+ }
250
+ // ---- chat context ----------------------------------------------------------
251
+ function openChat(state, target) {
252
+ const chat = findChat(target);
253
+ if (!chat) {
254
+ warn(`No chat matching "${target}". Try /chats or /search.`);
255
+ return;
256
+ }
257
+ state.active = chat;
258
+ setPrompt(state);
259
+ print(c.dim('─'.repeat(40)));
260
+ print(c.bold(displayName(chat)) + c.dim(` ${chat.jid}`));
261
+ print(renderMessages(getMessages(chat.jid, 15), loadConfig().preferences.time24h));
262
+ print(c.dim(`─── in this chat: type to send · @ opens a file picker to attach · /close ───`));
263
+ }
264
+ // ---- settings --------------------------------------------------------------
265
+ function showSettings() {
266
+ const cfg = loadConfig();
267
+ print(c.bold('Settings'));
268
+ print(` openLimit ${c.cyan(String(cfg.preferences.openLimit))} ${c.dim('messages shown by /open')}`);
269
+ print(` time24h ${c.cyan(String(cfg.preferences.time24h))} ${c.dim('24-hour clock')}`);
270
+ print(` notifications ${c.cyan(String(cfg.preferences.notifications))} ${c.dim('desktop alerts on new messages')}`);
271
+ print(` autoConnect ${c.cyan(String(cfg.preferences.autoConnect))} ${c.dim('go live automatically on start')}`);
272
+ print(` ai.enabled ${c.cyan(String(cfg.ai.enabled))} ${c.dim('(AI plugin)')}`);
273
+ print(c.dim(' Change with: /set <key> <value> e.g. /set autoConnect false'));
274
+ }
275
+ function setSetting(key, value) {
276
+ const cfg = loadConfig();
277
+ switch (key) {
278
+ case 'openLimit':
279
+ cfg.preferences.openLimit = Math.max(1, parseInt(value, 10) || cfg.preferences.openLimit);
280
+ break;
281
+ case 'time24h':
282
+ cfg.preferences.time24h = value === 'true' || value === '1';
283
+ break;
284
+ case 'notifications':
285
+ cfg.preferences.notifications = value === 'true' || value === '1';
286
+ break;
287
+ case 'autoConnect':
288
+ cfg.preferences.autoConnect = value === 'true' || value === '1';
289
+ break;
290
+ case 'ai.enabled':
291
+ cfg.ai.enabled = value === 'true' || value === '1';
292
+ break;
293
+ default:
294
+ warn(`Unknown key "${key}". Editable: openLimit, time24h, notifications, autoConnect, ai.enabled`);
295
+ return;
296
+ }
297
+ saveConfig(cfg);
298
+ print(`${c.green('✓')} set ${key} = ${value}`);
299
+ }
300
+ // ---- help ------------------------------------------------------------------
301
+ const HELP = `${c.bold('Commands')} ${c.dim('(slash optional for the first word)')}
302
+ ${c.cyan('/open')} <chat> open a chat — then just type to send
303
+ ${c.green('@')} open a file picker to attach (↑↓ to choose, ⏎ to pick)
304
+ ${c.cyan('/close')} leave the current chat
305
+ ${c.cyan('/chats')} · ${c.cyan('/recent')} · ${c.cyan('/contacts')} list chats / recent / contacts
306
+ ${c.cyan('/history')} [n] · ${c.cyan('/search')} <text> more history / search
307
+ ${c.cyan('/send')} <chat> <msg> send without opening
308
+ ${c.cyan('/sendfile')} <chat> <path> [caption] send media
309
+ ${c.cyan('/reply')} <id> <msg> reply to a message by #id
310
+ ${c.cyan('/media')} [type] list media
311
+ ${c.cyan('/pin')} · ${c.cyan('/unpin')} · ${c.cyan('/archive')} · ${c.cyan('/unarchive')} <chat>
312
+ ${c.cyan('/connect')} · ${c.cyan('/disconnect')} go live (receive) / drop connection
313
+ ${c.cyan('/status')} · ${c.cyan('/settings')} · ${c.cyan('/set')} <k> <v> status / settings
314
+ ${c.cyan('/clear')} · ${c.cyan('/help')} · ${c.cyan('/quit')}`;
315
+ // ---- command dispatch ------------------------------------------------------
316
+ function tokenize(line) {
317
+ const out = [];
318
+ const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
319
+ let m;
320
+ while ((m = re.exec(line)))
321
+ out.push(m[1] ?? m[2] ?? m[3]);
322
+ return out;
323
+ }
324
+ async function handle(state, raw) {
325
+ const line = raw.trim();
326
+ if (!line)
327
+ return;
328
+ // Message composed with a chat open (no leading slash).
329
+ if (!line.startsWith('/') && state.active) {
330
+ const jid = state.active.jid;
331
+ const name = displayName(state.active);
332
+ // `@<path>` attaches a file (rest of the line becomes the caption).
333
+ const at = extractAttachment(line);
334
+ if (at) {
335
+ try {
336
+ const built = buildMediaContent(at.file, at.caption || undefined);
337
+ await sendTo(state, jid, name, built.content, {
338
+ previewText: at.caption || `[${built.kind}]`,
339
+ type: built.kind,
340
+ mediaPath: built.path,
341
+ });
342
+ }
343
+ catch (err) {
344
+ printError(err);
345
+ }
346
+ return;
347
+ }
348
+ // Otherwise plain text.
349
+ await sendTo(state, jid, name, { text: line }, { previewText: line, type: 'text' });
350
+ return;
351
+ }
352
+ const [cmdRaw, ...args] = tokenize(line);
353
+ const cmd = cmdRaw.replace(/^\//, '').toLowerCase();
354
+ const need = (v, n) => {
355
+ if (!v) {
356
+ warn(`Missing <${n}>.`);
357
+ return false;
358
+ }
359
+ return true;
360
+ };
361
+ switch (cmd) {
362
+ case 'quit':
363
+ case 'exit':
364
+ case 'q':
365
+ state.quitting = true;
366
+ state.rl.close();
367
+ return;
368
+ case 'help':
369
+ case 'h':
370
+ case '?':
371
+ print(HELP);
372
+ return;
373
+ case 'clear':
374
+ process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
375
+ banner();
376
+ return;
377
+ case 'open':
378
+ if (need(args[0], 'chat'))
379
+ openChat(state, args.join(' '));
380
+ return;
381
+ case 'close':
382
+ case 'back':
383
+ state.active = null;
384
+ setPrompt(state);
385
+ print(c.dim('left chat.'));
386
+ return;
387
+ case 'chats':
388
+ chats({ limit: args[0] });
389
+ refreshNames();
390
+ return;
391
+ case 'recent':
392
+ recent({ limit: args[0] });
393
+ return;
394
+ case 'contacts':
395
+ contacts({ limit: args[0] });
396
+ return;
397
+ case 'history':
398
+ if (state.active && (!args[0] || /^\d+$/.test(args[0])))
399
+ history(state.active.jid, { limit: args[0] });
400
+ else if (need(args[0], 'chat'))
401
+ history(args[0], { limit: args[1] });
402
+ return;
403
+ case 'search':
404
+ if (need(args[0], 'text'))
405
+ search(args.join(' '), {});
406
+ return;
407
+ case 'media':
408
+ media(args[0], {});
409
+ return;
410
+ case 'status':
411
+ status();
412
+ return;
413
+ case 'settings':
414
+ case 'config':
415
+ showSettings();
416
+ return;
417
+ case 'set':
418
+ if (need(args[0], 'key') && need(args[1], 'value'))
419
+ setSetting(args[0], args[1]);
420
+ return;
421
+ case 'pin':
422
+ if (need(args[0], 'chat')) {
423
+ pin(args.join(' '));
424
+ refreshNames();
425
+ }
426
+ return;
427
+ case 'unpin':
428
+ if (need(args[0], 'chat'))
429
+ unpin(args.join(' '));
430
+ return;
431
+ case 'archive':
432
+ if (need(args[0], 'chat'))
433
+ archive(args.join(' '));
434
+ return;
435
+ case 'unarchive':
436
+ if (need(args[0], 'chat'))
437
+ unarchive(args.join(' '));
438
+ return;
439
+ case 'connect':
440
+ case 'watch':
441
+ case 'live':
442
+ await ensureConnected(state);
443
+ return;
444
+ case 'disconnect':
445
+ if (state.conn) {
446
+ state.manualClose = true; // suppress the drop-listener's message
447
+ state.conn.close();
448
+ state.conn = null;
449
+ state.status = 'offline';
450
+ setPrompt(state);
451
+ print(c.dim('disconnected.'));
452
+ }
453
+ else
454
+ print(c.dim('not connected.'));
455
+ return;
456
+ case 'send': {
457
+ if (!need(args[0], 'chat') || !need(args[1], 'message'))
458
+ return;
459
+ const t = resolveTarget(args[0]);
460
+ if (!t) {
461
+ warn(`Could not resolve "${args[0]}".`);
462
+ return;
463
+ }
464
+ const text = args.slice(1).join(' ');
465
+ await sendTo(state, t.jid, t.name, { text }, { previewText: text, type: 'text' });
466
+ return;
467
+ }
468
+ case 'sendfile': {
469
+ if (!need(args[0], 'chat') || !need(args[1], 'path'))
470
+ return;
471
+ const t = resolveTarget(args[0]);
472
+ if (!t) {
473
+ warn(`Could not resolve "${args[0]}".`);
474
+ return;
475
+ }
476
+ try {
477
+ const caption = args.slice(2).join(' ') || undefined;
478
+ const built = buildMediaContent(args[1], caption);
479
+ await sendTo(state, t.jid, t.name, built.content, {
480
+ previewText: caption || `[${built.kind}]`,
481
+ type: built.kind,
482
+ mediaPath: built.path,
483
+ });
484
+ }
485
+ catch (err) {
486
+ printError(err);
487
+ }
488
+ return;
489
+ }
490
+ case 'reply':
491
+ if (need(args[0], 'id') && need(args[1], 'message'))
492
+ await reply(args[0], args.slice(1).join(' '));
493
+ return;
494
+ default:
495
+ warn(`Unknown command "${cmd}". Type /help.`);
496
+ }
497
+ }
498
+ // ---- entry -----------------------------------------------------------------
499
+ /** wa shell — the interactive client. Also runs when `wa` is given no command. */
500
+ export async function shell() {
501
+ banner();
502
+ refreshNames();
503
+ const rl = createInterface({
504
+ input: process.stdin,
505
+ output: process.stdout,
506
+ completer,
507
+ historySize: 200,
508
+ });
509
+ const state = { active: null, conn: null, status: 'offline', rl, quitting: false, manualClose: false };
510
+ setPrompt(state);
511
+ rl.prompt();
512
+ // Go live automatically on start (unless disabled or not linked). Fire-and-
513
+ // forget so browsing stays instant while the socket connects in the background.
514
+ if (process.stdin.isTTY && hasSession() && loadConfig().preferences.autoConnect) {
515
+ void ensureConnected(state);
516
+ }
517
+ let busy = false;
518
+ const queue = [];
519
+ const pump = async () => {
520
+ if (busy)
521
+ return;
522
+ busy = true;
523
+ while (queue.length) {
524
+ const line = queue.shift();
525
+ try {
526
+ await handle(state, line);
527
+ }
528
+ catch (err) {
529
+ printError(err);
530
+ }
531
+ }
532
+ busy = false;
533
+ if (!state.quitting)
534
+ rl.prompt();
535
+ };
536
+ rl.on('line', (line) => {
537
+ queue.push(line);
538
+ void pump();
539
+ });
540
+ // Overlays open synchronously on the trigger key so they reliably capture the
541
+ // keys you type next (no race with the line being submitted). `/` at the start
542
+ // of the line opens the slash-command menu; `@` (with a chat open, at a token
543
+ // boundary) opens the file picker. readline processes the keypress before our
544
+ // listener, so rl.line already includes the just-typed character.
545
+ readline.emitKeypressEvents(process.stdin);
546
+ let pickerOpen = false;
547
+ // Commands whose <chat> argument gets the live chat picker. "final" = chat is
548
+ // the last arg (run on pick); "then" = chat then more input (send/sendfile).
549
+ const CHAT_FINAL = new Set(['open', 'history', 'pin', 'unpin', 'archive', 'unarchive']);
550
+ const CHAT_THEN = new Set(['send', 'sendfile']);
551
+ const clearLine = () => rl.write(null, { ctrl: true, name: 'u' });
552
+ // Open the chat picker for a command, then insert/run with the chosen chat.
553
+ const chatPickInto = async (cmd) => {
554
+ const items = listChats(3000, true).map((ch) => ({
555
+ value: displayName(ch),
556
+ desc: truncate((ch.lastMessage ?? '').replace(/\s+/g, ' '), 30),
557
+ }));
558
+ const chat = await pickFromList('Select a chat', items, '');
559
+ clearLine();
560
+ if (!chat)
561
+ return; // cancelled → empty line
562
+ const q = /\s/.test(chat) ? `"${chat}"` : chat; // quote names with spaces
563
+ if (CHAT_THEN.has(cmd))
564
+ rl.write(`/${cmd} ${q} `); // wait for message/file
565
+ else {
566
+ queue.push(`/${cmd} ${q}`); // run right away
567
+ void pump();
568
+ }
569
+ };
570
+ process.stdin.on('keypress', (str, _key) => {
571
+ void _key;
572
+ if (!process.stdin.isTTY || pickerOpen || busy)
573
+ return;
574
+ // `/` menu — only when it's the first character on the line.
575
+ if (str === '/' && rl.line === '/') {
576
+ pickerOpen = true;
577
+ void (async () => {
578
+ try {
579
+ const picked = await pickFromList('Slash Commands', SLASH_ITEMS, '');
580
+ clearLine();
581
+ if (!picked)
582
+ return; // cancelled — leave the line empty
583
+ const cmd = picked.slice(1);
584
+ if (CHAT_FINAL.has(cmd) || CHAT_THEN.has(cmd)) {
585
+ await chatPickInto(cmd); // chain straight into the chat picker
586
+ return;
587
+ }
588
+ const item = SLASH_ITEMS.find((i) => i.value === picked);
589
+ if (item && !item.args) {
590
+ queue.push(picked); // no args → run now
591
+ void pump();
592
+ }
593
+ else {
594
+ rl.write(picked + ' '); // needs (non-chat) args → wait
595
+ }
596
+ }
597
+ finally {
598
+ pickerOpen = false;
599
+ }
600
+ })();
601
+ return;
602
+ }
603
+ // Space right after a chat command (typed manually) → open the chat picker.
604
+ if (str === ' ') {
605
+ const m = rl.line.match(/^\/(open|history|pin|unpin|archive|unarchive|send|sendfile) $/);
606
+ if (m) {
607
+ pickerOpen = true;
608
+ void (async () => {
609
+ try {
610
+ await chatPickInto(m[1]);
611
+ }
612
+ finally {
613
+ pickerOpen = false;
614
+ }
615
+ })();
616
+ return;
617
+ }
618
+ }
619
+ // `@` file picker — only with a chat open and at a fresh token boundary.
620
+ if (str === '@' && state.active) {
621
+ const before = rl.line.slice(0, Math.max(0, rl.cursor - 1)).replace(/@$/, '');
622
+ if (before !== '' && !before.endsWith(' '))
623
+ return;
624
+ pickerOpen = true;
625
+ pickFile(process.cwd())
626
+ .then((picked) => {
627
+ if (picked)
628
+ rl.write(picked.includes(' ') ? `"${picked}"` : picked);
629
+ else
630
+ rl.prompt(true);
631
+ })
632
+ .finally(() => {
633
+ pickerOpen = false;
634
+ });
635
+ }
636
+ });
637
+ rl.on('SIGINT', () => {
638
+ if (rl.line) {
639
+ // Clear the current input on first Ctrl+C.
640
+ rl.write(null, { ctrl: true, name: 'u' });
641
+ }
642
+ else {
643
+ state.quitting = true;
644
+ rl.close();
645
+ }
646
+ });
647
+ await new Promise((resolve) => {
648
+ rl.on('close', () => {
649
+ state.quitting = true;
650
+ state.conn?.close();
651
+ print('\n' + c.dim('Bye.'));
652
+ resolve();
653
+ });
654
+ });
655
+ }