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>
This commit is contained in:
Dhanush Santosh
2026-02-03 20:34:33 +05:30
committed by GitHub
parent ebc7987988
commit 88864ad6bc
20 changed files with 4571 additions and 277 deletions

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 { useAvailableTerminals } from '@/components/views/board-view/worktree-panel/hooks/use-available-terminals';
import { getTerminalIcon } from '@/components/icons/terminal-icons';
import { TerminalConfigSection } from './terminal-config-section';
export function TerminalSection() {
const {
@@ -53,253 +54,258 @@ export function TerminalSection() {
const { terminals, isRefreshing, refresh } = useAvailableTerminals();
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-green-500/20 to-green-600/10 flex items-center justify-center border border-green-500/20">
<SquareTerminal className="w-5 h-5 text-green-500" />
<div className="space-y-6">
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-green-500/20 to-green-600/10 flex items-center justify-center border border-green-500/20">
<SquareTerminal className="w-5 h-5 text-green-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Terminal</h2>
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">Terminal</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Customize terminal appearance and behavior. Theme follows your app theme in Appearance
settings.
</p>
</div>
<div className="p-6 space-y-6">
{/* Default External Terminal */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Default External Terminal</Label>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={refresh}
disabled={isRefreshing}
title="Refresh available terminals"
aria-label="Refresh available terminals"
>
<RefreshCw className={cn('w-3.5 h-3.5', isRefreshing && 'animate-spin')} />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Terminal to use when selecting "Open in Terminal" from the worktree menu
<p className="text-sm text-muted-foreground/80 ml-12">
Customize terminal appearance and behavior. Theme follows your app theme in Appearance
settings.
</p>
<Select
value={defaultTerminalId ?? 'integrated'}
onValueChange={(value) => {
setDefaultTerminalId(value === 'integrated' ? null : value);
toast.success(
value === 'integrated'
? 'Integrated terminal set as default'
: 'Default terminal changed'
);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a terminal" />
</SelectTrigger>
<SelectContent>
<SelectItem value="integrated">
<span className="flex items-center gap-2">
<Terminal className="w-4 h-4" />
Integrated Terminal
</span>
</SelectItem>
{terminals.map((terminal) => {
const TerminalIcon = getTerminalIcon(terminal.id);
return (
<SelectItem key={terminal.id} value={terminal.id}>
<span className="flex items-center gap-2">
<TerminalIcon className="w-4 h-4" />
{terminal.name}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
{terminals.length === 0 && !isRefreshing && (
<p className="text-xs text-muted-foreground italic">
No external terminals detected. Click refresh to re-scan.
</div>
<div className="p-6 space-y-6">
{/* Default External Terminal */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Default External Terminal</Label>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={refresh}
disabled={isRefreshing}
title="Refresh available terminals"
aria-label="Refresh available terminals"
>
<RefreshCw className={cn('w-3.5 h-3.5', isRefreshing && 'animate-spin')} />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Terminal to use when selecting "Open in Terminal" from the worktree menu
</p>
)}
</div>
{/* Default Open Mode */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Default Open Mode</Label>
<p className="text-xs text-muted-foreground">
How to open the integrated terminal when using "Open in Terminal" from the worktree menu
</p>
<Select
value={openTerminalMode}
onValueChange={(value: 'newTab' | 'split') => {
setOpenTerminalMode(value);
toast.success(
value === 'newTab'
? 'New terminals will open in new tabs'
: 'New terminals will split the current tab'
);
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="newTab">
<span className="flex items-center gap-2">
<SquarePlus className="w-4 h-4" />
New Tab
</span>
</SelectItem>
<SelectItem value="split">
<span className="flex items-center gap-2">
<SplitSquareHorizontal className="w-4 h-4" />
Split Current Tab
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Font Family */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Font Family</Label>
<Select
value={fontFamily || DEFAULT_FONT_VALUE}
onValueChange={(value) => {
setTerminalFontFamily(value);
toast.info('Font family changed', {
description: 'Restart terminal for changes to take effect',
});
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Default (Menlo / Monaco)" />
</SelectTrigger>
<SelectContent>
{TERMINAL_FONT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<span
style={{
fontFamily: option.value === DEFAULT_FONT_VALUE ? undefined : option.value,
}}
>
{option.label}
<Select
value={defaultTerminalId ?? 'integrated'}
onValueChange={(value) => {
setDefaultTerminalId(value === 'integrated' ? null : value);
toast.success(
value === 'integrated'
? 'Integrated terminal set as default'
: 'Default terminal changed'
);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a terminal" />
</SelectTrigger>
<SelectContent>
<SelectItem value="integrated">
<span className="flex items-center gap-2">
<Terminal className="w-4 h-4" />
Integrated Terminal
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Default Font Size */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Default Font Size</Label>
<span className="text-sm text-muted-foreground">{defaultFontSize}px</span>
{terminals.map((terminal) => {
const TerminalIcon = getTerminalIcon(terminal.id);
return (
<SelectItem key={terminal.id} value={terminal.id}>
<span className="flex items-center gap-2">
<TerminalIcon className="w-4 h-4" />
{terminal.name}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
{terminals.length === 0 && !isRefreshing && (
<p className="text-xs text-muted-foreground italic">
No external terminals detected. Click refresh to re-scan.
</p>
)}
</div>
<Slider
value={[defaultFontSize]}
min={8}
max={32}
step={1}
onValueChange={([value]) => setTerminalDefaultFontSize(value)}
className="flex-1"
/>
</div>
{/* Line Height */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Line Height</Label>
<span className="text-sm text-muted-foreground">{lineHeight.toFixed(1)}</span>
</div>
<Slider
value={[lineHeight]}
min={1.0}
max={2.0}
step={0.1}
onValueChange={([value]) => {
setTerminalLineHeight(value);
}}
onValueCommit={() => {
toast.info('Line height changed', {
description: 'Restart terminal for changes to take effect',
});
}}
className="flex-1"
/>
</div>
{/* Scrollback Lines */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Scrollback Buffer</Label>
<span className="text-sm text-muted-foreground">
{(scrollbackLines / 1000).toFixed(0)}k lines
</span>
</div>
<Slider
value={[scrollbackLines]}
min={1000}
max={100000}
step={1000}
onValueChange={([value]) => setTerminalScrollbackLines(value)}
onValueCommit={() => {
toast.info('Scrollback changed', {
description: 'Restart terminal for changes to take effect',
});
}}
className="flex-1"
/>
</div>
{/* Default Run Script */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Default Run Script</Label>
<p className="text-xs text-muted-foreground">
Command to run automatically when opening a new terminal (e.g., "claude", "codex")
</p>
<Input
value={defaultRunScript}
onChange={(e) => setTerminalDefaultRunScript(e.target.value)}
placeholder="e.g., claude, codex, npm run dev"
className="bg-accent/30 border-border/50"
/>
</div>
{/* Screen Reader Mode */}
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="text-foreground font-medium">Screen Reader Mode</Label>
{/* Default Open Mode */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Default Open Mode</Label>
<p className="text-xs text-muted-foreground">
Enable accessibility mode for screen readers
How to open the integrated terminal when using "Open in Terminal" from the worktree
menu
</p>
<Select
value={openTerminalMode}
onValueChange={(value: 'newTab' | 'split') => {
setOpenTerminalMode(value);
toast.success(
value === 'newTab'
? 'New terminals will open in new tabs'
: 'New terminals will split the current tab'
);
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="newTab">
<span className="flex items-center gap-2">
<SquarePlus className="w-4 h-4" />
New Tab
</span>
</SelectItem>
<SelectItem value="split">
<span className="flex items-center gap-2">
<SplitSquareHorizontal className="w-4 h-4" />
Split Current Tab
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
<Switch
checked={screenReaderMode}
onCheckedChange={(checked) => {
setTerminalScreenReaderMode(checked);
toast.success(
checked ? 'Screen reader mode enabled' : 'Screen reader mode disabled',
{
{/* Font Family */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Font Family</Label>
<Select
value={fontFamily || DEFAULT_FONT_VALUE}
onValueChange={(value) => {
setTerminalFontFamily(value);
toast.info('Font family changed', {
description: 'Restart terminal for changes to take effect',
}
);
}}
/>
});
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Default (Menlo / Monaco)" />
</SelectTrigger>
<SelectContent>
{TERMINAL_FONT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
<span
style={{
fontFamily: option.value === DEFAULT_FONT_VALUE ? undefined : option.value,
}}
>
{option.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Default Font Size */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Default Font Size</Label>
<span className="text-sm text-muted-foreground">{defaultFontSize}px</span>
</div>
<Slider
value={[defaultFontSize]}
min={8}
max={32}
step={1}
onValueChange={([value]) => setTerminalDefaultFontSize(value)}
className="flex-1"
/>
</div>
{/* Line Height */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Line Height</Label>
<span className="text-sm text-muted-foreground">{lineHeight.toFixed(1)}</span>
</div>
<Slider
value={[lineHeight]}
min={1.0}
max={2.0}
step={0.1}
onValueChange={([value]) => {
setTerminalLineHeight(value);
}}
onValueCommit={() => {
toast.info('Line height changed', {
description: 'Restart terminal for changes to take effect',
});
}}
className="flex-1"
/>
</div>
{/* Scrollback Lines */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Scrollback Buffer</Label>
<span className="text-sm text-muted-foreground">
{(scrollbackLines / 1000).toFixed(0)}k lines
</span>
</div>
<Slider
value={[scrollbackLines]}
min={1000}
max={100000}
step={1000}
onValueChange={([value]) => setTerminalScrollbackLines(value)}
onValueCommit={() => {
toast.info('Scrollback changed', {
description: 'Restart terminal for changes to take effect',
});
}}
className="flex-1"
/>
</div>
{/* Default Run Script */}
<div className="space-y-3">
<Label className="text-foreground font-medium">Default Run Script</Label>
<p className="text-xs text-muted-foreground">
Command to run automatically when opening a new terminal (e.g., "claude", "codex")
</p>
<Input
value={defaultRunScript}
onChange={(e) => setTerminalDefaultRunScript(e.target.value)}
placeholder="e.g., claude, codex, npm run dev"
className="bg-accent/30 border-border/50"
/>
</div>
{/* Screen Reader Mode */}
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="text-foreground font-medium">Screen Reader Mode</Label>
<p className="text-xs text-muted-foreground">
Enable accessibility mode for screen readers
</p>
</div>
<Switch
checked={screenReaderMode}
onCheckedChange={(checked) => {
setTerminalScreenReaderMode(checked);
toast.success(
checked ? 'Screen reader mode enabled' : 'Screen reader mode disabled',
{
description: 'Restart terminal for changes to take effect',
}
);
}}
/>
</div>
</div>
</div>
<TerminalConfigSection />
</div>
);
}

View File

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