moshcode 0.50.0 → 0.51.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 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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moshcode",
3
- "version": "0.50.0",
3
+ "version": "0.51.0",
4
4
  "type": "module",
5
5
  "description": "moshcode — a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript",
6
6
  "repository": {
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/commands.mjs CHANGED
@@ -350,6 +350,7 @@ const COMMANDS = [
350
350
  cliVerb("doctl", "drive the DigitalOcean CLI (droplets, apps, databases)"),
351
351
  cliVerb("turso", "drive the Turso CLI (auth, databases, replicas)"),
352
352
  cliVerb("tailscale", "drive the Tailscale CLI (mesh VPN: up, status, ssh, serve)"),
353
+ cliVerb("coral", "drive the Coral CLI (SQL over APIs, databases, and internal systems)"),
353
354
  cliVerb("alpaca", "drive the native Alpaca trading CLI"),
354
355
  cliVerb("trade", "look up tickers, inspect markets, and preview/place Alpaca orders"),
355
356
  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
- const ANSI = /\x1b\[[0-9;]*m/g;
75
- export const strip = (s) => String(s).replace(ANSI, "");
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
- const w = (key, min) => Math.max(min, ...rows.map((r) => String(r[key] ?? "").length));
82
- const nameW = w("name", 4);
83
- const engineW = w("engine", 6);
84
- return rows.map((r) => [
85
- indent,
86
- bone(r.name.padEnd(nameW)),
87
- " ",
88
- ash(String(r.engine).padEnd(engineW)),
89
- " ",
90
- paintState(r.state).padEnd(9 + (paintState(r.state).length - r.state.length)),
91
- " ",
92
- ash(tilde(r.cwd || "").padEnd(24)),
93
- " ",
94
- dim(humanAge(r.age)),
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. */
@@ -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
- items.push({ ...item, link, feed: result.feed.name, feedTitle: result.parsed.title || result.feed.title });
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
- return `${Math.round(days / 30)}mo ago`;
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
- /** The headline list. */
966
- export function renderHeadlines(items, { failures = [], columns, limit = DEFAULT_LIMIT, now = Date.now(), source = "" } = {}) {
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 tails = shown.map((item) => {
979
- const when = ago(item.date, now);
980
- return `${item.feed || ""}${when ? ` · ${when}` : ""}`.trim();
981
- });
982
- // One column width for every tail, not one per row: the feed name and the age
983
- // are what make a headline placeable, so they keep their room and the title
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
- lines.push(` ${acid(n)} ${bone(clip(item.title, room).padEnd(room))} ${ash(tails[i].padStart(tailWidth))}`);
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
- /** Printable width, ignoring the SGR sequences ui.mjs wraps text in. */
49
- export function visibleWidth(text) {
50
- return String(text ?? "").replace(/\x1b\[[0-9;]*m/g, "").length;
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) counts.set(item.feed, (counts.get(item.feed) || 0) + 1);
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.feed === state.filter) : state.items;
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
- const title = ` ${bone("moshcode rss")}${ash(` ${items.length} headline${items.length === 1 ? "" : "s"} · ${where}`)}`;
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 - visibleWidth(status) - 2) + status + " ");
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 visibleWidth(prompt + help) <= width ? prompt + help : prompt;
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 (visibleWidth(next) > width) break;
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.feed}${when ? ` · ${when}` : ""}`.trim();
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/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, and moshcode only conducts their native command lines.
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/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
+ }