Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7e90328
feat(terminal): Add core infrastructure for custom terminal configura…
DhanushSantosh Jan 28, 2026
cea405f
feat(terminal): Wire terminal service with settings service
DhanushSantosh Jan 28, 2026
13457de
feat(terminal): Add Settings UI and theme change synchronization
DhanushSantosh Jan 28, 2026
4f28286
fix(terminal): Add error handling and explicit field mapping for term…
DhanushSantosh Jan 28, 2026
691c181
fix(terminal): Use React Query mutation hook for settings updates
DhanushSantosh Jan 28, 2026
8d2d6d8
fix(terminal): Use React Query hook for globalSettings instead of store
DhanushSantosh Jan 28, 2026
f07fbc0
debug(terminal): Add detailed logging for terminal config application
DhanushSantosh Jan 28, 2026
efad219
Fix terminal rc updates and bash rcfile loading
DhanushSantosh Jan 28, 2026
7d31a05
feat(terminal): add banner on shell start
DhanushSantosh Jan 28, 2026
92cbb42
feat(terminal): colorize banner per theme
DhanushSantosh Jan 28, 2026
a44d425
chore(terminal): bump rc version for banner colors
DhanushSantosh Jan 28, 2026
b1387d2
feat(terminal): match banner colors to launcher
DhanushSantosh Jan 28, 2026
ca5a16f
feat(terminal): add prompt customization controls
DhanushSantosh Jan 28, 2026
01d59d5
feat: integrate oh-my-posh prompt themes
DhanushSantosh Jan 28, 2026
b3829f6
fix: resolve oh-my-posh theme path
DhanushSantosh Jan 28, 2026
942e6ba
fix: correct oh-my-posh config invocation
DhanushSantosh Jan 28, 2026
b818a45
docs: add terminal theme screenshot
DhanushSantosh Jan 28, 2026
a204bc1
fix: address review feedback and stabilize e2e test
DhanushSantosh Jan 28, 2026
02659b8
ui: split terminal config into separate card
DhanushSantosh Jan 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ const server = createServer(app);
// WebSocket servers using noServer mode for proper multi-path support
const wss = new WebSocketServer({ noServer: true });
const terminalWss = new WebSocketServer({ noServer: true });
const terminalService = getTerminalService();
const terminalService = getTerminalService(settingsService);

