Compare commits

..

2 Commits

Author SHA1 Message Date
Kacper
6e2f277f63 Merge v0.14.0rc into refactor/store-ui-slice
Resolve merge conflict in app-store.ts by keeping UI slice implementation
of getEffectiveFontSans/getEffectiveFontMono (already provided by ui-slice.ts)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 17:48:41 +01:00
Shirone
79236ba16e refactor(store): Extract UI slice from app-store.ts
- Extract UI-related state and actions into store/slices/ui-slice.ts
- Add UISliceState and UISliceActions interfaces to store/types/ui-types.ts
- First implementation of Zustand slice pattern in the codebase
- Fix pre-existing bug: fontSans/fontMono -> fontFamilySans/fontFamilyMono
- Maintain backward compatibility through re-exports

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 00:03:58 +01:00
36 changed files with 865 additions and 4894 deletions

View File

@@ -390,7 +390,7 @@ const server = createServer(app);
// WebSocket servers using noServer mode for proper multi-path support // WebSocket servers using noServer mode for proper multi-path support
const wss = new WebSocketServer({ noServer: true }); const wss = new WebSocketServer({ noServer: true });
const terminalWss = new WebSocketServer({ noServer: true }); const terminalWss = new WebSocketServer({ noServer: true });
const terminalService = getTerminalService(settingsService); const terminalService = getTerminalService();
/** /**
* Authenticate WebSocket upgrade requests * Authenticate WebSocket upgrade requests

View File

@@ -1,25 +0,0 @@
/**
* 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;

View File

@@ -14,7 +14,6 @@ import type { GlobalSettings } from '../../../types/settings.js';
import { getErrorMessage, logError, logger } from '../common.js'; import { getErrorMessage, logError, logger } from '../common.js';
import { setLogLevel, LogLevel } from '@automaker/utils'; import { setLogLevel, LogLevel } from '@automaker/utils';
import { setRequestLoggingEnabled } from '../../../index.js'; import { setRequestLoggingEnabled } from '../../../index.js';
import { getTerminalService } from '../../../services/terminal-service.js';
/** /**
* Map server log level string to LogLevel enum * Map server log level string to LogLevel enum
@@ -58,10 +57,6 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}` }, 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...'); logger.info('[SERVER_SETTINGS_UPDATE] Calling updateGlobalSettings...');
const settings = await settingsService.updateGlobalSettings(updates); const settings = await settingsService.updateGlobalSettings(updates);
logger.info( logger.info(
@@ -69,37 +64,6 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
settings.projects?.length ?? 0 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 // Apply server log level if it was updated
if ('serverLogLevel' in updates && updates.serverLogLevel) { if ('serverLogLevel' in updates && updates.serverLogLevel) {
const level = LOG_LEVEL_MAP[updates.serverLogLevel]; const level = LOG_LEVEL_MAP[updates.serverLogLevel];

View File

@@ -43,14 +43,10 @@ export function createInitGitHandler() {
// .git doesn't exist, continue with initialization // .git doesn't exist, continue with initialization
} }
// Initialize git with 'main' as the default branch (matching GitHub's standard since 2020) // Initialize git and create an initial empty commit
// and create an initial empty commit await execAsync(`git init && git commit --allow-empty -m "Initial commit"`, {
await execAsync(
`git init --initial-branch=main && git commit --allow-empty -m "Initial commit"`,
{
cwd: projectPath, cwd: projectPath,
} });
);
res.json({ res.json({
success: true, success: true,

View File

@@ -13,14 +13,6 @@ import * as path from 'path';
// to enforce ALLOWED_ROOT_DIRECTORY security boundary // to enforce ALLOWED_ROOT_DIRECTORY security boundary
import * as secureFs from '../lib/secure-fs.js'; import * as secureFs from '../lib/secure-fs.js';
import { createLogger } from '@automaker/utils'; 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'); const logger = createLogger('Terminal');
// System paths module handles shell binary checks and WSL detection // System paths module handles shell binary checks and WSL detection
@@ -32,27 +24,6 @@ import {
getShellPaths, getShellPaths,
} from '@automaker/platform'; } 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) // Maximum scrollback buffer size (characters)
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
@@ -71,114 +42,6 @@ let maxSessions = parseInt(process.env.TERMINAL_MAX_SESSIONS || '1000', 10);
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive input const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive input
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency 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 { export interface TerminalSession {
id: string; id: string;
pty: pty.IPty; pty: pty.IPty;
@@ -214,12 +77,6 @@ export class TerminalService extends EventEmitter {
!!(process.versions && (process.versions as Record<string, string>).electron) || !!(process.versions && (process.versions as Record<string, string>).electron) ||
!!process.env.ELECTRON_RUN_AS_NODE; !!process.env.ELECTRON_RUN_AS_NODE;
private useConptyFallback = false; // Track if we need to use winpty fallback on Windows 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. * Kill a PTY process with platform-specific handling.
@@ -245,19 +102,37 @@ export class TerminalService extends EventEmitter {
const platform = os.platform(); const platform = os.platform();
const shellPaths = getShellPaths(); 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 // Check if running in WSL - prefer user's shell or bash with --login
if (platform === 'linux' && this.isWSL()) { if (platform === 'linux' && this.isWSL()) {
const userShell = process.env.SHELL; const userShell = process.env.SHELL;
if (userShell) { if (userShell) {
// Try to find userShell in allowed paths // Try to find userShell in allowed paths
for (const allowedShell of shellPaths) { for (const allowedShell of shellPaths) {
if ( if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) {
allowedShell === userShell ||
getShellBasename(allowedShell) === getShellBasename(userShell)
) {
try { try {
if (systemPathExists(allowedShell)) { if (systemPathExists(allowedShell)) {
return { shell: allowedShell, args: getShellArgsForPath(allowedShell) }; return { shell: allowedShell, args: getShellArgs(allowedShell) };
} }
} catch { } catch {
// Path not allowed, continue searching // Path not allowed, continue searching
@@ -269,7 +144,7 @@ export class TerminalService extends EventEmitter {
for (const shell of shellPaths) { for (const shell of shellPaths) {
try { try {
if (systemPathExists(shell)) { if (systemPathExists(shell)) {
return { shell, args: getShellArgsForPath(shell) }; return { shell, args: getShellArgs(shell) };
} }
} catch { } catch {
// Path not allowed, continue // Path not allowed, continue
@@ -283,13 +158,10 @@ export class TerminalService extends EventEmitter {
if (userShell && platform !== 'win32') { if (userShell && platform !== 'win32') {
// Try to find userShell in allowed paths // Try to find userShell in allowed paths
for (const allowedShell of shellPaths) { for (const allowedShell of shellPaths) {
if ( if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) {
allowedShell === userShell ||
getShellBasename(allowedShell) === getShellBasename(userShell)
) {
try { try {
if (systemPathExists(allowedShell)) { if (systemPathExists(allowedShell)) {
return { shell: allowedShell, args: getShellArgsForPath(allowedShell) }; return { shell: allowedShell, args: getShellArgs(allowedShell) };
} }
} catch { } catch {
// Path not allowed, continue searching // Path not allowed, continue searching
@@ -302,7 +174,7 @@ export class TerminalService extends EventEmitter {
for (const shell of shellPaths) { for (const shell of shellPaths) {
try { try {
if (systemPathExists(shell)) { if (systemPathExists(shell)) {
return { shell, args: getShellArgsForPath(shell) }; return { shell, args: getShellArgs(shell) };
} }
} catch { } catch {
// Path not allowed or doesn't exist, continue to next // Path not allowed or doesn't exist, continue to next
@@ -441,9 +313,8 @@ export class TerminalService extends EventEmitter {
const id = `term-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; const id = `term-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
const { shell: detectedShell, args: detectedShellArgs } = this.detectShell(); const { shell: detectedShell, args: shellArgs } = this.detectShell();
const shell = options.shell || detectedShell; const shell = options.shell || detectedShell;
let shellArgs = options.shell ? getShellArgsForPath(shell) : [...detectedShellArgs];
// Validate and resolve working directory // Validate and resolve working directory
// Uses secureFs internally to enforce ALLOWED_ROOT_DIRECTORY // Uses secureFs internally to enforce ALLOWED_ROOT_DIRECTORY
@@ -461,89 +332,6 @@ 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> = { const env: Record<string, string> = {
...cleanEnv, ...cleanEnv,
TERM: 'xterm-256color', TERM: 'xterm-256color',
@@ -553,7 +341,6 @@ export class TerminalService extends EventEmitter {
LANG: process.env.LANG || 'en_US.UTF-8', LANG: process.env.LANG || 'en_US.UTF-8',
LC_ALL: process.env.LC_ALL || process.env.LANG || 'en_US.UTF-8', LC_ALL: process.env.LC_ALL || process.env.LANG || 'en_US.UTF-8',
...options.env, ...options.env,
...terminalConfigEnv, // Apply terminal config env vars last (highest priority)
}; };
logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`); logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`);
@@ -865,44 +652,6 @@ export class TerminalService extends EventEmitter {
return () => this.exitCallbacks.delete(callback); 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 * Clean up all sessions
*/ */
@@ -927,9 +676,9 @@ export class TerminalService extends EventEmitter {
// Singleton instance // Singleton instance
let terminalService: TerminalService | null = null; let terminalService: TerminalService | null = null;
export function getTerminalService(settingsService?: SettingsService): TerminalService { export function getTerminalService(): TerminalService {
if (!terminalService) { if (!terminalService) {
terminalService = new TerminalService(settingsService); terminalService = new TerminalService();
} }
return terminalService; return terminalService;
} }

View File

@@ -20,8 +20,8 @@ export interface TestRepo {
export async function createTestGitRepo(): Promise<TestRepo> { export async function createTestGitRepo(): Promise<TestRepo> {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-test-')); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-test-'));
// Initialize git repo with 'main' as the default branch (matching GitHub's standard) // Initialize git repo
await execAsync('git init --initial-branch=main', { cwd: tmpDir }); await execAsync('git init', { cwd: tmpDir });
// Use environment variables instead of git config to avoid affecting user's git config // Use environment variables instead of git config to avoid affecting user's git config
// These env vars override git config without modifying it // These env vars override git config without modifying it
@@ -38,6 +38,9 @@ export async function createTestGitRepo(): Promise<TestRepo> {
await execAsync('git add .', { cwd: tmpDir, env: gitEnv }); await execAsync('git add .', { cwd: tmpDir, env: gitEnv });
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir, env: gitEnv }); await execAsync('git commit -m "Initial commit"', { cwd: tmpDir, env: gitEnv });
// Create main branch explicitly
await execAsync('git branch -M main', { cwd: tmpDir });
return { return {
path: tmpDir, path: tmpDir,
cleanup: async () => { cleanup: async () => {

View File

@@ -14,8 +14,7 @@ describe('worktree create route - repositories without commits', () => {
async function initRepoWithoutCommit() { async function initRepoWithoutCommit() {
repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-no-commit-')); repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-no-commit-'));
// Initialize with 'main' as the default branch (matching GitHub's standard) await execAsync('git init', { cwd: repoPath });
await execAsync('git init --initial-branch=main', { cwd: repoPath });
// Don't set git config - use environment variables in commit operations instead // Don't set git config - use environment variables in commit operations instead
// to avoid affecting user's git config // to avoid affecting user's git config
// Intentionally skip creating an initial commit // Intentionally skip creating an initial commit

View File

@@ -30,16 +30,11 @@ import net from 'net';
describe('dev-server-service.ts', () => { describe('dev-server-service.ts', () => {
let testDir: string; let testDir: string;
let originalHostname: string | undefined;
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.resetModules(); vi.resetModules();
// Store and set HOSTNAME for consistent test behavior
originalHostname = process.env.HOSTNAME;
process.env.HOSTNAME = 'localhost';
testDir = path.join(os.tmpdir(), `dev-server-test-${Date.now()}`); testDir = path.join(os.tmpdir(), `dev-server-test-${Date.now()}`);
await fs.mkdir(testDir, { recursive: true }); await fs.mkdir(testDir, { recursive: true });
@@ -61,13 +56,6 @@ describe('dev-server-service.ts', () => {
}); });
afterEach(async () => { afterEach(async () => {
// Restore original HOSTNAME
if (originalHostname === undefined) {
delete process.env.HOSTNAME;
} else {
process.env.HOSTNAME = originalHostname;
}
try { try {
await fs.rm(testDir, { recursive: true, force: true }); await fs.rm(testDir, { recursive: true, force: true });
} catch { } catch {

View File

@@ -7,8 +7,8 @@
*/ */
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync, readFileSync, lstatSync } from 'fs'; import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'fs';
import { join, dirname, resolve } from 'path'; import { join, dirname } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@@ -112,29 +112,6 @@ execSync('npm install --omit=dev', {
}, },
}); });
// Step 6b: Replace symlinks for local packages with real copies
// npm install creates symlinks for file: references, but these break when packaged by electron-builder
console.log('🔗 Replacing symlinks with real directory copies...');
const nodeModulesAutomaker = join(BUNDLE_DIR, 'node_modules', '@automaker');
for (const pkgName of LOCAL_PACKAGES) {
const pkgDir = pkgName.replace('@automaker/', '');
const nmPkgPath = join(nodeModulesAutomaker, pkgDir);
try {
// lstatSync does not follow symlinks, allowing us to check for broken ones
if (lstatSync(nmPkgPath).isSymbolicLink()) {
const realPath = resolve(BUNDLE_DIR, 'libs', pkgDir);
rmSync(nmPkgPath);
cpSync(realPath, nmPkgPath, { recursive: true });
console.log(` ✓ Replaced symlink: ${pkgName}`);
}
} catch (error) {
// If the path doesn't exist, lstatSync throws ENOENT. We can safely ignore this.
if (error.code !== 'ENOENT') {
throw error;
}
}
}
// Step 7: Rebuild native modules for current architecture // Step 7: Rebuild native modules for current architecture
// This is critical for modules like node-pty that have native bindings // This is critical for modules like node-pty that have native bindings
console.log('🔨 Rebuilding native modules for current architecture...'); console.log('🔨 Rebuilding native modules for current architecture...');

View File

@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { Plus, Bug, FolderOpen, BookOpen } from 'lucide-react'; import { Plus, Bug, FolderOpen, BookOpen } from 'lucide-react';
import { useNavigate, useLocation } from '@tanstack/react-router'; import { useNavigate, useLocation } from '@tanstack/react-router';
import { cn, isMac } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { useOSDetection } from '@/hooks/use-os-detection'; import { useOSDetection } from '@/hooks/use-os-detection';
import { ProjectSwitcherItem } from './components/project-switcher-item'; import { ProjectSwitcherItem } from './components/project-switcher-item';
@@ -11,12 +11,9 @@ import { NotificationBell } from './components/notification-bell';
import { NewProjectModal } from '@/components/dialogs/new-project-modal'; import { NewProjectModal } from '@/components/dialogs/new-project-modal';
import { OnboardingDialog } from '@/components/layout/sidebar/dialogs'; import { OnboardingDialog } from '@/components/layout/sidebar/dialogs';
import { useProjectCreation } from '@/components/layout/sidebar/hooks'; import { useProjectCreation } from '@/components/layout/sidebar/hooks';
import { import { SIDEBAR_FEATURE_FLAGS } from '@/components/layout/sidebar/constants';
MACOS_ELECTRON_TOP_PADDING_CLASS,
SIDEBAR_FEATURE_FLAGS,
} from '@/components/layout/sidebar/constants';
import type { Project } from '@/lib/electron'; import type { Project } from '@/lib/electron';
import { getElectronAPI, isElectron } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron';
import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init'; import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { CreateSpecDialog } from '@/components/views/spec-view/dialogs'; import { CreateSpecDialog } from '@/components/views/spec-view/dialogs';
@@ -282,12 +279,7 @@ export function ProjectSwitcher() {
data-testid="project-switcher" data-testid="project-switcher"
> >
{/* Automaker Logo and Version */} {/* Automaker Logo and Version */}
<div <div className="flex flex-col items-center pt-3 pb-2 px-2">
className={cn(
'flex flex-col items-center pb-2 px-2',
isMac && isElectron() ? MACOS_ELECTRON_TOP_PADDING_CLASS : 'pt-3'
)}
>
<button <button
onClick={() => navigate({ to: '/dashboard' })} onClick={() => navigate({ to: '/dashboard' })}
className="group flex flex-col items-center gap-0.5" className="group flex flex-col items-center gap-0.5"

View File

@@ -6,7 +6,6 @@ import type { LucideIcon } from 'lucide-react';
import { cn, isMac } from '@/lib/utils'; import { cn, isMac } from '@/lib/utils';
import { formatShortcut } from '@/store/app-store'; import { formatShortcut } from '@/store/app-store';
import { isElectron, type Project } from '@/lib/electron'; import { isElectron, type Project } from '@/lib/electron';
import { MACOS_ELECTRON_TOP_PADDING_CLASS } from '../constants';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { useAppStore } from '@/store/app-store'; import { useAppStore } from '@/store/app-store';
import { import {
@@ -90,7 +89,7 @@ export function SidebarHeader({
<div <div
className={cn( className={cn(
'shrink-0 flex flex-col items-center relative px-2 pt-3 pb-2', 'shrink-0 flex flex-col items-center relative px-2 pt-3 pb-2',
isMac && isElectron() && MACOS_ELECTRON_TOP_PADDING_CLASS isMac && isElectron() && 'pt-[10px]'
)} )}
> >
<Tooltip> <Tooltip>
@@ -241,7 +240,7 @@ export function SidebarHeader({
<div <div
className={cn( className={cn(
'shrink-0 flex flex-col relative px-3 pt-3 pb-2', 'shrink-0 flex flex-col relative px-3 pt-3 pb-2',
isMac && isElectron() && MACOS_ELECTRON_TOP_PADDING_CLASS isMac && isElectron() && 'pt-[10px]'
)} )}
> >
{/* Header with logo and project dropdown */} {/* Header with logo and project dropdown */}

View File

@@ -3,9 +3,7 @@ import type { NavigateOptions } from '@tanstack/react-router';
import { ChevronDown, Wrench, Github, Folder } from 'lucide-react'; import { ChevronDown, Wrench, Github, Folder } from 'lucide-react';
import * as LucideIcons from 'lucide-react'; import * as LucideIcons from 'lucide-react';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { cn, isMac } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { isElectron } from '@/lib/electron';
import { MACOS_ELECTRON_TOP_PADDING_CLASS } from '../constants';
import { formatShortcut, useAppStore } from '@/store/app-store'; import { formatShortcut, useAppStore } from '@/store/app-store';
import { getAuthenticatedImageUrl } from '@/lib/api-fetch'; import { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import type { NavSection } from '../types'; import type { NavSection } from '../types';
@@ -119,12 +117,7 @@ export function SidebarNavigation({
className={cn( className={cn(
'flex-1 overflow-y-auto scrollbar-hide px-3 pb-2', 'flex-1 overflow-y-auto scrollbar-hide px-3 pb-2',
// Add top padding in discord mode since there's no header // Add top padding in discord mode since there's no header
// Extra padding for macOS Electron to avoid traffic light overlap sidebarStyle === 'discord' ? 'pt-3' : 'mt-1'
sidebarStyle === 'discord'
? isMac && isElectron()
? MACOS_ELECTRON_TOP_PADDING_CLASS
: 'pt-3'
: 'mt-1'
)} )}
> >
{/* Project name display for classic/discord mode */} {/* Project name display for classic/discord mode */}

View File

@@ -1,11 +1,5 @@
import { darkThemes, lightThemes } from '@/config/theme-options'; import { darkThemes, lightThemes } from '@/config/theme-options';
/**
* Tailwind class for top padding on macOS Electron to avoid overlapping with traffic light window controls.
* This padding is applied conditionally when running on macOS in Electron.
*/
export const MACOS_ELECTRON_TOP_PADDING_CLASS = 'pt-[38px]';
/** /**
* Shared constants for theme submenu positioning and layout. * Shared constants for theme submenu positioning and layout.
* Used across project-context-menu and project-selector-with-options components * Used across project-context-menu and project-selector-with-options components

View File

@@ -116,8 +116,9 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
}, },
copilot: { copilot: {
viewBox: '0 0 98 96', viewBox: '0 0 98 96',
// Official GitHub Octocat logo mark (theme-aware via currentColor) // Official GitHub Octocat logo mark
path: 'M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z', path: 'M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z',
fill: '#ffffff',
}, },
}; };

View File

@@ -1275,10 +1275,8 @@ export function BoardView() {
maxConcurrency={maxConcurrency} maxConcurrency={maxConcurrency}
runningAgentsCount={runningAutoTasks.length} runningAgentsCount={runningAutoTasks.length}
onConcurrencyChange={(newMaxConcurrency) => { onConcurrencyChange={(newMaxConcurrency) => {
if (currentProject) { if (currentProject && selectedWorktree) {
// If selectedWorktree is undefined or it's the main worktree, branchName will be null. const branchName = selectedWorktree.isMain ? null : selectedWorktree.branch;
// Otherwise, use the branch name.
const branchName = selectedWorktree?.isMain === false ? selectedWorktree.branch : null;
setMaxConcurrencyForWorktree(currentProject.id, branchName, newMaxConcurrency); setMaxConcurrencyForWorktree(currentProject.id, branchName, newMaxConcurrency);
// Persist to server settings so capacity checks use the correct value // Persist to server settings so capacity checks use the correct value

View File

@@ -1,283 +0,0 @@
/**
* 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>
);
}

View File

@@ -1,253 +0,0 @@
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;
}

View File

@@ -1,662 +0,0 @@
/**
* 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&apos;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&#10;alias gs='git status'&#10;alias ll='ls -la'&#10;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>
);
}

View File

@@ -24,7 +24,6 @@ import { TERMINAL_FONT_OPTIONS } from '@/config/terminal-themes';
import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options'; import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options';
import { useAvailableTerminals } from '@/components/views/board-view/worktree-panel/hooks/use-available-terminals'; import { useAvailableTerminals } from '@/components/views/board-view/worktree-panel/hooks/use-available-terminals';
import { getTerminalIcon } from '@/components/icons/terminal-icons'; import { getTerminalIcon } from '@/components/icons/terminal-icons';
import { TerminalConfigSection } from './terminal-config-section';
export function TerminalSection() { export function TerminalSection() {
const { const {
@@ -54,7 +53,6 @@ export function TerminalSection() {
const { terminals, isRefreshing, refresh } = useAvailableTerminals(); const { terminals, isRefreshing, refresh } = useAvailableTerminals();
return ( return (
<div className="space-y-6">
<div <div
className={cn( className={cn(
'rounded-2xl overflow-hidden', 'rounded-2xl overflow-hidden',
@@ -140,8 +138,7 @@ export function TerminalSection() {
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-foreground font-medium">Default Open Mode</Label> <Label className="text-foreground font-medium">Default Open Mode</Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
How to open the integrated terminal when using "Open in Terminal" from the worktree How to open the integrated terminal when using "Open in Terminal" from the worktree menu
menu
</p> </p>
<Select <Select
value={openTerminalMode} value={openTerminalMode}
@@ -304,8 +301,5 @@ export function TerminalSection() {
</div> </div>
</div> </div>
</div> </div>
<TerminalConfigSection />
</div>
); );
} }

View File

@@ -60,6 +60,8 @@ import {
type ShortcutKey, type ShortcutKey,
type KeyboardShortcuts, type KeyboardShortcuts,
type BackgroundSettings, type BackgroundSettings,
type UISliceState,
type UISliceActions,
// Settings types // Settings types
type ApiKeys, type ApiKeys,
// Chat types // Chat types
@@ -109,16 +111,13 @@ import {
} from './utils'; } from './utils';
// Import default values from modular defaults files // Import default values from modular defaults files
import { defaultBackgroundSettings, defaultTerminalState, MAX_INIT_OUTPUT_LINES } from './defaults'; import { defaultTerminalState, MAX_INIT_OUTPUT_LINES } from './defaults';
// Import UI slice
import { createUISlice, initialUIState } from './slices';
// Import internal theme utils (not re-exported publicly) // Import internal theme utils (not re-exported publicly)
import { import { persistEffectiveThemeForProject } from './utils/theme-utils';
getEffectiveFont,
saveThemeToStorage,
saveFontSansToStorage,
saveFontMonoToStorage,
persistEffectiveThemeForProject,
} from './utils/theme-utils';
const logger = createLogger('AppStore'); const logger = createLogger('AppStore');
const OPENCODE_BEDROCK_PROVIDER_ID = 'amazon-bedrock'; const OPENCODE_BEDROCK_PROVIDER_ID = 'amazon-bedrock';
@@ -146,6 +145,8 @@ export type {
ShortcutKey, ShortcutKey,
KeyboardShortcuts, KeyboardShortcuts,
BackgroundSettings, BackgroundSettings,
UISliceState,
UISliceActions,
ApiKeys, ApiKeys,
ImageAttachment, ImageAttachment,
TextFileAttachment, TextFileAttachment,
@@ -213,56 +214,72 @@ export { defaultBackgroundSettings, defaultTerminalState, MAX_INIT_OUTPUT_LINES
// - defaultTerminalState (./defaults/terminal-defaults.ts) // - defaultTerminalState (./defaults/terminal-defaults.ts)
const initialState: AppState = { const initialState: AppState = {
// Spread UI slice state first
...initialUIState,
// Project state
projects: [], projects: [],
currentProject: null, currentProject: null,
trashedProjects: [], trashedProjects: [],
projectHistory: [], projectHistory: [],
projectHistoryIndex: -1, projectHistoryIndex: -1,
currentView: 'welcome',
sidebarOpen: true, // Agent Session state
sidebarStyle: 'unified',
collapsedNavSections: {},
mobileSidebarHidden: false,
lastSelectedSessionByProject: {}, lastSelectedSessionByProject: {},
theme: getStoredTheme() || 'dark',
fontFamilySans: getStoredFontSans(), // Features/Kanban
fontFamilyMono: getStoredFontMono(),
features: [], features: [],
// App spec
appSpec: '', appSpec: '',
// IPC status
ipcConnected: false, ipcConnected: false,
// API Keys
apiKeys: { apiKeys: {
anthropic: '', anthropic: '',
google: '', google: '',
openai: '', openai: '',
}, },
// Chat Sessions
chatSessions: [], chatSessions: [],
currentChatSession: null, currentChatSession: null,
chatHistoryOpen: false,
// Auto Mode
autoModeByWorktree: {}, autoModeByWorktree: {},
autoModeActivityLog: [], autoModeActivityLog: [],
maxConcurrency: DEFAULT_MAX_CONCURRENCY, maxConcurrency: DEFAULT_MAX_CONCURRENCY,
boardViewMode: 'kanban',
// Feature Default Settings
defaultSkipTests: true, defaultSkipTests: true,
enableDependencyBlocking: true, enableDependencyBlocking: true,
skipVerificationInAutoMode: false, skipVerificationInAutoMode: false,
enableAiCommitMessages: true, enableAiCommitMessages: true,
planUseSelectedWorktreeBranch: true, planUseSelectedWorktreeBranch: true,
addFeatureUseSelectedWorktreeBranch: false, addFeatureUseSelectedWorktreeBranch: false,
// Worktree Settings
useWorktrees: true, useWorktrees: true,
currentWorktreeByProject: {}, currentWorktreeByProject: {},
worktreesByProject: {}, worktreesByProject: {},
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
muteDoneSound: false, // Server Settings
disableSplashScreen: false,
serverLogLevel: 'info', serverLogLevel: 'info',
enableRequestLogging: true, enableRequestLogging: true,
showQueryDevtools: true,
// Model Settings
enhancementModel: 'claude-sonnet', enhancementModel: 'claude-sonnet',
validationModel: 'claude-opus', validationModel: 'claude-opus',
phaseModels: DEFAULT_PHASE_MODELS, phaseModels: DEFAULT_PHASE_MODELS,
favoriteModels: [], favoriteModels: [],
// Cursor CLI Settings
enabledCursorModels: getAllCursorModelIds(), enabledCursorModels: getAllCursorModelIds(),
cursorDefaultModel: 'cursor-auto', cursorDefaultModel: 'cursor-auto',
// Codex CLI Settings
enabledCodexModels: getAllCodexModelIds(), enabledCodexModels: getAllCodexModelIds(),
codexDefaultModel: 'codex-gpt-5.2-codex', codexDefaultModel: 'codex-gpt-5.2-codex',
codexAutoLoadAgents: false, codexAutoLoadAgents: false,
@@ -270,6 +287,8 @@ const initialState: AppState = {
codexApprovalPolicy: 'on-request', codexApprovalPolicy: 'on-request',
codexEnableWebSearch: false, codexEnableWebSearch: false,
codexEnableImages: false, codexEnableImages: false,
// OpenCode CLI Settings
enabledOpencodeModels: getAllOpencodeModelIds(), enabledOpencodeModels: getAllOpencodeModelIds(),
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, opencodeDefaultModel: DEFAULT_OPENCODE_MODEL,
dynamicOpencodeModels: [], dynamicOpencodeModels: [],
@@ -279,61 +298,101 @@ const initialState: AppState = {
opencodeModelsError: null, opencodeModelsError: null,
opencodeModelsLastFetched: null, opencodeModelsLastFetched: null,
opencodeModelsLastFailedAt: null, opencodeModelsLastFailedAt: null,
// Gemini CLI Settings
enabledGeminiModels: getAllGeminiModelIds(), enabledGeminiModels: getAllGeminiModelIds(),
geminiDefaultModel: DEFAULT_GEMINI_MODEL, geminiDefaultModel: DEFAULT_GEMINI_MODEL,
// Copilot SDK Settings
enabledCopilotModels: getAllCopilotModelIds(), enabledCopilotModels: getAllCopilotModelIds(),
copilotDefaultModel: DEFAULT_COPILOT_MODEL, copilotDefaultModel: DEFAULT_COPILOT_MODEL,
// Provider Settings
disabledProviders: [], disabledProviders: [],
// Claude Agent SDK Settings
autoLoadClaudeMd: false, autoLoadClaudeMd: false,
skipSandboxWarning: false, skipSandboxWarning: false,
// MCP Servers
mcpServers: [], mcpServers: [],
// Editor Configuration
defaultEditorCommand: null, defaultEditorCommand: null,
// Terminal Configuration
defaultTerminalId: null, defaultTerminalId: null,
// Skills Configuration
enableSkills: true, enableSkills: true,
skillsSources: ['user', 'project'] as Array<'user' | 'project'>, skillsSources: ['user', 'project'] as Array<'user' | 'project'>,
// Subagents Configuration
enableSubagents: true, enableSubagents: true,
subagentsSources: ['user', 'project'] as Array<'user' | 'project'>, subagentsSources: ['user', 'project'] as Array<'user' | 'project'>,
// Prompt Customization
promptCustomization: {}, promptCustomization: {},
// Event Hooks
eventHooks: [], eventHooks: [],
// Claude-Compatible Providers
claudeCompatibleProviders: [], claudeCompatibleProviders: [],
claudeApiProfiles: [], claudeApiProfiles: [],
activeClaudeApiProfileId: null, activeClaudeApiProfileId: null,
// Project Analysis
projectAnalysis: null, projectAnalysis: null,
isAnalyzing: false, isAnalyzing: false,
boardBackgroundByProject: {},
previewTheme: null, // Terminal state
terminalState: defaultTerminalState, terminalState: defaultTerminalState,
terminalLayoutByProject: {}, terminalLayoutByProject: {},
// Spec Creation
specCreatingForProject: null, specCreatingForProject: null,
// Planning
defaultPlanningMode: 'skip' as PlanningMode, defaultPlanningMode: 'skip' as PlanningMode,
defaultRequirePlanApproval: false, defaultRequirePlanApproval: false,
defaultFeatureModel: DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel, defaultFeatureModel: DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel,
pendingPlanApproval: null, pendingPlanApproval: null,
// Claude Usage Tracking
claudeRefreshInterval: 60, claudeRefreshInterval: 60,
claudeUsage: null, claudeUsage: null,
claudeUsageLastUpdated: null, claudeUsageLastUpdated: null,
// Codex Usage Tracking
codexUsage: null, codexUsage: null,
codexUsageLastUpdated: null, codexUsageLastUpdated: null,
// Codex Models
codexModels: [], codexModels: [],
codexModelsLoading: false, codexModelsLoading: false,
codexModelsError: null, codexModelsError: null,
codexModelsLastFetched: null, codexModelsLastFetched: null,
codexModelsLastFailedAt: null, codexModelsLastFailedAt: null,
// Pipeline Configuration
pipelineConfigByProject: {}, pipelineConfigByProject: {},
worktreePanelVisibleByProject: {},
showInitScriptIndicatorByProject: {}, // Project-specific Worktree Settings
defaultDeleteBranchByProject: {}, defaultDeleteBranchByProject: {},
autoDismissInitScriptIndicatorByProject: {},
useWorktreesByProject: {}, useWorktreesByProject: {},
worktreePanelCollapsed: false,
lastProjectDir: '', // Init Script State
recentFolders: [],
initScriptState: {}, initScriptState: {},
}; };
export const useAppStore = create<AppState & AppActions>()((set, get) => ({ export const useAppStore = create<AppState & AppActions>()((set, get, store) => ({
// Spread initial non-UI state
...initialState, ...initialState,
// Spread UI slice (includes UI state and actions)
...createUISlice(set, get, store),
// Project actions // Project actions
setProjects: (projects) => set({ projects }), setProjects: (projects) => set({ projects }),
@@ -598,28 +657,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
saveProjects(get().projects); saveProjects(get().projects);
}, },
// View actions // View actions - provided by UI slice
setCurrentView: (view) => set({ currentView: view }),
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setSidebarOpen: (open) => set({ sidebarOpen: open }),
setSidebarStyle: (style) => set({ sidebarStyle: style }),
setCollapsedNavSections: (sections) => set({ collapsedNavSections: sections }),
toggleNavSection: (sectionLabel) =>
set((state) => ({
collapsedNavSections: {
...state.collapsedNavSections,
[sectionLabel]: !state.collapsedNavSections[sectionLabel],
},
})),
toggleMobileSidebarHidden: () =>
set((state) => ({ mobileSidebarHidden: !state.mobileSidebarHidden })),
setMobileSidebarHidden: (hidden) => set({ mobileSidebarHidden: hidden }),
// Theme actions // Theme actions (setTheme, getEffectiveTheme, setPreviewTheme provided by UI slice)
setTheme: (theme) => {
set({ theme });
saveThemeToStorage(theme);
},
setProjectTheme: (projectId: string, theme: ThemeMode | null) => { setProjectTheme: (projectId: string, theme: ThemeMode | null) => {
set((state) => ({ set((state) => ({
projects: state.projects.map((p) => projects: state.projects.map((p) =>
@@ -644,34 +684,17 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
// Persist to storage // Persist to storage
saveProjects(get().projects); saveProjects(get().projects);
}, },
getEffectiveTheme: () => {
const state = get();
// If there's a preview theme, use it (for hover preview)
if (state.previewTheme) return state.previewTheme;
// Otherwise, use project theme if set, or fall back to global theme
const projectTheme = state.currentProject?.theme as ThemeMode | undefined;
return projectTheme ?? state.theme;
},
setPreviewTheme: (theme) => set({ previewTheme: theme }),
// Font actions // Font actions (setFontSans, setFontMono, getEffectiveFontSans, getEffectiveFontMono provided by UI slice)
setFontSans: (fontFamily) => {
set({ fontFamilySans: fontFamily });
saveFontSansToStorage(fontFamily);
},
setFontMono: (fontFamily) => {
set({ fontFamilyMono: fontFamily });
saveFontMonoToStorage(fontFamily);
},
setProjectFontSans: (projectId: string, fontFamily: string | null) => { setProjectFontSans: (projectId: string, fontFamily: string | null) => {
set((state) => ({ set((state) => ({
projects: state.projects.map((p) => projects: state.projects.map((p) =>
p.id === projectId ? { ...p, fontSans: fontFamily ?? undefined } : p p.id === projectId ? { ...p, fontFamilySans: fontFamily ?? undefined } : p
), ),
// Also update currentProject if it's the one being changed // Also update currentProject if it's the one being changed
currentProject: currentProject:
state.currentProject?.id === projectId state.currentProject?.id === projectId
? { ...state.currentProject, fontSans: fontFamily ?? undefined } ? { ...state.currentProject, fontFamilySans: fontFamily ?? undefined }
: state.currentProject, : state.currentProject,
})); }));
@@ -681,28 +704,18 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
setProjectFontMono: (projectId: string, fontFamily: string | null) => { setProjectFontMono: (projectId: string, fontFamily: string | null) => {
set((state) => ({ set((state) => ({
projects: state.projects.map((p) => projects: state.projects.map((p) =>
p.id === projectId ? { ...p, fontMono: fontFamily ?? undefined } : p p.id === projectId ? { ...p, fontFamilyMono: fontFamily ?? undefined } : p
), ),
// Also update currentProject if it's the one being changed // Also update currentProject if it's the one being changed
currentProject: currentProject:
state.currentProject?.id === projectId state.currentProject?.id === projectId
? { ...state.currentProject, fontMono: fontFamily ?? undefined } ? { ...state.currentProject, fontFamilyMono: fontFamily ?? undefined }
: state.currentProject, : state.currentProject,
})); }));
// Persist to storage // Persist to storage
saveProjects(get().projects); saveProjects(get().projects);
}, },
getEffectiveFontSans: () => {
const state = get();
const projectFont = state.currentProject?.fontFamilySans;
return getEffectiveFont(projectFont, state.fontFamilySans, UI_SANS_FONT_OPTIONS);
},
getEffectiveFontMono: () => {
const state = get();
const projectFont = state.currentProject?.fontFamilyMono;
return getEffectiveFont(projectFont, state.fontFamilyMono, UI_MONO_FONT_OPTIONS);
},
// Claude API Profile actions (per-project override) // Claude API Profile actions (per-project override)
setProjectClaudeApiProfile: (projectId: string, profileId: string | null | undefined) => { setProjectClaudeApiProfile: (projectId: string, profileId: string | null | undefined) => {
@@ -886,8 +899,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
currentChatSession: currentChatSession:
state.currentChatSession?.id === sessionId ? null : state.currentChatSession, state.currentChatSession?.id === sessionId ? null : state.currentChatSession,
})), })),
setChatHistoryOpen: (open) => set({ chatHistoryOpen: open }), // setChatHistoryOpen and toggleChatHistory - provided by UI slice
toggleChatHistory: () => set((state) => ({ chatHistoryOpen: !state.chatHistoryOpen })),
// Auto Mode actions (per-worktree) // Auto Mode actions (per-worktree)
getWorktreeKey: (projectId: string, branchName: string | null) => getWorktreeKey: (projectId: string, branchName: string | null) =>
@@ -1018,8 +1030,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
})); }));
}, },
// Kanban Card Settings actions // Kanban Card Settings actions - setBoardViewMode provided by UI slice
setBoardViewMode: (mode) => set({ boardViewMode: mode }),
// Feature Default Settings actions // Feature Default Settings actions
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }), setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
@@ -1094,29 +1105,17 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
return mainWorktree?.branch ?? null; return mainWorktree?.branch ?? null;
}, },
// Keyboard Shortcuts actions // Keyboard Shortcuts actions - provided by UI slice
setKeyboardShortcut: (key, value) =>
set((state) => ({
keyboardShortcuts: { ...state.keyboardShortcuts, [key]: value },
})),
setKeyboardShortcuts: (shortcuts) =>
set((state) => ({
keyboardShortcuts: { ...state.keyboardShortcuts, ...shortcuts },
})),
resetKeyboardShortcuts: () => set({ keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS }),
// Audio Settings actions // Audio Settings actions - setMuteDoneSound provided by UI slice
setMuteDoneSound: (muted) => set({ muteDoneSound: muted }),
// Splash Screen actions // Splash Screen actions - setDisableSplashScreen provided by UI slice
setDisableSplashScreen: (disabled) => set({ disableSplashScreen: disabled }),
// Server Log Level actions // Server Log Level actions
setServerLogLevel: (level) => set({ serverLogLevel: level }), setServerLogLevel: (level) => set({ serverLogLevel: level }),
setEnableRequestLogging: (enabled) => set({ enableRequestLogging: enabled }), setEnableRequestLogging: (enabled) => set({ enableRequestLogging: enabled }),
// Developer Tools actions // Developer Tools actions - setShowQueryDevtools provided by UI slice
setShowQueryDevtools: (show) => set({ showQueryDevtools: show }),
// Enhancement Model actions // Enhancement Model actions
setEnhancementModel: (model) => set({ enhancementModel: model }), setEnhancementModel: (model) => set({ enhancementModel: model }),
@@ -1486,96 +1485,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
})), })),
getLastSelectedSession: (projectPath) => get().lastSelectedSessionByProject[projectPath] ?? null, getLastSelectedSession: (projectPath) => get().lastSelectedSessionByProject[projectPath] ?? null,
// Board Background actions // Board Background actions - provided by UI slice
setBoardBackground: (projectPath, imagePath) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
imagePath,
imageVersion: Date.now(), // Bust cache on image change
},
},
})),
setCardOpacity: (projectPath, opacity) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
cardOpacity: opacity,
},
},
})),
setColumnOpacity: (projectPath, opacity) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
columnOpacity: opacity,
},
},
})),
setColumnBorderEnabled: (projectPath, enabled) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
columnBorderEnabled: enabled,
},
},
})),
getBoardBackground: (projectPath) =>
get().boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings,
setCardGlassmorphism: (projectPath, enabled) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
cardGlassmorphism: enabled,
},
},
})),
setCardBorderEnabled: (projectPath, enabled) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
cardBorderEnabled: enabled,
},
},
})),
setCardBorderOpacity: (projectPath, opacity) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
cardBorderOpacity: opacity,
},
},
})),
setHideScrollbar: (projectPath, hide) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
hideScrollbar: hide,
},
},
})),
clearBoardBackground: (projectPath) =>
set((state) => {
const newBackgrounds = { ...state.boardBackgroundByProject };
delete newBackgrounds[projectPath];
return { boardBackgroundByProject: newBackgrounds };
}),
// Terminal actions // Terminal actions
setTerminalUnlocked: (unlocked, token) => setTerminalUnlocked: (unlocked, token) =>
@@ -2325,27 +2235,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
}; };
}), }),
// Worktree Panel Visibility actions // Worktree Panel Visibility actions - provided by UI slice
setWorktreePanelVisible: (projectPath, visible) =>
set((state) => ({
worktreePanelVisibleByProject: {
...state.worktreePanelVisibleByProject,
[projectPath]: visible,
},
})),
getWorktreePanelVisible: (projectPath) =>
get().worktreePanelVisibleByProject[projectPath] ?? true,
// Init Script Indicator Visibility actions // Init Script Indicator Visibility actions - provided by UI slice
setShowInitScriptIndicator: (projectPath, visible) =>
set((state) => ({
showInitScriptIndicatorByProject: {
...state.showInitScriptIndicatorByProject,
[projectPath]: visible,
},
})),
getShowInitScriptIndicator: (projectPath) =>
get().showInitScriptIndicatorByProject[projectPath] ?? true,
// Default Delete Branch actions // Default Delete Branch actions
setDefaultDeleteBranch: (projectPath, deleteBranch) => setDefaultDeleteBranch: (projectPath, deleteBranch) =>
@@ -2357,16 +2249,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
})), })),
getDefaultDeleteBranch: (projectPath) => get().defaultDeleteBranchByProject[projectPath] ?? false, getDefaultDeleteBranch: (projectPath) => get().defaultDeleteBranchByProject[projectPath] ?? false,
// Auto-dismiss Init Script Indicator actions // Auto-dismiss Init Script Indicator actions - provided by UI slice
setAutoDismissInitScriptIndicator: (projectPath, autoDismiss) =>
set((state) => ({
autoDismissInitScriptIndicatorByProject: {
...state.autoDismissInitScriptIndicatorByProject,
[projectPath]: autoDismiss,
},
})),
getAutoDismissInitScriptIndicator: (projectPath) =>
get().autoDismissInitScriptIndicatorByProject[projectPath] ?? true,
// Use Worktrees Override actions // Use Worktrees Override actions
setProjectUseWorktrees: (projectPath, useWorktrees) => setProjectUseWorktrees: (projectPath, useWorktrees) =>
@@ -2382,15 +2265,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
return projectOverride !== undefined ? projectOverride : get().useWorktrees; return projectOverride !== undefined ? projectOverride : get().useWorktrees;
}, },
// UI State actions // UI State actions - provided by UI slice
setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }),
setLastProjectDir: (dir) => set({ lastProjectDir: dir }),
setRecentFolders: (folders) => set({ recentFolders: folders }),
addRecentFolder: (folder) =>
set((state) => {
const filtered = state.recentFolders.filter((f) => f !== folder);
return { recentFolders: [folder, ...filtered].slice(0, 10) };
}),
// Claude Usage Tracking actions // Claude Usage Tracking actions
setClaudeRefreshInterval: (interval) => set({ claudeRefreshInterval: interval }), setClaudeRefreshInterval: (interval) => set({ claudeRefreshInterval: interval }),

View File

@@ -0,0 +1 @@
export { createUISlice, initialUIState, type UISlice } from './ui-slice';

View File

@@ -0,0 +1,343 @@
import type { StateCreator } from 'zustand';
import { UI_SANS_FONT_OPTIONS, UI_MONO_FONT_OPTIONS } from '@/config/ui-font-options';
import type { SidebarStyle } from '@automaker/types';
import type {
ViewMode,
ThemeMode,
BoardViewMode,
KeyboardShortcuts,
BackgroundSettings,
UISliceState,
UISliceActions,
} from '../types/ui-types';
import type { AppState, AppActions } from '../types/state-types';
import {
getStoredTheme,
getStoredFontSans,
getStoredFontMono,
DEFAULT_KEYBOARD_SHORTCUTS,
} from '../utils';
import { defaultBackgroundSettings } from '../defaults';
import {
getEffectiveFont,
saveThemeToStorage,
saveFontSansToStorage,
saveFontMonoToStorage,
} from '../utils/theme-utils';
/**
* UI Slice
* Contains all UI-related state and actions extracted from the main app store.
* This is the first slice pattern implementation in the codebase.
*/
export type UISlice = UISliceState & UISliceActions;
/**
* Initial UI state values
*/
export const initialUIState: UISliceState = {
// Core UI State
currentView: 'welcome',
sidebarOpen: true,
sidebarStyle: 'unified',
collapsedNavSections: {},
mobileSidebarHidden: false,
// Theme State
theme: getStoredTheme() || 'dark',
previewTheme: null,
// Font State
fontFamilySans: getStoredFontSans(),
fontFamilyMono: getStoredFontMono(),
// Board UI State
boardViewMode: 'kanban',
boardBackgroundByProject: {},
// Settings UI State
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
muteDoneSound: false,
disableSplashScreen: false,
showQueryDevtools: true,
chatHistoryOpen: false,
// Panel Visibility State
worktreePanelCollapsed: false,
worktreePanelVisibleByProject: {},
showInitScriptIndicatorByProject: {},
autoDismissInitScriptIndicatorByProject: {},
// File Picker UI State
lastProjectDir: '',
recentFolders: [],
};
/**
* Creates the UI slice for the Zustand store.
*
* Uses the StateCreator pattern to allow the slice to access other parts
* of the combined store state (e.g., currentProject for theme resolution).
*/
export const createUISlice: StateCreator<AppState & AppActions, [], [], UISlice> = (set, get) => ({
// Spread initial state
...initialUIState,
// ============================================================================
// View Actions
// ============================================================================
setCurrentView: (view: ViewMode) => set({ currentView: view }),
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setSidebarOpen: (open: boolean) => set({ sidebarOpen: open }),
setSidebarStyle: (style: SidebarStyle) => set({ sidebarStyle: style }),
setCollapsedNavSections: (sections: Record<string, boolean>) =>
set({ collapsedNavSections: sections }),
toggleNavSection: (sectionLabel: string) =>
set((state) => ({
collapsedNavSections: {
...state.collapsedNavSections,
[sectionLabel]: !state.collapsedNavSections[sectionLabel],
},
})),
toggleMobileSidebarHidden: () =>
set((state) => ({ mobileSidebarHidden: !state.mobileSidebarHidden })),
setMobileSidebarHidden: (hidden: boolean) => set({ mobileSidebarHidden: hidden }),
// ============================================================================
// Theme Actions
// ============================================================================
setTheme: (theme: ThemeMode) => {
set({ theme });
saveThemeToStorage(theme);
},
getEffectiveTheme: (): ThemeMode => {
const state = get();
// If there's a preview theme, use it (for hover preview)
if (state.previewTheme) return state.previewTheme;
// Otherwise, use project theme if set, or fall back to global theme
const projectTheme = state.currentProject?.theme as ThemeMode | undefined;
return projectTheme ?? state.theme;
},
setPreviewTheme: (theme: ThemeMode | null) => set({ previewTheme: theme }),
// ============================================================================
// Font Actions
// ============================================================================
setFontSans: (fontFamily: string | null) => {
set({ fontFamilySans: fontFamily });
saveFontSansToStorage(fontFamily);
},
setFontMono: (fontFamily: string | null) => {
set({ fontFamilyMono: fontFamily });
saveFontMonoToStorage(fontFamily);
},
getEffectiveFontSans: (): string | null => {
const state = get();
const projectFont = state.currentProject?.fontFamilySans;
return getEffectiveFont(projectFont, state.fontFamilySans, UI_SANS_FONT_OPTIONS);
},
getEffectiveFontMono: (): string | null => {
const state = get();
const projectFont = state.currentProject?.fontFamilyMono;
return getEffectiveFont(projectFont, state.fontFamilyMono, UI_MONO_FONT_OPTIONS);
},
// ============================================================================
// Board View Actions
// ============================================================================
setBoardViewMode: (mode: BoardViewMode) => set({ boardViewMode: mode }),
setBoardBackground: (projectPath: string, imagePath: string | null) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
imagePath,
imageVersion: Date.now(), // Bust cache on image change
},
},
})),
setCardOpacity: (projectPath: string, opacity: number) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
cardOpacity: opacity,
},
},
})),
setColumnOpacity: (projectPath: string, opacity: number) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
columnOpacity: opacity,
},
},
})),
setColumnBorderEnabled: (projectPath: string, enabled: boolean) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
columnBorderEnabled: enabled,
},
},
})),
setCardGlassmorphism: (projectPath: string, enabled: boolean) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
cardGlassmorphism: enabled,
},
},
})),
setCardBorderEnabled: (projectPath: string, enabled: boolean) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
cardBorderEnabled: enabled,
},
},
})),
setCardBorderOpacity: (projectPath: string, opacity: number) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
cardBorderOpacity: opacity,
},
},
})),
setHideScrollbar: (projectPath: string, hide: boolean) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
hideScrollbar: hide,
},
},
})),
getBoardBackground: (projectPath: string): BackgroundSettings =>
get().boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings,
clearBoardBackground: (projectPath: string) =>
set((state) => {
const newBackgrounds = { ...state.boardBackgroundByProject };
delete newBackgrounds[projectPath];
return { boardBackgroundByProject: newBackgrounds };
}),
// ============================================================================
// Settings UI Actions
// ============================================================================
setKeyboardShortcut: (key: keyof KeyboardShortcuts, value: string) =>
set((state) => ({
keyboardShortcuts: { ...state.keyboardShortcuts, [key]: value },
})),
setKeyboardShortcuts: (shortcuts: Partial<KeyboardShortcuts>) =>
set((state) => ({
keyboardShortcuts: { ...state.keyboardShortcuts, ...shortcuts },
})),
resetKeyboardShortcuts: () => set({ keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS }),
setMuteDoneSound: (muted: boolean) => set({ muteDoneSound: muted }),
setDisableSplashScreen: (disabled: boolean) => set({ disableSplashScreen: disabled }),
setShowQueryDevtools: (show: boolean) => set({ showQueryDevtools: show }),
setChatHistoryOpen: (open: boolean) => set({ chatHistoryOpen: open }),
toggleChatHistory: () => set((state) => ({ chatHistoryOpen: !state.chatHistoryOpen })),
// ============================================================================
// Panel Visibility Actions
// ============================================================================
setWorktreePanelCollapsed: (collapsed: boolean) => set({ worktreePanelCollapsed: collapsed }),
setWorktreePanelVisible: (projectPath: string, visible: boolean) =>
set((state) => ({
worktreePanelVisibleByProject: {
...state.worktreePanelVisibleByProject,
[projectPath]: visible,
},
})),
getWorktreePanelVisible: (projectPath: string): boolean =>
get().worktreePanelVisibleByProject[projectPath] ?? true,
setShowInitScriptIndicator: (projectPath: string, visible: boolean) =>
set((state) => ({
showInitScriptIndicatorByProject: {
...state.showInitScriptIndicatorByProject,
[projectPath]: visible,
},
})),
getShowInitScriptIndicator: (projectPath: string): boolean =>
get().showInitScriptIndicatorByProject[projectPath] ?? true,
setAutoDismissInitScriptIndicator: (projectPath: string, autoDismiss: boolean) =>
set((state) => ({
autoDismissInitScriptIndicatorByProject: {
...state.autoDismissInitScriptIndicatorByProject,
[projectPath]: autoDismiss,
},
})),
getAutoDismissInitScriptIndicator: (projectPath: string): boolean =>
get().autoDismissInitScriptIndicatorByProject[projectPath] ?? true,
// ============================================================================
// File Picker UI Actions
// ============================================================================
setLastProjectDir: (dir: string) => set({ lastProjectDir: dir }),
setRecentFolders: (folders: string[]) => set({ recentFolders: folders }),
addRecentFolder: (folder: string) =>
set((state) => {
const filtered = state.recentFolders.filter((f) => f !== folder);
return { recentFolders: [folder, ...filtered].slice(0, 10) };
}),
});

