@oh-my-pi/pi-tui 18.6.3 → 18.7.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 (57) hide show
  1. package/CHANGELOG.md +26 -58
  2. package/dist/types/app-keybindings.d.ts +1 -1
  3. package/dist/types/autocomplete.d.ts +5 -2
  4. package/dist/types/charts/chart-plan.d.ts +92 -0
  5. package/dist/types/charts/chart-svg.d.ts +30 -0
  6. package/dist/types/charts/table-data.d.ts +107 -0
  7. package/dist/types/chat/assistant-message.d.ts +4 -0
  8. package/dist/types/chat/chat-transcript-builder.d.ts +2 -0
  9. package/dist/types/chat/figure-markdown.d.ts +42 -0
  10. package/dist/types/chat/svg-figure.d.ts +33 -0
  11. package/dist/types/chat/svg-source.d.ts +49 -0
  12. package/dist/types/chat/table-chart.d.ts +88 -0
  13. package/dist/types/components/editor.d.ts +1 -1
  14. package/dist/types/components/image.d.ts +9 -0
  15. package/dist/types/components/markdown.d.ts +9 -0
  16. package/dist/types/fuzzy.d.ts +14 -0
  17. package/dist/types/keybindings.d.ts +1 -1
  18. package/dist/types/overlays/agents-hub.d.ts +9 -0
  19. package/dist/types/overlays/annotation-overlay.d.ts +5 -2
  20. package/dist/types/overlays/model-browser.d.ts +25 -3
  21. package/dist/types/overlays/plan-review-overlay.d.ts +3 -2
  22. package/dist/types/prompt/interactive-context-helpers.d.ts +2 -0
  23. package/dist/types/prompt/model-mention-autocomplete.d.ts +1 -1
  24. package/dist/types/render/composite.d.ts +2 -0
  25. package/dist/types/tools/glob.d.ts +4 -0
  26. package/dist/types/utils.d.ts +6 -0
  27. package/package.json +9 -9
  28. package/src/autocomplete.ts +8 -10
  29. package/src/charts/chart-plan.ts +367 -0
  30. package/src/charts/chart-svg.ts +693 -0
  31. package/src/charts/table-data.ts +436 -0
  32. package/src/chat/assistant-message.ts +139 -21
  33. package/src/chat/chat-transcript-builder.ts +4 -0
  34. package/src/chat/figure-markdown.ts +174 -0
  35. package/src/chat/svg-figure.ts +364 -0
  36. package/src/chat/svg-source.ts +188 -0
  37. package/src/chat/table-chart.ts +301 -0
  38. package/src/chrome/transcript-container.ts +7 -1
  39. package/src/components/editor.ts +52 -30
  40. package/src/components/image.ts +27 -1
  41. package/src/components/markdown.ts +6 -3
  42. package/src/fuzzy.ts +59 -19
  43. package/src/keybindings.ts +1 -1
  44. package/src/overlays/agent-transcript-viewer.ts +2 -0
  45. package/src/overlays/agents-hub.ts +14 -1
  46. package/src/overlays/annotation-overlay.ts +179 -80
  47. package/src/overlays/ask-dialog.ts +1 -1
  48. package/src/overlays/model-browser.ts +169 -76
  49. package/src/overlays/plan-review-overlay.ts +41 -4
  50. package/src/prompt/composer.ts +2 -14
  51. package/src/prompt/interactive-context-helpers.ts +3 -0
  52. package/src/prompt/model-mention-autocomplete.ts +14 -14
  53. package/src/render/composite.ts +62 -0
  54. package/src/status-line/segments.ts +3 -1
  55. package/src/tools/glob.ts +23 -6
  56. package/src/tui.ts +118 -105
  57. package/src/utils.ts +19 -0
