): 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..de0729fbd
--- /dev/null
+++ b/sources/profileRouteParams.ts
@@ -0,0 +1,56 @@
+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 };
+}
+
+export function consumeApiKeyIdParam(params: {
+ apiKeyIdParam?: string | string[];
+ selectedApiKeyId: string | null;
+}): {
+ nextSelectedApiKeyId: string | null | undefined;
+ shouldClearParam: boolean;
+} {
+ const nextApiKeyIdFromParams = normalizeOptionalParam(params.apiKeyIdParam);
+
+ if (typeof nextApiKeyIdFromParams !== 'string') {
+ return { nextSelectedApiKeyId: undefined, shouldClearParam: false };
+ }
+
+ if (nextApiKeyIdFromParams === '') {
+ return { nextSelectedApiKeyId: null, shouldClearParam: true };
+ }
+
+ if (nextApiKeyIdFromParams === params.selectedApiKeyId) {
+ return { nextSelectedApiKeyId: undefined, shouldClearParam: true };
+ }
+
+ return { nextSelectedApiKeyId: nextApiKeyIdFromParams, shouldClearParam: true };
+}
+
diff --git a/sources/realtime/RealtimeSession.ts b/sources/realtime/RealtimeSession.ts
index 93ab97318..374c81e0b 100644
--- a/sources/realtime/RealtimeSession.ts
+++ b/sources/realtime/RealtimeSession.ts
@@ -27,6 +27,8 @@ export async function startRealtimeSession(sessionId: string, initialContext?: s
}
const experimentsEnabled = storage.getState().settings.experiments;
+ const expVoiceAuthFlow = storage.getState().settings.expVoiceAuthFlow;
+ const useAuthFlow = experimentsEnabled && expVoiceAuthFlow;
const agentId = __DEV__ ? config.elevenLabsAgentIdDev : config.elevenLabsAgentIdProd;
if (!agentId) {
@@ -36,7 +38,7 @@ export async function startRealtimeSession(sessionId: string, initialContext?: s
try {
// Simple path: No experiments = no auth needed
- if (!experimentsEnabled) {
+ if (!useAuthFlow) {
currentSessionId = sessionId;
voiceSessionStarted = true;
await voiceSession.startSession({
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/scripts/compareTranslations.ts b/sources/scripts/compareTranslations.ts
index 6e740716c..5f5316a53 100644
--- a/sources/scripts/compareTranslations.ts
+++ b/sources/scripts/compareTranslations.ts
@@ -14,8 +14,10 @@ import { ru } from '../text/translations/ru';
import { pl } from '../text/translations/pl';
import { es } from '../text/translations/es';
import { pt } from '../text/translations/pt';
+import { it } from '../text/translations/it';
import { ca } from '../text/translations/ca';
import { zhHans } from '../text/translations/zh-Hans';
+import { ja } from '../text/translations/ja';
const translations = {
en,
@@ -23,8 +25,10 @@ const translations = {
pl,
es,
pt,
+ it,
ca,
'zh-Hans': zhHans,
+ ja,
};
const languageNames: Record = {
@@ -33,8 +37,10 @@ const languageNames: Record = {
pl: 'Polish',
es: 'Spanish',
pt: 'Portuguese',
+ it: 'Italian',
ca: 'Catalan',
'zh-Hans': 'Chinese (Simplified)',
+ ja: 'Japanese',
};
// Function to recursively extract all keys from an object
@@ -214,4 +220,4 @@ for (const key of sampleKeys) {
console.log(`- **${languageNames[langCode]}**: ${typeof value === 'string' ? `"${value}"` : '(function)'}`);
}
console.log('');
-}
\ No newline at end of file
+}
diff --git a/sources/scripts/findUntranslatedLiterals.ts b/sources/scripts/findUntranslatedLiterals.ts
new file mode 100644
index 000000000..cd0351449
--- /dev/null
+++ b/sources/scripts/findUntranslatedLiterals.ts
@@ -0,0 +1,253 @@
+#!/usr/bin/env tsx
+
+import * as fs from 'fs';
+import * as path from 'path';
+import ts from 'typescript';
+
+type Finding = {
+ file: string;
+ line: number;
+ col: number;
+ kind: 'jsx-text' | 'jsx-attr' | 'call-arg';
+ text: string;
+ context: string;
+};
+
+const projectRoot = path.resolve(__dirname, '../..');
+const sourcesRoot = path.join(projectRoot, 'sources');
+
+const EXCLUDE_DIRS = new Set([
+ 'node_modules',
+ '.git',
+ 'dist',
+ 'build',
+ 'coverage',
+]);
+
+function isUnder(dir: string, filePath: string): boolean {
+ const rel = path.relative(dir, filePath);
+ return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
+}
+
+function walk(dir: string, out: string[]) {
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
+ for (const entry of entries) {
+ if (entry.name.startsWith('.')) continue;
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (EXCLUDE_DIRS.has(entry.name)) continue;
+ walk(full, out);
+ continue;
+ }
+ if (!entry.isFile()) continue;
+ if (!/\.(ts|tsx|js|jsx)$/.test(entry.name)) continue;
+ out.push(full);
+ }
+}
+
+function getLineAndCol(sourceFile: ts.SourceFile, pos: number): { line: number; col: number } {
+ const lc = sourceFile.getLineAndCharacterOfPosition(pos);
+ return { line: lc.line + 1, col: lc.character + 1 };
+}
+
+function normalizeText(s: string): string {
+ return s.replace(/\s+/g, ' ').trim();
+}
+
+function shouldIgnoreLiteral(text: string): boolean {
+ const t = normalizeText(text);
+ if (!t) return true;
+
+ // Likely not user-facing / or intentionally not translated
+ if (t.startsWith('http://') || t.startsWith('https://')) return true;
+ if (/^[A-Z0-9_]{3,}$/.test(t)) return true; // ENV keys, constants
+ if (/^[a-z0-9._/-]+$/.test(t) && t.length <= 32) return true; // ids/paths/slugs
+ if (/^#[0-9a-f]{3,8}$/i.test(t)) return true;
+ if (/^\d+(\.\d+)*$/.test(t)) return true;
+
+ // Single punctuation / trivial
+ if (/^[•·\-\u2013\u2014]+$/.test(t)) return true;
+
+ return false;
+}
+
+const USER_FACING_ATTRS = new Set([
+ 'title',
+ 'subtitle',
+ 'description',
+ 'message',
+ 'label',
+ 'placeholder',
+ 'hint',
+ 'helperText',
+ 'emptyTitle',
+ 'emptyDescription',
+ 'confirmText',
+ 'cancelText',
+ 'text',
+ 'header',
+]);
+
+function isTCall(node: ts.Node): boolean {
+ if (!ts.isCallExpression(node)) return false;
+ if (ts.isIdentifier(node.expression)) return node.expression.text === 't';
+ return false;
+}
+
+function getNodeText(sourceFile: ts.SourceFile, node: ts.Node): string {
+ return sourceFile.text.slice(node.getStart(sourceFile), node.getEnd());
+}
+
+function takeContextLine(source: string, line: number): string {
+ const lines = source.split(/\r?\n/);
+ return lines[Math.max(0, Math.min(lines.length - 1, line - 1))]?.trim() ?? '';
+}
+
+function scanFile(filePath: string): Finding[] {
+ const rel = path.relative(projectRoot, filePath);
+
+ // Ignore translation sources and scripts
+ if (rel.includes(`sources${path.sep}text${path.sep}translations${path.sep}`)) return [];
+ if (rel.includes(`sources${path.sep}text${path.sep}_default`)) return [];
+ if (rel.includes(`sources${path.sep}scripts${path.sep}`)) return [];
+
+ const sourceText = fs.readFileSync(filePath, 'utf8');
+ const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true, filePath.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
+
+ const findings: Finding[] = [];
+
+ const visit = (node: ts.Node) => {
+ // JSX text nodes: Some string
+ if (ts.isJsxText(node)) {
+ const value = normalizeText(node.getText(sourceFile));
+ if (value && !shouldIgnoreLiteral(value)) {
+ const { line, col } = getLineAndCol(sourceFile, node.getStart(sourceFile));
+ findings.push({
+ file: rel,
+ line,
+ col,
+ kind: 'jsx-text',
+ text: value,
+ context: takeContextLine(sourceText, line),
+ });
+ }
+ }
+
+ // JSX attributes: title="Some"
+ if (ts.isJsxAttribute(node) && node.initializer) {
+ const attrName = node.name.getText(sourceFile);
+ if (USER_FACING_ATTRS.has(attrName)) {
+ const init = node.initializer;
+ if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) {
+ const value = normalizeText(init.text);
+ if (value && !shouldIgnoreLiteral(value)) {
+ const { line, col } = getLineAndCol(sourceFile, init.getStart(sourceFile));
+ findings.push({
+ file: rel,
+ line,
+ col,
+ kind: 'jsx-attr',
+ text: value,
+ context: takeContextLine(sourceText, line),
+ });
+ }
+ }
+ }
+ }
+
+ // Call args: Modal.alert("Error", "…")
+ if (ts.isCallExpression(node) && !isTCall(node)) {
+ const exprText = getNodeText(sourceFile, node.expression);
+ const isLikelyUiAlert =
+ exprText.endsWith('.alert') ||
+ exprText.endsWith('.confirm') ||
+ exprText.endsWith('.prompt') ||
+ exprText.includes('Toast') ||
+ exprText.includes('Modal');
+
+ if (isLikelyUiAlert) {
+ for (const arg of node.arguments) {
+ if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) {
+ const value = normalizeText(arg.text);
+ if (value && !shouldIgnoreLiteral(value)) {
+ const { line, col } = getLineAndCol(sourceFile, arg.getStart(sourceFile));
+ findings.push({
+ file: rel,
+ line,
+ col,
+ kind: 'call-arg',
+ text: value,
+ context: takeContextLine(sourceText, line),
+ });
+ }
+ }
+ }
+ }
+ }
+
+ ts.forEachChild(node, visit);
+ };
+
+ visit(sourceFile);
+
+ // Deduplicate exact same hits (common when JSXText includes leading/trailing whitespace)
+ const seen = new Set();
+ const unique: Finding[] = [];
+ for (const f of findings) {
+ const key = `${f.file}:${f.line}:${f.col}:${f.kind}:${f.text}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ unique.push(f);
+ }
+ return unique;
+}
+
+const files: string[] = [];
+const args = process.argv.slice(2);
+if (args.length === 0) {
+ walk(sourcesRoot, files);
+} else {
+ for (const arg of args) {
+ const full = path.isAbsolute(arg) ? arg : path.join(projectRoot, arg);
+ if (!fs.existsSync(full)) continue;
+ const stat = fs.statSync(full);
+ if (stat.isDirectory()) {
+ walk(full, files);
+ } else if (stat.isFile() && /\.(ts|tsx|js|jsx)$/.test(full)) {
+ files.push(full);
+ }
+ }
+}
+
+const all: Finding[] = [];
+for (const filePath of files) {
+ all.push(...scanFile(filePath));
+}
+
+all.sort((a, b) => {
+ if (a.file !== b.file) return a.file.localeCompare(b.file);
+ if (a.line !== b.line) return a.line - b.line;
+ return a.col - b.col;
+});
+
+const grouped = new Map();
+for (const f of all) {
+ const key = `${f.kind}:${f.text}`;
+ const list = grouped.get(key) ?? [];
+ list.push(f);
+ grouped.set(key, list);
+}
+
+console.log(`# Potential Untranslated UI Literals (${all.length} findings)\n`);
+console.log(`Scanned: ${files.length} source files under ${path.relative(projectRoot, sourcesRoot)}\n`);
+
+for (const [key, list] of grouped.entries()) {
+ const [kind, text] = key.split(':', 2);
+ console.log(`- ${kind}: "${text}" (${list.length} occurrence${list.length === 1 ? '' : 's'})`);
+ for (const f of list.slice(0, 10)) {
+ console.log(` - ${f.file}:${f.line}:${f.col} ${f.context}`);
+ }
+ if (list.length > 10) {
+ console.log(` - … ${list.length - 10} more`);
+ }
+}
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..7f83cd456 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,216 @@ export async function machineBash(
}
}
+export interface DetectCliEntry {
+ available: boolean;
+ resolvedPath?: string;
+ version?: string;
+ isLoggedIn?: boolean | null;
+}
+
+export interface DetectCliResponse {
+ path: string | null;
+ clis: Record<'claude' | 'codex' | 'gemini', DetectCliEntry>;
+}
+
+export type MachineDetectCliResult =
+ | { supported: true; response: DetectCliResponse }
+ | { supported: false; reason: 'not-supported' | 'error' };
+
+/**
+ * Query daemon CLI availability using a dedicated RPC (preferred).
+ *
+ * Falls back to `{ supported: false }` for older daemons that don't implement it.
+ */
+export async function machineDetectCli(machineId: string, params?: { includeLoginStatus?: boolean }): Promise {
+ try {
+ const result = await apiSocket.machineRPC(
+ machineId,
+ 'detect-cli',
+ { ...(params?.includeLoginStatus ? { includeLoginStatus: true } : {}) }
+ );
+
+ if (isPlainObject(result) && typeof result.error === 'string') {
+ // Older daemons (or errors) return an encrypted `{ error: ... }` payload.
+ if (result.error === 'Method not found') {
+ return { supported: false, reason: 'not-supported' };
+ }
+ return { supported: false, reason: 'error' };
+ }
+
+ if (!isPlainObject(result)) {
+ return { supported: false, reason: 'error' };
+ }
+
+ const clisRaw = result.clis;
+ if (!isPlainObject(clisRaw)) {
+ return { supported: false, reason: 'error' };
+ }
+
+ const getEntry = (name: 'claude' | 'codex' | 'gemini'): DetectCliEntry | null => {
+ const raw = (clisRaw as Record)[name];
+ if (!isPlainObject(raw) || typeof raw.available !== 'boolean') return null;
+ const resolvedPath = raw.resolvedPath;
+ const version = raw.version;
+ const isLoggedInRaw = (raw as any).isLoggedIn;
+ return {
+ available: raw.available,
+ ...(typeof resolvedPath === 'string' ? { resolvedPath } : {}),
+ ...(typeof version === 'string' ? { version } : {}),
+ ...((typeof isLoggedInRaw === 'boolean' || isLoggedInRaw === null) ? { isLoggedIn: isLoggedInRaw } : {}),
+ };
+ };
+
+ const claude = getEntry('claude');
+ const codex = getEntry('codex');
+ const gemini = getEntry('gemini');
+ if (!claude || !codex || !gemini) {
+ return { supported: false, reason: 'error' };
+ }
+
+ const pathValue = result.path;
+ const response: DetectCliResponse = {
+ path: typeof pathValue === 'string' ? pathValue : null,
+ clis: { claude, codex, gemini },
+ };
+
+ return { supported: true, response };
+ } catch {
+ return { supported: false, reason: 'error' };
+ }
+}
+
+export type EnvPreviewSecretsPolicy = 'none' | 'redacted' | 'full';
+
+export type PreviewEnvSensitivitySource = 'forced' | 'hinted' | 'none';
+
+export interface PreviewEnvValue {
+ value: string | null;
+ isSet: boolean;
+ isSensitive: boolean;
+ isForcedSensitive: boolean;
+ sensitivitySource: PreviewEnvSensitivitySource;
+ display: 'full' | 'redacted' | 'hidden' | 'unset';
+}
+
+export interface PreviewEnvResponse {
+ policy: EnvPreviewSecretsPolicy;
+ values: Record;
+}
+
+interface PreviewEnvRequest {
+ keys: string[];
+ extraEnv?: Record;
+ sensitiveKeys?: string[];
+}
+
+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: Object.fromEntries(
+ Object.entries(result.values as Record).map(([k, v]) => {
+ if (!isPlainObject(v)) {
+ const fallback: PreviewEnvValue = {
+ value: null,
+ isSet: false,
+ isSensitive: false,
+ isForcedSensitive: false,
+ sensitivitySource: 'none',
+ display: 'unset',
+ };
+ return [k, fallback] as const;
+ }
+
+ const display = v.display;
+ const safeDisplay =
+ display === 'full' || display === 'redacted' || display === 'hidden' || display === 'unset'
+ ? display
+ : 'unset';
+
+ const value = v.value;
+ const safeValue = typeof value === 'string' ? value : null;
+
+ const isSet = v.isSet;
+ const safeIsSet = typeof isSet === 'boolean' ? isSet : safeValue !== null;
+
+ const isSensitive = v.isSensitive;
+ const safeIsSensitive = typeof isSensitive === 'boolean' ? isSensitive : false;
+
+ // Back-compat for intermediate daemons: default to “not forced” if missing.
+ const isForcedSensitive = v.isForcedSensitive;
+ const safeIsForcedSensitive = typeof isForcedSensitive === 'boolean' ? isForcedSensitive : false;
+
+ const sensitivitySource = v.sensitivitySource;
+ const safeSensitivitySource: PreviewEnvSensitivitySource =
+ sensitivitySource === 'forced' || sensitivitySource === 'hinted' || sensitivitySource === 'none'
+ ? sensitivitySource
+ : (safeIsSensitive ? 'hinted' : 'none');
+
+ const entry: PreviewEnvValue = {
+ value: safeValue,
+ isSet: safeIsSet,
+ isSensitive: safeIsSensitive,
+ isForcedSensitive: safeIsForcedSensitive,
+ sensitivitySource: safeSensitivitySource,
+ display: safeDisplay,
+ };
+
+ return [k, entry] as const;
+ }),
+ ) as Record,
+ };
+ return { supported: true, response };
+ } catch {
+ return { supported: false };
+ }
+}
+
/**
* Update machine metadata with optimistic concurrency control and automatic retry
*/
@@ -532,4 +745,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..aa15da4cd 100644
--- a/sources/sync/persistence.ts
+++ b/sources/sync/persistence.ts
@@ -3,20 +3,43 @@ 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;
+ selectedApiKeyId: string | null;
agentType: NewSessionAgentType;
permissionMode: PermissionMode;
+ modelMode: ModelMode;
sessionType: NewSessionSessionType;
updatedAt: number;
}
@@ -26,7 +49,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 +163,16 @@ export function loadNewSessionDraft(): NewSessionDraft | null {
const input = typeof parsed.input === 'string' ? parsed.input : '';
const selectedMachineId = typeof parsed.selectedMachineId === 'string' ? parsed.selectedMachineId : null;
const selectedPath = typeof parsed.selectedPath === 'string' ? parsed.selectedPath : null;
+ const selectedProfileId = typeof parsed.selectedProfileId === 'string' ? parsed.selectedProfileId : null;
+ const selectedApiKeyId = typeof parsed.selectedApiKeyId === 'string' ? parsed.selectedApiKeyId : 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 +181,11 @@ export function loadNewSessionDraft(): NewSessionDraft | null {
input,
selectedMachineId,
selectedPath,
+ selectedProfileId,
+ selectedApiKeyId,
agentType,
permissionMode,
+ modelMode,
sessionType,
updatedAt,
};
@@ -188,6 +220,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 +285,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/profileSecrets.ts b/sources/sync/profileSecrets.ts
new file mode 100644
index 000000000..7c1b5a161
--- /dev/null
+++ b/sources/sync/profileSecrets.ts
@@ -0,0 +1,12 @@
+import type { AIBackendProfile } from '@/sync/settings';
+
+export function getRequiredSecretEnvVarName(profile: AIBackendProfile | null | undefined): string | null {
+ const required = profile?.requiredEnvVars ?? [];
+ const secret = required.find((v) => (v?.kind ?? 'secret') === 'secret');
+ return typeof secret?.name === 'string' && secret.name.length > 0 ? secret.name : null;
+}
+
+export function hasRequiredSecret(profile: AIBackendProfile | null | undefined): boolean {
+ return Boolean(getRequiredSecretEnvVarName(profile));
+}
+
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..3ddbce329 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.
@@ -24,10 +66,15 @@ export const getBuiltInProfileDocumentation = (id: string): ProfileDocumentation
switch (id) {
case 'anthropic':
return {
- description: 'Official Anthropic Claude API - uses your default Anthropic credentials',
+ description: 'Official Anthropic backend (Claude Code). Requires being logged in on the selected machine.',
environmentVariables: [],
- shellConfigExample: `# No additional environment variables needed
-# Uses ANTHROPIC_AUTH_TOKEN from your login session`,
+ shellConfigExample: `# No additional environment variables needed.
+# Make sure you are logged in to Claude Code on the target machine:
+# 1) Run: claude
+# 2) Then run: /login
+#
+# If you want to use an API key instead of CLI login, set:
+# export ANTHROPIC_AUTH_TOKEN="sk-..."`,
};
case 'deepseek':
return {
@@ -242,7 +289,8 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => {
return {
id: 'anthropic',
name: 'Anthropic (Default)',
- anthropicConfig: {},
+ authMode: 'machineLogin',
+ requiresMachineLogin: 'claude-code',
environmentVariables: [],
defaultPermissionMode: 'default',
compatibility: { claude: true, codex: false, gemini: false },
@@ -256,11 +304,12 @@ 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: {},
+ authMode: 'apiKeyEnv',
+ requiredEnvVars: [{ name: 'DEEPSEEK_AUTH_TOKEN', kind: 'secret' }],
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 +331,12 @@ 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: {},
+ authMode: 'apiKeyEnv',
+ requiredEnvVars: [{ name: 'Z_AI_AUTH_TOKEN', kind: 'secret' }],
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 +357,8 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => {
return {
id: 'openai',
name: 'OpenAI (GPT-5)',
- openaiConfig: {},
+ authMode: 'apiKeyEnv',
+ requiredEnvVars: [{ name: 'OPENAI_API_KEY', kind: 'secret' }],
environmentVariables: [
{ name: 'OPENAI_BASE_URL', value: 'https://api.openai.com/v1' },
{ name: 'OPENAI_MODEL', value: 'gpt-5-codex-high' },
@@ -326,7 +377,11 @@ export const getBuiltInProfile = (id: string): AIBackendProfile | null => {
return {
id: 'azure-openai',
name: 'Azure OpenAI',
- azureOpenAIConfig: {},
+ authMode: 'apiKeyEnv',
+ requiredEnvVars: [
+ { name: 'AZURE_OPENAI_API_KEY', kind: 'secret' },
+ { name: 'AZURE_OPENAI_ENDPOINT', kind: 'config' },
+ ],
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..936077915 100644
--- a/sources/sync/settings.spec.ts
+++ b/sources/sync/settings.spec.ts
@@ -89,6 +89,88 @@ 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();
+ });
+
+ it('should default per-experiment toggles to true when experiments is true (migration)', () => {
+ const parsed = settingsParse({
+ experiments: true,
+ // Note: per-experiment keys intentionally omitted (older clients)
+ } as any);
+
+ expect((parsed as any).expGemini).toBe(true);
+ expect((parsed as any).expUsageReporting).toBe(true);
+ expect((parsed as any).expFileViewer).toBe(true);
+ expect((parsed as any).expShowThinkingMessages).toBe(true);
+ expect((parsed as any).expSessionType).toBe(true);
+ expect((parsed as any).expZen).toBe(true);
+ expect((parsed as any).expVoiceAuthFlow).toBe(true);
+ });
+
+ it('should default per-experiment toggles to false when experiments is false (migration)', () => {
+ const parsed = settingsParse({
+ experiments: false,
+ // Note: per-experiment keys intentionally omitted (older clients)
+ } as any);
+
+ expect((parsed as any).expGemini).toBe(false);
+ expect((parsed as any).expUsageReporting).toBe(false);
+ expect((parsed as any).expFileViewer).toBe(false);
+ expect((parsed as any).expShowThinkingMessages).toBe(false);
+ expect((parsed as any).expSessionType).toBe(false);
+ expect((parsed as any).expZen).toBe(false);
+ expect((parsed as any).expVoiceAuthFlow).toBe(false);
+ });
+
+ it('should preserve explicit per-experiment toggles when present (no forced override)', () => {
+ const parsed = settingsParse({
+ experiments: true,
+ expGemini: false,
+ expUsageReporting: true,
+ expFileViewer: false,
+ expShowThinkingMessages: true,
+ expSessionType: false,
+ expZen: true,
+ expVoiceAuthFlow: false,
+ } as any);
+
+ expect((parsed as any).expGemini).toBe(false);
+ expect((parsed as any).expUsageReporting).toBe(true);
+ expect((parsed as any).expFileViewer).toBe(false);
+ expect((parsed as any).expShowThinkingMessages).toBe(true);
+ expect((parsed as any).expSessionType).toBe(false);
+ expect((parsed as any).expZen).toBe(true);
+ expect((parsed as any).expVoiceAuthFlow).toBe(false);
+ });
});
describe('applySettings', () => {
@@ -103,9 +185,22 @@ describe('settings', () => {
analyticsOptOut: false,
inferenceOpenAIKey: null,
experiments: false,
+ expGemini: false,
+ expUsageReporting: false,
+ expFileViewer: false,
+ expShowThinkingMessages: false,
+ expSessionType: false,
+ expZen: false,
+ expVoiceAuthFlow: false,
+ useProfiles: false,
useEnhancedSessionWizard: false,
+ usePickerSearch: false,
+ useMachinePickerSearch: false,
+ usePathPickerSearch: false,
alwaysShowContextSize: false,
agentInputEnterToSend: true,
+ agentInputActionBarLayout: 'auto',
+ agentInputChipDensity: 'auto',
avatarStyle: 'gradient',
showFlavorIcons: false,
compactSessionView: false,
@@ -122,6 +217,9 @@ describe('settings', () => {
lastUsedProfile: null,
favoriteDirectories: [],
favoriteMachines: [],
+ favoriteProfiles: [],
+ apiKeys: [],
+ defaultApiKeyByProfileId: {},
dismissedCLIWarnings: { perMachine: {}, global: {} },
};
const delta: Partial = {
@@ -137,9 +235,22 @@ describe('settings', () => {
analyticsOptOut: false,
inferenceOpenAIKey: null,
experiments: false,
+ expGemini: false,
+ expUsageReporting: false,
+ expFileViewer: false,
+ expShowThinkingMessages: false,
+ expSessionType: false,
+ expZen: false,
+ expVoiceAuthFlow: false,
+ useProfiles: false,
useEnhancedSessionWizard: false,
+ usePickerSearch: false,
+ useMachinePickerSearch: false,
+ usePathPickerSearch: false,
alwaysShowContextSize: false,
agentInputEnterToSend: true,
+ agentInputActionBarLayout: 'auto',
+ agentInputChipDensity: 'auto',
avatarStyle: 'gradient', // This should be preserved from currentSettings
showFlavorIcons: false,
compactSessionView: false,
@@ -156,6 +267,9 @@ describe('settings', () => {
lastUsedProfile: null,
favoriteDirectories: [],
favoriteMachines: [],
+ favoriteProfiles: [],
+ apiKeys: [],
+ defaultApiKeyByProfileId: {},
dismissedCLIWarnings: { perMachine: {}, global: {} },
});
});
@@ -171,9 +285,22 @@ describe('settings', () => {
analyticsOptOut: false,
inferenceOpenAIKey: null,
experiments: false,
+ expGemini: false,
+ expUsageReporting: false,
+ expFileViewer: false,
+ expShowThinkingMessages: false,
+ expSessionType: false,
+ expZen: false,
+ expVoiceAuthFlow: false,
+ useProfiles: false,
useEnhancedSessionWizard: false,
+ usePickerSearch: false,
+ useMachinePickerSearch: false,
+ usePathPickerSearch: false,
alwaysShowContextSize: false,
agentInputEnterToSend: true,
+ agentInputActionBarLayout: 'auto',
+ agentInputChipDensity: 'auto',
avatarStyle: 'gradient',
showFlavorIcons: false,
compactSessionView: false,
@@ -190,6 +317,9 @@ describe('settings', () => {
lastUsedProfile: null,
favoriteDirectories: [],
favoriteMachines: [],
+ favoriteProfiles: [],
+ apiKeys: [],
+ defaultApiKeyByProfileId: {},
dismissedCLIWarnings: { perMachine: {}, global: {} },
};
const delta: Partial = {};
@@ -207,9 +337,22 @@ describe('settings', () => {
analyticsOptOut: false,
inferenceOpenAIKey: null,
experiments: false,
+ expGemini: false,
+ expUsageReporting: false,
+ expFileViewer: false,
+ expShowThinkingMessages: false,
+ expSessionType: false,
+ expZen: false,
+ expVoiceAuthFlow: false,
+ useProfiles: false,
useEnhancedSessionWizard: false,
+ usePickerSearch: false,
+ useMachinePickerSearch: false,
+ usePathPickerSearch: false,
alwaysShowContextSize: false,
agentInputEnterToSend: true,
+ agentInputActionBarLayout: 'auto',
+ agentInputChipDensity: 'auto',
avatarStyle: 'gradient',
showFlavorIcons: false,
compactSessionView: false,
@@ -226,6 +369,9 @@ describe('settings', () => {
lastUsedProfile: null,
favoriteDirectories: [],
favoriteMachines: [],
+ favoriteProfiles: [],
+ apiKeys: [],
+ defaultApiKeyByProfileId: {},
dismissedCLIWarnings: { perMachine: {}, global: {} },
};
const delta: Partial = {
@@ -248,9 +394,22 @@ describe('settings', () => {
analyticsOptOut: false,
inferenceOpenAIKey: null,
experiments: false,
+ expGemini: false,
+ expUsageReporting: false,
+ expFileViewer: false,
+ expShowThinkingMessages: false,
+ expSessionType: false,
+ expZen: false,
+ expVoiceAuthFlow: false,
+ useProfiles: false,
useEnhancedSessionWizard: false,
+ usePickerSearch: false,
+ useMachinePickerSearch: false,
+ usePathPickerSearch: false,
alwaysShowContextSize: false,
agentInputEnterToSend: true,
+ agentInputActionBarLayout: 'auto',
+ agentInputChipDensity: 'auto',
avatarStyle: 'gradient',
showFlavorIcons: false,
compactSessionView: false,
@@ -267,6 +426,9 @@ describe('settings', () => {
lastUsedProfile: null,
favoriteDirectories: [],
favoriteMachines: [],
+ favoriteProfiles: [],
+ apiKeys: [],
+ defaultApiKeyByProfileId: {},
dismissedCLIWarnings: { perMachine: {}, global: {} },
};
expect(applySettings(currentSettings, {})).toEqual(currentSettings);
@@ -298,9 +460,22 @@ describe('settings', () => {
analyticsOptOut: false,
inferenceOpenAIKey: null,
experiments: false,
+ expGemini: false,
+ expUsageReporting: false,
+ expFileViewer: false,
+ expShowThinkingMessages: false,
+ expSessionType: false,
+ expZen: false,
+ expVoiceAuthFlow: false,
+ useProfiles: false,
useEnhancedSessionWizard: false,
+ usePickerSearch: false,
+ useMachinePickerSearch: false,
+ usePathPickerSearch: false,
alwaysShowContextSize: false,
agentInputEnterToSend: true,
+ agentInputActionBarLayout: 'auto',
+ agentInputChipDensity: 'auto',
avatarStyle: 'gradient',
showFlavorIcons: false,
compactSessionView: false,
@@ -317,6 +492,9 @@ describe('settings', () => {
lastUsedProfile: null,
favoriteDirectories: [],
favoriteMachines: [],
+ favoriteProfiles: [],
+ apiKeys: [],
+ defaultApiKeyByProfileId: {},
dismissedCLIWarnings: { perMachine: {}, global: {} },
};
const delta: any = {
@@ -360,11 +538,25 @@ describe('settings', () => {
analyticsOptOut: false,
inferenceOpenAIKey: null,
experiments: false,
+ expGemini: false,
+ expUsageReporting: false,
+ expFileViewer: false,
+ expShowThinkingMessages: false,
+ expSessionType: false,
+ expZen: false,
+ expVoiceAuthFlow: false,
+ useProfiles: false,
alwaysShowContextSize: false,
- avatarStyle: 'brutalist',
+ useEnhancedSessionWizard: false,
+ usePickerSearch: false,
+ useMachinePickerSearch: false,
+ usePathPickerSearch: false,
+ avatarStyle: 'brutalist',
showFlavorIcons: false,
compactSessionView: false,
agentInputEnterToSend: true,
+ agentInputActionBarLayout: 'auto',
+ agentInputChipDensity: 'auto',
hideInactiveSessions: false,
reviewPromptAnswered: false,
reviewPromptLikedApp: null,
@@ -376,10 +568,12 @@ describe('settings', () => {
lastUsedModelMode: null,
profiles: [],
lastUsedProfile: null,
- favoriteDirectories: ['~/src', '~/Desktop', '~/Documents'],
+ favoriteDirectories: [],
favoriteMachines: [],
+ favoriteProfiles: [],
+ apiKeys: [],
+ defaultApiKeyByProfileId: {},
dismissedCLIWarnings: { perMachine: {}, global: {} },
- useEnhancedSessionWizard: false,
});
});
@@ -542,6 +736,78 @@ describe('settings', () => {
};
expect(() => AIBackendProfileSchema.parse(invalidProfile)).toThrow();
});
+
+ it('rejects profiles with more than one required secret env var (V1 constraint)', () => {
+ const invalidProfile = {
+ id: crypto.randomUUID(),
+ name: 'Test Profile',
+ authMode: 'apiKeyEnv',
+ requiredEnvVars: [
+ { name: 'OPENAI_API_KEY', kind: 'secret' },
+ { name: 'ANTHROPIC_AUTH_TOKEN', kind: 'secret' },
+ ],
+ compatibility: { claude: true, codex: true, gemini: true },
+ };
+ expect(() => AIBackendProfileSchema.parse(invalidProfile)).toThrow();
+ });
+
+ it('rejects machine-login profiles that declare required secret env vars', () => {
+ const invalidProfile = {
+ id: crypto.randomUUID(),
+ name: 'Test Profile',
+ authMode: 'machineLogin',
+ requiredEnvVars: [
+ { name: 'OPENAI_API_KEY', kind: 'secret' },
+ ],
+ compatibility: { claude: true, codex: true, gemini: true },
+ };
+ expect(() => AIBackendProfileSchema.parse(invalidProfile)).toThrow();
+ });
+
+ it('rejects requiresMachineLogin when authMode is not machineLogin', () => {
+ const invalidProfile = {
+ id: crypto.randomUUID(),
+ name: 'Test Profile',
+ authMode: 'apiKeyEnv',
+ requiresMachineLogin: 'claude-code',
+ requiredEnvVars: [
+ { name: 'OPENAI_API_KEY', kind: 'secret' },
+ ],
+ compatibility: { claude: true, codex: true, gemini: true },
+ };
+ expect(() => AIBackendProfileSchema.parse(invalidProfile)).toThrow();
+ });
+ });
+
+ describe('SavedApiKey validation', () => {
+ it('accepts valid apiKeys entries in settingsParse', () => {
+ const now = Date.now();
+ const parsed = settingsParse({
+ apiKeys: [
+ { id: 'k1', name: 'My Key', value: 'sk-test', createdAt: now, updatedAt: now },
+ ],
+ });
+ expect(parsed.apiKeys.length).toBe(1);
+ expect(parsed.apiKeys[0]?.name).toBe('My Key');
+ expect(parsed.apiKeys[0]?.value).toBe('sk-test');
+ });
+
+ it('drops invalid apiKeys entries (missing value)', () => {
+ const parsed = settingsParse({
+ apiKeys: [
+ { id: 'k1', name: 'Missing value' },
+ ],
+ } as any);
+ // settingsParse validates per-field, so invalid field should fall back to default.
+ expect(parsed.apiKeys).toEqual([]);
+ });
+ });
+
+ describe('defaultApiKeyByProfileId', () => {
+ it('defaults to an empty object', () => {
+ const parsed = settingsParse({});
+ expect(parsed.defaultApiKeyByProfileId).toEqual({});
+ });
});
describe('version-mismatch scenario (bug fix)', () => {
@@ -560,7 +826,6 @@ describe('settings', () => {
{
id: 'server-profile',
name: 'Server Profile',
- anthropicConfig: {},
environmentVariables: [],
compatibility: { claude: true, codex: true, gemini: true },
isBuiltIn: false,
@@ -578,7 +843,6 @@ describe('settings', () => {
{
id: 'local-profile',
name: 'Local Profile',
- anthropicConfig: {},
environmentVariables: [],
compatibility: { claude: true, codex: true, gemini: true },
isBuiltIn: false,
@@ -680,7 +944,6 @@ describe('settings', () => {
profiles: [{
id: 'test-profile',
name: 'Test',
- anthropicConfig: {},
environmentVariables: [],
compatibility: { claude: true, codex: true, gemini: true },
isBuiltIn: false,
@@ -713,7 +976,6 @@ describe('settings', () => {
profiles: [{
id: 'device-b-profile',
name: 'Device B Profile',
- anthropicConfig: {},
environmentVariables: [],
compatibility: { claude: true, codex: true },
isBuiltIn: false,
@@ -825,7 +1087,6 @@ describe('settings', () => {
profiles: [{
id: 'server-profile-1',
name: 'Server Profile',
- anthropicConfig: {},
environmentVariables: [],
compatibility: { claude: true, codex: true },
isBuiltIn: false,
@@ -844,7 +1105,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..1b55d4f12 100644
--- a/sources/sync/settings.ts
+++ b/sources/sync/settings.ts
@@ -4,85 +4,33 @@ 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
const EnvironmentVariableSchema = z.object({
name: z.string().regex(/^[A-Z_][A-Z0-9_]*$/, 'Invalid environment variable name'),
value: z.string(),
+ // User override:
+ // - true: force secret handling in UI (and hint daemon)
+ // - false: force non-secret handling in UI (unless daemon enforces)
+ // - undefined: auto classification
+ isSecret: z.boolean().optional(),
+});
+
+const RequiredEnvVarKindSchema = z.enum(['secret', 'config']);
+
+const RequiredEnvVarSchema = z.object({
+ name: z.string().regex(/^[A-Z_][A-Z0-9_]*$/, 'Invalid environment variable name'),
+ // Defaults to secret so older serialized forms (missing kind) remain safe/strict.
+ kind: RequiredEnvVarKindSchema.default('secret'),
});
+const RequiresMachineLoginSchema = z.enum(['codex', 'claude-code', 'gemini-cli']);
+
// Profile compatibility schema
const ProfileCompatibilitySchema = z.object({
claude: z.boolean().default(true),
@@ -97,18 +45,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([]),
@@ -124,6 +63,19 @@ export const AIBackendProfileSchema = z.object({
// Compatibility metadata
compatibility: ProfileCompatibilitySchema.default({ claude: true, codex: true, gemini: true }),
+ // Authentication / requirements metadata (used by UI gating)
+ // - apiKeyEnv: profile expects required env vars to be present (optionally injected at spawn)
+ // - machineLogin: profile relies on a machine-local CLI login cache (no API key injection)
+ authMode: z.enum(['apiKeyEnv', 'machineLogin']).optional(),
+
+ // For machine-login profiles, specify which CLI must be logged in on the target machine.
+ // This is used for UX copy and for optional login-status detection.
+ requiresMachineLogin: RequiresMachineLoginSchema.optional(),
+
+ // Explicit environment variable requirements for this profile at runtime.
+ // V1 constraint: at most one required secret per profile (avoids ambiguous precedence/billing behavior).
+ requiredEnvVars: z.array(RequiredEnvVarSchema).optional(),
+
// Built-in profile indicator
isBuiltIn: z.boolean().default(false),
@@ -131,15 +83,108 @@ export const AIBackendProfileSchema = z.object({
createdAt: z.number().default(() => Date.now()),
updatedAt: z.number().default(() => Date.now()),
version: z.string().default('1.0.0'),
+}).superRefine((profile, ctx) => {
+ const requiredEnvVars = profile.requiredEnvVars ?? [];
+ const secretCount = requiredEnvVars.filter(v => (v?.kind ?? 'secret') === 'secret').length;
+
+ if (secretCount > 1) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['requiredEnvVars'],
+ message: 'V1 constraint: profiles may declare at most one required secret environment variable',
+ });
+ }
+
+ if (profile.authMode === 'machineLogin' && secretCount > 0) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['requiredEnvVars'],
+ message: 'Profiles with authMode=machineLogin must not declare required secret environment variables',
+ });
+ }
+
+ if (profile.requiresMachineLogin && profile.authMode !== 'machineLogin') {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['requiresMachineLogin'],
+ message: 'requiresMachineLogin may only be set when authMode=machineLogin',
+ });
+ }
});
export type AIBackendProfile = z.infer;
+export const SavedApiKeySchema = z.object({
+ id: z.string().min(1),
+ name: z.string().min(1).max(100),
+ // Secret. The UI must never re-display this after entry.
+ value: z.string().min(1),
+ createdAt: z.number().default(() => Date.now()),
+ updatedAt: z.number().default(() => Date.now()),
+});
+
+export type SavedApiKey = z.infer;
+
// Helper functions for profile validation and compatibility
export function validateProfileForAgent(profile: AIBackendProfile, agent: 'claude' | 'codex' | 'gemini'): boolean {
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 +202,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 +217,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 +229,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 +263,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,9 +279,24 @@ 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'),
+ // Per-experiment toggles (gated by `experiments` master switch in UI/usage)
+ expGemini: z.boolean().describe('Experimental: enable Gemini backend + Gemini-related UX'),
+ expUsageReporting: z.boolean().describe('Experimental: enable usage reporting UI'),
+ expFileViewer: z.boolean().describe('Experimental: enable session file viewer'),
+ expShowThinkingMessages: z.boolean().describe('Experimental: show assistant thinking messages'),
+ expSessionType: z.boolean().describe('Experimental: show session type selector (simple vs worktree)'),
+ expZen: z.boolean().describe('Experimental: enable Zen navigation/experience'),
+ expVoiceAuthFlow: z.boolean().describe('Experimental: enable authenticated voice token flow'),
+ 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)'),
+ agentInputActionBarLayout: z.enum(['auto', 'wrap', 'scroll', 'collapsed']).describe('Agent input action bar layout'),
+ agentInputChipDensity: z.enum(['auto', 'labels', 'icons']).describe('Agent input action chip density'),
avatarStyle: z.string().describe('Avatar display style'),
showFlavorIcons: z.boolean().describe('Whether to show AI provider icons in avatars'),
compactSessionView: z.boolean().describe('Whether to use compact view for active sessions'),
@@ -284,10 +315,14 @@ export const SettingsSchema = z.object({
// Profile management settings
profiles: z.array(AIBackendProfileSchema).describe('User-defined profiles for AI backend and environment variables'),
lastUsedProfile: z.string().nullable().describe('Last selected profile for new sessions'),
+ apiKeys: z.array(SavedApiKeySchema).default([]).describe('Saved API keys (encrypted settings). Value is never re-displayed in UI.'),
+ defaultApiKeyByProfileId: z.record(z.string(), z.string()).default({}).describe('Default saved API key ID to use per profile'),
// Favorite directories for quick path selection
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,9 +367,22 @@ export const settingsDefaults: Settings = {
wrapLinesInDiffs: false,
analyticsOptOut: false,
experiments: false,
+ expGemini: false,
+ expUsageReporting: false,
+ expFileViewer: false,
+ expShowThinkingMessages: false,
+ expSessionType: false,
+ expZen: false,
+ expVoiceAuthFlow: false,
+ useProfiles: false,
useEnhancedSessionWizard: false,
+ usePickerSearch: false,
+ useMachinePickerSearch: false,
+ usePathPickerSearch: false,
alwaysShowContextSize: false,
agentInputEnterToSend: true,
+ agentInputActionBarLayout: 'auto',
+ agentInputChipDensity: 'auto',
avatarStyle: 'brutalist',
showFlavorIcons: false,
compactSessionView: false,
@@ -350,10 +398,14 @@ export const settingsDefaults: Settings = {
// Profile management defaults
profiles: [],
lastUsedProfile: null,
- // Default favorite directories (real common directories on Unix-like systems)
- favoriteDirectories: ['~/src', '~/Desktop', '~/Documents'],
+ apiKeys: [],
+ defaultApiKeyByProfileId: {},
+ // 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 +421,95 @@ 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;
+ }
+ }
+
+ // Migration: Introduce per-experiment toggles.
+ // If persisted settings only had `experiments` (older clients), default ALL experiment toggles
+ // to match the master switch so existing users keep the same behavior.
+ const experimentKeys = [
+ 'expGemini',
+ 'expUsageReporting',
+ 'expFileViewer',
+ 'expShowThinkingMessages',
+ 'expSessionType',
+ 'expZen',
+ 'expVoiceAuthFlow',
+ ] as const;
+ const hasAnyExperimentKey = experimentKeys.some((k) => k in input);
+ if (!hasAnyExperimentKey) {
+ const enableAll = result.experiments === true;
+ for (const key of experimentKeys) {
+ result[key] = enableAll;
+ }
+ }
+
+ // 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..ff3098fd5 100644
--- a/sources/sync/sync.ts
+++ b/sources/sync/sync.ts
@@ -12,7 +12,7 @@ import { ActivityUpdateAccumulator } from './reducer/activityUpdateAccumulator';
import { randomUUID } from 'expo-crypto';
import * as Notifications from 'expo-notifications';
import { registerPushToken } from './apiPush';
-import { Platform, AppState } from 'react-native';
+import { Platform, AppState, InteractionManager } from 'react-native';
import { isRunningOnMac } from '@/utils/platform';
import { NormalizedMessage, normalizeRawMessage, RawRecord } from './typesRaw';
import { applySettings, Settings, settingsDefaults, settingsParse, SUPPORTED_SCHEMA_VERSION } from './settings';
@@ -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.
@@ -68,11 +69,15 @@ class Sync {
private todosSync: InvalidateSync;
private activityAccumulator: ActivityUpdateAccumulator;
private pendingSettings: Partial = loadPendingSettings();
+ private pendingSettingsFlushTimer: ReturnType | null = null;
+ private pendingSettingsDirty = false;
revenueCatInitialized = false;
// Generic locking mechanism
private recalculationLockCount = 0;
private lastRecalculationTime = 0;
+ private machinesRefreshInFlight: Promise | null = null;
+ private lastMachinesRefreshAt = 0;
constructor() {
this.sessionsSync = new InvalidateSync(this.fetchSessions);
@@ -114,10 +119,48 @@ class Sync {
this.todosSync.invalidate();
} else {
log.log(`📱 App state changed to: ${nextAppState}`);
+ // Reliability: ensure we persist any pending settings immediately when backgrounding.
+ // This avoids losing last-second settings changes if the OS suspends the app.
+ try {
+ if (this.pendingSettingsFlushTimer) {
+ clearTimeout(this.pendingSettingsFlushTimer);
+ this.pendingSettingsFlushTimer = null;
+ }
+ savePendingSettings(this.pendingSettings);
+ } catch {
+ // ignore
+ }
}
});
}
+ private schedulePendingSettingsFlush = () => {
+ if (this.pendingSettingsFlushTimer) {
+ clearTimeout(this.pendingSettingsFlushTimer);
+ }
+ this.pendingSettingsDirty = true;
+ // Debounce disk write + network sync to keep UI interactions snappy.
+ // IMPORTANT: JSON.stringify + MMKV.set are synchronous and can stall taps on iOS if run too often.
+ this.pendingSettingsFlushTimer = setTimeout(() => {
+ if (!this.pendingSettingsDirty) {
+ return;
+ }
+ this.pendingSettingsDirty = false;
+
+ const flush = () => {
+ // Persist pending settings for crash/restart safety.
+ savePendingSettings(this.pendingSettings);
+ // Trigger server sync (can be retried later).
+ this.settingsSync.invalidate();
+ };
+ if (Platform.OS === 'web') {
+ flush();
+ } else {
+ InteractionManager.runAfterInteractions(flush);
+ }
+ }, 900);
+ };
+
async create(credentials: AuthCredentials, encryption: Encryption) {
this.credentials = credentials;
this.encryption = encryption;
@@ -251,14 +294,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 +302,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);
@@ -304,7 +339,6 @@ class Sync {
// Save pending settings
this.pendingSettings = { ...this.pendingSettings, ...delta };
- savePendingSettings(this.pendingSettings);
// Sync PostHog opt-out state if it was changed
if (tracking && 'analyticsOptOut' in delta) {
@@ -316,8 +350,7 @@ class Sync {
}
}
- // Invalidate settings sync
- this.settingsSync.invalidate();
+ this.schedulePendingSettingsFlush();
}
refreshPurchases = () => {
@@ -552,6 +585,31 @@ class Sync {
return this.fetchMachines();
}
+ public refreshMachinesThrottled = async (params?: { staleMs?: number; force?: boolean }) => {
+ if (!this.credentials) return;
+ const staleMs = params?.staleMs ?? 30_000;
+ const force = params?.force ?? false;
+ const now = Date.now();
+
+ if (!force && (now - this.lastMachinesRefreshAt) < staleMs) {
+ return;
+ }
+
+ if (this.machinesRefreshInFlight) {
+ return this.machinesRefreshInFlight;
+ }
+
+ this.machinesRefreshInFlight = this.fetchMachines()
+ .then(() => {
+ this.lastMachinesRefreshAt = Date.now();
+ })
+ .finally(() => {
+ this.machinesRefreshInFlight = null;
+ });
+
+ return this.machinesRefreshInFlight;
+ }
+
public refreshSessions = async () => {
return this.sessionsSync.invalidateAndAwait();
}
@@ -843,7 +901,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 +915,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;
@@ -1140,6 +1196,7 @@ class Sync {
const API_ENDPOINT = getServerUrl();
const maxRetries = 3;
let retryCount = 0;
+ let lastVersionMismatch: { expectedVersion: number; currentVersion: number; pendingKeys: string[] } | null = null;
// Apply pending settings
if (Object.keys(this.pendingSettings).length > 0) {
@@ -1172,6 +1229,11 @@ class Sync {
break;
}
if (data.error === 'version-mismatch') {
+ lastVersionMismatch = {
+ expectedVersion: version ?? 0,
+ currentVersion: data.currentVersion,
+ pendingKeys: Object.keys(this.pendingSettings).sort(),
+ };
// Parse server settings
const serverSettings = data.currentSettings
? settingsParse(await this.encryption.decryptRaw(data.currentSettings))
@@ -1180,8 +1242,12 @@ class Sync {
// Merge: server base + our pending changes (our changes win)
const mergedSettings = applySettings(serverSettings, this.pendingSettings);
- // Update local storage with merged result at server's version
- storage.getState().applySettings(mergedSettings, data.currentVersion);
+ // Update local storage with merged result at server's version.
+ //
+ // Important: `data.currentVersion` can be LOWER than our local `settingsVersion`
+ // (e.g. when switching accounts/servers, or after server-side reset). If we only
+ // "apply when newer", we'd never converge and would retry forever.
+ storage.getState().replaceSettings(mergedSettings, data.currentVersion);
// Sync tracking state with merged settings
if (tracking) {
@@ -1189,11 +1255,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 {
@@ -1204,7 +1265,10 @@ class Sync {
// If exhausted retries, throw to trigger outer backoff delay
if (retryCount >= maxRetries) {
- throw new Error(`Settings sync failed after ${maxRetries} retries due to version conflicts`);
+ const mismatchHint = lastVersionMismatch
+ ? ` (expected=${lastVersionMismatch.expectedVersion}, current=${lastVersionMismatch.currentVersion}, pendingKeys=${lastVersionMismatch.pendingKeys.join(',')})`
+ : '';
+ throw new Error(`Settings sync failed after ${maxRetries} retries due to version conflicts${mismatchHint}`);
}
// Run request
@@ -1230,12 +1294,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 +1325,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 +1362,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 +1380,7 @@ class Sync {
});
}
} catch (error) {
- console.log('[fetchNativeUpdate] Error:', error);
+ console.error('[fetchNativeUpdate] Error:', error);
storage.getState().applyNativeUpdateStatus(null);
}
}
@@ -1354,7 +1401,6 @@ class Sync {
}
if (!apiKey) {
- console.log(`RevenueCat: No API key found for platform ${Platform.OS}`);
return;
}
@@ -1371,7 +1417,6 @@ class Sync {
});
this.revenueCatInitialized = true;
- console.log('RevenueCat initialized successfully');
}
// Sync purchases
@@ -1438,9 +1483,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 +1509,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 +1557,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 +1588,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 +1600,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 +1622,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 +2007,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 +2015,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..583e4132f 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,11 @@ export const ca: TranslationStructure = {
retry: 'Torna-ho a provar',
delete: 'Elimina',
optional: 'Opcional',
+ noMatches: 'Sense coincidències',
+ all: 'Tots',
+ machine: 'màquina',
+ clearSearch: 'Neteja la cerca',
+ refresh: 'Actualitza',
},
profile: {
@@ -96,6 +106,15 @@ export const ca: TranslationStructure = {
enterSecretKey: 'Introdueix la teva clau secreta',
invalidSecretKey: 'Clau secreta no vàlida. Comprova-ho i torna-ho a provar.',
enterUrlManually: 'Introdueix l\'URL manualment',
+ terminalUrlPlaceholder: 'happy://terminal?...',
+ restoreQrInstructions: '1. Obre Happy al teu dispositiu mòbil\n2. Ves a Configuració → Compte\n3. Toca "Vincular nou dispositiu"\n4. Escaneja aquest codi QR',
+ restoreWithSecretKeyInstead: 'Restaura amb clau secreta',
+ restoreWithSecretKeyDescription: 'Introdueix la teva clau secreta per recuperar l’accés al teu compte.',
+ secretKeyPlaceholder: 'XXXXX-XXXXX-XXXXX...',
+ unsupported: {
+ connectTitle: ({ name }: { name: string }) => `Connecta ${name}`,
+ runCommandInTerminal: 'Executa l\'ordre següent al terminal:',
+ },
},
settings: {
@@ -136,6 +155,8 @@ export const ca: TranslationStructure = {
usageSubtitle: "Veure l'ús de l'API i costos",
profiles: 'Perfils',
profilesSubtitle: 'Gestiona els perfils d\'entorn i variables',
+ apiKeys: 'Claus d’API',
+ apiKeysSubtitle: 'Gestiona les claus d’API desades (no es tornaran a mostrar després d’introduir-les)',
// Dynamic settings messages
accountConnected: ({ service }: { service: string }) => `Compte de ${service} connectat`,
@@ -173,11 +194,26 @@ export const ca: TranslationStructure = {
wrapLinesInDiffsDescription: 'Ajusta les línies llargues en lloc de desplaçament horitzontal a les vistes de diferències',
alwaysShowContextSize: 'Mostra sempre la mida del context',
alwaysShowContextSizeDescription: 'Mostra l\'ús del context fins i tot quan no estigui prop del límit',
+ agentInputActionBarLayout: 'Barra d’accions d’entrada',
+ agentInputActionBarLayoutDescription: 'Tria com es mostren els xips d’acció sobre el camp d’entrada',
+ agentInputActionBarLayoutOptions: {
+ auto: 'Auto',
+ wrap: 'Ajusta',
+ scroll: 'Desplaçable',
+ collapsed: 'Plegat',
+ },
+ agentInputChipDensity: 'Densitat dels xips d’acció',
+ agentInputChipDensityDescription: 'Tria si els xips d’acció mostren etiquetes o icones',
+ agentInputChipDensityOptions: {
+ auto: 'Auto',
+ labels: 'Etiquetes',
+ icons: 'Només icones',
+ },
avatarStyle: 'Estil d\'avatar',
avatarStyleDescription: 'Tria l\'aparença de l\'avatar de la sessió',
avatarOptions: {
pixelated: 'Pixelat',
- gradient: 'Gradient',
+ gradient: 'Degradat',
brutalist: 'Brutalista',
},
showFlavorIcons: "Mostrar icones de proveïdors d'IA",
@@ -193,6 +229,22 @@ export const ca: TranslationStructure = {
experimentalFeatures: 'Funcions experimentals',
experimentalFeaturesEnabled: 'Funcions experimentals activades',
experimentalFeaturesDisabled: 'Utilitzant només funcions estables',
+ experimentalOptions: 'Opcions experimentals',
+ experimentalOptionsDescription: 'Tria quines funcions experimentals estan activades.',
+ expGemini: 'Gemini',
+ expGeminiSubtitle: 'Activa sessions de Gemini CLI i la UI relacionada',
+ expUsageReporting: 'Informe d’ús',
+ expUsageReportingSubtitle: 'Activa pantalles d’ús i tokens',
+ expFileViewer: 'Visor de fitxers',
+ expFileViewerSubtitle: 'Activa l’entrada al visor de fitxers de la sessió',
+ expShowThinkingMessages: 'Mostra missatges de pensament',
+ expShowThinkingMessagesSubtitle: 'Mostra missatges d’estat/pensament de l’assistent al xat',
+ expSessionType: 'Selector de tipus de sessió',
+ expSessionTypeSubtitle: 'Mostra el selector de tipus de sessió (simple vs worktree)',
+ expZen: 'Zen',
+ expZenSubtitle: 'Activa l’entrada de navegació Zen',
+ expVoiceAuthFlow: 'Flux d’autenticació de veu',
+ expVoiceAuthFlowSubtitle: 'Utilitza el flux autenticat de tokens de veu (amb paywall)',
webFeatures: 'Funcions web',
webFeaturesDescription: 'Funcions disponibles només a la versió web de l\'app.',
enterToSend: 'Enter per enviar',
@@ -201,13 +253,22 @@ export const ca: TranslationStructure = {
commandPalette: 'Paleta de comandes',
commandPaletteEnabled: 'Prem ⌘K per obrir',
commandPaletteDisabled: 'Accés ràpid a comandes desactivat',
- markdownCopyV2: 'Markdown Copy v2',
+ markdownCopyV2: 'Còpia de Markdown v2',
markdownCopyV2Subtitle: 'Pulsació llarga obre modal de còpia',
hideInactiveSessions: 'Amaga les sessions inactives',
hideInactiveSessionsSubtitle: 'Mostra només els xats actius a la llista',
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 +321,27 @@ export const ca: TranslationStructure = {
newSession: {
// Used by new-session screen and launch flows
title: 'Inicia una nova sessió',
+ selectAiProfileTitle: 'Selecciona el perfil d’IA',
+ selectAiProfileDescription: 'Selecciona un perfil d’IA per aplicar variables d’entorn i valors per defecte a la sessió.',
+ changeProfile: 'Canvia el perfil',
+ aiBackendSelectedByProfile: 'El backend d’IA el selecciona el teu perfil. Per canviar-lo, selecciona un perfil diferent.',
+ selectAiBackendTitle: 'Selecciona el backend d’IA',
+ aiBackendLimitedByProfileAndMachineClis: 'Limitat pel perfil seleccionat i els CLI disponibles en aquesta màquina.',
+ aiBackendSelectWhichAiRuns: 'Selecciona quina IA executa la sessió.',
+ aiBackendNotCompatibleWithSelectedProfile: 'No és compatible amb el perfil seleccionat.',
+ aiBackendCliNotDetectedOnMachine: ({ cli }: { cli: string }) => `No s’ha detectat el CLI de ${cli} en aquesta màquina.`,
+ selectMachineTitle: 'Selecciona màquina',
+ selectMachineDescription: 'Tria on s’executa aquesta sessió.',
+ selectPathTitle: 'Selecciona camí',
+ selectWorkingDirectoryTitle: 'Selecciona el directori de treball',
+ selectWorkingDirectoryDescription: 'Tria la carpeta usada per a ordres i context.',
+ selectPermissionModeTitle: 'Selecciona el mode de permisos',
+ selectPermissionModeDescription: 'Controla com d’estrictes són les aprovacions.',
+ selectModelTitle: 'Selecciona el model d’IA',
+ selectModelDescription: 'Tria el model usat per aquesta sessió.',
+ selectSessionTypeTitle: 'Selecciona el tipus de sessió',
+ selectSessionTypeDescription: 'Tria una sessió simple o una lligada a un worktree de Git.',
+ 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,12 +357,46 @@ 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',
- worktree: 'Worktree',
+ worktree: 'Worktree (Git)',
comingSoon: 'Properament',
},
+ profileAvailability: {
+ requiresAgent: ({ agent }: { agent: string }) => `Requereix ${agent}`,
+ cliNotDetected: ({ cli }: { cli: string }) => `${cli} CLI no detectat`,
+ },
+ cliBanners: {
+ cliNotDetectedTitle: ({ cli }: { cli: string }) => `${cli} CLI no detectat`,
+ dontShowFor: 'No mostris aquest avís per a',
+ thisMachine: 'aquesta màquina',
+ anyMachine: 'qualsevol màquina',
+ installCommand: ({ command }: { command: string }) => `Instal·la: ${command} •`,
+ installCliIfAvailable: ({ cli }: { cli: string }) => `Instal·la el CLI de ${cli} si està disponible •`,
+ viewInstallationGuide: 'Veure la guia d’instal·lació →',
+ viewGeminiDocs: 'Veure la documentació de Gemini →',
+ },
worktree: {
creating: ({ name }: { name: string }) => `Creant worktree '${name}'...`,
notGitRepo: 'Els worktrees requereixen un repositori git',
@@ -305,6 +421,19 @@ export const ca: TranslationStructure = {
commandPalette: {
placeholder: 'Escriu una comanda o cerca...',
+ noCommandsFound: 'No s\'han trobat comandes',
+ },
+
+ commandView: {
+ completedWithNoOutput: '[Ordre completada sense sortida]',
+ },
+
+ voiceAssistant: {
+ connecting: 'Connectant...',
+ active: 'Assistent de veu actiu',
+ connectionError: 'Error de connexió',
+ label: 'Assistent de veu',
+ tapToEnd: 'Toca per acabar',
},
server: {
@@ -336,6 +465,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',
@@ -359,6 +489,9 @@ export const ca: TranslationStructure = {
happyHome: 'Directori de Happy',
copyMetadata: 'Copia les metadades',
agentState: 'Estat de l\'agent',
+ rawJsonDevMode: 'JSON en brut (mode desenvolupador)',
+ sessionStatus: 'Estat de la sessió',
+ fullSessionObject: 'Objecte complet de la sessió',
controlledByUser: 'Controlat per l\'usuari',
pendingRequests: 'Sol·licituds pendents',
activity: 'Activitat',
@@ -386,16 +519,52 @@ export const ca: TranslationStructure = {
runIt: 'Executa\'l',
scanQrCode: 'Escaneja el codi QR',
openCamera: 'Obre la càmera',
+ installCommand: '$ npm i -g happy-coder',
+ runCommand: '$ happy',
+ },
+ emptyMessages: {
+ noMessagesYet: 'Encara no hi ha missatges',
+ created: ({ time }: { time: string }) => `Creat ${time}`,
+ },
+ emptySessionsTablet: {
+ noActiveSessions: 'No hi ha sessions actives',
+ startNewSessionDescription: 'Inicia una sessió nova a qualsevol de les teves màquines connectades.',
+ startNewSessionButton: 'Inicia una sessió nova',
+ openTerminalToStart: 'Obre un nou terminal a l\'ordinador per iniciar una sessió.',
+ },
+ },
+
+ zen: {
+ title: 'Zen',
+ add: {
+ placeholder: 'Què s’ha de fer?',
+ },
+ home: {
+ noTasksYet: 'Encara no hi ha tasques. Toca + per afegir-ne una.',
+ },
+ view: {
+ workOnTask: 'Treballar en la tasca',
+ clarify: 'Aclarir',
+ delete: 'Suprimeix',
+ linkedSessions: 'Sessions enllaçades',
+ tapTaskTextToEdit: 'Toca el text de la tasca per editar',
},
},
agentInput: {
+ envVars: {
+ title: 'Variables d\'entorn',
+ titleWithCount: ({ count }: { count: number }) => `Variables d'entorn (${count})`,
+ },
permissionMode: {
title: 'MODE DE PERMISOS',
default: 'Per defecte',
acceptEdits: 'Accepta edicions',
plan: 'Mode de planificació',
bypassPermissions: 'Mode Yolo',
+ badgeAccept: 'Accepta',
+ badgePlan: 'Pla',
+ badgeYolo: 'YOLO',
badgeAcceptAllEdits: 'Accepta totes les edicions',
badgeBypassAllPermissions: 'Omet tots els permisos',
badgePlanMode: 'Mode de planificació',
@@ -412,32 +581,47 @@ export const ca: TranslationStructure = {
codexPermissionMode: {
title: 'MODE DE PERMISOS CODEX',
default: 'Configuració del CLI',
- readOnly: 'Read Only Mode',
- safeYolo: 'Safe YOLO',
+ readOnly: 'Mode només lectura',
+ safeYolo: 'YOLO segur',
yolo: 'YOLO',
- badgeReadOnly: 'Read Only Mode',
- badgeSafeYolo: 'Safe YOLO',
+ badgeReadOnly: 'Només lectura',
+ badgeSafeYolo: 'YOLO segur',
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',
+ title: 'MODEL CODEX',
+ gpt5CodexLow: 'gpt-5-codex baix',
+ gpt5CodexMedium: 'gpt-5-codex mitjà',
+ gpt5CodexHigh: 'gpt-5-codex alt',
+ gpt5Minimal: 'GPT-5 Mínim',
+ gpt5Low: 'GPT-5 Baix',
+ gpt5Medium: 'GPT-5 Mitjà',
+ gpt5High: 'GPT-5 Alt',
},
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`,
@@ -446,6 +630,11 @@ export const ca: TranslationStructure = {
fileLabel: 'FITXER',
folderLabel: 'CARPETA',
},
+ actionMenu: {
+ title: 'ACCIONS',
+ files: 'Fitxers',
+ stop: 'Atura',
+ },
noMachinesAvailable: 'Sense màquines',
},
@@ -504,6 +693,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})`,
@@ -666,6 +859,11 @@ export const ca: TranslationStructure = {
deviceLinkedSuccessfully: 'Dispositiu enllaçat amb èxit',
terminalConnectedSuccessfully: 'Terminal connectat amb èxit',
invalidAuthUrl: 'URL d\'autenticació no vàlida',
+ microphoneAccessRequiredTitle: 'Cal accés al micròfon',
+ microphoneAccessRequiredRequestPermission: 'Happy necessita accés al micròfon per al xat de veu. Concedeix el permís quan se’t demani.',
+ microphoneAccessRequiredEnableInSettings: 'Happy necessita accés al micròfon per al xat de veu. Activa l’accés al micròfon a la configuració del dispositiu.',
+ microphoneAccessRequiredBrowserInstructions: 'Permet l’accés al micròfon a la configuració del navegador. Potser hauràs de fer clic a la icona del cadenat a la barra d’adreces i habilitar el permís del micròfon per a aquest lloc.',
+ openSettings: 'Obre la configuració',
developerMode: 'Mode desenvolupador',
developerModeEnabled: 'Mode desenvolupador activat',
developerModeDisabled: 'Mode desenvolupador desactivat',
@@ -720,6 +918,15 @@ export const ca: TranslationStructure = {
daemon: 'Dimoni',
status: 'Estat',
stopDaemon: 'Atura el dimoni',
+ stopDaemonConfirmTitle: 'Aturar el dimoni?',
+ stopDaemonConfirmBody: 'No podràs iniciar sessions noves en aquesta màquina fins que reiniciïs el dimoni a l’ordinador. Les sessions actuals continuaran actives.',
+ daemonStoppedTitle: 'Dimoni aturat',
+ stopDaemonFailed: 'No s’ha pogut aturar el dimoni. Pot ser que no estigui en execució.',
+ renameTitle: 'Canvia el nom de la màquina',
+ renameDescription: 'Dona a aquesta màquina un nom personalitzat. Deixa-ho buit per usar el hostname per defecte.',
+ renamePlaceholder: 'Introdueix el nom de la màquina',
+ renamedSuccess: 'Màquina reanomenada correctament',
+ renameFailed: 'No s’ha pogut reanomenar la màquina',
lastKnownPid: 'Últim PID conegut',
lastKnownHttpPort: 'Últim port HTTP conegut',
startedAt: 'Iniciat a',
@@ -736,8 +943,15 @@ export const ca: TranslationStructure = {
lastSeen: 'Vist per última vegada',
never: 'Mai',
metadataVersion: 'Versió de les metadades',
+ detectedClis: 'CLI detectats',
+ detectedCliNotDetected: 'No detectat',
+ detectedCliUnknown: 'Desconegut',
+ detectedCliNotSupported: 'No compatible (actualitza happy-cli)',
untitledSession: 'Sessió sense títol',
back: 'Enrere',
+ notFound: 'Màquina no trobada',
+ unknownMachine: 'màquina desconeguda',
+ unknownPath: 'camí desconegut',
},
message: {
@@ -747,6 +961,10 @@ export const ca: TranslationStructure = {
unknownTime: 'temps desconegut',
},
+ chatFooter: {
+ permissionsTerminalOnly: 'Els permisos només es mostren al terminal. Reinicia o envia un missatge per controlar des de l\'app.',
+ },
+
codex: {
// Codex permission dialog buttons
permissions: {
@@ -773,6 +991,7 @@ export const ca: TranslationStructure = {
textCopied: 'Text copiat al porta-retalls',
failedToCopy: 'No s\'ha pogut copiar el text al porta-retalls',
noTextToCopy: 'No hi ha text disponible per copiar',
+ failedToOpen: 'No s\'ha pogut obrir la selecció de text. Torna-ho a provar.',
},
markdown: {
@@ -792,11 +1011,14 @@ export const ca: TranslationStructure = {
edit: 'Edita artefacte',
delete: 'Elimina',
updateError: 'No s\'ha pogut actualitzar l\'artefacte. Si us plau, torna-ho a provar.',
+ deleteError: 'No s\'ha pogut eliminar l\'artefacte. Torna-ho a provar.',
notFound: 'Artefacte no trobat',
discardChanges: 'Descartar els canvis?',
discardChangesDescription: 'Tens canvis sense desar. Estàs segur que vols descartar-los?',
deleteConfirm: 'Eliminar artefacte?',
deleteConfirmDescription: 'Aquest artefacte s\'eliminarà permanentment.',
+ noContent: 'Sense contingut',
+ untitled: 'Sense títol',
titlePlaceholder: 'Títol de l\'artefacte',
bodyPlaceholder: 'Escriu aquí el contingut...',
save: 'Desa',
@@ -894,8 +1116,213 @@ 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',
+ custom: 'Personalitzat',
+ builtInSaveAsHint: 'Desar un perfil integrat crea un nou perfil personalitzat.',
+ builtInNames: {
+ anthropic: 'Anthropic (Per defecte)',
+ deepseek: 'DeepSeek (Raonament)',
+ 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: '(Còpia)',
+ duplicateName: 'Ja existeix un perfil amb aquest nom',
+ setupInstructions: {
+ title: 'Instruccions de configuració',
+ viewOfficialGuide: 'Veure la guia oficial de configuració',
+ },
+ machineLogin: {
+ title: 'Inici de sessió CLI',
+ subtitle: 'Aquest perfil depèn d’una memòria cau d’inici de sessió del CLI a la màquina seleccionada.',
+ claudeCode: {
+ title: 'Claude Code',
+ instructions: 'Executa `claude` i després escriu `/login` per iniciar sessió.',
+ warning: 'Nota: definir `ANTHROPIC_AUTH_TOKEN` substitueix l’inici de sessió del CLI.',
+ },
+ codex: {
+ title: 'Codex',
+ instructions: 'Executa `codex login` per iniciar sessió.',
+ },
+ geminiCli: {
+ title: 'Gemini CLI',
+ instructions: 'Executa `gemini auth` per iniciar sessió.',
+ },
+ },
+ requirements: {
+ apiKeyRequired: 'Clau d’API',
+ configured: 'Configurada a la màquina',
+ notConfigured: 'No configurada',
+ checking: 'Comprovant…',
+ modalTitle: 'Cal una clau d’API',
+ modalBody: 'Aquest perfil requereix una clau d’API.\n\nOpcions disponibles:\n• Fer servir l’entorn de la màquina (recomanat)\n• Fer servir una clau desada a la configuració de l’app\n• Introduir una clau només per a aquesta sessió',
+ sectionTitle: 'Requisits',
+ sectionSubtitle: 'Aquests camps s’utilitzen per comprovar l’estat i evitar fallades inesperades.',
+ secretEnvVarPromptDescription: 'Introdueix el nom de la variable d’entorn secreta necessària (p. ex., OPENAI_API_KEY).',
+ modalHelpWithEnv: ({ env }: { env: string }) => `Aquest perfil necessita ${env}. Tria una opció a continuació.`,
+ modalHelpGeneric: 'Aquest perfil necessita una clau d’API. Tria una opció a continuació.',
+ modalRecommendation: 'Recomanat: defineix la clau a l’entorn del dimoni al teu ordinador (per no haver-la d’enganxar de nou). Després reinicia el dimoni perquè llegeixi la nova variable d’entorn.',
+ chooseOptionTitle: 'Tria una opció',
+ machineEnvStatus: {
+ theMachine: 'la màquina',
+ checkFor: ({ env }: { env: string }) => `Comprova ${env}`,
+ checking: ({ env }: { env: string }) => `Comprovant ${env}…`,
+ found: ({ env, machine }: { env: string; machine: string }) => `${env} trobat a ${machine}`,
+ notFound: ({ env, machine }: { env: string; machine: string }) => `${env} no trobat a ${machine}`,
+ },
+ machineEnvSubtitle: {
+ checking: 'Comprovant l’entorn del dimoni…',
+ found: 'Trobat a l’entorn del dimoni a la màquina.',
+ notFound: 'Configura-ho a l’entorn del dimoni a la màquina i reinicia el dimoni.',
+ },
+ options: {
+ none: {
+ title: 'Cap',
+ subtitle: 'No requereix clau d’API ni inici de sessió per CLI.',
+ },
+ apiKeyEnv: {
+ subtitle: 'Requereix una clau d’API que s’injectarà en iniciar la sessió.',
+ },
+ machineLogin: {
+ subtitle: 'Requereix haver iniciat sessió via un CLI a la màquina de destinació.',
+ longSubtitle: 'Requereix haver iniciat sessió via el CLI del backend d’IA escollit a la màquina de destinació.',
+ },
+ useMachineEnvironment: {
+ title: 'Fer servir l’entorn de la màquina',
+ subtitleWithEnv: ({ env }: { env: string }) => `Fer servir ${env} de l’entorn del dimoni.`,
+ subtitleGeneric: 'Fer servir la clau de l’entorn del dimoni.',
+ },
+ useSavedApiKey: {
+ title: 'Fer servir una clau d’API desada',
+ subtitle: 'Selecciona (o afegeix) una clau desada a l’app.',
+ },
+ enterOnce: {
+ title: 'Introduir una clau',
+ subtitle: 'Enganxa una clau només per a aquesta sessió (no es desarà).',
+ },
+ },
+ apiKeyEnvVar: {
+ title: 'Variable d’entorn de la clau d’API',
+ subtitle: 'Introdueix el nom de la variable d’entorn que aquest proveïdor espera per a la clau d’API (p. ex., OPENAI_API_KEY).',
+ label: 'Nom de la variable d’entorn',
+ },
+ sections: {
+ machineEnvironment: 'Entorn de la màquina',
+ useOnceTitle: 'Fer servir una vegada',
+ useOnceFooter: 'Enganxa una clau només per a aquesta sessió. No es desarà.',
+ },
+ actions: {
+ useMachineEnvironment: {
+ subtitle: 'Comença amb la clau que ja és present a la màquina.',
+ },
+ useOnceButton: 'Fer servir una vegada (només sessió)',
+ },
+ },
+ 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',
+ itemTitle: 'Màquina de previsualització per a variables d\'entorn',
+ selectMachine: 'Selecciona màquina',
+ resolveSubtitle: 'S\'usa només per previsualitzar els valors resolts a continuació (no canvia el que es desa).',
+ selectSubtitle: 'Selecciona una màquina per previsualitzar els valors resolts a continuació.',
+ },
+ 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',
+ secretToggleLabel: 'Secret',
+ secretToggleSubtitle: 'Amaga el valor a la UI i evita obtenir-lo de la màquina per a la previsualització.',
+ secretToggleEnforcedByDaemon: 'Imposat pel dimoni',
+ secretToggleResetToAuto: 'Restablir a automàtic',
+ 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.`,
@@ -904,6 +1331,45 @@ export const ca: TranslationStructure = {
},
},
+ apiKeys: {
+ addTitle: 'Nova clau d’API',
+ savedTitle: 'Claus d’API desades',
+ badgeReady: 'Clau d’API',
+ badgeRequired: 'Cal una clau d’API',
+ addSubtitle: 'Afegeix una clau d’API desada',
+ noneTitle: 'Cap',
+ noneSubtitle: 'Fes servir l’entorn de la màquina o introdueix una clau per a aquesta sessió',
+ emptyTitle: 'No hi ha claus desades',
+ emptySubtitle: 'Afegeix-ne una per utilitzar perfils amb clau d’API sense configurar variables d’entorn a la màquina.',
+ savedHiddenSubtitle: 'Desada (valor ocult)',
+ defaultLabel: 'Per defecte',
+ fields: {
+ name: 'Nom',
+ value: 'Valor',
+ },
+ placeholders: {
+ nameExample: 'p. ex., Work OpenAI',
+ },
+ validation: {
+ nameRequired: 'El nom és obligatori.',
+ valueRequired: 'El valor és obligatori.',
+ },
+ actions: {
+ replace: 'Substitueix',
+ replaceValue: 'Substitueix el valor',
+ setDefault: 'Estableix com a per defecte',
+ unsetDefault: 'Treu com a per defecte',
+ },
+ prompts: {
+ renameTitle: 'Reanomena la clau d’API',
+ renameDescription: 'Actualitza el nom descriptiu d’aquesta clau.',
+ replaceValueTitle: 'Substitueix el valor de la clau d’API',
+ replaceValueDescription: 'Enganxa el nou valor de la clau d’API. No es tornarà a mostrar després de desar-lo.',
+ deleteTitle: 'Elimina la clau d’API',
+ deleteConfirm: ({ name }: { name: string }) => `Vols eliminar “${name}”? Aquesta acció no es pot desfer.`,
+ },
+ },
+
feed: {
// Feed notifications for friend requests and acceptances
friendRequestFrom: ({ name }: { name: string }) => `${name} t'ha enviat una sol·licitud d'amistat`,
diff --git a/sources/text/translations/en.ts b/sources/text/translations/en.ts
index 7bddc729b..f50020874 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,11 @@ export const en: TranslationStructure = {
retry: 'Retry',
delete: 'Delete',
optional: 'optional',
+ noMatches: 'No matches',
+ all: 'All',
+ machine: 'machine',
+ clearSearch: 'Clear search',
+ refresh: 'Refresh',
},
profile: {
@@ -111,6 +119,15 @@ export const en: TranslationStructure = {
enterSecretKey: 'Please enter a secret key',
invalidSecretKey: 'Invalid secret key. Please check and try again.',
enterUrlManually: 'Enter URL manually',
+ terminalUrlPlaceholder: 'happy://terminal?...',
+ restoreQrInstructions: '1. Open Happy on your mobile device\n2. Go to Settings → Account\n3. Tap "Link New Device"\n4. Scan this QR code',
+ restoreWithSecretKeyInstead: 'Restore with Secret Key Instead',
+ restoreWithSecretKeyDescription: 'Enter your secret key to restore access to your account.',
+ secretKeyPlaceholder: 'XXXXX-XXXXX-XXXXX...',
+ unsupported: {
+ connectTitle: ({ name }: { name: string }) => `Connect ${name}`,
+ runCommandInTerminal: 'Run the following command in your terminal:',
+ },
},
settings: {
@@ -151,6 +168,8 @@ export const en: TranslationStructure = {
usageSubtitle: 'View your API usage and costs',
profiles: 'Profiles',
profilesSubtitle: 'Manage environment variable profiles for sessions',
+ apiKeys: 'API Keys',
+ apiKeysSubtitle: 'Manage saved API keys (never shown again after entry)',
// Dynamic settings messages
accountConnected: ({ service }: { service: string }) => `${service} account connected`,
@@ -188,6 +207,21 @@ export const en: TranslationStructure = {
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',
+ agentInputActionBarLayout: 'Input Action Bar',
+ agentInputActionBarLayoutDescription: 'Choose how action chips are displayed above the input',
+ agentInputActionBarLayoutOptions: {
+ auto: 'Auto',
+ wrap: 'Wrap',
+ scroll: 'Scrollable',
+ collapsed: 'Collapsed',
+ },
+ agentInputChipDensity: 'Action Chip Density',
+ agentInputChipDensityDescription: 'Choose whether action chips show labels or icons',
+ agentInputChipDensityOptions: {
+ auto: 'Auto',
+ labels: 'Labels',
+ icons: 'Icons only',
+ },
avatarStyle: 'Avatar Style',
avatarStyleDescription: 'Choose session avatar appearance',
avatarOptions: {
@@ -208,11 +242,27 @@ export const en: TranslationStructure = {
experimentalFeatures: 'Experimental Features',
experimentalFeaturesEnabled: 'Experimental features enabled',
experimentalFeaturesDisabled: 'Using stable features only',
+ experimentalOptions: 'Experimental options',
+ experimentalOptionsDescription: 'Choose which experimental features are enabled.',
+ expGemini: 'Gemini',
+ expGeminiSubtitle: 'Enable Gemini CLI sessions and Gemini-related UI',
+ expUsageReporting: 'Usage reporting',
+ expUsageReportingSubtitle: 'Enable usage and token reporting screens',
+ expFileViewer: 'File viewer',
+ expFileViewerSubtitle: 'Enable the session file viewer entrypoint',
+ expShowThinkingMessages: 'Show thinking messages',
+ expShowThinkingMessagesSubtitle: 'Show assistant thinking/status messages in chat',
+ expSessionType: 'Session type selector',
+ expSessionTypeSubtitle: 'Show the session type selector (simple vs worktree)',
+ expZen: 'Zen',
+ expZenSubtitle: 'Enable the Zen navigation entry',
+ expVoiceAuthFlow: 'Voice auth flow',
+ expVoiceAuthFlowSubtitle: 'Use authenticated voice token flow (paywall-aware)',
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 +273,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 +334,27 @@ export const en: TranslationStructure = {
newSession: {
// Used by new-session screen and launch flows
title: 'Start New Session',
+ selectAiProfileTitle: 'Select AI Profile',
+ selectAiProfileDescription: 'Select an AI profile to apply environment variables and defaults to your session.',
+ changeProfile: 'Change Profile',
+ aiBackendSelectedByProfile: 'AI backend is selected by your profile. To change it, select a different profile.',
+ selectAiBackendTitle: 'Select AI Backend',
+ aiBackendLimitedByProfileAndMachineClis: 'Limited by your selected profile and available CLIs on this machine.',
+ aiBackendSelectWhichAiRuns: 'Select which AI runs your session.',
+ aiBackendNotCompatibleWithSelectedProfile: 'Not compatible with the selected profile.',
+ aiBackendCliNotDetectedOnMachine: ({ cli }: { cli: string }) => `${cli} CLI not detected on this machine.`,
+ selectMachineTitle: 'Select Machine',
+ selectMachineDescription: 'Choose where this session runs.',
+ selectPathTitle: 'Select Path',
+ selectWorkingDirectoryTitle: 'Select Working Directory',
+ selectWorkingDirectoryDescription: 'Pick the folder used for commands and context.',
+ selectPermissionModeTitle: 'Select Permission Mode',
+ selectPermissionModeDescription: 'Control how strictly actions require approval.',
+ selectModelTitle: 'Select AI Model',
+ selectModelDescription: 'Choose the model used by this session.',
+ selectSessionTypeTitle: 'Select Session Type',
+ selectSessionTypeDescription: 'Choose a simple session or one tied to a Git worktree.',
+ 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,12 +370,46 @@ 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',
worktree: 'Worktree',
comingSoon: 'Coming soon',
},
+ profileAvailability: {
+ requiresAgent: ({ agent }: { agent: string }) => `Requires ${agent}`,
+ cliNotDetected: ({ cli }: { cli: string }) => `${cli} CLI not detected`,
+ },
+ cliBanners: {
+ cliNotDetectedTitle: ({ cli }: { cli: string }) => `${cli} CLI Not Detected`,
+ dontShowFor: "Don't show this popup for",
+ thisMachine: 'this machine',
+ anyMachine: 'any machine',
+ installCommand: ({ command }: { command: string }) => `Install: ${command} •`,
+ installCliIfAvailable: ({ cli }: { cli: string }) => `Install ${cli} CLI if available •`,
+ viewInstallationGuide: 'View Installation Guide →',
+ viewGeminiDocs: 'View Gemini Docs →',
+ },
worktree: {
creating: ({ name }: { name: string }) => `Creating worktree '${name}'...`,
notGitRepo: 'Worktrees require a git repository',
@@ -315,11 +429,24 @@ export const en: TranslationStructure = {
},
session: {
- inputPlaceholder: 'Type a message ...',
+ inputPlaceholder: 'What would you like to work on?',
},
commandPalette: {
placeholder: 'Type a command or search...',
+ noCommandsFound: 'No commands found',
+ },
+
+ commandView: {
+ completedWithNoOutput: '[Command completed with no output]',
+ },
+
+ voiceAssistant: {
+ connecting: 'Connecting...',
+ active: 'Voice Assistant Active',
+ connectionError: 'Connection Error',
+ label: 'Voice Assistant',
+ tapToEnd: 'Tap to end',
},
server: {
@@ -351,6 +478,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',
@@ -374,6 +502,9 @@ export const en: TranslationStructure = {
happyHome: 'Happy Home',
copyMetadata: 'Copy Metadata',
agentState: 'Agent State',
+ rawJsonDevMode: 'Raw JSON (Dev Mode)',
+ sessionStatus: 'Session Status',
+ fullSessionObject: 'Full Session Object',
controlledByUser: 'Controlled by User',
pendingRequests: 'Pending Requests',
activity: 'Activity',
@@ -401,16 +532,52 @@ export const en: TranslationStructure = {
runIt: 'Run it',
scanQrCode: 'Scan the QR code',
openCamera: 'Open Camera',
+ installCommand: '$ npm i -g happy-coder',
+ runCommand: '$ happy',
+ },
+ emptyMessages: {
+ noMessagesYet: 'No messages yet',
+ created: ({ time }: { time: string }) => `Created ${time}`,
+ },
+ emptySessionsTablet: {
+ noActiveSessions: 'No active sessions',
+ startNewSessionDescription: 'Start a new session on any of your connected machines.',
+ startNewSessionButton: 'Start New Session',
+ openTerminalToStart: 'Open a new terminal on your computer to start session.',
+ },
+ },
+
+ zen: {
+ title: 'Zen',
+ add: {
+ placeholder: 'What needs to be done?',
+ },
+ home: {
+ noTasksYet: 'No tasks yet. Tap + to add one.',
+ },
+ view: {
+ workOnTask: 'Work on task',
+ clarify: 'Clarify',
+ delete: 'Delete',
+ linkedSessions: 'Linked Sessions',
+ tapTaskTextToEdit: 'Tap the task text to edit',
},
},
agentInput: {
+ envVars: {
+ title: 'Env Vars',
+ titleWithCount: ({ count }: { count: number }) => `Env Vars (${count})`,
+ },
permissionMode: {
title: 'PERMISSION MODE',
default: 'Default',
acceptEdits: 'Accept Edits',
plan: 'Plan Mode',
bypassPermissions: 'Yolo Mode',
+ badgeAccept: 'Accept',
+ badgePlan: 'Plan',
+ badgeYolo: 'YOLO',
badgeAcceptAllEdits: 'Accept All Edits',
badgeBypassAllPermissions: 'Bypass All Permissions',
badgePlanMode: 'Plan Mode',
@@ -430,7 +597,7 @@ export const en: TranslationStructure = {
readOnly: 'Read Only Mode',
safeYolo: 'Safe YOLO',
yolo: 'YOLO',
- badgeReadOnly: 'Read Only Mode',
+ badgeReadOnly: 'Read Only',
badgeSafeYolo: 'Safe YOLO',
badgeYolo: 'YOLO',
},
@@ -454,6 +621,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`,
},
@@ -461,6 +643,11 @@ export const en: TranslationStructure = {
fileLabel: 'FILE',
folderLabel: 'FOLDER',
},
+ actionMenu: {
+ title: 'ACTIONS',
+ files: 'Files',
+ stop: 'Stop',
+ },
noMachinesAvailable: 'No machines',
},
@@ -519,6 +706,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',
@@ -681,6 +872,11 @@ export const en: TranslationStructure = {
deviceLinkedSuccessfully: 'Device linked successfully',
terminalConnectedSuccessfully: 'Terminal connected successfully',
invalidAuthUrl: 'Invalid authentication URL',
+ microphoneAccessRequiredTitle: 'Microphone Access Required',
+ microphoneAccessRequiredRequestPermission: 'Happy needs access to your microphone for voice chat. Please grant permission when prompted.',
+ microphoneAccessRequiredEnableInSettings: 'Happy needs access to your microphone for voice chat. Please enable microphone access in your device settings.',
+ microphoneAccessRequiredBrowserInstructions: 'Please allow microphone access in your browser settings. You may need to click the lock icon in the address bar and enable microphone permission for this site.',
+ openSettings: 'Open Settings',
developerMode: 'Developer Mode',
developerModeEnabled: 'Developer mode enabled',
developerModeDisabled: 'Developer mode disabled',
@@ -735,6 +931,15 @@ export const en: TranslationStructure = {
daemon: 'Daemon',
status: 'Status',
stopDaemon: 'Stop Daemon',
+ stopDaemonConfirmTitle: 'Stop Daemon?',
+ stopDaemonConfirmBody: 'You will not be able to spawn new sessions on this machine until you restart the daemon on your computer again. Your current sessions will stay alive.',
+ daemonStoppedTitle: 'Daemon Stopped',
+ stopDaemonFailed: 'Failed to stop daemon. It may not be running.',
+ renameTitle: 'Rename Machine',
+ renameDescription: 'Give this machine a custom name. Leave empty to use the default hostname.',
+ renamePlaceholder: 'Enter machine name',
+ renamedSuccess: 'Machine renamed successfully',
+ renameFailed: 'Failed to rename machine',
lastKnownPid: 'Last Known PID',
lastKnownHttpPort: 'Last Known HTTP Port',
startedAt: 'Started At',
@@ -751,8 +956,15 @@ export const en: TranslationStructure = {
lastSeen: 'Last Seen',
never: 'Never',
metadataVersion: 'Metadata Version',
+ detectedClis: 'Detected CLIs',
+ detectedCliNotDetected: 'Not detected',
+ detectedCliUnknown: 'Unknown',
+ detectedCliNotSupported: 'Not supported (update happy-cli)',
untitledSession: 'Untitled Session',
back: 'Back',
+ notFound: 'Machine not found',
+ unknownMachine: 'unknown machine',
+ unknownPath: 'unknown path',
},
message: {
@@ -762,6 +974,10 @@ export const en: TranslationStructure = {
unknownTime: 'unknown time',
},
+ chatFooter: {
+ permissionsTerminalOnly: 'Permissions are shown in the terminal only. Reset or send a message to control from the app.',
+ },
+
codex: {
// Codex permission dialog buttons
permissions: {
@@ -788,6 +1004,7 @@ export const en: TranslationStructure = {
textCopied: 'Text copied to clipboard',
failedToCopy: 'Failed to copy text to clipboard',
noTextToCopy: 'No text available to copy',
+ failedToOpen: 'Failed to open text selection. Please try again.',
},
markdown: {
@@ -808,11 +1025,14 @@ export const en: TranslationStructure = {
edit: 'Edit Artifact',
delete: 'Delete',
updateError: 'Failed to update artifact. Please try again.',
+ deleteError: 'Failed to delete 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',
+ noContent: 'No content',
+ untitled: 'Untitled',
titleLabel: 'TITLE',
titlePlaceholder: 'Enter a title for your artifact',
bodyLabel: 'CONTENT',
@@ -898,12 +1118,51 @@ export const en: TranslationStructure = {
friendAcceptedGeneric: 'Friend request accepted',
},
+ apiKeys: {
+ addTitle: 'New API key',
+ savedTitle: 'Saved API keys',
+ badgeReady: 'API key',
+ badgeRequired: 'API key required',
+ addSubtitle: 'Add a saved API key',
+ noneTitle: 'None',
+ noneSubtitle: 'Use machine environment or enter a key for this session',
+ emptyTitle: 'No saved keys',
+ emptySubtitle: 'Add one to use API-key profiles without setting machine env vars.',
+ savedHiddenSubtitle: 'Saved (value hidden)',
+ defaultLabel: 'Default',
+ fields: {
+ name: 'Name',
+ value: 'Value',
+ },
+ placeholders: {
+ nameExample: 'e.g. Work OpenAI',
+ },
+ validation: {
+ nameRequired: 'Name is required.',
+ valueRequired: 'Value is required.',
+ },
+ actions: {
+ replace: 'Replace',
+ replaceValue: 'Replace value',
+ setDefault: 'Set as default',
+ unsetDefault: 'Unset default',
+ },
+ prompts: {
+ renameTitle: 'Rename API key',
+ renameDescription: 'Update the friendly name for this key.',
+ replaceValueTitle: 'Replace API key value',
+ replaceValueDescription: 'Paste the new API key value. This value will not be shown again after saving.',
+ deleteTitle: 'Delete API key',
+ deleteConfirm: ({ name }: { name: string }) => `Delete “${name}”? This cannot be undone.`,
+ },
+ },
+
profiles: {
// 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 +1177,214 @@ 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',
+ custom: 'Custom',
+ builtInSaveAsHint: 'Saving a built-in profile creates a new custom profile.',
+ 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',
+ },
+ machineLogin: {
+ title: 'CLI login',
+ subtitle: 'This profile relies on a CLI login cache on the selected machine.',
+ claudeCode: {
+ title: 'Claude Code',
+ instructions: 'Run `claude`, then type `/login` to sign in.',
+ warning: 'Note: setting `ANTHROPIC_AUTH_TOKEN` overrides CLI login.',
+ },
+ codex: {
+ title: 'Codex',
+ instructions: 'Run `codex login` to sign in.',
+ },
+ geminiCli: {
+ title: 'Gemini CLI',
+ instructions: 'Run `gemini auth` to sign in.',
+ },
+ },
+ requirements: {
+ apiKeyRequired: 'API key',
+ configured: 'Configured on machine',
+ notConfigured: 'Not configured',
+ checking: 'Checking…',
+ modalTitle: 'API key required',
+ modalBody: 'This profile requires an API key.\n\nSupported options:\n• Use machine environment (recommended)\n• Use saved key from app settings\n• Enter a key for this session only',
+ sectionTitle: 'Requirements',
+ sectionSubtitle: 'These fields are used to preflight readiness and to avoid surprise failures.',
+ secretEnvVarPromptDescription: 'Enter the required secret environment variable name (e.g. OPENAI_API_KEY).',
+ modalHelpWithEnv: ({ env }: { env: string }) => `This profile needs ${env}. Choose one option below.`,
+ modalHelpGeneric: 'This profile needs an API key. Choose one option below.',
+ modalRecommendation: 'Recommended: set the key in your daemon environment on your computer (so you don’t have to paste it again). Then restart the daemon so it picks up the new env var.',
+ chooseOptionTitle: 'Choose an option',
+ machineEnvStatus: {
+ theMachine: 'the machine',
+ checkFor: ({ env }: { env: string }) => `Check for ${env}`,
+ checking: ({ env }: { env: string }) => `Checking ${env}…`,
+ found: ({ env, machine }: { env: string; machine: string }) => `${env} found on ${machine}`,
+ notFound: ({ env, machine }: { env: string; machine: string }) => `${env} not found on ${machine}`,
+ },
+ machineEnvSubtitle: {
+ checking: 'Checking daemon environment…',
+ found: 'Found in the daemon environment on the machine.',
+ notFound: 'Set it in the daemon environment on the machine and restart the daemon.',
+ },
+ options: {
+ none: {
+ title: 'None',
+ subtitle: 'Does not require an API key or CLI login.',
+ },
+ apiKeyEnv: {
+ subtitle: 'Requires an API key to be injected at session start.',
+ },
+ machineLogin: {
+ subtitle: 'Requires being logged in via a CLI on the target machine.',
+ longSubtitle: 'Requires being logged in via the CLI for the AI backend you choose on the target machine.',
+ },
+ useMachineEnvironment: {
+ title: 'Use machine environment',
+ subtitleWithEnv: ({ env }: { env: string }) => `Use ${env} from the daemon environment.`,
+ subtitleGeneric: 'Use the key from the daemon environment.',
+ },
+ useSavedApiKey: {
+ title: 'Use a saved API key',
+ subtitle: 'Select (or add) a saved key in the app.',
+ },
+ enterOnce: {
+ title: 'Enter a key',
+ subtitle: 'Paste a key for this session only (won’t be saved).',
+ },
+ },
+ apiKeyEnvVar: {
+ title: 'API key environment variable',
+ subtitle: 'Enter the env var name this provider expects for its API key (e.g. OPENAI_API_KEY).',
+ label: 'Environment variable name',
+ },
+ sections: {
+ machineEnvironment: 'Machine environment',
+ useOnceTitle: 'Use once',
+ useOnceFooter: 'Paste a key for this session only. It won’t be saved.',
+ },
+ actions: {
+ useMachineEnvironment: {
+ subtitle: 'Start with the key already present on the machine.',
+ },
+ useOnceButton: 'Use once (session only)',
+ },
+ },
+ 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',
+ itemTitle: 'Preview machine for environment variables preview',
+ selectMachine: 'Select machine',
+ resolveSubtitle: 'Used only to preview the resolved values below (does not change what is saved).',
+ selectSubtitle: 'Select a machine to preview the resolved values below.',
+ },
+ 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',
+ secretToggleLabel: 'Secret',
+ secretToggleSubtitle: 'Hide the value in the UI and avoid fetching it from the machine for preview.',
+ secretToggleEnforcedByDaemon: 'Enforced by daemon',
+ secretToggleResetToAuto: 'Reset to auto',
+ 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 +1392,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..f4f8640cb 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,11 @@ export const es: TranslationStructure = {
retry: 'Reintentar',
delete: 'Eliminar',
optional: 'opcional',
+ noMatches: 'Sin coincidencias',
+ all: 'Todo',
+ machine: 'máquina',
+ clearSearch: 'Limpiar búsqueda',
+ refresh: 'Actualizar',
},
profile: {
@@ -96,6 +106,15 @@ export const es: TranslationStructure = {
enterSecretKey: 'Ingresa tu clave secreta',
invalidSecretKey: 'Clave secreta inválida. Verifica e intenta de nuevo.',
enterUrlManually: 'Ingresar URL manualmente',
+ terminalUrlPlaceholder: 'happy://terminal?...',
+ restoreQrInstructions: '1. Abre Happy en tu dispositivo móvil\n2. Ve a Configuración → Cuenta\n3. Toca "Vincular nuevo dispositivo"\n4. Escanea este código QR',
+ restoreWithSecretKeyInstead: 'Restaurar con clave secreta',
+ restoreWithSecretKeyDescription: 'Ingresa tu clave secreta para recuperar el acceso a tu cuenta.',
+ secretKeyPlaceholder: 'XXXXX-XXXXX-XXXXX...',
+ unsupported: {
+ connectTitle: ({ name }: { name: string }) => `Conectar ${name}`,
+ runCommandInTerminal: 'Ejecuta el siguiente comando en tu terminal:',
+ },
},
settings: {
@@ -136,6 +155,8 @@ export const es: TranslationStructure = {
usageSubtitle: 'Ver tu uso de API y costos',
profiles: 'Perfiles',
profilesSubtitle: 'Gestionar perfiles de variables de entorno para sesiones',
+ apiKeys: 'Claves API',
+ apiKeysSubtitle: 'Gestiona las claves API guardadas (no se vuelven a mostrar después de ingresarlas)',
// Dynamic settings messages
accountConnected: ({ service }: { service: string }) => `Cuenta de ${service} conectada`,
@@ -173,6 +194,21 @@ export const es: TranslationStructure = {
wrapLinesInDiffsDescription: 'Ajustar líneas largas en lugar de desplazamiento horizontal en vistas de diferencias',
alwaysShowContextSize: 'Mostrar siempre tamaño del contexto',
alwaysShowContextSizeDescription: 'Mostrar uso del contexto incluso cuando no esté cerca del límite',
+ agentInputActionBarLayout: 'Barra de acciones de entrada',
+ agentInputActionBarLayoutDescription: 'Elige cómo se muestran los chips de acción encima del campo de entrada',
+ agentInputActionBarLayoutOptions: {
+ auto: 'Auto',
+ wrap: 'Ajustar',
+ scroll: 'Desplazable',
+ collapsed: 'Contraído',
+ },
+ agentInputChipDensity: 'Densidad de chips de acción',
+ agentInputChipDensityDescription: 'Elige si los chips de acción muestran etiquetas o íconos',
+ agentInputChipDensityOptions: {
+ auto: 'Auto',
+ labels: 'Etiquetas',
+ icons: 'Solo íconos',
+ },
avatarStyle: 'Estilo de avatar',
avatarStyleDescription: 'Elige la apariencia del avatar de sesión',
avatarOptions: {
@@ -193,6 +229,22 @@ export const es: TranslationStructure = {
experimentalFeatures: 'Características experimentales',
experimentalFeaturesEnabled: 'Características experimentales habilitadas',
experimentalFeaturesDisabled: 'Usando solo características estables',
+ experimentalOptions: 'Opciones experimentales',
+ experimentalOptionsDescription: 'Elige qué funciones experimentales están activadas.',
+ expGemini: 'Gemini',
+ expGeminiSubtitle: 'Enable Gemini CLI sessions and Gemini-related UI',
+ expUsageReporting: 'Usage reporting',
+ expUsageReportingSubtitle: 'Enable usage and token reporting screens',
+ expFileViewer: 'File viewer',
+ expFileViewerSubtitle: 'Enable the session file viewer entrypoint',
+ expShowThinkingMessages: 'Show thinking messages',
+ expShowThinkingMessagesSubtitle: 'Show assistant thinking/status messages in chat',
+ expSessionType: 'Session type selector',
+ expSessionTypeSubtitle: 'Show the session type selector (simple vs worktree)',
+ expZen: 'Zen',
+ expZenSubtitle: 'Enable the Zen navigation entry',
+ expVoiceAuthFlow: 'Voice auth flow',
+ expVoiceAuthFlowSubtitle: 'Use authenticated voice token flow (paywall-aware)',
webFeatures: 'Características web',
webFeaturesDescription: 'Características disponibles solo en la versión web de la aplicación.',
enterToSend: 'Enter para enviar',
@@ -201,13 +253,22 @@ export const es: TranslationStructure = {
commandPalette: 'Paleta de comandos',
commandPaletteEnabled: 'Presione ⌘K para abrir',
commandPaletteDisabled: 'Acceso rápido a comandos deshabilitado',
- markdownCopyV2: 'Markdown Copy v2',
+ markdownCopyV2: 'Copia de Markdown v2',
markdownCopyV2Subtitle: 'Pulsación larga abre modal de copiado',
hideInactiveSessions: 'Ocultar sesiones inactivas',
hideInactiveSessionsSubtitle: 'Muestra solo los chats activos en tu lista',
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 +321,27 @@ export const es: TranslationStructure = {
newSession: {
// Used by new-session screen and launch flows
title: 'Iniciar nueva sesión',
+ selectAiProfileTitle: 'Seleccionar perfil de IA',
+ selectAiProfileDescription: 'Selecciona un perfil de IA para aplicar variables de entorno y valores predeterminados a tu sesión.',
+ changeProfile: 'Cambiar perfil',
+ aiBackendSelectedByProfile: 'El backend de IA lo selecciona tu perfil. Para cambiarlo, selecciona un perfil diferente.',
+ selectAiBackendTitle: 'Seleccionar backend de IA',
+ aiBackendLimitedByProfileAndMachineClis: 'Limitado por tu perfil seleccionado y los CLI disponibles en esta máquina.',
+ aiBackendSelectWhichAiRuns: 'Selecciona qué IA ejecuta tu sesión.',
+ aiBackendNotCompatibleWithSelectedProfile: 'No es compatible con el perfil seleccionado.',
+ aiBackendCliNotDetectedOnMachine: ({ cli }: { cli: string }) => `No se detectó el CLI de ${cli} en esta máquina.`,
+ selectMachineTitle: 'Seleccionar máquina',
+ selectMachineDescription: 'Elige dónde se ejecuta esta sesión.',
+ selectPathTitle: 'Seleccionar ruta',
+ selectWorkingDirectoryTitle: 'Seleccionar directorio de trabajo',
+ selectWorkingDirectoryDescription: 'Elige la carpeta usada para comandos y contexto.',
+ selectPermissionModeTitle: 'Seleccionar modo de permisos',
+ selectPermissionModeDescription: 'Controla qué tan estrictamente las acciones requieren aprobación.',
+ selectModelTitle: 'Seleccionar modelo de IA',
+ selectModelDescription: 'Elige el modelo usado por esta sesión.',
+ selectSessionTypeTitle: 'Seleccionar tipo de sesión',
+ selectSessionTypeDescription: 'Elige una sesión simple o una vinculada a un worktree de Git.',
+ 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,12 +357,46 @@ 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',
worktree: 'Worktree',
comingSoon: 'Próximamente',
},
+ profileAvailability: {
+ requiresAgent: ({ agent }: { agent: string }) => `Requiere ${agent}`,
+ cliNotDetected: ({ cli }: { cli: string }) => `${cli} CLI no detectado`,
+ },
+ cliBanners: {
+ cliNotDetectedTitle: ({ cli }: { cli: string }) => `${cli} CLI no detectado`,
+ dontShowFor: 'No mostrar este aviso para',
+ thisMachine: 'esta máquina',
+ anyMachine: 'cualquier máquina',
+ installCommand: ({ command }: { command: string }) => `Instalar: ${command} •`,
+ installCliIfAvailable: ({ cli }: { cli: string }) => `Instala ${cli} CLI si está disponible •`,
+ viewInstallationGuide: 'Ver guía de instalación →',
+ viewGeminiDocs: 'Ver documentación de Gemini →',
+ },
worktree: {
creating: ({ name }: { name: string }) => `Creando worktree '${name}'...`,
notGitRepo: 'Los worktrees requieren un repositorio git',
@@ -305,6 +421,19 @@ export const es: TranslationStructure = {
commandPalette: {
placeholder: 'Escriba un comando o busque...',
+ noCommandsFound: 'No se encontraron comandos',
+ },
+
+ commandView: {
+ completedWithNoOutput: '[Comando completado sin salida]',
+ },
+
+ voiceAssistant: {
+ connecting: 'Conectando...',
+ active: 'Asistente de voz activo',
+ connectionError: 'Error de conexión',
+ label: 'Asistente de voz',
+ tapToEnd: 'Toca para finalizar',
},
server: {
@@ -336,6 +465,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',
@@ -359,6 +489,9 @@ export const es: TranslationStructure = {
happyHome: 'Directorio de Happy',
copyMetadata: 'Copiar metadatos',
agentState: 'Estado del agente',
+ rawJsonDevMode: 'JSON sin procesar (modo desarrollador)',
+ sessionStatus: 'Estado de la sesión',
+ fullSessionObject: 'Objeto de sesión completo',
controlledByUser: 'Controlado por el usuario',
pendingRequests: 'Solicitudes pendientes',
activity: 'Actividad',
@@ -386,16 +519,52 @@ export const es: TranslationStructure = {
runIt: 'Ejecútelo',
scanQrCode: 'Escanee el código QR',
openCamera: 'Abrir cámara',
+ installCommand: '$ npm i -g happy-coder',
+ runCommand: '$ happy',
+ },
+ emptyMessages: {
+ noMessagesYet: 'Aún no hay mensajes',
+ created: ({ time }: { time: string }) => `Creado ${time}`,
+ },
+ emptySessionsTablet: {
+ noActiveSessions: 'No hay sesiones activas',
+ startNewSessionDescription: 'Inicia una nueva sesión en cualquiera de tus máquinas conectadas.',
+ startNewSessionButton: 'Iniciar nueva sesión',
+ openTerminalToStart: 'Abre un nuevo terminal en tu computadora para iniciar una sesión.',
+ },
+ },
+
+ zen: {
+ title: 'Zen',
+ add: {
+ placeholder: '¿Qué hay que hacer?',
+ },
+ home: {
+ noTasksYet: 'Aún no hay tareas. Toca + para añadir una.',
+ },
+ view: {
+ workOnTask: 'Trabajar en la tarea',
+ clarify: 'Aclarar',
+ delete: 'Eliminar',
+ linkedSessions: 'Sesiones vinculadas',
+ tapTaskTextToEdit: 'Toca el texto de la tarea para editar',
},
},
agentInput: {
+ envVars: {
+ title: 'Variables de entorno',
+ titleWithCount: ({ count }: { count: number }) => `Variables de entorno (${count})`,
+ },
permissionMode: {
title: 'MODO DE PERMISOS',
default: 'Por defecto',
acceptEdits: 'Aceptar ediciones',
plan: 'Modo de planificación',
bypassPermissions: 'Modo Yolo',
+ badgeAccept: 'Aceptar',
+ badgePlan: 'Plan',
+ badgeYolo: 'YOLO',
badgeAcceptAllEdits: 'Aceptar todas las ediciones',
badgeBypassAllPermissions: 'Omitir todos los permisos',
badgePlanMode: 'Modo de planificación',
@@ -412,32 +581,47 @@ export const es: TranslationStructure = {
codexPermissionMode: {
title: 'MODO DE PERMISOS CODEX',
default: 'Configuración del CLI',
- readOnly: 'Read Only Mode',
- safeYolo: 'Safe YOLO',
+ readOnly: 'Modo de solo lectura',
+ safeYolo: 'YOLO seguro',
yolo: 'YOLO',
- badgeReadOnly: 'Read Only Mode',
- badgeSafeYolo: 'Safe YOLO',
+ badgeReadOnly: 'Solo lectura',
+ badgeSafeYolo: 'YOLO seguro',
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',
+ title: 'MODELO CODEX',
+ gpt5CodexLow: 'gpt-5-codex bajo',
+ gpt5CodexMedium: 'gpt-5-codex medio',
+ gpt5CodexHigh: 'gpt-5-codex alto',
+ gpt5Minimal: 'GPT-5 Mínimo',
+ gpt5Low: 'GPT-5 Bajo',
+ gpt5Medium: 'GPT-5 Medio',
+ gpt5High: 'GPT-5 Alto',
},
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`,
@@ -446,6 +630,11 @@ export const es: TranslationStructure = {
fileLabel: 'ARCHIVO',
folderLabel: 'CARPETA',
},
+ actionMenu: {
+ title: 'ACCIONES',
+ files: 'Archivos',
+ stop: 'Detener',
+ },
noMachinesAvailable: 'Sin máquinas',
},
@@ -504,6 +693,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})`,
@@ -666,6 +859,11 @@ export const es: TranslationStructure = {
deviceLinkedSuccessfully: 'Dispositivo vinculado exitosamente',
terminalConnectedSuccessfully: 'Terminal conectado exitosamente',
invalidAuthUrl: 'URL de autenticación inválida',
+ microphoneAccessRequiredTitle: 'Se requiere acceso al micrófono',
+ microphoneAccessRequiredRequestPermission: 'Happy necesita acceso a tu micrófono para el chat de voz. Concede el permiso cuando se te solicite.',
+ microphoneAccessRequiredEnableInSettings: 'Happy necesita acceso a tu micrófono para el chat de voz. Activa el acceso al micrófono en la configuración de tu dispositivo.',
+ microphoneAccessRequiredBrowserInstructions: 'Permite el acceso al micrófono en la configuración del navegador. Puede que debas hacer clic en el icono de candado en la barra de direcciones y habilitar el permiso del micrófono para este sitio.',
+ openSettings: 'Abrir configuración',
developerMode: 'Modo desarrollador',
developerModeEnabled: 'Modo desarrollador habilitado',
developerModeDisabled: 'Modo desarrollador deshabilitado',
@@ -717,9 +915,18 @@ export const es: TranslationStructure = {
offlineUnableToSpawn: 'El lanzador está deshabilitado mientras la máquina está desconectada',
offlineHelp: '• Asegúrate de que tu computadora esté en línea\n• Ejecuta `happy daemon status` para diagnosticar\n• ¿Estás usando la última versión del CLI? Actualiza con `npm install -g happy-coder@latest`',
launchNewSessionInDirectory: 'Iniciar nueva sesión en directorio',
- daemon: 'Daemon',
+ daemon: 'Demonio',
status: 'Estado',
stopDaemon: 'Detener daemon',
+ stopDaemonConfirmTitle: '¿Detener daemon?',
+ stopDaemonConfirmBody: 'No podrás crear nuevas sesiones en esta máquina hasta que reinicies el daemon en tu computadora. Tus sesiones actuales seguirán activas.',
+ daemonStoppedTitle: 'Daemon detenido',
+ stopDaemonFailed: 'No se pudo detener el daemon. Puede que no esté en ejecución.',
+ renameTitle: 'Renombrar máquina',
+ renameDescription: 'Dale a esta máquina un nombre personalizado. Déjalo vacío para usar el hostname predeterminado.',
+ renamePlaceholder: 'Ingresa el nombre de la máquina',
+ renamedSuccess: 'Máquina renombrada correctamente',
+ renameFailed: 'No se pudo renombrar la máquina',
lastKnownPid: 'Último PID conocido',
lastKnownHttpPort: 'Último puerto HTTP conocido',
startedAt: 'Iniciado en',
@@ -736,8 +943,15 @@ export const es: TranslationStructure = {
lastSeen: 'Visto por última vez',
never: 'Nunca',
metadataVersion: 'Versión de metadatos',
+ detectedClis: 'CLI detectados',
+ detectedCliNotDetected: 'No detectado',
+ detectedCliUnknown: 'Desconocido',
+ detectedCliNotSupported: 'No compatible (actualiza happy-cli)',
untitledSession: 'Sesión sin título',
back: 'Atrás',
+ notFound: 'Máquina no encontrada',
+ unknownMachine: 'máquina desconocida',
+ unknownPath: 'ruta desconocida',
},
message: {
@@ -747,6 +961,10 @@ export const es: TranslationStructure = {
unknownTime: 'tiempo desconocido',
},
+ chatFooter: {
+ permissionsTerminalOnly: 'Los permisos se muestran solo en el terminal. Restablece o envía un mensaje para controlar desde la app.',
+ },
+
codex: {
// Codex permission dialog buttons
permissions: {
@@ -773,6 +991,7 @@ export const es: TranslationStructure = {
textCopied: 'Texto copiado al portapapeles',
failedToCopy: 'Error al copiar el texto al portapapeles',
noTextToCopy: 'No hay texto disponible para copiar',
+ failedToOpen: 'No se pudo abrir la selección de texto. Intenta de nuevo.',
},
markdown: {
@@ -793,11 +1012,14 @@ export const es: TranslationStructure = {
edit: 'Editar artefacto',
delete: 'Eliminar',
updateError: 'No se pudo actualizar el artefacto. Por favor, intenta de nuevo.',
+ deleteError: 'No se pudo eliminar el artefacto. Intenta de nuevo.',
notFound: 'Artefacto no encontrado',
discardChanges: '¿Descartar cambios?',
discardChangesDescription: 'Tienes cambios sin guardar. ¿Estás seguro de que quieres descartarlos?',
deleteConfirm: '¿Eliminar artefacto?',
deleteConfirmDescription: 'Esta acción no se puede deshacer',
+ noContent: 'Sin contenido',
+ untitled: 'Sin título',
titleLabel: 'TÍTULO',
titlePlaceholder: 'Ingresa un título para tu artefacto',
bodyLabel: 'CONTENIDO',
@@ -883,6 +1105,45 @@ export const es: TranslationStructure = {
friendAcceptedGeneric: 'Solicitud de amistad aceptada',
},
+ apiKeys: {
+ addTitle: 'Nueva clave API',
+ savedTitle: 'Claves API guardadas',
+ badgeReady: 'Clave API',
+ badgeRequired: 'Se requiere clave API',
+ addSubtitle: 'Agregar una clave API guardada',
+ noneTitle: 'Ninguna',
+ noneSubtitle: 'Usa el entorno de la máquina o ingresa una clave para esta sesión',
+ emptyTitle: 'No hay claves guardadas',
+ emptySubtitle: 'Agrega una para usar perfiles con clave API sin configurar variables de entorno en la máquina.',
+ savedHiddenSubtitle: 'Guardada (valor oculto)',
+ defaultLabel: 'Predeterminada',
+ fields: {
+ name: 'Nombre',
+ value: 'Valor',
+ },
+ placeholders: {
+ nameExample: 'p. ej., Work OpenAI',
+ },
+ validation: {
+ nameRequired: 'El nombre es obligatorio.',
+ valueRequired: 'El valor es obligatorio.',
+ },
+ actions: {
+ replace: 'Reemplazar',
+ replaceValue: 'Reemplazar valor',
+ setDefault: 'Establecer como predeterminada',
+ unsetDefault: 'Quitar como predeterminada',
+ },
+ prompts: {
+ renameTitle: 'Renombrar clave API',
+ renameDescription: 'Actualiza el nombre descriptivo de esta clave.',
+ replaceValueTitle: 'Reemplazar valor de la clave API',
+ replaceValueDescription: 'Pega el nuevo valor de la clave API. Este valor no se mostrará de nuevo después de guardarlo.',
+ deleteTitle: 'Eliminar clave API',
+ deleteConfirm: ({ name }: { name: string }) => `¿Eliminar “${name}”? Esto no se puede deshacer.`,
+ },
+ },
+
profiles: {
// Profile management feature
title: 'Perfiles',
@@ -903,9 +1164,214 @@ 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',
+ custom: 'Personalizado',
+ builtInSaveAsHint: 'Guardar un perfil integrado crea un nuevo perfil personalizado.',
+ builtInNames: {
+ anthropic: 'Anthropic (Predeterminado)',
+ deepseek: 'DeepSeek (Razonamiento)',
+ 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: '(Copia)',
+ duplicateName: 'Ya existe un perfil con este nombre',
+ setupInstructions: {
+ title: 'Instrucciones de configuración',
+ viewOfficialGuide: 'Ver la guía oficial de configuración',
+ },
+ machineLogin: {
+ title: 'Se requiere iniciar sesión en la máquina',
+ subtitle: 'Este perfil depende de una caché de inicio de sesión del CLI en la máquina seleccionada.',
+ claudeCode: {
+ title: 'Claude Code',
+ instructions: 'Ejecuta `claude` y luego escribe `/login` para iniciar sesión.',
+ warning: 'Nota: establecer `ANTHROPIC_AUTH_TOKEN` sobrescribe el inicio de sesión del CLI.',
+ },
+ codex: {
+ title: 'Codex',
+ instructions: 'Ejecuta `codex login` para iniciar sesión.',
+ },
+ geminiCli: {
+ title: 'Gemini CLI',
+ instructions: 'Ejecuta `gemini auth` para iniciar sesión.',
+ },
+ },
+ requirements: {
+ apiKeyRequired: 'Clave API',
+ configured: 'Configurada en la máquina',
+ notConfigured: 'No configurada',
+ checking: 'Comprobando…',
+ modalTitle: 'Se requiere clave API',
+ modalBody: 'Este perfil requiere una clave API.\n\nOpciones disponibles:\n• Usar entorno de la máquina (recomendado)\n• Usar una clave guardada en la configuración de la app\n• Ingresar una clave solo para esta sesión',
+ sectionTitle: 'Requisitos',
+ sectionSubtitle: 'Estos campos se usan para comprobar el estado y evitar fallos inesperados.',
+ secretEnvVarPromptDescription: 'Ingresa el nombre de la variable de entorno secreta requerida (p. ej., OPENAI_API_KEY).',
+ modalHelpWithEnv: ({ env }: { env: string }) => `Este perfil necesita ${env}. Elige una opción abajo.`,
+ modalHelpGeneric: 'Este perfil necesita una clave API. Elige una opción abajo.',
+ modalRecommendation: 'Recomendado: configura la clave en el entorno del daemon en tu computadora (para no tener que pegarla de nuevo). Luego reinicia el daemon para que tome la nueva variable de entorno.',
+ chooseOptionTitle: 'Elige una opción',
+ machineEnvStatus: {
+ theMachine: 'la máquina',
+ checkFor: ({ env }: { env: string }) => `Comprobar ${env}`,
+ checking: ({ env }: { env: string }) => `Comprobando ${env}…`,
+ found: ({ env, machine }: { env: string; machine: string }) => `${env} encontrado en ${machine}`,
+ notFound: ({ env, machine }: { env: string; machine: string }) => `${env} no encontrado en ${machine}`,
+ },
+ machineEnvSubtitle: {
+ checking: 'Comprobando el entorno del daemon…',
+ found: 'Encontrado en el entorno del daemon en la máquina.',
+ notFound: 'Configúralo en el entorno del daemon en la máquina y reinicia el daemon.',
+ },
+ options: {
+ none: {
+ title: 'Ninguna',
+ subtitle: 'No requiere clave API ni inicio de sesión por CLI.',
+ },
+ apiKeyEnv: {
+ subtitle: 'Requiere una clave API que se inyectará al iniciar la sesión.',
+ },
+ machineLogin: {
+ subtitle: 'Requiere iniciar sesión mediante un CLI en la máquina de destino.',
+ longSubtitle: 'Requiere haber iniciado sesión mediante el CLI para el backend de IA que elijas en la máquina de destino.',
+ },
+ useMachineEnvironment: {
+ title: 'Usar entorno de la máquina',
+ subtitleWithEnv: ({ env }: { env: string }) => `Usar ${env} del entorno del daemon.`,
+ subtitleGeneric: 'Usar la clave del entorno del daemon.',
+ },
+ useSavedApiKey: {
+ title: 'Usar una clave API guardada',
+ subtitle: 'Selecciona (o agrega) una clave guardada en la app.',
+ },
+ enterOnce: {
+ title: 'Ingresar una clave',
+ subtitle: 'Pega una clave solo para esta sesión (no se guardará).',
+ },
+ },
+ apiKeyEnvVar: {
+ title: 'Variable de entorno de clave API',
+ subtitle: 'Ingresa el nombre de la variable de entorno que este proveedor espera para su clave API (p. ej., OPENAI_API_KEY).',
+ label: 'Nombre de la variable de entorno',
+ },
+ sections: {
+ machineEnvironment: 'Entorno de la máquina',
+ useOnceTitle: 'Usar una vez',
+ useOnceFooter: 'Pega una clave solo para esta sesión. No se guardará.',
+ },
+ actions: {
+ useMachineEnvironment: {
+ subtitle: 'Comenzar con la clave ya presente en la máquina.',
+ },
+ useOnceButton: 'Usar una vez (solo sesió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',
+ itemTitle: 'Máquina de vista previa para variables de entorno',
+ selectMachine: 'Seleccionar máquina',
+ resolveSubtitle: 'Se usa solo para previsualizar los valores resueltos abajo (no cambia lo que se guarda).',
+ selectSubtitle: 'Selecciona una máquina para previsualizar los valores resueltos abajo.',
+ },
+ 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',
+ secretToggleLabel: 'Secreto',
+ secretToggleSubtitle: 'Oculta el valor en la UI y evita obtenerlo de la máquina para la vista previa.',
+ secretToggleEnforcedByDaemon: 'Impuesto por el daemon',
+ secretToggleResetToAuto: 'Restablecer a automático',
+ 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..de9ead16f 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,11 @@ export const it: TranslationStructure = {
retry: 'Riprova',
delete: 'Elimina',
optional: 'opzionale',
+ noMatches: 'Nessuna corrispondenza',
+ all: 'Tutti',
+ machine: 'macchina',
+ clearSearch: 'Cancella ricerca',
+ refresh: 'Aggiorna',
saveAs: 'Salva con nome',
},
@@ -90,9 +100,214 @@ 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',
+ custom: 'Personalizzato',
+ builtInSaveAsHint: 'Salvare un profilo integrato crea un nuovo profilo personalizzato.',
+ builtInNames: {
+ anthropic: 'Anthropic (Predefinito)',
+ deepseek: 'DeepSeek (Ragionamento)',
+ 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: '(Copia)',
+ duplicateName: 'Esiste già un profilo con questo nome',
+ setupInstructions: {
+ title: 'Istruzioni di configurazione',
+ viewOfficialGuide: 'Visualizza la guida ufficiale di configurazione',
+ },
+ machineLogin: {
+ title: 'Login richiesto sulla macchina',
+ subtitle: 'Questo profilo si basa su una cache di login del CLI sulla macchina selezionata.',
+ claudeCode: {
+ title: 'Claude Code',
+ instructions: 'Esegui `claude`, poi digita `/login` per accedere.',
+ warning: 'Nota: impostare `ANTHROPIC_AUTH_TOKEN` sostituisce il login del CLI.',
+ },
+ codex: {
+ title: 'Codex',
+ instructions: 'Esegui `codex login` per accedere.',
+ },
+ geminiCli: {
+ title: 'Gemini CLI',
+ instructions: 'Esegui `gemini auth` per accedere.',
+ },
+ },
+ requirements: {
+ apiKeyRequired: 'Chiave API',
+ configured: 'Configurata sulla macchina',
+ notConfigured: 'Non configurata',
+ checking: 'Verifica…',
+ modalTitle: 'Chiave API richiesta',
+ modalBody: 'Questo profilo richiede una chiave API.\n\nOpzioni supportate:\n• Usa ambiente della macchina (consigliato)\n• Usa chiave salvata nelle impostazioni dell’app\n• Inserisci una chiave solo per questa sessione',
+ sectionTitle: 'Requisiti',
+ sectionSubtitle: 'Questi campi servono per verificare lo stato e evitare fallimenti inattesi.',
+ secretEnvVarPromptDescription: 'Inserisci il nome della variabile d’ambiente segreta richiesta (es. OPENAI_API_KEY).',
+ modalHelpWithEnv: ({ env }: { env: string }) => `Questo profilo richiede ${env}. Scegli un’opzione qui sotto.`,
+ modalHelpGeneric: 'Questo profilo richiede una chiave API. Scegli un’opzione qui sotto.',
+ modalRecommendation: 'Consigliato: imposta la chiave nell’ambiente del daemon sul tuo computer (così non dovrai incollarla di nuovo). Poi riavvia il daemon per caricare la nuova variabile d’ambiente.',
+ chooseOptionTitle: 'Scegli un’opzione',
+ machineEnvStatus: {
+ theMachine: 'la macchina',
+ checkFor: ({ env }: { env: string }) => `Controlla ${env}`,
+ checking: ({ env }: { env: string }) => `Verifica ${env}…`,
+ found: ({ env, machine }: { env: string; machine: string }) => `${env} trovato su ${machine}`,
+ notFound: ({ env, machine }: { env: string; machine: string }) => `${env} non trovato su ${machine}`,
+ },
+ machineEnvSubtitle: {
+ checking: 'Verifica ambiente del daemon…',
+ found: 'Trovato nell’ambiente del daemon sulla macchina.',
+ notFound: 'Impostalo nell’ambiente del daemon sulla macchina e riavvia il daemon.',
+ },
+ options: {
+ none: {
+ title: 'Nessuno',
+ subtitle: 'Non richiede chiave API né login CLI.',
+ },
+ apiKeyEnv: {
+ subtitle: 'Richiede una chiave API da iniettare all’avvio della sessione.',
+ },
+ machineLogin: {
+ subtitle: 'Richiede essere autenticati tramite un CLI sulla macchina di destinazione.',
+ longSubtitle: 'Richiede essere autenticati tramite il CLI per il backend IA scelto sulla macchina di destinazione.',
+ },
+ useMachineEnvironment: {
+ title: 'Usa ambiente della macchina',
+ subtitleWithEnv: ({ env }: { env: string }) => `Usa ${env} dall’ambiente del daemon.`,
+ subtitleGeneric: 'Usa la chiave dall’ambiente del daemon.',
+ },
+ useSavedApiKey: {
+ title: 'Usa una chiave API salvata',
+ subtitle: 'Seleziona (o aggiungi) una chiave salvata nell’app.',
+ },
+ enterOnce: {
+ title: 'Inserisci una chiave',
+ subtitle: 'Incolla una chiave solo per questa sessione (non verrà salvata).',
+ },
+ },
+ apiKeyEnvVar: {
+ title: 'Variabile d’ambiente della chiave API',
+ subtitle: 'Inserisci il nome della variabile d’ambiente che questo provider si aspetta per la chiave API (es. OPENAI_API_KEY).',
+ label: 'Nome variabile d’ambiente',
+ },
+ sections: {
+ machineEnvironment: 'Ambiente della macchina',
+ useOnceTitle: 'Usa una volta',
+ useOnceFooter: 'Incolla una chiave solo per questa sessione. Non verrà salvata.',
+ },
+ actions: {
+ useMachineEnvironment: {
+ subtitle: 'Inizia con la chiave già presente sulla macchina.',
+ },
+ useOnceButton: 'Usa una volta (solo sessione)',
+ },
+ },
+ 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',
+ itemTitle: 'Macchina di anteprima per variabili d\'ambiente',
+ selectMachine: 'Seleziona macchina',
+ resolveSubtitle: 'Usata solo per l\'anteprima dei valori risolti sotto (non cambia ciò che viene salvato).',
+ selectSubtitle: 'Seleziona una macchina per l\'anteprima dei valori risolti sotto.',
+ },
+ 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',
+ secretToggleLabel: 'Segreto',
+ secretToggleSubtitle: 'Nasconde il valore nella UI ed evita di recuperarlo dalla macchina per l\'anteprima.',
+ secretToggleEnforcedByDaemon: 'Imposto dal daemon',
+ secretToggleResetToAuto: 'Ripristina su automatico',
+ 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: 'Alternativa',
+ missing: 'Mancante',
+ },
+ },
+ },
delete: {
title: 'Elimina profilo',
message: ({ name }: { name: string }) => `Sei sicuro di voler eliminare "${name}"? Questa azione non può essere annullata.`,
@@ -125,6 +340,15 @@ export const it: TranslationStructure = {
enterSecretKey: 'Inserisci la chiave segreta',
invalidSecretKey: 'Chiave segreta non valida. Controlla e riprova.',
enterUrlManually: 'Inserisci URL manualmente',
+ terminalUrlPlaceholder: 'happy://terminal?...',
+ restoreQrInstructions: '1. Apri Happy sul tuo dispositivo mobile\n2. Vai su Impostazioni → Account\n3. Tocca "Collega nuovo dispositivo"\n4. Scansiona questo codice QR',
+ restoreWithSecretKeyInstead: 'Ripristina con chiave segreta',
+ restoreWithSecretKeyDescription: 'Inserisci la chiave segreta per ripristinare l’accesso al tuo account.',
+ secretKeyPlaceholder: 'XXXXX-XXXXX-XXXXX...',
+ unsupported: {
+ connectTitle: ({ name }: { name: string }) => `Connetti ${name}`,
+ runCommandInTerminal: 'Esegui il seguente comando nel terminale:',
+ },
},
settings: {
@@ -165,6 +389,8 @@ export const it: TranslationStructure = {
usageSubtitle: 'Vedi il tuo utilizzo API e i costi',
profiles: 'Profili',
profilesSubtitle: 'Gestisci i profili delle variabili ambiente per le sessioni',
+ apiKeys: 'Chiavi API',
+ apiKeysSubtitle: 'Gestisci le chiavi API salvate (non verranno più mostrate dopo l’inserimento)',
// Dynamic settings messages
accountConnected: ({ service }: { service: string }) => `Account ${service} collegato`,
@@ -202,6 +428,21 @@ export const it: TranslationStructure = {
wrapLinesInDiffsDescription: 'A capo delle righe lunghe invece dello scorrimento orizzontale nelle viste diff',
alwaysShowContextSize: 'Mostra sempre dimensione contesto',
alwaysShowContextSizeDescription: 'Mostra l\'uso del contesto anche quando non è vicino al limite',
+ agentInputActionBarLayout: 'Barra azioni di input',
+ agentInputActionBarLayoutDescription: 'Scegli come vengono mostrati i chip azione sopra il campo di input',
+ agentInputActionBarLayoutOptions: {
+ auto: 'Auto',
+ wrap: 'A capo',
+ scroll: 'Scorrevole',
+ collapsed: 'Compresso',
+ },
+ agentInputChipDensity: 'Densità dei chip azione',
+ agentInputChipDensityDescription: 'Scegli se i chip azione mostrano etichette o icone',
+ agentInputChipDensityOptions: {
+ auto: 'Auto',
+ labels: 'Etichette',
+ icons: 'Solo icone',
+ },
avatarStyle: 'Stile avatar',
avatarStyleDescription: 'Scegli l\'aspetto dell\'avatar di sessione',
avatarOptions: {
@@ -222,6 +463,22 @@ export const it: TranslationStructure = {
experimentalFeatures: 'Funzionalità sperimentali',
experimentalFeaturesEnabled: 'Funzionalità sperimentali abilitate',
experimentalFeaturesDisabled: 'Usando solo funzionalità stabili',
+ experimentalOptions: 'Opzioni sperimentali',
+ experimentalOptionsDescription: 'Scegli quali funzionalità sperimentali sono abilitate.',
+ expGemini: 'Gemini',
+ expGeminiSubtitle: 'Enable Gemini CLI sessions and Gemini-related UI',
+ expUsageReporting: 'Usage reporting',
+ expUsageReportingSubtitle: 'Enable usage and token reporting screens',
+ expFileViewer: 'File viewer',
+ expFileViewerSubtitle: 'Enable the session file viewer entrypoint',
+ expShowThinkingMessages: 'Show thinking messages',
+ expShowThinkingMessagesSubtitle: 'Show assistant thinking/status messages in chat',
+ expSessionType: 'Session type selector',
+ expSessionTypeSubtitle: 'Show the session type selector (simple vs worktree)',
+ expZen: 'Zen',
+ expZenSubtitle: 'Enable the Zen navigation entry',
+ expVoiceAuthFlow: 'Voice auth flow',
+ expVoiceAuthFlowSubtitle: 'Use authenticated voice token flow (paywall-aware)',
webFeatures: 'Funzionalità web',
webFeaturesDescription: 'Funzionalità disponibili solo nella versione web dell\'app.',
enterToSend: 'Invio con Enter',
@@ -230,13 +487,22 @@ export const it: TranslationStructure = {
commandPalette: 'Palette comandi',
commandPaletteEnabled: 'Premi ⌘K per aprire',
commandPaletteDisabled: 'Accesso rapido ai comandi disabilitato',
- markdownCopyV2: 'Markdown Copy v2',
+ markdownCopyV2: 'Copia Markdown v2',
markdownCopyV2Subtitle: 'Pressione lunga apre la finestra di copia',
hideInactiveSessions: 'Nascondi sessioni inattive',
hideInactiveSessionsSubtitle: 'Mostra solo le chat attive nella tua lista',
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 +555,27 @@ export const it: TranslationStructure = {
newSession: {
// Used by new-session screen and launch flows
title: 'Avvia nuova sessione',
+ selectAiProfileTitle: 'Seleziona profilo IA',
+ selectAiProfileDescription: 'Seleziona un profilo IA per applicare variabili d’ambiente e valori predefiniti alla sessione.',
+ changeProfile: 'Cambia profilo',
+ aiBackendSelectedByProfile: 'Il backend IA è determinato dal profilo. Per cambiarlo, seleziona un profilo diverso.',
+ selectAiBackendTitle: 'Seleziona backend IA',
+ aiBackendLimitedByProfileAndMachineClis: 'Limitato dal profilo selezionato e dalle CLI disponibili su questa macchina.',
+ aiBackendSelectWhichAiRuns: 'Seleziona quale IA esegue la sessione.',
+ aiBackendNotCompatibleWithSelectedProfile: 'Non compatibile con il profilo selezionato.',
+ aiBackendCliNotDetectedOnMachine: ({ cli }: { cli: string }) => `CLI di ${cli} non rilevata su questa macchina.`,
+ selectMachineTitle: 'Seleziona macchina',
+ selectMachineDescription: 'Scegli dove viene eseguita questa sessione.',
+ selectPathTitle: 'Seleziona percorso',
+ selectWorkingDirectoryTitle: 'Seleziona directory di lavoro',
+ selectWorkingDirectoryDescription: 'Scegli la cartella usata per comandi e contesto.',
+ selectPermissionModeTitle: 'Seleziona modalità di permessi',
+ selectPermissionModeDescription: 'Controlla quanto rigidamente le azioni richiedono approvazione.',
+ selectModelTitle: 'Seleziona modello IA',
+ selectModelDescription: 'Scegli il modello usato da questa sessione.',
+ selectSessionTypeTitle: 'Seleziona tipo di sessione',
+ selectSessionTypeDescription: 'Scegli una sessione semplice o una collegata a una worktree Git.',
+ 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,12 +591,46 @@ 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',
- worktree: 'Worktree',
+ worktree: 'Worktree (Git)',
comingSoon: 'In arrivo',
},
+ profileAvailability: {
+ requiresAgent: ({ agent }: { agent: string }) => `Richiede ${agent}`,
+ cliNotDetected: ({ cli }: { cli: string }) => `CLI di ${cli} non rilevata`,
+ },
+ cliBanners: {
+ cliNotDetectedTitle: ({ cli }: { cli: string }) => `CLI di ${cli} non rilevata`,
+ dontShowFor: 'Non mostrare questo avviso per',
+ thisMachine: 'questa macchina',
+ anyMachine: 'qualsiasi macchina',
+ installCommand: ({ command }: { command: string }) => `Installa: ${command} •`,
+ installCliIfAvailable: ({ cli }: { cli: string }) => `Installa la CLI di ${cli} se disponibile •`,
+ viewInstallationGuide: 'Vedi guida di installazione →',
+ viewGeminiDocs: 'Vedi documentazione Gemini →',
+ },
worktree: {
creating: ({ name }: { name: string }) => `Creazione worktree '${name}'...`,
notGitRepo: 'Le worktree richiedono un repository git',
@@ -334,6 +655,19 @@ export const it: TranslationStructure = {
commandPalette: {
placeholder: 'Digita un comando o cerca...',
+ noCommandsFound: 'Nessun comando trovato',
+ },
+
+ commandView: {
+ completedWithNoOutput: '[Comando completato senza output]',
+ },
+
+ voiceAssistant: {
+ connecting: 'Connessione...',
+ active: 'Assistente vocale attivo',
+ connectionError: 'Errore di connessione',
+ label: 'Assistente vocale',
+ tapToEnd: 'Tocca per terminare',
},
server: {
@@ -365,6 +699,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',
@@ -385,9 +720,12 @@ export const it: TranslationStructure = {
path: 'Percorso',
operatingSystem: 'Sistema operativo',
processId: 'ID processo',
- happyHome: 'Happy Home',
+ happyHome: 'Home di Happy',
copyMetadata: 'Copia metadati',
agentState: 'Stato agente',
+ rawJsonDevMode: 'JSON grezzo (modalità sviluppatore)',
+ sessionStatus: 'Stato sessione',
+ fullSessionObject: 'Oggetto sessione completo',
controlledByUser: 'Controllato dall\'utente',
pendingRequests: 'Richieste in sospeso',
activity: 'Attività',
@@ -415,16 +753,52 @@ export const it: TranslationStructure = {
runIt: 'Avviala',
scanQrCode: 'Scansiona il codice QR',
openCamera: 'Apri fotocamera',
+ installCommand: '$ npm i -g happy-coder',
+ runCommand: '$ happy',
+ },
+ emptyMessages: {
+ noMessagesYet: 'Ancora nessun messaggio',
+ created: ({ time }: { time: string }) => `Creato ${time}`,
+ },
+ emptySessionsTablet: {
+ noActiveSessions: 'Nessuna sessione attiva',
+ startNewSessionDescription: 'Avvia una nuova sessione su una delle tue macchine collegate.',
+ startNewSessionButton: 'Avvia nuova sessione',
+ openTerminalToStart: 'Apri un nuovo terminale sul computer per avviare una sessione.',
+ },
+ },
+
+ zen: {
+ title: 'Zen',
+ add: {
+ placeholder: 'Cosa bisogna fare?',
+ },
+ home: {
+ noTasksYet: 'Ancora nessuna attività. Tocca + per aggiungerne una.',
+ },
+ view: {
+ workOnTask: 'Lavora sul compito',
+ clarify: 'Chiarisci',
+ delete: 'Elimina',
+ linkedSessions: 'Sessioni collegate',
+ tapTaskTextToEdit: 'Tocca il testo del compito per modificarlo',
},
},
agentInput: {
+ envVars: {
+ title: 'Var env',
+ titleWithCount: ({ count }: { count: number }) => `Var env (${count})`,
+ },
permissionMode: {
title: 'MODALITÀ PERMESSI',
default: 'Predefinito',
acceptEdits: 'Accetta modifiche',
plan: 'Modalità piano',
bypassPermissions: 'Modalità YOLO',
+ badgeAccept: 'Accetta',
+ badgePlan: 'Piano',
+ badgeYolo: 'YOLO',
badgeAcceptAllEdits: 'Accetta tutte le modifiche',
badgeBypassAllPermissions: 'Bypassa tutti i permessi',
badgePlanMode: 'Modalità piano',
@@ -461,12 +835,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`,
@@ -475,6 +864,11 @@ export const it: TranslationStructure = {
fileLabel: 'FILE',
folderLabel: 'CARTELLA',
},
+ actionMenu: {
+ title: 'AZIONI',
+ files: 'File',
+ stop: 'Ferma',
+ },
noMachinesAvailable: 'Nessuna macchina',
},
@@ -537,6 +931,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})`,
@@ -575,7 +973,7 @@ export const it: TranslationStructure = {
loadingFile: ({ fileName }: { fileName: string }) => `Caricamento ${fileName}...`,
binaryFile: 'File binario',
cannotDisplayBinary: 'Impossibile mostrare il contenuto del file binario',
- diff: 'Diff',
+ diff: 'Differenze',
file: 'File',
fileEmpty: 'File vuoto',
noChanges: 'Nessuna modifica da mostrare',
@@ -695,6 +1093,11 @@ export const it: TranslationStructure = {
deviceLinkedSuccessfully: 'Dispositivo collegato con successo',
terminalConnectedSuccessfully: 'Terminale collegato con successo',
invalidAuthUrl: 'URL di autenticazione non valido',
+ microphoneAccessRequiredTitle: 'Accesso al microfono richiesto',
+ microphoneAccessRequiredRequestPermission: 'Happy ha bisogno dell’accesso al microfono per la chat vocale. Concedi il permesso quando richiesto.',
+ microphoneAccessRequiredEnableInSettings: 'Happy ha bisogno dell’accesso al microfono per la chat vocale. Abilita l’accesso al microfono nelle impostazioni del dispositivo.',
+ microphoneAccessRequiredBrowserInstructions: 'Consenti l’accesso al microfono nelle impostazioni del browser. Potrebbe essere necessario fare clic sull’icona del lucchetto nella barra degli indirizzi e abilitare il permesso del microfono per questo sito.',
+ openSettings: 'Apri impostazioni',
developerMode: 'Modalità sviluppatore',
developerModeEnabled: 'Modalità sviluppatore attivata',
developerModeDisabled: 'Modalità sviluppatore disattivata',
@@ -746,9 +1149,18 @@ export const it: TranslationStructure = {
launchNewSessionInDirectory: 'Avvia nuova sessione nella directory',
offlineUnableToSpawn: 'Avvio disabilitato quando la macchina è offline',
offlineHelp: '• Assicurati che il tuo computer sia online\n• Esegui `happy daemon status` per diagnosticare\n• Stai usando l\'ultima versione della CLI? Aggiorna con `npm install -g happy-coder@latest`',
- daemon: 'Daemon',
+ daemon: 'Demone',
status: 'Stato',
stopDaemon: 'Arresta daemon',
+ stopDaemonConfirmTitle: 'Arrestare il daemon?',
+ stopDaemonConfirmBody: 'Non potrai avviare nuove sessioni su questa macchina finché non riavvii il daemon sul computer. Le sessioni correnti resteranno attive.',
+ daemonStoppedTitle: 'Daemon arrestato',
+ stopDaemonFailed: 'Impossibile arrestare il daemon. Potrebbe non essere in esecuzione.',
+ renameTitle: 'Rinomina macchina',
+ renameDescription: 'Assegna a questa macchina un nome personalizzato. Lascia vuoto per usare l’hostname predefinito.',
+ renamePlaceholder: 'Inserisci nome macchina',
+ renamedSuccess: 'Macchina rinominata correttamente',
+ renameFailed: 'Impossibile rinominare la macchina',
lastKnownPid: 'Ultimo PID noto',
lastKnownHttpPort: 'Ultima porta HTTP nota',
startedAt: 'Avviato alle',
@@ -765,8 +1177,15 @@ export const it: TranslationStructure = {
lastSeen: 'Ultimo accesso',
never: 'Mai',
metadataVersion: 'Versione metadati',
+ detectedClis: 'CLI rilevate',
+ detectedCliNotDetected: 'Non rilevata',
+ detectedCliUnknown: 'Sconosciuta',
+ detectedCliNotSupported: 'Non supportata (aggiorna happy-cli)',
untitledSession: 'Sessione senza titolo',
back: 'Indietro',
+ notFound: 'Macchina non trovata',
+ unknownMachine: 'macchina sconosciuta',
+ unknownPath: 'percorso sconosciuto',
},
message: {
@@ -776,6 +1195,10 @@ export const it: TranslationStructure = {
unknownTime: 'ora sconosciuta',
},
+ chatFooter: {
+ permissionsTerminalOnly: 'I permessi vengono mostrati solo nel terminale. Reimposta o invia un messaggio per controllare dall’app.',
+ },
+
codex: {
// Codex permission dialog buttons
permissions: {
@@ -802,6 +1225,7 @@ export const it: TranslationStructure = {
textCopied: 'Testo copiato negli appunti',
failedToCopy: 'Impossibile copiare il testo negli appunti',
noTextToCopy: 'Nessun testo disponibile da copiare',
+ failedToOpen: 'Impossibile aprire la selezione del testo. Riprova.',
},
markdown: {
@@ -822,11 +1246,14 @@ export const it: TranslationStructure = {
edit: 'Modifica artefatto',
delete: 'Elimina',
updateError: 'Impossibile aggiornare l\'artefatto. Riprova.',
+ deleteError: 'Impossibile eliminare l’artefatto. Riprova.',
notFound: 'Artefatto non trovato',
discardChanges: 'Scartare le modifiche?',
discardChangesDescription: 'Hai modifiche non salvate. Sei sicuro di volerle scartare?',
deleteConfirm: 'Eliminare artefatto?',
deleteConfirmDescription: 'Questa azione non può essere annullata',
+ noContent: 'Nessun contenuto',
+ untitled: 'Senza titolo',
titleLabel: 'TITOLO',
titlePlaceholder: 'Inserisci un titolo per il tuo artefatto',
bodyLabel: 'CONTENUTO',
@@ -904,6 +1331,45 @@ export const it: TranslationStructure = {
noData: 'Nessun dato di utilizzo disponibile',
},
+ apiKeys: {
+ addTitle: 'Nuova chiave API',
+ savedTitle: 'Chiavi API salvate',
+ badgeReady: 'Chiave API',
+ badgeRequired: 'Chiave API richiesta',
+ addSubtitle: 'Aggiungi una chiave API salvata',
+ noneTitle: 'Nessuna',
+ noneSubtitle: 'Usa l’ambiente della macchina o inserisci una chiave per questa sessione',
+ emptyTitle: 'Nessuna chiave salvata',
+ emptySubtitle: 'Aggiungine una per usare profili con chiave API senza impostare variabili d’ambiente sulla macchina.',
+ savedHiddenSubtitle: 'Salvata (valore nascosto)',
+ defaultLabel: 'Predefinita',
+ fields: {
+ name: 'Nome',
+ value: 'Valore',
+ },
+ placeholders: {
+ nameExample: 'es. Work OpenAI',
+ },
+ validation: {
+ nameRequired: 'Il nome è obbligatorio.',
+ valueRequired: 'Il valore è obbligatorio.',
+ },
+ actions: {
+ replace: 'Sostituisci',
+ replaceValue: 'Sostituisci valore',
+ setDefault: 'Imposta come predefinita',
+ unsetDefault: 'Rimuovi predefinita',
+ },
+ prompts: {
+ renameTitle: 'Rinomina chiave API',
+ renameDescription: 'Aggiorna il nome descrittivo di questa chiave.',
+ replaceValueTitle: 'Sostituisci valore della chiave API',
+ replaceValueDescription: 'Incolla il nuovo valore della chiave API. Questo valore non verrà mostrato di nuovo dopo il salvataggio.',
+ deleteTitle: 'Elimina chiave API',
+ deleteConfirm: ({ name }: { name: string }) => `Eliminare “${name}”? Questa azione non può essere annullata.`,
+ },
+ },
+
feed: {
// Feed notifications for friend requests and acceptances
friendRequestFrom: ({ name }: { name: string }) => `${name} ti ha inviato una richiesta di amicizia`,
diff --git a/sources/text/translations/ja.ts b/sources/text/translations/ja.ts
index fe1007884..65f649ba4 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,11 @@ export const ja: TranslationStructure = {
retry: '再試行',
delete: '削除',
optional: '任意',
+ noMatches: '一致するものがありません',
+ all: 'すべて',
+ machine: 'マシン',
+ clearSearch: '検索をクリア',
+ refresh: '更新',
saveAs: '名前を付けて保存',
},
@@ -93,9 +93,214 @@ export const ja: TranslationStructure = {
enterTmuxTempDir: '一時ディレクトリのパスを入力',
tmuxUpdateEnvironment: '環境を自動更新',
nameRequired: 'プロファイル名は必須です',
- deleteConfirm: 'プロファイル「{name}」を削除してもよろしいですか?',
+ deleteConfirm: ({ name }: { name: string }) => `プロファイル「${name}」を削除してもよろしいですか?`,
editProfile: 'プロファイルを編集',
addProfileTitle: '新しいプロファイルを追加',
+ builtIn: '組み込み',
+ custom: 'カスタム',
+ builtInSaveAsHint: '組み込みプロファイルを保存すると、新しいカスタムプロファイルが作成されます。',
+ builtInNames: {
+ anthropic: 'Anthropic(デフォルト)',
+ deepseek: 'DeepSeek(推論)',
+ 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: '(コピー)',
+ duplicateName: '同じ名前のプロファイルが既に存在します',
+ setupInstructions: {
+ title: 'セットアップ手順',
+ viewOfficialGuide: '公式セットアップガイドを表示',
+ },
+ machineLogin: {
+ title: 'マシンでのログインが必要',
+ subtitle: 'このプロファイルは、選択したマシン上の CLI ログインキャッシュに依存します。',
+ claudeCode: {
+ title: 'Claude Code',
+ instructions: '`claude` を実行し、`/login` と入力してログインしてください。',
+ warning: '注意: `ANTHROPIC_AUTH_TOKEN` を設定すると CLI ログインを上書きします。',
+ },
+ codex: {
+ title: 'Codex',
+ instructions: '`codex login` を実行してログインしてください。',
+ },
+ geminiCli: {
+ title: 'Gemini CLI',
+ instructions: '`gemini auth` を実行してログインしてください。',
+ },
+ },
+ requirements: {
+ apiKeyRequired: 'APIキー',
+ configured: 'マシンで設定済み',
+ notConfigured: '未設定',
+ checking: '確認中…',
+ modalTitle: 'APIキーが必要です',
+ modalBody: 'このプロファイルにはAPIキーが必要です。\n\n利用可能な選択肢:\n• マシン環境を使用(推奨)\n• アプリ設定の保存済みキーを使用\n• このセッションのみキーを入力',
+ sectionTitle: '要件',
+ sectionSubtitle: 'これらの項目は事前チェックのために使用され、予期しない失敗を避けます。',
+ secretEnvVarPromptDescription: '必要な秘密環境変数名を入力してください(例: OPENAI_API_KEY)。',
+ modalHelpWithEnv: ({ env }: { env: string }) => `このプロファイルには${env}が必要です。以下から1つ選択してください。`,
+ modalHelpGeneric: 'このプロファイルにはAPIキーが必要です。以下から1つ選択してください。',
+ modalRecommendation: '推奨: コンピュータ上のデーモン環境にキーを設定してください(再度貼り付ける必要がなくなります)。その後デーモンを再起動して、新しい環境変数を読み込ませてください。',
+ chooseOptionTitle: '選択してください',
+ machineEnvStatus: {
+ theMachine: 'マシン',
+ checkFor: ({ env }: { env: string }) => `${env} を確認`,
+ checking: ({ env }: { env: string }) => `${env} を確認中…`,
+ found: ({ env, machine }: { env: string; machine: string }) => `${machine}で${env}が見つかりました`,
+ notFound: ({ env, machine }: { env: string; machine: string }) => `${machine}で${env}が見つかりません`,
+ },
+ machineEnvSubtitle: {
+ checking: 'デーモン環境を確認中…',
+ found: 'マシン上のデーモン環境で見つかりました。',
+ notFound: 'マシン上のデーモン環境に設定して、デーモンを再起動してください。',
+ },
+ options: {
+ none: {
+ title: 'なし',
+ subtitle: 'APIキーもCLIログインも不要です。',
+ },
+ apiKeyEnv: {
+ subtitle: 'セッション開始時に注入されるAPIキーが必要です。',
+ },
+ machineLogin: {
+ subtitle: 'ターゲットマシンでCLIからログインしている必要があります。',
+ longSubtitle: 'ターゲットマシンで選択したAIバックエンドのCLIにログインしている必要があります。',
+ },
+ useMachineEnvironment: {
+ title: 'マシン環境を使用',
+ subtitleWithEnv: ({ env }: { env: string }) => `デーモン環境から${env}を使用します。`,
+ subtitleGeneric: 'デーモン環境からキーを使用します。',
+ },
+ useSavedApiKey: {
+ title: '保存済みAPIキーを使用',
+ subtitle: 'アプリ内の保存済みキーを選択(または追加)します。',
+ },
+ enterOnce: {
+ title: 'キーを入力',
+ subtitle: 'このセッションのみキーを貼り付けます(保存されません)。',
+ },
+ },
+ apiKeyEnvVar: {
+ title: 'APIキーの環境変数',
+ subtitle: 'このプロバイダがAPIキーに期待する環境変数名を入力してください(例: OPENAI_API_KEY)。',
+ label: '環境変数名',
+ },
+ sections: {
+ machineEnvironment: 'マシン環境',
+ useOnceTitle: '一度だけ使用',
+ useOnceFooter: 'このセッションのみキーを貼り付けます。保存されません。',
+ },
+ actions: {
+ useMachineEnvironment: {
+ subtitle: 'マシンに既にあるキーを使用して開始します。',
+ },
+ useOnceButton: '一度だけ使用(セッションのみ)',
+ },
+ },
+ defaultSessionType: 'デフォルトのセッションタイプ',
+ defaultPermissionMode: {
+ title: 'デフォルトの権限モード',
+ descriptions: {
+ default: '権限を要求する',
+ acceptEdits: '編集を自動承認',
+ plan: '実行前に計画',
+ bypassPermissions: 'すべての権限をスキップ',
+ },
+ },
+ aiBackend: {
+ title: 'AIバックエンド',
+ selectAtLeastOneError: '少なくとも1つのAIバックエンドを選択してください。',
+ claudeSubtitle: 'Claude コマンドライン',
+ codexSubtitle: 'Codex コマンドライン',
+ geminiSubtitleExperimental: 'Gemini コマンドライン(実験)',
+ },
+ tmux: {
+ title: 'Tmux',
+ spawnSessionsTitle: 'Tmuxでセッションを起動',
+ spawnSessionsEnabledSubtitle: 'セッションは新しいtmuxウィンドウで起動します。',
+ spawnSessionsDisabledSubtitle: 'セッションは通常のシェルで起動します(tmux連携なし)',
+ sessionNamePlaceholder: '空 = 現在/最近のセッション',
+ tempDirPlaceholder: '/tmp(任意)',
+ },
+ previewMachine: {
+ title: 'マシンをプレビュー',
+ itemTitle: '環境変数のプレビュー用マシン',
+ 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: 'シークレット値 — セキュリティのため取得しません',
+ secretToggleLabel: 'シークレット',
+ secretToggleSubtitle: 'UIで値を非表示にし、プレビューのためにマシンから取得しません。',
+ secretToggleEnforcedByDaemon: 'デーモンで強制',
+ secretToggleResetToAuto: '自動に戻す',
+ 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}」を削除してもよろしいですか?この操作は元に戻せません。`,
@@ -128,6 +333,15 @@ export const ja: TranslationStructure = {
enterSecretKey: 'シークレットキーを入力してください',
invalidSecretKey: 'シークレットキーが無効です。確認して再試行してください。',
enterUrlManually: 'URLを手動で入力',
+ terminalUrlPlaceholder: 'happy://terminal?...',
+ restoreQrInstructions: '1. モバイル端末で Happy を開く\n2. 設定 → アカウント に移動\n3. 「新しいデバイスをリンク」をタップ\n4. この QR コードをスキャン',
+ restoreWithSecretKeyInstead: '秘密鍵で復元する',
+ restoreWithSecretKeyDescription: 'アカウントへのアクセスを復元するには秘密鍵を入力してください。',
+ secretKeyPlaceholder: 'XXXXX-XXXXX-XXXXX...',
+ unsupported: {
+ connectTitle: ({ name }: { name: string }) => `${name} を接続`,
+ runCommandInTerminal: 'ターミナルで次のコマンドを実行してください:',
+ },
},
settings: {
@@ -168,6 +382,8 @@ export const ja: TranslationStructure = {
usageSubtitle: 'API使用量とコストを確認',
profiles: 'プロファイル',
profilesSubtitle: 'セッション用の環境変数プロファイルを管理',
+ apiKeys: 'APIキー',
+ apiKeysSubtitle: '保存したAPIキーを管理(入力後は再表示されません)',
// Dynamic settings messages
accountConnected: ({ service }: { service: string }) => `${service}アカウントが接続されました`,
@@ -205,6 +421,21 @@ export const ja: TranslationStructure = {
wrapLinesInDiffsDescription: '差分表示で水平スクロールの代わりに長い行を折り返す',
alwaysShowContextSize: '常にコンテキストサイズを表示',
alwaysShowContextSizeDescription: '上限に近づいていなくてもコンテキスト使用量を表示',
+ agentInputActionBarLayout: '入力アクションバー',
+ agentInputActionBarLayoutDescription: '入力欄の上に表示するアクションチップの表示方法を選択します',
+ agentInputActionBarLayoutOptions: {
+ auto: '自動',
+ wrap: '折り返し',
+ scroll: 'スクロール',
+ collapsed: '折りたたみ',
+ },
+ agentInputChipDensity: 'アクションチップ密度',
+ agentInputChipDensityDescription: 'アクションチップをラベル表示にするかアイコン表示にするか選択します',
+ agentInputChipDensityOptions: {
+ auto: '自動',
+ labels: 'ラベル',
+ icons: 'アイコンのみ',
+ },
avatarStyle: 'アバタースタイル',
avatarStyleDescription: 'セッションアバターの外観を選択',
avatarOptions: {
@@ -225,6 +456,22 @@ export const ja: TranslationStructure = {
experimentalFeatures: '実験的機能',
experimentalFeaturesEnabled: '実験的機能が有効です',
experimentalFeaturesDisabled: '安定版機能のみを使用',
+ experimentalOptions: '実験オプション',
+ experimentalOptionsDescription: '有効にする実験的機能を選択します。',
+ expGemini: 'Gemini',
+ expGeminiSubtitle: 'Enable Gemini CLI sessions and Gemini-related UI',
+ expUsageReporting: 'Usage reporting',
+ expUsageReportingSubtitle: 'Enable usage and token reporting screens',
+ expFileViewer: 'File viewer',
+ expFileViewerSubtitle: 'Enable the session file viewer entrypoint',
+ expShowThinkingMessages: 'Show thinking messages',
+ expShowThinkingMessagesSubtitle: 'Show assistant thinking/status messages in chat',
+ expSessionType: 'Session type selector',
+ expSessionTypeSubtitle: 'Show the session type selector (simple vs worktree)',
+ expZen: 'Zen',
+ expZenSubtitle: 'Enable the Zen navigation entry',
+ expVoiceAuthFlow: 'Voice auth flow',
+ expVoiceAuthFlowSubtitle: 'Use authenticated voice token flow (paywall-aware)',
webFeatures: 'Web機能',
webFeaturesDescription: 'Webバージョンでのみ利用可能な機能。',
enterToSend: 'Enterで送信',
@@ -240,6 +487,15 @@ export const ja: TranslationStructure = {
enhancedSessionWizard: '拡張セッションウィザード',
enhancedSessionWizardEnabled: 'プロファイル優先セッションランチャーが有効',
enhancedSessionWizardDisabled: '標準セッションランチャーを使用',
+ profiles: 'AIプロファイル',
+ profilesEnabled: 'プロファイル選択を有効化',
+ profilesDisabled: 'プロファイル選択を無効化',
+ pickerSearch: 'ピッカー検索',
+ pickerSearchSubtitle: 'マシンとパスのピッカーに検索欄を表示',
+ machinePickerSearch: 'マシン検索',
+ machinePickerSearchSubtitle: 'マシンピッカーに検索欄を表示',
+ pathPickerSearch: 'パス検索',
+ pathPickerSearchSubtitle: 'パスピッカーに検索欄を表示',
},
errors: {
@@ -292,6 +548,27 @@ export const ja: TranslationStructure = {
newSession: {
// Used by new-session screen and launch flows
title: '新しいセッションを開始',
+ selectAiProfileTitle: 'AIプロファイルを選択',
+ selectAiProfileDescription: '環境変数とデフォルト設定をセッションに適用するため、AIプロファイルを選択してください。',
+ changeProfile: 'プロファイルを変更',
+ aiBackendSelectedByProfile: 'AIバックエンドはプロファイルで選択されています。変更するには別のプロファイルを選択してください。',
+ selectAiBackendTitle: 'AIバックエンドを選択',
+ aiBackendLimitedByProfileAndMachineClis: '選択したプロファイルと、このマシンで利用可能なCLIによって制限されます。',
+ aiBackendSelectWhichAiRuns: 'セッションで実行するAIを選択してください。',
+ aiBackendNotCompatibleWithSelectedProfile: '選択したプロファイルと互換性がありません。',
+ aiBackendCliNotDetectedOnMachine: ({ cli }: { cli: string }) => `このマシンで${cli} CLIが検出されませんでした。`,
+ selectMachineTitle: 'マシンを選択',
+ selectMachineDescription: 'このセッションを実行する場所を選択します。',
+ selectPathTitle: 'パスを選択',
+ selectWorkingDirectoryTitle: '作業ディレクトリを選択',
+ selectWorkingDirectoryDescription: 'コマンドとコンテキストに使用するフォルダを選択してください。',
+ selectPermissionModeTitle: '権限モードを選択',
+ selectPermissionModeDescription: '操作にどの程度承認が必要かを設定します。',
+ selectModelTitle: 'AIモデルを選択',
+ selectModelDescription: 'このセッションで使用するモデルを選択してください。',
+ selectSessionTypeTitle: 'セッションタイプを選択',
+ selectSessionTypeDescription: 'シンプルなセッション、またはGitのワークツリーに紐づくセッションを選択してください。',
+ searchPathsPlaceholder: 'パスを検索...',
noMachinesFound: 'マシンが見つかりません。まずコンピューターでHappyセッションを起動してください。',
allMachinesOffline: 'すべてのマシンがオフラインです',
machineDetails: 'マシンの詳細を表示 →',
@@ -307,12 +584,46 @@ 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: 'シンプル',
worktree: 'ワークツリー',
comingSoon: '近日公開',
},
+ profileAvailability: {
+ requiresAgent: ({ agent }: { agent: string }) => `${agent} が必要`,
+ cliNotDetected: ({ cli }: { cli: string }) => `${cli} CLI が検出されません`,
+ },
+ cliBanners: {
+ cliNotDetectedTitle: ({ cli }: { cli: string }) => `${cli} CLI が検出されません`,
+ dontShowFor: 'このポップアップを表示しない:',
+ thisMachine: 'このマシン',
+ anyMachine: 'すべてのマシン',
+ installCommand: ({ command }: { command: string }) => `インストール: ${command} •`,
+ installCliIfAvailable: ({ cli }: { cli: string }) => `${cli} CLI が利用可能ならインストール •`,
+ viewInstallationGuide: 'インストールガイドを見る →',
+ viewGeminiDocs: 'Geminiドキュメントを見る →',
+ },
worktree: {
creating: ({ name }: { name: string }) => `ワークツリー '${name}' を作成中...`,
notGitRepo: 'ワークツリーにはGitリポジトリが必要です',
@@ -337,6 +648,19 @@ export const ja: TranslationStructure = {
commandPalette: {
placeholder: 'コマンドを入力または検索...',
+ noCommandsFound: 'コマンドが見つかりません',
+ },
+
+ commandView: {
+ completedWithNoOutput: '[出力なしでコマンドが完了しました]',
+ },
+
+ voiceAssistant: {
+ connecting: '接続中...',
+ active: '音声アシスタントが有効です',
+ connectionError: '接続エラー',
+ label: '音声アシスタント',
+ tapToEnd: 'タップして終了',
},
server: {
@@ -363,13 +687,14 @@ export const ja: TranslationStructure = {
killSessionConfirm: 'このセッションを終了してもよろしいですか?',
archiveSession: 'セッションをアーカイブ',
archiveSessionConfirm: 'このセッションをアーカイブしてもよろしいですか?',
- happySessionIdCopied: 'Happy Session IDがクリップボードにコピーされました',
- failedToCopySessionId: 'Happy Session IDのコピーに失敗しました',
- happySessionId: 'Happy Session ID',
- claudeCodeSessionId: 'Claude Code Session ID',
- claudeCodeSessionIdCopied: 'Claude Code Session IDがクリップボードにコピーされました',
+ happySessionIdCopied: 'Happy セッション ID をクリップボードにコピーしました',
+ failedToCopySessionId: 'Happy セッション ID のコピーに失敗しました',
+ happySessionId: 'Happy セッション ID',
+ claudeCodeSessionId: 'Claude Code セッション ID',
+ claudeCodeSessionIdCopied: 'Claude Code セッション ID をクリップボードにコピーしました',
+ aiProfile: 'AIプロファイル',
aiProvider: 'AIプロバイダー',
- failedToCopyClaudeCodeSessionId: 'Claude Code Session IDのコピーに失敗しました',
+ failedToCopyClaudeCodeSessionId: 'Claude Code セッション ID のコピーに失敗しました',
metadataCopied: 'メタデータがクリップボードにコピーされました',
failedToCopyMetadata: 'メタデータのコピーに失敗しました',
failedToKillSession: 'セッションの終了に失敗しました',
@@ -388,9 +713,12 @@ export const ja: TranslationStructure = {
path: 'パス',
operatingSystem: 'オペレーティングシステム',
processId: 'プロセスID',
- happyHome: 'Happy Home',
+ happyHome: 'Happy のホーム',
copyMetadata: 'メタデータをコピー',
agentState: 'エージェント状態',
+ rawJsonDevMode: '生JSON(開発者モード)',
+ sessionStatus: 'セッションステータス',
+ fullSessionObject: 'セッションオブジェクト全体',
controlledByUser: 'ユーザーによる制御',
pendingRequests: '保留中のリクエスト',
activity: 'アクティビティ',
@@ -418,16 +746,52 @@ export const ja: TranslationStructure = {
runIt: '実行する',
scanQrCode: 'QRコードをスキャン',
openCamera: 'カメラを開く',
+ installCommand: '$ npm i -g happy-coder',
+ runCommand: '$ happy',
+ },
+ emptyMessages: {
+ noMessagesYet: 'まだメッセージはありません',
+ created: ({ time }: { time: string }) => `作成 ${time}`,
+ },
+ emptySessionsTablet: {
+ noActiveSessions: 'アクティブなセッションはありません',
+ startNewSessionDescription: '接続済みのどのマシンでも新しいセッションを開始できます。',
+ startNewSessionButton: '新しいセッションを開始',
+ openTerminalToStart: 'セッションを開始するには、コンピュータで新しいターミナルを開いてください。',
+ },
+ },
+
+ zen: {
+ title: 'Zen',
+ add: {
+ placeholder: 'やることは?',
+ },
+ home: {
+ noTasksYet: 'まだタスクはありません。+ をタップして追加します。',
+ },
+ view: {
+ workOnTask: 'タスクに取り組む',
+ clarify: '明確化',
+ delete: '削除',
+ linkedSessions: 'リンクされたセッション',
+ tapTaskTextToEdit: 'タスクのテキストをタップして編集',
},
},
agentInput: {
+ envVars: {
+ title: '環境変数',
+ titleWithCount: ({ count }: { count: number }) => `環境変数 (${count})`,
+ },
permissionMode: {
title: '権限モード',
default: 'デフォルト',
acceptEdits: '編集を許可',
plan: 'プランモード',
bypassPermissions: 'Yoloモード',
+ badgeAccept: '許可',
+ badgePlan: 'プラン',
+ badgeYolo: 'YOLO',
badgeAcceptAllEdits: 'すべての編集を許可',
badgeBypassAllPermissions: 'すべての権限をバイパス',
badgePlanMode: 'プランモード',
@@ -464,12 +828,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}%`,
@@ -478,6 +857,11 @@ export const ja: TranslationStructure = {
fileLabel: 'ファイル',
folderLabel: 'フォルダ',
},
+ actionMenu: {
+ title: '操作',
+ files: 'ファイル',
+ stop: '停止',
+ },
noMachinesAvailable: 'マシンなし',
},
@@ -540,6 +924,10 @@ export const ja: TranslationStructure = {
applyChanges: 'ファイルを更新',
viewDiff: '現在のファイル変更',
question: '質問',
+ changeTitle: 'タイトルを変更',
+ },
+ geminiExecute: {
+ cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`,
},
desc: {
terminalCmd: ({ cmd }: { cmd: string }) => `ターミナル(cmd: ${cmd})`,
@@ -562,7 +950,7 @@ export const ja: TranslationStructure = {
files: {
searchPlaceholder: 'ファイルを検索...',
- detachedHead: 'detached HEAD',
+ detachedHead: '切り離された HEAD',
summary: ({ staged, unstaged }: { staged: number; unstaged: number }) => `ステージ済み ${staged} • 未ステージ ${unstaged}`,
notRepo: 'Gitリポジトリではありません',
notUnderGit: 'このディレクトリはGitバージョン管理下にありません',
@@ -698,6 +1086,11 @@ export const ja: TranslationStructure = {
deviceLinkedSuccessfully: 'デバイスが正常にリンクされました',
terminalConnectedSuccessfully: 'ターミナルが正常に接続されました',
invalidAuthUrl: '無効な認証URL',
+ microphoneAccessRequiredTitle: 'マイクへのアクセスが必要です',
+ microphoneAccessRequiredRequestPermission: 'Happy は音声チャットのためにマイクへのアクセスが必要です。求められたら許可してください。',
+ microphoneAccessRequiredEnableInSettings: 'Happy は音声チャットのためにマイクへのアクセスが必要です。端末の設定でマイクのアクセスを有効にしてください。',
+ microphoneAccessRequiredBrowserInstructions: 'ブラウザの設定でマイクへのアクセスを許可してください。アドレスバーの鍵アイコンをクリックし、このサイトのマイク権限を有効にする必要がある場合があります。',
+ openSettings: '設定を開く',
developerMode: '開発者モード',
developerModeEnabled: '開発者モードが有効になりました',
developerModeDisabled: '開発者モードが無効になりました',
@@ -752,6 +1145,15 @@ export const ja: TranslationStructure = {
daemon: 'デーモン',
status: 'ステータス',
stopDaemon: 'デーモンを停止',
+ stopDaemonConfirmTitle: 'デーモンを停止しますか?',
+ stopDaemonConfirmBody: 'このマシンではデーモンを再起動するまで新しいセッションを作成できません。現在のセッションは継続します。',
+ daemonStoppedTitle: 'デーモンを停止しました',
+ stopDaemonFailed: 'デーモンを停止できませんでした。実行されていない可能性があります。',
+ renameTitle: 'マシン名を変更',
+ renameDescription: 'このマシンにカスタム名を設定します。空欄の場合はデフォルトのホスト名を使用します。',
+ renamePlaceholder: 'マシン名を入力',
+ renamedSuccess: 'マシン名を変更しました',
+ renameFailed: 'マシン名の変更に失敗しました',
lastKnownPid: '最後に確認されたPID',
lastKnownHttpPort: '最後に確認されたHTTPポート',
startedAt: '開始時刻',
@@ -768,8 +1170,15 @@ export const ja: TranslationStructure = {
lastSeen: '最終確認',
never: 'なし',
metadataVersion: 'メタデータバージョン',
+ detectedClis: '検出されたCLI',
+ detectedCliNotDetected: '未検出',
+ detectedCliUnknown: '不明',
+ detectedCliNotSupported: '未対応(happy-cliを更新してください)',
untitledSession: '無題のセッション',
back: '戻る',
+ notFound: 'マシンが見つかりません',
+ unknownMachine: '不明なマシン',
+ unknownPath: '不明なパス',
},
message: {
@@ -779,6 +1188,10 @@ export const ja: TranslationStructure = {
unknownTime: '不明な時間',
},
+ chatFooter: {
+ permissionsTerminalOnly: '権限はターミナルにのみ表示されます。リセットするかメッセージを送信して、アプリから制御してください。',
+ },
+
codex: {
// Codex permission dialog buttons
permissions: {
@@ -805,6 +1218,7 @@ export const ja: TranslationStructure = {
textCopied: 'テキストがクリップボードにコピーされました',
failedToCopy: 'テキストのクリップボードへのコピーに失敗しました',
noTextToCopy: 'コピーできるテキストがありません',
+ failedToOpen: 'テキスト選択を開けませんでした。もう一度お試しください。',
},
markdown: {
@@ -825,11 +1239,14 @@ export const ja: TranslationStructure = {
edit: 'アーティファクトを編集',
delete: '削除',
updateError: 'アーティファクトの更新に失敗しました。再試行してください。',
+ deleteError: 'アーティファクトを削除できませんでした。もう一度お試しください。',
notFound: 'アーティファクトが見つかりません',
discardChanges: '変更を破棄しますか?',
discardChangesDescription: '保存されていない変更があります。破棄してもよろしいですか?',
deleteConfirm: 'アーティファクトを削除しますか?',
deleteConfirmDescription: 'この操作は取り消せません',
+ noContent: '内容がありません',
+ untitled: '無題',
titleLabel: 'タイトル',
titlePlaceholder: 'アーティファクトのタイトルを入力',
bodyLabel: 'コンテンツ',
@@ -907,6 +1324,45 @@ export const ja: TranslationStructure = {
noData: '使用データがありません',
},
+ apiKeys: {
+ addTitle: '新しいAPIキー',
+ savedTitle: '保存済みAPIキー',
+ badgeReady: 'APIキー',
+ badgeRequired: 'APIキーが必要',
+ addSubtitle: '保存済みAPIキーを追加',
+ noneTitle: 'なし',
+ noneSubtitle: 'マシン環境を使用するか、このセッション用にキーを入力してください',
+ emptyTitle: '保存済みキーがありません',
+ emptySubtitle: 'マシンの環境変数を設定せずにAPIキープロファイルを使うには、追加してください。',
+ savedHiddenSubtitle: '保存済み(値は非表示)',
+ defaultLabel: 'デフォルト',
+ fields: {
+ name: '名前',
+ value: '値',
+ },
+ placeholders: {
+ nameExample: '例: Work OpenAI',
+ },
+ validation: {
+ nameRequired: '名前は必須です。',
+ valueRequired: '値は必須です。',
+ },
+ actions: {
+ replace: '置き換え',
+ replaceValue: '値を置き換え',
+ setDefault: 'デフォルトに設定',
+ unsetDefault: 'デフォルト解除',
+ },
+ prompts: {
+ renameTitle: 'APIキー名を変更',
+ renameDescription: 'このキーの表示名を更新します。',
+ replaceValueTitle: 'APIキーの値を置き換え',
+ replaceValueDescription: '新しいAPIキーの値を貼り付けてください。保存後は再表示されません。',
+ deleteTitle: 'APIキーを削除',
+ deleteConfirm: ({ name }: { name: string }) => `「${name}」を削除しますか?元に戻せません。`,
+ },
+ },
+
feed: {
// Feed notifications for friend requests and acceptances
friendRequestFrom: ({ name }: { name: string }) => `${name}さんから友達リクエストが届きました`,
diff --git a/sources/text/translations/pl.ts b/sources/text/translations/pl.ts
index 1c8e2f087..75a710a00 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,11 @@ export const pl: TranslationStructure = {
retry: 'Ponów',
delete: 'Usuń',
optional: 'opcjonalnie',
+ noMatches: 'Brak dopasowań',
+ all: 'Wszystko',
+ machine: 'maszyna',
+ clearSearch: 'Wyczyść wyszukiwanie',
+ refresh: 'Odśwież',
},
profile: {
@@ -88,8 +98,8 @@ export const pl: TranslationStructure = {
connecting: 'łączenie',
disconnected: 'rozłączono',
error: 'błąd',
- online: 'online',
- offline: 'offline',
+ online: 'w sieci',
+ offline: 'poza siecią',
lastSeen: ({ time }: { time: string }) => `ostatnio widziano ${time}`,
permissionRequired: 'wymagane uprawnienie',
activeNow: 'Aktywny teraz',
@@ -107,6 +117,15 @@ export const pl: TranslationStructure = {
enterSecretKey: 'Proszę wprowadzić klucz tajny',
invalidSecretKey: 'Nieprawidłowy klucz tajny. Sprawdź i spróbuj ponownie.',
enterUrlManually: 'Wprowadź URL ręcznie',
+ terminalUrlPlaceholder: 'happy://terminal?...',
+ restoreQrInstructions: '1. Otwórz Happy na urządzeniu mobilnym\n2. Przejdź do Ustawienia → Konto\n3. Dotknij „Połącz nowe urządzenie”\n4. Zeskanuj ten kod QR',
+ restoreWithSecretKeyInstead: 'Przywróć za pomocą klucza tajnego',
+ restoreWithSecretKeyDescription: 'Wpisz swój klucz tajny, aby odzyskać dostęp do konta.',
+ secretKeyPlaceholder: 'XXXXX-XXXXX-XXXXX...',
+ unsupported: {
+ connectTitle: ({ name }: { name: string }) => `Połącz ${name}`,
+ runCommandInTerminal: 'Uruchom poniższe polecenie w terminalu:',
+ },
},
settings: {
@@ -147,11 +166,13 @@ export const pl: TranslationStructure = {
usageSubtitle: 'Zobacz użycie API i koszty',
profiles: 'Profile',
profilesSubtitle: 'Zarządzaj profilami zmiennych środowiskowych dla sesji',
+ apiKeys: 'Klucze API',
+ apiKeysSubtitle: 'Zarządzaj zapisanymi kluczami API (po wpisaniu nie będą ponownie pokazywane)',
// Dynamic settings messages
accountConnected: ({ service }: { service: string }) => `Konto ${service} połączone`,
machineStatus: ({ name, status }: { name: string; status: 'online' | 'offline' }) =>
- `${name} jest ${status === 'online' ? 'online' : 'offline'}`,
+ `${name} jest ${status === 'online' ? 'w sieci' : 'poza siecią'}`,
featureToggled: ({ feature, enabled }: { feature: string; enabled: boolean }) =>
`${feature} ${enabled ? 'włączona' : 'wyłączona'}`,
},
@@ -184,6 +205,21 @@ export const pl: TranslationStructure = {
wrapLinesInDiffsDescription: 'Zawijaj długie linie zamiast przewijania poziomego w widokach różnic',
alwaysShowContextSize: 'Zawsze pokazuj rozmiar kontekstu',
alwaysShowContextSizeDescription: 'Wyświetlaj użycie kontekstu nawet gdy nie jest blisko limitu',
+ agentInputActionBarLayout: 'Pasek akcji pola wpisywania',
+ agentInputActionBarLayoutDescription: 'Wybierz, jak wyświetlać chipy akcji nad polem wpisywania',
+ agentInputActionBarLayoutOptions: {
+ auto: 'Automatycznie',
+ wrap: 'Zawijanie',
+ scroll: 'Przewijany',
+ collapsed: 'Zwinięty',
+ },
+ agentInputChipDensity: 'Gęstość chipów akcji',
+ agentInputChipDensityDescription: 'Wybierz, czy chipy akcji pokazują etykiety czy ikony',
+ agentInputChipDensityOptions: {
+ auto: 'Automatycznie',
+ labels: 'Etykiety',
+ icons: 'Tylko ikony',
+ },
avatarStyle: 'Styl awatara',
avatarStyleDescription: 'Wybierz wygląd awatara sesji',
avatarOptions: {
@@ -204,6 +240,22 @@ export const pl: TranslationStructure = {
experimentalFeatures: 'Funkcje eksperymentalne',
experimentalFeaturesEnabled: 'Funkcje eksperymentalne włączone',
experimentalFeaturesDisabled: 'Używane tylko stabilne funkcje',
+ experimentalOptions: 'Opcje eksperymentalne',
+ experimentalOptionsDescription: 'Wybierz, które funkcje eksperymentalne są włączone.',
+ expGemini: 'Gemini',
+ expGeminiSubtitle: 'Enable Gemini CLI sessions and Gemini-related UI',
+ expUsageReporting: 'Usage reporting',
+ expUsageReportingSubtitle: 'Enable usage and token reporting screens',
+ expFileViewer: 'File viewer',
+ expFileViewerSubtitle: 'Enable the session file viewer entrypoint',
+ expShowThinkingMessages: 'Show thinking messages',
+ expShowThinkingMessagesSubtitle: 'Show assistant thinking/status messages in chat',
+ expSessionType: 'Session type selector',
+ expSessionTypeSubtitle: 'Show the session type selector (simple vs worktree)',
+ expZen: 'Zen',
+ expZenSubtitle: 'Enable the Zen navigation entry',
+ expVoiceAuthFlow: 'Voice auth flow',
+ expVoiceAuthFlowSubtitle: 'Use authenticated voice token flow (paywall-aware)',
webFeatures: 'Funkcje webowe',
webFeaturesDescription: 'Funkcje dostępne tylko w wersji webowej aplikacji.',
enterToSend: 'Enter aby wysłać',
@@ -212,13 +264,22 @@ export const pl: TranslationStructure = {
commandPalette: 'Paleta poleceń',
commandPaletteEnabled: 'Naciśnij ⌘K, aby otworzyć',
commandPaletteDisabled: 'Szybki dostęp do poleceń wyłączony',
- markdownCopyV2: 'Markdown Copy v2',
+ markdownCopyV2: 'Kopiowanie Markdown v2',
markdownCopyV2Subtitle: 'Długie naciśnięcie otwiera modal kopiowania',
hideInactiveSessions: 'Ukryj nieaktywne sesje',
hideInactiveSessionsSubtitle: 'Wyświetlaj tylko aktywne czaty na liście',
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,8 +332,29 @@ export const pl: TranslationStructure = {
newSession: {
// Used by new-session screen and launch flows
title: 'Rozpocznij nową sesję',
+ selectAiProfileTitle: 'Wybierz profil AI',
+ selectAiProfileDescription: 'Wybierz profil AI, aby zastosować zmienne środowiskowe i domyślne ustawienia do sesji.',
+ changeProfile: 'Zmień profil',
+ aiBackendSelectedByProfile: 'Backend AI jest wybierany przez profil. Aby go zmienić, wybierz inny profil.',
+ selectAiBackendTitle: 'Wybierz backend AI',
+ aiBackendLimitedByProfileAndMachineClis: 'Ograniczone przez wybrany profil i dostępne CLI na tej maszynie.',
+ aiBackendSelectWhichAiRuns: 'Wybierz, które AI uruchamia Twoją sesję.',
+ aiBackendNotCompatibleWithSelectedProfile: 'Niezgodne z wybranym profilem.',
+ aiBackendCliNotDetectedOnMachine: ({ cli }: { cli: string }) => `Nie wykryto CLI ${cli} na tej maszynie.`,
+ selectMachineTitle: 'Wybierz maszynę',
+ selectMachineDescription: 'Wybierz, gdzie ta sesja działa.',
+ selectPathTitle: 'Wybierz ścieżkę',
+ selectWorkingDirectoryTitle: 'Wybierz katalog roboczy',
+ selectWorkingDirectoryDescription: 'Wybierz folder używany dla poleceń i kontekstu.',
+ selectPermissionModeTitle: 'Wybierz tryb uprawnień',
+ selectPermissionModeDescription: 'Określ, jak ściśle akcje wymagają zatwierdzenia.',
+ selectModelTitle: 'Wybierz model AI',
+ selectModelDescription: 'Wybierz model używany przez tę sesję.',
+ selectSessionTypeTitle: 'Wybierz typ sesji',
+ selectSessionTypeDescription: 'Wybierz sesję prostą lub powiązaną z Git worktree.',
+ searchPathsPlaceholder: 'Szukaj ścieżek...',
noMachinesFound: 'Nie znaleziono maszyn. Najpierw uruchom sesję Happy na swoim komputerze.',
- allMachinesOffline: 'Wszystkie maszyny są offline',
+ allMachinesOffline: 'Wszystkie maszyny są poza siecią',
machineDetails: 'Zobacz szczegóły maszyny →',
directoryDoesNotExist: 'Katalog nie został znaleziony',
createDirectoryConfirm: ({ directory }: { directory: string }) => `Katalog ${directory} nie istnieje. Czy chcesz go utworzyć?`,
@@ -286,12 +368,46 @@ 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',
- worktree: 'Worktree',
+ worktree: 'Drzewo robocze',
comingSoon: 'Wkrótce dostępne',
},
+ profileAvailability: {
+ requiresAgent: ({ agent }: { agent: string }) => `Wymaga ${agent}`,
+ cliNotDetected: ({ cli }: { cli: string }) => `Nie wykryto CLI ${cli}`,
+ },
+ cliBanners: {
+ cliNotDetectedTitle: ({ cli }: { cli: string }) => `Nie wykryto CLI ${cli}`,
+ dontShowFor: 'Nie pokazuj tego komunikatu dla',
+ thisMachine: 'tej maszyny',
+ anyMachine: 'dowolnej maszyny',
+ installCommand: ({ command }: { command: string }) => `Zainstaluj: ${command} •`,
+ installCliIfAvailable: ({ cli }: { cli: string }) => `Zainstaluj CLI ${cli}, jeśli jest dostępne •`,
+ viewInstallationGuide: 'Zobacz instrukcję instalacji →',
+ viewGeminiDocs: 'Zobacz dokumentację Gemini →',
+ },
worktree: {
creating: ({ name }: { name: string }) => `Tworzenie worktree '${name}'...`,
notGitRepo: 'Worktree wymaga repozytorium git',
@@ -316,6 +432,19 @@ export const pl: TranslationStructure = {
commandPalette: {
placeholder: 'Wpisz polecenie lub wyszukaj...',
+ noCommandsFound: 'Nie znaleziono poleceń',
+ },
+
+ commandView: {
+ completedWithNoOutput: '[Polecenie zakończone bez danych wyjściowych]',
+ },
+
+ voiceAssistant: {
+ connecting: 'Łączenie...',
+ active: 'Asystent głosowy aktywny',
+ connectionError: 'Błąd połączenia',
+ label: 'Asystent głosowy',
+ tapToEnd: 'Dotknij, aby zakończyć',
},
server: {
@@ -347,6 +476,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',
@@ -370,6 +500,9 @@ export const pl: TranslationStructure = {
happyHome: 'Katalog domowy Happy',
copyMetadata: 'Kopiuj metadane',
agentState: 'Stan agenta',
+ rawJsonDevMode: 'Surowy JSON (tryb deweloperski)',
+ sessionStatus: 'Status sesji',
+ fullSessionObject: 'Pełny obiekt sesji',
controlledByUser: 'Kontrolowany przez użytkownika',
pendingRequests: 'Oczekujące żądania',
activity: 'Aktywność',
@@ -396,16 +529,52 @@ export const pl: TranslationStructure = {
runIt: 'Uruchom je',
scanQrCode: 'Zeskanuj kod QR',
openCamera: 'Otwórz kamerę',
+ installCommand: '$ npm i -g happy-coder',
+ runCommand: '$ happy',
+ },
+ emptyMessages: {
+ noMessagesYet: 'Brak wiadomości',
+ created: ({ time }: { time: string }) => `Utworzono ${time}`,
+ },
+ emptySessionsTablet: {
+ noActiveSessions: 'Brak aktywnych sesji',
+ startNewSessionDescription: 'Rozpocznij nową sesję na dowolnej z połączonych maszyn.',
+ startNewSessionButton: 'Rozpocznij nową sesję',
+ openTerminalToStart: 'Otwórz nowy terminal na komputerze, aby rozpocząć sesję.',
+ },
+ },
+
+ zen: {
+ title: 'Zen',
+ add: {
+ placeholder: 'Co trzeba zrobić?',
+ },
+ home: {
+ noTasksYet: 'Brak zadań. Stuknij +, aby dodać.',
+ },
+ view: {
+ workOnTask: 'Pracuj nad zadaniem',
+ clarify: 'Doprecyzuj',
+ delete: 'Usuń',
+ linkedSessions: 'Powiązane sesje',
+ tapTaskTextToEdit: 'Stuknij tekst zadania, aby edytować',
},
},
agentInput: {
+ envVars: {
+ title: 'Zmienne środowiskowe',
+ titleWithCount: ({ count }: { count: number }) => `Zmienne środowiskowe (${count})`,
+ },
permissionMode: {
title: 'TRYB UPRAWNIEŃ',
default: 'Domyślny',
acceptEdits: 'Akceptuj edycje',
plan: 'Tryb planowania',
bypassPermissions: 'Tryb YOLO',
+ badgeAccept: 'Akceptuj',
+ badgePlan: 'Plan',
+ badgeYolo: 'YOLO',
badgeAcceptAllEdits: 'Akceptuj wszystkie edycje',
badgeBypassAllPermissions: 'Omiń wszystkie uprawnienia',
badgePlanMode: 'Tryb planowania',
@@ -422,39 +591,59 @@ export const pl: TranslationStructure = {
codexPermissionMode: {
title: 'TRYB UPRAWNIEŃ CODEX',
default: 'Ustawienia CLI',
- readOnly: 'Read Only Mode',
- safeYolo: 'Safe YOLO',
+ readOnly: 'Tryb tylko do odczytu',
+ safeYolo: 'Bezpieczne YOLO',
yolo: 'YOLO',
- badgeReadOnly: 'Read Only Mode',
- badgeSafeYolo: 'Safe YOLO',
+ badgeReadOnly: 'Tylko do odczytu',
+ badgeSafeYolo: 'Bezpieczne 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',
+ title: 'MODEL CODEX',
+ gpt5CodexLow: 'gpt-5-codex niski',
+ gpt5CodexMedium: 'gpt-5-codex średni',
+ gpt5CodexHigh: 'gpt-5-codex wysoki',
+ gpt5Minimal: 'GPT-5 Minimalny',
+ gpt5Low: 'GPT-5 Niski',
+ gpt5Medium: 'GPT-5 Średni',
+ gpt5High: 'GPT-5 Wysoki',
},
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}%`,
},
suggestion: {
fileLabel: 'PLIK',
- folderLabel: 'FOLDER',
+ folderLabel: 'KATALOG',
+ },
+ actionMenu: {
+ title: 'AKCJE',
+ files: 'Pliki',
+ stop: 'Zatrzymaj',
},
noMachinesAvailable: 'Brak maszyn',
},
@@ -514,6 +703,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})`,
@@ -676,6 +869,11 @@ export const pl: TranslationStructure = {
deviceLinkedSuccessfully: 'Urządzenie połączone pomyślnie',
terminalConnectedSuccessfully: 'Terminal połączony pomyślnie',
invalidAuthUrl: 'Nieprawidłowy URL uwierzytelnienia',
+ microphoneAccessRequiredTitle: 'Wymagany dostęp do mikrofonu',
+ microphoneAccessRequiredRequestPermission: 'Happy potrzebuje dostępu do mikrofonu do czatu głosowego. Udziel zgody, gdy pojawi się prośba.',
+ microphoneAccessRequiredEnableInSettings: 'Happy potrzebuje dostępu do mikrofonu do czatu głosowego. Włącz dostęp do mikrofonu w ustawieniach urządzenia.',
+ microphoneAccessRequiredBrowserInstructions: 'Zezwól na dostęp do mikrofonu w ustawieniach przeglądarki. Być może musisz kliknąć ikonę kłódki na pasku adresu i włączyć uprawnienie mikrofonu dla tej witryny.',
+ openSettings: 'Otwórz ustawienia',
developerMode: 'Tryb deweloperski',
developerModeEnabled: 'Tryb deweloperski włączony',
developerModeDisabled: 'Tryb deweloperski wyłączony',
@@ -727,9 +925,18 @@ export const pl: TranslationStructure = {
offlineUnableToSpawn: 'Launcher wyłączony, gdy maszyna jest offline',
offlineHelp: '• Upewnij się, że komputer jest online\n• Uruchom `happy daemon status`, aby zdiagnozować\n• Czy używasz najnowszej wersji CLI? Zaktualizuj poleceniem `npm install -g happy-coder@latest`',
launchNewSessionInDirectory: 'Uruchom nową sesję w katalogu',
- daemon: 'Daemon',
+ daemon: 'Demon',
status: 'Status',
stopDaemon: 'Zatrzymaj daemon',
+ stopDaemonConfirmTitle: 'Zatrzymać daemon?',
+ stopDaemonConfirmBody: 'Nie będziesz mógł tworzyć nowych sesji na tej maszynie, dopóki nie uruchomisz ponownie daemona na komputerze. Obecne sesje pozostaną aktywne.',
+ daemonStoppedTitle: 'Daemon zatrzymany',
+ stopDaemonFailed: 'Nie udało się zatrzymać daemona. Może nie działa.',
+ renameTitle: 'Zmień nazwę maszyny',
+ renameDescription: 'Nadaj tej maszynie własną nazwę. Pozostaw puste, aby użyć domyślnej nazwy hosta.',
+ renamePlaceholder: 'Wpisz nazwę maszyny',
+ renamedSuccess: 'Nazwa maszyny została zmieniona',
+ renameFailed: 'Nie udało się zmienić nazwy maszyny',
lastKnownPid: 'Ostatni znany PID',
lastKnownHttpPort: 'Ostatni znany port HTTP',
startedAt: 'Uruchomiony o',
@@ -746,8 +953,15 @@ export const pl: TranslationStructure = {
lastSeen: 'Ostatnio widziana',
never: 'Nigdy',
metadataVersion: 'Wersja metadanych',
+ detectedClis: 'Wykryte CLI',
+ detectedCliNotDetected: 'Nie wykryto',
+ detectedCliUnknown: 'Nieznane',
+ detectedCliNotSupported: 'Nieobsługiwane (zaktualizuj happy-cli)',
untitledSession: 'Sesja bez nazwy',
back: 'Wstecz',
+ notFound: 'Nie znaleziono maszyny',
+ unknownMachine: 'nieznana maszyna',
+ unknownPath: 'nieznana ścieżka',
},
message: {
@@ -757,6 +971,10 @@ export const pl: TranslationStructure = {
unknownTime: 'nieznany czas',
},
+ chatFooter: {
+ permissionsTerminalOnly: 'Uprawnienia są widoczne tylko w terminalu. Zresetuj lub wyślij wiadomość, aby sterować z aplikacji.',
+ },
+
codex: {
// Codex permission dialog buttons
permissions: {
@@ -783,6 +1001,7 @@ export const pl: TranslationStructure = {
textCopied: 'Tekst skopiowany do schowka',
failedToCopy: 'Nie udało się skopiować tekstu do schowka',
noTextToCopy: 'Brak tekstu do skopiowania',
+ failedToOpen: 'Nie udało się otworzyć wyboru tekstu. Spróbuj ponownie.',
},
markdown: {
@@ -816,11 +1035,14 @@ export const pl: TranslationStructure = {
edit: 'Edytuj artefakt',
delete: 'Usuń',
updateError: 'Nie udało się zaktualizować artefaktu. Spróbuj ponownie.',
+ deleteError: 'Nie udało się usunąć artefaktu. Spróbuj ponownie.',
notFound: 'Artefakt nie został znaleziony',
discardChanges: 'Odrzucić zmiany?',
discardChangesDescription: 'Masz niezapisane zmiany. Czy na pewno chcesz je odrzucić?',
deleteConfirm: 'Usunąć artefakt?',
deleteConfirmDescription: 'Tej operacji nie można cofnąć',
+ noContent: 'Brak treści',
+ untitled: 'Bez tytułu',
titleLabel: 'TYTUŁ',
titlePlaceholder: 'Wprowadź tytuł dla swojego artefaktu',
bodyLabel: 'TREŚĆ',
@@ -906,6 +1128,45 @@ export const pl: TranslationStructure = {
friendAcceptedGeneric: 'Zaproszenie do znajomych zaakceptowane',
},
+ apiKeys: {
+ addTitle: 'Nowy klucz API',
+ savedTitle: 'Zapisane klucze API',
+ badgeReady: 'Klucz API',
+ badgeRequired: 'Wymagany klucz API',
+ addSubtitle: 'Dodaj zapisany klucz API',
+ noneTitle: 'Brak',
+ noneSubtitle: 'Użyj środowiska maszyny lub wpisz klucz dla tej sesji',
+ emptyTitle: 'Brak zapisanych kluczy',
+ emptySubtitle: 'Dodaj jeden, aby używać profili z kluczem API bez ustawiania zmiennych środowiskowych na maszynie.',
+ savedHiddenSubtitle: 'Zapisany (wartość ukryta)',
+ defaultLabel: 'Domyślny',
+ fields: {
+ name: 'Nazwa',
+ value: 'Wartość',
+ },
+ placeholders: {
+ nameExample: 'np. Work OpenAI',
+ },
+ validation: {
+ nameRequired: 'Nazwa jest wymagana.',
+ valueRequired: 'Wartość jest wymagana.',
+ },
+ actions: {
+ replace: 'Zastąp',
+ replaceValue: 'Zastąp wartość',
+ setDefault: 'Ustaw jako domyślny',
+ unsetDefault: 'Usuń domyślny',
+ },
+ prompts: {
+ renameTitle: 'Zmień nazwę klucza API',
+ renameDescription: 'Zaktualizuj przyjazną nazwę dla tego klucza.',
+ replaceValueTitle: 'Zastąp wartość klucza API',
+ replaceValueDescription: 'Wklej nową wartość klucza API. Ta wartość nie będzie ponownie wyświetlana po zapisaniu.',
+ deleteTitle: 'Usuń klucz API',
+ deleteConfirm: ({ name }: { name: string }) => `Usunąć “${name}”? Tej czynności nie można cofnąć.`,
+ },
+ },
+
profiles: {
// Profile management feature
title: 'Profile',
@@ -926,9 +1187,214 @@ 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',
+ custom: 'Niestandardowe',
+ builtInSaveAsHint: 'Zapisanie wbudowanego profilu tworzy nowy profil niestandardowy.',
+ builtInNames: {
+ anthropic: 'Anthropic (Domyślny)',
+ 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: '(Kopia)',
+ duplicateName: 'Profil o tej nazwie już istnieje',
+ setupInstructions: {
+ title: 'Instrukcje konfiguracji',
+ viewOfficialGuide: 'Zobacz oficjalny przewodnik konfiguracji',
+ },
+ machineLogin: {
+ title: 'Wymagane logowanie na maszynie',
+ subtitle: 'Ten profil korzysta z pamięci podręcznej logowania CLI na wybranej maszynie.',
+ claudeCode: {
+ title: 'Claude Code',
+ instructions: 'Uruchom `claude`, a następnie wpisz `/login`, aby się zalogować.',
+ warning: 'Uwaga: ustawienie `ANTHROPIC_AUTH_TOKEN` zastępuje logowanie CLI.',
+ },
+ codex: {
+ title: 'Codex',
+ instructions: 'Uruchom `codex login`, aby się zalogować.',
+ },
+ geminiCli: {
+ title: 'Gemini CLI',
+ instructions: 'Uruchom `gemini auth`, aby się zalogować.',
+ },
+ },
+ requirements: {
+ apiKeyRequired: 'Klucz API',
+ configured: 'Skonfigurowano na maszynie',
+ notConfigured: 'Nie skonfigurowano',
+ checking: 'Sprawdzanie…',
+ modalTitle: 'Wymagany klucz API',
+ modalBody: 'Ten profil wymaga klucza API.\n\nDostępne opcje:\n• Użyj środowiska maszyny (zalecane)\n• Użyj zapisanego klucza z ustawień aplikacji\n• Wpisz klucz tylko dla tej sesji',
+ sectionTitle: 'Wymagania',
+ sectionSubtitle: 'Te pola służą do wstępnej weryfikacji i aby uniknąć niespodziewanych błędów.',
+ secretEnvVarPromptDescription: 'Wpisz nazwę wymaganej tajnej zmiennej środowiskowej (np. OPENAI_API_KEY).',
+ modalHelpWithEnv: ({ env }: { env: string }) => `Ten profil wymaga ${env}. Wybierz jedną z opcji poniżej.`,
+ modalHelpGeneric: 'Ten profil wymaga klucza API. Wybierz jedną z opcji poniżej.',
+ modalRecommendation: 'Zalecane: ustaw klucz w środowisku daemona na komputerze (żeby nie wklejać go ponownie). Następnie uruchom ponownie daemona, aby wczytał nową zmienną środowiskową.',
+ chooseOptionTitle: 'Wybierz opcję',
+ machineEnvStatus: {
+ theMachine: 'maszynie',
+ checkFor: ({ env }: { env: string }) => `Sprawdź ${env}`,
+ checking: ({ env }: { env: string }) => `Sprawdzanie ${env}…`,
+ found: ({ env, machine }: { env: string; machine: string }) => `${env} znaleziono na ${machine}`,
+ notFound: ({ env, machine }: { env: string; machine: string }) => `${env} nie znaleziono na ${machine}`,
+ },
+ machineEnvSubtitle: {
+ checking: 'Sprawdzanie środowiska daemona…',
+ found: 'Znaleziono w środowisku daemona na maszynie.',
+ notFound: 'Ustaw w środowisku daemona na maszynie i uruchom ponownie daemona.',
+ },
+ options: {
+ none: {
+ title: 'Brak',
+ subtitle: 'Nie wymaga klucza API ani logowania CLI.',
+ },
+ apiKeyEnv: {
+ subtitle: 'Wymaga klucza API wstrzykiwanego przy starcie sesji.',
+ },
+ machineLogin: {
+ subtitle: 'Wymaga zalogowania przez CLI na maszynie docelowej.',
+ longSubtitle: 'Wymaga zalogowania w CLI dla wybranego backendu AI na maszynie docelowej.',
+ },
+ useMachineEnvironment: {
+ title: 'Użyj środowiska maszyny',
+ subtitleWithEnv: ({ env }: { env: string }) => `Użyj ${env} ze środowiska daemona.`,
+ subtitleGeneric: 'Użyj klucza ze środowiska daemona.',
+ },
+ useSavedApiKey: {
+ title: 'Użyj zapisanego klucza API',
+ subtitle: 'Wybierz (lub dodaj) zapisany klucz w aplikacji.',
+ },
+ enterOnce: {
+ title: 'Wpisz klucz',
+ subtitle: 'Wklej klucz tylko dla tej sesji (nie zostanie zapisany).',
+ },
+ },
+ apiKeyEnvVar: {
+ title: 'Zmienna środowiskowa klucza API',
+ subtitle: 'Wpisz nazwę zmiennej środowiskowej, której ten dostawca oczekuje dla klucza API (np. OPENAI_API_KEY).',
+ label: 'Nazwa zmiennej środowiskowej',
+ },
+ sections: {
+ machineEnvironment: 'Środowisko maszyny',
+ useOnceTitle: 'Użyj raz',
+ useOnceFooter: 'Wklej klucz tylko dla tej sesji. Nie zostanie zapisany.',
+ },
+ actions: {
+ useMachineEnvironment: {
+ subtitle: 'Rozpocznij z kluczem już obecnym na maszynie.',
+ },
+ useOnceButton: 'Użyj raz (tylko sesja)',
+ },
+ },
+ 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',
+ itemTitle: 'Maszyna podglądu dla zmiennych środowiskowych',
+ selectMachine: 'Wybierz maszynę',
+ resolveSubtitle: 'Służy tylko do podglądu rozwiązanych wartości poniżej (nie zmienia tego, co zostanie zapisane).',
+ selectSubtitle: 'Wybierz maszynę, aby podejrzeć rozwiązane wartości poniżej.',
+ },
+ 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',
+ secretToggleLabel: 'Sekret',
+ secretToggleSubtitle: 'Ukrywa wartość w UI i nie pobiera jej z maszyny na potrzeby podglądu.',
+ secretToggleEnforcedByDaemon: 'Wymuszone przez daemon',
+ secretToggleResetToAuto: 'Przywróć automatyczne',
+ 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: 'Wartość zapasowa',
+ 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..7c5c794c3 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,11 @@ export const pt: TranslationStructure = {
retry: 'Tentar novamente',
delete: 'Excluir',
optional: 'Opcional',
+ noMatches: 'Nenhuma correspondência',
+ all: 'Todos',
+ machine: 'máquina',
+ clearSearch: 'Limpar pesquisa',
+ refresh: 'Atualizar',
},
profile: {
@@ -96,6 +106,15 @@ export const pt: TranslationStructure = {
enterSecretKey: 'Por favor, insira uma chave secreta',
invalidSecretKey: 'Chave secreta inválida. Verifique e tente novamente.',
enterUrlManually: 'Inserir URL manualmente',
+ terminalUrlPlaceholder: 'happy://terminal?...',
+ restoreQrInstructions: '1. Abra o Happy no seu dispositivo móvel\n2. Vá em Configurações → Conta\n3. Toque em "Vincular novo dispositivo"\n4. Escaneie este código QR',
+ restoreWithSecretKeyInstead: 'Restaurar com chave secreta',
+ restoreWithSecretKeyDescription: 'Digite sua chave secreta para recuperar o acesso à sua conta.',
+ secretKeyPlaceholder: 'XXXXX-XXXXX-XXXXX...',
+ unsupported: {
+ connectTitle: ({ name }: { name: string }) => `Conectar ${name}`,
+ runCommandInTerminal: 'Execute o seguinte comando no terminal:',
+ },
},
settings: {
@@ -136,6 +155,8 @@ export const pt: TranslationStructure = {
usageSubtitle: 'Visualizar uso da API e custos',
profiles: 'Perfis',
profilesSubtitle: 'Gerenciar perfis de ambiente e variáveis',
+ apiKeys: 'Chaves de API',
+ apiKeysSubtitle: 'Gerencie as chaves de API salvas (não serão exibidas novamente após o envio)',
// Dynamic settings messages
accountConnected: ({ service }: { service: string }) => `Conta ${service} conectada`,
@@ -173,6 +194,21 @@ export const pt: TranslationStructure = {
wrapLinesInDiffsDescription: 'Quebrar linhas longas ao invés de rolagem horizontal nas visualizações de diffs',
alwaysShowContextSize: 'Sempre mostrar tamanho do contexto',
alwaysShowContextSizeDescription: 'Exibir uso do contexto mesmo quando não estiver próximo do limite',
+ agentInputActionBarLayout: 'Barra de ações do input',
+ agentInputActionBarLayoutDescription: 'Escolha como os chips de ação são exibidos acima do campo de entrada',
+ agentInputActionBarLayoutOptions: {
+ auto: 'Auto',
+ wrap: 'Quebrar linha',
+ scroll: 'Rolável',
+ collapsed: 'Recolhido',
+ },
+ agentInputChipDensity: 'Densidade dos chips de ação',
+ agentInputChipDensityDescription: 'Escolha se os chips de ação exibem rótulos ou ícones',
+ agentInputChipDensityOptions: {
+ auto: 'Auto',
+ labels: 'Rótulos',
+ icons: 'Somente ícones',
+ },
avatarStyle: 'Estilo do avatar',
avatarStyleDescription: 'Escolha a aparência do avatar da sessão',
avatarOptions: {
@@ -193,6 +229,22 @@ export const pt: TranslationStructure = {
experimentalFeatures: 'Recursos experimentais',
experimentalFeaturesEnabled: 'Recursos experimentais ativados',
experimentalFeaturesDisabled: 'Usando apenas recursos estáveis',
+ experimentalOptions: 'Opções experimentais',
+ experimentalOptionsDescription: 'Escolha quais recursos experimentais estão ativados.',
+ expGemini: 'Gemini',
+ expGeminiSubtitle: 'Enable Gemini CLI sessions and Gemini-related UI',
+ expUsageReporting: 'Usage reporting',
+ expUsageReportingSubtitle: 'Enable usage and token reporting screens',
+ expFileViewer: 'File viewer',
+ expFileViewerSubtitle: 'Enable the session file viewer entrypoint',
+ expShowThinkingMessages: 'Show thinking messages',
+ expShowThinkingMessagesSubtitle: 'Show assistant thinking/status messages in chat',
+ expSessionType: 'Session type selector',
+ expSessionTypeSubtitle: 'Show the session type selector (simple vs worktree)',
+ expZen: 'Zen',
+ expZenSubtitle: 'Enable the Zen navigation entry',
+ expVoiceAuthFlow: 'Voice auth flow',
+ expVoiceAuthFlowSubtitle: 'Use authenticated voice token flow (paywall-aware)',
webFeatures: 'Recursos web',
webFeaturesDescription: 'Recursos disponíveis apenas na versão web do aplicativo.',
enterToSend: 'Enter para enviar',
@@ -201,13 +253,22 @@ export const pt: TranslationStructure = {
commandPalette: 'Paleta de comandos',
commandPaletteEnabled: 'Pressione ⌘K para abrir',
commandPaletteDisabled: 'Acesso rápido a comandos desativado',
- markdownCopyV2: 'Markdown Copy v2',
+ markdownCopyV2: 'Cópia de Markdown v2',
markdownCopyV2Subtitle: 'Pressione e segure para abrir modal de cópia',
hideInactiveSessions: 'Ocultar sessões inativas',
hideInactiveSessionsSubtitle: 'Mostre apenas os chats ativos na sua lista',
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 +321,27 @@ export const pt: TranslationStructure = {
newSession: {
// Used by new-session screen and launch flows
title: 'Iniciar nova sessão',
+ selectAiProfileTitle: 'Selecionar perfil de IA',
+ selectAiProfileDescription: 'Selecione um perfil de IA para aplicar variáveis de ambiente e padrões à sua sessão.',
+ changeProfile: 'Trocar perfil',
+ aiBackendSelectedByProfile: 'O backend de IA é selecionado pelo seu perfil. Para alterar, selecione um perfil diferente.',
+ selectAiBackendTitle: 'Selecionar backend de IA',
+ aiBackendLimitedByProfileAndMachineClis: 'Limitado pelo perfil selecionado e pelos CLIs disponíveis nesta máquina.',
+ aiBackendSelectWhichAiRuns: 'Selecione qual IA roda sua sessão.',
+ aiBackendNotCompatibleWithSelectedProfile: 'Não compatível com o perfil selecionado.',
+ aiBackendCliNotDetectedOnMachine: ({ cli }: { cli: string }) => `CLI do ${cli} não detectado nesta máquina.`,
+ selectMachineTitle: 'Selecionar máquina',
+ selectMachineDescription: 'Escolha onde esta sessão será executada.',
+ selectPathTitle: 'Selecionar caminho',
+ selectWorkingDirectoryTitle: 'Selecionar diretório de trabalho',
+ selectWorkingDirectoryDescription: 'Escolha a pasta usada para comandos e contexto.',
+ selectPermissionModeTitle: 'Selecionar modo de permissões',
+ selectPermissionModeDescription: 'Controle o quão estritamente as ações exigem aprovação.',
+ selectModelTitle: 'Selecionar modelo de IA',
+ selectModelDescription: 'Escolha o modelo usado por esta sessão.',
+ selectSessionTypeTitle: 'Selecionar tipo de sessão',
+ selectSessionTypeDescription: 'Escolha uma sessão simples ou uma vinculada a um worktree do Git.',
+ 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,12 +357,46 @@ 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',
- worktree: 'Worktree',
+ worktree: 'Árvore de trabalho',
comingSoon: 'Em breve',
},
+ profileAvailability: {
+ requiresAgent: ({ agent }: { agent: string }) => `Requer ${agent}`,
+ cliNotDetected: ({ cli }: { cli: string }) => `CLI do ${cli} não detectado`,
+ },
+ cliBanners: {
+ cliNotDetectedTitle: ({ cli }: { cli: string }) => `CLI do ${cli} não detectado`,
+ dontShowFor: 'Não mostrar este aviso para',
+ thisMachine: 'esta máquina',
+ anyMachine: 'qualquer máquina',
+ installCommand: ({ command }: { command: string }) => `Instalar: ${command} •`,
+ installCliIfAvailable: ({ cli }: { cli: string }) => `Instale o CLI do ${cli} se disponível •`,
+ viewInstallationGuide: 'Ver guia de instalação →',
+ viewGeminiDocs: 'Ver docs do Gemini →',
+ },
worktree: {
creating: ({ name }: { name: string }) => `Criando worktree '${name}'...`,
notGitRepo: 'Worktrees requerem um repositório git',
@@ -305,6 +421,19 @@ export const pt: TranslationStructure = {
commandPalette: {
placeholder: 'Digite um comando ou pesquise...',
+ noCommandsFound: 'Nenhum comando encontrado',
+ },
+
+ commandView: {
+ completedWithNoOutput: '[Comando concluído sem saída]',
+ },
+
+ voiceAssistant: {
+ connecting: 'Conectando...',
+ active: 'Assistente de voz ativo',
+ connectionError: 'Erro de conexão',
+ label: 'Assistente de voz',
+ tapToEnd: 'Toque para encerrar',
},
server: {
@@ -336,6 +465,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',
@@ -359,6 +489,9 @@ export const pt: TranslationStructure = {
happyHome: 'Diretório Happy',
copyMetadata: 'Copiar metadados',
agentState: 'Estado do agente',
+ rawJsonDevMode: 'JSON bruto (modo dev)',
+ sessionStatus: 'Status da sessão',
+ fullSessionObject: 'Objeto completo da sessão',
controlledByUser: 'Controlado pelo usuário',
pendingRequests: 'Solicitações pendentes',
activity: 'Atividade',
@@ -386,16 +519,52 @@ export const pt: TranslationStructure = {
runIt: 'Execute',
scanQrCode: 'Escaneie o código QR',
openCamera: 'Abrir câmera',
+ installCommand: '$ npm i -g happy-coder',
+ runCommand: '$ happy',
+ },
+ emptyMessages: {
+ noMessagesYet: 'Nenhuma mensagem ainda',
+ created: ({ time }: { time: string }) => `Criado ${time}`,
+ },
+ emptySessionsTablet: {
+ noActiveSessions: 'Nenhuma sessão ativa',
+ startNewSessionDescription: 'Inicie uma nova sessão em qualquer uma das suas máquinas conectadas.',
+ startNewSessionButton: 'Iniciar nova sessão',
+ openTerminalToStart: 'Abra um novo terminal no computador para iniciar uma sessão.',
+ },
+ },
+
+ zen: {
+ title: 'Zen',
+ add: {
+ placeholder: 'O que precisa ser feito?',
+ },
+ home: {
+ noTasksYet: 'Ainda não há tarefas. Toque em + para adicionar.',
+ },
+ view: {
+ workOnTask: 'Trabalhar na tarefa',
+ clarify: 'Esclarecer',
+ delete: 'Excluir',
+ linkedSessions: 'Sessões vinculadas',
+ tapTaskTextToEdit: 'Toque no texto da tarefa para editar',
},
},
agentInput: {
+ envVars: {
+ title: 'Vars env',
+ titleWithCount: ({ count }: { count: number }) => `Vars env (${count})`,
+ },
permissionMode: {
title: 'MODO DE PERMISSÃO',
default: 'Padrão',
acceptEdits: 'Aceitar edições',
plan: 'Modo de planejamento',
bypassPermissions: 'Modo Yolo',
+ badgeAccept: 'Aceitar',
+ badgePlan: 'Plano',
+ badgeYolo: 'YOLO',
badgeAcceptAllEdits: 'Aceitar todas as edições',
badgeBypassAllPermissions: 'Ignorar todas as permissões',
badgePlanMode: 'Modo de planejamento',
@@ -412,32 +581,47 @@ export const pt: TranslationStructure = {
codexPermissionMode: {
title: 'MODO DE PERMISSÃO CODEX',
default: 'Configurações do CLI',
- readOnly: 'Read Only Mode',
- safeYolo: 'Safe YOLO',
+ readOnly: 'Modo somente leitura',
+ safeYolo: 'YOLO seguro',
yolo: 'YOLO',
- badgeReadOnly: 'Read Only Mode',
- badgeSafeYolo: 'Safe YOLO',
+ badgeReadOnly: 'Somente leitura',
+ badgeSafeYolo: 'YOLO seguro',
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',
+ title: 'MODELO CODEX',
+ gpt5CodexLow: 'gpt-5-codex baixo',
+ gpt5CodexMedium: 'gpt-5-codex médio',
+ gpt5CodexHigh: 'gpt-5-codex alto',
+ gpt5Minimal: 'GPT-5 Mínimo',
+ gpt5Low: 'GPT-5 Baixo',
+ gpt5Medium: 'GPT-5 Médio',
+ gpt5High: 'GPT-5 Alto',
},
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`,
@@ -446,6 +630,11 @@ export const pt: TranslationStructure = {
fileLabel: 'ARQUIVO',
folderLabel: 'PASTA',
},
+ actionMenu: {
+ title: 'AÇÕES',
+ files: 'Arquivos',
+ stop: 'Parar',
+ },
noMachinesAvailable: 'Sem máquinas',
},
@@ -504,6 +693,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})`,
@@ -546,7 +739,7 @@ export const pt: TranslationStructure = {
loadingFile: ({ fileName }: { fileName: string }) => `Carregando ${fileName}...`,
binaryFile: 'Arquivo binário',
cannotDisplayBinary: 'Não é possível exibir o conteúdo do arquivo binário',
- diff: 'Diff',
+ diff: 'Diferenças',
file: 'Arquivo',
fileEmpty: 'Arquivo está vazio',
noChanges: 'Nenhuma alteração para exibir',
@@ -666,6 +859,11 @@ export const pt: TranslationStructure = {
deviceLinkedSuccessfully: 'Dispositivo vinculado com sucesso',
terminalConnectedSuccessfully: 'Terminal conectado com sucesso',
invalidAuthUrl: 'URL de autenticação inválida',
+ microphoneAccessRequiredTitle: 'É necessário acesso ao microfone',
+ microphoneAccessRequiredRequestPermission: 'O Happy precisa de acesso ao seu microfone para o chat por voz. Conceda a permissão quando solicitado.',
+ microphoneAccessRequiredEnableInSettings: 'O Happy precisa de acesso ao seu microfone para o chat por voz. Ative o acesso ao microfone nas configurações do seu dispositivo.',
+ microphoneAccessRequiredBrowserInstructions: 'Permita o acesso ao microfone nas configurações do navegador. Talvez seja necessário clicar no ícone de cadeado na barra de endereços e habilitar a permissão do microfone para este site.',
+ openSettings: 'Abrir configurações',
developerMode: 'Modo desenvolvedor',
developerModeEnabled: 'Modo desenvolvedor ativado',
developerModeDisabled: 'Modo desenvolvedor desativado',
@@ -720,6 +918,15 @@ export const pt: TranslationStructure = {
daemon: 'Daemon',
status: 'Status',
stopDaemon: 'Parar daemon',
+ stopDaemonConfirmTitle: 'Parar daemon?',
+ stopDaemonConfirmBody: 'Você não poderá iniciar novas sessões nesta máquina até reiniciar o daemon no seu computador. Suas sessões atuais continuarão ativas.',
+ daemonStoppedTitle: 'Daemon parado',
+ stopDaemonFailed: 'Falha ao parar o daemon. Talvez ele não esteja em execução.',
+ renameTitle: 'Renomear máquina',
+ renameDescription: 'Dê a esta máquina um nome personalizado. Deixe em branco para usar o hostname padrão.',
+ renamePlaceholder: 'Digite o nome da máquina',
+ renamedSuccess: 'Máquina renomeada com sucesso',
+ renameFailed: 'Falha ao renomear a máquina',
lastKnownPid: 'Último PID conhecido',
lastKnownHttpPort: 'Última porta HTTP conhecida',
startedAt: 'Iniciado em',
@@ -736,8 +943,15 @@ export const pt: TranslationStructure = {
lastSeen: 'Visto pela última vez',
never: 'Nunca',
metadataVersion: 'Versão dos metadados',
+ detectedClis: 'CLIs detectados',
+ detectedCliNotDetected: 'Não detectado',
+ detectedCliUnknown: 'Desconhecido',
+ detectedCliNotSupported: 'Não suportado (atualize o happy-cli)',
untitledSession: 'Sessão sem título',
back: 'Voltar',
+ notFound: 'Máquina não encontrada',
+ unknownMachine: 'máquina desconhecida',
+ unknownPath: 'caminho desconhecido',
},
message: {
@@ -747,6 +961,10 @@ export const pt: TranslationStructure = {
unknownTime: 'horário desconhecido',
},
+ chatFooter: {
+ permissionsTerminalOnly: 'As permissões são mostradas apenas no terminal. Redefina ou envie uma mensagem para controlar pelo app.',
+ },
+
codex: {
// Codex permission dialog buttons
permissions: {
@@ -773,6 +991,7 @@ export const pt: TranslationStructure = {
textCopied: 'Texto copiado para a área de transferência',
failedToCopy: 'Falha ao copiar o texto para a área de transferência',
noTextToCopy: 'Nenhum texto disponível para copiar',
+ failedToOpen: 'Falha ao abrir a seleção de texto. Tente novamente.',
},
markdown: {
@@ -792,11 +1011,14 @@ export const pt: TranslationStructure = {
edit: 'Editar artefato',
delete: 'Excluir',
updateError: 'Falha ao atualizar artefato. Por favor, tente novamente.',
+ deleteError: 'Falha ao excluir o artefato. Tente novamente.',
notFound: 'Artefato não encontrado',
discardChanges: 'Descartar alterações?',
discardChangesDescription: 'Você tem alterações não salvas. Tem certeza de que deseja descartá-las?',
deleteConfirm: 'Excluir artefato?',
deleteConfirmDescription: 'Este artefato será excluído permanentemente.',
+ noContent: 'Sem conteúdo',
+ untitled: 'Sem título',
titlePlaceholder: 'Título do artefato',
bodyPlaceholder: 'Digite o conteúdo aqui...',
save: 'Salvar',
@@ -894,8 +1116,213 @@ 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',
+ custom: 'Personalizado',
+ builtInSaveAsHint: 'Salvar um perfil integrado cria um novo perfil personalizado.',
+ builtInNames: {
+ anthropic: 'Anthropic (Padrão)',
+ deepseek: 'DeepSeek (Raciocínio)',
+ 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: '(Cópia)',
+ duplicateName: 'Já existe um perfil com este nome',
+ setupInstructions: {
+ title: 'Instruções de configuração',
+ viewOfficialGuide: 'Ver guia oficial de configuração',
+ },
+ machineLogin: {
+ title: 'Login necessário na máquina',
+ subtitle: 'Este perfil depende do cache de login do CLI na máquina selecionada.',
+ claudeCode: {
+ title: 'Claude Code',
+ instructions: 'Execute `claude` e depois digite `/login` para entrar.',
+ warning: 'Obs.: definir `ANTHROPIC_AUTH_TOKEN` substitui o login do CLI.',
+ },
+ codex: {
+ title: 'Codex',
+ instructions: 'Execute `codex login` para entrar.',
+ },
+ geminiCli: {
+ title: 'Gemini CLI',
+ instructions: 'Execute `gemini auth` para entrar.',
+ },
+ },
+ requirements: {
+ apiKeyRequired: 'Chave de API',
+ configured: 'Configurada na máquina',
+ notConfigured: 'Não configurada',
+ checking: 'Verificando…',
+ modalTitle: 'Chave de API necessária',
+ modalBody: 'Este perfil requer uma chave de API.\n\nOpções disponíveis:\n• Usar ambiente da máquina (recomendado)\n• Usar chave salva nas configurações do app\n• Inserir uma chave apenas para esta sessão',
+ sectionTitle: 'Requisitos',
+ sectionSubtitle: 'Estes campos são usados para checar a prontidão e evitar falhas inesperadas.',
+ secretEnvVarPromptDescription: 'Digite o nome da variável de ambiente secreta necessária (ex.: OPENAI_API_KEY).',
+ modalHelpWithEnv: ({ env }: { env: string }) => `Este perfil precisa de ${env}. Escolha uma opção abaixo.`,
+ modalHelpGeneric: 'Este perfil precisa de uma chave de API. Escolha uma opção abaixo.',
+ modalRecommendation: 'Recomendado: defina a chave no ambiente do daemon no seu computador (para não precisar colar novamente). Depois reinicie o daemon para ele carregar a nova variável de ambiente.',
+ chooseOptionTitle: 'Escolha uma opção',
+ machineEnvStatus: {
+ theMachine: 'a máquina',
+ checkFor: ({ env }: { env: string }) => `Verificar ${env}`,
+ checking: ({ env }: { env: string }) => `Verificando ${env}…`,
+ found: ({ env, machine }: { env: string; machine: string }) => `${env} encontrado em ${machine}`,
+ notFound: ({ env, machine }: { env: string; machine: string }) => `${env} não encontrado em ${machine}`,
+ },
+ machineEnvSubtitle: {
+ checking: 'Verificando ambiente do daemon…',
+ found: 'Encontrado no ambiente do daemon na máquina.',
+ notFound: 'Defina no ambiente do daemon na máquina e reinicie o daemon.',
+ },
+ options: {
+ none: {
+ title: 'Nenhum',
+ subtitle: 'Não requer chave de API nem login via CLI.',
+ },
+ apiKeyEnv: {
+ subtitle: 'Requer uma chave de API para ser injetada no início da sessão.',
+ },
+ machineLogin: {
+ subtitle: 'Requer estar logado via um CLI na máquina de destino.',
+ longSubtitle: 'Requer estar logado via o CLI do backend de IA escolhido na máquina de destino.',
+ },
+ useMachineEnvironment: {
+ title: 'Usar ambiente da máquina',
+ subtitleWithEnv: ({ env }: { env: string }) => `Usar ${env} do ambiente do daemon.`,
+ subtitleGeneric: 'Usar a chave do ambiente do daemon.',
+ },
+ useSavedApiKey: {
+ title: 'Usar uma chave de API salva',
+ subtitle: 'Selecione (ou adicione) uma chave salva no app.',
+ },
+ enterOnce: {
+ title: 'Inserir uma chave',
+ subtitle: 'Cole uma chave apenas para esta sessão (não será salva).',
+ },
+ },
+ apiKeyEnvVar: {
+ title: 'Variável de ambiente da chave de API',
+ subtitle: 'Digite o nome da variável de ambiente que este provedor espera para a chave de API (ex.: OPENAI_API_KEY).',
+ label: 'Nome da variável de ambiente',
+ },
+ sections: {
+ machineEnvironment: 'Ambiente da máquina',
+ useOnceTitle: 'Usar uma vez',
+ useOnceFooter: 'Cole uma chave apenas para esta sessão. Ela não será salva.',
+ },
+ actions: {
+ useMachineEnvironment: {
+ subtitle: 'Começar com a chave já presente na máquina.',
+ },
+ useOnceButton: 'Usar uma vez (apenas sessã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',
+ itemTitle: 'Máquina de pré-visualização para variáveis de ambiente',
+ selectMachine: 'Selecionar máquina',
+ resolveSubtitle: 'Usada apenas para pré-visualizar os valores resolvidos abaixo (não altera o que é salvo).',
+ selectSubtitle: 'Selecione uma máquina para pré-visualizar os valores resolvidos abaixo.',
+ },
+ 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',
+ secretToggleLabel: 'Segredo',
+ secretToggleSubtitle: 'Oculta o valor na interface e evita buscá-lo da máquina para pré-visualização.',
+ secretToggleEnforcedByDaemon: 'Imposto pelo daemon',
+ secretToggleResetToAuto: 'Redefinir para automático',
+ 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: 'Alternativa',
+ 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.`,
@@ -904,6 +1331,45 @@ export const pt: TranslationStructure = {
},
},
+ apiKeys: {
+ addTitle: 'Nova chave de API',
+ savedTitle: 'Chaves de API salvas',
+ badgeReady: 'Chave de API',
+ badgeRequired: 'Chave de API necessária',
+ addSubtitle: 'Adicionar uma chave de API salva',
+ noneTitle: 'Nenhuma',
+ noneSubtitle: 'Use o ambiente da máquina ou insira uma chave para esta sessão',
+ emptyTitle: 'Nenhuma chave salva',
+ emptySubtitle: 'Adicione uma para usar perfis com chave de API sem configurar variáveis de ambiente na máquina.',
+ savedHiddenSubtitle: 'Salva (valor oculto)',
+ defaultLabel: 'Padrão',
+ fields: {
+ name: 'Nome',
+ value: 'Valor',
+ },
+ placeholders: {
+ nameExample: 'ex.: Work OpenAI',
+ },
+ validation: {
+ nameRequired: 'Nome é obrigatório.',
+ valueRequired: 'Valor é obrigatório.',
+ },
+ actions: {
+ replace: 'Substituir',
+ replaceValue: 'Substituir valor',
+ setDefault: 'Definir como padrão',
+ unsetDefault: 'Remover padrão',
+ },
+ prompts: {
+ renameTitle: 'Renomear chave de API',
+ renameDescription: 'Atualize o nome amigável desta chave.',
+ replaceValueTitle: 'Substituir valor da chave de API',
+ replaceValueDescription: 'Cole o novo valor da chave de API. Este valor não será mostrado novamente após salvar.',
+ deleteTitle: 'Excluir chave de API',
+ deleteConfirm: ({ name }: { name: string }) => `Excluir “${name}”? Esta ação não pode ser desfeita.`,
+ },
+ },
+
feed: {
// Feed notifications for friend requests and acceptances
friendRequestFrom: ({ name }: { name: string }) => `${name} enviou-lhe um pedido de amizade`,
diff --git a/sources/text/translations/ru.ts b/sources/text/translations/ru.ts
index aa533ea82..0034f37dd 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,11 @@ export const ru: TranslationStructure = {
retry: 'Повторить',
delete: 'Удалить',
optional: 'необязательно',
+ noMatches: 'Нет совпадений',
+ all: 'Все',
+ machine: 'машина',
+ clearSearch: 'Очистить поиск',
+ refresh: 'Обновить',
},
connect: {
@@ -78,6 +88,15 @@ export const ru: TranslationStructure = {
enterSecretKey: 'Пожалуйста, введите секретный ключ',
invalidSecretKey: 'Неверный секретный ключ. Проверьте и попробуйте снова.',
enterUrlManually: 'Ввести URL вручную',
+ terminalUrlPlaceholder: 'happy://terminal?...',
+ restoreQrInstructions: '1. Откройте Happy на мобильном устройстве\n2. Перейдите в Настройки → Аккаунт\n3. Нажмите «Подключить новое устройство»\n4. Отсканируйте этот QR-код',
+ restoreWithSecretKeyInstead: 'Восстановить по секретному ключу',
+ restoreWithSecretKeyDescription: 'Введите секретный ключ, чтобы восстановить доступ к аккаунту.',
+ secretKeyPlaceholder: 'XXXXX-XXXXX-XXXXX...',
+ unsupported: {
+ connectTitle: ({ name }: { name: string }) => `Подключить ${name}`,
+ runCommandInTerminal: 'Выполните следующую команду в терминале:',
+ },
},
settings: {
@@ -118,11 +137,13 @@ export const ru: TranslationStructure = {
usageSubtitle: 'Просмотр использования API и затрат',
profiles: 'Профили',
profilesSubtitle: 'Управление профилями переменных окружения для сессий',
+ apiKeys: 'API-ключи',
+ apiKeysSubtitle: 'Управление сохранёнными API-ключами (после ввода больше не показываются)',
// Dynamic settings messages
accountConnected: ({ service }: { service: string }) => `Аккаунт ${service} подключен`,
machineStatus: ({ name, status }: { name: string; status: 'online' | 'offline' }) =>
- `${name} ${status === 'online' ? 'online' : 'offline'}`,
+ `${name} ${status === 'online' ? 'в сети' : 'не в сети'}`,
featureToggled: ({ feature, enabled }: { feature: string; enabled: boolean }) =>
`${feature} ${enabled ? 'включена' : 'отключена'}`,
},
@@ -155,6 +176,21 @@ export const ru: TranslationStructure = {
wrapLinesInDiffsDescription: 'Переносить длинные строки вместо горизонтальной прокрутки в представлениях различий',
alwaysShowContextSize: 'Всегда показывать размер контекста',
alwaysShowContextSizeDescription: 'Отображать использование контекста даже когда не близко к лимиту',
+ agentInputActionBarLayout: 'Панель действий ввода',
+ agentInputActionBarLayoutDescription: 'Выберите, как отображаются действия над полем ввода',
+ agentInputActionBarLayoutOptions: {
+ auto: 'Авто',
+ wrap: 'Перенос',
+ scroll: 'Прокрутка',
+ collapsed: 'Свернуто',
+ },
+ agentInputChipDensity: 'Плотность чипов действий',
+ agentInputChipDensityDescription: 'Выберите, показывать ли чипы действий с подписями или только значками',
+ agentInputChipDensityOptions: {
+ auto: 'Авто',
+ labels: 'Подписи',
+ icons: 'Только значки',
+ },
avatarStyle: 'Стиль аватара',
avatarStyleDescription: 'Выберите внешний вид аватара сессии',
avatarOptions: {
@@ -175,21 +211,46 @@ export const ru: TranslationStructure = {
experimentalFeatures: 'Экспериментальные функции',
experimentalFeaturesEnabled: 'Экспериментальные функции включены',
experimentalFeaturesDisabled: 'Используются только стабильные функции',
+ experimentalOptions: 'Экспериментальные опции',
+ experimentalOptionsDescription: 'Выберите, какие экспериментальные функции включены.',
+ expGemini: 'Gemini',
+ expGeminiSubtitle: 'Enable Gemini CLI sessions and Gemini-related UI',
+ expUsageReporting: 'Usage reporting',
+ expUsageReportingSubtitle: 'Enable usage and token reporting screens',
+ expFileViewer: 'File viewer',
+ expFileViewerSubtitle: 'Enable the session file viewer entrypoint',
+ expShowThinkingMessages: 'Show thinking messages',
+ expShowThinkingMessagesSubtitle: 'Show assistant thinking/status messages in chat',
+ expSessionType: 'Session type selector',
+ expSessionTypeSubtitle: 'Show the session type selector (simple vs worktree)',
+ expZen: 'Zen',
+ expZenSubtitle: 'Enable the Zen navigation entry',
+ expVoiceAuthFlow: 'Voice auth flow',
+ expVoiceAuthFlowSubtitle: 'Use authenticated voice token flow (paywall-aware)',
webFeatures: 'Веб-функции',
webFeaturesDescription: 'Функции, доступные только в веб-версии приложения.',
enterToSend: 'Enter для отправки',
enterToSendEnabled: 'Нажмите Enter для отправки (Shift+Enter для новой строки)',
enterToSendDisabled: 'Enter вставляет новую строку',
- commandPalette: 'Command Palette',
+ commandPalette: 'Палитра команд',
commandPaletteEnabled: 'Нажмите ⌘K для открытия',
commandPaletteDisabled: 'Быстрый доступ к командам отключён',
- markdownCopyV2: 'Markdown Copy v2',
+ markdownCopyV2: 'Копирование Markdown v2',
markdownCopyV2Subtitle: 'Долгое нажатие открывает модальное окно копирования',
hideInactiveSessions: 'Скрывать неактивные сессии',
hideInactiveSessionsSubtitle: 'Показывать в списке только активные чаты',
enhancedSessionWizard: 'Улучшенный мастер сессий',
enhancedSessionWizardEnabled: 'Лаунчер с профилем активен',
enhancedSessionWizardDisabled: 'Используется стандартный лаунчер',
+ profiles: 'Профили ИИ',
+ profilesEnabled: 'Выбор профилей включён',
+ profilesDisabled: 'Выбор профилей отключён',
+ pickerSearch: 'Поиск в выборе',
+ pickerSearchSubtitle: 'Показывать поле поиска в выборе машины и пути',
+ machinePickerSearch: 'Поиск машин',
+ machinePickerSearchSubtitle: 'Показывать поле поиска при выборе машины',
+ pathPickerSearch: 'Поиск путей',
+ pathPickerSearchSubtitle: 'Показывать поле поиска при выборе пути',
},
errors: {
@@ -242,8 +303,29 @@ export const ru: TranslationStructure = {
newSession: {
// Used by new-session screen and launch flows
title: 'Начать новую сессию',
+ selectAiProfileTitle: 'Выбрать профиль ИИ',
+ selectAiProfileDescription: 'Выберите профиль ИИ, чтобы применить переменные окружения и настройки по умолчанию к вашей сессии.',
+ changeProfile: 'Сменить профиль',
+ aiBackendSelectedByProfile: 'Бэкенд ИИ выбирается вашим профилем. Чтобы изменить его, выберите другой профиль.',
+ selectAiBackendTitle: 'Выбрать бэкенд ИИ',
+ aiBackendLimitedByProfileAndMachineClis: 'Ограничено выбранным профилем и доступными CLI на этой машине.',
+ aiBackendSelectWhichAiRuns: 'Выберите, какой ИИ будет работать в вашей сессии.',
+ aiBackendNotCompatibleWithSelectedProfile: 'Несовместимо с выбранным профилем.',
+ aiBackendCliNotDetectedOnMachine: ({ cli }: { cli: string }) => `${cli} CLI не обнаружен на этой машине.`,
+ selectMachineTitle: 'Выбрать машину',
+ selectMachineDescription: 'Выберите, где будет выполняться эта сессия.',
+ selectPathTitle: 'Выбрать путь',
+ selectWorkingDirectoryTitle: 'Выбрать рабочую директорию',
+ selectWorkingDirectoryDescription: 'Выберите папку, используемую для команд и контекста.',
+ selectPermissionModeTitle: 'Выбрать режим разрешений',
+ selectPermissionModeDescription: 'Настройте, насколько строго действия требуют подтверждения.',
+ selectModelTitle: 'Выбрать модель ИИ',
+ selectModelDescription: 'Выберите модель, используемую этой сессией.',
+ selectSessionTypeTitle: 'Выбрать тип сессии',
+ selectSessionTypeDescription: 'Выберите простую сессию или сессию, привязанную к Git worktree.',
+ searchPathsPlaceholder: 'Поиск путей...',
noMachinesFound: 'Машины не найдены. Сначала запустите сессию Happy на вашем компьютере.',
- allMachinesOffline: 'Все машины находятся offline',
+ allMachinesOffline: 'Все машины не в сети',
machineDetails: 'Посмотреть детали машины →',
directoryDoesNotExist: 'Директория не найдена',
createDirectoryConfirm: ({ directory }: { directory: string }) => `Директория ${directory} не существует. Хотите создать её?`,
@@ -257,12 +339,46 @@ 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: 'Простая',
- worktree: 'Worktree',
+ worktree: 'Рабочее дерево',
comingSoon: 'Скоро будет доступно',
},
+ profileAvailability: {
+ requiresAgent: ({ agent }: { agent: string }) => `Требуется ${agent}`,
+ cliNotDetected: ({ cli }: { cli: string }) => `${cli} CLI не обнаружен`,
+ },
+ cliBanners: {
+ cliNotDetectedTitle: ({ cli }: { cli: string }) => `${cli} CLI не обнаружен`,
+ dontShowFor: 'Не показывать это предупреждение для',
+ thisMachine: 'этой машины',
+ anyMachine: 'любой машины',
+ installCommand: ({ command }: { command: string }) => `Установить: ${command} •`,
+ installCliIfAvailable: ({ cli }: { cli: string }) => `Установите ${cli} CLI, если доступно •`,
+ viewInstallationGuide: 'Открыть руководство по установке →',
+ viewGeminiDocs: 'Открыть документацию Gemini →',
+ },
worktree: {
creating: ({ name }: { name: string }) => `Создание worktree '${name}'...`,
notGitRepo: 'Worktree требует наличия git репозитория',
@@ -310,6 +426,7 @@ export const ru: TranslationStructure = {
happySessionId: 'ID сессии Happy',
claudeCodeSessionId: 'ID сессии Claude Code',
claudeCodeSessionIdCopied: 'ID сессии Claude Code скопирован в буфер обмена',
+ aiProfile: 'Профиль ИИ',
aiProvider: 'Поставщик ИИ',
failedToCopyClaudeCodeSessionId: 'Не удалось скопировать ID сессии Claude Code',
metadataCopied: 'Метаданные скопированы в буфер обмена',
@@ -333,6 +450,9 @@ export const ru: TranslationStructure = {
happyHome: 'Домашний каталог Happy',
copyMetadata: 'Копировать метаданные',
agentState: 'Состояние агента',
+ rawJsonDevMode: 'Сырой JSON (режим разработчика)',
+ sessionStatus: 'Статус сессии',
+ fullSessionObject: 'Полный объект сессии',
controlledByUser: 'Управляется пользователем',
pendingRequests: 'Ожидающие запросы',
activity: 'Активность',
@@ -359,6 +479,35 @@ export const ru: TranslationStructure = {
runIt: 'Запустите его',
scanQrCode: 'Отсканируйте QR-код',
openCamera: 'Открыть камеру',
+ installCommand: '$ npm i -g happy-coder',
+ runCommand: '$ happy',
+ },
+ emptyMessages: {
+ noMessagesYet: 'Сообщений пока нет',
+ created: ({ time }: { time: string }) => `Создано ${time}`,
+ },
+ emptySessionsTablet: {
+ noActiveSessions: 'Нет активных сессий',
+ startNewSessionDescription: 'Запустите новую сессию на любой из подключённых машин.',
+ startNewSessionButton: 'Новая сессия',
+ openTerminalToStart: 'Откройте новый терминал на компьютере, чтобы начать сессию.',
+ },
+ },
+
+ zen: {
+ title: 'Zen',
+ add: {
+ placeholder: 'Что нужно сделать?',
+ },
+ home: {
+ noTasksYet: 'Пока нет задач. Нажмите +, чтобы добавить.',
+ },
+ view: {
+ workOnTask: 'Работать над задачей',
+ clarify: 'Уточнить',
+ delete: 'Удалить',
+ linkedSessions: 'Связанные сессии',
+ tapTaskTextToEdit: 'Нажмите на текст задачи, чтобы отредактировать',
},
},
@@ -377,8 +526,8 @@ export const ru: TranslationStructure = {
connecting: 'подключение',
disconnected: 'отключено',
error: 'ошибка',
- online: 'online',
- offline: 'offline',
+ online: 'в сети',
+ offline: 'не в сети',
lastSeen: ({ time }: { time: string }) => `в сети ${time}`,
permissionRequired: 'требуется разрешение',
activeNow: 'Активен сейчас',
@@ -397,15 +546,35 @@ export const ru: TranslationStructure = {
commandPalette: {
placeholder: 'Введите команду или поиск...',
+ noCommandsFound: 'Команды не найдены',
+ },
+
+ commandView: {
+ completedWithNoOutput: '[Команда завершена без вывода]',
+ },
+
+ voiceAssistant: {
+ connecting: 'Подключение...',
+ active: 'Голосовой ассистент активен',
+ connectionError: 'Ошибка соединения',
+ label: 'Голосовой ассистент',
+ tapToEnd: 'Нажмите, чтобы завершить',
},
agentInput: {
+ envVars: {
+ title: 'Переменные окружения',
+ titleWithCount: ({ count }: { count: number }) => `Переменные окружения (${count})`,
+ },
permissionMode: {
title: 'РЕЖИМ РАЗРЕШЕНИЙ',
default: 'По умолчанию',
acceptEdits: 'Принимать правки',
plan: 'Режим планирования',
bypassPermissions: 'YOLO режим',
+ badgeAccept: 'Принять',
+ badgePlan: 'План',
+ badgeYolo: 'YOLO',
badgeAcceptAllEdits: 'Принимать все правки',
badgeBypassAllPermissions: 'Обход всех разрешений',
badgePlanMode: 'Режим планирования',
@@ -422,22 +591,22 @@ export const ru: TranslationStructure = {
codexPermissionMode: {
title: 'РЕЖИМ РАЗРЕШЕНИЙ CODEX',
default: 'Настройки CLI',
- readOnly: 'Read Only Mode',
- safeYolo: 'Safe YOLO',
+ readOnly: 'Только чтение',
+ safeYolo: 'Безопасный YOLO',
yolo: 'YOLO',
badgeReadOnly: 'Только чтение',
- badgeSafeYolo: 'Safe YOLO',
+ badgeSafeYolo: 'Безопасный 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',
+ title: 'МОДЕЛЬ CODEX',
+ gpt5CodexLow: 'gpt-5-codex низкий',
+ gpt5CodexMedium: 'gpt-5-codex средний',
+ gpt5CodexHigh: 'gpt-5-codex высокий',
+ gpt5Minimal: 'GPT-5 Минимальный',
+ gpt5Low: 'GPT-5 Низкий',
+ gpt5Medium: 'GPT-5 Средний',
+ gpt5High: 'GPT-5 Высокий',
},
geminiPermissionMode: {
title: 'РЕЖИМ РАЗРЕШЕНИЙ',
@@ -449,6 +618,21 @@ export const ru: TranslationStructure = {
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}%`,
},
@@ -456,6 +640,11 @@ export const ru: TranslationStructure = {
fileLabel: 'ФАЙЛ',
folderLabel: 'ПАПКА',
},
+ actionMenu: {
+ title: 'ДЕЙСТВИЯ',
+ files: 'Файлы',
+ stop: 'Остановить',
+ },
noMachinesAvailable: 'Нет машин',
},
@@ -514,6 +703,10 @@ export const ru: TranslationStructure = {
applyChanges: 'Обновить файл',
viewDiff: 'Текущие изменения файла',
question: 'Вопрос',
+ changeTitle: 'Изменить заголовок',
+ },
+ geminiExecute: {
+ cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`,
},
desc: {
terminalCmd: ({ cmd }: { cmd: string }) => `Терминал(команда: ${cmd})`,
@@ -664,6 +857,11 @@ export const ru: TranslationStructure = {
deviceLinkedSuccessfully: 'Устройство успешно связано',
terminalConnectedSuccessfully: 'Терминал успешно подключен',
invalidAuthUrl: 'Неверный URL авторизации',
+ microphoneAccessRequiredTitle: 'Требуется доступ к микрофону',
+ microphoneAccessRequiredRequestPermission: 'Happy нужен доступ к микрофону для голосового чата. Разрешите доступ, когда появится запрос.',
+ microphoneAccessRequiredEnableInSettings: 'Happy нужен доступ к микрофону для голосового чата. Включите доступ к микрофону в настройках устройства.',
+ microphoneAccessRequiredBrowserInstructions: 'Разрешите доступ к микрофону в настройках браузера. Возможно, нужно нажать на значок замка в адресной строке и включить разрешение микрофона для этого сайта.',
+ openSettings: 'Открыть настройки',
developerMode: 'Режим разработчика',
developerModeEnabled: 'Режим разработчика включен',
developerModeDisabled: 'Режим разработчика отключен',
@@ -712,12 +910,21 @@ export const ru: TranslationStructure = {
},
machine: {
- offlineUnableToSpawn: 'Запуск отключен: машина offline',
- offlineHelp: '• Убедитесь, что компьютер online\n• Выполните `happy daemon status` для диагностики\n• Используете последнюю версию CLI? Обновите командой `npm install -g happy-coder@latest`',
+ offlineUnableToSpawn: 'Запуск отключён: машина офлайн',
+ offlineHelp: '• Убедитесь, что компьютер онлайн\n• Выполните `happy daemon status` для диагностики\n• Используете последнюю версию CLI? Обновите командой `npm install -g happy-coder@latest`',
launchNewSessionInDirectory: 'Запустить новую сессию в папке',
- daemon: 'Daemon',
+ daemon: 'Демон',
status: 'Статус',
stopDaemon: 'Остановить daemon',
+ stopDaemonConfirmTitle: 'Остановить демон?',
+ stopDaemonConfirmBody: 'Вы не сможете создавать новые сессии на этой машине, пока не перезапустите демон на компьютере. Текущие сессии останутся активными.',
+ daemonStoppedTitle: 'Демон остановлен',
+ stopDaemonFailed: 'Не удалось остановить демон. Возможно, он не запущен.',
+ renameTitle: 'Переименовать машину',
+ renameDescription: 'Дайте этой машине имя. Оставьте пустым, чтобы использовать hostname по умолчанию.',
+ renamePlaceholder: 'Введите имя машины',
+ renamedSuccess: 'Машина успешно переименована',
+ renameFailed: 'Не удалось переименовать машину',
lastKnownPid: 'Последний известный PID',
lastKnownHttpPort: 'Последний известный HTTP порт',
startedAt: 'Запущен в',
@@ -734,8 +941,15 @@ export const ru: TranslationStructure = {
lastSeen: 'Последняя активность',
never: 'Никогда',
metadataVersion: 'Версия метаданных',
+ detectedClis: 'Обнаруженные CLI',
+ detectedCliNotDetected: 'Не обнаружено',
+ detectedCliUnknown: 'Неизвестно',
+ detectedCliNotSupported: 'Не поддерживается (обновите happy-cli)',
untitledSession: 'Безымянная сессия',
back: 'Назад',
+ notFound: 'Машина не найдена',
+ unknownMachine: 'неизвестная машина',
+ unknownPath: 'неизвестный путь',
},
message: {
@@ -745,6 +959,10 @@ export const ru: TranslationStructure = {
unknownTime: 'неизвестное время',
},
+ chatFooter: {
+ permissionsTerminalOnly: 'Разрешения отображаются только в терминале. Сбросьте их или отправьте сообщение, чтобы управлять из приложения.',
+ },
+
codex: {
// Codex permission dialog buttons
permissions: {
@@ -783,6 +1001,7 @@ export const ru: TranslationStructure = {
textCopied: 'Текст скопирован в буфер обмена',
failedToCopy: 'Не удалось скопировать текст в буфер обмена',
noTextToCopy: 'Нет текста для копирования',
+ failedToOpen: 'Не удалось открыть выбор текста. Пожалуйста, попробуйте снова.',
},
markdown: {
@@ -815,11 +1034,14 @@ export const ru: TranslationStructure = {
edit: 'Редактировать артефакт',
delete: 'Удалить',
updateError: 'Не удалось обновить артефакт. Пожалуйста, попробуйте еще раз.',
+ deleteError: 'Не удалось удалить артефакт. Пожалуйста, попробуйте снова.',
notFound: 'Артефакт не найден',
discardChanges: 'Отменить изменения?',
discardChangesDescription: 'У вас есть несохраненные изменения. Вы уверены, что хотите их отменить?',
deleteConfirm: 'Удалить артефакт?',
deleteConfirmDescription: 'Это действие нельзя отменить',
+ noContent: 'Нет содержимого',
+ untitled: 'Без названия',
titleLabel: 'ЗАГОЛОВОК',
titlePlaceholder: 'Введите заголовок для вашего артефакта',
bodyLabel: 'СОДЕРЖИМОЕ',
@@ -905,6 +1127,45 @@ export const ru: TranslationStructure = {
friendAcceptedGeneric: 'Запрос в друзья принят',
},
+ apiKeys: {
+ addTitle: 'Новый API-ключ',
+ savedTitle: 'Сохранённые API-ключи',
+ badgeReady: 'API‑ключ',
+ badgeRequired: 'Требуется API‑ключ',
+ addSubtitle: 'Добавить сохранённый API-ключ',
+ noneTitle: 'Нет',
+ noneSubtitle: 'Используйте окружение машины или введите ключ для этой сессии',
+ emptyTitle: 'Нет сохранённых ключей',
+ emptySubtitle: 'Добавьте ключ, чтобы использовать профили с API-ключом без переменных окружения на машине.',
+ savedHiddenSubtitle: 'Сохранён (значение скрыто)',
+ defaultLabel: 'По умолчанию',
+ fields: {
+ name: 'Имя',
+ value: 'Значение',
+ },
+ placeholders: {
+ nameExample: 'например, Work OpenAI',
+ },
+ validation: {
+ nameRequired: 'Имя обязательно.',
+ valueRequired: 'Значение обязательно.',
+ },
+ actions: {
+ replace: 'Заменить',
+ replaceValue: 'Заменить значение',
+ setDefault: 'Сделать по умолчанию',
+ unsetDefault: 'Убрать по умолчанию',
+ },
+ prompts: {
+ renameTitle: 'Переименовать API-ключ',
+ renameDescription: 'Обновите понятное имя для этого ключа.',
+ replaceValueTitle: 'Заменить значение API-ключа',
+ replaceValueDescription: 'Вставьте новое значение API-ключа. После сохранения оно больше не будет показано.',
+ deleteTitle: 'Удалить API-ключ',
+ deleteConfirm: ({ name }: { name: string }) => `Удалить «${name}»? Это нельзя отменить.`,
+ },
+ },
+
profiles: {
// Profile management feature
title: 'Профили',
@@ -925,9 +1186,216 @@ export const ru: TranslationStructure = {
enterTmuxTempDir: 'Введите путь к временному каталогу',
tmuxUpdateEnvironment: 'Обновлять окружение автоматически',
nameRequired: 'Имя профиля обязательно',
- deleteConfirm: 'Вы уверены, что хотите удалить профиль "{name}"?',
+ deleteConfirm: ({ name }: { name: string }) => `Вы уверены, что хотите удалить профиль "${name}"?`,
editProfile: 'Редактировать Профиль',
addProfileTitle: 'Добавить Новый Профиль',
+ builtIn: 'Встроенный',
+ custom: 'Пользовательский',
+ builtInSaveAsHint: 'Сохранение встроенного профиля создаёт новый пользовательский профиль.',
+ builtInNames: {
+ anthropic: 'Anthropic (по умолчанию)',
+ deepseek: 'DeepSeek (Рассуждение)',
+ 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: '(Копия)',
+ duplicateName: 'Профиль с таким названием уже существует',
+ setupInstructions: {
+ title: 'Инструкции по настройке',
+ viewOfficialGuide: 'Открыть официальное руководство',
+ },
+ machineLogin: {
+ title: 'Требуется вход на машине',
+ subtitle: 'Этот профиль использует кэш входа CLI на выбранной машине.',
+ claudeCode: {
+ title: 'Claude Code',
+ instructions: 'Запустите `claude`, затем введите `/login`, чтобы войти.',
+ warning: 'Примечание: установка `ANTHROPIC_AUTH_TOKEN` переопределяет вход через CLI.',
+ },
+ codex: {
+ title: 'Codex',
+ instructions: 'Выполните `codex login`, чтобы войти.',
+ },
+ geminiCli: {
+ title: 'Gemini CLI',
+ instructions: 'Выполните `gemini auth`, чтобы войти.',
+ },
+ },
+ requirements: {
+ apiKeyRequired: 'API-ключ',
+ configured: 'Настроен на машине',
+ notConfigured: 'Не настроен',
+ checking: 'Проверка…',
+ modalTitle: 'Требуется API-ключ',
+ modalBody: 'Для этого профиля требуется API-ключ.\n\nДоступные варианты:\n• Использовать окружение машины (рекомендуется)\n• Использовать сохранённый ключ из настроек приложения\n• Ввести ключ только для этой сессии',
+ sectionTitle: 'Требования',
+ sectionSubtitle: 'Эти поля используются для предварительной проверки готовности и чтобы избежать неожиданных ошибок.',
+ secretEnvVarPromptDescription: 'Введите имя обязательной секретной переменной окружения (например, OPENAI_API_KEY).',
+ modalHelpWithEnv: ({ env }: { env: string }) => `Для этого профиля требуется ${env}. Выберите один вариант ниже.`,
+ modalHelpGeneric: 'Для этого профиля требуется API-ключ. Выберите один вариант ниже.',
+ modalRecommendation: 'Рекомендуется: задайте ключ в окружении демона на компьютере (чтобы не вставлять его снова). Затем перезапустите демон, чтобы он подхватил новую переменную окружения.',
+ chooseOptionTitle: 'Выберите вариант',
+ machineEnvStatus: {
+ theMachine: 'машине',
+ checkFor: ({ env }: { env: string }) => `Проверить ${env}`,
+ checking: ({ env }: { env: string }) => `Проверяем ${env}…`,
+ found: ({ env, machine }: { env: string; machine: string }) => `${env} найден на ${machine}`,
+ notFound: ({ env, machine }: { env: string; machine: string }) => `${env} не найден на ${machine}`,
+ },
+ machineEnvSubtitle: {
+ checking: 'Проверяем окружение демона…',
+ found: 'Найдено в окружении демона на машине.',
+ notFound: 'Укажите значение в окружении демона на машине и перезапустите демон.',
+ },
+ options: {
+ none: {
+ title: 'Нет',
+ subtitle: 'Не требует API-ключа или входа через CLI.',
+ },
+ apiKeyEnv: {
+ subtitle: 'Требуется API-ключ, который будет передан при запуске сессии.',
+ },
+ machineLogin: {
+ subtitle: 'Требуется вход через CLI на целевой машине.',
+ longSubtitle: 'Требуется быть авторизованным через CLI для выбранного бэкенда ИИ на целевой машине.',
+ },
+ useMachineEnvironment: {
+ title: 'Использовать окружение машины',
+ subtitleWithEnv: ({ env }: { env: string }) => `Использовать ${env} из окружения демона.`,
+ subtitleGeneric: 'Использовать ключ из окружения демона.',
+ },
+ useSavedApiKey: {
+ title: 'Использовать сохранённый API-ключ',
+ subtitle: 'Выберите (или добавьте) сохранённый ключ в приложении.',
+ },
+ enterOnce: {
+ title: 'Ввести ключ',
+ subtitle: 'Вставьте ключ только для этой сессии (он не будет сохранён).',
+ },
+ },
+ apiKeyEnvVar: {
+ title: 'Переменная окружения для API-ключа',
+ subtitle: 'Введите имя переменной окружения, которую этот провайдер ожидает для API-ключа (например, OPENAI_API_KEY).',
+ label: 'Имя переменной окружения',
+ },
+ sections: {
+ machineEnvironment: 'Окружение машины',
+ useOnceTitle: 'Использовать один раз',
+ useOnceFooter: 'Вставьте ключ только для этой сессии. Он не будет сохранён.',
+ },
+ actions: {
+ useMachineEnvironment: {
+ subtitle: 'Использовать ключ, который уже есть на машине.',
+ },
+ useOnceButton: 'Использовать один раз (только для сессии)',
+ },
+ },
+ defaultSessionType: 'Тип сессии по умолчанию',
+ defaultPermissionMode: {
+ title: 'Режим разрешений по умолчанию',
+ descriptions: {
+ default: 'Запрашивать разрешения',
+ acceptEdits: 'Авто-одобрять правки',
+ plan: 'Планировать перед выполнением',
+ bypassPermissions: 'Пропускать все разрешения',
+ },
+ },
+ aiBackend: {
+ title: 'Бекенд ИИ',
+ selectAtLeastOneError: 'Выберите хотя бы один бекенд ИИ.',
+ claudeSubtitle: 'CLI Claude',
+ codexSubtitle: 'CLI Codex',
+ geminiSubtitleExperimental: 'Gemini CLI (экспериментально)',
+ },
+ tmux: {
+ title: 'Tmux',
+ spawnSessionsTitle: 'Запускать сессии в Tmux',
+ spawnSessionsEnabledSubtitle: 'Сессии запускаются в новых окнах tmux.',
+ spawnSessionsDisabledSubtitle: 'Сессии запускаются в обычной оболочке (без интеграции с tmux)',
+ sessionNamePlaceholder: 'Пусто = текущая/последняя сессия',
+ tempDirPlaceholder: '/tmp (необязательно)',
+ },
+ previewMachine: {
+ title: 'Предпросмотр машины',
+ itemTitle: 'Машина предпросмотра для переменных окружения',
+ 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: 'Секретное значение — не извлекается из соображений безопасности',
+ secretToggleLabel: 'Секрет',
+ secretToggleSubtitle: 'Скрывает значение в UI и не извлекает его с машины для предварительного просмотра.',
+ secretToggleEnforcedByDaemon: 'Принудительно демоном',
+ secretToggleResetToAuto: 'Сбросить на авто',
+ 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..defbd99c4 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,11 @@ export const zhHans: TranslationStructure = {
retry: '重试',
delete: '删除',
optional: '可选的',
+ noMatches: '无匹配结果',
+ all: '全部',
+ machine: '机器',
+ clearSearch: '清除搜索',
+ refresh: '刷新',
},
profile: {
@@ -98,6 +108,15 @@ export const zhHans: TranslationStructure = {
enterSecretKey: '请输入密钥',
invalidSecretKey: '无效的密钥,请检查后重试。',
enterUrlManually: '手动输入 URL',
+ terminalUrlPlaceholder: 'happy://terminal?...',
+ restoreQrInstructions: '1. 在你的手机上打开 Happy\n2. 前往 设置 → 账户\n3. 点击“链接新设备”\n4. 扫描此二维码',
+ restoreWithSecretKeyInstead: '改用密钥恢复',
+ restoreWithSecretKeyDescription: '输入你的密钥以恢复账户访问权限。',
+ secretKeyPlaceholder: 'XXXXX-XXXXX-XXXXX...',
+ unsupported: {
+ connectTitle: ({ name }: { name: string }) => `连接 ${name}`,
+ runCommandInTerminal: '在终端中运行以下命令:',
+ },
},
settings: {
@@ -138,6 +157,8 @@ export const zhHans: TranslationStructure = {
usageSubtitle: '查看 API 使用情况和费用',
profiles: '配置文件',
profilesSubtitle: '管理环境配置文件和变量',
+ apiKeys: 'API 密钥',
+ apiKeysSubtitle: '管理已保存的 API 密钥(输入后将不再显示)',
// Dynamic settings messages
accountConnected: ({ service }: { service: string }) => `已连接 ${service} 账户`,
@@ -175,6 +196,21 @@ export const zhHans: TranslationStructure = {
wrapLinesInDiffsDescription: '在差异视图中换行显示长行而不是水平滚动',
alwaysShowContextSize: '始终显示上下文大小',
alwaysShowContextSizeDescription: '即使未接近限制时也显示上下文使用情况',
+ agentInputActionBarLayout: '输入操作栏',
+ agentInputActionBarLayoutDescription: '选择在输入框上方如何显示操作标签',
+ agentInputActionBarLayoutOptions: {
+ auto: '自动',
+ wrap: '换行',
+ scroll: '可滚动',
+ collapsed: '折叠',
+ },
+ agentInputChipDensity: '操作标签密度',
+ agentInputChipDensityDescription: '选择操作标签显示文字还是图标',
+ agentInputChipDensityOptions: {
+ auto: '自动',
+ labels: '文字',
+ icons: '仅图标',
+ },
avatarStyle: '头像风格',
avatarStyleDescription: '选择会话头像外观',
avatarOptions: {
@@ -195,6 +231,22 @@ export const zhHans: TranslationStructure = {
experimentalFeatures: '实验功能',
experimentalFeaturesEnabled: '实验功能已启用',
experimentalFeaturesDisabled: '仅使用稳定功能',
+ experimentalOptions: '实验选项',
+ experimentalOptionsDescription: '选择启用哪些实验功能。',
+ expGemini: 'Gemini',
+ expGeminiSubtitle: 'Enable Gemini CLI sessions and Gemini-related UI',
+ expUsageReporting: 'Usage reporting',
+ expUsageReportingSubtitle: 'Enable usage and token reporting screens',
+ expFileViewer: 'File viewer',
+ expFileViewerSubtitle: 'Enable the session file viewer entrypoint',
+ expShowThinkingMessages: 'Show thinking messages',
+ expShowThinkingMessagesSubtitle: 'Show assistant thinking/status messages in chat',
+ expSessionType: 'Session type selector',
+ expSessionTypeSubtitle: 'Show the session type selector (simple vs worktree)',
+ expZen: 'Zen',
+ expZenSubtitle: 'Enable the Zen navigation entry',
+ expVoiceAuthFlow: 'Voice auth flow',
+ expVoiceAuthFlowSubtitle: 'Use authenticated voice token flow (paywall-aware)',
webFeatures: 'Web 功能',
webFeaturesDescription: '仅在应用的 Web 版本中可用的功能。',
enterToSend: '回车发送',
@@ -210,6 +262,15 @@ export const zhHans: TranslationStructure = {
enhancedSessionWizard: '增强会话向导',
enhancedSessionWizardEnabled: '配置文件优先启动器已激活',
enhancedSessionWizardDisabled: '使用标准会话启动器',
+ profiles: 'AI 配置文件',
+ profilesEnabled: '已启用配置文件选择',
+ profilesDisabled: '已禁用配置文件选择',
+ pickerSearch: '选择器搜索',
+ pickerSearchSubtitle: '在设备和路径选择器中显示搜索框',
+ machinePickerSearch: '设备搜索',
+ machinePickerSearchSubtitle: '在设备选择器中显示搜索框',
+ pathPickerSearch: '路径搜索',
+ pathPickerSearchSubtitle: '在路径选择器中显示搜索框',
},
errors: {
@@ -262,6 +323,27 @@ export const zhHans: TranslationStructure = {
newSession: {
// Used by new-session screen and launch flows
title: '启动新会话',
+ selectAiProfileTitle: '选择 AI 配置',
+ selectAiProfileDescription: '选择一个 AI 配置,以将环境变量和默认值应用到会话。',
+ changeProfile: '更改配置',
+ aiBackendSelectedByProfile: 'AI 后端由所选配置决定。如需更改,请选择其他配置。',
+ selectAiBackendTitle: '选择 AI 后端',
+ aiBackendLimitedByProfileAndMachineClis: '受所选配置和此设备上可用的 CLI 限制。',
+ aiBackendSelectWhichAiRuns: '选择由哪个 AI 运行会话。',
+ aiBackendNotCompatibleWithSelectedProfile: '与所选配置不兼容。',
+ aiBackendCliNotDetectedOnMachine: ({ cli }: { cli: string }) => `此设备未检测到 ${cli} CLI。`,
+ selectMachineTitle: '选择设备',
+ selectMachineDescription: '选择此会话运行的位置。',
+ selectPathTitle: '选择路径',
+ selectWorkingDirectoryTitle: '选择工作目录',
+ selectWorkingDirectoryDescription: '选择用于命令和上下文的文件夹。',
+ selectPermissionModeTitle: '选择权限模式',
+ selectPermissionModeDescription: '控制操作需要批准的严格程度。',
+ selectModelTitle: '选择 AI 模型',
+ selectModelDescription: '选择此会话使用的模型。',
+ selectSessionTypeTitle: '选择会话类型',
+ selectSessionTypeDescription: '选择简单会话或与 Git worktree 关联的会话。',
+ searchPathsPlaceholder: '搜索路径...',
noMachinesFound: '未找到设备。请先在您的计算机上启动 Happy 会话。',
allMachinesOffline: '所有设备似乎都已离线',
machineDetails: '查看设备详情 →',
@@ -277,12 +359,46 @@ 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: '简单',
- worktree: 'Worktree',
+ worktree: 'Worktree(Git)',
comingSoon: '即将推出',
},
+ profileAvailability: {
+ requiresAgent: ({ agent }: { agent: string }) => `需要 ${agent}`,
+ cliNotDetected: ({ cli }: { cli: string }) => `未检测到 ${cli} CLI`,
+ },
+ cliBanners: {
+ cliNotDetectedTitle: ({ cli }: { cli: string }) => `${cli} CLI 未检测到`,
+ dontShowFor: '不再显示此提示:',
+ thisMachine: '此设备',
+ anyMachine: '所有设备',
+ installCommand: ({ command }: { command: string }) => `安装:${command} •`,
+ installCliIfAvailable: ({ cli }: { cli: string }) => `如可用请安装 ${cli} CLI •`,
+ viewInstallationGuide: '查看安装指南 →',
+ viewGeminiDocs: '查看 Gemini 文档 →',
+ },
worktree: {
creating: ({ name }: { name: string }) => `正在创建 worktree '${name}'...`,
notGitRepo: 'Worktree 需要 git 仓库',
@@ -307,6 +423,19 @@ export const zhHans: TranslationStructure = {
commandPalette: {
placeholder: '输入命令或搜索...',
+ noCommandsFound: '未找到命令',
+ },
+
+ commandView: {
+ completedWithNoOutput: '[命令完成且无输出]',
+ },
+
+ voiceAssistant: {
+ connecting: '连接中...',
+ active: '语音助手已启用',
+ connectionError: '连接错误',
+ label: '语音助手',
+ tapToEnd: '点击结束',
},
server: {
@@ -338,6 +467,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: '元数据已复制到剪贴板',
@@ -361,6 +491,9 @@ export const zhHans: TranslationStructure = {
happyHome: 'Happy 主目录',
copyMetadata: '复制元数据',
agentState: 'Agent 状态',
+ rawJsonDevMode: '原始 JSON(开发者模式)',
+ sessionStatus: '会话状态',
+ fullSessionObject: '完整会话对象',
controlledByUser: '用户控制',
pendingRequests: '待处理请求',
activity: '活动',
@@ -388,16 +521,52 @@ export const zhHans: TranslationStructure = {
runIt: '运行它',
scanQrCode: '扫描二维码',
openCamera: '打开相机',
+ installCommand: '$ npm i -g happy-coder',
+ runCommand: '$ happy',
+ },
+ emptyMessages: {
+ noMessagesYet: '暂无消息',
+ created: ({ time }: { time: string }) => `创建于 ${time}`,
+ },
+ emptySessionsTablet: {
+ noActiveSessions: '没有活动会话',
+ startNewSessionDescription: '在任意已连接设备上开始新的会话。',
+ startNewSessionButton: '开始新会话',
+ openTerminalToStart: '在电脑上打开新的终端以开始会话。',
+ },
+ },
+
+ zen: {
+ title: 'Zen',
+ add: {
+ placeholder: '需要做什么?',
+ },
+ home: {
+ noTasksYet: '还没有任务。点按 + 添加一个。',
+ },
+ view: {
+ workOnTask: '处理任务',
+ clarify: '澄清',
+ delete: '删除',
+ linkedSessions: '已关联的会话',
+ tapTaskTextToEdit: '点击任务文本以编辑',
},
},
agentInput: {
+ envVars: {
+ title: '环境变量',
+ titleWithCount: ({ count }: { count: number }) => `环境变量 (${count})`,
+ },
permissionMode: {
title: '权限模式',
default: '默认',
acceptEdits: '接受编辑',
plan: '计划模式',
bypassPermissions: 'Yolo 模式',
+ badgeAccept: '接受',
+ badgePlan: '计划',
+ badgeYolo: 'YOLO',
badgeAcceptAllEdits: '接受所有编辑',
badgeBypassAllPermissions: '绕过所有权限',
badgePlanMode: '计划模式',
@@ -414,32 +583,47 @@ export const zhHans: TranslationStructure = {
codexPermissionMode: {
title: 'CODEX 权限模式',
default: 'CLI 设置',
- readOnly: 'Read Only Mode',
- safeYolo: 'Safe YOLO',
+ readOnly: '只读模式',
+ safeYolo: '安全 YOLO',
yolo: 'YOLO',
- badgeReadOnly: 'Read Only Mode',
- badgeSafeYolo: 'Safe YOLO',
+ badgeReadOnly: '只读',
+ badgeSafeYolo: '安全 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',
+ title: 'CODEX 模型',
+ gpt5CodexLow: 'gpt-5-codex 低',
+ gpt5CodexMedium: 'gpt-5-codex 中',
+ gpt5CodexHigh: 'gpt-5-codex 高',
+ gpt5Minimal: 'GPT-5 最小',
+ gpt5Low: 'GPT-5 低',
+ gpt5Medium: 'GPT-5 中',
+ gpt5High: 'GPT-5 高',
},
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}%`,
@@ -448,6 +632,11 @@ export const zhHans: TranslationStructure = {
fileLabel: '文件',
folderLabel: '文件夹',
},
+ actionMenu: {
+ title: '操作',
+ files: '文件',
+ stop: '停止',
+ },
noMachinesAvailable: '无设备',
},
@@ -506,6 +695,10 @@ export const zhHans: TranslationStructure = {
applyChanges: '更新文件',
viewDiff: '当前文件更改',
question: '问题',
+ changeTitle: '更改标题',
+ },
+ geminiExecute: {
+ cwd: ({ cwd }: { cwd: string }) => `📁 ${cwd}`,
},
desc: {
terminalCmd: ({ cmd }: { cmd: string }) => `终端(命令: ${cmd})`,
@@ -668,6 +861,11 @@ export const zhHans: TranslationStructure = {
deviceLinkedSuccessfully: '设备链接成功',
terminalConnectedSuccessfully: '终端连接成功',
invalidAuthUrl: '无效的认证 URL',
+ microphoneAccessRequiredTitle: '需要麦克风权限',
+ microphoneAccessRequiredRequestPermission: 'Happy 需要访问你的麦克风用于语音聊天。出现提示时请授予权限。',
+ microphoneAccessRequiredEnableInSettings: 'Happy 需要访问你的麦克风用于语音聊天。请在设备设置中启用麦克风权限。',
+ microphoneAccessRequiredBrowserInstructions: '请在浏览器设置中允许麦克风访问。你可能需要点击地址栏中的锁形图标,并为此网站启用麦克风权限。',
+ openSettings: '打开设置',
developerMode: '开发者模式',
developerModeEnabled: '开发者模式已启用',
developerModeDisabled: '开发者模式已禁用',
@@ -722,6 +920,15 @@ export const zhHans: TranslationStructure = {
daemon: '守护进程',
status: '状态',
stopDaemon: '停止守护进程',
+ stopDaemonConfirmTitle: '停止守护进程?',
+ stopDaemonConfirmBody: '在您重新启动电脑上的守护进程之前,您将无法在此设备上创建新会话。当前会话将保持运行。',
+ daemonStoppedTitle: '守护进程已停止',
+ stopDaemonFailed: '停止守护进程失败。它可能未在运行。',
+ renameTitle: '重命名设备',
+ renameDescription: '为此设备设置自定义名称。留空则使用默认主机名。',
+ renamePlaceholder: '输入设备名称',
+ renamedSuccess: '设备重命名成功',
+ renameFailed: '设备重命名失败',
lastKnownPid: '最后已知 PID',
lastKnownHttpPort: '最后已知 HTTP 端口',
startedAt: '启动时间',
@@ -738,8 +945,15 @@ export const zhHans: TranslationStructure = {
lastSeen: '最后活跃',
never: '从未',
metadataVersion: '元数据版本',
+ detectedClis: '已检测到的 CLI',
+ detectedCliNotDetected: '未检测到',
+ detectedCliUnknown: '未知',
+ detectedCliNotSupported: '不支持(请更新 happy-cli)',
untitledSession: '无标题会话',
back: '返回',
+ notFound: '未找到设备',
+ unknownMachine: '未知设备',
+ unknownPath: '未知路径',
},
message: {
@@ -749,6 +963,10 @@ export const zhHans: TranslationStructure = {
unknownTime: '未知时间',
},
+ chatFooter: {
+ permissionsTerminalOnly: '权限仅在终端中显示。重置或发送消息即可从应用中控制。',
+ },
+
codex: {
// Codex permission dialog buttons
permissions: {
@@ -775,6 +993,7 @@ export const zhHans: TranslationStructure = {
textCopied: '文本已复制到剪贴板',
failedToCopy: '复制文本到剪贴板失败',
noTextToCopy: '没有可复制的文本',
+ failedToOpen: '无法打开文本选择。请重试。',
},
markdown: {
@@ -794,11 +1013,14 @@ export const zhHans: TranslationStructure = {
edit: '编辑工件',
delete: '删除',
updateError: '更新工件失败。请重试。',
+ deleteError: '删除工件失败。请重试。',
notFound: '未找到工件',
discardChanges: '放弃更改?',
discardChangesDescription: '您有未保存的更改。确定要放弃它们吗?',
deleteConfirm: '删除工件?',
deleteConfirmDescription: '此工件将被永久删除。',
+ noContent: '无内容',
+ untitled: '未命名',
titlePlaceholder: '工件标题',
bodyPlaceholder: '在此输入内容...',
save: '保存',
@@ -896,8 +1118,213 @@ export const zhHans: TranslationStructure = {
tmuxTempDir: 'tmux 临时目录',
enterTmuxTempDir: '输入 tmux 临时目录',
tmuxUpdateEnvironment: '更新 tmux 环境',
- deleteConfirm: '确定要删除此配置文件吗?',
+ deleteConfirm: ({ name }: { name: string }) => `确定要删除配置文件“${name}”吗?`,
nameRequired: '配置文件名称为必填项',
+ builtIn: '内置',
+ custom: '自定义',
+ builtInSaveAsHint: '保存内置配置文件会创建一个新的自定义配置文件。',
+ builtInNames: {
+ anthropic: 'Anthropic(默认)',
+ deepseek: 'DeepSeek(推理)',
+ 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: '(副本)',
+ duplicateName: '已存在同名配置文件',
+ setupInstructions: {
+ title: '设置说明',
+ viewOfficialGuide: '查看官方设置指南',
+ },
+ machineLogin: {
+ title: '需要在设备上登录',
+ subtitle: '此配置文件依赖所选设备上的 CLI 登录缓存。',
+ claudeCode: {
+ title: 'Claude Code',
+ instructions: '运行 `claude`,然后输入 `/login` 登录。',
+ warning: '注意:设置 `ANTHROPIC_AUTH_TOKEN` 会覆盖 CLI 登录。',
+ },
+ codex: {
+ title: 'Codex',
+ instructions: '运行 `codex login` 登录。',
+ },
+ geminiCli: {
+ title: 'Gemini CLI',
+ instructions: '运行 `gemini auth` 登录。',
+ },
+ },
+ requirements: {
+ apiKeyRequired: 'API 密钥',
+ configured: '已在设备上配置',
+ notConfigured: '未配置',
+ checking: '检查中…',
+ modalTitle: '需要 API 密钥',
+ modalBody: '此配置需要 API 密钥。\n\n支持的选项:\n• 使用设备环境(推荐)\n• 使用应用设置中保存的密钥\n• 仅为本次会话输入密钥',
+ sectionTitle: '要求',
+ sectionSubtitle: '这些字段用于预检查就绪状态,避免意外失败。',
+ secretEnvVarPromptDescription: '输入所需的秘密环境变量名称(例如 OPENAI_API_KEY)。',
+ modalHelpWithEnv: ({ env }: { env: string }) => `此配置需要 ${env}。请选择下面的一个选项。`,
+ modalHelpGeneric: '此配置需要 API 密钥。请选择下面的一个选项。',
+ modalRecommendation: '推荐:在电脑上的守护进程环境中设置密钥(这样就无需再次粘贴)。然后重启守护进程以读取新的环境变量。',
+ chooseOptionTitle: '选择一个选项',
+ machineEnvStatus: {
+ theMachine: '设备',
+ checkFor: ({ env }: { env: string }) => `检查 ${env}`,
+ checking: ({ env }: { env: string }) => `正在检查 ${env}…`,
+ found: ({ env, machine }: { env: string; machine: string }) => `在${machine}上找到 ${env}`,
+ notFound: ({ env, machine }: { env: string; machine: string }) => `在${machine}上未找到 ${env}`,
+ },
+ machineEnvSubtitle: {
+ checking: '正在检查守护进程环境…',
+ found: '已在设备的守护进程环境中找到。',
+ notFound: '请在设备的守护进程环境中设置它并重启守护进程。',
+ },
+ options: {
+ none: {
+ title: '无',
+ subtitle: '不需要 API 密钥或 CLI 登录。',
+ },
+ apiKeyEnv: {
+ subtitle: '需要在会话启动时注入 API 密钥。',
+ },
+ machineLogin: {
+ subtitle: '需要在目标设备上通过 CLI 登录。',
+ longSubtitle: '需要在目标设备上登录到所选 AI 后端的 CLI。',
+ },
+ useMachineEnvironment: {
+ title: '使用设备环境',
+ subtitleWithEnv: ({ env }: { env: string }) => `从守护进程环境中使用 ${env}。`,
+ subtitleGeneric: '从守护进程环境中使用密钥。',
+ },
+ useSavedApiKey: {
+ title: '使用已保存的 API 密钥',
+ subtitle: '在应用中选择(或添加)一个已保存的密钥。',
+ },
+ enterOnce: {
+ title: '输入密钥',
+ subtitle: '仅为本次会话粘贴密钥(不会保存)。',
+ },
+ },
+ apiKeyEnvVar: {
+ title: 'API 密钥环境变量',
+ subtitle: '输入此提供方期望的 API 密钥环境变量名(例如 OPENAI_API_KEY)。',
+ label: '环境变量名',
+ },
+ sections: {
+ machineEnvironment: '设备环境',
+ useOnceTitle: '仅使用一次',
+ useOnceFooter: '仅为本次会话粘贴密钥。不会保存。',
+ },
+ actions: {
+ useMachineEnvironment: {
+ subtitle: '使用设备上已存在的密钥开始。',
+ },
+ useOnceButton: '仅使用一次(仅本次会话)',
+ },
+ },
+ defaultSessionType: '默认会话类型',
+ defaultPermissionMode: {
+ title: '默认权限模式',
+ descriptions: {
+ default: '询问权限',
+ acceptEdits: '自动批准编辑',
+ plan: '执行前先规划',
+ bypassPermissions: '跳过所有权限',
+ },
+ },
+ aiBackend: {
+ title: 'AI 后端',
+ selectAtLeastOneError: '至少选择一个 AI 后端。',
+ claudeSubtitle: 'Claude 命令行',
+ codexSubtitle: 'Codex 命令行',
+ geminiSubtitleExperimental: 'Gemini 命令行(实验)',
+ },
+ tmux: {
+ title: 'tmux',
+ spawnSessionsTitle: '在 tmux 中启动会话',
+ spawnSessionsEnabledSubtitle: '会话将在新的 tmux 窗口中启动。',
+ spawnSessionsDisabledSubtitle: '会话将在普通 shell 中启动(无 tmux 集成)',
+ sessionNamePlaceholder: '留空 = 当前/最近会话',
+ tempDirPlaceholder: '/tmp(可选)',
+ },
+ previewMachine: {
+ title: '预览设备',
+ itemTitle: '用于环境变量预览的设备',
+ 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: '秘密值——出于安全原因不会读取',
+ secretToggleLabel: '秘密',
+ secretToggleSubtitle: '在 UI 中隐藏该值,并避免为预览从机器获取它。',
+ secretToggleEnforcedByDaemon: '由守护进程强制',
+ secretToggleResetToAuto: '重置为自动',
+ 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}"吗?此操作无法撤销。`,
@@ -906,6 +1333,45 @@ export const zhHans: TranslationStructure = {
},
},
+ apiKeys: {
+ addTitle: '新的 API 密钥',
+ savedTitle: '已保存的 API 密钥',
+ badgeReady: 'API 密钥',
+ badgeRequired: '需要 API 密钥',
+ addSubtitle: '添加已保存的 API 密钥',
+ noneTitle: '无',
+ noneSubtitle: '使用设备环境,或为本次会话输入密钥',
+ emptyTitle: '没有已保存的密钥',
+ emptySubtitle: '添加一个,以在不设置设备环境变量的情况下使用 API 密钥配置。',
+ savedHiddenSubtitle: '已保存(值已隐藏)',
+ defaultLabel: '默认',
+ fields: {
+ name: '名称',
+ value: '值',
+ },
+ placeholders: {
+ nameExample: '例如:Work OpenAI',
+ },
+ validation: {
+ nameRequired: '名称为必填项。',
+ valueRequired: '值为必填项。',
+ },
+ actions: {
+ replace: '替换',
+ replaceValue: '替换值',
+ setDefault: '设为默认',
+ unsetDefault: '取消默认',
+ },
+ prompts: {
+ renameTitle: '重命名 API 密钥',
+ renameDescription: '更新此密钥的友好名称。',
+ replaceValueTitle: '替换 API 密钥值',
+ replaceValueDescription: '粘贴新的 API 密钥值。保存后将不会再次显示。',
+ deleteTitle: '删除 API 密钥',
+ deleteConfirm: ({ name }: { name: string }) => `删除“${name}”?此操作无法撤销。`,
+ },
+ },
+
feed: {
// Feed notifications for friend requests and acceptances
friendRequestFrom: ({ 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/theme.ts b/sources/theme.ts
index c612581e3..a769757a7 100644
--- a/sources/theme.ts
+++ b/sources/theme.ts
@@ -49,7 +49,7 @@ export const lightTheme = {
surface: '#ffffff',
surfaceRipple: 'rgba(0, 0, 0, 0.08)',
surfacePressed: '#f0f0f2',
- surfaceSelected: Platform.select({ ios: '#C6C6C8', default: '#eaeaea' }),
+ surfaceSelected: Platform.select({ ios: '#eaeaea', default: '#eaeaea' }),
surfacePressedOverlay: Platform.select({ ios: '#D1D1D6', default: 'transparent' }),
surfaceHigh: '#F8F8F8',
surfaceHighest: '#f0f0f0',
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/microphonePermissions.ts b/sources/utils/microphonePermissions.ts
index 13a1f7004..d42e8b393 100644
--- a/sources/utils/microphonePermissions.ts
+++ b/sources/utils/microphonePermissions.ts
@@ -1,6 +1,7 @@
import { Platform, Linking } from 'react-native';
import { Modal } from '@/modal';
import { AudioModule } from 'expo-audio';
+import { t } from '@/text';
export interface MicrophonePermissionResult {
granted: boolean;
@@ -82,23 +83,23 @@ export async function checkMicrophonePermission(): Promise {
// Opens app settings on iOS/Android
Linking.openSettings();
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/recentMachines.ts b/sources/utils/recentMachines.ts
new file mode 100644
index 000000000..9c098d641
--- /dev/null
+++ b/sources/utils/recentMachines.ts
@@ -0,0 +1,31 @@
+import type { Machine } from '@/sync/storageTypes';
+import type { Session } from '@/sync/storageTypes';
+
+export function getRecentMachinesFromSessions(params: {
+ machines: Machine[];
+ sessions: Array | null | undefined;
+}): Machine[] {
+ if (!params.sessions || params.machines.length === 0) return [];
+
+ const byId = new Map(params.machines.map((m) => [m.id, m] as const));
+ const seen = new Set();
+ const machinesWithTimestamp: Array<{ machine: Machine; timestamp: number }> = [];
+
+ params.sessions.forEach((item) => {
+ if (typeof item === 'string') return;
+ const machineId = item.metadata?.machineId;
+ if (!machineId || seen.has(machineId)) return;
+ const machine = byId.get(machineId);
+ if (!machine) return;
+ seen.add(machineId);
+ machinesWithTimestamp.push({
+ machine,
+ timestamp: item.updatedAt || item.createdAt,
+ });
+ });
+
+ return machinesWithTimestamp
+ .sort((a, b) => b.timestamp - a.timestamp)
+ .map((item) => item.machine);
+}
+
diff --git a/sources/utils/recentPaths.ts b/sources/utils/recentPaths.ts
new file mode 100644
index 000000000..09eaa93d8
--- /dev/null
+++ b/sources/utils/recentPaths.ts
@@ -0,0 +1,45 @@
+import type { Session } from '@/sync/storageTypes';
+
+export function getRecentPathsForMachine(params: {
+ machineId: string;
+ recentMachinePaths: Array<{ machineId: string; path: string }>;
+ sessions: Array | null | undefined;
+}): string[] {
+ const paths: string[] = [];
+ const pathSet = new Set();
+
+ // First, add paths from recentMachinePaths (most recent first by storage order)
+ for (const entry of params.recentMachinePaths) {
+ if (entry.machineId === params.machineId && !pathSet.has(entry.path)) {
+ paths.push(entry.path);
+ pathSet.add(entry.path);
+ }
+ }
+
+ // Then add paths from sessions if we need more
+ if (params.sessions) {
+ const pathsWithTimestamps: Array<{ path: string; timestamp: number }> = [];
+
+ params.sessions.forEach((item) => {
+ if (typeof item === 'string') return;
+ const session = item;
+ if (session.metadata?.machineId === params.machineId && session.metadata?.path) {
+ const path = session.metadata.path;
+ if (!pathSet.has(path)) {
+ pathSet.add(path);
+ pathsWithTimestamps.push({
+ path,
+ timestamp: session.updatedAt || session.createdAt,
+ });
+ }
+ }
+ });
+
+ pathsWithTimestamps
+ .sort((a, b) => b.timestamp - a.timestamp)
+ .forEach((item) => paths.push(item.path));
+ }
+
+ return paths;
+}
+
diff --git a/sources/utils/storageScope.test.ts b/sources/utils/storageScope.test.ts
new file mode 100644
index 000000000..5436c31e1
--- /dev/null
+++ b/sources/utils/storageScope.test.ts
@@ -0,0 +1,46 @@
+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..8bfebde1a
--- /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 {
+ // Must be compatible with all underlying stores (SecureStore keys are especially strict).
+ return scope ? `${baseId}__${scope}` : baseId;
+}
diff --git a/vitest.config.ts b/vitest.config.ts
index 1836de229..74dff9b45 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -2,6 +2,9 @@ import { defineConfig } from 'vitest/config'
import { resolve } from 'node:path'
export default defineConfig({
+ define: {
+ __DEV__: false,
+ },
test: {
globals: false,
environment: 'node',
@@ -23,4 +26,4 @@ export default defineConfig({
'@': resolve('./sources'),
},
},
-})
\ No newline at end of file
+})
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"