/**
* Authenticate WebSocket upgrade requests
Expand Down
25 changes: 25 additions & 0 deletions apps/server/src/lib/terminal-themes-data.ts
Original file line number Diff line number Diff line change
@@ -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;
36 changes: 36 additions & 0 deletions apps/server/src/routes/settings/routes/update-global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { GlobalSettings } from '../../../types/settings.js';
import { getErrorMessage, logError, logger } from '../common.js';
import { setLogLevel, LogLevel } from '@automaker/utils';
import { setRequestLoggingEnabled } from '../../../index.js';
import { getTerminalService } from '../../../services/terminal-service.js';

/**
* Map server log level string to LogLevel enum
Expand Down Expand Up @@ -57,13 +58,48 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}`
);

// Get old settings to detect theme changes
const oldSettings = await settingsService.getGlobalSettings();
const oldTheme = oldSettings?.theme;

logger.info('[SERVER_SETTINGS_UPDATE] Calling updateGlobalSettings...');
const settings = await settingsService.updateGlobalSettings(updates);
logger.info(
'[SERVER_SETTINGS_UPDATE] Update complete, projects count:',
settings.projects?.length ?? 0
);

// Handle theme change - regenerate terminal RC files for all projects
if ('theme' in updates && updates.theme && updates.theme !== oldTheme) {
const terminalService = getTerminalService(settingsService);
const newTheme = updates.theme;

logger.info(
`[TERMINAL_CONFIG] Theme changed from ${oldTheme} to ${newTheme}, regenerating RC files`
);

// Regenerate RC files for all projects with terminal config enabled
const projects = settings.projects || [];
for (const project of projects) {
try {
const projectSettings = await settingsService.getProjectSettings(project.path);
// Check if terminal config is enabled (global or project-specific)
const terminalConfigEnabled =
projectSettings.terminalConfig?.enabled !== false &&
settings.terminalConfig?.enabled === true;

if (terminalConfigEnabled) {
await terminalService.onThemeChange(project.path, newTheme);
logger.info(`[TERMINAL_CONFIG] Regenerated RC files for project: ${project.name}`);
}
} catch (error) {
logger.warn(
`[TERMINAL_CONFIG] Failed to regenerate RC files for project ${project.name}: ${error}`
);
}
}
}

// Apply server log level if it was updated
if ('serverLogLevel' in updates && updates.serverLogLevel) {
const level = LOG_LEVEL_MAP[updates.serverLogLevel];
Expand Down
229 changes: 224 additions & 5 deletions apps/server/src/services/terminal-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ import * as path from 'path';
// to enforce ALLOWED_ROOT_DIRECTORY security boundary
import * as secureFs from '../lib/secure-fs.js';
import { createLogger } from '@automaker/utils';
import type { SettingsService } from './settings-service.js';
import { getTerminalThemeColors, getAllTerminalThemes } from '../lib/terminal-themes-data.js';
import {
getRcFilePath,
getTerminalDir,
ensureRcFilesUpToDate,
type TerminalConfig,
} from '@automaker/platform';

const logger = createLogger('Terminal');
// System paths module handles shell binary checks and WSL detection
Expand All @@ -24,6 +32,22 @@ import {
getShellPaths,
} from '@automaker/platform';

const BASH_LOGIN_ARG = '--login';
const BASH_RCFILE_ARG = '--rcfile';
const SHELL_NAME_BASH = 'bash';
const SHELL_NAME_ZSH = 'zsh';
const SHELL_NAME_SH = 'sh';
const DEFAULT_SHOW_USER_HOST = true;
const DEFAULT_SHOW_PATH = true;
const DEFAULT_SHOW_TIME = false;
const DEFAULT_SHOW_EXIT_STATUS = false;
const DEFAULT_PATH_DEPTH = 0;
const DEFAULT_PATH_STYLE: TerminalConfig['pathStyle'] = 'full';
const DEFAULT_CUSTOM_PROMPT = true;
const DEFAULT_PROMPT_FORMAT: TerminalConfig['promptFormat'] = 'standard';
const DEFAULT_SHOW_GIT_BRANCH = true;
const DEFAULT_SHOW_GIT_STATUS = true;

// Maximum scrollback buffer size (characters)
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal

Expand All @@ -42,6 +66,40 @@ let maxSessions = parseInt(process.env.TERMINAL_MAX_SESSIONS || '1000', 10);
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive input
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency

function applyBashRcFileArgs(args: string[], rcFilePath: string): string[] {
const sanitizedArgs: string[] = [];

for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === BASH_LOGIN_ARG) {
continue;
}
if (arg === BASH_RCFILE_ARG) {
index += 1;
continue;
}
sanitizedArgs.push(arg);
}

sanitizedArgs.push(BASH_RCFILE_ARG, rcFilePath);
return sanitizedArgs;
}

function normalizePathStyle(
pathStyle: TerminalConfig['pathStyle'] | undefined
): TerminalConfig['pathStyle'] {
if (pathStyle === 'short' || pathStyle === 'basename') {
return pathStyle;
}
return DEFAULT_PATH_STYLE;
}

function normalizePathDepth(pathDepth: number | undefined): number {
const depth =
typeof pathDepth === 'number' && Number.isFinite(pathDepth) ? pathDepth : DEFAULT_PATH_DEPTH;
return Math.max(DEFAULT_PATH_DEPTH, Math.floor(depth));
}

export interface TerminalSession {
id: string;
pty: pty.IPty;
Expand Down Expand Up @@ -77,6 +135,12 @@ export class TerminalService extends EventEmitter {
!!(process.versions && (process.versions as Record<string, string>).electron) ||
!!process.env.ELECTRON_RUN_AS_NODE;
private useConptyFallback = false; // Track if we need to use winpty fallback on Windows
private settingsService: SettingsService | null = null;

constructor(settingsService?: SettingsService) {
super();
this.settingsService = settingsService || null;
}

/**
* Kill a PTY process with platform-specific handling.
Expand Down Expand Up @@ -116,11 +180,11 @@ export class TerminalService extends EventEmitter {
return [];
}
// sh doesn't support --login in all implementations
if (shellName === 'sh') {
if (shellName === SHELL_NAME_SH) {
return [];
}
// bash, zsh, and other POSIX shells support --login
return ['--login'];
return [BASH_LOGIN_ARG];
};

// Check if running in WSL - prefer user's shell or bash with --login
Expand Down Expand Up @@ -313,8 +377,9 @@ export class TerminalService extends EventEmitter {

const id = `term-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;

const { shell: detectedShell, args: shellArgs } = this.detectShell();
const { shell: detectedShell, args: detectedShellArgs } = this.detectShell();
const shell = options.shell || detectedShell;
let shellArgs = [...detectedShellArgs];

// Validate and resolve working directory
// Uses secureFs internally to enforce ALLOWED_ROOT_DIRECTORY
Expand All @@ -332,6 +397,107 @@ 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;

// Merge global and project terminal configs
const globalTerminalConfig = globalSettings?.terminalConfig;
const projectTerminalConfig = projectSettings?.terminalConfig;

logger.info(
`[createSession] Terminal config: global.enabled=${globalTerminalConfig?.enabled}, project.enabled=${projectTerminalConfig?.enabled}`
);

// Determine if terminal config is enabled
const enabled =
projectTerminalConfig?.enabled !== undefined
? projectTerminalConfig.enabled
: globalTerminalConfig?.enabled || false;

logger.info(`[createSession] Terminal config effective enabled: ${enabled}`);

if (enabled && globalTerminalConfig) {
const currentTheme = globalSettings?.theme || 'dark';
const themeColors = getTerminalThemeColors(currentTheme);
const allThemes = getAllTerminalThemes();

// Full config object (global + project overrides)
const effectiveConfig: TerminalConfig = {
enabled: true,
customPrompt: globalTerminalConfig.customPrompt ?? DEFAULT_CUSTOM_PROMPT,
promptFormat: globalTerminalConfig.promptFormat ?? DEFAULT_PROMPT_FORMAT,
showGitBranch: globalTerminalConfig.showGitBranch ?? DEFAULT_SHOW_GIT_BRANCH,
showGitStatus: globalTerminalConfig.showGitStatus ?? DEFAULT_SHOW_GIT_STATUS,
showUserHost: globalTerminalConfig.showUserHost ?? DEFAULT_SHOW_USER_HOST,
showPath: globalTerminalConfig.showPath ?? DEFAULT_SHOW_PATH,
pathStyle: normalizePathStyle(globalTerminalConfig.pathStyle),
pathDepth: normalizePathDepth(globalTerminalConfig.pathDepth),
showTime: globalTerminalConfig.showTime ?? DEFAULT_SHOW_TIME,
showExitStatus: globalTerminalConfig.showExitStatus ?? DEFAULT_SHOW_EXIT_STATUS,
customAliases:
projectTerminalConfig?.customAliases || globalTerminalConfig.customAliases,
customEnvVars: {
...globalTerminalConfig.customEnvVars,
...projectTerminalConfig?.customEnvVars,
},
rcFileVersion: globalTerminalConfig.rcFileVersion,
};

// Ensure RC files are up to date
await ensureRcFilesUpToDate(
options.cwd || cwd,
currentTheme,
effectiveConfig,
themeColors,
allThemes
);

// Set shell-specific env vars
const shellName = path.basename(shell).toLowerCase();

if (shellName.includes(SHELL_NAME_BASH)) {
const bashRcFilePath = getRcFilePath(options.cwd || cwd, SHELL_NAME_BASH);
terminalConfigEnv.BASH_ENV = bashRcFilePath;
terminalConfigEnv.AUTOMAKER_CUSTOM_PROMPT = effectiveConfig.customPrompt
? 'true'
: 'false';
terminalConfigEnv.AUTOMAKER_THEME = currentTheme;
shellArgs = applyBashRcFileArgs(shellArgs, bashRcFilePath);
} else if (shellName.includes(SHELL_NAME_ZSH)) {
terminalConfigEnv.ZDOTDIR = getTerminalDir(options.cwd || cwd);
terminalConfigEnv.AUTOMAKER_CUSTOM_PROMPT = effectiveConfig.customPrompt
? 'true'
: 'false';
terminalConfigEnv.AUTOMAKER_THEME = currentTheme;
} else if (shellName === SHELL_NAME_SH) {
terminalConfigEnv.ENV = getRcFilePath(options.cwd || cwd, SHELL_NAME_SH);
terminalConfigEnv.AUTOMAKER_CUSTOM_PROMPT = effectiveConfig.customPrompt
? 'true'
: 'false';
terminalConfigEnv.AUTOMAKER_THEME = currentTheme;
}

// Add custom env vars from config
Object.assign(terminalConfigEnv, effectiveConfig.customEnvVars);

logger.info(
`[createSession] Terminal config enabled for session ${id}, shell: ${shellName}`
);
}
} catch (error) {
logger.warn(`[createSession] Failed to apply terminal config: ${error}`);
}
}

const env: Record<string, string> = {
...cleanEnv,
TERM: 'xterm-256color',
Expand All @@ -341,6 +507,7 @@ export class TerminalService extends EventEmitter {
LANG: process.env.LANG || 'en_US.UTF-8',
LC_ALL: process.env.LC_ALL || process.env.LANG || 'en_US.UTF-8',
...options.env,
...terminalConfigEnv, // Apply terminal config env vars last (highest priority)
};

logger.info(`Creating session ${id} with shell: ${shell} in ${cwd}`);
Expand Down Expand Up @@ -652,6 +819,58 @@ export class TerminalService extends EventEmitter {
return () => this.exitCallbacks.delete(callback);
}

/**
* Handle theme change - regenerate RC files with new theme colors
*/
async onThemeChange(projectPath: string, newTheme: string): Promise<void> {
if (!this.settingsService) {
logger.warn('[onThemeChange] SettingsService not available');
return;
}

try {
const globalSettings = await this.settingsService.getGlobalSettings();
const terminalConfig = globalSettings?.terminalConfig;

if (terminalConfig?.enabled) {
const themeColors = getTerminalThemeColors(
newTheme as import('@automaker/types').ThemeMode
);
const allThemes = getAllTerminalThemes();

// Regenerate RC files with new theme
const effectiveConfig: TerminalConfig = {
enabled: true,
customPrompt: terminalConfig.customPrompt ?? DEFAULT_CUSTOM_PROMPT,
promptFormat: terminalConfig.promptFormat ?? DEFAULT_PROMPT_FORMAT,
showGitBranch: terminalConfig.showGitBranch ?? DEFAULT_SHOW_GIT_BRANCH,
showGitStatus: terminalConfig.showGitStatus ?? DEFAULT_SHOW_GIT_STATUS,
showUserHost: terminalConfig.showUserHost ?? DEFAULT_SHOW_USER_HOST,
showPath: terminalConfig.showPath ?? DEFAULT_SHOW_PATH,
pathStyle: normalizePathStyle(terminalConfig.pathStyle),
pathDepth: normalizePathDepth(terminalConfig.pathDepth),
showTime: terminalConfig.showTime ?? DEFAULT_SHOW_TIME,
showExitStatus: terminalConfig.showExitStatus ?? DEFAULT_SHOW_EXIT_STATUS,
customAliases: terminalConfig.customAliases,
customEnvVars: terminalConfig.customEnvVars,
rcFileVersion: terminalConfig.rcFileVersion,
};

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
*/
Expand All @@ -676,9 +895,9 @@ export class TerminalService extends EventEmitter {
// Singleton instance
let terminalService: TerminalService | null = null;

export function getTerminalService(): TerminalService {
export function getTerminalService(settingsService?: SettingsService): TerminalService {
if (!terminalService) {
terminalService = new TerminalService();
terminalService = new TerminalService(settingsService);
}
return terminalService;
}
Loading