mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 08:53:36 +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 { BaseProvider } from './base-provider.js';
|
||||||
import type { InstallationStatus, ModelDefinition } from './types.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 fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
@@ -16,6 +22,7 @@ const DISCONNECTED_MARKERS: Record<string, string> = {
|
|||||||
codex: '.codex-disconnected',
|
codex: '.codex-disconnected',
|
||||||
cursor: '.cursor-disconnected',
|
cursor: '.cursor-disconnected',
|
||||||
opencode: '.opencode-disconnected',
|
opencode: '.opencode-disconnected',
|
||||||
|
gemini: '.gemini-disconnected',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -239,8 +246,8 @@ export class ProviderFactory {
|
|||||||
model.modelString === modelId ||
|
model.modelString === modelId ||
|
||||||
model.id.endsWith(`-${modelId}`) ||
|
model.id.endsWith(`-${modelId}`) ||
|
||||||
model.modelString.endsWith(`-${modelId}`) ||
|
model.modelString.endsWith(`-${modelId}`) ||
|
||||||
model.modelString === modelId.replace(/^(claude|cursor|codex)-/, '') ||
|
model.modelString === modelId.replace(/^(claude|cursor|codex|gemini)-/, '') ||
|
||||||
model.modelString === modelId.replace(/-(claude|cursor|codex)$/, '')
|
model.modelString === modelId.replace(/-(claude|cursor|codex|gemini)$/, '')
|
||||||
) {
|
) {
|
||||||
return model.supportsVision ?? true;
|
return model.supportsVision ?? true;
|
||||||
}
|
}
|
||||||
@@ -267,6 +274,7 @@ import { ClaudeProvider } from './claude-provider.js';
|
|||||||
import { CursorProvider } from './cursor-provider.js';
|
import { CursorProvider } from './cursor-provider.js';
|
||||||
import { CodexProvider } from './codex-provider.js';
|
import { CodexProvider } from './codex-provider.js';
|
||||||
import { OpencodeProvider } from './opencode-provider.js';
|
import { OpencodeProvider } from './opencode-provider.js';
|
||||||
|
import { GeminiProvider } from './gemini-provider.js';
|
||||||
|
|
||||||
// Register Claude provider
|
// Register Claude provider
|
||||||
registerProvider('claude', {
|
registerProvider('claude', {
|
||||||
@@ -301,3 +309,11 @@ registerProvider('opencode', {
|
|||||||
canHandleModel: (model: string) => isOpencodeModel(model),
|
canHandleModel: (model: string) => isOpencodeModel(model),
|
||||||
priority: 3, // Between codex (5) and claude (0)
|
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 { createAuthOpencodeHandler } from './routes/auth-opencode.js';
|
||||||
import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js';
|
import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js';
|
||||||
import { createOpencodeStatusHandler } from './routes/opencode-status.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 {
|
import {
|
||||||
createGetOpencodeModelsHandler,
|
createGetOpencodeModelsHandler,
|
||||||
createRefreshOpencodeModelsHandler,
|
createRefreshOpencodeModelsHandler,
|
||||||
@@ -72,6 +75,11 @@ export function createSetupRoutes(): Router {
|
|||||||
router.post('/auth-opencode', createAuthOpencodeHandler());
|
router.post('/auth-opencode', createAuthOpencodeHandler());
|
||||||
router.post('/deauth-opencode', createDeauthOpencodeHandler());
|
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
|
// OpenCode Dynamic Model Discovery routes
|
||||||
router.get('/opencode/models', createGetOpencodeModelsHandler());
|
router.get('/opencode/models', createGetOpencodeModelsHandler());
|
||||||
router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler());
|
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(
|
router.post(
|
||||||
'/start-dev',
|
'/start-dev',
|
||||||
validatePathParams('projectPath', 'worktreePath'),
|
validatePathParams('projectPath', 'worktreePath'),
|
||||||
createStartDevHandler()
|
createStartDevHandler(settingsService)
|
||||||
);
|
);
|
||||||
router.post('/stop-dev', createStopDevHandler());
|
router.post('/stop-dev', createStopDevHandler());
|
||||||
router.post('/list-dev-servers', createListDevServersHandler());
|
router.post('/list-dev-servers', createListDevServersHandler());
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* POST /start-dev endpoint - Start a dev server for a worktree
|
* POST /start-dev endpoint - Start a dev server for a worktree
|
||||||
*
|
*
|
||||||
* Spins up a development server (npm run dev) in the worktree directory
|
* Spins up a development server in the worktree directory on a unique port,
|
||||||
* on a unique port, allowing preview of the worktree's changes without
|
* allowing preview of the worktree's changes without affecting the main dev server.
|
||||||
* 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 { Request, Response } from 'express';
|
||||||
|
import type { SettingsService } from '../../../services/settings-service.js';
|
||||||
import { getDevServerService } from '../../../services/dev-server-service.js';
|
import { getDevServerService } from '../../../services/dev-server-service.js';
|
||||||
import { getErrorMessage, logError } from '../common.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> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { projectPath, worktreePath } = req.body as {
|
const { projectPath, worktreePath } = req.body as {
|
||||||
@@ -34,8 +40,25 @@ export function createStartDevHandler() {
|
|||||||
return;
|
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 devServerService = getDevServerService();
|
||||||
const result = await devServerService.startDevServer(projectPath, worktreePath);
|
const result = await devServerService.startDevServer(
|
||||||
|
projectPath,
|
||||||
|
worktreePath,
|
||||||
|
customCommand
|
||||||
|
);
|
||||||
|
|
||||||
if (result.success && result.result) {
|
if (result.success && result.result) {
|
||||||
res.json({
|
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
|
* 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(
|
async startDevServer(
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
worktreePath: string
|
worktreePath: string,
|
||||||
|
customCommand?: string
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
result?: {
|
result?: {
|
||||||
@@ -311,22 +355,41 @@ class DevServerService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for package.json
|
// Determine the dev command to use
|
||||||
const packageJsonPath = path.join(worktreePath, 'package.json');
|
let devCommand: { cmd: string; args: string[] };
|
||||||
if (!(await this.fileExists(packageJsonPath))) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `No package.json found in: ${worktreePath}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get dev command
|
// Normalize custom command: trim whitespace and treat empty strings as undefined
|
||||||
const devCommand = await this.getDevCommand(worktreePath);
|
const normalizedCustomCommand = customCommand?.trim();
|
||||||
if (!devCommand) {
|
|
||||||
return {
|
if (normalizedCustomCommand) {
|
||||||
success: false,
|
// Use the provided custom command
|
||||||
error: `Could not determine dev command for: ${worktreePath}`,
|
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
|
// Find available port
|
||||||
|
|||||||
@@ -852,6 +852,20 @@ export class SettingsService {
|
|||||||
delete updated.defaultFeatureModel;
|
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);
|
await writeSettingsJson(settingsPath, updated);
|
||||||
logger.info(`Project settings updated for ${projectPath}`);
|
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 { CursorProvider } from '@/providers/cursor-provider.js';
|
||||||
import { CodexProvider } from '@/providers/codex-provider.js';
|
import { CodexProvider } from '@/providers/codex-provider.js';
|
||||||
import { OpencodeProvider } from '@/providers/opencode-provider.js';
|
import { OpencodeProvider } from '@/providers/opencode-provider.js';
|
||||||
|
import { GeminiProvider } from '@/providers/gemini-provider.js';
|
||||||
|
|
||||||
describe('provider-factory.ts', () => {
|
describe('provider-factory.ts', () => {
|
||||||
let consoleSpy: any;
|
let consoleSpy: any;
|
||||||
@@ -11,6 +12,7 @@ describe('provider-factory.ts', () => {
|
|||||||
let detectCursorSpy: any;
|
let detectCursorSpy: any;
|
||||||
let detectCodexSpy: any;
|
let detectCodexSpy: any;
|
||||||
let detectOpencodeSpy: any;
|
let detectOpencodeSpy: any;
|
||||||
|
let detectGeminiSpy: any;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
consoleSpy = {
|
consoleSpy = {
|
||||||
@@ -30,6 +32,9 @@ describe('provider-factory.ts', () => {
|
|||||||
detectOpencodeSpy = vi
|
detectOpencodeSpy = vi
|
||||||
.spyOn(OpencodeProvider.prototype, 'detectInstallation')
|
.spyOn(OpencodeProvider.prototype, 'detectInstallation')
|
||||||
.mockResolvedValue({ installed: true });
|
.mockResolvedValue({ installed: true });
|
||||||
|
detectGeminiSpy = vi
|
||||||
|
.spyOn(GeminiProvider.prototype, 'detectInstallation')
|
||||||
|
.mockResolvedValue({ installed: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -38,6 +43,7 @@ describe('provider-factory.ts', () => {
|
|||||||
detectCursorSpy.mockRestore();
|
detectCursorSpy.mockRestore();
|
||||||
detectCodexSpy.mockRestore();
|
detectCodexSpy.mockRestore();
|
||||||
detectOpencodeSpy.mockRestore();
|
detectOpencodeSpy.mockRestore();
|
||||||
|
detectGeminiSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getProviderForModel', () => {
|
describe('getProviderForModel', () => {
|
||||||
@@ -166,9 +172,15 @@ describe('provider-factory.ts', () => {
|
|||||||
expect(hasClaudeProvider).toBe(true);
|
expect(hasClaudeProvider).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return exactly 4 providers', () => {
|
it('should return exactly 5 providers', () => {
|
||||||
const providers = ProviderFactory.getAllProviders();
|
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', () => {
|
it('should include CursorProvider', () => {
|
||||||
@@ -206,7 +218,8 @@ describe('provider-factory.ts', () => {
|
|||||||
expect(keys).toContain('cursor');
|
expect(keys).toContain('cursor');
|
||||||
expect(keys).toContain('codex');
|
expect(keys).toContain('codex');
|
||||||
expect(keys).toContain('opencode');
|
expect(keys).toContain('opencode');
|
||||||
expect(keys).toHaveLength(4);
|
expect(keys).toContain('gemini');
|
||||||
|
expect(keys).toHaveLength(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include cursor status', async () => {
|
it('should include cursor status', async () => {
|
||||||
|
|||||||
@@ -102,6 +102,8 @@
|
|||||||
"react-markdown": "10.1.0",
|
"react-markdown": "10.1.0",
|
||||||
"react-resizable-panels": "3.0.6",
|
"react-resizable-panels": "3.0.6",
|
||||||
"rehype-raw": "7.0.0",
|
"rehype-raw": "7.0.0",
|
||||||
|
"rehype-sanitize": "^6.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "2.0.7",
|
"sonner": "2.0.7",
|
||||||
"tailwind-merge": "3.4.0",
|
"tailwind-merge": "3.4.0",
|
||||||
"usehooks-ts": "3.1.1",
|
"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 rehypeRaw from 'rehype-raw';
|
||||||
import rehypeSanitize from 'rehype-sanitize';
|
import rehypeSanitize from 'rehype-sanitize';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Square, CheckSquare } from 'lucide-react';
|
||||||
|
|
||||||
interface MarkdownProps {
|
interface MarkdownProps {
|
||||||
children: string;
|
children: string;
|
||||||
className?: 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
|
* Reusable Markdown component for rendering markdown content
|
||||||
* Theme-aware styling that adapts to all predefined themes
|
* 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',
|
'[&_hr]:border-border [&_hr]:my-4',
|
||||||
// Images
|
// Images
|
||||||
'[&_img]:max-w-full [&_img]:h-auto [&_img]:rounded-lg [&_img]:my-2 [&_img]:border [&_img]:border-border',
|
'[&_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
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ReactMarkdown rehypePlugins={[rehypeRaw, rehypeSanitize]}>{children}</ReactMarkdown>
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={[rehypeRaw, rehypeSanitize]}
|
||||||
|
components={markdownComponents}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -395,6 +395,7 @@ export const PROVIDER_ICON_COMPONENTS: Record<
|
|||||||
cursor: CursorIcon,
|
cursor: CursorIcon,
|
||||||
codex: OpenAIIcon,
|
codex: OpenAIIcon,
|
||||||
opencode: OpenCodeIcon,
|
opencode: OpenCodeIcon,
|
||||||
|
gemini: GeminiIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -136,8 +136,9 @@ export const KanbanCard = memo(function KanbanCard({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Make the card a drop target for creating dependency links
|
// Make the card a drop target for creating dependency links
|
||||||
// Only backlog cards can be link targets (to avoid complexity with running features)
|
// All non-completed cards can be link targets to allow flexible dependency creation
|
||||||
const isDroppable = !isOverlay && feature.status === 'backlog' && !isSelectionMode;
|
// (completed features are excluded as they're already done)
|
||||||
|
const isDroppable = !isOverlay && feature.status !== 'completed' && !isSelectionMode;
|
||||||
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
|
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
|
||||||
id: `card-drop-${feature.id}`,
|
id: `card-drop-${feature.id}`,
|
||||||
disabled: !isDroppable,
|
disabled: !isDroppable,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { ArrowDown, ArrowUp, Link2, X } from 'lucide-react';
|
import { ArrowDown, ArrowUp, Link2, X } from 'lucide-react';
|
||||||
import type { Feature } from '@/store/app-store';
|
import type { Feature } from '@/store/app-store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { StatusBadge } from '../components';
|
||||||
|
import type { FeatureStatusWithPipeline } from '@automaker/types';
|
||||||
|
|
||||||
export type DependencyLinkType = 'parent' | 'child';
|
export type DependencyLinkType = 'parent' | 'child';
|
||||||
|
|
||||||
@@ -57,7 +59,10 @@ export function DependencyLinkDialog({
|
|||||||
<div className="py-4 space-y-4">
|
<div className="py-4 space-y-4">
|
||||||
{/* Dragged feature */}
|
{/* Dragged feature */}
|
||||||
<div className="p-3 rounded-lg border bg-muted/30">
|
<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">
|
<div className="text-sm font-medium line-clamp-3 break-words">
|
||||||
{draggedFeature.description}
|
{draggedFeature.description}
|
||||||
</div>
|
</div>
|
||||||
@@ -71,7 +76,10 @@ export function DependencyLinkDialog({
|
|||||||
|
|
||||||
{/* Target feature */}
|
{/* Target feature */}
|
||||||
<div className="p-3 rounded-lg border bg-muted/30">
|
<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">
|
<div className="text-sm font-medium line-clamp-3 break-words">
|
||||||
{targetFeature.description}
|
{targetFeature.description}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
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 { Label } from '@/components/ui/label';
|
||||||
import { Feature } from '@/store/app-store';
|
import { Feature } from '@/store/app-store';
|
||||||
import { Check, RefreshCw, Edit2, Eye } from 'lucide-react';
|
import { Check, RefreshCw, Edit2, Eye } from 'lucide-react';
|
||||||
@@ -42,6 +42,10 @@ export function PlanApprovalDialog({
|
|||||||
const [editedPlan, setEditedPlan] = useState(planContent);
|
const [editedPlan, setEditedPlan] = useState(planContent);
|
||||||
const [showRejectFeedback, setShowRejectFeedback] = useState(false);
|
const [showRejectFeedback, setShowRejectFeedback] = useState(false);
|
||||||
const [rejectFeedback, setRejectFeedback] = useState('');
|
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
|
// Reset state when dialog opens or plan content changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -50,6 +54,7 @@ export function PlanApprovalDialog({
|
|||||||
setIsEditMode(false);
|
setIsEditMode(false);
|
||||||
setShowRejectFeedback(false);
|
setShowRejectFeedback(false);
|
||||||
setRejectFeedback('');
|
setRejectFeedback('');
|
||||||
|
setShowFullDescription(false);
|
||||||
}
|
}
|
||||||
}, [open, planContent]);
|
}, [open, planContent]);
|
||||||
|
|
||||||
@@ -82,15 +87,31 @@ export function PlanApprovalDialog({
|
|||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="max-w-4xl" data-testid="plan-approval-dialog">
|
<DialogContent className="max-w-4xl" data-testid="plan-approval-dialog">
|
||||||
<DialogHeader>
|
<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>
|
<DialogDescription>
|
||||||
{viewOnly
|
{viewOnly
|
||||||
? 'View the generated plan for this feature.'
|
? 'View the generated plan for this feature.'
|
||||||
: 'Review the generated plan before implementation begins.'}
|
: 'Review the generated plan before implementation begins.'}
|
||||||
{feature && (
|
{feature && (
|
||||||
<span className="block mt-2 text-primary">
|
<span className="block mt-2 text-primary">
|
||||||
Feature: {feature.description.slice(0, 150)}
|
Feature:{' '}
|
||||||
{feature.description.length > 150 ? '...' : ''}
|
{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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
@@ -135,9 +156,7 @@ export function PlanApprovalDialog({
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-4 overflow-auto">
|
<PlanContentViewer content={editedPlan || ''} className="p-4" />
|
||||||
<Markdown>{editedPlan || 'No plan content available.'}</Markdown>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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';
|
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:
|
// Determine final branch name based on work mode:
|
||||||
// - 'current': Use current worktree's branch (or undefined if on main)
|
// - '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
|
// - 'custom': Use the provided branch name
|
||||||
let finalBranchName: string | undefined;
|
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
|
// This ensures features created on a non-main worktree are associated with that worktree
|
||||||
finalBranchName = currentWorktreeBranch || undefined;
|
finalBranchName = currentWorktreeBranch || undefined;
|
||||||
} else if (workMode === 'auto') {
|
} else if (workMode === 'auto') {
|
||||||
// Auto-generate a branch name based on primary branch (main/master) and timestamp
|
// Auto-generate a branch name based on feature title and timestamp
|
||||||
// Always use primary branch to avoid nested feature/feature/... paths
|
// Create a slug from the title: lowercase, replace non-alphanumeric with hyphens
|
||||||
const baseBranch =
|
const titleSlug =
|
||||||
(currentProject?.path ? getPrimaryWorktreeBranch(currentProject.path) : null) || 'main';
|
titleForBranch
|
||||||
const timestamp = Date.now();
|
.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);
|
const randomSuffix = Math.random().toString(36).substring(2, 6);
|
||||||
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
|
finalBranchName = `feature/${titleSlug}-${randomSuffix}`;
|
||||||
} else {
|
} else {
|
||||||
// Custom mode - use provided branch name
|
// Custom mode - use provided branch name
|
||||||
finalBranchName = featureData.branchName || undefined;
|
finalBranchName = featureData.branchName || undefined;
|
||||||
@@ -183,12 +211,13 @@ export function useBoardActions({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we need to generate a title
|
// Check if we need to generate a title (only if we didn't already generate it for the branch name)
|
||||||
const needsTitleGeneration = !featureData.title.trim() && featureData.description.trim();
|
const needsTitleGeneration =
|
||||||
|
!titleWasGenerated && !featureData.title.trim() && featureData.description.trim();
|
||||||
|
|
||||||
const newFeatureData = {
|
const newFeatureData = {
|
||||||
...featureData,
|
...featureData,
|
||||||
title: featureData.title,
|
title: titleWasGenerated ? titleForBranch : featureData.title,
|
||||||
titleGenerating: needsTitleGeneration,
|
titleGenerating: needsTitleGeneration,
|
||||||
status: 'backlog' as const,
|
status: 'backlog' as const,
|
||||||
branchName: finalBranchName,
|
branchName: finalBranchName,
|
||||||
@@ -255,7 +284,6 @@ export function useBoardActions({
|
|||||||
projectPath,
|
projectPath,
|
||||||
onWorktreeCreated,
|
onWorktreeCreated,
|
||||||
onWorktreeAutoSelect,
|
onWorktreeAutoSelect,
|
||||||
getPrimaryWorktreeBranch,
|
|
||||||
features,
|
features,
|
||||||
currentWorktreeBranch,
|
currentWorktreeBranch,
|
||||||
]
|
]
|
||||||
@@ -287,6 +315,31 @@ export function useBoardActions({
|
|||||||
) => {
|
) => {
|
||||||
const workMode = updates.workMode || 'current';
|
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
|
// Determine final branch name based on work mode
|
||||||
let finalBranchName: string | undefined;
|
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
|
// This ensures features updated on a non-main worktree are associated with that worktree
|
||||||
finalBranchName = currentWorktreeBranch || undefined;
|
finalBranchName = currentWorktreeBranch || undefined;
|
||||||
} else if (workMode === 'auto') {
|
} else if (workMode === 'auto') {
|
||||||
// Auto-generate a branch name based on primary branch (main/master) and timestamp
|
// Preserve existing branch name if one exists (avoid orphaning worktrees on edit)
|
||||||
// Always use primary branch to avoid nested feature/feature/... paths
|
if (updates.branchName?.trim()) {
|
||||||
const baseBranch =
|
finalBranchName = updates.branchName;
|
||||||
(currentProject?.path ? getPrimaryWorktreeBranch(currentProject.path) : null) || 'main';
|
} else {
|
||||||
const timestamp = Date.now();
|
// Auto-generate a branch name based on feature title
|
||||||
const randomSuffix = Math.random().toString(36).substring(2, 6);
|
// Create a slug from the title: lowercase, replace non-alphanumeric with hyphens
|
||||||
finalBranchName = `feature/${baseBranch}-${timestamp}-${randomSuffix}`;
|
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 {
|
} else {
|
||||||
finalBranchName = updates.branchName || undefined;
|
finalBranchName = updates.branchName || undefined;
|
||||||
}
|
}
|
||||||
@@ -343,7 +404,7 @@ export function useBoardActions({
|
|||||||
|
|
||||||
const finalUpdates = {
|
const finalUpdates = {
|
||||||
...restUpdates,
|
...restUpdates,
|
||||||
title: updates.title,
|
title: titleWasGenerated ? titleForBranch : updates.title,
|
||||||
branchName: finalBranchName,
|
branchName: finalBranchName,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -406,7 +467,6 @@ export function useBoardActions({
|
|||||||
setEditingFeature,
|
setEditingFeature,
|
||||||
currentProject,
|
currentProject,
|
||||||
onWorktreeCreated,
|
onWorktreeCreated,
|
||||||
getPrimaryWorktreeBranch,
|
|
||||||
features,
|
features,
|
||||||
currentWorktreeBranch,
|
currentWorktreeBranch,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -88,10 +88,10 @@ export function useBoardDragDrop({
|
|||||||
const targetFeature = features.find((f) => f.id === targetFeatureId);
|
const targetFeature = features.find((f) => f.id === targetFeatureId);
|
||||||
if (!targetFeature) return;
|
if (!targetFeature) return;
|
||||||
|
|
||||||
// Only allow linking backlog features (both must be in backlog)
|
// Don't allow linking completed features (they're already done)
|
||||||
if (draggedFeature.status !== 'backlog' || targetFeature.status !== 'backlog') {
|
if (draggedFeature.status === 'completed' || targetFeature.status === 'completed') {
|
||||||
toast.error('Cannot link features', {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,16 @@ import {
|
|||||||
CURSOR_MODEL_MAP,
|
CURSOR_MODEL_MAP,
|
||||||
CODEX_MODEL_MAP,
|
CODEX_MODEL_MAP,
|
||||||
OPENCODE_MODELS as OPENCODE_MODEL_CONFIGS,
|
OPENCODE_MODELS as OPENCODE_MODEL_CONFIGS,
|
||||||
|
GEMINI_MODEL_MAP,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
|
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 = {
|
export type ModelOption = {
|
||||||
id: string; // All model IDs use canonical prefixed format (e.g., "claude-sonnet", "cursor-auto")
|
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[] = [
|
export const ALL_MODELS: ModelOption[] = [
|
||||||
...CLAUDE_MODELS,
|
...CLAUDE_MODELS,
|
||||||
...CURSOR_MODELS,
|
...CURSOR_MODELS,
|
||||||
...CODEX_MODELS,
|
...CODEX_MODELS,
|
||||||
...OPENCODE_MODELS,
|
...OPENCODE_MODELS,
|
||||||
|
...GEMINI_MODELS,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink'];
|
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,
|
Cursor: CursorIcon,
|
||||||
Codex: OpenAIIcon,
|
Codex: OpenAIIcon,
|
||||||
OpenCode: OpenCodeIcon,
|
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,
|
AlertTriangle,
|
||||||
Workflow,
|
Workflow,
|
||||||
Database,
|
Database,
|
||||||
FlaskConical,
|
Terminal,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export interface ProjectNavigationItem {
|
|||||||
export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
|
export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
|
||||||
{ id: 'identity', label: 'Identity', icon: User },
|
{ id: 'identity', label: 'Identity', icon: User },
|
||||||
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
{ 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: 'theme', label: 'Theme', icon: Palette },
|
||||||
{ id: 'claude', label: 'Models', icon: Workflow },
|
{ id: 'claude', label: 'Models', icon: Workflow },
|
||||||
{ id: 'data', label: 'Data', icon: Database },
|
{ id: 'data', label: 'Data', icon: Database },
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export type ProjectSettingsViewId =
|
|||||||
| 'identity'
|
| 'identity'
|
||||||
| 'theme'
|
| 'theme'
|
||||||
| 'worktrees'
|
| 'worktrees'
|
||||||
| 'testing'
|
| 'commands'
|
||||||
| 'claude'
|
| 'claude'
|
||||||
| 'data'
|
| 'data'
|
||||||
| 'danger';
|
| 'danger';
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ export { ProjectSettingsView } from './project-settings-view';
|
|||||||
export { ProjectIdentitySection } from './project-identity-section';
|
export { ProjectIdentitySection } from './project-identity-section';
|
||||||
export { ProjectThemeSection } from './project-theme-section';
|
export { ProjectThemeSection } from './project-theme-section';
|
||||||
export { WorktreePreferencesSection } from './worktree-preferences-section';
|
export { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||||
export { TestingSection } from './testing-section';
|
export { CommandsSection } from './commands-section';
|
||||||
export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks';
|
export { useProjectSettingsView, type ProjectSettingsViewId } from './hooks';
|
||||||
export { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
export { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { ProjectIdentitySection } from './project-identity-section';
|
import { ProjectIdentitySection } from './project-identity-section';
|
||||||
import { ProjectThemeSection } from './project-theme-section';
|
import { ProjectThemeSection } from './project-theme-section';
|
||||||
import { WorktreePreferencesSection } from './worktree-preferences-section';
|
import { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||||
import { TestingSection } from './testing-section';
|
import { CommandsSection } from './commands-section';
|
||||||
import { ProjectModelsSection } from './project-models-section';
|
import { ProjectModelsSection } from './project-models-section';
|
||||||
import { DataManagementSection } from './data-management-section';
|
import { DataManagementSection } from './data-management-section';
|
||||||
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
||||||
@@ -87,8 +87,8 @@ export function ProjectSettingsView() {
|
|||||||
return <ProjectThemeSection project={currentProject} />;
|
return <ProjectThemeSection project={currentProject} />;
|
||||||
case 'worktrees':
|
case 'worktrees':
|
||||||
return <WorktreePreferencesSection project={currentProject} />;
|
return <WorktreePreferencesSection project={currentProject} />;
|
||||||
case 'testing':
|
case 'commands':
|
||||||
return <TestingSection project={currentProject} />;
|
return <CommandsSection project={currentProject} />;
|
||||||
case 'claude':
|
case 'claude':
|
||||||
return <ProjectModelsSection project={currentProject} />;
|
return <ProjectModelsSection project={currentProject} />;
|
||||||
case 'data':
|
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,
|
CursorSettingsTab,
|
||||||
CodexSettingsTab,
|
CodexSettingsTab,
|
||||||
OpencodeSettingsTab,
|
OpencodeSettingsTab,
|
||||||
|
GeminiSettingsTab,
|
||||||
} from './settings-view/providers';
|
} from './settings-view/providers';
|
||||||
import { MCPServersSection } from './settings-view/mcp-servers';
|
import { MCPServersSection } from './settings-view/mcp-servers';
|
||||||
import { PromptCustomizationSection } from './settings-view/prompts';
|
import { PromptCustomizationSection } from './settings-view/prompts';
|
||||||
@@ -123,6 +124,8 @@ export function SettingsView() {
|
|||||||
return <CodexSettingsTab />;
|
return <CodexSettingsTab />;
|
||||||
case 'opencode-provider':
|
case 'opencode-provider':
|
||||||
return <OpencodeSettingsTab />;
|
return <OpencodeSettingsTab />;
|
||||||
|
case 'gemini-provider':
|
||||||
|
return <GeminiSettingsTab />;
|
||||||
case 'providers':
|
case 'providers':
|
||||||
case 'claude': // Backwards compatibility - redirect to claude-provider
|
case 'claude': // Backwards compatibility - redirect to claude-provider
|
||||||
return <ClaudeSettingsTab />;
|
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',
|
'cursor-provider': 'cursor',
|
||||||
'codex-provider': 'codex',
|
'codex-provider': 'codex',
|
||||||
'opencode-provider': 'opencode',
|
'opencode-provider': 'opencode',
|
||||||
|
'gemini-provider': 'gemini',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SettingsNavigationProps {
|
interface SettingsNavigationProps {
|
||||||
|
|||||||
@@ -17,7 +17,13 @@ import {
|
|||||||
Code2,
|
Code2,
|
||||||
Webhook,
|
Webhook,
|
||||||
} from 'lucide-react';
|
} 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';
|
import type { SettingsViewId } from '../hooks/use-settings-view';
|
||||||
|
|
||||||
export interface NavigationItem {
|
export interface NavigationItem {
|
||||||
@@ -51,6 +57,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
|
|||||||
{ id: 'cursor-provider', label: 'Cursor', icon: CursorIcon },
|
{ id: 'cursor-provider', label: 'Cursor', icon: CursorIcon },
|
||||||
{ id: 'codex-provider', label: 'Codex', icon: OpenAIIcon },
|
{ id: 'codex-provider', label: 'Codex', icon: OpenAIIcon },
|
||||||
{ id: 'opencode-provider', label: 'OpenCode', icon: OpenCodeIcon },
|
{ id: 'opencode-provider', label: 'OpenCode', icon: OpenCodeIcon },
|
||||||
|
{ id: 'gemini-provider', label: 'Gemini', icon: GeminiIcon },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ id: 'mcp-servers', label: 'MCP Servers', icon: Plug },
|
{ id: 'mcp-servers', label: 'MCP Servers', icon: Plug },
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type SettingsViewId =
|
|||||||
| 'cursor-provider'
|
| 'cursor-provider'
|
||||||
| 'codex-provider'
|
| 'codex-provider'
|
||||||
| 'opencode-provider'
|
| 'opencode-provider'
|
||||||
|
| 'gemini-provider'
|
||||||
| 'mcp-servers'
|
| 'mcp-servers'
|
||||||
| 'prompts'
|
| 'prompts'
|
||||||
| 'model-defaults'
|
| 'model-defaults'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
CursorModelId,
|
CursorModelId,
|
||||||
CodexModelId,
|
CodexModelId,
|
||||||
OpencodeModelId,
|
OpencodeModelId,
|
||||||
|
GeminiModelId,
|
||||||
GroupedModel,
|
GroupedModel,
|
||||||
PhaseModelEntry,
|
PhaseModelEntry,
|
||||||
ClaudeCompatibleProvider,
|
ClaudeCompatibleProvider,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
CLAUDE_MODELS,
|
CLAUDE_MODELS,
|
||||||
CURSOR_MODELS,
|
CURSOR_MODELS,
|
||||||
OPENCODE_MODELS,
|
OPENCODE_MODELS,
|
||||||
|
GEMINI_MODELS,
|
||||||
THINKING_LEVELS,
|
THINKING_LEVELS,
|
||||||
THINKING_LEVEL_LABELS,
|
THINKING_LEVEL_LABELS,
|
||||||
REASONING_EFFORT_LEVELS,
|
REASONING_EFFORT_LEVELS,
|
||||||
@@ -39,6 +41,7 @@ import {
|
|||||||
OpenRouterIcon,
|
OpenRouterIcon,
|
||||||
GlmIcon,
|
GlmIcon,
|
||||||
MiniMaxIcon,
|
MiniMaxIcon,
|
||||||
|
GeminiIcon,
|
||||||
getProviderIconForModel,
|
getProviderIconForModel,
|
||||||
} from '@/components/ui/provider-icon';
|
} from '@/components/ui/provider-icon';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -168,6 +171,7 @@ export function PhaseModelSelector({
|
|||||||
const expandedProviderTriggerRef = useRef<HTMLDivElement>(null);
|
const expandedProviderTriggerRef = useRef<HTMLDivElement>(null);
|
||||||
const {
|
const {
|
||||||
enabledCursorModels,
|
enabledCursorModels,
|
||||||
|
enabledGeminiModels,
|
||||||
favoriteModels,
|
favoriteModels,
|
||||||
toggleFavoriteModel,
|
toggleFavoriteModel,
|
||||||
codexModels,
|
codexModels,
|
||||||
@@ -322,6 +326,11 @@ export function PhaseModelSelector({
|
|||||||
return enabledCursorModels.includes(model.id as CursorModelId);
|
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
|
// Helper to find current selected model details
|
||||||
const currentModel = useMemo(() => {
|
const currentModel = useMemo(() => {
|
||||||
const claudeModel = CLAUDE_MODELS.find((m) => m.id === selectedModel);
|
const claudeModel = CLAUDE_MODELS.find((m) => m.id === selectedModel);
|
||||||
@@ -359,6 +368,16 @@ export function PhaseModelSelector({
|
|||||||
const codexModel = transformedCodexModels.find((m) => m.id === selectedModel);
|
const codexModel = transformedCodexModels.find((m) => m.id === selectedModel);
|
||||||
if (codexModel) return { ...codexModel, icon: OpenAIIcon };
|
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
|
// Check OpenCode models (static) - use dynamic icon resolution for provider-specific icons
|
||||||
const opencodeModel = OPENCODE_MODELS.find((m) => m.id === selectedModel);
|
const opencodeModel = OPENCODE_MODELS.find((m) => m.id === selectedModel);
|
||||||
if (opencodeModel) return { ...opencodeModel, icon: getProviderIconForModel(opencodeModel.id) };
|
if (opencodeModel) return { ...opencodeModel, icon: getProviderIconForModel(opencodeModel.id) };
|
||||||
@@ -459,6 +478,7 @@ export function PhaseModelSelector({
|
|||||||
selectedProviderId,
|
selectedProviderId,
|
||||||
selectedThinkingLevel,
|
selectedThinkingLevel,
|
||||||
availableCursorModels,
|
availableCursorModels,
|
||||||
|
availableGeminiModels,
|
||||||
transformedCodexModels,
|
transformedCodexModels,
|
||||||
dynamicOpencodeModels,
|
dynamicOpencodeModels,
|
||||||
enabledProviders,
|
enabledProviders,
|
||||||
@@ -524,17 +544,20 @@ export function PhaseModelSelector({
|
|||||||
|
|
||||||
// Check if providers are disabled (needed for rendering conditions)
|
// Check if providers are disabled (needed for rendering conditions)
|
||||||
const isCursorDisabled = disabledProviders.includes('cursor');
|
const isCursorDisabled = disabledProviders.includes('cursor');
|
||||||
|
const isGeminiDisabled = disabledProviders.includes('gemini');
|
||||||
|
|
||||||
// Group models (filtering out disabled providers)
|
// 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 favs: typeof CLAUDE_MODELS = [];
|
||||||
const cModels: typeof CLAUDE_MODELS = [];
|
const cModels: typeof CLAUDE_MODELS = [];
|
||||||
const curModels: typeof CURSOR_MODELS = [];
|
const curModels: typeof CURSOR_MODELS = [];
|
||||||
const codModels: typeof transformedCodexModels = [];
|
const codModels: typeof transformedCodexModels = [];
|
||||||
|
const gemModels: typeof GEMINI_MODELS = [];
|
||||||
const ocModels: ModelOption[] = [];
|
const ocModels: ModelOption[] = [];
|
||||||
|
|
||||||
const isClaudeDisabled = disabledProviders.includes('claude');
|
const isClaudeDisabled = disabledProviders.includes('claude');
|
||||||
const isCodexDisabled = disabledProviders.includes('codex');
|
const isCodexDisabled = disabledProviders.includes('codex');
|
||||||
|
const isGeminiDisabledInner = disabledProviders.includes('gemini');
|
||||||
const isOpencodeDisabled = disabledProviders.includes('opencode');
|
const isOpencodeDisabled = disabledProviders.includes('opencode');
|
||||||
|
|
||||||
// Process Claude Models (skip if provider is disabled)
|
// 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)
|
// Process OpenCode Models (skip if provider is disabled)
|
||||||
if (!isOpencodeDisabled) {
|
if (!isOpencodeDisabled) {
|
||||||
allOpencodeModels.forEach((model) => {
|
allOpencodeModels.forEach((model) => {
|
||||||
@@ -586,11 +620,13 @@ export function PhaseModelSelector({
|
|||||||
claude: cModels,
|
claude: cModels,
|
||||||
cursor: curModels,
|
cursor: curModels,
|
||||||
codex: codModels,
|
codex: codModels,
|
||||||
|
gemini: gemModels,
|
||||||
opencode: ocModels,
|
opencode: ocModels,
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
favoriteModels,
|
favoriteModels,
|
||||||
availableCursorModels,
|
availableCursorModels,
|
||||||
|
availableGeminiModels,
|
||||||
transformedCodexModels,
|
transformedCodexModels,
|
||||||
allOpencodeModels,
|
allOpencodeModels,
|
||||||
disabledProviders,
|
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
|
// Render ClaudeCompatibleProvider model item with thinking level support
|
||||||
const renderProviderModelItem = (
|
const renderProviderModelItem = (
|
||||||
provider: ClaudeCompatibleProvider,
|
provider: ClaudeCompatibleProvider,
|
||||||
@@ -1839,6 +1929,10 @@ export function PhaseModelSelector({
|
|||||||
if (model.provider === 'codex') {
|
if (model.provider === 'codex') {
|
||||||
return renderCodexModelItem(model as (typeof transformedCodexModels)[0]);
|
return renderCodexModelItem(model as (typeof transformedCodexModels)[0]);
|
||||||
}
|
}
|
||||||
|
// Gemini model
|
||||||
|
if (model.provider === 'gemini') {
|
||||||
|
return renderGeminiModelItem(model as (typeof GEMINI_MODELS)[0]);
|
||||||
|
}
|
||||||
// OpenCode model
|
// OpenCode model
|
||||||
if (model.provider === 'opencode') {
|
if (model.provider === 'opencode') {
|
||||||
return renderOpencodeModelItem(model);
|
return renderOpencodeModelItem(model);
|
||||||
@@ -1917,6 +2011,12 @@ export function PhaseModelSelector({
|
|||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!isGeminiDisabled && gemini.length > 0 && (
|
||||||
|
<CommandGroup heading="Gemini Models">
|
||||||
|
{gemini.map((model) => renderGeminiModelItem(model))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
{opencodeSections.length > 0 && (
|
{opencodeSections.length > 0 && (
|
||||||
<CommandGroup heading={OPENCODE_CLI_GROUP_LABEL}>
|
<CommandGroup heading={OPENCODE_CLI_GROUP_LABEL}>
|
||||||
{opencodeSections.map((section, sectionIndex) => (
|
{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 { CursorSettingsTab } from './cursor-settings-tab';
|
||||||
export { CodexSettingsTab } from './codex-settings-tab';
|
export { CodexSettingsTab } from './codex-settings-tab';
|
||||||
export { OpencodeSettingsTab } from './opencode-settings-tab';
|
export { OpencodeSettingsTab } from './opencode-settings-tab';
|
||||||
|
export { GeminiSettingsTab } from './gemini-settings-tab';
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
import {
|
||||||
import { Cpu } from 'lucide-react';
|
AnthropicIcon,
|
||||||
|
CursorIcon,
|
||||||
|
OpenAIIcon,
|
||||||
|
GeminiIcon,
|
||||||
|
OpenCodeIcon,
|
||||||
|
} from '@/components/ui/provider-icon';
|
||||||
import { CursorSettingsTab } from './cursor-settings-tab';
|
import { CursorSettingsTab } from './cursor-settings-tab';
|
||||||
import { ClaudeSettingsTab } from './claude-settings-tab';
|
import { ClaudeSettingsTab } from './claude-settings-tab';
|
||||||
import { CodexSettingsTab } from './codex-settings-tab';
|
import { CodexSettingsTab } from './codex-settings-tab';
|
||||||
import { OpencodeSettingsTab } from './opencode-settings-tab';
|
import { OpencodeSettingsTab } from './opencode-settings-tab';
|
||||||
|
import { GeminiSettingsTab } from './gemini-settings-tab';
|
||||||
|
|
||||||
interface ProviderTabsProps {
|
interface ProviderTabsProps {
|
||||||
defaultTab?: 'claude' | 'cursor' | 'codex' | 'opencode';
|
defaultTab?: 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
|
export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue={defaultTab} className="w-full">
|
<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">
|
<TabsTrigger value="claude" className="flex items-center gap-2">
|
||||||
<AnthropicIcon className="w-4 h-4" />
|
<AnthropicIcon className="w-4 h-4" />
|
||||||
Claude
|
Claude
|
||||||
@@ -28,9 +34,13 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
|
|||||||
Codex
|
Codex
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="opencode" className="flex items-center gap-2">
|
<TabsTrigger value="opencode" className="flex items-center gap-2">
|
||||||
<Cpu className="w-4 h-4" />
|
<OpenCodeIcon className="w-4 h-4" />
|
||||||
OpenCode
|
OpenCode
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="gemini" className="flex items-center gap-2">
|
||||||
|
<GeminiIcon className="w-4 h-4" />
|
||||||
|
Gemini
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="claude">
|
<TabsContent value="claude">
|
||||||
@@ -48,6 +58,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
|
|||||||
<TabsContent value="opencode">
|
<TabsContent value="opencode">
|
||||||
<OpencodeSettingsTab />
|
<OpencodeSettingsTab />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="gemini">
|
||||||
|
<GeminiSettingsTab />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,13 @@ import {
|
|||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
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 { TerminalOutput } from '../components';
|
||||||
import { useCliInstallation, useTokenSave } from '../hooks';
|
import { useCliInstallation, useTokenSave } from '../hooks';
|
||||||
|
|
||||||
@@ -40,7 +46,7 @@ interface ProvidersSetupStepProps {
|
|||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProviderTab = 'claude' | 'cursor' | 'codex' | 'opencode';
|
type ProviderTab = 'claude' | 'cursor' | 'codex' | 'opencode' | 'gemini';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Claude Content
|
// 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
|
// Main Component
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1225,11 +1543,13 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
|
|||||||
codexCliStatus,
|
codexCliStatus,
|
||||||
codexAuthStatus,
|
codexAuthStatus,
|
||||||
opencodeCliStatus,
|
opencodeCliStatus,
|
||||||
|
geminiCliStatus,
|
||||||
setClaudeCliStatus,
|
setClaudeCliStatus,
|
||||||
setCursorCliStatus,
|
setCursorCliStatus,
|
||||||
setCodexCliStatus,
|
setCodexCliStatus,
|
||||||
setCodexAuthStatus,
|
setCodexAuthStatus,
|
||||||
setOpencodeCliStatus,
|
setOpencodeCliStatus,
|
||||||
|
setGeminiCliStatus,
|
||||||
} = useSetupStore();
|
} = useSetupStore();
|
||||||
|
|
||||||
// Check all providers on mount
|
// 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
|
// Run all checks in parallel
|
||||||
await Promise.all([checkClaude(), checkCursor(), checkCodex(), checkOpencode()]);
|
await Promise.all([checkClaude(), checkCursor(), checkCodex(), checkOpencode(), checkGemini()]);
|
||||||
setIsInitialChecking(false);
|
setIsInitialChecking(false);
|
||||||
}, [
|
}, [
|
||||||
setClaudeCliStatus,
|
setClaudeCliStatus,
|
||||||
@@ -1328,6 +1668,7 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
|
|||||||
setCodexCliStatus,
|
setCodexCliStatus,
|
||||||
setCodexAuthStatus,
|
setCodexAuthStatus,
|
||||||
setOpencodeCliStatus,
|
setOpencodeCliStatus,
|
||||||
|
setGeminiCliStatus,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1354,11 +1695,15 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
|
|||||||
const isOpencodeInstalled = opencodeCliStatus?.installed === true;
|
const isOpencodeInstalled = opencodeCliStatus?.installed === true;
|
||||||
const isOpencodeAuthenticated = opencodeCliStatus?.auth?.authenticated === true;
|
const isOpencodeAuthenticated = opencodeCliStatus?.auth?.authenticated === true;
|
||||||
|
|
||||||
|
const isGeminiInstalled = geminiCliStatus?.installed === true;
|
||||||
|
const isGeminiAuthenticated = geminiCliStatus?.auth?.authenticated === true;
|
||||||
|
|
||||||
const hasAtLeastOneProvider =
|
const hasAtLeastOneProvider =
|
||||||
isClaudeAuthenticated ||
|
isClaudeAuthenticated ||
|
||||||
isCursorAuthenticated ||
|
isCursorAuthenticated ||
|
||||||
isCodexAuthenticated ||
|
isCodexAuthenticated ||
|
||||||
isOpencodeAuthenticated;
|
isOpencodeAuthenticated ||
|
||||||
|
isGeminiAuthenticated;
|
||||||
|
|
||||||
type ProviderStatus = 'not_installed' | 'installed_not_auth' | 'authenticated' | 'verifying';
|
type ProviderStatus = 'not_installed' | 'installed_not_auth' | 'authenticated' | 'verifying';
|
||||||
|
|
||||||
@@ -1402,6 +1747,13 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
|
|||||||
status: getProviderStatus(isOpencodeInstalled, isOpencodeAuthenticated),
|
status: getProviderStatus(isOpencodeInstalled, isOpencodeAuthenticated),
|
||||||
color: 'text-green-500',
|
color: 'text-green-500',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'gemini' as const,
|
||||||
|
label: 'Gemini',
|
||||||
|
icon: GeminiIcon,
|
||||||
|
status: getProviderStatus(isGeminiInstalled, isGeminiAuthenticated),
|
||||||
|
color: 'text-blue-500',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const renderStatusIcon = (status: ProviderStatus) => {
|
const renderStatusIcon = (status: ProviderStatus) => {
|
||||||
@@ -1438,7 +1790,7 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as ProviderTab)}>
|
<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) => {
|
{providers.map((provider) => {
|
||||||
const Icon = provider.icon;
|
const Icon = provider.icon;
|
||||||
return (
|
return (
|
||||||
@@ -1484,6 +1836,9 @@ export function ProvidersSetupStep({ onNext, onBack }: ProvidersSetupStepProps)
|
|||||||
<TabsContent value="opencode" className="mt-0">
|
<TabsContent value="opencode" className="mt-0">
|
||||||
<OpencodeContent />
|
<OpencodeContent />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="gemini" className="mt-0">
|
||||||
|
<GeminiContent />
|
||||||
|
</TabsContent>
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export {
|
|||||||
useCursorCliStatus,
|
useCursorCliStatus,
|
||||||
useCodexCliStatus,
|
useCodexCliStatus,
|
||||||
useOpencodeCliStatus,
|
useOpencodeCliStatus,
|
||||||
|
useGeminiCliStatus,
|
||||||
useGitHubCliStatus,
|
useGitHubCliStatus,
|
||||||
useApiKeysStatus,
|
useApiKeysStatus,
|
||||||
usePlatformInfo,
|
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
|
* Fetch GitHub CLI status
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -21,15 +21,20 @@ import { useAuthStore } from '@/store/auth-store';
|
|||||||
import { waitForMigrationComplete, resetMigrationState } from './use-settings-migration';
|
import { waitForMigrationComplete, resetMigrationState } from './use-settings-migration';
|
||||||
import {
|
import {
|
||||||
DEFAULT_OPENCODE_MODEL,
|
DEFAULT_OPENCODE_MODEL,
|
||||||
|
DEFAULT_GEMINI_MODEL,
|
||||||
DEFAULT_MAX_CONCURRENCY,
|
DEFAULT_MAX_CONCURRENCY,
|
||||||
getAllOpencodeModelIds,
|
getAllOpencodeModelIds,
|
||||||
getAllCursorModelIds,
|
getAllCursorModelIds,
|
||||||
|
getAllCodexModelIds,
|
||||||
|
getAllGeminiModelIds,
|
||||||
migrateCursorModelIds,
|
migrateCursorModelIds,
|
||||||
migrateOpencodeModelIds,
|
migrateOpencodeModelIds,
|
||||||
migratePhaseModelEntry,
|
migratePhaseModelEntry,
|
||||||
type GlobalSettings,
|
type GlobalSettings,
|
||||||
type CursorModelId,
|
type CursorModelId,
|
||||||
type OpencodeModelId,
|
type OpencodeModelId,
|
||||||
|
type CodexModelId,
|
||||||
|
type GeminiModelId,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
|
|
||||||
const logger = createLogger('SettingsSync');
|
const logger = createLogger('SettingsSync');
|
||||||
@@ -66,6 +71,10 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
|||||||
'cursorDefaultModel',
|
'cursorDefaultModel',
|
||||||
'enabledOpencodeModels',
|
'enabledOpencodeModels',
|
||||||
'opencodeDefaultModel',
|
'opencodeDefaultModel',
|
||||||
|
'enabledCodexModels',
|
||||||
|
'codexDefaultModel',
|
||||||
|
'enabledGeminiModels',
|
||||||
|
'geminiDefaultModel',
|
||||||
'enabledDynamicModelIds',
|
'enabledDynamicModelIds',
|
||||||
'disabledProviders',
|
'disabledProviders',
|
||||||
'autoLoadClaudeMd',
|
'autoLoadClaudeMd',
|
||||||
@@ -567,6 +576,37 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
|||||||
sanitizedEnabledOpencodeModels.push(sanitizedOpencodeDefaultModel);
|
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 =
|
const persistedDynamicModelIds =
|
||||||
serverSettings.enabledDynamicModelIds ?? currentAppState.enabledDynamicModelIds;
|
serverSettings.enabledDynamicModelIds ?? currentAppState.enabledDynamicModelIds;
|
||||||
const sanitizedDynamicModelIds = persistedDynamicModelIds.filter(
|
const sanitizedDynamicModelIds = persistedDynamicModelIds.filter(
|
||||||
@@ -659,6 +699,10 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
|||||||
cursorDefaultModel: sanitizedCursorDefault,
|
cursorDefaultModel: sanitizedCursorDefault,
|
||||||
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
|
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
|
||||||
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
|
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
|
||||||
|
enabledCodexModels: sanitizedEnabledCodexModels,
|
||||||
|
codexDefaultModel: sanitizedCodexDefaultModel,
|
||||||
|
enabledGeminiModels: sanitizedEnabledGeminiModels,
|
||||||
|
geminiDefaultModel: sanitizedGeminiDefaultModel,
|
||||||
enabledDynamicModelIds: sanitizedDynamicModelIds,
|
enabledDynamicModelIds: sanitizedDynamicModelIds,
|
||||||
disabledProviders: serverSettings.disabledProviders ?? [],
|
disabledProviders: serverSettings.disabledProviders ?? [],
|
||||||
autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? false,
|
autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? false,
|
||||||
|
|||||||
@@ -1655,6 +1655,48 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}> => this.post('/api/setup/opencode/cache/clear'),
|
}> => 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) => {
|
onInstallProgress: (callback: (progress: unknown) => void) => {
|
||||||
return this.subscribeToEvent('agent:stream', callback);
|
return this.subscribeToEvent('agent:stream', callback);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -176,6 +176,8 @@ export const queryKeys = {
|
|||||||
codex: () => ['cli', 'codex'] as const,
|
codex: () => ['cli', 'codex'] as const,
|
||||||
/** OpenCode CLI status */
|
/** OpenCode CLI status */
|
||||||
opencode: () => ['cli', 'opencode'] as const,
|
opencode: () => ['cli', 'opencode'] as const,
|
||||||
|
/** Gemini CLI status */
|
||||||
|
gemini: () => ['cli', 'gemini'] as const,
|
||||||
/** GitHub CLI status */
|
/** GitHub CLI status */
|
||||||
github: () => ['cli', 'github'] as const,
|
github: () => ['cli', 'github'] as const,
|
||||||
/** API keys status */
|
/** API keys status */
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
CursorModelId,
|
CursorModelId,
|
||||||
CodexModelId,
|
CodexModelId,
|
||||||
OpencodeModelId,
|
OpencodeModelId,
|
||||||
|
GeminiModelId,
|
||||||
PhaseModelConfig,
|
PhaseModelConfig,
|
||||||
PhaseModelKey,
|
PhaseModelKey,
|
||||||
PhaseModelEntry,
|
PhaseModelEntry,
|
||||||
@@ -39,8 +40,10 @@ import {
|
|||||||
getAllCursorModelIds,
|
getAllCursorModelIds,
|
||||||
getAllCodexModelIds,
|
getAllCodexModelIds,
|
||||||
getAllOpencodeModelIds,
|
getAllOpencodeModelIds,
|
||||||
|
getAllGeminiModelIds,
|
||||||
DEFAULT_PHASE_MODELS,
|
DEFAULT_PHASE_MODELS,
|
||||||
DEFAULT_OPENCODE_MODEL,
|
DEFAULT_OPENCODE_MODEL,
|
||||||
|
DEFAULT_GEMINI_MODEL,
|
||||||
DEFAULT_MAX_CONCURRENCY,
|
DEFAULT_MAX_CONCURRENCY,
|
||||||
DEFAULT_GLOBAL_SETTINGS,
|
DEFAULT_GLOBAL_SETTINGS,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
@@ -729,6 +732,10 @@ export interface AppState {
|
|||||||
opencodeModelsLastFetched: number | null; // Timestamp of last successful fetch
|
opencodeModelsLastFetched: number | null; // Timestamp of last successful fetch
|
||||||
opencodeModelsLastFailedAt: number | null; // Timestamp of last failed 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
|
// Provider Visibility Settings
|
||||||
disabledProviders: ModelProvider[]; // Providers that are disabled and hidden from dropdowns
|
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 }>
|
providers: Array<{ id: string; name: string; authenticated: boolean; authMethod?: string }>
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
|
// Gemini CLI Settings actions
|
||||||
|
setEnabledGeminiModels: (models: GeminiModelId[]) => void;
|
||||||
|
setGeminiDefaultModel: (model: GeminiModelId) => void;
|
||||||
|
toggleGeminiModel: (model: GeminiModelId, enabled: boolean) => void;
|
||||||
|
|
||||||
// Provider Visibility Settings actions
|
// Provider Visibility Settings actions
|
||||||
setDisabledProviders: (providers: ModelProvider[]) => void;
|
setDisabledProviders: (providers: ModelProvider[]) => void;
|
||||||
toggleProviderDisabled: (provider: ModelProvider, disabled: boolean) => void;
|
toggleProviderDisabled: (provider: ModelProvider, disabled: boolean) => void;
|
||||||
@@ -1503,6 +1515,8 @@ const initialState: AppState = {
|
|||||||
opencodeModelsError: null,
|
opencodeModelsError: null,
|
||||||
opencodeModelsLastFetched: null,
|
opencodeModelsLastFetched: null,
|
||||||
opencodeModelsLastFailedAt: 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
|
disabledProviders: [], // No providers disabled by default
|
||||||
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
|
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
|
||||||
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
|
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
|
// Provider Visibility Settings actions
|
||||||
setDisabledProviders: (providers) => set({ disabledProviders: providers }),
|
setDisabledProviders: (providers) => set({ disabledProviders: providers }),
|
||||||
toggleProviderDisabled: (provider, disabled) =>
|
toggleProviderDisabled: (provider, disabled) =>
|
||||||
|
|||||||
@@ -63,6 +63,22 @@ export interface OpencodeCliStatus {
|
|||||||
error?: string;
|
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
|
// Codex Auth Method
|
||||||
export type CodexAuthMethod =
|
export type CodexAuthMethod =
|
||||||
| 'api_key_env' // OPENAI_API_KEY environment variable
|
| 'api_key_env' // OPENAI_API_KEY environment variable
|
||||||
@@ -120,6 +136,7 @@ export type SetupStep =
|
|||||||
| 'cursor'
|
| 'cursor'
|
||||||
| 'codex'
|
| 'codex'
|
||||||
| 'opencode'
|
| 'opencode'
|
||||||
|
| 'gemini'
|
||||||
| 'github'
|
| 'github'
|
||||||
| 'complete';
|
| 'complete';
|
||||||
|
|
||||||
@@ -149,6 +166,9 @@ export interface SetupState {
|
|||||||
// OpenCode CLI state
|
// OpenCode CLI state
|
||||||
opencodeCliStatus: OpencodeCliStatus | null;
|
opencodeCliStatus: OpencodeCliStatus | null;
|
||||||
|
|
||||||
|
// Gemini CLI state
|
||||||
|
geminiCliStatus: GeminiCliStatus | null;
|
||||||
|
|
||||||
// Setup preferences
|
// Setup preferences
|
||||||
skipClaudeSetup: boolean;
|
skipClaudeSetup: boolean;
|
||||||
}
|
}
|
||||||
@@ -183,6 +203,9 @@ export interface SetupActions {
|
|||||||
// OpenCode CLI
|
// OpenCode CLI
|
||||||
setOpencodeCliStatus: (status: OpencodeCliStatus | null) => void;
|
setOpencodeCliStatus: (status: OpencodeCliStatus | null) => void;
|
||||||
|
|
||||||
|
// Gemini CLI
|
||||||
|
setGeminiCliStatus: (status: GeminiCliStatus | null) => void;
|
||||||
|
|
||||||
// Preferences
|
// Preferences
|
||||||
setSkipClaudeSetup: (skip: boolean) => void;
|
setSkipClaudeSetup: (skip: boolean) => void;
|
||||||
}
|
}
|
||||||
@@ -216,6 +239,8 @@ const initialState: SetupState = {
|
|||||||
|
|
||||||
opencodeCliStatus: null,
|
opencodeCliStatus: null,
|
||||||
|
|
||||||
|
geminiCliStatus: null,
|
||||||
|
|
||||||
skipClaudeSetup: shouldSkipSetup,
|
skipClaudeSetup: shouldSkipSetup,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -288,6 +313,9 @@ export const useSetupStore = create<SetupState & SetupActions>()((set, get) => (
|
|||||||
// OpenCode CLI
|
// OpenCode CLI
|
||||||
setOpencodeCliStatus: (status) => set({ opencodeCliStatus: status }),
|
setOpencodeCliStatus: (status) => set({ opencodeCliStatus: status }),
|
||||||
|
|
||||||
|
// Gemini CLI
|
||||||
|
setGeminiCliStatus: (status) => set({ geminiCliStatus: status }),
|
||||||
|
|
||||||
// Preferences
|
// Preferences
|
||||||
setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }),
|
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 type { ModelOption, ThinkingLevelOption, ReasoningEffortOption } from './model-display.js';
|
||||||
export {
|
export {
|
||||||
CLAUDE_MODELS,
|
CLAUDE_MODELS,
|
||||||
|
GEMINI_MODELS,
|
||||||
THINKING_LEVELS,
|
THINKING_LEVELS,
|
||||||
THINKING_LEVEL_LABELS,
|
THINKING_LEVEL_LABELS,
|
||||||
REASONING_EFFORT_LEVELS,
|
REASONING_EFFORT_LEVELS,
|
||||||
@@ -249,6 +250,9 @@ export * from './cursor-cli.js';
|
|||||||
// OpenCode types
|
// OpenCode types
|
||||||
export * from './opencode-models.js';
|
export * from './opencode-models.js';
|
||||||
|
|
||||||
|
// Gemini types
|
||||||
|
export * from './gemini-models.js';
|
||||||
|
|
||||||
// Provider utilities
|
// Provider utilities
|
||||||
export {
|
export {
|
||||||
PROVIDER_PREFIXES,
|
PROVIDER_PREFIXES,
|
||||||
@@ -256,6 +260,7 @@ export {
|
|||||||
isClaudeModel,
|
isClaudeModel,
|
||||||
isCodexModel,
|
isCodexModel,
|
||||||
isOpencodeModel,
|
isOpencodeModel,
|
||||||
|
isGeminiModel,
|
||||||
getModelProvider,
|
getModelProvider,
|
||||||
stripProviderPrefix,
|
stripProviderPrefix,
|
||||||
addProviderPrefix,
|
addProviderPrefix,
|
||||||
|
|||||||
@@ -10,20 +10,21 @@ import type { ReasoningEffort } from './provider.js';
|
|||||||
import type { CursorModelId } from './cursor-models.js';
|
import type { CursorModelId } from './cursor-models.js';
|
||||||
import type { AgentModel, CodexModelId } from './model.js';
|
import type { AgentModel, CodexModelId } from './model.js';
|
||||||
import { CODEX_MODEL_MAP } 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
|
* ModelOption - Display metadata for a model option in the UI
|
||||||
*/
|
*/
|
||||||
export interface ModelOption {
|
export interface ModelOption {
|
||||||
/** Model identifier (supports both Claude and Cursor models) */
|
/** Model identifier (supports Claude, Cursor, Gemini models) */
|
||||||
id: ModelAlias | CursorModelId;
|
id: ModelAlias | CursorModelId | GeminiModelId;
|
||||||
/** Display name shown to user */
|
/** Display name shown to user */
|
||||||
label: string;
|
label: string;
|
||||||
/** Descriptive text explaining model capabilities */
|
/** Descriptive text explaining model capabilities */
|
||||||
description: string;
|
description: string;
|
||||||
/** Optional badge text (e.g., "Speed", "Balanced", "Premium") */
|
/** Optional badge text (e.g., "Speed", "Balanced", "Premium") */
|
||||||
badge?: string;
|
badge?: string;
|
||||||
/** AI provider (supports 'claude' and 'cursor') */
|
/** AI provider */
|
||||||
provider: ModelProvider;
|
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
|
* 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.gpt52]: 'GPT-5.2',
|
||||||
[CODEX_MODEL_MAP.gpt51]: 'GPT-5.1',
|
[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 { CursorModelId } from './cursor-models.js';
|
||||||
import type { OpencodeModelId } from './opencode-models.js';
|
import type { OpencodeModelId } from './opencode-models.js';
|
||||||
|
import type { GeminiModelId } from './gemini-models.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Canonical Claude model IDs with provider prefix
|
* Canonical Claude model IDs with provider prefix
|
||||||
@@ -119,6 +120,7 @@ export type DynamicModelId = `${string}/${string}`;
|
|||||||
*/
|
*/
|
||||||
export type PrefixedCursorModelId = `cursor-${string}`;
|
export type PrefixedCursorModelId = `cursor-${string}`;
|
||||||
export type PrefixedOpencodeModelId = `opencode-${string}`;
|
export type PrefixedOpencodeModelId = `opencode-${string}`;
|
||||||
|
export type PrefixedGeminiModelId = `gemini-${string}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ModelId - Unified model identifier across providers
|
* ModelId - Unified model identifier across providers
|
||||||
@@ -127,7 +129,9 @@ export type ModelId =
|
|||||||
| ModelAlias
|
| ModelAlias
|
||||||
| CodexModelId
|
| CodexModelId
|
||||||
| CursorModelId
|
| CursorModelId
|
||||||
|
| GeminiModelId
|
||||||
| OpencodeModelId
|
| OpencodeModelId
|
||||||
| DynamicModelId
|
| DynamicModelId
|
||||||
| PrefixedCursorModelId
|
| 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 { CURSOR_MODEL_MAP, LEGACY_CURSOR_MODEL_MAP } from './cursor-models.js';
|
||||||
import { CLAUDE_MODEL_MAP, CODEX_MODEL_MAP } from './model.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 { OPENCODE_MODEL_CONFIG_MAP, LEGACY_OPENCODE_MODEL_MAP } from './opencode-models.js';
|
||||||
|
import { GEMINI_MODEL_MAP } from './gemini-models.js';
|
||||||
|
|
||||||
/** Provider prefix constants */
|
/** Provider prefix constants */
|
||||||
export const PROVIDER_PREFIXES = {
|
export const PROVIDER_PREFIXES = {
|
||||||
cursor: 'cursor-',
|
cursor: 'cursor-',
|
||||||
codex: 'codex-',
|
codex: 'codex-',
|
||||||
opencode: 'opencode-',
|
opencode: 'opencode-',
|
||||||
|
gemini: 'gemini-',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,6 +92,28 @@ export function isCodexModel(model: string | undefined | null): boolean {
|
|||||||
return model in CODEX_MODEL_MAP;
|
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
|
* 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
|
* @returns The provider type, defaults to 'claude' for unknown models
|
||||||
*/
|
*/
|
||||||
export function getModelProvider(model: string | undefined | null): ModelProvider {
|
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)) {
|
if (isOpencodeModel(model)) {
|
||||||
return 'opencode';
|
return 'opencode';
|
||||||
}
|
}
|
||||||
@@ -199,6 +227,7 @@ export function stripProviderPrefix(model: string): string {
|
|||||||
* addProviderPrefix('cursor-composer-1', 'cursor') // 'cursor-composer-1' (no change)
|
* addProviderPrefix('cursor-composer-1', 'cursor') // 'cursor-composer-1' (no change)
|
||||||
* addProviderPrefix('gpt-5.2', 'codex') // 'codex-gpt-5.2'
|
* addProviderPrefix('gpt-5.2', 'codex') // 'codex-gpt-5.2'
|
||||||
* addProviderPrefix('sonnet', 'claude') // 'sonnet' (Claude doesn't use prefix)
|
* 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 {
|
export function addProviderPrefix(model: string, provider: ModelProvider): string {
|
||||||
if (!model || typeof model !== 'string') return model;
|
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)) {
|
if (!model.startsWith(PROVIDER_PREFIXES.opencode)) {
|
||||||
return `${PROVIDER_PREFIXES.opencode}${model}`;
|
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
|
// Claude models don't use prefixes
|
||||||
return model;
|
return model;
|
||||||
@@ -250,6 +283,7 @@ export function normalizeModelString(model: string | undefined | null): string {
|
|||||||
model.startsWith(PROVIDER_PREFIXES.cursor) ||
|
model.startsWith(PROVIDER_PREFIXES.cursor) ||
|
||||||
model.startsWith(PROVIDER_PREFIXES.codex) ||
|
model.startsWith(PROVIDER_PREFIXES.codex) ||
|
||||||
model.startsWith(PROVIDER_PREFIXES.opencode) ||
|
model.startsWith(PROVIDER_PREFIXES.opencode) ||
|
||||||
|
model.startsWith(PROVIDER_PREFIXES.gemini) ||
|
||||||
model.startsWith('claude-')
|
model.startsWith('claude-')
|
||||||
) {
|
) {
|
||||||
return model;
|
return model;
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export function getThinkingTokenBudget(level: ThinkingLevel | undefined): number
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** ModelProvider - AI model provider for credentials and API key management */
|
/** 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
|
// Claude-Compatible Providers - Configuration for Claude-compatible API endpoints
|
||||||
@@ -1190,6 +1190,14 @@ export interface ProjectSettings {
|
|||||||
*/
|
*/
|
||||||
testCommand?: string;
|
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)
|
// Phase Model Overrides (per-project)
|
||||||
/**
|
/**
|
||||||
* Override phase model settings for this 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-markdown": "10.1.0",
|
||||||
"react-resizable-panels": "3.0.6",
|
"react-resizable-panels": "3.0.6",
|
||||||
"rehype-raw": "7.0.0",
|
"rehype-raw": "7.0.0",
|
||||||
|
"rehype-sanitize": "^6.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "2.0.7",
|
"sonner": "2.0.7",
|
||||||
"tailwind-merge": "3.4.0",
|
"tailwind-merge": "3.4.0",
|
||||||
"usehooks-ts": "3.1.1",
|
"usehooks-ts": "3.1.1",
|
||||||
@@ -12130,6 +12132,16 @@
|
|||||||
"node": "^18.17.0 || >=20.5.0"
|
"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": {
|
"node_modules/matcher": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
|
||||||
@@ -12153,6 +12165,34 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/mdast-util-from-markdown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz",
|
"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"
|
"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": {
|
"node_modules/mdast-util-mdx-expression": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
|
"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"
|
"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": {
|
"node_modules/micromark-factory-destination": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
|
||||||
@@ -14184,6 +14446,24 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/remark-parse": {
|
||||||
"version": "11.0.0",
|
"version": "11.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
|
||||||
@@ -14217,6 +14497,21 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user