diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 4bd496bc8..2255fdc13 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -390,7 +390,7 @@ const server = createServer(app); // WebSocket servers using noServer mode for proper multi-path support const wss = new WebSocketServer({ noServer: true }); const terminalWss = new WebSocketServer({ noServer: true }); -const terminalService = getTerminalService(); +const terminalService = getTerminalService(settingsService); /** * Authenticate WebSocket upgrade requests diff --git a/apps/server/src/lib/terminal-themes-data.ts b/apps/server/src/lib/terminal-themes-data.ts new file mode 100644 index 000000000..854bf1a80 --- /dev/null +++ b/apps/server/src/lib/terminal-themes-data.ts @@ -0,0 +1,25 @@ +/** + * Terminal Theme Data - Re-export terminal themes from platform package + * + * This module re-exports terminal theme data for use in the server. + */ + +import { terminalThemeColors, getTerminalThemeColors as getThemeColors } from '@automaker/platform'; +import type { ThemeMode } from '@automaker/types'; +import type { TerminalTheme } from '@automaker/platform'; + +/** + * Get terminal theme colors for a given theme mode + */ +export function getTerminalThemeColors(theme: ThemeMode): TerminalTheme { + return getThemeColors(theme); +} + +/** + * Get all terminal themes + */ +export function getAllTerminalThemes(): Record { + return terminalThemeColors; +} + +export default terminalThemeColors; diff --git a/apps/server/src/routes/settings/routes/update-global.ts b/apps/server/src/routes/settings/routes/update-global.ts index b45e99650..817b5c1da 100644 --- a/apps/server/src/routes/settings/routes/update-global.ts +++ b/apps/server/src/routes/settings/routes/update-global.ts @@ -14,6 +14,7 @@ import type { GlobalSettings } from '../../../types/settings.js'; import { getErrorMessage, logError, logger } from '../common.js'; import { setLogLevel, LogLevel } from '@automaker/utils'; import { setRequestLoggingEnabled } from '../../../index.js'; +import { getTerminalService } from '../../../services/terminal-service.js'; /** * Map server log level string to LogLevel enum @@ -57,6 +58,10 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) { }, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}` ); + // Get old settings to detect theme changes + const oldSettings = await settingsService.getGlobalSettings(); + const oldTheme = oldSettings?.theme; + logger.info('[SERVER_SETTINGS_UPDATE] Calling updateGlobalSettings...'); const settings = await settingsService.updateGlobalSettings(updates); logger.info( @@ -64,6 +69,37 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) { settings.projects?.length ?? 0 ); + // Handle theme change - regenerate terminal RC files for all projects + if ('theme' in updates && updates.theme && updates.theme !== oldTheme) { + const terminalService = getTerminalService(settingsService); + const newTheme = updates.theme; + + logger.info( + `[TERMINAL_CONFIG] Theme changed from ${oldTheme} to ${newTheme}, regenerating RC files` + ); + + // Regenerate RC files for all projects with terminal config enabled + const projects = settings.projects || []; + for (const project of projects) { + try { + const projectSettings = await settingsService.getProjectSettings(project.path); + // Check if terminal config is enabled (global or project-specific) + const terminalConfigEnabled = + projectSettings.terminalConfig?.enabled !== false && + settings.terminalConfig?.enabled === true; + + if (terminalConfigEnabled) { + await terminalService.onThemeChange(project.path, newTheme); + logger.info(`[TERMINAL_CONFIG] Regenerated RC files for project: ${project.name}`); + } + } catch (error) { + logger.warn( + `[TERMINAL_CONFIG] Failed to regenerate RC files for project ${project.name}: ${error}` + ); + } + } + } + // Apply server log level if it was updated if ('serverLogLevel' in updates && updates.serverLogLevel) { const level = LOG_LEVEL_MAP[updates.serverLogLevel]; diff --git a/apps/server/src/services/terminal-service.ts b/apps/server/src/services/terminal-service.ts index f83aaede6..167ab3480 100644 --- a/apps/server/src/services/terminal-service.ts +++ b/apps/server/src/services/terminal-service.ts @@ -13,6 +13,14 @@ import * as path from 'path'; // to enforce ALLOWED_ROOT_DIRECTORY security boundary import * as secureFs from '../lib/secure-fs.js'; import { createLogger } from '@automaker/utils'; +import type { SettingsService } from './settings-service.js'; +import { getTerminalThemeColors, getAllTerminalThemes } from '../lib/terminal-themes-data.js'; +import { + getRcFilePath, + getTerminalDir, + ensureRcFilesUpToDate, + type TerminalConfig, +} from '@automaker/platform'; const logger = createLogger('Terminal'); // System paths module handles shell binary checks and WSL detection @@ -24,6 +32,27 @@ import { getShellPaths, } from '@automaker/platform'; +const BASH_LOGIN_ARG = '--login'; +const BASH_RCFILE_ARG = '--rcfile'; +const SHELL_NAME_BASH = 'bash'; +const SHELL_NAME_ZSH = 'zsh'; +const SHELL_NAME_SH = 'sh'; +const DEFAULT_SHOW_USER_HOST = true; +const DEFAULT_SHOW_PATH = true; +const DEFAULT_SHOW_TIME = false; +const DEFAULT_SHOW_EXIT_STATUS = false; +const DEFAULT_PATH_DEPTH = 0; +const DEFAULT_PATH_STYLE: TerminalConfig['pathStyle'] = 'full'; +const DEFAULT_CUSTOM_PROMPT = true; +const DEFAULT_PROMPT_FORMAT: TerminalConfig['promptFormat'] = 'standard'; +const DEFAULT_SHOW_GIT_BRANCH = true; +const DEFAULT_SHOW_GIT_STATUS = true; +const DEFAULT_CUSTOM_ALIASES = ''; +const DEFAULT_CUSTOM_ENV_VARS: Record = {}; +const PROMPT_THEME_CUSTOM = 'custom'; +const PROMPT_THEME_PREFIX = 'omp-'; +const OMP_THEME_ENV_VAR = 'AUTOMAKER_OMP_THEME'; + // Maximum scrollback buffer size (characters) const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal @@ -42,6 +71,114 @@ let maxSessions = parseInt(process.env.TERMINAL_MAX_SESSIONS || '1000', 10); const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive input const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency +function applyBashRcFileArgs(args: string[], rcFilePath: string): string[] { + const sanitizedArgs: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === BASH_LOGIN_ARG) { + continue; + } + if (arg === BASH_RCFILE_ARG) { + index += 1; + continue; + } + sanitizedArgs.push(arg); + } + + sanitizedArgs.push(BASH_RCFILE_ARG, rcFilePath); + return sanitizedArgs; +} + +function normalizePathStyle( + pathStyle: TerminalConfig['pathStyle'] | undefined +): TerminalConfig['pathStyle'] { + if (pathStyle === 'short' || pathStyle === 'basename') { + return pathStyle; + } + return DEFAULT_PATH_STYLE; +} + +function normalizePathDepth(pathDepth: number | undefined): number { + const depth = + typeof pathDepth === 'number' && Number.isFinite(pathDepth) ? pathDepth : DEFAULT_PATH_DEPTH; + return Math.max(DEFAULT_PATH_DEPTH, Math.floor(depth)); +} + +function getShellBasename(shellPath: string): string { + const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\')); + return lastSep >= 0 ? shellPath.slice(lastSep + 1) : shellPath; +} + +function getShellArgsForPath(shellPath: string): string[] { + const shellName = getShellBasename(shellPath).toLowerCase().replace('.exe', ''); + if (shellName === 'powershell' || shellName === 'pwsh' || shellName === 'cmd') { + return []; + } + if (shellName === SHELL_NAME_SH) { + return []; + } + return [BASH_LOGIN_ARG]; +} + +function resolveOmpThemeName(promptTheme: string | undefined): string | null { + if (!promptTheme || promptTheme === PROMPT_THEME_CUSTOM) { + return null; + } + if (promptTheme.startsWith(PROMPT_THEME_PREFIX)) { + return promptTheme.slice(PROMPT_THEME_PREFIX.length); + } + return null; +} + +function buildEffectiveTerminalConfig( + globalTerminalConfig: TerminalConfig | undefined, + projectTerminalConfig: Partial | undefined +): TerminalConfig { + const mergedEnvVars = { + ...(globalTerminalConfig?.customEnvVars ?? DEFAULT_CUSTOM_ENV_VARS), + ...(projectTerminalConfig?.customEnvVars ?? DEFAULT_CUSTOM_ENV_VARS), + }; + + return { + enabled: projectTerminalConfig?.enabled ?? globalTerminalConfig?.enabled ?? false, + customPrompt: globalTerminalConfig?.customPrompt ?? DEFAULT_CUSTOM_PROMPT, + promptFormat: globalTerminalConfig?.promptFormat ?? DEFAULT_PROMPT_FORMAT, + showGitBranch: + projectTerminalConfig?.showGitBranch ?? + globalTerminalConfig?.showGitBranch ?? + DEFAULT_SHOW_GIT_BRANCH, + showGitStatus: + projectTerminalConfig?.showGitStatus ?? + globalTerminalConfig?.showGitStatus ?? + DEFAULT_SHOW_GIT_STATUS, + showUserHost: + projectTerminalConfig?.showUserHost ?? + globalTerminalConfig?.showUserHost ?? + DEFAULT_SHOW_USER_HOST, + showPath: + projectTerminalConfig?.showPath ?? globalTerminalConfig?.showPath ?? DEFAULT_SHOW_PATH, + pathStyle: normalizePathStyle( + projectTerminalConfig?.pathStyle ?? globalTerminalConfig?.pathStyle + ), + pathDepth: normalizePathDepth( + projectTerminalConfig?.pathDepth ?? globalTerminalConfig?.pathDepth + ), + showTime: + projectTerminalConfig?.showTime ?? globalTerminalConfig?.showTime ?? DEFAULT_SHOW_TIME, + showExitStatus: + projectTerminalConfig?.showExitStatus ?? + globalTerminalConfig?.showExitStatus ?? + DEFAULT_SHOW_EXIT_STATUS, + customAliases: + projectTerminalConfig?.customAliases ?? + globalTerminalConfig?.customAliases ?? + DEFAULT_CUSTOM_ALIASES, + customEnvVars: mergedEnvVars, + rcFileVersion: globalTerminalConfig?.rcFileVersion, + }; +} + export interface TerminalSession { id: string; pty: pty.IPty; @@ -77,6 +214,12 @@ export class TerminalService extends EventEmitter { !!(process.versions && (process.versions as Record).electron) || !!process.env.ELECTRON_RUN_AS_NODE; private useConptyFallback = false; // Track if we need to use winpty fallback on Windows + private settingsService: SettingsService | null = null; + + constructor(settingsService?: SettingsService) { + super(); + this.settingsService = settingsService || null; + } /** * Kill a PTY process with platform-specific handling. @@ -102,37 +245,19 @@ export class TerminalService extends EventEmitter { const platform = os.platform(); const shellPaths = getShellPaths(); - // Helper to get basename handling both path separators - const getBasename = (shellPath: string): string => { - const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\')); - return lastSep >= 0 ? shellPath.slice(lastSep + 1) : shellPath; - }; - - // Helper to get shell args based on shell name - const getShellArgs = (shell: string): string[] => { - const shellName = getBasename(shell).toLowerCase().replace('.exe', ''); - // PowerShell and cmd don't need --login - if (shellName === 'powershell' || shellName === 'pwsh' || shellName === 'cmd') { - return []; - } - // sh doesn't support --login in all implementations - if (shellName === 'sh') { - return []; - } - // bash, zsh, and other POSIX shells support --login - return ['--login']; - }; - // Check if running in WSL - prefer user's shell or bash with --login if (platform === 'linux' && this.isWSL()) { const userShell = process.env.SHELL; if (userShell) { // Try to find userShell in allowed paths for (const allowedShell of shellPaths) { - if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) { + if ( + allowedShell === userShell || + getShellBasename(allowedShell) === getShellBasename(userShell) + ) { try { if (systemPathExists(allowedShell)) { - return { shell: allowedShell, args: getShellArgs(allowedShell) }; + return { shell: allowedShell, args: getShellArgsForPath(allowedShell) }; } } catch { // Path not allowed, continue searching @@ -144,7 +269,7 @@ export class TerminalService extends EventEmitter { for (const shell of shellPaths) { try { if (systemPathExists(shell)) { - return { shell, args: getShellArgs(shell) }; + return { shell, args: getShellArgsForPath(shell) }; } } catch { // Path not allowed, continue @@ -158,10 +283,13 @@ export class TerminalService extends EventEmitter { if (userShell && platform !== 'win32') { // Try to find userShell in allowed paths for (const allowedShell of shellPaths) { - if (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) { + if ( + allowedShell === userShell || + getShellBasename(allowedShell) === getShellBasename(userShell) + ) { try { if (systemPathExists(allowedShell)) { - return { shell: allowedShell, args: getShellArgs(allowedShell) }; + return { shell: allowedShell, args: getShellArgsForPath(allowedShell) }; } } catch { // Path not allowed, continue searching @@ -174,7 +302,7 @@ export class TerminalService extends EventEmitter { for (const shell of shellPaths) { try { if (systemPathExists(shell)) { - return { shell, args: getShellArgs(shell) }; + return { shell, args: getShellArgsForPath(shell) }; } } catch { // Path not allowed or doesn't exist, continue to next @@ -313,8 +441,9 @@ export class TerminalService extends EventEmitter { const id = `term-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; - const { shell: detectedShell, args: shellArgs } = this.detectShell(); + const { shell: detectedShell, args: detectedShellArgs } = this.detectShell(); const shell = options.shell || detectedShell; + let shellArgs = options.shell ? getShellArgsForPath(shell) : [...detectedShellArgs]; // Validate and resolve working directory // Uses secureFs internally to enforce ALLOWED_ROOT_DIRECTORY @@ -332,6 +461,89 @@ export class TerminalService extends EventEmitter { } } + // Terminal config injection (custom prompts, themes) + const terminalConfigEnv: Record = {}; + 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 = { ...cleanEnv, TERM: 'xterm-256color', @@ -341,6 +553,7 @@ export class TerminalService extends EventEmitter { LANG: process.env.LANG || 'en_US.UTF-8', LC_ALL: process.env.LC_ALL || process.env.LANG || 'en_US.UTF-8', ...options.env, + ...terminalConfigEnv, // Apply terminal config env vars last (highest priority) }; logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`); @@ -652,6 +865,44 @@ export class TerminalService extends EventEmitter { return () => this.exitCallbacks.delete(callback); } + /** + * Handle theme change - regenerate RC files with new theme colors + */ + async onThemeChange(projectPath: string, newTheme: string): Promise { + if (!this.settingsService) { + logger.warn('[onThemeChange] SettingsService not available'); + return; + } + + try { + const globalSettings = await this.settingsService.getGlobalSettings(); + const terminalConfig = globalSettings?.terminalConfig; + const projectSettings = await this.settingsService.getProjectSettings(projectPath); + const projectTerminalConfig = projectSettings?.terminalConfig; + const effectiveConfig = buildEffectiveTerminalConfig(terminalConfig, projectTerminalConfig); + + if (effectiveConfig.enabled && terminalConfig) { + const themeColors = getTerminalThemeColors( + newTheme as import('@automaker/types').ThemeMode + ); + const allThemes = getAllTerminalThemes(); + + // Regenerate RC files with new theme + await ensureRcFilesUpToDate( + projectPath, + newTheme as import('@automaker/types').ThemeMode, + effectiveConfig, + themeColors, + allThemes + ); + + logger.info(`[onThemeChange] Regenerated RC files for theme: ${newTheme}`); + } + } catch (error) { + logger.error(`[onThemeChange] Failed to regenerate RC files: ${error}`); + } + } + /** * Clean up all sessions */ @@ -676,9 +927,9 @@ export class TerminalService extends EventEmitter { // Singleton instance let terminalService: TerminalService | null = null; -export function getTerminalService(): TerminalService { +export function getTerminalService(settingsService?: SettingsService): TerminalService { if (!terminalService) { - terminalService = new TerminalService(); + terminalService = new TerminalService(settingsService); } return terminalService; } diff --git a/apps/ui/src/components/views/settings-view/terminal/prompt-preview.tsx b/apps/ui/src/components/views/settings-view/terminal/prompt-preview.tsx new file mode 100644 index 000000000..4315ce5ce --- /dev/null +++ b/apps/ui/src/components/views/settings-view/terminal/prompt-preview.tsx @@ -0,0 +1,283 @@ +/** + * Prompt Preview - Shows a live preview of the custom terminal prompt + */ + +import type { ReactNode } from 'react'; +import { cn } from '@/lib/utils'; +import type { ThemeMode } from '@automaker/types'; +import { getTerminalTheme } from '@/config/terminal-themes'; + +interface PromptPreviewProps { + format: 'standard' | 'minimal' | 'powerline' | 'starship'; + theme: ThemeMode; + showGitBranch: boolean; + showGitStatus: boolean; + showUserHost: boolean; + showPath: boolean; + pathStyle: 'full' | 'short' | 'basename'; + pathDepth: number; + showTime: boolean; + showExitStatus: boolean; + isOmpTheme?: boolean; + promptThemeLabel?: string; + className?: string; +} + +export function PromptPreview({ + format, + theme, + showGitBranch, + showGitStatus, + showUserHost, + showPath, + pathStyle, + pathDepth, + showTime, + showExitStatus, + isOmpTheme = false, + promptThemeLabel, + className, +}: PromptPreviewProps) { + const terminalTheme = getTerminalTheme(theme); + + const formatPath = (inputPath: string) => { + let displayPath = inputPath; + let prefix = ''; + + if (displayPath.startsWith('~/')) { + prefix = '~/'; + displayPath = displayPath.slice(2); + } else if (displayPath.startsWith('/')) { + prefix = '/'; + displayPath = displayPath.slice(1); + } + + const segments = displayPath.split('/').filter((segment) => segment.length > 0); + const depth = Math.max(0, pathDepth); + const trimmedSegments = depth > 0 ? segments.slice(-depth) : segments; + + let formattedSegments = trimmedSegments; + if (pathStyle === 'basename' && trimmedSegments.length > 0) { + formattedSegments = [trimmedSegments[trimmedSegments.length - 1]]; + } else if (pathStyle === 'short') { + formattedSegments = trimmedSegments.map((segment, index) => { + if (index < trimmedSegments.length - 1) { + return segment.slice(0, 1); + } + return segment; + }); + } + + const joined = formattedSegments.join('/'); + if (prefix === '/' && joined.length === 0) { + return '/'; + } + if (prefix === '~/' && joined.length === 0) { + return '~'; + } + return `${prefix}${joined}`; + }; + + // Generate preview text based on format + const renderPrompt = () => { + if (isOmpTheme) { + return ( +
+
+ {promptThemeLabel ?? 'Oh My Posh theme'} +
+
+ Rendered by the oh-my-posh CLI in the terminal. +
+
+ Preview here stays generic to avoid misleading output. +
+
+ ); + } + + 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 ( +
+ {showTime && {time} } + {showUserHost && ( + + {user} + @ + {host}{' '} + + )} + {showPath && {path}} + {gitInfo && {gitInfo}} + {showExitStatus && {status}} + $ + +
+ ); + } + + case 'powerline': { + const powerlineSegments: ReactNode[] = []; + if (showUserHost) { + powerlineSegments.push( + + [{user} + @ + {host}] + + ); + } + if (showPath) { + powerlineSegments.push( + + [{path}] + + ); + } + const powerlineCore = powerlineSegments.flatMap((segment, index) => + index === 0 + ? [segment] + : [ + + ─ + , + segment, + ] + ); + const powerlineExtras: ReactNode[] = []; + if (gitInfo) { + powerlineExtras.push( + + {gitInfo} + + ); + } + if (showTime) { + powerlineExtras.push( + + {time} + + ); + } + if (showExitStatus) { + powerlineExtras.push( + + {status} + + ); + } + const powerlineLine: ReactNode[] = [...powerlineCore]; + if (powerlineExtras.length > 0) { + if (powerlineLine.length > 0) { + powerlineLine.push(' '); + } + powerlineLine.push(...powerlineExtras); + } + + return ( +
+
+ ┌─ + {powerlineLine} +
+
+ └─ + $ + +
+
+ ); + } + + case 'starship': { + return ( +
+
+ {showTime && {time} } + {showUserHost && ( + <> + {user} + @ + {host} + + )} + {showPath && ( + <> + in + {path} + + )} + {branch && ( + <> + on + + {branch} + {dirty} + + + )} + {showExitStatus && {status}} +
+
+ + +
+
+ ); + } + + case 'standard': + default: { + return ( +
+ {showTime && {time} } + {showUserHost && ( + <> + [{user} + @ + {host} + ] + + )} + {showPath && {path}} + {gitInfo && {gitInfo}} + {showExitStatus && {status}} + $ + +
+ ); + } + } + }; + + return ( +
+
Preview
+ {renderPrompt()} +
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/terminal/prompt-theme-presets.ts b/apps/ui/src/components/views/settings-view/terminal/prompt-theme-presets.ts new file mode 100644 index 000000000..48f0ec474 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/terminal/prompt-theme-presets.ts @@ -0,0 +1,253 @@ +import type { TerminalPromptTheme } from '@automaker/types'; + +export const PROMPT_THEME_CUSTOM_ID: TerminalPromptTheme = 'custom'; + +export const OMP_THEME_NAMES = [ + '1_shell', + 'M365Princess', + 'agnoster', + 'agnoster.minimal', + 'agnosterplus', + 'aliens', + 'amro', + 'atomic', + 'atomicBit', + 'avit', + 'blue-owl', + 'blueish', + 'bubbles', + 'bubblesextra', + 'bubblesline', + 'capr4n', + 'catppuccin', + 'catppuccin_frappe', + 'catppuccin_latte', + 'catppuccin_macchiato', + 'catppuccin_mocha', + 'cert', + 'chips', + 'cinnamon', + 'clean-detailed', + 'cloud-context', + 'cloud-native-azure', + 'cobalt2', + 'craver', + 'darkblood', + 'devious-diamonds', + 'di4am0nd', + 'dracula', + 'easy-term', + 'emodipt', + 'emodipt-extend', + 'fish', + 'free-ukraine', + 'froczh', + 'gmay', + 'glowsticks', + 'grandpa-style', + 'gruvbox', + 'half-life', + 'honukai', + 'hotstick.minimal', + 'hul10', + 'hunk', + 'huvix', + 'if_tea', + 'illusi0n', + 'iterm2', + 'jandedobbeleer', + 'jblab_2021', + 'jonnychipz', + 'json', + 'jtracey93', + 'jv_sitecorian', + 'kali', + 'kushal', + 'lambda', + 'lambdageneration', + 'larserikfinholt', + 'lightgreen', + 'marcduiker', + 'markbull', + 'material', + 'microverse-power', + 'mojada', + 'montys', + 'mt', + 'multiverse-neon', + 'negligible', + 'neko', + 'night-owl', + 'nordtron', + 'nu4a', + 'onehalf.minimal', + 'paradox', + 'pararussel', + 'patriksvensson', + 'peru', + 'pixelrobots', + 'plague', + 'poshmon', + 'powerlevel10k_classic', + 'powerlevel10k_lean', + 'powerlevel10k_modern', + 'powerlevel10k_rainbow', + 'powerline', + 'probua.minimal', + 'pure', + 'quick-term', + 'remk', + 'robbyrussell', + 'rudolfs-dark', + 'rudolfs-light', + 'sim-web', + 'slim', + 'slimfat', + 'smoothie', + 'sonicboom_dark', + 'sonicboom_light', + 'sorin', + 'space', + 'spaceship', + 'star', + 'stelbent-compact.minimal', + 'stelbent.minimal', + 'takuya', + 'the-unnamed', + 'thecyberden', + 'tiwahu', + 'tokyo', + 'tokyonight_storm', + 'tonybaloney', + 'uew', + 'unicorn', + 'velvet', + 'wholespace', + 'wopian', + 'xtoys', + 'ys', + 'zash', +] as const; + +type OmpThemeName = (typeof OMP_THEME_NAMES)[number]; + +type PromptFormat = 'standard' | 'minimal' | 'powerline' | 'starship'; + +type PathStyle = 'full' | 'short' | 'basename'; + +export interface PromptThemeConfig { + promptFormat: PromptFormat; + showGitBranch: boolean; + showGitStatus: boolean; + showUserHost: boolean; + showPath: boolean; + pathStyle: PathStyle; + pathDepth: number; + showTime: boolean; + showExitStatus: boolean; +} + +export interface PromptThemePreset { + id: TerminalPromptTheme; + label: string; + description: string; + config: PromptThemeConfig; +} + +const PATH_DEPTH_FULL = 0; +const PATH_DEPTH_TWO = 2; +const PATH_DEPTH_THREE = 3; + +const POWERLINE_HINTS = ['powerline', 'powerlevel10k', 'agnoster', 'bubbles', 'smoothie']; +const MINIMAL_HINTS = ['minimal', 'pure', 'slim', 'negligible']; +const STARSHIP_HINTS = ['spaceship', 'star']; +const SHORT_PATH_HINTS = ['compact', 'lean', 'slim']; +const TIME_HINTS = ['time', 'clock']; +const EXIT_STATUS_HINTS = ['status', 'exit', 'fail', 'error']; + +function toPromptThemeId(name: OmpThemeName): TerminalPromptTheme { + return `omp-${name}` as TerminalPromptTheme; +} + +function formatLabel(name: string): string { + const cleaned = name.replace(/[._-]+/g, ' ').trim(); + return cleaned + .split(' ') + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +function buildPresetConfig(name: OmpThemeName): PromptThemeConfig { + const lower = name.toLowerCase(); + const isPowerline = POWERLINE_HINTS.some((hint) => lower.includes(hint)); + const isMinimal = MINIMAL_HINTS.some((hint) => lower.includes(hint)); + const isStarship = STARSHIP_HINTS.some((hint) => lower.includes(hint)); + let promptFormat: PromptFormat = 'standard'; + + if (isPowerline) { + promptFormat = 'powerline'; + } else if (isMinimal) { + promptFormat = 'minimal'; + } else if (isStarship) { + promptFormat = 'starship'; + } + + const showUserHost = !isMinimal; + const showPath = true; + const pathStyle: PathStyle = isMinimal ? 'short' : 'full'; + let pathDepth = isMinimal ? PATH_DEPTH_THREE : PATH_DEPTH_FULL; + + if (SHORT_PATH_HINTS.some((hint) => lower.includes(hint))) { + pathDepth = PATH_DEPTH_TWO; + } + + if (lower.includes('powerlevel10k')) { + pathDepth = PATH_DEPTH_THREE; + } + + const showTime = TIME_HINTS.some((hint) => lower.includes(hint)); + const showExitStatus = EXIT_STATUS_HINTS.some((hint) => lower.includes(hint)); + + return { + promptFormat, + showGitBranch: true, + showGitStatus: true, + showUserHost, + showPath, + pathStyle, + pathDepth, + showTime, + showExitStatus, + }; +} + +export const PROMPT_THEME_PRESETS: PromptThemePreset[] = OMP_THEME_NAMES.map((name) => ({ + id: toPromptThemeId(name), + label: `${formatLabel(name)} (OMP)`, + description: 'Oh My Posh theme preset', + config: buildPresetConfig(name), +})); + +export function getPromptThemePreset(presetId: TerminalPromptTheme): PromptThemePreset | null { + return PROMPT_THEME_PRESETS.find((preset) => preset.id === presetId) ?? null; +} + +export function getMatchingPromptThemeId(config: PromptThemeConfig): TerminalPromptTheme { + const match = PROMPT_THEME_PRESETS.find((preset) => { + const presetConfig = preset.config; + return ( + presetConfig.promptFormat === config.promptFormat && + presetConfig.showGitBranch === config.showGitBranch && + presetConfig.showGitStatus === config.showGitStatus && + presetConfig.showUserHost === config.showUserHost && + presetConfig.showPath === config.showPath && + presetConfig.pathStyle === config.pathStyle && + presetConfig.pathDepth === config.pathDepth && + presetConfig.showTime === config.showTime && + presetConfig.showExitStatus === config.showExitStatus + ); + }); + + return match?.id ?? PROMPT_THEME_CUSTOM_ID; +} diff --git a/apps/ui/src/components/views/settings-view/terminal/terminal-config-section.tsx b/apps/ui/src/components/views/settings-view/terminal/terminal-config-section.tsx new file mode 100644 index 000000000..ddfd9201d --- /dev/null +++ b/apps/ui/src/components/views/settings-view/terminal/terminal-config-section.tsx @@ -0,0 +1,662 @@ +/** + * Terminal Config Section - Custom terminal configurations with theme synchronization + * + * This component provides UI for enabling custom terminal prompts that automatically + * sync with Automaker's 40 themes. It's an opt-in feature that generates shell configs + * in .automaker/terminal/ without modifying user's existing RC files. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Wand2, GitBranch, Info, Plus, X } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useAppStore } from '@/store/app-store'; +import { toast } from 'sonner'; +import { PromptPreview } from './prompt-preview'; +import type { TerminalPromptTheme } from '@automaker/types'; +import { + PROMPT_THEME_CUSTOM_ID, + PROMPT_THEME_PRESETS, + getMatchingPromptThemeId, + getPromptThemePreset, + type PromptThemeConfig, +} from './prompt-theme-presets'; +import { useUpdateGlobalSettings } from '@/hooks/mutations/use-settings-mutations'; +import { useGlobalSettings } from '@/hooks/queries/use-settings'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; + +export function TerminalConfigSection() { + const PATH_DEPTH_MIN = 0; + const PATH_DEPTH_MAX = 10; + const ENV_VAR_UPDATE_DEBOUNCE_MS = 400; + const ENV_VAR_ID_PREFIX = 'env'; + const TERMINAL_RC_FILE_VERSION = 11; + const { theme } = useAppStore(); + const { data: globalSettings } = useGlobalSettings(); + const updateGlobalSettings = useUpdateGlobalSettings({ showSuccessToast: false }); + const envVarIdRef = useRef(0); + const envVarUpdateTimeoutRef = useRef | 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) => { + 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) => { + 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 + ); + + 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 + ); + + scheduleEnvVarsUpdate(envVarsObject); + }; + + return ( +
+
+
+
+ +
+

+ Custom Terminal Configurations +

+
+

+ 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. +

+
+ +
+ {/* Enable Toggle */} +
+
+ +

+ Create theme-synced shell configs in .automaker/terminal/ +

+
+ +
+ + {terminalConfig.enabled && ( + <> + {/* Info Box */} +
+ +
+ How it works: Custom configs are applied to new terminals only. + Your ~/.bashrc and ~/.zshrc are still loaded first. Close and reopen terminals to + see changes. +
+
+ + {/* Custom Prompt Toggle */} +
+
+ +

+ Override default shell prompt with themed version +

+
+ handleUpdateConfig({ customPrompt: checked })} + /> +
+ + {terminalConfig.customPrompt && ( + <> + {/* Prompt Format */} +
+ + +
+ + {isOmpTheme && ( +
+ +
+ {promptThemePreset?.label ?? 'Oh My Posh theme'} uses the + oh-my-posh CLI for rendering. Ensure it's installed for the full theme. + Prompt format and segment toggles are ignored while an OMP theme is selected. +
+
+ )} + +
+ + +
+ + {/* Git Info Toggles */} +
+
+
+ + +
+ handleUpdateConfig({ showGitBranch: checked })} + disabled={isOmpTheme} + /> +
+ +
+
+ * + +
+ handleUpdateConfig({ showGitStatus: checked })} + disabled={!terminalConfig.showGitBranch || isOmpTheme} + /> +
+
+ + {/* Prompt Segments */} +
+
+
+ + +
+ handleUpdateConfig({ showUserHost: checked })} + disabled={isOmpTheme} + /> +
+ +
+
+ ~/ + +
+ handleUpdateConfig({ showPath: checked })} + disabled={isOmpTheme} + /> +
+ +
+
+ + +
+ handleUpdateConfig({ showTime: checked })} + disabled={isOmpTheme} + /> +
+ +
+
+ + +
+ handleUpdateConfig({ showExitStatus: checked })} + disabled={isOmpTheme} + /> +
+ +
+
+ + +
+ +
+ + + handleUpdateConfig({ + pathDepth: clampPathDepth(Number(event.target.value) || 0), + }) + } + disabled={!terminalConfig.showPath || isOmpTheme} + /> +
+
+
+ + {/* Live Preview */} +
+ + +
+ + )} + + {/* Custom Aliases */} +
+
+ +

+ Add shell aliases (one per line, e.g., alias ll='ls -la') +

+
+