Compare commits

...

17 Commits

Author SHA1 Message Date
Dhanush Santosh
88864ad6bc feature/custom terminal configs (#717)
* feat(terminal): Add core infrastructure for custom terminal configurations

- Add TerminalConfig types to settings schema (global & project-specific)
- Create RC generator with hex-to-xterm-256 color mapping
- Create RC file manager for .automaker/terminal/ directory
- Add terminal theme color data (40 themes) to platform package
- Integrate terminal config injection into TerminalService
- Support bash, zsh, and sh with proper env var injection (BASH_ENV, ZDOTDIR, ENV)
- Add onThemeChange hook for theme synchronization

Part of custom terminal configurations feature implementation.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat(terminal): Wire terminal service with settings service

- Pass SettingsService to TerminalService constructor
- Initialize terminal service with settings service dependency
- Enable terminal config injection to work with actual settings

This completes Steps 1-4 of the terminal configuration plan:
- RC Generator (color mapping, prompt formats)
- RC File Manager (file I/O, atomic writes)
- Settings Schema (GlobalSettings + ProjectSettings)
- Terminal Service Integration (env var injection)

Next steps: Settings UI and theme change hooks.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat(terminal): Add Settings UI and theme change synchronization

Complete Steps 5 & 6 of terminal configuration implementation:

Settings UI Components:
- Add PromptPreview component with live theme-aware rendering
- Add TerminalConfigSection with comprehensive controls:
  * Enable/disable toggle with confirmation dialog
  * Custom prompt toggle
  * Prompt format selector (4 formats)
  * Git branch/status toggles
  * Custom aliases textarea
  * Custom env vars key-value editor with validation
  * Info box explaining behavior
- Integrate into existing TerminalSection

Theme Change Hook:
- Add theme detection in update-global settings route
- Regenerate RC files for all projects when theme changes
- Skip projects with terminal config disabled
- Error handling with per-project logging
- Inject terminal service with settings service dependency

This completes the full terminal configuration feature:
✓ RC Generator (color mapping, prompts)
✓ RC File Manager (file I/O, versioning)
✓ Settings Schema (types, defaults)
✓ Terminal Service Integration (env vars, PTY spawn)
✓ Settings UI (comprehensive controls, preview)
✓ Theme Synchronization (automatic RC regeneration)

New terminals will use custom prompts matching app theme.
Existing terminals unaffected. User RC files preserved.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(terminal): Add error handling and explicit field mapping for terminal config

- Add try-catch block to handleToggleEnabled
- Explicitly set all required terminalConfig fields
- Add console logging for debugging
- Show error toast if update fails
- Include rcFileVersion: 1 in config object

This should fix the issue where the toggle doesn't enable after
clicking OK in the confirmation dialog.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(terminal): Use React Query mutation hook for settings updates

The issue was that `updateGlobalSettings` doesn't exist in the app store.
The correct pattern is to use the `useUpdateGlobalSettings` hook from
use-settings-mutations.ts, which is a React Query mutation.

Changes:
- Import useUpdateGlobalSettings from mutations hook
- Use mutation.mutate() instead of direct function call
- Add proper onSuccess/onError callbacks
- Remove async/await pattern (React Query handles this)

This fixes the toggle not enabling after clicking OK in the confirmation dialog.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(terminal): Use React Query hook for globalSettings instead of store

The root cause: Component was reading globalSettings from the app store,
which doesn't update reactively when the mutation completes.

Solution: Use useGlobalSettings() React Query hook which:
- Automatically refetches when the mutation invalidates the cache
- Triggers re-render with updated data
- Makes the toggle reflect the new state

Now the flow is:
1. User clicks toggle → confirmation dialog
2. Click OK → mutation.mutate() called
3. Mutation succeeds → invalidates queryKeys.settings.global()
4. Query refetches → component re-renders with new globalSettings
5. Toggle shows enabled state ✓

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* debug(terminal): Add detailed logging for terminal config application

Add logging to track:
- When terminal config check happens
- CWD being used
- Global and project enabled states
- Effective enabled state

This will help diagnose why RC files aren't being generated
when opening terminals in Automaker.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Fix terminal rc updates and bash rcfile loading

* feat(terminal): add banner on shell start

* feat(terminal): colorize banner per theme

* chore(terminal): bump rc version for banner colors

* feat(terminal): match banner colors to launcher

* feat(terminal): add prompt customization controls

* feat: integrate oh-my-posh prompt themes

* fix: resolve oh-my-posh theme path

* fix: correct oh-my-posh config invocation

* docs: add terminal theme screenshot

* fix: address review feedback and stabilize e2e test

* ui: split terminal config into separate card

* fix: enable cross-platform Warp terminal detection

- Remove macOS-only platform restriction for Warp
- Add Linux CLI alias 'warp-terminal' (primary on Linux)
- Add CLI launch handler using --cwd flag
- Fixes issue where Warp was not detected on Linux systems

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 20:34:33 +05:30
Shirone
ebc7987988 Merge pull request #720 from noamloewenstern/fix/board-view-concurrency-null-worktree
fix(ui): handle null selectedWorktree in max concurrency handler
2026-02-02 15:31:44 +00:00
Shirone
29b3eef500 Merge pull request #744 from AutoMaker-Org/fix/git-project-initial-branch
fix(server): Use 'main' as default branch for new git projects
2026-02-02 14:20:03 +00:00
Kacper
010e516b0e fix(server): Use 'main' as default branch for new git projects
Git initialization now explicitly specifies --initial-branch=main to match
GitHub's default branch standard (since October 2020). This prevents the
branch name mismatch that caused features to disappear from the UI when
pushing to GitHub.

Fixes #734

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 15:07:43 +01:00
Shirone
00e4712ae7 Merge pull request #743 from AutoMaker-Org/fix/broken-syslinks-on-server
fix(electron): Fix broken symlinks in server bundle preventing app startup
2026-02-02 13:50:39 +00:00
Kacper
4b4ae04fbe refactor: Address PR review feedback for symlink and directory handling
- Use lstatSync with try/catch for robust broken symlink detection
- Remove redundant existsSync check before mkdirSync with recursive: true

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 14:36:24 +01:00
Kacper
04775af561 fix(electron): Fix broken symlinks in server bundle preventing app startup
Fixes #742

This commit resolves two critical issues that prevented the Electron app from starting:

1. **Broken symlinks in server bundle**
   - After npm install, local @automaker/* packages were symlinked in node_modules
   - These symlinks broke after electron-builder packaging since relative paths no longer existed
   - Solution: Added Step 6b in prepare-server.mjs to replace symlinks with real directory copies
   - Added lstatSync and resolve imports to support symlink detection and replacement

2. **electronUserDataWriteFileSync fails on first launch**
   - The userData directory doesn't exist on first app launch
   - Writing .api-key file would fail with ENOENT error
   - Solution: Added directory existence check and creation with { recursive: true } before writing

Files modified:
- apps/ui/scripts/prepare-server.mjs: Added symlink replacement logic after npm install
- libs/platform/src/system-paths.ts: Added parent directory creation in electronUserDataWriteFileSync

Verification: After these fixes, npm run build:electron produces a working app that starts without ERR_MODULE_NOT_FOUND errors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 14:26:59 +01:00
Shirone
b8fa7fc579 Merge pull request #732 from AutoMaker-Org/fix/icon-posiition-on-mac
fix(ui): adjust padding for logo for mac
2026-01-31 12:01:49 +00:00
Shirone
7fb0d0f2ca refactor(ui): Integrate macOS Electron padding logic into ProjectSwitcher
Updated the ProjectSwitcher component to conditionally apply top padding based on the operating system and Electron environment. This change utilizes the newly created MACOS_ELECTRON_TOP_PADDING_CLASS for improved maintainability and consistency across the UI.
2026-01-31 12:54:36 +01:00
Kacper
f15725f28a refactor(ui): Extract macOS Electron padding into shared constant
Extract the hardcoded 'pt-[38px]' magic number into a shared constant
MACOS_ELECTRON_TOP_PADDING_CLASS for better maintainability. This
addresses the PR #732 review feedback from Gemini Code Assist.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 20:43:28 +01:00
Kacper
7d7d152d4e fix(ui): Adjust sidebar padding for macOS Electron compatibility
Updated the sidebar header and navigation components to increase top padding for macOS Electron users from 10px to 38px, ensuring better layout and avoiding overlap with the traffic light controls. This change enhances the user experience on macOS platforms.
2026-01-30 20:36:33 +01:00
Noam Loewenstern
07f777da22 Update apps/ui/src/components/views/board-view.tsx
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-30 02:52:27 +02:00
Noam Loewenstern
b10501ea79 fix(ui): handle null selectedWorktree in max concurrency handler 2026-01-30 02:44:51 +02:00
DhanushSantosh
1a460c301a fix(test): Set HOSTNAME in dev server tests for consistent behavior
Dev server test was failing on non-localhost hostnames (e.g., 'fedora')
because it expected 'localhost' in the URL. Now sets HOSTNAME env var
in test setup and restores it in teardown for consistent test behavior
across all environments.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-28 19:55:23 +05:30
DhanushSantosh
c1f480fe49 fix(ui): Make GitHub Copilot icon theme-aware for light mode visibility
The Copilot icon had a hardcoded white fill that made it invisible on
light theme backgrounds. Changed to use currentColor so it adapts to
theme and respects CSS text color classes.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-28 19:55:08 +05:30
Shirone
ef3f8de33b Merge pull request #715 from OG-Ken/fix/opencode-dynamic-models-404-endpoint
fix: Correct OpenCode dynamic models API endpoint URL
2026-01-27 12:02:33 +00:00
Ken Lopez
d379bf412a fix: Correct OpenCode dynamic models API endpoint URL
The fetchOpencodeModels function was calling '/api/opencode/models' which
returns 404. Changed to '/api/setup/opencode/models' which correctly
returns the dynamic models.

This fixes an issue where enabled OpenCode dynamic models (e.g., local
Ollama models) were not appearing in the Model Defaults dropdown selectors
despite being visible and enabled in the OpenCode Settings page.
2026-01-27 03:06:28 -05:00
33 changed files with 4659 additions and 302 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(); const terminalService = getTerminalService(settingsService);
/** /**
* Authenticate WebSocket upgrade requests * Authenticate WebSocket upgrade requests

View File

@@ -0,0 +1,25 @@
/**
* Terminal Theme Data - Re-export terminal themes from platform package
*
* This module re-exports terminal theme data for use in the server.
*/
import { terminalThemeColors, getTerminalThemeColors as getThemeColors } from '@automaker/platform';
import type { ThemeMode } from '@automaker/types';
import type { TerminalTheme } from '@automaker/platform';
/**
* Get terminal theme colors for a given theme mode
*/
export function getTerminalThemeColors(theme: ThemeMode): TerminalTheme {
return getThemeColors(theme);
}
/**
* Get all terminal themes
*/
export function getAllTerminalThemes(): Record<ThemeMode, TerminalTheme> {
return terminalThemeColors;
}
export default terminalThemeColors;

View File

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

View File

@@ -13,6 +13,14 @@ 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
@@ -24,6 +32,27 @@ 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
@@ -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_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;
@@ -77,6 +214,12 @@ 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.
@@ -102,37 +245,19 @@ 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 (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) { if (
allowedShell === userShell ||
getShellBasename(allowedShell) === getShellBasename(userShell)
) {
try { try {
if (systemPathExists(allowedShell)) { if (systemPathExists(allowedShell)) {
return { shell: allowedShell, args: getShellArgs(allowedShell) }; return { shell: allowedShell, args: getShellArgsForPath(allowedShell) };
} }
} catch { } catch {
// Path not allowed, continue searching // Path not allowed, continue searching
@@ -144,7 +269,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: getShellArgs(shell) }; return { shell, args: getShellArgsForPath(shell) };
} }
} catch { } catch {
// Path not allowed, continue // Path not allowed, continue
@@ -158,10 +283,13 @@ 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 (allowedShell === userShell || getBasename(allowedShell) === getBasename(userShell)) { if (
allowedShell === userShell ||
getShellBasename(allowedShell) === getShellBasename(userShell)
) {
try { try {
if (systemPathExists(allowedShell)) { if (systemPathExists(allowedShell)) {
return { shell: allowedShell, args: getShellArgs(allowedShell) }; return { shell: allowedShell, args: getShellArgsForPath(allowedShell) };
} }
} catch { } catch {
// Path not allowed, continue searching // Path not allowed, continue searching
@@ -174,7 +302,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: getShellArgs(shell) }; return { shell, args: getShellArgsForPath(shell) };
} }
} catch { } catch {
// Path not allowed or doesn't exist, continue to next // 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 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; 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
@@ -332,6 +461,89 @@ export class TerminalService extends EventEmitter {
} }
} }
// Terminal config injection (custom prompts, themes)
const terminalConfigEnv: Record<string, string> = {};
if (this.settingsService) {
try {
logger.info(
`[createSession] Checking terminal config for session ${id}, cwd: ${options.cwd || cwd}`
);
const globalSettings = await this.settingsService.getGlobalSettings();
const projectSettings = options.cwd
? await this.settingsService.getProjectSettings(options.cwd)
: null;
const globalTerminalConfig = globalSettings?.terminalConfig;
const projectTerminalConfig = projectSettings?.terminalConfig;
const effectiveConfig = buildEffectiveTerminalConfig(
globalTerminalConfig,
projectTerminalConfig
);
logger.info(
`[createSession] Terminal config: global.enabled=${globalTerminalConfig?.enabled}, project.enabled=${projectTerminalConfig?.enabled}`
);
logger.info(
`[createSession] Terminal config effective enabled: ${effectiveConfig.enabled}`
);
if (effectiveConfig.enabled && globalTerminalConfig) {
const currentTheme = globalSettings?.theme || 'dark';
const themeColors = getTerminalThemeColors(currentTheme);
const allThemes = getAllTerminalThemes();
const promptTheme =
projectTerminalConfig?.promptTheme ?? globalTerminalConfig.promptTheme;
const ompThemeName = resolveOmpThemeName(promptTheme);
// Ensure RC files are up to date
await ensureRcFilesUpToDate(
options.cwd || cwd,
currentTheme,
effectiveConfig,
themeColors,
allThemes
);
// Set shell-specific env vars
const shellName = getShellBasename(shell).toLowerCase();
if (ompThemeName && effectiveConfig.customPrompt) {
terminalConfigEnv[OMP_THEME_ENV_VAR] = ompThemeName;
}
if (shellName.includes(SHELL_NAME_BASH)) {
const bashRcFilePath = getRcFilePath(options.cwd || cwd, SHELL_NAME_BASH);
terminalConfigEnv.BASH_ENV = bashRcFilePath;
terminalConfigEnv.AUTOMAKER_CUSTOM_PROMPT = effectiveConfig.customPrompt
? 'true'
: 'false';
terminalConfigEnv.AUTOMAKER_THEME = currentTheme;
shellArgs = applyBashRcFileArgs(shellArgs, bashRcFilePath);
} else if (shellName.includes(SHELL_NAME_ZSH)) {
terminalConfigEnv.ZDOTDIR = getTerminalDir(options.cwd || cwd);
terminalConfigEnv.AUTOMAKER_CUSTOM_PROMPT = effectiveConfig.customPrompt
? 'true'
: 'false';
terminalConfigEnv.AUTOMAKER_THEME = currentTheme;
} else if (shellName === SHELL_NAME_SH) {
terminalConfigEnv.ENV = getRcFilePath(options.cwd || cwd, SHELL_NAME_SH);
terminalConfigEnv.AUTOMAKER_CUSTOM_PROMPT = effectiveConfig.customPrompt
? 'true'
: 'false';
terminalConfigEnv.AUTOMAKER_THEME = currentTheme;
}
// Add custom env vars from config
Object.assign(terminalConfigEnv, effectiveConfig.customEnvVars);
logger.info(
`[createSession] Terminal config enabled for session ${id}, shell: ${shellName}`
);
}
} catch (error) {
logger.warn(`[createSession] Failed to apply terminal config: ${error}`);
}
}
const env: Record<string, string> = { const env: Record<string, string> = {
...cleanEnv, ...cleanEnv,
TERM: 'xterm-256color', TERM: 'xterm-256color',
@@ -341,6 +553,7 @@ 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}`);
@@ -652,6 +865,44 @@ 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
*/ */
@@ -676,9 +927,9 @@ export class TerminalService extends EventEmitter {
// Singleton instance // Singleton instance
let terminalService: TerminalService | null = null; let terminalService: TerminalService | null = null;
export function getTerminalService(): TerminalService { export function getTerminalService(settingsService?: SettingsService): TerminalService {
if (!terminalService) { if (!terminalService) {
terminalService = new TerminalService(); terminalService = new TerminalService(settingsService);
} }
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 // Initialize git repo with 'main' as the default branch (matching GitHub's standard)
await execAsync('git init', { cwd: tmpDir }); await execAsync('git init --initial-branch=main', { 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,9 +38,6 @@ 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,7 +14,8 @@ 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-'));
await execAsync('git init', { cwd: repoPath }); // Initialize with 'main' as the default branch (matching GitHub's standard)
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,11 +30,16 @@ 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 });
@@ -56,6 +61,13 @@ 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 } from 'fs'; import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync, readFileSync, lstatSync } from 'fs';
import { join, dirname } from 'path'; import { join, dirname, resolve } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@@ -112,6 +112,29 @@ 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 } from '@/lib/utils'; import { cn, isMac } 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,9 +11,12 @@ 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 { SIDEBAR_FEATURE_FLAGS } from '@/components/layout/sidebar/constants'; import {
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 } from '@/lib/electron'; import { getElectronAPI, isElectron } 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';
@@ -279,7 +282,12 @@ export function ProjectSwitcher() {
data-testid="project-switcher" data-testid="project-switcher"
> >
{/* Automaker Logo and Version */} {/* Automaker Logo and Version */}
<div className="flex flex-col items-center pt-3 pb-2 px-2"> <div
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,6 +6,7 @@ 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 {
@@ -89,7 +90,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() && 'pt-[10px]' isMac && isElectron() && MACOS_ELECTRON_TOP_PADDING_CLASS
)} )}
> >
<Tooltip> <Tooltip>
@@ -240,7 +241,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() && 'pt-[10px]' isMac && isElectron() && MACOS_ELECTRON_TOP_PADDING_CLASS
)} )}
> >
{/* Header with logo and project dropdown */} {/* Header with logo and project dropdown */}

View File

@@ -3,7 +3,9 @@ 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 } from '@/lib/utils'; import { cn, isMac } 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';
@@ -117,7 +119,12 @@ 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
sidebarStyle === 'discord' ? 'pt-3' : 'mt-1' // Extra padding for macOS Electron to avoid traffic light overlap
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,5 +1,11 @@
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,9 +116,8 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
}, },
copilot: { copilot: {
viewBox: '0 0 98 96', viewBox: '0 0 98 96',
// Official GitHub Octocat logo mark // Official GitHub Octocat logo mark (theme-aware via currentColor)
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,8 +1275,10 @@ export function BoardView() {
maxConcurrency={maxConcurrency} maxConcurrency={maxConcurrency}
runningAgentsCount={runningAutoTasks.length} runningAgentsCount={runningAutoTasks.length}
onConcurrencyChange={(newMaxConcurrency) => { onConcurrencyChange={(newMaxConcurrency) => {
if (currentProject && selectedWorktree) { if (currentProject) {
const branchName = selectedWorktree.isMain ? null : selectedWorktree.branch; // If selectedWorktree is undefined or it's the main worktree, branchName will be null.
// 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

@@ -0,0 +1,283 @@
/**
* Prompt Preview - Shows a live preview of the custom terminal prompt
*/
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import type { ThemeMode } from '@automaker/types';
import { getTerminalTheme } from '@/config/terminal-themes';
interface PromptPreviewProps {
format: 'standard' | 'minimal' | 'powerline' | 'starship';
theme: ThemeMode;
showGitBranch: boolean;
showGitStatus: boolean;
showUserHost: boolean;
showPath: boolean;
pathStyle: 'full' | 'short' | 'basename';
pathDepth: number;
showTime: boolean;
showExitStatus: boolean;
isOmpTheme?: boolean;
promptThemeLabel?: string;
className?: string;
}
export function PromptPreview({
format,
theme,
showGitBranch,
showGitStatus,
showUserHost,
showPath,
pathStyle,
pathDepth,
showTime,
showExitStatus,
isOmpTheme = false,
promptThemeLabel,
className,
}: PromptPreviewProps) {
const terminalTheme = getTerminalTheme(theme);
const formatPath = (inputPath: string) => {
let displayPath = inputPath;
let prefix = '';
if (displayPath.startsWith('~/')) {
prefix = '~/';
displayPath = displayPath.slice(2);
} else if (displayPath.startsWith('/')) {
prefix = '/';
displayPath = displayPath.slice(1);
}
const segments = displayPath.split('/').filter((segment) => segment.length > 0);
const depth = Math.max(0, pathDepth);
const trimmedSegments = depth > 0 ? segments.slice(-depth) : segments;
let formattedSegments = trimmedSegments;
if (pathStyle === 'basename' && trimmedSegments.length > 0) {
formattedSegments = [trimmedSegments[trimmedSegments.length - 1]];
} else if (pathStyle === 'short') {
formattedSegments = trimmedSegments.map((segment, index) => {
if (index < trimmedSegments.length - 1) {
return segment.slice(0, 1);
}
return segment;
});
}
const joined = formattedSegments.join('/');
if (prefix === '/' && joined.length === 0) {
return '/';
}
if (prefix === '~/' && joined.length === 0) {
return '~';
}
return `${prefix}${joined}`;
};
// Generate preview text based on format
const renderPrompt = () => {
if (isOmpTheme) {
return (
<div className="font-mono text-sm leading-relaxed space-y-2">
<div style={{ color: terminalTheme.magenta }}>
{promptThemeLabel ?? 'Oh My Posh theme'}
</div>
<div className="text-xs text-muted-foreground">
Rendered by the oh-my-posh CLI in the terminal.
</div>
<div className="text-xs text-muted-foreground">
Preview here stays generic to avoid misleading output.
</div>
</div>
);
}
const user = 'user';
const host = 'automaker';
const path = formatPath('~/projects/automaker');
const branch = showGitBranch ? 'main' : null;
const dirty = showGitStatus && showGitBranch ? '*' : '';
const time = showTime ? '[14:32]' : '';
const status = showExitStatus ? '✗ 1' : '';
const gitInfo = branch ? ` (${branch}${dirty})` : '';
switch (format) {
case 'minimal': {
return (
<div className="font-mono text-sm leading-relaxed">
{showTime && <span style={{ color: terminalTheme.magenta }}>{time} </span>}
{showUserHost && (
<span style={{ color: terminalTheme.cyan }}>
{user}
<span style={{ color: terminalTheme.foreground }}>@</span>
<span style={{ color: terminalTheme.blue }}>{host}</span>{' '}
</span>
)}
{showPath && <span style={{ color: terminalTheme.yellow }}>{path}</span>}
{gitInfo && <span style={{ color: terminalTheme.magenta }}>{gitInfo}</span>}
{showExitStatus && <span style={{ color: terminalTheme.red }}> {status}</span>}
<span style={{ color: terminalTheme.green }}> $</span>
<span className="ml-1 animate-pulse"></span>
</div>
);
}
case 'powerline': {
const powerlineSegments: ReactNode[] = [];
if (showUserHost) {
powerlineSegments.push(
<span key="user-host" style={{ color: terminalTheme.cyan }}>
[{user}
<span style={{ color: terminalTheme.foreground }}>@</span>
<span style={{ color: terminalTheme.blue }}>{host}</span>]
</span>
);
}
if (showPath) {
powerlineSegments.push(
<span key="path" style={{ color: terminalTheme.yellow }}>
[{path}]
</span>
);
}
const powerlineCore = powerlineSegments.flatMap((segment, index) =>
index === 0
? [segment]
: [
<span key={`sep-${index}`} style={{ color: terminalTheme.cyan }}>
</span>,
segment,
]
);
const powerlineExtras: ReactNode[] = [];
if (gitInfo) {
powerlineExtras.push(
<span key="git" style={{ color: terminalTheme.magenta }}>
{gitInfo}
</span>
);
}
if (showTime) {
powerlineExtras.push(
<span key="time" style={{ color: terminalTheme.magenta }}>
{time}
</span>
);
}
if (showExitStatus) {
powerlineExtras.push(
<span key="status" style={{ color: terminalTheme.red }}>
{status}
</span>
);
}
const powerlineLine: ReactNode[] = [...powerlineCore];
if (powerlineExtras.length > 0) {
if (powerlineLine.length > 0) {
powerlineLine.push(' ');
}
powerlineLine.push(...powerlineExtras);
}
return (
<div className="font-mono text-sm leading-relaxed space-y-1">
<div>
<span style={{ color: terminalTheme.cyan }}></span>
{powerlineLine}
</div>
<div>
<span style={{ color: terminalTheme.cyan }}></span>
<span style={{ color: terminalTheme.green }}>$</span>
<span className="ml-1 animate-pulse"></span>
</div>
</div>
);
}
case 'starship': {
return (
<div className="font-mono text-sm leading-relaxed space-y-1">
<div>
{showTime && <span style={{ color: terminalTheme.magenta }}>{time} </span>}
{showUserHost && (
<>
<span style={{ color: terminalTheme.cyan }}>{user}</span>
<span style={{ color: terminalTheme.foreground }}>@</span>
<span style={{ color: terminalTheme.blue }}>{host}</span>
</>
)}
{showPath && (
<>
<span style={{ color: terminalTheme.foreground }}> in </span>
<span style={{ color: terminalTheme.yellow }}>{path}</span>
</>
)}
{branch && (
<>
<span style={{ color: terminalTheme.foreground }}> on </span>
<span style={{ color: terminalTheme.magenta }}>
{branch}
{dirty}
</span>
</>
)}
{showExitStatus && <span style={{ color: terminalTheme.red }}> {status}</span>}
</div>
<div>
<span style={{ color: terminalTheme.green }}></span>
<span className="ml-1 animate-pulse"></span>
</div>
</div>
);
}
case 'standard':
default: {
return (
<div className="font-mono text-sm leading-relaxed">
{showTime && <span style={{ color: terminalTheme.magenta }}>{time} </span>}
{showUserHost && (
<>
<span style={{ color: terminalTheme.cyan }}>[{user}</span>
<span style={{ color: terminalTheme.foreground }}>@</span>
<span style={{ color: terminalTheme.blue }}>{host}</span>
<span style={{ color: terminalTheme.cyan }}>]</span>
</>
)}
{showPath && <span style={{ color: terminalTheme.yellow }}> {path}</span>}
{gitInfo && <span style={{ color: terminalTheme.magenta }}>{gitInfo}</span>}
{showExitStatus && <span style={{ color: terminalTheme.red }}> {status}</span>}
<span style={{ color: terminalTheme.green }}> $</span>
<span className="ml-1 animate-pulse"></span>
</div>
);
}
}
};
return (
<div
className={cn(
'rounded-lg border p-4',
'bg-[var(--terminal-bg)] text-[var(--terminal-fg)]',
'shadow-inner',
className
)}
style={
{
'--terminal-bg': terminalTheme.background,
'--terminal-fg': terminalTheme.foreground,
} as React.CSSProperties
}
>
<div className="mb-2 text-xs text-muted-foreground opacity-70">Preview</div>
{renderPrompt()}
</div>
);
}

View File

@@ -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;
}

View File

@@ -0,0 +1,662 @@
/**
* Terminal Config Section - Custom terminal configurations with theme synchronization
*
* This component provides UI for enabling custom terminal prompts that automatically
* sync with Automaker's 40 themes. It's an opt-in feature that generates shell configs
* in .automaker/terminal/ without modifying user's existing RC files.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Wand2, GitBranch, Info, Plus, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
import { PromptPreview } from './prompt-preview';
import type { TerminalPromptTheme } from '@automaker/types';
import {
PROMPT_THEME_CUSTOM_ID,
PROMPT_THEME_PRESETS,
getMatchingPromptThemeId,
getPromptThemePreset,
type PromptThemeConfig,
} from './prompt-theme-presets';
import { useUpdateGlobalSettings } from '@/hooks/mutations/use-settings-mutations';
import { useGlobalSettings } from '@/hooks/queries/use-settings';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
export function TerminalConfigSection() {
const PATH_DEPTH_MIN = 0;
const PATH_DEPTH_MAX = 10;
const ENV_VAR_UPDATE_DEBOUNCE_MS = 400;
const ENV_VAR_ID_PREFIX = 'env';
const TERMINAL_RC_FILE_VERSION = 11;
const { theme } = useAppStore();
const { data: globalSettings } = useGlobalSettings();
const updateGlobalSettings = useUpdateGlobalSettings({ showSuccessToast: false });
const envVarIdRef = useRef(0);
const envVarUpdateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const createEnvVarEntry = useCallback(
(key = '', value = '') => {
envVarIdRef.current += 1;
return {
id: `${ENV_VAR_ID_PREFIX}-${envVarIdRef.current}`,
key,
value,
};
},
[ENV_VAR_ID_PREFIX]
);
const [localEnvVars, setLocalEnvVars] = useState<
Array<{ id: string; key: string; value: string }>
>(() =>
Object.entries(globalSettings?.terminalConfig?.customEnvVars || {}).map(([key, value]) =>
createEnvVarEntry(key, value)
)
);
const [showEnableConfirm, setShowEnableConfirm] = useState(false);
const clampPathDepth = (value: number) =>
Math.min(PATH_DEPTH_MAX, Math.max(PATH_DEPTH_MIN, value));
const defaultTerminalConfig = {
enabled: false,
customPrompt: true,
promptFormat: 'standard' as const,
promptTheme: PROMPT_THEME_CUSTOM_ID,
showGitBranch: true,
showGitStatus: true,
showUserHost: true,
showPath: true,
pathStyle: 'full' as const,
pathDepth: PATH_DEPTH_MIN,
showTime: false,
showExitStatus: false,
customAliases: '',
customEnvVars: {},
};
const terminalConfig = {
...defaultTerminalConfig,
...globalSettings?.terminalConfig,
customAliases:
globalSettings?.terminalConfig?.customAliases ?? defaultTerminalConfig.customAliases,
customEnvVars:
globalSettings?.terminalConfig?.customEnvVars ?? defaultTerminalConfig.customEnvVars,
};
const promptThemeConfig: PromptThemeConfig = {
promptFormat: terminalConfig.promptFormat,
showGitBranch: terminalConfig.showGitBranch,
showGitStatus: terminalConfig.showGitStatus,
showUserHost: terminalConfig.showUserHost,
showPath: terminalConfig.showPath,
pathStyle: terminalConfig.pathStyle,
pathDepth: terminalConfig.pathDepth,
showTime: terminalConfig.showTime,
showExitStatus: terminalConfig.showExitStatus,
};
const storedPromptTheme = terminalConfig.promptTheme;
const activePromptThemeId =
storedPromptTheme === PROMPT_THEME_CUSTOM_ID
? PROMPT_THEME_CUSTOM_ID
: (storedPromptTheme ?? getMatchingPromptThemeId(promptThemeConfig));
const isOmpTheme =
storedPromptTheme !== undefined && storedPromptTheme !== PROMPT_THEME_CUSTOM_ID;
const promptThemePreset = isOmpTheme
? getPromptThemePreset(storedPromptTheme as TerminalPromptTheme)
: null;
const applyEnabledUpdate = (enabled: boolean) => {
// Ensure all required fields are present
const updatedConfig = {
enabled,
customPrompt: terminalConfig.customPrompt,
promptFormat: terminalConfig.promptFormat,
showGitBranch: terminalConfig.showGitBranch,
showGitStatus: terminalConfig.showGitStatus,
showUserHost: terminalConfig.showUserHost,
showPath: terminalConfig.showPath,
pathStyle: terminalConfig.pathStyle,
pathDepth: terminalConfig.pathDepth,
showTime: terminalConfig.showTime,
showExitStatus: terminalConfig.showExitStatus,
promptTheme: terminalConfig.promptTheme ?? PROMPT_THEME_CUSTOM_ID,
customAliases: terminalConfig.customAliases,
customEnvVars: terminalConfig.customEnvVars,
rcFileVersion: TERMINAL_RC_FILE_VERSION,
};
updateGlobalSettings.mutate(
{ terminalConfig: updatedConfig },
{
onSuccess: () => {
toast.success(
enabled ? 'Custom terminal configs enabled' : 'Custom terminal configs disabled',
{
description: enabled
? 'New terminals will use custom prompts'
: '.automaker/terminal/ will be cleaned up',
}
);
},
onError: (error) => {
console.error('[TerminalConfig] Failed to update settings:', error);
toast.error('Failed to update terminal config', {
description: error instanceof Error ? error.message : 'Unknown error',
});
},
}
);
};
useEffect(() => {
setLocalEnvVars(
Object.entries(globalSettings?.terminalConfig?.customEnvVars || {}).map(([key, value]) =>
createEnvVarEntry(key, value)
)
);
}, [createEnvVarEntry, globalSettings?.terminalConfig?.customEnvVars]);
useEffect(() => {
return () => {
if (envVarUpdateTimeoutRef.current) {
clearTimeout(envVarUpdateTimeoutRef.current);
}
};
}, []);
const handleToggleEnabled = async (enabled: boolean) => {
if (enabled) {
setShowEnableConfirm(true);
return;
}
applyEnabledUpdate(false);
};
const handleUpdateConfig = (updates: Partial<typeof terminalConfig>) => {
const nextPromptTheme = updates.promptTheme ?? PROMPT_THEME_CUSTOM_ID;
updateGlobalSettings.mutate(
{
terminalConfig: {
...terminalConfig,
...updates,
promptTheme: nextPromptTheme,
},
},
{
onError: (error) => {
console.error('[TerminalConfig] Failed to update settings:', error);
toast.error('Failed to update terminal config', {
description: error instanceof Error ? error.message : 'Unknown error',
});
},
}
);
};
const scheduleEnvVarsUpdate = (envVarsObject: Record<string, string>) => {
if (envVarUpdateTimeoutRef.current) {
clearTimeout(envVarUpdateTimeoutRef.current);
}
envVarUpdateTimeoutRef.current = setTimeout(() => {
handleUpdateConfig({ customEnvVars: envVarsObject });
}, ENV_VAR_UPDATE_DEBOUNCE_MS);
};
const handlePromptThemeChange = (themeId: string) => {
if (themeId === PROMPT_THEME_CUSTOM_ID) {
handleUpdateConfig({ promptTheme: PROMPT_THEME_CUSTOM_ID });
return;
}
const preset = getPromptThemePreset(themeId as TerminalPromptTheme);
if (!preset) {
handleUpdateConfig({ promptTheme: PROMPT_THEME_CUSTOM_ID });
return;
}
handleUpdateConfig({
...preset.config,
promptTheme: preset.id,
});
};
const addEnvVar = () => {
setLocalEnvVars([...localEnvVars, createEnvVarEntry()]);
};
const removeEnvVar = (id: string) => {
const newVars = localEnvVars.filter((envVar) => envVar.id !== id);
setLocalEnvVars(newVars);
// Update settings
const envVarsObject = newVars.reduce(
(acc, { key, value }) => {
if (key) acc[key] = value;
return acc;
},
{} as Record<string, string>
);
scheduleEnvVarsUpdate(envVarsObject);
};
const updateEnvVar = (id: string, field: 'key' | 'value', newValue: string) => {
const newVars = localEnvVars.map((envVar) =>
envVar.id === id ? { ...envVar, [field]: newValue } : envVar
);
setLocalEnvVars(newVars);
// Validate and update settings (only if key is valid)
const envVarsObject = newVars.reduce(
(acc, { key, value }) => {
// Only include vars with valid keys (alphanumeric + underscore)
if (key && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
acc[key] = value;
}
return acc;
},
{} as Record<string, string>
);
scheduleEnvVarsUpdate(envVarsObject);
};
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-purple-500/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-purple-500/20 to-purple-600/10 flex items-center justify-center border border-purple-500/20">
<Wand2 className="w-5 h-5 text-purple-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Custom Terminal Configurations
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Generate custom shell prompts that automatically sync with your app theme. Opt-in feature
that creates configs in .automaker/terminal/ without modifying your existing RC files.
</p>
</div>
<div className="p-6 space-y-6">
{/* Enable Toggle */}
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="text-foreground font-medium">Enable Custom Configurations</Label>
<p className="text-xs text-muted-foreground">
Create theme-synced shell configs in .automaker/terminal/
</p>
</div>
<Switch checked={terminalConfig.enabled} onCheckedChange={handleToggleEnabled} />
</div>
{terminalConfig.enabled && (
<>
{/* Info Box */}
<div className="rounded-lg border border-purple-500/20 bg-purple-500/5 p-3 flex gap-2">
<Info className="h-4 w-4 text-purple-500 flex-shrink-0 mt-0.5" />
<div className="text-xs text-foreground/80">
<strong>How it works:</strong> Custom configs are applied to new terminals only.
Your ~/.bashrc and ~/.zshrc are still loaded first. Close and reopen terminals to
see changes.
</div>
</div>
{/* Custom Prompt Toggle */}
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="text-foreground font-medium">Custom Prompt</Label>
<p className="text-xs text-muted-foreground">
Override default shell prompt with themed version
</p>
</div>
<Switch
checked={terminalConfig.customPrompt}
onCheckedChange={(checked) => handleUpdateConfig({ customPrompt: checked })}
/>
</div>
{terminalConfig.customPrompt && (
<>
{/* Prompt Format */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Prompt Theme (Oh My Posh)</Label>
<Select value={activePromptThemeId} onValueChange={handlePromptThemeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={PROMPT_THEME_CUSTOM_ID}>
<div className="space-y-0.5">
<div>Custom</div>
<div className="text-xs text-muted-foreground">
Hand-tuned configuration
</div>
</div>
</SelectItem>
{PROMPT_THEME_PRESETS.map((preset) => (
<SelectItem key={preset.id} value={preset.id}>
<div className="space-y-0.5">
<div>{preset.label}</div>
<div className="text-xs text-muted-foreground">
{preset.description}
</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isOmpTheme && (
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3 flex gap-2">
<Info className="h-4 w-4 text-emerald-500 flex-shrink-0 mt-0.5" />
<div className="text-xs text-foreground/80">
<strong>{promptThemePreset?.label ?? 'Oh My Posh theme'}</strong> uses the
oh-my-posh CLI for rendering. Ensure it&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,6 +24,7 @@ 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 {
@@ -53,253 +54,258 @@ export function TerminalSection() {
const { terminals, isRefreshing, refresh } = useAvailableTerminals(); const { terminals, isRefreshing, refresh } = useAvailableTerminals();
return ( return (
<div <div className="space-y-6">
className={cn( <div
'rounded-2xl overflow-hidden', className={cn(
'border border-border/50', 'rounded-2xl overflow-hidden',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl', 'border border-border/50',
'shadow-sm shadow-black/5' 'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
)} 'shadow-sm shadow-black/5'
> )}
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent"> >
<div className="flex items-center gap-3 mb-2"> <div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-green-500/20 to-green-600/10 flex items-center justify-center border border-green-500/20"> <div className="flex items-center gap-3 mb-2">
<SquareTerminal className="w-5 h-5 text-green-500" /> <div className="w-9 h-9 rounded-xl bg-gradient-to-br from-green-500/20 to-green-600/10 flex items-center justify-center border border-green-500/20">
<SquareTerminal className="w-5 h-5 text-green-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Terminal</h2>
</div> </div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Terminal</h2> <p className="text-sm text-muted-foreground/80 ml-12">
</div> Customize terminal appearance and behavior. Theme follows your app theme in Appearance
<p className="text-sm text-muted-foreground/80 ml-12"> settings.
Customize terminal appearance and behavior. Theme follows your app theme in Appearance
settings.
</p>
</div>
<div className="p-6 space-y-6">
{/* Default External Terminal */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Default External Terminal</Label>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={refresh}
disabled={isRefreshing}
title="Refresh available terminals"
aria-label="Refresh available terminals"
>
<RefreshCw className={cn('w-3.5 h-3.5', isRefreshing && 'animate-spin')} />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Terminal to use when selecting "Open in Terminal" from the worktree menu
</p> </p>
<Select </div>
value={defaultTerminalId ?? 'integrated'} <div className="p-6 space-y-6">
onValueChange={(value) => { {/* Default External Terminal */}
setDefaultTerminalId(value === 'integrated' ? null : value); <div className="space-y-3">
toast.success( <div className="flex items-center justify-between">
value === 'integrated' <Label className="text-foreground font-medium">Default External Terminal</Label>
? 'Integrated terminal set as default' <Button
: 'Default terminal changed' variant="ghost"
); size="sm"
}} className="h-7 w-7 p-0"
> onClick={refresh}
<SelectTrigger className="w-full"> disabled={isRefreshing}
<SelectValue placeholder="Select a terminal" /> title="Refresh available terminals"
</SelectTrigger> aria-label="Refresh available terminals"
<SelectContent> >
<SelectItem value="integrated"> <RefreshCw className={cn('w-3.5 h-3.5', isRefreshing && 'animate-spin')} />
<span className="flex items-center gap-2"> </Button>
<Terminal className="w-4 h-4" /> </div>
Integrated Terminal <p className="text-xs text-muted-foreground">
</span> Terminal to use when selecting "Open in Terminal" from the worktree menu
</SelectItem>
{terminals.map((terminal) => {
const TerminalIcon = getTerminalIcon(terminal.id);
return (
<SelectItem key={terminal.id} value={terminal.id}>
<span className="flex items-center gap-2">
<TerminalIcon className="w-4 h-4" />
{terminal.name}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
{terminals.length === 0 && !isRefreshing && (
<p className="text-xs text-muted-foreground italic">
No external terminals detected. Click refresh to re-scan.
</p> </p>
)} <Select
</div> value={defaultTerminalId ?? 'integrated'}
onValueChange={(value) => {
{/* Default Open Mode */} setDefaultTerminalId(value === 'integrated' ? null : value);
<div className="space-y-3"> toast.success(
<Label className="text-foreground font-medium">Default Open Mode</Label> value === 'integrated'
<p className="text-xs text-muted-foreground"> ? 'Integrated terminal set as default'
How to open the integrated terminal when using "Open in Terminal" from the worktree menu : 'Default terminal changed'
</p> );
<Select }}
value={openTerminalMode} >
onValueChange={(value: 'newTab' | 'split') => { <SelectTrigger className="w-full">
setOpenTerminalMode(value); <SelectValue placeholder="Select a terminal" />
toast.success( </SelectTrigger>
value === 'newTab' <SelectContent>
? 'New terminals will open in new tabs' <SelectItem value="integrated">
: 'New terminals will split the current tab' <span className="flex items-center gap-2">
); <Terminal className="w-4 h-4" />
}} Integrated Terminal
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="newTab">
<span className="flex items-center gap-2">
<SquarePlus className="w-4 h-4" />
New Tab
</span>
</SelectItem>
<SelectItem value="split">
<span className="flex items-center gap-2">
<SplitSquareHorizontal className="w-4 h-4" />
Split Current Tab
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Font Family */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Font Family</Label>
<Select
value={fontFamily || DEFAULT_FONT_VALUE}
onValueChange={(value) => {
setTerminalFontFamily(value);
toast.info('Font family changed', {
description: 'Restart terminal for changes to take effect',
});
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Default (Menlo / Monaco)" />
</SelectTrigger>
<SelectContent>
{TERMINAL_FONT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<span
style={{
fontFamily: option.value === DEFAULT_FONT_VALUE ? undefined : option.value,
}}
>
{option.label}
</span> </span>
</SelectItem> </SelectItem>
))} {terminals.map((terminal) => {
</SelectContent> const TerminalIcon = getTerminalIcon(terminal.id);
</Select> return (
</div> <SelectItem key={terminal.id} value={terminal.id}>
<span className="flex items-center gap-2">
{/* Default Font Size */} <TerminalIcon className="w-4 h-4" />
<div className="space-y-3"> {terminal.name}
<div className="flex items-center justify-between"> </span>
<Label className="text-foreground font-medium">Default Font Size</Label> </SelectItem>
<span className="text-sm text-muted-foreground">{defaultFontSize}px</span> );
})}
</SelectContent>
</Select>
{terminals.length === 0 && !isRefreshing && (
<p className="text-xs text-muted-foreground italic">
No external terminals detected. Click refresh to re-scan.
</p>
)}
</div> </div>
<Slider
value={[defaultFontSize]}
min={8}
max={32}
step={1}
onValueChange={([value]) => setTerminalDefaultFontSize(value)}
className="flex-1"
/>
</div>
{/* Line Height */} {/* Default Open Mode */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <Label className="text-foreground font-medium">Default Open Mode</Label>
<Label className="text-foreground font-medium">Line Height</Label>
<span className="text-sm text-muted-foreground">{lineHeight.toFixed(1)}</span>
</div>
<Slider
value={[lineHeight]}
min={1.0}
max={2.0}
step={0.1}
onValueChange={([value]) => {
setTerminalLineHeight(value);
}}
onValueCommit={() => {
toast.info('Line height changed', {
description: 'Restart terminal for changes to take effect',
});
}}
className="flex-1"
/>
</div>
{/* Scrollback Lines */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Scrollback Buffer</Label>
<span className="text-sm text-muted-foreground">
{(scrollbackLines / 1000).toFixed(0)}k lines
</span>
</div>
<Slider
value={[scrollbackLines]}
min={1000}
max={100000}
step={1000}
onValueChange={([value]) => setTerminalScrollbackLines(value)}
onValueCommit={() => {
toast.info('Scrollback changed', {
description: 'Restart terminal for changes to take effect',
});
}}
className="flex-1"
/>
</div>
{/* Default Run Script */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Default Run Script</Label>
<p className="text-xs text-muted-foreground">
Command to run automatically when opening a new terminal (e.g., "claude", "codex")
</p>
<Input
value={defaultRunScript}
onChange={(e) => setTerminalDefaultRunScript(e.target.value)}
placeholder="e.g., claude, codex, npm run dev"
className="bg-accent/30 border-border/50"
/>
</div>
{/* Screen Reader Mode */}
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="text-foreground font-medium">Screen Reader Mode</Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Enable accessibility mode for screen readers How to open the integrated terminal when using "Open in Terminal" from the worktree
menu
</p> </p>
<Select
value={openTerminalMode}
onValueChange={(value: 'newTab' | 'split') => {
setOpenTerminalMode(value);
toast.success(
value === 'newTab'
? 'New terminals will open in new tabs'
: 'New terminals will split the current tab'
);
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="newTab">
<span className="flex items-center gap-2">
<SquarePlus className="w-4 h-4" />
New Tab
</span>
</SelectItem>
<SelectItem value="split">
<span className="flex items-center gap-2">
<SplitSquareHorizontal className="w-4 h-4" />
Split Current Tab
</span>
</SelectItem>
</SelectContent>
</Select>
</div> </div>
<Switch
checked={screenReaderMode} {/* Font Family */}
onCheckedChange={(checked) => { <div className="space-y-3">
setTerminalScreenReaderMode(checked); <Label className="text-foreground font-medium">Font Family</Label>
toast.success( <Select
checked ? 'Screen reader mode enabled' : 'Screen reader mode disabled', value={fontFamily || DEFAULT_FONT_VALUE}
{ onValueChange={(value) => {
setTerminalFontFamily(value);
toast.info('Font family changed', {
description: 'Restart terminal for changes to take effect', description: 'Restart terminal for changes to take effect',
} });
); }}
}} >
/> <SelectTrigger className="w-full">
<SelectValue placeholder="Default (Menlo / Monaco)" />
</SelectTrigger>
<SelectContent>
{TERMINAL_FONT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<span
style={{
fontFamily: option.value === DEFAULT_FONT_VALUE ? undefined : option.value,
}}
>
{option.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Default Font Size */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Default Font Size</Label>
<span className="text-sm text-muted-foreground">{defaultFontSize}px</span>
</div>
<Slider
value={[defaultFontSize]}
min={8}
max={32}
step={1}
onValueChange={([value]) => setTerminalDefaultFontSize(value)}
className="flex-1"
/>
</div>
{/* Line Height */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Line Height</Label>
<span className="text-sm text-muted-foreground">{lineHeight.toFixed(1)}</span>
</div>
<Slider
value={[lineHeight]}
min={1.0}
max={2.0}
step={0.1}
onValueChange={([value]) => {
setTerminalLineHeight(value);
}}
onValueCommit={() => {
toast.info('Line height changed', {
description: 'Restart terminal for changes to take effect',
});
}}
className="flex-1"
/>
</div>
{/* Scrollback Lines */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Scrollback Buffer</Label>
<span className="text-sm text-muted-foreground">
{(scrollbackLines / 1000).toFixed(0)}k lines
</span>
</div>
<Slider
value={[scrollbackLines]}
min={1000}
max={100000}
step={1000}
onValueChange={([value]) => setTerminalScrollbackLines(value)}
onValueCommit={() => {
toast.info('Scrollback changed', {
description: 'Restart terminal for changes to take effect',
});
}}
className="flex-1"
/>
</div>
{/* Default Run Script */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Default Run Script</Label>
<p className="text-xs text-muted-foreground">
Command to run automatically when opening a new terminal (e.g., "claude", "codex")
</p>
<Input
value={defaultRunScript}
onChange={(e) => setTerminalDefaultRunScript(e.target.value)}
placeholder="e.g., claude, codex, npm run dev"
className="bg-accent/30 border-border/50"
/>
</div>
{/* Screen Reader Mode */}
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="text-foreground font-medium">Screen Reader Mode</Label>
<p className="text-xs text-muted-foreground">
Enable accessibility mode for screen readers
</p>
</div>
<Switch
checked={screenReaderMode}
onCheckedChange={(checked) => {
setTerminalScreenReaderMode(checked);
toast.success(
checked ? 'Screen reader mode enabled' : 'Screen reader mode disabled',
{
description: 'Restart terminal for changes to take effect',
}
);
}}
/>
</div>
</div> </div>
</div> </div>
<TerminalConfigSection />
</div> </div>
); );
} }

View File

@@ -2512,7 +2512,7 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
authMethod?: string; authMethod?: string;
}>; }>;
error?: string; error?: string;
}>('/api/opencode/models'); }>('/api/setup/opencode/models');
if (data.success && data.models) { if (data.success && data.models) {
// Filter out Bedrock models // Filter out Bedrock models

View File

@@ -80,6 +80,7 @@ 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 () => {
@@ -88,7 +89,7 @@ test.describe('Edit Feature', () => {
hasText: originalDescription, hasText: originalDescription,
}); });
expect(await featureCard.count()).toBeGreaterThan(0); expect(await featureCard.count()).toBeGreaterThan(0);
}).toPass({ timeout: 10000 }); }).toPass({ timeout: 20000 });
// Get the feature ID from the card // Get the feature ID from the card
const featureCard = page const featureCard = page

BIN
docs/pr/terminal-omp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -0,0 +1,632 @@
# 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,3 +186,37 @@ 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

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

@@ -0,0 +1,972 @@
/**
* 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,6 +750,9 @@ 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

@@ -0,0 +1,468 @@
/**
* 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', cliCommand: 'warp-cli',
cliAliases: ['warp-terminal', 'warp'],
macAppName: 'Warp', macAppName: 'Warp',
platform: 'darwin',
}, },
{ {
id: 'ghostty', id: 'ghostty',
@@ -476,6 +476,11 @@ 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

@@ -0,0 +1,100 @@
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

@@ -0,0 +1,55 @@
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,14 +27,16 @@ 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 (16): dark, retro, dracula, nord, monokai, tokyonight, solarized, * - Dark themes (20): 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,
* - Light themes (16): light, cream, solarizedlight, github, paper, rose, mint, * ember, ayu-dark, ayu-mirage, matcha
* lavender, sand, sky, peach, snow, sepia, gruvboxlight, nordlight, blossom * - Light themes (20): light, cream, solarizedlight, github, paper, rose, mint,
* lavender, sand, sky, peach, snow, sepia, gruvboxlight, nordlight, blossom,
* ayu-light, onelight, bluloco, feather
*/ */
export type ThemeMode = export type ThemeMode =
| 'system' | 'system'
// Dark themes (16) // Dark themes (20)
| 'dark' | 'dark'
| 'retro' | 'retro'
| 'dracula' | 'dracula'
@@ -51,7 +53,11 @@ export type ThemeMode =
| 'gray' | 'gray'
| 'forest' | 'forest'
| 'ocean' | 'ocean'
// Light themes (16) | 'ember'
| 'ayu-dark'
| 'ayu-mirage'
| 'matcha'
// Light themes (20)
| 'light' | 'light'
| 'cream' | 'cream'
| 'solarizedlight' | 'solarizedlight'
@@ -67,7 +73,138 @@ 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';
@@ -840,6 +977,39 @@ 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 */
@@ -1245,6 +1415,33 @@ 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,7 +9,11 @@ set -e
# ============================================================================ # ============================================================================
# CONFIGURATION & CONSTANTS # CONFIGURATION & CONSTANTS
# ============================================================================ # ============================================================================
export $(grep -v '^#' .env | xargs) if [ -f .env ]; then
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"
@@ -1154,7 +1158,9 @@ fi
# Execute the appropriate command # Execute the appropriate command
case $MODE in case $MODE in
web) web)
export $(grep -v '^#' .env | xargs) if [ -f .env ]; then
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"