docs/plans/metadata-driven-model-mode-selection.md
metadata.models[], metadata.operatingModes[], metadata.currentModelCode, metadata.currentOperatingModeCode) for model and mode selection in active sessions, instead of hardcoding options per agent typeModelMode and PermissionMode become structured types { key: string; name: string; description?: string | null } — UI shows name everywhere, key is the value sent to backend/storedconfig_options_update, modes_update, models_update events and populates metadata.models[], metadata.operatingModes[], metadata.currentModelCode, metadata.currentOperatingModeCode{ code: string; value: string; description?: string | null } — maps as { key: code, name: value, description }ModelMode type is currently a flat string unionPermissionMode type is currently a flat string union ('default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo')Session.modelMode is restricted to 'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite'Session.permissionMode is restricted to the PermissionMode string unionupdateSessionModelMode() and updateSessionPermissionMode() in storage only accept those specific stringssendMessage() only sends model in meta for Gemini sessions| File | Change |
|---|---|
packages/happy-app/sources/components/PermissionModeSelector.tsx | Change both ModelMode and PermissionMode to { key, name, description } struct |
packages/happy-app/sources/sync/storageTypes.ts | Change Session.modelMode and Session.permissionMode to `string |
packages/happy-app/sources/sync/storage.ts | Widen both updateSessionModelMode() and updateSessionPermissionMode() to accept string |
packages/happy-app/sources/components/AgentInput.tsx | Accept structs for both, render from metadata or hardcoded, show name in UI |
packages/happy-app/sources/-session/SessionView.tsx | Build structs from metadata for both model and mode, pass to AgentInput |
packages/happy-app/sources/sync/sync.ts | Send model key in meta for all agent types, send permission mode key |
packages/happy-app/sources/sync/typesMessageMeta.ts | Ensure meta schema accepts any string for permissionMode |
packages/happy-app/sources/app/(app)/new/index.tsx | Adapt to structs for both model and mode |
[x] immediately when doneyarn workspace happy-app lint is unavailable because the package has no lint scriptModelMode type in PermissionModeSelector.tsx from flat string union to { key: string; name: string; description?: string | null }PermissionMode type in PermissionModeSelector.tsx from flat string union to { key: string; name: string; description?: string | null }Session.modelMode in storageTypes.ts from hardcoded union to string | null (stores key only)Session.permissionMode in storageTypes.ts from hardcoded union to string | null (stores key only)updateSessionModelMode() in storage.ts to accept stringupdateSessionPermissionMode() in storage.ts to accept stringpermissionMode in MessageMetaSchema in typesMessageMeta.ts from enum to z.string() (keys are now arbitrary strings)[{ key: 'default', name: 'Default' }, { key: 'acceptEdits', name: 'Accept Edits' }, ...][{ key: 'default', name: 'Default' }, { key: 'read-only', name: 'Read Only' }, ...]name is simply the key capitalized (e.g. "plan" → "Plan", "build" → "Build") — no translation keys needed for mode namesModelMode struct for current model: look up session.modelMode key in metadata.models[], fall back to metadata.currentModelCode, or nullPermissionMode struct for current mode: look up session.permissionMode key in metadata.operatingModes[] (for non-claude/codex) or hardcoded list (for claude/codex)availableModels list: use metadata.models[] if non-empty, else hardcoded fallback for known agentsavailableModes list: use hardcoded for claude/codex, use metadata.operatingModes[] for others if non-emptyupdateModelMode callback: extract key from struct, call updateSessionModelMode(key)updatePermissionMode callback: extract key from struct, call updateSessionPermissionMode(key)AgentInputmodelMode → ModelMode | null, permissionMode → PermissionMode | nullavailableModels and availableModes props (arrays of structs)availableModels, show name as label, description as subtitle, compare by keyavailableModes, show name as label, compare by keyonModelModeChange(struct) with full structonPermissionModeChange(struct) with full structmodelMode.name and permissionMode.name instead of hardcoded label lookupsavailableModes structssync.ts sendMessage(), read session.modelMode (key string) and send in meta.model when set and not 'default' — for ALL agent types, not just Geminisession.permissionMode (key string) and send in meta.permissionModemodelMode state to ModelMode | null structpermissionMode state to PermissionMode structupdateSessionModelMode(modelMode.key) and updateSessionPermissionMode(permissionMode.key)lastUsedModelMode / lastUsedPermissionMode settings to store/restore keysname from metadata when metadata.models is populatedname from metadata for non-claude/codex agentskey (not name) is sent in meta.model for all agent typeskey (not name) is sent in meta.permissionModename is shown in status bar, selectors, and badges — never raw key// Both ModelMode and PermissionMode use the same shape
type ModelMode = {
key: string; // Technical ID sent to backend (e.g. "gemini-2.5-pro")
name: string; // Display name shown in UI (e.g. "Gemini 2.5 Pro")
description?: string | null; // Optional subtitle (e.g. "Most capable")
};
type PermissionMode = {
key: string; // Technical ID sent to backend (e.g. "plan", "build")
name: string; // Display name = key capitalized (e.g. "Plan", "Build")
description?: string | null; // Optional subtitle
};
// metadata.models[] → ModelMode[]
metadata.models.map(m => ({
key: m.code,
name: m.value,
description: m.description
}))
// metadata.operatingModes[] → PermissionMode[]
metadata.operatingModes.map(m => ({
key: m.code,
name: m.value,
description: m.description
}))
// Claude permission modes — name is just key capitalized
const CLAUDE_PERMISSION_MODES: PermissionMode[] = [
{ key: 'default', name: 'Default' },
{ key: 'acceptEdits', name: 'Accept Edits' },
{ key: 'plan', name: 'Plan' },
{ key: 'bypassPermissions', name: 'Bypass Permissions' },
];
// Gemini models (fallback when metadata not available)
const GEMINI_MODELS: ModelMode[] = [
{ key: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', description: 'Most capable' },
{ key: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', description: 'Fast & efficient' },
{ key: 'gemini-2.5-flash-lite', name: 'Gemini 2.5 Flash Lite', description: 'Fastest' },
];
// storageTypes.ts
Session.modelMode?: string | null; // Key only (e.g. "gemini-2.5-pro")
Session.permissionMode?: string | null; // Key only (e.g. "acceptEdits")
// storage.ts
updateSessionModelMode(sessionId: string, key: string)
updateSessionPermissionMode(sessionId: string, key: string)
Backend emits metadata:
metadata.models = [{ code: "gemini-2.5-pro", value: "Gemini 2.5 Pro", description: "Most capable" }, ...]
metadata.currentModelCode = "gemini-2.5-pro"
metadata.operatingModes = [{ code: "default", value: "Default" }, ...]
metadata.currentOperatingModeCode = "default"
SessionView builds structs:
modelMode = { key: "gemini-2.5-pro", name: "Gemini 2.5 Pro", description: "Most capable" }
permissionMode = { key: "default", name: "Default", description: null }
availableModels = metadata.models → ModelMode[]
availableModes = hardcoded (claude/codex) OR metadata.operatingModes → PermissionMode[]
AgentInput renders:
Shows "Gemini 2.5 Pro" (name) in model selector and status bar
Shows "Default" (name) in mode selector and status bar
On selection → calls onChange with full { key, name, description } struct
SessionView handles change:
Extracts key → calls updateSessionModelMode("gemini-2.5-pro")
Extracts key → calls updateSessionPermissionMode("acceptEdits")
sendMessage():
Reads session.modelMode ("gemini-2.5-pro") → sends as meta.model (ALL agents)
Reads session.permissionMode ("acceptEdits") → sends as meta.permissionMode
Manual verification: