): string;
+ show(config: {
+ component: ComponentType
;
+ props?: Omit
;
+ }): string;
hide(id: string): void;
hideAll(): void;
-}
\ No newline at end of file
+}
diff --git a/sources/profileRouteParams.test.ts b/sources/profileRouteParams.test.ts
new file mode 100644
index 000000000..166f0d0b3
--- /dev/null
+++ b/sources/profileRouteParams.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest';
+import { consumeProfileIdParam } from './profileRouteParams';
+
+describe('consumeProfileIdParam', () => {
+ it('does nothing when param is missing', () => {
+ expect(consumeProfileIdParam({ profileIdParam: undefined, selectedProfileId: null })).toEqual({
+ nextSelectedProfileId: undefined,
+ shouldClearParam: false,
+ });
+ });
+
+ it('clears param and deselects when param is empty string', () => {
+ expect(consumeProfileIdParam({ profileIdParam: '', selectedProfileId: 'abc' })).toEqual({
+ nextSelectedProfileId: null,
+ shouldClearParam: true,
+ });
+ });
+
+ it('clears param without changing selection when it matches current selection', () => {
+ expect(consumeProfileIdParam({ profileIdParam: 'abc', selectedProfileId: 'abc' })).toEqual({
+ nextSelectedProfileId: undefined,
+ shouldClearParam: true,
+ });
+ });
+
+ it('clears param and selects when it differs from current selection', () => {
+ expect(consumeProfileIdParam({ profileIdParam: 'next', selectedProfileId: 'abc' })).toEqual({
+ nextSelectedProfileId: 'next',
+ shouldClearParam: true,
+ });
+ });
+
+ it('accepts array params and uses the first value', () => {
+ expect(consumeProfileIdParam({ profileIdParam: ['next', 'ignored'], selectedProfileId: null })).toEqual({
+ nextSelectedProfileId: 'next',
+ shouldClearParam: true,
+ });
+ });
+
+ it('treats empty array params as missing', () => {
+ expect(consumeProfileIdParam({ profileIdParam: [], selectedProfileId: null })).toEqual({
+ nextSelectedProfileId: undefined,
+ shouldClearParam: false,
+ });
+ });
+});
diff --git a/sources/profileRouteParams.ts b/sources/profileRouteParams.ts
new file mode 100644
index 000000000..99eae054a
--- /dev/null
+++ b/sources/profileRouteParams.ts
@@ -0,0 +1,32 @@
+export function normalizeOptionalParam(value?: string | string[]) {
+ if (Array.isArray(value)) {
+ return value[0];
+ }
+ return value;
+}
+
+export function consumeProfileIdParam(params: {
+ profileIdParam?: string | string[];
+ selectedProfileId: string | null;
+}): {
+ nextSelectedProfileId: string | null | undefined;
+ shouldClearParam: boolean;
+} {
+ const nextProfileIdFromParams = normalizeOptionalParam(params.profileIdParam);
+
+ if (typeof nextProfileIdFromParams !== 'string') {
+ return { nextSelectedProfileId: undefined, shouldClearParam: false };
+ }
+
+ if (nextProfileIdFromParams === '') {
+ return { nextSelectedProfileId: null, shouldClearParam: true };
+ }
+
+ if (nextProfileIdFromParams === params.selectedProfileId) {
+ // Nothing to do, but still clear it so it doesn't lock the selection.
+ return { nextSelectedProfileId: undefined, shouldClearParam: true };
+ }
+
+ return { nextSelectedProfileId: nextProfileIdFromParams, shouldClearParam: true };
+}
+
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..558485cc4
--- /dev/null
+++ b/sources/sync/messageMeta.test.ts
@@ -0,0 +1,54 @@
+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);
+ });
+
+ it('includes displayText when explicitly provided (including empty string)', () => {
+ const meta = buildOutgoingMessageMeta({
+ sentFrom: 'web',
+ permissionMode: 'default',
+ appendSystemPrompt: 'PROMPT',
+ displayText: '',
+ });
+
+ expect('displayText' in meta).toBe(true);
+ expect(meta.displayText).toBe('');
+ });
+
+ it('includes fallbackModel when explicitly provided', () => {
+ const meta = buildOutgoingMessageMeta({
+ sentFrom: 'web',
+ permissionMode: 'default',
+ appendSystemPrompt: 'PROMPT',
+ fallbackModel: 'gemini-2.5-flash',
+ });
+
+ expect('fallbackModel' in meta).toBe(true);
+ expect(meta.fallbackModel).toBe('gemini-2.5-flash');
+ });
+});
diff --git a/sources/sync/messageMeta.ts b/sources/sync/messageMeta.ts
new file mode 100644
index 000000000..d97b22055
--- /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 !== undefined ? { displayText: params.displayText } : {}),
+ ...(params.model !== undefined ? { model: params.model } : {}),
+ ...(params.fallbackModel !== undefined ? { fallbackModel: params.fallbackModel } : {}),
+ };
+}
diff --git a/sources/sync/modelOptions.ts b/sources/sync/modelOptions.ts
new file mode 100644
index 000000000..0278fd621
--- /dev/null
+++ b/sources/sync/modelOptions.ts
@@ -0,0 +1,33 @@
+import type { ModelMode } from './permissionTypes';
+import { t } from '@/text';
+
+export type AgentType = 'claude' | 'codex' | 'gemini';
+
+export type ModelOption = Readonly<{
+ value: ModelMode;
+ label: string;
+ description: string;
+}>;
+
+export function getModelOptionsForAgentType(agentType: AgentType): readonly ModelOption[] {
+ if (agentType === 'gemini') {
+ return [
+ {
+ value: 'gemini-2.5-pro',
+ label: t('agentInput.geminiModel.gemini25Pro.label'),
+ description: t('agentInput.geminiModel.gemini25Pro.description'),
+ },
+ {
+ value: 'gemini-2.5-flash',
+ label: t('agentInput.geminiModel.gemini25Flash.label'),
+ description: t('agentInput.geminiModel.gemini25Flash.description'),
+ },
+ {
+ value: 'gemini-2.5-flash-lite',
+ label: t('agentInput.geminiModel.gemini25FlashLite.label'),
+ description: t('agentInput.geminiModel.gemini25FlashLite.description'),
+ },
+ ];
+ }
+ return [];
+}
diff --git a/sources/sync/ops.ts b/sources/sync/ops.ts
index 07f70e694..eeb6bbccc 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:
@@ -146,7 +148,7 @@ export interface SpawnSessionOptions {
// - OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL, OPENAI_API_TIMEOUT_MS
// - AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_VERSION, AZURE_OPENAI_DEPLOYMENT_NAME
// - TOGETHER_API_KEY, TOGETHER_MODEL
- // - TMUX_SESSION_NAME, TMUX_TMPDIR, TMUX_UPDATE_ENVIRONMENT
+ // - TMUX_SESSION_NAME, TMUX_TMPDIR
// - API_TIMEOUT_MS, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
// - Custom variables (DEEPSEEK_*, Z_AI_*, etc.)
environmentVariables?: Record;
@@ -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) {
@@ -234,6 +237,83 @@ export async function machineBash(
}
}
+export type EnvPreviewSecretsPolicy = 'none' | 'redacted' | 'full';
+
+export interface PreviewEnvValue {
+ value: string | null;
+ isSet: boolean;
+ isSensitive: boolean;
+ display: 'full' | 'redacted' | 'hidden' | 'unset';
+}
+
+export interface PreviewEnvResponse {
+ policy: EnvPreviewSecretsPolicy;
+ values: Record;
+}
+
+interface PreviewEnvRequest {
+ keys: string[];
+ extraEnv?: Record;
+ sensitiveHints?: Record;
+}
+
+export type MachinePreviewEnvResult =
+ | { supported: true; response: PreviewEnvResponse }
+ | { supported: false };
+
+function isPlainObject(value: unknown): value is Record {
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
+}
+
+/**
+ * Preview environment variables exactly as the daemon will spawn them.
+ *
+ * This calls the daemon's `preview-env` RPC (if supported). The daemon computes:
+ * - effective env = { ...daemon.process.env, ...expand(extraEnv) }
+ * - applies `HAPPY_ENV_PREVIEW_SECRETS` policy for sensitive variables
+ *
+ * If the daemon is old and doesn't support `preview-env`, returns `{ supported: false }`.
+ */
+export async function machinePreviewEnv(
+ machineId: string,
+ params: PreviewEnvRequest
+): Promise {
+ try {
+ const result = await apiSocket.machineRPC(
+ machineId,
+ 'preview-env',
+ params
+ );
+
+ if (isPlainObject(result) && typeof result.error === 'string') {
+ // Older daemons (or errors) return an encrypted `{ error: ... }` payload.
+ // Treat method-not-found as “unsupported” and fallback to bash-based probing.
+ if (result.error === 'Method not found') {
+ return { supported: false };
+ }
+ // For any other error, degrade gracefully in UI by using fallback behavior.
+ return { supported: false };
+ }
+
+ // Basic shape validation (be defensive for mixed daemon versions).
+ if (
+ !isPlainObject(result) ||
+ (result.policy !== 'none' && result.policy !== 'redacted' && result.policy !== 'full') ||
+ !isPlainObject(result.values)
+ ) {
+ return { supported: false };
+ }
+
+ const response: PreviewEnvResponse = {
+ policy: result.policy as EnvPreviewSecretsPolicy,
+ values: result.values as unknown as Record,
+ };
+ return { supported: true, response };
+ } catch {
+ return { supported: false };
+ }
+}
+
/**
* Update machine metadata with optimistic concurrency control and automatic retry
*/
@@ -532,4 +612,4 @@ export type {
TreeNode,
SessionRipgrepResponse,
SessionKillResponse
-};
\ No newline at end of file
+};
diff --git a/sources/sync/permissionMapping.test.ts b/sources/sync/permissionMapping.test.ts
new file mode 100644
index 000000000..52bc50c20
--- /dev/null
+++ b/sources/sync/permissionMapping.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest';
+import { mapPermissionModeAcrossAgents } from './permissionMapping';
+
+describe('mapPermissionModeAcrossAgents', () => {
+ it('returns the same mode when from and to are the same', () => {
+ expect(mapPermissionModeAcrossAgents('plan', 'claude', 'claude')).toBe('plan');
+ });
+
+ it('maps Claude plan to Gemini safe-yolo', () => {
+ expect(mapPermissionModeAcrossAgents('plan', 'claude', 'gemini')).toBe('safe-yolo');
+ });
+
+ it('maps Claude bypassPermissions to Gemini yolo', () => {
+ expect(mapPermissionModeAcrossAgents('bypassPermissions', 'claude', 'gemini')).toBe('yolo');
+ });
+
+ it('maps Claude acceptEdits to Gemini safe-yolo', () => {
+ expect(mapPermissionModeAcrossAgents('acceptEdits', 'claude', 'gemini')).toBe('safe-yolo');
+ });
+
+ it('maps Codex yolo to Claude bypassPermissions', () => {
+ expect(mapPermissionModeAcrossAgents('yolo', 'codex', 'claude')).toBe('bypassPermissions');
+ });
+
+ it('maps Gemini safe-yolo to Claude plan', () => {
+ expect(mapPermissionModeAcrossAgents('safe-yolo', 'gemini', 'claude')).toBe('plan');
+ });
+
+ it('preserves read-only across agents', () => {
+ expect(mapPermissionModeAcrossAgents('read-only', 'claude', 'codex')).toBe('read-only');
+ expect(mapPermissionModeAcrossAgents('read-only', 'codex', 'claude')).toBe('read-only');
+ expect(mapPermissionModeAcrossAgents('read-only', 'gemini', 'claude')).toBe('read-only');
+ });
+
+ it('keeps Codex/Gemini modes unchanged when switching between them', () => {
+ expect(mapPermissionModeAcrossAgents('read-only', 'gemini', 'codex')).toBe('read-only');
+ expect(mapPermissionModeAcrossAgents('safe-yolo', 'codex', 'gemini')).toBe('safe-yolo');
+ });
+});
diff --git a/sources/sync/permissionMapping.ts b/sources/sync/permissionMapping.ts
new file mode 100644
index 000000000..5330454c6
--- /dev/null
+++ b/sources/sync/permissionMapping.ts
@@ -0,0 +1,52 @@
+import type { PermissionMode } from './permissionTypes';
+import type { AgentType } from './modelOptions';
+
+function isCodexLike(agent: AgentType) {
+ return agent === 'codex' || agent === 'gemini';
+}
+
+export function mapPermissionModeAcrossAgents(
+ mode: PermissionMode,
+ from: AgentType,
+ to: AgentType,
+): PermissionMode {
+ if (from === to) return mode;
+
+ const fromCodexLike = isCodexLike(from);
+ const toCodexLike = isCodexLike(to);
+
+ // Codex <-> Gemini uses the same permission mode set.
+ if (fromCodexLike && toCodexLike) return mode;
+
+ if (!fromCodexLike && toCodexLike) {
+ // Claude -> Codex/Gemini
+ switch (mode) {
+ case 'bypassPermissions':
+ return 'yolo';
+ case 'plan':
+ return 'safe-yolo';
+ case 'acceptEdits':
+ return 'safe-yolo';
+ case 'read-only':
+ return 'read-only';
+ case 'default':
+ return 'default';
+ default:
+ return 'default';
+ }
+ }
+
+ // Codex/Gemini -> Claude
+ switch (mode) {
+ case 'yolo':
+ return 'bypassPermissions';
+ case 'safe-yolo':
+ return 'plan';
+ case 'read-only':
+ return 'read-only';
+ case 'default':
+ return 'default';
+ default:
+ return 'default';
+ }
+}
diff --git a/sources/sync/permissionTypes.test.ts b/sources/sync/permissionTypes.test.ts
new file mode 100644
index 000000000..c585b4c41
--- /dev/null
+++ b/sources/sync/permissionTypes.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from 'vitest';
+import type { PermissionMode } from './permissionTypes';
+import {
+ isModelMode,
+ isPermissionMode,
+ normalizePermissionModeForAgentFlavor,
+ normalizeProfileDefaultPermissionMode,
+} from './permissionTypes';
+
+describe('normalizePermissionModeForAgentFlavor', () => {
+ it('clamps non-codex permission modes to default for codex', () => {
+ expect(normalizePermissionModeForAgentFlavor('plan', 'codex')).toBe('default');
+ });
+
+ it('clamps codex-like permission modes to default for claude', () => {
+ expect(normalizePermissionModeForAgentFlavor('read-only', 'claude')).toBe('default');
+ });
+
+ it('preserves codex-like modes for gemini', () => {
+ expect(normalizePermissionModeForAgentFlavor('safe-yolo', 'gemini')).toBe('safe-yolo');
+ expect(normalizePermissionModeForAgentFlavor('yolo', 'gemini')).toBe('yolo');
+ });
+
+ it('preserves claude modes for claude', () => {
+ const modes: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions'];
+ for (const mode of modes) {
+ expect(normalizePermissionModeForAgentFlavor(mode, 'claude')).toBe(mode);
+ }
+ });
+});
+
+describe('isPermissionMode', () => {
+ it('returns true for valid permission modes', () => {
+ expect(isPermissionMode('default')).toBe(true);
+ expect(isPermissionMode('read-only')).toBe(true);
+ expect(isPermissionMode('plan')).toBe(true);
+ });
+
+ it('returns false for invalid values', () => {
+ expect(isPermissionMode('bogus')).toBe(false);
+ expect(isPermissionMode(null)).toBe(false);
+ expect(isPermissionMode(123)).toBe(false);
+ });
+});
+
+describe('normalizeProfileDefaultPermissionMode', () => {
+ it('clamps codex-like modes to default for profile defaultPermissionMode', () => {
+ expect(normalizeProfileDefaultPermissionMode('read-only')).toBe('default');
+ expect(normalizeProfileDefaultPermissionMode('safe-yolo')).toBe('default');
+ expect(normalizeProfileDefaultPermissionMode('yolo')).toBe('default');
+ });
+});
+
+describe('isModelMode', () => {
+ it('returns true for valid model modes', () => {
+ expect(isModelMode('default')).toBe(true);
+ expect(isModelMode('adaptiveUsage')).toBe(true);
+ expect(isModelMode('gemini-2.5-pro')).toBe(true);
+ });
+
+ it('returns false for invalid values', () => {
+ expect(isModelMode('bogus')).toBe(false);
+ expect(isModelMode(null)).toBe(false);
+ });
+});
diff --git a/sources/sync/permissionTypes.ts b/sources/sync/permissionTypes.ts
new file mode 100644
index 000000000..b85972a1d
--- /dev/null
+++ b/sources/sync/permissionTypes.ts
@@ -0,0 +1,62 @@
+export type PermissionMode =
+ | 'default'
+ | 'acceptEdits'
+ | 'bypassPermissions'
+ | 'plan'
+ | 'read-only'
+ | 'safe-yolo'
+ | 'yolo';
+
+const ALL_PERMISSION_MODES = [
+ 'default',
+ 'acceptEdits',
+ 'bypassPermissions',
+ 'plan',
+ 'read-only',
+ 'safe-yolo',
+ 'yolo',
+] as const;
+
+export const CLAUDE_PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions'] as const;
+export const CODEX_LIKE_PERMISSION_MODES = ['default', 'read-only', 'safe-yolo', 'yolo'] as const;
+
+export type AgentFlavor = 'claude' | 'codex' | 'gemini';
+
+export function isPermissionMode(value: unknown): value is PermissionMode {
+ return typeof value === 'string' && (ALL_PERMISSION_MODES as readonly string[]).includes(value);
+}
+
+export function normalizePermissionModeForAgentFlavor(mode: PermissionMode, flavor: AgentFlavor): PermissionMode {
+ if (flavor === 'codex' || flavor === 'gemini') {
+ return (CODEX_LIKE_PERMISSION_MODES as readonly string[]).includes(mode) ? mode : 'default';
+ }
+ return (CLAUDE_PERMISSION_MODES as readonly string[]).includes(mode) ? mode : 'default';
+}
+
+export function normalizeProfileDefaultPermissionMode(mode: PermissionMode | null | undefined): PermissionMode {
+ if (!mode) return 'default';
+ return (CLAUDE_PERMISSION_MODES as readonly string[]).includes(mode) ? mode : 'default';
+}
+
+export const MODEL_MODES = [
+ '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',
+] as const;
+
+export type ModelMode = (typeof MODEL_MODES)[number];
+
+export function isModelMode(value: unknown): value is ModelMode {
+ return typeof value === 'string' && (MODEL_MODES as readonly string[]).includes(value);
+}
diff --git a/sources/sync/persistence.test.ts b/sources/sync/persistence.test.ts
new file mode 100644
index 000000000..0e15b8c3c
--- /dev/null
+++ b/sources/sync/persistence.test.ts
@@ -0,0 +1,114 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const store = new Map();
+
+vi.mock('react-native-mmkv', () => {
+ class MMKV {
+ getString(key: string) {
+ return store.get(key);
+ }
+
+ set(key: string, value: string) {
+ store.set(key, value);
+ }
+
+ delete(key: string) {
+ store.delete(key);
+ }
+
+ clearAll() {
+ store.clear();
+ }
+ }
+
+ return { MMKV };
+});
+
+import { clearPersistence, loadNewSessionDraft, loadSessionModelModes, saveSessionModelModes } from './persistence';
+
+describe('persistence', () => {
+ beforeEach(() => {
+ clearPersistence();
+ });
+
+ describe('session model modes', () => {
+ it('returns an empty object when nothing is persisted', () => {
+ expect(loadSessionModelModes()).toEqual({});
+ });
+
+ it('roundtrips session model modes', () => {
+ saveSessionModelModes({ abc: 'gemini-2.5-pro' });
+ expect(loadSessionModelModes()).toEqual({ abc: 'gemini-2.5-pro' });
+ });
+
+ it('filters out invalid persisted model modes', () => {
+ store.set(
+ 'session-model-modes',
+ JSON.stringify({ abc: 'gemini-2.5-pro', bad: 'adaptiveUsage' }),
+ );
+ expect(loadSessionModelModes()).toEqual({ abc: 'gemini-2.5-pro' });
+ });
+ });
+
+ describe('new session draft', () => {
+ it('preserves valid non-session modelMode values', () => {
+ store.set(
+ 'new-session-draft-v1',
+ JSON.stringify({
+ input: '',
+ selectedMachineId: null,
+ selectedPath: null,
+ selectedProfileId: null,
+ agentType: 'claude',
+ permissionMode: 'default',
+ modelMode: 'adaptiveUsage',
+ sessionType: 'simple',
+ updatedAt: Date.now(),
+ }),
+ );
+
+ const draft = loadNewSessionDraft();
+ expect(draft?.modelMode).toBe('adaptiveUsage');
+ });
+
+ it('clamps invalid permissionMode to default', () => {
+ store.set(
+ 'new-session-draft-v1',
+ JSON.stringify({
+ input: '',
+ selectedMachineId: null,
+ selectedPath: null,
+ selectedProfileId: null,
+ agentType: 'gemini',
+ permissionMode: 'bogus',
+ modelMode: 'default',
+ sessionType: 'simple',
+ updatedAt: Date.now(),
+ }),
+ );
+
+ const draft = loadNewSessionDraft();
+ expect(draft?.permissionMode).toBe('default');
+ });
+
+ it('clamps invalid modelMode to default', () => {
+ store.set(
+ 'new-session-draft-v1',
+ JSON.stringify({
+ input: '',
+ selectedMachineId: null,
+ selectedPath: null,
+ selectedProfileId: null,
+ agentType: 'gemini',
+ permissionMode: 'default',
+ modelMode: 'not-a-real-model',
+ sessionType: 'simple',
+ updatedAt: Date.now(),
+ }),
+ );
+
+ const draft = loadNewSessionDraft();
+ expect(draft?.modelMode).toBe('default');
+ });
+ });
+});
diff --git a/sources/sync/persistence.ts b/sources/sync/persistence.ts
index 2f9367523..afe07faca 100644
--- a/sources/sync/persistence.ts
+++ b/sources/sync/persistence.ts
@@ -3,20 +3,42 @@ 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 { Session } from './storageTypes';
+import { isModelMode, isPermissionMode, type PermissionMode, type ModelMode } from '@/sync/permissionTypes';
+import { readStorageScopeFromEnv, scopedStorageId } from '@/utils/storageScope';
-const mmkv = new MMKV();
+const isWebRuntime = typeof window !== 'undefined' && typeof document !== 'undefined';
+const storageScope = isWebRuntime ? null : readStorageScopeFromEnv();
+const mmkv = storageScope ? new MMKV({ id: scopedStorageId('default', storageScope) }) : new MMKV();
const NEW_SESSION_DRAFT_KEY = 'new-session-draft-v1';
export type NewSessionAgentType = 'claude' | 'codex' | 'gemini';
export type NewSessionSessionType = 'simple' | 'worktree';
+type SessionModelMode = NonNullable;
+
+// NOTE:
+// This set must stay in sync with the configurable Session model modes.
+// TypeScript will catch invalid entries here, but it won't force adding new Session modes.
+const SESSION_MODEL_MODES = new Set([
+ 'default',
+ 'gemini-2.5-pro',
+ 'gemini-2.5-flash',
+ 'gemini-2.5-flash-lite',
+]);
+
+function isSessionModelMode(value: unknown): value is SessionModelMode {
+ return typeof value === 'string' && SESSION_MODEL_MODES.has(value as SessionModelMode);
+}
+
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 +48,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,11 +162,15 @@ 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)
+ const permissionMode: PermissionMode = isPermissionMode(parsed.permissionMode)
+ ? parsed.permissionMode
+ : 'default';
+ const modelMode: ModelMode = isModelMode(parsed.modelMode)
+ ? parsed.modelMode
: 'default';
const sessionType: NewSessionSessionType = parsed.sessionType === 'worktree' ? 'worktree' : 'simple';
const updatedAt = typeof parsed.updatedAt === 'number' ? parsed.updatedAt : Date.now();
@@ -152,8 +179,10 @@ export function loadNewSessionDraft(): NewSessionDraft | null {
input,
selectedMachineId,
selectedPath,
+ selectedProfileId,
agentType,
permissionMode,
+ modelMode,
sessionType,
updatedAt,
};
@@ -188,6 +217,34 @@ export function saveSessionPermissionModes(modes: Record
mmkv.set('session-permission-modes', JSON.stringify(modes));
}
+export function loadSessionModelModes(): Record {
+ const modes = mmkv.getString('session-model-modes');
+ if (modes) {
+ try {
+ const parsed: unknown = JSON.parse(modes);
+ if (!parsed || typeof parsed !== 'object') {
+ return {};
+ }
+
+ const result: Record = {};
+ Object.entries(parsed as Record).forEach(([sessionId, mode]) => {
+ if (isSessionModelMode(mode)) {
+ result[sessionId] = mode;
+ }
+ });
+ return result;
+ } catch (e) {
+ console.error('Failed to parse session model modes', e);
+ return {};
+ }
+ }
+ return {};
+}
+
+export function saveSessionModelModes(modes: Record) {
+ mmkv.set('session-model-modes', JSON.stringify(modes));
+}
+
export function loadProfile(): Profile {
const profile = mmkv.getString('profile');
if (profile) {
@@ -225,4 +282,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.test.ts b/sources/sync/profileGrouping.test.ts
new file mode 100644
index 000000000..5a08b3ac5
--- /dev/null
+++ b/sources/sync/profileGrouping.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest';
+import { buildProfileGroups, toggleFavoriteProfileId } from './profileGrouping';
+
+describe('toggleFavoriteProfileId', () => {
+ it('adds the profile id to the front when missing', () => {
+ expect(toggleFavoriteProfileId([], 'anthropic')).toEqual(['anthropic']);
+ });
+
+ it('removes the profile id when already present', () => {
+ expect(toggleFavoriteProfileId(['anthropic', 'openai'], 'anthropic')).toEqual(['openai']);
+ });
+
+ it('supports favoriting the default environment (empty profile id)', () => {
+ expect(toggleFavoriteProfileId(['anthropic'], '')).toEqual(['', 'anthropic']);
+ expect(toggleFavoriteProfileId(['', 'anthropic'], '')).toEqual(['anthropic']);
+ });
+});
+
+describe('buildProfileGroups', () => {
+ it('filters favoriteIds to resolvable profiles (preserves default environment favorite)', () => {
+ const customProfiles = [
+ {
+ id: 'custom-profile',
+ name: 'Custom Profile',
+ environmentVariables: [],
+ compatibility: { claude: true, codex: true, gemini: true },
+ isBuiltIn: false,
+ createdAt: 0,
+ updatedAt: 0,
+ version: '1.0.0',
+ },
+ ];
+
+ const groups = buildProfileGroups({
+ customProfiles,
+ favoriteProfileIds: ['', 'anthropic', 'missing-profile', 'custom-profile'],
+ });
+
+ expect(groups.favoriteIds.has('')).toBe(true);
+ expect(groups.favoriteIds.has('anthropic')).toBe(true);
+ expect(groups.favoriteIds.has('custom-profile')).toBe(true);
+ expect(groups.favoriteIds.has('missing-profile')).toBe(false);
+ });
+});
diff --git a/sources/sync/profileGrouping.ts b/sources/sync/profileGrouping.ts
new file mode 100644
index 000000000..d493bc7d9
--- /dev/null
+++ b/sources/sync/profileGrouping.ts
@@ -0,0 +1,67 @@
+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 toggleFavoriteProfileId(favoriteProfileIds: string[], profileId: string): string[] {
+ const normalized: string[] = [];
+ const seen = new Set();
+ for (const id of favoriteProfileIds) {
+ if (seen.has(id)) continue;
+ seen.add(id);
+ normalized.push(id);
+ }
+
+ if (seen.has(profileId)) {
+ return normalized.filter((id) => id !== profileId);
+ }
+
+ return [profileId, ...normalized];
+}
+
+export function buildProfileGroups({
+ customProfiles,
+ favoriteProfileIds,
+}: {
+ customProfiles: AIBackendProfile[];
+ favoriteProfileIds: string[];
+}): ProfileGroups {
+ const builtInIds = new Set(DEFAULT_PROFILES.map((profile) => profile.id));
+
+ 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 favoriteIds = new Set(favoriteProfiles.map((profile) => profile.id));
+ // Preserve "default environment" favorite marker (not a real profile object).
+ if (favoriteProfileIds.includes('')) {
+ favoriteIds.add('');
+ }
+
+ 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..340093911
--- /dev/null
+++ b/sources/sync/profileMutations.ts
@@ -0,0 +1,38 @@
+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, opts?: { copySuffix?: string }): AIBackendProfile {
+ const suffix = opts?.copySuffix ?? '(Copy)';
+ const separator = profile.name.trim().length > 0 ? ' ' : '';
+ return {
+ ...profile,
+ id: randomUUID(),
+ name: `${profile.name}${separator}${suffix}`,
+ 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.test.ts b/sources/sync/profileUtils.test.ts
new file mode 100644
index 000000000..f6f1553c8
--- /dev/null
+++ b/sources/sync/profileUtils.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest';
+import { getBuiltInProfileNameKey, getProfilePrimaryCli } from './profileUtils';
+
+describe('getProfilePrimaryCli', () => {
+ it('ignores unknown compatibility keys', () => {
+ const profile = {
+ compatibility: { unknownCli: true },
+ } as any;
+
+ expect(getProfilePrimaryCli(profile)).toBe('none');
+ });
+});
+
+describe('getBuiltInProfileNameKey', () => {
+ it('returns the translation key for known built-in profile ids', () => {
+ expect(getBuiltInProfileNameKey('anthropic')).toBe('profiles.builtInNames.anthropic');
+ expect(getBuiltInProfileNameKey('deepseek')).toBe('profiles.builtInNames.deepseek');
+ expect(getBuiltInProfileNameKey('zai')).toBe('profiles.builtInNames.zai');
+ expect(getBuiltInProfileNameKey('openai')).toBe('profiles.builtInNames.openai');
+ expect(getBuiltInProfileNameKey('azure-openai')).toBe('profiles.builtInNames.azureOpenai');
+ });
+
+ it('returns null for unknown ids', () => {
+ expect(getBuiltInProfileNameKey('unknown')).toBeNull();
+ });
+});
diff --git a/sources/sync/profileUtils.ts b/sources/sync/profileUtils.ts
index d90a98a93..ca04c41bb 100644
--- a/sources/sync/profileUtils.ts
+++ b/sources/sync/profileUtils.ts
@@ -1,5 +1,47 @@
import { AIBackendProfile } from './settings';
+export type ProfilePrimaryCli = 'claude' | 'codex' | 'gemini' | 'multi' | 'none';
+
+export type BuiltInProfileId = 'anthropic' | 'deepseek' | 'zai' | 'openai' | 'azure-openai';
+
+export type BuiltInProfileNameKey =
+ | 'profiles.builtInNames.anthropic'
+ | 'profiles.builtInNames.deepseek'
+ | 'profiles.builtInNames.zai'
+ | 'profiles.builtInNames.openai'
+ | 'profiles.builtInNames.azureOpenai';
+
+const ALLOWED_PROFILE_CLIS = new Set(['claude', 'codex', 'gemini']);
+
+export function getProfilePrimaryCli(profile: AIBackendProfile | null | undefined): ProfilePrimaryCli {
+ if (!profile) return 'none';
+ const supported = Object.entries(profile.compatibility ?? {})
+ .filter(([, isSupported]) => isSupported)
+ .map(([cli]) => cli)
+ .filter((cli): cli is 'claude' | 'codex' | 'gemini' => ALLOWED_PROFILE_CLIS.has(cli));
+
+ if (supported.length === 0) return 'none';
+ if (supported.length === 1) return supported[0];
+ return 'multi';
+}
+
+export function getBuiltInProfileNameKey(id: string): BuiltInProfileNameKey | null {
+ switch (id as BuiltInProfileId) {
+ case 'anthropic':
+ return 'profiles.builtInNames.anthropic';
+ case 'deepseek':
+ return 'profiles.builtInNames.deepseek';
+ case 'zai':
+ return 'profiles.builtInNames.zai';
+ case 'openai':
+ return 'profiles.builtInNames.openai';
+ case 'azure-openai':
+ return 'profiles.builtInNames.azureOpenai';
+ default:
+ return null;
+ }
+}
+
/**
* Documentation and expected values for built-in profiles.
* These help users understand what environment variables to set and their expected values.
@@ -242,7 +284,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 +297,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 +322,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 +346,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 +364,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/serverConfig.ts b/sources/sync/serverConfig.ts
index fedea04df..b52f452d0 100644
--- a/sources/sync/serverConfig.ts
+++ b/sources/sync/serverConfig.ts
@@ -1,7 +1,10 @@
import { MMKV } from 'react-native-mmkv';
+import { readStorageScopeFromEnv, scopedStorageId } from '@/utils/storageScope';
// Separate MMKV instance for server config that persists across logouts
-const serverConfigStorage = new MMKV({ id: 'server-config' });
+const isWebRuntime = typeof window !== 'undefined' && typeof document !== 'undefined';
+const serverConfigScope = isWebRuntime ? null : readStorageScopeFromEnv();
+const serverConfigStorage = new MMKV({ id: scopedStorageId('server-config', serverConfigScope) });
const SERVER_KEY = 'custom-server-url';
const DEFAULT_SERVER_URL = 'https://api.cluster-fluster.com';
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..c42eb8391 100644
--- a/sources/sync/settings.ts
+++ b/sources/sync/settings.ts
@@ -4,77 +4,10 @@ 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(),
tmpDir: z.string().optional(),
- updateEnvironment: z.boolean().optional(),
});
// Environment variables schema with validation
@@ -97,18 +30,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 +64,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 +136,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 +151,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,43 +163,12 @@ 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
if (profile.tmuxConfig.sessionName !== undefined) envVars.TMUX_SESSION_NAME = profile.tmuxConfig.sessionName;
// Empty string may be valid for tmpDir to use tmux defaults
if (profile.tmuxConfig.tmpDir !== undefined) envVars.TMUX_TMPDIR = profile.tmuxConfig.tmpDir;
- if (profile.tmuxConfig.updateEnvironment !== undefined) {
- envVars.TMUX_UPDATE_ENVIRONMENT = profile.tmuxConfig.updateEnvironment.toString();
- }
}
return envVars;
@@ -249,6 +197,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 +213,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 +243,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 +289,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 +311,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 +332,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 48e7ab771..83d5c716d 100644
--- a/sources/sync/storage.ts
+++ b/sources/sync/storage.ts
@@ -11,8 +11,8 @@ import { Purchases, customerInfoToPurchases } from "./purchases";
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 { loadSettings, loadLocalSettings, saveLocalSettings, saveSettings, loadPurchases, savePurchases, loadProfile, saveProfile, loadSessionDrafts, saveSessionDrafts, loadSessionPermissionModes, saveSessionPermissionModes, loadSessionModelModes, saveSessionModelModes } from "./persistence";
+import type { PermissionMode } from '@/sync/permissionTypes';
import type { CustomerInfo } from './revenueCat/types';
import React from "react";
import { sync } from "./sync";
@@ -46,6 +46,8 @@ function isSessionActive(session: { active: boolean; activeAt: number }): boolea
// Known entitlement IDs
export type KnownEntitlements = 'pro';
+type SessionModelMode = NonNullable;
+
interface SessionMessages {
messages: Message[];
messagesMap: Record;
@@ -102,6 +104,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;
@@ -250,6 +253,7 @@ export const storage = create()((set, get) => {
let profile = loadProfile();
let sessionDrafts = loadSessionDrafts();
let sessionPermissionModes = loadSessionPermissionModes();
+ let sessionModelModes = loadSessionModelModes();
return {
settings,
settingsVersion: version,
@@ -303,6 +307,7 @@ export const storage = create()((set, get) => {
// Load drafts and permission modes if sessions are empty (initial load)
const savedDrafts = Object.keys(state.sessions).length === 0 ? sessionDrafts : {};
const savedPermissionModes = Object.keys(state.sessions).length === 0 ? sessionPermissionModes : {};
+ const savedModelModes = Object.keys(state.sessions).length === 0 ? sessionModelModes : {};
// Merge new sessions with existing ones
const mergedSessions: Record = { ...state.sessions };
@@ -317,11 +322,14 @@ export const storage = create()((set, get) => {
const savedDraft = savedDrafts[session.id];
const existingPermissionMode = state.sessions[session.id]?.permissionMode;
const savedPermissionMode = savedPermissionModes[session.id];
+ const existingModelMode = state.sessions[session.id]?.modelMode;
+ const savedModelMode = savedModelModes[session.id];
mergedSessions[session.id] = {
...session,
presence,
draft: existingDraft || savedDraft || session.draft || null,
- permissionMode: existingPermissionMode || savedPermissionMode || session.permissionMode || 'default'
+ permissionMode: existingPermissionMode || savedPermissionMode || session.permissionMode || 'default',
+ modelMode: existingModelMode || savedModelMode || session.modelMode || 'default',
};
});
@@ -366,8 +374,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 +390,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 +399,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 +625,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 +636,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);
@@ -821,6 +825,16 @@ export const storage = create()((set, get) => {
}
};
+ // Collect all model modes for persistence (only non-default values to save space)
+ const allModes: Record = {};
+ Object.entries(updatedSessions).forEach(([id, sess]) => {
+ if (sess.modelMode && sess.modelMode !== 'default') {
+ allModes[id] = sess.modelMode;
+ }
+ });
+
+ saveSessionModelModes(allModes);
+
// No need to rebuild sessionListViewData since model mode doesn't affect the list display
return {
...state,
@@ -871,12 +885,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,
@@ -931,6 +943,10 @@ export const storage = create()((set, get) => {
const modes = loadSessionPermissionModes();
delete modes[sessionId];
saveSessionPermissionModes(modes);
+
+ const modelModes = loadSessionModelModes();
+ delete modelModes[sessionId];
+ saveSessionModelModes(modelModes);
// Rebuild sessionListViewData without the deleted session
const sessionListViewData = buildSessionListViewData(remainingSessions);
diff --git a/sources/sync/storageTypes.ts b/sources/sync/storageTypes.ts
index 82fedb5c1..a42b46cd1 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()
@@ -69,8 +70,8 @@ export interface Session {
id: string;
}>;
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' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite' | null; // Local model mode, not synced to server
+ permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'; // Local permission mode, not synced to server
+ modelMode?: 'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite'; // 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.
@@ -153,4 +154,4 @@ export interface GitStatus {
aheadCount?: number; // Commits ahead of upstream
behindCount?: number; // Commits behind upstream
stashCount?: number; // Number of stash entries
-}
\ No newline at end of file
+}
diff --git a/sources/sync/sync.ts b/sources/sync/sync.ts
index 5393a3651..e2c43a708 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.
@@ -251,14 +252,7 @@ class Sync {
sentFrom = 'web'; // fallback
}
- // Model settings - for Gemini, we pass the selected model; for others, CLI handles it
- let model: string | null = null;
- if (isGemini && modelMode !== 'default') {
- // For Gemini ACP, pass the selected model to CLI
- model = modelMode;
- }
- const fallbackModel: string | null = null;
-
+ const model = isGemini && modelMode !== 'default' ? modelMode : undefined;
// Create user message content with metadata
const content: RawRecord = {
role: 'user',
@@ -266,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);
@@ -843,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: {
@@ -858,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;
@@ -1189,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 {
@@ -1230,12 +1216,6 @@ 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);
@@ -1267,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);
}
@@ -1314,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) {
@@ -1333,7 +1302,7 @@ class Sync {
});
}
} catch (error) {
- console.log('[fetchNativeUpdate] Error:', error);
+ console.error('[fetchNativeUpdate] Error:', error);
storage.getState().applyNativeUpdateStatus(null);
}
}
@@ -1354,7 +1323,6 @@ class Sync {
}
if (!apiKey) {
- console.log(`RevenueCat: No API key found for platform ${Platform.OS}`);
return;
}
@@ -1371,7 +1339,6 @@ class Sync {
});
this.revenueCatInitialized = true;
- console.log('RevenueCat initialized successfully');
}
// Sync purchases
@@ -1438,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);
@@ -1467,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;
}
@@ -1515,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') {
@@ -1549,7 +1510,8 @@ class Sync {
const dataType = rawContent?.content?.data?.type;
// Debug logging to trace lifecycle events
- if (dataType === 'task_complete' || dataType === 'turn_aborted' || dataType === 'task_started') {
+ const isDev = typeof __DEV__ !== 'undefined' && __DEV__;
+ if (isDev && (dataType === 'task_complete' || dataType === 'turn_aborted' || dataType === 'task_started')) {
console.log(`🔄 [Sync] Lifecycle event detected: contentType=${contentType}, dataType=${dataType}`);
}
@@ -1560,7 +1522,7 @@ class Sync {
const isTaskStarted =
((contentType === 'acp' || contentType === 'codex') && dataType === 'task_started');
- if (isTaskComplete || isTaskStarted) {
+ if (isDev && (isTaskComplete || isTaskStarted)) {
console.log(`🔄 [Sync] Updating thinking state: isTaskComplete=${isTaskComplete}, isTaskStarted=${isTaskStarted}`);
}
@@ -1582,7 +1544,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') {
@@ -1968,7 +1929,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`);
}
@@ -1977,17 +1937,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.spec.ts b/sources/sync/typesRaw.spec.ts
index 29178a25d..55851f426 100644
--- a/sources/sync/typesRaw.spec.ts
+++ b/sources/sync/typesRaw.spec.ts
@@ -1489,4 +1489,136 @@ describe('Zod Transform - WOLOG Content Normalization', () => {
}
});
});
+
+ describe('ACP tool result normalization', () => {
+ it('normalizes ACP tool-result output to text', () => {
+ const raw = {
+ role: 'agent' as const,
+ content: {
+ type: 'acp' as const,
+ provider: 'gemini' as const,
+ data: {
+ type: 'tool-result' as const,
+ callId: 'call_abc123',
+ output: [{ type: 'text', text: 'hello' }],
+ id: 'acp-msg-1',
+ },
+ },
+ };
+
+ const normalized = normalizeRawMessage('msg-1', null, Date.now(), raw);
+ expect(normalized?.role).toBe('agent');
+ if (normalized && normalized.role === 'agent') {
+ const item = normalized.content[0];
+ expect(item.type).toBe('tool-result');
+ if (item.type === 'tool-result') {
+ expect(item.content).toBe('hello');
+ }
+ }
+ });
+
+ it('normalizes ACP tool-call-result output to text', () => {
+ const raw = {
+ role: 'agent' as const,
+ content: {
+ type: 'acp' as const,
+ provider: 'gemini' as const,
+ data: {
+ type: 'tool-call-result' as const,
+ callId: 'call_abc123',
+ output: [{ type: 'text', text: 'hello' }],
+ id: 'acp-msg-2',
+ },
+ },
+ };
+
+ const normalized = normalizeRawMessage('msg-2', null, Date.now(), raw);
+ expect(normalized?.role).toBe('agent');
+ if (normalized && normalized.role === 'agent') {
+ const item = normalized.content[0];
+ expect(item.type).toBe('tool-result');
+ if (item.type === 'tool-result') {
+ expect(item.content).toBe('hello');
+ }
+ }
+ });
+
+ it('normalizes ACP tool-result string output to text', () => {
+ const raw = {
+ role: 'agent' as const,
+ content: {
+ type: 'acp' as const,
+ provider: 'gemini' as const,
+ data: {
+ type: 'tool-result' as const,
+ callId: 'call_abc123',
+ output: 'direct string',
+ id: 'acp-msg-3',
+ },
+ },
+ };
+
+ const normalized = normalizeRawMessage('msg-3', null, Date.now(), raw);
+ expect(normalized?.role).toBe('agent');
+ if (normalized && normalized.role === 'agent') {
+ const item = normalized.content[0];
+ expect(item.type).toBe('tool-result');
+ if (item.type === 'tool-result') {
+ expect(item.content).toBe('direct string');
+ }
+ }
+ });
+
+ it('normalizes ACP tool-result object output to JSON text', () => {
+ const raw = {
+ role: 'agent' as const,
+ content: {
+ type: 'acp' as const,
+ provider: 'gemini' as const,
+ data: {
+ type: 'tool-result' as const,
+ callId: 'call_abc123',
+ output: { key: 'value' },
+ id: 'acp-msg-4',
+ },
+ },
+ };
+
+ const normalized = normalizeRawMessage('msg-4', null, Date.now(), raw);
+ expect(normalized?.role).toBe('agent');
+ if (normalized && normalized.role === 'agent') {
+ const item = normalized.content[0];
+ expect(item.type).toBe('tool-result');
+ if (item.type === 'tool-result') {
+ expect(item.content).toBe(JSON.stringify({ key: 'value' }));
+ }
+ }
+ });
+
+ it('normalizes ACP tool-result null output to empty text', () => {
+ const raw = {
+ role: 'agent' as const,
+ content: {
+ type: 'acp' as const,
+ provider: 'gemini' as const,
+ data: {
+ type: 'tool-result' as const,
+ callId: 'call_abc123',
+ output: null,
+ id: 'acp-msg-5',
+ },
+ },
+ };
+
+ const normalized = normalizeRawMessage('msg-5', null, Date.now(), raw);
+ expect(normalized?.role).toBe('agent');
+ if (normalized && normalized.role === 'agent') {
+ const item = normalized.content[0];
+ expect(item.type).toBe('tool-result');
+ if (item.type === 'tool-result') {
+ expect(item.content).toBe('');
+ }
+ }
+ });
+ });
});
diff --git a/sources/sync/typesRaw.ts b/sources/sync/typesRaw.ts
index aa7b2ed82..b408a9053 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(),
@@ -246,13 +248,13 @@ const rawAgentRecordSchema = z.discriminatedUnion('type', [z.object({
oldContent: z.string().optional(),
newContent: z.string().optional(),
id: z.string()
- }),
+ }).passthrough(),
// Terminal/command output
z.object({
type: z.literal('terminal-output'),
data: z.string(),
callId: z.string()
- }),
+ }).passthrough(),
// Task lifecycle events
z.object({ type: z.literal('task_started'), id: z.string() }),
z.object({ type: z.literal('task_complete'), id: z.string() }),
@@ -264,7 +266,7 @@ const rawAgentRecordSchema = z.discriminatedUnion('type', [z.object({
toolName: z.string(),
description: z.string(),
options: z.any().optional()
- }),
+ }).passthrough(),
// Usage/metrics
z.object({ type: z.literal('token_count') }).passthrough()
])
@@ -402,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,
@@ -525,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,
@@ -630,7 +666,7 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA
content: [{
type: 'tool-result',
tool_use_id: raw.content.data.callId,
- content: raw.content.data.output,
+ content: toolResultContentToText(raw.content.data.output),
is_error: false,
uuid: raw.content.data.id,
parentUUID: null
@@ -702,7 +738,7 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA
content: [{
type: 'tool-result',
tool_use_id: raw.content.data.callId,
- content: raw.content.data.output,
+ content: toolResultContentToText(raw.content.data.output),
is_error: raw.content.data.isError ?? false,
uuid: raw.content.data.id,
parentUUID: null
@@ -721,7 +757,7 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA
content: [{
type: 'tool-result',
tool_use_id: raw.content.data.callId,
- content: raw.content.data.output,
+ content: toolResultContentToText(raw.content.data.output),
is_error: false,
uuid: raw.content.data.id,
parentUUID: null
@@ -815,4 +851,4 @@ export function normalizeRawMessage(id: string, localId: string | null, createdA
}
}
return null;
-}
\ No newline at end of file
+}
diff --git a/sources/text/README.md b/sources/text/README.md
index 09128f3ef..38551135d 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,13 @@ export const en = {
} as const;
```
+### `_types.ts`
+Contains the TypeScript types derived from the English translation structure.
+
+This keeps the canonical translation object (`translations/en.ts`) separate from the type-level API:
+- `Translations` / `TranslationStructure` are derived from `en` and used to type-check other locales.
+- `TranslationKey` / `TranslationParams` are derived from `Translations` (in `index.ts`) to type `t(...)`.
+
### `index.ts`
Main module with the `t` function and utilities:
- `t()` - Main translation function with strict typing
@@ -164,7 +171,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',
@@ -215,9 +222,9 @@ statusMessage: ({ files, online, syncing }: {
## Future Expansion
To add more languages:
-1. Create new translation files (e.g., `_spanish.ts`)
+1. Create new translation files (e.g., `translations/es.ts`)
2. Update types to include new locales
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 0a94f0590..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',
- readOnly: 'Read Only',
- safeYolo: 'Safe YOLO',
- yolo: 'YOLO',
- badgeReadOnly: 'Read Only',
- badgeSafeYolo: 'Safe YOLO',
- badgeYolo: 'YOLO',
- },
- 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 provide feedback',
- }
- },
-
- 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..435f5471e
--- /dev/null
+++ b/sources/text/_types.ts
@@ -0,0 +1,3 @@
+export type { TranslationStructure } from './translations/en';
+
+export type Translations = import('./translations/en').TranslationStructure;
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 46f9d4f9c..ee8d1b1af 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
@@ -31,6 +31,8 @@ export const ca: TranslationStructure = {
common: {
// Simple string constants
+ add: 'Afegeix',
+ actions: 'Accions',
cancel: 'Cancel·la',
authenticate: 'Autentica',
save: 'Desa',
@@ -47,6 +49,9 @@ export const ca: TranslationStructure = {
yes: 'Sí',
no: 'No',
discard: 'Descarta',
+ discardChanges: 'Descarta els canvis',
+ unsavedChangesWarning: 'Tens canvis sense desar.',
+ keepEditing: 'Continua editant',
version: 'Versió',
copied: 'Copiat',
copy: 'Copiar',
@@ -60,6 +65,10 @@ export const ca: TranslationStructure = {
retry: 'Torna-ho a provar',
delete: 'Elimina',
optional: 'Opcional',
+ noMatches: 'Sense coincidències',
+ all: 'All',
+ machine: 'màquina',
+ clearSearch: 'Clear search',
},
profile: {
@@ -208,6 +217,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: 'Perfils d\'IA',
+ profilesEnabled: 'Selecció de perfils activada',
+ profilesDisabled: 'Selecció de perfils desactivada',
+ pickerSearch: 'Cerca als selectors',
+ pickerSearchSubtitle: 'Mostra un camp de cerca als selectors de màquina i camí',
+ machinePickerSearch: 'Cerca de màquines',
+ machinePickerSearchSubtitle: 'Mostra un camp de cerca als selectors de màquines',
+ pathPickerSearch: 'Cerca de camins',
+ pathPickerSearchSubtitle: 'Mostra un camp de cerca als selectors de camins',
},
errors: {
@@ -260,6 +278,9 @@ export const ca: TranslationStructure = {
newSession: {
// Used by new-session screen and launch flows
title: 'Inicia una nova sessió',
+ selectMachineTitle: 'Selecciona màquina',
+ selectPathTitle: 'Selecciona camí',
+ searchPathsPlaceholder: 'Cerca camins...',
noMachinesFound: 'No s\'han trobat màquines. Inicia una sessió de Happy al teu ordinador primer.',
allMachinesOffline: 'Totes les màquines estan fora de línia',
machineDetails: 'Veure detalls de la màquina →',
@@ -275,6 +296,26 @@ export const ca: TranslationStructure = {
startNewSessionInFolder: 'Nova sessió aquí',
noMachineSelected: 'Si us plau, selecciona una màquina per iniciar la sessió',
noPathSelected: 'Si us plau, selecciona un directori per iniciar la sessió',
+ machinePicker: {
+ searchPlaceholder: 'Cerca màquines...',
+ recentTitle: 'Recents',
+ favoritesTitle: 'Preferits',
+ allTitle: 'Totes',
+ emptyMessage: 'No hi ha màquines disponibles',
+ },
+ pathPicker: {
+ enterPathTitle: 'Introdueix el camí',
+ enterPathPlaceholder: 'Introdueix un camí...',
+ customPathTitle: 'Camí personalitzat',
+ recentTitle: 'Recents',
+ favoritesTitle: 'Preferits',
+ suggestedTitle: 'Suggerits',
+ allTitle: 'Totes',
+ emptyRecent: 'No hi ha camins recents',
+ emptyFavorites: 'No hi ha camins preferits',
+ emptySuggested: 'No hi ha camins suggerits',
+ emptyAll: 'No hi ha camins',
+ },
sessionType: {
title: 'Tipus de sessió',
simple: 'Simple',
@@ -336,6 +377,7 @@ export const ca: TranslationStructure = {
happySessionId: 'ID de la sessió de Happy',
claudeCodeSessionId: 'ID de la sessió de Claude Code',
claudeCodeSessionIdCopied: 'ID de la sessió de Claude Code copiat al porta-retalls',
+ aiProfile: 'Perfil d\'IA',
aiProvider: 'Proveïdor d\'IA',
failedToCopyClaudeCodeSessionId: 'Ha fallat copiar l\'ID de la sessió de Claude Code',
metadataCopied: 'Metadades copiades al porta-retalls',
@@ -390,6 +432,10 @@ export const ca: TranslationStructure = {
},
agentInput: {
+ envVars: {
+ title: 'Variables d\'entorn',
+ titleWithCount: ({ count }: { count: number }) => `Variables d'entorn (${count})`,
+ },
permissionMode: {
title: 'MODE DE PERMISOS',
default: 'Per defecte',
@@ -430,14 +476,29 @@ 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: 'Només lectura',
+ safeYolo: 'YOLO segur',
+ yolo: 'YOLO',
+ badgeReadOnly: 'Només lectura',
+ badgeSafeYolo: 'YOLO segur',
+ badgeYolo: 'YOLO',
+ },
+ geminiModel: {
+ title: 'MODEL GEMINI',
+ gemini25Pro: {
+ label: 'Gemini 2.5 Pro',
+ description: 'Més capaç',
+ },
+ gemini25Flash: {
+ label: 'Gemini 2.5 Flash',
+ description: 'Ràpid i eficient',
+ },
+ gemini25FlashLite: {
+ label: 'Gemini 2.5 Flash Lite',
+ description: 'Més ràpid',
+ },
},
context: {
remaining: ({ percent }: { percent: number }) => `${percent}% restant`,
@@ -504,6 +565,10 @@ export const ca: TranslationStructure = {
applyChanges: 'Actualitza fitxer',
viewDiff: 'Canvis del fitxer actual',
question: 'Pregunta',
+ changeTitle: 'Canvia el títol',
+ },
+ geminiExecute: {
+ cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`,
},
desc: {
terminalCmd: ({ cmd }: { cmd: string }) => `Terminal(cmd: ${cmd})`,
@@ -894,8 +959,120 @@ 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',
+ builtIn: 'Integrat',
+ builtInNames: {
+ anthropic: 'Anthropic (Default)',
+ deepseek: 'DeepSeek (Reasoner)',
+ zai: 'Z.AI (GLM-4.6)',
+ openai: 'OpenAI (GPT-5)',
+ azureOpenai: 'Azure OpenAI',
+ },
+ groups: {
+ favorites: 'Preferits',
+ custom: 'Els teus perfils',
+ builtIn: 'Perfils integrats',
+ },
+ actions: {
+ viewEnvironmentVariables: 'Variables d\'entorn',
+ addToFavorites: 'Afegeix als preferits',
+ removeFromFavorites: 'Treu dels preferits',
+ editProfile: 'Edita el perfil',
+ duplicateProfile: 'Duplica el perfil',
+ deleteProfile: 'Elimina el perfil',
+ },
+ copySuffix: '(Copy)',
+ duplicateName: 'Ja existeix un perfil amb aquest nom',
+ setupInstructions: {
+ title: 'Instruccions de configuració',
+ viewOfficialGuide: 'Veure la guia oficial de configuració',
+ },
+ defaultSessionType: 'Tipus de sessió predeterminat',
+ defaultPermissionMode: {
+ title: 'Mode de permisos predeterminat',
+ descriptions: {
+ default: 'Demana permisos',
+ acceptEdits: 'Aprova edicions automàticament',
+ plan: 'Planifica abans d\'executar',
+ bypassPermissions: 'Salta tots els permisos',
+ },
+ },
+ aiBackend: {
+ title: 'Backend d\'IA',
+ selectAtLeastOneError: 'Selecciona com a mínim un backend d\'IA.',
+ claudeSubtitle: 'CLI de Claude',
+ codexSubtitle: 'CLI de Codex',
+ geminiSubtitleExperimental: 'CLI de Gemini (experimental)',
+ },
+ tmux: {
+ title: 'Tmux',
+ spawnSessionsTitle: 'Inicia sessions a Tmux',
+ spawnSessionsEnabledSubtitle: 'Les sessions s\'inicien en noves finestres de tmux.',
+ spawnSessionsDisabledSubtitle: 'Les sessions s\'inicien en un shell normal (sense integració amb tmux)',
+ sessionNamePlaceholder: 'Buit = sessió actual/més recent',
+ tempDirPlaceholder: '/tmp (opcional)',
+ },
+ previewMachine: {
+ title: 'Previsualitza màquina',
+ selectMachine: 'Selecciona màquina',
+ resolveSubtitle: 'Resol variables d\'entorn de la màquina per a aquest perfil.',
+ selectSubtitle: 'Selecciona una màquina per previsualitzar els valors resolts.',
+ },
+ environmentVariables: {
+ title: 'Variables d\'entorn',
+ addVariable: 'Afegeix variable',
+ namePlaceholder: 'Nom de variable (p. ex., MY_CUSTOM_VAR)',
+ valuePlaceholder: 'Valor (p. ex., my-value o ${MY_VAR})',
+ validation: {
+ nameRequired: 'Introdueix un nom de variable.',
+ invalidNameFormat: 'Els noms de variable han de ser lletres majúscules, números i guions baixos, i no poden començar amb un número.',
+ duplicateName: 'Aquesta variable ja existeix.',
+ },
+ card: {
+ valueLabel: 'Valor:',
+ fallbackValueLabel: 'Valor de reserva:',
+ valueInputPlaceholder: 'Valor',
+ defaultValueInputPlaceholder: 'Valor per defecte',
+ secretNotRetrieved: 'Valor secret - no es recupera per seguretat',
+ overridingDefault: ({ expectedValue }: { expectedValue: string }) =>
+ `S'està substituint el valor predeterminat documentat: ${expectedValue}`,
+ useMachineEnvToggle: 'Utilitza el valor de l\'entorn de la màquina',
+ resolvedOnSessionStart: 'Es resol quan la sessió s\'inicia a la màquina seleccionada.',
+ sourceVariableLabel: 'Variable d\'origen',
+ sourceVariablePlaceholder: 'Nom de variable d\'origen (p. ex., Z_AI_MODEL)',
+ checkingMachine: ({ machine }: { machine: string }) => `Comprovant ${machine}...`,
+ emptyOnMachine: ({ machine }: { machine: string }) => `Buit a ${machine}`,
+ emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Buit a ${machine} (utilitzant reserva)`,
+ notFoundOnMachine: ({ machine }: { machine: string }) => `No trobat a ${machine}`,
+ notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `No trobat a ${machine} (utilitzant reserva)`,
+ valueFoundOnMachine: ({ machine }: { machine: string }) => `Valor trobat a ${machine}`,
+ differsFromDocumented: ({ expectedValue }: { expectedValue: string }) =>
+ `Difiereix del valor documentat: ${expectedValue}`,
+ },
+ preview: {
+ secretValueHidden: ({ value }: { value: string }) => `${value} - ocult per seguretat`,
+ hiddenValue: '***ocult***',
+ emptyValue: '(buit)',
+ sessionWillReceive: ({ name, value }: { name: string; value: string }) =>
+ `La sessió rebrà: ${name} = ${value}`,
+ },
+ previewModal: {
+ titleWithProfile: ({ profileName }: { profileName: string }) => `Variables d'entorn · ${profileName}`,
+ descriptionPrefix: 'Aquestes variables d\'entorn s\'envien en iniciar la sessió. Els valors es resolen usant el dimoni a',
+ descriptionFallbackMachine: 'la màquina seleccionada',
+ descriptionSuffix: '.',
+ emptyMessage: 'No hi ha variables d\'entorn configurades per a aquest perfil.',
+ checkingSuffix: '(comprovant…)',
+ detail: {
+ fixed: 'Fix',
+ machine: 'Màquina',
+ checking: 'Comprovant',
+ fallback: 'Reserva',
+ missing: 'Falta',
+ },
+ },
+ },
delete: {
title: 'Eliminar Perfil',
message: ({ name }: { name: string }) => `Estàs segur que vols eliminar "${name}"? Aquesta acció no es pot desfer.`,
diff --git a/sources/text/translations/en.ts b/sources/text/translations/en.ts
index 7bddc729b..6ed9244c7 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',
@@ -46,6 +44,8 @@ export const en: TranslationStructure = {
common: {
// Simple string constants
+ add: 'Add',
+ actions: 'Actions',
cancel: 'Cancel',
authenticate: 'Authenticate',
save: 'Save',
@@ -62,6 +62,9 @@ export const en: TranslationStructure = {
yes: 'Yes',
no: 'No',
discard: 'Discard',
+ discardChanges: 'Discard changes',
+ unsavedChangesWarning: 'You have unsaved changes.',
+ keepEditing: 'Keep editing',
version: 'Version',
copy: 'Copy',
copied: 'Copied',
@@ -75,6 +78,10 @@ export const en: TranslationStructure = {
retry: 'Retry',
delete: 'Delete',
optional: 'optional',
+ noMatches: 'No matches',
+ all: 'All',
+ machine: 'machine',
+ clearSearch: 'Clear search',
},
profile: {
@@ -211,8 +218,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 +230,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: {
@@ -275,6 +291,9 @@ export const en: TranslationStructure = {
newSession: {
// Used by new-session screen and launch flows
title: 'Start New Session',
+ selectMachineTitle: 'Select Machine',
+ selectPathTitle: 'Select Path',
+ searchPathsPlaceholder: 'Search paths...',
noMachinesFound: 'No machines found. Start a Happy session on your computer first.',
allMachinesOffline: 'All machines appear offline',
machineDetails: 'View machine details →',
@@ -290,6 +309,26 @@ export const en: TranslationStructure = {
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',
+ machinePicker: {
+ searchPlaceholder: 'Search machines...',
+ recentTitle: 'Recent',
+ favoritesTitle: 'Favorites',
+ allTitle: 'All',
+ emptyMessage: 'No machines available',
+ },
+ pathPicker: {
+ enterPathTitle: 'Enter Path',
+ enterPathPlaceholder: 'Enter a path...',
+ customPathTitle: 'Custom Path',
+ recentTitle: 'Recent',
+ favoritesTitle: 'Favorites',
+ suggestedTitle: 'Suggested',
+ allTitle: 'All',
+ emptyRecent: 'No recent paths',
+ emptyFavorites: 'No favorite paths',
+ emptySuggested: 'No suggested paths',
+ emptyAll: 'No paths',
+ },
sessionType: {
title: 'Session Type',
simple: 'Simple',
@@ -315,7 +354,7 @@ export const en: TranslationStructure = {
},
session: {
- inputPlaceholder: 'Type a message ...',
+ inputPlaceholder: 'What would you like to work on?',
},
commandPalette: {
@@ -351,6 +390,7 @@ export const en: TranslationStructure = {
happySessionId: 'Happy Session ID',
claudeCodeSessionId: 'Claude Code Session ID',
claudeCodeSessionIdCopied: 'Claude Code Session ID copied to clipboard',
+ aiProfile: 'AI Profile',
aiProvider: 'AI Provider',
failedToCopyClaudeCodeSessionId: 'Failed to copy Claude Code Session ID',
metadataCopied: 'Metadata copied to clipboard',
@@ -405,6 +445,10 @@ export const en: TranslationStructure = {
},
agentInput: {
+ envVars: {
+ title: 'Env Vars',
+ titleWithCount: ({ count }: { count: number }) => `Env Vars (${count})`,
+ },
permissionMode: {
title: 'PERMISSION MODE',
default: 'Default',
@@ -454,6 +498,21 @@ export const en: TranslationStructure = {
badgeSafeYolo: 'Safe YOLO',
badgeYolo: 'YOLO',
},
+ geminiModel: {
+ title: 'GEMINI MODEL',
+ gemini25Pro: {
+ label: 'Gemini 2.5 Pro',
+ description: 'Most capable',
+ },
+ gemini25Flash: {
+ label: 'Gemini 2.5 Flash',
+ description: 'Fast & efficient',
+ },
+ gemini25FlashLite: {
+ label: 'Gemini 2.5 Flash Lite',
+ description: 'Fastest',
+ },
+ },
context: {
remaining: ({ percent }: { percent: number }) => `${percent}% left`,
},
@@ -519,6 +578,10 @@ export const en: TranslationStructure = {
applyChanges: 'Update file',
viewDiff: 'Current file changes',
question: 'Question',
+ changeTitle: 'Change Title',
+ },
+ geminiExecute: {
+ cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`,
},
askUserQuestion: {
submit: 'Submit Answer',
@@ -902,8 +965,8 @@ export const en: TranslationStructure = {
// Profile management feature
title: 'Profiles',
subtitle: 'Manage environment variable profiles for sessions',
- noProfile: 'No Profile',
- noProfileDescription: 'Use default environment settings',
+ noProfile: 'Default Environment',
+ noProfileDescription: 'Use the machine environment without profile variables',
defaultModel: 'Default Model',
addProfile: 'Add Profile',
profileName: 'Profile Name',
@@ -918,9 +981,121 @@ 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',
+ builtIn: 'Built-in',
+ builtInNames: {
+ anthropic: 'Anthropic (Default)',
+ deepseek: 'DeepSeek (Reasoner)',
+ zai: 'Z.AI (GLM-4.6)',
+ openai: 'OpenAI (GPT-5)',
+ azureOpenai: 'Azure OpenAI',
+ },
+ groups: {
+ favorites: 'Favorites',
+ custom: 'Your Profiles',
+ builtIn: 'Built-in Profiles',
+ },
+ actions: {
+ viewEnvironmentVariables: 'Environment Variables',
+ addToFavorites: 'Add to favorites',
+ removeFromFavorites: 'Remove from favorites',
+ editProfile: 'Edit profile',
+ duplicateProfile: 'Duplicate profile',
+ deleteProfile: 'Delete profile',
+ },
+ copySuffix: '(Copy)',
+ duplicateName: 'A profile with this name already exists',
+ setupInstructions: {
+ title: 'Setup Instructions',
+ viewOfficialGuide: 'View Official Setup Guide',
+ },
+ defaultSessionType: 'Default Session Type',
+ defaultPermissionMode: {
+ title: 'Default Permission Mode',
+ descriptions: {
+ default: 'Ask for permissions',
+ acceptEdits: 'Auto-approve edits',
+ plan: 'Plan before executing',
+ bypassPermissions: 'Skip all permissions',
+ },
+ },
+ aiBackend: {
+ title: 'AI Backend',
+ selectAtLeastOneError: 'Select at least one AI backend.',
+ claudeSubtitle: 'Claude CLI',
+ codexSubtitle: 'Codex CLI',
+ geminiSubtitleExperimental: 'Gemini CLI (experimental)',
+ },
+ tmux: {
+ title: 'Tmux',
+ spawnSessionsTitle: 'Spawn Sessions in Tmux',
+ spawnSessionsEnabledSubtitle: 'Sessions spawn in new tmux windows.',
+ spawnSessionsDisabledSubtitle: 'Sessions spawn in regular shell (no tmux integration)',
+ sessionNamePlaceholder: 'Empty = current/most recent session',
+ tempDirPlaceholder: '/tmp (optional)',
+ },
+ previewMachine: {
+ title: 'Preview Machine',
+ selectMachine: 'Select machine',
+ resolveSubtitle: 'Resolve machine environment variables for this profile.',
+ selectSubtitle: 'Select a machine to preview resolved values.',
+ },
+ environmentVariables: {
+ title: 'Environment Variables',
+ addVariable: 'Add Variable',
+ namePlaceholder: 'Variable name (e.g., MY_CUSTOM_VAR)',
+ valuePlaceholder: 'Value (e.g., my-value or ${MY_VAR})',
+ validation: {
+ nameRequired: 'Enter a variable name.',
+ invalidNameFormat: 'Variable names must be uppercase letters, numbers, and underscores, and cannot start with a number.',
+ duplicateName: 'That variable already exists.',
+ },
+ card: {
+ valueLabel: 'Value:',
+ fallbackValueLabel: 'Fallback value:',
+ valueInputPlaceholder: 'Value',
+ defaultValueInputPlaceholder: 'Default value',
+ secretNotRetrieved: 'Secret value - not retrieved for security',
+ overridingDefault: ({ expectedValue }: { expectedValue: string }) =>
+ `Overriding documented default: ${expectedValue}`,
+ useMachineEnvToggle: 'Use value from machine environment',
+ resolvedOnSessionStart: 'Resolved when the session starts on the selected machine.',
+ sourceVariableLabel: 'Source variable',
+ sourceVariablePlaceholder: 'Source variable name (e.g., Z_AI_MODEL)',
+ checkingMachine: ({ machine }: { machine: string }) => `Checking ${machine}...`,
+ emptyOnMachine: ({ machine }: { machine: string }) => `Empty on ${machine}`,
+ emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Empty on ${machine} (using fallback)`,
+ notFoundOnMachine: ({ machine }: { machine: string }) => `Not found on ${machine}`,
+ notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `Not found on ${machine} (using fallback)`,
+ valueFoundOnMachine: ({ machine }: { machine: string }) => `Value found on ${machine}`,
+ differsFromDocumented: ({ expectedValue }: { expectedValue: string }) =>
+ `Differs from documented value: ${expectedValue}`,
+ },
+ preview: {
+ secretValueHidden: ({ value }: { value: string }) => `${value} - hidden for security`,
+ hiddenValue: '***hidden***',
+ emptyValue: '(empty)',
+ sessionWillReceive: ({ name, value }: { name: string; value: string }) =>
+ `Session will receive: ${name} = ${value}`,
+ },
+ previewModal: {
+ titleWithProfile: ({ profileName }: { profileName: string }) => `Env Vars · ${profileName}`,
+ descriptionPrefix: 'These environment variables are sent when starting the session. Values are resolved using the daemon on',
+ descriptionFallbackMachine: 'the selected machine',
+ descriptionSuffix: '.',
+ emptyMessage: 'No environment variables are set for this profile.',
+ checkingSuffix: '(checking…)',
+ detail: {
+ fixed: 'Fixed',
+ machine: 'Machine',
+ checking: 'Checking',
+ fallback: 'Fallback',
+ missing: 'Missing',
+ },
+ },
+ },
delete: {
title: 'Delete Profile',
message: ({ name }: { name: string }) => `Are you sure you want to delete "${name}"? This action cannot be undone.`,
@@ -928,6 +1103,8 @@ export const en: TranslationStructure = {
cancel: 'Cancel',
},
}
-} as const;
+};
+
+export type TranslationStructure = typeof en;
-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 a79953775..9a2af7b1a 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
@@ -31,6 +31,8 @@ export const es: TranslationStructure = {
common: {
// Simple string constants
+ add: 'Añadir',
+ actions: 'Acciones',
cancel: 'Cancelar',
authenticate: 'Autenticar',
save: 'Guardar',
@@ -47,6 +49,9 @@ export const es: TranslationStructure = {
yes: 'Sí',
no: 'No',
discard: 'Descartar',
+ discardChanges: 'Descartar cambios',
+ unsavedChangesWarning: 'Tienes cambios sin guardar.',
+ keepEditing: 'Seguir editando',
version: 'Versión',
copied: 'Copiado',
copy: 'Copiar',
@@ -60,6 +65,10 @@ export const es: TranslationStructure = {
retry: 'Reintentar',
delete: 'Eliminar',
optional: 'opcional',
+ noMatches: 'Sin coincidencias',
+ all: 'All',
+ machine: 'máquina',
+ clearSearch: 'Clear search',
},
profile: {
@@ -208,6 +217,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: 'Perfiles de IA',
+ profilesEnabled: 'Selección de perfiles habilitada',
+ profilesDisabled: 'Selección de perfiles deshabilitada',
+ pickerSearch: 'Búsqueda en selectores',
+ pickerSearchSubtitle: 'Mostrar un campo de búsqueda en los selectores de máquina y ruta',
+ machinePickerSearch: 'Búsqueda de máquinas',
+ machinePickerSearchSubtitle: 'Mostrar un campo de búsqueda en los selectores de máquinas',
+ pathPickerSearch: 'Búsqueda de rutas',
+ pathPickerSearchSubtitle: 'Mostrar un campo de búsqueda en los selectores de rutas',
},
errors: {
@@ -260,6 +278,9 @@ export const es: TranslationStructure = {
newSession: {
// Used by new-session screen and launch flows
title: 'Iniciar nueva sesión',
+ selectMachineTitle: 'Seleccionar máquina',
+ selectPathTitle: 'Seleccionar ruta',
+ searchPathsPlaceholder: 'Buscar rutas...',
noMachinesFound: 'No se encontraron máquinas. Inicia una sesión de Happy en tu computadora primero.',
allMachinesOffline: 'Todas las máquinas están desconectadas',
machineDetails: 'Ver detalles de la máquina →',
@@ -275,6 +296,26 @@ export const es: TranslationStructure = {
startNewSessionInFolder: 'Nueva sesión aquí',
noMachineSelected: 'Por favor, selecciona una máquina para iniciar la sesión',
noPathSelected: 'Por favor, selecciona un directorio para iniciar la sesión',
+ machinePicker: {
+ searchPlaceholder: 'Buscar máquinas...',
+ recentTitle: 'Recientes',
+ favoritesTitle: 'Favoritos',
+ allTitle: 'Todas',
+ emptyMessage: 'No hay máquinas disponibles',
+ },
+ pathPicker: {
+ enterPathTitle: 'Ingresar ruta',
+ enterPathPlaceholder: 'Ingresa una ruta...',
+ customPathTitle: 'Ruta personalizada',
+ recentTitle: 'Recientes',
+ favoritesTitle: 'Favoritos',
+ suggestedTitle: 'Sugeridas',
+ allTitle: 'Todas',
+ emptyRecent: 'No hay rutas recientes',
+ emptyFavorites: 'No hay rutas favoritas',
+ emptySuggested: 'No hay rutas sugeridas',
+ emptyAll: 'No hay rutas',
+ },
sessionType: {
title: 'Tipo de sesión',
simple: 'Simple',
@@ -336,6 +377,7 @@ export const es: TranslationStructure = {
happySessionId: 'ID de sesión de Happy',
claudeCodeSessionId: 'ID de sesión de Claude Code',
claudeCodeSessionIdCopied: 'ID de sesión de Claude Code copiado al portapapeles',
+ aiProfile: 'Perfil de IA',
aiProvider: 'Proveedor de IA',
failedToCopyClaudeCodeSessionId: 'Falló al copiar ID de sesión de Claude Code',
metadataCopied: 'Metadatos copiados al portapapeles',
@@ -390,6 +432,10 @@ export const es: TranslationStructure = {
},
agentInput: {
+ envVars: {
+ title: 'Variables de entorno',
+ titleWithCount: ({ count }: { count: number }) => `Variables de entorno (${count})`,
+ },
permissionMode: {
title: 'MODO DE PERMISOS',
default: 'Por defecto',
@@ -430,14 +476,29 @@ 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: 'Solo lectura',
+ safeYolo: 'YOLO seguro',
+ yolo: 'YOLO',
+ badgeReadOnly: 'Solo lectura',
+ badgeSafeYolo: 'YOLO seguro',
+ badgeYolo: 'YOLO',
+ },
+ geminiModel: {
+ title: 'MODELO GEMINI',
+ gemini25Pro: {
+ label: 'Gemini 2.5 Pro',
+ description: 'Más capaz',
+ },
+ gemini25Flash: {
+ label: 'Gemini 2.5 Flash',
+ description: 'Rápido y eficiente',
+ },
+ gemini25FlashLite: {
+ label: 'Gemini 2.5 Flash Lite',
+ description: 'Más rápido',
+ },
},
context: {
remaining: ({ percent }: { percent: number }) => `${percent}% restante`,
@@ -504,6 +565,10 @@ export const es: TranslationStructure = {
applyChanges: 'Actualizar archivo',
viewDiff: 'Cambios del archivo actual',
question: 'Pregunta',
+ changeTitle: 'Cambiar título',
+ },
+ geminiExecute: {
+ cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`,
},
desc: {
terminalCmd: ({ cmd }: { cmd: string }) => `Terminal(cmd: ${cmd})`,
@@ -903,9 +968,121 @@ 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',
+ builtIn: 'Integrado',
+ builtInNames: {
+ anthropic: 'Anthropic (Default)',
+ deepseek: 'DeepSeek (Reasoner)',
+ zai: 'Z.AI (GLM-4.6)',
+ openai: 'OpenAI (GPT-5)',
+ azureOpenai: 'Azure OpenAI',
+ },
+ groups: {
+ favorites: 'Favoritos',
+ custom: 'Tus perfiles',
+ builtIn: 'Perfiles integrados',
+ },
+ actions: {
+ viewEnvironmentVariables: 'Variables de entorno',
+ addToFavorites: 'Agregar a favoritos',
+ removeFromFavorites: 'Quitar de favoritos',
+ editProfile: 'Editar perfil',
+ duplicateProfile: 'Duplicar perfil',
+ deleteProfile: 'Eliminar perfil',
+ },
+ copySuffix: '(Copy)',
+ duplicateName: 'Ya existe un perfil con este nombre',
+ setupInstructions: {
+ title: 'Instrucciones de configuración',
+ viewOfficialGuide: 'Ver la guía oficial de configuración',
+ },
+ defaultSessionType: 'Tipo de sesión predeterminado',
+ defaultPermissionMode: {
+ title: 'Modo de permisos predeterminado',
+ descriptions: {
+ default: 'Pedir permisos',
+ acceptEdits: 'Aprobar ediciones automáticamente',
+ plan: 'Planificar antes de ejecutar',
+ bypassPermissions: 'Omitir todos los permisos',
+ },
+ },
+ aiBackend: {
+ title: 'Backend de IA',
+ selectAtLeastOneError: 'Selecciona al menos un backend de IA.',
+ claudeSubtitle: 'CLI de Claude',
+ codexSubtitle: 'CLI de Codex',
+ geminiSubtitleExperimental: 'CLI de Gemini (experimental)',
+ },
+ tmux: {
+ title: 'Tmux',
+ spawnSessionsTitle: 'Iniciar sesiones en Tmux',
+ spawnSessionsEnabledSubtitle: 'Las sesiones se abren en nuevas ventanas de tmux.',
+ spawnSessionsDisabledSubtitle: 'Las sesiones se abren en una shell normal (sin integración con tmux)',
+ sessionNamePlaceholder: 'Vacío = sesión actual/más reciente',
+ tempDirPlaceholder: '/tmp (opcional)',
+ },
+ previewMachine: {
+ title: 'Vista previa de la máquina',
+ selectMachine: 'Seleccionar máquina',
+ resolveSubtitle: 'Resolver variables de entorno de la máquina para este perfil.',
+ selectSubtitle: 'Selecciona una máquina para previsualizar los valores resueltos.',
+ },
+ environmentVariables: {
+ title: 'Variables de entorno',
+ addVariable: 'Añadir variable',
+ namePlaceholder: 'Nombre de variable (p. ej., MY_CUSTOM_VAR)',
+ valuePlaceholder: 'Valor (p. ej., mi-valor o ${MY_VAR})',
+ validation: {
+ nameRequired: 'Introduce un nombre de variable.',
+ invalidNameFormat: 'Los nombres de variables deben ser letras mayúsculas, números y guiones bajos, y no pueden empezar por un número.',
+ duplicateName: 'Esa variable ya existe.',
+ },
+ card: {
+ valueLabel: 'Valor:',
+ fallbackValueLabel: 'Valor de respaldo:',
+ valueInputPlaceholder: 'Valor',
+ defaultValueInputPlaceholder: 'Valor predeterminado',
+ secretNotRetrieved: 'Valor secreto: no se recupera por seguridad',
+ overridingDefault: ({ expectedValue }: { expectedValue: string }) =>
+ `Sobrescribiendo el valor documentado: ${expectedValue}`,
+ useMachineEnvToggle: 'Usar valor del entorno de la máquina',
+ resolvedOnSessionStart: 'Se resuelve al iniciar la sesión en la máquina seleccionada.',
+ sourceVariableLabel: 'Variable de origen',
+ sourceVariablePlaceholder: 'Nombre de variable de origen (p. ej., Z_AI_MODEL)',
+ checkingMachine: ({ machine }: { machine: string }) => `Verificando ${machine}...`,
+ emptyOnMachine: ({ machine }: { machine: string }) => `Vacío en ${machine}`,
+ emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Vacío en ${machine} (usando respaldo)`,
+ notFoundOnMachine: ({ machine }: { machine: string }) => `No encontrado en ${machine}`,
+ notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `No encontrado en ${machine} (usando respaldo)`,
+ valueFoundOnMachine: ({ machine }: { machine: string }) => `Valor encontrado en ${machine}`,
+ differsFromDocumented: ({ expectedValue }: { expectedValue: string }) =>
+ `Difiere del valor documentado: ${expectedValue}`,
+ },
+ preview: {
+ secretValueHidden: ({ value }: { value: string }) => `${value} - oculto por seguridad`,
+ hiddenValue: '***oculto***',
+ emptyValue: '(vacío)',
+ sessionWillReceive: ({ name, value }: { name: string; value: string }) =>
+ `La sesión recibirá: ${name} = ${value}`,
+ },
+ previewModal: {
+ titleWithProfile: ({ profileName }: { profileName: string }) => `Vars de entorno · ${profileName}`,
+ descriptionPrefix: 'Estas variables de entorno se envían al iniciar la sesión. Los valores se resuelven usando el daemon en',
+ descriptionFallbackMachine: 'la máquina seleccionada',
+ descriptionSuffix: '.',
+ emptyMessage: 'No hay variables de entorno configuradas para este perfil.',
+ checkingSuffix: '(verificando…)',
+ detail: {
+ fixed: 'Fijo',
+ machine: 'Máquina',
+ checking: 'Verificando',
+ fallback: 'Respaldo',
+ missing: 'Falta',
+ },
+ },
+ },
delete: {
title: 'Eliminar Perfil',
message: ({ name }: { name: string }) => `¿Estás seguro de que quieres eliminar "${name}"? Esta acción no se puede deshacer.`,
diff --git a/sources/text/translations/it.ts b/sources/text/translations/it.ts
index bfa52467a..6ec7548dc 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
@@ -31,6 +31,8 @@ export const it: TranslationStructure = {
common: {
// Simple string constants
+ add: 'Aggiungi',
+ actions: 'Azioni',
cancel: 'Annulla',
authenticate: 'Autentica',
save: 'Salva',
@@ -46,6 +48,9 @@ export const it: TranslationStructure = {
yes: 'Sì',
no: 'No',
discard: 'Scarta',
+ discardChanges: 'Scarta modifiche',
+ unsavedChangesWarning: 'Hai modifiche non salvate.',
+ keepEditing: 'Continua a modificare',
version: 'Versione',
copied: 'Copiato',
copy: 'Copia',
@@ -59,6 +64,10 @@ export const it: TranslationStructure = {
retry: 'Riprova',
delete: 'Elimina',
optional: 'opzionale',
+ noMatches: 'Nessuna corrispondenza',
+ all: 'All',
+ machine: 'macchina',
+ clearSearch: 'Clear search',
saveAs: 'Salva con nome',
},
@@ -90,9 +99,121 @@ 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',
+ builtIn: 'Integrato',
+ builtInNames: {
+ anthropic: 'Anthropic (Default)',
+ deepseek: 'DeepSeek (Reasoner)',
+ zai: 'Z.AI (GLM-4.6)',
+ openai: 'OpenAI (GPT-5)',
+ azureOpenai: 'Azure OpenAI',
+ },
+ groups: {
+ favorites: 'Preferiti',
+ custom: 'I tuoi profili',
+ builtIn: 'Profili integrati',
+ },
+ actions: {
+ viewEnvironmentVariables: 'Variabili ambiente',
+ addToFavorites: 'Aggiungi ai preferiti',
+ removeFromFavorites: 'Rimuovi dai preferiti',
+ editProfile: 'Modifica profilo',
+ duplicateProfile: 'Duplica profilo',
+ deleteProfile: 'Elimina profilo',
+ },
+ copySuffix: '(Copy)',
+ duplicateName: 'Esiste già un profilo con questo nome',
+ setupInstructions: {
+ title: 'Istruzioni di configurazione',
+ viewOfficialGuide: 'Visualizza la guida ufficiale di configurazione',
+ },
+ defaultSessionType: 'Tipo di sessione predefinito',
+ defaultPermissionMode: {
+ title: 'Modalità di permesso predefinita',
+ descriptions: {
+ default: 'Chiedi permessi',
+ acceptEdits: 'Approva automaticamente le modifiche',
+ plan: 'Pianifica prima di eseguire',
+ bypassPermissions: 'Salta tutti i permessi',
+ },
+ },
+ aiBackend: {
+ title: 'Backend IA',
+ selectAtLeastOneError: 'Seleziona almeno un backend IA.',
+ claudeSubtitle: 'Claude CLI',
+ codexSubtitle: 'Codex CLI',
+ geminiSubtitleExperimental: 'Gemini CLI (sperimentale)',
+ },
+ tmux: {
+ title: 'Tmux',
+ spawnSessionsTitle: 'Avvia sessioni in Tmux',
+ spawnSessionsEnabledSubtitle: 'Le sessioni vengono avviate in nuove finestre di tmux.',
+ spawnSessionsDisabledSubtitle: 'Le sessioni vengono avviate in una shell normale (senza integrazione tmux)',
+ sessionNamePlaceholder: 'Vuoto = sessione corrente/più recente',
+ tempDirPlaceholder: '/tmp (opzionale)',
+ },
+ previewMachine: {
+ title: 'Anteprima macchina',
+ selectMachine: 'Seleziona macchina',
+ resolveSubtitle: 'Risolvi le variabili ambiente della macchina per questo profilo.',
+ selectSubtitle: 'Seleziona una macchina per visualizzare l\'anteprima dei valori risolti.',
+ },
+ environmentVariables: {
+ title: 'Variabili ambiente',
+ addVariable: 'Aggiungi variabile',
+ namePlaceholder: 'Nome variabile (es., MY_CUSTOM_VAR)',
+ valuePlaceholder: 'Valore (es., my-value o ${MY_VAR})',
+ validation: {
+ nameRequired: 'Inserisci un nome variabile.',
+ invalidNameFormat: 'I nomi delle variabili devono usare lettere maiuscole, numeri e underscore e non possono iniziare con un numero.',
+ duplicateName: 'Questa variabile esiste già.',
+ },
+ card: {
+ valueLabel: 'Valore:',
+ fallbackValueLabel: 'Valore di fallback:',
+ valueInputPlaceholder: 'Valore',
+ defaultValueInputPlaceholder: 'Valore predefinito',
+ secretNotRetrieved: 'Valore segreto - non recuperato per sicurezza',
+ overridingDefault: ({ expectedValue }: { expectedValue: string }) =>
+ `Sostituzione del valore predefinito documentato: ${expectedValue}`,
+ useMachineEnvToggle: 'Usa valore dall\'ambiente della macchina',
+ resolvedOnSessionStart: 'Risolto quando la sessione viene avviata sulla macchina selezionata.',
+ sourceVariableLabel: 'Variabile sorgente',
+ sourceVariablePlaceholder: 'Nome variabile sorgente (es., Z_AI_MODEL)',
+ checkingMachine: ({ machine }: { machine: string }) => `Verifica ${machine}...`,
+ emptyOnMachine: ({ machine }: { machine: string }) => `Vuoto su ${machine}`,
+ emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Vuoto su ${machine} (uso fallback)`,
+ notFoundOnMachine: ({ machine }: { machine: string }) => `Non trovato su ${machine}`,
+ notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `Non trovato su ${machine} (uso fallback)`,
+ valueFoundOnMachine: ({ machine }: { machine: string }) => `Valore trovato su ${machine}`,
+ differsFromDocumented: ({ expectedValue }: { expectedValue: string }) =>
+ `Diverso dal valore documentato: ${expectedValue}`,
+ },
+ preview: {
+ secretValueHidden: ({ value }: { value: string }) => `${value} - nascosto per sicurezza`,
+ hiddenValue: '***nascosto***',
+ emptyValue: '(vuoto)',
+ sessionWillReceive: ({ name, value }: { name: string; value: string }) =>
+ `La sessione riceverà: ${name} = ${value}`,
+ },
+ previewModal: {
+ titleWithProfile: ({ profileName }: { profileName: string }) => `Variabili ambiente · ${profileName}`,
+ descriptionPrefix: 'Queste variabili ambiente vengono inviate all\'avvio della sessione. I valori vengono risolti dal daemon su',
+ descriptionFallbackMachine: 'la macchina selezionata',
+ descriptionSuffix: '.',
+ emptyMessage: 'Nessuna variabile ambiente è impostata per questo profilo.',
+ checkingSuffix: '(verifica…)',
+ detail: {
+ fixed: 'Fisso',
+ machine: 'Macchina',
+ checking: 'Verifica',
+ fallback: 'Fallback',
+ missing: 'Mancante',
+ },
+ },
+ },
delete: {
title: 'Elimina profilo',
message: ({ name }: { name: string }) => `Sei sicuro di voler eliminare "${name}"? Questa azione non può essere annullata.`,
@@ -237,6 +358,15 @@ export const it: TranslationStructure = {
enhancedSessionWizard: 'Wizard sessione avanzato',
enhancedSessionWizardEnabled: 'Avvio sessioni con profili attivo',
enhancedSessionWizardDisabled: 'Usando avvio sessioni standard',
+ profiles: 'Profili IA',
+ profilesEnabled: 'Selezione profili abilitata',
+ profilesDisabled: 'Selezione profili disabilitata',
+ pickerSearch: 'Ricerca nei selettori',
+ pickerSearchSubtitle: 'Mostra un campo di ricerca nei selettori di macchina e percorso',
+ machinePickerSearch: 'Ricerca macchine',
+ machinePickerSearchSubtitle: 'Mostra un campo di ricerca nei selettori di macchine',
+ pathPickerSearch: 'Ricerca percorsi',
+ pathPickerSearchSubtitle: 'Mostra un campo di ricerca nei selettori di percorsi',
},
errors: {
@@ -289,6 +419,9 @@ export const it: TranslationStructure = {
newSession: {
// Used by new-session screen and launch flows
title: 'Avvia nuova sessione',
+ selectMachineTitle: 'Seleziona macchina',
+ selectPathTitle: 'Seleziona percorso',
+ searchPathsPlaceholder: 'Cerca percorsi...',
noMachinesFound: 'Nessuna macchina trovata. Avvia prima una sessione Happy sul tuo computer.',
allMachinesOffline: 'Tutte le macchine sembrano offline',
machineDetails: 'Visualizza dettagli macchina →',
@@ -304,6 +437,26 @@ export const it: TranslationStructure = {
notConnectedToServer: 'Non connesso al server. Controlla la tua connessione Internet.',
noMachineSelected: 'Seleziona una macchina per avviare la sessione',
noPathSelected: 'Seleziona una directory in cui avviare la sessione',
+ machinePicker: {
+ searchPlaceholder: 'Cerca macchine...',
+ recentTitle: 'Recenti',
+ favoritesTitle: 'Preferiti',
+ allTitle: 'Tutte',
+ emptyMessage: 'Nessuna macchina disponibile',
+ },
+ pathPicker: {
+ enterPathTitle: 'Inserisci percorso',
+ enterPathPlaceholder: 'Inserisci un percorso...',
+ customPathTitle: 'Percorso personalizzato',
+ recentTitle: 'Recenti',
+ favoritesTitle: 'Preferiti',
+ suggestedTitle: 'Suggeriti',
+ allTitle: 'Tutte',
+ emptyRecent: 'Nessun percorso recente',
+ emptyFavorites: 'Nessun percorso preferito',
+ emptySuggested: 'Nessun percorso suggerito',
+ emptyAll: 'Nessun percorso',
+ },
sessionType: {
title: 'Tipo di sessione',
simple: 'Semplice',
@@ -365,6 +518,7 @@ export const it: TranslationStructure = {
happySessionId: 'ID sessione Happy',
claudeCodeSessionId: 'ID sessione Claude Code',
claudeCodeSessionIdCopied: 'ID sessione Claude Code copiato negli appunti',
+ aiProfile: 'Profilo IA',
aiProvider: 'Provider IA',
failedToCopyClaudeCodeSessionId: 'Impossibile copiare l\'ID sessione Claude Code',
metadataCopied: 'Metadati copiati negli appunti',
@@ -419,6 +573,10 @@ export const it: TranslationStructure = {
},
agentInput: {
+ envVars: {
+ title: 'Var env',
+ titleWithCount: ({ count }: { count: number }) => `Var env (${count})`,
+ },
permissionMode: {
title: 'MODALITÀ PERMESSI',
default: 'Predefinito',
@@ -461,12 +619,27 @@ 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',
+ },
+ geminiModel: {
+ title: 'MODELLO GEMINI',
+ gemini25Pro: {
+ label: 'Gemini 2.5 Pro',
+ description: 'Il più potente',
+ },
+ gemini25Flash: {
+ label: 'Gemini 2.5 Flash',
+ description: 'Veloce ed efficiente',
+ },
+ gemini25FlashLite: {
+ label: 'Gemini 2.5 Flash Lite',
+ description: 'Il più veloce',
+ },
},
context: {
remaining: ({ percent }: { percent: number }) => `${percent}% restante`,
@@ -537,6 +710,10 @@ export const it: TranslationStructure = {
applyChanges: 'Aggiorna file',
viewDiff: 'Modifiche file attuali',
question: 'Domanda',
+ changeTitle: 'Cambia titolo',
+ },
+ geminiExecute: {
+ cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`,
},
desc: {
terminalCmd: ({ cmd }: { cmd: string }) => `Terminale(cmd: ${cmd})`,
diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts
index fe1007884..6dad8ea86 100644
--- a/sources/text/translations/ja.ts
+++ b/sources/text/translations/ja.ts
@@ -5,17 +5,7 @@
* - Functions with typed object parameters for dynamic text
*/
-import { TranslationStructure } from "../_default";
-
-/**
- * Japanese plural helper function
- * Japanese doesn't have grammatical plurals, so this just returns the appropriate form
- * @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;
-}
+import type { TranslationStructure } from '../_types';
export const ja: TranslationStructure = {
tabs: {
@@ -34,6 +24,8 @@ export const ja: TranslationStructure = {
common: {
// Simple string constants
+ add: '追加',
+ actions: '操作',
cancel: 'キャンセル',
authenticate: '認証',
save: '保存',
@@ -49,6 +41,9 @@ export const ja: TranslationStructure = {
yes: 'はい',
no: 'いいえ',
discard: '破棄',
+ discardChanges: '変更を破棄',
+ unsavedChangesWarning: '未保存の変更があります。',
+ keepEditing: '編集を続ける',
version: 'バージョン',
copied: 'コピーしました',
copy: 'コピー',
@@ -62,6 +57,10 @@ export const ja: TranslationStructure = {
retry: '再試行',
delete: '削除',
optional: '任意',
+ noMatches: '一致するものがありません',
+ all: 'All',
+ machine: 'マシン',
+ clearSearch: 'Clear search',
saveAs: '名前を付けて保存',
},
@@ -93,9 +92,121 @@ export const ja: TranslationStructure = {
enterTmuxTempDir: '一時ディレクトリのパスを入力',
tmuxUpdateEnvironment: '環境を自動更新',
nameRequired: 'プロファイル名は必須です',
- deleteConfirm: 'プロファイル「{name}」を削除してもよろしいですか?',
+ deleteConfirm: ({ name }: { name: string }) => `プロファイル「${name}」を削除してもよろしいですか?`,
editProfile: 'プロファイルを編集',
addProfileTitle: '新しいプロファイルを追加',
+ builtIn: '組み込み',
+ builtInNames: {
+ anthropic: 'Anthropic (Default)',
+ deepseek: 'DeepSeek (Reasoner)',
+ zai: 'Z.AI (GLM-4.6)',
+ openai: 'OpenAI (GPT-5)',
+ azureOpenai: 'Azure OpenAI',
+ },
+ groups: {
+ favorites: 'お気に入り',
+ custom: 'あなたのプロファイル',
+ builtIn: '組み込みプロファイル',
+ },
+ actions: {
+ viewEnvironmentVariables: '環境変数',
+ addToFavorites: 'お気に入りに追加',
+ removeFromFavorites: 'お気に入りから削除',
+ editProfile: 'プロファイルを編集',
+ duplicateProfile: 'プロファイルを複製',
+ deleteProfile: 'プロファイルを削除',
+ },
+ copySuffix: '(Copy)',
+ duplicateName: '同じ名前のプロファイルが既に存在します',
+ setupInstructions: {
+ title: 'セットアップ手順',
+ viewOfficialGuide: '公式セットアップガイドを表示',
+ },
+ defaultSessionType: 'デフォルトのセッションタイプ',
+ defaultPermissionMode: {
+ title: 'デフォルトの権限モード',
+ descriptions: {
+ default: '権限を要求する',
+ acceptEdits: '編集を自動承認',
+ plan: '実行前に計画',
+ bypassPermissions: 'すべての権限をスキップ',
+ },
+ },
+ aiBackend: {
+ title: 'AIバックエンド',
+ selectAtLeastOneError: '少なくとも1つのAIバックエンドを選択してください。',
+ claudeSubtitle: 'Claude CLI',
+ codexSubtitle: 'Codex CLI',
+ geminiSubtitleExperimental: 'Gemini CLI(実験)',
+ },
+ tmux: {
+ title: 'Tmux',
+ spawnSessionsTitle: 'Tmuxでセッションを起動',
+ spawnSessionsEnabledSubtitle: 'セッションは新しいtmuxウィンドウで起動します。',
+ spawnSessionsDisabledSubtitle: 'セッションは通常のシェルで起動します(tmux連携なし)',
+ sessionNamePlaceholder: '空 = 現在/最近のセッション',
+ tempDirPlaceholder: '/tmp(任意)',
+ },
+ previewMachine: {
+ title: 'マシンをプレビュー',
+ selectMachine: 'マシンを選択',
+ resolveSubtitle: 'このプロファイルのマシン環境変数を解決します。',
+ selectSubtitle: '解決後の値をプレビューするマシンを選択してください。',
+ },
+ environmentVariables: {
+ title: '環境変数',
+ addVariable: '変数を追加',
+ namePlaceholder: '変数名(例: MY_CUSTOM_VAR)',
+ valuePlaceholder: '値(例: my-value または ${MY_VAR})',
+ validation: {
+ nameRequired: '変数名を入力してください。',
+ invalidNameFormat: '変数名は大文字、数字、アンダースコアのみで、数字から始めることはできません。',
+ duplicateName: 'その変数は既に存在します。',
+ },
+ card: {
+ valueLabel: '値:',
+ fallbackValueLabel: 'フォールバック値:',
+ valueInputPlaceholder: '値',
+ defaultValueInputPlaceholder: 'デフォルト値',
+ secretNotRetrieved: 'シークレット値 — セキュリティのため取得しません',
+ overridingDefault: ({ expectedValue }: { expectedValue: string }) =>
+ `ドキュメントのデフォルト値を上書き: ${expectedValue}`,
+ useMachineEnvToggle: 'マシン環境から値を使用',
+ resolvedOnSessionStart: '選択したマシンでセッション開始時に解決されます。',
+ sourceVariableLabel: '参照元変数',
+ sourceVariablePlaceholder: '参照元変数名(例: Z_AI_MODEL)',
+ checkingMachine: ({ machine }: { machine: string }) => `${machine} を確認中...`,
+ emptyOnMachine: ({ machine }: { machine: string }) => `${machine} では空です`,
+ emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `${machine} では空です(フォールバック使用)`,
+ notFoundOnMachine: ({ machine }: { machine: string }) => `${machine} で見つかりません`,
+ notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `${machine} で見つかりません(フォールバック使用)`,
+ valueFoundOnMachine: ({ machine }: { machine: string }) => `${machine} で値を確認`,
+ differsFromDocumented: ({ expectedValue }: { expectedValue: string }) =>
+ `ドキュメント値と異なります: ${expectedValue}`,
+ },
+ preview: {
+ secretValueHidden: ({ value }: { value: string }) => `${value} - セキュリティのため非表示`,
+ hiddenValue: '***非表示***',
+ emptyValue: '(空)',
+ sessionWillReceive: ({ name, value }: { name: string; value: string }) =>
+ `セッションに渡される値: ${name} = ${value}`,
+ },
+ previewModal: {
+ titleWithProfile: ({ profileName }: { profileName: string }) => `環境変数 · ${profileName}`,
+ descriptionPrefix: 'これらの環境変数はセッション開始時に送信されます。値はデーモンが',
+ descriptionFallbackMachine: '選択したマシン',
+ descriptionSuffix: 'で解決します。',
+ emptyMessage: 'このプロファイルには環境変数が設定されていません。',
+ checkingSuffix: '(確認中…)',
+ detail: {
+ fixed: '固定',
+ machine: 'マシン',
+ checking: '確認中',
+ fallback: 'フォールバック',
+ missing: '未設定',
+ },
+ },
+ },
delete: {
title: 'プロファイルを削除',
message: ({ name }: { name: string }) => `「${name}」を削除してもよろしいですか?この操作は元に戻せません。`,
@@ -240,6 +351,15 @@ export const ja: TranslationStructure = {
enhancedSessionWizard: '拡張セッションウィザード',
enhancedSessionWizardEnabled: 'プロファイル優先セッションランチャーが有効',
enhancedSessionWizardDisabled: '標準セッションランチャーを使用',
+ profiles: 'AIプロファイル',
+ profilesEnabled: 'プロファイル選択を有効化',
+ profilesDisabled: 'プロファイル選択を無効化',
+ pickerSearch: 'ピッカー検索',
+ pickerSearchSubtitle: 'マシンとパスのピッカーに検索欄を表示',
+ machinePickerSearch: 'マシン検索',
+ machinePickerSearchSubtitle: 'マシンピッカーに検索欄を表示',
+ pathPickerSearch: 'パス検索',
+ pathPickerSearchSubtitle: 'パスピッカーに検索欄を表示',
},
errors: {
@@ -292,6 +412,9 @@ export const ja: TranslationStructure = {
newSession: {
// Used by new-session screen and launch flows
title: '新しいセッションを開始',
+ selectMachineTitle: 'マシンを選択',
+ selectPathTitle: 'パスを選択',
+ searchPathsPlaceholder: 'パスを検索...',
noMachinesFound: 'マシンが見つかりません。まずコンピューターでHappyセッションを起動してください。',
allMachinesOffline: 'すべてのマシンがオフラインです',
machineDetails: 'マシンの詳細を表示 →',
@@ -307,6 +430,26 @@ export const ja: TranslationStructure = {
notConnectedToServer: 'サーバーに接続されていません。インターネット接続を確認してください。',
noMachineSelected: 'セッションを開始するマシンを選択してください',
noPathSelected: 'セッションを開始するディレクトリを選択してください',
+ machinePicker: {
+ searchPlaceholder: 'マシンを検索...',
+ recentTitle: '最近',
+ favoritesTitle: 'お気に入り',
+ allTitle: 'すべて',
+ emptyMessage: '利用可能なマシンがありません',
+ },
+ pathPicker: {
+ enterPathTitle: 'パスを入力',
+ enterPathPlaceholder: 'パスを入力...',
+ customPathTitle: 'カスタムパス',
+ recentTitle: '最近',
+ favoritesTitle: 'お気に入り',
+ suggestedTitle: 'おすすめ',
+ allTitle: 'すべて',
+ emptyRecent: '最近のパスはありません',
+ emptyFavorites: 'お気に入りのパスはありません',
+ emptySuggested: 'おすすめのパスはありません',
+ emptyAll: 'パスがありません',
+ },
sessionType: {
title: 'セッションタイプ',
simple: 'シンプル',
@@ -368,6 +511,7 @@ export const ja: TranslationStructure = {
happySessionId: 'Happy Session ID',
claudeCodeSessionId: 'Claude Code Session ID',
claudeCodeSessionIdCopied: 'Claude Code Session IDがクリップボードにコピーされました',
+ aiProfile: 'AIプロファイル',
aiProvider: 'AIプロバイダー',
failedToCopyClaudeCodeSessionId: 'Claude Code Session IDのコピーに失敗しました',
metadataCopied: 'メタデータがクリップボードにコピーされました',
@@ -422,6 +566,10 @@ export const ja: TranslationStructure = {
},
agentInput: {
+ envVars: {
+ title: '環境変数',
+ titleWithCount: ({ count }: { count: number }) => `環境変数 (${count})`,
+ },
permissionMode: {
title: '権限モード',
default: 'デフォルト',
@@ -464,12 +612,27 @@ 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',
+ },
+ geminiModel: {
+ title: 'GEMINIモデル',
+ gemini25Pro: {
+ label: 'Gemini 2.5 Pro',
+ description: '最高性能',
+ },
+ gemini25Flash: {
+ label: 'Gemini 2.5 Flash',
+ description: '高速・効率的',
+ },
+ gemini25FlashLite: {
+ label: 'Gemini 2.5 Flash Lite',
+ description: '最速',
+ },
},
context: {
remaining: ({ percent }: { percent: number }) => `残り ${percent}%`,
@@ -540,6 +703,10 @@ export const ja: TranslationStructure = {
applyChanges: 'ファイルを更新',
viewDiff: '現在のファイル変更',
question: '質問',
+ changeTitle: 'タイトルを変更',
+ },
+ geminiExecute: {
+ cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`,
},
desc: {
terminalCmd: ({ cmd }: { cmd: string }) => `ターミナル(cmd: ${cmd})`,
diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts
index 1c8e2f087..a40e122cc 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
@@ -42,6 +42,8 @@ export const pl: TranslationStructure = {
common: {
// Simple string constants
+ add: 'Dodaj',
+ actions: 'Akcje',
cancel: 'Anuluj',
authenticate: 'Uwierzytelnij',
save: 'Zapisz',
@@ -58,6 +60,9 @@ export const pl: TranslationStructure = {
yes: 'Tak',
no: 'Nie',
discard: 'Odrzuć',
+ discardChanges: 'Odrzuć zmiany',
+ unsavedChangesWarning: 'Masz niezapisane zmiany.',
+ keepEditing: 'Kontynuuj edycję',
version: 'Wersja',
copied: 'Skopiowano',
copy: 'Kopiuj',
@@ -71,6 +76,10 @@ export const pl: TranslationStructure = {
retry: 'Ponów',
delete: 'Usuń',
optional: 'opcjonalnie',
+ noMatches: 'Brak dopasowań',
+ all: 'All',
+ machine: 'maszyna',
+ clearSearch: 'Clear search',
},
profile: {
@@ -219,6 +228,15 @@ export const pl: TranslationStructure = {
enhancedSessionWizard: 'Ulepszony kreator sesji',
enhancedSessionWizardEnabled: 'Aktywny launcher z profilem',
enhancedSessionWizardDisabled: 'Używanie standardowego launchera sesji',
+ profiles: 'Profile AI',
+ profilesEnabled: 'Wybór profili włączony',
+ profilesDisabled: 'Wybór profili wyłączony',
+ pickerSearch: 'Wyszukiwanie w selektorach',
+ pickerSearchSubtitle: 'Pokaż pole wyszukiwania w selektorach maszyn i ścieżek',
+ machinePickerSearch: 'Wyszukiwanie maszyn',
+ machinePickerSearchSubtitle: 'Pokaż pole wyszukiwania w selektorach maszyn',
+ pathPickerSearch: 'Wyszukiwanie ścieżek',
+ pathPickerSearchSubtitle: 'Pokaż pole wyszukiwania w selektorach ścieżek',
},
errors: {
@@ -271,6 +289,9 @@ export const pl: TranslationStructure = {
newSession: {
// Used by new-session screen and launch flows
title: 'Rozpocznij nową sesję',
+ selectMachineTitle: 'Wybierz maszynę',
+ selectPathTitle: 'Wybierz ścieżkę',
+ searchPathsPlaceholder: 'Szukaj ścieżek...',
noMachinesFound: 'Nie znaleziono maszyn. Najpierw uruchom sesję Happy na swoim komputerze.',
allMachinesOffline: 'Wszystkie maszyny są offline',
machineDetails: 'Zobacz szczegóły maszyny →',
@@ -286,6 +307,26 @@ export const pl: TranslationStructure = {
startNewSessionInFolder: 'Nowa sesja tutaj',
noMachineSelected: 'Proszę wybrać maszynę do rozpoczęcia sesji',
noPathSelected: 'Proszę wybrać katalog do rozpoczęcia sesji',
+ machinePicker: {
+ searchPlaceholder: 'Szukaj maszyn...',
+ recentTitle: 'Ostatnie',
+ favoritesTitle: 'Ulubione',
+ allTitle: 'Wszystkie',
+ emptyMessage: 'Brak dostępnych maszyn',
+ },
+ pathPicker: {
+ enterPathTitle: 'Wpisz ścieżkę',
+ enterPathPlaceholder: 'Wpisz ścieżkę...',
+ customPathTitle: 'Niestandardowa ścieżka',
+ recentTitle: 'Ostatnie',
+ favoritesTitle: 'Ulubione',
+ suggestedTitle: 'Sugerowane',
+ allTitle: 'Wszystkie',
+ emptyRecent: 'Brak ostatnich ścieżek',
+ emptyFavorites: 'Brak ulubionych ścieżek',
+ emptySuggested: 'Brak sugerowanych ścieżek',
+ emptyAll: 'Brak ścieżek',
+ },
sessionType: {
title: 'Typ sesji',
simple: 'Prosta',
@@ -347,6 +388,7 @@ export const pl: TranslationStructure = {
happySessionId: 'ID sesji Happy',
claudeCodeSessionId: 'ID sesji Claude Code',
claudeCodeSessionIdCopied: 'ID sesji Claude Code skopiowane do schowka',
+ aiProfile: 'Profil AI',
aiProvider: 'Dostawca AI',
failedToCopyClaudeCodeSessionId: 'Nie udało się skopiować ID sesji Claude Code',
metadataCopied: 'Metadane skopiowane do schowka',
@@ -400,6 +442,10 @@ export const pl: TranslationStructure = {
},
agentInput: {
+ envVars: {
+ title: 'Zmienne środowiskowe',
+ titleWithCount: ({ count }: { count: number }) => `Zmienne środowiskowe (${count})`,
+ },
permissionMode: {
title: 'TRYB UPRAWNIEŃ',
default: 'Domyślny',
@@ -440,14 +486,29 @@ 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: 'Tylko do odczytu',
+ safeYolo: 'Bezpieczne YOLO',
+ yolo: 'YOLO',
+ badgeReadOnly: 'Tylko do odczytu',
+ badgeSafeYolo: 'Bezpieczne YOLO',
+ badgeYolo: 'YOLO',
+ },
+ geminiModel: {
+ title: 'MODEL GEMINI',
+ gemini25Pro: {
+ label: 'Gemini 2.5 Pro',
+ description: 'Najbardziej zaawansowany',
+ },
+ gemini25Flash: {
+ label: 'Gemini 2.5 Flash',
+ description: 'Szybki i wydajny',
+ },
+ gemini25FlashLite: {
+ label: 'Gemini 2.5 Flash Lite',
+ description: 'Najszybszy',
+ },
},
context: {
remaining: ({ percent }: { percent: number }) => `Pozostało ${percent}%`,
@@ -514,6 +575,10 @@ export const pl: TranslationStructure = {
applyChanges: 'Zaktualizuj plik',
viewDiff: 'Bieżące zmiany pliku',
question: 'Pytanie',
+ changeTitle: 'Zmień tytuł',
+ },
+ geminiExecute: {
+ cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`,
},
desc: {
terminalCmd: ({ cmd }: { cmd: string }) => `Terminal(cmd: ${cmd})`,
@@ -926,9 +991,121 @@ 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',
+ builtIn: 'Wbudowane',
+ builtInNames: {
+ anthropic: 'Anthropic (Default)',
+ deepseek: 'DeepSeek (Reasoner)',
+ zai: 'Z.AI (GLM-4.6)',
+ openai: 'OpenAI (GPT-5)',
+ azureOpenai: 'Azure OpenAI',
+ },
+ groups: {
+ favorites: 'Ulubione',
+ custom: 'Twoje profile',
+ builtIn: 'Profile wbudowane',
+ },
+ actions: {
+ viewEnvironmentVariables: 'Zmienne środowiskowe',
+ addToFavorites: 'Dodaj do ulubionych',
+ removeFromFavorites: 'Usuń z ulubionych',
+ editProfile: 'Edytuj profil',
+ duplicateProfile: 'Duplikuj profil',
+ deleteProfile: 'Usuń profil',
+ },
+ copySuffix: '(Copy)',
+ duplicateName: 'Profil o tej nazwie już istnieje',
+ setupInstructions: {
+ title: 'Instrukcje konfiguracji',
+ viewOfficialGuide: 'Zobacz oficjalny przewodnik konfiguracji',
+ },
+ defaultSessionType: 'Domyślny typ sesji',
+ defaultPermissionMode: {
+ title: 'Domyślny tryb uprawnień',
+ descriptions: {
+ default: 'Pytaj o uprawnienia',
+ acceptEdits: 'Automatycznie zatwierdzaj edycje',
+ plan: 'Zaplanuj przed wykonaniem',
+ bypassPermissions: 'Pomiń wszystkie uprawnienia',
+ },
+ },
+ aiBackend: {
+ title: 'Backend AI',
+ selectAtLeastOneError: 'Wybierz co najmniej jeden backend AI.',
+ claudeSubtitle: 'CLI Claude',
+ codexSubtitle: 'CLI Codex',
+ geminiSubtitleExperimental: 'CLI Gemini (eksperymentalne)',
+ },
+ tmux: {
+ title: 'Tmux',
+ spawnSessionsTitle: 'Uruchamiaj sesje w Tmux',
+ spawnSessionsEnabledSubtitle: 'Sesje uruchamiają się w nowych oknach tmux.',
+ spawnSessionsDisabledSubtitle: 'Sesje uruchamiają się w zwykłej powłoce (bez integracji z tmux)',
+ sessionNamePlaceholder: 'Puste = bieżąca/najnowsza sesja',
+ tempDirPlaceholder: '/tmp (opcjonalne)',
+ },
+ previewMachine: {
+ title: 'Podgląd maszyny',
+ selectMachine: 'Wybierz maszynę',
+ resolveSubtitle: 'Rozwiąż zmienne środowiskowe maszyny dla tego profilu.',
+ selectSubtitle: 'Wybierz maszynę, aby podejrzeć rozwiązane wartości.',
+ },
+ environmentVariables: {
+ title: 'Zmienne środowiskowe',
+ addVariable: 'Dodaj zmienną',
+ namePlaceholder: 'Nazwa zmiennej (np. MY_CUSTOM_VAR)',
+ valuePlaceholder: 'Wartość (np. my-value lub ${MY_VAR})',
+ validation: {
+ nameRequired: 'Wprowadź nazwę zmiennej.',
+ invalidNameFormat: 'Nazwy zmiennych muszą zawierać wielkie litery, cyfry i podkreślenia oraz nie mogą zaczynać się od cyfry.',
+ duplicateName: 'Taka zmienna już istnieje.',
+ },
+ card: {
+ valueLabel: 'Wartość:',
+ fallbackValueLabel: 'Wartość fallback:',
+ valueInputPlaceholder: 'Wartość',
+ defaultValueInputPlaceholder: 'Wartość domyślna',
+ secretNotRetrieved: 'Wartość sekretna - nie jest pobierana ze względów bezpieczeństwa',
+ overridingDefault: ({ expectedValue }: { expectedValue: string }) =>
+ `Nadpisywanie udokumentowanej wartości domyślnej: ${expectedValue}`,
+ useMachineEnvToggle: 'Użyj wartości ze środowiska maszyny',
+ resolvedOnSessionStart: 'Rozwiązywane podczas uruchamiania sesji na wybranej maszynie.',
+ sourceVariableLabel: 'Zmienna źródłowa',
+ sourceVariablePlaceholder: 'Nazwa zmiennej źródłowej (np. Z_AI_MODEL)',
+ checkingMachine: ({ machine }: { machine: string }) => `Sprawdzanie ${machine}...`,
+ emptyOnMachine: ({ machine }: { machine: string }) => `Pusto na ${machine}`,
+ emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Pusto na ${machine} (używam fallback)`,
+ notFoundOnMachine: ({ machine }: { machine: string }) => `Nie znaleziono na ${machine}`,
+ notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `Nie znaleziono na ${machine} (używam fallback)`,
+ valueFoundOnMachine: ({ machine }: { machine: string }) => `Znaleziono wartość na ${machine}`,
+ differsFromDocumented: ({ expectedValue }: { expectedValue: string }) =>
+ `Różni się od udokumentowanej wartości: ${expectedValue}`,
+ },
+ preview: {
+ secretValueHidden: ({ value }: { value: string }) => `${value} - ukryte ze względów bezpieczeństwa`,
+ hiddenValue: '***ukryte***',
+ emptyValue: '(puste)',
+ sessionWillReceive: ({ name, value }: { name: string; value: string }) =>
+ `Sesja otrzyma: ${name} = ${value}`,
+ },
+ previewModal: {
+ titleWithProfile: ({ profileName }: { profileName: string }) => `Zmienne środowiskowe · ${profileName}`,
+ descriptionPrefix: 'Te zmienne środowiskowe są wysyłane podczas uruchamiania sesji. Wartości są rozwiązywane przez daemon na',
+ descriptionFallbackMachine: 'wybranej maszynie',
+ descriptionSuffix: '.',
+ emptyMessage: 'Dla tego profilu nie ustawiono zmiennych środowiskowych.',
+ checkingSuffix: '(sprawdzanie…)',
+ detail: {
+ fixed: 'Stała',
+ machine: 'Maszyna',
+ checking: 'Sprawdzanie',
+ fallback: 'Fallback',
+ missing: 'Brak',
+ },
+ },
+ },
delete: {
title: 'Usuń Profil',
message: ({ name }: { name: string }) => `Czy na pewno chcesz usunąć "${name}"? Tej czynności nie można cofnąć.`,
diff --git a/sources/text/translations/pt.ts b/sources/text/translations/pt.ts
index 859a7ae8b..9e33b159c 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
@@ -31,6 +31,8 @@ export const pt: TranslationStructure = {
common: {
// Simple string constants
+ add: 'Adicionar',
+ actions: 'Ações',
cancel: 'Cancelar',
authenticate: 'Autenticar',
save: 'Salvar',
@@ -47,6 +49,9 @@ export const pt: TranslationStructure = {
yes: 'Sim',
no: 'Não',
discard: 'Descartar',
+ discardChanges: 'Descartar alterações',
+ unsavedChangesWarning: 'Você tem alterações não salvas.',
+ keepEditing: 'Continuar editando',
version: 'Versão',
copied: 'Copiado',
copy: 'Copiar',
@@ -60,6 +65,10 @@ export const pt: TranslationStructure = {
retry: 'Tentar novamente',
delete: 'Excluir',
optional: 'Opcional',
+ noMatches: 'Nenhuma correspondência',
+ all: 'All',
+ machine: 'máquina',
+ clearSearch: 'Clear search',
},
profile: {
@@ -208,6 +217,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: 'Perfis de IA',
+ profilesEnabled: 'Seleção de perfis ativada',
+ profilesDisabled: 'Seleção de perfis desativada',
+ pickerSearch: 'Busca nos seletores',
+ pickerSearchSubtitle: 'Mostrar um campo de busca nos seletores de máquina e caminho',
+ machinePickerSearch: 'Busca de máquinas',
+ machinePickerSearchSubtitle: 'Mostrar um campo de busca nos seletores de máquinas',
+ pathPickerSearch: 'Busca de caminhos',
+ pathPickerSearchSubtitle: 'Mostrar um campo de busca nos seletores de caminhos',
},
errors: {
@@ -260,6 +278,9 @@ export const pt: TranslationStructure = {
newSession: {
// Used by new-session screen and launch flows
title: 'Iniciar nova sessão',
+ selectMachineTitle: 'Selecionar máquina',
+ selectPathTitle: 'Selecionar caminho',
+ searchPathsPlaceholder: 'Pesquisar caminhos...',
noMachinesFound: 'Nenhuma máquina encontrada. Inicie uma sessão Happy no seu computador primeiro.',
allMachinesOffline: 'Todas as máquinas estão offline',
machineDetails: 'Ver detalhes da máquina →',
@@ -275,6 +296,26 @@ export const pt: TranslationStructure = {
startNewSessionInFolder: 'Nova sessão aqui',
noMachineSelected: 'Por favor, selecione uma máquina para iniciar a sessão',
noPathSelected: 'Por favor, selecione um diretório para iniciar a sessão',
+ machinePicker: {
+ searchPlaceholder: 'Pesquisar máquinas...',
+ recentTitle: 'Recentes',
+ favoritesTitle: 'Favoritos',
+ allTitle: 'Todas',
+ emptyMessage: 'Nenhuma máquina disponível',
+ },
+ pathPicker: {
+ enterPathTitle: 'Inserir caminho',
+ enterPathPlaceholder: 'Insira um caminho...',
+ customPathTitle: 'Caminho personalizado',
+ recentTitle: 'Recentes',
+ favoritesTitle: 'Favoritos',
+ suggestedTitle: 'Sugeridos',
+ allTitle: 'Todas',
+ emptyRecent: 'Nenhum caminho recente',
+ emptyFavorites: 'Nenhum caminho favorito',
+ emptySuggested: 'Nenhum caminho sugerido',
+ emptyAll: 'Nenhum caminho',
+ },
sessionType: {
title: 'Tipo de sessão',
simple: 'Simples',
@@ -336,6 +377,7 @@ export const pt: TranslationStructure = {
happySessionId: 'ID da sessão Happy',
claudeCodeSessionId: 'ID da sessão Claude Code',
claudeCodeSessionIdCopied: 'ID da sessão Claude Code copiado para a área de transferência',
+ aiProfile: 'Perfil de IA',
aiProvider: 'Provedor de IA',
failedToCopyClaudeCodeSessionId: 'Falha ao copiar ID da sessão Claude Code',
metadataCopied: 'Metadados copiados para a área de transferência',
@@ -390,6 +432,10 @@ export const pt: TranslationStructure = {
},
agentInput: {
+ envVars: {
+ title: 'Vars env',
+ titleWithCount: ({ count }: { count: number }) => `Vars env (${count})`,
+ },
permissionMode: {
title: 'MODO DE PERMISSÃO',
default: 'Padrão',
@@ -430,14 +476,29 @@ 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: 'Somente leitura',
+ safeYolo: 'YOLO seguro',
+ yolo: 'YOLO',
+ badgeReadOnly: 'Somente leitura',
+ badgeSafeYolo: 'YOLO seguro',
+ badgeYolo: 'YOLO',
+ },
+ geminiModel: {
+ title: 'MODELO GEMINI',
+ gemini25Pro: {
+ label: 'Gemini 2.5 Pro',
+ description: 'Mais capaz',
+ },
+ gemini25Flash: {
+ label: 'Gemini 2.5 Flash',
+ description: 'Rápido e eficiente',
+ },
+ gemini25FlashLite: {
+ label: 'Gemini 2.5 Flash Lite',
+ description: 'Mais rápido',
+ },
},
context: {
remaining: ({ percent }: { percent: number }) => `${percent}% restante`,
@@ -504,6 +565,10 @@ export const pt: TranslationStructure = {
applyChanges: 'Atualizar arquivo',
viewDiff: 'Alterações do arquivo atual',
question: 'Pergunta',
+ changeTitle: 'Alterar título',
+ },
+ geminiExecute: {
+ cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`,
},
desc: {
terminalCmd: ({ cmd }: { cmd: string }) => `Terminal(cmd: ${cmd})`,
@@ -894,8 +959,120 @@ 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',
+ builtIn: 'Integrado',
+ builtInNames: {
+ anthropic: 'Anthropic (Default)',
+ deepseek: 'DeepSeek (Reasoner)',
+ zai: 'Z.AI (GLM-4.6)',
+ openai: 'OpenAI (GPT-5)',
+ azureOpenai: 'Azure OpenAI',
+ },
+ groups: {
+ favorites: 'Favoritos',
+ custom: 'Seus perfis',
+ builtIn: 'Perfis integrados',
+ },
+ actions: {
+ viewEnvironmentVariables: 'Variáveis de ambiente',
+ addToFavorites: 'Adicionar aos favoritos',
+ removeFromFavorites: 'Remover dos favoritos',
+ editProfile: 'Editar perfil',
+ duplicateProfile: 'Duplicar perfil',
+ deleteProfile: 'Excluir perfil',
+ },
+ copySuffix: '(Copy)',
+ duplicateName: 'Já existe um perfil com este nome',
+ setupInstructions: {
+ title: 'Instruções de configuração',
+ viewOfficialGuide: 'Ver guia oficial de configuração',
+ },
+ defaultSessionType: 'Tipo de sessão padrão',
+ defaultPermissionMode: {
+ title: 'Modo de permissão padrão',
+ descriptions: {
+ default: 'Solicitar permissões',
+ acceptEdits: 'Aprovar edições automaticamente',
+ plan: 'Planejar antes de executar',
+ bypassPermissions: 'Ignorar todas as permissões',
+ },
+ },
+ aiBackend: {
+ title: 'Backend de IA',
+ selectAtLeastOneError: 'Selecione pelo menos um backend de IA.',
+ claudeSubtitle: 'CLI do Claude',
+ codexSubtitle: 'CLI do Codex',
+ geminiSubtitleExperimental: 'CLI do Gemini (experimental)',
+ },
+ tmux: {
+ title: 'Tmux',
+ spawnSessionsTitle: 'Iniciar sessões no Tmux',
+ spawnSessionsEnabledSubtitle: 'As sessões são iniciadas em novas janelas do tmux.',
+ spawnSessionsDisabledSubtitle: 'As sessões são iniciadas no shell comum (sem integração com tmux)',
+ sessionNamePlaceholder: 'Vazio = sessão atual/mais recente',
+ tempDirPlaceholder: '/tmp (opcional)',
+ },
+ previewMachine: {
+ title: 'Pré-visualizar máquina',
+ selectMachine: 'Selecionar máquina',
+ resolveSubtitle: 'Resolver variáveis de ambiente da máquina para este perfil.',
+ selectSubtitle: 'Selecione uma máquina para pré-visualizar os valores resolvidos.',
+ },
+ environmentVariables: {
+ title: 'Variáveis de ambiente',
+ addVariable: 'Adicionar variável',
+ namePlaceholder: 'Nome da variável (e.g., MY_CUSTOM_VAR)',
+ valuePlaceholder: 'Valor (e.g., my-value ou ${MY_VAR})',
+ validation: {
+ nameRequired: 'Digite um nome de variável.',
+ invalidNameFormat: 'Os nomes das variáveis devem conter letras maiúsculas, números e sublinhados, e não podem começar com um número.',
+ duplicateName: 'Essa variável já existe.',
+ },
+ card: {
+ valueLabel: 'Valor:',
+ fallbackValueLabel: 'Valor de fallback:',
+ valueInputPlaceholder: 'Valor',
+ defaultValueInputPlaceholder: 'Valor padrão',
+ secretNotRetrieved: 'Valor secreto - não é recuperado por segurança',
+ overridingDefault: ({ expectedValue }: { expectedValue: string }) =>
+ `Substituindo o valor padrão documentado: ${expectedValue}`,
+ useMachineEnvToggle: 'Usar valor do ambiente da máquina',
+ resolvedOnSessionStart: 'Resolvido quando a sessão começa na máquina selecionada.',
+ sourceVariableLabel: 'Variável de origem',
+ sourceVariablePlaceholder: 'Nome da variável de origem (e.g., Z_AI_MODEL)',
+ checkingMachine: ({ machine }: { machine: string }) => `Verificando ${machine}...`,
+ emptyOnMachine: ({ machine }: { machine: string }) => `Vazio em ${machine}`,
+ emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `Vazio em ${machine} (usando fallback)`,
+ notFoundOnMachine: ({ machine }: { machine: string }) => `Não encontrado em ${machine}`,
+ notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `Não encontrado em ${machine} (usando fallback)`,
+ valueFoundOnMachine: ({ machine }: { machine: string }) => `Valor encontrado em ${machine}`,
+ differsFromDocumented: ({ expectedValue }: { expectedValue: string }) =>
+ `Diferente do valor documentado: ${expectedValue}`,
+ },
+ preview: {
+ secretValueHidden: ({ value }: { value: string }) => `${value} - oculto por segurança`,
+ hiddenValue: '***oculto***',
+ emptyValue: '(vazio)',
+ sessionWillReceive: ({ name, value }: { name: string; value: string }) =>
+ `A sessão receberá: ${name} = ${value}`,
+ },
+ previewModal: {
+ titleWithProfile: ({ profileName }: { profileName: string }) => `Vars de ambiente · ${profileName}`,
+ descriptionPrefix: 'Estas variáveis de ambiente são enviadas ao iniciar a sessão. Os valores são resolvidos usando o daemon em',
+ descriptionFallbackMachine: 'a máquina selecionada',
+ descriptionSuffix: '.',
+ emptyMessage: 'Nenhuma variável de ambiente está definida para este perfil.',
+ checkingSuffix: '(verificando…)',
+ detail: {
+ fixed: 'Fixo',
+ machine: 'Máquina',
+ checking: 'Verificando',
+ fallback: 'Fallback',
+ missing: 'Ausente',
+ },
+ },
+ },
delete: {
title: 'Excluir Perfil',
message: ({ name }: { name: string }) => `Tem certeza de que deseja excluir "${name}"? Esta ação não pode ser desfeita.`,
diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts
index aa533ea82..c05ed4fac 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
@@ -42,6 +42,8 @@ export const ru: TranslationStructure = {
common: {
// Simple string constants
+ add: 'Добавить',
+ actions: 'Действия',
cancel: 'Отмена',
authenticate: 'Авторизация',
save: 'Сохранить',
@@ -58,6 +60,9 @@ export const ru: TranslationStructure = {
yes: 'Да',
no: 'Нет',
discard: 'Отменить',
+ discardChanges: 'Отменить изменения',
+ unsavedChangesWarning: 'У вас есть несохранённые изменения.',
+ keepEditing: 'Продолжить редактирование',
version: 'Версия',
copied: 'Скопировано',
copy: 'Копировать',
@@ -71,6 +76,10 @@ export const ru: TranslationStructure = {
retry: 'Повторить',
delete: 'Удалить',
optional: 'необязательно',
+ noMatches: 'Нет совпадений',
+ all: 'All',
+ machine: 'машина',
+ clearSearch: 'Clear search',
},
connect: {
@@ -190,6 +199,15 @@ export const ru: TranslationStructure = {
enhancedSessionWizard: 'Улучшенный мастер сессий',
enhancedSessionWizardEnabled: 'Лаунчер с профилем активен',
enhancedSessionWizardDisabled: 'Используется стандартный лаунчер',
+ profiles: 'Профили ИИ',
+ profilesEnabled: 'Выбор профилей включён',
+ profilesDisabled: 'Выбор профилей отключён',
+ pickerSearch: 'Поиск в выборе',
+ pickerSearchSubtitle: 'Показывать поле поиска в выборе машины и пути',
+ machinePickerSearch: 'Поиск машин',
+ machinePickerSearchSubtitle: 'Показывать поле поиска при выборе машины',
+ pathPickerSearch: 'Поиск путей',
+ pathPickerSearchSubtitle: 'Показывать поле поиска при выборе пути',
},
errors: {
@@ -242,6 +260,9 @@ export const ru: TranslationStructure = {
newSession: {
// Used by new-session screen and launch flows
title: 'Начать новую сессию',
+ selectMachineTitle: 'Выбрать машину',
+ selectPathTitle: 'Выбрать путь',
+ searchPathsPlaceholder: 'Поиск путей...',
noMachinesFound: 'Машины не найдены. Сначала запустите сессию Happy на вашем компьютере.',
allMachinesOffline: 'Все машины находятся offline',
machineDetails: 'Посмотреть детали машины →',
@@ -257,6 +278,26 @@ export const ru: TranslationStructure = {
startNewSessionInFolder: 'Новая сессия здесь',
noMachineSelected: 'Пожалуйста, выберите машину для запуска сессии',
noPathSelected: 'Пожалуйста, выберите директорию для запуска сессии',
+ machinePicker: {
+ searchPlaceholder: 'Поиск машин...',
+ recentTitle: 'Недавние',
+ favoritesTitle: 'Избранное',
+ allTitle: 'Все',
+ emptyMessage: 'Нет доступных машин',
+ },
+ pathPicker: {
+ enterPathTitle: 'Введите путь',
+ enterPathPlaceholder: 'Введите путь...',
+ customPathTitle: 'Пользовательский путь',
+ recentTitle: 'Недавние',
+ favoritesTitle: 'Избранное',
+ suggestedTitle: 'Рекомендуемые',
+ allTitle: 'Все',
+ emptyRecent: 'Нет недавних путей',
+ emptyFavorites: 'Нет избранных путей',
+ emptySuggested: 'Нет рекомендуемых путей',
+ emptyAll: 'Нет путей',
+ },
sessionType: {
title: 'Тип сессии',
simple: 'Простая',
@@ -310,6 +351,7 @@ export const ru: TranslationStructure = {
happySessionId: 'ID сессии Happy',
claudeCodeSessionId: 'ID сессии Claude Code',
claudeCodeSessionIdCopied: 'ID сессии Claude Code скопирован в буфер обмена',
+ aiProfile: 'Профиль ИИ',
aiProvider: 'Поставщик ИИ',
failedToCopyClaudeCodeSessionId: 'Не удалось скопировать ID сессии Claude Code',
metadataCopied: 'Метаданные скопированы в буфер обмена',
@@ -400,6 +442,10 @@ export const ru: TranslationStructure = {
},
agentInput: {
+ envVars: {
+ title: 'Переменные окружения',
+ titleWithCount: ({ count }: { count: number }) => `Переменные окружения (${count})`,
+ },
permissionMode: {
title: 'РЕЖИМ РАЗРЕШЕНИЙ',
default: 'По умолчанию',
@@ -449,6 +495,21 @@ export const ru: TranslationStructure = {
badgeSafeYolo: 'Безопасный YOLO',
badgeYolo: 'YOLO',
},
+ geminiModel: {
+ title: 'GEMINI MODEL',
+ gemini25Pro: {
+ label: 'Gemini 2.5 Pro',
+ description: 'Самая мощная',
+ },
+ gemini25Flash: {
+ label: 'Gemini 2.5 Flash',
+ description: 'Быстро и эффективно',
+ },
+ gemini25FlashLite: {
+ label: 'Gemini 2.5 Flash Lite',
+ description: 'Самая быстрая',
+ },
+ },
context: {
remaining: ({ percent }: { percent: number }) => `Осталось ${percent}%`,
},
@@ -514,6 +575,10 @@ export const ru: TranslationStructure = {
applyChanges: 'Обновить файл',
viewDiff: 'Текущие изменения файла',
question: 'Вопрос',
+ changeTitle: 'Изменить заголовок',
+ },
+ geminiExecute: {
+ cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`,
},
desc: {
terminalCmd: ({ cmd }: { cmd: string }) => `Терминал(команда: ${cmd})`,
@@ -925,9 +990,123 @@ export const ru: TranslationStructure = {
enterTmuxTempDir: 'Введите путь к временному каталогу',
tmuxUpdateEnvironment: 'Обновлять окружение автоматически',
nameRequired: 'Имя профиля обязательно',
- deleteConfirm: 'Вы уверены, что хотите удалить профиль "{name}"?',
+ deleteConfirm: ({ name }: { name: string }) => `Вы уверены, что хотите удалить профиль "${name}"?`,
editProfile: 'Редактировать Профиль',
addProfileTitle: 'Добавить Новый Профиль',
+ builtIn: 'Встроенный',
+ builtInNames: {
+ anthropic: 'Anthropic (Default)',
+ deepseek: 'DeepSeek (Reasoner)',
+ zai: 'Z.AI (GLM-4.6)',
+ openai: 'OpenAI (GPT-5)',
+ azureOpenai: 'Azure OpenAI',
+ },
+ groups: {
+ favorites: 'Избранное',
+ custom: 'Ваши профили',
+ builtIn: 'Встроенные профили',
+ },
+ actions: {
+ viewEnvironmentVariables: 'Переменные окружения',
+ addToFavorites: 'Добавить в избранное',
+ removeFromFavorites: 'Убрать из избранного',
+ editProfile: 'Редактировать профиль',
+ duplicateProfile: 'Дублировать профиль',
+ deleteProfile: 'Удалить профиль',
+ },
+ copySuffix: '(Copy)',
+ duplicateName: 'Профиль с таким названием уже существует',
+ setupInstructions: {
+ title: 'Инструкции по настройке',
+ viewOfficialGuide: 'Открыть официальное руководство',
+ },
+ defaultSessionType: 'Тип сессии по умолчанию',
+ defaultPermissionMode: {
+ title: 'Режим разрешений по умолчанию',
+ descriptions: {
+ default: 'Запрашивать разрешения',
+ acceptEdits: 'Авто-одобрять правки',
+ plan: 'Планировать перед выполнением',
+ bypassPermissions: 'Пропускать все разрешения',
+ },
+ },
+ aiBackend: {
+ title: 'Бекенд ИИ',
+ selectAtLeastOneError: 'Выберите хотя бы один бекенд ИИ.',
+ claudeSubtitle: 'Claude CLI',
+ codexSubtitle: 'Codex CLI',
+ geminiSubtitleExperimental: 'Gemini CLI (экспериментально)',
+ },
+ tmux: {
+ title: 'Tmux',
+ spawnSessionsTitle: 'Запускать сессии в Tmux',
+ spawnSessionsEnabledSubtitle: 'Сессии запускаются в новых окнах tmux.',
+ spawnSessionsDisabledSubtitle: 'Сессии запускаются в обычной оболочке (без интеграции с tmux)',
+ sessionNamePlaceholder: 'Пусто = текущая/последняя сессия',
+ tempDirPlaceholder: '/tmp (необязательно)',
+ },
+ previewMachine: {
+ title: 'Предпросмотр машины',
+ selectMachine: 'Выбрать машину',
+ resolveSubtitle: 'Разрешить переменные окружения машины для этого профиля.',
+ selectSubtitle: 'Выберите машину, чтобы просмотреть вычисленные значения.',
+ },
+ environmentVariables: {
+ title: 'Переменные окружения',
+ addVariable: 'Добавить переменную',
+ namePlaceholder: 'Имя переменной (например, MY_CUSTOM_VAR)',
+ valuePlaceholder: 'Значение (например, my-value или ${MY_VAR})',
+ validation: {
+ nameRequired: 'Введите имя переменной.',
+ invalidNameFormat: 'Имена переменных должны содержать заглавные буквы, цифры и подчёркивания и не могут начинаться с цифры.',
+ duplicateName: 'Такая переменная уже существует.',
+ },
+ card: {
+ valueLabel: 'Значение:',
+ fallbackValueLabel: 'Значение по умолчанию:',
+ valueInputPlaceholder: 'Значение',
+ defaultValueInputPlaceholder: 'Значение по умолчанию',
+ secretNotRetrieved: 'Секретное значение — не извлекается из соображений безопасности',
+ overridingDefault: ({ expectedValue }: { expectedValue: string }) =>
+ `Переопределение документированного значения: ${expectedValue}`,
+ useMachineEnvToggle: 'Использовать значение из окружения машины',
+ resolvedOnSessionStart: 'Разрешается при запуске сессии на выбранной машине.',
+ sourceVariableLabel: 'Переменная-источник',
+ sourceVariablePlaceholder: 'Имя переменной-источника (например, Z_AI_MODEL)',
+ checkingMachine: ({ machine }: { machine: string }) => `Проверка ${machine}...`,
+ emptyOnMachine: ({ machine }: { machine: string }) => `Пусто на ${machine}`,
+ emptyOnMachineUsingFallback: ({ machine }: { machine: string }) =>
+ `Пусто на ${machine} (используется значение по умолчанию)`,
+ notFoundOnMachine: ({ machine }: { machine: string }) => `Не найдено на ${machine}`,
+ notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) =>
+ `Не найдено на ${machine} (используется значение по умолчанию)`,
+ valueFoundOnMachine: ({ machine }: { machine: string }) => `Значение найдено на ${machine}`,
+ differsFromDocumented: ({ expectedValue }: { expectedValue: string }) =>
+ `Отличается от документированного значения: ${expectedValue}`,
+ },
+ preview: {
+ secretValueHidden: ({ value }: { value: string }) => `${value} — скрыто из соображений безопасности`,
+ hiddenValue: '***скрыто***',
+ emptyValue: '(пусто)',
+ sessionWillReceive: ({ name, value }: { name: string; value: string }) =>
+ `Сессия получит: ${name} = ${value}`,
+ },
+ previewModal: {
+ titleWithProfile: ({ profileName }: { profileName: string }) => `Переменные окружения · ${profileName}`,
+ descriptionPrefix: 'Эти переменные окружения отправляются при запуске сессии. Значения разрешаются демоном на',
+ descriptionFallbackMachine: 'выбранной машине',
+ descriptionSuffix: '.',
+ emptyMessage: 'Для этого профиля не заданы переменные окружения.',
+ checkingSuffix: '(проверка…)',
+ detail: {
+ fixed: 'Фиксированное',
+ machine: 'Машина',
+ checking: 'Проверка',
+ fallback: 'По умолчанию',
+ missing: 'Отсутствует',
+ },
+ },
+ },
delete: {
title: 'Удалить Профиль',
message: ({ name }: { name: string }) => `Вы уверены, что хотите удалить "${name}"? Это действие нельзя отменить.`,
diff --git a/sources/text/translations/zh-Hans.ts b/sources/text/translations/zh-Hans.ts
index b77851fde..d78e41b61 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
@@ -33,6 +33,8 @@ export const zhHans: TranslationStructure = {
common: {
// Simple string constants
+ add: '添加',
+ actions: '操作',
cancel: '取消',
authenticate: '认证',
save: '保存',
@@ -49,6 +51,9 @@ export const zhHans: TranslationStructure = {
yes: '是',
no: '否',
discard: '放弃',
+ discardChanges: '放弃更改',
+ unsavedChangesWarning: '你有未保存的更改。',
+ keepEditing: '继续编辑',
version: '版本',
copied: '已复制',
copy: '复制',
@@ -62,6 +67,10 @@ export const zhHans: TranslationStructure = {
retry: '重试',
delete: '删除',
optional: '可选的',
+ noMatches: '无匹配结果',
+ all: 'All',
+ machine: '机器',
+ clearSearch: 'Clear search',
},
profile: {
@@ -210,6 +219,15 @@ export const zhHans: TranslationStructure = {
enhancedSessionWizard: '增强会话向导',
enhancedSessionWizardEnabled: '配置文件优先启动器已激活',
enhancedSessionWizardDisabled: '使用标准会话启动器',
+ profiles: 'AI 配置文件',
+ profilesEnabled: '已启用配置文件选择',
+ profilesDisabled: '已禁用配置文件选择',
+ pickerSearch: '选择器搜索',
+ pickerSearchSubtitle: '在设备和路径选择器中显示搜索框',
+ machinePickerSearch: '设备搜索',
+ machinePickerSearchSubtitle: '在设备选择器中显示搜索框',
+ pathPickerSearch: '路径搜索',
+ pathPickerSearchSubtitle: '在路径选择器中显示搜索框',
},
errors: {
@@ -262,6 +280,9 @@ export const zhHans: TranslationStructure = {
newSession: {
// Used by new-session screen and launch flows
title: '启动新会话',
+ selectMachineTitle: '选择设备',
+ selectPathTitle: '选择路径',
+ searchPathsPlaceholder: '搜索路径...',
noMachinesFound: '未找到设备。请先在您的计算机上启动 Happy 会话。',
allMachinesOffline: '所有设备似乎都已离线',
machineDetails: '查看设备详情 →',
@@ -277,6 +298,26 @@ export const zhHans: TranslationStructure = {
notConnectedToServer: '未连接到服务器。请检查您的网络连接。',
noMachineSelected: '请选择一台设备以启动会话',
noPathSelected: '请选择一个目录以启动会话',
+ machinePicker: {
+ searchPlaceholder: '搜索设备...',
+ recentTitle: '最近',
+ favoritesTitle: '收藏',
+ allTitle: '全部',
+ emptyMessage: '没有可用设备',
+ },
+ pathPicker: {
+ enterPathTitle: '输入路径',
+ enterPathPlaceholder: '输入路径...',
+ customPathTitle: '自定义路径',
+ recentTitle: '最近',
+ favoritesTitle: '收藏',
+ suggestedTitle: '推荐',
+ allTitle: '全部',
+ emptyRecent: '没有最近的路径',
+ emptyFavorites: '没有收藏的路径',
+ emptySuggested: '没有推荐的路径',
+ emptyAll: '没有路径',
+ },
sessionType: {
title: '会话类型',
simple: '简单',
@@ -338,6 +379,7 @@ export const zhHans: TranslationStructure = {
happySessionId: 'Happy 会话 ID',
claudeCodeSessionId: 'Claude Code 会话 ID',
claudeCodeSessionIdCopied: 'Claude Code 会话 ID 已复制到剪贴板',
+ aiProfile: 'AI 配置文件',
aiProvider: 'AI 提供商',
failedToCopyClaudeCodeSessionId: '复制 Claude Code 会话 ID 失败',
metadataCopied: '元数据已复制到剪贴板',
@@ -392,6 +434,10 @@ export const zhHans: TranslationStructure = {
},
agentInput: {
+ envVars: {
+ title: '环境变量',
+ titleWithCount: ({ count }: { count: number }) => `环境变量 (${count})`,
+ },
permissionMode: {
title: '权限模式',
default: '默认',
@@ -432,14 +478,29 @@ export const zhHans: TranslationStructure = {
gpt5High: 'GPT-5 High',
},
geminiPermissionMode: {
- title: '权限模式',
+ title: 'GEMINI 权限模式',
default: '默认',
- acceptEdits: '接受编辑',
- plan: '计划模式',
- bypassPermissions: 'Yolo 模式',
- badgeAcceptAllEdits: '接受所有编辑',
- badgeBypassAllPermissions: '绕过所有权限',
- badgePlanMode: '计划模式',
+ readOnly: '只读',
+ safeYolo: '安全 YOLO',
+ yolo: 'YOLO',
+ badgeReadOnly: '只读',
+ badgeSafeYolo: '安全 YOLO',
+ badgeYolo: 'YOLO',
+ },
+ geminiModel: {
+ title: 'GEMINI 模型',
+ gemini25Pro: {
+ label: 'Gemini 2.5 Pro',
+ description: '最强能力',
+ },
+ gemini25Flash: {
+ label: 'Gemini 2.5 Flash',
+ description: '快速且高效',
+ },
+ gemini25FlashLite: {
+ label: 'Gemini 2.5 Flash Lite',
+ description: '最快',
+ },
},
context: {
remaining: ({ percent }: { percent: number }) => `剩余 ${percent}%`,
@@ -506,6 +567,10 @@ export const zhHans: TranslationStructure = {
applyChanges: '更新文件',
viewDiff: '当前文件更改',
question: '问题',
+ changeTitle: '更改标题',
+ },
+ geminiExecute: {
+ cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`,
},
desc: {
terminalCmd: ({ cmd }: { cmd: string }) => `终端(命令: ${cmd})`,
@@ -896,8 +961,120 @@ export const zhHans: TranslationStructure = {
tmuxTempDir: 'tmux 临时目录',
enterTmuxTempDir: '输入 tmux 临时目录',
tmuxUpdateEnvironment: '更新 tmux 环境',
- deleteConfirm: '确定要删除此配置文件吗?',
+ deleteConfirm: ({ name }: { name: string }) => `确定要删除配置文件“${name}”吗?`,
nameRequired: '配置文件名称为必填项',
+ builtIn: '内置',
+ builtInNames: {
+ anthropic: 'Anthropic (Default)',
+ deepseek: 'DeepSeek (Reasoner)',
+ zai: 'Z.AI (GLM-4.6)',
+ openai: 'OpenAI (GPT-5)',
+ azureOpenai: 'Azure OpenAI',
+ },
+ groups: {
+ favorites: '收藏',
+ custom: '你的配置文件',
+ builtIn: '内置配置文件',
+ },
+ actions: {
+ viewEnvironmentVariables: '环境变量',
+ addToFavorites: '添加到收藏',
+ removeFromFavorites: '从收藏中移除',
+ editProfile: '编辑配置文件',
+ duplicateProfile: '复制配置文件',
+ deleteProfile: '删除配置文件',
+ },
+ copySuffix: '(Copy)',
+ duplicateName: '已存在同名配置文件',
+ setupInstructions: {
+ title: '设置说明',
+ viewOfficialGuide: '查看官方设置指南',
+ },
+ defaultSessionType: '默认会话类型',
+ defaultPermissionMode: {
+ title: '默认权限模式',
+ descriptions: {
+ default: '询问权限',
+ acceptEdits: '自动批准编辑',
+ plan: '执行前先规划',
+ bypassPermissions: '跳过所有权限',
+ },
+ },
+ aiBackend: {
+ title: 'AI 后端',
+ selectAtLeastOneError: '至少选择一个 AI 后端。',
+ claudeSubtitle: 'Claude CLI',
+ codexSubtitle: 'Codex CLI',
+ geminiSubtitleExperimental: 'Gemini CLI(实验)',
+ },
+ tmux: {
+ title: 'tmux',
+ spawnSessionsTitle: '在 tmux 中启动会话',
+ spawnSessionsEnabledSubtitle: '会话将在新的 tmux 窗口中启动。',
+ spawnSessionsDisabledSubtitle: '会话将在普通 shell 中启动(无 tmux 集成)',
+ sessionNamePlaceholder: '留空 = 当前/最近会话',
+ tempDirPlaceholder: '/tmp(可选)',
+ },
+ previewMachine: {
+ title: '预览设备',
+ selectMachine: '选择设备',
+ resolveSubtitle: '为此配置文件解析设备环境变量。',
+ selectSubtitle: '选择设备以预览解析后的值。',
+ },
+ environmentVariables: {
+ title: '环境变量',
+ addVariable: '添加变量',
+ namePlaceholder: '变量名(例如 MY_CUSTOM_VAR)',
+ valuePlaceholder: '值(例如 my-value 或 ${MY_VAR})',
+ validation: {
+ nameRequired: '请输入变量名。',
+ invalidNameFormat: '变量名必须由大写字母、数字和下划线组成,且不能以数字开头。',
+ duplicateName: '该变量已存在。',
+ },
+ card: {
+ valueLabel: '值:',
+ fallbackValueLabel: '备用值:',
+ valueInputPlaceholder: '值',
+ defaultValueInputPlaceholder: '默认值',
+ secretNotRetrieved: '秘密值——出于安全原因不会读取',
+ overridingDefault: ({ expectedValue }: { expectedValue: string }) =>
+ `正在覆盖文档默认值:${expectedValue}`,
+ useMachineEnvToggle: '使用设备环境中的值',
+ resolvedOnSessionStart: '会话在所选设备上启动时解析。',
+ sourceVariableLabel: '来源变量',
+ sourceVariablePlaceholder: '来源变量名(例如 Z_AI_MODEL)',
+ checkingMachine: ({ machine }: { machine: string }) => `正在检查 ${machine}...`,
+ emptyOnMachine: ({ machine }: { machine: string }) => `${machine} 上为空`,
+ emptyOnMachineUsingFallback: ({ machine }: { machine: string }) => `${machine} 上为空(使用备用值)`,
+ notFoundOnMachine: ({ machine }: { machine: string }) => `在 ${machine} 上未找到`,
+ notFoundOnMachineUsingFallback: ({ machine }: { machine: string }) => `在 ${machine} 上未找到(使用备用值)`,
+ valueFoundOnMachine: ({ machine }: { machine: string }) => `在 ${machine} 上找到值`,
+ differsFromDocumented: ({ expectedValue }: { expectedValue: string }) =>
+ `与文档值不同:${expectedValue}`,
+ },
+ preview: {
+ secretValueHidden: ({ value }: { value: string }) => `${value} - 出于安全已隐藏`,
+ hiddenValue: '***已隐藏***',
+ emptyValue: '(空)',
+ sessionWillReceive: ({ name, value }: { name: string; value: string }) =>
+ `会话将收到:${name} = ${value}`,
+ },
+ previewModal: {
+ titleWithProfile: ({ profileName }: { profileName: string }) => `环境变量 · ${profileName}`,
+ descriptionPrefix: '这些环境变量会在启动会话时发送。值会通过守护进程解析于',
+ descriptionFallbackMachine: '所选设备',
+ descriptionSuffix: '。',
+ emptyMessage: '该配置文件未设置环境变量。',
+ checkingSuffix: '(检查中…)',
+ detail: {
+ fixed: '固定',
+ machine: '设备',
+ checking: '检查中',
+ fallback: '备用',
+ missing: '缺失',
+ },
+ },
+ },
delete: {
title: '删除配置',
message: ({ name }: { name: string }) => `确定要删除"${name}"吗?此操作无法撤销。`,
diff --git a/sources/theme.css b/sources/theme.css
index 7e241b5ae..7bc81abac 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 - 96px)) !important;
+ max-height: min(820px, calc(100vh - 96px)) !important;
+ min-height: min(820px, calc(100vh - 96px)) !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
+}
diff --git a/sources/utils/envVarTemplate.test.ts b/sources/utils/envVarTemplate.test.ts
new file mode 100644
index 000000000..52ca30646
--- /dev/null
+++ b/sources/utils/envVarTemplate.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest';
+import { formatEnvVarTemplate, parseEnvVarTemplate } from './envVarTemplate';
+
+describe('envVarTemplate', () => {
+ it('preserves := operator during parse/format round-trip', () => {
+ const input = '${FOO:=bar}';
+ const parsed = parseEnvVarTemplate(input);
+ expect(parsed).toEqual({ sourceVar: 'FOO', operator: ':=', fallback: 'bar' });
+ expect(formatEnvVarTemplate(parsed!)).toBe(input);
+ });
+
+ it('preserves :- operator during parse/format round-trip', () => {
+ const input = '${FOO:-bar}';
+ const parsed = parseEnvVarTemplate(input);
+ expect(parsed).toEqual({ sourceVar: 'FOO', operator: ':-', fallback: 'bar' });
+ expect(formatEnvVarTemplate(parsed!)).toBe(input);
+ });
+
+ it('round-trips templates without a fallback', () => {
+ const input = '${FOO}';
+ const parsed = parseEnvVarTemplate(input);
+ expect(parsed).toEqual({ sourceVar: 'FOO', operator: null, fallback: '' });
+ expect(formatEnvVarTemplate(parsed!)).toBe(input);
+ });
+
+ it('formats an empty fallback when operator is explicitly provided', () => {
+ expect(formatEnvVarTemplate({ sourceVar: 'FOO', operator: ':=', fallback: '' })).toBe('${FOO:=}');
+ expect(formatEnvVarTemplate({ sourceVar: 'FOO', operator: ':-', fallback: '' })).toBe('${FOO:-}');
+ });
+});
+
diff --git a/sources/utils/envVarTemplate.ts b/sources/utils/envVarTemplate.ts
new file mode 100644
index 000000000..493ca41eb
--- /dev/null
+++ b/sources/utils/envVarTemplate.ts
@@ -0,0 +1,40 @@
+export type EnvVarTemplateOperator = ':-' | ':=';
+
+export type EnvVarTemplate = Readonly<{
+ sourceVar: string;
+ fallback: string;
+ operator: EnvVarTemplateOperator | null;
+}>;
+
+export function parseEnvVarTemplate(value: string): EnvVarTemplate | null {
+ const withFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*)(:-|:=)(.*)\}$/);
+ if (withFallback) {
+ return {
+ sourceVar: withFallback[1],
+ operator: withFallback[2] as EnvVarTemplateOperator,
+ fallback: withFallback[3],
+ };
+ }
+
+ const noFallback = value.match(/^\$\{([A-Z_][A-Z0-9_]*)\}$/);
+ if (noFallback) {
+ return {
+ sourceVar: noFallback[1],
+ operator: null,
+ fallback: '',
+ };
+ }
+
+ return null;
+}
+
+export function formatEnvVarTemplate(params: {
+ sourceVar: string;
+ fallback: string;
+ operator?: EnvVarTemplateOperator | null;
+}): string {
+ const operator: EnvVarTemplateOperator | null = params.operator ?? (params.fallback !== '' ? ':-' : null);
+ const suffix = operator ? `${operator}${params.fallback}` : '';
+ return `\${${params.sourceVar}${suffix}}`;
+}
+
diff --git a/sources/utils/ignoreNextRowPress.test.ts b/sources/utils/ignoreNextRowPress.test.ts
new file mode 100644
index 000000000..807780c5b
--- /dev/null
+++ b/sources/utils/ignoreNextRowPress.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it, vi } from 'vitest';
+import { ignoreNextRowPress } from './ignoreNextRowPress';
+
+describe('ignoreNextRowPress', () => {
+ it('resets the ignore flag on the next tick', () => {
+ vi.useFakeTimers();
+ try {
+ const ref = { current: false };
+
+ ignoreNextRowPress(ref);
+ expect(ref.current).toBe(true);
+
+ vi.runAllTimers();
+ expect(ref.current).toBe(false);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/sources/utils/ignoreNextRowPress.ts b/sources/utils/ignoreNextRowPress.ts
new file mode 100644
index 000000000..55c95e473
--- /dev/null
+++ b/sources/utils/ignoreNextRowPress.ts
@@ -0,0 +1,7 @@
+export function ignoreNextRowPress(ref: { current: boolean }): void {
+ ref.current = true;
+ setTimeout(() => {
+ ref.current = false;
+ }, 0);
+}
+
diff --git a/sources/utils/promptUnsavedChangesAlert.test.ts b/sources/utils/promptUnsavedChangesAlert.test.ts
new file mode 100644
index 000000000..85daab85f
--- /dev/null
+++ b/sources/utils/promptUnsavedChangesAlert.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from 'vitest';
+import type { AlertButton } from '@/modal/types';
+import { promptUnsavedChangesAlert } from '@/utils/promptUnsavedChangesAlert';
+
+const basePromptOptions = {
+ title: 'Discard changes',
+ message: 'You have unsaved changes.',
+ discardText: 'Discard',
+ saveText: 'Save',
+ keepEditingText: 'Keep editing',
+} as const;
+
+function createPromptHarness() {
+ let lastButtons: AlertButton[] | undefined;
+
+ const alert = (_title: string, _message?: string, buttons?: AlertButton[]) => {
+ lastButtons = buttons;
+ };
+
+ const promise = promptUnsavedChangesAlert(alert, basePromptOptions);
+
+ function press(text: string) {
+ const button = lastButtons?.find((b) => b.text === text);
+ expect(button).toBeDefined();
+ button?.onPress?.();
+ }
+
+ return { promise, press };
+}
+
+describe('promptUnsavedChangesAlert', () => {
+ it('resolves to save when the Save button is pressed', async () => {
+ const { promise, press } = createPromptHarness();
+
+ press('Save');
+
+ await expect(promise).resolves.toBe('save');
+ });
+
+ it('resolves to discard when the Discard button is pressed', async () => {
+ const { promise, press } = createPromptHarness();
+
+ press('Discard');
+
+ await expect(promise).resolves.toBe('discard');
+ });
+
+ it('resolves to keepEditing when the Keep editing button is pressed', async () => {
+ const { promise, press } = createPromptHarness();
+
+ press('Keep editing');
+
+ await expect(promise).resolves.toBe('keepEditing');
+ });
+});
diff --git a/sources/utils/promptUnsavedChangesAlert.ts b/sources/utils/promptUnsavedChangesAlert.ts
new file mode 100644
index 000000000..867580f3a
--- /dev/null
+++ b/sources/utils/promptUnsavedChangesAlert.ts
@@ -0,0 +1,35 @@
+import type { AlertButton } from '@/modal/types';
+
+export type UnsavedChangesDecision = 'discard' | 'save' | 'keepEditing';
+
+export function promptUnsavedChangesAlert(
+ alert: (title: string, message?: string, buttons?: AlertButton[]) => void,
+ params: {
+ title: string;
+ message: string;
+ discardText: string;
+ saveText: string;
+ keepEditingText: string;
+ },
+): Promise {
+ return new Promise((resolve) => {
+ alert(params.title, params.message, [
+ {
+ text: params.discardText,
+ style: 'destructive',
+ onPress: () => resolve('discard'),
+ },
+ {
+ text: params.saveText,
+ style: 'default',
+ onPress: () => resolve('save'),
+ },
+ {
+ text: params.keepEditingText,
+ style: 'cancel',
+ onPress: () => resolve('keepEditing'),
+ },
+ ]);
+ });
+}
+
diff --git a/sources/utils/storageScope.test.ts b/sources/utils/storageScope.test.ts
new file mode 100644
index 000000000..bb2d15354
--- /dev/null
+++ b/sources/utils/storageScope.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ EXPO_PUBLIC_STORAGE_SCOPE_ENV_VAR,
+ normalizeStorageScope,
+ readStorageScopeFromEnv,
+ scopedStorageId,
+} from './storageScope';
+
+describe('storageScope', () => {
+ describe('normalizeStorageScope', () => {
+ it('returns null for non-strings and empty strings', () => {
+ expect(normalizeStorageScope(undefined)).toBeNull();
+ expect(normalizeStorageScope(null)).toBeNull();
+ expect(normalizeStorageScope(123)).toBeNull();
+ expect(normalizeStorageScope('')).toBeNull();
+ expect(normalizeStorageScope(' ')).toBeNull();
+ });
+
+ it('sanitizes unsafe characters and clamps length', () => {
+ expect(normalizeStorageScope(' pr272-107 ')).toBe('pr272-107');
+ expect(normalizeStorageScope('a/b:c')).toBe('a_b_c');
+ expect(normalizeStorageScope('a__b')).toBe('a_b');
+
+ const long = 'x'.repeat(100);
+ expect(normalizeStorageScope(long)?.length).toBe(64);
+ });
+ });
+
+ describe('readStorageScopeFromEnv', () => {
+ it('reads from EXPO_PUBLIC_HAPPY_STORAGE_SCOPE', () => {
+ expect(readStorageScopeFromEnv({ [EXPO_PUBLIC_STORAGE_SCOPE_ENV_VAR]: 'stack-1' })).toBe('stack-1');
+ expect(readStorageScopeFromEnv({ [EXPO_PUBLIC_STORAGE_SCOPE_ENV_VAR]: ' ' })).toBeNull();
+ });
+ });
+
+ describe('scopedStorageId', () => {
+ it('returns baseId when scope is null', () => {
+ expect(scopedStorageId('auth_credentials', null)).toBe('auth_credentials');
+ });
+
+ it('namespaces when scope is present', () => {
+ expect(scopedStorageId('auth_credentials', 'stack-1')).toBe('auth_credentials::stack-1');
+ });
+ });
+});
+
diff --git a/sources/utils/storageScope.ts b/sources/utils/storageScope.ts
new file mode 100644
index 000000000..bce4620d3
--- /dev/null
+++ b/sources/utils/storageScope.ts
@@ -0,0 +1,32 @@
+export const EXPO_PUBLIC_STORAGE_SCOPE_ENV_VAR = 'EXPO_PUBLIC_HAPPY_STORAGE_SCOPE';
+
+/**
+ * Returns a sanitized storage scope suitable for identifiers/keys, or null.
+ *
+ * Notes:
+ * - This is intentionally conservative (stable, URL/key friendly).
+ * - If unset/empty, callers should behave exactly as they did before (no scoping).
+ */
+export function normalizeStorageScope(value: unknown): string | null {
+ if (typeof value !== 'string') return null;
+ const trimmed = value.trim();
+ if (!trimmed) return null;
+
+ // Keep only safe characters to avoid backend/storage quirks (keychain, MMKV id, etc.)
+ // Replace everything else with '_' for stability.
+ const sanitized = trimmed.replace(/[^a-zA-Z0-9._-]/g, '_');
+ const collapsed = sanitized.replace(/_+/g, '_');
+ const clamped = collapsed.slice(0, 64);
+ return clamped || null;
+}
+
+export function readStorageScopeFromEnv(
+ env: Record = process.env,
+): string | null {
+ return normalizeStorageScope(env[EXPO_PUBLIC_STORAGE_SCOPE_ENV_VAR]);
+}
+
+export function scopedStorageId(baseId: string, scope: string | null): string {
+ return scope ? `${baseId}::${scope}` : baseId;
+}
+
diff --git a/yarn.lock b/yarn.lock
index ce5b12ad1..f2481eef6 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3109,6 +3109,13 @@
dependencies:
"@types/react" "*"
+"@types/react-test-renderer@^19.1.0":
+ version "19.1.0"
+ resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz#1d0af8f2e1b5931e245b8b5b234d1502b854dc10"
+ integrity sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==
+ dependencies:
+ "@types/react" "*"
+
"@types/react@*":
version "19.1.8"
resolved "https://registry.yarnpkg.com/@types/react/-/react-19.1.8.tgz#ff8395f2afb764597265ced15f8dddb0720ae1c3"