| author | |
| committer | |
| log | 548cd113b25e9e839e094c08cb4ce96366a9cb8b |
| tree | cf9b86aa70ccf138506b8cb89a341eaa441e1e98 |
| parent | 13501e85f61f2b004c3dc2819ccfb237fe30e305 |
| signature | Commit is signed but in an unrecognized format. |
12 files changed, 522 insertions(+), 19 deletions(-)
TODO.md created+10| ... | @@ -0,0 +1,10 @@ | ||
| 1 | - [ ] Folder picker for new projects | ||
| 2 | - [ ] Projects should be persisted so they're still visible after closing | ||
| 3 | - [ ] Remove model selection from project creation | ||
| 4 | - [ ] First message vanishes when thread is created?? Awful ux | ||
| 5 | - [ ] Show more clearly when thinking is happening | ||
| 6 | |||
| 7 | ## Bigger overhauls | ||
| 8 | |||
| 9 | - [ ] Markdown rendering | ||
| 10 | - [ ] Code block rendering | ||
apps/desktop/src/codexAppServerManager.ts+247-2| ... | @@ -5,6 +5,7 @@ import readline from "node:readline"; | ... | @@ -5,6 +5,7 @@ import readline from "node:readline"; |
| 5 | 5 | ||
| 6 | import type { | 6 | import type { |
| 7 | ProviderEvent, | 7 | ProviderEvent, |
| 8 | ProviderModel, | ||
| 8 | ProviderSendTurnInput, | 9 | ProviderSendTurnInput, |
| 9 | ProviderSession, | 10 | ProviderSession, |
| 10 | ProviderSessionStartInput, | 11 | ProviderSessionStartInput, |
| ... | @@ -197,7 +198,11 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -197,7 +198,11 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 197 | throw new Error("Session is missing a thread id."); | 198 | throw new Error("Session is missing a thread id."); |
| 198 | } | 199 | } |
| 199 | 200 | ||
| 200 | const response = await this.sendRequest(context, "turn/start", { | 201 | const turnStartParams: { |
| 202 | threadId: string; | ||
| 203 | input: Array<{ type: "text"; text: string; text_elements: [] }>; | ||
| 204 | model?: string; | ||
| 205 | } = { | ||
| 201 | threadId: context.session.threadId, | 206 | threadId: context.session.threadId, |
| 202 | input: [ | 207 | input: [ |
| 203 | { | 208 | { |
| ... | @@ -206,7 +211,16 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -206,7 +211,16 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 206 | text_elements: [], | 211 | text_elements: [], |
| 207 | }, | 212 | }, |
| 208 | ], | 213 | ], |
| 209 | }); | 214 | }; |
| 215 | if (input.model) { | ||
| 216 | turnStartParams.model = input.model; | ||
| 217 | } | ||
| 218 | |||
| 219 | const response = await this.sendRequest( | ||
| 220 | context, | ||
| 221 | "turn/start", | ||
| 222 | turnStartParams, | ||
| 223 | ); | ||
| 210 | 224 | ||
| 211 | const turn = this.readObject(this.readObject(response), "turn"); | 225 | const turn = this.readObject(this.readObject(response), "turn"); |
| 212 | const turnId = this.readString(turn, "id"); | 226 | const turnId = this.readString(turn, "id"); |
| ... | @@ -225,6 +239,177 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -225,6 +239,177 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 225 | }; | 239 | }; |
| 226 | } | 240 | } |
| 227 | 241 | ||
| 242 | async listModels(cwd?: string): Promise<ProviderModel[]> { | ||
| 243 | const child = spawn("codex", ["app-server"], { | ||
| 244 | cwd, | ||
| 245 | env: process.env, | ||
| 246 | stdio: ["pipe", "pipe", "pipe"], | ||
| 247 | }); | ||
| 248 | const output = readline.createInterface({ input: child.stdout }); | ||
| 249 | const pending = new Map<PendingRequestKey, PendingRequest>(); | ||
| 250 | let nextRequestId = 1; | ||
| 251 | let exited = false; | ||
| 252 | let exitError: Error | null = null; | ||
| 253 | |||
| 254 | const cleanup = () => { | ||
| 255 | for (const request of pending.values()) { | ||
| 256 | clearTimeout(request.timeout); | ||
| 257 | } | ||
| 258 | pending.clear(); | ||
| 259 | output.close(); | ||
| 260 | if (!child.killed) { | ||
| 261 | child.kill(); | ||
| 262 | } | ||
| 263 | }; | ||
| 264 | |||
| 265 | const writeMessage = (message: unknown) => { | ||
| 266 | if (!child.stdin.writable) { | ||
| 267 | throw new Error("Cannot write to codex app-server stdin."); | ||
| 268 | } | ||
| 269 | child.stdin.write(`${JSON.stringify(message)}\n`); | ||
| 270 | }; | ||
| 271 | |||
| 272 | const sendRequest = async <TResponse>( | ||
| 273 | method: string, | ||
| 274 | params: unknown, | ||
| 275 | timeoutMs = 20_000, | ||
| 276 | ): Promise<TResponse> => { | ||
| 277 | if (exited) { | ||
| 278 | throw ( | ||
| 279 | exitError ?? | ||
| 280 | new Error("codex app-server exited before request could be sent.") | ||
| 281 | ); | ||
| 282 | } | ||
| 283 | |||
| 284 | const id = nextRequestId; | ||
| 285 | nextRequestId += 1; | ||
| 286 | |||
| 287 | const result = await new Promise<unknown>((resolve, reject) => { | ||
| 288 | const timeout = setTimeout(() => { | ||
| 289 | pending.delete(String(id)); | ||
| 290 | reject(new Error(`Timed out waiting for ${method}.`)); | ||
| 291 | }, timeoutMs); | ||
| 292 | |||
| 293 | pending.set(String(id), { | ||
| 294 | timeout, | ||
| 295 | resolve, | ||
| 296 | reject, | ||
| 297 | method, | ||
| 298 | }); | ||
| 299 | writeMessage({ id, method, params }); | ||
| 300 | }); | ||
| 301 | |||
| 302 | return result as TResponse; | ||
| 303 | }; | ||
| 304 | |||
| 305 | output.on("line", (line) => { | ||
| 306 | let parsed: unknown; | ||
| 307 | try { | ||
| 308 | parsed = JSON.parse(line); | ||
| 309 | } catch { | ||
| 310 | return; | ||
| 311 | } | ||
| 312 | |||
| 313 | if (this.isResponse(parsed)) { | ||
| 314 | const key = String(parsed.id); | ||
| 315 | const request = pending.get(key); | ||
| 316 | if (!request) { | ||
| 317 | return; | ||
| 318 | } | ||
| 319 | |||
| 320 | clearTimeout(request.timeout); | ||
| 321 | pending.delete(key); | ||
| 322 | |||
| 323 | if (parsed.error?.message) { | ||
| 324 | request.reject( | ||
| 325 | new Error( | ||
| 326 | `${request.method} failed: ${String(parsed.error.message)}`, | ||
| 327 | ), | ||
| 328 | ); | ||
| 329 | return; | ||
| 330 | } | ||
| 331 | |||
| 332 | request.resolve(parsed.result); | ||
| 333 | return; | ||
| 334 | } | ||
| 335 | |||
| 336 | if (this.isServerRequest(parsed)) { | ||
| 337 | writeMessage({ | ||
| 338 | id: parsed.id, | ||
| 339 | error: { | ||
| 340 | code: -32601, | ||
| 341 | message: `Unsupported server request: ${parsed.method}`, | ||
| 342 | }, | ||
| 343 | }); | ||
| 344 | } | ||
| 345 | }); | ||
| 346 | |||
| 347 | child.on("error", (error) => { | ||
| 348 | exited = true; | ||
| 349 | exitError = error; | ||
| 350 | for (const request of pending.values()) { | ||
| 351 | clearTimeout(request.timeout); | ||
| 352 | request.reject(error); | ||
| 353 | } | ||
| 354 | pending.clear(); | ||
| 355 | }); | ||
| 356 | |||
| 357 | child.on("exit", (code, signal) => { | ||
| 358 | exited = true; | ||
| 359 | if (!exitError && code !== 0) { | ||
| 360 | exitError = new Error( | ||
| 361 | `codex app-server exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`, | ||
| 362 | ); | ||
| 363 | } | ||
| 364 | for (const request of pending.values()) { | ||
| 365 | clearTimeout(request.timeout); | ||
| 366 | request.reject( | ||
| 367 | exitError ?? | ||
| 368 | new Error("codex app-server exited before request completed."), | ||
| 369 | ); | ||
| 370 | } | ||
| 371 | pending.clear(); | ||
| 372 | }); | ||
| 373 | |||
| 374 | try { | ||
| 375 | await sendRequest("initialize", { | ||
| 376 | clientInfo: { | ||
| 377 | name: "codething_desktop", | ||
| 378 | title: "CodeThing Desktop", | ||
| 379 | version: "0.1.0", | ||
| 380 | }, | ||
| 381 | capabilities: { | ||
| 382 | experimentalApi: false, | ||
| 383 | }, | ||
| 384 | }); | ||
| 385 | |||
| 386 | writeMessage({ method: "initialized" }); | ||
| 387 | |||
| 388 | const deduped = new Map<string, ProviderModel>(); | ||
| 389 | let cursor: string | null = null; | ||
| 390 | |||
| 391 | while (true) { | ||
| 392 | const response = await sendRequest<unknown>("model/list", { | ||
| 393 | cursor, | ||
| 394 | limit: 100, | ||
| 395 | }); | ||
| 396 | const parsed = this.parseModelListResponse(response); | ||
| 397 | for (const model of parsed.models) { | ||
| 398 | deduped.set(model.model, model); | ||
| 399 | } | ||
| 400 | |||
| 401 | if (!parsed.nextCursor) { | ||
| 402 | break; | ||
| 403 | } | ||
| 404 | cursor = parsed.nextCursor; | ||
| 405 | } | ||
| 406 | |||
| 407 | return Array.from(deduped.values()); | ||
| 408 | } finally { | ||
| 409 | cleanup(); | ||
| 410 | } | ||
| 411 | } | ||
| 412 | |||
| 228 | async interruptTurn(sessionId: string, turnId?: string): Promise<void> { | 413 | async interruptTurn(sessionId: string, turnId?: string): Promise<void> { |
| 229 | const context = this.requireSession(sessionId); | 414 | const context = this.requireSession(sessionId); |
| 230 | const effectiveTurnId = turnId ?? context.session.activeTurnId; | 415 | const effectiveTurnId = turnId ?? context.session.activeTurnId; |
| ... | @@ -622,6 +807,66 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -622,6 +807,66 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 622 | }; | 807 | }; |
| 623 | } | 808 | } |
| 624 | 809 | ||
| 810 | private parseModelListResponse(response: unknown): { | ||
| 811 | models: ProviderModel[]; | ||
| 812 | nextCursor: string | null; | ||
| 813 | } { | ||
| 814 | const object = this.readObject(response); | ||
| 815 | if (!object) { | ||
| 816 | throw new Error("model/list response was not an object."); | ||
| 817 | } | ||
| 818 | |||
| 819 | const data = object.data; | ||
| 820 | if (!Array.isArray(data)) { | ||
| 821 | throw new Error("model/list response missing data array."); | ||
| 822 | } | ||
| 823 | |||
| 824 | const models: ProviderModel[] = []; | ||
| 825 | for (const entry of data) { | ||
| 826 | const item = this.readObject(entry); | ||
| 827 | if (!item) { | ||
| 828 | continue; | ||
| 829 | } | ||
| 830 | |||
| 831 | const model = this.readString(item, "model"); | ||
| 832 | if (!model) { | ||
| 833 | continue; | ||
| 834 | } | ||
| 835 | |||
| 836 | const id = this.readString(item, "id") ?? model; | ||
| 837 | const displayName = this.readString(item, "displayName") ?? model; | ||
| 838 | const description = this.readString(item, "description"); | ||
| 839 | const isDefault = | ||
| 840 | typeof item.isDefault === "boolean" ? item.isDefault : undefined; | ||
| 841 | const rawUpgrade = item.upgrade; | ||
| 842 | const upgrade = | ||
| 843 | typeof rawUpgrade === "string" | ||
| 844 | ? rawUpgrade | ||
| 845 | : rawUpgrade === null | ||
| 846 | ? null | ||
| 847 | : undefined; | ||
| 848 | |||
| 849 | models.push({ | ||
| 850 | id, | ||
| 851 | model, | ||
| 852 | displayName, | ||
| 853 | ...(description ? { description } : {}), | ||
| 854 | ...(isDefault === undefined ? {} : { isDefault }), | ||
| 855 | ...(upgrade === undefined ? {} : { upgrade }), | ||
| 856 | }); | ||
| 857 | } | ||
| 858 | |||
| 859 | const rawCursor = object.nextCursor; | ||
| 860 | const nextCursor = | ||
| 861 | typeof rawCursor === "string" | ||
| 862 | ? rawCursor | ||
| 863 | : rawCursor === null || rawCursor === undefined | ||
| 864 | ? null | ||
| 865 | : null; | ||
| 866 | |||
| 867 | return { models, nextCursor }; | ||
| 868 | } | ||
| 869 | |||
| 625 | private isServerRequest(value: unknown): value is JsonRpcRequest { | 870 | private isServerRequest(value: unknown): value is JsonRpcRequest { |
| 626 | if (!value || typeof value !== "object") { | 871 | if (!value || typeof value !== "object") { |
| 627 | return false; | 872 | return false; |
apps/desktop/src/main.ts+9| ... | @@ -139,6 +139,15 @@ function registerIpcHandlers(): void { | ... | @@ -139,6 +139,15 @@ function registerIpcHandlers(): void { |
| 139 | ipcMain.handle(IPC_CHANNELS.providerSessionList, async () => { | 139 | ipcMain.handle(IPC_CHANNELS.providerSessionList, async () => { |
| 140 | return providerManager.listSessions(); | 140 | return providerManager.listSessions(); |
| 141 | }); | 141 | }); |
| 142 | |||
| 143 | ipcMain.handle( | ||
| 144 | IPC_CHANNELS.providerModelList, | ||
| 145 | async (_event, payload: unknown) => { | ||
| 146 | return providerManager.listModels( | ||
| 147 | payload as Parameters<typeof providerManager.listModels>[0], | ||
| 148 | ); | ||
| 149 | }, | ||
| 150 | ); | ||
| 142 | } | 151 | } |
| 143 | 152 | ||
| 144 | async function runTerminalCommand( | 153 | async function runTerminalCommand( |
apps/desktop/src/preload.ts+2| ... | @@ -41,6 +41,8 @@ const nativeApi: NativeApi = { | ... | @@ -41,6 +41,8 @@ const nativeApi: NativeApi = { |
| 41 | stopSession: (input) => | 41 | stopSession: (input) => |
| 42 | ipcRenderer.invoke(IPC_CHANNELS.providerSessionStop, input), | 42 | ipcRenderer.invoke(IPC_CHANNELS.providerSessionStop, input), |
| 43 | listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.providerSessionList), | 43 | listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.providerSessionList), |
| 44 | listModels: (input) => | ||
| 45 | ipcRenderer.invoke(IPC_CHANNELS.providerModelList, input), | ||
| 44 | onEvent: (callback) => { | 46 | onEvent: (callback) => { |
| 45 | const listener = (_event: Electron.IpcRendererEvent, payload: unknown) => | 47 | const listener = (_event: Electron.IpcRendererEvent, payload: unknown) => |
| 46 | callback(payload as Parameters<typeof callback>[0]); | 48 | callback(payload as Parameters<typeof callback>[0]); |
apps/desktop/src/providerManager.ts+14| ... | @@ -3,12 +3,15 @@ import { EventEmitter } from "node:events"; | ... | @@ -3,12 +3,15 @@ import { EventEmitter } from "node:events"; |
| 3 | import { | 3 | import { |
| 4 | type ProviderEvent, | 4 | type ProviderEvent, |
| 5 | type ProviderInterruptTurnInput, | 5 | type ProviderInterruptTurnInput, |
| 6 | type ProviderListModelsInput, | ||
| 7 | type ProviderModel, | ||
| 6 | type ProviderSendTurnInput, | 8 | type ProviderSendTurnInput, |
| 7 | type ProviderSession, | 9 | type ProviderSession, |
| 8 | type ProviderSessionStartInput, | 10 | type ProviderSessionStartInput, |
| 9 | type ProviderStopSessionInput, | 11 | type ProviderStopSessionInput, |
| 10 | type ProviderTurnStartResult, | 12 | type ProviderTurnStartResult, |
| 11 | providerInterruptTurnInputSchema, | 13 | providerInterruptTurnInputSchema, |
| 14 | providerListModelsInputSchema, | ||
| 12 | providerSendTurnInputSchema, | 15 | providerSendTurnInputSchema, |
| 13 | providerSessionStartInputSchema, | 16 | providerSessionStartInputSchema, |
| 14 | providerStopSessionInputSchema, | 17 | providerStopSessionInputSchema, |
| ... | @@ -63,6 +66,17 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { | ... | @@ -63,6 +66,17 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 63 | return this.codex.listSessions(); | 66 | return this.codex.listSessions(); |
| 64 | } | 67 | } |
| 65 | 68 | ||
| 69 | async listModels( | ||
| 70 | raw: ProviderListModelsInput = {}, | ||
| 71 | ): Promise<ProviderModel[]> { | ||
| 72 | const input = providerListModelsInputSchema.parse(raw); | ||
| 73 | if (input.provider !== "codex") { | ||
| 74 | throw new Error(`Provider '${input.provider}' is not implemented yet.`); | ||
| 75 | } | ||
| 76 | |||
| 77 | return this.codex.listModels(input.cwd); | ||
| 78 | } | ||
| 79 | |||
| 66 | stopAll(): void { | 80 | stopAll(): void { |
| 67 | this.codex.stopAll(); | 81 | this.codex.stopAll(); |
| 68 | } | 82 | } |
apps/renderer/src/components/ChatView.tsx+185-11| ... | @@ -1,22 +1,54 @@ | ... | @@ -1,22 +1,54 @@ |
| 1 | import { type FormEvent, useEffect, useMemo, useRef, useState } from "react"; | 1 | import type { ProviderModel } from "@acme/contracts"; |
| 2 | import { | ||
| 3 | type FormEvent, | ||
| 4 | type KeyboardEvent, | ||
| 5 | useEffect, | ||
| 6 | useMemo, | ||
| 7 | useRef, | ||
| 8 | useState, | ||
| 9 | } from "react"; | ||
| 2 | 10 | ||
| 3 | import { derivePhase, formatTimestamp, readNativeApi } from "../session-logic"; | 11 | import { derivePhase, formatTimestamp, readNativeApi } from "../session-logic"; |
| 4 | import { useStore } from "../store"; | 12 | import { useStore } from "../store"; |
| 5 | 13 | ||
| 14 | const DEFAULT_MODEL = "gpt-5.2-codex"; | ||
| 15 | |||
| 6 | export default function ChatView() { | 16 | export default function ChatView() { |
| 7 | const { state, dispatch } = useStore(); | 17 | const { state, dispatch } = useStore(); |
| 8 | const api = useMemo(() => readNativeApi(), []); | 18 | const api = useMemo(() => readNativeApi(), []); |
| 9 | const [prompt, setPrompt] = useState(""); | 19 | const [prompt, setPrompt] = useState(""); |
| 10 | const [isSending, setIsSending] = useState(false); | 20 | const [isSending, setIsSending] = useState(false); |
| 11 | const [isConnecting, setIsConnecting] = useState(false); | 21 | const [isConnecting, setIsConnecting] = useState(false); |
| 22 | const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); | ||
| 23 | const [isLoadingModels, setIsLoadingModels] = useState(false); | ||
| 24 | const [modelLoadError, setModelLoadError] = useState<string | null>(null); | ||
| 25 | const [availableModels, setAvailableModels] = useState<ProviderModel[]>([]); | ||
| 12 | const messagesEndRef = useRef<HTMLDivElement>(null); | 26 | const messagesEndRef = useRef<HTMLDivElement>(null); |
| 13 | const textareaRef = useRef<HTMLTextAreaElement>(null); | 27 | const textareaRef = useRef<HTMLTextAreaElement>(null); |
| 28 | const modelMenuRef = useRef<HTMLDivElement>(null); | ||
| 14 | 29 | ||
| 15 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); | 30 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); |
| 16 | const activeProject = state.projects.find( | 31 | const activeProject = state.projects.find( |
| 17 | (p) => p.id === activeThread?.projectId, | 32 | (p) => p.id === activeThread?.projectId, |
| 18 | ); | 33 | ); |
| 34 | const selectedModel = | ||
| 35 | activeThread?.model || activeProject?.model || DEFAULT_MODEL; | ||
| 19 | const phase = derivePhase(activeThread?.session ?? null); | 36 | const phase = derivePhase(activeThread?.session ?? null); |
| 37 | const modelOptions = useMemo(() => { | ||
| 38 | const deduped = new Map<string, ProviderModel>(); | ||
| 39 | for (const model of availableModels) { | ||
| 40 | deduped.set(model.model, model); | ||
| 41 | } | ||
| 42 | if (!deduped.has(selectedModel)) { | ||
| 43 | deduped.set(selectedModel, { | ||
| 44 | id: selectedModel, | ||
| 45 | model: selectedModel, | ||
| 46 | displayName: selectedModel, | ||
| 47 | description: "Current model for this thread.", | ||
| 48 | }); | ||
| 49 | } | ||
| 50 | return Array.from(deduped.values()); | ||
| 51 | }, [availableModels, selectedModel]); | ||
| 20 | 52 | ||
| 21 | // Auto-scroll on new messages | 53 | // Auto-scroll on new messages |
| 22 | const messageCount = activeThread?.messages.length ?? 0; | 54 | const messageCount = activeThread?.messages.length ?? 0; |
| ... | @@ -34,6 +66,67 @@ export default function ChatView() { | ... | @@ -34,6 +66,67 @@ export default function ChatView() { |
| 34 | ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; | 66 | ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; |
| 35 | }, [prompt]); | 67 | }, [prompt]); |
| 36 | 68 | ||
| 69 | useEffect(() => { | ||
| 70 | if (!isModelMenuOpen) return; | ||
| 71 | |||
| 72 | const handleClickOutside = (event: MouseEvent) => { | ||
| 73 | if (!modelMenuRef.current) return; | ||
| 74 | if ( | ||
| 75 | event.target instanceof Node && | ||
| 76 | !modelMenuRef.current.contains(event.target) | ||
| 77 | ) { | ||
| 78 | setIsModelMenuOpen(false); | ||
| 79 | } | ||
| 80 | }; | ||
| 81 | |||
| 82 | window.addEventListener("mousedown", handleClickOutside); | ||
| 83 | return () => { | ||
| 84 | window.removeEventListener("mousedown", handleClickOutside); | ||
| 85 | }; | ||
| 86 | }, [isModelMenuOpen]); | ||
| 87 | |||
| 88 | useEffect(() => { | ||
| 89 | if (!api || !state.activeThreadId) return; | ||
| 90 | |||
| 91 | let cancelled = false; | ||
| 92 | |||
| 93 | const loadModels = async () => { | ||
| 94 | setIsLoadingModels(true); | ||
| 95 | setModelLoadError(null); | ||
| 96 | try { | ||
| 97 | const models = await api.providers.listModels( | ||
| 98 | activeProject?.cwd ? { cwd: activeProject.cwd } : undefined, | ||
| 99 | ); | ||
| 100 | if (cancelled) return; | ||
| 101 | |||
| 102 | const sorted = [...models].sort((a, b) => { | ||
| 103 | if ((a.isDefault ?? false) && !(b.isDefault ?? false)) return -1; | ||
| 104 | if ((b.isDefault ?? false) && !(a.isDefault ?? false)) return 1; | ||
| 105 | return a.displayName.localeCompare(b.displayName); | ||
| 106 | }); | ||
| 107 | setAvailableModels(sorted); | ||
| 108 | } catch (err) { | ||
| 109 | if (cancelled) return; | ||
| 110 | setAvailableModels([]); | ||
| 111 | setModelLoadError( | ||
| 112 | err instanceof Error | ||
| 113 | ? err.message | ||
| 114 | : "Could not load available models from Codex.", | ||
| 115 | ); | ||
| 116 | } finally { | ||
| 117 | if (!cancelled) { | ||
| 118 | setIsLoadingModels(false); | ||
| 119 | } | ||
| 120 | } | ||
| 121 | }; | ||
| 122 | |||
| 123 | void loadModels(); | ||
| 124 | |||
| 125 | return () => { | ||
| 126 | cancelled = true; | ||
| 127 | }; | ||
| 128 | }, [api, activeProject?.cwd, state.activeThreadId]); | ||
| 129 | |||
| 37 | const ensureSession = async (): Promise<boolean> => { | 130 | const ensureSession = async (): Promise<boolean> => { |
| 38 | if (!api || !activeThread || !activeProject) return false; | 131 | if (!api || !activeThread || !activeProject) return false; |
| 39 | if (activeThread.session && activeThread.session.status !== "closed") | 132 | if (activeThread.session && activeThread.session.status !== "closed") |
| ... | @@ -44,7 +137,7 @@ export default function ChatView() { | ... | @@ -44,7 +137,7 @@ export default function ChatView() { |
| 44 | const session = await api.providers.startSession({ | 137 | const session = await api.providers.startSession({ |
| 45 | provider: "codex", | 138 | provider: "codex", |
| 46 | cwd: activeProject.cwd || undefined, | 139 | cwd: activeProject.cwd || undefined, |
| 47 | model: activeProject.model || undefined, | 140 | model: selectedModel || undefined, |
| 48 | }); | 141 | }); |
| 49 | dispatch({ | 142 | dispatch({ |
| 50 | type: "UPDATE_SESSION", | 143 | type: "UPDATE_SESSION", |
| ... | @@ -105,7 +198,11 @@ export default function ChatView() { | ... | @@ -105,7 +198,11 @@ export default function ChatView() { |
| 105 | 198 | ||
| 106 | setIsSending(true); | 199 | setIsSending(true); |
| 107 | try { | 200 | try { |
| 108 | await api.providers.sendTurn({ sessionId, input: trimmed }); | 201 | await api.providers.sendTurn({ |
| 202 | sessionId, | ||
| 203 | input: trimmed, | ||
| 204 | model: selectedModel || undefined, | ||
| 205 | }); | ||
| 109 | } catch (err) { | 206 | } catch (err) { |
| 110 | dispatch({ | 207 | dispatch({ |
| 111 | type: "SET_ERROR", | 208 | type: "SET_ERROR", |
| ... | @@ -125,7 +222,17 @@ export default function ChatView() { | ... | @@ -125,7 +222,17 @@ export default function ChatView() { |
| 125 | }); | 222 | }); |
| 126 | }; | 223 | }; |
| 127 | 224 | ||
| 128 | const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { | 225 | const onModelSelect = (model: string) => { |
| 226 | if (!activeThread) return; | ||
| 227 | dispatch({ | ||
| 228 | type: "SET_THREAD_MODEL", | ||
| 229 | threadId: activeThread.id, | ||
| 230 | model, | ||
| 231 | }); | ||
| 232 | setIsModelMenuOpen(false); | ||
| 233 | }; | ||
| 234 | |||
| 235 | const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => { | ||
| 129 | if (e.key === "Enter" && !e.shiftKey) { | 236 | if (e.key === "Enter" && !e.shiftKey) { |
| 130 | e.preventDefault(); | 237 | e.preventDefault(); |
| 131 | void onSend(e as unknown as FormEvent); | 238 | void onSend(e as unknown as FormEvent); |
| ... | @@ -255,10 +362,10 @@ export default function ChatView() { | ... | @@ -255,10 +362,10 @@ export default function ChatView() { |
| 255 | onSubmit={onSend} | 362 | onSubmit={onSend} |
| 256 | className="mx-auto flex max-w-3xl items-end gap-2" | 363 | className="mx-auto flex max-w-3xl items-end gap-2" |
| 257 | > | 364 | > |
| 258 | <div className="relative flex-1"> | 365 | <div className="flex-1 rounded-2xl border border-white/[0.1] bg-[#121214] px-3 py-3"> |
| 259 | <textarea | 366 | <textarea |
| 260 | ref={textareaRef} | 367 | ref={textareaRef} |
| 261 | className="w-full resize-none rounded-xl border border-white/[0.1] bg-white/[0.04] px-4 py-3 pr-12 font-mono text-sm text-[#e0e0e0] placeholder:text-[#a0a0a0]/30 focus:border-white/20 focus:outline-none" | 368 | className="w-full resize-none bg-transparent px-1 pb-1 font-mono text-sm text-[#e0e0e0] placeholder:text-[#a0a0a0]/30 focus:outline-none" |
| 262 | rows={1} | 369 | rows={1} |
| 263 | value={prompt} | 370 | value={prompt} |
| 264 | onChange={(e) => setPrompt(e.target.value)} | 371 | onChange={(e) => setPrompt(e.target.value)} |
| ... | @@ -270,11 +377,78 @@ export default function ChatView() { | ... | @@ -270,11 +377,78 @@ export default function ChatView() { |
| 270 | } | 377 | } |
| 271 | disabled={isSending || isConnecting} | 378 | disabled={isSending || isConnecting} |
| 272 | /> | 379 | /> |
| 273 | {activeProject && ( | 380 | <div className="mt-2 flex items-center justify-between gap-2"> |
| 274 | <span className="absolute right-3 bottom-2 text-[9px] text-[#a0a0a0]/25"> | 381 | <div className="relative" ref={modelMenuRef}> |
| 275 | {activeProject.model} | 382 | <button |
| 276 | </span> | 383 | type="button" |
| 277 | )} | 384 | className="inline-flex items-center gap-2 rounded-full border border-white/[0.1] bg-white/[0.04] px-3 py-1.5 text-xs text-[#d8d8d8] transition-colors duration-150 hover:bg-white/[0.08]" |
| 385 | onClick={() => setIsModelMenuOpen((open) => !open)} | ||
| 386 | > | ||
| 387 | <span className="max-w-[180px] truncate"> | ||
| 388 | {selectedModel} | ||
| 389 | </span> | ||
| 390 | <span className="text-[10px] text-[#a0a0a0]/70">▼</span> | ||
| 391 | </button> | ||
| 392 | {isModelMenuOpen && ( | ||
| 393 | <div className="absolute bottom-full left-0 z-20 mb-2 w-[340px] rounded-2xl border border-white/[0.1] bg-[#1b1b1d]/95 p-2 shadow-[0_16px_40px_rgba(0,0,0,0.55)] backdrop-blur"> | ||
| 394 | <p className="px-2 py-1 text-[11px] text-[#a0a0a0]/70"> | ||
| 395 | Select model | ||
| 396 | </p> | ||
| 397 | <div className="max-h-72 overflow-y-auto"> | ||
| 398 | {modelOptions.map((model) => { | ||
| 399 | const isSelected = model.model === selectedModel; | ||
| 400 | return ( | ||
| 401 | <button | ||
| 402 | key={model.id} | ||
| 403 | type="button" | ||
| 404 | className={`mb-0.5 flex w-full items-start justify-between gap-2 rounded-xl px-2 py-2 text-left transition-colors duration-150 ${ | ||
| 405 | isSelected | ||
| 406 | ? "bg-white/[0.08] text-white" | ||
| 407 | : "text-[#d4d4d4] hover:bg-white/[0.05]" | ||
| 408 | }`} | ||
| 409 | onClick={() => onModelSelect(model.model)} | ||
| 410 | > | ||
| 411 | <span className="min-w-0"> | ||
| 412 | <span className="block truncate text-sm"> | ||
| 413 | {model.displayName} | ||
| 414 | </span> | ||
| 415 | {model.description && ( | ||
| 416 | <span className="block pt-0.5 text-[10px] text-[#a0a0a0]/65"> | ||
| 417 | {model.description} | ||
| 418 | </span> | ||
| 419 | )} | ||
| 420 | </span> | ||
| 421 | <span | ||
| 422 | className={`pt-0.5 text-sm ${ | ||
| 423 | isSelected ? "text-white" : "text-transparent" | ||
| 424 | }`} | ||
| 425 | > | ||
| 426 | ✓ | ||
| 427 | </span> | ||
| 428 | </button> | ||
| 429 | ); | ||
| 430 | })} | ||
| 431 | </div> | ||
| 432 | {isLoadingModels && ( | ||
| 433 | <p className="px-2 pt-2 text-[10px] text-[#a0a0a0]/60"> | ||
| 434 | Refreshing available models... | ||
| 435 | </p> | ||
| 436 | )} | ||
| 437 | {modelLoadError && ( | ||
| 438 | <p className="px-2 pt-2 text-[10px] text-amber-200/80"> | ||
| 439 | Could not load full model list from Codex:{" "} | ||
| 440 | {modelLoadError} | ||
| 441 | </p> | ||
| 442 | )} | ||
| 443 | </div> | ||
| 444 | )} | ||
| 445 | </div> | ||
| 446 | {activeProject && ( | ||
| 447 | <span className="text-[10px] text-[#a0a0a0]/35"> | ||
| 448 | {activeProject.name} | ||
| 449 | </span> | ||
| 450 | )} | ||
| 451 | </div> | ||
| 278 | </div> | 452 | </div> |
| 279 | {phase === "running" ? ( | 453 | {phase === "running" ? ( |
| 280 | <button | 454 | <button |
apps/renderer/src/components/Sidebar.tsx+9-4| ... | @@ -2,6 +2,8 @@ import { useState } from "react"; | ... | @@ -2,6 +2,8 @@ import { useState } from "react"; |
| 2 | import { useStore } from "../store"; | 2 | import { useStore } from "../store"; |
| 3 | import type { Project } from "../types"; | 3 | import type { Project } from "../types"; |
| 4 | 4 | ||
| 5 | const DEFAULT_MODEL = "gpt-5.2-codex"; | ||
| 6 | |||
| 5 | function formatRelativeTime(iso: string): string { | 7 | function formatRelativeTime(iso: string): string { |
| 6 | const diff = Date.now() - new Date(iso).getTime(); | 8 | const diff = Date.now() - new Date(iso).getTime(); |
| 7 | const minutes = Math.floor(diff / 60_000); | 9 | const minutes = Math.floor(diff / 60_000); |
| ... | @@ -16,7 +18,7 @@ export default function Sidebar() { | ... | @@ -16,7 +18,7 @@ export default function Sidebar() { |
| 16 | const { state, dispatch } = useStore(); | 18 | const { state, dispatch } = useStore(); |
| 17 | const [addingProject, setAddingProject] = useState(false); | 19 | const [addingProject, setAddingProject] = useState(false); |
| 18 | const [newCwd, setNewCwd] = useState(""); | 20 | const [newCwd, setNewCwd] = useState(""); |
| 19 | const [newModel, setNewModel] = useState("gpt-5.1-codex"); | 21 | const [newModel, setNewModel] = useState(DEFAULT_MODEL); |
| 20 | 22 | ||
| 21 | const handleAddProject = () => { | 23 | const handleAddProject = () => { |
| 22 | const cwd = newCwd.trim(); | 24 | const cwd = newCwd.trim(); |
| ... | @@ -26,12 +28,12 @@ export default function Sidebar() { | ... | @@ -26,12 +28,12 @@ export default function Sidebar() { |
| 26 | id: crypto.randomUUID(), | 28 | id: crypto.randomUUID(), |
| 27 | name, | 29 | name, |
| 28 | cwd, | 30 | cwd, |
| 29 | model: newModel.trim() || "gpt-5.1-codex", | 31 | model: newModel.trim() || DEFAULT_MODEL, |
| 30 | expanded: true, | 32 | expanded: true, |
| 31 | }; | 33 | }; |
| 32 | dispatch({ type: "ADD_PROJECT", project }); | 34 | dispatch({ type: "ADD_PROJECT", project }); |
| 33 | setNewCwd(""); | 35 | setNewCwd(""); |
| 34 | setNewModel("gpt-5.1-codex"); | 36 | setNewModel(DEFAULT_MODEL); |
| 35 | setAddingProject(false); | 37 | setAddingProject(false); |
| 36 | }; | 38 | }; |
| 37 | 39 | ||
| ... | @@ -42,6 +44,9 @@ export default function Sidebar() { | ... | @@ -42,6 +44,9 @@ export default function Sidebar() { |
| 42 | id: crypto.randomUUID(), | 44 | id: crypto.randomUUID(), |
| 43 | projectId, | 45 | projectId, |
| 44 | title: "New thread", | 46 | title: "New thread", |
| 47 | model: | ||
| 48 | state.projects.find((p) => p.id === projectId)?.model ?? | ||
| 49 | DEFAULT_MODEL, | ||
| 45 | session: null, | 50 | session: null, |
| 46 | messages: [], | 51 | messages: [], |
| 47 | events: [], | 52 | events: [], |
| ... | @@ -183,7 +188,7 @@ export default function Sidebar() { | ... | @@ -183,7 +188,7 @@ export default function Sidebar() { |
| 183 | /> | 188 | /> |
| 184 | <input | 189 | <input |
| 185 | className="mb-2 w-full rounded-md border border-white/[0.1] bg-white/[0.04] px-2 py-1.5 font-mono text-xs text-[#e0e0e0] placeholder:text-[#a0a0a0]/30 focus:border-white/30 focus:outline-none" | 190 | className="mb-2 w-full rounded-md border border-white/[0.1] bg-white/[0.04] px-2 py-1.5 font-mono text-xs text-[#e0e0e0] placeholder:text-[#a0a0a0]/30 focus:border-white/30 focus:outline-none" |
| 186 | placeholder="model (default: gpt-5.1-codex)" | 191 | placeholder={`model (default: ${DEFAULT_MODEL})`} |
| 187 | value={newModel} | 192 | value={newModel} |
| 188 | onChange={(e) => setNewModel(e.target.value)} | 193 | onChange={(e) => setNewModel(e.target.value)} |
| 189 | onKeyDown={(e) => { | 194 | onKeyDown={(e) => { |
apps/renderer/src/store.ts+11-1| ... | @@ -27,7 +27,8 @@ type Action = | ... | @@ -27,7 +27,8 @@ type Action = |
| 27 | | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession } | 27 | | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession } |
| 28 | | { type: "PUSH_USER_MESSAGE"; threadId: string; id: string; text: string } | 28 | | { type: "PUSH_USER_MESSAGE"; threadId: string; id: string; text: string } |
| 29 | | { type: "SET_ERROR"; threadId: string; error: string | null } | 29 | | { type: "SET_ERROR"; threadId: string; error: string | null } |
| 30 | | { type: "SET_THREAD_TITLE"; threadId: string; title: string }; | 30 | | { type: "SET_THREAD_TITLE"; threadId: string; title: string } |
| 31 | | { type: "SET_THREAD_MODEL"; threadId: string; model: string }; | ||
| 31 | 32 | ||
| 32 | // ── State ──────────────────────────────────────────────────────────── | 33 | // ── State ──────────────────────────────────────────────────────────── |
| 33 | 34 | ||
| ... | @@ -160,6 +161,15 @@ function reducer(state: AppState, action: Action): AppState { | ... | @@ -160,6 +161,15 @@ function reducer(state: AppState, action: Action): AppState { |
| 160 | })), | 161 | })), |
| 161 | }; | 162 | }; |
| 162 | 163 | ||
| 164 | case "SET_THREAD_MODEL": | ||
| 165 | return { | ||
| 166 | ...state, | ||
| 167 | threads: updateThread(state.threads, action.threadId, (t) => ({ | ||
| 168 | ...t, | ||
| 169 | model: action.model, | ||
| 170 | })), | ||
| 171 | }; | ||
| 172 | |||
| 163 | default: | 173 | default: |
| 164 | return state; | 174 | return state; |
| 165 | } | 175 | } |
apps/renderer/src/types.ts+1| ... | @@ -22,6 +22,7 @@ export interface Thread { | ... | @@ -22,6 +22,7 @@ export interface Thread { |
| 22 | id: string; | 22 | id: string; |
| 23 | projectId: string; | 23 | projectId: string; |
| 24 | title: string; | 24 | title: string; |
| 25 | model: string; | ||
| 25 | session: ProviderSession | null; | 26 | session: ProviderSession | null; |
| 26 | messages: ChatMessage[]; | 27 | messages: ChatMessage[]; |
| 27 | events: ProviderEvent[]; | 28 | events: ProviderEvent[]; |
packages/contracts/src/ipc.ts+4| ... | @@ -2,6 +2,8 @@ import type { AgentConfig, AgentExit, OutputChunk } from "./agent"; | ... | @@ -2,6 +2,8 @@ import type { AgentConfig, AgentExit, OutputChunk } from "./agent"; |
| 2 | import type { | 2 | import type { |
| 3 | ProviderEvent, | 3 | ProviderEvent, |
| 4 | ProviderInterruptTurnInput, | 4 | ProviderInterruptTurnInput, |
| 5 | ProviderListModelsInput, | ||
| 6 | ProviderModel, | ||
| 5 | ProviderSendTurnInput, | 7 | ProviderSendTurnInput, |
| 6 | ProviderSession, | 8 | ProviderSession, |
| 7 | ProviderSessionStartInput, | 9 | ProviderSessionStartInput, |
| ... | @@ -27,6 +29,7 @@ export const IPC_CHANNELS = { | ... | @@ -27,6 +29,7 @@ export const IPC_CHANNELS = { |
| 27 | providerTurnInterrupt: "provider:turn:interrupt", | 29 | providerTurnInterrupt: "provider:turn:interrupt", |
| 28 | providerSessionStop: "provider:session:stop", | 30 | providerSessionStop: "provider:session:stop", |
| 29 | providerSessionList: "provider:session:list", | 31 | providerSessionList: "provider:session:list", |
| 32 | providerModelList: "provider:model:list", | ||
| 30 | providerEvent: "provider:event", | 33 | providerEvent: "provider:event", |
| 31 | } as const; | 34 | } as const; |
| 32 | 35 | ||
| ... | @@ -57,6 +60,7 @@ export interface NativeApi { | ... | @@ -57,6 +60,7 @@ export interface NativeApi { |
| 57 | interruptTurn: (input: ProviderInterruptTurnInput) => Promise<void>; | 60 | interruptTurn: (input: ProviderInterruptTurnInput) => Promise<void>; |
| 58 | stopSession: (input: ProviderStopSessionInput) => Promise<void>; | 61 | stopSession: (input: ProviderStopSessionInput) => Promise<void>; |
| 59 | listSessions: () => Promise<ProviderSession[]>; | 62 | listSessions: () => Promise<ProviderSession[]>; |
| 63 | listModels: (input?: ProviderListModelsInput) => Promise<ProviderModel[]>; | ||
| 60 | onEvent: (callback: (event: ProviderEvent) => void) => () => void; | 64 | onEvent: (callback: (event: ProviderEvent) => void) => () => void; |
| 61 | }; | 65 | }; |
| 62 | } | 66 | } |
packages/contracts/src/provider.test.ts+11-1| ... | @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; | ... | @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | providerEventSchema, | 4 | providerEventSchema, |
| 5 | providerListModelsInputSchema, | ||
| 5 | providerSendTurnInputSchema, | 6 | providerSendTurnInputSchema, |
| 6 | providerSessionStartInputSchema, | 7 | providerSessionStartInputSchema, |
| 7 | } from "./provider"; | 8 | } from "./provider"; |
| ... | @@ -16,12 +17,21 @@ describe("providerSessionStartInputSchema", () => { | ... | @@ -16,12 +17,21 @@ describe("providerSessionStartInputSchema", () => { |
| 16 | }); | 17 | }); |
| 17 | 18 | ||
| 18 | describe("providerSendTurnInputSchema", () => { | 19 | describe("providerSendTurnInputSchema", () => { |
| 19 | it("trims input text", () => { | 20 | it("trims input text and optional model", () => { |
| 20 | const parsed = providerSendTurnInputSchema.parse({ | 21 | const parsed = providerSendTurnInputSchema.parse({ |
| 21 | sessionId: "sess_1", | 22 | sessionId: "sess_1", |
| 22 | input: " summarize this repo ", | 23 | input: " summarize this repo ", |
| 24 | model: " gpt-5.2-codex ", | ||
| 23 | }); | 25 | }); |
| 24 | expect(parsed.input).toBe("summarize this repo"); | 26 | expect(parsed.input).toBe("summarize this repo"); |
| 27 | expect(parsed.model).toBe("gpt-5.2-codex"); | ||
| 28 | }); | ||
| 29 | }); | ||
| 30 | |||
| 31 | describe("providerListModelsInputSchema", () => { | ||
| 32 | it("defaults provider to codex", () => { | ||
| 33 | const parsed = providerListModelsInputSchema.parse({}); | ||
| 34 | expect(parsed.provider).toBe("codex"); | ||
| 25 | }); | 35 | }); |
| 26 | }); | 36 | }); |
| 27 | 37 |
packages/contracts/src/provider.ts+19| ... | @@ -47,6 +47,7 @@ export const providerSessionStartInputSchema = z.object({ | ... | @@ -47,6 +47,7 @@ export const providerSessionStartInputSchema = z.object({ |
| 47 | export const providerSendTurnInputSchema = z.object({ | 47 | export const providerSendTurnInputSchema = z.object({ |
| 48 | sessionId: z.string().min(1), | 48 | sessionId: z.string().min(1), |
| 49 | input: z.string().trim().min(1).max(120_000), | 49 | input: z.string().trim().min(1).max(120_000), |
| 50 | model: z.string().trim().min(1).optional(), | ||
| 50 | }); | 51 | }); |
| 51 | 52 | ||
| 52 | export const providerTurnStartResultSchema = z.object({ | 53 | export const providerTurnStartResultSchema = z.object({ |
| ... | @@ -63,6 +64,20 @@ export const providerStopSessionInputSchema = z.object({ | ... | @@ -63,6 +64,20 @@ export const providerStopSessionInputSchema = z.object({ |
| 63 | sessionId: z.string().min(1), | 64 | sessionId: z.string().min(1), |
| 64 | }); | 65 | }); |
| 65 | 66 | ||
| 67 | export const providerModelSchema = z.object({ | ||
| 68 | id: z.string().min(1), | ||
| 69 | model: z.string().min(1), | ||
| 70 | displayName: z.string().min(1), | ||
| 71 | description: z.string().min(1).optional(), | ||
| 72 | isDefault: z.boolean().optional(), | ||
| 73 | upgrade: z.string().min(1).nullable().optional(), | ||
| 74 | }); | ||
| 75 | |||
| 76 | export const providerListModelsInputSchema = z.object({ | ||
| 77 | provider: providerKindSchema.default("codex"), | ||
| 78 | cwd: z.string().min(1).optional(), | ||
| 79 | }); | ||
| 80 | |||
| 66 | export const providerEventKindSchema = z.enum([ | 81 | export const providerEventKindSchema = z.enum([ |
| 67 | "session", | 82 | "session", |
| 68 | "notification", | 83 | "notification", |
| ... | @@ -105,5 +120,9 @@ export type ProviderInterruptTurnInput = z.input< | ... | @@ -105,5 +120,9 @@ export type ProviderInterruptTurnInput = z.input< |
| 105 | export type ProviderStopSessionInput = z.input< | 120 | export type ProviderStopSessionInput = z.input< |
| 106 | typeof providerStopSessionInputSchema | 121 | typeof providerStopSessionInputSchema |
| 107 | >; | 122 | >; |
| 123 | export type ProviderModel = z.infer<typeof providerModelSchema>; | ||
| 124 | export type ProviderListModelsInput = z.input< | ||
| 125 | typeof providerListModelsInputSchema | ||
| 126 | >; | ||
| 108 | export type ProviderEventKind = z.infer<typeof providerEventKindSchema>; | 127 | export type ProviderEventKind = z.infer<typeof providerEventKindSchema>; |
| 109 | export type ProviderEvent = z.infer<typeof providerEventSchema>; | 128 | export type ProviderEvent = z.infer<typeof providerEventSchema>; |