use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io;
use std::sync::OnceLock;
use super::traits::{
CliCommandBuilder, MessageClass, MessageMetadata, OutputParser, ParsedMessage, PromptSubmitter,
StartupAction,
};
use crate::pty::vte::VteParser;
use crate::transport::SpawnOptions;
use crate::core::types::CliTool;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GeminiCommand {
Help,
About,
Stats,
Privacy,
Chat { subcommand: Option<ChatSubcommand> },
Resume,
Clear,
Compress,
Memory { subcommand: Option<MemorySubcommand> },
Directory { subcommand: Option<DirectorySubcommand> },
Mcp { subcommand: Option<McpSubcommand> },
Tools { subcommand: Option<ToolsSubcommand> },
Extensions,
Skills { subcommand: Option<SkillsSubcommand> },
Restore { tool_call_id: Option<String> },
Settings,
Theme,
Auth,
Model,
Editor,
Copy,
Bug { text: Option<String> },
Init,
Vim,
Quit,
Exit,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChatSubcommand {
Save { tag: String },
Resume { tag: String },
List,
Delete { tag: String },
Share { filename: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MemorySubcommand {
Show,
Add { text: String },
Refresh,
List,
Remove { text: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DirectorySubcommand {
Add { paths: Vec<String> },
Show,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpSubcommand {
List,
Desc,
Schema,
Auth { server_name: Option<String> },
Refresh,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolsSubcommand {
Desc,
NoDesc,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillsSubcommand {
List,
Enable { name: String },
Disable { name: String },
Reload,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ApprovalMode {
Default,
AutoEdit,
Yolo,
Plan,
}
impl std::fmt::Display for ApprovalMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ApprovalMode::Default => write!(f, "default"),
ApprovalMode::AutoEdit => write!(f, "auto_edit"),
ApprovalMode::Yolo => write!(f, "yolo"),
ApprovalMode::Plan => write!(f, "plan"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GeminiControl {
Confirm,
Dismiss,
Cancel,
Exit,
DoubleEsc,
LineStart,
LineEnd,
CursorUp,
CursorDown,
CursorLeft,
CursorRight,
WordLeft,
WordRight,
DeleteToLineEnd,
DeleteToLineStart,
DeletePrevWord,
DeleteNextWord,
Backspace,
DeleteChar,
Undo,
Redo,
ScrollUp,
ScrollDown,
ScrollTop,
ScrollBottom,
PageUp,
PageDown,
PrevHistory,
NextHistory,
ReverseSearch,
InsertNewline,
ExternalEditor,
Paste,
ClearScreen,
ToggleYolo,
CycleApprovalMode,
ToggleTodo,
ToggleCopyMode,
ShowIdeContext,
ToggleMarkdown,
ToggleErrorDetails,
Restart,
FocusShell,
ToggleShellMode,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GeminiSpecialInput {
FileReference(String),
ShellCommand(String),
}
impl GeminiCommand {
pub fn to_pty_input(&self) -> String {
let cmd = match self {
Self::Help => "/help".to_string(),
Self::About => "/about".to_string(),
Self::Stats => "/stats".to_string(),
Self::Privacy => "/privacy".to_string(),
Self::Chat { subcommand } => {
if let Some(sub) = subcommand {
match sub {
ChatSubcommand::Save { tag } => format!("/chat save {}", tag),
ChatSubcommand::Resume { tag } => format!("/chat resume {}", tag),
ChatSubcommand::List => "/chat list".to_string(),
ChatSubcommand::Delete { tag } => format!("/chat delete {}", tag),
ChatSubcommand::Share { filename } => format!("/chat share {}", filename),
}
} else {
"/chat".to_string()
}
}
Self::Resume => "/resume".to_string(),
Self::Clear => "/clear".to_string(),
Self::Compress => "/compress".to_string(),
Self::Memory { subcommand } => {
if let Some(sub) = subcommand {
match sub {
MemorySubcommand::Show => "/memory show".to_string(),
MemorySubcommand::Add { text } => format!("/memory add {}", text),
MemorySubcommand::Refresh => "/memory refresh".to_string(),
MemorySubcommand::List => "/memory list".to_string(),
MemorySubcommand::Remove { text } => format!("/memory remove {}", text),
}
} else {
"/memory".to_string()
}
}
Self::Directory { subcommand } => {
if let Some(sub) = subcommand {
match sub {
DirectorySubcommand::Add { paths } => {
format!("/directory add {}", paths.join(","))
}
DirectorySubcommand::Show => "/directory show".to_string(),
}
} else {
"/directory".to_string()
}
}
Self::Mcp { subcommand } => {
if let Some(sub) = subcommand {
match sub {
McpSubcommand::List => "/mcp list".to_string(),
McpSubcommand::Desc => "/mcp desc".to_string(),
McpSubcommand::Schema => "/mcp schema".to_string(),
McpSubcommand::Auth { server_name } => {
if let Some(name) = server_name {
format!("/mcp auth {}", name)
} else {
"/mcp auth".to_string()
}
}
McpSubcommand::Refresh => "/mcp refresh".to_string(),
}
} else {
"/mcp".to_string()
}
}
Self::Tools { subcommand } => {
if let Some(sub) = subcommand {
match sub {
ToolsSubcommand::Desc => "/tools desc".to_string(),
ToolsSubcommand::NoDesc => "/tools nodesc".to_string(),
}
} else {
"/tools".to_string()
}
}
Self::Extensions => "/extensions".to_string(),
Self::Skills { subcommand } => {
if let Some(sub) = subcommand {
match sub {
SkillsSubcommand::List => "/skills list".to_string(),
SkillsSubcommand::Enable { name } => format!("/skills enable {}", name),
SkillsSubcommand::Disable { name } => format!("/skills disable {}", name),
SkillsSubcommand::Reload => "/skills reload".to_string(),
}
} else {
"/skills".to_string()
}
}
Self::Restore { tool_call_id } => {
if let Some(id) = tool_call_id {
format!("/restore {}", id)
} else {
"/restore".to_string()
}
}
Self::Settings => "/settings".to_string(),
Self::Theme => "/theme".to_string(),
Self::Auth => "/auth".to_string(),
Self::Model => "/model".to_string(),
Self::Editor => "/editor".to_string(),
Self::Copy => "/copy".to_string(),
Self::Bug { text } => {
if let Some(t) = text {
format!("/bug {}", t)
} else {
"/bug".to_string()
}
}
Self::Init => "/init".to_string(),
Self::Vim => "/vim".to_string(),
Self::Quit => "/quit".to_string(),
Self::Exit => "/exit".to_string(),
};
format!("{}\n", cmd)
}
}
impl GeminiControl {
pub fn to_bytes(&self) -> Vec<u8> {
match self {
Self::Confirm => vec![0x0D], Self::Dismiss => vec![0x1B], Self::Cancel => vec![0x03], Self::Exit => vec![0x04], Self::DoubleEsc => vec![0x1B, 0x1B],
Self::LineStart => vec![0x01], Self::LineEnd => vec![0x05], Self::CursorUp => vec![0x1B, 0x5B, 0x41], Self::CursorDown => vec![0x1B, 0x5B, 0x42], Self::CursorLeft => vec![0x1B, 0x5B, 0x44], Self::CursorRight => vec![0x1B, 0x5B, 0x43], Self::WordLeft => vec![0x1B, 0x62], Self::WordRight => vec![0x1B, 0x66],
Self::DeleteToLineEnd => vec![0x0B], Self::DeleteToLineStart => vec![0x15], Self::DeletePrevWord => vec![0x17], Self::DeleteNextWord => vec![0x1B, 0x64], Self::Backspace => vec![0x08], Self::DeleteChar => vec![0x7F], Self::Undo => vec![0x1A], Self::Redo => vec![0x1B, 0x5B, 0x31, 0x3B, 0x32, 0x5A],
Self::ScrollUp => vec![0x1B, 0x5B, 0x31, 0x3B, 0x32, 0x41], Self::ScrollDown => vec![0x1B, 0x5B, 0x31, 0x3B, 0x32, 0x42], Self::ScrollTop => vec![0x1B, 0x5B, 0x31, 0x7E], Self::ScrollBottom => vec![0x1B, 0x5B, 0x34, 0x7E], Self::PageUp => vec![0x1B, 0x5B, 0x35, 0x7E], Self::PageDown => vec![0x1B, 0x5B, 0x36, 0x7E],
Self::PrevHistory => vec![0x10], Self::NextHistory => vec![0x0E], Self::ReverseSearch => vec![0x12],
Self::InsertNewline => vec![0x0A], Self::ExternalEditor => vec![0x18], Self::Paste => vec![0x16],
Self::ClearScreen => vec![0x0C], Self::ToggleYolo => vec![0x19], Self::CycleApprovalMode => vec![0x1B, 0x5B, 0x5A], Self::ToggleTodo => vec![0x14], Self::ToggleCopyMode => vec![0x13], Self::ShowIdeContext => vec![0x07], Self::ToggleMarkdown => vec![0x1B, 0x6D], Self::ToggleErrorDetails => vec![0x1B, 0x5B, 0x32, 0x34, 0x7E], Self::Restart => vec![0x52], Self::FocusShell => vec![0x09], Self::ToggleShellMode => b"!\n".to_vec(), }
}
}
impl GeminiSpecialInput {
pub fn to_pty_input(&self) -> String {
match self {
Self::FileReference(path) => format!("@{}", path),
Self::ShellCommand(cmd) => format!("!{}\n", cmd),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum GeminiResponse {
WelcomeScreen {
model: String,
version: String,
},
TokenUsage {
used: u64,
remaining: u64,
cached_savings: Option<u64>,
},
RateLimitHit {
retry_at: Option<String>,
message: String,
},
ApprovalModeChanged {
mode: ApprovalMode,
},
MemoryInfo {
entries: Vec<String>,
file_paths: Vec<String>,
},
McpServerList {
servers: Vec<McpServer>,
},
SkillsList {
skills: Vec<Skill>,
},
StatusInfo {
session_duration: Option<String>,
model: Option<String>,
token_usage: Option<u64>,
cached_tokens: Option<u64>,
},
CheckpointInfo {
checkpoints: Vec<Checkpoint>,
},
SessionList {
sessions: Vec<Session>,
},
ExtensionList {
extensions: Vec<Extension>,
},
ModelMenu {
current_model: String,
available_models: Vec<String>,
},
UpdateAvailable {
current_version: String,
new_version: String,
},
ContextUsage {
percent_used: f32,
tokens_used: Option<u64>,
tokens_total: Option<u64>,
},
ToolApprovalRequest {
tool_name: String,
description: Option<String>,
},
Working {
message: String,
},
Raw(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpServer {
pub name: String,
pub status: McpServerStatus,
pub tools: Vec<String>,
pub description: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum McpServerStatus {
Connected,
Disconnected,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Skill {
pub name: String,
pub enabled: bool,
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Checkpoint {
pub id: String,
pub timestamp: String,
pub filename: String,
pub tool_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Session {
pub id: String,
pub index: Option<usize>,
pub created_at: Option<String>,
pub message_count: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Extension {
pub name: String,
pub version: Option<String>,
pub enabled: bool,
pub source: Option<String>,
}
pub struct GeminiOutputParser {
buffer: String,
pending_response: String,
response_complete: bool,
}
impl GeminiOutputParser {
pub fn new() -> Self {
Self {
buffer: String::new(),
pending_response: String::new(),
response_complete: false,
}
}
pub fn pending_response(&self) -> &str {
&self.pending_response
}
pub fn is_response_complete(&self) -> bool {
self.response_complete
}
pub fn feed(&mut self, data: &str) {
self.buffer.push_str(data);
}
pub fn parse(&mut self) -> Vec<GeminiResponse> {
let responses = Self::parse_text(&self.buffer);
self.buffer.clear();
responses
}
pub fn parse_text(text: &str) -> Vec<GeminiResponse> {
let mut responses = Vec::new();
if let Some(response) = Self::parse_welcome_screen(text) {
responses.push(response);
}
if let Some(response) = Self::parse_token_usage(text) {
responses.push(response);
}
if let Some(response) = Self::parse_rate_limit(text) {
responses.push(response);
}
if let Some(response) = Self::parse_approval_mode(text) {
responses.push(response);
}
if let Some(response) = Self::parse_memory_info(text) {
responses.push(response);
}
if let Some(response) = Self::parse_mcp_list(text) {
responses.push(response);
}
if let Some(response) = Self::parse_skills_list(text) {
responses.push(response);
}
if let Some(response) = Self::parse_status_info(text) {
responses.push(response);
}
if let Some(response) = Self::parse_checkpoint_info(text) {
responses.push(response);
}
if let Some(response) = Self::parse_session_list(text) {
responses.push(response);
}
if let Some(response) = Self::parse_extension_list(text) {
responses.push(response);
}
if let Some(response) = Self::parse_model_menu(text) {
responses.push(response);
}
if let Some(response) = Self::parse_update_available(text) {
responses.push(response);
}
if let Some(response) = Self::parse_context_usage(text) {
responses.push(response);
}
if let Some(response) = Self::parse_tool_approval(text) {
responses.push(response);
}
if let Some(response) = Self::parse_working_indicator(text) {
responses.push(response);
}
if responses.is_empty() && !text.trim().is_empty() {
responses.push(GeminiResponse::Raw(text.to_string()));
}
responses
}
fn parse_welcome_screen(text: &str) -> Option<GeminiResponse> {
static WELCOME_RE: OnceLock<Regex> = OnceLock::new();
let re = WELCOME_RE.get_or_init(|| {
Regex::new(r"Gemini CLI \(v([\d.]+)\)").unwrap()
});
let version = re.captures(text)
.and_then(|cap| cap.get(1))
.map(|m| m.as_str().to_string())?;
static MODEL_RE: OnceLock<Regex> = OnceLock::new();
let model_re = MODEL_RE.get_or_init(|| {
Regex::new(r"Model:\s*(\S+)").unwrap()
});
let model = model_re.captures(text)
.and_then(|cap| cap.get(1))
.map(|m| m.as_str().to_string())
.or_else(|| {
if text.contains("gemini-2.5-pro") {
Some("gemini-2.5-pro".to_string())
} else if text.contains("gemini-2.5-flash") {
Some("gemini-2.5-flash".to_string())
} else if text.contains("gemini-2.0-flash") {
Some("gemini-2.0-flash".to_string())
} else {
None
}
})?;
Some(GeminiResponse::WelcomeScreen { model, version })
}
fn parse_token_usage(text: &str) -> Option<GeminiResponse> {
static USAGE_RE: OnceLock<Regex> = OnceLock::new();
let re = USAGE_RE.get_or_init(|| {
Regex::new(r"Token usage:\s*([\d,]+)\s*used,\s*([\d,]+)\s*remaining").unwrap()
});
let caps = re.captures(text)?;
let used = caps.get(1)?
.as_str()
.replace(",", "")
.parse::<u64>()
.ok()?;
let remaining = caps.get(2)?
.as_str()
.replace(",", "")
.parse::<u64>()
.ok()?;
static CACHED_RE: OnceLock<Regex> = OnceLock::new();
let cached_re = CACHED_RE.get_or_init(|| {
Regex::new(r"Cached token savings:\s*([\d,]+)").unwrap()
});
let cached_savings = cached_re.captures(text)
.and_then(|cap| cap.get(1))
.and_then(|m| m.as_str().replace(",", "").parse::<u64>().ok());
Some(GeminiResponse::TokenUsage {
used,
remaining,
cached_savings,
})
}
fn parse_rate_limit(text: &str) -> Option<GeminiResponse> {
static RATE_LIMIT_RE: OnceLock<Regex> = OnceLock::new();
let re = RATE_LIMIT_RE.get_or_init(|| {
Regex::new(r"(?i)rate limit|quota exceeded").unwrap()
});
if !re.is_match(text) {
return None;
}
static RETRY_RE: OnceLock<Regex> = OnceLock::new();
let retry_re = RETRY_RE.get_or_init(|| {
Regex::new(r"(?i)try again (?:at|in)\s+([^\n]+)").unwrap()
});
let retry_at = retry_re.captures(text)
.and_then(|cap| cap.get(1))
.map(|m| m.as_str().trim().to_string());
Some(GeminiResponse::RateLimitHit {
retry_at,
message: text.to_string(),
})
}
fn parse_approval_mode(text: &str) -> Option<GeminiResponse> {
static APPROVAL_RE: OnceLock<Regex> = OnceLock::new();
let re = APPROVAL_RE.get_or_init(|| {
Regex::new(r"Approval mode:\s*(\w+)").unwrap()
});
let mode_str = re.captures(text)
.and_then(|cap| cap.get(1))
.map(|m| m.as_str())?;
let mode = match mode_str.to_lowercase().as_str() {
"default" => ApprovalMode::Default,
"auto_edit" | "autoedit" => ApprovalMode::AutoEdit,
"yolo" => ApprovalMode::Yolo,
"plan" => ApprovalMode::Plan,
_ => return None,
};
Some(GeminiResponse::ApprovalModeChanged { mode })
}
fn parse_memory_info(text: &str) -> Option<GeminiResponse> {
if !text.contains("GEMINI.md") && !text.contains("/memory") {
return None;
}
let mut file_paths = Vec::new();
let mut entries = Vec::new();
static PATH_RE: OnceLock<Regex> = OnceLock::new();
let path_re = PATH_RE.get_or_init(|| {
Regex::new(r"([~/.][\w/.-]*GEMINI\.md)").unwrap()
});
for cap in path_re.captures_iter(text) {
if let Some(path) = cap.get(1) {
file_paths.push(path.as_str().to_string());
}
}
if !file_paths.is_empty() {
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') || trimmed.starts_with('-') || trimmed.starts_with('*') {
entries.push(trimmed.to_string());
}
}
Some(GeminiResponse::MemoryInfo {
entries,
file_paths,
})
} else {
None
}
}
fn parse_mcp_list(text: &str) -> Option<GeminiResponse> {
if !text.contains("MCP") && !text.contains("server") {
return None;
}
let mut servers = Vec::new();
static SERVER_RE: OnceLock<Regex> = OnceLock::new();
let server_re = SERVER_RE.get_or_init(|| {
Regex::new(r"(\w+)\s+(connected|disconnected|error)").unwrap()
});
for cap in server_re.captures_iter(text) {
let name = cap.get(1).map(|m| m.as_str().to_string()).unwrap_or_default();
let status_str = cap.get(2).map(|m| m.as_str()).unwrap_or("");
let status = match status_str.to_lowercase().as_str() {
"connected" => McpServerStatus::Connected,
"disconnected" => McpServerStatus::Disconnected,
"error" => McpServerStatus::Error,
_ => McpServerStatus::Disconnected,
};
servers.push(McpServer {
name,
status,
tools: Vec::new(), description: None,
});
}
if servers.is_empty() {
None
} else {
Some(GeminiResponse::McpServerList { servers })
}
}
fn parse_skills_list(text: &str) -> Option<GeminiResponse> {
if !text.contains("skill") && !text.contains("Skills") {
return None;
}
let mut skills = Vec::new();
static SKILL_RE: OnceLock<Regex> = OnceLock::new();
let skill_re = SKILL_RE.get_or_init(|| {
Regex::new(r"(\w[\w-]+)\s+(enabled|disabled)").unwrap()
});
for cap in skill_re.captures_iter(text) {
let name = cap.get(1).map(|m| m.as_str().to_string()).unwrap_or_default();
let enabled = cap.get(2).map(|m| m.as_str() == "enabled").unwrap_or(false);
skills.push(Skill {
name,
enabled,
description: None,
});
}
if skills.is_empty() {
None
} else {
Some(GeminiResponse::SkillsList { skills })
}
}
fn parse_status_info(text: &str) -> Option<GeminiResponse> {
static DURATION_RE: OnceLock<Regex> = OnceLock::new();
let duration_re = DURATION_RE.get_or_init(|| {
Regex::new(r"Session duration:\s*([^\n]+)").unwrap()
});
let session_duration = duration_re.captures(text)
.and_then(|cap| cap.get(1))
.map(|m| m.as_str().trim().to_string());
static MODEL_RE: OnceLock<Regex> = OnceLock::new();
let model_re = MODEL_RE.get_or_init(|| {
Regex::new(r"Model:\s*(\S+)").unwrap()
});
let model = model_re.captures(text)
.and_then(|cap| cap.get(1))
.map(|m| m.as_str().to_string());
static TOKEN_RE: OnceLock<Regex> = OnceLock::new();
let token_re = TOKEN_RE.get_or_init(|| {
Regex::new(r"Total tokens:\s*([\d,]+)").unwrap()
});
let token_usage = token_re.captures(text)
.and_then(|cap| cap.get(1))
.and_then(|m| m.as_str().replace(",", "").parse::<u64>().ok());
if session_duration.is_some() || model.is_some() || token_usage.is_some() {
Some(GeminiResponse::StatusInfo {
session_duration,
model,
token_usage,
cached_tokens: None,
})
} else {
None
}
}
fn parse_checkpoint_info(text: &str) -> Option<GeminiResponse> {
if !text.contains("checkpoint") && !text.contains("restore") {
return None;
}
let mut checkpoints = Vec::new();
static CHECKPOINT_RE: OnceLock<Regex> = OnceLock::new();
let checkpoint_re = CHECKPOINT_RE.get_or_init(|| {
Regex::new(r"(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}_\d{3}Z)-(.+?)-(\w+)").unwrap()
});
for cap in checkpoint_re.captures_iter(text) {
let timestamp = cap.get(1).map(|m| m.as_str().to_string()).unwrap_or_default();
let filename = cap.get(2).map(|m| m.as_str().to_string()).unwrap_or_default();
let tool_name = cap.get(3).map(|m| m.as_str().to_string()).unwrap_or_default();
let id = cap.get(0).map(|m| m.as_str().to_string()).unwrap_or_default();
checkpoints.push(Checkpoint {
id,
timestamp,
filename,
tool_name,
});
}
if checkpoints.is_empty() {
None
} else {
Some(GeminiResponse::CheckpointInfo { checkpoints })
}
}
fn parse_session_list(text: &str) -> Option<GeminiResponse> {
if !text.contains("session") && !text.contains("Session") {
return None;
}
let mut sessions = Vec::new();
static SESSION_RE: OnceLock<Regex> = OnceLock::new();
let session_re = SESSION_RE.get_or_init(|| {
Regex::new(r"(\d+)\.\s+([a-f0-9-]+)\s+(.+?)(?:\n|$)").unwrap()
});
for cap in session_re.captures_iter(text) {
let index = cap.get(1).and_then(|m| m.as_str().parse::<usize>().ok());
let id = cap.get(2).map(|m| m.as_str().to_string()).unwrap_or_default();
let details = cap.get(3).map(|m| m.as_str()).unwrap_or("");
let created_at = if details.contains("created") {
Some(details.to_string())
} else {
None
};
sessions.push(Session {
id,
index,
created_at,
message_count: None,
});
}
if sessions.is_empty() {
None
} else {
Some(GeminiResponse::SessionList { sessions })
}
}
fn parse_extension_list(text: &str) -> Option<GeminiResponse> {
if !text.contains("extension") && !text.contains("Extension") {
return None;
}
let mut extensions = Vec::new();
static EXT_RE: OnceLock<Regex> = OnceLock::new();
let ext_re = EXT_RE.get_or_init(|| {
Regex::new(r"([\w-]+)(?:\s+v?([\d.]+))?\s+(enabled|disabled)").unwrap()
});
for cap in ext_re.captures_iter(text) {
let name = cap.get(1).map(|m| m.as_str().to_string()).unwrap_or_default();
let version = cap.get(2).map(|m| m.as_str().to_string());
let enabled = cap.get(3).map(|m| m.as_str() == "enabled").unwrap_or(false);
extensions.push(Extension {
name,
version,
enabled,
source: None,
});
}
if extensions.is_empty() {
None
} else {
Some(GeminiResponse::ExtensionList { extensions })
}
}
fn parse_model_menu(text: &str) -> Option<GeminiResponse> {
if !text.contains("model") || !text.contains("select") {
return None;
}
static CURRENT_RE: OnceLock<Regex> = OnceLock::new();
let current_re = CURRENT_RE.get_or_init(|| {
Regex::new(r"current:\s*(\S+)").unwrap()
});
let current_model = current_re.captures(text)
.and_then(|cap| cap.get(1))
.map(|m| m.as_str().to_string())?;
let mut available_models = Vec::new();
static MODEL_RE: OnceLock<Regex> = OnceLock::new();
let model_re = MODEL_RE.get_or_init(|| {
Regex::new(r"gemini-[\d.]+-[\w-]+").unwrap()
});
for cap in model_re.captures_iter(text) {
if let Some(model) = cap.get(0) {
let model_name = model.as_str().to_string();
if !available_models.contains(&model_name) {
available_models.push(model_name);
}
}
}
Some(GeminiResponse::ModelMenu {
current_model,
available_models,
})
}
fn parse_update_available(text: &str) -> Option<GeminiResponse> {
static UPDATE_RE: OnceLock<Regex> = OnceLock::new();
let re = UPDATE_RE.get_or_init(|| {
Regex::new(r"Update available.*?([\d.]+)\s*->\s*([\d.]+)").unwrap()
});
re.captures(text).map(|cap| {
GeminiResponse::UpdateAvailable {
current_version: cap.get(1).unwrap().as_str().to_string(),
new_version: cap.get(2).unwrap().as_str().to_string(),
}
})
}
fn parse_context_usage(text: &str) -> Option<GeminiResponse> {
static CONTEXT_RE: OnceLock<Regex> = OnceLock::new();
let re = CONTEXT_RE.get_or_init(|| {
Regex::new(r"Context:\s*([\d.]+)%|(\d+)%\s*used").unwrap()
});
re.captures(text).and_then(|cap| {
let percent_used = cap.get(1)
.or_else(|| cap.get(2))
.and_then(|m| m.as_str().parse::<f32>().ok())?;
Some(GeminiResponse::ContextUsage {
percent_used,
tokens_used: None,
tokens_total: None,
})
})
}
fn parse_tool_approval(text: &str) -> Option<GeminiResponse> {
if !text.contains("approve") && !text.contains("Allow") {
return None;
}
static TOOL_RE: OnceLock<Regex> = OnceLock::new();
let tool_re = TOOL_RE.get_or_init(|| {
Regex::new(r"(?:approve|allow)\s+(\w+)").unwrap()
});
tool_re.captures(text).map(|cap| {
let tool_name = cap.get(1).unwrap().as_str().to_string();
GeminiResponse::ToolApprovalRequest {
tool_name,
description: None,
}
})
}
fn parse_working_indicator(text: &str) -> Option<GeminiResponse> {
if text.contains("Working") || text.contains("Processing") || text.contains("...") {
Some(GeminiResponse::Working {
message: text.to_string(),
})
} else {
None
}
}
pub fn buffer(&self) -> &str {
&self.buffer
}
pub fn clear(&mut self) {
self.buffer.clear();
}
}
impl Default for GeminiOutputParser {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GeminiTopLevelCommand {
Interactive,
Prompt { text: String },
PromptInteractive { text: String },
Resume { id: Option<String> },
ListSessions,
DeleteSession { id: String },
Extensions { action: ExtensionAction },
Mcp { action: McpTopLevelAction },
Skills { action: SkillsAction },
Version,
Help,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExtensionAction {
Install { source: String, ref_name: Option<String>, auto_update: bool, pre_release: bool },
Uninstall { name: String },
List,
Update { name: Option<String> },
Enable { name: String, scope: Option<String> },
Disable { name: String, scope: Option<String> },
New { path: String, template: Option<String> },
Link { path: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpTopLevelAction {
Add { name: String, command_or_url: String, args: Vec<String>, transport: Option<String>, headers: Vec<String> },
List,
Remove { name: String },
Enable { name: String },
Disable { name: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillsAction {
Install { source: String, scope: Option<String> },
Uninstall { name: String },
Enable { name: String },
Disable { name: String },
}
impl GeminiTopLevelCommand {
pub fn to_command_string(&self) -> String {
match self {
Self::Interactive => "gemini".to_string(),
Self::Prompt { text } => format!("gemini -p \"{}\"", escape_quotes(text)),
Self::PromptInteractive { text } => format!("gemini -i \"{}\"", escape_quotes(text)),
Self::Resume { id } => {
if let Some(session_id) = id {
format!("gemini --resume {}", session_id)
} else {
"gemini --resume".to_string()
}
}
Self::ListSessions => "gemini --list-sessions".to_string(),
Self::DeleteSession { id } => format!("gemini --delete-session {}", id),
Self::Extensions { action } => match action {
ExtensionAction::Install { source, ref_name, auto_update, pre_release } => {
let mut cmd = format!("gemini extensions install {}", source);
if let Some(r) = ref_name {
cmd.push_str(&format!(" --ref {}", r));
}
if *auto_update {
cmd.push_str(" --auto-update");
}
if *pre_release {
cmd.push_str(" --pre-release");
}
cmd
}
ExtensionAction::Uninstall { name } => {
format!("gemini extensions uninstall {}", name)
}
ExtensionAction::List => "gemini extensions list".to_string(),
ExtensionAction::Update { name } => {
if let Some(n) = name {
format!("gemini extensions update {}", n)
} else {
"gemini extensions update --all".to_string()
}
}
ExtensionAction::Enable { name, scope } => {
let mut cmd = format!("gemini extensions enable {}", name);
if let Some(s) = scope {
cmd.push_str(&format!(" --scope {}", s));
}
cmd
}
ExtensionAction::Disable { name, scope } => {
let mut cmd = format!("gemini extensions disable {}", name);
if let Some(s) = scope {
cmd.push_str(&format!(" --scope {}", s));
}
cmd
}
ExtensionAction::New { path, template } => {
let mut cmd = format!("gemini extensions new {}", path);
if let Some(t) = template {
cmd.push_str(&format!(" {}", t));
}
cmd
}
ExtensionAction::Link { path } => {
format!("gemini extensions link {}", path)
}
},
Self::Mcp { action } => match action {
McpTopLevelAction::Add { name, command_or_url, args, transport, headers } => {
let mut cmd = format!("gemini mcp add {}", name);
if let Some(t) = transport {
cmd.push_str(&format!(" --transport {}", t));
}
for header in headers {
cmd.push_str(&format!(" --header \"{}\"", escape_quotes(header)));
}
cmd.push_str(&format!(" {}", command_or_url));
for arg in args {
cmd.push_str(&format!(" {}", arg));
}
cmd
}
McpTopLevelAction::List => "gemini mcp list".to_string(),
McpTopLevelAction::Remove { name } => format!("gemini mcp remove {}", name),
McpTopLevelAction::Enable { name } => format!("gemini mcp enable {}", name),
McpTopLevelAction::Disable { name } => format!("gemini mcp disable {}", name),
},
Self::Skills { action } => match action {
SkillsAction::Install { source, scope } => {
let mut cmd = format!("gemini skills install {}", source);
if let Some(s) = scope {
cmd.push_str(&format!(" --scope {}", s));
}
cmd
}
SkillsAction::Uninstall { name } => {
format!("gemini skills uninstall {}", name)
}
SkillsAction::Enable { name } => format!("gemini skills enable {}", name),
SkillsAction::Disable { name } => format!("gemini skills disable {}", name),
},
Self::Version => "gemini --version".to_string(),
Self::Help => "gemini --help".to_string(),
}
}
}
#[derive(Debug, Default)]
pub struct GeminiCommandBuilder {
flags: HashMap<String, Option<String>>,
prompt: Option<String>,
}
impl GeminiCommandBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn model(mut self, model: &str) -> Self {
self.flags.insert("--model".to_string(), Some(model.to_string()));
self
}
pub fn output_format(mut self, format: &str) -> Self {
self.flags.insert("--output-format".to_string(), Some(format.to_string()));
self
}
pub fn screen_reader(mut self) -> Self {
self.flags.insert("--screen-reader".to_string(), None);
self
}
pub fn debug(mut self) -> Self {
self.flags.insert("--debug".to_string(), None);
self
}
pub fn yolo(mut self) -> Self {
self.flags.insert("--yolo".to_string(), None);
self
}
pub fn approval_mode(mut self, mode: ApprovalMode) -> Self {
self.flags.insert("--approval-mode".to_string(), Some(mode.to_string()));
self
}
pub fn allowed_tools(mut self, tools: &str) -> Self {
self.flags.insert("--allowed-tools".to_string(), Some(tools.to_string()));
self
}
pub fn sandbox(mut self) -> Self {
self.flags.insert("--sandbox".to_string(), None);
self
}
pub fn include_directories(mut self, dirs: &str) -> Self {
self.flags.insert("--include-directories".to_string(), Some(dirs.to_string()));
self
}
pub fn resume(mut self, session: &str) -> Self {
self.flags.insert("--resume".to_string(), Some(session.to_string()));
self
}
pub fn extensions(mut self, extensions: &str) -> Self {
self.flags.insert("--extensions".to_string(), Some(extensions.to_string()));
self
}
pub fn allowed_mcp_servers(mut self, servers: &str) -> Self {
self.flags.insert("--allowed-mcp-server-names".to_string(), Some(servers.to_string()));
self
}
pub fn prompt(mut self, prompt: &str) -> Self {
self.prompt = Some(prompt.to_string());
self
}
pub fn build(&self) -> String {
let mut cmd = vec!["gemini".to_string()];
for (flag, value) in &self.flags {
cmd.push(flag.clone());
if let Some(val) = value {
cmd.push(format!("\"{}\"", escape_quotes(val)));
}
}
if let Some(prompt) = &self.prompt {
cmd.push("-p".to_string());
cmd.push(format!("\"{}\"", escape_quotes(prompt)));
}
cmd.join(" ")
}
}
fn escape_quotes(s: &str) -> String {
s.replace('"', "\\\"")
}
impl OutputParser for GeminiOutputParser {
fn feed(&mut self, data: &str) {
self.buffer.push_str(data);
}
fn parse(&mut self) -> Vec<ParsedMessage> {
let raw = std::mem::take(&mut self.buffer);
let responses = Self::parse_text(&raw);
self.response_complete = false;
let mut messages = Vec::new();
for resp in responses {
let content = match &resp {
GeminiResponse::Raw(s) => s.clone(),
GeminiResponse::Working { message } => message.clone(),
GeminiResponse::RateLimitHit { message, .. } => message.clone(),
GeminiResponse::WelcomeScreen { model, version } => {
format!("Gemini CLI (v{}) Model: {}", version, model)
}
GeminiResponse::ApprovalModeChanged { mode } => {
format!("Approval mode: {}", mode)
}
GeminiResponse::ToolApprovalRequest { tool_name, .. } => {
format!("Approve {}", tool_name)
}
other => format!("{:?}", other),
};
let class = self.classify(&content);
match class {
MessageClass::AiResponse => {
self.pending_response.push_str(&content);
messages.push(ParsedMessage {
class: MessageClass::AiResponse,
content,
metadata: MessageMetadata {
tool: CliTool::Gemini,
is_partial: true,
..Default::default()
},
});
}
MessageClass::PromptReady => {
if !self.pending_response.is_empty() {
let final_response = std::mem::take(&mut self.pending_response);
messages.push(ParsedMessage {
class: MessageClass::AiResponse,
content: final_response,
metadata: MessageMetadata {
tool: CliTool::Gemini,
is_partial: false,
..Default::default()
},
});
}
self.response_complete = true;
messages.push(ParsedMessage {
class: MessageClass::PromptReady,
content,
metadata: MessageMetadata {
tool: CliTool::Gemini,
..Default::default()
},
});
}
_ => {
messages.push(ParsedMessage {
class,
content,
metadata: MessageMetadata {
tool: CliTool::Gemini,
..Default::default()
},
});
}
}
}
messages
}
fn extract_ai_text(&self, raw_cleaned: &str) -> String {
let mut parser = VteParser::new();
let cleaned = parser.parse(raw_cleaned);
let mut result_lines = Vec::new();
let mut in_ai_response = false;
for line in cleaned.lines() {
let trimmed = line.trim();
if !in_ai_response && trimmed.is_empty() {
continue;
}
if trimmed.contains("███")
|| trimmed.contains("Tips for getting started")
|| trimmed.contains("GEMINI.md")
|| trimmed.contains("no sandbox")
|| trimmed.contains("sandbox")
|| trimmed.contains("Auto (Gemini")
|| trimmed.starts_with('┌')
|| trimmed.starts_with('└')
|| trimmed.starts_with('│')
|| trimmed.starts_with('─')
|| trimmed.starts_with('╭')
|| trimmed.starts_with('╰')
|| trimmed.starts_with('╯')
|| trimmed.starts_with('╮')
|| trimmed.contains("ctrl+")
|| trimmed.contains("shortcuts")
|| trimmed.contains("Ask questions")
|| trimmed.contains("Be specific")
|| trimmed.contains("help for more")
|| trimmed.contains("Initializing...")
|| trimmed.contains("Queued")
|| trimmed.contains("Navigate your prompt")
|| trimmed.contains("esc to cancel")
|| trimmed.contains("Up and Down arrows")
|| (trimmed.contains("press") && trimmed.contains("to edit"))
{
continue;
}
if trimmed.starts_with("> Type your message") {
break;
}
if trimmed.contains("Thinking")
|| trimmed.contains("thinking")
|| trimmed.contains("Answering in Kind")
|| trimmed.contains("Crafting a Reply")
|| trimmed.contains("Composing")
{
in_ai_response = true;
continue;
}
if !trimmed.is_empty() {
let cleaned_line: String = trimmed
.chars()
.filter(|c| {
c.is_alphanumeric()
|| c.is_whitespace()
|| ".,!?-\u{2014}:;()[]{}<<>>\"'".contains(*c)
|| (*c >= '\u{0400}' && *c <= '\u{04FF}') })
.collect();
let final_line = cleaned_line.trim();
if !final_line.is_empty()
&& !final_line.starts_with("0s")
&& !final_line.starts_with("1s")
&& !final_line.ends_with("s)")
{
in_ai_response = true;
result_lines.push(final_line.to_string());
}
}
}
let joined = result_lines.join(" ").trim().to_string();
if let Some(question_pos) = joined.find('?') {
let after_prompt = joined[question_pos + 1..].trim();
if !after_prompt.is_empty() {
return after_prompt.to_string();
}
}
joined
}
fn classify(&self, text: &str) -> MessageClass {
if text.contains("Working") || text.contains("Processing") || text.contains("Thinking") {
return MessageClass::ThinkingIndicator;
}
if text.contains("approve") || text.contains("Allow") {
return MessageClass::ToolApproval;
}
if text.contains("rate limit") || text.contains("quota exceeded") {
return MessageClass::Error;
}
if text.contains("Approval mode:") {
return MessageClass::InfoMessage;
}
if text.contains("███") {
return MessageClass::UiElement;
}
if text.contains('╭')
|| text.contains('╰')
|| text.contains('│')
|| text.contains('─')
{
return MessageClass::UiElement;
}
if text.contains("Tips for getting started")
|| text.contains("no sandbox")
|| text.contains("GEMINI.md")
{
return MessageClass::UiElement;
}
if text.contains("Type your message") {
return MessageClass::PromptReady;
}
MessageClass::Raw
}
fn buffer(&self) -> &str {
&self.buffer
}
fn clear(&mut self) {
self.buffer.clear();
}
fn tool(&self) -> CliTool {
CliTool::Gemini
}
}
pub struct GeminiPromptSubmitter;
impl GeminiPromptSubmitter {
pub fn new() -> Self {
Self
}
}
impl Default for GeminiPromptSubmitter {
fn default() -> Self {
Self::new()
}
}
impl PromptSubmitter for GeminiPromptSubmitter {
fn send_prompt(&self, writer: &mut dyn io::Write, prompt: &str) -> io::Result<()> {
writer.write_all(prompt.as_bytes())?;
writer.write_all(b"\t")?;
writer.write_all(b"\r")?;
writer.flush()
}
fn send_command(&self, writer: &mut dyn io::Write, command: &str) -> io::Result<()> {
writer.write_all(command.as_bytes())?;
writer.write_all(b"\r")?;
writer.flush()
}
fn send_control(&self, writer: &mut dyn io::Write, bytes: &[u8]) -> io::Result<()> {
writer.write_all(bytes)?;
writer.flush()
}
fn handle_startup(&self, output: &str) -> StartupAction {
if output.contains("Type your message") {
StartupAction::Ready
} else {
StartupAction::Waiting
}
}
fn tool(&self) -> CliTool {
CliTool::Gemini
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_command_to_pty_input() {
assert_eq!(GeminiCommand::Help.to_pty_input(), "/help\n");
assert_eq!(GeminiCommand::Stats.to_pty_input(), "/stats\n");
assert_eq!(GeminiCommand::Clear.to_pty_input(), "/clear\n");
}
#[test]
fn test_memory_commands() {
assert_eq!(
GeminiCommand::Memory { subcommand: Some(MemorySubcommand::Show) }.to_pty_input(),
"/memory show\n"
);
assert_eq!(
GeminiCommand::Memory {
subcommand: Some(MemorySubcommand::Add {
text: "test".to_string()
})
}
.to_pty_input(),
"/memory add test\n"
);
}
#[test]
fn test_chat_commands() {
assert_eq!(
GeminiCommand::Chat {
subcommand: Some(ChatSubcommand::Save {
tag: "checkpoint1".to_string()
})
}
.to_pty_input(),
"/chat save checkpoint1\n"
);
assert_eq!(
GeminiCommand::Chat {
subcommand: Some(ChatSubcommand::List)
}
.to_pty_input(),
"/chat list\n"
);
}
#[test]
fn test_mcp_commands() {
assert_eq!(
GeminiCommand::Mcp { subcommand: Some(McpSubcommand::List) }.to_pty_input(),
"/mcp list\n"
);
assert_eq!(
GeminiCommand::Mcp {
subcommand: Some(McpSubcommand::Auth {
server_name: Some("github".to_string())
})
}
.to_pty_input(),
"/mcp auth github\n"
);
}
#[test]
fn test_skills_commands() {
assert_eq!(
GeminiCommand::Skills { subcommand: Some(SkillsSubcommand::List) }.to_pty_input(),
"/skills list\n"
);
assert_eq!(
GeminiCommand::Skills {
subcommand: Some(SkillsSubcommand::Enable {
name: "skill-creator".to_string()
})
}
.to_pty_input(),
"/skills enable skill-creator\n"
);
}
#[test]
fn test_control_to_bytes() {
assert_eq!(GeminiControl::Cancel.to_bytes(), vec![0x03]);
assert_eq!(GeminiControl::Exit.to_bytes(), vec![0x04]);
assert_eq!(GeminiControl::ClearScreen.to_bytes(), vec![0x0C]);
assert_eq!(GeminiControl::ToggleYolo.to_bytes(), vec![0x19]);
}
#[test]
fn test_special_input() {
assert_eq!(
GeminiSpecialInput::FileReference("src/main.rs".to_string()).to_pty_input(),
"@src/main.rs"
);
assert_eq!(
GeminiSpecialInput::ShellCommand("git status".to_string()).to_pty_input(),
"!git status\n"
);
}
#[test]
fn test_parse_welcome_screen() {
let text = "Gemini CLI (v0.23.0)\nModel: gemini-2.5-pro";
let response = GeminiOutputParser::parse_welcome_screen(text).unwrap();
match response {
GeminiResponse::WelcomeScreen { model, version } => {
assert_eq!(version, "0.23.0");
assert_eq!(model, "gemini-2.5-pro");
}
_ => panic!("Expected WelcomeScreen"),
}
}
#[test]
fn test_parse_token_usage() {
let text = "Token usage: 12,345 used, 87,655 remaining\nCached token savings: 5,000";
let response = GeminiOutputParser::parse_token_usage(text).unwrap();
match response {
GeminiResponse::TokenUsage { used, remaining, cached_savings } => {
assert_eq!(used, 12345);
assert_eq!(remaining, 87655);
assert_eq!(cached_savings, Some(5000));
}
_ => panic!("Expected TokenUsage"),
}
}
#[test]
fn test_parse_rate_limit() {
let text = "Rate limit exceeded. Try again at 2026-01-26 15:30:00 UTC";
let response = GeminiOutputParser::parse_rate_limit(text).unwrap();
match response {
GeminiResponse::RateLimitHit { retry_at, .. } => {
assert_eq!(retry_at, Some("2026-01-26 15:30:00 UTC".to_string()));
}
_ => panic!("Expected RateLimitHit"),
}
}
#[test]
fn test_parse_approval_mode() {
let text = "Approval mode: auto_edit";
let response = GeminiOutputParser::parse_approval_mode(text).unwrap();
match response {
GeminiResponse::ApprovalModeChanged { mode } => {
assert_eq!(mode, ApprovalMode::AutoEdit);
}
_ => panic!("Expected ApprovalModeChanged"),
}
}
#[test]
fn test_top_level_commands() {
assert_eq!(
GeminiTopLevelCommand::Interactive.to_command_string(),
"gemini"
);
assert_eq!(
GeminiTopLevelCommand::Prompt {
text: "test query".to_string()
}
.to_command_string(),
"gemini -p \"test query\""
);
assert_eq!(
GeminiTopLevelCommand::ListSessions.to_command_string(),
"gemini --list-sessions"
);
}
#[test]
fn test_command_builder() {
let cmd = GeminiCommandBuilder::new()
.model("gemini-2.5-flash")
.yolo()
.prompt("test query")
.build();
assert!(cmd.contains("gemini"));
assert!(cmd.contains("--model"));
assert!(cmd.contains("gemini-2.5-flash"));
assert!(cmd.contains("--yolo"));
assert!(cmd.contains("-p"));
assert!(cmd.contains("test query"));
}
#[test]
fn test_approval_mode_display() {
assert_eq!(ApprovalMode::Default.to_string(), "default");
assert_eq!(ApprovalMode::AutoEdit.to_string(), "auto_edit");
assert_eq!(ApprovalMode::Yolo.to_string(), "yolo");
assert_eq!(ApprovalMode::Plan.to_string(), "plan");
}
#[test]
fn test_extension_commands() {
let cmd = GeminiTopLevelCommand::Extensions {
action: ExtensionAction::Install {
source: "https://github.com/user/ext".to_string(),
ref_name: Some("v1.0.0".to_string()),
auto_update: true,
pre_release: false,
},
};
let cmd_str = cmd.to_command_string();
assert!(cmd_str.contains("gemini extensions install"));
assert!(cmd_str.contains("https://github.com/user/ext"));
assert!(cmd_str.contains("--ref v1.0.0"));
assert!(cmd_str.contains("--auto-update"));
}
#[test]
fn test_mcp_top_level_commands() {
let cmd = GeminiTopLevelCommand::Mcp {
action: McpTopLevelAction::Add {
name: "my-server".to_string(),
command_or_url: "/path/to/server".to_string(),
args: vec!["arg1".to_string(), "arg2".to_string()],
transport: None,
headers: vec![],
},
};
let cmd_str = cmd.to_command_string();
assert!(cmd_str.contains("gemini mcp add my-server"));
assert!(cmd_str.contains("/path/to/server"));
assert!(cmd_str.contains("arg1"));
assert!(cmd_str.contains("arg2"));
}
#[test]
fn test_parse_memory_info() {
let text = "~/.gemini/GEMINI.md\n.gemini/GEMINI.md\n# Instructions\n- Use Rust\n- Follow best practices";
let response = GeminiOutputParser::parse_memory_info(text).unwrap();
match response {
GeminiResponse::MemoryInfo { entries, file_paths } => {
assert_eq!(file_paths.len(), 2);
assert!(file_paths.contains(&"~/.gemini/GEMINI.md".to_string()));
assert!(entries.len() > 0);
}
_ => panic!("Expected MemoryInfo"),
}
}
#[test]
fn test_parse_update_available() {
let text = "Update available: 0.23.0 -> 0.24.0";
let response = GeminiOutputParser::parse_update_available(text).unwrap();
match response {
GeminiResponse::UpdateAvailable { current_version, new_version } => {
assert_eq!(current_version, "0.23.0");
assert_eq!(new_version, "0.24.0");
}
_ => panic!("Expected UpdateAvailable"),
}
}
#[test]
fn test_directory_commands() {
assert_eq!(
GeminiCommand::Directory {
subcommand: Some(DirectorySubcommand::Add {
paths: vec!["../lib".to_string(), "../docs".to_string()]
})
}
.to_pty_input(),
"/directory add ../lib,../docs\n"
);
assert_eq!(
GeminiCommand::Directory {
subcommand: Some(DirectorySubcommand::Show)
}
.to_pty_input(),
"/directory show\n"
);
}
#[test]
fn test_restore_command() {
assert_eq!(
GeminiCommand::Restore { tool_call_id: None }.to_pty_input(),
"/restore\n"
);
assert_eq!(
GeminiCommand::Restore {
tool_call_id: Some("2025-06-22T10-00-00_000Z-file.txt-write_file".to_string())
}
.to_pty_input(),
"/restore 2025-06-22T10-00-00_000Z-file.txt-write_file\n"
);
}
}
pub struct GeminiPipeBuilder;
impl CliCommandBuilder for GeminiPipeBuilder {
fn build_command(&self, opts: &SpawnOptions) -> std::process::Command {
let mut cmd = std::process::Command::new("gemini");
cmd.arg("--output-format");
cmd.arg("stream-json");
cmd.arg("-p");
for arg in &opts.extra_args {
cmd.arg(arg);
}
cmd.arg(&opts.prompt);
cmd
}
}