Merge remote-tracking branch 'upstream/v0.14.0rc' into feature/unified-sidebar

This commit is contained in:
Stefan de Vogelaere
2026-01-23 01:47:05 +01:00
52 changed files with 3592 additions and 315 deletions

View File

@@ -0,0 +1,815 @@
/**
* Gemini Provider - Executes queries using the Gemini CLI
*
* Extends CliProvider with Gemini-specific:
* - Event normalization for Gemini's JSONL streaming format
* - Google account and API key authentication support
* - Thinking level configuration
*
* Based on https://github.com/google-gemini/gemini-cli
*/
import { execSync } from 'child_process';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { CliProvider, type CliSpawnConfig, type CliErrorInfo } from './cli-provider.js';
import type {
ProviderConfig,
ExecuteOptions,
ProviderMessage,
InstallationStatus,
ModelDefinition,
ContentBlock,
} from './types.js';
import { validateBareModelId } from '@automaker/types';
import { GEMINI_MODEL_MAP, type GeminiAuthStatus } from '@automaker/types';
import { createLogger, isAbortError } from '@automaker/utils';
import { spawnJSONLProcess } from '@automaker/platform';
// Create logger for this module
const logger = createLogger('GeminiProvider');
// =============================================================================
// Gemini Stream Event Types
// =============================================================================
/**
* Base event structure from Gemini CLI --output-format stream-json
*
* Actual CLI output format:
* {"type":"init","timestamp":"...","session_id":"...","model":"..."}
* {"type":"message","timestamp":"...","role":"user","content":"..."}
* {"type":"message","timestamp":"...","role":"assistant","content":"...","delta":true}
* {"type":"tool_use","timestamp":"...","tool_name":"...","tool_id":"...","parameters":{...}}
* {"type":"tool_result","timestamp":"...","tool_id":"...","status":"success","output":"..."}
* {"type":"result","timestamp":"...","status":"success","stats":{...}}
*/
interface GeminiStreamEvent {
type: 'init' | 'message' | 'tool_use' | 'tool_result' | 'result' | 'error';
timestamp?: string;
session_id?: string;
}
interface GeminiInitEvent extends GeminiStreamEvent {
type: 'init';
session_id: string;
model: string;
}
interface GeminiMessageEvent extends GeminiStreamEvent {
type: 'message';
role: 'user' | 'assistant';
content: string;
delta?: boolean;
session_id?: string;
}
interface GeminiToolUseEvent extends GeminiStreamEvent {
type: 'tool_use';
tool_id: string;
tool_name: string;
parameters: Record<string, unknown>;
session_id?: string;
}
interface GeminiToolResultEvent extends GeminiStreamEvent {
type: 'tool_result';
tool_id: string;
status: 'success' | 'error';
output: string;
session_id?: string;
}
interface GeminiResultEvent extends GeminiStreamEvent {
type: 'result';
status: 'success' | 'error';
stats?: {
total_tokens?: number;
input_tokens?: number;
output_tokens?: number;
cached?: number;
input?: number;
duration_ms?: number;
tool_calls?: number;
};
error?: string;
session_id?: string;
}
// =============================================================================
// Error Codes
// =============================================================================
export enum GeminiErrorCode {
NOT_INSTALLED = 'GEMINI_NOT_INSTALLED',
NOT_AUTHENTICATED = 'GEMINI_NOT_AUTHENTICATED',
RATE_LIMITED = 'GEMINI_RATE_LIMITED',
MODEL_UNAVAILABLE = 'GEMINI_MODEL_UNAVAILABLE',
NETWORK_ERROR = 'GEMINI_NETWORK_ERROR',
PROCESS_CRASHED = 'GEMINI_PROCESS_CRASHED',
TIMEOUT = 'GEMINI_TIMEOUT',
UNKNOWN = 'GEMINI_UNKNOWN_ERROR',
}
export interface GeminiError extends Error {
code: GeminiErrorCode;
recoverable: boolean;
suggestion?: string;
}
// =============================================================================
// Tool Name Normalization
// =============================================================================
/**
* Gemini CLI tool name to standard tool name mapping
* This allows the UI to properly categorize and display Gemini tool calls
*/
const GEMINI_TOOL_NAME_MAP: Record<string, string> = {
write_todos: 'TodoWrite',
read_file: 'Read',
read_many_files: 'Read',
replace: 'Edit',
write_file: 'Write',
run_shell_command: 'Bash',
search_file_content: 'Grep',
glob: 'Glob',
list_directory: 'Ls',
web_fetch: 'WebFetch',
google_web_search: 'WebSearch',
};
/**
* Normalize Gemini tool names to standard tool names
*/
function normalizeGeminiToolName(geminiToolName: string): string {
return GEMINI_TOOL_NAME_MAP[geminiToolName] || geminiToolName;
}
/**
* Normalize Gemini tool input parameters to standard format
*
* Gemini `write_todos` format:
* {"todos": [{"description": "Task text", "status": "pending|in_progress|completed|cancelled"}]}
*
* Claude `TodoWrite` format:
* {"todos": [{"content": "Task text", "status": "pending|in_progress|completed", "activeForm": "..."}]}
*/
function normalizeGeminiToolInput(
toolName: string,
input: Record<string, unknown>
): Record<string, unknown> {
// Normalize write_todos: map 'description' to 'content', handle 'cancelled' status
if (toolName === 'write_todos' && Array.isArray(input.todos)) {
return {
todos: input.todos.map((todo: { description?: string; status?: string }) => ({
content: todo.description || '',
// Map 'cancelled' to 'completed' since Claude doesn't have cancelled status
status: todo.status === 'cancelled' ? 'completed' : todo.status,
// Use description as activeForm since Gemini doesn't have it
activeForm: todo.description || '',
})),
};
}
return input;
}
/**
* GeminiProvider - Integrates Gemini CLI as an AI provider
*
* Features:
* - Google account OAuth login support
* - API key authentication (GEMINI_API_KEY)
* - Vertex AI support
* - Thinking level configuration
* - Streaming JSON output
*/
export class GeminiProvider extends CliProvider {
constructor(config: ProviderConfig = {}) {
super(config);
// Trigger CLI detection on construction
this.ensureCliDetected();
}
// ==========================================================================
// CliProvider Abstract Method Implementations
// ==========================================================================
getName(): string {
return 'gemini';
}
getCliName(): string {
return 'gemini';
}
getSpawnConfig(): CliSpawnConfig {
return {
windowsStrategy: 'npx', // Gemini CLI can be run via npx
npxPackage: '@google/gemini-cli', // Official Google Gemini CLI package
commonPaths: {
linux: [
path.join(os.homedir(), '.local/bin/gemini'),
'/usr/local/bin/gemini',
path.join(os.homedir(), '.npm-global/bin/gemini'),
],
darwin: [
path.join(os.homedir(), '.local/bin/gemini'),
'/usr/local/bin/gemini',
'/opt/homebrew/bin/gemini',
path.join(os.homedir(), '.npm-global/bin/gemini'),
],
win32: [
path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'gemini.cmd'),
path.join(os.homedir(), '.npm-global', 'gemini.cmd'),
],
},
};
}
/**
* Extract prompt text from ExecuteOptions
*/
private extractPromptText(options: ExecuteOptions): string {
if (typeof options.prompt === 'string') {
return options.prompt;
} else if (Array.isArray(options.prompt)) {
return options.prompt
.filter((p) => p.type === 'text' && p.text)
.map((p) => p.text)
.join('\n');
} else {
throw new Error('Invalid prompt format');
}
}
buildCliArgs(options: ExecuteOptions): string[] {
// Model comes in stripped of provider prefix (e.g., '2.5-flash' from 'gemini-2.5-flash')
// We need to add 'gemini-' back since it's part of the actual CLI model name
const bareModel = options.model || '2.5-flash';
const cliArgs: string[] = [];
// Streaming JSON output format for real-time updates
cliArgs.push('--output-format', 'stream-json');
// Model selection - Gemini CLI expects full model names like "gemini-2.5-flash"
// Unlike Cursor CLI where 'cursor-' is just a routing prefix, for Gemini CLI
// the 'gemini-' is part of the actual model name Google expects
if (bareModel && bareModel !== 'auto') {
// Add gemini- prefix if not already present (handles edge cases)
const cliModel = bareModel.startsWith('gemini-') ? bareModel : `gemini-${bareModel}`;
cliArgs.push('--model', cliModel);
}
// Disable sandbox mode for faster execution (sandbox adds overhead)
cliArgs.push('--sandbox', 'false');
// YOLO mode for automatic approval (required for non-interactive use)
// Use explicit approval-mode for clearer semantics
cliArgs.push('--approval-mode', 'yolo');
// Explicitly include the working directory in allowed workspace directories
// This ensures Gemini CLI allows file operations in the project directory,
// even if it has a different workspace cached from a previous session
if (options.cwd) {
cliArgs.push('--include-directories', options.cwd);
}
// Note: Gemini CLI doesn't have a --thinking-level flag.
// Thinking capabilities are determined by the model selection (e.g., gemini-2.5-pro).
// The model handles thinking internally based on the task complexity.
// The prompt will be passed as the last positional argument
// We'll append it in executeQuery after extracting the text
return cliArgs;
}
/**
* Convert Gemini event to AutoMaker ProviderMessage format
*/
normalizeEvent(event: unknown): ProviderMessage | null {
const geminiEvent = event as GeminiStreamEvent;
switch (geminiEvent.type) {
case 'init': {
// Init event - capture session but don't yield a message
const initEvent = geminiEvent as GeminiInitEvent;
logger.debug(
`Gemini init event: session=${initEvent.session_id}, model=${initEvent.model}`
);
return null;
}
case 'message': {
const messageEvent = geminiEvent as GeminiMessageEvent;
// Skip user messages - already handled by caller
if (messageEvent.role === 'user') {
return null;
}
// Handle assistant messages
if (messageEvent.role === 'assistant') {
return {
type: 'assistant',
session_id: messageEvent.session_id,
message: {
role: 'assistant',
content: [{ type: 'text', text: messageEvent.content }],
},
};
}
return null;
}
case 'tool_use': {
const toolEvent = geminiEvent as GeminiToolUseEvent;
const normalizedName = normalizeGeminiToolName(toolEvent.tool_name);
const normalizedInput = normalizeGeminiToolInput(
toolEvent.tool_name,
toolEvent.parameters as Record<string, unknown>
);
return {
type: 'assistant',
session_id: toolEvent.session_id,
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
name: normalizedName,
tool_use_id: toolEvent.tool_id,
input: normalizedInput,
},
],
},
};
}
case 'tool_result': {
const toolResultEvent = geminiEvent as GeminiToolResultEvent;
// If tool result is an error, prefix with error indicator
const content =
toolResultEvent.status === 'error'
? `[ERROR] ${toolResultEvent.output}`
: toolResultEvent.output;
return {
type: 'assistant',
session_id: toolResultEvent.session_id,
message: {
role: 'assistant',
content: [
{
type: 'tool_result',
tool_use_id: toolResultEvent.tool_id,
content,
},
],
},
};
}
case 'result': {
const resultEvent = geminiEvent as GeminiResultEvent;
if (resultEvent.status === 'error') {
return {
type: 'error',
session_id: resultEvent.session_id,
error: resultEvent.error || 'Unknown error',
};
}
// Success result - include stats for logging
logger.debug(
`Gemini result: status=${resultEvent.status}, tokens=${resultEvent.stats?.total_tokens}`
);
return {
type: 'result',
subtype: 'success',
session_id: resultEvent.session_id,
};
}
case 'error': {
const errorEvent = geminiEvent as GeminiResultEvent;
return {
type: 'error',
session_id: errorEvent.session_id,
error: errorEvent.error || 'Unknown error',
};
}
default:
logger.debug(`Unknown Gemini event type: ${geminiEvent.type}`);
return null;
}
}
// ==========================================================================
// CliProvider Overrides
// ==========================================================================
/**
* Override error mapping for Gemini-specific error codes
*/
protected mapError(stderr: string, exitCode: number | null): CliErrorInfo {
const lower = stderr.toLowerCase();
if (
lower.includes('not authenticated') ||
lower.includes('please log in') ||
lower.includes('unauthorized') ||
lower.includes('login required') ||
lower.includes('error authenticating') ||
lower.includes('loadcodeassist') ||
(lower.includes('econnrefused') && lower.includes('8888'))
) {
return {
code: GeminiErrorCode.NOT_AUTHENTICATED,
message: 'Gemini CLI is not authenticated',
recoverable: true,
suggestion:
'Run "gemini" interactively to log in, or set GEMINI_API_KEY environment variable',
};
}
if (
lower.includes('rate limit') ||
lower.includes('too many requests') ||
lower.includes('429') ||
lower.includes('quota exceeded')
) {
return {
code: GeminiErrorCode.RATE_LIMITED,
message: 'Gemini API rate limit exceeded',
recoverable: true,
suggestion: 'Wait a few minutes and try again. Free tier: 60 req/min, 1000 req/day',
};
}
if (
lower.includes('model not available') ||
lower.includes('invalid model') ||
lower.includes('unknown model') ||
lower.includes('modelnotfounderror') ||
lower.includes('model not found') ||
(lower.includes('not found') && lower.includes('404'))
) {
return {
code: GeminiErrorCode.MODEL_UNAVAILABLE,
message: 'Requested model is not available',
recoverable: true,
suggestion: 'Try using "gemini-2.5-flash" or select a different model',
};
}
if (
lower.includes('network') ||
lower.includes('connection') ||
lower.includes('econnrefused') ||
lower.includes('timeout')
) {
return {
code: GeminiErrorCode.NETWORK_ERROR,
message: 'Network connection error',
recoverable: true,
suggestion: 'Check your internet connection and try again',
};
}
if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) {
return {
code: GeminiErrorCode.PROCESS_CRASHED,
message: 'Gemini CLI process was terminated',
recoverable: true,
suggestion: 'The process may have run out of memory. Try a simpler task.',
};
}
return {
code: GeminiErrorCode.UNKNOWN,
message: stderr || `Gemini CLI exited with code ${exitCode}`,
recoverable: false,
};
}
/**
* Override install instructions for Gemini-specific guidance
*/
protected getInstallInstructions(): string {
return 'Install with: npm install -g @google/gemini-cli (or visit https://github.com/google-gemini/gemini-cli)';
}
/**
* Execute a prompt using Gemini CLI with streaming
*/
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
this.ensureCliDetected();
// Validate that model doesn't have a provider prefix
validateBareModelId(options.model, 'GeminiProvider');
if (!this.cliPath) {
throw this.createError(
GeminiErrorCode.NOT_INSTALLED,
'Gemini CLI is not installed',
true,
this.getInstallInstructions()
);
}
// Extract prompt text to pass as positional argument
const promptText = this.extractPromptText(options);
// Build CLI args and append the prompt as the last positional argument
const cliArgs = this.buildCliArgs(options);
cliArgs.push(promptText); // Gemini CLI uses positional args for the prompt
const subprocessOptions = this.buildSubprocessOptions(options, cliArgs);
let sessionId: string | undefined;
logger.debug(`GeminiProvider.executeQuery called with model: "${options.model}"`);
try {
for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) {
const event = rawEvent as GeminiStreamEvent;
// Capture session ID from init event
if (event.type === 'init') {
const initEvent = event as GeminiInitEvent;
sessionId = initEvent.session_id;
logger.debug(`Session started: ${sessionId}, model: ${initEvent.model}`);
}
// Normalize and yield the event
const normalized = this.normalizeEvent(event);
if (normalized) {
if (!normalized.session_id && sessionId) {
normalized.session_id = sessionId;
}
yield normalized;
}
}
} catch (error) {
if (isAbortError(error)) {
logger.debug('Query aborted');
return;
}
// Map CLI errors to GeminiError
if (error instanceof Error && 'stderr' in error) {
const errorInfo = this.mapError(
(error as { stderr?: string }).stderr || error.message,
(error as { exitCode?: number | null }).exitCode ?? null
);
throw this.createError(
errorInfo.code as GeminiErrorCode,
errorInfo.message,
errorInfo.recoverable,
errorInfo.suggestion
);
}
throw error;
}
}
// ==========================================================================
// Gemini-Specific Methods
// ==========================================================================
/**
* Create a GeminiError with details
*/
private createError(
code: GeminiErrorCode,
message: string,
recoverable: boolean = false,
suggestion?: string
): GeminiError {
const error = new Error(message) as GeminiError;
error.code = code;
error.recoverable = recoverable;
error.suggestion = suggestion;
error.name = 'GeminiError';
return error;
}
/**
* Get Gemini CLI version
*/
async getVersion(): Promise<string | null> {
this.ensureCliDetected();
if (!this.cliPath) return null;
try {
const result = execSync(`"${this.cliPath}" --version`, {
encoding: 'utf8',
timeout: 5000,
stdio: 'pipe',
}).trim();
return result;
} catch {
return null;
}
}
/**
* Check authentication status
*
* Uses a fast credential check approach:
* 1. Check for GEMINI_API_KEY environment variable
* 2. Check for Google Cloud credentials
* 3. Check for Gemini settings file with stored credentials
* 4. Quick CLI auth test with --help (fast, doesn't make API calls)
*/
async checkAuth(): Promise<GeminiAuthStatus> {
this.ensureCliDetected();
if (!this.cliPath) {
logger.debug('checkAuth: CLI not found');
return { authenticated: false, method: 'none' };
}
logger.debug('checkAuth: Starting credential check');
// Determine the likely auth method based on environment
const hasApiKey = !!process.env.GEMINI_API_KEY;
const hasEnvApiKey = hasApiKey;
const hasVertexAi = !!(
process.env.GOOGLE_APPLICATION_CREDENTIALS || process.env.GOOGLE_CLOUD_PROJECT
);
logger.debug(`checkAuth: hasApiKey=${hasApiKey}, hasVertexAi=${hasVertexAi}`);
// Check for Gemini credentials file (~/.gemini/settings.json)
const geminiConfigDir = path.join(os.homedir(), '.gemini');
const settingsPath = path.join(geminiConfigDir, 'settings.json');
let hasCredentialsFile = false;
let authType: string | null = null;
try {
await fs.access(settingsPath);
logger.debug(`checkAuth: Found settings file at ${settingsPath}`);
try {
const content = await fs.readFile(settingsPath, 'utf8');
const settings = JSON.parse(content);
// Auth config is at security.auth.selectedType (e.g., "oauth-personal", "oauth-adc", "api-key")
const selectedType = settings?.security?.auth?.selectedType;
if (selectedType) {
hasCredentialsFile = true;
authType = selectedType;
logger.debug(`checkAuth: Settings file has auth config, selectedType=${selectedType}`);
} else {
logger.debug(`checkAuth: Settings file found but no auth type configured`);
}
} catch (e) {
logger.debug(`checkAuth: Failed to parse settings file: ${e}`);
}
} catch {
logger.debug('checkAuth: No settings file found');
}
// If we have an API key, we're authenticated
if (hasApiKey) {
logger.debug('checkAuth: Using API key authentication');
return {
authenticated: true,
method: 'api_key',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
};
}
// If we have Vertex AI credentials, we're authenticated
if (hasVertexAi) {
logger.debug('checkAuth: Using Vertex AI authentication');
return {
authenticated: true,
method: 'vertex_ai',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
};
}
// Check if settings file indicates configured authentication
if (hasCredentialsFile && authType) {
// OAuth types: "oauth-personal", "oauth-adc"
// API key type: "api-key"
// Code assist: "code-assist" (requires IDE integration)
if (authType.startsWith('oauth')) {
logger.debug(`checkAuth: OAuth authentication configured (${authType})`);
return {
authenticated: true,
method: 'google_login',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
};
}
if (authType === 'api-key') {
logger.debug('checkAuth: API key authentication configured in settings');
return {
authenticated: true,
method: 'api_key',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
};
}
if (authType === 'code-assist' || authType === 'codeassist') {
logger.debug('checkAuth: Code Assist auth configured but requires local server');
return {
authenticated: false,
method: 'google_login',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
error:
'Code Assist authentication requires IDE integration. Please use "gemini" CLI to log in with a different method, or set GEMINI_API_KEY.',
};
}
// Unknown auth type but something is configured
logger.debug(`checkAuth: Unknown auth type configured: ${authType}`);
return {
authenticated: true,
method: 'google_login',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
};
}
// No credentials found
logger.debug('checkAuth: No valid credentials found');
return {
authenticated: false,
method: 'none',
hasApiKey,
hasEnvApiKey,
hasCredentialsFile,
error:
'No authentication configured. Run "gemini" interactively to log in, or set GEMINI_API_KEY.',
};
}
/**
* Detect installation status (required by BaseProvider)
*/
async detectInstallation(): Promise<InstallationStatus> {
const installed = await this.isInstalled();
const version = installed ? await this.getVersion() : undefined;
const auth = await this.checkAuth();
return {
installed,
version: version || undefined,
path: this.cliPath || undefined,
method: 'cli',
hasApiKey: !!process.env.GEMINI_API_KEY,
authenticated: auth.authenticated,
};
}
/**
* Get the detected CLI path (public accessor for status endpoints)
*/
getCliPath(): string | null {
this.ensureCliDetected();
return this.cliPath;
}
/**
* Get available Gemini models
*/
getAvailableModels(): ModelDefinition[] {
return Object.entries(GEMINI_MODEL_MAP).map(([id, config]) => ({
id, // Full model ID with gemini- prefix (e.g., 'gemini-2.5-flash')
name: config.label,
modelString: id, // Same as id - CLI uses the full model name
provider: 'gemini',
description: config.description,
supportsTools: true,
supportsVision: config.supportsVision,
contextWindow: config.contextWindow,
}));
}
/**
* Check if a feature is supported
*/
supportsFeature(feature: string): boolean {
const supported = ['tools', 'text', 'streaming', 'vision', 'thinking'];
return supported.includes(feature);
}
}