View File

@@ -117,3 +117,112 @@ export interface KeyboardShortcuts {
closeTerminal: string; closeTerminal: string;
newTerminalTab: string; newTerminalTab: string;
} }
// Import SidebarStyle from @automaker/types for UI slice
import type { SidebarStyle } from '@automaker/types';
/**
* UI Slice State
* Contains all UI-related state that is extracted into the UI slice.
*/
export interface UISliceState {
// Core UI State
currentView: ViewMode;
sidebarOpen: boolean;
sidebarStyle: SidebarStyle;
collapsedNavSections: Record<string, boolean>;
mobileSidebarHidden: boolean;
// Theme State
theme: ThemeMode;
previewTheme: ThemeMode | null;
// Font State
fontFamilySans: string | null;
fontFamilyMono: string | null;
// Board UI State
boardViewMode: BoardViewMode;
boardBackgroundByProject: Record<string, BackgroundSettings>;
// Settings UI State
keyboardShortcuts: KeyboardShortcuts;
muteDoneSound: boolean;
disableSplashScreen: boolean;
showQueryDevtools: boolean;
chatHistoryOpen: boolean;
// Panel Visibility State
worktreePanelCollapsed: boolean;
worktreePanelVisibleByProject: Record<string, boolean>;
showInitScriptIndicatorByProject: Record<string, boolean>;
autoDismissInitScriptIndicatorByProject: Record<string, boolean>;
// File Picker UI State
lastProjectDir: string;
recentFolders: string[];
}
/**
* UI Slice Actions
* Contains all UI-related actions that are extracted into the UI slice.
*/
export interface UISliceActions {
// View Actions
setCurrentView: (view: ViewMode) => void;
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
setSidebarStyle: (style: SidebarStyle) => void;
setCollapsedNavSections: (sections: Record<string, boolean>) => void;
toggleNavSection: (sectionLabel: string) => void;
toggleMobileSidebarHidden: () => void;
setMobileSidebarHidden: (hidden: boolean) => void;
// Theme Actions (Pure UI only - project theme actions stay in main store)
setTheme: (theme: ThemeMode) => void;
getEffectiveTheme: () => ThemeMode;
setPreviewTheme: (theme: ThemeMode | null) => void;
// Font Actions (Pure UI only - project font actions stay in main store)
setFontSans: (fontFamily: string | null) => void;
setFontMono: (fontFamily: string | null) => void;
getEffectiveFontSans: () => string | null;
getEffectiveFontMono: () => string | null;
// Board View Actions
setBoardViewMode: (mode: BoardViewMode) => void;
setBoardBackground: (projectPath: string, imagePath: string | null) => void;
setCardOpacity: (projectPath: string, opacity: number) => void;
setColumnOpacity: (projectPath: string, opacity: number) => void;
setColumnBorderEnabled: (projectPath: string, enabled: boolean) => void;
setCardGlassmorphism: (projectPath: string, enabled: boolean) => void;
setCardBorderEnabled: (projectPath: string, enabled: boolean) => void;
setCardBorderOpacity: (projectPath: string, opacity: number) => void;
setHideScrollbar: (projectPath: string, hide: boolean) => void;
getBoardBackground: (projectPath: string) => BackgroundSettings;
clearBoardBackground: (projectPath: string) => void;
// Settings UI Actions
setKeyboardShortcut: (key: keyof KeyboardShortcuts, value: string) => void;
setKeyboardShortcuts: (shortcuts: Partial<KeyboardShortcuts>) => void;
resetKeyboardShortcuts: () => void;
setMuteDoneSound: (muted: boolean) => void;
setDisableSplashScreen: (disabled: boolean) => void;
setShowQueryDevtools: (show: boolean) => void;
setChatHistoryOpen: (open: boolean) => void;
toggleChatHistory: () => void;
// Panel Visibility Actions
setWorktreePanelCollapsed: (collapsed: boolean) => void;
setWorktreePanelVisible: (projectPath: string, visible: boolean) => void;
getWorktreePanelVisible: (projectPath: string) => boolean;
setShowInitScriptIndicator: (projectPath: string, visible: boolean) => void;
getShowInitScriptIndicator: (projectPath: string) => boolean;
setAutoDismissInitScriptIndicator: (projectPath: string, autoDismiss: boolean) => void;
getAutoDismissInitScriptIndicator: (projectPath: string) => boolean;
// File Picker UI Actions
setLastProjectDir: (dir: string) => void;
setRecentFolders: (folders: string[]) => void;
addRecentFolder: (folder: string) => void;
}

