fm-bench 0.4.3 → 0.4.4

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 (3) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/table.js +84 -11
package/README.md CHANGED
@@ -149,7 +149,7 @@ Token counts come from `fm token-count --quiet`. If `fm` cannot count a response
149
149
 
150
150
  Measured runs stream by default so `fm-bench` can capture TTFT and streaming smoothness. Use `--no-stream` if you need buffered `fm respond` behavior; TTFT, TPOT, second-chunk, and chunk-gap fields that depend on streaming will be blank.
151
151
 
152
- Terminal output is responsive. Wide terminals show full scoreboard and detail tables, medium terminals show a tighter operating-point table, and narrow terminals switch to compact model cards. Use `--width` to preview a layout and `--ascii` for log systems that do not render Unicode borders well.
152
+ Terminal output is responsive. Wide terminals show full scoreboard and detail tables, medium terminals show a tighter operating-point table, and narrow terminals switch to compact model cards. Long `NOTE`, `DESCRIPTION`, and model quota cells wrap so error context stays visible instead of being hidden behind ellipses. Use `--width` to preview a layout and `--ascii` for log systems that do not render Unicode borders well.
153
153
 
154
154
  ## Table Legend
155
155
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fm-bench",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Dynamic benchmark CLI for Apple's fm command on macOS 27+.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/table.js CHANGED
@@ -3,24 +3,30 @@ import { stripAnsi } from './ansi.js';
3
3
  export function renderTable(headers, rows, options = {}) {
4
4
  const ascii = Boolean(options.ascii);
5
5
  const maxCellWidth = options.maxCellWidth || 60;
6
- const normalizedRows = rows.map((row) => row.map((cell) => {
6
+ const wrapColumns = normalizeWrapColumns(headers, options.wrapColumns);
7
+ const normalizedRows = rows.map((row) => row.map((cell, index) => {
7
8
  const normalized = normalizeCell(cell);
8
9
  return {
9
10
  ...normalized,
10
- text: truncate(normalized.text, maxCellWidth)
11
+ text: wrapColumns.has(index) ? normalized.text : truncate(normalized.text, maxCellWidth)
11
12
  };
12
13
  }));
13
- const widths = headers.map((header, index) => {
14
+ const widths = fitTableWidths(headers, headers.map((header, index) => {
14
15
  const values = [truncate(header, maxCellWidth), ...normalizedRows.map((row) => row[index]?.text ?? '')];
16
+ if (wrapColumns.has(index)) {
17
+ return Math.max(...values.map((value) => Math.min(maxCellWidth, visibleLength(value))));
18
+ }
15
19
  return Math.max(...values.map(visibleLength));
16
- });
20
+ }), wrapColumns, terminalWidth(options));
17
21
  const style = ascii ? ASCII_TABLE : UNICODE_TABLE;
18
22
 
19
23
  const top = rule(style.topLeft, style.topJoin, style.topRight, style.horizontal, widths);
20
24
  const middle = rule(style.midLeft, style.midJoin, style.midRight, style.horizontal, widths);
21
25
  const bottom = rule(style.bottomLeft, style.bottomJoin, style.bottomRight, style.horizontal, widths);
22
26
  const headerLine = rowLine(headers.map((header) => truncate(header, maxCellWidth)), widths, style, true, options);
23
- const bodyLines = normalizedRows.map((row) => rowLine(row, widths, style, false, options));
27
+ const bodyLines = wrapColumns.size > 0
28
+ ? normalizedRows.flatMap((row) => wrappedRowLines(row, widths, style, wrapColumns, options))
29
+ : normalizedRows.map((row) => rowLine(row, widths, style, false, options));
24
30
 
25
31
  return [top, headerLine, middle, ...bodyLines, bottom].join('\n');
26
32
  }
@@ -155,7 +161,7 @@ export function renderSummaryTable(summary, options = {}) {
155
161
  cell(formatNumber(item.outputTokenThroughput), tones.systemTps),
156
162
  cell(formatNumber(item.rps), tones.rps),
157
163
  cell(formatPercent(item.latency.cv), cvTone(item.latency.cv)),
158
- cell(item.available ? '' : compactReason(item.skippedReason), item.available ? null : 'yellow')
164
+ cell(item.available ? '' : cleanReason(item.skippedReason), item.available ? null : 'yellow')
159
165
  ];
160
166
 
161
167
  if (mode === 'medium') {
@@ -194,7 +200,7 @@ export function renderSummaryTable(summary, options = {}) {
194
200
  ? (hasGoodput ? mediumHeaders : mediumHeaders.filter((header) => header !== 'good' && header !== 'good rps'))
195
201
  : (hasGoodput ? wideHeaders : wideHeaders.filter((header) => header !== 'good' && header !== 'good rps'));
196
202
 
197
- return renderTable(headers, rows, { ...options, maxCellWidth: mode === 'medium' ? 24 : 52 });
203
+ return renderTable(headers, rows, { ...options, maxCellWidth: mode === 'medium' ? 24 : 52, wrapColumns: ['note'] });
198
204
  }
199
205
 
200
206
  export function renderDetailTable(summary, options = {}) {
@@ -241,7 +247,7 @@ export function renderDetailTable(summary, options = {}) {
241
247
  'description'
242
248
  ];
243
249
 
244
- return renderTable(headers, rows, { ...options, maxCellWidth: mode === 'medium' ? 34 : 52 });
250
+ return renderTable(headers, rows, { ...options, maxCellWidth: mode === 'medium' ? 34 : 52, wrapColumns: ['description'] });
245
251
  }
246
252
 
247
253
  export function renderCompactSummary(summary, options = {}) {
@@ -286,8 +292,8 @@ export function renderModelsTable(models, options = {}) {
286
292
  cell(model.name),
287
293
  cell(model.available ? 'yes' : 'no', model.available ? 'green' : 'yellow'),
288
294
  model.description || '-',
289
- compactReason(model.quota || model.reason || '-')
290
- ]), options);
295
+ cleanReason(model.quota || model.reason || '-')
296
+ ]), { ...options, wrapColumns: ['description', 'quota'] });
291
297
  }
