@oh-my-pi/pi-tui 18.9.2 → 18.10.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 (59) hide show
  1. package/CHANGELOG.md +53 -105
  2. package/dist/types/app-keybindings.d.ts +5 -0
  3. package/dist/types/chat/assistant-message.d.ts +1 -1
  4. package/dist/types/chat/user-message.d.ts +3 -2
  5. package/dist/types/chrome/countdown-timer.d.ts +3 -2
  6. package/dist/types/debug-server.d.ts +2 -0
  7. package/dist/types/overlays/annotation-overlay.d.ts +19 -0
  8. package/dist/types/overlays/ask-dialog.d.ts +11 -6
  9. package/dist/types/overlays/btw-history-panel.d.ts +2 -6
  10. package/dist/types/overlays/hook-editor.d.ts +7 -2
  11. package/dist/types/overlays/hook-input.d.ts +12 -4
  12. package/dist/types/overlays/jobs-panel.d.ts +8 -7
  13. package/dist/types/overlays/login-dialog.d.ts +8 -3
  14. package/dist/types/overlays/mcp-add-wizard.d.ts +9 -1
  15. package/dist/types/overlays/pause-screen.d.ts +5 -0
  16. package/dist/types/overlays/report-panel.d.ts +5 -1
  17. package/dist/types/overlays/reset-usage-selector.d.ts +7 -5
  18. package/dist/types/overlays/session-account-selector.d.ts +4 -0
  19. package/dist/types/overlays/session-info-overlay.d.ts +7 -3
  20. package/dist/types/overlays/usage-dashboard.d.ts +6 -6
  21. package/dist/types/prompt/composer.d.ts +1 -20
  22. package/dist/types/status-line/context-usage.d.ts +1 -1
  23. package/dist/types/tools/grouped-file-output.d.ts +4 -1
  24. package/dist/types/tui.d.ts +10 -3
  25. package/package.json +9 -9
  26. package/src/app-keybindings.ts +5 -0
  27. package/src/apps/autoresearch-dashboard.ts +440 -27
  28. package/src/chat/assistant-message.ts +41 -13
  29. package/src/chat/user-message.ts +32 -19
  30. package/src/chrome/countdown-timer.ts +3 -3
  31. package/src/chrome/transcript-container.ts +202 -28
  32. package/src/debug-server.ts +51 -69
  33. package/src/hotkeys-markdown.ts +1 -0
  34. package/src/native/reconcile.ts +24 -6
  35. package/src/overlays/advisor-config.ts +259 -8
  36. package/src/overlays/annotation-overlay.ts +420 -168
  37. package/src/overlays/ask-dialog.ts +249 -105
  38. package/src/overlays/btw-history-panel.ts +178 -124
  39. package/src/overlays/hook-editor.ts +90 -32
  40. package/src/overlays/hook-input.ts +105 -19
  41. package/src/overlays/jobs-panel.ts +154 -132
  42. package/src/overlays/login-dialog.ts +201 -91
  43. package/src/overlays/mcp-add-wizard.ts +559 -279
  44. package/src/overlays/pause-screen.ts +81 -23
  45. package/src/overlays/plugin-selector.ts +17 -1
  46. package/src/overlays/queue-mode-selector.ts +10 -0
  47. package/src/overlays/report-panel.ts +6 -2
  48. package/src/overlays/reset-usage-selector.ts +130 -21
  49. package/src/overlays/session-account-selector.ts +36 -0
  50. package/src/overlays/session-info-overlay.ts +197 -76
  51. package/src/overlays/show-images-selector.ts +1 -0
  52. package/src/overlays/theme-selector.ts +6 -1
  53. package/src/overlays/thinking-selector.ts +8 -4
  54. package/src/overlays/usage-dashboard.ts +495 -143
  55. package/src/prompt/composer.ts +35 -72
  56. package/src/status-line/context-usage.ts +154 -57
  57. package/src/tools/grep.ts +1 -0
  58. package/src/tools/grouped-file-output.ts +11 -2
  59. package/src/tui.ts +27 -4