View File

@@ -80,7 +80,6 @@ test.describe('Edit Feature', () => {
await clickAddFeature(page); await clickAddFeature(page);
await fillAddFeatureDialog(page, originalDescription); await fillAddFeatureDialog(page, originalDescription);
await confirmAddFeature(page); await confirmAddFeature(page);
await page.waitForTimeout(2000);
// Wait for the feature to appear in the backlog // Wait for the feature to appear in the backlog
await expect(async () => { await expect(async () => {
@@ -89,7 +88,7 @@ test.describe('Edit Feature', () => {
hasText: originalDescription, hasText: originalDescription,
}); });
expect(await featureCard.count()).toBeGreaterThan(0); expect(await featureCard.count()).toBeGreaterThan(0);
}).toPass({ timeout: 20000 }); }).toPass({ timeout: 10000 });
// Get the feature ID from the card // Get the feature ID from the card
const featureCard = page const featureCard = page

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -1,632 +0,0 @@
# Implementation Plan: Custom Terminal Configurations with Theme Synchronization
## Overview
Implement custom shell configuration files (.bashrc, .zshrc) that automatically sync with Automaker's 40 themes, providing a seamless terminal experience where prompt colors match the app theme. This is an **opt-in feature** that creates configs in `.automaker/terminal/` without modifying user's existing RC files.
## Architecture
### Core Components
1. **RC Generator** (`libs/platform/src/rc-generator.ts`) - NEW
- Template-based generation for bash/zsh/sh
- Theme-to-ANSI color mapping from hex values
- Git info integration (branch, dirty status)
- Prompt format templates (standard, minimal, powerline, starship-inspired)
2. **RC File Manager** (`libs/platform/src/rc-file-manager.ts`) - NEW
- File I/O for `.automaker/terminal/` directory
- Version checking and regeneration logic
- Path resolution for different shells
3. **Terminal Service** (`apps/server/src/services/terminal-service.ts`) - MODIFY
- Inject BASH_ENV/ZDOTDIR environment variables when spawning PTY
- Hook for theme change regeneration
- Backwards compatible (no change when disabled)
4. **Settings Schema** (`libs/types/src/settings.ts`) - MODIFY
- Add `terminalConfig` to GlobalSettings and ProjectSettings
- Include enable toggle, prompt format, git info toggles, custom aliases/env vars
5. **Settings UI** (`apps/ui/src/components/views/settings-view/terminal/terminal-config-section.tsx`) - NEW
- Enable/disable toggle with explanation
- Prompt format selector (4 formats)
- Git info toggles (branch/status)
- Custom aliases textarea
- Custom env vars key-value editor
- Live preview panel showing example prompt
## File Structure
```
.automaker/terminal/
├── bashrc.sh # Bash config (sourced via BASH_ENV)
├── zshrc.zsh # Zsh config (via ZDOTDIR)
├── common.sh # Shared functions (git prompt, etc.)
├── themes/
│ ├── dark.sh # Theme-specific color exports (40 files)
│ ├── dracula.sh
│ ├── nord.sh
│ └── ... (38 more)
├── version.txt # RC file format version (for migrations)
└── user-custom.sh # User's additional customizations (optional)
```
## Implementation Steps
### Step 1: Create RC Generator Package
**File**: `libs/platform/src/rc-generator.ts`
**Key Functions**:
```typescript
// Main generation functions
export function generateBashrc(theme: ThemeMode, config: TerminalConfig): string;
export function generateZshrc(theme: ThemeMode, config: TerminalConfig): string;
export function generateCommonFunctions(): string;
export function generateThemeColors(theme: ThemeMode): string;
// Color mapping
export function hexToXterm256(hex: string): number;
export function getThemeANSIColors(terminalTheme: TerminalTheme): ANSIColors;
```
**Templates**:
- Source user's original ~/.bashrc or ~/.zshrc first
- Load theme colors from `themes/${AUTOMAKER_THEME}.sh`
- Set custom PS1/PROMPT only if `AUTOMAKER_CUSTOM_PROMPT=true`
- Include git prompt function: `automaker_git_prompt()`
**Example bashrc.sh template**:
```bash
#!/bin/bash
# Automaker Terminal Configuration v1.0
# Source user's original bashrc first
if [ -f "$HOME/.bashrc" ]; then
source "$HOME/.bashrc"
fi
# Load Automaker theme colors
AUTOMAKER_THEME="${AUTOMAKER_THEME:-dark}"
if [ -f "${BASH_SOURCE%/*}/themes/$AUTOMAKER_THEME.sh" ]; then
source "${BASH_SOURCE%/*}/themes/$AUTOMAKER_THEME.sh"
fi
# Load common functions (git prompt)
source "${BASH_SOURCE%/*}/common.sh"
# Set custom prompt (only if enabled)
if [ "$AUTOMAKER_CUSTOM_PROMPT" = "true" ]; then
PS1="\[$COLOR_USER\]\u@\h\[$COLOR_RESET\] "
PS1="$PS1\[$COLOR_PATH\]\w\[$COLOR_RESET\]"
PS1="$PS1\$(automaker_git_prompt) "
PS1="$PS1\[$COLOR_PROMPT\]\$\[$COLOR_RESET\] "
fi
# Load user customizations (if exists)
if [ -f "${BASH_SOURCE%/*}/user-custom.sh" ]; then
source "${BASH_SOURCE%/*}/user-custom.sh"
fi
```
**Color Mapping Algorithm**:
1. Get hex colors from `apps/ui/src/config/terminal-themes.ts` (TerminalTheme interface)
2. Convert hex to RGB
3. Map to closest xterm-256 color code using Euclidean distance in RGB space
4. Generate ANSI escape codes: `\[\e[38;5;{code}m\]` for foreground
### Step 2: Create RC File Manager
**File**: `libs/platform/src/rc-file-manager.ts`
**Key Functions**:
```typescript
export async function ensureTerminalDir(projectPath: string): Promise<void>;
export async function writeRcFiles(
projectPath: string,
theme: ThemeMode,
config: TerminalConfig
): Promise<void>;
export function getRcFilePath(projectPath: string, shell: 'bash' | 'zsh' | 'sh'): string;
export async function checkRcFileVersion(projectPath: string): Promise<number | null>;
export async function needsRegeneration(
projectPath: string,
theme: ThemeMode,
config: TerminalConfig
): Promise<boolean>;
```
**File Operations**:
- Create `.automaker/terminal/` if doesn't exist
- Write RC files with 0644 permissions
- Write theme color files (40 themes × 1 file each)
- Create version.txt with format version (currently "11")
- Support atomic writes (write to temp, then rename)
### Step 3: Add Settings Schema
**File**: `libs/types/src/settings.ts`
**Add to GlobalSettings** (around line 842):
```typescript
/** Terminal configuration settings */
terminalConfig?: {
/** Enable custom terminal configurations (default: false) */
enabled: boolean;
/** Enable custom prompt (default: true when enabled) */
customPrompt: boolean;
/** Prompt format template */
promptFormat: 'standard' | 'minimal' | 'powerline' | 'starship';
/** Prompt theme preset */
promptTheme?: TerminalPromptTheme;
/** Show git branch in prompt (default: true) */
showGitBranch: boolean;
/** Show git status dirty indicator (default: true) */
showGitStatus: boolean;
/** Show user and host in prompt (default: true) */
showUserHost: boolean;
/** Show path in prompt (default: true) */
showPath: boolean;
/** Path display style */
pathStyle: 'full' | 'short' | 'basename';
/** Limit path depth (0 = full path) */
pathDepth: number;
/** Show current time in prompt (default: false) */
showTime: boolean;
/** Show last command exit status when non-zero (default: false) */
showExitStatus: boolean;
/** User-provided custom aliases (multiline string) */
customAliases: string;
/** User-provided custom env vars */
customEnvVars: Record<string, string>;
/** RC file format version (for migration) */
rcFileVersion?: number;
};
```
**Add to ProjectSettings**:
```typescript
/** Project-specific terminal config overrides */
terminalConfig?: {
/** Override global enabled setting */
enabled?: boolean;
/** Override prompt theme preset */
promptTheme?: TerminalPromptTheme;
/** Override showing user/host */
showUserHost?: boolean;
/** Override showing path */
showPath?: boolean;
/** Override path style */
pathStyle?: 'full' | 'short' | 'basename';
/** Override path depth (0 = full path) */
pathDepth?: number;
/** Override showing time */
showTime?: boolean;
/** Override showing exit status */
showExitStatus?: boolean;
/** Project-specific custom aliases */
customAliases?: string;
/** Project-specific env vars */
customEnvVars?: Record<string, string>;
/** Custom welcome message for this project */
welcomeMessage?: string;
};
```
**Defaults**:
```typescript
const DEFAULT_TERMINAL_CONFIG = {
enabled: false,
customPrompt: true,
promptFormat: 'standard' as const,
promptTheme: 'custom' as const,
showGitBranch: true,
showGitStatus: true,
showUserHost: true,
showPath: true,
pathStyle: 'full' as const,
pathDepth: 0,
showTime: false,
showExitStatus: false,
customAliases: '',
customEnvVars: {},
rcFileVersion: 11,
};
```
**Oh My Posh Themes**:
- When `promptTheme` starts with `omp-` and `oh-my-posh` is available, the generated RC files will
initialize oh-my-posh with the selected theme name.
- If oh-my-posh is not installed, the prompt falls back to the Automaker-built prompt format.
- `POSH_THEMES_PATH` is exported to the standard user themes directory so themes resolve offline.
### Step 4: Modify Terminal Service
**File**: `apps/server/src/services/terminal-service.ts`
**Modification Point**: In `createSession()` method, around line 335-344 where `env` object is built.
**Add before PTY spawn**:
```typescript
// Get terminal config from settings
const terminalConfig = await this.settingsService?.getGlobalSettings();
const projectSettings = options.projectPath
? await this.settingsService?.getProjectSettings(options.projectPath)
: null;
const effectiveTerminalConfig = {
...terminalConfig?.terminalConfig,
...projectSettings?.terminalConfig,
};
if (effectiveTerminalConfig?.enabled) {
// Ensure RC files are up to date
const currentTheme = terminalConfig?.theme || 'dark';
await ensureRcFilesUpToDate(options.projectPath || cwd, currentTheme, effectiveTerminalConfig);
// Set shell-specific env vars
const shellName = path.basename(shell).toLowerCase();
if (shellName.includes('bash')) {
env.BASH_ENV = getRcFilePath(options.projectPath || cwd, 'bash');
env.AUTOMAKER_CUSTOM_PROMPT = effectiveTerminalConfig.customPrompt ? 'true' : 'false';
env.AUTOMAKER_THEME = currentTheme;
} else if (shellName.includes('zsh')) {
env.ZDOTDIR = path.join(options.projectPath || cwd, '.automaker', 'terminal');
env.AUTOMAKER_CUSTOM_PROMPT = effectiveTerminalConfig.customPrompt ? 'true' : 'false';
env.AUTOMAKER_THEME = currentTheme;
} else if (shellName === 'sh') {
env.ENV = getRcFilePath(options.projectPath || cwd, 'sh');
env.AUTOMAKER_CUSTOM_PROMPT = effectiveTerminalConfig.customPrompt ? 'true' : 'false';
env.AUTOMAKER_THEME = currentTheme;
}
}
```
**Add new method for theme changes**:
```typescript
async onThemeChange(projectPath: string, newTheme: ThemeMode): Promise<void> {
const globalSettings = await this.settingsService?.getGlobalSettings();
const terminalConfig = globalSettings?.terminalConfig;
if (terminalConfig?.enabled) {
// Regenerate RC files with new theme
await writeRcFiles(projectPath, newTheme, terminalConfig);
}
}
```
### Step 5: Create Settings UI
**File**: `apps/ui/src/components/views/settings-view/terminal/terminal-config-section.tsx`
**Component Structure**:
```typescript
export function TerminalConfigSection() {
return (
<div>
{/* Enable Toggle with Warning */}
<div>
<Label>Custom Terminal Configurations</Label>
<Switch checked={enabled} onCheckedChange={handleToggle} />
<p>Creates custom shell configs in .automaker/terminal/</p>
</div>
{enabled && (
<>
{/* Custom Prompt Toggle */}
<Switch checked={customPrompt} />
{/* Prompt Format Selector */}
<Select value={promptFormat} onValueChange={setPromptFormat}>
<option value="standard">Standard</option>
<option value="minimal">Minimal</option>
<option value="powerline">Powerline</option>
<option value="starship">Starship-Inspired</option>
</Select>
{/* Git Info Toggles */}
<Switch checked={showGitBranch} label="Show Git Branch" />
<Switch checked={showGitStatus} label="Show Git Status" />
{/* Custom Aliases */}
<Textarea
value={customAliases}
placeholder="# Custom aliases\nalias ll='ls -la'"
/>
{/* Custom Env Vars */}
<KeyValueEditor
value={customEnvVars}
onChange={setCustomEnvVars}
/>
{/* Live Preview Panel */}
<PromptPreview
format={promptFormat}
theme={effectiveTheme}
gitBranch={showGitBranch ? 'main' : null}
gitDirty={showGitStatus}
/>
</>
)}
</div>
);
}
```
**Preview Component**:
Shows example prompt like: `[user@host] ~/projects/automaker (main*) $`
Updates instantly when theme or format changes.
### Step 6: Theme Change Hook
**File**: `apps/server/src/routes/settings.ts`
**Hook into theme update endpoint**:
```typescript
// After updating theme in settings
if (oldTheme !== newTheme) {
// Regenerate RC files for all projects with terminal config enabled
const projects = settings.projects;
for (const project of projects) {
const projectSettings = await settingsService.getProjectSettings(project.path);
if (projectSettings.terminalConfig?.enabled !== false) {
await terminalService.onThemeChange(project.path, newTheme);
}
}
}
```
## Shell Configuration Strategy
### Bash (via BASH_ENV)
- Set `BASH_ENV=/path/to/.automaker/terminal/bashrc.sh`
- BASH_ENV is loaded for all shells (interactive and non-interactive)
- User's ~/.bashrc is sourced first within our bashrc.sh
- No need for `--rcfile` flag (which would skip ~/.bashrc)
### Zsh (via ZDOTDIR)
- Set `ZDOTDIR=/path/to/.automaker/terminal/`
- Create `.zshrc` symlink: `zshrc.zsh`
- User's ~/.zshrc is sourced within our zshrc.zsh
- Zsh's canonical configuration directory mechanism
### Sh (via ENV)
- Set `ENV=/path/to/.automaker/terminal/common.sh`
- POSIX shell standard environment variable
- Minimal prompt (POSIX sh doesn't support advanced prompts)
## Prompt Formats
### 1. Standard
```
[user@host] ~/path/to/project (main*) $
```
### 2. Minimal
```
~/project (main*) $
```
### 3. Powerline (Unicode box-drawing)
```
┌─[user@host]─[~/path]─[main*]
└─$
```
### 4. Starship-Inspired
```
user@host in ~/path on main*
```
## Theme Synchronization
### On Initial Enable
1. User toggles "Enable Custom Terminal Configs"
2. Show confirmation dialog explaining what will happen
3. Generate RC files for current theme
4. Set `rcFileVersion: 11` in settings
### On Theme Change
1. User changes app theme in settings
2. Settings API detects theme change
3. Call `terminalService.onThemeChange()` for each project
4. Regenerate theme color files (`.automaker/terminal/themes/`)
5. Existing terminals keep old theme (expected behavior)
6. New terminals use new theme
### On Disable
1. User toggles off "Enable Custom Terminal Configs"
2. Delete `.automaker/terminal/` directory
3. New terminals spawn without custom env vars
4. Existing terminals continue with current config until restarted
## Critical Files
### Files to Modify
1. `/home/dhanush/Projects/automaker/apps/server/src/services/terminal-service.ts` - Add env var injection logic at line ~335-344
2. `/home/dhanush/Projects/automaker/libs/types/src/settings.ts` - Add terminalConfig to GlobalSettings (~line 842) and ProjectSettings
3. `/home/dhanush/Projects/automaker/apps/server/src/routes/settings.ts` - Add theme change hook
### Files to Create
1. `/home/dhanush/Projects/automaker/libs/platform/src/rc-generator.ts` - RC file generation logic
2. `/home/dhanush/Projects/automaker/libs/platform/src/rc-file-manager.ts` - File I/O and path resolution
3. `/home/dhanush/Projects/automaker/apps/ui/src/components/views/settings-view/terminal/terminal-config-section.tsx` - Settings UI
4. `/home/dhanush/Projects/automaker/apps/ui/src/components/views/settings-view/terminal/prompt-preview.tsx` - Live preview component
### Files to Read
1. `/home/dhanush/Projects/automaker/apps/ui/src/config/terminal-themes.ts` - Source of theme hex colors for ANSI mapping
## Testing Approach
### Unit Tests
- `rc-generator.test.ts`: Test template generation for all 40 themes
- `rc-file-manager.test.ts`: Test file I/O and version checking
- `terminal-service.test.ts`: Test env var injection with mocked PTY spawn
### E2E Tests
- Enable custom configs in settings
- Change theme and verify new terminals use new colors
- Add custom aliases and verify they work in terminal
- Test all 4 prompt formats
- Test disable flow (files removed, terminals work normally)
### Manual Testing Checklist
- [ ] Test on macOS with zsh
- [ ] Test on Linux with bash
- [ ] Test all 40 themes have correct colors
- [ ] Test git prompt in repo vs non-repo directories
- [ ] Test custom aliases execution
- [ ] Test custom env vars available
- [ ] Test project-specific overrides
- [ ] Test disable/re-enable flow
## Verification
### End-to-End Test
1. Enable custom terminal configs in settings
2. Set prompt format to "powerline"
3. Add custom alias: `alias gs='git status'`
4. Change theme to "dracula"
5. Open new terminal
6. Verify:
- Prompt uses powerline format with theme colors
- Git branch shows if in repo
- `gs` alias works
- User's ~/.bashrc still loaded (test with known alias from user's file)
7. Change theme to "nord"
8. Open new terminal
9. Verify prompt colors changed to match nord theme
10. Disable custom configs
11. Verify `.automaker/terminal/` deleted
12. Open new terminal
13. Verify standard prompt without custom config
### Success Criteria
- ✅ Feature can be enabled/disabled in settings
- ✅ RC files generated in `.automaker/terminal/`
- ✅ Prompt colors match theme (all 40 themes)
- ✅ Git branch/status shown in prompt
- ✅ Custom aliases work
- ✅ Custom env vars available
- ✅ User's original ~/.bashrc or ~/.zshrc still loads
- ✅ Theme changes regenerate color files
- ✅ Works on Mac (zsh) and Linux (bash)
- ✅ No breaking changes to existing terminal functionality
## Security & Safety
### File Permissions
- RC files: 0644 (user read/write, others read)
- Directory: 0755 (user rwx, others rx)
- No secrets in RC files
### Input Sanitization
- Escape special characters in custom aliases
- Validate env var names (alphanumeric + underscore only)
- No eval of user-provided code
- Shell escaping for all user inputs
### Backwards Compatibility
- Feature disabled by default
- Existing terminals unaffected when disabled
- User's original RC files always sourced first
- Easy rollback (just disable and delete files)
## Branch Creation
Per PR workflow in DEVELOPMENT_WORKFLOW.md:
1. Create feature branch: `git checkout -b feature/custom-terminal-configs`
2. Implement changes following this plan
3. Test thoroughly
4. Merge upstream RC before shipping: `git merge upstream/v0.14.0rc --no-edit`
5. Push to origin: `git push -u origin feature/custom-terminal-configs`
6. Create PR targeting `main` branch
## Documentation
After implementation, create comprehensive documentation at:
`/home/dhanush/Projects/automaker/docs/terminal-custom-configs.md`
**Documentation should cover**:
- Feature overview and benefits
- How to enable custom terminal configs
- Prompt format options with examples
- Custom aliases and env vars
- Theme synchronization behavior
- Troubleshooting common issues
- How to disable the feature
- Technical details for contributors
## Timeline Estimate
- Week 1: Core infrastructure (RC generator, file manager, settings schema)
- Week 2: Terminal service integration, theme sync
- Week 3: Settings UI, preview component
- Week 4: Testing, documentation, polish
Total: ~4 weeks for complete implementation

