Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions src-tauri/src/agents/detect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,18 @@ pub enum AgentKind {
Claude,
Codex,
Gemini,
Opencode,
}

impl AgentKind {
pub const ALL: [AgentKind; 3] = [AgentKind::Claude, AgentKind::Codex, AgentKind::Gemini];
pub const ALL: [AgentKind; 4] = [
AgentKind::Claude,
AgentKind::Codex,
AgentKind::Gemini,
AgentKind::Opencode,
];
/// Agents exposed to the app. Gemini launch code is kept intact — add it here to enable it.
pub const ENABLED: [AgentKind; 2] = [AgentKind::Claude, AgentKind::Codex];
pub const ENABLED: [AgentKind; 3] = [AgentKind::Claude, AgentKind::Codex, AgentKind::Opencode];

pub fn is_enabled(self) -> bool {
Self::ENABLED.contains(&self)
Expand All @@ -42,6 +48,7 @@ impl AgentKind {
"claude" => Some(Self::Claude),
"codex" => Some(Self::Codex),
"gemini" => Some(Self::Gemini),
"opencode" => Some(Self::Opencode),
_ => None,
}
}
Expand All @@ -51,6 +58,7 @@ impl AgentKind {
Self::Claude => "claude",
Self::Codex => "codex",
Self::Gemini => "gemini",
Self::Opencode => "opencode",
}
}

Expand All @@ -59,6 +67,7 @@ impl AgentKind {
Self::Claude => "Claude Code",
Self::Codex => "Codex",
Self::Gemini => "Gemini",
Self::Opencode => "opencode",
}
}

Expand All @@ -67,14 +76,15 @@ impl AgentKind {
Self::Claude => "claude",
Self::Codex => "codex",
Self::Gemini => "gemini",
Self::Opencode => "opencode",
}
}

