moshcode 0.41.0 → 0.43.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/src/rss-ui.mjs ADDED
@@ -0,0 +1,517 @@
1
+ // `moshcode rss` — the headlines as a place you can sit in.
2
+ //
3
+ // `/news` prints a list and gives the terminal back, which is the right shape
4
+ // for "what happened" and the wrong one for reading. This is the same data as
5
+ // somewhere you point at: feeds down the left, headlines in the middle, the
6
+ // story itself in place of the list when you pick one.
7
+ //
8
+ // The same terminal discipline as src/herd-ui.mjs, and for the same reasons:
9
+ // no dependencies, alternate screen and SGR mouse reporting written by hand,
10
+ // and every escape sequence undone in a single restore path so a crash cannot
11
+ // leave a terminal with no cursor and the mouse still captured.
12
+ //
13
+ // Rendering is a pure function of state (`renderReader`), so a frame can be
14
+ // asserted in a test without a tty, a fetch, or a keystroke.
15
+ import { parseMouse } from "./herd-ui.mjs";
16
+ import { acid, amber, ash, bone, danger, dim } from "./ui.mjs";
17
+ import {
18
+ ago,
19
+ collectNews,
20
+ findFeed,
21
+ readingList,
22
+ searchFeeds,
23
+ } from "./news.mjs";
24
+
25
+ const ESC = {
26
+ altOn: "\x1b[?1049h", altOff: "\x1b[?1049l",
27
+ hideCursor: "\x1b[?25l", showCursor: "\x1b[?25h",
28
+ mouseOn: "\x1b[?1000h\x1b[?1006h", mouseOff: "\x1b[?1006l\x1b[?1000l",
29
+ clear: "\x1b[2J\x1b[H",
30
+ };
31
+
32
+ /** Columns given to the feed sidebar, and the fixed chrome around the list. */
33
+ const SIDEBAR = 20;
34
+ const HEADER_LINES = 2; // title + rule
35
+ const FOOTER_LINES = 2; // rule + keys
36
+
37
+ /** Printable width, ignoring the SGR sequences ui.mjs wraps text in. */
38
+ export function visibleWidth(text) {
39
+ return String(text ?? "").replace(/\x1b\[[0-9;]*m/g, "").length;
40
+ }
41
+
42
+ /** Pad to `width` printable columns, colour codes not counted. */
43
+ function pad(text, width) {
44
+ const short = width - visibleWidth(text);
45
+ return short > 0 ? text + " ".repeat(short) : text;
46
+ }
47
+
48
+ /** Truncate to `width` printable columns. Only ever called on uncoloured text. */
49
+ function clip(text, width) {
50
+ const s = String(text ?? "");
51
+ return s.length > width ? `${s.slice(0, Math.max(0, width - 1))}…` : s;
52
+ }
53
+
54
+ /**
55
+ * Break `text` into lines of at most `width`, on word boundaries where it can.
56
+ *
57
+ * A word longer than the pane — which in practice means a URL — is split across
58
+ * lines rather than truncated. It has to be split somehow: left whole it wraps
59
+ * the terminal itself and every line below it lands one row low, which is the
60
+ * one failure that tears the whole frame. Splitting rather than cutting because
61
+ * the over-long word is usually the link, and half a link is not a link.
62
+ */
63
+ export function wrap(text, width) {
64
+ const columns = Math.max(1, Math.floor(width));
65
+ const words = String(text ?? "").split(/\s+/).filter(Boolean);
66
+ const lines = [];
67
+ let line = "";
68
+ const flush = () => { if (line) { lines.push(line); line = ""; } };
69
+
70
+ for (const word of words) {
71
+ if (word.length > columns) {
72
+ flush();
73
+ for (let i = 0; i < word.length; i += columns) lines.push(word.slice(i, i + columns));
74
+ continue;
75
+ }
76
+ if (!line) { line = word; continue; }
77
+ if (line.length + 1 + word.length <= columns) { line += ` ${word}`; continue; }
78
+ flush();
79
+ line = word;
80
+ }
81
+ flush();
82
+ return lines;
83
+ }
84
+
85
+ /**
86
+ * Decode a chunk of raw-mode input.
87
+ *
88
+ * herd-ui's parseInput is deliberately narrow — it answers a nine-key screen —
89
+ * so this reads the keys a reader needs instead of widening that one and
90
+ * changing what the herd list responds to. Mouse reports are shared, because
91
+ * SGR decoding has exactly one correct answer.
92
+ */
93
+ export function decodeKeys(buffer) {
94
+ const events = [];
95
+ // Mouse reports are removed from the text, not merely read out of it. A
96
+ // release (`…m`) decodes to no event at all, so leaving the sequence behind
97
+ // would hand `[ < 0 ; 1 0 ; 5 m` to the key decoder below — which, with the
98
+ // search box open, types the mouse position into the query.
99
+ const text = String(buffer).replace(/\x1b\[<\d+;\d+;\d+[Mm]/g, (sequence) => {
100
+ const parsed = parseMouse(sequence);
101
+ if (parsed) events.push(parsed);
102
+ return "";
103
+ });
104
+ if (!text) return events;
105
+
106
+ // Escape sequences first, longest first, so ESC [ A is an arrow rather than
107
+ // an escape followed by two letters.
108
+ const SEQUENCES = [
109
+ ["\x1b[A", "up"], ["\x1b[B", "down"], ["\x1b[C", "right"], ["\x1b[D", "left"],
110
+ ["\x1b[5~", "pageup"], ["\x1b[6~", "pagedown"],
111
+ ["\x1b[H", "home"], ["\x1b[F", "end"],
112
+ ];
113
+ let i = 0;
114
+ while (i < text.length) {
115
+ const seq = SEQUENCES.find(([code]) => text.startsWith(code, i));
116
+ if (seq) { events.push({ kind: "key", name: seq[1] }); i += seq[0].length; continue; }
117
+ const ch = text[i];
118
+ if (ch === "\x1b") { events.push({ kind: "key", name: "escape" }); i += 1; continue; }
119
+ if (ch === "\r" || ch === "\n") { events.push({ kind: "key", name: "enter" }); i += 1; continue; }
120
+ if (ch === "\x7f" || ch === "\b") { events.push({ kind: "key", name: "backspace" }); i += 1; continue; }
121
+ if (ch === "\x03") { events.push({ kind: "key", name: "ctrl-c" }); i += 1; continue; }
122
+ events.push({ kind: "key", name: ch, char: ch });
123
+ i += 1;
124
+ }
125
+ return events;
126
+ }
127
+
128
+ /** Feeds down the left, with a count each and an "all" row on top. */
129
+ export function sidebarRows(state) {
130
+ const counts = new Map();
131
+ for (const item of state.items) counts.set(item.feed, (counts.get(item.feed) || 0) + 1);
132
+ const rows = [{ key: null, label: "all", count: state.items.length }];
133
+ for (const feed of state.feeds) {
134
+ rows.push({ key: feed.name, label: feed.name, count: counts.get(feed.name) || 0 });
135
+ }
136
+ return rows;
137
+ }
138
+
139
+ /** The headlines currently on show — everything, or one feed's. */
140
+ export function visibleItems(state) {
141
+ return state.filter ? state.items.filter((i) => i.feed === state.filter) : state.items;
142
+ }
143
+
144
+ /**
145
+ * One frame, as an array of exactly `rows` lines.
146
+ *
147
+ * Exactly, not at most: the screen is repainted by clearing and writing, so a
148
+ * short frame leaves the tail of the previous one on screen and a long one
149
+ * scrolls the terminal and tears the whole layout.
150
+ */
151
+ export function renderReader(state, { rows = 24, cols = 80 } = {}) {
152
+ const width = Math.max(60, cols);
153
+ const bodyHeight = Math.max(3, rows - HEADER_LINES - FOOTER_LINES);
154
+ const listWidth = width - SIDEBAR - 3;
155
+ const out = [];
156
+
157
+ // Header ------------------------------------------------------------------
158
+ const items = visibleItems(state);
159
+ const where = state.query ? `“${state.query}”`
160
+ : state.filter ? state.filter
161
+ : state.usingDefaults ? "default feeds" : "all feeds";
162
+ const title = ` ${bone("moshcode rss")}${ash(` ${items.length} headline${items.length === 1 ? "" : "s"} · ${where}`)}`;
163
+ const status = state.loading ? acid("loading…")
164
+ : state.failures.length ? amber(`${state.failures.length} feed${state.failures.length === 1 ? "" : "s"} down`)
165
+ : "";
166
+ out.push(pad(title, width - visibleWidth(status) - 2) + status + " ");
167
+ out.push(` ${dim("─".repeat(Math.max(10, width - 4)))}`);
168
+
169
+ // Body --------------------------------------------------------------------
170
+ const side = sidebarRows(state);
171
+ const bodyLines = state.pane === "article"
172
+ ? articleLines(state, { width: width - 4, height: bodyHeight })
173
+ : listLines(state, items, { width: listWidth, height: bodyHeight });
174
+
175
+ for (let row = 0; row < bodyHeight; row++) {
176
+ if (state.pane === "article") { out.push(bodyLines[row] ?? ""); continue; }
177
+ const feedRow = side[row + state.sideOffset];
178
+ let left = "";
179
+ if (feedRow) {
180
+ const selected = state.pane === "feeds" && row + state.sideOffset === state.sideSelected;
181
+ const active = (feedRow.key ?? null) === (state.filter ?? null);
182
+ const cursor = selected ? acid("▸") : " ";
183
+ // 2 indent + cursor + space + label + space + 3 count = SIDEBAR exactly.
184
+ // Building it one wider than the column it is padded into is how every
185
+ // body row ends up a column past the edge of the terminal.
186
+ const label = clip(feedRow.label, SIDEBAR - 8);
187
+ const paint = active ? bone : ash;
188
+ left = ` ${cursor} ${paint(pad(label, SIDEBAR - 8))} ${dim(String(feedRow.count).padStart(3))}`;
189
+ }
190
+ out.push(`${pad(left, SIDEBAR)} ${dim("│")} ${bodyLines[row] ?? ""}`);
191
+ }
192
+
193
+ // Footer ------------------------------------------------------------------
194
+ out.push(` ${dim("─".repeat(Math.max(10, width - 4)))}`);
195
+ out.push(` ${keyHint(state, width - 4)}`);
196
+ return out.slice(0, rows);
197
+ }
198
+
199
+ /**
200
+ * The footer, trimmed to fit.
201
+ *
202
+ * Hints are dropped from the right until the line fits the terminal, rather
203
+ * than being allowed to run past it — a footer one column too wide wraps onto a
204
+ * line the frame did not budget for, which scrolls the screen and puts every
205
+ * row of the next frame one off. They are ordered least-guessable first, so
206
+ * what survives on a narrow terminal is what someone could not have guessed.
207
+ */
208
+ function keyHint(state, width) {
209
+ if (state.mode === "search") {
210
+ const prompt = `${ash("search:")} ${bone(state.input)}${acid("▏")}`;
211
+ const help = dim(" ⏎ run · esc cancel");
212
+ return visibleWidth(prompt + help) <= width ? prompt + help : prompt;
213
+ }
214
+ const keys = state.pane === "article"
215
+ ? [["⏎/esc", "back"], ["o", "open"], ["j/k", "next/prev"], ["q", "quit"]]
216
+ : [["↑↓", "move"], ["⏎", "read"], ["o", "open"], ["/", "search"], ["tab", "feeds"], ["r", "refresh"], ["q", "quit"]];
217
+
218
+ const sep = dim(" · ");
219
+ let line = "";
220
+ for (const [key, what] of keys) {
221
+ const next = (line ? line + sep : "") + `${acid(key)} ${ash(what)}`;
222
+ if (visibleWidth(next) > width) break;
223
+ line = next;
224
+ }
225
+ return line;
226
+ }
227
+
228
+ /** The headline list, as `height` lines. */
229
+ function listLines(state, items, { width, height }) {
230
+ if (state.loading && !items.length) return [ash("fetching feeds…")];
231
+ if (!items.length) {
232
+ const lines = [ash("nothing here")];
233
+ if (state.failures.length) {
234
+ lines.push("", amber(`${state.failures.length} feed${state.failures.length === 1 ? "" : "s"} didn't answer:`));
235
+ for (const f of state.failures.slice(0, height - 3)) lines.push(` ${dim(`${f.name} — ${f.error}`)}`);
236
+ }
237
+ return lines;
238
+ }
239
+ const window = items.slice(state.offset, state.offset + height);
240
+ // One tail column for the whole viewport, so the feed names line up instead
241
+ // of ending wherever each headline happens to stop. Capped at half the pane
242
+ // so a feed with a long name cannot squeeze the headline out of its own list,
243
+ // and the title takes whatever is left — floored at nothing, because a floor
244
+ // above the available width is how a line ends up wider than the terminal.
245
+ const tails = window.map((item) => {
246
+ const when = ago(item.date, state.now);
247
+ return `${item.feed}${when ? ` · ${when}` : ""}`.trim();
248
+ });
249
+ const tailWidth = Math.min(Math.max(0, ...tails.map((t) => t.length)), Math.floor(width / 2));
250
+ const room = Math.max(1, width - tailWidth - 4);
251
+
252
+ return window.map((item, i) => {
253
+ const selected = state.offset + i === state.selected;
254
+ const cursor = selected ? acid("▸") : " ";
255
+ const title = pad(clip(item.title, room), room);
256
+ const tail = clip(tails[i], tailWidth).padStart(tailWidth);
257
+ return `${cursor} ${selected ? bone(title) : ash(title)} ${dim(tail)}`;
258
+ });
259
+ }
260
+
261
+ /** The selected story, as `height` lines. */
262
+ function articleLines(state, { width, height }) {
263
+ const items = visibleItems(state);
264
+ const item = items[state.selected];
265
+ if (!item) return [ash("nothing selected")];
266
+ const lines = [""];
267
+ for (const line of wrap(item.title, width - 4)) lines.push(` ${bone(line)}`);
268
+ lines.push("");
269
+ const when = item.date ? `${new Date(item.date).toLocaleString()} · ${ago(item.date, state.now)}` : "no date";
270
+ lines.push(` ${ash(`${item.feedTitle || item.feed} · ${when}`)}`);
271
+ if (item.author) lines.push(` ${ash(`by ${item.author}`)}`);
272
+ lines.push("");
273
+ if (item.summary) {
274
+ for (const line of wrap(item.summary, width - 4)) lines.push(` ${ash(line)}`);
275
+ lines.push("");
276
+ }
277
+ // Wrapped, not clipped: this is the line someone copies out of the reader.
278
+ if (item.link) for (const line of wrap(item.link, width - 4)) lines.push(` ${acid(line)}`);
279
+ else lines.push(` ${dim("this item has no link")}`);
280
+ return lines.slice(0, height);
281
+ }
282
+
283
+ /** Keep the selection inside the list, and the viewport around the selection. */
284
+ function clampView(state, height) {
285
+ const items = visibleItems(state);
286
+ state.selected = Math.max(0, Math.min(state.selected, Math.max(0, items.length - 1)));
287
+ if (state.selected < state.offset) state.offset = state.selected;
288
+ if (state.selected >= state.offset + height) state.offset = state.selected - height + 1;
289
+ state.offset = Math.max(0, Math.min(state.offset, Math.max(0, items.length - height)));
290
+ }
291
+
292
+ /**
293
+ * Run the reader. Returns a process exit code.
294
+ *
295
+ * `deps` mirrors herdUi's: injectable stdin/stdout and an injectable fetch, so
296
+ * the loop can be driven in a test with no terminal and no network.
297
+ */
298
+ export async function rssUi(argv = [], deps = {}) {
299
+ const {
300
+ stdin = process.stdin,
301
+ stdout = process.stdout,
302
+ fetchImpl,
303
+ openUrl,
304
+ env = process.env,
305
+ write = (s) => process.stdout.write(`${s}\n`),
306
+ } = deps;
307
+
308
+ if (!stdin.isTTY || !stdout.isTTY) {
309
+ write("moshcode rss needs an interactive terminal — try `moshcode news`");
310
+ return 1;
311
+ }
312
+
313
+ // A query on the command line (`moshcode rss tariffs`) opens straight into
314
+ // the search, which is the same shape `/news <keyword>` has.
315
+ const query = argv.filter((a) => !String(a).startsWith("-")).join(" ").trim();
316
+ const list = readingList(env);
317
+
318
+ const state = {
319
+ feeds: query ? searchFeeds(query) : list.feeds,
320
+ usingDefaults: query ? false : list.usingDefaults,
321
+ query: query || null,
322
+ items: [],
323
+ failures: [],
324
+ selected: 0,
325
+ offset: 0,
326
+ sideSelected: 0,
327
+ sideOffset: 0,
328
+ filter: null,
329
+ pane: "list",
330
+ mode: "browse",
331
+ input: "",
332
+ loading: true,
333
+ now: Date.now(),
334
+ };
335
+
336
+ let done = false;
337
+ let restored = false;
338
+ const wasRaw = Boolean(stdin.isRaw);
339
+ const restore = () => {
340
+ if (restored) return;
341
+ restored = true;
342
+ stdout.write(ESC.mouseOff + ESC.showCursor + ESC.altOff);
343
+ try { stdin.setRawMode?.(wasRaw); } catch { /* already gone */ }
344
+ stdin.pause();
345
+ };
346
+ const enter = () => {
347
+ restored = false;
348
+ stdout.write(ESC.altOn + ESC.hideCursor + ESC.mouseOn);
349
+ try { stdin.setRawMode?.(true); } catch { /* not a tty */ }
350
+ stdin.resume();
351
+ };
352
+ const onSignal = () => { restore(); process.exit(130); };
353
+ process.on("exit", restore);
354
+ process.on("SIGINT", onSignal);
355
+ process.on("SIGTERM", onSignal);
356
+
357
+ const height = () => Math.max(3, (stdout.rows || 24) - HEADER_LINES - FOOTER_LINES);
358
+ const draw = () => {
359
+ if (done) return;
360
+ clampView(state, height());
361
+ stdout.write(ESC.clear + renderReader(state, { rows: stdout.rows || 24, cols: stdout.columns || 80 }).join("\r\n"));
362
+ };
363
+
364
+ const load = async () => {
365
+ state.loading = true;
366
+ draw();
367
+ const { items, failures } = await collectNews(state.feeds, { fetchImpl });
368
+ state.items = items;
369
+ state.failures = failures;
370
+ state.now = Date.now();
371
+ state.loading = false;
372
+ state.selected = 0;
373
+ state.offset = 0;
374
+ draw();
375
+ };
376
+
377
+ enter();
378
+ draw();
379
+ const onResize = () => draw();
380
+ stdout.on("resize", onResize);
381
+ await load();
382
+
383
+ await new Promise((resolve) => {
384
+ const onData = async (buf) => {
385
+ for (const event of decodeKeys(buf)) {
386
+ if (done) return;
387
+
388
+ // The search prompt owns every key while it is up, or typing "q" into
389
+ // it would quit instead of searching for the letter q.
390
+ if (state.mode === "search") {
391
+ if (event.kind !== "key") continue;
392
+ if (event.name === "escape") { state.mode = "browse"; state.input = ""; draw(); continue; }
393
+ if (event.name === "backspace") { state.input = state.input.slice(0, -1); draw(); continue; }
394
+ if (event.name === "enter") {
395
+ const q = state.input.trim();
396
+ state.mode = "browse";
397
+ state.input = "";
398
+ if (!q) { draw(); continue; }
399
+ state.query = q;
400
+ state.feeds = searchFeeds(q);
401
+ state.usingDefaults = false;
402
+ state.filter = null;
403
+ state.pane = "list";
404
+ await load();
405
+ continue;
406
+ }
407
+ if (event.char && event.char >= " ") { state.input += event.char; draw(); }
408
+ continue;
409
+ }
410
+
411
+ if (event.kind === "wheel") {
412
+ state.selected += event.direction;
413
+ draw();
414
+ continue;
415
+ }
416
+ if (event.kind === "click") {
417
+ // Row 1-2 are the header, so the first list row is line 3.
418
+ const index = state.offset + (event.row - HEADER_LINES - 1);
419
+ if (event.col <= SIDEBAR) {
420
+ const side = sidebarRows(state)[state.sideOffset + (event.row - HEADER_LINES - 1)];
421
+ if (side) { state.filter = side.key; state.selected = 0; state.offset = 0; state.pane = "list"; draw(); }
422
+ continue;
423
+ }
424
+ const items = visibleItems(state);
425
+ if (index >= 0 && index < items.length) {
426
+ // A single click selects; a second on the same row reads it — the
427
+ // rule herd-ui settled on, so one stray click is never a trip.
428
+ const opening = index === state.selected && state.pane === "list";
429
+ state.selected = index;
430
+ state.pane = opening ? "article" : "list";
431
+ draw();
432
+ }
433
+ continue;
434
+ }
435
+ if (event.kind !== "key") continue;
436
+
437
+ const name = event.name;
438
+ if (name === "q" || name === "ctrl-c") { done = true; resolve(); return; }
439
+
440
+ if (state.pane === "article") {
441
+ if (name === "enter" || name === "escape" || name === "left" || name === "h") { state.pane = "list"; draw(); continue; }
442
+ if (name === "j" || name === "down") { state.selected += 1; draw(); continue; }
443
+ if (name === "k" || name === "up") { state.selected -= 1; draw(); continue; }
444
+ }
445
+
446
+ if (name === "/") { state.mode = "search"; state.input = ""; draw(); continue; }
447
+ if (name === "r") { await load(); continue; }
448
+ if (name === "tab" || name === "\t") {
449
+ state.pane = state.pane === "feeds" ? "list" : "feeds";
450
+ draw();
451
+ continue;
452
+ }
453
+
454
+ // The sidebar has its own selection, so it has to claim the movement
455
+ // keys before the headline list does — otherwise tab would highlight a
456
+ // feed and j/k would scroll the headlines beside it.
457
+ if (state.pane === "feeds") {
458
+ const side = sidebarRows(state);
459
+ const move = (delta) => {
460
+ state.sideSelected = Math.max(0, Math.min(state.sideSelected + delta, side.length - 1));
461
+ const rows = height();
462
+ if (state.sideSelected < state.sideOffset) state.sideOffset = state.sideSelected;
463
+ if (state.sideSelected >= state.sideOffset + rows) state.sideOffset = state.sideSelected - rows + 1;
464
+ draw();
465
+ };
466
+ if (name === "j" || name === "down") { move(1); continue; }
467
+ if (name === "k" || name === "up") { move(-1); continue; }
468
+ if (name === "g" || name === "home") { state.sideSelected = 0; state.sideOffset = 0; draw(); continue; }
469
+ if (name === "G" || name === "end") { move(side.length); continue; }
470
+ if (name === "enter" || name === "right" || name === "l") {
471
+ const row = side[state.sideSelected];
472
+ state.filter = row ? row.key : null;
473
+ state.selected = 0;
474
+ state.offset = 0;
475
+ state.pane = "list";
476
+ draw();
477
+ continue;
478
+ }
479
+ if (name === "escape") { state.pane = "list"; draw(); continue; }
480
+ continue;
481
+ }
482
+
483
+ if (name === "j" || name === "down") { state.selected += 1; draw(); continue; }
484
+ if (name === "k" || name === "up") { state.selected -= 1; draw(); continue; }
485
+ if (name === "pagedown" || name === " ") { state.selected += height(); draw(); continue; }
486
+ if (name === "pageup") { state.selected -= height(); draw(); continue; }
487
+ if (name === "g" || name === "home") { state.selected = 0; draw(); continue; }
488
+ if (name === "G" || name === "end") { state.selected = visibleItems(state).length - 1; draw(); continue; }
489
+ if (name === "enter") { state.pane = "article"; draw(); continue; }
490
+ if (name === "o") {
491
+ const item = visibleItems(state)[state.selected];
492
+ if (!item?.link) continue;
493
+ // The browser gets the terminal only for as long as the opener runs;
494
+ // a headless box just falls through with nothing opened.
495
+ const opened = openUrl ? openUrl(item.link) : false;
496
+ if (!opened) {
497
+ restore();
498
+ write(`open this in a browser:\n ${item.link}`);
499
+ enter();
500
+ }
501
+ draw();
502
+ continue;
503
+ }
504
+ if (name === "a") { state.filter = null; state.selected = 0; draw(); continue; }
505
+ }
506
+ };
507
+ stdin.on("data", onData);
508
+ });
509
+
510
+ stdout.off("resize", onResize);
511
+ restore();
512
+ process.off("exit", restore);
513
+ process.off("SIGINT", onSignal);
514
+ process.off("SIGTERM", onSignal);
515
+ stdout.write("\n");
516
+ return 0;
517
+ }
package/src/tui.mjs CHANGED
@@ -879,6 +879,26 @@ export async function tui() {
879
879
  await cryptoCommand(rest, { openUrl: (url) => canOpenBrowser() && openBrowser(url) });
880
880
  continue;
881
881
  }
882
+ // `/news` renders in the pit rather than handing the terminal over: it is a
883
+ // list and a prompt to come back to, the same as `/stocks`.
884
+ if (cmd === "news") {
885
+ const { newsCommand } = await import("./news.mjs");
886
+ await newsCommand(rest, { openUrl: (url) => canOpenBrowser() && openBrowser(url) });
887
+ continue;
888
+ }
889
+ // `/rss` is the exception `/attach` is: it takes the whole terminal, so
890
+ // readline has to let go of stdin first or the two fight over every key.
891
+ if (cmd === "rss" || cmd === "reader") {
892
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
893
+ console.log(err("/rss needs an interactive terminal — try /news"));
894
+ continue;
895
+ }
896
+ const { rssUi } = await import("./rss-ui.mjs");
897
+ rl.close();
898
+ await rssUi(rest, { openUrl: (url) => canOpenBrowser() && openBrowser(url) });
899
+ rl = mkrl();
900
+ continue;
901
+ }
882
902
  if (cmd === "plugin" || cmd === "plugins") {
883
903
  await pluginCommand(rest);
884
904
  continue;