View File

@@ -186,37 +186,3 @@ export {
findTerminalById, findTerminalById,
openInExternalTerminal, openInExternalTerminal,
} from './terminal.js'; } from './terminal.js';
// RC Generator - Shell configuration file generation
export {
hexToXterm256,
getThemeANSIColors,
generateBashrc,
generateZshrc,
generateCommonFunctions,
generateThemeColors,
getShellName,
type TerminalConfig,
type TerminalTheme,
type ANSIColors,
} from './rc-generator.js';
// RC File Manager - Shell configuration file I/O
export {
RC_FILE_VERSION,
getTerminalDir,
getThemesDir,
getRcFilePath,
ensureTerminalDir,
checkRcFileVersion,
needsRegeneration,
writeAllThemeFiles,
writeThemeFile,
writeRcFiles,
ensureRcFilesUpToDate,
deleteTerminalDir,
ensureUserCustomFile,
} from './rc-file-manager.js';
// Terminal Theme Colors - Raw theme color data for all 40 themes
export { terminalThemeColors, getTerminalThemeColors } from './terminal-theme-colors.js';

View File

@@ -1,308 +0,0 @@
/**
* RC File Manager - Manage shell configuration files in .automaker/terminal/
*
* This module handles file I/O operations for generating and managing shell RC files,
* including version checking and regeneration logic.
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { createHash } from 'node:crypto';
import type { ThemeMode } from '@automaker/types';
import {
generateBashrc,
generateZshrc,
generateCommonFunctions,
generateThemeColors,
type TerminalConfig,
type TerminalTheme,
} from './rc-generator.js';
/**
* Current RC file format version
*/
export const RC_FILE_VERSION = 11;
const RC_SIGNATURE_FILENAME = 'config.sha256';
/**
* Get the terminal directory path
*/
export function getTerminalDir(projectPath: string): string {
return path.join(projectPath, '.automaker', 'terminal');
}
/**
* Get the themes directory path
*/
export function getThemesDir(projectPath: string): string {
return path.join(getTerminalDir(projectPath), 'themes');
}
/**
* Get RC file path for specific shell
*/
export function getRcFilePath(projectPath: string, shell: 'bash' | 'zsh' | 'sh'): string {
const terminalDir = getTerminalDir(projectPath);
switch (shell) {
case 'bash':
return path.join(terminalDir, 'bashrc.sh');
case 'zsh':
return path.join(terminalDir, '.zshrc'); // Zsh looks for .zshrc in ZDOTDIR
case 'sh':
return path.join(terminalDir, 'common.sh');
}
}
/**
* Ensure terminal directory exists
*/
export async function ensureTerminalDir(projectPath: string): Promise<void> {
const terminalDir = getTerminalDir(projectPath);
const themesDir = getThemesDir(projectPath);
await fs.mkdir(terminalDir, { recursive: true, mode: 0o755 });
await fs.mkdir(themesDir, { recursive: true, mode: 0o755 });
}
/**
* Write RC file with atomic write (write to temp, then rename)
*/
async function atomicWriteFile(
filePath: string,
content: string,
mode: number = 0o644
): Promise<void> {
const tempPath = `${filePath}.tmp`;
await fs.writeFile(tempPath, content, { encoding: 'utf8', mode });
await fs.rename(tempPath, filePath);
}
function sortObjectKeys(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => sortObjectKeys(item));
}
if (value && typeof value === 'object') {
const sortedEntries = Object.entries(value as Record<string, unknown>)
.filter(([, entryValue]) => entryValue !== undefined)
.sort(([left], [right]) => left.localeCompare(right));
const sortedObject: Record<string, unknown> = {};
for (const [key, entryValue] of sortedEntries) {
sortedObject[key] = sortObjectKeys(entryValue);
}
return sortedObject;
}
return value;
}
function buildConfigSignature(theme: ThemeMode, config: TerminalConfig): string {
const payload = { theme, config: sortObjectKeys(config) };
const serializedPayload = JSON.stringify(payload);
return createHash('sha256').update(serializedPayload).digest('hex');
}
async function readSignatureFile(projectPath: string): Promise<string | null> {
const signaturePath = path.join(getTerminalDir(projectPath), RC_SIGNATURE_FILENAME);
try {
const signature = await fs.readFile(signaturePath, 'utf8');
return signature.trim() || null;
} catch {
return null;
}
}
async function writeSignatureFile(projectPath: string, signature: string): Promise<void> {
const signaturePath = path.join(getTerminalDir(projectPath), RC_SIGNATURE_FILENAME);
await atomicWriteFile(signaturePath, `${signature}\n`, 0o644);
}
/**
* Check current RC file version
*/
export async function checkRcFileVersion(projectPath: string): Promise<number | null> {
const versionPath = path.join(getTerminalDir(projectPath), 'version.txt');
try {
const content = await fs.readFile(versionPath, 'utf8');
const version = parseInt(content.trim(), 10);
return isNaN(version) ? null : version;
} catch (error) {
return null; // File doesn't exist or can't be read
}
}
/**
* Write version file
*/
async function writeVersionFile(projectPath: string, version: number): Promise<void> {
const versionPath = path.join(getTerminalDir(projectPath), 'version.txt');
await atomicWriteFile(versionPath, `${version}\n`, 0o644);
}
/**
* Check if RC files need regeneration
*/
export async function needsRegeneration(
projectPath: string,
theme: ThemeMode,
config: TerminalConfig
): Promise<boolean> {
const currentVersion = await checkRcFileVersion(projectPath);
// Regenerate if version doesn't match or files don't exist
if (currentVersion !== RC_FILE_VERSION) {
return true;
}
const expectedSignature = buildConfigSignature(theme, config);
const existingSignature = await readSignatureFile(projectPath);
if (!existingSignature || existingSignature !== expectedSignature) {
return true;
}
// Check if critical files exist
const bashrcPath = getRcFilePath(projectPath, 'bash');
const zshrcPath = getRcFilePath(projectPath, 'zsh');
const commonPath = path.join(getTerminalDir(projectPath), 'common.sh');
const themeFilePath = path.join(getThemesDir(projectPath), `${theme}.sh`);
try {
await Promise.all([
fs.access(bashrcPath),
fs.access(zshrcPath),
fs.access(commonPath),
fs.access(themeFilePath),
]);
return false; // All files exist
} catch {
return true; // Some files are missing
}
}
/**
* Write all theme color files (all 40 themes)
*/
export async function writeAllThemeFiles(
projectPath: string,
terminalThemes: Record<ThemeMode, TerminalTheme>
): Promise<void> {
const themesDir = getThemesDir(projectPath);
await fs.mkdir(themesDir, { recursive: true, mode: 0o755 });
const themeEntries = Object.entries(terminalThemes);
await Promise.all(
themeEntries.map(async ([themeName, theme]) => {
const themeFilePath = path.join(themesDir, `${themeName}.sh`);
const content = generateThemeColors(theme);
await atomicWriteFile(themeFilePath, content, 0o644);
})
);
}
/**
* Write a single theme color file
*/
export async function writeThemeFile(
projectPath: string,
theme: ThemeMode,
themeColors: TerminalTheme
): Promise<void> {
const themesDir = getThemesDir(projectPath);
await fs.mkdir(themesDir, { recursive: true, mode: 0o755 });
const themeFilePath = path.join(themesDir, `${theme}.sh`);
const content = generateThemeColors(themeColors);
await atomicWriteFile(themeFilePath, content, 0o644);
}
/**
* Write all RC files
*/
export async function writeRcFiles(
projectPath: string,
theme: ThemeMode,
config: TerminalConfig,
themeColors: TerminalTheme,
allThemes: Record<ThemeMode, TerminalTheme>
): Promise<void> {
await ensureTerminalDir(projectPath);
// Write common functions file
const commonPath = path.join(getTerminalDir(projectPath), 'common.sh');
const commonContent = generateCommonFunctions(config);
await atomicWriteFile(commonPath, commonContent, 0o644);
// Write bashrc
const bashrcPath = getRcFilePath(projectPath, 'bash');
const bashrcContent = generateBashrc(themeColors, config);
await atomicWriteFile(bashrcPath, bashrcContent, 0o644);
// Write zshrc
const zshrcPath = getRcFilePath(projectPath, 'zsh');
const zshrcContent = generateZshrc(themeColors, config);
await atomicWriteFile(zshrcPath, zshrcContent, 0o644);
// Write all theme files (40 themes)
await writeAllThemeFiles(projectPath, allThemes);
// Write version file
await writeVersionFile(projectPath, RC_FILE_VERSION);
// Write config signature for change detection
const signature = buildConfigSignature(theme, config);
await writeSignatureFile(projectPath, signature);
}
/**
* Ensure RC files are up to date
*/
export async function ensureRcFilesUpToDate(
projectPath: string,
theme: ThemeMode,
config: TerminalConfig,
themeColors: TerminalTheme,
allThemes: Record<ThemeMode, TerminalTheme>
): Promise<void> {
const needsRegen = await needsRegeneration(projectPath, theme, config);
if (needsRegen) {
await writeRcFiles(projectPath, theme, config, themeColors, allThemes);
}
}
/**
* Delete terminal directory (for disable flow)
*/
export async function deleteTerminalDir(projectPath: string): Promise<void> {
const terminalDir = getTerminalDir(projectPath);
try {
await fs.rm(terminalDir, { recursive: true, force: true });
} catch (error) {
// Ignore errors if directory doesn't exist
}
}
/**
* Create user-custom.sh placeholder if it doesn't exist
*/
export async function ensureUserCustomFile(projectPath: string): Promise<void> {
const userCustomPath = path.join(getTerminalDir(projectPath), 'user-custom.sh');
try {
await fs.access(userCustomPath);
} catch {
// File doesn't exist, create it
const content = `#!/bin/sh
# Automaker User Customizations
# Add your custom shell configuration here
# This file will not be overwritten by Automaker
# Example: Add custom aliases
# alias myalias='command'
# Example: Add custom environment variables
# export MY_VAR="value"
`;
await atomicWriteFile(userCustomPath, content, 0o644);
}
}