View File

@@ -7,7 +7,13 @@
import { BaseProvider } from './base-provider.js';
import type { InstallationStatus, ModelDefinition } from './types.js';
import { isCursorModel, isCodexModel, isOpencodeModel, type ModelProvider } from '@automaker/types';
import {
isCursorModel,
isCodexModel,
isOpencodeModel,
isGeminiModel,
type ModelProvider,
} from '@automaker/types';
import * as fs from 'fs';
import * as path from 'path';
@@ -16,6 +22,7 @@ const DISCONNECTED_MARKERS: Record<string, string> = {
codex: '.codex-disconnected',
cursor: '.cursor-disconnected',
opencode: '.opencode-disconnected',
gemini: '.gemini-disconnected',
};
/**
@@ -239,8 +246,8 @@ export class ProviderFactory {
model.modelString === modelId ||
model.id.endsWith(`-${modelId}`) ||
model.modelString.endsWith(`-${modelId}`) ||
model.modelString === modelId.replace(/^(claude|cursor|codex)-/, '') ||
model.modelString === modelId.replace(/-(claude|cursor|codex)$/, '')
model.modelString === modelId.replace(/^(claude|cursor|codex|gemini)-/, '') ||
model.modelString === modelId.replace(/-(claude|cursor|codex|gemini)$/, '')
) {
return model.supportsVision ?? true;
}
@@ -267,6 +274,7 @@ import { ClaudeProvider } from './claude-provider.js';
import { CursorProvider } from './cursor-provider.js';
import { CodexProvider } from './codex-provider.js';
import { OpencodeProvider } from './opencode-provider.js';
import { GeminiProvider } from './gemini-provider.js';
// Register Claude provider
registerProvider('claude', {
@@ -301,3 +309,11 @@ registerProvider('opencode', {
canHandleModel: (model: string) => isOpencodeModel(model),
priority: 3, // Between codex (5) and claude (0)
});
// Register Gemini provider
registerProvider('gemini', {
factory: () => new GeminiProvider(),
aliases: ['google'],
canHandleModel: (model: string) => isGeminiModel(model),
priority: 4, // Between opencode (3) and codex (5)
});

View File

@@ -24,6 +24,9 @@ import { createDeauthCursorHandler } from './routes/deauth-cursor.js';
import { createAuthOpencodeHandler } from './routes/auth-opencode.js';
import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js';
import { createOpencodeStatusHandler } from './routes/opencode-status.js';
import { createGeminiStatusHandler } from './routes/gemini-status.js';
import { createAuthGeminiHandler } from './routes/auth-gemini.js';
import { createDeauthGeminiHandler } from './routes/deauth-gemini.js';
import {
createGetOpencodeModelsHandler,
createRefreshOpencodeModelsHandler,
@@ -72,6 +75,11 @@ export function createSetupRoutes(): Router {
router.post('/auth-opencode', createAuthOpencodeHandler());
router.post('/deauth-opencode', createDeauthOpencodeHandler());
// Gemini CLI routes
router.get('/gemini-status', createGeminiStatusHandler());
router.post('/auth-gemini', createAuthGeminiHandler());
router.post('/deauth-gemini', createDeauthGeminiHandler());
// OpenCode Dynamic Model Discovery routes
router.get('/opencode/models', createGetOpencodeModelsHandler());
router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler());

View File

@@ -0,0 +1,42 @@
/**
* POST /auth-gemini endpoint - Connect Gemini CLI to the app
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import * as fs from 'fs/promises';
import * as path from 'path';
const DISCONNECTED_MARKER_FILE = '.gemini-disconnected';
/**
* Creates handler for POST /api/setup/auth-gemini
* Removes the disconnection marker to allow Gemini CLI to be used
*/
export function createAuthGeminiHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const projectRoot = process.cwd();
const automakerDir = path.join(projectRoot, '.automaker');
const markerPath = path.join(automakerDir, DISCONNECTED_MARKER_FILE);
// Remove the disconnection marker if it exists
try {
await fs.unlink(markerPath);
} catch {
// File doesn't exist, nothing to remove
}
res.json({
success: true,
message: 'Gemini CLI connected to app',
});
} catch (error) {
logError(error, 'Auth Gemini failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -0,0 +1,42 @@
/**
* POST /deauth-gemini endpoint - Disconnect Gemini CLI from the app
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import * as fs from 'fs/promises';
import * as path from 'path';
const DISCONNECTED_MARKER_FILE = '.gemini-disconnected';
/**
* Creates handler for POST /api/setup/deauth-gemini
* Creates a marker file to disconnect Gemini CLI from the app
*/
export function createDeauthGeminiHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const projectRoot = process.cwd();
const automakerDir = path.join(projectRoot, '.automaker');
// Ensure .automaker directory exists
await fs.mkdir(automakerDir, { recursive: true });
const markerPath = path.join(automakerDir, DISCONNECTED_MARKER_FILE);
// Create the disconnection marker
await fs.writeFile(markerPath, 'Gemini CLI disconnected from app');
res.json({
success: true,
message: 'Gemini CLI disconnected from app',
});
} catch (error) {
logError(error, 'Deauth Gemini failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -0,0 +1,79 @@
/**
* GET /gemini-status endpoint - Get Gemini CLI installation and auth status
*/
import type { Request, Response } from 'express';
import { GeminiProvider } from '../../../providers/gemini-provider.js';
import { getErrorMessage, logError } from '../common.js';
import * as fs from 'fs/promises';
import * as path from 'path';
const DISCONNECTED_MARKER_FILE = '.gemini-disconnected';
async function isGeminiDisconnectedFromApp(): Promise<boolean> {
try {
const projectRoot = process.cwd();
const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE);
await fs.access(markerPath);
return true;
} catch {
return false;
}
}
/**
* Creates handler for GET /api/setup/gemini-status
* Returns Gemini CLI installation and authentication status
*/
export function createGeminiStatusHandler() {
const installCommand = 'npm install -g @google/gemini-cli';
const loginCommand = 'gemini';
return async (_req: Request, res: Response): Promise<void> => {
try {
// Check if user has manually disconnected from the app
if (await isGeminiDisconnectedFromApp()) {
res.json({
success: true,
installed: true,
version: null,
path: null,
auth: {
authenticated: false,
method: 'none',
hasApiKey: false,
},
installCommand,
loginCommand,
});
return;
}
const provider = new GeminiProvider();
const status = await provider.detectInstallation();
const auth = await provider.checkAuth();
res.json({
success: true,
installed: status.installed,
version: status.version || null,
path: status.path || null,
auth: {
authenticated: auth.authenticated,
method: auth.method,
hasApiKey: auth.hasApiKey || false,
hasEnvApiKey: auth.hasEnvApiKey || false,
error: auth.error,
},
installCommand,
loginCommand,
});
} catch (error) {
logError(error, 'Get Gemini status failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -134,7 +134,7 @@ export function createWorktreeRoutes(
router.post(
'/start-dev',
validatePathParams('projectPath', 'worktreePath'),
createStartDevHandler()
createStartDevHandler(settingsService)
);
router.post('/stop-dev', createStopDevHandler());
router.post('/list-dev-servers', createListDevServersHandler());

View File

@@ -1,16 +1,22 @@
/**
* POST /start-dev endpoint - Start a dev server for a worktree
*
* Spins up a development server (npm run dev) in the worktree directory
* on a unique port, allowing preview of the worktree's changes without
* affecting the main dev server.
* Spins up a development server in the worktree directory on a unique port,
* allowing preview of the worktree's changes without affecting the main dev server.
*
* If a custom devCommand is configured in project settings, it will be used.
* Otherwise, auto-detection based on package manager (npm/yarn/pnpm/bun run dev) is used.
*/
import type { Request, Response } from 'express';
import type { SettingsService } from '../../../services/settings-service.js';
import { getDevServerService } from '../../../services/dev-server-service.js';
import { getErrorMessage, logError } from '../common.js';
import { createLogger } from '@automaker/utils';
export function createStartDevHandler() {
const logger = createLogger('start-dev');
export function createStartDevHandler(settingsService?: SettingsService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, worktreePath } = req.body as {
@@ -34,8 +40,25 @@ export function createStartDevHandler() {
return;
}
// Get custom dev command from project settings (if configured)
let customCommand: string | undefined;
if (settingsService) {
const projectSettings = await settingsService.getProjectSettings(projectPath);
const devCommand = projectSettings?.devCommand?.trim();
if (devCommand) {
customCommand = devCommand;
logger.debug(`Using custom dev command from project settings: ${customCommand}`);
} else {
logger.debug('No custom dev command configured, using auto-detection');
}
}
const devServerService = getDevServerService();
const result = await devServerService.startDevServer(projectPath, worktreePath);
const result = await devServerService.startDevServer(
projectPath,
worktreePath,
customCommand
);
if (result.success && result.result) {
res.json({

View File

@@ -273,12 +273,56 @@ class DevServerService {
}
}
/**
* Parse a custom command string into cmd and args
* Handles quoted strings with spaces (e.g., "my command" arg1 arg2)
*/
private parseCustomCommand(command: string): { cmd: string; args: string[] } {
const tokens: string[] = [];
let current = '';
let inQuote = false;
let quoteChar = '';
for (let i = 0; i < command.length; i++) {
const char = command[i];
if (inQuote) {
if (char === quoteChar) {
inQuote = false;
} else {
current += char;
}
} else if (char === '"' || char === "'") {
inQuote = true;
quoteChar = char;
} else if (char === ' ') {
if (current) {
tokens.push(current);
current = '';
}
} else {
current += char;
}
}
if (current) {
tokens.push(current);
}
const [cmd, ...args] = tokens;
return { cmd: cmd || '', args };
}
/**
* Start a dev server for a worktree
* @param projectPath - The project root path
* @param worktreePath - The worktree directory path
* @param customCommand - Optional custom command to run instead of auto-detected dev command
*/
async startDevServer(
projectPath: string,
worktreePath: string
worktreePath: string,
customCommand?: string
): Promise<{
success: boolean;
result?: {
@@ -311,22 +355,41 @@ class DevServerService {
};
}
// Check for package.json
const packageJsonPath = path.join(worktreePath, 'package.json');
if (!(await this.fileExists(packageJsonPath))) {
return {
success: false,
error: `No package.json found in: ${worktreePath}`,
};
}
// Determine the dev command to use
let devCommand: { cmd: string; args: string[] };
// Get dev command
const devCommand = await this.getDevCommand(worktreePath);
if (!devCommand) {
return {
success: false,
error: `Could not determine dev command for: ${worktreePath}`,
};
// Normalize custom command: trim whitespace and treat empty strings as undefined
const normalizedCustomCommand = customCommand?.trim();
if (normalizedCustomCommand) {
// Use the provided custom command
devCommand = this.parseCustomCommand(normalizedCustomCommand);
if (!devCommand.cmd) {
return {
success: false,
error: 'Invalid custom command: command cannot be empty',
};
}
logger.debug(`Using custom command: ${normalizedCustomCommand}`);
} else {
// Check for package.json when auto-detecting
const packageJsonPath = path.join(worktreePath, 'package.json');
if (!(await this.fileExists(packageJsonPath))) {
return {
success: false,
error: `No package.json found in: ${worktreePath}`,
};
}
// Get dev command from package manager detection
const detectedCommand = await this.getDevCommand(worktreePath);
if (!detectedCommand) {
return {
success: false,
error: `Could not determine dev command for: ${worktreePath}`,
};
}
devCommand = detectedCommand;
}
// Find available port

View File

@@ -852,6 +852,20 @@ export class SettingsService {
delete updated.defaultFeatureModel;
}
// Handle devCommand special cases:
// - null means delete the key (use auto-detection)
// - string means custom command
if ('devCommand' in updates && updates.devCommand === null) {
delete updated.devCommand;
}
// Handle testCommand special cases:
// - null means delete the key (use auto-detection)
// - string means custom command
if ('testCommand' in updates && updates.testCommand === null) {
delete updated.testCommand;
}
await writeSettingsJson(settingsPath, updated);
logger.info(`Project settings updated for ${projectPath}`);

View File

@@ -4,6 +4,7 @@ import { ClaudeProvider } from '@/providers/claude-provider.js';
import { CursorProvider } from '@/providers/cursor-provider.js';
import { CodexProvider } from '@/providers/codex-provider.js';
import { OpencodeProvider } from '@/providers/opencode-provider.js';
import { GeminiProvider } from '@/providers/gemini-provider.js';
describe('provider-factory.ts', () => {
let consoleSpy: any;
@@ -11,6 +12,7 @@ describe('provider-factory.ts', () => {
let detectCursorSpy: any;
let detectCodexSpy: any;
let detectOpencodeSpy: any;
let detectGeminiSpy: any;
beforeEach(() => {
consoleSpy = {
@@ -30,6 +32,9 @@ describe('provider-factory.ts', () => {
detectOpencodeSpy = vi
.spyOn(OpencodeProvider.prototype, 'detectInstallation')
.mockResolvedValue({ installed: true });
detectGeminiSpy = vi
.spyOn(GeminiProvider.prototype, 'detectInstallation')
.mockResolvedValue({ installed: true });
});
afterEach(() => {
@@ -38,6 +43,7 @@ describe('provider-factory.ts', () => {
detectCursorSpy.mockRestore();
detectCodexSpy.mockRestore();
detectOpencodeSpy.mockRestore();
detectGeminiSpy.mockRestore();
});
describe('getProviderForModel', () => {
@@ -166,9 +172,15 @@ describe('provider-factory.ts', () => {
expect(hasClaudeProvider).toBe(true);
});
it('should return exactly 4 providers', () => {
it('should return exactly 5 providers', () => {
const providers = ProviderFactory.getAllProviders();
expect(providers).toHaveLength(4);
expect(providers).toHaveLength(5);
});
it('should include GeminiProvider', () => {
const providers = ProviderFactory.getAllProviders();
const hasGeminiProvider = providers.some((p) => p instanceof GeminiProvider);
expect(hasGeminiProvider).toBe(true);
});
it('should include CursorProvider', () => {
@@ -206,7 +218,8 @@ describe('provider-factory.ts', () => {
expect(keys).toContain('cursor');
expect(keys).toContain('codex');
expect(keys).toContain('opencode');
expect(keys).toHaveLength(4);
expect(keys).toContain('gemini');
expect(keys).toHaveLength(5);
});
it('should include cursor status', async () => {

View File

@@ -102,6 +102,8 @@
"react-markdown": "10.1.0",
"react-resizable-panels": "3.0.6",
"rehype-raw": "7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"sonner": "2.0.7",
"tailwind-merge": "3.4.0",
"usehooks-ts": "3.1.1",

View File

@@ -1,13 +1,97 @@
import ReactMarkdown from 'react-markdown';
import ReactMarkdown, { Components } from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import remarkGfm from 'remark-gfm';
import { cn } from '@/lib/utils';
import { Square, CheckSquare } from 'lucide-react';
interface MarkdownProps {
children: string;
className?: string;
}
/**
* Renders a tasks code block as a proper task list with checkboxes
*/
function TasksBlock({ content }: { content: string }) {
const lines = content.split('\n');
return (
<div className="my-4 space-y-1">
{lines.map((line, idx) => {
const trimmed = line.trim();
// Check for phase/section headers (## Phase 1: ...)
const headerMatch = trimmed.match(/^##\s+(.+)$/);
if (headerMatch) {
return (
<div key={idx} className="text-foreground font-semibold mt-4 mb-2 text-sm">
{headerMatch[1]}
</div>
);
}
// Check for task items (- [ ] or - [x])
const taskMatch = trimmed.match(/^-\s*\[([ xX])\]\s*(.+)$/);
if (taskMatch) {
const isChecked = taskMatch[1].toLowerCase() === 'x';
const taskText = taskMatch[2];
return (
<div key={idx} className="flex items-start gap-2 py-1">
{isChecked ? (
<CheckSquare className="w-4 h-4 text-emerald-500 mt-0.5 flex-shrink-0" />
) : (
<Square className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
)}
<span
className={cn(
'text-sm',
isChecked ? 'text-muted-foreground line-through' : 'text-foreground-secondary'
)}
>
{taskText}
</span>
</div>
);
}
// Empty lines
if (!trimmed) {
return <div key={idx} className="h-2" />;
}
// Other content (render as-is)
return (
<div key={idx} className="text-sm text-foreground-secondary">
{trimmed}
</div>
);
})}
</div>
);
}
/**
* Custom components for ReactMarkdown
*/
const markdownComponents: Components = {
// Handle code blocks - special case for 'tasks' language
code({ className, children }) {
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : '';
const content = String(children).replace(/\n$/, '');
// Special handling for tasks code blocks
if (language === 'tasks') {
return <TasksBlock content={content} />;
}
// Regular code (inline or block)
return <code className={className}>{children}</code>;
},
};
/**
* Reusable Markdown component for rendering markdown content
* Theme-aware styling that adapts to all predefined themes
@@ -42,10 +126,20 @@ export function Markdown({ children, className }: MarkdownProps) {
'[&_hr]:border-border [&_hr]:my-4',
// Images
'[&_img]:max-w-full [&_img]:h-auto [&_img]:rounded-lg [&_img]:my-2 [&_img]:border [&_img]:border-border',
// Tables
'[&_table]:w-full [&_table]:border-collapse [&_table]:my-4',
'[&_th]:border [&_th]:border-border [&_th]:bg-muted [&_th]:px-3 [&_th]:py-2 [&_th]:text-left [&_th]:text-foreground [&_th]:font-semibold',
'[&_td]:border [&_td]:border-border [&_td]:px-3 [&_td]:py-2 [&_td]:text-foreground-secondary',
className
)}
>
<ReactMarkdown rehypePlugins={[rehypeRaw, rehypeSanitize]}>{children}</ReactMarkdown>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
components={markdownComponents}
>
{children}
</ReactMarkdown>
</div>
);
}

View File

@@ -395,6 +395,7 @@ export const PROVIDER_ICON_COMPONENTS: Record<
cursor: CursorIcon,
codex: OpenAIIcon,
opencode: OpenCodeIcon,
gemini: GeminiIcon,
};
/**

View File

@@ -136,8 +136,9 @@ export const KanbanCard = memo(function KanbanCard({
});
// Make the card a drop target for creating dependency links
// Only backlog cards can be link targets (to avoid complexity with running features)
const isDroppable = !isOverlay && feature.status === 'backlog' && !isSelectionMode;
// All non-completed cards can be link targets to allow flexible dependency creation
// (completed features are excluded as they're already done)
const isDroppable = !isOverlay && feature.status !== 'completed' && !isSelectionMode;
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
id: `card-drop-${feature.id}`,
disabled: !isDroppable,

View File

@@ -12,6 +12,8 @@ import { Button } from '@/components/ui/button';
import { ArrowDown, ArrowUp, Link2, X } from 'lucide-react';
import type { Feature } from '@/store/app-store';
import { cn } from '@/lib/utils';
import { StatusBadge } from '../components';
import type { FeatureStatusWithPipeline } from '@automaker/types';
export type DependencyLinkType = 'parent' | 'child';
@@ -57,7 +59,10 @@ export function DependencyLinkDialog({
<div className="py-4 space-y-4">
{/* Dragged feature */}
<div className="p-3 rounded-lg border bg-muted/30">
<div className="text-xs text-muted-foreground mb-1">Dragged Feature</div>
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-muted-foreground">Dragged Feature</span>
<StatusBadge status={draggedFeature.status as FeatureStatusWithPipeline} size="sm" />
</div>
<div className="text-sm font-medium line-clamp-3 break-words">
{draggedFeature.description}
</div>
@@ -71,7 +76,10 @@ export function DependencyLinkDialog({
{/* Target feature */}
<div className="p-3 rounded-lg border bg-muted/30">
<div className="text-xs text-muted-foreground mb-1">Target Feature</div>
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-muted-foreground">Target Feature</span>
<StatusBadge status={targetFeature.status as FeatureStatusWithPipeline} size="sm" />
</div>
<div className="text-sm font-medium line-clamp-3 break-words">
{targetFeature.description}
</div>

View File

@@ -11,7 +11,7 @@ import {
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Markdown } from '@/components/ui/markdown';
import { PlanContentViewer } from './plan-content-viewer';
import { Label } from '@/components/ui/label';
import { Feature } from '@/store/app-store';
import { Check, RefreshCw, Edit2, Eye } from 'lucide-react';
@@ -42,6 +42,10 @@ export function PlanApprovalDialog({
const [editedPlan, setEditedPlan] = useState(planContent);
const [showRejectFeedback, setShowRejectFeedback] = useState(false);
const [rejectFeedback, setRejectFeedback] = useState('');
const [showFullDescription, setShowFullDescription] = useState(false);
const DESCRIPTION_LIMIT = 250;
const TITLE_LIMIT = 50;
// Reset state when dialog opens or plan content changes
useEffect(() => {
@@ -50,6 +54,7 @@ export function PlanApprovalDialog({
setIsEditMode(false);
setShowRejectFeedback(false);
setRejectFeedback('');
setShowFullDescription(false);
}
}, [open, planContent]);
@@ -82,15 +87,31 @@ export function PlanApprovalDialog({
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl" data-testid="plan-approval-dialog">
<DialogHeader>
<DialogTitle>{viewOnly ? 'View Plan' : 'Review Plan'}</DialogTitle>
<DialogTitle>
{viewOnly ? 'View Plan' : 'Review Plan'}
{feature?.title && feature.title.length <= TITLE_LIMIT && (
<span className="font-normal text-muted-foreground"> - {feature.title}</span>
)}
</DialogTitle>
<DialogDescription>
{viewOnly
? 'View the generated plan for this feature.'
: 'Review the generated plan before implementation begins.'}
{feature && (
<span className="block mt-2 text-primary">
Feature: {feature.description.slice(0, 150)}
{feature.description.length > 150 ? '...' : ''}
Feature:{' '}
{showFullDescription || feature.description.length <= DESCRIPTION_LIMIT
? feature.description
: `${feature.description.slice(0, DESCRIPTION_LIMIT)}...`}
{feature.description.length > DESCRIPTION_LIMIT && (
<button
type="button"
onClick={() => setShowFullDescription(!showFullDescription)}
className="ml-1 text-muted-foreground hover:text-foreground underline text-sm"
>
{showFullDescription ? 'show less' : 'show more'}
</button>
)}
</span>
)}
</DialogDescription>
@@ -135,9 +156,7 @@ export function PlanApprovalDialog({
disabled={isLoading}
/>
) : (
<div className="p-4 overflow-auto">
<Markdown>{editedPlan || 'No plan content available.'}</Markdown>
</div>
<PlanContentViewer content={editedPlan || ''} className="p-4" />
)}
</div>

View File

@@ -0,0 +1,216 @@
'use client';
import { useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, Wrench } from 'lucide-react';
import { Markdown } from '@/components/ui/markdown';
import { cn } from '@/lib/utils';
interface ToolCall {
tool: string;
input: string;
}
interface ParsedPlanContent {
toolCalls: ToolCall[];
planMarkdown: string;
}
/**
* Parses plan content to separate tool calls from the actual plan/specification markdown.
* Tool calls appear at the beginning (exploration phase), followed by the plan markdown.
*/
function parsePlanContent(content: string): ParsedPlanContent {
const lines = content.split('\n');
const toolCalls: ToolCall[] = [];
let planStartIndex = -1;
let currentTool: string | null = null;
let currentInput: string[] = [];
let inJsonBlock = false;
let braceDepth = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
// Check if this line starts the actual plan/spec (markdown heading)
// Plans typically start with # or ## headings
if (
!inJsonBlock &&
(trimmed.match(/^#{1,3}\s+\S/) || // Markdown headings (including emoji like ## ✅ Plan)
trimmed.startsWith('---') || // Horizontal rule often used as separator
trimmed.match(/^\*\*\S/)) // Bold text starting a section
) {
// Flush any active tool call before starting the plan
if (currentTool && currentInput.length > 0) {
toolCalls.push({
tool: currentTool,
input: currentInput.join('\n').trim(),
});
currentTool = null;
currentInput = [];
}
planStartIndex = i;
break;
}
// Detect tool call start (supports tool names with dots/hyphens like web.run, file-read)
const toolMatch = trimmed.match(/^(?:🔧\s*)?Tool:\s*([^\s]+)/i);
if (toolMatch && !inJsonBlock) {
// Save previous tool call if exists
if (currentTool && currentInput.length > 0) {
toolCalls.push({
tool: currentTool,
input: currentInput.join('\n').trim(),
});
}
currentTool = toolMatch[1];
currentInput = [];
continue;
}
// Detect Input: line
if (trimmed.startsWith('Input:') && currentTool) {
const inputContent = trimmed.replace(/^Input:\s*/, '');
if (inputContent) {
currentInput.push(inputContent);
// Check if JSON starts
if (inputContent.includes('{')) {
braceDepth =
(inputContent.match(/\{/g) || []).length - (inputContent.match(/\}/g) || []).length;
inJsonBlock = braceDepth > 0;
}
}
continue;
}
// If we're collecting input for a tool
if (currentTool) {
if (inJsonBlock) {
currentInput.push(line);
braceDepth += (trimmed.match(/\{/g) || []).length - (trimmed.match(/\}/g) || []).length;
if (braceDepth <= 0) {
inJsonBlock = false;
// Save tool call
toolCalls.push({
tool: currentTool,
input: currentInput.join('\n').trim(),
});
currentTool = null;
currentInput = [];
}
} else if (trimmed.startsWith('{')) {
// JSON block starting
currentInput.push(line);
braceDepth = (trimmed.match(/\{/g) || []).length - (trimmed.match(/\}/g) || []).length;
inJsonBlock = braceDepth > 0;
if (!inJsonBlock) {
// Single-line JSON
toolCalls.push({
tool: currentTool,
input: currentInput.join('\n').trim(),
});
currentTool = null;
currentInput = [];
}
} else if (trimmed === '') {
// Empty line might end the tool call section
if (currentInput.length > 0) {
toolCalls.push({
tool: currentTool,
input: currentInput.join('\n').trim(),
});
currentTool = null;
currentInput = [];
}
}
}
}
// Save any remaining tool call
if (currentTool && currentInput.length > 0) {
toolCalls.push({
tool: currentTool,
input: currentInput.join('\n').trim(),
});
}
// Extract plan markdown
let planMarkdown = '';
if (planStartIndex >= 0) {
planMarkdown = lines.slice(planStartIndex).join('\n').trim();
} else if (toolCalls.length === 0) {
// No tool calls found, treat entire content as markdown
planMarkdown = content.trim();
}
return { toolCalls, planMarkdown };
}
interface PlanContentViewerProps {
content: string;
className?: string;
}
export function PlanContentViewer({ content, className }: PlanContentViewerProps) {
const [showToolCalls, setShowToolCalls] = useState(false);
const { toolCalls, planMarkdown } = useMemo(() => parsePlanContent(content), [content]);
if (!content || !content.trim()) {
return (
<div className={cn('text-muted-foreground text-center py-8', className)}>
No plan content available.
</div>
);
}
return (
<div className={cn('space-y-4', className)}>
{/* Tool Calls Section - Collapsed by default */}
{toolCalls.length > 0 && (
<div className="border border-border rounded-lg overflow-hidden">
<button
onClick={() => setShowToolCalls(!showToolCalls)}
className="w-full px-4 py-2 flex items-center gap-2 bg-muted/30 hover:bg-muted/50 transition-colors text-left"
>
{showToolCalls ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)}
<Wrench className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
Exploration ({toolCalls.length} tool call{toolCalls.length !== 1 ? 's' : ''})
</span>
</button>
{showToolCalls && (
<div className="p-3 space-y-2 bg-muted/10 max-h-[300px] overflow-y-auto">
{toolCalls.map((tc, idx) => (
<div key={idx} className="text-xs font-mono">
<div className="text-cyan-400 mb-1">Tool: {tc.tool}</div>
<pre className="bg-muted/50 rounded p-2 overflow-x-auto text-foreground-secondary whitespace-pre-wrap">
{tc.input}
</pre>
</div>
))}
</div>
)}
</div>
)}
{/* Plan/Specification Content - Main focus */}
{planMarkdown ? (
<div className="min-h-[200px]">
<Markdown>{planMarkdown}</Markdown>
</div>
) : toolCalls.length > 0 ? (
<div className="text-muted-foreground text-center py-8 border border-dashed border-border rounded-lg">
<p className="text-sm">No specification content found.</p>
<p className="text-xs mt-1">The plan appears to only contain exploration tool calls.</p>
</div>
) : null}
</div>
);
}

View File

@@ -123,9 +123,34 @@ export function useBoardActions({
}) => {
const workMode = featureData.workMode || 'current';
// For auto worktree mode, we need a title for the branch name.
// If no title provided, generate one from the description first.
let titleForBranch = featureData.title;
let titleWasGenerated = false;
if (workMode === 'auto' && !featureData.title.trim() && featureData.description.trim()) {
// Generate title first so we can use it for the branch name
const api = getElectronAPI();
if (api?.features?.generateTitle) {
try {
const result = await api.features.generateTitle(featureData.description);
if (result.success && result.title) {
titleForBranch = result.title;
titleWasGenerated = true;
}
} catch (error) {
logger.error('Error generating title for branch name:', error);
}
}
// If title generation failed, fall back to first part of description
if (!titleForBranch.trim()) {
titleForBranch = featureData.description.substring(0, 60);
}
}
// Determine final branch name based on work mode:
// - 'current': Use current worktree's branch (or undefined if on main)
// - 'auto': Auto-generate branch name based on current branch
// - 'auto': Auto-generate branch name based on feature title
// - 'custom': Use the provided branch name
let finalBranchName: string | undefined;
@@ -134,13 +159,16 @@ export function useBoardActions({
// This ensures features created on a non-main worktree are associated with that worktree
finalBranchName = currentWorktreeBranch || undefined;
} else if (workMode === 'auto') {
// Auto-generate a branch name based on primary branch (main/master) and timestamp
// Always use primary branch to avoid nested feature/feature/... paths
const baseBranch =
(currentProject?.path ? getPrimaryWorktreeBranch(currentProject.path) : null) || 'main';
const timestamp = Date.now();
// Auto-generate a branch name based on feature title and timestamp
// Create a slug from the title: lowercase, replace non-alphanumeric with hyphens
const titleSlug =
titleForBranch
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric sequences with hyphens
.substring(0, 50) // Limit length first
.replace(/^-|-$/g, '') || 'untitled'; // Then remove leading/trailing hyphens, with fallback
const randomSuffix = Math.random().toString(36).substring(2, 6);
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
finalBranchName = `feature/${titleSlug}-${randomSuffix}`;
} else {
// Custom mode - use provided branch name
finalBranchName = featureData.branchName || undefined;
@@ -183,12 +211,13 @@ export function useBoardActions({
}
}
// Check if we need to generate a title
const needsTitleGeneration = !featureData.title.trim() && featureData.description.trim();
// Check if we need to generate a title (only if we didn't already generate it for the branch name)
const needsTitleGeneration =
!titleWasGenerated && !featureData.title.trim() && featureData.description.trim();
const newFeatureData = {
...featureData,
title: featureData.title,
title: titleWasGenerated ? titleForBranch : featureData.title,
titleGenerating: needsTitleGeneration,
status: 'backlog' as const,
branchName: finalBranchName,
@@ -255,7 +284,6 @@ export function useBoardActions({
projectPath,
onWorktreeCreated,
onWorktreeAutoSelect,
getPrimaryWorktreeBranch,
features,
currentWorktreeBranch,
]
@@ -287,6 +315,31 @@ export function useBoardActions({
) => {
const workMode = updates.workMode || 'current';
// For auto worktree mode, we need a title for the branch name.
// If no title provided, generate one from the description first.
let titleForBranch = updates.title;
let titleWasGenerated = false;
if (workMode === 'auto' && !updates.title.trim() && updates.description.trim()) {
// Generate title first so we can use it for the branch name
const api = getElectronAPI();
if (api?.features?.generateTitle) {
try {
const result = await api.features.generateTitle(updates.description);
if (result.success && result.title) {
titleForBranch = result.title;
titleWasGenerated = true;
}
} catch (error) {
logger.error('Error generating title for branch name:', error);
}
}
// If title generation failed, fall back to first part of description
if (!titleForBranch.trim()) {
titleForBranch = updates.description.substring(0, 60);
}
}
// Determine final branch name based on work mode
let finalBranchName: string | undefined;
@@ -295,13 +348,21 @@ export function useBoardActions({
// This ensures features updated on a non-main worktree are associated with that worktree
finalBranchName = currentWorktreeBranch || undefined;
} else if (workMode === 'auto') {
// Auto-generate a branch name based on primary branch (main/master) and timestamp
// Always use primary branch to avoid nested feature/feature/... paths
const baseBranch =
(currentProject?.path ? getPrimaryWorktreeBranch(currentProject.path) : null) || 'main';
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(2, 6);
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
// Preserve existing branch name if one exists (avoid orphaning worktrees on edit)
if (updates.branchName?.trim()) {
finalBranchName = updates.branchName;
} else {
// Auto-generate a branch name based on feature title
// Create a slug from the title: lowercase, replace non-alphanumeric with hyphens
const titleSlug =
titleForBranch
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric sequences with hyphens
.substring(0, 50) // Limit length first
.replace(/^-|-$/g, '') || 'untitled'; // Then remove leading/trailing hyphens, with fallback
const randomSuffix = Math.random().toString(36).substring(2, 6);
finalBranchName = `feature/${titleSlug}-${randomSuffix}`;
}
} else {
finalBranchName = updates.branchName || undefined;
}
@@ -343,7 +404,7 @@ export function useBoardActions({
const finalUpdates = {
...restUpdates,
title: updates.title,
title: titleWasGenerated ? titleForBranch : updates.title,
branchName: finalBranchName,
};
@@ -406,7 +467,6 @@ export function useBoardActions({
setEditingFeature,
currentProject,
onWorktreeCreated,
getPrimaryWorktreeBranch,
features,
currentWorktreeBranch,
]

View File

@@ -88,10 +88,10 @@ export function useBoardDragDrop({
const targetFeature = features.find((f) => f.id === targetFeatureId);
if (!targetFeature) return;
// Only allow linking backlog features (both must be in backlog)
if (draggedFeature.status !== 'backlog' || targetFeature.status !== 'backlog') {
// Don't allow linking completed features (they're already done)
if (draggedFeature.status === 'completed' || targetFeature.status === 'completed') {
toast.error('Cannot link features', {
description: 'Both features must be in the backlog to create a dependency link.',
description: 'Completed features cannot be linked.',
});
return;
}

View File

@@ -4,9 +4,16 @@ import {
CURSOR_MODEL_MAP,
CODEX_MODEL_MAP,
OPENCODE_MODELS as OPENCODE_MODEL_CONFIGS,
GEMINI_MODEL_MAP,
} from '@automaker/types';
import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
import {
AnthropicIcon,
CursorIcon,
OpenAIIcon,
OpenCodeIcon,
GeminiIcon,
} from '@/components/ui/provider-icon';
export type ModelOption = {
id: string; // All model IDs use canonical prefixed format (e.g., "claude-sonnet", "cursor-auto")
@@ -118,13 +125,29 @@ export const OPENCODE_MODELS: ModelOption[] = OPENCODE_MODEL_CONFIGS.map((config
}));
/**
* All available models (Claude + Cursor + Codex + OpenCode)
* Gemini models derived from GEMINI_MODEL_MAP
* Model IDs already have 'gemini-' prefix (like Cursor models)
*/
export const GEMINI_MODELS: ModelOption[] = Object.entries(GEMINI_MODEL_MAP).map(
([id, config]) => ({
id, // IDs already have gemini- prefix (e.g., 'gemini-2.5-flash')
label: config.label,
description: config.description,
badge: config.supportsThinking ? 'Thinking' : 'Speed',
provider: 'gemini' as ModelProvider,
hasThinking: config.supportsThinking,
})
);
/**
* All available models (Claude + Cursor + Codex + OpenCode + Gemini)
*/
export const ALL_MODELS: ModelOption[] = [
...CLAUDE_MODELS,
...CURSOR_MODELS,
...CODEX_MODELS,
...OPENCODE_MODELS,
...GEMINI_MODELS,
];
export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink'];
@@ -171,4 +194,5 @@ export const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: str
Cursor: CursorIcon,
Codex: OpenAIIcon,
OpenCode: OpenCodeIcon,
Gemini: GeminiIcon,
};

View File

@@ -0,0 +1,316 @@
import { useState, useEffect, useCallback, type KeyboardEvent } from 'react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Terminal, Save, RotateCcw, Info, X, Play, FlaskConical } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { useProjectSettings } from '@/hooks/queries';
import { useUpdateProjectSettings } from '@/hooks/mutations';
import type { Project } from '@/lib/electron';
/** Preset dev server commands for quick selection */
const DEV_SERVER_PRESETS = [
{ label: 'npm run dev', command: 'npm run dev' },
{ label: 'yarn dev', command: 'yarn dev' },
{ label: 'pnpm dev', command: 'pnpm dev' },
{ label: 'bun dev', command: 'bun dev' },
{ label: 'npm start', command: 'npm start' },
{ label: 'cargo watch', command: 'cargo watch -x run' },
{ label: 'go run', command: 'go run .' },
] as const;
/** Preset test commands for quick selection */
const TEST_PRESETS = [
{ label: 'npm test', command: 'npm test' },
{ label: 'yarn test', command: 'yarn test' },
{ label: 'pnpm test', command: 'pnpm test' },
{ label: 'bun test', command: 'bun test' },
{ label: 'pytest', command: 'pytest' },
{ label: 'cargo test', command: 'cargo test' },
{ label: 'go test', command: 'go test ./...' },
] as const;
interface CommandsSectionProps {
project: Project;
}
export function CommandsSection({ project }: CommandsSectionProps) {
// Fetch project settings using TanStack Query
const { data: projectSettings, isLoading, isError } = useProjectSettings(project.path);
// Mutation hook for updating project settings
const updateSettingsMutation = useUpdateProjectSettings(project.path);
// Local state for the input fields
const [devCommand, setDevCommand] = useState('');
const [originalDevCommand, setOriginalDevCommand] = useState('');
const [testCommand, setTestCommand] = useState('');
const [originalTestCommand, setOriginalTestCommand] = useState('');
// Sync local state when project settings load or project changes
useEffect(() => {
// Reset local state when project changes to avoid showing stale values
setDevCommand('');
setOriginalDevCommand('');
setTestCommand('');
setOriginalTestCommand('');
}, [project.path]);
useEffect(() => {
if (projectSettings) {
const dev = projectSettings.devCommand || '';
const test = projectSettings.testCommand || '';
setDevCommand(dev);
setOriginalDevCommand(dev);
setTestCommand(test);
setOriginalTestCommand(test);
}
}, [projectSettings]);
// Check if there are unsaved changes
const hasDevChanges = devCommand !== originalDevCommand;
const hasTestChanges = testCommand !== originalTestCommand;
const hasChanges = hasDevChanges || hasTestChanges;
const isSaving = updateSettingsMutation.isPending;
// Save all commands
const handleSave = useCallback(() => {
const normalizedDevCommand = devCommand.trim();
const normalizedTestCommand = testCommand.trim();
updateSettingsMutation.mutate(
{
devCommand: normalizedDevCommand || null,
testCommand: normalizedTestCommand || null,
},
{
onSuccess: () => {
setDevCommand(normalizedDevCommand);
setOriginalDevCommand(normalizedDevCommand);
setTestCommand(normalizedTestCommand);
setOriginalTestCommand(normalizedTestCommand);
},
}
);
}, [devCommand, testCommand, updateSettingsMutation]);
// Reset to original values
const handleReset = useCallback(() => {
setDevCommand(originalDevCommand);
setTestCommand(originalTestCommand);
}, [originalDevCommand, originalTestCommand]);
// Use a preset command
const handleUseDevPreset = useCallback((command: string) => {
setDevCommand(command);
}, []);
const handleUseTestPreset = useCallback((command: string) => {
setTestCommand(command);
}, []);
// Clear commands
const handleClearDev = useCallback(() => {
setDevCommand('');
}, []);
const handleClearTest = useCallback(() => {
setTestCommand('');
}, []);
// Handle keyboard shortcuts (Enter to save)
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && hasChanges && !isSaving) {
e.preventDefault();
handleSave();
}
},
[hasChanges, isSaving, handleSave]
);
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Terminal className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Project Commands</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure custom commands for development and testing.
</p>
</div>
<div className="p-6 space-y-8">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Spinner size="md" />
</div>
) : isError ? (
<div className="flex items-center justify-center py-8 text-sm text-destructive">
Failed to load project settings. Please try again.
</div>
) : (
<>
{/* Dev Server Command Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Play className="w-4 h-4 text-brand-500" />
<h3 className="text-base font-medium text-foreground">Dev Server</h3>
{hasDevChanges && (
<span className="text-xs text-amber-500 font-medium">(unsaved)</span>
)}
</div>
<div className="space-y-3 pl-6">
<div className="relative">
<Input
id="dev-command"
value={devCommand}
onChange={(e) => setDevCommand(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="e.g., npm run dev, yarn dev, cargo watch"
className="font-mono text-sm pr-8"
data-testid="dev-command-input"
/>
{devCommand && (
<Button
variant="ghost"
size="sm"
onClick={handleClearDev}
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
aria-label="Clear dev command"
>
<X className="w-3.5 h-3.5" />
</Button>
)}
</div>
<p className="text-xs text-muted-foreground/80">
Leave empty to auto-detect based on your package manager.
</p>
{/* Dev Presets */}
<div className="flex flex-wrap gap-1.5">
{DEV_SERVER_PRESETS.map((preset) => (
<Button
key={preset.command}
variant="outline"
size="sm"
onClick={() => handleUseDevPreset(preset.command)}
className="text-xs font-mono h-7 px-2"
>
{preset.label}
</Button>
))}
</div>
</div>
</div>
{/* Divider */}
<div className="border-t border-border/30" />
{/* Test Command Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<FlaskConical className="w-4 h-4 text-brand-500" />
<h3 className="text-base font-medium text-foreground">Test Runner</h3>
{hasTestChanges && (
<span className="text-xs text-amber-500 font-medium">(unsaved)</span>
)}
</div>
<div className="space-y-3 pl-6">
<div className="relative">
<Input
id="test-command"
value={testCommand}
onChange={(e) => setTestCommand(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="e.g., npm test, pytest, cargo test"
className="font-mono text-sm pr-8"
data-testid="test-command-input"
/>
{testCommand && (
<Button
variant="ghost"
size="sm"
onClick={handleClearTest}
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
aria-label="Clear test command"
>
<X className="w-3.5 h-3.5" />
</Button>
)}
</div>
<p className="text-xs text-muted-foreground/80">
Leave empty to auto-detect based on your project structure.
</p>
{/* Test Presets */}
<div className="flex flex-wrap gap-1.5">
{TEST_PRESETS.map((preset) => (
<Button
key={preset.command}
variant="outline"
size="sm"
onClick={() => handleUseTestPreset(preset.command)}
className="text-xs font-mono h-7 px-2"
>
{preset.label}
</Button>
))}
</div>
</div>
</div>
{/* Auto-detection Info */}
<div className="flex items-start gap-3 p-3 rounded-lg bg-accent/20 border border-border/30">
<Info className="w-4 h-4 text-brand-500 mt-0.5 shrink-0" />
<div className="text-xs text-muted-foreground">
<p className="font-medium text-foreground mb-1">Auto-detection</p>
<p>
When no custom command is set, the system automatically detects your package
manager and test framework based on project files (package.json, Cargo.toml,
go.mod, etc.).
</p>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={!hasChanges || isSaving}
className="gap-1.5"
>
<RotateCcw className="w-3.5 h-3.5" />
Reset
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!hasChanges || isSaving}
className="gap-1.5"
>
{isSaving ? <Spinner size="xs" /> : <Save className="w-3.5 h-3.5" />}
Save
</Button>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -6,7 +6,7 @@ import {
AlertTriangle,
Workflow,
Database,
FlaskConical,
Terminal,
} from 'lucide-react';
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
@@ -19,7 +19,7 @@ export interface ProjectNavigationItem {
export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
{ id: 'identity', label: 'Identity', icon: User },
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
{ id: 'testing', label: 'Testing', icon: FlaskConical },
{ id: 'commands', label: 'Commands', icon: Terminal },
{ id: 'theme', label: 'Theme', icon: Palette },
{ id: 'claude', label: 'Models', icon: Workflow },
{ id: 'data', label: 'Data', icon: Database },

View File

@@ -4,7 +4,7 @@ export type ProjectSettingsViewId =
| 'identity'
| 'theme'
| 'worktrees'
| 'testing'
| 'commands'
| 'claude'
| 'data'
| 'danger';

View File

@@ -2,6 +2,6 @@ export { ProjectSettingsView } from './project-settings-view';
export { ProjectIdentitySection } from './project-identity-section';
export { ProjectThemeSection } from './project-theme-section';
export { WorktreePreferencesSection } from './worktree-preferences-section';
export { TestingSection } from './testing-section';
export { CommandsSection } from './commands-section';
export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks';
export { ProjectSettingsNavigation } from './components/project-settings-navigation';

View File

@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { ProjectIdentitySection } from './project-identity-section';
import { ProjectThemeSection } from './project-theme-section';
import { WorktreePreferencesSection } from './worktree-preferences-section';
import { TestingSection } from './testing-section';
import { CommandsSection } from './commands-section';
import { ProjectModelsSection } from './project-models-section';
import { DataManagementSection } from './data-management-section';
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
@@ -87,8 +87,8 @@ export function ProjectSettingsView() {
return <ProjectThemeSection project={currentProject} />;
case 'worktrees':
return <WorktreePreferencesSection project={currentProject} />;
case 'testing':
return <TestingSection project={currentProject} />;
case 'commands':
return <CommandsSection project={currentProject} />;
case 'claude':
return <ProjectModelsSection project={currentProject} />;
case 'data':

View File

@@ -1,223 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { FlaskConical, Save, RotateCcw, Info } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import type { Project } from '@/lib/electron';
interface TestingSectionProps {
project: Project;
}
export function TestingSection({ project }: TestingSectionProps) {
const [testCommand, setTestCommand] = useState('');
const [originalTestCommand, setOriginalTestCommand] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
// Check if there are unsaved changes
const hasChanges = testCommand !== originalTestCommand;
// Load project settings when project changes
useEffect(() => {
let isCancelled = false;
const currentPath = project.path;
const loadProjectSettings = async () => {
setIsLoading(true);
try {
const httpClient = getHttpApiClient();
const response = await httpClient.settings.getProject(currentPath);
// Avoid updating state if component unmounted or project changed
if (isCancelled) return;
if (response.success && response.settings) {
const command = response.settings.testCommand || '';
setTestCommand(command);
setOriginalTestCommand(command);
}
} catch (error) {
if (!isCancelled) {
console.error('Failed to load project settings:', error);
}
} finally {
if (!isCancelled) {
setIsLoading(false);
}
}
};
loadProjectSettings();
return () => {
isCancelled = true;
};
}, [project.path]);
// Save test command
const handleSave = useCallback(async () => {
setIsSaving(true);
try {
const httpClient = getHttpApiClient();
const normalizedCommand = testCommand.trim();
const response = await httpClient.settings.updateProject(project.path, {
testCommand: normalizedCommand || undefined,
});
if (response.success) {
setTestCommand(normalizedCommand);
setOriginalTestCommand(normalizedCommand);
toast.success('Test command saved');
} else {
toast.error('Failed to save test command', {
description: response.error,
});
}
} catch (error) {
console.error('Failed to save test command:', error);
toast.error('Failed to save test command');
} finally {
setIsSaving(false);
}
}, [project.path, testCommand]);
// Reset to original value
const handleReset = useCallback(() => {
setTestCommand(originalTestCommand);
}, [originalTestCommand]);
// Use a preset command
const handleUsePreset = useCallback((command: string) => {
setTestCommand(command);
}, []);
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<FlaskConical className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Testing Configuration
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure how tests are run for this project.
</p>
</div>
<div className="p-6 space-y-6">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Spinner size="md" />
</div>
) : (
<>
{/* Test Command Input */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="test-command" className="text-foreground font-medium">
Test Command
</Label>
{hasChanges && (
<span className="text-xs text-amber-500 font-medium">(unsaved changes)</span>
)}
</div>
<Input
id="test-command"
value={testCommand}
onChange={(e) => setTestCommand(e.target.value)}
placeholder="e.g., npm test, yarn test, pytest, go test ./..."
className="font-mono text-sm"
data-testid="test-command-input"
/>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
The command to run tests for this project. If not specified, the test runner will
auto-detect based on your project structure (package.json, Cargo.toml, go.mod,
etc.).
</p>
</div>
{/* Auto-detection Info */}
<div className="flex items-start gap-3 p-3 rounded-lg bg-accent/20 border border-border/30">
<Info className="w-4 h-4 text-brand-500 mt-0.5 shrink-0" />
<div className="text-xs text-muted-foreground">
<p className="font-medium text-foreground mb-1">Auto-detection</p>
<p>
When no custom command is set, the test runner automatically detects and uses the
appropriate test framework based on your project files (Vitest, Jest, Pytest,
Cargo, Go Test, etc.).
</p>
</div>
</div>
{/* Quick Presets */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Quick Presets</Label>
<div className="flex flex-wrap gap-2">
{[
{ label: 'npm test', command: 'npm test' },
{ label: 'yarn test', command: 'yarn test' },
{ label: 'pnpm test', command: 'pnpm test' },
{ label: 'bun test', command: 'bun test' },
{ label: 'pytest', command: 'pytest' },
{ label: 'cargo test', command: 'cargo test' },
{ label: 'go test', command: 'go test ./...' },
].map((preset) => (
<Button
key={preset.command}
variant="outline"
size="sm"
onClick={() => handleUsePreset(preset.command)}
className="text-xs font-mono"
>
{preset.label}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground/80">
Click a preset to use it as your test command.
</p>
</div>
{/* Action Buttons */}
<div className="flex items-center justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={!hasChanges || isSaving}
className="gap-1.5"
>
<RotateCcw className="w-3.5 h-3.5" />
Reset
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!hasChanges || isSaving}
className="gap-1.5"
>
{isSaving ? <Spinner size="xs" /> : <Save className="w-3.5 h-3.5" />}
Save
</Button>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -23,6 +23,7 @@ import {
CursorSettingsTab,
CodexSettingsTab,
OpencodeSettingsTab,
GeminiSettingsTab,
} from './settings-view/providers';
import { MCPServersSection } from './settings-view/mcp-servers';
import { PromptCustomizationSection } from './settings-view/prompts';
@@ -123,6 +124,8 @@ export function SettingsView() {
return <CodexSettingsTab />;
case 'opencode-provider':
return <OpencodeSettingsTab />;
case 'gemini-provider':
return <GeminiSettingsTab />;
case 'providers':
case 'claude': // Backwards compatibility - redirect to claude-provider
return <ClaudeSettingsTab />;

View File

@@ -0,0 +1,250 @@
import { Button } from '@/components/ui/button';
import { SkeletonPulse } from '@/components/ui/skeleton';
import { Spinner } from '@/components/ui/spinner';
import { CheckCircle2, AlertCircle, RefreshCw, Key } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CliStatus } from '../shared/types';
import { GeminiIcon } from '@/components/ui/provider-icon';
export type GeminiAuthMethod =
| 'api_key' // API key authentication
| 'google_login' // Google OAuth authentication
| 'vertex_ai' // Vertex AI authentication
| 'none';
export interface GeminiAuthStatus {
authenticated: boolean;
method: GeminiAuthMethod;
hasApiKey?: boolean;
hasEnvApiKey?: boolean;
hasCredentialsFile?: boolean;
error?: string;
}
function getAuthMethodLabel(method: GeminiAuthMethod): string {
switch (method) {
case 'api_key':
return 'API Key';
case 'google_login':
return 'Google OAuth';
case 'vertex_ai':
return 'Vertex AI';
default:
return method || 'Unknown';
}
}
interface GeminiCliStatusProps {
status: CliStatus | null;
authStatus?: GeminiAuthStatus | null;
isChecking: boolean;
onRefresh: () => void;
}
export function GeminiCliStatusSkeleton() {
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<SkeletonPulse className="w-9 h-9 rounded-xl" />
<SkeletonPulse className="h-6 w-36" />
</div>
<SkeletonPulse className="w-9 h-9 rounded-lg" />
</div>
<div className="ml-12">
<SkeletonPulse className="h-4 w-80" />
</div>
</div>
<div className="p-6 space-y-4">
{/* Installation status skeleton */}
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
<SkeletonPulse className="w-10 h-10 rounded-xl" />
<div className="flex-1 space-y-2">
<SkeletonPulse className="h-4 w-40" />
<SkeletonPulse className="h-3 w-32" />
<SkeletonPulse className="h-3 w-48" />
</div>
</div>
{/* Auth status skeleton */}
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
<SkeletonPulse className="w-10 h-10 rounded-xl" />
<div className="flex-1 space-y-2">
<SkeletonPulse className="h-4 w-28" />
<SkeletonPulse className="h-3 w-36" />
</div>
</div>
</div>
</div>
);
}
export function GeminiCliStatus({
status,
authStatus,
isChecking,
onRefresh,
}: GeminiCliStatusProps) {
if (!status) return <GeminiCliStatusSkeleton />;
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 flex items-center justify-center border border-blue-500/20">
<GeminiIcon className="w-5 h-5 text-blue-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Gemini CLI</h2>
</div>
<Button
variant="ghost"
size="icon"
onClick={onRefresh}
disabled={isChecking}
data-testid="refresh-gemini-cli"
title="Refresh Gemini CLI detection"
className={cn(
'h-9 w-9 rounded-lg',
'hover:bg-accent/50 hover:scale-105',
'transition-all duration-200'
)}
>
{isChecking ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Gemini CLI provides access to Google&apos;s Gemini AI models with thinking capabilities.
</p>
</div>
<div className="p-6 space-y-4">
{status.success && status.status === 'installed' ? (
<div className="space-y-3">
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">Gemini CLI Installed</p>
<div className="text-xs text-emerald-400/70 mt-1.5 space-y-0.5">
{status.method && (
<p>
Method: <span className="font-mono">{status.method}</span>
</p>
)}
{status.version && (
<p>
Version: <span className="font-mono">{status.version}</span>
</p>
)}
{status.path && (
<p className="truncate" title={status.path}>
Path: <span className="font-mono text-[10px]">{status.path}</span>
</p>
)}
</div>
</div>
</div>
{/* Authentication Status */}
{authStatus?.authenticated ? (
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">Authenticated</p>
<div className="text-xs text-emerald-400/70 mt-1.5">
{authStatus.method !== 'none' && (
<p>
Method:{' '}
<span className="font-mono">{getAuthMethodLabel(authStatus.method)}</span>
</p>
)}
</div>
</div>
</div>
) : (
<div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
<div className="w-10 h-10 rounded-xl bg-red-500/15 flex items-center justify-center border border-red-500/20 shrink-0 mt-0.5">
<AlertCircle className="w-5 h-5 text-red-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-red-400">Authentication Failed</p>
{authStatus?.error && (
<p className="text-xs text-red-400/70 mt-1">{authStatus.error}</p>
)}
<p className="text-xs text-red-400/70 mt-2">
Run <code className="font-mono bg-red-500/10 px-1 rounded">gemini</code>{' '}
interactively in your terminal to log in with Google, or set the{' '}
<code className="font-mono bg-red-500/10 px-1 rounded">GEMINI_API_KEY</code>{' '}
environment variable.
</p>
</div>
</div>
)}
{status.recommendation && (
<p className="text-xs text-muted-foreground/70 ml-1">{status.recommendation}</p>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
<AlertCircle className="w-5 h-5 text-amber-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-amber-400">Gemini CLI Not Detected</p>
<p className="text-xs text-amber-400/70 mt-1">
{status.recommendation || 'Install Gemini CLI to use Google Gemini models.'}
</p>
</div>
</div>
{status.installCommands && (
<div className="space-y-3">
<p className="text-xs font-medium text-foreground/80">Installation Commands:</p>
<div className="space-y-2">
{status.installCommands.npm && (
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
npm
</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.npm}
</code>
</div>
)}
{status.installCommands.macos && (
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
macOS/Linux
</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.macos}
</code>
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -17,6 +17,7 @@ const NAV_ID_TO_PROVIDER: Record<string, ModelProvider> = {
'cursor-provider': 'cursor',
'codex-provider': 'codex',
'opencode-provider': 'opencode',
'gemini-provider': 'gemini',
};
interface SettingsNavigationProps {

View File

@@ -17,7 +17,13 @@ import {
Code2,
Webhook,
} from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
import {
AnthropicIcon,
CursorIcon,
OpenAIIcon,
OpenCodeIcon,
GeminiIcon,
} from '@/components/ui/provider-icon';
import type { SettingsViewId } from '../hooks/use-settings-view';
export interface NavigationItem {
@@ -51,6 +57,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
{ id: 'cursor-provider', label: 'Cursor', icon: CursorIcon },
{ id: 'codex-provider', label: 'Codex', icon: OpenAIIcon },
{ id: 'opencode-provider', label: 'OpenCode', icon: OpenCodeIcon },
{ id: 'gemini-provider', label: 'Gemini', icon: GeminiIcon },
],
},
{ id: 'mcp-servers', label: 'MCP Servers', icon: Plug },

View File

@@ -8,6 +8,7 @@ export type SettingsViewId =
| 'cursor-provider'
| 'codex-provider'
| 'opencode-provider'
| 'gemini-provider'
| 'mcp-servers'
| 'prompts'
| 'model-defaults'

View File

@@ -7,6 +7,7 @@ import type {
CursorModelId,
CodexModelId,
OpencodeModelId,
GeminiModelId,
GroupedModel,
PhaseModelEntry,
ClaudeCompatibleProvider,
@@ -25,6 +26,7 @@ import {
CLAUDE_MODELS,
CURSOR_MODELS,
OPENCODE_MODELS,
GEMINI_MODELS,
THINKING_LEVELS,
THINKING_LEVEL_LABELS,
REASONING_EFFORT_LEVELS,
@@ -39,6 +41,7 @@ import {
OpenRouterIcon,
GlmIcon,
MiniMaxIcon,
GeminiIcon,
getProviderIconForModel,
} from '@/components/ui/provider-icon';
import { Button } from '@/components/ui/button';
@@ -168,6 +171,7 @@ export function PhaseModelSelector({
const expandedProviderTriggerRef = useRef<HTMLDivElement>(null);
const {
enabledCursorModels,
enabledGeminiModels,
favoriteModels,
toggleFavoriteModel,
codexModels,
@@ -322,6 +326,11 @@ export function PhaseModelSelector({
return enabledCursorModels.includes(model.id as CursorModelId);
});
// Filter Gemini models to only show enabled ones
const availableGeminiModels = GEMINI_MODELS.filter((model) => {
return enabledGeminiModels.includes(model.id as GeminiModelId);
});
// Helper to find current selected model details
const currentModel = useMemo(() => {
const claudeModel = CLAUDE_MODELS.find((m) => m.id === selectedModel);
@@ -359,6 +368,16 @@ export function PhaseModelSelector({
const codexModel = transformedCodexModels.find((m) => m.id === selectedModel);
if (codexModel) return { ...codexModel, icon: OpenAIIcon };
// Check Gemini models
// Note: Gemini CLI doesn't support thinking level configuration
const geminiModel = availableGeminiModels.find((m) => m.id === selectedModel);
if (geminiModel) {
return {
...geminiModel,
icon: GeminiIcon,
};
}
// Check OpenCode models (static) - use dynamic icon resolution for provider-specific icons
const opencodeModel = OPENCODE_MODELS.find((m) => m.id === selectedModel);
if (opencodeModel) return { ...opencodeModel, icon: getProviderIconForModel(opencodeModel.id) };
@@ -459,6 +478,7 @@ export function PhaseModelSelector({
selectedProviderId,
selectedThinkingLevel,
availableCursorModels,
availableGeminiModels,
transformedCodexModels,
dynamicOpencodeModels,
enabledProviders,
@@ -524,17 +544,20 @@ export function PhaseModelSelector({
// Check if providers are disabled (needed for rendering conditions)
const isCursorDisabled = disabledProviders.includes('cursor');
const isGeminiDisabled = disabledProviders.includes('gemini');
// Group models (filtering out disabled providers)
const { favorites, claude, cursor, codex, opencode } = useMemo(() => {
const { favorites, claude, cursor, codex, gemini, opencode } = useMemo(() => {
const favs: typeof CLAUDE_MODELS = [];
const cModels: typeof CLAUDE_MODELS = [];
const curModels: typeof CURSOR_MODELS = [];
const codModels: typeof transformedCodexModels = [];
const gemModels: typeof GEMINI_MODELS = [];
const ocModels: ModelOption[] = [];
const isClaudeDisabled = disabledProviders.includes('claude');
const isCodexDisabled = disabledProviders.includes('codex');
const isGeminiDisabledInner = disabledProviders.includes('gemini');
const isOpencodeDisabled = disabledProviders.includes('opencode');
// Process Claude Models (skip if provider is disabled)
@@ -570,6 +593,17 @@ export function PhaseModelSelector({
});
}
// Process Gemini Models (skip if provider is disabled)
if (!isGeminiDisabledInner) {
availableGeminiModels.forEach((model) => {
if (favoriteModels.includes(model.id)) {
favs.push(model);
} else {
gemModels.push(model);
}
});
}
// Process OpenCode Models (skip if provider is disabled)
if (!isOpencodeDisabled) {
allOpencodeModels.forEach((model) => {
@@ -586,11 +620,13 @@ export function PhaseModelSelector({
claude: cModels,
cursor: curModels,
codex: codModels,
gemini: gemModels,
opencode: ocModels,
};
}, [
favoriteModels,
availableCursorModels,
availableGeminiModels,
transformedCodexModels,
allOpencodeModels,
disabledProviders,
@@ -1027,6 +1063,60 @@ export function PhaseModelSelector({
);
};
// Render Gemini model item - simple selector without thinking level
// Note: Gemini CLI doesn't support a --thinking-level flag, thinking is model-internal
const renderGeminiModelItem = (model: (typeof GEMINI_MODELS)[0]) => {
const isSelected = selectedModel === model.id;
const isFavorite = favoriteModels.includes(model.id);
return (
<CommandItem
key={model.id}
value={model.label}
onSelect={() => {
onChange({ model: model.id as GeminiModelId });
setOpen(false);
}}
className="group flex items-center justify-between py-2"
>
<div className="flex items-center gap-3 overflow-hidden">
<GeminiIcon
className={cn(
'h-4 w-4 shrink-0',
isSelected ? 'text-primary' : 'text-muted-foreground'
)}
/>
<div className="flex flex-col truncate">
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
{model.label}
</span>
<span className="truncate text-xs text-muted-foreground">{model.description}</span>
</div>
</div>
<div className="flex items-center gap-1 ml-2">
<Button
variant="ghost"
size="icon"
className={cn(
'h-6 w-6 hover:bg-transparent hover:text-yellow-500 focus:ring-0',
isFavorite
? 'text-yellow-500 opacity-100'
: 'opacity-0 group-hover:opacity-100 text-muted-foreground'
)}
onClick={(e) => {
e.stopPropagation();
toggleFavoriteModel(model.id);
}}
>
<Star className={cn('h-3.5 w-3.5', isFavorite && 'fill-current')} />
</Button>
{isSelected && <Check className="h-4 w-4 text-primary shrink-0" />}
</div>
</CommandItem>
);
};
// Render ClaudeCompatibleProvider model item with thinking level support
const renderProviderModelItem = (
provider: ClaudeCompatibleProvider,
@@ -1839,6 +1929,10 @@ export function PhaseModelSelector({
if (model.provider === 'codex') {
return renderCodexModelItem(model as (typeof transformedCodexModels)[0]);
}
// Gemini model
if (model.provider === 'gemini') {
return renderGeminiModelItem(model as (typeof GEMINI_MODELS)[0]);
}
// OpenCode model
if (model.provider === 'opencode') {
return renderOpencodeModelItem(model);
@@ -1917,6 +2011,12 @@ export function PhaseModelSelector({
</CommandGroup>
)}
{!isGeminiDisabled && gemini.length > 0 && (
<CommandGroup heading="Gemini Models">
{gemini.map((model) => renderGeminiModelItem(model))}
</CommandGroup>
)}
{opencodeSections.length > 0 && (
<CommandGroup heading={OPENCODE_CLI_GROUP_LABEL}>
{opencodeSections.map((section, sectionIndex) => (

View File

@@ -0,0 +1,146 @@
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import type { GeminiModelId } from '@automaker/types';
import { GeminiIcon } from '@/components/ui/provider-icon';
import { GEMINI_MODEL_MAP } from '@automaker/types';
interface GeminiModelConfigurationProps {
enabledGeminiModels: GeminiModelId[];
geminiDefaultModel: GeminiModelId;
isSaving: boolean;
onDefaultModelChange: (model: GeminiModelId) => void;
onModelToggle: (model: GeminiModelId, enabled: boolean) => void;
}
interface GeminiModelInfo {
id: GeminiModelId;
label: string;
description: string;
supportsThinking: boolean;
}
// Build model info from the GEMINI_MODEL_MAP
const GEMINI_MODEL_INFO: Record<GeminiModelId, GeminiModelInfo> = Object.fromEntries(
Object.entries(GEMINI_MODEL_MAP).map(([id, config]) => [
id as GeminiModelId,
{
id: id as GeminiModelId,
label: config.label,
description: config.description,
supportsThinking: config.supportsThinking,
},
])
) as Record<GeminiModelId, GeminiModelInfo>;
export function GeminiModelConfiguration({
enabledGeminiModels,
geminiDefaultModel,
isSaving,
onDefaultModelChange,
onModelToggle,
}: GeminiModelConfigurationProps) {
const availableModels = Object.values(GEMINI_MODEL_INFO);
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 flex items-center justify-center border border-blue-500/20">
<GeminiIcon className="w-5 h-5 text-blue-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Model Configuration
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Configure which Gemini models are available in the feature modal
</p>
</div>
<div className="p-6 space-y-6">
<div className="space-y-2">
<Label>Default Model</Label>
<Select
value={geminiDefaultModel}
onValueChange={(v) => onDefaultModelChange(v as GeminiModelId)}
disabled={isSaving}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
<div className="flex items-center gap-2">
<span>{model.label}</span>
{model.supportsThinking && (
<Badge variant="outline" className="text-xs">
Thinking
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<Label>Available Models</Label>
<div className="grid gap-3">
{availableModels.map((model) => {
const isEnabled = enabledGeminiModels.includes(model.id);
const isDefault = model.id === geminiDefaultModel;
return (
<div
key={model.id}
className="flex items-center justify-between p-3 rounded-xl border border-border/50 bg-card/50 hover:bg-accent/30 transition-colors"
>
<div className="flex items-center gap-3">
<Checkbox
checked={isEnabled}
onCheckedChange={(checked) => onModelToggle(model.id, !!checked)}
disabled={isSaving || isDefault}
/>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{model.label}</span>
{model.supportsThinking && (
<Badge variant="outline" className="text-xs">
Thinking
</Badge>
)}
{isDefault && (
<Badge variant="secondary" className="text-xs">
Default
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{model.description}</p>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,130 @@
import { useState, useCallback, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
import { GeminiCliStatus, GeminiCliStatusSkeleton } from '../cli-status/gemini-cli-status';
import { GeminiModelConfiguration } from './gemini-model-configuration';
import { ProviderToggle } from './provider-toggle';
import { useGeminiCliStatus } from '@/hooks/queries';
import { queryKeys } from '@/lib/query-keys';
import type { CliStatus as SharedCliStatus } from '../shared/types';
import type { GeminiAuthStatus } from '../cli-status/gemini-cli-status';
import type { GeminiModelId } from '@automaker/types';
export function GeminiSettingsTab() {
const queryClient = useQueryClient();
const { enabledGeminiModels, geminiDefaultModel, setGeminiDefaultModel, toggleGeminiModel } =
useAppStore();
const [isSaving, setIsSaving] = useState(false);
// React Query hooks for data fetching
const {
data: cliStatusData,
isLoading: isCheckingGeminiCli,
refetch: refetchCliStatus,
} = useGeminiCliStatus();
const isCliInstalled = cliStatusData?.installed ?? false;
// Transform CLI status to the expected format
const cliStatus = useMemo((): SharedCliStatus | null => {
if (!cliStatusData) return null;
return {
success: cliStatusData.success ?? false,
status: cliStatusData.installed ? 'installed' : 'not_installed',
method: cliStatusData.auth?.method,
version: cliStatusData.version,
path: cliStatusData.path,
recommendation: cliStatusData.recommendation,
// Server sends installCommand (singular), transform to expected format
installCommands: cliStatusData.installCommand
? { npm: cliStatusData.installCommand }
: cliStatusData.installCommands,
};
}, [cliStatusData]);
// Transform auth status to the expected format
const authStatus = useMemo((): GeminiAuthStatus | null => {
if (!cliStatusData?.auth) return null;
return {
authenticated: cliStatusData.auth.authenticated,
method: (cliStatusData.auth.method as GeminiAuthStatus['method']) || 'none',
hasApiKey: cliStatusData.auth.hasApiKey,
hasEnvApiKey: cliStatusData.auth.hasEnvApiKey,
error: cliStatusData.auth.error,
};
}, [cliStatusData]);
// Refresh all gemini-related queries
const handleRefreshGeminiCli = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.cli.gemini() });
await refetchCliStatus();
toast.success('Gemini CLI refreshed');
}, [queryClient, refetchCliStatus]);
const handleDefaultModelChange = useCallback(
(model: GeminiModelId) => {
setIsSaving(true);
try {
setGeminiDefaultModel(model);
toast.success('Default model updated');
} catch {
toast.error('Failed to update default model');
} finally {
setIsSaving(false);
}
},
[setGeminiDefaultModel]
);
const handleModelToggle = useCallback(
(model: GeminiModelId, enabled: boolean) => {
setIsSaving(true);
try {
toggleGeminiModel(model, enabled);
} catch {
toast.error('Failed to update models');
} finally {
setIsSaving(false);
}
},
[toggleGeminiModel]
);
// Show skeleton only while checking CLI status initially
if (!cliStatus && isCheckingGeminiCli) {
return (
<div className="space-y-6">
<GeminiCliStatusSkeleton />
</div>
);
}
return (
<div className="space-y-6">
{/* Provider Visibility Toggle */}
<ProviderToggle provider="gemini" providerLabel="Gemini" />
<GeminiCliStatus
status={cliStatus}
authStatus={authStatus}
isChecking={isCheckingGeminiCli}
onRefresh={handleRefreshGeminiCli}
/>
{/* Model Configuration - Only show when CLI is installed */}
{isCliInstalled && (
<GeminiModelConfiguration
enabledGeminiModels={enabledGeminiModels}
geminiDefaultModel={geminiDefaultModel}
isSaving={isSaving}
onDefaultModelChange={handleDefaultModelChange}
onModelToggle={handleModelToggle}
/>
)}
</div>
);
}
export default GeminiSettingsTab;

View File

@@ -3,3 +3,4 @@ export { ClaudeSettingsTab } from './claude-settings-tab';
export { CursorSettingsTab } from './cursor-settings-tab';
export { CodexSettingsTab } from './codex-settings-tab';
export { OpencodeSettingsTab } from './opencode-settings-tab';
export { GeminiSettingsTab } from './gemini-settings-tab';

View File

@@ -1,20 +1,26 @@
import React from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
import { Cpu } from 'lucide-react';
import {
AnthropicIcon,
CursorIcon,
OpenAIIcon,
GeminiIcon,
OpenCodeIcon,
} from '@/components/ui/provider-icon';
import { CursorSettingsTab } from './cursor-settings-tab';
import { ClaudeSettingsTab } from './claude-settings-tab';
import { CodexSettingsTab } from './codex-settings-tab';
import { OpencodeSettingsTab } from './opencode-settings-tab';
import { GeminiSettingsTab } from './gemini-settings-tab';
interface ProviderTabsProps {
defaultTab?: 'claude' | 'cursor' | 'codex' | 'opencode';
defaultTab?: 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini';
}
export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
return (
<Tabs defaultValue={defaultTab} className="w-full">
<TabsList className="grid w-full grid-cols-4 mb-6">
<TabsList className="grid w-full grid-cols-5 mb-6">
<TabsTrigger value="claude" className="flex items-center gap-2">
<AnthropicIcon className="w-4 h-4" />
Claude
@@ -28,9 +34,13 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
Codex
</TabsTrigger>
<TabsTrigger value="opencode" className="flex items-center gap-2">
<Cpu className="w-4 h-4" />
<OpenCodeIcon className="w-4 h-4" />
OpenCode
</TabsTrigger>
<TabsTrigger value="gemini" className="flex items-center gap-2">
<GeminiIcon className="w-4 h-4" />
Gemini
</TabsTrigger>
</TabsList>
<TabsContent value="claude">
@@ -48,6 +58,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
<TabsContent value="opencode">
<OpencodeSettingsTab />
</TabsContent>
<TabsContent value="gemini">
<GeminiSettingsTab />
</TabsContent>
</Tabs>
);
}

View File

@@ -31,7 +31,13 @@ import {
import { Spinner } from '@/components/ui/spinner';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
import {
AnthropicIcon,
CursorIcon,
OpenAIIcon,
OpenCodeIcon,
GeminiIcon,
} from '@/components/ui/provider-icon';
import { TerminalOutput } from '../components';
import { useCliInstallation, useTokenSave } from '../hooks';
@@ -40,7 +46,7 @@ interface ProvidersSetupStepProps {
onBack: () => void;
}
type ProviderTab = 'claude' | 'cursor' | 'codex' | 'opencode';
type ProviderTab = 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini';
// ============================================================================
// Claude Content
@@ -1209,6 +1215,318 @@ function OpencodeContent() {
);
}
// ============================================================================
// Gemini Content
// ============================================================================
function GeminiContent() {
const { geminiCliStatus, setGeminiCliStatus } = useSetupStore();
const { setApiKeys, apiKeys } = useAppStore();
const [isChecking, setIsChecking] = useState(false);
const [apiKey, setApiKey] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [isLoggingIn, setIsLoggingIn] = useState(false);
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const checkStatus = useCallback(async () => {
setIsChecking(true);
try {
const api = getElectronAPI();
if (!api.setup?.getGeminiStatus) return;
const result = await api.setup.getGeminiStatus();
if (result.success) {
setGeminiCliStatus({
installed: result.installed ?? false,
version: result.version,
path: result.path,
auth: result.auth,
installCommand: result.installCommand,
loginCommand: result.loginCommand,
});
if (result.auth?.authenticated) {
toast.success('Gemini CLI is ready!');
}
}
} catch {
// Ignore
} finally {
setIsChecking(false);
}
}, [setGeminiCliStatus]);
useEffect(() => {
checkStatus();
return () => {
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
};
}, [checkStatus]);
const copyCommand = (command: string) => {
navigator.clipboard.writeText(command);
toast.success('Command copied to clipboard');
};
const handleSaveApiKey = async () => {
if (!apiKey.trim()) return;
setIsSaving(true);
try {
const api = getElectronAPI();
if (!api.setup?.saveApiKey) {
toast.error('Save API not available');
return;
}
const result = await api.setup.saveApiKey('google', apiKey);
if (result.success) {
setApiKeys({ ...apiKeys, google: apiKey });
setGeminiCliStatus({
...geminiCliStatus,
installed: geminiCliStatus?.installed ?? false,
auth: { authenticated: true, method: 'api_key' },
});
toast.success('API key saved successfully!');
}
} catch {
toast.error('Failed to save API key');
} finally {
setIsSaving(false);
}
};
const handleLogin = async () => {
setIsLoggingIn(true);
try {
const loginCommand = geminiCliStatus?.loginCommand || 'gemini auth login';
await navigator.clipboard.writeText(loginCommand);
toast.info('Login command copied! Paste in terminal to authenticate.');
let attempts = 0;
pollIntervalRef.current = setInterval(async () => {
attempts++;
try {
const api = getElectronAPI();
if (!api.setup?.getGeminiStatus) return;
const result = await api.setup.getGeminiStatus();
if (result.auth?.authenticated) {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setGeminiCliStatus({
...geminiCliStatus,
installed: result.installed ?? true,
version: result.version,
path: result.path,
auth: result.auth,
});
setIsLoggingIn(false);
toast.success('Successfully logged in to Gemini!');
}
} catch {
// Ignore
}
if (attempts >= 60) {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setIsLoggingIn(false);
toast.error('Login timed out. Please try again.');
}
}, 2000);
} catch {
toast.error('Failed to start login process');
setIsLoggingIn(false);
}
};
const isReady = geminiCliStatus?.installed && geminiCliStatus?.auth?.authenticated;
return (
<Card className="bg-card border-border">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<GeminiIcon className="w-5 h-5" />
Gemini CLI Status
</CardTitle>
<Button variant="ghost" size="sm" onClick={checkStatus} disabled={isChecking}>
{isChecking ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<CardDescription>
{geminiCliStatus?.installed
? geminiCliStatus.auth?.authenticated
? `Authenticated${geminiCliStatus.version ? ` (v${geminiCliStatus.version})` : ''}`
: 'Installed but not authenticated'
: 'Not installed on your system'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isReady && (
<div className="space-y-3">
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium text-foreground">CLI Installed</p>
<p className="text-sm text-muted-foreground">
{geminiCliStatus?.version && `Version: ${geminiCliStatus.version}`}
</p>
</div>
</div>
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<p className="font-medium text-foreground">Authenticated</p>
</div>
</div>
)}
{!geminiCliStatus?.installed && !isChecking && (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-lg bg-muted/30 border border-border">
<XCircle className="w-5 h-5 text-muted-foreground shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-foreground">Gemini CLI not found</p>
<p className="text-sm text-muted-foreground mt-1">
Install the Gemini CLI to use Google Gemini models.
</p>
</div>
</div>
<div className="space-y-3 p-4 rounded-lg bg-muted/30 border border-border">
<p className="font-medium text-foreground text-sm">Install Gemini CLI:</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground overflow-x-auto">
{geminiCliStatus?.installCommand || 'npm install -g @google/gemini-cli'}
</code>
<Button
variant="ghost"
size="icon"
onClick={() =>
copyCommand(
geminiCliStatus?.installCommand || 'npm install -g @google/gemini-cli'
)
}
>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
</div>
)}
{geminiCliStatus?.installed && !geminiCliStatus?.auth?.authenticated && !isChecking && (
<div className="space-y-4">
{/* Show CLI installed toast */}
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium text-foreground">CLI Installed</p>
<p className="text-sm text-muted-foreground">
{geminiCliStatus?.version && `Version: ${geminiCliStatus.version}`}
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 rounded-lg bg-amber-500/10 border border-amber-500/20">
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-foreground">Gemini CLI not authenticated</p>
<p className="text-sm text-muted-foreground mt-1">
Run the login command or provide a Google API key below.
</p>
</div>
</div>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="cli" className="border-border">
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-3">
<Terminal className="w-5 h-5 text-muted-foreground" />
<span className="font-medium">Google OAuth Login</span>
</div>
</AccordionTrigger>
<AccordionContent className="pt-4 space-y-4">
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
{geminiCliStatus?.loginCommand || 'gemini auth login'}
</code>
<Button
variant="ghost"
size="icon"
onClick={() =>
copyCommand(geminiCliStatus?.loginCommand || 'gemini auth login')
}
>
<Copy className="w-4 h-4" />
</Button>
</div>
<Button
onClick={handleLogin}
disabled={isLoggingIn}
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
>
{isLoggingIn ? (
<>
<Spinner size="sm" className="mr-2" />
Waiting for login...
</>
) : (
'Copy Command & Wait for Login'
)}
</Button>
</AccordionContent>
</AccordionItem>
<AccordionItem value="api-key" className="border-border">
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-3">
<Key className="w-5 h-5 text-muted-foreground" />
<span className="font-medium">Google API Key</span>
</div>
</AccordionTrigger>
<AccordionContent className="pt-4 space-y-4">
<div className="space-y-2">
<Input
type="password"
placeholder="AIza..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="bg-input border-border text-foreground"
/>
<p className="text-xs text-muted-foreground">
<a
href="https://aistudio.google.com/apikey"
target="_blank"
rel="noopener noreferrer"
className="text-brand-500 hover:underline"
>
Get an API key from Google AI Studio
<ExternalLink className="w-3 h-3 inline ml-1" />
</a>
</p>
</div>
<Button
onClick={handleSaveApiKey}
disabled={isSaving || !apiKey.trim()}
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
>
{isSaving ? <Spinner size="sm" /> : 'Save API Key'}
</Button>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
)}
{isChecking && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
<Spinner size="md" />
<p className="font-medium text-foreground">Checking Gemini CLI status...</p>
</div>
)}
</CardContent>
</Card>
);
}
// ============================================================================
// Main Component
// ============================================================================
@@ -1225,11 +1543,13 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
codexCliStatus,
codexAuthStatus,
opencodeCliStatus,
geminiCliStatus,
setClaudeCliStatus,
setCursorCliStatus,
setCodexCliStatus,
setCodexAuthStatus,
setOpencodeCliStatus,
setGeminiCliStatus,
} = useSetupStore();
// Check all providers on mount
@@ -1319,8 +1639,28 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
}
};
// Check Gemini
const checkGemini = async () => {
try {
if (!api.setup?.getGeminiStatus) return;
const result = await api.setup.getGeminiStatus();
if (result.success) {
setGeminiCliStatus({
installed: result.installed ?? false,
version: result.version,
path: result.path,
auth: result.auth,
installCommand: result.installCommand,
loginCommand: result.loginCommand,
});
}
} catch {
// Ignore errors
}
};
// Run all checks in parallel
await Promise.all([checkClaude(), checkCursor(), checkCodex(), checkOpencode()]);
await Promise.all([checkClaude(), checkCursor(), checkCodex(), checkOpencode(), checkGemini()]);
setIsInitialChecking(false);
}, [
setClaudeCliStatus,
@@ -1328,6 +1668,7 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
setCodexCliStatus,
setCodexAuthStatus,
setOpencodeCliStatus,
setGeminiCliStatus,
]);
useEffect(() => {
@@ -1354,11 +1695,15 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
const isOpencodeInstalled = opencodeCliStatus?.installed === true;
const isOpencodeAuthenticated = opencodeCliStatus?.auth?.authenticated === true;
const isGeminiInstalled = geminiCliStatus?.installed === true;
const isGeminiAuthenticated = geminiCliStatus?.auth?.authenticated === true;
const hasAtLeastOneProvider =
isClaudeAuthenticated ||
isCursorAuthenticated ||
isCodexAuthenticated ||
isOpencodeAuthenticated;
isOpencodeAuthenticated ||
isGeminiAuthenticated;
type ProviderStatus = 'not_installed' | 'installed_not_auth' | 'authenticated' | 'verifying';
@@ -1402,6 +1747,13 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
status: getProviderStatus(isOpencodeInstalled, isOpencodeAuthenticated),
color: 'text-green-500',
},
{
id: 'gemini' as const,
label: 'Gemini',
icon: GeminiIcon,
status: getProviderStatus(isGeminiInstalled, isGeminiAuthenticated),
color: 'text-blue-500',
},
];
const renderStatusIcon = (status: ProviderStatus) => {
@@ -1438,7 +1790,7 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
)}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as ProviderTab)}>
<TabsList className="grid w-full grid-cols-4 h-auto p-1">
<TabsList className="grid w-full grid-cols-5 h-auto p-1">
{providers.map((provider) => {
const Icon = provider.icon;
return (
@@ -1484,6 +1836,9 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
<TabsContent value="opencode" className="mt-0">
<OpencodeContent />
</TabsContent>
<TabsContent value="gemini" className="mt-0">
<GeminiContent />
</TabsContent>
</div>
</Tabs>

View File

@@ -63,6 +63,7 @@ export {
useCursorCliStatus,
useCodexCliStatus,
useOpencodeCliStatus,
useGeminiCliStatus,
useGitHubCliStatus,
useApiKeysStatus,
usePlatformInfo,

View File

@@ -89,6 +89,26 @@ export function useOpencodeCliStatus() {
});
}
/**
* Fetch Gemini CLI status
*
* @returns Query result with Gemini CLI status
*/
export function useGeminiCliStatus() {
return useQuery({
queryKey: queryKeys.cli.gemini(),
queryFn: async () => {
const api = getElectronAPI();
const result = await api.setup.getGeminiStatus();
if (!result.success) {
throw new Error(result.error || 'Failed to fetch Gemini status');
}
return result;
},
staleTime: STALE_TIMES.CLI_STATUS,
});
}
/**
* Fetch GitHub CLI status
*

View File

@@ -21,15 +21,20 @@ import { useAuthStore } from '@/store/auth-store';
import { waitForMigrationComplete, resetMigrationState } from './use-settings-migration';
import {
DEFAULT_OPENCODE_MODEL,
DEFAULT_GEMINI_MODEL,
DEFAULT_MAX_CONCURRENCY,
getAllOpencodeModelIds,
getAllCursorModelIds,
getAllCodexModelIds,
getAllGeminiModelIds,
migrateCursorModelIds,
migrateOpencodeModelIds,
migratePhaseModelEntry,
type GlobalSettings,
type CursorModelId,
type OpencodeModelId,
type CodexModelId,
type GeminiModelId,
} from '@automaker/types';
const logger = createLogger('SettingsSync');
@@ -66,6 +71,10 @@ const SETTINGS_FIELDS_TO_SYNC = [
'cursorDefaultModel',
'enabledOpencodeModels',
'opencodeDefaultModel',
'enabledCodexModels',
'codexDefaultModel',
'enabledGeminiModels',
'geminiDefaultModel',
'enabledDynamicModelIds',
'disabledProviders',
'autoLoadClaudeMd',
@@ -567,6 +576,37 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
sanitizedEnabledOpencodeModels.push(sanitizedOpencodeDefaultModel);
}
// Sanitize Codex models
const validCodexModelIds = new Set(getAllCodexModelIds());
const DEFAULT_CODEX_MODEL: CodexModelId = 'codex-gpt-5.2-codex';
const sanitizedEnabledCodexModels = (serverSettings.enabledCodexModels ?? []).filter(
(id): id is CodexModelId => validCodexModelIds.has(id as CodexModelId)
);
const sanitizedCodexDefaultModel = validCodexModelIds.has(
serverSettings.codexDefaultModel as CodexModelId
)
? (serverSettings.codexDefaultModel as CodexModelId)
: DEFAULT_CODEX_MODEL;
if (!sanitizedEnabledCodexModels.includes(sanitizedCodexDefaultModel)) {
sanitizedEnabledCodexModels.push(sanitizedCodexDefaultModel);
}
// Sanitize Gemini models
const validGeminiModelIds = new Set(getAllGeminiModelIds());
const sanitizedEnabledGeminiModels = (serverSettings.enabledGeminiModels ?? []).filter(
(id): id is GeminiModelId => validGeminiModelIds.has(id as GeminiModelId)
);
const sanitizedGeminiDefaultModel = validGeminiModelIds.has(
serverSettings.geminiDefaultModel as GeminiModelId
)
? (serverSettings.geminiDefaultModel as GeminiModelId)
: DEFAULT_GEMINI_MODEL;
if (!sanitizedEnabledGeminiModels.includes(sanitizedGeminiDefaultModel)) {
sanitizedEnabledGeminiModels.push(sanitizedGeminiDefaultModel);
}
const persistedDynamicModelIds =
serverSettings.enabledDynamicModelIds ?? currentAppState.enabledDynamicModelIds;
const sanitizedDynamicModelIds = persistedDynamicModelIds.filter(
@@ -659,6 +699,10 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
cursorDefaultModel: sanitizedCursorDefault,
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
enabledCodexModels: sanitizedEnabledCodexModels,
codexDefaultModel: sanitizedCodexDefaultModel,
enabledGeminiModels: sanitizedEnabledGeminiModels,
geminiDefaultModel: sanitizedGeminiDefaultModel,
enabledDynamicModelIds: sanitizedDynamicModelIds,
disabledProviders: serverSettings.disabledProviders ?? [],
autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? false,

View File

@@ -1655,6 +1655,48 @@ export class HttpApiClient implements ElectronAPI {
error?: string;
}> => this.post('/api/setup/opencode/cache/clear'),
// Gemini CLI methods
getGeminiStatus: (): Promise<{
success: boolean;
status?: string;
installed?: boolean;
method?: string;
version?: string;
path?: string;
recommendation?: string;
installCommands?: {
macos?: string;
linux?: string;
npm?: string;
};
auth?: {
authenticated: boolean;
method: string;
hasApiKey?: boolean;
hasEnvApiKey?: boolean;
error?: string;
};
loginCommand?: string;
installCommand?: string;
error?: string;
}> => this.get('/api/setup/gemini-status'),
authGemini: (): Promise<{
success: boolean;
requiresManualAuth?: boolean;
command?: string;
message?: string;
error?: string;
}> => this.post('/api/setup/auth-gemini'),
deauthGemini: (): Promise<{
success: boolean;
requiresManualDeauth?: boolean;
command?: string;
message?: string;
error?: string;
}> => this.post('/api/setup/deauth-gemini'),
onInstallProgress: (callback: (progress: unknown) => void) => {
return this.subscribeToEvent('agent:stream', callback);
},

View File

@@ -176,6 +176,8 @@ export const queryKeys = {
codex: () => ['cli', 'codex'] as const,
/** OpenCode CLI status */
opencode: () => ['cli', 'opencode'] as const,
/** Gemini CLI status */
gemini: () => ['cli', 'gemini'] as const,
/** GitHub CLI status */
github: () => ['cli', 'github'] as const,
/** API keys status */

View File

@@ -21,6 +21,7 @@ import type {
CursorModelId,
CodexModelId,
OpencodeModelId,
GeminiModelId,
PhaseModelConfig,
PhaseModelKey,
PhaseModelEntry,
@@ -39,8 +40,10 @@ import {
getAllCursorModelIds,
getAllCodexModelIds,
getAllOpencodeModelIds,
getAllGeminiModelIds,
DEFAULT_PHASE_MODELS,
DEFAULT_OPENCODE_MODEL,
DEFAULT_GEMINI_MODEL,
DEFAULT_MAX_CONCURRENCY,
DEFAULT_GLOBAL_SETTINGS,
} from '@automaker/types';
@@ -729,6 +732,10 @@ export interface AppState {
opencodeModelsLastFetched: number | null; // Timestamp of last successful fetch
opencodeModelsLastFailedAt: number | null; // Timestamp of last failed fetch
// Gemini CLI Settings (global)
enabledGeminiModels: GeminiModelId[]; // Which Gemini models are available in feature modal
geminiDefaultModel: GeminiModelId; // Default Gemini model selection
// Provider Visibility Settings
disabledProviders: ModelProvider[]; // Providers that are disabled and hidden from dropdowns
@@ -1218,6 +1225,11 @@ export interface AppActions {
providers: Array<{ id: string; name: string; authenticated: boolean; authMethod?: string }>
) => void;
// Gemini CLI Settings actions
setEnabledGeminiModels: (models: GeminiModelId[]) => void;
setGeminiDefaultModel: (model: GeminiModelId) => void;
toggleGeminiModel: (model: GeminiModelId, enabled: boolean) => void;
// Provider Visibility Settings actions
setDisabledProviders: (providers: ModelProvider[]) => void;
toggleProviderDisabled: (provider: ModelProvider, disabled: boolean) => void;
@@ -1503,6 +1515,8 @@ const initialState: AppState = {
opencodeModelsError: null,
opencodeModelsLastFetched: null,
opencodeModelsLastFailedAt: null,
enabledGeminiModels: getAllGeminiModelIds(), // All Gemini models enabled by default
geminiDefaultModel: DEFAULT_GEMINI_MODEL, // Default to Gemini 2.5 Flash
disabledProviders: [], // No providers disabled by default
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
@@ -2735,6 +2749,16 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
),
}),
// Gemini CLI Settings actions
setEnabledGeminiModels: (models) => set({ enabledGeminiModels: models }),
setGeminiDefaultModel: (model) => set({ geminiDefaultModel: model }),
toggleGeminiModel: (model, enabled) =>
set((state) => ({
enabledGeminiModels: enabled
? [...state.enabledGeminiModels, model]
: state.enabledGeminiModels.filter((m) => m !== model),
})),
// Provider Visibility Settings actions
setDisabledProviders: (providers) => set({ disabledProviders: providers }),
toggleProviderDisabled: (provider, disabled) =>

View File

@@ -63,6 +63,22 @@ export interface OpencodeCliStatus {
error?: string;
}
// Gemini CLI Status
export interface GeminiCliStatus {
installed: boolean;
version?: string | null;
path?: string | null;
auth?: {
authenticated: boolean;
method: string;
hasApiKey?: boolean;
hasEnvApiKey?: boolean;
};
installCommand?: string;
loginCommand?: string;
error?: string;
}
// Codex Auth Method
export type CodexAuthMethod =
| 'api_key_env' // OPENAI_API_KEY environment variable
@@ -120,6 +136,7 @@ export type SetupStep =
| 'cursor'
| 'codex'
| 'opencode'
| 'gemini'
| 'github'
| 'complete';
@@ -149,6 +166,9 @@ export interface SetupState {
// OpenCode CLI state
opencodeCliStatus: OpencodeCliStatus | null;
// Gemini CLI state
geminiCliStatus: GeminiCliStatus | null;
// Setup preferences
skipClaudeSetup: boolean;
}
@@ -183,6 +203,9 @@ export interface SetupActions {
// OpenCode CLI
setOpencodeCliStatus: (status: OpencodeCliStatus | null) => void;
// Gemini CLI
setGeminiCliStatus: (status: GeminiCliStatus | null) => void;
// Preferences
setSkipClaudeSetup: (skip: boolean) => void;
}
@@ -216,6 +239,8 @@ const initialState: SetupState = {
opencodeCliStatus: null,
geminiCliStatus: null,
skipClaudeSetup: shouldSkipSetup,
};
@@ -288,6 +313,9 @@ export const useSetupStore = create<SetupState & SetupActions>()((set, get) => (
// OpenCode CLI
setOpencodeCliStatus: (status) => set({ opencodeCliStatus: status }),
// Gemini CLI
setGeminiCliStatus: (status) => set({ geminiCliStatus: status }),
// Preferences
setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }),
}));

View File

@@ -0,0 +1,101 @@
/**
* Gemini CLI Model Definitions
*
* Defines available models for Gemini CLI integration.
* Based on https://github.com/google-gemini/gemini-cli
*/
/**
* Gemini model configuration
*/
export interface GeminiModelConfig {
label: string;
description: string;
supportsVision: boolean;
supportsThinking: boolean;
contextWindow?: number;
}
/**
* Available Gemini models via the Gemini CLI
* Models from Gemini 2.5 and 3.0 series
*
* Model IDs use 'gemini-' prefix for consistent provider routing (like Cursor).
* When passed to the CLI, the prefix is part of the actual model name.
*/
export const GEMINI_MODEL_MAP = {
// Gemini 3 Series (latest)
'gemini-3-pro-preview': {
label: 'Gemini 3 Pro Preview',
description: 'Most advanced Gemini model with deep reasoning capabilities.',
supportsVision: true,
supportsThinking: true,
contextWindow: 1000000,
},
'gemini-3-flash-preview': {
label: 'Gemini 3 Flash Preview',
description: 'Fast Gemini 3 model for quick tasks.',
supportsVision: true,
supportsThinking: true,
contextWindow: 1000000,
},
// Gemini 2.5 Series
'gemini-2.5-pro': {
label: 'Gemini 2.5 Pro',
description: 'Advanced model with strong reasoning and 1M context.',
supportsVision: true,
supportsThinking: true,
contextWindow: 1000000,
},
'gemini-2.5-flash': {
label: 'Gemini 2.5 Flash',
description: 'Balanced speed and capability for most tasks.',
supportsVision: true,
supportsThinking: true,
contextWindow: 1000000,
},
'gemini-2.5-flash-lite': {
label: 'Gemini 2.5 Flash Lite',
description: 'Fastest Gemini model for simple tasks.',
supportsVision: true,
supportsThinking: false,
contextWindow: 1000000,
},
} as const satisfies Record<string, GeminiModelConfig>;
/**
* Gemini model ID type (keys already have gemini- prefix)
*/
export type GeminiModelId = keyof typeof GEMINI_MODEL_MAP;
/**
* Get all Gemini model IDs
*/
export function getAllGeminiModelIds(): GeminiModelId[] {
return Object.keys(GEMINI_MODEL_MAP) as GeminiModelId[];
}
/**
* Default Gemini model (balanced choice)
*/
export const DEFAULT_GEMINI_MODEL: GeminiModelId = 'gemini-2.5-flash';
/**
* Thinking level configuration for Gemini models
* Note: The Gemini CLI does not currently expose a --thinking-level flag.
* Thinking control (thinkingLevel/thinkingBudget) is available via the Gemini API.
* This type is defined for potential future CLI support or API-level configuration.
*/
export type GeminiThinkingLevel = 'off' | 'low' | 'medium' | 'high';
/**
* Gemini CLI authentication status
*/
export interface GeminiAuthStatus {
authenticated: boolean;
method: 'google_login' | 'api_key' | 'vertex_ai' | 'none';
hasApiKey?: boolean;
hasEnvApiKey?: boolean;
hasCredentialsFile?: boolean;
error?: string;
}

View File

@@ -205,6 +205,7 @@ export {
export type { ModelOption, ThinkingLevelOption, ReasoningEffortOption } from './model-display.js';
export {
CLAUDE_MODELS,
GEMINI_MODELS,
THINKING_LEVELS,
THINKING_LEVEL_LABELS,
REASONING_EFFORT_LEVELS,
@@ -249,6 +250,9 @@ export * from './cursor-cli.js';
// OpenCode types
export * from './opencode-models.js';
// Gemini types
export * from './gemini-models.js';
// Provider utilities
export {
PROVIDER_PREFIXES,
@@ -256,6 +260,7 @@ export {
isClaudeModel,
isCodexModel,
isOpencodeModel,
isGeminiModel,
getModelProvider,
stripProviderPrefix,
addProviderPrefix,

View File

@@ -10,20 +10,21 @@ import type { ReasoningEffort } from './provider.js';
import type { CursorModelId } from './cursor-models.js';
import type { AgentModel, CodexModelId } from './model.js';
import { CODEX_MODEL_MAP } from './model.js';
import { GEMINI_MODEL_MAP, type GeminiModelId } from './gemini-models.js';
/**
* ModelOption - Display metadata for a model option in the UI
*/
export interface ModelOption {
/** Model identifier (supports both Claude and Cursor models) */
id: ModelAlias | CursorModelId;
/** Model identifier (supports Claude, Cursor, Gemini models) */
id: ModelAlias | CursorModelId | GeminiModelId;
/** Display name shown to user */
label: string;
/** Descriptive text explaining model capabilities */
description: string;
/** Optional badge text (e.g., "Speed", "Balanced", "Premium") */
badge?: string;
/** AI provider (supports 'claude' and 'cursor') */
/** AI provider */
provider: ModelProvider;
}
@@ -113,6 +114,22 @@ export const CODEX_MODELS: (ModelOption & { hasReasoning?: boolean })[] = [
},
];
/**
* Gemini model options with full metadata for UI display
* Based on https://github.com/google-gemini/gemini-cli
* Model IDs match the keys in GEMINI_MODEL_MAP (e.g., 'gemini-2.5-flash')
*/
export const GEMINI_MODELS: (ModelOption & { hasThinking?: boolean })[] = Object.entries(
GEMINI_MODEL_MAP
).map(([id, config]) => ({
id: id as GeminiModelId,
label: config.label,
description: config.description,
badge: config.supportsThinking ? 'Thinking' : 'Speed',
provider: 'gemini' as const,
hasThinking: config.supportsThinking,
}));
/**
* Thinking level options with display labels
*
@@ -200,5 +217,16 @@ export function getModelDisplayName(model: ModelAlias | string): string {
[CODEX_MODEL_MAP.gpt52]: 'GPT-5.2',
[CODEX_MODEL_MAP.gpt51]: 'GPT-5.1',
};
return displayNames[model] || model;
// Check direct match first
if (model in displayNames) {
return displayNames[model];
}
// Check Gemini model map - IDs are like 'gemini-2.5-flash'
if (model in GEMINI_MODEL_MAP) {
return GEMINI_MODEL_MAP[model as keyof typeof GEMINI_MODEL_MAP].label;
}
return model;
}

View File

@@ -3,6 +3,7 @@
*/
import type { CursorModelId } from './cursor-models.js';
import type { OpencodeModelId } from './opencode-models.js';
import type { GeminiModelId } from './gemini-models.js';
/**
* Canonical Claude model IDs with provider prefix
@@ -119,6 +120,7 @@ export type DynamicModelId = `${string}/${string}`;
*/
export type PrefixedCursorModelId = `cursor-${string}`;
export type PrefixedOpencodeModelId = `opencode-${string}`;
export type PrefixedGeminiModelId = `gemini-${string}`;
/**
* ModelId - Unified model identifier across providers
@@ -127,7 +129,9 @@ export type ModelId =
| ModelAlias
| CodexModelId
| CursorModelId
| GeminiModelId
| OpencodeModelId
| DynamicModelId
| PrefixedCursorModelId
| PrefixedOpencodeModelId;
| PrefixedOpencodeModelId
| PrefixedGeminiModelId;

View File

@@ -10,12 +10,14 @@ import type { ModelProvider } from './settings.js';
import { CURSOR_MODEL_MAP, LEGACY_CURSOR_MODEL_MAP } from './cursor-models.js';
import { CLAUDE_MODEL_MAP, CODEX_MODEL_MAP } from './model.js';
import { OPENCODE_MODEL_CONFIG_MAP, LEGACY_OPENCODE_MODEL_MAP } from './opencode-models.js';
import { GEMINI_MODEL_MAP } from './gemini-models.js';
/** Provider prefix constants */
export const PROVIDER_PREFIXES = {
cursor: 'cursor-',
codex: 'codex-',
opencode: 'opencode-',
gemini: 'gemini-',
} as const;
/**
@@ -90,6 +92,28 @@ export function isCodexModel(model: string | undefined | null): boolean {
return model in CODEX_MODEL_MAP;
}
/**
* Check if a model string represents a Gemini model
*
* @param model - Model string to check (e.g., "gemini-2.5-pro", "gemini-3-pro-preview")
* @returns true if the model is a Gemini model
*/
export function isGeminiModel(model: string | undefined | null): boolean {
if (!model || typeof model !== 'string') return false;
// Canonical format: gemini- prefix (e.g., "gemini-2.5-flash")
if (model.startsWith(PROVIDER_PREFIXES.gemini)) {
return true;
}
// Check if it's a known Gemini model ID (map keys include gemini- prefix)
if (model in GEMINI_MODEL_MAP) {
return true;
}
return false;
}
/**
* Check if a model string represents an OpenCode model
*
@@ -151,7 +175,11 @@ export function isOpencodeModel(model: string | undefined | null): boolean {
* @returns The provider type, defaults to 'claude' for unknown models
*/
export function getModelProvider(model: string | undefined | null): ModelProvider {
// Check OpenCode first since it uses provider-prefixed formats that could conflict
// Check Gemini first since it uses gemini- prefix
if (isGeminiModel(model)) {
return 'gemini';
}
// Check OpenCode next since it uses provider-prefixed formats that could conflict
if (isOpencodeModel(model)) {
return 'opencode';
}
@@ -199,6 +227,7 @@ export function stripProviderPrefix(model: string): string {
* addProviderPrefix('cursor-composer-1', 'cursor') // 'cursor-composer-1' (no change)
* addProviderPrefix('gpt-5.2', 'codex') // 'codex-gpt-5.2'
* addProviderPrefix('sonnet', 'claude') // 'sonnet' (Claude doesn't use prefix)
* addProviderPrefix('2.5-flash', 'gemini') // 'gemini-2.5-flash'
*/
export function addProviderPrefix(model: string, provider: ModelProvider): string {
if (!model || typeof model !== 'string') return model;
@@ -215,6 +244,10 @@ export function addProviderPrefix(model: string, provider: ModelProvider): strin
if (!model.startsWith(PROVIDER_PREFIXES.opencode)) {
return `${PROVIDER_PREFIXES.opencode}${model}`;
}
} else if (provider === 'gemini') {
if (!model.startsWith(PROVIDER_PREFIXES.gemini)) {
return `${PROVIDER_PREFIXES.gemini}${model}`;
}
}
// Claude models don't use prefixes
return model;
@@ -250,6 +283,7 @@ export function normalizeModelString(model: string | undefined | null): string {
model.startsWith(PROVIDER_PREFIXES.cursor) ||
model.startsWith(PROVIDER_PREFIXES.codex) ||
model.startsWith(PROVIDER_PREFIXES.opencode) ||
model.startsWith(PROVIDER_PREFIXES.gemini) ||
model.startsWith('claude-')
) {
return model;

View File

@@ -99,7 +99,7 @@ export function getThinkingTokenBudget(level: ThinkingLevel | undefined): number
}
/** ModelProvider - AI model provider for credentials and API key management */
export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode';
export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini';
// ============================================================================
// Claude-Compatible Providers - Configuration for Claude-compatible API endpoints
@@ -1190,6 +1190,14 @@ export interface ProjectSettings {
*/
testCommand?: string;
// Dev Server Configuration
/**
* Custom command to start the development server for this project.
* If not specified, auto-detection will be used based on project structure.
* Examples: "npm run dev", "yarn dev", "pnpm dev", "cargo watch", "go run ."
*/
devCommand?: string;
// Phase Model Overrides (per-project)
/**
* Override phase model settings for this project.

295
package-lock.json generated
View File

@@ -163,6 +163,8 @@
"react-markdown": "10.1.0",
"react-resizable-panels": "3.0.6",
"rehype-raw": "7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"sonner": "2.0.7",
"tailwind-merge": "3.4.0",
"usehooks-ts": "3.1.1",
@@ -12130,6 +12132,16 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/markdown-table": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
"integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/matcher": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
@@ -12153,6 +12165,34 @@
"node": ">= 0.4"
}
},
"node_modules/mdast-util-find-and-replace": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
"integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"escape-string-regexp": "^5.0.0",
"unist-util-is": "^6.0.0",
"unist-util-visit-parents": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mdast-util-from-markdown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz",
@@ -12177,6 +12217,107 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
"integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
"license": "MIT",
"dependencies": {
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-gfm-autolink-literal": "^2.0.0",
"mdast-util-gfm-footnote": "^2.0.0",
"mdast-util-gfm-strikethrough": "^2.0.0",
"mdast-util-gfm-table": "^2.0.0",
"mdast-util-gfm-task-list-item": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-autolink-literal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
"integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"ccount": "^2.0.0",
"devlop": "^1.0.0",
"mdast-util-find-and-replace": "^3.0.0",
"micromark-util-character": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-footnote": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
"integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.1.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0",
"micromark-util-normalize-identifier": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-strikethrough": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
"integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-table": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
"integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"markdown-table": "^3.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-task-list-item": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
"integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-mdx-expression": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
@@ -12396,6 +12537,127 @@
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-extension-gfm": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
"integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
"license": "MIT",
"dependencies": {
"micromark-extension-gfm-autolink-literal": "^2.0.0",
"micromark-extension-gfm-footnote": "^2.0.0",
"micromark-extension-gfm-strikethrough": "^2.0.0",
"micromark-extension-gfm-table": "^2.0.0",
"micromark-extension-gfm-tagfilter": "^2.0.0",
"micromark-extension-gfm-task-list-item": "^2.0.0",
"micromark-util-combine-extensions": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-autolink-literal": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
"integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
"license": "MIT",
"dependencies": {
"micromark-util-character": "^2.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-footnote": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
"integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-core-commonmark": "^2.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-normalize-identifier": "^2.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-strikethrough": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
"integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-util-chunked": "^2.0.0",
"micromark-util-classify-character": "^2.0.0",
"micromark-util-resolve-all": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-table": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
"integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-tagfilter": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
"integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
"license": "MIT",
"dependencies": {
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-task-list-item": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
"integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-factory-destination": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
@@ -14184,6 +14446,24 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
"integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-gfm": "^3.0.0",
"micromark-extension-gfm": "^3.0.0",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-parse": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@@ -14217,6 +14497,21 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-stringify": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
"integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-to-markdown": "^2.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",