@vitest-agent/ui 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/dispatcher/cells/single-file-fail.js +28 -0
  4. package/dispatcher/cells/single-file-pass.js +19 -0
  5. package/dispatcher/cells/single-file-threshold.js +22 -0
  6. package/dispatcher/cells/single-project-fail.js +34 -0
  7. package/dispatcher/cells/single-project-pass.js +17 -0
  8. package/dispatcher/cells/single-project-threshold.js +25 -0
  9. package/dispatcher/cells/single-test-fail.js +22 -0
  10. package/dispatcher/cells/single-test-pass.js +19 -0
  11. package/dispatcher/cells/single-test-threshold.js +12 -0
  12. package/dispatcher/cells/workspace-fail.js +27 -0
  13. package/dispatcher/cells/workspace-pass.js +25 -0
  14. package/dispatcher/cells/workspace-threshold.js +28 -0
  15. package/dispatcher/classify.js +48 -0
  16. package/dispatcher/dispatch.js +73 -0
  17. package/dispatcher/footer.js +55 -0
  18. package/dispatcher/helpers.js +186 -0
  19. package/dispatcher/ink-helpers.js +79 -0
  20. package/format-duration.js +34 -0
  21. package/index.d.ts +1843 -0
  22. package/index.js +40 -0
  23. package/package.json +59 -0
  24. package/pubsub/Channel.js +30 -0
  25. package/pubsub/Publisher.js +37 -0
  26. package/pubsub/Subscriber.js +77 -0
  27. package/pubsub/index.js +5 -0
  28. package/reducer.js +260 -0
  29. package/render-agent.js +137 -0
  30. package/render-ink/CountColumns.js +34 -0
  31. package/render-ink/CoverageBlock.js +80 -0
  32. package/render-ink/FailureSection.js +69 -0
  33. package/render-ink/FailuresSection.js +85 -0
  34. package/render-ink/ModuleHeader.js +43 -0
  35. package/render-ink/ProjectRow.js +56 -0
  36. package/render-ink/StatusIcon.js +44 -0
  37. package/render-ink/StreamApp.js +453 -0
  38. package/render-ink/SuggestedActions.js +49 -0
  39. package/render-ink/TestRow.js +33 -0
  40. package/render-ink/TrendLine.js +42 -0
  41. package/render-ink/index.js +14 -0
  42. package/render-ink/spinner.js +62 -0
  43. package/render-ink/tag-suffix.js +18 -0
  44. package/synthesize.js +335 -0
  45. package/tsdoc-metadata.json +11 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 C. Spencer Beggs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # @vitest-agent/ui
