diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5aa5635cc..a7ca4f9aa 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -23,42 +23,42 @@ This allows you to test production-like builds with real users before releasing
```bash
# Development variant (default)
-npm run ios:dev
+yarn ios:dev
# Preview variant
-npm run ios:preview
+yarn ios:preview
# Production variant
-npm run ios:production
+yarn ios:production
```
### Android Development
```bash
# Development variant
-npm run android:dev
+yarn android:dev
# Preview variant
-npm run android:preview
+yarn android:preview
# Production variant
-npm run android:production
+yarn android:production
```
### macOS Desktop (Tauri)
```bash
# Development variant - run with hot reload
-npm run tauri:dev
+yarn tauri:dev
# Build development variant
-npm run tauri:build:dev
+yarn tauri:build:dev
# Build preview variant
-npm run tauri:build:preview
+yarn tauri:build:preview
# Build production variant
-npm run tauri:build:production
+yarn tauri:build:production
```
**How Tauri Variants Work:**
@@ -71,13 +71,13 @@ npm run tauri:build:production
```bash
# Start dev server for development variant
-npm run start:dev
+yarn start:dev
# Start dev server for preview variant
-npm run start:preview
+yarn start:preview
# Start dev server for production variant
-npm run start:production
+yarn start:production
```
## Visual Differences
@@ -95,7 +95,7 @@ This makes it easy to distinguish which version you're testing!
1. **Build development variant:**
```bash
- npm run ios:dev
+ yarn ios:dev
```
2. **Make your changes** to the code
@@ -104,19 +104,19 @@ This makes it easy to distinguish which version you're testing!
4. **Rebuild if needed** for native changes:
```bash
- npm run ios:dev
+ yarn ios:dev
```
### Testing Preview (Pre-Release)
1. **Build preview variant:**
```bash
- npm run ios:preview
+ yarn ios:preview
```
2. **Test OTA updates:**
```bash
- npm run ota # Publishes to preview branch
+ yarn ota # Publishes to preview branch
```
3. **Verify** the preview build works as expected
@@ -125,17 +125,17 @@ This makes it easy to distinguish which version you're testing!
1. **Build production variant:**
```bash
- npm run ios:production
+ yarn ios:production
```
2. **Submit to App Store:**
```bash
- npm run submit
+ yarn submit
```
3. **Deploy OTA updates:**
```bash
- npm run ota:production
+ yarn ota:production
```
## All Variants Simultaneously
@@ -144,9 +144,9 @@ You can install all three variants on the same device:
```bash
# Build all three variants
-npm run ios:dev
-npm run ios:preview
-npm run ios:production
+yarn ios:dev
+yarn ios:preview
+yarn ios:production
```
All three apps appear on your device with different icons and names!
@@ -195,12 +195,12 @@ You can connect different variants to different Happy CLI instances:
```bash
# Development app → Dev CLI daemon
-npm run android:dev
-# Connect to CLI running: npm run dev:daemon:start
+yarn android:dev
+# Connect to CLI running: yarn dev:daemon:start
# Production app → Stable CLI daemon
-npm run android:production
-# Connect to CLI running: npm run stable:daemon:start
+yarn android:production
+# Connect to CLI running: yarn stable:daemon:start
```
Each app maintains separate authentication and sessions!
@@ -210,7 +210,7 @@ Each app maintains separate authentication and sessions!
To test with a local Happy server:
```bash
-npm run start:local-server
+yarn start:local-server
```
This sets:
@@ -227,8 +227,8 @@ This shouldn't happen - each variant has a unique bundle ID. If it does:
1. Check `app.config.js` - verify `bundleId` is set correctly for the variant
2. Clean build:
```bash
- npm run prebuild
- npm run ios:dev # or whichever variant
+ yarn prebuild
+ yarn ios:dev # or whichever variant
```
### App not updating after changes
@@ -236,12 +236,12 @@ This shouldn't happen - each variant has a unique bundle ID. If it does:
1. **For JS changes**: Hot reload should work automatically
2. **For native changes**: Rebuild the variant:
```bash
- npm run ios:dev # Force rebuild
+ yarn ios:dev # Force rebuild
```
3. **For config changes**: Clean and prebuild:
```bash
- npm run prebuild
- npm run ios:dev
+ yarn prebuild
+ yarn ios:dev
```
### All three apps look the same
@@ -258,7 +258,7 @@ If they're all the same name, the variant might not be set correctly. Verify:
echo $APP_ENV
# Or look at the build output
-npm run ios:dev # Should show "Happy (dev)" as the name
+yarn ios:dev # Should show "Happy (dev)" as the name
```
### Connected device not found
@@ -270,7 +270,7 @@ For iOS connected device testing:
xcrun devicectl list devices
# Run on specific connected device
-npm run ios:connected-device
+yarn ios:connected-device
```
## Tips
diff --git a/sources/-session/SessionView.tsx b/sources/-session/SessionView.tsx
index e93fdd4eb..457419294 100644
--- a/sources/-session/SessionView.tsx
+++ b/sources/-session/SessionView.tsx
@@ -168,6 +168,9 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session:
const shouldShowCliWarning = isCliOutdated && !isAcknowledged;
// Get permission mode from session object, default to 'default'
const permissionMode = session.permissionMode || 'default';
+ // Get model mode from session object - for Gemini sessions use explicit model, default to gemini-2.5-pro
+ const isGeminiSession = session.metadata?.flavor === 'gemini';
+ const modelMode = session.modelMode || (isGeminiSession ? 'gemini-2.5-pro' : 'default');
const sessionStatus = useSessionStatus(session);
const sessionUsage = useSessionUsage(sessionId);
const alwaysShowContextSize = useSetting('alwaysShowContextSize');
@@ -193,6 +196,11 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session:
storage.getState().updateSessionPermissionMode(sessionId, mode);
}, [sessionId]);
+ // Function to update model mode (for Gemini sessions)
+ const updateModelMode = React.useCallback((mode: 'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite') => {
+ storage.getState().updateSessionModelMode(sessionId, mode);
+ }, [sessionId]);
+
// Memoize header-dependent styles to prevent re-renders
const headerDependentStyles = React.useMemo(() => ({
contentContainer: {
@@ -272,6 +280,8 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session:
sessionId={sessionId}
permissionMode={permissionMode}
onPermissionModeChange={updatePermissionMode}
+ modelMode={modelMode as any}
+ onModelModeChange={updateModelMode as any}
metadata={session.metadata}
connectionStatus={{
text: sessionStatus.statusText,
diff --git a/sources/app/(app)/_layout.tsx b/sources/app/(app)/_layout.tsx
index 408d7ad24..64367c054 100644
--- a/sources/app/(app)/_layout.tsx
+++ b/sources/app/(app)/_layout.tsx
@@ -117,6 +117,12 @@ export default function RootLayout() {
headerTitle: t('settings.features'),
}}
/>
+
+
);
diff --git a/sources/app/(app)/new/index.tsx b/sources/app/(app)/new/index.tsx
index c8d76009f..031c7af34 100644
--- a/sources/app/(app)/new/index.tsx
+++ b/sources/app/(app)/new/index.tsx
@@ -1,6 +1,5 @@
import React from 'react';
-import { View, Text, Platform, Pressable, useWindowDimensions, ScrollView, TextInput } from 'react-native';
-import Constants from 'expo-constants';
+import { View, Text, Platform, Pressable, useWindowDimensions, ScrollView } from 'react-native';
import { Typography } from '@/constants/Typography';
import { useAllMachines, storage, useSetting, useSettingMutable, useSessions } from '@/sync/storage';
import { Ionicons, Octicons } from '@expo/vector-icons';
@@ -16,38 +15,28 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { machineSpawnNewSession } from '@/sync/ops';
import { Modal } from '@/modal';
import { sync } from '@/sync/sync';
-import { SessionTypeSelector } from '@/components/SessionTypeSelector';
+import { SessionTypeSelector, SessionTypeSelectorRows } from '@/components/SessionTypeSelector';
import { createWorktree } from '@/utils/createWorktree';
import { getTempData, type NewSessionData } from '@/utils/tempDataStore';
import { linkTaskToSession } from '@/-zen/model/taskSessionLink';
-import { PermissionMode, ModelMode, PermissionModeSelector } from '@/components/PermissionModeSelector';
+import type { PermissionMode, ModelMode } from '@/sync/permissionTypes';
import { AIBackendProfile, getProfileEnvironmentVariables, validateProfileForAgent } from '@/sync/settings';
-import { getBuiltInProfile, DEFAULT_PROFILES } from '@/sync/profileUtils';
+import { getBuiltInProfile, DEFAULT_PROFILES, getProfilePrimaryCli } from '@/sync/profileUtils';
import { AgentInput } from '@/components/AgentInput';
import { StyleSheet } from 'react-native-unistyles';
-import { randomUUID } from 'expo-crypto';
import { useCLIDetection } from '@/hooks/useCLIDetection';
-import { useEnvironmentVariables, resolveEnvVarSubstitution, extractEnvVarReferences } from '@/hooks/useEnvironmentVariables';
-import { formatPathRelativeToHome } from '@/utils/sessionUtils';
-import { resolveAbsolutePath } from '@/utils/pathUtils';
-import { MultiTextInput } from '@/components/MultiTextInput';
+
import { isMachineOnline } from '@/utils/machineUtils';
import { StatusDot } from '@/components/StatusDot';
-import { SearchableListSelector, SelectorConfig } from '@/components/SearchableListSelector';
import { clearNewSessionDraft, loadNewSessionDraft, saveNewSessionDraft } from '@/sync/persistence';
-
-// Simple temporary state for passing selections back from picker screens
-let onMachineSelected: (machineId: string) => void = () => { };
-let onProfileSaved: (profile: AIBackendProfile) => void = () => { };
-
-export const callbacks = {
- onMachineSelected: (machineId: string) => {
- onMachineSelected(machineId);
- },
- onProfileSaved: (profile: AIBackendProfile) => {
- onProfileSaved(profile);
- }
-}
+import { MachineSelector } from '@/components/newSession/MachineSelector';
+import { PathSelector } from '@/components/newSession/PathSelector';
+import { SearchHeader } from '@/components/SearchHeader';
+import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon';
+import { EnvironmentVariablesPreviewModal } from '@/components/newSession/EnvironmentVariablesPreviewModal';
+import { buildProfileGroups } from '@/sync/profileGrouping';
+import { ItemRowActions } from '@/components/ItemRowActions';
+import type { ItemAction } from '@/components/ItemActionsMenuModal';
// Optimized profile lookup utility
const useProfileMap = (profiles: AIBackendProfile[]) => {
@@ -61,7 +50,7 @@ const useProfileMap = (profiles: AIBackendProfile[]) => {
// Returns ALL profile environment variables - daemon will use them as-is
const transformProfileToEnvironmentVars = (profile: AIBackendProfile, agentType: 'claude' | 'codex' | 'gemini' = 'claude') => {
// getProfileEnvironmentVariables already returns ALL env vars from profile
- // including custom environmentVariables array and provider-specific configs
+ // including custom environmentVariables array
return getProfileEnvironmentVariables(profile);
};
@@ -97,43 +86,56 @@ const getRecentPathForMachine = (machineId: string | null, recentPaths: Array<{
const RECENT_PATHS_DEFAULT_VISIBLE = 5;
const STATUS_ITEM_GAP = 11; // Spacing between status items (machine, CLI) - ~2 character spaces at 11px font
-const styles = StyleSheet.create((theme, rt) => ({
+ const styles = StyleSheet.create((theme, rt) => ({
container: {
flex: 1,
justifyContent: Platform.OS === 'web' ? 'center' : 'flex-end',
paddingTop: Platform.OS === 'web' ? 0 : 40,
+ ...(Platform.select({
+ web: { minHeight: 0 },
+ default: {},
+ }) as any),
},
scrollContainer: {
flex: 1,
+ ...(Platform.select({
+ web: { minHeight: 0 },
+ default: {},
+ }) as any),
},
- contentContainer: {
- width: '100%',
- alignSelf: 'center',
- paddingTop: rt.insets.top,
- paddingBottom: 16,
- },
- wizardContainer: {
- backgroundColor: theme.colors.surface,
- borderRadius: 16,
- marginHorizontal: 16,
- padding: 16,
- marginBottom: 16,
- },
- sectionHeader: {
- fontSize: 14,
- fontWeight: '600',
- color: theme.colors.text,
- marginBottom: 8,
- marginTop: 12,
- ...Typography.default('semiBold')
- },
- sectionDescription: {
- fontSize: 12,
- color: theme.colors.textSecondary,
- marginBottom: 12,
- lineHeight: 18,
- ...Typography.default()
- },
+ contentContainer: {
+ width: '100%',
+ alignSelf: 'center',
+ paddingTop: rt.insets.top + 24,
+ paddingBottom: 16,
+ },
+ wizardContainer: {
+ marginBottom: 16,
+ },
+ wizardSectionHeaderRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ marginBottom: 8,
+ marginTop: 12,
+ paddingHorizontal: 16,
+ },
+ sectionHeader: {
+ fontSize: 17,
+ fontWeight: '600',
+ color: theme.colors.text,
+ marginBottom: 8,
+ marginTop: 12,
+ ...Typography.default('semiBold')
+ },
+ sectionDescription: {
+ fontSize: 12,
+ color: theme.colors.textSecondary,
+ marginBottom: 12,
+ lineHeight: 18,
+ paddingHorizontal: 16,
+ ...Typography.default()
+ },
profileListItem: {
backgroundColor: theme.colors.input.background,
borderRadius: 12,
@@ -202,18 +204,6 @@ const styles = StyleSheet.create((theme, rt) => ({
flex: 1,
...Typography.default()
},
- advancedHeader: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- paddingVertical: 12,
- },
- advancedHeaderText: {
- fontSize: 13,
- fontWeight: '500',
- color: theme.colors.textSecondary,
- ...Typography.default(),
- },
permissionGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
@@ -256,13 +246,19 @@ const styles = StyleSheet.create((theme, rt) => ({
function NewSessionWizard() {
const { theme, rt } = useUnistyles();
- const router = useRouter();
- const safeArea = useSafeAreaInsets();
- const { prompt, dataId, machineId: machineIdParam, path: pathParam } = useLocalSearchParams<{
- prompt?: string;
- dataId?: string;
- machineId?: string;
+ const router = useRouter();
+ const safeArea = useSafeAreaInsets();
+ const headerHeight = useHeaderHeight();
+ const { width: screenWidth } = useWindowDimensions();
+
+ const newSessionSidePadding = 16;
+ const newSessionBottomPadding = Math.max(screenWidth < 420 ? 8 : 16, safeArea.bottom);
+ const { prompt, dataId, machineId: machineIdParam, path: pathParam, profileId: profileIdParam } = useLocalSearchParams<{
+ prompt?: string;
+ dataId?: string;
+ machineId?: string;
path?: string;
+ profileId?: string;
}>();
// Try to get data from temporary store first
@@ -284,13 +280,16 @@ function NewSessionWizard() {
// Control A (false): Simpler AgentInput-driven layout
// Variant B (true): Enhanced profile-first wizard with sections
const useEnhancedSessionWizard = useSetting('useEnhancedSessionWizard');
+ const useProfiles = useSetting('useProfiles');
const lastUsedPermissionMode = useSetting('lastUsedPermissionMode');
- const lastUsedModelMode = useSetting('lastUsedModelMode');
const experimentsEnabled = useSetting('experiments');
+ const useMachinePickerSearch = useSetting('useMachinePickerSearch');
+ const usePathPickerSearch = useSetting('usePathPickerSearch');
const [profiles, setProfiles] = useSettingMutable('profiles');
const lastUsedProfile = useSetting('lastUsedProfile');
const [favoriteDirectories, setFavoriteDirectories] = useSettingMutable('favoriteDirectories');
const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines');
+ const [favoriteProfileIds, setFavoriteProfileIds] = useSettingMutable('favoriteProfiles');
const [dismissedCLIWarnings, setDismissedCLIWarnings] = useSettingMutable('dismissedCLIWarnings');
// Combined profiles (built-in + custom)
@@ -300,30 +299,61 @@ function NewSessionWizard() {
}, [profiles]);
const profileMap = useProfileMap(allProfiles);
+
+ const {
+ favoriteProfiles: favoriteProfileItems,
+ customProfiles: nonFavoriteCustomProfiles,
+ builtInProfiles: nonFavoriteBuiltInProfiles,
+ favoriteIds: favoriteProfileIdSet,
+ } = React.useMemo(() => {
+ return buildProfileGroups({ customProfiles: profiles, favoriteProfileIds });
+ }, [favoriteProfileIds, profiles]);
+
+ const toggleFavoriteProfile = React.useCallback((profileId: string) => {
+ if (favoriteProfileIdSet.has(profileId)) {
+ setFavoriteProfileIds(favoriteProfileIds.filter((id) => id !== profileId));
+ } else {
+ setFavoriteProfileIds([profileId, ...favoriteProfileIds]);
+ }
+ }, [favoriteProfileIdSet, favoriteProfileIds, setFavoriteProfileIds]);
const machines = useAllMachines();
const sessions = useSessions();
// Wizard state
const [selectedProfileId, setSelectedProfileId] = React.useState(() => {
+ if (!useProfiles) {
+ return null;
+ }
+ const draftProfileId = persistedDraft?.selectedProfileId;
+ if (draftProfileId && profileMap.has(draftProfileId)) {
+ return draftProfileId;
+ }
if (lastUsedProfile && profileMap.has(lastUsedProfile)) {
return lastUsedProfile;
}
- return 'anthropic'; // Default to Anthropic
+ // Default to "no profile" so default session creation remains unchanged.
+ return null;
});
+
+ React.useEffect(() => {
+ if (!useProfiles && selectedProfileId !== null) {
+ setSelectedProfileId(null);
+ }
+ }, [useProfiles, selectedProfileId]);
+ const allowGemini = experimentsEnabled;
+
const [agentType, setAgentType] = React.useState<'claude' | 'codex' | 'gemini'>(() => {
// Check if agent type was provided in temp data
if (tempSessionData?.agentType) {
- // Only allow gemini if experiments are enabled
- if (tempSessionData.agentType === 'gemini' && !experimentsEnabled) {
+ if (tempSessionData.agentType === 'gemini' && !allowGemini) {
return 'claude';
}
return tempSessionData.agentType;
}
- if (lastUsedAgent === 'claude' || lastUsedAgent === 'codex') {
- return lastUsedAgent;
- }
- // Only allow gemini if experiments are enabled
- if (lastUsedAgent === 'gemini' && experimentsEnabled) {
+ if (lastUsedAgent === 'claude' || lastUsedAgent === 'codex' || lastUsedAgent === 'gemini') {
+ if (lastUsedAgent === 'gemini' && !allowGemini) {
+ return 'claude';
+ }
return lastUsedAgent;
}
return 'claude';
@@ -331,14 +361,14 @@ function NewSessionWizard() {
// Agent cycling handler (for cycling through claude -> codex -> gemini)
// Note: Does NOT persist immediately - persistence is handled by useEffect below
- const handleAgentClick = React.useCallback(() => {
+ const handleAgentCycle = React.useCallback(() => {
setAgentType(prev => {
- // Cycle: claude -> codex -> gemini (if experiments) -> claude
+ // Cycle: claude -> codex -> (gemini?) -> claude
if (prev === 'claude') return 'codex';
- if (prev === 'codex') return experimentsEnabled ? 'gemini' : 'claude';
+ if (prev === 'codex') return allowGemini ? 'gemini' : 'claude';
return 'claude';
});
- }, [experimentsEnabled]);
+ }, [allowGemini]);
// Persist agent selection changes (separate from setState to avoid race condition)
// This runs after agentType state is updated, ensuring the value is stable
@@ -349,13 +379,13 @@ function NewSessionWizard() {
const [sessionType, setSessionType] = React.useState<'simple' | 'worktree'>('simple');
const [permissionMode, setPermissionMode] = React.useState(() => {
// Initialize with last used permission mode if valid, otherwise default to 'default'
- const validClaudeGeminiModes: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions'];
- const validCodexModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo'];
+ const validClaudeModes: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions'];
+ const validCodexGeminiModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo'];
if (lastUsedPermissionMode) {
- if (agentType === 'codex' && validCodexModes.includes(lastUsedPermissionMode as PermissionMode)) {
+ if ((agentType === 'codex' || agentType === 'gemini') && validCodexGeminiModes.includes(lastUsedPermissionMode as PermissionMode)) {
return lastUsedPermissionMode as PermissionMode;
- } else if ((agentType === 'claude' || agentType === 'gemini') && validClaudeGeminiModes.includes(lastUsedPermissionMode as PermissionMode)) {
+ } else if (agentType === 'claude' && validClaudeModes.includes(lastUsedPermissionMode as PermissionMode)) {
return lastUsedPermissionMode as PermissionMode;
}
}
@@ -368,19 +398,21 @@ function NewSessionWizard() {
const [modelMode, setModelMode] = React.useState(() => {
const validClaudeModes: ModelMode[] = ['default', 'adaptiveUsage', 'sonnet', 'opus'];
- const validCodexModes: ModelMode[] = ['gpt-5-codex-high', 'gpt-5-codex-medium', 'gpt-5-codex-low', 'default', 'gpt-5-minimal', 'gpt-5-low', 'gpt-5-medium', 'gpt-5-high'];
- const validGeminiModes: ModelMode[] = ['default'];
-
- if (lastUsedModelMode) {
- if (agentType === 'codex' && validCodexModes.includes(lastUsedModelMode as ModelMode)) {
- return lastUsedModelMode as ModelMode;
- } else if (agentType === 'claude' && validClaudeModes.includes(lastUsedModelMode as ModelMode)) {
- return lastUsedModelMode as ModelMode;
- } else if (agentType === 'gemini' && validGeminiModes.includes(lastUsedModelMode as ModelMode)) {
- return lastUsedModelMode as ModelMode;
+ const validCodexModes: ModelMode[] = ['gpt-5-codex-high', 'gpt-5-codex-medium', 'gpt-5-codex-low', 'gpt-5-minimal', 'gpt-5-low', 'gpt-5-medium', 'gpt-5-high'];
+ // Note: 'default' is NOT valid for Gemini - we want explicit model selection
+ const validGeminiModes: ModelMode[] = ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'];
+
+ if (persistedDraft?.modelMode) {
+ const draftMode = persistedDraft.modelMode as ModelMode;
+ if (agentType === 'codex' && validCodexModes.includes(draftMode)) {
+ return draftMode;
+ } else if (agentType === 'claude' && validClaudeModes.includes(draftMode)) {
+ return draftMode;
+ } else if (agentType === 'gemini' && validGeminiModes.includes(draftMode)) {
+ return draftMode;
}
}
- return agentType === 'codex' ? 'gpt-5-codex-high' : 'default';
+ return agentType === 'codex' ? 'gpt-5-codex-high' : agentType === 'gemini' ? 'gemini-2.5-pro' : 'default';
});
// Session details state
@@ -398,12 +430,24 @@ function NewSessionWizard() {
return null;
});
- const handlePermissionModeChange = React.useCallback((mode: PermissionMode) => {
+ const hasUserSelectedPermissionModeRef = React.useRef(false);
+ const permissionModeRef = React.useRef(permissionMode);
+ React.useEffect(() => {
+ permissionModeRef.current = permissionMode;
+ }, [permissionMode]);
+
+ const applyPermissionMode = React.useCallback((mode: PermissionMode, source: 'user' | 'auto') => {
setPermissionMode(mode);
- // Save the new selection immediately
sync.applySettings({ lastUsedPermissionMode: mode });
+ if (source === 'user') {
+ hasUserSelectedPermissionModeRef.current = true;
+ }
}, []);
+ const handlePermissionModeChange = React.useCallback((mode: PermissionMode) => {
+ applyPermissionMode(mode, 'user');
+ }, [applyPermissionMode]);
+
//
// Path selection
//
@@ -415,7 +459,6 @@ function NewSessionWizard() {
return tempSessionData?.prompt || prompt || persistedDraft?.input || '';
});
const [isCreating, setIsCreating] = React.useState(false);
- const [showAdvanced, setShowAdvanced] = React.useState(false);
// Handle machineId route param from picker screens (main's navigation pattern)
React.useEffect(() => {
@@ -432,6 +475,32 @@ function NewSessionWizard() {
}
}, [machineIdParam, machines, recentMachinePaths, selectedMachineId]);
+ // Ensure a machine is pre-selected once machines have loaded (wizard expects this).
+ React.useEffect(() => {
+ if (selectedMachineId !== null) {
+ return;
+ }
+ if (machines.length === 0) {
+ return;
+ }
+
+ let machineIdToUse: string | null = null;
+ if (recentMachinePaths.length > 0) {
+ for (const recent of recentMachinePaths) {
+ if (machines.find(m => m.id === recent.machineId)) {
+ machineIdToUse = recent.machineId;
+ break;
+ }
+ }
+ }
+ if (!machineIdToUse) {
+ machineIdToUse = machines[0].id;
+ }
+
+ setSelectedMachineId(machineIdToUse);
+ setSelectedPath(getRecentPathForMachine(machineIdToUse, recentMachinePaths));
+ }, [machines, recentMachinePaths, selectedMachineId]);
+
// Handle path route param from picker screens (main's navigation pattern)
React.useEffect(() => {
if (typeof pathParam !== 'string') {
@@ -477,19 +546,6 @@ function NewSessionWizard() {
}
}, [cliAvailability.timestamp, cliAvailability.claude, cliAvailability.codex, cliAvailability.gemini, agentType, experimentsEnabled]);
- // Extract all ${VAR} references from profiles to query daemon environment
- const envVarRefs = React.useMemo(() => {
- const refs = new Set();
- allProfiles.forEach(profile => {
- extractEnvVarReferences(profile.environmentVariables || [])
- .forEach(ref => refs.add(ref));
- });
- return Array.from(refs);
- }, [allProfiles]);
-
- // Query daemon environment for ${VAR} resolution
- const { variables: daemonEnv } = useEnvironmentVariables(selectedMachineId, envVarRefs);
-
// Temporary banner dismissal (X button) - resets when component unmounts or machine changes
const [hiddenBanners, setHiddenBanners] = React.useState<{ claude: boolean; codex: boolean; gemini: boolean }>({ claude: false, codex: false, gemini: false });
@@ -533,40 +589,43 @@ function NewSessionWizard() {
}
}, [selectedMachineId, dismissedCLIWarnings, setDismissedCLIWarnings]);
- // Helper to check if profile is available (compatible + CLI detected)
+ // Helper to check if profile is available (CLI detected + experiments gating)
const isProfileAvailable = React.useCallback((profile: AIBackendProfile): { available: boolean; reason?: string } => {
- // Check profile compatibility with selected agent type
- if (!validateProfileForAgent(profile, agentType)) {
- // Build list of agents this profile supports (excluding current)
- // Uses Object.entries to iterate over compatibility flags - scales automatically with new agents
- const supportedAgents = (Object.entries(profile.compatibility) as [string, boolean][])
- .filter(([agent, supported]) => supported && agent !== agentType)
- .map(([agent]) => agent.charAt(0).toUpperCase() + agent.slice(1)); // 'claude' -> 'Claude'
- const required = supportedAgents.join(' or ') || 'another agent';
+ const supportedCLIs = (Object.entries(profile.compatibility) as [string, boolean][])
+ .filter(([, supported]) => supported)
+ .map(([agent]) => agent as 'claude' | 'codex' | 'gemini');
+
+ const allowedCLIs = supportedCLIs.filter((cli) => cli !== 'gemini' || experimentsEnabled);
+
+ if (allowedCLIs.length === 0) {
return {
available: false,
- reason: `requires-agent:${required}`,
+ reason: 'no-supported-cli',
};
}
- // Check if required CLI is detected on machine (only if detection completed)
- // Determine required CLI: if profile supports exactly one CLI, that CLI is required
- // Uses Object.entries to iterate - scales automatically when new agents are added
- const supportedCLIs = (Object.entries(profile.compatibility) as [string, boolean][])
- .filter(([, supported]) => supported)
- .map(([agent]) => agent);
- const requiredCLI = supportedCLIs.length === 1 ? supportedCLIs[0] as 'claude' | 'codex' | 'gemini' : null;
+ // If a profile requires exactly one CLI, enforce that one.
+ if (allowedCLIs.length === 1) {
+ const requiredCLI = allowedCLIs[0];
+ if (cliAvailability[requiredCLI] === false) {
+ return {
+ available: false,
+ reason: `cli-not-detected:${requiredCLI}`,
+ };
+ }
+ return { available: true };
+ }
- if (requiredCLI && cliAvailability[requiredCLI] === false) {
+ // Multi-CLI profiles: available if *any* supported CLI is available (or detection not finished).
+ const anyAvailable = allowedCLIs.some((cli) => cliAvailability[cli] !== false);
+ if (!anyAvailable) {
return {
available: false,
- reason: `cli-not-detected:${requiredCLI}`,
+ reason: 'cli-not-detected:any',
};
}
-
- // Optimistic: If detection hasn't completed (null) or profile supports both, assume available
return { available: true };
- }, [agentType, cliAvailability]);
+ }, [cliAvailability, experimentsEnabled]);
// Computed values
const compatibleProfiles = React.useMemo(() => {
@@ -590,6 +649,58 @@ function NewSessionWizard() {
return machines.find(m => m.id === selectedMachineId);
}, [selectedMachineId, machines]);
+ const openProfileEdit = React.useCallback((params: { profileId?: string; cloneFromProfileId?: string }) => {
+ // Persist wizard state before navigating so selection doesn't reset on return.
+ saveNewSessionDraft({
+ input: sessionPrompt,
+ selectedMachineId,
+ selectedPath,
+ selectedProfileId: useProfiles ? selectedProfileId : null,
+ agentType,
+ permissionMode,
+ modelMode,
+ sessionType,
+ updatedAt: Date.now(),
+ });
+
+ router.push({
+ pathname: '/new/pick/profile-edit',
+ params: {
+ ...params,
+ ...(selectedMachineId ? { machineId: selectedMachineId } : {}),
+ },
+ } as any);
+ }, [agentType, modelMode, permissionMode, router, selectedMachineId, selectedPath, selectedProfileId, sessionPrompt, sessionType, useProfiles]);
+
+ const handleAddProfile = React.useCallback(() => {
+ openProfileEdit({});
+ }, [openProfileEdit]);
+
+ const handleDuplicateProfile = React.useCallback((profile: AIBackendProfile) => {
+ openProfileEdit({ cloneFromProfileId: profile.id });
+ }, [openProfileEdit]);
+
+ const handleDeleteProfile = React.useCallback((profile: AIBackendProfile) => {
+ Modal.alert(
+ t('profiles.delete.title'),
+ t('profiles.delete.message', { name: profile.name }),
+ [
+ { text: t('profiles.delete.cancel'), style: 'cancel' },
+ {
+ text: t('profiles.delete.confirm'),
+ style: 'destructive',
+ onPress: () => {
+ const updatedProfiles = profiles.filter(p => p.id !== profile.id);
+ setProfiles(updatedProfiles);
+ if (selectedProfileId === profile.id) {
+ setSelectedProfileId(null);
+ }
+ },
+ },
+ ],
+ );
+ }, [profiles, selectedProfileId, setProfiles]);
+
// Get recent paths for the selected machine
// Recent machines computed from sessions (for inline machine selection)
const recentMachines = React.useMemo(() => {
@@ -616,6 +727,10 @@ function NewSessionWizard() {
.map(item => item.machine);
}, [sessions, machines]);
+ const favoriteMachineItems = React.useMemo(() => {
+ return machines.filter(m => favoriteMachines.includes(m.id));
+ }, [machines, favoriteMachines]);
+
const recentPaths = React.useMemo(() => {
if (!selectedMachineId) return [];
@@ -661,298 +776,376 @@ function NewSessionWizard() {
// Validation
const canCreate = React.useMemo(() => {
- return (
- selectedProfileId !== null &&
- selectedMachineId !== null &&
- selectedPath.trim() !== ''
- );
- }, [selectedProfileId, selectedMachineId, selectedPath]);
+ return selectedMachineId !== null && selectedPath.trim() !== '';
+ }, [selectedMachineId, selectedPath]);
const selectProfile = React.useCallback((profileId: string) => {
+ const prevSelectedProfileId = selectedProfileId;
setSelectedProfileId(profileId);
// Check both custom profiles and built-in profiles
const profile = profileMap.get(profileId) || getBuiltInProfile(profileId);
if (profile) {
- // Auto-select agent based on profile's EXCLUSIVE compatibility
- // Only switch if profile supports exactly one CLI - scales automatically with new agents
- const supportedCLIs = (Object.entries(profile.compatibility) as [string, boolean][])
+ const supportedAgents = (Object.entries(profile.compatibility) as Array<[string, boolean]>)
.filter(([, supported]) => supported)
- .map(([agent]) => agent);
+ .map(([agent]) => agent as 'claude' | 'codex' | 'gemini')
+ .filter((agent) => agent !== 'gemini' || allowGemini);
- if (supportedCLIs.length === 1) {
- const requiredAgent = supportedCLIs[0] as 'claude' | 'codex' | 'gemini';
- // Check if this agent is available and allowed
- const isAvailable = cliAvailability[requiredAgent] !== false;
- const isAllowed = requiredAgent !== 'gemini' || experimentsEnabled;
-
- if (isAvailable && isAllowed) {
- setAgentType(requiredAgent);
- }
- // If the required CLI is unavailable or not allowed, keep current agent (profile will show as unavailable)
+ if (supportedAgents.length > 0 && !supportedAgents.includes(agentType)) {
+ setAgentType(supportedAgents[0] ?? 'claude');
}
- // If supportedCLIs.length > 1, profile supports multiple CLIs - don't force agent switch
// Set session type from profile's default
if (profile.defaultSessionType) {
setSessionType(profile.defaultSessionType);
}
- // Set permission mode from profile's default
- if (profile.defaultPermissionMode) {
- setPermissionMode(profile.defaultPermissionMode as PermissionMode);
+
+ // Apply permission defaults only on first selection (or if the user hasn't explicitly chosen one).
+ // Switching between profiles should not reset permissions when the backend stays the same.
+ if (!hasUserSelectedPermissionModeRef.current && profile.defaultPermissionMode) {
+ const nextMode = profile.defaultPermissionMode as PermissionMode;
+ // If the user is switching profiles (not initial selection), keep their current permissionMode.
+ const isInitialProfileSelection = prevSelectedProfileId === null;
+ if (isInitialProfileSelection) {
+ applyPermissionMode(nextMode, 'auto');
+ }
+ }
+ }
+ }, [agentType, allowGemini, applyPermissionMode, profileMap, selectedProfileId]);
+
+ // Handle profile route param from picker screens
+ React.useEffect(() => {
+ if (!useProfiles) {
+ return;
+ }
+
+ const nextProfileIdFromParams = Array.isArray(profileIdParam) ? profileIdParam[0] : profileIdParam;
+ if (typeof nextProfileIdFromParams !== 'string') {
+ return;
+ }
+ if (nextProfileIdFromParams === '') {
+ if (selectedProfileId !== null) {
+ setSelectedProfileId(null);
+ }
+ return;
+ }
+ if (nextProfileIdFromParams !== selectedProfileId) {
+ selectProfile(nextProfileIdFromParams);
+ }
+ }, [profileIdParam, selectedProfileId, selectProfile, useProfiles]);
+
+ // Keep agentType compatible with the currently selected profile.
+ React.useEffect(() => {
+ if (!useProfiles || selectedProfileId === null) {
+ return;
+ }
+
+ const profile = profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId);
+ if (!profile) {
+ return;
+ }
+
+ const supportedAgents = (Object.entries(profile.compatibility) as Array<[string, boolean]>)
+ .filter(([, supported]) => supported)
+ .map(([agent]) => agent as 'claude' | 'codex' | 'gemini')
+ .filter((agent) => agent !== 'gemini' || allowGemini);
+
+ if (supportedAgents.length > 0 && !supportedAgents.includes(agentType)) {
+ setAgentType(supportedAgents[0] ?? 'claude');
+ }
+ }, [agentType, allowGemini, profileMap, selectedProfileId, useProfiles]);
+
+ const prevAgentTypeRef = React.useRef(agentType);
+
+ const mapPermissionModeAcrossAgents = React.useCallback((mode: PermissionMode, from: 'claude' | 'codex' | 'gemini', to: 'claude' | 'codex' | 'gemini'): PermissionMode => {
+ if (from === to) return mode;
+
+ const toCodex = to === 'codex';
+ if (toCodex) {
+ // Claude/Gemini -> Codex
+ switch (mode) {
+ case 'bypassPermissions':
+ return 'yolo';
+ case 'plan':
+ return 'safe-yolo';
+ case 'acceptEdits':
+ return 'safe-yolo';
+ case 'default':
+ return 'default';
+ default:
+ return 'default';
}
}
- }, [profileMap, cliAvailability.claude, cliAvailability.codex, cliAvailability.gemini, experimentsEnabled]);
- // Reset permission mode to 'default' when agent type changes and current mode is invalid for new agent
+ // Codex -> Claude/Gemini
+ switch (mode) {
+ case 'yolo':
+ return 'bypassPermissions';
+ case 'safe-yolo':
+ return 'plan';
+ case 'read-only':
+ return 'default';
+ case 'default':
+ return 'default';
+ default:
+ return 'default';
+ }
+ }, []);
+
+ // When agent type changes, keep the "permission level" consistent by mapping modes across backends.
React.useEffect(() => {
+ const prev = prevAgentTypeRef.current;
+ if (prev === agentType) {
+ return;
+ }
+ prevAgentTypeRef.current = agentType;
+
+ const current = permissionModeRef.current;
const validClaudeModes: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions'];
- const validCodexModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo'];
+ const validCodexGeminiModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo'];
+
+ const isValidForNewAgent = (agentType === 'codex' || agentType === 'gemini')
+ ? validCodexGeminiModes.includes(current)
+ : validClaudeModes.includes(current);
+
+ if (isValidForNewAgent) {
+ return;
+ }
- const isValidForCurrentAgent = agentType === 'codex'
- ? validCodexModes.includes(permissionMode)
- : validClaudeModes.includes(permissionMode);
+ const mapped = mapPermissionModeAcrossAgents(current, prev, agentType);
+ applyPermissionMode(mapped, 'auto');
+ }, [agentType, applyPermissionMode, mapPermissionModeAcrossAgents]);
+
+ // Reset model mode when agent type changes to appropriate default
+ React.useEffect(() => {
+ const validClaudeModes: ModelMode[] = ['default', 'adaptiveUsage', 'sonnet', 'opus'];
+ const validCodexModes: ModelMode[] = ['gpt-5-codex-high', 'gpt-5-codex-medium', 'gpt-5-codex-low', 'gpt-5-minimal', 'gpt-5-low', 'gpt-5-medium', 'gpt-5-high'];
+ // Note: 'default' is NOT valid for Gemini - we want explicit model selection
+ const validGeminiModes: ModelMode[] = ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'];
+
+ let isValidForCurrentAgent = false;
+ if (agentType === 'codex') {
+ isValidForCurrentAgent = validCodexModes.includes(modelMode);
+ } else if (agentType === 'gemini') {
+ isValidForCurrentAgent = validGeminiModes.includes(modelMode);
+ } else {
+ isValidForCurrentAgent = validClaudeModes.includes(modelMode);
+ }
if (!isValidForCurrentAgent) {
- setPermissionMode('default');
+ // Set appropriate default for each agent type
+ if (agentType === 'codex') {
+ setModelMode('gpt-5-codex-high');
+ } else if (agentType === 'gemini') {
+ setModelMode('gemini-2.5-pro');
+ } else {
+ setModelMode('default');
+ }
}
- }, [agentType, permissionMode]);
+ }, [agentType, modelMode]);
// Scroll to section helpers - for AgentInput button clicks
- const scrollToSection = React.useCallback((ref: React.RefObject) => {
- if (!ref.current || !scrollViewRef.current) return;
-
- // Use requestAnimationFrame to ensure layout is painted before measuring
- requestAnimationFrame(() => {
- if (ref.current && scrollViewRef.current) {
- ref.current.measureLayout(
- scrollViewRef.current as any,
- (x, y) => {
- scrollViewRef.current?.scrollTo({ y: y - 20, animated: true });
- },
- () => {
- console.warn('measureLayout failed');
- }
- );
- }
- });
+ const wizardSectionOffsets = React.useRef<{ profile?: number; agent?: number; machine?: number; path?: number; permission?: number; sessionType?: number }>({});
+ const registerWizardSectionOffset = React.useCallback((key: keyof typeof wizardSectionOffsets.current) => {
+ return (e: any) => {
+ wizardSectionOffsets.current[key] = e?.nativeEvent?.layout?.y ?? 0;
+ };
+ }, []);
+ const scrollToWizardSection = React.useCallback((key: keyof typeof wizardSectionOffsets.current) => {
+ const y = wizardSectionOffsets.current[key];
+ if (typeof y !== 'number' || !scrollViewRef.current) return;
+ scrollViewRef.current.scrollTo({ y: Math.max(0, y - 20), animated: true });
}, []);
const handleAgentInputProfileClick = React.useCallback(() => {
- scrollToSection(profileSectionRef);
- }, [scrollToSection]);
+ scrollToWizardSection('profile');
+ }, [scrollToWizardSection]);
const handleAgentInputMachineClick = React.useCallback(() => {
- scrollToSection(machineSectionRef);
- }, [scrollToSection]);
+ scrollToWizardSection('machine');
+ }, [scrollToWizardSection]);
const handleAgentInputPathClick = React.useCallback(() => {
- scrollToSection(pathSectionRef);
- }, [scrollToSection]);
+ scrollToWizardSection('path');
+ }, [scrollToWizardSection]);
- const handleAgentInputPermissionChange = React.useCallback((mode: PermissionMode) => {
- setPermissionMode(mode);
- scrollToSection(permissionSectionRef);
- }, [scrollToSection]);
+ const handleAgentInputPermissionClick = React.useCallback(() => {
+ scrollToWizardSection('permission');
+ }, [scrollToWizardSection]);
const handleAgentInputAgentClick = React.useCallback(() => {
- scrollToSection(profileSectionRef); // Agent tied to profile section
- }, [scrollToSection]);
+ scrollToWizardSection('agent');
+ }, [scrollToWizardSection]);
- const handleAddProfile = React.useCallback(() => {
- const newProfile: AIBackendProfile = {
- id: randomUUID(),
- name: '',
- anthropicConfig: {},
- environmentVariables: [],
- compatibility: { claude: true, codex: true, gemini: true },
- isBuiltIn: false,
- createdAt: Date.now(),
- updatedAt: Date.now(),
- version: '1.0.0',
- };
- const profileData = encodeURIComponent(JSON.stringify(newProfile));
- router.push(`/new/pick/profile-edit?profileData=${profileData}`);
- }, [router]);
+ const ignoreProfileRowPressRef = React.useRef(false);
- const handleEditProfile = React.useCallback((profile: AIBackendProfile) => {
- const profileData = encodeURIComponent(JSON.stringify(profile));
- const machineId = selectedMachineId || '';
- router.push(`/new/pick/profile-edit?profileData=${profileData}&machineId=${machineId}`);
- }, [router, selectedMachineId]);
-
- const handleDuplicateProfile = React.useCallback((profile: AIBackendProfile) => {
- const duplicatedProfile: AIBackendProfile = {
- ...profile,
- id: randomUUID(),
- name: `${profile.name} (Copy)`,
- isBuiltIn: false,
- createdAt: Date.now(),
- updatedAt: Date.now(),
- };
- const profileData = encodeURIComponent(JSON.stringify(duplicatedProfile));
- router.push(`/new/pick/profile-edit?profileData=${profileData}`);
- }, [router]);
+ const openProfileEnvVarsPreview = React.useCallback((profile: AIBackendProfile) => {
+ Modal.show({
+ component: EnvironmentVariablesPreviewModal,
+ props: {
+ environmentVariables: getProfileEnvironmentVariables(profile),
+ machineId: selectedMachineId,
+ machineName: selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host,
+ profileName: profile.name,
+ },
+ } as any);
+ }, [selectedMachine, selectedMachineId]);
+
+ const renderProfileLeftElement = React.useCallback((profile: AIBackendProfile) => {
+ return ;
+ }, []);
+
+ const renderProfileRightElement = React.useCallback((profile: AIBackendProfile, isSelected: boolean, isFavorite: boolean) => {
+ const envVarCount = Object.keys(getProfileEnvironmentVariables(profile)).length;
+
+ const actions: ItemAction[] = [];
+ if (envVarCount > 0) {
+ actions.push({
+ id: 'envVars',
+ title: 'View environment variables',
+ icon: 'list-outline',
+ onPress: () => openProfileEnvVarsPreview(profile),
+ });
+ }
+ actions.push({
+ id: 'favorite',
+ title: isFavorite ? 'Remove from favorites' : 'Add to favorites',
+ icon: isFavorite ? 'star' : 'star-outline',
+ color: isFavorite ? theme.colors.button.primary.background : theme.colors.textSecondary,
+ onPress: () => toggleFavoriteProfile(profile.id),
+ });
+ actions.push({
+ id: 'edit',
+ title: 'Edit profile',
+ icon: 'create-outline',
+ onPress: () => openProfileEdit({ profileId: profile.id }),
+ });
+ actions.push({
+ id: 'copy',
+ title: 'Duplicate profile',
+ icon: 'copy-outline',
+ onPress: () => handleDuplicateProfile(profile),
+ });
+ if (!profile.isBuiltIn) {
+ actions.push({
+ id: 'delete',
+ title: 'Delete profile',
+ icon: 'trash-outline',
+ destructive: true,
+ onPress: () => handleDeleteProfile(profile),
+ });
+ }
+
+ return (
+
+
+
+
+ 0 ? ['envVars'] : []}
+ iconSize={20}
+ onActionPressIn={() => {
+ ignoreProfileRowPressRef.current = true;
+ }}
+ />
+
+ );
+ }, [
+ handleDeleteProfile,
+ handleDuplicateProfile,
+ openProfileEnvVarsPreview,
+ openProfileEdit,
+ screenWidth,
+ theme.colors.button.primary.background,
+ theme.colors.button.secondary.tint,
+ theme.colors.deleteAction,
+ theme.colors.textSecondary,
+ toggleFavoriteProfile,
+ ]);
// Helper to get meaningful subtitle text for profiles
const getProfileSubtitle = React.useCallback((profile: AIBackendProfile): string => {
const parts: string[] = [];
const availability = isProfileAvailable(profile);
- // Add "Built-in" indicator first for built-in profiles
if (profile.isBuiltIn) {
parts.push('Built-in');
}
- // Add CLI type second (before warnings/availability)
if (profile.compatibility.claude && profile.compatibility.codex) {
- parts.push('Claude & Codex CLI');
+ parts.push('Claude & Codex');
} else if (profile.compatibility.claude) {
- parts.push('Claude CLI');
+ parts.push('Claude');
} else if (profile.compatibility.codex) {
- parts.push('Codex CLI');
+ parts.push('Codex');
}
- // Add availability warning if unavailable
if (!availability.available && availability.reason) {
if (availability.reason.startsWith('requires-agent:')) {
const required = availability.reason.split(':')[1];
- parts.push(`⚠️ This profile uses ${required} CLI only`);
+ parts.push(`Requires ${required}`);
} else if (availability.reason.startsWith('cli-not-detected:')) {
const cli = availability.reason.split(':')[1];
- const cliName = cli === 'claude' ? 'Claude' : 'Codex';
- parts.push(`⚠️ ${cliName} CLI not detected (this profile needs it)`);
- }
- }
-
- // Get model name - check both anthropicConfig and environmentVariables
- let modelName: string | undefined;
- if (profile.anthropicConfig?.model) {
- // User set in GUI - literal value, no evaluation needed
- modelName = profile.anthropicConfig.model;
- } else if (profile.openaiConfig?.model) {
- modelName = profile.openaiConfig.model;
- } else {
- // Check environmentVariables - may need ${VAR} evaluation
- const modelEnvVar = profile.environmentVariables?.find(ev => ev.name === 'ANTHROPIC_MODEL');
- if (modelEnvVar) {
- const resolved = resolveEnvVarSubstitution(modelEnvVar.value, daemonEnv);
- if (resolved) {
- // Show as "VARIABLE: value" when evaluated from ${VAR}
- const varName = modelEnvVar.value.match(/^\$\{(.+)\}$/)?.[1];
- modelName = varName ? `${varName}: ${resolved}` : resolved;
- } else {
- // Show raw ${VAR} if not resolved (machine not selected or var not set)
- modelName = modelEnvVar.value;
- }
- }
- }
-
- if (modelName) {
- parts.push(modelName);
- }
-
- // Add base URL if exists in environmentVariables
- const baseUrlEnvVar = profile.environmentVariables?.find(ev => ev.name === 'ANTHROPIC_BASE_URL');
- if (baseUrlEnvVar) {
- const resolved = resolveEnvVarSubstitution(baseUrlEnvVar.value, daemonEnv);
- if (resolved) {
- // Extract hostname and show with variable name
- const varName = baseUrlEnvVar.value.match(/^\$\{([A-Z_][A-Z0-9_]*)/)?.[1];
- try {
- const url = new URL(resolved);
- const display = varName ? `${varName}: ${url.hostname}` : url.hostname;
- parts.push(display);
- } catch {
- // Not a valid URL, show as-is with variable name
- parts.push(varName ? `${varName}: ${resolved}` : resolved);
- }
- } else {
- // Show raw ${VAR} if not resolved (machine not selected or var not set)
- parts.push(baseUrlEnvVar.value);
+ parts.push(`${cli} CLI not detected`);
}
}
- return parts.join(', ');
- }, [agentType, isProfileAvailable, daemonEnv]);
+ return parts.join(' · ');
+ }, [isProfileAvailable]);
- const handleDeleteProfile = React.useCallback((profile: AIBackendProfile) => {
- Modal.alert(
- t('profiles.delete.title'),
- t('profiles.delete.message', { name: profile.name }),
- [
- { text: t('profiles.delete.cancel'), style: 'cancel' },
- {
- text: t('profiles.delete.confirm'),
- style: 'destructive',
- onPress: () => {
- const updatedProfiles = profiles.filter(p => p.id !== profile.id);
- setProfiles(updatedProfiles); // Use mutable setter for persistence
- if (selectedProfileId === profile.id) {
- setSelectedProfileId('anthropic'); // Default to Anthropic
- }
- }
- }
- ]
- );
- }, [profiles, selectedProfileId, setProfiles]);
+ const handleMachineClick = React.useCallback(() => {
+ router.push({
+ pathname: '/new/pick/machine',
+ params: selectedMachineId ? { selectedId: selectedMachineId } : {},
+ });
+ }, [router, selectedMachineId]);
- // Handle machine and path selection callbacks
- React.useEffect(() => {
- let handler = (machineId: string) => {
- let machine = storage.getState().machines[machineId];
- if (machine) {
- setSelectedMachineId(machineId);
- const bestPath = getRecentPathForMachine(machineId, recentMachinePaths);
- setSelectedPath(bestPath);
- }
- };
- onMachineSelected = handler;
- return () => {
- onMachineSelected = () => { };
- };
- }, [recentMachinePaths]);
+ const handleProfileClick = React.useCallback(() => {
+ router.push({
+ pathname: '/new/pick/profile',
+ params: {
+ ...(selectedProfileId ? { selectedId: selectedProfileId } : {}),
+ ...(selectedMachineId ? { machineId: selectedMachineId } : {}),
+ },
+ });
+ }, [router, selectedMachineId, selectedProfileId]);
- React.useEffect(() => {
- let handler = (savedProfile: AIBackendProfile) => {
- // Handle saved profile from profile-edit screen
-
- // Check if this is a built-in profile being edited
- const isBuiltIn = DEFAULT_PROFILES.some(bp => bp.id === savedProfile.id);
- let profileToSave = savedProfile;
-
- // For built-in profiles, create a new custom profile instead of modifying the built-in
- if (isBuiltIn) {
- profileToSave = {
- ...savedProfile,
- id: randomUUID(), // Generate new UUID for custom profile
- isBuiltIn: false,
- };
+ const handleAgentClick = React.useCallback(() => {
+ if (useProfiles && selectedProfileId !== null) {
+ const profile = profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId);
+ const supportedAgents = profile
+ ? (Object.entries(profile.compatibility) as Array<[string, boolean]>)
+ .filter(([, supported]) => supported)
+ .map(([agent]) => agent as 'claude' | 'codex' | 'gemini')
+ .filter((agent) => agent !== 'gemini' || allowGemini)
+ : [];
+
+ if (supportedAgents.length <= 1) {
+ Modal.alert(
+ 'AI Backend',
+ 'AI backend is selected by your profile. To change it, select a different profile.',
+ [
+ { text: t('common.ok'), style: 'cancel' },
+ { text: 'Change Profile', onPress: handleProfileClick },
+ ],
+ );
+ return;
}
- const existingIndex = profiles.findIndex(p => p.id === profileToSave.id);
- let updatedProfiles: AIBackendProfile[];
+ const currentIndex = supportedAgents.indexOf(agentType);
+ const nextIndex = (currentIndex + 1) % supportedAgents.length;
+ setAgentType(supportedAgents[nextIndex] ?? supportedAgents[0] ?? 'claude');
+ return;
+ }
- if (existingIndex >= 0) {
- // Update existing profile
- updatedProfiles = [...profiles];
- updatedProfiles[existingIndex] = profileToSave;
- } else {
- // Add new profile
- updatedProfiles = [...profiles, profileToSave];
- }
-
- setProfiles(updatedProfiles); // Use mutable setter for persistence
- setSelectedProfileId(profileToSave.id);
- };
- onProfileSaved = handler;
- return () => {
- onProfileSaved = () => { };
- };
- }, [profiles, setProfiles]);
-
- const handleMachineClick = React.useCallback(() => {
- router.push('/new/pick/machine');
- }, [router]);
+ handleAgentCycle();
+ }, [agentType, allowGemini, handleAgentCycle, handleProfileClick, profileMap, selectedProfileId, setAgentType, useProfiles]);
const handlePathClick = React.useCallback(() => {
if (selectedMachineId) {
@@ -966,6 +1159,33 @@ function NewSessionWizard() {
}
}, [selectedMachineId, selectedPath, router]);
+ const selectedProfileForEnvVars = React.useMemo(() => {
+ if (!useProfiles || !selectedProfileId) return null;
+ return profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId) || null;
+ }, [profileMap, selectedProfileId, useProfiles]);
+
+ const selectedProfileEnvVars = React.useMemo(() => {
+ if (!selectedProfileForEnvVars) return {};
+ return transformProfileToEnvironmentVars(selectedProfileForEnvVars, agentType) ?? {};
+ }, [agentType, selectedProfileForEnvVars]);
+
+ const selectedProfileEnvVarsCount = React.useMemo(() => {
+ return Object.keys(selectedProfileEnvVars).length;
+ }, [selectedProfileEnvVars]);
+
+ const handleEnvVarsClick = React.useCallback(() => {
+ if (!selectedProfileForEnvVars) return;
+ Modal.show({
+ component: EnvironmentVariablesPreviewModal,
+ props: {
+ environmentVariables: selectedProfileEnvVars,
+ machineId: selectedMachineId,
+ machineName: selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host,
+ profileName: selectedProfileForEnvVars.name,
+ },
+ } as any);
+ }, [selectedMachine, selectedMachineId, selectedProfileEnvVars, selectedProfileForEnvVars]);
+
// Session creation
const handleCreateSession = React.useCallback(async () => {
if (!selectedMachineId) {
@@ -1001,18 +1221,24 @@ function NewSessionWizard() {
// Save settings
const updatedPaths = [{ machineId: selectedMachineId, path: selectedPath }, ...recentMachinePaths.filter(rp => rp.machineId !== selectedMachineId)].slice(0, 10);
- sync.applySettings({
+ const profilesActive = useProfiles;
+
+ // Keep prod session creation behavior unchanged:
+ // only persist/apply profiles & model when an explicit opt-in flag is enabled.
+ const settingsUpdate: Parameters[0] = {
recentMachinePaths: updatedPaths,
lastUsedAgent: agentType,
- lastUsedProfile: selectedProfileId,
lastUsedPermissionMode: permissionMode,
- lastUsedModelMode: modelMode,
- });
+ };
+ if (profilesActive) {
+ settingsUpdate.lastUsedProfile = selectedProfileId;
+ }
+ sync.applySettings(settingsUpdate);
// Get environment variables from selected profile
let environmentVariables = undefined;
- if (selectedProfileId) {
- const selectedProfile = profileMap.get(selectedProfileId);
+ if (profilesActive && selectedProfileId) {
+ const selectedProfile = profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId);
if (selectedProfile) {
environmentVariables = transformProfileToEnvironmentVars(selectedProfile, agentType);
}
@@ -1023,6 +1249,7 @@ function NewSessionWizard() {
directory: actualPath,
approvedNewDirectoryCreation: true,
agent: agentType,
+ profileId: profilesActive ? (selectedProfileId ?? '') : undefined,
environmentVariables
});
@@ -1032,8 +1259,11 @@ function NewSessionWizard() {
await sync.refreshSessions();
- // Set permission mode on the session
+ // Set permission mode and model mode on the session
storage.getState().updateSessionPermissionMode(result.sessionId, permissionMode);
+ if (agentType === 'gemini' && modelMode && modelMode !== 'default') {
+ storage.getState().updateSessionModelMode(result.sessionId, modelMode as 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite');
+ }
// Send initial message if provided
if (sessionPrompt.trim()) {
@@ -1061,9 +1291,24 @@ function NewSessionWizard() {
Modal.alert(t('common.error'), errorMessage);
setIsCreating(false);
}
- }, [selectedMachineId, selectedPath, sessionPrompt, sessionType, experimentsEnabled, agentType, selectedProfileId, permissionMode, modelMode, recentMachinePaths, profileMap, router]);
+ }, [selectedMachineId, selectedPath, sessionPrompt, sessionType, experimentsEnabled, agentType, selectedProfileId, permissionMode, modelMode, recentMachinePaths, profileMap, router, useEnhancedSessionWizard]);
+
+ const showInlineClose = screenWidth < 520;
+
+ const handleCloseModal = React.useCallback(() => {
+ // On web (especially mobile), `router.back()` can be a no-op if the modal is the first history entry.
+ // Fall back to home so the user always has an exit.
+ if (Platform.OS === 'web') {
+ if (typeof window !== 'undefined' && window.history.length > 1) {
+ router.back();
+ } else {
+ router.replace('/');
+ }
+ return;
+ }
- const screenWidth = useWindowDimensions().width;
+ router.back();
+ }, [router]);
// Machine online status for AgentInput (DRY - reused in info box too)
const connectionStatus = React.useMemo(() => {
@@ -1086,6 +1331,20 @@ function NewSessionWizard() {
};
}, [selectedMachine, selectedMachineId, cliAvailability, experimentsEnabled, theme]);
+ const persistDraftNow = React.useCallback(() => {
+ saveNewSessionDraft({
+ input: sessionPrompt,
+ selectedMachineId,
+ selectedPath,
+ selectedProfileId: useProfiles ? selectedProfileId : null,
+ agentType,
+ permissionMode,
+ modelMode,
+ sessionType,
+ updatedAt: Date.now(),
+ });
+ }, [agentType, modelMode, permissionMode, selectedMachineId, selectedPath, selectedProfileId, sessionPrompt, sessionType, useProfiles]);
+
// Persist the current wizard state so it survives remounts and screen navigation
// Uses debouncing to avoid excessive writes
const draftSaveTimerRef = React.useRef | null>(null);
@@ -1094,22 +1353,14 @@ function NewSessionWizard() {
clearTimeout(draftSaveTimerRef.current);
}
draftSaveTimerRef.current = setTimeout(() => {
- saveNewSessionDraft({
- input: sessionPrompt,
- selectedMachineId,
- selectedPath,
- agentType,
- permissionMode,
- sessionType,
- updatedAt: Date.now(),
- });
+ persistDraftNow();
}, 250);
return () => {
if (draftSaveTimerRef.current) {
clearTimeout(draftSaveTimerRef.current);
}
};
- }, [sessionPrompt, selectedMachineId, selectedPath, agentType, permissionMode, sessionType]);
+ }, [persistDraftNow]);
// ========================================================================
// CONTROL A: Simpler AgentInput-driven layout (flag OFF)
@@ -1119,48 +1370,90 @@ function NewSessionWizard() {
return (
+ {showInlineClose && (
+
+
+
+ )}
- {/* Session type selector only if experiments enabled */}
- {experimentsEnabled && (
- 700 ? 16 : 8, marginBottom: 16 }}>
-
-
+
+
)}
-
- {/* AgentInput with inline chips - sticky at bottom */}
- 700 ? 16 : 8, paddingBottom: Math.max(16, safeArea.bottom) }}>
-
-
+
+
+ []}
agentType={agentType}
onAgentClick={handleAgentClick}
permissionMode={permissionMode}
onPermissionModeChange={handlePermissionModeChange}
- modelMode={modelMode}
- onModelModeChange={setModelMode}
connectionStatus={connectionStatus}
machineName={selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host}
- onMachineClick={handleMachineClick}
- currentPath={selectedPath}
- onPathClick={handlePathClick}
- />
-
-
+ onMachineClick={handleMachineClick}
+ currentPath={selectedPath}
+ onPathClick={handlePathClick}
+ contentPaddingHorizontal={0}
+ {...(useProfiles ? {
+ profileId: selectedProfileId,
+ onProfileClick: handleProfileClick,
+ envVarsCount: selectedProfileEnvVarsCount || undefined,
+ onEnvVarsClick: selectedProfileEnvVarsCount > 0 ? handleEnvVarsClick : undefined,
+ } : {})}
+ />
+
+
+
);
@@ -1173,9 +1466,29 @@ function NewSessionWizard() {
return (
+ {showInlineClose && (
+
+
+
+ )}
- 700 ? 16 : 8 }
- ]}>
+
-
- {/* CLI Detection Status Banner - shows after detection completes */}
- {selectedMachineId && cliAvailability.timestamp > 0 && selectedMachine && connectionStatus && (
-
-
-
-
- {selectedMachine.metadata?.displayName || selectedMachine.metadata?.host || 'Machine'}:
-
-
-
-
- {connectionStatus.text}
-
-
-
-
- {cliAvailability.claude ? '✓' : '✗'}
-
-
- claude
-
-
-
-
- {cliAvailability.codex ? '✓' : '✗'}
-
-
- codex
+
+ {/* CLI Detection Status Banner - shows after detection completes */}
+ {selectedMachineId && cliAvailability.timestamp > 0 && selectedMachine && connectionStatus && (
+
+
+
+
+
+ {selectedMachine.metadata?.displayName || selectedMachine.metadata?.host || 'Machine'}:
-
- {experimentsEnabled && (
-
- {cliAvailability.gemini ? '✓' : '✗'}
+
+
+ {connectionStatus.text}
-
- gemini
+
+
+
+ {cliAvailability.claude ? '✓' : '✗'}
+
+
+ claude
- )}
+
+
+ {cliAvailability.codex ? '✓' : '✗'}
+
+
+ codex
+
+
+ {experimentsEnabled && (
+
+
+ {cliAvailability.gemini ? '✓' : '✗'}
+
+
+ gemini
+
+
+ )}
+
)}
- {/* Section 1: Profile Management */}
-
- 1.
-
- Choose AI Profile
+ {useProfiles && (
+ <>
+
+
+
+ Select AI Profile
+
+
+
+ Select an AI profile to apply environment variables and defaults to your session.
+
+
+ {favoriteProfileItems.length > 0 && (
+
+ {favoriteProfileItems.map((profile, index) => {
+ const availability = isProfileAvailable(profile);
+ const isSelected = selectedProfileId === profile.id;
+ const isLast = index === favoriteProfileItems.length - 1;
+ return (
+ - {
+ if (!availability.available) return;
+ if (ignoreProfileRowPressRef.current) {
+ ignoreProfileRowPressRef.current = false;
+ return;
+ }
+ selectProfile(profile.id);
+ }}
+ rightElement={renderProfileRightElement(profile, isSelected, true)}
+ showDivider={!isLast}
+ />
+ );
+ })}
+
+ )}
+
+ {nonFavoriteCustomProfiles.length > 0 && (
+
+ {nonFavoriteCustomProfiles.map((profile, index) => {
+ const availability = isProfileAvailable(profile);
+ const isSelected = selectedProfileId === profile.id;
+ const isLast = index === nonFavoriteCustomProfiles.length - 1;
+ const isFavorite = favoriteProfileIdSet.has(profile.id);
+ return (
+ - {
+ if (!availability.available) return;
+ if (ignoreProfileRowPressRef.current) {
+ ignoreProfileRowPressRef.current = false;
+ return;
+ }
+ selectProfile(profile.id);
+ }}
+ rightElement={renderProfileRightElement(profile, isSelected, isFavorite)}
+ showDivider={!isLast}
+ />
+ );
+ })}
+
+ )}
+
+
+ }
+ showChevron={false}
+ selected={!selectedProfileId}
+ onPress={() => setSelectedProfileId(null)}
+ rightElement={!selectedProfileId
+ ? (
+
+
+
+ )
+ : null}
+ showDivider={nonFavoriteBuiltInProfiles.length > 0}
+ />
+ {nonFavoriteBuiltInProfiles.map((profile, index) => {
+ const availability = isProfileAvailable(profile);
+ const isSelected = selectedProfileId === profile.id;
+ const isLast = index === nonFavoriteBuiltInProfiles.length - 1;
+ const isFavorite = favoriteProfileIdSet.has(profile.id);
+ return (
+ - {
+ if (!availability.available) return;
+ if (ignoreProfileRowPressRef.current) {
+ ignoreProfileRowPressRef.current = false;
+ return;
+ }
+ selectProfile(profile.id);
+ }}
+ rightElement={renderProfileRightElement(profile, isSelected, isFavorite)}
+ showDivider={!isLast}
+ />
+ );
+ })}
+
+
+ }
+ onPress={handleAddProfile}
+ showChevron={false}
+ showDivider={false}
+ />
+
+
+
+ >
+ )}
+
+ {/* Section: AI Backend */}
+
+
+
+
+ Select AI Backend
+
+
- Choose which AI backend runs your session (Claude or Codex). Create custom profiles for alternative APIs.
+ {useProfiles && selectedProfileId
+ ? 'Limited by your selected profile and available CLIs on this machine.'
+ : 'Select which AI runs your session.'}
{/* Missing CLI Installation Banners */}
@@ -1402,7 +1859,7 @@ function NewSessionWizard() {
)}
- {selectedMachineId && cliAvailability.gemini === false && experimentsEnabled && !isWarningDismissed('gemini') && !hiddenBanners.gemini && (
+ {selectedMachineId && cliAvailability.gemini === false && allowGemini && !isWarningDismissed('gemini') && !hiddenBanners.gemini && (
)}
- {/* Custom profiles - show first */}
- {profiles.map((profile) => {
- const availability = isProfileAvailable(profile);
-
- return (
- availability.available && selectProfile(profile.id)}
- disabled={!availability.available}
- >
-
-
- {profile.compatibility.claude && profile.compatibility.codex ? '✳꩜' :
- profile.compatibility.claude ? '✳' : '꩜'}
-
-
-
- {profile.name}
-
- {getProfileSubtitle(profile)}
-
-
-
- {selectedProfileId === profile.id && (
-
- )}
- {
- e.stopPropagation();
- handleDeleteProfile(profile);
- }}
- >
-
-
- {
- e.stopPropagation();
- handleDuplicateProfile(profile);
- }}
- >
-
-
- {
- e.stopPropagation();
- handleEditProfile(profile);
- }}
- >
-
-
-
-
- );
- })}
-
- {/* Built-in profiles - show after custom */}
- {DEFAULT_PROFILES.map((profileDisplay) => {
- const profile = getBuiltInProfile(profileDisplay.id);
- if (!profile) return null;
-
- const availability = isProfileAvailable(profile);
-
- return (
- availability.available && selectProfile(profile.id)}
- disabled={!availability.available}
- >
-
-
- {profile.compatibility.claude && profile.compatibility.codex ? '✳꩜' :
- profile.compatibility.claude ? '✳' : '꩜'}
-
-
-
- {profile.name}
-
- {getProfileSubtitle(profile)}
-
-
-
- {selectedProfileId === profile.id && (
-
- )}
- {
- e.stopPropagation();
- handleEditProfile(profile);
+ } headerStyle={{ paddingTop: 0, paddingBottom: 0 }}>
+ {(() => {
+ const selectedProfile = useProfiles && selectedProfileId
+ ? (profileMap.get(selectedProfileId) || getBuiltInProfile(selectedProfileId))
+ : null;
+
+ const options: Array<{
+ key: 'claude' | 'codex' | 'gemini';
+ title: string;
+ subtitle: string;
+ icon: React.ComponentProps['name'];
+ }> = [
+ { key: 'claude', title: 'Claude', subtitle: 'Claude CLI', icon: 'sparkles-outline' },
+ { key: 'codex', title: 'Codex', subtitle: 'Codex CLI', icon: 'terminal-outline' },
+ ...(allowGemini ? [{ key: 'gemini' as const, title: 'Gemini', subtitle: 'Gemini CLI', icon: 'planet-outline' as const }] : []),
+ ];
+
+ return options.map((option, index) => {
+ const compatible = !selectedProfile || !!selectedProfile.compatibility?.[option.key];
+ const cliOk = cliAvailability[option.key] !== false;
+ const disabledReason = !compatible
+ ? 'Not compatible with the selected profile.'
+ : !cliOk
+ ? `${option.title} CLI not detected on this machine.`
+ : null;
+
+ const isSelected = agentType === option.key;
+
+ return (
+ }
+ selected={isSelected}
+ disabled={!!disabledReason}
+ onPress={() => {
+ if (disabledReason) {
+ Modal.alert(
+ 'AI Backend',
+ disabledReason,
+ compatible
+ ? [{ text: t('common.ok'), style: 'cancel' }]
+ : [
+ { text: t('common.ok'), style: 'cancel' },
+ ...(useProfiles && selectedProfileId ? [{ text: 'Change Profile', onPress: handleAgentInputProfileClick }] : []),
+ ],
+ );
+ return;
+ }
+ setAgentType(option.key);
}}
- >
-
-
-
-
- );
- })}
-
- {/* Profile Action Buttons */}
-
-
-
-
- Add
-
-
- selectedProfile && handleDuplicateProfile(selectedProfile)}
- disabled={!selectedProfile}
- >
-
-
- Duplicate
-
-
- selectedProfile && !selectedProfile.isBuiltIn && handleDeleteProfile(selectedProfile)}
- disabled={!selectedProfile || selectedProfile.isBuiltIn}
- >
-
-
- Delete
-
-
-
+ rightElement={(
+
+
+
+ )}
+ showChevron={false}
+ showDivider={index < options.length - 1}
+ />
+ );
+ });
+ })()}
+
+
+
{/* Section 2: Machine Selection */}
-
-
- 2.
+
+
Select Machine
-
- config={{
- getItemId: (machine) => machine.id,
- getItemTitle: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id,
- getItemSubtitle: undefined,
- getItemIcon: (machine) => (
-
- ),
- getRecentItemIcon: (machine) => (
-
- ),
- getItemStatus: (machine) => {
- const offline = !isMachineOnline(machine);
- return {
- text: offline ? 'offline' : 'online',
- color: offline ? theme.colors.status.disconnected : theme.colors.status.connected,
- dotColor: offline ? theme.colors.status.disconnected : theme.colors.status.connected,
- isPulsing: !offline,
- };
- },
- formatForDisplay: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id,
- parseFromDisplay: (text) => {
- return machines.find(m =>
- m.metadata?.displayName === text || m.metadata?.host === text || m.id === text
- ) || null;
- },
- filterItem: (machine, searchText) => {
- const displayName = (machine.metadata?.displayName || '').toLowerCase();
- const host = (machine.metadata?.host || '').toLowerCase();
- const search = searchText.toLowerCase();
- return displayName.includes(search) || host.includes(search);
- },
- searchPlaceholder: "Type to filter machines...",
- recentSectionTitle: "Recent Machines",
- favoritesSectionTitle: "Favorite Machines",
- noItemsMessage: "No machines available",
- showFavorites: true,
- showRecent: true,
- showSearch: true,
- allowCustomInput: false,
- compactItems: true,
- }}
- items={machines}
- recentItems={recentMachines}
- favoriteItems={machines.filter(m => favoriteMachines.includes(m.id))}
- selectedItem={selectedMachine || null}
- onSelect={(machine) => {
- setSelectedMachineId(machine.id);
- const bestPath = getRecentPathForMachine(machine.id, recentMachinePaths);
- setSelectedPath(bestPath);
- }}
- onToggleFavorite={(machine) => {
- const isInFavorites = favoriteMachines.includes(machine.id);
- if (isInFavorites) {
- setFavoriteMachines(favoriteMachines.filter(id => id !== machine.id));
- } else {
- setFavoriteMachines([...favoriteMachines, machine.id]);
- }
- }}
+ {
+ setSelectedMachineId(machine.id);
+ const bestPath = getRecentPathForMachine(machine.id, recentMachinePaths);
+ setSelectedPath(bestPath);
+ }}
+ onToggleFavorite={(machine) => {
+ const isInFavorites = favoriteMachines.includes(machine.id);
+ if (isInFavorites) {
+ setFavoriteMachines(favoriteMachines.filter(id => id !== machine.id));
+ } else {
+ setFavoriteMachines([...favoriteMachines, machine.id]);
+ }
+ }}
/>
{/* Section 3: Working Directory */}
-
-
- 3.
+
+
Select Working Directory
-
- config={{
- getItemId: (path) => path,
- getItemTitle: (path) => formatPathRelativeToHome(path, selectedMachine?.metadata?.homeDir),
- getItemSubtitle: undefined,
- getItemIcon: (path) => (
-
- ),
- getRecentItemIcon: (path) => (
-
- ),
- getFavoriteItemIcon: (path) => (
-
- ),
- canRemoveFavorite: (path) => path !== selectedMachine?.metadata?.homeDir,
- formatForDisplay: (path) => formatPathRelativeToHome(path, selectedMachine?.metadata?.homeDir),
- parseFromDisplay: (text) => {
- if (selectedMachine?.metadata?.homeDir) {
- return resolveAbsolutePath(text, selectedMachine.metadata.homeDir);
- }
- return null;
- },
- filterItem: (path, searchText) => {
- const displayPath = formatPathRelativeToHome(path, selectedMachine?.metadata?.homeDir);
- return displayPath.toLowerCase().includes(searchText.toLowerCase());
- },
- searchPlaceholder: "Type to filter or enter custom directory...",
- recentSectionTitle: "Recent Directories",
- favoritesSectionTitle: "Favorite Directories",
- noItemsMessage: "No recent directories",
- showFavorites: true,
- showRecent: true,
- showSearch: true,
- allowCustomInput: true,
- compactItems: true,
- }}
- items={recentPaths}
- recentItems={recentPaths}
- favoriteItems={(() => {
- if (!selectedMachine?.metadata?.homeDir) return [];
- const homeDir = selectedMachine.metadata.homeDir;
- // Include home directory plus user favorites
- return [homeDir, ...favoriteDirectories.map(fav => resolveAbsolutePath(fav, homeDir))];
- })()}
- selectedItem={selectedPath}
- onSelect={(path) => {
- setSelectedPath(path);
- }}
- onToggleFavorite={(path) => {
- const homeDir = selectedMachine?.metadata?.homeDir;
- if (!homeDir) return;
-
- // Don't allow removing home directory (handled by canRemoveFavorite)
- if (path === homeDir) return;
-
- // Convert to relative format for storage
- const relativePath = formatPathRelativeToHome(path, homeDir);
-
- // Check if already in favorites
- const isInFavorites = favoriteDirectories.some(fav =>
- resolveAbsolutePath(fav, homeDir) === path
- );
-
- if (isInFavorites) {
- // Remove from favorites
- setFavoriteDirectories(favoriteDirectories.filter(fav =>
- resolveAbsolutePath(fav, homeDir) !== path
- ));
- } else {
- // Add to favorites
- setFavoriteDirectories([...favoriteDirectories, relativePath]);
- }
- }}
- context={{ homeDir: selectedMachine?.metadata?.homeDir }}
- />
+
{/* Section 4: Permission Mode */}
-
- 4. Permission Mode
-
-
- {(agentType === 'codex'
- ? [
- { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' },
- { value: 'read-only' as PermissionMode, label: 'Read Only', description: 'Read-only mode', icon: 'eye-outline' },
- { value: 'safe-yolo' as PermissionMode, label: 'Safe YOLO', description: 'Workspace write with approval', icon: 'shield-checkmark-outline' },
- { value: 'yolo' as PermissionMode, label: 'YOLO', description: 'Full access, skip permissions', icon: 'flash-outline' },
- ]
- : [
- { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' },
- { value: 'acceptEdits' as PermissionMode, label: 'Accept Edits', description: 'Auto-approve edits', icon: 'checkmark-outline' },
- { value: 'plan' as PermissionMode, label: 'Plan', description: 'Plan before executing', icon: 'list-outline' },
- { value: 'bypassPermissions' as PermissionMode, label: 'Bypass Permissions', description: 'Skip all permissions', icon: 'flash-outline' },
- ]
- ).map((option, index, array) => (
- -
+
+
+ Select Permission Mode
+
+
+
+ {(agentType === 'codex' || agentType === 'gemini'
+ ? [
+ { value: 'default' as PermissionMode, label: t(agentType === 'codex' ? 'agentInput.codexPermissionMode.default' : 'agentInput.geminiPermissionMode.default'), description: 'Use CLI permission settings', icon: 'shield-outline' },
+ { value: 'read-only' as PermissionMode, label: t(agentType === 'codex' ? 'agentInput.codexPermissionMode.readOnly' : 'agentInput.geminiPermissionMode.readOnly'), description: 'Read-only mode', icon: 'eye-outline' },
+ { value: 'safe-yolo' as PermissionMode, label: t(agentType === 'codex' ? 'agentInput.codexPermissionMode.safeYolo' : 'agentInput.geminiPermissionMode.safeYolo'), description: 'Workspace write with approval', icon: 'shield-checkmark-outline' },
+ { value: 'yolo' as PermissionMode, label: t(agentType === 'codex' ? 'agentInput.codexPermissionMode.yolo' : 'agentInput.geminiPermissionMode.yolo'), description: 'Full access, skip permissions', icon: 'flash-outline' },
+ ]
+ : [
+ { value: 'default' as PermissionMode, label: t('agentInput.permissionMode.default'), description: 'Ask for permissions', icon: 'shield-outline' },
+ { value: 'acceptEdits' as PermissionMode, label: t('agentInput.permissionMode.acceptEdits'), description: 'Auto-approve edits', icon: 'checkmark-outline' },
+ { value: 'plan' as PermissionMode, label: t('agentInput.permissionMode.plan'), description: 'Plan before executing', icon: 'list-outline' },
+ { value: 'bypassPermissions' as PermissionMode, label: t('agentInput.permissionMode.bypassPermissions'), description: 'Skip all permissions', icon: 'flash-outline' },
+ ]
+ ).map((option, index, array) => (
+
}
rightElement={permissionMode === option.value ? (
- ) : null}
- onPress={() => setPermissionMode(option.value)}
- showChevron={false}
- selected={permissionMode === option.value}
- showDivider={index < array.length - 1}
- style={permissionMode === option.value ? {
- borderWidth: 2,
- borderColor: theme.colors.button.primary.tint,
- borderRadius: Platform.select({ ios: 10, default: 16 }),
- } : undefined}
- />
- ))}
-
-
- {/* Section 5: Advanced Options (Collapsible) */}
- {experimentsEnabled && (
- <>
- setShowAdvanced(!showAdvanced)}
- >
- Advanced Options
-
-
-
- {showAdvanced && (
-
-
-
- )}
- >
- )}
-
+ ) : null}
+ onPress={() => handlePermissionModeChange(option.value)}
+ showChevron={false}
+ selected={permissionMode === option.value}
+ showDivider={index < array.length - 1}
+ />
+ ))}
+
+
+
+
+ {/* Section 5: Session Type */}
+
+
+
+ Select Session Type
+
+
+
+
+ } headerStyle={{ paddingTop: 0, paddingBottom: 0 }}>
+
+
+
+
-
- {/* Section 5: AgentInput - Sticky at bottom */}
- 700 ? 16 : 8, paddingBottom: Math.max(16, safeArea.bottom) }}>
-
- []}
- agentType={agentType}
- onAgentClick={handleAgentInputAgentClick}
- permissionMode={permissionMode}
- onPermissionModeChange={handleAgentInputPermissionChange}
- modelMode={modelMode}
- onModelModeChange={setModelMode}
- connectionStatus={connectionStatus}
- machineName={selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host}
- onMachineClick={handleAgentInputMachineClick}
- currentPath={selectedPath}
- onPathClick={handleAgentInputPathClick}
- profileId={selectedProfileId}
- onProfileClick={handleAgentInputProfileClick}
- />
-
-
+
+ {/* AgentInput - Sticky at bottom */}
+
+
+
+ []}
+ agentType={agentType}
+ onAgentClick={handleAgentInputAgentClick}
+ permissionMode={permissionMode}
+ onPermissionClick={handleAgentInputPermissionClick}
+ modelMode={modelMode}
+ onModelModeChange={setModelMode}
+ connectionStatus={connectionStatus}
+ machineName={selectedMachine?.metadata?.displayName || selectedMachine?.metadata?.host}
+ onMachineClick={handleAgentInputMachineClick}
+ currentPath={selectedPath}
+ onPathClick={handleAgentInputPathClick}
+ contentPaddingHorizontal={0}
+ {...(useProfiles ? {
+ profileId: selectedProfileId,
+ onProfileClick: handleAgentInputProfileClick,
+ envVarsCount: selectedProfileEnvVarsCount || undefined,
+ onEnvVarsClick: selectedProfileEnvVarsCount > 0 ? handleEnvVarsClick : undefined,
+ } : {})}
+ />
+
+
+
);
diff --git a/sources/app/(app)/new/pick/machine.tsx b/sources/app/(app)/new/pick/machine.tsx
index c02580e8d..3568fc3d2 100644
--- a/sources/app/(app)/new/pick/machine.tsx
+++ b/sources/app/(app)/new/pick/machine.tsx
@@ -3,13 +3,11 @@ import { View, Text } from 'react-native';
import { Stack, useRouter, useLocalSearchParams } from 'expo-router';
import { CommonActions, useNavigation } from '@react-navigation/native';
import { Typography } from '@/constants/Typography';
-import { useAllMachines, useSessions } from '@/sync/storage';
-import { Ionicons } from '@expo/vector-icons';
-import { isMachineOnline } from '@/utils/machineUtils';
+import { useAllMachines, useSessions, useSetting, useSettingMutable } from '@/sync/storage';
import { StyleSheet, useUnistyles } from 'react-native-unistyles';
import { t } from '@/text';
import { ItemList } from '@/components/ItemList';
-import { SearchableListSelector } from '@/components/SearchableListSelector';
+import { MachineSelector } from '@/components/newSession/MachineSelector';
const stylesheet = StyleSheet.create((theme) => ({
container: {
@@ -38,6 +36,8 @@ export default function MachinePickerScreen() {
const params = useLocalSearchParams<{ selectedId?: string }>();
const machines = useAllMachines();
const sessions = useSessions();
+ const useMachinePickerSearch = useSetting('useMachinePickerSearch');
+ const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines');
const selectedMachine = machines.find(m => m.id === params.selectedId) || null;
@@ -65,14 +65,15 @@ export default function MachinePickerScreen() {
sessions?.forEach(item => {
if (typeof item === 'string') return; // Skip section headers
- const session = item as any;
- if (session.metadata?.machineId && !machineIds.has(session.metadata.machineId)) {
- const machine = machines.find(m => m.id === session.metadata.machineId);
+ const session = item;
+ const machineId = session.metadata?.machineId;
+ if (machineId && !machineIds.has(machineId)) {
+ const machine = machines.find(m => m.id === machineId);
if (machine) {
machineIds.add(machine.id);
machinesWithTimestamp.push({
machine,
- timestamp: session.updatedAt || session.createdAt
+ timestamp: session.updatedAt || session.createdAt,
});
}
}
@@ -114,63 +115,23 @@ export default function MachinePickerScreen() {
}}
/>
-
- config={{
- getItemId: (machine) => machine.id,
- getItemTitle: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id,
- getItemSubtitle: undefined,
- getItemIcon: (machine) => (
-
- ),
- getRecentItemIcon: (machine) => (
-
- ),
- getItemStatus: (machine) => {
- const offline = !isMachineOnline(machine);
- return {
- text: offline ? 'offline' : 'online',
- color: offline ? theme.colors.status.disconnected : theme.colors.status.connected,
- dotColor: offline ? theme.colors.status.disconnected : theme.colors.status.connected,
- isPulsing: !offline,
- };
- },
- formatForDisplay: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id,
- parseFromDisplay: (text) => {
- return machines.find(m =>
- m.metadata?.displayName === text || m.metadata?.host === text || m.id === text
- ) || null;
- },
- filterItem: (machine, searchText) => {
- const displayName = (machine.metadata?.displayName || '').toLowerCase();
- const host = (machine.metadata?.host || '').toLowerCase();
- const search = searchText.toLowerCase();
- return displayName.includes(search) || host.includes(search);
- },
- searchPlaceholder: "Type to filter machines...",
- recentSectionTitle: "Recent Machines",
- favoritesSectionTitle: "Favorite Machines",
- noItemsMessage: "No machines available",
- showFavorites: false, // Simpler modal experience - no favorites in modal
- showRecent: true,
- showSearch: true,
- allowCustomInput: false,
- compactItems: true,
- }}
- items={machines}
- recentItems={recentMachines}
- favoriteItems={[]}
- selectedItem={selectedMachine}
+ favoriteMachines.includes(m.id))}
onSelect={handleSelectMachine}
+ showFavorites={true}
+ showSearch={useMachinePickerSearch}
+ onToggleFavorite={(machine) => {
+ const isInFavorites = favoriteMachines.includes(machine.id);
+ setFavoriteMachines(isInFavorites
+ ? favoriteMachines.filter(id => id !== machine.id)
+ : [...favoriteMachines, machine.id]
+ );
+ }}
/>
>
);
-}
\ No newline at end of file
+}
diff --git a/sources/app/(app)/new/pick/path.tsx b/sources/app/(app)/new/pick/path.tsx
index b0214d6c6..4762b9f28 100644
--- a/sources/app/(app)/new/pick/path.tsx
+++ b/sources/app/(app)/new/pick/path.tsx
@@ -1,32 +1,18 @@
import React, { useState, useMemo, useRef } from 'react';
-import { View, Text, ScrollView, Pressable } from 'react-native';
+import { View, Text, Pressable } from 'react-native';
import { Stack, useRouter, useLocalSearchParams } from 'expo-router';
import { CommonActions, useNavigation } from '@react-navigation/native';
-import { ItemGroup } from '@/components/ItemGroup';
-import { Item } from '@/components/Item';
import { Typography } from '@/constants/Typography';
-import { useAllMachines, useSessions, useSetting } from '@/sync/storage';
+import { useAllMachines, useSessions, useSetting, useSettingMutable } from '@/sync/storage';
import { Ionicons } from '@expo/vector-icons';
import { StyleSheet, useUnistyles } from 'react-native-unistyles';
-import { layout } from '@/components/layout';
import { t } from '@/text';
-import { MultiTextInput, MultiTextInputHandle } from '@/components/MultiTextInput';
+import { ItemList } from '@/components/ItemList';
+import { layout } from '@/components/layout';
+import { PathSelector } from '@/components/newSession/PathSelector';
+import { SearchHeader } from '@/components/SearchHeader';
const stylesheet = StyleSheet.create((theme) => ({
- container: {
- flex: 1,
- backgroundColor: theme.colors.groupped.background,
- },
- scrollContainer: {
- flex: 1,
- },
- scrollContent: {
- alignItems: 'center',
- },
- contentWrapper: {
- width: '100%',
- maxWidth: layout.maxWidth,
- },
emptyContainer: {
flex: 1,
justifyContent: 'center',
@@ -39,6 +25,11 @@ const stylesheet = StyleSheet.create((theme) => ({
textAlign: 'center',
...Typography.default(),
},
+ contentWrapper: {
+ width: '100%',
+ maxWidth: layout.maxWidth,
+ alignSelf: 'center',
+ },
pathInputContainer: {
flexDirection: 'row',
alignItems: 'center',
@@ -66,10 +57,12 @@ export default function PathPickerScreen() {
const params = useLocalSearchParams<{ machineId?: string; selectedPath?: string }>();
const machines = useAllMachines();
const sessions = useSessions();
- const inputRef = useRef(null);
const recentMachinePaths = useSetting('recentMachinePaths');
+ const usePathPickerSearch = useSetting('usePathPickerSearch');
+ const [favoriteDirectories, setFavoriteDirectories] = useSettingMutable('favoriteDirectories');
const [customPath, setCustomPath] = useState(params.selectedPath || '');
+ const [pathSearchQuery, setPathSearchQuery] = useState('');
// Get the selected machine
const machine = useMemo(() => {
@@ -162,13 +155,11 @@ export default function PathPickerScreen() {
)
}}
/>
-
+
-
- No machine selected
-
+ No machine selected
-
+
>
);
}
@@ -198,104 +189,29 @@ export default function PathPickerScreen() {
)
}}
/>
-
-
-
-
-
-
-
-
-
-
-
- {recentPaths.length > 0 && (
-
- {recentPaths.map((path, index) => {
- const isSelected = customPath.trim() === path;
- const isLast = index === recentPaths.length - 1;
-
- return (
-
- }
- onPress={() => {
- setCustomPath(path);
- setTimeout(() => inputRef.current?.focus(), 50);
- }}
- selected={isSelected}
- showChevron={false}
- pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined}
- showDivider={!isLast}
- />
- );
- })}
-
- )}
-
- {recentPaths.length === 0 && (
-
- {(() => {
- const homeDir = machine.metadata?.homeDir || '/home';
- const suggestedPaths = [
- homeDir,
- `${homeDir}/projects`,
- `${homeDir}/Documents`,
- `${homeDir}/Desktop`
- ];
- return suggestedPaths.map((path, index) => {
- const isSelected = customPath.trim() === path;
-
- return (
-
- }
- onPress={() => {
- setCustomPath(path);
- setTimeout(() => inputRef.current?.focus(), 50);
- }}
- selected={isSelected}
- showChevron={false}
- pressableStyle={isSelected ? { backgroundColor: theme.colors.surfaceSelected } : undefined}
- showDivider={index < 3}
- />
- );
- });
- })()}
-
- )}
-
-
-
+
+ {usePathPickerSearch && (
+
+ )}
+
+
+
+
>
);
-}
\ No newline at end of file
+}
diff --git a/sources/app/(app)/new/pick/profile-edit.tsx b/sources/app/(app)/new/pick/profile-edit.tsx
index 9bf311c82..8fe47f83e 100644
--- a/sources/app/(app)/new/pick/profile-edit.tsx
+++ b/sources/app/(app)/new/pick/profile-edit.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { View, KeyboardAvoidingView, Platform, useWindowDimensions } from 'react-native';
import { Stack, useRouter, useLocalSearchParams } from 'expo-router';
+import { CommonActions, useNavigation } from '@react-navigation/native';
import { StyleSheet } from 'react-native-unistyles';
import { useUnistyles } from 'react-native-unistyles';
import { useHeaderHeight } from '@react-navigation/elements';
@@ -9,48 +10,167 @@ import { t } from '@/text';
import { ProfileEditForm } from '@/components/ProfileEditForm';
import { AIBackendProfile } from '@/sync/settings';
import { layout } from '@/components/layout';
-import { callbacks } from '../index';
+import { useSettingMutable } from '@/sync/storage';
+import { DEFAULT_PROFILES, getBuiltInProfile } from '@/sync/profileUtils';
+import { convertBuiltInProfileToCustom, createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations';
+import { Modal } from '@/modal';
export default function ProfileEditScreen() {
const { theme } = useUnistyles();
const router = useRouter();
- const params = useLocalSearchParams<{ profileData?: string; machineId?: string }>();
+ const navigation = useNavigation();
+ const params = useLocalSearchParams<{
+ profileId?: string | string[];
+ cloneFromProfileId?: string | string[];
+ profileData?: string | string[];
+ machineId?: string | string[];
+ }>();
+ const profileIdParam = Array.isArray(params.profileId) ? params.profileId[0] : params.profileId;
+ const cloneFromProfileIdParam = Array.isArray(params.cloneFromProfileId) ? params.cloneFromProfileId[0] : params.cloneFromProfileId;
+ const profileDataParam = Array.isArray(params.profileData) ? params.profileData[0] : params.profileData;
+ const machineIdParam = Array.isArray(params.machineId) ? params.machineId[0] : params.machineId;
const screenWidth = useWindowDimensions().width;
const headerHeight = useHeaderHeight();
+ const [profiles, setProfiles] = useSettingMutable('profiles');
+ const [, setLastUsedProfile] = useSettingMutable('lastUsedProfile');
+ const [isDirty, setIsDirty] = React.useState(false);
+ const isDirtyRef = React.useRef(false);
+
+ React.useEffect(() => {
+ isDirtyRef.current = isDirty;
+ }, [isDirty]);
// Deserialize profile from URL params
const profile: AIBackendProfile = React.useMemo(() => {
- if (params.profileData) {
+ if (profileDataParam) {
try {
- return JSON.parse(decodeURIComponent(params.profileData));
+ // Params may arrive already decoded (native) or URL-encoded (web / manual encodeURIComponent).
+ // Try raw JSON first, then fall back to decodeURIComponent.
+ try {
+ return JSON.parse(profileDataParam);
+ } catch {
+ return JSON.parse(decodeURIComponent(profileDataParam));
+ }
} catch (error) {
console.error('Failed to parse profile data:', error);
}
}
+ const resolveById = (id: string) => profiles.find((p) => p.id === id) ?? getBuiltInProfile(id) ?? null;
+
+ if (cloneFromProfileIdParam) {
+ const base = resolveById(cloneFromProfileIdParam);
+ if (base) {
+ return duplicateProfileForEdit(base);
+ }
+ }
+
+ if (profileIdParam) {
+ const existing = resolveById(profileIdParam);
+ if (existing) {
+ return existing;
+ }
+ }
+
// Return empty profile for new profile creation
- return {
- id: '',
- name: '',
- anthropicConfig: {},
- environmentVariables: [],
- compatibility: { claude: true, codex: true },
- isBuiltIn: false,
- createdAt: Date.now(),
- updatedAt: Date.now(),
- version: '1.0.0',
- };
- }, [params.profileData]);
+ return createEmptyCustomProfile();
+ }, [cloneFromProfileIdParam, profileDataParam, profileIdParam, profiles]);
+
+ const confirmDiscard = React.useCallback(async () => {
+ return Modal.confirm(
+ 'Discard changes?',
+ 'You have unsaved changes. Discard them?',
+ { destructive: true, confirmText: 'Discard', cancelText: 'Keep editing' },
+ );
+ }, []);
+
+ React.useEffect(() => {
+ const subscription = (navigation as any)?.addListener?.('beforeRemove', (e: any) => {
+ if (!isDirtyRef.current) return;
+
+ e.preventDefault();
+
+ void (async () => {
+ const discard = await confirmDiscard();
+ if (discard) {
+ isDirtyRef.current = false;
+ (navigation as any).dispatch(e.data.action);
+ }
+ })();
+ });
+
+ return subscription;
+ }, [confirmDiscard, navigation]);
const handleSave = (savedProfile: AIBackendProfile) => {
- // Call the callback to notify wizard of saved profile
- callbacks.onProfileSaved(savedProfile);
- router.back();
- };
+ if (!savedProfile.name || savedProfile.name.trim() === '') {
+ Modal.alert(t('common.error'), 'Enter a profile name.');
+ return;
+ }
+
+ const isBuiltIn =
+ savedProfile.isBuiltIn === true ||
+ DEFAULT_PROFILES.some((bp) => bp.id === savedProfile.id) ||
+ !!getBuiltInProfile(savedProfile.id);
+
+ let profileToSave = savedProfile;
+ if (isBuiltIn) {
+ profileToSave = convertBuiltInProfileToCustom(savedProfile);
+ }
+
+ // Duplicate name guard (same behavior as settings/profiles)
+ const isDuplicateName = profiles.some((p) => {
+ if (isBuiltIn) {
+ return p.name.trim() === profileToSave.name.trim();
+ }
+ return p.id !== profileToSave.id && p.name.trim() === profileToSave.name.trim();
+ });
+ if (isDuplicateName) {
+ Modal.alert(t('common.error'), 'A profile with that name already exists.');
+ return;
+ }
+
+ const existingIndex = profiles.findIndex((p) => p.id === profileToSave.id);
+ const isNewProfile = existingIndex < 0;
+ const updatedProfiles = existingIndex >= 0
+ ? profiles.map((p, idx) => idx === existingIndex ? { ...profileToSave, updatedAt: Date.now() } : p)
+ : [...profiles, profileToSave];
- const handleCancel = () => {
+ setProfiles(updatedProfiles);
+
+ // Update last used profile for convenience in other screens.
+ if (isNewProfile) {
+ setLastUsedProfile(profileToSave.id);
+ }
+
+ // Pass selection back to the /new screen via navigation params (unmount-safe).
+ const state = (navigation as any).getState?.();
+ const previousRoute = state?.routes?.[state.index - 1];
+ if (state && state.index > 0 && previousRoute) {
+ (navigation as any).dispatch({
+ ...CommonActions.setParams({ profileId: profileToSave.id }),
+ source: previousRoute.key,
+ } as never);
+ }
+ // Prevent the unsaved-changes guard from triggering on successful save.
+ isDirtyRef.current = false;
+ setIsDirty(false);
router.back();
};
+ const handleCancel = React.useCallback(() => {
+ void (async () => {
+ if (!isDirtyRef.current) {
+ router.back();
+ return;
+ }
+ const discard = await confirmDiscard();
+ if (discard) {
+ isDirtyRef.current = false;
+ router.back();
+ }
+ })();
+ }, [confirmDiscard, router]);
+
return (
@@ -84,7 +205,7 @@ export default function ProfileEditScreen() {
const profileEditScreenStyles = StyleSheet.create((theme, rt) => ({
container: {
flex: 1,
- backgroundColor: theme.colors.surface,
+ backgroundColor: theme.colors.groupped.background,
paddingTop: rt.insets.top,
paddingBottom: rt.insets.bottom,
},
diff --git a/sources/app/(app)/new/pick/profile.tsx b/sources/app/(app)/new/pick/profile.tsx
new file mode 100644
index 000000000..5f82486df
--- /dev/null
+++ b/sources/app/(app)/new/pick/profile.tsx
@@ -0,0 +1,309 @@
+import React from 'react';
+import { Stack, useRouter, useLocalSearchParams } from 'expo-router';
+import { CommonActions, useNavigation } from '@react-navigation/native';
+import { Pressable, View } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { Item } from '@/components/Item';
+import { ItemGroup } from '@/components/ItemGroup';
+import { ItemList } from '@/components/ItemList';
+import { useSetting, useSettingMutable } from '@/sync/storage';
+import { t } from '@/text';
+import { useUnistyles } from 'react-native-unistyles';
+import { AIBackendProfile } from '@/sync/settings';
+import { Modal } from '@/modal';
+import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon';
+import { buildProfileGroups } from '@/sync/profileGrouping';
+import { ItemRowActions } from '@/components/ItemRowActions';
+import type { ItemAction } from '@/components/ItemActionsMenuModal';
+
+export default function ProfilePickerScreen() {
+ const { theme } = useUnistyles();
+ const router = useRouter();
+ const navigation = useNavigation();
+ const params = useLocalSearchParams<{ selectedId?: string; machineId?: string; profileId?: string | string[] }>();
+ const useProfiles = useSetting('useProfiles');
+ const experimentsEnabled = useSetting('experiments');
+ const [profiles, setProfiles] = useSettingMutable('profiles');
+ const [favoriteProfileIds, setFavoriteProfileIds] = useSettingMutable('favoriteProfiles');
+
+ const selectedId = typeof params.selectedId === 'string' ? params.selectedId : '';
+ const machineId = typeof params.machineId === 'string' ? params.machineId : undefined;
+ const profileId = Array.isArray(params.profileId) ? params.profileId[0] : params.profileId;
+
+ const renderProfileIcon = React.useCallback((profile: AIBackendProfile) => {
+ return ;
+ }, []);
+
+ const getProfileBackendSubtitle = React.useCallback((profile: Pick) => {
+ const parts: string[] = [];
+ if (profile.compatibility?.claude) parts.push(t('agentInput.agent.claude'));
+ if (profile.compatibility?.codex) parts.push(t('agentInput.agent.codex'));
+ if (experimentsEnabled && profile.compatibility?.gemini) parts.push(t('agentInput.agent.gemini'));
+ return parts.length > 0 ? parts.join(' • ') : '';
+ }, [experimentsEnabled]);
+
+ const getProfileSubtitle = React.useCallback((profile: AIBackendProfile) => {
+ const backend = getProfileBackendSubtitle(profile);
+ if (profile.isBuiltIn) {
+ return backend ? `Built-in · ${backend}` : 'Built-in';
+ }
+ return backend;
+ }, [getProfileBackendSubtitle]);
+
+ const setProfileParamAndClose = React.useCallback((profileId: string) => {
+ const state = navigation.getState();
+ const previousRoute = state?.routes?.[state.index - 1];
+ if (state && state.index > 0 && previousRoute) {
+ navigation.dispatch({
+ ...CommonActions.setParams({ profileId }),
+ source: previousRoute.key,
+ } as never);
+ }
+ router.back();
+ }, [navigation, router]);
+
+ React.useEffect(() => {
+ if (typeof profileId === 'string' && profileId.length > 0) {
+ setProfileParamAndClose(profileId);
+ }
+ }, [profileId, setProfileParamAndClose]);
+
+ const openProfileCreate = React.useCallback(() => {
+ const base = '/new/pick/profile-edit';
+ router.push(machineId ? `${base}?machineId=${encodeURIComponent(machineId)}` as any : base as any);
+ }, [machineId, router]);
+
+ const openProfileEdit = React.useCallback((profileId: string) => {
+ const base = `/new/pick/profile-edit?profileId=${encodeURIComponent(profileId)}`;
+ router.push(machineId ? `${base}&machineId=${encodeURIComponent(machineId)}` as any : base as any);
+ }, [machineId, router]);
+
+ const openProfileDuplicate = React.useCallback((cloneFromProfileId: string) => {
+ const base = `/new/pick/profile-edit?cloneFromProfileId=${encodeURIComponent(cloneFromProfileId)}`;
+ router.push(machineId ? `${base}&machineId=${encodeURIComponent(machineId)}` as any : base as any);
+ }, [machineId, router]);
+
+ const {
+ favoriteProfiles: favoriteProfileItems,
+ customProfiles: nonFavoriteCustomProfiles,
+ builtInProfiles: nonFavoriteBuiltInProfiles,
+ favoriteIds: favoriteProfileIdSet,
+ } = React.useMemo(() => {
+ return buildProfileGroups({ customProfiles: profiles, favoriteProfileIds });
+ }, [favoriteProfileIds, profiles]);
+
+ const toggleFavoriteProfile = React.useCallback((profileId: string) => {
+ if (favoriteProfileIdSet.has(profileId)) {
+ setFavoriteProfileIds(favoriteProfileIds.filter((id) => id !== profileId));
+ } else {
+ setFavoriteProfileIds([profileId, ...favoriteProfileIds]);
+ }
+ }, [favoriteProfileIdSet, favoriteProfileIds, setFavoriteProfileIds]);
+
+ const handleAddProfile = React.useCallback(() => {
+ openProfileCreate();
+ }, [openProfileCreate]);
+
+ const handleDeleteProfile = React.useCallback((profile: AIBackendProfile) => {
+ Modal.alert(
+ t('profiles.delete.title'),
+ t('profiles.delete.message', { name: profile.name }),
+ [
+ { text: t('profiles.delete.cancel'), style: 'cancel' },
+ {
+ text: t('profiles.delete.confirm'),
+ style: 'destructive',
+ onPress: () => {
+ // Only custom profiles live in `profiles` setting.
+ const updatedProfiles = profiles.filter(p => p.id !== profile.id);
+ setProfiles(updatedProfiles);
+ if (selectedId === profile.id) {
+ setProfileParamAndClose('');
+ }
+ },
+ },
+ ],
+ );
+ }, [profiles, selectedId, setProfileParamAndClose, setProfiles]);
+
+ const renderProfileRowRightElement = React.useCallback((profile: AIBackendProfile, isSelected: boolean, isFavorite: boolean) => {
+ const actions: ItemAction[] = [
+ {
+ id: 'favorite',
+ title: isFavorite ? 'Remove from favorites' : 'Add to favorites',
+ icon: isFavorite ? 'star' : 'star-outline',
+ color: isFavorite ? theme.colors.button.primary.background : theme.colors.textSecondary,
+ onPress: () => toggleFavoriteProfile(profile.id),
+ },
+ {
+ id: 'edit',
+ title: 'Edit profile',
+ icon: 'create-outline',
+ onPress: () => openProfileEdit(profile.id),
+ },
+ {
+ id: 'copy',
+ title: 'Duplicate profile',
+ icon: 'copy-outline',
+ onPress: () => openProfileDuplicate(profile.id),
+ },
+ ];
+ if (!profile.isBuiltIn) {
+ actions.push({
+ id: 'delete',
+ title: 'Delete profile',
+ icon: 'trash-outline',
+ destructive: true,
+ onPress: () => handleDeleteProfile(profile),
+ });
+ }
+
+ return (
+
+
+
+
+
+
+ );
+ }, [
+ handleDeleteProfile,
+ openProfileEdit,
+ openProfileDuplicate,
+ theme.colors.button.primary.background,
+ theme.colors.button.secondary.tint,
+ theme.colors.deleteAction,
+ theme.colors.textSecondary,
+ toggleFavoriteProfile,
+ ]);
+
+ return (
+ <>
+
+
+
+ {!useProfiles ? (
+
+ }
+ showChevron={false}
+ />
+ }
+ onPress={() => router.push('/settings/features')}
+ />
+
+ ) : (
+ <>
+ {favoriteProfileItems.length > 0 && (
+
+ {favoriteProfileItems.map((profile, index) => {
+ const isSelected = selectedId === profile.id;
+ const isLast = index === favoriteProfileItems.length - 1;
+ return (
+ - setProfileParamAndClose(profile.id)}
+ showChevron={false}
+ selected={isSelected}
+ rightElement={renderProfileRowRightElement(profile, isSelected, true)}
+ showDivider={!isLast}
+ />
+ );
+ })}
+
+ )}
+
+ {nonFavoriteCustomProfiles.length > 0 && (
+
+ {nonFavoriteCustomProfiles.map((profile, index) => {
+ const isSelected = selectedId === profile.id;
+ const isLast = index === nonFavoriteCustomProfiles.length - 1;
+ const isFavorite = favoriteProfileIdSet.has(profile.id);
+ return (
+ - setProfileParamAndClose(profile.id)}
+ showChevron={false}
+ selected={isSelected}
+ rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)}
+ showDivider={!isLast}
+ />
+ );
+ })}
+
+ )}
+
+
+ }
+ onPress={() => setProfileParamAndClose('')}
+ showChevron={false}
+ selected={selectedId === ''}
+ rightElement={selectedId === ''
+ ?
+ : null}
+ showDivider={nonFavoriteBuiltInProfiles.length > 0}
+ />
+ {nonFavoriteBuiltInProfiles.map((profile, index) => {
+ const isSelected = selectedId === profile.id;
+ const isLast = index === nonFavoriteBuiltInProfiles.length - 1;
+ const isFavorite = favoriteProfileIdSet.has(profile.id);
+ return (
+ - setProfileParamAndClose(profile.id)}
+ showChevron={false}
+ selected={isSelected}
+ rightElement={renderProfileRowRightElement(profile, isSelected, isFavorite)}
+ showDivider={!isLast}
+ />
+ );
+ })}
+
+
+
+ }
+ onPress={handleAddProfile}
+ showChevron={false}
+ />
+
+ >
+ )}
+
+ >
+ );
+}
diff --git a/sources/app/(app)/session/[id]/info.tsx b/sources/app/(app)/session/[id]/info.tsx
index 631df7f39..e1cf603d2 100644
--- a/sources/app/(app)/session/[id]/info.tsx
+++ b/sources/app/(app)/session/[id]/info.tsx
@@ -7,7 +7,7 @@ import { Item } from '@/components/Item';
import { ItemGroup } from '@/components/ItemGroup';
import { ItemList } from '@/components/ItemList';
import { Avatar } from '@/components/Avatar';
-import { useSession, useIsDataReady } from '@/sync/storage';
+import { useSession, useIsDataReady, useSetting } from '@/sync/storage';
import { getSessionName, useSessionStatus, formatOSPlatform, formatPathRelativeToHome, getSessionAvatarId } from '@/utils/sessionUtils';
import * as Clipboard from 'expo-clipboard';
import { Modal } from '@/modal';
@@ -20,6 +20,7 @@ import { CodeView } from '@/components/CodeView';
import { Session } from '@/sync/storageTypes';
import { useHappyAction } from '@/hooks/useHappyAction';
import { HappyError } from '@/utils/errors';
+import { getBuiltInProfile } from '@/sync/profileUtils';
// Animated status dot component
function StatusDot({ color, isPulsing, size = 8 }: { color: string; isPulsing?: boolean; size?: number }) {
@@ -66,10 +67,24 @@ function SessionInfoContent({ session }: { session: Session }) {
const devModeEnabled = __DEV__;
const sessionName = getSessionName(session);
const sessionStatus = useSessionStatus(session);
-
+ const useProfiles = useSetting('useProfiles');
+ const profiles = useSetting('profiles');
+
// Check if CLI version is outdated
const isCliOutdated = session.metadata?.version && !isVersionSupported(session.metadata.version, MINIMUM_CLI_VERSION);
+ const profileLabel = React.useMemo(() => {
+ const profileId = session.metadata?.profileId;
+ if (profileId === null || profileId === '') return t('profiles.noProfile');
+ if (typeof profileId !== 'string') return t('status.unknown');
+
+ const builtIn = getBuiltInProfile(profileId);
+ if (builtIn) return builtIn.name;
+
+ const custom = profiles.find(p => p.id === profileId);
+ return custom?.name ?? t('status.unknown');
+ }, [profiles, session.metadata?.profileId]);
+
const handleCopySessionId = useCallback(async () => {
if (!session) return;
try {
@@ -198,10 +213,10 @@ function SessionInfoContent({ session }: { session: Session }) {
)}
- {/* Session Details */}
-
- -
+
}
onPress={handleCopySessionId}
@@ -221,17 +236,17 @@ function SessionInfoContent({ session }: { session: Session }) {
}}
/>
)}
- }
- showChevron={false}
- />
- }
- showChevron={false}
+ }
+ showChevron={false}
+ />
+ }
+ showChevron={false}
/>
)}
- - {
- const flavor = session.metadata.flavor || 'claude';
- if (flavor === 'claude') return 'Claude';
- if (flavor === 'gpt' || flavor === 'openai') return 'Codex';
- if (flavor === 'gemini') return 'Gemini';
- return flavor;
- })()}
- icon={}
- showChevron={false}
- />
- {session.metadata.hostPid && (
-
- {
+ const flavor = session.metadata.flavor || 'claude';
+ if (flavor === 'claude') return 'Claude';
+ if (flavor === 'gpt' || flavor === 'openai') return 'Codex';
+ if (flavor === 'gemini') return 'Gemini';
+ return flavor;
+ })()}
+ icon={}
+ showChevron={false}
+ />
+ {useProfiles && session.metadata?.profileId !== undefined && (
+
}
+ showChevron={false}
+ />
+ )}
+ {session.metadata.hostPid && (
+ }
showChevron={false}
/>
diff --git a/sources/app/(app)/settings/features.tsx b/sources/app/(app)/settings/features.tsx
index ac7261455..c701ef908 100644
--- a/sources/app/(app)/settings/features.tsx
+++ b/sources/app/(app)/settings/features.tsx
@@ -9,11 +9,14 @@ import { t } from '@/text';
export default function FeaturesSettingsScreen() {
const [experiments, setExperiments] = useSettingMutable('experiments');
+ const [useProfiles, setUseProfiles] = useSettingMutable('useProfiles');
const [agentInputEnterToSend, setAgentInputEnterToSend] = useSettingMutable('agentInputEnterToSend');
const [commandPaletteEnabled, setCommandPaletteEnabled] = useLocalSettingMutable('commandPaletteEnabled');
const [markdownCopyV2, setMarkdownCopyV2] = useLocalSettingMutable('markdownCopyV2');
const [hideInactiveSessions, setHideInactiveSessions] = useSettingMutable('hideInactiveSessions');
const [useEnhancedSessionWizard, setUseEnhancedSessionWizard] = useSettingMutable('useEnhancedSessionWizard');
+ const [useMachinePickerSearch, setUseMachinePickerSearch] = useSettingMutable('useMachinePickerSearch');
+ const [usePathPickerSearch, setUsePathPickerSearch] = useSettingMutable('usePathPickerSearch');
return (
@@ -72,6 +75,34 @@ export default function FeaturesSettingsScreen() {
}
showChevron={false}
/>
+ }
+ rightElement={}
+ showChevron={false}
+ />
+ }
+ rightElement={}
+ showChevron={false}
+ />
+ }
+ rightElement={
+
+ }
+ showChevron={false}
+ />
{/* Web-only Features */}
diff --git a/sources/app/(app)/settings/profiles.tsx b/sources/app/(app)/settings/profiles.tsx
index fa4522023..8b80f011b 100644
--- a/sources/app/(app)/settings/profiles.tsx
+++ b/sources/app/(app)/settings/profiles.tsx
@@ -1,25 +1,24 @@
import React from 'react';
-import { View, Text, Pressable, ScrollView, Alert } from 'react-native';
+import { View, Pressable } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useSettingMutable } from '@/sync/storage';
import { StyleSheet } from 'react-native-unistyles';
import { useUnistyles } from 'react-native-unistyles';
-import { Typography } from '@/constants/Typography';
import { t } from '@/text';
-import { Modal as HappyModal } from '@/modal/ModalManager';
-import { layout } from '@/components/layout';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
-import { useWindowDimensions } from 'react-native';
+import { Modal } from '@/modal';
import { AIBackendProfile } from '@/sync/settings';
import { getBuiltInProfile, DEFAULT_PROFILES } from '@/sync/profileUtils';
import { ProfileEditForm } from '@/components/ProfileEditForm';
-import { randomUUID } from 'expo-crypto';
-
-interface ProfileDisplay {
- id: string;
- name: string;
- isBuiltIn: boolean;
-}
+import { ItemList } from '@/components/ItemList';
+import { ItemGroup } from '@/components/ItemGroup';
+import { Item } from '@/components/Item';
+import { ItemRowActions } from '@/components/ItemRowActions';
+import type { ItemAction } from '@/components/ItemActionsMenuModal';
+import { Switch } from '@/components/Switch';
+import { ProfileCompatibilityIcon } from '@/components/newSession/ProfileCompatibilityIcon';
+import { buildProfileGroups } from '@/sync/profileGrouping';
+import { convertBuiltInProfileToCustom, createEmptyCustomProfile, duplicateProfileForEdit } from '@/sync/profileMutations';
+import { useSetting } from '@/sync/storage';
interface ProfileManagerProps {
onProfileSelect?: (profile: AIBackendProfile | null) => void;
@@ -27,28 +26,48 @@ interface ProfileManagerProps {
}
// Profile utilities now imported from @/sync/profileUtils
-
-function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerProps) {
+const ProfileManager = React.memo(function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerProps) {
const { theme } = useUnistyles();
+ const [useProfiles, setUseProfiles] = useSettingMutable('useProfiles');
const [profiles, setProfiles] = useSettingMutable('profiles');
const [lastUsedProfile, setLastUsedProfile] = useSettingMutable('lastUsedProfile');
+ const [favoriteProfileIds, setFavoriteProfileIds] = useSettingMutable('favoriteProfiles');
const [editingProfile, setEditingProfile] = React.useState(null);
const [showAddForm, setShowAddForm] = React.useState(false);
- const safeArea = useSafeAreaInsets();
- const screenWidth = useWindowDimensions().width;
+ const [isEditingDirty, setIsEditingDirty] = React.useState(false);
+ const isEditingDirtyRef = React.useRef(false);
+ const experimentsEnabled = useSetting('experiments');
+
+ React.useEffect(() => {
+ isEditingDirtyRef.current = isEditingDirty;
+ }, [isEditingDirty]);
+
+ if (!useProfiles) {
+ return (
+
+
+ }
+ rightElement={
+
+ }
+ showChevron={false}
+ />
+
+
+ );
+ }
const handleAddProfile = () => {
- setEditingProfile({
- id: randomUUID(),
- name: '',
- anthropicConfig: {},
- environmentVariables: [],
- compatibility: { claude: true, codex: true, gemini: true },
- isBuiltIn: false,
- createdAt: Date.now(),
- updatedAt: Date.now(),
- version: '1.0.0',
- });
+ setEditingProfile(createEmptyCustomProfile());
setShowAddForm(true);
};
@@ -57,37 +76,55 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr
setShowAddForm(true);
};
- const handleDeleteProfile = (profile: AIBackendProfile) => {
- // Show confirmation dialog before deleting
- Alert.alert(
+ const handleDuplicateProfile = (profile: AIBackendProfile) => {
+ setEditingProfile(duplicateProfileForEdit(profile));
+ setShowAddForm(true);
+ };
+
+ const closeEditor = React.useCallback(() => {
+ setShowAddForm(false);
+ setEditingProfile(null);
+ setIsEditingDirty(false);
+ }, []);
+
+ const requestCloseEditor = React.useCallback(() => {
+ void (async () => {
+ if (!isEditingDirtyRef.current) {
+ closeEditor();
+ return;
+ }
+ const discard = await Modal.confirm(
+ 'Discard changes?',
+ 'You have unsaved changes. Discard them?',
+ { destructive: true, confirmText: 'Discard', cancelText: 'Keep editing' },
+ );
+ if (discard) {
+ isEditingDirtyRef.current = false;
+ closeEditor();
+ }
+ })();
+ }, [closeEditor]);
+
+ const handleDeleteProfile = async (profile: AIBackendProfile) => {
+ const confirmed = await Modal.confirm(
t('profiles.delete.title'),
t('profiles.delete.message', { name: profile.name }),
- [
- {
- text: t('profiles.delete.cancel'),
- style: 'cancel',
- },
- {
- text: t('profiles.delete.confirm'),
- style: 'destructive',
- onPress: () => {
- const updatedProfiles = profiles.filter(p => p.id !== profile.id);
- setProfiles(updatedProfiles);
-
- // Clear last used profile if it was deleted
- if (lastUsedProfile === profile.id) {
- setLastUsedProfile(null);
- }
-
- // Notify parent if this was the selected profile
- if (selectedProfileId === profile.id && onProfileSelect) {
- onProfileSelect(null);
- }
- },
- },
- ],
- { cancelable: true }
+ { cancelText: t('profiles.delete.cancel'), confirmText: t('profiles.delete.confirm'), destructive: true }
);
+ if (!confirmed) return;
+
+ const updatedProfiles = profiles.filter(p => p.id !== profile.id);
+ setProfiles(updatedProfiles);
+
+ // Clear last used profile if it was deleted
+ if (lastUsedProfile === profile.id) {
+ setLastUsedProfile(null);
+ }
+
+ // Notify parent if this was the selected profile
+ if (selectedProfileId === profile.id && onProfileSelect) {
+ onProfileSelect(null);
+ }
};
const handleSelectProfile = (profileId: string | null) => {
@@ -110,9 +147,35 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr
setLastUsedProfile(profileId);
};
+ const {
+ favoriteProfiles: favoriteProfileItems,
+ customProfiles: nonFavoriteCustomProfiles,
+ builtInProfiles: nonFavoriteBuiltInProfiles,
+ favoriteIds: favoriteProfileIdSet,
+ } = React.useMemo(() => {
+ return buildProfileGroups({ customProfiles: profiles, favoriteProfileIds });
+ }, [favoriteProfileIds, profiles]);
+
+ const toggleFavoriteProfile = (profileId: string) => {
+ if (favoriteProfileIdSet.has(profileId)) {
+ setFavoriteProfileIds(favoriteProfileIds.filter((id) => id !== profileId));
+ } else {
+ setFavoriteProfileIds([profileId, ...favoriteProfileIds]);
+ }
+ };
+
+ const getProfileBackendSubtitle = React.useCallback((profile: Pick) => {
+ const parts: string[] = [];
+ if (profile.compatibility?.claude) parts.push(t('agentInput.agent.claude'));
+ if (profile.compatibility?.codex) parts.push(t('agentInput.agent.codex'));
+ if (experimentsEnabled && profile.compatibility?.gemini) parts.push(t('agentInput.agent.gemini'));
+ return parts.length > 0 ? parts.join(' • ') : '';
+ }, [experimentsEnabled]);
+
const handleSaveProfile = (profile: AIBackendProfile) => {
// Profile validation - ensure name is not empty
if (!profile.name || profile.name.trim() === '') {
+ Modal.alert(t('common.error'), 'Enter a profile name.');
return;
}
@@ -121,16 +184,14 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr
// For built-in profiles, create a new custom profile instead of modifying the built-in
if (isBuiltIn) {
- const newProfile: AIBackendProfile = {
- ...profile,
- id: randomUUID(), // Generate new UUID for custom profile
- };
+ const newProfile = convertBuiltInProfileToCustom(profile);
// Check for duplicate names (excluding the new profile)
const isDuplicate = profiles.some(p =>
p.name.trim() === newProfile.name.trim()
);
if (isDuplicate) {
+ Modal.alert(t('common.error'), 'A profile with that name already exists.');
return;
}
@@ -142,6 +203,7 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr
p.id !== profile.id && p.name.trim() === profile.name.trim()
);
if (isDuplicate) {
+ Modal.alert(t('common.error'), 'A profile with that name already exists.');
return;
}
@@ -151,7 +213,10 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr
if (existingIndex >= 0) {
// Update existing profile
updatedProfiles = [...profiles];
- updatedProfiles[existingIndex] = profile;
+ updatedProfiles[existingIndex] = {
+ ...profile,
+ updatedAt: Date.now(),
+ };
} else {
// Add new profile
updatedProfiles = [...profiles, profile];
@@ -160,257 +225,233 @@ function ProfileManager({ onProfileSelect, selectedProfileId }: ProfileManagerPr
setProfiles(updatedProfiles);
}
- setShowAddForm(false);
- setEditingProfile(null);
+ closeEditor();
};
return (
-
- 700 ? 16 : 8,
- paddingBottom: safeArea.bottom + 100,
- }}
- >
-
-
- {t('profiles.title')}
-
-
- {/* None option - no profile */}
- handleSelectProfile(null)}
- >
-
-
-
-
-
- {t('profiles.noProfile')}
-
-
- {t('profiles.noProfileDescription')}
-
-
- {selectedProfileId === null && (
-
- )}
-
+
+
+ {favoriteProfileItems.length > 0 && (
+
+ {favoriteProfileItems.map((profile) => {
+ const isSelected = selectedProfileId === profile.id;
+ const isFavorite = favoriteProfileIdSet.has(profile.id);
+ const actions: ItemAction[] = [
+ {
+ id: 'favorite',
+ title: isFavorite ? 'Remove from favorites' : 'Add to favorites',
+ icon: isFavorite ? 'star' : 'star-outline',
+ color: isFavorite ? theme.colors.button.primary.background : theme.colors.textSecondary,
+ onPress: () => toggleFavoriteProfile(profile.id),
+ },
+ {
+ id: 'edit',
+ title: 'Edit profile',
+ icon: 'create-outline',
+ onPress: () => handleEditProfile(profile),
+ },
+ {
+ id: 'copy',
+ title: 'Duplicate profile',
+ icon: 'copy-outline',
+ onPress: () => handleDuplicateProfile(profile),
+ },
+ ];
+ if (!profile.isBuiltIn) {
+ actions.push({
+ id: 'delete',
+ title: 'Delete profile',
+ icon: 'trash-outline',
+ destructive: true,
+ onPress: () => { void handleDeleteProfile(profile); },
+ });
+ }
+ return (
+ }
+ onPress={() => handleSelectProfile(profile.id)}
+ showChevron={false}
+ selected={isSelected}
+ rightElement={(
+
+
+
+
+
+
+ )}
+ />
+ );
+ })}
+
+ )}
- {/* Built-in profiles */}
- {DEFAULT_PROFILES.map((profileDisplay) => {
- const profile = getBuiltInProfile(profileDisplay.id);
- if (!profile) return null;
+ {nonFavoriteCustomProfiles.length > 0 && (
+
+ {nonFavoriteCustomProfiles.map((profile) => {
+ const isSelected = selectedProfileId === profile.id;
+ const isFavorite = favoriteProfileIdSet.has(profile.id);
+ const actions: ItemAction[] = [
+ {
+ id: 'favorite',
+ title: isFavorite ? 'Remove from favorites' : 'Add to favorites',
+ icon: isFavorite ? 'star' : 'star-outline',
+ color: isFavorite ? theme.colors.button.primary.background : theme.colors.textSecondary,
+ onPress: () => toggleFavoriteProfile(profile.id),
+ },
+ {
+ id: 'edit',
+ title: 'Edit profile',
+ icon: 'create-outline',
+ onPress: () => handleEditProfile(profile),
+ },
+ {
+ id: 'copy',
+ title: 'Duplicate profile',
+ icon: 'copy-outline',
+ onPress: () => handleDuplicateProfile(profile),
+ },
+ {
+ id: 'delete',
+ title: 'Delete profile',
+ icon: 'trash-outline',
+ destructive: true,
+ onPress: () => { void handleDeleteProfile(profile); },
+ },
+ ];
+ return (
+ }
+ onPress={() => handleSelectProfile(profile.id)}
+ showChevron={false}
+ selected={isSelected}
+ rightElement={(
+
+
+
+
+
+
+ )}
+ />
+ );
+ })}
+
+ )}
- return (
-
+ {nonFavoriteBuiltInProfiles.map((profile) => {
+ const isSelected = selectedProfileId === profile.id;
+ const isFavorite = favoriteProfileIdSet.has(profile.id);
+ const actions: ItemAction[] = [
+ {
+ id: 'favorite',
+ title: isFavorite ? 'Remove from favorites' : 'Add to favorites',
+ icon: isFavorite ? 'star' : 'star-outline',
+ color: isFavorite ? theme.colors.button.primary.background : theme.colors.textSecondary,
+ onPress: () => toggleFavoriteProfile(profile.id),
+ },
+ {
+ id: 'edit',
+ title: 'Edit profile',
+ icon: 'create-outline',
+ onPress: () => handleEditProfile(profile),
+ },
+ {
+ id: 'copy',
+ title: 'Duplicate profile',
+ icon: 'copy-outline',
+ onPress: () => handleDuplicateProfile(profile),
+ },
+ ];
+ return (
+ - handleSelectProfile(profile.id)}
- >
-
-
-
-
-
- {profile.name}
-
-
- {profile.anthropicConfig?.model || 'Default model'}
- {profile.anthropicConfig?.baseUrl && ` • ${profile.anthropicConfig.baseUrl}`}
-
-
-
- {selectedProfileId === profile.id && (
-
- )}
- handleEditProfile(profile)}
- >
-
-
-
-
- );
- })}
-
- {/* Custom profiles */}
- {profiles.map((profile) => (
- handleSelectProfile(profile.id)}
- >
-
-
-
-
-
- {profile.name}
-
-
- {profile.anthropicConfig?.model || t('profiles.defaultModel')}
- {profile.tmuxConfig?.sessionName && ` • tmux: ${profile.tmuxConfig.sessionName}`}
- {profile.tmuxConfig?.tmpDir && ` • dir: ${profile.tmuxConfig.tmpDir}`}
-
-
-
- {selectedProfileId === profile.id && (
-
- )}
- handleEditProfile(profile)}
- >
-
-
- handleDeleteProfile(profile)}
- style={{ marginLeft: 16 }}
- >
-
-
-
-
- ))}
-
- {/* Add profile button */}
- }
+ onPress={() => handleSelectProfile(profile.id)}
+ showChevron={false}
+ selected={isSelected}
+ rightElement={(
+
+
+
+
+
+
+ )}
+ />
+ );
+ })}
+
+
+
+ }
onPress={handleAddProfile}
- >
-
-
- {t('profiles.addProfile')}
-
-
-
-
+ showChevron={false}
+ />
+
+
{/* Profile Add/Edit Modal */}
{showAddForm && editingProfile && (
-
-
+
+ { }}>
{
- setShowAddForm(false);
- setEditingProfile(null);
- }}
+ onCancel={requestCloseEditor}
+ onDirtyChange={setIsEditingDirty}
/>
-
-
+
+
)}
);
-}
+});
// ProfileEditForm now imported from @/components/ProfileEditForm
@@ -428,9 +469,12 @@ const profileManagerStyles = StyleSheet.create((theme) => ({
},
modalContent: {
width: '100%',
- maxWidth: Math.min(layout.maxWidth, 600),
+ maxWidth: 600,
maxHeight: '90%',
+ borderRadius: 16,
+ overflow: 'hidden',
+ backgroundColor: theme.colors.groupped.background,
},
}));
-export default ProfileManager;
\ No newline at end of file
+export default ProfileManager;
diff --git a/sources/app/(app)/settings/voice/language.tsx b/sources/app/(app)/settings/voice/language.tsx
index 74799de38..7f95d9eba 100644
--- a/sources/app/(app)/settings/voice/language.tsx
+++ b/sources/app/(app)/settings/voice/language.tsx
@@ -1,17 +1,16 @@
import React, { useState, useMemo } from 'react';
-import { View, TextInput, FlatList } from 'react-native';
+import { FlatList } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Item } from '@/components/Item';
import { ItemGroup } from '@/components/ItemGroup';
import { ItemList } from '@/components/ItemList';
+import { SearchHeader } from '@/components/SearchHeader';
import { useSettingMutable } from '@/sync/storage';
-import { useUnistyles } from 'react-native-unistyles';
import { LANGUAGES, getLanguageDisplayName, type Language } from '@/constants/Languages';
import { t } from '@/text';
export default function LanguageSelectionScreen() {
- const { theme } = useUnistyles();
const router = useRouter();
const [voiceAssistantLanguage, setVoiceAssistantLanguage] = useSettingMutable('voiceAssistantLanguage');
const [searchQuery, setSearchQuery] = useState('');
@@ -37,52 +36,11 @@ export default function LanguageSelectionScreen() {
return (
- {/* Search Header */}
-
-
-
-
- {searchQuery.length > 0 && (
- setSearchQuery('')}
- style={{ marginLeft: 8 }}
- />
- )}
-
-
+
{/* Language List */}
void;
+ onPermissionClick?: () => void;
modelMode?: ModelMode;
onModelModeChange?: (mode: ModelMode) => void;
metadata?: Metadata | null;
@@ -73,10 +74,18 @@ interface AgentInputProps {
minHeight?: number;
profileId?: string | null;
onProfileClick?: () => void;
+ envVarsCount?: number;
+ onEnvVarsClick?: () => void;
+ contentPaddingHorizontal?: number;
}
const MAX_CONTEXT_SIZE = 190000;
+function truncateWithEllipsis(value: string, maxChars: number) {
+ if (value.length <= maxChars) return value;
+ return `${value.slice(0, maxChars)}…`;
+}
+
const stylesheet = StyleSheet.create((theme, runtime) => ({
container: {
alignItems: 'center',
@@ -223,16 +232,18 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({
// Button styles
actionButtonsContainer: {
flexDirection: 'row',
- alignItems: 'center',
+ alignItems: 'flex-end',
justifyContent: 'space-between',
paddingHorizontal: 0,
},
- actionButtonsLeft: {
- flexDirection: 'row',
- gap: 8,
- flex: 1,
- overflow: 'hidden',
- },
+ actionButtonsLeft: {
+ flexDirection: 'row',
+ columnGap: 6,
+ rowGap: 3,
+ flex: 1,
+ flexWrap: 'wrap',
+ overflow: 'visible',
+ },
actionButton: {
flexDirection: 'row',
alignItems: 'center',
@@ -256,6 +267,7 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({
alignItems: 'center',
flexShrink: 0,
marginLeft: 8,
+ marginRight: 8,
},
sendButtonActive: {
backgroundColor: theme.colors.button.primary.background,
@@ -300,13 +312,16 @@ export const AgentInput = React.memo(React.forwardRef 0;
// Check if this is a Codex or Gemini session
- const isCodex = props.metadata?.flavor === 'codex';
- const isGemini = props.metadata?.flavor === 'gemini';
+ const effectiveFlavor = props.metadata?.flavor ?? props.agentType;
+ const isCodex = effectiveFlavor === 'codex';
+ const isGemini = effectiveFlavor === 'gemini';
// Profile data
const profiles = useSetting('profiles');
const currentProfile = React.useMemo(() => {
- if (!props.profileId) return null;
+ if (props.profileId === undefined || props.profileId === null || props.profileId.trim() === '') {
+ return null;
+ }
// Check custom profiles first
const customProfile = profiles.find(p => p.id === props.profileId);
if (customProfile) return customProfile;
@@ -314,6 +329,25 @@ export const AgentInput = React.memo(React.forwardRef {
+ if (props.profileId === undefined) {
+ return null;
+ }
+ if (props.profileId === null || props.profileId.trim() === '') {
+ return t('profiles.noProfile');
+ }
+ if (currentProfile) {
+ return currentProfile.name;
+ }
+ const shortId = props.profileId.length > 8 ? `${props.profileId.slice(0, 8)}…` : props.profileId;
+ return `${t('status.unknown')} (${shortId})`;
+ }, [props.profileId, currentProfile]);
+
+ const profileIcon = React.useMemo(() => {
+ // Always show a stable "profile" icon so the chip reads as Profile selection (not "current provider").
+ return 'person-circle-outline';
+ }, []);
+
// Calculate context warning
const contextWarning = props.usageData?.contextSize
? getContextWarning(props.usageData.contextSize, props.alwaysShowContextSize ?? false, theme)
@@ -338,7 +372,6 @@ export const AgentInput = React.memo(React.forwardRef {
- // console.log('📝 Input state changed:', JSON.stringify(newState));
setInputState(newState);
}, []);
@@ -348,18 +381,6 @@ export const AgentInput = React.memo(React.forwardRef {
- // console.log('🔍 Autocomplete Debug:', JSON.stringify({
- // value: props.value,
- // inputState,
- // activeWord,
- // suggestionsCount: suggestions.length,
- // selected,
- // prefixes: props.autocompletePrefixes
- // }, null, 2));
- // }, [props.value, inputState, activeWord, suggestions.length, selected]);
-
// Handle suggestion selection
const handleSuggestionSelect = React.useCallback((index: number) => {
if (!suggestions[index] || !inputRef.current) return;
@@ -381,8 +402,6 @@ export const AgentInput = React.memo(React.forwardRef {
+ const mode = props.permissionMode ?? 'default';
+
+ if (isCodex) {
+ return mode === 'default' ? 'CLI' :
+ mode === 'read-only' ? 'RO' :
+ mode === 'safe-yolo' ? 'Safe' :
+ mode === 'yolo' ? 'YOLO' : '';
+ }
+
+ if (isGemini) {
+ return mode === 'default' ? t('agentInput.geminiPermissionMode.default') :
+ mode === 'acceptEdits' ? 'Accept' :
+ mode === 'bypassPermissions' ? 'YOLO' :
+ mode === 'plan' ? 'Plan' : '';
+ }
+
+ return mode === 'default' ? t('agentInput.permissionMode.default') :
+ mode === 'acceptEdits' ? 'Accept' :
+ mode === 'bypassPermissions' ? 'YOLO' :
+ mode === 'plan' ? 'Plan' : '';
+ }, [isCodex, isGemini, props.permissionMode]);
+
// Handle settings button press
const handleSettingsPress = React.useCallback(() => {
hapticsLight();
setShowSettings(prev => !prev);
}, []);
+ const showPermissionChip = Boolean(props.onPermissionModeChange || props.onPermissionClick);
+
// Handle settings selection
const handleSettingsSelect = React.useCallback((mode: PermissionMode) => {
hapticsLight();
@@ -493,7 +537,7 @@ export const AgentInput = React.memo(React.forwardRef 700 ? 16 : 8 }
+ { paddingHorizontal: props.contentPaddingHorizontal ?? (screenWidth > 700 ? 16 : 8) }
]}>
- setShowSettings(false)}>
-
-
+ setShowSettings(false)} style={styles.overlayBackdrop} />
700 ? 0 : 8 }
]}>
-
+
{/* Permission Mode Section */}
{isCodex ? t('agentInput.codexPermissionMode.title') : isGemini ? t('agentInput.geminiPermissionMode.title') : t('agentInput.permissionMode.title')}
- {(isCodex
+ {((isCodex || isGemini)
? (['default', 'read-only', 'safe-yolo', 'yolo'] as const)
- : (['default', 'acceptEdits', 'plan', 'bypassPermissions'] as const) // Claude and Gemini share same modes
+ : (['default', 'acceptEdits', 'plan', 'bypassPermissions'] as const)
).map((mode) => {
const modeConfig = isCodex ? {
'default': { label: t('agentInput.codexPermissionMode.default') },
@@ -543,10 +585,10 @@ export const AgentInput = React.memo(React.forwardRef
{t('agentInput.model.title')}
-
- {t('agentInput.model.configureInCli')}
-
+ {isGemini ? (
+ // Gemini model selector
+ (['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'] as const).map((model) => {
+ const modelConfig = {
+ 'gemini-2.5-pro': { label: 'Gemini 2.5 Pro', description: 'Most capable' },
+ 'gemini-2.5-flash': { label: 'Gemini 2.5 Flash', description: 'Fast & efficient' },
+ 'gemini-2.5-flash-lite': { label: 'Gemini 2.5 Flash Lite', description: 'Fastest' },
+ };
+ const config = modelConfig[model];
+ const isSelected = props.modelMode === model;
+
+ return (
+ {
+ hapticsLight();
+ props.onModelModeChange?.(model);
+ }}
+ style={({ pressed }) => ({
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 16,
+ paddingVertical: 8,
+ backgroundColor: pressed ? theme.colors.surfacePressed : 'transparent'
+ })}
+ >
+
+ {isSelected && (
+
+ )}
+
+
+
+ {config.label}
+
+
+ {config.description}
+
+
+
+ );
+ })
+ ) : (
+
+ {t('agentInput.model.configureInCli')}
+
+ )}
@@ -635,23 +743,23 @@ export const AgentInput = React.memo(React.forwardRef
-
+
{props.connectionStatus && (
<>
-
{props.connectionStatus.text}
-
- {/* CLI Status - only shown when provided (wizard only) */}
- {props.connectionStatus.cliStatus && (
- <>
-
-
- {props.connectionStatus.cliStatus.claude ? '✓' : '✗'}
-
-
- claude
-
-
-
-
- {props.connectionStatus.cliStatus.codex ? '✓' : '✗'}
-
-
- codex
-
-
- {props.connectionStatus.cliStatus.gemini !== undefined && (
-
-
- {props.connectionStatus.cliStatus.gemini ? '✓' : '✗'}
-
-
- gemini
-
-
- )}
- >
- )}
>
)}
{contextWarning && (
@@ -765,9 +805,9 @@ export const AgentInput = React.memo(React.forwardRef
)}
- {/* Box 1: Context Information (Machine + Path) - Only show if either exists */}
- {(props.machineName !== undefined || props.currentPath) && (
-
- {/* Machine chip */}
- {props.machineName !== undefined && props.onMachineClick && (
- {
- hapticsLight();
- props.onMachineClick?.();
- }}
- hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }}
- style={(p) => ({
- flexDirection: 'row',
- alignItems: 'center',
+ {/* Box 2: Action Area (Input + Send) */}
+
+ {/* Input field */}
+
+
+
+
+ {/* Action buttons below input */}
+
+
+ {/* Row 1: Settings, Profile (FIRST), Agent, Abort, Git Status */}
+
+
+
+ {/* Permission chip (popover in standard flow, scroll in wizard) */}
+ {showPermissionChip && (
+ {
+ hapticsLight();
+ if (props.onPermissionClick) {
+ props.onPermissionClick();
+ return;
+ }
+ handleSettingsPress();
+ }}
+ hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }}
+ style={(p) => ({
+ flexDirection: 'row',
+ alignItems: 'center',
borderRadius: Platform.select({ default: 16, android: 20 }),
- paddingHorizontal: 10,
+ paddingHorizontal: 10,
paddingVertical: 6,
+ justifyContent: 'center',
height: 32,
opacity: p.pressed ? 0.7 : 1,
- gap: 6,
- })}
- >
-
-
- {props.machineName === null ? t('agentInput.noMachinesAvailable') : props.machineName}
-
-
- )}
+ gap: 6,
+ })}
+ >
+
+
+ {permissionChipLabel}
+
+
+ )}
- {/* Path chip */}
- {props.currentPath && props.onPathClick && (
+ {/* Profile selector button - FIRST */}
+ {props.onProfileClick && (
{
hapticsLight();
- props.onPathClick?.();
+ props.onProfileClick?.();
}}
hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }}
style={(p) => ({
@@ -838,83 +898,70 @@ export const AgentInput = React.memo(React.forwardRef
-
+
- {props.currentPath}
+ {profileLabel ?? t('profiles.noProfile')}
-
+
)}
-
- )}
-
- {/* Box 2: Action Area (Input + Send) */}
-
- {/* Input field */}
-
-
-
-
- {/* Action buttons below input */}
-
-
- {/* Row 1: Settings, Profile (FIRST), Agent, Abort, Git Status */}
-
-
- {/* Settings button */}
- {props.onPermissionModeChange && (
+ {/* Env vars preview (standard flow) */}
+ {props.onEnvVarsClick && (
{
+ hapticsLight();
+ props.onEnvVarsClick?.();
+ }}
hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }}
style={(p) => ({
flexDirection: 'row',
alignItems: 'center',
borderRadius: Platform.select({ default: 16, android: 20 }),
- paddingHorizontal: 8,
+ paddingHorizontal: 10,
paddingVertical: 6,
justifyContent: 'center',
height: 32,
opacity: p.pressed ? 0.7 : 1,
+ gap: 6,
})}
>
-
+
+
+ {props.envVarsCount ? `Env Vars (${props.envVarsCount})` : 'Env Vars'}
+
)}
- {/* Profile selector button - FIRST */}
- {props.profileId && props.onProfileClick && (
+ {/* Agent selector button */}
+ {props.agentType && props.onAgentClick && (
{
hapticsLight();
- props.onProfileClick?.();
+ props.onAgentClick?.();
}}
hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }}
style={(p) => ({
@@ -929,28 +976,28 @@ export const AgentInput = React.memo(React.forwardRef
-
+
- {currentProfile?.name || 'Select Profile'}
+ {props.agentType === 'claude' ? t('agentInput.agent.claude') : props.agentType === 'codex' ? t('agentInput.agent.codex') : t('agentInput.agent.gemini')}
)}
- {/* Agent selector button */}
- {props.agentType && props.onAgentClick && (
+ {/* Machine selector button */}
+ {(props.machineName !== undefined) && props.onMachineClick && (
{
hapticsLight();
- props.onAgentClick?.();
+ props.onMachineClick?.();
}}
hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }}
style={(p) => ({
@@ -965,21 +1012,23 @@ export const AgentInput = React.memo(React.forwardRef
-
-
- {props.agentType === 'claude' ? t('agentInput.agent.claude') : props.agentType === 'codex' ? t('agentInput.agent.codex') : t('agentInput.agent.gemini')}
-
-
- )}
+
+
+ {props.machineName === null
+ ? t('agentInput.noMachinesAvailable')
+ : truncateWithEllipsis(props.machineName, 12)}
+
+
+ )}
{/* Abort button */}
{props.onAbort && (
@@ -1085,6 +1134,46 @@ export const AgentInput = React.memo(React.forwardRef
+
+ {/* Row 2: Path selector (separate line to match pre-PR272 layout) */}
+ {props.currentPath && props.onPathClick && (
+
+
+ {
+ hapticsLight();
+ props.onPathClick?.();
+ }}
+ hitSlop={{ top: 5, bottom: 10, left: 0, right: 0 }}
+ style={(p) => ({
+ flexDirection: 'row',
+ alignItems: 'center',
+ borderRadius: Platform.select({ default: 16, android: 20 }),
+ paddingHorizontal: 10,
+ paddingVertical: 6,
+ justifyContent: 'center',
+ height: 32,
+ opacity: p.pressed ? 0.7 : 1,
+ gap: 6,
+ })}
+ >
+
+
+ {props.currentPath}
+
+
+
+
+ )}
diff --git a/sources/components/EnvironmentVariableCard.tsx b/sources/components/EnvironmentVariableCard.tsx
index 2185e0b21..26647598e 100644
--- a/sources/components/EnvironmentVariableCard.tsx
+++ b/sources/components/EnvironmentVariableCard.tsx
@@ -1,13 +1,16 @@
import React from 'react';
-import { View, Text, TextInput, Pressable } from 'react-native';
+import { View, Text, TextInput, Pressable, Platform } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useUnistyles } from 'react-native-unistyles';
import { Typography } from '@/constants/Typography';
-import { useEnvironmentVariables } from '@/hooks/useEnvironmentVariables';
+import { Switch } from '@/components/Switch';
export interface EnvironmentVariableCardProps {
variable: { name: string; value: string };
machineId: string | null;
+ machineName?: string | null;
+ machineEnv?: Record;
+ isMachineEnvLoading?: boolean;
expectedValue?: string; // From profile documentation
description?: string; // Variable description
isSecret?: boolean; // Whether this is a secret (never query remote)
@@ -24,8 +27,8 @@ function parseVariableValue(value: string): {
remoteVariableName: string;
defaultValue: string;
} {
- // Match: ${VARIABLE_NAME:-default_value}
- const matchWithFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*):-(.*)\}$/);
+ // Match: ${VARIABLE_NAME:-default_value} or ${VARIABLE_NAME:=default_value}
+ const matchWithFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*):[-=](.*)\}$/);
if (matchWithFallback) {
return {
useRemoteVariable: true,
@@ -59,6 +62,9 @@ function parseVariableValue(value: string): {
export function EnvironmentVariableCard({
variable,
machineId,
+ machineName,
+ machineEnv,
+ isMachineEnvLoading = false,
expectedValue,
description,
isSecret = false,
@@ -68,20 +74,42 @@ export function EnvironmentVariableCard({
}: EnvironmentVariableCardProps) {
const { theme } = useUnistyles();
+ const webNoOutline = React.useMemo(() => (Platform.select({
+ web: {
+ outline: 'none',
+ outlineStyle: 'none',
+ outlineWidth: 0,
+ outlineColor: 'transparent',
+ boxShadow: 'none',
+ WebkitBoxShadow: 'none',
+ WebkitAppearance: 'none',
+ },
+ default: {},
+ }) as object), []);
+
+ const secondaryTextStyle = React.useMemo(() => ({
+ fontSize: Platform.select({ ios: 15, default: 14 }),
+ lineHeight: 20,
+ letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }),
+ ...Typography.default(),
+ }), []);
+
+ const remoteToggleLabelStyle = React.useMemo(() => ({
+ fontSize: Platform.select({ ios: 17, default: 16 }),
+ lineHeight: 20,
+ letterSpacing: Platform.select({ ios: -0.24, default: 0.1 }),
+ ...Typography.default(),
+ }), []);
+
// Parse current value
const parsed = parseVariableValue(variable.value);
const [useRemoteVariable, setUseRemoteVariable] = React.useState(parsed.useRemoteVariable);
const [remoteVariableName, setRemoteVariableName] = React.useState(parsed.remoteVariableName);
const [defaultValue, setDefaultValue] = React.useState(parsed.defaultValue);
- // Query remote machine for variable value (only if checkbox enabled and not secret)
- const shouldQueryRemote = useRemoteVariable && !isSecret && remoteVariableName.trim() !== '';
- const { variables: remoteValues } = useEnvironmentVariables(
- machineId,
- shouldQueryRemote ? [remoteVariableName] : []
- );
-
- const remoteValue = remoteValues[remoteVariableName];
+ const remoteValue = machineEnv?.[remoteVariableName];
+ const hasFallback = defaultValue.trim() !== '';
+ const machineLabel = machineName?.trim() ? machineName.trim() : 'machine';
// Update parent when local state changes
React.useEffect(() => {
@@ -98,18 +126,39 @@ export function EnvironmentVariableCard({
const showRemoteDiffersWarning = remoteValue !== null && expectedValue && remoteValue !== expectedValue;
const showDefaultOverrideWarning = expectedValue && defaultValue !== expectedValue;
+ const computedTemplateValue =
+ useRemoteVariable && remoteVariableName.trim() !== ''
+ ? `\${${remoteVariableName}${defaultValue ? `:-${defaultValue}` : ''}}`
+ : defaultValue;
+
+ const resolvedSessionValue =
+ isSecret
+ ? (useRemoteVariable && remoteVariableName
+ ? `\${${remoteVariableName}${defaultValue ? `:-***` : ''}} - hidden for security`
+ : (defaultValue ? '***hidden***' : '(empty)'))
+ : (useRemoteVariable && machineId && remoteValue !== undefined
+ ? (remoteValue === null || remoteValue === '' ? (hasFallback ? defaultValue : '(empty)') : remoteValue)
+ : (computedTemplateValue || '(empty)'));
+
return (
{/* Header row with variable name and action buttons */}
@@ -138,107 +187,171 @@ export function EnvironmentVariableCard({
{/* Description */}
{description && (
{description}
)}
- {/* Checkbox: First try copying variable from remote machine */}
- setUseRemoteVariable(!useRemoteVariable)}
- >
-
- {useRemoteVariable && (
-
- )}
-
-
- First try copying variable from remote machine:
-
-
+ {/* Value label */}
+
+ {useRemoteVariable ? 'Fallback value:' : 'Value:'}
+
- {/* Remote variable name input */}
+ {/* Value input */}
- {/* Remote variable status */}
+ {/* Security message for secrets */}
+ {isSecret && (
+
+ Secret value - not retrieved for security
+
+ )}
+
+ {/* Default override warning */}
+ {showDefaultOverrideWarning && !isSecret && (
+
+ Overriding documented default: {expectedValue}
+
+ )}
+
+
+
+ {/* Toggle: Use value from machine environment */}
+
+
+ Use value from machine environment
+
+
+
+
+
+ Resolved when the session starts on the selected machine.
+
+
+ {/* Source variable name input (only when enabled) */}
+ {useRemoteVariable && (
+ <>
+
+ Source variable
+
+
+ setRemoteVariableName(text.toUpperCase())}
+ autoCapitalize="characters"
+ autoCorrect={false}
+ />
+ >
+ )}
+
+ {/* Machine environment status (only with machine context) */}
{useRemoteVariable && !isSecret && machineId && remoteVariableName.trim() !== '' && (
- {remoteValue === undefined ? (
+ {isMachineEnvLoading || remoteValue === undefined ? (
- ⏳ Checking remote machine...
+ Checking {machineLabel}...
- ) : remoteValue === null ? (
+ ) : (remoteValue === null || remoteValue === '') ? (
- ✗ Value not found
+ {remoteValue === '' ? (
+ hasFallback ? `Empty on ${machineLabel} (using fallback)` : `Empty on ${machineLabel}`
+ ) : (
+ hasFallback ? `Not found on ${machineLabel} (using fallback)` : `Not found on ${machineLabel}`
+ )}
) : (
<>
- ✓ Value found: {remoteValue}
+ Value found on {machineLabel}
{showRemoteDiffersWarning && (
- ⚠️ Differs from documented value: {expectedValue}
+ Differs from documented value: {expectedValue}
)}
>
@@ -246,90 +359,13 @@ export function EnvironmentVariableCard({
)}
- {useRemoteVariable && !isSecret && !machineId && (
-
- ℹ️ Select a machine to check if variable exists
-
- )}
-
- {/* Security message for secrets */}
- {isSecret && (
-
- 🔒 Secret value - not retrieved for security
-
- )}
-
- {/* Default value label */}
-
- Default value:
-
-
- {/* Default value input */}
-
-
- {/* Default override warning */}
- {showDefaultOverrideWarning && !isSecret && (
-
- ⚠️ Overriding documented default: {expectedValue}
-
- )}
-
{/* Session preview */}
- Session will receive: {variable.name} = {
- isSecret
- ? (useRemoteVariable && remoteVariableName
- ? `\${${remoteVariableName}${defaultValue ? `:-***` : ''}} - hidden for security`
- : (defaultValue ? '***hidden***' : '(empty)'))
- : (useRemoteVariable && remoteValue !== undefined && remoteValue !== null
- ? remoteValue
- : defaultValue || '(empty)')
- }
+ Session will receive: {variable.name} = {resolvedSessionValue}
);
diff --git a/sources/components/EnvironmentVariablesList.keys.test.ts b/sources/components/EnvironmentVariablesList.keys.test.ts
new file mode 100644
index 000000000..814e3e8db
--- /dev/null
+++ b/sources/components/EnvironmentVariablesList.keys.test.ts
@@ -0,0 +1,14 @@
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { describe, expect, it } from 'vitest';
+
+describe('EnvironmentVariablesList item keys', () => {
+ it('does not key EnvironmentVariableCard by array index', () => {
+ const file = join(process.cwd(), 'sources/components/EnvironmentVariablesList.tsx');
+ const content = readFileSync(file, 'utf8');
+
+ expect(content).not.toContain('key={index}');
+ expect(content).toContain('key={envVar.name}');
+ });
+});
+
diff --git a/sources/components/EnvironmentVariablesList.tsx b/sources/components/EnvironmentVariablesList.tsx
index e42e61415..9b92e2198 100644
--- a/sources/components/EnvironmentVariablesList.tsx
+++ b/sources/components/EnvironmentVariablesList.tsx
@@ -1,14 +1,19 @@
import React from 'react';
-import { View, Text, Pressable, TextInput } from 'react-native';
+import { View, Text, Pressable, TextInput, Platform } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useUnistyles } from 'react-native-unistyles';
import { Typography } from '@/constants/Typography';
import { EnvironmentVariableCard } from './EnvironmentVariableCard';
import type { ProfileDocumentation } from '@/sync/profileUtils';
+import { Item } from '@/components/Item';
+import { Modal } from '@/modal';
+import { t } from '@/text';
+import { useEnvironmentVariables } from '@/hooks/useEnvironmentVariables';
export interface EnvironmentVariablesListProps {
environmentVariables: Array<{ name: string; value: string }>;
machineId: string | null;
+ machineName?: string | null;
profileDocs?: ProfileDocumentation | null;
onChange: (newVariables: Array<{ name: string; value: string }>) => void;
}
@@ -20,16 +25,55 @@ export interface EnvironmentVariablesListProps {
export function EnvironmentVariablesList({
environmentVariables,
machineId,
+ machineName,
profileDocs,
onChange,
}: EnvironmentVariablesListProps) {
const { theme } = useUnistyles();
+ // Extract variable name from a template value (for matching documentation / machine env lookup)
+ const extractVarNameFromValue = React.useCallback((value: string): string | null => {
+ const match = value.match(/^\$\{([A-Z_][A-Z0-9_]*)/);
+ return match ? match[1] : null;
+ }, []);
+
+ const SECRET_NAME_REGEX = React.useMemo(() => /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i, []);
+
+ const resolvedEnvVarRefs = React.useMemo(() => {
+ const refs = new Set();
+ environmentVariables.forEach((envVar) => {
+ const ref = extractVarNameFromValue(envVar.value);
+ if (!ref) return;
+ // Don't query secret-like env vars from the machine.
+ if (SECRET_NAME_REGEX.test(ref) || SECRET_NAME_REGEX.test(envVar.name)) return;
+ refs.add(ref);
+ });
+ return Array.from(refs);
+ }, [SECRET_NAME_REGEX, environmentVariables, extractVarNameFromValue]);
+
+ const { variables: machineEnv, isLoading: isMachineEnvLoading } = useEnvironmentVariables(
+ machineId,
+ resolvedEnvVarRefs,
+ );
+
// Add variable inline form state
const [showAddForm, setShowAddForm] = React.useState(false);
const [newVarName, setNewVarName] = React.useState('');
const [newVarValue, setNewVarValue] = React.useState('');
+ const webNoOutline = React.useMemo(() => (Platform.select({
+ web: {
+ outline: 'none',
+ outlineStyle: 'none',
+ outlineWidth: 0,
+ outlineColor: 'transparent',
+ boxShadow: 'none',
+ WebkitBoxShadow: 'none',
+ WebkitAppearance: 'none',
+ },
+ default: {},
+ }) as object), []);
+
// Helper to get expected value and description from documentation
const getDocumentation = React.useCallback((varName: string) => {
if (!profileDocs) return { expectedValue: undefined, description: undefined, isSecret: false };
@@ -42,12 +86,6 @@ export function EnvironmentVariablesList({
};
}, [profileDocs]);
- // Extract variable name from value (for matching documentation)
- const extractVarNameFromValue = React.useCallback((value: string): string | null => {
- const match = value.match(/^\$\{([A-Z_][A-Z0-9_]*)/);
- return match ? match[1] : null;
- }, []);
-
const handleUpdateVariable = React.useCallback((index: number, newValue: string) => {
const updated = [...environmentVariables];
updated[index] = { ...updated[index], value: newValue };
@@ -76,20 +114,29 @@ export function EnvironmentVariablesList({
}, [environmentVariables, onChange]);
const handleAddVariable = React.useCallback(() => {
- if (!newVarName.trim()) return;
+ const normalizedName = newVarName.trim().toUpperCase();
+ if (!normalizedName) {
+ Modal.alert(t('common.error'), 'Enter a variable name.');
+ return;
+ }
// Validate variable name format
- if (!/^[A-Z_][A-Z0-9_]*$/.test(newVarName.trim())) {
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(normalizedName)) {
+ Modal.alert(
+ t('common.error'),
+ 'Variable names must be uppercase letters, numbers, and underscores, and cannot start with a number.',
+ );
return;
}
// Check for duplicates
- if (environmentVariables.some(v => v.name === newVarName.trim())) {
+ if (environmentVariables.some(v => v.name === normalizedName)) {
+ Modal.alert(t('common.error'), 'That variable already exists.');
return;
}
onChange([...environmentVariables, {
- name: newVarName.trim(),
+ name: normalizedName,
value: newVarValue.trim() || ''
}]);
@@ -97,162 +144,150 @@ export function EnvironmentVariablesList({
setNewVarName('');
setNewVarValue('');
setShowAddForm(false);
- }, [newVarName, newVarValue, environmentVariables, onChange]);
+ }, [environmentVariables, newVarName, newVarValue, onChange]);
return (
- {/* Section header */}
-
- Environment Variables
-
-
- {/* Add Variable Button */}
- setShowAddForm(true)}
- >
-
- Add Variable
+ Environment Variables
-
+
+
+ {environmentVariables.length > 0 && (
+
+ {environmentVariables.map((envVar, index) => {
+ const varNameFromValue = extractVarNameFromValue(envVar.value);
+ const docs = getDocumentation(varNameFromValue || envVar.name);
+ const isSecret =
+ docs.isSecret ||
+ SECRET_NAME_REGEX.test(envVar.name) ||
+ SECRET_NAME_REGEX.test(varNameFromValue || '');
+
+ return (
+ handleUpdateVariable(index, newValue)}
+ onDelete={() => handleDeleteVariable(index)}
+ onDuplicate={() => handleDuplicateVariable(index)}
+ />
+ );
+ })}
+
+ )}
+
+
+
+ }
+ showChevron={false}
+ onPress={() => {
+ if (showAddForm) {
+ setShowAddForm(false);
+ setNewVarName('');
+ setNewVarValue('');
+ } else {
+ setShowAddForm(true);
+ }
+ }}
+ />
+
+ {showAddForm && (
+
+
+ setNewVarName(text.toUpperCase())}
+ autoCapitalize="characters"
+ autoCorrect={false}
+ />
+
+
+
+
+
- {/* Add variable inline form */}
- {showAddForm && (
-
-
-
-
- {
- setShowAddForm(false);
- setNewVarName('');
- setNewVarValue('');
- }}
- >
-
- Cancel
-
-
({
backgroundColor: theme.colors.button.primary.background,
- borderRadius: 6,
- padding: theme.margins.sm,
+ borderRadius: 10,
+ paddingVertical: 10,
alignItems: 'center',
- }}
- onPress={handleAddVariable}
+ opacity: !newVarName.trim() ? 0.5 : pressed ? 0.85 : 1,
+ })}
>
-
+
Add
-
- )}
-
- {/* Variable cards */}
- {environmentVariables.map((envVar, index) => {
- const varNameFromValue = extractVarNameFromValue(envVar.value);
- const docs = getDocumentation(varNameFromValue || envVar.name);
-
- // Auto-detect secrets if not explicitly documented
- const isSecret = docs.isSecret || /TOKEN|KEY|SECRET|AUTH/i.test(envVar.name) || /TOKEN|KEY|SECRET|AUTH/i.test(varNameFromValue || '');
-
- return (
- handleUpdateVariable(index, newValue)}
- onDelete={() => handleDeleteVariable(index)}
- onDuplicate={() => handleDuplicateVariable(index)}
- />
- );
- })}
+ )}
+
);
}
diff --git a/sources/components/Item.tsx b/sources/components/Item.tsx
index 379a815d4..e761b3f98 100644
--- a/sources/components/Item.tsx
+++ b/sources/components/Item.tsx
@@ -15,6 +15,7 @@ import * as Clipboard from 'expo-clipboard';
import { Modal } from '@/modal';
import { t } from '@/text';
import { StyleSheet, useUnistyles } from 'react-native-unistyles';
+import { ItemGroupSelectionContext } from '@/components/ItemGroup';
export interface ItemProps {
title: string;
@@ -111,7 +112,8 @@ const stylesheet = StyleSheet.create((theme, runtime) => ({
export const Item = React.memo((props) => {
const { theme } = useUnistyles();
const styles = stylesheet;
-
+ const selectionContext = React.useContext(ItemGroupSelectionContext);
+
// Platform-specific measurements
const isIOS = Platform.OS === 'ios';
const isAndroid = Platform.OS === 'android';
@@ -197,9 +199,10 @@ export const Item = React.memo((props) => {
// The copy will be handled by long press instead
const handlePress = onPress;
- const isInteractive = handlePress || onLongPress || (copy && !isWeb);
- const showAccessory = isInteractive && showChevron && !rightElement;
- const chevronSize = (isIOS && !isWeb) ? 17 : 24;
+ const isInteractive = handlePress || onLongPress || (copy && !isWeb);
+ const showAccessory = isInteractive && showChevron && !rightElement;
+ const chevronSize = (isIOS && !isWeb) ? 17 : 24;
+ const showSelectedBackground = !!selected && ((selectionContext?.selectableItemCount ?? 2) > 1);
const titleColor = destructive ? styles.titleDestructive : (selected ? styles.titleSelected : styles.titleNormal);
const containerPadding = subtitle ? styles.containerWithSubtitle : styles.containerWithoutSubtitle;
@@ -285,21 +288,23 @@ export const Item = React.memo((props) => {
>
);
- if (isInteractive) {
- return (
- [
- {
- backgroundColor: pressed && isIOS && !isWeb ? theme.colors.surfacePressedOverlay : 'transparent',
- opacity: disabled ? 0.5 : 1
- },
- pressableStyle
- ]}
+ disabled={disabled || loading}
+ style={({ pressed }) => [
+ {
+ backgroundColor: pressed && isIOS && !isWeb
+ ? theme.colors.surfacePressedOverlay
+ : (showSelectedBackground ? theme.colors.surfaceSelected : 'transparent'),
+ opacity: disabled ? 0.5 : 1
+ },
+ pressableStyle
+ ]}
android_ripple={(isAndroid || isWeb) ? {
color: theme.colors.surfaceRipple,
borderless: false,
diff --git a/sources/components/ItemActionsMenuModal.tsx b/sources/components/ItemActionsMenuModal.tsx
new file mode 100644
index 000000000..387a94b60
--- /dev/null
+++ b/sources/components/ItemActionsMenuModal.tsx
@@ -0,0 +1,91 @@
+import React from 'react';
+import { View, Text, ScrollView, Pressable } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { useUnistyles } from 'react-native-unistyles';
+import { Typography } from '@/constants/Typography';
+import { ItemGroup } from '@/components/ItemGroup';
+import { Item } from '@/components/Item';
+
+export type ItemAction = {
+ id: string;
+ title: string;
+ icon: React.ComponentProps['name'];
+ onPress: () => void;
+ destructive?: boolean;
+ color?: string;
+};
+
+export interface ItemActionsMenuModalProps {
+ title: string;
+ actions: ItemAction[];
+ onClose: () => void;
+}
+
+export function ItemActionsMenuModal(props: ItemActionsMenuModalProps) {
+ const { theme } = useUnistyles();
+
+ const closeThen = React.useCallback((fn: () => void) => {
+ props.onClose();
+ setTimeout(() => fn(), 0);
+ }, [props]);
+
+ return (
+
+
+
+ {props.title}
+
+
+ ({ opacity: pressed ? 0.7 : 1 })}
+ >
+
+
+
+
+
+
+ {props.actions.map((action, idx) => (
+
+ }
+ onPress={() => closeThen(action.onPress)}
+ showChevron={false}
+ showDivider={idx < props.actions.length - 1}
+ />
+ ))}
+
+
+
+ );
+}
diff --git a/sources/components/ItemGroup.tsx b/sources/components/ItemGroup.tsx
index 0e046fb86..006f534d3 100644
--- a/sources/components/ItemGroup.tsx
+++ b/sources/components/ItemGroup.tsx
@@ -11,6 +11,8 @@ import { Typography } from '@/constants/Typography';
import { layout } from './layout';
import { StyleSheet, useUnistyles } from 'react-native-unistyles';
+export const ItemGroupSelectionContext = React.createContext<{ selectableItemCount: number } | null>(null);
+
interface ItemChildProps {
showDivider?: boolean;
[key: string]: any;
@@ -95,6 +97,25 @@ export const ItemGroup = React.memo((props) => {
containerStyle
} = props;
+ const selectableItemCount = React.useMemo(() => {
+ const countSelectable = (node: React.ReactNode): number => {
+ return React.Children.toArray(node).reduce((count, child) => {
+ if (!React.isValidElement(child)) {
+ return count;
+ }
+ if (child.type === React.Fragment) {
+ return count + countSelectable(child.props.children);
+ }
+ const propsAny = child.props as any;
+ const hasTitle = typeof propsAny?.title === 'string';
+ const isSelectable = typeof propsAny?.onPress === 'function' || typeof propsAny?.onLongPress === 'function';
+ return count + (hasTitle && isSelectable ? 1 : 0);
+ }, 0);
+ };
+
+ return countSelectable(children);
+ }, [children]);
+
return (
@@ -116,21 +137,23 @@ export const ItemGroup = React.memo((props) => {
{/* Content Container */}
- {React.Children.map(children, (child, index) => {
- if (React.isValidElement(child)) {
- // Don't add props to React.Fragment
- if (child.type === React.Fragment) {
- return child;
+
+ {React.Children.map(children, (child, index) => {
+ if (React.isValidElement(child)) {
+ // Don't add props to React.Fragment
+ if (child.type === React.Fragment) {
+ return child;
+ }
+ const isLast = index === React.Children.count(children) - 1;
+ const childProps = child.props as ItemChildProps;
+ return React.cloneElement(child, {
+ ...childProps,
+ showDivider: !isLast && childProps.showDivider !== false
+ });
}
- const isLast = index === React.Children.count(children) - 1;
- const childProps = child.props as ItemChildProps;
- return React.cloneElement(child, {
- ...childProps,
- showDivider: !isLast && childProps.showDivider !== false
- });
- }
- return child;
- })}
+ return child;
+ })}
+
{/* Footer */}
@@ -144,4 +167,4 @@ export const ItemGroup = React.memo((props) => {
);
-});
\ No newline at end of file
+});
diff --git a/sources/components/ItemRowActions.tsx b/sources/components/ItemRowActions.tsx
new file mode 100644
index 000000000..9d2789411
--- /dev/null
+++ b/sources/components/ItemRowActions.tsx
@@ -0,0 +1,85 @@
+import React from 'react';
+import { View, Pressable, useWindowDimensions } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { useUnistyles } from 'react-native-unistyles';
+import { Modal } from '@/modal';
+import { ItemActionsMenuModal, type ItemAction } from '@/components/ItemActionsMenuModal';
+
+export interface ItemRowActionsProps {
+ title: string;
+ actions: ItemAction[];
+ compactThreshold?: number;
+ compactActionIds?: string[];
+ iconSize?: number;
+ gap?: number;
+ onActionPressIn?: () => void;
+}
+
+export function ItemRowActions(props: ItemRowActionsProps) {
+ const { theme } = useUnistyles();
+ const { width } = useWindowDimensions();
+ const compact = width < (props.compactThreshold ?? 420);
+
+ const compactIds = React.useMemo(() => new Set(props.compactActionIds ?? []), [props.compactActionIds]);
+ const inlineActions = React.useMemo(() => {
+ if (!compact) return props.actions;
+ return props.actions.filter((a) => compactIds.has(a.id));
+ }, [compact, compactIds, props.actions]);
+ const overflowActions = React.useMemo(() => {
+ if (!compact) return [];
+ return props.actions.filter((a) => !compactIds.has(a.id));
+ }, [compact, compactIds, props.actions]);
+
+ const openMenu = React.useCallback(() => {
+ if (overflowActions.length === 0) return;
+ Modal.show({
+ component: ItemActionsMenuModal,
+ props: {
+ title: props.title,
+ actions: overflowActions,
+ },
+ } as any);
+ }, [overflowActions, props.title]);
+
+ const iconSize = props.iconSize ?? 20;
+ const gap = props.gap ?? 16;
+
+ return (
+
+ {inlineActions.map((action) => (
+ props.onActionPressIn?.()}
+ onPress={(e: any) => {
+ e?.stopPropagation?.();
+ action.onPress();
+ }}
+ >
+
+
+ ))}
+
+ {compact && overflowActions.length > 0 && (
+ props.onActionPressIn?.()}
+ onPress={(e: any) => {
+ e?.stopPropagation?.();
+ openMenu();
+ }}
+ >
+
+
+ )}
+
+ );
+}
diff --git a/sources/components/NewSessionWizard.tsx b/sources/components/NewSessionWizard.tsx
deleted file mode 100644
index ea556c99f..000000000
--- a/sources/components/NewSessionWizard.tsx
+++ /dev/null
@@ -1,1917 +0,0 @@
-import React, { useState, useMemo } from 'react';
-import { View, Text, Pressable, ScrollView, TextInput } from 'react-native';
-import { StyleSheet, useUnistyles } from 'react-native-unistyles';
-import { Typography } from '@/constants/Typography';
-import { t } from '@/text';
-import { Ionicons } from '@expo/vector-icons';
-import { SessionTypeSelector } from '@/components/SessionTypeSelector';
-import { PermissionModeSelector, PermissionMode, ModelMode } from '@/components/PermissionModeSelector';
-import { ItemGroup } from '@/components/ItemGroup';
-import { Item } from '@/components/Item';
-import { useAllMachines, useSessions, useSetting, storage } from '@/sync/storage';
-import { useRouter } from 'expo-router';
-import { AIBackendProfile, validateProfileForAgent, getProfileEnvironmentVariables } from '@/sync/settings';
-import { Modal } from '@/modal';
-import { sync } from '@/sync/sync';
-import { profileSyncService } from '@/sync/profileSync';
-
-const stylesheet = StyleSheet.create((theme) => ({
- container: {
- flex: 1,
- },
- header: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- paddingHorizontal: 24,
- paddingVertical: 16,
- borderBottomWidth: 1,
- borderBottomColor: theme.colors.divider,
- },
- headerTitle: {
- fontSize: 18,
- fontWeight: '600',
- color: theme.colors.text,
- ...Typography.default('semiBold'),
- },
- stepIndicator: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingHorizontal: 24,
- paddingVertical: 16,
- borderBottomWidth: 1,
- borderBottomColor: theme.colors.divider,
- },
- stepDot: {
- width: 8,
- height: 8,
- borderRadius: 4,
- marginHorizontal: 4,
- },
- stepDotActive: {
- backgroundColor: theme.colors.button.primary.background,
- },
- stepDotInactive: {
- backgroundColor: theme.colors.divider,
- },
- stepContent: {
- flex: 1,
- paddingHorizontal: 24,
- paddingTop: 24,
- paddingBottom: 0, // No bottom padding since footer is separate
- },
- stepTitle: {
- fontSize: 20,
- fontWeight: '600',
- color: theme.colors.text,
- marginBottom: 8,
- ...Typography.default('semiBold'),
- },
- stepDescription: {
- fontSize: 16,
- color: theme.colors.textSecondary,
- marginBottom: 24,
- ...Typography.default(),
- },
- footer: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- paddingHorizontal: 24,
- paddingVertical: 16,
- borderTopWidth: 1,
- borderTopColor: theme.colors.divider,
- backgroundColor: theme.colors.surface, // Ensure footer has solid background
- },
- button: {
- paddingHorizontal: 16,
- paddingVertical: 12,
- borderRadius: 8,
- minWidth: 100,
- alignItems: 'center',
- justifyContent: 'center',
- },
- buttonPrimary: {
- backgroundColor: theme.colors.button.primary.background,
- },
- buttonSecondary: {
- backgroundColor: 'transparent',
- borderWidth: 1,
- borderColor: theme.colors.divider,
- },
- buttonText: {
- fontSize: 16,
- fontWeight: '600',
- ...Typography.default('semiBold'),
- },
- buttonTextPrimary: {
- color: '#FFFFFF',
- },
- buttonTextSecondary: {
- color: theme.colors.text,
- },
- textInput: {
- backgroundColor: theme.colors.input.background,
- borderRadius: 8,
- paddingHorizontal: 12,
- paddingVertical: 10,
- fontSize: 16,
- color: theme.colors.text,
- borderWidth: 1,
- borderColor: theme.colors.divider,
- ...Typography.default(),
- },
- agentOption: {
- flexDirection: 'row',
- alignItems: 'center',
- padding: 16,
- borderRadius: 12,
- borderWidth: 2,
- marginBottom: 12,
- },
- agentOptionSelected: {
- borderColor: theme.colors.button.primary.background,
- backgroundColor: theme.colors.input.background,
- },
- agentOptionUnselected: {
- borderColor: theme.colors.divider,
- backgroundColor: theme.colors.input.background,
- },
- agentIcon: {
- width: 40,
- height: 40,
- borderRadius: 20,
- backgroundColor: theme.colors.button.primary.background,
- alignItems: 'center',
- justifyContent: 'center',
- marginRight: 16,
- },
- agentInfo: {
- flex: 1,
- },
- agentName: {
- fontSize: 16,
- fontWeight: '600',
- color: theme.colors.text,
- ...Typography.default('semiBold'),
- },
- agentDescription: {
- fontSize: 14,
- color: theme.colors.textSecondary,
- marginTop: 4,
- ...Typography.default(),
- },
-}));
-
-type WizardStep = 'profile' | 'profileConfig' | 'sessionType' | 'agent' | 'options' | 'machine' | 'path' | 'prompt';
-
-// Profile selection item component with management actions
-interface ProfileSelectionItemProps {
- profile: AIBackendProfile;
- isSelected: boolean;
- onSelect: () => void;
- onUseAsIs: () => void;
- onEdit: () => void;
- onDuplicate?: () => void;
- onDelete?: () => void;
- showManagementActions?: boolean;
-}
-
-function ProfileSelectionItem({ profile, isSelected, onSelect, onUseAsIs, onEdit, onDuplicate, onDelete, showManagementActions = false }: ProfileSelectionItemProps) {
- const { theme } = useUnistyles();
- const styles = stylesheet;
-
- return (
-
- {/* Profile Header */}
-
-
-
-
-
-
-
- {profile.name}
-
-
- {profile.description}
-
- {profile.isBuiltIn && (
-
- Built-in profile
-
- )}
-
- {isSelected && (
-
- )}
-
-
-
- {/* Action Buttons - Only show when selected */}
- {isSelected && (
-
- {/* Primary Actions */}
-
-
-
-
- Use As-Is
-
-
-
-
-
-
- Edit
-
-
-
-
- {/* Management Actions - Only show for custom profiles */}
- {showManagementActions && !profile.isBuiltIn && (
-
-
-
-
- Duplicate
-
-
-
-
-
-
- Delete
-
-
-
- )}
-
- )}
-
- );
-}
-
-// Manual configuration item component
-interface ManualConfigurationItemProps {
- isSelected: boolean;
- onSelect: () => void;
- onUseCliVars: () => void;
- onConfigureManually: () => void;
-}
-
-function ManualConfigurationItem({ isSelected, onSelect, onUseCliVars, onConfigureManually }: ManualConfigurationItemProps) {
- const { theme } = useUnistyles();
- const styles = stylesheet;
-
- return (
-
- {/* Profile Header */}
-
-
-
-
-
-
-
- Manual Configuration
-
-
- Use CLI environment variables or configure manually
-
-
- {isSelected && (
-
- )}
-
-
-
- {/* Action Buttons - Only show when selected */}
- {isSelected && (
-
-
-
-
- Use CLI Vars
-
-
-
-
-
-
- Configure
-
-
-
- )}
-
- );
-}
-
-interface NewSessionWizardProps {
- onComplete: (config: {
- sessionType: 'simple' | 'worktree';
- profileId: string | null;
- agentType: 'claude' | 'codex';
- permissionMode: PermissionMode;
- modelMode: ModelMode;
- machineId: string;
- path: string;
- prompt: string;
- environmentVariables?: Record;
- }) => void;
- onCancel: () => void;
- initialPrompt?: string;
-}
-
-export function NewSessionWizard({ onComplete, onCancel, initialPrompt = '' }: NewSessionWizardProps) {
- const { theme } = useUnistyles();
- const styles = stylesheet;
- const router = useRouter();
- const machines = useAllMachines();
- const sessions = useSessions();
- const experimentsEnabled = useSetting('experiments');
- const recentMachinePaths = useSetting('recentMachinePaths');
- const lastUsedAgent = useSetting('lastUsedAgent');
- const lastUsedPermissionMode = useSetting('lastUsedPermissionMode');
- const lastUsedModelMode = useSetting('lastUsedModelMode');
- const profiles = useSetting('profiles');
- const lastUsedProfile = useSetting('lastUsedProfile');
-
- // Wizard state
- const [currentStep, setCurrentStep] = useState('profile');
- const [sessionType, setSessionType] = useState<'simple' | 'worktree'>('simple');
- const [agentType, setAgentType] = useState<'claude' | 'codex'>(() => {
- if (lastUsedAgent === 'claude' || lastUsedAgent === 'codex') {
- return lastUsedAgent;
- }
- return 'claude';
- });
- const [permissionMode, setPermissionMode] = useState('default');
- const [modelMode, setModelMode] = useState('default');
- const [selectedProfileId, setSelectedProfileId] = useState(() => {
- return lastUsedProfile;
- });
-
- // Built-in profiles
- const builtInProfiles: AIBackendProfile[] = useMemo(() => [
- {
- id: 'anthropic',
- name: 'Anthropic (Default)',
- description: 'Default Claude configuration',
- anthropicConfig: {},
- environmentVariables: [],
- compatibility: { claude: true, codex: false, gemini: false },
- isBuiltIn: true,
- createdAt: Date.now(),
- updatedAt: Date.now(),
- version: '1.0.0',
- },
- {
- id: 'deepseek',
- name: 'DeepSeek (Reasoner)',
- description: 'DeepSeek reasoning model with proxy to Anthropic API',
- anthropicConfig: {
- baseUrl: 'https://api.deepseek.com/anthropic',
- model: 'deepseek-reasoner',
- },
- environmentVariables: [
- { name: 'API_TIMEOUT_MS', value: '600000' },
- { name: 'ANTHROPIC_SMALL_FAST_MODEL', value: 'deepseek-chat' },
- { name: 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', value: '1' },
- ],
- compatibility: { claude: true, codex: false, gemini: false },
- isBuiltIn: true,
- createdAt: Date.now(),
- updatedAt: Date.now(),
- version: '1.0.0',
- },
- {
- id: 'openai',
- name: 'OpenAI (GPT-4/Codex)',
- description: 'OpenAI GPT-4 and Codex models',
- openaiConfig: {
- baseUrl: 'https://api.openai.com/v1',
- model: 'gpt-4-turbo',
- },
- environmentVariables: [],
- compatibility: { claude: false, codex: true, gemini: false },
- isBuiltIn: true,
- createdAt: Date.now(),
- updatedAt: Date.now(),
- version: '1.0.0',
- },
- {
- id: 'azure-openai-codex',
- name: 'Azure OpenAI (Codex)',
- description: 'Microsoft Azure OpenAI for Codex agents',
- azureOpenAIConfig: {
- endpoint: 'https://your-resource.openai.azure.com/',
- apiVersion: '2024-02-15-preview',
- deploymentName: 'gpt-4-turbo',
- },
- environmentVariables: [],
- compatibility: { claude: false, codex: true, gemini: false },
- isBuiltIn: true,
- createdAt: Date.now(),
- updatedAt: Date.now(),
- version: '1.0.0',
- },
- {
- id: 'azure-openai',
- name: 'Azure OpenAI',
- description: 'Microsoft Azure OpenAI configuration',
- azureOpenAIConfig: {
- apiVersion: '2024-02-15-preview',
- },
- environmentVariables: [
- { name: 'AZURE_OPENAI_API_VERSION', value: '2024-02-15-preview' },
- ],
- compatibility: { claude: false, codex: true, gemini: false },
- isBuiltIn: true,
- createdAt: Date.now(),
- updatedAt: Date.now(),
- version: '1.0.0',
- },
- {
- id: 'zai',
- name: 'Z.ai (GLM-4.6)',
- description: 'Z.ai GLM-4.6 model with proxy to Anthropic API',
- anthropicConfig: {
- baseUrl: 'https://api.z.ai/api/anthropic',
- model: 'glm-4.6',
- },
- environmentVariables: [],
- compatibility: { claude: true, codex: false, gemini: false },
- isBuiltIn: true,
- createdAt: Date.now(),
- updatedAt: Date.now(),
- version: '1.0.0',
- },
- {
- id: 'microsoft',
- name: 'Microsoft Azure',
- description: 'Microsoft Azure AI services',
- openaiConfig: {
- baseUrl: 'https://api.openai.azure.com',
- model: 'gpt-4-turbo',
- },
- environmentVariables: [],
- compatibility: { claude: false, codex: true, gemini: false },
- isBuiltIn: true,
- createdAt: Date.now(),
- updatedAt: Date.now(),
- version: '1.0.0',
- },
- ], []);
-
- // Combined profiles
- const allProfiles = useMemo(() => {
- return [...builtInProfiles, ...profiles];
- }, [profiles, builtInProfiles]);
-
- const [selectedMachineId, setSelectedMachineId] = useState(() => {
- if (machines.length > 0) {
- // Check if we have a recently used machine that's currently available
- if (recentMachinePaths.length > 0) {
- for (const recent of recentMachinePaths) {
- if (machines.find(m => m.id === recent.machineId)) {
- return recent.machineId;
- }
- }
- }
- return machines[0].id;
- }
- return '';
- });
- const [selectedPath, setSelectedPath] = useState(() => {
- if (machines.length > 0 && selectedMachineId) {
- const machine = machines.find(m => m.id === selectedMachineId);
- return machine?.metadata?.homeDir || '/home';
- }
- return '/home';
- });
- const [prompt, setPrompt] = useState(initialPrompt);
- const [customPath, setCustomPath] = useState('');
- const [showCustomPathInput, setShowCustomPathInput] = useState(false);
-
- // Profile configuration state
- const [profileApiKeys, setProfileApiKeys] = useState>>({});
- const [profileConfigs, setProfileConfigs] = useState>>({});
-
- // Dynamic steps based on whether profile needs configuration
- const steps: WizardStep[] = React.useMemo(() => {
- const baseSteps: WizardStep[] = experimentsEnabled
- ? ['profile', 'sessionType', 'agent', 'options', 'machine', 'path', 'prompt']
- : ['profile', 'agent', 'options', 'machine', 'path', 'prompt'];
-
- // Insert profileConfig step after profile if needed
- if (profileNeedsConfiguration(selectedProfileId)) {
- const profileIndex = baseSteps.indexOf('profile');
- const beforeProfile = baseSteps.slice(0, profileIndex + 1) as WizardStep[];
- const afterProfile = baseSteps.slice(profileIndex + 1) as WizardStep[];
- return [
- ...beforeProfile,
- 'profileConfig',
- ...afterProfile
- ] as WizardStep[];
- }
-
- return baseSteps;
- }, [experimentsEnabled, selectedProfileId]);
-
- // Helper function to check if profile needs API keys
- const profileNeedsConfiguration = (profileId: string | null): boolean => {
- if (!profileId) return false; // Manual configuration doesn't need API keys
- const profile = allProfiles.find(p => p.id === profileId);
- if (!profile) return false;
-
- // Check if profile is one that requires API keys
- const profilesNeedingKeys = ['openai', 'azure-openai', 'azure-openai-codex', 'zai', 'microsoft', 'deepseek'];
- return profilesNeedingKeys.includes(profile.id);
- };
-
- // Get required fields for profile configuration
- const getProfileRequiredFields = (profileId: string | null): Array<{key: string, label: string, placeholder: string, isPassword?: boolean}> => {
- if (!profileId) return [];
- const profile = allProfiles.find(p => p.id === profileId);
- if (!profile) return [];
-
- switch (profile.id) {
- case 'deepseek':
- return [
- { key: 'ANTHROPIC_AUTH_TOKEN', label: 'DeepSeek API Key', placeholder: 'DEEPSEEK_API_KEY', isPassword: true }
- ];
- case 'openai':
- return [
- { key: 'OPENAI_API_KEY', label: 'OpenAI API Key', placeholder: 'sk-...', isPassword: true }
- ];
- case 'azure-openai':
- return [
- { key: 'AZURE_OPENAI_API_KEY', label: 'Azure OpenAI API Key', placeholder: 'Enter your Azure OpenAI API key', isPassword: true },
- { key: 'AZURE_OPENAI_ENDPOINT', label: 'Azure Endpoint', placeholder: 'https://your-resource.openai.azure.com/' },
- { key: 'AZURE_OPENAI_DEPLOYMENT_NAME', label: 'Deployment Name', placeholder: 'gpt-4-turbo' }
- ];
- case 'zai':
- return [
- { key: 'ANTHROPIC_AUTH_TOKEN', label: 'Z.ai API Key', placeholder: 'Z_AI_API_KEY', isPassword: true }
- ];
- case 'microsoft':
- return [
- { key: 'AZURE_OPENAI_API_KEY', label: 'Azure API Key', placeholder: 'Enter your Azure API key', isPassword: true },
- { key: 'AZURE_OPENAI_ENDPOINT', label: 'Azure Endpoint', placeholder: 'https://your-resource.openai.azure.com/' },
- { key: 'AZURE_OPENAI_DEPLOYMENT_NAME', label: 'Deployment Name', placeholder: 'gpt-4-turbo' }
- ];
- case 'azure-openai-codex':
- return [
- { key: 'AZURE_OPENAI_API_KEY', label: 'Azure OpenAI API Key', placeholder: 'Enter your Azure OpenAI API key', isPassword: true },
- { key: 'AZURE_OPENAI_ENDPOINT', label: 'Azure Endpoint', placeholder: 'https://your-resource.openai.azure.com/' },
- { key: 'AZURE_OPENAI_DEPLOYMENT_NAME', label: 'Deployment Name', placeholder: 'gpt-4-turbo' }
- ];
- default:
- return [];
- }
- };
-
- // Auto-load profile settings and sync with CLI
- React.useEffect(() => {
- if (selectedProfileId) {
- const selectedProfile = allProfiles.find(p => p.id === selectedProfileId);
- if (selectedProfile) {
- // Auto-select agent type based on profile compatibility
- if (selectedProfile.compatibility.claude && !selectedProfile.compatibility.codex) {
- setAgentType('claude');
- } else if (selectedProfile.compatibility.codex && !selectedProfile.compatibility.claude) {
- setAgentType('codex');
- }
-
- // Sync active profile to CLI
- profileSyncService.setActiveProfile(selectedProfileId).catch(error => {
- console.error('[Wizard] Failed to sync active profile to CLI:', error);
- });
- }
- }
- }, [selectedProfileId, allProfiles]);
-
- // Sync profiles with CLI on component mount and when profiles change
- React.useEffect(() => {
- const syncProfiles = async () => {
- try {
- await profileSyncService.bidirectionalSync(allProfiles);
- } catch (error) {
- console.error('[Wizard] Failed to sync profiles with CLI:', error);
- // Continue without sync - profiles work locally
- }
- };
-
- // Sync on mount
- syncProfiles();
-
- // Set up sync listener for profile changes
- const handleSyncEvent = (event: any) => {
- if (event.status === 'error') {
- console.warn('[Wizard] Profile sync error:', event.error);
- }
- };
-
- profileSyncService.addEventListener(handleSyncEvent);
-
- return () => {
- profileSyncService.removeEventListener(handleSyncEvent);
- };
- }, [allProfiles]);
-
- // Get recent paths for the selected machine
- const recentPaths = useMemo(() => {
- if (!selectedMachineId) return [];
-
- const paths: string[] = [];
- const pathSet = new Set();
-
- // First, add paths from recentMachinePaths (these are the most recent)
- recentMachinePaths.forEach(entry => {
- if (entry.machineId === selectedMachineId && !pathSet.has(entry.path)) {
- paths.push(entry.path);
- pathSet.add(entry.path);
- }
- });
-
- // Then add paths from sessions if we need more
- if (sessions) {
- const pathsWithTimestamps: Array<{ path: string; timestamp: number }> = [];
-
- sessions.forEach(item => {
- if (typeof item === 'string') return; // Skip section headers
-
- const session = item as any;
- if (session.metadata?.machineId === selectedMachineId && session.metadata?.path) {
- const path = session.metadata.path;
- if (!pathSet.has(path)) {
- pathSet.add(path);
- pathsWithTimestamps.push({
- path,
- timestamp: session.updatedAt || session.createdAt
- });
- }
- }
- });
-
- // Sort session paths by most recent first and add them
- pathsWithTimestamps
- .sort((a, b) => b.timestamp - a.timestamp)
- .forEach(item => paths.push(item.path));
- }
-
- return paths;
- }, [sessions, selectedMachineId, recentMachinePaths]);
-
- const currentStepIndex = steps.indexOf(currentStep);
- const isFirstStep = currentStepIndex === 0;
- const isLastStep = currentStepIndex === steps.length - 1;
-
- // Handler for "Use Profile As-Is" - quick session creation
- const handleUseProfileAsIs = (profile: AIBackendProfile) => {
- setSelectedProfileId(profile.id);
-
- // Auto-select agent type based on profile compatibility
- if (profile.compatibility.claude && !profile.compatibility.codex) {
- setAgentType('claude');
- } else if (profile.compatibility.codex && !profile.compatibility.claude) {
- setAgentType('codex');
- }
-
- // Get environment variables from profile (no user configuration)
- const environmentVariables = getProfileEnvironmentVariables(profile);
-
- // Complete wizard immediately with profile settings
- onComplete({
- sessionType,
- profileId: profile.id,
- agentType: agentType || (profile.compatibility.claude ? 'claude' : 'codex'),
- permissionMode,
- modelMode,
- machineId: selectedMachineId,
- path: showCustomPathInput && customPath.trim() ? customPath.trim() : selectedPath,
- prompt,
- environmentVariables,
- });
- };
-
- // Handler for "Edit Profile" - load profile and go to configuration step
- const handleEditProfile = (profile: AIBackendProfile) => {
- setSelectedProfileId(profile.id);
-
- // Auto-select agent type based on profile compatibility
- if (profile.compatibility.claude && !profile.compatibility.codex) {
- setAgentType('claude');
- } else if (profile.compatibility.codex && !profile.compatibility.claude) {
- setAgentType('codex');
- }
-
- // If profile needs configuration, go to profileConfig step
- if (profileNeedsConfiguration(profile.id)) {
- setCurrentStep('profileConfig');
- } else {
- // If no configuration needed, proceed to next step in the normal flow
- const profileIndex = steps.indexOf('profile');
- setCurrentStep(steps[profileIndex + 1]);
- }
- };
-
- // Handler for "Create New Profile"
- const handleCreateProfile = () => {
- Modal.prompt(
- 'Create New Profile',
- 'Enter a name for your new profile:',
- {
- defaultValue: 'My Custom Profile',
- confirmText: 'Create',
- cancelText: 'Cancel'
- }
- ).then((profileName) => {
- if (profileName && profileName.trim()) {
- const newProfile: AIBackendProfile = {
- id: crypto.randomUUID(),
- name: profileName.trim(),
- description: 'Custom AI profile',
- anthropicConfig: {},
- environmentVariables: [],
- compatibility: { claude: true, codex: true, gemini: true },
- isBuiltIn: false,
- createdAt: Date.now(),
- updatedAt: Date.now(),
- version: '1.0.0',
- };
-
- // Get current profiles from settings
- const currentProfiles = storage.getState().settings.profiles || [];
- const updatedProfiles = [...currentProfiles, newProfile];
-
- // Persist through settings system
- sync.applySettings({ profiles: updatedProfiles });
-
- // Sync with CLI
- profileSyncService.syncGuiToCli(updatedProfiles).catch(error => {
- console.error('[Wizard] Failed to sync new profile with CLI:', error);
- });
-
- // Auto-select the newly created profile
- setSelectedProfileId(newProfile.id);
- }
- });
- };
-
- // Handler for "Duplicate Profile"
- const handleDuplicateProfile = (profile: AIBackendProfile) => {
- Modal.prompt(
- 'Duplicate Profile',
- `Enter a name for the duplicate of "${profile.name}":`,
- {
- defaultValue: `${profile.name} (Copy)`,
- confirmText: 'Duplicate',
- cancelText: 'Cancel'
- }
- ).then((newName) => {
- if (newName && newName.trim()) {
- const duplicatedProfile: AIBackendProfile = {
- ...profile,
- id: crypto.randomUUID(),
- name: newName.trim(),
- description: profile.description ? `Copy of ${profile.description}` : 'Custom AI profile',
- isBuiltIn: false,
- createdAt: Date.now(),
- updatedAt: Date.now(),
- };
-
- // Get current profiles from settings
- const currentProfiles = storage.getState().settings.profiles || [];
- const updatedProfiles = [...currentProfiles, duplicatedProfile];
-
- // Persist through settings system
- sync.applySettings({ profiles: updatedProfiles });
-
- // Sync with CLI
- profileSyncService.syncGuiToCli(updatedProfiles).catch(error => {
- console.error('[Wizard] Failed to sync duplicated profile with CLI:', error);
- });
- }
- });
- };
-
- // Handler for "Delete Profile"
- const handleDeleteProfile = (profile: AIBackendProfile) => {
- Modal.confirm(
- 'Delete Profile',
- `Are you sure you want to delete "${profile.name}"? This action cannot be undone.`,
- {
- confirmText: 'Delete',
- destructive: true
- }
- ).then((confirmed) => {
- if (confirmed) {
- // Get current profiles from settings
- const currentProfiles = storage.getState().settings.profiles || [];
- const updatedProfiles = currentProfiles.filter(p => p.id !== profile.id);
-
- // Persist through settings system
- sync.applySettings({ profiles: updatedProfiles });
-
- // Sync with CLI
- profileSyncService.syncGuiToCli(updatedProfiles).catch(error => {
- console.error('[Wizard] Failed to sync profile deletion with CLI:', error);
- });
-
- // Clear selection if deleted profile was selected
- if (selectedProfileId === profile.id) {
- setSelectedProfileId(null);
- }
- }
- });
- };
-
- // Handler for "Use CLI Environment Variables" - quick session creation with CLI vars
- const handleUseCliEnvironmentVariables = () => {
- setSelectedProfileId(null);
-
- // Complete wizard immediately with no profile (rely on CLI environment variables)
- onComplete({
- sessionType,
- profileId: null,
- agentType,
- permissionMode,
- modelMode,
- machineId: selectedMachineId,
- path: showCustomPathInput && customPath.trim() ? customPath.trim() : selectedPath,
- prompt,
- environmentVariables: undefined, // Let CLI handle environment variables
- });
- };
-
- // Handler for "Manual Configuration" - go through normal wizard flow
- const handleManualConfiguration = () => {
- setSelectedProfileId(null);
-
- // Proceed to next step in normal wizard flow
- const profileIndex = steps.indexOf('profile');
- setCurrentStep(steps[profileIndex + 1]);
- };
-
- const handleNext = () => {
- // Special handling for profileConfig step - skip if profile doesn't need configuration
- if (currentStep === 'profileConfig' && (!selectedProfileId || !profileNeedsConfiguration(selectedProfileId))) {
- setCurrentStep(steps[currentStepIndex + 1]);
- return;
- }
-
- if (isLastStep) {
- // Get environment variables from selected profile with proper precedence handling
- let environmentVariables: Record | undefined;
- if (selectedProfileId) {
- const selectedProfile = allProfiles.find(p => p.id === selectedProfileId);
- if (selectedProfile) {
- // Start with profile environment variables (base configuration)
- environmentVariables = getProfileEnvironmentVariables(selectedProfile);
-
- // Only add user-provided API keys if they're non-empty
- // This preserves CLI environment variable precedence when wizard fields are empty
- const userApiKeys = profileApiKeys[selectedProfileId];
- if (userApiKeys) {
- Object.entries(userApiKeys).forEach(([key, value]) => {
- // Only override if user provided a non-empty value
- if (value && value.trim().length > 0) {
- environmentVariables![key] = value;
- }
- });
- }
-
- // Only add user configurations if they're non-empty
- const userConfigs = profileConfigs[selectedProfileId];
- if (userConfigs) {
- Object.entries(userConfigs).forEach(([key, value]) => {
- // Only override if user provided a non-empty value
- if (value && value.trim().length > 0) {
- environmentVariables![key] = value;
- }
- });
- }
- }
- }
-
- onComplete({
- sessionType,
- profileId: selectedProfileId,
- agentType,
- permissionMode,
- modelMode,
- machineId: selectedMachineId,
- path: showCustomPathInput && customPath.trim() ? customPath.trim() : selectedPath,
- prompt,
- environmentVariables,
- });
- } else {
- setCurrentStep(steps[currentStepIndex + 1]);
- }
- };
-
- const handleBack = () => {
- if (isFirstStep) {
- onCancel();
- } else {
- setCurrentStep(steps[currentStepIndex - 1]);
- }
- };
-
- const canProceed = useMemo(() => {
- switch (currentStep) {
- case 'profile':
- return true; // Always valid (profile can be null for manual config)
- case 'profileConfig':
- if (!selectedProfileId) return false;
- const requiredFields = getProfileRequiredFields(selectedProfileId);
- // Profile configuration step is always shown when needed
- // Users can leave fields empty to preserve CLI environment variables
- return true;
- case 'sessionType':
- return true; // Always valid
- case 'agent':
- return true; // Always valid
- case 'options':
- return true; // Always valid
- case 'machine':
- return selectedMachineId.length > 0;
- case 'path':
- return (selectedPath.trim().length > 0) || (showCustomPathInput && customPath.trim().length > 0);
- case 'prompt':
- return prompt.trim().length > 0;
- default:
- return false;
- }
- }, [currentStep, selectedMachineId, selectedPath, prompt, showCustomPathInput, customPath, selectedProfileId, profileApiKeys, profileConfigs, getProfileRequiredFields]);
-
- const renderStepContent = () => {
- switch (currentStep) {
- case 'profile':
- return (
-
- Choose AI Profile
-
- Select a pre-configured AI profile or set up manually
-
-
-
- {builtInProfiles.map((profile) => (
- setSelectedProfileId(profile.id)}
- onUseAsIs={() => handleUseProfileAsIs(profile)}
- onEdit={() => handleEditProfile(profile)}
- />
- ))}
-
-
- {profiles.length > 0 && (
-
- {profiles.map((profile) => (
- setSelectedProfileId(profile.id)}
- onUseAsIs={() => handleUseProfileAsIs(profile)}
- onEdit={() => handleEditProfile(profile)}
- onDuplicate={() => handleDuplicateProfile(profile)}
- onDelete={() => handleDeleteProfile(profile)}
- showManagementActions={true}
- />
- ))}
-
- )}
-
- {/* Create New Profile Button */}
-
-
-
-
-
-
-
- Create New Profile
-
-
- Set up a custom AI backend configuration
-
-
-
-
-
-
- setSelectedProfileId(null)}
- onUseCliVars={() => handleUseCliEnvironmentVariables()}
- onConfigureManually={() => handleManualConfiguration()}
- />
-
-
-
-
- 💡 **Profile Selection Options:**
-
-
- • **Use As-Is**: Quick session creation with current profile settings
-
-
- • **Edit**: Configure API keys and settings before session creation
-
-
- • **Manual**: Use CLI environment variables without profile configuration
-
-
-
- );
-
- case 'profileConfig':
- if (!selectedProfileId || !profileNeedsConfiguration(selectedProfileId)) {
- // Skip configuration if no profile selected or profile doesn't need configuration
- setCurrentStep(steps[currentStepIndex + 1]);
- return null;
- }
-
- return (
-
- Configure {allProfiles.find(p => p.id === selectedProfileId)?.name || 'Profile'}
-
- Enter your API keys and configuration details
-
-
-
- {getProfileRequiredFields(selectedProfileId).map((field) => (
-
-
- {field.label}
-
- {
- if (field.isPassword) {
- // API key
- setProfileApiKeys(prev => ({
- ...prev,
- [selectedProfileId!]: {
- ...(prev[selectedProfileId!] as Record || {}),
- [field.key]: text
- }
- }));
- } else {
- // Configuration field
- setProfileConfigs(prev => ({
- ...prev,
- [selectedProfileId!]: {
- ...(prev[selectedProfileId!] as Record || {}),
- [field.key]: text
- }
- }));
- }
- }}
- secureTextEntry={field.isPassword}
- autoCapitalize="none"
- autoCorrect={false}
- returnKeyType="next"
- />
-
- ))}
-
-
-
-
- 💡 Tip: Your API keys are only used for this session and are not stored permanently
-
-
- 📝 Note: Leave fields empty to use CLI environment variables if they're already set
-
-
-
- );
-
- case 'sessionType':
- return (
-
- Choose AI Backend & Session Type
-
- Select your AI provider and how you want to work with your code
-
-
-
- {[
- {
- id: 'anthropic',
- name: 'Anthropic Claude',
- description: 'Advanced reasoning and coding assistant',
- icon: 'cube-outline',
- agentType: 'claude' as const
- },
- {
- id: 'openai',
- name: 'OpenAI GPT-5',
- description: 'Specialized coding assistant',
- icon: 'code-outline',
- agentType: 'codex' as const
- },
- {
- id: 'deepseek',
- name: 'DeepSeek Reasoner',
- description: 'Advanced reasoning model',
- icon: 'analytics-outline',
- agentType: 'claude' as const
- },
- {
- id: 'zai',
- name: 'Z.ai',
- description: 'AI assistant for development',
- icon: 'flash-outline',
- agentType: 'claude' as const
- },
- {
- id: 'microsoft',
- name: 'Microsoft Azure',
- description: 'Enterprise AI services',
- icon: 'cloud-outline',
- agentType: 'codex' as const
- },
- ].map((backend) => (
-
- }
- rightElement={agentType === backend.agentType ? (
-
- ) : null}
- onPress={() => setAgentType(backend.agentType)}
- showChevron={false}
- selected={agentType === backend.agentType}
- showDivider={true}
- />
- ))}
-
-
-
-
- );
-
- case 'agent':
- return (
-
- Choose AI Agent
-
- Select which AI assistant you want to use
-
-
- {selectedProfileId && (
-
-
- Profile: {allProfiles.find(p => p.id === selectedProfileId)?.name || 'Unknown'}
-
-
- {allProfiles.find(p => p.id === selectedProfileId)?.description}
-
-
- )}
-
- p.id === selectedProfileId)?.compatibility.claude && {
- opacity: 0.5,
- backgroundColor: theme.colors.surface
- }
- ]}
- onPress={() => {
- if (!selectedProfileId || allProfiles.find(p => p.id === selectedProfileId)?.compatibility.claude) {
- setAgentType('claude');
- }
- }}
- disabled={!!(selectedProfileId && !allProfiles.find(p => p.id === selectedProfileId)?.compatibility.claude)}
- >
-
- C
-
-
- Claude
-
- Anthropic's AI assistant, great for coding and analysis
-
- {selectedProfileId && !allProfiles.find(p => p.id === selectedProfileId)?.compatibility.claude && (
-
- Not compatible with selected profile
-
- )}
-
- {agentType === 'claude' && (
-
- )}
-
-
- p.id === selectedProfileId)?.compatibility.codex && {
- opacity: 0.5,
- backgroundColor: theme.colors.surface
- }
- ]}
- onPress={() => {
- if (!selectedProfileId || allProfiles.find(p => p.id === selectedProfileId)?.compatibility.codex) {
- setAgentType('codex');
- }
- }}
- disabled={!!(selectedProfileId && !allProfiles.find(p => p.id === selectedProfileId)?.compatibility.codex)}
- >
-
- X
-
-
- Codex
-
- OpenAI's specialized coding assistant
-
- {selectedProfileId && !allProfiles.find(p => p.id === selectedProfileId)?.compatibility.codex && (
-
- Not compatible with selected profile
-
- )}
-
- {agentType === 'codex' && (
-
- )}
-
-
- );
-
- case 'options':
- return (
-
- Agent Options
-
- Configure how the AI agent should behave
-
-
- {selectedProfileId && (
-
-
- Using profile: {allProfiles.find(p => p.id === selectedProfileId)?.name || 'Unknown'}
-
-
- Environment variables will be applied automatically
-
-
- )}
-
- {([
- { value: 'default', label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' },
- { value: 'acceptEdits', label: 'Accept Edits', description: 'Auto-approve edits', icon: 'checkmark-outline' },
- { value: 'plan', label: 'Plan', description: 'Plan before executing', icon: 'list-outline' },
- { value: 'bypassPermissions', label: 'Bypass Permissions', description: 'Skip all permissions', icon: 'flash-outline' },
- ] as const).map((option, index, array) => (
-
- }
- rightElement={permissionMode === option.value ? (
-
- ) : null}
- onPress={() => setPermissionMode(option.value as PermissionMode)}
- showChevron={false}
- selected={permissionMode === option.value}
- showDivider={index < array.length - 1}
- />
- ))}
-
-
-
- {(agentType === 'claude' ? [
- { value: 'default', label: 'Default', description: 'Balanced performance', icon: 'cube-outline' },
- { value: 'adaptiveUsage', label: 'Adaptive Usage', description: 'Automatically choose model', icon: 'analytics-outline' },
- { value: 'sonnet', label: 'Sonnet', description: 'Fast and efficient', icon: 'speedometer-outline' },
- { value: 'opus', label: 'Opus', description: 'Most capable model', icon: 'diamond-outline' },
- ] as const : [
- { value: 'gpt-5-codex-high', label: 'GPT-5 Codex High', description: 'Best for complex coding', icon: 'diamond-outline' },
- { value: 'gpt-5-codex-medium', label: 'GPT-5 Codex Medium', description: 'Balanced coding assistance', icon: 'cube-outline' },
- { value: 'gpt-5-codex-low', label: 'GPT-5 Codex Low', description: 'Fast coding help', icon: 'speedometer-outline' },
- ] as const).map((option, index, array) => (
-
- }
- rightElement={modelMode === option.value ? (
-
- ) : null}
- onPress={() => setModelMode(option.value as ModelMode)}
- showChevron={false}
- selected={modelMode === option.value}
- showDivider={index < array.length - 1}
- />
- ))}
-
-
- );
-
- case 'machine':
- return (
-
- Select Machine
-
- Choose which machine to run your session on
-
-
-
- {machines.map((machine, index) => (
-
- }
- rightElement={selectedMachineId === machine.id ? (
-
- ) : null}
- onPress={() => {
- setSelectedMachineId(machine.id);
- // Update path when machine changes
- const homeDir = machine.metadata?.homeDir || '/home';
- setSelectedPath(homeDir);
- }}
- showChevron={false}
- selected={selectedMachineId === machine.id}
- showDivider={index < machines.length - 1}
- />
- ))}
-
-
- );
-
- case 'path':
- return (
-
- Working Directory
-
- Choose the directory to work in
-
-
- {/* Recent Paths */}
- {recentPaths.length > 0 && (
-
- {recentPaths.map((path, index) => (
-
- }
- rightElement={selectedPath === path && !showCustomPathInput ? (
-
- ) : null}
- onPress={() => {
- setSelectedPath(path);
- setShowCustomPathInput(false);
- }}
- showChevron={false}
- selected={selectedPath === path && !showCustomPathInput}
- showDivider={index < recentPaths.length - 1}
- />
- ))}
-
- )}
-
- {/* Common Directories */}
-
- {(() => {
- const machine = machines.find(m => m.id === selectedMachineId);
- const homeDir = machine?.metadata?.homeDir || '/home';
- const pathOptions = [
- { value: homeDir, label: homeDir, description: 'Home directory' },
- { value: `${homeDir}/projects`, label: `${homeDir}/projects`, description: 'Projects folder' },
- { value: `${homeDir}/Documents`, label: `${homeDir}/Documents`, description: 'Documents folder' },
- { value: `${homeDir}/Desktop`, label: `${homeDir}/Desktop`, description: 'Desktop folder' },
- ];
- return pathOptions.map((option, index) => (
-
- }
- rightElement={selectedPath === option.value && !showCustomPathInput ? (
-
- ) : null}
- onPress={() => {
- setSelectedPath(option.value);
- setShowCustomPathInput(false);
- }}
- showChevron={false}
- selected={selectedPath === option.value && !showCustomPathInput}
- showDivider={index < pathOptions.length - 1}
- />
- ));
- })()}
-
-
- {/* Custom Path Option */}
-
-
- }
- rightElement={showCustomPathInput ? (
-
- ) : null}
- onPress={() => setShowCustomPathInput(true)}
- showChevron={false}
- selected={showCustomPathInput}
- showDivider={false}
- />
- {showCustomPathInput && (
-
-
-
- )}
-
-
- );
-
- case 'prompt':
- return (
-
- Initial Message
-
- Write your first message to the AI agent
-
-
-
-
- );
-
- default:
- return null;
- }
- };
-
- return (
-
-
- New Session
-
-
-
-
-
-
- {steps.map((step, index) => (
-
- ))}
-
-
-
- {renderStepContent()}
-
-
-
-
-
- {isFirstStep ? 'Cancel' : 'Back'}
-
-
-
-
-
- {isLastStep ? 'Create Session' : 'Next'}
-
-
-
-
- );
-}
\ No newline at end of file
diff --git a/sources/components/PermissionModeSelector.tsx b/sources/components/PermissionModeSelector.tsx
deleted file mode 100644
index da96dd234..000000000
--- a/sources/components/PermissionModeSelector.tsx
+++ /dev/null
@@ -1,110 +0,0 @@
-import React from 'react';
-import { Text, Pressable, Platform } from 'react-native';
-import { Ionicons } from '@expo/vector-icons';
-import { Typography } from '@/constants/Typography';
-import { hapticsLight } from './haptics';
-
-export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo';
-
-export type ModelMode = 'default' | 'adaptiveUsage' | 'sonnet' | 'opus' | 'gpt-5-codex-high' | 'gpt-5-codex-medium' | 'gpt-5-codex-low' | 'gpt-5-minimal' | 'gpt-5-low' | 'gpt-5-medium' | 'gpt-5-high';
-
-interface PermissionModeSelectorProps {
- mode: PermissionMode;
- onModeChange: (mode: PermissionMode) => void;
- disabled?: boolean;
-}
-
-const modeConfig = {
- default: {
- label: 'Default',
- icon: 'shield-checkmark' as const,
- description: 'Ask for permissions'
- },
- acceptEdits: {
- label: 'Accept Edits',
- icon: 'create' as const,
- description: 'Auto-approve edits'
- },
- plan: {
- label: 'Plan',
- icon: 'list' as const,
- description: 'Plan before executing'
- },
- bypassPermissions: {
- label: 'Yolo',
- icon: 'flash' as const,
- description: 'Skip all permissions'
- },
- // Codex modes (not displayed in this component, but needed for type compatibility)
- 'read-only': {
- label: 'Read-only',
- icon: 'eye' as const,
- description: 'Read-only mode'
- },
- 'safe-yolo': {
- label: 'Safe YOLO',
- icon: 'shield' as const,
- description: 'Safe YOLO mode'
- },
- 'yolo': {
- label: 'YOLO',
- icon: 'rocket' as const,
- description: 'YOLO mode'
- },
-};
-
-const modeOrder: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions'];
-
-export const PermissionModeSelector: React.FC = ({
- mode,
- onModeChange,
- disabled = false
-}) => {
- const currentConfig = modeConfig[mode];
-
- const handleTap = () => {
- hapticsLight();
- const currentIndex = modeOrder.indexOf(mode);
- const nextIndex = (currentIndex + 1) % modeOrder.length;
- onModeChange(modeOrder[nextIndex]);
- };
-
- return (
-
-
- {/*
- {currentConfig.label}
- */}
-
- );
-};
\ No newline at end of file
diff --git a/sources/components/ProfileEditForm.tsx b/sources/components/ProfileEditForm.tsx
index 8a3864d44..5f543fdf1 100644
--- a/sources/components/ProfileEditForm.tsx
+++ b/sources/components/ProfileEditForm.tsx
@@ -1,580 +1,535 @@
import React from 'react';
-import { View, Text, Pressable, ScrollView, TextInput, ViewStyle, Linking, Platform } from 'react-native';
+import { View, Text, TextInput, ViewStyle, Linking, Platform, Pressable, useWindowDimensions } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { StyleSheet } from 'react-native-unistyles';
import { useUnistyles } from 'react-native-unistyles';
import { Typography } from '@/constants/Typography';
import { t } from '@/text';
import { AIBackendProfile } from '@/sync/settings';
-import { PermissionMode, ModelMode } from '@/components/PermissionModeSelector';
+import type { PermissionMode } from '@/sync/permissionTypes';
import { SessionTypeSelector } from '@/components/SessionTypeSelector';
+import { ItemList } from '@/components/ItemList';
import { ItemGroup } from '@/components/ItemGroup';
import { Item } from '@/components/Item';
+import { Switch } from '@/components/Switch';
import { getBuiltInProfileDocumentation } from '@/sync/profileUtils';
-import { useEnvironmentVariables, extractEnvVarReferences } from '@/hooks/useEnvironmentVariables';
import { EnvironmentVariablesList } from '@/components/EnvironmentVariablesList';
+import { useSetting, useAllMachines, useMachine, useSettingMutable } from '@/sync/storage';
+import { Modal } from '@/modal';
+import { MachineSelector } from '@/components/newSession/MachineSelector';
+import type { Machine } from '@/sync/storageTypes';
export interface ProfileEditFormProps {
profile: AIBackendProfile;
machineId: string | null;
onSave: (profile: AIBackendProfile) => void;
onCancel: () => void;
+ onDirtyChange?: (isDirty: boolean) => void;
containerStyle?: ViewStyle;
}
+interface MachinePreviewModalProps {
+ machines: Machine[];
+ favoriteMachineIds: string[];
+ selectedMachineId: string | null;
+ onSelect: (machineId: string) => void;
+ onToggleFavorite: (machineId: string) => void;
+ onClose: () => void;
+}
+
+function MachinePreviewModal(props: MachinePreviewModalProps) {
+ const { theme } = useUnistyles();
+ const { height: windowHeight } = useWindowDimensions();
+
+ const selectedMachine = React.useMemo(() => {
+ if (!props.selectedMachineId) return null;
+ return props.machines.find((m) => m.id === props.selectedMachineId) ?? null;
+ }, [props.machines, props.selectedMachineId]);
+
+ const favoriteMachines = React.useMemo(() => {
+ const byId = new Map(props.machines.map((m) => [m.id, m] as const));
+ return props.favoriteMachineIds.map((id) => byId.get(id)).filter(Boolean) as Machine[];
+ }, [props.favoriteMachineIds, props.machines]);
+
+ const maxHeight = Math.min(720, Math.max(420, Math.floor(windowHeight * 0.85)));
+
+ return (
+
+
+
+ Preview Machine
+
+
+ ({ opacity: pressed ? 0.7 : 1 })}
+ >
+
+
+
+
+
+ 0}
+ showSearch
+ searchPlacement={favoriteMachines.length > 0 ? 'favorites' : 'all'}
+ onSelect={(machine) => {
+ props.onSelect(machine.id);
+ props.onClose();
+ }}
+ onToggleFavorite={(machine) => props.onToggleFavorite(machine.id)}
+ />
+
+
+ );
+}
+
export function ProfileEditForm({
profile,
machineId,
onSave,
onCancel,
- containerStyle
+ onDirtyChange,
+ containerStyle,
}: ProfileEditFormProps) {
const { theme } = useUnistyles();
+ const styles = stylesheet;
+ const groupStyle = React.useMemo(() => ({ marginBottom: 12 }), []);
+ const experimentsEnabled = useSetting('experiments');
+ const machines = useAllMachines();
+ const [favoriteMachines, setFavoriteMachines] = useSettingMutable('favoriteMachines');
+ const routeMachine = machineId;
+ const [previewMachineId, setPreviewMachineId] = React.useState(routeMachine);
+
+ React.useEffect(() => {
+ setPreviewMachineId(routeMachine);
+ }, [routeMachine]);
+
+ const resolvedMachineId = routeMachine ?? previewMachineId;
+ const resolvedMachine = useMachine(resolvedMachineId ?? '');
+
+ const toggleFavoriteMachineId = React.useCallback((machineIdToToggle: string) => {
+ if (favoriteMachines.includes(machineIdToToggle)) {
+ setFavoriteMachines(favoriteMachines.filter((id) => id !== machineIdToToggle));
+ } else {
+ setFavoriteMachines([machineIdToToggle, ...favoriteMachines]);
+ }
+ }, [favoriteMachines, setFavoriteMachines]);
+
+ const showMachinePreviewPicker = React.useCallback(() => {
+ Modal.show({
+ component: MachinePreviewModal,
+ props: {
+ machines,
+ favoriteMachineIds: favoriteMachines,
+ selectedMachineId: previewMachineId,
+ onSelect: setPreviewMachineId,
+ onToggleFavorite: toggleFavoriteMachineId,
+ },
+ } as any);
+ }, [favoriteMachines, machines, previewMachineId, toggleFavoriteMachineId]);
- // Get documentation for built-in profiles
const profileDocs = React.useMemo(() => {
if (!profile.isBuiltIn) return null;
return getBuiltInProfileDocumentation(profile.id);
- }, [profile.isBuiltIn, profile.id]);
+ }, [profile.id, profile.isBuiltIn]);
- // Local state for environment variables (unified for all config)
const [environmentVariables, setEnvironmentVariables] = React.useState>(
- profile.environmentVariables || []
+ profile.environmentVariables || [],
);
- // Extract ${VAR} references from environmentVariables for querying daemon
- const envVarNames = React.useMemo(() => {
- return extractEnvVarReferences(environmentVariables);
- }, [environmentVariables]);
-
- // Query daemon environment using hook
- const { variables: actualEnvVars } = useEnvironmentVariables(machineId, envVarNames);
-
const [name, setName] = React.useState(profile.name || '');
const [useTmux, setUseTmux] = React.useState(profile.tmuxConfig?.sessionName !== undefined);
const [tmuxSession, setTmuxSession] = React.useState(profile.tmuxConfig?.sessionName || '');
const [tmuxTmpDir, setTmuxTmpDir] = React.useState(profile.tmuxConfig?.tmpDir || '');
- const [useStartupScript, setUseStartupScript] = React.useState(!!profile.startupBashScript);
- const [startupScript, setStartupScript] = React.useState(profile.startupBashScript || '');
- const [defaultSessionType, setDefaultSessionType] = React.useState<'simple' | 'worktree'>(profile.defaultSessionType || 'simple');
- const [defaultPermissionMode, setDefaultPermissionMode] = React.useState((profile.defaultPermissionMode as PermissionMode) || 'default');
- const [agentType, setAgentType] = React.useState<'claude' | 'codex'>(() => {
- if (profile.compatibility.claude && !profile.compatibility.codex) return 'claude';
- if (profile.compatibility.codex && !profile.compatibility.claude) return 'codex';
- return 'claude'; // Default to Claude if both or neither
- });
-
- const handleSave = () => {
+ const [defaultSessionType, setDefaultSessionType] = React.useState<'simple' | 'worktree'>(
+ profile.defaultSessionType || 'simple',
+ );
+ const [defaultPermissionMode, setDefaultPermissionMode] = React.useState(
+ (profile.defaultPermissionMode as PermissionMode) || 'default',
+ );
+ const [compatibility, setCompatibility] = React.useState>(
+ profile.compatibility || { claude: true, codex: true, gemini: true },
+ );
+
+ const initialSnapshotRef = React.useRef(null);
+ if (initialSnapshotRef.current === null) {
+ initialSnapshotRef.current = JSON.stringify({
+ name,
+ environmentVariables,
+ useTmux,
+ tmuxSession,
+ tmuxTmpDir,
+ defaultSessionType,
+ defaultPermissionMode,
+ compatibility,
+ });
+ }
+
+ const isDirty = React.useMemo(() => {
+ const currentSnapshot = JSON.stringify({
+ name,
+ environmentVariables,
+ useTmux,
+ tmuxSession,
+ tmuxTmpDir,
+ defaultSessionType,
+ defaultPermissionMode,
+ compatibility,
+ });
+ return currentSnapshot !== initialSnapshotRef.current;
+ }, [
+ compatibility,
+ defaultPermissionMode,
+ defaultSessionType,
+ environmentVariables,
+ name,
+ tmuxSession,
+ tmuxTmpDir,
+ useTmux,
+ ]);
+
+ React.useEffect(() => {
+ onDirtyChange?.(isDirty);
+ }, [isDirty, onDirtyChange]);
+
+ const toggleCompatibility = React.useCallback((key: keyof AIBackendProfile['compatibility']) => {
+ setCompatibility((prev) => {
+ const next = { ...prev, [key]: !prev[key] };
+ const enabledCount = Object.values(next).filter(Boolean).length;
+ if (enabledCount === 0) {
+ Modal.alert(t('common.error'), 'Select at least one AI backend.');
+ return prev;
+ }
+ return next;
+ });
+ }, []);
+
+ const openSetupGuide = React.useCallback(async () => {
+ const url = profileDocs?.setupGuideUrl;
+ if (!url) return;
+ try {
+ if (Platform.OS === 'web') {
+ window.open(url, '_blank');
+ } else {
+ await Linking.openURL(url);
+ }
+ } catch (error) {
+ console.error('Failed to open URL:', error);
+ }
+ }, [profileDocs?.setupGuideUrl]);
+
+ const handleSave = React.useCallback(() => {
if (!name.trim()) {
- // Profile name validation - prevent saving empty profiles
+ Modal.alert(t('common.error'), 'Enter a profile name.');
return;
}
onSave({
...profile,
name: name.trim(),
- // Clear all config objects - ALL configuration now in environmentVariables
- anthropicConfig: {},
- openaiConfig: {},
- azureOpenAIConfig: {},
- // Use environment variables from state (managed by EnvironmentVariablesList)
environmentVariables,
- // Keep non-env-var configuration
- tmuxConfig: useTmux ? {
- sessionName: tmuxSession.trim() || '', // Empty string = use current/most recent tmux session
- tmpDir: tmuxTmpDir.trim() || undefined,
- updateEnvironment: undefined, // Preserve schema compatibility, not used by daemon
- } : {
- sessionName: undefined,
- tmpDir: undefined,
- updateEnvironment: undefined,
- },
- startupBashScript: useStartupScript ? (startupScript.trim() || undefined) : undefined,
- defaultSessionType: defaultSessionType,
- defaultPermissionMode: defaultPermissionMode,
+ tmuxConfig: useTmux
+ ? {
+ ...(profile.tmuxConfig ?? {}),
+ sessionName: tmuxSession.trim() || '',
+ tmpDir: tmuxTmpDir.trim() || undefined,
+ }
+ : undefined,
+ defaultSessionType,
+ defaultPermissionMode,
+ compatibility,
updatedAt: Date.now(),
});
- };
+ }, [
+ compatibility,
+ defaultPermissionMode,
+ defaultSessionType,
+ environmentVariables,
+ name,
+ onSave,
+ profile,
+ tmuxSession,
+ tmuxTmpDir,
+ useTmux,
+ ]);
return (
-
-
- {/* Profile Name */}
-
- {t('profiles.profileName')}
-
-
-
- {/* Built-in Profile Documentation - Setup Instructions */}
- {profile.isBuiltIn && profileDocs && (
-
-
-
-
- Setup Instructions
-
-
-
-
- {profileDocs.description}
-
-
- {profileDocs.setupGuideUrl && (
- {
- try {
- const url = profileDocs.setupGuideUrl!;
- // On web/Tauri desktop, use window.open
- if (Platform.OS === 'web') {
- window.open(url, '_blank');
- } else {
- // On native (iOS/Android), use Linking API
- await Linking.openURL(url);
- }
- } catch (error) {
- console.error('Failed to open URL:', error);
- }
- }}
- style={{
- flexDirection: 'row',
- alignItems: 'center',
- backgroundColor: theme.colors.button.primary.background,
- borderRadius: 8,
- padding: 12,
- marginBottom: 16,
- }}
- >
-
-
- View Official Setup Guide
-
-
-
- )}
-
- )}
-
- {/* Session Type */}
-
- Default Session Type
-
-
-
+
+
+
+
+
+
- {/* Permission Mode */}
-
- Default Permission Mode
-
-
- {[
- { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' },
- { value: 'acceptEdits' as PermissionMode, label: 'Accept Edits', description: 'Auto-approve edits', icon: 'checkmark-outline' },
- { value: 'plan' as PermissionMode, label: 'Plan', description: 'Plan before executing', icon: 'list-outline' },
- { value: 'bypassPermissions' as PermissionMode, label: 'Bypass Permissions', description: 'Skip all permissions', icon: 'flash-outline' },
- ].map((option, index, array) => (
-
- }
- rightElement={defaultPermissionMode === option.value ? (
-
- ) : null}
- onPress={() => setDefaultPermissionMode(option.value)}
- showChevron={false}
- selected={defaultPermissionMode === option.value}
- showDivider={index < array.length - 1}
- style={defaultPermissionMode === option.value ? {
- borderWidth: 2,
- borderColor: theme.colors.button.primary.tint,
- borderRadius: 8,
- } : undefined}
+ {profile.isBuiltIn && profileDocs?.setupGuideUrl && (
+
+ }
+ onPress={() => void openSetupGuide()}
+ />
+
+ )}
+
+
+
+
+
+
+ {[
+ { value: 'default' as PermissionMode, label: 'Default', description: 'Ask for permissions', icon: 'shield-outline' },
+ { value: 'acceptEdits' as PermissionMode, label: 'Accept Edits', description: 'Auto-approve edits', icon: 'checkmark-outline' },
+ { value: 'plan' as PermissionMode, label: 'Plan', description: 'Plan before executing', icon: 'list-outline' },
+ { value: 'bypassPermissions' as PermissionMode, label: 'Bypass Permissions', description: 'Skip all permissions', icon: 'flash-outline' },
+ ].map((option, index, array) => (
+
- ))}
-
-
-
- {/* Tmux Enable/Disable */}
-
- setUseTmux(!useTmux)}
- >
-
- {useTmux && (
-
- )}
-
-
-
- Spawn Sessions in Tmux
-
-
-
- {useTmux ? 'Sessions spawn in new tmux windows. Configure session name and temp directory below.' : 'Sessions spawn in regular shell (no tmux integration)'}
-
-
- {/* Tmux Session Name */}
-
- Tmux Session Name ({t('common.optional')})
-
-
- Leave empty to use first existing tmux session (or create "happy" if none exist). Specify name (e.g., "my-work") for specific session.
-
-
+ ) : null
+ }
+ onPress={() => setDefaultPermissionMode(option.value)}
+ showChevron={false}
+ selected={defaultPermissionMode === option.value}
+ showDivider={index < array.length - 1}
/>
+ ))}
+
- {/* Tmux Temp Directory */}
-
- Tmux Temp Directory ({t('common.optional')})
-
-
- Temporary directory for tmux session files. Leave empty for system default.
-
-
+ }
+ rightElement={ toggleCompatibility('claude')} />}
+ showChevron={false}
+ onPress={() => toggleCompatibility('claude')}
+ />
+ }
+ rightElement={ toggleCompatibility('codex')} />}
+ showChevron={false}
+ onPress={() => toggleCompatibility('codex')}
+ />
+ {experimentsEnabled && (
+ }
+ rightElement={ toggleCompatibility('gemini')} />}
+ showChevron={false}
+ onPress={() => toggleCompatibility('gemini')}
+ showDivider={false}
/>
+ )}
+
- {/* Startup Bash Script */}
-
-
- setUseStartupScript(!useStartupScript)}
- >
-
- {useStartupScript && (
-
- )}
-
-
-
- Startup Bash Script
-
+
+ }
+ showChevron={false}
+ onPress={() => setUseTmux((v) => !v)}
+ />
+ {useTmux && (
+
+
+ Tmux Session Name ({t('common.optional')})
+
-
- {useStartupScript
- ? 'Executed before spawning each session. Use for dynamic setup, environment checks, or custom initialization.'
- : 'No startup script - sessions spawn directly'}
-
-
+
+ Tmux Temp Directory ({t('common.optional')})
- {useStartupScript && startupScript.trim() && (
- {
- if (Platform.OS === 'web') {
- navigator.clipboard.writeText(startupScript);
- }
- }}
- >
-
-
- )}
-
+
+ )}
+
- {/* Environment Variables Section - Unified configuration */}
-
+ }
+ onPress={showMachinePreviewPicker}
/>
+
+ )}
+
+
+
+
- {/* Action buttons */}
-
+
+
+
({
backgroundColor: theme.colors.surface,
- borderRadius: 8,
- padding: 12,
+ borderRadius: 10,
+ paddingVertical: 12,
alignItems: 'center',
- }}
- onPress={onCancel}
+ opacity: pressed ? 0.85 : 1,
+ })}
>
-
+
{t('common.cancel')}
- {profile.isBuiltIn ? (
- // For built-in profiles, show "Save As" button (creates custom copy)
-
-
- {t('common.saveAs')}
-
-
- ) : (
- // For custom profiles, show regular "Save" button
-
-
- {t('common.save')}
-
-
- )}
+
+
+ ({
+ backgroundColor: theme.colors.button.primary.background,
+ borderRadius: 10,
+ paddingVertical: 12,
+ alignItems: 'center',
+ opacity: pressed ? 0.85 : 1,
+ })}
+ >
+
+ {profile.isBuiltIn ? t('common.saveAs') : t('common.save')}
+
+
-
+
+
);
}
-const profileEditFormStyles = StyleSheet.create((theme, rt) => ({
- scrollView: {
- flex: 1,
+const stylesheet = StyleSheet.create((theme) => ({
+ inputContainer: {
+ paddingHorizontal: 16,
+ paddingVertical: 12,
},
- scrollContent: {
- padding: 20,
+ selectorContainer: {
+ paddingHorizontal: 12,
+ paddingBottom: 4,
},
- formContainer: {
- backgroundColor: theme.colors.surface,
- borderRadius: 16, // Matches new session panel main container
- padding: 20,
- width: '100%',
+ fieldLabel: {
+ ...Typography.default('semiBold'),
+ fontSize: 13,
+ color: theme.colors.groupped.sectionTitle,
+ marginBottom: 8,
+ },
+ textInput: {
+ ...Typography.default('regular'),
+ backgroundColor: theme.colors.input.background,
+ borderRadius: 10,
+ paddingHorizontal: 12,
+ paddingVertical: Platform.select({ ios: 10, default: 12 }),
+ fontSize: Platform.select({ ios: 17, default: 16 }),
+ lineHeight: Platform.select({ ios: 22, default: 24 }),
+ letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }),
+ color: theme.colors.input.text,
+ ...(Platform.select({
+ web: {
+ outline: 'none',
+ outlineStyle: 'none',
+ outlineWidth: 0,
+ outlineColor: 'transparent',
+ boxShadow: 'none',
+ WebkitBoxShadow: 'none',
+ WebkitAppearance: 'none',
+ },
+ default: {},
+ }) as object),
+ },
+ multilineInput: {
+ ...Typography.default('regular'),
+ backgroundColor: theme.colors.input.background,
+ borderRadius: 10,
+ paddingHorizontal: 12,
+ paddingVertical: 12,
+ fontSize: 14,
+ lineHeight: 20,
+ color: theme.colors.input.text,
+ fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
+ minHeight: 120,
+ ...(Platform.select({
+ web: {
+ outline: 'none',
+ outlineStyle: 'none',
+ outlineWidth: 0,
+ outlineColor: 'transparent',
+ boxShadow: 'none',
+ WebkitBoxShadow: 'none',
+ WebkitAppearance: 'none',
+ },
+ default: {},
+ }) as object),
},
}));
diff --git a/sources/components/SearchHeader.tsx b/sources/components/SearchHeader.tsx
new file mode 100644
index 000000000..511de8705
--- /dev/null
+++ b/sources/components/SearchHeader.tsx
@@ -0,0 +1,118 @@
+import * as React from 'react';
+import { View, TextInput, Platform, StyleProp, ViewStyle } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { StyleSheet, useUnistyles } from 'react-native-unistyles';
+import { Typography } from '@/constants/Typography';
+import { layout } from '@/components/layout';
+
+export interface SearchHeaderProps {
+ value: string;
+ onChangeText: (text: string) => void;
+ placeholder: string;
+ containerStyle?: StyleProp;
+ autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters';
+ autoCorrect?: boolean;
+ inputRef?: React.Ref;
+ onFocus?: () => void;
+ onBlur?: () => void;
+}
+
+const INPUT_BORDER_RADIUS = 10;
+
+const stylesheet = StyleSheet.create((theme) => ({
+ container: {
+ backgroundColor: theme.colors.surface,
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ borderBottomWidth: 1,
+ borderBottomColor: theme.colors.divider,
+ },
+ content: {
+ width: '100%',
+ maxWidth: layout.maxWidth,
+ alignSelf: 'center',
+ },
+ inputWrapper: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: theme.colors.input.background,
+ borderRadius: INPUT_BORDER_RADIUS,
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ },
+ textInput: {
+ flex: 1,
+ ...Typography.default('regular'),
+ fontSize: Platform.select({ ios: 17, default: 16 }),
+ lineHeight: Platform.select({ ios: 22, default: 24 }),
+ letterSpacing: Platform.select({ ios: -0.41, default: 0.15 }),
+ color: theme.colors.input.text,
+ paddingVertical: 0,
+ ...(Platform.select({
+ web: {
+ outline: 'none',
+ outlineStyle: 'none',
+ outlineWidth: 0,
+ outlineColor: 'transparent',
+ boxShadow: 'none',
+ WebkitBoxShadow: 'none',
+ WebkitAppearance: 'none',
+ },
+ default: {},
+ }) as object),
+ },
+ clearIcon: {
+ marginLeft: 8,
+ },
+}));
+
+export function SearchHeader({
+ value,
+ onChangeText,
+ placeholder,
+ containerStyle,
+ autoCapitalize = 'none',
+ autoCorrect = false,
+ inputRef,
+ onFocus,
+ onBlur,
+}: SearchHeaderProps) {
+ const { theme } = useUnistyles();
+ const styles = stylesheet;
+
+ return (
+
+
+
+
+
+ {value.trim().length > 0 && (
+ onChangeText('')}
+ style={styles.clearIcon}
+ />
+ )}
+
+
+
+ );
+}
diff --git a/sources/components/SearchableListSelector.tsx b/sources/components/SearchableListSelector.tsx
index c81ba79e2..5d93bb0eb 100644
--- a/sources/components/SearchableListSelector.tsx
+++ b/sources/components/SearchableListSelector.tsx
@@ -5,10 +5,9 @@ import { StyleSheet, useUnistyles } from 'react-native-unistyles';
import { Typography } from '@/constants/Typography';
import { ItemGroup } from '@/components/ItemGroup';
import { Item } from '@/components/Item';
-import { MultiTextInput } from '@/components/MultiTextInput';
-import { Modal } from '@/modal';
import { t } from '@/text';
import { StatusDot } from '@/components/StatusDot';
+import { SearchHeader } from '@/components/SearchHeader';
/**
* Configuration object for customizing the SearchableListSelector component.
@@ -40,12 +39,14 @@ export interface SelectorConfig {
searchPlaceholder: string;
recentSectionTitle: string;
favoritesSectionTitle: string;
+ allSectionTitle?: string;
noItemsMessage: string;
// Optional features
showFavorites?: boolean;
showRecent?: boolean;
showSearch?: boolean;
+ showAll?: boolean;
allowCustomInput?: boolean;
// Item subtitle override (for recent items, e.g., "Recently used")
@@ -59,9 +60,6 @@ export interface SelectorConfig {
// Check if a favorite item can be removed (e.g., home directory can't be removed)
canRemoveFavorite?: (item: T) => boolean;
-
- // Visual customization
- compactItems?: boolean; // Use reduced padding for more compact lists (default: false)
}
/**
@@ -75,140 +73,26 @@ export interface SearchableListSelectorProps {
selectedItem: T | null;
onSelect: (item: T) => void;
onToggleFavorite?: (item: T) => void;
- context?: any; // Additional context (e.g., homeDir for paths)
+ context?: any; // Additional context (e.g., homeDir for paths)
// Optional overrides
showFavorites?: boolean;
showRecent?: boolean;
showSearch?: boolean;
-
- // Controlled collapse states (optional - defaults to uncontrolled internal state)
- collapsedSections?: {
- recent?: boolean;
- favorites?: boolean;
- all?: boolean;
- };
- onCollapsedSectionsChange?: (collapsed: { recent?: boolean; favorites?: boolean; all?: boolean }) => void;
+ searchPlacement?: 'header' | 'recent' | 'favorites' | 'all';
}
const RECENT_ITEMS_DEFAULT_VISIBLE = 5;
-// Spacing constants (match existing codebase patterns)
-const STATUS_DOT_TEXT_GAP = 4; // Gap between StatusDot and text (used throughout app for status indicators)
-const ITEM_SPACING_GAP = 4; // Gap between elements and spacing between items (compact)
-const COMPACT_ITEM_PADDING = 4; // Vertical padding for compact lists
-// Border radius constants (consistent rounding)
-const INPUT_BORDER_RADIUS = 10; // Input field and containers
-const BUTTON_BORDER_RADIUS = 8; // Buttons and actionable elements
-// ITEM_BORDER_RADIUS must match ItemGroup's contentContainer borderRadius to prevent clipping
-// ItemGroup uses Platform.select({ ios: 10, default: 16 })
-const ITEM_BORDER_RADIUS = Platform.select({ ios: 10, default: 16 }); // Match ItemGroup container radius
+const STATUS_DOT_TEXT_GAP = 4;
+const ITEM_SPACING_GAP = 16;
const stylesheet = StyleSheet.create((theme) => ({
- inputContainer: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 8,
- paddingHorizontal: 16,
- paddingBottom: 8,
- },
- inputWrapper: {
- flex: 1,
- backgroundColor: theme.colors.input.background,
- borderRadius: INPUT_BORDER_RADIUS,
- borderWidth: 0.5,
- borderColor: theme.colors.divider,
- },
- inputInner: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingHorizontal: 12,
- },
- inputField: {
- flex: 1,
- },
- clearButton: {
- width: 20,
- height: 20,
- borderRadius: INPUT_BORDER_RADIUS,
- backgroundColor: theme.colors.textSecondary,
- justifyContent: 'center',
- alignItems: 'center',
- marginLeft: 8,
- },
- favoriteButton: {
- borderRadius: BUTTON_BORDER_RADIUS,
- padding: 8,
- },
- sectionHeader: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- paddingHorizontal: 16,
- paddingVertical: 10,
- },
- sectionHeaderText: {
- fontSize: 13,
- fontWeight: '500',
- color: theme.colors.text,
- ...Typography.default(),
- },
- selectedItemStyle: {
- borderWidth: 2,
- borderColor: theme.colors.button.primary.tint,
- borderRadius: ITEM_BORDER_RADIUS,
- },
- compactItemStyle: {
- paddingVertical: COMPACT_ITEM_PADDING,
- minHeight: 0, // Override Item's default minHeight (44-56px) for compact mode
- },
- itemBackground: {
- backgroundColor: theme.colors.input.background,
- borderRadius: ITEM_BORDER_RADIUS,
- marginBottom: ITEM_SPACING_GAP,
- },
showMoreTitle: {
textAlign: 'center',
- color: theme.colors.button.primary.tint,
+ color: theme.colors.textLink,
},
}));
-/**
- * Generic searchable list selector component with recent items, favorites, and filtering.
- *
- * Pattern extracted from Working Directory section in new session wizard.
- * Supports any data type through TypeScript generics and configuration object.
- *
- * Features:
- * - Search/filter with smart skip (doesn't filter when input matches selection)
- * - Recent items with "Show More" toggle
- * - Favorites with add/remove
- * - Collapsible sections
- * - Custom input support (optional)
- *
- * @example
- * // For machines:
- *
- * config={machineConfig}
- * items={machines}
- * recentItems={recentMachines}
- * favoriteItems={favoriteMachines}
- * selectedItem={selectedMachine}
- * onSelect={(machine) => setSelectedMachine(machine)}
- * onToggleFavorite={(machine) => toggleFavorite(machine.id)}
- * />
- *
- * // For paths:
- *
- * config={pathConfig}
- * items={allPaths}
- * recentItems={recentPaths}
- * favoriteItems={favoritePaths}
- * selectedItem={selectedPath}
- * onSelect={(path) => setSelectedPath(path)}
- * onToggleFavorite={(path) => toggleFavorite(path)}
- * context={{ homeDir }}
- * />
- */
export function SearchableListSelector(props: SearchableListSelectorProps) {
const { theme } = useUnistyles();
const styles = stylesheet;
@@ -224,167 +108,51 @@ export function SearchableListSelector(props: SearchableListSelectorProps)
showFavorites = config.showFavorites !== false,
showRecent = config.showRecent !== false,
showSearch = config.showSearch !== false,
- collapsedSections,
- onCollapsedSectionsChange,
+ searchPlacement = 'header',
} = props;
+ const showAll = config.showAll !== false;
- // Use controlled state if provided, otherwise use internal state
- const isControlled = collapsedSections !== undefined && onCollapsedSectionsChange !== undefined;
-
- // State management (matches Working Directory pattern)
- const [inputText, setInputText] = React.useState(() => {
- if (selectedItem) {
- return config.formatForDisplay(selectedItem, context);
- }
- return '';
- });
+ // Search query is intentionally decoupled from the selected value so pickers don't start pre-filtered.
+ const [inputText, setInputText] = React.useState('');
const [showAllRecent, setShowAllRecent] = React.useState(false);
- // Internal uncontrolled state (used when not controlled from parent)
- const [internalShowRecentSection, setInternalShowRecentSection] = React.useState(false);
- const [internalShowFavoritesSection, setInternalShowFavoritesSection] = React.useState(false);
- const [internalShowAllItemsSection, setInternalShowAllItemsSection] = React.useState(true);
-
- // Use controlled or uncontrolled state
- const showRecentSection = isControlled ? !collapsedSections?.recent : internalShowRecentSection;
- const showFavoritesSection = isControlled ? !collapsedSections?.favorites : internalShowFavoritesSection;
- const showAllItemsSection = isControlled ? !collapsedSections?.all : internalShowAllItemsSection;
-
- // Toggle handlers that work for both controlled and uncontrolled
- const toggleRecentSection = () => {
- if (isControlled) {
- onCollapsedSectionsChange?.({ ...collapsedSections, recent: !collapsedSections?.recent });
- } else {
- setInternalShowRecentSection(!internalShowRecentSection);
- }
- };
-
- const toggleFavoritesSection = () => {
- if (isControlled) {
- onCollapsedSectionsChange?.({ ...collapsedSections, favorites: !collapsedSections?.favorites });
- } else {
- setInternalShowFavoritesSection(!internalShowFavoritesSection);
- }
- };
-
- const toggleAllItemsSection = () => {
- if (isControlled) {
- onCollapsedSectionsChange?.({ ...collapsedSections, all: !collapsedSections?.all });
- } else {
- setInternalShowAllItemsSection(!internalShowAllItemsSection);
- }
- };
-
- // Track if user is actively typing (vs clicking from list) to control expansion behavior
- const isUserTyping = React.useRef(false);
+ const favoriteIds = React.useMemo(() => {
+ return new Set(favoriteItems.map((item) => config.getItemId(item)));
+ }, [favoriteItems, config]);
- // Update input text when selected item changes externally
- React.useEffect(() => {
- if (selectedItem && !isUserTyping.current) {
- setInputText(config.formatForDisplay(selectedItem, context));
- }
- }, [selectedItem, config, context]);
-
- // Filtering logic with smart skip (matches Working Directory pattern)
- const filteredRecentItems = React.useMemo(() => {
- if (!inputText.trim()) return recentItems;
-
- // Don't filter if text matches the currently selected item (user clicked from list)
- const selectedDisplayText = selectedItem ? config.formatForDisplay(selectedItem, context) : null;
- if (selectedDisplayText && inputText === selectedDisplayText) {
- return recentItems; // Show all items, don't filter
- }
+ const baseRecentItems = React.useMemo(() => {
+ return recentItems.filter((item) => !favoriteIds.has(config.getItemId(item)));
+ }, [recentItems, favoriteIds, config]);
- // User is typing - filter the list
- return recentItems.filter(item => config.filterItem(item, inputText, context));
- }, [recentItems, inputText, selectedItem, config, context]);
+ const baseAllItems = React.useMemo(() => {
+ const recentIds = new Set(baseRecentItems.map((item) => config.getItemId(item)));
+ return items.filter((item) => !favoriteIds.has(config.getItemId(item)) && !recentIds.has(config.getItemId(item)));
+ }, [items, baseRecentItems, favoriteIds, config]);
const filteredFavoriteItems = React.useMemo(() => {
if (!inputText.trim()) return favoriteItems;
+ return favoriteItems.filter((item) => config.filterItem(item, inputText, context));
+ }, [favoriteItems, inputText, config, context]);
- const selectedDisplayText = selectedItem ? config.formatForDisplay(selectedItem, context) : null;
- if (selectedDisplayText && inputText === selectedDisplayText) {
- return favoriteItems; // Show all favorites, don't filter
- }
-
- // Don't filter if text matches a favorite (user clicked from list)
- if (favoriteItems.some(item => config.formatForDisplay(item, context) === inputText)) {
- return favoriteItems; // Show all favorites, don't filter
- }
-
- return favoriteItems.filter(item => config.filterItem(item, inputText, context));
- }, [favoriteItems, inputText, selectedItem, config, context]);
-
- // Check if current input can be added to favorites
- const canAddToFavorites = React.useMemo(() => {
- if (!onToggleFavorite || !inputText.trim()) return false;
-
- // Parse input to see if it's a valid item
- const parsedItem = config.parseFromDisplay(inputText.trim(), context);
- if (!parsedItem) return false;
+ const filteredRecentItems = React.useMemo(() => {
+ if (!inputText.trim()) return baseRecentItems;
+ return baseRecentItems.filter((item) => config.filterItem(item, inputText, context));
+ }, [baseRecentItems, inputText, config, context]);
- // Check if already in favorites
- const parsedId = config.getItemId(parsedItem);
- return !favoriteItems.some(fav => config.getItemId(fav) === parsedId);
- }, [inputText, favoriteItems, config, context, onToggleFavorite]);
+ const filteredItems = React.useMemo(() => {
+ if (!inputText.trim()) return baseAllItems;
+ return baseAllItems.filter((item) => config.filterItem(item, inputText, context));
+ }, [baseAllItems, inputText, config, context]);
- // Handle input text change
const handleInputChange = (text: string) => {
- isUserTyping.current = true; // User is actively typing
setInputText(text);
- // If allowCustomInput, try to parse and select
if (config.allowCustomInput && text.trim()) {
const parsedItem = config.parseFromDisplay(text.trim(), context);
- if (parsedItem) {
- onSelect(parsedItem);
- }
- }
- };
-
- // Handle item selection from list
- const handleSelectItem = (item: T) => {
- isUserTyping.current = false; // User clicked from list
- setInputText(config.formatForDisplay(item, context));
- onSelect(item);
- };
-
- // Handle clear button
- const handleClear = () => {
- isUserTyping.current = false;
- setInputText('');
- // Don't clear selection - just clear input
- };
-
- // Handle add to favorites
- const handleAddToFavorites = () => {
- if (!canAddToFavorites || !onToggleFavorite) return;
-
- const parsedItem = config.parseFromDisplay(inputText.trim(), context);
- if (parsedItem) {
- onToggleFavorite(parsedItem);
+ if (parsedItem) onSelect(parsedItem);
}
};
- // Handle remove from favorites
- const handleRemoveFavorite = (item: T) => {
- if (!onToggleFavorite) return;
-
- Modal.alert(
- 'Remove Favorite',
- `Remove "${config.getItemTitle(item)}" from ${config.favoritesSectionTitle.toLowerCase()}?`,
- [
- { text: t('common.cancel'), style: 'cancel' },
- {
- text: 'Remove',
- style: 'destructive',
- onPress: () => onToggleFavorite(item)
- }
- ]
- );
- };
-
- // Render status with StatusDot (DRY helper - matches Item.tsx detail style)
const renderStatus = (status: { text: string; color: string; dotColor: string; isPulsing?: boolean } | null | undefined) => {
if (!status) return null;
return (
@@ -394,22 +162,49 @@ export function SearchableListSelector(props: SearchableListSelectorProps)
isPulsing={status.isPulsing}
size={6}
/>
-
+
{status.text}
);
};
- // Render individual item (for recent items)
- const renderItem = (item: T, isSelected: boolean, isLast: boolean, showDividerOverride?: boolean, forRecent = false) => {
+ const renderFavoriteToggle = (item: T, isFavorite: boolean) => {
+ if (!showFavorites || !onToggleFavorite) return null;
+
+ const canRemove = config.canRemoveFavorite?.(item) ?? true;
+ const disabled = isFavorite && !canRemove;
+ const color = isFavorite ? theme.colors.button.primary.background : theme.colors.textSecondary;
+
+ return (
+ {
+ e.stopPropagation();
+ if (disabled) return;
+ onToggleFavorite(item);
+ }}
+ >
+
+
+ );
+ };
+
+ const renderItem = (item: T, isSelected: boolean, isLast: boolean, showDividerOverride?: boolean, forRecent = false, forFavorite = false) => {
const itemId = config.getItemId(item);
const title = config.getItemTitle(item);
const subtitle = forRecent && config.getRecentItemSubtitle
@@ -417,8 +212,11 @@ export function SearchableListSelector(props: SearchableListSelectorProps)
: config.getItemSubtitle?.(item);
const icon = forRecent && config.getRecentItemIcon
? config.getRecentItemIcon(item)
- : config.getItemIcon(item);
+ : forFavorite && config.getFavoriteItemIcon
+ ? config.getFavoriteItemIcon(item)
+ : config.getItemIcon(item);
const status = config.getItemStatus?.(item, theme);
+ const isFavorite = favoriteIds.has(itemId) || forFavorite;
return (
- (props: SearchableListSelectorProps)
subtitle={subtitle}
subtitleLines={0}
leftElement={icon}
- rightElement={
+ rightElement={(
{renderStatus(status)}
- {isSelected && (
+
- )}
+
+ {renderFavoriteToggle(item, isFavorite)}
- }
- onPress={() => handleSelectItem(item)}
+ )}
+ onPress={() => onSelect(item)}
showChevron={false}
selected={isSelected}
showDivider={showDividerOverride !== undefined ? showDividerOverride : !isLast}
- style={[
- styles.itemBackground,
- config.compactItems ? styles.compactItemStyle : undefined,
- isSelected ? styles.selectedItemStyle : undefined
- ]}
/>
);
};
- // "Show More" logic (matches Working Directory pattern)
- const itemsToShow = (inputText.trim() && isUserTyping.current) || showAllRecent
+ const showAllRecentItems = showAllRecent || inputText.trim().length > 0;
+ const recentItemsToShow = showAllRecentItems
? filteredRecentItems
: filteredRecentItems.slice(0, RECENT_ITEMS_DEFAULT_VISIBLE);
+ const hasRecentGroupBase = showRecent && baseRecentItems.length > 0;
+ const hasFavoritesGroupBase = showFavorites && favoriteItems.length > 0;
+ const hasAllGroupBase = showAll && baseAllItems.length > 0;
+
+ const effectiveSearchPlacement = React.useMemo(() => {
+ if (!showSearch) return 'header' as const;
+ if (searchPlacement === 'header') return 'header' as const;
+
+ if (searchPlacement === 'favorites' && hasFavoritesGroupBase) return 'favorites' as const;
+ if (searchPlacement === 'recent' && hasRecentGroupBase) return 'recent' as const;
+ if (searchPlacement === 'all' && hasAllGroupBase) return 'all' as const;
+
+ // Fall back to the first visible group so the search never disappears.
+ if (hasFavoritesGroupBase) return 'favorites' as const;
+ if (hasRecentGroupBase) return 'recent' as const;
+ if (hasAllGroupBase) return 'all' as const;
+ return 'header' as const;
+ }, [hasAllGroupBase, hasFavoritesGroupBase, hasRecentGroupBase, searchPlacement, showSearch]);
+
+ const showNoMatches = inputText.trim().length > 0;
+ const shouldRenderRecentGroup = showRecent && (filteredRecentItems.length > 0 || (effectiveSearchPlacement === 'recent' && showSearch && hasRecentGroupBase));
+ const shouldRenderFavoritesGroup = showFavorites && (filteredFavoriteItems.length > 0 || (effectiveSearchPlacement === 'favorites' && showSearch && hasFavoritesGroupBase));
+ const shouldRenderAllGroup = showAll && (filteredItems.length > 0 || (effectiveSearchPlacement === 'all' && showSearch && hasAllGroupBase));
+
+ const searchNodeHeader = showSearch ? (
+
+ ) : null;
+
+ const searchNodeEmbedded = showSearch ? (
+
+ ) : null;
+
+ const renderEmptyRow = (title: string) => (
+
+ );
+
return (
<>
- {/* Search Input */}
- {showSearch && (
-
-
-
-
-
-
- {inputText.trim() && (
- ([
- styles.clearButton,
- { opacity: pressed ? 0.6 : 0.8 }
- ])}
- >
-
-
- )}
-
-
- {showFavorites && onToggleFavorite && (
- ([
- styles.favoriteButton,
- {
- backgroundColor: canAddToFavorites
- ? theme.colors.button.primary.background
- : theme.colors.divider,
- opacity: pressed ? 0.7 : 1,
- }
- ])}
- >
-
-
+ {effectiveSearchPlacement === 'header' && searchNodeHeader}
+
+ {shouldRenderRecentGroup && (
+
+ {effectiveSearchPlacement === 'recent' && searchNodeEmbedded}
+ {recentItemsToShow.length === 0
+ ? renderEmptyRow(showNoMatches ? 'No matches' : config.noItemsMessage)
+ : recentItemsToShow.map((item, index, arr) => {
+ const itemId = config.getItemId(item);
+ const selectedId = selectedItem ? config.getItemId(selectedItem) : null;
+ const isSelected = itemId === selectedId;
+ const isLast = index === arr.length - 1;
+
+ const showDivider = !isLast ||
+ (!inputText.trim() &&
+ !showAllRecent &&
+ filteredRecentItems.length > RECENT_ITEMS_DEFAULT_VISIBLE);
+
+ return renderItem(item, isSelected, isLast, showDivider, true, false);
+ })}
+
+ {!inputText.trim() && filteredRecentItems.length > RECENT_ITEMS_DEFAULT_VISIBLE && recentItemsToShow.length > 0 && (
+ - setShowAllRecent(!showAllRecent)}
+ showChevron={false}
+ showDivider={false}
+ titleStyle={styles.showMoreTitle}
+ />
)}
-
+
)}
- {/* Recent Items Section */}
- {showRecent && filteredRecentItems.length > 0 && (
- <>
-
- {config.recentSectionTitle}
-
-
-
- {showRecentSection && (
-
- {itemsToShow.map((item, index, arr) => {
- const itemId = config.getItemId(item);
- const selectedId = selectedItem ? config.getItemId(selectedItem) : null;
- const isSelected = itemId === selectedId;
- const isLast = index === arr.length - 1;
-
- // Override divider logic for "Show More" button
- const showDivider = !isLast ||
- (!(inputText.trim() && isUserTyping.current) &&
- !showAllRecent &&
- filteredRecentItems.length > RECENT_ITEMS_DEFAULT_VISIBLE);
-
- return renderItem(item, isSelected, isLast, showDivider, true);
- })}
-
- {/* Show More Button */}
- {!(inputText.trim() && isUserTyping.current) &&
- filteredRecentItems.length > RECENT_ITEMS_DEFAULT_VISIBLE && (
- - setShowAllRecent(!showAllRecent)}
- showChevron={false}
- showDivider={false}
- titleStyle={styles.showMoreTitle}
- />
- )}
-
- )}
- >
+ {shouldRenderFavoritesGroup && (
+
+ {effectiveSearchPlacement === 'favorites' && searchNodeEmbedded}
+ {filteredFavoriteItems.length === 0
+ ? renderEmptyRow(showNoMatches ? 'No matches' : config.noItemsMessage)
+ : filteredFavoriteItems.map((item, index) => {
+ const itemId = config.getItemId(item);
+ const selectedId = selectedItem ? config.getItemId(selectedItem) : null;
+ const isSelected = itemId === selectedId;
+ const isLast = index === filteredFavoriteItems.length - 1;
+ return renderItem(item, isSelected, isLast, !isLast, false, true);
+ })}
+
)}
- {/* Favorites Section */}
- {showFavorites && filteredFavoriteItems.length > 0 && (
- <>
-
- {config.favoritesSectionTitle}
-
-
-
- {showFavoritesSection && (
-
- {filteredFavoriteItems.map((item, index) => {
- const itemId = config.getItemId(item);
- const selectedId = selectedItem ? config.getItemId(selectedItem) : null;
- const isSelected = itemId === selectedId;
- const isLast = index === filteredFavoriteItems.length - 1;
-
- const title = config.getItemTitle(item);
- const subtitle = config.getItemSubtitle?.(item);
- const icon = config.getFavoriteItemIcon?.(item) || config.getItemIcon(item);
- const status = config.getItemStatus?.(item, theme);
- const canRemove = config.canRemoveFavorite?.(item) ?? true;
-
- return (
- -
- {renderStatus(status)}
- {isSelected && (
-
- )}
- {onToggleFavorite && canRemove && (
- {
- e.stopPropagation();
- handleRemoveFavorite(item);
- }}
- >
-
-
- )}
-
- }
- onPress={() => handleSelectItem(item)}
- showChevron={false}
- selected={isSelected}
- showDivider={!isLast}
- style={[
- styles.itemBackground,
- config.compactItems ? styles.compactItemStyle : undefined,
- isSelected ? styles.selectedItemStyle : undefined
- ]}
- />
- );
- })}
-
- )}
- >
+ {shouldRenderAllGroup && (
+
+ {effectiveSearchPlacement === 'all' && searchNodeEmbedded}
+ {filteredItems.length === 0
+ ? renderEmptyRow(showNoMatches ? 'No matches' : config.noItemsMessage)
+ : filteredItems.map((item, index) => {
+ const itemId = config.getItemId(item);
+ const selectedId = selectedItem ? config.getItemId(selectedItem) : null;
+ const isSelected = itemId === selectedId;
+ const isLast = index === filteredItems.length - 1;
+ return renderItem(item, isSelected, isLast, !isLast, false, false);
+ })}
+
)}
- {/* All Items Section - always shown when items provided */}
- {items.length > 0 && (
- <>
-
-
- {config.recentSectionTitle.replace('Recent ', 'All ')}
-
-
-
-
- {showAllItemsSection && (
-
- {items.map((item, index) => {
- const itemId = config.getItemId(item);
- const selectedId = selectedItem ? config.getItemId(selectedItem) : null;
- const isSelected = itemId === selectedId;
- const isLast = index === items.length - 1;
-
- return renderItem(item, isSelected, isLast, !isLast, false);
- })}
-
- )}
- >
+ {!shouldRenderRecentGroup && !shouldRenderFavoritesGroup && !shouldRenderAllGroup && (
+
+ {effectiveSearchPlacement !== 'header' && searchNodeEmbedded}
+ {renderEmptyRow(showNoMatches ? 'No matches' : config.noItemsMessage)}
+
)}
>
);
diff --git a/sources/components/SessionTypeSelector.tsx b/sources/components/SessionTypeSelector.tsx
index 33aefd357..d7489d4ec 100644
--- a/sources/components/SessionTypeSelector.tsx
+++ b/sources/components/SessionTypeSelector.tsx
@@ -1,142 +1,82 @@
import React from 'react';
-import { View, Text, Pressable, Platform } from 'react-native';
+import { View } from 'react-native';
import { StyleSheet, useUnistyles } from 'react-native-unistyles';
-import { Typography } from '@/constants/Typography';
+import { ItemGroup } from '@/components/ItemGroup';
+import { Item } from '@/components/Item';
import { t } from '@/text';
-interface SessionTypeSelectorProps {
+export interface SessionTypeSelectorProps {
value: 'simple' | 'worktree';
onChange: (value: 'simple' | 'worktree') => void;
+ title?: string | null;
}
const stylesheet = StyleSheet.create((theme) => ({
- container: {
- backgroundColor: theme.colors.surface,
- borderRadius: Platform.select({ default: 12, android: 16 }),
- marginBottom: 12,
- overflow: 'hidden',
- },
- title: {
- fontSize: 13,
- color: theme.colors.textSecondary,
- marginBottom: 8,
- marginLeft: 16,
- marginTop: 12,
- ...Typography.default('semiBold'),
- },
- optionContainer: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingHorizontal: 16,
- paddingVertical: 12,
- minHeight: 44,
- },
- optionPressed: {
- backgroundColor: theme.colors.surfacePressed,
- },
- radioButton: {
+ radioOuter: {
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 2,
alignItems: 'center',
justifyContent: 'center',
- marginRight: 12,
},
- radioButtonActive: {
+ radioActive: {
borderColor: theme.colors.radio.active,
},
- radioButtonInactive: {
+ radioInactive: {
borderColor: theme.colors.radio.inactive,
},
- radioButtonDot: {
+ radioDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: theme.colors.radio.dot,
},
- optionContent: {
- flex: 1,
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- },
- optionLabel: {
- fontSize: 16,
- ...Typography.default('regular'),
- },
- optionLabelActive: {
- color: theme.colors.text,
- },
- optionLabelInactive: {
- color: theme.colors.text,
- },
- divider: {
- height: Platform.select({ ios: 0.33, default: 0.5 }),
- backgroundColor: theme.colors.divider,
- marginLeft: 48,
- },
}));
-export const SessionTypeSelector: React.FC = ({ value, onChange }) => {
+export function SessionTypeSelectorRows({ value, onChange }: Pick) {
const { theme } = useUnistyles();
const styles = stylesheet;
- const handlePress = (type: 'simple' | 'worktree') => {
- onChange(type);
- };
-
return (
-
- {t('newSession.sessionType.title')}
-
- handlePress('simple')}
- style={({ pressed }) => [
- styles.optionContainer,
- pressed && styles.optionPressed,
- ]}
- >
-
- {value === 'simple' && }
-
-
-
- {t('newSession.sessionType.simple')}
-
-
-
+ <>
+ -
+ {value === 'simple' && }
+
+ )}
+ selected={value === 'simple'}
+ onPress={() => onChange('simple')}
+ showChevron={false}
+ showDivider={true}
+ />
+
+ -
+ {value === 'worktree' && }
+
+ )}
+ selected={value === 'worktree'}
+ onPress={() => onChange('worktree')}
+ showChevron={false}
+ showDivider={false}
+ />
+ >
+ );
+}
-
+export function SessionTypeSelector({ value, onChange, title = t('newSession.sessionType.title') }: SessionTypeSelectorProps) {
+ if (title === null) {
+ return ;
+ }
- handlePress('worktree')}
- style={({ pressed }) => [
- styles.optionContainer,
- pressed && styles.optionPressed,
- ]}
- >
-
- {value === 'worktree' && }
-
-
-
- {t('newSession.sessionType.worktree')}
-
-
-
-
+ return (
+
+
+
);
-};
\ No newline at end of file
+}
diff --git a/sources/components/SettingsView.tsx b/sources/components/SettingsView.tsx
index 249345e97..955b66f49 100644
--- a/sources/components/SettingsView.tsx
+++ b/sources/components/SettingsView.tsx
@@ -37,6 +37,8 @@ export const SettingsView = React.memo(function SettingsView() {
const [devModeEnabled, setDevModeEnabled] = useLocalSettingMutable('devModeEnabled');
const isPro = __DEV__ || useEntitlement('pro');
const experiments = useSetting('experiments');
+ const useEnhancedSessionWizard = useSetting('useEnhancedSessionWizard');
+ const useProfiles = useSetting('useProfiles');
const isCustomServer = isUsingCustomServer();
const allMachines = useAllMachines();
const profile = useProfile();
@@ -110,7 +112,7 @@ export const SettingsView = React.memo(function SettingsView() {
// Anthropic connection
const [connectingAnthropic, connectAnthropic] = useHappyAction(async () => {
- router.push('/settings/connect/claude');
+ router.push('/(app)/settings/connect/claude');
});
// Anthropic disconnection
@@ -302,19 +304,19 @@ export const SettingsView = React.memo(function SettingsView() {
title={t('settings.account')}
subtitle={t('settings.accountSubtitle')}
icon={}
- onPress={() => router.push('/settings/account')}
+ onPress={() => router.push('/(app)/settings/account')}
/>
}
- onPress={() => router.push('/settings/appearance')}
+ onPress={() => router.push('/(app)/settings/appearance')}
/>
}
- onPress={() => router.push('/settings/voice')}
+ onPress={() => router.push('/(app)/settings/voice')}
/>
}
onPress={() => router.push('/settings/features')}
/>
- }
- onPress={() => router.push('/settings/profiles')}
- />
+ {useProfiles && (
+ }
+ onPress={() => router.push('/settings/profiles')}
+ />
+ )}
{experiments && (
}
- onPress={() => router.push('/dev')}
+ onPress={() => router.push('/(app)/dev')}
/>
)}
@@ -357,7 +361,7 @@ export const SettingsView = React.memo(function SettingsView() {
icon={}
onPress={() => {
trackWhatsNewClicked();
- router.push('/changelog');
+ router.push('/(app)/changelog');
}}
/>
- ({
+ track: {
+ width: TRACK_WIDTH,
+ height: TRACK_HEIGHT,
+ borderRadius: TRACK_HEIGHT / 2,
+ padding: PADDING,
+ justifyContent: 'center',
+ },
+ thumb: {
+ width: THUMB_SIZE,
+ height: THUMB_SIZE,
+ borderRadius: THUMB_SIZE / 2,
+ },
+}));
+
+export const Switch = ({ value, disabled, onValueChange }: SwitchProps) => {
+ const { theme } = useUnistyles();
+ const styles = stylesheet;
+
+ const translateX = value ? TRACK_WIDTH - THUMB_SIZE - PADDING * 2 : 0;
+
+ return (
+ onValueChange?.(!value)}
+ style={({ pressed }) => ({
+ opacity: disabled ? 0.6 : pressed ? 0.85 : 1,
+ })}
+ >
+
+
+
+
+ );
+};
+
diff --git a/sources/components/newSession/DirectorySelector.tsx b/sources/components/newSession/DirectorySelector.tsx
new file mode 100644
index 000000000..6c66fabe4
--- /dev/null
+++ b/sources/components/newSession/DirectorySelector.tsx
@@ -0,0 +1,111 @@
+import React from 'react';
+import { Ionicons } from '@expo/vector-icons';
+import { useUnistyles } from 'react-native-unistyles';
+import { SearchableListSelector } from '@/components/SearchableListSelector';
+import { formatPathRelativeToHome } from '@/utils/sessionUtils';
+import { resolveAbsolutePath } from '@/utils/pathUtils';
+
+export interface DirectorySelectorProps {
+ machineHomeDir?: string | null;
+ selectedPath: string;
+ recentPaths: string[];
+ suggestedPaths?: string[];
+ favoritePaths?: string[];
+ onSelect: (path: string) => void;
+ onToggleFavorite?: (path: string) => void;
+ showFavorites?: boolean;
+ showRecent?: boolean;
+ showSearch?: boolean;
+ searchPlaceholder?: string;
+ recentSectionTitle?: string;
+ favoritesSectionTitle?: string;
+ allSectionTitle?: string;
+ noItemsMessage?: string;
+}
+
+export function DirectorySelector({
+ machineHomeDir,
+ selectedPath,
+ recentPaths,
+ suggestedPaths = [],
+ favoritePaths = [],
+ onSelect,
+ onToggleFavorite,
+ showFavorites = true,
+ showRecent = true,
+ showSearch = true,
+ searchPlaceholder = 'Type to filter directories...',
+ recentSectionTitle = 'Recent Directories',
+ favoritesSectionTitle = 'Favorite Directories',
+ allSectionTitle = 'All Directories',
+ noItemsMessage = 'No recent directories',
+}: DirectorySelectorProps) {
+ const { theme } = useUnistyles();
+ const homeDir = machineHomeDir || undefined;
+ const recentOrSuggestedPaths = recentPaths.length > 0 ? recentPaths : suggestedPaths;
+ const recentTitle = recentPaths.length > 0 ? recentSectionTitle : 'Suggested Directories';
+
+ const allPaths = React.useMemo(() => {
+ const seen = new Set();
+ const ordered: string[] = [];
+ for (const p of [...favoritePaths, ...recentOrSuggestedPaths]) {
+ if (!p) continue;
+ if (seen.has(p)) continue;
+ seen.add(p);
+ ordered.push(p);
+ }
+ return ordered;
+ }, [favoritePaths, recentOrSuggestedPaths]);
+
+ return (
+
+ config={{
+ getItemId: (path) => path,
+ getItemTitle: (path) => formatPathRelativeToHome(path, homeDir),
+ getItemSubtitle: undefined,
+ getItemIcon: () => (
+
+ ),
+ getRecentItemIcon: () => (
+
+ ),
+ formatForDisplay: (path) => formatPathRelativeToHome(path, homeDir),
+ parseFromDisplay: (text) => {
+ const trimmed = text.trim();
+ if (!trimmed) return null;
+ if (trimmed.startsWith('/')) return trimmed;
+ if (homeDir) return resolveAbsolutePath(trimmed, homeDir);
+ return null;
+ },
+ filterItem: (path, searchText) => {
+ const displayPath = formatPathRelativeToHome(path, homeDir);
+ return displayPath.toLowerCase().includes(searchText.toLowerCase());
+ },
+ searchPlaceholder,
+ recentSectionTitle: recentTitle,
+ favoritesSectionTitle,
+ allSectionTitle,
+ noItemsMessage,
+ showFavorites,
+ showRecent,
+ showSearch,
+ showAll: favoritePaths.length > 0,
+ allowCustomInput: true,
+ }}
+ items={allPaths}
+ recentItems={recentOrSuggestedPaths}
+ favoriteItems={favoritePaths}
+ selectedItem={selectedPath || null}
+ onSelect={onSelect}
+ onToggleFavorite={onToggleFavorite}
+ />
+ );
+}
diff --git a/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx b/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx
new file mode 100644
index 000000000..dac0c164a
--- /dev/null
+++ b/sources/components/newSession/EnvironmentVariablesPreviewModal.tsx
@@ -0,0 +1,241 @@
+import React from 'react';
+import { View, Text, ScrollView, Pressable, Platform, useWindowDimensions } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { useUnistyles } from 'react-native-unistyles';
+import { Typography } from '@/constants/Typography';
+import { ItemGroup } from '@/components/ItemGroup';
+import { Item } from '@/components/Item';
+import { useEnvironmentVariables } from '@/hooks/useEnvironmentVariables';
+
+export interface EnvironmentVariablesPreviewModalProps {
+ environmentVariables: Record;
+ machineId: string | null;
+ machineName?: string | null;
+ profileName?: string | null;
+ onClose: () => void;
+}
+
+function parseTemplateValue(value: string): { sourceVar: string; fallback: string } | null {
+ const withFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*):[-=](.*)\}$/);
+ if (withFallback) {
+ return { sourceVar: withFallback[1], fallback: withFallback[2] };
+ }
+ const noFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*)\}$/);
+ if (noFallback) {
+ return { sourceVar: noFallback[1], fallback: '' };
+ }
+ return null;
+}
+
+function isSecretLike(name: string) {
+ return /TOKEN|KEY|SECRET|AUTH|PASS|PASSWORD|COOKIE/i.test(name);
+}
+
+export function EnvironmentVariablesPreviewModal(props: EnvironmentVariablesPreviewModalProps) {
+ const { theme } = useUnistyles();
+ const { height: windowHeight } = useWindowDimensions();
+ const scrollRef = React.useRef(null);
+ const scrollYRef = React.useRef(0);
+
+ const handleScroll = React.useCallback((e: any) => {
+ scrollYRef.current = e?.nativeEvent?.contentOffset?.y ?? 0;
+ }, []);
+
+ // On web, RN ScrollView inside a modal doesn't reliably respond to mouse wheel / trackpad scroll.
+ // Manually translate wheel deltas into scrollTo.
+ const handleWheel = React.useCallback((e: any) => {
+ if (Platform.OS !== 'web') return;
+ const deltaY = e?.deltaY;
+ if (typeof deltaY !== 'number' || Number.isNaN(deltaY)) return;
+
+ if (e?.cancelable) {
+ e?.preventDefault?.();
+ }
+ e?.stopPropagation?.();
+ scrollRef.current?.scrollTo({ y: Math.max(0, scrollYRef.current + deltaY), animated: false });
+ }, []);
+
+ const envVarEntries = React.useMemo(() => {
+ return Object.entries(props.environmentVariables)
+ .map(([name, value]) => ({ name, value }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+ }, [props.environmentVariables]);
+
+ const refsToQuery = React.useMemo(() => {
+ const refs = new Set();
+ envVarEntries.forEach((envVar) => {
+ const parsed = parseTemplateValue(envVar.value);
+ if (parsed?.sourceVar) {
+ // Never fetch secret-like values into UI memory.
+ if (isSecretLike(envVar.name) || isSecretLike(parsed.sourceVar)) return;
+ refs.add(parsed.sourceVar);
+ }
+ });
+ return Array.from(refs);
+ }, [envVarEntries]);
+
+ const { variables: machineEnv } = useEnvironmentVariables(props.machineId, refsToQuery);
+
+ const title = props.profileName ? `Env Vars · ${props.profileName}` : 'Environment Variables';
+ const maxHeight = Math.min(720, Math.max(360, Math.floor(windowHeight * 0.85)));
+
+ return (
+
+
+
+ {title}
+
+
+ ({ opacity: pressed ? 0.7 : 1 })}
+ >
+
+
+
+
+
+
+
+ These environment variables are sent when starting the session. Values are resolved using the daemon on{' '}
+ {props.machineName ? (
+
+ {props.machineName}
+
+ ) : (
+ 'the selected machine'
+ )}
+ .
+
+
+
+ {envVarEntries.length === 0 ? (
+
+
+ No environment variables are set for this profile.
+
+
+ ) : (
+
+ {envVarEntries.map((envVar, idx) => {
+ const parsed = parseTemplateValue(envVar.value);
+ const secret = isSecretLike(envVar.name) || (parsed?.sourceVar ? isSecretLike(parsed.sourceVar) : false);
+
+ const hasMachineContext = Boolean(props.machineId);
+ const resolvedValue = parsed?.sourceVar ? machineEnv[parsed.sourceVar] : undefined;
+ const isMachineBased = Boolean(parsed?.sourceVar);
+
+ let displayValue: string;
+ if (secret) {
+ displayValue = '•••';
+ } else if (parsed) {
+ if (!hasMachineContext) {
+ displayValue = `\${${parsed.sourceVar}${parsed.fallback ? `:-${parsed.fallback}` : ''}}`;
+ } else if (resolvedValue === undefined) {
+ displayValue = `\${${parsed.sourceVar}${parsed.fallback ? `:-${parsed.fallback}` : ''}} (checking…)`;
+ } else if (resolvedValue === null || resolvedValue === '') {
+ displayValue = parsed.fallback ? parsed.fallback : '(empty)';
+ } else {
+ displayValue = resolvedValue;
+ }
+ } else {
+ displayValue = envVar.value || '(empty)';
+ }
+
+ const detailLabel = (() => {
+ if (secret) return undefined;
+ if (!isMachineBased) return 'Fixed';
+ if (!hasMachineContext) return 'Machine';
+ if (resolvedValue === undefined) return 'Checking';
+ if (resolvedValue === null || resolvedValue === '') return parsed?.fallback ? 'Fallback' : 'Missing';
+ return 'Machine';
+ })();
+
+ const detailColor = (() => {
+ if (!detailLabel) return theme.colors.textSecondary;
+ if (detailLabel === 'Machine') return theme.colors.status.connected;
+ if (detailLabel === 'Fallback' || detailLabel === 'Missing') return theme.colors.warning;
+ return theme.colors.textSecondary;
+ })();
+
+ const rightElement = (() => {
+ if (secret) return undefined;
+ if (!isMachineBased) return undefined;
+ if (!hasMachineContext || detailLabel === 'Checking') {
+ return ;
+ }
+ return ;
+ })();
+
+ return (
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
diff --git a/sources/components/newSession/MachineSelector.tsx b/sources/components/newSession/MachineSelector.tsx
new file mode 100644
index 000000000..402637f3e
--- /dev/null
+++ b/sources/components/newSession/MachineSelector.tsx
@@ -0,0 +1,105 @@
+import React from 'react';
+import { Ionicons } from '@expo/vector-icons';
+import { useUnistyles } from 'react-native-unistyles';
+import { SearchableListSelector } from '@/components/SearchableListSelector';
+import type { Machine } from '@/sync/storageTypes';
+import { isMachineOnline } from '@/utils/machineUtils';
+
+export interface MachineSelectorProps {
+ machines: Machine[];
+ selectedMachine: Machine | null;
+ recentMachines?: Machine[];
+ favoriteMachines?: Machine[];
+ onSelect: (machine: Machine) => void;
+ onToggleFavorite?: (machine: Machine) => void;
+ showFavorites?: boolean;
+ showRecent?: boolean;
+ showSearch?: boolean;
+ searchPlacement?: 'header' | 'recent' | 'favorites' | 'all';
+ searchPlaceholder?: string;
+ recentSectionTitle?: string;
+ favoritesSectionTitle?: string;
+ allSectionTitle?: string;
+ noItemsMessage?: string;
+}
+
+export function MachineSelector({
+ machines,
+ selectedMachine,
+ recentMachines = [],
+ favoriteMachines = [],
+ onSelect,
+ onToggleFavorite,
+ showFavorites = true,
+ showRecent = true,
+ showSearch = true,
+ searchPlacement = 'header',
+ searchPlaceholder = 'Type to filter machines...',
+ recentSectionTitle = 'Recent Machines',
+ favoritesSectionTitle = 'Favorite Machines',
+ allSectionTitle = 'All Machines',
+ noItemsMessage = 'No machines available',
+}: MachineSelectorProps) {
+ const { theme } = useUnistyles();
+
+ return (
+
+ config={{
+ getItemId: (machine) => machine.id,
+ getItemTitle: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id,
+ getItemSubtitle: undefined,
+ getItemIcon: () => (
+
+ ),
+ getRecentItemIcon: () => (
+
+ ),
+ getItemStatus: (machine) => {
+ const offline = !isMachineOnline(machine);
+ return {
+ text: offline ? 'offline' : 'online',
+ color: offline ? theme.colors.status.disconnected : theme.colors.status.connected,
+ dotColor: offline ? theme.colors.status.disconnected : theme.colors.status.connected,
+ isPulsing: !offline,
+ };
+ },
+ formatForDisplay: (machine) => machine.metadata?.displayName || machine.metadata?.host || machine.id,
+ parseFromDisplay: (text) => {
+ return machines.find(m =>
+ m.metadata?.displayName === text || m.metadata?.host === text || m.id === text
+ ) || null;
+ },
+ filterItem: (machine, searchText) => {
+ const displayName = (machine.metadata?.displayName || '').toLowerCase();
+ const host = (machine.metadata?.host || '').toLowerCase();
+ const search = searchText.toLowerCase();
+ return displayName.includes(search) || host.includes(search);
+ },
+ searchPlaceholder,
+ recentSectionTitle,
+ favoritesSectionTitle,
+ allSectionTitle,
+ noItemsMessage,
+ showFavorites,
+ showRecent,
+ showSearch,
+ allowCustomInput: false,
+ }}
+ items={machines}
+ recentItems={recentMachines}
+ favoriteItems={favoriteMachines}
+ selectedItem={selectedMachine}
+ onSelect={onSelect}
+ onToggleFavorite={onToggleFavorite}
+ searchPlacement={searchPlacement}
+ />
+ );
+}
diff --git a/sources/components/newSession/PathSelector.tsx b/sources/components/newSession/PathSelector.tsx
new file mode 100644
index 000000000..6ab44d019
--- /dev/null
+++ b/sources/components/newSession/PathSelector.tsx
@@ -0,0 +1,456 @@
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { View, Pressable } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { StyleSheet, useUnistyles } from 'react-native-unistyles';
+import { ItemGroup } from '@/components/ItemGroup';
+import { Item } from '@/components/Item';
+import { SearchHeader } from '@/components/SearchHeader';
+import { MultiTextInput, MultiTextInputHandle } from '@/components/MultiTextInput';
+import { formatPathRelativeToHome } from '@/utils/sessionUtils';
+import { resolveAbsolutePath } from '@/utils/pathUtils';
+
+export interface PathSelectorProps {
+ machineHomeDir: string;
+ selectedPath: string;
+ onChangeSelectedPath: (path: string) => void;
+ recentPaths: string[];
+ usePickerSearch: boolean;
+ searchVariant?: 'header' | 'group' | 'none';
+ searchQuery?: string;
+ onChangeSearchQuery?: (text: string) => void;
+ favoriteDirectories: string[];
+ onChangeFavoriteDirectories: (dirs: string[]) => void;
+}
+
+const stylesheet = StyleSheet.create((theme) => ({
+ pathInputContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ paddingHorizontal: 16,
+ paddingVertical: 16,
+ },
+ pathInput: {
+ flex: 1,
+ backgroundColor: theme.colors.input.background,
+ borderRadius: 10,
+ paddingHorizontal: 12,
+ minHeight: 36,
+ position: 'relative',
+ borderWidth: 0.5,
+ borderColor: theme.colors.divider,
+ },
+}));
+
+const ITEM_RIGHT_GAP = 16;
+
+export function PathSelector({
+ machineHomeDir,
+ selectedPath,
+ onChangeSelectedPath,
+ recentPaths,
+ usePickerSearch,
+ searchVariant = 'header',
+ searchQuery: controlledSearchQuery,
+ onChangeSearchQuery: onChangeSearchQueryProp,
+ favoriteDirectories,
+ onChangeFavoriteDirectories,
+}: PathSelectorProps) {
+ const { theme } = useUnistyles();
+ const styles = stylesheet;
+ const inputRef = useRef(null);
+ const searchInputRef = useRef(null);
+ const searchWasFocusedRef = useRef(false);
+
+ const [uncontrolledSearchQuery, setUncontrolledSearchQuery] = useState('');
+ const searchQuery = controlledSearchQuery ?? uncontrolledSearchQuery;
+ const setSearchQuery = onChangeSearchQueryProp ?? setUncontrolledSearchQuery;
+
+ const suggestedPaths = useMemo(() => {
+ const homeDir = machineHomeDir || '/home';
+ return [
+ homeDir,
+ `${homeDir}/projects`,
+ `${homeDir}/Documents`,
+ `${homeDir}/Desktop`,
+ ];
+ }, [machineHomeDir]);
+
+ const favoritePaths = useMemo(() => {
+ const homeDir = machineHomeDir || '/home';
+ const paths = favoriteDirectories.map((fav) => resolveAbsolutePath(fav, homeDir));
+ const seen = new Set();
+ const ordered: string[] = [];
+ for (const p of paths) {
+ if (!p) continue;
+ if (seen.has(p)) continue;
+ seen.add(p);
+ ordered.push(p);
+ }
+ return ordered;
+ }, [favoriteDirectories, machineHomeDir]);
+
+ const filteredFavoritePaths = useMemo(() => {
+ if (!usePickerSearch || !searchQuery.trim()) return favoritePaths;
+ const query = searchQuery.toLowerCase();
+ return favoritePaths.filter((path) => path.toLowerCase().includes(query));
+ }, [favoritePaths, searchQuery, usePickerSearch]);
+
+ const filteredRecentPaths = useMemo(() => {
+ const base = recentPaths.filter((p) => !favoritePaths.includes(p));
+ if (!usePickerSearch || !searchQuery.trim()) return base;
+ const query = searchQuery.toLowerCase();
+ return base.filter((path) => path.toLowerCase().includes(query));
+ }, [favoritePaths, recentPaths, searchQuery, usePickerSearch]);
+
+ const filteredSuggestedPaths = useMemo(() => {
+ const base = suggestedPaths.filter((p) => !favoritePaths.includes(p));
+ if (!usePickerSearch || !searchQuery.trim()) return base;
+ const query = searchQuery.toLowerCase();
+ return base.filter((path) => path.toLowerCase().includes(query));
+ }, [favoritePaths, searchQuery, suggestedPaths, usePickerSearch]);
+
+ const baseRecentPaths = useMemo(() => {
+ return recentPaths.filter((p) => !favoritePaths.includes(p));
+ }, [favoritePaths, recentPaths]);
+
+ const baseSuggestedPaths = useMemo(() => {
+ return suggestedPaths.filter((p) => !favoritePaths.includes(p));
+ }, [favoritePaths, suggestedPaths]);
+
+ const effectiveGroupSearchPlacement = useMemo(() => {
+ if (!usePickerSearch || searchVariant !== 'group') return null as null | 'favorites' | 'recent' | 'suggested' | 'fallback';
+ const preferred: 'suggested' | 'recent' | 'favorites' | 'fallback' =
+ baseSuggestedPaths.length > 0 ? 'suggested'
+ : baseRecentPaths.length > 0 ? 'recent'
+ : favoritePaths.length > 0 ? 'favorites'
+ : 'fallback';
+
+ if (preferred === 'suggested') {
+ if (filteredSuggestedPaths.length > 0) return 'suggested';
+ if (filteredFavoritePaths.length > 0) return 'favorites';
+ if (filteredRecentPaths.length > 0) return 'recent';
+ return 'suggested';
+ }
+
+ if (preferred === 'recent') {
+ if (filteredRecentPaths.length > 0) return 'recent';
+ if (filteredFavoritePaths.length > 0) return 'favorites';
+ if (filteredSuggestedPaths.length > 0) return 'suggested';
+ return 'recent';
+ }
+
+ if (preferred === 'favorites') {
+ if (filteredFavoritePaths.length > 0) return 'favorites';
+ if (filteredRecentPaths.length > 0) return 'recent';
+ if (filteredSuggestedPaths.length > 0) return 'suggested';
+ return 'favorites';
+ }
+
+ return 'fallback';
+ }, [
+ baseRecentPaths.length,
+ baseSuggestedPaths.length,
+ favoritePaths.length,
+ filteredFavoritePaths.length,
+ filteredRecentPaths.length,
+ filteredSuggestedPaths.length,
+ searchVariant,
+ usePickerSearch,
+ ]);
+
+ useEffect(() => {
+ if (!usePickerSearch || searchVariant !== 'group') return;
+ if (!searchWasFocusedRef.current) return;
+
+ const id = setTimeout(() => {
+ // Keep the search box usable while it moves between groups by restoring focus.
+ // (The underlying TextInput unmounts/remounts as placement changes.)
+ try {
+ searchInputRef.current?.focus?.();
+ } catch { }
+ }, 0);
+ return () => clearTimeout(id);
+ }, [effectiveGroupSearchPlacement, searchVariant, usePickerSearch]);
+
+ const showNoMatchesRow = usePickerSearch && searchQuery.trim().length > 0;
+ const shouldRenderFavoritesGroup = filteredFavoritePaths.length > 0 || effectiveGroupSearchPlacement === 'favorites';
+ const shouldRenderRecentGroup = filteredRecentPaths.length > 0 || effectiveGroupSearchPlacement === 'recent';
+ const shouldRenderSuggestedGroup = filteredSuggestedPaths.length > 0 || effectiveGroupSearchPlacement === 'suggested';
+ const shouldRenderFallbackGroup = effectiveGroupSearchPlacement === 'fallback';
+
+ const toggleFavorite = React.useCallback((absolutePath: string) => {
+ const homeDir = machineHomeDir || '/home';
+
+ const relativePath = formatPathRelativeToHome(absolutePath, homeDir);
+ const resolved = resolveAbsolutePath(relativePath, homeDir);
+ const isInFavorites = favoriteDirectories.some((fav) => resolveAbsolutePath(fav, homeDir) === resolved);
+
+ onChangeFavoriteDirectories(isInFavorites
+ ? favoriteDirectories.filter((fav) => resolveAbsolutePath(fav, homeDir) !== resolved)
+ : [...favoriteDirectories, relativePath]
+ );
+ }, [favoriteDirectories, machineHomeDir, onChangeFavoriteDirectories]);
+
+ const setPathAndFocus = React.useCallback((path: string) => {
+ onChangeSelectedPath(path);
+ setTimeout(() => inputRef.current?.focus(), 50);
+ }, [onChangeSelectedPath]);
+
+ const renderRightElement = React.useCallback((absolutePath: string, isSelected: boolean, isFavorite: boolean) => {
+ return (
+
+
+
+
+ {
+ e.stopPropagation();
+ toggleFavorite(absolutePath);
+ }}
+ >
+
+
+
+ );
+ }, [theme.colors.button.primary.background, theme.colors.textSecondary, toggleFavorite]);
+
+ return (
+ <>
+ {usePickerSearch && searchVariant === 'header' && (
+
+ )}
+
+
+
+
+
+
+
+
+
+ {usePickerSearch && searchVariant === 'group' && shouldRenderRecentGroup && (
+
+ {effectiveGroupSearchPlacement === 'recent' && (
+ { searchWasFocusedRef.current = true; }}
+ onBlur={() => { searchWasFocusedRef.current = false; }}
+ containerStyle={{
+ backgroundColor: 'transparent',
+ borderBottomWidth: 0,
+ }}
+ />
+ )}
+ {filteredRecentPaths.length === 0
+ ? (
+
+ )
+ : filteredRecentPaths.map((path, index) => {
+ const isSelected = selectedPath.trim() === path;
+ const isLast = index === filteredRecentPaths.length - 1;
+ const isFavorite = favoritePaths.includes(path);
+ return (
+ }
+ onPress={() => setPathAndFocus(path)}
+ selected={isSelected}
+ showChevron={false}
+ rightElement={renderRightElement(path, isSelected, isFavorite)}
+ showDivider={!isLast}
+ />
+ );
+ })}
+
+ )}
+
+ {shouldRenderFavoritesGroup && (
+
+ {usePickerSearch && searchVariant === 'group' && effectiveGroupSearchPlacement === 'favorites' && (
+ { searchWasFocusedRef.current = true; }}
+ onBlur={() => { searchWasFocusedRef.current = false; }}
+ containerStyle={{
+ backgroundColor: 'transparent',
+ borderBottomWidth: 0,
+ }}
+ />
+ )}
+ {filteredFavoritePaths.length === 0
+ ? (
+
+ )
+ : filteredFavoritePaths.map((path, index) => {
+ const isSelected = selectedPath.trim() === path;
+ const isLast = index === filteredFavoritePaths.length - 1;
+ return (
+ }
+ onPress={() => setPathAndFocus(path)}
+ selected={isSelected}
+ showChevron={false}
+ rightElement={renderRightElement(path, isSelected, true)}
+ showDivider={!isLast}
+ />
+ );
+ })}
+
+ )}
+
+ {filteredRecentPaths.length > 0 && searchVariant !== 'group' && (
+
+ {filteredRecentPaths.map((path, index) => {
+ const isSelected = selectedPath.trim() === path;
+ const isLast = index === filteredRecentPaths.length - 1;
+ const isFavorite = favoritePaths.includes(path);
+ return (
+ }
+ onPress={() => setPathAndFocus(path)}
+ selected={isSelected}
+ showChevron={false}
+ rightElement={renderRightElement(path, isSelected, isFavorite)}
+ showDivider={!isLast}
+ />
+ );
+ })}
+
+ )}
+
+ {usePickerSearch && searchVariant === 'group' && shouldRenderSuggestedGroup && (
+
+ {effectiveGroupSearchPlacement === 'suggested' && (
+ { searchWasFocusedRef.current = true; }}
+ onBlur={() => { searchWasFocusedRef.current = false; }}
+ containerStyle={{
+ backgroundColor: 'transparent',
+ borderBottomWidth: 0,
+ }}
+ />
+ )}
+ {filteredSuggestedPaths.length === 0
+ ? (
+
+ )
+ : filteredSuggestedPaths.map((path, index) => {
+ const isSelected = selectedPath.trim() === path;
+ const isLast = index === filteredSuggestedPaths.length - 1;
+ const isFavorite = favoritePaths.includes(path);
+ return (
+ }
+ onPress={() => setPathAndFocus(path)}
+ selected={isSelected}
+ showChevron={false}
+ rightElement={renderRightElement(path, isSelected, isFavorite)}
+ showDivider={!isLast}
+ />
+ );
+ })}
+
+ )}
+
+ {filteredRecentPaths.length === 0 && filteredSuggestedPaths.length > 0 && searchVariant !== 'group' && (
+
+ {filteredSuggestedPaths.map((path, index) => {
+ const isSelected = selectedPath.trim() === path;
+ const isLast = index === filteredSuggestedPaths.length - 1;
+ const isFavorite = favoritePaths.includes(path);
+ return (
+ }
+ onPress={() => setPathAndFocus(path)}
+ selected={isSelected}
+ showChevron={false}
+ rightElement={renderRightElement(path, isSelected, isFavorite)}
+ showDivider={!isLast}
+ />
+ );
+ })}
+
+ )}
+
+ {usePickerSearch && searchVariant === 'group' && shouldRenderFallbackGroup && (
+
+ { searchWasFocusedRef.current = true; }}
+ onBlur={() => { searchWasFocusedRef.current = false; }}
+ containerStyle={{
+ backgroundColor: 'transparent',
+ borderBottomWidth: 0,
+ }}
+ />
+
+
+ )}
+ >
+ );
+}
diff --git a/sources/components/newSession/ProfileCompatibilityIcon.tsx b/sources/components/newSession/ProfileCompatibilityIcon.tsx
new file mode 100644
index 000000000..dfaac7f8a
--- /dev/null
+++ b/sources/components/newSession/ProfileCompatibilityIcon.tsx
@@ -0,0 +1,71 @@
+import React from 'react';
+import { Text, View, type ViewStyle } from 'react-native';
+import { useUnistyles } from 'react-native-unistyles';
+import { Typography } from '@/constants/Typography';
+import type { AIBackendProfile } from '@/sync/settings';
+import { useSetting } from '@/sync/storage';
+
+type Props = {
+ profile: Pick;
+ size?: number;
+ style?: ViewStyle;
+};
+
+export function ProfileCompatibilityIcon({ profile, size = 32, style }: Props) {
+ const { theme } = useUnistyles();
+ const experimentsEnabled = useSetting('experiments');
+
+ const hasClaude = !!profile.compatibility?.claude;
+ const hasCodex = !!profile.compatibility?.codex;
+ const hasGemini = experimentsEnabled && !!profile.compatibility?.gemini;
+
+ const glyphs = React.useMemo(() => {
+ const items: Array<{ key: string; glyph: string; factor: number }> = [];
+ if (hasClaude) items.push({ key: 'claude', glyph: '✳', factor: 1.14 });
+ if (hasCodex) items.push({ key: 'codex', glyph: '꩜', factor: 0.82 });
+ if (hasGemini) items.push({ key: 'gemini', glyph: '✦', factor: 0.88 });
+ if (items.length === 0) items.push({ key: 'none', glyph: '•', factor: 0.85 });
+ return items;
+ }, [hasClaude, hasCodex, hasGemini]);
+
+ const multiScale = glyphs.length === 1 ? 1 : glyphs.length === 2 ? 0.6 : 0.5;
+
+ return (
+
+ {glyphs.length === 1 ? (
+
+ {glyphs[0].glyph}
+
+ ) : (
+
+ {glyphs.map((item) => {
+ const fontSize = Math.round(size * multiScale * item.factor);
+ return (
+
+ {item.glyph}
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/sources/components/tools/ToolView.tsx b/sources/components/tools/ToolView.tsx
index 98e42d879..15b8c8567 100644
--- a/sources/components/tools/ToolView.tsx
+++ b/sources/components/tools/ToolView.tsx
@@ -50,6 +50,14 @@ export const ToolView = React.memo((props) => {
let icon = ;
let noStatus = false;
let hideDefaultError = false;
+
+ // For Gemini: unknown tools should be rendered as minimal (hidden)
+ // This prevents showing raw INPUT/OUTPUT for internal Gemini tools
+ // that we haven't explicitly added to knownTools
+ const isGemini = props.metadata?.flavor === 'gemini';
+ if (!knownTool && isGemini) {
+ minimal = true;
+ }
// Extract status first to potentially use as title
if (knownTool && typeof knownTool.extractStatus === 'function') {
diff --git a/sources/components/tools/knownTools.tsx b/sources/components/tools/knownTools.tsx
index 0405806e3..696f8315e 100644
--- a/sources/components/tools/knownTools.tsx
+++ b/sources/components/tools/knownTools.tsx
@@ -180,6 +180,11 @@ export const knownTools = {
const path = resolvePath(opts.tool.input.file_path, opts.metadata);
return path;
}
+ // Gemini uses 'locations' array with 'path' field
+ if (opts.tool.input.locations && Array.isArray(opts.tool.input.locations) && opts.tool.input.locations[0]?.path) {
+ const path = resolvePath(opts.tool.input.locations[0].path, opts.metadata);
+ return path;
+ }
return t('tools.names.readFile');
},
minimal: true,
@@ -187,7 +192,10 @@ export const knownTools = {
input: z.object({
file_path: z.string().describe('The absolute path to the file to read'),
limit: z.number().optional().describe('The number of lines to read'),
- offset: z.number().optional().describe('The line number to start reading from')
+ offset: z.number().optional().describe('The line number to start reading from'),
+ // Gemini format
+ items: z.array(z.any()).optional(),
+ locations: z.array(z.object({ path: z.string() }).loose()).optional()
}).partial().loose(),
result: z.object({
file: z.object({
@@ -199,6 +207,28 @@ export const knownTools = {
}).loose().optional()
}).partial().loose()
},
+ // Gemini uses lowercase 'read'
+ 'read': {
+ title: (opts: { metadata: Metadata | null, tool: ToolCall }) => {
+ // Gemini uses 'locations' array with 'path' field
+ if (opts.tool.input.locations && Array.isArray(opts.tool.input.locations) && opts.tool.input.locations[0]?.path) {
+ const path = resolvePath(opts.tool.input.locations[0].path, opts.metadata);
+ return path;
+ }
+ if (typeof opts.tool.input.file_path === 'string') {
+ const path = resolvePath(opts.tool.input.file_path, opts.metadata);
+ return path;
+ }
+ return t('tools.names.readFile');
+ },
+ minimal: true,
+ icon: ICON_READ,
+ input: z.object({
+ items: z.array(z.any()).optional(),
+ locations: z.array(z.object({ path: z.string() }).loose()).optional(),
+ file_path: z.string().optional()
+ }).partial().loose()
+ },
'Edit': {
title: (opts: { metadata: Metadata | null, tool: ToolCall }) => {
if (typeof opts.tool.input.file_path === 'string') {
@@ -510,6 +540,150 @@ export const knownTools = {
return t('tools.names.reasoning');
}
},
+ 'GeminiReasoning': {
+ title: (opts: { metadata: Metadata | null, tool: ToolCall }) => {
+ // Use the title from input if provided
+ if (opts.tool.input?.title && typeof opts.tool.input.title === 'string') {
+ return opts.tool.input.title;
+ }
+ return t('tools.names.reasoning');
+ },
+ icon: ICON_REASONING,
+ minimal: true,
+ input: z.object({
+ title: z.string().describe('The title of the reasoning')
+ }).partial().loose(),
+ result: z.object({
+ content: z.string().describe('The reasoning content'),
+ status: z.enum(['completed', 'in_progress', 'canceled']).optional().describe('The status of the reasoning')
+ }).partial().loose(),
+ extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => {
+ if (opts.tool.input?.title && typeof opts.tool.input.title === 'string') {
+ return opts.tool.input.title;
+ }
+ return t('tools.names.reasoning');
+ }
+ },
+ 'think': {
+ title: (opts: { metadata: Metadata | null, tool: ToolCall }) => {
+ // Use the title from input if provided
+ if (opts.tool.input?.title && typeof opts.tool.input.title === 'string') {
+ return opts.tool.input.title;
+ }
+ return t('tools.names.reasoning');
+ },
+ icon: ICON_REASONING,
+ minimal: true,
+ input: z.object({
+ title: z.string().optional().describe('The title of the thinking'),
+ items: z.array(z.any()).optional().describe('Items to think about'),
+ locations: z.array(z.any()).optional().describe('Locations to consider')
+ }).partial().loose(),
+ result: z.object({
+ content: z.string().optional().describe('The reasoning content'),
+ text: z.string().optional().describe('The reasoning text'),
+ status: z.enum(['completed', 'in_progress', 'canceled']).optional().describe('The status')
+ }).partial().loose(),
+ extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => {
+ if (opts.tool.input?.title && typeof opts.tool.input.title === 'string') {
+ return opts.tool.input.title;
+ }
+ return t('tools.names.reasoning');
+ }
+ },
+ 'change_title': {
+ title: 'Change Title',
+ icon: ICON_EDIT,
+ minimal: true,
+ noStatus: true,
+ input: z.object({
+ title: z.string().optional().describe('New session title')
+ }).partial().loose(),
+ result: z.object({}).partial().loose()
+ },
+ // Gemini internal tools - should be hidden (minimal)
+ 'search': {
+ title: t('tools.names.search'),
+ icon: ICON_SEARCH,
+ minimal: true,
+ input: z.object({
+ items: z.array(z.any()).optional(),
+ locations: z.array(z.any()).optional()
+ }).partial().loose()
+ },
+ 'edit': {
+ title: (opts: { metadata: Metadata | null, tool: ToolCall }) => {
+ // Gemini sends data in nested structure, try multiple locations
+ let filePath: string | undefined;
+
+ // 1. Check toolCall.content[0].path
+ if (opts.tool.input?.toolCall?.content?.[0]?.path) {
+ filePath = opts.tool.input.toolCall.content[0].path;
+ }
+ // 2. Check toolCall.title (has nice "Writing to ..." format)
+ else if (opts.tool.input?.toolCall?.title) {
+ return opts.tool.input.toolCall.title;
+ }
+ // 3. Check input[0].path (array format)
+ else if (Array.isArray(opts.tool.input?.input) && opts.tool.input.input[0]?.path) {
+ filePath = opts.tool.input.input[0].path;
+ }
+ // 4. Check direct path field
+ else if (typeof opts.tool.input?.path === 'string') {
+ filePath = opts.tool.input.path;
+ }
+
+ if (filePath) {
+ return resolvePath(filePath, opts.metadata);
+ }
+ return t('tools.names.editFile');
+ },
+ icon: ICON_EDIT,
+ isMutable: true,
+ input: z.object({
+ path: z.string().describe('The file path to edit'),
+ oldText: z.string().describe('The text to replace'),
+ newText: z.string().describe('The new text'),
+ type: z.string().optional().describe('Type of edit (diff)')
+ }).partial().loose()
+ },
+ 'shell': {
+ title: t('tools.names.terminal'),
+ icon: ICON_TERMINAL,
+ minimal: true,
+ isMutable: true,
+ input: z.object({}).partial().loose()
+ },
+ 'execute': {
+ title: (opts: { metadata: Metadata | null, tool: ToolCall }) => {
+ // Gemini sends nice title in toolCall.title
+ if (opts.tool.input?.toolCall?.title) {
+ // Title is like "rm file.txt [cwd /path] (description)"
+ // Extract just the command part before [
+ const fullTitle = opts.tool.input.toolCall.title;
+ const bracketIdx = fullTitle.indexOf(' [');
+ if (bracketIdx > 0) {
+ return fullTitle.substring(0, bracketIdx);
+ }
+ return fullTitle;
+ }
+ return t('tools.names.terminal');
+ },
+ icon: ICON_TERMINAL,
+ isMutable: true,
+ input: z.object({}).partial().loose(),
+ extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => {
+ // Extract description from parentheses at the end
+ if (opts.tool.input?.toolCall?.title) {
+ const title = opts.tool.input.toolCall.title;
+ const parenMatch = title.match(/\(([^)]+)\)$/);
+ if (parenMatch) {
+ return parenMatch[1];
+ }
+ }
+ return null;
+ }
+ },
'CodexPatch': {
title: t('tools.names.applyChanges'),
icon: ICON_EDIT,
@@ -564,6 +738,83 @@ export const knownTools = {
return t('tools.names.applyChanges');
}
},
+ 'GeminiBash': {
+ title: t('tools.names.terminal'),
+ icon: ICON_TERMINAL,
+ minimal: true,
+ hideDefaultError: true,
+ isMutable: true,
+ input: z.object({
+ command: z.array(z.string()).describe('The command array to execute'),
+ cwd: z.string().optional().describe('Current working directory')
+ }).partial().loose(),
+ extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => {
+ if (opts.tool.input?.command && Array.isArray(opts.tool.input.command)) {
+ let cmdArray = opts.tool.input.command;
+ // Remove shell wrapper prefix if present (bash/zsh with -lc flag)
+ if (cmdArray.length >= 3 && (cmdArray[0] === 'bash' || cmdArray[0] === '/bin/bash' || cmdArray[0] === 'zsh' || cmdArray[0] === '/bin/zsh') && cmdArray[1] === '-lc') {
+ return cmdArray[2];
+ }
+ return cmdArray.join(' ');
+ }
+ return null;
+ }
+ },
+ 'GeminiPatch': {
+ title: t('tools.names.applyChanges'),
+ icon: ICON_EDIT,
+ minimal: true,
+ hideDefaultError: true,
+ isMutable: true,
+ input: z.object({
+ auto_approved: z.boolean().optional().describe('Whether changes were auto-approved'),
+ changes: z.record(z.string(), z.object({
+ add: z.object({
+ content: z.string()
+ }).optional(),
+ modify: z.object({
+ old_content: z.string(),
+ new_content: z.string()
+ }).optional(),
+ delete: z.object({
+ content: z.string()
+ }).optional()
+ }).loose()).describe('File changes to apply')
+ }).partial().loose(),
+ extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => {
+ // Show the first file being modified
+ if (opts.tool.input?.changes && typeof opts.tool.input.changes === 'object') {
+ const files = Object.keys(opts.tool.input.changes);
+ if (files.length > 0) {
+ const path = resolvePath(files[0], opts.metadata);
+ const fileName = path.split('/').pop() || path;
+ if (files.length > 1) {
+ return t('tools.desc.modifyingMultipleFiles', {
+ file: fileName,
+ count: files.length - 1
+ });
+ }
+ return fileName;
+ }
+ }
+ return null;
+ },
+ extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => {
+ // Show the number of files being modified
+ if (opts.tool.input?.changes && typeof opts.tool.input.changes === 'object') {
+ const files = Object.keys(opts.tool.input.changes);
+ const fileCount = files.length;
+ if (fileCount === 1) {
+ const path = resolvePath(files[0], opts.metadata);
+ const fileName = path.split('/').pop() || path;
+ return t('tools.desc.modifyingFile', { file: fileName });
+ } else if (fileCount > 1) {
+ return t('tools.desc.modifyingFiles', { count: fileCount });
+ }
+ }
+ return t('tools.names.applyChanges');
+ }
+ },
'CodexDiff': {
title: t('tools.names.viewDiff'),
icon: ICON_EDIT,
@@ -594,6 +845,43 @@ export const knownTools = {
return t('tools.desc.showingDiff');
}
},
+ 'GeminiDiff': {
+ title: t('tools.names.viewDiff'),
+ icon: ICON_EDIT,
+ minimal: false, // Show full diff view
+ hideDefaultError: true,
+ noStatus: true, // Always successful, stateless like Task
+ input: z.object({
+ unified_diff: z.string().optional().describe('Unified diff content'),
+ filePath: z.string().optional().describe('File path'),
+ description: z.string().optional().describe('Edit description')
+ }).partial().loose(),
+ result: z.object({
+ status: z.literal('completed').describe('Always completed')
+ }).partial().loose(),
+ extractSubtitle: (opts: { metadata: Metadata | null, tool: ToolCall }) => {
+ // Try to extract filename from filePath first
+ if (opts.tool.input?.filePath && typeof opts.tool.input.filePath === 'string') {
+ const basename = opts.tool.input.filePath.split('/').pop() || opts.tool.input.filePath;
+ return basename;
+ }
+ // Fall back to extracting from unified diff
+ if (opts.tool.input?.unified_diff && typeof opts.tool.input.unified_diff === 'string') {
+ const diffLines = opts.tool.input.unified_diff.split('\n');
+ for (const line of diffLines) {
+ if (line.startsWith('+++ b/') || line.startsWith('+++ ')) {
+ const fileName = line.replace(/^\+\+\+ (b\/)?/, '');
+ const basename = fileName.split('/').pop() || fileName;
+ return basename;
+ }
+ }
+ }
+ return null;
+ },
+ extractDescription: (opts: { metadata: Metadata | null, tool: ToolCall }) => {
+ return t('tools.desc.showingDiff');
+ }
+ },
'AskUserQuestion': {
title: (opts: { metadata: Metadata | null, tool: ToolCall }) => {
// Use first question header as title if available
diff --git a/sources/components/tools/views/GeminiEditView.tsx b/sources/components/tools/views/GeminiEditView.tsx
new file mode 100644
index 000000000..9a4255634
--- /dev/null
+++ b/sources/components/tools/views/GeminiEditView.tsx
@@ -0,0 +1,75 @@
+import * as React from 'react';
+import { ToolSectionView } from '../../tools/ToolSectionView';
+import { ToolViewProps } from './_all';
+import { ToolDiffView } from '@/components/tools/ToolDiffView';
+import { trimIdent } from '@/utils/trimIdent';
+import { useSetting } from '@/sync/storage';
+
+/**
+ * Extract edit content from Gemini's nested input format.
+ *
+ * Gemini sends data in nested structure:
+ * - tool.input.toolCall.content[0]
+ * - tool.input.input[0]
+ * - tool.input (direct fields)
+ */
+function extractEditContent(input: any): { oldText: string; newText: string; path: string } {
+ // Try various locations where Gemini might put the edit data
+
+ // 1. Check tool.input.toolCall.content[0]
+ if (input?.toolCall?.content?.[0]) {
+ const content = input.toolCall.content[0];
+ return {
+ oldText: content.oldText || '',
+ newText: content.newText || '',
+ path: content.path || ''
+ };
+ }
+
+ // 2. Check tool.input.input[0] (array format)
+ if (Array.isArray(input?.input) && input.input[0]) {
+ const content = input.input[0];
+ return {
+ oldText: content.oldText || '',
+ newText: content.newText || '',
+ path: content.path || ''
+ };
+ }
+
+ // 3. Check direct fields (simple format)
+ return {
+ oldText: input?.oldText || input?.old_string || '',
+ newText: input?.newText || input?.new_string || '',
+ path: input?.path || input?.file_path || ''
+ };
+}
+
+/**
+ * Gemini Edit View
+ *
+ * Handles Gemini's edit tool format which uses:
+ * - oldText (instead of old_string)
+ * - newText (instead of new_string)
+ * - path (instead of file_path)
+ */
+export const GeminiEditView = React.memo(({ tool }) => {
+ const showLineNumbersInToolViews = useSetting('showLineNumbersInToolViews');
+
+ const { oldText, newText } = extractEditContent(tool.input);
+ const oldString = trimIdent(oldText);
+ const newString = trimIdent(newText);
+
+ return (
+ <>
+
+
+
+ >
+ );
+});
+
diff --git a/sources/components/tools/views/GeminiExecuteView.tsx b/sources/components/tools/views/GeminiExecuteView.tsx
new file mode 100644
index 000000000..86fe20e84
--- /dev/null
+++ b/sources/components/tools/views/GeminiExecuteView.tsx
@@ -0,0 +1,92 @@
+import * as React from 'react';
+import { View, Text } from 'react-native';
+import { StyleSheet } from 'react-native-unistyles';
+import { ToolSectionView } from '../../tools/ToolSectionView';
+import { ToolViewProps } from './_all';
+import { CodeView } from '@/components/CodeView';
+
+/**
+ * Extract execute command info from Gemini's nested input format.
+ */
+function extractExecuteInfo(input: any): { command: string; description: string; cwd: string } {
+ let command = '';
+ let description = '';
+ let cwd = '';
+
+ // Try to get title from toolCall.title
+ // Format: "rm file.txt [current working directory /path] (description)"
+ if (input?.toolCall?.title) {
+ const fullTitle = input.toolCall.title;
+
+ // Extract command (before [)
+ const bracketIdx = fullTitle.indexOf(' [');
+ if (bracketIdx > 0) {
+ command = fullTitle.substring(0, bracketIdx);
+ } else {
+ command = fullTitle;
+ }
+
+ // Extract cwd from [current working directory /path]
+ const cwdMatch = fullTitle.match(/\[current working directory ([^\]]+)\]/);
+ if (cwdMatch) {
+ cwd = cwdMatch[1];
+ }
+
+ // Extract description from (...)
+ const descMatch = fullTitle.match(/\(([^)]+)\)$/);
+ if (descMatch) {
+ description = descMatch[1];
+ }
+ }
+
+ return { command, description, cwd };
+}
+
+/**
+ * Gemini Execute View
+ *
+ * Displays shell/terminal commands from Gemini's execute tool.
+ */
+export const GeminiExecuteView = React.memo(({ tool }) => {
+ const { command, description, cwd } = extractExecuteInfo(tool.input);
+
+ if (!command) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+
+ {(description || cwd) && (
+
+ {cwd && (
+ 📁 {cwd}
+ )}
+ {description && (
+ {description}
+ )}
+
+ )}
+ >
+ );
+});
+
+const styles = StyleSheet.create((theme) => ({
+ infoContainer: {
+ paddingHorizontal: 12,
+ paddingBottom: 8,
+ },
+ cwdText: {
+ fontSize: 12,
+ color: theme.colors.textSecondary,
+ marginBottom: 4,
+ },
+ descriptionText: {
+ fontSize: 13,
+ color: theme.colors.textSecondary,
+ fontStyle: 'italic',
+ },
+}));
+
diff --git a/sources/components/tools/views/_all.tsx b/sources/components/tools/views/_all.tsx
index 64190c0d1..fe087a377 100644
--- a/sources/components/tools/views/_all.tsx
+++ b/sources/components/tools/views/_all.tsx
@@ -15,6 +15,8 @@ import { CodexBashView } from './CodexBashView';
import { CodexPatchView } from './CodexPatchView';
import { CodexDiffView } from './CodexDiffView';
import { AskUserQuestionView } from './AskUserQuestionView';
+import { GeminiEditView } from './GeminiEditView';
+import { GeminiExecuteView } from './GeminiExecuteView';
export type ToolViewProps = {
tool: ToolCall;
@@ -39,7 +41,10 @@ export const toolViewRegistry: Record = {
exit_plan_mode: ExitPlanToolView,
MultiEdit: MultiEditView,
Task: TaskView,
- AskUserQuestion: AskUserQuestionView
+ AskUserQuestion: AskUserQuestionView,
+ // Gemini tools (lowercase)
+ edit: GeminiEditView,
+ execute: GeminiExecuteView,
};
export const toolFullViewRegistry: Record = {
@@ -71,3 +76,5 @@ export { ExitPlanToolView } from './ExitPlanToolView';
export { MultiEditView } from './MultiEditView';
export { TaskView } from './TaskView';
export { AskUserQuestionView } from './AskUserQuestionView';
+export { GeminiEditView } from './GeminiEditView';
+export { GeminiExecuteView } from './GeminiExecuteView';
diff --git a/sources/hooks/envVarUtils.ts b/sources/hooks/envVarUtils.ts
index 325404655..e839a6b10 100644
--- a/sources/hooks/envVarUtils.ts
+++ b/sources/hooks/envVarUtils.ts
@@ -32,23 +32,27 @@ export function resolveEnvVarSubstitution(
value: string,
daemonEnv: EnvironmentVariables
): string | null {
- // Match ${VAR} or ${VAR:-default} or ${VAR:=default} (bash parameter expansion)
+ // Match ${VAR} or ${VAR:-default} (bash parameter expansion subset).
// Group 1: Variable name (required)
- // Group 2: Default value (optional) - includes the :- or := prefix
- // Group 3: The actual default value without prefix (optional)
- const match = value.match(/^\$\{([A-Z_][A-Z0-9_]*)(:-(.*))?(:=(.*))?}$/);
+ // Group 2: Default value (optional)
+ const match = value.match(/^\$\{([A-Z_][A-Z0-9_]*)(?::[-=](.*))?\}$/);
if (match) {
const varName = match[1];
- const defaultValue = match[3] ?? match[5]; // :- default or := default
+ const defaultValue = match[2]; // :- default
const daemonValue = daemonEnv[varName];
- if (daemonValue !== undefined && daemonValue !== null) {
+ // For ${VAR:-default} and ${VAR:=default}, treat empty string as "missing" (bash semantics).
+ // For plain ${VAR}, preserve empty string (it is an explicit value).
+ if (daemonValue !== undefined && daemonValue !== null && daemonValue !== '') {
return daemonValue;
}
// Variable not set - use default if provided
if (defaultValue !== undefined) {
return defaultValue;
}
+ if (daemonValue === '') {
+ return '';
+ }
return null;
}
// Not a substitution - return literal value
@@ -76,9 +80,9 @@ export function extractEnvVarReferences(
const refs = new Set();
environmentVariables.forEach(ev => {
- // Match ${VAR} or ${VAR:-default} or ${VAR:=default} (bash parameter expansion)
+ // Match ${VAR}, ${VAR:-default}, or ${VAR:=default} (bash parameter expansion subset).
// Only capture the variable name, not the default value
- const match = ev.value.match(/^\$\{([A-Z_][A-Z0-9_]*)(:-.*|:=.*)?\}$/);
+ const match = ev.value.match(/^\$\{([A-Z_][A-Z0-9_]*)(?::[-=].*)?\}$/);
if (match) {
// Variable name is already validated by regex pattern [A-Z_][A-Z0-9_]*
refs.add(match[1]);
diff --git a/sources/hooks/useCLIDetection.ts b/sources/hooks/useCLIDetection.ts
index bda5c547b..97f8f1cb0 100644
--- a/sources/hooks/useCLIDetection.ts
+++ b/sources/hooks/useCLIDetection.ts
@@ -52,7 +52,9 @@ export function useCLIDetection(machineId: string | null): CLIAvailability {
const detectCLIs = async () => {
// Set detecting flag (non-blocking - UI stays responsive)
setAvailability(prev => ({ ...prev, isDetecting: true }));
- console.log('[useCLIDetection] Starting detection for machineId:', machineId);
+ if (__DEV__) {
+ console.log('[useCLIDetection] Starting detection for machineId:', machineId);
+ }
try {
// Use single bash command to check both CLIs efficiently
@@ -66,7 +68,9 @@ export function useCLIDetection(machineId: string | null): CLIAvailability {
);
if (cancelled) return;
- console.log('[useCLIDetection] Result:', { success: result.success, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr });
+ if (__DEV__) {
+ console.log('[useCLIDetection] Result:', { success: result.success, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr });
+ }
if (result.success && result.exitCode === 0) {
// Parse output: "claude:true\ncodex:false\ngemini:false"
@@ -80,7 +84,9 @@ export function useCLIDetection(machineId: string | null): CLIAvailability {
}
});
- console.log('[useCLIDetection] Parsed CLI status:', cliStatus);
+ if (__DEV__) {
+ console.log('[useCLIDetection] Parsed CLI status:', cliStatus);
+ }
setAvailability({
claude: cliStatus.claude ?? null,
codex: cliStatus.codex ?? null,
@@ -90,7 +96,9 @@ export function useCLIDetection(machineId: string | null): CLIAvailability {
});
} else {
// Detection command failed - CONSERVATIVE fallback (don't assume availability)
- console.log('[useCLIDetection] Detection failed (success=false or exitCode!=0):', result);
+ if (__DEV__) {
+ console.log('[useCLIDetection] Detection failed (success=false or exitCode!=0):', result);
+ }
setAvailability({
claude: null,
codex: null,
@@ -104,7 +112,9 @@ export function useCLIDetection(machineId: string | null): CLIAvailability {
if (cancelled) return;
// Network/RPC error - CONSERVATIVE fallback (don't assume availability)
- console.log('[useCLIDetection] Network/RPC error:', error);
+ if (__DEV__) {
+ console.log('[useCLIDetection] Network/RPC error:', error);
+ }
setAvailability({
claude: null,
codex: null,
diff --git a/sources/hooks/useEnvironmentVariables.test.ts b/sources/hooks/useEnvironmentVariables.test.ts
index e1bae6d24..45a978263 100644
--- a/sources/hooks/useEnvironmentVariables.test.ts
+++ b/sources/hooks/useEnvironmentVariables.test.ts
@@ -89,6 +89,10 @@ describe('resolveEnvVarSubstitution', () => {
expect(resolveEnvVarSubstitution('${VAR:-fallback}', envWithNull)).toBe('fallback');
});
+ it('returns default when VAR is empty string in ${VAR:-default}', () => {
+ expect(resolveEnvVarSubstitution('${EMPTY:-fallback}', daemonEnv)).toBe('fallback');
+ });
+
it('returns literal for non-substitution values', () => {
expect(resolveEnvVarSubstitution('literal-value', daemonEnv)).toBe('literal-value');
});
diff --git a/sources/hooks/useEnvironmentVariables.ts b/sources/hooks/useEnvironmentVariables.ts
index 568bb0583..a012c46af 100644
--- a/sources/hooks/useEnvironmentVariables.ts
+++ b/sources/hooks/useEnvironmentVariables.ts
@@ -69,12 +69,37 @@ export function useEnvironmentVariables(
return;
}
- // Build batched command: query all variables in single bash invocation
- // Format: echo "VAR1=$VAR1" && echo "VAR2=$VAR2" && ...
- // Using echo with variable expansion ensures we get daemon's environment
- const command = validVarNames
- .map(name => `echo "${name}=$${name}"`)
- .join(' && ');
+ // Query variables in a single machineBash() call.
+ //
+ // IMPORTANT: This runs inside the daemon process environment on the machine, because the
+ // RPC handler executes commands using Node's `exec()` without overriding `env`.
+ // That means this matches what `${VAR}` expansion uses when spawning sessions on the daemon
+ // (see happy-cli: expandEnvironmentVariables(..., process.env)).
+ // Prefer a JSON protocol (via `node`) to preserve newlines and distinguish unset vs empty.
+ // Fallback to bash-only output if node isn't available.
+ const nodeScript = [
+ // node -e sets argv[1] to "-e", so args start at argv[2]
+ "const keys = process.argv.slice(2);",
+ "const out = {};",
+ "for (const k of keys) {",
+ " out[k] = Object.prototype.hasOwnProperty.call(process.env, k) ? process.env[k] : null;",
+ "}",
+ "process.stdout.write(JSON.stringify(out));",
+ ].join("");
+ const jsonCommand = `node -e '${nodeScript.replace(/'/g, "'\\''")}' ${validVarNames.join(' ')}`;
+ // Shell fallback uses `printenv` to distinguish unset vs empty via exit code.
+ // Note: values containing newlines may not round-trip here; the node/JSON path preserves them.
+ const shellFallback = [
+ `for name in ${validVarNames.join(' ')}; do`,
+ `if printenv "$name" >/dev/null 2>&1; then`,
+ `printf "%s=%s\\n" "$name" "$(printenv "$name")";`,
+ `else`,
+ `printf "%s=__HAPPY_UNSET__\\n" "$name";`,
+ `fi;`,
+ `done`,
+ ].join(' ');
+
+ const command = `if command -v node >/dev/null 2>&1; then ${jsonCommand}; else ${shellFallback}; fi`;
try {
const result = await machineBash(machineId, command, '/');
@@ -82,16 +107,40 @@ export function useEnvironmentVariables(
if (cancelled) return;
if (result.success && result.exitCode === 0) {
- // Parse output: "VAR1=value1\nVAR2=value2\nVAR3="
- const lines = result.stdout.trim().split('\n');
- lines.forEach(line => {
- const equalsIndex = line.indexOf('=');
- if (equalsIndex !== -1) {
- const name = line.substring(0, equalsIndex);
- const value = line.substring(equalsIndex + 1);
- results[name] = value || null; // Empty string → null (not set)
+ const stdout = result.stdout;
+
+ // JSON protocol: {"VAR":"value","MISSING":null}
+ // Be resilient to any stray output (log lines, warnings) by extracting the last JSON object.
+ const trimmed = stdout.trim();
+ const firstBrace = trimmed.indexOf('{');
+ const lastBrace = trimmed.lastIndexOf('}');
+ if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
+ const jsonSlice = trimmed.slice(firstBrace, lastBrace + 1);
+ try {
+ const parsed = JSON.parse(jsonSlice) as Record;
+ validVarNames.forEach((name) => {
+ results[name] = Object.prototype.hasOwnProperty.call(parsed, name) ? parsed[name] : null;
+ });
+ } catch {
+ // Fall through to line parser if JSON is malformed.
}
- });
+ }
+
+ // Fallback line parser: "VAR=value" or "VAR=__HAPPY_UNSET__"
+ if (Object.keys(results).length === 0) {
+ // Do not trim each line: it can corrupt values with meaningful whitespace.
+ const lines = stdout.split(/\r?\n/).filter((l) => l.length > 0);
+ lines.forEach((line) => {
+ // Ignore unrelated output (warnings, prompts, etc).
+ if (!/^[A-Z_][A-Z0-9_]*=/.test(line)) return;
+ const equalsIndex = line.indexOf('=');
+ if (equalsIndex !== -1) {
+ const name = line.substring(0, equalsIndex);
+ const value = line.substring(equalsIndex + 1);
+ results[name] = value === '__HAPPY_UNSET__' ? null : value;
+ }
+ });
+ }
// Ensure all requested variables have entries (even if missing from output)
validVarNames.forEach(name => {
diff --git a/sources/modal/components/BaseModal.tsx b/sources/modal/components/BaseModal.tsx
index 48ff2ab08..8ff4ab56f 100644
--- a/sources/modal/components/BaseModal.tsx
+++ b/sources/modal/components/BaseModal.tsx
@@ -9,6 +9,13 @@ import {
Platform
} from 'react-native';
+// On web, stop events from propagating to expo-router's modal overlay
+// which intercepts clicks when it applies pointer-events: none to body
+const stopPropagation = (e: { stopPropagation: () => void }) => e.stopPropagation();
+const webEventHandlers = Platform.OS === 'web'
+ ? { onClick: stopPropagation, onPointerDown: stopPropagation, onTouchStart: stopPropagation }
+ : {};
+
interface BaseModalProps {
visible: boolean;
onClose?: () => void;
@@ -57,9 +64,10 @@ export function BaseModal({
animationType={animationType}
onRequestClose={onClose}
>
-
{
+ it('does not serialize full profile JSON into profile-edit URL params', () => {
+ const file = join(process.cwd(), 'sources/app/(app)/new/pick/profile.tsx');
+ const content = readFileSync(file, 'utf8');
+
+ expect(content).not.toContain('profileData=');
+ expect(content).not.toContain('encodeURIComponent(profileData)');
+ expect(content).not.toContain('JSON.stringify(profile)');
+ });
+
+ it('consumes returned profileId param from profile-edit to auto-select and close', () => {
+ const file = join(process.cwd(), 'sources/app/(app)/new/pick/profile.tsx');
+ const content = readFileSync(file, 'utf8');
+
+ // When profile-edit navigates back, it returns selection via navigation params.
+ // The picker must read that param and forward it back to /new.
+ expect(content).toMatch(/profileId\?:/);
+ expect(content).toContain('setProfileParamAndClose');
+ expect(content).toContain('params.profileId');
+ });
+});
diff --git a/sources/realtime/RealtimeVoiceSession.tsx b/sources/realtime/RealtimeVoiceSession.tsx
index da558e1ec..71445ca04 100644
--- a/sources/realtime/RealtimeVoiceSession.tsx
+++ b/sources/realtime/RealtimeVoiceSession.tsx
@@ -9,6 +9,11 @@ import type { VoiceSession, VoiceSessionConfig } from './types';
// Static reference to the conversation hook instance
let conversationInstance: ReturnType | null = null;
+function debugLog(...args: unknown[]) {
+ if (!__DEV__) return;
+ console.debug(...args);
+}
+
// Global voice session implementation
class RealtimeVoiceSessionImpl implements VoiceSession {
@@ -93,18 +98,18 @@ export const RealtimeVoiceSession: React.FC = () => {
const conversation = useConversation({
clientTools: realtimeClientTools,
onConnect: (data) => {
- console.log('Realtime session connected:', data);
+ debugLog('Realtime session connected');
storage.getState().setRealtimeStatus('connected');
storage.getState().setRealtimeMode('idle');
},
onDisconnect: () => {
- console.log('Realtime session disconnected');
+ debugLog('Realtime session disconnected');
storage.getState().setRealtimeStatus('disconnected');
storage.getState().setRealtimeMode('idle', true); // immediate mode change
storage.getState().clearRealtimeModeDebounce();
},
onMessage: (data) => {
- console.log('Realtime message:', data);
+ debugLog('Realtime message received');
},
onError: (error) => {
// Log but don't block app - voice features will be unavailable
@@ -116,10 +121,10 @@ export const RealtimeVoiceSession: React.FC = () => {
storage.getState().setRealtimeMode('idle', true); // immediate mode change
},
onStatusChange: (data) => {
- console.log('Realtime status change:', data);
+ debugLog('Realtime status change');
},
onModeChange: (data) => {
- console.log('Realtime mode change:', data);
+ debugLog('Realtime mode change');
// Only animate when speaking
const mode = data.mode as string;
@@ -129,7 +134,7 @@ export const RealtimeVoiceSession: React.FC = () => {
storage.getState().setRealtimeMode(isSpeaking ? 'speaking' : 'idle');
},
onDebug: (message) => {
- console.debug('Realtime debug:', message);
+ debugLog('Realtime debug:', message);
}
});
@@ -157,4 +162,4 @@ export const RealtimeVoiceSession: React.FC = () => {
// This component doesn't render anything visible
return null;
-};
\ No newline at end of file
+};
diff --git a/sources/realtime/RealtimeVoiceSession.web.tsx b/sources/realtime/RealtimeVoiceSession.web.tsx
index 54edb4672..1aa82a06d 100644
--- a/sources/realtime/RealtimeVoiceSession.web.tsx
+++ b/sources/realtime/RealtimeVoiceSession.web.tsx
@@ -9,11 +9,16 @@ import type { VoiceSession, VoiceSessionConfig } from './types';
// Static reference to the conversation hook instance
let conversationInstance: ReturnType | null = null;
+function debugLog(...args: unknown[]) {
+ if (!__DEV__) return;
+ console.debug(...args);
+}
+
// Global voice session implementation
class RealtimeVoiceSessionImpl implements VoiceSession {
async startSession(config: VoiceSessionConfig): Promise {
- console.log('[RealtimeVoiceSessionImpl] conversationInstance:', conversationInstance);
+ debugLog('[RealtimeVoiceSessionImpl] startSession');
if (!conversationInstance) {
console.warn('Realtime voice session not initialized - conversationInstance is null');
return;
@@ -55,7 +60,7 @@ class RealtimeVoiceSessionImpl implements VoiceSession {
const conversationId = await conversationInstance.startSession(sessionConfig);
- console.log('Started conversation with ID:', conversationId);
+ debugLog('Started conversation');
} catch (error) {
console.error('Failed to start realtime session:', error);
storage.getState().setRealtimeStatus('error');
@@ -98,18 +103,18 @@ export const RealtimeVoiceSession: React.FC = () => {
const conversation = useConversation({
clientTools: realtimeClientTools,
onConnect: () => {
- console.log('Realtime session connected');
+ debugLog('Realtime session connected');
storage.getState().setRealtimeStatus('connected');
storage.getState().setRealtimeMode('idle');
},
onDisconnect: () => {
- console.log('Realtime session disconnected');
+ debugLog('Realtime session disconnected');
storage.getState().setRealtimeStatus('disconnected');
storage.getState().setRealtimeMode('idle', true); // immediate mode change
storage.getState().clearRealtimeModeDebounce();
},
onMessage: (data) => {
- console.log('Realtime message:', data);
+ debugLog('Realtime message received');
},
onError: (error) => {
// Log but don't block app - voice features will be unavailable
@@ -121,10 +126,10 @@ export const RealtimeVoiceSession: React.FC = () => {
storage.getState().setRealtimeMode('idle', true); // immediate mode change
},
onStatusChange: (data) => {
- console.log('Realtime status change:', data);
+ debugLog('Realtime status change');
},
onModeChange: (data) => {
- console.log('Realtime mode change:', data);
+ debugLog('Realtime mode change');
// Only animate when speaking
const mode = data.mode as string;
@@ -134,7 +139,7 @@ export const RealtimeVoiceSession: React.FC = () => {
storage.getState().setRealtimeMode(isSpeaking ? 'speaking' : 'idle');
},
onDebug: (message) => {
- console.debug('Realtime debug:', message);
+ debugLog('Realtime debug:', message);
}
});
@@ -142,16 +147,16 @@ export const RealtimeVoiceSession: React.FC = () => {
useEffect(() => {
// Store the conversation instance globally
- console.log('[RealtimeVoiceSession] Setting conversationInstance:', conversation);
+ debugLog('[RealtimeVoiceSession] Setting conversationInstance');
conversationInstance = conversation;
// Register the voice session once
if (!hasRegistered.current) {
try {
- console.log('[RealtimeVoiceSession] Registering voice session');
+ debugLog('[RealtimeVoiceSession] Registering voice session');
registerVoiceSession(new RealtimeVoiceSessionImpl());
hasRegistered.current = true;
- console.log('[RealtimeVoiceSession] Voice session registered successfully');
+ debugLog('[RealtimeVoiceSession] Voice session registered successfully');
} catch (error) {
console.error('Failed to register voice session:', error);
}
@@ -165,4 +170,4 @@ export const RealtimeVoiceSession: React.FC = () => {
// This component doesn't render anything visible
return null;
-};
\ No newline at end of file
+};
diff --git a/sources/sync/messageMeta.test.ts b/sources/sync/messageMeta.test.ts
new file mode 100644
index 000000000..a7f11a90e
--- /dev/null
+++ b/sources/sync/messageMeta.test.ts
@@ -0,0 +1,30 @@
+import { describe, it, expect } from 'vitest';
+import { buildOutgoingMessageMeta } from './messageMeta';
+
+describe('buildOutgoingMessageMeta', () => {
+ it('does not include model fields by default', () => {
+ const meta = buildOutgoingMessageMeta({
+ sentFrom: 'web',
+ permissionMode: 'default',
+ appendSystemPrompt: 'PROMPT',
+ });
+
+ expect(meta.sentFrom).toBe('web');
+ expect(meta.permissionMode).toBe('default');
+ expect(meta.appendSystemPrompt).toBe('PROMPT');
+ expect('model' in meta).toBe(false);
+ expect('fallbackModel' in meta).toBe(false);
+ });
+
+ it('includes model when explicitly provided', () => {
+ const meta = buildOutgoingMessageMeta({
+ sentFrom: 'web',
+ permissionMode: 'default',
+ model: 'gemini-2.5-pro',
+ appendSystemPrompt: 'PROMPT',
+ });
+
+ expect(meta.model).toBe('gemini-2.5-pro');
+ expect('model' in meta).toBe(true);
+ });
+});
diff --git a/sources/sync/messageMeta.ts b/sources/sync/messageMeta.ts
new file mode 100644
index 000000000..ab3c0b61a
--- /dev/null
+++ b/sources/sync/messageMeta.ts
@@ -0,0 +1,19 @@
+import type { MessageMeta } from './typesMessageMeta';
+
+export function buildOutgoingMessageMeta(params: {
+ sentFrom: string;
+ permissionMode: NonNullable;
+ model?: MessageMeta['model'];
+ fallbackModel?: MessageMeta['fallbackModel'];
+ appendSystemPrompt: string;
+ displayText?: string;
+}): MessageMeta {
+ return {
+ sentFrom: params.sentFrom,
+ permissionMode: params.permissionMode,
+ appendSystemPrompt: params.appendSystemPrompt,
+ ...(params.displayText ? { displayText: params.displayText } : {}),
+ ...(params.model !== undefined ? { model: params.model } : {}),
+ ...(params.fallbackModel !== undefined ? { fallbackModel: params.fallbackModel } : {}),
+ };
+}
diff --git a/sources/sync/ops.ts b/sources/sync/ops.ts
index 07f70e694..510cfe104 100644
--- a/sources/sync/ops.ts
+++ b/sources/sync/ops.ts
@@ -139,6 +139,8 @@ export interface SpawnSessionOptions {
approvedNewDirectoryCreation?: boolean;
token?: string;
agent?: 'codex' | 'claude' | 'gemini';
+ // Session-scoped profile identity (non-secret). Empty string means "no profile".
+ profileId?: string;
// Environment variables from AI backend profile
// Accepts any environment variables - daemon will pass them to the agent process
// Common variables include:
@@ -159,7 +161,7 @@ export interface SpawnSessionOptions {
*/
export async function machineSpawnNewSession(options: SpawnSessionOptions): Promise {
- const { machineId, directory, approvedNewDirectoryCreation = false, token, agent, environmentVariables } = options;
+ const { machineId, directory, approvedNewDirectoryCreation = false, token, agent, environmentVariables, profileId } = options;
try {
const result = await apiSocket.machineRPC;
}>(
machineId,
'spawn-happy-session',
- { type: 'spawn-in-directory', directory, approvedNewDirectoryCreation, token, agent, environmentVariables }
+ { type: 'spawn-in-directory', directory, approvedNewDirectoryCreation, token, agent, profileId, environmentVariables }
);
return result;
} catch (error) {
diff --git a/sources/sync/permissionTypes.ts b/sources/sync/permissionTypes.ts
new file mode 100644
index 000000000..c42e082d9
--- /dev/null
+++ b/sources/sync/permissionTypes.ts
@@ -0,0 +1,24 @@
+export type PermissionMode =
+ | 'default'
+ | 'acceptEdits'
+ | 'bypassPermissions'
+ | 'plan'
+ | 'read-only'
+ | 'safe-yolo'
+ | 'yolo';
+
+export type ModelMode =
+ | 'default'
+ | 'adaptiveUsage'
+ | 'sonnet'
+ | 'opus'
+ | 'gpt-5-codex-high'
+ | 'gpt-5-codex-medium'
+ | 'gpt-5-codex-low'
+ | 'gpt-5-minimal'
+ | 'gpt-5-low'
+ | 'gpt-5-medium'
+ | 'gpt-5-high'
+ | 'gemini-2.5-pro'
+ | 'gemini-2.5-flash'
+ | 'gemini-2.5-flash-lite';
diff --git a/sources/sync/persistence.ts b/sources/sync/persistence.ts
index 2f9367523..4baef71a7 100644
--- a/sources/sync/persistence.ts
+++ b/sources/sync/persistence.ts
@@ -3,7 +3,7 @@ import { Settings, settingsDefaults, settingsParse, SettingsSchema } from './set
import { LocalSettings, localSettingsDefaults, localSettingsParse } from './localSettings';
import { Purchases, purchasesDefaults, purchasesParse } from './purchases';
import { Profile, profileDefaults, profileParse } from './profile';
-import type { PermissionMode } from '@/components/PermissionModeSelector';
+import type { PermissionMode, ModelMode } from '@/sync/permissionTypes';
const mmkv = new MMKV();
const NEW_SESSION_DRAFT_KEY = 'new-session-draft-v1';
@@ -15,8 +15,10 @@ export interface NewSessionDraft {
input: string;
selectedMachineId: string | null;
selectedPath: string | null;
+ selectedProfileId: string | null;
agentType: NewSessionAgentType;
permissionMode: PermissionMode;
+ modelMode: ModelMode;
sessionType: NewSessionSessionType;
updatedAt: number;
}
@@ -26,7 +28,8 @@ export function loadSettings(): { settings: Settings, version: number | null } {
if (settings) {
try {
const parsed = JSON.parse(settings);
- return { settings: settingsParse(parsed.settings), version: parsed.version };
+ const version = typeof parsed.version === 'number' ? parsed.version : null;
+ return { settings: settingsParse(parsed.settings), version };
} catch (e) {
console.error('Failed to parse settings', e);
return { settings: { ...settingsDefaults }, version: null };
@@ -139,12 +142,16 @@ export function loadNewSessionDraft(): NewSessionDraft | null {
const input = typeof parsed.input === 'string' ? parsed.input : '';
const selectedMachineId = typeof parsed.selectedMachineId === 'string' ? parsed.selectedMachineId : null;
const selectedPath = typeof parsed.selectedPath === 'string' ? parsed.selectedPath : null;
+ const selectedProfileId = typeof parsed.selectedProfileId === 'string' ? parsed.selectedProfileId : null;
const agentType: NewSessionAgentType = parsed.agentType === 'codex' || parsed.agentType === 'gemini'
? parsed.agentType
: 'claude';
const permissionMode: PermissionMode = typeof parsed.permissionMode === 'string'
? (parsed.permissionMode as PermissionMode)
: 'default';
+ const modelMode: ModelMode = typeof parsed.modelMode === 'string'
+ ? (parsed.modelMode as ModelMode)
+ : 'default';
const sessionType: NewSessionSessionType = parsed.sessionType === 'worktree' ? 'worktree' : 'simple';
const updatedAt = typeof parsed.updatedAt === 'number' ? parsed.updatedAt : Date.now();
@@ -152,8 +159,10 @@ export function loadNewSessionDraft(): NewSessionDraft | null {
input,
selectedMachineId,
selectedPath,
+ selectedProfileId,
agentType,
permissionMode,
+ modelMode,
sessionType,
updatedAt,
};
@@ -225,4 +234,4 @@ export function retrieveTempText(id: string): string | null {
export function clearPersistence() {
mmkv.clearAll();
-}
\ No newline at end of file
+}
diff --git a/sources/sync/profileGrouping.ts b/sources/sync/profileGrouping.ts
new file mode 100644
index 000000000..59b0eb551
--- /dev/null
+++ b/sources/sync/profileGrouping.ts
@@ -0,0 +1,46 @@
+import { AIBackendProfile } from '@/sync/settings';
+import { DEFAULT_PROFILES, getBuiltInProfile } from '@/sync/profileUtils';
+
+export interface ProfileGroups {
+ favoriteProfiles: AIBackendProfile[];
+ customProfiles: AIBackendProfile[];
+ builtInProfiles: AIBackendProfile[];
+ favoriteIds: Set;
+ builtInIds: Set;
+}
+
+function isProfile(profile: AIBackendProfile | null | undefined): profile is AIBackendProfile {
+ return Boolean(profile);
+}
+
+export function buildProfileGroups({
+ customProfiles,
+ favoriteProfileIds,
+}: {
+ customProfiles: AIBackendProfile[];
+ favoriteProfileIds: string[];
+}): ProfileGroups {
+ const builtInIds = new Set(DEFAULT_PROFILES.map((profile) => profile.id));
+ const favoriteIds = new Set(favoriteProfileIds);
+
+ const customById = new Map(customProfiles.map((profile) => [profile.id, profile] as const));
+
+ const favoriteProfiles = favoriteProfileIds
+ .map((id) => customById.get(id) ?? getBuiltInProfile(id))
+ .filter(isProfile);
+
+ const nonFavoriteCustomProfiles = customProfiles.filter((profile) => !favoriteIds.has(profile.id));
+
+ const nonFavoriteBuiltInProfiles = DEFAULT_PROFILES
+ .map((profile) => getBuiltInProfile(profile.id))
+ .filter(isProfile)
+ .filter((profile) => !favoriteIds.has(profile.id));
+
+ return {
+ favoriteProfiles,
+ customProfiles: nonFavoriteCustomProfiles,
+ builtInProfiles: nonFavoriteBuiltInProfiles,
+ favoriteIds,
+ builtInIds,
+ };
+}
diff --git a/sources/sync/profileMutations.ts b/sources/sync/profileMutations.ts
new file mode 100644
index 000000000..2f7ab69be
--- /dev/null
+++ b/sources/sync/profileMutations.ts
@@ -0,0 +1,36 @@
+import { randomUUID } from 'expo-crypto';
+import { AIBackendProfile } from '@/sync/settings';
+
+export function createEmptyCustomProfile(): AIBackendProfile {
+ return {
+ id: randomUUID(),
+ name: '',
+ environmentVariables: [],
+ compatibility: { claude: true, codex: true, gemini: true },
+ isBuiltIn: false,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ version: '1.0.0',
+ };
+}
+
+export function duplicateProfileForEdit(profile: AIBackendProfile): AIBackendProfile {
+ return {
+ ...profile,
+ id: randomUUID(),
+ name: `${profile.name} (Copy)`,
+ isBuiltIn: false,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+}
+
+export function convertBuiltInProfileToCustom(profile: AIBackendProfile): AIBackendProfile {
+ return {
+ ...profile,
+ id: randomUUID(),
+ isBuiltIn: false,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ };
+}
diff --git a/sources/sync/profileSync.ts b/sources/sync/profileSync.ts
deleted file mode 100644
index 694ea1410..000000000
--- a/sources/sync/profileSync.ts
+++ /dev/null
@@ -1,453 +0,0 @@
-/**
- * Profile Synchronization Service
- *
- * Handles bidirectional synchronization of profiles between GUI and CLI storage.
- * Ensures consistent profile data across both systems with proper conflict resolution.
- */
-
-import { AIBackendProfile, validateProfileForAgent, getProfileEnvironmentVariables } from './settings';
-import { sync } from './sync';
-import { storage } from './storage';
-import { apiSocket } from './apiSocket';
-import { Modal } from '@/modal';
-
-// Profile sync status types
-export type SyncStatus = 'idle' | 'syncing' | 'success' | 'error';
-export type SyncDirection = 'gui-to-cli' | 'cli-to-gui' | 'bidirectional';
-
-// Profile sync conflict resolution strategies
-export type ConflictResolution = 'gui-wins' | 'cli-wins' | 'most-recent' | 'merge';
-
-// Profile sync event data
-export interface ProfileSyncEvent {
- direction: SyncDirection;
- status: SyncStatus;
- profilesSynced?: number;
- error?: string;
- timestamp: number;
- message?: string;
- warning?: string;
-}
-
-// Profile sync configuration
-export interface ProfileSyncConfig {
- autoSync: boolean;
- conflictResolution: ConflictResolution;
- syncOnProfileChange: boolean;
- syncOnAppStart: boolean;
-}
-
-// Default sync configuration
-const DEFAULT_SYNC_CONFIG: ProfileSyncConfig = {
- autoSync: true,
- conflictResolution: 'most-recent',
- syncOnProfileChange: true,
- syncOnAppStart: true,
-};
-
-class ProfileSyncService {
- private static instance: ProfileSyncService;
- private syncStatus: SyncStatus = 'idle';
- private lastSyncTime: number = 0;
- private config: ProfileSyncConfig = DEFAULT_SYNC_CONFIG;
- private eventListeners: Array<(event: ProfileSyncEvent) => void> = [];
-
- private constructor() {
- // Private constructor for singleton
- }
-
- public static getInstance(): ProfileSyncService {
- if (!ProfileSyncService.instance) {
- ProfileSyncService.instance = new ProfileSyncService();
- }
- return ProfileSyncService.instance;
- }
-
- /**
- * Add event listener for sync events
- */
- public addEventListener(listener: (event: ProfileSyncEvent) => void): void {
- this.eventListeners.push(listener);
- }
-
- /**
- * Remove event listener
- */
- public removeEventListener(listener: (event: ProfileSyncEvent) => void): void {
- const index = this.eventListeners.indexOf(listener);
- if (index > -1) {
- this.eventListeners.splice(index, 1);
- }
- }
-
- /**
- * Emit sync event to all listeners
- */
- private emitEvent(event: ProfileSyncEvent): void {
- this.eventListeners.forEach(listener => {
- try {
- listener(event);
- } catch (error) {
- console.error('[ProfileSync] Event listener error:', error);
- }
- });
- }
-
- /**
- * Update sync configuration
- */
- public updateConfig(config: Partial): void {
- this.config = { ...this.config, ...config };
- }
-
- /**
- * Get current sync configuration
- */
- public getConfig(): ProfileSyncConfig {
- return { ...this.config };
- }
-
- /**
- * Get current sync status
- */
- public getSyncStatus(): SyncStatus {
- return this.syncStatus;
- }
-
- /**
- * Get last sync time
- */
- public getLastSyncTime(): number {
- return this.lastSyncTime;
- }
-
- /**
- * Sync profiles from GUI to CLI using proper Happy infrastructure
- * SECURITY NOTE: Direct file access is PROHIBITED - use Happy RPC infrastructure
- */
- public async syncGuiToCli(profiles: AIBackendProfile[]): Promise {
- if (this.syncStatus === 'syncing') {
- throw new Error('Sync already in progress');
- }
-
- this.syncStatus = 'syncing';
- this.emitEvent({
- direction: 'gui-to-cli',
- status: 'syncing',
- timestamp: Date.now(),
- });
-
- try {
- // Profiles are stored in GUI settings and available through existing Happy sync system
- // CLI daemon reads profiles from GUI settings via existing channels
- // TODO: Implement machine RPC endpoints for profile management in CLI daemon
- console.log(`[ProfileSync] GUI profiles stored in Happy settings. CLI access via existing infrastructure.`);
-
- this.lastSyncTime = Date.now();
- this.syncStatus = 'success';
-
- this.emitEvent({
- direction: 'gui-to-cli',
- status: 'success',
- profilesSynced: profiles.length,
- timestamp: Date.now(),
- message: 'Profiles available through Happy settings system'
- });
- } catch (error) {
- this.syncStatus = 'error';
- const errorMessage = error instanceof Error ? error.message : 'Unknown sync error';
-
- this.emitEvent({
- direction: 'gui-to-cli',
- status: 'error',
- error: errorMessage,
- timestamp: Date.now(),
- });
-
- throw error;
- }
- }
-
- /**
- * Sync profiles from CLI to GUI using proper Happy infrastructure
- * SECURITY NOTE: Direct file access is PROHIBITED - use Happy RPC infrastructure
- */
- public async syncCliToGui(): Promise {
- if (this.syncStatus === 'syncing') {
- throw new Error('Sync already in progress');
- }
-
- this.syncStatus = 'syncing';
- this.emitEvent({
- direction: 'cli-to-gui',
- status: 'syncing',
- timestamp: Date.now(),
- });
-
- try {
- // CLI profiles are accessed through Happy settings system, not direct file access
- // Return profiles from current GUI settings
- const currentProfiles = storage.getState().settings.profiles || [];
-
- console.log(`[ProfileSync] Retrieved ${currentProfiles.length} profiles from Happy settings`);
-
- this.lastSyncTime = Date.now();
- this.syncStatus = 'success';
-
- this.emitEvent({
- direction: 'cli-to-gui',
- status: 'success',
- profilesSynced: currentProfiles.length,
- timestamp: Date.now(),
- message: 'Profiles retrieved from Happy settings system'
- });
-
- return currentProfiles;
- } catch (error) {
- this.syncStatus = 'error';
- const errorMessage = error instanceof Error ? error.message : 'Unknown sync error';
-
- this.emitEvent({
- direction: 'cli-to-gui',
- status: 'error',
- error: errorMessage,
- timestamp: Date.now(),
- });
-
- throw error;
- }
- }
-
- /**
- * Perform bidirectional sync with conflict resolution
- */
- public async bidirectionalSync(guiProfiles: AIBackendProfile[]): Promise {
- if (this.syncStatus === 'syncing') {
- throw new Error('Sync already in progress');
- }
-
- this.syncStatus = 'syncing';
- this.emitEvent({
- direction: 'bidirectional',
- status: 'syncing',
- timestamp: Date.now(),
- });
-
- try {
- // Get CLI profiles
- const cliProfiles = await this.syncCliToGui();
-
- // Resolve conflicts based on configuration
- const resolvedProfiles = await this.resolveConflicts(guiProfiles, cliProfiles);
-
- // Update CLI with resolved profiles
- await this.syncGuiToCli(resolvedProfiles);
-
- this.lastSyncTime = Date.now();
- this.syncStatus = 'success';
-
- this.emitEvent({
- direction: 'bidirectional',
- status: 'success',
- profilesSynced: resolvedProfiles.length,
- timestamp: Date.now(),
- });
-
- return resolvedProfiles;
- } catch (error) {
- this.syncStatus = 'error';
- const errorMessage = error instanceof Error ? error.message : 'Unknown sync error';
-
- this.emitEvent({
- direction: 'bidirectional',
- status: 'error',
- error: errorMessage,
- timestamp: Date.now(),
- });
-
- throw error;
- }
- }
-
- /**
- * Resolve conflicts between GUI and CLI profiles
- */
- private async resolveConflicts(
- guiProfiles: AIBackendProfile[],
- cliProfiles: AIBackendProfile[]
- ): Promise {
- const { conflictResolution } = this.config;
- const resolvedProfiles: AIBackendProfile[] = [];
- const processedIds = new Set();
-
- // Process profiles that exist in both GUI and CLI
- for (const guiProfile of guiProfiles) {
- const cliProfile = cliProfiles.find(p => p.id === guiProfile.id);
-
- if (cliProfile) {
- let resolvedProfile: AIBackendProfile;
-
- switch (conflictResolution) {
- case 'gui-wins':
- resolvedProfile = { ...guiProfile, updatedAt: Date.now() };
- break;
- case 'cli-wins':
- resolvedProfile = { ...cliProfile, updatedAt: Date.now() };
- break;
- case 'most-recent':
- resolvedProfile = guiProfile.updatedAt! >= cliProfile.updatedAt!
- ? { ...guiProfile }
- : { ...cliProfile };
- break;
- case 'merge':
- resolvedProfile = await this.mergeProfiles(guiProfile, cliProfile);
- break;
- default:
- resolvedProfile = { ...guiProfile };
- }
-
- resolvedProfiles.push(resolvedProfile);
- processedIds.add(guiProfile.id);
- } else {
- // Profile exists only in GUI
- resolvedProfiles.push({ ...guiProfile, updatedAt: Date.now() });
- processedIds.add(guiProfile.id);
- }
- }
-
- // Add profiles that exist only in CLI
- for (const cliProfile of cliProfiles) {
- if (!processedIds.has(cliProfile.id)) {
- resolvedProfiles.push({ ...cliProfile, updatedAt: Date.now() });
- }
- }
-
- return resolvedProfiles;
- }
-
- /**
- * Merge two profiles, preferring non-null values from both
- */
- private async mergeProfiles(
- guiProfile: AIBackendProfile,
- cliProfile: AIBackendProfile
- ): Promise {
- const merged: AIBackendProfile = {
- id: guiProfile.id,
- name: guiProfile.name || cliProfile.name,
- description: guiProfile.description || cliProfile.description,
- anthropicConfig: { ...cliProfile.anthropicConfig, ...guiProfile.anthropicConfig },
- openaiConfig: { ...cliProfile.openaiConfig, ...guiProfile.openaiConfig },
- azureOpenAIConfig: { ...cliProfile.azureOpenAIConfig, ...guiProfile.azureOpenAIConfig },
- togetherAIConfig: { ...cliProfile.togetherAIConfig, ...guiProfile.togetherAIConfig },
- tmuxConfig: { ...cliProfile.tmuxConfig, ...guiProfile.tmuxConfig },
- environmentVariables: this.mergeEnvironmentVariables(
- cliProfile.environmentVariables || [],
- guiProfile.environmentVariables || []
- ),
- compatibility: { ...cliProfile.compatibility, ...guiProfile.compatibility },
- isBuiltIn: guiProfile.isBuiltIn || cliProfile.isBuiltIn,
- createdAt: Math.min(guiProfile.createdAt || 0, cliProfile.createdAt || 0),
- updatedAt: Math.max(guiProfile.updatedAt || 0, cliProfile.updatedAt || 0),
- version: guiProfile.version || cliProfile.version || '1.0.0',
- };
-
- return merged;
- }
-
- /**
- * Merge environment variables from two profiles
- */
- private mergeEnvironmentVariables(
- cliVars: Array<{ name: string; value: string }>,
- guiVars: Array<{ name: string; value: string }>
- ): Array<{ name: string; value: string }> {
- const mergedVars = new Map();
-
- // Add CLI variables first
- cliVars.forEach(v => mergedVars.set(v.name, v.value));
-
- // Override with GUI variables
- guiVars.forEach(v => mergedVars.set(v.name, v.value));
-
- return Array.from(mergedVars.entries()).map(([name, value]) => ({ name, value }));
- }
-
- /**
- * Set active profile using Happy settings infrastructure
- * SECURITY NOTE: Direct file access is PROHIBITED - use Happy settings system
- */
- public async setActiveProfile(profileId: string): Promise {
- try {
- // Store in GUI settings using Happy's settings system
- sync.applySettings({ lastUsedProfile: profileId });
-
- console.log(`[ProfileSync] Set active profile ${profileId} in Happy settings`);
-
- // Note: CLI daemon accesses active profile through Happy settings system
- // TODO: Implement machine RPC endpoint for setting active profile in CLI daemon
- } catch (error) {
- console.error('[ProfileSync] Failed to set active profile:', error);
- throw error;
- }
- }
-
- /**
- * Get active profile using Happy settings infrastructure
- * SECURITY NOTE: Direct file access is PROHIBITED - use Happy settings system
- */
- public async getActiveProfile(): Promise {
- try {
- // Get active profile from Happy settings system
- const lastUsedProfileId = storage.getState().settings.lastUsedProfile;
-
- if (!lastUsedProfileId) {
- return null;
- }
-
- const profiles = storage.getState().settings.profiles || [];
- const activeProfile = profiles.find((p: AIBackendProfile) => p.id === lastUsedProfileId);
-
- if (activeProfile) {
- console.log(`[ProfileSync] Retrieved active profile ${activeProfile.name} from Happy settings`);
- return activeProfile;
- }
-
- return null;
- } catch (error) {
- console.error('[ProfileSync] Failed to get active profile:', error);
- return null;
- }
- }
-
- /**
- * Auto-sync if enabled and conditions are met
- */
- public async autoSyncIfNeeded(guiProfiles: AIBackendProfile[]): Promise {
- if (!this.config.autoSync) {
- return;
- }
-
- const timeSinceLastSync = Date.now() - this.lastSyncTime;
- const AUTO_SYNC_INTERVAL = 5 * 60 * 1000; // 5 minutes
-
- if (timeSinceLastSync > AUTO_SYNC_INTERVAL) {
- try {
- await this.bidirectionalSync(guiProfiles);
- } catch (error) {
- console.error('[ProfileSync] Auto-sync failed:', error);
- // Don't throw for auto-sync failures
- }
- }
- }
-}
-
-// Export singleton instance
-export const profileSyncService = ProfileSyncService.getInstance();
-
-// Export convenience functions
-export const syncGuiToCli = (profiles: AIBackendProfile[]) => profileSyncService.syncGuiToCli(profiles);
-export const syncCliToGui = () => profileSyncService.syncCliToGui();
-export const bidirectionalSync = (guiProfiles: AIBackendProfile[]) => profileSyncService.bidirectionalSync(guiProfiles);
-export const setActiveProfile = (profileId: string) => profileSyncService.setActiveProfile(profileId);
-export const getActiveProfile = () => profileSyncService.getActiveProfile();
\ No newline at end of file
diff --git a/sources/sync/profileUtils.ts b/sources/sync/profileUtils.ts
index d90a98a93..aed7c68ae 100644
--- a/sources/sync/profileUtils.ts
+++ b/sources/sync/profileUtils.ts
@@ -1,5 +1,18 @@
import { AIBackendProfile } from './settings';
+export type ProfilePrimaryCli = 'claude' | 'codex' | 'gemini' | 'multi' | 'none';
+
+export function getProfilePrimaryCli(profile: AIBackendProfile | null | undefined): ProfilePrimaryCli {
+ if (!profile) return 'none';
+ const supported = Object.entries(profile.compatibility ?? {})
+ .filter(([, isSupported]) => isSupported)
+ .map(([cli]) => cli as 'claude' | 'codex' | 'gemini');
+
+ if (supported.length === 0) return 'none';
+ if (supported.length === 1) return supported[0];
+ return 'multi';
+}
+
/**
* Documentation and expected values for built-in profiles.
* These help users understand what environment variables to set and their expected values.
@@ -242,7 +255,6 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => {
return {
id: 'anthropic',
name: 'Anthropic (Default)',
- anthropicConfig: {},
environmentVariables: [],
defaultPermissionMode: 'default',
compatibility: { claude: true, codex: false, gemini: false },
@@ -256,11 +268,10 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => {
// Launch daemon with: DEEPSEEK_AUTH_TOKEN=sk-... DEEPSEEK_BASE_URL=https://api.deepseek.com/anthropic
// Uses ${VAR:-default} format for fallback values (bash parameter expansion)
// Secrets use ${VAR} without fallback for security
- // NOTE: anthropicConfig left empty so environmentVariables aren't overridden (getProfileEnvironmentVariables priority)
+ // NOTE: Profiles are env-var based; environmentVariables are the single source of truth.
return {
id: 'deepseek',
name: 'DeepSeek (Reasoner)',
- anthropicConfig: {},
environmentVariables: [
{ name: 'ANTHROPIC_BASE_URL', value: '${DEEPSEEK_BASE_URL:-https://api.deepseek.com/anthropic}' },
{ name: 'ANTHROPIC_AUTH_TOKEN', value: '${DEEPSEEK_AUTH_TOKEN}' }, // Secret - no fallback
@@ -282,11 +293,10 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => {
// Model mappings: Z_AI_OPUS_MODEL=GLM-4.6, Z_AI_SONNET_MODEL=GLM-4.6, Z_AI_HAIKU_MODEL=GLM-4.5-Air
// Uses ${VAR:-default} format for fallback values (bash parameter expansion)
// Secrets use ${VAR} without fallback for security
- // NOTE: anthropicConfig left empty so environmentVariables aren't overridden
+ // NOTE: Profiles are env-var based; environmentVariables are the single source of truth.
return {
id: 'zai',
name: 'Z.AI (GLM-4.6)',
- anthropicConfig: {},
environmentVariables: [
{ name: 'ANTHROPIC_BASE_URL', value: '${Z_AI_BASE_URL:-https://api.z.ai/api/anthropic}' },
{ name: 'ANTHROPIC_AUTH_TOKEN', value: '${Z_AI_AUTH_TOKEN}' }, // Secret - no fallback
@@ -307,7 +317,6 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => {
return {
id: 'openai',
name: 'OpenAI (GPT-5)',
- openaiConfig: {},
environmentVariables: [
{ name: 'OPENAI_BASE_URL', value: 'https://api.openai.com/v1' },
{ name: 'OPENAI_MODEL', value: 'gpt-5-codex-high' },
@@ -326,7 +335,6 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => {
return {
id: 'azure-openai',
name: 'Azure OpenAI',
- azureOpenAIConfig: {},
environmentVariables: [
{ name: 'AZURE_OPENAI_API_VERSION', value: '2024-02-15-preview' },
{ name: 'AZURE_OPENAI_DEPLOYMENT_NAME', value: 'gpt-5-codex' },
diff --git a/sources/sync/reducer/phase0-skipping.spec.ts b/sources/sync/reducer/phase0-skipping.spec.ts
index 5e005ab59..c1bb0e2ff 100644
--- a/sources/sync/reducer/phase0-skipping.spec.ts
+++ b/sources/sync/reducer/phase0-skipping.spec.ts
@@ -93,12 +93,6 @@ describe('Phase 0 permission skipping issue', () => {
// Process messages and AgentState together (simulates opening chat)
const result = reducer(state, toolMessages, agentState);
- // Log what happened (for debugging)
- console.log('Result messages:', result.messages.length);
- console.log('Permission mappings:', {
- toolIdToMessageId: Array.from(state.toolIdToMessageId.entries())
- });
-
// Find the tool messages in the result
const webFetchTool = result.messages.find(m => m.kind === 'tool-call' && m.tool?.name === 'WebFetch');
const writeTool = result.messages.find(m => m.kind === 'tool-call' && m.tool?.name === 'Write');
@@ -203,4 +197,4 @@ describe('Phase 0 permission skipping issue', () => {
expect(toolAfterPermission?.tool?.permission?.id).toBe('tool1');
expect(toolAfterPermission?.tool?.permission?.status).toBe('approved');
});
-});
\ No newline at end of file
+});
diff --git a/sources/sync/settings.spec.ts b/sources/sync/settings.spec.ts
index 4f36ce46f..1f38fef48 100644
--- a/sources/sync/settings.spec.ts
+++ b/sources/sync/settings.spec.ts
@@ -89,6 +89,37 @@ describe('settings', () => {
}
});
});
+
+ it('should migrate legacy provider config objects into environmentVariables', () => {
+ const settingsWithLegacyProfileConfig: any = {
+ profiles: [
+ {
+ id: 'legacy-profile',
+ name: 'Legacy Profile',
+ isBuiltIn: false,
+ compatibility: { claude: true, codex: true, gemini: true },
+ environmentVariables: [{ name: 'FOO', value: 'bar' }],
+ openaiConfig: {
+ apiKey: 'sk-test',
+ baseUrl: 'https://example.com',
+ model: 'gpt-test',
+ },
+ },
+ ],
+ };
+
+ const parsed = settingsParse(settingsWithLegacyProfileConfig);
+ expect(parsed.profiles).toHaveLength(1);
+
+ const profile = parsed.profiles[0]!;
+ expect(profile.environmentVariables).toEqual(expect.arrayContaining([
+ { name: 'FOO', value: 'bar' },
+ { name: 'OPENAI_API_KEY', value: 'sk-test' },
+ { name: 'OPENAI_BASE_URL', value: 'https://example.com' },
+ { name: 'OPENAI_MODEL', value: 'gpt-test' },
+ ]));
+ expect((profile as any).openaiConfig).toBeUndefined();
+ });
});
describe('applySettings', () => {
@@ -103,7 +134,11 @@ describe('settings', () => {
analyticsOptOut: false,
inferenceOpenAIKey: null,
experiments: false,
+ useProfiles: false,
useEnhancedSessionWizard: false,
+ usePickerSearch: false,
+ useMachinePickerSearch: false,
+ usePathPickerSearch: false,
alwaysShowContextSize: false,
agentInputEnterToSend: true,
avatarStyle: 'gradient',
@@ -122,6 +157,7 @@ describe('settings', () => {
lastUsedProfile: null,
favoriteDirectories: [],
favoriteMachines: [],
+ favoriteProfiles: [],
dismissedCLIWarnings: { perMachine: {}, global: {} },
};
const delta: Partial = {
@@ -137,7 +173,11 @@ describe('settings', () => {
analyticsOptOut: false,
inferenceOpenAIKey: null,
experiments: false,
+ useProfiles: false,
useEnhancedSessionWizard: false,
+ usePickerSearch: false,
+ useMachinePickerSearch: false,
+ usePathPickerSearch: false,
alwaysShowContextSize: false,
agentInputEnterToSend: true,
avatarStyle: 'gradient', // This should be preserved from currentSettings
@@ -156,6 +196,7 @@ describe('settings', () => {
lastUsedProfile: null,
favoriteDirectories: [],
favoriteMachines: [],
+ favoriteProfiles: [],
dismissedCLIWarnings: { perMachine: {}, global: {} },
});
});
@@ -171,7 +212,11 @@ describe('settings', () => {
analyticsOptOut: false,
inferenceOpenAIKey: null,
experiments: false,
+ useProfiles: false,
useEnhancedSessionWizard: false,
+ usePickerSearch: false,
+ useMachinePickerSearch: false,
+ usePathPickerSearch: false,
alwaysShowContextSize: false,
agentInputEnterToSend: true,
avatarStyle: 'gradient',
@@ -190,6 +235,7 @@ describe('settings', () => {
lastUsedProfile: null,
favoriteDirectories: [],
favoriteMachines: [],
+ favoriteProfiles: [],
dismissedCLIWarnings: { perMachine: {}, global: {} },
};
const delta: Partial = {};
@@ -207,7 +253,11 @@ describe('settings', () => {
analyticsOptOut: false,
inferenceOpenAIKey: null,
experiments: false,
+ useProfiles: false,
useEnhancedSessionWizard: false,
+ usePickerSearch: false,
+ useMachinePickerSearch: false,
+ usePathPickerSearch: false,
alwaysShowContextSize: false,
agentInputEnterToSend: true,
avatarStyle: 'gradient',
@@ -226,6 +276,7 @@ describe('settings', () => {
lastUsedProfile: null,
favoriteDirectories: [],
favoriteMachines: [],
+ favoriteProfiles: [],
dismissedCLIWarnings: { perMachine: {}, global: {} },
};
const delta: Partial = {
@@ -248,7 +299,11 @@ describe('settings', () => {
analyticsOptOut: false,
inferenceOpenAIKey: null,
experiments: false,
+ useProfiles: false,
useEnhancedSessionWizard: false,
+ usePickerSearch: false,
+ useMachinePickerSearch: false,
+ usePathPickerSearch: false,
alwaysShowContextSize: false,
agentInputEnterToSend: true,
avatarStyle: 'gradient',
@@ -267,6 +322,7 @@ describe('settings', () => {
lastUsedProfile: null,
favoriteDirectories: [],
favoriteMachines: [],
+ favoriteProfiles: [],
dismissedCLIWarnings: { perMachine: {}, global: {} },
};
expect(applySettings(currentSettings, {})).toEqual(currentSettings);
@@ -298,7 +354,11 @@ describe('settings', () => {
analyticsOptOut: false,
inferenceOpenAIKey: null,
experiments: false,
+ useProfiles: false,
useEnhancedSessionWizard: false,
+ usePickerSearch: false,
+ useMachinePickerSearch: false,
+ usePathPickerSearch: false,
alwaysShowContextSize: false,
agentInputEnterToSend: true,
avatarStyle: 'gradient',
@@ -317,6 +377,7 @@ describe('settings', () => {
lastUsedProfile: null,
favoriteDirectories: [],
favoriteMachines: [],
+ favoriteProfiles: [],
dismissedCLIWarnings: { perMachine: {}, global: {} },
};
const delta: any = {
@@ -360,8 +421,13 @@ describe('settings', () => {
analyticsOptOut: false,
inferenceOpenAIKey: null,
experiments: false,
+ useProfiles: false,
alwaysShowContextSize: false,
- avatarStyle: 'brutalist',
+ useEnhancedSessionWizard: false,
+ usePickerSearch: false,
+ useMachinePickerSearch: false,
+ usePathPickerSearch: false,
+ avatarStyle: 'brutalist',
showFlavorIcons: false,
compactSessionView: false,
agentInputEnterToSend: true,
@@ -376,10 +442,10 @@ describe('settings', () => {
lastUsedModelMode: null,
profiles: [],
lastUsedProfile: null,
- favoriteDirectories: ['~/src', '~/Desktop', '~/Documents'],
+ favoriteDirectories: [],
favoriteMachines: [],
+ favoriteProfiles: [],
dismissedCLIWarnings: { perMachine: {}, global: {} },
- useEnhancedSessionWizard: false,
});
});
@@ -560,7 +626,6 @@ describe('settings', () => {
{
id: 'server-profile',
name: 'Server Profile',
- anthropicConfig: {},
environmentVariables: [],
compatibility: { claude: true, codex: true, gemini: true },
isBuiltIn: false,
@@ -578,7 +643,6 @@ describe('settings', () => {
{
id: 'local-profile',
name: 'Local Profile',
- anthropicConfig: {},
environmentVariables: [],
compatibility: { claude: true, codex: true, gemini: true },
isBuiltIn: false,
@@ -680,7 +744,6 @@ describe('settings', () => {
profiles: [{
id: 'test-profile',
name: 'Test',
- anthropicConfig: {},
environmentVariables: [],
compatibility: { claude: true, codex: true, gemini: true },
isBuiltIn: false,
@@ -713,7 +776,6 @@ describe('settings', () => {
profiles: [{
id: 'device-b-profile',
name: 'Device B Profile',
- anthropicConfig: {},
environmentVariables: [],
compatibility: { claude: true, codex: true },
isBuiltIn: false,
@@ -825,7 +887,6 @@ describe('settings', () => {
profiles: [{
id: 'server-profile-1',
name: 'Server Profile',
- anthropicConfig: {},
environmentVariables: [],
compatibility: { claude: true, codex: true },
isBuiltIn: false,
@@ -844,7 +905,6 @@ describe('settings', () => {
profiles: [{
id: 'local-profile-1',
name: 'Local Profile',
- anthropicConfig: {},
environmentVariables: [],
compatibility: { claude: true, codex: true, gemini: true },
isBuiltIn: false,
diff --git a/sources/sync/settings.ts b/sources/sync/settings.ts
index 5746c863d..ce7e3669e 100644
--- a/sources/sync/settings.ts
+++ b/sources/sync/settings.ts
@@ -4,72 +4,6 @@ import * as z from 'zod';
// Configuration Profile Schema (for environment variable profiles)
//
-// Environment variable schemas for different AI providers
-// Note: baseUrl fields accept either valid URLs or ${VAR} or ${VAR:-default} template strings
-const AnthropicConfigSchema = z.object({
- baseUrl: z.string().refine(
- (val) => {
- if (!val) return true; // Optional
- // Allow ${VAR} and ${VAR:-default} template strings
- if (/^\$\{[A-Z_][A-Z0-9_]*(:-[^}]*)?\}$/.test(val)) return true;
- // Otherwise validate as URL
- try {
- new URL(val);
- return true;
- } catch {
- return false;
- }
- },
- { message: 'Must be a valid URL or ${VAR} or ${VAR:-default} template string' }
- ).optional(),
- authToken: z.string().optional(),
- model: z.string().optional(),
-});
-
-const OpenAIConfigSchema = z.object({
- apiKey: z.string().optional(),
- baseUrl: z.string().refine(
- (val) => {
- if (!val) return true;
- // Allow ${VAR} and ${VAR:-default} template strings
- if (/^\$\{[A-Z_][A-Z0-9_]*(:-[^}]*)?\}$/.test(val)) return true;
- try {
- new URL(val);
- return true;
- } catch {
- return false;
- }
- },
- { message: 'Must be a valid URL or ${VAR} or ${VAR:-default} template string' }
- ).optional(),
- model: z.string().optional(),
-});
-
-const AzureOpenAIConfigSchema = z.object({
- apiKey: z.string().optional(),
- endpoint: z.string().refine(
- (val) => {
- if (!val) return true;
- // Allow ${VAR} and ${VAR:-default} template strings
- if (/^\$\{[A-Z_][A-Z0-9_]*(:-[^}]*)?\}$/.test(val)) return true;
- try {
- new URL(val);
- return true;
- } catch {
- return false;
- }
- },
- { message: 'Must be a valid URL or ${VAR} or ${VAR:-default} template string' }
- ).optional(),
- apiVersion: z.string().optional(),
- deploymentName: z.string().optional(),
-});
-
-const TogetherAIConfigSchema = z.object({
- apiKey: z.string().optional(),
- model: z.string().optional(),
-});
-
// Tmux configuration schema
const TmuxConfigSchema = z.object({
sessionName: z.string().optional(),
@@ -97,18 +31,9 @@ export const AIBackendProfileSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
- // Agent-specific configurations
- anthropicConfig: AnthropicConfigSchema.optional(),
- openaiConfig: OpenAIConfigSchema.optional(),
- azureOpenAIConfig: AzureOpenAIConfigSchema.optional(),
- togetherAIConfig: TogetherAIConfigSchema.optional(),
-
// Tmux configuration
tmuxConfig: TmuxConfigSchema.optional(),
- // Startup bash script (executed before spawning session)
- startupBashScript: z.string().optional(),
-
// Environment variables (validated)
environmentVariables: z.array(EnvironmentVariableSchema).default([]),
@@ -140,6 +65,61 @@ export function validateProfileForAgent(profile: AIBackendProfile, agent: 'claud
return profile.compatibility[agent];
}
+function mergeEnvironmentVariables(
+ existing: unknown,
+ additions: Record
+): Array<{ name: string; value: string }> {
+ const map = new Map();
+
+ if (Array.isArray(existing)) {
+ for (const entry of existing) {
+ if (!entry || typeof entry !== 'object') continue;
+ const name = (entry as any).name;
+ const value = (entry as any).value;
+ if (typeof name !== 'string' || typeof value !== 'string') continue;
+ map.set(name, value);
+ }
+ }
+
+ for (const [name, value] of Object.entries(additions)) {
+ if (typeof value !== 'string') continue;
+ if (!map.has(name)) {
+ map.set(name, value);
+ }
+ }
+
+ return Array.from(map.entries()).map(([name, value]) => ({ name, value }));
+}
+
+function normalizeLegacyProfileConfig(profile: unknown): unknown {
+ if (!profile || typeof profile !== 'object') return profile;
+
+ const raw = profile as Record;
+ const additions: Record = {
+ ANTHROPIC_BASE_URL: raw.anthropicConfig?.baseUrl,
+ ANTHROPIC_AUTH_TOKEN: raw.anthropicConfig?.authToken,
+ ANTHROPIC_MODEL: raw.anthropicConfig?.model,
+ OPENAI_API_KEY: raw.openaiConfig?.apiKey,
+ OPENAI_BASE_URL: raw.openaiConfig?.baseUrl,
+ OPENAI_MODEL: raw.openaiConfig?.model,
+ AZURE_OPENAI_API_KEY: raw.azureOpenAIConfig?.apiKey,
+ AZURE_OPENAI_ENDPOINT: raw.azureOpenAIConfig?.endpoint,
+ AZURE_OPENAI_API_VERSION: raw.azureOpenAIConfig?.apiVersion,
+ AZURE_OPENAI_DEPLOYMENT_NAME: raw.azureOpenAIConfig?.deploymentName,
+ TOGETHER_API_KEY: raw.togetherAIConfig?.apiKey,
+ TOGETHER_MODEL: raw.togetherAIConfig?.model,
+ };
+
+ const environmentVariables = mergeEnvironmentVariables(raw.environmentVariables, additions);
+
+ // Remove legacy provider config objects. Any values are preserved via environmentVariables migration above.
+ const { anthropicConfig, openaiConfig, azureOpenAIConfig, togetherAIConfig, ...rest } = raw;
+ return {
+ ...rest,
+ environmentVariables,
+ };
+}
+
/**
* Converts a profile into environment variables for session spawning.
*
@@ -157,8 +137,8 @@ export function validateProfileForAgent(profile: AIBackendProfile, agent: 'claud
* Sent: ANTHROPIC_AUTH_TOKEN=${Z_AI_AUTH_TOKEN} (literal string with placeholder)
*
* 4. DAEMON EXPANDS ${VAR} from its process.env when spawning session:
- * - Tmux mode: Shell expands via `export ANTHROPIC_AUTH_TOKEN="${Z_AI_AUTH_TOKEN}";` before launching
- * - Non-tmux mode: Node.js spawn with env: { ...process.env, ...profileEnvVars } (shell expansion in child)
+ * - Tmux mode: daemon interpolates ${VAR} / ${VAR:-default} / ${VAR:=default} in env values before launching (shells do not expand placeholders inside env values automatically)
+ * - Non-tmux mode: daemon interpolates ${VAR} / ${VAR:-default} / ${VAR:=default} in env values before calling spawn() (Node does not expand placeholders)
*
* 5. SESSION RECEIVES actual expanded values:
* ANTHROPIC_AUTH_TOKEN=sk-real-key (expanded from daemon's Z_AI_AUTH_TOKEN, not literal ${Z_AI_AUTH_TOKEN})
@@ -172,7 +152,7 @@ export function validateProfileForAgent(profile: AIBackendProfile, agent: 'claud
* - Each session uses its selected backend for its entire lifetime (no mid-session switching)
* - Keep secrets in shell environment, not in GUI/profile storage
*
- * PRIORITY ORDER when spawning (daemon/run.ts):
+ * PRIORITY ORDER when spawning:
* Final env = { ...daemon.process.env, ...expandedProfileVars, ...authVars }
* authVars override profile, profile overrides daemon.process.env
*/
@@ -184,34 +164,6 @@ export function getProfileEnvironmentVariables(profile: AIBackendProfile): Recor
envVars[envVar.name] = envVar.value;
});
- // Add Anthropic config
- if (profile.anthropicConfig) {
- if (profile.anthropicConfig.baseUrl) envVars.ANTHROPIC_BASE_URL = profile.anthropicConfig.baseUrl;
- if (profile.anthropicConfig.authToken) envVars.ANTHROPIC_AUTH_TOKEN = profile.anthropicConfig.authToken;
- if (profile.anthropicConfig.model) envVars.ANTHROPIC_MODEL = profile.anthropicConfig.model;
- }
-
- // Add OpenAI config
- if (profile.openaiConfig) {
- if (profile.openaiConfig.apiKey) envVars.OPENAI_API_KEY = profile.openaiConfig.apiKey;
- if (profile.openaiConfig.baseUrl) envVars.OPENAI_BASE_URL = profile.openaiConfig.baseUrl;
- if (profile.openaiConfig.model) envVars.OPENAI_MODEL = profile.openaiConfig.model;
- }
-
- // Add Azure OpenAI config
- if (profile.azureOpenAIConfig) {
- if (profile.azureOpenAIConfig.apiKey) envVars.AZURE_OPENAI_API_KEY = profile.azureOpenAIConfig.apiKey;
- if (profile.azureOpenAIConfig.endpoint) envVars.AZURE_OPENAI_ENDPOINT = profile.azureOpenAIConfig.endpoint;
- if (profile.azureOpenAIConfig.apiVersion) envVars.AZURE_OPENAI_API_VERSION = profile.azureOpenAIConfig.apiVersion;
- if (profile.azureOpenAIConfig.deploymentName) envVars.AZURE_OPENAI_DEPLOYMENT_NAME = profile.azureOpenAIConfig.deploymentName;
- }
-
- // Add Together AI config
- if (profile.togetherAIConfig) {
- if (profile.togetherAIConfig.apiKey) envVars.TOGETHER_API_KEY = profile.togetherAIConfig.apiKey;
- if (profile.togetherAIConfig.model) envVars.TOGETHER_MODEL = profile.togetherAIConfig.model;
- }
-
// Add Tmux config
if (profile.tmuxConfig) {
// Empty string means "use current/most recent session", so include it
@@ -249,6 +201,8 @@ export function isProfileVersionCompatible(profileVersion: string, requiredVersi
//
// Current schema version for backward compatibility
+// NOTE: This schemaVersion is for the Happy app's settings blob (synced via the server).
+// happy-cli maintains its own local settings schemaVersion separately.
export const SUPPORTED_SCHEMA_VERSION = 2;
export const SettingsSchema = z.object({
@@ -263,7 +217,12 @@ export const SettingsSchema = z.object({
wrapLinesInDiffs: z.boolean().describe('Whether to wrap long lines in diff views'),
analyticsOptOut: z.boolean().describe('Whether to opt out of anonymous analytics'),
experiments: z.boolean().describe('Whether to enable experimental features'),
+ useProfiles: z.boolean().describe('Whether to enable AI backend profiles feature'),
useEnhancedSessionWizard: z.boolean().describe('A/B test flag: Use enhanced profile-based session wizard UI'),
+ // Legacy combined toggle (kept for backward compatibility; see settingsParse migration)
+ usePickerSearch: z.boolean().describe('Whether to show search in machine/path picker UIs (legacy combined toggle)'),
+ useMachinePickerSearch: z.boolean().describe('Whether to show search in machine picker UIs'),
+ usePathPickerSearch: z.boolean().describe('Whether to show search in path picker UIs'),
alwaysShowContextSize: z.boolean().describe('Always show context size in agent input'),
agentInputEnterToSend: z.boolean().describe('Whether pressing Enter submits/sends in the agent input (web)'),
avatarStyle: z.string().describe('Avatar display style'),
@@ -288,6 +247,8 @@ export const SettingsSchema = z.object({
favoriteDirectories: z.array(z.string()).describe('User-defined favorite directories for quick access in path selection'),
// Favorite machines for quick machine selection
favoriteMachines: z.array(z.string()).describe('User-defined favorite machines (machine IDs) for quick access in machine selection'),
+ // Favorite profiles for quick profile selection (built-in or custom profile IDs)
+ favoriteProfiles: z.array(z.string()).describe('User-defined favorite profiles (profile IDs) for quick access in profile selection'),
// Dismissed CLI warning banners (supports both per-machine and global dismissal)
dismissedCLIWarnings: z.object({
perMachine: z.record(z.string(), z.object({
@@ -332,7 +293,11 @@ export const settingsDefaults: Settings = {
wrapLinesInDiffs: false,
analyticsOptOut: false,
experiments: false,
+ useProfiles: false,
useEnhancedSessionWizard: false,
+ usePickerSearch: false,
+ useMachinePickerSearch: false,
+ usePathPickerSearch: false,
alwaysShowContextSize: false,
agentInputEnterToSend: true,
avatarStyle: 'brutalist',
@@ -350,10 +315,12 @@ export const settingsDefaults: Settings = {
// Profile management defaults
profiles: [],
lastUsedProfile: null,
- // Default favorite directories (real common directories on Unix-like systems)
- favoriteDirectories: ['~/src', '~/Desktop', '~/Documents'],
+ // Favorite directories (empty by default)
+ favoriteDirectories: [],
// Favorite machines (empty by default)
favoriteMachines: [],
+ // Favorite profiles (empty by default)
+ favoriteProfiles: [],
// Dismissed CLI warnings (empty by default)
dismissedCLIWarnings: { perMachine: {}, global: {} },
};
@@ -369,28 +336,75 @@ export function settingsParse(settings: unknown): Settings {
return { ...settingsDefaults };
}
- const parsed = SettingsSchemaPartial.safeParse(settings);
- if (!parsed.success) {
- // For invalid settings, preserve unknown fields but use defaults for known fields
- const unknownFields = { ...(settings as any) };
- // Remove all known schema fields from unknownFields
- const knownFields = Object.keys(SettingsSchema.shape);
- knownFields.forEach(key => delete unknownFields[key]);
- return { ...settingsDefaults, ...unknownFields };
- }
+ const isDev = typeof __DEV__ !== 'undefined' && __DEV__;
+
+ // IMPORTANT: be tolerant of partially-invalid settings objects.
+ // A single invalid field (e.g. one malformed profile) must not reset all other known settings to defaults.
+ const input = settings as Record;
+ const result: any = { ...settingsDefaults };
+
+ // Parse known fields individually to avoid whole-object failure.
+ (Object.keys(SettingsSchema.shape) as Array).forEach((key) => {
+ if (!Object.prototype.hasOwnProperty.call(input, key)) return;
+
+ // Special-case profiles: validate per profile entry, keep valid ones.
+ if (key === 'profiles') {
+ const profilesValue = input[key];
+ if (Array.isArray(profilesValue)) {
+ const parsedProfiles: AIBackendProfile[] = [];
+ for (const rawProfile of profilesValue) {
+ const parsedProfile = AIBackendProfileSchema.safeParse(normalizeLegacyProfileConfig(rawProfile));
+ if (parsedProfile.success) {
+ parsedProfiles.push(parsedProfile.data);
+ } else if (isDev) {
+ console.warn('[settingsParse] Dropping invalid profile entry', parsedProfile.error.issues);
+ }
+ }
+ result.profiles = parsedProfiles;
+ }
+ return;
+ }
+
+ const schema = SettingsSchema.shape[key];
+ const parsedField = schema.safeParse(input[key]);
+ if (parsedField.success) {
+ result[key] = parsedField.data;
+ } else if (isDev) {
+ console.warn(`[settingsParse] Invalid settings field "${String(key)}" - using default`, parsedField.error.issues);
+ }
+ });
// Migration: Convert old 'zh' language code to 'zh-Hans'
- if (parsed.data.preferredLanguage === 'zh') {
- console.log('[Settings Migration] Converting language code from "zh" to "zh-Hans"');
- parsed.data.preferredLanguage = 'zh-Hans';
+ if (result.preferredLanguage === 'zh') {
+ result.preferredLanguage = 'zh-Hans';
}
- // Merge defaults, parsed settings, and preserve unknown fields
- const unknownFields = { ...(settings as any) };
- // Remove known fields from unknownFields to preserve only the unknown ones
- Object.keys(parsed.data).forEach(key => delete unknownFields[key]);
+ // Migration: Convert legacy combined picker-search toggle into per-picker toggles.
+ // Only apply if new fields were not present in persisted settings.
+ const hasMachineSearch = 'useMachinePickerSearch' in input;
+ const hasPathSearch = 'usePathPickerSearch' in input;
+ if (!hasMachineSearch && !hasPathSearch) {
+ const legacy = SettingsSchema.shape.usePickerSearch.safeParse(input.usePickerSearch);
+ if (legacy.success && legacy.data === true) {
+ result.useMachinePickerSearch = true;
+ result.usePathPickerSearch = true;
+ }
+ }
+
+ // Preserve unknown fields (forward compatibility).
+ for (const [key, value] of Object.entries(input)) {
+ if (key === '__proto__') continue;
+ if (!Object.prototype.hasOwnProperty.call(SettingsSchema.shape, key)) {
+ Object.defineProperty(result, key, {
+ value,
+ enumerable: true,
+ configurable: true,
+ writable: true,
+ });
+ }
+ }
- return { ...settingsDefaults, ...parsed.data, ...unknownFields };
+ return result as Settings;
}
//
diff --git a/sources/sync/storage.ts b/sources/sync/storage.ts
index f1fe413f6..8c76be584 100644
--- a/sources/sync/storage.ts
+++ b/sources/sync/storage.ts
@@ -12,7 +12,7 @@ import { TodoState } from "../-zen/model/ops";
import { Profile } from "./profile";
import { UserProfile, RelationshipUpdatedEvent } from "./friendTypes";
import { loadSettings, loadLocalSettings, saveLocalSettings, saveSettings, loadPurchases, savePurchases, loadProfile, saveProfile, loadSessionDrafts, saveSessionDrafts, loadSessionPermissionModes, saveSessionPermissionModes } from "./persistence";
-import type { PermissionMode } from '@/components/PermissionModeSelector';
+import type { PermissionMode } from '@/sync/permissionTypes';
import type { CustomerInfo } from './revenueCat/types';
import React from "react";
import { sync } from "./sync";
@@ -102,6 +102,7 @@ interface StorageState {
applyMessages: (sessionId: string, messages: NormalizedMessage[]) => { changed: string[], hasReadyEvent: boolean };
applyMessagesLoaded: (sessionId: string) => void;
applySettings: (settings: Settings, version: number) => void;
+ replaceSettings: (settings: Settings, version: number) => void;
applySettingsLocal: (settings: Partial) => void;
applyLocalSettings: (settings: Partial) => void;
applyPurchases: (customerInfo: CustomerInfo) => void;
@@ -117,7 +118,7 @@ interface StorageState {
getActiveSessions: () => Session[];
updateSessionDraft: (sessionId: string, draft: string | null) => void;
updateSessionPermissionMode: (sessionId: string, mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo') => void;
- updateSessionModelMode: (sessionId: string, mode: 'default') => void;
+ updateSessionModelMode: (sessionId: string, mode: 'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite') => void;
// Artifact methods
applyArtifacts: (artifacts: DecryptedArtifact[]) => void;
addArtifact: (artifact: DecryptedArtifact) => void;
@@ -366,8 +367,6 @@ export const storage = create()((set, get) => {
listData.push(...inactiveSessions);
}
- // console.log(`📊 Storage: applySessions called with ${sessions.length} sessions, active: ${activeSessions.length}, inactive: ${inactiveSessions.length}`);
-
// Process AgentState updates for sessions that already have messages loaded
const updatedSessionMessages = { ...state.sessionMessages };
@@ -384,15 +383,6 @@ export const storage = create()((set, get) => {
const currentRealtimeSessionId = getCurrentRealtimeSessionId();
const voiceSession = getVoiceSession();
- // console.log('[REALTIME DEBUG] Permission check:', {
- // currentRealtimeSessionId,
- // sessionId: session.id,
- // match: currentRealtimeSessionId === session.id,
- // hasVoiceSession: !!voiceSession,
- // oldRequests: Object.keys(oldSession?.agentState?.requests || {}),
- // newRequests: Object.keys(newSession.agentState?.requests || {})
- // });
-
if (currentRealtimeSessionId === session.id && voiceSession) {
const oldRequests = oldSession?.agentState?.requests || {};
const newRequests = newSession.agentState?.requests || {};
@@ -402,7 +392,6 @@ export const storage = create()((set, get) => {
if (!oldRequests[requestId]) {
// This is a NEW permission request
const toolName = request.tool;
- // console.log('[REALTIME DEBUG] Sending permission notification for:', toolName);
voiceSession.sendTextMessage(
`Claude is requesting permission to use the ${toolName} tool`
);
@@ -629,7 +618,7 @@ export const storage = create()((set, get) => {
};
}),
applySettings: (settings: Settings, version: number) => set((state) => {
- if (state.settingsVersion === null || state.settingsVersion < version) {
+ if (state.settingsVersion == null || state.settingsVersion < version) {
saveSettings(settings, version);
return {
...state,
@@ -640,6 +629,14 @@ export const storage = create()((set, get) => {
return state;
}
}),
+ replaceSettings: (settings: Settings, version: number) => set((state) => {
+ saveSettings(settings, version);
+ return {
+ ...state,
+ settings,
+ settingsVersion: version
+ };
+ }),
applyLocalSettings: (delta: Partial) => set((state) => {
const updatedLocalSettings = applyLocalSettings(state.localSettings, delta);
saveLocalSettings(updatedLocalSettings);
@@ -808,7 +805,7 @@ export const storage = create()((set, get) => {
sessions: updatedSessions
};
}),
- updateSessionModelMode: (sessionId: string, mode: 'default') => set((state) => {
+ updateSessionModelMode: (sessionId: string, mode: 'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite') => set((state) => {
const session = state.sessions[sessionId];
if (!session) return state;
@@ -871,12 +868,10 @@ export const storage = create()((set, get) => {
}),
// Artifact methods
applyArtifacts: (artifacts: DecryptedArtifact[]) => set((state) => {
- console.log(`🗂️ Storage.applyArtifacts: Applying ${artifacts.length} artifacts`);
const mergedArtifacts = { ...state.artifacts };
artifacts.forEach(artifact => {
mergedArtifacts[artifact.id] = artifact;
});
- console.log(`🗂️ Storage.applyArtifacts: Total artifacts after merge: ${Object.keys(mergedArtifacts).length}`);
return {
...state,
diff --git a/sources/sync/storageTypes.ts b/sources/sync/storageTypes.ts
index bff187d04..f57b41c41 100644
--- a/sources/sync/storageTypes.ts
+++ b/sources/sync/storageTypes.ts
@@ -10,6 +10,7 @@ export const MetadataSchema = z.object({
version: z.string().optional(),
name: z.string().optional(),
os: z.string().optional(),
+ profileId: z.string().nullable().optional(), // Session-scoped profile identity (non-secret)
summary: z.object({
text: z.string(),
updatedAt: z.number()
@@ -70,7 +71,7 @@ export interface Session {
}>;
draft?: string | null; // Local draft message, not synced to server
permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo' | null; // Local permission mode, not synced to server
- modelMode?: 'default' | null; // Local model mode, not synced to server (models configured in CLI)
+ modelMode?: 'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite' | null; // Local model mode, not synced to server
// IMPORTANT: latestUsage is extracted from reducerState.latestUsage after message processing.
// We store it directly on Session to ensure it's available immediately on load.
// Do NOT store reducerState itself on Session - it's mutable and should only exist in SessionMessages.
diff --git a/sources/sync/sync.ts b/sources/sync/sync.ts
index fde7d5b02..e3b5c0df5 100644
--- a/sources/sync/sync.ts
+++ b/sources/sync/sync.ts
@@ -39,6 +39,7 @@ import { fetchFeed } from './apiFeed';
import { FeedItem } from './feedTypes';
import { UserProfile } from './friendTypes';
import { initializeTodoSync } from '../-zen/model/ops';
+import { buildOutgoingMessageMeta } from './messageMeta';
class Sync {
// Spawned agents (especially in spawn mode) can take noticeable time to connect.
@@ -223,9 +224,13 @@ class Sync {
return;
}
- // Read permission mode and model mode from session state
+ // Read permission mode from session state
const permissionMode = session.permissionMode || 'default';
- const modelMode = session.modelMode || 'default';
+
+ // Read model mode - for Gemini, default to gemini-2.5-pro if not set
+ const flavor = session.metadata?.flavor;
+ const isGemini = flavor === 'gemini';
+ const modelMode = session.modelMode || (isGemini ? 'gemini-2.5-pro' : 'default');
// Generate local ID
const localId = randomUUID();
@@ -247,10 +252,7 @@ class Sync {
sentFrom = 'web'; // fallback
}
- // Model settings - models are configured in CLI settings
- const model: string | null = null;
- const fallbackModel: string | null = null;
-
+ const model = isGemini && modelMode !== 'default' ? modelMode : undefined;
// Create user message content with metadata
const content: RawRecord = {
role: 'user',
@@ -258,14 +260,13 @@ class Sync {
type: 'text',
text
},
- meta: {
+ meta: buildOutgoingMessageMeta({
sentFrom,
permissionMode: permissionMode || 'default',
model,
- fallbackModel,
appendSystemPrompt: systemPrompt,
- ...(displayText && { displayText }) // Add displayText if provided
- }
+ displayText,
+ })
};
const encryptedRawRecord = await encryption.encryptRawRecord(content);
@@ -835,7 +836,6 @@ class Sync {
private fetchMachines = async () => {
if (!this.credentials) return;
- console.log('📊 Sync: Fetching machines...');
const API_ENDPOINT = getServerUrl();
const response = await fetch(`${API_ENDPOINT}/v1/machines`, {
headers: {
@@ -850,7 +850,6 @@ class Sync {
}
const data = await response.json();
- console.log(`📊 Sync: Fetched ${Array.isArray(data) ? data.length : 0} machines from server`);
const machines = data as Array<{
id: string;
metadata: string;
@@ -1173,7 +1172,7 @@ class Sync {
const mergedSettings = applySettings(serverSettings, this.pendingSettings);
// Update local storage with merged result at server's version
- storage.getState().applySettings(mergedSettings, data.currentVersion);
+ storage.getState().replaceSettings(mergedSettings, data.currentVersion);
// Sync tracking state with merged settings
if (tracking) {
@@ -1181,11 +1180,6 @@ class Sync {
}
// Log and retry
- console.log('settings version-mismatch, retrying', {
- serverVersion: data.currentVersion,
- retry: retryCount + 1,
- pendingKeys: Object.keys(this.pendingSettings)
- });
retryCount++;
continue;
} else {
@@ -1222,14 +1216,8 @@ class Sync {
parsedSettings = { ...settingsDefaults };
}
- // Log
- console.log('settings', JSON.stringify({
- settings: parsedSettings,
- version: data.settingsVersion
- }));
-
// Apply settings to storage
- storage.getState().applySettings(parsedSettings, data.settingsVersion);
+ storage.getState().replaceSettings(parsedSettings, data.settingsVersion);
// Sync PostHog opt-out state with settings
if (tracking) {
@@ -1259,16 +1247,6 @@ class Sync {
const data = await response.json();
const parsedProfile = profileParse(data);
- // Log profile data for debugging
- console.log('profile', JSON.stringify({
- id: parsedProfile.id,
- timestamp: parsedProfile.timestamp,
- firstName: parsedProfile.firstName,
- lastName: parsedProfile.lastName,
- hasAvatar: !!parsedProfile.avatar,
- hasGitHub: !!parsedProfile.github
- }));
-
// Apply profile to storage
storage.getState().applyProfile(parsedProfile);
}
@@ -1306,12 +1284,11 @@ class Sync {
});
if (!response.ok) {
- console.log(`[fetchNativeUpdate] Request failed: ${response.status}`);
+ log.log(`[fetchNativeUpdate] Request failed: ${response.status}`);
return;
}
const data = await response.json();
- console.log('[fetchNativeUpdate] Data:', data);
// Apply update status to storage
if (data.update_required && data.update_url) {
@@ -1325,7 +1302,7 @@ class Sync {
});
}
} catch (error) {
- console.log('[fetchNativeUpdate] Error:', error);
+ console.error('[fetchNativeUpdate] Error:', error);
storage.getState().applyNativeUpdateStatus(null);
}
}
@@ -1346,7 +1323,6 @@ class Sync {
}
if (!apiKey) {
- console.log(`RevenueCat: No API key found for platform ${Platform.OS}`);
return;
}
@@ -1363,7 +1339,6 @@ class Sync {
});
this.revenueCatInitialized = true;
- console.log('RevenueCat initialized successfully');
}
// Sync purchases
@@ -1430,9 +1405,6 @@ class Sync {
}
}
}
- console.log('Batch decrypted and normalized messages in', Date.now() - start, 'ms');
- console.log('normalizedMessages', JSON.stringify(normalizedMessages));
- // console.log('messages', JSON.stringify(normalizedMessages));
// Apply to storage
this.applyMessages(sessionId, normalizedMessages);
@@ -1459,7 +1431,7 @@ class Sync {
log.log('finalStatus: ' + JSON.stringify(finalStatus));
if (finalStatus !== 'granted') {
- console.log('Failed to get push token for push notification!');
+ log.log('Failed to get push token for push notification!');
return;
}
@@ -1507,15 +1479,12 @@ class Sync {
}
private handleUpdate = async (update: unknown) => {
- console.log('🔄 Sync: handleUpdate called with:', JSON.stringify(update).substring(0, 300));
const validatedUpdate = ApiUpdateContainerSchema.safeParse(update);
if (!validatedUpdate.success) {
- console.log('❌ Sync: Invalid update received:', validatedUpdate.error);
console.error('❌ Sync: Invalid update data:', update);
return;
}
const updateData = validatedUpdate.data;
- console.log(`🔄 Sync: Validated update type: ${updateData.body.t}`);
if (updateData.body.t === 'new-message') {
@@ -1534,13 +1503,38 @@ class Sync {
if (decrypted) {
lastMessage = normalizeRawMessage(decrypted.id, decrypted.localId, decrypted.createdAt, decrypted.content);
+ // Check for task lifecycle events to update thinking state
+ // This ensures UI updates even if volatile activity updates are lost
+ const rawContent = decrypted.content as { role?: string; content?: { type?: string; data?: { type?: string } } } | null;
+ const contentType = rawContent?.content?.type;
+ const dataType = rawContent?.content?.data?.type;
+
+ // Debug logging to trace lifecycle events
+ if (dataType === 'task_complete' || dataType === 'turn_aborted' || dataType === 'task_started') {
+ console.log(`🔄 [Sync] Lifecycle event detected: contentType=${contentType}, dataType=${dataType}`);
+ }
+
+ const isTaskComplete =
+ ((contentType === 'acp' || contentType === 'codex') &&
+ (dataType === 'task_complete' || dataType === 'turn_aborted'));
+
+ const isTaskStarted =
+ ((contentType === 'acp' || contentType === 'codex') && dataType === 'task_started');
+
+ if (isTaskComplete || isTaskStarted) {
+ console.log(`🔄 [Sync] Updating thinking state: isTaskComplete=${isTaskComplete}, isTaskStarted=${isTaskStarted}`);
+ }
+
// Update session
const session = storage.getState().sessions[updateData.body.sid];
if (session) {
this.applySessions([{
...session,
updatedAt: updateData.createdAt,
- seq: updateData.seq
+ seq: updateData.seq,
+ // Update thinking state based on task lifecycle events
+ ...(isTaskComplete ? { thinking: false } : {}),
+ ...(isTaskStarted ? { thinking: true } : {})
}])
} else {
// Fetch sessions again if we don't have this session
@@ -1549,7 +1543,6 @@ class Sync {
// Update messages
if (lastMessage) {
- console.log('🔄 Sync: Applying message:', JSON.stringify(lastMessage));
this.applyMessages(updateData.body.sid, [lastMessage]);
let hasMutableTool = false;
if (lastMessage.role === 'agent' && lastMessage.content[0] && lastMessage.content[0].type === 'tool-result') {
@@ -1935,7 +1928,6 @@ class Sync {
}
if (sessions.length > 0) {
- // console.log('flushing activity updates ' + sessions.length);
this.applySessions(sessions);
// log.log(`🔄 Activity updates flushed - updated ${sessions.length} sessions`);
}
@@ -1944,17 +1936,13 @@ class Sync {
private handleEphemeralUpdate = (update: unknown) => {
const validatedUpdate = ApiEphemeralUpdateSchema.safeParse(update);
if (!validatedUpdate.success) {
- console.log('Invalid ephemeral update received:', validatedUpdate.error);
console.error('Invalid ephemeral update received:', update);
return;
- } else {
- // console.log('Ephemeral update received:', update);
}
const updateData = validatedUpdate.data;
// Process activity updates through smart debounce accumulator
if (updateData.type === 'activity') {
- // console.log('adding activity update ' + updateData.id);
this.activityAccumulator.addUpdate(updateData);
}
diff --git a/sources/sync/typesRaw.ts b/sources/sync/typesRaw.ts
index 4dde5e855..7921435b2 100644
--- a/sources/sync/typesRaw.ts
+++ b/sources/sync/typesRaw.ts
@@ -47,7 +47,9 @@ export type RawToolUseContent = z.infer;
const rawToolResultContentSchema = z.object({
type: z.literal('tool_result'),
tool_use_id: z.string(),
- content: z.union([z.array(z.object({ type: z.literal('text'), text: z.string() })), z.string()]),
+ // Tool results can be strings, Claude-style arrays of text blocks, or structured JSON (Codex/Gemini).
+ // We accept any here and normalize later for display.
+ content: z.any(),
is_error: z.boolean().optional(),
permissions: z.object({
date: z.number(),
@@ -206,6 +208,68 @@ const rawAgentRecordSchema = z.discriminatedUnion('type', [z.object({
id: z.string()
})
])
+}), z.object({
+ // ACP (Agent Communication Protocol) - unified format for all agent providers
+ type: z.literal('acp'),
+ provider: z.enum(['gemini', 'codex', 'claude', 'opencode']),
+ data: z.discriminatedUnion('type', [
+ // Core message types
+ z.object({ type: z.literal('reasoning'), message: z.string() }),
+ z.object({ type: z.literal('message'), message: z.string() }),
+ z.object({ type: z.literal('thinking'), text: z.string() }),
+ // Tool interactions
+ z.object({
+ type: z.literal('tool-call'),
+ callId: z.string(),
+ input: z.any(),
+ name: z.string(),
+ id: z.string()
+ }),
+ z.object({
+ type: z.literal('tool-result'),
+ callId: z.string(),
+ output: z.any(),
+ id: z.string(),
+ isError: z.boolean().optional()
+ }),
+ // Hyphenated tool-call-result (for backwards compatibility with CLI)
+ z.object({
+ type: z.literal('tool-call-result'),
+ callId: z.string(),
+ output: z.any(),
+ id: z.string()
+ }),
+ // File operations
+ z.object({
+ type: z.literal('file-edit'),
+ description: z.string(),
+ filePath: z.string(),
+ diff: z.string().optional(),
+ oldContent: z.string().optional(),
+ newContent: z.string().optional(),
+ id: z.string()
+ }),
+ // Terminal/command output
+ z.object({
+ type: z.literal('terminal-output'),
+ data: z.string(),
+ callId: z.string()
+ }),
+ // Task lifecycle events
+ z.object({ type: z.literal('task_started'), id: z.string() }),
+ z.object({ type: z.literal('task_complete'), id: z.string() }),
+ z.object({ type: z.literal('turn_aborted'), id: z.string() }),
+ // Permissions
+ z.object({
+ type: z.literal('permission-request'),
+ permissionId: z.string(),
+ toolName: z.string(),
+ description: z.string(),
+ options: z.any().optional()
+ }),
+ // Usage/metrics
+ z.object({ type: z.literal('token_count') }).passthrough()
+ ])
})]);
/**
@@ -340,13 +404,46 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA
// Zod transform handles normalization during validation
let parsed = rawRecordSchema.safeParse(raw);
if (!parsed.success) {
- console.error('=== VALIDATION ERROR ===');
- console.error('Zod issues:', JSON.stringify(parsed.error.issues, null, 2));
- console.error('Raw message:', JSON.stringify(raw, null, 2));
- console.error('=== END ERROR ===');
+ // Never log full raw messages in production: tool outputs and user text may contain secrets.
+ // Keep enough context for debugging in dev builds only.
+ console.error(`[typesRaw] Message validation failed (id=${id})`);
+ if (__DEV__) {
+ console.error('Zod issues:', JSON.stringify(parsed.error.issues, null, 2));
+ console.error('Raw summary:', {
+ role: raw?.role,
+ contentType: (raw as any)?.content?.type,
+ });
+ }
return null;
}
raw = parsed.data;
+
+ const toolResultContentToText = (content: unknown): string => {
+ if (content === null || content === undefined) return '';
+ if (typeof content === 'string') return content;
+
+ // Claude sometimes sends tool_result.content as [{ type: 'text', text: '...' }]
+ if (Array.isArray(content)) {
+ const maybeTextBlocks = content as Array<{ type?: unknown; text?: unknown }>;
+ const isTextBlocks = maybeTextBlocks.every((b) => b && typeof b === 'object' && b.type === 'text' && typeof b.text === 'string');
+ if (isTextBlocks) {
+ return maybeTextBlocks.map((b) => b.text as string).join('');
+ }
+
+ try {
+ return JSON.stringify(content);
+ } catch {
+ return String(content);
+ }
+ }
+
+ try {
+ return JSON.stringify(content);
+ } catch {
+ return String(content);
+ }
+ };
+
if (raw.role === 'user') {
return {
id,
@@ -463,10 +560,11 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA
} else {
for (let c of raw.content.data.message.content) {
if (c.type === 'tool_result') {
+ const rawResultContent = raw.content.data.toolUseResult ?? c.content;
content.push({
...c, // WOLOG: Preserve all fields including unknown ones
type: 'tool-result',
- content: raw.content.data.toolUseResult ? raw.content.data.toolUseResult : (typeof c.content === 'string' ? c.content : c.content[0].text),
+ content: toolResultContentToText(rawResultContent),
is_error: c.is_error || false,
uuid: raw.content.data.uuid,
parentUUID: raw.content.data.parentUuid ?? null,
@@ -559,6 +657,97 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA
}
if (raw.content.data.type === 'tool-call-result') {
// Cast tool call results to agent tool-result messages
+ return {
+ id,
+ localId,
+ createdAt,
+ role: 'agent',
+ isSidechain: false,
+ content: [{
+ type: 'tool-result',
+ tool_use_id: raw.content.data.callId,
+ content: toolResultContentToText(raw.content.data.output),
+ is_error: false,
+ uuid: raw.content.data.id,
+ parentUUID: null
+ }],
+ meta: raw.meta
+ } satisfies NormalizedMessage;
+ }
+ }
+ // ACP (Agent Communication Protocol) - unified format for all agent providers
+ if (raw.content.type === 'acp') {
+ if (raw.content.data.type === 'message') {
+ return {
+ id,
+ localId,
+ createdAt,
+ role: 'agent',
+ isSidechain: false,
+ content: [{
+ type: 'text',
+ text: raw.content.data.message,
+ uuid: id,
+ parentUUID: null
+ }],
+ meta: raw.meta
+ } satisfies NormalizedMessage;
+ }
+ if (raw.content.data.type === 'reasoning') {
+ return {
+ id,
+ localId,
+ createdAt,
+ role: 'agent',
+ isSidechain: false,
+ content: [{
+ type: 'text',
+ text: raw.content.data.message,
+ uuid: id,
+ parentUUID: null
+ }],
+ meta: raw.meta
+ } satisfies NormalizedMessage;
+ }
+ if (raw.content.data.type === 'tool-call') {
+ return {
+ id,
+ localId,
+ createdAt,
+ role: 'agent',
+ isSidechain: false,
+ content: [{
+ type: 'tool-call',
+ id: raw.content.data.callId,
+ name: raw.content.data.name || 'unknown',
+ input: raw.content.data.input,
+ description: null,
+ uuid: raw.content.data.id,
+ parentUUID: null
+ }],
+ meta: raw.meta
+ } satisfies NormalizedMessage;
+ }
+ if (raw.content.data.type === 'tool-result') {
+ return {
+ id,
+ localId,
+ createdAt,
+ role: 'agent',
+ isSidechain: false,
+ content: [{
+ type: 'tool-result',
+ tool_use_id: raw.content.data.callId,
+ content: raw.content.data.output,
+ is_error: raw.content.data.isError ?? false,
+ uuid: raw.content.data.id,
+ parentUUID: null
+ }],
+ meta: raw.meta
+ } satisfies NormalizedMessage;
+ }
+ // Handle hyphenated tool-call-result (backwards compatibility)
+ if (raw.content.data.type === 'tool-call-result') {
return {
id,
localId,
@@ -576,6 +765,89 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA
meta: raw.meta
} satisfies NormalizedMessage;
}
+ if (raw.content.data.type === 'thinking') {
+ return {
+ id,
+ localId,
+ createdAt,
+ role: 'agent',
+ isSidechain: false,
+ content: [{
+ type: 'thinking',
+ thinking: raw.content.data.text,
+ uuid: id,
+ parentUUID: null
+ }],
+ meta: raw.meta
+ } satisfies NormalizedMessage;
+ }
+ if (raw.content.data.type === 'file-edit') {
+ // Map file-edit to tool-call for UI rendering
+ return {
+ id,
+ localId,
+ createdAt,
+ role: 'agent',
+ isSidechain: false,
+ content: [{
+ type: 'tool-call',
+ id: raw.content.data.id,
+ name: 'file-edit',
+ input: {
+ filePath: raw.content.data.filePath,
+ description: raw.content.data.description,
+ diff: raw.content.data.diff,
+ oldContent: raw.content.data.oldContent,
+ newContent: raw.content.data.newContent
+ },
+ description: raw.content.data.description,
+ uuid: raw.content.data.id,
+ parentUUID: null
+ }],
+ meta: raw.meta
+ } satisfies NormalizedMessage;
+ }
+ if (raw.content.data.type === 'terminal-output') {
+ // Map terminal-output to tool-result
+ return {
+ id,
+ localId,
+ createdAt,
+ role: 'agent',
+ isSidechain: false,
+ content: [{
+ type: 'tool-result',
+ tool_use_id: raw.content.data.callId,
+ content: raw.content.data.data,
+ is_error: false,
+ uuid: id,
+ parentUUID: null
+ }],
+ meta: raw.meta
+ } satisfies NormalizedMessage;
+ }
+ if (raw.content.data.type === 'permission-request') {
+ // Map permission-request to tool-call for UI to show permission dialog
+ return {
+ id,
+ localId,
+ createdAt,
+ role: 'agent',
+ isSidechain: false,
+ content: [{
+ type: 'tool-call',
+ id: raw.content.data.permissionId,
+ name: raw.content.data.toolName,
+ input: raw.content.data.options ?? {},
+ description: raw.content.data.description,
+ uuid: id,
+ parentUUID: null
+ }],
+ meta: raw.meta
+ } satisfies NormalizedMessage;
+ }
+ // Task lifecycle events (task_started, task_complete, turn_aborted) and token_count
+ // are status/metrics - skip normalization, they don't need UI rendering
}
}
return null;
diff --git a/sources/text/README.md b/sources/text/README.md
index 09128f3ef..9c40002fc 100644
--- a/sources/text/README.md
+++ b/sources/text/README.md
@@ -82,8 +82,8 @@ t('invalid.key') // Error: Key doesn't exist
## Files Structure
-### `_default.ts`
-Contains the main translation object with mixed string/function values:
+### `translations/en.ts`
+Contains the canonical English translation object with mixed string/function values:
```typescript
export const en = {
@@ -97,6 +97,9 @@ export const en = {
} as const;
```
+### `_types.ts`
+Contains the TypeScript types derived from the English translation structure.
+
### `index.ts`
Main module with the `t` function and utilities:
- `t()` - Main translation function with strict typing
@@ -164,7 +167,7 @@ The API stays the same, but you get:
## Adding New Translations
-1. **Add to `_default.ts`**:
+1. **Add to `translations/en.ts`**:
```typescript
// String constant
newConstant: 'My New Text',
@@ -220,4 +223,4 @@ To add more languages:
3. Add locale switching logic
4. All existing type safety is preserved
-This implementation provides a solid foundation that can scale while maintaining perfect type safety and developer experience.
\ No newline at end of file
+This implementation provides a solid foundation that can scale while maintaining perfect type safety and developer experience.
diff --git a/sources/text/_default.ts b/sources/text/_default.ts
deleted file mode 100644
index 66ed2cbee..000000000
--- a/sources/text/_default.ts
+++ /dev/null
@@ -1,937 +0,0 @@
-/**
- * English translations for the Happy app
- * Values can be:
- * - String constants for static text
- * - Functions with typed object parameters for dynamic text
- */
-
-/**
- * English plural helper function
- * @param options - Object containing count, singular, and plural forms
- * @returns The appropriate form based on count
- */
-function plural({ count, singular, plural }: { count: number; singular: string; plural: string }): string {
- return count === 1 ? singular : plural;
-}
-
-export const en = {
- tabs: {
- // Tab navigation labels
- inbox: 'Inbox',
- sessions: 'Terminals',
- settings: 'Settings',
- },
-
- inbox: {
- // Inbox screen
- emptyTitle: 'Empty Inbox',
- emptyDescription: 'Connect with friends to start sharing sessions',
- updates: 'Updates',
- },
-
- common: {
- // Simple string constants
- cancel: 'Cancel',
- authenticate: 'Authenticate',
- save: 'Save',
- saveAs: 'Save As',
- error: 'Error',
- success: 'Success',
- ok: 'OK',
- continue: 'Continue',
- back: 'Back',
- create: 'Create',
- rename: 'Rename',
- reset: 'Reset',
- logout: 'Logout',
- yes: 'Yes',
- no: 'No',
- discard: 'Discard',
- version: 'Version',
- copied: 'Copied',
- copy: 'Copy',
- scanning: 'Scanning...',
- urlPlaceholder: 'https://example.com',
- home: 'Home',
- message: 'Message',
- files: 'Files',
- fileViewer: 'File Viewer',
- loading: 'Loading...',
- retry: 'Retry',
- delete: 'Delete',
- optional: 'optional',
- },
-
- profile: {
- userProfile: 'User Profile',
- details: 'Details',
- firstName: 'First Name',
- lastName: 'Last Name',
- username: 'Username',
- status: 'Status',
- },
-
- status: {
- connected: 'connected',
- connecting: 'connecting',
- disconnected: 'disconnected',
- error: 'error',
- online: 'online',
- offline: 'offline',
- lastSeen: ({ time }: { time: string }) => `last seen ${time}`,
- permissionRequired: 'permission required',
- activeNow: 'Active now',
- unknown: 'unknown',
- },
-
- time: {
- justNow: 'just now',
- minutesAgo: ({ count }: { count: number }) => `${count} minute${count !== 1 ? 's' : ''} ago`,
- hoursAgo: ({ count }: { count: number }) => `${count} hour${count !== 1 ? 's' : ''} ago`,
- },
-
- connect: {
- restoreAccount: 'Restore Account',
- enterSecretKey: 'Please enter a secret key',
- invalidSecretKey: 'Invalid secret key. Please check and try again.',
- enterUrlManually: 'Enter URL manually',
- },
-
- settings: {
- title: 'Settings',
- connectedAccounts: 'Connected Accounts',
- connectAccount: 'Connect account',
- github: 'GitHub',
- machines: 'Machines',
- features: 'Features',
- social: 'Social',
- account: 'Account',
- accountSubtitle: 'Manage your account details',
- appearance: 'Appearance',
- appearanceSubtitle: 'Customize how the app looks',
- voiceAssistant: 'Voice Assistant',
- voiceAssistantSubtitle: 'Configure voice interaction preferences',
- featuresTitle: 'Features',
- featuresSubtitle: 'Enable or disable app features',
- developer: 'Developer',
- developerTools: 'Developer Tools',
- about: 'About',
- aboutFooter: 'Happy Coder is a Codex and Claude Code mobile client. It\'s fully end-to-end encrypted and your account is stored only on your device. Not affiliated with Anthropic.',
- whatsNew: 'What\'s New',
- whatsNewSubtitle: 'See the latest updates and improvements',
- reportIssue: 'Report an Issue',
- privacyPolicy: 'Privacy Policy',
- termsOfService: 'Terms of Service',
- eula: 'EULA',
- supportUs: 'Support us',
- supportUsSubtitlePro: 'Thank you for your support!',
- supportUsSubtitle: 'Support project development',
- scanQrCodeToAuthenticate: 'Scan QR code to authenticate',
- githubConnected: ({ login }: { login: string }) => `Connected as @${login}`,
- connectGithubAccount: 'Connect your GitHub account',
- claudeAuthSuccess: 'Successfully connected to Claude',
- exchangingTokens: 'Exchanging tokens...',
- usage: 'Usage',
- usageSubtitle: 'View your API usage and costs',
- profiles: 'Profiles',
- profilesSubtitle: 'Manage environment variable profiles for sessions',
-
- // Dynamic settings messages
- accountConnected: ({ service }: { service: string }) => `${service} account connected`,
- machineStatus: ({ name, status }: { name: string; status: 'online' | 'offline' }) =>
- `${name} is ${status}`,
- featureToggled: ({ feature, enabled }: { feature: string; enabled: boolean }) =>
- `${feature} ${enabled ? 'enabled' : 'disabled'}`,
- },
-
- settingsAppearance: {
- // Appearance settings screen
- theme: 'Theme',
- themeDescription: 'Choose your preferred color scheme',
- themeOptions: {
- adaptive: 'Adaptive',
- light: 'Light',
- dark: 'Dark',
- },
- themeDescriptions: {
- adaptive: 'Match system settings',
- light: 'Always use light theme',
- dark: 'Always use dark theme',
- },
- display: 'Display',
- displayDescription: 'Control layout and spacing',
- inlineToolCalls: 'Inline Tool Calls',
- inlineToolCallsDescription: 'Display tool calls directly in chat messages',
- expandTodoLists: 'Expand Todo Lists',
- expandTodoListsDescription: 'Show all todos instead of just changes',
- showLineNumbersInDiffs: 'Show Line Numbers in Diffs',
- showLineNumbersInDiffsDescription: 'Display line numbers in code diffs',
- showLineNumbersInToolViews: 'Show Line Numbers in Tool Views',
- showLineNumbersInToolViewsDescription: 'Display line numbers in tool view diffs',
- wrapLinesInDiffs: 'Wrap Lines in Diffs',
- wrapLinesInDiffsDescription: 'Wrap long lines instead of horizontal scrolling in diff views',
- alwaysShowContextSize: 'Always Show Context Size',
- alwaysShowContextSizeDescription: 'Display context usage even when not near limit',
- avatarStyle: 'Avatar Style',
- avatarStyleDescription: 'Choose session avatar appearance',
- avatarOptions: {
- pixelated: 'Pixelated',
- gradient: 'Gradient',
- brutalist: 'Brutalist',
- },
- showFlavorIcons: 'Show AI Provider Icons',
- showFlavorIconsDescription: 'Display AI provider icons on session avatars',
- compactSessionView: 'Compact Session View',
- compactSessionViewDescription: 'Show active sessions in a more compact layout',
- },
-
- settingsFeatures: {
- // Features settings screen
- experiments: 'Experiments',
- experimentsDescription: 'Enable experimental features that are still in development. These features may be unstable or change without notice.',
- experimentalFeatures: 'Experimental Features',
- experimentalFeaturesEnabled: 'Experimental features enabled',
- experimentalFeaturesDisabled: 'Using stable features only',
- webFeatures: 'Web Features',
- webFeaturesDescription: 'Features available only in the web version of the app.',
- enterToSend: 'Enter to Send',
- enterToSendEnabled: 'Press Enter to send (Shift+Enter for a new line)',
- enterToSendDisabled: 'Enter inserts a new line',
- commandPalette: 'Command Palette',
- commandPaletteEnabled: 'Press ⌘K to open',
- commandPaletteDisabled: 'Quick command access disabled',
- markdownCopyV2: 'Markdown Copy v2',
- markdownCopyV2Subtitle: 'Long press opens copy modal',
- hideInactiveSessions: 'Hide inactive sessions',
- hideInactiveSessionsSubtitle: 'Show only active chats in your list',
- enhancedSessionWizard: 'Enhanced Session Wizard',
- enhancedSessionWizardEnabled: 'Profile-first session launcher active',
- enhancedSessionWizardDisabled: 'Using standard session launcher',
- },
-
- errors: {
- networkError: 'Network error occurred',
- serverError: 'Server error occurred',
- unknownError: 'An unknown error occurred',
- connectionTimeout: 'Connection timed out',
- authenticationFailed: 'Authentication failed',
- permissionDenied: 'Permission denied',
- fileNotFound: 'File not found',
- invalidFormat: 'Invalid format',
- operationFailed: 'Operation failed',
- tryAgain: 'Please try again',
- contactSupport: 'Contact support if the problem persists',
- sessionNotFound: 'Session not found',
- voiceSessionFailed: 'Failed to start voice session',
- voiceServiceUnavailable: 'Voice service is temporarily unavailable',
- oauthInitializationFailed: 'Failed to initialize OAuth flow',
- tokenStorageFailed: 'Failed to store authentication tokens',
- oauthStateMismatch: 'Security validation failed. Please try again',
- tokenExchangeFailed: 'Failed to exchange authorization code',
- oauthAuthorizationDenied: 'Authorization was denied',
- webViewLoadFailed: 'Failed to load authentication page',
- failedToLoadProfile: 'Failed to load user profile',
- userNotFound: 'User not found',
- sessionDeleted: 'Session has been deleted',
- sessionDeletedDescription: 'This session has been permanently removed',
-
- // Error functions with context
- fieldError: ({ field, reason }: { field: string; reason: string }) =>
- `${field}: ${reason}`,
- validationError: ({ field, min, max }: { field: string; min: number; max: number }) =>
- `${field} must be between ${min} and ${max}`,
- retryIn: ({ seconds }: { seconds: number }) =>
- `Retry in ${seconds} ${seconds === 1 ? 'second' : 'seconds'}`,
- errorWithCode: ({ message, code }: { message: string; code: number | string }) =>
- `${message} (Error ${code})`,
- disconnectServiceFailed: ({ service }: { service: string }) =>
- `Failed to disconnect ${service}`,
- connectServiceFailed: ({ service }: { service: string }) =>
- `Failed to connect ${service}. Please try again.`,
- failedToLoadFriends: 'Failed to load friends list',
- failedToAcceptRequest: 'Failed to accept friend request',
- failedToRejectRequest: 'Failed to reject friend request',
- failedToRemoveFriend: 'Failed to remove friend',
- searchFailed: 'Search failed. Please try again.',
- failedToSendRequest: 'Failed to send friend request',
- },
-
- newSession: {
- // Used by new-session screen and launch flows
- title: 'Start New Session',
- noMachinesFound: 'No machines found. Start a Happy session on your computer first.',
- allMachinesOffline: 'All machines appear offline',
- machineDetails: 'View machine details →',
- directoryDoesNotExist: 'Directory Not Found',
- createDirectoryConfirm: ({ directory }: { directory: string }) => `The directory ${directory} does not exist. Do you want to create it?`,
- sessionStarted: 'Session Started',
- sessionStartedMessage: 'The session has been started successfully.',
- sessionSpawningFailed: 'Session spawning failed - no session ID returned.',
- startingSession: 'Starting session...',
- startNewSessionInFolder: 'New session here',
- failedToStart: 'Failed to start session. Make sure the daemon is running on the target machine.',
- sessionTimeout: 'Session startup timed out. The machine may be slow or the daemon may not be responding.',
- notConnectedToServer: 'Not connected to server. Check your internet connection.',
- noMachineSelected: 'Please select a machine to start the session',
- noPathSelected: 'Please select a directory to start the session in',
- sessionType: {
- title: 'Session Type',
- simple: 'Simple',
- worktree: 'Worktree',
- comingSoon: 'Coming soon',
- },
- worktree: {
- creating: ({ name }: { name: string }) => `Creating worktree '${name}'...`,
- notGitRepo: 'Worktrees require a git repository',
- failed: ({ error }: { error: string }) => `Failed to create worktree: ${error}`,
- success: 'Worktree created successfully',
- }
- },
-
- sessionHistory: {
- // Used by session history screen
- title: 'Session History',
- empty: 'No sessions found',
- today: 'Today',
- yesterday: 'Yesterday',
- daysAgo: ({ count }: { count: number }) => `${count} ${count === 1 ? 'day' : 'days'} ago`,
- viewAll: 'View all sessions',
- },
-
- session: {
- inputPlaceholder: 'Type a message ...',
- },
-
- commandPalette: {
- placeholder: 'Type a command or search...',
- },
-
- server: {
- // Used by Server Configuration screen (app/(app)/server.tsx)
- serverConfiguration: 'Server Configuration',
- enterServerUrl: 'Please enter a server URL',
- notValidHappyServer: 'Not a valid Happy Server',
- changeServer: 'Change Server',
- continueWithServer: 'Continue with this server?',
- resetToDefault: 'Reset to Default',
- resetServerDefault: 'Reset server to default?',
- validating: 'Validating...',
- validatingServer: 'Validating server...',
- serverReturnedError: 'Server returned an error',
- failedToConnectToServer: 'Failed to connect to server',
- currentlyUsingCustomServer: 'Currently using custom server',
- customServerUrlLabel: 'Custom Server URL',
- advancedFeatureFooter: "This is an advanced feature. Only change the server if you know what you're doing. You will need to log out and log in again after changing servers."
- },
-
- sessionInfo: {
- // Used by Session Info screen (app/(app)/session/[id]/info.tsx)
- killSession: 'Kill Session',
- killSessionConfirm: 'Are you sure you want to terminate this session?',
- archiveSession: 'Archive Session',
- archiveSessionConfirm: 'Are you sure you want to archive this session?',
- happySessionIdCopied: 'Happy Session ID copied to clipboard',
- failedToCopySessionId: 'Failed to copy Happy Session ID',
- happySessionId: 'Happy Session ID',
- claudeCodeSessionId: 'Claude Code Session ID',
- claudeCodeSessionIdCopied: 'Claude Code Session ID copied to clipboard',
- aiProvider: 'AI Provider',
- failedToCopyClaudeCodeSessionId: 'Failed to copy Claude Code Session ID',
- metadataCopied: 'Metadata copied to clipboard',
- failedToCopyMetadata: 'Failed to copy metadata',
- failedToKillSession: 'Failed to kill session',
- failedToArchiveSession: 'Failed to archive session',
- connectionStatus: 'Connection Status',
- created: 'Created',
- lastUpdated: 'Last Updated',
- sequence: 'Sequence',
- quickActions: 'Quick Actions',
- viewMachine: 'View Machine',
- viewMachineSubtitle: 'View machine details and sessions',
- killSessionSubtitle: 'Immediately terminate the session',
- archiveSessionSubtitle: 'Archive this session and stop it',
- metadata: 'Metadata',
- host: 'Host',
- path: 'Path',
- operatingSystem: 'Operating System',
- processId: 'Process ID',
- happyHome: 'Happy Home',
- copyMetadata: 'Copy Metadata',
- agentState: 'Agent State',
- controlledByUser: 'Controlled by User',
- pendingRequests: 'Pending Requests',
- activity: 'Activity',
- thinking: 'Thinking',
- thinkingSince: 'Thinking Since',
- cliVersion: 'CLI Version',
- cliVersionOutdated: 'CLI Update Required',
- cliVersionOutdatedMessage: ({ currentVersion, requiredVersion }: { currentVersion: string; requiredVersion: string }) =>
- `Version ${currentVersion} installed. Update to ${requiredVersion} or later`,
- updateCliInstructions: 'Please run npm install -g happy-coder@latest',
- deleteSession: 'Delete Session',
- deleteSessionSubtitle: 'Permanently remove this session',
- deleteSessionConfirm: 'Delete Session Permanently?',
- deleteSessionWarning: 'This action cannot be undone. All messages and data associated with this session will be permanently deleted.',
- failedToDeleteSession: 'Failed to delete session',
- sessionDeleted: 'Session deleted successfully',
-
- },
-
- components: {
- emptyMainScreen: {
- // Used by EmptyMainScreen component
- readyToCode: 'Ready to code?',
- installCli: 'Install the Happy CLI',
- runIt: 'Run it',
- scanQrCode: 'Scan the QR code',
- openCamera: 'Open Camera',
- },
- },
-
- agentInput: {
- permissionMode: {
- title: 'PERMISSION MODE',
- default: 'Default',
- acceptEdits: 'Accept Edits',
- plan: 'Plan Mode',
- bypassPermissions: 'Yolo Mode',
- badgeAcceptAllEdits: 'Accept All Edits',
- badgeBypassAllPermissions: 'Bypass All Permissions',
- badgePlanMode: 'Plan Mode',
- },
- agent: {
- claude: 'Claude',
- codex: 'Codex',
- gemini: 'Gemini',
- },
- model: {
- title: 'MODEL',
- configureInCli: 'Configure models in CLI settings',
- },
- codexPermissionMode: {
- title: 'CODEX PERMISSION MODE',
- default: 'CLI Settings',
- readOnly: 'Read Only Mode',
- safeYolo: 'Safe YOLO',
- yolo: 'YOLO',
- badgeReadOnly: 'Read Only Mode',
- badgeSafeYolo: 'Safe YOLO',
- badgeYolo: 'YOLO',
- },
- codexModel: {
- title: 'CODEX MODEL',
- gpt5CodexLow: 'gpt-5-codex low',
- gpt5CodexMedium: 'gpt-5-codex medium',
- gpt5CodexHigh: 'gpt-5-codex high',
- gpt5Minimal: 'GPT-5 Minimal',
- gpt5Low: 'GPT-5 Low',
- gpt5Medium: 'GPT-5 Medium',
- gpt5High: 'GPT-5 High',
- },
- geminiPermissionMode: {
- title: 'GEMINI PERMISSION MODE',
- default: 'Default',
- acceptEdits: 'Accept Edits',
- plan: 'Plan Mode',
- bypassPermissions: 'Yolo Mode',
- badgeAcceptAllEdits: 'Accept All Edits',
- badgeBypassAllPermissions: 'Bypass All Permissions',
- badgePlanMode: 'Plan Mode',
- },
- context: {
- remaining: ({ percent }: { percent: number }) => `${percent}% left`,
- },
- suggestion: {
- fileLabel: 'FILE',
- folderLabel: 'FOLDER',
- },
- noMachinesAvailable: 'No machines',
- },
-
- machineLauncher: {
- showLess: 'Show less',
- showAll: ({ count }: { count: number }) => `Show all (${count} paths)`,
- enterCustomPath: 'Enter custom path',
- offlineUnableToSpawn: 'Unable to spawn new session, offline',
- },
-
- sidebar: {
- sessionsTitle: 'Happy',
- },
-
- toolView: {
- input: 'Input',
- output: 'Output',
- },
-
- tools: {
- fullView: {
- description: 'Description',
- inputParams: 'Input Parameters',
- output: 'Output',
- error: 'Error',
- completed: 'Tool completed successfully',
- noOutput: 'No output was produced',
- running: 'Tool is running...',
- rawJsonDevMode: 'Raw JSON (Dev Mode)',
- },
- taskView: {
- initializing: 'Initializing agent...',
- moreTools: ({ count }: { count: number }) => `+${count} more ${plural({ count, singular: 'tool', plural: 'tools' })}`,
- },
- multiEdit: {
- editNumber: ({ index, total }: { index: number; total: number }) => `Edit ${index} of ${total}`,
- replaceAll: 'Replace All',
- },
- names: {
- task: 'Task',
- terminal: 'Terminal',
- searchFiles: 'Search Files',
- search: 'Search',
- searchContent: 'Search Content',
- listFiles: 'List Files',
- planProposal: 'Plan proposal',
- readFile: 'Read File',
- editFile: 'Edit File',
- writeFile: 'Write File',
- fetchUrl: 'Fetch URL',
- readNotebook: 'Read Notebook',
- editNotebook: 'Edit Notebook',
- todoList: 'Todo List',
- webSearch: 'Web Search',
- reasoning: 'Reasoning',
- applyChanges: 'Update file',
- viewDiff: 'Current file changes',
- question: 'Question',
- },
- askUserQuestion: {
- submit: 'Submit Answer',
- multipleQuestions: ({ count }: { count: number }) => `${count} questions`,
- },
- desc: {
- terminalCmd: ({ cmd }: { cmd: string }) => `Terminal(cmd: ${cmd})`,
- searchPattern: ({ pattern }: { pattern: string }) => `Search(pattern: ${pattern})`,
- searchPath: ({ basename }: { basename: string }) => `Search(path: ${basename})`,
- fetchUrlHost: ({ host }: { host: string }) => `Fetch URL(url: ${host})`,
- editNotebookMode: ({ path, mode }: { path: string; mode: string }) => `Edit Notebook(file: ${path}, mode: ${mode})`,
- todoListCount: ({ count }: { count: number }) => `Todo List(count: ${count})`,
- webSearchQuery: ({ query }: { query: string }) => `Web Search(query: ${query})`,
- grepPattern: ({ pattern }: { pattern: string }) => `grep(pattern: ${pattern})`,
- multiEditEdits: ({ path, count }: { path: string; count: number }) => `${path} (${count} edits)`,
- readingFile: ({ file }: { file: string }) => `Reading ${file}`,
- writingFile: ({ file }: { file: string }) => `Writing ${file}`,
- modifyingFile: ({ file }: { file: string }) => `Modifying ${file}`,
- modifyingFiles: ({ count }: { count: number }) => `Modifying ${count} files`,
- modifyingMultipleFiles: ({ file, count }: { file: string; count: number }) => `${file} and ${count} more`,
- showingDiff: 'Showing changes',
- }
- },
-
- files: {
- searchPlaceholder: 'Search files...',
- detachedHead: 'detached HEAD',
- summary: ({ staged, unstaged }: { staged: number; unstaged: number }) => `${staged} staged • ${unstaged} unstaged`,
- notRepo: 'Not a git repository',
- notUnderGit: 'This directory is not under git version control',
- searching: 'Searching files...',
- noFilesFound: 'No files found',
- noFilesInProject: 'No files in project',
- tryDifferentTerm: 'Try a different search term',
- searchResults: ({ count }: { count: number }) => `Search Results (${count})`,
- projectRoot: 'Project root',
- stagedChanges: ({ count }: { count: number }) => `Staged Changes (${count})`,
- unstagedChanges: ({ count }: { count: number }) => `Unstaged Changes (${count})`,
- // File viewer strings
- loadingFile: ({ fileName }: { fileName: string }) => `Loading ${fileName}...`,
- binaryFile: 'Binary File',
- cannotDisplayBinary: 'Cannot display binary file content',
- diff: 'Diff',
- file: 'File',
- fileEmpty: 'File is empty',
- noChanges: 'No changes to display',
- },
-
- settingsVoice: {
- // Voice settings screen
- languageTitle: 'Language',
- languageDescription: 'Choose your preferred language for voice assistant interactions. This setting syncs across all your devices.',
- preferredLanguage: 'Preferred Language',
- preferredLanguageSubtitle: 'Language used for voice assistant responses',
- language: {
- searchPlaceholder: 'Search languages...',
- title: 'Languages',
- footer: ({ count }: { count: number }) => `${count} ${plural({ count, singular: 'language', plural: 'languages' })} available`,
- autoDetect: 'Auto-detect',
- }
- },
-
- settingsAccount: {
- // Account settings screen
- accountInformation: 'Account Information',
- status: 'Status',
- statusActive: 'Active',
- statusNotAuthenticated: 'Not Authenticated',
- anonymousId: 'Anonymous ID',
- publicId: 'Public ID',
- notAvailable: 'Not available',
- linkNewDevice: 'Link New Device',
- linkNewDeviceSubtitle: 'Scan QR code to link device',
- profile: 'Profile',
- name: 'Name',
- github: 'GitHub',
- tapToDisconnect: 'Tap to disconnect',
- server: 'Server',
- backup: 'Backup',
- backupDescription: 'Your secret key is the only way to recover your account. Save it in a secure place like a password manager.',
- secretKey: 'Secret Key',
- tapToReveal: 'Tap to reveal',
- tapToHide: 'Tap to hide',
- secretKeyLabel: 'SECRET KEY (TAP TO COPY)',
- secretKeyCopied: 'Secret key copied to clipboard. Store it in a safe place!',
- secretKeyCopyFailed: 'Failed to copy secret key',
- privacy: 'Privacy',
- privacyDescription: 'Help improve the app by sharing anonymous usage data. No personal information is collected.',
- analytics: 'Analytics',
- analyticsDisabled: 'No data is shared',
- analyticsEnabled: 'Anonymous usage data is shared',
- dangerZone: 'Danger Zone',
- logout: 'Logout',
- logoutSubtitle: 'Sign out and clear local data',
- logoutConfirm: 'Are you sure you want to logout? Make sure you have backed up your secret key!',
- },
-
- settingsLanguage: {
- // Language settings screen
- title: 'Language',
- description: 'Choose your preferred language for the app interface. This will sync across all your devices.',
- currentLanguage: 'Current Language',
- automatic: 'Automatic',
- automaticSubtitle: 'Detect from device settings',
- needsRestart: 'Language Changed',
- needsRestartMessage: 'The app needs to restart to apply the new language setting.',
- restartNow: 'Restart Now',
- },
-
- connectButton: {
- authenticate: 'Authenticate Terminal',
- authenticateWithUrlPaste: 'Authenticate Terminal with URL paste',
- pasteAuthUrl: 'Paste the auth URL from your terminal',
- },
-
- updateBanner: {
- updateAvailable: 'Update available',
- pressToApply: 'Press to apply the update',
- whatsNew: "What's new",
- seeLatest: 'See the latest updates and improvements',
- nativeUpdateAvailable: 'App Update Available',
- tapToUpdateAppStore: 'Tap to update in App Store',
- tapToUpdatePlayStore: 'Tap to update in Play Store',
- },
-
- changelog: {
- // Used by the changelog screen
- version: ({ version }: { version: number }) => `Version ${version}`,
- noEntriesAvailable: 'No changelog entries available.',
- },
-
- terminal: {
- // Used by terminal connection screens
- webBrowserRequired: 'Web Browser Required',
- webBrowserRequiredDescription: 'Terminal connection links can only be opened in a web browser for security reasons. Please use the QR code scanner or open this link on a computer.',
- processingConnection: 'Processing connection...',
- invalidConnectionLink: 'Invalid Connection Link',
- invalidConnectionLinkDescription: 'The connection link is missing or invalid. Please check the URL and try again.',
- connectTerminal: 'Connect Terminal',
- terminalRequestDescription: 'A terminal is requesting to connect to your Happy Coder account. This will allow the terminal to send and receive messages securely.',
- connectionDetails: 'Connection Details',
- publicKey: 'Public Key',
- encryption: 'Encryption',
- endToEndEncrypted: 'End-to-end encrypted',
- acceptConnection: 'Accept Connection',
- connecting: 'Connecting...',
- reject: 'Reject',
- security: 'Security',
- securityFooter: 'This connection link was processed securely in your browser and was never sent to any server. Your private data will remain secure and only you can decrypt the messages.',
- securityFooterDevice: 'This connection was processed securely on your device and was never sent to any server. Your private data will remain secure and only you can decrypt the messages.',
- clientSideProcessing: 'Client-Side Processing',
- linkProcessedLocally: 'Link processed locally in browser',
- linkProcessedOnDevice: 'Link processed locally on device',
- },
-
- modals: {
- // Used across connect flows and settings
- authenticateTerminal: 'Authenticate Terminal',
- pasteUrlFromTerminal: 'Paste the authentication URL from your terminal',
- deviceLinkedSuccessfully: 'Device linked successfully',
- terminalConnectedSuccessfully: 'Terminal connected successfully',
- invalidAuthUrl: 'Invalid authentication URL',
- developerMode: 'Developer Mode',
- developerModeEnabled: 'Developer mode enabled',
- developerModeDisabled: 'Developer mode disabled',
- disconnectGithub: 'Disconnect GitHub',
- disconnectGithubConfirm: 'Are you sure you want to disconnect your GitHub account?',
- disconnectService: ({ service }: { service: string }) =>
- `Disconnect ${service}`,
- disconnectServiceConfirm: ({ service }: { service: string }) =>
- `Are you sure you want to disconnect ${service} from your account?`,
- disconnect: 'Disconnect',
- failedToConnectTerminal: 'Failed to connect terminal',
- cameraPermissionsRequiredToConnectTerminal: 'Camera permissions are required to connect terminal',
- failedToLinkDevice: 'Failed to link device',
- cameraPermissionsRequiredToScanQr: 'Camera permissions are required to scan QR codes'
- },
-
- navigation: {
- // Navigation titles and screen headers
- connectTerminal: 'Connect Terminal',
- linkNewDevice: 'Link New Device',
- restoreWithSecretKey: 'Restore with Secret Key',
- whatsNew: "What's New",
- friends: 'Friends',
- },
-
- welcome: {
- // Main welcome screen for unauthenticated users
- title: 'Codex and Claude Code mobile client',
- subtitle: 'End-to-end encrypted and your account is stored only on your device.',
- createAccount: 'Create account',
- linkOrRestoreAccount: 'Link or restore account',
- loginWithMobileApp: 'Login with mobile app',
- },
-
- review: {
- // Used by utils/requestReview.ts
- enjoyingApp: 'Enjoying the app?',
- feedbackPrompt: "We'd love to hear your feedback!",
- yesILoveIt: 'Yes, I love it!',
- notReally: 'Not really'
- },
-
- items: {
- // Used by Item component for copy toast
- copiedToClipboard: ({ label }: { label: string }) => `${label} copied to clipboard`
- },
-
- machine: {
- launchNewSessionInDirectory: 'Launch New Session in Directory',
- offlineUnableToSpawn: 'Launcher disabled while machine is offline',
- offlineHelp: '• Make sure your computer is online\n• Run `happy daemon status` to diagnose\n• Are you running the latest CLI version? Upgrade with `npm install -g happy-coder@latest`',
- daemon: 'Daemon',
- status: 'Status',
- stopDaemon: 'Stop Daemon',
- lastKnownPid: 'Last Known PID',
- lastKnownHttpPort: 'Last Known HTTP Port',
- startedAt: 'Started At',
- cliVersion: 'CLI Version',
- daemonStateVersion: 'Daemon State Version',
- activeSessions: ({ count }: { count: number }) => `Active Sessions (${count})`,
- machineGroup: 'Machine',
- host: 'Host',
- machineId: 'Machine ID',
- username: 'Username',
- homeDirectory: 'Home Directory',
- platform: 'Platform',
- architecture: 'Architecture',
- lastSeen: 'Last Seen',
- never: 'Never',
- metadataVersion: 'Metadata Version',
- untitledSession: 'Untitled Session',
- back: 'Back',
- },
-
- message: {
- switchedToMode: ({ mode }: { mode: string }) => `Switched to ${mode} mode`,
- unknownEvent: 'Unknown event',
- usageLimitUntil: ({ time }: { time: string }) => `Usage limit reached until ${time}`,
- unknownTime: 'unknown time',
- },
-
- codex: {
- // Codex permission dialog buttons
- permissions: {
- yesForSession: "Yes, and don't ask for a session",
- stopAndExplain: 'Stop, and explain what to do',
- }
- },
-
- claude: {
- // Claude permission dialog buttons
- permissions: {
- yesAllowAllEdits: 'Yes, allow all edits during this session',
- yesForTool: "Yes, don't ask again for this tool",
- noTellClaude: 'No, and tell Claude what to do differently',
- }
- },
-
- textSelection: {
- // Text selection screen
- selectText: 'Select text range',
- title: 'Select Text',
- noTextProvided: 'No text provided',
- textNotFound: 'Text not found or expired',
- textCopied: 'Text copied to clipboard',
- failedToCopy: 'Failed to copy text to clipboard',
- noTextToCopy: 'No text available to copy',
- },
-
- markdown: {
- // Markdown copy functionality
- codeCopied: 'Code copied',
- copyFailed: 'Copy failed',
- mermaidRenderFailed: 'Failed to render mermaid diagram',
- },
-
- artifacts: {
- // Artifacts feature
- title: 'Artifacts',
- countSingular: '1 artifact',
- countPlural: ({ count }: { count: number }) => `${count} artifacts`,
- empty: 'No artifacts yet',
- emptyDescription: 'Create your first artifact to get started',
- new: 'New Artifact',
- edit: 'Edit Artifact',
- delete: 'Delete',
- updateError: 'Failed to update artifact. Please try again.',
- notFound: 'Artifact not found',
- discardChanges: 'Discard changes?',
- discardChangesDescription: 'You have unsaved changes. Are you sure you want to discard them?',
- deleteConfirm: 'Delete artifact?',
- deleteConfirmDescription: 'This action cannot be undone',
- titleLabel: 'TITLE',
- titlePlaceholder: 'Enter a title for your artifact',
- bodyLabel: 'CONTENT',
- bodyPlaceholder: 'Write your content here...',
- emptyFieldsError: 'Please enter a title or content',
- createError: 'Failed to create artifact. Please try again.',
- save: 'Save',
- saving: 'Saving...',
- loading: 'Loading artifacts...',
- error: 'Failed to load artifact',
- },
-
- friends: {
- // Friends feature
- title: 'Friends',
- manageFriends: 'Manage your friends and connections',
- searchTitle: 'Find Friends',
- pendingRequests: 'Friend Requests',
- myFriends: 'My Friends',
- noFriendsYet: "You don't have any friends yet",
- findFriends: 'Find Friends',
- remove: 'Remove',
- pendingRequest: 'Pending',
- sentOn: ({ date }: { date: string }) => `Sent on ${date}`,
- accept: 'Accept',
- reject: 'Reject',
- addFriend: 'Add Friend',
- alreadyFriends: 'Already Friends',
- requestPending: 'Request Pending',
- searchInstructions: 'Enter a username to search for friends',
- searchPlaceholder: 'Enter username...',
- searching: 'Searching...',
- userNotFound: 'User not found',
- noUserFound: 'No user found with that username',
- checkUsername: 'Please check the username and try again',
- howToFind: 'How to Find Friends',
- findInstructions: 'Search for friends by their username. Both you and your friend need to have GitHub connected to send friend requests.',
- requestSent: 'Friend request sent!',
- requestAccepted: 'Friend request accepted!',
- requestRejected: 'Friend request rejected',
- friendRemoved: 'Friend removed',
- confirmRemove: 'Remove Friend',
- confirmRemoveMessage: 'Are you sure you want to remove this friend?',
- cannotAddYourself: 'You cannot send a friend request to yourself',
- bothMustHaveGithub: 'Both users must have GitHub connected to become friends',
- status: {
- none: 'Not connected',
- requested: 'Request sent',
- pending: 'Request pending',
- friend: 'Friends',
- rejected: 'Rejected',
- },
- acceptRequest: 'Accept Request',
- removeFriend: 'Remove Friend',
- removeFriendConfirm: ({ name }: { name: string }) => `Are you sure you want to remove ${name} as a friend?`,
- requestSentDescription: ({ name }: { name: string }) => `Your friend request has been sent to ${name}`,
- requestFriendship: 'Request friendship',
- cancelRequest: 'Cancel friendship request',
- cancelRequestConfirm: ({ name }: { name: string }) => `Cancel your friendship request to ${name}?`,
- denyRequest: 'Deny friendship',
- nowFriendsWith: ({ name }: { name: string }) => `You are now friends with ${name}`,
- },
-
- usage: {
- // Usage panel strings
- today: 'Today',
- last7Days: 'Last 7 days',
- last30Days: 'Last 30 days',
- totalTokens: 'Total Tokens',
- totalCost: 'Total Cost',
- tokens: 'Tokens',
- cost: 'Cost',
- usageOverTime: 'Usage over time',
- byModel: 'By Model',
- noData: 'No usage data available',
- },
-
- feed: {
- // Feed notifications for friend requests and acceptances
- friendRequestFrom: ({ name }: { name: string }) => `${name} sent you a friend request`,
- friendRequestGeneric: 'New friend request',
- friendAccepted: ({ name }: { name: string }) => `You are now friends with ${name}`,
- friendAcceptedGeneric: 'Friend request accepted',
- },
-
- profiles: {
- // Profile management feature
- title: 'Profiles',
- subtitle: 'Manage environment variable profiles for sessions',
- noProfile: 'No Profile',
- noProfileDescription: 'Use default environment settings',
- defaultModel: 'Default Model',
- addProfile: 'Add Profile',
- profileName: 'Profile Name',
- enterName: 'Enter profile name',
- baseURL: 'Base URL',
- authToken: 'Auth Token',
- enterToken: 'Enter auth token',
- model: 'Model',
- tmuxSession: 'Tmux Session',
- enterTmuxSession: 'Enter tmux session name',
- tmuxTempDir: 'Tmux Temp Directory',
- enterTmuxTempDir: 'Enter temp directory path',
- tmuxUpdateEnvironment: 'Update environment automatically',
- nameRequired: 'Profile name is required',
- deleteConfirm: 'Are you sure you want to delete the profile "{name}"?',
- editProfile: 'Edit Profile',
- addProfileTitle: 'Add New Profile',
- delete: {
- title: 'Delete Profile',
- message: ({ name }: { name: string }) => `Are you sure you want to delete "${name}"? This action cannot be undone.`,
- confirm: 'Delete',
- cancel: 'Cancel',
- },
- }
-} as const;
-
-export type Translations = typeof en;
-
-/**
- * Generic translation type that matches the structure of Translations
- * but allows different string values (for other languages)
- */
-export type TranslationStructure = {
- readonly [K in keyof Translations]: {
- readonly [P in keyof Translations[K]]: Translations[K][P] extends string
- ? string
- : Translations[K][P] extends (...args: any[]) => string
- ? Translations[K][P]
- : Translations[K][P] extends object
- ? {
- readonly [Q in keyof Translations[K][P]]: Translations[K][P][Q] extends string
- ? string
- : Translations[K][P][Q]
- }
- : Translations[K][P]
- }
-};
diff --git a/sources/text/_types.ts b/sources/text/_types.ts
new file mode 100644
index 000000000..4234daee3
--- /dev/null
+++ b/sources/text/_types.ts
@@ -0,0 +1,24 @@
+import { en } from './translations/en';
+
+export type Translations = typeof en;
+
+/**
+ * Generic translation type that matches the structure of Translations
+ * but allows different string values (for other languages).
+ */
+export type TranslationStructure = {
+ readonly [K in keyof Translations]: {
+ readonly [P in keyof Translations[K]]: Translations[K][P] extends string
+ ? string
+ : Translations[K][P] extends (...args: any[]) => string
+ ? Translations[K][P]
+ : Translations[K][P] extends object
+ ? {
+ readonly [Q in keyof Translations[K][P]]: Translations[K][P][Q] extends string
+ ? string
+ : Translations[K][P][Q]
+ }
+ : Translations[K][P]
+ }
+};
+
diff --git a/sources/text/index.ts b/sources/text/index.ts
index e627bb855..a05afb9d6 100644
--- a/sources/text/index.ts
+++ b/sources/text/index.ts
@@ -1,4 +1,5 @@
-import { en, type Translations, type TranslationStructure } from './_default';
+import { en } from './translations/en';
+import type { Translations, TranslationStructure } from './_types';
import { ru } from './translations/ru';
import { pl } from './translations/pl';
import { es } from './translations/es';
@@ -98,13 +99,11 @@ let found = false;
if (settings.settings.preferredLanguage && settings.settings.preferredLanguage in translations) {
currentLanguage = settings.settings.preferredLanguage as SupportedLanguage;
found = true;
- console.log(`[i18n] Using preferred language: ${currentLanguage}`);
}
// Read from device
if (!found) {
let locales = Localization.getLocales();
- console.log(`[i18n] Device locales:`, locales.map(l => l.languageCode));
for (let l of locales) {
if (l.languageCode) {
// Expo added special handling for Chinese variants using script code https://github.com/expo/expo/pull/34984
@@ -114,35 +113,26 @@ if (!found) {
// We only have translations for simplified Chinese right now, but looking for help with traditional Chinese.
if (l.languageScriptCode === 'Hans') {
chineseVariant = 'zh-Hans';
- // } else if (l.languageScriptCode === 'Hant') {
- // chineseVariant = 'zh-Hant';
}
- console.log(`[i18n] Chinese script code: ${l.languageScriptCode} -> ${chineseVariant}`);
-
if (chineseVariant && chineseVariant in translations) {
currentLanguage = chineseVariant as SupportedLanguage;
- console.log(`[i18n] Using Chinese variant: ${currentLanguage}`);
break;
}
currentLanguage = 'zh-Hans';
- console.log(`[i18n] Falling back to simplified Chinese: zh-Hans`);
break;
}
// Direct match for non-Chinese languages
if (l.languageCode in translations) {
currentLanguage = l.languageCode as SupportedLanguage;
- console.log(`[i18n] Using device locale: ${currentLanguage}`);
break;
}
}
}
}
-console.log(`[i18n] Final language: ${currentLanguage}`);
-
/**
* Main translation function with strict typing
*
diff --git a/sources/text/translations/ca.ts b/sources/text/translations/ca.ts
index e27bdba63..4067d6861 100644
--- a/sources/text/translations/ca.ts
+++ b/sources/text/translations/ca.ts
@@ -1,4 +1,4 @@
-import type { TranslationStructure } from '../_default';
+import type { TranslationStructure } from '../_types';
/**
* Catalan plural helper function
@@ -208,6 +208,15 @@ export const ca: TranslationStructure = {
enhancedSessionWizard: 'Assistent de sessió millorat',
enhancedSessionWizardEnabled: 'Llançador de sessió amb perfil actiu',
enhancedSessionWizardDisabled: 'Usant el llançador de sessió estàndard',
+ profiles: 'AI Profiles',
+ profilesEnabled: 'Profile selection enabled',
+ profilesDisabled: 'Profile selection disabled',
+ pickerSearch: 'Picker Search',
+ pickerSearchSubtitle: 'Show a search field in machine and path pickers',
+ machinePickerSearch: 'Machine search',
+ machinePickerSearchSubtitle: 'Show a search field in machine pickers',
+ pathPickerSearch: 'Path search',
+ pathPickerSearchSubtitle: 'Show a search field in path pickers',
},
errors: {
@@ -430,14 +439,14 @@ export const ca: TranslationStructure = {
gpt5High: 'GPT-5 High',
},
geminiPermissionMode: {
- title: 'MODE DE PERMISOS',
+ title: 'MODE DE PERMISOS GEMINI',
default: 'Per defecte',
- acceptEdits: 'Accepta edicions',
- plan: 'Mode de planificació',
- bypassPermissions: 'Mode Yolo',
- badgeAcceptAllEdits: 'Accepta totes les edicions',
- badgeBypassAllPermissions: 'Omet tots els permisos',
- badgePlanMode: 'Mode de planificació',
+ readOnly: 'Read Only Mode',
+ safeYolo: 'Safe YOLO',
+ yolo: 'YOLO',
+ badgeReadOnly: 'Read Only Mode',
+ badgeSafeYolo: 'Safe YOLO',
+ badgeYolo: 'YOLO',
},
context: {
remaining: ({ percent }: { percent: number }) => `${percent}% restant`,
@@ -760,7 +769,7 @@ export const ca: TranslationStructure = {
permissions: {
yesAllowAllEdits: 'Sí, permet totes les edicions durant aquesta sessió',
yesForTool: 'Sí, no tornis a preguntar per aquesta eina',
- noTellClaude: 'No, i digues a Claude què fer diferent',
+ noTellClaude: 'No, proporciona comentaris',
}
},
@@ -894,7 +903,7 @@ export const ca: TranslationStructure = {
tmuxTempDir: 'Directori temporal tmux',
enterTmuxTempDir: 'Introdueix el directori temporal tmux',
tmuxUpdateEnvironment: 'Actualitza l\'entorn tmux',
- deleteConfirm: 'Segur que vols eliminar aquest perfil?',
+ deleteConfirm: ({ name }: { name: string }) => `Segur que vols eliminar el perfil "${name}"?`,
nameRequired: 'El nom del perfil és obligatori',
delete: {
title: 'Eliminar Perfil',
diff --git a/sources/text/translations/en.ts b/sources/text/translations/en.ts
index 201e9c0ec..2ef8d1025 100644
--- a/sources/text/translations/en.ts
+++ b/sources/text/translations/en.ts
@@ -1,5 +1,3 @@
-import type { TranslationStructure } from '../_default';
-
/**
* English plural helper function
* English has 2 plural forms: singular, plural
@@ -14,10 +12,10 @@ function plural({ count, singular, plural }: { count: number; singular: string;
* ENGLISH TRANSLATIONS - DEDICATED FILE
*
* This file represents the new translation architecture where each language
- * has its own dedicated file instead of being embedded in _default.ts.
+ * has its own dedicated file instead of being embedded in _types.ts.
*
* STRUCTURE CHANGE:
- * - Previously: All languages in _default.ts as objects
+ * - Previously: All languages in a single default file
* - Now: Separate files for each language (en.ts, ru.ts, pl.ts, es.ts, etc.)
* - Benefit: Better maintainability, smaller files, easier language management
*
@@ -29,7 +27,7 @@ function plural({ count, singular, plural }: { count: number; singular: string;
* - Type safety enforced by TranslationStructure interface
* - New translation keys must be added to ALL language files
*/
-export const en: TranslationStructure = {
+export const en = {
tabs: {
// Tab navigation labels
inbox: 'Inbox',
@@ -211,8 +209,8 @@ export const en: TranslationStructure = {
webFeatures: 'Web Features',
webFeaturesDescription: 'Features available only in the web version of the app.',
enterToSend: 'Enter to Send',
- enterToSendEnabled: 'Press Enter to send messages',
- enterToSendDisabled: 'Press ⌘+Enter to send messages',
+ enterToSendEnabled: 'Press Enter to send (Shift+Enter for a new line)',
+ enterToSendDisabled: 'Enter inserts a new line',
commandPalette: 'Command Palette',
commandPaletteEnabled: 'Press ⌘K to open',
commandPaletteDisabled: 'Quick command access disabled',
@@ -223,6 +221,15 @@ export const en: TranslationStructure = {
enhancedSessionWizard: 'Enhanced Session Wizard',
enhancedSessionWizardEnabled: 'Profile-first session launcher active',
enhancedSessionWizardDisabled: 'Using standard session launcher',
+ profiles: 'AI Profiles',
+ profilesEnabled: 'Profile selection enabled',
+ profilesDisabled: 'Profile selection disabled',
+ pickerSearch: 'Picker Search',
+ pickerSearchSubtitle: 'Show a search field in machine and path pickers',
+ machinePickerSearch: 'Machine search',
+ machinePickerSearchSubtitle: 'Show a search field in machine pickers',
+ pathPickerSearch: 'Path search',
+ pathPickerSearchSubtitle: 'Show a search field in path pickers',
},
errors: {
@@ -447,12 +454,12 @@ export const en: TranslationStructure = {
geminiPermissionMode: {
title: 'GEMINI PERMISSION MODE',
default: 'Default',
- acceptEdits: 'Accept Edits',
- plan: 'Plan Mode',
- bypassPermissions: 'Yolo Mode',
- badgeAcceptAllEdits: 'Accept All Edits',
- badgeBypassAllPermissions: 'Bypass All Permissions',
- badgePlanMode: 'Plan Mode',
+ readOnly: 'Read Only',
+ safeYolo: 'Safe YOLO',
+ yolo: 'YOLO',
+ badgeReadOnly: 'Read Only',
+ badgeSafeYolo: 'Safe YOLO',
+ badgeYolo: 'YOLO',
},
context: {
remaining: ({ percent }: { percent: number }) => `${percent}% left`,
@@ -775,7 +782,7 @@ export const en: TranslationStructure = {
permissions: {
yesAllowAllEdits: 'Yes, allow all edits during this session',
yesForTool: "Yes, don't ask again for this tool",
- noTellClaude: 'No, and tell Claude what to do differently',
+ noTellClaude: 'No, and provide feedback',
}
},
@@ -918,7 +925,7 @@ export const en: TranslationStructure = {
enterTmuxTempDir: 'Enter temp directory path',
tmuxUpdateEnvironment: 'Update environment automatically',
nameRequired: 'Profile name is required',
- deleteConfirm: 'Are you sure you want to delete the profile "{name}"?',
+ deleteConfirm: ({ name }: { name: string }) => `Are you sure you want to delete the profile "${name}"?`,
editProfile: 'Edit Profile',
addProfileTitle: 'Add New Profile',
delete: {
@@ -930,4 +937,4 @@ export const en: TranslationStructure = {
}
} as const;
-export type TranslationsEn = typeof en;
\ No newline at end of file
+export type TranslationsEn = typeof en;
diff --git a/sources/text/translations/es.ts b/sources/text/translations/es.ts
index 387d726cc..3272ca282 100644
--- a/sources/text/translations/es.ts
+++ b/sources/text/translations/es.ts
@@ -1,4 +1,4 @@
-import type { TranslationStructure } from '../_default';
+import type { TranslationStructure } from '../_types';
/**
* Spanish plural helper function
@@ -208,6 +208,15 @@ export const es: TranslationStructure = {
enhancedSessionWizard: 'Asistente de sesión mejorado',
enhancedSessionWizardEnabled: 'Lanzador de sesión con perfil activo',
enhancedSessionWizardDisabled: 'Usando el lanzador de sesión estándar',
+ profiles: 'AI Profiles',
+ profilesEnabled: 'Profile selection enabled',
+ profilesDisabled: 'Profile selection disabled',
+ pickerSearch: 'Picker Search',
+ pickerSearchSubtitle: 'Show a search field in machine and path pickers',
+ machinePickerSearch: 'Machine search',
+ machinePickerSearchSubtitle: 'Show a search field in machine pickers',
+ pathPickerSearch: 'Path search',
+ pathPickerSearchSubtitle: 'Show a search field in path pickers',
},
errors: {
@@ -430,14 +439,14 @@ export const es: TranslationStructure = {
gpt5High: 'GPT-5 High',
},
geminiPermissionMode: {
- title: 'MODO DE PERMISOS',
+ title: 'MODO DE PERMISOS GEMINI',
default: 'Por defecto',
- acceptEdits: 'Aceptar ediciones',
- plan: 'Modo de planificación',
- bypassPermissions: 'Modo Yolo',
- badgeAcceptAllEdits: 'Aceptar todas las ediciones',
- badgeBypassAllPermissions: 'Omitir todos los permisos',
- badgePlanMode: 'Modo de planificación',
+ readOnly: 'Read Only Mode',
+ safeYolo: 'Safe YOLO',
+ yolo: 'YOLO',
+ badgeReadOnly: 'Read Only Mode',
+ badgeSafeYolo: 'Safe YOLO',
+ badgeYolo: 'YOLO',
},
context: {
remaining: ({ percent }: { percent: number }) => `${percent}% restante`,
@@ -760,7 +769,7 @@ export const es: TranslationStructure = {
permissions: {
yesAllowAllEdits: 'Sí, permitir todas las ediciones durante esta sesión',
yesForTool: 'Sí, no volver a preguntar para esta herramienta',
- noTellClaude: 'No, y decirle a Claude qué hacer diferente',
+ noTellClaude: 'No, proporcionar comentarios',
}
},
@@ -903,7 +912,7 @@ export const es: TranslationStructure = {
enterTmuxTempDir: 'Ingrese la ruta del directorio temporal',
tmuxUpdateEnvironment: 'Actualizar entorno automáticamente',
nameRequired: 'El nombre del perfil es requerido',
- deleteConfirm: '¿Estás seguro de que quieres eliminar el perfil "{name}"?',
+ deleteConfirm: ({ name }: { name: string }) => `¿Estás seguro de que quieres eliminar el perfil "${name}"?`,
editProfile: 'Editar Perfil',
addProfileTitle: 'Agregar Nuevo Perfil',
delete: {
diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts
index 4743fdd6e..c1c31981e 100644
--- a/sources/text/translations/it.ts
+++ b/sources/text/translations/it.ts
@@ -1,4 +1,4 @@
-import type { TranslationStructure } from '../_default';
+import type { TranslationStructure } from '../_types';
/**
* Italian plural helper function
@@ -90,7 +90,7 @@ export const it: TranslationStructure = {
enterTmuxTempDir: 'Inserisci percorso directory temporanea',
tmuxUpdateEnvironment: 'Aggiorna ambiente automaticamente',
nameRequired: 'Il nome del profilo è obbligatorio',
- deleteConfirm: 'Sei sicuro di voler eliminare il profilo "{name}"?',
+ deleteConfirm: ({ name }: { name: string }) => `Sei sicuro di voler eliminare il profilo "${name}"?`,
editProfile: 'Modifica profilo',
addProfileTitle: 'Aggiungi nuovo profilo',
delete: {
@@ -237,6 +237,15 @@ export const it: TranslationStructure = {
enhancedSessionWizard: 'Wizard sessione avanzato',
enhancedSessionWizardEnabled: 'Avvio sessioni con profili attivo',
enhancedSessionWizardDisabled: 'Usando avvio sessioni standard',
+ profiles: 'AI Profiles',
+ profilesEnabled: 'Profile selection enabled',
+ profilesDisabled: 'Profile selection disabled',
+ pickerSearch: 'Picker Search',
+ pickerSearchSubtitle: 'Show a search field in machine and path pickers',
+ machinePickerSearch: 'Machine search',
+ machinePickerSearchSubtitle: 'Show a search field in machine pickers',
+ pathPickerSearch: 'Path search',
+ pathPickerSearchSubtitle: 'Show a search field in path pickers',
},
errors: {
@@ -461,12 +470,12 @@ export const it: TranslationStructure = {
geminiPermissionMode: {
title: 'MODALITÀ PERMESSI GEMINI',
default: 'Predefinito',
- acceptEdits: 'Accetta modifiche',
- plan: 'Modalità piano',
- bypassPermissions: 'Modalità YOLO',
- badgeAcceptAllEdits: 'Accetta tutte le modifiche',
- badgeBypassAllPermissions: 'Bypassa tutti i permessi',
- badgePlanMode: 'Modalità piano',
+ readOnly: 'Modalità sola lettura',
+ safeYolo: 'YOLO sicuro',
+ yolo: 'YOLO',
+ badgeReadOnly: 'Modalità sola lettura',
+ badgeSafeYolo: 'YOLO sicuro',
+ badgeYolo: 'YOLO',
},
context: {
remaining: ({ percent }: { percent: number }) => `${percent}% restante`,
@@ -789,7 +798,7 @@ export const it: TranslationStructure = {
permissions: {
yesAllowAllEdits: 'Sì, consenti tutte le modifiche durante questa sessione',
yesForTool: 'Sì, non chiedere più per questo strumento',
- noTellClaude: 'No, e di a Claude cosa fare diversamente',
+ noTellClaude: 'No, fornisci feedback',
}
},
diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts
index c8af39724..34d6d8047 100644
--- a/sources/text/translations/ja.ts
+++ b/sources/text/translations/ja.ts
@@ -5,7 +5,7 @@
* - Functions with typed object parameters for dynamic text
*/
-import { TranslationStructure } from "../_default";
+import type { TranslationStructure } from '../_types';
/**
* Japanese plural helper function
@@ -93,7 +93,7 @@ export const ja: TranslationStructure = {
enterTmuxTempDir: '一時ディレクトリのパスを入力',
tmuxUpdateEnvironment: '環境を自動更新',
nameRequired: 'プロファイル名は必須です',
- deleteConfirm: 'プロファイル「{name}」を削除してもよろしいですか?',
+ deleteConfirm: ({ name }: { name: string }) => `プロファイル「${name}」を削除してもよろしいですか?`,
editProfile: 'プロファイルを編集',
addProfileTitle: '新しいプロファイルを追加',
delete: {
@@ -240,6 +240,15 @@ export const ja: TranslationStructure = {
enhancedSessionWizard: '拡張セッションウィザード',
enhancedSessionWizardEnabled: 'プロファイル優先セッションランチャーが有効',
enhancedSessionWizardDisabled: '標準セッションランチャーを使用',
+ profiles: 'AI Profiles',
+ profilesEnabled: 'Profile selection enabled',
+ profilesDisabled: 'Profile selection disabled',
+ pickerSearch: 'Picker Search',
+ pickerSearchSubtitle: 'Show a search field in machine and path pickers',
+ machinePickerSearch: 'Machine search',
+ machinePickerSearchSubtitle: 'Show a search field in machine pickers',
+ pathPickerSearch: 'Path search',
+ pathPickerSearchSubtitle: 'Show a search field in path pickers',
},
errors: {
@@ -464,12 +473,12 @@ export const ja: TranslationStructure = {
geminiPermissionMode: {
title: 'GEMINI権限モード',
default: 'デフォルト',
- acceptEdits: '編集を許可',
- plan: 'プランモード',
- bypassPermissions: 'Yoloモード',
- badgeAcceptAllEdits: 'すべての編集を許可',
- badgeBypassAllPermissions: 'すべての権限をバイパス',
- badgePlanMode: 'プランモード',
+ readOnly: '読み取り専用モード',
+ safeYolo: 'セーフYOLO',
+ yolo: 'YOLO',
+ badgeReadOnly: '読み取り専用モード',
+ badgeSafeYolo: 'セーフYOLO',
+ badgeYolo: 'YOLO',
},
context: {
remaining: ({ percent }: { percent: number }) => `残り ${percent}%`,
@@ -792,7 +801,7 @@ export const ja: TranslationStructure = {
permissions: {
yesAllowAllEdits: 'はい、このセッション中のすべての編集を許可',
yesForTool: "はい、このツールについては確認しない",
- noTellClaude: 'いいえ、Claudeに別の方法を伝える',
+ noTellClaude: 'いいえ、フィードバックを提供',
}
},
diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts
index 4d24cd147..436e9a4ee 100644
--- a/sources/text/translations/pl.ts
+++ b/sources/text/translations/pl.ts
@@ -1,4 +1,4 @@
-import type { TranslationStructure } from '../_default';
+import type { TranslationStructure } from '../_types';
/**
* Polish plural helper function
@@ -219,6 +219,15 @@ export const pl: TranslationStructure = {
enhancedSessionWizard: 'Ulepszony kreator sesji',
enhancedSessionWizardEnabled: 'Aktywny launcher z profilem',
enhancedSessionWizardDisabled: 'Używanie standardowego launchera sesji',
+ profiles: 'AI Profiles',
+ profilesEnabled: 'Profile selection enabled',
+ profilesDisabled: 'Profile selection disabled',
+ pickerSearch: 'Picker Search',
+ pickerSearchSubtitle: 'Show a search field in machine and path pickers',
+ machinePickerSearch: 'Machine search',
+ machinePickerSearchSubtitle: 'Show a search field in machine pickers',
+ pathPickerSearch: 'Path search',
+ pathPickerSearchSubtitle: 'Show a search field in path pickers',
},
errors: {
@@ -440,14 +449,14 @@ export const pl: TranslationStructure = {
gpt5High: 'GPT-5 High',
},
geminiPermissionMode: {
- title: 'TRYB UPRAWNIEŃ',
+ title: 'TRYB UPRAWNIEŃ GEMINI',
default: 'Domyślny',
- acceptEdits: 'Akceptuj edycje',
- plan: 'Tryb planowania',
- bypassPermissions: 'Tryb YOLO',
- badgeAcceptAllEdits: 'Akceptuj wszystkie edycje',
- badgeBypassAllPermissions: 'Omiń wszystkie uprawnienia',
- badgePlanMode: 'Tryb planowania',
+ readOnly: 'Read Only Mode',
+ safeYolo: 'Safe YOLO',
+ yolo: 'YOLO',
+ badgeReadOnly: 'Read Only Mode',
+ badgeSafeYolo: 'Safe YOLO',
+ badgeYolo: 'YOLO',
},
context: {
remaining: ({ percent }: { percent: number }) => `Pozostało ${percent}%`,
@@ -770,7 +779,7 @@ export const pl: TranslationStructure = {
permissions: {
yesAllowAllEdits: 'Tak, zezwól na wszystkie edycje podczas tej sesji',
yesForTool: 'Tak, nie pytaj ponownie dla tego narzędzia',
- noTellClaude: 'Nie, i powiedz Claude co zrobić inaczej',
+ noTellClaude: 'Nie, przekaż opinię',
}
},
@@ -926,7 +935,7 @@ export const pl: TranslationStructure = {
enterTmuxTempDir: 'Wprowadź ścieżkę do katalogu tymczasowego',
tmuxUpdateEnvironment: 'Aktualizuj środowisko automatycznie',
nameRequired: 'Nazwa profilu jest wymagana',
- deleteConfirm: 'Czy na pewno chcesz usunąć profil "{name}"?',
+ deleteConfirm: ({ name }: { name: string }) => `Czy na pewno chcesz usunąć profil "${name}"?`,
editProfile: 'Edytuj Profil',
addProfileTitle: 'Dodaj Nowy Profil',
delete: {
diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts
index 7a8508f9b..080db2565 100644
--- a/sources/text/translations/pt.ts
+++ b/sources/text/translations/pt.ts
@@ -1,4 +1,4 @@
-import type { TranslationStructure } from '../_default';
+import type { TranslationStructure } from '../_types';
/**
* Portuguese plural helper function
@@ -208,6 +208,15 @@ export const pt: TranslationStructure = {
enhancedSessionWizard: 'Assistente de sessão aprimorado',
enhancedSessionWizardEnabled: 'Lançador de sessão com perfil ativo',
enhancedSessionWizardDisabled: 'Usando o lançador de sessão padrão',
+ profiles: 'AI Profiles',
+ profilesEnabled: 'Profile selection enabled',
+ profilesDisabled: 'Profile selection disabled',
+ pickerSearch: 'Picker Search',
+ pickerSearchSubtitle: 'Show a search field in machine and path pickers',
+ machinePickerSearch: 'Machine search',
+ machinePickerSearchSubtitle: 'Show a search field in machine pickers',
+ pathPickerSearch: 'Path search',
+ pathPickerSearchSubtitle: 'Show a search field in path pickers',
},
errors: {
@@ -430,14 +439,14 @@ export const pt: TranslationStructure = {
gpt5High: 'GPT-5 High',
},
geminiPermissionMode: {
- title: 'MODO DE PERMISSÃO',
+ title: 'MODO DE PERMISSÃO GEMINI',
default: 'Padrão',
- acceptEdits: 'Aceitar edições',
- plan: 'Modo de planejamento',
- bypassPermissions: 'Modo Yolo',
- badgeAcceptAllEdits: 'Aceitar todas as edições',
- badgeBypassAllPermissions: 'Ignorar todas as permissões',
- badgePlanMode: 'Modo de planejamento',
+ readOnly: 'Read Only Mode',
+ safeYolo: 'Safe YOLO',
+ yolo: 'YOLO',
+ badgeReadOnly: 'Read Only Mode',
+ badgeSafeYolo: 'Safe YOLO',
+ badgeYolo: 'YOLO',
},
context: {
remaining: ({ percent }: { percent: number }) => `${percent}% restante`,
@@ -760,7 +769,7 @@ export const pt: TranslationStructure = {
permissions: {
yesAllowAllEdits: 'Sim, permitir todas as edições durante esta sessão',
yesForTool: 'Sim, não perguntar novamente para esta ferramenta',
- noTellClaude: 'Não, e dizer ao Claude o que fazer diferente',
+ noTellClaude: 'Não, fornecer feedback',
}
},
@@ -894,7 +903,7 @@ export const pt: TranslationStructure = {
tmuxTempDir: 'Diretório temporário tmux',
enterTmuxTempDir: 'Digite o diretório temporário tmux',
tmuxUpdateEnvironment: 'Atualizar ambiente tmux',
- deleteConfirm: 'Tem certeza de que deseja excluir este perfil?',
+ deleteConfirm: ({ name }: { name: string }) => `Tem certeza de que deseja excluir o perfil "${name}"?`,
nameRequired: 'O nome do perfil é obrigatório',
delete: {
title: 'Excluir Perfil',
diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts
index 238ce60be..d42e4bb20 100644
--- a/sources/text/translations/ru.ts
+++ b/sources/text/translations/ru.ts
@@ -1,4 +1,4 @@
-import type { TranslationStructure } from '../_default';
+import type { TranslationStructure } from '../_types';
/**
* Russian plural helper function
@@ -190,6 +190,15 @@ export const ru: TranslationStructure = {
enhancedSessionWizard: 'Улучшенный мастер сессий',
enhancedSessionWizardEnabled: 'Лаунчер с профилем активен',
enhancedSessionWizardDisabled: 'Используется стандартный лаунчер',
+ profiles: 'AI Profiles',
+ profilesEnabled: 'Profile selection enabled',
+ profilesDisabled: 'Profile selection disabled',
+ pickerSearch: 'Picker Search',
+ pickerSearchSubtitle: 'Show a search field in machine and path pickers',
+ machinePickerSearch: 'Machine search',
+ machinePickerSearchSubtitle: 'Show a search field in machine pickers',
+ pathPickerSearch: 'Path search',
+ pathPickerSearchSubtitle: 'Show a search field in path pickers',
},
errors: {
@@ -442,12 +451,12 @@ export const ru: TranslationStructure = {
geminiPermissionMode: {
title: 'РЕЖИМ РАЗРЕШЕНИЙ',
default: 'По умолчанию',
- acceptEdits: 'Принимать правки',
- plan: 'Режим планирования',
- bypassPermissions: 'YOLO режим',
- badgeAcceptAllEdits: 'Принимать все правки',
- badgeBypassAllPermissions: 'Обход всех разрешений',
- badgePlanMode: 'Режим планирования',
+ readOnly: 'Только чтение',
+ safeYolo: 'Безопасный YOLO',
+ yolo: 'YOLO',
+ badgeReadOnly: 'Только чтение',
+ badgeSafeYolo: 'Безопасный YOLO',
+ badgeYolo: 'YOLO',
},
context: {
remaining: ({ percent }: { percent: number }) => `Осталось ${percent}%`,
@@ -758,7 +767,7 @@ export const ru: TranslationStructure = {
permissions: {
yesAllowAllEdits: 'Да, разрешить все правки в этой сессии',
yesForTool: 'Да, больше не спрашивать для этого инструмента',
- noTellClaude: 'Нет, и сказать Claude что делать по-другому',
+ noTellClaude: 'Нет, дать обратную связь',
}
},
@@ -925,7 +934,7 @@ export const ru: TranslationStructure = {
enterTmuxTempDir: 'Введите путь к временному каталогу',
tmuxUpdateEnvironment: 'Обновлять окружение автоматически',
nameRequired: 'Имя профиля обязательно',
- deleteConfirm: 'Вы уверены, что хотите удалить профиль "{name}"?',
+ deleteConfirm: ({ name }: { name: string }) => `Вы уверены, что хотите удалить профиль "${name}"?`,
editProfile: 'Редактировать Профиль',
addProfileTitle: 'Добавить Новый Профиль',
delete: {
diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts
index 630414316..fc33c96f9 100644
--- a/sources/text/translations/zh-Hans.ts
+++ b/sources/text/translations/zh-Hans.ts
@@ -5,7 +5,7 @@
* - Functions with typed object parameters for dynamic text
*/
-import { TranslationStructure } from "../_default";
+import type { TranslationStructure } from '../_types';
/**
* Chinese plural helper function
@@ -210,6 +210,15 @@ export const zhHans: TranslationStructure = {
enhancedSessionWizard: '增强会话向导',
enhancedSessionWizardEnabled: '配置文件优先启动器已激活',
enhancedSessionWizardDisabled: '使用标准会话启动器',
+ profiles: 'AI Profiles',
+ profilesEnabled: 'Profile selection enabled',
+ profilesDisabled: 'Profile selection disabled',
+ pickerSearch: 'Picker Search',
+ pickerSearchSubtitle: 'Show a search field in machine and path pickers',
+ machinePickerSearch: 'Machine search',
+ machinePickerSearchSubtitle: 'Show a search field in machine pickers',
+ pathPickerSearch: 'Path search',
+ pathPickerSearchSubtitle: 'Show a search field in path pickers',
},
errors: {
@@ -432,14 +441,14 @@ export const zhHans: TranslationStructure = {
gpt5High: 'GPT-5 High',
},
geminiPermissionMode: {
- title: '权限模式',
+ title: 'GEMINI 权限模式',
default: '默认',
- acceptEdits: '接受编辑',
- plan: '计划模式',
- bypassPermissions: 'Yolo 模式',
- badgeAcceptAllEdits: '接受所有编辑',
- badgeBypassAllPermissions: '绕过所有权限',
- badgePlanMode: '计划模式',
+ readOnly: 'Read Only Mode',
+ safeYolo: 'Safe YOLO',
+ yolo: 'YOLO',
+ badgeReadOnly: 'Read Only Mode',
+ badgeSafeYolo: 'Safe YOLO',
+ badgeYolo: 'YOLO',
},
context: {
remaining: ({ percent }: { percent: number }) => `剩余 ${percent}%`,
@@ -762,7 +771,7 @@ export const zhHans: TranslationStructure = {
permissions: {
yesAllowAllEdits: '是,允许本次会话的所有编辑',
yesForTool: '是,不再询问此工具',
- noTellClaude: '否,并告诉 Claude 该如何不同地操作',
+ noTellClaude: '否,提供反馈',
}
},
@@ -896,7 +905,7 @@ export const zhHans: TranslationStructure = {
tmuxTempDir: 'tmux 临时目录',
enterTmuxTempDir: '输入 tmux 临时目录',
tmuxUpdateEnvironment: '更新 tmux 环境',
- deleteConfirm: '确定要删除此配置文件吗?',
+ deleteConfirm: ({ name }: { name: string }) => `确定要删除配置文件“${name}”吗?`,
nameRequired: '配置文件名称为必填项',
delete: {
title: '删除配置',
diff --git a/sources/theme.css b/sources/theme.css
index 7e241b5ae..2c80b7222 100644
--- a/sources/theme.css
+++ b/sources/theme.css
@@ -33,6 +33,18 @@
scrollbar-color: var(--colors-divider) var(--colors-surface-high);
}
+/* Expo Router (web) modal sizing
+ - Expo Router uses a Vaul/Radix drawer for `presentation: 'modal'` on web.
+ - Default sizing is a bit short on large screens; override via attribute selectors
+ so we don't rely on hashed classnames. */
+@media (min-width: 700px) {
+ [data-vaul-drawer][data-vaul-drawer-direction="bottom"] [data-presentation="modal"] {
+ height: min(820px, calc(100vh - 48px)) !important;
+ max-height: min(820px, calc(100vh - 48px)) !important;
+ min-height: min(820px, calc(100vh - 48px)) !important;
+ }
+}
+
/* Ensure scrollbars are visible on hover for macOS */
::-webkit-scrollbar:horizontal {
height: 12px;
@@ -40,4 +52,4 @@
::-webkit-scrollbar:vertical {
width: 12px;
-}
\ No newline at end of file
+}