@pellux/goodvibes-tui 0.20.3 → 0.22.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 (142) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +23 -2
  3. package/docs/foundation-artifacts/operator-contract.json +78 -1
  4. package/package.json +4 -2
  5. package/src/audio/spoken-turn-controller.ts +31 -1
  6. package/src/audio/spoken-turn-wiring.ts +26 -4
  7. package/src/cli/bundle-command.ts +1 -1
  8. package/src/cli/completions/generate.ts +658 -0
  9. package/src/cli/config-overrides.ts +68 -0
  10. package/src/cli/entrypoint.ts +6 -0
  11. package/src/cli/help.ts +4 -2
  12. package/src/cli/management-commands.ts +1 -1
  13. package/src/cli/management.ts +1 -8
  14. package/src/cli/parser.ts +31 -18
  15. package/src/cli/service-command.ts +1 -1
  16. package/src/cli/surface-command.ts +1 -1
  17. package/src/cli/tui-startup.ts +72 -10
  18. package/src/cli/types.ts +14 -3
  19. package/src/cli-flags.ts +1 -0
  20. package/src/config/atomic-write.ts +70 -0
  21. package/src/config/goodvibes-home-audit.ts +2 -0
  22. package/src/config/read-versioned.ts +115 -0
  23. package/src/core/context-auto-compact.ts +77 -0
  24. package/src/core/conversation-rendering.ts +49 -15
  25. package/src/core/conversation.ts +101 -16
  26. package/src/core/format-user-error.ts +192 -0
  27. package/src/core/stream-event-wiring.ts +144 -0
  28. package/src/core/stream-stall-watchdog.ts +103 -0
  29. package/src/core/system-message-router.ts +5 -1
  30. package/src/core/turn-event-wiring.ts +124 -0
  31. package/src/daemon/cli.ts +5 -0
  32. package/src/export/cost-utils.ts +71 -0
  33. package/src/export/gist-uploader.ts +136 -0
  34. package/src/input/command-registry.ts +32 -1
  35. package/src/input/commands/control-room-runtime.ts +10 -10
  36. package/src/input/commands/experience-runtime.ts +5 -4
  37. package/src/input/commands/knowledge.ts +1 -1
  38. package/src/input/commands/local-auth-runtime.ts +27 -5
  39. package/src/input/commands/local-setup.ts +4 -6
  40. package/src/input/commands/memory-product-runtime.ts +8 -6
  41. package/src/input/commands/operator-panel-runtime.ts +1 -1
  42. package/src/input/commands/operator-runtime.ts +3 -10
  43. package/src/input/commands/{integration-runtime.ts → plugin-runtime.ts} +1 -1
  44. package/src/input/commands/provider.ts +57 -3
  45. package/src/input/commands/recall-review.ts +26 -2
  46. package/src/input/commands/services-runtime.ts +2 -2
  47. package/src/input/commands/session-workflow.ts +8 -16
  48. package/src/input/commands/session.ts +70 -20
  49. package/src/input/commands/share-runtime.ts +99 -12
  50. package/src/input/commands/tts-runtime.ts +30 -4
  51. package/src/input/commands.ts +2 -4
  52. package/src/input/delete-key-policy.ts +46 -0
  53. package/src/input/feed-context-factory.ts +2 -0
  54. package/src/input/handler-feed.ts +3 -0
  55. package/src/input/handler-interactions.ts +2 -15
  56. package/src/input/handler-modal-routes.ts +128 -12
  57. package/src/input/handler-modal-token-routes.ts +22 -5
  58. package/src/input/handler-onboarding-cloudflare.ts +1 -1
  59. package/src/input/handler-onboarding.ts +73 -69
  60. package/src/input/handler-types.ts +163 -0
  61. package/src/input/handler.ts +6 -2
  62. package/src/input/input-history.ts +76 -6
  63. package/src/input/model-picker-filter.ts +265 -0
  64. package/src/input/model-picker-items.ts +208 -0
  65. package/src/input/model-picker.ts +92 -325
  66. package/src/input/onboarding/handler-onboarding-routes.ts +7 -2
  67. package/src/input/onboarding/onboarding-verification-helpers.ts +76 -0
  68. package/src/input/onboarding/onboarding-wizard-apply.ts +14 -4
  69. package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +16 -2
  70. package/src/input/onboarding/onboarding-wizard-cloudflare.ts +8 -8
  71. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +1 -1
  72. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +2 -29
  73. package/src/input/onboarding/onboarding-wizard-rules.ts +28 -28
  74. package/src/input/onboarding/onboarding-wizard-state.ts +20 -20
  75. package/src/input/onboarding/onboarding-wizard-steps.ts +24 -25
  76. package/src/input/onboarding/onboarding-wizard-types.ts +145 -3
  77. package/src/input/onboarding/onboarding-wizard-validation.ts +77 -0
  78. package/src/input/onboarding/onboarding-wizard.ts +3 -3
  79. package/src/input/settings-modal-behavior.ts +5 -0
  80. package/src/input/settings-modal-data.ts +378 -0
  81. package/src/input/settings-modal-mutations.ts +157 -0
  82. package/src/input/settings-modal-reset.ts +154 -0
  83. package/src/input/settings-modal.ts +236 -232
  84. package/src/main.ts +93 -85
  85. package/src/panels/agent-inspector-panel.ts +120 -18
  86. package/src/panels/agent-inspector-shared.ts +29 -0
  87. package/src/panels/builtin/agent.ts +4 -1
  88. package/src/panels/builtin/development.ts +5 -1
  89. package/src/panels/builtin/knowledge.ts +14 -13
  90. package/src/panels/builtin/operations.ts +22 -1
  91. package/src/panels/builtin/shared.ts +7 -0
  92. package/src/panels/cockpit-panel.ts +123 -3
  93. package/src/panels/cockpit-read-model.ts +232 -0
  94. package/src/panels/confirm-state.ts +27 -12
  95. package/src/panels/cost-tracker-panel.ts +23 -67
  96. package/src/panels/eval-panel.ts +10 -9
  97. package/src/panels/index.ts +1 -1
  98. package/src/panels/knowledge-graph-panel.ts +84 -0
  99. package/src/panels/local-auth-panel.ts +124 -4
  100. package/src/panels/memory-panel.ts +370 -40
  101. package/src/panels/project-planning-panel.ts +42 -4
  102. package/src/panels/search-focus.ts +11 -5
  103. package/src/panels/session-maintenance.ts +66 -15
  104. package/src/panels/subscription-panel.ts +33 -25
  105. package/src/panels/types.ts +28 -1
  106. package/src/panels/wrfc-panel.ts +224 -41
  107. package/src/renderer/agent-detail-modal.ts +118 -13
  108. package/src/renderer/code-block.ts +10 -2
  109. package/src/renderer/compositor.ts +18 -4
  110. package/src/renderer/context-inspector.ts +1 -5
  111. package/src/renderer/context-status-hint.ts +54 -0
  112. package/src/renderer/diff.ts +94 -21
  113. package/src/renderer/markdown.ts +29 -13
  114. package/src/renderer/settings-modal-helpers.ts +1 -1
  115. package/src/renderer/settings-modal.ts +90 -10
  116. package/src/renderer/shell-surface.ts +10 -0
  117. package/src/renderer/syntax-highlighter.ts +10 -3
  118. package/src/renderer/term-caps.ts +318 -0
  119. package/src/renderer/theme.ts +158 -0
  120. package/src/renderer/tool-call.ts +12 -2
  121. package/src/renderer/ui-factory.ts +50 -6
  122. package/src/runtime/bootstrap-command-context.ts +1 -0
  123. package/src/runtime/bootstrap-command-parts.ts +18 -0
  124. package/src/runtime/bootstrap-core.ts +145 -13
  125. package/src/runtime/bootstrap-shell.ts +11 -0
  126. package/src/runtime/bootstrap.ts +9 -0
  127. package/src/runtime/onboarding/apply.ts +4 -6
  128. package/src/runtime/onboarding/index.ts +1 -0
  129. package/src/runtime/onboarding/markers.ts +42 -49
  130. package/src/runtime/onboarding/progress.ts +148 -0
  131. package/src/runtime/onboarding/state.ts +133 -55
  132. package/src/runtime/onboarding/types.ts +20 -0
  133. package/src/runtime/services.ts +27 -1
  134. package/src/runtime/wrfc-persistence.ts +237 -0
  135. package/src/shell/blocking-input.ts +20 -5
  136. package/src/tools/wrfc-agent-guard.ts +64 -3
  137. package/src/utils/format-elapsed.ts +30 -0
  138. package/src/utils/terminal-width.ts +45 -0
  139. package/src/version.ts +1 -1
  140. package/src/work-plans/work-plan-store.ts +4 -6
  141. package/src/panels/knowledge-panel.ts +0 -345
  142. package/src/planning/project-planning-coordinator.ts +0 -543
