@oh-my-pi/pi-tui 18.6.3 → 18.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -109
- package/dist/types/app-keybindings.d.ts +1 -1
- package/dist/types/apps/debug/log-viewer.d.ts +4 -0
- package/dist/types/apps/debug/raw-sse-buffer.d.ts +2 -0
- package/dist/types/autocomplete.d.ts +5 -2
- package/dist/types/charts/chart-plan.d.ts +92 -0
- package/dist/types/charts/chart-svg.d.ts +30 -0
- package/dist/types/charts/table-data.d.ts +107 -0
- package/dist/types/chat/assistant-message.d.ts +4 -0
- package/dist/types/chat/chat-transcript-builder.d.ts +2 -0
- package/dist/types/chat/fence-figure.d.ts +42 -0
- package/dist/types/chat/figure-markdown.d.ts +42 -0
- package/dist/types/chat/svg-figure.d.ts +33 -0
- package/dist/types/chat/svg-source.d.ts +49 -0
- package/dist/types/chat/table-chart.d.ts +88 -0
- package/dist/types/chat/tool-execution.d.ts +2 -0
- package/dist/types/components/editor.d.ts +9 -4
- package/dist/types/components/image.d.ts +9 -0
- package/dist/types/components/markdown.d.ts +16 -2
- package/dist/types/fuzzy.d.ts +29 -1
- package/dist/types/keybindings.d.ts +1 -1
- package/dist/types/native/backend.d.ts +2 -1
- package/dist/types/native/blobs.d.ts +4 -2
- package/dist/types/native/encode.d.ts +3 -4
- package/dist/types/overlays/agent-transcript-viewer.d.ts +8 -1
- package/dist/types/overlays/agents-hub.d.ts +9 -0
- package/dist/types/overlays/annotation-overlay.d.ts +5 -2
- package/dist/types/overlays/history-search.d.ts +10 -0
- package/dist/types/overlays/model-browser.d.ts +31 -3
- package/dist/types/overlays/plan-review-overlay.d.ts +3 -2
- package/dist/types/prompt/custom-editor.d.ts +7 -4
- package/dist/types/prompt/interactive-context-helpers.d.ts +4 -1
- package/dist/types/prompt/model-mention-autocomplete.d.ts +1 -1
- package/dist/types/render/composite.d.ts +2 -0
- package/dist/types/tools/glob.d.ts +4 -0
- package/dist/types/tools/renderer.d.ts +22 -0
- package/dist/types/tools/streaming-output.d.ts +11 -1
- package/dist/types/tools/write.d.ts +8 -1
- package/dist/types/utils.d.ts +6 -0
- package/package.json +9 -9
- package/src/apps/debug/log-viewer.ts +188 -72
- package/src/apps/debug/raw-sse-buffer.ts +8 -0
- package/src/apps/debug/raw-sse.ts +39 -5
- package/src/apps/git/diff-pane.ts +26 -5
- package/src/autocomplete.ts +67 -43
- package/src/charts/chart-plan.ts +367 -0
- package/src/charts/chart-svg.ts +693 -0
- package/src/charts/table-data.ts +436 -0
- package/src/chat/assistant-message.ts +169 -39
- package/src/chat/chat-transcript-builder.ts +4 -0
- package/src/chat/fence-figure.ts +80 -0
- package/src/chat/figure-markdown.ts +174 -0
- package/src/chat/svg-figure.ts +362 -0
- package/src/chat/svg-source.ts +188 -0
- package/src/chat/table-chart.ts +301 -0
- package/src/chat/tool-execution.ts +53 -4
- package/src/chrome/transcript-container.ts +7 -1
- package/src/components/editor.ts +68 -33
- package/src/components/image.ts +27 -1
- package/src/components/markdown.ts +80 -304
- package/src/fuzzy.ts +115 -30
- package/src/keybindings.ts +1 -1
- package/src/native/backend.ts +170 -15
- package/src/native/blobs.ts +4 -2
- package/src/native/encode.ts +58 -31
- package/src/overlays/agent-hub-projection.ts +70 -2
- package/src/overlays/agent-hub.ts +3 -2
- package/src/overlays/agent-transcript-viewer.ts +66 -13
- package/src/overlays/agents-hub.ts +28 -8
- package/src/overlays/annotation-overlay.ts +179 -80
- package/src/overlays/ask-dialog.ts +1 -1
- package/src/overlays/copy-selector.ts +30 -8
- package/src/overlays/extensions/state-manager.ts +52 -12
- package/src/overlays/history-search.ts +100 -7
- package/src/overlays/jobs-panel.ts +30 -2
- package/src/overlays/model-browser.ts +269 -90
- package/src/overlays/model-hub.ts +34 -7
- package/src/overlays/oauth-selector.ts +41 -26
- package/src/overlays/plan-review-overlay.ts +183 -68
- package/src/overlays/rewind-selector.ts +86 -17
- package/src/overlays/session-selector.ts +24 -4
- package/src/overlays/settings-selector.ts +21 -6
- package/src/overlays/tree-selector.ts +20 -4
- package/src/overlays/usage-dashboard.ts +12 -15
- package/src/prompt/attachment-chips.ts +2 -1
- package/src/prompt/composer-attachments.ts +6 -4
- package/src/prompt/composer.ts +2 -14
- package/src/prompt/custom-editor.ts +48 -19
- package/src/prompt/interactive-context-helpers.ts +30 -18
- package/src/prompt/macos-spelling.ts +7 -0
- package/src/prompt/magic-keywords.ts +11 -2
- package/src/prompt/model-mention-autocomplete.ts +14 -14
- package/src/prompt/word-completion.ts +38 -5
- package/src/render/composite.ts +62 -0
- package/src/setup/scenes/splash.ts +109 -45
- package/src/status-line/component.ts +220 -64
- package/src/status-line/segments.ts +8 -3
- package/src/terminal.ts +3 -2
- package/src/tools/ast-edit.ts +59 -35
- package/src/tools/ast-grep.ts +35 -16
- package/src/tools/default-renderer.ts +35 -19
- package/src/tools/eval-format/index.ts +28 -2
- package/src/tools/eval-format/javascript.ts +7 -5
- package/src/tools/eval.ts +164 -78
- package/src/tools/glob.ts +23 -6
- package/src/tools/grep.ts +81 -48
- package/src/tools/lsp.ts +81 -59
- package/src/tools/mcp.ts +98 -68
- package/src/tools/renderer.ts +27 -0
- package/src/tools/streaming-output.ts +120 -48
- package/src/tools/task.ts +111 -54
- package/src/tools/todo.ts +115 -29
- package/src/tools/wait.ts +34 -24
- package/src/tools/write.ts +77 -39
- package/src/tui.ts +118 -105
- package/src/utils.ts +19 -0
|
@@ -21,7 +21,7 @@ import { centerLine, sliceWithWidth, truncateToWidth, visibleWidth } from "../..
|
|
|
21
21
|
import { formatBytes } from "@oh-my-pi/pi-utils";
|
|
22
22
|
import { sanitizeDisplayText } from "../../overlays/extensions/display-text";
|
|
23
23
|
import { getLanguageFromPath } from "../../lang-from-path";
|
|
24
|
-
import { createHighlightStream, theme } from "../../theme/theme";
|
|
24
|
+
import { createHighlightStream, getThemeEpoch, theme } from "../../theme/theme";
|
|
25
25
|
import { bgAnsiHex, canvasHex, fgAnsiHex, mixHex, pill, selectionBgAnsi, textHex, withBg } from "./colors";
|
|
26
26
|
import { DIFF_CONTEXT_LINES, type FileAssetSide, type FileStreamUpdate } from "./state";
|
|
27
27
|
|
|
@@ -502,9 +502,12 @@ interface DiffPalette {
|
|
|
502
502
|
gutterDel: string;
|
|
503
503
|
}
|
|
504
504
|
|
|
505
|
-
let paletteCache: {
|
|
505
|
+
let paletteCache: { epoch: number; theme: object; palette: DiffPalette } | undefined;
|
|
506
506
|
|
|
507
|
+
/** Diff colors for the active theme, rebuilt only when the theme instance or epoch changes. */
|
|
507
508
|
function palette(): DiffPalette {
|
|
509
|
+
const epoch = getThemeEpoch();
|
|
510
|
+
if (paletteCache?.epoch === epoch && paletteCache.theme === theme) return paletteCache.palette;
|
|
508
511
|
const added = theme.getColorHex("toolDiffAdded");
|
|
509
512
|
const removed = theme.getColorHex("toolDiffRemoved");
|
|
510
513
|
const accent = theme.getColorHex("accent");
|
|
@@ -512,8 +515,6 @@ function palette(): DiffPalette {
|
|
|
512
515
|
const dark = luminance === undefined || luminance <= 0.5;
|
|
513
516
|
const canvas = canvasHex();
|
|
514
517
|
const text = textHex();
|
|
515
|
-
const key = `${added}\u0000${removed}\u0000${accent}\u0000${dark}\u0000${canvas}\u0000${text}`;
|
|
516
|
-
if (paletteCache?.key === key) return paletteCache.palette;
|
|
517
518
|
const built: DiffPalette = {
|
|
518
519
|
addSoft: bgAnsiHex(mixHex(canvas, added, dark ? 0.18 : 0.24)),
|
|
519
520
|
addStrong: bgAnsiHex(mixHex(canvas, added, dark ? 0.42 : 0.48)),
|
|
@@ -529,7 +530,7 @@ function palette(): DiffPalette {
|
|
|
529
530
|
gutterAdd: added,
|
|
530
531
|
gutterDel: removed,
|
|
531
532
|
};
|
|
532
|
-
paletteCache = {
|
|
533
|
+
paletteCache = { epoch, theme, palette: built };
|
|
533
534
|
return built;
|
|
534
535
|
}
|
|
535
536
|
|
|
@@ -599,6 +600,10 @@ export class DiffPane {
|
|
|
599
600
|
#lastHeight = 1;
|
|
600
601
|
#lastWidth = 0;
|
|
601
602
|
#layoutCache: { key: string; visuals: Visual[] } | undefined;
|
|
603
|
+
/** Minimap column for (layout, height, scroll offset, palette). */
|
|
604
|
+
#minimapCache:
|
|
605
|
+
| { visuals: Visual[]; height: number; scrollTop: number; colors: DiffPalette; lines: string[] }
|
|
606
|
+
| undefined;
|
|
602
607
|
/** Per visible row: clickable hunk-button ranges recorded during render. */
|
|
603
608
|
#hits: ({ hunk: number; primary?: [number, number]; discard?: [number, number] } | undefined)[] = [];
|
|
604
609
|
/**
|
|
@@ -1488,6 +1493,22 @@ export class DiffPane {
|
|
|
1488
1493
|
}
|
|
1489
1494
|
|
|
1490
1495
|
#renderMinimap(visuals: Visual[], height: number, colors: DiffPalette): string[] {
|
|
1496
|
+
const cached = this.#minimapCache;
|
|
1497
|
+
if (
|
|
1498
|
+
cached &&
|
|
1499
|
+
cached.visuals === visuals &&
|
|
1500
|
+
cached.height === height &&
|
|
1501
|
+
cached.scrollTop === this.scrollTop &&
|
|
1502
|
+
cached.colors === colors
|
|
1503
|
+
) {
|
|
1504
|
+
return cached.lines;
|
|
1505
|
+
}
|
|
1506
|
+
const lines = this.#buildMinimap(visuals, height, colors);
|
|
1507
|
+
this.#minimapCache = { visuals, height, scrollTop: this.scrollTop, colors, lines };
|
|
1508
|
+
return lines;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
#buildMinimap(visuals: Visual[], height: number, colors: DiffPalette): string[] {
|
|
1491
1512
|
const total = visuals.length;
|
|
1492
1513
|
const lines: string[] = [];
|
|
1493
1514
|
const bandKind = (band: number): RowKind | "hunk" | null => {
|
package/src/autocomplete.ts
CHANGED
|
@@ -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
|
-
/**
|
|
267
|
-
|
|
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).
|
|
@@ -589,6 +592,22 @@ function buildMidPromptSkillCompletions(commands: CommandEntry[], lowerPrefix: s
|
|
|
589
592
|
);
|
|
590
593
|
}
|
|
591
594
|
|
|
595
|
+
const DIR_CACHE_MAX = 100;
|
|
596
|
+
const DIR_CACHE_EVICT = 50;
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Drops the `count` oldest entries once `map` exceeds `max`. Callers delete a
|
|
600
|
+
* key before re-setting it, so Map insertion order is refresh order.
|
|
601
|
+
*/
|
|
602
|
+
function evictOldest(map: Map<string, unknown>, max: number, count: number): void {
|
|
603
|
+
if (map.size <= max) return;
|
|
604
|
+
let remaining = count;
|
|
605
|
+
for (const key of map.keys()) {
|
|
606
|
+
if (remaining-- === 0) break;
|
|
607
|
+
map.delete(key);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
592
611
|
// Combined provider that handles both slash commands and file paths.
|
|
593
612
|
export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
594
613
|
#commands: CommandEntry[];
|
|
@@ -598,6 +617,9 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
598
617
|
// per-directory readdir fast-path for prefix completions. Global fuzzy
|
|
599
618
|
// discovery continues to use native fuzzyFind + shared scan cache.
|
|
600
619
|
#dirCache: Map<string, { entries: fs.Dirent[]; timestamp: number }> = new Map();
|
|
620
|
+
// Whether a scoped fuzzy query's base (`src/` in `@src/foo`) is a directory;
|
|
621
|
+
// stat'ing it on every keystroke is redundant while the base is unchanged.
|
|
622
|
+
#scopedBaseCache: Map<string, { isDirectory: boolean; timestamp: number }> = new Map();
|
|
601
623
|
readonly #DIR_CACHE_TTL = 2000; // 2 seconds
|
|
602
624
|
|
|
603
625
|
constructor(
|
|
@@ -806,7 +828,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
806
828
|
};
|
|
807
829
|
}
|
|
808
830
|
|
|
809
|
-
// Slash command suggestions can be accepted before
|
|
831
|
+
// Slash command suggestions can be accepted before an async refresh
|
|
810
832
|
// catches up to newly typed characters. Replace the live command token,
|
|
811
833
|
// not only the prefix captured when the suggestion list was rendered.
|
|
812
834
|
// Absolute-path completions share the leading-slash prefix shape but
|
|
@@ -976,11 +998,21 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
976
998
|
baseDir = path.join(this.#basePath, displayBase);
|
|
977
999
|
}
|
|
978
1000
|
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1001
|
+
const now = Date.now();
|
|
1002
|
+
let cachedBase = this.#scopedBaseCache.get(baseDir);
|
|
1003
|
+
if (!cachedBase || now - cachedBase.timestamp >= this.#DIR_CACHE_TTL) {
|
|
1004
|
+
let isDirectory = false;
|
|
1005
|
+
try {
|
|
1006
|
+
isDirectory = (await fs.promises.stat(baseDir)).isDirectory();
|
|
1007
|
+
} catch {
|
|
1008
|
+
// Missing or inaccessible base: not a scope.
|
|
982
1009
|
}
|
|
983
|
-
|
|
1010
|
+
cachedBase = { isDirectory, timestamp: now };
|
|
1011
|
+
this.#scopedBaseCache.delete(baseDir);
|
|
1012
|
+
this.#scopedBaseCache.set(baseDir, cachedBase);
|
|
1013
|
+
evictOldest(this.#scopedBaseCache, DIR_CACHE_MAX, DIR_CACHE_EVICT);
|
|
1014
|
+
}
|
|
1015
|
+
if (!cachedBase.isDirectory) {
|
|
984
1016
|
return null;
|
|
985
1017
|
}
|
|
986
1018
|
|
|
@@ -1003,17 +1035,9 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
1003
1035
|
}
|
|
1004
1036
|
|
|
1005
1037
|
const entries = await fs.promises.readdir(searchDir, { withFileTypes: true });
|
|
1038
|
+
this.#dirCache.delete(searchDir);
|
|
1006
1039
|
this.#dirCache.set(searchDir, { entries, timestamp: now });
|
|
1007
|
-
|
|
1008
|
-
if (this.#dirCache.size > 100) {
|
|
1009
|
-
const sortedKeys = [...this.#dirCache.entries()]
|
|
1010
|
-
.sort((a, b) => a[1].timestamp - b[1].timestamp)
|
|
1011
|
-
.slice(0, 50)
|
|
1012
|
-
.map(([key]) => key);
|
|
1013
|
-
for (const key of sortedKeys) {
|
|
1014
|
-
this.#dirCache.delete(key);
|
|
1015
|
-
}
|
|
1016
|
-
}
|
|
1040
|
+
evictOldest(this.#dirCache, DIR_CACHE_MAX, DIR_CACHE_EVICT);
|
|
1017
1041
|
|
|
1018
1042
|
return entries;
|
|
1019
1043
|
}
|
|
@@ -1021,8 +1045,10 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
1021
1045
|
invalidateDirCache(dir?: string): void {
|
|
1022
1046
|
if (dir) {
|
|
1023
1047
|
this.#dirCache.delete(dir);
|
|
1048
|
+
this.#scopedBaseCache.delete(dir);
|
|
1024
1049
|
} else {
|
|
1025
1050
|
this.#dirCache.clear();
|
|
1051
|
+
this.#scopedBaseCache.clear();
|
|
1026
1052
|
}
|
|
1027
1053
|
}
|
|
1028
1054
|
|
|
@@ -1087,26 +1113,29 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
1087
1113
|
const entries = await this.#getCachedDirEntries(searchDir);
|
|
1088
1114
|
const suggestions: AutocompleteItem[] = [];
|
|
1089
1115
|
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1116
|
+
const lowerSearchPrefix = searchPrefix.toLowerCase();
|
|
1117
|
+
// Skip .git directory
|
|
1118
|
+
const matched = entries.filter(
|
|
1119
|
+
entry => entry.name !== ".git" && entry.name.toLowerCase().startsWith(lowerSearchPrefix),
|
|
1120
|
+
);
|
|
1121
|
+
// Directory flag per match; symlinks are stat'ed concurrently to learn whether
|
|
1122
|
+
// they point at a directory. `null` drops a broken symlink, a file deleted
|
|
1123
|
+
// between readdir and stat, or a permission error.
|
|
1124
|
+
const directoryFlags = await Promise.all(
|
|
1125
|
+
matched.map(entry =>
|
|
1126
|
+
!entry.isDirectory() && entry.isSymbolicLink()
|
|
1127
|
+
? fs.promises.stat(path.join(searchDir, entry.name)).then(
|
|
1128
|
+
stats => stats.isDirectory(),
|
|
1129
|
+
() => null,
|
|
1130
|
+
)
|
|
1131
|
+
: entry.isDirectory(),
|
|
1132
|
+
),
|
|
1133
|
+
);
|
|
1098
1134
|
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
if (
|
|
1102
|
-
|
|
1103
|
-
const fullPath = path.join(searchDir, entry.name);
|
|
1104
|
-
isDirectory = (await fs.promises.stat(fullPath)).isDirectory();
|
|
1105
|
-
} catch {
|
|
1106
|
-
// Broken symlink, file deleted between readdir and stat, or permission error
|
|
1107
|
-
continue;
|
|
1108
|
-
}
|
|
1109
|
-
}
|
|
1135
|
+
for (let index = 0; index < matched.length; index++) {
|
|
1136
|
+
const isDirectory = directoryFlags[index];
|
|
1137
|
+
if (typeof isDirectory !== "boolean") continue;
|
|
1138
|
+
const entry = matched[index]!;
|
|
1110
1139
|
|
|
1111
1140
|
let relativePath: string;
|
|
1112
1141
|
const name = entry.name;
|
|
@@ -1300,19 +1329,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
1300
1329
|
const slashStart = findLeadingSlashCommandStart(textBeforeCursor);
|
|
1301
1330
|
if (slashStart === null) return null;
|
|
1302
1331
|
const commandText = textBeforeCursor.slice(slashStart);
|
|
1303
|
-
if (commandText.length <= 1) return null; // Bare "/" alone, don't auto-complete
|
|
1304
1332
|
if (commandText.includes(" ")) return null; // Only complete command name, not args
|
|
1305
1333
|
|
|
1306
|
-
const
|
|
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.
|
|
1334
|
+
const lowerPrefix = commandText.slice(1).toLowerCase();
|
|
1311
1335
|
const matches = buildSlashCommandCompletions(
|
|
1312
1336
|
collapseSkillNamespace(this.#commands, lowerPrefix),
|
|
1313
1337
|
lowerPrefix,
|
|
1314
1338
|
this.#commandUsage,
|
|
1315
|
-
)
|
|
1339
|
+
);
|
|
1316
1340
|
|
|
1317
1341
|
if (matches.length === 0) return null;
|
|
1318
1342
|
// 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
|
+
}
|