diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index 890e04840..6938c3079 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -18,6 +18,7 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; import { useQueryStore } from "@/stores/queryStore"; import { useToast } from "@/composables/useToast"; import { buildAiContext, runAgentStream, type AiAction } from "@/lib/ai"; +import { formatAiModelOption } from "@/lib/aiModelPresentation"; import type { AgentEvent } from "@/lib/tauri"; import { buildAiAgentPlan } from "@/lib/aiAgentPlan"; import { buildAiAgentStepItems, type AiAgentStepItem, type AiAgentStepTone } from "@/lib/aiAgentStepPresentation"; @@ -136,6 +137,14 @@ function displayModelName(modelId: string) { return modelOptions.value.find((model) => model.id === modelId)?.displayName || modelId; } +function modelOptionPresentation(modelId: string, label = displayModelName(modelId)) { + return formatAiModelOption(label, modelId); +} + +function modelOptionSecondary(modelId: string, label = displayModelName(modelId)) { + return modelOptionPresentation(modelId, label).secondary; +} + /** Deferred context compaction info; applied after stream ends to avoid shifting assistantIdx. */ const pendingCompaction = ref<{ summary: string; compactedMessages: number } | null>(null); @@ -1300,7 +1309,7 @@ const messageRenderer = computed(() => { :display-name="displayModelName" trigger-class="min-w-0 w-auto max-w-[220px] shrink justify-end rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground" content-class="w-72" - item-class="text-xs px-2" + item-class="h-auto min-h-8 px-2 py-1.5 text-xs" @update:model-value="handleModelSelect" @update:open="(open: boolean) => open && fetchModelOptions()" > @@ -1308,9 +1317,9 @@ const messageRenderer = computed(() => { {{ loading ? t("ai.loadingModels") : label }} diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 333a78aaa..c9275c975 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -37,6 +37,7 @@ import { type CustomTheme, } from "@/stores/settingsStore"; import { loadEditorTheme, editorFontTheme } from "@/lib/editorThemes"; +import { formatAiModelOption } from "@/lib/aiModelPresentation"; import ThemeCustomizerDialog from "./ThemeCustomizerDialog.vue"; import { isTauriRuntime } from "@/lib/tauriRuntime"; import { useTheme } from "@/composables/useTheme"; @@ -1594,6 +1595,14 @@ function displayAiModelName(modelId: string): string { return aiModelOptions.value.find((model) => model.id === modelId)?.displayName || modelId; } +function aiModelOptionPresentation(modelId: string, label = displayAiModelName(modelId)) { + return formatAiModelOption(label, modelId); +} + +function aiModelOptionSecondary(modelId: string, label = displayAiModelName(modelId)) { + return aiModelOptionPresentation(modelId, label).secondary; +} + async function aiRefreshModels() { if (aiModelLoading.value) return; if (!aiModelListSupported.value) { @@ -2985,6 +2994,7 @@ watch( :display-name="displayAiModelName" trigger-class="h-8 min-w-[104px] max-w-[150px] shrink-0 border border-border bg-background px-2 text-xs shadow-none hover:bg-muted/50" content-class="w-72" + item-class="h-auto min-h-8 py-1.5" @update:model-value="aiSelectModel" @update:open="onAiModelListOpen" > @@ -2992,9 +3002,9 @@ watch( {{ loading ? t("ai.loadingModels") : t("ai.browseModels") }} diff --git a/apps/desktop/src/lib/__tests__/aiModelPresentation.spec.ts b/apps/desktop/src/lib/__tests__/aiModelPresentation.spec.ts new file mode 100644 index 000000000..b0b337c93 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/aiModelPresentation.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { formatAiModelOption, shouldShowAiModelSecondaryLabel } from "@/lib/aiModelPresentation"; + +describe("shouldShowAiModelSecondaryLabel", () => { + it("hides secondary text when display name only changes case", () => { + expect(shouldShowAiModelSecondaryLabel("GPT-5.5", "gpt-5.5")).toBe(false); + }); + + it("hides secondary text when display name only changes separators", () => { + expect(shouldShowAiModelSecondaryLabel("GPT-5.4-Mini", "gpt-5.4-mini")).toBe(false); + }); + + it("hides secondary text for exact matches", () => { + expect(shouldShowAiModelSecondaryLabel("gpt-5.3-codex", "gpt-5.3-codex")).toBe(false); + }); + + it("keeps secondary text when provider display name is meaningfully different", () => { + expect(shouldShowAiModelSecondaryLabel("Claude Sonnet 4", "claude-sonnet-4-20250514")).toBe(true); + }); +}); + +describe("formatAiModelOption", () => { + it("returns a single-line Codex-style model option", () => { + expect(formatAiModelOption("GPT-5.5", "gpt-5.5")).toEqual({ primary: "GPT-5.5", secondary: undefined }); + }); + + it("returns model id as secondary text only when useful", () => { + expect(formatAiModelOption("Claude Sonnet 4", "claude-sonnet-4-20250514")).toEqual({ + primary: "Claude Sonnet 4", + secondary: "claude-sonnet-4-20250514", + }); + }); +}); diff --git a/apps/desktop/src/lib/aiModelPresentation.ts b/apps/desktop/src/lib/aiModelPresentation.ts new file mode 100644 index 000000000..94c8c8ce9 --- /dev/null +++ b/apps/desktop/src/lib/aiModelPresentation.ts @@ -0,0 +1,25 @@ +export interface AiModelPresentation { + primary: string; + secondary?: string; +} + +function compactModelName(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[\s_-]+/g, ""); +} + +export function shouldShowAiModelSecondaryLabel(label: string, modelId: string): boolean { + const normalizedLabel = compactModelName(label); + const normalizedId = compactModelName(modelId); + if (!normalizedLabel || !normalizedId || normalizedLabel === normalizedId) return false; + return true; +} + +export function formatAiModelOption(label: string, modelId: string): AiModelPresentation { + return { + primary: label || modelId, + secondary: shouldShowAiModelSecondaryLabel(label, modelId) ? modelId : undefined, + }; +} diff --git a/crates/dbx-core/src/ai_cli_agent.rs b/crates/dbx-core/src/ai_cli_agent.rs index 5f5f43c65..1c2caa876 100644 --- a/crates/dbx-core/src/ai_cli_agent.rs +++ b/crates/dbx-core/src/ai_cli_agent.rs @@ -29,6 +29,7 @@ pub enum CliAgentJsonlDialect { pub struct CliAgentProcessSpec { pub command: CliAgentCommandSpec, + pub env: Vec<(String, String)>, pub dialect: CliAgentJsonlDialect, pub classify_spawn_error: fn(&str) -> String, pub classify_run_error: fn(&str) -> String, @@ -327,6 +328,7 @@ pub async fn run_cli_jsonl_agent( ) -> Result { let mut child = Command::new(&spec.command.program) .args(&spec.command.args) + .envs(spec.env.iter().map(|(key, value)| (key.as_str(), value.as_str()))) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() @@ -439,6 +441,7 @@ mod tests { let spec = CliAgentProcessSpec { command: CliAgentCommandSpec { program: "sh".to_string(), args: vec!["-c".to_string(), script] }, + env: Vec::new(), dialect: CliAgentJsonlDialect::CodexExec, classify_spawn_error, classify_run_error, diff --git a/crates/dbx-core/src/ai_codex_cli.rs b/crates/dbx-core/src/ai_codex_cli.rs index 2c3ce0508..dec694b8c 100644 --- a/crates/dbx-core/src/ai_codex_cli.rs +++ b/crates/dbx-core/src/ai_codex_cli.rs @@ -6,11 +6,16 @@ use crate::ai_cli_agent::{ CliAgentJsonlDialect, CliAgentProcessSpec, CliAgentRunOptions, }; use serde_json::Value; +use std::collections::BTreeSet; +use std::env; +use std::path::{Path, PathBuf}; +use std::process::Stdio; use std::time::Instant; use tokio::process::Command; use tokio::sync::Notify; const DEFAULT_CODEX_MODELS: &[&str] = &["default", "gpt-5.5", "gpt-5.4-mini"]; +const CODEX_PATH_MARKER: &str = "__DBX_CODEX_PATH__"; pub type CodexRunOptions = CliAgentRunOptions; pub type CodexCommandSpec = CliAgentCommandSpec; @@ -19,6 +24,209 @@ fn codex_program(config: &AiConfig) -> String { config.codex_cli_path.as_deref().map(str::trim).filter(|path| !path.is_empty()).unwrap_or("codex").to_string() } +async fn resolve_codex_command(config: &AiConfig) -> CodexCommandSpec { + let configured = codex_program(config); + if is_path_like_program(&configured) { + return CodexCommandSpec { program: expand_tilde(&configured), args: Vec::new() }; + } + if let Some(path) = resolve_program_path(&configured).await { + CodexCommandSpec { program: path, args: Vec::new() } + } else { + CodexCommandSpec { program: configured, args: Vec::new() } + } +} + +fn command_env(command: &CodexCommandSpec) -> Vec<(String, String)> { + command.parent_dir().map(|dir| ("PATH".to_string(), merged_path_with_dir(&dir))).into_iter().collect() +} + +trait CommandParentDir { + fn parent_dir(&self) -> Option; +} + +impl CommandParentDir for CodexCommandSpec { + fn parent_dir(&self) -> Option { + Path::new(&self.program) + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .map(|parent| parent.to_string_lossy().to_string()) + } +} + +async fn resolve_program_path(program: &str) -> Option { + if let Some(path) = direct_program_path(program) { + return Some(path); + } + if let Some(path) = common_program_path(program) { + return Some(path); + } + shell_program_path(program).await +} + +fn direct_program_path(program: &str) -> Option { + let path = Path::new(program); + if path.is_absolute() && path.is_file() { + Some(path.to_string_lossy().to_string()) + } else { + None + } +} + +fn common_program_path(program: &str) -> Option { + common_executable_dirs() + .into_iter() + .flat_map(|dir| program_path_candidates(&dir, program)) + .find(|path| path.is_file()) + .map(|path| path.to_string_lossy().to_string()) +} + +#[cfg(not(windows))] +fn program_path_candidates(dir: &Path, program: &str) -> Vec { + vec![dir.join(program)] +} + +#[cfg(windows)] +fn program_path_candidates(dir: &Path, program: &str) -> Vec { + let path = Path::new(program); + if path.extension().is_some() { + return vec![dir.join(program)]; + } + ["", ".cmd", ".exe", ".bat", ".ps1"].iter().map(|extension| dir.join(format!("{program}{extension}"))).collect() +} + +#[cfg(not(windows))] +async fn shell_program_path(program: &str) -> Option { + let script = shell_resolve_script(program); + let mut command = Command::new(user_shell()); + command.args(user_shell_args(&script)); + command.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()); + let output = command.output().await.ok()?; + output.status.success().then_some(())?; + let stdout = String::from_utf8_lossy(&output.stdout); + stdout + .find(CODEX_PATH_MARKER) + .and_then(|index| { + stdout[index + CODEX_PATH_MARKER.len()..].lines().map(str::trim).find(|line| !line.is_empty()) + }) + .filter(|path| Path::new(path).is_file()) + .map(ToString::to_string) +} + +#[cfg(windows)] +async fn shell_program_path(program: &str) -> Option { + let script = format!("(Get-Command {} -ErrorAction SilentlyContinue).Source", windows_shell_quote(program)); + let mut command = Command::new("powershell.exe"); + command.args(["-NoProfile", "-Command", &script]); + command.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()); + let output = command.output().await.ok()?; + output.status.success().then_some(())?; + let stdout = String::from_utf8_lossy(&output.stdout); + stdout + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .filter(|path| Path::new(path).is_file()) + .map(ToString::to_string) +} + +#[cfg(windows)] +fn windows_shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +fn is_path_like_program(program: &str) -> bool { + program.contains('/') || program.contains('\\') || program.starts_with('~') +} + +fn expand_tilde(path: &str) -> String { + crate::path_utils::expand_tilde(path) +} + +fn common_executable_dirs() -> Vec { + let mut dirs = Vec::new(); + if let Ok(path) = env::var("PATH") { + dirs.extend(env::split_paths(&path)); + } + #[cfg(windows)] + { + if let Ok(app_data) = env::var("APPDATA") { + dirs.push(PathBuf::from(app_data).join("npm")); + } + } + #[cfg(not(windows))] + { + dirs.extend([ + PathBuf::from("/opt/homebrew/bin"), + PathBuf::from("/usr/local/bin"), + PathBuf::from("/usr/bin"), + PathBuf::from("/bin"), + PathBuf::from("/usr/sbin"), + PathBuf::from("/sbin"), + ]); + } + dirs +} + +fn merged_path_with_dir(dir: &str) -> String { + let mut seen = BTreeSet::new(); + let mut dirs = vec![PathBuf::from(dir)]; + dirs.extend(common_executable_dirs()); + let paths = dirs.into_iter().filter(|path| seen.insert(path.clone())).collect::>(); + env::join_paths(paths).unwrap_or_default().to_string_lossy().to_string() +} + +#[cfg(not(windows))] +fn user_shell() -> String { + env::var("SHELL").ok().filter(|value| !value.trim().is_empty()).unwrap_or_else(|| { + if Path::new("/bin/zsh").exists() { + "/bin/zsh".to_string() + } else { + "/bin/sh".to_string() + } + }) +} + +#[cfg(not(windows))] +fn user_shell_args(script: &str) -> Vec { + let shell = user_shell(); + let shell_name = Path::new(&shell).file_name().and_then(|value| value.to_str()).unwrap_or_default(); + match shell_name { + "fish" => vec!["-l".to_string(), "-i".to_string(), "-c".to_string(), script.to_string()], + "bash" => vec![ + "--noprofile".to_string(), + "--norc".to_string(), + "-i".to_string(), + "-c".to_string(), + bash_login_script(script), + ], + "sh" | "dash" => vec!["-ic".to_string(), script.to_string()], + "zsh" => vec!["-ilc".to_string(), script.to_string()], + _ => vec!["-lc".to_string(), script.to_string()], + } +} + +#[cfg(not(windows))] +fn bash_login_script(script: &str) -> String { + format!( + "for dbx_profile in ~/.bash_profile ~/.bash_login ~/.profile ~/.bashrc; do \ + [ -r \"$dbx_profile\" ] && . \"$dbx_profile\"; \ + done; unset dbx_profile; {script}" + ) +} + +#[cfg(not(windows))] +fn shell_resolve_script(program: &str) -> String { + format!("printf '%s\\n' {}; command -v {}", shell_quote(CODEX_PATH_MARKER), shell_quote(program)) +} + +#[cfg(not(windows))] +fn shell_quote(value: &str) -> String { + if value.is_empty() { + return "''".to_string(); + } + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + pub fn codex_enabled_tools(agent_mode: bool) -> Vec<&'static str> { dbx_mcp_enabled_tools(agent_mode) } @@ -76,7 +284,12 @@ pub fn build_codex_prompt(system_prompt: &str, messages: &[crate::ai::AiMessage] } pub async fn list_codex_models(config: &AiConfig) -> Result, String> { - let output = Command::new(codex_program(config)).args(["debug", "models"]).output().await; + let command = resolve_codex_command(config).await; + let output = Command::new(&command.program) + .args(["debug", "models"]) + .envs(command_env(&command).iter().map(|(key, value)| (key.as_str(), value.as_str()))) + .output() + .await; let Ok(output) = output else { return Ok(model_infos(DEFAULT_CODEX_MODELS)); @@ -122,8 +335,10 @@ fn parse_codex_models(stdout: &str) -> Option> { pub async fn test_codex_connection(config: &AiConfig) -> Result { let start = Instant::now(); - let mut command = Command::new(codex_program(config)); + let codex_command = resolve_codex_command(config).await; + let mut command = Command::new(&codex_command.program); command.args(["exec", "--json", "--skip-git-repo-check", "--sandbox", "read-only"]); + command.envs(command_env(&codex_command).iter().map(|(key, value)| (key.as_str(), value.as_str()))); let model = config.model.trim(); if !model.is_empty() && !model.eq_ignore_ascii_case("default") { @@ -190,10 +405,14 @@ pub async fn run_codex_agent( cancelled: &Notify, on_event: impl Fn(AgentEvent) + Send + Sync + 'static, ) -> Result { - let command = build_codex_exec_command(config, prompt, &options); + let mut command = build_codex_exec_command(config, prompt, &options); + let resolved_command = resolve_codex_command(config).await; + command.program = resolved_command.program; + let env = command_env(&command); run_cli_jsonl_agent( CliAgentProcessSpec { command, + env, dialect: CliAgentJsonlDialect::CodexExec, classify_spawn_error: classify_codex_spawn_error, classify_run_error: classify_codex_run_error, @@ -206,9 +425,11 @@ pub async fn run_codex_agent( #[cfg(test)] mod tests { + #[cfg(not(windows))] + use super::shell_quote; use super::{ - build_codex_exec_command, codex_enabled_tools, parse_codex_jsonl_event, parse_codex_models, CodexRunOptions, - DEFAULT_CODEX_MODELS, + build_codex_exec_command, codex_enabled_tools, command_env, common_executable_dirs, is_path_like_program, + merged_path_with_dir, parse_codex_jsonl_event, parse_codex_models, CodexRunOptions, DEFAULT_CODEX_MODELS, }; use crate::agent_events::AgentEvent; use crate::ai::{AiApiStyle, AiAuthMethod, AiConfig, AiProvider, AiReasoningLevel}; @@ -291,6 +512,53 @@ mod tests { assert!(spec.args.contains(&"model_reasoning_effort=\"high\"".to_string())); } + #[test] + fn path_like_codex_programs_are_detected() { + assert!(is_path_like_program("/opt/homebrew/bin/codex")); + assert!(is_path_like_program("~/bin/codex")); + assert!(is_path_like_program(r"C:\Tools\codex.exe")); + assert!(!is_path_like_program("codex")); + } + + #[test] + #[cfg(not(windows))] + fn common_codex_lookup_dirs_include_homebrew_and_system_paths() { + let dirs = common_executable_dirs(); + + assert!(dirs.iter().any(|dir| dir == std::path::Path::new("/opt/homebrew/bin"))); + assert!(dirs.iter().any(|dir| dir == std::path::Path::new("/usr/local/bin"))); + assert!(dirs.iter().any(|dir| dir == std::path::Path::new("/usr/bin"))); + } + + #[test] + #[cfg(not(windows))] + fn codex_command_env_prepends_resolved_program_dir_and_keeps_node_dirs() { + let command = CliAgentCommandSpec { program: "/opt/homebrew/bin/codex".to_string(), args: Vec::new() }; + let env = command_env(&command); + let path = env.iter().find(|(key, _)| key == "PATH").map(|(_, value)| value).unwrap(); + let dirs = std::env::split_paths(path).collect::>(); + + assert_eq!(dirs.first().unwrap(), std::path::Path::new("/opt/homebrew/bin")); + assert!(dirs.iter().any(|dir| dir == std::path::Path::new("/usr/bin"))); + } + + #[test] + #[cfg(not(windows))] + fn merged_path_deduplicates_codex_dir() { + let path = merged_path_with_dir("/opt/homebrew/bin"); + let dirs = std::env::split_paths(&path).collect::>(); + let count = dirs.iter().filter(|dir| *dir == std::path::Path::new("/opt/homebrew/bin")).count(); + + assert_eq!(count, 1); + } + + #[test] + #[cfg(not(windows))] + fn shell_quote_handles_codex_program_names() { + assert_eq!(shell_quote("codex"), "'codex'"); + assert_eq!(shell_quote("can't"), "'can'\"'\"'t'"); + } + #[test] fn default_model_list_matches_plan() { assert_eq!(model_infos(DEFAULT_CODEX_MODELS), model_infos(&["default", "gpt-5.5", "gpt-5.4-mini"]));