@linxiraos/pi-tui 1.2.1 → 1.3.1

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 (280) hide show
  1. package/CHANGELOG.md +245 -224
  2. package/README.md +36 -0
  3. package/THIRD-PARTY-NOTICES.txt +55 -5
  4. package/dist/types/app-keybindings.d.ts +18 -8
  5. package/dist/types/apps/git/diff-pane.d.ts +5 -0
  6. package/dist/types/apps/git/git-tui.d.ts +16 -2
  7. package/dist/types/apps/git/state.d.ts +11 -0
  8. package/dist/types/autocomplete.d.ts +2 -0
  9. package/dist/types/charts/chart-svg.d.ts +7 -13
  10. package/dist/types/chat/advisor-message.d.ts +4 -1
  11. package/dist/types/chat/assistant-message.d.ts +9 -4
  12. package/dist/types/chat/figure-markdown.d.ts +7 -2
  13. package/dist/types/chat/messages.d.ts +3 -1
  14. package/dist/types/chat/read-tool-group.d.ts +2 -2
  15. package/dist/types/chat/table-chart.d.ts +11 -15
  16. package/dist/types/chat/tool-execution.d.ts +32 -20
  17. package/dist/types/chat/user-message.d.ts +3 -2
  18. package/dist/types/chrome/countdown-timer.d.ts +3 -2
  19. package/dist/types/chrome/diff.d.ts +2 -3
  20. package/dist/types/chrome/status-notice.d.ts +2 -0
  21. package/dist/types/chrome/transcript-container.d.ts +2 -0
  22. package/dist/types/components/image.d.ts +2 -0
  23. package/dist/types/components/markdown.d.ts +49 -3
  24. package/dist/types/components/text.d.ts +2 -0
  25. package/dist/types/debug-server.d.ts +2 -0
  26. package/dist/types/keybinding-matchers.d.ts +6 -0
  27. package/dist/types/multiplexers/tmux.d.ts +1 -0
  28. package/dist/types/multiplexers/types.d.ts +6 -0
  29. package/dist/types/native/backend.d.ts +5 -4
  30. package/dist/types/native/blobs.d.ts +24 -12
  31. package/dist/types/overlays/agent-hub-projection.d.ts +10 -5
  32. package/dist/types/overlays/annotation-overlay.d.ts +19 -0
  33. package/dist/types/overlays/approval-dialog.d.ts +79 -0
  34. package/dist/types/overlays/ask-dialog.d.ts +11 -6
  35. package/dist/types/overlays/btw-history-panel.d.ts +2 -6
  36. package/dist/types/overlays/hook-editor.d.ts +7 -2
  37. package/dist/types/overlays/hook-input.d.ts +12 -4
  38. package/dist/types/overlays/job-tree.d.ts +50 -0
  39. package/dist/types/overlays/jobs-panel.d.ts +25 -11
  40. package/dist/types/overlays/login-dialog.d.ts +8 -3
  41. package/dist/types/overlays/mcp-add-wizard.d.ts +9 -1
  42. package/dist/types/overlays/model-browser.d.ts +6 -1
  43. package/dist/types/overlays/model-picker.d.ts +2 -0
  44. package/dist/types/overlays/pause-screen.d.ts +5 -0
  45. package/dist/types/overlays/plan-review-overlay.d.ts +4 -0
  46. package/dist/types/overlays/report-panel.d.ts +5 -1
  47. package/dist/types/overlays/reset-usage-selector.d.ts +7 -5
  48. package/dist/types/overlays/session-account-selector.d.ts +4 -0
  49. package/dist/types/overlays/session-info-overlay.d.ts +7 -3
  50. package/dist/types/overlays/session-selector.d.ts +3 -1
  51. package/dist/types/overlays/usage-dashboard.d.ts +6 -6
  52. package/dist/types/prompt/composer.d.ts +23 -20
  53. package/dist/types/prompt/custom-editor.d.ts +20 -10
  54. package/dist/types/prompt/image-references.d.ts +22 -0
  55. package/dist/types/prompt/internal-url-autocomplete.d.ts +1 -0
  56. package/dist/types/prompt/macos-spelling.d.ts +2 -1
  57. package/dist/types/prompt/prose-gate.d.ts +3 -0
  58. package/dist/types/prompt/queued-messages.d.ts +1 -1
  59. package/dist/types/prompt/typing-word.d.ts +25 -0
  60. package/dist/types/prompt/welcome.d.ts +26 -6
  61. package/dist/types/render/hanging-wrap.d.ts +7 -0
  62. package/dist/types/render/hyperlink.d.ts +2 -0
  63. package/dist/types/render/render-utils.d.ts +9 -1
  64. package/dist/types/render/utils.d.ts +1 -1
  65. package/dist/types/setup/scenes/splash.d.ts +6 -5
  66. package/dist/types/status-line/context-usage.d.ts +4 -1
  67. package/dist/types/status-line/host.d.ts +6 -4
  68. package/dist/types/status-line/startup.d.ts +6 -6
  69. package/dist/types/status-line/types.d.ts +3 -0
  70. package/dist/types/stdin-buffer.d.ts +7 -0
  71. package/dist/types/terminal-capabilities.d.ts +16 -1
  72. package/dist/types/terminal-multiplexer.d.ts +7 -0
  73. package/dist/types/terminal.d.ts +8 -4
  74. package/dist/types/theme/symbols.d.ts +1 -1
  75. package/dist/types/theme/theme.d.ts +11 -0
  76. package/dist/types/thinking.d.ts +5 -0
  77. package/dist/types/tmux.d.ts +39 -4
  78. package/dist/types/tools/ast-edit.d.ts +2 -2
  79. package/dist/types/tools/ast-grep.d.ts +2 -2
  80. package/dist/types/tools/bash.d.ts +6 -1
  81. package/dist/types/tools/edit.d.ts +2 -0
  82. package/dist/types/tools/eval.d.ts +53 -4
  83. package/dist/types/tools/find.d.ts +14 -4
  84. package/dist/types/tools/glob.d.ts +3 -3
  85. package/dist/types/tools/grep.d.ts +2 -2
  86. package/dist/types/tools/grouped-file-output.d.ts +9 -4
  87. package/dist/types/tools/native-view.d.ts +1 -1
  88. package/dist/types/tools/read.d.ts +32 -10
  89. package/dist/types/tools/renderer.d.ts +2 -0
  90. package/dist/types/tools/task.d.ts +31 -0
  91. package/dist/types/tools/web-search-types.d.ts +1 -0
  92. package/dist/types/tools/write.d.ts +2 -0
  93. package/dist/types/tui.d.ts +20 -3
  94. package/package.json +10 -10
  95. package/src/app-keybindings.d.ts +18 -8
  96. package/src/app-keybindings.ts +20 -8
  97. package/src/apps/autoresearch-dashboard.ts +444 -27
  98. package/src/apps/debug/raw-sse.ts +2 -2
  99. package/src/apps/git/diff-pane.ts +39 -41
  100. package/src/apps/git/git-tui.ts +80 -14
  101. package/src/apps/git/sidebar.ts +10 -1
  102. package/src/apps/git/state.ts +12 -0
  103. package/src/autocomplete.d.ts +2 -0
  104. package/src/autocomplete.ts +21 -19
  105. package/src/bracketed-paste.ts +9 -2
  106. package/src/charts/chart-svg.d.ts +7 -13
  107. package/src/charts/chart-svg.ts +19 -22
  108. package/src/charts/table-data.ts +17 -6
  109. package/src/chat/advisor-message.d.ts +4 -1
  110. package/src/chat/advisor-message.ts +77 -24
  111. package/src/chat/assistant-message.d.ts +9 -4
  112. package/src/chat/assistant-message.ts +127 -31
  113. package/src/chat/bash-execution.ts +7 -2
  114. package/src/chat/figure-markdown.d.ts +7 -2
  115. package/src/chat/figure-markdown.ts +10 -3
  116. package/src/chat/image-loading.ts +9 -6
  117. package/src/chat/messages.d.ts +3 -1
  118. package/src/chat/messages.ts +4 -1
  119. package/src/chat/read-tool-group.d.ts +2 -2
  120. package/src/chat/read-tool-group.ts +97 -42
  121. package/src/chat/svg-figure.ts +3 -1
  122. package/src/chat/svg-source.ts +6 -3
  123. package/src/chat/table-chart.d.ts +11 -15
  124. package/src/chat/table-chart.ts +24 -57
  125. package/src/chat/tool-execution.d.ts +32 -20
  126. package/src/chat/tool-execution.ts +103 -36
  127. package/src/chat/user-message.d.ts +3 -2
  128. package/src/chat/user-message.ts +54 -34
  129. package/src/chrome/countdown-timer.ts +3 -3
  130. package/src/chrome/diff.d.ts +2 -3
  131. package/src/chrome/diff.ts +4 -9
  132. package/src/chrome/status-notice.ts +5 -0
  133. package/src/chrome/transcript-container.d.ts +2 -0
  134. package/src/chrome/transcript-container.ts +212 -28
  135. package/src/components/editor.ts +12 -12
  136. package/src/components/image.d.ts +2 -0
  137. package/src/components/image.ts +54 -20
  138. package/src/components/markdown.d.ts +49 -3
  139. package/src/components/markdown.ts +482 -12
  140. package/src/components/settings-list.ts +2 -4
  141. package/src/components/text.d.ts +2 -0
  142. package/src/components/text.ts +11 -1
  143. package/src/debug-server.d.ts +2 -0
  144. package/src/debug-server.ts +51 -69
  145. package/src/hotkeys-markdown.ts +2 -0
  146. package/src/keybinding-matchers.d.ts +6 -0
  147. package/src/keybinding-matchers.ts +19 -0
  148. package/src/multiplexers/tmux.d.ts +1 -0
  149. package/src/multiplexers/tmux.ts +3 -0
  150. package/src/multiplexers/types.d.ts +6 -0
  151. package/src/multiplexers/types.ts +6 -0
  152. package/src/native/backend.d.ts +5 -4
  153. package/src/native/backend.ts +13 -5
  154. package/src/native/blobs.d.ts +24 -12
  155. package/src/native/blobs.ts +56 -7
  156. package/src/native/reconcile.ts +43 -21
  157. package/src/overlays/advisor-config.ts +259 -8
  158. package/src/overlays/agent-hub-projection.d.ts +10 -5
  159. package/src/overlays/agent-hub-projection.ts +63 -12
  160. package/src/overlays/annotation-overlay.ts +422 -168
  161. package/src/overlays/approval-dialog.ts +389 -0
  162. package/src/overlays/ask-dialog.ts +244 -105
  163. package/src/overlays/btw-history-panel.ts +208 -147
  164. package/src/overlays/extensions/extension-list.ts +4 -6
  165. package/src/overlays/hook-editor.ts +90 -32
  166. package/src/overlays/hook-input.ts +105 -19
  167. package/src/overlays/job-tree.ts +177 -0
  168. package/src/overlays/jobs-panel.ts +222 -142
  169. package/src/overlays/login-dialog.ts +201 -91
  170. package/src/overlays/mcp-add-wizard.ts +562 -279
  171. package/src/overlays/model-browser.ts +32 -7
  172. package/src/overlays/model-hub.ts +4 -10
  173. package/src/overlays/model-picker.ts +58 -21
  174. package/src/overlays/pause-screen.ts +81 -23
  175. package/src/overlays/plan-review-overlay.ts +16 -0
  176. package/src/overlays/plugin-selector.ts +17 -1
  177. package/src/overlays/queue-mode-selector.ts +10 -0
  178. package/src/overlays/report-panel.ts +6 -2
  179. package/src/overlays/reset-usage-selector.ts +130 -21
  180. package/src/overlays/session-account-selector.ts +36 -0
  181. package/src/overlays/session-info-overlay.ts +199 -76
  182. package/src/overlays/session-selector.ts +15 -10
  183. package/src/overlays/settings-selector.ts +3 -0
  184. package/src/overlays/show-images-selector.ts +1 -0
  185. package/src/overlays/snapcompact-shape-preview.ts +13 -6
  186. package/src/overlays/theme-selector.ts +6 -1
  187. package/src/overlays/thinking-selector.ts +8 -4
  188. package/src/overlays/tree-selector.ts +117 -2
  189. package/src/overlays/usage-dashboard.ts +498 -144
  190. package/src/prompt/composer-cache.ts +4 -1
  191. package/src/prompt/composer.ts +200 -74
  192. package/src/prompt/custom-editor.ts +168 -65
  193. package/src/prompt/image-references.d.ts +22 -0
  194. package/src/prompt/image-references.ts +79 -2
  195. package/src/prompt/internal-url-autocomplete.ts +1 -0
  196. package/src/prompt/macos-spelling.ts +28 -5
  197. package/src/prompt/prose-gate.ts +3 -0
  198. package/src/prompt/queued-messages.ts +9 -2
  199. package/src/prompt/tips.txt +1 -2
  200. package/src/prompt/typing-word.ts +59 -0
  201. package/src/prompt/welcome.ts +97 -45
  202. package/src/render/code-cell.ts +18 -5
  203. package/src/render/hanging-wrap.d.ts +7 -0
  204. package/src/render/hanging-wrap.ts +58 -0
  205. package/src/render/hyperlink.d.ts +2 -0
  206. package/src/render/hyperlink.ts +5 -0
  207. package/src/render/render-utils.d.ts +9 -1
  208. package/src/render/render-utils.ts +16 -2
  209. package/src/render/utils.d.ts +1 -1
  210. package/src/render/utils.ts +1 -1
  211. package/src/render/width-aware-text.ts +6 -5
  212. package/src/setup/scenes/splash.ts +22 -8
  213. package/src/status-line/component.ts +79 -11
  214. package/src/status-line/context-usage.ts +176 -73
  215. package/src/status-line/footer.ts +18 -35
  216. package/src/status-line/host.ts +6 -1
  217. package/src/status-line/segments.ts +16 -5
  218. package/src/status-line/startup.ts +6 -6
  219. package/src/status-line/types.ts +1 -0
  220. package/src/stdin-buffer.d.ts +7 -0
  221. package/src/stdin-buffer.ts +300 -62
  222. package/src/terminal-capabilities.d.ts +16 -1
  223. package/src/terminal-capabilities.ts +66 -28
  224. package/src/terminal-multiplexer.d.ts +7 -0
  225. package/src/terminal-multiplexer.ts +14 -0
  226. package/src/terminal.d.ts +8 -4
  227. package/src/terminal.ts +190 -63
  228. package/src/theme/symbols.d.ts +1 -1
  229. package/src/theme/symbols.ts +1 -0
  230. package/src/theme/theme-class.ts +1 -0
  231. package/src/theme/theme.d.ts +11 -0
  232. package/src/theme/theme.ts +46 -14
  233. package/src/thinking.d.ts +5 -0
  234. package/src/thinking.ts +9 -1
  235. package/src/tmux.d.ts +39 -4
  236. package/src/tmux.ts +110 -16
  237. package/src/tools/ask.ts +20 -9
  238. package/src/tools/ast-edit.d.ts +2 -2
  239. package/src/tools/ast-edit.ts +30 -7
  240. package/src/tools/ast-grep.d.ts +2 -2
  241. package/src/tools/ast-grep.ts +39 -35
  242. package/src/tools/bash.d.ts +6 -1
  243. package/src/tools/bash.ts +15 -9
  244. package/src/tools/debug.ts +2 -1
  245. package/src/tools/default-renderer.ts +3 -2
  246. package/src/tools/edit.d.ts +2 -0
  247. package/src/tools/edit.ts +100 -21
  248. package/src/tools/eval.d.ts +53 -4
  249. package/src/tools/eval.ts +173 -137
  250. package/src/tools/find.d.ts +14 -4
  251. package/src/tools/find.ts +71 -18
  252. package/src/tools/glob.d.ts +3 -3
  253. package/src/tools/glob.ts +51 -17
  254. package/src/tools/grep.d.ts +2 -2
  255. package/src/tools/grep.ts +99 -22
  256. package/src/tools/grouped-file-output.d.ts +9 -4
  257. package/src/tools/grouped-file-output.ts +21 -6
  258. package/src/tools/lsp.ts +30 -18
  259. package/src/tools/mcp.ts +2 -2
  260. package/src/tools/native-view.d.ts +1 -1
  261. package/src/tools/native-view.ts +2 -2
  262. package/src/tools/read.d.ts +32 -10
  263. package/src/tools/read.ts +89 -43
  264. package/src/tools/renderer.d.ts +2 -0
  265. package/src/tools/renderer.ts +2 -0
  266. package/src/tools/task.d.ts +31 -0
  267. package/src/tools/task.ts +40 -7
  268. package/src/tools/todo.ts +3 -2
  269. package/src/tools/wait.ts +16 -30
  270. package/src/tools/web-search-types.d.ts +1 -0
  271. package/src/tools/web-search-types.ts +1 -0
  272. package/src/tools/web-search.ts +2 -1
  273. package/src/tools/write.d.ts +2 -0
  274. package/src/tools/write.ts +36 -12
  275. package/src/tools/yield.ts +2 -2
  276. package/src/tui.d.ts +20 -3
  277. package/src/tui.ts +160 -43
  278. package/src/utils.ts +5 -3
  279. package/dist/types/tools/eval-format/code-mode-declarations.d.ts +0 -5
  280. package/src/tools/eval-format/code-mode-declarations.ts +0 -61