fn adapter(self) -> Option<(&'static str, &'static str)> {
match self {
Self::Claude => Some(("claude-agent-acp", CLAUDE_ACP_PACKAGE)),
Self::Codex => Some(("codex-acp", CODEX_ACP_PACKAGE)),
Self::Gemini => None,
Self::Gemini | Self::Opencode => None,
}
}
}
Expand Down Expand Up @@ -242,6 +252,11 @@ pub async fn detect_with(kind: AgentKind, custom_path: Option<&str>) -> Detected
args: vec![gemini_acp_flag(&cli).await.into()],
env: vec![],
}),
AgentKind::Opencode => Ok(LaunchSpec {
command: cli.clone(),
args: vec!["acp".into()],
env: vec![],
}),
AgentKind::Codex => adapter_launch(kind, find_in_path("npx").as_deref()).map(|mut spec| {
if std::env::var_os("CODEX_PATH").is_none() {
spec.env
Expand All @@ -254,6 +269,7 @@ pub async fn detect_with(kind: AgentKind, custom_path: Option<&str>) -> Detected
info.authenticated = match kind {
AgentKind::Claude => claude_authenticated(&cli).await,
AgentKind::Codex => codex_authenticated(&cli).await,
AgentKind::Opencode => Some(true),
AgentKind::Gemini => None,
};

Expand Down Expand Up @@ -315,6 +331,11 @@ async fn detect_custom(kind: AgentKind, custom: PathBuf) -> DetectedAgent {
args: vec![gemini_acp_flag(&custom).await.into()],
env: vec![],
}),
AgentKind::Opencode => Ok(LaunchSpec {
command: custom.clone(),
args: vec!["acp".into()],
env: vec![],
}),
AgentKind::Codex => adapter_launch(kind, find_in_path("npx").as_deref()).map(|mut spec| {
spec.env.push(("CODEX_PATH".into(), display.clone()));
spec
Expand All @@ -331,6 +352,7 @@ async fn detect_custom(kind: AgentKind, custom: PathBuf) -> DetectedAgent {
info.authenticated = match (kind, auth_cli.as_deref()) {
(AgentKind::Claude, Some(cli)) => claude_authenticated(cli).await,
(AgentKind::Codex, Some(cli)) => codex_authenticated(cli).await,
(AgentKind::Opencode, _) => Some(true),
_ => None,
};
let launch = match launch {
Expand Down Expand Up @@ -398,6 +420,7 @@ mod tests {
fn claude_and_codex_enabled() {
assert!(AgentKind::Claude.is_enabled());
assert!(AgentKind::Codex.is_enabled());
assert!(AgentKind::Opencode.is_enabled());
assert!(!AgentKind::Gemini.is_enabled());
assert_eq!(AgentKind::Claude.path_setting_key(), "agent.claude.path");
}
Expand Down
7 changes: 7 additions & 0 deletions src-tauri/src/agents/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,13 @@ impl AgentManager {
None => prompt,
};

// opencode exposes MCP tools as `tools.<server>.<tool>` instead of `mcp__<server>__<tool>`.
let prompt = if kind == Some(AgentKind::Opencode) {
prompt.replace("mcp__diffity__", "tools.diffity.")
} else {
prompt
};

let rt = self.runtime(&rec, binding).await?;

let user = ChatMessageContent::User(UserMessageContent {
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/agents/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ impl RunPermissions {
match (agent, self) {
(AgentKind::Codex, Self::Bypass) => "agent-full-access",
(AgentKind::Codex, _) => "read-only",
(AgentKind::Opencode, _) => "build",
(_, Self::Bypass) => "bypassPermissions",
_ => "default",
}
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/src/agents/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,13 +315,13 @@ impl Shared {

/// Diffity's own MCP tools are already gated by mode on the bridge, so they never need a user prompt.
/// Matched by exact name so a shell command that merely mentions a tool doesn't pass: Claude titles
/// them `mcp__diffity__add_comment`, Codex `mcp.diffity.add_comment`.
/// them `mcp__diffity__add_comment`, Codex `mcp.diffity.add_comment`, opencode `tools.diffity.add_comment`.
fn is_own_tool(title: Option<&str>) -> bool {
let Some(title) = title else {
return false;
};
let lower = title.trim().to_ascii_lowercase();
let Some(rest) = ["mcp__diffity__", "mcp.diffity.", "diffity."]
let Some(rest) = ["mcp__diffity__", "mcp.diffity.", "diffity.", "tools.diffity."]
.iter()
.find_map(|prefix| lower.strip_prefix(prefix))
else {
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/core/mentions.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Agent mention detection (`@claude`, `@codex`) for review comments.

/// Mention handles, one per agent id. The first mentioned handle decides which agent answers.
pub const AGENT_HANDLES: [&str; 2] = ["claude", "codex"];
pub const AGENT_HANDLES: [&str; 3] = ["claude", "codex", "opencode"];

/// True when `body` mentions an agent (case-insensitive, whole word) outside fenced code blocks and inline code spans.
pub fn mentions_agent(body: &str) -> bool {
Expand Down
9 changes: 9 additions & 0 deletions src/features/claude/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ export const AGENTS: AgentMeta[] = [
login: 'Run `codex login` in a terminal to sign in.',
pathHint: 'Point to the codex CLI or codex-acp.',
},
{
id: 'opencode',
name: 'opencode',
short: 'opencode',
handle: 'opencode',
install: 'npm i -g @opencode/cli',
login: 'Run `opencode auth login` in a terminal to sign in.',
pathHint: 'Point to the opencode CLI.',
},
];

const AGENTS_KEY = ['agents'];
Expand Down
2 changes: 1 addition & 1 deletion src/lib/mentions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Mirror of `crate::core::mentions` (src-tauri): `@claude` / `@codex` (case-insensitive, whole word) outside code.

/** Mention handles, one per agent id. */
export const AGENT_HANDLES = ['claude', 'codex'] as const;
export const AGENT_HANDLES = ['claude', 'codex', 'opencode'] as const;

function stripCode(body: string): string {
const out: string[] = [];
Expand Down