diff --git a/.gitignore b/.gitignore index 246c31cac..6405f3c92 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,10 @@ dist-ssr CodexMonitor.zip .codex-worktrees/ .codexmonitor/ +.agent/ +.agents/ +.claude/ +.cursor/ public/assets/material-icons/ # Nix diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index 8c7d205ca..24e8176a4 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -1231,7 +1231,11 @@ impl DaemonState { codex_aux_core::codex_doctor_core(&self.app_settings, codex_bin, codex_args).await } - async fn generate_commit_message(&self, workspace_id: String) -> Result { + async fn generate_commit_message( + &self, + workspace_id: String, + commit_message_model_id: Option, + ) -> Result { let repo_root = git_ui_core::resolve_repo_root_for_workspace_core( &self.workspaces, workspace_id.clone(), @@ -1247,6 +1251,7 @@ impl DaemonState { workspace_id, &diff, &commit_message_prompt, + commit_message_model_id.as_deref(), |workspace_id, thread_id| { emit_background_thread_hide(&self.event_sink, workspace_id, thread_id); }, diff --git a/src-tauri/src/bin/codex_monitor_daemon/rpc/git.rs b/src-tauri/src/bin/codex_monitor_daemon/rpc/git.rs index 0a719ac67..388819b37 100644 --- a/src-tauri/src/bin/codex_monitor_daemon/rpc/git.rs +++ b/src-tauri/src/bin/codex_monitor_daemon/rpc/git.rs @@ -366,7 +366,11 @@ pub(super) async fn try_handle( Ok(value) => value, Err(err) => return Some(Err(err)), }; - let message = match state.generate_commit_message(workspace_id).await { + let commit_message_model_id = parse_optional_string(params, "commitMessageModelId"); + let message = match state + .generate_commit_message(workspace_id, commit_message_model_id) + .await + { Ok(value) => value, Err(err) => return Some(Err(err)), }; diff --git a/src-tauri/src/codex/mod.rs b/src-tauri/src/codex/mod.rs index 1db804cb1..7996bab50 100644 --- a/src-tauri/src/codex/mod.rs +++ b/src-tauri/src/codex/mod.rs @@ -875,6 +875,7 @@ pub(crate) async fn get_config_model( #[tauri::command] pub(crate) async fn generate_commit_message( workspace_id: String, + commit_message_model_id: Option, state: State<'_, AppState>, app: AppHandle, ) -> Result { @@ -883,7 +884,10 @@ pub(crate) async fn generate_commit_message( &*state, app, "generate_commit_message", - json!({ "workspaceId": workspace_id }), + json!({ + "workspaceId": workspace_id, + "commitMessageModelId": commit_message_model_id, + }), ) .await?; return serde_json::from_value(value).map_err(|err| err.to_string()); @@ -900,6 +904,7 @@ pub(crate) async fn generate_commit_message( workspace_id, &diff, &commit_message_prompt, + commit_message_model_id.as_deref(), |workspace_id, thread_id| { let _ = app.emit( "app-server-event", diff --git a/src-tauri/src/shared/codex_aux_core.rs b/src-tauri/src/shared/codex_aux_core.rs index 53e504bb0..5e3186057 100644 --- a/src-tauri/src/shared/codex_aux_core.rs +++ b/src-tauri/src/shared/codex_aux_core.rs @@ -395,6 +395,7 @@ pub(crate) async fn run_background_prompt_core( sessions: &Mutex>>, workspace_id: String, prompt: String, + model: Option<&str>, on_hide_thread: F, timeout_error: &str, turn_error_fallback: &str, @@ -452,13 +453,16 @@ where callbacks.insert(thread_id.clone(), tx); } - let turn_params = json!({ + let mut turn_params = json!({ "threadId": thread_id, "input": [{ "type": "text", "text": prompt }], "cwd": session.entry.path, "approvalPolicy": "never", "sandboxPolicy": { "type": "readOnly" }, }); + if let Some(model_id) = model { + turn_params["model"] = json!(model_id); + } let turn_result = session.send_request("turn/start", turn_params).await; let turn_result = match turn_result { Ok(result) => result, @@ -545,6 +549,7 @@ pub(crate) async fn generate_commit_message_core( workspace_id: String, diff: &str, template: &str, + model: Option<&str>, on_hide_thread: F, ) -> Result where @@ -555,6 +560,7 @@ where sessions, workspace_id, prompt, + model, on_hide_thread, "Timeout waiting for commit message generation", "Unknown error during commit message generation", @@ -581,6 +587,7 @@ where sessions, workspace_id, metadata_prompt, + None, on_hide_thread, "Timeout waiting for metadata generation", "Unknown error during metadata generation", @@ -609,6 +616,7 @@ where sessions, workspace_id, prompt, + None, on_hide_thread, "Timeout waiting for agent configuration generation", "Unknown error during agent configuration generation", diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 12d4e3576..eec548865 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -533,6 +533,8 @@ pub(crate) struct AppSettings { rename = "commitMessagePrompt" )] pub(crate) commit_message_prompt: String, + #[serde(default, rename = "commitMessageModelId")] + pub(crate) commit_message_model_id: Option, #[serde( default = "default_system_notifications_enabled", rename = "systemNotificationsEnabled" @@ -1140,6 +1142,7 @@ impl Default for AppSettings { preload_git_diffs: default_preload_git_diffs(), git_diff_ignore_whitespace_changes: default_git_diff_ignore_whitespace_changes(), commit_message_prompt: default_commit_message_prompt(), + commit_message_model_id: None, collaboration_modes_enabled: true, steer_enabled: true, pause_queued_messages_when_response_required: diff --git a/src/App.tsx b/src/App.tsx index be5e33140..94e29980e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -107,6 +107,7 @@ import { useWorkspaceLaunchScript } from "@app/hooks/useWorkspaceLaunchScript"; import { useWorkspaceLaunchScripts } from "@app/hooks/useWorkspaceLaunchScripts"; import { useWorktreeSetupScript } from "@app/hooks/useWorktreeSetupScript"; import { useGitCommitController } from "@app/hooks/useGitCommitController"; +import { effectiveCommitMessageModelId } from "@/features/git/utils/commitMessageModelSelection"; import { WorkspaceHome } from "@/features/workspaces/components/WorkspaceHome"; import { MobileServerSetupWizard } from "@/features/mobile/components/MobileServerSetupWizard"; import { useMobileServerSetup } from "@/features/mobile/hooks/useMobileServerSetup"; @@ -446,6 +447,10 @@ function MainApp() { setSelectedCodexArgsOverride, persistThreadCodexParams, }); + const commitMessageModelId = useMemo( + () => effectiveCommitMessageModelId(models, appSettings.commitMessageModelId), + [models, appSettings.commitMessageModelId], + ); const composerShortcuts = { modelShortcut: appSettings.composerModelShortcut, @@ -1522,6 +1527,7 @@ function MainApp() { activeWorkspace, activeWorkspaceId, activeWorkspaceIdRef, + commitMessageModelId, gitStatus, refreshGitStatus, refreshGitLog, diff --git a/src/features/app/hooks/useGitCommitController.ts b/src/features/app/hooks/useGitCommitController.ts index 9461f9aff..1c70b40a1 100644 --- a/src/features/app/hooks/useGitCommitController.ts +++ b/src/features/app/hooks/useGitCommitController.ts @@ -18,6 +18,7 @@ type GitCommitControllerOptions = { activeWorkspace: WorkspaceInfo | null; activeWorkspaceId: string | null; activeWorkspaceIdRef: RefObject; + commitMessageModelId: string | null; gitStatus: GitStatusState; refreshGitStatus: () => void; refreshGitLog?: () => void; @@ -53,6 +54,7 @@ export function useGitCommitController({ activeWorkspace, activeWorkspaceId, activeWorkspaceIdRef, + commitMessageModelId, gitStatus, refreshGitStatus, refreshGitLog, @@ -100,7 +102,7 @@ export function useGitCommitController({ setCommitMessageLoading(true); setCommitMessageError(null); try { - const message = await generateCommitMessage(workspaceId); + const message = await generateCommitMessage(workspaceId, commitMessageModelId); if (!shouldApplyCommitMessage(activeWorkspaceIdRef.current, workspaceId)) { return; } @@ -117,7 +119,7 @@ export function useGitCommitController({ setCommitMessageLoading(false); } } - }, [activeWorkspace, commitMessageLoading, activeWorkspaceIdRef]); + }, [activeWorkspace, commitMessageLoading, activeWorkspaceIdRef, commitMessageModelId]); useEffect(() => { setCommitMessage(""); diff --git a/src/features/git/utils/commitMessageModelSelection.test.ts b/src/features/git/utils/commitMessageModelSelection.test.ts new file mode 100644 index 000000000..509288693 --- /dev/null +++ b/src/features/git/utils/commitMessageModelSelection.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import type { ModelOption } from "@/types"; +import { effectiveCommitMessageModelId } from "./commitMessageModelSelection"; + +const MODELS: ModelOption[] = [ + { + id: "m-1", + model: "gpt-5.1", + displayName: "GPT-5.1", + description: "", + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + isDefault: false, + }, + { + id: "m-2", + model: "gpt-5.2", + displayName: "GPT-5.2", + description: "", + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + isDefault: true, + }, +]; + +describe("effectiveCommitMessageModelId", () => { + it("passes through null when no model is saved", () => { + expect(effectiveCommitMessageModelId(MODELS, null)).toBeNull(); + }); + + it("returns the saved model when it exists in the workspace", () => { + expect(effectiveCommitMessageModelId(MODELS, "gpt-5.1")).toBe("gpt-5.1"); + }); + + it("falls back to null when saved model is unavailable in the workspace", () => { + expect(effectiveCommitMessageModelId(MODELS, "gpt-4.1")).toBeNull(); + }); + + it("falls back to null when no models are available", () => { + expect(effectiveCommitMessageModelId([], "gpt-5.1")).toBeNull(); + }); +}); diff --git a/src/features/git/utils/commitMessageModelSelection.ts b/src/features/git/utils/commitMessageModelSelection.ts new file mode 100644 index 000000000..ef15e673f --- /dev/null +++ b/src/features/git/utils/commitMessageModelSelection.ts @@ -0,0 +1,15 @@ +import type { ModelOption } from "@/types"; + +/** + * Returns the saved commit-message model ID when available for the active + * workspace, or `null` to let the backend fall back to the workspace default. + * + * This is a pure runtime guard — it never mutates the persisted setting. + */ +export function effectiveCommitMessageModelId( + models: ModelOption[], + savedModelId: string | null, +): string | null { + if (savedModelId == null) return null; + return models.some((m) => m.model === savedModelId) ? savedModelId : null; +} diff --git a/src/features/models/utils/modelListResponse.test.ts b/src/features/models/utils/modelListResponse.test.ts new file mode 100644 index 000000000..6af73c3fd --- /dev/null +++ b/src/features/models/utils/modelListResponse.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { formatModelSlug, parseModelListResponse } from "./modelListResponse"; + +describe("formatModelSlug", () => { + it("capitalizes plain segments", () => { + expect(formatModelSlug("codex-mini")).toBe("Codex-Mini"); + }); + + it("uppercases known acronyms", () => { + expect(formatModelSlug("gpt-5.3-codex")).toBe("GPT-5.3-Codex"); + }); + + it("leaves version-like segments unchanged", () => { + expect(formatModelSlug("gpt-5.1-codex-max")).toBe("GPT-5.1-Codex-Max"); + }); + + it("handles a version-only slug", () => { + expect(formatModelSlug("gpt-5.2")).toBe("GPT-5.2"); + }); + it("is case-insensitive for acronym detection", () => { + expect(formatModelSlug("GPT-5.3-codex")).toBe("GPT-5.3-Codex"); + expect(formatModelSlug("Gpt-5.3-codex")).toBe("GPT-5.3-Codex"); + }); + + it("returns empty string for non-string input", () => { + expect(formatModelSlug(null)).toBe(""); + expect(formatModelSlug(undefined)).toBe(""); + expect(formatModelSlug(42)).toBe(""); + }); + + it("returns empty string for blank strings", () => { + expect(formatModelSlug("")).toBe(""); + expect(formatModelSlug(" ")).toBe(""); + }); + + it("handles a single segment", () => { + expect(formatModelSlug("codex")).toBe("Codex"); + expect(formatModelSlug("gpt")).toBe("GPT"); + }); +}); + +describe("parseModelListResponse", () => { + it("uses displayName when present", () => { + const response = { + result: { + data: [ + { id: "m1", model: "gpt-5.3-codex-spark", displayName: "GPT-5.3-Codex-Spark" }, + ], + }, + }; + const [model] = parseModelListResponse(response); + expect(model.displayName).toBe("GPT-5.3-Codex-Spark"); + }); + + it("formats the slug when displayName is missing", () => { + const response = { + result: { + data: [{ id: "m1", model: "gpt-5.3-codex" }], + }, + }; + const [model] = parseModelListResponse(response); + expect(model.displayName).toBe("GPT-5.3-Codex"); + }); + + it("formats the slug when displayName is an empty string", () => { + const response = { + result: { + data: [{ id: "m1", model: "gpt-5.1-codex-mini", displayName: "" }], + }, + }; + const [model] = parseModelListResponse(response); + expect(model.displayName).toBe("GPT-5.1-Codex-Mini"); + }); + + it("formats the slug when displayName equals the model slug", () => { + const response = { + result: { + data: [{ id: "m1", model: "gpt-5.3-codex", displayName: "gpt-5.3-codex" }], + }, + }; + const [model] = parseModelListResponse(response); + expect(model.displayName).toBe("GPT-5.3-Codex"); + }); + + it("preserves displayName when it differs from the slug", () => { + const response = { + result: { + data: [ + { id: "m1", model: "gpt-5.3-codex-spark", displayName: "GPT-5.3-Codex-Spark" }, + { id: "m2", model: "gpt-5.2-codex", displayName: "gpt-5.2-codex" }, + ], + }, + }; + const models = parseModelListResponse(response); + expect(models[0].displayName).toBe("GPT-5.3-Codex-Spark"); + expect(models[1].displayName).toBe("GPT-5.2-Codex"); + }); +}); diff --git a/src/features/models/utils/modelListResponse.ts b/src/features/models/utils/modelListResponse.ts index a5065a73c..12161da91 100644 --- a/src/features/models/utils/modelListResponse.ts +++ b/src/features/models/utils/modelListResponse.ts @@ -1,5 +1,30 @@ import type { ModelOption } from "../../../types"; +const UPPERCASE_SEGMENTS = new Set(["gpt"]); + +/** + * Formats a model slug like "gpt-5.3-codex" into "GPT-5.3-Codex". + * Known acronyms are uppercased, version-like segments are left as-is, + * and everything else is capitalized. + */ +export function formatModelSlug(slug: unknown): string { + if (typeof slug !== "string" || !slug.trim()) { + return ""; + } + return slug + .split("-") + .map((segment) => { + if (UPPERCASE_SEGMENTS.has(segment.toLowerCase())) { + return segment.toUpperCase(); + } + if (/^\d/.test(segment)) { + return segment; + } + return segment.charAt(0).toUpperCase() + segment.slice(1); + }) + .join("-"); +} + export function normalizeEffortValue(value: unknown): string | null { if (typeof value !== "string") { return null; @@ -81,10 +106,13 @@ export function parseModelListResponse(response: unknown): ModelOption[] { return null; } const record = item as Record; + const modelSlug = String(record.model ?? record.id ?? ""); + const rawDisplayName = String(record.displayName || record.display_name || ""); + const hasCustomDisplayName = rawDisplayName !== "" && rawDisplayName !== modelSlug; return { id: String(record.id ?? record.model ?? ""), - model: String(record.model ?? record.id ?? ""), - displayName: String(record.displayName ?? record.display_name ?? record.model ?? ""), + model: modelSlug, + displayName: hasCustomDisplayName ? rawDisplayName : (formatModelSlug(modelSlug) || modelSlug), description: String(record.description ?? ""), supportedReasoningEfforts: parseReasoningEfforts(record), defaultReasoningEffort: normalizeEffortValue( diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index 53d7985eb..c411eb72d 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -116,6 +116,7 @@ const baseSettings: AppSettings = { preloadGitDiffs: true, gitDiffIgnoreWhitespaceChanges: false, commitMessagePrompt: DEFAULT_COMMIT_MESSAGE_PROMPT, + commitMessageModelId: null, collaborationModesEnabled: true, steerEnabled: true, pauseQueuedMessagesWhenResponseRequired: true, diff --git a/src/features/settings/components/sections/SettingsGitSection.tsx b/src/features/settings/components/sections/SettingsGitSection.tsx index 8a6b1c166..38380a61b 100644 --- a/src/features/settings/components/sections/SettingsGitSection.tsx +++ b/src/features/settings/components/sections/SettingsGitSection.tsx @@ -1,8 +1,9 @@ -import type { AppSettings } from "@/types"; +import type { AppSettings, ModelOption } from "@/types"; type SettingsGitSectionProps = { appSettings: AppSettings; onUpdateAppSettings: (next: AppSettings) => Promise; + models: ModelOption[]; commitMessagePromptDraft: string; commitMessagePromptDirty: boolean; commitMessagePromptSaving: boolean; @@ -14,6 +15,7 @@ type SettingsGitSectionProps = { export function SettingsGitSection({ appSettings, onUpdateAppSettings, + models, commitMessagePromptDraft, commitMessagePromptDirty, commitMessagePromptSaving, @@ -103,6 +105,36 @@ export function SettingsGitSection({ + {models.length > 0 && ( +
+ +
+ The model used when generating commit messages. Leave on default to use the + workspace model. +
+ +
+ )} ); } diff --git a/src/features/settings/hooks/useAppSettings.ts b/src/features/settings/hooks/useAppSettings.ts index f13d949e6..a4caa4b16 100644 --- a/src/features/settings/hooks/useAppSettings.ts +++ b/src/features/settings/hooks/useAppSettings.ts @@ -180,6 +180,7 @@ function buildDefaultSettings(): AppSettings { preloadGitDiffs: true, gitDiffIgnoreWhitespaceChanges: false, commitMessagePrompt: DEFAULT_COMMIT_MESSAGE_PROMPT, + commitMessageModelId: null, collaborationModesEnabled: true, steerEnabled: true, pauseQueuedMessagesWhenResponseRequired: true, diff --git a/src/features/settings/hooks/useSettingsGitSection.ts b/src/features/settings/hooks/useSettingsGitSection.ts index 1713ed1e4..e6b47c9d2 100644 --- a/src/features/settings/hooks/useSettingsGitSection.ts +++ b/src/features/settings/hooks/useSettingsGitSection.ts @@ -1,15 +1,17 @@ import { useCallback, useEffect, useState } from "react"; -import type { AppSettings } from "@/types"; +import type { AppSettings, ModelOption } from "@/types"; import { DEFAULT_COMMIT_MESSAGE_PROMPT } from "@utils/commitMessagePrompt"; type UseSettingsGitSectionArgs = { appSettings: AppSettings; onUpdateAppSettings: (next: AppSettings) => Promise; + models: ModelOption[]; }; export type SettingsGitSectionProps = { appSettings: AppSettings; onUpdateAppSettings: (next: AppSettings) => Promise; + models: ModelOption[]; commitMessagePromptDraft: string; commitMessagePromptDirty: boolean; commitMessagePromptSaving: boolean; @@ -21,6 +23,7 @@ export type SettingsGitSectionProps = { export const useSettingsGitSection = ({ appSettings, onUpdateAppSettings, + models, }: UseSettingsGitSectionArgs): SettingsGitSectionProps => { const [commitMessagePromptDraft, setCommitMessagePromptDraft] = useState( appSettings.commitMessagePrompt, @@ -74,6 +77,7 @@ export const useSettingsGitSection = ({ return { appSettings, onUpdateAppSettings, + models, commitMessagePromptDraft, commitMessagePromptDirty, commitMessagePromptSaving, diff --git a/src/features/settings/hooks/useSettingsViewOrchestration.ts b/src/features/settings/hooks/useSettingsViewOrchestration.ts index 52201598f..f1f9526b4 100644 --- a/src/features/settings/hooks/useSettingsViewOrchestration.ts +++ b/src/features/settings/hooks/useSettingsViewOrchestration.ts @@ -185,11 +185,6 @@ export function useSettingsViewOrchestration({ onTestSystemNotification, }); - const gitSectionProps = useSettingsGitSection({ - appSettings, - onUpdateAppSettings, - }); - const serverSectionProps = useSettingsServerSection({ appSettings, onUpdateAppSettings, @@ -206,6 +201,12 @@ export function useSettingsViewOrchestration({ onUpdateWorkspaceSettings, }); + const gitSectionProps = useSettingsGitSection({ + appSettings, + onUpdateAppSettings, + models: codexSectionProps.defaultModels, + }); + const featuresSectionProps = useSettingsFeaturesSection({ appSettings, featureWorkspaceId, diff --git a/src/services/tauri.ts b/src/services/tauri.ts index 805121e41..728b0438b 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -1024,8 +1024,9 @@ export async function setThreadName( export async function generateCommitMessage( workspaceId: string, + commitMessageModelId: string | null, ): Promise { - return invoke("generate_commit_message", { workspaceId }); + return invoke("generate_commit_message", { workspaceId, commitMessageModelId }); } export type GeneratedAgentConfiguration = { diff --git a/src/types.ts b/src/types.ts index 13e08964e..521176e8b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -226,6 +226,7 @@ export type AppSettings = { preloadGitDiffs: boolean; gitDiffIgnoreWhitespaceChanges: boolean; commitMessagePrompt: string; + commitMessageModelId: string | null; collaborationModesEnabled: boolean; steerEnabled: boolean; pauseQueuedMessagesWhenResponseRequired: boolean;