moshcode 0.50.0 → 0.52.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/README.md +3 -1
- package/package.json +1 -1
- package/src/advisor.mjs +1 -6
- package/src/aliases.mjs +2 -2
- package/src/commands.mjs +10 -7
- package/src/crypto.mjs +6 -22
- package/src/games.mjs +4 -6
- package/src/herd-cli.mjs +15 -16
- package/src/news-sources.mjs +80 -0
- package/src/news.mjs +168 -20
- package/src/rss-ui.mjs +53 -51
- package/src/shell.mjs +96 -0
- package/src/tools.mjs +14 -1
- package/src/tui.mjs +12 -8
- package/src/ui.mjs +236 -0
package/README.md
CHANGED
|
@@ -433,6 +433,7 @@ moshcode install doppler # official script, installed user-local (needs
|
|
|
433
433
|
moshcode install doctl # GitHub release binary → ~/.local/bin
|
|
434
434
|
moshcode install turso # official script → ~/.turso (new shell to pick up PATH)
|
|
435
435
|
moshcode install tailscale # official script; system daemon, so it needs root
|
|
436
|
+
moshcode install coral # official script → ~/.local/bin (checksum-verified)
|
|
436
437
|
|
|
437
438
|
moshcode gh pr list # straight through to the native CLI
|
|
438
439
|
moshcode railway up
|
|
@@ -1034,7 +1035,7 @@ chmod +x deploy.mosh
|
|
|
1034
1035
|
| `ask(prompt)` | blocking gate — waits for human reply at moshcode.sh |
|
|
1035
1036
|
| `say("…")` | print a line |
|
|
1036
1037
|
| `sleep(ms)` | pause for N milliseconds (blocking) |
|
|
1037
|
-
| `shell(cmd)` | run a shell command (blocking, `$SHELL -
|
|
1038
|
+
| `shell(cmd)` | run a shell command (blocking, `$SHELL -ic`, so your rc file loads); returns `{ ok, code }` |
|
|
1038
1039
|
| `stop()` | end the loop (`alive = false`) |
|
|
1039
1040
|
| `repeat()` | back to the top of the loop |
|
|
1040
1041
|
|
|
@@ -1061,6 +1062,7 @@ chmod +x deploy.mosh
|
|
|
1061
1062
|
| `doctl(args…)` | drive the DigitalOcean CLI |
|
|
1062
1063
|
| `turso(args…)` | drive the Turso CLI |
|
|
1063
1064
|
| `tailscale(args…)` | drive the Tailscale CLI |
|
|
1065
|
+
| `coral(args…)` | drive the Coral CLI (SQL over APIs, databases, internal systems) |
|
|
1064
1066
|
| `pwd()` | print the current repo/location |
|
|
1065
1067
|
| `run(file)` | run another .mosh file (include/compose) |
|
|
1066
1068
|
|
package/package.json
CHANGED
package/src/advisor.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// The API is documented at https://advis0r.com/api and returns *stored*
|
|
9
9
|
// snapshots: a report carries `reportGeneratedAt`, and every renderer prints it.
|
|
10
10
|
// A stale price is fine; a stale price dressed up as a live one is not.
|
|
11
|
-
import { acid, ash, amber, bone, danger, dim } from "./ui.mjs";
|
|
11
|
+
import { acid, ash, amber, bone, clip, danger, dim } from "./ui.mjs";
|
|
12
12
|
|
|
13
13
|
export const DEFAULT_ADVISOR_URL = "https://advis0r.com";
|
|
14
14
|
|
|
@@ -297,11 +297,6 @@ function compact(v) {
|
|
|
297
297
|
|
|
298
298
|
const day = (v) => (v ? String(v).slice(0, 10) : "—");
|
|
299
299
|
|
|
300
|
-
function clip(text, width) {
|
|
301
|
-
const s = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
302
|
-
return s.length <= width ? s : `${s.slice(0, Math.max(1, width - 1))}…`;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
300
|
/** Direction → color, so a wall of signals is skimmable. */
|
|
306
301
|
function tone(direction) {
|
|
307
302
|
if (direction === "positive") return acid;
|
package/src/aliases.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// An alias is a name and a line. The line is a shell command unless it starts
|
|
9
9
|
// with `/`, in which case it is a pit command:
|
|
10
10
|
//
|
|
11
|
-
// /alias set gs "git status" → /gs runs `$SHELL -
|
|
11
|
+
// /alias set gs "git status" → /gs runs `$SHELL -ic "git status"`
|
|
12
12
|
// /alias set cc "/agents claude" → /cc opens claude autonomously
|
|
13
13
|
//
|
|
14
14
|
// Shell-by-default because that is what the prompt is mostly asked for, and the
|
|
@@ -148,7 +148,7 @@ export function removeAlias(name) {
|
|
|
148
148
|
*
|
|
149
149
|
* Appended rather than substituted, the way a shell alias behaves: `/gs -sb` is
|
|
150
150
|
* `git status -sb`. `args` is the raw remainder of the typed line, not the
|
|
151
|
-
* tokenized parts, so the user's own quoting survives into `$SHELL -
|
|
151
|
+
* tokenized parts, so the user's own quoting survives into `$SHELL -ic`.
|
|
152
152
|
*
|
|
153
153
|
* The `!` is what routes a bare value to the shell — the pit already reads a
|
|
154
154
|
* leading `!` as "run this in $SHELL", so an alias does not need a second path
|
package/src/commands.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { cliVerb, aiVerb } from "./cli.mjs";
|
|
|
20
20
|
import { ingestApproval, pollApproval } from "./notify.mjs";
|
|
21
21
|
import { capture, killSession, sendPrompt } from "./herd.mjs";
|
|
22
22
|
import { herdStart, roster, waitFor } from "./herd-cli.mjs";
|
|
23
|
+
import { shellInvocation } from "./shell.mjs";
|
|
23
24
|
|
|
24
25
|
// The moshcoding pit-anthem playlist. mosh() blasts this URL and, on a desktop
|
|
25
26
|
// with a GUI, tries to open it in the default browser.
|
|
@@ -190,9 +191,9 @@ const COMMANDS = [
|
|
|
190
191
|
|
|
191
192
|
{
|
|
192
193
|
name: "shell",
|
|
193
|
-
summary: "run a shell command (blocking, cmd.exe on Windows or $SHELL
|
|
194
|
+
summary: "run a shell command (blocking, cmd.exe on Windows or $SHELL elsewhere)",
|
|
194
195
|
usage: "shell(cmd)",
|
|
195
|
-
detail: "runs cmd in $SHELL; returns { ok, code, signal }",
|
|
196
|
+
detail: "runs cmd in $SHELL, loading your rc file where it can; returns { ok, code, signal }",
|
|
196
197
|
// The moshscript system verb for arbitrary shell commands. Blocking
|
|
197
198
|
// (spawnSync + inherited stdio) so it runs inline in the no-`await` style,
|
|
198
199
|
// and the child owns the terminal for interactive commands. Returns
|
|
@@ -202,15 +203,16 @@ const COMMANDS = [
|
|
|
202
203
|
const cmd = args.join(" ");
|
|
203
204
|
if (!cmd) throw new Error("moshscript: shell() requires a command string");
|
|
204
205
|
if (ctx.dryRun) {
|
|
205
|
-
ctx.out(` ▶ shell(${JSON.stringify(cmd)}) → would run: $SHELL
|
|
206
|
+
ctx.out(` ▶ shell(${JSON.stringify(cmd)}) → would run: $SHELL ${shellInvocation(cmd).flags} ${JSON.stringify(cmd)}`);
|
|
206
207
|
// Same R8 contract as the comment above: `code` is always present, so a
|
|
207
208
|
// script branching on the exit status behaves the same under --dry-run.
|
|
208
209
|
return { ok: true, code: 0, dryRun: true };
|
|
209
210
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
211
|
+
// Same invocation the pit's own `!cmd` uses, so a command that works when
|
|
212
|
+
// typed works when scripted: interactive where a terminal is attached, so
|
|
213
|
+
// the user's rc file — and the aliases in it — are loaded. src/shell.mjs
|
|
214
|
+
// has the reasoning, including why a headless run stays non-interactive.
|
|
215
|
+
const { shell: sh, args: shArgs } = shellInvocation(cmd);
|
|
214
216
|
ctx.out(` ▶ shell: ${cmd}`);
|
|
215
217
|
const res = spawnSync(sh, shArgs, { stdio: "inherit" });
|
|
216
218
|
if (res.error) throw res.error;
|
|
@@ -350,6 +352,7 @@ const COMMANDS = [
|
|
|
350
352
|
cliVerb("doctl", "drive the DigitalOcean CLI (droplets, apps, databases)"),
|
|
351
353
|
cliVerb("turso", "drive the Turso CLI (auth, databases, replicas)"),
|
|
352
354
|
cliVerb("tailscale", "drive the Tailscale CLI (mesh VPN: up, status, ssh, serve)"),
|
|
355
|
+
cliVerb("coral", "drive the Coral CLI (SQL over APIs, databases, and internal systems)"),
|
|
353
356
|
cliVerb("alpaca", "drive the native Alpaca trading CLI"),
|
|
354
357
|
cliVerb("trade", "look up tickers, inspect markets, and preview/place Alpaca orders"),
|
|
355
358
|
cliVerb("pwd", "print the current repo/location"),
|
package/src/crypto.mjs
CHANGED
|
@@ -13,7 +13,12 @@
|
|
|
13
13
|
// Rendering them through one code path would mean one set of labels lying about
|
|
14
14
|
// one of them.
|
|
15
15
|
import { advisorBase } from "./advisor.mjs";
|
|
16
|
-
import { acid, ash, amber, bone, danger, dim } from "./ui.mjs";
|
|
16
|
+
import { acid, ash, amber, bone, clip, danger, dim, sparkline } from "./ui.mjs";
|
|
17
|
+
|
|
18
|
+
// `spark` is this module's own command, so the renderer keeps its name in the
|
|
19
|
+
// public surface even though the drawing now lives in ui.mjs with the rest of
|
|
20
|
+
// the layout primitives.
|
|
21
|
+
export { sparkline };
|
|
17
22
|
|
|
18
23
|
const USAGE = `usage: moshcode crypto <pair|verb> [args…]
|
|
19
24
|
|
|
@@ -384,11 +389,6 @@ function stamp(v) {
|
|
|
384
389
|
|
|
385
390
|
const day = (v) => (v ? String(v).slice(0, 10) : "—");
|
|
386
391
|
|
|
387
|
-
function clip(text, width) {
|
|
388
|
-
const s = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
389
|
-
return s.length <= width ? s : `${s.slice(0, Math.max(1, width - 1))}…`;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
392
|
function wrapText(text, width) {
|
|
393
393
|
const words = String(text).replace(/\s+/g, " ").trim().split(" ");
|
|
394
394
|
const lines = [];
|
|
@@ -416,22 +416,6 @@ function scoreTone(score) {
|
|
|
416
416
|
return danger;
|
|
417
417
|
}
|
|
418
418
|
|
|
419
|
-
const SPARK_TICKS = "▁▂▃▄▅▆▇█";
|
|
420
|
-
|
|
421
|
-
/** Render a close series as one line of block characters. */
|
|
422
|
-
export function sparkline(points) {
|
|
423
|
-
const values = (Array.isArray(points) ? points : []).map(Number).filter(Number.isFinite);
|
|
424
|
-
if (!values.length) return "";
|
|
425
|
-
const min = Math.min(...values);
|
|
426
|
-
const max = Math.max(...values);
|
|
427
|
-
// A flat series has no range to scale into; drawing it at the floor would
|
|
428
|
-
// imply a crash, so it sits mid-band instead.
|
|
429
|
-
if (max === min) return SPARK_TICKS[3].repeat(values.length);
|
|
430
|
-
return values
|
|
431
|
-
.map((v) => SPARK_TICKS[Math.min(SPARK_TICKS.length - 1, Math.floor(((v - min) / (max - min)) * SPARK_TICKS.length))])
|
|
432
|
-
.join("");
|
|
433
|
-
}
|
|
434
|
-
|
|
435
419
|
/**
|
|
436
420
|
* The API ships a disclaimer with every substantive response. Printing it is
|
|
437
421
|
* not decoration — this renders scored market analysis in a terminal next to a
|
package/src/games.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// a state, hand it a key, hand it a tick, ask it for rows — and everything that
|
|
12
12
|
// touches a terminal lives in `runGame` down the bottom. That is what makes an
|
|
13
13
|
// arcade testable: test/games.test.mjs plays entire games without a TTY.
|
|
14
|
-
import { acid, amber, ash, bone, danger, dim, rgb } from "./ui.mjs";
|
|
14
|
+
import { acid, amber, ash, bone, danger, dim, pad, rgb, strip, visible } from "./ui.mjs";
|
|
15
15
|
import { TETRIS } from "./games-tetris.mjs";
|
|
16
16
|
import { SNAKE } from "./games-snake.mjs";
|
|
17
17
|
import { PACMAN } from "./games-pacman.mjs";
|
|
@@ -70,11 +70,9 @@ export function resolveGame(name) {
|
|
|
70
70
|
|
|
71
71
|
// Colour codes are invisible but not zero-width to `.length`, so every pad in
|
|
72
72
|
// here measures the stripped string. Getting this wrong is how a board's right
|
|
73
|
-
// edge ends up ragged the moment someone wins.
|
|
74
|
-
|
|
75
|
-
export
|
|
76
|
-
export const visible = (s) => strip(s).length;
|
|
77
|
-
const pad = (s, width) => s + " ".repeat(Math.max(0, width - visible(s)));
|
|
73
|
+
// edge ends up ragged the moment someone wins. The games re-export these
|
|
74
|
+
// because every one of them draws with them; the implementations are ui.mjs's.
|
|
75
|
+
export { strip, visible };
|
|
78
76
|
|
|
79
77
|
/**
|
|
80
78
|
* The one frame every game is drawn in.
|
package/src/herd-cli.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
import { clearReport, reportState, STATES, withState } from "./herd-state.mjs";
|
|
17
17
|
import { ENGINES, resolveEngine, resolveExecutable, agentLaunchArgs } from "./engines.mjs";
|
|
18
18
|
import { ingestApproval, pollApproval } from "./notify.mjs";
|
|
19
|
-
import { acid, amber, ash, bone, danger, dim, err, info, ok, warn } from "./ui.mjs";
|
|
19
|
+
import { acid, amber, ash, bone, danger, dim, err, info, ok, table, warn } from "./ui.mjs";
|
|
20
20
|
|
|
21
21
|
/** Distinct exit codes, because `wait` exists to be branched on (R10). */
|
|
22
22
|
export const EXIT = { matched: 0, usage: 1, timeout: 2, gone: 3 };
|
|
@@ -78,21 +78,20 @@ export function paintState(state) {
|
|
|
78
78
|
*/
|
|
79
79
|
export function renderRoster(rows, { indent = " " } = {}) {
|
|
80
80
|
if (!rows.length) return "";
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
"
|
|
94
|
-
|
|
95
|
-
].join("")).join("\n");
|
|
81
|
+
// Cells go in painted and `table` measures what prints, which is what the
|
|
82
|
+
// state column needed: padding a coloured string to a fixed 9 used to mean
|
|
83
|
+
// hand-correcting the width by the length of its own escape codes, and the
|
|
84
|
+
// cwd column was pinned at 24 whether the paths were 8 columns or 60.
|
|
85
|
+
return table(
|
|
86
|
+
rows.map((r) => [
|
|
87
|
+
bone(r.name),
|
|
88
|
+
ash(String(r.engine)),
|
|
89
|
+
paintState(r.state),
|
|
90
|
+
ash(tilde(r.cwd || "")),
|
|
91
|
+
dim(humanAge(r.age)),
|
|
92
|
+
]),
|
|
93
|
+
{ columns: ["name", "engine", "state", "cwd", "age"], header: false, indent: indent.length },
|
|
94
|
+
);
|
|
96
95
|
}
|
|
97
96
|
|
|
98
97
|
/** Every session, with state attached. The one place that assembles both. */
|
package/src/news-sources.mjs
CHANGED
|
@@ -88,6 +88,86 @@ export function isDeadEndLink(url) {
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Words a quote page's title is padded with, and the corporate suffixes that
|
|
93
|
+
* are part of a company's legal name rather than part of a headline.
|
|
94
|
+
*
|
|
95
|
+
* Stripped before counting words, because what is left after they go is the
|
|
96
|
+
* company name — and a title that is only a company name is a label on a page,
|
|
97
|
+
* not a report of something that happened.
|
|
98
|
+
*/
|
|
99
|
+
const LABEL_WORDS = new RegExp(
|
|
100
|
+
"\\b(common|preferred|ordinary|class [a-c]|stock|stocks|shares?|share|price|prices|"
|
|
101
|
+
+ "quote|quotes|chart|charts|charting|financials?|fundamentals?|overview|profile|"
|
|
102
|
+
+ "summary|statistics|historical|data|dividends?|earnings|holdings?|ratings?|"
|
|
103
|
+
+ "forecast|analysis|news|nasdaq|nyse|amex|otc|inc|incorporated|corp|corporation|"
|
|
104
|
+
+ "ltd|limited|plc|llc|lp|co|company|group|holdings|sa|ag|nv|the|and|of)\\b\\.?",
|
|
105
|
+
"gi",
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The reference-page paths, host-agnostic.
|
|
110
|
+
*
|
|
111
|
+
* Every finance site builds the same page — one URL per ticker, showing the
|
|
112
|
+
* current price and never going stale — and they nearly all spell it with one
|
|
113
|
+
* of these segments. Matching the path shape rather than a list of hostnames
|
|
114
|
+
* means a site nobody thought of is still recognised, and a site that changes
|
|
115
|
+
* its domain does not need a code change.
|
|
116
|
+
*/
|
|
117
|
+
const QUOTE_PATH = new RegExp(
|
|
118
|
+
"(^|/)(quote|quotes|symbol|symbols|tickers?|market-activity|market-data|"
|
|
119
|
+
+ "investing/stock|investing/stocks|markets/companies|data/equities|"
|
|
120
|
+
+ "stocks/charts|stock-price|price-quote)(/|$)",
|
|
121
|
+
"i",
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Is this a standing reference page rather than a story?
|
|
126
|
+
*
|
|
127
|
+
* A news search for a ticker does not come back with only news. Measured on
|
|
128
|
+
* `LTRN`, four of Bing's nine results were the same kind of thing: nasdaq.com's
|
|
129
|
+
* insider-activity and advanced-charting tabs, marketwatch.com's financials
|
|
130
|
+
* tab, and seekingalpha.com/symbol/LTRN. None of them is an article. They are
|
|
131
|
+
* the permanent page a site keeps for a ticker, and they are worse than merely
|
|
132
|
+
* useless in a dated list, because the date attached to them is whenever the
|
|
133
|
+
* crawler last looked:
|
|
134
|
+
*
|
|
135
|
+
* · Two of the four carried a 2020 date and sorted to the bottom as "75mo
|
|
136
|
+
* ago", which reads as an old story rather than as a page with no date.
|
|
137
|
+
* · marketwatch.com/investing/stock/ltrn/financials carried *yesterday's*
|
|
138
|
+
* date and sorted to the very top, so the freshest-looking headline in the
|
|
139
|
+
* list was a page that has not changed in years.
|
|
140
|
+
*
|
|
141
|
+
* Both signals are required, because either alone is wrong often enough to
|
|
142
|
+
* matter. A quote-shaped URL is not proof — publishers file real stories under
|
|
143
|
+
* `/quote/` — and a short title is not proof either, since "Lantern Pharma
|
|
144
|
+
* Halts Trial" is four words and is news. Demanding both means a story has to
|
|
145
|
+
* be filed at a reference URL *and* be titled like a label before it is
|
|
146
|
+
* dropped, and on the LTRN sample that is exactly the four pages and none of
|
|
147
|
+
* the five stories.
|
|
148
|
+
*
|
|
149
|
+
* Dropped rather than merely deranked, but never silently: collectNews returns
|
|
150
|
+
* the count so the listing can say how many it set aside.
|
|
151
|
+
*/
|
|
152
|
+
export function isReferencePage(url, title) {
|
|
153
|
+
let path;
|
|
154
|
+
try { path = new URL(String(url)).pathname; }
|
|
155
|
+
catch { return false; }
|
|
156
|
+
if (!QUOTE_PATH.test(path)) return false;
|
|
157
|
+
|
|
158
|
+
// The ticker in parentheses, then a bare all-caps ticker anywhere, then the
|
|
159
|
+
// boilerplate. Order matters: "(LTRN)" has to go before the bare-ticker rule
|
|
160
|
+
// sees it, or the parentheses are left behind as a word of their own.
|
|
161
|
+
const bare = String(title ?? "")
|
|
162
|
+
.replace(/\([^)]*\)/g, " ")
|
|
163
|
+
.replace(/\b[A-Z]{1,5}(?:\.[A-Z]{1,2})?\b/g, " ")
|
|
164
|
+
.replace(LABEL_WORDS, " ")
|
|
165
|
+
.replace(/[^a-z0-9]+/gi, " ")
|
|
166
|
+
.trim();
|
|
167
|
+
const words = bare ? bare.split(/\s+/).length : 0;
|
|
168
|
+
return words <= 3;
|
|
169
|
+
}
|
|
170
|
+
|
|
91
171
|
/** The vendored copy of what profullstack.com/feeds.opml serves. */
|
|
92
172
|
const PROFULLSTACK_OPML = new URL("./profullstack-feeds.opml", import.meta.url);
|
|
93
173
|
|
package/src/news.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
defaultFeeds,
|
|
29
29
|
FEED_LISTS,
|
|
30
30
|
isDeadEndLink,
|
|
31
|
+
isReferencePage,
|
|
31
32
|
resolveList,
|
|
32
33
|
subscribableLists,
|
|
33
34
|
unwrapRedirect,
|
|
@@ -644,7 +645,7 @@ export function parseFeed(xml, { url = "" } = {}) {
|
|
|
644
645
|
|
|
645
646
|
const items = [];
|
|
646
647
|
for (const block of blocks) {
|
|
647
|
-
const title = pick(block, "title");
|
|
648
|
+
const title = tidyTitle(pick(block, "title"));
|
|
648
649
|
const link = linkOf(block, url);
|
|
649
650
|
if (!title && !link) continue; // nothing to show and nothing to open
|
|
650
651
|
items.push({
|
|
@@ -663,6 +664,22 @@ function clip(value, max) {
|
|
|
663
664
|
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
|
664
665
|
}
|
|
665
666
|
|
|
667
|
+
/**
|
|
668
|
+
* A title with someone else's truncation marker tidied up.
|
|
669
|
+
*
|
|
670
|
+
* Search engines cut long titles and mark the cut with a space and three dots.
|
|
671
|
+
* Left alone that trailing " ..." is a word like any other to a wrapper, so a
|
|
672
|
+
* headline that fills its last line exactly puts the dots on a line of their
|
|
673
|
+
* own — a row containing nothing but "...". Joining it to the word before it,
|
|
674
|
+
* as the single character it means, keeps it where the cut happened.
|
|
675
|
+
*/
|
|
676
|
+
export function tidyTitle(title) {
|
|
677
|
+
return String(title ?? "")
|
|
678
|
+
.replace(/\s*(\.\s*){3,}\s*$/, "…")
|
|
679
|
+
.replace(/\s+…$/, "…")
|
|
680
|
+
.trim();
|
|
681
|
+
}
|
|
682
|
+
|
|
666
683
|
// ---------------------------------------------------------------------------
|
|
667
684
|
// Fetching
|
|
668
685
|
// ---------------------------------------------------------------------------
|
|
@@ -766,6 +783,8 @@ export async function collectNews(feeds, { fetchImpl, timeoutMs = DEFAULT_TIMEOU
|
|
|
766
783
|
const items = [];
|
|
767
784
|
const failures = [];
|
|
768
785
|
const seen = new Set();
|
|
786
|
+
const titles = new Set();
|
|
787
|
+
let skipped = 0;
|
|
769
788
|
for (const result of results) {
|
|
770
789
|
if (result.error) { failures.push({ name: result.feed.name, url: result.feed.url, error: result.error }); continue; }
|
|
771
790
|
for (const item of result.parsed.items) {
|
|
@@ -778,17 +797,57 @@ export async function collectNews(feeds, { fetchImpl, timeoutMs = DEFAULT_TIMEOU
|
|
|
778
797
|
// aggregator interstitials qualify; an item with no link at all is still
|
|
779
798
|
// worth listing, because its title carries the news.
|
|
780
799
|
if (link && isDeadEndLink(link)) continue;
|
|
800
|
+
// A ticker's standing quote page is not news, and its crawl date makes it
|
|
801
|
+
// sort as though it were. Counted rather than hidden — the listing says
|
|
802
|
+
// how many it set aside.
|
|
803
|
+
if (link && isReferencePage(link, item.title)) { skipped++; continue; }
|
|
781
804
|
const key = link || `${result.feed.name}:${item.title}`;
|
|
782
805
|
if (seen.has(key)) continue;
|
|
783
806
|
seen.add(key);
|
|
784
|
-
|
|
807
|
+
// The same title twice is one story, whichever URL carried it. Search
|
|
808
|
+
// engines return a publisher's own page and two syndications of it, and
|
|
809
|
+
// subscribed feeds overlap the same way; the first copy wins, and because
|
|
810
|
+
// this runs before the sort that is the first feed to answer rather than
|
|
811
|
+
// the newest. Only titles that survive normalisation to something
|
|
812
|
+
// substantial are deduped, so a feed of one-word entries is left alone.
|
|
813
|
+
const fingerprint = fingerprintTitle(item.title);
|
|
814
|
+
if (fingerprint) {
|
|
815
|
+
if (titles.has(fingerprint)) continue;
|
|
816
|
+
titles.add(fingerprint);
|
|
817
|
+
}
|
|
818
|
+
items.push({
|
|
819
|
+
...item,
|
|
820
|
+
link,
|
|
821
|
+
feed: result.feed.name,
|
|
822
|
+
feedTitle: result.parsed.title || result.feed.title,
|
|
823
|
+
// Who published it, which is what a searcher needs and what the feed
|
|
824
|
+
// name cannot say: every result of a `/news search` comes from the one
|
|
825
|
+
// search feed, so a column of "bing" nine times over is a column that
|
|
826
|
+
// tells you nothing. Empty for an item with no link.
|
|
827
|
+
host: link ? hostOf(link) : "",
|
|
828
|
+
});
|
|
785
829
|
}
|
|
786
830
|
}
|
|
787
831
|
// Newest first, and undated entries last rather than pretending they are old:
|
|
788
832
|
// plenty of feeds omit dates entirely, and sorting them to the bottom keeps
|
|
789
833
|
// them reachable without letting them claim the top of the list.
|
|
790
834
|
items.sort((a, b) => (b.date ?? -Infinity) - (a.date ?? -Infinity));
|
|
791
|
-
return { items, failures };
|
|
835
|
+
return { items, failures, skipped };
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* A title reduced to what two copies of the same story would share.
|
|
840
|
+
*
|
|
841
|
+
* Case, punctuation and whitespace go, because syndication reflows all three.
|
|
842
|
+
* Returns "" for anything too short to be worth deduping on, so the caller can
|
|
843
|
+
* tell "no fingerprint" from "a fingerprint that happens to be empty".
|
|
844
|
+
*/
|
|
845
|
+
export function fingerprintTitle(title) {
|
|
846
|
+
const key = String(title ?? "")
|
|
847
|
+
.toLowerCase()
|
|
848
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
849
|
+
.trim();
|
|
850
|
+
return key.length >= 12 ? key : "";
|
|
792
851
|
}
|
|
793
852
|
|
|
794
853
|
/**
|
|
@@ -959,37 +1018,124 @@ export function ago(ms, now = Date.now()) {
|
|
|
959
1018
|
if (days < 14) return `${days}d ago`;
|
|
960
1019
|
const weeks = Math.round(days / 7);
|
|
961
1020
|
if (weeks < 9) return `${weeks}w ago`;
|
|
962
|
-
|
|
1021
|
+
const months = Math.round(days / 30);
|
|
1022
|
+
if (months < 24) return `${months}mo ago`;
|
|
1023
|
+
// Past two years, months stop being a unit anyone reads. "75mo ago" is a
|
|
1024
|
+
// number to divide before it means anything; "6y ago" is already the answer.
|
|
1025
|
+
// Whole years only — at this distance the decimal is precision about
|
|
1026
|
+
// something nobody is deciding anything on.
|
|
1027
|
+
return `${Math.round(days / 365)}y ago`;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Break `text` into lines of at most `width`, on word boundaries where it can.
|
|
1032
|
+
*
|
|
1033
|
+
* A word longer than the width — which in practice means a URL — is split
|
|
1034
|
+
* across lines rather than truncated. It has to be split somehow: left whole it
|
|
1035
|
+
* wraps the terminal itself and every line below it lands one row low, which is
|
|
1036
|
+
* the one failure that tears a whole frame. Splitting rather than cutting
|
|
1037
|
+
* because the over-long word is usually the link, and half a link is not a link.
|
|
1038
|
+
*
|
|
1039
|
+
* `max` caps how many lines come back, for a caller laying out a fixed number
|
|
1040
|
+
* of rows. The last line kept is clipped rather than simply dropped, so the cut
|
|
1041
|
+
* announces itself instead of looking like the text ended there.
|
|
1042
|
+
*
|
|
1043
|
+
* Lives here rather than in rss-ui.mjs, which is where it was written and which
|
|
1044
|
+
* still exports it, because the plain listing needs the same wrapping and
|
|
1045
|
+
* rss-ui.mjs already imports this module — taking it the other way is a cycle.
|
|
1046
|
+
*/
|
|
1047
|
+
export function wrap(text, width, max = Infinity) {
|
|
1048
|
+
const columns = Math.max(1, Math.floor(width));
|
|
1049
|
+
const words = String(text ?? "").split(/\s+/).filter(Boolean);
|
|
1050
|
+
const lines = [];
|
|
1051
|
+
let line = "";
|
|
1052
|
+
const flush = () => { if (line) { lines.push(line); line = ""; } };
|
|
1053
|
+
|
|
1054
|
+
for (const word of words) {
|
|
1055
|
+
if (word.length > columns) {
|
|
1056
|
+
flush();
|
|
1057
|
+
for (let i = 0; i < word.length; i += columns) lines.push(word.slice(i, i + columns));
|
|
1058
|
+
continue;
|
|
1059
|
+
}
|
|
1060
|
+
if (!line) { line = word; continue; }
|
|
1061
|
+
if (line.length + 1 + word.length <= columns) { line += ` ${word}`; continue; }
|
|
1062
|
+
flush();
|
|
1063
|
+
line = word;
|
|
1064
|
+
}
|
|
1065
|
+
flush();
|
|
1066
|
+
if (lines.length <= max) return lines;
|
|
1067
|
+
// The dropped lines have to leave a mark, or the text simply appears to end
|
|
1068
|
+
// where it was cut. Appended where there is room and cut into the last line
|
|
1069
|
+
// where there is not, because a marker that pushes past `columns` is the one
|
|
1070
|
+
// thing this function exists to prevent.
|
|
1071
|
+
const kept = lines.slice(0, max);
|
|
1072
|
+
const last = kept[max - 1];
|
|
1073
|
+
kept[max - 1] = `${last.length < columns ? last : last.slice(0, Math.max(0, columns - 1))}…`;
|
|
1074
|
+
return kept;
|
|
963
1075
|
}
|
|
964
1076
|
|
|
965
|
-
/**
|
|
966
|
-
|
|
1077
|
+
/** At most this many lines of one headline. Three is a very long headline. */
|
|
1078
|
+
const TITLE_LINES = 3;
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* The headline list.
|
|
1082
|
+
*
|
|
1083
|
+
* Wrapped rather than clipped to one line, which is the change that makes this
|
|
1084
|
+
* readable. A single-line row spends its width on a fixed column for the feed
|
|
1085
|
+
* name and the age, and what gives is the headline — so the one thing on the
|
|
1086
|
+
* row you actually needed is the one thing cut off, and a press release titled
|
|
1087
|
+
* "Lantern Pharma Establishes Open-Medicine AI as a Separate Company to
|
|
1088
|
+
* Commercialize and Expand…" reads as "Establishes Open-Medicine AI as a
|
|
1089
|
+
* Separate Compa…". The title now takes the full width and as many lines as it
|
|
1090
|
+
* needs, and the attribution moves under it where it costs the headline
|
|
1091
|
+
* nothing.
|
|
1092
|
+
*
|
|
1093
|
+
* `byHost` picks what each headline is attributed to. A search sets it: every
|
|
1094
|
+
* row arrives on the one search feed, so a column reading "bing" nine times is
|
|
1095
|
+
* a column that says nothing, while "tmcnet.com" against "finance.yahoo.com" is
|
|
1096
|
+
* most of what tells you whether a result is worth opening. The reading list
|
|
1097
|
+
* leaves it off, because there the feed name is the one you chose, the one
|
|
1098
|
+
* `--feed` takes, and — for an aggregator like Hacker News, where every item
|
|
1099
|
+
* links to a different host — the only stable thing on the row.
|
|
1100
|
+
*/
|
|
1101
|
+
export function renderHeadlines(items, {
|
|
1102
|
+
failures = [], columns, limit = DEFAULT_LIMIT, now = Date.now(), source = "",
|
|
1103
|
+
skipped = 0, byHost = false,
|
|
1104
|
+
} = {}) {
|
|
967
1105
|
const width = Math.max(48, Math.min(Number(columns) || 88, 100));
|
|
968
1106
|
const shown = items.slice(0, limit);
|
|
969
1107
|
if (!shown.length) {
|
|
970
1108
|
const lines = ["", ` ${ash("nothing came back")}`];
|
|
1109
|
+
if (skipped) lines.push(` ${ash(`${skipped} quote page${skipped === 1 ? " was" : "s were"} skipped — they were not news`)}`);
|
|
971
1110
|
if (failures.length) lines.push("", ...failureLines(failures));
|
|
972
1111
|
else lines.push("", ` ${ash("subscribe with")} ${bone("/news add <url>")}`);
|
|
973
1112
|
return lines.join("\n");
|
|
974
1113
|
}
|
|
975
1114
|
|
|
976
|
-
// The widest index, so "9." and "10." line their titles up
|
|
1115
|
+
// The widest index, so "9." and "10." line their titles up, and everything
|
|
1116
|
+
// under a headline hangs off that same margin.
|
|
977
1117
|
const gutter = String(shown.length).length + 1;
|
|
978
|
-
const
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
// is what gives — and they line up, which is the whole point of a column.
|
|
985
|
-
const tailWidth = Math.max(0, ...tails.map((t) => t.length));
|
|
986
|
-
const room = Math.max(24, width - gutter - tailWidth - 4);
|
|
1118
|
+
const indent = ` ${" ".repeat(gutter)} `;
|
|
1119
|
+
const room = width - indent.length;
|
|
1120
|
+
|
|
1121
|
+
const head = [source || `${items.length} headline${items.length === 1 ? "" : "s"}`];
|
|
1122
|
+
if (skipped) head.push(`${skipped} quote page${skipped === 1 ? "" : "s"} skipped`);
|
|
1123
|
+
const lines = ["", ` ${ash(head.join(" · "))}`, ""];
|
|
987
1124
|
|
|
988
|
-
const lines = ["", ` ${ash(source || `${items.length} headline${items.length === 1 ? "" : "s"}`)}`, ""];
|
|
989
1125
|
for (const [i, item] of shown.entries()) {
|
|
990
1126
|
const n = `${i + 1}.`.padStart(gutter);
|
|
991
|
-
|
|
1127
|
+
// An item with no title at all still gets its number and its meta line —
|
|
1128
|
+
// parseFeed only keeps a titleless entry when it has a link worth opening.
|
|
1129
|
+
const [first = "", ...rest] = wrap(item.title, room, TITLE_LINES);
|
|
1130
|
+
lines.push(` ${acid(n)} ${bone(first)}`);
|
|
1131
|
+
for (const line of rest) lines.push(`${indent}${bone(line)}`);
|
|
1132
|
+
const when = ago(item.date, now);
|
|
1133
|
+
const who = byHost ? item.host || item.feed : item.feed || item.host;
|
|
1134
|
+
const meta = [who || "", when].filter(Boolean).join(" · ");
|
|
1135
|
+
if (meta) lines.push(`${indent}${ash(meta)}`);
|
|
1136
|
+
if (i < shown.length - 1) lines.push("");
|
|
992
1137
|
}
|
|
1138
|
+
|
|
993
1139
|
lines.push("", ` ${ash("open one with")} ${bone("/news open <n>")}`);
|
|
994
1140
|
if (failures.length) lines.push("", ...failureLines(failures));
|
|
995
1141
|
return lines.join("\n");
|
|
@@ -1222,17 +1368,19 @@ export async function newsCommand(argv = [], deps = {}) {
|
|
|
1222
1368
|
}
|
|
1223
1369
|
}
|
|
1224
1370
|
|
|
1225
|
-
const { items, failures } = await collectNews(feeds, { fetchImpl, timeoutMs: request.timeoutMs });
|
|
1371
|
+
const { items, failures, skipped } = await collectNews(feeds, { fetchImpl, timeoutMs: request.timeoutMs });
|
|
1226
1372
|
const shown = items.slice(0, request.limit);
|
|
1227
1373
|
|
|
1228
1374
|
if (request.json) {
|
|
1229
|
-
out(JSON.stringify({ items: shown, failures, feeds: feeds.length }, null, 2));
|
|
1375
|
+
out(JSON.stringify({ items: shown, failures, skipped, feeds: feeds.length }, null, 2));
|
|
1230
1376
|
} else {
|
|
1231
1377
|
out(renderHeadlines(items, {
|
|
1232
1378
|
failures,
|
|
1233
1379
|
columns,
|
|
1234
1380
|
limit: request.limit,
|
|
1235
1381
|
now,
|
|
1382
|
+
skipped,
|
|
1383
|
+
byHost: request.verb === "search",
|
|
1236
1384
|
source: source ? `${source} · ${items.length} headline${items.length === 1 ? "" : "s"}` : "",
|
|
1237
1385
|
}));
|
|
1238
1386
|
}
|
package/src/rss-ui.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// Rendering is a pure function of state (`renderReader`), so a frame can be
|
|
14
14
|
// asserted in a test without a tty, a fetch, or a keystroke.
|
|
15
15
|
import { parseMouse } from "./herd-ui.mjs";
|
|
16
|
-
import { acid, amber, ash, bone, danger, dim } from "./ui.mjs";
|
|
16
|
+
import { acid, amber, ash, bone, danger, dim, pad, visible } from "./ui.mjs";
|
|
17
17
|
import {
|
|
18
18
|
ago,
|
|
19
19
|
collectNews,
|
|
@@ -22,8 +22,14 @@ import {
|
|
|
22
22
|
readingList,
|
|
23
23
|
resolveVerb,
|
|
24
24
|
searchFeeds,
|
|
25
|
+
wrap,
|
|
25
26
|
} from "./news.mjs";
|
|
26
27
|
|
|
28
|
+
// Written here, and moved to news.mjs when the plain listing needed the same
|
|
29
|
+
// wrapping — re-exported so the name this module published stays where callers
|
|
30
|
+
// and tests already look for it.
|
|
31
|
+
export { wrap };
|
|
32
|
+
|
|
27
33
|
/**
|
|
28
34
|
* Verbs `/rss` hands straight to `moshcode news`.
|
|
29
35
|
*
|
|
@@ -45,16 +51,10 @@ const SIDEBAR = 20;
|
|
|
45
51
|
const HEADER_LINES = 2; // title + rule
|
|
46
52
|
const FOOTER_LINES = 2; // rule + keys
|
|
47
53
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Pad to `width` printable columns, colour codes not counted. */
|
|
54
|
-
function pad(text, width) {
|
|
55
|
-
const short = width - visibleWidth(text);
|
|
56
|
-
return short > 0 ? text + " ".repeat(short) : text;
|
|
57
|
-
}
|
|
54
|
+
// Printable width, ignoring the SGR sequences ui.mjs wraps text in. Named
|
|
55
|
+
// `visibleWidth` on the way out because the reader's tests and callers already
|
|
56
|
+
// use that name; `pad` comes from the same place now.
|
|
57
|
+
export { visible as visibleWidth };
|
|
58
58
|
|
|
59
59
|
/** Truncate to `width` printable columns. Only ever called on uncoloured text. */
|
|
60
60
|
function clip(text, width) {
|
|
@@ -62,37 +62,6 @@ function clip(text, width) {
|
|
|
62
62
|
return s.length > width ? `${s.slice(0, Math.max(0, width - 1))}…` : s;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
/**
|
|
66
|
-
* Break `text` into lines of at most `width`, on word boundaries where it can.
|
|
67
|
-
*
|
|
68
|
-
* A word longer than the pane — which in practice means a URL — is split across
|
|
69
|
-
* lines rather than truncated. It has to be split somehow: left whole it wraps
|
|
70
|
-
* the terminal itself and every line below it lands one row low, which is the
|
|
71
|
-
* one failure that tears the whole frame. Splitting rather than cutting because
|
|
72
|
-
* the over-long word is usually the link, and half a link is not a link.
|
|
73
|
-
*/
|
|
74
|
-
export function wrap(text, width) {
|
|
75
|
-
const columns = Math.max(1, Math.floor(width));
|
|
76
|
-
const words = String(text ?? "").split(/\s+/).filter(Boolean);
|
|
77
|
-
const lines = [];
|
|
78
|
-
let line = "";
|
|
79
|
-
const flush = () => { if (line) { lines.push(line); line = ""; } };
|
|
80
|
-
|
|
81
|
-
for (const word of words) {
|
|
82
|
-
if (word.length > columns) {
|
|
83
|
-
flush();
|
|
84
|
-
for (let i = 0; i < word.length; i += columns) lines.push(word.slice(i, i + columns));
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
if (!line) { line = word; continue; }
|
|
88
|
-
if (line.length + 1 + word.length <= columns) { line += ` ${word}`; continue; }
|
|
89
|
-
flush();
|
|
90
|
-
line = word;
|
|
91
|
-
}
|
|
92
|
-
flush();
|
|
93
|
-
return lines;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
65
|
/**
|
|
97
66
|
* Decode a chunk of raw-mode input.
|
|
98
67
|
*
|
|
@@ -136,20 +105,48 @@ export function decodeKeys(buffer) {
|
|
|
136
105
|
return events;
|
|
137
106
|
}
|
|
138
107
|
|
|
108
|
+
/**
|
|
109
|
+
* What a headline is grouped and filtered by.
|
|
110
|
+
*
|
|
111
|
+
* The feed it came from, except on a search — there every result arrives on the
|
|
112
|
+
* one search feed, so grouping by feed puts all of them in a single row called
|
|
113
|
+
* "bing" and the sidebar becomes a label rather than a filter. The publisher is
|
|
114
|
+
* the useful split instead.
|
|
115
|
+
*
|
|
116
|
+
* Only on a search, because the reverse is worse the rest of the time: a feed
|
|
117
|
+
* like Hacker News links out to a different host on nearly every item, so
|
|
118
|
+
* grouping subscribed feeds by publisher would replace thirteen feed names with
|
|
119
|
+
* several hundred one-item rows.
|
|
120
|
+
*/
|
|
121
|
+
export function groupOf(state, item) {
|
|
122
|
+
return (state.query ? item.host || item.feed : item.feed) || "";
|
|
123
|
+
}
|
|
124
|
+
|
|
139
125
|
/** Feeds down the left, with a count each and an "all" row on top. */
|
|
140
126
|
export function sidebarRows(state) {
|
|
141
127
|
const counts = new Map();
|
|
142
|
-
for (const item of state.items)
|
|
128
|
+
for (const item of state.items) {
|
|
129
|
+
const key = groupOf(state, item);
|
|
130
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
131
|
+
}
|
|
143
132
|
const rows = [{ key: null, label: "all", count: state.items.length }];
|
|
133
|
+
if (state.query) {
|
|
134
|
+
// Publishers, busiest first — there is no subscription order to fall back
|
|
135
|
+
// on, and the site that carried five of the results is the one worth seeing.
|
|
136
|
+
for (const [key, count] of [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))) {
|
|
137
|
+
if (key) rows.push({ key, label: key, count });
|
|
138
|
+
}
|
|
139
|
+
return rows;
|
|
140
|
+
}
|
|
144
141
|
for (const feed of state.feeds) {
|
|
145
142
|
rows.push({ key: feed.name, label: feed.name, count: counts.get(feed.name) || 0 });
|
|
146
143
|
}
|
|
147
144
|
return rows;
|
|
148
145
|
}
|
|
149
146
|
|
|
150
|
-
/** The headlines currently on show — everything, or one feed's. */
|
|
147
|
+
/** The headlines currently on show — everything, or one feed's (one publisher's, on a search). */
|
|
151
148
|
export function visibleItems(state) {
|
|
152
|
-
return state.filter ? state.items.filter((i) => i
|
|
149
|
+
return state.filter ? state.items.filter((i) => groupOf(state, i) === state.filter) : state.items;
|
|
153
150
|
}
|
|
154
151
|
|
|
155
152
|
/**
|
|
@@ -170,11 +167,14 @@ export function renderReader(state, { rows = 24, cols = 80 } = {}) {
|
|
|
170
167
|
const where = state.query ? `“${state.query}”`
|
|
171
168
|
: state.filter ? state.filter
|
|
172
169
|
: state.usingDefaults ? "default feeds" : "all feeds";
|
|
173
|
-
|
|
170
|
+
// The quote pages collectNews set aside are counted here too — a reader that
|
|
171
|
+
// hides results without saying so is a reader you cannot trust the count of.
|
|
172
|
+
const aside = state.skipped ? ` · ${state.skipped} quote page${state.skipped === 1 ? "" : "s"} skipped` : "";
|
|
173
|
+
const title = ` ${bone("moshcode rss")}${ash(` ${items.length} headline${items.length === 1 ? "" : "s"} · ${where}${aside}`)}`;
|
|
174
174
|
const status = state.loading ? acid("loading…")
|
|
175
175
|
: state.failures.length ? amber(`${state.failures.length} feed${state.failures.length === 1 ? "" : "s"} down`)
|
|
176
176
|
: "";
|
|
177
|
-
out.push(pad(title, width -
|
|
177
|
+
out.push(pad(title, width - visible(status) - 2) + status + " ");
|
|
178
178
|
out.push(` ${dim("─".repeat(Math.max(10, width - 4)))}`);
|
|
179
179
|
|
|
180
180
|
// Body --------------------------------------------------------------------
|
|
@@ -220,7 +220,7 @@ function keyHint(state, width) {
|
|
|
220
220
|
if (state.mode === "search") {
|
|
221
221
|
const prompt = `${ash("search:")} ${bone(state.input)}${acid("▏")}`;
|
|
222
222
|
const help = dim(" ⏎ run · esc cancel");
|
|
223
|
-
return
|
|
223
|
+
return visible(prompt + help) <= width ? prompt + help : prompt;
|
|
224
224
|
}
|
|
225
225
|
const keys = state.pane === "article"
|
|
226
226
|
? [["⏎/esc", "back"], ["o", "open"], ["j/k", "next/prev"], ["q", "quit"]]
|
|
@@ -230,7 +230,7 @@ function keyHint(state, width) {
|
|
|
230
230
|
let line = "";
|
|
231
231
|
for (const [key, what] of keys) {
|
|
232
232
|
const next = (line ? line + sep : "") + `${acid(key)} ${ash(what)}`;
|
|
233
|
-
if (
|
|
233
|
+
if (visible(next) > width) break;
|
|
234
234
|
line = next;
|
|
235
235
|
}
|
|
236
236
|
return line;
|
|
@@ -255,7 +255,7 @@ function listLines(state, items, { width, height }) {
|
|
|
255
255
|
// above the available width is how a line ends up wider than the terminal.
|
|
256
256
|
const tails = window.map((item) => {
|
|
257
257
|
const when = ago(item.date, state.now);
|
|
258
|
-
return `${item
|
|
258
|
+
return `${groupOf(state, item)}${when ? ` · ${when}` : ""}`.trim();
|
|
259
259
|
});
|
|
260
260
|
const tailWidth = Math.min(Math.max(0, ...tails.map((t) => t.length)), Math.floor(width / 2));
|
|
261
261
|
const room = Math.max(1, width - tailWidth - 4);
|
|
@@ -355,6 +355,7 @@ export async function rssUi(argv = [], deps = {}) {
|
|
|
355
355
|
query: query || null,
|
|
356
356
|
items: [],
|
|
357
357
|
failures: [],
|
|
358
|
+
skipped: 0,
|
|
358
359
|
selected: 0,
|
|
359
360
|
offset: 0,
|
|
360
361
|
sideSelected: 0,
|
|
@@ -398,9 +399,10 @@ export async function rssUi(argv = [], deps = {}) {
|
|
|
398
399
|
const load = async () => {
|
|
399
400
|
state.loading = true;
|
|
400
401
|
draw();
|
|
401
|
-
const { items, failures } = await collectNews(state.feeds, { fetchImpl });
|
|
402
|
+
const { items, failures, skipped } = await collectNews(state.feeds, { fetchImpl });
|
|
402
403
|
state.items = items;
|
|
403
404
|
state.failures = failures;
|
|
405
|
+
state.skipped = skipped || 0;
|
|
404
406
|
state.now = Date.now();
|
|
405
407
|
state.loading = false;
|
|
406
408
|
state.selected = 0;
|
package/src/shell.mjs
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// One answer to "how does the pit run a shell command".
|
|
2
|
+
//
|
|
3
|
+
// The pit is not a shell, so everything it runs on the user's behalf goes out
|
|
4
|
+
// through $SHELL: `!cmd`, `/shell`, a shell-valued alias from /alias, and
|
|
5
|
+
// moshscript's shell(). The obvious spelling is `$SHELL -c "<cmd>"`, and it is
|
|
6
|
+
// wrong in a way that costs an afternoon to find. `zsh -c` and `bash -c` are
|
|
7
|
+
// non-interactive shells, and a non-interactive shell does not read ~/.zshrc or
|
|
8
|
+
// ~/.bashrc — so the aliases and functions defined there are simply not there:
|
|
9
|
+
//
|
|
10
|
+
// /alias set prs gh-prs-all → zsh -c gh-prs-all
|
|
11
|
+
// → zsh:1: command not found: gh-prs-all
|
|
12
|
+
//
|
|
13
|
+
// while the identical word works when typed at a prompt. That is a bug rather
|
|
14
|
+
// than a footnote, because naming a shell command is most of what /alias is
|
|
15
|
+
// for, and the shell commands people name are the ones they already named once
|
|
16
|
+
// in ~/.zsh_aliases. An alias that resolves at the prompt and not in the pit
|
|
17
|
+
// makes the pit look broken, and from the user's side it is.
|
|
18
|
+
//
|
|
19
|
+
// So we ask for an interactive shell. `-i` is the switch that makes bash and
|
|
20
|
+
// zsh read their rc file, and the rc file is where the user's shell actually
|
|
21
|
+
// lives. Anything already on PATH worked before and still works; what changes
|
|
22
|
+
// is that aliases and functions now resolve too.
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Shells whose startup file is read only when the shell is interactive.
|
|
26
|
+
*
|
|
27
|
+
* Deliberately just bash and zsh. fish sources config.fish however it was
|
|
28
|
+
* started, so it needs nothing from us; plain sh/dash have no rc file to miss
|
|
29
|
+
* and `-i` would only buy them job-control machinery; and a shell we have not
|
|
30
|
+
* heard of is likelier to be harmed by an unexpected flag than helped by it.
|
|
31
|
+
* Being wrong here means running a command in a shell that cannot see the
|
|
32
|
+
* user's aliases, which is exactly where we started — so an unknown shell
|
|
33
|
+
* lands on the old behaviour rather than on a guess.
|
|
34
|
+
*/
|
|
35
|
+
const RC_ON_INTERACTIVE = new Set(["bash", "zsh"]);
|
|
36
|
+
|
|
37
|
+
/** Set this to opt a session out of rc loading and get plain `-c` back. */
|
|
38
|
+
export const NO_RC_ENV = "MOSHCODE_SHELL_NO_RC";
|
|
39
|
+
|
|
40
|
+
/** Windows has no rc file in this sense; cmd.exe wants its own flag spelling. */
|
|
41
|
+
const CMD_FLAGS = ["/d", "/s", "/c"];
|
|
42
|
+
|
|
43
|
+
/** The shell the user runs, or the platform's fallback. */
|
|
44
|
+
export function shellPath(env = process.env, platform = process.platform) {
|
|
45
|
+
if (platform === "win32") return env.COMSPEC || "cmd.exe";
|
|
46
|
+
return env.SHELL || "/bin/sh";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `zsh` from `/usr/bin/zsh`, `bash` from `C:\...\bash.exe`.
|
|
51
|
+
*
|
|
52
|
+
* Both separators by hand rather than path.basename, which is bound to the
|
|
53
|
+
* platform the code is running on: it would leave a Windows path intact when
|
|
54
|
+
* asked on Linux, and this function is also asked about the other platform —
|
|
55
|
+
* shellInvocation takes `platform` as an option so the Windows branch can be
|
|
56
|
+
* tested from anywhere.
|
|
57
|
+
*/
|
|
58
|
+
export function shellName(shell) {
|
|
59
|
+
const tail = String(shell || "").split(/[\\/]/).pop() || "";
|
|
60
|
+
return tail.replace(/\.exe$/i, "");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* How to spawn `rawCmd`, as { shell, args, flags, interactive }.
|
|
65
|
+
*
|
|
66
|
+
* `rawCmd` empty means "a shell to sit in" — no args at all, which is already
|
|
67
|
+
* an interactive shell and already reads the rc file.
|
|
68
|
+
*
|
|
69
|
+
* `tty` is why this takes options rather than reading the world directly. An
|
|
70
|
+
* interactive bash with no terminal attached prints
|
|
71
|
+
*
|
|
72
|
+
* bash: cannot set terminal process group (…): Inappropriate ioctl for device
|
|
73
|
+
* bash: no job control in this shell
|
|
74
|
+
*
|
|
75
|
+
* on stderr before it runs a thing, which would turn every headless run — cron,
|
|
76
|
+
* CI, `moshcode run script.mosh` in a pipeline — into noise around the output
|
|
77
|
+
* someone is trying to read. With a terminal attached, both shells are silent.
|
|
78
|
+
* So the rc file is loaded where a person is watching, which is the case that
|
|
79
|
+
* wanted it, and a headless run keeps the old quiet behaviour. zsh alone would
|
|
80
|
+
* not need the guard; the guard is not worth splitting per shell for.
|
|
81
|
+
*/
|
|
82
|
+
export function shellInvocation(rawCmd, {
|
|
83
|
+
env = process.env,
|
|
84
|
+
platform = process.platform,
|
|
85
|
+
tty = Boolean(process.stdin?.isTTY && process.stdout?.isTTY),
|
|
86
|
+
} = {}) {
|
|
87
|
+
const shell = shellPath(env, platform);
|
|
88
|
+
const name = shellName(shell);
|
|
89
|
+
if (!rawCmd) return { shell, args: [], flags: "", interactive: true, name };
|
|
90
|
+
if (platform === "win32") {
|
|
91
|
+
return { shell, args: [...CMD_FLAGS, rawCmd], flags: CMD_FLAGS.join(" "), interactive: false, name };
|
|
92
|
+
}
|
|
93
|
+
const interactive = tty && RC_ON_INTERACTIVE.has(name) && !env[NO_RC_ENV];
|
|
94
|
+
const flags = interactive ? "-ic" : "-c";
|
|
95
|
+
return { shell, args: [flags, rawCmd], flags, interactive, name };
|
|
96
|
+
}
|
package/src/tools.mjs
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// These are deliberately separate from coding engines: UGig owns marketplace
|
|
3
3
|
// workflows, CoinPay owns payment workflows, c0mpute owns the compute network,
|
|
4
4
|
// c0upons owns community coupons and bounties, the cloud CLIs below own
|
|
5
|
-
// deploys/secrets/infra,
|
|
5
|
+
// deploys/secrets/infra, Coral owns read-only data access across those systems,
|
|
6
|
+
// and moshcode only conducts their native command lines.
|
|
6
7
|
import { homedir } from "node:os";
|
|
7
8
|
import path from "node:path";
|
|
8
9
|
import { fileURLToPath } from "node:url";
|
|
@@ -123,6 +124,18 @@ export const TOOLS = {
|
|
|
123
124
|
// silently re-adding package repos.
|
|
124
125
|
upgrade: { cmd: "tailscale", args: ["update"] },
|
|
125
126
|
},
|
|
127
|
+
coral: {
|
|
128
|
+
desc: "Coral — read-only SQL across your APIs, databases, and internal systems",
|
|
129
|
+
bin: "coral",
|
|
130
|
+
// The vendor script resolves the latest GitHub release, verifies its
|
|
131
|
+
// sha256, and drops the binary in $HOME/.local/bin (CORAL_INSTALL_DIR
|
|
132
|
+
// overrides) — the same dir gh/supabase/doctl land in, so no binDirs.
|
|
133
|
+
// The script is POSIX sh, but withcoral.com documents `| bash`, so that is
|
|
134
|
+
// the pipeline we run. Re-running it is Coral's own documented upgrade path
|
|
135
|
+
// for a direct install, and toolUpgradeSpec falls back to install, so there
|
|
136
|
+
// is deliberately no upgrade key here.
|
|
137
|
+
install: { cmd: "bash", args: ["-c", "curl -fsSL https://withcoral.com/install.sh | bash"] },
|
|
138
|
+
},
|
|
126
139
|
alpaca: {
|
|
127
140
|
desc: "Alpaca — paper/live trading, market data, positions, and watchlists",
|
|
128
141
|
bin: "alpaca",
|
package/src/tui.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { stocksCommand } from "./advisor.mjs";
|
|
|
25
25
|
import { cryptoCommand } from "./crypto.mjs";
|
|
26
26
|
import { gamesCommand } from "./games.mjs";
|
|
27
27
|
import { canOpenBrowser, openBrowser } from "./open-url.mjs";
|
|
28
|
+
import { shellInvocation } from "./shell.mjs";
|
|
28
29
|
import { banner, hr, acid, ash, bone, dim, ok, err, warn, info, moshcodeVersion } from "./ui.mjs";
|
|
29
30
|
import { CORE_CLI_COMMAND_NAMES } from "./cli-schema.mjs";
|
|
30
31
|
import { RENAMED_COMMANDS, findPitCommand, pitHelpModel, renderPitCommand, suggest, wantsHelp } from "./help.mjs";
|
|
@@ -128,7 +129,7 @@ export function splitCommandLine(line) {
|
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
// Everything after the first word of a command line, exactly as typed. `/shell`
|
|
131
|
-
// hands this straight to `$SHELL -
|
|
132
|
+
// hands this straight to `$SHELL -ic`, the same way `!cmd` does: the shell does
|
|
132
133
|
// its own parsing, so re-joining the tokenized parts would strip the user's
|
|
133
134
|
// quotes and escapes and silently split `-m "two words"` into two arguments.
|
|
134
135
|
function commandRemainder(line, words = 1) {
|
|
@@ -148,7 +149,7 @@ function commandRemainder(line, words = 1) {
|
|
|
148
149
|
* quotes there belong to the shell. Tokenizing tells the two apart: exactly one
|
|
149
150
|
* token means the whole value was quoted, so use it with the quotes stripped;
|
|
150
151
|
* anything else is a bare command line, and it goes through verbatim so the
|
|
151
|
-
* user's own quoting survives into `$SHELL -
|
|
152
|
+
* user's own quoting survives into `$SHELL -ic`.
|
|
152
153
|
*/
|
|
153
154
|
export function aliasValue(line) {
|
|
154
155
|
const raw = commandRemainder(line, 3); // past "/alias", "set", "<name>"
|
|
@@ -508,12 +509,12 @@ async function openWorkflowTool(key, tool, args) {
|
|
|
508
509
|
|
|
509
510
|
// Spawn the user's shell with the terminal fully handed over (stdio inherit),
|
|
510
511
|
// inheriting the current cwd + env. No args → an interactive shell; a raw
|
|
511
|
-
// command string → `$SHELL -
|
|
512
|
+
// command string → `$SHELL -ic "<cmd>"` (one-off). Interactive so the command
|
|
513
|
+
// can see the aliases and functions in ~/.zshrc — see src/shell.mjs for why
|
|
514
|
+
// that is not optional. Resolves { ok, code, signal }.
|
|
512
515
|
function runShell(rawCmd) {
|
|
513
516
|
return new Promise((resolve) => {
|
|
514
|
-
const shell =
|
|
515
|
-
|| (process.platform === "win32" ? (process.env.COMSPEC || "cmd.exe") : "/bin/sh");
|
|
516
|
-
const args = rawCmd ? ["-c", rawCmd] : [];
|
|
517
|
+
const { shell, args } = shellInvocation(rawCmd);
|
|
517
518
|
let child;
|
|
518
519
|
try { child = spawn(shell, args, { stdio: "inherit" }); }
|
|
519
520
|
catch (e) { resolve({ ok: false, error: e }); return; }
|
|
@@ -525,9 +526,12 @@ function runShell(rawCmd) {
|
|
|
525
526
|
// vim `:sh` — drop into a shell and land back at the mosh prompt on exit, with
|
|
526
527
|
// the whole TUI session (history, cwd) intact. `rawCmd` runs a one-off instead.
|
|
527
528
|
async function openShell(rawCmd) {
|
|
528
|
-
|
|
529
|
+
// The flags come from the same place the spawn does, so the echoed line is
|
|
530
|
+
// what actually ran — a `-c` printed above an `-ic` invocation is the kind of
|
|
531
|
+
// small lie that sends someone debugging the wrong shell.
|
|
532
|
+
const { flags, name: shellName } = shellInvocation(rawCmd);
|
|
529
533
|
console.log(info(rawCmd
|
|
530
|
-
? `${bone(shellName)} ${ash(
|
|
534
|
+
? `${bone(shellName)} ${ash(flags)} ${ash(rawCmd)}`
|
|
531
535
|
: `dropping to ${bone(shellName)} — ${ash("`exit` or Ctrl-D brings you back to the pit")}`));
|
|
532
536
|
console.log(hr());
|
|
533
537
|
const r = await runShell(rawCmd);
|
package/src/ui.mjs
CHANGED
|
@@ -49,3 +49,239 @@ export function banner() {
|
|
|
49
49
|
export function hr() {
|
|
50
50
|
return ash("─".repeat(Math.min(process.stdout.columns || 60, 60)));
|
|
51
51
|
}
|
|
52
|
+
|
|
53
|
+
/* ------------------------------------------------- measuring and padding */
|
|
54
|
+
|
|
55
|
+
// Colour codes are invisible but not zero-width to `.length`, so anything that
|
|
56
|
+
// lines columns up has to measure the stripped string. Three modules had each
|
|
57
|
+
// grown their own copy of this before it lived here — games.mjs, rss-ui.mjs and
|
|
58
|
+
// the herd-ui test — which is the usual sign it belongs in one place. Getting
|
|
59
|
+
// it wrong is how a table's right edge goes ragged the moment one cell is
|
|
60
|
+
// coloured, and every caller here paints cells.
|
|
61
|
+
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
62
|
+
|
|
63
|
+
/** `text` with every SGR sequence removed. */
|
|
64
|
+
export const strip = (s) => String(s ?? "").replace(ANSI, "");
|
|
65
|
+
|
|
66
|
+
/** Printable width of `text` in terminal columns, colour codes not counted. */
|
|
67
|
+
export const visible = (s) => strip(s).length;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Pad to `width` printable columns.
|
|
71
|
+
*
|
|
72
|
+
* Short-circuits rather than truncating when the text is already wider: a table
|
|
73
|
+
* that silently ate a long name would be worse than one column of ragged edge,
|
|
74
|
+
* and `clip` is right there for callers that would rather cut.
|
|
75
|
+
*/
|
|
76
|
+
export function pad(text, width, align = "left") {
|
|
77
|
+
const s = String(text ?? "");
|
|
78
|
+
const short = width - visible(s);
|
|
79
|
+
if (short <= 0) return s;
|
|
80
|
+
if (align === "right") return " ".repeat(short) + s;
|
|
81
|
+
if (align === "center") {
|
|
82
|
+
const left = Math.floor(short / 2);
|
|
83
|
+
return " ".repeat(left) + s + " ".repeat(short - left);
|
|
84
|
+
}
|
|
85
|
+
return s + " ".repeat(short);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Truncate to `width` printable columns, ellipsis included in the budget.
|
|
90
|
+
*
|
|
91
|
+
* Colour-aware on purpose. `slice` on a painted string can cut inside an escape
|
|
92
|
+
* sequence or drop the reset that ends one, and a terminal handed a colour it
|
|
93
|
+
* never gets told to stop using keeps it for everything printed afterwards —
|
|
94
|
+
* including the next command's output. So this walks the escapes, counts only
|
|
95
|
+
* what prints, and closes the run if the cut landed inside one.
|
|
96
|
+
*
|
|
97
|
+
* Whitespace is collapsed first, matching the copies in advisor.mjs and
|
|
98
|
+
* crypto.mjs this replaces: these are table cells, and a cell containing a
|
|
99
|
+
* newline breaks the row it sits in.
|
|
100
|
+
*/
|
|
101
|
+
export function clip(text, width, { collapse = true } = {}) {
|
|
102
|
+
let s = String(text ?? "");
|
|
103
|
+
// \s never appears inside an SGR sequence, so this cannot corrupt one.
|
|
104
|
+
if (collapse) s = s.replace(/\s+/g, " ").trim();
|
|
105
|
+
if (visible(s) <= width) return s;
|
|
106
|
+
if (width <= 0) return "";
|
|
107
|
+
|
|
108
|
+
const room = Math.max(1, width - 1); // one column reserved for the ellipsis
|
|
109
|
+
let out = "";
|
|
110
|
+
let printed = 0;
|
|
111
|
+
let painted = false;
|
|
112
|
+
for (let i = 0; i < s.length && printed < room; i++) {
|
|
113
|
+
if (s[i] === "\x1b") {
|
|
114
|
+
const match = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
|
|
115
|
+
if (match) {
|
|
116
|
+
out += match[0];
|
|
117
|
+
painted = true;
|
|
118
|
+
i += match[0].length - 1;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
out += s[i];
|
|
123
|
+
printed++;
|
|
124
|
+
}
|
|
125
|
+
// A full reset rather than ui.mjs's narrower `\x1b[39m`: the cut may have
|
|
126
|
+
// landed inside dim, or inside a colour some caller opened around us, and
|
|
127
|
+
// leaking either is the bug this function exists to avoid.
|
|
128
|
+
return out + (painted ? "\x1b[0m" : "") + "…";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* --------------------------------------------------------------- layout */
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A column-aligned table, sized to its contents.
|
|
135
|
+
*
|
|
136
|
+
* The shape moshcode already prints by hand in advisor.mjs, crypto.mjs and
|
|
137
|
+
* herd-cli.mjs: a heading row, then rows of padded cells. Those all hardcode
|
|
138
|
+
* their widths, which is why a long ticker or a deep cwd pushes the columns out
|
|
139
|
+
* of line — this measures instead.
|
|
140
|
+
*
|
|
141
|
+
* `rows` are objects (addressed by `column.key`) or arrays (by position).
|
|
142
|
+
* Cells may be pre-painted; widths are measured with `visible`, so they line up
|
|
143
|
+
* anyway. Returns a string with no trailing newline, like `banner()`.
|
|
144
|
+
*
|
|
145
|
+
* ```
|
|
146
|
+
* ticker score price
|
|
147
|
+
* NVDA 92 $1,203.44
|
|
148
|
+
* RIVN 41 $12.09
|
|
149
|
+
* ```
|
|
150
|
+
*/
|
|
151
|
+
export function table(rows, {
|
|
152
|
+
columns = [],
|
|
153
|
+
gap = 2,
|
|
154
|
+
indent = 2,
|
|
155
|
+
header = true,
|
|
156
|
+
rule = false,
|
|
157
|
+
max = Infinity,
|
|
158
|
+
paint = ash,
|
|
159
|
+
} = {}) {
|
|
160
|
+
const cols = columns.map((c) => (typeof c === "string" ? { key: c, header: c } : c));
|
|
161
|
+
if (!cols.length) return "";
|
|
162
|
+
const body = Array.isArray(rows) ? rows : [];
|
|
163
|
+
const shown = body.slice(0, max);
|
|
164
|
+
|
|
165
|
+
const cell = (row, col, i) => {
|
|
166
|
+
const raw = Array.isArray(row) ? row[i] : row?.[col.key];
|
|
167
|
+
return raw == null ? "" : String(raw);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// A column is as wide as the widest thing in it, header included, unless the
|
|
171
|
+
// caller pinned it. Measured across the rows actually printed — sizing to
|
|
172
|
+
// rows cut by `max` would leave a gutter of dead space.
|
|
173
|
+
const widths = cols.map((col, i) => col.width ?? Math.max(
|
|
174
|
+
header ? visible(col.header ?? col.key ?? "") : 0,
|
|
175
|
+
...shown.map((row) => visible(cell(row, col, i))),
|
|
176
|
+
0,
|
|
177
|
+
));
|
|
178
|
+
|
|
179
|
+
const lead = " ".repeat(Math.max(0, indent));
|
|
180
|
+
const sep = " ".repeat(Math.max(1, gap));
|
|
181
|
+
const last = cols.length - 1;
|
|
182
|
+
// The final left-aligned column is emitted unpadded. Trailing spaces are
|
|
183
|
+
// invisible but real — they wrap early in a narrow terminal and show up in
|
|
184
|
+
// every snapshot — and trimming the finished line cannot remove them once a
|
|
185
|
+
// cell is painted, because the padding sits *inside* the colour codes, before
|
|
186
|
+
// the reset. Not adding it is the only thing that works for painted cells.
|
|
187
|
+
const fit = (text, i, align) => (i === last && (align ?? "left") === "left" ? String(text ?? "") : pad(text, widths[i], align));
|
|
188
|
+
// An empty final cell still gets its separator, so the trim is still needed —
|
|
189
|
+
// but it only has to remove literal spaces now, and with the last column left
|
|
190
|
+
// unpadded there are never any hiding inside a colour run for it to miss.
|
|
191
|
+
const line = (cells) => (lead + cells.join(sep)).replace(/ +$/, "");
|
|
192
|
+
|
|
193
|
+
const out = [];
|
|
194
|
+
if (header) {
|
|
195
|
+
out.push(line(cols.map((col, i) => paint(fit(col.header ?? col.key ?? "", i, col.align)))));
|
|
196
|
+
if (rule) out.push(line(widths.map((w) => ash("─".repeat(w)))));
|
|
197
|
+
}
|
|
198
|
+
for (const row of shown) {
|
|
199
|
+
out.push(line(cols.map((col, i) => fit(cell(row, col, i), i, col.align))));
|
|
200
|
+
}
|
|
201
|
+
if (body.length > shown.length) {
|
|
202
|
+
out.push(`${lead}${dim(`… ${body.length - shown.length} more`)}`);
|
|
203
|
+
}
|
|
204
|
+
return out.join("\n");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The box-drawing sets `panel` can frame with. */
|
|
208
|
+
const BORDERS = {
|
|
209
|
+
round: "╭╮╰╯─│",
|
|
210
|
+
single: "┌┐└┘─│",
|
|
211
|
+
double: "╔╗╚╝═║",
|
|
212
|
+
bold: "┏┓┗┛━┃",
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Frame `body` in a box, sized to its widest line.
|
|
217
|
+
*
|
|
218
|
+
* games.mjs has a `frame()` that does this for a game board with its own header
|
|
219
|
+
* and key line; this is the plain version for everything else. The title sits
|
|
220
|
+
* in the top edge rather than above it, which keeps a panel to one visual unit.
|
|
221
|
+
*
|
|
222
|
+
* ```
|
|
223
|
+
* ╭─ herd ──────────────╮
|
|
224
|
+
* │ api claude idle │
|
|
225
|
+
* ╰─────────────────────╯
|
|
226
|
+
* ```
|
|
227
|
+
*/
|
|
228
|
+
export function panel(body, { title = "", width = 0, indent = 2, style = "round", paint = ash } = {}) {
|
|
229
|
+
const lines = (Array.isArray(body) ? body : String(body ?? "").split("\n")).map(String);
|
|
230
|
+
const [tl, tr, bl, br, h, v] = BORDERS[style] || BORDERS.round;
|
|
231
|
+
// The title has to fit between the corners with its two spacers and at least
|
|
232
|
+
// one run of edge on each side, or the top row comes out wider than the box.
|
|
233
|
+
const inner = Math.max(width, visible(title) ? visible(title) + 4 : 0, ...lines.map(visible), 0);
|
|
234
|
+
const lead = " ".repeat(Math.max(0, indent));
|
|
235
|
+
|
|
236
|
+
// The bottom edge is `inner + 4` wide: two corners and the two spacer columns
|
|
237
|
+
// the body sits between. A titled top has to come out the same, so its run of
|
|
238
|
+
// edge is whatever is left after `╭─ title ` and the closing corner —
|
|
239
|
+
// `inner - len - 1`, not `inner - len - 3`, which drew every titled panel two
|
|
240
|
+
// columns narrow and visibly ragged.
|
|
241
|
+
const top = visible(title)
|
|
242
|
+
? `${tl}${h} ${title} ${h.repeat(Math.max(0, inner - visible(title) - 1))}${tr}`
|
|
243
|
+
: `${tl}${h.repeat(inner + 2)}${tr}`;
|
|
244
|
+
|
|
245
|
+
return [
|
|
246
|
+
lead + paint(top),
|
|
247
|
+
...lines.map((l) => `${lead}${paint(v)} ${pad(l, inner)} ${paint(v)}`),
|
|
248
|
+
`${lead}${paint(`${bl}${h.repeat(inner + 2)}${br}`)}`,
|
|
249
|
+
].join("\n");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/* --------------------------------------------------------------- charts */
|
|
253
|
+
|
|
254
|
+
const SPARK_TICKS = "▁▂▃▄▅▆▇█";
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Render a series as one line of block characters.
|
|
258
|
+
*
|
|
259
|
+
* Lifted out of crypto.mjs, where it was the only chart in the codebase and
|
|
260
|
+
* private to price history. Nothing about it is about prices.
|
|
261
|
+
*/
|
|
262
|
+
export function sparkline(points) {
|
|
263
|
+
const values = (Array.isArray(points) ? points : []).map(Number).filter(Number.isFinite);
|
|
264
|
+
if (!values.length) return "";
|
|
265
|
+
const min = Math.min(...values);
|
|
266
|
+
const max = Math.max(...values);
|
|
267
|
+
// A flat series has no range to scale into; drawing it at the floor would
|
|
268
|
+
// imply a crash, so it sits mid-band instead.
|
|
269
|
+
if (max === min) return SPARK_TICKS[3].repeat(values.length);
|
|
270
|
+
return values
|
|
271
|
+
.map((v) => SPARK_TICKS[Math.min(SPARK_TICKS.length - 1, Math.floor(((v - min) / (max - min)) * SPARK_TICKS.length))])
|
|
272
|
+
.join("");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* A horizontal meter: `████████░░░░ 62%`.
|
|
277
|
+
*
|
|
278
|
+
* Clamped at both ends because the inputs are real — a download that reports
|
|
279
|
+
* more bytes than its own content-length, a quota already overspent — and a bar
|
|
280
|
+
* that renders past its track corrupts whatever is drawn to the right of it.
|
|
281
|
+
*/
|
|
282
|
+
export function gauge(value, { max = 1, width = 20, label = true, paint = acid } = {}) {
|
|
283
|
+
const ratio = max > 0 && Number.isFinite(value / max) ? Math.min(1, Math.max(0, value / max)) : 0;
|
|
284
|
+
const filled = Math.round(ratio * width);
|
|
285
|
+
const bar = paint("█".repeat(filled)) + dim("░".repeat(Math.max(0, width - filled)));
|
|
286
|
+
return label ? `${bar} ${ash(`${Math.round(ratio * 100)}%`)}` : bar;
|
|
287
|
+
}
|