@stigmer/react 0.2.3 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/composer/ComposerToolbar.d.ts +5 -1
  2. package/composer/ComposerToolbar.d.ts.map +1 -1
  3. package/composer/ComposerToolbar.js +6 -3
  4. package/composer/ComposerToolbar.js.map +1 -1
  5. package/composer/SessionComposer.d.ts +17 -1
  6. package/composer/SessionComposer.d.ts.map +1 -1
  7. package/composer/SessionComposer.js +10 -3
  8. package/composer/SessionComposer.js.map +1 -1
  9. package/execution/MessageEntry.d.ts +3 -1
  10. package/execution/MessageEntry.d.ts.map +1 -1
  11. package/execution/MessageEntry.js +30 -1
  12. package/execution/MessageEntry.js.map +1 -1
  13. package/index.d.ts +3 -3
  14. package/index.d.ts.map +1 -1
  15. package/index.js +2 -2
  16. package/index.js.map +1 -1
  17. package/models/HarnessSelector.d.ts +41 -0
  18. package/models/HarnessSelector.d.ts.map +1 -0
  19. package/models/HarnessSelector.js +74 -0
  20. package/models/HarnessSelector.js.map +1 -0
  21. package/models/ModelSelector.d.ts +26 -16
  22. package/models/ModelSelector.d.ts.map +1 -1
  23. package/models/ModelSelector.js +128 -48
  24. package/models/ModelSelector.js.map +1 -1
  25. package/models/__tests__/HarnessSelector.test.d.ts +2 -0
  26. package/models/__tests__/HarnessSelector.test.d.ts.map +1 -0
  27. package/models/__tests__/HarnessSelector.test.js +160 -0
  28. package/models/__tests__/HarnessSelector.test.js.map +1 -0
  29. package/models/__tests__/harness.test.d.ts +2 -0
  30. package/models/__tests__/harness.test.d.ts.map +1 -0
  31. package/models/__tests__/harness.test.js +50 -0
  32. package/models/__tests__/harness.test.js.map +1 -0
  33. package/models/__tests__/useModelRegistry.test.d.ts +2 -0
  34. package/models/__tests__/useModelRegistry.test.d.ts.map +1 -0
  35. package/models/__tests__/useModelRegistry.test.js +148 -0
  36. package/models/__tests__/useModelRegistry.test.js.map +1 -0
  37. package/models/harness.d.ts +21 -0
  38. package/models/harness.d.ts.map +1 -0
  39. package/models/harness.js +34 -0
  40. package/models/harness.js.map +1 -0
  41. package/models/index.d.ts +7 -2
  42. package/models/index.d.ts.map +1 -1
  43. package/models/index.js +3 -1
  44. package/models/index.js.map +1 -1
  45. package/models/registry.d.ts +53 -13
  46. package/models/registry.d.ts.map +1 -1
  47. package/models/registry.js +51 -40
  48. package/models/registry.js.map +1 -1
  49. package/models/useModelRegistry.d.ts +39 -19
  50. package/models/useModelRegistry.d.ts.map +1 -1
  51. package/models/useModelRegistry.js +45 -23
  52. package/models/useModelRegistry.js.map +1 -1
  53. package/package.json +4 -4
  54. package/runner/RunnerListPanel.js +2 -1
  55. package/runner/RunnerListPanel.js.map +1 -1
  56. package/runner/__tests__/phase.test.js +6 -2
  57. package/runner/__tests__/phase.test.js.map +1 -1
  58. package/runner/phase.d.ts +9 -7
  59. package/runner/phase.d.ts.map +1 -1
  60. package/runner/phase.js +18 -12
  61. package/runner/phase.js.map +1 -1
  62. package/session/__tests__/useCreateSession.test.d.ts +2 -0
  63. package/session/__tests__/useCreateSession.test.d.ts.map +1 -0
  64. package/session/__tests__/useCreateSession.test.js +232 -0
  65. package/session/__tests__/useCreateSession.test.js.map +1 -0
  66. package/session/__tests__/useNewSessionFlow.test.d.ts +2 -0
  67. package/session/__tests__/useNewSessionFlow.test.d.ts.map +1 -0
  68. package/session/__tests__/useNewSessionFlow.test.js +199 -0
  69. package/session/__tests__/useNewSessionFlow.test.js.map +1 -0
  70. package/session/__tests__/useSessionConversation.test.js +37 -0
  71. package/session/__tests__/useSessionConversation.test.js.map +1 -1
  72. package/session/index.d.ts +1 -1
  73. package/session/index.d.ts.map +1 -1
  74. package/session/useCreateSession.d.ts +8 -0
  75. package/session/useCreateSession.d.ts.map +1 -1
  76. package/session/useCreateSession.js +2 -0
  77. package/session/useCreateSession.js.map +1 -1
  78. package/session/useNewSessionFlow.d.ts +6 -1
  79. package/session/useNewSessionFlow.d.ts.map +1 -1
  80. package/session/useNewSessionFlow.js +34 -8
  81. package/session/useNewSessionFlow.js.map +1 -1
  82. package/session/usePersistedModel.d.ts +16 -1
  83. package/session/usePersistedModel.d.ts.map +1 -1
  84. package/session/usePersistedModel.js +15 -6
  85. package/session/usePersistedModel.js.map +1 -1
  86. package/session/useSessionConversation.d.ts.map +1 -1
  87. package/session/useSessionConversation.js +6 -1
  88. package/session/useSessionConversation.js.map +1 -1
  89. package/session/useSessionPageFlow.d.ts +11 -0
  90. package/session/useSessionPageFlow.d.ts.map +1 -1
  91. package/session/useSessionPageFlow.js +11 -2
  92. package/session/useSessionPageFlow.js.map +1 -1
  93. package/src/composer/ComposerToolbar.tsx +24 -1
  94. package/src/composer/SessionComposer.tsx +35 -1
  95. package/src/execution/MessageEntry.tsx +134 -1
  96. package/src/index.ts +15 -1
  97. package/src/models/HarnessSelector.tsx +130 -0
  98. package/src/models/ModelSelector.tsx +285 -81
  99. package/src/models/__tests__/HarnessSelector.test.tsx +190 -0
  100. package/src/models/__tests__/harness.test.ts +66 -0
  101. package/src/models/__tests__/useModelRegistry.test.tsx +209 -0
  102. package/src/models/harness.ts +45 -0
  103. package/src/models/index.ts +7 -2
  104. package/src/models/registry.ts +122 -50
  105. package/src/models/useModelRegistry.ts +74 -24
  106. package/src/runner/RunnerListPanel.tsx +13 -5
  107. package/src/runner/__tests__/phase.test.ts +6 -2
  108. package/src/runner/phase.ts +18 -12
  109. package/src/session/__tests__/useCreateSession.test.tsx +296 -0
  110. package/src/session/__tests__/useNewSessionFlow.test.tsx +258 -0
  111. package/src/session/__tests__/useSessionConversation.test.tsx +53 -0
  112. package/src/session/index.ts +1 -1
  113. package/src/session/useCreateSession.ts +9 -0
  114. package/src/session/useNewSessionFlow.ts +46 -9
  115. package/src/session/usePersistedModel.ts +30 -6
  116. package/src/session/useSessionConversation.ts +6 -1
  117. package/src/session/useSessionPageFlow.ts +26 -2
  118. package/styles.css +1 -1