View File

@@ -1,972 +0,0 @@
/**
* RC Generator - Generate shell configuration files for custom terminal prompts
*
* This module generates bash/zsh/sh configuration files that sync with Automaker's themes,
* providing custom prompts with theme-matched colors while preserving user's existing RC files.
*/
import type { ThemeMode } from '@automaker/types';
/**
* Terminal configuration options
*/
export interface TerminalConfig {
enabled: boolean;
customPrompt: boolean;
promptFormat: 'standard' | 'minimal' | 'powerline' | 'starship';
showGitBranch: boolean;
showGitStatus: boolean;
showUserHost: boolean;
showPath: boolean;
pathStyle: 'full' | 'short' | 'basename';
pathDepth: number;
showTime: boolean;
showExitStatus: boolean;
customAliases: string;
customEnvVars: Record<string, string>;
rcFileVersion?: number;
}
/**
* Terminal theme colors (hex values)
*/
export interface TerminalTheme {
background: string;
foreground: string;
cursor: string;
cursorAccent: string;
selectionBackground: string;
selectionForeground?: string;
black: string;
red: string;
green: string;
yellow: string;
blue: string;
magenta: string;
cyan: string;
white: string;
brightBlack: string;
brightRed: string;
brightGreen: string;
brightYellow: string;
brightBlue: string;
brightMagenta: string;
brightCyan: string;
brightWhite: string;
}
/**
* ANSI color codes for shell prompts
*/
export interface ANSIColors {
user: string;
host: string;
path: string;
gitBranch: string;
gitDirty: string;
prompt: string;
reset: string;
}
const STARTUP_COLOR_PRIMARY = 51;
const STARTUP_COLOR_SECONDARY = 39;
const STARTUP_COLOR_ACCENT = 33;
const DEFAULT_PATH_DEPTH = 0;
const DEFAULT_PATH_STYLE: TerminalConfig['pathStyle'] = 'full';
const OMP_THEME_ENV_VAR = 'AUTOMAKER_OMP_THEME';
const OMP_BINARY = 'oh-my-posh';
const OMP_SHELL_BASH = 'bash';
const OMP_SHELL_ZSH = 'zsh';
/**
* Convert hex color to RGB
*/
function hexToRgb(hex: string): { r: number; g: number; b: number } {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) {
throw new Error(`Invalid hex color: ${hex}`);
}
return {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
};
}
/**
* Calculate Euclidean distance between two RGB colors
*/
function colorDistance(
c1: { r: number; g: number; b: number },
c2: { r: number; g: number; b: number }
): number {
return Math.sqrt(Math.pow(c1.r - c2.r, 2) + Math.pow(c1.g - c2.g, 2) + Math.pow(c1.b - c2.b, 2));
}
/**
* xterm-256 color palette (simplified - standard colors + 6x6x6 RGB cube + grayscale)
*/
const XTERM_256_PALETTE: Array<{ r: number; g: number; b: number }> = [];
// Standard colors (0-15) - already handled by ANSI basic colors
// RGB cube (16-231): 6x6x6 cube with levels 0, 95, 135, 175, 215, 255
const levels = [0, 95, 135, 175, 215, 255];
for (let r = 0; r < 6; r++) {
for (let g = 0; g < 6; g++) {
for (let b = 0; b < 6; b++) {
XTERM_256_PALETTE.push({ r: levels[r], g: levels[g], b: levels[b] });
}
}
}
// Grayscale (232-255): 24 shades from #080808 to #eeeeee
for (let i = 0; i < 24; i++) {
const gray = 8 + i * 10;
XTERM_256_PALETTE.push({ r: gray, g: gray, b: gray });
}
/**
* Convert hex color to closest xterm-256 color code
*/
export function hexToXterm256(hex: string): number {
const rgb = hexToRgb(hex);
let closestIndex = 16; // Start from RGB cube
let minDistance = Infinity;
XTERM_256_PALETTE.forEach((color, index) => {
const distance = colorDistance(rgb, color);
if (distance < minDistance) {
minDistance = distance;
closestIndex = index + 16; // Offset by 16 (standard colors)
}
});
return closestIndex;
}
/**
* Get ANSI color codes from theme colors
*/
export function getThemeANSIColors(theme: TerminalTheme): ANSIColors {
return {
user: `\\[\\e[38;5;${hexToXterm256(theme.cyan)}m\\]`,
host: `\\[\\e[38;5;${hexToXterm256(theme.blue)}m\\]`,
path: `\\[\\e[38;5;${hexToXterm256(theme.yellow)}m\\]`,
gitBranch: `\\[\\e[38;5;${hexToXterm256(theme.magenta)}m\\]`,
gitDirty: `\\[\\e[38;5;${hexToXterm256(theme.red)}m\\]`,
prompt: `\\[\\e[38;5;${hexToXterm256(theme.green)}m\\]`,
reset: '\\[\\e[0m\\]',
};
}
/**
* Escape shell special characters in user input
*/
function shellEscape(str: string): string {
return str.replace(/([`$\\"])/g, '\\$1');
}
/**
* Validate environment variable name
*/
function isValidEnvVarName(name: string): boolean {
return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name);
}
function stripPromptEscapes(ansiColor: string): string {
return ansiColor.replace(/\\\[/g, '').replace(/\\\]/g, '');
}
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 generateOhMyPoshInit(
shell: typeof OMP_SHELL_BASH | typeof OMP_SHELL_ZSH,
fallback: string
) {
const themeVar = `$${OMP_THEME_ENV_VAR}`;
const initCommand = `${OMP_BINARY} init ${shell} --config`;
return `if [ -n "${themeVar}" ] && command -v ${OMP_BINARY} >/dev/null 2>&1; then
automaker_omp_theme="$(automaker_resolve_omp_theme)"
if [ -n "$automaker_omp_theme" ]; then
eval "$(${initCommand} "$automaker_omp_theme")"
else
${fallback}
fi
else
${fallback}
fi`;
}
/**
* Generate common shell functions (git prompt, etc.)
*/
export function generateCommonFunctions(config: TerminalConfig): string {
const gitPrompt = config.showGitBranch
? `
automaker_git_prompt() {
local branch=""
local dirty=""
# Check if we're in a git repository
if git rev-parse --git-dir > /dev/null 2>&1; then
# Get current branch name
branch=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --short HEAD 2>/dev/null)
${
config.showGitStatus
? `
# Check if working directory is dirty
if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
dirty="*"
fi
`
: ''
}
if [ -n "$branch" ]; then
echo -n " ($branch$dirty)"
fi
fi
}
`
: `
automaker_git_prompt() {
# Git prompt disabled
echo -n ""
}
`;
return `#!/bin/sh
# Automaker Terminal Configuration - Common Functions v1.0
${gitPrompt}
AUTOMAKER_INFO_UNKNOWN="Unknown"
AUTOMAKER_BANNER_LABEL_WIDTH=12
AUTOMAKER_BYTES_PER_KIB=1024
AUTOMAKER_KIB_PER_MIB=1024
AUTOMAKER_MIB_PER_GIB=1024
AUTOMAKER_COLOR_PRIMARY="\\033[38;5;${STARTUP_COLOR_PRIMARY}m"
AUTOMAKER_COLOR_SECONDARY="\\033[38;5;${STARTUP_COLOR_SECONDARY}m"
AUTOMAKER_COLOR_ACCENT="\\033[38;5;${STARTUP_COLOR_ACCENT}m"
AUTOMAKER_COLOR_RESET="\\033[0m"
AUTOMAKER_SHOW_TIME="${config.showTime === true ? 'true' : 'false'}"
AUTOMAKER_SHOW_EXIT_STATUS="${config.showExitStatus === true ? 'true' : 'false'}"
AUTOMAKER_SHOW_USER_HOST="${config.showUserHost === false ? 'false' : 'true'}"
AUTOMAKER_SHOW_PATH="${config.showPath === false ? 'false' : 'true'}"
AUTOMAKER_PATH_STYLE="${normalizePathStyle(config.pathStyle)}"
AUTOMAKER_PATH_DEPTH=${normalizePathDepth(config.pathDepth)}
automaker_default_themes_dir="\${XDG_DATA_HOME:-\$HOME/.local/share}/oh-my-posh/themes"
if [ -z "$POSH_THEMES_PATH" ] || [ ! -d "$POSH_THEMES_PATH" ]; then
POSH_THEMES_PATH="$automaker_default_themes_dir"
fi
export POSH_THEMES_PATH
automaker_resolve_omp_theme() {
automaker_theme_name="$AUTOMAKER_OMP_THEME"
if [ -z "$automaker_theme_name" ]; then
return 1
fi
if [ -f "$automaker_theme_name" ]; then
printf '%s' "$automaker_theme_name"
return 0
fi
automaker_themes_base="\${POSH_THEMES_PATH%/}"
if [ -n "$automaker_themes_base" ]; then
if [ -f "$automaker_themes_base/$automaker_theme_name.omp.json" ]; then
printf '%s' "$automaker_themes_base/$automaker_theme_name.omp.json"
return 0
fi
if [ -f "$automaker_themes_base/$automaker_theme_name.omp.yaml" ]; then
printf '%s' "$automaker_themes_base/$automaker_theme_name.omp.yaml"
return 0
fi
fi
return 1
}
automaker_command_exists() {
command -v "$1" >/dev/null 2>&1
}
automaker_get_os() {
if [ -f /etc/os-release ]; then
. /etc/os-release
if [ -n "$PRETTY_NAME" ]; then
echo "$PRETTY_NAME"
return
fi
if [ -n "$NAME" ] && [ -n "$VERSION" ]; then
echo "$NAME $VERSION"
return
fi
fi
if automaker_command_exists sw_vers; then
echo "$(sw_vers -productName) $(sw_vers -productVersion)"
return
fi
uname -s 2>/dev/null || echo "$AUTOMAKER_INFO_UNKNOWN"
}
automaker_get_uptime() {
if automaker_command_exists uptime; then
if uptime -p >/dev/null 2>&1; then
uptime -p
return
fi
uptime 2>/dev/null | sed 's/.*up \\([^,]*\\).*/\\1/' || uptime 2>/dev/null
return
fi
echo "$AUTOMAKER_INFO_UNKNOWN"
}
automaker_get_cpu() {
if automaker_command_exists lscpu; then
lscpu | sed -n 's/Model name:[[:space:]]*//p' | head -n 1
return
fi
if automaker_command_exists sysctl; then
sysctl -n machdep.cpu.brand_string 2>/dev/null || sysctl -n hw.model 2>/dev/null
return
fi
uname -m 2>/dev/null || echo "$AUTOMAKER_INFO_UNKNOWN"
}
automaker_get_memory() {
if automaker_command_exists free; then
free -h | awk '/Mem:/ {print $3 " / " $2}'
return
fi
if automaker_command_exists vm_stat; then
local page_size
local pages_free
local pages_active
local pages_inactive
local pages_wired
local pages_total
page_size=$(vm_stat | awk '/page size of/ {print $8}')
pages_free=$(vm_stat | awk '/Pages free/ {print $3}' | tr -d '.')
pages_active=$(vm_stat | awk '/Pages active/ {print $3}' | tr -d '.')
pages_inactive=$(vm_stat | awk '/Pages inactive/ {print $3}' | tr -d '.')
pages_wired=$(vm_stat | awk '/Pages wired down/ {print $4}' | tr -d '.')
pages_total=$((pages_free + pages_active + pages_inactive + pages_wired))
awk -v total="$pages_total" -v free="$pages_free" -v size="$page_size" \
-v bytes_kib="$AUTOMAKER_BYTES_PER_KIB" \
-v kib_mib="$AUTOMAKER_KIB_PER_MIB" \
-v mib_gib="$AUTOMAKER_MIB_PER_GIB" \
'BEGIN {
total_gb = total * size / bytes_kib / kib_mib / mib_gib;
used_gb = (total - free) * size / bytes_kib / kib_mib / mib_gib;
printf("%.1f GB / %.1f GB", used_gb, total_gb);
}'
return
fi
if automaker_command_exists sysctl; then
local total_bytes
total_bytes=$(sysctl -n hw.memsize 2>/dev/null)
if [ -n "$total_bytes" ]; then
awk -v total="$total_bytes" \
-v bytes_kib="$AUTOMAKER_BYTES_PER_KIB" \
-v kib_mib="$AUTOMAKER_KIB_PER_MIB" \
-v mib_gib="$AUTOMAKER_MIB_PER_GIB" \
'BEGIN {printf("%.1f GB", total / bytes_kib / kib_mib / mib_gib)}'
return
fi
fi
echo "$AUTOMAKER_INFO_UNKNOWN"
}
automaker_get_disk() {
if automaker_command_exists df; then
df -h / 2>/dev/null | awk 'NR==2 {print $3 " / " $2}'
return
fi
echo "$AUTOMAKER_INFO_UNKNOWN"
}
automaker_get_ip() {
if automaker_command_exists hostname; then
local ip_addr
ip_addr=$(hostname -I 2>/dev/null | awk '{print $1}')
if [ -n "$ip_addr" ]; then
echo "$ip_addr"
return
fi
fi
if automaker_command_exists ipconfig; then
local ip_addr
ip_addr=$(ipconfig getifaddr en0 2>/dev/null)
if [ -n "$ip_addr" ]; then
echo "$ip_addr"
return
fi
fi
echo "$AUTOMAKER_INFO_UNKNOWN"
}
automaker_trim_path_depth() {
local path="$1"
local depth="$2"
if [ -z "$depth" ] || [ "$depth" -le 0 ]; then
echo "$path"
return
fi
echo "$path" | awk -v depth="$depth" -F/ '{
prefix=""
start=1
if ($1=="") { prefix="/"; start=2 }
else if ($1=="~") { prefix="~/"; start=2 }
n=NF
if (n < start) {
if (prefix=="/") { print "/" }
else if (prefix=="~/") { print "~" }
else { print $0 }
next
}
segCount = n - start + 1
d = depth
if (d > segCount) { d = segCount }
out=""
for (i = n - d + 1; i <= n; i++) {
out = out (out=="" ? "" : "/") $i
}
if (prefix=="/") {
if (out=="") { out="/" } else { out="/" out }
} else if (prefix=="~/") {
if (out=="") { out="~" } else { out="~/" out }
}
print out
}'
}
automaker_shorten_path() {
local path="$1"
echo "$path" | awk -F/ '{
prefix=""
start=1
if ($1=="") { prefix="/"; start=2 }
else if ($1=="~") { prefix="~/"; start=2 }
n=NF
if (n < start) {
if (prefix=="/") { print "/" }
else if (prefix=="~/") { print "~" }
else { print $0 }
next
}
out=""
for (i = start; i <= n; i++) {
seg = $i
if (i < n && length(seg) > 0) { seg = substr(seg, 1, 1) }
out = out (out=="" ? "" : "/") seg
}
if (prefix=="/") { out="/" out }
else if (prefix=="~/") { out="~/" out }
print out
}'
}
automaker_prompt_path() {
if [ "$AUTOMAKER_SHOW_PATH" != "true" ]; then
return
fi
local current_path="$PWD"
if [ -n "$HOME" ] && [ "\${current_path#"$HOME"}" != "$current_path" ]; then
current_path="~\${current_path#$HOME}"
fi
if [ "$AUTOMAKER_PATH_DEPTH" -gt 0 ]; then
current_path=$(automaker_trim_path_depth "$current_path" "$AUTOMAKER_PATH_DEPTH")
fi
case "$AUTOMAKER_PATH_STYLE" in
basename)
if [ "$current_path" = "/" ] || [ "$current_path" = "~" ]; then
echo -n "$current_path"
else
echo -n "\${current_path##*/}"
fi
;;
short)
echo -n "$(automaker_shorten_path "$current_path")"
;;
full|*)
echo -n "$current_path"
;;
esac
}
automaker_prompt_time() {
if [ "$AUTOMAKER_SHOW_TIME" != "true" ]; then
return
fi
date +%H:%M
}
automaker_prompt_status() {
automaker_last_status=$?
if [ "$AUTOMAKER_SHOW_EXIT_STATUS" != "true" ]; then
return
fi
if [ "$automaker_last_status" -eq 0 ]; then
return
fi
printf "✗ %s" "$automaker_last_status"
}
automaker_show_banner() {
local label_width="$AUTOMAKER_BANNER_LABEL_WIDTH"
local logo_line_1=" █▀▀█ █ █ ▀▀█▀▀ █▀▀█ █▀▄▀█ █▀▀█ █ █ █▀▀ █▀▀█ "
local logo_line_2=" █▄▄█ █ █ █ █ █ █ ▀ █ █▄▄█ █▀▄ █▀▀ █▄▄▀ "
local logo_line_3=" ▀ ▀ ▀▀▀ ▀ ▀▀▀▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀▀ "
local accent_color="\${AUTOMAKER_COLOR_PRIMARY}"
local secondary_color="\${AUTOMAKER_COLOR_SECONDARY}"
local tertiary_color="\${AUTOMAKER_COLOR_ACCENT}"
local label_color="\${AUTOMAKER_COLOR_SECONDARY}"
local reset_color="\${AUTOMAKER_COLOR_RESET}"
printf "%b%s%b\n" "$accent_color" "$logo_line_1" "$reset_color"
printf "%b%s%b\n" "$secondary_color" "$logo_line_2" "$reset_color"
printf "%b%s%b\n" "$tertiary_color" "$logo_line_3" "$reset_color"
printf "\n"
local shell_name="\${SHELL##*/}"
if [ -z "$shell_name" ]; then
shell_name=$(basename "$0" 2>/dev/null || echo "shell")
fi
local user_host="\${USER:-unknown}@$(hostname 2>/dev/null || echo unknown)"
printf "%b%s%b\n" "$label_color" "$user_host" "$reset_color"
printf "%b%-\${label_width}s%b %s\n" "$label_color" "OS:" "$reset_color" "$(automaker_get_os)"
printf "%b%-\${label_width}s%b %s\n" "$label_color" "Uptime:" "$reset_color" "$(automaker_get_uptime)"
printf "%b%-\${label_width}s%b %s\n" "$label_color" "Shell:" "$reset_color" "$shell_name"
printf "%b%-\${label_width}s%b %s\n" "$label_color" "Terminal:" "$reset_color" "\${TERM_PROGRAM:-$TERM}"
printf "%b%-\${label_width}s%b %s\n" "$label_color" "CPU:" "$reset_color" "$(automaker_get_cpu)"
printf "%b%-\${label_width}s%b %s\n" "$label_color" "Memory:" "$reset_color" "$(automaker_get_memory)"
printf "%b%-\${label_width}s%b %s\n" "$label_color" "Disk:" "$reset_color" "$(automaker_get_disk)"
printf "%b%-\${label_width}s%b %s\n" "$label_color" "Local IP:" "$reset_color" "$(automaker_get_ip)"
printf "\n"
}
automaker_show_banner_once() {
case "$-" in
*i*) ;;
*) return ;;
esac
if [ "$AUTOMAKER_BANNER_SHOWN" = "true" ]; then
return
fi
automaker_show_banner
export AUTOMAKER_BANNER_SHOWN="true"
}
`;
}
/**
* Generate prompt based on format
*/
function generatePrompt(
format: TerminalConfig['promptFormat'],
colors: ANSIColors,
config: TerminalConfig
): string {
const userHostSegment = config.showUserHost
? `${colors.user}\\u${colors.reset}@${colors.host}\\h${colors.reset}`
: '';
const pathSegment = config.showPath
? `${colors.path}\\$(automaker_prompt_path)${colors.reset}`
: '';
const gitSegment = config.showGitBranch
? `${colors.gitBranch}\\$(automaker_git_prompt)${colors.reset}`
: '';
const timeSegment = config.showTime
? `${colors.gitBranch}[\\$(automaker_prompt_time)]${colors.reset}`
: '';
const statusSegment = config.showExitStatus
? `${colors.gitDirty}\\$(automaker_prompt_status)${colors.reset}`
: '';
switch (format) {
case 'minimal': {
const minimalSegments = [timeSegment, userHostSegment, pathSegment, gitSegment, statusSegment]
.filter((segment) => segment.length > 0)
.join(' ');
return `PS1="${minimalSegments ? `${minimalSegments} ` : ''}${colors.prompt}\\$${colors.reset} "`;
}
case 'powerline': {
const powerlineCoreSegments = [
userHostSegment ? `[${userHostSegment}]` : '',
pathSegment ? `[${pathSegment}]` : '',
].filter((segment) => segment.length > 0);
const powerlineCore = powerlineCoreSegments.join('─');
const powerlineExtras = [gitSegment, timeSegment, statusSegment]
.filter((segment) => segment.length > 0)
.join(' ');
const powerlineLine = [powerlineCore, powerlineExtras]
.filter((segment) => segment.length > 0)
.join(' ');
return `PS1="┌─${powerlineLine}\\n└─${colors.prompt}\\$${colors.reset} "`;
}
case 'starship': {
let starshipLine = '';
if (userHostSegment && pathSegment) {
starshipLine = `${userHostSegment} in ${pathSegment}`;
} else {
starshipLine = [userHostSegment, pathSegment]
.filter((segment) => segment.length > 0)
.join(' ');
}
if (gitSegment) {
starshipLine = `${starshipLine}${starshipLine ? ' on ' : ''}${gitSegment}`;
}
const starshipSegments = [timeSegment, starshipLine, statusSegment]
.filter((segment) => segment.length > 0)
.join(' ');
return `PS1="${starshipSegments}\\n${colors.prompt}${colors.reset} "`;
}
case 'standard':
default: {
const standardSegments = [
timeSegment,
userHostSegment ? `[${userHostSegment}]` : '',
pathSegment,
gitSegment,
statusSegment,
]
.filter((segment) => segment.length > 0)
.join(' ');
return `PS1="${standardSegments ? `${standardSegments} ` : ''}${colors.prompt}\\$${colors.reset} "`;
}
}
}
/**
* Generate Zsh prompt based on format
*/
function generateZshPrompt(
format: TerminalConfig['promptFormat'],
colors: ANSIColors,
config: TerminalConfig
): string {
// Convert bash-style \u, \h, \w to zsh-style %n, %m, %~
// Remove bash-style escaping \[ \] (not needed in zsh)
const zshColors = {
user: colors.user
.replace(/\\[\[\]\\e]/g, '')
.replace(/\\e/g, '%{')
.replace(/m\\]/g, 'm%}'),
host: colors.host
.replace(/\\[\[\]\\e]/g, '')
.replace(/\\e/g, '%{')
.replace(/m\\]/g, 'm%}'),
path: colors.path
.replace(/\\[\[\]\\e]/g, '')
.replace(/\\e/g, '%{')
.replace(/m\\]/g, 'm%}'),
gitBranch: colors.gitBranch
.replace(/\\[\[\]\\e]/g, '')
.replace(/\\e/g, '%{')
.replace(/m\\]/g, 'm%}'),
gitDirty: colors.gitDirty
.replace(/\\[\[\]\\e]/g, '')
.replace(/\\e/g, '%{')
.replace(/m\\]/g, 'm%}'),
prompt: colors.prompt
.replace(/\\[\[\]\\e]/g, '')
.replace(/\\e/g, '%{')
.replace(/m\\]/g, 'm%}'),
reset: colors.reset
.replace(/\\[\[\]\\e]/g, '')
.replace(/\\e/g, '%{')
.replace(/m\\]/g, 'm%}'),
};
const userHostSegment = config.showUserHost
? `[${zshColors.user}%n${zshColors.reset}@${zshColors.host}%m${zshColors.reset}]`
: '';
const pathSegment = config.showPath
? `${zshColors.path}$(automaker_prompt_path)${zshColors.reset}`
: '';
const gitSegment = config.showGitBranch
? `${zshColors.gitBranch}$(automaker_git_prompt)${zshColors.reset}`
: '';
const timeSegment = config.showTime
? `${zshColors.gitBranch}[$(automaker_prompt_time)]${zshColors.reset}`
: '';
const statusSegment = config.showExitStatus
? `${zshColors.gitDirty}$(automaker_prompt_status)${zshColors.reset}`
: '';
const segments = [timeSegment, userHostSegment, pathSegment, gitSegment, statusSegment].filter(
(segment) => segment.length > 0
);
const inlineSegments = segments.join(' ');
const inlineWithSpace = inlineSegments ? `${inlineSegments} ` : '';
switch (format) {
case 'minimal': {
return `PROMPT="${inlineWithSpace}${zshColors.prompt}%#${zshColors.reset} "`;
}
case 'powerline': {
const powerlineCoreSegments = [
userHostSegment ? `[${userHostSegment}]` : '',
pathSegment ? `[${pathSegment}]` : '',
].filter((segment) => segment.length > 0);
const powerlineCore = powerlineCoreSegments.join('─');
const powerlineExtras = [gitSegment, timeSegment, statusSegment]
.filter((segment) => segment.length > 0)
.join(' ');
const powerlineLine = [powerlineCore, powerlineExtras]
.filter((segment) => segment.length > 0)
.join(' ');
return `PROMPT="┌─${powerlineLine}
└─${zshColors.prompt}%#${zshColors.reset} "`;
}
case 'starship': {
let starshipLine = '';
if (userHostSegment && pathSegment) {
starshipLine = `${userHostSegment} in ${pathSegment}`;
} else {
starshipLine = [userHostSegment, pathSegment]
.filter((segment) => segment.length > 0)
.join(' ');
}
if (gitSegment) {
starshipLine = `${starshipLine}${starshipLine ? ' on ' : ''}${gitSegment}`;
}
const starshipSegments = [timeSegment, starshipLine, statusSegment]
.filter((segment) => segment.length > 0)
.join(' ');
return `PROMPT="${starshipSegments}
${zshColors.prompt}${zshColors.reset} "`;
}
case 'standard':
default: {
const standardSegments = [
timeSegment,
userHostSegment ? `[${userHostSegment}]` : '',
pathSegment,
gitSegment,
statusSegment,
]
.filter((segment) => segment.length > 0)
.join(' ');
return `PROMPT="${standardSegments ? `${standardSegments} ` : ''}${zshColors.prompt}%#${zshColors.reset} "`;
}
}
}
/**
* Generate custom aliases section
*/
function generateAliases(config: TerminalConfig): string {
if (!config.customAliases) return '';
// Escape and validate aliases
const escapedAliases = shellEscape(config.customAliases);
return `
# Custom aliases
${escapedAliases}
`;
}
/**
* Generate custom environment variables section
*/
function generateEnvVars(config: TerminalConfig): string {
if (!config.customEnvVars || Object.keys(config.customEnvVars).length === 0) {
return '';
}
const validEnvVars = Object.entries(config.customEnvVars)
.filter(([name]) => isValidEnvVarName(name))
.map(([name, value]) => `export ${name}="${shellEscape(value)}"`)
.join('\n');
return validEnvVars
? `
# Custom environment variables
${validEnvVars}
`
: '';
}
/**
* Generate bashrc configuration
*/
export function generateBashrc(theme: TerminalTheme, config: TerminalConfig): string {
const colors = getThemeANSIColors(theme);
const promptLine = generatePrompt(config.promptFormat, colors, config);
const promptInitializer = generateOhMyPoshInit(OMP_SHELL_BASH, promptLine);
return `#!/bin/bash
# Automaker Terminal Configuration v1.0
# This file is automatically generated - manual edits will be overwritten
# Source user's original bashrc first (preserves user configuration)
if [ -f "$HOME/.bashrc" ]; then
source "$HOME/.bashrc"
fi
# Load Automaker theme colors
AUTOMAKER_THEME="\${AUTOMAKER_THEME:-dark}"
if [ -f "\${BASH_SOURCE%/*}/themes/$AUTOMAKER_THEME.sh" ]; then
source "\${BASH_SOURCE%/*}/themes/$AUTOMAKER_THEME.sh"
fi
# Load common functions (git prompt)
if [ -f "\${BASH_SOURCE%/*}/common.sh" ]; then
source "\${BASH_SOURCE%/*}/common.sh"
fi
# Show Automaker banner on shell start
if command -v automaker_show_banner_once >/dev/null 2>&1; then
automaker_show_banner_once
fi
# Set custom prompt (only if enabled)
if [ "$AUTOMAKER_CUSTOM_PROMPT" = "true" ]; then
${promptInitializer}
fi
${generateAliases(config)}${generateEnvVars(config)}
# Load user customizations (if exists)
if [ -f "\${BASH_SOURCE%/*}/user-custom.sh" ]; then
source "\${BASH_SOURCE%/*}/user-custom.sh"
fi
`;
}
/**
* Generate zshrc configuration
*/
export function generateZshrc(theme: TerminalTheme, config: TerminalConfig): string {
const colors = getThemeANSIColors(theme);
const promptLine = generateZshPrompt(config.promptFormat, colors, config);
const promptInitializer = generateOhMyPoshInit(OMP_SHELL_ZSH, promptLine);
return `#!/bin/zsh
# Automaker Terminal Configuration v1.0
# This file is automatically generated - manual edits will be overwritten
# Source user's original zshrc first (preserves user configuration)
if [ -f "$HOME/.zshrc" ]; then
source "$HOME/.zshrc"
fi
# Load Automaker theme colors
AUTOMAKER_THEME="\${AUTOMAKER_THEME:-dark}"
if [ -f "\${ZDOTDIR:-\${0:a:h}}/themes/$AUTOMAKER_THEME.sh" ]; then
source "\${ZDOTDIR:-\${0:a:h}}/themes/$AUTOMAKER_THEME.sh"
fi
# Load common functions (git prompt)
if [ -f "\${ZDOTDIR:-\${0:a:h}}/common.sh" ]; then
source "\${ZDOTDIR:-\${0:a:h}}/common.sh"
fi
# Enable command substitution in PROMPT
setopt PROMPT_SUBST
# Show Automaker banner on shell start
if command -v automaker_show_banner_once >/dev/null 2>&1; then
automaker_show_banner_once
fi
# Set custom prompt (only if enabled)
if [ "$AUTOMAKER_CUSTOM_PROMPT" = "true" ]; then
${promptInitializer}
fi
${generateAliases(config)}${generateEnvVars(config)}
# Load user customizations (if exists)
if [ -f "\${ZDOTDIR:-\${0:a:h}}/user-custom.sh" ]; then
source "\${ZDOTDIR:-\${0:a:h}}/user-custom.sh"
fi
`;
}
/**
* Generate theme color exports for shell
*/
export function generateThemeColors(theme: TerminalTheme): string {
const colors = getThemeANSIColors(theme);
const rawColors = {
user: stripPromptEscapes(colors.user),
host: stripPromptEscapes(colors.host),
path: stripPromptEscapes(colors.path),
gitBranch: stripPromptEscapes(colors.gitBranch),
gitDirty: stripPromptEscapes(colors.gitDirty),
prompt: stripPromptEscapes(colors.prompt),
reset: stripPromptEscapes(colors.reset),
};
return `#!/bin/sh
# Automaker Theme Colors
# This file is automatically generated - manual edits will be overwritten
# ANSI color codes for prompt
export COLOR_USER="${colors.user}"
export COLOR_HOST="${colors.host}"
export COLOR_PATH="${colors.path}"
export COLOR_GIT_BRANCH="${colors.gitBranch}"
export COLOR_GIT_DIRTY="${colors.gitDirty}"
export COLOR_PROMPT="${colors.prompt}"
export COLOR_RESET="${colors.reset}"
# ANSI color codes for banner output (no prompt escapes)
export COLOR_USER_RAW="${rawColors.user}"
export COLOR_HOST_RAW="${rawColors.host}"
export COLOR_PATH_RAW="${rawColors.path}"
export COLOR_GIT_BRANCH_RAW="${rawColors.gitBranch}"
export COLOR_GIT_DIRTY_RAW="${rawColors.gitDirty}"
export COLOR_PROMPT_RAW="${rawColors.prompt}"
export COLOR_RESET_RAW="${rawColors.reset}"
`;
}
/**
* Get shell name from file extension
*/
export function getShellName(rcFile: string): 'bash' | 'zsh' | 'sh' | null {
if (rcFile.endsWith('.sh') && rcFile.includes('bashrc')) return 'bash';
if (rcFile.endsWith('.zsh') || rcFile.endsWith('.zshrc')) return 'zsh';
if (rcFile.endsWith('.sh')) return 'sh';
return null;
}

