cli-whatsapp 0.1.14 → 0.1.18

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
  }
@@ -10,13 +10,21 @@ export function chats(opts) {
10
10
  print(renderChatList(rows, cfg.preferences.time24h));
11
11
  }
12
12
  /** wa contacts [--limit N] */
13
- export function contacts(opts) {
14
- const limit = clampLimit(opts.limit, 500);
15
- const rows = listContacts(limit);
13
+ /** wa contacts [starts] — list contacts; `starts` filters by name prefix
14
+ * (e.g. `wa contacts a` → names starting with "a"). */
15
+ export function contacts(starts, opts) {
16
+ // Show everyone by default; honor an explicit --limit if given.
17
+ const limit = opts.limit ? Math.max(1, parseInt(opts.limit, 10) || 100000) : 100000;
18
+ const filter = starts?.replace(/^-+/, '').trim() || undefined; // tolerate "-a"
19
+ const rows = listContacts(limit, filter);
16
20
  if (rows.length === 0) {
17
- print(c.dim('No contacts cached yet. Run `wa sync`.'));
21
+ print(c.dim(filter
22
+ ? `No contacts starting with "${filter}".`
23
+ : 'No contacts cached yet. Run `wa sync`.'));
18
24
  return;
19
25
  }
26
+ if (filter)
27
+ print(c.dim(`Contacts starting with "${filter}":`));
20
28
  const width = String(rows.length).length;
21
29
  print(rows
22
30
  .map((r, i) => {
@@ -521,14 +521,14 @@ async function handle(state, raw) {
521
521
  print(c.dim('left chat.'));
522
522
  return;
523
523
  case 'chats':
524
- chats({ limit: args[0] });
524
+ chats({ limit: args[0] ?? '20' });
525
525
  refreshNames();
526
- return;
526
+ return; // top 20 recent by default
527
527
  case 'recent':
528
528
  recent({ limit: args[0] });
529
529
  return;
530
530
  case 'contacts':
531
- contacts({ limit: args[0] });
531
+ contacts(args[0], {});
532
532
  return;
533
533
  case 'history':
534
534
  if (state.active && (!args[0] || /^\d+$/.test(args[0])))
package/dist/cli/index.js CHANGED
@@ -80,8 +80,8 @@ program
80
80
  .option('-n, --limit <n>', 'max chats to show')
81
81
  .action(run(chats));
82
82
  program
83
- .command('contacts')
84
- .description('List known contacts')
83
+ .command('contacts [starts]')
84
+ .description('List contacts; pass a letter to filter (e.g. `wa contacts a`)')
85
85
  .option('-n, --limit <n>', 'max contacts to show')
86
86
  .action(run(contacts));
87
87
  program
package/dist/db/sqlite.js CHANGED
@@ -145,13 +145,18 @@ export function listChats(limit = 50, includeArchived = false) {
145
145
  LIMIT ?`)
146
146
  .all(limit);
147
147
  }
148
- export function listContacts(limit = 500) {
148
+ export function listContacts(limit = 500, startsWith) {
149
149
  // Exclude WhatsApp privacy-masked placeholder names (e.g. "+91∙∙∙∙∙∙∙∙37").
150
+ const params = [];
151
+ let where = "name IS NOT NULL AND name NOT LIKE '%∙%'";
152
+ if (startsWith) {
153
+ where += " AND name LIKE ? ESCAPE '\\'";
154
+ params.push(escapeLike(startsWith) + '%');
155
+ }
156
+ params.push(limit);
150
157
  return getDb()
151
- .prepare(`SELECT * FROM contacts
152
- WHERE name IS NOT NULL AND name NOT LIKE '%∙%'
153
- ORDER BY name COLLATE NOCASE LIMIT ?`)
154
- .all(limit);
158
+ .prepare(`SELECT * FROM contacts WHERE ${where} ORDER BY name COLLATE NOCASE LIMIT ?`)
159
+ .all(...params);
155
160
  }
156
161
  export function getMessages(chatJid, limit = 30) {
157
162
  // Newest N, returned oldest-first for natural chat reading order.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-whatsapp",
3
- "version": "0.1.14",
3
+ "version": "0.1.18",
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": {