@linxiraos/pi-tui 1.1.26 → 1.2.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 (423) hide show
  1. package/CHANGELOG.md +51 -491
  2. package/README.md +13 -7
  3. package/dist/types/active-terminal.d.ts +30 -0
  4. package/dist/types/app-keybindings.d.ts +7 -2
  5. package/dist/types/apps/cleanse-board.d.ts +1 -1
  6. package/dist/types/apps/debug/log-viewer.d.ts +4 -0
  7. package/dist/types/apps/debug/raw-sse-buffer.d.ts +2 -0
  8. package/dist/types/apps/git/git-tui.d.ts +5 -4
  9. package/dist/types/apps/git/help.d.ts +3 -1
  10. package/dist/types/apps/if-bench-board.d.ts +2 -2
  11. package/dist/types/apps/setup-model-picker.d.ts +1 -1
  12. package/dist/types/autocomplete.d.ts +5 -2
  13. package/dist/types/charts/chart-plan.d.ts +674 -0
  14. package/dist/types/charts/chart-svg.d.ts +59 -0
  15. package/dist/types/charts/table-data.d.ts +175 -0
  16. package/dist/types/chat/assistant-message.d.ts +11 -1
  17. package/dist/types/chat/chat-transcript-builder.d.ts +3 -0
  18. package/dist/types/chat/compaction-summary-message.d.ts +1 -0
  19. package/dist/types/chat/execution-shared.d.ts +6 -5
  20. package/dist/types/chat/fence-figure.d.ts +42 -0
  21. package/dist/types/chat/figure-markdown.d.ts +42 -0
  22. package/dist/types/chat/messages.d.ts +1 -1
  23. package/dist/types/chat/svg-figure.d.ts +33 -0
  24. package/dist/types/chat/svg-source.d.ts +49 -0
  25. package/dist/types/chat/table-chart.d.ts +89 -0
  26. package/dist/types/chat/tool-execution.d.ts +2 -0
  27. package/dist/types/chat/user-message.d.ts +17 -4
  28. package/dist/types/chrome/shared.d.ts +9 -0
  29. package/dist/types/chrome/status-notice.d.ts +15 -3
  30. package/dist/types/chrome/transcript-container.d.ts +6 -0
  31. package/dist/types/components/box.d.ts +1 -0
  32. package/dist/types/components/disclosure.d.ts +1 -0
  33. package/dist/types/components/editor.d.ts +23 -5
  34. package/dist/types/components/form.d.ts +1 -0
  35. package/dist/types/components/image.d.ts +26 -44
  36. package/dist/types/components/input.d.ts +2 -1
  37. package/dist/types/components/key-value-list.d.ts +1 -0
  38. package/dist/types/components/layout/row.d.ts +1 -0
  39. package/dist/types/components/layout/stack.d.ts +1 -0
  40. package/dist/types/components/loader.d.ts +5 -6
  41. package/dist/types/components/markdown.d.ts +47 -5
  42. package/dist/types/components/scroll-view.d.ts +1 -0
  43. package/dist/types/components/section.d.ts +1 -0
  44. package/dist/types/components/table.d.ts +1 -0
  45. package/dist/types/components/text.d.ts +1 -0
  46. package/dist/types/components/tree-view.d.ts +7 -0
  47. package/dist/types/components/truncated-text.d.ts +1 -0
  48. package/dist/types/fuzzy.d.ts +29 -1
  49. package/dist/types/key-hint-format.d.ts +9 -1
  50. package/dist/types/keybinding-matchers.d.ts +3 -0
  51. package/dist/types/keybindings.d.ts +1 -1
  52. package/dist/types/kitty-graphics.d.ts +2 -2
  53. package/dist/types/loop-watchdog.d.ts +8 -7
  54. package/dist/types/multiplexers/cmux.d.ts +16 -0
  55. package/dist/types/multiplexers/herdr.d.ts +26 -0
  56. package/dist/types/multiplexers/notify.d.ts +13 -0
  57. package/dist/types/multiplexers/orca.d.ts +12 -0
  58. package/dist/types/multiplexers/screen.d.ts +9 -0
  59. package/dist/types/multiplexers/tmux.d.ts +29 -0
  60. package/dist/types/multiplexers/types.d.ts +100 -0
  61. package/dist/types/multiplexers/wmux.d.ts +12 -0
  62. package/dist/types/multiplexers/zellij.d.ts +12 -0
  63. package/dist/types/native/apply.d.ts +4 -3
  64. package/dist/types/native/backend.d.ts +5 -4
  65. package/dist/types/native/blobs.d.ts +7 -3
  66. package/dist/types/native/encode.d.ts +8 -6
  67. package/dist/types/native/node.d.ts +36 -5
  68. package/dist/types/native/overlay.d.ts +8 -3
  69. package/dist/types/native/settle.d.ts +4 -3
  70. package/dist/types/overlays/agent-hub-projection.d.ts +6 -5
  71. package/dist/types/overlays/agent-hub-types.d.ts +9 -0
  72. package/dist/types/overlays/agent-hub.d.ts +3 -0
  73. package/dist/types/overlays/agent-transcript-viewer.d.ts +12 -2
  74. package/dist/types/overlays/agents-hub.d.ts +9 -0
  75. package/dist/types/overlays/annotation-overlay.d.ts +19 -2
  76. package/dist/types/overlays/annotation-types.d.ts +2 -0
  77. package/dist/types/overlays/ask-dialog.d.ts +5 -2
  78. package/dist/types/overlays/btw-history-panel.d.ts +13 -1
  79. package/dist/types/overlays/copy-selector.d.ts +1 -0
  80. package/dist/types/overlays/history-search.d.ts +10 -0
  81. package/dist/types/overlays/hook-selector.d.ts +9 -4
  82. package/dist/types/overlays/hub-frame.d.ts +4 -1
  83. package/dist/types/overlays/jobs-panel.d.ts +1 -7
  84. package/dist/types/overlays/model-browser.d.ts +54 -7
  85. package/dist/types/overlays/model-hub.d.ts +37 -0
  86. package/dist/types/overlays/oauth-selector.d.ts +0 -1
  87. package/dist/types/overlays/plan-review-overlay.d.ts +3 -2
  88. package/dist/types/overlays/report-panel.d.ts +68 -0
  89. package/dist/types/overlays/rewind-selector.d.ts +1 -0
  90. package/dist/types/overlays/session-observer-registry.d.ts +2 -0
  91. package/dist/types/overlays/session-selector.d.ts +6 -2
  92. package/dist/types/overlays/settings-defs.d.ts +1 -0
  93. package/dist/types/overlays/thinking-selector.d.ts +2 -2
  94. package/dist/types/overlays/usage-dashboard.d.ts +12 -8
  95. package/dist/types/overlays/usage-display.d.ts +3 -1
  96. package/dist/types/prompt/composer-cache.d.ts +0 -16
  97. package/dist/types/prompt/composer.d.ts +18 -3
  98. package/dist/types/prompt/custom-editor.d.ts +25 -13
  99. package/dist/types/prompt/image-source.d.ts +2 -1
  100. package/dist/types/prompt/interactive-context-helpers.d.ts +5 -1
  101. package/dist/types/prompt/model-mention-autocomplete.d.ts +1 -1
  102. package/dist/types/prompt/welcome.d.ts +7 -2
  103. package/dist/types/render/composite.d.ts +2 -0
  104. package/dist/types/render/output-pane.d.ts +1 -0
  105. package/dist/types/render/render-utils.d.ts +1 -0
  106. package/dist/types/render/tool-card.d.ts +6 -0
  107. package/dist/types/render/width-aware-text.d.ts +2 -0
  108. package/dist/types/setup/scenes/types.d.ts +1 -0
  109. package/dist/types/space-hold.d.ts +40 -25
  110. package/dist/types/status-line/component.d.ts +3 -0
  111. package/dist/types/status-line/context-usage.d.ts +7 -5
  112. package/dist/types/status-line/host.d.ts +4 -1
  113. package/dist/types/status-line/segments.d.ts +2 -0
  114. package/dist/types/status-line/types.d.ts +14 -3
  115. package/dist/types/terminal-capabilities.d.ts +41 -36
  116. package/dist/types/terminal-multiplexer.d.ts +127 -14
  117. package/dist/types/terminal.d.ts +23 -16
  118. package/dist/types/theme/active-symbols.d.ts +1 -1
  119. package/dist/types/theme/schema.d.ts +11 -2
  120. package/dist/types/theme/symbols.d.ts +1 -1
  121. package/dist/types/theme/theme-class.d.ts +7 -0
  122. package/dist/types/theme/theme.d.ts +1 -1
  123. package/dist/types/theme/tui-adapters.d.ts +3 -0
  124. package/dist/types/thinking.d.ts +1 -1
  125. package/dist/types/tmux.d.ts +11 -4
  126. package/dist/types/tools/bash.d.ts +7 -2
  127. package/dist/types/tools/daemon.d.ts +1 -1
  128. package/dist/types/tools/eval.d.ts +22 -3
  129. package/dist/types/tools/glob.d.ts +4 -0
  130. package/dist/types/tools/native-view.d.ts +47 -0
  131. package/dist/types/tools/output-meta.d.ts +11 -1
  132. package/dist/types/tools/renderer.d.ts +37 -2
  133. package/dist/types/tools/streaming-output.d.ts +20 -1
  134. package/dist/types/tools/task.d.ts +2 -3
  135. package/dist/types/tools/write.d.ts +9 -2
  136. package/dist/types/tools/yield.d.ts +22 -0
  137. package/dist/types/tui.d.ts +36 -0
  138. package/dist/types/utils.d.ts +6 -0
  139. package/dist/types/vim.d.ts +19 -5
  140. package/dist/types/windows-input-mode.d.ts +33 -1
  141. package/package.json +13 -9
  142. package/src/active-terminal.d.ts +30 -0
  143. package/src/active-terminal.ts +80 -0
  144. package/src/app-keybindings.d.ts +7 -2
  145. package/src/app-keybindings.ts +7 -2
  146. package/src/apps/autoresearch-dashboard.ts +7 -3
  147. package/src/apps/cleanse-board.ts +2 -2
  148. package/src/apps/cleanse-picker.ts +1 -1
  149. package/src/apps/debug/log-viewer.ts +188 -72
  150. package/src/apps/debug/raw-sse-buffer.ts +8 -0
  151. package/src/apps/debug/raw-sse.ts +39 -5
  152. package/src/apps/git/diff-pane.ts +96 -23
  153. package/src/apps/git/git-tui.ts +23 -16
  154. package/src/apps/git/help.ts +29 -20
  155. package/src/apps/git/sidebar.ts +8 -3
  156. package/src/apps/if-bench-board.ts +2 -2
  157. package/src/apps/ps-top.ts +2 -2
  158. package/src/apps/setup-model-picker.ts +1 -1
  159. package/src/autocomplete.d.ts +5 -2
  160. package/src/autocomplete.ts +67 -43
  161. package/src/bracketed-paste.ts +10 -3
  162. package/src/charts/chart-plan.d.ts +674 -0
  163. package/src/charts/chart-plan.ts +5081 -0
  164. package/src/charts/chart-svg.d.ts +59 -0
  165. package/src/charts/chart-svg.ts +4657 -0
  166. package/src/charts/table-data.d.ts +175 -0
  167. package/src/charts/table-data.ts +1478 -0
  168. package/src/chat/advisor-message.ts +11 -0
  169. package/src/chat/assistant-message.d.ts +11 -1
  170. package/src/chat/assistant-message.ts +245 -54
  171. package/src/chat/chat-transcript-builder.d.ts +3 -0
  172. package/src/chat/chat-transcript-builder.ts +9 -3
  173. package/src/chat/compaction-summary-message.d.ts +1 -0
  174. package/src/chat/compaction-summary-message.ts +8 -0
  175. package/src/chat/execution-shared.d.ts +6 -5
  176. package/src/chat/execution-shared.ts +42 -25
  177. package/src/chat/fence-figure.d.ts +42 -0
  178. package/src/chat/fence-figure.ts +80 -0
  179. package/src/chat/figure-markdown.d.ts +42 -0
  180. package/src/chat/figure-markdown.ts +174 -0
  181. package/src/chat/messages.d.ts +1 -1
  182. package/src/chat/messages.ts +1 -1
  183. package/src/chat/read-tool-group.ts +6 -4
  184. package/src/chat/skill-message.ts +6 -0
  185. package/src/chat/svg-figure.d.ts +33 -0
  186. package/src/chat/svg-figure.ts +399 -0
  187. package/src/chat/svg-source.d.ts +49 -0
  188. package/src/chat/svg-source.ts +188 -0
  189. package/src/chat/table-chart.d.ts +89 -0
  190. package/src/chat/table-chart.ts +294 -0
  191. package/src/chat/tool-execution.d.ts +2 -0
  192. package/src/chat/tool-execution.ts +86 -7
  193. package/src/chat/user-message.d.ts +17 -4
  194. package/src/chat/user-message.ts +71 -8
  195. package/src/chrome/shared.ts +53 -0
  196. package/src/chrome/status-notice.ts +25 -7
  197. package/src/chrome/transcript-container.d.ts +6 -0
  198. package/src/chrome/transcript-container.ts +162 -19
  199. package/src/components/box.d.ts +1 -0
  200. package/src/components/box.ts +5 -0
  201. package/src/components/disclosure.d.ts +1 -0
  202. package/src/components/disclosure.ts +8 -0
  203. package/src/components/editor.d.ts +23 -5
  204. package/src/components/editor.ts +316 -47
  205. package/src/components/form.d.ts +1 -0
  206. package/src/components/form.ts +8 -0
  207. package/src/components/image.d.ts +26 -44
  208. package/src/components/image.ts +115 -152
  209. package/src/components/input.d.ts +2 -1
  210. package/src/components/input.ts +17 -7
  211. package/src/components/key-value-list.d.ts +1 -0
  212. package/src/components/key-value-list.ts +4 -0
  213. package/src/components/layout/row.d.ts +1 -0
  214. package/src/components/layout/row.ts +7 -0
  215. package/src/components/layout/stack.d.ts +1 -0
  216. package/src/components/layout/stack.ts +7 -0
  217. package/src/components/loader.d.ts +5 -6
  218. package/src/components/loader.ts +10 -17
  219. package/src/components/markdown.d.ts +47 -5
  220. package/src/components/markdown.ts +786 -303
  221. package/src/components/scroll-view.d.ts +1 -0
  222. package/src/components/scroll-view.ts +5 -0
  223. package/src/components/section.d.ts +1 -0
  224. package/src/components/section.ts +5 -0
  225. package/src/components/settings-list.ts +2 -0
  226. package/src/components/table.d.ts +1 -0
  227. package/src/components/table.ts +4 -0
  228. package/src/components/text.d.ts +1 -0
  229. package/src/components/text.ts +7 -0
  230. package/src/components/tree-view.d.ts +7 -0
  231. package/src/components/tree-view.ts +38 -8
  232. package/src/components/truncated-text.d.ts +1 -0
  233. package/src/components/truncated-text.ts +5 -0
  234. package/src/desktop-notify.ts +3 -2
  235. package/src/fuzzy.d.ts +29 -1
  236. package/src/fuzzy.ts +115 -30
  237. package/src/hotkeys-markdown.ts +9 -2
  238. package/src/key-hint-format.d.ts +9 -1
  239. package/src/key-hint-format.ts +26 -11
  240. package/src/keybinding-matchers.d.ts +3 -0
  241. package/src/keybinding-matchers.ts +6 -2
  242. package/src/keybindings.d.ts +1 -1
  243. package/src/keybindings.ts +1 -1
  244. package/src/kitty-graphics.d.ts +2 -2
  245. package/src/kitty-graphics.ts +11 -5
  246. package/src/loop-watchdog.d.ts +8 -7
  247. package/src/loop-watchdog.ts +18 -13
  248. package/src/multiplexers/cmux.d.ts +16 -0
  249. package/src/multiplexers/cmux.ts +29 -0
  250. package/src/multiplexers/herdr.d.ts +26 -0
  251. package/src/multiplexers/herdr.ts +64 -0
  252. package/src/multiplexers/notify.d.ts +13 -0
  253. package/src/multiplexers/notify.ts +24 -0
  254. package/src/multiplexers/orca.d.ts +12 -0
  255. package/src/multiplexers/orca.ts +16 -0
  256. package/src/multiplexers/screen.d.ts +9 -0
  257. package/src/multiplexers/screen.ts +15 -0
  258. package/src/multiplexers/tmux.d.ts +29 -0
  259. package/src/multiplexers/tmux.ts +40 -0
  260. package/src/multiplexers/types.d.ts +100 -0
  261. package/src/multiplexers/types.ts +110 -0
  262. package/src/multiplexers/wmux.d.ts +12 -0
  263. package/src/multiplexers/wmux.ts +16 -0
  264. package/src/multiplexers/zellij.d.ts +12 -0
  265. package/src/multiplexers/zellij.ts +24 -0
  266. package/src/native/apply.d.ts +4 -3
  267. package/src/native/apply.ts +4 -3
  268. package/src/native/backend.d.ts +5 -4
  269. package/src/native/backend.ts +199 -22
  270. package/src/native/blobs.d.ts +7 -3
  271. package/src/native/blobs.ts +47 -12
  272. package/src/native/encode.d.ts +8 -6
  273. package/src/native/encode.ts +76 -34
  274. package/src/native/node.d.ts +36 -5
  275. package/src/native/node.ts +31 -5
  276. package/src/native/overlay.d.ts +8 -3
  277. package/src/native/overlay.ts +17 -4
  278. package/src/native/reconcile.ts +16 -4
  279. package/src/native/settle.d.ts +4 -3
  280. package/src/native/settle.ts +4 -3
  281. package/src/overlays/agent-hub-projection.d.ts +6 -5
  282. package/src/overlays/agent-hub-projection.ts +124 -13
  283. package/src/overlays/agent-hub-types.d.ts +9 -0
  284. package/src/overlays/agent-hub-types.ts +6 -0
  285. package/src/overlays/agent-hub.ts +48 -29
  286. package/src/overlays/agent-transcript-viewer.ts +93 -23
  287. package/src/overlays/agents-hub.ts +28 -8
  288. package/src/overlays/annotation-overlay.ts +293 -90
  289. package/src/overlays/annotation-types.ts +2 -0
  290. package/src/overlays/ask-dialog.ts +10 -17
  291. package/src/overlays/btw-history-panel.ts +136 -39
  292. package/src/overlays/copy-selector.d.ts +1 -0
  293. package/src/overlays/copy-selector.ts +32 -8
  294. package/src/overlays/extensions/extension-dashboard.ts +4 -4
  295. package/src/overlays/extensions/state-manager.ts +52 -12
  296. package/src/overlays/history-search.ts +100 -7
  297. package/src/overlays/hook-selector.ts +12 -5
  298. package/src/overlays/hub-frame.ts +19 -1
  299. package/src/overlays/jobs-panel.ts +33 -52
  300. package/src/overlays/model-browser.ts +367 -122
  301. package/src/overlays/model-hub.ts +435 -59
  302. package/src/overlays/move-overlay.ts +28 -14
  303. package/src/overlays/oauth-selector.ts +54 -70
  304. package/src/overlays/pause-screen.ts +2 -2
  305. package/src/overlays/plan-review-overlay.ts +208 -70
  306. package/src/overlays/plugin-settings.ts +3 -3
  307. package/src/overlays/report-panel.ts +191 -0
  308. package/src/overlays/rewind-selector.ts +88 -17
  309. package/src/overlays/session-observer-registry.d.ts +2 -0
  310. package/src/overlays/session-observer-registry.ts +10 -2
  311. package/src/overlays/session-selector.ts +64 -26
  312. package/src/overlays/settings-defs.ts +5 -4
  313. package/src/overlays/settings-selector.ts +33 -8
  314. package/src/overlays/snapcompact-shape-preview.ts +2 -3
  315. package/src/overlays/stats-notice.ts +1 -1
  316. package/src/overlays/thinking-selector.ts +11 -10
  317. package/src/overlays/tree-selector.ts +41 -20
  318. package/src/overlays/usage-dashboard.ts +117 -58
  319. package/src/overlays/usage-display.ts +10 -4
  320. package/src/overlays/usage-row.ts +22 -8
  321. package/src/prompt/attachment-chips.ts +15 -11
  322. package/src/prompt/composer-attachments.ts +6 -4
  323. package/src/prompt/composer-cache.ts +7 -86
  324. package/src/prompt/composer.ts +37 -26
  325. package/src/prompt/custom-editor.ts +282 -105
  326. package/src/prompt/image-source.ts +2 -1
  327. package/src/prompt/interactive-context-helpers.ts +32 -18
  328. package/src/prompt/macos-spelling.ts +7 -0
  329. package/src/prompt/magic-keywords.ts +11 -2
  330. package/src/prompt/model-mention-autocomplete.ts +14 -14
  331. package/src/prompt/queued-messages.ts +6 -4
  332. package/src/prompt/tips.txt +13 -9
  333. package/src/prompt/welcome.ts +64 -46
  334. package/src/prompt/word-completion.ts +38 -5
  335. package/src/render/composite.d.ts +2 -0
  336. package/src/render/composite.ts +64 -0
  337. package/src/render/output-pane.d.ts +1 -0
  338. package/src/render/output-pane.ts +6 -0
  339. package/src/render/render-utils.d.ts +1 -0
  340. package/src/render/render-utils.ts +16 -1
  341. package/src/render/tool-card.d.ts +6 -0
  342. package/src/render/tool-card.ts +15 -0
  343. package/src/render/width-aware-text.d.ts +2 -0
  344. package/src/render/width-aware-text.ts +5 -0
  345. package/src/setup/scenes/model.ts +2 -1
  346. package/src/setup/scenes/types.ts +1 -0
  347. package/src/space-hold.d.ts +40 -25
  348. package/src/space-hold.ts +156 -52
  349. package/src/status-line/component.ts +292 -78
  350. package/src/status-line/context-usage.ts +21 -46
  351. package/src/status-line/host.ts +4 -1
  352. package/src/status-line/segments.ts +40 -22
  353. package/src/status-line/types.ts +15 -3
  354. package/src/terminal-capabilities.d.ts +41 -36
  355. package/src/terminal-capabilities.ts +137 -213
  356. package/src/terminal-multiplexer.d.ts +127 -14
  357. package/src/terminal-multiplexer.ts +112 -33
  358. package/src/terminal.d.ts +23 -16
  359. package/src/terminal.ts +203 -99
  360. package/src/theme/active-symbols.d.ts +1 -1
  361. package/src/theme/active-symbols.ts +1 -1
  362. package/src/theme/schema-validation.ts +1 -0
  363. package/src/theme/schema.d.ts +11 -2
  364. package/src/theme/schema.ts +14 -1
  365. package/src/theme/session-color.ts +1 -7
  366. package/src/theme/symbols.d.ts +1 -1
  367. package/src/theme/symbols.ts +11 -4
  368. package/src/theme/theme-class.d.ts +7 -0
  369. package/src/theme/theme-class.ts +26 -1
  370. package/src/theme/theme-schema.json +5 -1
  371. package/src/theme/theme.d.ts +1 -1
  372. package/src/theme/theme.ts +8 -4
  373. package/src/theme/tui-adapters.d.ts +3 -0
  374. package/src/theme/tui-adapters.ts +68 -0
  375. package/src/thinking.d.ts +1 -1
  376. package/src/thinking.ts +1 -1
  377. package/src/tmux.d.ts +11 -4
  378. package/src/tmux.ts +47 -11
  379. package/src/tools/ast-edit.ts +59 -35
  380. package/src/tools/ast-grep.ts +35 -16
  381. package/src/tools/bash.d.ts +7 -2
  382. package/src/tools/bash.ts +116 -47
  383. package/src/tools/daemon.d.ts +1 -1
  384. package/src/tools/daemon.ts +1 -1
  385. package/src/tools/default-renderer.ts +35 -19
  386. package/src/tools/edit.ts +142 -120
  387. package/src/tools/eval-format/index.ts +28 -2
  388. package/src/tools/eval-format/javascript.ts +7 -5
  389. package/src/tools/eval.d.ts +22 -3
  390. package/src/tools/eval.ts +364 -208
  391. package/src/tools/fetch.ts +5 -6
  392. package/src/tools/glob.d.ts +4 -0
  393. package/src/tools/glob.ts +33 -65
  394. package/src/tools/grep.ts +81 -48
  395. package/src/tools/index.ts +2 -0
  396. package/src/tools/lsp.ts +81 -59
  397. package/src/tools/mcp.ts +111 -68
  398. package/src/tools/native-view.d.ts +47 -0
  399. package/src/tools/native-view.ts +102 -1
  400. package/src/tools/output-meta.d.ts +11 -1
  401. package/src/tools/output-meta.ts +23 -3
  402. package/src/tools/read.ts +7 -5
  403. package/src/tools/renderer.d.ts +37 -2
  404. package/src/tools/renderer.ts +42 -2
  405. package/src/tools/streaming-output.d.ts +20 -1
  406. package/src/tools/streaming-output.ts +154 -50
  407. package/src/tools/task.d.ts +2 -3
  408. package/src/tools/task.ts +115 -62
  409. package/src/tools/todo.ts +119 -30
  410. package/src/tools/wait.ts +34 -24
  411. package/src/tools/web-search.ts +1 -1
  412. package/src/tools/write.d.ts +9 -2
  413. package/src/tools/write.ts +147 -95
  414. package/src/tools/yield.d.ts +22 -0
  415. package/src/tools/yield.ts +225 -0
  416. package/src/tui.d.ts +36 -0
  417. package/src/tui.ts +666 -217
  418. package/src/utils.d.ts +6 -0
  419. package/src/utils.ts +19 -0
  420. package/src/vim.d.ts +19 -5
  421. package/src/vim.ts +333 -18
  422. package/src/windows-input-mode.d.ts +33 -1
  423. package/src/windows-input-mode.ts +212 -6