292
298
 
293
299
  function formatRangeMs(low, high) {
@@ -372,6 +378,69 @@ function renderWrappedTable(headers, rows, widths, options = {}) {
372
378
  return [top, headerLine, middle, ...bodyLines, bottom].join('\n');
373
379
  }
374
380
 
381
+ function normalizeWrapColumns(headers, wrapColumns = []) {
382
+ const normalized = new Set();
383
+ for (const column of wrapColumns || []) {
384
+ if (Number.isInteger(column)) {
385
+ if (column >= 0 && column < headers.length) normalized.add(column);
386
+ continue;
387
+ }
388
+
389
+ const index = headers.findIndex((header) => String(header).toLowerCase() === String(column).toLowerCase());
390
+ if (index >= 0) normalized.add(index);
391
+ }
392
+ return normalized;
393
+ }
394
+
395
+ function fitTableWidths(headers, widths, wrapColumns, targetWidth) {
396
+ if (!targetWidth || wrapColumns.size === 0 || renderedTableWidth(widths) <= targetWidth) return widths;
397
+
398
+ const fitted = [...widths];
399
+ const minimums = fitted.map((width, index) => (
400
+ wrapColumns.has(index)
401
+ ? Math.max(visibleLength(String(headers[index]).toUpperCase()), Math.min(12, width))
402
+ : width
403
+ ));
404
+ let excess = renderedTableWidth(fitted) - targetWidth;
405
+
406
+ while (excess > 0) {
407
+ const candidates = [...wrapColumns]
408
+ .filter((index) => fitted[index] > minimums[index])
409
+ .sort((a, b) => fitted[b] - fitted[a]);
410
+ if (candidates.length === 0) break;
411
+
412
+ for (const index of candidates) {
413
+ if (excess <= 0) break;
414
+ fitted[index] -= 1;
415
+ excess -= 1;
416
+ }
417
+ }
418
+
419
+ return fitted;
420
+ }
421
+
422
+ function renderedTableWidth(widths) {
423
+ return widths.reduce((sum, width) => sum + width, 0) + (widths.length * 3) + 1;
424
+ }
425
+
426
+ function wrappedRowLines(row, widths, style, wrapColumns, options = {}) {
427
+ const wrappedCells = row.map((value, index) => {
428
+ const normalized = normalizeCell(value);
429
+ return {
430
+ tone: normalized.tone,
431
+ lines: wrapColumns.has(index) ? wrapText(normalized.text, widths[index]) : [normalized.text]
432
+ };
433
+ });
434
+ const height = Math.max(...wrappedCells.map((item) => item.lines.length));
435
+ const lines = [];
436
+
437
+ for (let lineIndex = 0; lineIndex < height; lineIndex += 1) {
438
+ lines.push(rowLine(wrappedCells.map((item) => cell(item.lines[lineIndex] ?? '', item.tone)), widths, style, false, options));
439
+ }
440
+
441
+ return lines;
442
+ }
443
+
375
444
  function rowLine(row, widths, style, header = false, options = {}) {
376
445
  return `${style.vertical}${row.map((cell, index) => {
377
446
  const normalized = header ? { text: String(cell).toUpperCase(), tone: 'header' } : normalizeCell(cell);
@@ -417,8 +486,12 @@ function visibleLength(value) {
417
486
  return stripAnsi(value).length;
418
487
  }
419
488
 
489
+ function cleanReason(value) {
490
+ return String(value || '').replace(/\s+/g, ' ').trim();
491
+ }
492
+
420
493
  function compactReason(value) {
421
- const clean = String(value || '').replace(/\s+/g, ' ').trim();
494
+ const clean = cleanReason(value);
422
495
  if (clean.length <= 58) return clean;
423
496
  return `${clean.slice(0, 55)}...`;
424
497
  }