refactoring: switched to gpt-5-nano for commit message generation

Remove commitMessageModel from persisted settings and defaults
Eliminate UI controls and state for commitMessageModel in GitSettings and config store
Update backend and persistence layers to stop reading or writing commitMessageModel
This commit is contained in:
Bohdan Triapitsyn
2026-01-19 03:42:34 +02:00
parent ce1622b1e2
commit 494da1315c
8 changed files with 129 additions and 173 deletions
+71 -24
View File
@@ -15,6 +15,44 @@ use tokio::fs;
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
use tokio::process::Command; use tokio::process::Command;
fn extract_json_object(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
let mut start = match trimmed.find('{') {
Some(index) => index,
None => return None,
};
while start < trimmed.len() {
let mut end = match trimmed[start..].find('}') {
Some(index) => start + index,
None => break,
};
loop {
let candidate = &trimmed[start..=end];
if serde_json::from_str::<Value>(candidate).is_ok() {
return Some(candidate.to_string());
}
end = match trimmed[end + 1..].find('}') {
Some(index) => end + 1 + index,
None => break,
};
}
start = match trimmed[start + 1..].find('{') {
Some(index) => start + 1 + index,
None => break,
};
}
None
}
const GIT_IDENTITY_STORAGE_FILE: &str = "git-identities.json"; const GIT_IDENTITY_STORAGE_FILE: &str = "git-identities.json";
const GIT_FILE_DIFF_TIMEOUT_MS: u64 = 15_000; const GIT_FILE_DIFF_TIMEOUT_MS: u64 = 15_000;
const GIT_LS_REMOTE_TIMEOUT_MS: u64 = 5_000; const GIT_LS_REMOTE_TIMEOUT_MS: u64 = 5_000;
@@ -2225,32 +2263,17 @@ Diff summary:
{}"#, {}"#,
diff_summaries diff_summaries
); );
let settings = state.settings().load().await.unwrap_or(serde_json::Value::Null);
let raw_model = settings.get("commitMessageModel")
.and_then(|v| v.as_str())
.unwrap_or("");
let model_candidate = raw_model let model = "gpt-5-nano";
.split('/')
.last()
.unwrap_or(raw_model)
.trim();
let model = if model_candidate.is_empty() {
"big-pickle"
} else {
model_candidate
};
// 3. Call API // 3. Call API
let client = Client::new(); let client = Client::new();
let res = client let res = client
.post("https://opencode.ai/zen/v1/chat/completions") .post("https://opencode.ai/zen/v1/responses")
.json(&serde_json::json!({ .json(&serde_json::json!({
"model": model, "model": model,
"messages": [{ "role": "user", "content": prompt }], "input": [{ "role": "user", "content": prompt }],
"max_tokens": 3000, "max_output_tokens": 1000,
"stream": false, "stream": false,
"reasoning": { "reasoning": {
"effort": "low" "effort": "low"
@@ -2265,8 +2288,12 @@ Diff summary:
} }
let body: serde_json::Value = res.json().await.map_err(|e| e.to_string())?; let body: serde_json::Value = res.json().await.map_err(|e| e.to_string())?;
let raw_content = body["choices"][0]["message"]["content"] let raw_content = body["output"]
.as_str() .as_array()
.and_then(|items| items.iter().find(|item| item["type"] == "message"))
.and_then(|item| item["content"].as_array())
.and_then(|content| content.iter().find(|entry| entry["type"] == "output_text"))
.and_then(|entry| entry["text"].as_str())
.unwrap_or("") .unwrap_or("")
.trim(); .trim();
@@ -2278,8 +2305,28 @@ Diff summary:
.trim_end_matches("```") .trim_end_matches("```")
.trim(); .trim();
let message: GeneratedCommitMessage = let extracted = extract_json_object(cleaned);
serde_json::from_str(cleaned).map_err(|e| format!("Failed to parse AI response: {}", e))?;
Ok(CommitMessageResponse { message }) let mut last_error: Option<String> = None;
if let Some(candidate) = extracted.as_deref() {
if candidate.starts_with('{') || candidate.starts_with('[') {
match serde_json::from_str::<GeneratedCommitMessage>(candidate) {
Ok(message) => return Ok(CommitMessageResponse { message }),
Err(err) => last_error = Some(err.to_string()),
}
}
}
if cleaned.starts_with('{') || cleaned.starts_with('[') {
match serde_json::from_str::<GeneratedCommitMessage>(cleaned) {
Ok(message) => return Ok(CommitMessageResponse { message }),
Err(err) => last_error = Some(err.to_string()),
}
}
Err(format!(
"Failed to parse AI response: {}",
last_error.unwrap_or_else(|| "unknown error".to_string())
))
} }
@@ -265,15 +265,6 @@ fn sanitize_settings_update(payload: &Value) -> Value {
result_obj.insert("defaultGitIdentityId".to_string(), json!(trimmed)); result_obj.insert("defaultGitIdentityId".to_string(), json!(trimmed));
} }
} }
if let Some(Value::String(s)) = obj.get("commitMessageModel") {
let trimmed = s.trim();
if trimmed.is_empty() {
result_obj.insert("commitMessageModel".to_string(), Value::Null);
} else {
result_obj.insert("commitMessageModel".to_string(), json!(trimmed));
}
}
// Boolean fields // Boolean fields
if let Some(Value::Bool(b)) = obj.get("gitmojiEnabled") { if let Some(Value::Bool(b)) = obj.get("gitmojiEnabled") {
result_obj.insert("gitmojiEnabled".to_string(), json!(b)); result_obj.insert("gitmojiEnabled".to_string(), json!(b));
@@ -1,64 +1,23 @@
import React from 'react'; import React from 'react';
import { RiInformationLine } from '@remixicon/react'; import { RiInformationLine } from '@remixicon/react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { ModelSelector } from '@/components/sections/agents/ModelSelector';
import { updateDesktopSettings } from '@/lib/persistence'; import { updateDesktopSettings } from '@/lib/persistence';
import { isDesktopRuntime, getDesktopSettings } from '@/lib/desktop'; import { isDesktopRuntime, getDesktopSettings } from '@/lib/desktop';
import { useConfigStore } from '@/stores/useConfigStore'; import { useConfigStore } from '@/stores/useConfigStore';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
const FALLBACK_PROVIDER_ID = 'opencode';
const FALLBACK_MODEL_ID = 'big-pickle';
const getDisplayModel = (
storedModel: string | undefined,
providers: Array<{ id: string; models: Array<{ id: string }> }>
): { providerId: string; modelId: string } => {
if (storedModel) {
const parts = storedModel.split('/');
if (parts.length === 2 && parts[0] && parts[1]) {
return { providerId: parts[0], modelId: parts[1] };
}
}
const fallbackProvider = providers.find(p => p.id === FALLBACK_PROVIDER_ID);
if (fallbackProvider?.models.some(m => m.id === FALLBACK_MODEL_ID)) {
return { providerId: FALLBACK_PROVIDER_ID, modelId: FALLBACK_MODEL_ID };
}
const firstProvider = providers[0];
if (firstProvider?.models[0]) {
return { providerId: firstProvider.id, modelId: firstProvider.models[0].id };
}
return { providerId: '', modelId: '' };
};
export const GitSettings: React.FC = () => { export const GitSettings: React.FC = () => {
const settingsCommitMessageModel = useConfigStore((state) => state.settingsCommitMessageModel);
const setSettingsCommitMessageModel = useConfigStore((state) => state.setSettingsCommitMessageModel);
const settingsGitmojiEnabled = useConfigStore((state) => state.settingsGitmojiEnabled); const settingsGitmojiEnabled = useConfigStore((state) => state.settingsGitmojiEnabled);
const setSettingsGitmojiEnabled = useConfigStore((state) => state.setSettingsGitmojiEnabled); const setSettingsGitmojiEnabled = useConfigStore((state) => state.setSettingsGitmojiEnabled);
const providers = useConfigStore((state) => state.providers);
const [isLoading, setIsLoading] = React.useState(true); const [isLoading, setIsLoading] = React.useState(true);
const opencodeProviders = React.useMemo(() => {
return providers.filter((provider) => provider.id === FALLBACK_PROVIDER_ID);
}, [providers]);
const parsedModel = React.useMemo(() => {
const effectiveStoredModel = settingsCommitMessageModel?.startsWith(`${FALLBACK_PROVIDER_ID}/`)
? settingsCommitMessageModel
: undefined;
return getDisplayModel(effectiveStoredModel, opencodeProviders);
}, [settingsCommitMessageModel, opencodeProviders]);
// Load current settings // Load current settings
React.useEffect(() => { React.useEffect(() => {
const loadSettings = async () => { const loadSettings = async () => {
try { try {
let data: { commitMessageModel?: string; gitmojiEnabled?: boolean } | null = null; let data: { gitmojiEnabled?: boolean } | null = null;
// 1. Desktop runtime (Tauri) // 1. Desktop runtime (Tauri)
if (isDesktopRuntime()) { if (isDesktopRuntime()) {
@@ -72,7 +31,6 @@ export const GitSettings: React.FC = () => {
const settings = result?.settings; const settings = result?.settings;
if (settings) { if (settings) {
data = { data = {
commitMessageModel: typeof settings.commitMessageModel === 'string' ? settings.commitMessageModel : undefined,
gitmojiEnabled: typeof (settings as Record<string, unknown>).gitmojiEnabled === 'boolean' gitmojiEnabled: typeof (settings as Record<string, unknown>).gitmojiEnabled === 'boolean'
? ((settings as Record<string, unknown>).gitmojiEnabled as boolean) ? ((settings as Record<string, unknown>).gitmojiEnabled as boolean)
: undefined, : undefined,
@@ -96,10 +54,6 @@ export const GitSettings: React.FC = () => {
} }
if (data) { if (data) {
const model = typeof data.commitMessageModel === 'string' && data.commitMessageModel.trim().length > 0
? data.commitMessageModel.trim()
: undefined;
setSettingsCommitMessageModel(model);
if (typeof data.gitmojiEnabled === 'boolean') { if (typeof data.gitmojiEnabled === 'boolean') {
setSettingsGitmojiEnabled(data.gitmojiEnabled); setSettingsGitmojiEnabled(data.gitmojiEnabled);
} }
@@ -112,20 +66,7 @@ export const GitSettings: React.FC = () => {
} }
}; };
loadSettings(); loadSettings();
}, [setSettingsCommitMessageModel, setSettingsGitmojiEnabled]); }, [setSettingsGitmojiEnabled]);
const handleModelChange = React.useCallback(async (providerId: string, modelId: string) => {
const newValue = providerId && modelId ? `${providerId}/${modelId}` : undefined;
setSettingsCommitMessageModel(newValue);
try {
await updateDesktopSettings({
commitMessageModel: newValue ?? '',
});
} catch (error) {
console.warn('Failed to save commit message model:', error);
}
}, [setSettingsCommitMessageModel]);
const handleGitmojiChange = React.useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => { const handleGitmojiChange = React.useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
const enabled = event.target.checked; const enabled = event.target.checked;
@@ -160,20 +101,6 @@ export const GitSettings: React.FC = () => {
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<fieldset className="flex flex-col gap-1.5">
<legend className="typography-ui-label text-muted-foreground">Model for generation</legend>
<ModelSelector
providerId={parsedModel.providerId}
modelId={parsedModel.modelId}
onChange={handleModelChange}
allowedProviderIds={[FALLBACK_PROVIDER_ID]}
/>
<p className="typography-meta text-muted-foreground mt-1">
This model will be used to analyze diffs and suggest commit messages.
{!settingsCommitMessageModel && <> Default: <span className="text-foreground">opencode/big-pickle</span></>}
</p>
</fieldset>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
<input <input
@@ -374,7 +374,7 @@ export const useChatScrollManager = ({
? (container.querySelector(`[data-message-id="${persistedAnchor.anchorId}"]`) as HTMLElement | null) ? (container.querySelector(`[data-message-id="${persistedAnchor.anchorId}"]`) as HTMLElement | null)
: null; : null;
const messageHeight = anchorElement?.offsetHeight ?? 0; const messageHeight = anchorElement?.offsetHeight ?? 0;
const restoredSpacerHeight = Math.max(0, persistedAnchor.spacerHeight - messageHeight); const restoredSpacerHeight = Math.max(0, persistedAnchor.spacerHeight - (messageHeight - 50));
flushSync(() => { flushSync(() => {
setAnchorId(persistedAnchor.anchorId); setAnchorId(persistedAnchor.anchorId);
-1
View File
@@ -59,7 +59,6 @@ export type DesktopSettings = {
defaultGitIdentityId?: string; // ''/undefined = unset, 'global' or profile id defaultGitIdentityId?: string; // ''/undefined = unset, 'global' or profile id
autoCreateWorktree?: boolean; autoCreateWorktree?: boolean;
queueModeEnabled?: boolean; queueModeEnabled?: boolean;
commitMessageModel?: string; // format: "provider/model"
gitmojiEnabled?: boolean; gitmojiEnabled?: boolean;
// User-added skills catalogs (persisted to ~/.config/openchamber/settings.json) // User-added skills catalogs (persisted to ~/.config/openchamber/settings.json)
-8
View File
@@ -47,11 +47,6 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
} else { } else {
localStorage.removeItem('pinnedDirectories'); localStorage.removeItem('pinnedDirectories');
} }
if (settings.commitMessageModel) {
localStorage.setItem('commitMessageModel', settings.commitMessageModel);
} else {
localStorage.removeItem('commitMessageModel');
}
if (typeof settings.gitmojiEnabled === 'boolean') { if (typeof settings.gitmojiEnabled === 'boolean') {
localStorage.setItem('gitmojiEnabled', String(settings.gitmojiEnabled)); localStorage.setItem('gitmojiEnabled', String(settings.gitmojiEnabled));
} else { } else {
@@ -271,9 +266,6 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (typeof candidate.autoCreateWorktree === 'boolean') { if (typeof candidate.autoCreateWorktree === 'boolean') {
result.autoCreateWorktree = candidate.autoCreateWorktree; result.autoCreateWorktree = candidate.autoCreateWorktree;
} }
if (typeof candidate.commitMessageModel === 'string' && candidate.commitMessageModel.length > 0) {
result.commitMessageModel = candidate.commitMessageModel;
}
if (typeof candidate.gitmojiEnabled === 'boolean') { if (typeof candidate.gitmojiEnabled === 'boolean') {
result.gitmojiEnabled = candidate.gitmojiEnabled; result.gitmojiEnabled = candidate.gitmojiEnabled;
} }
-15
View File
@@ -25,7 +25,6 @@ interface OpenChamberDefaults {
defaultVariant?: string; defaultVariant?: string;
defaultAgent?: string; defaultAgent?: string;
autoCreateWorktree?: boolean; autoCreateWorktree?: boolean;
commitMessageModel?: string;
gitmojiEnabled?: boolean; gitmojiEnabled?: boolean;
} }
@@ -39,7 +38,6 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
defaultVariant: settings?.defaultVariant, defaultVariant: settings?.defaultVariant,
defaultAgent: settings?.defaultAgent, defaultAgent: settings?.defaultAgent,
autoCreateWorktree: settings?.autoCreateWorktree, autoCreateWorktree: settings?.autoCreateWorktree,
commitMessageModel: settings?.commitMessageModel,
gitmojiEnabled: settings?.gitmojiEnabled, gitmojiEnabled: settings?.gitmojiEnabled,
}; };
} }
@@ -54,7 +52,6 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
const defaultModel = typeof data?.defaultModel === 'string' ? data.defaultModel.trim() : ''; const defaultModel = typeof data?.defaultModel === 'string' ? data.defaultModel.trim() : '';
const defaultVariant = typeof data?.defaultVariant === 'string' ? data.defaultVariant.trim() : ''; const defaultVariant = typeof data?.defaultVariant === 'string' ? data.defaultVariant.trim() : '';
const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : ''; const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : '';
const commitMessageModel = typeof data?.commitMessageModel === 'string' ? data.commitMessageModel.trim() : '';
const gitmojiEnabled = typeof data?.gitmojiEnabled === 'boolean' ? data.gitmojiEnabled : undefined; const gitmojiEnabled = typeof data?.gitmojiEnabled === 'boolean' ? data.gitmojiEnabled : undefined;
return { return {
@@ -62,7 +59,6 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
defaultVariant: defaultVariant.length > 0 ? defaultVariant : undefined, defaultVariant: defaultVariant.length > 0 ? defaultVariant : undefined,
defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined, defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined,
autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined, autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined,
commitMessageModel: commitMessageModel.length > 0 ? commitMessageModel : undefined,
gitmojiEnabled, gitmojiEnabled,
}; };
} }
@@ -83,7 +79,6 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
const defaultModel = typeof data?.defaultModel === 'string' ? data.defaultModel.trim() : ''; const defaultModel = typeof data?.defaultModel === 'string' ? data.defaultModel.trim() : '';
const defaultVariant = typeof data?.defaultVariant === 'string' ? data.defaultVariant.trim() : ''; const defaultVariant = typeof data?.defaultVariant === 'string' ? data.defaultVariant.trim() : '';
const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : ''; const defaultAgent = typeof data?.defaultAgent === 'string' ? data.defaultAgent.trim() : '';
const commitMessageModel = typeof data?.commitMessageModel === 'string' ? data.commitMessageModel.trim() : '';
const gitmojiEnabled = typeof data?.gitmojiEnabled === 'boolean' ? data.gitmojiEnabled : undefined; const gitmojiEnabled = typeof data?.gitmojiEnabled === 'boolean' ? data.gitmojiEnabled : undefined;
return { return {
@@ -91,7 +86,6 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
defaultVariant: defaultVariant.length > 0 ? defaultVariant : undefined, defaultVariant: defaultVariant.length > 0 ? defaultVariant : undefined,
defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined, defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined,
autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined, autoCreateWorktree: typeof data?.autoCreateWorktree === 'boolean' ? data.autoCreateWorktree : undefined,
commitMessageModel: commitMessageModel.length > 0 ? commitMessageModel : undefined,
gitmojiEnabled, gitmojiEnabled,
}; };
} catch { } catch {
@@ -384,7 +378,6 @@ interface ConfigStore {
settingsDefaultVariant: string | undefined; settingsDefaultVariant: string | undefined;
settingsDefaultAgent: string | undefined; settingsDefaultAgent: string | undefined;
settingsAutoCreateWorktree: boolean; settingsAutoCreateWorktree: boolean;
settingsCommitMessageModel: string | undefined; // format: "provider/model"
settingsGitmojiEnabled: boolean; settingsGitmojiEnabled: boolean;
activateDirectory: (directory: string | null | undefined) => Promise<void>; activateDirectory: (directory: string | null | undefined) => Promise<void>;
@@ -402,7 +395,6 @@ interface ConfigStore {
setSettingsDefaultVariant: (variant: string | undefined) => void; setSettingsDefaultVariant: (variant: string | undefined) => void;
setSettingsDefaultAgent: (agent: string | undefined) => void; setSettingsDefaultAgent: (agent: string | undefined) => void;
setSettingsAutoCreateWorktree: (enabled: boolean) => void; setSettingsAutoCreateWorktree: (enabled: boolean) => void;
setSettingsCommitMessageModel: (model: string | undefined) => void;
setSettingsGitmojiEnabled: (enabled: boolean) => void; setSettingsGitmojiEnabled: (enabled: boolean) => void;
saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => void; saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => void;
getAgentModelSelection: (agentName: string) => { providerId: string; modelId: string } | null; getAgentModelSelection: (agentName: string) => { providerId: string; modelId: string } | null;
@@ -447,7 +439,6 @@ export const useConfigStore = create<ConfigStore>()(
settingsDefaultVariant: undefined, settingsDefaultVariant: undefined,
settingsDefaultAgent: undefined, settingsDefaultAgent: undefined,
settingsAutoCreateWorktree: false, settingsAutoCreateWorktree: false,
settingsCommitMessageModel: undefined,
settingsGitmojiEnabled: false, settingsGitmojiEnabled: false,
activateDirectory: async (directory) => { activateDirectory: async (directory) => {
@@ -892,7 +883,6 @@ export const useConfigStore = create<ConfigStore>()(
settingsDefaultVariant: openChamberDefaults.defaultVariant, settingsDefaultVariant: openChamberDefaults.defaultVariant,
settingsDefaultAgent: openChamberDefaults.defaultAgent, settingsDefaultAgent: openChamberDefaults.defaultAgent,
settingsAutoCreateWorktree: openChamberDefaults.autoCreateWorktree ?? false, settingsAutoCreateWorktree: openChamberDefaults.autoCreateWorktree ?? false,
settingsCommitMessageModel: openChamberDefaults.commitMessageModel,
settingsGitmojiEnabled: openChamberDefaults.gitmojiEnabled ?? false, settingsGitmojiEnabled: openChamberDefaults.gitmojiEnabled ?? false,
directoryScoped: { directoryScoped: {
...state.directoryScoped, ...state.directoryScoped,
@@ -1314,10 +1304,6 @@ export const useConfigStore = create<ConfigStore>()(
set({ settingsAutoCreateWorktree: enabled }); set({ settingsAutoCreateWorktree: enabled });
}, },
setSettingsCommitMessageModel: (model) => {
set({ settingsCommitMessageModel: model });
},
setSettingsGitmojiEnabled: (enabled: boolean) => { setSettingsGitmojiEnabled: (enabled: boolean) => {
set({ settingsGitmojiEnabled: enabled }); set({ settingsGitmojiEnabled: enabled });
}, },
@@ -1427,7 +1413,6 @@ export const useConfigStore = create<ConfigStore>()(
settingsDefaultVariant: state.settingsDefaultVariant, settingsDefaultVariant: state.settingsDefaultVariant,
settingsDefaultAgent: state.settingsDefaultAgent, settingsDefaultAgent: state.settingsDefaultAgent,
settingsAutoCreateWorktree: state.settingsAutoCreateWorktree, settingsAutoCreateWorktree: state.settingsAutoCreateWorktree,
settingsCommitMessageModel: state.settingsCommitMessageModel,
settingsGitmojiEnabled: state.settingsGitmojiEnabled, settingsGitmojiEnabled: state.settingsGitmojiEnabled,
}), }),
}, },
+55 -40
View File
@@ -285,6 +285,31 @@ const stripJsonMarkdownWrapper = (value) => {
return trimmed; return trimmed;
}; };
const extractJsonObject = (value) => {
if (typeof value !== 'string') {
return null;
}
const source = value.trim();
if (!source) {
return null;
}
let start = source.indexOf('{');
while (start !== -1) {
let end = source.indexOf('}', start + 1);
while (end !== -1) {
const candidate = source.slice(start, end + 1);
try {
JSON.parse(candidate);
return candidate;
} catch {
end = source.indexOf('}', end + 1);
}
}
start = source.indexOf('{', start + 1);
}
return null;
};
const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR const OPENCHAMBER_DATA_DIR = process.env.OPENCHAMBER_DATA_DIR
? path.resolve(process.env.OPENCHAMBER_DATA_DIR) ? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
: path.join(os.homedir(), '.config', 'openchamber'); : path.join(os.homedir(), '.config', 'openchamber');
@@ -613,10 +638,6 @@ const sanitizeSettingsUpdate = (payload) => {
if (typeof candidate.autoCreateWorktree === 'boolean') { if (typeof candidate.autoCreateWorktree === 'boolean') {
result.autoCreateWorktree = candidate.autoCreateWorktree; result.autoCreateWorktree = candidate.autoCreateWorktree;
} }
if (typeof candidate.commitMessageModel === 'string') {
const trimmed = candidate.commitMessageModel.trim();
result.commitMessageModel = trimmed.length > 0 ? trimmed : undefined;
}
if (typeof candidate.gitmojiEnabled === 'boolean') { if (typeof candidate.gitmojiEnabled === 'boolean') {
result.gitmojiEnabled = candidate.gitmojiEnabled; result.gitmojiEnabled = candidate.gitmojiEnabled;
} }
@@ -847,7 +868,7 @@ const persistSettings = async (changes) => {
console.log(`[persistSettings] Successfully saved ${next.projects?.length || 0} projects to disk`); console.log(`[persistSettings] Successfully saved ${next.projects?.length || 0} projects to disk`);
return formatSettingsResponse(next); return formatSettingsResponse(next);
}); });
return persistSettingsLock; return persistSettingsLock;
}; };
@@ -1377,7 +1398,7 @@ async function restartOpenCode() {
} }
killProcessOnPort(portToKill); killProcessOnPort(portToKill);
// Brief delay to allow port release // Brief delay to allow port release
await new Promise((resolve) => setTimeout(resolve, 250)); await new Promise((resolve) => setTimeout(resolve, 250));
@@ -1388,7 +1409,7 @@ async function restartOpenCode() {
openCodePort = null; openCodePort = null;
syncToHmrState(); syncToHmrState();
} }
openCodeApiPrefixDetected = true; openCodeApiPrefixDetected = true;
openCodeApiPrefix = ''; openCodeApiPrefix = '';
if (openCodeApiDetectionTimer) { if (openCodeApiDetectionTimer) {
@@ -2523,7 +2544,7 @@ async function main(options = {}) {
}); });
// ============== SKILL ENDPOINTS ============== // ============== SKILL ENDPOINTS ==============
const { const {
getSkillSources, getSkillSources,
discoverSkills, discoverSkills,
@@ -2545,7 +2566,7 @@ async function main(options = {}) {
return res.status(400).json({ error }); return res.status(400).json({ error });
} }
const skills = discoverSkills(directory); const skills = discoverSkills(directory);
// Enrich with full sources info // Enrich with full sources info
const enrichedSkills = skills.map(skill => { const enrichedSkills = skills.map(skill => {
const sources = getSkillSources(skill.name, directory); const sources = getSkillSources(skill.name, directory);
@@ -2554,7 +2575,7 @@ async function main(options = {}) {
sources sources
}; };
}); });
res.json({ skills: enrichedSkills }); res.json({ skills: enrichedSkills });
} catch (error) { } catch (error) {
console.error('Failed to list skills:', error); console.error('Failed to list skills:', error);
@@ -2860,17 +2881,17 @@ async function main(options = {}) {
if (!directory) { if (!directory) {
return res.status(400).json({ error }); return res.status(400).json({ error });
} }
const sources = getSkillSources(skillName, directory); const sources = getSkillSources(skillName, directory);
if (!sources.md.exists || !sources.md.dir) { if (!sources.md.exists || !sources.md.dir) {
return res.status(404).json({ error: 'Skill not found' }); return res.status(404).json({ error: 'Skill not found' });
} }
const content = readSkillSupportingFile(sources.md.dir, filePath); const content = readSkillSupportingFile(sources.md.dir, filePath);
if (content === null) { if (content === null) {
return res.status(404).json({ error: 'File not found' }); return res.status(404).json({ error: 'File not found' });
} }
res.json({ path: filePath, content }); res.json({ path: filePath, content });
} catch (error) { } catch (error) {
console.error('Failed to read skill file:', error); console.error('Failed to read skill file:', error);
@@ -2942,14 +2963,14 @@ async function main(options = {}) {
if (!directory) { if (!directory) {
return res.status(400).json({ error }); return res.status(400).json({ error });
} }
const sources = getSkillSources(skillName, directory); const sources = getSkillSources(skillName, directory);
if (!sources.md.exists || !sources.md.dir) { if (!sources.md.exists || !sources.md.dir) {
return res.status(404).json({ error: 'Skill not found' }); return res.status(404).json({ error: 'Skill not found' });
} }
writeSkillSupportingFile(sources.md.dir, filePath, content || ''); writeSkillSupportingFile(sources.md.dir, filePath, content || '');
res.json({ res.json({
success: true, success: true,
message: `File ${filePath} saved successfully`, message: `File ${filePath} saved successfully`,
@@ -2969,14 +2990,14 @@ async function main(options = {}) {
if (!directory) { if (!directory) {
return res.status(400).json({ error }); return res.status(400).json({ error });
} }
const sources = getSkillSources(skillName, directory); const sources = getSkillSources(skillName, directory);
if (!sources.md.exists || !sources.md.dir) { if (!sources.md.exists || !sources.md.dir) {
return res.status(404).json({ error: 'Skill not found' }); return res.status(404).json({ error: 'Skill not found' });
} }
deleteSkillSupportingFile(sources.md.dir, filePath); deleteSkillSupportingFile(sources.md.dir, filePath);
res.json({ res.json({
success: true, success: true,
message: `File ${filePath} deleted successfully`, message: `File ${filePath} deleted successfully`,
@@ -3378,26 +3399,19 @@ async function main(options = {}) {
.join('\n\n'); .join('\n\n');
const prompt = `You are drafting git commit notes for this codebase. Respond in JSON of the shape {"subject": string, "highlights": string[]} (ONLY the JSON in response, no markdown wrappers or anything except JSON) with these rules:\n- subject follows our convention: type[optional-scope]: summary (examples: "feat: add diff virtualization", "fix(chat): restore enter key handling")\n- allowed types: feat, fix, chore, style, refactor, perf, docs, test, build, ci (choose the best match or fallback to chore)\n- summary must be imperative, concise, <= 70 characters, no trailing punctuation\n- scope is optional; include only when obvious from filenames/folders; do not invent scopes\n- focus on the most impactful user-facing change; if multiple capabilities ship together, align the subject with the dominant theme and use highlights to cover the other major outcomes\n- highlights array should contain 2-3 plain sentences (<= 90 chars each) that describe distinct features or UI changes users will notice (e.g. "Add per-file revert action in Changes list"). Avoid subjective benefit statements, marketing tone, repeating the subject, or referencing helper function names. Highlight additions such as new controls/buttons, new actions (e.g. revert), or stored state changes explicitly. Skip highlights if fewer than two meaningful points exist.\n- text must be plain (no markdown bullets); each highlight should start with an uppercase verb\n\nDiff summary:\n${diffSummaries}`; const prompt = `You are drafting git commit notes for this codebase. Respond in JSON of the shape {"subject": string, "highlights": string[]} (ONLY the JSON in response, no markdown wrappers or anything except JSON) with these rules:\n- subject follows our convention: type[optional-scope]: summary (examples: "feat: add diff virtualization", "fix(chat): restore enter key handling")\n- allowed types: feat, fix, chore, style, refactor, perf, docs, test, build, ci (choose the best match or fallback to chore)\n- summary must be imperative, concise, <= 70 characters, no trailing punctuation\n- scope is optional; include only when obvious from filenames/folders; do not invent scopes\n- focus on the most impactful user-facing change; if multiple capabilities ship together, align the subject with the dominant theme and use highlights to cover the other major outcomes\n- highlights array should contain 2-3 plain sentences (<= 90 chars each) that describe distinct features or UI changes users will notice (e.g. "Add per-file revert action in Changes list"). Avoid subjective benefit statements, marketing tone, repeating the subject, or referencing helper function names. Highlight additions such as new controls/buttons, new actions (e.g. revert), or stored state changes explicitly. Skip highlights if fewer than two meaningful points exist.\n- text must be plain (no markdown bullets); each highlight should start with an uppercase verb\n\nDiff summary:\n${diffSummaries}`;
const settings = await readSettingsFromDiskMigrated(); const model = 'gpt-5-nano';
const rawModel = typeof settings.commitMessageModel === 'string' ? settings.commitMessageModel.trim() : '';
const model = (() => {
if (!rawModel) return 'big-pickle';
const parts = rawModel.split('/').filter(Boolean);
const candidate = parts.length > 1 ? parts[parts.length - 1] : parts[0];
return candidate || 'big-pickle';
})();
const completionTimeout = createTimeoutSignal(LONG_REQUEST_TIMEOUT_MS); const completionTimeout = createTimeoutSignal(LONG_REQUEST_TIMEOUT_MS);
let response; let response;
try { try {
response = await fetch('https://opencode.ai/zen/v1/chat/completions', { response = await fetch('https://opencode.ai/zen/v1/responses', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
model, model,
messages: [{ role: 'user', content: prompt }], input: [{ role: 'user', content: prompt }],
max_tokens: 3000, max_output_tokens: 1000,
stream: false, stream: false,
reasoning: { reasoning: {
effort: 'low' effort: 'low'
@@ -3416,14 +3430,15 @@ async function main(options = {}) {
} }
const data = await response.json(); const data = await response.json();
const raw = data?.choices?.[0]?.message?.content?.trim(); const raw = data?.output?.find((item) => item?.type === 'message')?.content?.find((item) => item?.type === 'output_text')?.text?.trim();
if (!raw) { if (!raw) {
return res.status(502).json({ error: 'No commit message returned by generator' }); return res.status(502).json({ error: 'No commit message returned by generator' });
} }
const cleanedJson = stripJsonMarkdownWrapper(raw); const cleanedJson = stripJsonMarkdownWrapper(raw);
const candidates = [cleanedJson, raw].filter((candidate, index, array) => { const extractedJson = extractJsonObject(cleanedJson) || extractJsonObject(raw);
const candidates = [cleanedJson, extractedJson, raw].filter((candidate, index, array) => {
return candidate && array.indexOf(candidate) === index; return candidate && array.indexOf(candidate) === index;
}); });
@@ -4210,14 +4225,14 @@ async function main(options = {}) {
} }
const dirents = await fsPromises.readdir(resolvedPath, { withFileTypes: true }); const dirents = await fsPromises.readdir(resolvedPath, { withFileTypes: true });
// Get gitignored paths if requested // Get gitignored paths if requested
let ignoredPaths = new Set(); let ignoredPaths = new Set();
if (respectGitignore) { if (respectGitignore) {
try { try {
// Get all entry paths to check (relative to resolvedPath for git check-ignore) // Get all entry paths to check (relative to resolvedPath for git check-ignore)
const pathsToCheck = dirents.map((d) => d.name); const pathsToCheck = dirents.map((d) => d.name);
if (pathsToCheck.length > 0) { if (pathsToCheck.length > 0) {
try { try {
// Use git check-ignore with paths as arguments // Use git check-ignore with paths as arguments
@@ -4227,13 +4242,13 @@ async function main(options = {}) {
cwd: resolvedPath, cwd: resolvedPath,
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}); });
let stdout = ''; let stdout = '';
child.stdout.on('data', (data) => { stdout += data.toString(); }); child.stdout.on('data', (data) => { stdout += data.toString(); });
child.on('close', () => resolve(stdout)); child.on('close', () => resolve(stdout));
child.on('error', () => resolve('')); child.on('error', () => resolve(''));
}); });
result.split('\n').filter(Boolean).forEach((name) => { result.split('\n').filter(Boolean).forEach((name) => {
const fullPath = path.join(resolvedPath, name.trim()); const fullPath = path.join(resolvedPath, name.trim());
ignoredPaths.add(fullPath); ignoredPaths.add(fullPath);
@@ -4246,16 +4261,16 @@ async function main(options = {}) {
// If git is not available, continue without gitignore filtering // If git is not available, continue without gitignore filtering
} }
} }
const entries = await Promise.all( const entries = await Promise.all(
dirents.map(async (dirent) => { dirents.map(async (dirent) => {
const entryPath = path.join(resolvedPath, dirent.name); const entryPath = path.join(resolvedPath, dirent.name);
// Skip gitignored entries // Skip gitignored entries
if (respectGitignore && ignoredPaths.has(entryPath)) { if (respectGitignore && ignoredPaths.has(entryPath)) {
return null; return null;
} }
let isDirectory = dirent.isDirectory(); let isDirectory = dirent.isDirectory();
const isSymbolicLink = dirent.isSymbolicLink(); const isSymbolicLink = dirent.isSymbolicLink();