@@ -9,15 +9,16 @@ import type { Theme } from "../theme/theme";
9
9
  import {
10
10
  currentResults,
11
11
  findBaselineMetric,
12
+ findBaselineResult,
12
13
  findBaselineRunNumber,
13
14
  findBaselineSecondary,
14
15
  formatElapsed,
15
16
  isBetter,
16
17
  } from "./autoresearch-data";
17
- import { formatNum, type ExperimentResult, type ExperimentState } from "../tools/autoresearch";
18
- import type { TspSpan, TspTableColumn, TspText } from "@linxiraos/pi-wire";
18
+ import { fmtNum, formatNum, type ExperimentResult, type ExperimentState } from "../tools/autoresearch";
19
+ import type { TspScrollBy, TspSpan, TspTableColumn, TspText } from "@linxiraos/pi-wire";
19
20
  import { card, col, elapsed, keyed, node, row, span, text } from "../native/describe";
20
- import type { DescribeContext, NativeNode, NativeUiEvent } from "../native/node";
21
+ import type { DescribeContext, NativeNode, NativeScroll, NativeUiEvent } from "../native/node";
21
22
  import { actionBar, actionButton, hintsRow } from "../native/overlay";
22
23
  import { Memo } from "../native/memo";
23
24
  import { isNativeRendering } from "../native/state";