@@ -1,15 +1,53 @@
1
1
  import type { ModelDefinition } from '@pellux/goodvibes-sdk/platform/providers';
2
2
  import type { FavoritesStore } from '@pellux/goodvibes-sdk/platform/providers';
3
- import { EFFORT_DESCRIPTIONS } from '@pellux/goodvibes-sdk/platform/providers';
4
- import { getQualityTier, getQualityTierFromScore, compositeScore, A_TIER_THRESHOLD } from '@pellux/goodvibes-sdk/platform/providers';
5
3
  import type { BenchmarkStore } from '@pellux/goodvibes-sdk/platform/providers';
6
4
  import type { ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
7
5
  import { detectFamily, POPULAR_PROVIDERS, tierToCategoryFilter } from './model-picker-types.ts';
8
- import type { BenchmarkSort, CapabilityFilter, CategoryFilter, FilteredModelsCache, FilteredProvidersCache, GroupByMode, ModelItemsCache, ModelPickerFocusPane, ModelPickerTarget, ModelPickerTargetInfo, PickerItem, PickerMode, ProviderItemsCache } from './model-picker-types.ts';
6
+ import type {
7
+ BenchmarkSort,
8
+ CapabilityFilter,
9
+ CategoryFilter,
10
+ FilteredModelsCache,
11
+ FilteredProvidersCache,
12
+ GroupByMode,
13
+ ModelItemsCache,
14
+ ModelPickerFocusPane,
15
+ ModelPickerTarget,
16
+ ModelPickerTargetInfo,
17
+ PickerItem,
18
+ PickerMode,
19
+ ProviderItemsCache,
20
+ } from './model-picker-types.ts';
9
21
  import { filterProviders, groupProviders } from './model-picker-provider-filter.ts';
22
+ import {
23
+ buildFilteredModels,
24
+ buildFilteredProviders,
25
+ getSyntheticSubgroup,
26
+ setKey,
27
+ orderedListKey,
28
+ mapKey,
29
+ } from './model-picker-filter.ts';
30
+ import {
31
+ buildModelItems,
32
+ buildProviderItems,
33
+ buildEffortItems,
34
+ getModelGroupKey,
35
+ toModelItem,
36
+ } from './model-picker-items.ts';
10
37
 
11
38
  export { detectFamily, POPULAR_PROVIDERS, tierToCategoryFilter } from './model-picker-types.ts';
12
- export type { BenchmarkSort, CapabilityFilter, CategoryFilter, GroupByMode, ModelFamily, ModelPickerFocusPane, ModelPickerTarget, ModelPickerTargetInfo, PickerItem, PickerMode } from './model-picker-types.ts';
39
+ export type {
40
+ BenchmarkSort,
41
+ CapabilityFilter,
42
+ CategoryFilter,
43
+ GroupByMode,
44
+ ModelFamily,
45
+ ModelPickerFocusPane,
46
+ ModelPickerTarget,
47
+ ModelPickerTargetInfo,
48
+ PickerItem,
49
+ PickerMode,
50
+ } from './model-picker-types.ts';
13
51
 
14
52
  /**
15
53
  * ModelPickerModal - Multi-step interactive picker for model, provider, and effort.
@@ -55,7 +93,7 @@ export class ModelPickerModal {
55
93
  /** Current input string in contextCap mode. */
56
94
  public contextCapQuery = '';
57
95
 
58
- // ── Search / filter ──────────────────────────────────────────────────────────
96
+ // ── Search / filter ─────────────────────────────────────────────────────────────────────────────
59
97
  /** Current search query string (empty = no filter). */
60
98
  public query = '';
61
99
  /** Active pricing tier filter. */
@@ -134,7 +172,7 @@ export class ModelPickerModal {
134
172
  }
135
173
  }
