stdout-chat 0.3.0 → 0.4.1
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 +27 -0
- package/README.md +3 -1
- package/bin/stdout-chat.js +12 -4
- package/lib/complete.js +41 -0
- package/lib/session.js +39 -3
- package/lib/ui.js +47 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `stdout-chat` (the CLI). Dates are release dates.
|
|
4
4
|
|
|
5
|
+
## [0.4.1] — 2026-09-20
|
|
6
|
+
|
|
7
|
+
### What's New
|
|
8
|
+
|
|
9
|
+
- Your own line shows up once. Hitting Enter used to leave `> hi` on screen and then the feed printed `in dmitrii hi` right under it — two copies of everything you said, while the app showed one. Now the typed line is wiped the moment you submit and the feed's copy (with its id, ready for `/r`) is the only one. If the post fails (`slow down · retry in 2s`, revoked key), the line comes back dim above the error so you can see what did not go out — ↑ still recalls it.
|
|
10
|
+
|
|
11
|
+
### Technical
|
|
12
|
+
|
|
13
|
+
- `lib/ui.js`: `eraseSubmitted(line)` — cursor up + clear for every row the echoed `> line` took (`ceil((prompt + line) / columns)`), then column 0. Must run synchronously from `onLine`, before any await: readline has just written the newline, so the row above the cursor is exactly the echo; the next `print`/`prompt(true)` re-draws the prompt as usual.
|
|
14
|
+
- `lib/session.js`: `echoesViaStream(line)` — true for plain text and `/r|/reply <id> text`, false for commands, `/r` usage and unknown slashes (the server's 422 keeps its `> /dance` context). `post(text, reply, { unsent })`: on failure prints `> <unsent>` via `info` (dim) before the error; `handleInput` passes the raw line for posts and replies only.
|
|
15
|
+
- `bin/stdout-chat.js`: `onLine` calls `ui.eraseSubmitted(line)` when `echoesViaStream(line)`, then `handleInput`.
|
|
16
|
+
- Tests: 96 → 99 (erase sequences for one row and a wrapped line; `echoesViaStream` table; failed post restores the line, a failed command does not).
|
|
17
|
+
|
|
18
|
+
## [0.4.0] — 2026-09-20
|
|
19
|
+
|
|
20
|
+
### What's New
|
|
21
|
+
|
|
22
|
+
- 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.
|
|
23
|
+
- 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.
|
|
24
|
+
|
|
25
|
+
### Technical
|
|
26
|
+
|
|
27
|
+
- 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 → `[[], '']`.
|
|
28
|
+
- `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).
|
|
29
|
+
- `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.
|
|
30
|
+
- 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).
|
|
31
|
+
|
|
5
32
|
## [0.3.0] — 2026-09-20
|
|
6
33
|
|
|
7
34
|
### What's New
|
package/README.md
CHANGED
|
@@ -54,6 +54,8 @@ The key is checked against the server, then saved to `~/.config/stdout-chat/conf
|
|
|
54
54
|
| `/help` | list this |
|
|
55
55
|
| `/quit` | leave (Ctrl-C and Ctrl-D too) |
|
|
56
56
|
|
|
57
|
+
type `/` to see the commands · Tab completes commands and @nicks (`/n⇥` → `/notify `, `/notify a⇥` → `all`, `@ki⇥` → `@kira `).
|
|
58
|
+
|
|
57
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.
|
|
58
60
|
|
|
59
61
|
## Flags
|
|
@@ -97,7 +99,7 @@ void() { # void · void -f · void -r a1b4 text · void some words
|
|
|
97
99
|
|
|
98
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.
|
|
99
101
|
- Reconnects with backoff (1 → 30 s) and `Last-Event-ID`, so nothing is missed across the server's 15-minute stream rotation.
|
|
100
|
-
- 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 `/`.
|
|
101
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.
|
|
102
104
|
|
|
103
105
|
## Notifications
|
package/bin/stdout-chat.js
CHANGED
|
@@ -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, echoesViaStream } 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
|
|
|
@@ -26,7 +27,7 @@ usage: npx stdout-chat [options]
|
|
|
26
27
|
|
|
27
28
|
at the prompt:
|
|
28
29
|
/help /r <id> text /dm <nick|sid> /top /who /key sc_… /key off /notify /clear /quit
|
|
29
|
-
anything else is posted to #void
|
|
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
|
-
|
|
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
|
-
|
|
118
|
+
session = new Session({
|
|
114
119
|
api,
|
|
115
120
|
print: ui.print,
|
|
116
121
|
color,
|
|
@@ -173,6 +178,9 @@ async function main() {
|
|
|
173
178
|
if (mode === 'interactive') {
|
|
174
179
|
ui.start({
|
|
175
180
|
onLine: async (line) => {
|
|
181
|
+
// Synchronous, before any await: the row above the cursor is still
|
|
182
|
+
// the echoed input. Its rendering arrives over the stream instead.
|
|
183
|
+
if (echoesViaStream(line)) ui.eraseSubmitted(line);
|
|
176
184
|
try {
|
|
177
185
|
await session.handleInput(line);
|
|
178
186
|
} catch (err) {
|
package/lib/complete.js
ADDED
|
@@ -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,6 +10,21 @@ 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';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* True for input whose rendering comes back over the stream (a post, or a
|
|
18
|
+
* `/r <id> text` reply): the typed line is erased on submit so the feed shows
|
|
19
|
+
* the message once. Commands (`/top`, `/key …`, unknown slashes the server
|
|
20
|
+
* answers with 422) keep their `> /cmd` line as context.
|
|
21
|
+
*/
|
|
22
|
+
export function echoesViaStream(raw) {
|
|
23
|
+
const line = String(raw == null ? '' : raw).trim();
|
|
24
|
+
if (!line) return false;
|
|
25
|
+
if (line[0] !== '/') return true;
|
|
26
|
+
return /^\/(r|reply)\s+\S+\s+\S/.test(line);
|
|
27
|
+
}
|
|
13
28
|
export const HELP_TEXT = [
|
|
14
29
|
'/help this list',
|
|
15
30
|
'/r <id> text reply to a line (ids are the dim column on the left)',
|
|
@@ -291,7 +306,7 @@ export class Session {
|
|
|
291
306
|
async handleInput(raw) {
|
|
292
307
|
const line = String(raw == null ? '' : raw).trim();
|
|
293
308
|
if (!line) return;
|
|
294
|
-
if (line[0] !== '/') { await this.post(line); return; }
|
|
309
|
+
if (line[0] !== '/') { await this.post(line, null, { unsent: line }); return; }
|
|
295
310
|
const cmd = line.split(/\s+/, 1)[0].toLowerCase();
|
|
296
311
|
const rest = line.slice(cmd.length).trim();
|
|
297
312
|
switch (cmd) {
|
|
@@ -322,7 +337,7 @@ export class Session {
|
|
|
322
337
|
case '/r': case '/reply': {
|
|
323
338
|
const m = rest.match(/^(\S+)\s+([\s\S]+)$/);
|
|
324
339
|
if (!m) { this.info('usage: /r <id> text'); return; }
|
|
325
|
-
await this.post(m[2].trim(), m[1]);
|
|
340
|
+
await this.post(m[2].trim(), m[1], { unsent: line });
|
|
326
341
|
return;
|
|
327
342
|
}
|
|
328
343
|
default:
|
|
@@ -330,12 +345,19 @@ export class Session {
|
|
|
330
345
|
}
|
|
331
346
|
}
|
|
332
347
|
|
|
333
|
-
|
|
348
|
+
/**
|
|
349
|
+
* `unsent` is the raw input line to put back (dim, with its prompt) when the
|
|
350
|
+
* post fails: the UI erased the typed line on submit for lines that echo via
|
|
351
|
+
* the stream (see `echoesViaStream`), so an error alone would leave "slow
|
|
352
|
+
* down · retry in 2s" with nothing above it to retry.
|
|
353
|
+
*/
|
|
354
|
+
async post(text, reply = null, { unsent = null } = {}) {
|
|
334
355
|
if (!this.key) { this.info(HINT_NO_KEY); return false; }
|
|
335
356
|
try {
|
|
336
357
|
await this.api.post({ text, reply }, this.key); // echo arrives via SSE, not from the response
|
|
337
358
|
return true;
|
|
338
359
|
} catch (err) {
|
|
360
|
+
if (unsent) this.info(`> ${unsent}`);
|
|
339
361
|
this.error(errorMessage(err));
|
|
340
362
|
return false;
|
|
341
363
|
}
|
|
@@ -372,6 +394,20 @@ export class Session {
|
|
|
372
394
|
this.info(`invite sent · ${to} has 10 min · you'll get a push when they accept`);
|
|
373
395
|
}
|
|
374
396
|
|
|
397
|
+
/** Nicks seen this session, most recent line first, unique; `anon` is not a nick. Feeds @-completion. */
|
|
398
|
+
nicks() {
|
|
399
|
+
const seen = new Set();
|
|
400
|
+
const out = [];
|
|
401
|
+
for (const m of [...this.lines.values()].reverse()) {
|
|
402
|
+
if (!m || m.username == null || m.username === '') continue;
|
|
403
|
+
const nick = String(m.username);
|
|
404
|
+
if (seen.has(nick)) continue;
|
|
405
|
+
seen.add(nick);
|
|
406
|
+
out.push(nick);
|
|
407
|
+
}
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
|
|
375
411
|
/** True when `s` is the short id (the dim left column) of a line seen this session. */
|
|
376
412
|
isKnownSid(s) {
|
|
377
413
|
for (const m of this.lines.values()) if (m && m.sid != null && String(m.sid) === s) return true;
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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();
|
|
@@ -43,6 +67,25 @@ export function createUI({ input = process.stdin, output = process.stdout, promp
|
|
|
43
67
|
if (rl) rl.prompt(true);
|
|
44
68
|
}
|
|
45
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Erase the line the user just submitted (readline has already echoed
|
|
72
|
+
* `> text` and moved to a fresh row). Call it synchronously from `onLine`,
|
|
73
|
+
* before anything else prints — the row above the cursor is then exactly
|
|
74
|
+
* that echo. A long input that wrapped takes several rows; all of them go.
|
|
75
|
+
* Used for lines whose real rendering comes back over the stream, so the
|
|
76
|
+
* feed shows a message once, not "> hi" and then "in dmitrii hi".
|
|
77
|
+
*/
|
|
78
|
+
function eraseSubmitted(line) {
|
|
79
|
+
if (!rl) return;
|
|
80
|
+
const cols = Math.max(1, output.columns || 80);
|
|
81
|
+
const rows = Math.max(1, Math.ceil((prompt.length + String(line == null ? '' : line).length) / cols));
|
|
82
|
+
for (let i = 0; i < rows; i++) {
|
|
83
|
+
readline.moveCursor(output, 0, -1);
|
|
84
|
+
readline.clearLine(output, 0);
|
|
85
|
+
}
|
|
86
|
+
readline.cursorTo(output, 0);
|
|
87
|
+
}
|
|
88
|
+
|
|
46
89
|
/** Remove entries matching `pred` from the in-memory input history (e.g. `/key sc_…`). */
|
|
47
90
|
function scrubHistory(pred) {
|
|
48
91
|
if (rl && Array.isArray(rl.history)) rl.history = rl.history.filter((h) => !pred(h));
|
|
@@ -53,6 +96,7 @@ export function createUI({ input = process.stdin, output = process.stdout, promp
|
|
|
53
96
|
start,
|
|
54
97
|
close,
|
|
55
98
|
clear,
|
|
99
|
+
eraseSubmitted,
|
|
56
100
|
scrubHistory,
|
|
57
101
|
get interactive() { return rl !== null; },
|
|
58
102
|
};
|