@@ -147,11 +148,17 @@ export function createDashboardController(): DashboardController {
147
148
  }
148
149
 
149
150
  let scrollView: ScrollView | undefined;
151
+ /** Natively the terminal scrolls the sheet body: bumped per scroll key. */
152
+ let scroll: NativeScroll | undefined;
150
153
  const native = new Memo();
151
154
  return {
152
- /** A glass sheet titled by the experiment (the panels' sheet style); the body is borderless. */
155
+ /** A glass sheet titled by the experiment; what it is follows, muted. */
153
156
  get nativeOverlay() {
154
- return { role: "zeta.overlay.autoresearch", head: describeTitle(runtime), size: "lg" as const };
157
+ return {
158
+ role: "zeta.overlay.autoresearch",
159
+ head: describeSheetHead(runtime),
160
+ size: "lg" as const,
161
+ };
155
162
  },
156
163
  describe(cx: DescribeContext): NativeNode {
157
164
  const state = runtime.state;
@@ -159,6 +166,7 @@ export function createDashboardController(): DashboardController {
159
166
  return native.get(
160
167
  [
161
168
  chart,
169
+ scroll,
162
170
  state,
163
171
  state.results.length,
164
172
  state.results.at(-1),
@@ -171,29 +179,9 @@ export function createDashboardController(): DashboardController {
171
179
  runtime.runningExperiment,
172
180
  ],
173
181
  () => {
174
- const running = runtime.runningExperiment;
175
- const children: NativeNode[] = describeDashboard(runtime, 0, chart);
176
- if (running) {
177
- children.push(
178
- row(
179
- [
180
- node("spinner", { label: [span("running", "warning")], tone: "warning" }),
181
- elapsed(Date.now() - running.startedAt),
182
- text([span(replaceTabs(running.command), "warning")], { truncate: "end" }),
183
- ],
184
- { gap: "sm" },
185
- ),
186
- );
187
- }
188
- children.push(
189
- hintsRow([
190
- { keys: ["up", "down", "j", "k"], label: "scroll" },
191
- { keys: ["pageUp", "pageDown"], label: "page" },
192
- { keys: ["g", "shift+g"], label: "top/bottom" },
193
- ]),
194
- );
182
+ const children = describeSheet(runtime, chart);
195
183
  children.push(actionBar([null, actionButton("Close", "close", { keys: "escape" })]));
196
- return col(children, { gap: "md", role: "zeta.app.autoresearch" });
184
+ return { ...col(children, { gap: "md", role: "zeta.app.autoresearch" }), scroll };
197
185
  },
198
186
  );
199
187
  },
@@ -223,19 +211,27 @@ export function createDashboardController(): DashboardController {
223
211
  done(undefined);
224
212
  return;
225
213
  }
214
+ let by: TspScrollBy | undefined;
226
215
  if (matchesKey(data, "up") || matchesKey(data, "k")) {
216
+ by = "line-up";
227
217
  scrollView?.scroll(-1);
228
218
  } else if (matchesKey(data, "down") || matchesKey(data, "j")) {
219
+ by = "line-down";
229
220
  scrollView?.scroll(1);
230
221
  } else if (matchesKey(data, "pageUp")) {
222
+ by = "page-up";
231
223
  scrollView?.page(-1);
232
224
  } else if (matchesKey(data, "pageDown")) {
225
+ by = "page-down";
233
226
  scrollView?.page(1);
234
227
  } else if (data === "g") {
228
+ by = "start";
235
229
  scrollView?.scrollToTop();
236
230
  } else if (data === "G") {
231
+ by = "end";
237
232
  scrollView?.scrollToBottom();
238
233
  }
234
+ if (by) scroll = { by, n: (scroll?.n ?? 0) + 1 };
239
235
  tui.requestRender();
240
236
  },
241
237
  invalidate(): void {},
@@ -513,6 +509,427 @@ function describeDashboard(runtime: AutoresearchDashboardRuntime, maxRows: numbe
513
509
  return [keyed(col(children, { gap: "sm" }), "dashboard")];
514
510
  }
515
511
 
512
+ // -- native sheet --------------------------------------------------------------
513
+
514
+ /** Sheet head: the experiment's name, then what the sheet is. */
515
+ function describeSheetHead(runtime: AutoresearchDashboardRuntime): TspSpan[] {
516
+ const name = runtime.state.name?.trim();
517
+ return name ? [span(replaceTabs(name)), span(" · Autoresearch", "muted")] : [span("Autoresearch")];
518
+ }
519
+
520
+ const STATUS_LABEL: Record<ExperimentResult["status"], string> = {
521
+ keep: "kept",
522
+ discard: "discarded",
523
+ crash: "crashed",
524
+ checks_failed: "checks failed",
525
+ };
526
+
527
+ const STATUS_TOKEN: Record<ExperimentResult["status"], string> = {
528
+ keep: "success",
529
+ discard: "dim",
530
+ crash: "error",
531
+ checks_failed: "error",
532
+ };
533
+
534
+ /** Fraction digits (0–2) that show every value exactly, so numbers that sit together align. */
535
+ function fractionDigits(values: readonly number[]): number {
536
+ let digits = 0;
537
+ for (const value of values) {
538
+ while (digits < 2 && Math.abs(Math.round(value * 10 ** digits) - value * 10 ** digits) > 1e-6) digits += 1;
539
+ }
540
+ return digits;
541
+ }
542
+
543
+ /** A metric split into its number and unit, so the unit can sit a step quieter. */
544
+ function metricSpans(value: number, unit: string, digits: number, numberStyle?: string): TspSpan[] {
545
+ const spans = [span(fmtNum(value, digits), numberStyle)];
546
+ if (unit) spans.push(span(unit, "muted"));
547
+ return spans;
548
+ }
549
+
550
+ /** Signed change from `base` to `value` in percent (`−12.4%`), or null when there is nothing to compare. */
551
+ function percentChange(value: number, base: number | null | undefined): string | null {
552
+ if (base === null || base === undefined || base === 0 || value === base) return null;
553
+ const delta = ((value - base) / base) * 100;
554
+ return `${delta > 0 ? "+" : "−"}${Math.abs(delta).toFixed(1)}%`;
555
+ }
556
+
557
+ /** What the sheet's mode says, with the span token it reads in. */
558
+ function modeLabel(runtime: AutoresearchDashboardRuntime): [label: string, token: string] {
559
+ const status = renderModeStatus(runtime, runtime.state);
560
+ switch (status) {
561
+ case "mode on":
562
+ return ["Mode on", "success"];
563
+ case "baseline pending":
564
+ return ["Baseline pending", "warning"];
565
+ case "segment complete":
566
+ return ["Segment complete", "muted"];
567
+ default:
568
+ return ["Mode off", "muted"];
569
+ }
570
+ }
571
+
572
+ /**
573
+ * The overlay's body: the goal and setup, the headline numbers (best,
574
+ * baseline, confidence, runs), what is happening now, the metric per run and
575
+ * the runs newest first. Empty segments explain what comes next instead.
576
+ */
577
+ function describeSheet(runtime: AutoresearchDashboardRuntime, chart: boolean): NativeNode[] {
578
+ const state = runtime.state;
579
+ const current = currentResults(state.results, state.currentSegment);
580
+ const archived = state.results.length - current.length;
581
+ const children: NativeNode[] = [describeIntro(runtime, archived > 0)];
582
+ const now = describeNow(runtime);
583
+ if (now) children.push(now);
584
+ if (current.length === 0) {
585
+ children.push(describeEmpty(runtime, archived));
586
+ return children;
587
+ }
588
+ children.push(describeStats(runtime, current));
589
+ const indexed = state.results
590
+ .map((result, index) => ({ result, index }))
591
+ .filter(({ result }) => result.segment === state.currentSegment);
592
+ if (chart && indexed.length >= 2) children.push(describeTrend(runtime, indexed));
593
+ children.push(describeRuns(runtime, indexed));
594
+ if (archived > 0) {
595
+ children.push(
596
+ keyed(
597
+ text([span(`${archived} ${archived === 1 ? "run" : "runs"} archived from earlier segments`)], {
598
+ role: "zeta.autoresearch.note",
599
+ }),
600
+ "archived",
601
+ ),
602
+ );
603
+ }
604
+ return children;
605
+ }
606
+
607
+ /** The goal as a sentence, then one quiet line of setup: mode, metric and direction, segment, branch. */
608
+ function describeIntro(runtime: AutoresearchDashboardRuntime, segmented: boolean): NativeNode {
609
+ const state = runtime.state;
610
+ const children: NativeNode[] = [];
611
+ const goal = state.goal?.trim();
612
+ if (goal) children.push(keyed(text(replaceTabs(goal), { wrap: "word", role: "zeta.autoresearch.goal" }), "goal"));
613
+ const [mode, modeToken] = modeLabel(runtime);
614
+ const meta: TspSpan[] = [
615
+ span("● ", modeToken),
616
+ span(mode),
617
+ span(" · "),
618
+ span(state.metricName, "mono"),
619
+ span(`, ${state.bestDirection} is better`),
620
+ ];
621
+ if (segmented) meta.push(span(" · "), span(`Segment ${state.currentSegment + 1}`));
622
+ if (state.maxExperiments !== null) meta.push(span(" · "), span(`${state.maxExperiments} runs per segment`));
623
+ if (state.branch) meta.push(span(" · "), span(replaceTabs(state.branch), "mono"));
624
+ children.push(keyed(text(meta, { truncate: "end", role: "zeta.autoresearch.meta" }), "meta"));
625
+ return keyed(col(children, { gap: "xs", role: "zeta.autoresearch.intro" }), "intro");
626
+ }
627
+
628
+ /** What is happening right now: a benchmark running, or a finished run waiting to be logged. */
629
+ function describeNow(runtime: AutoresearchDashboardRuntime): NativeNode | undefined {
630
+ const state = runtime.state;
631
+ const running = runtime.runningExperiment;
632
+ if (running) {
633
+ return keyed(
634
+ row(
635
+ [
636
+ node("spinner", { label: [span("Running")], tone: "pending" }),
637
+ elapsed(Date.now() - running.startedAt),
638
+ text([span(replaceTabs(running.command), "mono")], {
639
+ truncate: "end",
640
+ role: "zeta.autoresearch.command",
641
+ }),
642
+ ],
643
+ { gap: "sm", align: "center", role: "zeta.autoresearch.running", tone: "pending" },
644
+ ),
645
+ "now",
646
+ );
647
+ }
648
+ const pending = runtime.lastRunSummary;
649
+ if (!pending) return undefined;
650
+ const result: TspSpan[] = [span(`Run #${pending.runNumber} ${pending.passed ? "passed" : "failed"}`, "strong")];
651
+ if (pending.parsedPrimary !== null) {
652
+ result.push(
653
+ span(` · ${state.metricName} `),
654
+ ...metricSpans(pending.parsedPrimary, state.metricUnit, fractionDigits([pending.parsedPrimary]), "mono"),
655
+ );
656
+ }
657
+ return keyed(
658
+ row(
659
+ [
660
+ text(result, { truncate: "end" }),
661
+ text([span("Log it with "), span("log_experiment", "mono"), span(" before the next run")], {
662
+ truncate: "end",
663
+ role: "zeta.autoresearch.next",
664
+ }),
665
+ ],
666
+ { gap: "md", align: "center", role: "zeta.autoresearch.pending", tone: "warning" },
667
+ ),
668
+ "now",
669
+ );
670
+ }
671
+
672
+ /** No runs in this segment yet: what the dashboard is waiting for. */
673
+ function describeEmpty(runtime: AutoresearchDashboardRuntime, archived: number): NativeNode {
674
+ const [title, detail] = runtime.lastRunSummary
675
+ ? ["Waiting for the first log", "Log the finished run to set this segment's baseline."]
676
+ : runtime.runningExperiment
677
+ ? ["Measuring the baseline", "Every later run is compared with this first one."]
678
+ : runtime.autoresearchMode
679
+ ? [
680
+ "No baseline yet",
681
+ "Run the benchmark once and log it as the baseline. Every later run is compared with it.",
682
+ ]
683
+ : ["No experiments logged yet", "Turn autoresearch mode on to start measuring."];
684
+ const children: NativeNode[] = [
685
+ text([span(title)], { role: "zeta.autoresearch.empty.title" }),
686
+ text([span(detail)], { wrap: "word", role: "zeta.autoresearch.empty.detail" }),
687
+ ];
688
+ if (archived > 0) {
689
+ children.push(
690
+ text([span(`${archived} ${archived === 1 ? "run" : "runs"} archived from earlier segments`)], {
691
+ role: "zeta.autoresearch.note",
692
+ }),
693
+ );
694
+ }
695
+ return keyed(col(children, { gap: "xs", align: "center", role: "zeta.autoresearch.empty" }), "empty");
696
+ }
697
+
698
+ /** One headline number: a label, the value, and one line saying what it means. */
699
+ function stat(key: string, label: string, value: TspSpan[], detail: NativeNode[]): NativeNode {
700
+ return keyed(
701
+ col(
702
+ [
703
+ text(label, { role: "zeta.autoresearch.stat.k" }),
704
+ text(value, { wrap: "none", truncate: "end", role: "zeta.autoresearch.stat.v" }),
705
+ ...detail,
706
+ ],
707
+ { role: "zeta.autoresearch.stat" },
708
+ ),
709
+ key,
710
+ );
711
+ }
712
+
713
+ function statDetail(spans: TspSpan[]): NativeNode {
714
+ return text(spans, { truncate: "end", role: "zeta.autoresearch.stat.sub" });
715
+ }
716
+
717
+ /** Best, baseline, confidence and the run tally, then the secondary metrics at the best run. */
718
+ function describeStats(runtime: AutoresearchDashboardRuntime, current: readonly ExperimentResult[]): NativeNode {
719
+ const state = runtime.state;
720
+ const unit = state.metricUnit;
721
+ const best = findBestResult(state);
722
+ const baseline = findBaselineMetric(state.results, state.currentSegment);
723
+ const baselineRun = findBaselineRunNumber(state.results, state.currentSegment);
724
+ const baselineResult = current.find(result => result.status === "keep" && !result.flagged);
725
+ const counts = statusCounts(current);
726
+ const digits = fractionDigits(
727
+ [best?.result.metric, baseline].filter((value): value is number => value !== undefined && value !== null),
728
+ );
729
+ const tiles: NativeNode[] = [];
730
+
731
+ if (best) {
732
+ const run = best.result.runNumber ?? best.index + 1;
733
+ const change = percentChange(best.result.metric, baseline);
734
+ const better = baseline !== null && isBetter(best.result.metric, baseline, state.bestDirection);
735
+ tiles.push(
736
+ stat("best", "Best", metricSpans(best.result.metric, unit, digits), [
737
+ statDetail(
738
+ change
739
+ ? [span(change, better ? "success" : "error"), span(` vs baseline · run #${run}`)]
740
+ : [span(`The baseline · run #${run}`)],
741
+ ),
742
+ ]),
743
+ );
744
+ } else {
745
+ tiles.push(stat("best", "Best", [span("—", "muted")], [statDetail([span("No kept run yet")])]));
746
+ }
747
+
748
+ const baselineDetail: TspSpan[] = [];
749
+ if (baselineRun !== null) baselineDetail.push(span(`Run #${baselineRun}`));
750
+ if (baselineResult?.commit) baselineDetail.push(span(" · "), span(baselineResult.commit.slice(0, 7), "mono"));
751
+ tiles.push(
752
+ stat("baseline", "Baseline", baseline === null ? [span("—", "muted")] : metricSpans(baseline, unit, digits), [
753
+ statDetail(baselineDetail.length > 0 ? baselineDetail : [span("Not logged yet")]),
754
+ ]),
755
+ );
756
+
757
+ const confidence = state.confidence;
758
+ tiles.push(
759
+ stat(
760
+ "confidence",
761
+ "Confidence",
762
+ confidence === null
763
+ ? [span("—", "muted")]
764
+ : [span(confidence.toFixed(1), confidenceToken(confidence)), span("×", "muted")],
765
+ [
766
+ statDetail([
767
+ span(
768
+ confidence === null
769
+ ? "Needs three runs and a gain"
770
+ : confidence >= 2
771
+ ? "Gain is clear of run noise"
772
+ : confidence >= 1
773
+ ? "Gain is near run noise"
774
+ : "Gain is within run noise",
775
+ ),
776
+ ]),
777
+ ],
778
+ ),
779
+ );
780
+
781
+ const total = Math.max(state.maxExperiments ?? 0, current.length);
782
+ const failed = counts.crash + counts.checks_failed;
783
+ const tally: TspSpan[] = [span(`${counts.keep} kept · ${counts.discard} discarded`)];
784
+ if (failed > 0) tally.push(span(" · "), span(`${failed} failed`, "error"));
785
+ tiles.push(
786
+ stat(
787
+ "runs",
788
+ "Runs",
789
+ state.maxExperiments !== null
790
+ ? [span(String(current.length)), span(` / ${state.maxExperiments}`, "muted")]
791
+ : [span(String(current.length))],
792
+ [
793
+ node("meter", {
794
+ value: current.length / total,
795
+ parts: [
796
+ { value: counts.keep / total, token: "success", label: `${counts.keep} kept` },
797
+ { value: counts.discard / total, token: "muted", label: `${counts.discard} discarded` },
798
+ {
799
+ value: failed / total,
800
+ token: "error",
801
+ label: `${counts.crash} crashed · ${counts.checks_failed} checks failed`,
802
+ },
803
+ ],
804
+ size: "sm",
805
+ role: "zeta.autoresearch.tally",
806
+ }),
807
+ statDetail(tally),
808
+ ],
809
+ ),
810
+ );
811
+
812
+ const children: NativeNode[] = [row(tiles, { role: "zeta.autoresearch.stats" })];
813
+ if (best && state.secondaryMetrics.length > 0) {
814
+ const baselineSecondary = findBaselineSecondary(state.results, state.currentSegment, state.secondaryMetrics);
815
+ const spans: TspSpan[] = [];
816
+ for (const metric of state.secondaryMetrics) {
817
+ const value = best.result.metrics[metric.name];
818
+ if (value === undefined) continue;
819
+ spans.push(span(spans.length === 0 ? "At best " : " · "));
820
+ spans.push(
821
+ span(`${metric.name} `, "mono muted"),
822
+ ...metricSpans(value, metric.unit, fractionDigits([value]), "mono"),
823
+ );
824
+ const change = percentChange(value, baselineSecondary[metric.name]);
825
+ if (change) spans.push(span(` ${change}`, "mono"));
826
+ }
827
+ if (spans.length > 0) {
828
+ children.push(keyed(text(spans, { truncate: "end", role: "zeta.autoresearch.secondary" }), "secondary"));
829
+ }
830
+ }
831
+ return keyed(col(children, { role: "zeta.autoresearch.summary" }), "summary");
832
+ }
833
+
834
+ /** The primary metric per run, in run order. */
835
+ function describeTrend(
836
+ runtime: AutoresearchDashboardRuntime,
837
+ indexed: readonly { result: ExperimentResult; index: number }[],
838
+ ): NativeNode {
839
+ const state = runtime.state;
840
+ return node(
841
+ "chart",
842
+ {
843
+ kind: "bars",
844
+ series: indexed.map(({ result, index }) => {
845
+ const run = result.runNumber ?? index + 1;
846
+ return {
847
+ label: `#${run}`,
848
+ value: result.metric,
849
+ title: `#${run} ${STATUS_LABEL[result.status]} · ${formatNum(result.metric, state.metricUnit)} · ${replaceTabs(result.description)}`,
850
+ };
851
+ }),
852
+ token: "accent",
853
+ summary: [span(`${state.metricName} per run, ${state.bestDirection} is better`)],
854
+ size: "md",
855
+ role: "zeta.autoresearch.trend",
856
+ },
857
+ undefined,
858
+ "trend",
859
+ );
860
+ }
861
+
862
+ /** Every run of the segment, newest first: outcome, metric and its change from the baseline, commit, what changed. */
863
+ function describeRuns(
864
+ runtime: AutoresearchDashboardRuntime,
865
+ indexed: readonly { result: ExperimentResult; index: number }[],
866
+ ): NativeNode {
867
+ const state = runtime.state;
868
+ const baseline = findBaselineResult(state.results, state.currentSegment);
869
+ const best = findBestResult(state);
870
+ // Cells carry bare numbers at one precision per column; the unit goes in the head unless the name says it.
871
+ const head = (name: string, unit: string): TspSpan[] =>
872
+ unit && !name.toLowerCase().endsWith(`_${unit.toLowerCase()}`)
873
+ ? [span(name, "mono"), span(` ${unit}`, "muted")]
874
+ : [span(name, "mono")];
875
+ const measured = indexed.filter(({ result }) => result.status !== "crash").map(({ result }) => result);
876
+ const metricDigits = fractionDigits(measured.map(result => result.metric));
877
+ const secondaryDigits = state.secondaryMetrics.map(metric =>
878
+ fractionDigits(
879
+ measured.map(result => result.metrics[metric.name]).filter((value): value is number => value !== undefined),
880
+ ),
881
+ );
882
+ const cols: TspTableColumn[] = [
883
+ { id: "run", head: "#", align: "end", priority: 3 },
884
+ { id: "status", head: "Outcome", priority: 4 },
885
+ { id: "metric", head: head(state.metricName, state.metricUnit), align: "end", priority: 5 },
886
+ { id: "change", head: "vs baseline", align: "end", priority: 4 },
887
+ ...state.secondaryMetrics.map((metric, index): TspTableColumn => ({
888
+ id: `secondary${index}`,
889
+ head: head(metric.name, metric.unit),
890
+ align: "end",
891
+ priority: 1,
892
+ })),
893
+ { id: "commit", head: "Commit", priority: 2 },
894
+ { id: "description", head: "Change", truncate: "end", grow: 1, priority: 3 },
895
+ ];
896
+ const rows = indexed
897
+ .slice()
898
+ .reverse()
899
+ .map(({ result, index }) => {
900
+ const isBest = best?.index === index;
901
+ const isBaseline = result === baseline;
902
+ const crashed = result.status === "crash";
903
+ const change = isBaseline || crashed ? null : percentChange(result.metric, baseline?.metric);
904
+ const better = baseline !== null && isBetter(result.metric, baseline.metric, state.bestDirection);
905
+ const cells: Record<string, TspText> = {
906
+ run: [span(String(result.runNumber ?? index + 1), "mono")],
907
+ status: isBest
908
+ ? [span("● ", "success"), span("best", "strong")]
909
+ : [span("● ", STATUS_TOKEN[result.status]), span(STATUS_LABEL[result.status])],
910
+ metric: crashed
911
+ ? [span("—", "muted")]
912
+ : [span(fmtNum(result.metric, metricDigits), isBest ? "mono strong" : "mono")],
913
+ change: isBaseline
914
+ ? [span("baseline", "muted")]
915
+ : change
916
+ ? [span(change, `mono ${result.status === "keep" ? (better ? "success" : "error") : "muted"}`)]
917
+ : [span("—", "muted")],
918
+ commit: [span(result.commit ? result.commit.slice(0, 7) : "—", result.commit ? "mono" : "muted")],
919
+ description: [span(replaceTabs(result.description))],
920
+ };
921
+ state.secondaryMetrics.forEach((metric, metricIndex) => {
922
+ const value = result.metrics[metric.name];
923
+ cells[`secondary${metricIndex}`] =
924
+ value === undefined
925
+ ? [span("—", "muted")]
926
+ : [span(fmtNum(value, secondaryDigits[metricIndex] ?? 0), "mono")];
927
+ });
928
+ return { id: String(index), cells };
929
+ });
930
+ return node("table", { cols, rows, role: "zeta.autoresearch.runs" }, undefined, "runs");
931
+ }
932
+
516
933
  function statusCounts(results: readonly ExperimentResult[]): Record<ExperimentResult["status"], number> {
517
934
  const counts: Record<ExperimentResult["status"], number> = { keep: 0, discard: 0, crash: 0, checks_failed: 0 };
518
935
  for (const result of results) counts[result.status] += 1;
@@ -245,7 +245,7 @@ export class RawSseViewerComponent implements Component {
245
245
  /** The action bar's buttons run the same code as their keys. */
246
246
  handleNativeEvent(event: NativeUiEvent): void {
247
247
  if (event.type !== "action") return;
248
- if (event.act === "copy") this.#copyAll();
248
+ if (event.act === "copy-raw") this.#copyAll();
249
249
  else if (event.act === "close") this.#close();
250
250
  }
251
251
 
@@ -305,7 +305,7 @@ export class RawSseViewerComponent implements Component {
305
305
  "status",
306
306
  ),
307
307
  actionBar([
308
- actionButton("Copy raw", "copy", { keys: "ctrl+c", tone: "accent" }),
308
+ actionButton("Copy raw", "copy-raw", { keys: "ctrl+c", tone: "accent" }),
309
309
  null,
310
310
  actionButton("Close", "close", { keys: "escape" }),
311
311
  ]),
@@ -570,6 +570,43 @@ interface AssetDocument {
570
570
  /** Result of a left click inside the pane. */
571
571
  export type PaneClick = { type: "hunk-action"; hunk: HunkBlock; action: HunkAction } | { type: "handled" } | null;
572
572
 
573
+ /**
574
+ * The heading of one side of a media/binary change (`Before · PNG · Git LFS`):
575
+ * `label` plus the side's kind, shared by the ANSI pane and the native compare.
576
+ */
577
+ export function assetTitle(label: string, side: FileAssetSide): string {
578
+ let kind: string;
579
+ let lfs = false;
580
+ switch (side.kind) {
581
+ case "empty":
582
+ return label;
583
+ case "image":
584
+ kind =
585
+ side.image.sourceMimeType === "image/svg+xml"
586
+ ? "SVG"
587
+ : side.image.sourceMimeType.replace(/^image\//, "").toUpperCase();
588
+ lfs = side.image.lfsOid !== undefined;
589
+ break;
590
+ case "text":
591
+ kind = tuiText("gitKindText", "Text");
592
+ lfs = side.lfsOid !== undefined;
593
+ break;
594
+ case "binary":
595
+ kind = tuiText("gitKindBinary", "Binary");
596
+ lfs = side.lfsOid !== undefined;
597
+ break;
598
+ case "tooLarge":
599
+ kind = tuiText("gitKindTooLarge", "Too large");
600
+ lfs = side.lfsOid !== undefined;
601
+ break;
602
+ case "lfsMissing":
603
+ kind = tuiText("gitKindLfsMissing", "LFS missing");
604
+ lfs = true;
605
+ break;
606
+ }
607
+ return `${label} · ${kind}${lfs ? " · Git LFS" : ""}`;
608
+ }
609
+
573
610
  /**
574
611
  * Scrollable diff viewport. The root component feeds it key/mouse input and
575
612
  * composes its rendered lines into the frame.
@@ -1129,17 +1166,11 @@ export class DiffPane {
1129
1166
  for (let index = 0; index < height; index++) {
1130
1167
  const leftSource =
1131
1168
  index === 0
1132
- ? centerLine(
1133
- theme.bold(this.#assetTitle(tuiText("gitAssetBefore", "Before"), asset.old)),
1134
- leftWidth,
1135
- ).trimEnd()
1169
+ ? centerLine(theme.bold(assetTitle(tuiText("gitAssetBefore", "Before"), asset.old)), leftWidth).trimEnd()
1136
1170
  : (oldLines[index - 1] ?? "");
1137
1171
  const rightSource =
1138
1172
  index === 0
1139
- ? centerLine(
1140
- theme.bold(this.#assetTitle(tuiText("gitAssetAfter", "After"), asset.new)),
1141
- rightWidth,
1142
- ).trimEnd()
1173
+ ? centerLine(theme.bold(assetTitle(tuiText("gitAssetAfter", "After"), asset.new)), rightWidth).trimEnd()
1143
1174
  : (newLines[index - 1] ?? "");
1144
1175
  const left = truncateToWidth(leftSource, leftWidth);
1145
1176
  const right = truncateToWidth(rightSource, rightWidth);
@@ -1218,39 +1249,6 @@ export class DiffPane {
1218
1249
  return Array.from({ length: height }, (_, index) => content[index - top] ?? "");
1219
1250
  }
1220
1251
 
1221
- #assetTitle(label: string, side: FileAssetSide): string {
1222
- let kind: string;
1223
- let lfs = false;
1224
- switch (side.kind) {
1225
- case "empty":
1226
- return label;
1227
- case "image":
1228
- kind =
1229
- side.image.sourceMimeType === "image/svg+xml"
1230
- ? "SVG"
1231
- : side.image.sourceMimeType.replace(/^image\//, "").toUpperCase();
1232
- lfs = side.image.lfsOid !== undefined;
1233
- break;
1234
- case "text":
1235
- kind = tuiText("gitKindText", "Text");
1236
- lfs = side.lfsOid !== undefined;
1237
- break;
1238
- case "binary":
1239
- kind = tuiText("gitKindBinary", "Binary");
1240
- lfs = side.lfsOid !== undefined;
1241
- break;
1242
- case "tooLarge":
1243
- kind = tuiText("gitKindTooLarge", "Too large");
1244
- lfs = side.lfsOid !== undefined;
1245
- break;
1246
- case "lfsMissing":
1247
- kind = tuiText("gitKindLfsMissing", "LFS missing");
1248
- lfs = true;
1249
- break;
1250
- }
1251
- return `${label} · ${kind}${lfs ? " · Git LFS" : ""}`;
1252
- }
1253
-
1254
1252
  #renderStreaming(width: number, height: number): string[] {
1255
1253
  const streaming = this.#streaming;
1256
1254
  if (!streaming) return [];