@@ -0,0 +1,436 @@
1
+ /**
2
+ * Numeric reading of GFM table cells and columns, the input to
3
+ * {@link planChart}: what number a cell holds (in base units, with its
4
+ * dimension and annotations) and which role each column plays — a measure to
5
+ * plot, a category label, an ordered x axis, or a row index to ignore.
6
+ *
7
+ * Cells are model-written prose, not CSV: `~1,400`, `**4** ✅`, `17 (21.5%)`,
8
+ * `24–72 h`, `12 → 15`, `5/8`, `1m 56s`, `TIMEOUT`. The reader keeps the first
9
+ * number and its unit and classifies the rest, so a column of mostly clean
10
+ * numbers survives a few annotated or sentinel cells.
11
+ */
12
+
13
+ /** What a number measures; values of one dimension share an axis after unit normalization. */
14
+ export type Dimension = "count" | "percent" | "duration" | "bytes" | "currency" | "ratio" | "fraction" | "rate";
15
+
16
+ /**
17
+ * How cleanly a cell reads as its number: bare (`12 ms`), with a short note
18
+ * (`17 (21.5%)`, `16 pass`), a range (`24–72 h`), a transition (`12 → 15`), a
19
+ * fraction (`5/8`), or a number leading prose (`~1,400w, nearly ready`).
20
+ */
21
+ export type CellFit = "pure" | "note" | "range" | "arrow" | "fraction" | "prose";
22
+
23
+ /** A cell that reads as a number. */
24
+ export interface NumberCell {
25
+ readonly kind: "number";
26
+ /** Value in base units: seconds, bytes, a plain count, percent points. */
27
+ readonly value: number;
28
+ readonly dim: Dimension;
29
+ /** Unit or currency symbol as written (`ms`, `KiB`, `$`, `tok/s`); empty for bare numbers. */
30
+ readonly unit: string;
31
+ readonly fit: CellFit;
32
+ /** Written as an estimate or bound (`~`, `≈`, `<`, `≥`). */
33
+ readonly approx: boolean;
34
+ /** Written with an explicit sign (`+12%`, `-3`). */
35
+ readonly signed: boolean;
36
+ /** The cell was bolded: models bold the value they want noticed. */
37
+ readonly emphasis: boolean;
38
+ /** The cell text without Markdown decoration. */
39
+ readonly text: string;
40
+ /** The number as written, with approximation, sign, currency and unit (`~$250`, `12.5 ms`, `24–72 h`). */
41
+ readonly figure: string;
42
+ /** `a → b`: `b`, in the same base units. */
43
+ readonly to?: number;
44
+ /** `a → b`: `b` as written. */
45
+ readonly toFigure?: string;
46
+ /** `a–b`: `b`, in the same base units. */
47
+ readonly upper?: number;
48
+ /** `a/b`: `b`. */
49
+ readonly denominator?: number;
50
+ }
51
+
52
+ /** A cell that is not a quantity. */
53
+ export interface OtherCell {
54
+ readonly kind: "missing" | "date" | "time" | "version" | "id" | "text";
55
+ readonly text: string;
56
+ readonly emphasis: boolean;
57
+ }
58
+
59
+ export type Cell = NumberCell | OtherCell;
60
+
61
+ /**
62
+ * What a column contributes to a chart: `measure` values to plot, `label`
63
+ * category names, `temporal` dates or times, `sequence` an ordered step
64
+ * (`Run`, `Day`, `Workers`), `index` a row number or identifier never plotted.
65
+ */
66
+ export type ColumnRole = "measure" | "label" | "temporal" | "sequence" | "index";
67
+
68
+ export interface TableColumn {
69
+ readonly index: number;
70
+ /** Header text without Markdown decoration. */
71
+ readonly header: string;
72
+ readonly role: ColumnRole;
73
+ readonly cells: readonly Cell[];
74
+ /** Dominant dimension of the numeric cells. */
75
+ readonly dim: Dimension | undefined;
76
+ /** Representative unit of the dominant dimension (`$`, `tok/s`); empty when bare. */
77
+ readonly unit: string;
78
+ /** Numeric cells disagree on dimension (a metric-per-row table). */
79
+ readonly mixed: boolean;
80
+ /** Every numeric data cell holds the same value: an echoed setting, not a measure. */
81
+ readonly constant: boolean;
82
+ /** Share of numeric cells written with an explicit sign. */
83
+ readonly signedShare: number;
84
+ /** Share of numeric cells written as `a → b`. */
85
+ readonly arrowShare: number;
86
+ }
87
+
88
+ /** A table read for charting. */
89
+ export interface TableAnalysis {
90
+ readonly columns: readonly TableColumn[];
91
+ /** Category labels: the first text or temporal column, else a sequence or index column. */
92
+ readonly label: TableColumn | undefined;
93
+ /** Plottable measure columns, constant ones excluded. */
94
+ readonly measures: readonly TableColumn[];
95
+ /** Data rows, in table order, without total/summary rows. */
96
+ readonly rows: readonly number[];
97
+ /** Total/summary rows (`Total`, `Average`, …), kept out of the scale. */
98
+ readonly totals: readonly number[];
99
+ }
100
+
101
+ const NUMBER = String.raw`(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?(?:[eE][+-]?\d+)?|\.\d+`;
102
+ const CELL = new RegExp(
103
+ String.raw`^(?<approx>~|≈|<=|>=|<|>|≤|≥)?\s*(?<sign>[+\-−±])?\s*(?<cur>[$€£¥])?\s*(?<num>${NUMBER})\s*(?<unit>%|[A-Za-zµμ×]+(?:\/[A-Za-z]+)?|\/s)?(?<rest>.*)$`,
104
+ );
105
+ const FRACTION = new RegExp(String.raw`^(${NUMBER})\s*\/\s*(${NUMBER})(?![\d/])(.*)$`);
106
+ const RANGE_TAIL = new RegExp(String.raw`^[-–—]\s*[~≈]?\s*[$€£¥]?\s*(${NUMBER})\s*(%|[A-Za-zµμ×]+)?`);
107
+ const ARROW_TAIL = new RegExp(String.raw`^(?:→|->|⇒)\s*[~≈]?\s*([+\-−])?\s*[$€£¥]?\s*(${NUMBER})\s*(%|[A-Za-zµμ×]+)?`);
108
+ const COMPOUND_DURATION =
109
+ /^(?:(\d+(?:\.\d+)?)\s*d)?\s*(?:(\d+(?:\.\d+)?)\s*h)?\s*(?:(\d+(?:\.\d+)?)\s*m(?:in)?)?\s*(?:(\d+(?:\.\d+)?)\s*s)?$/;
110
+ const DATE =
111
+ /^\d{4}-\d{2}(?:-\d{2})?(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?\b|^\d{1,2}\/\d{1,2}\/\d{2,4}$|^(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+\d{1,2}\b/i;
112
+ const TIME = /^\d{1,2}:\d{2}(?::\d{2})?(?:\.\d+)?\s*(?:am|pm)?$/i;
113
+ const VERSION = /^v?\d+\.\d+\.\d+|^v\d/i;
114
+ const IDENTIFIER = /^(?:#\d+|[0-9a-f]{7,40}$|PR\s*#?\d+|L\d+\b)/i;
115
+ /** Cell texts that stand for "no value". */
116
+ const MISSING: Readonly<Record<string, true>> = {
117
+ "": true,
118
+ "-": true,
119
+ "—": true,
120
+ "–": true,
121
+ "−": true,
122
+ "n/a": true,
123
+ na: true,
124
+ none: true,
125
+ "?": true,
126
+ "…": true,
127
+ "...": true,
128
+ null: true,
129
+ nan: true,
130
+ tbd: true,
131
+ "·": true,
132
+ x: true,
133
+ "✗": true,
134
+ };
135
+ /** Status marks that decorate a value without changing it (`**4** ✅`, `⚠️ 12`). */
136
+ const MARKS = /[✅❌⚠🟢🔴🟡✓✗✔★⭐]|\uFE0E|\uFE0F/gu;
137
+
138
+ /** Unit → dimension and factor to base units. Matched case-insensitively unless listed in {@link CASED_UNITS}. */
139
+ const UNITS: Readonly<Record<string, readonly [Dimension, number]>> = {
140
+ "%": ["percent", 1],
141
+ pp: ["percent", 1],
142
+ pct: ["percent", 1],
143
+ ns: ["duration", 1e-9],
144
+ µs: ["duration", 1e-6],
145
+ μs: ["duration", 1e-6],
146
+ us: ["duration", 1e-6],
147
+ ms: ["duration", 1e-3],
148
+ s: ["duration", 1],
149
+ sec: ["duration", 1],
150
+ secs: ["duration", 1],
151
+ seconds: ["duration", 1],
152
+ min: ["duration", 60],
153
+ mins: ["duration", 60],
154
+ minutes: ["duration", 60],
155
+ h: ["duration", 3600],
156
+ hr: ["duration", 3600],
157
+ hrs: ["duration", 3600],
158
+ hours: ["duration", 3600],
159
+ d: ["duration", 86_400],
160
+ days: ["duration", 86_400],
161
+ bytes: ["bytes", 1],
162
+ kb: ["bytes", 1e3],
163
+ kib: ["bytes", 1024],
164
+ mb: ["bytes", 1e6],
165
+ mib: ["bytes", 1024 ** 2],
166
+ gb: ["bytes", 1e9],
167
+ gib: ["bytes", 1024 ** 3],
168
+ tb: ["bytes", 1e12],
169
+ tib: ["bytes", 1024 ** 4],
170
+ x: ["ratio", 1],
171
+ "×": ["ratio", 1],
172
+ k: ["count", 1e3],
173
+ bn: ["count", 1e9],
174
+ };
175
+ /** Units whose case decides their meaning: `B` bytes vs `b` billions, `M` millions vs `m` minutes. */
176
+ const CASED_UNITS: Readonly<Record<string, readonly [Dimension, number]>> = {
177
+ B: ["bytes", 1],
178
+ b: ["count", 1e9],
179
+ M: ["count", 1e6],
180
+ m: ["duration", 60],
181
+ };
182
+
183
+ /** Header names of row numbers and identifiers: never plotted, never a category axis when text exists. */
184
+ const INDEX_HEADER =
185
+ /^(?:#|no\.?|n°|id|ids|rank|row|idx|index|line|lines?\s*#|ln|pr|pr\s*#|issue|commit|sha|hash|port|pid|code|exit\s*code|status|version|ver|ref|offset|priority|prio)$/i;
186
+ /** Header names of ordered steps: with monotonic values, a valid x axis for a line chart. */
187
+ const SEQUENCE_HEADER =
188
+ /^(?:step|phase|stage|round|tier|level|wave|pass|attempt|iter|iteration|epoch|run|batch|day|week|month|year|quarter|n|workers?|threads?|concurrency|jobs|depth|k)$/i;
189
+ /**
190
+ * Row labels that summarize the rows above them: `Total …` in any wording, an
191
+ * average only as a bare label (`Mean latency` is a metric, `Mean` a summary).
192
+ */
193
+ const TOTAL_ROW =
194
+ /^(?:(?:total|totals|sum|overall|grand total|subtotal|σ)\b|(?:average|avg|mean|median|all|combined|net)\s*(?:\(.*\))?:?$)/i;
195
+
196
+ /**
197
+ * Strip inline Markdown from a cell: links keep their text; bold, italics,
198
+ * strikethrough, code spans and `<br>` keep their content.
199
+ */
200
+ export function plainCell(markdown: string): { text: string; emphasis: boolean } {
201
+ let text = markdown.replace(/\\\|/g, "|");
202
+ const emphasis = /(\*\*|__)\S/.test(text);
203
+ text = text
204
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
205
+ .replace(/(\*\*|__)(.+?)\1/g, "$2")
206
+ .replace(/(?<![\w*])\*(?!\s)(.+?)(?<!\s)\*(?![\w*])/g, "$1")
207
+ .replace(/(?<!\w)_(?!\s)(.+?)(?<!\s)_(?!\w)/g, "$1")
208
+ .replace(/~~(.+?)~~/g, "$1")
209
+ .replace(/`([^`]*)`/g, "$1")
210
+ .replace(/<br\s*\/?>/gi, " ")
211
+ .replace(/<[^>]+>/g, "")
212
+ .replace(/\s+/g, " ")
213
+ .trim();
214
+ return { text, emphasis };
215
+ }
216
+
217
+ /** Parse the first number of a (Markdown) cell, or classify why it is not one. */
218
+ export function parseCell(markdown: string): Cell {
219
+ const plain = plainCell(markdown);
220
+ const text = plain.text.replace(MARKS, "").replace(/\s+/g, " ").trim();
221
+ const emphasis = plain.emphasis;
222
+ const other = (kind: OtherCell["kind"]): OtherCell => ({ kind, text: plain.text, emphasis });
223
+ if (MISSING[text.toLowerCase()]) return other("missing");
224
+ if (DATE.test(text)) return other("date");
225
+ if (TIME.test(text)) return other("time");
226
+ if (VERSION.test(text)) return other("version");
227
+ if (IDENTIFIER.test(text)) return other("id");
228
+
229
+ const base = { approx: false, signed: false, emphasis, text: plain.text };
230
+ const fraction = FRACTION.exec(text);
231
+ if (fraction) {
232
+ const rest = fraction[3]!;
233
+ return {
234
+ ...base,
235
+ kind: "number",
236
+ value: toNumber(fraction[1]!),
237
+ dim: "fraction",
238
+ unit: "/",
239
+ fit: rest.trim() ? "note" : "fraction",
240
+ figure: text.slice(0, text.length - rest.length).trim(),
241
+ denominator: toNumber(fraction[2]!),
242
+ };
243
+ }
244
+
245
+ const compound = COMPOUND_DURATION.exec(text);
246
+ if (compound && compound.slice(1).filter(Boolean).length >= 2) {
247
+ const [, days, hours, minutes, seconds] = compound.map(part => Number(part ?? 0));
248
+ const value = days! * 86_400 + hours! * 3600 + minutes! * 60 + seconds!;
249
+ return { ...base, kind: "number", value, dim: "duration", unit: "s", fit: "pure", figure: text };
250
+ }
251
+
252
+ const match = CELL.exec(text);
253
+ if (!match?.groups) return other("text");
254
+ const groups = match.groups;
255
+ let unit = groups.unit ?? "";
256
+ let rest = groups.rest!.trim();
257
+ let dim: Dimension = groups.cur ? "currency" : "count";
258
+ let factor = 1;
259
+ if (unit) {
260
+ const known = CASED_UNITS[unit] ?? UNITS[unit.toLowerCase()];
261
+ if (known) {
262
+ [dim, factor] = known;
263
+ } else if (unit.includes("/")) {
264
+ dim = "rate";
265
+ } else {
266
+ // A word after the number is a noun (`16 pass`), not a unit.
267
+ rest = `${unit} ${rest}`.trim();
268
+ unit = "";
269
+ }
270
+ }
271
+ // Through the unit when it stayed one, else through the number.
272
+ const figureEnd = unit ? text.length - groups.rest!.length : text.indexOf(groups.num!) + groups.num!.length;
273
+ const figure = text.slice(0, figureEnd).trim();
274
+ if (groups.cur) unit = groups.cur;
275
+ // `24–72 h`: the unit after the range's end applies to its start too.
276
+ const range = RANGE_TAIL.exec(rest);
277
+ const rangeUnit = range?.[2] ? (CASED_UNITS[range[2]] ?? UNITS[range[2].toLowerCase()]) : undefined;
278
+ if (!unit && rangeUnit) {
279
+ [dim, factor] = rangeUnit;
280
+ unit = range![2]!;
281
+ }
282
+ const negative = groups.sign === "-" || groups.sign === "−";
283
+ const value = toNumber(groups.num!) * factor * (negative ? -1 : 1);
284
+ const cell = {
285
+ ...base,
286
+ kind: "number" as const,
287
+ value,
288
+ dim,
289
+ unit,
290
+ figure,
291
+ // A trailing `+` (`~$250+`) marks a lower bound.
292
+ approx: groups.approx !== undefined || rest === "+",
293
+ signed: groups.sign !== undefined,
294
+ };
295
+ if (!rest || rest === "+") return { ...cell, fit: "pure" };
296
+ if (range) {
297
+ const upper = toNumber(range[1]!) * tailFactor(range[2], factor);
298
+ return { ...cell, fit: "range", upper, figure: `${figure}${range[0].trim()}` };
299
+ }
300
+ const arrow = ARROW_TAIL.exec(rest);
301
+ if (arrow) {
302
+ const sign = arrow[1] === "-" || arrow[1] === "−" ? -1 : 1;
303
+ const to = sign * toNumber(arrow[2]!) * tailFactor(arrow[3], factor);
304
+ return { ...cell, fit: "arrow", to, toFigure: arrow[0].replace(/^(?:→|->|⇒)\s*/, "").trim() };
305
+ }
306
+ if (/^\(.*\)$/.test(rest) || /^[A-Za-z][\w-]{0,11}$/.test(rest)) return { ...cell, fit: "note" };
307
+ return { ...cell, fit: "prose" };
308
+ }
309
+
310
+ function toNumber(written: string): number {
311
+ return Number(written.replaceAll(",", ""));
312
+ }
313
+
314
+ /** Factor of a unit written after the second number of a range or transition; the first number's when absent. */
315
+ function tailFactor(unit: string | undefined, fallback: number): number {
316
+ if (!unit) return fallback;
317
+ const known = CASED_UNITS[unit] ?? UNITS[unit.toLowerCase()];
318
+ return known ? known[1] : fallback;
319
+ }
320
+
321
+ /** Read a table's columns and rows for charting. Cells are the raw Markdown of each cell. */
322
+ export function analyzeTable(header: readonly string[], rows: readonly (readonly string[])[]): TableAnalysis {
323
+ const parsed = rows.map(row => header.map((_, index) => parseCell(row[index] ?? "")));
324
+ const draft = header.map((cell, index) => readColumn(index, plainCell(cell).text, parsed));
325
+ const labels = draft.filter(column => column.role === "label" || column.role === "temporal");
326
+ const label =
327
+ labels[0] ?? draft.find(column => column.role === "sequence") ?? draft.find(column => column.role === "index");
328
+ const totals: number[] = [];
329
+ const data: number[] = [];
330
+ for (let row = 0; row < rows.length; row++) {
331
+ const name = label ? label.cells[row]!.text : "";
332
+ (label && TOTAL_ROW.test(name.replace(MARKS, "").trim()) ? totals : data).push(row);
333
+ }
334
+ const columns = draft.map(column => finishColumn(column, data));
335
+ const measures = columns.filter(column => column.role === "measure" && !column.constant);
336
+ return { columns, label: label ? columns[label.index] : undefined, measures, rows: data, totals };
337
+ }
338
+
339
+ type DraftColumn = Omit<TableColumn, "constant">;
340
+
341
+ function readColumn(index: number, header: string, parsed: readonly (readonly Cell[])[]): DraftColumn {
342
+ const cells = resolveMinutes(parsed.map(row => row[index]!));
343
+ const present = cells.filter(cell => cell.kind !== "missing");
344
+ const numbers = present.filter(isNumber);
345
+ const strict = numbers.filter(cell => cell.fit !== "prose");
346
+ const total = Math.max(1, present.length);
347
+ const numberShare = numbers.length / total;
348
+ const strictShare = strict.length / total;
349
+ const temporalShare = present.filter(cell => cell.kind === "date" || cell.kind === "time").length / total;
350
+
351
+ const dims = new Map<Dimension, NumberCell[]>();
352
+ for (const cell of numbers) {
353
+ const same = dims.get(cell.dim);
354
+ if (same) same.push(cell);
355
+ else dims.set(cell.dim, [cell]);
356
+ }
357
+ let dominant: NumberCell[] = [];
358
+ for (const group of dims.values()) if (group.length > dominant.length) dominant = group;
359
+ const dim = dominant[0]?.dim;
360
+ const mixed = numbers.length > 0 && dominant.length / numbers.length < 0.8;
361
+ const unit = dim === "currency" || dim === "rate" ? (dominant[0]?.unit ?? "") : "";
362
+
363
+ const trimmed = header.trim();
364
+ const sequential =
365
+ index === 0 &&
366
+ numbers.length >= 3 &&
367
+ numbers.length === present.length &&
368
+ numbers.every((cell, at) => cell.dim === "count" && cell.value === numbers[0]!.value + at);
369
+ let role: ColumnRole;
370
+ if (temporalShare >= 0.8) role = "temporal";
371
+ else if (numberShare >= 0.8 && (INDEX_HEADER.test(trimmed) || sequential)) role = "index";
372
+ else if (numberShare >= 0.8 && SEQUENCE_HEADER.test(trimmed) && isMonotonic(numbers.map(cell => cell.value)))
373
+ role = "sequence";
374
+ else if (numberShare >= 0.8 && strictShare >= 0.6) role = "measure";
375
+ // A mostly numeric column whose other cells are short status words (`TIMEOUT`, `MISS`, `OOM`).
376
+ else if (numberShare >= 0.6 && strictShare >= 0.5 && present.every(cell => isNumber(cell) || cell.text.length <= 24))
377
+ role = "measure";
378
+ else role = "label";
379
+
380
+ return {
381
+ index,
382
+ header,
383
+ role,
384
+ cells,
385
+ dim,
386
+ unit,
387
+ mixed,
388
+ signedShare: numbers.length ? numbers.filter(cell => cell.signed).length / numbers.length : 0,
389
+ arrowShare: numbers.length ? numbers.filter(cell => cell.to !== undefined).length / numbers.length : 0,
390
+ };
391
+ }
392
+
393
+ /**
394
+ * A lone `m` means minutes beside other durations and millions otherwise;
395
+ * {@link CASED_UNITS} reads it as minutes, so a column whose only durations
396
+ * are `m` cells reads them as millions.
397
+ */
398
+ function resolveMinutes(cells: Cell[]): Cell[] {
399
+ const durations = cells.filter(cell => isNumber(cell) && cell.dim === "duration");
400
+ if (durations.length === 0 || !durations.every(cell => isNumber(cell) && cell.unit === "m")) return cells;
401
+ const rescale = (seconds: number | undefined) => (seconds === undefined ? undefined : (seconds / 60) * 1e6);
402
+ return cells.map(cell =>
403
+ isNumber(cell) && cell.unit === "m"
404
+ ? { ...cell, dim: "count", value: rescale(cell.value)!, to: rescale(cell.to), upper: rescale(cell.upper) }
405
+ : cell,
406
+ );
407
+ }
408
+
409
+ function finishColumn(column: DraftColumn, rows: readonly number[]): TableColumn {
410
+ const values = new Set<number>();
411
+ let count = 0;
412
+ for (const row of rows) {
413
+ const cell = column.cells[row];
414
+ if (cell && isNumber(cell)) {
415
+ values.add(cell.value);
416
+ count++;
417
+ }
418
+ }
419
+ return { ...column, constant: count > 0 && values.size <= 1 };
420
+ }
421
+
422
+ /** Strictly increasing or strictly decreasing. */
423
+ export function isMonotonic(values: readonly number[]): boolean {
424
+ if (values.length < 2) return false;
425
+ const up = values[1]! > values[0]!;
426
+ for (let at = 1; at < values.length; at++) {
427
+ const step = values[at]! - values[at - 1]!;
428
+ if (step === 0 || step > 0 !== up) return false;
429
+ }
430
+ return true;
431
+ }
432
+
433
+ /** Whether `cell` reads as a number. */
434
+ export function isNumber(cell: Cell | undefined): cell is NumberCell {
435
+ return cell?.kind === "number";
436
+ }