slopus--happy
98e40dac97
CLI Smoke Test / smoke-test-linux (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-linux (24) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (24) (push) Has been cancelled
Expo App TypeScript typecheck / typecheck (push) Has been cancelled
202 行
9.3 KiB
TypeScript
202 行
9.3 KiB
TypeScript
import * as z from 'zod';
|
|
import { AgentDefaultOverridesSchema } from './agentDefaults';
|
|
import { DEFAULT_USER_MESSAGE_BUBBLE_COLOR } from '../utils/userMessageBubbleColor';
|
|
|
|
//
|
|
// Settings Schema
|
|
//
|
|
|
|
// Current schema version for backward compatibility
|
|
export const SUPPORTED_SCHEMA_VERSION = 2;
|
|
|
|
// Where (and whether) the branch/model/effort/context status bar renders
|
|
// around the composer. 'hiddenOnMobile' hides it on phones but shows it
|
|
// below the composer on tablet/desktop/web.
|
|
export const SESSION_STATUS_BAR_DISPLAY_MODES = ['hidden', 'hiddenOnMobile', 'above', 'below'] as const;
|
|
export type SessionStatusBarDisplay = typeof SESSION_STATUS_BAR_DISPLAY_MODES[number];
|
|
|
|
export const SettingsSchema = z.object({
|
|
// Schema version for compatibility detection
|
|
schemaVersion: z.number().default(SUPPORTED_SCHEMA_VERSION).describe('Settings schema version for compatibility checks'),
|
|
|
|
viewInline: z.boolean().describe('Whether to view inline tool calls'),
|
|
inferenceOpenAIKey: z.string().nullish().describe('OpenAI API key for inference'),
|
|
expandTodos: z.boolean().describe('Whether to expand todo lists'),
|
|
showLineNumbers: z.boolean().describe('Whether to show line numbers in diffs'),
|
|
showLineNumbersInToolViews: z.boolean().describe('Whether to show line numbers in tool view diffs'),
|
|
wrapLinesInDiffs: z.boolean().describe('Whether to wrap long lines in diff views'),
|
|
diffStyle: z.enum(['unified', 'split']).describe('Diff view style (split is web-only)'),
|
|
analyticsOptOut: z.boolean().describe('Whether to opt out of anonymous analytics'),
|
|
experiments: z.boolean().describe('Whether to enable experimental features'),
|
|
alwaysShowContextSize: z.boolean().describe('Always show context size in agent input'),
|
|
agentInputEnterToSend: z.boolean().describe('Whether pressing Enter submits/sends in the agent input (web)'),
|
|
avatarStyle: z.string().describe('Avatar display style'),
|
|
showFlavorIcons: z.boolean().describe('Whether to show AI provider icons in avatars'),
|
|
userMessageBubbleColor: z.string().describe('User message bubble color preset'),
|
|
sessionStatusBarDisplay: z.enum(SESSION_STATUS_BAR_DISPLAY_MODES).describe('Whether/where to show the branch, model, effort, and context status bar'),
|
|
|
|
hideInactiveSessions: z.boolean().describe('Hide inactive sessions in the main list'),
|
|
sortSessionsByActivity: z.boolean().describe('Sort the session list by last activity instead of creation date'),
|
|
expResumeSession: z.boolean().describe('Enable experimental session resume feature'),
|
|
fileDiffsSidebar: z.boolean().describe('Show the file diffs sidebar next to the chat on desktop'),
|
|
groupToolCalls: z.boolean().describe('Collapse consecutive tool calls into grouped containers in chat'),
|
|
expImageUpload: z.boolean().describe('Enable experimental image upload in chat'),
|
|
reviewPromptAnswered: z.boolean().describe('Whether the review prompt has been answered'),
|
|
reviewPromptLikedApp: z.boolean().nullish().describe('Whether user liked the app when asked'),
|
|
voiceAssistantLanguage: z.string().nullable().describe('Preferred language for voice assistant (null for auto-detect)'),
|
|
voiceCustomAgentId: z.string().nullable().describe('Custom ElevenLabs agent ID (null to use Happy default)'),
|
|
voiceBypassToken: z.boolean().describe('Bypass Happy server token and connect directly to ElevenLabs (requires custom agent ID)'),
|
|
preferredLanguage: z.string().nullable().describe('Preferred UI language (null for auto-detect from device locale)'),
|
|
recentMachinePaths: z.array(z.object({
|
|
machineId: z.string(),
|
|
path: z.string()
|
|
})).describe('Last 10 machine-path combinations, ordered by most recent first'),
|
|
lastUsedAgent: z.string().nullable().describe('Last selected agent type for new sessions'),
|
|
lastUsedPermissionMode: z.string().nullable().describe('Last selected permission mode for new sessions'),
|
|
lastUsedModelMode: z.string().nullable().describe('Last selected model mode for new sessions'),
|
|
agentDefaultOverrides: AgentDefaultOverridesSchema.describe('User-selected agent defaults. Missing values use code defaults and are not sent as agent metadata.'),
|
|
// Dismissed CLI warning banners (supports both per-machine and global dismissal)
|
|
dismissedCLIWarnings: z.object({
|
|
perMachine: z.record(z.string(), z.object({
|
|
claude: z.boolean().optional(),
|
|
codex: z.boolean().optional(),
|
|
gemini: z.boolean().optional(),
|
|
openclaw: z.boolean().optional(),
|
|
})).default({}),
|
|
global: z.object({
|
|
claude: z.boolean().optional(),
|
|
codex: z.boolean().optional(),
|
|
gemini: z.boolean().optional(),
|
|
openclaw: z.boolean().optional(),
|
|
}).default({}),
|
|
}).default({ perMachine: {}, global: {} }).describe('Tracks which CLI installation warnings user has dismissed (per-machine or globally)'),
|
|
});
|
|
|
|
//
|
|
// NOTE: Settings must be a flat object with no to minimal nesting, one field == one setting,
|
|
// you can name them with a prefix if you want to group them, but don't nest them.
|
|
// You can nest if value is a single value (like image with url and width and height)
|
|
// Settings are always merged with defaults and field by field.
|
|
//
|
|
// This structure must be forward and backward compatible. Meaning that some versions of the app
|
|
// could be missing some fields or have a new fields. Everything must be preserved and client must
|
|
// only touch the fields it knows about.
|
|
//
|
|
|
|
const SettingsSchemaPartial = SettingsSchema.partial();
|
|
|
|
export type Settings = z.infer<typeof SettingsSchema>;
|
|
|
|
//
|
|
// Defaults
|
|
//
|
|
|
|
export const settingsDefaults: Settings = {
|
|
schemaVersion: SUPPORTED_SCHEMA_VERSION,
|
|
viewInline: false,
|
|
inferenceOpenAIKey: null,
|
|
expandTodos: true,
|
|
showLineNumbers: true,
|
|
showLineNumbersInToolViews: false,
|
|
wrapLinesInDiffs: true,
|
|
diffStyle: 'unified',
|
|
analyticsOptOut: false,
|
|
experiments: false,
|
|
alwaysShowContextSize: false,
|
|
agentInputEnterToSend: true,
|
|
avatarStyle: 'brutalist',
|
|
showFlavorIcons: false,
|
|
userMessageBubbleColor: DEFAULT_USER_MESSAGE_BUBBLE_COLOR,
|
|
// Hidden everywhere by default — the context usage indicator is still too
|
|
// raw to roll out; users can opt back in from appearance settings.
|
|
sessionStatusBarDisplay: 'hidden',
|
|
|
|
hideInactiveSessions: false,
|
|
sortSessionsByActivity: false,
|
|
expResumeSession: false,
|
|
fileDiffsSidebar: false,
|
|
groupToolCalls: false,
|
|
expImageUpload: false,
|
|
reviewPromptAnswered: false,
|
|
reviewPromptLikedApp: null,
|
|
voiceAssistantLanguage: null,
|
|
voiceCustomAgentId: null,
|
|
voiceBypassToken: false,
|
|
preferredLanguage: null,
|
|
recentMachinePaths: [],
|
|
lastUsedAgent: null,
|
|
lastUsedPermissionMode: null,
|
|
lastUsedModelMode: null,
|
|
agentDefaultOverrides: {},
|
|
dismissedCLIWarnings: { perMachine: {}, global: {} },
|
|
};
|
|
Object.freeze(settingsDefaults);
|
|
|
|
//
|
|
// Resolving
|
|
//
|
|
|
|
export function settingsParse(settings: unknown): Settings {
|
|
// Handle null/undefined/invalid inputs
|
|
if (!settings || typeof settings !== 'object') {
|
|
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 };
|
|
}
|
|
|
|
// 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';
|
|
}
|
|
|
|
// 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]);
|
|
|
|
return { ...settingsDefaults, ...parsed.data, ...unknownFields };
|
|
}
|
|
|
|
//
|
|
// Applying changes
|
|
// NOTE: May be something more sophisticated here around defaults and merging, but for now this is fine.
|
|
//
|
|
|
|
export function applySettings(settings: Settings, delta: Partial<Settings>): Settings {
|
|
// Original behavior: start with settings, apply delta, fill in missing with defaults
|
|
const result = { ...settings, ...delta };
|
|
|
|
// Fill in any missing fields with defaults
|
|
Object.keys(settingsDefaults).forEach(key => {
|
|
if (!(key in result)) {
|
|
(result as any)[key] = (settingsDefaults as any)[key];
|
|
}
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
export function settingsToSyncPayload(settings: Settings): Partial<Settings> {
|
|
const result: Partial<Settings> = { ...settings };
|
|
const compactAgentOverrides = Object.fromEntries(
|
|
Object.entries(settings.agentDefaultOverrides ?? {}).filter(([, value]) => (
|
|
value && typeof value === 'object' && Object.keys(value).length > 0
|
|
)),
|
|
) as Settings['agentDefaultOverrides'];
|
|
if (Object.keys(compactAgentOverrides).length === 0) {
|
|
delete result.agentDefaultOverrides;
|
|
} else {
|
|
result.agentDefaultOverrides = compactAgentOverrides;
|
|
}
|
|
return result;
|
|
}
|