@oh-my-pi/pi-tui 18.8.8 → 18.8.9

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 (44) hide show
  1. package/CHANGELOG.md +25 -23
  2. package/dist/types/charts/chart-plan.d.ts +11 -5
  3. package/dist/types/charts/chart-svg.d.ts +11 -7
  4. package/dist/types/charts/table-data.d.ts +6 -1
  5. package/dist/types/kitty-graphics.d.ts +2 -2
  6. package/dist/types/multiplexers/cmux.d.ts +16 -0
  7. package/dist/types/multiplexers/herdr.d.ts +26 -0
  8. package/dist/types/multiplexers/notify.d.ts +13 -0
  9. package/dist/types/multiplexers/orca.d.ts +12 -0
  10. package/dist/types/multiplexers/screen.d.ts +9 -0
  11. package/dist/types/multiplexers/tmux.d.ts +29 -0
  12. package/dist/types/multiplexers/types.d.ts +100 -0
  13. package/dist/types/multiplexers/wmux.d.ts +12 -0
  14. package/dist/types/multiplexers/zellij.d.ts +12 -0
  15. package/dist/types/status-line/types.d.ts +2 -2
  16. package/dist/types/terminal-capabilities.d.ts +24 -32
  17. package/dist/types/terminal-multiplexer.d.ts +127 -14
  18. package/dist/types/terminal.d.ts +5 -4
  19. package/dist/types/tmux.d.ts +1 -4
  20. package/package.json +9 -9
  21. package/src/charts/chart-plan.ts +152 -12
  22. package/src/charts/chart-svg.ts +479 -126
  23. package/src/charts/table-data.ts +43 -8
  24. package/src/chat/svg-figure.ts +45 -8
  25. package/src/kitty-graphics.ts +4 -4
  26. package/src/multiplexers/cmux.ts +29 -0
  27. package/src/multiplexers/herdr.ts +64 -0
  28. package/src/multiplexers/notify.ts +24 -0
  29. package/src/multiplexers/orca.ts +16 -0
  30. package/src/multiplexers/screen.ts +15 -0
  31. package/src/multiplexers/tmux.ts +40 -0
  32. package/src/multiplexers/types.ts +110 -0
  33. package/src/multiplexers/wmux.ts +16 -0
  34. package/src/multiplexers/zellij.ts +24 -0
  35. package/src/status-line/component.ts +12 -8
  36. package/src/status-line/segments.ts +7 -11
  37. package/src/status-line/types.ts +2 -2
  38. package/src/terminal-capabilities.ts +65 -181
  39. package/src/terminal-multiplexer.ts +112 -33
  40. package/src/terminal.ts +34 -28
  41. package/src/theme/session-color.ts +1 -7
  42. package/src/theme/theme.ts +7 -4
  43. package/src/tmux.ts +4 -11
  44. package/src/tui.ts +18 -12