View File

@@ -750,9 +750,6 @@ export function electronUserDataWriteFileSync(
throw new Error('[SystemPaths] Electron userData path not initialized'); throw new Error('[SystemPaths] Electron userData path not initialized');
} }
const fullPath = path.join(electronUserDataPath, relativePath); const fullPath = path.join(electronUserDataPath, relativePath);
// Ensure parent directory exists (may not exist on first launch)
const dir = path.dirname(fullPath);
fsSync.mkdirSync(dir, { recursive: true });
fsSync.writeFileSync(fullPath, data, options); fsSync.writeFileSync(fullPath, data, options);
} }

View File

@@ -1,468 +0,0 @@
/**
* Terminal Theme Colors - Color definitions for all 40 themes
*
* This module contains only the raw color data for terminal themes,
* extracted from the UI package to avoid circular dependencies.
* These colors are used by both UI (xterm.js) and server (RC file generation).
*/
import type { ThemeMode } from '@automaker/types';
import type { TerminalTheme } from './rc-generator.js';
// Dark theme (default)
const darkTheme: TerminalTheme = {
background: '#0a0a0a',
foreground: '#d4d4d4',
cursor: '#d4d4d4',
cursorAccent: '#0a0a0a',
selectionBackground: '#264f78',
black: '#1e1e1e',
red: '#f44747',
green: '#6a9955',
yellow: '#dcdcaa',
blue: '#569cd6',
magenta: '#c586c0',
cyan: '#4ec9b0',
white: '#d4d4d4',
brightBlack: '#808080',
brightRed: '#f44747',
brightGreen: '#6a9955',
brightYellow: '#dcdcaa',
brightBlue: '#569cd6',
brightMagenta: '#c586c0',
brightCyan: '#4ec9b0',
brightWhite: '#ffffff',
};
// Light theme
const lightTheme: TerminalTheme = {
background: '#ffffff',
foreground: '#383a42',
cursor: '#383a42',
cursorAccent: '#ffffff',
selectionBackground: '#add6ff',
black: '#383a42',
red: '#e45649',
green: '#50a14f',
yellow: '#c18401',
blue: '#4078f2',
magenta: '#a626a4',
cyan: '#0184bc',
white: '#fafafa',
brightBlack: '#4f525e',
brightRed: '#e06c75',
brightGreen: '#98c379',
brightYellow: '#e5c07b',
brightBlue: '#61afef',
brightMagenta: '#c678dd',
brightCyan: '#56b6c2',
brightWhite: '#ffffff',
};
// Retro / Cyberpunk theme - neon green on black
const retroTheme: TerminalTheme = {
background: '#000000',
foreground: '#39ff14',
cursor: '#39ff14',
cursorAccent: '#000000',
selectionBackground: '#39ff14',
selectionForeground: '#000000',
black: '#000000',
red: '#ff0055',
green: '#39ff14',
yellow: '#ffff00',
blue: '#00ffff',
magenta: '#ff00ff',
cyan: '#00ffff',
white: '#39ff14',
brightBlack: '#555555',
brightRed: '#ff5555',
brightGreen: '#55ff55',
brightYellow: '#ffff55',
brightBlue: '#55ffff',
brightMagenta: '#ff55ff',
brightCyan: '#55ffff',
brightWhite: '#ffffff',
};
// Dracula theme
const draculaTheme: TerminalTheme = {
background: '#282a36',
foreground: '#f8f8f2',
cursor: '#f8f8f2',
cursorAccent: '#282a36',
selectionBackground: '#44475a',
black: '#21222c',
red: '#ff5555',
green: '#50fa7b',
yellow: '#f1fa8c',
blue: '#bd93f9',
magenta: '#ff79c6',
cyan: '#8be9fd',
white: '#f8f8f2',
brightBlack: '#6272a4',
brightRed: '#ff6e6e',
brightGreen: '#69ff94',
brightYellow: '#ffffa5',
brightBlue: '#d6acff',
brightMagenta: '#ff92df',
brightCyan: '#a4ffff',
brightWhite: '#ffffff',
};
// Nord theme
const nordTheme: TerminalTheme = {
background: '#2e3440',
foreground: '#d8dee9',
cursor: '#d8dee9',
cursorAccent: '#2e3440',
selectionBackground: '#434c5e',
black: '#3b4252',
red: '#bf616a',
green: '#a3be8c',
yellow: '#ebcb8b',
blue: '#81a1c1',
magenta: '#b48ead',
cyan: '#88c0d0',
white: '#e5e9f0',
brightBlack: '#4c566a',
brightRed: '#bf616a',
brightGreen: '#a3be8c',
brightYellow: '#ebcb8b',
brightBlue: '#81a1c1',
brightMagenta: '#b48ead',
brightCyan: '#8fbcbb',
brightWhite: '#eceff4',
};
// Monokai theme
const monokaiTheme: TerminalTheme = {
background: '#272822',
foreground: '#f8f8f2',
cursor: '#f8f8f2',
cursorAccent: '#272822',
selectionBackground: '#49483e',
black: '#272822',
red: '#f92672',
green: '#a6e22e',
yellow: '#f4bf75',
blue: '#66d9ef',
magenta: '#ae81ff',
cyan: '#a1efe4',
white: '#f8f8f2',
brightBlack: '#75715e',
brightRed: '#f92672',
brightGreen: '#a6e22e',
brightYellow: '#f4bf75',
brightBlue: '#66d9ef',
brightMagenta: '#ae81ff',
brightCyan: '#a1efe4',
brightWhite: '#f9f8f5',
};
// Tokyo Night theme
const tokyonightTheme: TerminalTheme = {
background: '#1a1b26',
foreground: '#a9b1d6',
cursor: '#c0caf5',
cursorAccent: '#1a1b26',
selectionBackground: '#33467c',
black: '#15161e',
red: '#f7768e',
green: '#9ece6a',
yellow: '#e0af68',
blue: '#7aa2f7',
magenta: '#bb9af7',
cyan: '#7dcfff',
white: '#a9b1d6',
brightBlack: '#414868',
brightRed: '#f7768e',
brightGreen: '#9ece6a',
brightYellow: '#e0af68',
brightBlue: '#7aa2f7',
brightMagenta: '#bb9af7',
brightCyan: '#7dcfff',
brightWhite: '#c0caf5',
};
// Solarized Dark theme
const solarizedTheme: TerminalTheme = {
background: '#002b36',
foreground: '#93a1a1',
cursor: '#93a1a1',
cursorAccent: '#002b36',
selectionBackground: '#073642',
black: '#073642',
red: '#dc322f',
green: '#859900',
yellow: '#b58900',
blue: '#268bd2',
magenta: '#d33682',
cyan: '#2aa198',
white: '#eee8d5',
brightBlack: '#002b36',
brightRed: '#cb4b16',
brightGreen: '#586e75',
brightYellow: '#657b83',
brightBlue: '#839496',
brightMagenta: '#6c71c4',
brightCyan: '#93a1a1',
brightWhite: '#fdf6e3',
};
// Gruvbox Dark theme
const gruvboxTheme: TerminalTheme = {
background: '#282828',
foreground: '#ebdbb2',
cursor: '#ebdbb2',
cursorAccent: '#282828',
selectionBackground: '#504945',
black: '#282828',
red: '#cc241d',
green: '#98971a',
yellow: '#d79921',
blue: '#458588',
magenta: '#b16286',
cyan: '#689d6a',
white: '#a89984',
brightBlack: '#928374',
brightRed: '#fb4934',
brightGreen: '#b8bb26',
brightYellow: '#fabd2f',
brightBlue: '#83a598',
brightMagenta: '#d3869b',
brightCyan: '#8ec07c',
brightWhite: '#ebdbb2',
};
// Catppuccin Mocha theme
const catppuccinTheme: TerminalTheme = {
background: '#1e1e2e',
foreground: '#cdd6f4',
cursor: '#f5e0dc',
cursorAccent: '#1e1e2e',
selectionBackground: '#45475a',
black: '#45475a',
red: '#f38ba8',
green: '#a6e3a1',
yellow: '#f9e2af',
blue: '#89b4fa',
magenta: '#cba6f7',
cyan: '#94e2d5',
white: '#bac2de',
brightBlack: '#585b70',
brightRed: '#f38ba8',
brightGreen: '#a6e3a1',
brightYellow: '#f9e2af',
brightBlue: '#89b4fa',
brightMagenta: '#cba6f7',
brightCyan: '#94e2d5',
brightWhite: '#a6adc8',
};
// One Dark theme
const onedarkTheme: TerminalTheme = {
background: '#282c34',
foreground: '#abb2bf',
cursor: '#528bff',
cursorAccent: '#282c34',
selectionBackground: '#3e4451',
black: '#282c34',
red: '#e06c75',
green: '#98c379',
yellow: '#e5c07b',
blue: '#61afef',
magenta: '#c678dd',
cyan: '#56b6c2',
white: '#abb2bf',
brightBlack: '#5c6370',
brightRed: '#e06c75',
brightGreen: '#98c379',
brightYellow: '#e5c07b',
brightBlue: '#61afef',
brightMagenta: '#c678dd',
brightCyan: '#56b6c2',
brightWhite: '#ffffff',
};
// Synthwave '84 theme
const synthwaveTheme: TerminalTheme = {
background: '#262335',
foreground: '#ffffff',
cursor: '#ff7edb',
cursorAccent: '#262335',
selectionBackground: '#463465',
black: '#262335',
red: '#fe4450',
green: '#72f1b8',
yellow: '#fede5d',
blue: '#03edf9',
magenta: '#ff7edb',
cyan: '#03edf9',
white: '#ffffff',
brightBlack: '#614d85',
brightRed: '#fe4450',
brightGreen: '#72f1b8',
brightYellow: '#f97e72',
brightBlue: '#03edf9',
brightMagenta: '#ff7edb',
brightCyan: '#03edf9',
brightWhite: '#ffffff',
};
// Red theme
const redTheme: TerminalTheme = {
background: '#1a0a0a',
foreground: '#c8b0b0',
cursor: '#ff4444',
cursorAccent: '#1a0a0a',
selectionBackground: '#5a2020',
black: '#2a1010',
red: '#ff4444',
green: '#6a9a6a',
yellow: '#ccaa55',
blue: '#6688aa',
magenta: '#aa5588',
cyan: '#558888',
white: '#b0a0a0',
brightBlack: '#6a4040',
brightRed: '#ff6666',
brightGreen: '#88bb88',
brightYellow: '#ddbb66',
brightBlue: '#88aacc',
brightMagenta: '#cc77aa',
brightCyan: '#77aaaa',
brightWhite: '#d0c0c0',
};
// Cream theme
const creamTheme: TerminalTheme = {
background: '#f5f3ee',
foreground: '#5a4a3a',
cursor: '#9d6b53',
cursorAccent: '#f5f3ee',
selectionBackground: '#d4c4b0',
black: '#5a4a3a',
red: '#c85a4f',
green: '#7a9a6a',
yellow: '#c9a554',
blue: '#6b8aaa',
magenta: '#a66a8a',
cyan: '#5a9a8a',
white: '#b0a090',
brightBlack: '#8a7a6a',
brightRed: '#e07060',
brightGreen: '#90b080',
brightYellow: '#e0bb70',
brightBlue: '#80a0c0',
brightMagenta: '#c080a0',
brightCyan: '#70b0a0',
brightWhite: '#d0c0b0',
};
// Sunset theme
const sunsetTheme: TerminalTheme = {
background: '#1e1a24',
foreground: '#f2e8dd',
cursor: '#dd8855',
cursorAccent: '#1e1a24',
selectionBackground: '#3a2a40',
black: '#1e1a24',
red: '#dd6655',
green: '#88bb77',
yellow: '#ddaa66',
blue: '#6699cc',
magenta: '#cc7799',
cyan: '#66ccaa',
white: '#e8d8c8',
brightBlack: '#4a3a50',
brightRed: '#ee8866',
brightGreen: '#99cc88',
brightYellow: '#eebb77',
brightBlue: '#88aadd',
brightMagenta: '#dd88aa',
brightCyan: '#88ddbb',
brightWhite: '#f5e8dd',
};
// Gray theme
const grayTheme: TerminalTheme = {
background: '#2a2d32',
foreground: '#d0d0d5',
cursor: '#8fa0c0',
cursorAccent: '#2a2d32',
selectionBackground: '#3a3f48',
black: '#2a2d32',
red: '#d87070',
green: '#78b088',
yellow: '#d0b060',
blue: '#7090c0',
magenta: '#a880b0',
cyan: '#60a0b0',
white: '#b0b0b8',
brightBlack: '#606068',
brightRed: '#e88888',
brightGreen: '#90c8a0',
brightYellow: '#e0c878',
brightBlue: '#90b0d8',
brightMagenta: '#c098c8',
brightCyan: '#80b8c8',
brightWhite: '#e0e0e8',
};
/**
* Theme color mapping for all 40 themes
*/
export const terminalThemeColors: Record<ThemeMode, TerminalTheme> = {
// Special
system: darkTheme, // Resolved at runtime based on OS preference
// Dark themes (16)
dark: darkTheme,
retro: retroTheme,
dracula: draculaTheme,
nord: nordTheme,
monokai: monokaiTheme,
tokyonight: tokyonightTheme,
solarized: solarizedTheme,
gruvbox: gruvboxTheme,
catppuccin: catppuccinTheme,
onedark: onedarkTheme,
synthwave: synthwaveTheme,
red: redTheme,
sunset: sunsetTheme,
gray: grayTheme,
forest: gruvboxTheme, // Green-ish theme
ocean: nordTheme, // Blue-ish theme
ember: monokaiTheme, // Warm orange theme
'ayu-dark': darkTheme,
'ayu-mirage': darkTheme,
matcha: nordTheme,
// Light themes (16)
light: lightTheme,
cream: creamTheme,
solarizedlight: lightTheme,
github: lightTheme,
paper: lightTheme,
rose: lightTheme,
mint: lightTheme,
lavender: lightTheme,
sand: creamTheme,
sky: lightTheme,
peach: creamTheme,
snow: lightTheme,
sepia: creamTheme,
gruvboxlight: creamTheme,
nordlight: lightTheme,
blossom: lightTheme,
'ayu-light': lightTheme,
onelight: lightTheme,
bluloco: lightTheme,
feather: lightTheme,
};
/**
* Get terminal theme colors for a given theme mode
*/
export function getTerminalThemeColors(theme: ThemeMode): TerminalTheme {
return terminalThemeColors[theme] || darkTheme;
}

