stdout-chat 0.4.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 CHANGED
@@ -2,6 +2,19 @@
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
+
5
18
  ## [0.4.0] — 2026-09-20
6
19
 
7
20
  ### What's New
@@ -5,7 +5,7 @@ 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, SLASH_HINT } from '../lib/session.js';
8
+ import { Session, SLASH_HINT, echoesViaStream } from '../lib/session.js';
9
9
  import { complete } from '../lib/complete.js';
10
10
  import { renderError, renderInfo } from '../lib/render.js';
11
11
  import { createNotifier } from '../lib/notify.js';
@@ -178,6 +178,9 @@ async function main() {
178
178
  if (mode === 'interactive') {
179
179
  ui.start({
180
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);
181
184
  try {
182
185
  await session.handleInput(line);
183
186
  } catch (err) {
package/lib/session.js CHANGED
@@ -12,6 +12,19 @@ import { NOTIFY_LEVELS } from './notify.js';
12
12
  export const HINT_NO_KEY = 'type /key sc_… to post · get it in the app: /key';
13
13
  // Printed once above the prompt when a line starts with `/` (see lib/ui.js).
14
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
+ }
15
28
  export const HELP_TEXT = [
16
29
  '/help this list',
17
30
  '/r <id> text reply to a line (ids are the dim column on the left)',
@@ -293,7 +306,7 @@ export class Session {
293
306
  async handleInput(raw) {
294
307
  const line = String(raw == null ? '' : raw).trim();
295
308
  if (!line) return;
296
- if (line[0] !== '/') { await this.post(line); return; }
309
+ if (line[0] !== '/') { await this.post(line, null, { unsent: line }); return; }
297
310
  const cmd = line.split(/\s+/, 1)[0].toLowerCase();
298
311
  const rest = line.slice(cmd.length).trim();
299
312
  switch (cmd) {
@@ -324,7 +337,7 @@ export class Session {
324
337
  case '/r': case '/reply': {
325
338
  const m = rest.match(/^(\S+)\s+([\s\S]+)$/);
326
339
  if (!m) { this.info('usage: /r <id> text'); return; }
327
- await this.post(m[2].trim(), m[1]);
340
+ await this.post(m[2].trim(), m[1], { unsent: line });
328
341
  return;
329
342
  }
330
343
  default:
@@ -332,12 +345,19 @@ export class Session {
332
345
  }
333
346
  }
334
347
 
335
- async post(text, reply = null) {
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 } = {}) {
336
355
  if (!this.key) { this.info(HINT_NO_KEY); return false; }
337
356
  try {
338
357
  await this.api.post({ text, reply }, this.key); // echo arrives via SSE, not from the response
339
358
  return true;
340
359
  } catch (err) {
360
+ if (unsent) this.info(`> ${unsent}`);
341
361
  this.error(errorMessage(err));
342
362
  return false;
343
363
  }
package/lib/ui.js CHANGED
@@ -67,6 +67,25 @@ export function createUI({ input = process.stdin, output = process.stdout, promp
67
67
  if (rl) rl.prompt(true);
68
68
  }
69
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
+
70
89
  /** Remove entries matching `pred` from the in-memory input history (e.g. `/key sc_…`). */
71
90
  function scrubHistory(pred) {
72
91
  if (rl && Array.isArray(rl.history)) rl.history = rl.history.filter((h) => !pred(h));
@@ -77,6 +96,7 @@ export function createUI({ input = process.stdin, output = process.stdout, promp
77
96
  start,
78
97
  close,
79
98
  clear,
99
+ eraseSubmitted,
80
100
  scrubHistory,
81
101
  get interactive() { return rl !== null; },
82
102
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stdout-chat",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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": {