@@ -1,20 +1,133 @@
1
- /** True when this process is running inside a Herdr pane. */
2
- export declare function isInsideHerdr(env?: NodeJS.ProcessEnv): boolean;
3
- /** Terminal multiplexers omp recognizes as owning the screen grid. */
4
- export type TerminalMultiplexer = "herdr" | "tmux" | "screen" | "zellij" | "cmux" | "wmux";
1
+ import type { TerminalMultiplexerModule, TerminalMultiplexerNotificationRequest } from "./multiplexers/types.js";
5
2
  /**
6
- * Classify which terminal multiplexer owns the current screen grid, or `null`
7
- * for a direct terminal. Single source of truth for both the render-path gate
3
+ * Every multiplexer omp recognizes, in classification order within each
4
+ * precedence tier. Adding a multiplexer means adding its module here.
5
+ */
6
+ declare const registry: readonly [{
7
+ readonly id: "herdr";
8
+ readonly precedence: "session";
9
+ readonly sessionEnvKeys: readonly ["HERDR_ENV", "HERDR_PANE_ID", "HERDR_TAB_ID", "HERDR_WORKSPACE_ID"];
10
+ readonly ownsScreenGrid: true;
11
+ readonly isInside: (env?: NodeJS.ProcessEnv) => boolean;
12
+ readonly notifier: {
13
+ readonly tier: "pane";
14
+ readonly send: ({ message, env }: TerminalMultiplexerNotificationRequest) => boolean;
15
+ };
16
+ readonly hyperlinks: "render";
17
+ readonly honorsSynchronizedOutput: true;
18
+ readonly imagesRequireOverride: true;
19
+ }, {
20
+ readonly id: "tmux";
21
+ readonly precedence: "session";
22
+ readonly sessionEnvKeys: readonly ["TMUX"];
23
+ readonly termPrefix: "tmux";
24
+ readonly ownsScreenGrid: true;
25
+ readonly isInside: (env?: NodeJS.ProcessEnv) => boolean;
26
+ readonly notifier: {
27
+ readonly tier: "inBand";
28
+ readonly send: ({ sequence, write }: TerminalMultiplexerNotificationRequest) => boolean;
29
+ };
30
+ readonly hyperlinks: {
31
+ readonly forward: {
32
+ readonly termProgram: "tmux";
33
+ readonly major: 3;
34
+ readonly minor: 4;
35
+ };
36
+ };
37
+ readonly backgroundColorCache: {
38
+ readonly passthrough: typeof import("./tmux.js").wrapTmuxPassthrough;
39
+ readonly settleMs: 100;
40
+ };
41
+ readonly expandsRepPadding: true;
42
+ readonly expiresSynchronizedOutput: true;
43
+ readonly altRestoreEndsSynchronizedOutput: true;
44
+ readonly growsBeforeSigwinch: true;
45
+ }, {
46
+ readonly id: "screen";
47
+ readonly precedence: "session";
48
+ readonly sessionEnvKeys: readonly ["STY"];
49
+ readonly termPrefix: "screen";
50
+ readonly ownsScreenGrid: true;
51
+ readonly isInside: (env?: NodeJS.ProcessEnv) => boolean;
52
+ readonly hyperlinks: "drop";
53
+ }, {
54
+ readonly id: "zellij";
55
+ readonly precedence: "session";
56
+ readonly sessionEnvKeys: readonly ["ZELLIJ"];
57
+ readonly ownsScreenGrid: true;
58
+ readonly isInside: (env?: NodeJS.ProcessEnv) => boolean;
59
+ readonly notifier: {
60
+ readonly tier: "inBand";
61
+ readonly send: ({ sequence, write }: TerminalMultiplexerNotificationRequest) => boolean;
62
+ };
63
+ readonly backgroundQueryBrokenOn: readonly ["darwin"];
64
+ }, {
65
+ readonly id: "cmux";
66
+ readonly precedence: "session";
67
+ readonly sessionEnvKeys: readonly ["CMUX_WORKSPACE_ID", "CMUX_SURFACE_ID", "CMUX_REMOTE_TRANSPORT"];
68
+ readonly ownsScreenGrid: true;
69
+ readonly isInside: (env?: NodeJS.ProcessEnv) => boolean;
70
+ readonly notifier: {
71
+ readonly tier: "surface";
72
+ readonly send: ({ message, env }: TerminalMultiplexerNotificationRequest) => boolean;
73
+ };
74
+ }, {
75
+ readonly id: "wmux";
76
+ readonly precedence: "session";
77
+ readonly sessionEnvKeys: readonly ["WMUX", "WMUX_SURFACE_ID"];
78
+ readonly ownsScreenGrid: true;
79
+ readonly isInside: (env?: NodeJS.ProcessEnv) => boolean;
80
+ }, {
81
+ readonly id: "orca";
82
+ readonly precedence: "outerApp";
83
+ readonly sessionEnvKeys: readonly ["ORCA_PANE_KEY", "ORCA_WORKTREE_ID"];
84
+ readonly ownsScreenGrid: false;
85
+ readonly isInside: (env?: NodeJS.ProcessEnv) => boolean;
86
+ }];
87
+ /** Terminal multiplexers omp recognizes. */
88
+ export type TerminalMultiplexer = (typeof registry)[number]["id"];
89
+ /** Every environment variable multiplexer classification reads, including the TERM fallback. */
90
+ export declare const TERMINAL_MULTIPLEXER_ENV_KEYS: readonly string[];
91
+ /**
92
+ * Whether an explicit session marker identifies the current provider.
93
+ *
94
+ * TERM is intentionally excluded: it is a classification fallback, not proof
95
+ * that a particular multiplexer session owns the current grid.
96
+ */
97
+ export declare function hasTerminalMultiplexerSession(multiplexer: TerminalMultiplexer, env?: NodeJS.ProcessEnv): boolean;
98
+ /**
99
+ * Every multiplexer whose explicit session markers are present, in registry
100
+ * order. Nested sessions all appear; TERM is not consulted.
101
+ */
102
+ export declare function terminalMultiplexerSessions(env?: NodeJS.ProcessEnv): TerminalMultiplexerModule<TerminalMultiplexer>[];
103
+ /**
104
+ * The multiplexer TERM names, whether or not its session markers survived.
105
+ * This is classification's TERM fallback; session markers are not consulted.
106
+ */
107
+ export declare function terminalMultiplexerForTerm(env?: NodeJS.ProcessEnv): TerminalMultiplexerModule<TerminalMultiplexer> | undefined;
108
+ /**
109
+ * Registry entry for the multiplexer hosting the current process, or
110
+ * `undefined` for a direct terminal. Same precedence as
111
+ * {@link classifyTerminalMultiplexer}.
112
+ */
113
+ export declare function classifyTerminalMultiplexerModule(env?: NodeJS.ProcessEnv): TerminalMultiplexerModule<TerminalMultiplexer> | undefined;
114
+ /**
115
+ * Classify which terminal multiplexer hosts the current process, or `null` for
116
+ * a direct terminal. Single source of truth for both the render-path gate
8
117
  * (`isInsideTerminalMultiplexer`) and the debug snapshot label.
9
118
  *
10
- * TMUX/STY/ZELLIJ, Herdr, and the CMUX/WMUX workspace/surface/remote-transport
11
- * markers are authoritative session signals. TERM can also survive when those
12
- * are stripped (`sudo` without -E, `su`, env-sanitizing launchers/ssh). Do not
13
- * use CMUX_SOCKET_PATH / WMUX_CLI / WMUX_PIPE here: they are CLI socket/path
14
- * overrides and can be set outside a CMUX/WMUX terminal. wmux is a Windows
15
- * multiplexer (Electron + xterm.js) modeled on cmux/herdr that repaints its
16
- * pane in place and exports WMUX=1 plus a native WMUX_SURFACE_ID.
119
+ * Session markers are authoritative. TERM can also survive when those markers
120
+ * are stripped (`sudo` without -E, `su`, env-sanitizing launchers/ssh), so a
121
+ * `tmux`/`screen` TERM prefix comes next. Outer applications such as Orca rank
122
+ * last so a multiplexer running inside them wins.
17
123
  */
