@vizzly-testing/cli 0.35.3-beta.0 → 0.35.3-beta.2

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.
@@ -5,6 +5,7 @@
5
5
  import { createApiClient as defaultCreateApiClient, getBuildContext as defaultGetBuildContext, getComparisonContext as defaultGetComparisonContext, getReviewQueueContext as defaultGetReviewQueueContext, getScreenshotContext as defaultGetScreenshotContext, getSimilarFingerprintContext as defaultGetSimilarFingerprintContext } from '../api/index.js';
6
6
  import { createLocalWorkspaceContextProvider as defaultCreateLocalWorkspaceContextProvider } from '../context/local-workspace-provider.js';
7
7
  import { resolveContextSource as defaultResolveContextSource } from '../context/provider-resolver.js';
8
+ import { VizzlyError } from '../errors/vizzly-error.js';
8
9
  import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js';
9
10
  import * as defaultOutput from '../utils/output.js';
10
11
  import { readSession as defaultReadSession } from '../utils/session.js';
@@ -16,7 +17,7 @@ function buildSourceErrorMessage() {
16
17
  return '--source must be one of: auto, cloud, local';
17
18
  }
18
19
  function buildIncludeErrorMessage() {
19
- return '--include must contain only: screenshots, diffs, comments';
20
+ return '--include must contain only: diffs';
20
21
  }
