@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
@@ -32,6 +32,20 @@ export declare class FuzzyText {
32
32
  /** Match `query` (space-separated tokens; all must match) against the prepared text. */
33
33
  match(query: string): FuzzyMatch;
34
34
  }
35
+ /**
36
+ * Items prepared once for repeated {@link fuzzyRank}-equivalent ranking.
37
+ *
38
+ * `fuzzyRank` resolves each candidate's search index through the module cache,
39
+ * which stops admitting entries at {@link INDEX_CACHE_MAX}; larger candidate
40
+ * lists (the full model catalog) rebuild the overflow on every query. Hold one
41
+ * corpus per stable candidate list and call {@link rank} per keystroke instead.
42
+ */
43
+ export declare class FuzzyCorpus<T> {
44
+ #private;
45
+ constructor(items: readonly T[], getText: (item: T) => string);
46
+ /** Same results as `fuzzyRank(items, query, getText)` for the prepared items. */
47
+ rank(query: string): FuzzyFilterResult<T>[];
48
+ }
35
49
  /**
36
50
  * Filter and sort items by fuzzy match quality (best matches first).
37
51
  * Supports space-separated tokens: all tokens must match.
@@ -107,7 +107,7 @@ export declare const TUI_KEYBINDINGS: {
107
107
  readonly description: "Delete word backward";
108
108
  };
109
109
  readonly "tui.editor.deleteWordForward": {
110
- readonly defaultKeys: ["alt+delete", "alt+d", "super+alt+delete", "super+alt+d"];
110
+ readonly defaultKeys: ["alt+delete", "alt+d", "ctrl+delete", "super+alt+delete", "super+alt+d"];
111
111
  readonly description: "Delete word forward";
112
112
  };
113
113
  readonly "tui.editor.deleteToLineStart": {
@@ -60,6 +60,15 @@ export interface AgentsHubDeps {
60
60
  export interface AgentsHubCallbacks {
61
61
  onCancel: () => void;
62
62
  }
63
+ /**
64
+ * Parse model output into a {@link GeneratedAgentSpec}. Accepts a bare JSON object
65
+ * (tried first) or one wrapped in a code fence. Returns the spec with trimmed fields.
66
+ * Throws if the output is not valid JSON or not an object, if `identifier`, `whenToUse`
67
+ * or `systemPrompt` is missing or not a string, if the identifier is not lowercase
68
+ * kebab-case with 2+ words, if `whenToUse` does not start with "Use this agent when",
69
+ * or if `systemPrompt` is empty.
70
+ */
71
+ export declare function parseGeneratedAgentSpec(raw: string): GeneratedAgentSpec;
63
72
  /**
64
73
  * The fullscreen agents hub component. Hosted via
65
74
  * `ui.showOverlay(..., { fullscreen: true })`; the host must call
@@ -1,4 +1,4 @@
1
- import { type Component, type TUI } from "../index.js";
1
+ import { type Focusable, type TUI } from "../index.js";
2
2
  import { type KeybindingsManager } from "../app-keybindings.js";
3
3
  import { type Theme } from "../theme/theme.js";
4
4
  import type { CodeReviewAnnotation, CodeReviewOverlayResult, ReviewDiffFile, TextReviewAnnotation, TextReviewOverlayResult, TextReviewSource } from "./annotation-types.js";
@@ -18,10 +18,13 @@ export interface TextReviewOverlayCallbacks extends AnnotationOverlayCallbacks {
18
18
  onComplete(result: TextReviewOverlayResult | undefined): void;
19
19
  }
20
20
  /** A fullscreen, annotated diff picker that only relies on public OMP APIs. */