18
124
  export declare function classifyTerminalMultiplexer(env?: NodeJS.ProcessEnv): TerminalMultiplexer | null;
19
- /** True when a terminal multiplexer owns the current screen grid. */
125
+ /** True when the classified multiplexer owns the current screen grid. */
20
126
  export declare function isInsideTerminalMultiplexer(env?: NodeJS.ProcessEnv): boolean;
127
+ /**
128
+ * Offer a notification to every active session's notifier, innermost tier
129
+ * first. Returns whether one delivered it; otherwise the terminal fallback
130
+ * applies unchanged.
131
+ */
132
+ export declare function routeTerminalMultiplexerNotification(request: TerminalMultiplexerNotificationRequest): boolean;
133
+ export {};
@@ -298,10 +298,11 @@ export declare class ProcessTerminal implements Terminal {
298
298
  onAppearanceReport(callback: (appearance: TerminalAppearance, requestToken?: TerminalAppearanceRequestToken) => void): () => void;
299
299
  /**
300
300
  * Re-query the terminal background through the startup DA1-sentinel FIFO,
301
- * pending/queued gating, parsing, dedup, and appearance callbacks. Inside
302
- * tmux, only this explicit path first passes an OSC 11 query to the outer
303
- * terminal, waits briefly for tmux to consume the response into its cache,
304
- * then reads that cache with a direct query. The outer query deliberately has
301
+ * pending/queued gating, parsing, dedup, and appearance callbacks. Inside a
302
+ * multiplexer that answers OSC 11 from a cache (tmux), only this explicit
303
+ * path first passes an OSC 11 query to the outer terminal, waits briefly for
304
+ * the multiplexer to consume the response into its cache, then reads that
305
+ * cache with a direct query. The outer query deliberately has
305
306
  * no DA1 sentinel: multiplexers can decode a fragmented DA1 response as a key
306
307
  * sequence and leak the remaining bytes into the editor. Startup and Mode 2031
307
308
  * probes remain direct. Suppressed while inactive, headless, or after teardown.
@@ -1,7 +1,4 @@
1
- /** Whether the process is running inside a tmux session. */
2
- export declare function isInsideTmux(env?: NodeJS.ProcessEnv): boolean;
3
- /** Wrap a control sequence in tmux's DCS passthrough envelope. */
4
- export declare function wrapTmuxPassthrough(payload: string): string;
1
+ export { wrapTmuxPassthrough } from "./multiplexers/tmux.js";
5
2
  /** Pass a control sequence through tmux, leaving direct-terminal output unchanged. */
6
3
  export declare function wrapTmuxPassthroughIfNeeded(payload: string, env?: NodeJS.ProcessEnv): string;
7
4
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "18.8.8",
4
+ "version": "18.8.9",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -41,14 +41,14 @@
41
41
  "gen:glyphs": "bun scripts/gen-glyphs.ts"
42
42
  },
