mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Merge remote-tracking branch 'upstream/v0.14.0rc' into feature/unified-sidebar
This commit is contained in:
815
apps/server/src/providers/gemini-provider.ts
Normal file
815
apps/server/src/providers/gemini-provider.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
42
apps/server/src/routes/setup/routes/auth-gemini.ts
Normal file
42
apps/server/src/routes/setup/routes/auth-gemini.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
42
apps/server/src/routes/setup/routes/deauth-gemini.ts
Normal file
42
apps/server/src/routes/setup/routes/deauth-gemini.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
79
apps/server/src/routes/setup/routes/gemini-status.ts
Normal file
79
apps/server/src/routes/setup/routes/gemini-status.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -395,6 +395,7 @@ export const PROVIDER_ICON_COMPONENTS: Record<
|
||||
cursor: CursorIcon,
|
||||
codex: OpenAIIcon,
|
||||
opencode: OpenCodeIcon,
|
||||
gemini: GeminiIcon,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -4,7 +4,7 @@ export type ProjectSettingsViewId =
|
||||
| 'identity'
|
||||
| 'theme'
|
||||
| 'worktrees'
|
||||
| 'testing'
|
||||
| 'commands'
|
||||
| 'claude'
|
||||
| 'data'
|
||||
| 'danger';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -8,6 +8,7 @@ export type SettingsViewId =
|
||||
| 'cursor-provider'
|
||||
| 'codex-provider'
|
||||
| 'opencode-provider'
|
||||
| 'gemini-provider'
|
||||
| 'mcp-servers'
|
||||
| 'prompts'
|
||||
| 'model-defaults'
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ export {
|
||||
useCursorCliStatus,
|
||||
useCodexCliStatus,
|
||||
useOpencodeCliStatus,
|
||||
useGeminiCliStatus,
|
||||
useGitHubCliStatus,
|
||||
useApiKeysStatus,
|
||||
usePlatformInfo,
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
101
libs/types/src/gemini-models.ts
Normal file
101
libs/types/src/gemini-models.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
295
package-lock.json
generated
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user