mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
Merge pull request #390 from AutoMaker-Org/opencode-support
Opencode support
This commit is contained in:
@@ -25,5 +25,8 @@ export { ClaudeProvider } from './claude-provider.js';
|
|||||||
export { CursorProvider, CursorErrorCode, CursorError } from './cursor-provider.js';
|
export { CursorProvider, CursorErrorCode, CursorError } from './cursor-provider.js';
|
||||||
export { CursorConfigManager } from './cursor-config-manager.js';
|
export { CursorConfigManager } from './cursor-config-manager.js';
|
||||||
|
|
||||||
|
// OpenCode provider
|
||||||
|
export { OpencodeProvider } from './opencode-provider.js';
|
||||||
|
|
||||||
// Provider factory
|
// Provider factory
|
||||||
export { ProviderFactory } from './provider-factory.js';
|
export { ProviderFactory } from './provider-factory.js';
|
||||||
|
|||||||
666
apps/server/src/providers/opencode-provider.ts
Normal file
666
apps/server/src/providers/opencode-provider.ts
Normal file
@@ -0,0 +1,666 @@
|
|||||||
|
/**
|
||||||
|
* OpenCode Provider - Executes queries using opencode CLI
|
||||||
|
*
|
||||||
|
* Extends CliProvider with OpenCode-specific configuration:
|
||||||
|
* - Event normalization for OpenCode's stream-json format
|
||||||
|
* - Model definitions for anthropic, openai, and google models
|
||||||
|
* - NPX-based Windows execution strategy
|
||||||
|
* - Platform-specific npm global installation paths
|
||||||
|
*
|
||||||
|
* Spawns the opencode CLI with --output-format stream-json for streaming responses.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
import { CliProvider, type CliSpawnConfig } from './cli-provider.js';
|
||||||
|
import type {
|
||||||
|
ProviderConfig,
|
||||||
|
ExecuteOptions,
|
||||||
|
ProviderMessage,
|
||||||
|
ModelDefinition,
|
||||||
|
InstallationStatus,
|
||||||
|
ContentBlock,
|
||||||
|
} from '@automaker/types';
|
||||||
|
import { stripProviderPrefix } from '@automaker/types';
|
||||||
|
import { type SubprocessOptions, getOpenCodeAuthIndicators } from '@automaker/platform';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// OpenCode Auth Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface OpenCodeAuthStatus {
|
||||||
|
authenticated: boolean;
|
||||||
|
method: 'api_key' | 'oauth' | 'none';
|
||||||
|
hasOAuthToken?: boolean;
|
||||||
|
hasApiKey?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// OpenCode Stream Event Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base interface for all OpenCode stream events
|
||||||
|
*/
|
||||||
|
interface OpenCodeBaseEvent {
|
||||||
|
/** Event type identifier */
|
||||||
|
type: string;
|
||||||
|
/** Optional session identifier */
|
||||||
|
session_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text delta event - Incremental text output from the model
|
||||||
|
*/
|
||||||
|
export interface OpenCodeTextDeltaEvent extends OpenCodeBaseEvent {
|
||||||
|
type: 'text-delta';
|
||||||
|
/** The incremental text content */
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text end event - Signals completion of text generation
|
||||||
|
*/
|
||||||
|
export interface OpenCodeTextEndEvent extends OpenCodeBaseEvent {
|
||||||
|
type: 'text-end';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool call event - Request to execute a tool
|
||||||
|
*/
|
||||||
|
export interface OpenCodeToolCallEvent extends OpenCodeBaseEvent {
|
||||||
|
type: 'tool-call';
|
||||||
|
/** Unique identifier for this tool call */
|
||||||
|
call_id?: string;
|
||||||
|
/** Tool name to invoke */
|
||||||
|
name: string;
|
||||||
|
/** Arguments to pass to the tool */
|
||||||
|
args: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool result event - Output from a tool execution
|
||||||
|
*/
|
||||||
|
export interface OpenCodeToolResultEvent extends OpenCodeBaseEvent {
|
||||||
|
type: 'tool-result';
|
||||||
|
/** The tool call ID this result corresponds to */
|
||||||
|
call_id?: string;
|
||||||
|
/** Output from the tool execution */
|
||||||
|
output: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool error event - Tool execution failed
|
||||||
|
*/
|
||||||
|
export interface OpenCodeToolErrorEvent extends OpenCodeBaseEvent {
|
||||||
|
type: 'tool-error';
|
||||||
|
/** The tool call ID that failed */
|
||||||
|
call_id?: string;
|
||||||
|
/** Error message describing the failure */
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start step event - Begins an agentic loop iteration
|
||||||
|
*/
|
||||||
|
export interface OpenCodeStartStepEvent extends OpenCodeBaseEvent {
|
||||||
|
type: 'start-step';
|
||||||
|
/** Step number in the agentic loop */
|
||||||
|
step?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finish step event - Completes an agentic loop iteration
|
||||||
|
*/
|
||||||
|
export interface OpenCodeFinishStepEvent extends OpenCodeBaseEvent {
|
||||||
|
type: 'finish-step';
|
||||||
|
/** Step number that completed */
|
||||||
|
step?: number;
|
||||||
|
/** Whether the step completed successfully */
|
||||||
|
success?: boolean;
|
||||||
|
/** Optional result data */
|
||||||
|
result?: string;
|
||||||
|
/** Optional error if step failed */
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union type of all OpenCode stream events
|
||||||
|
*/
|
||||||
|
export type OpenCodeStreamEvent =
|
||||||
|
| OpenCodeTextDeltaEvent
|
||||||
|
| OpenCodeTextEndEvent
|
||||||
|
| OpenCodeToolCallEvent
|
||||||
|
| OpenCodeToolResultEvent
|
||||||
|
| OpenCodeToolErrorEvent
|
||||||
|
| OpenCodeStartStepEvent
|
||||||
|
| OpenCodeFinishStepEvent;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Tool Use ID Generation
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/** Counter for generating unique tool use IDs when call_id is not provided */
|
||||||
|
let toolUseIdCounter = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique tool use ID for tool calls without explicit IDs
|
||||||
|
*/
|
||||||
|
function generateToolUseId(): string {
|
||||||
|
toolUseIdCounter += 1;
|
||||||
|
return `opencode-tool-${toolUseIdCounter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the tool use ID counter (useful for testing)
|
||||||
|
*/
|
||||||
|
export function resetToolUseIdCounter(): void {
|
||||||
|
toolUseIdCounter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Provider Implementation
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpencodeProvider - Integrates opencode CLI as an AI provider
|
||||||
|
*
|
||||||
|
* OpenCode is an npm-distributed CLI tool that provides access to
|
||||||
|
* multiple AI model providers through a unified interface.
|
||||||
|
*/
|
||||||
|
export class OpencodeProvider extends CliProvider {
|
||||||
|
constructor(config: ProviderConfig = {}) {
|
||||||
|
super(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// CliProvider Abstract Method Implementations
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
getName(): string {
|
||||||
|
return 'opencode';
|
||||||
|
}
|
||||||
|
|
||||||
|
getCliName(): string {
|
||||||
|
return 'opencode';
|
||||||
|
}
|
||||||
|
|
||||||
|
getSpawnConfig(): CliSpawnConfig {
|
||||||
|
return {
|
||||||
|
windowsStrategy: 'npx',
|
||||||
|
npxPackage: 'opencode-ai@latest',
|
||||||
|
commonPaths: {
|
||||||
|
linux: [
|
||||||
|
path.join(os.homedir(), '.opencode/bin/opencode'),
|
||||||
|
path.join(os.homedir(), '.npm-global/bin/opencode'),
|
||||||
|
'/usr/local/bin/opencode',
|
||||||
|
'/usr/bin/opencode',
|
||||||
|
path.join(os.homedir(), '.local/bin/opencode'),
|
||||||
|
],
|
||||||
|
darwin: [
|
||||||
|
path.join(os.homedir(), '.opencode/bin/opencode'),
|
||||||
|
path.join(os.homedir(), '.npm-global/bin/opencode'),
|
||||||
|
'/usr/local/bin/opencode',
|
||||||
|
'/opt/homebrew/bin/opencode',
|
||||||
|
path.join(os.homedir(), '.local/bin/opencode'),
|
||||||
|
],
|
||||||
|
win32: [
|
||||||
|
path.join(os.homedir(), '.opencode', 'bin', 'opencode.exe'),
|
||||||
|
path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'opencode.cmd'),
|
||||||
|
path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'opencode'),
|
||||||
|
path.join(process.env.APPDATA || '', 'npm', 'opencode.cmd'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build CLI arguments for the `opencode run` command
|
||||||
|
*
|
||||||
|
* Arguments built:
|
||||||
|
* - 'run' subcommand for executing queries
|
||||||
|
* - '--format', 'stream-json' for JSONL streaming output
|
||||||
|
* - '-q' / '--quiet' to suppress spinner and interactive elements
|
||||||
|
* - '-c', '<cwd>' for working directory
|
||||||
|
* - '--model', '<model>' for model selection (if specified)
|
||||||
|
* - '-' as final arg to read prompt from stdin
|
||||||
|
*
|
||||||
|
* The prompt is NOT included in CLI args - it's passed via stdin to avoid
|
||||||
|
* shell escaping issues with special characters in content.
|
||||||
|
*
|
||||||
|
* @param options - Execution options containing model, cwd, etc.
|
||||||
|
* @returns Array of CLI arguments for opencode run
|
||||||
|
*/
|
||||||
|
buildCliArgs(options: ExecuteOptions): string[] {
|
||||||
|
const args: string[] = ['run'];
|
||||||
|
|
||||||
|
// Add streaming JSON output format for JSONL parsing
|
||||||
|
args.push('--format', 'stream-json');
|
||||||
|
|
||||||
|
// Suppress spinner and interactive elements for non-TTY usage
|
||||||
|
args.push('-q');
|
||||||
|
|
||||||
|
// Set working directory
|
||||||
|
if (options.cwd) {
|
||||||
|
args.push('-c', options.cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle model selection
|
||||||
|
// Strip 'opencode-' prefix if present, OpenCode uses format like 'anthropic/claude-sonnet-4-5'
|
||||||
|
if (options.model) {
|
||||||
|
const model = stripProviderPrefix(options.model);
|
||||||
|
args.push('--model', model);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use '-' to indicate reading prompt from stdin
|
||||||
|
// This avoids shell escaping issues with special characters
|
||||||
|
args.push('-');
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Prompt Handling
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract prompt text from ExecuteOptions for passing via stdin
|
||||||
|
*
|
||||||
|
* Handles both string prompts and array-based prompts with content blocks.
|
||||||
|
* For array prompts with images, extracts only text content (images would
|
||||||
|
* need separate handling via file paths if OpenCode supports them).
|
||||||
|
*
|
||||||
|
* @param options - Execution options containing the prompt
|
||||||
|
* @returns Plain text prompt string
|
||||||
|
*/
|
||||||
|
private extractPromptText(options: ExecuteOptions): string {
|
||||||
|
if (typeof options.prompt === 'string') {
|
||||||
|
return options.prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array-based prompt - extract text content
|
||||||
|
if (Array.isArray(options.prompt)) {
|
||||||
|
return options.prompt
|
||||||
|
.filter((block) => block.type === 'text' && block.text)
|
||||||
|
.map((block) => block.text)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Invalid prompt format: expected string or content block array');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build subprocess options with stdin data for prompt
|
||||||
|
*
|
||||||
|
* Extends the base class method to add stdinData containing the prompt.
|
||||||
|
* This allows passing prompts via stdin instead of CLI arguments,
|
||||||
|
* avoiding shell escaping issues with special characters.
|
||||||
|
*
|
||||||
|
* @param options - Execution options
|
||||||
|
* @param cliArgs - CLI arguments from buildCliArgs
|
||||||
|
* @returns SubprocessOptions with stdinData set
|
||||||
|
*/
|
||||||
|
protected buildSubprocessOptions(options: ExecuteOptions, cliArgs: string[]): SubprocessOptions {
|
||||||
|
const subprocessOptions = super.buildSubprocessOptions(options, cliArgs);
|
||||||
|
|
||||||
|
// Pass prompt via stdin to avoid shell interpretation of special characters
|
||||||
|
// like $(), backticks, quotes, etc. that may appear in prompts or file content
|
||||||
|
subprocessOptions.stdinData = this.extractPromptText(options);
|
||||||
|
|
||||||
|
return subprocessOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a raw CLI event to ProviderMessage format
|
||||||
|
*
|
||||||
|
* Maps OpenCode event types to the standard ProviderMessage structure:
|
||||||
|
* - text-delta -> type: 'assistant', content with type: 'text'
|
||||||
|
* - text-end -> null (informational, no message needed)
|
||||||
|
* - tool-call -> type: 'assistant', content with type: 'tool_use'
|
||||||
|
* - tool-result -> type: 'assistant', content with type: 'tool_result'
|
||||||
|
* - tool-error -> type: 'error'
|
||||||
|
* - start-step -> null (informational, no message needed)
|
||||||
|
* - finish-step with success -> type: 'result', subtype: 'success'
|
||||||
|
* - finish-step with error -> type: 'error'
|
||||||
|
*
|
||||||
|
* @param event - Raw event from OpenCode CLI JSONL output
|
||||||
|
* @returns Normalized ProviderMessage or null to skip the event
|
||||||
|
*/
|
||||||
|
normalizeEvent(event: unknown): ProviderMessage | null {
|
||||||
|
if (!event || typeof event !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCodeEvent = event as OpenCodeStreamEvent;
|
||||||
|
|
||||||
|
switch (openCodeEvent.type) {
|
||||||
|
case 'text-delta': {
|
||||||
|
const textEvent = openCodeEvent as OpenCodeTextDeltaEvent;
|
||||||
|
|
||||||
|
// Skip empty text deltas
|
||||||
|
if (!textEvent.text) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content: ContentBlock[] = [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: textEvent.text,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'assistant',
|
||||||
|
session_id: textEvent.session_id,
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'text-end': {
|
||||||
|
// Text end is informational - no message needed
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tool-call': {
|
||||||
|
const toolEvent = openCodeEvent as OpenCodeToolCallEvent;
|
||||||
|
|
||||||
|
// Generate a tool use ID if not provided
|
||||||
|
const toolUseId = toolEvent.call_id || generateToolUseId();
|
||||||
|
|
||||||
|
const content: ContentBlock[] = [
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
name: toolEvent.name,
|
||||||
|
tool_use_id: toolUseId,
|
||||||
|
input: toolEvent.args,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'assistant',
|
||||||
|
session_id: toolEvent.session_id,
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tool-result': {
|
||||||
|
const resultEvent = openCodeEvent as OpenCodeToolResultEvent;
|
||||||
|
|
||||||
|
const content: ContentBlock[] = [
|
||||||
|
{
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: resultEvent.call_id,
|
||||||
|
content: resultEvent.output,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'assistant',
|
||||||
|
session_id: resultEvent.session_id,
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tool-error': {
|
||||||
|
const errorEvent = openCodeEvent as OpenCodeToolErrorEvent;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'error',
|
||||||
|
session_id: errorEvent.session_id,
|
||||||
|
error: errorEvent.error || 'Tool execution failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'start-step': {
|
||||||
|
// Start step is informational - no message needed
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'finish-step': {
|
||||||
|
const finishEvent = openCodeEvent as OpenCodeFinishStepEvent;
|
||||||
|
|
||||||
|
// Check if the step failed
|
||||||
|
if (finishEvent.success === false || finishEvent.error) {
|
||||||
|
return {
|
||||||
|
type: 'error',
|
||||||
|
session_id: finishEvent.session_id,
|
||||||
|
error: finishEvent.error || 'Step execution failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Successful completion
|
||||||
|
return {
|
||||||
|
type: 'result',
|
||||||
|
subtype: 'success',
|
||||||
|
session_id: finishEvent.session_id,
|
||||||
|
result: finishEvent.result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
// Unknown event type - skip it
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Model Configuration
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available models for OpenCode
|
||||||
|
*
|
||||||
|
* Returns model definitions for supported AI providers:
|
||||||
|
* - Anthropic Claude models (Sonnet, Opus, Haiku)
|
||||||
|
* - OpenAI GPT-4o
|
||||||
|
* - Google Gemini 2.5 Pro
|
||||||
|
*/
|
||||||
|
getAvailableModels(): ModelDefinition[] {
|
||||||
|
return [
|
||||||
|
// OpenCode Free Tier Models
|
||||||
|
{
|
||||||
|
id: 'opencode/big-pickle',
|
||||||
|
name: 'Big Pickle (Free)',
|
||||||
|
modelString: 'opencode/big-pickle',
|
||||||
|
provider: 'opencode',
|
||||||
|
description: 'OpenCode free tier model - great for general coding',
|
||||||
|
supportsTools: true,
|
||||||
|
supportsVision: false,
|
||||||
|
tier: 'basic',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'opencode/gpt-5-nano',
|
||||||
|
name: 'GPT-5 Nano (Free)',
|
||||||
|
modelString: 'opencode/gpt-5-nano',
|
||||||
|
provider: 'opencode',
|
||||||
|
description: 'Fast and lightweight free tier model',
|
||||||
|
supportsTools: true,
|
||||||
|
supportsVision: false,
|
||||||
|
tier: 'basic',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'opencode/grok-code',
|
||||||
|
name: 'Grok Code (Free)',
|
||||||
|
modelString: 'opencode/grok-code',
|
||||||
|
provider: 'opencode',
|
||||||
|
description: 'OpenCode free tier Grok model for coding',
|
||||||
|
supportsTools: true,
|
||||||
|
supportsVision: false,
|
||||||
|
tier: 'basic',
|
||||||
|
},
|
||||||
|
// Amazon Bedrock - Claude Models
|
||||||
|
{
|
||||||
|
id: 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0',
|
||||||
|
name: 'Claude Sonnet 4.5 (Bedrock)',
|
||||||
|
modelString: 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0',
|
||||||
|
provider: 'opencode',
|
||||||
|
description: 'Latest Claude Sonnet via AWS Bedrock - fast and intelligent',
|
||||||
|
supportsTools: true,
|
||||||
|
supportsVision: true,
|
||||||
|
tier: 'premium',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0',
|
||||||
|
name: 'Claude Opus 4.5 (Bedrock)',
|
||||||
|
modelString: 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0',
|
||||||
|
provider: 'opencode',
|
||||||
|
description: 'Most capable Claude model via AWS Bedrock',
|
||||||
|
supportsTools: true,
|
||||||
|
supportsVision: true,
|
||||||
|
tier: 'premium',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0',
|
||||||
|
name: 'Claude Haiku 4.5 (Bedrock)',
|
||||||
|
modelString: 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0',
|
||||||
|
provider: 'opencode',
|
||||||
|
description: 'Fastest Claude model via AWS Bedrock',
|
||||||
|
supportsTools: true,
|
||||||
|
supportsVision: true,
|
||||||
|
tier: 'standard',
|
||||||
|
},
|
||||||
|
// Amazon Bedrock - DeepSeek Models
|
||||||
|
{
|
||||||
|
id: 'amazon-bedrock/deepseek.r1-v1:0',
|
||||||
|
name: 'DeepSeek R1 (Bedrock)',
|
||||||
|
modelString: 'amazon-bedrock/deepseek.r1-v1:0',
|
||||||
|
provider: 'opencode',
|
||||||
|
description: 'DeepSeek R1 reasoning model - excellent for coding',
|
||||||
|
supportsTools: true,
|
||||||
|
supportsVision: false,
|
||||||
|
tier: 'premium',
|
||||||
|
},
|
||||||
|
// Amazon Bedrock - Amazon Nova Models
|
||||||
|
{
|
||||||
|
id: 'amazon-bedrock/amazon.nova-pro-v1:0',
|
||||||
|
name: 'Amazon Nova Pro (Bedrock)',
|
||||||
|
modelString: 'amazon-bedrock/amazon.nova-pro-v1:0',
|
||||||
|
provider: 'opencode',
|
||||||
|
description: 'Amazon Nova Pro - balanced performance',
|
||||||
|
supportsTools: true,
|
||||||
|
supportsVision: true,
|
||||||
|
tier: 'standard',
|
||||||
|
},
|
||||||
|
// Amazon Bedrock - Meta Llama Models
|
||||||
|
{
|
||||||
|
id: 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0',
|
||||||
|
name: 'Llama 4 Maverick 17B (Bedrock)',
|
||||||
|
modelString: 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0',
|
||||||
|
provider: 'opencode',
|
||||||
|
description: 'Meta Llama 4 Maverick via AWS Bedrock',
|
||||||
|
supportsTools: true,
|
||||||
|
supportsVision: false,
|
||||||
|
tier: 'standard',
|
||||||
|
},
|
||||||
|
// Amazon Bedrock - Qwen Models
|
||||||
|
{
|
||||||
|
id: 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0',
|
||||||
|
name: 'Qwen3 Coder 480B (Bedrock)',
|
||||||
|
modelString: 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0',
|
||||||
|
provider: 'opencode',
|
||||||
|
description: 'Qwen3 Coder 480B - excellent for coding',
|
||||||
|
supportsTools: true,
|
||||||
|
supportsVision: false,
|
||||||
|
tier: 'premium',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Feature Support
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a feature is supported by OpenCode
|
||||||
|
*
|
||||||
|
* Supported features:
|
||||||
|
* - tools: Function calling / tool use
|
||||||
|
* - text: Text generation
|
||||||
|
* - vision: Image understanding
|
||||||
|
*/
|
||||||
|
supportsFeature(feature: string): boolean {
|
||||||
|
const supportedFeatures = ['tools', 'text', 'vision'];
|
||||||
|
return supportedFeatures.includes(feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Authentication
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check authentication status for OpenCode CLI
|
||||||
|
*
|
||||||
|
* Checks for authentication via:
|
||||||
|
* - OAuth token in auth file
|
||||||
|
* - API key in auth file
|
||||||
|
*/
|
||||||
|
async checkAuth(): Promise<OpenCodeAuthStatus> {
|
||||||
|
const authIndicators = await getOpenCodeAuthIndicators();
|
||||||
|
|
||||||
|
// Check for OAuth token
|
||||||
|
if (authIndicators.hasOAuthToken) {
|
||||||
|
return {
|
||||||
|
authenticated: true,
|
||||||
|
method: 'oauth',
|
||||||
|
hasOAuthToken: true,
|
||||||
|
hasApiKey: authIndicators.hasApiKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for API key
|
||||||
|
if (authIndicators.hasApiKey) {
|
||||||
|
return {
|
||||||
|
authenticated: true,
|
||||||
|
method: 'api_key',
|
||||||
|
hasOAuthToken: false,
|
||||||
|
hasApiKey: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
authenticated: false,
|
||||||
|
method: 'none',
|
||||||
|
hasOAuthToken: false,
|
||||||
|
hasApiKey: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Installation Detection
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect OpenCode installation status
|
||||||
|
*
|
||||||
|
* Checks if the opencode CLI is available either through:
|
||||||
|
* - Direct installation (npm global)
|
||||||
|
* - NPX (fallback on Windows)
|
||||||
|
* Also checks authentication status.
|
||||||
|
*/
|
||||||
|
async detectInstallation(): Promise<InstallationStatus> {
|
||||||
|
this.ensureCliDetected();
|
||||||
|
|
||||||
|
const installed = await this.isInstalled();
|
||||||
|
const auth = await this.checkAuth();
|
||||||
|
|
||||||
|
return {
|
||||||
|
installed,
|
||||||
|
path: this.cliPath || undefined,
|
||||||
|
method: this.detectedStrategy === 'npx' ? 'npm' : 'cli',
|
||||||
|
authenticated: auth.authenticated,
|
||||||
|
hasApiKey: auth.hasApiKey,
|
||||||
|
hasOAuthToken: auth.hasOAuthToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
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, type ModelProvider } from '@automaker/types';
|
import { isCursorModel, isCodexModel, isOpencodeModel, type ModelProvider } from '@automaker/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provider registration entry
|
* Provider registration entry
|
||||||
@@ -201,6 +201,7 @@ export class ProviderFactory {
|
|||||||
import { ClaudeProvider } from './claude-provider.js';
|
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';
|
||||||
|
|
||||||
// Register Claude provider
|
// Register Claude provider
|
||||||
registerProvider('claude', {
|
registerProvider('claude', {
|
||||||
@@ -228,3 +229,10 @@ registerProvider('codex', {
|
|||||||
canHandleModel: (model: string) => isCodexModel(model),
|
canHandleModel: (model: string) => isCodexModel(model),
|
||||||
priority: 5, // Medium priority - check after Cursor but before Claude
|
priority: 5, // Medium priority - check after Cursor but before Claude
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Register OpenCode provider
|
||||||
|
registerProvider('opencode', {
|
||||||
|
factory: () => new OpencodeProvider(),
|
||||||
|
canHandleModel: (model: string) => isOpencodeModel(model),
|
||||||
|
priority: 3, // Between codex (5) and claude (0)
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { createCursorStatusHandler } from './routes/cursor-status.js';
|
|||||||
import { createCodexStatusHandler } from './routes/codex-status.js';
|
import { createCodexStatusHandler } from './routes/codex-status.js';
|
||||||
import { createInstallCodexHandler } from './routes/install-codex.js';
|
import { createInstallCodexHandler } from './routes/install-codex.js';
|
||||||
import { createAuthCodexHandler } from './routes/auth-codex.js';
|
import { createAuthCodexHandler } from './routes/auth-codex.js';
|
||||||
|
import { createOpencodeStatusHandler } from './routes/opencode-status.js';
|
||||||
import {
|
import {
|
||||||
createGetCursorConfigHandler,
|
createGetCursorConfigHandler,
|
||||||
createSetCursorDefaultModelHandler,
|
createSetCursorDefaultModelHandler,
|
||||||
@@ -49,6 +50,9 @@ export function createSetupRoutes(): Router {
|
|||||||
router.get('/codex-status', createCodexStatusHandler());
|
router.get('/codex-status', createCodexStatusHandler());
|
||||||
router.post('/install-codex', createInstallCodexHandler());
|
router.post('/install-codex', createInstallCodexHandler());
|
||||||
router.post('/auth-codex', createAuthCodexHandler());
|
router.post('/auth-codex', createAuthCodexHandler());
|
||||||
|
|
||||||
|
// OpenCode CLI routes
|
||||||
|
router.get('/opencode-status', createOpencodeStatusHandler());
|
||||||
router.get('/cursor-config', createGetCursorConfigHandler());
|
router.get('/cursor-config', createGetCursorConfigHandler());
|
||||||
router.post('/cursor-config/default-model', createSetCursorDefaultModelHandler());
|
router.post('/cursor-config/default-model', createSetCursorDefaultModelHandler());
|
||||||
router.post('/cursor-config/models', createSetCursorModelsHandler());
|
router.post('/cursor-config/models', createSetCursorModelsHandler());
|
||||||
|
|||||||
59
apps/server/src/routes/setup/routes/opencode-status.ts
Normal file
59
apps/server/src/routes/setup/routes/opencode-status.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* GET /opencode-status endpoint - Get OpenCode CLI installation and auth status
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { OpencodeProvider } from '../../../providers/opencode-provider.js';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates handler for GET /api/setup/opencode-status
|
||||||
|
* Returns OpenCode CLI installation and authentication status
|
||||||
|
*/
|
||||||
|
export function createOpencodeStatusHandler() {
|
||||||
|
const installCommand = 'curl -fsSL https://opencode.ai/install | bash';
|
||||||
|
const loginCommand = 'opencode auth login';
|
||||||
|
|
||||||
|
return async (_req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const provider = new OpencodeProvider();
|
||||||
|
const status = await provider.detectInstallation();
|
||||||
|
|
||||||
|
// Derive auth method from authenticated status and API key presence
|
||||||
|
let authMethod = 'none';
|
||||||
|
if (status.authenticated) {
|
||||||
|
authMethod = status.hasApiKey ? 'api_key_env' : 'cli_authenticated';
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
installed: status.installed,
|
||||||
|
version: status.version || null,
|
||||||
|
path: status.path || null,
|
||||||
|
auth: {
|
||||||
|
authenticated: status.authenticated || false,
|
||||||
|
method: authMethod,
|
||||||
|
hasApiKey: status.hasApiKey || false,
|
||||||
|
hasEnvApiKey: !!process.env.ANTHROPIC_API_KEY || !!process.env.OPENAI_API_KEY,
|
||||||
|
hasOAuthToken: status.hasOAuthToken || false,
|
||||||
|
},
|
||||||
|
recommendation: status.installed
|
||||||
|
? undefined
|
||||||
|
: 'Install OpenCode CLI to use multi-provider AI models.',
|
||||||
|
installCommand,
|
||||||
|
loginCommand,
|
||||||
|
installCommands: {
|
||||||
|
macos: installCommand,
|
||||||
|
linux: installCommand,
|
||||||
|
npm: 'npm install -g opencode-ai',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Get OpenCode status failed');
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
1277
apps/server/tests/unit/providers/opencode-provider.test.ts
Normal file
1277
apps/server/tests/unit/providers/opencode-provider.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,12 +3,14 @@ import { ProviderFactory } from '@/providers/provider-factory.js';
|
|||||||
import { ClaudeProvider } from '@/providers/claude-provider.js';
|
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';
|
||||||
|
|
||||||
describe('provider-factory.ts', () => {
|
describe('provider-factory.ts', () => {
|
||||||
let consoleSpy: any;
|
let consoleSpy: any;
|
||||||
let detectClaudeSpy: any;
|
let detectClaudeSpy: any;
|
||||||
let detectCursorSpy: any;
|
let detectCursorSpy: any;
|
||||||
let detectCodexSpy: any;
|
let detectCodexSpy: any;
|
||||||
|
let detectOpencodeSpy: any;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
consoleSpy = {
|
consoleSpy = {
|
||||||
@@ -25,6 +27,9 @@ describe('provider-factory.ts', () => {
|
|||||||
detectCodexSpy = vi
|
detectCodexSpy = vi
|
||||||
.spyOn(CodexProvider.prototype, 'detectInstallation')
|
.spyOn(CodexProvider.prototype, 'detectInstallation')
|
||||||
.mockResolvedValue({ installed: true });
|
.mockResolvedValue({ installed: true });
|
||||||
|
detectOpencodeSpy = vi
|
||||||
|
.spyOn(OpencodeProvider.prototype, 'detectInstallation')
|
||||||
|
.mockResolvedValue({ installed: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -32,6 +37,7 @@ describe('provider-factory.ts', () => {
|
|||||||
detectClaudeSpy.mockRestore();
|
detectClaudeSpy.mockRestore();
|
||||||
detectCursorSpy.mockRestore();
|
detectCursorSpy.mockRestore();
|
||||||
detectCodexSpy.mockRestore();
|
detectCodexSpy.mockRestore();
|
||||||
|
detectOpencodeSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getProviderForModel', () => {
|
describe('getProviderForModel', () => {
|
||||||
@@ -160,9 +166,9 @@ describe('provider-factory.ts', () => {
|
|||||||
expect(hasClaudeProvider).toBe(true);
|
expect(hasClaudeProvider).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return exactly 3 providers', () => {
|
it('should return exactly 4 providers', () => {
|
||||||
const providers = ProviderFactory.getAllProviders();
|
const providers = ProviderFactory.getAllProviders();
|
||||||
expect(providers).toHaveLength(3);
|
expect(providers).toHaveLength(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include CursorProvider', () => {
|
it('should include CursorProvider', () => {
|
||||||
@@ -199,7 +205,8 @@ describe('provider-factory.ts', () => {
|
|||||||
expect(keys).toContain('claude');
|
expect(keys).toContain('claude');
|
||||||
expect(keys).toContain('cursor');
|
expect(keys).toContain('cursor');
|
||||||
expect(keys).toContain('codex');
|
expect(keys).toContain('codex');
|
||||||
expect(keys).toHaveLength(3);
|
expect(keys).toContain('opencode');
|
||||||
|
expect(keys).toHaveLength(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include cursor status', async () => {
|
it('should include cursor status', async () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ComponentType, SVGProps } from 'react';
|
import type { ComponentType, SVGProps } from 'react';
|
||||||
|
import { Cpu } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { AgentModel, ModelProvider } from '@automaker/types';
|
import type { AgentModel, ModelProvider } from '@automaker/types';
|
||||||
import { getProviderFromModel } from '@/lib/utils';
|
import { getProviderFromModel } from '@/lib/utils';
|
||||||
@@ -96,6 +97,10 @@ export function GrokIcon(props: Omit<ProviderIconProps, 'provider'>) {
|
|||||||
return <ProviderIcon provider={PROVIDER_ICON_KEYS.grok} {...props} />;
|
return <ProviderIcon provider={PROVIDER_ICON_KEYS.grok} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function OpenCodeIcon({ className, ...props }: { className?: string }) {
|
||||||
|
return <Cpu className={cn('inline-block', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
export const PROVIDER_ICON_COMPONENTS: Record<
|
export const PROVIDER_ICON_COMPONENTS: Record<
|
||||||
ModelProvider,
|
ModelProvider,
|
||||||
ComponentType<{ className?: string }>
|
ComponentType<{ className?: string }>
|
||||||
@@ -103,6 +108,7 @@ export const PROVIDER_ICON_COMPONENTS: Record<
|
|||||||
claude: AnthropicIcon,
|
claude: AnthropicIcon,
|
||||||
cursor: CursorIcon, // Default for Cursor provider (will be overridden by getProviderIconForModel)
|
cursor: CursorIcon, // Default for Cursor provider (will be overridden by getProviderIconForModel)
|
||||||
codex: OpenAIIcon,
|
codex: OpenAIIcon,
|
||||||
|
opencode: OpenCodeIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
|
import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
|
||||||
import {
|
import {
|
||||||
DescriptionImageDropZone,
|
DescriptionImageDropZone,
|
||||||
@@ -21,15 +21,10 @@ import {
|
|||||||
FeatureTextFilePath as DescriptionTextFilePath,
|
FeatureTextFilePath as DescriptionTextFilePath,
|
||||||
ImagePreviewMap,
|
ImagePreviewMap,
|
||||||
} from '@/components/ui/description-image-dropzone';
|
} from '@/components/ui/description-image-dropzone';
|
||||||
import {
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
MessageSquare,
|
import { Sparkles, ChevronDown, ChevronRight, Play, Cpu, FolderKanban } from 'lucide-react';
|
||||||
Settings2,
|
|
||||||
SlidersHorizontal,
|
|
||||||
Sparkles,
|
|
||||||
ChevronDown,
|
|
||||||
Play,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { modelSupportsThinking } from '@/lib/utils';
|
import { modelSupportsThinking } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
@@ -41,19 +36,22 @@ import {
|
|||||||
PlanningMode,
|
PlanningMode,
|
||||||
Feature,
|
Feature,
|
||||||
} from '@/store/app-store';
|
} from '@/store/app-store';
|
||||||
import type { ReasoningEffort } from '@automaker/types';
|
import type { ReasoningEffort, PhaseModelEntry } from '@automaker/types';
|
||||||
import { codexModelHasThinking, supportsReasoningEffort } from '@automaker/types';
|
import {
|
||||||
|
supportsReasoningEffort,
|
||||||
|
PROVIDER_PREFIXES,
|
||||||
|
isCursorModel,
|
||||||
|
isClaudeModel,
|
||||||
|
} from '@automaker/types';
|
||||||
import {
|
import {
|
||||||
ModelSelector,
|
|
||||||
ThinkingLevelSelector,
|
|
||||||
ReasoningEffortSelector,
|
|
||||||
ProfileQuickSelect,
|
|
||||||
TestingTabContent,
|
TestingTabContent,
|
||||||
PrioritySelector,
|
PrioritySelector,
|
||||||
BranchSelector,
|
BranchSelector,
|
||||||
PlanningModeSelector,
|
PlanningModeSelect,
|
||||||
AncestorContextSection,
|
AncestorContextSection,
|
||||||
|
ProfileTypeahead,
|
||||||
} from '../shared';
|
} from '../shared';
|
||||||
|
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||||
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
|
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -67,7 +65,6 @@ import {
|
|||||||
formatAncestorContextForPrompt,
|
formatAncestorContextForPrompt,
|
||||||
type AncestorContext,
|
type AncestorContext,
|
||||||
} from '@automaker/dependency-resolver';
|
} from '@automaker/dependency-resolver';
|
||||||
import { isCursorModel, PROVIDER_PREFIXES } from '@automaker/types';
|
|
||||||
|
|
||||||
const logger = createLogger('AddFeatureDialog');
|
const logger = createLogger('AddFeatureDialog');
|
||||||
|
|
||||||
@@ -82,7 +79,7 @@ type FeatureData = {
|
|||||||
model: AgentModel;
|
model: AgentModel;
|
||||||
thinkingLevel: ThinkingLevel;
|
thinkingLevel: ThinkingLevel;
|
||||||
reasoningEffort: ReasoningEffort;
|
reasoningEffort: ReasoningEffort;
|
||||||
branchName: string; // Can be empty string to use current branch
|
branchName: string;
|
||||||
priority: number;
|
priority: number;
|
||||||
planningMode: PlanningMode;
|
planningMode: PlanningMode;
|
||||||
requirePlanApproval: boolean;
|
requirePlanApproval: boolean;
|
||||||
@@ -96,14 +93,13 @@ interface AddFeatureDialogProps {
|
|||||||
onAddAndStart?: (feature: FeatureData) => void;
|
onAddAndStart?: (feature: FeatureData) => void;
|
||||||
categorySuggestions: string[];
|
categorySuggestions: string[];
|
||||||
branchSuggestions: string[];
|
branchSuggestions: string[];
|
||||||
branchCardCounts?: Record<string, number>; // Map of branch name to unarchived card count
|
branchCardCounts?: Record<string, number>;
|
||||||
defaultSkipTests: boolean;
|
defaultSkipTests: boolean;
|
||||||
defaultBranch?: string;
|
defaultBranch?: string;
|
||||||
currentBranch?: string;
|
currentBranch?: string;
|
||||||
isMaximized: boolean;
|
isMaximized: boolean;
|
||||||
showProfilesOnly: boolean;
|
showProfilesOnly: boolean;
|
||||||
aiProfiles: AIProfile[];
|
aiProfiles: AIProfile[];
|
||||||
// Spawn task mode props
|
|
||||||
parentFeature?: Feature | null;
|
parentFeature?: Feature | null;
|
||||||
allFeatures?: Feature[];
|
allFeatures?: Feature[];
|
||||||
}
|
}
|
||||||
@@ -128,37 +124,43 @@ export function AddFeatureDialog({
|
|||||||
const isSpawnMode = !!parentFeature;
|
const isSpawnMode = !!parentFeature;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [useCurrentBranch, setUseCurrentBranch] = useState(true);
|
const [useCurrentBranch, setUseCurrentBranch] = useState(true);
|
||||||
const [newFeature, setNewFeature] = useState({
|
|
||||||
title: '',
|
// Form state
|
||||||
category: '',
|
const [title, setTitle] = useState('');
|
||||||
description: '',
|
const [category, setCategory] = useState('');
|
||||||
images: [] as FeatureImage[],
|
const [description, setDescription] = useState('');
|
||||||
imagePaths: [] as DescriptionImagePath[],
|
const [images, setImages] = useState<FeatureImage[]>([]);
|
||||||
textFilePaths: [] as DescriptionTextFilePath[],
|
const [imagePaths, setImagePaths] = useState<DescriptionImagePath[]>([]);
|
||||||
skipTests: false,
|
const [textFilePaths, setTextFilePaths] = useState<DescriptionTextFilePath[]>([]);
|
||||||
model: 'opus' as ModelAlias,
|
const [skipTests, setSkipTests] = useState(false);
|
||||||
thinkingLevel: 'none' as ThinkingLevel,
|
const [branchName, setBranchName] = useState('');
|
||||||
reasoningEffort: 'none' as ReasoningEffort,
|
const [priority, setPriority] = useState(2);
|
||||||
branchName: '',
|
|
||||||
priority: 2 as number, // Default to medium priority
|
// Model selection state
|
||||||
});
|
const [selectedProfileId, setSelectedProfileId] = useState<string | undefined>();
|
||||||
const [newFeaturePreviewMap, setNewFeaturePreviewMap] = useState<ImagePreviewMap>(
|
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>({ model: 'opus' });
|
||||||
() => new Map()
|
|
||||||
);
|
// Check if current model supports planning mode (Claude/Anthropic only)
|
||||||
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
|
const modelSupportsPlanningMode = isClaudeModel(modelEntry.model);
|
||||||
|
|
||||||
|
// Planning mode state
|
||||||
|
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
||||||
|
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
const [previewMap, setPreviewMap] = useState<ImagePreviewMap>(() => new Map());
|
||||||
const [descriptionError, setDescriptionError] = useState(false);
|
const [descriptionError, setDescriptionError] = useState(false);
|
||||||
const [isEnhancing, setIsEnhancing] = useState(false);
|
const [isEnhancing, setIsEnhancing] = useState(false);
|
||||||
const [enhancementMode, setEnhancementMode] = useState<
|
const [enhancementMode, setEnhancementMode] = useState<
|
||||||
'improve' | 'technical' | 'simplify' | 'acceptance'
|
'improve' | 'technical' | 'simplify' | 'acceptance'
|
||||||
>('improve');
|
>('improve');
|
||||||
const [planningMode, setPlanningMode] = useState<PlanningMode>('skip');
|
const [enhanceOpen, setEnhanceOpen] = useState(false);
|
||||||
const [requirePlanApproval, setRequirePlanApproval] = useState(false);
|
|
||||||
|
|
||||||
// Spawn mode state
|
// Spawn mode state
|
||||||
const [ancestors, setAncestors] = useState<AncestorContext[]>([]);
|
const [ancestors, setAncestors] = useState<AncestorContext[]>([]);
|
||||||
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
|
const [selectedAncestorIds, setSelectedAncestorIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Get planning mode defaults and worktrees setting from store
|
// Get defaults from store
|
||||||
const { defaultPlanningMode, defaultRequirePlanApproval, defaultAIProfileId, useWorktrees } =
|
const { defaultPlanningMode, defaultRequirePlanApproval, defaultAIProfileId, useWorktrees } =
|
||||||
useAppStore();
|
useAppStore();
|
||||||
|
|
||||||
@@ -168,28 +170,29 @@ export function AddFeatureDialog({
|
|||||||
// Sync defaults when dialog opens
|
// Sync defaults when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
// Find the default profile if one is set
|
|
||||||
const defaultProfile = defaultAIProfileId
|
const defaultProfile = defaultAIProfileId
|
||||||
? aiProfiles.find((p) => p.id === defaultAIProfileId)
|
? aiProfiles.find((p) => p.id === defaultAIProfileId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
setNewFeature((prev) => ({
|
setSkipTests(defaultSkipTests);
|
||||||
...prev,
|
setBranchName(defaultBranch || '');
|
||||||
skipTests: defaultSkipTests,
|
|
||||||
branchName: defaultBranch || '',
|
|
||||||
// Use default profile's model/thinkingLevel if set, else fallback to defaults
|
|
||||||
model: defaultProfile?.model ?? 'opus',
|
|
||||||
thinkingLevel: defaultProfile?.thinkingLevel ?? 'none',
|
|
||||||
}));
|
|
||||||
setUseCurrentBranch(true);
|
setUseCurrentBranch(true);
|
||||||
setPlanningMode(defaultPlanningMode);
|
setPlanningMode(defaultPlanningMode);
|
||||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||||
|
|
||||||
|
// Set model from default profile or fallback
|
||||||
|
if (defaultProfile) {
|
||||||
|
setSelectedProfileId(defaultProfile.id);
|
||||||
|
applyProfileToModel(defaultProfile);
|
||||||
|
} else {
|
||||||
|
setSelectedProfileId(undefined);
|
||||||
|
setModelEntry({ model: 'opus' });
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize ancestors for spawn mode
|
// Initialize ancestors for spawn mode
|
||||||
if (parentFeature) {
|
if (parentFeature) {
|
||||||
const ancestorList = getAncestors(parentFeature, allFeatures);
|
const ancestorList = getAncestors(parentFeature, allFeatures);
|
||||||
setAncestors(ancestorList);
|
setAncestors(ancestorList);
|
||||||
// Only select parent by default - ancestors are optional context
|
|
||||||
setSelectedAncestorIds(new Set([parentFeature.id]));
|
setSelectedAncestorIds(new Set([parentFeature.id]));
|
||||||
} else {
|
} else {
|
||||||
setAncestors([]);
|
setAncestors([]);
|
||||||
@@ -208,36 +211,62 @@ export function AddFeatureDialog({
|
|||||||
allFeatures,
|
allFeatures,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const applyProfileToModel = (profile: AIProfile) => {
|
||||||
|
if (profile.provider === 'cursor') {
|
||||||
|
const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
|
||||||
|
setModelEntry({ model: cursorModel as ModelAlias });
|
||||||
|
} else if (profile.provider === 'codex') {
|
||||||
|
setModelEntry({
|
||||||
|
model: profile.codexModel || 'codex-gpt-5.2-codex',
|
||||||
|
reasoningEffort: 'none',
|
||||||
|
});
|
||||||
|
} else if (profile.provider === 'opencode') {
|
||||||
|
setModelEntry({ model: profile.opencodeModel || 'opencode/big-pickle' });
|
||||||
|
} else {
|
||||||
|
// Claude
|
||||||
|
setModelEntry({
|
||||||
|
model: profile.model || 'sonnet',
|
||||||
|
thinkingLevel: profile.thinkingLevel || 'none',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProfileSelect = (profile: AIProfile) => {
|
||||||
|
setSelectedProfileId(profile.id);
|
||||||
|
applyProfileToModel(profile);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModelChange = (entry: PhaseModelEntry) => {
|
||||||
|
setModelEntry(entry);
|
||||||
|
// Clear profile selection when manually changing model
|
||||||
|
setSelectedProfileId(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
const buildFeatureData = (): FeatureData | null => {
|
const buildFeatureData = (): FeatureData | null => {
|
||||||
if (!newFeature.description.trim()) {
|
if (!description.trim()) {
|
||||||
setDescriptionError(true);
|
setDescriptionError(true);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate branch selection when "other branch" is selected
|
if (useWorktrees && !useCurrentBranch && !branchName.trim()) {
|
||||||
if (useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()) {
|
|
||||||
toast.error('Please select a branch name');
|
toast.error('Please select a branch name');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const category = newFeature.category || 'Uncategorized';
|
const finalCategory = category || 'Uncategorized';
|
||||||
const selectedModel = newFeature.model;
|
const selectedModel = modelEntry.model;
|
||||||
const normalizedThinking = modelSupportsThinking(selectedModel)
|
const normalizedThinking = modelSupportsThinking(selectedModel)
|
||||||
? newFeature.thinkingLevel
|
? modelEntry.thinkingLevel || 'none'
|
||||||
: 'none';
|
: 'none';
|
||||||
const normalizedReasoning = supportsReasoningEffort(selectedModel)
|
const normalizedReasoning = supportsReasoningEffort(selectedModel)
|
||||||
? newFeature.reasoningEffort
|
? modelEntry.reasoningEffort || 'none'
|
||||||
: 'none';
|
: 'none';
|
||||||
|
|
||||||
// Use current branch if toggle is on
|
const finalBranchName = useCurrentBranch ? currentBranch || '' : branchName || '';
|
||||||
// If currentBranch is provided (non-primary worktree), use it
|
|
||||||
// Otherwise (primary worktree), use empty string which means "unassigned" (show only on primary)
|
|
||||||
const finalBranchName = useCurrentBranch ? currentBranch || '' : newFeature.branchName || '';
|
|
||||||
|
|
||||||
// Build final description - prepend ancestor context in spawn mode
|
// Build final description with ancestor context in spawn mode
|
||||||
let finalDescription = newFeature.description;
|
let finalDescription = description;
|
||||||
if (isSpawnMode && parentFeature && selectedAncestorIds.size > 0) {
|
if (isSpawnMode && parentFeature && selectedAncestorIds.size > 0) {
|
||||||
// Create parent context as an AncestorContext
|
|
||||||
const parentContext: AncestorContext = {
|
const parentContext: AncestorContext = {
|
||||||
id: parentFeature.id,
|
id: parentFeature.id,
|
||||||
title: parentFeature.title,
|
title: parentFeature.title,
|
||||||
@@ -254,93 +283,84 @@ export function AddFeatureDialog({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (contextText) {
|
if (contextText) {
|
||||||
finalDescription = `${contextText}\n\n---\n\n## Task Description\n\n${newFeature.description}`;
|
finalDescription = `${contextText}\n\n---\n\n## Task Description\n\n${description}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: newFeature.title,
|
title,
|
||||||
category,
|
category: finalCategory,
|
||||||
description: finalDescription,
|
description: finalDescription,
|
||||||
images: newFeature.images,
|
images,
|
||||||
imagePaths: newFeature.imagePaths,
|
imagePaths,
|
||||||
textFilePaths: newFeature.textFilePaths,
|
textFilePaths,
|
||||||
skipTests: newFeature.skipTests,
|
skipTests,
|
||||||
model: selectedModel,
|
model: selectedModel,
|
||||||
thinkingLevel: normalizedThinking,
|
thinkingLevel: normalizedThinking,
|
||||||
reasoningEffort: normalizedReasoning,
|
reasoningEffort: normalizedReasoning,
|
||||||
branchName: finalBranchName,
|
branchName: finalBranchName,
|
||||||
priority: newFeature.priority,
|
priority,
|
||||||
planningMode,
|
planningMode,
|
||||||
requirePlanApproval,
|
requirePlanApproval,
|
||||||
// In spawn mode, automatically add parent as dependency
|
|
||||||
dependencies: isSpawnMode && parentFeature ? [parentFeature.id] : undefined,
|
dependencies: isSpawnMode && parentFeature ? [parentFeature.id] : undefined,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setNewFeature({
|
setTitle('');
|
||||||
title: '',
|
setCategory('');
|
||||||
category: '',
|
setDescription('');
|
||||||
description: '',
|
setImages([]);
|
||||||
images: [],
|
setImagePaths([]);
|
||||||
imagePaths: [],
|
setTextFilePaths([]);
|
||||||
textFilePaths: [],
|
setSkipTests(defaultSkipTests);
|
||||||
skipTests: defaultSkipTests,
|
setBranchName('');
|
||||||
model: 'opus',
|
setPriority(2);
|
||||||
priority: 2,
|
setSelectedProfileId(undefined);
|
||||||
thinkingLevel: 'none',
|
setModelEntry({ model: 'opus' });
|
||||||
reasoningEffort: 'none',
|
|
||||||
branchName: '',
|
|
||||||
});
|
|
||||||
setUseCurrentBranch(true);
|
setUseCurrentBranch(true);
|
||||||
setPlanningMode(defaultPlanningMode);
|
setPlanningMode(defaultPlanningMode);
|
||||||
setRequirePlanApproval(defaultRequirePlanApproval);
|
setRequirePlanApproval(defaultRequirePlanApproval);
|
||||||
setNewFeaturePreviewMap(new Map());
|
setPreviewMap(new Map());
|
||||||
setShowAdvancedOptions(false);
|
|
||||||
setDescriptionError(false);
|
setDescriptionError(false);
|
||||||
|
setEnhanceOpen(false);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAction = (actionFn?: (data: FeatureData) => void) => {
|
const handleAction = (actionFn?: (data: FeatureData) => void) => {
|
||||||
if (!actionFn) return;
|
if (!actionFn) return;
|
||||||
|
|
||||||
const featureData = buildFeatureData();
|
const featureData = buildFeatureData();
|
||||||
if (!featureData) return;
|
if (!featureData) return;
|
||||||
|
|
||||||
actionFn(featureData);
|
actionFn(featureData);
|
||||||
resetForm();
|
resetForm();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAdd = () => handleAction(onAdd);
|
const handleAdd = () => handleAction(onAdd);
|
||||||
|
|
||||||
const handleAddAndStart = () => handleAction(onAddAndStart);
|
const handleAddAndStart = () => handleAction(onAddAndStart);
|
||||||
|
|
||||||
const handleDialogClose = (open: boolean) => {
|
const handleDialogClose = (open: boolean) => {
|
||||||
onOpenChange(open);
|
onOpenChange(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setNewFeaturePreviewMap(new Map());
|
setPreviewMap(new Map());
|
||||||
setShowAdvancedOptions(false);
|
|
||||||
setDescriptionError(false);
|
setDescriptionError(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEnhanceDescription = async () => {
|
const handleEnhanceDescription = async () => {
|
||||||
if (!newFeature.description.trim() || isEnhancing) return;
|
if (!description.trim() || isEnhancing) return;
|
||||||
|
|
||||||
setIsEnhancing(true);
|
setIsEnhancing(true);
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
const result = await api.enhancePrompt?.enhance(
|
const result = await api.enhancePrompt?.enhance(
|
||||||
newFeature.description,
|
description,
|
||||||
enhancementMode,
|
enhancementMode,
|
||||||
enhancementOverride.effectiveModel, // API accepts string, extract from PhaseModelEntry
|
enhancementOverride.effectiveModel,
|
||||||
enhancementOverride.effectiveModelEntry.thinkingLevel // Pass thinking level
|
enhancementOverride.effectiveModelEntry.thinkingLevel
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result?.success && result.enhancedText) {
|
if (result?.success && result.enhancedText) {
|
||||||
const enhancedText = result.enhancedText;
|
setDescription(result.enhancedText);
|
||||||
setNewFeature((prev) => ({ ...prev, description: enhancedText }));
|
|
||||||
toast.success('Description enhanced!');
|
toast.success('Description enhanced!');
|
||||||
} else {
|
} else {
|
||||||
toast.error(result?.error || 'Failed to enhance description');
|
toast.error(result?.error || 'Failed to enhance description');
|
||||||
@@ -353,59 +373,9 @@ export function AddFeatureDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleModelSelect = (model: string) => {
|
// Shared card styling
|
||||||
// For Cursor models, thinking is handled by the model itself
|
const cardClass = 'rounded-lg border border-border/50 bg-muted/30 p-4 space-y-3';
|
||||||
// For Claude models, check if it supports extended thinking
|
const sectionHeaderClass = 'flex items-center gap-2 text-sm font-medium text-foreground';
|
||||||
const isCursor = isCursorModel(model);
|
|
||||||
setNewFeature({
|
|
||||||
...newFeature,
|
|
||||||
model: model as ModelAlias,
|
|
||||||
thinkingLevel: isCursor
|
|
||||||
? 'none'
|
|
||||||
: modelSupportsThinking(model)
|
|
||||||
? newFeature.thinkingLevel
|
|
||||||
: 'none',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleProfileSelect = (profile: AIProfile) => {
|
|
||||||
if (profile.provider === 'cursor') {
|
|
||||||
// Cursor profile - set cursor model
|
|
||||||
const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
|
|
||||||
setNewFeature({
|
|
||||||
...newFeature,
|
|
||||||
model: cursorModel as ModelAlias,
|
|
||||||
thinkingLevel: 'none', // Cursor handles thinking internally
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Claude profile - ensure model is always set from profile
|
|
||||||
const profileModel = profile.model;
|
|
||||||
if (!profileModel || !['haiku', 'sonnet', 'opus'].includes(profileModel)) {
|
|
||||||
console.warn(
|
|
||||||
`[ProfileSelect] Invalid or missing model "${profileModel}" for profile "${profile.name}", defaulting to sonnet`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setNewFeature({
|
|
||||||
...newFeature,
|
|
||||||
model:
|
|
||||||
profileModel && ['haiku', 'sonnet', 'opus'].includes(profileModel)
|
|
||||||
? profileModel
|
|
||||||
: 'sonnet',
|
|
||||||
thinkingLevel:
|
|
||||||
profile.thinkingLevel && profile.thinkingLevel !== 'none'
|
|
||||||
? profile.thinkingLevel
|
|
||||||
: 'none',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cursor models handle thinking internally, so only show thinking selector for Claude models
|
|
||||||
const isCurrentModelCursor = isCursorModel(newFeature.model);
|
|
||||||
const newModelAllowsThinking =
|
|
||||||
!isCurrentModelCursor && modelSupportsThinking(newFeature.model || 'sonnet');
|
|
||||||
|
|
||||||
// Codex models that support reasoning effort - show reasoning selector
|
|
||||||
const newModelAllowsReasoning = supportsReasoningEffort(newFeature.model || '');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleDialogClose}>
|
<Dialog open={open} onOpenChange={handleDialogClose}>
|
||||||
@@ -433,239 +403,264 @@ export function AddFeatureDialog({
|
|||||||
: 'Create a new feature card for the Kanban board.'}
|
: 'Create a new feature card for the Kanban board.'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Tabs defaultValue="prompt" className="py-4 flex-1 min-h-0 flex flex-col">
|
|
||||||
<TabsList className="w-full grid grid-cols-3 mb-4">
|
|
||||||
<TabsTrigger value="prompt" data-testid="tab-prompt">
|
|
||||||
<MessageSquare className="w-4 h-4 mr-2" />
|
|
||||||
Prompt
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="model" data-testid="tab-model">
|
|
||||||
<Settings2 className="w-4 h-4 mr-2" />
|
|
||||||
Model
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="options" data-testid="tab-options">
|
|
||||||
<SlidersHorizontal className="w-4 h-4 mr-2" />
|
|
||||||
Options
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{/* Prompt Tab */}
|
<div className="py-4 space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||||
<TabsContent value="prompt" className="space-y-4 overflow-y-auto cursor-default">
|
{/* Ancestor Context Section - only in spawn mode */}
|
||||||
{/* Ancestor Context Section - only in spawn mode */}
|
{isSpawnMode && parentFeature && (
|
||||||
{isSpawnMode && parentFeature && (
|
<AncestorContextSection
|
||||||
<AncestorContextSection
|
parentFeature={{
|
||||||
parentFeature={{
|
id: parentFeature.id,
|
||||||
id: parentFeature.id,
|
title: parentFeature.title,
|
||||||
title: parentFeature.title,
|
description: parentFeature.description,
|
||||||
description: parentFeature.description,
|
spec: parentFeature.spec,
|
||||||
spec: parentFeature.spec,
|
summary: parentFeature.summary,
|
||||||
summary: parentFeature.summary,
|
}}
|
||||||
}}
|
ancestors={ancestors}
|
||||||
ancestors={ancestors}
|
selectedAncestorIds={selectedAncestorIds}
|
||||||
selectedAncestorIds={selectedAncestorIds}
|
onSelectionChange={setSelectedAncestorIds}
|
||||||
onSelectionChange={setSelectedAncestorIds}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
|
{/* Task Details Section */}
|
||||||
|
<div className={cardClass}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="description">Description</Label>
|
<Label htmlFor="description">Description</Label>
|
||||||
<DescriptionImageDropZone
|
<DescriptionImageDropZone
|
||||||
value={newFeature.description}
|
value={description}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setNewFeature({ ...newFeature, description: value });
|
setDescription(value);
|
||||||
if (value.trim()) {
|
if (value.trim()) setDescriptionError(false);
|
||||||
setDescriptionError(false);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
images={newFeature.imagePaths}
|
images={imagePaths}
|
||||||
onImagesChange={(images) => setNewFeature({ ...newFeature, imagePaths: images })}
|
onImagesChange={setImagePaths}
|
||||||
textFiles={newFeature.textFilePaths}
|
textFiles={textFilePaths}
|
||||||
onTextFilesChange={(textFiles) =>
|
onTextFilesChange={setTextFilePaths}
|
||||||
setNewFeature({ ...newFeature, textFilePaths: textFiles })
|
|
||||||
}
|
|
||||||
placeholder="Describe the feature..."
|
placeholder="Describe the feature..."
|
||||||
previewMap={newFeaturePreviewMap}
|
previewMap={previewMap}
|
||||||
onPreviewMapChange={setNewFeaturePreviewMap}
|
onPreviewMapChange={setPreviewMap}
|
||||||
autoFocus
|
autoFocus
|
||||||
error={descriptionError}
|
error={descriptionError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="title">Title (optional)</Label>
|
<Label htmlFor="title">Title (optional)</Label>
|
||||||
<Input
|
<Input
|
||||||
id="title"
|
id="title"
|
||||||
value={newFeature.title}
|
value={title}
|
||||||
onChange={(e) => setNewFeature({ ...newFeature, title: e.target.value })}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="Leave blank to auto-generate"
|
placeholder="Leave blank to auto-generate"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-fit items-center gap-3 select-none cursor-default">
|
|
||||||
<DropdownMenu>
|
{/* Collapsible Enhancement Section */}
|
||||||
<DropdownMenuTrigger asChild>
|
<Collapsible open={enhanceOpen} onOpenChange={setEnhanceOpen}>
|
||||||
<Button variant="outline" size="sm" className="w-[200px] justify-between">
|
<CollapsibleTrigger asChild>
|
||||||
{enhancementMode === 'improve' && 'Improve Clarity'}
|
<button className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full py-1">
|
||||||
{enhancementMode === 'technical' && 'Add Technical Details'}
|
{enhanceOpen ? (
|
||||||
{enhancementMode === 'simplify' && 'Simplify'}
|
<ChevronDown className="w-4 h-4" />
|
||||||
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
|
) : (
|
||||||
<ChevronDown className="w-4 h-4 ml-2" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
<span>Enhance with AI</span>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="pt-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 pl-6">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="h-8 text-xs">
|
||||||
|
{enhancementMode === 'improve' && 'Improve Clarity'}
|
||||||
|
{enhancementMode === 'technical' && 'Add Technical Details'}
|
||||||
|
{enhancementMode === 'simplify' && 'Simplify'}
|
||||||
|
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
|
||||||
|
<ChevronDown className="w-3 h-3 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
|
||||||
|
Improve Clarity
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
|
||||||
|
Add Technical Details
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
|
||||||
|
Simplify
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
|
||||||
|
Add Acceptance Criteria
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
onClick={handleEnhanceDescription}
|
||||||
|
disabled={!description.trim() || isEnhancing}
|
||||||
|
loading={isEnhancing}
|
||||||
|
>
|
||||||
|
<Sparkles className="w-3 h-3 mr-1" />
|
||||||
|
Enhance
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start">
|
|
||||||
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
|
|
||||||
Improve Clarity
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
|
|
||||||
Add Technical Details
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
|
|
||||||
Simplify
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
|
|
||||||
Add Acceptance Criteria
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<Button
|
<ModelOverrideTrigger
|
||||||
type="button"
|
currentModelEntry={enhancementOverride.effectiveModelEntry}
|
||||||
variant="outline"
|
onModelChange={enhancementOverride.setOverride}
|
||||||
size="sm"
|
phase="enhancementModel"
|
||||||
onClick={handleEnhanceDescription}
|
isOverridden={enhancementOverride.isOverridden}
|
||||||
disabled={!newFeature.description.trim() || isEnhancing}
|
size="sm"
|
||||||
loading={isEnhancing}
|
variant="icon"
|
||||||
>
|
/>
|
||||||
<Sparkles className="w-4 h-4 mr-2" />
|
|
||||||
Enhance with AI
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<ModelOverrideTrigger
|
|
||||||
currentModelEntry={enhancementOverride.effectiveModelEntry}
|
|
||||||
onModelChange={enhancementOverride.setOverride}
|
|
||||||
phase="enhancementModel"
|
|
||||||
isOverridden={enhancementOverride.isOverridden}
|
|
||||||
size="sm"
|
|
||||||
variant="icon"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="category">Category (optional)</Label>
|
|
||||||
<CategoryAutocomplete
|
|
||||||
value={newFeature.category}
|
|
||||||
onChange={(value) => setNewFeature({ ...newFeature, category: value })}
|
|
||||||
suggestions={categorySuggestions}
|
|
||||||
placeholder="e.g., Core, UI, API"
|
|
||||||
data-testid="feature-category-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{useWorktrees && (
|
|
||||||
<BranchSelector
|
|
||||||
useCurrentBranch={useCurrentBranch}
|
|
||||||
onUseCurrentBranchChange={setUseCurrentBranch}
|
|
||||||
branchName={newFeature.branchName}
|
|
||||||
onBranchNameChange={(value) => setNewFeature({ ...newFeature, branchName: value })}
|
|
||||||
branchSuggestions={branchSuggestions}
|
|
||||||
branchCardCounts={branchCardCounts}
|
|
||||||
currentBranch={currentBranch}
|
|
||||||
testIdPrefix="feature"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Priority Selector */}
|
|
||||||
<PrioritySelector
|
|
||||||
selectedPriority={newFeature.priority}
|
|
||||||
onPrioritySelect={(priority) => setNewFeature({ ...newFeature, priority })}
|
|
||||||
testIdPrefix="priority"
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Model Tab */}
|
|
||||||
<TabsContent value="model" className="space-y-4 overflow-y-auto cursor-default">
|
|
||||||
{/* Show Advanced Options Toggle */}
|
|
||||||
{showProfilesOnly && (
|
|
||||||
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-medium text-foreground">Simple Mode Active</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Only showing AI profiles. Advanced model tweaking is hidden.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
</CollapsibleContent>
|
||||||
variant="outline"
|
</Collapsible>
|
||||||
size="sm"
|
</div>
|
||||||
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
|
|
||||||
data-testid="show-advanced-options-toggle"
|
{/* AI & Execution Section */}
|
||||||
>
|
<div className={cardClass}>
|
||||||
<Settings2 className="w-4 h-4 mr-2" />
|
<div className={sectionHeaderClass}>
|
||||||
{showAdvancedOptions ? 'Hide' : 'Show'} Advanced
|
<Cpu className="w-4 h-4 text-muted-foreground" />
|
||||||
</Button>
|
<span>AI & Execution</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Profile</Label>
|
||||||
|
<ProfileTypeahead
|
||||||
|
profiles={aiProfiles}
|
||||||
|
selectedProfileId={selectedProfileId}
|
||||||
|
onSelect={handleProfileSelect}
|
||||||
|
placeholder="Select profile..."
|
||||||
|
showManageLink
|
||||||
|
onManageLinkClick={() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
navigate({ to: '/profiles' });
|
||||||
|
}}
|
||||||
|
testIdPrefix="add-feature-profile"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Model</Label>
|
||||||
|
<PhaseModelSelector
|
||||||
|
value={modelEntry}
|
||||||
|
onChange={handleModelChange}
|
||||||
|
compact
|
||||||
|
align="end"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'grid gap-3',
|
||||||
|
modelSupportsPlanningMode ? 'grid-cols-2' : 'grid-cols-1'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{modelSupportsPlanningMode && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Planning</Label>
|
||||||
|
<PlanningModeSelect
|
||||||
|
mode={planningMode}
|
||||||
|
onModeChange={setPlanningMode}
|
||||||
|
testIdPrefix="add-feature-planning"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Options</Label>
|
||||||
|
<div className="flex flex-col gap-2 pt-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="add-feature-skip-tests"
|
||||||
|
checked={!skipTests}
|
||||||
|
onCheckedChange={(checked) => setSkipTests(!checked)}
|
||||||
|
data-testid="add-feature-skip-tests-checkbox"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="add-feature-skip-tests"
|
||||||
|
className="text-xs font-normal cursor-pointer"
|
||||||
|
>
|
||||||
|
Run tests
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{modelSupportsPlanningMode && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="add-feature-require-approval"
|
||||||
|
checked={requirePlanApproval}
|
||||||
|
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
|
||||||
|
disabled={planningMode === 'skip' || planningMode === 'lite'}
|
||||||
|
data-testid="add-feature-require-approval-checkbox"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="add-feature-require-approval"
|
||||||
|
className={cn(
|
||||||
|
'text-xs font-normal',
|
||||||
|
planningMode === 'skip' || planningMode === 'lite'
|
||||||
|
? 'cursor-not-allowed text-muted-foreground'
|
||||||
|
: 'cursor-pointer'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Require approval
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Organization Section */}
|
||||||
|
<div className={cardClass}>
|
||||||
|
<div className={sectionHeaderClass}>
|
||||||
|
<FolderKanban className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span>Organization</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Category</Label>
|
||||||
|
<CategoryAutocomplete
|
||||||
|
value={category}
|
||||||
|
onChange={setCategory}
|
||||||
|
suggestions={categorySuggestions}
|
||||||
|
placeholder="e.g., Core, UI, API"
|
||||||
|
data-testid="feature-category-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Priority</Label>
|
||||||
|
<PrioritySelector
|
||||||
|
selectedPriority={priority}
|
||||||
|
onPrioritySelect={setPriority}
|
||||||
|
testIdPrefix="priority"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Branch Selector */}
|
||||||
|
{useWorktrees && (
|
||||||
|
<div className="pt-2">
|
||||||
|
<BranchSelector
|
||||||
|
useCurrentBranch={useCurrentBranch}
|
||||||
|
onUseCurrentBranchChange={setUseCurrentBranch}
|
||||||
|
branchName={branchName}
|
||||||
|
onBranchNameChange={setBranchName}
|
||||||
|
branchSuggestions={branchSuggestions}
|
||||||
|
branchCardCounts={branchCardCounts}
|
||||||
|
currentBranch={currentBranch}
|
||||||
|
testIdPrefix="feature"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Quick Select Profile Section */}
|
|
||||||
<ProfileQuickSelect
|
|
||||||
profiles={aiProfiles}
|
|
||||||
selectedModel={newFeature.model}
|
|
||||||
selectedThinkingLevel={newFeature.thinkingLevel}
|
|
||||||
selectedCursorModel={isCurrentModelCursor ? newFeature.model : undefined}
|
|
||||||
onSelect={handleProfileSelect}
|
|
||||||
showManageLink
|
|
||||||
onManageLinkClick={() => {
|
|
||||||
onOpenChange(false);
|
|
||||||
navigate({ to: '/profiles' });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Separator */}
|
|
||||||
{aiProfiles.length > 0 && (!showProfilesOnly || showAdvancedOptions) && (
|
|
||||||
<div className="border-t border-border" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Claude Models Section */}
|
|
||||||
{(!showProfilesOnly || showAdvancedOptions) && (
|
|
||||||
<>
|
|
||||||
<ModelSelector selectedModel={newFeature.model} onModelSelect={handleModelSelect} />
|
|
||||||
{newModelAllowsThinking && (
|
|
||||||
<ThinkingLevelSelector
|
|
||||||
selectedLevel={newFeature.thinkingLevel}
|
|
||||||
onLevelSelect={(level) =>
|
|
||||||
setNewFeature({ ...newFeature, thinkingLevel: level })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{newModelAllowsReasoning && (
|
|
||||||
<ReasoningEffortSelector
|
|
||||||
selectedEffort={newFeature.reasoningEffort}
|
|
||||||
onEffortSelect={(effort) =>
|
|
||||||
setNewFeature({ ...newFeature, reasoningEffort: effort })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Options Tab */}
|
|
||||||
<TabsContent value="options" className="space-y-4 overflow-y-auto cursor-default">
|
|
||||||
{/* Planning Mode Section */}
|
|
||||||
<PlanningModeSelector
|
|
||||||
mode={planningMode}
|
|
||||||
onModeChange={setPlanningMode}
|
|
||||||
requireApproval={requirePlanApproval}
|
|
||||||
onRequireApprovalChange={setRequirePlanApproval}
|
|
||||||
featureDescription={newFeature.description}
|
|
||||||
testIdPrefix="add-feature"
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="border-t border-border my-4" />
|
|
||||||
|
|
||||||
{/* Testing Section */}
|
|
||||||
<TestingTabContent
|
|
||||||
skipTests={newFeature.skipTests}
|
|
||||||
onSkipTestsChange={(skipTests) => setNewFeature({ ...newFeature, skipTests })}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -675,7 +670,7 @@ export function AddFeatureDialog({
|
|||||||
onClick={handleAddAndStart}
|
onClick={handleAddAndStart}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
data-testid="confirm-add-and-start-feature"
|
data-testid="confirm-add-and-start-feature"
|
||||||
disabled={useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()}
|
disabled={useWorktrees && !useCurrentBranch && !branchName.trim()}
|
||||||
>
|
>
|
||||||
<Play className="w-4 h-4 mr-2" />
|
<Play className="w-4 h-4 mr-2" />
|
||||||
Make
|
Make
|
||||||
@@ -686,7 +681,7 @@ export function AddFeatureDialog({
|
|||||||
hotkey={{ key: 'Enter', cmdCtrl: true }}
|
hotkey={{ key: 'Enter', cmdCtrl: true }}
|
||||||
hotkeyActive={open}
|
hotkeyActive={open}
|
||||||
data-testid="confirm-add-feature"
|
data-testid="confirm-add-feature"
|
||||||
disabled={useWorktrees && !useCurrentBranch && !newFeature.branchName.trim()}
|
disabled={useWorktrees && !useCurrentBranch && !branchName.trim()}
|
||||||
>
|
>
|
||||||
{isSpawnMode ? 'Spawn Task' : 'Add Feature'}
|
{isSpawnMode ? 'Spawn Task' : 'Add Feature'}
|
||||||
</HotkeyButton>
|
</HotkeyButton>
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
|
import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
|
||||||
import {
|
import {
|
||||||
DescriptionImageDropZone,
|
DescriptionImageDropZone,
|
||||||
@@ -21,18 +21,19 @@ import {
|
|||||||
FeatureTextFilePath as DescriptionTextFilePath,
|
FeatureTextFilePath as DescriptionTextFilePath,
|
||||||
ImagePreviewMap,
|
ImagePreviewMap,
|
||||||
} from '@/components/ui/description-image-dropzone';
|
} from '@/components/ui/description-image-dropzone';
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
import {
|
import {
|
||||||
MessageSquare,
|
|
||||||
Settings2,
|
|
||||||
SlidersHorizontal,
|
|
||||||
Sparkles,
|
Sparkles,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
History,
|
History,
|
||||||
|
Cpu,
|
||||||
|
FolderKanban,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { modelSupportsThinking } from '@/lib/utils';
|
import { cn, modelSupportsThinking } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
Feature,
|
Feature,
|
||||||
ModelAlias,
|
ModelAlias,
|
||||||
@@ -41,17 +42,15 @@ import {
|
|||||||
useAppStore,
|
useAppStore,
|
||||||
PlanningMode,
|
PlanningMode,
|
||||||
} from '@/store/app-store';
|
} from '@/store/app-store';
|
||||||
import type { ReasoningEffort } from '@automaker/types';
|
import type { ReasoningEffort, PhaseModelEntry, DescriptionHistoryEntry } from '@automaker/types';
|
||||||
import {
|
import {
|
||||||
ModelSelector,
|
|
||||||
ThinkingLevelSelector,
|
|
||||||
ReasoningEffortSelector,
|
|
||||||
ProfileQuickSelect,
|
|
||||||
TestingTabContent,
|
TestingTabContent,
|
||||||
PrioritySelector,
|
PrioritySelector,
|
||||||
BranchSelector,
|
BranchSelector,
|
||||||
PlanningModeSelector,
|
PlanningModeSelect,
|
||||||
|
ProfileTypeahead,
|
||||||
} from '../shared';
|
} from '../shared';
|
||||||
|
import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults/phase-model-selector';
|
||||||
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
|
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -60,9 +59,14 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import type { DescriptionHistoryEntry } from '@automaker/types';
|
|
||||||
import { DependencyTreeDialog } from './dependency-tree-dialog';
|
import { DependencyTreeDialog } from './dependency-tree-dialog';
|
||||||
import { isCursorModel, PROVIDER_PREFIXES, supportsReasoningEffort } from '@automaker/types';
|
import {
|
||||||
|
isCursorModel,
|
||||||
|
isClaudeModel,
|
||||||
|
PROVIDER_PREFIXES,
|
||||||
|
supportsReasoningEffort,
|
||||||
|
} from '@automaker/types';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
|
||||||
const logger = createLogger('EditFeatureDialog');
|
const logger = createLogger('EditFeatureDialog');
|
||||||
|
|
||||||
@@ -112,6 +116,7 @@ export function EditFeatureDialog({
|
|||||||
aiProfiles,
|
aiProfiles,
|
||||||
allFeatures,
|
allFeatures,
|
||||||
}: EditFeatureDialogProps) {
|
}: EditFeatureDialogProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
|
const [editingFeature, setEditingFeature] = useState<Feature | null>(feature);
|
||||||
const [useCurrentBranch, setUseCurrentBranch] = useState(() => {
|
const [useCurrentBranch, setUseCurrentBranch] = useState(() => {
|
||||||
// If feature has no branchName, default to using current branch
|
// If feature has no branchName, default to using current branch
|
||||||
@@ -120,16 +125,28 @@ export function EditFeatureDialog({
|
|||||||
const [editFeaturePreviewMap, setEditFeaturePreviewMap] = useState<ImagePreviewMap>(
|
const [editFeaturePreviewMap, setEditFeaturePreviewMap] = useState<ImagePreviewMap>(
|
||||||
() => new Map()
|
() => new Map()
|
||||||
);
|
);
|
||||||
const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false);
|
|
||||||
const [isEnhancing, setIsEnhancing] = useState(false);
|
const [isEnhancing, setIsEnhancing] = useState(false);
|
||||||
const [enhancementMode, setEnhancementMode] = useState<
|
const [enhancementMode, setEnhancementMode] = useState<
|
||||||
'improve' | 'technical' | 'simplify' | 'acceptance'
|
'improve' | 'technical' | 'simplify' | 'acceptance'
|
||||||
>('improve');
|
>('improve');
|
||||||
|
const [enhanceOpen, setEnhanceOpen] = useState(false);
|
||||||
const [showDependencyTree, setShowDependencyTree] = useState(false);
|
const [showDependencyTree, setShowDependencyTree] = useState(false);
|
||||||
const [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip');
|
const [planningMode, setPlanningMode] = useState<PlanningMode>(feature?.planningMode ?? 'skip');
|
||||||
const [requirePlanApproval, setRequirePlanApproval] = useState(
|
const [requirePlanApproval, setRequirePlanApproval] = useState(
|
||||||
feature?.requirePlanApproval ?? false
|
feature?.requirePlanApproval ?? false
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Model selection state
|
||||||
|
const [selectedProfileId, setSelectedProfileId] = useState<string | undefined>();
|
||||||
|
const [modelEntry, setModelEntry] = useState<PhaseModelEntry>(() => ({
|
||||||
|
model: (feature?.model as ModelAlias) || 'opus',
|
||||||
|
thinkingLevel: feature?.thinkingLevel || 'none',
|
||||||
|
reasoningEffort: feature?.reasoningEffort || 'none',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Check if current model supports planning mode (Claude/Anthropic only)
|
||||||
|
const modelSupportsPlanningMode = isClaudeModel(modelEntry.model);
|
||||||
|
|
||||||
// Track the source of description changes for history
|
// Track the source of description changes for history
|
||||||
const [descriptionChangeSource, setDescriptionChangeSource] = useState<
|
const [descriptionChangeSource, setDescriptionChangeSource] = useState<
|
||||||
{ source: 'enhance'; mode: 'improve' | 'technical' | 'simplify' | 'acceptance' } | 'edit' | null
|
{ source: 'enhance'; mode: 'improve' | 'technical' | 'simplify' | 'acceptance' } | 'edit' | null
|
||||||
@@ -156,14 +173,52 @@ export function EditFeatureDialog({
|
|||||||
setOriginalDescription(feature.description ?? '');
|
setOriginalDescription(feature.description ?? '');
|
||||||
setDescriptionChangeSource(null);
|
setDescriptionChangeSource(null);
|
||||||
setShowHistory(false);
|
setShowHistory(false);
|
||||||
|
setEnhanceOpen(false);
|
||||||
|
// Reset model entry
|
||||||
|
setModelEntry({
|
||||||
|
model: (feature.model as ModelAlias) || 'opus',
|
||||||
|
thinkingLevel: feature.thinkingLevel || 'none',
|
||||||
|
reasoningEffort: feature.reasoningEffort || 'none',
|
||||||
|
});
|
||||||
|
setSelectedProfileId(undefined);
|
||||||
} else {
|
} else {
|
||||||
setEditFeaturePreviewMap(new Map());
|
setEditFeaturePreviewMap(new Map());
|
||||||
setShowEditAdvancedOptions(false);
|
|
||||||
setDescriptionChangeSource(null);
|
setDescriptionChangeSource(null);
|
||||||
setShowHistory(false);
|
setShowHistory(false);
|
||||||
}
|
}
|
||||||
}, [feature]);
|
}, [feature]);
|
||||||
|
|
||||||
|
const applyProfileToModel = (profile: AIProfile) => {
|
||||||
|
if (profile.provider === 'cursor') {
|
||||||
|
const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
|
||||||
|
setModelEntry({ model: cursorModel as ModelAlias });
|
||||||
|
} else if (profile.provider === 'codex') {
|
||||||
|
setModelEntry({
|
||||||
|
model: profile.codexModel || 'codex-gpt-5.2-codex',
|
||||||
|
reasoningEffort: 'none',
|
||||||
|
});
|
||||||
|
} else if (profile.provider === 'opencode') {
|
||||||
|
setModelEntry({ model: profile.opencodeModel || 'opencode/big-pickle' });
|
||||||
|
} else {
|
||||||
|
// Claude
|
||||||
|
setModelEntry({
|
||||||
|
model: profile.model || 'sonnet',
|
||||||
|
thinkingLevel: profile.thinkingLevel || 'none',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProfileSelect = (profile: AIProfile) => {
|
||||||
|
setSelectedProfileId(profile.id);
|
||||||
|
applyProfileToModel(profile);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleModelChange = (entry: PhaseModelEntry) => {
|
||||||
|
setModelEntry(entry);
|
||||||
|
// Clear profile selection when manually changing model
|
||||||
|
setSelectedProfileId(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
const handleUpdate = () => {
|
const handleUpdate = () => {
|
||||||
if (!editingFeature) return;
|
if (!editingFeature) return;
|
||||||
|
|
||||||
@@ -179,12 +234,12 @@ export function EditFeatureDialog({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedModel = (editingFeature.model ?? 'opus') as ModelAlias;
|
const selectedModel = modelEntry.model;
|
||||||
const normalizedThinking: ThinkingLevel = modelSupportsThinking(selectedModel)
|
const normalizedThinking: ThinkingLevel = modelSupportsThinking(selectedModel)
|
||||||
? (editingFeature.thinkingLevel ?? 'none')
|
? (modelEntry.thinkingLevel ?? 'none')
|
||||||
: 'none';
|
: 'none';
|
||||||
const normalizedReasoning: ReasoningEffort = supportsReasoningEffort(selectedModel)
|
const normalizedReasoning: ReasoningEffort = supportsReasoningEffort(selectedModel)
|
||||||
? (editingFeature.reasoningEffort ?? 'none')
|
? (modelEntry.reasoningEffort ?? 'none')
|
||||||
: 'none';
|
: 'none';
|
||||||
|
|
||||||
// Use current branch if toggle is on
|
// Use current branch if toggle is on
|
||||||
@@ -226,7 +281,6 @@ export function EditFeatureDialog({
|
|||||||
|
|
||||||
onUpdate(editingFeature.id, updates, historySource, historyEnhancementMode);
|
onUpdate(editingFeature.id, updates, historySource, historyEnhancementMode);
|
||||||
setEditFeaturePreviewMap(new Map());
|
setEditFeaturePreviewMap(new Map());
|
||||||
setShowEditAdvancedOptions(false);
|
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -236,56 +290,6 @@ export function EditFeatureDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleModelSelect = (model: string) => {
|
|
||||||
if (!editingFeature) return;
|
|
||||||
// For Cursor models, thinking is handled by the model itself
|
|
||||||
// For Claude models, check if it supports extended thinking
|
|
||||||
// For Codex models, use reasoning effort instead
|
|
||||||
const isCursor = isCursorModel(model);
|
|
||||||
const supportsThinking = modelSupportsThinking(model);
|
|
||||||
const supportsReasoning = supportsReasoningEffort(model);
|
|
||||||
|
|
||||||
setEditingFeature({
|
|
||||||
...editingFeature,
|
|
||||||
model: model as ModelAlias,
|
|
||||||
thinkingLevel:
|
|
||||||
isCursor || !supportsThinking ? 'none' : (editingFeature.thinkingLevel ?? 'none'),
|
|
||||||
reasoningEffort: !supportsReasoning ? 'none' : (editingFeature.reasoningEffort ?? 'none'),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleProfileSelect = (profile: AIProfile) => {
|
|
||||||
if (!editingFeature) return;
|
|
||||||
if (profile.provider === 'cursor') {
|
|
||||||
// Cursor profile - set cursor model
|
|
||||||
const cursorModel = `${PROVIDER_PREFIXES.cursor}${profile.cursorModel || 'auto'}`;
|
|
||||||
setEditingFeature({
|
|
||||||
...editingFeature,
|
|
||||||
model: cursorModel as ModelAlias,
|
|
||||||
thinkingLevel: 'none', // Cursor handles thinking internally
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Claude profile - ensure model is always set from profile
|
|
||||||
const profileModel = profile.model;
|
|
||||||
if (!profileModel || !['haiku', 'sonnet', 'opus'].includes(profileModel)) {
|
|
||||||
console.warn(
|
|
||||||
`[ProfileSelect] Invalid or missing model "${profileModel}" for profile "${profile.name}", defaulting to sonnet`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setEditingFeature({
|
|
||||||
...editingFeature,
|
|
||||||
model:
|
|
||||||
profileModel && ['haiku', 'sonnet', 'opus'].includes(profileModel)
|
|
||||||
? profileModel
|
|
||||||
: 'sonnet',
|
|
||||||
thinkingLevel:
|
|
||||||
profile.thinkingLevel && profile.thinkingLevel !== 'none'
|
|
||||||
? profile.thinkingLevel
|
|
||||||
: 'none',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEnhanceDescription = async () => {
|
const handleEnhanceDescription = async () => {
|
||||||
if (!editingFeature?.description.trim() || isEnhancing) return;
|
if (!editingFeature?.description.trim() || isEnhancing) return;
|
||||||
|
|
||||||
@@ -316,18 +320,14 @@ export function EditFeatureDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cursor models handle thinking internally, so only show thinking selector for Claude models
|
|
||||||
const isCurrentModelCursor = isCursorModel(editingFeature?.model as string);
|
|
||||||
const editModelAllowsThinking =
|
|
||||||
!isCurrentModelCursor && modelSupportsThinking(editingFeature?.model);
|
|
||||||
|
|
||||||
// Codex models that support reasoning effort - show reasoning selector
|
|
||||||
const editModelAllowsReasoning = supportsReasoningEffort(editingFeature?.model || '');
|
|
||||||
|
|
||||||
if (!editingFeature) {
|
if (!editingFeature) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared card styling
|
||||||
|
const cardClass = 'rounded-lg border border-border/50 bg-muted/30 p-4 space-y-3';
|
||||||
|
const sectionHeaderClass = 'flex items-center gap-2 text-sm font-medium text-foreground';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={!!editingFeature} onOpenChange={handleDialogClose}>
|
<Dialog open={!!editingFeature} onOpenChange={handleDialogClose}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
@@ -350,26 +350,93 @@ export function EditFeatureDialog({
|
|||||||
<DialogTitle>Edit Feature</DialogTitle>
|
<DialogTitle>Edit Feature</DialogTitle>
|
||||||
<DialogDescription>Modify the feature details.</DialogDescription>
|
<DialogDescription>Modify the feature details.</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Tabs defaultValue="prompt" className="py-4 flex-1 min-h-0 flex flex-col">
|
|
||||||
<TabsList className="w-full grid grid-cols-3 mb-4">
|
|
||||||
<TabsTrigger value="prompt" data-testid="edit-tab-prompt">
|
|
||||||
<MessageSquare className="w-4 h-4 mr-2" />
|
|
||||||
Prompt
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="model" data-testid="edit-tab-model">
|
|
||||||
<Settings2 className="w-4 h-4 mr-2" />
|
|
||||||
Model
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="options" data-testid="edit-tab-options">
|
|
||||||
<SlidersHorizontal className="w-4 h-4 mr-2" />
|
|
||||||
Options
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{/* Prompt Tab */}
|
<div className="py-4 space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||||
<TabsContent value="prompt" className="space-y-4 overflow-y-auto cursor-default">
|
{/* Task Details Section */}
|
||||||
|
<div className={cardClass}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="edit-description">Description</Label>
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="edit-description">Description</Label>
|
||||||
|
{/* Version History Button */}
|
||||||
|
{feature?.descriptionHistory && feature.descriptionHistory.length > 0 && (
|
||||||
|
<Popover open={showHistory} onOpenChange={setShowHistory}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1.5 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<History className="w-3.5 h-3.5" />
|
||||||
|
History ({feature.descriptionHistory.length})
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80 p-0" align="end">
|
||||||
|
<div className="p-3 border-b">
|
||||||
|
<h4 className="font-medium text-sm">Version History</h4>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Click a version to restore it
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
|
||||||
|
{[...(feature.descriptionHistory || [])]
|
||||||
|
.reverse()
|
||||||
|
.map((entry: DescriptionHistoryEntry, index: number) => {
|
||||||
|
const isCurrentVersion =
|
||||||
|
entry.description === editingFeature.description;
|
||||||
|
const date = new Date(entry.timestamp);
|
||||||
|
const formattedDate = date.toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
const sourceLabel =
|
||||||
|
entry.source === 'initial'
|
||||||
|
? 'Original'
|
||||||
|
: entry.source === 'enhance'
|
||||||
|
? `Enhanced (${entry.enhancementMode || 'improve'})`
|
||||||
|
: 'Edited';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${entry.timestamp}-${index}`}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingFeature((prev) =>
|
||||||
|
prev ? { ...prev, description: entry.description } : prev
|
||||||
|
);
|
||||||
|
// Mark as edit since user is restoring from history
|
||||||
|
setDescriptionChangeSource('edit');
|
||||||
|
setShowHistory(false);
|
||||||
|
toast.success('Description restored from history');
|
||||||
|
}}
|
||||||
|
className={`w-full text-left p-2 rounded-md hover:bg-muted transition-colors ${
|
||||||
|
isCurrentVersion ? 'bg-muted/50 border border-primary/20' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs font-medium">{sourceLabel}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formattedDate}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||||
|
{entry.description.slice(0, 100)}
|
||||||
|
{entry.description.length > 100 ? '...' : ''}
|
||||||
|
</p>
|
||||||
|
{isCurrentVersion && (
|
||||||
|
<span className="text-xs text-primary font-medium mt-1 block">
|
||||||
|
Current version
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<DescriptionImageDropZone
|
<DescriptionImageDropZone
|
||||||
value={editingFeature.description}
|
value={editingFeature.description}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
@@ -402,6 +469,7 @@ export function EditFeatureDialog({
|
|||||||
data-testid="edit-feature-description"
|
data-testid="edit-feature-description"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="edit-title">Title (optional)</Label>
|
<Label htmlFor="edit-title">Title (optional)</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -417,274 +485,233 @@ export function EditFeatureDialog({
|
|||||||
data-testid="edit-feature-title"
|
data-testid="edit-feature-title"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-fit items-center gap-3 select-none cursor-default">
|
|
||||||
<DropdownMenu>
|
{/* Collapsible Enhancement Section */}
|
||||||
<DropdownMenuTrigger asChild>
|
<Collapsible open={enhanceOpen} onOpenChange={setEnhanceOpen}>
|
||||||
<Button variant="outline" size="sm" className="w-[180px] justify-between">
|
<CollapsibleTrigger asChild>
|
||||||
{enhancementMode === 'improve' && 'Improve Clarity'}
|
<button className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full py-1">
|
||||||
{enhancementMode === 'technical' && 'Add Technical Details'}
|
{enhanceOpen ? (
|
||||||
{enhancementMode === 'simplify' && 'Simplify'}
|
<ChevronDown className="w-4 h-4" />
|
||||||
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
|
) : (
|
||||||
<ChevronDown className="w-4 h-4 ml-2" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
<span>Enhance with AI</span>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="pt-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 pl-6">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="h-8 text-xs">
|
||||||
|
{enhancementMode === 'improve' && 'Improve Clarity'}
|
||||||
|
{enhancementMode === 'technical' && 'Add Technical Details'}
|
||||||
|
{enhancementMode === 'simplify' && 'Simplify'}
|
||||||
|
{enhancementMode === 'acceptance' && 'Add Acceptance Criteria'}
|
||||||
|
<ChevronDown className="w-3 h-3 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
|
||||||
|
Improve Clarity
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
|
||||||
|
Add Technical Details
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
|
||||||
|
Simplify
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
|
||||||
|
Add Acceptance Criteria
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
onClick={handleEnhanceDescription}
|
||||||
|
disabled={!editingFeature.description.trim() || isEnhancing}
|
||||||
|
loading={isEnhancing}
|
||||||
|
>
|
||||||
|
<Sparkles className="w-3 h-3 mr-1" />
|
||||||
|
Enhance
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start">
|
|
||||||
<DropdownMenuItem onClick={() => setEnhancementMode('improve')}>
|
|
||||||
Improve Clarity
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setEnhancementMode('technical')}>
|
|
||||||
Add Technical Details
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setEnhancementMode('simplify')}>
|
|
||||||
Simplify
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setEnhancementMode('acceptance')}>
|
|
||||||
Add Acceptance Criteria
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<Button
|
<ModelOverrideTrigger
|
||||||
type="button"
|
currentModelEntry={enhancementOverride.effectiveModelEntry}
|
||||||
variant="outline"
|
onModelChange={enhancementOverride.setOverride}
|
||||||
size="sm"
|
phase="enhancementModel"
|
||||||
onClick={handleEnhanceDescription}
|
isOverridden={enhancementOverride.isOverridden}
|
||||||
disabled={!editingFeature.description.trim() || isEnhancing}
|
size="sm"
|
||||||
loading={isEnhancing}
|
variant="icon"
|
||||||
>
|
/>
|
||||||
<Sparkles className="w-4 h-4 mr-2" />
|
|
||||||
Enhance with AI
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<ModelOverrideTrigger
|
|
||||||
currentModelEntry={enhancementOverride.effectiveModelEntry}
|
|
||||||
onModelChange={enhancementOverride.setOverride}
|
|
||||||
phase="enhancementModel"
|
|
||||||
isOverridden={enhancementOverride.isOverridden}
|
|
||||||
size="sm"
|
|
||||||
variant="icon"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Version History Button */}
|
|
||||||
{feature?.descriptionHistory && feature.descriptionHistory.length > 0 && (
|
|
||||||
<Popover open={showHistory} onOpenChange={setShowHistory}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button type="button" variant="outline" size="sm" className="gap-2">
|
|
||||||
<History className="w-4 h-4" />
|
|
||||||
History ({feature.descriptionHistory.length})
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-80 p-0" align="start">
|
|
||||||
<div className="p-3 border-b">
|
|
||||||
<h4 className="font-medium text-sm">Version History</h4>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Click a version to restore it
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
|
|
||||||
{[...(feature.descriptionHistory || [])]
|
|
||||||
.reverse()
|
|
||||||
.map((entry: DescriptionHistoryEntry, index: number) => {
|
|
||||||
const isCurrentVersion = entry.description === editingFeature.description;
|
|
||||||
const date = new Date(entry.timestamp);
|
|
||||||
const formattedDate = date.toLocaleDateString(undefined, {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
const sourceLabel =
|
|
||||||
entry.source === 'initial'
|
|
||||||
? 'Original'
|
|
||||||
: entry.source === 'enhance'
|
|
||||||
? `Enhanced (${entry.enhancementMode || 'improve'})`
|
|
||||||
: 'Edited';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={`${entry.timestamp}-${index}`}
|
|
||||||
onClick={() => {
|
|
||||||
setEditingFeature((prev) =>
|
|
||||||
prev ? { ...prev, description: entry.description } : prev
|
|
||||||
);
|
|
||||||
// Mark as edit since user is restoring from history
|
|
||||||
setDescriptionChangeSource('edit');
|
|
||||||
setShowHistory(false);
|
|
||||||
toast.success('Description restored from history');
|
|
||||||
}}
|
|
||||||
className={`w-full text-left p-2 rounded-md hover:bg-muted transition-colors ${
|
|
||||||
isCurrentVersion ? 'bg-muted/50 border border-primary/20' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-xs font-medium">{sourceLabel}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formattedDate}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
|
||||||
{entry.description.slice(0, 100)}
|
|
||||||
{entry.description.length > 100 ? '...' : ''}
|
|
||||||
</p>
|
|
||||||
{isCurrentVersion && (
|
|
||||||
<span className="text-xs text-primary font-medium mt-1 block">
|
|
||||||
Current version
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="edit-category">Category (optional)</Label>
|
|
||||||
<CategoryAutocomplete
|
|
||||||
value={editingFeature.category}
|
|
||||||
onChange={(value) =>
|
|
||||||
setEditingFeature({
|
|
||||||
...editingFeature,
|
|
||||||
category: value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
suggestions={categorySuggestions}
|
|
||||||
placeholder="e.g., Core, UI, API"
|
|
||||||
data-testid="edit-feature-category"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{useWorktrees && (
|
|
||||||
<BranchSelector
|
|
||||||
useCurrentBranch={useCurrentBranch}
|
|
||||||
onUseCurrentBranchChange={setUseCurrentBranch}
|
|
||||||
branchName={editingFeature.branchName ?? ''}
|
|
||||||
onBranchNameChange={(value) =>
|
|
||||||
setEditingFeature({
|
|
||||||
...editingFeature,
|
|
||||||
branchName: value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
branchSuggestions={branchSuggestions}
|
|
||||||
branchCardCounts={branchCardCounts}
|
|
||||||
currentBranch={currentBranch}
|
|
||||||
disabled={editingFeature.status !== 'backlog'}
|
|
||||||
testIdPrefix="edit-feature"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Priority Selector */}
|
|
||||||
<PrioritySelector
|
|
||||||
selectedPriority={editingFeature.priority ?? 2}
|
|
||||||
onPrioritySelect={(priority) =>
|
|
||||||
setEditingFeature({
|
|
||||||
...editingFeature,
|
|
||||||
priority,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
testIdPrefix="edit-priority"
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Model Tab */}
|
|
||||||
<TabsContent value="model" className="space-y-4 overflow-y-auto cursor-default">
|
|
||||||
{/* Show Advanced Options Toggle */}
|
|
||||||
{showProfilesOnly && (
|
|
||||||
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg border border-border">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-medium text-foreground">Simple Mode Active</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Only showing AI profiles. Advanced model tweaking is hidden.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
</CollapsibleContent>
|
||||||
variant="outline"
|
</Collapsible>
|
||||||
size="sm"
|
</div>
|
||||||
onClick={() => setShowEditAdvancedOptions(!showEditAdvancedOptions)}
|
|
||||||
data-testid="edit-show-advanced-options-toggle"
|
{/* AI & Execution Section */}
|
||||||
>
|
<div className={cardClass}>
|
||||||
<Settings2 className="w-4 h-4 mr-2" />
|
<div className={sectionHeaderClass}>
|
||||||
{showEditAdvancedOptions ? 'Hide' : 'Show'} Advanced
|
<Cpu className="w-4 h-4 text-muted-foreground" />
|
||||||
</Button>
|
<span>AI & Execution</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Profile</Label>
|
||||||
|
<ProfileTypeahead
|
||||||
|
profiles={aiProfiles}
|
||||||
|
selectedProfileId={selectedProfileId}
|
||||||
|
onSelect={handleProfileSelect}
|
||||||
|
placeholder="Select profile..."
|
||||||
|
showManageLink
|
||||||
|
onManageLinkClick={() => {
|
||||||
|
onClose();
|
||||||
|
navigate({ to: '/profiles' });
|
||||||
|
}}
|
||||||
|
testIdPrefix="edit-feature-profile"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Model</Label>
|
||||||
|
<PhaseModelSelector
|
||||||
|
value={modelEntry}
|
||||||
|
onChange={handleModelChange}
|
||||||
|
compact
|
||||||
|
align="end"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'grid gap-3',
|
||||||
|
modelSupportsPlanningMode ? 'grid-cols-2' : 'grid-cols-1'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{modelSupportsPlanningMode && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Planning</Label>
|
||||||
|
<PlanningModeSelect
|
||||||
|
mode={planningMode}
|
||||||
|
onModeChange={setPlanningMode}
|
||||||
|
testIdPrefix="edit-feature-planning"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Options</Label>
|
||||||
|
<div className="flex flex-col gap-2 pt-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="edit-feature-skip-tests"
|
||||||
|
checked={!(editingFeature.skipTests ?? false)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setEditingFeature({ ...editingFeature, skipTests: !checked })
|
||||||
|
}
|
||||||
|
data-testid="edit-feature-skip-tests-checkbox"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="edit-feature-skip-tests"
|
||||||
|
className="text-xs font-normal cursor-pointer"
|
||||||
|
>
|
||||||
|
Run tests
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{modelSupportsPlanningMode && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="edit-feature-require-approval"
|
||||||
|
checked={requirePlanApproval}
|
||||||
|
onCheckedChange={(checked) => setRequirePlanApproval(!!checked)}
|
||||||
|
disabled={planningMode === 'skip' || planningMode === 'lite'}
|
||||||
|
data-testid="edit-feature-require-approval-checkbox"
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="edit-feature-require-approval"
|
||||||
|
className={cn(
|
||||||
|
'text-xs font-normal',
|
||||||
|
planningMode === 'skip' || planningMode === 'lite'
|
||||||
|
? 'cursor-not-allowed text-muted-foreground'
|
||||||
|
: 'cursor-pointer'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Require approval
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Organization Section */}
|
||||||
|
<div className={cardClass}>
|
||||||
|
<div className={sectionHeaderClass}>
|
||||||
|
<FolderKanban className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span>Organization</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Category</Label>
|
||||||
|
<CategoryAutocomplete
|
||||||
|
value={editingFeature.category}
|
||||||
|
onChange={(value) =>
|
||||||
|
setEditingFeature({
|
||||||
|
...editingFeature,
|
||||||
|
category: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
suggestions={categorySuggestions}
|
||||||
|
placeholder="e.g., Core, UI, API"
|
||||||
|
data-testid="edit-feature-category"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Priority</Label>
|
||||||
|
<PrioritySelector
|
||||||
|
selectedPriority={editingFeature.priority ?? 2}
|
||||||
|
onPrioritySelect={(priority) =>
|
||||||
|
setEditingFeature({
|
||||||
|
...editingFeature,
|
||||||
|
priority,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
testIdPrefix="edit-priority"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Branch Selector */}
|
||||||
|
{useWorktrees && (
|
||||||
|
<div className="pt-2">
|
||||||
|
<BranchSelector
|
||||||
|
useCurrentBranch={useCurrentBranch}
|
||||||
|
onUseCurrentBranchChange={setUseCurrentBranch}
|
||||||
|
branchName={editingFeature.branchName ?? ''}
|
||||||
|
onBranchNameChange={(value) =>
|
||||||
|
setEditingFeature({
|
||||||
|
...editingFeature,
|
||||||
|
branchName: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
branchSuggestions={branchSuggestions}
|
||||||
|
branchCardCounts={branchCardCounts}
|
||||||
|
currentBranch={currentBranch}
|
||||||
|
disabled={editingFeature.status !== 'backlog'}
|
||||||
|
testIdPrefix="edit-feature"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Quick Select Profile Section */}
|
|
||||||
<ProfileQuickSelect
|
|
||||||
profiles={aiProfiles}
|
|
||||||
selectedModel={editingFeature.model ?? 'opus'}
|
|
||||||
selectedThinkingLevel={editingFeature.thinkingLevel ?? 'none'}
|
|
||||||
selectedCursorModel={
|
|
||||||
isCurrentModelCursor ? (editingFeature.model as string) : undefined
|
|
||||||
}
|
|
||||||
onSelect={handleProfileSelect}
|
|
||||||
testIdPrefix="edit-profile-quick-select"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Separator */}
|
|
||||||
{aiProfiles.length > 0 && (!showProfilesOnly || showEditAdvancedOptions) && (
|
|
||||||
<div className="border-t border-border" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Claude Models Section */}
|
|
||||||
{(!showProfilesOnly || showEditAdvancedOptions) && (
|
|
||||||
<>
|
|
||||||
<ModelSelector
|
|
||||||
selectedModel={(editingFeature.model ?? 'opus') as ModelAlias}
|
|
||||||
onModelSelect={handleModelSelect}
|
|
||||||
testIdPrefix="edit-model-select"
|
|
||||||
/>
|
|
||||||
{editModelAllowsThinking && (
|
|
||||||
<ThinkingLevelSelector
|
|
||||||
selectedLevel={editingFeature.thinkingLevel ?? 'none'}
|
|
||||||
onLevelSelect={(level) =>
|
|
||||||
setEditingFeature({
|
|
||||||
...editingFeature,
|
|
||||||
thinkingLevel: level,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
testIdPrefix="edit-thinking-level"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{editModelAllowsReasoning && (
|
|
||||||
<ReasoningEffortSelector
|
|
||||||
selectedEffort={editingFeature.reasoningEffort ?? 'none'}
|
|
||||||
onEffortSelect={(effort) =>
|
|
||||||
setEditingFeature({
|
|
||||||
...editingFeature,
|
|
||||||
reasoningEffort: effort,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
testIdPrefix="edit-reasoning-effort"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Options Tab */}
|
|
||||||
<TabsContent value="options" className="space-y-4 overflow-y-auto cursor-default">
|
|
||||||
{/* Planning Mode Section */}
|
|
||||||
<PlanningModeSelector
|
|
||||||
mode={planningMode}
|
|
||||||
onModeChange={setPlanningMode}
|
|
||||||
requireApproval={requirePlanApproval}
|
|
||||||
onRequireApprovalChange={setRequirePlanApproval}
|
|
||||||
featureDescription={editingFeature.description}
|
|
||||||
testIdPrefix="edit-feature"
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="border-t border-border my-4" />
|
|
||||||
|
|
||||||
{/* Testing Section */}
|
|
||||||
<TestingTabContent
|
|
||||||
skipTests={editingFeature.skipTests ?? false}
|
|
||||||
onSkipTestsChange={(skipTests) => setEditingFeature({ ...editingFeature, skipTests })}
|
|
||||||
testIdPrefix="edit"
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
<DialogFooter className="sm:!justify-between">
|
<DialogFooter className="sm:!justify-between">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export * from './thinking-level-selector';
|
|||||||
export * from './reasoning-effort-selector';
|
export * from './reasoning-effort-selector';
|
||||||
export * from './profile-quick-select';
|
export * from './profile-quick-select';
|
||||||
export * from './profile-select';
|
export * from './profile-select';
|
||||||
|
export * from './profile-typeahead';
|
||||||
export * from './testing-tab-content';
|
export * from './testing-tab-content';
|
||||||
export * from './priority-selector';
|
export * from './priority-selector';
|
||||||
export * from './priority-select';
|
export * from './priority-select';
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import type { ModelAlias } from '@/store/app-store';
|
import type { ModelAlias } from '@/store/app-store';
|
||||||
import type { ModelProvider, ThinkingLevel, ReasoningEffort } from '@automaker/types';
|
import type { ModelProvider, ThinkingLevel, ReasoningEffort } from '@automaker/types';
|
||||||
import { CURSOR_MODEL_MAP, CODEX_MODEL_MAP } from '@automaker/types';
|
import {
|
||||||
|
CURSOR_MODEL_MAP,
|
||||||
|
CODEX_MODEL_MAP,
|
||||||
|
OPENCODE_MODELS as OPENCODE_MODEL_CONFIGS,
|
||||||
|
} 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 } from '@/components/ui/provider-icon';
|
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
|
||||||
|
|
||||||
export type ModelOption = {
|
export type ModelOption = {
|
||||||
id: string; // Claude models use ModelAlias, Cursor models use "cursor-{id}"
|
id: string; // Claude models use ModelAlias, Cursor models use "cursor-{id}"
|
||||||
@@ -99,9 +103,25 @@ export const CODEX_MODELS: ModelOption[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All available models (Claude + Cursor + Codex)
|
* OpenCode models derived from OPENCODE_MODEL_CONFIGS
|
||||||
*/
|
*/
|
||||||
export const ALL_MODELS: ModelOption[] = [...CLAUDE_MODELS, ...CURSOR_MODELS, ...CODEX_MODELS];
|
export const OPENCODE_MODELS: ModelOption[] = OPENCODE_MODEL_CONFIGS.map((config) => ({
|
||||||
|
id: config.id,
|
||||||
|
label: config.label,
|
||||||
|
description: config.description,
|
||||||
|
badge: config.tier === 'free' ? 'Free' : config.tier === 'premium' ? 'Premium' : undefined,
|
||||||
|
provider: 'opencode' as ModelProvider,
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All available models (Claude + Cursor + Codex + OpenCode)
|
||||||
|
*/
|
||||||
|
export const ALL_MODELS: ModelOption[] = [
|
||||||
|
...CLAUDE_MODELS,
|
||||||
|
...CURSOR_MODELS,
|
||||||
|
...CODEX_MODELS,
|
||||||
|
...OPENCODE_MODELS,
|
||||||
|
];
|
||||||
|
|
||||||
export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink'];
|
export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink'];
|
||||||
|
|
||||||
@@ -146,4 +166,5 @@ export const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: str
|
|||||||
Anthropic: AnthropicIcon,
|
Anthropic: AnthropicIcon,
|
||||||
Cursor: CursorIcon,
|
Cursor: CursorIcon,
|
||||||
Codex: OpenAIIcon,
|
Codex: OpenAIIcon,
|
||||||
|
OpenCode: OpenCodeIcon,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ interface PlanningModeSelectProps {
|
|||||||
testIdPrefix?: string;
|
testIdPrefix?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** If true, only renders the dropdown without description or checkbox */
|
||||||
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modes = [
|
const modes = [
|
||||||
@@ -81,47 +83,58 @@ export function PlanningModeSelect({
|
|||||||
testIdPrefix = 'planning-mode',
|
testIdPrefix = 'planning-mode',
|
||||||
className,
|
className,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
compact = false,
|
||||||
}: PlanningModeSelectProps) {
|
}: PlanningModeSelectProps) {
|
||||||
const selectedMode = modes.find((m) => m.value === mode);
|
const selectedMode = modes.find((m) => m.value === mode);
|
||||||
|
|
||||||
// Disable approval checkbox for skip/lite modes since they don't use planning
|
// Disable approval checkbox for skip/lite modes since they don't use planning
|
||||||
const isApprovalDisabled = disabled || mode === 'skip' || mode === 'lite';
|
const isApprovalDisabled = disabled || mode === 'skip' || mode === 'lite';
|
||||||
|
|
||||||
|
const selectDropdown = (
|
||||||
|
<Select
|
||||||
|
value={mode}
|
||||||
|
onValueChange={(value: string) => onModeChange(value as PlanningMode)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9" data-testid={`${testIdPrefix}-select-trigger`}>
|
||||||
|
<SelectValue>
|
||||||
|
{selectedMode && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<selectedMode.icon className={cn('h-4 w-4', selectedMode.color)} />
|
||||||
|
<span>{selectedMode.label}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{modes.map((m) => {
|
||||||
|
const Icon = m.icon;
|
||||||
|
return (
|
||||||
|
<SelectItem
|
||||||
|
key={m.value}
|
||||||
|
value={m.value}
|
||||||
|
data-testid={`${testIdPrefix}-option-${m.value}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className={cn('h-3.5 w-3.5', m.color)} />
|
||||||
|
<span>{m.label}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Compact mode - just the dropdown
|
||||||
|
if (compact) {
|
||||||
|
return <div className={className}>{selectDropdown}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full mode with description and optional checkbox
|
||||||
return (
|
return (
|
||||||
<div className={cn('space-y-2', className)}>
|
<div className={cn('space-y-2', className)}>
|
||||||
<Select
|
{selectDropdown}
|
||||||
value={mode}
|
|
||||||
onValueChange={(value: string) => onModeChange(value as PlanningMode)}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-9" data-testid={`${testIdPrefix}-select-trigger`}>
|
|
||||||
<SelectValue>
|
|
||||||
{selectedMode && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<selectedMode.icon className={cn('h-4 w-4', selectedMode.color)} />
|
|
||||||
<span>{selectedMode.label}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{modes.map((m) => {
|
|
||||||
const Icon = m.icon;
|
|
||||||
return (
|
|
||||||
<SelectItem
|
|
||||||
key={m.value}
|
|
||||||
value={m.value}
|
|
||||||
data-testid={`${testIdPrefix}-option-${m.value}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Icon className={cn('h-3.5 w-3.5', m.color)} />
|
|
||||||
<span>{m.label}</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{selectedMode && <p className="text-xs text-muted-foreground">{selectedMode.description}</p>}
|
{selectedMode && <p className="text-xs text-muted-foreground">{selectedMode.description}</p>}
|
||||||
{onRequireApprovalChange && (
|
{onRequireApprovalChange && (
|
||||||
<div className="flex items-center gap-2 pt-1">
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface PrioritySelectorProps {
|
interface PrioritySelectorProps {
|
||||||
@@ -13,49 +12,46 @@ export function PrioritySelector({
|
|||||||
testIdPrefix = 'priority',
|
testIdPrefix = 'priority',
|
||||||
}: PrioritySelectorProps) {
|
}: PrioritySelectorProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="flex gap-2">
|
||||||
<Label>Priority</Label>
|
<button
|
||||||
<div className="flex gap-2">
|
type="button"
|
||||||
<button
|
onClick={() => onPrioritySelect(1)}
|
||||||
type="button"
|
className={cn(
|
||||||
onClick={() => onPrioritySelect(1)}
|
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||||
className={cn(
|
selectedPriority === 1
|
||||||
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
? 'bg-red-500/20 text-red-500 border-2 border-red-500/50'
|
||||||
selectedPriority === 1
|
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
|
||||||
? 'bg-red-500/20 text-red-500 border-2 border-red-500/50'
|
)}
|
||||||
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
|
data-testid={`${testIdPrefix}-high-button`}
|
||||||
)}
|
>
|
||||||
data-testid={`${testIdPrefix}-high-button`}
|
High
|
||||||
>
|
</button>
|
||||||
High
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<button
|
onClick={() => onPrioritySelect(2)}
|
||||||
type="button"
|
className={cn(
|
||||||
onClick={() => onPrioritySelect(2)}
|
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||||
className={cn(
|
selectedPriority === 2
|
||||||
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
? 'bg-yellow-500/20 text-yellow-500 border-2 border-yellow-500/50'
|
||||||
selectedPriority === 2
|
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
|
||||||
? 'bg-yellow-500/20 text-yellow-500 border-2 border-yellow-500/50'
|
)}
|
||||||
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
|
data-testid={`${testIdPrefix}-medium-button`}
|
||||||
)}
|
>
|
||||||
data-testid={`${testIdPrefix}-medium-button`}
|
Medium
|
||||||
>
|
</button>
|
||||||
Medium
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<button
|
onClick={() => onPrioritySelect(3)}
|
||||||
type="button"
|
className={cn(
|
||||||
onClick={() => onPrioritySelect(3)}
|
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||||
className={cn(
|
selectedPriority === 3
|
||||||
'flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
? 'bg-blue-500/20 text-blue-500 border-2 border-blue-500/50'
|
||||||
selectedPriority === 3
|
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
|
||||||
? 'bg-blue-500/20 text-blue-500 border-2 border-blue-500/50'
|
)}
|
||||||
: 'bg-muted/50 text-muted-foreground border border-border hover:bg-muted'
|
data-testid={`${testIdPrefix}-low-button`}
|
||||||
)}
|
>
|
||||||
data-testid={`${testIdPrefix}-low-button`}
|
Low
|
||||||
>
|
</button>
|
||||||
Low
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Check, ChevronsUpDown, UserCircle, Settings2 } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandSeparator,
|
||||||
|
} from '@/components/ui/command';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import type { AIProfile } from '@automaker/types';
|
||||||
|
import { CURSOR_MODEL_MAP, profileHasThinking, getCodexModelLabel } from '@automaker/types';
|
||||||
|
import { PROVIDER_ICON_COMPONENTS } from '@/components/ui/provider-icon';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get display string for a profile's model configuration
|
||||||
|
*/
|
||||||
|
function getProfileModelDisplay(profile: AIProfile): string {
|
||||||
|
if (profile.provider === 'cursor') {
|
||||||
|
const cursorModel = profile.cursorModel || 'auto';
|
||||||
|
const modelConfig = CURSOR_MODEL_MAP[cursorModel];
|
||||||
|
return modelConfig?.label || cursorModel;
|
||||||
|
}
|
||||||
|
if (profile.provider === 'codex') {
|
||||||
|
return getCodexModelLabel(profile.codexModel || 'codex-gpt-5.2-codex');
|
||||||
|
}
|
||||||
|
if (profile.provider === 'opencode') {
|
||||||
|
// Extract a short label from the opencode model
|
||||||
|
const modelId = profile.opencodeModel || '';
|
||||||
|
if (modelId.includes('/')) {
|
||||||
|
const parts = modelId.split('/');
|
||||||
|
return parts[parts.length - 1].split('.')[0] || modelId;
|
||||||
|
}
|
||||||
|
return modelId;
|
||||||
|
}
|
||||||
|
// Claude
|
||||||
|
return profile.model || 'sonnet';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get display string for a profile's thinking configuration
|
||||||
|
*/
|
||||||
|
function getProfileThinkingDisplay(profile: AIProfile): string | null {
|
||||||
|
if (profile.provider === 'cursor' || profile.provider === 'codex') {
|
||||||
|
return profileHasThinking(profile) ? 'thinking' : null;
|
||||||
|
}
|
||||||
|
// Claude
|
||||||
|
return profile.thinkingLevel && profile.thinkingLevel !== 'none' ? profile.thinkingLevel : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileTypeaheadProps {
|
||||||
|
profiles: AIProfile[];
|
||||||
|
selectedProfileId?: string;
|
||||||
|
onSelect: (profile: AIProfile) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
showManageLink?: boolean;
|
||||||
|
onManageLinkClick?: () => void;
|
||||||
|
testIdPrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileTypeahead({
|
||||||
|
profiles,
|
||||||
|
selectedProfileId,
|
||||||
|
onSelect,
|
||||||
|
placeholder = 'Select profile...',
|
||||||
|
className,
|
||||||
|
disabled = false,
|
||||||
|
showManageLink = false,
|
||||||
|
onManageLinkClick,
|
||||||
|
testIdPrefix = 'profile-typeahead',
|
||||||
|
}: ProfileTypeaheadProps) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [inputValue, setInputValue] = React.useState('');
|
||||||
|
const [triggerWidth, setTriggerWidth] = React.useState<number>(0);
|
||||||
|
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
const selectedProfile = React.useMemo(
|
||||||
|
() => profiles.find((p) => p.id === selectedProfileId),
|
||||||
|
[profiles, selectedProfileId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update trigger width when component mounts or value changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (triggerRef.current) {
|
||||||
|
const updateWidth = () => {
|
||||||
|
setTriggerWidth(triggerRef.current?.offsetWidth || 0);
|
||||||
|
};
|
||||||
|
updateWidth();
|
||||||
|
const resizeObserver = new ResizeObserver(updateWidth);
|
||||||
|
resizeObserver.observe(triggerRef.current);
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [selectedProfileId]);
|
||||||
|
|
||||||
|
// Filter profiles based on input
|
||||||
|
const filteredProfiles = React.useMemo(() => {
|
||||||
|
if (!inputValue) return profiles;
|
||||||
|
const lower = inputValue.toLowerCase();
|
||||||
|
return profiles.filter(
|
||||||
|
(p) =>
|
||||||
|
p.name.toLowerCase().includes(lower) ||
|
||||||
|
p.description?.toLowerCase().includes(lower) ||
|
||||||
|
p.provider.toLowerCase().includes(lower)
|
||||||
|
);
|
||||||
|
}, [profiles, inputValue]);
|
||||||
|
|
||||||
|
const handleSelect = (profile: AIProfile) => {
|
||||||
|
onSelect(profile);
|
||||||
|
setInputValue('');
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
ref={triggerRef}
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn('w-full justify-between h-9', className)}
|
||||||
|
data-testid={`${testIdPrefix}-trigger`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 truncate">
|
||||||
|
{selectedProfile ? (
|
||||||
|
<>
|
||||||
|
{(() => {
|
||||||
|
const ProviderIcon = PROVIDER_ICON_COMPONENTS[selectedProfile.provider];
|
||||||
|
return ProviderIcon ? (
|
||||||
|
<ProviderIcon className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<UserCircle className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<span className="truncate">{selectedProfile.name}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserCircle className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">{placeholder}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{ width: Math.max(triggerWidth, 280) }}
|
||||||
|
data-testid={`${testIdPrefix}-content`}
|
||||||
|
>
|
||||||
|
<Command shouldFilter={false}>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Search profiles..."
|
||||||
|
className="h-9"
|
||||||
|
value={inputValue}
|
||||||
|
onValueChange={setInputValue}
|
||||||
|
/>
|
||||||
|
<CommandList className="max-h-[300px]">
|
||||||
|
<CommandEmpty>No profile found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{filteredProfiles.map((profile) => {
|
||||||
|
const ProviderIcon = PROVIDER_ICON_COMPONENTS[profile.provider];
|
||||||
|
const isSelected = profile.id === selectedProfileId;
|
||||||
|
const modelDisplay = getProfileModelDisplay(profile);
|
||||||
|
const thinkingDisplay = getProfileThinkingDisplay(profile);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={profile.id}
|
||||||
|
value={profile.id}
|
||||||
|
onSelect={() => handleSelect(profile)}
|
||||||
|
className="flex items-center gap-2 py-2"
|
||||||
|
data-testid={`${testIdPrefix}-option-${profile.id}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
{ProviderIcon ? (
|
||||||
|
<ProviderIcon className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<UserCircle className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col min-w-0 flex-1">
|
||||||
|
<span className="text-sm font-medium truncate">{profile.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground truncate">
|
||||||
|
{modelDisplay}
|
||||||
|
{thinkingDisplay && (
|
||||||
|
<span className="text-amber-500"> + {thinkingDisplay}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{profile.isBuiltIn && (
|
||||||
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||||
|
Built-in
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Check className={cn('h-4 w-4', isSelected ? 'opacity-100' : 'opacity-0')} />
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
{showManageLink && onManageLinkClick && (
|
||||||
|
<>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
setOpen(false);
|
||||||
|
onManageLinkClick();
|
||||||
|
}}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
data-testid={`${testIdPrefix}-manage-link`}
|
||||||
|
>
|
||||||
|
<Settings2 className="w-4 h-4 mr-2" />
|
||||||
|
Manage AI Profiles
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { cn, modelSupportsThinking } from '@/lib/utils';
|
import { cn, modelSupportsThinking } from '@/lib/utils';
|
||||||
import { DialogFooter } from '@/components/ui/dialog';
|
import { DialogFooter } from '@/components/ui/dialog';
|
||||||
import { Brain } from 'lucide-react';
|
import { Brain } from 'lucide-react';
|
||||||
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type {
|
import type {
|
||||||
AIProfile,
|
AIProfile,
|
||||||
@@ -17,8 +17,15 @@ import type {
|
|||||||
ModelProvider,
|
ModelProvider,
|
||||||
CursorModelId,
|
CursorModelId,
|
||||||
CodexModelId,
|
CodexModelId,
|
||||||
|
OpencodeModelId,
|
||||||
|
} from '@automaker/types';
|
||||||
|
import {
|
||||||
|
CURSOR_MODEL_MAP,
|
||||||
|
cursorModelHasThinking,
|
||||||
|
CODEX_MODEL_MAP,
|
||||||
|
OPENCODE_MODELS,
|
||||||
|
DEFAULT_OPENCODE_MODEL,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import { CURSOR_MODEL_MAP, cursorModelHasThinking, CODEX_MODEL_MAP } from '@automaker/types';
|
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { CLAUDE_MODELS, THINKING_LEVELS, ICON_OPTIONS } from '../constants';
|
import { CLAUDE_MODELS, THINKING_LEVELS, ICON_OPTIONS } from '../constants';
|
||||||
|
|
||||||
@@ -50,6 +57,8 @@ export function ProfileForm({
|
|||||||
cursorModel: profile.cursorModel || ('auto' as CursorModelId),
|
cursorModel: profile.cursorModel || ('auto' as CursorModelId),
|
||||||
// Codex-specific - use a valid CodexModelId from CODEX_MODEL_MAP
|
// Codex-specific - use a valid CodexModelId from CODEX_MODEL_MAP
|
||||||
codexModel: profile.codexModel || (CODEX_MODEL_MAP.gpt52Codex as CodexModelId),
|
codexModel: profile.codexModel || (CODEX_MODEL_MAP.gpt52Codex as CodexModelId),
|
||||||
|
// OpenCode-specific
|
||||||
|
opencodeModel: profile.opencodeModel || (DEFAULT_OPENCODE_MODEL as OpencodeModelId),
|
||||||
icon: profile.icon || 'Brain',
|
icon: profile.icon || 'Brain',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,6 +75,8 @@ export function ProfileForm({
|
|||||||
cursorModel: profile.cursorModel || ('auto' as CursorModelId),
|
cursorModel: profile.cursorModel || ('auto' as CursorModelId),
|
||||||
// Codex-specific - use a valid CodexModelId from CODEX_MODEL_MAP
|
// Codex-specific - use a valid CodexModelId from CODEX_MODEL_MAP
|
||||||
codexModel: profile.codexModel || (CODEX_MODEL_MAP.gpt52Codex as CodexModelId),
|
codexModel: profile.codexModel || (CODEX_MODEL_MAP.gpt52Codex as CodexModelId),
|
||||||
|
// OpenCode-specific
|
||||||
|
opencodeModel: profile.opencodeModel || (DEFAULT_OPENCODE_MODEL as OpencodeModelId),
|
||||||
icon: profile.icon || 'Brain',
|
icon: profile.icon || 'Brain',
|
||||||
});
|
});
|
||||||
}, [profile]);
|
}, [profile]);
|
||||||
@@ -79,10 +90,14 @@ export function ProfileForm({
|
|||||||
// Only reset Claude fields when switching TO Claude; preserve otherwise
|
// Only reset Claude fields when switching TO Claude; preserve otherwise
|
||||||
model: provider === 'claude' ? 'sonnet' : formData.model,
|
model: provider === 'claude' ? 'sonnet' : formData.model,
|
||||||
thinkingLevel: provider === 'claude' ? 'none' : formData.thinkingLevel,
|
thinkingLevel: provider === 'claude' ? 'none' : formData.thinkingLevel,
|
||||||
// Reset cursor/codex models when switching to that provider
|
// Reset cursor/codex/opencode models when switching to that provider
|
||||||
cursorModel: provider === 'cursor' ? 'auto' : formData.cursorModel,
|
cursorModel: provider === 'cursor' ? 'auto' : formData.cursorModel,
|
||||||
codexModel:
|
codexModel:
|
||||||
provider === 'codex' ? (CODEX_MODEL_MAP.gpt52Codex as CodexModelId) : formData.codexModel,
|
provider === 'codex' ? (CODEX_MODEL_MAP.gpt52Codex as CodexModelId) : formData.codexModel,
|
||||||
|
opencodeModel:
|
||||||
|
provider === 'opencode'
|
||||||
|
? (DEFAULT_OPENCODE_MODEL as OpencodeModelId)
|
||||||
|
: formData.opencodeModel,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,6 +122,13 @@ export function ProfileForm({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpencodeModelChange = (opencodeModel: OpencodeModelId) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
opencodeModel,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!formData.name.trim()) {
|
if (!formData.name.trim()) {
|
||||||
toast.error('Please enter a profile name');
|
toast.error('Please enter a profile name');
|
||||||
@@ -140,6 +162,11 @@ export function ProfileForm({
|
|||||||
...baseProfile,
|
...baseProfile,
|
||||||
codexModel: formData.codexModel,
|
codexModel: formData.codexModel,
|
||||||
});
|
});
|
||||||
|
} else if (formData.provider === 'opencode') {
|
||||||
|
onSave({
|
||||||
|
...baseProfile,
|
||||||
|
opencodeModel: formData.opencodeModel,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
onSave({
|
onSave({
|
||||||
...baseProfile,
|
...baseProfile,
|
||||||
@@ -203,7 +230,7 @@ export function ProfileForm({
|
|||||||
{/* Provider Selection */}
|
{/* Provider Selection */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>AI Provider</Label>
|
<Label>AI Provider</Label>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleProviderChange('claude')}
|
onClick={() => handleProviderChange('claude')}
|
||||||
@@ -246,6 +273,20 @@ export function ProfileForm({
|
|||||||
<OpenAIIcon className="w-4 h-4" />
|
<OpenAIIcon className="w-4 h-4" />
|
||||||
Codex
|
Codex
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleProviderChange('opencode')}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||||
|
formData.provider === 'opencode'
|
||||||
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
|
: 'bg-background hover:bg-accent border-border'
|
||||||
|
)}
|
||||||
|
data-testid="provider-select-opencode"
|
||||||
|
>
|
||||||
|
<OpenCodeIcon className="w-4 h-4" />
|
||||||
|
OpenCode
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -404,6 +445,61 @@ export function ProfileForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* OpenCode Model Selection */}
|
||||||
|
{formData.provider === 'opencode' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<OpenCodeIcon className="w-4 h-4 text-primary" />
|
||||||
|
OpenCode Model
|
||||||
|
</Label>
|
||||||
|
<div className="flex flex-col gap-2 max-h-[300px] overflow-y-auto">
|
||||||
|
{OPENCODE_MODELS.map((model) => (
|
||||||
|
<button
|
||||||
|
key={model.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleOpencodeModelChange(model.id)}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-between',
|
||||||
|
formData.opencodeModel === model.id
|
||||||
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
|
: 'bg-background hover:bg-accent border-border'
|
||||||
|
)}
|
||||||
|
data-testid={`opencode-model-select-${model.id}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-start gap-0.5">
|
||||||
|
<span>{model.label}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-xs',
|
||||||
|
formData.opencodeModel === model.id
|
||||||
|
? 'text-primary-foreground/70'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{model.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
'text-xs capitalize shrink-0',
|
||||||
|
formData.opencodeModel === model.id
|
||||||
|
? 'border-primary-foreground/50 text-primary-foreground'
|
||||||
|
: model.tier === 'free'
|
||||||
|
? 'border-green-500/50 text-green-600 dark:text-green-400'
|
||||||
|
: model.tier === 'premium'
|
||||||
|
? 'border-amber-500/50 text-amber-600 dark:text-amber-400'
|
||||||
|
: 'border-muted-foreground/50 text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{model.tier}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Claude Thinking Level */}
|
{/* Claude Thinking Level */}
|
||||||
{formData.provider === 'claude' && supportsThinking && (
|
{formData.provider === 'claude' && supportsThinking && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature
|
|||||||
import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
|
import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
|
||||||
import { AccountSection } from './settings-view/account';
|
import { AccountSection } from './settings-view/account';
|
||||||
import { SecuritySection } from './settings-view/security';
|
import { SecuritySection } from './settings-view/security';
|
||||||
import { ClaudeSettingsTab, CursorSettingsTab, CodexSettingsTab } from './settings-view/providers';
|
import {
|
||||||
|
ClaudeSettingsTab,
|
||||||
|
CursorSettingsTab,
|
||||||
|
CodexSettingsTab,
|
||||||
|
OpencodeSettingsTab,
|
||||||
|
} 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';
|
||||||
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
|
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
|
||||||
@@ -109,6 +114,8 @@ export function SettingsView() {
|
|||||||
return <CursorSettingsTab />;
|
return <CursorSettingsTab />;
|
||||||
case 'codex-provider':
|
case 'codex-provider':
|
||||||
return <CodexSettingsTab />;
|
return <CodexSettingsTab />;
|
||||||
|
case 'opencode-provider':
|
||||||
|
return <OpencodeSettingsTab />;
|
||||||
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,306 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { CheckCircle2, AlertCircle, RefreshCw, XCircle, Bot } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { CliStatus } from '../shared/types';
|
||||||
|
|
||||||
|
export type OpencodeAuthMethod =
|
||||||
|
| 'api_key_env' // ANTHROPIC_API_KEY or other provider env vars
|
||||||
|
| 'api_key' // Manually stored API key
|
||||||
|
| 'oauth' // OAuth authentication
|
||||||
|
| 'config_file' // Config file with credentials
|
||||||
|
| 'none';
|
||||||
|
|
||||||
|
export interface OpencodeAuthStatus {
|
||||||
|
authenticated: boolean;
|
||||||
|
method: OpencodeAuthMethod;
|
||||||
|
hasApiKey?: boolean;
|
||||||
|
hasEnvApiKey?: boolean;
|
||||||
|
hasOAuthToken?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthMethodLabel(method: OpencodeAuthMethod): string {
|
||||||
|
switch (method) {
|
||||||
|
case 'api_key':
|
||||||
|
return 'API Key';
|
||||||
|
case 'api_key_env':
|
||||||
|
return 'API Key (Environment)';
|
||||||
|
case 'oauth':
|
||||||
|
return 'OAuth Authentication';
|
||||||
|
case 'config_file':
|
||||||
|
return 'Configuration File';
|
||||||
|
default:
|
||||||
|
return method || 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpencodeCliStatusProps {
|
||||||
|
status: CliStatus | null;
|
||||||
|
authStatus?: OpencodeAuthStatus | null;
|
||||||
|
isChecking: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonPulse({ className }: { className?: string }) {
|
||||||
|
return <div className={cn('animate-pulse bg-muted/50 rounded', className)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpencodeCliStatusSkeleton() {
|
||||||
|
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 OpencodeModelConfigSkeleton() {
|
||||||
|
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">
|
||||||
|
<SkeletonPulse className="w-9 h-9 rounded-xl" />
|
||||||
|
<SkeletonPulse className="h-6 w-40" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-12">
|
||||||
|
<SkeletonPulse className="h-4 w-72" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Default Model skeleton */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<SkeletonPulse className="h-4 w-24" />
|
||||||
|
<SkeletonPulse className="h-10 w-full rounded-md" />
|
||||||
|
</div>
|
||||||
|
{/* Available Models skeleton */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<SkeletonPulse className="h-4 w-32" />
|
||||||
|
{/* Provider group skeleton */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SkeletonPulse className="w-4 h-4 rounded" />
|
||||||
|
<SkeletonPulse className="h-4 w-20" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center justify-between p-3 rounded-xl border border-border/30 bg-muted/10"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<SkeletonPulse className="w-5 h-5 rounded" />
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<SkeletonPulse className="h-4 w-32" />
|
||||||
|
<SkeletonPulse className="h-3 w-48" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SkeletonPulse className="h-5 w-12 rounded-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpencodeCliStatus({
|
||||||
|
status,
|
||||||
|
authStatus,
|
||||||
|
isChecking,
|
||||||
|
onRefresh,
|
||||||
|
}: OpencodeCliStatusProps) {
|
||||||
|
if (!status) return <OpencodeCliStatusSkeleton />;
|
||||||
|
|
||||||
|
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-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||||
|
<Bot className="w-5 h-5 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground tracking-tight">OpenCode CLI</h2>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={isChecking}
|
||||||
|
data-testid="refresh-opencode-cli"
|
||||||
|
title="Refresh OpenCode CLI detection"
|
||||||
|
className={cn(
|
||||||
|
'h-9 w-9 rounded-lg',
|
||||||
|
'hover:bg-accent/50 hover:scale-105',
|
||||||
|
'transition-all duration-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('w-4 h-4', isChecking && 'animate-spin')} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||||
|
OpenCode CLI provides multi-provider AI support with Claude, GPT, and Gemini models.
|
||||||
|
</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">OpenCode 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">
|
||||||
|
<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-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">
|
||||||
|
<XCircle className="w-5 h-5 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-amber-400">Not Authenticated</p>
|
||||||
|
<p className="text-xs text-amber-400/70 mt-1">
|
||||||
|
Run{' '}
|
||||||
|
<code className="font-mono bg-amber-500/10 px-1 rounded">opencode auth</code> or
|
||||||
|
set an API key to authenticate.
|
||||||
|
</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">OpenCode CLI Not Detected</p>
|
||||||
|
<p className="text-xs text-amber-400/70 mt-1">
|
||||||
|
{status.recommendation || 'Install OpenCode CLI to use multi-provider AI 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
User,
|
User,
|
||||||
Shield,
|
Shield,
|
||||||
|
Cpu,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
||||||
import type { SettingsViewId } from '../hooks/use-settings-view';
|
import type { SettingsViewId } from '../hooks/use-settings-view';
|
||||||
@@ -41,6 +42,7 @@ export const GLOBAL_NAV_ITEMS: NavigationItem[] = [
|
|||||||
{ id: 'claude-provider', label: 'Claude', icon: AnthropicIcon },
|
{ id: 'claude-provider', label: 'Claude', icon: AnthropicIcon },
|
||||||
{ 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: Cpu },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ id: 'mcp-servers', label: 'MCP Servers', icon: Plug },
|
{ id: 'mcp-servers', label: 'MCP Servers', icon: Plug },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type SettingsViewId =
|
|||||||
| 'claude-provider'
|
| 'claude-provider'
|
||||||
| 'cursor-provider'
|
| 'cursor-provider'
|
||||||
| 'codex-provider'
|
| 'codex-provider'
|
||||||
|
| 'opencode-provider'
|
||||||
| 'mcp-servers'
|
| 'mcp-servers'
|
||||||
| 'prompts'
|
| 'prompts'
|
||||||
| 'model-defaults'
|
| 'model-defaults'
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
ModelAlias,
|
ModelAlias,
|
||||||
CursorModelId,
|
CursorModelId,
|
||||||
CodexModelId,
|
CodexModelId,
|
||||||
|
OpencodeModelId,
|
||||||
GroupedModel,
|
GroupedModel,
|
||||||
PhaseModelEntry,
|
PhaseModelEntry,
|
||||||
ThinkingLevel,
|
ThinkingLevel,
|
||||||
@@ -23,13 +24,14 @@ import {
|
|||||||
CLAUDE_MODELS,
|
CLAUDE_MODELS,
|
||||||
CURSOR_MODELS,
|
CURSOR_MODELS,
|
||||||
CODEX_MODELS,
|
CODEX_MODELS,
|
||||||
|
OPENCODE_MODELS,
|
||||||
THINKING_LEVELS,
|
THINKING_LEVELS,
|
||||||
THINKING_LEVEL_LABELS,
|
THINKING_LEVEL_LABELS,
|
||||||
REASONING_EFFORT_LEVELS,
|
REASONING_EFFORT_LEVELS,
|
||||||
REASONING_EFFORT_LABELS,
|
REASONING_EFFORT_LABELS,
|
||||||
} from '@/components/views/board-view/shared/model-constants';
|
} from '@/components/views/board-view/shared/model-constants';
|
||||||
import { Check, ChevronsUpDown, Star, ChevronRight } from 'lucide-react';
|
import { Check, ChevronsUpDown, Star, ChevronRight } from 'lucide-react';
|
||||||
import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
@@ -199,6 +201,10 @@ export function PhaseModelSelector({
|
|||||||
const codexModel = CODEX_MODELS.find((m) => m.id === selectedModel);
|
const codexModel = CODEX_MODELS.find((m) => m.id === selectedModel);
|
||||||
if (codexModel) return { ...codexModel, icon: OpenAIIcon };
|
if (codexModel) return { ...codexModel, icon: OpenAIIcon };
|
||||||
|
|
||||||
|
// Check OpenCode models
|
||||||
|
const opencodeModel = OPENCODE_MODELS.find((m) => m.id === selectedModel);
|
||||||
|
if (opencodeModel) return { ...opencodeModel, icon: OpenCodeIcon };
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}, [selectedModel, selectedThinkingLevel, availableCursorModels]);
|
}, [selectedModel, selectedThinkingLevel, availableCursorModels]);
|
||||||
|
|
||||||
@@ -236,11 +242,12 @@ export function PhaseModelSelector({
|
|||||||
}, [availableCursorModels, enabledCursorModels]);
|
}, [availableCursorModels, enabledCursorModels]);
|
||||||
|
|
||||||
// Group models
|
// Group models
|
||||||
const { favorites, claude, cursor, codex } = React.useMemo(() => {
|
const { favorites, claude, cursor, codex, opencode } = React.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 CODEX_MODELS = [];
|
const codModels: typeof CODEX_MODELS = [];
|
||||||
|
const ocModels: typeof OPENCODE_MODELS = [];
|
||||||
|
|
||||||
// Process Claude Models
|
// Process Claude Models
|
||||||
CLAUDE_MODELS.forEach((model) => {
|
CLAUDE_MODELS.forEach((model) => {
|
||||||
@@ -269,7 +276,22 @@ export function PhaseModelSelector({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { favorites: favs, claude: cModels, cursor: curModels, codex: codModels };
|
// Process OpenCode Models
|
||||||
|
OPENCODE_MODELS.forEach((model) => {
|
||||||
|
if (favoriteModels.includes(model.id)) {
|
||||||
|
favs.push(model);
|
||||||
|
} else {
|
||||||
|
ocModels.push(model);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
favorites: favs,
|
||||||
|
claude: cModels,
|
||||||
|
cursor: curModels,
|
||||||
|
codex: codModels,
|
||||||
|
opencode: ocModels,
|
||||||
|
};
|
||||||
}, [favoriteModels, availableCursorModels]);
|
}, [favoriteModels, availableCursorModels]);
|
||||||
|
|
||||||
// Render Codex model item with secondary popover for reasoning effort (only for models that support it)
|
// Render Codex model item with secondary popover for reasoning effort (only for models that support it)
|
||||||
@@ -453,6 +475,64 @@ export function PhaseModelSelector({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Render OpenCode model item (simple selector, no thinking/reasoning options)
|
||||||
|
const renderOpencodeModelItem = (model: (typeof OPENCODE_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 OpencodeModelId });
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="group flex items-center justify-between py-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 overflow-hidden">
|
||||||
|
<OpenCodeIcon
|
||||||
|
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">
|
||||||
|
{model.badge && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground mr-1">
|
||||||
|
{model.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<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 Cursor model item (no thinking level needed)
|
// Render Cursor model item (no thinking level needed)
|
||||||
const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => {
|
const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => {
|
||||||
const modelValue = stripProviderPrefix(model.id);
|
const modelValue = stripProviderPrefix(model.id);
|
||||||
@@ -835,6 +915,10 @@ export function PhaseModelSelector({
|
|||||||
if (model.provider === 'codex') {
|
if (model.provider === 'codex') {
|
||||||
return renderCodexModelItem(model);
|
return renderCodexModelItem(model);
|
||||||
}
|
}
|
||||||
|
// OpenCode model
|
||||||
|
if (model.provider === 'opencode') {
|
||||||
|
return renderOpencodeModelItem(model);
|
||||||
|
}
|
||||||
// Claude model
|
// Claude model
|
||||||
return renderClaudeModelItem(model);
|
return renderClaudeModelItem(model);
|
||||||
});
|
});
|
||||||
@@ -864,6 +948,12 @@ export function PhaseModelSelector({
|
|||||||
{codex.map((model) => renderCodexModelItem(model))}
|
{codex.map((model) => renderCodexModelItem(model))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{opencode.length > 0 && (
|
||||||
|
<CommandGroup heading="OpenCode Models">
|
||||||
|
{opencode.map((model) => renderOpencodeModelItem(model))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
|
|||||||
@@ -8,10 +8,8 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Cpu } from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { CodexModelId } from '@automaker/types';
|
import type { CodexModelId } from '@automaker/types';
|
||||||
import { CODEX_MODEL_MAP } from '@automaker/types';
|
|
||||||
import { OpenAIIcon } from '@/components/ui/provider-icon';
|
import { OpenAIIcon } from '@/components/ui/provider-icon';
|
||||||
|
|
||||||
interface CodexModelConfigurationProps {
|
interface CodexModelConfigurationProps {
|
||||||
@@ -160,17 +158,6 @@ export function CodexModelConfiguration({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getModelDisplayName(modelId: string): string {
|
|
||||||
const displayNames: Record<string, string> = {
|
|
||||||
'codex-gpt-5.2-codex': 'GPT-5.2-Codex',
|
|
||||||
'codex-gpt-5.1-codex-max': 'GPT-5.1-Codex-Max',
|
|
||||||
'codex-gpt-5.1-codex-mini': 'GPT-5.1-Codex-Mini',
|
|
||||||
'codex-gpt-5.2': 'GPT-5.2',
|
|
||||||
'codex-gpt-5.1': 'GPT-5.1',
|
|
||||||
};
|
|
||||||
return displayNames[modelId] || modelId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function supportsReasoningEffort(modelId: string): boolean {
|
function supportsReasoningEffort(modelId: string): boolean {
|
||||||
const reasoningModels = [
|
const reasoningModels = [
|
||||||
'codex-gpt-5.2-codex',
|
'codex-gpt-5.2-codex',
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export { ProviderTabs } from './provider-tabs';
|
|||||||
export { ClaudeSettingsTab } from './claude-settings-tab';
|
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';
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
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 { Terminal, Cloud, Cpu, Brain, Sparkles, Zap } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { OpencodeModelId, OpencodeProvider, OpencodeModelConfig } from '@automaker/types';
|
||||||
|
import { OPENCODE_MODELS, OPENCODE_MODEL_CONFIG_MAP } from '@automaker/types';
|
||||||
|
import { AnthropicIcon } from '@/components/ui/provider-icon';
|
||||||
|
import type { ComponentType } from 'react';
|
||||||
|
|
||||||
|
interface OpencodeModelConfigurationProps {
|
||||||
|
enabledOpencodeModels: OpencodeModelId[];
|
||||||
|
opencodeDefaultModel: OpencodeModelId;
|
||||||
|
isSaving: boolean;
|
||||||
|
onDefaultModelChange: (model: OpencodeModelId) => void;
|
||||||
|
onModelToggle: (model: OpencodeModelId, enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the appropriate icon component for a given OpenCode provider
|
||||||
|
*/
|
||||||
|
function getProviderIcon(provider: OpencodeProvider): ComponentType<{ className?: string }> {
|
||||||
|
switch (provider) {
|
||||||
|
case 'opencode':
|
||||||
|
return Terminal;
|
||||||
|
case 'amazon-bedrock-anthropic':
|
||||||
|
return AnthropicIcon;
|
||||||
|
case 'amazon-bedrock-deepseek':
|
||||||
|
return Brain;
|
||||||
|
case 'amazon-bedrock-amazon':
|
||||||
|
return Cloud;
|
||||||
|
case 'amazon-bedrock-meta':
|
||||||
|
return Cpu;
|
||||||
|
case 'amazon-bedrock-mistral':
|
||||||
|
return Sparkles;
|
||||||
|
case 'amazon-bedrock-qwen':
|
||||||
|
return Zap;
|
||||||
|
default:
|
||||||
|
return Terminal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a formatted provider label for display
|
||||||
|
*/
|
||||||
|
function getProviderLabel(provider: OpencodeProvider): string {
|
||||||
|
switch (provider) {
|
||||||
|
case 'opencode':
|
||||||
|
return 'OpenCode (Free)';
|
||||||
|
case 'amazon-bedrock-anthropic':
|
||||||
|
return 'Claude (Bedrock)';
|
||||||
|
case 'amazon-bedrock-deepseek':
|
||||||
|
return 'DeepSeek (Bedrock)';
|
||||||
|
case 'amazon-bedrock-amazon':
|
||||||
|
return 'Amazon Nova (Bedrock)';
|
||||||
|
case 'amazon-bedrock-meta':
|
||||||
|
return 'Meta Llama (Bedrock)';
|
||||||
|
case 'amazon-bedrock-mistral':
|
||||||
|
return 'Mistral (Bedrock)';
|
||||||
|
case 'amazon-bedrock-qwen':
|
||||||
|
return 'Qwen (Bedrock)';
|
||||||
|
default:
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpencodeModelConfiguration({
|
||||||
|
enabledOpencodeModels,
|
||||||
|
opencodeDefaultModel,
|
||||||
|
isSaving,
|
||||||
|
onDefaultModelChange,
|
||||||
|
onModelToggle,
|
||||||
|
}: OpencodeModelConfigurationProps) {
|
||||||
|
// Group models by provider for organized display
|
||||||
|
const modelsByProvider = OPENCODE_MODELS.reduce(
|
||||||
|
(acc, model) => {
|
||||||
|
if (!acc[model.provider]) {
|
||||||
|
acc[model.provider] = [];
|
||||||
|
}
|
||||||
|
acc[model.provider].push(model);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<OpencodeProvider, OpencodeModelConfig[]>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Order: Free tier first, then Claude, then others
|
||||||
|
const providerOrder: OpencodeProvider[] = [
|
||||||
|
'opencode',
|
||||||
|
'amazon-bedrock-anthropic',
|
||||||
|
'amazon-bedrock-deepseek',
|
||||||
|
'amazon-bedrock-amazon',
|
||||||
|
'amazon-bedrock-meta',
|
||||||
|
'amazon-bedrock-mistral',
|
||||||
|
'amazon-bedrock-qwen',
|
||||||
|
];
|
||||||
|
|
||||||
|
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">
|
||||||
|
Model Configuration
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||||
|
Configure which OpenCode models are available in the feature modal
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Default Model Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Default Model</Label>
|
||||||
|
<Select
|
||||||
|
value={opencodeDefaultModel}
|
||||||
|
onValueChange={(v) => onDefaultModelChange(v as OpencodeModelId)}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{enabledOpencodeModels.map((modelId) => {
|
||||||
|
const model = OPENCODE_MODEL_CONFIG_MAP[modelId];
|
||||||
|
if (!model) return null;
|
||||||
|
const ProviderIconComponent = getProviderIcon(model.provider);
|
||||||
|
return (
|
||||||
|
<SelectItem key={modelId} value={modelId}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ProviderIconComponent className="w-4 h-4" />
|
||||||
|
<span>{model.label}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Available Models grouped by provider */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Label>Available Models</Label>
|
||||||
|
{providerOrder.map((provider) => {
|
||||||
|
const models = modelsByProvider[provider];
|
||||||
|
if (!models || models.length === 0) return null;
|
||||||
|
|
||||||
|
const ProviderIconComponent = getProviderIcon(provider);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={provider} className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<ProviderIconComponent className="w-4 h-4" />
|
||||||
|
<span className="font-medium">{getProviderLabel(provider)}</span>
|
||||||
|
{provider === 'opencode' && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs bg-green-500/10 text-green-500 border-green-500/30"
|
||||||
|
>
|
||||||
|
Free
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{models.map((model) => {
|
||||||
|
const isEnabled = enabledOpencodeModels.includes(model.id);
|
||||||
|
const isDefault = model.id === opencodeDefaultModel;
|
||||||
|
|
||||||
|
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.supportsVision && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Vision
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{model.tier === 'free' && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs bg-green-500/10 text-green-500 border-green-500/30"
|
||||||
|
>
|
||||||
|
Free
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import {
|
||||||
|
OpencodeCliStatus,
|
||||||
|
OpencodeCliStatusSkeleton,
|
||||||
|
OpencodeModelConfigSkeleton,
|
||||||
|
} from '../cli-status/opencode-cli-status';
|
||||||
|
import { OpencodeModelConfiguration } from './opencode-model-configuration';
|
||||||
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
|
import type { CliStatus as SharedCliStatus } from '../shared/types';
|
||||||
|
import type { OpencodeModelId } from '@automaker/types';
|
||||||
|
import type { OpencodeAuthStatus } from '../cli-status/opencode-cli-status';
|
||||||
|
|
||||||
|
const logger = createLogger('OpencodeSettings');
|
||||||
|
|
||||||
|
export function OpencodeSettingsTab() {
|
||||||
|
const {
|
||||||
|
enabledOpencodeModels,
|
||||||
|
opencodeDefaultModel,
|
||||||
|
setOpencodeDefaultModel,
|
||||||
|
toggleOpencodeModel,
|
||||||
|
} = useAppStore();
|
||||||
|
|
||||||
|
const [isCheckingOpencodeCli, setIsCheckingOpencodeCli] = useState(false);
|
||||||
|
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||||
|
const [cliStatus, setCliStatus] = useState<SharedCliStatus | null>(null);
|
||||||
|
const [authStatus, setAuthStatus] = useState<OpencodeAuthStatus | null>(null);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
// Load OpenCode CLI status on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const checkOpencodeStatus = async () => {
|
||||||
|
setIsCheckingOpencodeCli(true);
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (api?.setup?.getOpencodeStatus) {
|
||||||
|
const result = await api.setup.getOpencodeStatus();
|
||||||
|
setCliStatus({
|
||||||
|
success: result.success,
|
||||||
|
status: result.installed ? 'installed' : 'not_installed',
|
||||||
|
method: result.auth?.method,
|
||||||
|
version: result.version,
|
||||||
|
path: result.path,
|
||||||
|
recommendation: result.recommendation,
|
||||||
|
installCommands: result.installCommands,
|
||||||
|
});
|
||||||
|
// Set auth status if available
|
||||||
|
if (result.auth) {
|
||||||
|
setAuthStatus({
|
||||||
|
authenticated: result.auth.authenticated,
|
||||||
|
method: (result.auth.method as OpencodeAuthStatus['method']) || 'none',
|
||||||
|
hasApiKey: result.auth.hasApiKey,
|
||||||
|
hasEnvApiKey: result.auth.hasEnvApiKey,
|
||||||
|
hasOAuthToken: result.auth.hasOAuthToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback for web mode or when API is not available
|
||||||
|
setCliStatus({
|
||||||
|
success: false,
|
||||||
|
status: 'not_installed',
|
||||||
|
recommendation: 'OpenCode CLI detection is only available in desktop mode.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to check OpenCode CLI status:', error);
|
||||||
|
setCliStatus({
|
||||||
|
success: false,
|
||||||
|
status: 'not_installed',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsCheckingOpencodeCli(false);
|
||||||
|
setIsInitialLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkOpencodeStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRefreshOpencodeCli = useCallback(async () => {
|
||||||
|
setIsCheckingOpencodeCli(true);
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (api?.setup?.getOpencodeStatus) {
|
||||||
|
const result = await api.setup.getOpencodeStatus();
|
||||||
|
setCliStatus({
|
||||||
|
success: result.success,
|
||||||
|
status: result.installed ? 'installed' : 'not_installed',
|
||||||
|
method: result.auth?.method,
|
||||||
|
version: result.version,
|
||||||
|
path: result.path,
|
||||||
|
recommendation: result.recommendation,
|
||||||
|
installCommands: result.installCommands,
|
||||||
|
});
|
||||||
|
// Update auth status if available
|
||||||
|
if (result.auth) {
|
||||||
|
setAuthStatus({
|
||||||
|
authenticated: result.auth.authenticated,
|
||||||
|
method: (result.auth.method as OpencodeAuthStatus['method']) || 'none',
|
||||||
|
hasApiKey: result.auth.hasApiKey,
|
||||||
|
hasEnvApiKey: result.auth.hasEnvApiKey,
|
||||||
|
hasOAuthToken: result.auth.hasOAuthToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to refresh OpenCode CLI status:', error);
|
||||||
|
toast.error('Failed to refresh OpenCode CLI status');
|
||||||
|
} finally {
|
||||||
|
setIsCheckingOpencodeCli(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDefaultModelChange = useCallback(
|
||||||
|
(model: OpencodeModelId) => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
setOpencodeDefaultModel(model);
|
||||||
|
toast.success('Default model updated');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to update default model');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setOpencodeDefaultModel]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleModelToggle = useCallback(
|
||||||
|
(model: OpencodeModelId, enabled: boolean) => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
toggleOpencodeModel(model, enabled);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to update models');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[toggleOpencodeModel]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show loading skeleton during initial load
|
||||||
|
if (isInitialLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<OpencodeCliStatusSkeleton />
|
||||||
|
<OpencodeModelConfigSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCliInstalled = cliStatus?.success && cliStatus?.status === 'installed';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<OpencodeCliStatus
|
||||||
|
status={cliStatus}
|
||||||
|
authStatus={authStatus}
|
||||||
|
isChecking={isCheckingOpencodeCli}
|
||||||
|
onRefresh={handleRefreshOpencodeCli}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Model Configuration - Only show when CLI is installed */}
|
||||||
|
{isCliInstalled && (
|
||||||
|
<OpencodeModelConfiguration
|
||||||
|
enabledOpencodeModels={enabledOpencodeModels}
|
||||||
|
opencodeDefaultModel={opencodeDefaultModel}
|
||||||
|
isSaving={isSaving}
|
||||||
|
onDefaultModelChange={handleDefaultModelChange}
|
||||||
|
onModelToggle={handleModelToggle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OpencodeSettingsTab;
|
||||||
@@ -1,18 +1,20 @@
|
|||||||
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 { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
||||||
|
import { Cpu } from 'lucide-react';
|
||||||
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';
|
||||||
|
|
||||||
interface ProviderTabsProps {
|
interface ProviderTabsProps {
|
||||||
defaultTab?: 'claude' | 'cursor' | 'codex';
|
defaultTab?: 'claude' | 'cursor' | 'codex' | 'opencode';
|
||||||
}
|
}
|
||||||
|
|
||||||
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-3 mb-6">
|
<TabsList className="grid w-full grid-cols-4 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
|
||||||
@@ -25,6 +27,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
|
|||||||
<OpenAIIcon className="w-4 h-4" />
|
<OpenAIIcon className="w-4 h-4" />
|
||||||
Codex
|
Codex
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="opencode" className="flex items-center gap-2">
|
||||||
|
<Cpu className="w-4 h-4" />
|
||||||
|
OpenCode
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="claude">
|
<TabsContent value="claude">
|
||||||
@@ -38,6 +44,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
|
|||||||
<TabsContent value="codex">
|
<TabsContent value="codex">
|
||||||
<CodexSettingsTab />
|
<CodexSettingsTab />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="opencode">
|
||||||
|
<OpencodeSettingsTab />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ import {
|
|||||||
WelcomeStep,
|
WelcomeStep,
|
||||||
ThemeStep,
|
ThemeStep,
|
||||||
CompleteStep,
|
CompleteStep,
|
||||||
ClaudeSetupStep,
|
ProvidersSetupStep,
|
||||||
CursorSetupStep,
|
|
||||||
CodexSetupStep,
|
|
||||||
GitHubSetupStep,
|
GitHubSetupStep,
|
||||||
} from './setup-view/steps';
|
} from './setup-view/steps';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
@@ -16,20 +14,31 @@ const logger = createLogger('SetupView');
|
|||||||
|
|
||||||
// Main Setup View
|
// Main Setup View
|
||||||
export function SetupView() {
|
export function SetupView() {
|
||||||
const { currentStep, setCurrentStep, completeSetup, setSkipClaudeSetup } = useSetupStore();
|
const { currentStep, setCurrentStep, completeSetup } = useSetupStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const steps = ['welcome', 'theme', 'claude', 'cursor', 'codex', 'github', 'complete'] as const;
|
// Simplified steps: welcome, theme, providers (combined), github, complete
|
||||||
|
const steps = ['welcome', 'theme', 'providers', 'github', 'complete'] as const;
|
||||||
type StepName = (typeof steps)[number];
|
type StepName = (typeof steps)[number];
|
||||||
|
|
||||||
const getStepName = (): StepName => {
|
const getStepName = (): StepName => {
|
||||||
if (currentStep === 'claude_detect' || currentStep === 'claude_auth') return 'claude';
|
// Map old step names to new consolidated steps
|
||||||
if (currentStep === 'welcome') return 'welcome';
|
if (currentStep === 'welcome') return 'welcome';
|
||||||
if (currentStep === 'theme') return 'theme';
|
if (currentStep === 'theme') return 'theme';
|
||||||
if (currentStep === 'cursor') return 'cursor';
|
if (
|
||||||
if (currentStep === 'codex') return 'codex';
|
currentStep === 'claude_detect' ||
|
||||||
|
currentStep === 'claude_auth' ||
|
||||||
|
currentStep === 'cursor' ||
|
||||||
|
currentStep === 'codex' ||
|
||||||
|
currentStep === 'opencode' ||
|
||||||
|
currentStep === 'providers'
|
||||||
|
) {
|
||||||
|
return 'providers';
|
||||||
|
}
|
||||||
if (currentStep === 'github') return 'github';
|
if (currentStep === 'github') return 'github';
|
||||||
return 'complete';
|
return 'complete';
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentIndex = steps.indexOf(getStepName());
|
const currentIndex = steps.indexOf(getStepName());
|
||||||
|
|
||||||
const handleNext = (from: string) => {
|
const handleNext = (from: string) => {
|
||||||
@@ -40,18 +49,10 @@ export function SetupView() {
|
|||||||
setCurrentStep('theme');
|
setCurrentStep('theme');
|
||||||
break;
|
break;
|
||||||
case 'theme':
|
case 'theme':
|
||||||
logger.debug('[Setup Flow] Moving to claude_detect step');
|
logger.debug('[Setup Flow] Moving to providers step');
|
||||||
setCurrentStep('claude_detect');
|
setCurrentStep('providers');
|
||||||
break;
|
break;
|
||||||
case 'claude':
|
case 'providers':
|
||||||
logger.debug('[Setup Flow] Moving to cursor step');
|
|
||||||
setCurrentStep('cursor');
|
|
||||||
break;
|
|
||||||
case 'cursor':
|
|
||||||
logger.debug('[Setup Flow] Moving to codex step');
|
|
||||||
setCurrentStep('codex');
|
|
||||||
break;
|
|
||||||
case 'codex':
|
|
||||||
logger.debug('[Setup Flow] Moving to github step');
|
logger.debug('[Setup Flow] Moving to github step');
|
||||||
setCurrentStep('github');
|
setCurrentStep('github');
|
||||||
break;
|
break;
|
||||||
@@ -68,37 +69,15 @@ export function SetupView() {
|
|||||||
case 'theme':
|
case 'theme':
|
||||||
setCurrentStep('welcome');
|
setCurrentStep('welcome');
|
||||||
break;
|
break;
|
||||||
case 'claude':
|
case 'providers':
|
||||||
setCurrentStep('theme');
|
setCurrentStep('theme');
|
||||||
break;
|
break;
|
||||||
case 'cursor':
|
|
||||||
setCurrentStep('claude_detect');
|
|
||||||
break;
|
|
||||||
case 'codex':
|
|
||||||
setCurrentStep('cursor');
|
|
||||||
break;
|
|
||||||
case 'github':
|
case 'github':
|
||||||
setCurrentStep('codex');
|
setCurrentStep('providers');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSkipClaude = () => {
|
|
||||||
logger.debug('[Setup Flow] Skipping Claude setup');
|
|
||||||
setSkipClaudeSetup(true);
|
|
||||||
setCurrentStep('cursor');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSkipCursor = () => {
|
|
||||||
logger.debug('[Setup Flow] Skipping Cursor setup');
|
|
||||||
setCurrentStep('codex');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSkipCodex = () => {
|
|
||||||
logger.debug('[Setup Flow] Skipping Codex setup');
|
|
||||||
setCurrentStep('github');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSkipGithub = () => {
|
const handleSkipGithub = () => {
|
||||||
logger.debug('[Setup Flow] Skipping GitHub setup');
|
logger.debug('[Setup Flow] Skipping GitHub setup');
|
||||||
setCurrentStep('complete');
|
setCurrentStep('complete');
|
||||||
@@ -137,27 +116,15 @@ export function SetupView() {
|
|||||||
<ThemeStep onNext={() => handleNext('theme')} onBack={() => handleBack('theme')} />
|
<ThemeStep onNext={() => handleNext('theme')} onBack={() => handleBack('theme')} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(currentStep === 'claude_detect' || currentStep === 'claude_auth') && (
|
{(currentStep === 'providers' ||
|
||||||
<ClaudeSetupStep
|
currentStep === 'claude_detect' ||
|
||||||
onNext={() => handleNext('claude')}
|
currentStep === 'claude_auth' ||
|
||||||
onBack={() => handleBack('claude')}
|
currentStep === 'cursor' ||
|
||||||
onSkip={handleSkipClaude}
|
currentStep === 'codex' ||
|
||||||
/>
|
currentStep === 'opencode') && (
|
||||||
)}
|
<ProvidersSetupStep
|
||||||
|
onNext={() => handleNext('providers')}
|
||||||
{currentStep === 'cursor' && (
|
onBack={() => handleBack('providers')}
|
||||||
<CursorSetupStep
|
|
||||||
onNext={() => handleNext('cursor')}
|
|
||||||
onBack={() => handleBack('cursor')}
|
|
||||||
onSkip={handleSkipCursor}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentStep === 'codex' && (
|
|
||||||
<CodexSetupStep
|
|
||||||
onNext={() => handleNext('codex')}
|
|
||||||
onBack={() => handleBack('codex')}
|
|
||||||
onSkip={handleSkipCodex}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ interface ClaudeSetupStepProps {
|
|||||||
onSkip: () => void;
|
onSkip: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ClaudeSetupContentProps {
|
||||||
|
/** Hide header and navigation for embedded use */
|
||||||
|
embedded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
type VerificationStatus = 'idle' | 'verifying' | 'verified' | 'error';
|
type VerificationStatus = 'idle' | 'verifying' | 'verified' | 'error';
|
||||||
|
|
||||||
// Claude Setup Step
|
// Claude Setup Step
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
export { WelcomeStep } from './welcome-step';
|
export { WelcomeStep } from './welcome-step';
|
||||||
export { ThemeStep } from './theme-step';
|
export { ThemeStep } from './theme-step';
|
||||||
export { CompleteStep } from './complete-step';
|
export { CompleteStep } from './complete-step';
|
||||||
|
export { ProvidersSetupStep } from './providers-setup-step';
|
||||||
|
export { GitHubSetupStep } from './github-setup-step';
|
||||||
|
|
||||||
|
// Legacy individual step exports (kept for backwards compatibility)
|
||||||
export { ClaudeSetupStep } from './claude-setup-step';
|
export { ClaudeSetupStep } from './claude-setup-step';
|
||||||
export { CursorSetupStep } from './cursor-setup-step';
|
export { CursorSetupStep } from './cursor-setup-step';
|
||||||
export { CodexSetupStep } from './codex-setup-step';
|
export { CodexSetupStep } from './codex-setup-step';
|
||||||
export { GitHubSetupStep } from './github-setup-step';
|
export { OpencodeSetupStep } from './opencode-setup-step';
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Loader2,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowLeft,
|
||||||
|
ExternalLink,
|
||||||
|
Copy,
|
||||||
|
RefreshCw,
|
||||||
|
AlertTriangle,
|
||||||
|
XCircle,
|
||||||
|
Terminal,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { StatusBadge } from '../components';
|
||||||
|
|
||||||
|
const logger = createLogger('OpencodeSetupStep');
|
||||||
|
|
||||||
|
interface OpencodeSetupStepProps {
|
||||||
|
onNext: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpencodeCliStatus {
|
||||||
|
installed: boolean;
|
||||||
|
version?: string | null;
|
||||||
|
path?: string | null;
|
||||||
|
auth?: {
|
||||||
|
authenticated: boolean;
|
||||||
|
method: string;
|
||||||
|
};
|
||||||
|
installCommand?: string;
|
||||||
|
loginCommand?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepProps) {
|
||||||
|
const { opencodeCliStatus, setOpencodeCliStatus } = useSetupStore();
|
||||||
|
const [isChecking, setIsChecking] = 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?.getOpencodeStatus) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await api.setup.getOpencodeStatus();
|
||||||
|
if (result.success) {
|
||||||
|
const status: OpencodeCliStatus = {
|
||||||
|
installed: result.installed ?? false,
|
||||||
|
version: result.version,
|
||||||
|
path: result.path,
|
||||||
|
auth: result.auth,
|
||||||
|
installCommand: result.installCommand,
|
||||||
|
loginCommand: result.loginCommand,
|
||||||
|
};
|
||||||
|
setOpencodeCliStatus(status);
|
||||||
|
|
||||||
|
if (result.auth?.authenticated) {
|
||||||
|
toast.success('OpenCode CLI is ready!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to check OpenCode status:', error);
|
||||||
|
} finally {
|
||||||
|
setIsChecking(false);
|
||||||
|
}
|
||||||
|
}, [setOpencodeCliStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkStatus();
|
||||||
|
// Cleanup polling on unmount
|
||||||
|
return () => {
|
||||||
|
if (pollIntervalRef.current) {
|
||||||
|
clearInterval(pollIntervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [checkStatus]);
|
||||||
|
|
||||||
|
const copyCommand = (command: string) => {
|
||||||
|
navigator.clipboard.writeText(command);
|
||||||
|
toast.success('Command copied to clipboard');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
setIsLoggingIn(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Copy login command to clipboard and show instructions
|
||||||
|
const loginCommand = opencodeCliStatus?.loginCommand || 'opencode auth login';
|
||||||
|
await navigator.clipboard.writeText(loginCommand);
|
||||||
|
toast.info('Login command copied! Paste in terminal to authenticate.');
|
||||||
|
|
||||||
|
// Poll for auth status
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 60; // 2 minutes with 2s interval
|
||||||
|
|
||||||
|
pollIntervalRef.current = setInterval(async () => {
|
||||||
|
attempts++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.setup?.getOpencodeStatus) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await api.setup.getOpencodeStatus();
|
||||||
|
|
||||||
|
if (result.auth?.authenticated) {
|
||||||
|
if (pollIntervalRef.current) {
|
||||||
|
clearInterval(pollIntervalRef.current);
|
||||||
|
pollIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
setOpencodeCliStatus({
|
||||||
|
...opencodeCliStatus,
|
||||||
|
installed: result.installed ?? true,
|
||||||
|
version: result.version,
|
||||||
|
path: result.path,
|
||||||
|
auth: result.auth,
|
||||||
|
} as OpencodeCliStatus);
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
toast.success('Successfully logged in to OpenCode!');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore polling errors
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempts >= maxAttempts) {
|
||||||
|
if (pollIntervalRef.current) {
|
||||||
|
clearInterval(pollIntervalRef.current);
|
||||||
|
pollIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
toast.error('Login timed out. Please try again.');
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Login failed:', error);
|
||||||
|
toast.error('Failed to start login process');
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isReady = opencodeCliStatus?.installed && opencodeCliStatus?.auth?.authenticated;
|
||||||
|
|
||||||
|
const getStatusBadge = () => {
|
||||||
|
if (isChecking) {
|
||||||
|
return <StatusBadge status="checking" label="Checking..." />;
|
||||||
|
}
|
||||||
|
if (opencodeCliStatus?.auth?.authenticated) {
|
||||||
|
return <StatusBadge status="authenticated" label="Ready" />;
|
||||||
|
}
|
||||||
|
if (opencodeCliStatus?.installed) {
|
||||||
|
return <StatusBadge status="unverified" label="Not Logged In" />;
|
||||||
|
}
|
||||||
|
return <StatusBadge status="not_installed" label="Not Installed" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-16 h-16 rounded-xl bg-green-500/10 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Terminal className="w-8 h-8 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-foreground mb-2">OpenCode CLI Setup</h2>
|
||||||
|
<p className="text-muted-foreground">Optional - Use OpenCode as an AI provider</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Banner */}
|
||||||
|
<Card className="bg-green-500/10 border-green-500/20">
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-green-500 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">This step is optional</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Configure OpenCode CLI for access to free tier models and AWS Bedrock models. You
|
||||||
|
can skip this and use other providers, or configure it later in Settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Status Card */}
|
||||||
|
<Card className="bg-card border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Terminal className="w-5 h-5" />
|
||||||
|
OpenCode CLI Status
|
||||||
|
<Badge variant="outline" className="ml-2">
|
||||||
|
Optional
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getStatusBadge()}
|
||||||
|
<Button variant="ghost" size="sm" onClick={checkStatus} disabled={isChecking}>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${isChecking ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
{opencodeCliStatus?.installed
|
||||||
|
? opencodeCliStatus.auth?.authenticated
|
||||||
|
? `Authenticated via ${opencodeCliStatus.auth.method === 'api_key' ? 'API Key' : 'Browser Login'}${opencodeCliStatus.version ? ` (v${opencodeCliStatus.version})` : ''}`
|
||||||
|
: 'Installed but not authenticated'
|
||||||
|
: 'Not installed on your system'}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Success State */}
|
||||||
|
{isReady && (
|
||||||
|
<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">OpenCode CLI is ready!</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
You can use OpenCode models for AI tasks.
|
||||||
|
{opencodeCliStatus?.version && (
|
||||||
|
<span className="ml-1">Version: {opencodeCliStatus.version}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Not Installed */}
|
||||||
|
{!opencodeCliStatus?.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">OpenCode CLI not found</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Install the OpenCode CLI to use free tier and AWS Bedrock 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 OpenCode 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">
|
||||||
|
{opencodeCliStatus?.installCommand || 'npm install -g opencode'}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() =>
|
||||||
|
copyCommand(opencodeCliStatus?.installCommand || 'npm install -g opencode')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="https://github.com/opencode-ai/opencode"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center text-sm text-brand-500 hover:underline mt-2"
|
||||||
|
>
|
||||||
|
View installation docs
|
||||||
|
<ExternalLink className="w-3 h-3 ml-1" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Installed but not authenticated */}
|
||||||
|
{opencodeCliStatus?.installed &&
|
||||||
|
!opencodeCliStatus?.auth?.authenticated &&
|
||||||
|
!isChecking && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<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">OpenCode CLI not authenticated</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Run the login command to authenticate with OpenCode.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 p-4 rounded-lg bg-muted/30 border border-border">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Run the login command in your terminal, then complete authentication in your
|
||||||
|
browser:
|
||||||
|
</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">
|
||||||
|
{opencodeCliStatus?.loginCommand || 'opencode auth login'}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() =>
|
||||||
|
copyCommand(opencodeCliStatus?.loginCommand || 'opencode 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 ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Waiting for login...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Copy Command & Wait for Login'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{isChecking && (
|
||||||
|
<div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||||
|
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">Checking OpenCode CLI status...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex justify-between pt-4">
|
||||||
|
<Button variant="ghost" onClick={onBack} className="text-muted-foreground">
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" onClick={onSkip} className="text-muted-foreground">
|
||||||
|
{isReady ? 'Skip' : 'Skip for now'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onNext}
|
||||||
|
className="bg-brand-500 hover:bg-brand-600 text-white"
|
||||||
|
data-testid="opencode-next-button"
|
||||||
|
>
|
||||||
|
{isReady ? 'Continue' : 'Continue without OpenCode'}
|
||||||
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info note */}
|
||||||
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
|
You can always configure OpenCode later in Settings
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1288,6 +1288,32 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}> => this.post('/api/setup/verify-codex-auth', { authMethod, apiKey }),
|
}> => this.post('/api/setup/verify-codex-auth', { authMethod, apiKey }),
|
||||||
|
|
||||||
|
// OpenCode CLI methods
|
||||||
|
getOpencodeStatus: (): 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;
|
||||||
|
hasAuthFile?: boolean;
|
||||||
|
hasOAuthToken?: boolean;
|
||||||
|
hasApiKey?: boolean;
|
||||||
|
hasStoredApiKey?: boolean;
|
||||||
|
hasEnvApiKey?: boolean;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}> => this.get('/api/setup/opencode-status'),
|
||||||
|
|
||||||
onInstallProgress: (callback: (progress: unknown) => void) => {
|
onInstallProgress: (callback: (progress: unknown) => void) => {
|
||||||
return this.subscribeToEvent('agent:stream', callback);
|
return this.subscribeToEvent('agent:stream', callback);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
AIProfile,
|
AIProfile,
|
||||||
CursorModelId,
|
CursorModelId,
|
||||||
CodexModelId,
|
CodexModelId,
|
||||||
|
OpencodeModelId,
|
||||||
PhaseModelConfig,
|
PhaseModelConfig,
|
||||||
PhaseModelKey,
|
PhaseModelKey,
|
||||||
PhaseModelEntry,
|
PhaseModelEntry,
|
||||||
@@ -23,7 +24,13 @@ import type {
|
|||||||
PipelineStep,
|
PipelineStep,
|
||||||
PromptCustomization,
|
PromptCustomization,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import { getAllCursorModelIds, getAllCodexModelIds, DEFAULT_PHASE_MODELS } from '@automaker/types';
|
import {
|
||||||
|
getAllCursorModelIds,
|
||||||
|
getAllCodexModelIds,
|
||||||
|
getAllOpencodeModelIds,
|
||||||
|
DEFAULT_PHASE_MODELS,
|
||||||
|
DEFAULT_OPENCODE_MODEL,
|
||||||
|
} from '@automaker/types';
|
||||||
|
|
||||||
const logger = createLogger('AppStore');
|
const logger = createLogger('AppStore');
|
||||||
|
|
||||||
@@ -567,6 +574,10 @@ export interface AppState {
|
|||||||
codexEnableWebSearch: boolean; // Enable web search capability
|
codexEnableWebSearch: boolean; // Enable web search capability
|
||||||
codexEnableImages: boolean; // Enable image processing
|
codexEnableImages: boolean; // Enable image processing
|
||||||
|
|
||||||
|
// OpenCode CLI Settings (global)
|
||||||
|
enabledOpencodeModels: OpencodeModelId[]; // Which OpenCode models are available in feature modal
|
||||||
|
opencodeDefaultModel: OpencodeModelId; // Default OpenCode model selection
|
||||||
|
|
||||||
// Claude Agent SDK Settings
|
// Claude Agent SDK Settings
|
||||||
autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option
|
autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option
|
||||||
skipSandboxWarning: boolean; // Skip the sandbox environment warning dialog on startup
|
skipSandboxWarning: boolean; // Skip the sandbox environment warning dialog on startup
|
||||||
@@ -938,6 +949,11 @@ export interface AppActions {
|
|||||||
setCodexEnableWebSearch: (enabled: boolean) => Promise<void>;
|
setCodexEnableWebSearch: (enabled: boolean) => Promise<void>;
|
||||||
setCodexEnableImages: (enabled: boolean) => Promise<void>;
|
setCodexEnableImages: (enabled: boolean) => Promise<void>;
|
||||||
|
|
||||||
|
// OpenCode CLI Settings actions
|
||||||
|
setEnabledOpencodeModels: (models: OpencodeModelId[]) => void;
|
||||||
|
setOpencodeDefaultModel: (model: OpencodeModelId) => void;
|
||||||
|
toggleOpencodeModel: (model: OpencodeModelId, enabled: boolean) => void;
|
||||||
|
|
||||||
// Claude Agent SDK Settings actions
|
// Claude Agent SDK Settings actions
|
||||||
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
|
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
|
||||||
setSkipSandboxWarning: (skip: boolean) => Promise<void>;
|
setSkipSandboxWarning: (skip: boolean) => Promise<void>;
|
||||||
@@ -1175,6 +1191,8 @@ const initialState: AppState = {
|
|||||||
codexApprovalPolicy: 'on-request', // Default to on-request for balanced safety
|
codexApprovalPolicy: 'on-request', // Default to on-request for balanced safety
|
||||||
codexEnableWebSearch: false, // Default to disabled
|
codexEnableWebSearch: false, // Default to disabled
|
||||||
codexEnableImages: false, // Default to disabled
|
codexEnableImages: false, // Default to disabled
|
||||||
|
enabledOpencodeModels: getAllOpencodeModelIds(), // All OpenCode models enabled by default
|
||||||
|
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, // Default to Claude Sonnet 4.5
|
||||||
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)
|
||||||
mcpServers: [], // No MCP servers configured by default
|
mcpServers: [], // No MCP servers configured by default
|
||||||
@@ -1908,6 +1926,16 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
await syncSettingsToServer();
|
await syncSettingsToServer();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// OpenCode CLI Settings actions
|
||||||
|
setEnabledOpencodeModels: (models) => set({ enabledOpencodeModels: models }),
|
||||||
|
setOpencodeDefaultModel: (model) => set({ opencodeDefaultModel: model }),
|
||||||
|
toggleOpencodeModel: (model, enabled) =>
|
||||||
|
set((state) => ({
|
||||||
|
enabledOpencodeModels: enabled
|
||||||
|
? [...state.enabledOpencodeModels, model]
|
||||||
|
: state.enabledOpencodeModels.filter((m) => m !== model),
|
||||||
|
})),
|
||||||
|
|
||||||
// Claude Agent SDK Settings actions
|
// Claude Agent SDK Settings actions
|
||||||
setAutoLoadClaudeMd: async (enabled) => {
|
setAutoLoadClaudeMd: async (enabled) => {
|
||||||
const previous = get().autoLoadClaudeMd;
|
const previous = get().autoLoadClaudeMd;
|
||||||
|
|||||||
@@ -48,6 +48,20 @@ export interface CodexCliStatus {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenCode CLI Status
|
||||||
|
export interface OpencodeCliStatus {
|
||||||
|
installed: boolean;
|
||||||
|
version?: string | null;
|
||||||
|
path?: string | null;
|
||||||
|
auth?: {
|
||||||
|
authenticated: boolean;
|
||||||
|
method: string;
|
||||||
|
};
|
||||||
|
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
|
||||||
@@ -99,10 +113,12 @@ export interface InstallProgress {
|
|||||||
export type SetupStep =
|
export type SetupStep =
|
||||||
| 'welcome'
|
| 'welcome'
|
||||||
| 'theme'
|
| 'theme'
|
||||||
|
| 'providers'
|
||||||
| 'claude_detect'
|
| 'claude_detect'
|
||||||
| 'claude_auth'
|
| 'claude_auth'
|
||||||
| 'cursor'
|
| 'cursor'
|
||||||
| 'codex'
|
| 'codex'
|
||||||
|
| 'opencode'
|
||||||
| 'github'
|
| 'github'
|
||||||
| 'complete';
|
| 'complete';
|
||||||
|
|
||||||
@@ -116,6 +132,7 @@ export interface SetupState {
|
|||||||
claudeCliStatus: CliStatus | null;
|
claudeCliStatus: CliStatus | null;
|
||||||
claudeAuthStatus: ClaudeAuthStatus | null;
|
claudeAuthStatus: ClaudeAuthStatus | null;
|
||||||
claudeInstallProgress: InstallProgress;
|
claudeInstallProgress: InstallProgress;
|
||||||
|
claudeIsVerifying: boolean;
|
||||||
|
|
||||||
// GitHub CLI state
|
// GitHub CLI state
|
||||||
ghCliStatus: GhCliStatus | null;
|
ghCliStatus: GhCliStatus | null;
|
||||||
@@ -128,6 +145,9 @@ export interface SetupState {
|
|||||||
codexAuthStatus: CodexAuthStatus | null;
|
codexAuthStatus: CodexAuthStatus | null;
|
||||||
codexInstallProgress: InstallProgress;
|
codexInstallProgress: InstallProgress;
|
||||||
|
|
||||||
|
// OpenCode CLI state
|
||||||
|
opencodeCliStatus: OpencodeCliStatus | null;
|
||||||
|
|
||||||
// Setup preferences
|
// Setup preferences
|
||||||
skipClaudeSetup: boolean;
|
skipClaudeSetup: boolean;
|
||||||
}
|
}
|
||||||
@@ -145,6 +165,7 @@ export interface SetupActions {
|
|||||||
setClaudeAuthStatus: (status: ClaudeAuthStatus | null) => void;
|
setClaudeAuthStatus: (status: ClaudeAuthStatus | null) => void;
|
||||||
setClaudeInstallProgress: (progress: Partial<InstallProgress>) => void;
|
setClaudeInstallProgress: (progress: Partial<InstallProgress>) => void;
|
||||||
resetClaudeInstallProgress: () => void;
|
resetClaudeInstallProgress: () => void;
|
||||||
|
setClaudeIsVerifying: (isVerifying: boolean) => void;
|
||||||
|
|
||||||
// GitHub CLI
|
// GitHub CLI
|
||||||
setGhCliStatus: (status: GhCliStatus | null) => void;
|
setGhCliStatus: (status: GhCliStatus | null) => void;
|
||||||
@@ -158,6 +179,9 @@ export interface SetupActions {
|
|||||||
setCodexInstallProgress: (progress: Partial<InstallProgress>) => void;
|
setCodexInstallProgress: (progress: Partial<InstallProgress>) => void;
|
||||||
resetCodexInstallProgress: () => void;
|
resetCodexInstallProgress: () => void;
|
||||||
|
|
||||||
|
// OpenCode CLI
|
||||||
|
setOpencodeCliStatus: (status: OpencodeCliStatus | null) => void;
|
||||||
|
|
||||||
// Preferences
|
// Preferences
|
||||||
setSkipClaudeSetup: (skip: boolean) => void;
|
setSkipClaudeSetup: (skip: boolean) => void;
|
||||||
}
|
}
|
||||||
@@ -180,6 +204,7 @@ const initialState: SetupState = {
|
|||||||
claudeCliStatus: null,
|
claudeCliStatus: null,
|
||||||
claudeAuthStatus: null,
|
claudeAuthStatus: null,
|
||||||
claudeInstallProgress: { ...initialInstallProgress },
|
claudeInstallProgress: { ...initialInstallProgress },
|
||||||
|
claudeIsVerifying: false,
|
||||||
|
|
||||||
ghCliStatus: null,
|
ghCliStatus: null,
|
||||||
cursorCliStatus: null,
|
cursorCliStatus: null,
|
||||||
@@ -188,6 +213,8 @@ const initialState: SetupState = {
|
|||||||
codexAuthStatus: null,
|
codexAuthStatus: null,
|
||||||
codexInstallProgress: { ...initialInstallProgress },
|
codexInstallProgress: { ...initialInstallProgress },
|
||||||
|
|
||||||
|
opencodeCliStatus: null,
|
||||||
|
|
||||||
skipClaudeSetup: shouldSkipSetup,
|
skipClaudeSetup: shouldSkipSetup,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -231,6 +258,8 @@ export const useSetupStore = create<SetupState & SetupActions>()((set, get) => (
|
|||||||
claudeInstallProgress: { ...initialInstallProgress },
|
claudeInstallProgress: { ...initialInstallProgress },
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
setClaudeIsVerifying: (isVerifying) => set({ claudeIsVerifying: isVerifying }),
|
||||||
|
|
||||||
// GitHub CLI
|
// GitHub CLI
|
||||||
setGhCliStatus: (status) => set({ ghCliStatus: status }),
|
setGhCliStatus: (status) => set({ ghCliStatus: status }),
|
||||||
|
|
||||||
@@ -255,6 +284,9 @@ export const useSetupStore = create<SetupState & SetupActions>()((set, get) => (
|
|||||||
codexInstallProgress: { ...initialInstallProgress },
|
codexInstallProgress: { ...initialInstallProgress },
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// OpenCode CLI
|
||||||
|
setOpencodeCliStatus: (status) => set({ opencodeCliStatus: status }),
|
||||||
|
|
||||||
// Preferences
|
// Preferences
|
||||||
setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }),
|
setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
cleanupTempDir,
|
cleanupTempDir,
|
||||||
authenticateForTests,
|
authenticateForTests,
|
||||||
handleLoginScreenIfPresent,
|
handleLoginScreenIfPresent,
|
||||||
|
setupWelcomeView,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
|
|
||||||
// Create unique temp dirs for this test run
|
// Create unique temp dirs for this test run
|
||||||
@@ -102,53 +103,53 @@ test.describe('Board Background Persistence', () => {
|
|||||||
JSON.stringify({ version: 1 }, null, 2)
|
JSON.stringify({ version: 1 }, null, 2)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set up app state with both projects in the list (not recent, but in projects list)
|
// Set up welcome view with both projects in the list
|
||||||
await page.addInitScript(
|
await setupWelcomeView(page, {
|
||||||
({ projects }: { projects: string[] }) => {
|
workspaceDir: TEST_TEMP_DIR,
|
||||||
const appState = {
|
recentProjects: [
|
||||||
state: {
|
{
|
||||||
projects: [
|
id: projectAId,
|
||||||
{
|
name: projectAName,
|
||||||
id: projects[0],
|
path: projectAPath,
|
||||||
name: projects[1],
|
lastOpened: new Date(Date.now() - 86400000).toISOString(),
|
||||||
path: projects[2],
|
},
|
||||||
lastOpened: new Date(Date.now() - 86400000).toISOString(),
|
{
|
||||||
theme: 'red',
|
id: projectBId,
|
||||||
},
|
name: projectBName,
|
||||||
{
|
path: projectBPath,
|
||||||
id: projects[3],
|
lastOpened: new Date(Date.now() - 172800000).toISOString(),
|
||||||
name: projects[4],
|
},
|
||||||
path: projects[5],
|
],
|
||||||
lastOpened: new Date(Date.now() - 172800000).toISOString(),
|
});
|
||||||
theme: 'red',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
currentProject: null,
|
|
||||||
currentView: 'welcome',
|
|
||||||
theme: 'red',
|
|
||||||
sidebarOpen: true,
|
|
||||||
apiKeys: { anthropic: '', google: '' },
|
|
||||||
chatSessions: [],
|
|
||||||
chatHistoryOpen: false,
|
|
||||||
maxConcurrency: 3,
|
|
||||||
boardBackgroundByProject: {},
|
|
||||||
},
|
|
||||||
version: 2,
|
|
||||||
};
|
|
||||||
localStorage.setItem('automaker-storage', JSON.stringify(appState));
|
|
||||||
|
|
||||||
// Setup complete
|
await authenticateForTests(page);
|
||||||
const setupState = {
|
|
||||||
state: {
|
// Intercept settings API to use our test projects and clear currentProjectId
|
||||||
setupComplete: true,
|
// This ensures the app shows the welcome view with our test projects
|
||||||
workspaceDir: '/tmp',
|
await page.route('**/api/settings/global', async (route) => {
|
||||||
|
const response = await route.fetch();
|
||||||
|
const json = await response.json();
|
||||||
|
if (json.settings) {
|
||||||
|
// Clear currentProjectId to show welcome view
|
||||||
|
json.settings.currentProjectId = null;
|
||||||
|
// Include our test projects so they appear in the recent projects list
|
||||||
|
json.settings.projects = [
|
||||||
|
{
|
||||||
|
id: projectAId,
|
||||||
|
name: projectAName,
|
||||||
|
path: projectAPath,
|
||||||
|
lastOpened: new Date(Date.now() - 86400000).toISOString(),
|
||||||
},
|
},
|
||||||
version: 0,
|
{
|
||||||
};
|
id: projectBId,
|
||||||
localStorage.setItem('setup-storage', JSON.stringify(setupState));
|
name: projectBName,
|
||||||
},
|
path: projectBPath,
|
||||||
{ projects: [projectAId, projectAName, projectAPath, projectBId, projectBName, projectBPath] }
|
lastOpened: new Date(Date.now() - 172800000).toISOString(),
|
||||||
);
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
await route.fulfill({ response, json });
|
||||||
|
});
|
||||||
|
|
||||||
// Track API calls to /api/settings/project to verify settings are being loaded
|
// Track API calls to /api/settings/project to verify settings are being loaded
|
||||||
const settingsApiCalls: Array<{ url: string; method: string; body: string }> = [];
|
const settingsApiCalls: Array<{ url: string; method: string; body: string }> = [];
|
||||||
@@ -163,7 +164,6 @@ test.describe('Board Background Persistence', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Navigate to the app
|
// Navigate to the app
|
||||||
await authenticateForTests(page);
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.waitForLoadState('load');
|
await page.waitForLoadState('load');
|
||||||
await handleLoginScreenIfPresent(page);
|
await handleLoginScreenIfPresent(page);
|
||||||
@@ -179,10 +179,10 @@ test.describe('Board Background Persistence', () => {
|
|||||||
// Wait for board view
|
// Wait for board view
|
||||||
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
|
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
// Verify project A is current
|
// Verify project A is current (check header paragraph which is always visible)
|
||||||
await expect(
|
await expect(page.locator('[data-testid="board-view"]').getByText(projectAName)).toBeVisible({
|
||||||
page.locator('[data-testid="project-selector"]').getByText(projectAName)
|
timeout: 5000,
|
||||||
).toBeVisible({ timeout: 5000 });
|
});
|
||||||
|
|
||||||
// CRITICAL: Wait for settings to be loaded (useProjectSettingsLoader hook)
|
// CRITICAL: Wait for settings to be loaded (useProjectSettingsLoader hook)
|
||||||
// This ensures the background settings are fetched from the server
|
// This ensures the background settings are fetched from the server
|
||||||
@@ -196,8 +196,16 @@ test.describe('Board Background Persistence', () => {
|
|||||||
// Wait for initial project load to stabilize
|
// Wait for initial project load to stabilize
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Ensure sidebar is expanded before interacting with project selector
|
||||||
|
const expandSidebarButton = page.locator('button:has-text("Expand sidebar")');
|
||||||
|
if (await expandSidebarButton.isVisible()) {
|
||||||
|
await expandSidebarButton.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
}
|
||||||
|
|
||||||
// Switch to project B (no background)
|
// Switch to project B (no background)
|
||||||
const projectSelector = page.locator('[data-testid="project-selector"]');
|
const projectSelector = page.locator('[data-testid="project-selector"]');
|
||||||
|
await expect(projectSelector).toBeVisible({ timeout: 5000 });
|
||||||
await projectSelector.click();
|
await projectSelector.click();
|
||||||
|
|
||||||
// Wait for dropdown to be visible
|
// Wait for dropdown to be visible
|
||||||
@@ -315,7 +323,6 @@ test.describe('Board Background Persistence', () => {
|
|||||||
name: project[1],
|
name: project[1],
|
||||||
path: project[2],
|
path: project[2],
|
||||||
lastOpened: new Date().toISOString(),
|
lastOpened: new Date().toISOString(),
|
||||||
theme: 'red',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const appState = {
|
const appState = {
|
||||||
@@ -323,8 +330,9 @@ test.describe('Board Background Persistence', () => {
|
|||||||
projects: [projectObj],
|
projects: [projectObj],
|
||||||
currentProject: projectObj,
|
currentProject: projectObj,
|
||||||
currentView: 'board',
|
currentView: 'board',
|
||||||
theme: 'red',
|
theme: 'dark',
|
||||||
sidebarOpen: true,
|
sidebarOpen: true,
|
||||||
|
skipSandboxWarning: true,
|
||||||
apiKeys: { anthropic: '', google: '' },
|
apiKeys: { anthropic: '', google: '' },
|
||||||
chatSessions: [],
|
chatSessions: [],
|
||||||
chatHistoryOpen: false,
|
chatHistoryOpen: false,
|
||||||
@@ -335,19 +343,44 @@ test.describe('Board Background Persistence', () => {
|
|||||||
};
|
};
|
||||||
localStorage.setItem('automaker-storage', JSON.stringify(appState));
|
localStorage.setItem('automaker-storage', JSON.stringify(appState));
|
||||||
|
|
||||||
// Setup complete
|
// Setup complete - use correct key name
|
||||||
const setupState = {
|
const setupState = {
|
||||||
state: {
|
state: {
|
||||||
|
isFirstRun: false,
|
||||||
setupComplete: true,
|
setupComplete: true,
|
||||||
workspaceDir: '/tmp',
|
skipClaudeSetup: false,
|
||||||
},
|
},
|
||||||
version: 0,
|
version: 1,
|
||||||
};
|
};
|
||||||
localStorage.setItem('setup-storage', JSON.stringify(setupState));
|
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||||
|
|
||||||
|
// Disable splash screen in tests
|
||||||
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
},
|
},
|
||||||
{ project: [projectId, projectName, projectPath] }
|
{ project: [projectId, projectName, projectPath] }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await authenticateForTests(page);
|
||||||
|
|
||||||
|
// Intercept settings API to use our test project instead of the E2E fixture
|
||||||
|
await page.route('**/api/settings/global', async (route) => {
|
||||||
|
const response = await route.fetch();
|
||||||
|
const json = await response.json();
|
||||||
|
// Override to use our test project
|
||||||
|
if (json.settings) {
|
||||||
|
json.settings.currentProjectId = projectId;
|
||||||
|
json.settings.projects = [
|
||||||
|
{
|
||||||
|
id: projectId,
|
||||||
|
name: projectName,
|
||||||
|
path: projectPath,
|
||||||
|
lastOpened: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
await route.fulfill({ response, json });
|
||||||
|
});
|
||||||
|
|
||||||
// Track API calls to /api/settings/project to verify settings are being loaded
|
// Track API calls to /api/settings/project to verify settings are being loaded
|
||||||
const settingsApiCalls: Array<{ url: string; method: string; body: string }> = [];
|
const settingsApiCalls: Array<{ url: string; method: string; body: string }> = [];
|
||||||
page.on('request', (request) => {
|
page.on('request', (request) => {
|
||||||
@@ -360,8 +393,7 @@ test.describe('Board Background Persistence', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Navigate and authenticate
|
// Navigate to the app
|
||||||
await authenticateForTests(page);
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.waitForLoadState('load');
|
await page.waitForLoadState('load');
|
||||||
await handleLoginScreenIfPresent(page);
|
await handleLoginScreenIfPresent(page);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { authenticateForTests } from '../utils';
|
import { authenticateForTests, handleLoginScreenIfPresent } from '../utils';
|
||||||
|
|
||||||
const SETTINGS_PATH = path.resolve(process.cwd(), '../server/data/settings.json');
|
const SETTINGS_PATH = path.resolve(process.cwd(), '../server/data/settings.json');
|
||||||
const WORKSPACE_ROOT = path.resolve(process.cwd(), '../..');
|
const WORKSPACE_ROOT = path.resolve(process.cwd(), '../..');
|
||||||
@@ -109,6 +109,8 @@ test.describe('Settings startup sync race', () => {
|
|||||||
// Ensure authenticated and app is loaded at least to welcome/board.
|
// Ensure authenticated and app is loaded at least to welcome/board.
|
||||||
await authenticateForTests(page);
|
await authenticateForTests(page);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
await page.waitForLoadState('load');
|
||||||
|
await handleLoginScreenIfPresent(page);
|
||||||
await page
|
await page
|
||||||
.locator('[data-testid="welcome-view"], [data-testid="board-view"]')
|
.locator('[data-testid="welcome-view"], [data-testid="board-view"]')
|
||||||
.first()
|
.first()
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ export {
|
|||||||
getCodexCliPaths,
|
getCodexCliPaths,
|
||||||
getCodexConfigDir,
|
getCodexConfigDir,
|
||||||
getCodexAuthPath,
|
getCodexAuthPath,
|
||||||
|
getOpenCodeCliPaths,
|
||||||
|
getOpenCodeConfigDir,
|
||||||
|
getOpenCodeAuthPath,
|
||||||
getShellPaths,
|
getShellPaths,
|
||||||
getExtendedPath,
|
getExtendedPath,
|
||||||
// Node.js paths
|
// Node.js paths
|
||||||
@@ -126,6 +129,9 @@ export {
|
|||||||
findCodexCliPath,
|
findCodexCliPath,
|
||||||
getCodexAuthIndicators,
|
getCodexAuthIndicators,
|
||||||
type CodexAuthIndicators,
|
type CodexAuthIndicators,
|
||||||
|
findOpenCodeCliPath,
|
||||||
|
getOpenCodeAuthIndicators,
|
||||||
|
type OpenCodeAuthIndicators,
|
||||||
// Electron userData operations
|
// Electron userData operations
|
||||||
setElectronUserDataPath,
|
setElectronUserDataPath,
|
||||||
getElectronUserDataPath,
|
getElectronUserDataPath,
|
||||||
|
|||||||
@@ -543,6 +543,11 @@ function getAllAllowedSystemPaths(): string[] {
|
|||||||
// Codex config directory and files
|
// Codex config directory and files
|
||||||
getCodexConfigDir(),
|
getCodexConfigDir(),
|
||||||
getCodexAuthPath(),
|
getCodexAuthPath(),
|
||||||
|
// OpenCode CLI paths
|
||||||
|
...getOpenCodeCliPaths(),
|
||||||
|
// OpenCode config directory and files
|
||||||
|
getOpenCodeConfigDir(),
|
||||||
|
getOpenCodeAuthPath(),
|
||||||
// Shell paths
|
// Shell paths
|
||||||
...getShellPaths(),
|
...getShellPaths(),
|
||||||
// Node.js system paths
|
// Node.js system paths
|
||||||
@@ -564,6 +569,8 @@ function getAllAllowedSystemDirs(): string[] {
|
|||||||
getClaudeProjectsDir(),
|
getClaudeProjectsDir(),
|
||||||
// Codex config
|
// Codex config
|
||||||
getCodexConfigDir(),
|
getCodexConfigDir(),
|
||||||
|
// OpenCode config
|
||||||
|
getOpenCodeConfigDir(),
|
||||||
// Version managers (need recursive access for version directories)
|
// Version managers (need recursive access for version directories)
|
||||||
...getNvmPaths(),
|
...getNvmPaths(),
|
||||||
...getFnmPaths(),
|
...getFnmPaths(),
|
||||||
@@ -1007,3 +1014,209 @@ export async function getCodexAuthIndicators(): Promise<CodexAuthIndicators> {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// OpenCode CLI Detection
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const OPENCODE_DATA_DIR = '.local/share/opencode';
|
||||||
|
const OPENCODE_AUTH_FILENAME = 'auth.json';
|
||||||
|
const OPENCODE_TOKENS_KEY = 'tokens';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get common paths where OpenCode CLI might be installed
|
||||||
|
*/
|
||||||
|
export function getOpenCodeCliPaths(): string[] {
|
||||||
|
const isWindows = process.platform === 'win32';
|
||||||
|
const homeDir = os.homedir();
|
||||||
|
|
||||||
|
if (isWindows) {
|
||||||
|
const appData = process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming');
|
||||||
|
const localAppData = process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local');
|
||||||
|
return [
|
||||||
|
// OpenCode's default installation directory
|
||||||
|
path.join(homeDir, '.opencode', 'bin', 'opencode.exe'),
|
||||||
|
path.join(homeDir, '.local', 'bin', 'opencode.exe'),
|
||||||
|
path.join(appData, 'npm', 'opencode.cmd'),
|
||||||
|
path.join(appData, 'npm', 'opencode'),
|
||||||
|
path.join(appData, '.npm-global', 'bin', 'opencode.cmd'),
|
||||||
|
path.join(appData, '.npm-global', 'bin', 'opencode'),
|
||||||
|
// Volta on Windows
|
||||||
|
path.join(homeDir, '.volta', 'bin', 'opencode.exe'),
|
||||||
|
// pnpm on Windows
|
||||||
|
path.join(localAppData, 'pnpm', 'opencode.cmd'),
|
||||||
|
path.join(localAppData, 'pnpm', 'opencode'),
|
||||||
|
// Go installation (if OpenCode is a Go binary)
|
||||||
|
path.join(homeDir, 'go', 'bin', 'opencode.exe'),
|
||||||
|
path.join(process.env.GOPATH || path.join(homeDir, 'go'), 'bin', 'opencode.exe'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include NVM bin paths for opencode installed via npm global under NVM
|
||||||
|
const nvmBinPaths = getNvmBinPaths().map((binPath) => path.join(binPath, 'opencode'));
|
||||||
|
|
||||||
|
// Include fnm bin paths
|
||||||
|
const fnmBinPaths = getFnmBinPaths().map((binPath) => path.join(binPath, 'opencode'));
|
||||||
|
|
||||||
|
// pnpm global bin path
|
||||||
|
const pnpmHome = process.env.PNPM_HOME || path.join(homeDir, '.local', 'share', 'pnpm');
|
||||||
|
|
||||||
|
return [
|
||||||
|
// OpenCode's default installation directory
|
||||||
|
path.join(homeDir, '.opencode', 'bin', 'opencode'),
|
||||||
|
// Standard locations
|
||||||
|
path.join(homeDir, '.local', 'bin', 'opencode'),
|
||||||
|
'/opt/homebrew/bin/opencode',
|
||||||
|
'/usr/local/bin/opencode',
|
||||||
|
'/usr/bin/opencode',
|
||||||
|
path.join(homeDir, '.npm-global', 'bin', 'opencode'),
|
||||||
|
// Linuxbrew
|
||||||
|
'/home/linuxbrew/.linuxbrew/bin/opencode',
|
||||||
|
// Volta
|
||||||
|
path.join(homeDir, '.volta', 'bin', 'opencode'),
|
||||||
|
// pnpm global
|
||||||
|
path.join(pnpmHome, 'opencode'),
|
||||||
|
// Yarn global
|
||||||
|
path.join(homeDir, '.yarn', 'bin', 'opencode'),
|
||||||
|
path.join(homeDir, '.config', 'yarn', 'global', 'node_modules', '.bin', 'opencode'),
|
||||||
|
// Go installation (if OpenCode is a Go binary)
|
||||||
|
path.join(homeDir, 'go', 'bin', 'opencode'),
|
||||||
|
path.join(process.env.GOPATH || path.join(homeDir, 'go'), 'bin', 'opencode'),
|
||||||
|
// Snap packages
|
||||||
|
'/snap/bin/opencode',
|
||||||
|
// NVM paths
|
||||||
|
...nvmBinPaths,
|
||||||
|
// fnm paths
|
||||||
|
...fnmBinPaths,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the OpenCode data directory path
|
||||||
|
* macOS/Linux: ~/.local/share/opencode
|
||||||
|
* Windows: %USERPROFILE%\.local\share\opencode
|
||||||
|
*/
|
||||||
|
export function getOpenCodeConfigDir(): string {
|
||||||
|
return path.join(os.homedir(), OPENCODE_DATA_DIR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get path to OpenCode auth file
|
||||||
|
*/
|
||||||
|
export function getOpenCodeAuthPath(): string {
|
||||||
|
return path.join(getOpenCodeConfigDir(), OPENCODE_AUTH_FILENAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if OpenCode CLI is installed and return its path
|
||||||
|
*/
|
||||||
|
export async function findOpenCodeCliPath(): Promise<string | null> {
|
||||||
|
return findFirstExistingPath(getOpenCodeCliPaths());
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenCodeAuthIndicators {
|
||||||
|
hasAuthFile: boolean;
|
||||||
|
hasOAuthToken: boolean;
|
||||||
|
hasApiKey: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPENCODE_OAUTH_KEYS = ['access_token', 'oauth_token'] as const;
|
||||||
|
const OPENCODE_API_KEY_KEYS = ['api_key', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY'] as const;
|
||||||
|
|
||||||
|
// Provider names that OpenCode uses for provider-specific auth entries
|
||||||
|
const OPENCODE_PROVIDERS = ['anthropic', 'openai', 'google', 'bedrock', 'amazon-bedrock'] as const;
|
||||||
|
|
||||||
|
function getOpenCodeNestedTokens(record: Record<string, unknown>): Record<string, unknown> | null {
|
||||||
|
const tokens = record[OPENCODE_TOKENS_KEY];
|
||||||
|
if (tokens && typeof tokens === 'object' && !Array.isArray(tokens)) {
|
||||||
|
return tokens as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the auth JSON has provider-specific OAuth credentials
|
||||||
|
* OpenCode stores auth in format: { "anthropic": { "type": "oauth", "access": "...", "refresh": "..." } }
|
||||||
|
*/
|
||||||
|
function hasProviderOAuth(authJson: Record<string, unknown>): boolean {
|
||||||
|
for (const provider of OPENCODE_PROVIDERS) {
|
||||||
|
const providerAuth = authJson[provider];
|
||||||
|
if (providerAuth && typeof providerAuth === 'object' && !Array.isArray(providerAuth)) {
|
||||||
|
const auth = providerAuth as Record<string, unknown>;
|
||||||
|
// Check for OAuth type with access token
|
||||||
|
if (auth.type === 'oauth' && typeof auth.access === 'string' && auth.access) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Also check for access_token field directly
|
||||||
|
if (typeof auth.access_token === 'string' && auth.access_token) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the auth JSON has provider-specific API key credentials
|
||||||
|
*/
|
||||||
|
function hasProviderApiKey(authJson: Record<string, unknown>): boolean {
|
||||||
|
for (const provider of OPENCODE_PROVIDERS) {
|
||||||
|
const providerAuth = authJson[provider];
|
||||||
|
if (providerAuth && typeof providerAuth === 'object' && !Array.isArray(providerAuth)) {
|
||||||
|
const auth = providerAuth as Record<string, unknown>;
|
||||||
|
// Check for API key type
|
||||||
|
if (auth.type === 'api_key' && typeof auth.key === 'string' && auth.key) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Also check for api_key field directly
|
||||||
|
if (typeof auth.api_key === 'string' && auth.api_key) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get OpenCode authentication status by checking auth file indicators
|
||||||
|
*/
|
||||||
|
export async function getOpenCodeAuthIndicators(): Promise<OpenCodeAuthIndicators> {
|
||||||
|
const result: OpenCodeAuthIndicators = {
|
||||||
|
hasAuthFile: false,
|
||||||
|
hasOAuthToken: false,
|
||||||
|
hasApiKey: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const authContent = await systemPathReadFile(getOpenCodeAuthPath());
|
||||||
|
result.hasAuthFile = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const authJson = JSON.parse(authContent) as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Check for legacy top-level keys
|
||||||
|
result.hasOAuthToken = hasNonEmptyStringField(authJson, OPENCODE_OAUTH_KEYS);
|
||||||
|
result.hasApiKey = hasNonEmptyStringField(authJson, OPENCODE_API_KEY_KEYS);
|
||||||
|
|
||||||
|
// Check for nested tokens object (legacy format)
|
||||||
|
const nestedTokens = getOpenCodeNestedTokens(authJson);
|
||||||
|
if (nestedTokens) {
|
||||||
|
result.hasOAuthToken =
|
||||||
|
result.hasOAuthToken || hasNonEmptyStringField(nestedTokens, OPENCODE_OAUTH_KEYS);
|
||||||
|
result.hasApiKey =
|
||||||
|
result.hasApiKey || hasNonEmptyStringField(nestedTokens, OPENCODE_API_KEY_KEYS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for provider-specific auth entries (current OpenCode format)
|
||||||
|
// Format: { "anthropic": { "type": "oauth", "access": "...", "refresh": "..." } }
|
||||||
|
result.hasOAuthToken = result.hasOAuthToken || hasProviderOAuth(authJson);
|
||||||
|
result.hasApiKey = result.hasApiKey || hasProviderApiKey(authJson);
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors; file exists but contents are unreadable
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Auth file not found or inaccessible
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
@@ -175,12 +175,16 @@ export type {
|
|||||||
export * from './cursor-models.js';
|
export * from './cursor-models.js';
|
||||||
export * from './cursor-cli.js';
|
export * from './cursor-cli.js';
|
||||||
|
|
||||||
|
// OpenCode types
|
||||||
|
export * from './opencode-models.js';
|
||||||
|
|
||||||
// Provider utilities
|
// Provider utilities
|
||||||
export {
|
export {
|
||||||
PROVIDER_PREFIXES,
|
PROVIDER_PREFIXES,
|
||||||
isCursorModel,
|
isCursorModel,
|
||||||
isClaudeModel,
|
isClaudeModel,
|
||||||
isCodexModel,
|
isCodexModel,
|
||||||
|
isOpencodeModel,
|
||||||
getModelProvider,
|
getModelProvider,
|
||||||
stripProviderPrefix,
|
stripProviderPrefix,
|
||||||
addProviderPrefix,
|
addProviderPrefix,
|
||||||
|
|||||||
397
libs/types/src/opencode-models.ts
Normal file
397
libs/types/src/opencode-models.ts
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
/**
|
||||||
|
* OpenCode Model IDs
|
||||||
|
* Models available via OpenCode CLI (opencode models command)
|
||||||
|
*/
|
||||||
|
export type OpencodeModelId =
|
||||||
|
// OpenCode Free Tier Models
|
||||||
|
| 'opencode/big-pickle'
|
||||||
|
| 'opencode/glm-4.7-free'
|
||||||
|
| 'opencode/gpt-5-nano'
|
||||||
|
| 'opencode/grok-code'
|
||||||
|
| 'opencode/minimax-m2.1-free'
|
||||||
|
// Amazon Bedrock - Claude Models
|
||||||
|
| 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0'
|
||||||
|
| 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0'
|
||||||
|
| 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0'
|
||||||
|
| 'amazon-bedrock/anthropic.claude-sonnet-4-20250514-v1:0'
|
||||||
|
| 'amazon-bedrock/anthropic.claude-opus-4-20250514-v1:0'
|
||||||
|
| 'amazon-bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0'
|
||||||
|
| 'amazon-bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0'
|
||||||
|
| 'amazon-bedrock/anthropic.claude-3-opus-20240229-v1:0'
|
||||||
|
// Amazon Bedrock - DeepSeek Models
|
||||||
|
| 'amazon-bedrock/deepseek.r1-v1:0'
|
||||||
|
| 'amazon-bedrock/deepseek.v3-v1:0'
|
||||||
|
// Amazon Bedrock - Amazon Nova Models
|
||||||
|
| 'amazon-bedrock/amazon.nova-premier-v1:0'
|
||||||
|
| 'amazon-bedrock/amazon.nova-pro-v1:0'
|
||||||
|
| 'amazon-bedrock/amazon.nova-lite-v1:0'
|
||||||
|
// Amazon Bedrock - Meta Llama Models
|
||||||
|
| 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0'
|
||||||
|
| 'amazon-bedrock/meta.llama3-3-70b-instruct-v1:0'
|
||||||
|
// Amazon Bedrock - Mistral Models
|
||||||
|
| 'amazon-bedrock/mistral.mistral-large-2402-v1:0'
|
||||||
|
// Amazon Bedrock - Qwen Models
|
||||||
|
| 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0'
|
||||||
|
| 'amazon-bedrock/qwen.qwen3-235b-a22b-2507-v1:0';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider type for OpenCode models
|
||||||
|
*/
|
||||||
|
export type OpencodeProvider =
|
||||||
|
| 'opencode'
|
||||||
|
| 'amazon-bedrock-anthropic'
|
||||||
|
| 'amazon-bedrock-deepseek'
|
||||||
|
| 'amazon-bedrock-amazon'
|
||||||
|
| 'amazon-bedrock-meta'
|
||||||
|
| 'amazon-bedrock-mistral'
|
||||||
|
| 'amazon-bedrock-qwen';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Friendly aliases mapped to full model IDs
|
||||||
|
*/
|
||||||
|
export const OPENCODE_MODEL_MAP: Record<string, OpencodeModelId> = {
|
||||||
|
// OpenCode free tier aliases
|
||||||
|
'big-pickle': 'opencode/big-pickle',
|
||||||
|
pickle: 'opencode/big-pickle',
|
||||||
|
'glm-free': 'opencode/glm-4.7-free',
|
||||||
|
'gpt-nano': 'opencode/gpt-5-nano',
|
||||||
|
nano: 'opencode/gpt-5-nano',
|
||||||
|
'grok-code': 'opencode/grok-code',
|
||||||
|
grok: 'opencode/grok-code',
|
||||||
|
minimax: 'opencode/minimax-m2.1-free',
|
||||||
|
|
||||||
|
// Claude aliases (via Bedrock)
|
||||||
|
'claude-sonnet-4.5': 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0',
|
||||||
|
'sonnet-4.5': 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0',
|
||||||
|
sonnet: 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0',
|
||||||
|
'claude-opus-4.5': 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0',
|
||||||
|
'opus-4.5': 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0',
|
||||||
|
opus: 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0',
|
||||||
|
'claude-haiku-4.5': 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0',
|
||||||
|
'haiku-4.5': 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0',
|
||||||
|
haiku: 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0',
|
||||||
|
|
||||||
|
// DeepSeek aliases
|
||||||
|
'deepseek-r1': 'amazon-bedrock/deepseek.r1-v1:0',
|
||||||
|
r1: 'amazon-bedrock/deepseek.r1-v1:0',
|
||||||
|
'deepseek-v3': 'amazon-bedrock/deepseek.v3-v1:0',
|
||||||
|
|
||||||
|
// Nova aliases
|
||||||
|
'nova-premier': 'amazon-bedrock/amazon.nova-premier-v1:0',
|
||||||
|
'nova-pro': 'amazon-bedrock/amazon.nova-pro-v1:0',
|
||||||
|
nova: 'amazon-bedrock/amazon.nova-pro-v1:0',
|
||||||
|
|
||||||
|
// Llama aliases
|
||||||
|
llama4: 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0',
|
||||||
|
'llama-4': 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0',
|
||||||
|
llama3: 'amazon-bedrock/meta.llama3-3-70b-instruct-v1:0',
|
||||||
|
|
||||||
|
// Qwen aliases
|
||||||
|
qwen: 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0',
|
||||||
|
'qwen-coder': 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenCode model metadata
|
||||||
|
*/
|
||||||
|
export interface OpencodeModelConfig {
|
||||||
|
id: OpencodeModelId;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
supportsVision: boolean;
|
||||||
|
provider: OpencodeProvider;
|
||||||
|
tier: 'free' | 'standard' | 'premium';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete list of OpenCode model configurations
|
||||||
|
*/
|
||||||
|
export const OPENCODE_MODELS: OpencodeModelConfig[] = [
|
||||||
|
// OpenCode Free Tier Models
|
||||||
|
{
|
||||||
|
id: 'opencode/big-pickle',
|
||||||
|
label: 'Big Pickle',
|
||||||
|
description: 'OpenCode free tier model - great for general coding',
|
||||||
|
supportsVision: false,
|
||||||
|
provider: 'opencode',
|
||||||
|
tier: 'free',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'opencode/glm-4.7-free',
|
||||||
|
label: 'GLM 4.7 Free',
|
||||||
|
description: 'OpenCode free tier GLM model',
|
||||||
|
supportsVision: false,
|
||||||
|
provider: 'opencode',
|
||||||
|
tier: 'free',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'opencode/gpt-5-nano',
|
||||||
|
label: 'GPT-5 Nano',
|
||||||
|
description: 'OpenCode free tier nano model - fast and lightweight',
|
||||||
|
supportsVision: false,
|
||||||
|
provider: 'opencode',
|
||||||
|
tier: 'free',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'opencode/grok-code',
|
||||||
|
label: 'Grok Code',
|
||||||
|
description: 'OpenCode free tier Grok model for coding',
|
||||||
|
supportsVision: false,
|
||||||
|
provider: 'opencode',
|
||||||
|
tier: 'free',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'opencode/minimax-m2.1-free',
|
||||||
|
label: 'MiniMax M2.1 Free',
|
||||||
|
description: 'OpenCode free tier MiniMax model',
|
||||||
|
supportsVision: false,
|
||||||
|
provider: 'opencode',
|
||||||
|
tier: 'free',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Amazon Bedrock - Claude Models
|
||||||
|
{
|
||||||
|
id: 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0',
|
||||||
|
label: 'Claude Sonnet 4.5 (Bedrock)',
|
||||||
|
description: 'Latest Claude Sonnet via AWS Bedrock - fast and intelligent (default)',
|
||||||
|
supportsVision: true,
|
||||||
|
provider: 'amazon-bedrock-anthropic',
|
||||||
|
tier: 'premium',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0',
|
||||||
|
label: 'Claude Opus 4.5 (Bedrock)',
|
||||||
|
description: 'Most capable Claude model via AWS Bedrock',
|
||||||
|
supportsVision: true,
|
||||||
|
provider: 'amazon-bedrock-anthropic',
|
||||||
|
tier: 'premium',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0',
|
||||||
|
label: 'Claude Haiku 4.5 (Bedrock)',
|
||||||
|
description: 'Fastest Claude model via AWS Bedrock',
|
||||||
|
supportsVision: true,
|
||||||
|
provider: 'amazon-bedrock-anthropic',
|
||||||
|
tier: 'standard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'amazon-bedrock/anthropic.claude-sonnet-4-20250514-v1:0',
|
||||||
|
label: 'Claude Sonnet 4 (Bedrock)',
|
||||||
|
description: 'Claude Sonnet 4 via AWS Bedrock',
|
||||||
|
supportsVision: true,
|
||||||
|
provider: 'amazon-bedrock-anthropic',
|
||||||
|
tier: 'premium',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'amazon-bedrock/anthropic.claude-opus-4-20250514-v1:0',
|
||||||
|
label: 'Claude Opus 4 (Bedrock)',
|
||||||
|
description: 'Claude Opus 4 via AWS Bedrock',
|
||||||
|
supportsVision: true,
|
||||||
|
provider: 'amazon-bedrock-anthropic',
|
||||||
|
tier: 'premium',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'amazon-bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0',
|
||||||
|
label: 'Claude 3.7 Sonnet (Bedrock)',
|
||||||
|
description: 'Claude 3.7 Sonnet via AWS Bedrock',
|
||||||
|
supportsVision: true,
|
||||||
|
provider: 'amazon-bedrock-anthropic',
|
||||||
|
tier: 'standard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'amazon-bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0',
|
||||||
|
label: 'Claude 3.5 Sonnet (Bedrock)',
|
||||||
|
description: 'Claude 3.5 Sonnet v2 via AWS Bedrock',
|
||||||
|
supportsVision: true,
|
||||||
|
provider: 'amazon-bedrock-anthropic',
|
||||||
|
tier: 'standard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'amazon-bedrock/anthropic.claude-3-opus-20240229-v1:0',
|
||||||
|
label: 'Claude 3 Opus (Bedrock)',
|
||||||
|
description: 'Claude 3 Opus via AWS Bedrock',
|
||||||
|
supportsVision: true,
|
||||||
|
provider: 'amazon-bedrock-anthropic',
|
||||||
|
tier: 'premium',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Amazon Bedrock - DeepSeek Models
|
||||||
|
{
|
||||||
|
id: 'amazon-bedrock/deepseek.r1-v1:0',
|
||||||
|
label: 'DeepSeek R1 (Bedrock)',
|
||||||
|
description: 'DeepSeek R1 reasoning model via AWS Bedrock - excellent for coding',
|
||||||
|
supportsVision: false,
|
||||||
|
provider: 'amazon-bedrock-deepseek',
|
||||||
|
tier: 'premium',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'amazon-bedrock/deepseek.v3-v1:0',
|
||||||
|
label: 'DeepSeek V3 (Bedrock)',
|
||||||
|
description: 'DeepSeek V3 via AWS Bedrock',
|
||||||
|
supportsVision: false,
|
||||||
|
provider: 'amazon-bedrock-deepseek',
|
||||||
|
tier: 'standard',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Amazon Bedrock - Amazon Nova Models
|
||||||
|
{
|
||||||
|
id: 'amazon-bedrock/amazon.nova-premier-v1:0',
|
||||||
|
label: 'Amazon Nova Premier (Bedrock)',
|
||||||
|
description: 'Amazon Nova Premier - most capable Nova model',
|
||||||
|
supportsVision: true,
|
||||||
|
provider: 'amazon-bedrock-amazon',
|
||||||
|
tier: 'premium',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'amazon-bedrock/amazon.nova-pro-v1:0',
|
||||||
|
label: 'Amazon Nova Pro (Bedrock)',
|
||||||
|
description: 'Amazon Nova Pro - balanced performance',
|
||||||
|
supportsVision: true,
|
||||||
|
provider: 'amazon-bedrock-amazon',
|
||||||
|
tier: 'standard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'amazon-bedrock/amazon.nova-lite-v1:0',
|
||||||
|
label: 'Amazon Nova Lite (Bedrock)',
|
||||||
|
description: 'Amazon Nova Lite - fast and efficient',
|
||||||
|
supportsVision: true,
|
||||||
|
provider: 'amazon-bedrock-amazon',
|
||||||
|
tier: 'standard',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Amazon Bedrock - Meta Llama Models
|
||||||
|
{
|
||||||
|
id: 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0',
|
||||||
|
label: 'Llama 4 Maverick 17B (Bedrock)',
|
||||||
|
description: 'Meta Llama 4 Maverick via AWS Bedrock',
|
||||||
|
supportsVision: false,
|
||||||
|
provider: 'amazon-bedrock-meta',
|
||||||
|
tier: 'standard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'amazon-bedrock/meta.llama3-3-70b-instruct-v1:0',
|
||||||
|
label: 'Llama 3.3 70B (Bedrock)',
|
||||||
|
description: 'Meta Llama 3.3 70B via AWS Bedrock',
|
||||||
|
supportsVision: false,
|
||||||
|
provider: 'amazon-bedrock-meta',
|
||||||
|
tier: 'standard',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Amazon Bedrock - Mistral Models
|
||||||
|
{
|
||||||
|
id: 'amazon-bedrock/mistral.mistral-large-2402-v1:0',
|
||||||
|
label: 'Mistral Large (Bedrock)',
|
||||||
|
description: 'Mistral Large via AWS Bedrock',
|
||||||
|
supportsVision: false,
|
||||||
|
provider: 'amazon-bedrock-mistral',
|
||||||
|
tier: 'standard',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Amazon Bedrock - Qwen Models
|
||||||
|
{
|
||||||
|
id: 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0',
|
||||||
|
label: 'Qwen3 Coder 480B (Bedrock)',
|
||||||
|
description: 'Qwen3 Coder 480B via AWS Bedrock - excellent for coding',
|
||||||
|
supportsVision: false,
|
||||||
|
provider: 'amazon-bedrock-qwen',
|
||||||
|
tier: 'premium',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'amazon-bedrock/qwen.qwen3-235b-a22b-2507-v1:0',
|
||||||
|
label: 'Qwen3 235B (Bedrock)',
|
||||||
|
description: 'Qwen3 235B via AWS Bedrock',
|
||||||
|
supportsVision: false,
|
||||||
|
provider: 'amazon-bedrock-qwen',
|
||||||
|
tier: 'premium',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete model configuration map indexed by model ID
|
||||||
|
*/
|
||||||
|
export const OPENCODE_MODEL_CONFIG_MAP: Record<OpencodeModelId, OpencodeModelConfig> =
|
||||||
|
OPENCODE_MODELS.reduce(
|
||||||
|
(acc, config) => {
|
||||||
|
acc[config.id] = config;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<OpencodeModelId, OpencodeModelConfig>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default OpenCode model - Claude Sonnet 4.5 via Bedrock
|
||||||
|
*/
|
||||||
|
export const DEFAULT_OPENCODE_MODEL: OpencodeModelId =
|
||||||
|
'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Get display name for model
|
||||||
|
*/
|
||||||
|
export function getOpencodeModelLabel(modelId: OpencodeModelId): string {
|
||||||
|
return OPENCODE_MODEL_CONFIG_MAP[modelId]?.label ?? modelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Get all OpenCode model IDs
|
||||||
|
*/
|
||||||
|
export function getAllOpencodeModelIds(): OpencodeModelId[] {
|
||||||
|
return OPENCODE_MODELS.map((config) => config.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Check if OpenCode model supports vision
|
||||||
|
*/
|
||||||
|
export function opencodeModelSupportsVision(modelId: OpencodeModelId): boolean {
|
||||||
|
return OPENCODE_MODEL_CONFIG_MAP[modelId]?.supportsVision ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Get the provider for a model
|
||||||
|
*/
|
||||||
|
export function getOpencodeModelProvider(modelId: OpencodeModelId): OpencodeProvider {
|
||||||
|
return OPENCODE_MODEL_CONFIG_MAP[modelId]?.provider ?? 'opencode';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Resolve an alias or partial model ID to a full model ID
|
||||||
|
*/
|
||||||
|
export function resolveOpencodeModelId(input: string): OpencodeModelId | undefined {
|
||||||
|
// Check if it's already a valid model ID
|
||||||
|
if (OPENCODE_MODEL_CONFIG_MAP[input as OpencodeModelId]) {
|
||||||
|
return input as OpencodeModelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check alias map
|
||||||
|
const normalized = input.toLowerCase();
|
||||||
|
return OPENCODE_MODEL_MAP[normalized];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Check if a string is a valid OpenCode model ID
|
||||||
|
*/
|
||||||
|
export function isOpencodeModelId(value: string): value is OpencodeModelId {
|
||||||
|
return value in OPENCODE_MODEL_CONFIG_MAP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Get models filtered by provider
|
||||||
|
*/
|
||||||
|
export function getOpencodeModelsByProvider(provider: OpencodeProvider): OpencodeModelConfig[] {
|
||||||
|
return OPENCODE_MODELS.filter((config) => config.provider === provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Get models filtered by tier
|
||||||
|
*/
|
||||||
|
export function getOpencodeModelsByTier(
|
||||||
|
tier: 'free' | 'standard' | 'premium'
|
||||||
|
): OpencodeModelConfig[] {
|
||||||
|
return OPENCODE_MODELS.filter((config) => config.tier === tier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Get free tier models
|
||||||
|
*/
|
||||||
|
export function getOpencodeFreeModels(): OpencodeModelConfig[] {
|
||||||
|
return getOpencodeModelsByTier('free');
|
||||||
|
}
|
||||||
@@ -7,14 +7,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ModelProvider } from './settings.js';
|
import type { ModelProvider } from './settings.js';
|
||||||
import { CURSOR_MODEL_MAP, type CursorModelId } from './cursor-models.js';
|
import { 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 { CODEX_MODEL_CONFIG_MAP, type CodexModelId } from './codex-models.js';
|
import { OPENCODE_MODEL_CONFIG_MAP } from './opencode-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-',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,6 +87,41 @@ 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 an OpenCode model
|
||||||
|
*
|
||||||
|
* OpenCode models can be identified by:
|
||||||
|
* - Explicit 'opencode-' prefix (for routing in Automaker)
|
||||||
|
* - 'opencode/' prefix (OpenCode free tier models)
|
||||||
|
* - 'amazon-bedrock/' prefix (AWS Bedrock models via OpenCode)
|
||||||
|
* - Full model ID from OPENCODE_MODEL_CONFIG_MAP
|
||||||
|
*
|
||||||
|
* @param model - Model string to check (e.g., "opencode-sonnet", "opencode/big-pickle", "amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0")
|
||||||
|
* @returns true if the model is an OpenCode model
|
||||||
|
*/
|
||||||
|
export function isOpencodeModel(model: string | undefined | null): boolean {
|
||||||
|
if (!model || typeof model !== 'string') return false;
|
||||||
|
|
||||||
|
// Check for explicit opencode- prefix (Automaker routing prefix)
|
||||||
|
if (model.startsWith(PROVIDER_PREFIXES.opencode)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a known OpenCode model ID
|
||||||
|
if (model in OPENCODE_MODEL_CONFIG_MAP) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for OpenCode native model prefixes
|
||||||
|
// - opencode/ = OpenCode free tier models (e.g., opencode/big-pickle)
|
||||||
|
// - amazon-bedrock/ = AWS Bedrock models (e.g., amazon-bedrock/anthropic.claude-*)
|
||||||
|
if (model.startsWith('opencode/') || model.startsWith('amazon-bedrock/')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the provider for a model string
|
* Get the provider for a model string
|
||||||
*
|
*
|
||||||
@@ -93,7 +129,11 @@ export function isCodexModel(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 Codex first before Cursor, since Cursor also supports gpt models
|
// Check OpenCode first since it uses provider-prefixed formats that could conflict
|
||||||
|
if (isOpencodeModel(model)) {
|
||||||
|
return 'opencode';
|
||||||
|
}
|
||||||
|
// Check Codex before Cursor, since Cursor also supports gpt models
|
||||||
// but bare gpt-* should route to Codex
|
// but bare gpt-* should route to Codex
|
||||||
if (isCodexModel(model)) {
|
if (isCodexModel(model)) {
|
||||||
return 'codex';
|
return 'codex';
|
||||||
@@ -149,6 +189,10 @@ export function addProviderPrefix(model: string, provider: ModelProvider): strin
|
|||||||
if (!model.startsWith(PROVIDER_PREFIXES.codex)) {
|
if (!model.startsWith(PROVIDER_PREFIXES.codex)) {
|
||||||
return `${PROVIDER_PREFIXES.codex}${model}`;
|
return `${PROVIDER_PREFIXES.codex}${model}`;
|
||||||
}
|
}
|
||||||
|
} else if (provider === 'opencode') {
|
||||||
|
if (!model.startsWith(PROVIDER_PREFIXES.opencode)) {
|
||||||
|
return `${PROVIDER_PREFIXES.opencode}${model}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Claude models don't use prefixes
|
// Claude models don't use prefixes
|
||||||
return model;
|
return model;
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ export interface InstallationStatus {
|
|||||||
*/
|
*/
|
||||||
method?: 'cli' | 'wsl' | 'npm' | 'brew' | 'sdk';
|
method?: 'cli' | 'wsl' | 'npm' | 'brew' | 'sdk';
|
||||||
hasApiKey?: boolean;
|
hasApiKey?: boolean;
|
||||||
|
hasOAuthToken?: boolean;
|
||||||
authenticated?: boolean;
|
authenticated?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
import type { ModelAlias, AgentModel, CodexModelId } from './model.js';
|
import type { ModelAlias, AgentModel, CodexModelId } from './model.js';
|
||||||
import type { CursorModelId } from './cursor-models.js';
|
import type { CursorModelId } from './cursor-models.js';
|
||||||
import { CURSOR_MODEL_MAP, getAllCursorModelIds } from './cursor-models.js';
|
import { CURSOR_MODEL_MAP, getAllCursorModelIds } from './cursor-models.js';
|
||||||
|
import type { OpencodeModelId } from './opencode-models.js';
|
||||||
|
import { getAllOpencodeModelIds, DEFAULT_OPENCODE_MODEL } from './opencode-models.js';
|
||||||
import type { PromptCustomization } from './prompts.js';
|
import type { PromptCustomization } from './prompts.js';
|
||||||
import type { CodexSandboxMode, CodexApprovalPolicy } from './codex.js';
|
import type { CodexSandboxMode, CodexApprovalPolicy } from './codex.js';
|
||||||
import type { ReasoningEffort } from './provider.js';
|
import type { ReasoningEffort } from './provider.js';
|
||||||
@@ -97,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';
|
export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode';
|
||||||
|
|
||||||
const DEFAULT_CODEX_AUTO_LOAD_AGENTS = false;
|
const DEFAULT_CODEX_AUTO_LOAD_AGENTS = false;
|
||||||
const DEFAULT_CODEX_SANDBOX_MODE: CodexSandboxMode = 'workspace-write';
|
const DEFAULT_CODEX_SANDBOX_MODE: CodexSandboxMode = 'workspace-write';
|
||||||
@@ -262,6 +264,10 @@ export interface AIProfile {
|
|||||||
// Codex-specific settings
|
// Codex-specific settings
|
||||||
/** Which Codex/GPT model to use - only for Codex provider */
|
/** Which Codex/GPT model to use - only for Codex provider */
|
||||||
codexModel?: CodexModelId;
|
codexModel?: CodexModelId;
|
||||||
|
|
||||||
|
// OpenCode-specific settings
|
||||||
|
/** Which OpenCode model to use - only for OpenCode provider */
|
||||||
|
opencodeModel?: OpencodeModelId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -285,6 +291,11 @@ export function profileHasThinking(profile: AIProfile): boolean {
|
|||||||
return model.startsWith('o');
|
return model.startsWith('o');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (profile.provider === 'opencode') {
|
||||||
|
// OpenCode models don't expose thinking configuration
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,6 +311,10 @@ export function getProfileModelString(profile: AIProfile): string {
|
|||||||
return `codex:${profile.codexModel || 'codex-gpt-5.2'}`;
|
return `codex:${profile.codexModel || 'codex-gpt-5.2'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (profile.provider === 'opencode') {
|
||||||
|
return `opencode:${profile.opencodeModel || DEFAULT_OPENCODE_MODEL}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Claude
|
// Claude
|
||||||
return profile.model || 'sonnet';
|
return profile.model || 'sonnet';
|
||||||
}
|
}
|
||||||
@@ -478,6 +493,12 @@ export interface GlobalSettings {
|
|||||||
/** Default Cursor model selection when switching to Cursor CLI */
|
/** Default Cursor model selection when switching to Cursor CLI */
|
||||||
cursorDefaultModel: CursorModelId;
|
cursorDefaultModel: CursorModelId;
|
||||||
|
|
||||||
|
// OpenCode CLI Settings (global)
|
||||||
|
/** Which OpenCode models are available in feature modal (empty = all) */
|
||||||
|
enabledOpencodeModels?: OpencodeModelId[];
|
||||||
|
/** Default OpenCode model selection when switching to OpenCode CLI */
|
||||||
|
opencodeDefaultModel?: OpencodeModelId;
|
||||||
|
|
||||||
// Input Configuration
|
// Input Configuration
|
||||||
/** User's keyboard shortcut bindings */
|
/** User's keyboard shortcut bindings */
|
||||||
keyboardShortcuts: KeyboardShortcuts;
|
keyboardShortcuts: KeyboardShortcuts;
|
||||||
@@ -768,6 +789,8 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
|||||||
validationModel: 'opus',
|
validationModel: 'opus',
|
||||||
enabledCursorModels: getAllCursorModelIds(),
|
enabledCursorModels: getAllCursorModelIds(),
|
||||||
cursorDefaultModel: 'auto',
|
cursorDefaultModel: 'auto',
|
||||||
|
enabledOpencodeModels: getAllOpencodeModelIds(),
|
||||||
|
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL,
|
||||||
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
|
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
|
||||||
aiProfiles: [],
|
aiProfiles: [],
|
||||||
projects: [],
|
projects: [],
|
||||||
|
|||||||
Reference in New Issue
Block a user