mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-17 22:13:08 +00:00
Merge remote-tracking branch 'origin/v0.15.0rc' into feature/bug-startup-warning-ignores-claude-oauth-credenti-fuzx
This commit is contained in:
@@ -24,7 +24,7 @@
|
||||
"test:unit": "vitest run tests/unit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "0.1.76",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.32",
|
||||
"@automaker/dependency-resolver": "1.0.0",
|
||||
"@automaker/git-utils": "1.0.0",
|
||||
"@automaker/model-resolver": "1.0.0",
|
||||
@@ -34,7 +34,7 @@
|
||||
"@automaker/utils": "1.0.0",
|
||||
"@github/copilot-sdk": "^0.1.16",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
"@openai/codex-sdk": "^0.77.0",
|
||||
"@openai/codex-sdk": "^0.98.0",
|
||||
"cookie-parser": "1.4.7",
|
||||
"cors": "2.8.5",
|
||||
"dotenv": "17.2.3",
|
||||
|
||||
@@ -485,7 +485,7 @@ const server = createServer(app);
|
||||
// WebSocket servers using noServer mode for proper multi-path support
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
const terminalWss = new WebSocketServer({ noServer: true });
|
||||
const terminalService = getTerminalService();
|
||||
const terminalService = getTerminalService(settingsService);
|
||||
|
||||
/**
|
||||
* Authenticate WebSocket upgrade requests
|
||||
|
||||
@@ -253,11 +253,27 @@ function buildMcpOptions(config: CreateSdkOptionsConfig): McpOptions {
|
||||
/**
|
||||
* Build thinking options for SDK configuration.
|
||||
* Converts ThinkingLevel to maxThinkingTokens for the Claude SDK.
|
||||
* For adaptive thinking (Opus 4.6), omits maxThinkingTokens to let the model
|
||||
* decide its own reasoning depth.
|
||||
*
|
||||
* @param thinkingLevel - The thinking level to convert
|
||||
* @returns Object with maxThinkingTokens if thinking is enabled
|
||||
* @returns Object with maxThinkingTokens if thinking is enabled with a budget
|
||||
*/
|
||||
function buildThinkingOptions(thinkingLevel?: ThinkingLevel): Partial<Options> {
|
||||
if (!thinkingLevel || thinkingLevel === 'none') {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Adaptive thinking (Opus 4.6): don't set maxThinkingTokens
|
||||
// The model will use adaptive thinking by default
|
||||
if (thinkingLevel === 'adaptive') {
|
||||
logger.debug(
|
||||
`buildThinkingOptions: thinkingLevel="adaptive" -> no maxThinkingTokens (model decides)`
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
// Manual budget-based thinking for Haiku/Sonnet
|
||||
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
|
||||
logger.debug(
|
||||
`buildThinkingOptions: thinkingLevel="${thinkingLevel}" -> maxThinkingTokens=${maxThinkingTokens}`
|
||||
|
||||
25
apps/server/src/lib/terminal-themes-data.ts
Normal file
25
apps/server/src/lib/terminal-themes-data.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Terminal Theme Data - Re-export terminal themes from platform package
|
||||
*
|
||||
* This module re-exports terminal theme data for use in the server.
|
||||
*/
|
||||
|
||||
import { terminalThemeColors, getTerminalThemeColors as getThemeColors } from '@automaker/platform';
|
||||
import type { ThemeMode } from '@automaker/types';
|
||||
import type { TerminalTheme } from '@automaker/platform';
|
||||
|
||||
/**
|
||||
* Get terminal theme colors for a given theme mode
|
||||
*/
|
||||
export function getTerminalThemeColors(theme: ThemeMode): TerminalTheme {
|
||||
return getThemeColors(theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all terminal themes
|
||||
*/
|
||||
export function getAllTerminalThemes(): Record<ThemeMode, TerminalTheme> {
|
||||
return terminalThemeColors;
|
||||
}
|
||||
|
||||
export default terminalThemeColors;
|
||||
@@ -219,8 +219,11 @@ export class ClaudeProvider extends BaseProvider {
|
||||
// claudeCompatibleProvider takes precedence over claudeApiProfile
|
||||
const providerConfig = claudeCompatibleProvider || claudeApiProfile;
|
||||
|
||||
// Convert thinking level to token budget
|
||||
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
|
||||
// Build thinking configuration
|
||||
// Adaptive thinking (Opus 4.6): don't set maxThinkingTokens, model uses adaptive by default
|
||||
// Manual thinking (Haiku/Sonnet): use budget_tokens
|
||||
const maxThinkingTokens =
|
||||
thinkingLevel === 'adaptive' ? undefined : getThinkingTokenBudget(thinkingLevel);
|
||||
|
||||
// Build Claude SDK options
|
||||
const sdkOptions: Options = {
|
||||
@@ -349,13 +352,13 @@ export class ClaudeProvider extends BaseProvider {
|
||||
getAvailableModels(): ModelDefinition[] {
|
||||
const models = [
|
||||
{
|
||||
id: 'claude-opus-4-5-20251101',
|
||||
name: 'Claude Opus 4.5',
|
||||
modelString: 'claude-opus-4-5-20251101',
|
||||
id: 'claude-opus-4-6',
|
||||
name: 'Claude Opus 4.6',
|
||||
modelString: 'claude-opus-4-6',
|
||||
provider: 'anthropic',
|
||||
description: 'Most capable Claude model',
|
||||
description: 'Most capable Claude model with adaptive thinking',
|
||||
contextWindow: 200000,
|
||||
maxOutputTokens: 16000,
|
||||
maxOutputTokens: 128000,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: 'premium' as const,
|
||||
|
||||
@@ -19,12 +19,11 @@ const MAX_OUTPUT_16K = 16000;
|
||||
export const CODEX_MODELS: ModelDefinition[] = [
|
||||
// ========== Recommended Codex Models ==========
|
||||
{
|
||||
id: CODEX_MODEL_MAP.gpt52Codex,
|
||||
name: 'GPT-5.2-Codex',
|
||||
modelString: CODEX_MODEL_MAP.gpt52Codex,
|
||||
id: CODEX_MODEL_MAP.gpt53Codex,
|
||||
name: 'GPT-5.3-Codex',
|
||||
modelString: CODEX_MODEL_MAP.gpt53Codex,
|
||||
provider: 'openai',
|
||||
description:
|
||||
'Most advanced agentic coding model for complex software engineering (default for ChatGPT users).',
|
||||
description: 'Latest frontier agentic coding model.',
|
||||
contextWindow: CONTEXT_WINDOW_256K,
|
||||
maxOutputTokens: MAX_OUTPUT_32K,
|
||||
supportsVision: true,
|
||||
@@ -33,12 +32,25 @@ export const CODEX_MODELS: ModelDefinition[] = [
|
||||
default: true,
|
||||
hasReasoning: true,
|
||||
},
|
||||
{
|
||||
id: CODEX_MODEL_MAP.gpt52Codex,
|
||||
name: 'GPT-5.2-Codex',
|
||||
modelString: CODEX_MODEL_MAP.gpt52Codex,
|
||||
provider: 'openai',
|
||||
description: 'Frontier agentic coding model.',
|
||||
contextWindow: CONTEXT_WINDOW_256K,
|
||||
maxOutputTokens: MAX_OUTPUT_32K,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: 'premium' as const,
|
||||
hasReasoning: true,
|
||||
},
|
||||
{
|
||||
id: CODEX_MODEL_MAP.gpt51CodexMax,
|
||||
name: 'GPT-5.1-Codex-Max',
|
||||
modelString: CODEX_MODEL_MAP.gpt51CodexMax,
|
||||
provider: 'openai',
|
||||
description: 'Optimized for long-horizon, agentic coding tasks in Codex.',
|
||||
description: 'Codex-optimized flagship for deep and fast reasoning.',
|
||||
contextWindow: CONTEXT_WINDOW_256K,
|
||||
maxOutputTokens: MAX_OUTPUT_32K,
|
||||
supportsVision: true,
|
||||
@@ -51,7 +63,7 @@ export const CODEX_MODELS: ModelDefinition[] = [
|
||||
name: 'GPT-5.1-Codex-Mini',
|
||||
modelString: CODEX_MODEL_MAP.gpt51CodexMini,
|
||||
provider: 'openai',
|
||||
description: 'Smaller, more cost-effective version for faster workflows.',
|
||||
description: 'Optimized for codex. Cheaper, faster, but less capable.',
|
||||
contextWindow: CONTEXT_WINDOW_128K,
|
||||
maxOutputTokens: MAX_OUTPUT_16K,
|
||||
supportsVision: true,
|
||||
@@ -66,7 +78,7 @@ export const CODEX_MODELS: ModelDefinition[] = [
|
||||
name: 'GPT-5.2',
|
||||
modelString: CODEX_MODEL_MAP.gpt52,
|
||||
provider: 'openai',
|
||||
description: 'Best general agentic model for tasks across industries and domains.',
|
||||
description: 'Latest frontier model with improvements across knowledge, reasoning and coding.',
|
||||
contextWindow: CONTEXT_WINDOW_256K,
|
||||
maxOutputTokens: MAX_OUTPUT_32K,
|
||||
supportsVision: true,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { execSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { findCliInWsl, isWslAvailable } from '@automaker/platform';
|
||||
import {
|
||||
CliProvider,
|
||||
type CliSpawnConfig,
|
||||
@@ -286,15 +287,113 @@ export class CursorProvider extends CliProvider {
|
||||
|
||||
getSpawnConfig(): CliSpawnConfig {
|
||||
return {
|
||||
windowsStrategy: 'wsl', // cursor-agent requires WSL on Windows
|
||||
windowsStrategy: 'direct',
|
||||
commonPaths: {
|
||||
linux: [
|
||||
path.join(os.homedir(), '.local/bin/cursor-agent'), // Primary symlink location
|
||||
'/usr/local/bin/cursor-agent',
|
||||
],
|
||||
darwin: [path.join(os.homedir(), '.local/bin/cursor-agent'), '/usr/local/bin/cursor-agent'],
|
||||
// Windows paths are not used - we check for WSL installation instead
|
||||
win32: [],
|
||||
win32: [
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'Cursor',
|
||||
'resources',
|
||||
'app',
|
||||
'bin',
|
||||
'cursor-agent.exe'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'Cursor',
|
||||
'resources',
|
||||
'app',
|
||||
'bin',
|
||||
'cursor-agent.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'Cursor',
|
||||
'resources',
|
||||
'app',
|
||||
'bin',
|
||||
'cursor.exe'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'Cursor',
|
||||
'cursor.exe'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'cursor',
|
||||
'resources',
|
||||
'app',
|
||||
'bin',
|
||||
'cursor-agent.exe'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'cursor',
|
||||
'resources',
|
||||
'app',
|
||||
'bin',
|
||||
'cursor-agent.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'cursor',
|
||||
'resources',
|
||||
'app',
|
||||
'bin',
|
||||
'cursor.exe'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'Programs',
|
||||
'cursor',
|
||||
'cursor.exe'
|
||||
),
|
||||
path.join(
|
||||
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
||||
'npm',
|
||||
'cursor-agent.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
||||
'npm',
|
||||
'cursor.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
||||
'.npm-global',
|
||||
'bin',
|
||||
'cursor-agent.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
||||
'.npm-global',
|
||||
'bin',
|
||||
'cursor.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'pnpm',
|
||||
'cursor-agent.cmd'
|
||||
),
|
||||
path.join(
|
||||
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
|
||||
'pnpm',
|
||||
'cursor.cmd'
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -487,6 +586,92 @@ export class CursorProvider extends CliProvider {
|
||||
* 2. Cursor IDE with 'cursor agent' subcommand support
|
||||
*/
|
||||
protected detectCli(): CliDetectionResult {
|
||||
if (process.platform === 'win32') {
|
||||
const findInPath = (command: string): string | null => {
|
||||
try {
|
||||
const result = execSync(`where ${command}`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
})
|
||||
.trim()
|
||||
.split(/\r?\n/)[0];
|
||||
|
||||
if (result && fs.existsSync(result)) {
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
// Not in PATH
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const isCursorAgentBinary = (cliPath: string) =>
|
||||
cliPath.toLowerCase().includes('cursor-agent');
|
||||
|
||||
const supportsCursorAgentSubcommand = (cliPath: string) => {
|
||||
try {
|
||||
execSync(`"${cliPath}" agent --version`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: 'pipe',
|
||||
windowsHide: true,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const pathResult = findInPath('cursor-agent') || findInPath('cursor');
|
||||
if (pathResult) {
|
||||
if (isCursorAgentBinary(pathResult) || supportsCursorAgentSubcommand(pathResult)) {
|
||||
return {
|
||||
cliPath: pathResult,
|
||||
useWsl: false,
|
||||
strategy: pathResult.toLowerCase().endsWith('.cmd') ? 'cmd' : 'direct',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const config = this.getSpawnConfig();
|
||||
for (const candidate of config.commonPaths.win32 || []) {
|
||||
const resolved = candidate;
|
||||
if (!fs.existsSync(resolved)) {
|
||||
continue;
|
||||
}
|
||||
if (isCursorAgentBinary(resolved) || supportsCursorAgentSubcommand(resolved)) {
|
||||
return {
|
||||
cliPath: resolved,
|
||||
useWsl: false,
|
||||
strategy: resolved.toLowerCase().endsWith('.cmd') ? 'cmd' : 'direct',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const wslLogger = (msg: string) => logger.debug(msg);
|
||||
if (isWslAvailable({ logger: wslLogger })) {
|
||||
const wslResult = findCliInWsl('cursor-agent', { logger: wslLogger });
|
||||
if (wslResult) {
|
||||
logger.debug(
|
||||
`Using cursor-agent via WSL (${wslResult.distribution || 'default'}): ${wslResult.wslPath}`
|
||||
);
|
||||
return {
|
||||
cliPath: 'wsl.exe',
|
||||
useWsl: true,
|
||||
wslCliPath: wslResult.wslPath,
|
||||
wslDistribution: wslResult.distribution,
|
||||
strategy: 'wsl',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('cursor-agent not found on Windows');
|
||||
return { cliPath: null, useWsl: false, strategy: 'direct' };
|
||||
}
|
||||
|
||||
// First try standard detection (PATH, common paths, WSL)
|
||||
const result = super.detectCli();
|
||||
if (result.cliPath) {
|
||||
@@ -495,7 +680,7 @@ export class CursorProvider extends CliProvider {
|
||||
|
||||
// Cursor-specific: Check versions directory for any installed version
|
||||
// This handles cases where cursor-agent is installed but not in PATH
|
||||
if (process.platform !== 'win32' && fs.existsSync(CursorProvider.VERSIONS_DIR)) {
|
||||
if (fs.existsSync(CursorProvider.VERSIONS_DIR)) {
|
||||
try {
|
||||
const versions = fs
|
||||
.readdirSync(CursorProvider.VERSIONS_DIR)
|
||||
@@ -521,33 +706,31 @@ export class CursorProvider extends CliProvider {
|
||||
|
||||
// If cursor-agent not found, try to find 'cursor' IDE and use 'cursor agent' subcommand
|
||||
// The Cursor IDE includes the agent as a subcommand: cursor agent
|
||||
if (process.platform !== 'win32') {
|
||||
const cursorPaths = [
|
||||
'/usr/bin/cursor',
|
||||
'/usr/local/bin/cursor',
|
||||
path.join(os.homedir(), '.local/bin/cursor'),
|
||||
'/opt/cursor/cursor',
|
||||
];
|
||||
const cursorPaths = [
|
||||
'/usr/bin/cursor',
|
||||
'/usr/local/bin/cursor',
|
||||
path.join(os.homedir(), '.local/bin/cursor'),
|
||||
'/opt/cursor/cursor',
|
||||
];
|
||||
|
||||
for (const cursorPath of cursorPaths) {
|
||||
if (fs.existsSync(cursorPath)) {
|
||||
// Verify cursor agent subcommand works
|
||||
try {
|
||||
execSync(`"${cursorPath}" agent --version`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
logger.debug(`Using cursor agent via Cursor IDE: ${cursorPath}`);
|
||||
// Return cursor path but we'll use 'cursor agent' subcommand
|
||||
return {
|
||||
cliPath: cursorPath,
|
||||
useWsl: false,
|
||||
strategy: 'native',
|
||||
};
|
||||
} catch {
|
||||
// cursor agent subcommand doesn't work, try next path
|
||||
}
|
||||
for (const cursorPath of cursorPaths) {
|
||||
if (fs.existsSync(cursorPath)) {
|
||||
// Verify cursor agent subcommand works
|
||||
try {
|
||||
execSync(`"${cursorPath}" agent --version`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
logger.debug(`Using cursor agent via Cursor IDE: ${cursorPath}`);
|
||||
// Return cursor path but we'll use 'cursor agent' subcommand
|
||||
return {
|
||||
cliPath: cursorPath,
|
||||
useWsl: false,
|
||||
strategy: 'native',
|
||||
};
|
||||
} catch {
|
||||
// cursor agent subcommand doesn't work, try next path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ export class ProviderFactory {
|
||||
/**
|
||||
* Get the appropriate provider for a given model ID
|
||||
*
|
||||
* @param modelId Model identifier (e.g., "claude-opus-4-5-20251101", "cursor-gpt-4o", "cursor-auto")
|
||||
* @param modelId Model identifier (e.g., "claude-opus-4-6", "cursor-gpt-4o", "cursor-auto")
|
||||
* @param options Optional settings
|
||||
* @param options.throwOnDisconnected Throw error if provider is disconnected (default: true)
|
||||
* @returns Provider instance for the model
|
||||
|
||||
@@ -10,14 +10,23 @@ import type { Request, Response } from 'express';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types';
|
||||
import { getAppSpecPath } from '@automaker/platform';
|
||||
import { simpleQuery } from '../../../providers/simple-query-service.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getPromptCustomization, getProviderByModelId } from '../../../lib/settings-helpers.js';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import {
|
||||
buildUserPrompt,
|
||||
isValidEnhancementMode,
|
||||
type EnhancementMode,
|
||||
} from '../../../lib/enhancement-prompts.js';
|
||||
import {
|
||||
extractTechnologyStack,
|
||||
extractXmlElements,
|
||||
extractXmlSection,
|
||||
unescapeXml,
|
||||
} from '../../../lib/xml-extractor.js';
|
||||
|
||||
const logger = createLogger('EnhancePrompt');
|
||||
|
||||
@@ -53,6 +62,66 @@ interface EnhanceErrorResponse {
|
||||
error: string;
|
||||
}
|
||||
|
||||
async function buildProjectContext(projectPath: string): Promise<string | null> {
|
||||
const contextBlocks: string[] = [];
|
||||
|
||||
try {
|
||||
const appSpecPath = getAppSpecPath(projectPath);
|
||||
const specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string;
|
||||
|
||||
const projectName = extractXmlSection(specContent, 'project_name');
|
||||
const overview = extractXmlSection(specContent, 'overview');
|
||||
const techStack = extractTechnologyStack(specContent);
|
||||
const coreSection = extractXmlSection(specContent, 'core_capabilities');
|
||||
const coreCapabilities = coreSection ? extractXmlElements(coreSection, 'capability') : [];
|
||||
|
||||
const summaryLines: string[] = [];
|
||||
if (projectName) {
|
||||
summaryLines.push(`Name: ${unescapeXml(projectName.trim())}`);
|
||||
}
|
||||
if (overview) {
|
||||
summaryLines.push(`Overview: ${unescapeXml(overview.trim())}`);
|
||||
}
|
||||
if (techStack.length > 0) {
|
||||
summaryLines.push(`Tech Stack: ${techStack.join(', ')}`);
|
||||
}
|
||||
if (coreCapabilities.length > 0) {
|
||||
summaryLines.push(`Core Capabilities: ${coreCapabilities.slice(0, 10).join(', ')}`);
|
||||
}
|
||||
|
||||
if (summaryLines.length > 0) {
|
||||
contextBlocks.push(`PROJECT CONTEXT:\n${summaryLines.map((line) => `- ${line}`).join('\n')}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('No app_spec.txt context available for enhancement', error);
|
||||
}
|
||||
|
||||
try {
|
||||
const featureLoader = new FeatureLoader();
|
||||
const features = await featureLoader.getAll(projectPath);
|
||||
const featureTitles = features
|
||||
.map((feature) => feature.title || feature.name || feature.id)
|
||||
.filter((title) => Boolean(title));
|
||||
|
||||
if (featureTitles.length > 0) {
|
||||
const listed = featureTitles.slice(0, 30).map((title) => `- ${title}`);
|
||||
contextBlocks.push(
|
||||
`EXISTING FEATURES (avoid duplicates):\n${listed.join('\n')}${
|
||||
featureTitles.length > 30 ? '\n- ...' : ''
|
||||
}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('Failed to load existing features for enhancement context', error);
|
||||
}
|
||||
|
||||
if (contextBlocks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return contextBlocks.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the enhance request handler
|
||||
*
|
||||
@@ -122,6 +191,10 @@ export function createEnhanceHandler(
|
||||
|
||||
// Build the user prompt with few-shot examples
|
||||
const userPrompt = buildUserPrompt(validMode, trimmedText, true);
|
||||
const projectContext = projectPath ? await buildProjectContext(projectPath) : null;
|
||||
if (projectContext) {
|
||||
logger.debug('Including project context in enhancement prompt');
|
||||
}
|
||||
|
||||
// Check if the model is a provider model (like "GLM-4.5-Air")
|
||||
// If so, get the provider config and resolved Claude model
|
||||
@@ -156,7 +229,7 @@ export function createEnhanceHandler(
|
||||
// The system prompt is combined with user prompt since some providers
|
||||
// don't have a separate system prompt concept
|
||||
const result = await simpleQuery({
|
||||
prompt: `${systemPrompt}\n\n${userPrompt}`,
|
||||
prompt: [systemPrompt, projectContext, userPrompt].filter(Boolean).join('\n\n'),
|
||||
model: resolvedModel,
|
||||
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
|
||||
maxTurns: 1,
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import path from 'path';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { getImagesDir } from '@automaker/platform';
|
||||
import { sanitizeFilename } from '@automaker/utils';
|
||||
|
||||
export function createSaveImageHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -39,7 +40,7 @@ export function createSaveImageHandler() {
|
||||
// Generate unique filename with timestamp
|
||||
const timestamp = Date.now();
|
||||
const ext = path.extname(filename) || '.png';
|
||||
const baseName = path.basename(filename, ext);
|
||||
const baseName = sanitizeFilename(path.basename(filename, ext), 'image');
|
||||
const uniqueFilename = `${baseName}-${timestamp}${ext}`;
|
||||
const filePath = path.join(imagesDir, uniqueFilename);
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { GlobalSettings } from '../../../types/settings.js';
|
||||
import { getErrorMessage, logError, logger } from '../common.js';
|
||||
import { setLogLevel, LogLevel } from '@automaker/utils';
|
||||
import { setRequestLoggingEnabled } from '../../../index.js';
|
||||
import { getTerminalService } from '../../../services/terminal-service.js';
|
||||
|
||||
/**
|
||||
* Map server log level string to LogLevel enum
|
||||
@@ -57,6 +58,10 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
|
||||
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}`
|
||||
);
|
||||
|
||||
// Get old settings to detect theme changes
|
||||
const oldSettings = await settingsService.getGlobalSettings();
|
||||
const oldTheme = oldSettings?.theme;
|
||||
|
||||
logger.info('[SERVER_SETTINGS_UPDATE] Calling updateGlobalSettings...');
|
||||
const settings = await settingsService.updateGlobalSettings(updates);
|
||||
logger.info(
|
||||
@@ -64,6 +69,37 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
|
||||
settings.projects?.length ?? 0
|
||||
);
|
||||
|
||||
// Handle theme change - regenerate terminal RC files for all projects
|
||||
if ('theme' in updates && updates.theme && updates.theme !== oldTheme) {
|
||||
const terminalService = getTerminalService(settingsService);
|
||||
const newTheme = updates.theme;
|
||||
|
||||
logger.info(
|
||||
`[TERMINAL_CONFIG] Theme changed from ${oldTheme} to ${newTheme}, regenerating RC files`
|
||||
);
|
||||
|
||||
// Regenerate RC files for all projects with terminal config enabled
|
||||
const projects = settings.projects || [];
|
||||
for (const project of projects) {
|
||||
try {
|
||||
const projectSettings = await settingsService.getProjectSettings(project.path);
|
||||
// Check if terminal config is enabled (global or project-specific)
|
||||
const terminalConfigEnabled =
|
||||
projectSettings.terminalConfig?.enabled !== false &&
|
||||
settings.terminalConfig?.enabled === true;
|
||||
|
||||
if (terminalConfigEnabled) {
|
||||
await terminalService.onThemeChange(project.path, newTheme);
|
||||
logger.info(`[TERMINAL_CONFIG] Regenerated RC files for project: ${project.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[TERMINAL_CONFIG] Failed to regenerate RC files for project ${project.name}: ${error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply server log level if it was updated
|
||||
if ('serverLogLevel' in updates && updates.serverLogLevel) {
|
||||
const level = LOG_LEVEL_MAP[updates.serverLogLevel];
|
||||
|
||||
@@ -16,6 +16,21 @@ import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
function isUntrackedLine(line: string): boolean {
|
||||
return line.startsWith('?? ');
|
||||
}
|
||||
|
||||
function isExcludedWorktreeLine(line: string): boolean {
|
||||
return line.includes('.worktrees/') || line.endsWith('.worktrees');
|
||||
}
|
||||
|
||||
function isBlockingChangeLine(line: string): boolean {
|
||||
if (!line.trim()) return false;
|
||||
if (isExcludedWorktreeLine(line)) return false;
|
||||
if (isUntrackedLine(line)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are uncommitted changes in the working directory
|
||||
* Excludes .worktrees/ directory which is created by automaker
|
||||
@@ -23,15 +38,7 @@ const execAsync = promisify(exec);
|
||||
async function hasUncommittedChanges(cwd: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git status --porcelain', { cwd });
|
||||
const lines = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => {
|
||||
if (!line.trim()) return false;
|
||||
// Exclude .worktrees/ directory (created by automaker)
|
||||
if (line.includes('.worktrees/') || line.endsWith('.worktrees')) return false;
|
||||
return true;
|
||||
});
|
||||
const lines = stdout.trim().split('\n').filter(isBlockingChangeLine);
|
||||
return lines.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -45,15 +52,7 @@ async function hasUncommittedChanges(cwd: string): Promise<boolean> {
|
||||
async function getChangesSummary(cwd: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git status --short', { cwd });
|
||||
const lines = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => {
|
||||
if (!line.trim()) return false;
|
||||
// Exclude .worktrees/ directory
|
||||
if (line.includes('.worktrees/') || line.endsWith('.worktrees')) return false;
|
||||
return true;
|
||||
});
|
||||
const lines = stdout.trim().split('\n').filter(isBlockingChangeLine);
|
||||
if (lines.length === 0) return '';
|
||||
if (lines.length <= 5) return lines.join(', ');
|
||||
return `${lines.slice(0, 5).join(', ')} and ${lines.length - 5} more files`;
|
||||
|
||||
@@ -2657,13 +2657,67 @@ Address the follow-up instructions above. Review the previous work and make the
|
||||
// Load feature for commit message
|
||||
const feature = await this.loadFeature(projectPath, featureId);
|
||||
const commitMessage = feature
|
||||
? `feat: ${this.extractTitleFromDescription(
|
||||
feature.description
|
||||
)}\n\nImplemented by Automaker auto-mode`
|
||||
: `feat: Feature ${featureId}`;
|
||||
? await this.generateCommitMessage(feature, workDir)
|
||||
: `feat: Feature ${featureId}\n\nImplemented by Automaker auto-mode`;
|
||||
|
||||
// Stage and commit
|
||||
await execAsync('git add -A', { cwd: workDir });
|
||||
// Determine which files to stage
|
||||
// For feature branches, only stage files changed on this branch to avoid committing unrelated changes
|
||||
let filesToStage: string[] = [];
|
||||
|
||||
try {
|
||||
// Get the current branch
|
||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: workDir,
|
||||
});
|
||||
const branch = currentBranch.trim();
|
||||
|
||||
// Get the base branch (usually main/master)
|
||||
const { stdout: baseBranchOutput } = await execAsync(
|
||||
'git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || echo "refs/remotes/origin/main"',
|
||||
{ cwd: workDir }
|
||||
);
|
||||
const baseBranch = baseBranchOutput.trim().replace('refs/remotes/origin/', '');
|
||||
|
||||
// If we're on a feature branch (not the base branch), only stage files changed on this branch
|
||||
if (branch !== baseBranch && feature?.branchName) {
|
||||
try {
|
||||
// Get files changed on this branch compared to base
|
||||
const { stdout: branchFiles } = await execAsync(
|
||||
`git diff --name-only ${baseBranch}...HEAD`,
|
||||
{ cwd: workDir }
|
||||
);
|
||||
|
||||
if (branchFiles.trim()) {
|
||||
filesToStage = branchFiles.trim().split('\n').filter(Boolean);
|
||||
logger.info(`Staging ${filesToStage.length} files changed on branch ${branch}`);
|
||||
}
|
||||
} catch (diffError) {
|
||||
// If diff fails (e.g., base branch doesn't exist), fall back to staging all changes
|
||||
logger.warn(`Could not diff against base branch, staging all changes: ${diffError}`);
|
||||
filesToStage = [];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Could not determine branch-specific files: ${error}`);
|
||||
}
|
||||
|
||||
// Stage files
|
||||
if (filesToStage.length > 0) {
|
||||
// Stage only the specific files changed on this branch
|
||||
for (const file of filesToStage) {
|
||||
try {
|
||||
await execAsync(`git add "${file.replace(/"/g, '\\"')}"`, { cwd: workDir });
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to stage file ${file}: ${error}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: stage all changes (original behavior)
|
||||
// This happens for main branch features or when branch detection fails
|
||||
await execAsync('git add -A', { cwd: workDir });
|
||||
}
|
||||
|
||||
// Commit
|
||||
await execAsync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, {
|
||||
cwd: workDir,
|
||||
});
|
||||
@@ -3664,13 +3718,14 @@ Format your response as a structured markdown document.`;
|
||||
// Recovery cases:
|
||||
// 1. Standard pending/ready/backlog statuses
|
||||
// 2. Features with approved plans that have incomplete tasks (crash recovery)
|
||||
// 3. Features stuck in 'in_progress' status (crash recovery)
|
||||
// 3. Features stuck in 'in_progress' or 'interrupted' status (crash recovery)
|
||||
// 4. Features with 'generating' planSpec status (spec generation was interrupted)
|
||||
const needsRecovery =
|
||||
feature.status === 'pending' ||
|
||||
feature.status === 'ready' ||
|
||||
feature.status === 'backlog' ||
|
||||
feature.status === 'in_progress' || // Recover features that were in progress when server crashed
|
||||
feature.status === 'interrupted' || // Recover features explicitly marked interrupted on shutdown
|
||||
(feature.planSpec?.status === 'approved' &&
|
||||
(feature.planSpec.tasksCompleted ?? 0) < (feature.planSpec.tasksTotal ?? 0)) ||
|
||||
feature.planSpec?.status === 'generating'; // Recover interrupted spec generation
|
||||
@@ -3710,7 +3765,7 @@ Format your response as a structured markdown document.`;
|
||||
|
||||
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
||||
logger.info(
|
||||
`[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} candidates (pending/ready/backlog/in_progress/approved_with_pending_tasks/generating) for ${worktreeDesc}`
|
||||
`[loadPendingFeatures] Found ${allFeatures.length} total features, ${pendingFeatures.length} candidates (pending/ready/backlog/in_progress/interrupted/approved_with_pending_tasks/generating) for ${worktreeDesc}`
|
||||
);
|
||||
|
||||
if (pendingFeatures.length === 0) {
|
||||
@@ -3840,6 +3895,58 @@ Format your response as a structured markdown document.`;
|
||||
return firstLine.substring(0, 57) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a comprehensive commit message for a feature
|
||||
* Includes title, description summary, and file statistics
|
||||
*/
|
||||
private async generateCommitMessage(feature: Feature, workDir: string): Promise<string> {
|
||||
const title = this.extractTitleFromDescription(feature.description);
|
||||
|
||||
// Extract description summary (first 3-5 lines, up to 300 chars)
|
||||
let descriptionSummary = '';
|
||||
if (feature.description && feature.description.trim()) {
|
||||
const lines = feature.description.split('\n').filter((l) => l.trim());
|
||||
const summaryLines = lines.slice(0, 5); // First 5 non-empty lines
|
||||
descriptionSummary = summaryLines.join('\n');
|
||||
|
||||
// Limit to 300 characters
|
||||
if (descriptionSummary.length > 300) {
|
||||
descriptionSummary = descriptionSummary.substring(0, 297) + '...';
|
||||
}
|
||||
}
|
||||
|
||||
// Get file statistics to add context
|
||||
let fileStats = '';
|
||||
try {
|
||||
const { stdout: diffStat } = await execAsync('git diff --cached --stat', { cwd: workDir });
|
||||
if (diffStat.trim()) {
|
||||
// Extract just the summary line (last line with file count)
|
||||
const statLines = diffStat.trim().split('\n');
|
||||
const summaryLine = statLines[statLines.length - 1];
|
||||
if (summaryLine && summaryLine.includes('file')) {
|
||||
fileStats = `\n${summaryLine.trim()}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors getting stats
|
||||
}
|
||||
|
||||
// Build commit message
|
||||
let message = `feat: ${title}`;
|
||||
|
||||
if (descriptionSummary && descriptionSummary !== title) {
|
||||
message += `\n\n${descriptionSummary}`;
|
||||
}
|
||||
|
||||
if (fileStats) {
|
||||
message += fileStats;
|
||||
}
|
||||
|
||||
message += '\n\nImplemented by Automaker auto-mode';
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the planning prompt prefix based on feature's planning mode
|
||||
*/
|
||||
@@ -5430,9 +5537,10 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if feature was interrupted (in_progress or pipeline_*)
|
||||
// Check if feature was interrupted (in_progress/interrupted or pipeline_*)
|
||||
if (
|
||||
feature.status === 'in_progress' ||
|
||||
feature.status === 'interrupted' ||
|
||||
(feature.status && feature.status.startsWith('pipeline_'))
|
||||
) {
|
||||
// Check if context (agent-output.md) exists
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface DevServerInfo {
|
||||
flushTimeout: NodeJS.Timeout | null;
|
||||
// Flag to indicate server is stopping (prevents output after stop)
|
||||
stopping: boolean;
|
||||
// Flag to indicate if URL has been detected from output
|
||||
urlDetected: boolean;
|
||||
}
|
||||
|
||||
// Port allocation starts at 3001 to avoid conflicts with common dev ports
|
||||
@@ -103,6 +105,54 @@ class DevServerService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect actual server URL from output
|
||||
* Parses stdout/stderr for common URL patterns from dev servers
|
||||
*/
|
||||
private detectUrlFromOutput(server: DevServerInfo, content: string): void {
|
||||
// Skip if URL already detected
|
||||
if (server.urlDetected) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Common URL patterns from various dev servers:
|
||||
// - Vite: "Local: http://localhost:5173/"
|
||||
// - Next.js: "ready - started server on 0.0.0.0:3000, url: http://localhost:3000"
|
||||
// - CRA/Webpack: "On Your Network: http://192.168.1.1:3000"
|
||||
// - Generic: Any http:// or https:// URL
|
||||
const urlPatterns = [
|
||||
/(?:Local|Network):\s+(https?:\/\/[^\s]+)/i, // Vite format
|
||||
/(?:ready|started server).*?(?:url:\s*)?(https?:\/\/[^\s,]+)/i, // Next.js format
|
||||
/(https?:\/\/(?:localhost|127\.0\.0\.1|\[::\]):\d+)/i, // Generic localhost URL
|
||||
/(https?:\/\/[^\s<>"{}|\\^`\[\]]+)/i, // Any HTTP(S) URL
|
||||
];
|
||||
|
||||
for (const pattern of urlPatterns) {
|
||||
const match = content.match(pattern);
|
||||
if (match && match[1]) {
|
||||
const detectedUrl = match[1].trim();
|
||||
// Validate it looks like a reasonable URL
|
||||
if (detectedUrl.startsWith('http://') || detectedUrl.startsWith('https://')) {
|
||||
server.url = detectedUrl;
|
||||
server.urlDetected = true;
|
||||
logger.info(
|
||||
`Detected actual server URL: ${detectedUrl} (allocated port was ${server.port})`
|
||||
);
|
||||
|
||||
// Emit URL update event
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('dev-server:url-detected', {
|
||||
worktreePath: server.worktreePath,
|
||||
url: detectedUrl,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming stdout/stderr data from dev server process
|
||||
* Buffers data for scrollback replay and schedules throttled emission
|
||||
@@ -115,6 +165,9 @@ class DevServerService {
|
||||
|
||||
const content = data.toString();
|
||||
|
||||
// Try to detect actual server URL from output
|
||||
this.detectUrlFromOutput(server, content);
|
||||
|
||||
// Append to scrollback buffer for replay on reconnect
|
||||
this.appendToScrollback(server, content);
|
||||
|
||||
@@ -446,13 +499,14 @@ class DevServerService {
|
||||
const serverInfo: DevServerInfo = {
|
||||
worktreePath,
|
||||
port,
|
||||
url: `http://${hostname}:${port}`,
|
||||
url: `http://${hostname}:${port}`, // Initial URL, may be updated by detectUrlFromOutput
|
||||
process: devProcess,
|
||||
startedAt: new Date(),
|
||||
scrollbackBuffer: '',
|
||||
outputBuffer: '',
|
||||
flushTimeout: null,
|
||||
stopping: false,
|
||||
urlDetected: false, // Will be set to true when actual URL is detected from output
|
||||
};
|
||||
|
||||
// Capture stdout with buffer management and event emission
|
||||
|
||||
@@ -13,6 +13,14 @@ import * as path from 'path';
|
||||
// to enforce ALLOWED_ROOT_DIRECTORY security boundary
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
import { getTerminalThemeColors, getAllTerminalThemes } from '../lib/terminal-themes-data.js';
|
||||
import {
|
||||
getRcFilePath,
|
||||
getTerminalDir,
|
||||
ensureRcFilesUpToDate,
|
||||
type TerminalConfig,
|
||||
} from '@automaker/platform';
|
||||
|
||||
const logger = createLogger('Terminal');
|
||||
// System paths module handles shell binary checks and WSL detection
|
||||
@@ -24,6 +32,27 @@ import {
|
||||
getShellPaths,
|
||||
} from '@automaker/platform';
|
||||
|
||||
const BASH_LOGIN_ARG = '--login';
|
||||
const BASH_RCFILE_ARG = '--rcfile';
|
||||
const SHELL_NAME_BASH = 'bash';
|
||||
const SHELL_NAME_ZSH = 'zsh';
|
||||
const SHELL_NAME_SH = 'sh';
|
||||
const DEFAULT_SHOW_USER_HOST = true;
|
||||
const DEFAULT_SHOW_PATH = true;
|
||||
const DEFAULT_SHOW_TIME = false;
|
||||
const DEFAULT_SHOW_EXIT_STATUS = false;
|
||||
const DEFAULT_PATH_DEPTH = 0;
|
||||
const DEFAULT_PATH_STYLE: TerminalConfig['pathStyle'] = 'full';
|
||||
const DEFAULT_CUSTOM_PROMPT = true;
|
||||
const DEFAULT_PROMPT_FORMAT: TerminalConfig['promptFormat'] = 'standard';
|
||||
const DEFAULT_SHOW_GIT_BRANCH = true;
|
||||
const DEFAULT_SHOW_GIT_STATUS = true;
|
||||
const DEFAULT_CUSTOM_ALIASES = '';
|
||||
const DEFAULT_CUSTOM_ENV_VARS: Record<string, string> = {};
|
||||
const PROMPT_THEME_CUSTOM = 'custom';
|
||||
const PROMPT_THEME_PREFIX = 'omp-';
|
||||
const OMP_THEME_ENV_VAR = 'AUTOMAKER_OMP_THEME';
|
||||
|
||||
// Maximum scrollback buffer size (characters)
|
||||
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
|
||||
|
||||
@@ -42,6 +71,114 @@ let maxSessions = parseInt(process.env.TERMINAL_MAX_SESSIONS || '1000', 10);
|
||||
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive input
|
||||
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency
|
||||
|
||||
function applyBashRcFileArgs(args: string[], rcFilePath: string): string[] {
|
||||
const sanitizedArgs: string[] = [];
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === BASH_LOGIN_ARG) {
|
||||
continue;
|
||||
}
|
||||
if (arg === BASH_RCFILE_ARG) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
sanitizedArgs.push(arg);
|
||||
}
|
||||
|
||||
sanitizedArgs.push(BASH_RCFILE_ARG, rcFilePath);
|
||||
return sanitizedArgs;
|
||||
}
|
||||
|
||||
function normalizePathStyle(
|
||||
pathStyle: TerminalConfig['pathStyle'] | undefined
|
||||
): TerminalConfig['pathStyle'] {
|
||||
if (pathStyle === 'short' || pathStyle === 'basename') {
|
||||
return pathStyle;
|
||||
}
|
||||
return DEFAULT_PATH_STYLE;
|
||||
}
|
||||
|
||||
function normalizePathDepth(pathDepth: number | undefined): number {
|
||||
const depth =
|
||||
typeof pathDepth === 'number' && Number.isFinite(pathDepth) ? pathDepth : DEFAULT_PATH_DEPTH;
|
||||
return Math.max(DEFAULT_PATH_DEPTH, Math.floor(depth));
|
||||
}
|
||||
|
||||
function getShellBasename(shellPath: string): string {
|
||||
const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\'));
|
||||
return lastSep >= 0 ? shellPath.slice(lastSep + 1) : shellPath;
|
||||
}
|
||||
|
||||
function getShellArgsForPath(shellPath: string): string[] {
|
||||
const shellName = getShellBasename(shellPath).toLowerCase().replace('.exe', '');
|
||||
if (shellName === 'powershell' || shellName === 'pwsh' || shellName === 'cmd') {
|
||||
return [];
|
||||
}
|
||||
if (shellName === SHELL_NAME_SH) {
|
||||
return [];
|
||||
}
|
||||
return [BASH_LOGIN_ARG];
|
||||
}
|
||||
|
||||
function resolveOmpThemeName(promptTheme: string | undefined): string | null {
|
||||
if (!promptTheme || promptTheme === PROMPT_THEME_CUSTOM) {
|
||||
return null;
|
||||
}
|
||||
if (promptTheme.startsWith(PROMPT_THEME_PREFIX)) {
|
||||
return promptTheme.slice(PROMPT_THEME_PREFIX.length);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildEffectiveTerminalConfig(
|
||||
globalTerminalConfig: TerminalConfig | undefined,
|
||||
projectTerminalConfig: Partial<TerminalConfig> | undefined
|
||||
): TerminalConfig {
|
||||
const mergedEnvVars = {
|
||||
...(globalTerminalConfig?.customEnvVars ?? DEFAULT_CUSTOM_ENV_VARS),
|
||||
...(projectTerminalConfig?.customEnvVars ?? DEFAULT_CUSTOM_ENV_VARS),
|
||||
};
|
||||
|
||||
return {
|
||||
enabled: projectTerminalConfig?.enabled ?? globalTerminalConfig?.enabled ?? false,
|
||||
customPrompt: globalTerminalConfig?.customPrompt ?? DEFAULT_CUSTOM_PROMPT,
|
||||
promptFormat: globalTerminalConfig?.promptFormat ?? DEFAULT_PROMPT_FORMAT,
|
||||
showGitBranch:
|
||||
projectTerminalConfig?.showGitBranch ??
|
||||
globalTerminalConfig?.showGitBranch ??
|
||||
DEFAULT_SHOW_GIT_BRANCH,
|
||||
showGitStatus:
|
||||
projectTerminalConfig?.showGitStatus ??
|
||||
globalTerminalConfig?.showGitStatus ??
|
||||
DEFAULT_SHOW_GIT_STATUS,
|
||||
showUserHost:
|
||||
projectTerminalConfig?.showUserHost ??
|
||||
globalTerminalConfig?.showUserHost ??
|
||||
DEFAULT_SHOW_USER_HOST,
|
||||
showPath:
|
||||
projectTerminalConfig?.showPath ?? globalTerminalConfig?.showPath ?? DEFAULT_SHOW_PATH,
|
||||
pathStyle: normalizePathStyle(
|
||||
projectTerminalConfig?.pathStyle ?? globalTerminalConfig?.pathStyle
|
||||
),
|
||||
pathDepth: normalizePathDepth(
|
||||
projectTerminalConfig?.pathDepth ?? globalTerminalConfig?.pathDepth
|
||||
),
|
||||
showTime:
|
||||
projectTerminalConfig?.showTime ?? globalTerminalConfig?.showTime ?? DEFAULT_SHOW_TIME,
|
||||
showExitStatus:
|
||||
projectTerminalConfig?.showExitStatus ??
|
||||
globalTerminalConfig?.showExitStatus ??
|
||||
DEFAULT_SHOW_EXIT_STATUS,
|
||||
customAliases:
|
||||
projectTerminalConfig?.customAliases ??
|
||||
globalTerminalConfig?.customAliases ??
|
||||
DEFAULT_CUSTOM_ALIASES,
|
||||
customEnvVars: mergedEnvVars,
|
||||
rcFileVersion: globalTerminalConfig?.rcFileVersion,
|
||||
};
|
||||
}
|
||||
|
||||
export interface TerminalSession {
|
||||
id: string;
|
||||
pty: pty.IPty;
|
||||
@@ -77,6 +214,12 @@ export class TerminalService extends EventEmitter {
|
||||
!!(process.versions && (process.versions as Record<string, string>).electron) ||
|
||||
!!process.env.ELECTRON_RUN_AS_NODE;
|
||||
private useConptyFallback = false; // Track if we need to use winpty fallback on Windows
|
||||
private settingsService: SettingsService | null = null;
|
||||
|
||||
constructor(settingsService?: SettingsService) {
|
||||
super();
|
||||
this.settingsService = settingsService || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a PTY process with platform-specific handling.
|
||||
@@ -102,37 +245,19 @@ export class TerminalService extends EventEmitter {
|
||||
const platform = os.platform();
|
||||
const shellPaths = getShellPaths();
|
||||
|
||||
// Helper to get basename handling both path separators
|
||||
const getBasename = (shellPath: string): string => {
|
||||
const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\'));
|
||||
return lastSep >= 0 ? shellPath.slice(lastSep + 1) : shellPath;
|
||||
};
|
||||
|
||||
// Helper to get shell args based on shell name
|
||||
const getShellArgs = (shell: string): string[] => {
|
||||
const shellName = getBasename(shell).toLowerCase().replace('.exe', '');
|
||||
// PowerShell and cmd don't need --login
|
||||
if (shellName === 'powershell' || shellName === 'pwsh' || shellName === 'cmd') {
|
||||
return [];
|
||||
}
|
||||
// sh doesn't support --login in all implementations
|
||||
if (shellName === 'sh') {
|
||||
return [];
|
||||
}
|
||||
// bash, zsh, and other POSIX shells support --login
|
||||
return ['--login'];
|
||||
};
|
||||
|
||||
// Check if running in WSL - prefer user's shell or bash with --login
|
||||
if (platform === 'linux' && this.isWSL()) {
|
||||
const userShell = process.env.SHELL;
|
||||
if (userShell) {
|
||||
// Try to find userShell in allowed paths
|
||||
for (const allowedShell of shellPaths) {
|
||||
if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) {
|
||||
if (
|
||||
allowedShell === userShell ||
|
||||
getShellBasename(allowedShell) === getShellBasename(userShell)
|
||||
) {
|
||||
try {
|
||||
if (systemPathExists(allowedShell)) {
|
||||
return { shell: allowedShell, args: getShellArgs(allowedShell) };
|
||||
return { shell: allowedShell, args: getShellArgsForPath(allowedShell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed, continue searching
|
||||
@@ -144,7 +269,7 @@ export class TerminalService extends EventEmitter {
|
||||
for (const shell of shellPaths) {
|
||||
try {
|
||||
if (systemPathExists(shell)) {
|
||||
return { shell, args: getShellArgs(shell) };
|
||||
return { shell, args: getShellArgsForPath(shell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed, continue
|
||||
@@ -158,10 +283,13 @@ export class TerminalService extends EventEmitter {
|
||||
if (userShell && platform !== 'win32') {
|
||||
// Try to find userShell in allowed paths
|
||||
for (const allowedShell of shellPaths) {
|
||||
if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) {
|
||||
if (
|
||||
allowedShell === userShell ||
|
||||
getShellBasename(allowedShell) === getShellBasename(userShell)
|
||||
) {
|
||||
try {
|
||||
if (systemPathExists(allowedShell)) {
|
||||
return { shell: allowedShell, args: getShellArgs(allowedShell) };
|
||||
return { shell: allowedShell, args: getShellArgsForPath(allowedShell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed, continue searching
|
||||
@@ -174,7 +302,7 @@ export class TerminalService extends EventEmitter {
|
||||
for (const shell of shellPaths) {
|
||||
try {
|
||||
if (systemPathExists(shell)) {
|
||||
return { shell, args: getShellArgs(shell) };
|
||||
return { shell, args: getShellArgsForPath(shell) };
|
||||
}
|
||||
} catch {
|
||||
// Path not allowed or doesn't exist, continue to next
|
||||
@@ -313,8 +441,9 @@ export class TerminalService extends EventEmitter {
|
||||
|
||||
const id = `term-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
|
||||
const { shell: detectedShell, args: shellArgs } = this.detectShell();
|
||||
const { shell: detectedShell, args: detectedShellArgs } = this.detectShell();
|
||||
const shell = options.shell || detectedShell;
|
||||
let shellArgs = options.shell ? getShellArgsForPath(shell) : [...detectedShellArgs];
|
||||
|
||||
// Validate and resolve working directory
|
||||
// Uses secureFs internally to enforce ALLOWED_ROOT_DIRECTORY
|
||||
@@ -332,6 +461,89 @@ export class TerminalService extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal config injection (custom prompts, themes)
|
||||
const terminalConfigEnv: Record<string, string> = {};
|
||||
if (this.settingsService) {
|
||||
try {
|
||||
logger.info(
|
||||
`[createSession] Checking terminal config for session ${id}, cwd: ${options.cwd || cwd}`
|
||||
);
|
||||
const globalSettings = await this.settingsService.getGlobalSettings();
|
||||
const projectSettings = options.cwd
|
||||
? await this.settingsService.getProjectSettings(options.cwd)
|
||||
: null;
|
||||
|
||||
const globalTerminalConfig = globalSettings?.terminalConfig;
|
||||
const projectTerminalConfig = projectSettings?.terminalConfig;
|
||||
const effectiveConfig = buildEffectiveTerminalConfig(
|
||||
globalTerminalConfig,
|
||||
projectTerminalConfig
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`[createSession] Terminal config: global.enabled=${globalTerminalConfig?.enabled}, project.enabled=${projectTerminalConfig?.enabled}`
|
||||
);
|
||||
logger.info(
|
||||
`[createSession] Terminal config effective enabled: ${effectiveConfig.enabled}`
|
||||
);
|
||||
|
||||
if (effectiveConfig.enabled && globalTerminalConfig) {
|
||||
const currentTheme = globalSettings?.theme || 'dark';
|
||||
const themeColors = getTerminalThemeColors(currentTheme);
|
||||
const allThemes = getAllTerminalThemes();
|
||||
const promptTheme =
|
||||
projectTerminalConfig?.promptTheme ?? globalTerminalConfig.promptTheme;
|
||||
const ompThemeName = resolveOmpThemeName(promptTheme);
|
||||
|
||||
// Ensure RC files are up to date
|
||||
await ensureRcFilesUpToDate(
|
||||
options.cwd || cwd,
|
||||
currentTheme,
|
||||
effectiveConfig,
|
||||
themeColors,
|
||||
allThemes
|
||||
);
|
||||
|
||||
// Set shell-specific env vars
|
||||
const shellName = getShellBasename(shell).toLowerCase();
|
||||
if (ompThemeName && effectiveConfig.customPrompt) {
|
||||
terminalConfigEnv[OMP_THEME_ENV_VAR] = ompThemeName;
|
||||
}
|
||||
|
||||
if (shellName.includes(SHELL_NAME_BASH)) {
|
||||
const bashRcFilePath = getRcFilePath(options.cwd || cwd, SHELL_NAME_BASH);
|
||||
terminalConfigEnv.BASH_ENV = bashRcFilePath;
|
||||
terminalConfigEnv.AUTOMAKER_CUSTOM_PROMPT = effectiveConfig.customPrompt
|
||||
? 'true'
|
||||
: 'false';
|
||||
terminalConfigEnv.AUTOMAKER_THEME = currentTheme;
|
||||
shellArgs = applyBashRcFileArgs(shellArgs, bashRcFilePath);
|
||||
} else if (shellName.includes(SHELL_NAME_ZSH)) {
|
||||
terminalConfigEnv.ZDOTDIR = getTerminalDir(options.cwd || cwd);
|
||||
terminalConfigEnv.AUTOMAKER_CUSTOM_PROMPT = effectiveConfig.customPrompt
|
||||
? 'true'
|
||||
: 'false';
|
||||
terminalConfigEnv.AUTOMAKER_THEME = currentTheme;
|
||||
} else if (shellName === SHELL_NAME_SH) {
|
||||
terminalConfigEnv.ENV = getRcFilePath(options.cwd || cwd, SHELL_NAME_SH);
|
||||
terminalConfigEnv.AUTOMAKER_CUSTOM_PROMPT = effectiveConfig.customPrompt
|
||||
? 'true'
|
||||
: 'false';
|
||||
terminalConfigEnv.AUTOMAKER_THEME = currentTheme;
|
||||
}
|
||||
|
||||
// Add custom env vars from config
|
||||
Object.assign(terminalConfigEnv, effectiveConfig.customEnvVars);
|
||||
|
||||
logger.info(
|
||||
`[createSession] Terminal config enabled for session ${id}, shell: ${shellName}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`[createSession] Failed to apply terminal config: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...cleanEnv,
|
||||
TERM: 'xterm-256color',
|
||||
@@ -341,6 +553,7 @@ export class TerminalService extends EventEmitter {
|
||||
LANG: process.env.LANG || 'en_US.UTF-8',
|
||||
LC_ALL: process.env.LC_ALL || process.env.LANG || 'en_US.UTF-8',
|
||||
...options.env,
|
||||
...terminalConfigEnv, // Apply terminal config env vars last (highest priority)
|
||||
};
|
||||
|
||||
logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`);
|
||||
@@ -652,6 +865,44 @@ export class TerminalService extends EventEmitter {
|
||||
return () => this.exitCallbacks.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle theme change - regenerate RC files with new theme colors
|
||||
*/
|
||||
async onThemeChange(projectPath: string, newTheme: string): Promise<void> {
|
||||
if (!this.settingsService) {
|
||||
logger.warn('[onThemeChange] SettingsService not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const globalSettings = await this.settingsService.getGlobalSettings();
|
||||
const terminalConfig = globalSettings?.terminalConfig;
|
||||
const projectSettings = await this.settingsService.getProjectSettings(projectPath);
|
||||
const projectTerminalConfig = projectSettings?.terminalConfig;
|
||||
const effectiveConfig = buildEffectiveTerminalConfig(terminalConfig, projectTerminalConfig);
|
||||
|
||||
if (effectiveConfig.enabled && terminalConfig) {
|
||||
const themeColors = getTerminalThemeColors(
|
||||
newTheme as import('@automaker/types').ThemeMode
|
||||
);
|
||||
const allThemes = getAllTerminalThemes();
|
||||
|
||||
// Regenerate RC files with new theme
|
||||
await ensureRcFilesUpToDate(
|
||||
projectPath,
|
||||
newTheme as import('@automaker/types').ThemeMode,
|
||||
effectiveConfig,
|
||||
themeColors,
|
||||
allThemes
|
||||
);
|
||||
|
||||
logger.info(`[onThemeChange] Regenerated RC files for theme: ${newTheme}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[onThemeChange] Failed to regenerate RC files: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all sessions
|
||||
*/
|
||||
@@ -676,9 +927,9 @@ export class TerminalService extends EventEmitter {
|
||||
// Singleton instance
|
||||
let terminalService: TerminalService | null = null;
|
||||
|
||||
export function getTerminalService(): TerminalService {
|
||||
export function getTerminalService(settingsService?: SettingsService): TerminalService {
|
||||
if (!terminalService) {
|
||||
terminalService = new TerminalService();
|
||||
terminalService = new TerminalService(settingsService);
|
||||
}
|
||||
return terminalService;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('model-resolver.ts', () => {
|
||||
|
||||
it("should resolve 'opus' alias to full model string", () => {
|
||||
const result = resolveModelString('opus');
|
||||
expect(result).toBe('claude-opus-4-5-20251101');
|
||||
expect(result).toBe('claude-opus-4-6');
|
||||
expect(consoleSpy.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Migrated legacy ID: "opus" -> "claude-opus"')
|
||||
);
|
||||
@@ -117,7 +117,7 @@ describe('model-resolver.ts', () => {
|
||||
describe('getEffectiveModel', () => {
|
||||
it('should prioritize explicit model over session and default', () => {
|
||||
const result = getEffectiveModel('opus', 'haiku', 'gpt-5.2');
|
||||
expect(result).toBe('claude-opus-4-5-20251101');
|
||||
expect(result).toBe('claude-opus-4-6');
|
||||
});
|
||||
|
||||
it('should use session model when explicit is not provided', () => {
|
||||
|
||||
@@ -491,5 +491,29 @@ describe('sdk-options.ts', () => {
|
||||
expect(options.maxThinkingTokens).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('adaptive thinking for Opus 4.6', () => {
|
||||
it('should not set maxThinkingTokens for adaptive thinking (model decides)', async () => {
|
||||
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createAutoModeOptions({
|
||||
cwd: '/test/path',
|
||||
thinkingLevel: 'adaptive',
|
||||
});
|
||||
|
||||
expect(options.maxThinkingTokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not include maxThinkingTokens when thinkingLevel is "none"', async () => {
|
||||
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createAutoModeOptions({
|
||||
cwd: '/test/path',
|
||||
thinkingLevel: 'none',
|
||||
});
|
||||
|
||||
expect(options.maxThinkingTokens).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Hello',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
model: 'claude-opus-4-6',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
@@ -59,7 +59,7 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test prompt',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
model: 'claude-opus-4-6',
|
||||
cwd: '/test/dir',
|
||||
systemPrompt: 'You are helpful',
|
||||
maxTurns: 10,
|
||||
@@ -71,7 +71,7 @@ describe('claude-provider.ts', () => {
|
||||
expect(sdk.query).toHaveBeenCalledWith({
|
||||
prompt: 'Test prompt',
|
||||
options: expect.objectContaining({
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
model: 'claude-opus-4-6',
|
||||
systemPrompt: 'You are helpful',
|
||||
maxTurns: 10,
|
||||
cwd: '/test/dir',
|
||||
@@ -91,7 +91,7 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
model: 'claude-opus-4-6',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
model: 'claude-opus-4-6',
|
||||
cwd: '/test',
|
||||
abortController,
|
||||
});
|
||||
@@ -145,7 +145,7 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Current message',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
model: 'claude-opus-4-6',
|
||||
cwd: '/test',
|
||||
conversationHistory,
|
||||
sdkSessionId: 'test-session-id',
|
||||
@@ -176,7 +176,7 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: arrayPrompt as any,
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
model: 'claude-opus-4-6',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
@@ -196,7 +196,7 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
model: 'claude-opus-4-6',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
@@ -222,7 +222,7 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
model: 'claude-opus-4-6',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
@@ -286,7 +286,7 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
model: 'claude-opus-4-6',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
@@ -313,7 +313,7 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
model: 'claude-opus-4-6',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
@@ -341,7 +341,7 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
model: 'claude-opus-4-6',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
@@ -366,12 +366,12 @@ describe('claude-provider.ts', () => {
|
||||
expect(models).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('should include Claude Opus 4.5', () => {
|
||||
it('should include Claude Opus 4.6', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
const opus = models.find((m) => m.id === 'claude-opus-4-5-20251101');
|
||||
const opus = models.find((m) => m.id === 'claude-opus-4-6');
|
||||
expect(opus).toBeDefined();
|
||||
expect(opus?.name).toBe('Claude Opus 4.5');
|
||||
expect(opus?.name).toBe('Claude Opus 4.6');
|
||||
expect(opus?.provider).toBe('anthropic');
|
||||
});
|
||||
|
||||
@@ -400,7 +400,7 @@ describe('claude-provider.ts', () => {
|
||||
it('should mark Opus as default', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
const opus = models.find((m) => m.id === 'claude-opus-4-5-20251101');
|
||||
const opus = models.find((m) => m.id === 'claude-opus-4-6');
|
||||
expect(opus?.default).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -54,8 +54,8 @@ describe('provider-factory.ts', () => {
|
||||
|
||||
describe('getProviderForModel', () => {
|
||||
describe('Claude models (claude-* prefix)', () => {
|
||||
it('should return ClaudeProvider for claude-opus-4-5-20251101', () => {
|
||||
const provider = ProviderFactory.getProviderForModel('claude-opus-4-5-20251101');
|
||||
it('should return ClaudeProvider for claude-opus-4-6', () => {
|
||||
const provider = ProviderFactory.getProviderForModel('claude-opus-4-6');
|
||||
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('provider-factory.ts', () => {
|
||||
});
|
||||
|
||||
it('should be case-insensitive for claude models', () => {
|
||||
const provider = ProviderFactory.getProviderForModel('CLAUDE-OPUS-4-5-20251101');
|
||||
const provider = ProviderFactory.getProviderForModel('CLAUDE-OPUS-4-6');
|
||||
expect(provider).toBeInstanceOf(ClaudeProvider);
|
||||
});
|
||||
});
|
||||
|
||||
106
apps/server/tests/unit/routes/worktree/switch-branch.test.ts
Normal file
106
apps/server/tests/unit/routes/worktree/switch-branch.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
import { createMockExpressContext } from '../../../utils/mocks.js';
|
||||
|
||||
vi.mock('child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('child_process')>();
|
||||
return {
|
||||
...actual,
|
||||
exec: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('util', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('util')>();
|
||||
return {
|
||||
...actual,
|
||||
promisify: (fn: unknown) => fn,
|
||||
};
|
||||
});
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { createSwitchBranchHandler } from '@/routes/worktree/routes/switch-branch.js';
|
||||
|
||||
const mockExec = exec as Mock;
|
||||
|
||||
describe('switch-branch route', () => {
|
||||
let req: Request;
|
||||
let res: Response;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const context = createMockExpressContext();
|
||||
req = context.req;
|
||||
res = context.res;
|
||||
});
|
||||
|
||||
it('should allow switching when only untracked files exist', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/repo/path',
|
||||
branchName: 'feature/test',
|
||||
};
|
||||
|
||||
mockExec.mockImplementation(async (command: string) => {
|
||||
if (command === 'git rev-parse --abbrev-ref HEAD') {
|
||||
return { stdout: 'main\n', stderr: '' };
|
||||
}
|
||||
if (command === 'git rev-parse --verify feature/test') {
|
||||
return { stdout: 'abc123\n', stderr: '' };
|
||||
}
|
||||
if (command === 'git status --porcelain') {
|
||||
return { stdout: '?? .automaker/\n?? notes.txt\n', stderr: '' };
|
||||
}
|
||||
if (command === 'git checkout "feature/test"') {
|
||||
return { stdout: '', stderr: '' };
|
||||
}
|
||||
return { stdout: '', stderr: '' };
|
||||
});
|
||||
|
||||
const handler = createSwitchBranchHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
result: {
|
||||
previousBranch: 'main',
|
||||
currentBranch: 'feature/test',
|
||||
message: "Switched to branch 'feature/test'",
|
||||
},
|
||||
});
|
||||
expect(mockExec).toHaveBeenCalledWith('git checkout "feature/test"', { cwd: '/repo/path' });
|
||||
});
|
||||
|
||||
it('should block switching when tracked files are modified', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/repo/path',
|
||||
branchName: 'feature/test',
|
||||
};
|
||||
|
||||
mockExec.mockImplementation(async (command: string) => {
|
||||
if (command === 'git rev-parse --abbrev-ref HEAD') {
|
||||
return { stdout: 'main\n', stderr: '' };
|
||||
}
|
||||
if (command === 'git rev-parse --verify feature/test') {
|
||||
return { stdout: 'abc123\n', stderr: '' };
|
||||
}
|
||||
if (command === 'git status --porcelain') {
|
||||
return { stdout: ' M src/index.ts\n?? notes.txt\n', stderr: '' };
|
||||
}
|
||||
if (command === 'git status --short') {
|
||||
return { stdout: ' M src/index.ts\n?? notes.txt\n', stderr: '' };
|
||||
}
|
||||
return { stdout: '', stderr: '' };
|
||||
});
|
||||
|
||||
const handler = createSwitchBranchHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error:
|
||||
'Cannot switch branches: you have uncommitted changes (M src/index.ts). Please commit your changes first.',
|
||||
code: 'UNCOMMITTED_CHANGES',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { AutoModeService } from '@/services/auto-mode-service.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
describe('auto-mode-service.ts', () => {
|
||||
let service: AutoModeService;
|
||||
@@ -842,4 +845,76 @@ describe('auto-mode-service.ts', () => {
|
||||
expect(service.isFeatureRunning('feature-3')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('interrupted recovery', () => {
|
||||
async function createFeatureFixture(
|
||||
projectPath: string,
|
||||
feature: Partial<Feature> & Pick<Feature, 'id'>
|
||||
): Promise<string> {
|
||||
const featureDir = path.join(projectPath, '.automaker', 'features', feature.id);
|
||||
await fs.mkdir(featureDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(featureDir, 'feature.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
title: 'Feature',
|
||||
description: 'Feature description',
|
||||
category: 'implementation',
|
||||
status: 'backlog',
|
||||
...feature,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
return featureDir;
|
||||
}
|
||||
|
||||
it('should resume features marked as interrupted after restart', async () => {
|
||||
const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-resume-'));
|
||||
try {
|
||||
const featureDir = await createFeatureFixture(projectPath, {
|
||||
id: 'feature-interrupted',
|
||||
status: 'interrupted',
|
||||
});
|
||||
await fs.writeFile(path.join(featureDir, 'agent-output.md'), 'partial progress');
|
||||
await createFeatureFixture(projectPath, {
|
||||
id: 'feature-complete',
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
const resumeFeatureMock = vi.fn().mockResolvedValue(undefined);
|
||||
(service as any).resumeFeature = resumeFeatureMock;
|
||||
|
||||
await (service as any).resumeInterruptedFeatures(projectPath);
|
||||
|
||||
expect(resumeFeatureMock).toHaveBeenCalledTimes(1);
|
||||
expect(resumeFeatureMock).toHaveBeenCalledWith(projectPath, 'feature-interrupted', true);
|
||||
} finally {
|
||||
await fs.rm(projectPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should include interrupted features in pending recovery candidates', async () => {
|
||||
const projectPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-pending-'));
|
||||
try {
|
||||
await createFeatureFixture(projectPath, {
|
||||
id: 'feature-interrupted',
|
||||
status: 'interrupted',
|
||||
});
|
||||
await createFeatureFixture(projectPath, {
|
||||
id: 'feature-waiting-approval',
|
||||
status: 'waiting_approval',
|
||||
});
|
||||
|
||||
const pendingFeatures = await (service as any).loadPendingFeatures(projectPath, null);
|
||||
const pendingIds = pendingFeatures.map((feature: Feature) => feature.id);
|
||||
|
||||
expect(pendingIds).toContain('feature-interrupted');
|
||||
expect(pendingIds).not.toContain('feature-waiting-approval');
|
||||
} finally {
|
||||
await fs.rm(projectPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -380,6 +380,148 @@ describe('dev-server-service.ts', () => {
|
||||
expect(service.listDevServers().result.servers).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL detection from output', () => {
|
||||
it('should detect Vite format URL', async () => {
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
|
||||
const mockProcess = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
|
||||
// Start server
|
||||
await service.startDevServer(testDir, testDir);
|
||||
|
||||
// Simulate Vite output
|
||||
mockProcess.stdout.emit('data', Buffer.from(' VITE v5.0.0 ready in 123 ms\n'));
|
||||
mockProcess.stdout.emit('data', Buffer.from(' ➜ Local: http://localhost:5173/\n'));
|
||||
|
||||
// Give it a moment to process
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const serverInfo = service.getServerInfo(testDir);
|
||||
expect(serverInfo?.url).toBe('http://localhost:5173/');
|
||||
expect(serverInfo?.urlDetected).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect Next.js format URL', async () => {
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
|
||||
const mockProcess = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
|
||||
await service.startDevServer(testDir, testDir);
|
||||
|
||||
// Simulate Next.js output
|
||||
mockProcess.stdout.emit(
|
||||
'data',
|
||||
Buffer.from('ready - started server on 0.0.0.0:3000, url: http://localhost:3000\n')
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const serverInfo = service.getServerInfo(testDir);
|
||||
expect(serverInfo?.url).toBe('http://localhost:3000');
|
||||
expect(serverInfo?.urlDetected).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect generic localhost URL', async () => {
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
|
||||
const mockProcess = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
|
||||
await service.startDevServer(testDir, testDir);
|
||||
|
||||
// Simulate generic output with URL
|
||||
mockProcess.stdout.emit('data', Buffer.from('Server running at http://localhost:8080\n'));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const serverInfo = service.getServerInfo(testDir);
|
||||
expect(serverInfo?.url).toBe('http://localhost:8080');
|
||||
expect(serverInfo?.urlDetected).toBe(true);
|
||||
});
|
||||
|
||||
it('should keep initial URL if no URL detected in output', async () => {
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
|
||||
const mockProcess = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
|
||||
const result = await service.startDevServer(testDir, testDir);
|
||||
|
||||
// Simulate output without URL
|
||||
mockProcess.stdout.emit('data', Buffer.from('Server starting...\n'));
|
||||
mockProcess.stdout.emit('data', Buffer.from('Ready!\n'));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const serverInfo = service.getServerInfo(testDir);
|
||||
// Should keep the initial allocated URL
|
||||
expect(serverInfo?.url).toBe(result.result?.url);
|
||||
expect(serverInfo?.urlDetected).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect HTTPS URLs', async () => {
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
|
||||
const mockProcess = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
|
||||
await service.startDevServer(testDir, testDir);
|
||||
|
||||
// Simulate HTTPS dev server
|
||||
mockProcess.stdout.emit('data', Buffer.from('Server at https://localhost:3443\n'));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const serverInfo = service.getServerInfo(testDir);
|
||||
expect(serverInfo?.url).toBe('https://localhost:3443');
|
||||
expect(serverInfo?.urlDetected).toBe(true);
|
||||
});
|
||||
|
||||
it('should only detect URL once (not update after first detection)', async () => {
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
|
||||
const mockProcess = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
|
||||
await service.startDevServer(testDir, testDir);
|
||||
|
||||
// First URL
|
||||
mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:5173/\n'));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const firstUrl = service.getServerInfo(testDir)?.url;
|
||||
|
||||
// Try to emit another URL
|
||||
mockProcess.stdout.emit('data', Buffer.from('Network: http://192.168.1.1:5173/\n'));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Should keep the first detected URL
|
||||
const serverInfo = service.getServerInfo(testDir);
|
||||
expect(serverInfo?.url).toBe(firstUrl);
|
||||
expect(serverInfo?.url).toBe('http://localhost:5173/');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper to create a mock child process
|
||||
|
||||
@@ -199,7 +199,7 @@ The agent is configured with:
|
||||
|
||||
```javascript
|
||||
{
|
||||
model: "claude-opus-4-5-20251101",
|
||||
model: "claude-opus-4-6",
|
||||
maxTurns: 20,
|
||||
cwd: workingDirectory,
|
||||
allowedTools: [
|
||||
|
||||
@@ -405,9 +405,28 @@ export function Sidebar() {
|
||||
</div>
|
||||
|
||||
{/* Scroll indicator - shows there's more content below */}
|
||||
{canScrollDown && sidebarOpen && (
|
||||
<div className="flex justify-center py-1 border-t border-border/30">
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground/50 animate-bounce" />
|
||||
{canScrollDown && (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex justify-center py-2 border-t border-border/30',
|
||||
'bg-gradient-to-t from-background via-background/95 to-transparent',
|
||||
'-mt-8 pt-8',
|
||||
'pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-auto flex flex-col items-center gap-0.5">
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'w-4 h-4 text-brand-500/70 animate-bounce',
|
||||
sidebarOpen ? 'block' : 'w-3 h-3'
|
||||
)}
|
||||
/>
|
||||
{sidebarOpen && (
|
||||
<span className="text-[10px] font-medium text-muted-foreground/70 uppercase tracking-wide">
|
||||
Scroll
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -437,6 +437,63 @@ export function BoardView() {
|
||||
// Auto mode hook - pass current worktree to get worktree-specific state
|
||||
// Must be after selectedWorktree is defined
|
||||
const autoMode = useAutoMode(selectedWorktree);
|
||||
|
||||
const refreshBoardState = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
const projectPath = currentProject.path;
|
||||
const beforeFeatures = (
|
||||
queryClient.getQueryData(queryKeys.features.all(projectPath)) as Feature[] | undefined
|
||||
)?.length;
|
||||
const beforeWorktrees = (
|
||||
queryClient.getQueryData(queryKeys.worktrees.all(projectPath)) as
|
||||
| { worktrees?: unknown[] }
|
||||
| undefined
|
||||
)?.worktrees?.length;
|
||||
const beforeRunningAgents = (
|
||||
queryClient.getQueryData(queryKeys.runningAgents.all()) as { count?: number } | undefined
|
||||
)?.count;
|
||||
const beforeAutoModeRunning = autoMode.isRunning;
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
queryClient.refetchQueries({ queryKey: queryKeys.features.all(projectPath) }),
|
||||
queryClient.refetchQueries({ queryKey: queryKeys.runningAgents.all() }),
|
||||
queryClient.refetchQueries({ queryKey: queryKeys.worktrees.all(projectPath) }),
|
||||
autoMode.refreshStatus(),
|
||||
]);
|
||||
|
||||
const afterFeatures = (
|
||||
queryClient.getQueryData(queryKeys.features.all(projectPath)) as Feature[] | undefined
|
||||
)?.length;
|
||||
const afterWorktrees = (
|
||||
queryClient.getQueryData(queryKeys.worktrees.all(projectPath)) as
|
||||
| { worktrees?: unknown[] }
|
||||
| undefined
|
||||
)?.worktrees?.length;
|
||||
const afterRunningAgents = (
|
||||
queryClient.getQueryData(queryKeys.runningAgents.all()) as { count?: number } | undefined
|
||||
)?.count;
|
||||
const afterAutoModeRunning = autoMode.isRunning;
|
||||
|
||||
if (
|
||||
beforeFeatures !== afterFeatures ||
|
||||
beforeWorktrees !== afterWorktrees ||
|
||||
beforeRunningAgents !== afterRunningAgents ||
|
||||
beforeAutoModeRunning !== afterAutoModeRunning
|
||||
) {
|
||||
logger.info('[Board] Refresh detected state mismatch', {
|
||||
features: { before: beforeFeatures, after: afterFeatures },
|
||||
worktrees: { before: beforeWorktrees, after: afterWorktrees },
|
||||
runningAgents: { before: beforeRunningAgents, after: afterRunningAgents },
|
||||
autoModeRunning: { before: beforeAutoModeRunning, after: afterAutoModeRunning },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[Board] Failed to refresh board state:', error);
|
||||
toast.error('Failed to refresh board state');
|
||||
}
|
||||
}, [autoMode, currentProject, queryClient]);
|
||||
// Get runningTasks from the hook (scoped to current project/worktree)
|
||||
const runningAutoTasks = autoMode.runningTasks;
|
||||
// Get worktree-specific maxConcurrency from the hook
|
||||
@@ -1321,6 +1378,7 @@ export function BoardView() {
|
||||
isCreatingSpec={isCreatingSpec}
|
||||
creatingSpecProjectPath={creatingSpecProjectPath}
|
||||
onShowBoardBackground={() => setShowBoardBackgroundModal(true)}
|
||||
onRefreshBoard={refreshBoardState}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Wand2, GitBranch, ClipboardCheck } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Wand2, GitBranch, ClipboardCheck, RefreshCw } from 'lucide-react';
|
||||
import { UsagePopover } from '@/components/usage-popover';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
@@ -35,6 +37,7 @@ interface BoardHeaderProps {
|
||||
creatingSpecProjectPath?: string;
|
||||
// Board controls props
|
||||
onShowBoardBackground: () => void;
|
||||
onRefreshBoard: () => Promise<void>;
|
||||
// View toggle props
|
||||
viewMode: ViewMode;
|
||||
onViewModeChange: (mode: ViewMode) => void;
|
||||
@@ -60,6 +63,7 @@ export function BoardHeader({
|
||||
isCreatingSpec,
|
||||
creatingSpecProjectPath,
|
||||
onShowBoardBackground,
|
||||
onRefreshBoard,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
}: BoardHeaderProps) {
|
||||
@@ -110,9 +114,20 @@ export function BoardHeader({
|
||||
|
||||
// State for mobile actions panel
|
||||
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
||||
const [isRefreshingBoard, setIsRefreshingBoard] = useState(false);
|
||||
|
||||
const isTablet = useIsTablet();
|
||||
|
||||
const handleRefreshBoard = useCallback(async () => {
|
||||
if (isRefreshingBoard) return;
|
||||
setIsRefreshingBoard(true);
|
||||
try {
|
||||
await onRefreshBoard();
|
||||
} finally {
|
||||
setIsRefreshingBoard(false);
|
||||
}
|
||||
}, [isRefreshingBoard, onRefreshBoard]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-5 p-4 border-b border-border bg-glass backdrop-blur-md">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -127,6 +142,22 @@ export function BoardHeader({
|
||||
<BoardControls isMounted={isMounted} onShowBoardBackground={onShowBoardBackground} />
|
||||
</div>
|
||||
<div className="flex gap-4 items-center">
|
||||
{isMounted && !isTablet && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon-sm"
|
||||
onClick={handleRefreshBoard}
|
||||
disabled={isRefreshingBoard}
|
||||
aria-label="Refresh board state from server"
|
||||
>
|
||||
<RefreshCw className={isRefreshingBoard ? 'w-4 h-4 animate-spin' : 'w-4 h-4'} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Refresh board state from server</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Usage Popover - show if either provider is authenticated, only on desktop */}
|
||||
{isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import { cn } from '@/lib/utils';
|
||||
import { modelSupportsThinking } from '@/lib/utils';
|
||||
import { useAppStore, ThinkingLevel, FeatureImage, PlanningMode, Feature } from '@/store/app-store';
|
||||
import type { ReasoningEffort, PhaseModelEntry, AgentModel } from '@automaker/types';
|
||||
import { supportsReasoningEffort } from '@automaker/types';
|
||||
import { supportsReasoningEffort, isAdaptiveThinkingModel } from '@automaker/types';
|
||||
import {
|
||||
PrioritySelector,
|
||||
WorkModeSelector,
|
||||
@@ -264,7 +264,20 @@ export function AddFeatureDialog({
|
||||
}, [planningMode]);
|
||||
|
||||
const handleModelChange = (entry: PhaseModelEntry) => {
|
||||
setModelEntry(entry);
|
||||
// Normalize thinking level when switching between adaptive and non-adaptive models
|
||||
const isNewModelAdaptive =
|
||||
typeof entry.model === 'string' && isAdaptiveThinkingModel(entry.model);
|
||||
const currentLevel = entry.thinkingLevel || 'none';
|
||||
|
||||
if (isNewModelAdaptive && currentLevel !== 'none' && currentLevel !== 'adaptive') {
|
||||
// Switching TO Opus 4.6 with a manual level -> auto-switch to 'adaptive'
|
||||
setModelEntry({ ...entry, thinkingLevel: 'adaptive' });
|
||||
} else if (!isNewModelAdaptive && currentLevel === 'adaptive') {
|
||||
// Switching FROM Opus 4.6 with adaptive -> auto-switch to 'high'
|
||||
setModelEntry({ ...entry, thinkingLevel: 'high' });
|
||||
} else {
|
||||
setModelEntry(entry);
|
||||
}
|
||||
};
|
||||
|
||||
const buildFeatureData = (): FeatureData | null => {
|
||||
|
||||
@@ -241,9 +241,9 @@ export function CreatePRDialog({
|
||||
<GitPullRequest className="w-5 h-5" />
|
||||
Create Pull Request
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<DialogDescription className="break-words">
|
||||
Push changes and create a pull request from{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
|
||||
<code className="font-mono bg-muted px-1 rounded break-all">{worktree.branch}</code>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
@@ -167,7 +167,14 @@ export const ALL_MODELS: ModelOption[] = [
|
||||
...COPILOT_MODELS,
|
||||
];
|
||||
|
||||
export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink'];
|
||||
export const THINKING_LEVELS: ThinkingLevel[] = [
|
||||
'none',
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'ultrathink',
|
||||
'adaptive',
|
||||
];
|
||||
|
||||
export const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {
|
||||
none: 'None',
|
||||
@@ -175,6 +182,7 @@ export const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {
|
||||
medium: 'Med',
|
||||
high: 'High',
|
||||
ultrathink: 'Ultra',
|
||||
adaptive: 'Adaptive',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,19 +2,25 @@ import { Label } from '@/components/ui/label';
|
||||
import { Brain } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ThinkingLevel } from '@/store/app-store';
|
||||
import { THINKING_LEVELS, THINKING_LEVEL_LABELS } from './model-constants';
|
||||
import { THINKING_LEVEL_LABELS } from './model-constants';
|
||||
import { getThinkingLevelsForModel } from '@automaker/types';
|
||||
|
||||
interface ThinkingLevelSelectorProps {
|
||||
selectedLevel: ThinkingLevel;
|
||||
onLevelSelect: (level: ThinkingLevel) => void;
|
||||
testIdPrefix?: string;
|
||||
/** Optional model ID to filter available thinking levels (e.g., Opus 4.6 only shows None/Adaptive) */
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export function ThinkingLevelSelector({
|
||||
selectedLevel,
|
||||
onLevelSelect,
|
||||
testIdPrefix = 'thinking-level',
|
||||
model,
|
||||
}: ThinkingLevelSelectorProps) {
|
||||
const levels = getThinkingLevelsForModel(model || '');
|
||||
|
||||
return (
|
||||
<div className="space-y-2 pt-2 border-t border-border">
|
||||
<Label className="flex items-center gap-2 text-sm">
|
||||
@@ -22,7 +28,7 @@ export function ThinkingLevelSelector({
|
||||
Thinking Level
|
||||
</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
{levels.map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
type="button"
|
||||
@@ -40,7 +46,9 @@ export function ThinkingLevelSelector({
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Higher levels give more time to reason through complex problems.
|
||||
{levels.includes('adaptive')
|
||||
? 'Adaptive thinking lets the model decide how much reasoning to use.'
|
||||
: 'Higher levels give more time to reason through complex problems.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
isGroupSelected,
|
||||
getSelectedVariant,
|
||||
codexModelHasThinking,
|
||||
getThinkingLevelsForModel,
|
||||
} from '@automaker/types';
|
||||
import {
|
||||
CLAUDE_MODELS,
|
||||
@@ -28,7 +29,6 @@ import {
|
||||
OPENCODE_MODELS,
|
||||
GEMINI_MODELS,
|
||||
COPILOT_MODELS,
|
||||
THINKING_LEVELS,
|
||||
THINKING_LEVEL_LABELS,
|
||||
REASONING_EFFORT_LEVELS,
|
||||
REASONING_EFFORT_LABELS,
|
||||
@@ -1296,7 +1296,9 @@ export function PhaseModelSelector({
|
||||
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
Thinking Level
|
||||
</div>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
{getThinkingLevelsForModel(
|
||||
model.mapsToClaudeModel === 'opus' ? 'claude-opus' : model.id || ''
|
||||
).map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => {
|
||||
@@ -1322,6 +1324,7 @@ export function PhaseModelSelector({
|
||||
{level === 'medium' && 'Moderate reasoning (10k tokens)'}
|
||||
{level === 'high' && 'Deep reasoning (16k tokens)'}
|
||||
{level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
|
||||
{level === 'adaptive' && 'Model decides reasoning depth'}
|
||||
</span>
|
||||
</div>
|
||||
{isSelected && currentThinking === level && (
|
||||
@@ -1402,7 +1405,9 @@ export function PhaseModelSelector({
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground border-b border-border/50 mb-1">
|
||||
Thinking Level
|
||||
</div>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
{getThinkingLevelsForModel(
|
||||
model.mapsToClaudeModel === 'opus' ? 'claude-opus' : model.id || ''
|
||||
).map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => {
|
||||
@@ -1428,6 +1433,7 @@ export function PhaseModelSelector({
|
||||
{level === 'medium' && 'Moderate reasoning (10k tokens)'}
|
||||
{level === 'high' && 'Deep reasoning (16k tokens)'}
|
||||
{level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
|
||||
{level === 'adaptive' && 'Model decides reasoning depth'}
|
||||
</span>
|
||||
</div>
|
||||
{isSelected && currentThinking === level && (
|
||||
@@ -1564,7 +1570,7 @@ export function PhaseModelSelector({
|
||||
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
Thinking Level
|
||||
</div>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
{getThinkingLevelsForModel(model.id).map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => {
|
||||
@@ -1589,6 +1595,7 @@ export function PhaseModelSelector({
|
||||
{level === 'medium' && 'Moderate reasoning (10k tokens)'}
|
||||
{level === 'high' && 'Deep reasoning (16k tokens)'}
|
||||
{level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
|
||||
{level === 'adaptive' && 'Model decides reasoning depth'}
|
||||
</span>
|
||||
</div>
|
||||
{isSelected && currentThinking === level && (
|
||||
@@ -1685,7 +1692,7 @@ export function PhaseModelSelector({
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground border-b border-border/50 mb-1">
|
||||
Thinking Level
|
||||
</div>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
{getThinkingLevelsForModel(model.id).map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => {
|
||||
@@ -1710,6 +1717,7 @@ export function PhaseModelSelector({
|
||||
{level === 'medium' && 'Moderate reasoning (10k tokens)'}
|
||||
{level === 'high' && 'Deep reasoning (16k tokens)'}
|
||||
{level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
|
||||
{level === 'adaptive' && 'Model decides reasoning depth'}
|
||||
</span>
|
||||
</div>
|
||||
{isSelected && currentThinking === level && (
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CodexModelId } from '@automaker/types';
|
||||
import { supportsReasoningEffort, type CodexModelId } from '@automaker/types';
|
||||
import { OpenAIIcon } from '@/components/ui/provider-icon';
|
||||
|
||||
interface CodexModelConfigurationProps {
|
||||
@@ -27,25 +27,30 @@ interface CodexModelInfo {
|
||||
}
|
||||
|
||||
const CODEX_MODEL_INFO: Record<CodexModelId, CodexModelInfo> = {
|
||||
'codex-gpt-5.3-codex': {
|
||||
id: 'codex-gpt-5.3-codex',
|
||||
label: 'GPT-5.3-Codex',
|
||||
description: 'Latest frontier agentic coding model',
|
||||
},
|
||||
'codex-gpt-5.2-codex': {
|
||||
id: 'codex-gpt-5.2-codex',
|
||||
label: 'GPT-5.2-Codex',
|
||||
description: 'Most advanced agentic coding model for complex software engineering',
|
||||
description: 'Frontier agentic coding model',
|
||||
},
|
||||
'codex-gpt-5.1-codex-max': {
|
||||
id: 'codex-gpt-5.1-codex-max',
|
||||
label: 'GPT-5.1-Codex-Max',
|
||||
description: 'Optimized for long-horizon, agentic coding tasks in Codex',
|
||||
description: 'Codex-optimized flagship for deep and fast reasoning',
|
||||
},
|
||||
'codex-gpt-5.1-codex-mini': {
|
||||
id: 'codex-gpt-5.1-codex-mini',
|
||||
label: 'GPT-5.1-Codex-Mini',
|
||||
description: 'Smaller, more cost-effective version for faster workflows',
|
||||
description: 'Optimized for codex. Cheaper, faster, but less capable',
|
||||
},
|
||||
'codex-gpt-5.2': {
|
||||
id: 'codex-gpt-5.2',
|
||||
label: 'GPT-5.2',
|
||||
description: 'Best general agentic model for tasks across industries and domains',
|
||||
description: 'Latest frontier model with improvements across knowledge, reasoning and coding',
|
||||
},
|
||||
'codex-gpt-5.1': {
|
||||
id: 'codex-gpt-5.1',
|
||||
@@ -157,13 +162,3 @@ export function CodexModelConfiguration({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function supportsReasoningEffort(modelId: string): boolean {
|
||||
const reasoningModels = [
|
||||
'codex-gpt-5.2-codex',
|
||||
'codex-gpt-5.1-codex-max',
|
||||
'codex-gpt-5.2',
|
||||
'codex-gpt-5.1',
|
||||
];
|
||||
return reasoningModels.includes(modelId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Prompt Preview - Shows a live preview of the custom terminal prompt
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ThemeMode } from '@automaker/types';
|
||||
import { getTerminalTheme } from '@/config/terminal-themes';
|
||||
|
||||
interface PromptPreviewProps {
|
||||
format: 'standard' | 'minimal' | 'powerline' | 'starship';
|
||||
theme: ThemeMode;
|
||||
showGitBranch: boolean;
|
||||
showGitStatus: boolean;
|
||||
showUserHost: boolean;
|
||||
showPath: boolean;
|
||||
pathStyle: 'full' | 'short' | 'basename';
|
||||
pathDepth: number;
|
||||
showTime: boolean;
|
||||
showExitStatus: boolean;
|
||||
isOmpTheme?: boolean;
|
||||
promptThemeLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PromptPreview({
|
||||
format,
|
||||
theme,
|
||||
showGitBranch,
|
||||
showGitStatus,
|
||||
showUserHost,
|
||||
showPath,
|
||||
pathStyle,
|
||||
pathDepth,
|
||||
showTime,
|
||||
showExitStatus,
|
||||
isOmpTheme = false,
|
||||
promptThemeLabel,
|
||||
className,
|
||||
}: PromptPreviewProps) {
|
||||
const terminalTheme = getTerminalTheme(theme);
|
||||
|
||||
const formatPath = (inputPath: string) => {
|
||||
let displayPath = inputPath;
|
||||
let prefix = '';
|
||||
|
||||
if (displayPath.startsWith('~/')) {
|
||||
prefix = '~/';
|
||||
displayPath = displayPath.slice(2);
|
||||
} else if (displayPath.startsWith('/')) {
|
||||
prefix = '/';
|
||||
displayPath = displayPath.slice(1);
|
||||
}
|
||||
|
||||
const segments = displayPath.split('/').filter((segment) => segment.length > 0);
|
||||
const depth = Math.max(0, pathDepth);
|
||||
const trimmedSegments = depth > 0 ? segments.slice(-depth) : segments;
|
||||
|
||||
let formattedSegments = trimmedSegments;
|
||||
if (pathStyle === 'basename' && trimmedSegments.length > 0) {
|
||||
formattedSegments = [trimmedSegments[trimmedSegments.length - 1]];
|
||||
} else if (pathStyle === 'short') {
|
||||
formattedSegments = trimmedSegments.map((segment, index) => {
|
||||
if (index < trimmedSegments.length - 1) {
|
||||
return segment.slice(0, 1);
|
||||
}
|
||||
return segment;
|
||||
});
|
||||
}
|
||||
|
||||
const joined = formattedSegments.join('/');
|
||||
if (prefix === '/' && joined.length === 0) {
|
||||
return '/';
|
||||
}
|
||||
if (prefix === '~/' && joined.length === 0) {
|
||||
return '~';
|
||||
}
|
||||
return `${prefix}${joined}`;
|
||||
};
|
||||
|
||||
// Generate preview text based on format
|
||||
const renderPrompt = () => {
|
||||
if (isOmpTheme) {
|
||||
return (
|
||||
<div className="font-mono text-sm leading-relaxed space-y-2">
|
||||
<div style={{ color: terminalTheme.magenta }}>
|
||||
{promptThemeLabel ?? 'Oh My Posh theme'}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Rendered by the oh-my-posh CLI in the terminal.
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Preview here stays generic to avoid misleading output.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const user = 'user';
|
||||
const host = 'automaker';
|
||||
const path = formatPath('~/projects/automaker');
|
||||
const branch = showGitBranch ? 'main' : null;
|
||||
const dirty = showGitStatus && showGitBranch ? '*' : '';
|
||||
const time = showTime ? '[14:32]' : '';
|
||||
const status = showExitStatus ? '✗ 1' : '';
|
||||
|
||||
const gitInfo = branch ? ` (${branch}${dirty})` : '';
|
||||
|
||||
switch (format) {
|
||||
case 'minimal': {
|
||||
return (
|
||||
<div className="font-mono text-sm leading-relaxed">
|
||||
{showTime && <span style={{ color: terminalTheme.magenta }}>{time} </span>}
|
||||
{showUserHost && (
|
||||
<span style={{ color: terminalTheme.cyan }}>
|
||||
{user}
|
||||
<span style={{ color: terminalTheme.foreground }}>@</span>
|
||||
<span style={{ color: terminalTheme.blue }}>{host}</span>{' '}
|
||||
</span>
|
||||
)}
|
||||
{showPath && <span style={{ color: terminalTheme.yellow }}>{path}</span>}
|
||||
{gitInfo && <span style={{ color: terminalTheme.magenta }}>{gitInfo}</span>}
|
||||
{showExitStatus && <span style={{ color: terminalTheme.red }}> {status}</span>}
|
||||
<span style={{ color: terminalTheme.green }}> $</span>
|
||||
<span className="ml-1 animate-pulse">▊</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'powerline': {
|
||||
const powerlineSegments: ReactNode[] = [];
|
||||
if (showUserHost) {
|
||||
powerlineSegments.push(
|
||||
<span key="user-host" style={{ color: terminalTheme.cyan }}>
|
||||
[{user}
|
||||
<span style={{ color: terminalTheme.foreground }}>@</span>
|
||||
<span style={{ color: terminalTheme.blue }}>{host}</span>]
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (showPath) {
|
||||
powerlineSegments.push(
|
||||
<span key="path" style={{ color: terminalTheme.yellow }}>
|
||||
[{path}]
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const powerlineCore = powerlineSegments.flatMap((segment, index) =>
|
||||
index === 0
|
||||
? [segment]
|
||||
: [
|
||||
<span key={`sep-${index}`} style={{ color: terminalTheme.cyan }}>
|
||||
─
|
||||
</span>,
|
||||
segment,
|
||||
]
|
||||
);
|
||||
const powerlineExtras: ReactNode[] = [];
|
||||
if (gitInfo) {
|
||||
powerlineExtras.push(
|
||||
<span key="git" style={{ color: terminalTheme.magenta }}>
|
||||
{gitInfo}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (showTime) {
|
||||
powerlineExtras.push(
|
||||
<span key="time" style={{ color: terminalTheme.magenta }}>
|
||||
{time}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (showExitStatus) {
|
||||
powerlineExtras.push(
|
||||
<span key="status" style={{ color: terminalTheme.red }}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const powerlineLine: ReactNode[] = [...powerlineCore];
|
||||
if (powerlineExtras.length > 0) {
|
||||
if (powerlineLine.length > 0) {
|
||||
powerlineLine.push(' ');
|
||||
}
|
||||
powerlineLine.push(...powerlineExtras);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="font-mono text-sm leading-relaxed space-y-1">
|
||||
<div>
|
||||
<span style={{ color: terminalTheme.cyan }}>┌─</span>
|
||||
{powerlineLine}
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: terminalTheme.cyan }}>└─</span>
|
||||
<span style={{ color: terminalTheme.green }}>$</span>
|
||||
<span className="ml-1 animate-pulse">▊</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'starship': {
|
||||
return (
|
||||
<div className="font-mono text-sm leading-relaxed space-y-1">
|
||||
<div>
|
||||
{showTime && <span style={{ color: terminalTheme.magenta }}>{time} </span>}
|
||||
{showUserHost && (
|
||||
<>
|
||||
<span style={{ color: terminalTheme.cyan }}>{user}</span>
|
||||
<span style={{ color: terminalTheme.foreground }}>@</span>
|
||||
<span style={{ color: terminalTheme.blue }}>{host}</span>
|
||||
</>
|
||||
)}
|
||||
{showPath && (
|
||||
<>
|
||||
<span style={{ color: terminalTheme.foreground }}> in </span>
|
||||
<span style={{ color: terminalTheme.yellow }}>{path}</span>
|
||||
</>
|
||||
)}
|
||||
{branch && (
|
||||
<>
|
||||
<span style={{ color: terminalTheme.foreground }}> on </span>
|
||||
<span style={{ color: terminalTheme.magenta }}>
|
||||
{branch}
|
||||
{dirty}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{showExitStatus && <span style={{ color: terminalTheme.red }}> {status}</span>}
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: terminalTheme.green }}>❯</span>
|
||||
<span className="ml-1 animate-pulse">▊</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'standard':
|
||||
default: {
|
||||
return (
|
||||
<div className="font-mono text-sm leading-relaxed">
|
||||
{showTime && <span style={{ color: terminalTheme.magenta }}>{time} </span>}
|
||||
{showUserHost && (
|
||||
<>
|
||||
<span style={{ color: terminalTheme.cyan }}>[{user}</span>
|
||||
<span style={{ color: terminalTheme.foreground }}>@</span>
|
||||
<span style={{ color: terminalTheme.blue }}>{host}</span>
|
||||
<span style={{ color: terminalTheme.cyan }}>]</span>
|
||||
</>
|
||||
)}
|
||||
{showPath && <span style={{ color: terminalTheme.yellow }}> {path}</span>}
|
||||
{gitInfo && <span style={{ color: terminalTheme.magenta }}>{gitInfo}</span>}
|
||||
{showExitStatus && <span style={{ color: terminalTheme.red }}> {status}</span>}
|
||||
<span style={{ color: terminalTheme.green }}> $</span>
|
||||
<span className="ml-1 animate-pulse">▊</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border p-4',
|
||||
'bg-[var(--terminal-bg)] text-[var(--terminal-fg)]',
|
||||
'shadow-inner',
|
||||
className
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--terminal-bg': terminalTheme.background,
|
||||
'--terminal-fg': terminalTheme.foreground,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="mb-2 text-xs text-muted-foreground opacity-70">Preview</div>
|
||||
{renderPrompt()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import type { TerminalPromptTheme } from '@automaker/types';
|
||||
|
||||
export const PROMPT_THEME_CUSTOM_ID: TerminalPromptTheme = 'custom';
|
||||
|
||||
export const OMP_THEME_NAMES = [
|
||||
'1_shell',
|
||||
'M365Princess',
|
||||
'agnoster',
|
||||
'agnoster.minimal',
|
||||
'agnosterplus',
|
||||
'aliens',
|
||||
'amro',
|
||||
'atomic',
|
||||
'atomicBit',
|
||||
'avit',
|
||||
'blue-owl',
|
||||
'blueish',
|
||||
'bubbles',
|
||||
'bubblesextra',
|
||||
'bubblesline',
|
||||
'capr4n',
|
||||
'catppuccin',
|
||||
'catppuccin_frappe',
|
||||
'catppuccin_latte',
|
||||
'catppuccin_macchiato',
|
||||
'catppuccin_mocha',
|
||||
'cert',
|
||||
'chips',
|
||||
'cinnamon',
|
||||
'clean-detailed',
|
||||
'cloud-context',
|
||||
'cloud-native-azure',
|
||||
'cobalt2',
|
||||
'craver',
|
||||
'darkblood',
|
||||
'devious-diamonds',
|
||||
'di4am0nd',
|
||||
'dracula',
|
||||
'easy-term',
|
||||
'emodipt',
|
||||
'emodipt-extend',
|
||||
'fish',
|
||||
'free-ukraine',
|
||||
'froczh',
|
||||
'gmay',
|
||||
'glowsticks',
|
||||
'grandpa-style',
|
||||
'gruvbox',
|
||||
'half-life',
|
||||
'honukai',
|
||||
'hotstick.minimal',
|
||||
'hul10',
|
||||
'hunk',
|
||||
'huvix',
|
||||
'if_tea',
|
||||
'illusi0n',
|
||||
'iterm2',
|
||||
'jandedobbeleer',
|
||||
'jblab_2021',
|
||||
'jonnychipz',
|
||||
'json',
|
||||
'jtracey93',
|
||||
'jv_sitecorian',
|
||||
'kali',
|
||||
'kushal',
|
||||
'lambda',
|
||||
'lambdageneration',
|
||||
'larserikfinholt',
|
||||
'lightgreen',
|
||||
'marcduiker',
|
||||
'markbull',
|
||||
'material',
|
||||
'microverse-power',
|
||||
'mojada',
|
||||
'montys',
|
||||
'mt',
|
||||
'multiverse-neon',
|
||||
'negligible',
|
||||
'neko',
|
||||
'night-owl',
|
||||
'nordtron',
|
||||
'nu4a',
|
||||
'onehalf.minimal',
|
||||
'paradox',
|
||||
'pararussel',
|
||||
'patriksvensson',
|
||||
'peru',
|
||||
'pixelrobots',
|
||||
'plague',
|
||||
'poshmon',
|
||||
'powerlevel10k_classic',
|
||||
'powerlevel10k_lean',
|
||||
'powerlevel10k_modern',
|
||||
'powerlevel10k_rainbow',
|
||||
'powerline',
|
||||
'probua.minimal',
|
||||
'pure',
|
||||
'quick-term',
|
||||
'remk',
|
||||
'robbyrussell',
|
||||
'rudolfs-dark',
|
||||
'rudolfs-light',
|
||||
'sim-web',
|
||||
'slim',
|
||||
'slimfat',
|
||||
'smoothie',
|
||||
'sonicboom_dark',
|
||||
'sonicboom_light',
|
||||
'sorin',
|
||||
'space',
|
||||
'spaceship',
|
||||
'star',
|
||||
'stelbent-compact.minimal',
|
||||
'stelbent.minimal',
|
||||
'takuya',
|
||||
'the-unnamed',
|
||||
'thecyberden',
|
||||
'tiwahu',
|
||||
'tokyo',
|
||||
'tokyonight_storm',
|
||||
'tonybaloney',
|
||||
'uew',
|
||||
'unicorn',
|
||||
'velvet',
|
||||
'wholespace',
|
||||
'wopian',
|
||||
'xtoys',
|
||||
'ys',
|
||||
'zash',
|
||||
] as const;
|
||||
|
||||
type OmpThemeName = (typeof OMP_THEME_NAMES)[number];
|
||||
|
||||
type PromptFormat = 'standard' | 'minimal' | 'powerline' | 'starship';
|
||||
|
||||
type PathStyle = 'full' | 'short' | 'basename';
|
||||
|
||||
export interface PromptThemeConfig {
|
||||
promptFormat: PromptFormat;
|
||||
showGitBranch: boolean;
|
||||
showGitStatus: boolean;
|
||||
showUserHost: boolean;
|
||||
showPath: boolean;
|
||||
pathStyle: PathStyle;
|
||||
pathDepth: number;
|
||||
showTime: boolean;
|
||||
showExitStatus: boolean;
|
||||
}
|
||||
|
||||
export interface PromptThemePreset {
|
||||
id: TerminalPromptTheme;
|
||||
label: string;
|
||||
description: string;
|
||||
config: PromptThemeConfig;
|
||||
}
|
||||
|
||||
const PATH_DEPTH_FULL = 0;
|
||||
const PATH_DEPTH_TWO = 2;
|
||||
const PATH_DEPTH_THREE = 3;
|
||||
|
||||
const POWERLINE_HINTS = ['powerline', 'powerlevel10k', 'agnoster', 'bubbles', 'smoothie'];
|
||||
const MINIMAL_HINTS = ['minimal', 'pure', 'slim', 'negligible'];
|
||||
const STARSHIP_HINTS = ['spaceship', 'star'];
|
||||
const SHORT_PATH_HINTS = ['compact', 'lean', 'slim'];
|
||||
const TIME_HINTS = ['time', 'clock'];
|
||||
const EXIT_STATUS_HINTS = ['status', 'exit', 'fail', 'error'];
|
||||
|
||||
function toPromptThemeId(name: OmpThemeName): TerminalPromptTheme {
|
||||
return `omp-${name}` as TerminalPromptTheme;
|
||||
}
|
||||
|
||||
function formatLabel(name: string): string {
|
||||
const cleaned = name.replace(/[._-]+/g, ' ').trim();
|
||||
return cleaned
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function buildPresetConfig(name: OmpThemeName): PromptThemeConfig {
|
||||
const lower = name.toLowerCase();
|
||||
const isPowerline = POWERLINE_HINTS.some((hint) => lower.includes(hint));
|
||||
const isMinimal = MINIMAL_HINTS.some((hint) => lower.includes(hint));
|
||||
const isStarship = STARSHIP_HINTS.some((hint) => lower.includes(hint));
|
||||
let promptFormat: PromptFormat = 'standard';
|
||||
|
||||
if (isPowerline) {
|
||||
promptFormat = 'powerline';
|
||||
} else if (isMinimal) {
|
||||
promptFormat = 'minimal';
|
||||
} else if (isStarship) {
|
||||
promptFormat = 'starship';
|
||||
}
|
||||
|
||||
const showUserHost = !isMinimal;
|
||||
const showPath = true;
|
||||
const pathStyle: PathStyle = isMinimal ? 'short' : 'full';
|
||||
let pathDepth = isMinimal ? PATH_DEPTH_THREE : PATH_DEPTH_FULL;
|
||||
|
||||
if (SHORT_PATH_HINTS.some((hint) => lower.includes(hint))) {
|
||||
pathDepth = PATH_DEPTH_TWO;
|
||||
}
|
||||
|
||||
if (lower.includes('powerlevel10k')) {
|
||||
pathDepth = PATH_DEPTH_THREE;
|
||||
}
|
||||
|
||||
const showTime = TIME_HINTS.some((hint) => lower.includes(hint));
|
||||
const showExitStatus = EXIT_STATUS_HINTS.some((hint) => lower.includes(hint));
|
||||
|
||||
return {
|
||||
promptFormat,
|
||||
showGitBranch: true,
|
||||
showGitStatus: true,
|
||||
showUserHost,
|
||||
showPath,
|
||||
pathStyle,
|
||||
pathDepth,
|
||||
showTime,
|
||||
showExitStatus,
|
||||
};
|
||||
}
|
||||
|
||||
export const PROMPT_THEME_PRESETS: PromptThemePreset[] = OMP_THEME_NAMES.map((name) => ({
|
||||
id: toPromptThemeId(name),
|
||||
label: `${formatLabel(name)} (OMP)`,
|
||||
description: 'Oh My Posh theme preset',
|
||||
config: buildPresetConfig(name),
|
||||
}));
|
||||
|
||||
export function getPromptThemePreset(presetId: TerminalPromptTheme): PromptThemePreset | null {
|
||||
return PROMPT_THEME_PRESETS.find((preset) => preset.id === presetId) ?? null;
|
||||
}
|
||||
|
||||
export function getMatchingPromptThemeId(config: PromptThemeConfig): TerminalPromptTheme {
|
||||
const match = PROMPT_THEME_PRESETS.find((preset) => {
|
||||
const presetConfig = preset.config;
|
||||
return (
|
||||
presetConfig.promptFormat === config.promptFormat &&
|
||||
presetConfig.showGitBranch === config.showGitBranch &&
|
||||
presetConfig.showGitStatus === config.showGitStatus &&
|
||||
presetConfig.showUserHost === config.showUserHost &&
|
||||
presetConfig.showPath === config.showPath &&
|
||||
presetConfig.pathStyle === config.pathStyle &&
|
||||
presetConfig.pathDepth === config.pathDepth &&
|
||||
presetConfig.showTime === config.showTime &&
|
||||
presetConfig.showExitStatus === config.showExitStatus
|
||||
);
|
||||
});
|
||||
|
||||
return match?.id ?? PROMPT_THEME_CUSTOM_ID;
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
/**
|
||||
* Terminal Config Section - Custom terminal configurations with theme synchronization
|
||||
*
|
||||
* This component provides UI for enabling custom terminal prompts that automatically
|
||||
* sync with Automaker's 40 themes. It's an opt-in feature that generates shell configs
|
||||
* in .automaker/terminal/ without modifying user's existing RC files.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Wand2, GitBranch, Info, Plus, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { toast } from 'sonner';
|
||||
import { PromptPreview } from './prompt-preview';
|
||||
import type { TerminalPromptTheme } from '@automaker/types';
|
||||
import {
|
||||
PROMPT_THEME_CUSTOM_ID,
|
||||
PROMPT_THEME_PRESETS,
|
||||
getMatchingPromptThemeId,
|
||||
getPromptThemePreset,
|
||||
type PromptThemeConfig,
|
||||
} from './prompt-theme-presets';
|
||||
import { useUpdateGlobalSettings } from '@/hooks/mutations/use-settings-mutations';
|
||||
import { useGlobalSettings } from '@/hooks/queries/use-settings';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
|
||||
export function TerminalConfigSection() {
|
||||
const PATH_DEPTH_MIN = 0;
|
||||
const PATH_DEPTH_MAX = 10;
|
||||
const ENV_VAR_UPDATE_DEBOUNCE_MS = 400;
|
||||
const ENV_VAR_ID_PREFIX = 'env';
|
||||
const TERMINAL_RC_FILE_VERSION = 11;
|
||||
const { theme } = useAppStore();
|
||||
const { data: globalSettings } = useGlobalSettings();
|
||||
const updateGlobalSettings = useUpdateGlobalSettings({ showSuccessToast: false });
|
||||
const envVarIdRef = useRef(0);
|
||||
const envVarUpdateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const createEnvVarEntry = useCallback(
|
||||
(key = '', value = '') => {
|
||||
envVarIdRef.current += 1;
|
||||
return {
|
||||
id: `${ENV_VAR_ID_PREFIX}-${envVarIdRef.current}`,
|
||||
key,
|
||||
value,
|
||||
};
|
||||
},
|
||||
[ENV_VAR_ID_PREFIX]
|
||||
);
|
||||
const [localEnvVars, setLocalEnvVars] = useState<
|
||||
Array<{ id: string; key: string; value: string }>
|
||||
>(() =>
|
||||
Object.entries(globalSettings?.terminalConfig?.customEnvVars || {}).map(([key, value]) =>
|
||||
createEnvVarEntry(key, value)
|
||||
)
|
||||
);
|
||||
const [showEnableConfirm, setShowEnableConfirm] = useState(false);
|
||||
|
||||
const clampPathDepth = (value: number) =>
|
||||
Math.min(PATH_DEPTH_MAX, Math.max(PATH_DEPTH_MIN, value));
|
||||
|
||||
const defaultTerminalConfig = {
|
||||
enabled: false,
|
||||
customPrompt: true,
|
||||
promptFormat: 'standard' as const,
|
||||
promptTheme: PROMPT_THEME_CUSTOM_ID,
|
||||
showGitBranch: true,
|
||||
showGitStatus: true,
|
||||
showUserHost: true,
|
||||
showPath: true,
|
||||
pathStyle: 'full' as const,
|
||||
pathDepth: PATH_DEPTH_MIN,
|
||||
showTime: false,
|
||||
showExitStatus: false,
|
||||
customAliases: '',
|
||||
customEnvVars: {},
|
||||
};
|
||||
|
||||
const terminalConfig = {
|
||||
...defaultTerminalConfig,
|
||||
...globalSettings?.terminalConfig,
|
||||
customAliases:
|
||||
globalSettings?.terminalConfig?.customAliases ?? defaultTerminalConfig.customAliases,
|
||||
customEnvVars:
|
||||
globalSettings?.terminalConfig?.customEnvVars ?? defaultTerminalConfig.customEnvVars,
|
||||
};
|
||||
|
||||
const promptThemeConfig: PromptThemeConfig = {
|
||||
promptFormat: terminalConfig.promptFormat,
|
||||
showGitBranch: terminalConfig.showGitBranch,
|
||||
showGitStatus: terminalConfig.showGitStatus,
|
||||
showUserHost: terminalConfig.showUserHost,
|
||||
showPath: terminalConfig.showPath,
|
||||
pathStyle: terminalConfig.pathStyle,
|
||||
pathDepth: terminalConfig.pathDepth,
|
||||
showTime: terminalConfig.showTime,
|
||||
showExitStatus: terminalConfig.showExitStatus,
|
||||
};
|
||||
|
||||
const storedPromptTheme = terminalConfig.promptTheme;
|
||||
const activePromptThemeId =
|
||||
storedPromptTheme === PROMPT_THEME_CUSTOM_ID
|
||||
? PROMPT_THEME_CUSTOM_ID
|
||||
: (storedPromptTheme ?? getMatchingPromptThemeId(promptThemeConfig));
|
||||
const isOmpTheme =
|
||||
storedPromptTheme !== undefined && storedPromptTheme !== PROMPT_THEME_CUSTOM_ID;
|
||||
const promptThemePreset = isOmpTheme
|
||||
? getPromptThemePreset(storedPromptTheme as TerminalPromptTheme)
|
||||
: null;
|
||||
|
||||
const applyEnabledUpdate = (enabled: boolean) => {
|
||||
// Ensure all required fields are present
|
||||
const updatedConfig = {
|
||||
enabled,
|
||||
customPrompt: terminalConfig.customPrompt,
|
||||
promptFormat: terminalConfig.promptFormat,
|
||||
showGitBranch: terminalConfig.showGitBranch,
|
||||
showGitStatus: terminalConfig.showGitStatus,
|
||||
showUserHost: terminalConfig.showUserHost,
|
||||
showPath: terminalConfig.showPath,
|
||||
pathStyle: terminalConfig.pathStyle,
|
||||
pathDepth: terminalConfig.pathDepth,
|
||||
showTime: terminalConfig.showTime,
|
||||
showExitStatus: terminalConfig.showExitStatus,
|
||||
promptTheme: terminalConfig.promptTheme ?? PROMPT_THEME_CUSTOM_ID,
|
||||
customAliases: terminalConfig.customAliases,
|
||||
customEnvVars: terminalConfig.customEnvVars,
|
||||
rcFileVersion: TERMINAL_RC_FILE_VERSION,
|
||||
};
|
||||
|
||||
updateGlobalSettings.mutate(
|
||||
{ terminalConfig: updatedConfig },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
enabled ? 'Custom terminal configs enabled' : 'Custom terminal configs disabled',
|
||||
{
|
||||
description: enabled
|
||||
? 'New terminals will use custom prompts'
|
||||
: '.automaker/terminal/ will be cleaned up',
|
||||
}
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('[TerminalConfig] Failed to update settings:', error);
|
||||
toast.error('Failed to update terminal config', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLocalEnvVars(
|
||||
Object.entries(globalSettings?.terminalConfig?.customEnvVars || {}).map(([key, value]) =>
|
||||
createEnvVarEntry(key, value)
|
||||
)
|
||||
);
|
||||
}, [createEnvVarEntry, globalSettings?.terminalConfig?.customEnvVars]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (envVarUpdateTimeoutRef.current) {
|
||||
clearTimeout(envVarUpdateTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleToggleEnabled = async (enabled: boolean) => {
|
||||
if (enabled) {
|
||||
setShowEnableConfirm(true);
|
||||
return;
|
||||
}
|
||||
|
||||
applyEnabledUpdate(false);
|
||||
};
|
||||
|
||||
const handleUpdateConfig = (updates: Partial<typeof terminalConfig>) => {
|
||||
const nextPromptTheme = updates.promptTheme ?? PROMPT_THEME_CUSTOM_ID;
|
||||
|
||||
updateGlobalSettings.mutate(
|
||||
{
|
||||
terminalConfig: {
|
||||
...terminalConfig,
|
||||
...updates,
|
||||
promptTheme: nextPromptTheme,
|
||||
},
|
||||
},
|
||||
{
|
||||
onError: (error) => {
|
||||
console.error('[TerminalConfig] Failed to update settings:', error);
|
||||
toast.error('Failed to update terminal config', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const scheduleEnvVarsUpdate = (envVarsObject: Record<string, string>) => {
|
||||
if (envVarUpdateTimeoutRef.current) {
|
||||
clearTimeout(envVarUpdateTimeoutRef.current);
|
||||
}
|
||||
envVarUpdateTimeoutRef.current = setTimeout(() => {
|
||||
handleUpdateConfig({ customEnvVars: envVarsObject });
|
||||
}, ENV_VAR_UPDATE_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const handlePromptThemeChange = (themeId: string) => {
|
||||
if (themeId === PROMPT_THEME_CUSTOM_ID) {
|
||||
handleUpdateConfig({ promptTheme: PROMPT_THEME_CUSTOM_ID });
|
||||
return;
|
||||
}
|
||||
|
||||
const preset = getPromptThemePreset(themeId as TerminalPromptTheme);
|
||||
if (!preset) {
|
||||
handleUpdateConfig({ promptTheme: PROMPT_THEME_CUSTOM_ID });
|
||||
return;
|
||||
}
|
||||
|
||||
handleUpdateConfig({
|
||||
...preset.config,
|
||||
promptTheme: preset.id,
|
||||
});
|
||||
};
|
||||
|
||||
const addEnvVar = () => {
|
||||
setLocalEnvVars([...localEnvVars, createEnvVarEntry()]);
|
||||
};
|
||||
|
||||
const removeEnvVar = (id: string) => {
|
||||
const newVars = localEnvVars.filter((envVar) => envVar.id !== id);
|
||||
setLocalEnvVars(newVars);
|
||||
|
||||
// Update settings
|
||||
const envVarsObject = newVars.reduce(
|
||||
(acc, { key, value }) => {
|
||||
if (key) acc[key] = value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
|
||||
scheduleEnvVarsUpdate(envVarsObject);
|
||||
};
|
||||
|
||||
const updateEnvVar = (id: string, field: 'key' | 'value', newValue: string) => {
|
||||
const newVars = localEnvVars.map((envVar) =>
|
||||
envVar.id === id ? { ...envVar, [field]: newValue } : envVar
|
||||
);
|
||||
setLocalEnvVars(newVars);
|
||||
|
||||
// Validate and update settings (only if key is valid)
|
||||
const envVarsObject = newVars.reduce(
|
||||
(acc, { key, value }) => {
|
||||
// Only include vars with valid keys (alphanumeric + underscore)
|
||||
if (key && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
|
||||
scheduleEnvVarsUpdate(envVarsObject);
|
||||
};
|
||||
|
||||
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-purple-500/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-purple-500/20 to-purple-600/10 flex items-center justify-center border border-purple-500/20">
|
||||
<Wand2 className="w-5 h-5 text-purple-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
Custom Terminal Configurations
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Generate custom shell prompts that automatically sync with your app theme. Opt-in feature
|
||||
that creates configs in .automaker/terminal/ without modifying your existing RC files.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Enable Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-foreground font-medium">Enable Custom Configurations</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create theme-synced shell configs in .automaker/terminal/
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={terminalConfig.enabled} onCheckedChange={handleToggleEnabled} />
|
||||
</div>
|
||||
|
||||
{terminalConfig.enabled && (
|
||||
<>
|
||||
{/* Info Box */}
|
||||
<div className="rounded-lg border border-purple-500/20 bg-purple-500/5 p-3 flex gap-2">
|
||||
<Info className="h-4 w-4 text-purple-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-foreground/80">
|
||||
<strong>How it works:</strong> Custom configs are applied to new terminals only.
|
||||
Your ~/.bashrc and ~/.zshrc are still loaded first. Close and reopen terminals to
|
||||
see changes.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Prompt Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-foreground font-medium">Custom Prompt</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Override default shell prompt with themed version
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={terminalConfig.customPrompt}
|
||||
onCheckedChange={(checked) => handleUpdateConfig({ customPrompt: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{terminalConfig.customPrompt && (
|
||||
<>
|
||||
{/* Prompt Format */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Prompt Theme (Oh My Posh)</Label>
|
||||
<Select value={activePromptThemeId} onValueChange={handlePromptThemeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={PROMPT_THEME_CUSTOM_ID}>
|
||||
<div className="space-y-0.5">
|
||||
<div>Custom</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Hand-tuned configuration
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
{PROMPT_THEME_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.id} value={preset.id}>
|
||||
<div className="space-y-0.5">
|
||||
<div>{preset.label}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{preset.description}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isOmpTheme && (
|
||||
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3 flex gap-2">
|
||||
<Info className="h-4 w-4 text-emerald-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-foreground/80">
|
||||
<strong>{promptThemePreset?.label ?? 'Oh My Posh theme'}</strong> uses the
|
||||
oh-my-posh CLI for rendering. Ensure it's installed for the full theme.
|
||||
Prompt format and segment toggles are ignored while an OMP theme is selected.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Prompt Format</Label>
|
||||
<Select
|
||||
value={terminalConfig.promptFormat}
|
||||
onValueChange={(value: 'standard' | 'minimal' | 'powerline' | 'starship') =>
|
||||
handleUpdateConfig({ promptFormat: value })
|
||||
}
|
||||
disabled={isOmpTheme}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard">
|
||||
<div className="space-y-0.5">
|
||||
<div>Standard</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
[user@host] ~/path (main*) $
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="minimal">
|
||||
<div className="space-y-0.5">
|
||||
<div>Minimal</div>
|
||||
<div className="text-xs text-muted-foreground">~/path (main*) $</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="powerline">
|
||||
<div className="space-y-0.5">
|
||||
<div>Powerline</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
┌─[user@host]─[~/path]─[main*]
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="starship">
|
||||
<div className="space-y-0.5">
|
||||
<div>Starship-Inspired</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
user@host in ~/path on main*
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Git Info Toggles */}
|
||||
<div className="space-y-4 pl-4 border-l-2 border-border/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<Label className="text-sm">Show Git Branch</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={terminalConfig.showGitBranch}
|
||||
onCheckedChange={(checked) => handleUpdateConfig({ showGitBranch: checked })}
|
||||
disabled={isOmpTheme}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">*</span>
|
||||
<Label className="text-sm">Show Git Status (dirty indicator)</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={terminalConfig.showGitStatus}
|
||||
onCheckedChange={(checked) => handleUpdateConfig({ showGitStatus: checked })}
|
||||
disabled={!terminalConfig.showGitBranch || isOmpTheme}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prompt Segments */}
|
||||
<div className="space-y-4 pl-4 border-l-2 border-border/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wand2 className="w-4 h-4 text-muted-foreground" />
|
||||
<Label className="text-sm">Show User & Host</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={terminalConfig.showUserHost}
|
||||
onCheckedChange={(checked) => handleUpdateConfig({ showUserHost: checked })}
|
||||
disabled={isOmpTheme}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">~/</span>
|
||||
<Label className="text-sm">Show Path</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={terminalConfig.showPath}
|
||||
onCheckedChange={(checked) => handleUpdateConfig({ showPath: checked })}
|
||||
disabled={isOmpTheme}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">⏱</span>
|
||||
<Label className="text-sm">Show Time</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={terminalConfig.showTime}
|
||||
onCheckedChange={(checked) => handleUpdateConfig({ showTime: checked })}
|
||||
disabled={isOmpTheme}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">✗</span>
|
||||
<Label className="text-sm">Show Exit Status</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={terminalConfig.showExitStatus}
|
||||
onCheckedChange={(checked) => handleUpdateConfig({ showExitStatus: checked })}
|
||||
disabled={isOmpTheme}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Path Style</Label>
|
||||
<Select
|
||||
value={terminalConfig.pathStyle}
|
||||
onValueChange={(value: 'full' | 'short' | 'basename') =>
|
||||
handleUpdateConfig({ pathStyle: value })
|
||||
}
|
||||
disabled={!terminalConfig.showPath || isOmpTheme}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="full">Full</SelectItem>
|
||||
<SelectItem value="short">Short</SelectItem>
|
||||
<SelectItem value="basename">Basename</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Path Depth</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={PATH_DEPTH_MIN}
|
||||
max={PATH_DEPTH_MAX}
|
||||
value={terminalConfig.pathDepth}
|
||||
onChange={(event) =>
|
||||
handleUpdateConfig({
|
||||
pathDepth: clampPathDepth(Number(event.target.value) || 0),
|
||||
})
|
||||
}
|
||||
disabled={!terminalConfig.showPath || isOmpTheme}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Preview */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Preview</Label>
|
||||
<PromptPreview
|
||||
format={terminalConfig.promptFormat}
|
||||
theme={theme}
|
||||
showGitBranch={terminalConfig.showGitBranch}
|
||||
showGitStatus={terminalConfig.showGitStatus}
|
||||
showUserHost={terminalConfig.showUserHost}
|
||||
showPath={terminalConfig.showPath}
|
||||
pathStyle={terminalConfig.pathStyle}
|
||||
pathDepth={terminalConfig.pathDepth}
|
||||
showTime={terminalConfig.showTime}
|
||||
showExitStatus={terminalConfig.showExitStatus}
|
||||
isOmpTheme={isOmpTheme}
|
||||
promptThemeLabel={promptThemePreset?.label}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Custom Aliases */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-foreground font-medium">Custom Aliases</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add shell aliases (one per line, e.g., alias ll='ls -la')
|
||||
</p>
|
||||
</div>
|
||||
<Textarea
|
||||
value={terminalConfig.customAliases}
|
||||
onChange={(e) => handleUpdateConfig({ customAliases: e.target.value })}
|
||||
placeholder="# Custom aliases alias gs='git status' alias ll='ls -la' alias ..='cd ..'"
|
||||
className="font-mono text-sm h-32"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Custom Environment Variables */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-foreground font-medium">
|
||||
Custom Environment Variables
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add custom env vars (alphanumeric + underscore only)
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={addEnvVar} className="h-8 gap-1.5">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{localEnvVars.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{localEnvVars.map((envVar) => (
|
||||
<div key={envVar.id} className="flex gap-2 items-start">
|
||||
<Input
|
||||
value={envVar.key}
|
||||
onChange={(e) => updateEnvVar(envVar.id, 'key', e.target.value)}
|
||||
placeholder="VAR_NAME"
|
||||
className={cn(
|
||||
'font-mono text-sm flex-1',
|
||||
envVar.key &&
|
||||
!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(envVar.key) &&
|
||||
'border-destructive'
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
value={envVar.value}
|
||||
onChange={(e) => updateEnvVar(envVar.id, 'value', e.target.value)}
|
||||
placeholder="value"
|
||||
className="font-mono text-sm flex-[2]"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeEnvVar(envVar.id)}
|
||||
className="h-9 w-9 p-0 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showEnableConfirm}
|
||||
onOpenChange={setShowEnableConfirm}
|
||||
title="Enable custom terminal configurations"
|
||||
description="Automaker will generate per-project shell configuration files for your terminal."
|
||||
icon={Info}
|
||||
confirmText="Enable"
|
||||
onConfirm={() => applyEnabledUpdate(true)}
|
||||
>
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>Creates shell config files in `.automaker/terminal/`</li>
|
||||
<li>Applies prompts and colors that match your app theme</li>
|
||||
<li>Leaves your existing `~/.bashrc` and `~/.zshrc` untouched</li>
|
||||
</ul>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
New terminal sessions will use the custom prompt; existing sessions are unchanged.
|
||||
</p>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { TERMINAL_FONT_OPTIONS } from '@/config/terminal-themes';
|
||||
import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options';
|
||||
import { useAvailableTerminals } from '@/components/views/board-view/worktree-panel/hooks/use-available-terminals';
|
||||
import { getTerminalIcon } from '@/components/icons/terminal-icons';
|
||||
import { TerminalConfigSection } from './terminal-config-section';
|
||||
|
||||
export function TerminalSection() {
|
||||
const {
|
||||
@@ -53,253 +54,258 @@ export function TerminalSection() {
|
||||
const { terminals, isRefreshing, refresh } = useAvailableTerminals();
|
||||
|
||||
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-green-500/20 to-green-600/10 flex items-center justify-center border border-green-500/20">
|
||||
<SquareTerminal className="w-5 h-5 text-green-500" />
|
||||
<div className="space-y-6">
|
||||
<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-green-500/20 to-green-600/10 flex items-center justify-center border border-green-500/20">
|
||||
<SquareTerminal className="w-5 h-5 text-green-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Terminal</h2>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Terminal</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Customize terminal appearance and behavior. Theme follows your app theme in Appearance
|
||||
settings.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Default External Terminal */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Default External Terminal</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={refresh}
|
||||
disabled={isRefreshing}
|
||||
title="Refresh available terminals"
|
||||
aria-label="Refresh available terminals"
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', isRefreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Terminal to use when selecting "Open in Terminal" from the worktree menu
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Customize terminal appearance and behavior. Theme follows your app theme in Appearance
|
||||
settings.
|
||||
</p>
|
||||
<Select
|
||||
value={defaultTerminalId ?? 'integrated'}
|
||||
onValueChange={(value) => {
|
||||
setDefaultTerminalId(value === 'integrated' ? null : value);
|
||||
toast.success(
|
||||
value === 'integrated'
|
||||
? 'Integrated terminal set as default'
|
||||
: 'Default terminal changed'
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a terminal" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="integrated">
|
||||
<span className="flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4" />
|
||||
Integrated Terminal
|
||||
</span>
|
||||
</SelectItem>
|
||||
{terminals.map((terminal) => {
|
||||
const TerminalIcon = getTerminalIcon(terminal.id);
|
||||
return (
|
||||
<SelectItem key={terminal.id} value={terminal.id}>
|
||||
<span className="flex items-center gap-2">
|
||||
<TerminalIcon className="w-4 h-4" />
|
||||
{terminal.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{terminals.length === 0 && !isRefreshing && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No external terminals detected. Click refresh to re-scan.
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Default External Terminal */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Default External Terminal</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={refresh}
|
||||
disabled={isRefreshing}
|
||||
title="Refresh available terminals"
|
||||
aria-label="Refresh available terminals"
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', isRefreshing && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Terminal to use when selecting "Open in Terminal" from the worktree menu
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Default Open Mode */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Default Open Mode</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How to open the integrated terminal when using "Open in Terminal" from the worktree menu
|
||||
</p>
|
||||
<Select
|
||||
value={openTerminalMode}
|
||||
onValueChange={(value: 'newTab' | 'split') => {
|
||||
setOpenTerminalMode(value);
|
||||
toast.success(
|
||||
value === 'newTab'
|
||||
? 'New terminals will open in new tabs'
|
||||
: 'New terminals will split the current tab'
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="newTab">
|
||||
<span className="flex items-center gap-2">
|
||||
<SquarePlus className="w-4 h-4" />
|
||||
New Tab
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="split">
|
||||
<span className="flex items-center gap-2">
|
||||
<SplitSquareHorizontal className="w-4 h-4" />
|
||||
Split Current Tab
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Font Family */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Font Family</Label>
|
||||
<Select
|
||||
value={fontFamily || DEFAULT_FONT_VALUE}
|
||||
onValueChange={(value) => {
|
||||
setTerminalFontFamily(value);
|
||||
toast.info('Font family changed', {
|
||||
description: 'Restart terminal for changes to take effect',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Default (Menlo / Monaco)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TERMINAL_FONT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: option.value === DEFAULT_FONT_VALUE ? undefined : option.value,
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
<Select
|
||||
value={defaultTerminalId ?? 'integrated'}
|
||||
onValueChange={(value) => {
|
||||
setDefaultTerminalId(value === 'integrated' ? null : value);
|
||||
toast.success(
|
||||
value === 'integrated'
|
||||
? 'Integrated terminal set as default'
|
||||
: 'Default terminal changed'
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a terminal" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="integrated">
|
||||
<span className="flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4" />
|
||||
Integrated Terminal
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Default Font Size */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Default Font Size</Label>
|
||||
<span className="text-sm text-muted-foreground">{defaultFontSize}px</span>
|
||||
{terminals.map((terminal) => {
|
||||
const TerminalIcon = getTerminalIcon(terminal.id);
|
||||
return (
|
||||
<SelectItem key={terminal.id} value={terminal.id}>
|
||||
<span className="flex items-center gap-2">
|
||||
<TerminalIcon className="w-4 h-4" />
|
||||
{terminal.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{terminals.length === 0 && !isRefreshing && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No external terminals detected. Click refresh to re-scan.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Slider
|
||||
value={[defaultFontSize]}
|
||||
min={8}
|
||||
max={32}
|
||||
step={1}
|
||||
onValueChange={([value]) => setTerminalDefaultFontSize(value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Line Height */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Line Height</Label>
|
||||
<span className="text-sm text-muted-foreground">{lineHeight.toFixed(1)}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[lineHeight]}
|
||||
min={1.0}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
onValueChange={([value]) => {
|
||||
setTerminalLineHeight(value);
|
||||
}}
|
||||
onValueCommit={() => {
|
||||
toast.info('Line height changed', {
|
||||
description: 'Restart terminal for changes to take effect',
|
||||
});
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scrollback Lines */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Scrollback Buffer</Label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{(scrollbackLines / 1000).toFixed(0)}k lines
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[scrollbackLines]}
|
||||
min={1000}
|
||||
max={100000}
|
||||
step={1000}
|
||||
onValueChange={([value]) => setTerminalScrollbackLines(value)}
|
||||
onValueCommit={() => {
|
||||
toast.info('Scrollback changed', {
|
||||
description: 'Restart terminal for changes to take effect',
|
||||
});
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Default Run Script */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Default Run Script</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Command to run automatically when opening a new terminal (e.g., "claude", "codex")
|
||||
</p>
|
||||
<Input
|
||||
value={defaultRunScript}
|
||||
onChange={(e) => setTerminalDefaultRunScript(e.target.value)}
|
||||
placeholder="e.g., claude, codex, npm run dev"
|
||||
className="bg-accent/30 border-border/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Screen Reader Mode */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-foreground font-medium">Screen Reader Mode</Label>
|
||||
{/* Default Open Mode */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Default Open Mode</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enable accessibility mode for screen readers
|
||||
How to open the integrated terminal when using "Open in Terminal" from the worktree
|
||||
menu
|
||||
</p>
|
||||
<Select
|
||||
value={openTerminalMode}
|
||||
onValueChange={(value: 'newTab' | 'split') => {
|
||||
setOpenTerminalMode(value);
|
||||
toast.success(
|
||||
value === 'newTab'
|
||||
? 'New terminals will open in new tabs'
|
||||
: 'New terminals will split the current tab'
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="newTab">
|
||||
<span className="flex items-center gap-2">
|
||||
<SquarePlus className="w-4 h-4" />
|
||||
New Tab
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="split">
|
||||
<span className="flex items-center gap-2">
|
||||
<SplitSquareHorizontal className="w-4 h-4" />
|
||||
Split Current Tab
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Switch
|
||||
checked={screenReaderMode}
|
||||
onCheckedChange={(checked) => {
|
||||
setTerminalScreenReaderMode(checked);
|
||||
toast.success(
|
||||
checked ? 'Screen reader mode enabled' : 'Screen reader mode disabled',
|
||||
{
|
||||
|
||||
{/* Font Family */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Font Family</Label>
|
||||
<Select
|
||||
value={fontFamily || DEFAULT_FONT_VALUE}
|
||||
onValueChange={(value) => {
|
||||
setTerminalFontFamily(value);
|
||||
toast.info('Font family changed', {
|
||||
description: 'Restart terminal for changes to take effect',
|
||||
}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Default (Menlo / Monaco)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TERMINAL_FONT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: option.value === DEFAULT_FONT_VALUE ? undefined : option.value,
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Default Font Size */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Default Font Size</Label>
|
||||
<span className="text-sm text-muted-foreground">{defaultFontSize}px</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[defaultFontSize]}
|
||||
min={8}
|
||||
max={32}
|
||||
step={1}
|
||||
onValueChange={([value]) => setTerminalDefaultFontSize(value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Line Height */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Line Height</Label>
|
||||
<span className="text-sm text-muted-foreground">{lineHeight.toFixed(1)}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[lineHeight]}
|
||||
min={1.0}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
onValueChange={([value]) => {
|
||||
setTerminalLineHeight(value);
|
||||
}}
|
||||
onValueCommit={() => {
|
||||
toast.info('Line height changed', {
|
||||
description: 'Restart terminal for changes to take effect',
|
||||
});
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scrollback Lines */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Scrollback Buffer</Label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{(scrollbackLines / 1000).toFixed(0)}k lines
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[scrollbackLines]}
|
||||
min={1000}
|
||||
max={100000}
|
||||
step={1000}
|
||||
onValueChange={([value]) => setTerminalScrollbackLines(value)}
|
||||
onValueCommit={() => {
|
||||
toast.info('Scrollback changed', {
|
||||
description: 'Restart terminal for changes to take effect',
|
||||
});
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Default Run Script */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Default Run Script</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Command to run automatically when opening a new terminal (e.g., "claude", "codex")
|
||||
</p>
|
||||
<Input
|
||||
value={defaultRunScript}
|
||||
onChange={(e) => setTerminalDefaultRunScript(e.target.value)}
|
||||
placeholder="e.g., claude, codex, npm run dev"
|
||||
className="bg-accent/30 border-border/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Screen Reader Mode */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-foreground font-medium">Screen Reader Mode</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enable accessibility mode for screen readers
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={screenReaderMode}
|
||||
onCheckedChange={(checked) => {
|
||||
setTerminalScreenReaderMode(checked);
|
||||
toast.success(
|
||||
checked ? 'Screen reader mode enabled' : 'Screen reader mode disabled',
|
||||
{
|
||||
description: 'Restart terminal for changes to take effect',
|
||||
}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TerminalConfigSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,11 +10,12 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { STALE_TIMES } from '@/lib/query-client';
|
||||
import { getGlobalEventsRecent } from '@/hooks/use-event-recency';
|
||||
import { createSmartPollingInterval, getGlobalEventsRecent } from '@/hooks/use-event-recency';
|
||||
import type { Feature } from '@/store/app-store';
|
||||
|
||||
const FEATURES_REFETCH_ON_FOCUS = false;
|
||||
const FEATURES_REFETCH_ON_RECONNECT = false;
|
||||
const FEATURES_POLLING_INTERVAL = 30000;
|
||||
/** Default polling interval for agent output when WebSocket is inactive */
|
||||
const AGENT_OUTPUT_POLLING_INTERVAL = 5000;
|
||||
|
||||
@@ -43,6 +44,7 @@ export function useFeatures(projectPath: string | undefined) {
|
||||
},
|
||||
enabled: !!projectPath,
|
||||
staleTime: STALE_TIMES.FEATURES,
|
||||
refetchInterval: createSmartPollingInterval(FEATURES_POLLING_INTERVAL),
|
||||
refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS,
|
||||
refetchOnReconnect: FEATURES_REFETCH_ON_RECONNECT,
|
||||
});
|
||||
|
||||
@@ -9,9 +9,11 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { getElectronAPI, type RunningAgent } from '@/lib/electron';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { STALE_TIMES } from '@/lib/query-client';
|
||||
import { createSmartPollingInterval } from '@/hooks/use-event-recency';
|
||||
|
||||
const RUNNING_AGENTS_REFETCH_ON_FOCUS = false;
|
||||
const RUNNING_AGENTS_REFETCH_ON_RECONNECT = false;
|
||||
const RUNNING_AGENTS_POLLING_INTERVAL = 30000;
|
||||
|
||||
interface RunningAgentsResult {
|
||||
agents: RunningAgent[];
|
||||
@@ -47,8 +49,7 @@ export function useRunningAgents() {
|
||||
};
|
||||
},
|
||||
staleTime: STALE_TIMES.RUNNING_AGENTS,
|
||||
// Note: Don't use refetchInterval here - rely on WebSocket invalidation
|
||||
// for real-time updates instead of polling
|
||||
refetchInterval: createSmartPollingInterval(RUNNING_AGENTS_POLLING_INTERVAL),
|
||||
refetchOnWindowFocus: RUNNING_AGENTS_REFETCH_ON_FOCUS,
|
||||
refetchOnReconnect: RUNNING_AGENTS_REFETCH_ON_RECONNECT,
|
||||
});
|
||||
|
||||
@@ -8,9 +8,11 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { STALE_TIMES } from '@/lib/query-client';
|
||||
import { createSmartPollingInterval } from '@/hooks/use-event-recency';
|
||||
|
||||
const WORKTREE_REFETCH_ON_FOCUS = false;
|
||||
const WORKTREE_REFETCH_ON_RECONNECT = false;
|
||||
const WORKTREES_POLLING_INTERVAL = 30000;
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
@@ -65,6 +67,7 @@ export function useWorktrees(projectPath: string | undefined, includeDetails = t
|
||||
},
|
||||
enabled: !!projectPath,
|
||||
staleTime: STALE_TIMES.WORKTREES,
|
||||
refetchInterval: createSmartPollingInterval(WORKTREES_POLLING_INTERVAL),
|
||||
refetchOnWindowFocus: WORKTREE_REFETCH_ON_FOCUS,
|
||||
refetchOnReconnect: WORKTREE_REFETCH_ON_RECONNECT,
|
||||
});
|
||||
|
||||
@@ -6,10 +6,12 @@ import { useAppStore } from '@/store/app-store';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import type { AutoModeEvent } from '@/types/electron';
|
||||
import type { WorktreeInfo } from '@/components/views/board-view/worktree-panel/types';
|
||||
import { getGlobalEventsRecent } from '@/hooks/use-event-recency';
|
||||
|
||||
const logger = createLogger('AutoMode');
|
||||
|
||||
const AUTO_MODE_SESSION_KEY = 'automaker:autoModeRunningByWorktreeKey';
|
||||
const AUTO_MODE_POLLING_INTERVAL = 30000;
|
||||
|
||||
/**
|
||||
* Generate a worktree key for session storage
|
||||
@@ -140,42 +142,54 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
// Check if we can start a new task based on concurrency limit
|
||||
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.status) return;
|
||||
|
||||
const result = await api.autoMode.status(currentProject.path, branchName);
|
||||
if (result.success && result.isAutoLoopRunning !== undefined) {
|
||||
const backendIsRunning = result.isAutoLoopRunning;
|
||||
|
||||
if (backendIsRunning !== isAutoModeRunning) {
|
||||
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
||||
logger.info(
|
||||
`[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
|
||||
);
|
||||
setAutoModeRunning(
|
||||
currentProject.id,
|
||||
branchName,
|
||||
backendIsRunning,
|
||||
result.maxConcurrency,
|
||||
result.runningFeatures
|
||||
);
|
||||
setAutoModeSessionForWorktree(currentProject.path, branchName, backendIsRunning);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error syncing auto mode state with backend:', error);
|
||||
}
|
||||
}, [branchName, currentProject, isAutoModeRunning, setAutoModeRunning]);
|
||||
|
||||
// On mount, query backend for current auto loop status and sync UI state.
|
||||
// This handles cases where the backend is still running after a page refresh.
|
||||
useEffect(() => {
|
||||
void refreshStatus();
|
||||
}, [refreshStatus]);
|
||||
|
||||
// Periodic polling fallback when WebSocket events are stale.
|
||||
useEffect(() => {
|
||||
if (!currentProject) return;
|
||||
|
||||
const syncWithBackend = async () => {
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.autoMode?.status) return;
|
||||
const interval = setInterval(() => {
|
||||
if (getGlobalEventsRecent()) return;
|
||||
void refreshStatus();
|
||||
}, AUTO_MODE_POLLING_INTERVAL);
|
||||
|
||||
const result = await api.autoMode.status(currentProject.path, branchName);
|
||||
if (result.success && result.isAutoLoopRunning !== undefined) {
|
||||
const backendIsRunning = result.isAutoLoopRunning;
|
||||
|
||||
if (backendIsRunning !== isAutoModeRunning) {
|
||||
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
||||
logger.info(
|
||||
`[AutoMode] Syncing UI state with backend for ${worktreeDesc} in ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
|
||||
);
|
||||
setAutoModeRunning(
|
||||
currentProject.id,
|
||||
branchName,
|
||||
backendIsRunning,
|
||||
result.maxConcurrency,
|
||||
result.runningFeatures
|
||||
);
|
||||
setAutoModeSessionForWorktree(currentProject.path, branchName, backendIsRunning);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error syncing auto mode state with backend:', error);
|
||||
}
|
||||
};
|
||||
|
||||
syncWithBackend();
|
||||
}, [currentProject, branchName, setAutoModeRunning]);
|
||||
return () => clearInterval(interval);
|
||||
}, [currentProject, refreshStatus]);
|
||||
|
||||
// Handle auto mode events - listen globally for all projects/worktrees
|
||||
useEffect(() => {
|
||||
@@ -672,5 +686,6 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
start,
|
||||
stop,
|
||||
stopFeature,
|
||||
refreshStatus,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,18 +27,20 @@ export interface AgentTaskInfo {
|
||||
/**
|
||||
* Default model used by the feature executor
|
||||
*/
|
||||
export const DEFAULT_MODEL = 'claude-opus-4-5-20251101';
|
||||
export const DEFAULT_MODEL = 'claude-opus-4-6';
|
||||
|
||||
/**
|
||||
* Formats a model name for display
|
||||
*/
|
||||
export function formatModelName(model: string): string {
|
||||
// Claude models
|
||||
if (model.includes('opus-4-6')) return 'Opus 4.6';
|
||||
if (model.includes('opus')) return 'Opus 4.5';
|
||||
if (model.includes('sonnet')) return 'Sonnet 4.5';
|
||||
if (model.includes('haiku')) return 'Haiku 4.5';
|
||||
|
||||
// Codex/GPT models - specific formatting
|
||||
if (model === 'codex-gpt-5.3-codex') return 'GPT-5.3 Codex';
|
||||
if (model === 'codex-gpt-5.2-codex') return 'GPT-5.2 Codex';
|
||||
if (model === 'codex-gpt-5.2') return 'GPT-5.2';
|
||||
if (model === 'codex-gpt-5.1-codex-max') return 'GPT-5.1 Max';
|
||||
|
||||
@@ -80,6 +80,7 @@ test.describe('Edit Feature', () => {
|
||||
await clickAddFeature(page);
|
||||
await fillAddFeatureDialog(page, originalDescription);
|
||||
await confirmAddFeature(page);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Wait for the feature to appear in the backlog
|
||||
await expect(async () => {
|
||||
@@ -88,7 +89,7 @@ test.describe('Edit Feature', () => {
|
||||
hasText: originalDescription,
|
||||
});
|
||||
expect(await featureCard.count()).toBeGreaterThan(0);
|
||||
}).toPass({ timeout: 10000 });
|
||||
}).toPass({ timeout: 20000 });
|
||||
|
||||
// Get the feature ID from the card
|
||||
const featureCard = page
|
||||
|
||||
Reference in New Issue
Block a user