@oh-my-pi/pi-tui 18.8.7 → 18.8.8

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 (40) hide show
  1. package/CHANGELOG.md +28 -64
  2. package/dist/types/chat/execution-shared.d.ts +6 -5
  3. package/dist/types/keybinding-matchers.d.ts +3 -0
  4. package/dist/types/overlays/annotation-overlay.d.ts +12 -0
  5. package/dist/types/overlays/annotation-types.d.ts +2 -0
  6. package/dist/types/overlays/hook-selector.d.ts +9 -4
  7. package/dist/types/overlays/model-browser.d.ts +2 -2
  8. package/dist/types/overlays/model-hub.d.ts +21 -4
  9. package/dist/types/overlays/session-observer-registry.d.ts +2 -0
  10. package/dist/types/overlays/usage-dashboard.d.ts +3 -1
  11. package/dist/types/status-line/types.d.ts +1 -1
  12. package/dist/types/theme/symbols.d.ts +1 -1
  13. package/dist/types/tools/eval.d.ts +1 -1
  14. package/dist/types/tools/native-view.d.ts +47 -0
  15. package/dist/types/tools/renderer.d.ts +15 -2
  16. package/dist/types/tui.d.ts +20 -0
  17. package/dist/types/vim.d.ts +19 -5
  18. package/package.json +9 -9
  19. package/src/chat/execution-shared.ts +42 -25
  20. package/src/chat/tool-execution.ts +9 -2
  21. package/src/components/editor.ts +182 -5
  22. package/src/keybinding-matchers.ts +6 -2
  23. package/src/overlays/agent-hub.ts +7 -3
  24. package/src/overlays/annotation-overlay.ts +87 -6
  25. package/src/overlays/annotation-types.ts +2 -0
  26. package/src/overlays/hook-selector.ts +12 -5
  27. package/src/overlays/model-browser.ts +3 -3
  28. package/src/overlays/model-hub.ts +107 -27
  29. package/src/overlays/session-observer-registry.ts +10 -2
  30. package/src/overlays/usage-dashboard.ts +70 -23
  31. package/src/prompt/composer.ts +1 -0
  32. package/src/status-line/segments.ts +3 -0
  33. package/src/status-line/types.ts +1 -1
  34. package/src/theme/symbols.ts +5 -0
  35. package/src/tools/bash.ts +82 -31
  36. package/src/tools/eval.ts +149 -123
  37. package/src/tools/native-view.ts +102 -1
  38. package/src/tools/renderer.ts +15 -2
  39. package/src/tui.ts +68 -0
  40. package/src/vim.ts +333 -18
package/src/tools/bash.ts CHANGED
@@ -25,9 +25,9 @@ import type {
25
25
  RenderResultOptions,
26
26
  ToolRenderer,
27
27
  } from "./renderer";
28
- import { ansi, compact, keyed } from "../native/describe";
29
- import type { NativeChild } from "../native/node";
30
- import { footnoteText, resultText } from "./native-view";
28
+ import { INTENT_FIELD, type TspTone } from "@oh-my-pi/pi-wire";
29
+ import { plainText } from "../native/spans";
30
+ import { resultText, runBox, runFoot, runInput, runOutput, type RunState } from "./native-view";
31
31
 
32
32
  /** Default collapsed shell output preview height. */
33
33
  export const BASH_DEFAULT_PREVIEW_LINES = DEFAULT_TERMINAL_PREVIEW_LINES;
@@ -367,34 +367,41 @@ function bashStatsParts(
367
367
  }
368
368
 
369
369
  /**
370
- * Native head facts of a shell call: title, the command once (`cd`/env
371
- * prefix included), a `background` badge for a backgrounded job, the
372
- * `exit N` chip and a `timed out` note only when the deadline hit.
370
+ * Native head facts of a shell call: title, the model's intent as the target
371
+ * (none without one), the command line for Copy command, the `exit N` chip
372
+ * and a `timed out` note only when the deadline hit. The command itself and a
373
+ * backgrounded job's state draw in the run box.
373
374
  */
