stdout-chat 0.2.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes to `stdout-chat` (the CLI). Dates are release dates.
4
4
 
5
+ ## [0.4.0] — 2026-09-20
6
+
7
+ ### What's New
8
+
9
+ - Type `/` at the prompt and one dim line lists the commands (`commands · /help · /r <id> text · /dm <nick|sid> · /top · /who · /key · /notify · /clear · /quit`) — once per line, the input stays put. `/help` is unchanged.
10
+ - Tab completion: `/n⇥` → `/notify `, `/notify a⇥` → `all` (`mentions|all|off`), `/key ⇥` → `off`, and `@ki⇥` → `@kira ` from the nicks seen this session (most recent first, case-insensitive), anywhere in the line — inside a `/r` reply too. Plain text: Tab does nothing.
11
+
12
+ ### Technical
13
+
14
+ - New `lib/complete.js`: pure `complete(line, { commands, nicks })` → `[matches, prefix]`, readline's completer shape. Commands come from a fixed list; `/notify` and `/key` complete their first argument; a token under the cursor starting with `@` completes against `nicks` (unique, caller's order). Everything else → `[[], '']`.
15
+ - `lib/ui.js`: `createUI({ completer, hint })`. The completer is handed to `readline.createInterface`. The hint is driven by a `keypress` listener added after `createInterface`, so it runs after readline applied the key and just reads `rl.line`: `'/'` and not yet hinted → print; `''` → reset; `'line'` → reset. No key parsing of our own, so editing and history are untouched; works in Terminal.app and tmux. Off in `--read` / `--tail` / piped modes (no prompt there).
16
+ - `lib/session.js`: `SLASH_HINT`; `nicks()` (most recent first, unique, no `anon`) feeds `@` completion. `bin/stdout-chat.js` wires both; the hint goes through `renderInfo`, so `NO_COLOR` / `--no-color` make it plain.
17
+ - Tests: 82 → 96 (`test/complete.test.js` and `test/ui.test.js` new — the UI is driven through fake streams so readline parses real key bytes; `nicks()` in session).
18
+
19
+ ## [0.3.0] — 2026-09-20
20
+
21
+ ### What's New
22
+
23
+ - `/dm <nick|sid>` at the prompt invites someone from #void to a private chat. Pass a nick, or the id of one of their lines (the dim column on the left) to target that author. The invite lives 10 minutes; accepting happens in the app, and the private chat itself opens on your phone — the CLI prints the server's answer (`invite sent · nova has 10 min · you'll get a push when they accept`) or its refusal verbatim (`not_found`, `busy`, `rate_limited` …).
24
+
25
+ ### Technical
26
+
27
+ - `lib/api.js`: `dm({ nick | sid }, key)` → `POST /void/dm`, JSON body `{sid}` or `{nick}`, `Accept: application/json`, returns `{ id, to, expires_at }`. Errors go through the existing `ApiError` mapping (server `message` verbatim, `retry_after` honoured).
28
+ - `lib/session.js`: `cmdDm` — no key → the usual hint, no argument → `usage: /dm <nick|sid>`, argument equal to a `sid` seen this session → `{sid}`, otherwise `{nick}`; the success line is built from the returned `to`.
29
+ - Tests: 76 → 82 (`/dm` sid/nick routing, usage, no-key, verbatim errors; `api.dm` request shape).
30
+
5
31
  ## [0.2.0] — 2026-09-20
6
32
 
7
33
  ### What's New