43
43
  "dependencies": {
44
- "@oh-my-pi/omptype": "18.8.8",
45
- "@oh-my-pi/pi-agent-core": "18.8.8",
46
- "@oh-my-pi/pi-ai": "18.8.8",
47
- "@oh-my-pi/pi-catalog": "18.8.8",
48
- "@oh-my-pi/pi-natives": "18.8.8",
49
- "@oh-my-pi/pi-utils": "18.8.8",
50
- "@oh-my-pi/pi-wire": "18.8.8",
51
- "@oh-my-pi/snapcompact": "18.8.8"
44
+ "@oh-my-pi/omptype": "18.8.9",
45
+ "@oh-my-pi/pi-agent-core": "18.8.9",
46
+ "@oh-my-pi/pi-ai": "18.8.9",
47
+ "@oh-my-pi/pi-catalog": "18.8.9",
48
+ "@oh-my-pi/pi-natives": "18.8.9",
49
+ "@oh-my-pi/pi-utils": "18.8.9",
50
+ "@oh-my-pi/pi-wire": "18.8.9",
51
+ "@oh-my-pi/snapcompact": "18.8.9"
52
52
  },
53
53
  "devDependencies": {
54
54
  "kitty-vt-wasm": "^0.2.0"
@@ -13,6 +13,7 @@
13
13
  * out of the scale, and units never share an axis.
14
14
  */
15
15
  import {
16
+ type Cell,
16
17
  type Dimension,
17
18
  isMonotonic,
18
19
  isNumber,
@@ -32,6 +33,8 @@ import {
32
33
  * - `share`: parts of a whole (percentages summing to 100).
33
34
  * - `diverging`: signed changes around zero.
34
35
  * - `scatter`: two continuous measures per item.
36
+ * - `change`: metrics in different units against a baseline column (before/after), as factors.
37
+ * - `progress`: scores out of a total (`12/12`), as filled tracks.
35
38
  */
36
39
  export type ChartKind =
37
40
  | "bar"
@@ -42,14 +45,16 @@ export type ChartKind =
42
45
  | "multiples"
43
46
  | "share"
44
47
  | "diverging"
45
- | "scatter";
48
+ | "scatter"
49
+ | "change"
50
+ | "progress";
46
51
 
47
52
  /** What to draw from a table: the kind, the column naming each category, and the columns plotted. */
48
53
  export interface ChartPlan {
49
54
  readonly kind: ChartKind;
50
55
  /** Column naming each category (or holding the x values of a line); rows are numbered without one. */
51
56
  readonly label: number | undefined;
52
- /** Plotted columns, in order. */
57
+ /** Plotted columns, in order; a `change` measures the rest against the first (or its `a → b` cells). */
53
58
  readonly series: readonly number[];
54
59
  /** Each data row is its own series (a metric with its own unit) and the plotted columns are the categories. */
55
60
  readonly transpose: boolean;
@@ -94,6 +99,8 @@ export interface ChartSpec {
94
99
  readonly caption?: string;
95
100
  /** Name of the category axis (the label column's header). */
96
101
  readonly axis: string;
102
+ /** What a `change` measures against (its baseline column); its points are factors of it. */
103
+ readonly baseline?: string;
97
104
  }
98
105
 
99
106
  const BEFORE =
@@ -102,6 +109,8 @@ const AFTER = /\b(?:after|new|patched|optimi[sz]ed|fixed|warm|now|to|v2|with|pro
102
109
  const DELTA = /(?:Δ|delta|change|diff|improvement|speedup|reduction|savings|saved|gain|regression|% chg)/i;
103
110
  /** Aggregate columns that restate the others; dropped when enough same-unit series remain. */
104
111
  const AGGREGATE = /^(?:total|sum|cumulative|cum\.?|running total|avg|average|mean)\b/i;
112
+ /** The arrow of an `a → b` header or cell, with the spaces around it. */
113
+ const ARROW = /\s*(?:→|->|⇒)\s*/;
105
114
  /** Row labels that name an ordered step (`Run 3`, `Day 2`, `N=1000`, `Q3`). */
106
115
  const SEQUENCE_LABEL =
107
116
  /^(?:run|day|week|month|iter(?:ation)?|round|step|phase|epoch|v|version|n\s*=|wave|attempt|pass|batch|q[1-4]|#)\s*\d/i;
@@ -110,11 +119,18 @@ const MONTH = /^(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b/i;
110
119
  const MAX_PANELS = 6;
111
120
  /** Most lines one line chart keeps apart. */
112
121
  const MAX_LINES = 6;
122
+ /** Most series a `progress` pick draws as tracks per category; more read as bars or a heatmap. */
123
+ const MAX_TRACKS = 3;
124
+ /** Smallest factor (either way) that makes a `change` chart say more than its table. */
125
+ const NOTABLE_FACTOR = 1.5;
113
126
 
114
127
  /** The local best guess at a chart for `table`, or `undefined` when no chart fits. */
115
128
  export function planChart(table: TableAnalysis): ChartPlan | undefined {
116
129
  const { rows } = table;
117
130
  let label = table.label;
131
+ const change = changeColumns(table);
132
+ if (change)
133
+ return { kind: "change", label: label?.index, series: change.map(column => column.index), transpose: false };
118
134
  let measures = dropAggregates(table.measures);
119
135
  if (measures.length === 0 || rows.length < 2) return undefined;
120
136
 
@@ -136,9 +152,11 @@ export function planChart(table: TableAnalysis): ChartPlan | undefined {
136
152
  transpose,
137
153
  });
138
154
 
155
+ // `a → b` cells: one axis when the rows share a unit, factors of change when each row has its own.
156
+ if (measures.length === 1 && measures[0]!.arrowShare >= 0.6)
157
+ return plan(measures[0]!.mixed ? "change" : "paired", measures);
139
158
  const mixed = measures.filter(column => column.mixed);
140
159
  if (mixed.length > 0 && mixed.length * 2 >= measures.length) return plan("multiples", measures, true);
141
- if (measures.length === 1 && measures[0]!.arrowShare >= 0.6) return plan("paired", measures);
142
160
 
143
161
  const dims = new Set(measures.map(column => column.dim));
144
162
  if (dims.size === 1 && measures.length >= 2 && measures.length <= 4) {
@@ -151,6 +169,8 @@ export function planChart(table: TableAnalysis): ChartPlan | undefined {
151
169
  const lead = measures[0]!.dim;
152
170
  return plan("line", measures.filter(column => column.dim === lead).slice(0, MAX_LINES));
153
171
  }
172
+ // Several score columns compare like any same-unit measures (as rates); a lone one fills tracks.
173
+ if (measures.length === 1 && measures[0]!.scores) return plan("progress", measures);
154
174
  if (measures.length <= 2) {
155
175
  const delta = measures.find(
156
176
  column =>
@@ -159,7 +179,7 @@ export function planChart(table: TableAnalysis): ChartPlan | undefined {
159
179
  );
160
180
  if (delta) return plan("diverging", [delta]);
161
181
  }
162
- const share = measures.find(column => column.dim === "percent");
182
+ const share = measures.find(column => column.dim === "percent" && !column.scores);
163
183
  if (share && rows.length <= 10 && (measures.length === 1 || (measures.length === 2 && dims.size === 2))) {
164
184
  const values = columnValues(share, rows);
165
185
  const sum = values.reduce((total, value) => total + value, 0);
@@ -183,6 +203,7 @@ export function buildChart(table: TableAnalysis, plan: ChartPlan): ChartSpec | u
183
203
  const label = plan.label === undefined ? undefined : table.columns[plan.label];
184
204
  const columns = plan.series.map(index => table.columns[index]).filter(column => column !== undefined);
185
205
  if (columns.length === 0) return undefined;
206
+ if (plan.kind === "change" && !plan.transpose) return changeChart(table, label, columns);
186
207
  const rowName = (row: number, at: number) => (label ? label.cells[row]!.text : "") || `#${at + 1}`;
187
208
 
188
209
  let categories: string[];
@@ -193,11 +214,11 @@ export function buildChart(table: TableAnalysis, plan: ChartPlan): ChartSpec | u
193
214
  } else if (plan.kind === "paired" && columns.length === 1) {
194
215
  // `a → b` cells: the column splits into its from and to values.
195
216
  const column = columns[0]!;
196
- const [from, to] = column.header.split(/\s*(?:→|->|⇒)\s*/);
197
- const before = columnSeries(column, table.rows, from && to ? from : "before");
217
+ const [from, to] = arrowNames(column.header);
218
+ const before = columnSeries(column, table.rows, from);
198
219
  const after: ChartSeries = {
199
220
  ...before,
200
- name: from && to ? to : "after",
221
+ name: to,
201
222
  points: table.rows.map(row => {
202
223
  const cell = column.cells[row];
203
224
  return isNumber(cell) && cell.to !== undefined
@@ -247,15 +268,27 @@ export function buildChart(table: TableAnalysis, plan: ChartPlan): ChartSpec | u
247
268
 
248
269
  /**
249
270
  * Whether a chart of `spec` reads faster than its table: at least four
250
- * categories, and either nine plotted values or a 3× spread within a series.
251
- * On a model-judged sample of assistant tables this gate kept 85% of the
252
- * tables a chart clearly helped while rejecting most two- and three-row ones.
271
+ * categories (or small-multiple panels), and either nine plotted values or a
272
+ * 3× spread within a series. On a model-judged sample of assistant tables this
273
+ * gate kept 85% of the tables a chart clearly helped while rejecting most two-
274
+ * and three-row ones. A `change` needs three metrics and one moving by
275
+ * {@link NOTABLE_FACTOR} either way: its factors are what the table lacks.
253
276
  */
254
277
  export function worthCharting(spec: ChartSpec): boolean {
255
- if (spec.categories.length < 4) return false;
278
+ const { kind, categories, series } = spec;
279
+ // Factors share no unit with the table's figures: three metrics are enough once one moves noticeably.
280
+ if (kind === "change")
281
+ return (
282
+ categories.length >= 3 &&
283
+ series.some(entry =>
284
+ entry.points.some(point => point && Math.abs(Math.log(point.value)) >= Math.log(NOTABLE_FACTOR)),
285
+ )
286
+ );
287
+ // Small multiples compare within each panel, so four metrics count like four categories.
288
+ if ((kind === "multiples" ? Math.max(categories.length, series.length) : categories.length) < 4) return false;
256
289
  let points = 0;
257
290
  let spread = 1;
258
- for (const entry of spec.series) {
291
+ for (const entry of series) {
259
292
  const positive: number[] = [];
260
293
  for (const point of entry.points) {
261
294
  if (!point) continue;
@@ -277,10 +310,117 @@ function fitKind(kind: ChartKind, series: readonly ChartSeries[], hasX: boolean)
277
310
  if (kind === "scatter" && hasX) return kind;
278
311
  if (new Set(series.map(entry => `${entry.dim}:${entry.unit}`)).size > 1) return "multiples";
279
312
  if (kind === "share" || kind === "diverging" || kind === "line" || kind === "heatmap") return kind;
313
+ if (
314
+ kind === "progress" &&
315
+ series.length <= MAX_TRACKS &&
316
+ series.every(
317
+ entry =>
318
+ entry.dim === "percent" && entry.points.every(point => !point || (point.value >= 0 && point.value <= 100)),
319
+ )
320
+ )
321
+ return kind;
280
322
  const count = series.length;
281
323
  return count === 1 ? "bar" : count === 2 ? "paired" : count <= 4 ? "grouped" : "heatmap";
282
324
  }
283
325
 
326
+ /**
327
+ * Baseline and compared columns of a before/after table a shared axis cannot
328
+ * hold: rows in different units, or prose cells (`none seen`) keeping its
329
+ * columns from reading as measures. The baseline is the `before` column; the
330
+ * compared ones are the `after` column and any measure beside it pairing with
331
+ * the baseline in two or more rows. Deltas and columns holding their own
332
+ * `a → b` transitions take no part.
333
+ */
334
+ function changeColumns(table: TableAnalysis): TableColumn[] | undefined {
335
+ const { label } = table;
336
+ if (label?.role !== "label") return undefined;
337
+ const values = table.columns.filter(
338
+ column =>
339
+ column !== label &&
340
+ (column.role === "measure" || column.role === "label") &&
341
+ !DELTA.test(column.header) &&
342
+ !ARROW.test(column.header) &&
343
+ column.arrowShare < 0.6,
344
+ );
345
+ const baseline = values.find(column => BEFORE.test(column.header));
346
+ if (!baseline) return undefined;
347
+ const rows = [...table.rows, ...table.totals];
348
+ const compared = values.filter(
349
+ column =>
350
+ column !== baseline &&
351
+ (column.role === "measure" || AFTER.test(column.header)) &&
352
+ rows.filter(row => changePoint(baseline.cells[row], column.cells[row])).length >= 2,
353
+ );
354
+ if (!compared.some(column => AFTER.test(column.header))) return undefined;
355
+ const columns = [baseline, ...compared];
356
+ // One unit throughout: a shared axis holds the values themselves (`paired`, `grouped`).
357
+ if (columns.every(column => column.role === "measure" && !column.mixed)) return undefined;
358
+ return columns;
359
+ }
360
+
361
+ /**
362
+ * A `change` over `columns`: per row, each compared column as a factor of the
363
+ * baseline's value, or the `a → b` cells of a lone column as factors of their
364
+ * start. Total rows stay (a factor never skews the scale); rows with no pair
365
+ * are named in the caption instead.
366
+ */
367
+ function changeChart(
368
+ table: TableAnalysis,
369
+ label: TableColumn | undefined,
370
+ columns: readonly TableColumn[],
371
+ ): ChartSpec | undefined {
372
+ const [baseline, ...compared] = columns;
373
+ if (!baseline) return undefined;
374
+ const [from, to] = arrowNames(baseline.header);
375
+ const pairs = compared.length
376
+ ? compared.map(column => ({
377
+ name: column.header,
378
+ at: (row: number) => changePoint(baseline.cells[row], column.cells[row]),
379
+ }))
380
+ : [{ name: to, at: (row: number) => arrowChange(baseline.cells[row]) }];
381
+ const rows = [...table.rows, ...table.totals].sort((a, b) => a - b);
382
+ const name = (row: number) => (label ? label.cells[row]!.text : "") || `#${row + 1}`;
383
+ const kept = rows.filter(row => pairs.some(pair => pair.at(row)));
384
+ if (kept.length < 2) return undefined;
385
+ const skipped = rows.filter(row => !kept.includes(row)).map(name);
386
+ return {
387
+ kind: "change",
388
+ categories: kept.map(name),
389
+ series: pairs.map(pair => ({
390
+ name: pair.name,
391
+ dim: "ratio",
392
+ unit: "",
393
+ points: kept.map(row => pair.at(row) ?? null),
394
+ notes: kept.map(() => undefined),
395
+ })),
396
+ caption: skipped.length ? `Not plotted: ${skipped.join(" · ")}` : undefined,
397
+ axis: label?.header ?? "",
398
+ baseline: compared.length ? baseline.header : from,
399
+ };
400
+ }
401
+
402
+ /** `to` as a factor of `from` (`59.4 s → 1.23 s` is 0.0207), when both are numbers of one dimension. */
403
+ function changePoint(from: Cell | undefined, to: Cell | undefined): ChartPoint | undefined {
404
+ if (!isNumber(from) || !isNumber(to) || from.dim !== to.dim || from.value <= 0 || to.value < 0) return undefined;
405
+ return {
406
+ value: to.value / from.value,
407
+ text: `${from.figure} → ${to.figure}`,
408
+ emphasis: from.emphasis || to.emphasis,
409
+ };
410
+ }
411
+
412
+ /** An `a → b` cell as the factor `b ÷ a`. */
413
+ function arrowChange(cell: Cell | undefined): ChartPoint | undefined {
414
+ if (!isNumber(cell) || cell.to === undefined || cell.value <= 0 || cell.to < 0) return undefined;
415
+ return { value: cell.to / cell.value, text: `${cell.figure} → ${cell.toFigure ?? ""}`, emphasis: cell.emphasis };
416
+ }
417
+
418
+ /** The from and to names in an `a → b` header (`out tokens r5 → r6`), else `before` and `after`. */
419
+ function arrowNames(header: string): [string, string] {
420
+ const [from, to] = header.split(ARROW);
421
+ return from && to ? [from, to] : ["before", "after"];
422
+ }
423
+
284
424
  /** Same-unit aggregate columns (`Total`, `avg`) restate the series beside them. */
285
425
  function dropAggregates(measures: readonly TableColumn[]): readonly TableColumn[] {
286
426
  return measures.filter(