2
+
3
+ [![npm](https://img.shields.io/npm/v/@vitest-agent/ui?label=npm&color=cb3837)](https://www.npmjs.com/package/@vitest-agent/ui)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-4caf50.svg)](https://opensource.org/licenses/MIT)
5
+ [![TypeScript 6.0](https://img.shields.io/badge/TypeScript-6.0-3178c6.svg)](https://www.typescriptlang.org/)
6
+
7
+ > **Part of the [vitest-agent](https://vitest-agent.dev) ecosystem.** Most users want **[@vitest-agent/plugin](https://www.npmjs.com/package/@vitest-agent/plugin)**, which pulls this package in automatically. Install `@vitest-agent/ui` directly only if you build custom rendering on the primitives.
8
+
9
+ Pure rendering primitives for vitest-agent. Owns the `RunEvent` reducer, the shape-tailored 12-cell dispatcher matrix, two render paths (agent string and React Ink tree), a PubSub channel for live event transport, and the synthesizers that bridge live Vitest data and stored reports into the event taxonomy. React and Ink are required peer dependencies.
10
+
11
+ ## Features
12
+
13
+ - **Reducer** — pure `(state, event) => state` fold over the `RunEvent` discriminated union; `reduceRenderStateAll` for one-shot replay
14
+ - **Dispatcher matrix** — `dispatch` and `dispatchInk` route a `DispatchInputs` to the matching `(RunShape, RunOutcome)` cell; `classifyRunShape` and `classifyOutcome` derive the coordinates
15
+ - **Ink components** — `StreamApp` and the supporting primitives (`ModuleHeader`, `TestRow`, `CoverageBlock`, `TrendLine`, `FailureSection`, etc.) for live `stream`-mode frames
16
+ - **PubSub channel** — `RunEventChannel` Effect tag and helpers (`accumulateUntilFinished`, `forEachRenderState`, `renderStateStream`) for live event transport
17
+ - **Synthesizers** — `synthesizeRunEvents` from live Vitest modules; `synthesizeFromAgentReport` from a stored `AgentReport`
18
+ - **Footer builder** — `buildFooter` assembles the L1 MCP-tool-pointer footer; `dominantClassification` picks the most actionable failure class
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ npm install @vitest-agent/ui
24
+ # or
25
+ pnpm add @vitest-agent/ui
26
+ ```
27
+
28
+ React and Ink are required peers:
29
+
30
+ ```bash
31
+ npm install react ink
32
+ ```
33
+
34
+ ## Quick start
35
+
36
+ ```ts
37
+ import { reduceRenderStateAll } from "@vitest-agent/ui";
38
+ import { dispatch } from "@vitest-agent/ui";
39
+
40
+ const state = reduceRenderStateAll(events);
41
+ // state is a fully reduced RenderState
42
+
43
+ const agentString = dispatch(buildDispatchInputs(state, input), opts);
44
+ // agentString is the markdown-flavored final-frame string for the run
45
+ ```
46
+
47
+ ## Documentation
48
+
49
+ Package reference at [vitest-agent.dev/ui](https://vitest-agent.dev/ui).
50
+
51
+ ## License
52
+
53
+ [MIT](LICENSE)
@@ -0,0 +1,28 @@
1
+ import { buildFooter } from "../footer.js";
2
+ import { formatDisplayDuration } from "../../format-duration.js";
3
+ import { formatFailure, soleModulePath } from "../helpers.js";
4
+ import { renderAgentStringAsInk } from "../ink-helpers.js";
5
+
6
+ //#region src/dispatcher/cells/single-file-fail.ts
7
+ const DEFAULT_WIDTH = 80;
8
+ const renderAgent = (inputs) => {
9
+ const modulePath = soleModulePath(inputs.state);
10
+ if (modulePath === void 0) return "";
11
+ const { passCount, failCount, skipCount, durationMs } = inputs.state.totals;
12
+ const parts = [`${passCount}/${passCount + failCount + skipCount} passed`];
13
+ if (failCount > 0) parts.push(`${failCount} failed`);
14
+ if (skipCount > 0) parts.push(`${skipCount} skipped`);
15
+ const sections = [`${modulePath}: ${parts.join(", ")} (${formatDisplayDuration(durationMs)})`];
16
+ if (inputs.state.failures.length > 0) {
17
+ sections.push("Failures:");
18
+ for (const failure of inputs.state.failures) sections.push(...formatFailure(failure, DEFAULT_WIDTH));
19
+ }
20
+ return `${sections.join("\n")}\n${buildFooter(inputs)}`;
21
+ };
22
+ const renderSingleFileFail = {
23
+ agent: renderAgent,
24
+ ink: (inputs) => renderAgentStringAsInk(renderAgent(inputs))
25
+ };
26
+
27
+ //#endregion
28
+ export { renderSingleFileFail };
@@ -0,0 +1,19 @@
1
+ import { buildFooter } from "../footer.js";
2
+ import { formatDisplayDuration } from "../../format-duration.js";
3
+ import { soleModulePath } from "../helpers.js";
4
+ import { renderAgentStringAsInk } from "../ink-helpers.js";
5
+
6
+ //#region src/dispatcher/cells/single-file-pass.ts
7
+ const renderAgent = (inputs) => {
8
+ const modulePath = soleModulePath(inputs.state);
9
+ if (modulePath === void 0) return "";
10
+ const { passCount, durationMs } = inputs.state.totals;
11
+ return `${modulePath}: ${passCount} ${passCount === 1 ? "test" : "tests"} passed (${formatDisplayDuration(durationMs)})\n${buildFooter(inputs)}`;
12
+ };
13
+ const renderSingleFilePass = {
14
+ agent: renderAgent,
15
+ ink: (inputs) => renderAgentStringAsInk(renderAgent(inputs))
16
+ };
17
+
18
+ //#endregion
19
+ export { renderSingleFilePass };
@@ -0,0 +1,22 @@
1
+ import { buildFooter } from "../footer.js";
2
+ import { formatDisplayDuration } from "../../format-duration.js";
3
+ import { formatCoverageJudgmentLine, soleModulePath } from "../helpers.js";
4
+ import { renderAgentStringAsInk } from "../ink-helpers.js";
5
+
6
+ //#region src/dispatcher/cells/single-file-threshold.ts
7
+ const renderAgent = (inputs) => {
8
+ const modulePath = soleModulePath(inputs.state);
9
+ if (modulePath === void 0) return "";
10
+ const { passCount, durationMs } = inputs.state.totals;
11
+ const lines = [`${modulePath}: ${passCount} ${passCount === 1 ? "test" : "tests"} passed (${formatDisplayDuration(durationMs)})`];
12
+ const coverage = formatCoverageJudgmentLine(inputs.state);
13
+ if (coverage !== null) lines.push(coverage);
14
+ return `${lines.join("\n")}\n${buildFooter(inputs)}`;
15
+ };
16
+ const renderSingleFileThreshold = {
17
+ agent: renderAgent,
18
+ ink: (inputs) => renderAgentStringAsInk(renderAgent(inputs))
19
+ };
20
+
21
+ //#endregion
22
+ export { renderSingleFileThreshold };
@@ -0,0 +1,34 @@
1
+ import { buildFooter } from "../footer.js";
2
+ import { formatFailure, formatTotals } from "../helpers.js";
3
+ import { renderAgentStringAsInk } from "../ink-helpers.js";
4
+
5
+ //#region src/dispatcher/cells/single-project-fail.ts
6
+ const DEFAULT_WIDTH = 80;
7
+ const renderAgent = (inputs) => {
8
+ const sections = [[formatTotals(inputs.state)]];
9
+ if (inputs.state.failures.length > 0) {
10
+ const failureLines = ["Failures:"];
11
+ for (const failure of inputs.state.failures) failureLines.push(...formatFailure(failure, DEFAULT_WIDTH));
12
+ sections.push(failureLines);
13
+ }
14
+ const moduleLines = ["Modules:"];
15
+ for (const path of inputs.state.moduleOrder) {
16
+ const m = inputs.state.modules[path];
17
+ if (!m) continue;
18
+ const parts = [];
19
+ if (m.passCount > 0) parts.push(`${m.passCount} passed`);
20
+ if (m.failCount > 0) parts.push(`${m.failCount} failed`);
21
+ if (m.skipCount > 0) parts.push(`${m.skipCount} skipped`);
22
+ const summary = parts.length > 0 ? parts.join(", ") : `status=${m.status}`;
23
+ moduleLines.push(`- ${m.modulePath}: ${summary}`);
24
+ }
25
+ if (moduleLines.length > 1) sections.push(moduleLines);
26
+ return `${sections.map((section) => section.join("\n")).join("\n\n")}\n${buildFooter(inputs)}`;
27
+ };
28
+ const renderSingleProjectFail = {
29
+ agent: renderAgent,
30
+ ink: (inputs) => renderAgentStringAsInk(renderAgent(inputs))
31
+ };
32
+
33
+ //#endregion
34
+ export { renderSingleProjectFail };
@@ -0,0 +1,17 @@
1
+ import { buildFooter } from "../footer.js";
2
+ import { formatTotals } from "../helpers.js";
3
+ import { renderAgentStringAsInk } from "../ink-helpers.js";
4
+
5
+ //#region src/dispatcher/cells/single-project-pass.ts
6
+ const renderAgent = (inputs) => {
7
+ const modules = inputs.state.moduleOrder.length;
8
+ const noun = modules === 1 ? "module" : "modules";
9
+ return `${[formatTotals(inputs.state), `${modules} ${noun} all-passed.`].join("\n\n")}\n${buildFooter(inputs)}`;
10
+ };
11
+ const renderSingleProjectPass = {
12
+ agent: renderAgent,
13
+ ink: (inputs) => renderAgentStringAsInk(renderAgent(inputs))
14
+ };
15
+
16
+ //#endregion
17
+ export { renderSingleProjectPass };
@@ -0,0 +1,25 @@
1
+ import { buildFooter } from "../footer.js";
2
+ import { formatBelowTargetTable, formatCoverageJudgmentLine, formatTotals, formatTrendLine } from "../helpers.js";
3
+ import { renderAgentStringAsInk } from "../ink-helpers.js";
4
+
5
+ //#region src/dispatcher/cells/single-project-threshold.ts
6
+ const BELOW_TARGET_LIMIT = 5;
7
+ const renderAgent = (inputs) => {
8
+ const sections = [[formatTotals(inputs.state)]];
9
+ const coverage = formatCoverageJudgmentLine(inputs.state);
10
+ const trend = formatTrendLine(inputs.trend);
11
+ const summaryLines = [];
12
+ if (coverage !== null) summaryLines.push(coverage);
13
+ if (trend !== null) summaryLines.push(trend);
14
+ if (summaryLines.length > 0) sections.push(summaryLines);
15
+ const below = formatBelowTargetTable(inputs.belowTarget, BELOW_TARGET_LIMIT);
16
+ if (below.length > 0) sections.push([...below]);
17
+ return `${sections.map((section) => section.join("\n")).join("\n\n")}\n${buildFooter(inputs)}`;
18
+ };
19
+ const renderSingleProjectThreshold = {
20
+ agent: renderAgent,
21
+ ink: (inputs) => renderAgentStringAsInk(renderAgent(inputs))
22
+ };
23
+
24
+ //#endregion
25
+ export { renderSingleProjectThreshold };
@@ -0,0 +1,22 @@
1
+ import { buildFooter } from "../footer.js";
2
+ import { formatDisplayDuration } from "../../format-duration.js";
3
+ import { formatFailure, formatTestName, soleTest } from "../helpers.js";
4
+ import { renderAgentStringAsInk } from "../ink-helpers.js";
5
+
6
+ //#region src/dispatcher/cells/single-test-fail.ts
7
+ const DEFAULT_WIDTH = 80;
8
+ const renderAgent = (inputs) => {
9
+ const test = soleTest(inputs.state);
10
+ if (test === void 0) return "";
11
+ const duration = test.durationMs ?? 0;
12
+ const header = `✗ ${formatTestName(test)} (${formatDisplayDuration(duration)})`;
13
+ const failure = inputs.state.failures[0];
14
+ return `${failure === void 0 ? `${header}\n` : `${header}\n${formatFailure(failure, DEFAULT_WIDTH).join("\n")}\n`}${buildFooter(inputs)}`;
15
+ };
16
+ const renderSingleTestFail = {
17
+ agent: renderAgent,
18
+ ink: (inputs) => renderAgentStringAsInk(renderAgent(inputs))
19
+ };
20
+
21
+ //#endregion
22
+ export { renderSingleTestFail };
@@ -0,0 +1,19 @@
1
+ import { buildFooter } from "../footer.js";
2
+ import { formatDisplayDuration } from "../../format-duration.js";
3
+ import { formatTestName, soleTest } from "../helpers.js";
4
+ import { renderAgentStringAsInk } from "../ink-helpers.js";
5
+
6
+ //#region src/dispatcher/cells/single-test-pass.ts
7
+ const renderAgent = (inputs) => {
8
+ const test = soleTest(inputs.state);
9
+ if (test === void 0) return "";
10
+ const duration = test.durationMs ?? 0;
11
+ return `✓ ${formatTestName(test)} (${formatDisplayDuration(duration)})\n${buildFooter(inputs)}`;
12
+ };
13
+ const renderSingleTestPass = {
14
+ agent: renderAgent,
15
+ ink: (inputs) => renderAgentStringAsInk(renderAgent(inputs))
16
+ };
17
+
18
+ //#endregion
19
+ export { renderSingleTestPass };
@@ -0,0 +1,12 @@
1
+ //#region src/dispatcher/cells/single-test-threshold.ts
2
+ /**
3
+ * No-op cell. The `single-test × threshold-violation` combination has
4
+ * no observable difference from `single-test × all-pass` because a
5
+ * single-test run never exercises the full file's coverage. The cell
6
+ * exists so the dispatcher matrix is total; calling it falls through
7
+ * to the empty string.
8
+ */
9
+ const renderSingleTestThreshold = { agent: () => "" };
10
+
11
+ //#endregion
12
+ export { renderSingleTestThreshold };
@@ -0,0 +1,27 @@
1
+ import { buildFooter } from "../footer.js";
2
+ import { formatFailure, formatProjectsTable, formatTrendLine, formatWorkspaceTotal } from "../helpers.js";
3
+ import { renderAgentStringAsInk } from "../ink-helpers.js";
4
+
5
+ //#region src/dispatcher/cells/workspace-fail.ts
6
+ const DEFAULT_WIDTH = 80;
7
+ const renderAgent = (inputs) => {
8
+ const sections = [];
9
+ const projects = formatProjectsTable(inputs.projects);
10
+ if (projects.length > 0) sections.push([...projects]);
11
+ if (inputs.state.failures.length > 0) {
12
+ const failureLines = ["Failures:"];
13
+ for (const failure of inputs.state.failures) failureLines.push(...formatFailure(failure, DEFAULT_WIDTH));
14
+ sections.push(failureLines);
15
+ }
16
+ const trend = formatTrendLine(inputs.trend);
17
+ if (trend !== null) sections.push([trend]);
18
+ if (inputs.projects.length > 0) sections.push([formatWorkspaceTotal(inputs.projects)]);
19
+ return `${sections.map((section) => section.join("\n")).join("\n\n")}\n${buildFooter(inputs)}`;
20
+ };
21
+ const renderWorkspaceFail = {
22
+ agent: renderAgent,
23
+ ink: (inputs) => renderAgentStringAsInk(renderAgent(inputs))
24
+ };
25
+
26
+ //#endregion
27
+ export { renderWorkspaceFail };
@@ -0,0 +1,25 @@
1
+ import { buildFooter } from "../footer.js";
2
+ import { formatCoverageJudgmentLine, formatProjectsTable, formatTrendLine, formatWorkspaceTotal } from "../helpers.js";
3
+ import { renderAgentStringAsInk } from "../ink-helpers.js";
4
+
5
+ //#region src/dispatcher/cells/workspace-pass.ts
6
+ const renderAgent = (inputs) => {
7
+ const sections = [];
8
+ const projects = formatProjectsTable(inputs.projects);
9
+ if (projects.length > 0) sections.push([...projects]);
10
+ const summaryLines = [];
11
+ const coverage = formatCoverageJudgmentLine(inputs.state);
12
+ if (coverage !== null) summaryLines.push(coverage);
13
+ const trend = formatTrendLine(inputs.trend);
14
+ if (trend !== null) summaryLines.push(trend);
15
+ if (summaryLines.length > 0) sections.push(summaryLines);
16
+ if (inputs.projects.length > 0) sections.push([formatWorkspaceTotal(inputs.projects)]);
17
+ return `${sections.map((section) => section.join("\n")).join("\n\n")}\n${buildFooter(inputs)}`;
18
+ };
19
+ const renderWorkspacePass = {
20
+ agent: renderAgent,
21
+ ink: (inputs) => renderAgentStringAsInk(renderAgent(inputs))
22
+ };
23
+
24
+ //#endregion
25
+ export { renderWorkspacePass };
@@ -0,0 +1,28 @@
1
+ import { buildFooter } from "../footer.js";
2
+ import { formatBelowTargetTable, formatCoverageJudgmentLine, formatProjectsTable, formatTrendLine, formatWorkspaceTotal } from "../helpers.js";
3
+ import { renderAgentStringAsInk } from "../ink-helpers.js";
4
+
5
+ //#region src/dispatcher/cells/workspace-threshold.ts
6
+ const BELOW_TARGET_LIMIT = 10;
7
+ const renderAgent = (inputs) => {
8
+ const sections = [];
9
+ const projects = formatProjectsTable(inputs.projects);
10
+ if (projects.length > 0) sections.push([...projects]);
11
+ const summaryLines = [];
12
+ const coverage = formatCoverageJudgmentLine(inputs.state);
13
+ if (coverage !== null) summaryLines.push(coverage);
14
+ const trend = formatTrendLine(inputs.trend);
15
+ if (trend !== null) summaryLines.push(trend);
16
+ if (summaryLines.length > 0) sections.push(summaryLines);
17
+ const below = formatBelowTargetTable(inputs.belowTarget, BELOW_TARGET_LIMIT);
18
+ if (below.length > 0) sections.push([...below]);
19
+ if (inputs.projects.length > 0) sections.push([formatWorkspaceTotal(inputs.projects)]);
20
+ return `${sections.map((section) => section.join("\n")).join("\n\n")}\n${buildFooter(inputs)}`;
21
+ };
22
+ const renderWorkspaceThreshold = {
23
+ agent: renderAgent,
24
+ ink: (inputs) => renderAgentStringAsInk(renderAgent(inputs))
25
+ };
26
+
27
+ //#endregion
28
+ export { renderWorkspaceThreshold };
@@ -0,0 +1,48 @@
1
+ //#region src/dispatcher/classify.ts
2
+ /**
3
+ * Compute the run shape from the reduced state plus the per-project
4
+ * aggregates the plugin carries through `DispatchInputs.projects`.
5
+ *
6
+ * The classification follows the state-shape signal described in the
7
+ * UI rewrite spec §7 open question 1:
8
+ *
9
+ * 1. More than one project → `workspace`.
10
+ * 2. One module with exactly one test → `single-test`.
11
+ * 3. One module with more than one test → `single-file`.
12
+ * 4. Otherwise → `single-project`.
13
+ *
14
+ * @param state - the fully-reduced render state
15
+ * @param projects - per-project summaries from the dispatch inputs
16
+ * @returns the run shape classification
17
+ * @public
18
+ */
19
+ const classifyRunShape = (state, projects) => {
20
+ if (projects.length > 1) return "workspace";
21
+ const moduleEntries = Object.values(state.modules);
22
+ if (moduleEntries.length === 1) {
23
+ const sole = moduleEntries[0];
24
+ if (sole && sole.tests.length === 1) return "single-test";
25
+ return "single-file";
26
+ }
27
+ return "single-project";
28
+ };
29
+ /**
30
+ * Compute the outcome class from the reduced state.
31
+ *
32
+ * Precedence: failures win over threshold violations. A run with one
33
+ * failing test and a coverage gap classifies as `some-fail`; the
34
+ * threshold-violation cell is reserved for runs where the test suite
35
+ * itself is clean but coverage policy is not.
36
+ *
37
+ * @param state - the fully-reduced render state
38
+ * @returns the outcome classification
39
+ * @public
40
+ */
41
+ const classifyOutcome = (state) => {
42
+ if (state.totals.failCount > 0) return "some-fail";
43
+ if (state.coverage !== null && state.coverage.violations.length > 0) return "threshold-violation";
44
+ return "all-pass";
45
+ };
46
+
47
+ //#endregion
48
+ export { classifyOutcome, classifyRunShape };
@@ -0,0 +1,73 @@
1
+ import { renderSingleFileFail } from "./cells/single-file-fail.js";
2
+ import { renderSingleFilePass } from "./cells/single-file-pass.js";
3
+ import { renderSingleFileThreshold } from "./cells/single-file-threshold.js";
4
+ import { renderSingleProjectFail } from "./cells/single-project-fail.js";
5
+ import { renderSingleProjectPass } from "./cells/single-project-pass.js";
6
+ import { renderSingleProjectThreshold } from "./cells/single-project-threshold.js";
7
+ import { renderSingleTestFail } from "./cells/single-test-fail.js";
8
+ import { renderSingleTestPass } from "./cells/single-test-pass.js";
9
+ import { renderSingleTestThreshold } from "./cells/single-test-threshold.js";
10
+ import { renderWorkspaceFail } from "./cells/workspace-fail.js";
11
+ import { renderWorkspacePass } from "./cells/workspace-pass.js";
12
+ import { renderWorkspaceThreshold } from "./cells/workspace-threshold.js";
13
+
14
+ //#region src/dispatcher/dispatch.ts
15
+ /**
16
+ * The 4×3 cell table. Exported for the test harness so spy-based
17
+ * routing tests can introspect the wiring without re-deriving it from
18
+ * source.
19
+ *
20
+ * @public
21
+ */
22
+ const dispatcherTable = {
23
+ "single-test": {
24
+ "all-pass": renderSingleTestPass,
25
+ "some-fail": renderSingleTestFail,
26
+ "threshold-violation": renderSingleTestThreshold
27
+ },
28
+ "single-file": {
29
+ "all-pass": renderSingleFilePass,
30
+ "some-fail": renderSingleFileFail,
31
+ "threshold-violation": renderSingleFileThreshold
32
+ },
33
+ "single-project": {
34
+ "all-pass": renderSingleProjectPass,
35
+ "some-fail": renderSingleProjectFail,
36
+ "threshold-violation": renderSingleProjectThreshold
37
+ },
38
+ workspace: {
39
+ "all-pass": renderWorkspacePass,
40
+ "some-fail": renderWorkspaceFail,
41
+ "threshold-violation": renderWorkspaceThreshold
42
+ }
43
+ };
44
+ /**
45
+ * Dispatch to the cell that matches `(inputs.shape, inputs.outcome)`
46
+ * and return its agent-half string output.
47
+ *
48
+ * @param inputs - the classified dispatch inputs
49
+ * @param opts - cell rendering options
50
+ * @returns the agent-string output for the matched cell
51
+ * @public
52
+ */
53
+ const dispatch = (inputs, opts) => {
54
+ return dispatcherTable[inputs.shape][inputs.outcome].agent(inputs, opts);
55
+ };
56
+ /**
57
+ * Dispatch to the cell that matches `(inputs.shape, inputs.outcome)`
58
+ * and return its Ink-half React tree. Returns `null` when the matched
59
+ * cell does not expose an `ink` half — callers should fall back to
60
+ * the agent string in that case.
61
+ *
62
+ * @param inputs - the classified dispatch inputs
63
+ * @param opts - cell rendering options
64
+ * @returns the Ink React element, or `null` when the cell has no Ink half
65
+ * @public
66
+ */
67
+ const dispatchInk = (inputs, opts) => {
68
+ const cell = dispatcherTable[inputs.shape][inputs.outcome];
69
+ return cell.ink !== void 0 ? cell.ink(inputs, opts) : null;
70
+ };
71
+
72
+ //#endregion
73
+ export { dispatch, dispatchInk, dispatcherTable };
@@ -0,0 +1,55 @@
1
+ //#region src/dispatcher/footer.ts
2
+ const FOOTER_COVERAGE_GAP = "Use `file_coverage` to find uncovered functions.";
3
+ const FOOTER_FAILURE_PERSISTENT = "Use `test_errors` for failure detail; `failure_signature_get` to check known patterns.";
4
+ const FOOTER_FAILURE_FLAKY = "Use `failure_signature_get` to confirm the flakiness signature.";
5
+ const FOOTER_THRESHOLD = "Use `test_coverage` for the workspace coverage breakdown.";
6
+ /**
7
+ * Pick the dominant classification from a state's failure list.
8
+ *
9
+ * Priority order (most actionable first): `new-failure`, `persistent`,
10
+ * `flaky`, `recovered`, `stable`. Returns `null` when the failure list
11
+ * is empty or every failure is unclassified.
12
+ *
13
+ * @param state - the fully-reduced render state
14
+ * @returns the most actionable classification, or `null` when none applies
15
+ * @public
16
+ */
17
+ const dominantClassification = (state) => {
18
+ if (state.failures.length === 0) return null;
19
+ const priority = [
20
+ "new-failure",
21
+ "persistent",
22
+ "flaky",
23
+ "recovered",
24
+ "stable"
25
+ ];
26
+ const present = /* @__PURE__ */ new Set();
27
+ for (const f of state.failures) if (f.classification !== null) present.add(f.classification);
28
+ for (const cls of priority) if (present.has(cls)) return cls;
29
+ return null;
30
+ };
31
+ /**
32
+ * Compose the trailing footer lines for a dispatcher cell. Returns an
33
+ * empty string when no pointer applies. The footer starts with a
34
+ * leading newline so cells can append it directly without crafting
35
+ * their own separator.
36
+ *
37
+ * @param inputs - the classified dispatch inputs
38
+ * @returns the footer string, or an empty string when no pointer applies
39
+ * @public
40
+ */
41
+ const buildFooter = (inputs) => {
42
+ const lines = [];
43
+ if (inputs.outcome === "all-pass" && inputs.belowTarget.length > 0) lines.push(FOOTER_COVERAGE_GAP);
44
+ if (inputs.outcome === "some-fail") {
45
+ const cls = dominantClassification(inputs.state);
46
+ if (cls === "new-failure" || cls === "persistent") lines.push(FOOTER_FAILURE_PERSISTENT);
47
+ else if (cls === "flaky") lines.push(FOOTER_FAILURE_FLAKY);
48
+ }
49
+ if (inputs.outcome === "threshold-violation") lines.push(FOOTER_THRESHOLD);
50
+ if (lines.length === 0) return "";
51
+ return `\n${lines.join("\n")}\n`;
52
+ };
53
+
54
+ //#endregion
55
+ export { buildFooter, dominantClassification };