21
22
  function validateLimitRange(value, flagName, {
22
23
  min = 1,
@@ -30,15 +31,81 @@ function validateLimitRange(value, flagName, {
30
31
  }
31
32
  return [];
32
33
  }
33
- function validateOffset(value) {
34
+ function validateCursor(value) {
34
35
  if (value == null) {
35
36
  return [];
36
37
  }
38
+ if (typeof value !== 'string' || value.trim().length === 0) {
39
+ return ['--cursor must be a non-empty opaque cursor'];
40
+ }
41
+ return [];
42
+ }
43
+ function validateOffset(value) {
44
+ if (value == null) return [];
37
45
  if (!Number.isInteger(value) || value < 0) {
38
46
  return ['--offset must be a non-negative integer'];
39
47
  }
40
48
  return [];
41
49
  }
50
+ let COMPACT_CONTEXT_LIMIT = 10;
51
+ let COMPACT_SUMMARY_MAX_BYTES = 64 * 1024;
52
+ let COMPACT_DIFFS_MAX_BYTES = 512 * 1024;
53
+ function getCompactPage(collection, label) {
54
+ if (!collection || !Array.isArray(collection.items) || !collection.page) {
55
+ throw new VizzlyError(`Vizzly returned an invalid compact ${label} collection`, 'COMPACT_CONTEXT_INVALID');
56
+ }
57
+ if (collection.items.length > COMPACT_CONTEXT_LIMIT) {
58
+ throw new VizzlyError(`Vizzly returned more than ${COMPACT_CONTEXT_LIMIT} compact ${label} items`, 'COMPACT_CONTEXT_INVALID');
59
+ }
60
+ if (collection.items.some(item => !item || typeof item !== 'object')) {
61
+ throw new VizzlyError(`Vizzly returned an invalid item in compact ${label}`, 'COMPACT_CONTEXT_INVALID');
62
+ }
63
+ let {
64
+ page
65
+ } = collection;
66
+ let validLimit = Number.isInteger(page.limit) && page.limit >= 1 && page.limit <= COMPACT_CONTEXT_LIMIT;
67
+ let validReturned = Number.isInteger(page.returned) && page.returned === collection.items.length;
68
+ let validTotal = page.total == null || Number.isInteger(page.total) && page.total >= 0 && page.total >= page.returned;
69
+ let validHasMore = typeof page.has_more === 'boolean';
70
+ let validCursor = page.next_cursor == null || typeof page.next_cursor === 'string' && page.next_cursor.length > 0;
71
+ if (!validLimit || !validReturned || !validTotal || !validHasMore || !validCursor) {
72
+ throw new VizzlyError(`Vizzly returned invalid pagination facts for compact ${label}`, 'COMPACT_CONTEXT_INVALID');
73
+ }
74
+ if (page.included === false && collection.items.length > 0) {
75
+ throw new VizzlyError(`Vizzly returned items for an omitted compact ${label} collection`, 'COMPACT_CONTEXT_INVALID');
76
+ }
77
+ if (page.has_more !== Boolean(page.next_cursor)) {
78
+ throw new VizzlyError(`Vizzly returned contradictory pagination facts for compact ${label}`, 'COMPACT_CONTEXT_INVALID');
79
+ }
80
+ return collection;
81
+ }
82
+ function validateCompactContext(context, resource) {
83
+ if (!context || typeof context !== 'object') {
84
+ throw new VizzlyError(`Vizzly returned an invalid compact ${resource} context`, 'COMPACT_CONTEXT_INVALID');
85
+ }
86
+ if (context.resource !== `${resource}_context` || !context[resource]?.id) {
87
+ throw new VizzlyError(`Vizzly returned an invalid compact ${resource} context`, 'COMPACT_CONTEXT_INVALID');
88
+ }
89
+ if (resource === 'build') {
90
+ getCompactPage(context.evidence, 'build evidence');
91
+ } else {
92
+ let history = context.history || {};
93
+ getCompactPage(history.similar_by_fingerprint, 'similar history');
94
+ getCompactPage(history.recent_by_name, 'recent history');
95
+ }
96
+ return context;
97
+ }
98
+ function validateCompactOutputSize(payload, resource, details) {
99
+ let bytes = Buffer.byteLength(JSON.stringify(payload));
100
+ let maxBytes = details === 'diffs' ? COMPACT_DIFFS_MAX_BYTES : COMPACT_SUMMARY_MAX_BYTES;
101
+ if (bytes > maxBytes) {
102
+ throw new VizzlyError(`Vizzly produced an oversized compact ${resource} context`, 'COMPACT_CONTEXT_OVERSIZED', {
103
+ bytes,
104
+ max_bytes: maxBytes
105
+ });
106
+ }
107
+ return payload;
108
+ }
42
109
  function validateSourceOption(value) {
43
110
  if (value == null) {
44
111
  return [];
@@ -55,8 +122,23 @@ function parseIncludeOption(value) {
55
122
  let rawItems = Array.isArray(value) ? value : String(value).split(',');
56
123
  return rawItems.map(item => item.trim()).filter(Boolean);
57
124
  }
125
+ function buildCompactContextRequest(options = {}, globalOptions = {}) {
126
+ let include = parseIncludeOption(options.include);
127
+ let compact = !options.full && (!globalOptions.json || options.agent);
128
+ let details = include.includes('diffs') ? 'diffs' : 'summary';
129
+ return {
130
+ compact,
131
+ details,
132
+ include,
133
+ query: compact ? {
134
+ details,
135
+ limit: COMPACT_CONTEXT_LIMIT,
136
+ cursor: options.cursor
137
+ } : undefined
138
+ };
139
+ }
58
140
  function validateIncludeOption(value) {
59
- let allowed = new Set(['screenshots', 'diffs', 'comments']);
141
+ let allowed = new Set(['diffs']);
60
142
  let invalid = parseIncludeOption(value).filter(item => !allowed.has(item));
61
143
  return invalid.length > 0 ? [buildIncludeErrorMessage()] : [];
62
144
  }
@@ -92,7 +174,7 @@ function createClient(config, createApiClient) {
92
174
  function attachContextSource(context, source) {
93
175
  return {
94
176
  ...context,
95
- source: context?.source || source
177
+ source
96
178
  };
97
179
  }
98
180
  async function loadContextConfig(globalOptions, options, deps) {
@@ -239,12 +321,6 @@ function getStatusTone(colors, status) {
239
321
  function getComparisonDisplayState(comparison = {}) {
240
322
  return comparison.result || comparison.status || 'unknown';
241
323
  }
242
- function isChangedComparison(comparison = {}) {
243
- return ['changed', 'failed', 'pending'].includes(getComparisonDisplayState(comparison));
244
- }
245
- function isNewComparison(comparison = {}) {
246
- return getComparisonDisplayState(comparison) === 'new';
247
- }
248
324
  function getComparisonName(comparison = {}) {
249
325
  return comparison.screenshot_name || comparison.screenshot?.name || comparison.name || comparison.id || 'unknown screenshot';
250
326
  }
@@ -255,139 +331,6 @@ function getComparisonFingerprint(comparison = {}) {
255
331
  return comparison.diff?.fingerprint_hash || comparison.analysis?.fingerprint_hash || null;
256
332
  }
257
333
 
258
- /**
259
- * Decide whether a comparison belongs in the agent handoff.
260
- *
261
- * Explicit API review state wins because an already-reviewed visual change is
262
- * not actionable. Legacy result fallback keeps older flat responses useful
263
- * only when the server did not supply that review fact.
264
- *
265
- * @param {Object} comparison - Normalized comparison record.
266
- * @returns {boolean} Whether the record is actionable evidence.
267
- */
268
- function isEvidenceCandidate(comparison = {}) {
269
- if (comparison.needs_review != null) {
270
- return comparison.needs_review === true;
271
- }
272
- return ['changed', 'new', 'failed', 'error'].includes(comparison.result);
273
- }
274
-
275
- /**
276
- * Keep the group facts needed to understand one comparison in isolation.
277
- *
278
- * Repeating this small server-owned summary on each record avoids returning
279
- * the full, potentially unbounded group tree in compact agent output.
280
- *
281
- * @param {Object} group - Normalized screenshot group.
282
- * @returns {Object} Compact API-backed aggregate facts for the group.
283
- */
284
- function buildEvidenceGroup(group = {}) {
285
- let aggregate = group.aggregate_status || {};
286
- return {
287
- name: group.name || null,
288
- variant_count: group.variant_count ?? null,
289
- needs_review_count: aggregate.needs_review_count ?? null,
290
- failed_count: aggregate.failed_count ?? null,
291
- max_diff_percentage: aggregate.max_diff_percentage ?? null
292
- };
293
- }
294
-
295
- /**
296
- * Shape one normalized comparison for the bounded evidence queue.
297
- *
298
- * The projection keeps visual result, review state, render assets, and
299
- * Honeydiff facts together so an agent can reason about a diff without
300
- * joining separate collections client-side.
301
- *
302
- * @param {Object} comparison - Normalized comparison record.
303
- * @param {Object} group - Normalized group containing the comparison.
304
- * @returns {Object} One self-contained comparison evidence record.
305
- */
306
- function buildComparisonEvidence(comparison = {}, group = {}) {
307
- return {
308
- kind: 'comparison',
309
- id: comparison.id,
310
- name: comparison.name,
311
- result: comparison.result,
312
- status: comparison.status,
313
- review_state: comparison.review_state,
314
- visual_review: comparison.visual_review,
315
- approval_status: comparison.approval_status,
316
- needs_review: comparison.needs_review,
317
- is_flaky: comparison.is_flaky,
318
- group: buildEvidenceGroup(group),
319
- screenshot: comparison.screenshot,
320
- baseline: comparison.baseline,
321
- diff: comparison.diff
322
- };
323
- }
324
-
325
- /**
326
- * Represent a capture failure without pretending it is a comparison.
327
- *
328
- * Failed screenshots have useful render evidence but no comparison ID. The
329
- * explicit kind and null ID prevent suggested commands from sending a
330
- * screenshot identifier to the comparison endpoint.
331
- *
332
- * @param {Object} capture - Normalized failed screenshot capture.
333
- * @returns {Object} One failed-capture evidence record.
334
- */
335
- function buildFailedCaptureEvidence(capture = {}) {
336
- return {
337
- ...buildComparisonEvidence(capture, {
338
- name: capture.name
339
- }),
340
- kind: 'failed_capture',
341
- id: null,
342
- error_message: capture.error_message
343
- };
344
- }
345
-
346
- /**
347
- * Read actionable variants without overriding a server-reviewed group.
348
- *
349
- * A false aggregate is authoritative even when a partial variant payload
350
- * appears pending, which prevents the client from reopening completed work.
351
- *
352
- * @param {Object} group - Normalized screenshot group.
353
- * @returns {Object[]} Actionable variants in API order.
354
- */
355
- function getGroupEvidence(group = {}) {
356
- if (group.aggregate_status?.needs_review === false) {
357
- return [];
358
- }
359
- return (group.variants || []).filter(isEvidenceCandidate);
360
- }
361
-
362
- /**
363
- * Interleave actionable variants across groups in their original API order.
364
- *
365
- * Taking one variant per group before taking second variants preserves useful
366
- * breadth when the final handoff is capped and one screenshot has many device
367
- * or browser variants.
368
- *
369
- * @param {Object[]} groups - Normalized screenshot groups.
370
- * @returns {Object[]} Self-contained evidence records in breadth-first order.
371
- */
372
- function selectBreadthFirstEvidence(groups = []) {
373
- let candidatesByGroup = groups.map(getGroupEvidence);
374
- let evidence = [];
375
- let variantIndex = 0;
376
- let remaining = candidatesByGroup.some(candidates => candidates.length > 0);
377
- while (remaining) {
378
- remaining = false;
379
- for (let groupIndex = 0; groupIndex < groups.length; groupIndex += 1) {
380
- let comparison = candidatesByGroup[groupIndex][variantIndex];
381
- if (comparison) {
382
- evidence.push(buildComparisonEvidence(comparison, groups[groupIndex]));
383
- remaining = true;
384
- }
385
- }
386
- variantIndex += 1;
387
- }
388
- return evidence;
389
- }
390
-
391
334
  /**
392
335
  * Quote a value only when a suggested command needs shell protection.
393
336
  *
@@ -430,43 +373,41 @@ function appendContextSource(command, context = {}) {
430
373
  let source = isLocalContext(context) ? 'local' : 'cloud';
431
374
  return `${command} --source ${source}`;
432
375
  }
433
-
434
- /**
435
- * Build concrete drill-down paths from the evidence actually returned.
436
- *
437
- * Executable commands replace generic client-authored advice. They let an
438
- * agent ask the API for deeper comparison, history, or raw diff context, and
439
- * only suggest the full build when the bounded queue omitted records.
440
- *
441
- * @param {Object} context - Normalized build context.
442
- * @param {Object[]} evidence - Evidence included in the compact handoff.
443
- * @param {boolean} truncated - Whether additional evidence was omitted.
444
- * @returns {{label: string, command: string}[]} Suggested CLI commands.
445
- */
446
- function buildSuggestedCommands(context = {}, evidence = [], truncated = false) {
376
+ function buildCompactBuildCommands(context = {}, include = [], cursor = null) {
377
+ let evidence = context.evidence?.items || [];
378
+ let page = context.evidence?.page || {};
447
379
  let commands = [];
448
- let firstComparison = evidence.find(item => item.kind === 'comparison' && item.id);
449
- let firstNamedEvidence = evidence.find(item => item.name);
450
380
  let buildTarget = isLocalContext(context) ? 'current' : context.build?.id || null;
451
- if (firstComparison?.id) {
381
+ let firstComparison = evidence.find(item => item.type === 'comparison' && item.id);
382
+ let firstNamedEvidence = evidence.find(item => item.screenshot_name);
383
+ if (firstComparison) {
452
384
  commands.push({
453
385
  label: 'Inspect comparison context',
454
- command: appendContextSource(`vizzly --json context comparison ${quoteCommandArgument(firstComparison.id)}`, context)
386
+ command: appendContextSource(`vizzly --json context comparison ${quoteCommandArgument(firstComparison.id)} --agent`, context)
455
387
  });
456
388
  }
457
- if (firstNamedEvidence?.name) {
389
+ if (firstNamedEvidence) {
390
+ let screenshotName = firstNamedEvidence.screenshot_name;
458
391
  commands.push({
459
392
  label: 'Inspect screenshot history',
460
- command: appendContextSource(`vizzly --json context screenshot ${quoteCommandArgument(firstNamedEvidence.name)}`, context)
393
+ command: appendContextSource(`vizzly --json context screenshot ${quoteCommandArgument(screenshotName)}`, context)
461
394
  });
462
395
  }
463
- if (buildTarget && evidence.length > 0) {
396
+ if (buildTarget && evidence.length > 0 && !include.includes('diffs')) {
397
+ let cursorFlag = cursor ? ` --cursor ${quoteCommandArgument(cursor)}` : '';
464
398
  commands.push({
465
399
  label: 'Load raw diff diagnostics',
466
- command: appendContextSource(`vizzly --json context build ${quoteCommandArgument(buildTarget)} --agent --include diffs`, context)
400
+ command: appendContextSource(`vizzly --json context build ${quoteCommandArgument(buildTarget)} --agent --include diffs${cursorFlag}`, context)
401
+ });
402
+ }
403
+ if (buildTarget && page.has_more && page.next_cursor) {
404
+ let includeFlag = include.length > 0 ? ` --include ${include.join(',')}` : '';
405
+ commands.push({
406
+ label: 'Load next evidence page',
407
+ command: appendContextSource(`vizzly --json context build ${quoteCommandArgument(buildTarget)} --agent --cursor ${quoteCommandArgument(page.next_cursor)}${includeFlag}`, context)
467
408
  });
468
409
  }
469
- if (buildTarget && truncated) {
410
+ if (buildTarget) {
470
411
  commands.push({
471
412
  label: 'Load full build context',
472
413
  command: appendContextSource(`vizzly --json context build ${quoteCommandArgument(buildTarget)} --agent --full`, context)
@@ -474,68 +415,124 @@ function buildSuggestedCommands(context = {}, evidence = [], truncated = false)
474
415
  }
475
416
  return commands;
476
417
  }
477
-
478
- /**
479
- * Create the compact agent presentation without rewriting API truth.
480
- *
481
- * Status, summaries, review facts, assets, and Honeydiff values pass through
482
- * normalization from the server. The client owns only the bounded selection,
483
- * truthful truncation facts, explicit includes, and follow-up commands.
484
- *
485
- * @param {Object} context - Raw build context returned by the provider.
486
- * @param {Object} options - Compact presentation options.
487
- * @param {string|null} [options.source] - Resolved source fallback.
488
- * @param {string[]} [options.include] - Explicit detail collections.
489
- * @param {number} [options.evidenceLimit] - Maximum evidence record count.
490
- * @returns {Object} Bounded agent build context.
491
- */
492
- function buildAgentBuildPayload(context, {
493
- source = null,
494
- include = [],
495
- evidenceLimit = 10
496
- } = {}) {
497
- let includeSet = new Set(include);
498
- let includeDiffs = includeSet.has('diffs');
499
- let normalized = normalizeBuildContext(context, {
500
- includeDiffs
501
- });
502
- let candidates = [...normalized.failed_captures.map(buildFailedCaptureEvidence), ...selectBreadthFirstEvidence(normalized.groups)];
503
- let evidence = candidates.slice(0, evidenceLimit);
504
- let evidenceTruncated = candidates.length > evidence.length;
505
- let payload = {
418
+ function buildCompactBuildPayload(context, include = [], cursor = null) {
419
+ return {
420
+ ...context,
506
421
  resource: 'build_agent_context',
507
- source: normalized.source || source,
508
- review_flow: normalized.review_flow,
509
- scope: normalized.scope || null,
510
- project: {
511
- organization: normalized.scope?.organization?.slug || null,
512
- slug: normalized.scope?.project?.slug || null,
513
- name: normalized.scope?.project?.name || null,
514
- visibility: normalized.scope?.project?.visibility || null
515
- },
516
- build: normalized.build || null,
517
- baseline: {
518
- selected: normalized.baseline?.selected || null,
519
- selection_reason: normalized.baseline?.selection_reason || null
520
- },
521
- status: normalized.status || null,
522
- summary: normalized.summary || null,
523
- signature_properties: normalized.signature_properties ?? null,
524
- evidence_limit: evidenceLimit,
525
- evidence_returned: evidence.length,
526
- evidence_truncated: evidenceTruncated,
527
- evidence,
528
- links: normalized.links || {},
529
- preview: normalized.preview || null,
530
- suggested_commands: buildSuggestedCommands(normalized, evidence, evidenceTruncated)
422
+ suggested_commands: buildCompactBuildCommands(context, include, cursor)
531
423
  };
532
- if (includeSet.has('screenshots')) {
533
- payload.screenshots = normalized.screenshots || [];
424
+ }
425
+ function buildCompactComparisonCommands(context = {}, include = []) {
426
+ let commands = [];
427
+ let comparisonId = context.comparison?.id;
428
+ let streams = [['similar_by_fingerprint', 'similar history'], ['recent_by_name', 'recent history']];
429
+ let includeFlag = include.length > 0 ? ` --include ${include.join(',')}` : '';
430
+ if (comparisonId && !include.includes('diffs')) {
431
+ commands.push({
432
+ label: 'Load raw diff diagnostics',
433
+ command: appendContextSource(`vizzly --json context comparison ${quoteCommandArgument(comparisonId)} --agent --include diffs`, context)
434
+ });
534
435
  }
535
- if (includeSet.has('comments')) {
536
- payload.comments = normalized.comments || {};
436
+ for (let [stream, label] of streams) {
437
+ let page = context.history?.[stream]?.page;
438
+ if (!comparisonId || !page?.has_more || !page.next_cursor) {
439
+ continue;
440
+ }
441
+ commands.push({
442
+ label: `Load next ${label} page`,
443
+ command: appendContextSource(`vizzly --json context comparison ${quoteCommandArgument(comparisonId)} --agent --cursor ${quoteCommandArgument(page.next_cursor)}${includeFlag}`, context)
444
+ });
445
+ }
446
+ if (comparisonId) {
447
+ commands.push({
448
+ label: 'Load full comparison context',
449
+ command: appendContextSource(`vizzly --json context comparison ${quoteCommandArgument(comparisonId)} --agent --full`, context)
450
+ });
451
+ }
452
+ return commands;
453
+ }
454
+ function buildCompactComparisonPayload(context, include = []) {
455
+ return {
456
+ ...context,
457
+ resource: 'comparison_agent_context',
458
+ suggested_commands: buildCompactComparisonCommands(context, include)
459
+ };
460
+ }
461
+ function formatKnownBoolean(value) {
462
+ if (value === true) return 'yes';
463
+ if (value === false) return 'no';
464
+ return 'unknown';
465
+ }
466
+ function formatCompactPage(collection) {
467
+ let page = collection?.page || {};
468
+ let returned = page.returned ?? collection?.items?.length;
469
+ let total = page.total;
470
+ if (returned == null) return 'unknown';
471
+ if (total == null) return String(returned);
472
+ return `${returned} of ${total}${page.has_more ? ' · more available' : ''}`;
473
+ }
474
+ function displayCompactHeading(output, context, title, status) {
475
+ let colors = output.getColors();
476
+ let tone = getStatusTone(colors, status);
477
+ let organization = context.scope?.organization?.slug || 'unknown';
478
+ let project = context.scope?.project?.slug || 'unknown';
479
+ output.print(` ${colors.bold(title)} ${tone(status.toUpperCase())}`);
480
+ output.print(` ${colors.dim(`@${organization}/${project}`)}`);
481
+ output.blank();
482
+ }
483
+ function displayCompactBuildContext(output, context) {
484
+ output.header('context', 'build');
485
+ let colors = output.getColors();
486
+ let build = context.build || {};
487
+ let displayStatus = build.status || context.status?.state || 'unknown';
488
+ displayCompactHeading(output, context, build.name || build.id || 'unknown build', displayStatus);
489
+ output.labelValue('Attention', formatKnownBoolean(context.status?.needs_review));
490
+ output.labelValue('Evidence', formatCompactPage(context.evidence));
491
+ let items = context.evidence?.items || [];
492
+ if (items.length > 0) {
493
+ output.blank();
494
+ output.print(' Evidence');
495
+ for (let item of items) {
496
+ let result = item.result || item.status || 'unknown';
497
+ let percentage = getComparisonDiffPercentage(item);
498
+ let detail = percentage == null ? '' : ` · ${percentage}% diff`;
499
+ output.print(` ${colors.dim('•')} ${getComparisonName(item)}: ${result}${detail}`);
500
+ }
501
+ }
502
+ if (context.evidence?.page?.has_more) {
503
+ let next = context.suggested_commands?.find(command => command.label === 'Load next evidence page');
504
+ if (next) {
505
+ output.blank();
506
+ output.labelValue('Next', next.command);
507
+ }
508
+ }
509
+ if (context.links?.build_url) {
510
+ output.labelValue('Build URL', context.links.build_url);
511
+ }
512
+ }
513
+ function displayCompactComparisonContext(output, context) {
514
+ output.header('context', 'comparison');
515
+ let comparison = context.comparison || {};
516
+ let displayState = getComparisonDisplayState(comparison);
517
+ displayCompactHeading(output, context, getComparisonName(comparison), displayState);
518
+ output.labelValue('Images', comparison.diff?.image_url || comparison.analysis?.diff_image_url ? 'baseline/current/diff available' : 'unavailable');
519
+ output.labelValue('Similar history', formatCompactPage(context.history?.similar_by_fingerprint));
520
+ output.labelValue('Recent history', formatCompactPage(context.history?.recent_by_name));
521
+ let similar = context.history?.similar_by_fingerprint?.items || [];
522
+ if (similar.length > 0) {
523
+ output.blank();
524
+ output.print(' Similar Diffs');
525
+ printComparisonList(output, similar);
526
+ }
527
+ let recent = context.history?.recent_by_name?.items || [];
528
+ if (recent.length > 0) {
529
+ output.blank();
530
+ output.print(' Recent Diffs');
531
+ printComparisonList(output, recent);
532
+ }
533
+ if (context.links?.comparison_url) {
534
+ output.labelValue('Comparison URL', context.links.comparison_url);
537
535
  }
538
- return payload;
539
536
  }
540
537
  function getBuildCommentsCount(context = {}) {
541
538
  if (Array.isArray(context.comments?.build)) {
@@ -734,61 +731,6 @@ function displayBuildContext(output, context) {
734
731
  printFailedCaptures(output, failedCaptures);
735
732
  }
736
733
  }
737
- function formatAgentBuildContext(context) {
738
- let comparisons = context.comparisons || [];
739
- let changed = comparisons.filter(isChangedComparison);
740
- let fresh = comparisons.filter(isNewComparison);
741
- let needsReview = comparisons.filter(comparison => comparison.needs_review);
742
- let baseline = context.baseline?.selected;
743
- let lines = [`# Vizzly Visual Context: ${context.build?.name || context.build?.id || 'Build'}`, '', `Project: ${context.scope?.organization?.slug || 'unknown'}/${context.scope?.project?.slug || 'unknown'}`, `Build: ${context.build?.id || 'unknown'} (${context.build?.status || 'unknown'})`];
744
- if (baseline) {
745
- lines.push(`Approved baseline: ${baseline.name || baseline.id || 'selected'} (${baseline.approval_status || 'unknown'})`);
746
- }
747
- if (context.status) {
748
- lines.push(`Needs review: ${context.status.needs_review ? 'yes' : 'no'} (${context.status.pending_comparisons || 0} pending comparisons)`);
749
- }
750
- if (context.preview?.url || context.preview?.preview_url) {
751
- lines.push(`Preview: ${context.preview.url || context.preview.preview_url}`);
752
- }
753
- if (context.links?.build_url) {
754
- lines.push(`Build URL: ${context.links.build_url}`);
755
- }
756
- if (context.links?.report_url) {
757
- lines.push(`Report: ${context.links.report_url}`);
758
- }
759
- lines.push('');
760
- lines.push('## Diff Summary');
761
- lines.push(`- Total comparisons: ${comparisons.length}`);
762
- lines.push(`- Changed: ${changed.length}`);
763
- lines.push(`- New: ${fresh.length}`);
764
- lines.push(`- Needs review: ${needsReview.length}`);
765
- if (changed.length > 0 || fresh.length > 0) {
766
- lines.push('');
767
- lines.push('## Evidence To Inspect');
768
- for (let comparison of [...changed, ...fresh].slice(0, 10)) {
769
- let diffPercentage = getComparisonDiffPercentage(comparison);
770
- let detail = diffPercentage == null ? '' : ` · ${diffPercentage}% diff`;
771
- let diffUrl = comparison.diff?.image_url || comparison.analysis?.diff_image_url;
772
- lines.push(`- ${getComparisonName(comparison)}: ${getComparisonDisplayState(comparison)}${detail}`);
773
- if (diffUrl) {
774
- lines.push(` Diff: ${diffUrl}`);
775
- }
776
- }
777
- }
778
- if (comparisons.length > 0 && changed.length === 0 && fresh.length === 0) {
779
- lines.push('');
780
- lines.push('## Reviewed Screenshots');
781
- for (let comparison of comparisons.slice(0, 10)) {
782
- lines.push(`- ${getComparisonName(comparison)}: ${getComparisonDisplayState(comparison)}`);
783
- }
784
- if (comparisons.length > 10) {
785
- lines.push(`- ...${comparisons.length - 10} more`);
786
- }
787
- }
788
- lines.push('');
789
- lines.push('Use this as reviewed UI context. Treat approved baselines as visual truth, inspect meaningful diffs, and leave approval decisions to humans.');
790
- return lines.join('\n');
791
- }
792
734
  function countScreenshotCommentEntries(groups = []) {
793
735
  return groups.reduce((total, group) => total + (group.comments?.length || 0), 0);
794
736
  }
@@ -825,11 +767,16 @@ function displayScreenshotContext(output, context) {
825
767
  output.header('context', 'screenshot');
826
768
  let colors = output.getColors();
827
769
  let confirmedRegionLabels = formatConfirmedRegionLabels(context.confirmed_regions);
770
+ let hotspotAnalysis = context.hotspot_analysis;
828
771
  output.print(` ${colors.bold(context.screenshot.name)}`);
829
772
  output.print(` ${colors.dim(`@${context.scope.organization.slug}/${context.scope.project.slug}`)}`);
830
773
  output.blank();
831
774
  output.labelValue('Memory', `${context.history.recent_comparisons.length} recent comparisons · ${context.confirmed_regions.length} confirmed regions`);
832
- output.labelValue('Hotspots', `${context.hotspot_analysis.total_builds_analyzed} builds analyzed · ${context.hotspot_analysis.confidence}`);
775
+ if (hotspotAnalysis) {
776
+ output.labelValue('Hotspots', `${hotspotAnalysis.total_builds_analyzed ?? 'unknown'} builds analyzed · ${hotspotAnalysis.confidence ?? 'unknown'}`);
777
+ } else {
778
+ output.labelValue('Hotspots', 'unavailable');
779
+ }
833
780
  if (confirmedRegionLabels) {
834
781
  output.labelValue('Known Regions', confirmedRegionLabels);
835
782
  }
@@ -891,18 +838,23 @@ export async function contextBuildCommand(buildId, options = {}, globalOptions =
891
838
  return;
892
839
  }
893
840
  let resolvedBuildId = resolveBuildContextId(buildId, runtime, deps);
894
- let include = parseIncludeOption(options.include);
895
- let query = globalOptions.json && options.agent && !options.full ? {
896
- details: include.includes('diffs') ? 'diffs' : 'summary'
897
- } : undefined;
841
+ let {
842
+ compact,
843
+ details,
844
+ include,
845
+ query
846
+ } = buildCompactContextRequest(options, globalOptions);
898
847
  output.startSpinner('Fetching build context...');
899
848
  let context = await runtime.provider.getBuildContext(resolvedBuildId, query);
900
849
  output.stopSpinner();
901
- if (globalOptions.json && options.agent && !options.full) {
902
- output.data(buildAgentBuildPayload(context, {
903
- source: runtime.source,
904
- include
905
- }));
850
+ if (compact) {
851
+ let compactContext = validateCompactContext(context, 'build');
852
+ let payload = validateCompactOutputSize(buildCompactBuildPayload(compactContext, include, options.cursor), 'build', details);
853
+ if (globalOptions.json) {
854
+ output.data(payload);
855
+ } else {
856
+ displayCompactBuildContext(output, payload);
857
+ }
906
858
  output.cleanup();
907
859
  return;
908
860
  }
@@ -911,11 +863,6 @@ export async function contextBuildCommand(buildId, options = {}, globalOptions =
911
863
  output.cleanup();
912
864
  return;
913
865
  }
914
- if (options.agent) {
915
- output.print(formatAgentBuildContext(context));
916
- output.cleanup();
917
- return;
918
- }
919
866
  displayBuildContext(output, context);
920
867
  output.cleanup();
921
868
  } catch (error) {
@@ -944,14 +891,26 @@ export async function contextComparisonCommand(comparisonId, options = {}, globa
944
891
  if (!runtime) {
945
892
  return;
946
893
  }
947
- let query = {
948
- similarLimit: options.similarLimit,
949
- recentLimit: options.recentLimit,
950
- windowSize: options.windowSize
951
- };
894
+ let {
895
+ compact,
896
+ details,
897
+ include,
898
+ query
899
+ } = buildCompactContextRequest(options, globalOptions);
952
900
  output.startSpinner('Fetching comparison context...');
953
901
  let context = await runtime.provider.getComparisonContext(comparisonId, query);
954
902
  output.stopSpinner();
903
+ if (compact) {
904
+ let compactContext = validateCompactContext(context, 'comparison');
905
+ let payload = validateCompactOutputSize(buildCompactComparisonPayload(compactContext, include), 'comparison', details);
906
+ if (globalOptions.json) {
907
+ output.data(payload);
908
+ } else {
909
+ displayCompactComparisonContext(output, payload);
910
+ }
911
+ output.cleanup();
912
+ return;
913
+ }
955
914
  if (globalOptions.json) {
956
915
  output.data(context);
957
916
  output.cleanup();
@@ -1088,20 +1047,14 @@ export async function contextReviewQueueCommand(options = {}, globalOptions = {}
1088
1047
  export function validateContextBuildOptions(_options = {}) {
1089
1048
  let errors = validateSourceOption(_options.source);
1090
1049
  errors.push(...validateIncludeOption(_options.include));
1050
+ errors.push(...validateCursor(_options.cursor));
1091
1051
  return errors;
1092
1052
  }
1093
1053
  export function validateContextComparisonOptions(options = {}) {
1094
1054
  let errors = [];
1095
1055
  errors.push(...validateSourceOption(options.source));
1096
- errors.push(...validateLimitRange(options.similarLimit, '--similar-limit', {
1097
- max: 50
1098
- }));
1099
- errors.push(...validateLimitRange(options.recentLimit, '--recent-limit', {
1100
- max: 50
1101
- }));
1102
- errors.push(...validateLimitRange(options.windowSize, '--window-size', {
1103
- max: 50
1104
- }));
1056
+ errors.push(...validateIncludeOption(options.include));
1057
+ errors.push(...validateCursor(options.cursor));
1105
1058
  return errors;
1106
1059
  }
1107
1060
  export function validateContextScreenshotOptions(options = {}) {