import { useCallback, useEffect, useMemo, useState } from "react"; import { Pressable, Text, TextInput, View } from "react-native"; import { router } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, Check, ChevronDown, MoreVertical, Pencil, Plus, X } from "lucide-react-native"; import { ProjectIconView } from "@/components/project-icon-view"; import type { PaseoConfigRaw, PaseoConfigRevision, ProjectConfigRpcError, } from "@getpaseo/protocol/messages"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Alert } from "@/components/ui/alert"; import { ExternalLink } from "@/components/ui/external-link"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Switch } from "@/components/ui/switch"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { SettingsTextAreaCard } from "@/components/settings-textarea"; import { SettingsGroup } from "@/screens/settings/settings-group"; import { SettingsSection } from "@/screens/settings/settings-section"; import { settingsStyles } from "@/styles/settings"; import { useProjects } from "@/hooks/use-projects"; import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; import { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime"; import { useToast } from "@/contexts/toast-context"; import { confirmDialog } from "@/utils/confirm-dialog"; import { applyDraftToConfig, configToDraft, METADATA_PROMPT_KEYS, type LifecycleOriginalKind, type MetadataPromptKey, type ProjectConfigDraft, type ProjectScriptDraft, } from "@/utils/project-config-form"; import { buildProjectsSettingsRoute } from "@/utils/host-routes"; import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects"; const SCRIPT_SERVICE_TYPE = "service"; const ICON_SIZE = 14; interface MetadataPromptField { title: string; placeholder: string; sectionTestID: string; inputTestID: string; } const METADATA_PROMPT_FIELDS: Record = { agentTitle: { title: "Agent titles", placeholder: "Keep titles imperative and under 40 characters", sectionTestID: "metadata-prompt-agent-title-section", inputTestID: "metadata-prompt-agent-title-input", }, branchName: { title: "Branch names", placeholder: "Prefix branches with feat/ or fix/, mb/ for personal branches", sectionTestID: "metadata-prompt-branch-name-section", inputTestID: "metadata-prompt-branch-name-input", }, commitMessage: { title: "Commit messages", placeholder: "Use Conventional Commits with a scope", sectionTestID: "metadata-prompt-commit-message-section", inputTestID: "metadata-prompt-commit-message-input", }, pullRequest: { title: "Pull requests", placeholder: "Lead with a one-paragraph summary, include a Test plan section", sectionTestID: "metadata-prompt-pull-request-section", inputTestID: "metadata-prompt-pull-request-input", }, }; const WORKTREE_GROUP_INFO = "Commands that run when a worktree is created or torn down for this project"; const WORKTREE_DOCS_URL = "https://paseo.sh/docs/worktrees"; const WORKTREE_DOCS_TOOLTIP = "See docs for more details and the environment variables available to these commands"; const SCRIPTS_GROUP_INFO = "Long-running services and one-off commands you can launch from any agent in this project"; const METADATA_GROUP_INFO = "Project-specific instructions injected into the AI prompts Paseo uses to generate metadata — use them to enforce your team's conventions like branch naming, commit style, or PR format"; const NO_TARGET_MESSAGE = "We don't have an editable copy of this project on any connected host."; const HOST_SWITCHER_LABEL = "Switch host"; type ReadProjectConfigData = Awaited>; export interface ProjectSettingsScreenProps { projectKey: string; } export default function ProjectSettingsScreen({ projectKey }: ProjectSettingsScreenProps) { const { projects } = useProjects(); const project = useMemo( () => projects.find((entry) => entry.projectKey === projectKey), [projects, projectKey], ); const editableHosts = useMemo(() => filterEditableHosts(project), [project]); const [selectedServerId, setSelectedServerId] = useState( () => editableHosts[0]?.serverId ?? "", ); useEffect(() => { const stillValid = editableHosts.some((host) => host.serverId === selectedServerId); if (!stillValid) { setSelectedServerId(editableHosts[0]?.serverId ?? ""); } }, [editableHosts, selectedServerId]); const selectedSnapshot = useHostRuntimeSnapshot(selectedServerId); const isHostGone = Boolean(selectedServerId) && (selectedSnapshot?.connectionStatus === "offline" || selectedSnapshot?.connectionStatus === "error"); const selectedHost = editableHosts.find((host) => host.serverId === selectedServerId); const client = useHostRuntimeClient(selectedHost?.serverId ?? ""); if (!project || editableHosts.length === 0 || !selectedHost || !client) { return ; } return ( ); } function filterEditableHosts(project: ProjectSummary | undefined): ProjectHostEntry[] { if (!project) return []; return project.hosts.filter( (host) => host.isOnline && host.serverId.trim().length > 0 && host.repoRoot.trim().length > 0, ); } function navigateBackToProjects() { router.navigate(buildProjectsSettingsRoute()); } function NoEditableTarget() { return ( {NO_TARGET_MESSAGE} ); } function BackToProjectsButton() { return ( ); } interface ProjectSettingsBodyProps { project: ProjectSummary; hosts: ProjectHostEntry[]; selectedHost: ProjectHostEntry; onSelectHost: (serverId: string) => void; client: DaemonClient; isHostGone: boolean; } function ProjectSettingsBody({ project, hosts, selectedHost, onSelectHost, client, isHostGone, }: ProjectSettingsBodyProps) { const queryKey = useMemo( () => ["project-config", selectedHost.serverId, selectedHost.repoRoot] as const, [selectedHost.serverId, selectedHost.repoRoot], ); const readQuery = useQuery({ queryKey, queryFn: () => client.readProjectConfig(selectedHost.repoRoot), retry: false, }); const data = readQuery.data; const projectIconTargets = useMemo( () => [ { serverId: selectedHost.serverId, projectKey: project.projectKey, iconWorkingDir: selectedHost.repoRoot, }, ], [project.projectKey, selectedHost.repoRoot, selectedHost.serverId], ); const projectIconDataByKey = useProjectIconDataByProjectKey({ serverId: null, projects: projectIconTargets, }); const projectIconDataUri = projectIconDataByKey.get(project.projectKey) ?? null; const loadedConfig: PaseoConfigRaw | null = data?.ok ? (data.config ?? {}) : null; const loadedRevision: PaseoConfigRevision | null = data?.ok ? data.revision : null; const readError: ProjectConfigRpcError | null = data && !data.ok ? data.error : null; const handleReload = useCallback(() => { void readQuery.refetch(); }, [readQuery]); const hasMultipleHosts = hosts.length > 1; return ( {renderContent({ readQuery, loadedConfig, loadedRevision, readError, selectedHost, queryKey, client, onReload: handleReload, hasMultipleHosts, isHostGone, })} ); } interface RenderContentInput { readQuery: ReturnType>; loadedConfig: PaseoConfigRaw | null; loadedRevision: PaseoConfigRevision | null; readError: ProjectConfigRpcError | null; selectedHost: ProjectHostEntry; queryKey: readonly [string, string, string]; client: DaemonClient; onReload: () => void; hasMultipleHosts: boolean; isHostGone: boolean; } function renderContent({ readQuery, loadedConfig, loadedRevision, readError, selectedHost, queryKey, client, onReload, hasMultipleHosts, isHostGone, }: RenderContentInput) { if (readQuery.isLoading) { return ( ); } if (readQuery.isError) { return ( ); } if (readError) { return ( ); } if (isHostGone) { return ; } if (!loadedConfig) { return ( ); } const formKey = `${selectedHost.serverId}::${selectedHost.repoRoot}::${revisionToKey(loadedRevision)}`; return ( ); } function revisionToKey(revision: PaseoConfigRevision | null): string { if (!revision) return "none"; return `${revision.mtimeMs}-${revision.size}`; } interface ReadFailureCalloutProps { kind: "transport" | ProjectConfigRpcError["code"]; error: unknown; onReload: () => void; hasMultipleHosts: boolean; } function ReadFailureCallout({ kind, error, onReload, hasMultipleHosts }: ReadFailureCalloutProps) { const { testID, title, description } = resolveReadFailureCopy({ kind, error, hasMultipleHosts }); return ( ); } function resolveReadFailureCopy(input: { kind: ReadFailureCalloutProps["kind"]; error: unknown; hasMultipleHosts: boolean; }): { testID: string; title: string; description: string } { if (input.kind === "invalid_project_config") { return { testID: "invalid-callout", title: "paseo.json couldn't be parsed", description: "Fix the file on disk, then reload.", }; } if (input.kind === "project_not_found") { return { testID: "project-not-found-callout", title: "This host doesn't have this project", description: input.hasMultipleHosts ? "Switch to another host above, or reload." : "The selected host has no record of this project.", }; } if (input.kind === "transport") { const detail = errorToDetail(input.error); return { testID: "read-transport-callout", title: "Couldn't load paseo.json", description: detail ?? "The host didn't respond.", }; } return { testID: "read-failed-callout", title: "Couldn't load paseo.json", description: "Reload to try again.", }; } function errorToDetail(error: unknown): string | null { if (error instanceof Error && error.message.length > 0) return error.message; if (typeof error === "string" && error.length > 0) return error; return null; } interface ProjectConfigFormProps { baseConfig: PaseoConfigRaw; revision: PaseoConfigRevision | null; repoRoot: string; queryKey: readonly [string, string, string]; client: DaemonClient; onReload: () => void; } function ProjectConfigForm({ baseConfig, revision, repoRoot, queryKey, client, onReload, }: ProjectConfigFormProps) { const queryClient = useQueryClient(); const toast = useToast(); const [draft, setDraft] = useState(() => configToDraft(baseConfig)); const [writeError, setWriteError] = useState(null); const [editingScriptId, setEditingScriptId] = useState(null); const saveMutation = useMutation({ mutationFn: async (input: { config: PaseoConfigRaw; expectedRevision: PaseoConfigRevision | null; }) => { return client.writeProjectConfig({ repoRoot, config: input.config, expectedRevision: input.expectedRevision, }); }, onSuccess: (result) => { if (result.ok) { queryClient.setQueryData(queryKey, { ok: true, config: result.config, revision: result.revision, requestId: "local-cache", repoRoot, }); setWriteError(null); queryClient.invalidateQueries({ queryKey: ["projects"] }); toast.show("Project saved", { variant: "success" }); } else { setWriteError(result.error); } }, }); const handleSave = useCallback(() => { if (writeError?.code === "stale_project_config") return; const config = applyDraftToConfig({ draft, base: baseConfig }); saveMutation.mutate({ config, expectedRevision: revision }); }, [draft, baseConfig, revision, writeError, saveMutation]); const handleReload = useCallback(() => { setWriteError(null); onReload(); }, [onReload]); const updateDraft = useCallback((updater: (draft: ProjectConfigDraft) => ProjectConfigDraft) => { setDraft((prev) => updater(prev)); }, []); const handleSetupChange = useCallback( (text: string) => updateDraft((d) => ({ ...d, setupText: text })), [updateDraft], ); const handleTeardownChange = useCallback( (text: string) => updateDraft((d) => ({ ...d, teardownText: text })), [updateDraft], ); const handleMetadataPromptChange = useCallback( (key: MetadataPromptKey, text: string) => updateDraft((d) => ({ ...d, metadataPrompts: { ...d.metadataPrompts, [key]: text }, })), [updateDraft], ); const handleRemoveScript = useCallback( async (script: ProjectScriptDraft) => { const ok = await confirmDialog({ title: "Remove script?", message: `Remove ${script.name || "this script"}?`, confirmLabel: "Remove", cancelLabel: "Cancel", destructive: true, }); if (!ok) return; updateDraft((d) => ({ ...d, scripts: d.scripts.filter((entry) => entry.id !== script.id), })); }, [updateDraft], ); const handleEditScript = useCallback((script: ProjectScriptDraft) => { setEditingScriptId(script.id); }, []); const handleAddScript = useCallback(() => { const id = `script-draft-new-${Date.now()}`; updateDraft((d) => ({ ...d, scripts: [ ...d.scripts, { id, name: "", commandText: "", commandOriginalKind: "missing" satisfies LifecycleOriginalKind, type: "", portText: "", rawEntry: {}, }, ], })); setEditingScriptId(id); }, [updateDraft]); const handleEditingDraftChange = useCallback( (next: ProjectScriptDraft) => { updateDraft((d) => ({ ...d, scripts: d.scripts.map((entry) => (entry.id === next.id ? next : entry)), })); }, [updateDraft], ); const handleCancelEditing = useCallback(() => { if (!editingScriptId) { return; } updateDraft((d) => { const entry = d.scripts.find((row) => row.id === editingScriptId); if (!entry) return d; const isEmpty = entry.name.trim().length === 0 && entry.commandText.trim().length === 0 && entry.type.trim().length === 0 && entry.portText.trim().length === 0; if (!isEmpty) return d; return { ...d, scripts: d.scripts.filter((row) => row.id !== editingScriptId) }; }); setEditingScriptId(null); }, [editingScriptId, updateDraft]); const handleSaveEditing = useCallback(() => { setEditingScriptId(null); }, []); const editingScript = draft.scripts.find((entry) => entry.id === editingScriptId); const hasInvalidScripts = useMemo( () => draft.scripts.some((script) => validateScript(script).hasErrors), [draft.scripts], ); const scriptsTrailing = useMemo( () => ( ), [handleAddScript], ); const setupDocsLink = useMemo( () => ( ), [], ); const teardownDocsLink = useMemo( () => ( ), [], ); const isStale = writeError?.code === "stale_project_config"; const isWriteFailed = writeError?.code === "write_failed"; const saveDisabled = saveMutation.isPending || isStale || hasInvalidScripts; return ( {draft.scripts.length === 0 ? ( No scripts yet. ) : ( draft.scripts.map((script, index) => ( )) )} {METADATA_PROMPT_KEYS.map((key, index) => ( ))} {isStale ? ( ) : null} {isWriteFailed ? ( ) : null} {editingScript ? ( ) : null} ); } function ResolveSpinnerColor(): string { return styles.spinnerColor.color; } interface ProjectNameEditorProps { project: ProjectSummary; client: DaemonClient; } function ProjectNameEditor({ project, client }: ProjectNameEditorProps) { const queryClient = useQueryClient(); const toast = useToast(); const [isEditing, setIsEditing] = useState(false); const [value, setValue] = useState(project.projectCustomName ?? ""); const renameMutation = useMutation({ mutationFn: (customName: string | null) => client.renameProject(project.projectKey, customName), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["projects"] }); setIsEditing(false); toast.show("Project renamed", { variant: "success" }); }, onError: (error) => { const message = error instanceof Error ? error.message : "Couldn't rename project"; toast.show(message, { variant: "error" }); }, }); const handleStartEdit = useCallback(() => { setValue(project.projectCustomName ?? ""); setIsEditing(true); }, [project.projectCustomName]); const handleCancel = useCallback(() => { setIsEditing(false); setValue(project.projectCustomName ?? ""); }, [project.projectCustomName]); const handleSave = useCallback(() => { const trimmed = value.trim(); const next = trimmed.length === 0 ? null : trimmed; if (next === (project.projectCustomName ?? null)) { setIsEditing(false); return; } renameMutation.mutate(next); }, [value, project.projectCustomName, renameMutation]); const handleReset = useCallback(() => { renameMutation.mutate(null); }, [renameMutation]); if (!isEditing) { return ( {project.projectName} {project.projectCustomName ? ( Reset ) : null} ); } return ( ); } function ProjectTitleIcon({ iconDataUri, projectName, projectKey, }: { iconDataUri: string | null; projectName: string; projectKey: string; }) { const initial = projectName.trim().charAt(0).toUpperCase() || "?"; return ( ); } interface HostContextProps { hosts: ProjectHostEntry[]; selectedHost: ProjectHostEntry; onSelectHost: (serverId: string) => void; } function HostContext({ hosts, selectedHost, onSelectHost }: HostContextProps) { if (hosts.length > 1) { return ; } return ( {selectedHost.serverName} ); } function HostStatusDot({ serverId }: { serverId: string }) { const { theme } = useUnistyles(); const snapshot = useHostRuntimeSnapshot(serverId); const status = snapshot?.connectionStatus ?? "connecting"; let color: string; if (status === "online") color = theme.colors.palette.green[400]; else if (status === "connecting") color = theme.colors.palette.amber[500]; else color = theme.colors.palette.red[500]; const dotStyle = useMemo(() => [styles.hostStatusDot, { backgroundColor: color }], [color]); return ; } interface HostPickerProps { hosts: ProjectHostEntry[]; selectedHost: ProjectHostEntry; onSelectHost: (serverId: string) => void; } function HostPicker({ hosts, selectedHost, onSelectHost }: HostPickerProps) { return ( {selectedHost.serverName} {hosts.map((host) => ( ))} ); } interface HostPickerItemProps { host: ProjectHostEntry; isSelected: boolean; onSelectHost: (serverId: string) => void; } function HostPickerItem({ host, isSelected, onSelectHost }: HostPickerItemProps) { const handleSelect = useCallback( () => onSelectHost(host.serverId), [host.serverId, onSelectHost], ); return ( {host.serverName} ); } interface MetadataPromptSectionProps { promptKey: MetadataPromptKey; value: string; onChange: (key: MetadataPromptKey, text: string) => void; flush?: boolean; } function MetadataPromptSection({ promptKey, value, onChange, flush }: MetadataPromptSectionProps) { const meta = METADATA_PROMPT_FIELDS[promptKey]; const handleChange = useCallback( (text: string) => onChange(promptKey, text), [onChange, promptKey], ); return ( ); } interface ScriptRowProps { script: ProjectScriptDraft; isFirst: boolean; onEdit: (script: ProjectScriptDraft) => void; onRemove: (script: ProjectScriptDraft) => void; } function ScriptRow({ script, isFirst, onEdit, onRemove }: ScriptRowProps) { const handleEdit = useCallback(() => onEdit(script), [onEdit, script]); const handleRemove = useCallback(() => onRemove(script), [onRemove, script]); const rowStyle = isFirst ? styles.scriptRow : styles.scriptRowWithBorder; return ( {script.name || "Untitled script"} {scriptHint(script)} Edit Remove ); } function scriptHint(script: ProjectScriptDraft): string { const pieces: string[] = []; if (script.type) pieces.push(script.type); if (script.portText) pieces.push(`port ${script.portText}`); if (script.commandText) pieces.push(script.commandText.split("\n")[0] ?? ""); return pieces.join(" · "); } interface ScriptValidation { hasErrors: boolean; nameError: string | null; commandError: string | null; } function validateScript(script: ProjectScriptDraft): ScriptValidation { const nameError = script.name.trim().length === 0 ? "Name is required" : null; const commandError = script.commandText.trim().length === 0 ? "Command is required" : null; return { hasErrors: Boolean(nameError || commandError), nameError, commandError, }; } interface ScriptEditModalProps { script: ProjectScriptDraft; onChange: (next: ProjectScriptDraft) => void; onCancel: () => void; onSave: () => void; } interface ScriptFieldsTouched { name: boolean; command: boolean; } const ALL_TOUCHED: ScriptFieldsTouched = { name: true, command: true }; const NONE_TOUCHED: ScriptFieldsTouched = { name: false, command: false }; function ScriptEditModal({ script, onChange, onCancel, onSave }: ScriptEditModalProps) { const [touched, setTouched] = useState(NONE_TOUCHED); useEffect(() => { setTouched(NONE_TOUCHED); }, [script.id]); const markTouched = useCallback((field: keyof ScriptFieldsTouched) => { setTouched((prev) => (prev[field] ? prev : { ...prev, [field]: true })); }, []); const handleNameChange = useCallback( (text: string) => onChange({ ...script, name: text }), [onChange, script], ); const handleCommandChange = useCallback( (text: string) => onChange({ ...script, commandText: text }), [onChange, script], ); const handleServiceToggle = useCallback( (next: boolean) => onChange({ ...script, type: next ? SCRIPT_SERVICE_TYPE : "" }), [onChange, script], ); const handleNameBlur = useCallback(() => markTouched("name"), [markTouched]); const handleCommandBlur = useCallback(() => markTouched("command"), [markTouched]); const validation = validateScript(script); const handleSavePress = useCallback(() => { if (validation.hasErrors) { setTouched(ALL_TOUCHED); return; } onSave(); }, [validation.hasErrors, onSave]); const showNameError = touched.name && validation.nameError; const showCommandError = touched.command && validation.commandError; const isService = script.type === SCRIPT_SERVICE_TYPE; const sheetHeader = useMemo( () => ({ title: script.name ? `Edit ${script.name}` : "New script" }), [script.name], ); return ( Name {showNameError ? ( {validation.nameError} ) : null} Command {showCommandError ? ( {validation.commandError} ) : null} Run as a service Paseo supervises the process and assigns a port via $PASEO_PORT ); } const styles = StyleSheet.create((theme) => ({ noTargetContainer: { padding: theme.spacing[4], alignItems: "flex-start", gap: theme.spacing[3], }, noTargetText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, body: { padding: theme.spacing[4], gap: theme.spacing[2], }, backButton: { alignSelf: "flex-start", paddingHorizontal: 0, }, headerBlock: { marginTop: theme.spacing[2], marginBottom: theme.spacing[4], gap: theme.spacing[2], }, titleRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[3], }, projectTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.lg, fontWeight: theme.fontWeight.medium, flexShrink: 1, }, nameEditorRow: { flex: 1, flexDirection: "row", alignItems: "center", gap: theme.spacing[2], minWidth: 0, }, nameEditorIconButton: { padding: theme.spacing[1], }, nameEditorInput: { flex: 1, color: theme.colors.foreground, fontSize: theme.fontSize.lg, fontWeight: theme.fontWeight.medium, paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.md, borderWidth: 1, borderColor: theme.colors.border, backgroundColor: theme.colors.surface2, minWidth: 0, }, nameEditorResetButton: { paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[2], }, nameEditorResetText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, }, titleIcon: { width: 28, height: 28, borderRadius: theme.borderRadius.md, }, titleIconFallback: { width: 28, height: 28, borderRadius: theme.borderRadius.md, alignItems: "center", justifyContent: "center", }, titleIconFallbackText: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, }, iconColor: { color: theme.colors.foregroundMuted, }, hostIndicator: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[1], borderRadius: theme.borderRadius.lg, alignSelf: "flex-start", minWidth: 0, }, hostStatusDot: { width: 8, height: 8, borderRadius: theme.borderRadius.full, }, hostName: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, flexShrink: 1, minWidth: 0, }, centered: { flex: 1, alignItems: "center", justifyContent: "center", padding: theme.spacing[6], }, errorBlock: { marginTop: theme.spacing[2], }, emptyScripts: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, scriptRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingVertical: theme.spacing[4], paddingHorizontal: theme.spacing[4], }, scriptRowWithBorder: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingVertical: theme.spacing[4], paddingHorizontal: theme.spacing[4], borderTopWidth: 1, borderTopColor: theme.colors.border, }, scriptRowMain: { flex: 1, minWidth: 0, gap: theme.spacing[1], }, scriptKebab: { padding: theme.spacing[1], }, calloutWrap: { marginTop: theme.spacing[3], }, footer: { marginTop: theme.spacing[4], flexDirection: "row", justifyContent: "flex-end", }, modalSection: { gap: theme.spacing[2], }, modalLabel: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, }, modalInput: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.md, borderWidth: 1, borderColor: theme.colors.border, backgroundColor: theme.colors.surface2, }, modalMultilineInput: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.md, borderWidth: 1, borderColor: theme.colors.border, backgroundColor: theme.colors.surface2, minHeight: 100, textAlignVertical: "top", }, modalFooter: { flexDirection: "row", justifyContent: "flex-end", gap: theme.spacing[2], marginTop: theme.spacing[2], }, fieldError: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.xs, }, serviceToggleRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[3], }, serviceToggleText: { flex: 1, minWidth: 0, gap: theme.spacing[1], }, serviceToggleLabel: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, }, modalHint: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, }, placeholderColor: { color: theme.colors.foregroundMuted, }, chevronColor: { color: theme.colors.foregroundMuted, }, spinnerColor: { color: theme.colors.foregroundMuted, }, }));