@@ -0,0 +1,209 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderHook } from "@testing-library/react";
3
+ import { useModelRegistry } from "../useModelRegistry";
4
+ import {
5
+ MODEL_REGISTRY,
6
+ DEFAULT_MODEL_ID,
7
+ DEFAULT_CURSOR_MODEL_ID,
8
+ DISABLED_PROVIDERS,
9
+ modelKey,
10
+ } from "../registry";
11
+
12
+ describe("useModelRegistry", () => {
13
+ describe("unified mode (no harness)", () => {
14
+ it("excludes DISABLED_PROVIDERS from models", () => {
15
+ const { result } = renderHook(() => useModelRegistry());
16
+ for (const model of result.current.models) {
17
+ expect(DISABLED_PROVIDERS.has(model.provider)).toBe(false);
18
+ }
19
+ });
20
+
21
+ it("includes models from both native and cursor harnesses", () => {
22
+ const { result } = renderHook(() => useModelRegistry());
23
+ const harnesses = new Set(result.current.models.map((m) => m.harness));
24
+ expect(harnesses.has("native")).toBe(true);
25
+ expect(harnesses.has("cursor")).toBe(true);
26
+ });
27
+
28
+ it("includes all non-disabled models from MODEL_REGISTRY", () => {
29
+ const { result } = renderHook(() => useModelRegistry());
30
+ const expected = MODEL_REGISTRY.filter(
31
+ (m) => !DISABLED_PROVIDERS.has(m.provider),
32
+ );
33
+ expect(result.current.models).toHaveLength(expected.length);
34
+ });
35
+
36
+ it("resolves defaultModel to DEFAULT_MODEL_ID", () => {
37
+ const { result } = renderHook(() => useModelRegistry());
38
+ expect(result.current.defaultModel.modelId).toBe(DEFAULT_MODEL_ID);
39
+ });
40
+
41
+ it("groups models by provider in byProvider map", () => {
42
+ const { result } = renderHook(() => useModelRegistry());
43
+ for (const [provider, models] of result.current.byProvider) {
44
+ for (const m of models) {
45
+ expect(m.provider).toBe(provider);
46
+ }
47
+ }
48
+ });
49
+
50
+ it("returns providers matching byProvider keys in order", () => {
51
+ const { result } = renderHook(() => useModelRegistry());
52
+ const fromMap = Array.from(result.current.byProvider.keys());
53
+ expect(result.current.providers).toEqual(fromMap);
54
+ });
55
+
56
+ it("looks up enabled models by getModel", () => {
57
+ const { result } = renderHook(() => useModelRegistry());
58
+ const model = result.current.getModel(DEFAULT_MODEL_ID);
59
+ expect(model).toBeDefined();
60
+ expect(model!.modelId).toBe(DEFAULT_MODEL_ID);
61
+ });
62
+
63
+ it("returns undefined for disabled provider models via getModel", () => {
64
+ const { result } = renderHook(() => useModelRegistry());
65
+ const disabledModel = MODEL_REGISTRY.find((m) =>
66
+ DISABLED_PROVIDERS.has(m.provider),
67
+ );
68
+ if (disabledModel) {
69
+ expect(result.current.getModel(disabledModel.modelId)).toBeUndefined();
70
+ }
71
+ });
72
+
73
+ it("returns undefined for unknown model IDs via getModel", () => {
74
+ const { result } = renderHook(() => useModelRegistry());
75
+ expect(result.current.getModel("nonexistent-model")).toBeUndefined();
76
+ });
77
+
78
+ it("returns featured models subset", () => {
79
+ const { result } = renderHook(() => useModelRegistry());
80
+ expect(result.current.featured.length).toBeGreaterThan(0);
81
+ for (const model of result.current.featured) {
82
+ expect(model.featured).toBe(true);
83
+ }
84
+ });
85
+
86
+ it("featured models are a subset of all models", () => {
87
+ const { result } = renderHook(() => useModelRegistry());
88
+ const allKeys = new Set(
89
+ result.current.models.map((m) => modelKey(m.harness, m.modelId)),
90
+ );
91
+ for (const model of result.current.featured) {
92
+ expect(allKeys.has(modelKey(model.harness, model.modelId))).toBe(true);
93
+ }
94
+ });
95
+
96
+ it("resolves models by compound key via getByKey", () => {
97
+ const { result } = renderHook(() => useModelRegistry());
98
+ const key = modelKey("native", DEFAULT_MODEL_ID);
99
+ const model = result.current.getByKey(key);
100
+ expect(model).toBeDefined();
101
+ expect(model!.modelId).toBe(DEFAULT_MODEL_ID);
102
+ expect(model!.harness).toBe("native");
103
+ });
104
+
105
+ it("resolves cursor models by compound key via getByKey", () => {
106
+ const { result } = renderHook(() => useModelRegistry());
107
+ const key = modelKey("cursor", DEFAULT_CURSOR_MODEL_ID);
108
+ const model = result.current.getByKey(key);
109
+ expect(model).toBeDefined();
110
+ expect(model!.modelId).toBe(DEFAULT_CURSOR_MODEL_ID);
111
+ expect(model!.harness).toBe("cursor");
112
+ });
113
+
114
+ it("returns undefined for unknown compound keys via getByKey", () => {
115
+ const { result } = renderHook(() => useModelRegistry());
116
+ expect(result.current.getByKey("native/nonexistent")).toBeUndefined();
117
+ });
118
+ });
119
+
120
+ describe("native harness (explicit)", () => {
121
+ it("shows only native-harness models", () => {
122
+ const { result } = renderHook(() =>
123
+ useModelRegistry({ harness: "native" }),
124
+ );
125
+ for (const model of result.current.models) {
126
+ expect(model.harness).toBe("native");
127
+ }
128
+ });
129
+
130
+ it("excludes cursor-harness models", () => {
131
+ const { result } = renderHook(() =>
132
+ useModelRegistry({ harness: "native" }),
133
+ );
134
+ const cursorModels = result.current.models.filter(
135
+ (m) => m.harness === "cursor",
136
+ );
137
+ expect(cursorModels).toHaveLength(0);
138
+ });
139
+
140
+ it("resolves defaultModel to DEFAULT_MODEL_ID", () => {
141
+ const { result } = renderHook(() =>
142
+ useModelRegistry({ harness: "native" }),
143
+ );
144
+ expect(result.current.defaultModel.modelId).toBe(DEFAULT_MODEL_ID);
145
+ });
146
+ });
147
+
148
+ describe("cursor harness", () => {
149
+ it("shows only cursor-harness models", () => {
150
+ const { result } = renderHook(() =>
151
+ useModelRegistry({ harness: "cursor" }),
152
+ );
153
+ for (const model of result.current.models) {
154
+ expect(model.harness).toBe("cursor");
155
+ }
156
+ });
157
+
158
+ it("includes cursor-harness models from multiple providers", () => {
159
+ const { result } = renderHook(() =>
160
+ useModelRegistry({ harness: "cursor" }),
161
+ );
162
+ const providers = new Set(result.current.models.map((m) => m.provider));
163
+ expect(providers.size).toBeGreaterThan(1);
164
+ });
165
+
166
+ it("includes all cursor-harness models from MODEL_REGISTRY", () => {
167
+ const { result } = renderHook(() =>
168
+ useModelRegistry({ harness: "cursor" }),
169
+ );
170
+ const cursorModels = MODEL_REGISTRY.filter(
171
+ (m) => m.harness === "cursor",
172
+ );
173
+ expect(result.current.models).toHaveLength(cursorModels.length);
174
+ });
175
+
176
+ it("resolves defaultModel to DEFAULT_CURSOR_MODEL_ID", () => {
177
+ const { result } = renderHook(() =>
178
+ useModelRegistry({ harness: "cursor" }),
179
+ );
180
+ expect(result.current.defaultModel.modelId).toBe(
181
+ DEFAULT_CURSOR_MODEL_ID,
182
+ );
183
+ });
184
+
185
+ it("looks up cursor models via getModel", () => {
186
+ const { result } = renderHook(() =>
187
+ useModelRegistry({ harness: "cursor" }),
188
+ );
189
+ expect(
190
+ result.current.getModel(DEFAULT_CURSOR_MODEL_ID),
191
+ ).toBeDefined();
192
+ });
193
+
194
+ it("cannot look up native-only models via getModel", () => {
195
+ const { result } = renderHook(() =>
196
+ useModelRegistry({ harness: "cursor" }),
197
+ );
198
+ expect(result.current.getModel(DEFAULT_MODEL_ID)).toBeUndefined();
199
+ });
200
+ });
201
+
202
+ describe("defaultModel fallback", () => {
203
+ it("falls back to the first enabled model when default ID is missing", () => {
204
+ const { result } = renderHook(() => useModelRegistry());
205
+ expect(result.current.defaultModel).toBeDefined();
206
+ expect(result.current.models).toContain(result.current.defaultModel);
207
+ });
208
+ });
209
+ });
@@ -0,0 +1,45 @@
1
+ import { Harness } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_pb";
2
+
3
+ /**
4
+ * String literal alias for the proto {@link Harness} enum.
5
+ *
6
+ * Used on component props and hook options so platform builders
7
+ * do not need to import proto enums to use the SDK.
8
+ */
9
+ export type HarnessOption = "native" | "cursor";
10
+
11
+ /** User-facing labels for each harness option. */
12
+ export const HARNESS_LABELS: Readonly<Record<HarnessOption, string>> = {
13
+ native: "Stigmer",
14
+ cursor: "Cursor",
15
+ };
16
+
17
+ /** Platform default — resolves to the native engine. */
18
+ export const DEFAULT_HARNESS: HarnessOption = "native";
19
+
20
+ /** Convert a {@link HarnessOption} string to the proto {@link Harness} enum. */
21
+ export function toProtoHarness(h: HarnessOption): Harness {
22
+ switch (h) {
23
+ case "cursor":
24
+ return Harness.CURSOR;
25
+ case "native":
26
+ default:
27
+ return Harness.NATIVE;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Convert a proto {@link Harness} enum to a {@link HarnessOption} string.
33
+ *
34
+ * `UNSPECIFIED` and any unknown values map to `"native"`.
35
+ */
36
+ export function fromProtoHarness(h: Harness): HarnessOption {
37
+ switch (h) {
38
+ case Harness.CURSOR:
39
+ return "cursor";
40
+ case Harness.NATIVE:
41
+ case Harness.UNSPECIFIED:
42
+ default:
43
+ return "native";
44
+ }
45
+ }
@@ -1,6 +1,11 @@
1
- export { MODEL_REGISTRY, DEFAULT_MODEL_ID, DISABLED_PROVIDERS } from "./registry";
1
+ export { MODEL_REGISTRY, DEFAULT_MODEL_ID, DEFAULT_CURSOR_MODEL_ID, DISABLED_PROVIDERS, modelKey, parseModelKey } from "./registry";
2
+ export type { ParsedModelKey } from "./registry";
2
3
  export type { ModelInfo, Provider, CostTier } from "./registry";
3
4
  export { useModelRegistry } from "./useModelRegistry";
4
- export type { UseModelRegistryReturn } from "./useModelRegistry";
5
+ export type { UseModelRegistryReturn, UseModelRegistryOptions } from "./useModelRegistry";
5
6
  export { ModelSelector } from "./ModelSelector";
6
7
  export type { ModelSelectorProps } from "./ModelSelector";
8
+ export { HarnessSelector } from "./HarnessSelector";
9
+ export type { HarnessSelectorProps } from "./HarnessSelector";
10
+ export { DEFAULT_HARNESS, HARNESS_LABELS, toProtoHarness, fromProtoHarness } from "./harness";
11
+ export type { HarnessOption } from "./harness";
@@ -1,15 +1,16 @@
1
1
  /**
2
2
  * Model registry — UI-relevant metadata for all platform-supported LLM models.
3
3
  *
4
- * Ported from the Python source of truth at
5
- * backend/libs/python/graphton/src/graphton/core/model_registry.py.
4
+ * Reads from the unified JSON registry at backend/libs/model-registry.json,
5
+ * which is the single source of truth for model IDs, display names, pricing,
6
+ * and cost tiers across all harnesses and runtimes.
6
7
  *
7
- * This is a static, hardcoded list. A future backend RPC will replace it
8
- * with a dynamic query; when that happens consumers will only need to
9
- * swap from the static constant to a fetched result — the shape stays
10
- * the same.
8
+ * Update it with: @update-model-registry
11
9
  */
12
10
 
11
+ import type { HarnessOption } from "./harness";
12
+ import registryData from "../../data/model-registry.json";
13
+
13
14
  /**
14
15
  * Pricing bracket for a model.
15
16
  *
@@ -21,10 +22,18 @@ export type CostTier = "economy" | "standard" | "premium";
21
22
 
22
23
  /**
23
24
  * LLM provider identifier. Each provider maps to a distinct inference
24
- * backend. The {@link MODEL_REGISTRY} groups models by provider for
25
- * display in {@link ModelSelector}.
25
+ * backend (or intermediary, in the case of Cursor-served third-party
26
+ * models). The {@link MODEL_REGISTRY} uses this for grouping in the
27
+ * "Show All" expanded view.
26
28
  */
27
- export type Provider = "anthropic" | "openai" | "ollama";
29
+ export type Provider =
30
+ | "anthropic"
31
+ | "openai"
32
+ | "google"
33
+ | "xai"
34
+ | "cursor"
35
+ | "moonshot"
36
+ | "ollama";
28
37
 
29
38
  /**
30
39
  * Providers whose models should be hidden from the UI.
@@ -36,7 +45,6 @@ export type Provider = "anthropic" | "openai" | "ollama";
36
45
  * To re-enable a provider, simply remove it from this set.
37
46
  */
38
47
  export const DISABLED_PROVIDERS: ReadonlySet<Provider> = new Set([
39
- "openai",
40
48
  "ollama",
41
49
  ]);
42
50
 
@@ -56,54 +64,118 @@ export interface ModelInfo {
56
64
  readonly modelId: string;
57
65
  /** LLM provider that serves this model. */
58
66
  readonly provider: Provider;
59
- /** Human-readable name shown in the {@link ModelSelector} dropdown. */
67
+ /** Human-readable name shown in the model picker. */
60
68
  readonly displayName: string;
61
69
  /** Pricing bracket used for cost-tier indicators in the UI. */
62
70
  readonly costTier: CostTier;
71
+ /** Which execution engine serves this model. */
72
+ readonly harness: HarnessOption;
73
+ /**
74
+ * When `true`, appears in the curated default list (the short view
75
+ * before "Show All Models" is expanded or search is used).
76
+ */
77
+ readonly featured: boolean;
78
+ }
79
+
80
+ /**
81
+ * Per-model cost entry for programmatic access to pricing data.
82
+ * Re-exported for consumers that need dollar-level pricing beyond the
83
+ * coarse `CostTier` label.
84
+ */
85
+ export interface ModelCostEntry {
86
+ readonly modelId: string;
87
+ readonly inputPricePerMillion: number;
88
+ readonly outputPricePerMillion: number;
89
+ readonly cacheWritePricePerMillion: number;
90
+ readonly cacheReadPricePerMillion: number;
91
+ }
92
+
93
+ /**
94
+ * Build a compound key that uniquely identifies a model across harnesses.
95
+ *
96
+ * The same underlying model name (e.g. `claude-4.6-sonnet`) can exist in
97
+ * both native and cursor harnesses. The compound key disambiguates.
98
+ */
99
+ export function modelKey(harness: HarnessOption, modelId: string): string {
100
+ return `${harness}/${modelId}`;
101
+ }
102
+
103
+ /** Parsed result of a compound `harness/modelId` key. */
104
+ export interface ParsedModelKey {
105
+ /** Harness portion of the compound key. */
106
+ harness: HarnessOption;
107
+ /** Model ID portion of the compound key. */
108
+ modelId: string;
63
109
  }
64
110
 
65
111
  /**
66
- * Static catalog of all platform-supported LLM models.
112
+ * Parse a compound key back into its `(harness, modelId)` parts.
113
+ * Returns `undefined` for malformed keys.
114
+ */
115
+ export function parseModelKey(key: string): ParsedModelKey | undefined {
116
+ const idx = key.indexOf("/");
117
+ if (idx < 1) return undefined;
118
+ const harness = key.slice(0, idx);
119
+ if (harness !== "native" && harness !== "cursor") return undefined;
120
+ return { harness, modelId: key.slice(idx + 1) };
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // JSON → ModelInfo mapping
125
+ // ---------------------------------------------------------------------------
126
+
127
+ interface RegistryJsonEntry {
128
+ id?: string;
129
+ displayName?: string;
130
+ provider?: string;
131
+ harness?: string;
132
+ costTier?: string;
133
+ featured?: boolean;
134
+ pricing?: {
135
+ inputPricePerMillion: number;
136
+ outputPricePerMillion: number;
137
+ cacheWritePricePerMillion: number;
138
+ cacheReadPricePerMillion: number;
139
+ };
140
+ $comment?: string;
141
+ }
142
+
143
+ const VALID_COST_TIERS = new Set(["economy", "standard", "premium"]);
144
+ const VALID_HARNESSES = new Set(["native", "cursor"]);
145
+
146
+ function isModelEntry(entry: RegistryJsonEntry): entry is Required<Pick<RegistryJsonEntry, "id" | "displayName" | "provider" | "harness" | "costTier">> & RegistryJsonEntry {
147
+ return (
148
+ typeof entry.id === "string" &&
149
+ typeof entry.displayName === "string" &&
150
+ typeof entry.provider === "string" &&
151
+ typeof entry.harness === "string" && VALID_HARNESSES.has(entry.harness) &&
152
+ typeof entry.costTier === "string" && VALID_COST_TIERS.has(entry.costTier)
153
+ );
154
+ }
155
+
156
+ /**
157
+ * Static catalog of all platform-supported LLM models, loaded from the
158
+ * unified JSON registry.
67
159
  *
68
- * Each entry carries the metadata needed for model selection UI.
69
160
  * {@link useModelRegistry} filters out disabled providers and provides
70
161
  * lookup helpers on top of this list.
71
162
  */
72
- export const MODEL_REGISTRY: readonly ModelInfo[] = [
73
- // ── Anthropic — Generation 4.6 ────────────────────────────────────
74
- { modelId: "claude-opus-4.6", provider: "anthropic", displayName: "Claude Opus 4.6", costTier: "premium" },
75
- { modelId: "claude-sonnet-4.6", provider: "anthropic", displayName: "Claude Sonnet 4.6", costTier: "standard" },
76
-
77
- // ── Anthropic — Generation 4.5 ────────────────────────────────────
78
- { modelId: "claude-opus-4.5", provider: "anthropic", displayName: "Claude Opus 4.5", costTier: "premium" },
79
- { modelId: "claude-sonnet-4.5", provider: "anthropic", displayName: "Claude Sonnet 4.5", costTier: "standard" },
80
-
81
- // ── Anthropic — Generation 4 ──────────────────────────────────────
82
- { modelId: "claude-opus-4", provider: "anthropic", displayName: "Claude Opus 4", costTier: "premium" },
83
- { modelId: "claude-haiku-4.5", provider: "anthropic", displayName: "Claude Haiku 4.5", costTier: "economy" },
84
-
85
- // ── Anthropic Generation 3.5 ────────────────────────────────────
86
- { modelId: "claude-sonnet-3.5", provider: "anthropic", displayName: "Claude Sonnet 3.5", costTier: "standard" },
87
- { modelId: "claude-haiku-3.5", provider: "anthropic", displayName: "Claude Haiku 3.5", costTier: "economy" },
88
-
89
- // ── OpenAI ────────────────────────────────────────────────────────
90
- { modelId: "gpt-4", provider: "openai", displayName: "GPT-4", costTier: "premium" },
91
- { modelId: "gpt-4-turbo", provider: "openai", displayName: "GPT-4 Turbo", costTier: "standard" },
92
- { modelId: "gpt-4o", provider: "openai", displayName: "GPT-4o", costTier: "standard" },
93
- { modelId: "gpt-4o-mini", provider: "openai", displayName: "GPT-4o Mini", costTier: "economy" },
94
- { modelId: "gpt-3.5-turbo", provider: "openai", displayName: "GPT-3.5 Turbo", costTier: "economy" },
95
- { modelId: "o1", provider: "openai", displayName: "o1", costTier: "premium" },
96
- { modelId: "o1-mini", provider: "openai", displayName: "o1 Mini", costTier: "standard" },
97
-
98
- // ── Ollama (local, no cost) ───────────────────────────────────────
99
- { modelId: "qwen2.5-coder:7b", provider: "ollama", displayName: "Qwen 2.5 Coder 7B", costTier: "economy" },
100
- { modelId: "qwen2.5-coder:14b", provider: "ollama", displayName: "Qwen 2.5 Coder 14B", costTier: "economy" },
101
- { modelId: "codellama:7b", provider: "ollama", displayName: "Code Llama 7B", costTier: "economy" },
102
- { modelId: "codellama:13b", provider: "ollama", displayName: "Code Llama 13B", costTier: "economy" },
103
- { modelId: "deepseek-coder-v2:16b", provider: "ollama", displayName: "DeepSeek Coder V2 16B", costTier: "economy" },
104
- { modelId: "llama3.2:3b", provider: "ollama", displayName: "Llama 3.2 3B", costTier: "economy" },
105
- { modelId: "mistral:7b", provider: "ollama", displayName: "Mistral 7B", costTier: "economy" },
106
- ] as const;
107
-
108
- /** Model ID used when no user preference is set. */
163
+ export const MODEL_REGISTRY: readonly ModelInfo[] = (
164
+ registryData.models as RegistryJsonEntry[]
165
+ )
166
+ .filter(isModelEntry)
167
+ .map((m) => ({
168
+ modelId: m.id,
169
+ provider: m.provider as Provider,
170
+ displayName: m.displayName,
171
+ costTier: m.costTier as CostTier,
172
+ harness: m.harness as HarnessOption,
173
+ featured: m.featured ?? false,
174
+ }));
175
+
176
+ /** Model ID used when no user preference is set (native harness). */
109
177
  export const DEFAULT_MODEL_ID = "claude-sonnet-4.5";
178
+
179
+ /** Model ID used when the Cursor harness is selected and no user preference is set. */
180
+ export const DEFAULT_CURSOR_MODEL_ID = "default";
181
+
@@ -4,23 +4,52 @@ import { useMemo } from "react";
4
4
  import {
5
5
  MODEL_REGISTRY,
6
6
  DEFAULT_MODEL_ID,
7
+ DEFAULT_CURSOR_MODEL_ID,
7
8
  DISABLED_PROVIDERS,
9
+ modelKey,
8
10
  type ModelInfo,
9
11
  type Provider,
10
12
  } from "./registry";
13
+ import type { HarnessOption } from "./harness";
14
+
15
+ /** Options for {@link useModelRegistry}. */
16
+ export interface UseModelRegistryOptions {
17
+ /**
18
+ * Restrict to a single harness.
19
+ *
20
+ * - `"cursor"` — only Cursor-harness models, default → {@link DEFAULT_CURSOR_MODEL_ID}
21
+ * - `"native"` — only native-harness models, filtered by {@link DISABLED_PROVIDERS}
22
+ * - omitted — **unified mode**: all enabled models from both harnesses
23
+ */
24
+ readonly harness?: HarnessOption;
25
+ }
11
26
 
12
27
  /** Return value of {@link useModelRegistry}. */
13
28
  export interface UseModelRegistryReturn {
14
- /** All enabled models, filtered to exclude {@link DISABLED_PROVIDERS}. */
29
+ /** All enabled models for the selected mode. */
15
30
  readonly models: readonly ModelInfo[];
16
31
  /** Models grouped by provider for sectioned rendering. */
17
32
  readonly byProvider: ReadonlyMap<Provider, readonly ModelInfo[]>;
18
- /** The platform default model, resolved from {@link DEFAULT_MODEL_ID}. */
33
+ /** The platform default model for the current mode. */
19
34
  readonly defaultModel: ModelInfo;
20
- /** Look up a single model by its `modelId`. Returns `undefined` for unknown or disabled models. */
35
+ /**
36
+ * Look up a single model by its `modelId`.
37
+ *
38
+ * In unified mode the same modelId can exist under both harnesses
39
+ * (e.g. `claude-sonnet-4.5` on native and `claude-4.5-sonnet` on cursor).
40
+ * This returns the first match. Use {@link getByKey} with a compound key
41
+ * for an unambiguous lookup.
42
+ */
21
43
  readonly getModel: (modelId: string) => ModelInfo | undefined;
22
44
  /** Ordered list of enabled provider identifiers. */
23
45
  readonly providers: readonly Provider[];
46
+ /** Curated subset of models marked as `featured` in the registry. */
47
+ readonly featured: readonly ModelInfo[];
48
+ /**
49
+ * Look up a model by its compound key (`"native/claude-sonnet-4.6"`).
50
+ * Always unambiguous, even in unified mode.
51
+ */
52
+ readonly getByKey: (key: string) => ModelInfo | undefined;
24
53
  }
25
54
 
26
55
  /**
@@ -31,38 +60,53 @@ export interface UseModelRegistryReturn {
31
60
  * who want full control over rendering import this hook and build
32
61
  * their own UI.
33
62
  *
63
+ * **Modes:**
64
+ * - `options.harness === "cursor"` — Cursor-harness models only
65
+ * - `options.harness === "native"` — native models, excluding disabled providers
66
+ * - `options.harness` omitted — unified: both harnesses, excluding disabled providers
67
+ *
34
68
  * @example
35
69
  * ```tsx
36
- * function CustomModelPicker({ onSelect }: { onSelect: (id: string) => void }) {
37
- * const { models, byProvider, defaultModel, getModel } = useModelRegistry();
70
+ * // Unified mode flat picker with all models
71
+ * const { featured, models, getByKey } = useModelRegistry();
38
72
  *
39
- * return (
40
- * <select
41
- * defaultValue={defaultModel.modelId}
42
- * onChange={(e) => onSelect(e.target.value)}
43
- * >
44
- * {models.map((m) => (
45
- * <option key={m.modelId} value={m.modelId}>
46
- * {m.displayName} ({m.costTier})
47
- * </option>
48
- * ))}
49
- * </select>
50
- * );
51
- * }
73
+ * // Legacy single-harness mode
74
+ * const { models, defaultModel } = useModelRegistry({ harness: "native" });
52
75
  * ```
53
76
  */
54
- export function useModelRegistry(): UseModelRegistryReturn {
77
+ export function useModelRegistry(options?: UseModelRegistryOptions): UseModelRegistryReturn {
78
+ const harness = options?.harness;
79
+
55
80
  return useMemo(() => {
81
+ const isCursor = harness === "cursor";
82
+ const isNative = harness === "native";
83
+ const isUnified = harness === undefined;
84
+ const defaultId = isCursor ? DEFAULT_CURSOR_MODEL_ID : DEFAULT_MODEL_ID;
85
+
56
86
  const byProvider = new Map<Provider, ModelInfo[]>();
57
87
  const byId = new Map<string, ModelInfo>();
88
+ const byCompoundKey = new Map<string, ModelInfo>();
58
89
  const enabledModels: ModelInfo[] = [];
90
+ const featuredModels: ModelInfo[] = [];
59
91
  let defaultModel: ModelInfo | undefined;
60
92
 
61
93
  for (const model of MODEL_REGISTRY) {
62
- if (DISABLED_PROVIDERS.has(model.provider)) continue;
94
+ if (isCursor) {
95
+ if (model.harness !== "cursor") continue;
96
+ } else if (isNative) {
97
+ if (model.harness !== "native") continue;
98
+ if (DISABLED_PROVIDERS.has(model.provider)) continue;
99
+ } else {
100
+ // Unified — include both harnesses, still respect DISABLED_PROVIDERS
101
+ if (DISABLED_PROVIDERS.has(model.provider)) continue;
102
+ }
63
103
 
64
104
  enabledModels.push(model);
65
- byId.set(model.modelId, model);
105
+ byCompoundKey.set(modelKey(model.harness, model.modelId), model);
106
+
107
+ if (!byId.has(model.modelId)) {
108
+ byId.set(model.modelId, model);
109
+ }
66
110
 
67
111
  const group = byProvider.get(model.provider);
68
112
  if (group) {
@@ -71,8 +115,12 @@ export function useModelRegistry(): UseModelRegistryReturn {
71
115
  byProvider.set(model.provider, [model]);
72
116
  }
73
117
 
74
- if (model.modelId === DEFAULT_MODEL_ID) {
75
- defaultModel = model;
118
+ if (model.modelId === defaultId) {
119
+ defaultModel ??= model;
120
+ }
121
+
122
+ if (model.featured) {
123
+ featuredModels.push(model);
76
124
  }
77
125
  }
78
126
 
@@ -84,6 +132,8 @@ export function useModelRegistry(): UseModelRegistryReturn {
84
132
  defaultModel: defaultModel ?? enabledModels[0],
85
133
  getModel: (modelId: string) => byId.get(modelId),
86
134
  providers,
135
+ featured: featuredModels,
136
+ getByKey: (key: string) => byCompoundKey.get(key),
87
137
  };
88
- }, []);
138
+ }, [harness]);
89
139
  }
@@ -576,13 +576,21 @@ function ActionMenu({
576
576
  // ---------------------------------------------------------------------------
577
577
 
578
578
  function PhaseBadge({ phase }: { phase: RunnerPhase }) {
579
+ const starting = phase === RunnerPhase.STARTING;
579
580
  return (
580
581
  <span className="inline-flex shrink-0 items-center gap-1">
581
- <span
582
- className={`inline-block h-1.5 w-1.5 rounded-full ${phaseDotColor(phase)}`}
583
- aria-hidden="true"
584
- />
585
- <span className="text-[0.65rem] text-muted-foreground">
582
+ {starting ? (
583
+ <span
584
+ className="inline-block h-2.5 w-2.5 animate-spin rounded-full border border-primary border-t-transparent"
585
+ aria-hidden="true"
586
+ />
587
+ ) : (
588
+ <span
589
+ className={`inline-block h-1.5 w-1.5 rounded-full ${phaseDotColor(phase)}`}
590
+ aria-hidden="true"
591
+ />
592
+ )}
593
+ <span className={cn("text-[0.65rem]", starting ? "text-primary" : "text-muted-foreground")}>
586
594
  {phaseLabel(phase)}
587
595
  </span>
588
596
  </span>