136
174
 
137
- // ── Category filter cycling ───────────────────────────────────────────────
175
+ // ── Category filter cycling ───────────────────────────────────────────────────
138
176
  private static readonly CATEGORY_CYCLE: CategoryFilter[] = ['all', 'free', 'paid', 'subscription'];
139
177
  /** Cycle to next pricing tier filter. */
140
178
  cycleCategory(): void {
@@ -144,7 +182,7 @@ export class ModelPickerModal {
144
182
  this._clampSelection();
145
183
  }
146
184
 
147
- // ── Group-by cycling ──────────────────────────────────────────────────────
185
+ // ── Group-by cycling ────────────────────────────────────────────────────────────────
148
186
  private static readonly GROUP_BY_CYCLE: GroupByMode[] = ['provider', 'family', 'pricingTier', 'qualityTier'];
149
187
  /** Cycle to next group-by mode. */
150
188
  cycleGroupBy(): void {
@@ -154,7 +192,7 @@ export class ModelPickerModal {
154
192
  this._clampSelection();
155
193
  }
156
194
 
157
- // ── Benchmark sort cycling ────────────────────────────────────────────────
195
+ // ── Benchmark sort cycling ───────────────────────────────────────────────────────────────
158
196
  private static readonly BENCHMARK_SORT_CYCLE: BenchmarkSort[] = ['none', 'composite', 'swe', 'gpqa'];
159
197
  /** Cycle to next benchmark sort order. */
160
198
  cycleBenchmarkSort(): void {
@@ -281,7 +319,7 @@ export class ModelPickerModal {
281
319
  this.clearCaches();
282
320
  }
283
321
 
284
- // ── Search helpers ─────────────────────────────────────────────────────────
322
+ // ── Search helpers ─────────────────────────────────────────────────────────────────────
285
323
 
286
324
  /** Append a character to the search query and clamp selectedIndex. */
287
325
  appendChar(ch: string): void {
@@ -351,175 +389,35 @@ export class ModelPickerModal {
351
389
 
352
390
  /** Return providers matching the current query (case-insensitive substring), in grouped order. */
353
391
  getFilteredProviders(): string[] {
354
- const cached = this.filteredProvidersCache;
355
- if (
356
- cached !== null
357
- && cached.providersRef === this.providers
358
- && cached.query === this.query
359
- ) {
360
- return cached.result;
361
- }
362
-
363
- const result = filterProviders(this.providers, this.query);
364
- this.filteredProvidersCache = {
365
- providersRef: this.providers,
366
- query: this.query,
367
- result,
368
- };
392
+ const { result, cache } = buildFilteredProviders(
393
+ this.providers,
394
+ this.query,
395
+ this.filteredProvidersCache,
396
+ );
397
+ this.filteredProvidersCache = cache;
369
398
  return result;
370
399
  }
371
400
 
372
401
  /** Return models matching all current filters, sorted per benchmarkSort. */
373
402
  getFilteredModels(): ModelDefinition[] {
374
- const configuredProvidersKey = setKey(this.configuredProviders);
375
- const pinnedIdsKey = setKey(this.pinnedIds);
376
- const recentIdsKey = orderedListKey(this.recentIds);
377
- const cached = this.filteredModelsCache;
378
- if (
379
- cached !== null
380
- && cached.modelsRef === this.models
381
- && cached.configuredProvidersKey === configuredProvidersKey
382
- && cached.pinnedIdsKey === pinnedIdsKey
383
- && cached.recentIdsKey === recentIdsKey
384
- && cached.query === this.query
385
- && cached.categoryFilter === this.categoryFilter
386
- && cached.capabilityFilter === this.capabilityFilter
387
- && cached.availableOnly === this.availableOnly
388
- && cached.benchmarkSort === this.benchmarkSort
389
- && cached.groupBy === this.groupBy
390
- ) {
391
- return cached.result;
392
- }
393
-
394
- let result = this.models;
395
-
396
- // Available-only filter
397
- if (this.availableOnly && this.configuredProviders.size > 0) {
398
- result = result.filter(m => this.configuredProviders.has(m.provider));
399
- }
400
-
401
- // Pricing tier / category filter
402
- if (this.categoryFilter === 'free') {
403
- result = result.filter(m => m.tier === 'free');
404
- } else if (this.categoryFilter === 'paid') {
405
- result = result.filter(m => m.tier === 'standard' || m.tier === 'premium' || m.tier == null);
406
- } else if (this.categoryFilter === 'subscription') {
407
- result = result.filter(m => tierToCategoryFilter(m.tier) === 'subscription');
408
- }
409
-
410
- // Capability filter
411
- if (this.capabilityFilter === 'reasoning') {
412
- result = result.filter(m => m.capabilities?.reasoning === true);
413
- } else if (this.capabilityFilter === 'toolUse') {
414
- result = result.filter(m => m.capabilities?.toolCalling === true);
415
- } else if (this.capabilityFilter === 'multimodal') {
416
- result = result.filter(m => m.capabilities?.multimodal === true);
417
- }
418
-
419
- // Query filter — fuzzy: every space-separated word must appear somewhere
420
- if (this.query.trim().length > 0) {
421
- const words = this.query.toLowerCase().split(/\s+/).filter(Boolean);
422
- result = result.filter(m => {
423
- const haystack = `${m.id} ${m.displayName} ${m.provider}`.toLowerCase();
424
- return words.every(w => haystack.includes(w));
425
- });
426
- }
427
-
428
- // Benchmark sort
429
- if (this.benchmarkSort !== 'none') {
430
- result = [...result].sort((a, b) => {
431
- let scoreA: number | null = null;
432
- let scoreB: number | null = null;
433
-
434
- // For synthetic models, use pre-computed bestCompositeScore from backend lookup
435
- // (synthetic canonical slugs don't exist in ZeroEval benchmark data)
436
- if (this.benchmarkSort === 'composite') {
437
- if (a.provider === 'synthetic') {
438
- scoreA = this.providerRegistry.getSyntheticModelInfoFromCatalog(a.id)?.bestCompositeScore ?? null;
439
- } else {
440
- const bA = this.benchmarkStore.getBenchmarks(a.id) ?? this.benchmarkStore.getBenchmarks(a.displayName);
441
- scoreA = bA ? compositeScore(bA.benchmarks) : null;
442
- }
443
- if (b.provider === 'synthetic') {
444
- scoreB = this.providerRegistry.getSyntheticModelInfoFromCatalog(b.id)?.bestCompositeScore ?? null;
445
- } else {
446
- const bB = this.benchmarkStore.getBenchmarks(b.id) ?? this.benchmarkStore.getBenchmarks(b.displayName);
447
- scoreB = bB ? compositeScore(bB.benchmarks) : null;
448
- }
449
- } else {
450
- // swe/gpqa sort — individual benchmark scores not available for synthetic models — only composite is cached
451
- const bA = a.provider === 'synthetic' ? null : (this.benchmarkStore.getBenchmarks(a.id) ?? this.benchmarkStore.getBenchmarks(a.displayName));
452
- const bB = b.provider === 'synthetic' ? null : (this.benchmarkStore.getBenchmarks(b.id) ?? this.benchmarkStore.getBenchmarks(b.displayName));
453
- if (this.benchmarkSort === 'swe') {
454
- scoreA = bA?.benchmarks.swe ?? null;
455
- scoreB = bB?.benchmarks.swe ?? null;
456
- } else if (this.benchmarkSort === 'gpqa') {
457
- scoreA = bA?.benchmarks.gpqa ?? null;
458
- scoreB = bB?.benchmarks.gpqa ?? null;
459
- }
460
- }
461
- // Models with no score sink to the end
462
- if (scoreA == null && scoreB == null) return 0;
463
- if (scoreA == null) return 1;
464
- if (scoreB == null) return -1;
465
- return scoreB - scoreA; // descending
466
- });
467
- }
468
-
469
- // Synthetic sub-grouping: when groupBy is 'provider', order synthetic models so that
470
- // "Top Models" (score ≥ 0.65) appear before "All Synthetic", each sub-group internally
471
- // sorted: top by composite score desc, all alphabetically by id.
472
- if (this.groupBy === 'provider' && this.benchmarkSort === 'none') {
473
- const nonSynthetic = result.filter(m => m.provider !== 'synthetic');
474
- const synthetic = result.filter(m => m.provider === 'synthetic');
475
-
476
- if (synthetic.length > 0) {
477
- const topModels = synthetic.filter(m => this._getSyntheticSubgroup(m) === 'top');
478
- const allModels = synthetic.filter(m => this._getSyntheticSubgroup(m) === 'all');
479
-
480
- // Sort top models by composite score descending
481
- topModels.sort((a, b) => {
482
- const sA = this.providerRegistry.getSyntheticModelInfoFromCatalog(a.id)?.bestCompositeScore ?? null;
483
- const sB = this.providerRegistry.getSyntheticModelInfoFromCatalog(b.id)?.bestCompositeScore ?? null;
484
- if (sA == null && sB == null) return 0;
485
- if (sA == null) return 1;
486
- if (sB == null) return -1;
487
- return sB - sA;
488
- });
489
-
490
- // Sort remaining alphabetically by id
491
- allModels.sort((a, b) => a.id.localeCompare(b.id));
492
-
493
- result = [...nonSynthetic, ...topModels, ...allModels];
494
- }
495
- }
496
-
497
- // Boost recent (non-pinned) models to the front of the list,
498
- // preserving relative order within the recent group and within the rest.
499
- if (this.recentIds.length > 0) {
500
- const recentSet = new Set(this.recentIds);
501
- const recent = this.recentIds
502
- .filter(id => result.some(m => m.id === id && !this.pinnedIds.has(id)))
503
- .map(id => result.find(m => m.id === id)!)
504
- .filter(Boolean);
505
- const rest = result.filter(m => !recentSet.has(m.id) || this.pinnedIds.has(m.id));
506
- result = [...recent, ...rest];
507
- }
508
-
509
- this.filteredModelsCache = {
510
- modelsRef: this.models,
511
- configuredProvidersKey,
512
- pinnedIdsKey,
513
- recentIdsKey,
514
- query: this.query,
515
- categoryFilter: this.categoryFilter,
516
- capabilityFilter: this.capabilityFilter,
517
- availableOnly: this.availableOnly,
518
- benchmarkSort: this.benchmarkSort,
519
- groupBy: this.groupBy,
520
- result,
521
- };
522
- this.modelItemsCache = null;
403
+ const { result, cache } = buildFilteredModels(
404
+ {
405
+ models: this.models,
406
+ configuredProviders: this.configuredProviders,
407
+ pinnedIds: this.pinnedIds,
408
+ recentIds: this.recentIds,
409
+ query: this.query,
410
+ categoryFilter: this.categoryFilter,
411
+ capabilityFilter: this.capabilityFilter,
412
+ availableOnly: this.availableOnly,
413
+ benchmarkSort: this.benchmarkSort,
414
+ groupBy: this.groupBy,
415
+ benchmarkStore: this.benchmarkStore,
416
+ providerRegistry: this.providerRegistry,
417
+ },
418
+ this.filteredModelsCache,
419
+ );
420
+ this.filteredModelsCache = cache;
523
421
  return result;
524
422
  }
525
423
 
@@ -541,151 +439,37 @@ export class ModelPickerModal {
541
439
  * - 'All Synthetic' — remaining synthetic models
542
440
  */
543
441
  getModelGroupKey(model: ModelDefinition): string {
544
- switch (this.groupBy) {
545
- case 'provider':
546
- if (model.provider === 'synthetic') {
547
- return this._getSyntheticSubgroup(model) === 'top' ? 'Top Models' : 'All Synthetic';
548
- }
549
- return model.provider;
550
- case 'family': return detectFamily(model);
551
- case 'pricingTier': return tierToCategoryFilter(model.tier);
552
- case 'qualityTier': {
553
- if (model.provider === 'synthetic') {
554
- const info = this.providerRegistry.getSyntheticModelInfoFromCatalog(model.id);
555
- return info?.bestCompositeScore != null ? getQualityTierFromScore(info.bestCompositeScore) : 'C';
556
- }
557
- const b = this.benchmarkStore.getBenchmarks(model.id) ?? this.benchmarkStore.getBenchmarks(model.displayName);
558
- return b ? getQualityTier(b.benchmarks) : 'C';
559
- }
560
- }
561
- }
562
-
563
- /**
564
- * Classify a synthetic model as 'top' or 'all' based on benchmark composite score.
565
- * 'top': has benchmark data and score ≥ 0.65 (A-tier or S-tier)
566
- * 'all': no benchmark data or score < 0.65
567
- */
568
- private _getSyntheticSubgroup(model: ModelDefinition): 'top' | 'all' {
569
- const info = this.providerRegistry.getSyntheticModelInfoFromCatalog(model.id);
570
- const score = info?.bestCompositeScore ?? null;
571
- return score !== null && score >= A_TIER_THRESHOLD ? 'top' : 'all';
442
+ return getModelGroupKey(model, this.groupBy, this.providerRegistry, this.benchmarkStore);
572
443
  }
573
444
 
574
445
  /** Get the items for the current mode as a unified list. */
575
446
  getItems(): PickerItem[] {
576
447
  if (this.mode === 'model') {
577
448
  const filtered = this.getFilteredModels();
578
- const pinnedIdsKey = setKey(this.pinnedIds);
579
- const cached = this.modelItemsCache;
580
- if (
581
- cached !== null
582
- && cached.filteredModelsRef === filtered
583
- && cached.pinnedIdsKey === pinnedIdsKey
584
- && cached.groupBy === this.groupBy
585
- ) {
586
- return cached.result;
587
- }
588
-
589
- // Separate pinned and unpinned
590
- const pinned = filtered.filter(m => this.pinnedIds.has(m.id));
591
- const unpinned = filtered.filter(m => !this.pinnedIds.has(m.id));
592
-
593
- const items: PickerItem[] = [];
594
-
595
- // Pinned section header (only if pinned models are in the filtered list)
596
- if (pinned.length > 0) {
597
- items.push({ id: '__header__pinned', label: 'Favorites', isGroupHeader: true });
598
- for (const m of pinned) {
599
- items.push(this._modelToItem(m, true));
600
- }
601
- }
602
-
603
- // Grouped unpinned models
604
- let lastGroupKey = '';
605
- for (const m of unpinned) {
606
- const groupKey = this.getModelGroupKey(m);
607
- if (groupKey !== lastGroupKey) {
608
- items.push({ id: `__header__${groupKey}`, label: groupKey, isGroupHeader: true });
609
- lastGroupKey = groupKey;
610
- }
611
- items.push(this._modelToItem(m, false));
612
- }
613
-
614
- this.modelItemsCache = {
615
- filteredModelsRef: filtered,
616
- pinnedIdsKey,
617
- groupBy: this.groupBy,
618
- result: items,
619
- };
620
- return items;
449
+ const { result, cache } = buildModelItems(
450
+ filtered,
451
+ this.pinnedIds,
452
+ this.groupBy,
453
+ this.providerRegistry,
454
+ this.benchmarkStore,
455
+ this.modelItemsCache,
456
+ );
457
+ this.modelItemsCache = cache;
458
+ return result;
621
459
  }
622
460
  if (this.mode === 'provider') {
623
461
  const filteredProviders = this.getFilteredProviders();
624
- const configuredProvidersKey = setKey(this.configuredProviders);
625
- const configuredViaKey = mapKey(this.configuredViaMap);
626
- const cached = this.providerItemsCache;
627
- if (
628
- cached !== null
629
- && cached.filteredProvidersRef === filteredProviders
630
- && cached.configuredProvidersKey === configuredProvidersKey
631
- && cached.configuredViaKey === configuredViaKey
632
- ) {
633
- return cached.result;
634
- }
635
-
636
- const providerItems: PickerItem[] = [];
637
- let currentGroup: 'Popular' | 'All Providers' | null = null;
638
- for (const p of filteredProviders) {
639
- const group: 'Popular' | 'All Providers' = POPULAR_PROVIDERS.has(p.toLowerCase()) ? 'Popular' : 'All Providers';
640
- if (group !== currentGroup) {
641
- providerItems.push({ id: `__header__${group}`, label: group, isGroupHeader: true });
642
- currentGroup = group;
643
- }
644
- providerItems.push({ id: p, label: p, isConfigured: this.configuredProviders.has(p), configuredVia: this.configuredViaMap.get(p) });
645
- }
646
-
647
- this.providerItemsCache = {
648
- filteredProvidersRef: filteredProviders,
649
- configuredProvidersKey,
650
- configuredViaKey,
651
- result: providerItems,
652
- };
653
- return providerItems;
462
+ const { result, cache } = buildProviderItems(
463
+ filteredProviders,
464
+ this.configuredProviders,
465
+ this.configuredViaMap,
466
+ this.providerItemsCache,
467
+ );
468
+ this.providerItemsCache = cache;
469
+ return result;
654
470
  }
655
471
  // effort mode
656
- return this.effortLevels.map(e => ({ id: e, label: e, detail: EFFORT_DESCRIPTIONS[e] ?? '' }));
657
- }
658
-
659
- /** Build a PickerItem for a model, including quality tier and pin status. */
660
- private _modelToItem(model: ModelDefinition, isPinned: boolean): PickerItem {
661
- // For synthetic models, derive quality tier from cached bestCompositeScore
662
- // (synthetic canonical slugs don't exist in ZeroEval benchmark data)
663
- let qualityTier: string | undefined;
664
- let detail: string;
665
- if (model.provider === 'synthetic') {
666
- const synthInfo = this.providerRegistry.getSyntheticModelInfoFromCatalog(model.id);
667
- if (synthInfo?.bestCompositeScore != null) {
668
- qualityTier = getQualityTierFromScore(synthInfo.bestCompositeScore);
669
- }
670
- // Reuse synthInfo for provider count detail
671
- detail = synthInfo !== null
672
- ? `${model.provider} [${synthInfo.keyedBackendCount} provider${synthInfo.keyedBackendCount !== 1 ? 's' : ''}]`
673
- : model.provider;
674
- } else {
675
- detail = model.provider;
676
- const b = this.benchmarkStore.getBenchmarks(model.id) ?? this.benchmarkStore.getBenchmarks(model.displayName);
677
- qualityTier = b ? getQualityTier(b.benchmarks) : undefined;
678
- }
679
- const isFree = tierToCategoryFilter(model.tier) === 'free';
680
-
681
- return {
682
- id: model.id,
683
- label: model.displayName,
684
- detail,
685
- qualityTier,
686
- isPinned,
687
- isFree,
688
- };
472
+ return buildEffortItems(this.effortLevels);
689
473
  }
690
474
 
691
475
  /** Get count of selectable (non-header) items in current mode. */
@@ -730,7 +514,7 @@ export class ModelPickerModal {
730
514
  return filtered[this.selectedIndex] ?? null;
731
515
  }
732
516
 
733
- // ── Private helpers ────────────────────────────────────────────────────────
517
+ // ── Private helpers ─────────────────────────────────────────────────────────────────────
734
518
 
735
519
  private _clampSelection(): void {
736
520
  const count = this.getItemCount();
@@ -778,20 +562,3 @@ export class ModelPickerModal {
778
562
  this.clearFilteredCaches();
779
563
  }
780
564
  }
781
-
782
- function setKey(values: ReadonlySet<string>): string {
783
- if (values.size === 0) return '';
784
- return [...values].sort().join('\u001f');
785
- }
786
-
787
- function orderedListKey(values: readonly string[]): string {
788
- return values.length === 0 ? '' : values.join('\u001f');
789
- }
790
-
791
- function mapKey(values: ReadonlyMap<string, string | undefined>): string {
792
- if (values.size === 0) return '';
793
- return [...values.entries()]
794
- .sort(([left], [right]) => left.localeCompare(right))
795
- .map(([key, value]) => `${key}\u001e${value ?? ''}`)
796
- .join('\u001f');
797
- }
@@ -15,6 +15,8 @@ type OnboardingRouteState = {
15
15
  source?: 'settings' | 'onboarding',
16
16
  ) => boolean;
17
17
  onAction?: (action: OnboardingWizardAction) => void;
18
+ /** Called after any step navigation so the handler can persist progress. */
19
+ onStepChange?: () => void;
18
20
  };
19
21
 
20
22
  function activateSelection(state: OnboardingRouteState): void {
@@ -74,11 +76,13 @@ export function handleOnboardingWizardToken(state: OnboardingRouteState, token:
74
76
  }
75
77
  } else if (token.logicalName === 'left') {
76
78
  state.onboardingWizard.prevStep();
79
+ state.onStepChange?.();
77
80
  } else if (token.logicalName === 'right') {
78
81
  state.onboardingWizard.nextStep();
82
+ state.onStepChange?.();
79
83
  } else if (token.logicalName === 'tab') {
80
- if (token.shift) state.onboardingWizard.prevStep();
81
- else state.onboardingWizard.nextStep();
84
+ if (token.shift) { state.onboardingWizard.prevStep(); state.onStepChange?.(); }
85
+ else { state.onboardingWizard.nextStep(); state.onStepChange?.(); }
82
86
  } else if (token.logicalName === 'up') {
83
87
  state.onboardingWizard.moveSelection(-1, visibleFields);
84
88
  } else if (token.logicalName === 'down') {
@@ -116,6 +120,7 @@ export function handleOnboardingWizardToken(state: OnboardingRouteState, token:
116
120
  const stepIndex = Number(token.value) - 1;
117
121
  if (stepIndex < state.onboardingWizard.steps.length) {
118
122
  state.onboardingWizard.setStep(stepIndex);
123
+ state.onStepChange?.();
119
124
  }
120
125
  }
121
126
  }
@@ -0,0 +1,76 @@
1
+ import type { OnboardingVerificationItem } from '../../runtime/onboarding/index.ts';
2
+
3
+ /**
4
+ * Extract an OAuth authorization code from a callback URL or raw code string.
5
+ * Returns the `code` query parameter if input is a URL, or the trimmed string
6
+ * itself if it looks like a raw code. Returns null for empty input.
7
+ */
8
+ export function extractAuthorizationCode(input: string): string | null {
9
+ const trimmed = input.trim();
10
+ if (!trimmed) return null;
11
+
12
+ try {
13
+ const url = new URL(trimmed);
14
+ return url.searchParams.get('code');
15
+ } catch {
16
+ return trimmed;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Return true if the value is a recognized loopback host (localhost, 127.x.x.x,
22
+ * ::1, or [::1]). Tolerates null / undefined / empty values by returning false.
23
+ */
24
+ export function isLoopbackHostValue(value: string | null | undefined): boolean {
25
+ const normalized = (value ?? '').trim().toLowerCase();
26
+ if (normalized.length === 0) return false;
27
+ return normalized === 'localhost'
28
+ || normalized === '::1'
29
+ || normalized === '[::1]'
30
+ || normalized === '0:0:0:0:0:0:0:1'
31
+ || /^127(?:\.\d{1,3}){3}$/.test(normalized);
32
+ }
33
+
34
+ /** Priority rank used when de-duplicating verification items by id. */
35
+ function onboardingVerificationStatusRank(item: OnboardingVerificationItem): number {
36
+ if (item.status === 'fail') return 3;
37
+ if (item.status === 'warn') return 2;
38
+ return 1;
39
+ }
40
+
41
+ /**
42
+ * Collapse a list of OnboardingVerificationItem entries so that each unique id
43
+ * appears at most once, keeping the highest-severity status when duplicates exist.
44
+ */
45
+ export function dedupeOnboardingVerificationItems(
46
+ items: readonly OnboardingVerificationItem[],
47
+ ): OnboardingVerificationItem[] {
48
+ const order: string[] = [];
49
+ const byId = new Map<string, OnboardingVerificationItem>();
50
+ for (const item of items) {
51
+ const existing = byId.get(item.id);
52
+ if (!existing) {
53
+ order.push(item.id);
54
+ byId.set(item.id, item);
55
+ continue;
56
+ }
57
+ if (onboardingVerificationStatusRank(item) > onboardingVerificationStatusRank(existing)) {
58
+ byId.set(item.id, item);
59
+ }
60
+ }
61
+ return order.map((id) => byId.get(id)).filter((item): item is OnboardingVerificationItem => Boolean(item));
62
+ }
63
+
64
+ /**
65
+ * Format a human-readable summary of an onboarding apply operation given the
66
+ * list of verification items returned after the apply completed.
67
+ */
68
+ export function formatOnboardingApplyCompletionMessage(items: readonly OnboardingVerificationItem[]): string {
69
+ const warnings = items.filter((item) => item.status === 'warn');
70
+ if (warnings.length === 0) return `Onboarding applied and verified ${items.length} item(s).`;
71
+ const passed = items.filter((item) => item.status === 'pass').length;
72
+ return [
73
+ `Onboarding settings applied. ${passed} verification item(s) passed; ${warnings.length} warning(s) need attention.`,
74
+ ...warnings.map((warning) => ` warning ${warning.id}: ${warning.message}`),
75
+ ].join('\n');
76
+ }
@@ -16,9 +16,9 @@ import {
16
16
  isExternalSurfaceSelectedByDefault,
17
17
  } from './onboarding-wizard-external-surfaces.ts';
18
18
  import { buildGoodVibesSecretKey, buildGoodVibesSecretRef, isLoopbackAddress, isSecretReferenceValue } from './onboarding-wizard-helpers.ts';
19
- import type { OnboardingWizardController } from './onboarding-wizard.ts';
19
+ import type { OnboardingWizardControllerLike } from './onboarding-wizard-types.ts';
20
20
 
21
- export function buildOnboardingApplyRequest(controller: OnboardingWizardController): OnboardingApplyRequest {
21
+ export function buildOnboardingApplyRequest(controller: OnboardingWizardControllerLike): OnboardingApplyRequest {
22
22
  const operations: OnboardingApplyOperation[] = [];
23
23
  const hasServers = controller.hasServerCapabilitiesSelected();
24
24
  const browserAccess = controller.shouldEnableBrowserSurface();
@@ -178,7 +178,7 @@ export function buildOnboardingApplyRequest(controller: OnboardingWizardControll
178
178
  }
179
179
 
180
180
  function addCloudflareOperations(
181
- controller: OnboardingWizardController,
181
+ controller: OnboardingWizardControllerLike,
182
182
  operations: OnboardingApplyOperation[],
183
183
  setSecret: (key: string, value: string) => void,
184
184
  ): void {
@@ -248,10 +248,20 @@ function addCloudflareOperations(
248
248
  setConfig('cloudflare.maxQueueOpsPerDay', controller.getNumberFieldValue('cloudflare.max-queue-ops-per-day', config?.maxQueueOpsPerDay ?? 10000, 1));
249
249
  setConfig('batch.mode', batchMode);
250
250
  setConfig('batch.queueBackend', batchMode !== 'off' && components.queues ? 'cloudflare' : 'local');
251
+ // Zero Trust Tunnel auto-enables trustProxy on both services so the
252
+ // login-rate-limiter keys on the real CF-Connecting-IP rather than the tunnel
253
+ // egress address. RESIDUAL RISK: until the SDK validates CF-Connecting-IP
254
+ // against Cloudflare's published IP ranges (SDK handoff Item 5), a client
255
+ // that reaches the listener directly can spoof the header to bypass the
256
+ // per-IP limiter. The wizard surfaces this in the cloudflare step notice.
257
+ if (components.zeroTrustTunnel) {
258
+ setConfig('controlPlane.trustProxy', true);
259
+ setConfig('httpListener.trustProxy', true);
260
+ }
251
261
  }
252
262
 
253
263
  export function addNetworkOperations(
254
- controller: OnboardingWizardController,
264
+ controller: OnboardingWizardControllerLike,
255
265
  operations: OnboardingApplyOperation[],
256
266
  customNetwork: boolean,
257
267
  enabled: {