@thematica/prism-tui 0.0.0-stage → 0.1.0-beta.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.
Files changed (42) hide show
  1. package/README.md +32 -2
  2. package/dist/packages/prism-tui/src/account.d.ts +4 -0
  3. package/dist/packages/prism-tui/src/account.d.ts.map +1 -0
  4. package/dist/packages/prism-tui/src/account.js +25 -0
  5. package/dist/packages/prism-tui/src/account.js.map +1 -0
  6. package/dist/packages/prism-tui/src/cli.d.ts +3 -0
  7. package/dist/packages/prism-tui/src/cli.d.ts.map +1 -0
  8. package/dist/packages/prism-tui/src/cli.js +117 -0
  9. package/dist/packages/prism-tui/src/cli.js.map +1 -0
  10. package/dist/packages/prism-tui/src/credentials.d.ts +11 -0
  11. package/dist/packages/prism-tui/src/credentials.d.ts.map +1 -0
  12. package/dist/packages/prism-tui/src/credentials.js +53 -0
  13. package/dist/packages/prism-tui/src/credentials.js.map +1 -0
  14. package/dist/packages/prism-tui/src/device-login.d.ts +14 -0
  15. package/dist/packages/prism-tui/src/device-login.d.ts.map +1 -0
  16. package/dist/packages/prism-tui/src/device-login.js +78 -0
  17. package/dist/packages/prism-tui/src/device-login.js.map +1 -0
  18. package/dist/src/cli/answer.d.ts +219 -0
  19. package/dist/src/cli/answer.d.ts.map +1 -0
  20. package/dist/src/cli/answer.js +201 -0
  21. package/dist/src/cli/answer.js.map +1 -0
  22. package/dist/src/cli/app/App.d.ts +127 -0
  23. package/dist/src/cli/app/App.d.ts.map +1 -0
  24. package/dist/src/cli/app/App.js +968 -0
  25. package/dist/src/cli/app/App.js.map +1 -0
  26. package/dist/src/cli/app/api.d.ts +130 -0
  27. package/dist/src/cli/app/api.d.ts.map +1 -0
  28. package/dist/src/cli/app/api.js +80 -0
  29. package/dist/src/cli/app/api.js.map +1 -0
  30. package/dist/src/cli/app/run-app.d.ts +9 -0
  31. package/dist/src/cli/app/run-app.d.ts.map +1 -0
  32. package/dist/src/cli/app/run-app.js +11 -0
  33. package/dist/src/cli/app/run-app.js.map +1 -0
  34. package/dist/src/facts/fresh.d.ts +15 -0
  35. package/dist/src/facts/fresh.d.ts.map +1 -0
  36. package/dist/src/facts/fresh.js +22 -0
  37. package/dist/src/facts/fresh.js.map +1 -0
  38. package/dist/src/market/amounts.d.ts +208 -0
  39. package/dist/src/market/amounts.d.ts.map +1 -0
  40. package/dist/src/market/amounts.js +522 -0
  41. package/dist/src/market/amounts.js.map +1 -0
  42. package/package.json +15 -4
