From ab55dd2796916f2839b53541693b9a044b431deb Mon Sep 17 00:00:00 2001 From: Louis Knight-Webb Date: Wed, 25 Jun 2025 18:23:50 +0100 Subject: [PATCH] Gemini support --- backend/src/bin/generate_types.rs | 7 +- backend/src/executor.rs | 6 +- backend/src/executors/gemini.rs | 84 +++++++++++++++++++ backend/src/executors/mod.rs | 2 + backend/src/models/task_attempt.rs | 18 +++- backend/src/routes/tasks.rs | 1 + frontend/src/components/OnboardingDialog.tsx | 1 + .../tasks/ExecutionOutputViewer.tsx | 7 +- .../components/tasks/TaskDetailsToolbar.tsx | 1 + shared/types.ts | 12 +-- 10 files changed, 127 insertions(+), 12 deletions(-) create mode 100644 backend/src/executors/gemini.rs diff --git a/backend/src/bin/generate_types.rs b/backend/src/bin/generate_types.rs index d63209ce..233c310f 100644 --- a/backend/src/bin/generate_types.rs +++ b/backend/src/bin/generate_types.rs @@ -7,7 +7,8 @@ fn generate_constants() -> String { export const EXECUTOR_TYPES: string[] = [ "echo", "claude", - "amp" + "amp", + "gemini" ]; export const EDITOR_TYPES: EditorType[] = [ @@ -22,7 +23,8 @@ export const EDITOR_TYPES: EditorType[] = [ export const EXECUTOR_LABELS: Record = { "echo": "Echo (Test Mode)", "claude": "Claude", - "amp": "Amp" + "amp": "Amp", + "gemini": "Gemini" }; export const EDITOR_LABELS: Record = { @@ -97,6 +99,7 @@ fn main() { vibe_kanban::models::task_attempt::UpdateTaskAttempt::decl(), vibe_kanban::models::task_attempt::CreateFollowUpAttempt::decl(), vibe_kanban::models::task_attempt_activity::TaskAttemptActivity::decl(), + vibe_kanban::models::task_attempt_activity::TaskAttemptActivityWithPrompt::decl(), vibe_kanban::models::task_attempt_activity::CreateTaskAttemptActivity::decl(), vibe_kanban::routes::filesystem::DirectoryEntry::decl(), vibe_kanban::models::task_attempt::DiffChunkType::decl(), diff --git a/backend/src/executor.rs b/backend/src/executor.rs index 6e8531fc..888e2de7 100644 --- a/backend/src/executor.rs +++ b/backend/src/executor.rs @@ -7,7 +7,7 @@ use tokio::{ use ts_rs::TS; use uuid::Uuid; -use crate::executors::{AmpExecutor, ClaudeExecutor, EchoExecutor}; +use crate::executors::{AmpExecutor, ClaudeExecutor, EchoExecutor, GeminiExecutor}; #[derive(Debug)] pub enum ExecutorError { @@ -110,6 +110,7 @@ pub enum ExecutorConfig { Echo, Claude, Amp, + Gemini, // Future executors can be added here // Shell { command: String }, // Docker { image: String, command: String }, @@ -130,11 +131,13 @@ impl ExecutorConstants { ExecutorConfig::Echo, ExecutorConfig::Claude, ExecutorConfig::Amp, + ExecutorConfig::Gemini, ], executor_labels: vec![ "Echo (Test Mode)".to_string(), "Claude".to_string(), "Amp".to_string(), + "Gemini".to_string(), ], } } @@ -146,6 +149,7 @@ impl ExecutorConfig { ExecutorConfig::Echo => Box::new(EchoExecutor), ExecutorConfig::Claude => Box::new(ClaudeExecutor), ExecutorConfig::Amp => Box::new(AmpExecutor), + ExecutorConfig::Gemini => Box::new(GeminiExecutor), } } } diff --git a/backend/src/executors/gemini.rs b/backend/src/executors/gemini.rs new file mode 100644 index 00000000..1fc2e571 --- /dev/null +++ b/backend/src/executors/gemini.rs @@ -0,0 +1,84 @@ +use async_trait::async_trait; +use tokio::process::{Child, Command}; +use uuid::Uuid; + +use crate::{ + executor::{Executor, ExecutorError}, + models::task::Task, +}; + +/// An executor that uses Gemini CLI to process tasks +pub struct GeminiExecutor; + +/// An executor that resumes a Gemini session +pub struct GeminiFollowupExecutor { + pub session_id: String, + pub prompt: String, +} + +#[async_trait] +impl Executor for GeminiExecutor { + async fn spawn( + &self, + pool: &sqlx::SqlitePool, + task_id: Uuid, + worktree_path: &str, + ) -> Result { + // Get the task to fetch its description + let task = Task::find_by_id(pool, task_id) + .await? + .ok_or(ExecutorError::TaskNotFound)?; + + let prompt = format!( + "Task title: {} + Task description: {}", + task.title, + task.description + .as_deref() + .unwrap_or("No description provided") + ); + + // Use Gemini CLI to process the task + let child = Command::new("npx") + .kill_on_drop(true) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .current_dir(worktree_path) + .arg("@bloopai/gemini-cli-interactive") + .arg("-p") + .arg(&prompt) + .process_group(0) // Create new process group so we can kill entire tree + .spawn() + .map_err(ExecutorError::SpawnFailed)?; + + Ok(child) + } +} + +#[async_trait] +impl Executor for GeminiFollowupExecutor { + async fn spawn( + &self, + pool: &sqlx::SqlitePool, + task_id: Uuid, + worktree_path: &str, + ) -> Result { + // Use Gemini CLI with session resumption (if supported) + let child = Command::new("npx") + .kill_on_drop(true) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .current_dir(worktree_path) + .arg("@bloopai/gemini-cli-interactive") + .arg("-p") + .arg(&self.prompt) + .arg(format!("--resume={}", self.session_id)) + .process_group(0) // Create new process group so we can kill entire tree + .spawn() + .map_err(ExecutorError::SpawnFailed)?; + + Ok(child) + } +} diff --git a/backend/src/executors/mod.rs b/backend/src/executors/mod.rs index 2cc76479..fc7a245a 100644 --- a/backend/src/executors/mod.rs +++ b/backend/src/executors/mod.rs @@ -2,10 +2,12 @@ pub mod amp; pub mod claude; pub mod dev_server; pub mod echo; +pub mod gemini; pub mod setup_script; pub use amp::{AmpExecutor, AmpFollowupExecutor}; pub use claude::{ClaudeExecutor, ClaudeFollowupExecutor}; pub use dev_server::DevServerExecutor; pub use echo::EchoExecutor; +pub use gemini::{GeminiExecutor, GeminiFollowupExecutor}; pub use setup_script::SetupScriptExecutor; diff --git a/backend/src/models/task_attempt.rs b/backend/src/models/task_attempt.rs index 7b7e1687..81f6bb81 100644 --- a/backend/src/models/task_attempt.rs +++ b/backend/src/models/task_attempt.rs @@ -583,6 +583,7 @@ impl TaskAttempt { let executor_config = match most_recent_coding_agent.executor_type.as_deref() { Some("claude") => crate::executor::ExecutorConfig::Claude, Some("amp") => crate::executor::ExecutorConfig::Amp, + Some("gemini") => crate::executor::ExecutorConfig::Gemini, Some("echo") => crate::executor::ExecutorConfig::Echo, _ => return Err(TaskAttemptError::TaskNotFound), // Invalid executor type }; @@ -613,6 +614,7 @@ impl TaskAttempt { match executor_name.as_ref().map(|s| s.as_str()) { Some("claude") => crate::executor::ExecutorConfig::Claude, Some("amp") => crate::executor::ExecutorConfig::Amp, + Some("gemini") => crate::executor::ExecutorConfig::Gemini, _ => crate::executor::ExecutorConfig::Echo, // Default for "echo" or None } } @@ -725,6 +727,7 @@ impl TaskAttempt { crate::executor::ExecutorConfig::Echo => "echo", crate::executor::ExecutorConfig::Claude => "claude", crate::executor::ExecutorConfig::Amp => "amp", + crate::executor::ExecutorConfig::Gemini => "gemini", }; ( "executor".to_string(), @@ -737,6 +740,7 @@ impl TaskAttempt { crate::executor::ExecutorConfig::Echo => "echo", crate::executor::ExecutorConfig::Claude => "claude", crate::executor::ExecutorConfig::Amp => "amp", + crate::executor::ExecutorConfig::Gemini => "gemini", }; ( "followup_executor".to_string(), @@ -856,7 +860,9 @@ impl TaskAttempt { session_id, prompt, } => { - use crate::executors::{AmpFollowupExecutor, ClaudeFollowupExecutor}; + use crate::executors::{ + AmpFollowupExecutor, ClaudeFollowupExecutor, GeminiFollowupExecutor, + }; let executor: Box = match config { crate::executor::ExecutorConfig::Claude => { @@ -879,6 +885,16 @@ impl TaskAttempt { return Err(TaskAttemptError::TaskNotFound); // No thread ID for followup } } + crate::executor::ExecutorConfig::Gemini => { + if let Some(sid) = session_id { + Box::new(GeminiFollowupExecutor { + session_id: sid.clone(), + prompt: prompt.clone(), + }) + } else { + return Err(TaskAttemptError::TaskNotFound); // No session ID for followup + } + } crate::executor::ExecutorConfig::Echo => { // Echo doesn't support followup, use regular echo config.create_executor() diff --git a/backend/src/routes/tasks.rs b/backend/src/routes/tasks.rs index e79354ad..814ec995 100644 --- a/backend/src/routes/tasks.rs +++ b/backend/src/routes/tasks.rs @@ -141,6 +141,7 @@ pub async fn create_task_and_start( crate::executor::ExecutorConfig::Echo => "echo".to_string(), crate::executor::ExecutorConfig::Claude => "claude".to_string(), crate::executor::ExecutorConfig::Amp => "amp".to_string(), + crate::executor::ExecutorConfig::Gemini => "gemini".to_string(), }); let attempt_payload = CreateTaskAttempt { executor: executor_string, diff --git a/frontend/src/components/OnboardingDialog.tsx b/frontend/src/components/OnboardingDialog.tsx index 2336c958..a38e3559 100644 --- a/frontend/src/components/OnboardingDialog.tsx +++ b/frontend/src/components/OnboardingDialog.tsx @@ -99,6 +99,7 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {

{executor.type === 'claude' && 'Claude Code from Anthropic'} {executor.type === 'amp' && 'From Sourcegraph'} + {executor.type === 'gemini' && 'Google Gemini from Bloop'} {executor.type === 'echo' && 'This is just for debugging vibe-kanban itself'}

diff --git a/frontend/src/components/tasks/ExecutionOutputViewer.tsx b/frontend/src/components/tasks/ExecutionOutputViewer.tsx index daa5e1b6..97ccad20 100644 --- a/frontend/src/components/tasks/ExecutionOutputViewer.tsx +++ b/frontend/src/components/tasks/ExecutionOutputViewer.tsx @@ -36,12 +36,13 @@ export function ExecutionOutputViewer({ const isAmpExecutor = executor === 'amp'; const isClaudeExecutor = executor === 'claude'; + const isGeminiExecutor = executor === 'gemini'; const hasStdout = !!executionProcess.stdout; const hasStderr = !!executionProcess.stderr; - // Check if stdout looks like JSONL (for Amp or Claude executor) + // Check if stdout looks like JSONL (for Amp, Claude, or Gemini executor) const { isValidJsonl, jsonlFormat } = useMemo(() => { - if ((!isAmpExecutor && !isClaudeExecutor) || !executionProcess.stdout) { + if ((!isAmpExecutor && !isClaudeExecutor && !isGeminiExecutor) || !executionProcess.stdout) { return { isValidJsonl: false, jsonlFormat: null }; } @@ -98,7 +99,7 @@ export function ExecutionOutputViewer({ } catch { return { isValidJsonl: false, jsonlFormat: null }; } - }, [isAmpExecutor, isClaudeExecutor, executionProcess.stdout]); + }, [isAmpExecutor, isClaudeExecutor, isGeminiExecutor, executionProcess.stdout]); // Set initial view mode based on JSONL detection useEffect(() => { diff --git a/frontend/src/components/tasks/TaskDetailsToolbar.tsx b/frontend/src/components/tasks/TaskDetailsToolbar.tsx index a725daca..0a5aecab 100644 --- a/frontend/src/components/tasks/TaskDetailsToolbar.tsx +++ b/frontend/src/components/tasks/TaskDetailsToolbar.tsx @@ -56,6 +56,7 @@ const availableExecutors = [ { id: 'echo', name: 'Echo' }, { id: 'claude', name: 'Claude' }, { id: 'amp', name: 'Amp' }, + { id: 'gemini', name: 'Gemini' }, ]; export function TaskDetailsToolbar({ diff --git a/shared/types.ts b/shared/types.ts index 3a037de8..c927df46 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -20,7 +20,7 @@ export type SoundConstants = { sound_files: Array, sound_labels: Arra export type ConfigConstants = { editor: EditorConstants, sound: SoundConstants, }; -export type ExecutorConfig = { "type": "echo" } | { "type": "claude" } | { "type": "amp" }; +export type ExecutorConfig = { "type": "echo" } | { "type": "claude" } | { "type": "amp" } | { "type": "gemini" }; export type ExecutorConstants = { executor_types: Array, executor_labels: Array, }; @@ -60,10 +60,10 @@ export type CreateFollowUpAttempt = { prompt: string, }; export type TaskAttemptActivity = { id: string, execution_process_id: string, status: TaskAttemptStatus, note: string | null, created_at: string, }; -export type CreateTaskAttemptActivity = { execution_process_id: string, status: TaskAttemptStatus | null, note: string | null, }; - export type TaskAttemptActivityWithPrompt = { id: string, execution_process_id: string, status: TaskAttemptStatus, note: string | null, created_at: string, prompt: string | null, }; +export type CreateTaskAttemptActivity = { execution_process_id: string, status: TaskAttemptStatus | null, note: string | null, }; + export type DirectoryEntry = { name: string, path: string, is_directory: boolean, is_git_repo: boolean, }; export type DiffChunkType = "Equal" | "Insert" | "Delete"; @@ -98,7 +98,8 @@ export type UpdateExecutorSession = { session_id: string | null, prompt: string export const EXECUTOR_TYPES: string[] = [ "echo", "claude", - "amp" + "amp", + "gemini" ]; export const EDITOR_TYPES: EditorType[] = [ @@ -113,7 +114,8 @@ export const EDITOR_TYPES: EditorType[] = [ export const EXECUTOR_LABELS: Record = { "echo": "Echo (Test Mode)", "claude": "Claude", - "amp": "Amp" + "amp": "Amp", + "gemini": "Gemini" }; export const EDITOR_LABELS: Record = {