21
- export declare class AnnotationOverlay implements Component {
21
+ export declare class AnnotationOverlay implements Focusable {
22
22
  #private;
23
+ focused: boolean;
23
24
  constructor(tui: TUI, theme: Theme, keybindings: KeybindingsManager, files: readonly ReviewDiffFile[], mode: string, callbacks: CodeReviewOverlayCallbacks);
24
25
  constructor(tui: TUI, theme: Theme, keybindings: KeybindingsManager, source: TextReviewSource, callbacks: TextReviewOverlayCallbacks);
26
+ /** Keep the nested editor's cursor mode aligned with the overlay focus target. */
27
+ setUseTerminalCursor(useTerminalCursor: boolean): void;
25
28
  invalidate(): void;
26
29
  dispose(): void;
27
30
  getAnnotations(): CodeReviewAnnotation[];
@@ -49,6 +49,12 @@ export interface ModelBrowserSource extends ModelRoleLookup {
49
49
  readonly knownRoleIds: readonly string[];
50
50
  readonly mruOrder: readonly string[];
51
51
  readonly modelPerf: ReadonlyMap<string, ModelBrowserPerf>;
52
+ /**
53
+ * Service tier the host would send for `model`, when it runs one. Rows with a
54
+ * measured tier aggregate (`selector@tier`) then display that tier's numbers
55
+ * instead of the standard aggregate.
56
+ */
57
+ serviceTierFor?(model: Model): string | undefined;
52
58
  getRoleInfo(role: string): ModelBrowserRoleInfo;
53
59
  defaultRoleChain(role: string): string[];
54
60
  resolveRoleValue(value: string | undefined, models: Model[], roleLookup?: ModelRoleLookup): ResolvedModelRoleValue;
@@ -134,12 +140,28 @@ export interface SearchAffinity {
134
140
  * policy, not evidence of user preference.
135
141
  */
136
142
  export declare function buildSearchAffinity(providerOrder: ReadonlyArray<string>, roles: RoleAssignments, mruOrder: ReadonlyArray<string>): SearchAffinity;
137
- /** Rank picker and mention candidates by text relevance, user affinity, and MRU/version order. */
138
- export declare function rankModelItems(query: string, items: ReadonlyArray<ModelBrowserItem>, options: {
143
+ /** Inputs to {@link rankModelItems} / {@link ModelItemRanker}. */
144
+ export interface ModelRankOptions {
139
145
  roles: RoleAssignments;
140
146
  mruOrder: ReadonlyArray<string>;
141
147
  affinity: SearchAffinity;
142
- }): ModelBrowserItem[];
148
+ }
149
+ /**
150
+ * {@link rankModelItems} prepared for one candidate list: the fuzzy index, the
151
+ * MRU/version order, and the affinity ranks depend only on the items and options,
152
+ * so per-keystroke callers (composer mentions, `/switch` arguments, the picker)
153
+ * hold one ranker and pay only the per-query fuzzy scan.
154
+ */
155
+ export declare class ModelItemRanker {
156
+ #private;
157
+ readonly items: ReadonlyArray<ModelBrowserItem>;
158
+ readonly options: ModelRankOptions;
159
+ constructor(items: ReadonlyArray<ModelBrowserItem>, options: ModelRankOptions);
160
+ /** Rank by text relevance, user affinity, and MRU/version order. */
161
+ rank(query: string): ModelBrowserItem[];
162
+ }
163
+ /** Rank picker and mention candidates by text relevance, user affinity, and MRU/version order. */
164
+ export declare function rankModelItems(query: string, items: ReadonlyArray<ModelBrowserItem>, options: ModelRankOptions): ModelBrowserItem[];
143
165
  /**
144
166
  * A slim role chip: `● default ◉` — solid dot for configured assignments,
145
167
  * hollow for auto-selected fallbacks, thinking glyph attached when set.
@@ -13,8 +13,9 @@
13
13
  * Focus regions (`toc`/`body`/`actions`) cycle with Tab/Shift+Tab; arrows move
14
14
  * within the focused region and step left into the sidebar. The default focus is
15
15
  * `actions`, so the muscle memory of the old single-target overlay carries over:
16
- * ↑/↓ select options, Enter confirms, ←/→ drives the slider when there is no
17
- * sidebar, g/G + PgUp/PgDn scroll, and the external-editor key opens the plan.
16
+ * ↑/↓ select options, Enter confirms, ←/→ drives the slider, g/G + PgUp/PgDn
17
+ * scroll, and the external-editor key opens the plan. Under Tern the options
18
+ * are a horizontal bar, so ←/→ select options and Shift+←/→ drive the slider.
18
19
  */
19
20
  import { type Component } from "../index.js";
20
21
  import type { HookSelectorSlider } from "./hook-selector.js";
@@ -17,6 +17,8 @@ export interface AssistantMessageHost {
17
17
  readonly proseOnlyThinking: boolean;
18
18
  readonly expandThinkingBlocks: boolean;
19
19
  readonly assistantImagesVisible: boolean;
20
+ /** Whether the viewed session's tables get charts: the main session's do, a focused subagent's do not. */
21
+ readonly tableChartsVisible: boolean;
20
22
  readonly hideToolActivity: boolean;
21
23
  readonly toolOutputExpanded: boolean;
22
24
  readonly ui: {
@@ -19,7 +19,7 @@ export declare function applyModelMentionCompletion(lines: string[], cursorLine:
19
19
  };
20
20
  /**
21
21
  * Create a session-scoped model candidate lookup using picker ordering. The
22
- * scope and search affinity are reused across queries until their inputs change.
22
+ * scope and its prepared ranker are reused across queries until their inputs change.
23
23
  */
24
24
  export declare function createModelMentionSource(host: {
25
25
  source: ModelBrowserSource;
@@ -0,0 +1,2 @@
1
+ /** Splice overlay content into a base line at a specific column. Single-pass optimized. */
2
+ export declare function compositeLineAt(baseLine: string, overlayLine: string, startCol: number, overlayWidth: number, totalWidth: number): string;
@@ -12,6 +12,10 @@ export interface GlobToolDetails {
12
12
  fileCount?: number;
13
13
  files?: string[];
14
14
  truncated?: boolean;
15
+ /** The scan hit the tool deadline, so the listed files are an incomplete
16
+ * set rather than a complete answer. Distinct from `truncated`, which also
17
+ * covers result-limit and output truncation. */
18
+ timedOut?: boolean;
15
19
  error?: string;
16
20
  /** Working directory at search time. Used by the renderer to resolve relative
17
21
  * file paths to absolute paths for OSC 8 hyperlinks. */
@@ -26,6 +26,12 @@ export interface TextSizingOptions {
26
26
  */
27
27
  export declare function encodeTextSized(text: string, options?: TextSizingOptions): string;
28
28
  export declare function sliceWithWidth(line: string, startCol: number, length: number, strict?: boolean | null): SliceResult;
29
+ /**
30
+ * Hard-wrap one literal terminal row without trimming spaces or splitting graphemes.
31
+ * An oversized grapheme is emitted intact even when it exceeds the requested width.
32
+ * Zero-cell rows remain one empty row, matching restored-terminal reflow.
33
+ */
34
+ export declare function wrapLiteralLine(line: string, width: number): string[];
29
35
  export declare function truncateToWidth(text: string, maxWidth: number, ellipsisKind?: Ellipsis | null | "", pad?: boolean | null): string;
30
36
  export declare function wrapTextWithAnsi(text: string, width: number): string[];
31
37
  export declare function extractSegments(line: string, beforeEnd: number, afterStart: number, afterLen: number, strictAfter: boolean): ExtractSegmentsResult;
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.6.3",
4
+ "version": "18.7.0",
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.6.3",
45
- "@oh-my-pi/pi-agent-core": "18.6.3",
46
- "@oh-my-pi/pi-ai": "18.6.3",
47
- "@oh-my-pi/pi-catalog": "18.6.3",
48
- "@oh-my-pi/pi-natives": "18.6.3",
49
- "@oh-my-pi/pi-utils": "18.6.3",
50
- "@oh-my-pi/pi-wire": "18.6.3",
51
- "@oh-my-pi/snapcompact": "18.6.3"
44
+ "@oh-my-pi/omptype": "18.7.0",
45
+ "@oh-my-pi/pi-agent-core": "18.7.0",
46
+ "@oh-my-pi/pi-ai": "18.7.0",
47
+ "@oh-my-pi/pi-catalog": "18.7.0",
48
+ "@oh-my-pi/pi-natives": "18.7.0",
49
+ "@oh-my-pi/pi-utils": "18.7.0",
50
+ "@oh-my-pi/pi-wire": "18.7.0",
51
+ "@oh-my-pi/snapcompact": "18.7.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "kitty-vt-wasm": "^0.2.0"
@@ -263,8 +263,11 @@ export interface AutocompleteProvider {
263
263
 
264
264
  /** Get inline hint text to show as dim ghost text after the cursor */
265
265
  getInlineHint?(lines: string[], cursorLine: number, cursorCol: number): string | null;
266
- /** Synchronously try to complete a slash command at the start of a line (no async I/O). */
267
- /** Returns matched items and the full prefix, or null if not applicable. */
266
+ /**
267
+ * Synchronously list slash command-name completions for a leading slash token (no async I/O).
268
+ * Mirrors the command-name branch of {@link getSuggestions}, including the bare `/` listing and
269
+ * the collapsed `/skill:` namespace row. Returns null outside a command-name token or with no match.
270
+ */
268
271
  trySyncSlashCompletion?(textBeforeCursor: string): { items: AutocompleteItem[]; prefix: string } | null;
269
272
  /**
270
273
  * Synchronously try to expand text immediately before the cursor (no async I/O).
@@ -806,7 +809,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
806
809
  };
807
810
  }
808
811
 
809
- // Slash command suggestions can be accepted before the debounced refresh
812
+ // Slash command suggestions can be accepted before an async refresh
810
813
  // catches up to newly typed characters. Replace the live command token,
811
814
  // not only the prefix captured when the suggestion list was rendered.
812
815
  // Absolute-path completions share the leading-slash prefix shape but
@@ -1300,19 +1303,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
1300
1303
  const slashStart = findLeadingSlashCommandStart(textBeforeCursor);
1301
1304
  if (slashStart === null) return null;
1302
1305
  const commandText = textBeforeCursor.slice(slashStart);
1303
- if (commandText.length <= 1) return null; // Bare "/" alone, don't auto-complete
1304
1306
  if (commandText.includes(" ")) return null; // Only complete command name, not args
1305
1307
 
1306
- const prefix = commandText.slice(1);
1307
- const lowerPrefix = prefix.toLowerCase();
1308
-
1309
- // The `/skill:` namespace row is excluded here: the sync path submits
1310
- // immediately after applying, and the bare namespace is not a command.
1308
+ const lowerPrefix = commandText.slice(1).toLowerCase();
1311
1309
  const matches = buildSlashCommandCompletions(
1312
1310
  collapseSkillNamespace(this.#commands, lowerPrefix),
1313
1311
  lowerPrefix,
1314
1312
  this.#commandUsage,
1315
- ).filter(item => item.value !== SKILL_NAMESPACE);
1313
+ );
1316
1314
 
1317
1315
  if (matches.length === 0) return null;
1318
1316
  // Mirror `getSuggestions`: preserve leading whitespace so the editor's
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Which chart a table becomes and the data it plots. {@link planChart} is the
3
+ * local best guess over a {@link TableAnalysis}: the chart kind, the label
4
+ * column and the measure columns to draw. A host may substitute its own
5
+ * {@link ChartPlan} (a model's pick for a multi-series table); either way
6
+ * {@link buildChart} turns it into the {@link ChartSpec} that
7
+ * {@link renderChartSvg} draws, and {@link worthCharting} decides whether
8
+ * the picture says more than the table already does.
9
+ *
10
+ * The heuristics were fitted on ~7k tables from real assistant answers: the
11
+ * label is the first text column (index columns like `#`/`Rank` never are),
12
+ * table order is kept (most tables are deliberately unsorted), total rows stay
13
+ * out of the scale, and units never share an axis.
14
+ */
15
+ import {
16
+ type Dimension,
17
+ isMonotonic,
18
+ isNumber,
19
+ type NumberCell,
20
+ type TableAnalysis,
21
+ type TableColumn,
22
+ } from "./table-data";
23
+
24
+ /**
25
+ * Chart kinds, by the table shapes they fit:
26
+ * - `bar`: one measure across categories.
27
+ * - `paired`: two same-unit measures per category (before/after, A vs B), or `a → b` cells.
28
+ * - `grouped`: three or four same-unit measures per category.
29
+ * - `line`: measures over an ordered axis (dates, runs, sizes).
30
+ * - `heatmap`: a matrix of same-unit values.
31
+ * - `multiples`: measures in different units, each scaled on its own; transposed when each row is its own metric.
32
+ * - `share`: parts of a whole (percentages summing to 100).
33
+ * - `diverging`: signed changes around zero.
34
+ * - `scatter`: two continuous measures per item.
35
+ */
36
+ export type ChartKind =
37
+ | "bar"
38
+ | "paired"
39
+ | "grouped"
40
+ | "line"
41
+ | "heatmap"
42
+ | "multiples"
43
+ | "share"
44
+ | "diverging"
45
+ | "scatter";
46
+
47
+ /** What to draw from a table: the kind, the column naming each category, and the columns plotted. */
48
+ export interface ChartPlan {
49
+ readonly kind: ChartKind;
50
+ /** Column naming each category (or holding the x values of a line); rows are numbered without one. */
51
+ readonly label: number | undefined;
52
+ /** Plotted columns, in order. */
53
+ readonly series: readonly number[];
54
+ /** Each data row is its own series (a metric with its own unit) and the plotted columns are the categories. */
55
+ readonly transpose: boolean;
56
+ }
57
+
58
+ /** One plotted value. */
59
+ export interface ChartPoint {
60
+ /** Value in base units (seconds, bytes, …). */
61
+ readonly value: number;
62
+ /** The number as the table wrote it (`~$250`, `12.5 ms`), shown as the value label. */
63
+ readonly text: string;
64
+ /** The table bolded the cell. */
65
+ readonly emphasis: boolean;
66
+ /** Upper end of a range cell (`24–72 h`). */
67
+ readonly upper?: number;
68
+ }
69
+
70
+ export interface ChartSeries {
71
+ readonly name: string;
72
+ readonly dim: Dimension;
73
+ /** Currency symbol or rate unit the axis prints; empty for other dimensions. */
74
+ readonly unit: string;
75
+ /** One per category; `null` where the cell holds no number of this series' dimension. */
76
+ readonly points: readonly (ChartPoint | null)[];
77
+ /** Status words standing in for a value (`TIMEOUT`, `OOM`), one per category. */
78
+ readonly notes: readonly (string | undefined)[];
79
+ }
80
+
81
+ /** The data a chart draws, independent of how it is drawn. */
82
+ export interface ChartSpec {
83
+ readonly kind: ChartKind;
84
+ readonly categories: readonly string[];
85
+ readonly series: readonly ChartSeries[];
86
+ /** Numeric x positions of a line or the x measure of a scatter, one per category. */
87
+ readonly x?: {
88
+ readonly name: string;
89
+ readonly dim: Dimension;
90
+ readonly unit: string;
91
+ readonly values: readonly number[];
92
+ };
93
+ /** Total/summary rows, printed under the chart instead of skewing its scale. */
94
+ readonly caption?: string;
95
+ /** Name of the category axis (the label column's header). */
96
+ readonly axis: string;
97
+ }
98
+
99
+ const BEFORE =
100
+ /\b(?:before|old|baseline|base|main|master|prev|previous|original|orig|cold|was|from|v1|stock|default|unpatched|without)\b/i;
101
+ const AFTER = /\b(?:after|new|patched|optimi[sz]ed|fixed|warm|now|to|v2|with|proposed|branch|pr|ours)\b/i;
102
+ const DELTA = /(?:Δ|delta|change|diff|improvement|speedup|reduction|savings|saved|gain|regression|% chg)/i;
103
+ /** Aggregate columns that restate the others; dropped when enough same-unit series remain. */
104
+ const AGGREGATE = /^(?:total|sum|cumulative|cum\.?|running total|avg|average|mean)\b/i;
105
+ /** Row labels that name an ordered step (`Run 3`, `Day 2`, `N=1000`, `Q3`). */
106
+ const SEQUENCE_LABEL =
107
+ /^(?:run|day|week|month|iter(?:ation)?|round|step|phase|epoch|v|version|n\s*=|wave|attempt|pass|batch|q[1-4]|#)\s*\d/i;
108
+ const MONTH = /^(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b/i;
109
+ /** Most panels a small-multiples grid stays legible with. */
110
+ const MAX_PANELS = 6;
111
+ /** Most lines one line chart keeps apart. */
112
+ const MAX_LINES = 6;
113
+
114
+ /** The local best guess at a chart for `table`, or `undefined` when no chart fits. */
115
+ export function planChart(table: TableAnalysis): ChartPlan | undefined {
116
+ const { rows } = table;
117
+ let label = table.label;
118
+ let measures = dropAggregates(table.measures);
119
+ if (measures.length === 0 || rows.length < 2) return undefined;
120
+
121
+ let order = axisOrder(table, label);
122
+ // Without a name column, a monotonic leading measure (`Workers | p50 | p99`) is the
123
+ // x axis of the rest; beside one it is just a sorted measure (`% | Self | Function`).
124
+ if (!order && label?.role !== "label" && measures.length >= 2 && measures[0]!.index === 0 && rows.length >= 4) {
125
+ const xs = columnValues(measures[0]!, rows);
126
+ if (xs.length === rows.length && isMonotonic(xs)) {
127
+ label = measures[0];
128
+ measures = measures.slice(1);
129
+ order = "numeric";
130
+ }
131
+ }
132
+ const plan = (kind: ChartKind, series: readonly TableColumn[], transpose = false): ChartPlan => ({
133
+ kind,
134
+ label: label?.index,
135
+ series: series.map(column => column.index),
136
+ transpose,
137
+ });
138
+
139
+ const mixed = measures.filter(column => column.mixed);
140
+ 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
+
143
+ const dims = new Set(measures.map(column => column.dim));
144
+ if (dims.size === 1 && measures.length >= 2 && measures.length <= 4) {
145
+ const before = measures.find(column => BEFORE.test(column.header));
146
+ const after = measures.find(column => column !== before && AFTER.test(column.header));
147
+ if (before && after) return plan("paired", [before, after]);
148
+ }
149
+ if (rows.length === 2 && measures.length === 1) return undefined;
150
+ if (order && rows.length >= 4) {
151
+ const lead = measures[0]!.dim;
152
+ return plan("line", measures.filter(column => column.dim === lead).slice(0, MAX_LINES));
153
+ }
154
+ if (measures.length <= 2) {
155
+ const delta = measures.find(
156
+ column =>
157
+ (column.signedShare >= 0.6 || DELTA.test(column.header)) &&
158
+ columnValues(column, rows).some(value => value < 0),
159
+ );
160
+ if (delta) return plan("diverging", [delta]);
161
+ }
162
+ const share = measures.find(column => column.dim === "percent");
163
+ if (share && rows.length <= 10 && (measures.length === 1 || (measures.length === 2 && dims.size === 2))) {
164
+ const values = columnValues(share, rows);
165
+ const sum = values.reduce((total, value) => total + value, 0);
166
+ if (values.every(value => value >= 0) && sum >= 97 && sum <= 103) return plan("share", [share]);
167
+ }
168
+ if (dims.size === 1 && measures.length >= 4 && rows.length >= 3) return plan("heatmap", measures);
169
+ if (!label && measures.length === 2 && rows.length >= 6) return plan("scatter", measures);
170
+ if (dims.size >= 2) return plan("multiples", measures.slice(0, MAX_PANELS));
171
+ if (measures.length === 2) return plan("paired", measures);
172
+ if (measures.length >= 3) return plan("grouped", measures);
173
+ return plan("bar", measures);
174
+ }
175
+
176
+ /**
177
+ * The data `plan` draws from `table`, or `undefined` when it leaves fewer than
178
+ * two categories or no series with two values. The kind is fitted to the
179
+ * series it ends up with ({@link fitKind}), so any plan — a model's pick
180
+ * included — draws as something the renderer supports.
181
+ */
182
+ export function buildChart(table: TableAnalysis, plan: ChartPlan): ChartSpec | undefined {
183
+ const label = plan.label === undefined ? undefined : table.columns[plan.label];
184
+ const columns = plan.series.map(index => table.columns[index]).filter(column => column !== undefined);
185
+ if (columns.length === 0) return undefined;
186
+ const rowName = (row: number, at: number) => (label ? label.cells[row]!.text : "") || `#${at + 1}`;
187
+
188
+ let categories: string[];
189
+ let series: ChartSeries[];
190
+ if (plan.transpose) {
191
+ categories = columns.map(column => column.header);
192
+ series = table.rows.map((row, at) => rowSeries(rowName(row, at), columns, row));
193
+ } else if (plan.kind === "paired" && columns.length === 1) {
194
+ // `a → b` cells: the column splits into its from and to values.
195
+ const column = columns[0]!;
196
+ const [from, to] = column.header.split(/\s*(?:→|->|⇒)\s*/);
197
+ const before = columnSeries(column, table.rows, from && to ? from : "before");
198
+ const after: ChartSeries = {
199
+ ...before,
200
+ name: from && to ? to : "after",
201
+ points: table.rows.map(row => {
202
+ const cell = column.cells[row];
203
+ return isNumber(cell) && cell.to !== undefined
204
+ ? { value: cell.to, text: cell.toFigure ?? "", emphasis: cell.emphasis }
205
+ : null;
206
+ }),
207
+ };
208
+ categories = table.rows.map(rowName);
209
+ series = [before, after];
210
+ } else {
211
+ categories = table.rows.map(rowName);
212
+ series = columns.map(column => columnSeries(column, table.rows, column.header));
213
+ }
214
+
215
+ let x: ChartSpec["x"];
216
+ if (plan.kind === "scatter" && series.length >= 2) {
217
+ const xs = series[0]!;
218
+ x = { name: xs.name, dim: xs.dim, unit: xs.unit, values: xs.points.map(entry => entry?.value ?? Number.NaN) };
219
+ series = [series[1]!];
220
+ } else if (plan.kind === "line" && label && !plan.transpose) {
221
+ const values = table.rows.map(row => label.cells[row]).map(cell => (isNumber(cell) ? cell.value : Number.NaN));
222
+ if (values.every(Number.isFinite) && isMonotonic(values)) {
223
+ const first = label.cells[table.rows[0]!];
224
+ x = {
225
+ name: label.header,
226
+ dim: isNumber(first) ? first.dim : "count",
227
+ unit: label.unit,
228
+ values,
229
+ };
230
+ }
231
+ }
232
+
233
+ series = series.filter(entry => entry.points.filter(point => point !== null).length >= 2);
234
+ if (categories.length < 2 || series.length === 0) return undefined;
235
+ const kind = plan.transpose ? "multiples" : fitKind(plan.kind, series, x !== undefined);
236
+ // A share or a delta reads one series; the rest would overdraw it.
237
+ if (kind === "share" || kind === "diverging") series = series.slice(0, 1);
238
+ return {
239
+ kind,
240
+ categories,
241
+ series,
242
+ x,
243
+ caption: totalsCaption(table, label, columns, plan.transpose),
244
+ axis: label?.header ?? "",
245
+ };
246
+ }
247
+
248
+ /**
249
+ * 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.
253
+ */
254
+ export function worthCharting(spec: ChartSpec): boolean {
255
+ if (spec.categories.length < 4) return false;
256
+ let points = 0;
257
+ let spread = 1;
258
+ for (const entry of spec.series) {
259
+ const positive: number[] = [];
260
+ for (const point of entry.points) {
261
+ if (!point) continue;
262
+ points++;
263
+ if (point.value > 0) positive.push(point.value);
264
+ }
265
+ if (positive.length >= 2) spread = Math.max(spread, Math.max(...positive) / Math.min(...positive));
266
+ }
267
+ return points >= 9 || spread >= 3;
268
+ }
269
+
270
+ /**
271
+ * The nearest kind `kind` draws as for `series`: a shared axis needs one unit
272
+ * (else small multiples), bars take one series, a dumbbell two, grouped bars
273
+ * up to four (more become a heatmap), and a scatter needs its x measure.
274
+ */
275
+ function fitKind(kind: ChartKind, series: readonly ChartSeries[], hasX: boolean): ChartKind {
276
+ if (kind === "multiples") return kind;
277
+ if (kind === "scatter" && hasX) return kind;
278
+ if (new Set(series.map(entry => `${entry.dim}:${entry.unit}`)).size > 1) return "multiples";
279
+ if (kind === "share" || kind === "diverging" || kind === "line" || kind === "heatmap") return kind;
280
+ const count = series.length;
281
+ return count === 1 ? "bar" : count === 2 ? "paired" : count <= 4 ? "grouped" : "heatmap";
282
+ }
283
+
284
+ /** Same-unit aggregate columns (`Total`, `avg`) restate the series beside them. */
285
+ function dropAggregates(measures: readonly TableColumn[]): readonly TableColumn[] {
286
+ return measures.filter(
287
+ column =>
288
+ !AGGREGATE.test(column.header) ||
289
+ measures.filter(other => other !== column && other.dim === column.dim).length < 2,
290
+ );
291
+ }
292
+
293
+ /** How the label column orders the rows: by time, by step, by a numeric size — or not at all. */
294
+ function axisOrder(
295
+ table: TableAnalysis,
296
+ label: TableColumn | undefined,
297
+ ): "temporal" | "sequence" | "numeric" | undefined {
298
+ if (!label) return undefined;
299
+ if (label.role === "temporal") return "temporal";
300
+ if (label.role === "sequence") return "sequence";
301
+ const names = table.rows.map(row => label.cells[row]!.text);
302
+ const mostly = (pattern: RegExp) => names.filter(name => pattern.test(name)).length >= names.length * 0.8;
303
+ if (mostly(SEQUENCE_LABEL)) return "sequence";
304
+ if (mostly(MONTH)) return "temporal";
305
+ const values = columnValues(label, table.rows);
306
+ if (values.length === names.length && values.length >= 3 && isMonotonic(values)) return "numeric";
307
+ return undefined;
308
+ }
309
+
310
+ function columnValues(column: TableColumn, rows: readonly number[]): number[] {
311
+ const values: number[] = [];
312
+ for (const row of rows) {
313
+ const cell = column.cells[row];
314
+ if (isNumber(cell)) values.push(cell.value);
315
+ }
316
+ return values;
317
+ }
318
+
319
+ /** A column as a series: its cells of the column's dimension, other cells as gaps (status words kept as notes). */
320
+ function columnSeries(column: TableColumn, rows: readonly number[], name: string): ChartSeries {
321
+ const points: (ChartPoint | null)[] = [];
322
+ const notes: (string | undefined)[] = [];
323
+ for (const row of rows) {
324
+ const cell = column.cells[row]!;
325
+ const fits = isNumber(cell) && cell.dim === column.dim;
326
+ points.push(fits ? point(cell) : null);
327
+ notes.push(!fits && cell.kind !== "missing" && !isNumber(cell) ? cell.text : undefined);
328
+ }
329
+ return { name, dim: column.dim ?? "count", unit: column.unit, points, notes };
330
+ }
331
+
332
+ /** One row of a metric-per-row table as a series over the plotted columns, in the row's dominant dimension. */
333
+ function rowSeries(name: string, columns: readonly TableColumn[], row: number): ChartSeries {
334
+ const cells = columns.map(column => column.cells[row]);
335
+ const tally = new Map<Dimension, number>();
336
+ for (const cell of cells) if (isNumber(cell)) tally.set(cell.dim, (tally.get(cell.dim) ?? 0) + 1);
337
+ let dim: Dimension = "count";
338
+ for (const [key, count] of tally) if (count > (tally.get(dim) ?? 0)) dim = key;
339
+ const unitCell = cells.find(cell => isNumber(cell) && cell.dim === dim);
340
+ return {
341
+ name,
342
+ dim,
343
+ unit: isNumber(unitCell) && (dim === "currency" || dim === "rate") ? unitCell.unit : "",
344
+ points: cells.map(cell => (isNumber(cell) && cell.dim === dim ? point(cell) : null)),
345
+ notes: cells.map(cell => (cell && !isNumber(cell) && cell.kind !== "missing" ? cell.text : undefined)),
346
+ };
347
+ }
348
+
349
+ /** A numeric cell as a plotted point labelled with its written figure. */
350
+ function point(cell: NumberCell): ChartPoint {
351
+ return { value: cell.value, text: cell.figure, emphasis: cell.emphasis, upper: cell.upper };
352
+ }
353
+
354
+ /** `Total: 2,964 · 2,310` for each summary row, over the plotted columns. */
355
+ function totalsCaption(
356
+ table: TableAnalysis,
357
+ label: TableColumn | undefined,
358
+ columns: readonly TableColumn[],
359
+ transpose: boolean,
360
+ ): string | undefined {
361
+ if (transpose || !label || table.totals.length === 0) return undefined;
362
+ const lines = table.totals.slice(0, 2).flatMap(row => {
363
+ const values = columns.map(column => column.cells[row]?.text ?? "").filter(Boolean);
364
+ return values.length ? [`${label.cells[row]!.text}: ${values.join(" · ")}`] : [];
365
+ });
366
+ return lines.length ? lines.join(" ") : undefined;
367
+ }