@@ -0,0 +1,968 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
3
+ import TextInput from "ink-text-input";
4
+ import { useEffect, useState } from "react";
5
+ import { amountBesideQuote, amountSize, amountText, bandsOf, bracketedAmount, convertedAt, displayName, duration, figureAmount, filtersText, listFigure, oneLine, periodPhrase, progressLine, rateClause, REPORTS, SHEET_MONTHS, shortAmount, usdMoney } from "../answer.js";
6
+ import { ApiError, curlFor, isAnswer, requestFor } from "./api.js";
7
+ export const keyHint = (place) => (place === "Mac" ? "Make a key with: prism key create" : "Use prism login in the installed terminal app, or copy a Prism key from your Thematica profile.");
8
+ /** What stopped the start-up check, and what to do about it. */
9
+ export function blockedFor(error, place, url) {
10
+ if (error instanceof ApiError) {
11
+ if (error.status === 401)
12
+ return { message: `Not authorized: the ${place} refused this key.`, hint: keyHint(place) };
13
+ if (error.status === 0)
14
+ return { message: `Can't reach Prism at ${url}.`, hint: place === "Mac" ? "Start the Mac's API with: pnpm dev" : "Check the connection; the cloud may also be paused by its spending cap." };
15
+ if (error.status === 404)
16
+ return { message: "This version of Prism has no /v1/account yet.", hint: "Deploy the new version first (ops/deploy.sh)." };
17
+ }
18
+ return { message: error.message, hint: "" };
19
+ }
20
+ const amount = (n) => (Number.isInteger(n) ? String(n) : n.toFixed(2));
21
+ export { oneLine, shortAmount };
22
+ const clip = (s, width) => (s.length <= width ? s : `${s.slice(0, Math.max(0, width - 1))}…`);
23
+ export function accountLine(place, account) {
24
+ if (account === null)
25
+ return `Prism · ${place} · checking the key…`;
26
+ if (account.wallet !== undefined) {
27
+ const balance = account.wallet.balance_usd;
28
+ return `Prism · ${place} · signed in · Thematica balance ${balance.startsWith("-") ? `-$${balance.slice(1)}` : `$${balance}`} · Prism usage this month $${account.wallet.prism_usage_this_month_usd}`;
29
+ }
30
+ if (account.limits.per_month_usd === null)
31
+ return `Prism · ${place} · signed in as ${account.account.name}`;
32
+ return `Prism · ${place} · signed in as ${account.account.name} · $${amount(account.limits.per_run_usd)} a run · $${account.spent_this_month_usd.toFixed(2)} of $${amount(account.limits.per_month_usd)} this month`;
33
+ }
34
+ /** One list as lines: every band, with its companies in order or "none" (Ben, 2026-10-20: an empty band still shows). */
35
+ export function itemsOf(answer, list) {
36
+ const report = REPORTS.find((r) => r.key === list);
37
+ const bands = bandsOf(answer, list);
38
+ const items = [];
39
+ for (const [band, label] of report?.bands ?? []) {
40
+ const rows = bands[band] ?? [];
41
+ items.push({ kind: "band", label: `${label} (${rows.length})` });
42
+ if (rows.length === 0)
43
+ items.push({ kind: "none" });
44
+ for (const row of rows)
45
+ items.push({ kind: "row", row });
46
+ }
47
+ return items;
48
+ }
49
+ export function rowText(row, list, width) {
50
+ const figure = list === "biggest" ? ` · ${listFigure(row)}` : "";
51
+ return clip(`${row.ticker.padEnd(6)} ${displayName(row.name)}${figure}${row.reason ? ` · ${oneLine(row.reason)}` : ""}`, width);
52
+ }
53
+ /**
54
+ * A quote as it reads on one line. A filing's table row comes as cells split by "|" or blank lines
55
+ * ("Consulting | $ | 35,106,786 | $ | 33,195,104"; Ben, 2026-10-20: "tables aren't showing up or it's
56
+ * broken"): its cells are joined by " · ", a currency sign kept with the number after it and a
57
+ * percent sign with the number before it. Prose keeps its words; its line breaks become spaces.
58
+ */
59
+ export function quoteText(raw) {
60
+ const text = raw ?? "";
61
+ const cells = text.split(/\s*\|\s*|\n\s*\n/).map(oneLine).filter((c) => c !== "");
62
+ const numeric = (c) => /^[(\-–]?(?:US\$|[$€£¥])?\s?[\d.,]+%?\)?$|^(?:US\$|[$€£¥%)])$/.test(c);
63
+ if (!text.includes("|") && !(cells.length >= 3 && cells.slice(1).every(numeric)))
64
+ return oneLine(text);
65
+ const joined = [];
66
+ for (const cell of cells) {
67
+ const last = joined.length - 1;
68
+ const prev = joined[last];
69
+ if (prev !== undefined && /^(?:US\$|[$€£¥])$/.test(prev))
70
+ joined[last] = `${prev}${cell}`;
71
+ else if (prev !== undefined && /^[%)]$/.test(cell))
72
+ joined[last] = `${prev}${cell}`;
73
+ else
74
+ joined.push(cell);
75
+ }
76
+ return joined.join(" · ");
77
+ }
78
+ /** A filing's form, in plain words. */
79
+ const FORMS = { "10-K": "Annual report (10-K)", "20-F": "Annual report (20-F)", "40-F": "Annual report (40-F)", "10-Q": "Quarterly report (10-Q)", "8-K": "Current report (8-K)", "6-K": "Report (6-K)" };
80
+ /** A numbered item is a different part of each form; Prism's own names for the notes it reads, in plain words. */
81
+ const ITEMS = {
82
+ "10-K": { "1": "Business description", "1A": "Risk factors", "2": "Properties", "3": "Legal proceedings", "7": "Management's discussion", "7A": "Market risk", "8": "Financial statements" },
83
+ "20-F": { "3": "Key information", "4": "Company description", "5": "Operating and financial review", "8": "Financial information", "18": "Financial statements" },
84
+ "10-Q": { "1": "Financial statements", "2": "Management's discussion" },
85
+ };
86
+ const NOTES = { segment_note: "Notes on business segments", revenue_note: "Notes on revenue", concentration_note: "Notes on major customers", aif: "Annual information form", mdna: "Management's discussion", opening: "Opening pages" };
87
+ /** A capital first letter, except where the second letter is one already: "mRNA-1215", "eBay" and "iPhone" stay as written. */
88
+ const capital = (s) => (/^[a-z][A-Z]/.test(s) ? s : `${s.charAt(0).toUpperCase()}${s.slice(1)}`);
89
+ /**
90
+ * Where a quote comes from, in plain words (Ben, 2026-10-20: "what's revenue note, item 4?"), and when
91
+ * its figure was true (2026-10-21): "Annual report (20-F) · Notes on revenue · for fiscal 2026 · filed
92
+ * 2026-06-15".
93
+ */
94
+ export function sourceText(q) {
95
+ const form = q.form ?? "";
96
+ const item = q.item ?? "";
97
+ const part = item === "" ? null : (NOTES[item] ?? ITEMS[form]?.[item.toUpperCase()] ?? (/^\d/.test(item) ? `Item ${item}` : capital(item.replace(/_/g, " "))));
98
+ return [FORMS[form] ?? (form === "" ? null : form), part, periodPhrase(q.period, "for"), q.filed_at === null ? null : `filed ${q.filed_at}`].filter((x) => x !== null && x !== "").join(" · ");
99
+ }
100
+ /** Words wrapped to a width: the first line starts with `first`, the others with `next`. */
101
+ function wrapWords(text, width, first, next) {
102
+ const out = [];
103
+ let line = first;
104
+ let started = false;
105
+ for (const word of text.split(" ").filter((w) => w !== "")) {
106
+ if (started && line.length + 1 + word.length > width) {
107
+ out.push(line);
108
+ line = next + word;
109
+ }
110
+ else
111
+ line = started ? `${line} ${word}` : line + word;
112
+ started = true;
113
+ }
114
+ out.push(line);
115
+ return out;
116
+ }
117
+ /** A line wrapped to a width, keeping its own indent. */
118
+ function wrap(line, width) {
119
+ const indent = /^\s*/.exec(line)?.[0] ?? "";
120
+ return wrapWords(line.trim(), width, indent, indent);
121
+ }
122
+ /** Wraps words to a width; a word longer than the width is cut, so a table's columns stay aligned. */
123
+ function cellLines(text, width) {
124
+ const words = text.split(" ").filter((w) => w !== "").flatMap((w) => (w.length <= width ? [w] : (w.match(new RegExp(`.{1,${Math.max(1, width)}}`, "g")) ?? [w])));
125
+ return wrapWords(words.join(" "), width, "", "");
126
+ }
127
+ /** A table: a header row, then each row with its cells wrapped inside their columns. */
128
+ function tableLines(headers, rows, widths) {
129
+ const pad = (s, w) => `${s}${" ".repeat(Math.max(0, w - s.length))}`;
130
+ const lines = [{ text: headers.map((h, i) => pad(h, widths[i] ?? 0)).join(" ").trimEnd(), style: "header" }];
131
+ rows.forEach((row, r) => {
132
+ const cells = row.map((c, i) => cellLines(c, widths[i] ?? 10));
133
+ const height = Math.max(...cells.map((c) => c.length));
134
+ if (r > 0 && height > 1)
135
+ lines.push({ text: "" });
136
+ for (let k = 0; k < height; k++)
137
+ lines.push({ text: cells.map((c, i) => pad(c[k] ?? "", widths[i] ?? 0)).join(" ").trimEnd() });
138
+ });
139
+ return lines;
140
+ }
141
+ /** Text as a full sentence: a capital first letter and a full stop. */
142
+ const sentence = (text) => {
143
+ const s = oneLine(text);
144
+ return s === "" ? "" : `${capital(s)}${/[.!?]$/.test(s) ? "" : "."}`;
145
+ };
146
+ /** Text without its full stop, unless the stop ends an abbreviation ("S.A.", "L.P.", "Inc."). */
147
+ const withoutStop = (text) => {
148
+ const s = oneLine(text);
149
+ return /(?:\b[A-Za-z]\.){2,}$|\b(?:Inc|Ltd|Corp|Co|Jr|Sr|Bros|No|Ph\.D)\.$/.test(s) ? s : s.replace(/[\s.;,:]+$/, "");
150
+ };
151
+ /** Each name once, in its first place, whatever its case: VIPS's suppliers named "brand partners" twice. */
152
+ const unique = (names) => names.filter((n, i) => names.findIndex((m) => m.toLowerCase() === n.toLowerCase()) === i);
153
+ function listOf(items, max) {
154
+ const parts = items.length > max ? [...items.slice(0, max), `${items.length - max} more`] : [...items];
155
+ return parts.length <= 1 ? (parts[0] ?? "") : `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
156
+ }
157
+ // The marks and codes, the scale words and the currency names a fact-sheet writer names a table's unit with.
158
+ const UNIT_MARK = String.raw `(?:US\$|U\.S\.\s?\$|[A-Z]{1,3}\$|\$|€|£|¥|RMB|Ps\.?|[A-Z]{3})`;
159
+ const UNIT_WORD = String.raw `(?:thousands?|millions?|billions?|trillions?)`;
160
+ const UNIT_OF = String.raw `(?:\s+of\s+(?:[A-Za-z.]+\s+)?(?:[Dd]ollars|[Pp]esos|[Rr]eais|[Ee]uros|[Yy]uan|[Yy]en|[Rr]enminbi|RMB|[Ss]oles|[Rr]upees|[Ff]rancs|[Pp]ounds|[Ww]on|[Kk]ron(?:er|or|a|e)|[A-Z]{3}))?`;
161
+ const UNIT_IN_BRACKETS = new RegExp(String.raw `\s*\((?:${UNIT_MARK}\s+)?in\s+(?:${UNIT_MARK}\s?)?${UNIT_WORD}\)`, "g");
162
+ /**
163
+ * Clauses in which a fact-sheet writer describes the filing's table, not the business (the terminal
164
+ * test of 2026-10-23): "in thousands", "in RMB thousands", "in millions of US dollars", "as the table
165
+ * reports it", "the amount is the row's last figure", "age as shown in the committee table". The
166
+ * amount says its own scale, so no sentence shows them.
167
+ */
168
+ const WRITER_NOTES = [
169
+ new RegExp(String.raw `,?\s*(?:[Rr]eported\s+)?\b[Ii]n\s+(?:${UNIT_MARK}\s?)?${UNIT_WORD}${UNIT_OF}(?=\s*(?:[,.;)]|$)|\s+(?:for|at|as|in|during|of the)\b)`, "g"),
170
+ // After a comma, any unit the table counts in: ", in millions of adjusted scripts", ", in thousands of metric tons".
171
+ new RegExp(String.raw `,\s*(?:reported\s+)?[Ii]n\s+${UNIT_WORD}\s+of\s+[^,.;()]+?(?=\s*(?:[,.;)]|$))`, "g"),
172
+ // In brackets, in a name or a detail: "Customer assets (US$ in billions)", "DARTs (in thousands)".
173
+ UNIT_IN_BRACKETS,
174
+ /[,;]?\s*as (?:the|its) [\w\s-]*?table (?:reports|shows|gives|states|lists) (?:it|them)\b/g,
175
+ // Before "as shown in the … table", so the age goes with it.
176
+ /[,;]?\s*age as shown in the [\w\s-]*?table\b/g,
177
+ /[,;]?\s*as (?:stated|shown|reported|given|listed) in the [\w\s-]*?table\b/g,
178
+ /[,;]?\s*the (?:amount|figure|number|value) is the row's \w+ (?:figure|number|amount|value|column)\b/g,
179
+ /[,;]?\s*by (?:quarter|year); this is the (?:latest|last|most recent) (?:quarter|year)\b/g,
180
+ /^the row (?:shows|gives)\s+/i,
181
+ ];
182
+ /** A fact's detail without the writer's notes on the table it was read from: "" when nothing else is left. */
183
+ export function writerNotesOut(detail) {
184
+ let s = oneLine(detail);
185
+ for (const note of WRITER_NOTES)
186
+ s = s.replace(note, "");
187
+ // What a cut leaves: a space before a stop, a comma before a stop, a stop after an abbreviation's ("the U.S..").
188
+ s = s.replace(/\s+([,.;])(?=\s|$)/g, "$1").replace(/([,;])\s*([,.;])/g, "$2").replace(/(?<!\.)\.\.(?!\.)/g, ".").replace(/^[\s,;.]+/, "").trim();
189
+ return /[\p{L}\p{N}]/u.test(s) ? s : "";
190
+ }
191
+ /** A fact's name as sentences show it: less a footnote mark stuck to a word and a note on the table's unit ("Customer assets(4) (US$ in billions)" reads "Customer assets"). */
192
+ const nameOut = (name) => oneLine(name.replace(/(?<=[\p{L}\p{N}.)])\(\d{1,2}\)/gu, "").replace(UNIT_IN_BRACKETS, ""));
193
+ /**
194
+ * Whether a text already gives a figure: the digits it is written with ("3,272.2" for "R$3,272.2
195
+ * million", "10.3" for "10.3%") stand there as a number of their own, not inside another ("5%" is not
196
+ * in "2025"), or, for a figure with no digits, its words.
197
+ */
198
+ function gives(text, figure) {
199
+ if (figure === "")
200
+ return false;
201
+ const digits = /\d[\d,.]*\d|\d/.exec(figure)?.[0];
202
+ if (digits === undefined)
203
+ return text.toLowerCase().includes(figure.toLowerCase());
204
+ return new RegExp(String.raw `(?<!\d)(?<!\d[.,])${digits.replace(/\./g, "\\.")}(?!\d|[.,]\d)`).test(text);
205
+ }
206
+ /** Words in a company's name that do not tell it from others: "Intercorp Financial Services Inc." is told by "Intercorp". */
207
+ const COMMON_NAME_WORDS = new Set(["corp", "corporation", "incorporated", "limited", "company", "group", "holdings", "holding", "international", "global", "financial", "services", "technologies", "technology", "systems", "industries", "partners", "brands", "enterprises"]);
208
+ /** Words that name the company itself after a title: "of the Company", "of the Board". */
209
+ const OWN_WORDS = new Set(["company", "companies", "group", "bank", "corporation", "board", "firm", "holding", "partnership", "trust", "fund", "directors"]);
210
+ /** Words after a comma that go on with a title or a role, not another company's name: "Chief Executive Officer, President and Director". */
211
+ const TITLE_WORD = /^(?:president|director|directors|chair|chairman|chairwoman|chairperson|founder|co-?founder|secretary|treasurer|chief|member|executive|board|officer|C[A-Z]O|independent|lead|senior|vice|general|interim|acting|co-chief|non-executive|managing)$/i;
212
+ /** An organization's name as a title gives it: capitalized words, with "and", "&" or "de" between them ("State Street Investment Management"). */
213
+ const ORGANIZATION = String.raw `[\p{Lu}\p{N}][\p{L}\p{N}&'’./-]*(?:\s+(?:[\p{Lu}\p{N}&][\p{L}\p{N}&'’./-]*|and|de|du|der|di))*`;
214
+ /** Legal and short words in an organization's name that say nothing of whose it is: "Inc.", "S.A.", "A/S". */
215
+ const LEGAL_WORDS = new Set(["inc", "corp", "corporation", "ltd", "limited", "llc", "plc", "company", "holdings", "holding", "group", "and", "de", "du", "der", "di"]);
216
+ /**
217
+ * Whether the organization a title names after it is the company itself: the first one named, "of
218
+ * IFS", "of the Board", "at Interbank", or after a comma, "Chief Executive Officer, Pearson". It is
219
+ * the company's own when it is named by the company's ticker or name, every other word legal or
220
+ * common ("of Automatic Data Processing, Inc."), not a unit of it ("State Street Investment
221
+ * Management", "Amazon Web Services"); none named is the company's own.
222
+ */
223
+ function ownOrganization(after, company) {
224
+ const scope = after.split(";")[0] ?? "";
225
+ const named = [];
226
+ const of = new RegExp(String.raw `\b(?:of|at)\s+(?:the\s+)?(${ORGANIZATION})`, "u").exec(scope);
227
+ if (of !== null)
228
+ named.push({ at: of.index, name: of[1] ?? "" });
229
+ for (const m of scope.matchAll(new RegExp(String.raw `,\s*(${ORGANIZATION})`, "gu"))) {
230
+ if (TITLE_WORD.test((m[1] ?? "").split(/\s+/)[0] ?? ""))
231
+ continue;
232
+ named.push({ at: m.index ?? 0, name: m[1] ?? "" });
233
+ break;
234
+ }
235
+ const first = named.sort((a, b) => a.at - b.at)[0];
236
+ if (first === undefined)
237
+ return true;
238
+ const words = first.name.toLowerCase().split(/\s+/).map((w) => w.replace(/[^\p{L}\p{N}&]+/gu, "")).filter((w) => w !== "");
239
+ if (OWN_WORDS.has(words[0] ?? ""))
240
+ return true;
241
+ if (company === undefined)
242
+ return false;
243
+ const own = new Set([company.ticker.toLowerCase(), ...(displayName(company.name).toLowerCase().match(/[\p{L}\p{N}&]{3,}/gu) ?? []).filter((w) => !COMMON_NAME_WORDS.has(w) && !LEGAL_WORDS.has(w))]);
244
+ return words.some((w) => own.has(w)) && words.every((w) => own.has(w) || LEGAL_WORDS.has(w) || w.length < 3);
245
+ }
246
+ // A title no longer held, or not this one: "Former Chief Revenue Officer", "President and former CEO",
247
+ // "Served as President during fiscal year 2025", "after more than two decades as CEO", "a non-CEO NEO",
248
+ // "Deputy Chief Executive Officer", "Vice Chairman"; and after it, "until his retirement", "through February 10, 2026".
249
+ const PAST_BEFORE = /\b(?:former|formerly|previously|ex|retired|leave|leaving|left)\b|^served as\b|\b(?:decades?|years?)\s+as\s+$/i;
250
+ const NOT_THIS_TITLE = /\b(?:deputy|vice|non|assistant|associate)[\s-]*$/i;
251
+ const PAST_AFTER = /\b(?:until (?:his|her|their) (?:retirement|departure)|retire[ds]?|retirement|ceased)\b|\b(?:until|through)\s+(?:[A-Z][a-z]+\s+(?:\d{1,2},\s+)?)?\d{4}\b/i;
252
+ /**
253
+ * Where a person comes in the executives' sentence (the terminal test of 2026-10-23: "the chief
254
+ * executive first"): 0 the company's chief executive; 1 the chair of its board; 2 its president,
255
+ * finance chief or operating chief; 3 its other chief officers; 4 its other officers (vice presidents,
256
+ * general counsel, a founder); 5 an officer of another company or of a unit ("Chief Executive Officer
257
+ * of Inteligo", "CEO, Amazon Web Services"); 6 everyone else, the directors among them. A title held
258
+ * no longer, a deputy's, a vice chair's or a committee's does not count.
259
+ */
260
+ function tierOf(role, company) {
261
+ const clauses = role.split(/\s*;\s*/);
262
+ // A title a clause holds now, at the company itself.
263
+ const holds = (clause, title) => {
264
+ const m = title.exec(clause);
265
+ if (m === null)
266
+ return false;
267
+ const before = clause.slice(0, m.index);
268
+ const after = clause.slice(m.index + m[0].length);
269
+ if (PAST_BEFORE.test(before) || NOT_THIS_TITLE.test(before) || PAST_AFTER.test(after.split(",")[0] ?? ""))
270
+ return false;
271
+ return ownOrganization(after, company);
272
+ };
273
+ const any = (title) => clauses.some((c) => holds(c, title));
274
+ if (any(/\b(?:co-?)?(?:chief executive(?: officer)?|C\.?E\.?O)\b/i))
275
+ return 0;
276
+ // A chair is the board's: a committee's chair never, and a bare "chair" not when the role names a committee.
277
+ if (clauses.some((c) => !/\bcommittee\b/i.test(c) && (/\bboard\b/i.test(c) || !/\bcommittee\b/i.test(role)) && holds(c, /\b(?:executive\s+|non-executive\s+)?chair(?:man|woman|person)?\b/i)))
278
+ return 1;
279
+ if (any(/\b(?:president|chief (?:financial|operating) officer|C[FO]O)\b/i))
280
+ return 2;
281
+ if (any(/\bchief\b[\w\s,&-]*?\bofficer\b|\bC[A-Z]O\b/i))
282
+ return 3;
283
+ if (any(/\b(?:vice[\s-]president|general counsel|secretary|treasurer|controller|comptroller|managing director|co-?founder|founder|officer)\b/i))
284
+ return 4;
285
+ return /\b(?:chief|president|officer|treasurer|secretary|general counsel|controller|founder)\b/i.test(role) ? 5 : 6;
286
+ }
287
+ /** Whether a detail already names its fact: half or more of the name's words are in it ("take rate charged by PicPay" in "PicPay charges a take rate …"). */
288
+ function namedIn(name, detail) {
289
+ const words = (name.toLowerCase().match(/[\p{L}\p{N}]{3,}/gu) ?? []);
290
+ const said = new Set(detail.toLowerCase().match(/[\p{L}\p{N}]{3,}/gu) ?? []);
291
+ return words.length === 0 || words.filter((w) => said.has(w)).length * 2 >= words.length;
292
+ }
293
+ /**
294
+ * A person's role as their fact says it: its first part, and any later part that holds a title ("…;
295
+ * non-executive Chairman of the Board since August 2017"), less earlier jobs ("previously …",
296
+ * "earlier …") and past roles ("… from December 2016 to June 2023").
297
+ */
298
+ function roleOf(detail) {
299
+ const current = writerNotesOut(detail).replace(/[;,]?\s+(?:and\s+)?(?:has\s+)?(?:previously|formerly|earlier)\b.*$/i, "");
300
+ const past = /\b(?:former|retired)\b|\b(?:from\s+)?(?:[A-Z][a-z]+\s+)?\d{4}\s+(?:to|through|until)\s+(?:[A-Z][a-z]+\s+)?\d{4}\b/;
301
+ const title = /\b(?:chair|chairman|chairwoman|chairperson|chief|president|officer|director|founder|secretary|treasurer|counsel|controller|C[A-Z]O)\b/i;
302
+ const clauses = current.split(/\s*;\s*/).filter((c, i) => i === 0 || (title.test(c) && !past.test(c)));
303
+ return withoutStop(clauses.join("; "));
304
+ }
305
+ /**
306
+ * A person's or holder's name less its footnote marks, a "Mr." or "Ms." and its full stop, in normal
307
+ * case when it came in capitals: "Vinci Capital Partners III(6)" reads "Vinci Capital Partners III",
308
+ * "THE VANGUARD GROUP" "The Vanguard Group". A one-word name in capitals ("JERA", "KKR") stays.
309
+ */
310
+ const personName = (name) => {
311
+ const n = withoutStop(oneLine(name).replace(/\s*(?:\(\d{1,2}\)|\*+)+$/, "").replace(/^(?:Mr|Ms|Mrs|Miss)\.?\s+/, ""));
312
+ return n.includes(" ") ? displayName(n) : n;
313
+ };
314
+ /** One key for the same person or holder written twice: "Marcelo Kopel" and "Marcelo Kopel", "Mr. Robert A. Bradway" and "Robert A. Bradway", in any case or spacing, with or without "Dr." or "Ph.D.". */
315
+ const personKey = (name) => personName(name)
316
+ .toLowerCase()
317
+ .replace(/\s*(?:\([^)]*\)|“[^”]*”|"[^"]*")/g, "")
318
+ .replace(/^(?:dr|prof|sir|dame)\.?\s+/, "")
319
+ .replace(/(?:,?\s+(?:ph\.?\s?d|m\.?d|j\.?d|mba|cpa|esq|d\.?phil)\.?)+$/, "")
320
+ .replace(/[^\p{L}\p{N}]+/gu, " ")
321
+ .trim();
322
+ /** An age as the people facts write it ("30 years") reads "age 30"; any other amount as written. */
323
+ const ageText = (amount) => {
324
+ const years = /^(\d{1,3})\s*(?:years?(?:\s+old)?)?$/i.exec(amount.trim())?.[1];
325
+ return years !== undefined ? `age ${years}` : amount;
326
+ };
327
+ /** A holder that is a table's total, not an owner: "Other", "Subtotal", "All directors and executive officers as a group". */
328
+ const NOT_AN_OWNER = /^(?:others?|all others?|sub-?totals?|totals?|public(?:\s+float)?|free float|treasury(?:\s+(?:shares|stock))?)$|\bas a group\b/i;
329
+ /** A share's number, to rank owners by: 14.63 for "14.63%", and for "7.90", a table's figure whose header gave the percent sign. */
330
+ const shareSize = (share) => Number(/(\d+(?:\.\d+)?)\s*(?:%|percent\b)/.exec(share)?.[1] ?? /^\s*(\d+(?:\.\d+)?)\s*$/.exec(share)?.[1] ?? Number.NaN);
331
+ /** A share as sentences show it: as written, with its percent sign when the table's header had it ("7.90" reads "7.90%"). */
332
+ const shareText = (share) => (/^\d+(?:\.\d+)?$/.test(share.trim()) ? `${share.trim()}%` : share);
333
+ /** The sentences show at most this many facts of each kind; the rest are counted at the end (the terminal test of 2026-10-23: "each capped"). */
334
+ const SHOWN = { key_figure: 4, pipeline: 3, pricing: 3, strategic_move: 3, setback: 3, other: 4 };
335
+ /** Each capped kind as the closing count names it, one and more: "1 more recent move", "2 more ways it charges". */
336
+ const MORE = {
337
+ key_figure: ["figure", "figures"],
338
+ pipeline: ["product in development", "products in development"],
339
+ pricing: ["way it charges", "ways it charges"],
340
+ strategic_move: ["recent move", "recent moves"],
341
+ setback: ["setback", "setbacks"],
342
+ other: ["fact", "facts"],
343
+ };
344
+ /** The executives' and the owners' sentences name at most this many, then "and N more". */
345
+ const PEOPLE_SHOWN = 4;
346
+ function valueOf(fact) {
347
+ if (typeof fact === "string")
348
+ return { name: "", share: "", amount: "", period: "", detail: oneLine(fact) };
349
+ const f = (fact ?? {});
350
+ const s = (k) => (typeof f[k] === "string" ? oneLine(f[k]) : "");
351
+ return { name: nameOut(s("name")), share: s("share"), amount: s("amount"), period: s("period"), detail: s("detail") || s("text"), ...(f.scale_confirmed === false ? { scaleConfirmed: false } : {}) };
352
+ }
353
+ /** A fact from the API with its amount's dollars, rate, currency and display. */
354
+ const factValue = (f) => ({ ...valueOf(f.fact), usd: f.amount_usd ?? null, fx: f.fx ?? null, ...(f.currency !== undefined ? { currency: f.currency } : {}), ...(f.display !== undefined ? { display: f.display } : {}) });
355
+ /** A fact's amount as the API gives it. */
356
+ const amountOf = (v) => ({ text: v.amount, usd: v.usd ?? null, fx: v.fx ?? null, ...(v.currency !== undefined ? { currency: v.currency } : {}), ...(v.display !== undefined ? { display: v.display } : {}), ...(v.scaleConfirmed === false ? { scaleConfirmed: false } : {}) });
357
+ /**
358
+ * The business in sentences, built from the fact sheet's fields, one kind of fact per sentence and
359
+ * no kind named (Ben, 2026-10-20: "just show the fact"). Every number is a quoted one, written
360
+ * short with its currency (Ben, 2026-10-23: "US$303.2B in lists"); `f` shows each fact with its
361
+ * quote. An amount in another currency reads in US dollars with the original in brackets, and the
362
+ * sentence names the Federal Reserve's rate once (Ben, 2026-10-22).
363
+ *
364
+ * Since 2026-10-24 (the terminal test of 2026-10-23): every sentence carries its fact's amount or share
365
+ * ("Market share for us was 10.3% in 2025", not the detail alone); a detail's notes on the filing's
366
+ * table ("in RMB thousands") are left out; and the light pass's kinds read as facts, not bare details:
367
+ * one sentence names the executives with their ages and roles, the chief executive first and the same
368
+ * person once, another the largest owners with their shares, and short sentences tell how the company
369
+ * charges, what it is developing and its setbacks with their amounts, a few of each, the rest counted
370
+ * at the end. `company` tells the company's own chief executive from a subsidiary's.
371
+ */
372
+ export function businessSentences(facts, company) {
373
+ const by = (kind) => facts.filter((f) => f.kind === kind).map((f) => ({ ...factValue(f), filed: f.quote.filed_at ?? "" }));
374
+ // When a figure was true (Ben, 2026-10-21); a figure with no period was true when its report was filed.
375
+ const when = (v) => (v?.period ? ` ${periodPhrase(v.period)}` : v?.filed ? ` (annual report filed ${v.filed})` : "");
376
+ const amount = (v) => amountText(amountOf(v));
377
+ // Inside a name's brackets the original follows the dollars with "or", so brackets never nest.
378
+ const bracketed = (v) => bracketedAmount(amountOf(v));
379
+ const rate = (vs) => rateClause(vs.map((v) => (v === undefined ? null : convertedAt(v.usd, v.fx))));
380
+ // A converted amount sorts by its dollars, so amounts in two currencies sort right; any other amount
381
+ // by its number as read, as before 2026-10-23. A negative amount (a loss, "US$(1,404) million")
382
+ // never "brought in" anything, so it stays out of these sentences.
383
+ const size = (v) => amountSize(amountOf(v));
384
+ const big = (vs) => vs
385
+ .filter((v) => {
386
+ const s = size(v);
387
+ return v.amount !== "" && s !== null && s >= 0;
388
+ })
389
+ .sort((a, b) => (size(b) ?? 0) - (size(a) ?? 0));
390
+ // A name in a list keeps its share: "Brazil (71%)".
391
+ const named = (v) => (v.share ? `${v.name} (${v.share})` : v.name);
392
+ // A fact's figure: its amount, its share in brackets after it when it has both.
393
+ const figure = (v) => (v.amount ? `${amount(v)}${v.share ? ` (${v.share})` : ""}` : v.share);
394
+ // The figure's parts a text does not give yet, the amount with its rate.
395
+ const missing = (text, v) => {
396
+ const parts = [v.amount && !gives(text, v.amount) ? amount(v) : "", v.share && !gives(text, v.share) ? v.share : ""].filter((p) => p !== "");
397
+ return parts.length === 0 ? "" : `${parts.join(" and ")}${when(v)}${v.amount && !gives(text, v.amount) ? rate([v]) : ""}`;
398
+ };
399
+ // A fact told by its detail, less the writer's notes, or by its name when nothing is left; the
400
+ // figure follows when the detail does not give it ("Total amount received from the disposal: US$597M …").
401
+ const told = (v, detail = writerNotesOut(v.detail)) => {
402
+ const base = withoutStop(detail) || withoutStop(v.name);
403
+ if (base === "")
404
+ return "";
405
+ const more = missing(base, v);
406
+ return more === "" ? sentence(base) : `${capital(base)}: ${more}.`;
407
+ };
408
+ const out = [];
409
+ const left = [];
410
+ const capped = (kind, vs, say) => {
411
+ for (const v of vs.slice(0, SHOWN[kind]))
412
+ out.push(say(v));
413
+ const n = vs.length - SHOWN[kind];
414
+ if (n > 0)
415
+ left.push(`${n} more ${MORE[kind][n === 1 ? 0 : 1]}`);
416
+ };
417
+ for (const v of by("revenue_total"))
418
+ if (v.amount)
419
+ out.push(`Its revenue was ${amount(v)}${when(v)}${rate([v])}.`);
420
+ for (const v of by("concentration"))
421
+ out.push(v.share && v.name ? `${capital(v.name)} made up ${v.share} of revenue${when(v)}.` : told(v));
422
+ const sold = by("what_it_sells");
423
+ const soldBig = big(sold);
424
+ if (soldBig.length > 0) {
425
+ const [first, ...rest] = soldBig;
426
+ out.push(`${capital(listOf([`${first.name} brought in ${amount(first)}`, ...rest.map((v) => `${v.name} ${amount(v)}`)], 4))}${when(first)}${rate(soldBig.slice(0, 4))}.`);
427
+ }
428
+ const soldNames = unique(sold.filter((v) => !soldBig.includes(v) && v.name !== "").map(named));
429
+ if (soldNames.length > 0)
430
+ out.push(`It sells ${listOf(soldNames, 5)}.`);
431
+ // Beside the places and segments that brought in an amount, those with only a share still show it ("Brazil (52.6%)").
432
+ const geography = by("geography");
433
+ const regions = big(geography);
434
+ if (regions.length > 0) {
435
+ const [first, ...rest] = regions;
436
+ out.push(`${capital(listOf([`${first.name} brought in ${amount(first)}`, ...rest.map((v) => `${v.name} ${amount(v)}`)], 5))}${when(first)}${rate(regions.slice(0, 5))}.`);
437
+ }
438
+ const places = unique(geography.filter((v) => !regions.includes(v) && v.name !== "" && (regions.length === 0 || v.share !== "")).map(named));
439
+ if (places.length > 0)
440
+ out.push(`It ${regions.length > 0 ? "also sells" : "sells"} in ${listOf(places, 5)}.`);
441
+ const segments = by("segment");
442
+ const segBig = big(segments);
443
+ if (segBig.length > 0)
444
+ out.push(`Its largest segments were ${listOf(segBig.map((v) => `${v.name} (${bracketed(v)})`), 3)}${when(segBig[0])}${rate(segBig.slice(0, 3))}.`);
445
+ const otherSegments = unique(segments.filter((v) => !segBig.includes(v) && v.name !== "" && (segBig.length === 0 || v.share !== "")).map(named));
446
+ if (otherSegments.length > 0)
447
+ out.push(`Its ${segBig.length > 0 ? "other segments" : "segments"} are ${listOf(otherSegments, 5)}.`);
448
+ const customers = by("customer");
449
+ const shares = customers.filter((c) => c.share !== "" && c.name !== "");
450
+ for (const v of shares.slice(0, 3))
451
+ out.push(`${capital(v.name)} made up ${v.share} of revenue${when(v)}.`);
452
+ const customerNames = unique(customers.filter((c) => !shares.slice(0, 3).includes(c) && c.name !== "").map((c) => (c.amount && !c.share ? `${c.name} (${bracketed(c)})` : named(c))));
453
+ if (customerNames.length > 0)
454
+ out.push(`Its customers include ${listOf(customerNames, 4)}.`);
455
+ const suppliers = unique(by("supplier").filter((v) => v.name !== "").map((v) => (v.amount && !v.share ? `${v.name} (${bracketed(v)})` : named(v))));
456
+ if (suppliers.length > 0)
457
+ out.push(`Its suppliers include ${listOf(suppliers, 4)}.`);
458
+ // A detail that does not name its fact follows the fact's name, when the name is short: "Risto-cel:
459
+ // Treatment of sickle cell disease, in a Phase 1/2 trial", "Prepayment financing: Card transactions are settled early …".
460
+ const withName = (v) => {
461
+ const detail = writerNotesOut(v.detail);
462
+ const name = withoutStop(v.name);
463
+ if (name === "" || name.length > 60 || detail === "" || namedIn(name, detail))
464
+ return told(v, detail);
465
+ // The figure follows the name, and the detail its own sentence: "Regular yearly subscription rate: US$895 (…). Applies to …".
466
+ const more = missing(`${name} ${detail}`, v);
467
+ return more === "" ? sentence(`${name}: ${withoutStop(detail)}`) : `${capital(name)}: ${more}. ${sentence(detail)}`;
468
+ };
469
+ // What it is developing, and how it charges (those with a detail first).
470
+ capped("pipeline", by("pipeline"), withName);
471
+ const pricing = by("pricing");
472
+ capped("pricing", [...pricing.filter((v) => writerNotesOut(v.detail) !== ""), ...pricing.filter((v) => writerNotesOut(v.detail) === "")], withName);
473
+ capped("key_figure", by("key_figure"), (v) => (v.name && figure(v) ? `${capital(v.name)} was ${figure(v)}${when(v)}${v.amount ? rate([v]) : ""}.` : told(v)));
474
+ capped("strategic_move", by("strategic_move"), (v) => {
475
+ const detail = writerNotesOut(v.detail);
476
+ return /^[A-Z][a-z]+ed\b/.test(detail) ? told(v, `It ${detail.charAt(0).toLowerCase()}${detail.slice(1)}`) : told(v, detail);
477
+ });
478
+ // A setback whose detail does not give its amount is told by its name and the sum at stake, so the
479
+ // amount is never read as another number in the detail ("… with R$ 96.6 million covered by insurers").
480
+ capped("setback", by("setback"), (v) => {
481
+ const detail = writerNotesOut(v.detail);
482
+ if (v.name === "" || missing(detail, v) === "")
483
+ return told(v, detail);
484
+ const name = capital(personName(v.name));
485
+ const more = missing(name, v);
486
+ return more === "" ? `${name}${when(v)}.` : `${name}: ${more}.`;
487
+ });
488
+ // The executives and directors, each once with the most the sheet says of them, the chief executive first.
489
+ const people = new Map();
490
+ for (const v of by("executive")) {
491
+ const name = personName(v.name);
492
+ const key = personKey(name);
493
+ if (key === "")
494
+ continue;
495
+ const role = roleOf(v.detail);
496
+ const age = v.amount ? ageText(v.amount) : "";
497
+ const tier = tierOf(role, company);
498
+ const seen = people.get(key);
499
+ if (seen === undefined)
500
+ people.set(key, { name, age, role, tier, order: people.size });
501
+ else
502
+ people.set(key, { ...seen, age: seen.age || age, role: role.length > seen.role.length ? role : seen.role, tier: Math.min(seen.tier, tier) });
503
+ }
504
+ const leaders = [...people.values()].sort((a, b) => a.tier - b.tier || a.order - b.order);
505
+ if (leaders.length > 0)
506
+ out.push(`Its executives and directors include ${listOf(leaders.map((p) => ([p.age, p.role].some((x) => x !== "") ? `${p.name} (${[p.age, p.role].filter((x) => x !== "").join(", ")})` : p.name)), PEOPLE_SHOWN)}.`);
507
+ // The largest owners with their shares, each once, less a table's totals ("Other", "Subtotal").
508
+ const holders = new Map();
509
+ // A share that reads no number ranks last.
510
+ const rank = (share) => (Number.isNaN(shareSize(share)) ? -1 : shareSize(share));
511
+ for (const v of by("owner")) {
512
+ const name = personName(v.name);
513
+ const key = personKey(name);
514
+ if (key === "" || NOT_AN_OWNER.test(name))
515
+ continue;
516
+ const seen = holders.get(key);
517
+ if (seen === undefined)
518
+ holders.set(key, { name, share: v.share, order: holders.size });
519
+ else if (rank(v.share) > rank(seen.share))
520
+ holders.set(key, { ...seen, share: v.share });
521
+ }
522
+ const owners = [...holders.values()].sort((a, b) => rank(b.share) - rank(a.share) || a.order - b.order);
523
+ if (owners.length > 0)
524
+ out.push(`Its largest ${owners.length === 1 ? "owner is" : "owners are"} ${listOf(owners.map((o) => (o.share ? `${o.name} (${shareText(o.share)})` : o.name)), PEOPLE_SHOWN)}.`);
525
+ const known = new Set(["revenue_total", "concentration", "what_it_sells", "geography", "segment", "customer", "supplier", "pipeline", "pricing", "key_figure", "strategic_move", "setback", "executive", "owner"]);
526
+ capped("other", facts.filter((f) => !known.has(f.kind)).map((f) => ({ ...factValue(f), filed: f.quote.filed_at ?? "" })), (v) => told(v));
527
+ if (left.length > 0)
528
+ out.push(`Its fact sheet lists ${listOf(left, left.length)}.`);
529
+ // A sentence that ends on an abbreviation keeps one stop: "… and outside the U.S.".
530
+ return out.filter((s) => s !== "").map((s) => s.replace(/(?<!\.)\.\.$/, "."));
531
+ }
532
+ /** The company's place on each list it is on: the list, its tier, and why, the most-money row with its revenue. */
533
+ export function callRows(answer, ticker, profile) {
534
+ const total = profile?.facts.map((f) => (f.kind === "revenue_total" ? factValue(f) : null)).find((v) => v !== null && v.amount !== "");
535
+ const totalText = total ? ` Its total revenue was ${amountText(amountOf(total))}${total.period ? ` ${periodPhrase(total.period)}` : ""}${rateClause([convertedAt(total.usd, total.fx)])}.` : "";
536
+ const rows = [];
537
+ for (const r of REPORTS) {
538
+ const bands = bandsOf(answer, r.key);
539
+ for (const [band, label] of r.bands) {
540
+ const row = (bands[band] ?? []).find((x) => x.ticker === ticker);
541
+ if (row === undefined)
542
+ continue;
543
+ let why = sentence(row.reason ?? "");
544
+ if (r.key === "biggest") {
545
+ const f = row.revenue_from_theme;
546
+ const when = f ? periodPhrase(f.period ?? f.quote?.period) || (f.quote?.filed_at ? `in its annual report filed ${f.quote.filed_at}` : "") : "";
547
+ const revenue = f
548
+ ? `Its filings give ${amountText(figureAmount(f))} of revenue from this theme${when === "" ? "" : ` ${when}`}${rateClause([convertedAt(f.amount_usd, f.fx)])}.`
549
+ : row.revenue_estimate_usd != null
550
+ ? `Prism's estimate${answer.as_of ? `, made ${answer.as_of}` : ""}: about ${usdMoney(row.revenue_estimate_usd)} a year of its revenue comes from this theme. ${row.figure_gave_way === true ? "The figure its filings give is far larger and likely counts more than this theme; it is on its fact sheet." : "Its filings give no figure for this theme alone."}`
551
+ : "Its filings give no figure for its revenue from this theme.";
552
+ why = `${why} ${revenue}${totalText}`.trim();
553
+ }
554
+ rows.push({ report: r.label, tier: label, why });
555
+ }
556
+ }
557
+ return rows;
558
+ }
559
+ /** The fact sheet's kinds, in the order the full list shows them. */
560
+ const KINDS = ["revenue_total", "segment", "what_it_sells", "customer", "concentration", "geography", "supplier", "key_figure", "strategic_move"];
561
+ /** Where a fact sheet comes from: "From its annual report (20-F), filed 2026-02-27: 6 facts." */
562
+ export function sheetSource(profile) {
563
+ const forms = profile.facts.map((f) => f.quote.form).filter((f) => f !== null && f !== "");
564
+ const form = forms.sort((a, b) => forms.filter((x) => x === b).length - forms.filter((x) => x === a).length)[0];
565
+ return `From its ${(FORMS[form ?? ""] ?? "filings").replace(/^Annual report/, "annual report")}, filed ${profile.fact_sheet_as_of ?? "?"}: ${profile.facts.length} ${profile.facts.length === 1 ? "fact" : "facts"}.`;
566
+ }
567
+ /** Why a company shows no business: its sheet is older than the rule allows, or it has none. */
568
+ export const noSheetText = (factSheetAsOf) => (factSheetAsOf === null ? "No filings to quote." : `Its newest annual report on file is from ${factSheetAsOf}, more than ${SHEET_MONTHS} months ago, so Prism does not use it.`);
569
+ /**
570
+ * Every fact kind by kind, as `f` shows them: a sentence of its name, its figure with when it was true
571
+ * and its detail, the last of them after a dot ("Peru · PEN 10,876,215,000 in 2025"), the detail less
572
+ * the writer's notes on the table (since 2026-10-24); its quote; and where the quote comes from.
573
+ */
574
+ export function everyFact(facts) {
575
+ const order = (k) => (KINDS.indexOf(k) === -1 ? KINDS.length : KINDS.indexOf(k));
576
+ return [...facts]
577
+ .sort((a, b) => order(a.kind) - order(b.kind))
578
+ .map((f) => {
579
+ const v = factValue(f);
580
+ // Beside its quote, an amount reads in full, as the filing writes it (Ben, 2026-10-23: "US$303,154 million beside the quote").
581
+ const figure = v.amount ? amountBesideQuote(amountOf(v), v.period) : [v.share, v.share ? periodPhrase(v.period) : ""].filter((x) => x !== "").join(" ");
582
+ const parts = [v.name, figure, writerNotesOut(v.detail)].filter((x) => x !== "");
583
+ const text = parts.length <= 1 ? (parts[0] ?? "") : `${parts.slice(0, -1).join(": ")} · ${parts[parts.length - 1]}`;
584
+ return { sentence: sentence(text), quote: quoteText(f.quote.text), source: sourceText(f.quote) };
585
+ });
586
+ }
587
+ /**
588
+ * A company's page (Ben, 2026-10-20): its name and size, then three sections. The call: a table of
589
+ * each list it is on, its tier and why. The proof: a table of the quotes behind it and where each
590
+ * comes from. The business: what its fact sheet says, in sentences. With `all`, every fact with its
591
+ * quote instead of the business sentences. At most 100 characters wide; under 80, each table row
592
+ * stacks, so a small window cuts nothing. Every value carries its name, as "Market cap: US$49B" (Ben,
593
+ * 2026-10-21; its currency since 2026-10-23), and a stacked row names what its table's header would.
594
+ */
595
+ export function companyView(answer, row, profile, error, screenWidth, all = false) {
596
+ const width = Math.min(screenWidth, 100);
597
+ const narrow = width < 80;
598
+ const lines = [];
599
+ const add = (text, style) => lines.push(style === undefined ? { text } : { text, style });
600
+ const para = (text, indent, style) => wrapWords(text, width, indent, indent).forEach((t) => add(t, style));
601
+ const rule = () => add("─".repeat(width), "dim");
602
+ const section = (title) => add(` ${title} `, "section");
603
+ const cap = `Market cap: ${row.market_value ? `${usdMoney(row.market_value.usd)} on ${row.market_value.as_of}` : "not known"}`;
604
+ const name = `${row.ticker} ${displayName(row.name)}`;
605
+ if (!narrow && name.length + cap.length + 2 <= width)
606
+ add(`${name}${" ".repeat(width - name.length - cap.length)}${cap}`, "bold");
607
+ else {
608
+ add(name, "bold");
609
+ add(cap);
610
+ }
611
+ const about = profile?.summary ? (/^.*?[.!?](?=\s|$)/.exec(oneLine(profile.summary))?.[0] ?? oneLine(profile.summary)) : "";
612
+ if (about !== "")
613
+ para(about, "");
614
+ rule();
615
+ section("THE CALL");
616
+ const calls = callRows(answer, row.ticker, profile);
617
+ if (narrow) {
618
+ calls.forEach((c, i) => {
619
+ if (i > 0)
620
+ add("");
621
+ add(c.report, "bold");
622
+ para(`Tier: ${c.tier}`, " ");
623
+ para(`Why: ${c.why}`, " ");
624
+ });
625
+ }
626
+ else
627
+ lines.push(...tableLines(["Report", "Tier", "Why"], calls.map((c) => [c.report, c.tier, c.why]), [17, 15, width - 36]));
628
+ const proof = (row.evidence ?? []).filter((q) => quoteText(q.text) !== "");
629
+ if (proof.length > 0) {
630
+ add("");
631
+ section("THE PROOF");
632
+ if (narrow) {
633
+ proof.forEach((q, i) => {
634
+ if (i > 0)
635
+ add("");
636
+ wrapWords(`“${quoteText(q.text)}”`, width, " ", " ").forEach((t) => add(t));
637
+ para(`Where it comes from: ${sourceText(q)}`, " ", "dim");
638
+ });
639
+ }
640
+ else {
641
+ const quoteWidth = Math.floor((width - 2) * 0.62);
642
+ lines.push(...tableLines(["Quote", "Where it comes from"], proof.map((q) => [`“${quoteText(q.text)}”`, sourceText(q)]), [quoteWidth, width - quoteWidth - 2]));
643
+ }
644
+ }
645
+ add("");
646
+ const facts = error === undefined && profile !== null && !profile.no_filings_to_quote;
647
+ section(all && facts ? "EVERY FACT, WITH ITS QUOTE" : "THE BUSINESS");
648
+ if (error !== undefined)
649
+ add(error);
650
+ else if (profile === null)
651
+ add("Loading…", "dim");
652
+ else if (profile.no_filings_to_quote)
653
+ para(noSheetText(profile.fact_sheet_as_of), "");
654
+ else {
655
+ para(sheetSource(profile), "", "dim");
656
+ if (!all)
657
+ for (const s of businessSentences(profile.facts, profile))
658
+ wrapWords(s, width, "• ", " ").forEach((t) => add(t));
659
+ else
660
+ for (const f of everyFact(profile.facts)) {
661
+ add("");
662
+ wrapWords(f.sentence, width, "• ", " ").forEach((t) => add(t));
663
+ if (f.quote !== "")
664
+ wrapWords(`“${f.quote}”`, width, " ", " ").forEach((t) => add(t, "dim"));
665
+ if (f.source !== "")
666
+ para(`Where it comes from: ${f.source}`, " ", "dim");
667
+ }
668
+ }
669
+ return lines;
670
+ }
671
+ /**
672
+ * A time on this computer's clock, with its zone (the terminal test of 2026-10-23: past runs read
673
+ * "15:03", UTC, for 11:03 in New York): "2026-10-23 18:53 EDT". `timeZone` is for tests.
674
+ */
675
+ export function localTime(iso, timeZone) {
676
+ const d = new Date(iso);
677
+ if (Number.isNaN(d.getTime()))
678
+ return iso;
679
+ const parts = new Intl.DateTimeFormat("en-US", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23", timeZoneName: "short", ...(timeZone === undefined ? {} : { timeZone }) }).formatToParts(d);
680
+ const part = (type) => parts.find((p) => p.type === type)?.value ?? "";
681
+ return `${part("year")}-${part("month")}-${part("day")} ${part("hour")}:${part("minute")} ${part("timeZoneName")}`;
682
+ }
683
+ /** A past run's line: when it started, on this computer's clock; its state, cost and theme. */
684
+ export const pastRunText = (r, width, timeZone) => clip(` ${localTime(r.started_at, timeZone)} · ${r.status} · $${r.cost.usd.toFixed(2)} · ${oneLine(r.title)}`, width);
685
+ /**
686
+ * The company page's last line: its keys, then "32 of 56 lines". The count always shows: in a narrow
687
+ * window the keys are said shorter first, then left out (the terminal test of 2026-10-23: at 70
688
+ * columns the count was cut off).
689
+ */
690
+ export function companyFooter(all, shownTo, total, width) {
691
+ const count = `${shownTo} of ${total} lines`;
692
+ const keys = [
693
+ `↑↓ scroll · Space: next page · f: ${all ? "back to the summary" : "every fact with its quote"} · Esc: back · q: quit`,
694
+ `↑↓ · Space: page · f: ${all ? "summary" : "every fact"} · Esc: back · q: quit`,
695
+ `Space: page · f: ${all ? "summary" : "every fact"} · Esc: back`,
696
+ "Esc: back",
697
+ ];
698
+ return keys.map((k) => `${k} · ${count}`).find((line) => line.length <= width) ?? clip(count, width);
699
+ }
700
+ /** Copies text with macOS's pbcopy. */
701
+ async function pbcopy(text) {
702
+ const { spawn } = await import("node:child_process");
703
+ await new Promise((resolve, reject) => {
704
+ const child = spawn("pbcopy");
705
+ child.on("error", reject);
706
+ child.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`pbcopy exited with ${code}`))));
707
+ child.stdin.end(text);
708
+ });
709
+ }
710
+ /** The second step's grey text, Ben's words (2026-10-24): what elaborating means, then an example. */
711
+ export const ELABORATE_GUIDE = "e.g. Why you believe it, which kinds of companies you want, sizes or regions, what to leave out, or whether you think a popular fear is wrong.";
712
+ export const ELABORATE_EXAMPLE = "e.g. The market fears AI will cut software seats, but companies are buying more. I want mid-cap software names, $2B to $10B.";
713
+ export function App({ api, place, settingsFile, pollMs = 2000, copy = pbcopy, timeZone }) {
714
+ const { exit } = useApp();
715
+ const { stdout } = useStdout();
716
+ const width = Math.max(40, (stdout.columns ?? 100) - 2);
717
+ const height = Math.max(8, (stdout.rows ?? 30) - 8);
718
+ const [screen, setScreen] = useState({ name: "checking" });
719
+ const [account, setAccount] = useState(null);
720
+ const [draft, setDraft] = useState({ title: "", description: "" });
721
+ const [field, setField] = useState("title");
722
+ const nextStep = () => {
723
+ if (oneLine(draft.title).length < 2)
724
+ return setScreen({ name: "new", note: "Type a thesis first, 2 characters or more." });
725
+ setScreen({ name: "new" });
726
+ setField("description");
727
+ };
728
+ const refreshAccount = () => {
729
+ if (api !== null)
730
+ api.account().then(setAccount, () => undefined);
731
+ };
732
+ // Start-up: the key is checked before anything else, and nothing is spent until a theme is typed.
733
+ useEffect(() => {
734
+ if (api === null) {
735
+ setScreen({ name: "blocked", message: `Not authorized: there is no key in ${settingsFile}.`, hint: keyHint(place) });
736
+ return;
737
+ }
738
+ api.account().then((a) => {
739
+ setAccount(a);
740
+ setScreen({ name: "new" });
741
+ }, (error) => setScreen({ name: "blocked", ...blockedFor(error, place, api.url) }));
742
+ }, []);
743
+ // A run is followed until it answers; a run that fails or is refused goes back to a new theme with its reason.
744
+ const runningId = screen.name === "running" ? screen.runId : null;
745
+ useEffect(() => {
746
+ if (runningId === null || api === null)
747
+ return;
748
+ let stopped = false;
749
+ const tick = async () => {
750
+ try {
751
+ const view = await api.run(runningId);
752
+ if (stopped)
753
+ return;
754
+ setScreen((s) => {
755
+ if (s.name !== "running" || s.runId !== runningId)
756
+ return s;
757
+ if (isAnswer(view))
758
+ return { name: "results", answer: view, request: s.request ?? requestFor(view), list: 0, index: 0, ...(s.ranAt === undefined ? {} : { ranAt: s.ranAt }) };
759
+ if (view.status === "failed" || view.status === "refused")
760
+ return { name: "new", note: `The run "${s.title}" ended without an answer: ${view.error ?? view.status}` };
761
+ return { ...s, progress: view.progress, usd: view.cost?.usd ?? 0, line: progressLine(view.progress, Math.round((Date.now() - s.startedAt) / 1000), view.cost?.usd ?? 0),
762
+ note: view.status === "paused" ? "Your queue is paused. Add credit or change your spending limit, then run prism resume." : undefined };
763
+ });
764
+ if (isAnswer(view))
765
+ refreshAccount();
766
+ }
767
+ catch (error) {
768
+ if (!stopped)
769
+ setScreen((s) => (s.name === "running" && s.runId === runningId ? { ...s, note: error.message } : s));
770
+ }
771
+ };
772
+ void tick();
773
+ const timer = setInterval(() => void tick(), pollMs);
774
+ return () => {
775
+ stopped = true;
776
+ clearInterval(timer);
777
+ };
778
+ }, [runningId]);
779
+ // The elapsed time counts every second from the clock (Ben, 2026-10-24), between the cloud checks
780
+ // above, which come every pollMs and bring the step and the cost.
781
+ useEffect(() => {
782
+ if (runningId === null)
783
+ return;
784
+ const clock = setInterval(() => {
785
+ setScreen((s) => (s.name === "running" && s.runId === runningId ? { ...s, line: progressLine(s.progress, Math.round((Date.now() - s.startedAt) / 1000), s.usd ?? 0) } : s));
786
+ }, 1000);
787
+ return () => clearInterval(clock);
788
+ }, [runningId]);
789
+ const companyTicker = screen.name === "company" && screen.profile === null && screen.error === undefined ? screen.row.ticker : null;
790
+ useEffect(() => {
791
+ if (companyTicker === null || api === null)
792
+ return;
793
+ api.profile(companyTicker).then((profile) => setScreen((s) => (s.name === "company" && s.row.ticker === companyTicker ? { ...s, profile } : s)), (error) => setScreen((s) => (s.name === "company" && s.row.ticker === companyTicker ? { ...s, error: error.message } : s)));
794
+ }, [companyTicker]);
795
+ const loadingPast = screen.name === "past" && screen.runs === null;
796
+ useEffect(() => {
797
+ if (!loadingPast || api === null)
798
+ return;
799
+ api.runs().then((runs) => setScreen((s) => (s.name === "past" ? { ...s, runs } : s)), (error) => setScreen((s) => (s.name === "past" ? { ...s, runs: [], error: error.message } : s)));
800
+ }, [loadingPast]);
801
+ const startRun = async () => {
802
+ if (api === null)
803
+ return;
804
+ const title = oneLine(draft.title);
805
+ if (title.length < 2) {
806
+ setField("title");
807
+ return setScreen({ name: "new", note: "Type a thesis first, 2 characters or more." });
808
+ }
809
+ const description = oneLine(draft.description);
810
+ const request = { theme: { title, ...(description === "" ? {} : { description }) } };
811
+ setScreen({ name: "new", note: "Starting the run…" });
812
+ try {
813
+ const runId = await api.start(request);
814
+ setField("title");
815
+ setScreen({ name: "running", runId, title, request, startedAt: Date.now(), line: progressLine(undefined, 0, 0) });
816
+ }
817
+ catch (error) {
818
+ setScreen({ name: "new", note: `Prism did not start the run: ${error.message}` });
819
+ }
820
+ };
821
+ const openRun = async (run) => {
822
+ if (api === null)
823
+ return;
824
+ try {
825
+ const view = await api.run(run.run_id);
826
+ const ranAt = localTime(run.started_at, timeZone);
827
+ if (isAnswer(view))
828
+ return setScreen({ name: "results", answer: view, request: requestFor(view), list: 0, index: 0, ranAt });
829
+ if (view.status === "queued" || view.status === "running" || view.status === "paused" || view.status === "waiting to retry") {
830
+ return setScreen({ name: "running", runId: run.run_id, title: run.title, startedAt: Date.parse(run.started_at), line: progressLine(view.progress, 0, view.cost?.usd ?? 0), ranAt });
831
+ }
832
+ setScreen((s) => (s.name === "past" ? { ...s, error: `That run ended without an answer: ${view.error ?? view.status}` } : s));
833
+ }
834
+ catch (error) {
835
+ setScreen((s) => (s.name === "past" ? { ...s, error: error.message } : s));
836
+ }
837
+ };
838
+ useInput((input, key) => {
839
+ switch (screen.name) {
840
+ case "checking":
841
+ if (input === "q")
842
+ exit();
843
+ return;
844
+ case "blocked":
845
+ if (input === "q" || key.escape || key.return)
846
+ exit();
847
+ return;
848
+ case "new":
849
+ // Two steps (Ben, 2026-10-24): the thesis, Enter, then the optional elaboration, Enter to run.
850
+ // Esc on the second step goes back to the thesis; on the first, to past runs.
851
+ if (key.escape) {
852
+ if (field === "description")
853
+ setField("title");
854
+ else
855
+ setScreen({ name: "past", runs: null, index: 0 });
856
+ }
857
+ return;
858
+ case "running":
859
+ // q asks first (Ben, 2026-10-24): Enter quits, any other key stays. Quitting never stops the run.
860
+ if (screen.confirmQuit === true) {
861
+ if (key.return)
862
+ exit();
863
+ else
864
+ setScreen((s) => (s.name === "running" ? { ...s, confirmQuit: false } : s));
865
+ }
866
+ else if (key.escape)
867
+ setScreen({ name: "new", note: `"${screen.title}" carries on; open it from past runs (Esc).` });
868
+ else if (input === "p")
869
+ setScreen({ name: "past", runs: null, index: 0 });
870
+ else if (input === "q")
871
+ setScreen((s) => (s.name === "running" ? { ...s, confirmQuit: true } : s));
872
+ return;
873
+ case "results": {
874
+ const list = REPORTS[screen.list]?.key ?? "purest";
875
+ const rows = itemsOf(screen.answer, list).flatMap((i) => (i.kind === "row" ? [i.row] : []));
876
+ if (key.leftArrow || key.rightArrow)
877
+ setScreen({ ...screen, list: (screen.list + (key.rightArrow ? 1 : REPORTS.length - 1)) % REPORTS.length, index: 0, note: undefined });
878
+ else if (key.upArrow)
879
+ setScreen({ ...screen, index: Math.max(0, screen.index - 1) });
880
+ else if (key.downArrow)
881
+ setScreen({ ...screen, index: Math.min(Math.max(0, rows.length - 1), screen.index + 1) });
882
+ else if (key.return && rows[screen.index] !== undefined)
883
+ setScreen({ name: "company", back: screen, row: rows[screen.index], profile: null, offset: 0, all: false });
884
+ else if (input === "c" && api !== null) {
885
+ copy(curlFor(api.url, screen.request)).then(() => setScreen((s) => (s.name === "results" ? { ...s, note: "Copied the curl command; the key in it is $PRISM_API_KEY." } : s)), () => setScreen((s) => (s.name === "results" ? { ...s, note: curlFor(api.url, screen.request) } : s)));
886
+ }
887
+ else if (input === "n") {
888
+ setDraft({ title: "", description: "" });
889
+ setField("title");
890
+ setField("title");
891
+ setScreen({ name: "new" });
892
+ }
893
+ else if (input === "p")
894
+ setScreen({ name: "past", runs: null, index: 0 });
895
+ else if (input === "q")
896
+ exit();
897
+ return;
898
+ }
899
+ case "company": {
900
+ const last = Math.max(0, companyView(screen.back.answer, screen.row, screen.profile, screen.error, width, screen.all).length - height);
901
+ const at = Math.min(screen.offset, last);
902
+ const page = Math.max(1, height - 1);
903
+ if (key.escape || key.leftArrow)
904
+ setScreen(screen.back);
905
+ else if (key.upArrow)
906
+ setScreen({ ...screen, offset: Math.max(0, at - 1) });
907
+ else if (key.downArrow)
908
+ setScreen({ ...screen, offset: Math.min(last, at + 1) });
909
+ else if (key.pageDown || input === " ")
910
+ setScreen({ ...screen, offset: Math.min(last, at + page) });
911
+ else if (key.pageUp || input === "b")
912
+ setScreen({ ...screen, offset: Math.max(0, at - page) });
913
+ else if (input === "f")
914
+ setScreen({ ...screen, all: !screen.all, offset: 0 });
915
+ else if (input === "q")
916
+ exit();
917
+ return;
918
+ }
919
+ case "past": {
920
+ const runs = screen.runs ?? [];
921
+ if (key.upArrow)
922
+ setScreen({ ...screen, index: Math.max(0, screen.index - 1) });
923
+ else if (key.downArrow)
924
+ setScreen({ ...screen, index: Math.min(Math.max(0, runs.length - 1), screen.index + 1) });
925
+ else if (key.return && runs[screen.index] !== undefined)
926
+ void openRun(runs[screen.index]);
927
+ else if (input === "n" || key.escape)
928
+ setScreen({ name: "new" });
929
+ else if (input === "q")
930
+ exit();
931
+ return;
932
+ }
933
+ }
934
+ });
935
+ const header = _jsx(Text, { bold: true, children: clip(accountLine(place, account), width) });
936
+ switch (screen.name) {
937
+ case "checking":
938
+ return _jsx(Box, { flexDirection: "column", children: header });
939
+ case "blocked":
940
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: `Prism · ${place}` }), _jsx(Text, { color: "red", children: screen.message }), screen.hint === "" ? null : _jsx(Text, { children: screen.hint }), _jsx(Text, { dimColor: true, children: "Enter or q: quit" })] }));
941
+ case "new":
942
+ return (_jsxs(Box, { flexDirection: "column", children: [header, _jsx(Text, { children: " " }), field === "title" ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: "Up to 2,000 characters. Filters: US$500 million and up, no funds; a size the thesis names, such as mid-cap or small-cap, sets the market cap instead." }), _jsx(Text, { color: "cyan", children: "Your thesis" }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: "> " }), _jsx(TextInput, { value: draft.title, focus: true, onChange: (title) => setDraft((d) => ({ ...d, title })), onSubmit: nextStep })] }), screen.note === undefined ? null : _jsx(Text, { color: "yellow", children: screen.note }), _jsx(Text, { dimColor: true, children: "Enter: continue \u00B7 Esc: past runs \u00B7 Ctrl+C: quit" })] })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: ["Your thesis: ", oneLine(draft.title)] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "Elaborate (optional)" }), _jsx(Text, { dimColor: true, children: " \u00B7 press Enter to skip" })] }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: "> " }), _jsx(TextInput, { value: draft.description, focus: true, onChange: (description) => setDraft((d) => ({ ...d, description })), onSubmit: () => void startRun() })] }), draft.description === "" ? (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: ELABORATE_GUIDE }), _jsx(Text, { dimColor: true, children: ELABORATE_EXAMPLE })] })) : null, screen.note === undefined ? null : _jsx(Text, { color: "yellow", children: screen.note }), _jsx(Text, { dimColor: true, children: "Enter: run \u00B7 Enter on empty: skip \u00B7 Esc: back to the thesis \u00B7 Ctrl+C: quit" })] }))] }));
943
+ case "running":
944
+ return (_jsxs(Box, { flexDirection: "column", children: [header, _jsx(Text, { children: " " }), _jsx(Text, { children: clip(oneLine(screen.title), width) }), _jsx(Text, { color: "cyan", children: screen.line }), screen.note === undefined ? null : _jsx(Text, { color: "yellow", children: clip(screen.note, width) }), screen.confirmQuit === true ? (_jsx(Text, { color: "yellow", children: "Quit Prism? The run keeps going in the cloud; reopen it later from past runs. Press Enter to quit, any other key to stay." })) : (_jsx(Text, { dimColor: true, children: "Esc: new theme, the run carries on \u00B7 p: past runs \u00B7 q: quit" }))] }));
945
+ case "results": {
946
+ const a = screen.answer;
947
+ const list = REPORTS[screen.list]?.key ?? "purest";
948
+ const about = wrap(REPORTS[screen.list]?.description ?? "", width);
949
+ const shown = Math.max(4, height - about.length);
950
+ const items = itemsOf(a, list);
951
+ const rowItems = items.map((item, i) => [item, i]).filter(([item]) => item.kind === "row");
952
+ const selected = rowItems[screen.index]?.[1] ?? 0;
953
+ const start = Math.max(0, Math.min(selected - Math.floor(shown / 2), items.length - shown));
954
+ return (_jsxs(Box, { flexDirection: "column", children: [header, _jsx(Text, { children: clip(oneLine(a.theme.title), width) }), _jsx(Text, { dimColor: true, children: clip(`${screen.ranAt === undefined ? "" : `ran ${screen.ranAt} · `}${a.partial ? "partial" : "done"} in ${duration(a.took_seconds)} · $${a.cost.usd.toFixed(2)} · ${a.checked.candidates} candidates, ${a.checked.after_filters} after filters, ${a.checked.exposed} exposed`, width) }), _jsx(Text, { dimColor: true, children: clip(`Filters: ${filtersText(a.filters)}`, width) }), _jsx(Box, { children: REPORTS.map((r, i) => (_jsx(Text, { inverse: i === screen.list, children: ` ${r.label} ` }, r.key))) }), about.map((line, i) => (_jsx(Text, { dimColor: true, children: line }, `about${i}`))), items.slice(start, start + shown).map((item, i) => item.kind === "band" ? (_jsx(Text, { bold: true, children: item.label }, `b${start + i}`)) : item.kind === "none" ? (_jsx(Text, { dimColor: true, children: " none" }, `n${start + i}`)) : (_jsx(Text, { inverse: start + i === selected, children: ` ${rowText(item.row, list, width - 1)}` }, `r${start + i}`))), a.partial && a.partial_reason ? _jsx(Text, { color: "yellow", children: clip(`Partial: ${a.partial_reason}`, width) }) : null, screen.note === undefined ? null : _jsx(Text, { color: "yellow", children: clip(screen.note, width * 3) }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 move \u00B7 \u2190\u2192 list \u00B7 Enter: company \u00B7 c: copy curl \u00B7 n: new theme \u00B7 p: past runs \u00B7 q: quit" })] }));
955
+ }
956
+ case "company": {
957
+ const view = companyView(screen.back.answer, screen.row, screen.profile, screen.error, width, screen.all);
958
+ const offset = Math.min(screen.offset, Math.max(0, view.length - height));
959
+ return (_jsxs(Box, { flexDirection: "column", children: [header, view.slice(offset, offset + height).map((l, i) => (_jsx(Text, { bold: l.style === "bold" || l.style === "section", inverse: l.style === "section", dimColor: l.style === "dim", underline: l.style === "header", children: l.text === "" ? " " : l.text }, offset + i))), _jsx(Text, { dimColor: true, children: companyFooter(screen.all, Math.min(view.length, offset + height), view.length, width) })] }));
960
+ }
961
+ case "past": {
962
+ const runs = screen.runs;
963
+ const start = Math.max(0, screen.index - Math.floor(height / 2));
964
+ return (_jsxs(Box, { flexDirection: "column", children: [header, _jsx(Text, { children: "Past runs, newest first" }), runs === null ? _jsx(Text, { dimColor: true, children: "Loading\u2026" }) : null, runs !== null && runs.length === 0 && screen.error === undefined ? _jsx(Text, { dimColor: true, children: "No runs yet." }) : null, (runs ?? []).slice(start, start + height).map((r, i) => (_jsx(Text, { inverse: start + i === screen.index, children: pastRunText(r, width, timeZone) }, r.run_id))), screen.error === undefined ? null : _jsx(Text, { color: "yellow", children: clip(screen.error, width) }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 move \u00B7 Enter: open \u00B7 n or Esc: new theme \u00B7 q: quit" })] }));
965
+ }
966
+ }
967
+ }
968
+ //# sourceMappingURL=App.js.map