374
375
  function shellToolHead(
375
376
  title: string,
376
377
  command: string,
378
+ intent: string | undefined,
377
379
  details: BashToolDetails | undefined,
378
380
  settled: boolean,
379
381
  ): NativeToolHead {
380
382
  const exit = settled && details?.exitCode !== undefined && details.exitCode !== 0 ? details.exitCode : undefined;
381
383
  return {
382
384
  title,
383
- target: command,
384
- targetKind: "command",
385
- lang: "bash",
386
- badges:
387
- details?.async?.state === "running"
388
- ? [{ text: "background", title: `Backgrounded as job ${details.async.jobId}` }]
389
- : undefined,
385
+ target: intent,
386
+ targetKind: intent ? "text" : undefined,
387
+ command,
390
388
  exit,
391
389
  note: settled && details?.timedOut === true ? "timed out" : undefined,
392
390
  };
393
391
  }
394
392
 
395
- /** The quiet final line's facts under shell output: service state and the full-output artifact. */
393
+ /** The model's intent line (`i` arg) as plain head text, or undefined when absent. */
394
+ function callIntent(args: unknown): string | undefined {
395
+ if (!args || typeof args !== "object" || !(INTENT_FIELD in args)) return undefined;
396
+ const value = args[INTENT_FIELD];
397
+ if (typeof value !== "string") return undefined;
398
+ return plainText(value).trim() || undefined;
399
+ }
400
+
401
+ /** The run foot's facts under shell output: background job, service state and the full-output artifact. */
396
402
  function shellFootParts(details: BashToolDetails | undefined, artifactId: string | undefined): string[] {
397
403
  const parts: string[] = [];
404
+ if (details?.async?.state === "running") parts.push(`Job ${details.async.jobId}`);
398
405
  const service = details?.service;
399
406
  if (service) {
400
407
  parts.push(`Service ${service.name}`, service.state);
@@ -405,6 +412,27 @@ function shellFootParts(details: BashToolDetails | undefined, artifactId: string
405
412
  return parts;
406
413
  }
407
414
 
415
+ /** The command line as a run's input: wrapped shell source. */
416
+ const SHELL_INPUT = { role: "omp.tool.bash.command", lang: "bash", wrap: true } as const;
417
+
418
+ /** Box tint per run state: failed runs read as errors, timed-out ones as warnings. */
419
+ const RUN_BOX_TONE: Partial<Record<RunState, TspTone>> = { failed: "error", "timed-out": "warning" };
420
+
421
+ /** The foot state of a shell call from its result. */
422
+ function shellRunState(
423
+ details: BashToolDetails | undefined,
424
+ isError: boolean,
425
+ isPartial: boolean,
426
+ cancelled: boolean,
427
+ ): RunState {
428
+ if (cancelled) return "cancelled";
429
+ if (isPartial) return "running";
430
+ if (details?.timedOut === true) return "timed-out";
431
+ if (details?.async?.state === "running") return "background";
432
+ if (isError || (details?.exitCode !== undefined && details.exitCode !== 0)) return "failed";
433
+ return "done";
434
+ }
435
+
408
436
  function toBashRenderArgs<TArgs>(args: TArgs | undefined, config: ShellRendererConfig<TArgs>): BashRenderArgs {
409
437
  return {
410
438
  command: config.resolveCommand?.(args),
@@ -610,10 +638,21 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
610
638
  },
611
639
  describeCall(args: TArgs, options: RenderResultOptions): NativeToolView {
612
640
  const command = formatBashCommandText(toBashRenderArgs(args, config));
613
- // The command shows once, in the head; the body is only the output.
641
+ // No foot until the command starts; once it runs (before any output) the clock shows.
642
+ const started = options.executionStarted === true;
614
643
  return {
615
- tool: shellToolHead(config.resolveTitle(args, options), command, undefined, false),
616
- preview: { tail: BASH_DEFAULT_PREVIEW_LINES },
644
+ tool: shellToolHead(config.resolveTitle(args, options), command, callIntent(args), undefined, false),
645
+ body: [
646
+ runBox(
647
+ [
648
+ runInput(command, SHELL_INPUT),
649
+ started &&
650
+ runFoot({ state: options.cancelled ? "cancelled" : "running", elapsedMs: options.elapsedMs }),
651
+ ],
652
+ { key: "run" },
653
+ ),
654
+ ],
655
+ preview: "children",
617
656
  };
618
657
  },
619
658
 
@@ -637,22 +676,34 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
637
676
  const showingFullOutput = expanded && renderContext?.isFullOutput === true;
638
677
  const command = formatBashCommandText(toBashRenderArgs(args, config));
639
678
  const previewLines = renderContext?.previewLines ?? BASH_DEFAULT_PREVIEW_LINES;
640
- // Wall time is the head timer, the timeout a note only when hit, the exit a head chip:
641
- // what is left (service state, artifact, truncation) is one quiet final line.
679
+ const state = shellRunState(details, isError, isPartial, options.cancelled === true);
680
+ // Settled: the measured wall time wins. A background job outlives the call: its run time is unknown here.
681
+ const elapsedMs =
682
+ state === "background"
683
+ ? undefined
684
+ : isPartial
685
+ ? options.elapsedMs
686
+ : (details?.wallTimeMs ?? options.elapsedMs);
642
687
  const meta = details?.meta;
643
- const body: NativeChild[] = compact([
644
- output.trim().length > 0 &&
645
- keyed(ansi(output, { follow: isPartial, role: "omp.tool.bash.output" }), "output"),
646
- footnoteText(shellFootParts(details, stripped.artifactId), {
647
- ...meta,
648
- truncation: showingFullOutput ? undefined : meta?.truncation,
649
- }),
650
- ]);
688
+ const box = runBox(
689
+ [
690
+ runInput(command, SHELL_INPUT),
691
+ output.trim().length > 0 && runOutput(output, { role: "omp.tool.bash.output", previewLines }),
692
+ runFoot({
693
+ state,
694
+ exitCode: details?.exitCode,
695
+ elapsedMs,
696
+ facts: shellFootParts(details, stripped.artifactId),
697
+ meta: { ...meta, truncation: showingFullOutput ? undefined : meta?.truncation },
698
+ }),
699
+ ],
700
+ { key: "run", tone: RUN_BOX_TONE[state] },
701
+ );
651
702
  return {
652
- tool: shellToolHead(config.resolveTitle(args, options), command, details, !isPartial),
653
- body,
654
- tone: !isPartial && isError && details?.timedOut === true ? "warning" : undefined,
655
- preview: { tail: previewLines },
703
+ tool: shellToolHead(config.resolveTitle(args, options), command, callIntent(args), details, !isPartial),
704
+ body: [box],
705
+ tone: state === "timed-out" ? "warning" : undefined,
706
+ preview: "children",
656
707
  };
657
708
  },
658
709
  mergeCallAndResult: true,
package/src/tools/eval.ts CHANGED
@@ -9,10 +9,10 @@ import type {
9
9
  ToolRenderer,
10
10
  } from "./renderer";
11
11
  import type { TspSpan } from "@oh-my-pi/pi-wire";
12
- import { ansi, code as codeNode, compact, keyed, md, node, span, text } from "../native/describe";
12
+ import { compact, keyed, md, node, span, text } from "../native/describe";
13
13
  import type { NativeChild, NativeNode } from "../native/node";
14
14
  import { plainText } from "../native/spans";
15
- import { footnoteText, resultText } from "./native-view";
15
+ import { resultText, runBox, runFoot, type RunFootInput, runInput, runOutput, type RunState } from "./native-view";
16
16
  import { renderAgentTreeRow } from "./agent-tree";
17
17
  import { truncateToVisualLines } from "../chrome/visual-truncate";
18
18
  import { getMarkdownTheme, type Theme } from "../theme/theme";
@@ -694,89 +694,104 @@ function splitConsoleTables(output: string): EvalOutputPart[] {
694
694
  return parts;
695
695
  }
696
696
 
697
- /** One cell's output: markdown, else terminal text with any `console.table` blocks as tables. */
698
- function evalOutputNodes(output: string, markdown: boolean, running: boolean, error: boolean): NativeNode[] {
697
+ /**
698
+ * One cell's outputs: markdown, else terminal text (following its tail,
699
+ * clamped to the last `previewLines` while folded) with any `console.table`
700
+ * blocks as tables.
701
+ */
702
+ function evalOutputNodes(output: string, markdown: boolean, previewLines: number): NativeNode[] {
699
703
  if (output.trim().length === 0) return [];
700
- if (markdown && !error) return [md(output)];
701
- const tone = error ? "error" : undefined;
702
- return splitConsoleTables(output).map(part =>
704
+ if (markdown) return [keyed(md(output), "output")];
705
+ return splitConsoleTables(output).map((part, i) =>
703
706
  part.kind === "text"
704
- ? ansi(part.text, { follow: running, tone, role: "omp.tool.eval.output" })
705
- : node("table", {
706
- // Node's index column header is noise in a real table.
707
- cols: part.head.map((head, i) => ({ id: `c${i}`, head: head === "(index)" ? "" : head })),
708
- rows: part.rows.map((cells, r) => ({
709
- id: `r${r}`,
710
- cells: Object.fromEntries(cells.map((cell, i) => [`c${i}`, cell])),
711
- })),
712
- role: "omp.tool.eval.table",
713
- }),
707
+ ? runOutput(part.text, { role: "omp.tool.eval.output", previewLines, key: i === 0 ? "output" : `output-${i}` })
708
+ : node(
709
+ "table",
710
+ {
711
+ // Node's index column header is noise in a real table.
712
+ cols: part.head.map((head, c) => ({ id: `c${c}`, head: head === "(index)" ? "" : head })),
713
+ rows: part.rows.map((cells, r) => ({
714
+ id: `r${r}`,
715
+ cells: Object.fromEntries(cells.map((cell, c) => [`c${c}`, cell])),
716
+ })),
717
+ role: "omp.tool.eval.table",
718
+ },
719
+ undefined,
720
+ `table-${i}`,
721
+ ),
722
+ );
723
+ }
724
+
725
+ /** Status lines a folded cell keeps: the newest ones. */
726
+ const RUN_STATUS_TAIL = 8;
727
+
728
+ /**
729
+ * A cell's status events (`omp.run.status`): all of them when expanded, else
730
+ * the newest {@link RUN_STATUS_TAIL} after a muted `N earlier` line.
731
+ */
732
+ function evalStatusSection(events: readonly EvalStatusEvent[], expanded: boolean): NativeNode | undefined {
733
+ if (events.length === 0) return undefined;
734
+ const hidden = expanded ? 0 : Math.max(0, events.length - RUN_STATUS_TAIL);
735
+ const lines = events.slice(hidden).map((event, i) => keyed(describeStatusEvent(event), `s${hidden + i}`));
736
+ return node(
737
+ "col",
738
+ { role: "omp.run.status", gap: "none" },
739
+ compact<NativeNode>([hidden > 0 && keyed(text([span(`${hidden} earlier`, "muted")]), "earlier"), ...lines]),
740
+ "status",
714
741
  );
715
742
  }
716
743
 
717
- /** Inputs of one eval cell section. */
718
- interface EvalCellSection {
744
+ /** The foot state of a cell, or undefined while it has not started. */
745
+ function evalCellState(status: EvalCellResult["status"], cancelled: boolean): RunState | undefined {
746
+ switch (status) {
747
+ case "pending":
748
+ return undefined;
749
+ case "running":
750
+ return cancelled ? "cancelled" : "running";
751
+ case "complete":
752
+ return "done";
753
+ case "error":
754
+ return "failed";
755
+ }
756
+ }
757
+
758
+ /** Inputs of one eval cell box. */
759
+ interface EvalCellRun {
719
760
  readonly language: EvalLanguage;
720
761
  readonly code: string;
721
762
  readonly title?: string;
722
- readonly status?: EvalCellResult["status"];
723
- readonly durationMs?: number;
724
- readonly output?: readonly NativeChild[];
763
+ readonly outputs?: readonly NativeChild[];
764
+ readonly status?: NativeNode;
765
+ /** Omitted while the cell has not started. */
766
+ readonly foot?: RunFootInput;
725
767
  }
726
768
 
727
769
  /**
728
- * A notebook cell: a gutter mark beside the input (←, muted until the cell
729
- * runs, omp's thinking starburst while it does) and beside its output (→).
730
- * Only multi-cell calls repeat titles; marks never invent execution counts.
770
+ * A notebook cell as one run box: the caption (multi-cell calls only), the
771
+ * source, its outputs, status lines and foot. A failed cell tints its box.
731
772
  */
732
- function evalCellSection(cell: EvalCellSection, index: number, total: number): NativeNode {
733
- let head: TspSpan[] | undefined;
773
+ function evalCellBox(cell: EvalCellRun, index: number, total: number): NativeNode {
774
+ let caption: NativeNode | undefined;
734
775
  if (total > 1) {
735
- head = [span(`${index + 1}/${total}`, "muted")];
776
+ const spans: TspSpan[] = [span(`${index + 1}/${total}`, "muted")];
736
777
  const title = plainText(cell.title ?? "").trim();
737
- if (title) head.push(span(` ${title}`, "toolTitle"));
738
- if (cell.status === "error") head.push(span(" · failed", "error"));
739
- if (cell.durationMs !== undefined) head.push(span(` · ${(cell.durationMs / 1000).toFixed(2)}s`, "muted"));
778
+ if (title) spans.push(span(` ${title}`, "toolTitle"));
779
+ caption = keyed(text(spans, { role: "omp.run.caption" }), "caption");
740
780
  }
741
- const inputMark =
742
- cell.status === "running"
743
- ? node("spinner", { style: "starburst", role: "omp.tool.eval.prompt", aria: "Running" })
744
- : node("icon", {
745
- name: "arrow-left",
746
- role: "omp.tool.eval.prompt",
747
- aria: "Input",
748
- tone: !cell.status || cell.status === "pending" ? "muted" : undefined,
749
- });
750
- return node(
751
- "col",
752
- {
753
- role: "omp.tool.eval.cell",
754
- tone: cell.status === "error" ? "error" : cell.status === "running" ? "pending" : undefined,
755
- },
756
- compact<NativeChild>([
757
- head ? text(head, { role: "omp.tool.eval.caption" }) : undefined,
758
- node(
759
- "row",
760
- { role: "omp.tool.eval.input", align: "start" },
761
- [
762
- inputMark,
763
- keyed(codeNode(cell.code, { lang: languageForHighlighter(cell.language), numbers: false }), "code"),
764
- ],
765
- "input",
766
- ),
767
- cell.output?.length
768
- ? node(
769
- "row",
770
- { role: "omp.tool.eval.result", align: "start" },
771
- [
772
- node("icon", { name: "arrow-right", role: "omp.tool.eval.prompt", aria: "Output" }),
773
- node("col", { role: "omp.tool.eval.outputs" }, cell.output),
774
- ],
775
- "output",
776
- )
777
- : undefined,
778
- ]),
779
- `cell-${index}`,
781
+ return runBox(
782
+ [
783
+ caption,
784
+ cell.code.trim().length > 0 &&
785
+ runInput(cell.code, {
786
+ role: "omp.tool.eval.input",
787
+ lang: languageForHighlighter(cell.language),
788
+ wrap: false,
789
+ }),
790
+ ...(cell.outputs ?? []),
791
+ cell.status,
792
+ cell.foot && runFoot(cell.foot),
793
+ ],
794
+ { key: `cell-${index}`, tone: cell.foot?.state === "failed" ? "error" : undefined },
780
795
  );
781
796
  }
782
797
 
@@ -1129,16 +1144,20 @@ export const evalToolRenderer = {
1129
1144
  };
1130
1145
  },
1131
1146
 
1132
- describeCall(args: EvalRenderArgs, _options: RenderResultOptions): NativeToolView {
1147
+ describeCall(args: EvalRenderArgs, options: RenderResultOptions): NativeToolView {
1133
1148
  const cells = getRenderCells(args);
1149
+ // No foot until the call starts; then the first cell runs (before any result names it).
1150
+ const startFoot: RunFootInput | undefined = options.executionStarted
1151
+ ? { state: options.cancelled ? "cancelled" : "running", elapsedMs: options.elapsedMs }
1152
+ : undefined;
1134
1153
  return {
1135
1154
  tool: evalToolHead(
1136
1155
  cells[0]?.title,
1137
1156
  cells.map(cell => cell.language),
1138
1157
  cells.length,
1139
1158
  ),
1140
- body: cells.map((cell, i) => evalCellSection(cell, i, cells.length)),
1141
- preview: { lines: EVAL_DEFAULT_PREVIEW_LINES },
1159
+ body: cells.map((cell, i) => evalCellBox({ ...cell, foot: i === 0 ? startFoot : undefined }, i, cells.length)),
1160
+ preview: "children",
1142
1161
  };
1143
1162
  },
1144
1163
 
@@ -1149,82 +1168,89 @@ export const evalToolRenderer = {
1149
1168
  ): NativeToolView {
1150
1169
  const details = result.details;
1151
1170
  const isPartial = options.isPartial === true;
1171
+ const cancelled = options.cancelled === true;
1172
+ const expanded = options.renderContext?.expanded ?? options.expanded;
1152
1173
  const previewLines = options.renderContext?.previewLines ?? EVAL_DEFAULT_PREVIEW_LINES;
1153
1174
  const jsonNodes = (details?.jsonOutputs ?? []).map((value, index) =>
1154
1175
  keyed(describeJsonTree(value, { hiddenRootKeys: [] }), `display-${index}`),
1155
1176
  );
1156
- // A notice and truncation go to one quiet final line (a background job is a head badge).
1157
- const footer = footnoteText(compact([details?.notice]), details?.meta);
1177
+ // The call's notice and truncation join the last run's foot facts (a background job is a head badge).
1178
+ const callFacts = { facts: compact([details?.notice]), meta: details?.meta };
1158
1179
  const cellResults = details?.cells;
1159
- let cells: NativeNode[];
1160
- let head: NativeToolHead;
1161
1180
  if (cellResults && cellResults.length > 0) {
1162
1181
  const languages = cellResults.map(cell => cell.language ?? details?.language ?? "python");
1163
- head = evalToolHead(cellResults[0]!.title, languages, cellResults.length, details);
1164
- cells = cellResults.map((cell, i) => {
1182
+ const states = cellResults.map(cell => evalCellState(cell.status, cancelled));
1183
+ // A cell that never started has no foot to carry the call's facts: the last one that ran does.
1184
+ const factsIndex = states.findLastIndex(state => state !== undefined);
1185
+ let earlierMs = 0;
1186
+ const cells = cellResults.map((cell, i) => {
1165
1187
  const language = languages[i]!;
1166
- return evalCellSection(
1188
+ const state = states[i];
1189
+ // A running cell's clock is the call's minus the cells that finished before it.
1190
+ const elapsedMs =
1191
+ state === "running" || state === "cancelled"
1192
+ ? options.elapsedMs === undefined
1193
+ ? undefined
1194
+ : Math.max(0, options.elapsedMs - earlierMs)
1195
+ : cell.durationMs;
1196
+ earlierMs += cell.durationMs ?? 0;
1197
+ return evalCellBox(
1167
1198
  {
1168
1199
  language,
1169
1200
  code: formatEvalCodeForDisplay(cell.code, language),
1170
1201
  title: cell.title,
1171
- status: cell.status,
1172
- durationMs: cell.durationMs,
1173
- output: [
1202
+ outputs: [
1174
1203
  ...evalOutputNodes(
1175
1204
  cell.output,
1176
- cell.hasMarkdown === true,
1177
- cell.status === "running",
1178
- cell.status === "error",
1205
+ cell.hasMarkdown === true && cell.status !== "error",
1206
+ previewLines,
1179
1207
  ),
1180
- ...(cell.statusEvents ?? []).map(describeStatusEvent),
1181
1208
  ...(i === cellResults.length - 1 ? jsonNodes : []),
1182
1209
  ],
1210
+ status: evalStatusSection(cell.statusEvents ?? [], expanded),
1211
+ foot: state && {
1212
+ state,
1213
+ exitCode: cell.exitCode,
1214
+ elapsedMs,
1215
+ ...(i === factsIndex ? callFacts : {}),
1216
+ },
1183
1217
  },
1184
1218
  i,
1185
1219
  cellResults.length,
1186
1220
  );
1187
1221
  });
1188
- } else {
1189
- // No per-cell results (older details): the call's cells, with the whole output under the last.
1190
- const argCells = getRenderCells(args);
1191
- const languages = details?.languages ?? (details?.language ? [details.language] : []);
1192
- head = evalToolHead(
1222
+ return {
1223
+ tool: evalToolHead(cellResults[0]!.title, languages, cellResults.length, details),
1224
+ body: cells,
1225
+ preview: "children",
1226
+ };
1227
+ }
1228
+ // No per-cell results (older details): the call's cells, the whole output and foot under the last.
1229
+ const argCells = getRenderCells(args);
1230
+ const languages = details?.languages ?? (details?.language ? [details.language] : []);
1231
+ const rawOutput = options.renderContext?.output ?? resultText(result).trimEnd();
1232
+ const state: RunState = cancelled ? "cancelled" : isPartial ? "running" : result.isError ? "failed" : "done";
1233
+ const last: Omit<EvalCellRun, "language" | "code" | "title"> = {
1234
+ outputs: [
1235
+ ...evalOutputNodes(stripOutputNotice(rawOutput, details?.meta).trimEnd(), false, previewLines),
1236
+ ...jsonNodes,
1237
+ ],
1238
+ status: evalStatusSection(details?.statusEvents ?? [], expanded),
1239
+ foot: { state, elapsedMs: options.elapsedMs, ...callFacts },
1240
+ };
1241
+ const runs: EvalCellRun[] =
1242
+ argCells.length > 0
1243
+ ? argCells.map((cell, i) => (i === argCells.length - 1 ? { ...cell, ...last } : cell))
1244
+ : [{ language: languages[0] ?? "python", code: "", ...last }];
1245
+ return {
1246
+ tool: evalToolHead(
1193
1247
  argCells[0]?.title,
1194
1248
  argCells.length > 0 ? argCells.map(cell => cell.language) : languages,
1195
1249
  argCells.length,
1196
1250
  details,
1197
- );
1198
- const rawOutput = options.renderContext?.output ?? resultText(result).trimEnd();
1199
- const output = [
1200
- ...evalOutputNodes(
1201
- stripOutputNotice(rawOutput, details?.meta).trimEnd(),
1202
- false,
1203
- isPartial,
1204
- result.isError === true,
1205
- ),
1206
- ...(details?.statusEvents ?? []).map(describeStatusEvent),
1207
- ...jsonNodes,
1208
- ];
1209
- cells =
1210
- argCells.length > 0
1211
- ? argCells.map((cell, i) =>
1212
- evalCellSection(
1213
- {
1214
- ...cell,
1215
- status: isPartial ? "running" : result.isError ? "error" : "complete",
1216
- output: i === argCells.length - 1 ? output : undefined,
1217
- },
1218
- i,
1219
- argCells.length,
1220
- ),
1221
- )
1222
- : output;
1223
- }
1224
- return {
1225
- tool: head,
1226
- body: compact<NativeChild>([...cells, footer]),
1227
- preview: { lines: previewLines },
1251
+ ),
1252
+ body: runs.map((run, i) => evalCellBox(run, i, runs.length)),
1253
+ preview: "children",
1228
1254
  };
1229
1255
  },
1230
1256
 
@@ -6,7 +6,7 @@
6
6
  import * as path from "node:path";
7
7
  import { pathToFileURL } from "node:url";
8
8
  import type { TspSpan, TspText, TspTone } from "@oh-my-pi/pi-wire";
9
- import { code, keyed, node, row, span, text } from "../native/describe";
9
+ import { ansi, code, compact, keyed, node, row, span, text } from "../native/describe";
10
10
  import type { NativeChild, NativeNode } from "../native/node";
11
11
  import { type ParsedDiagnostic, parseDiagnosticMessage, shortenPath } from "../render/render-utils";
12
12
  import { plainText } from "../native/spans";
@@ -189,6 +189,107 @@ export function footnoteText(parts: readonly string[], meta?: OutputMeta): Nativ
189
189
  };
190
190
  }
191
191
 
192
+ /** Drawn lines of a run's input (command, cell code) before its "N more lines" button. */
193
+ export const RUN_INPUT_PREVIEW_LINES = 6;
194
+
195
+ /**
196
+ * What a run's foot names: `running` (live clock), `background` (an async job
197
+ * outlives the call), and the settled outcomes. A failure with an exit code
198
+ * reads `exit N`; without one, `Failed`.
199
+ */
200
+ export type RunState = "running" | "background" | "done" | "failed" | "cancelled" | "timed-out";
201
+
202
+ const RUN_STATE: Record<RunState, { readonly word: string; readonly tone: TspTone }> = {
203
+ running: { word: "Running", tone: "pending" },
204
+ background: { word: "In background", tone: "pending" },
205
+ done: { word: "Done", tone: "success" },
206
+ failed: { word: "Failed", tone: "error" },
207
+ cancelled: { word: "Cancelled", tone: "muted" },
208
+ "timed-out": { word: "Timed out", tone: "warning" },
209
+ };
210
+
211
+ /** Inputs of {@link runFoot}. */
212
+ export interface RunFootInput {
213
+ readonly state: RunState;
214
+ /** Exit code of a `failed` run; a non-zero code names the state `exit N`. */
215
+ readonly exitCode?: number;
216
+ /** Milliseconds run so far (`running`) or in total; the time is omitted when undefined. */
217
+ readonly elapsedMs?: number;
218
+ /** Quiet facts (job id, service state, artifact), muted and ` · `-joined. */
219
+ readonly facts?: readonly string[];
220
+ /** Truncation / artifact-capture notices, appended to the facts in `warning`. */
221
+ readonly meta?: OutputMeta;
222
+ }
223
+
224
+ /**
225
+ * The foot line of a run box (`omp.run.foot`): the state word in its tone,
226
+ * the run time (a live clock while running, frozen once settled) and the
227
+ * quiet facts. Glyphs are the terminal's styling, never text.
228
+ */
229
+ export function runFoot(input: RunFootInput): NativeNode {
230
+ const failedWithCode = input.state === "failed" && input.exitCode !== undefined && input.exitCode !== 0;
231
+ const state = failedWithCode
232
+ ? { word: `exit ${input.exitCode}`, tone: RUN_STATE.failed.tone }
233
+ : RUN_STATE[input.state];
234
+ const facts = (input.facts ?? []).map(fact => plainText(fact).trim()).filter(fact => fact.length > 0);
235
+ const notices: string[] = [];
236
+ if (input.meta?.truncation) notices.push(formatTruncationMetaNotice(input.meta.truncation, input.meta.source));
237
+ if (input.meta?.artifactError) notices.push(formatArtifactErrorNotice(input.meta.artifactError));
238
+ const factSpans: TspSpan[] = [];
239
+ if (facts.length > 0) factSpans.push(span(facts.join(" · "), "muted"));
240
+ for (const notice of notices) {
241
+ if (factSpans.length > 0) factSpans.push(span(" · ", "muted"));
242
+ factSpans.push(span(notice, "warning"));
243
+ }
244
+ const ms = input.elapsedMs === undefined ? undefined : Math.max(0, Math.round(input.elapsedMs));
245
+ const time =
246
+ ms === undefined
247
+ ? undefined
248
+ : node(
249
+ "elapsed",
250
+ input.state === "running"
251
+ ? { role: "omp.run.time", age: ms, format: "short" }
252
+ : { role: "omp.run.time", age: ms, stopped: ms, format: "short" },
253
+ undefined,
254
+ "time",
255
+ );
256
+ return node(
257
+ "row",
258
+ { role: "omp.run.foot", gap: "sm", align: "center" },
259
+ compact<NativeNode>([
260
+ keyed(text([span(state.word)], { role: "omp.run.state", tone: state.tone }), "state"),
261
+ time,
262
+ factSpans.length > 0 && keyed(text(factSpans, { role: "omp.run.facts", wrap: "word" }), "facts"),
263
+ ]),
264
+ "foot",
265
+ );
266
+ }
267
+
268
+ /** A run's input section: the command or cell source, clamped to {@link RUN_INPUT_PREVIEW_LINES}. */
269
+ export function runInput(source: string, p: { role: string; lang: string; wrap: boolean }): NativeNode {
270
+ return keyed(
271
+ code(source.trimEnd(), { role: p.role, lang: p.lang, wrap: p.wrap, preview: { lines: RUN_INPUT_PREVIEW_LINES } }),
272
+ "input",
273
+ );
274
+ }
275
+
276
+ /** A run's terminal output: follows its tail, clamped to the last `previewLines` lines while folded. */
277
+ export function runOutput(output: string, p: { role: string; previewLines: number; key?: string }): NativeNode {
278
+ return keyed(ansi(output, { role: p.role, follow: true, preview: { lines: p.previewLines } }), p.key ?? "output");
279
+ }
280
+
281
+ /**
282
+ * One run as one box (`omp.run`): input, output, status lines and foot in
283
+ * order. `tone` tints the box: `error` for a failed run, `warning` for a
284
+ * timed-out one.
285
+ */
286
+ export function runBox(
287
+ children: readonly (NativeChild | undefined | false)[],
288
+ p: { key: string; tone?: TspTone },
289
+ ): NativeNode {
290
+ return node("col", { role: "omp.run", gap: "none", tone: p.tone }, compact(children), p.key);
291
+ }
292
+
192
293
  /** Diagnostic rows shown before `+N more`. */
193
294
  const DIAGNOSTIC_ROWS = 5;
194
295