@@ -121,9 +121,12 @@ export declare function numberedCode(lines: readonly NumberedLine[], opts?: {
121
121
  * header's suffix or the match count as a chip), then its lines as
122
122
  * {@link numberedCode} with match lines marked and `…` between non-adjacent
123
123
  * runs. Unnumbered body lines stay plain `code`. `maxFiles` keeps only the
124
- * first files (the caller says how many were left out).
124
+ * first files (the caller says how many were left out). `file` owns the lines
125
+ * before any header: a single-file search prints none, yet its lines get the
126
+ * same file row as a directory search's.
125
127
  */
126
128
  export declare function describeGroupedOutput(lines: readonly string[], options?: {
127
129
  lang?: (path: string) => string | undefined;
128
130
  maxFiles?: number;
131
+ file?: string;
129
132
  }): NativeNode[];
@@ -115,9 +115,6 @@ export interface Component {
115
115
  * last time.
116
116
  */
117
117
  render(width: number): readonly string[];
118
- /** Inline decision panels must keep displaced settled transcript rows in
119
- * native scrollback even when mounted inside a transient editor container. */
120
- readonly retireDisplacedTranscript?: boolean;
121
118
  /**
122
119
  * Describe the component semantically for a Tern Surface Protocol
123
120
  * terminal (see `native/node.ts`). Called instead of `render()` when the
@@ -440,6 +437,16 @@ export declare class TUI extends Container {
440
437
  top: number;
441
438
  length: number;
442
439
  };
440
+ /**
441
+ * Rows of native history the last paint left on screen above a mutable
442
+ * viewport that reached the bottom row, or `undefined` when that paint left
443
+ * rows free below it (or none happened, or a destructive reset will repaint
444
+ * from row zero). Rows above a bottom-reaching viewport may already sit in
445
+ * native scrollback, which a later shrink cannot pull back down.
446
+ */
447
+ getFilledHistoryRows(): number | undefined;
448
+ /** Whether native history holds rows the frame provider committed since the last destructive reset. */
449
+ hasProviderHistory(): boolean;
443
450
  /**
444
451
  * Probe for opt-in normal-buffer click capture. The provider is read every
445
452
  * frame; while it returns true (and no fullscreen overlay owns the
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "18.9.2",
4
+ "version": "18.10.1",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -41,14 +41,14 @@
41
41
  "gen:glyphs": "bun scripts/gen-glyphs.ts"
42
42
  },
43
43
  "dependencies": {
44
- "@oh-my-pi/omptype": "18.9.2",
45
- "@oh-my-pi/pi-agent-core": "18.9.2",
46
- "@oh-my-pi/pi-ai": "18.9.2",
47
- "@oh-my-pi/pi-catalog": "18.9.2",
48
- "@oh-my-pi/pi-natives": "18.9.2",
49
- "@oh-my-pi/pi-utils": "18.9.2",
50
- "@oh-my-pi/pi-wire": "18.9.2",
51
- "@oh-my-pi/snapcompact": "18.9.2"
44
+ "@oh-my-pi/omptype": "18.10.1",
45
+ "@oh-my-pi/pi-agent-core": "18.10.1",
46
+ "@oh-my-pi/pi-ai": "18.10.1",
47
+ "@oh-my-pi/pi-catalog": "18.10.1",
48
+ "@oh-my-pi/pi-natives": "18.10.1",
49
+ "@oh-my-pi/pi-utils": "18.10.1",
50
+ "@oh-my-pi/pi-wire": "18.10.1",
51
+ "@oh-my-pi/snapcompact": "18.10.1"
52
52
  },
53
53
  "devDependencies": {
54
54
  "kitty-vt-wasm": "^0.2.0"
@@ -37,6 +37,7 @@ interface AppKeybindings {
37
37
  "app.model.selectTemporary": true;
38
38
  "app.tools.expand": true;
39
39
  "app.tools.toggleVisibility": true;
40
+ "app.ask.hide": true;
40
41
  "app.editor.external": true;
41
42
  "app.message.followUp": true;
42
43
  "app.retry": true;
@@ -138,6 +139,10 @@ export const KEYBINDINGS = {
138
139
  defaultKeys: "ctrl+shift+o",
139
140
  description: "Show or hide tool activity",
140
141
  },
142
+ "app.ask.hide": {
143
+ defaultKeys: "alt+h",
144
+ description: "Hide or show a pending ask dialog",
145
+ },
141
146
  "app.editor.external": {
142
147
  defaultKeys: "ctrl+g",
143
148
  description: "Open external editor",
@@ -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 "@oh-my-pi/pi-wire";
18
+ import { fmtNum, formatNum, type ExperimentResult, type ExperimentState } from "../tools/autoresearch";
19
+ import type { TspScrollBy, TspSpan, TspTableColumn, TspText } from "@oh-my-pi/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,13 @@ 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: "omp.overlay.autoresearch", head: describeTitle(runtime), size: "lg" as const };
157
+ return { role: "omp.overlay.autoresearch", head: describeSheetHead(runtime), size: "lg" as const };
155
158
  },
156
159
  describe(cx: DescribeContext): NativeNode {
157
160
  const state = runtime.state;
@@ -159,6 +162,7 @@ export function createDashboardController(): DashboardController {
159
162
  return native.get(
160
163
  [
161
164
  chart,
165
+ scroll,
162
166
  state,
163
167
  state.results.length,
164
168
  state.results.at(-1),
@@ -171,29 +175,9 @@ export function createDashboardController(): DashboardController {
171
175
  runtime.runningExperiment,
172
176
  ],
173
177
  () => {
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
- );
178
+ const children = describeSheet(runtime, chart);
195
179
  children.push(actionBar([null, actionButton("Close", "close", { keys: "escape" })]));
196
- return col(children, { gap: "md", role: "omp.app.autoresearch" });
180
+ return { ...col(children, { gap: "md", role: "omp.app.autoresearch" }), scroll };
197
181
  },
198
182
  );
199
183
  },
@@ -223,19 +207,27 @@ export function createDashboardController(): DashboardController {
223
207
  done(undefined);
224
208
  return;
225
209
  }
210
+ let by: TspScrollBy | undefined;
226
211
  if (matchesKey(data, "up") || matchesKey(data, "k")) {
212
+ by = "line-up";
227
213
  scrollView?.scroll(-1);
228
214
  } else if (matchesKey(data, "down") || matchesKey(data, "j")) {
215
+ by = "line-down";
229
216
  scrollView?.scroll(1);
230
217
  } else if (matchesKey(data, "pageUp")) {
218
+ by = "page-up";
231
219
  scrollView?.page(-1);
232
220
  } else if (matchesKey(data, "pageDown")) {
221
+ by = "page-down";
233
222
  scrollView?.page(1);
234
223
  } else if (data === "g") {
224
+ by = "start";
235
225
  scrollView?.scrollToTop();
236
226
  } else if (data === "G") {
227
+ by = "end";
237
228
  scrollView?.scrollToBottom();
238
229
  }
230
+ if (by) scroll = { by, n: (scroll?.n ?? 0) + 1 };
239
231
  tui.requestRender();
240
232
  },
241
233
  invalidate(): void {},
@@ -513,6 +505,427 @@ function describeDashboard(runtime: AutoresearchDashboardRuntime, maxRows: numbe
513
505
  return [keyed(col(children, { gap: "sm" }), "dashboard")];
514
506
  }
515
507
 
508
+ // -- native sheet --------------------------------------------------------------
509
+
510
+ /** Sheet head: the experiment's name, then what the sheet is. */
511
+ function describeSheetHead(runtime: AutoresearchDashboardRuntime): TspSpan[] {
512
+ const name = runtime.state.name?.trim();
513
+ return name ? [span(replaceTabs(name)), span(" · Autoresearch", "muted")] : [span("Autoresearch")];
514
+ }
515
+
516
+ const STATUS_LABEL: Record<ExperimentResult["status"], string> = {
517
+ keep: "kept",
518
+ discard: "discarded",
519
+ crash: "crashed",
520
+ checks_failed: "checks failed",
521
+ };
522
+
523
+ const STATUS_TOKEN: Record<ExperimentResult["status"], string> = {
524
+ keep: "success",
525
+ discard: "dim",
526
+ crash: "error",
527
+ checks_failed: "error",
528
+ };
529
+
530
+ /** Fraction digits (0–2) that show every value exactly, so numbers that sit together align. */
531
+ function fractionDigits(values: readonly number[]): number {
532
+ let digits = 0;
533
+ for (const value of values) {
534
+ while (digits < 2 && Math.abs(Math.round(value * 10 ** digits) - value * 10 ** digits) > 1e-6) digits += 1;
535
+ }
536
+ return digits;
537
+ }
538
+
539
+ /** A metric split into its number and unit, so the unit can sit a step quieter. */
540
+ function metricSpans(value: number, unit: string, digits: number, numberStyle?: string): TspSpan[] {
541
+ const spans = [span(fmtNum(value, digits), numberStyle)];
542
+ if (unit) spans.push(span(unit, "muted"));
543
+ return spans;
544
+ }
545
+
546
+ /** Signed change from `base` to `value` in percent (`−12.4%`), or null when there is nothing to compare. */
547
+ function percentChange(value: number, base: number | null | undefined): string | null {
548
+ if (base === null || base === undefined || base === 0 || value === base) return null;
549
+ const delta = ((value - base) / base) * 100;
550
+ return `${delta > 0 ? "+" : "−"}${Math.abs(delta).toFixed(1)}%`;
551
+ }
552
+
553
+ /** What the sheet's mode says, with the span token it reads in. */
554
+ function modeLabel(runtime: AutoresearchDashboardRuntime): [label: string, token: string] {
555
+ const status = renderModeStatus(runtime, runtime.state);
556
+ switch (status) {
557
+ case "mode on":
558
+ return ["Mode on", "success"];
559
+ case "baseline pending":
560
+ return ["Baseline pending", "warning"];
561
+ case "segment complete":
562
+ return ["Segment complete", "muted"];
563
+ default:
564
+ return ["Mode off", "muted"];
565
+ }
566
+ }
567
+
568
+ /**
569
+ * The overlay's body: the goal and setup, the headline numbers (best,
570
+ * baseline, confidence, runs), what is happening now, the metric per run and
571
+ * the runs newest first. Empty segments explain what comes next instead.
572
+ */
573
+ function describeSheet(runtime: AutoresearchDashboardRuntime, chart: boolean): NativeNode[] {
574
+ const state = runtime.state;
575
+ const current = currentResults(state.results, state.currentSegment);
576
+ const archived = state.results.length - current.length;
577
+ const children: NativeNode[] = [describeIntro(runtime, archived > 0)];
578
+ const now = describeNow(runtime);
579
+ if (now) children.push(now);
580
+ if (current.length === 0) {
581
+ children.push(describeEmpty(runtime, archived));
582
+ return children;
583
+ }
584
+ children.push(describeStats(runtime, current));
585
+ const indexed = state.results
586
+ .map((result, index) => ({ result, index }))
587
+ .filter(({ result }) => result.segment === state.currentSegment);
588
+ if (chart && indexed.length >= 2) children.push(describeTrend(runtime, indexed));
589
+ children.push(describeRuns(runtime, indexed));
590
+ if (archived > 0) {
591
+ children.push(
592
+ keyed(
593
+ text([span(`${archived} ${archived === 1 ? "run" : "runs"} archived from earlier segments`)], {
594
+ role: "omp.autoresearch.note",
595
+ }),
596
+ "archived",
597
+ ),
598
+ );
599
+ }
600
+ return children;
601
+ }
602
+
603
+ /** The goal as a sentence, then one quiet line of setup: mode, metric and direction, segment, branch. */
604
+ function describeIntro(runtime: AutoresearchDashboardRuntime, segmented: boolean): NativeNode {
605
+ const state = runtime.state;
606
+ const children: NativeNode[] = [];
607
+ const goal = state.goal?.trim();
608
+ if (goal) children.push(keyed(text(replaceTabs(goal), { wrap: "word", role: "omp.autoresearch.goal" }), "goal"));
609
+ const [mode, modeToken] = modeLabel(runtime);
610
+ const meta: TspSpan[] = [
611
+ span("● ", modeToken),
612
+ span(mode),
613
+ span(" · "),
614
+ span(state.metricName, "mono"),
615
+ span(`, ${state.bestDirection} is better`),
616
+ ];
617
+ if (segmented) meta.push(span(" · "), span(`Segment ${state.currentSegment + 1}`));
618
+ if (state.maxExperiments !== null) meta.push(span(" · "), span(`${state.maxExperiments} runs per segment`));
619
+ if (state.branch) meta.push(span(" · "), span(replaceTabs(state.branch), "mono"));
620
+ children.push(keyed(text(meta, { truncate: "end", role: "omp.autoresearch.meta" }), "meta"));
621
+ return keyed(col(children, { gap: "xs", role: "omp.autoresearch.intro" }), "intro");
622
+ }
623
+
624
+ /** What is happening right now: a benchmark running, or a finished run waiting to be logged. */
625
+ function describeNow(runtime: AutoresearchDashboardRuntime): NativeNode | undefined {
626
+ const state = runtime.state;
627
+ const running = runtime.runningExperiment;
628
+ if (running) {
629
+ return keyed(
630
+ row(
631
+ [
632
+ node("spinner", { label: [span("Running")], tone: "pending" }),
633
+ elapsed(Date.now() - running.startedAt),
634
+ text([span(replaceTabs(running.command), "mono")], {
635
+ truncate: "end",
636
+ role: "omp.autoresearch.command",
637
+ }),
638
+ ],
639
+ { gap: "sm", align: "center", role: "omp.autoresearch.running", tone: "pending" },
640
+ ),
641
+ "now",
642
+ );
643
+ }
644
+ const pending = runtime.lastRunSummary;
645
+ if (!pending) return undefined;
646
+ const result: TspSpan[] = [span(`Run #${pending.runNumber} ${pending.passed ? "passed" : "failed"}`, "strong")];
647
+ if (pending.parsedPrimary !== null) {
648
+ result.push(
649
+ span(` · ${state.metricName} `),
650
+ ...metricSpans(pending.parsedPrimary, state.metricUnit, fractionDigits([pending.parsedPrimary]), "mono"),
651
+ );
652
+ }
653
+ return keyed(
654
+ row(
655
+ [
656
+ text(result, { truncate: "end" }),
657
+ text([span("Log it with "), span("log_experiment", "mono"), span(" before the next run")], {
658
+ truncate: "end",
659
+ role: "omp.autoresearch.next",
660
+ }),
661
+ ],
662
+ { gap: "md", align: "center", role: "omp.autoresearch.pending", tone: "warning" },
663
+ ),
664
+ "now",
665
+ );
666
+ }
667
+
668
+ /** No runs in this segment yet: what the dashboard is waiting for. */
669
+ function describeEmpty(runtime: AutoresearchDashboardRuntime, archived: number): NativeNode {
670
+ const [title, detail] = runtime.lastRunSummary
671
+ ? ["Waiting for the first log", "Log the finished run to set this segment's baseline."]
672
+ : runtime.runningExperiment
673
+ ? ["Measuring the baseline", "Every later run is compared with this first one."]
674
+ : runtime.autoresearchMode
675
+ ? [
676
+ "No baseline yet",
677
+ "Run the benchmark once and log it as the baseline. Every later run is compared with it.",
678
+ ]
679
+ : ["No experiments logged yet", "Turn autoresearch mode on to start measuring."];
680
+ const children: NativeNode[] = [
681
+ text([span(title)], { role: "omp.autoresearch.empty.title" }),
682
+ text([span(detail)], { wrap: "word", role: "omp.autoresearch.empty.detail" }),
683
+ ];
684
+ if (archived > 0) {
685
+ children.push(
686
+ text([span(`${archived} ${archived === 1 ? "run" : "runs"} archived from earlier segments`)], {
687
+ role: "omp.autoresearch.note",
688
+ }),
689
+ );
690
+ }
691
+ return keyed(col(children, { gap: "xs", align: "center", role: "omp.autoresearch.empty" }), "empty");
692
+ }
693
+
694
+ /** One headline number: a label, the value, and one line saying what it means. */
695
+ function stat(key: string, label: string, value: TspSpan[], detail: NativeNode[]): NativeNode {
696
+ return keyed(
697
+ col(
698
+ [
699
+ text(label, { role: "omp.autoresearch.stat.k" }),
700
+ text(value, { wrap: "none", truncate: "end", role: "omp.autoresearch.stat.v" }),
701
+ ...detail,
702
+ ],
703
+ { role: "omp.autoresearch.stat" },
704
+ ),
705
+ key,
706
+ );
707
+ }
708
+
709
+ function statDetail(spans: TspSpan[]): NativeNode {
710
+ return text(spans, { truncate: "end", role: "omp.autoresearch.stat.sub" });
711
+ }
712
+
713
+ /** Best, baseline, confidence and the run tally, then the secondary metrics at the best run. */
714
+ function describeStats(runtime: AutoresearchDashboardRuntime, current: readonly ExperimentResult[]): NativeNode {
715
+ const state = runtime.state;
716
+ const unit = state.metricUnit;
717
+ const best = findBestResult(state);
718
+ const baseline = findBaselineMetric(state.results, state.currentSegment);
719
+ const baselineRun = findBaselineRunNumber(state.results, state.currentSegment);
720
+ const baselineResult = current.find(result => result.status === "keep" && !result.flagged);
721
+ const counts = statusCounts(current);
722
+ const digits = fractionDigits(
723
+ [best?.result.metric, baseline].filter((value): value is number => value !== undefined && value !== null),
724
+ );
725
+ const tiles: NativeNode[] = [];
726
+
727
+ if (best) {
728
+ const run = best.result.runNumber ?? best.index + 1;
729
+ const change = percentChange(best.result.metric, baseline);
730
+ const better = baseline !== null && isBetter(best.result.metric, baseline, state.bestDirection);
731
+ tiles.push(
732
+ stat("best", "Best", metricSpans(best.result.metric, unit, digits), [
733
+ statDetail(
734
+ change
735
+ ? [span(change, better ? "success" : "error"), span(` vs baseline · run #${run}`)]
736
+ : [span(`The baseline · run #${run}`)],
737
+ ),
738
+ ]),
739
+ );
740
+ } else {
741
+ tiles.push(stat("best", "Best", [span("—", "muted")], [statDetail([span("No kept run yet")])]));
742
+ }
743
+
744
+ const baselineDetail: TspSpan[] = [];
745
+ if (baselineRun !== null) baselineDetail.push(span(`Run #${baselineRun}`));
746
+ if (baselineResult?.commit) baselineDetail.push(span(" · "), span(baselineResult.commit.slice(0, 7), "mono"));
747
+ tiles.push(
748
+ stat("baseline", "Baseline", baseline === null ? [span("—", "muted")] : metricSpans(baseline, unit, digits), [
749
+ statDetail(baselineDetail.length > 0 ? baselineDetail : [span("Not logged yet")]),
750
+ ]),
751
+ );
752
+
753
+ const confidence = state.confidence;
754
+ tiles.push(
755
+ stat(
756
+ "confidence",
757
+ "Confidence",
758
+ confidence === null
759
+ ? [span("—", "muted")]
760
+ : [span(confidence.toFixed(1), confidenceToken(confidence)), span("×", "muted")],
761
+ [
762
+ statDetail([
763
+ span(
764
+ confidence === null
765
+ ? "Needs three runs and a gain"
766
+ : confidence >= 2
767
+ ? "Gain is clear of run noise"
768
+ : confidence >= 1
769
+ ? "Gain is near run noise"
770
+ : "Gain is within run noise",
771
+ ),
772
+ ]),
773
+ ],
774
+ ),
775
+ );
776
+
777
+ const total = Math.max(state.maxExperiments ?? 0, current.length);
778
+ const failed = counts.crash + counts.checks_failed;
779
+ const tally: TspSpan[] = [span(`${counts.keep} kept · ${counts.discard} discarded`)];
780
+ if (failed > 0) tally.push(span(" · "), span(`${failed} failed`, "error"));
781
+ tiles.push(
782
+ stat(
783
+ "runs",
784
+ "Runs",
785
+ state.maxExperiments !== null
786
+ ? [span(String(current.length)), span(` / ${state.maxExperiments}`, "muted")]
787
+ : [span(String(current.length))],
788
+ [
789
+ node("meter", {
790
+ value: current.length / total,
791
+ parts: [
792
+ { value: counts.keep / total, token: "success", label: `${counts.keep} kept` },
793
+ { value: counts.discard / total, token: "muted", label: `${counts.discard} discarded` },
794
+ {
795
+ value: failed / total,
796
+ token: "error",
797
+ label: `${counts.crash} crashed · ${counts.checks_failed} checks failed`,
798
+ },
799
+ ],
800
+ size: "sm",
801
+ role: "omp.autoresearch.tally",
802
+ }),
803
+ statDetail(tally),
804
+ ],
805
+ ),
806
+ );
807
+
808
+ const children: NativeNode[] = [row(tiles, { role: "omp.autoresearch.stats" })];
809
+ if (best && state.secondaryMetrics.length > 0) {
810
+ const baselineSecondary = findBaselineSecondary(state.results, state.currentSegment, state.secondaryMetrics);
811
+ const spans: TspSpan[] = [];
812
+ for (const metric of state.secondaryMetrics) {
813
+ const value = best.result.metrics[metric.name];
814
+ if (value === undefined) continue;
815
+ spans.push(span(spans.length === 0 ? "At best " : " · "));
816
+ spans.push(
817
+ span(`${metric.name} `, "mono muted"),
818
+ ...metricSpans(value, metric.unit, fractionDigits([value]), "mono"),
819
+ );
820
+ const change = percentChange(value, baselineSecondary[metric.name]);
821
+ if (change) spans.push(span(` ${change}`, "mono"));
822
+ }
823
+ if (spans.length > 0) {
824
+ children.push(keyed(text(spans, { truncate: "end", role: "omp.autoresearch.secondary" }), "secondary"));
825
+ }
826
+ }
827
+ return keyed(col(children, { role: "omp.autoresearch.summary" }), "summary");
828
+ }
829
+
830
+ /** The primary metric per run, in run order. */
831
+ function describeTrend(
832
+ runtime: AutoresearchDashboardRuntime,
833
+ indexed: readonly { result: ExperimentResult; index: number }[],
834
+ ): NativeNode {
835
+ const state = runtime.state;
836
+ return node(
837
+ "chart",
838
+ {
839
+ kind: "bars",
840
+ series: indexed.map(({ result, index }) => {
841
+ const run = result.runNumber ?? index + 1;
842
+ return {
843
+ label: `#${run}`,
844
+ value: result.metric,
845
+ title: `#${run} ${STATUS_LABEL[result.status]} · ${formatNum(result.metric, state.metricUnit)} · ${replaceTabs(result.description)}`,
846
+ };
847
+ }),
848
+ token: "accent",
849
+ summary: [span(`${state.metricName} per run, ${state.bestDirection} is better`)],
850
+ size: "md",
851
+ role: "omp.autoresearch.trend",
852
+ },
853
+ undefined,
854
+ "trend",
855
+ );
856
+ }
857
+
858
+ /** Every run of the segment, newest first: outcome, metric and its change from the baseline, commit, what changed. */
859
+ function describeRuns(
860
+ runtime: AutoresearchDashboardRuntime,
861
+ indexed: readonly { result: ExperimentResult; index: number }[],
862
+ ): NativeNode {
863
+ const state = runtime.state;
864
+ const baseline = findBaselineResult(state.results, state.currentSegment);
865
+ const best = findBestResult(state);
866
+ // Cells carry bare numbers at one precision per column; the unit goes in the head unless the name says it.
867
+ const head = (name: string, unit: string): TspSpan[] =>
868
+ unit && !name.toLowerCase().endsWith(`_${unit.toLowerCase()}`)
869
+ ? [span(name, "mono"), span(` ${unit}`, "muted")]
870
+ : [span(name, "mono")];
871
+ const measured = indexed.filter(({ result }) => result.status !== "crash").map(({ result }) => result);
872
+ const metricDigits = fractionDigits(measured.map(result => result.metric));
873
+ const secondaryDigits = state.secondaryMetrics.map(metric =>
874
+ fractionDigits(
875
+ measured.map(result => result.metrics[metric.name]).filter((value): value is number => value !== undefined),
876
+ ),
877
+ );
878
+ const cols: TspTableColumn[] = [
879
+ { id: "run", head: "#", align: "end", priority: 3 },
880
+ { id: "status", head: "Outcome", priority: 4 },
881
+ { id: "metric", head: head(state.metricName, state.metricUnit), align: "end", priority: 5 },
882
+ { id: "change", head: "vs baseline", align: "end", priority: 4 },
883
+ ...state.secondaryMetrics.map((metric, index): TspTableColumn => ({
884
+ id: `secondary${index}`,
885
+ head: head(metric.name, metric.unit),
886
+ align: "end",
887
+ priority: 1,
888
+ })),
889
+ { id: "commit", head: "Commit", priority: 2 },
890
+ { id: "description", head: "Change", truncate: "end", grow: 1, priority: 3 },
891
+ ];
892
+ const rows = indexed
893
+ .slice()
894
+ .reverse()
895
+ .map(({ result, index }) => {
896
+ const isBest = best?.index === index;
897
+ const isBaseline = result === baseline;
898
+ const crashed = result.status === "crash";
899
+ const change = isBaseline || crashed ? null : percentChange(result.metric, baseline?.metric);
900
+ const better = baseline !== null && isBetter(result.metric, baseline.metric, state.bestDirection);
901
+ const cells: Record<string, TspText> = {
902
+ run: [span(String(result.runNumber ?? index + 1), "mono")],
903
+ status: isBest
904
+ ? [span("● ", "success"), span("best", "strong")]
905
+ : [span("● ", STATUS_TOKEN[result.status]), span(STATUS_LABEL[result.status])],
906
+ metric: crashed
907
+ ? [span("—", "muted")]
908
+ : [span(fmtNum(result.metric, metricDigits), isBest ? "mono strong" : "mono")],
909
+ change: isBaseline
910
+ ? [span("baseline", "muted")]
911
+ : change
912
+ ? [span(change, `mono ${result.status === "keep" ? (better ? "success" : "error") : "muted"}`)]
913
+ : [span("—", "muted")],
914
+ commit: [span(result.commit ? result.commit.slice(0, 7) : "—", result.commit ? "mono" : "muted")],
915
+ description: [span(replaceTabs(result.description))],
916
+ };
917
+ state.secondaryMetrics.forEach((metric, metricIndex) => {
918
+ const value = result.metrics[metric.name];
919
+ cells[`secondary${metricIndex}`] =
920
+ value === undefined
921
+ ? [span("—", "muted")]
922
+ : [span(fmtNum(value, secondaryDigits[metricIndex] ?? 0), "mono")];
923
+ });
924
+ return { id: String(index), cells };
925
+ });
926
+ return node("table", { cols, rows, role: "omp.autoresearch.runs" }, undefined, "runs");
927
+ }
928
+
516
929
  function statusCounts(results: readonly ExperimentResult[]): Record<ExperimentResult["status"], number> {
517
930
  const counts: Record<ExperimentResult["status"], number> = { keep: 0, discard: 0, crash: 0, checks_failed: 0 };
518
931
  for (const result of results) counts[result.status] += 1;