package/README.md CHANGED
@@ -47,12 +47,15 @@ The key is checked against the server, then saved to `~/.config/stdout-chat/conf
47
47
  | `/r <id> text` | reply to a line — ids are the dim column on the left |
48
48
  | `/top` | this week's top authors |
49
49
  | `/who` | how many are in the room |
50
+ | `/dm <nick|sid>` | invite them to a private chat — a line's id targets its author; the chat itself opens on your phone |
50
51
  | `/key sc_…` | save a key · `/key` shows who you are · `/key off` forgets it |
51
52
  | `/notify` | desktop banners: `/notify` shows the level · `/notify mentions` (default) · `all` · `off` |
52
53
  | `/clear` | clear the screen |
53
54
  | `/help` | list this |
54
55
  | `/quit` | leave (Ctrl-C and Ctrl-D too) |
55
56
 
57
+ type `/` to see the commands · Tab completes commands and @nicks (`/n⇥` → `/notify `, `/notify a⇥` → `all`, `@ki⇥` → `@kira `).
58
+
56
59
  Server errors are printed as the server phrases them (`slow down · retry in 2s`), never as stack traces. Your own line is not echoed locally — it shows up when the room sees it, in order.
57
60
 
58
61
  ## Flags
@@ -96,7 +99,8 @@ void() { # void · void -f · void -r a1b4 text · void some words
96
99
 
97
100
  - `GET /void` for history, `GET /void/stream` (Server-Sent Events) for the live feed, `POST /void` to speak, `GET /void/me` to check a key. All JSON.
98
101
  - Reconnects with backoff (1 → 30 s) and `Last-Event-ID`, so nothing is missed across the server's 15-minute stream rotation.
99
- - Plain scrolling output with a `readline` prompt: no alternate screen, no curses — works in tmux splits and over ssh.
102
+ - Plain scrolling output with a `readline` prompt: no alternate screen, no curses — works in tmux splits and over ssh. Tab completion is readline's own `completer`; the `/` hint is one dim line printed above the prompt the first time a line starts with `/`.
103
+ - `POST /void/dm` sends a private-chat invite (`/dm`). The invite lives 10 minutes; when they accept you get a push and the private chat opens in the app on your phone — the terminal only sends the invite and prints what the server says.
100
104
 
101
105
  ## Notifications
102
106
 
@@ -5,7 +5,8 @@ import process from 'node:process';
5
5
  import { createApi, DEFAULT_API, errorMessage } from '../lib/api.js';
6
6
  import { loadConfig } from '../lib/config.js';
7
7
  import { createUI } from '../lib/ui.js';
8
- import { Session } from '../lib/session.js';
8
+ import { Session, SLASH_HINT } from '../lib/session.js';
9
+ import { complete } from '../lib/complete.js';
9
10
  import { renderError, renderInfo } from '../lib/render.js';
10
11
  import { createNotifier } from '../lib/notify.js';
11
12
 
@@ -25,8 +26,8 @@ usage: npx stdout-chat [options]
25
26
  -v, --version print the version
26
27
 
27
28
  at the prompt:
28
- /help /r <id> text /top /who /key sc_… /key off /notify /clear /quit
29
- anything else is posted to #void
29
+ /help /r <id> text /dm <nick|sid> /top /who /key sc_… /key off /notify /clear /quit
30
+ anything else is posted to #void · type / to see the commands · Tab completes commands and @nicks
30
31
 
31
32
  desktop banners (macOS / Linux) when someone replies to you or writes @you —
32
33
  only while the prompt or --tail is running · /notify mentions|all|off
@@ -106,11 +107,15 @@ async function main() {
106
107
  : (process.stdin.isTTY && process.stdout.isTTY) ? 'interactive'
107
108
  : 'follow'; // piped: history + stream, no prompt
108
109
 
109
- const ui = createUI();
110
+ let session = null; // assigned below; the completer only runs on Tab, at the prompt
111
+ const ui = createUI({
112
+ completer: (line) => complete(line, { nicks: session ? session.nicks() : [] }),
113
+ hint: renderInfo(SLASH_HINT, { color }),
114
+ });
110
115
  const stop = new AbortController();
111
116
  let closing = false;
112
117
 
113
- const session = new Session({
118
+ session = new Session({
114
119
  api,
115
120
  print: ui.print,
116
121
  color,
package/lib/api.js CHANGED
@@ -90,6 +90,20 @@ export function createApi({ base = DEFAULT_API, version = '0.0.0', fetchImpl = n
90
90
  });
91
91
  },
92
92
 
93
+ /**
94
+ * POST /void/dm {sid} | {nick} → 201 { id, to, expires_at }. Same shape as
95
+ * post(): JSON in, JSON out (Accept: application/json); the session builds
96
+ * the human line from `to`. Errors carry the server's message verbatim.
97
+ */
98
+ dm({ nick, sid } = {}, key) {
99
+ const body = sid != null && sid !== '' ? { sid: String(sid) } : { nick: String(nick == null ? '' : nick) };
100
+ return json(`${root}/void/dm`, {
101
+ method: 'POST',
102
+ headers: headers({ 'Content-Type': 'application/json' }, key),
103
+ body: JSON.stringify(body),
104
+ });
105
+ },
106
+
93
107
  /** GET /void/stream → Response (200, body is a ReadableStream). Throws ApiError on non-2xx. */
94
108
  async openStream({ lastEventId = null, signal = null } = {}) {
95
109
  const extra = { 'Cache-Control': 'no-cache' };
@@ -0,0 +1,41 @@
1
+ // Tab completion for the `> ` prompt. Pure: (line, { commands, nicks }) →
2
+ // [matches, prefix], the shape readline's `completer` option expects. `line`
3
+ // is the input up to the cursor; `prefix` is the part of it that the matches
4
+ // replace (readline appends what the common prefix adds beyond it).
5
+ export const COMMANDS = ['/help', '/r ', '/dm ', '/top', '/who', '/key ', '/notify ', '/clear', '/quit'];
6
+ export const NOTIFY_ARGS = ['mentions', 'all', 'off'];
7
+ export const KEY_ARGS = ['off'];
8
+ const NONE = [[], ''];
9
+
10
+ function startsWithFold(s, prefix) {
11
+ return s.slice(0, prefix.length).toLowerCase() === prefix.toLowerCase();
12
+ }
13
+
14
+ /** `@ki` under the cursor → `@kira ` (unique nicks, caller's order, case-insensitive). */
15
+ function completeNick(line, nicks) {
16
+ const token = line.slice(line.search(/\S*$/));
17
+ if (token[0] !== '@') return NONE;
18
+ const want = token.slice(1);
19
+ const seen = new Set();
20
+ const matches = [];
21
+ for (const raw of nicks || []) {
22
+ const nick = String(raw == null ? '' : raw);
23
+ if (!nick || seen.has(nick) || !startsWithFold(nick, want)) continue;
24
+ seen.add(nick);
25
+ matches.push(`@${nick} `);
26
+ }
27
+ return [matches, token];
28
+ }
29
+
30
+ export function complete(line, { commands = COMMANDS, nicks = [] } = {}) {
31
+ const s = String(line == null ? '' : line);
32
+ if (s[0] === '/') {
33
+ const sp = s.search(/\s/);
34
+ if (sp < 0) return [commands.filter((c) => startsWithFold(c, s)), s]; // still typing the command
35
+ const cmd = s.slice(0, sp).toLowerCase();
36
+ const arg = s.slice(sp).trimStart();
37
+ const args = cmd === '/notify' ? NOTIFY_ARGS : cmd === '/key' ? KEY_ARGS : null;
38
+ if (args && !/\s/.test(arg)) return [args.filter((a) => startsWithFold(a, arg)), arg];
39
+ }
40
+ return completeNick(s, nicks);
41
+ }
package/lib/session.js CHANGED
@@ -10,11 +10,14 @@ import * as defaultConfig from './config.js';
10
10
  import { NOTIFY_LEVELS } from './notify.js';
11
11
 
12
12
  export const HINT_NO_KEY = 'type /key sc_… to post · get it in the app: /key';
13
+ // Printed once above the prompt when a line starts with `/` (see lib/ui.js).
14
+ export const SLASH_HINT = 'commands · /help · /r <id> text · /dm <nick|sid> · /top · /who · /key · /notify · /clear · /quit';
13
15
  export const HELP_TEXT = [
14
16
  '/help this list',
15
17
  '/r <id> text reply to a line (ids are the dim column on the left)',
16
18
  '/top this week\'s top authors',
17
19
  '/who how many are in the room',
20
+ '/dm <nick|sid> invite them to a private chat (accept happens in the app)',
18
21
  '/key sc_… save your key (get it in the app: /key)',
19
22
  '/key off forget the key on this machine (revoke it in the app)',
20
23
  '/notify desktop banners: mentions (default) · all · off',
@@ -309,6 +312,9 @@ export class Session {
309
312
  case '/who':
310
313
  await this.cmdWho();
311
314
  return;
315
+ case '/dm':
316
+ await this.cmdDm(rest);
317
+ return;
312
318
  case '/key':
313
319
  await this.cmdKey(rest);
314
320
  return;
@@ -352,6 +358,42 @@ export class Session {
352
358
  this.info(this.count === 1 ? '1 in room' : `${this.count} in room`);
353
359
  }
354
360
 
361
+ /** `/dm <nick|sid>`: a sid seen this session targets that line's author, anything else is a nick. */
362
+ async cmdDm(arg) {
363
+ if (!this.key) { this.info(HINT_NO_KEY); return; }
364
+ if (!arg) { this.info('usage: /dm <nick|sid>'); return; }
365
+ const target = this.isKnownSid(arg) ? { sid: arg } : { nick: arg };
366
+ let res;
367
+ try {
368
+ res = await this.api.dm(target, this.key);
369
+ } catch (err) {
370
+ this.error(errorMessage(err));
371
+ return;
372
+ }
373
+ const to = res && res.to != null && res.to !== '' ? String(res.to) : arg;
374
+ this.info(`invite sent · ${to} has 10 min · you'll get a push when they accept`);
375
+ }
376
+
377
+ /** Nicks seen this session, most recent line first, unique; `anon` is not a nick. Feeds @-completion. */
378
+ nicks() {
379
+ const seen = new Set();
380
+ const out = [];
381
+ for (const m of [...this.lines.values()].reverse()) {
382
+ if (!m || m.username == null || m.username === '') continue;
383
+ const nick = String(m.username);
384
+ if (seen.has(nick)) continue;
385
+ seen.add(nick);
386
+ out.push(nick);
387
+ }
388
+ return out;
389
+ }
390
+
391
+ /** True when `s` is the short id (the dim left column) of a line seen this session. */
392
+ isKnownSid(s) {
393
+ for (const m of this.lines.values()) if (m && m.sid != null && String(m.sid) === s) return true;
394
+ return false;
395
+ }
396
+
355
397
  async cmdKey(arg) {
356
398
  if (!arg) {
357
399
  if (this.me) this.info(greeting(this.me));
package/lib/ui.js CHANGED
@@ -2,9 +2,15 @@
2
2
  // bottom. No alternate screen, no curses — works in tmux splits and pipes.
3
3
  import readline from 'node:readline';
4
4
 
5
- export function createUI({ input = process.stdin, output = process.stdout, prompt = '> ' } = {}) {
5
+ /**
6
+ * `completer(line) → [matches, prefix]` is handed to readline as-is (see
7
+ * lib/complete.js). `hint` is one already-rendered line printed above the
8
+ * prompt the first time an input line becomes exactly `/`.
9
+ */
10
+ export function createUI({ input = process.stdin, output = process.stdout, prompt = '> ', completer = null, hint = null } = {}) {
6
11
  let rl = null;
7
12
  let closed = false;
13
+ let hinted = false; // the `/` hint was shown for the line being typed
8
14
 
9
15
  function print(text) {
10
16
  if (closed && !rl) { output.write(`${text}\n`); return; }
@@ -16,14 +22,32 @@ export function createUI({ input = process.stdin, output = process.stdout, promp
16
22
  if (rl) rl.prompt(true);
17
23
  }
18
24
 
25
+ // Why a keypress listener that reads `rl.line` (and not our own key parser):
26
+ // readline installs its keypress handler in createInterface, so a listener
27
+ // added afterwards runs after readline has already applied the key — `rl.line`
28
+ // is the edited line, whatever the key was (typed char, paste, backspace,
29
+ // Ctrl-U, history arrow). We never touch the key ourselves, so editing and
30
+ // history behave exactly as before, in Terminal.app and in tmux alike. The
31
+ // flag resets on submit ('line') and whenever the line is empty again.
32
+ function onKeypress() {
33
+ if (!rl || !hint) return;
34
+ if (rl.line === '') { hinted = false; return; }
35
+ if (rl.line === '/' && !hinted) { hinted = true; print(hint); }
36
+ }
37
+
19
38
  function start({ onLine, onClose }) {
20
- rl = readline.createInterface({ input, output, prompt, terminal: true, historySize: 200 });
21
- rl.on('line', (line) => { Promise.resolve(onLine(line)).catch(() => {}).then(() => { if (rl) rl.prompt(true); }); });
39
+ const opts = { input, output, prompt, terminal: true, historySize: 200 };
40
+ if (typeof completer === 'function') opts.completer = completer;
41
+ rl = readline.createInterface(opts);
42
+ hinted = false;
43
+ input.on('keypress', onKeypress);
44
+ rl.on('line', (line) => { hinted = false; Promise.resolve(onLine(line)).catch(() => {}).then(() => { if (rl) rl.prompt(true); }); });
22
45
  rl.on('SIGINT', () => close());
23
46
  rl.on('close', () => {
24
47
  const wasOpen = !closed;
25
48
  closed = true;
26
49
  rl = null;
50
+ input.removeListener('keypress', onKeypress);
27
51
  if (wasOpen && onClose) onClose();
28
52
  });
29
53
  rl.prompt();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stdout-chat",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "#void from your terminal — read, tail and post to stdout.chat's public room. Zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {