Files
galaxy/app/src/terminal/cli_agent.rs
T

598 lines
21 KiB
Rust

//! CLI agent detection and configuration.
//!
//! This module provides types for detecting and working with CLI-based AI agents
//! like Claude Code, Gemini CLI, Codex, Amp, and Droid.
use std::borrow::Cow;
use std::collections::HashMap;
use ai::skills::SkillProvider;
use enum_iterator::Sequence;
use galaxy_editor::content::{buffer::Buffer, markdown::MarkdownStyle};
use markdown_parser::parse_markdown;
use pathfinder_color::ColorU;
use serde::{Deserialize, Serialize};
use smol_str::SmolStr;
use galaxy_cli::agent::Harness;
use galaxy_completer::parsers::simple::top_level_command;
use galaxy_editor::content::buffer::Buffer;
use galaxy_editor::content::markdown::MarkdownStyle;
use galaxy_util::path::EscapeChar;
use galaxyui::{AppContext, SingletonEntity};
use crate::ai::agent::{AgentReviewCommentBatch, DiffSetHunk};
use crate::ai::blocklist::CLAUDE_ORANGE;
use crate::code::editor::line::EditorLineLocation;
use crate::code_review::comments::AttachedReviewCommentTarget;
use crate::server::telemetry::CLIAgentType;
use crate::ui_components::icons::Icon;
use crate::workspaces::user_workspaces::UserWorkspaces;
/// UID for the Uber team.
/// See https://warp.metabaseapp.com/dashboard/1454?team_id=46347
const UBER_TEAM_UID: &str = "BdVbYjy9LRZcZrYBemSfAF";
/// Gemini brand blue color
pub(crate) const GEMINI_BLUE: ColorU = ColorU {
r: 66,
g: 133,
b: 244,
a: 255,
};
/// OpenAI brand color (dark gray/black)
pub(crate) const OPENAI_COLOR: ColorU = ColorU {
r: 0,
g: 0,
b: 0,
a: 255,
};
/// Amp brand color (#F34E3F)
const AMP_COLOR: ColorU = ColorU {
r: 243,
g: 78,
b: 63,
a: 255,
};
/// Droid brand color (white)
const DROID_COLOR: ColorU = ColorU {
r: 255,
g: 255,
b: 255,
a: 255,
};
/// OpenCode brand color (gray, used for contrast calculation only)
pub(crate) const OPENCODE_COLOR: ColorU = ColorU {
r: 128,
g: 128,
b: 128,
a: 255,
};
/// Copilot brand color (Copilot purple selected from https://brand.github.com/brand-identity/copilot)
const COPILOT_COLOR: ColorU = ColorU {
r: 133,
g: 52,
b: 243,
a: 255,
};
/// Pi brand color (white, monochrome logo)
const PI_COLOR: ColorU = ColorU {
r: 255,
g: 255,
b: 255,
a: 255,
};
/// Antigravity brand color (white, monochrome logo)
const ANTIGRAVITY_COLOR: ColorU = ColorU {
r: 255,
g: 255,
b: 255,
a: 255,
};
/// Auggie brand color (white, monochrome logo)
const AUGGIE_COLOR: ColorU = ColorU {
r: 255,
g: 255,
b: 255,
a: 255,
};
/// Cursor brand color (#26251E, from official brand assets)
const CURSOR_COLOR: ColorU = ColorU {
r: 38,
g: 37,
b: 30,
a: 255,
};
/// Goose brand color (#101010, from Block's official Goose logo)
const GOOSE_COLOR: ColorU = ColorU {
r: 16,
g: 16,
b: 16,
a: 255,
};
/// Hermes brand color (Nous Research purple #7C3AED)
const HERMES_PURPLE: ColorU = ColorU {
r: 124,
g: 58,
b: 237,
a: 255,
};
/// Mistral brand orange (#FA520F)
const MISTRAL_ORANGE: ColorU = ColorU {
r: 250,
g: 82,
b: 15,
a: 255,
};
/// Represents a CLI agent (e.g., Claude Code, Gemini CLI, Codex, Amp, Droid, OpenCode, Copilot, Pi, Auggie, Cursor, Goose, Hermes, Mistral Vibe)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Sequence, Serialize, Deserialize)]
pub enum CLIAgent {
Claude,
Gemini,
Codex,
Amp,
Droid,
OpenCode,
Copilot,
Pi,
Auggie,
CursorCli,
Goose,
Hermes,
Vibe,
Antigravity,
/// Represents an unknown/custom CLI agent matched by user-configured regex patterns.
Unknown,
}
impl CLIAgent {
/// The command prefix used to invoke this CLI agent.
pub fn command_prefix(&self) -> &'static str {
match self {
CLIAgent::Claude => "claude",
CLIAgent::Gemini => "gemini",
CLIAgent::Codex => "codex",
CLIAgent::Amp => "amp",
CLIAgent::Droid => "droid",
CLIAgent::OpenCode => "opencode",
CLIAgent::Copilot => "copilot",
CLIAgent::Pi => "pi",
CLIAgent::Auggie => "auggie",
CLIAgent::CursorCli => "agent",
CLIAgent::Goose => "goose",
CLIAgent::Hermes => "hermes",
CLIAgent::Vibe => "vibe",
CLIAgent::Antigravity => "agy",
CLIAgent::Unknown => "",
}
}
/// Serialized version of the CLIAgent name (e.g. "Claude", "Gemini"). Used for the
/// session-sharing protocol's opaque `cli_agent` string field.
pub fn to_serialized_name(&self) -> String {
serde_json::to_value(self)
.ok()
.and_then(|v| v.as_str().map(str::to_owned))
.unwrap_or_default()
}
/// Inverse of `to_serialized_name`. Falls back to `Unknown`.
pub fn from_serialized_name(name: &str) -> CLIAgent {
serde_json::from_value(name.into()).unwrap_or(CLIAgent::Unknown)
}
/// Returns the [`CLIAgent`] corresponding to a cloud-agent [`Harness`] when it represents a
/// third-party agent. Returns `None` for [`Harness::Oz`] (Warp's built-in harness has no
/// distinct CLI agent identity).
pub fn from_harness(harness: Harness) -> Option<Self> {
match harness {
Harness::Oz => None,
Harness::Claude => Some(CLIAgent::Claude),
Harness::Gemini => Some(CLIAgent::Gemini),
Harness::OpenCode => Some(CLIAgent::OpenCode),
Harness::Codex => Some(CLIAgent::Codex),
Harness::Unknown => Some(CLIAgent::Unknown),
}
}
pub fn display_name(&self) -> &'static str {
match self {
CLIAgent::Claude => "Claude Code",
CLIAgent::Gemini => "Gemini",
CLIAgent::Codex => "Codex",
CLIAgent::Amp => "Amp",
CLIAgent::Droid => "Droid",
CLIAgent::OpenCode => "OpenCode",
CLIAgent::Copilot => "Copilot",
CLIAgent::Pi => "Pi",
CLIAgent::Auggie => "Auggie",
CLIAgent::CursorCli => "Cursor",
CLIAgent::Goose => "Goose",
CLIAgent::Hermes => "Hermes",
CLIAgent::Vibe => "Mistral Vibe",
CLIAgent::Antigravity => "Antigravity",
CLIAgent::Unknown => "CLI Agent",
}
}
/// Returns the Icon for this CLI agent, or `None` for unknown/custom agents.
pub fn icon(&self) -> Option<Icon> {
match self {
CLIAgent::Claude => Some(Icon::ClaudeLogo),
CLIAgent::Gemini => Some(Icon::GeminiLogo),
CLIAgent::Codex => Some(Icon::OpenAILogo),
CLIAgent::Amp => Some(Icon::AmpLogo),
CLIAgent::Droid => Some(Icon::DroidLogo),
CLIAgent::OpenCode => Some(Icon::OpenCodeLogo),
CLIAgent::Copilot => Some(Icon::CopilotLogo),
CLIAgent::Pi => Some(Icon::PiLogo),
CLIAgent::Auggie => Some(Icon::AuggieLogo),
CLIAgent::CursorCli => Some(Icon::CursorLogo),
CLIAgent::Goose => Some(Icon::GooseLogo),
CLIAgent::Hermes => None,
// Vibe is recognized but ships without a brand asset. The brand color
// still drives the toolbar tile; an `Icon::MistralLogo` can be wired
// up in a follow-up once an officially licensed SVG is available.
CLIAgent::Vibe => None,
CLIAgent::Antigravity => Some(Icon::AntigravityLogo),
CLIAgent::Unknown => None,
}
}
/// Returns the skill providers whose skills this CLI agent can natively interpret.
/// When the CLI agent rich input is open, only skills from these providers are shown
/// in the slash menu. Returns an empty slice for agents with no known skills support.
pub fn supported_skill_providers(&self) -> &'static [SkillProvider] {
match self {
CLIAgent::Claude => &[SkillProvider::Claude],
CLIAgent::Codex => &[
SkillProvider::Agents,
SkillProvider::Claude,
SkillProvider::Codex,
],
CLIAgent::OpenCode => &[
SkillProvider::OpenCode,
SkillProvider::Agents,
SkillProvider::Claude,
],
CLIAgent::Gemini => &[SkillProvider::Agents, SkillProvider::Gemini],
CLIAgent::Amp => &[SkillProvider::Agents],
CLIAgent::Copilot => &[SkillProvider::Agents, SkillProvider::Copilot],
CLIAgent::Droid => &[SkillProvider::Droid, SkillProvider::Agents],
CLIAgent::Pi => &[SkillProvider::Agents],
CLIAgent::Auggie => &[SkillProvider::Agents],
CLIAgent::CursorCli => &[SkillProvider::Agents],
CLIAgent::Goose => &[SkillProvider::Agents],
CLIAgent::Hermes => &[SkillProvider::Agents],
CLIAgent::Vibe => &[SkillProvider::Agents],
CLIAgent::Antigravity => &[],
CLIAgent::Unknown => &[],
}
}
/// Returns the prefix character used for skill invocations by this CLI agent.
/// Most agents use `/` (e.g. `/skill-name`), but Codex uses `$` (e.g. `$skill-name`).
pub fn skill_command_prefix(&self) -> &'static str {
match self {
CLIAgent::Codex => "$",
_ => "/",
}
}
/// Whether this CLI agent supports the `!` bash mode prefix in the rich input.
/// When `true`, typing `!` in the CLI agent rich input activates shell mode with
/// decorations, completions, and error underlining.
///
/// TODO(advait): Check whether Gemini, Amp, Droid, and Copilot support `!` bash
/// mode and enable them here if so.
pub fn supports_bash_mode(&self) -> bool {
matches!(
self,
CLIAgent::Claude | CLIAgent::Codex | CLIAgent::OpenCode
)
}
/// Returns the brand color for this CLI agent, or `None` for unknown/custom agents.
pub fn brand_color(&self) -> Option<ColorU> {
match self {
CLIAgent::Claude => Some(CLAUDE_ORANGE),
CLIAgent::Gemini => Some(GEMINI_BLUE),
CLIAgent::Codex => Some(OPENAI_COLOR),
CLIAgent::Amp => Some(AMP_COLOR),
CLIAgent::Droid => Some(DROID_COLOR),
CLIAgent::OpenCode => Some(OPENCODE_COLOR),
CLIAgent::Copilot => Some(COPILOT_COLOR),
CLIAgent::Pi => Some(PI_COLOR),
CLIAgent::Auggie => Some(AUGGIE_COLOR),
CLIAgent::CursorCli => Some(CURSOR_COLOR),
CLIAgent::Goose => Some(GOOSE_COLOR),
CLIAgent::Hermes => Some(HERMES_PURPLE),
CLIAgent::Vibe => Some(MISTRAL_ORANGE),
CLIAgent::Antigravity => Some(ANTIGRAVITY_COLOR),
CLIAgent::Unknown => None,
}
}
/// Returns the icon color to use when rendered on the brand-colored circle background.
/// Agents with light brand colors use a dark icon for contrast.
pub fn brand_icon_color(&self) -> ColorU {
match self {
CLIAgent::Pi | CLIAgent::Auggie | CLIAgent::Droid | CLIAgent::Antigravity => {
ColorU::new(0, 0, 0, 255)
}
_ => ColorU::white(),
}
}
/// Extracts the first meaningful command token from a command string.
///
/// When `escape_char` is provided, uses shell parsing to skip leading
/// env-var assignments (e.g. `FOO=1 claude` → `claude`).
/// Otherwise falls back to a simple whitespace split.
fn extract_first_command(command: &str, escape_char: Option<EscapeChar>) -> Option<String> {
match escape_char {
Some(esc) => top_level_command(command, esc),
None => command.split_whitespace().next().map(String::from),
}
}
/// Detects the CLI agent from a command string.
///
/// When `escape_char` is provided, full shell parsing is used to skip leading
/// env-var assignments (e.g. `FOO=1 claude`). Otherwise falls back to a simple
/// whitespace split.
///
/// If `aliases` is provided, the first word of the command will be looked up
/// in the alias map. If found, the alias value replaces the first word to
/// produce the resolved command used for detection.
///
/// Returns `Some(CLIAgent)` if the command matches a known CLI agent, `None` otherwise.
pub fn detect(
command: &str,
escape_char: Option<EscapeChar>,
aliases: Option<&HashMap<SmolStr, String>>,
ctx: &AppContext,
) -> Option<CLIAgent> {
let trimmed = command.trim_start();
let first_word = Self::extract_first_command(trimmed, escape_char)?;
// Resolve the full command through aliases. If the first word matches an
// alias, replace it with the alias value to produce the resolved command.
let resolved_command: Cow<'_, str> = aliases
.and_then(|a| a.get(first_word.as_str()))
.map(|alias_value| {
let rest = trimmed
.find(first_word.as_str())
.map(|pos| &trimmed[pos + first_word.len()..])
.unwrap_or("");
Cow::Owned(format!("{}{}", alias_value.trim(), rest))
})
.unwrap_or(Cow::Borrowed(trimmed));
let resolved_first_word = Self::extract_first_command(&resolved_command, escape_char)?;
// Check if resolved command matches any known CLI agent.
// Also matches `aifx agent run claude` as Claude for Uber employees,
// and the `vibe-acp` ACP-mode binary as Mistral Vibe.
enum_iterator::all::<CLIAgent>()
.filter(|agent| !matches!(agent, CLIAgent::Unknown))
.find(|agent| {
resolved_first_word == agent.command_prefix()
|| (matches!(agent, CLIAgent::Claude)
&& Self::is_aifx_agent_run_claude(&resolved_command, ctx))
|| (matches!(agent, CLIAgent::Vibe) && resolved_first_word == "vibe-acp")
})
}
/// Returns true if the resolved command is `aifx agent run claude` (Uber's
/// internal wrapper around Claude) and the user is on the Uber team.
/// We special-case this so Uber employees get the toolbar without needing
/// to configure anything.
fn is_aifx_agent_run_claude(resolved_command: &str, ctx: &AppContext) -> bool {
resolved_command.starts_with("aifx agent run claude")
&& Self::is_on_uber_team(UserWorkspaces::as_ref(ctx))
}
fn is_on_uber_team(user_workspaces: &UserWorkspaces) -> bool {
user_workspaces
.workspaces()
.iter()
.flat_map(|workspace| workspace.teams.iter())
.any(|team| team.uid.uid() == UBER_TEAM_UID)
}
}
/// Builds a prompt string from a batch of code review comments suitable for
/// writing to a CLI agent's PTY.
///
/// # Location format
/// Locations use `L<line>` notation (1-indexed).
/// Line ranges are written `L<start>-L<end>` where both ends are **inclusive**.
/// Instructs the agent to run `git diff` for deleted-line context rather than
/// inlining the full diff.
pub fn build_review_prompt(review: &AgentReviewCommentBatch) -> String {
let mut text = String::from(
"Please address the following code review comments. \
Run `git diff` (or `git diff HEAD`) to see the full context of any changes, \
especially for deleted lines.\n",
);
for comment in &review.comments {
if comment.outdated {
continue;
}
let body = export_review_comment_for_cli_prompt(&comment.content);
let location = match &comment.target {
AttachedReviewCommentTarget::Line {
absolute_file_path,
line,
..
} => {
let path = absolute_file_path.display_path();
match line {
EditorLineLocation::Current { line_number, .. } => {
let n = line_number.as_usize() + 1;
format!("{path} L{n}")
}
EditorLineLocation::Removed { line_number, .. } => {
let n = line_number.as_usize() + 1;
format!("{path} (deleted, was L{n} — see `git diff`)")
}
EditorLineLocation::Collapsed { line_range } => {
// line_range is [start, end) 0-indexed; convert to L<start>-L<end>
// where both start and end are 1-indexed inclusive.
let start = line_range.start.as_usize() + 1;
let end = line_range.end.as_usize();
format!("{path} (collapsed hunk, L{start}-L{end} — see `git diff`)")
}
}
}
AttachedReviewCommentTarget::File { absolute_file_path } => {
let path = absolute_file_path.display_path();
let is_deleted = review.diff_set.iter().any(|(file_key, hunks)| {
path.ends_with(file_key.as_str())
&& !hunks.is_empty()
&& hunks
.iter()
.all(|h| h.lines_added == 0 && h.lines_removed > 0)
});
if is_deleted {
format!("{path} (deleted file — see `git diff`)")
} else {
path
}
}
AttachedReviewCommentTarget::General => "General".to_string(),
};
text.push_str(&format!("\n- {location}: {body}"));
}
text
}
fn export_review_comment_for_cli_prompt(comment: &str) -> String {
let mut result = parse_markdown(comment)
.map(|parsed| {
Buffer::export_to_markdown(
parsed,
None,
MarkdownStyle::Export {
app_context: None,
should_not_escape_markdown_punctuation: true,
},
)
})
.unwrap_or_else(|_| comment.to_string());
result.truncate(result.trim_end().len());
result
}
/// Builds a prompt string for a single diff hunk location suitable for writing
/// to a CLI agent's PTY. Includes change stats (+N -N) and instructs the agent
/// to run `git diff` for full context.
///
/// # Location format
/// `<path> L<start>-L<end>` where `start` and `end` are 1-indexed and both
/// ends are **inclusive**.
pub fn build_diff_hunk_prompt(
file_path: &str,
start_line: usize,
end_line: usize,
lines_added: u32,
lines_removed: u32,
) -> String {
format!(
"{file_path} L{start_line}-L{end_line} (+{lines_added} -{lines_removed}) \
-- run `git diff` to see the full context."
)
}
/// Builds a prompt string for a set of diff file context hunks suitable for
/// writing to a CLI agent's PTY.
///
/// # Location format
/// Each line is `<path> L<start>-L<end> (+N -N)` where `start` and `end` are
/// 1-indexed and both ends are **inclusive**.
pub fn build_diff_context_prompt(file_diffs: &HashMap<String, Vec<DiffSetHunk>>) -> String {
let mut text = String::new();
let mut sorted_keys: Vec<&String> = file_diffs.keys().collect();
sorted_keys.sort();
for file_key in sorted_keys {
let hunks = &file_diffs[file_key];
for hunk in hunks {
// hunk.line_range is [start, end) 0-indexed; convert to L<start>-L<end>
// where both start and end are 1-indexed inclusive.
let start = hunk.line_range.start.as_usize() + 1;
let end = hunk.line_range.end.as_usize();
text.push_str(&format!(
"{file_key} L{start}-L{end} (+{} -{})",
hunk.lines_added, hunk.lines_removed,
));
text.push('\n');
}
}
// Remove trailing newline.
text.truncate(text.trim_end().len());
text
}
/// Builds a prompt for a single-line text selection suitable for writing to a CLI agent's PTY.
/// Prefixes the literal text with its file path and line number for context.
///
/// # Format
/// `<path> L<line>: <text>` where `line` is 1-indexed.
pub fn build_selection_substring_prompt(file_path: &str, line: usize, text: &str) -> String {
format!("{file_path} L{line}: {text}")
}
/// Builds a prompt for a multi-line selection suitable for writing to a CLI agent's PTY.
/// For single-line selections, use [`build_selection_substring_prompt`] instead.
///
/// # Location format
/// `<path> L<start>-L<end>` where line numbers are 1-indexed and both ends are inclusive.
pub fn build_selection_line_range_prompt(
file_path: &str,
start_line: usize,
end_line: usize,
) -> String {
format!("{file_path} L{start_line}-L{end_line}")
}
impl From<CLIAgent> for CLIAgentType {
fn from(agent: CLIAgent) -> Self {
match agent {
CLIAgent::Claude => CLIAgentType::Claude,
CLIAgent::Gemini => CLIAgentType::Gemini,
CLIAgent::Codex => CLIAgentType::Codex,
CLIAgent::Amp => CLIAgentType::Amp,
CLIAgent::Droid => CLIAgentType::Droid,
CLIAgent::OpenCode => CLIAgentType::OpenCode,
CLIAgent::Copilot => CLIAgentType::Copilot,
CLIAgent::Pi => CLIAgentType::Pi,
CLIAgent::Auggie => CLIAgentType::Auggie,
CLIAgent::CursorCli => CLIAgentType::Cursor,
CLIAgent::Goose => CLIAgentType::Goose,
CLIAgent::Hermes => CLIAgentType::Hermes,
CLIAgent::Vibe => CLIAgentType::Vibe,
CLIAgent::Antigravity => CLIAgentType::Antigravity,
CLIAgent::Unknown => CLIAgentType::Unknown,
}
}
}
#[cfg(test)]
#[path = "cli_agent_tests.rs"]
mod tests;