View File

@@ -140,9 +140,9 @@ const SUPPORTED_TERMINALS: TerminalDefinition[] = [
{ {
id: 'warp', id: 'warp',
name: 'Warp', name: 'Warp',
cliCommand: 'warp-cli', cliCommand: 'warp',
cliAliases: ['warp-terminal', 'warp'],
macAppName: 'Warp', macAppName: 'Warp',
platform: 'darwin',
}, },
{ {
id: 'ghostty', id: 'ghostty',
@@ -476,11 +476,6 @@ async function executeTerminalCommand(terminal: TerminalInfo, targetPath: string
await spawnDetached(command, [`--working-directory=${targetPath}`]); await spawnDetached(command, [`--working-directory=${targetPath}`]);
break; break;
case 'warp':
// Warp: uses --cwd flag (CLI mode, not app bundle)
await spawnDetached(command, ['--cwd', targetPath]);
break;
case 'alacritty': case 'alacritty':
// Alacritty: uses --working-directory flag // Alacritty: uses --working-directory flag
await spawnDetached(command, ['--working-directory', targetPath]); await spawnDetached(command, ['--working-directory', targetPath]);

View File

@@ -1,100 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { needsRegeneration, writeRcFiles } from '../src/rc-file-manager';
import { terminalThemeColors } from '../src/terminal-theme-colors';
import type { TerminalConfig } from '../src/rc-generator';
import type { ThemeMode } from '@automaker/types';
describe('rc-file-manager.ts', () => {
let tempDir: string;
let projectPath: string;
const TEMP_DIR_PREFIX = 'platform-rc-files-test-';
const PROJECT_DIR_NAME = 'test-project';
const THEME_DARK = 'dark' as ThemeMode;
const THEME_LIGHT = 'light' as ThemeMode;
const PROMPT_FORMAT_STANDARD: TerminalConfig['promptFormat'] = 'standard';
const PROMPT_FORMAT_MINIMAL: TerminalConfig['promptFormat'] = 'minimal';
const EMPTY_ALIASES = '';
const PATH_STYLE_FULL: TerminalConfig['pathStyle'] = 'full';
const PATH_DEPTH_DEFAULT = 0;
const baseConfig: TerminalConfig = {
enabled: true,
customPrompt: true,
promptFormat: PROMPT_FORMAT_STANDARD,
showGitBranch: true,
showGitStatus: true,
showUserHost: true,
showPath: true,
pathStyle: PATH_STYLE_FULL,
pathDepth: PATH_DEPTH_DEFAULT,
showTime: false,
showExitStatus: false,
customAliases: EMPTY_ALIASES,
customEnvVars: {},
};
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), TEMP_DIR_PREFIX));
projectPath = path.join(tempDir, PROJECT_DIR_NAME);
await fs.mkdir(projectPath, { recursive: true });
});
afterEach(async () => {
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
it('should not regenerate when signature matches', async () => {
await writeRcFiles(
projectPath,
THEME_DARK,
baseConfig,
terminalThemeColors[THEME_DARK],
terminalThemeColors
);
const needsRegen = await needsRegeneration(projectPath, THEME_DARK, baseConfig);
expect(needsRegen).toBe(false);
});
it('should regenerate when config changes', async () => {
await writeRcFiles(
projectPath,
THEME_DARK,
baseConfig,
terminalThemeColors[THEME_DARK],
terminalThemeColors
);
const updatedConfig: TerminalConfig = {
...baseConfig,
promptFormat: PROMPT_FORMAT_MINIMAL,
};
const needsRegen = await needsRegeneration(projectPath, THEME_DARK, updatedConfig);
expect(needsRegen).toBe(true);
});
it('should regenerate when theme changes', async () => {
await writeRcFiles(
projectPath,
THEME_DARK,
baseConfig,
terminalThemeColors[THEME_DARK],
terminalThemeColors
);
const needsRegen = await needsRegeneration(projectPath, THEME_LIGHT, baseConfig);
expect(needsRegen).toBe(true);
});
});

View File

@@ -1,55 +0,0 @@
import { describe, it, expect } from 'vitest';
import { generateCommonFunctions, generateThemeColors } from '../src/rc-generator';
import { terminalThemeColors } from '../src/terminal-theme-colors';
import type { TerminalConfig } from '../src/rc-generator';
import type { ThemeMode } from '@automaker/types';
describe('rc-generator.ts', () => {
const THEME_DARK = 'dark' as ThemeMode;
const PROMPT_FORMAT_STANDARD: TerminalConfig['promptFormat'] = 'standard';
const EMPTY_ALIASES = '';
const EMPTY_ENV_VARS = {};
const PATH_STYLE_FULL: TerminalConfig['pathStyle'] = 'full';
const PATH_DEPTH_DEFAULT = 0;
const EXPECTED_BANNER_FUNCTION = 'automaker_show_banner_once';
const RAW_COLOR_PREFIX = 'export COLOR_USER_RAW=';
const RAW_COLOR_ESCAPE_START = '\\\\[';
const RAW_COLOR_ESCAPE_END = '\\\\]';
const STARTUP_PRIMARY_COLOR = '38;5;51m';
const STARTUP_SECONDARY_COLOR = '38;5;39m';
const STARTUP_ACCENT_COLOR = '38;5;33m';
const baseConfig: TerminalConfig = {
enabled: true,
customPrompt: true,
promptFormat: PROMPT_FORMAT_STANDARD,
showGitBranch: true,
showGitStatus: true,
showUserHost: true,
showPath: true,
pathStyle: PATH_STYLE_FULL,
pathDepth: PATH_DEPTH_DEFAULT,
showTime: false,
showExitStatus: false,
customAliases: EMPTY_ALIASES,
customEnvVars: EMPTY_ENV_VARS,
};
it('includes banner functions in common shell script', () => {
const output = generateCommonFunctions(baseConfig);
expect(output).toContain(EXPECTED_BANNER_FUNCTION);
expect(output).toContain(STARTUP_PRIMARY_COLOR);
expect(output).toContain(STARTUP_SECONDARY_COLOR);
expect(output).toContain(STARTUP_ACCENT_COLOR);
});
it('exports raw banner colors without prompt escape wrappers', () => {
const output = generateThemeColors(terminalThemeColors[THEME_DARK]);
const rawLine = output.split('\n').find((line) => line.startsWith(RAW_COLOR_PREFIX));
expect(rawLine).toBeDefined();
expect(rawLine).not.toContain(RAW_COLOR_ESCAPE_START);
expect(rawLine).not.toContain(RAW_COLOR_ESCAPE_END);
});
});

View File

@@ -27,16 +27,14 @@ export type { ModelAlias };
* *
* Includes system theme and multiple color schemes organized by dark/light: * Includes system theme and multiple color schemes organized by dark/light:
* - System: Respects OS dark/light mode preference * - System: Respects OS dark/light mode preference
* - Dark themes (20): dark, retro, dracula, nord, monokai, tokyonight, solarized, * - Dark themes (16): dark, retro, dracula, nord, monokai, tokyonight, solarized,
* gruvbox, catppuccin, onedark, synthwave, red, sunset, gray, forest, ocean, * gruvbox, catppuccin, onedark, synthwave, red, sunset, gray, forest, ocean
* ember, ayu-dark, ayu-mirage, matcha * - Light themes (16): light, cream, solarizedlight, github, paper, rose, mint,
* - Light themes (20): light, cream, solarizedlight, github, paper, rose, mint, * lavender, sand, sky, peach, snow, sepia, gruvboxlight, nordlight, blossom
* lavender, sand, sky, peach, snow, sepia, gruvboxlight, nordlight, blossom,
* ayu-light, onelight, bluloco, feather
*/ */
export type ThemeMode = export type ThemeMode =
| 'system' | 'system'
// Dark themes (20) // Dark themes (16)
| 'dark' | 'dark'
| 'retro' | 'retro'
| 'dracula' | 'dracula'
@@ -53,11 +51,7 @@ export type ThemeMode =
| 'gray' | 'gray'
| 'forest' | 'forest'
| 'ocean' | 'ocean'
| 'ember' // Light themes (16)
| 'ayu-dark'
| 'ayu-mirage'
| 'matcha'
// Light themes (20)
| 'light' | 'light'
| 'cream' | 'cream'
| 'solarizedlight' | 'solarizedlight'
@@ -73,138 +67,7 @@ export type ThemeMode =
| 'sepia' | 'sepia'
| 'gruvboxlight' | 'gruvboxlight'
| 'nordlight' | 'nordlight'
| 'blossom' | 'blossom';
| 'ayu-light'
| 'onelight'
| 'bluloco'
| 'feather';
export type TerminalPromptTheme =
| 'custom'
| 'omp-1_shell'
| 'omp-agnoster'
| 'omp-agnoster.minimal'
| 'omp-agnosterplus'
| 'omp-aliens'
| 'omp-amro'
| 'omp-atomic'
| 'omp-atomicBit'
| 'omp-avit'
| 'omp-blue-owl'
| 'omp-blueish'
| 'omp-bubbles'
| 'omp-bubblesextra'
| 'omp-bubblesline'
| 'omp-capr4n'
| 'omp-catppuccin'
| 'omp-catppuccin_frappe'
| 'omp-catppuccin_latte'
| 'omp-catppuccin_macchiato'
| 'omp-catppuccin_mocha'
| 'omp-cert'
| 'omp-chips'
| 'omp-cinnamon'
| 'omp-clean-detailed'
| 'omp-cloud-context'
| 'omp-cloud-native-azure'
| 'omp-cobalt2'
| 'omp-craver'
| 'omp-darkblood'
| 'omp-devious-diamonds'
| 'omp-di4am0nd'
| 'omp-dracula'
| 'omp-easy-term'
| 'omp-emodipt'
| 'omp-emodipt-extend'
| 'omp-fish'
| 'omp-free-ukraine'
| 'omp-froczh'
| 'omp-gmay'
| 'omp-glowsticks'
| 'omp-grandpa-style'
| 'omp-gruvbox'
| 'omp-half-life'
| 'omp-honukai'
| 'omp-hotstick.minimal'
| 'omp-hul10'
| 'omp-hunk'
| 'omp-huvix'
| 'omp-if_tea'
| 'omp-illusi0n'
| 'omp-iterm2'
| 'omp-jandedobbeleer'
| 'omp-jblab_2021'
| 'omp-jonnychipz'
| 'omp-json'
| 'omp-jtracey93'
| 'omp-jv_sitecorian'
| 'omp-kali'
| 'omp-kushal'
| 'omp-lambda'
| 'omp-lambdageneration'
| 'omp-larserikfinholt'
| 'omp-lightgreen'
| 'omp-M365Princess'
| 'omp-marcduiker'
| 'omp-markbull'
| 'omp-material'
| 'omp-microverse-power'
| 'omp-mojada'
| 'omp-montys'
| 'omp-mt'
| 'omp-multiverse-neon'
| 'omp-negligible'
| 'omp-neko'
| 'omp-night-owl'
| 'omp-nordtron'
| 'omp-nu4a'
| 'omp-onehalf.minimal'
| 'omp-paradox'
| 'omp-pararussel'
| 'omp-patriksvensson'
| 'omp-peru'
| 'omp-pixelrobots'
| 'omp-plague'
| 'omp-poshmon'
| 'omp-powerlevel10k_classic'
| 'omp-powerlevel10k_lean'
| 'omp-powerlevel10k_modern'
| 'omp-powerlevel10k_rainbow'
| 'omp-powerline'
| 'omp-probua.minimal'
| 'omp-pure'
| 'omp-quick-term'
| 'omp-remk'
| 'omp-robbyrussell'
| 'omp-rudolfs-dark'
| 'omp-rudolfs-light'
| 'omp-sim-web'
| 'omp-slim'
| 'omp-slimfat'
| 'omp-smoothie'
| 'omp-sonicboom_dark'
| 'omp-sonicboom_light'
| 'omp-sorin'
| 'omp-space'
| 'omp-spaceship'
| 'omp-star'
| 'omp-stelbent-compact.minimal'
| 'omp-stelbent.minimal'
| 'omp-takuya'
| 'omp-the-unnamed'
| 'omp-thecyberden'
| 'omp-tiwahu'
| 'omp-tokyo'
| 'omp-tokyonight_storm'
| 'omp-tonybaloney'
| 'omp-uew'
| 'omp-unicorn'
| 'omp-velvet'
| 'omp-wholespace'
| 'omp-wopian'
| 'omp-xtoys'
| 'omp-ys'
| 'omp-zash';
/** PlanningMode - Planning levels for feature generation workflows */ /** PlanningMode - Planning levels for feature generation workflows */
export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full'; export type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
@@ -977,39 +840,6 @@ export interface GlobalSettings {
// Terminal Configuration // Terminal Configuration
/** How to open terminals from "Open in Terminal" worktree action */ /** How to open terminals from "Open in Terminal" worktree action */
openTerminalMode?: 'newTab' | 'split'; openTerminalMode?: 'newTab' | 'split';
/** Custom terminal configuration settings (prompt theming, aliases, env vars) */
terminalConfig?: {
/** Enable custom terminal configurations (default: false) */
enabled: boolean;
/** Enable custom prompt (default: true when enabled) */
customPrompt: boolean;
/** Prompt format template */
promptFormat: 'standard' | 'minimal' | 'powerline' | 'starship';
/** Prompt theme preset */
promptTheme?: TerminalPromptTheme;
/** Show git branch in prompt (default: true) */
showGitBranch: boolean;
/** Show git status dirty indicator (default: true) */
showGitStatus: boolean;
/** Show user and host in prompt (default: true) */
showUserHost: boolean;
/** Show path in prompt (default: true) */
showPath: boolean;
/** Path display style */
pathStyle: 'full' | 'short' | 'basename';
/** Limit path depth (0 = full path) */
pathDepth: number;
/** Show current time in prompt (default: false) */
showTime: boolean;
/** Show last command exit status when non-zero (default: false) */
showExitStatus: boolean;
/** User-provided custom aliases (multiline string) */
customAliases: string;
/** User-provided custom env vars */
customEnvVars: Record<string, string>;
/** RC file format version (for migration) */
rcFileVersion?: number;
};
// UI State Preferences // UI State Preferences
/** Whether sidebar is currently open */ /** Whether sidebar is currently open */
@@ -1415,33 +1245,6 @@ export interface ProjectSettings {
*/ */
defaultFeatureModel?: PhaseModelEntry; defaultFeatureModel?: PhaseModelEntry;
// Terminal Configuration Override (per-project)
/** Project-specific terminal config overrides */
terminalConfig?: {
/** Override global enabled setting */
enabled?: boolean;
/** Override prompt theme preset */
promptTheme?: TerminalPromptTheme;
/** Override showing user/host */
showUserHost?: boolean;
/** Override showing path */
showPath?: boolean;
/** Override path style */
pathStyle?: 'full' | 'short' | 'basename';
/** Override path depth (0 = full path) */
pathDepth?: number;
/** Override showing time */
showTime?: boolean;
/** Override showing exit status */
showExitStatus?: boolean;
/** Project-specific custom aliases */
customAliases?: string;
/** Project-specific env vars */
customEnvVars?: Record<string, string>;
/** Custom welcome message for this project */
welcomeMessage?: string;
};
// Deprecated Claude API Profile Override // Deprecated Claude API Profile Override
/** /**
* @deprecated Use phaseModelOverrides instead. * @deprecated Use phaseModelOverrides instead.

View File

@@ -9,11 +9,7 @@ set -e
# ============================================================================ # ============================================================================
# CONFIGURATION & CONSTANTS # CONFIGURATION & CONSTANTS
# ============================================================================ # ============================================================================
if [ -f .env ]; then export $(grep -v '^#' .env | xargs)
set -a
. ./.env
set +a
fi
APP_NAME="Automaker" APP_NAME="Automaker"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HISTORY_FILE="${HOME}/.automaker_launcher_history" HISTORY_FILE="${HOME}/.automaker_launcher_history"
@@ -1158,9 +1154,7 @@ fi
# Execute the appropriate command # Execute the appropriate command
case $MODE in case $MODE in
web) web)
if [ -f .env ]; then
export $(grep -v '^#' .env | xargs) export $(grep -v '^#' .env | xargs)
fi
export TEST_PORT="$WEB_PORT" export TEST_PORT="$WEB_PORT"
export VITE_SERVER_URL="http://${APP_HOST}:$SERVER_PORT" export VITE_SERVER_URL="http://${APP_HOST}:$SERVER_PORT"
export PORT="$SERVER_PORT" export PORT="$SERVER_PORT"