cli-whatsapp 0.1.16 → 0.1.19

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.
@@ -3,7 +3,9 @@ import { readdirSync, statSync } from 'node:fs';
3
3
  import { resolve, join, relative } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { c } from "../../utils/format.js";
6
- const VISIBLE = 12; // rows shown at once
6
+ // Rows shown at once capped so the whole overlay (list + ~6 lines of chrome)
7
+ // fits the terminal height and never has to scroll.
8
+ const VISIBLE = Math.max(4, Math.min(12, (process.stdout.rows || 24) - 9));
7
9
  const WIDTH = 58; // inner box width
8
10
  const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
9
11
  function pad(s, w) {
@@ -16,13 +18,16 @@ function highlight(s) {
16
18
  }
17
19
  const border = (l, r) => c.teal(l + '─'.repeat(WIDTH) + r);
18
20
  /**
19
- * Generic raw-mode overlay runner: sets up keypress capture (stealing readline's
20
- * handlers for the duration), renders below the prompt, and restores everything
21
- * on exit. `render` draws the overlay; `makeOnKey(done)` returns the keypress
22
- * handler and calls `done(result)` to close. No-op (null) on a non-TTY.
21
+ * Generic raw-mode overlay runner. Draws the overlay on the lines *below* the
22
+ * prompt and repaints using only RELATIVE cursor moves (never absolute
23
+ * save/restore), so it stays correct even when drawing near the bottom of the
24
+ * screen scrolls the terminal no stacking/ghosting. `buildRows()` returns the
25
+ * overlay's lines; `makeOnKey(done, repaint)` handles keys, calling `repaint()`
26
+ * after state changes and `done(result)` to close. No-op (null) on a non-TTY.
23
27
  */
24
- function runOverlay(render, makeOnKey) {
28
+ function runOverlay(buildRows, makeOnKey) {
25
29
  const input = process.stdin;
30
+ const out = process.stdout;
26
31
  if (!input.isTTY)
27
32
  return Promise.resolve(null);
28
33
  readline.emitKeypressEvents(input);
@@ -30,20 +35,40 @@ function runOverlay(render, makeOnKey) {
30
35
  input.setRawMode(true);
31
36
  const saved = input.listeners('keypress');
32
37
  input.removeAllListeners('keypress');
33
- process.stdout.write('\x1b7'); // save cursor at the prompt
34
- render();
38
+ // Cursor rests at the overlay's top-left between paints. All moves are
39
+ // relative to it, which survives terminal scrolling.
40
+ let drawn = 0;
41
+ const paint = () => {
42
+ const rows = buildRows();
43
+ if (drawn === 0) {
44
+ out.write('\n'); // open the first overlay line just below the prompt
45
+ }
46
+ else {
47
+ readline.cursorTo(out, 0);
48
+ readline.clearScreenDown(out); // wipe the previous overlay in place
49
+ }
50
+ out.write(rows.join('\n'));
51
+ if (rows.length > 1)
52
+ readline.moveCursor(out, 0, -(rows.length - 1));
53
+ readline.cursorTo(out, 0);
54
+ drawn = rows.length;
55
+ };
56
+ paint();
35
57
  return new Promise((res) => {
36
58
  let onKey;
37
59
  const done = (result) => {
38
60
  input.removeListener('keypress', onKey);
39
- process.stdout.write('\x1b8\x1b[0J'); // restore cursor, wipe overlay
61
+ readline.cursorTo(out, 0);
62
+ readline.clearScreenDown(out); // wipe the overlay
63
+ readline.moveCursor(out, 0, -1); // back up onto the prompt line
64
+ readline.cursorTo(out, 0);
40
65
  if (input.isTTY)
41
66
  input.setRawMode(wasRaw);
42
67
  for (const l of saved)
43
68
  input.on('keypress', l);
44
69
  res(result);
45
70
  };
46
- onKey = makeOnKey(done);
71
+ onKey = makeOnKey(done, paint);
47
72
  input.on('keypress', onKey);
48
73
  });
49
74
  }
@@ -86,7 +111,7 @@ export function pickFile(startDir) {
86
111
  return [{ name: '..', dir: true }, ...items];
87
112
  };
88
113
  let entries = load();
89
- const render = () => {
114
+ const buildRows = () => {
90
115
  if (index >= entries.length)
91
116
  index = Math.max(0, entries.length - 1);
92
117
  scroll = clampScroll(index, scroll, entries.length);
@@ -104,17 +129,17 @@ export function pickFile(startDir) {
104
129
  });
105
130
  rows.push(border('╰', '╯'));
106
131
  rows.push(c.dim(` @${filter}${c.gray('▏')} ↑↓ move · ⏎ ${entries[index]?.dir ? 'open' : 'attach'} · ⌫ up · esc cancel`));
107
- process.stdout.write('\x1b8\x1b[0J\n' + rows.join('\n'));
132
+ return rows;
108
133
  };
109
- return runOverlay(render, (done) => (str, key) => {
134
+ return runOverlay(buildRows, (done, repaint) => (str, key) => {
110
135
  if (!key)
111
136
  return;
112
137
  if (key.name === 'escape' || (key.ctrl && key.name === 'c'))
113
138
  return done(null);
114
139
  if (key.name === 'up')
115
- return void ((index = Math.max(0, index - 1)), render());
140
+ return void ((index = Math.max(0, index - 1)), repaint());
116
141
  if (key.name === 'down')
117
- return void ((index = Math.min(entries.length - 1, index + 1)), render());
142
+ return void ((index = Math.min(entries.length - 1, index + 1)), repaint());
118
143
  if (key.name === 'return') {
119
144
  const e = entries[index];
120
145
  if (!e)
@@ -125,7 +150,7 @@ export function pickFile(startDir) {
125
150
  index = 0;
126
151
  scroll = 0;
127
152
  entries = load();
128
- return render();
153
+ return repaint();
129
154
  }
130
155
  const full = join(dir, e.name);
131
156
  if (e.dir) {
@@ -134,7 +159,7 @@ export function pickFile(startDir) {
134
159
  index = 0;
135
160
  scroll = 0;
136
161
  entries = load();
137
- return render();
162
+ return repaint();
138
163
  }
139
164
  const rel = relative(process.cwd(), full);
140
165
  return done(rel && !rel.startsWith('..') ? rel : full);
@@ -147,14 +172,14 @@ export function pickFile(startDir) {
147
172
  index = 0;
148
173
  scroll = 0;
149
174
  entries = load();
150
- return render();
175
+ return repaint();
151
176
  }
152
177
  if (str && str.length === 1 && str >= ' ' && !key.ctrl && !key.meta) {
153
178
  filter += str;
154
179
  index = 0;
155
180
  scroll = 0;
156
181
  entries = load();
157
- return render();
182
+ return repaint();
158
183
  }
159
184
  });
160
185
  }
@@ -174,7 +199,7 @@ export function pickFromList(title, allItems, initialFilter = '') {
174
199
  };
175
200
  let items = match();
176
201
  const LEFT = 14; // width of the value column
177
- const render = () => {
202
+ const buildRows = () => {
178
203
  if (index >= items.length)
179
204
  index = Math.max(0, items.length - 1);
180
205
  scroll = clampScroll(index, scroll, items.length);
@@ -197,17 +222,17 @@ export function pickFromList(title, allItems, initialFilter = '') {
197
222
  });
198
223
  rows.push(border('╰', '╯'));
199
224
  rows.push(c.dim(` ${filter}${c.gray('▏')} ↑↓ move · ⏎ select · esc cancel`));
200
- process.stdout.write('\x1b8\x1b[0J\n' + rows.join('\n'));
225
+ return rows;
201
226
  };
202
- return runOverlay(render, (done) => (str, key) => {
227
+ return runOverlay(buildRows, (done, repaint) => (str, key) => {
203
228
  if (!key)
204
229
  return;
205
230
  if (key.name === 'escape' || (key.ctrl && key.name === 'c'))
206
231
  return done(null);
207
232
  if (key.name === 'up')
208
- return void ((index = Math.max(0, index - 1)), render());
233
+ return void ((index = Math.max(0, index - 1)), repaint());
209
234
  if (key.name === 'down')
210
- return void ((index = Math.min(items.length - 1, index + 1)), render());
235
+ return void ((index = Math.min(items.length - 1, index + 1)), repaint());
211
236
  if (key.name === 'return') {
212
237
  const it = items[index];
213
238
  return done(it ? (it.id ?? it.value) : null);
@@ -219,14 +244,14 @@ export function pickFromList(title, allItems, initialFilter = '') {
219
244
  index = 0;
220
245
  scroll = 0;
221
246
  items = match();
222
- return render();
247
+ return repaint();
223
248
  }
224
249
  if (str && str.length === 1 && str >= ' ' && !key.ctrl && !key.meta) {
225
250
  filter += str;
226
251
  index = 0;
227
252
  scroll = 0;
228
253
  items = match();
229
- return render();
254
+ return repaint();
230
255
  }
231
256
  });
232
257
  }
@@ -20,6 +20,7 @@ import { notify } from "../../utils/notify.js";
20
20
  // ---- tab completion --------------------------------------------------------
21
21
  const SLASH_ITEMS = [
22
22
  { value: '/open', desc: 'open a chat — then just type to send', args: true },
23
+ { value: '/switch', desc: 'switch to another chat/contact', args: true },
23
24
  { value: '/close', desc: 'leave the current chat' },
24
25
  { value: '/chats', desc: 'list conversations' },
25
26
  { value: '/recent', desc: 'recently active chats' },
@@ -47,7 +48,7 @@ const SLASH_ITEMS = [
47
48
  ];
48
49
  const SLASH = SLASH_ITEMS.map((it) => it.value);
49
50
  const CHAT_CMDS = new Set([
50
- '/open', '/send', '/sendfile', '/pin', '/unpin', '/archive', '/unarchive', '/history',
51
+ '/open', '/switch', '/send', '/sendfile', '/pin', '/unpin', '/archive', '/unarchive', '/history',
51
52
  ]);
52
53
  let nameCache = [];
53
54
  function refreshNames() {
@@ -439,6 +440,7 @@ function setSetting(key, value) {
439
440
  // ---- help ------------------------------------------------------------------
440
441
  const HELP = `${c.bold('Commands')} ${c.dim('(slash optional for the first word)')}
441
442
  ${c.cyan('/open')} <chat> open a chat — then just type to send
443
+ ${c.cyan('/switch')} <chat> jump to another chat/contact
442
444
  ${c.green('@')} open a file picker to attach (↑↓ to choose, ⏎ to pick)
443
445
  ${c.cyan('/close')} leave the current chat
444
446
  ${c.cyan('/chats')} · ${c.cyan('/recent')} · ${c.cyan('/contacts')} list chats / recent / contacts
@@ -511,6 +513,7 @@ async function handle(state, raw) {
511
513
  banner();
512
514
  return;
513
515
  case 'open':
516
+ case 'switch':
514
517
  if (need(args[0], 'chat'))
515
518
  openChat(state, args.join(' '));
516
519
  return;
@@ -521,9 +524,9 @@ async function handle(state, raw) {
521
524
  print(c.dim('left chat.'));
522
525
  return;
523
526
  case 'chats':
524
- chats({ limit: args[0] });
527
+ chats({ limit: args[0] ?? '20' });
525
528
  refreshNames();
526
- return;
529
+ return; // top 20 recent by default
527
530
  case 'recent':
528
531
  recent({ limit: args[0] });
529
532
  return;
@@ -695,7 +698,7 @@ export async function shell() {
695
698
  let pickerOpen = false;
696
699
  // Commands whose <chat> argument gets the live chat picker. "final" = chat is
697
700
  // the last arg (run on pick); "then" = chat then more input (send/sendfile).
698
- const CHAT_FINAL = new Set(['open', 'history', 'pin', 'unpin', 'archive', 'unarchive']);
701
+ const CHAT_FINAL = new Set(['open', 'switch', 'history', 'pin', 'unpin', 'archive', 'unarchive']);
699
702
  const CHAT_THEN = new Set(['send', 'sendfile']);
700
703
  const clearLine = () => rl.write(null, { ctrl: true, name: 'u' });
701
704
  // Open the chat picker for a command, then insert/run with the chosen chat.
@@ -761,7 +764,7 @@ export async function shell() {
761
764
  }
762
765
  // Space right after a chat command (typed manually) → open the chat picker.
763
766
  if (str === ' ') {
764
- const m = rl.line.match(/^\/(open|history|pin|unpin|archive|unarchive|send|sendfile) $/);
767
+ const m = rl.line.match(/^\/(open|switch|history|pin|unpin|archive|unarchive|send|sendfile) $/);
765
768
  if (m) {
766
769
  pickerOpen = true;
767
770
  void (async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-whatsapp",
3
- "version": "0.1.16",
3
+ "version": "0.1.19",
4
4
  "description": "A lightweight, fast, terminal-first WhatsApp client. Read, search, send, media, groups, and live notifications — from your terminal.",
5
5
  "type": "module",
6
6
  "bin": {