@@ -0,0 +1,1478 @@
1
+ /**
2
+ * Numeric reading of GFM table cells and columns, the input to
3
+ * {@link planChart}: what number a cell holds (in base units, with its
4
+ * dimension and annotations) and which role each column plays — a measure to
5
+ * plot, a category label, an ordered x axis, or a row index to ignore.
6
+ *
7
+ * Cells are model-written prose, not CSV: `~1,400`, `**4** ✅`, `17 (21.5%)`,
8
+ * `24–72 h`, `12 → 15`, `5/8`, `1m 56s`, `TIMEOUT`. The reader keeps the first
9
+ * number and its unit and classifies the rest, so a column of mostly clean
10
+ * numbers survives a few annotated or sentinel cells.
11
+ */
12
+
13
+ /** What a number measures; values of one dimension share an axis after unit normalization. */
14
+ export type Dimension = "count" | "percent" | "duration" | "bytes" | "currency" | "ratio" | "fraction" | "rate";
15
+
16
+ /**
17
+ * How cleanly a cell reads as its number: bare (`12 ms`), with a short note
18
+ * (`17 (21.5%)`, `16 pass`), a range (`24–72 h`), a transition (`12 → 15`), a
19
+ * fraction (`5/8`), or a number leading prose (`~1,400w, nearly ready`).
20
+ */
21
+ export type CellFit = "pure" | "note" | "range" | "arrow" | "fraction" | "prose";
22
+
23
+ /** A cell that reads as a number. */
24
+ export interface NumberCell {
25
+ readonly kind: "number";
26
+ /** Value in base units: seconds, bytes, a plain count, percent points. */
27
+ readonly value: number;
28
+ readonly dim: Dimension;
29
+ /** Unit or currency symbol as written (`ms`, `KiB`, `$`, `tok/s`); empty for bare numbers. */
30
+ readonly unit: string;
31
+ readonly fit: CellFit;
32
+ /** Written as an estimate or bound (`~`, `≈`, `<`, `≥`). */
33
+ readonly approx: boolean;
34
+ /** Written with an explicit sign (`+12%`, `-3`). */
35
+ readonly signed: boolean;
36
+ /** The cell was bolded: models bold the value they want noticed. */
37
+ readonly emphasis: boolean;
38
+ /** The cell text without Markdown decoration. */
39
+ readonly text: string;
40
+ /** The number as written, with approximation, sign, currency and unit (`~$250`, `12.5 ms`, `24–72 h`). */
41
+ readonly figure: string;
42
+ /** `a → b`: `b`, in the same base units. */
43
+ readonly to?: number;
44
+ /** `a → b`: `b` as written. */
45
+ readonly toFigure?: string;
46
+ /** `a–b`: `b`, in the same base units. */
47
+ readonly upper?: number;
48
+ /**
49
+ * `a/b`: `b`. {@link analyzeTable} reads a score (`12/12`, `154/160`, a mean
50
+ * like `0.6/4`) as the percent of `b` reached, unless its column also holds a
51
+ * plain fraction; a pair (`85 / 147` under `Edit / read calls`) keeps `a`.
52
+ */
53
+ readonly denominator?: number;
54
+ /**
55
+ * `a ± e` (`3.67s ± 0.69`, `~404ms ± 60`, `$0.72 (±0.16)`): `e`, in the same base units — the
56
+ * spread the table states around the value; {@link figure} then writes both (`3.67s ± 0.69`).
57
+ */
58
+ readonly error?: number;
59
+ /**
60
+ * A slash list of measurements in one unit (`79 / 124 µs`, `1,864 ms /
61
+ * 2,678 ms`, `**675ms** / 680ms`): every listed value, the first included,
62
+ * in base units, 2–4 of them. `value` is the first.
63
+ */
64
+ readonly values?: readonly number[];
65
+ }
66
+
67
+ /** A cell that is not a quantity. */
68
+ export interface OtherCell {
69
+ readonly kind: "missing" | "date" | "time" | "version" | "id" | "text";
70
+ readonly text: string;
71
+ readonly emphasis: boolean;
72
+ }
73
+
74
+ export type Cell = NumberCell | OtherCell;
75
+
76
+ /**
77
+ * What a column contributes to a chart: `measure` values to plot, `label`
78
+ * category names, `temporal` dates or times, `sequence` an ordered step
79
+ * (`Run`, `Day`, `Workers`), `index` a row number or identifier never plotted.
80
+ */
81
+ export type ColumnRole = "measure" | "label" | "temporal" | "sequence" | "index";
82
+
83
+ export interface TableColumn {
84
+ readonly index: number;
85
+ /** Header text without Markdown decoration. */
86
+ readonly header: string;
87
+ readonly role: ColumnRole;
88
+ readonly cells: readonly Cell[];
89
+ /** Dominant dimension of the numeric cells. */
90
+ readonly dim: Dimension | undefined;
91
+ /** Representative unit of the dominant dimension (`$`, `tok/s`); empty when bare. */
92
+ readonly unit: string;
93
+ /** Numeric cells disagree on dimension (a metric-per-row table). */
94
+ readonly mixed: boolean;
95
+ /** Numeric cells are mostly scores out of a total (`12/12`), read as percent of it. */
96
+ readonly scores: boolean;
97
+ /**
98
+ * Per row, the status its cell leads with ({@link cellStatus}), when most
99
+ * of the column's cells lead with one: a pass/fail or progress column.
100
+ */
101
+ readonly statuses?: readonly (Status | undefined)[];
102
+ /** Every numeric data cell holds the same value: an echoed setting, not a measure. */
103
+ readonly constant: boolean;
104
+ /** Share of numeric cells written with an explicit sign. */
105
+ readonly signedShare: number;
106
+ /** Share of numeric cells written as `a → b`. */
107
+ readonly arrowShare: number;
108
+ /**
109
+ * Index of the measure column this one restates, read from the values: a
110
+ * proportional copy (`pass` 33/38 beside `pass%` 87%, `Samples` beside `% of
111
+ * Total`, `Leverage vs $200` beside the cost) or a running total of it
112
+ * (`Offset` summing `Verts`). Such a column is never a measure of its own.
113
+ */
114
+ readonly restates?: number;
115
+ }
116
+
117
+ /**
118
+ * Measure columns that add up, read from the values: leaf `parts` summing to
119
+ * `whole` in every row (`Cached + Uncached input = Input`, `Input + Output =
120
+ * Total` → parts Cached, Uncached input, Output of Total), or — when not
121
+ * `exact` — parts falling within a `Total` column, the rest unlisted (`New +
122
+ * Edited ≤ Prose total`).
123
+ */
124
+ export interface Composition {
125
+ /** Column the parts make up; absent when only a judge says the parts form a whole. */
126
+ readonly whole: number | undefined;
127
+ /** Leaf part columns, in table order. */
128
+ readonly parts: readonly number[];
129
+ /** The parts sum to the whole within the written precision in every row. */
130
+ readonly exact: boolean;
131
+ }
132
+
133
+ /** A table read for charting. */
134
+ export interface TableAnalysis {
135
+ readonly columns: readonly TableColumn[];
136
+ /**
137
+ * Category labels: the first text or temporal column not made only of codes
138
+ * (`#2233`, `00`), else the first text column, else a sequence or index column.
139
+ */
140
+ readonly label: TableColumn | undefined;
141
+ /** Plottable measure columns, constant ones excluded. */
142
+ readonly measures: readonly TableColumn[];
143
+ /** Data rows, in table order, without total/summary rows. */
144
+ readonly rows: readonly number[];
145
+ /** Total/summary rows (`Total`, `Average`, …), kept out of the scale. */
146
+ readonly totals: readonly number[];
147
+ /** Measure columns adding up to a whole, when the values show it. */
148
+ readonly composition?: Composition;
149
+ }
150
+
151
+ const NUMBER = String.raw`(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?(?:[eE][+-]?\d+)?|\.\d+`;
152
+ const CELL = new RegExp(
153
+ String.raw`^(?<approx>~|≈|<=|>=|<|>|≤|≥)?\s*(?<sign>[+\-−±])?\s*(?<cur>[$€£¥])?\s*(?<num>${NUMBER})(?<plus>\+(?=\s*[A-Za-zµμ%]))?\s*(?<unit>%|[A-Za-zµμ×]+(?:\/[A-Za-z]+)?|\/s)?(?<rest>.*)$`,
154
+ );
155
+ const FRACTION = new RegExp(String.raw`^(${NUMBER})\s*\/\s*(${NUMBER})(?![\d/])(.*)$`);
156
+ const RANGE_TAIL = new RegExp(String.raw`^[-–—]\s*[~≈]?\s*[$€£¥]?\s*(${NUMBER})\s*(%|[A-Za-zµμ×]+)?`);
157
+ const ARROW_TAIL = new RegExp(String.raw`^(?:→|->|⇒)\s*[~≈]?\s*([+\-−])?\s*[$€£¥]?\s*(${NUMBER})\s*(%|[A-Za-zµμ×]+)?`);
158
+ /** The stated spread after a value: `± 0.69`, `±.074`, `(±0.16)`, `± 2.9 t/s`. */
159
+ const ERROR_TAIL = new RegExp(String.raw`^\(?\s*±\s*(${NUMBER})\s*(%|[A-Za-zµμ]+(?:\/[A-Za-z]+)?)?\s*\)?$`);
160
+ const COMPOUND_DURATION =
161
+ /^(?:(\d+(?:\.\d+)?)\s*d)?\s*(?:(\d+(?:\.\d+)?)\s*h)?\s*(?:(\d+(?:\.\d+)?)\s*m(?:in)?)?\s*(?:(\d+(?:\.\d+)?)\s*s)?$/;
162
+ const DATE =
163
+ /^\d{4}-\d{2}(?:-\d{2})?(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?\b|^\d{1,2}\/\d{1,2}\/\d{2,4}$|^(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+\d{1,2}\b/i;
164
+ const TIME = /^\d{1,2}:\d{2}(?::\d{2})?(?:\.\d+)?\s*(?:am|pm)?$/i;
165
+ const VERSION = /^v?\d+\.\d+\.\d+|^v\d/i;
166
+ /** `#12`, a bare hex hash, a hash leading its subject (`42e18af0 (Responses API)`), `PR #3`, `L42`. */
167
+ const IDENTIFIER =
168
+ /^(?:#\d+|[0-9a-f]{7,40}$|(?=[0-9a-f]{7,40}\s)(?=\d*[a-f])(?=[a-f]*\d)[0-9a-f]{7,40}\s|PR\s*#?\d+|L\d+\b)/i;
169
+ /** One number of a slash list or a sum, as written: estimate mark, sign, currency, number, unit or noun. */
170
+ const ITEM = new RegExp(
171
+ String.raw`(?<approx>[~≈])?\s*(?<sign>[+\-−])?\s*(?<cur>[$€£¥])?\s*(?<num>${NUMBER})\s*(?<unit>%|[A-Za-zµμ×]+(?:\/[A-Za-z]+)?)?`,
172
+ "y",
173
+ );
174
+ /** Between the items of a slash list (`79 / 124 µs`, `9/47/85`). */
175
+ const LIST_SEPARATOR = /\s*\/\s*/y;
176
+ /** Between the terms of a sum (`27.8 GB + 4.1 GB`, `4 hooks + 9 overlays`). */
177
+ const SUM_SEPARATOR = /\s*\+\s*/y;
178
+ /** The total a sum is written to equal (`… = 14 files`). */
179
+ const SUM_TOTAL = /\s*=\s*/y;
180
+ /** A cell that is one Markdown link, bold or not, and nothing else. */
181
+ const LINKED = /^\s*(?:\*\*|__)?\[[^\]]*\]\([^)]*\)(?:\*\*|__)?\s*$/;
182
+ /** Most values one slash list holds; longer lists are prose. */
183
+ const MAX_LISTED_VALUES = 4;
184
+ /** Spread (max/min) within which a bare slash list repeats one measurement rather than listing different things. */
185
+ const ALIKE_SPREAD = 3;
186
+ /** Cell texts that stand for "no value". */
187
+ const MISSING: Readonly<Record<string, true>> = {
188
+ "": true,
189
+ "-": true,
190
+ "—": true,
191
+ "–": true,
192
+ "−": true,
193
+ "n/a": true,
194
+ na: true,
195
+ none: true,
196
+ "?": true,
197
+ "…": true,
198
+ "...": true,
199
+ null: true,
200
+ nan: true,
201
+ tbd: true,
202
+ "·": true,
203
+ x: true,
204
+ "✗": true,
205
+ };
206
+ /**
207
+ * What a status cell (`ok`, `FAIL`, `pending`, ✅) says about its row: `good`
208
+ * passed or landed, `bad` failed or broke, `warn` partly or flaky, `pending`
209
+ * not settled yet.
210
+ */
211
+ export type Status = "good" | "bad" | "warn" | "pending";
212
+ /** Leading status words, matched whole and case-insensitively. */
213
+ const STATUS_WORDS: Readonly<Record<string, Status>> = {
214
+ ok: "good",
215
+ pass: "good",
216
+ passed: "good",
217
+ passes: "good",
218
+ passing: "good",
219
+ success: "good",
220
+ succeeded: "good",
221
+ done: "good",
222
+ complete: "good",
223
+ completed: "good",
224
+ landed: "good",
225
+ merged: "good",
226
+ fixed: "good",
227
+ works: "good",
228
+ working: "good",
229
+ green: "good",
230
+ fail: "bad",
231
+ failed: "bad",
232
+ fails: "bad",
233
+ failing: "bad",
234
+ failure: "bad",
235
+ error: "bad",
236
+ errored: "bad",
237
+ broken: "bad",
238
+ timeout: "bad",
239
+ "timed out": "bad",
240
+ crash: "bad",
241
+ crashed: "bad",
242
+ oom: "bad",
243
+ panic: "bad",
244
+ red: "bad",
245
+ blocked: "bad",
246
+ truncated: "bad",
247
+ partial: "warn",
248
+ flaky: "warn",
249
+ degraded: "warn",
250
+ pending: "pending",
251
+ running: "pending",
252
+ waiting: "pending",
253
+ queued: "pending",
254
+ skipped: "pending",
255
+ wip: "pending",
256
+ "in progress": "pending",
257
+ };
258
+ /** Leading status marks. */
259
+ const STATUS_MARKS: readonly (readonly [RegExp, Status])[] = [
260
+ [/^[✅✓✔🟢]/u, "good"],
261
+ [/^[❌✗✘🔴]/u, "bad"],
262
+ [/^[⚠🟡]/u, "warn"],
263
+ [/^[⏳🕐]/u, "pending"],
264
+ ];
265
+ const STATUS_WORD = new RegExp(
266
+ String.raw`^(?:${Object.keys(STATUS_WORDS)
267
+ .sort((a, b) => b.length - a.length)
268
+ .join("|")})(?![\w-])`,
269
+ "i",
270
+ );
271
+
272
+ /**
273
+ * The status a cell's text leads with (`FAIL`, `✓ via mmdc`, `**pending**`,
274
+ * `1 ok`), or `undefined` for any other text. A count before the word
275
+ * (`3 ok`) still reads as the word's status.
276
+ */
277
+ export function cellStatus(text: string): Status | undefined {
278
+ const trimmed = plainCell(text).text.trim();
279
+ for (const [mark, status] of STATUS_MARKS) if (mark.test(trimmed)) return status;
280
+ const word = STATUS_WORD.exec(trimmed.replace(/^\d+\s+/, ""));
281
+ return word ? STATUS_WORDS[word[0].toLowerCase()] : undefined;
282
+ }
283
+
284
+ /** `~Sort of (bad coords)` in a column of ✓ and ✗: a tilde before words hedges the verdict, read as `warn`. */
285
+ const HEDGE = /^~\s*\p{L}/u;
286
+
287
+ /** Status and trend marks that decorate a value without changing it (`**4** ✅`, `⚠️ 12`, `6 ↓`, `32 ▲`). */
288
+ const MARKS = /[✅❌⚠🟢🔴🟡✓✗✔★⭐↑↓⬆⬇▲▼]|\uFE0E|\uFE0F/gu;
289
+ /** Footnote marks after a value (`165*`, `12 ms†`): the value stands as written. */
290
+ const FOOTNOTE = /^[*†‡]+$/;
291
+ /** A whole number written with a leading zero (`03`, `004,006`): a code or a list of codes, never a quantity. */
292
+ const ZERO_PADDED = /^0\d/;
293
+ /** HTML tags a Markdown cell may carry; other `<…>` (`<cwd>`, `<T>`) is literal text. */
294
+ const HTML_TAG =
295
+ /<\/?(?:a|abbr|b|br|code|del|details|div|em|i|img|ins|kbd|mark|p|s|small|span|strong|sub|summary|sup|u)\b[^>]*>/gi;
296
+
297
+ /** Unit → dimension and factor to base units. Matched case-insensitively unless listed in {@link CASED_UNITS}. */
298
+ const UNITS: Readonly<Record<string, readonly [Dimension, number]>> = {
299
+ "%": ["percent", 1],
300
+ pp: ["percent", 1],
301
+ pct: ["percent", 1],
302
+ ns: ["duration", 1e-9],
303
+ µs: ["duration", 1e-6],
304
+ μs: ["duration", 1e-6],
305
+ us: ["duration", 1e-6],
306
+ ms: ["duration", 1e-3],
307
+ s: ["duration", 1],
308
+ sec: ["duration", 1],
309
+ secs: ["duration", 1],
310
+ seconds: ["duration", 1],
311
+ min: ["duration", 60],
312
+ mins: ["duration", 60],
313
+ minutes: ["duration", 60],
314
+ h: ["duration", 3600],
315
+ hr: ["duration", 3600],
316
+ hrs: ["duration", 3600],
317
+ hours: ["duration", 3600],
318
+ d: ["duration", 86_400],
319
+ days: ["duration", 86_400],
320
+ bytes: ["bytes", 1],
321
+ kb: ["bytes", 1e3],
322
+ kib: ["bytes", 1024],
323
+ mb: ["bytes", 1e6],
324
+ mib: ["bytes", 1024 ** 2],
325
+ gb: ["bytes", 1e9],
326
+ gib: ["bytes", 1024 ** 3],
327
+ tb: ["bytes", 1e12],
328
+ tib: ["bytes", 1024 ** 4],
329
+ x: ["ratio", 1],
330
+ "×": ["ratio", 1],
331
+ k: ["count", 1e3],
332
+ bn: ["count", 1e9],
333
+ };
334
+ /**
335
+ * Units whose case decides their meaning: `B` bytes vs `b` billions, `M`
336
+ * millions vs `m` minutes; `G` and `T` are giga and tera of a count until
337
+ * {@link resolveMagnitudes} reads them as sizes.
338
+ */
339
+ const CASED_UNITS: Readonly<Record<string, readonly [Dimension, number]>> = {
340
+ B: ["bytes", 1],
341
+ b: ["count", 1e9],
342
+ M: ["count", 1e6],
343
+ m: ["duration", 60],
344
+ G: ["count", 1e9],
345
+ T: ["count", 1e12],
346
+ };
347
+
348
+ /**
349
+ * Header names of row numbers and identifiers: never plotted, never a category
350
+ * axis when text exists. A header ending in an identifier noun (`New version`,
351
+ * `Issue ID`) counts too; either only for plain numbers, so `PR | 20 ms` stays a measure.
352
+ */
353
+ const INDEX_HEADER =
354
+ /^(?:#|no\.?|n°|id|ids|rank|row|idx|index|line|lines?\s*#|ln|pr|pr\s*#|issue|commit|sha|hash|port|pid|code|exit\s*code|status|version|ver|ref|offset|priority|prio)$|\s(?:id|ids|version|ver|sha|hash|pid|port)$/i;
355
+ /** Header names of ordered steps: with monotonic values, a valid x axis for a line chart. */
356
+ const SEQUENCE_HEADER =
357
+ /^(?:step|phase|stage|round|tier|level|wave|pass|attempt|iter|iteration|epoch|run|batch|day|week|month|year|quarter|n|workers?|threads?|concurrency|jobs|depth|k)$/i;
358
+ /**
359
+ * Row labels that summarize the rows above them: `Total …` in any wording, an
360
+ * average only as a bare label (`Mean latency` is a metric, `Mean` a summary).
361
+ */
362
+ const TOTAL_ROW =
363
+ /^(?:(?:total|totals|sum|overall|grand total|subtotal|σ)\b|(?:average|avg|mean|median|all|combined|net)\s*(?:\(.*\))?:?$)/i;
364
+ /**
365
+ * Summary labels that say nothing else (`Total`, `**Grand total**`, `Median:`), which sum or average the
366
+ * rows above them wherever they stand. Longer names (`Total spend`, `Totals by agent`) and words that also
367
+ * name options (`all`, `net`, `combined`) summarize only at the table's start or end.
368
+ */
369
+ const BARE_TOTAL = /^(?:total|totals|sum|grand total|sub-?total|σ|average|avg|mean|median)\s*(?:\(.*\))?:?$/i;
370
+ /** Relative slack within which a total row's figure matches the sum of the rows above it. */
371
+ const APPROX_TOTAL = 0.005;
372
+
373
+ /**
374
+ * Strip inline Markdown from a cell: links keep their text; bold, italics,
375
+ * strikethrough, code spans and `<br>` keep their content.
376
+ */
377
+ export function plainCell(markdown: string): { text: string; emphasis: boolean } {
378
+ let text = markdown.replace(/\\\|/g, "|");
379
+ // A closed pair: `__advisor*.jsonl` holds underscores, not bold.
380
+ const emphasis = /(\*\*|__)(?=\S).*?\S\1/.test(text);
381
+ // Code spans are literal: `sessions/<cwd>/*.jsonl` keeps its `<cwd>` and `*`.
382
+ const spans: string[] = [];
383
+ text = text
384
+ .replace(/`([^`]*)`/g, (_, code: string) => `\u0000${spans.push(code) - 1}\u0000`)
385
+ // A backslash escape (`\$12.50`, `\*`) is its character, never markup.
386
+ .replace(/\\([!-/:-@[-`{-~])/g, (_, escaped: string) => `\u0000${spans.push(escaped) - 1}\u0000`)
387
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
388
+ .replace(/(\*\*|__)(.+?)\1/g, "$2")
389
+ .replace(/(?<![\w*])\*(?!\s)(.+?)(?<!\s)\*(?![\w*])/g, "$1")
390
+ .replace(/(?<!\w)_(?!\s)(.+?)(?<!\s)_(?!\w)/g, "$1")
391
+ .replace(/~~(.+?)~~/g, "$1")
392
+ .replace(/<br\s*\/?>/gi, " ")
393
+ .replace(HTML_TAG, "")
394
+ .replace(/\u0000(\d+)\u0000/g, (_, at: string) => spans[Number(at)]!)
395
+ .replace(/\s+/g, " ")
396
+ .trim();
397
+ return { text, emphasis };
398
+ }
399
+
400
+ /** Parse the first number of a (Markdown) cell, or classify why it is not one. */
401
+ export function parseCell(markdown: string): Cell {
402
+ const plain = plainCell(markdown);
403
+ const text = plain.text.replace(MARKS, "").replace(/\s+/g, " ").trim();
404
+ const emphasis = plain.emphasis;
405
+ const other = (kind: OtherCell["kind"]): OtherCell => ({ kind, text: plain.text, emphasis });
406
+ if (Object.hasOwn(MISSING, text.toLowerCase())) return other("missing");
407
+ if (DATE.test(text)) return other("date");
408
+ if (TIME.test(text)) return other("time");
409
+ if (VERSION.test(text)) return other("version");
410
+ if (IDENTIFIER.test(text) || ZERO_PADDED.test(text)) return other("id");
411
+ // A number that is a link's whole text (`[312530](…/models/312530)`) names what it points at.
412
+ if (LINKED.test(markdown) && /^\d/.test(text)) return other("id");
413
+
414
+ const base = { approx: false, signed: false, emphasis, text: plain.text };
415
+ const listed = listCell(text, base);
416
+ if (listed) return listed;
417
+ const summed = sumCell(text, base);
418
+ if (summed) return summed;
419
+ const fraction = FRACTION.exec(text);
420
+ if (fraction) {
421
+ const rest = fraction[3]!;
422
+ return {
423
+ ...base,
424
+ kind: "number",
425
+ value: toNumber(fraction[1]!),
426
+ dim: "fraction",
427
+ unit: "/",
428
+ fit: rest.trim() ? "note" : "fraction",
429
+ figure: text.slice(0, text.length - rest.length).trim(),
430
+ denominator: toNumber(fraction[2]!),
431
+ };
432
+ }
433
+
434
+ const compound = COMPOUND_DURATION.exec(text);
435
+ if (compound && compound.slice(1).filter(Boolean).length >= 2) {
436
+ const [, days, hours, minutes, seconds] = compound.map(part => Number(part ?? 0));
437
+ const value = days! * 86_400 + hours! * 3600 + minutes! * 60 + seconds!;
438
+ return { ...base, kind: "number", value, dim: "duration", unit: "s", fit: "pure", figure: text };
439
+ }
440
+
441
+ const match = CELL.exec(text);
442
+ if (!match?.groups) return other("text");
443
+ const groups = match.groups;
444
+ let unit = groups.unit ?? "";
445
+ let rest = groups.rest!.trim();
446
+ let dim: Dimension = groups.cur ? "currency" : "count";
447
+ let factor = 1;
448
+ if (unit) {
449
+ const known = knownUnit(unit);
450
+ if (known) {
451
+ [dim, factor] = known;
452
+ } else if (unit.includes("/")) {
453
+ dim = "rate";
454
+ } else {
455
+ // A word after the number is a noun (`16 pass`, `8+ structs`), not a unit.
456
+ rest = `${groups.plus ?? ""}${unit} ${rest}`.trim();
457
+ unit = "";
458
+ }
459
+ }
460
+ // Through the unit when it stayed one, else through the number.
461
+ const figureEnd = unit ? text.length - groups.rest!.length : text.indexOf(groups.num!) + groups.num!.length;
462
+ const figure = text.slice(0, figureEnd).trim();
463
+ if (groups.cur) unit = groups.cur;
464
+ // `24–72 h`: the unit after the range's end applies to its start too.
465
+ const range = RANGE_TAIL.exec(rest);
466
+ const rangeUnit = range?.[2] ? knownUnit(range[2]) : undefined;
467
+ if (!unit && rangeUnit) {
468
+ [dim, factor] = rangeUnit;
469
+ unit = range![2]!;
470
+ }
471
+ // A magnitude suffix on money (`$6.0k`, `$1.2M`, `$4.4k–6.1k`) scales it; the amount stays currency.
472
+ if (groups.cur && dim === "count") {
473
+ dim = "currency";
474
+ unit = groups.cur;
475
+ }
476
+ const negative = groups.sign === "-" || groups.sign === "−";
477
+ const value = toNumber(groups.num!) * factor * (negative ? -1 : 1);
478
+ const cell = {
479
+ ...base,
480
+ kind: "number" as const,
481
+ value,
482
+ dim,
483
+ unit,
484
+ figure,
485
+ // A trailing `+` (`~$250+`, `4.9+ GiB`) marks a lower bound.
486
+ approx: groups.approx !== undefined || (groups.plus !== undefined && unit !== "") || rest === "+",
487
+ signed: groups.sign !== undefined,
488
+ };
489
+ if (!rest || rest === "+" || FOOTNOTE.test(rest)) return { ...cell, fit: "pure" };
490
+ const spread = ERROR_TAIL.exec(rest);
491
+ if (spread) {
492
+ // `482.8 ± 1.2 t/s`: a unit after the spread applies to the value too.
493
+ const written = spread[2] ?? "";
494
+ const tail: readonly [Dimension, number] | undefined =
495
+ knownUnit(written) ?? (written.includes("/") ? ["rate", 1] : undefined);
496
+ const own = !unit && tail ? { dim: tail[0], unit: written, value: value * tail[1] } : undefined;
497
+ const error = toNumber(spread[1]!) * (tail && own ? tail[1] : factor);
498
+ return {
499
+ ...cell,
500
+ ...own,
501
+ fit: "note",
502
+ error,
503
+ figure: `${figure} ± ${spread[1]}${spread[2] ? ` ${spread[2]}` : ""}`,
504
+ };
505
+ }
506
+ if (range) {
507
+ const upper = toNumber(range[1]!) * tailFactor(range[2], factor);
508
+ return { ...cell, fit: "range", upper, figure: `${figure}${range[0].trim()}` };
509
+ }
510
+ const arrow = ARROW_TAIL.exec(rest);
511
+ if (arrow) {
512
+ const sign = arrow[1] === "-" || arrow[1] === "−" ? -1 : 1;
513
+ const to = sign * toNumber(arrow[2]!) * tailFactor(arrow[3], factor);
514
+ return { ...cell, fit: "arrow", to, toFigure: arrow[0].replace(/^(?:→|->|⇒)\s*/, "").trim() };
515
+ }
516
+ if (/^\(.*\)$/.test(rest) || /^[A-Za-z][\w-]{0,11}$/.test(rest)) return { ...cell, fit: "note" };
517
+ return { ...cell, fit: "prose" };
518
+ }
519
+
520
+ function toNumber(written: string): number {
521
+ return Number(written.replaceAll(",", ""));
522
+ }
523
+
524
+ /** A number of a list or sum: its value as written, its unit (known or a noun) and how it was marked. */
525
+ interface Item {
526
+ readonly value: number;
527
+ readonly unit: string;
528
+ readonly approx: boolean;
529
+ readonly signed: boolean;
530
+ readonly currency: string;
531
+ }
532
+
533
+ /**
534
+ * The items of `text` from its start joined by `separator` (two at least, `max`
535
+ * at most), and where they end; `undefined` when fewer than two lead it.
536
+ */
537
+ function readItems(
538
+ text: string,
539
+ separator: RegExp,
540
+ max: number,
541
+ ): { items: Item[]; separators: string[]; end: number } | undefined {
542
+ const items: Item[] = [];
543
+ const separators: string[] = [];
544
+ let end = 0;
545
+ let at = 0;
546
+ while (items.length < max) {
547
+ ITEM.lastIndex = at;
548
+ const match = ITEM.exec(text);
549
+ if (!match?.groups) break;
550
+ const { approx, sign, cur, num } = match.groups;
551
+ // A word that is no unit (`214 / 72 cumulative`, `4 hooks`) ends the item at its number.
552
+ const unit = match.groups.unit ?? "";
553
+ const noun = unit !== "" && !knownUnit(unit) && !unit.includes("/");
554
+ items.push({
555
+ value: toNumber(num!) * (sign === "-" || sign === "−" ? -1 : 1),
556
+ unit: noun ? "" : unit,
557
+ approx: approx !== undefined,
558
+ signed: sign !== undefined,
559
+ currency: cur ?? "",
560
+ });
561
+ end = at = noun ? at + match[0].indexOf(num!) + num!.length : ITEM.lastIndex;
562
+ if (noun) break;
563
+ separator.lastIndex = at;
564
+ if (!separator.test(text)) break;
565
+ separators.push(text.slice(at, separator.lastIndex));
566
+ at = separator.lastIndex;
567
+ }
568
+ return items.length >= 2 ? { items, separators: separators.slice(0, items.length - 1), end } : undefined;
569
+ }
570
+
571
+ /**
572
+ * Dimension and per-item factor of `items` read as one quantity: a unit written
573
+ * after some items carries to the bare ones before it (`79 / 124 µs`); two
574
+ * dimensions make no one quantity ({@link readItems} ends an item at a noun).
575
+ * Bare items are counts; a unit per something (`tok/s`) is a rate.
576
+ */
577
+ function itemUnits(items: readonly Item[]): { dim: Dimension; factors: number[]; unit: string } | undefined {
578
+ let dim: Dimension | undefined;
579
+ const written: (number | undefined)[] = [];
580
+ const carried: (number | undefined)[] = [];
581
+ for (const item of items) {
582
+ const known = item.unit ? knownUnit(item.unit) : undefined;
583
+ // Money keeps its symbol; only a magnitude (`$1.2k`) may follow it.
584
+ if (item.currency && known && known[0] !== "count") return undefined;
585
+ const itemDim: Dimension | undefined = item.currency
586
+ ? "currency"
587
+ : item.unit
588
+ ? (known?.[0] ?? "rate")
589
+ : undefined;
590
+ if (itemDim && dim && itemDim !== dim) return undefined;
591
+ dim ??= itemDim;
592
+ // An item's own unit or currency scales it alone (`$627 + $1.48k`); only a physical unit carries back to
593
+ // bare items before it (`3.2 + 9.8 s`), never a magnitude (`5 + 4k`) or a currency sign.
594
+ written.push(item.unit || item.currency ? (known?.[1] ?? 1) : undefined);
595
+ carried.push(item.unit && !item.currency && known && known[0] !== "count" ? known[1] : undefined);
596
+ }
597
+ const factors: number[] = [];
598
+ for (let at = items.length - 1; at >= 0; at--)
599
+ factors[at] =
600
+ written[at] ?? (items[at + 1]?.unit || items[at + 1]?.currency ? carried[at + 1] : factors[at + 1]) ?? 1;
601
+ const lead = items.find(item => item.currency) ?? items.find(item => item.unit);
602
+ return { dim: dim ?? "count", factors, unit: lead?.currency || lead?.unit || "" };
603
+ }
604
+
605
+ /** Text after a list or a sum that only annotates it: nothing, a parenthetical, or one short word (`(45 total)`, `pass`). */
606
+ const SHORT_NOTE = /^$|^\(.*\)$|^[A-Za-z][\w-]{0,11}$/;
607
+ /** What a list or sum cell shares with any cell: its text and emphasis. */
608
+ type CellBase = Pick<NumberCell, "emphasis" | "text">;
609
+
610
+ /**
611
+ * A slash list of measurements as its first value with every value kept
612
+ * ({@link NumberCell.values}): two to {@link MAX_LISTED_VALUES} values of one
613
+ * unit written on each (`1,864 ms / 2,678 ms`) or after the last only (`79 /
614
+ * 124 µs`), slashes all spaced or all tight; or a spaced pair of bare values
615
+ * that is no score (`25.0 / 27.8`, `12.6 / 9.4`). Other bare lists (`5/8`,
616
+ * `27 / 30`, `19 / 1 / 0`) are fractions, scores or tallies, read elsewhere;
617
+ * `2/1/20k/3k` and `7/12 / 29%` are no one list.
618
+ */
619
+ function listCell(text: string, base: CellBase): NumberCell | undefined {
620
+ const read = readItems(text, LIST_SEPARATOR, MAX_LISTED_VALUES + 1);
621
+ if (!read || read.items.length > MAX_LISTED_VALUES) return undefined;
622
+ const { items, separators, end } = read;
623
+ const rest = text.slice(end).trim();
624
+ // `1/3 clean, else …`, `12/` and `3/6/9 more` read as prose or fractions; a list ends at its last value.
625
+ if (/^[\d/]/.test(rest)) return undefined;
626
+ if (new Set(separators.map(separator => separator === "/")).size > 1) return undefined;
627
+ // A unit after the last value carries back to bare ones; a currency sign leads only its own (`1.000 / $3.31`
628
+ // is a score and a price), and a magnitude only its own (`2/1/20k` is turns, edits and tokens).
629
+ const marked = items.map(item => Boolean(item.unit || item.currency));
630
+ const last = items.at(-1)!;
631
+ const carried = marked.slice(0, -1).every(mark => !mark) && !last.currency && knownUnit(last.unit)?.[0] !== "count";
632
+ // A spaced pair of bare numbers that is no score (`25.0 / 27.8`, `12.6 / 9.4`: the second is no whole, or
633
+ // the first exceeds it) lists two values of the column's quantity; `27 / 30` stays a score.
634
+ const [first, second] = items;
635
+ const whole = /^\d[\d,]*$/.test(text.slice(text.indexOf("/") + 1, end).trim());
636
+ const score = whole && first!.value >= 0 && first!.value <= second!.value;
637
+ const pair = items.length === 2 && !marked.some(Boolean) && separators[0] !== "/" && !score;
638
+ if (!pair && (!marked.at(-1) || !(marked.every(Boolean) || carried))) return undefined;
639
+ const units = itemUnits(items);
640
+ if (!units) return undefined;
641
+ const values = items.map((item, at) => item.value * units.factors[at]!);
642
+ return {
643
+ ...base,
644
+ kind: "number",
645
+ value: values[0]!,
646
+ dim: units.dim,
647
+ unit: units.unit,
648
+ fit: SHORT_NOTE.test(rest) ? "note" : "prose",
649
+ figure: text.slice(0, end).trim(),
650
+ approx: items.some(item => item.approx),
651
+ signed: items[0]!.signed,
652
+ values,
653
+ };
654
+ }
655
+
656
+ /**
657
+ * A sum of one quantity as its total: `27.8 GB + 4.1 GB` (the terms' sum, when
658
+ * they share a dimension and name no other thing) or `4 hooks + 9 overlays + 1
659
+ * helper = 14 files` (the total written after `=`).
660
+ */
661
+ function sumCell(text: string, base: CellBase): NumberCell | undefined {
662
+ const read = readItems(text, SUM_SEPARATOR, Number.POSITIVE_INFINITY);
663
+ if (!read || read.items[0]!.signed) return undefined;
664
+ let { end } = read;
665
+ SUM_TOTAL.lastIndex = end;
666
+ let total: Item | undefined;
667
+ if (SUM_TOTAL.test(text)) {
668
+ ITEM.lastIndex = SUM_TOTAL.lastIndex;
669
+ const match = ITEM.exec(text);
670
+ if (match?.groups) {
671
+ const { approx, cur, num, unit } = match.groups;
672
+ total = {
673
+ value: toNumber(num!),
674
+ unit: cur ?? unit ?? "",
675
+ approx: approx !== undefined,
676
+ signed: false,
677
+ currency: cur ?? "",
678
+ };
679
+ end = ITEM.lastIndex;
680
+ }
681
+ }
682
+ const rest = text.slice(end).trim();
683
+ if (/^[\d+=]/.test(rest)) return undefined;
684
+ const terms = total ? [total] : read.items;
685
+ const units = itemUnits(terms);
686
+ if (!units) return undefined;
687
+ const value = terms.reduce((sum, item, at) => sum + item.value * units.factors[at]!, 0);
688
+ return {
689
+ ...base,
690
+ kind: "number",
691
+ value,
692
+ dim: units.dim,
693
+ unit: units.unit,
694
+ fit: SHORT_NOTE.test(rest) ? "note" : "prose",
695
+ figure: text.slice(0, end).trim(),
696
+ approx: terms.some(item => item.approx) || read.items.some(item => item.approx),
697
+ signed: false,
698
+ };
699
+ }
700
+
701
+ /** Factor of a unit written after the second number of a range or transition; the first number's when absent. */
702
+ function tailFactor(unit: string | undefined, fallback: number): number {
703
+ if (!unit) return fallback;
704
+ return knownUnit(unit)?.[1] ?? fallback;
705
+ }
706
+
707
+ /** Dimension and base factor of `unit`, cased spellings first; own keys only, so `constructor` is no unit. */
708
+ function knownUnit(unit: string): readonly [Dimension, number] | undefined {
709
+ if (Object.hasOwn(CASED_UNITS, unit)) return CASED_UNITS[unit];
710
+ const lower = unit.toLowerCase();
711
+ return Object.hasOwn(UNITS, lower) ? UNITS[lower] : undefined;
712
+ }
713
+
714
+ /** Read a table's columns and rows for charting. Cells are the raw Markdown of each cell. */
715
+ export function analyzeTable(header: readonly string[], rows: readonly (readonly string[])[]): TableAnalysis {
716
+ let headers = header.map(cell => plainCell(cell).text);
717
+ const stacked = unstack(headers, rows);
718
+ if (stacked) {
719
+ headers = headers.slice(0, stacked.width);
720
+ rows = stacked.rows;
721
+ }
722
+ rows = unlist(rows);
723
+ const raw = rows.map(row => headers.map((_, index) => parseCell(row[index] ?? "")));
724
+ // `Edit / read calls | 85 / 147`: a spaced slash in the row's name (text before its numbers) pairs two
725
+ // values; one in a note after them (`copies whole message / hallucinates bullets`) pairs nothing.
726
+ const paired = raw.map(cells =>
727
+ cells
728
+ .slice(0, Math.max(0, cells.findIndex(isNumber)))
729
+ .some(cell => cell.kind === "text" && PAIR_SLASH.test(cell.text)),
730
+ );
731
+ // Scores or plain fractions, never both in one column: the two read on different axes and one kind drops out.
732
+ const scoring = headers.map(
733
+ (text, index) =>
734
+ !PAIR_SLASH.test(text) && raw.every((cells, row) => paired[row] || fractionKind(cells[index]) !== "fraction"),
735
+ );
736
+ // A column listing bare pairs (`214 / 104`) reads every spaced pair in it as one (`22 / 49` too, which alone
737
+ // would read as a score).
738
+ const pairing = headers.map((_, index) =>
739
+ raw.some(cells => {
740
+ const cell = cells[index];
741
+ return isNumber(cell) && cell.values !== undefined && cell.unit === "";
742
+ }),
743
+ );
744
+ const parsed = raw.map((cells, row) =>
745
+ cells.map((cell, index): Cell =>
746
+ // A list the header does not name item by item (`Layers/heads/d_model`, unlike `in / out / cache`)
747
+ // holds different things where its values are unlike: none of them is the cell's number.
748
+ !PAIR_SLASH.test(headers[index]!) && unlikeList(cell)
749
+ ? { kind: "text", text: cell.text, emphasis: cell.emphasis }
750
+ : pairing[index]
751
+ ? asPair(cell)
752
+ : scoring[index] && !paired[row]
753
+ ? asScore(cell)
754
+ : cell,
755
+ ),
756
+ );
757
+ const draft = fillDown(headers.map((text, index) => readColumn(index, text, parsed)));
758
+ // Without a text column, a leading column of named numbers (`4096B chunks`, `03`, `12-hover`) names the rows.
759
+ const lead = draft[0];
760
+ const unnamed = !draft.some(column => column.role === "label" || column.role === "temporal");
761
+ if (
762
+ lead?.role === "measure" &&
763
+ unnamed &&
764
+ lead.cells.some(
765
+ cell => cell.kind !== "missing" && (!isNumber(cell) || cell.fit === "note" || cell.fit === "prose"),
766
+ )
767
+ )
768
+ draft[0] = { ...lead, role: "label" };
769
+ else if (unnamed) {
770
+ // Else a column of words around numbers in mixed units (`5m write`, `+ 1 ping`) says how each row is
771
+ // done: it names the rows rather than measuring them.
772
+ const worded = draft.findIndex(
773
+ column =>
774
+ column.role === "measure" &&
775
+ column.mixed &&
776
+ column.cells.filter(cell => isNumber(cell) && cell.fit !== "pure").length * 2 > column.cells.length,
777
+ );
778
+ if (worded >= 0) draft[worded] = { ...draft[worded]!, role: "label" };
779
+ }
780
+ const labels = draft.filter(column => column.role === "label" || column.role === "temporal");
781
+ // Bare codes and numbers (`#2233`, `00`, `14`) name rows only when no worded column does; `#1 (baseline)` is worded.
782
+ const worded = (cell: Cell) =>
783
+ cell.kind !== "missing" && ((cell.kind !== "id" && !isNumber(cell)) || /\s/.test(cell.text));
784
+ const label =
785
+ labels.find(column => column.cells.some(worded)) ??
786
+ labels[0] ??
787
+ draft.find(column => column.role === "sequence") ??
788
+ draft.find(column => column.role === "index");
789
+ const totals: number[] = [];
790
+ const data: number[] = [];
791
+ const names = rows.map((_, row) => (label ? label.cells[row]!.text.replace(MARKS, "").trim() : ""));
792
+ const summary = names.map(name => label !== undefined && TOTAL_ROW.test(name));
793
+ // Whether a figure of `row` is the sum of two or more rows right above it (`Total scrapers | 74`).
794
+ const sumsAbove = (row: number) =>
795
+ draft.some(column => {
796
+ const total = column.cells[row];
797
+ if (column.role !== "measure" || !isNumber(total) || total.value <= 0) return false;
798
+ let sum = 0;
799
+ for (let above = row - 1; above >= 0; above--) {
800
+ const cell = column.cells[above];
801
+ if (!isNumber(cell) || cell.dim !== total.dim) return false;
802
+ sum += cell.value;
803
+ if (above < row - 1 && Math.abs(sum - total.value) <= total.value * APPROX_TOTAL) return true;
804
+ }
805
+ return false;
806
+ });
807
+ // A summary closes (or opens) the table, after any other summary rows; one amid the data only shares a
808
+ // name with one (a `net` suite, a `Totals by agent` query) unless its name is the bare summary word or
809
+ // its figure adds up the rows above it.
810
+ for (let row = 0; row < rows.length; row++) {
811
+ const summarizes =
812
+ summary[row] &&
813
+ (summary.slice(row + 1).every(Boolean) ||
814
+ summary.slice(0, row).every(Boolean) ||
815
+ BARE_TOTAL.test(names[row]!) ||
816
+ sumsAbove(row));
817
+ (summarizes ? totals : data).push(row);
818
+ }
819
+ const finished = keepSweeps(draft.map(column => finishColumn(column, data)));
820
+ const restated = restatedColumns(
821
+ finished.filter(column => column.role === "measure" && !column.constant),
822
+ data,
823
+ );
824
+ const columns = finished.map(column =>
825
+ restated.has(column.index) ? { ...column, restates: restated.get(column.index) } : column,
826
+ );
827
+ const measures = columns.filter(
828
+ column => column.role === "measure" && !column.constant && column.restates === undefined,
829
+ );
830
+ return {
831
+ columns,
832
+ label: label ? columns[label.index] : undefined,
833
+ measures,
834
+ rows: data,
835
+ totals,
836
+ composition: findComposition(measures, data),
837
+ };
838
+ }
839
+
840
+ /** A header with its numbers blanked: `scale 1 (200 tiles)` and `scale 8 (1600 tiles)` are one sweep. */
841
+ const SWEEP_STEP = /\d+(?:\.\d+)?/g;
842
+
843
+ /**
844
+ * `columns` with constant measures that are one step of a sweep kept as
845
+ * measures: `scale 8 (1600 tiles)` at 165 in every row beside `scale 1 (200
846
+ * tiles)` and `scale 4 (800 tiles)` is the sweep's last condition, not an
847
+ * echoed setting — the header, numbers blanked, matches a varying measure's.
848
+ */
849
+ function keepSweeps(columns: readonly TableColumn[]): TableColumn[] {
850
+ const varying = new Set(
851
+ columns
852
+ .filter(column => column.role === "measure" && !column.constant && /\d/.test(column.header))
853
+ .map(column => column.header.replace(SWEEP_STEP, "#")),
854
+ );
855
+ return columns.map(column =>
856
+ column.constant && column.role === "measure" && varying.has(column.header.replace(SWEEP_STEP, "#"))
857
+ ? { ...column, constant: false }
858
+ : column,
859
+ );
860
+ }
861
+
862
+ /** Most columns searched for parts summing to a whole (subsets grow combinatorially). */
863
+ const MAX_COMPOSED = 8;
864
+ /** Most parts one whole splits into directly. */
865
+ const MAX_PARTS = 4;
866
+ /** Share of its total that parts falling within it must make up in one row at least. */
867
+ const MIN_PART_SHARE = 0.25;
868
+ /** A whole that parts may fall within, leaving an unlisted rest. */
869
+ const WHOLE_HEADER = /\b(?:total|all|sum|overall)\b/i;
870
+
871
+ /**
872
+ * Half the last written digit of `cell`, in base units (`6,313k` → 500,
873
+ * `38.5%` → 0.05): how far its value may sit from the exact quantity it
874
+ * rounds. A score (`33/38` read as percent) and a whole count written in full
875
+ * (`46`, `1,980`) are exact.
876
+ */
877
+ export function writtenSlack(cell: NumberCell): number {
878
+ if (cell.denominator !== undefined && cell.dim === "percent") return 0;
879
+ const match = /(\d[\d,]*)(?:\.(\d+))?/.exec(cell.figure);
880
+ if (!match) return 0;
881
+ const written = Number(`${match[1]!.replaceAll(",", "")}${match[2] ? `.${match[2]}` : ""}`);
882
+ const factor = written ? Math.abs(cell.value) / written : 1;
883
+ const decimals = match[2]?.length ?? 0;
884
+ if (cell.dim === "count" && decimals === 0 && factor === 1 && !cell.approx) return 0;
885
+ return 0.5 * 10 ** -decimals * factor;
886
+ }
887
+
888
+ /** The cell of `column` at `row` as a plain number of the column's dimension; ranges and transitions hold no single value. */
889
+ function plainValue(column: TableColumn, row: number): NumberCell | undefined {
890
+ const cell = column.cells[row];
891
+ return isNumber(cell) && cell.dim === column.dim && cell.upper === undefined && cell.to === undefined
892
+ ? cell
893
+ : undefined;
894
+ }
895
+
896
+ /**
897
+ * Columns restating another column's values: `y = k·x` within the written
898
+ * precision over three or more rows where `x` varies, or a running total
899
+ * (`Offset` stepping by `Verts`). Of a proportional pair the first column
900
+ * stays, unless the second is a share summing to 100% beside counts or the
901
+ * table bolded more of its cells (`**87%**` beside `33/38`).
902
+ */
903
+ function restatedColumns(measures: readonly TableColumn[], rows: readonly number[]): Map<number, number> {
904
+ const restated = new Map<number, number>();
905
+ const share = (column: TableColumn) => {
906
+ if (column.dim !== "percent" || column.scores) return false;
907
+ const sum = rows.reduce((total, row) => total + (plainValue(column, row)?.value ?? 0), 0);
908
+ return sum >= 97 && sum <= 103;
909
+ };
910
+ for (const [at, a] of measures.entries()) {
911
+ for (const b of measures.slice(at + 1)) {
912
+ if (restated.has(a.index) || restated.has(b.index) || !proportional(a, b, rows)) continue;
913
+ const bolder = emphasized(b, rows) > emphasized(a, rows);
914
+ if ((share(b) && !share(a)) || bolder) restated.set(a.index, b.index);
915
+ else restated.set(b.index, a.index);
916
+ }
917
+ }
918
+ for (const column of measures) {
919
+ if (restated.has(column.index)) continue;
920
+ const source = measures.find(other => other !== column && runningTotal(column, other, rows));
921
+ if (source) restated.set(column.index, source.index);
922
+ }
923
+ return restated;
924
+ }
925
+
926
+ /** How many of `rows` the table bolded in `column`. */
927
+ function emphasized(column: TableColumn, rows: readonly number[]): number {
928
+ return rows.filter(row => column.cells[row]?.emphasis).length;
929
+ }
930
+
931
+ /** Most relative rounding a row may carry to count toward proving a proportion (`2%` beside `1` proves little). */
932
+ const TIGHT = 0.015;
933
+
934
+ /**
935
+ * `b = k·a` for one `k` in every row holding both (positive), within their
936
+ * written precision, over three or more rows where `a` varies and three
937
+ * written precisely enough to pin `k` down.
938
+ */
939
+ function proportional(a: TableColumn, b: TableColumn, rows: readonly number[]): boolean {
940
+ const pairs = rows.flatMap(row => {
941
+ const x = plainValue(a, row);
942
+ const y = plainValue(b, row);
943
+ return x && y ? [[x, y] as const] : [];
944
+ });
945
+ if (pairs.length < 3 || pairs.some(([x, y]) => x.value <= 0 || y.value <= 0)) return false;
946
+ const xs = pairs.map(([x]) => x.value);
947
+ if (Math.max(...xs) < 1.5 * Math.min(...xs)) return false;
948
+ let low = 0;
949
+ let high = Number.POSITIVE_INFINITY;
950
+ let tight = 0;
951
+ for (const [x, y] of pairs) {
952
+ const [sx, sy] = [writtenSlack(x), writtenSlack(y)];
953
+ if (sx >= x.value) return false;
954
+ low = Math.max(low, (y.value - sy) / (x.value + sx));
955
+ high = Math.min(high, (y.value + sy) / (x.value - sx));
956
+ if (sx / x.value + sy / y.value <= TIGHT) tight++;
957
+ }
958
+ return low <= high * (1 + 1e-9) && tight >= 3;
959
+ }
960
+
961
+ /** `total` steps by `step` from row to row (`Offset` 0, 16, 20 beside `Verts` 16, 4, 8), over three or more steps. */
962
+ function runningTotal(total: TableColumn, step: TableColumn, rows: readonly number[]): boolean {
963
+ if (total.dim !== step.dim || rows.length < 4) return false;
964
+ const at = (column: TableColumn, row: number) => plainValue(column, row)?.value;
965
+ const fits = (offset: 0 | 1) =>
966
+ rows.slice(1).every((row, index) => {
967
+ const now = at(total, row);
968
+ const before = at(total, rows[index]!);
969
+ const by = at(step, offset ? row : rows[index]!);
970
+ return now !== undefined && before !== undefined && by !== undefined && by !== 0 && now - before === by;
971
+ });
972
+ return fits(0) || fits(1);
973
+ }
974
+
975
+ /**
976
+ * The parts `measures` add up to: every relation `whole = a + b (+ …)` holding
977
+ * within the written precision in each row (three rows at least), expanded from
978
+ * the largest whole down to leaves. Without one, the same-unit columns beside
979
+ * a `Total` column that never exceed it are parts with an unlisted rest.
980
+ */
981
+ function findComposition(measures: readonly TableColumn[], rows: readonly number[]): Composition | undefined {
982
+ const pool = measures
983
+ .filter(
984
+ column =>
985
+ !column.mixed &&
986
+ column.arrowShare === 0 &&
987
+ rows.every(row => {
988
+ const cell = column.cells[row];
989
+ return !isNumber(cell) || (cell.value >= 0 && cell.upper === undefined);
990
+ }),
991
+ )
992
+ .slice(0, MAX_COMPOSED);
993
+ const sums = new Map<number, readonly TableColumn[]>();
994
+ for (const whole of pool) {
995
+ const others = pool.filter(column => column !== whole && column.dim === whole.dim);
996
+ for (const subset of subsets(others, 2, MAX_PARTS)) {
997
+ if (sums.has(whole.index)) break;
998
+ if (adds(whole, subset, rows, true)) sums.set(whole.index, subset);
999
+ }
1000
+ }
1001
+ if (sums.size > 0) {
1002
+ // Parts are non-negative and non-zero somewhere, so no column sums into itself: the expansion ends.
1003
+ const leaves = (index: number): number[] => sums.get(index)?.flatMap(part => leaves(part.index)) ?? [index];
1004
+ const nested = new Set([...sums.values()].flatMap(parts => parts.map(part => part.index)));
1005
+ const roots = [...sums.keys()].filter(index => !nested.has(index));
1006
+ const root = roots.sort((a, b) => leaves(b).length - leaves(a).length)[0] ?? [...sums.keys()][0]!;
1007
+ return { whole: root, parts: leaves(root).sort((a, b) => a - b), exact: true };
1008
+ }
1009
+ const whole = pool.find(column => WHOLE_HEADER.test(column.header));
1010
+ if (!whole) return undefined;
1011
+ // Within a total, a signed column (`vs current` +$4.66k) is a change from another row, not a part.
1012
+ const parts = pool.filter(
1013
+ column =>
1014
+ column !== whole && column.dim === whole.dim && !WHOLE_HEADER.test(column.header) && column.signedShare < 0.5,
1015
+ );
1016
+ if (parts.length < 2 || parts.length > MAX_PARTS || !adds(whole, parts, rows, false)) return undefined;
1017
+ // Parts make up a real share of their total somewhere: `27` tests and `6` turns fall within `16.8k` total tokens
1018
+ // only because they count other things.
1019
+ const share = (row: number) => {
1020
+ const total = plainValue(whole, row)?.value ?? 0;
1021
+ const sum = parts.reduce((acc, part) => acc + (plainValue(part, row)?.value ?? 0), 0);
1022
+ return total > 0 ? sum / total : 0;
1023
+ };
1024
+ if (!rows.some(row => share(row) >= MIN_PART_SHARE)) return undefined;
1025
+ return { whole: whole.index, parts: parts.map(column => column.index), exact: false };
1026
+ }
1027
+
1028
+ /** Subsets of `items` from `min` to `max` long, smallest first, each in `items` order. */
1029
+ function subsets<T>(items: readonly T[], min: number, max: number): T[][] {
1030
+ const out: T[][] = [];
1031
+ const grow = (start: number, picked: T[]) => {
1032
+ if (picked.length >= min) out.push(picked);
1033
+ if (picked.length === max) return;
1034
+ for (let at = start; at < items.length; at++) grow(at + 1, [...picked, items[at]!]);
1035
+ };
1036
+ grow(0, []);
1037
+ return out.sort((a, b) => a.length - b.length);
1038
+ }
1039
+
1040
+ /**
1041
+ * `parts` sum to `whole` (`exact`) or stay within it, short of it somewhere,
1042
+ * in every row holding all of them (three at least), each part non-zero in one.
1043
+ */
1044
+ function adds(whole: TableColumn, parts: readonly TableColumn[], rows: readonly number[], exact: boolean): boolean {
1045
+ let held = 0;
1046
+ let short = false;
1047
+ const used = new Set<TableColumn>();
1048
+ for (const row of rows) {
1049
+ const total = plainValue(whole, row);
1050
+ const cells = parts.map(part => plainValue(part, row));
1051
+ if (!total || cells.some(cell => !cell)) continue;
1052
+ held++;
1053
+ const sum = cells.reduce((acc, cell) => acc + cell!.value, 0);
1054
+ const slack =
1055
+ writtenSlack(total) + cells.reduce((acc, cell) => acc + writtenSlack(cell!), 0) + 1e-9 * total.value;
1056
+ if (exact ? Math.abs(sum - total.value) > slack : sum > total.value + slack) return false;
1057
+ if (sum < total.value - slack) short = true;
1058
+ cells.forEach((cell, at) => {
1059
+ if (cell!.value > 0) used.add(parts[at]!);
1060
+ });
1061
+ }
1062
+ return held >= 3 && used.size === parts.length && (exact || short);
1063
+ }
1064
+
1065
+ type DraftColumn = Omit<TableColumn, "constant">;
1066
+
1067
+ /**
1068
+ * Two or more copies of one table set side by side to save height
1069
+ * (`| | doc | lines | | doc | lines |`): the header repeats in blocks of
1070
+ * `width` columns, read as one table of `width` columns, block after block.
1071
+ * Block rows left empty (an odd row count) are dropped.
1072
+ */
1073
+ function unstack(
1074
+ headers: readonly string[],
1075
+ rows: readonly (readonly string[])[],
1076
+ ): { width: number; rows: string[][] } | undefined {
1077
+ for (let width = 2; width * 2 <= headers.length; width++) {
1078
+ if (headers.length % width !== 0 || !headers.slice(0, width).some(Boolean)) continue;
1079
+ if (!headers.every((name, at) => name === headers[at % width])) continue;
1080
+ const stacked: string[][] = [];
1081
+ for (let start = 0; start < headers.length; start += width)
1082
+ for (const row of rows) {
1083
+ const block = headers.slice(0, width).map((_, at) => row[start + at] ?? "");
1084
+ if (block.some(cell => plainCell(cell).text)) stacked.push(block);
1085
+ }
1086
+ return { width, rows: stacked };
1087
+ }
1088
+ return undefined;
1089
+ }
1090
+
1091
+ /** Fewest items a slash list must hold to split into rows wherever its name cell stands. */
1092
+ const MIN_LISTED = 3;
1093
+ /** Fewest items a slash list splits into when the row's name lists them before the values they pair. */
1094
+ const MIN_LISTED_PAIR = 2;
1095
+ /** Fewest numeric columns listing values in a row it splits: one list of numbers may be a row of unrelated metrics. */
1096
+ const MIN_LISTS = 2;
1097
+ /** A spaced slash between list items. */
1098
+ const LIST_SLASH = /\s+\/\s+/;
1099
+ /** A list item that is one number, as written (`12`, `~1,400`, `3.5 ms`, `40%`). */
1100
+ const LISTED_NUMBER = /^[~≈]?[$€£¥]?\d[\d,]*(?:\.\d+)?\s*(?:%|[A-Za-zµμ]+)?$/;
1101
+
1102
+ /**
1103
+ * Rows folding several rows into one (`response anthropic / chat / responses /
1104
+ * gemini → 3 wires | 12 / 12 / 12 / 9 | 6 / 0 / 0 / 0`), split back into one
1105
+ * row per item: a name cell listing {@link MIN_LISTED} or more items (a pair
1106
+ * only when the name cell comes before every list: `pager 24×80 / 48×120 |
1107
+ * 25.0 / 27.8 | 12.6 / 9.4`), {@link MIN_LISTS} or more numeric cells each
1108
+ * listing that many numbers, and no other number in the row. Each
1109
+ * item keeps the words the name cell writes before its first item and the
1110
+ * aside after its last (` → 3 wires`, ` (ms)`: a tail opening on a symbol, so
1111
+ * `blocks / conditional branches` keeps its last item whole); other text cells repeat.
1112
+ */
1113
+ function unlist(rows: readonly (readonly string[])[]): readonly (readonly string[])[] {
1114
+ return rows.flatMap(row => {
1115
+ const lists = row.map(cell => plainCell(cell).text.split(LIST_SLASH));
1116
+ const size = Math.max(...lists.map(list => list.length));
1117
+ if (size < MIN_LISTED_PAIR) return [row];
1118
+ const numeric = lists.map(list => list.every(item => LISTED_NUMBER.test(item)));
1119
+ const name = lists.findIndex((list, at) => list.length === size && !numeric[at]);
1120
+ const fits = lists.every(
1121
+ (list, at) => at === name || (list.length === size ? numeric[at] : !numeric[at] || !list[0]),
1122
+ );
1123
+ const listed = lists.filter((list, at) => list.length === size && numeric[at]).length;
1124
+ if (name < 0 || !fits || listed < MIN_LISTS) return [row];
1125
+ // A pair splits only where the row's name lists it, before the values it pairs (`pager 24×80 / 48×120 |
1126
+ // 25.0 / 27.8 | 12.6 / 9.4`); a slash in a note after them pairs nothing.
1127
+ if (size < MIN_LISTED && lists.some((list, at) => at < name && list.length === size)) return [row];
1128
+ const items = [...lists[name]!];
1129
+ const first = items[0]!;
1130
+ const last = items[size - 1]!;
1131
+ const before = first.includes(" ") ? first.slice(0, first.lastIndexOf(" ") + 1) : "";
1132
+ const aside = /\s+[^\p{L}\p{N}\s]/u.exec(last);
1133
+ const after = aside ? last.slice(aside.index) : "";
1134
+ items[0] = first.slice(before.length);
1135
+ items[size - 1] = last.slice(0, last.length - after.length);
1136
+ return items.map((item, index) =>
1137
+ row.map((cell, at) =>
1138
+ at === name ? `${before}${item}${after}` : lists[at]!.length === size ? lists[at]![index]! : cell,
1139
+ ),
1140
+ );
1141
+ });
1142
+ }
1143
+
1144
+ /**
1145
+ * Leading text columns written as an outline: a blank cell under a filled one
1146
+ * repeats it (`DB | Table.Column` naming the database once for its tables), so
1147
+ * the continuation rows keep their parent's name. Only columns before the
1148
+ * first measure; a blank written as `—` stays missing.
1149
+ */
1150
+ function fillDown(columns: DraftColumn[]): DraftColumn[] {
1151
+ const firstMeasure = columns.findIndex(column => column.role === "measure");
1152
+ return columns.map(column => {
1153
+ if (column.role !== "label" || (firstMeasure >= 0 && column.index > firstMeasure)) return column;
1154
+ const { cells } = column;
1155
+ const blank = (cell: Cell) => cell.kind === "missing" && cell.text === "";
1156
+ if (cells.length < 3 || blank(cells[0]!) || !cells.some(blank)) return column;
1157
+ const filled: Cell[] = [];
1158
+ for (const cell of cells) filled.push(blank(cell) ? filled.at(-1)! : cell);
1159
+ return { ...column, cells: filled };
1160
+ });
1161
+ }
1162
+
1163
+ /** A spaced slash naming two values (`p50 / p95`, `median / max`) rather than a path or a score. */
1164
+ const PAIR_SLASH = /\S \/ \S/;
1165
+
1166
+ /**
1167
+ * How an `a/b` cell reads: a `score` out of a whole-number `b` with
1168
+ * `0 ≤ a ≤ b` (`12/12`, `154/160`, a mean like `0.6/4`), a `list` like
1169
+ * `327 / 333 / 339`, else a plain `fraction` (`5/3`, `1.2/3.4`);
1170
+ * `undefined` for any other cell.
1171
+ */
1172
+ function fractionKind(cell: Cell | undefined): "score" | "list" | "fraction" | undefined {
1173
+ if (!isNumber(cell) || cell.dim !== "fraction" || !cell.denominator) return undefined;
1174
+ const after = cell.text.indexOf(cell.figure);
1175
+ if (after < 0 || /^\s*\//.test(cell.text.slice(after + cell.figure.length))) return "list";
1176
+ // The figure ends in its denominator as written: `/4` counts a whole, `/3.4` is a second value.
1177
+ const whole = /\/\s*\d[\d,]*$/.test(cell.figure);
1178
+ return whole && cell.value >= 0 && cell.value <= cell.denominator ? "score" : "fraction";
1179
+ }
1180
+
1181
+ /**
1182
+ * A bare slash list of three or more values unlike each other (`12 / 12 / 768`, `19 / 1 / 0`): a list of
1183
+ * different things, where alike values (`327 / 333 / 339`) repeat one measurement.
1184
+ */
1185
+ function unlikeList(cell: Cell): boolean {
1186
+ if (!isNumber(cell) || cell.dim !== "fraction") return false;
1187
+ const items = readItems(cell.text.replace(MARKS, "").trim(), LIST_SEPARATOR, MAX_LISTED_VALUES)?.items;
1188
+ if (!items || items.length < 3 || items.some(item => item.unit || item.currency)) return false;
1189
+ const values = items.map(item => item.value);
1190
+ return values.some(value => value <= 0) || Math.max(...values) > ALIKE_SPREAD * Math.min(...values);
1191
+ }
1192
+
1193
+ /** A spaced bare `a / b` read as a fraction or score (`22 / 49`) as the pair of values it lists; any other cell unchanged. */
1194
+ function asPair(cell: Cell): Cell {
1195
+ if (!isNumber(cell) || cell.denominator === undefined || cell.fit !== "fraction" || !PAIR_SLASH.test(cell.figure))
1196
+ return cell;
1197
+ return {
1198
+ ...cell,
1199
+ dim: "count",
1200
+ unit: "",
1201
+ fit: "note",
1202
+ denominator: undefined,
1203
+ values: [cell.value, cell.denominator],
1204
+ };
1205
+ }
1206
+
1207
+ /** A {@link fractionKind} `score` as the percent of its denominator it reached; any other cell unchanged. */
1208
+ function asScore(cell: Cell): Cell {
1209
+ if (!isNumber(cell) || !cell.denominator || fractionKind(cell) !== "score") return cell;
1210
+ return { ...cell, dim: "percent", value: (100 * cell.value) / cell.denominator };
1211
+ }
1212
+
1213
+ function readColumn(index: number, header: string, parsed: readonly (readonly Cell[])[]): DraftColumn {
1214
+ const cells = otherNouns(
1215
+ resolveClock(resolveMagnitudes(resolveMinutes(parsed.map(row => row[index]!)), header)),
1216
+ header,
1217
+ );
1218
+ const present = cells.filter(cell => cell.kind !== "missing");
1219
+ const numbers = present.filter(isNumber);
1220
+ const strict = numbers.filter(cell => cell.fit !== "prose");
1221
+ const total = Math.max(1, present.length);
1222
+ const numberShare = numbers.length / total;
1223
+ const strictShare = strict.length / total;
1224
+ const temporalShare = present.filter(cell => cell.kind === "date" || cell.kind === "time").length / total;
1225
+
1226
+ const dims = new Map<Dimension, NumberCell[]>();
1227
+ for (const cell of numbers) {
1228
+ const same = dims.get(cell.dim);
1229
+ if (same) same.push(cell);
1230
+ else dims.set(cell.dim, [cell]);
1231
+ }
1232
+ let dominant: NumberCell[] = [];
1233
+ for (const group of dims.values()) if (group.length > dominant.length) dominant = group;
1234
+ const dim = dominant[0]?.dim;
1235
+ const mixed = numbers.length > 0 && dominant.length / numbers.length < 0.8;
1236
+ const scored = numbers.filter(cell => cell.dim === "percent" && cell.denominator !== undefined).length;
1237
+ const unit = dim === "currency" || dim === "rate" ? (dominant[0]?.unit ?? "") : "";
1238
+
1239
+ // A lone `✗` reads as a missing value yet still says failed.
1240
+ const statuses = cells.map(cell => cellStatus(cell.text));
1241
+ const written = cells.filter(cell => cell.text.trim()).length;
1242
+ const statusShare = written ? statuses.filter(Boolean).length / written : 0;
1243
+
1244
+ const trimmed = header.trim();
1245
+ const sequential =
1246
+ index === 0 &&
1247
+ numbers.length >= 3 &&
1248
+ numbers.length === present.length &&
1249
+ numbers.every((cell, at) => cell.dim === "count" && cell.value === numbers[0]!.value + at);
1250
+ // Row numbers and identifiers are bare counts; a unit (`20 ms` under `PR`) makes the column a measure.
1251
+ const bare = numbers.every(cell => cell.dim === "count" && !cell.unit);
1252
+ // `1 ok`, `87 ok`, `3 fail`: every count tallies an outcome, a verdict per row rather than a quantity to plot.
1253
+ const tallies =
1254
+ numbers.length > 0 && bare && cells.every((cell, at) => !isNumber(cell) || statuses[at] !== undefined);
1255
+ let role: ColumnRole;
1256
+ if (temporalShare >= 0.8) role = "temporal";
1257
+ else if (tallies) role = "label";
1258
+ else if (numberShare >= 0.8 && (sequential || (bare && INDEX_HEADER.test(trimmed)))) role = "index";
1259
+ // Case numbers `01–11`, `12–17`, …, `64`, `65` beside the rows' names: spans tiling one numbering say where each
1260
+ // row sits, not how much it holds.
1261
+ else if (index > 0 && bare && tilesSpans(present)) role = "index";
1262
+ // A step header over ordered values (`Tier` 1, 1, 2, 3): repeats make it a bucket the rows fall into, still no measure.
1263
+ else if (
1264
+ numberShare >= 0.8 &&
1265
+ bare &&
1266
+ SEQUENCE_HEADER.test(trimmed) &&
1267
+ (isMonotonic(numbers.map(cell => cell.value)) || isOrdered(numbers.map(cell => cell.value)))
1268
+ )
1269
+ role = "sequence";
1270
+ else if (numberShare >= 0.8 && strictShare >= 0.6) role = "measure";
1271
+ // A mostly numeric column whose other cells are short status words (`TIMEOUT`, `MISS`, `OOM`).
1272
+ else if (numberShare >= 0.6 && strictShare >= 0.5 && present.every(cell => isNumber(cell) || cell.text.length <= 24))
1273
+ role = "measure";
1274
+ else role = "label";
1275
+
1276
+ return {
1277
+ index,
1278
+ header,
1279
+ role,
1280
+ cells,
1281
+ dim,
1282
+ unit,
1283
+ mixed,
1284
+ scores: numbers.length > 0 && scored / numbers.length >= 0.8,
1285
+ statuses:
1286
+ statusShare >= 0.6
1287
+ ? statuses.map((status, at) => status ?? (HEDGE.test(cells[at]!.text) ? "warn" : undefined))
1288
+ : undefined,
1289
+ signedShare: numbers.length ? numbers.filter(cell => cell.signed).length / numbers.length : 0,
1290
+ arrowShare: numbers.length ? numbers.filter(cell => cell.to !== undefined).length / numbers.length : 0,
1291
+ };
1292
+ }
1293
+
1294
+ /**
1295
+ * A lone `m` means minutes beside other durations and millions otherwise;
1296
+ * {@link CASED_UNITS} reads it as minutes, so a column whose only durations
1297
+ * are `m` cells reads them as millions.
1298
+ */
1299
+ function resolveMinutes(cells: Cell[]): Cell[] {
1300
+ const durations = cells.filter(cell => isNumber(cell) && cell.dim === "duration");
1301
+ if (durations.length === 0 || !durations.every(cell => isNumber(cell) && cell.unit === "m")) return cells;
1302
+ const rescale = (seconds: number | undefined) => (seconds === undefined ? undefined : (seconds / 60) * 1e6);
1303
+ return cells.map(cell =>
1304
+ isNumber(cell) && cell.unit === "m"
1305
+ ? { ...cell, dim: "count", value: rescale(cell.value)!, to: rescale(cell.to), upper: rescale(cell.upper) }
1306
+ : cell,
1307
+ );
1308
+ }
1309
+
1310
+ /** The word a bare count names after its figure (`4 new (45 total)` → `new`, `371 entries`). */
1311
+ const COUNTED_NOUN = /^\s*([A-Za-z][A-Za-z-]*)/;
1312
+
1313
+ /**
1314
+ * Counts of another thing than most of the column's (`371 entries` among `4
1315
+ * new` tests) read as text: one axis would set them against the rest. Applies
1316
+ * where most counts name one noun and two or more others name one the header
1317
+ * does not (a lone `1200px` among `rem` sizes is a stray unit, not a second quantity).
1318
+ */
1319
+ function otherNouns(cells: Cell[], header: string): Cell[] {
1320
+ const nouns = cells.map(cell => {
1321
+ if (!isNumber(cell) || cell.dim !== "count" || cell.unit || cell.fit === "pure" || cellStatus(cell.text))
1322
+ return undefined;
1323
+ const word = COUNTED_NOUN.exec(cell.text.slice(cell.text.indexOf(cell.figure) + cell.figure.length))?.[1];
1324
+ return word?.toLowerCase().replace(/e?s$/, "");
1325
+ });
1326
+ const tally = new Map<string, number>();
1327
+ for (const noun of nouns) if (noun) tally.set(noun, (tally.get(noun) ?? 0) + 1);
1328
+ const [main, count] = [...tally].sort((a, b) => b[1] - a[1])[0] ?? ["", 0];
1329
+ if (count < 2 || count * 2 <= cells.filter(isNumber).length) return cells;
1330
+ const named = header.toLowerCase();
1331
+ return cells.map((cell, at) => {
1332
+ const noun = nouns[at];
1333
+ return noun && noun !== main && (tally.get(noun) ?? 0) >= 2 && !named.includes(noun)
1334
+ ? { kind: "text", text: cell.text, emphasis: cell.emphasis }
1335
+ : cell;
1336
+ });
1337
+ }
1338
+
1339
+ /** Headers of a size on disk or in memory, whose bare magnitudes (`3.4G`, `960M`) count bytes. */
1340
+ const SIZE_HEADER = /\b(?:sizes?|disk|du|rss|memory|mem|heap|storage|footprint|bytes)\b/i;
1341
+ /** Bare magnitude suffixes of a `du -h` size, as powers of 1024. */
1342
+ const BINARY_POWER: Readonly<Record<string, number>> = { K: 1, k: 1, M: 2, G: 3, T: 4 };
1343
+
1344
+ /**
1345
+ * Bare magnitudes read by what they stand beside: under a size header
1346
+ * ({@link SIZE_HEADER}), `3.4G`, `960M` and `12K` are `du -h` sizes in powers
1347
+ * of 1024; beside millions (`600M` parameters), `1.2B` is billions, not bytes.
1348
+ */
1349
+ function resolveMagnitudes(cells: Cell[], header: string): Cell[] {
1350
+ const numbers = cells.filter(isNumber);
1351
+ const suffixed = numbers.filter(cell => cell.dim === "count" && Object.hasOwn(BINARY_POWER, cell.unit));
1352
+ if (suffixed.length === 0) return cells;
1353
+ const rescale = (value: number | undefined, from: number, to: number) =>
1354
+ value === undefined ? undefined : (value / from) * to;
1355
+ if (SIZE_HEADER.test(header) && numbers.every(cell => cell.dim === "count" || cell.dim === "bytes"))
1356
+ return cells.map(cell => {
1357
+ if (!isNumber(cell) || !suffixed.includes(cell)) return cell;
1358
+ const from = knownUnit(cell.unit)![1];
1359
+ const to = 1024 ** BINARY_POWER[cell.unit]!;
1360
+ return {
1361
+ ...cell,
1362
+ dim: "bytes",
1363
+ value: rescale(cell.value, from, to)!,
1364
+ to: rescale(cell.to, from, to),
1365
+ upper: rescale(cell.upper, from, to),
1366
+ };
1367
+ });
1368
+ const bytes = numbers.filter(cell => cell.dim === "bytes");
1369
+ // Billions read like millions: a few units each (`1.2B` beside `600M`), where bytes would be thousands (`4096B`).
1370
+ if (
1371
+ bytes.length === 0 ||
1372
+ !suffixed.some(cell => cell.unit === "M") ||
1373
+ !bytes.every(cell => cell.unit === "B" && Math.abs(cell.value) < 1000)
1374
+ )
1375
+ return cells;
1376
+ return cells.map(cell =>
1377
+ isNumber(cell) && cell.dim === "bytes"
1378
+ ? {
1379
+ ...cell,
1380
+ dim: "count",
1381
+ value: cell.value * 1e9,
1382
+ to: rescale(cell.to, 1, 1e9),
1383
+ upper: rescale(cell.upper, 1, 1e9),
1384
+ }
1385
+ : cell,
1386
+ );
1387
+ }
1388
+
1389
+ /** An elapsed time written on a clock face: `m:ss` or `h:mm:ss`, optionally with fractions of a second. */
1390
+ const CLOCK = /^(?:(\d{1,2}):)?(\d{1,3}):(\d{2}(?:\.\d+)?)$/;
1391
+
1392
+ /**
1393
+ * `4:14` beside `8s` and `24s` is four minutes fourteen seconds, not a time of
1394
+ * day: in a column holding other durations, clock cells read as durations
1395
+ * (`m:ss`, or `h:mm:ss`).
1396
+ */
1397
+ function resolveClock(cells: Cell[]): Cell[] {
1398
+ if (!cells.some(cell => isNumber(cell) && cell.dim === "duration")) return cells;
1399
+ return cells.map(cell => {
1400
+ if (cell.kind !== "time") return cell;
1401
+ const text = cell.text.replace(MARKS, "").trim();
1402
+ const clock = CLOCK.exec(text);
1403
+ if (!clock) return cell;
1404
+ const value = Number(clock[1] ?? 0) * 3600 + Number(clock[2]) * 60 + Number(clock[3]);
1405
+ return {
1406
+ kind: "number",
1407
+ value,
1408
+ dim: "duration",
1409
+ unit: "s",
1410
+ fit: "pure",
1411
+ approx: false,
1412
+ signed: false,
1413
+ emphasis: cell.emphasis,
1414
+ text: cell.text,
1415
+ figure: text,
1416
+ } satisfies NumberCell;
1417
+ });
1418
+ }
1419
+
1420
+ function finishColumn(column: DraftColumn, rows: readonly number[]): TableColumn {
1421
+ const values = new Set<number>();
1422
+ let count = 0;
1423
+ for (const row of rows) {
1424
+ const cell = column.cells[row];
1425
+ if (cell && isNumber(cell)) {
1426
+ values.add(cell.value);
1427
+ count++;
1428
+ }
1429
+ }
1430
+ return { ...column, constant: count > 0 && values.size <= 1 };
1431
+ }
1432
+
1433
+ /** A span of whole numbers written `a–b` (zero-padded or not: `01–11`), or one number. */
1434
+ const SPAN = /^(\d+)(?:\s*[-–—]\s*(\d+))?$/;
1435
+ /** Fewest spans that tile a numbering; two in a row may meet by chance. */
1436
+ const MIN_TILES = 3;
1437
+
1438
+ /**
1439
+ * Whether `cells` are spans of whole numbers laid end to end in order (`01–11`, `12–17`, `18–21`, `64`):
1440
+ * each starts right after the previous one ends, one at least a range, {@link MIN_TILES} at least.
1441
+ */
1442
+ function tilesSpans(cells: readonly Cell[]): boolean {
1443
+ if (cells.length < MIN_TILES) return false;
1444
+ let end: number | undefined;
1445
+ let ranged = false;
1446
+ for (const cell of cells) {
1447
+ const span = SPAN.exec(cell.text.replace(MARKS, "").trim());
1448
+ if (!span) return false;
1449
+ const first = Number(span[1]);
1450
+ const last = span[2] === undefined ? first : Number(span[2]);
1451
+ if (last < first || (end !== undefined && first !== end + 1)) return false;
1452
+ ranged ||= last > first;
1453
+ end = last;
1454
+ }
1455
+ return ranged;
1456
+ }
1457
+
1458
+ /** Strictly increasing or strictly decreasing. */
1459
+ export function isMonotonic(values: readonly number[]): boolean {
1460
+ if (values.length < 2) return false;
1461
+ const up = values[1]! > values[0]!;
1462
+ for (let at = 1; at < values.length; at++) {
1463
+ const step = values[at]! - values[at - 1]!;
1464
+ if (step === 0 || step > 0 !== up) return false;
1465
+ }
1466
+ return true;
1467
+ }
1468
+
1469
+ /** Non-decreasing with at least one repeat: ordered buckets (`1, 1, 2, 2, 3`), not a strictly monotonic axis. */
1470
+ function isOrdered(values: readonly number[]): boolean {
1471
+ if (values.length < 3 || new Set(values).size === values.length) return false;
1472
+ return values.every((value, at) => at === 0 || value >= values[at - 1]!);
1473
+ }
1474
+
1475
+ /** Whether `cell` reads as a number. */
1476
+ export function isNumber(cell: Cell | undefined): cell is NumberCell {
1477
+ return cell?.kind === "number";
1478
+ }