@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
@@ -0,0 +1,265 @@
1
+ /**
2
+ * model-picker-filter — pure filtering, sorting, and cache-key helpers for ModelPickerModal.
3
+ *
4
+ * All functions are stateless: they receive inputs and return results without
5
+ * side-effects. The class in model-picker.ts uses these as delegates.
6
+ */
7
+
8
+ import type { ModelDefinition } from '@pellux/goodvibes-sdk/platform/providers';
9
+ import { compositeScore, A_TIER_THRESHOLD } from '@pellux/goodvibes-sdk/platform/providers';
10
+ import type { BenchmarkStore } from '@pellux/goodvibes-sdk/platform/providers';
11
+ import type { ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
12
+ import { tierToCategoryFilter } from './model-picker-types.ts';
13
+ import type {
14
+ BenchmarkSort,
15
+ CapabilityFilter,
16
+ CategoryFilter,
17
+ FilteredModelsCache,
18
+ FilteredProvidersCache,
19
+ GroupByMode,
20
+ } from './model-picker-types.ts';
21
+ import { filterProviders } from './model-picker-provider-filter.ts';
22
+
23
+ export { filterProviders };
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Cache key helpers
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export function setKey(values: ReadonlySet<string>): string {
30
+ if (values.size === 0) return '';
31
+ return [...values].sort().join('');
32
+ }
33
+
34
+ export function orderedListKey(values: readonly string[]): string {
35
+ return values.length === 0 ? '' : values.join('');
36
+ }
37
+
38
+ export function mapKey(values: ReadonlyMap<string, string | undefined>): string {
39
+ if (values.size === 0) return '';
40
+ return [...values.entries()]
41
+ .sort(([left], [right]) => left.localeCompare(right))
42
+ .map(([key, value]) => `${key}${value ?? ''}`)
43
+ .join('');
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Synthetic sub-group classification
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export function getSyntheticSubgroup(
51
+ model: ModelDefinition,
52
+ providerRegistry: Pick<ProviderRegistry, 'getSyntheticModelInfoFromCatalog'>,
53
+ ): 'top' | 'all' {
54
+ const info = providerRegistry.getSyntheticModelInfoFromCatalog(model.id);
55
+ const score = info?.bestCompositeScore ?? null;
56
+ return score !== null && score >= A_TIER_THRESHOLD ? 'top' : 'all';
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // buildFilteredModels
61
+ // ---------------------------------------------------------------------------
62
+
63
+ export interface FilterModelParams {
64
+ readonly models: ModelDefinition[];
65
+ readonly configuredProviders: ReadonlySet<string>;
66
+ readonly pinnedIds: ReadonlySet<string>;
67
+ readonly recentIds: readonly string[];
68
+ readonly query: string;
69
+ readonly categoryFilter: CategoryFilter;
70
+ readonly capabilityFilter: CapabilityFilter;
71
+ readonly availableOnly: boolean;
72
+ readonly benchmarkSort: BenchmarkSort;
73
+ readonly groupBy: GroupByMode;
74
+ readonly benchmarkStore: Pick<BenchmarkStore, 'getBenchmarks'>;
75
+ readonly providerRegistry: Pick<ProviderRegistry, 'getSyntheticModelInfoFromCatalog'>;
76
+ }
77
+
78
+ export function buildFilteredModels(
79
+ params: FilterModelParams,
80
+ cache: FilteredModelsCache | null,
81
+ ): { result: ModelDefinition[]; cache: FilteredModelsCache } {
82
+ const {
83
+ models,
84
+ configuredProviders,
85
+ pinnedIds,
86
+ recentIds,
87
+ query,
88
+ categoryFilter,
89
+ capabilityFilter,
90
+ availableOnly,
91
+ benchmarkSort,
92
+ groupBy,
93
+ benchmarkStore,
94
+ providerRegistry,
95
+ } = params;
96
+
97
+ const configuredProvidersKey = setKey(configuredProviders);
98
+ const pinnedIdsKey = setKey(pinnedIds);
99
+ const recentIdsKey = orderedListKey(recentIds);
100
+
101
+ if (
102
+ cache !== null
103
+ && cache.modelsRef === models
104
+ && cache.configuredProvidersKey === configuredProvidersKey
105
+ && cache.pinnedIdsKey === pinnedIdsKey
106
+ && cache.recentIdsKey === recentIdsKey
107
+ && cache.query === query
108
+ && cache.categoryFilter === categoryFilter
109
+ && cache.capabilityFilter === capabilityFilter
110
+ && cache.availableOnly === availableOnly
111
+ && cache.benchmarkSort === benchmarkSort
112
+ && cache.groupBy === groupBy
113
+ ) {
114
+ return { result: cache.result, cache };
115
+ }
116
+
117
+ let result = models;
118
+
119
+ // Available-only filter
120
+ if (availableOnly && configuredProviders.size > 0) {
121
+ result = result.filter(m => configuredProviders.has(m.provider));
122
+ }
123
+
124
+ // Pricing tier / category filter
125
+ if (categoryFilter === 'free') {
126
+ result = result.filter(m => m.tier === 'free');
127
+ } else if (categoryFilter === 'paid') {
128
+ result = result.filter(m => m.tier === 'standard' || m.tier === 'premium' || m.tier == null);
129
+ } else if (categoryFilter === 'subscription') {
130
+ result = result.filter(m => tierToCategoryFilter(m.tier) === 'subscription');
131
+ }
132
+
133
+ // Capability filter
134
+ if (capabilityFilter === 'reasoning') {
135
+ result = result.filter(m => m.capabilities?.reasoning === true);
136
+ } else if (capabilityFilter === 'toolUse') {
137
+ result = result.filter(m => m.capabilities?.toolCalling === true);
138
+ } else if (capabilityFilter === 'multimodal') {
139
+ result = result.filter(m => m.capabilities?.multimodal === true);
140
+ }
141
+
142
+ // Query filter — fuzzy: every space-separated word must appear somewhere
143
+ if (query.trim().length > 0) {
144
+ const words = query.toLowerCase().split(/\s+/).filter(Boolean);
145
+ result = result.filter(m => {
146
+ const haystack = `${m.id} ${m.displayName} ${m.provider}`.toLowerCase();
147
+ return words.every(w => haystack.includes(w));
148
+ });
149
+ }
150
+
151
+ // Benchmark sort
152
+ if (benchmarkSort !== 'none') {
153
+ result = [...result].sort((a, b) => {
154
+ let scoreA: number | null = null;
155
+ let scoreB: number | null = null;
156
+
157
+ if (benchmarkSort === 'composite') {
158
+ if (a.provider === 'synthetic') {
159
+ scoreA = providerRegistry.getSyntheticModelInfoFromCatalog(a.id)?.bestCompositeScore ?? null;
160
+ } else {
161
+ const bA = benchmarkStore.getBenchmarks(a.id) ?? benchmarkStore.getBenchmarks(a.displayName);
162
+ scoreA = bA ? compositeScore(bA.benchmarks) : null;
163
+ }
164
+ if (b.provider === 'synthetic') {
165
+ scoreB = providerRegistry.getSyntheticModelInfoFromCatalog(b.id)?.bestCompositeScore ?? null;
166
+ } else {
167
+ const bB = benchmarkStore.getBenchmarks(b.id) ?? benchmarkStore.getBenchmarks(b.displayName);
168
+ scoreB = bB ? compositeScore(bB.benchmarks) : null;
169
+ }
170
+ } else {
171
+ // swe/gpqa sort — individual scores not available for synthetic models
172
+ const bA = a.provider === 'synthetic' ? null : (benchmarkStore.getBenchmarks(a.id) ?? benchmarkStore.getBenchmarks(a.displayName));
173
+ const bB = b.provider === 'synthetic' ? null : (benchmarkStore.getBenchmarks(b.id) ?? benchmarkStore.getBenchmarks(b.displayName));
174
+ if (benchmarkSort === 'swe') {
175
+ scoreA = bA?.benchmarks.swe ?? null;
176
+ scoreB = bB?.benchmarks.swe ?? null;
177
+ } else if (benchmarkSort === 'gpqa') {
178
+ scoreA = bA?.benchmarks.gpqa ?? null;
179
+ scoreB = bB?.benchmarks.gpqa ?? null;
180
+ }
181
+ }
182
+ // Models with no score sink to the end
183
+ if (scoreA == null && scoreB == null) return 0;
184
+ if (scoreA == null) return 1;
185
+ if (scoreB == null) return -1;
186
+ return scoreB - scoreA; // descending
187
+ });
188
+ }
189
+
190
+ // Synthetic sub-grouping: when groupBy is 'provider', order synthetic models so that
191
+ // "Top Models" (score >= 0.65) appear before "All Synthetic"
192
+ if (groupBy === 'provider' && benchmarkSort === 'none') {
193
+ const nonSynthetic = result.filter(m => m.provider !== 'synthetic');
194
+ const synthetic = result.filter(m => m.provider === 'synthetic');
195
+
196
+ if (synthetic.length > 0) {
197
+ const topModels = synthetic.filter(m => getSyntheticSubgroup(m, providerRegistry) === 'top');
198
+ const allModels = synthetic.filter(m => getSyntheticSubgroup(m, providerRegistry) === 'all');
199
+
200
+ // Sort top models by composite score descending
201
+ topModels.sort((a, b) => {
202
+ const sA = providerRegistry.getSyntheticModelInfoFromCatalog(a.id)?.bestCompositeScore ?? null;
203
+ const sB = providerRegistry.getSyntheticModelInfoFromCatalog(b.id)?.bestCompositeScore ?? null;
204
+ if (sA == null && sB == null) return 0;
205
+ if (sA == null) return 1;
206
+ if (sB == null) return -1;
207
+ return sB - sA;
208
+ });
209
+
210
+ // Sort remaining alphabetically by id
211
+ allModels.sort((a, b) => a.id.localeCompare(b.id));
212
+
213
+ result = [...nonSynthetic, ...topModels, ...allModels];
214
+ }
215
+ }
216
+
217
+ // Boost recent (non-pinned) models to the front
218
+ if (recentIds.length > 0) {
219
+ const recentSet = new Set(recentIds);
220
+ const recent = recentIds
221
+ .filter(id => result.some(m => m.id === id && !pinnedIds.has(id)))
222
+ .map(id => result.find(m => m.id === id)!)
223
+ .filter(Boolean);
224
+ const rest = result.filter(m => !recentSet.has(m.id) || pinnedIds.has(m.id));
225
+ result = [...recent, ...rest];
226
+ }
227
+
228
+ const newCache: FilteredModelsCache = {
229
+ modelsRef: models,
230
+ configuredProvidersKey,
231
+ pinnedIdsKey,
232
+ recentIdsKey,
233
+ query,
234
+ categoryFilter,
235
+ capabilityFilter,
236
+ availableOnly,
237
+ benchmarkSort,
238
+ groupBy,
239
+ result,
240
+ };
241
+
242
+ return { result, cache: newCache };
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // buildFilteredProviders (cache-aware)
247
+ // ---------------------------------------------------------------------------
248
+
249
+ export function buildFilteredProviders(
250
+ providers: string[],
251
+ query: string,
252
+ cache: FilteredProvidersCache | null,
253
+ ): { result: string[]; cache: FilteredProvidersCache } {
254
+ if (
255
+ cache !== null
256
+ && cache.providersRef === providers
257
+ && cache.query === query
258
+ ) {
259
+ return { result: cache.result, cache };
260
+ }
261
+
262
+ const result = filterProviders(providers, query);
263
+ const newCache: FilteredProvidersCache = { providersRef: providers, query, result };
264
+ return { result, cache: newCache };
265
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * model-picker-items — pure item-building helpers for ModelPickerModal.
3
+ *
4
+ * Converts filtered ModelDefinition/provider arrays into PickerItem lists
5
+ * with group headers, quality-tier badges, pin markers, and configured-via flags.
6
+ * All functions are stateless.
7
+ */
8
+
9
+ import type { ModelDefinition } from '@pellux/goodvibes-sdk/platform/providers';
10
+ import { EFFORT_DESCRIPTIONS, getQualityTier, getQualityTierFromScore } from '@pellux/goodvibes-sdk/platform/providers';
11
+ import type { BenchmarkStore } from '@pellux/goodvibes-sdk/platform/providers';
12
+ import type { ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
13
+ import {
14
+ POPULAR_PROVIDERS,
15
+ detectFamily,
16
+ tierToCategoryFilter,
17
+ } from './model-picker-types.ts';
18
+ import type {
19
+ GroupByMode,
20
+ ModelItemsCache,
21
+ PickerItem,
22
+ ProviderItemsCache,
23
+ } from './model-picker-types.ts';
24
+ import { getSyntheticSubgroup, setKey, mapKey } from './model-picker-filter.ts';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // getModelGroupKey
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export function getModelGroupKey(
31
+ model: ModelDefinition,
32
+ groupBy: GroupByMode,
33
+ providerRegistry: Pick<ProviderRegistry, 'getSyntheticModelInfoFromCatalog'>,
34
+ benchmarkStore: Pick<BenchmarkStore, 'getBenchmarks'>,
35
+ ): string {
36
+ switch (groupBy) {
37
+ case 'provider':
38
+ if (model.provider === 'synthetic') {
39
+ return getSyntheticSubgroup(model, providerRegistry) === 'top' ? 'Top Models' : 'All Synthetic';
40
+ }
41
+ return model.provider;
42
+ case 'family':
43
+ return detectFamily(model);
44
+ case 'pricingTier':
45
+ return tierToCategoryFilter(model.tier);
46
+ case 'qualityTier': {
47
+ if (model.provider === 'synthetic') {
48
+ const info = providerRegistry.getSyntheticModelInfoFromCatalog(model.id);
49
+ return info?.bestCompositeScore != null ? getQualityTierFromScore(info.bestCompositeScore) : 'C';
50
+ }
51
+ const b = benchmarkStore.getBenchmarks(model.id) ?? benchmarkStore.getBenchmarks(model.displayName);
52
+ return b ? getQualityTier(b.benchmarks) : 'C';
53
+ }
54
+ }
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // toModelItem — single model -> PickerItem
59
+ // ---------------------------------------------------------------------------
60
+
61
+ export function toModelItem(
62
+ model: ModelDefinition,
63
+ isPinned: boolean,
64
+ providerRegistry: Pick<ProviderRegistry, 'getSyntheticModelInfoFromCatalog'>,
65
+ benchmarkStore: Pick<BenchmarkStore, 'getBenchmarks'>,
66
+ ): PickerItem {
67
+ let qualityTier: string | undefined;
68
+ let detail: string;
69
+
70
+ if (model.provider === 'synthetic') {
71
+ const synthInfo = providerRegistry.getSyntheticModelInfoFromCatalog(model.id);
72
+ if (synthInfo?.bestCompositeScore != null) {
73
+ qualityTier = getQualityTierFromScore(synthInfo.bestCompositeScore);
74
+ }
75
+ detail = synthInfo !== null
76
+ ? `${model.provider} [${synthInfo.keyedBackendCount} provider${synthInfo.keyedBackendCount !== 1 ? 's' : ''}]`
77
+ : model.provider;
78
+ } else {
79
+ detail = model.provider;
80
+ const b = benchmarkStore.getBenchmarks(model.id) ?? benchmarkStore.getBenchmarks(model.displayName);
81
+ qualityTier = b ? getQualityTier(b.benchmarks) : undefined;
82
+ }
83
+
84
+ const isFree = tierToCategoryFilter(model.tier) === 'free';
85
+
86
+ return {
87
+ id: model.id,
88
+ label: model.displayName,
89
+ detail,
90
+ qualityTier,
91
+ isPinned,
92
+ isFree,
93
+ };
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // buildModelItems — filtered models -> PickerItem[] with group headers
98
+ // ---------------------------------------------------------------------------
99
+
100
+ export function buildModelItems(
101
+ filtered: ModelDefinition[],
102
+ pinnedIds: ReadonlySet<string>,
103
+ groupBy: GroupByMode,
104
+ providerRegistry: Pick<ProviderRegistry, 'getSyntheticModelInfoFromCatalog'>,
105
+ benchmarkStore: Pick<BenchmarkStore, 'getBenchmarks'>,
106
+ cache: ModelItemsCache | null,
107
+ ): { result: PickerItem[]; cache: ModelItemsCache } {
108
+ const pinnedIdsKey = setKey(pinnedIds);
109
+
110
+ if (
111
+ cache !== null
112
+ && cache.filteredModelsRef === filtered
113
+ && cache.pinnedIdsKey === pinnedIdsKey
114
+ && cache.groupBy === groupBy
115
+ ) {
116
+ return { result: cache.result, cache };
117
+ }
118
+
119
+ const pinned = filtered.filter(m => pinnedIds.has(m.id));
120
+ const unpinned = filtered.filter(m => !pinnedIds.has(m.id));
121
+
122
+ const items: PickerItem[] = [];
123
+
124
+ // Pinned section header (only if pinned models are in the filtered list)
125
+ if (pinned.length > 0) {
126
+ items.push({ id: '__header__pinned', label: 'Favorites', isGroupHeader: true });
127
+ for (const m of pinned) {
128
+ items.push(toModelItem(m, true, providerRegistry, benchmarkStore));
129
+ }
130
+ }
131
+
132
+ // Grouped unpinned models
133
+ let lastGroupKey = '';
134
+ for (const m of unpinned) {
135
+ const groupKey = getModelGroupKey(m, groupBy, providerRegistry, benchmarkStore);
136
+ if (groupKey !== lastGroupKey) {
137
+ items.push({ id: `__header__${groupKey}`, label: groupKey, isGroupHeader: true });
138
+ lastGroupKey = groupKey;
139
+ }
140
+ items.push(toModelItem(m, false, providerRegistry, benchmarkStore));
141
+ }
142
+
143
+ const newCache: ModelItemsCache = {
144
+ filteredModelsRef: filtered,
145
+ pinnedIdsKey,
146
+ groupBy,
147
+ result: items,
148
+ };
149
+
150
+ return { result: items, cache: newCache };
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // buildProviderItems — filtered providers -> PickerItem[] with group headers
155
+ // ---------------------------------------------------------------------------
156
+
157
+ export function buildProviderItems(
158
+ filteredProviders: string[],
159
+ configuredProviders: ReadonlySet<string>,
160
+ configuredViaMap: ReadonlyMap<string, 'env' | 'secrets' | 'subscription' | 'anonymous'>,
161
+ cache: ProviderItemsCache | null,
162
+ ): { result: PickerItem[]; cache: ProviderItemsCache } {
163
+ const configuredProvidersKey = setKey(configuredProviders);
164
+ const configuredViaKey = mapKey(configuredViaMap);
165
+
166
+ if (
167
+ cache !== null
168
+ && cache.filteredProvidersRef === filteredProviders
169
+ && cache.configuredProvidersKey === configuredProvidersKey
170
+ && cache.configuredViaKey === configuredViaKey
171
+ ) {
172
+ return { result: cache.result, cache };
173
+ }
174
+
175
+ const items: PickerItem[] = [];
176
+ let currentGroup: 'Popular' | 'All Providers' | null = null;
177
+
178
+ for (const p of filteredProviders) {
179
+ const group: 'Popular' | 'All Providers' = POPULAR_PROVIDERS.has(p.toLowerCase()) ? 'Popular' : 'All Providers';
180
+ if (group !== currentGroup) {
181
+ items.push({ id: `__header__${group}`, label: group, isGroupHeader: true });
182
+ currentGroup = group;
183
+ }
184
+ items.push({
185
+ id: p,
186
+ label: p,
187
+ isConfigured: configuredProviders.has(p),
188
+ configuredVia: configuredViaMap.get(p),
189
+ });
190
+ }
191
+
192
+ const newCache: ProviderItemsCache = {
193
+ filteredProvidersRef: filteredProviders,
194
+ configuredProvidersKey,
195
+ configuredViaKey,
196
+ result: items,
197
+ };
198
+
199
+ return { result: items, cache: newCache };
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // buildEffortItems — effort levels -> PickerItem[]
204
+ // ---------------------------------------------------------------------------
205
+
206
+ export function buildEffortItems(effortLevels: readonly string[]): PickerItem[] {
207
+ return effortLevels.map(e => ({ id: e, label: e, detail: EFFORT_DESCRIPTIONS[e] ?? '' }));
208
+ }