@vitest-agent/ui 5.1.2 → 5.2.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.
@@ -152,22 +152,23 @@ const formatTrendLine = (trend) => {
152
152
  return `Trend: ${trend.direction} (${Fmt.plural(trend.runCount, "run")})`;
153
153
  };
154
154
  /**
155
- * Format a compact projects-table line for one project. Pads the name
156
- * column to `nameWidth` so a column of rows aligns when joined with
157
- * newlines. Status glyph is `✓` for clean projects, `✗` for any
158
- * project carrying failures or violations.
155
+ * The two cells of one project's row: the status glyph with the name, then
156
+ * the counts, duration and (for a multi-tag project) the tag counts. The
157
+ * glyph is `✓` for a clean project and `✗` for one carrying failures or
158
+ * timeouts.
159
159
  */
160
- const formatProjectRow = (project, nameWidth) => {
160
+ const projectCells = (project) => {
161
161
  const timeoutCount = project.timeoutCount ?? 0;
162
162
  const total = project.passCount + project.failCount + project.skipCount + timeoutCount;
163
- const glyph = statusGlyph(project.failCount > 0 || timeoutCount > 0 ? "failure" : "success");
163
+ const failing = project.failCount > 0 || timeoutCount > 0;
164
+ const glyph = statusGlyph(failing ? "failure" : "success");
164
165
  const countParts = [`${project.passCount}/${total} passed`];
165
166
  if (project.failCount > 0) countParts.push(`${project.failCount} failed`);
166
167
  if (timeoutCount > 0) countParts.push(`${timeoutCount} timed out`);
167
- const counts = project.failCount > 0 || timeoutCount > 0 ? countParts.join(", ") : `${project.passCount} passed`;
168
+ const counts = failing ? countParts.join(", ") : `${project.passCount} passed`;
168
169
  const tagSuffix = formatTagCountSuffix(project.tagCounts);
169
- const base = ` ${glyph} ${project.name.padEnd(nameWidth)} ${counts} (${formatDisplayDuration(project.durationMs)})`;
170
- return tagSuffix.length === 0 ? base : `${base} ${tagSuffix}`;
170
+ const base = `${counts} (${formatDisplayDuration(project.durationMs)})`;
171
+ return [`${glyph} ${project.name}`, tagSuffix.length === 0 ? base : `${base} ${tagSuffix}`];
171
172
  };
172
173
  const formatTagCountSuffix = (tagCounts) => {
173
174
  if (tagCounts === void 0) return "";
@@ -176,14 +177,29 @@ const formatTagCountSuffix = (tagCounts) => {
176
177
  return [...entries].sort(([a], [b]) => a.localeCompare(b)).map(([tag, count]) => `${tag}:${count}`).join(" ");
177
178
  };
178
179
  /**
180
+ * A render width no table reaches, so the kit never truncates a cell: a
181
+ * project name or a coverage path is printed whole however long it is
182
+ * (issue #237 follow-up).
183
+ */
184
+ const UNBOUNDED_WIDTH = Number.MAX_SAFE_INTEGER;
185
+ /**
179
186
  * Format the `Projects (N):` block as an array of lines including the
180
- * leading header and each project row. The longest project name sets
181
- * the padded column width so the counts align vertically.
187
+ * leading header and each project row, indented two spaces.
188
+ *
189
+ * @remarks
190
+ * The rows are a header-less, space-aligned kit `Doc.table` rendered plain,
191
+ * so the name column is padded by display width (a wide or combining
192
+ * character in a project name keeps the counts aligned) rather than by
193
+ * UTF-16 length.
182
194
  */
183
195
  const formatProjectsTable = (projects) => {
184
196
  if (projects.length === 0) return [];
185
- const nameWidth = projects.reduce((max, p) => Math.max(max, p.name.length), 0);
186
- return [`Projects (${projects.length}):`, ...projects.map((p) => formatProjectRow(p, nameWidth))];
197
+ const table = Doc.table([{ header: "" }, { header: "" }], projects.map(projectCells));
198
+ const rows = Render.plain([table], Render.contextOf({
199
+ audience: "agent",
200
+ width: UNBOUNDED_WIDTH
201
+ })).split("\n").map((row) => ` ${row}`);
202
+ return [`Projects (${projects.length}):`, ...rows];
187
203
  };
188
204
  /**
189
205
  * Format the `Total:` footer for a workspace run.
@@ -197,55 +213,55 @@ const formatWorkspaceTotal = (projects) => formatTotalsLine({
197
213
  durationMs: sumOf(projects, (p) => p.durationMs)
198
214
  });
199
215
  const sumOf = (projects, pick) => projects.reduce((sum, p) => sum + pick(p), 0);
200
- const TABLE_COL_FILE_MIN = 60;
201
216
  /**
202
- * Format the `Files below aspirational target:` block — a pipe-delimited
203
- * table truncated to the first `limit` files with a "+N more" suffix.
204
- * Returns an empty array when `belowTarget` is empty.
217
+ * Format the `Files below aspirational target:` block — an istanbul-shaped
218
+ * pipe table (`Doc.table` with `style: "pipe"`) of the first `limit` files,
219
+ * followed by a `… N more` line when files were omitted. Returns an empty
220
+ * array when `belowTarget` is empty.
205
221
  *
206
- * The file column is sized to the longest path among the printed rows
207
- * (never narrower than {@link TABLE_COL_FILE_MIN}), so a printed path is
208
- * never truncated (issue #237 follow-up) — only the omitted rows (past
209
- * `limit`) are unaffected, since their width never entered the
210
- * calculation. Each path is written through `link` after the column is
211
- * padded, so a hyperlink wraps the path alone and never the padding.
222
+ * @remarks
223
+ * The table renders at {@link UNBOUNDED_WIDTH}, so the kit never truncates a
224
+ * cell and a printed path is always whole (issue #237 follow-up). Columns are
225
+ * padded by display width and the percentage cells are centred by the kit.
226
+ * Each path is a `Doc.link` written through `link`, so a hyperlink wraps the
227
+ * path alone and never the padding. It renders through `Render.ansi` at
228
+ * colour `none` (as {@link formatFailure} does), since `plain` never calls
229
+ * `link`.
212
230
  */
213
231
  const formatBelowTargetTable = (belowTarget, limit, link = (_target, label) => label) => {
214
232
  if (belowTarget.length === 0) return [];
215
- const top = belowTarget.slice(0, limit);
216
- const omitted = belowTarget.length - top.length;
217
- const fileColWidth = Math.max(TABLE_COL_FILE_MIN, ...top.map((f) => f.file.length + 2));
218
- const header = [
219
- "Files below aspirational target:",
220
- buildTableSeparator(fileColWidth),
221
- buildTableHeader(fileColWidth),
222
- buildTableSeparator(fileColWidth)
223
- ];
224
- const rows = top.map((file) => buildTableRow(file, fileColWidth, link));
225
- const footer = [];
226
- if (omitted > 0) footer.push(`… ${omitted} more (use the test_coverage MCP tool for the full list)`);
227
- return [
228
- ...header,
229
- ...rows,
230
- ...footer
231
- ];
232
- };
233
- const buildTableSeparator = (fileColWidth) => {
234
- return `${"-".repeat(fileColWidth)}|---------|---------|---------|---------|-------------------`;
235
- };
236
- const buildTableHeader = (fileColWidth) => {
237
- return ` ${"File".padEnd(fileColWidth - 1)}| % Stmts | % Branch| % Funcs | % Lines | Uncovered Line #s`;
238
- };
239
- const buildTableRow = (file, fileColWidth, link) => {
240
- return `${` ${link({ file: file.file }, file.file)}${" ".repeat(Math.max(0, fileColWidth - 1 - file.file.length))}`}|${pctCell(file.summary.statements)}|${pctCell(file.summary.branches)}|${pctCell(file.summary.functions)}|${pctCell(file.summary.lines)}|${` ${file.uncoveredLines}`}`;
241
- };
242
- const pctCell = (n) => {
243
- const text = `${Math.round(n)}`;
244
- const pad = Math.max(0, 9 - text.length);
245
- const left = Math.floor(pad / 2);
246
- const right = pad - left;
247
- return `${" ".repeat(left + 1)}${text}${" ".repeat(right)}`;
233
+ const pct = (header) => ({
234
+ header,
235
+ align: "center"
236
+ });
237
+ const table = Doc.table([
238
+ { header: "File" },
239
+ pct("% Stmts"),
240
+ pct("% Branch"),
241
+ pct("% Funcs"),
242
+ pct("% Lines"),
243
+ { header: "Uncovered Line #s" }
244
+ ], belowTarget.map((file) => [
245
+ Doc.link({ file: file.file }, file.file, { suffix: false }),
246
+ pctCell(file.summary.statements),
247
+ pctCell(file.summary.branches),
248
+ pctCell(file.summary.functions),
249
+ pctCell(file.summary.lines),
250
+ file.uncoveredLines
251
+ ]), {
252
+ style: "pipe",
253
+ cap: limit,
254
+ overflow: (hidden) => `… ${hidden} more (use the test_coverage MCP tool for the full list)`
255
+ });
256
+ return ["Files below aspirational target:", ...Render.ansi([table], {
257
+ ...Render.contextOf({
258
+ audience: "human",
259
+ width: UNBOUNDED_WIDTH
260
+ }),
261
+ link: (target, label) => "file" in target ? link(target, label) : label
262
+ }).split("\n")];
248
263
  };
264
+ const pctCell = (n) => `${Math.round(n)}`;
249
265
  const SEVERITY_LABEL = {
250
266
  info: "info",
251
267
  warn: "warn",
@@ -256,6 +272,8 @@ const SEVERITY_LABEL = {
256
272
  * `- <severity>: <title> (tool: <targetTool>)` with the detail indented under
257
273
  * it. Empty when there is nothing to suggest. Shared by `renderAgent` and the
258
274
  * dispatcher, so the one-shot and cell paths print the same list (issue #544).
275
+ *
276
+ * @public
259
277
  */
260
278
  const formatSuggestedActionLines = (actions) => {
261
279
  if (actions.length === 0) return [];
@@ -267,4 +285,4 @@ const formatSuggestedActionLines = (actions) => {
267
285
  return lines;
268
286
  };
269
287
 
270
- export { formatBelowTargetTable, formatCoverageJudgmentLine, formatCoverageSummaryLines, formatDisplayDuration, formatFailure, formatGlobShortfallLines, formatPercent, formatProjectRow, formatProjectsTable, formatSuggestedActionLines, formatTestName, formatTotals, formatTrendLine, formatWorkspaceTotal, soleModulePath, soleTest };
288
+ export { formatBelowTargetTable, formatCoverageJudgmentLine, formatCoverageSummaryLines, formatDisplayDuration, formatFailure, formatGlobShortfallLines, formatPercent, formatProjectsTable, formatSuggestedActionLines, formatTestName, formatTotals, formatTrendLine, formatWorkspaceTotal, soleModulePath, soleTest };
package/index.d.ts CHANGED
@@ -1334,6 +1334,15 @@ export declare const buildFooter: (inputs: DispatchInputs) => string;
1334
1334
  * @public
1335
1335
  */
1336
1336
  export declare const formatDisplayDuration: (ms: number) => string;
1337
+ /**
1338
+ * The suggested-actions block as a compact list: `Actions:` then, per action,
1339
+ * `- <severity>: <title> (tool: <targetTool>)` with the detail indented under
1340
+ * it. Empty when there is nothing to suggest. Shared by `renderAgent` and the
1341
+ * dispatcher, so the one-shot and cell paths print the same list (issue #544).
1342
+ *
1343
+ * @public
1344
+ */
1345
+ export declare const formatSuggestedActionLines: (actions: ReadonlyArray<SuggestedActionRecord>) => ReadonlyArray<string>;
1337
1346
  /**
1338
1347
  * Apply a single event to the previous state and return the next.
1339
1348
  *
package/index.js CHANGED
@@ -2,6 +2,7 @@ import { classifyOutcome, classifyRunShape } from "./dispatcher/classify.js";
2
2
  import { buildFooter, dominantClassification } from "./dispatcher/footer.js";
3
3
  import { VitestAgentStatus, VitestAgentTokens, inkStyle, statusGlyph, statusInkStyle } from "./theme.js";
4
4
  import { formatDisplayDuration } from "./format-duration.js";
5
+ import { formatSuggestedActionLines } from "./dispatcher/helpers.js";
5
6
  import { dispatch, dispatcherTable } from "./dispatcher/dispatch.js";
6
7
  import { reduceRenderState, reduceRenderStateAll } from "./reducer.js";
7
8
  import { renderAgent } from "./render-agent.js";
@@ -17,6 +18,6 @@ import { ActionSeverity, CoverageFile, CoverageGap, CoverageMetric, CoverageRend
17
18
  *
18
19
  * @public
19
20
  */
20
- const CURRENT_UI_VERSION = "5.1.2";
21
+ const CURRENT_UI_VERSION = "5.2.1";
21
22
 
22
- export { ActionSeverity, CURRENT_UI_VERSION, CoverageFile, CoverageGap, CoverageMetric, CoverageRenderState, FailureRecord, ModuleRecord, RenderState, RenderTotals, RunEvent, SPINNER_FRAMES, SPINNER_FRAME_MS, SUITE_FAILURE_LABEL, SUITE_LOAD_FAILURE_LABEL, SuggestedActionRecord, TestRecord, VitestAgentStatus, VitestAgentTokens, buildFooter, classifyOutcome, classifyRunShape, dispatch, dispatcherTable, dominantClassification, formatDisplayDuration, initialRenderState, inkStyle, reduceRenderState, reduceRenderStateAll, renderAgent, spinnerFrame, statusGlyph, statusInkStyle, synthesizeFromAgentReport, synthesizeRunEvents };
23
+ export { ActionSeverity, CURRENT_UI_VERSION, CoverageFile, CoverageGap, CoverageMetric, CoverageRenderState, FailureRecord, ModuleRecord, RenderState, RenderTotals, RunEvent, SPINNER_FRAMES, SPINNER_FRAME_MS, SUITE_FAILURE_LABEL, SUITE_LOAD_FAILURE_LABEL, SuggestedActionRecord, TestRecord, VitestAgentStatus, VitestAgentTokens, buildFooter, classifyOutcome, classifyRunShape, dispatch, dispatcherTable, dominantClassification, formatDisplayDuration, formatSuggestedActionLines, initialRenderState, inkStyle, reduceRenderState, reduceRenderStateAll, renderAgent, spinnerFrame, statusGlyph, statusInkStyle, synthesizeFromAgentReport, synthesizeRunEvents };
@@ -1,4 +1,5 @@
1
1
  import { VitestAgentTokens, inkStyle, statusGlyph, statusInkStyle } from "../theme.js";
2
+ import { Fmt } from "@effected/cli";
2
3
  import { useGlyphs } from "@effected/cli/ui";
3
4
  import { Text } from "ink";
4
5
  import { jsxs } from "react/jsx-runtime";
@@ -14,7 +15,7 @@ import { jsxs } from "react/jsx-runtime";
14
15
  const DURATION_CELL_WIDTH = 7;
15
16
  const cell = (count, status, glyphs) => /* @__PURE__ */ jsxs(Text, {
16
17
  ...count > 0 ? statusInkStyle(status) : inkStyle(VitestAgentTokens.zero),
17
- children: [String(count).padStart(4), statusGlyph(status, glyphs)]
18
+ children: [Fmt.pad(String(count), 4, { align: "right" }), statusGlyph(status, glyphs)]
18
19
  });
19
20
  /**
20
21
  * Renders four colored count columns (pass ✓, fail ✗, skip ↷, timeout ⧖)
package/ink/ProjectRow.js CHANGED
@@ -3,11 +3,19 @@ import { formatDisplayDuration } from "../format-duration.js";
3
3
  import { CountColumns, DURATION_CELL_WIDTH } from "./CountColumns.js";
4
4
  import { StatusIcon } from "./StatusIcon.js";
5
5
  import { TagColumns } from "./TagColumns.js";
6
+ import { Fmt } from "@effected/cli";
6
7
  import { Box, Text } from "ink";
7
8
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
8
9
 
9
10
  // src/ink/ProjectRow.tsx
10
11
  /**
12
+ * One row of the `workspace`-shape `stream` view: a per-Vitest-project
13
+ * rollup. Carries the project name and the four-outcome count columns.
14
+ *
15
+ * The glyph animates while the project has running modules and resolves
16
+ * to the outcome on finish: ✗ failure, ⧖ timeout, ↷ skip-only, ✓ pass.
17
+ */
18
+ /**
11
19
  * Pick the glyph element for the project row using the spec's severity
12
20
  * precedence (`✗ > ⧖ > ⚠ > ↷ > ✓`). Skip-only projects (no passes, no
13
21
  * failures, no timeouts) resolve to `↷`, not a false-positive `✓`.
@@ -34,7 +42,7 @@ const ProjectRow = ({ project, counts, running, timedOut = false, elapsedMs, fra
34
42
  projectGlyph(counts, running, timedOut, frame),
35
43
  /* @__PURE__ */ jsxs(Text, { children: [
36
44
  " ",
37
- project.name.padEnd(nameWidth),
45
+ Fmt.pad(project.name, nameWidth),
38
46
  " "
39
47
  ] }),
40
48
  /* @__PURE__ */ jsx(CountColumns, {
@@ -45,7 +53,7 @@ const ProjectRow = ({ project, counts, running, timedOut = false, elapsedMs, fra
45
53
  }),
46
54
  /* @__PURE__ */ jsxs(Text, {
47
55
  dimColor: true,
48
- children: [" ", formatDisplayDuration(elapsedMs).padStart(7)]
56
+ children: [" ", Fmt.pad(formatDisplayDuration(elapsedMs), 7, { align: "right" })]
49
57
  }),
50
58
  tagUnion.length > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Text, { children: " " }), /* @__PURE__ */ jsx(TagColumns, {
51
59
  tags: tagUnion,
package/ink/StreamApp.js CHANGED
@@ -133,8 +133,7 @@ const ModuleStreamRow = ({ module, nowMs, frame, nameWidth, timedOut, tagUnion:
133
133
  glyph,
134
134
  /* @__PURE__ */ jsxs(Text, { children: [
135
135
  " ",
136
- link({ file: module.modulePath }, module.modulePath),
137
- " ".repeat(Math.max(0, nameWidth - module.modulePath.length)),
136
+ Fmt.pad(link({ file: module.modulePath }, module.modulePath), nameWidth),
138
137
  " "
139
138
  ] }),
140
139
  /* @__PURE__ */ jsx(CountColumns, {
@@ -145,7 +144,7 @@ const ModuleStreamRow = ({ module, nowMs, frame, nameWidth, timedOut, tagUnion:
145
144
  }),
146
145
  /* @__PURE__ */ jsxs(Text, {
147
146
  dimColor: true,
148
- children: [" ", formatDisplayDuration(moduleElapsedMs(module, nowMs)).padStart(7)]
147
+ children: [" ", Fmt.pad(formatDisplayDuration(moduleElapsedMs(module, nowMs)), 7, { align: "right" })]
149
148
  }),
150
149
  rowTagUnion.length > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Text, { children: " " }), /* @__PURE__ */ jsx(TagColumns, {
151
150
  tags: rowTagUnion,
@@ -246,7 +245,7 @@ const FailureItem = ({ failure }) => {
246
245
  const TotalsLine = ({ totals, labelWidth }) => /* @__PURE__ */ jsxs(Text, { children: [
247
246
  /* @__PURE__ */ jsx(Text, {
248
247
  bold: true,
249
- children: labelWidth !== void 0 ? "Total:".padEnd(labelWidth) : "Total:"
248
+ children: labelWidth !== void 0 ? Fmt.pad("Total:", labelWidth) : "Total:"
250
249
  }),
251
250
  " ",
252
251
  /* @__PURE__ */ jsx(CountColumns, {
@@ -257,7 +256,7 @@ const TotalsLine = ({ totals, labelWidth }) => /* @__PURE__ */ jsxs(Text, { chil
257
256
  }),
258
257
  /* @__PURE__ */ jsxs(Text, {
259
258
  dimColor: true,
260
- children: [" ", formatDisplayDuration(totals.durationMs).padStart(7)]
259
+ children: [" ", Fmt.pad(formatDisplayDuration(totals.durationMs), 7, { align: "right" })]
261
260
  })
262
261
  ] });
263
262
  /**
@@ -349,7 +348,7 @@ const liveRegion = (state, shape, nowMs, frame, glyphs, link) => {
349
348
  })]
350
349
  });
351
350
  if (shape === "workspace") {
352
- const nameWidth = Math.max(0, ...groups.map((g) => g.name.length));
351
+ const nameWidth = Math.max(0, ...groups.map((g) => Fmt.width(g.name)));
353
352
  const groupTagCounts = new Map(groups.map((g) => [g.name, mergeTagCounts(g.modules)]));
354
353
  const workspaceTags = tagUnion(groups.map((g) => groupTagCounts.get(g.name)));
355
354
  let runningVisible = 0;
@@ -415,7 +414,7 @@ const liveRegion = (state, shape, nowMs, frame, glyphs, link) => {
415
414
  ] });
416
415
  }
417
416
  if (shape === "single-project") {
418
- const nameWidth = Math.max(0, ...ordered.map((m) => m.modulePath.length));
417
+ const nameWidth = Math.max(0, ...ordered.map((m) => Fmt.width(m.modulePath)));
419
418
  const moduleTags = tagUnion(ordered.map((m) => m.tagCounts));
420
419
  let runningVisible = 0;
421
420
  let runningOverflow = 0;
package/ink/TagColumns.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { VitestAgentTokens, inkStyle } from "../theme.js";
2
+ import { Fmt } from "@effected/cli";
2
3
  import { Text } from "ink";
3
4
  import { jsx, jsxs } from "react/jsx-runtime";
4
5
 
@@ -40,7 +41,7 @@ const TagColumns = ({ tags, counts }) => {
40
41
  i > 0 ? " " : "",
41
42
  tag,
42
43
  ":",
43
- String(count).padStart(4)
44
+ Fmt.pad(String(count), 4, { align: "right" })
44
45
  ]
45
46
  }, tag);
46
47
  }) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vitest-agent/ui",
3
- "version": "5.1.2",
3
+ "version": "5.2.1",
4
4
  "private": false,
5
5
  "description": "Shared event-sourced renderer for vitest-agent. React Ink view for humans, plain-string view for agents, both fed by the same reducer over a RunEvent stream.",
6
6
  "keywords": [
@@ -44,14 +44,15 @@
44
44
  "./package.json": "./package.json"
45
45
  },
46
46
  "dependencies": {
47
- "@vitest-agent/sdk": "8.0.0",
48
- "effect": "^4.0.6"
47
+ "@vitest-agent/sdk": "9.0.1",
48
+ "effect": "^4.0.7"
49
49
  },
50
50
  "peerDependencies": {
51
- "@effected/cli": "^0.22.2",
52
- "@effected/env": "^0.5.0",
53
- "@effected/glob": "^0.14.0",
54
- "@effected/walker": "^0.20.1",
51
+ "@effected/cli": "^0.24.0",
52
+ "@effected/config-file": "^0.22.0",
53
+ "@effected/env": "^0.7.0",
54
+ "@effected/glob": "^0.16.0",
55
+ "@effected/walker": "^0.22.0",
55
56
  "ink": "^8.0.0",
56
57
  "react": "^19.3.0"
57
58
  },
@@ -61,6 +62,9 @@
61
62
  },
62
63
  "react": {
63
64
  "optional": true
65
+ },
66
+ "@effected/config-file": {
67
+ "optional": true
64
68
  }
65
69
  },
66
70
  "engines": {
@@ -5,7 +5,7 @@
5
5
  "toolPackages": [
6
6
  {
7
7
  "packageName": "@microsoft/api-extractor",
8
- "packageVersion": "7.59.4"
8
+ "packageVersion": "7.60.0"
9
9
  }
10
10
  ]
11
11
  }