diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 8fa5cb50..c736886a 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -29,7 +29,6 @@ import { useAssistantStatus } from '@/hooks/useAssistantStatus'; import { useCurrentSessionActivity } from '@/hooks/useSessionActivity'; import { toast } from '@/components/ui'; import { useFileStore } from '@/stores/fileStore'; -import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults'; import { isVSCodeRuntime } from '@/lib/desktop'; import { isIMECompositionEvent } from '@/lib/ime'; import { @@ -38,6 +37,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { useContextStore } from '@/stores/contextStore'; const MAX_VISIBLE_TEXTAREA_LINES = 8; const EMPTY_QUEUE: QueuedMessage[] = []; @@ -96,23 +96,6 @@ const resolveWildcardPermissionAction = (ruleset: unknown, permission: string): return undefined; }; -const buildPermissionActionMap = (ruleset: unknown, permission: string): Record | undefined => { - const rules = asPermissionRuleset(ruleset); - if (!rules || rules.length === 0) { - return undefined; - } - - const map: Record = {}; - for (const rule of rules) { - if (rule.permission !== permission) { - continue; - } - map[rule.pattern] = rule.action; - } - - return Object.keys(map).length > 0 ? map : undefined; -}; - export const ChatInput: React.FC = ({ onOpenSettings, scrollToBottom }) => { const [message, setMessage] = React.useState(''); const [isDragging, setIsDragging] = React.useState(false); @@ -236,60 +219,47 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo }, [pendingInputText, consumePendingInputText]); const currentAgent = React.useMemo(() => { - if (!currentAgentName) { + const selectedName = currentSessionId + ? (useContextStore.getState().getSessionAgentSelection(currentSessionId) || currentAgentName) + : currentAgentName; + if (!selectedName) { return undefined; } - return agents.find((agent) => agent.name === currentAgentName); - }, [agents, currentAgentName]); + return agents.find((agent) => agent.name === selectedName); + }, [agents, currentAgentName, currentSessionId]); - const agentDefaultEditMode = React.useMemo(() => { + const agentEditAction = React.useMemo(() => { if (!currentAgent) { return 'deny'; } - const action = resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'ask'; - return action; + return resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'allow'; }, [currentAgent]); - const sessionAgentEditOverride = useSessionStore( + const sessionEditMode = useContextStore( React.useCallback((state) => { - if (!currentSessionId || !currentAgentName) { + if (!currentAgentName) { return undefined; } - const sessionMap = state.sessionAgentEditModes.get(currentSessionId); - return sessionMap?.get(currentAgentName); - }, [currentSessionId, currentAgentName]) + const sessionId = currentSessionId ?? '__global__'; + return state.getSessionAgentEditMode(sessionId, currentAgentName, 'ask'); + }, [currentAgentName, currentSessionId]) ); - const agentWebfetchPermission = React.useMemo(() => { - if (!currentAgent) { - return undefined; - } - return resolveWildcardPermissionAction(currentAgent.permission, 'webfetch'); - }, [currentAgent]); - - const agentBashPermission = React.useMemo(() => { - if (!currentAgent) { - return undefined; - } - const map = buildPermissionActionMap(currentAgent.permission, 'bash'); - return map ? (map as BashPermissionSetting) : undefined; - }, [currentAgent]); - - const permissionUiState = React.useMemo(() => calculateEditPermissionUIState({ - agentDefaultEditMode, - webfetchPermission: agentWebfetchPermission, - bashPermission: agentBashPermission, - }), [agentDefaultEditMode, agentWebfetchPermission, agentBashPermission]); - const selectionContextReady = Boolean(currentSessionId && currentAgentName); const effectiveEditPermission = React.useMemo(() => { - if (selectionContextReady && sessionAgentEditOverride && permissionUiState.modeAvailability[sessionAgentEditOverride]) { - return sessionAgentEditOverride; + // Only show accent when edits are effectively allowed. + if (agentEditAction === 'allow') { + return 'allow'; } - return permissionUiState.cascadeDefaultMode; - }, [permissionUiState, selectionContextReady, sessionAgentEditOverride]); + if (agentEditAction !== 'ask') { + return 'ask'; + } + + const sessionMode = selectionContextReady ? (sessionEditMode ?? 'ask') : 'ask'; + return (sessionMode === 'allow' || sessionMode === 'full') ? 'allow' : 'ask'; + }, [agentEditAction, selectionContextReady, sessionEditMode]); const chatInputAccent = React.useMemo(() => getEditModeColors(effectiveEditPermission), [effectiveEditPermission]); diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index 3b7badbb..263fb7ab 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -35,11 +35,11 @@ import { Input } from '@/components/ui/input'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { Switch } from '@/components/ui/switch'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useIsDesktopRuntime, useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs'; import { getAgentColor } from '@/lib/agentColors'; import { useDeviceInfo } from '@/lib/device'; -import { calculateEditPermissionUIState, type BashPermissionSetting } from '@/lib/permissions/editPermissionDefaults'; import { getEditModeColors } from '@/lib/permissions/editModeColors'; import { cn, fuzzyMatch } from '@/lib/utils'; import { useContextStore } from '@/stores/contextStore'; @@ -103,23 +103,6 @@ const resolveWildcardPermissionAction = (ruleset: unknown, permission: string): return undefined; }; -const buildPermissionActionMap = (ruleset: unknown, permission: string): Record | undefined => { - const rules = asPermissionRuleset(ruleset); - if (!rules || rules.length === 0) { - return undefined; - } - - const map: Record = {}; - for (const rule of rules) { - if (rule.permission !== permission) { - continue; - } - map[rule.pattern] = rule.action; - } - - return Object.keys(map).length > 0 ? map : undefined; -}; - interface CapabilityDefinition { key: 'tool_call' | 'reasoning'; icon: IconComponent; @@ -301,8 +284,6 @@ export const ModelControls: React.FC = ({ className }) => { saveAgentModelVariantForSession, getAgentModelVariantForSession, analyzeAndSaveExternalSessionChoices, - getSessionAgentEditMode, - setSessionAgentEditMode, } = useSessionStore(); const contextHydrated = useContextStore((state) => state.hasHydrated); @@ -328,6 +309,15 @@ export const ModelControls: React.FC = ({ className }) => { const uiAgentName = currentSessionId ? (sessionSavedAgentName || stickySessionAgentName || currentAgentName) : currentAgentName; + + const sessionIdForEditMode = currentSessionId ?? '__global__'; + const sessionEditMode = useContextStore((state) => { + if (!uiAgentName) { + return undefined; + } + return state.getSessionAgentEditMode(sessionIdForEditMode, uiAgentName, 'ask'); + }); + const setSessionAgentEditMode = useContextStore((state) => state.setSessionAgentEditMode); const { toggleFavoriteModel, isFavoriteModel, addRecentModel, isModelSelectorOpen, setModelSelectorOpen } = useUIStore(); const { favoriteModelsList, recentModelsList } = useModelLists(); @@ -349,12 +339,9 @@ export const ModelControls: React.FC = ({ className }) => { } return initial; }); - const [mobileEditOptionsOpen, setMobileEditOptionsOpen] = React.useState(false); // Use global state for model selector (allows Ctrl+M shortcut) const agentMenuOpen = isModelSelectorOpen; const setAgentMenuOpen = setModelSelectorOpen; - const [desktopEditOptionsOpen, setDesktopEditOptionsOpen] = React.useState(false); - const desktopEditOptionsId = React.useId(); const [desktopModelQuery, setDesktopModelQuery] = React.useState(''); const [modelSelectedIndex, setModelSelectedIndex] = React.useState(0); const modelItemRefs = React.useRef<(HTMLDivElement | null)[]>([]); @@ -372,9 +359,6 @@ export const ModelControls: React.FC = ({ className }) => { }, [activeMobilePanel, currentProviderId]); React.useEffect(() => { - if (activeMobilePanel !== 'agent') { - setMobileEditOptionsOpen(false); - } if (activeMobilePanel !== 'model') { setMobileModelQuery(''); } @@ -386,7 +370,6 @@ export const ModelControls: React.FC = ({ className }) => { prevAgentMenuOpenRef.current = agentMenuOpen; if (!agentMenuOpen) { - setDesktopEditOptionsOpen(false); setDesktopModelQuery(''); setModelSelectedIndex(0); @@ -405,81 +388,27 @@ export const ModelControls: React.FC = ({ className }) => { setModelSelectedIndex(0); }, [desktopModelQuery]); - const currentAgent = getCurrentAgent?.(); + const currentAgent = React.useMemo(() => { + if (uiAgentName) { + return agents.find((agent) => agent.name === uiAgentName); + } + return getCurrentAgent?.(); + }, [agents, getCurrentAgent, uiAgentName]); - const agentDefaultEditMode = React.useMemo(() => { + const agentEditAction = React.useMemo(() => { if (!currentAgent) { return 'deny'; } - const action = resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'ask'; - return action; + return resolveWildcardPermissionAction(currentAgent.permission, 'edit') ?? 'allow'; }, [currentAgent]); - const agentWebfetchPermission = React.useMemo(() => { - if (!currentAgent) { - return undefined; - } - return resolveWildcardPermissionAction(currentAgent.permission, 'webfetch'); - }, [currentAgent]); + const selectionContextReady = Boolean(uiAgentName); - const agentBashPermission = React.useMemo(() => { - if (!currentAgent) { - return undefined; - } - const map = buildPermissionActionMap(currentAgent.permission, 'bash'); - return map ? (map as BashPermissionSetting) : undefined; - }, [currentAgent]); - - const permissionUiState = React.useMemo(() => calculateEditPermissionUIState({ - agentDefaultEditMode, - webfetchPermission: agentWebfetchPermission, - bashPermission: agentBashPermission, - }), [agentDefaultEditMode, agentWebfetchPermission, agentBashPermission]); - - const { cascadeDefaultMode, modeAvailability, autoApproveAvailable } = permissionUiState; - - const selectionContextReady = Boolean(currentSessionId && uiAgentName); - const sessionMode = selectionContextReady && currentSessionId && uiAgentName - ? getSessionAgentEditMode(currentSessionId, uiAgentName, cascadeDefaultMode) - : cascadeDefaultMode; - - const editModeShortLabels: Record = { - ask: 'Ask before edits', - allow: 'Approve edit tools', - full: 'Approve every tool', - deny: 'Editing disabled', - }; - - const isModeDisabled = React.useCallback((mode: EditPermissionMode) => { - return !modeAvailability[mode]; - }, [modeAvailability]); - - const effectiveEditMode = React.useMemo(() => { - if (!selectionContextReady) { - return cascadeDefaultMode; - } - if (isModeDisabled(sessionMode) && sessionMode !== cascadeDefaultMode) { - return cascadeDefaultMode; - } - return sessionMode; - }, [cascadeDefaultMode, isModeDisabled, selectionContextReady, sessionMode]); - - const editPermissionOptions: Array<{ mode: EditPermissionMode; label: string; disabled: boolean }> = [ - { mode: 'ask', label: editModeShortLabels.ask, disabled: isModeDisabled('ask') }, - { mode: 'allow', label: editModeShortLabels.allow, disabled: isModeDisabled('allow') }, - { mode: 'full', label: editModeShortLabels.full, disabled: isModeDisabled('full') }, - ]; - - const activeEditModeColors = React.useMemo(() => getEditModeColors(effectiveEditMode), [effectiveEditMode]); - - const editToggleDisabled = !selectionContextReady || !autoApproveAvailable; - - React.useEffect(() => { - if (editToggleDisabled) { - setMobileEditOptionsOpen(false); - setDesktopEditOptionsOpen(false); - } - }, [editToggleDisabled]); + const approveEditsAvailable = agentEditAction === 'ask'; + const approveEditsChecked = approveEditsAvailable + ? sessionEditMode === 'allow' || sessionEditMode === 'full' + : agentEditAction === 'allow'; + const approveEditsDisabled = !selectionContextReady || !approveEditsAvailable; const sizeVariant: 'mobile' | 'vscode' | 'default' = isMobile ? 'mobile' : isVSCodeRuntime ? 'vscode' : 'default'; const buttonHeight = sizeVariant === 'mobile' ? 'h-9' : sizeVariant === 'vscode' ? 'h-6' : 'h-8'; @@ -487,8 +416,6 @@ export const ModelControls: React.FC = ({ className }) => { const controlIconSize = sizeVariant === 'mobile' ? 'h-5 w-5' : sizeVariant === 'vscode' ? 'h-4 w-4' : 'h-4 w-4'; const controlTextSize = isCompact ? 'typography-micro' : 'typography-meta'; const inlineGapClass = sizeVariant === 'mobile' ? 'gap-x-1' : sizeVariant === 'vscode' ? 'gap-x-1' : 'gap-x-3'; - const editPermissionMenuLabel = editModeShortLabels[effectiveEditMode]; - const renderEditModeIcon = React.useCallback((mode: EditPermissionMode, iconClass = editToggleIconClass) => { const combinedClassName = cn(iconClass, 'flex-shrink-0'); const modeColors = getEditModeColors(mode); @@ -507,15 +434,12 @@ export const ModelControls: React.FC = ({ className }) => { return ; }, [editToggleIconClass]); - const handleEditPermissionSelect = React.useCallback((mode: EditPermissionMode) => { - if (editToggleDisabled || !currentSessionId || !currentAgentName || isModeDisabled(mode)) { + const handleApproveEditsToggle = React.useCallback((checked: boolean) => { + if (!selectionContextReady || !currentAgentName || !approveEditsAvailable) { return; } - setSessionAgentEditMode(currentSessionId, currentAgentName, mode, cascadeDefaultMode); - setAgentMenuOpen(false); - setMobileEditOptionsOpen(false); - setDesktopEditOptionsOpen(false); - }, [cascadeDefaultMode, editToggleDisabled, currentSessionId, currentAgentName, setSessionAgentEditMode, setAgentMenuOpen, setDesktopEditOptionsOpen, isModeDisabled]); + setSessionAgentEditMode(sessionIdForEditMode, currentAgentName, checked ? 'allow' : 'ask', 'ask'); + }, [approveEditsAvailable, currentAgentName, selectionContextReady, setSessionAgentEditMode, sessionIdForEditMode]); const currentProvider = getCurrentProvider(); const models = Array.isArray(currentProvider?.models) ? currentProvider.models : []; @@ -1742,94 +1666,20 @@ export const ModelControls: React.FC = ({ className }) => { ); })} -
- - {mobileEditOptionsOpen && !editToggleDisabled && ( -
-
- {editPermissionOptions.map((option) => { - const isSelected = option.mode === effectiveEditMode; - const optionColors = getEditModeColors(option.mode); - return ( - - ); - })} -
-
- )} +
+
+ + Auto-approve edits + + +
@@ -2549,96 +2399,21 @@ export const ModelControls: React.FC = ({ className }) => { ))}
- - {desktopEditOptionsOpen && !editToggleDisabled && ( -
- {editPermissionOptions.map((option) => { - const isSelected = option.mode === effectiveEditMode; - const optionColors = getEditModeColors(option.mode); - return ( - - ); - })} -
- )} +
diff --git a/packages/ui/src/components/sections/agents/AgentsPage.tsx b/packages/ui/src/components/sections/agents/AgentsPage.tsx index fb6bd95d..9d72b022 100644 --- a/packages/ui/src/components/sections/agents/AgentsPage.tsx +++ b/packages/ui/src/components/sections/agents/AgentsPage.tsx @@ -22,15 +22,12 @@ import { } from '@/components/ui/select'; type PermissionAction = 'allow' | 'ask' | 'deny'; -type DefaultOverride = 'default' | PermissionAction; type PermissionRule = { permission: string; pattern: string; action: PermissionAction }; type PermissionConfigValue = PermissionAction | Record; - -type ParsedPermissionConfig = { - entries: Record; -}; +type PermissionRuleKey = `${string}::${string}`; const STANDARD_PERMISSION_KEYS = [ + '*', 'read', 'edit', 'glob', @@ -47,200 +44,179 @@ const STANDARD_PERMISSION_KEYS = [ 'codesearch', 'external_directory', 'doom_loop', + 'question', + 'plan_enter', + 'plan_exit', ] as const; const isPermissionAction = (value: unknown): value is PermissionAction => value === 'allow' || value === 'ask' || value === 'deny'; -const parsePermissionConfigValue = (value: unknown): PermissionConfigValue | undefined => { - if (isPermissionAction(value)) { - return value; - } +const buildRuleKey = (permission: string, pattern: string): PermissionRuleKey => + `${permission}::${pattern}`; - // We only manage wildcard rules in this UI. If a granular object is provided, - // read its wildcard action and ignore all other patterns. - if (value && typeof value === 'object' && !Array.isArray(value)) { - const wildcard = (value as Record)['*']; - if (isPermissionAction(wildcard)) { - return wildcard; +const normalizeRuleset = (ruleset: PermissionRule[]): PermissionRule[] => { + const map = new Map(); + for (const rule of ruleset) { + if (!rule.permission || rule.permission === 'invalid') { + continue; } + if (!rule.pattern) { + continue; + } + if (!isPermissionAction(rule.action)) { + continue; + } + map.set(buildRuleKey(rule.permission, rule.pattern), { + permission: rule.permission, + pattern: rule.pattern, + action: rule.action, + }); } - - return undefined; + return Array.from(map.values()); }; -const parsePermissionConfig = (value: unknown): ParsedPermissionConfig => { +const buildRuleMap = (ruleset: PermissionRule[]): Map => { + const map = new Map(); + for (const rule of normalizeRuleset(ruleset)) { + map.set(buildRuleKey(rule.permission, rule.pattern), rule); + } + return map; +}; + +const sortRules = (ruleset: PermissionRule[]): PermissionRule[] => + [...ruleset].sort((a, b) => { + const permissionCompare = a.permission.localeCompare(b.permission); + if (permissionCompare !== 0) return permissionCompare; + return a.pattern.localeCompare(b.pattern); + }); + +const areRulesEqual = (a: PermissionRule[], b: PermissionRule[]): boolean => { + const sortedA = sortRules(normalizeRuleset(a)); + const sortedB = sortRules(normalizeRuleset(b)); + if (sortedA.length !== sortedB.length) { + return false; + } + return sortedA.every((rule, index) => { + const other = sortedB[index]; + return rule.permission === other.permission + && rule.pattern === other.pattern + && rule.action === other.action; + }); +}; + +const getGlobalWildcardAction = (ruleset: PermissionRule[]): PermissionAction => { + const globalRule = ruleset.find((rule) => rule.permission === '*' && rule.pattern === '*'); + return globalRule?.action ?? 'allow'; +}; + +const filterRulesAgainstGlobal = (ruleset: PermissionRule[], globalAction: PermissionAction): PermissionRule[] => ( + normalizeRuleset(ruleset) + .filter((rule) => !(rule.permission === '*' && rule.pattern === '*')) + // Keep wildcard overrides only when they differ from global. + .filter((rule) => rule.pattern !== '*' || rule.action !== globalAction) +); + +const permissionConfigToRuleset = (value: unknown): PermissionRule[] => { if (isPermissionAction(value)) { - return { entries: { '*': value } }; + return [{ permission: '*', pattern: '*', action: value }]; } if (!value || typeof value !== 'object' || Array.isArray(value)) { - return { entries: {} }; - } - - const entries: Record = {}; - - for (const [key, raw] of Object.entries(value as Record)) { - const parsedValue = parsePermissionConfigValue(raw); - if (parsedValue !== undefined) { - entries[key] = parsedValue; - } - } - - return { entries }; -}; - -const splitDefaultOverrideFromEntries = (entries: Record): { - defaultOverride: DefaultOverride; - overrides: Record; -} => { - const overrides: Record = { ...entries }; - - const maybeDefault = overrides['*']; - if (isPermissionAction(maybeDefault)) { - delete overrides['*']; - return { defaultOverride: maybeDefault, overrides }; - } - - return { defaultOverride: 'default', overrides }; -}; - -const getOpenCodeDefaultActionForPermission = (permissionName: string): PermissionAction => { - if (permissionName === 'doom_loop' || permissionName === 'external_directory') { - return 'ask'; - } - return 'allow'; -}; - -const getAgentBaseDefaultActionForPermission = ( - permissionName: string, - defaultOverride: DefaultOverride, -): PermissionAction => { - if (defaultOverride === 'default') { - return getOpenCodeDefaultActionForPermission(permissionName); - } - - return defaultOverride; -}; - -const pruneRedundantPermissionOverrides = ( - entries: Record, - defaultOverride: DefaultOverride, -): Record => { - const pruned: Record = { ...entries }; - - for (const [permissionName, value] of Object.entries(entries)) { - if (permissionName === '*') { - continue; - } - - const baseDefaultAction = getAgentBaseDefaultActionForPermission(permissionName, defaultOverride); - const opencodeDefault = getOpenCodeDefaultActionForPermission(permissionName); - - // For doom_loop and external_directory (OpenCode default = "ask"), - // always keep explicit config if it differs from "ask" - if (isPermissionAction(value) && value !== opencodeDefault) { - continue; - } - - if (isPermissionAction(value) && value === baseDefaultAction) { - delete pruned[permissionName]; - continue; - } - - - if (value && typeof value === 'object' && !Array.isArray(value)) { - const map = value as Record; - const patterns = Object.keys(map); - const wildcardAction = map['*']; - // Keep if wildcard differs from OpenCode default - if (wildcardAction !== undefined && wildcardAction !== opencodeDefault) { - continue; - } - if (patterns.length === 1 && patterns[0] === '*' && wildcardAction === baseDefaultAction) { - delete pruned[permissionName]; - } - } - } - - return pruned; -}; - -const asPermissionRuleset = (value: unknown): PermissionRule[] | null => { - if (!Array.isArray(value)) { - return null; + return []; } const rules: PermissionRule[] = []; - for (const entry of value) { - if (!entry || typeof entry !== 'object') { + for (const [permissionName, configValue] of Object.entries(value as Record)) { + if (permissionName === '__originalKeys') { continue; } - const candidate = entry as Partial; - if (typeof candidate.permission !== 'string' || typeof candidate.pattern !== 'string' || typeof candidate.action !== 'string') { + if (isPermissionAction(configValue)) { + rules.push({ permission: permissionName, pattern: '*', action: configValue }); continue; } - if (candidate.action !== 'allow' && candidate.action !== 'ask' && candidate.action !== 'deny') { - continue; + if (configValue && typeof configValue === 'object' && !Array.isArray(configValue)) { + for (const [pattern, action] of Object.entries(configValue as Record)) { + if (isPermissionAction(action)) { + rules.push({ permission: permissionName, pattern, action }); + } + } } - rules.push({ permission: candidate.permission, pattern: candidate.pattern, action: candidate.action }); } return rules; }; -const rulesetToPermissionConfig = (ruleset: unknown): ParsedPermissionConfig => { - const rules = asPermissionRuleset(ruleset); - if (!rules || rules.length === 0) { - return { entries: {} }; +const buildPermissionConfigFromRules = (ruleset: PermissionRule[]): AgentConfig['permission'] | undefined => { + const normalized = normalizeRuleset(ruleset); + if (normalized.length === 0) { + return undefined; } - const wildcardByPermission: Record = {}; - - for (const rule of rules) { - if (!rule.permission || rule.permission === 'invalid') { - continue; - } - - // This UI only manages wildcard ("*") rules. - if (rule.pattern !== '*') { - continue; - } - - wildcardByPermission[rule.permission] = rule.action; + const grouped: Record> = {}; + for (const rule of normalized) { + (grouped[rule.permission] ||= {})[rule.pattern] = rule.action; } - // Get the global default from the ruleset (if any) - const globalDefault = wildcardByPermission['*']; - - const entries: Record = {}; - for (const [permissionName, action] of Object.entries(wildcardByPermission)) { - if (permissionName === '*') { - entries[permissionName] = action; - continue; - } - - // Determine what this permission would be without explicit config - const opencodeDefault = getOpenCodeDefaultActionForPermission(permissionName); - - if (globalDefault) { - // If there's a global default, skip permissions that match it - // (they're redundant - the global default covers them) - if (action === globalDefault) { - continue; - } + const result: Record = {}; + for (const [permissionName, patterns] of Object.entries(grouped)) { + if (Object.keys(patterns).length === 1 && patterns['*']) { + result[permissionName] = patterns['*']; } else { - // No global default - skip permissions that match OpenCode's built-in default - // (they're not explicitly configured, just inherited) - if (action === opencodeDefault) { - continue; - } + result[permissionName] = patterns; } - - entries[permissionName] = action; } - return { entries }; + return result as AgentConfig['permission']; +}; + +const buildPermissionConfigWithGlobal = ( + globalAction: PermissionAction, + ruleset: PermissionRule[], +): AgentConfig['permission'] => { + const normalized = normalizeRuleset(ruleset); + const grouped: Record> = {}; + + for (const rule of normalized) { + (grouped[rule.permission] ||= {})[rule.pattern] = rule.action; + } + + const result: Record = { + '*': globalAction, + }; + + for (const [permissionName, patterns] of Object.entries(grouped)) { + if (permissionName === '*') { + continue; + } + + if (Object.keys(patterns).length === 1 && patterns['*']) { + result[permissionName] = patterns['*']; + continue; + } + + result[permissionName] = patterns; + } + + return result as AgentConfig['permission']; +}; + +const buildPermissionDiffConfig = ( + baselineRules: PermissionRule[], + currentRules: PermissionRule[], +): AgentConfig['permission'] | undefined => { + const baselineMap = buildRuleMap(baselineRules); + const currentMap = buildRuleMap(currentRules); + const changedRules: PermissionRule[] = []; + + for (const [key, rule] of currentMap.entries()) { + const baselineRule = baselineMap.get(key); + if (!baselineRule || baselineRule.action !== rule.action) { + changedRules.push(rule); + } + } + + return buildPermissionConfigFromRules(changedRules); }; export const AgentsPage: React.FC = () => { @@ -258,10 +234,25 @@ export const AgentsPage: React.FC = () => { const [temperature, setTemperature] = React.useState(undefined); const [topP, setTopP] = React.useState(undefined); const [prompt, setPrompt] = React.useState(''); - const [defaultOverride, setDefaultOverride] = React.useState('default'); - const [permissionEntries, setPermissionEntries] = React.useState>({}); - const [pendingOverrideName, setPendingOverrideName] = React.useState(''); + const [globalPermission, setGlobalPermission] = React.useState('allow'); + const [permissionBaseline, setPermissionBaseline] = React.useState([]); + const [permissionRules, setPermissionRules] = React.useState([]); + const [pendingRuleName, setPendingRuleName] = React.useState(''); + const [pendingRulePattern, setPendingRulePattern] = React.useState('*'); + const [showPermissionEditor, setShowPermissionEditor] = React.useState(false); const [isSaving, setIsSaving] = React.useState(false); + const initialStateRef = React.useRef<{ + draftName: string; + draftScope: AgentScope; + description: string; + mode: 'primary' | 'subagent' | 'all'; + model: string; + temperature: number | undefined; + topP: number | undefined; + prompt: string; + globalPermission: PermissionAction; + permissionRules: PermissionRule[]; + } | null>(null); const currentDirectory = useDirectoryStore((state) => state.currentDirectory ?? null); const [toolIds, setToolIds] = React.useState([]); @@ -302,10 +293,7 @@ export const AgentsPage: React.FC = () => { const names = new Set(); for (const agent of agents) { - const rules = asPermissionRuleset((agent as { permission?: unknown }).permission); - if (!rules) { - continue; - } + const rules = normalizeRuleset(Array.isArray(agent.permission) ? agent.permission as PermissionRule[] : []); for (const rule of rules) { if (rule.permission && rule.permission !== '*' && rule.permission !== 'invalid') { names.add(rule.permission); @@ -329,87 +317,67 @@ export const AgentsPage: React.FC = () => { return Array.from(names).sort((a, b) => a.localeCompare(b)); }, [agents, permissionsBySession, toolIds]); - const getOverrideWildcardAction = React.useCallback((permissionName: string): PermissionAction | undefined => { - const configured = permissionEntries[permissionName]; - if (isPermissionAction(configured)) { - return configured; - } - if (configured && typeof configured === 'object' && !Array.isArray(configured)) { - const wildcard = (configured as Record)['*']; - if (isPermissionAction(wildcard)) { - return wildcard; - } - } - return undefined; - }, [permissionEntries]); + const baselineRuleMap = React.useMemo(() => buildRuleMap(permissionBaseline), [permissionBaseline]); + const currentRuleMap = React.useMemo(() => buildRuleMap(permissionRules), [permissionRules]); - const getCustomPatternCount = React.useCallback((permissionName: string): number => { - const configured = permissionEntries[permissionName]; - if (!configured || typeof configured !== 'object' || Array.isArray(configured)) { - return 0; - } - const patterns = Object.keys(configured).filter((pattern) => pattern !== '*'); - return patterns.length; - }, [permissionEntries]); + const getWildcardOverride = React.useCallback((permissionName: string): PermissionAction | undefined => ( + currentRuleMap.get(buildRuleKey(permissionName, '*'))?.action + ), [currentRuleMap]); - const setOverrideAction = React.useCallback((permissionName: string, action: PermissionAction) => { + const getEffectiveWildcardAction = React.useCallback((permissionName: string): PermissionAction => { if (permissionName === '*') { - return; + return globalPermission; } + return getWildcardOverride(permissionName) ?? globalPermission; + }, [getWildcardOverride, globalPermission]); - setPermissionEntries((prev) => { - const next: Record = { ...prev }; - const current = next[permissionName]; + const getPatternRules = React.useCallback((permissionName: string): PermissionRule[] => ( + permissionRules + .filter((rule) => rule.permission === permissionName && rule.pattern !== '*') + .sort((a, b) => a.pattern.localeCompare(b.pattern)) + ), [permissionRules]); - const uiDefaultAction = getAgentBaseDefaultActionForPermission(permissionName, defaultOverride); - const opencodeDefault = getOpenCodeDefaultActionForPermission(permissionName); + const summaryPermissionNames = React.useMemo(() => { + const names = new Set(); + for (const key of STANDARD_PERMISSION_KEYS) { + names.add(key); + } + for (const key of knownPermissionNames) { + names.add(key); + } + return Array.from(names).sort((a, b) => a.localeCompare(b)); + }, [knownPermissionNames]); - // Only remove if action matches BOTH the UI default AND OpenCode's built-in default - // If they differ, we need to keep the explicit override to ensure correct behavior - const canRemove = action === uiDefaultAction && action === opencodeDefault; - - if (current && typeof current === 'object' && !Array.isArray(current)) { - const map: Record = { ...(current as Record) }; - map['*'] = action; - - const nonWildcardPatterns = Object.keys(map).filter((pattern) => pattern !== '*'); - if (canRemove && nonWildcardPatterns.length === 0) { - delete next[permissionName]; - return next; - } - - next[permissionName] = map; - return next; - } - - if (canRemove) { - delete next[permissionName]; - return next; - } - - next[permissionName] = action; - return next; - }); - }, [defaultOverride]); - - const removeOverride = React.useCallback((permissionName: string) => { - setPermissionEntries((prev) => { - if (!(permissionName in prev)) { - return prev; - } - const next = { ...prev }; - delete next[permissionName]; - return next; - }); + const getFallbackDefaultAction = React.useCallback((permissionName: string): PermissionAction => { + if (permissionName === 'doom_loop' || permissionName === 'external_directory') { + return 'ask'; + } + return 'allow'; }, []); - const overrides = React.useMemo(() => { - const entries = Object.entries(permissionEntries).filter(([name]) => name !== '*'); - entries.sort(([a], [b]) => a.localeCompare(b)); - return entries; - }, [permissionEntries]); + const getPermissionSummary = React.useCallback((permissionName: string) => { + const defaultAction = permissionName === '*' + ? globalPermission + : (getWildcardOverride(permissionName) ?? globalPermission); + const patternRules = getPatternRules(permissionName); + const hasDefaultHint = false; + const patternCounts = patternRules.reduce>((acc, rule) => { + acc[rule.action] = (acc[rule.action] ?? 0) + 1; + return acc; + }, { allow: 0, ask: 0, deny: 0 }); + const patternSummary = (['allow', 'ask', 'deny'] as const) + .filter((action) => patternCounts[action] > 0) + .map((action) => `${patternCounts[action]} ${action}`) + .join(', '); + return { + defaultAction, + patternRulesCount: patternRules.length, + patternSummary, + hasDefaultHint, + }; + }, [getPatternRules, getWildcardOverride, globalPermission]); - const availableOverrideNames = React.useMemo(() => { + const availablePermissionNames = React.useMemo(() => { const names = new Set(); for (const key of STANDARD_PERMISSION_KEYS) { @@ -419,37 +387,68 @@ export const AgentsPage: React.FC = () => { for (const key of knownPermissionNames) { names.add(key); } - - for (const key of Object.keys(permissionEntries)) { - names.delete(key); - } - return Array.from(names).sort((a, b) => a.localeCompare(b)); - }, [knownPermissionNames, permissionEntries]); + }, [knownPermissionNames]); - const applyPendingOverride = React.useCallback((action: PermissionAction) => { - const name = pendingOverrideName.trim(); + const upsertRule = React.useCallback((permissionName: string, pattern: string, action: PermissionAction) => { + setPermissionRules((prev) => { + const map = buildRuleMap(prev); + map.set(buildRuleKey(permissionName, pattern), { permission: permissionName, pattern, action }); + return Array.from(map.values()); + }); + }, []); + + const removeRule = React.useCallback((permissionName: string, pattern: string) => { + setPermissionRules((prev) => { + const map = buildRuleMap(prev); + map.delete(buildRuleKey(permissionName, pattern)); + return Array.from(map.values()); + }); + }, []); + + const revertRule = React.useCallback((permissionName: string, pattern: string) => { + const baseline = baselineRuleMap.get(buildRuleKey(permissionName, pattern)); + if (baseline) { + upsertRule(permissionName, pattern, baseline.action); + return; + } + removeRule(permissionName, pattern); + }, [baselineRuleMap, removeRule, upsertRule]); + + const setRuleAction = React.useCallback((permissionName: string, pattern: string, action: PermissionAction) => { + upsertRule(permissionName, pattern, action); + }, [upsertRule]); + + const setGlobalPermissionAndPrune = React.useCallback((next: PermissionAction) => { + setGlobalPermission(next); + setPermissionRules((prev) => prev.filter((rule) => !(rule.pattern === '*' && rule.action === next))); + }, []); + + const applyPendingRule = React.useCallback((action: PermissionAction) => { + const name = pendingRuleName.trim(); if (!name) { + toast.error('Permission name is required'); return; } - const baseDefaultAction = getAgentBaseDefaultActionForPermission(name, defaultOverride); - - if (action === baseDefaultAction) { - const basis = defaultOverride === 'default' - ? 'OpenCode defaults' - : `Default Permissions ("${defaultOverride}")`; - - toast.message(`"${name}" already matches ${basis} (${baseDefaultAction}).`); - setPendingOverrideName(''); + const pattern = pendingRulePattern.trim() || '*'; + if (name === '*' && pattern === '*') { + setGlobalPermissionAndPrune(action); + setPendingRuleName(''); + setPendingRulePattern('*'); return; } - - setOverrideAction(name, action); - setPendingOverrideName(''); - }, [pendingOverrideName, setOverrideAction, defaultOverride]); + if (pattern === '*' && name !== '*' && action === globalPermission) { + removeRule(name, '*'); + } else { + upsertRule(name, pattern, action); + } + setPendingRuleName(''); + setPendingRulePattern('*'); + }, [globalPermission, pendingRuleName, pendingRulePattern, removeRule, setGlobalPermissionAndPrune, upsertRule]); const formatPermissionLabel = React.useCallback((permissionName: string): string => { + if (permissionName === '*') return 'Default'; if (permissionName === 'webfetch') return 'WebFetch'; if (permissionName === 'websearch') return 'WebSearch'; if (permissionName === 'codesearch') return 'CodeSearch'; @@ -466,51 +465,115 @@ export const AgentsPage: React.FC = () => { }, []); React.useEffect(() => { - setPendingOverrideName(''); + setPendingRuleName(''); + setPendingRulePattern('*'); - const applyPermissionState = (entries: Record) => { - const split = splitDefaultOverrideFromEntries(entries); - setDefaultOverride(split.defaultOverride); - setPermissionEntries(pruneRedundantPermissionOverrides(split.overrides, split.defaultOverride)); + const applyPermissionState = (rules: PermissionRule[]) => { + const normalized = normalizeRuleset(rules); + const nextGlobal = getGlobalWildcardAction(normalized); + const filtered = filterRulesAgainstGlobal(normalized, nextGlobal); + setGlobalPermission(nextGlobal); + setPermissionBaseline(filtered); + setPermissionRules(filtered); + return { global: nextGlobal, rules: filtered }; }; if (isNewAgent && agentDraft) { - setDraftName(agentDraft.name || ''); - setDraftScope(agentDraft.scope || 'user'); - setDescription(agentDraft.description || ''); - setMode(agentDraft.mode || 'subagent'); - setModel(agentDraft.model || ''); - setTemperature(agentDraft.temperature); - setTopP(agentDraft.top_p); - setPrompt(agentDraft.prompt || ''); + const draftNameValue = agentDraft.name || ''; + const draftScopeValue = agentDraft.scope || 'user'; + const descriptionValue = agentDraft.description || ''; + const modeValue = agentDraft.mode || 'subagent'; + const modelValue = agentDraft.model || ''; + const temperatureValue = agentDraft.temperature; + const topPValue = agentDraft.top_p; + const promptValue = agentDraft.prompt || ''; - const parsed = parsePermissionConfig(agentDraft.permission); - applyPermissionState(parsed.entries); + setDraftName(draftNameValue); + setDraftScope(draftScopeValue); + setDescription(descriptionValue); + setMode(modeValue); + setModel(modelValue); + setTemperature(temperatureValue); + setTopP(topPValue); + setPrompt(promptValue); + + const parsedRules = permissionConfigToRuleset(agentDraft.permission); + const permissionState = applyPermissionState(parsedRules); + + initialStateRef.current = { + draftName: draftNameValue, + draftScope: draftScopeValue, + description: descriptionValue, + mode: modeValue, + model: modelValue, + temperature: temperatureValue, + topP: topPValue, + prompt: promptValue, + globalPermission: permissionState.global, + permissionRules: permissionState.rules, + }; return; } if (selectedAgent && selectedAgentName === selectedAgent.name) { - setDescription(selectedAgent.description || ''); - setMode(selectedAgent.mode || 'subagent'); + const descriptionValue = selectedAgent.description || ''; + const modeValue = selectedAgent.mode || 'subagent'; + const modelValue = selectedAgent.model?.providerID && selectedAgent.model?.modelID + ? `${selectedAgent.model.providerID}/${selectedAgent.model.modelID}` + : ''; + const temperatureValue = selectedAgent.temperature; + const topPValue = selectedAgent.topP; + const promptValue = selectedAgent.prompt || ''; - if (selectedAgent.model?.providerID && selectedAgent.model?.modelID) { - setModel(`${selectedAgent.model.providerID}/${selectedAgent.model.modelID}`); - } else { - setModel(''); - } + setDescription(descriptionValue); + setMode(modeValue); - setTemperature(selectedAgent.temperature); - setTopP(selectedAgent.topP); - setPrompt(selectedAgent.prompt || ''); + setModel(modelValue); + setTemperature(temperatureValue); + setTopP(topPValue); + setPrompt(promptValue); - const parsed = rulesetToPermissionConfig(selectedAgent.permission); - applyPermissionState(parsed.entries); + const permissionState = applyPermissionState( + Array.isArray(selectedAgent.permission) ? selectedAgent.permission as PermissionRule[] : [], + ); + + initialStateRef.current = { + draftName: '', + draftScope: 'user', + description: descriptionValue, + mode: modeValue, + model: modelValue, + temperature: temperatureValue, + topP: topPValue, + prompt: promptValue, + globalPermission: permissionState.global, + permissionRules: permissionState.rules, + }; } }, [agentDraft, isNewAgent, selectedAgent, selectedAgentName]); - // Note: We no longer prune overrides when defaultOverride changes. - // This preserves user's explicit overrides until they save. - // Pruning only happens in handleSave to avoid losing overrides during editing. + const isDirty = React.useMemo(() => { + const initial = initialStateRef.current; + if (!initial) { + return false; + } + + if (isNewAgent) { + if (draftName !== initial.draftName) return true; + if (draftScope !== initial.draftScope) return true; + } + + if (description !== initial.description) return true; + if (mode !== initial.mode) return true; + if (model !== initial.model) return true; + if (temperature !== initial.temperature) return true; + if (topP !== initial.topP) return true; + if (prompt !== initial.prompt) return true; + if (globalPermission !== initial.globalPermission) return true; + if (!areRulesEqual(permissionRules, initial.permissionRules)) return true; + + return false; + }, [description, draftName, draftScope, globalPermission, isNewAgent, mode, model, permissionRules, prompt, temperature, topP]); const handleSave = async () => { const agentName = isNewAgent ? draftName.trim().replace(/\s+/g, '-') : selectedAgentName?.trim(); @@ -530,6 +593,7 @@ export const AgentsPage: React.FC = () => { try { const trimmedModel = model.trim(); + const permissionConfig = buildPermissionConfigWithGlobal(globalPermission, permissionRules); const config: AgentConfig = { name: agentName, description: description.trim() || undefined, @@ -538,36 +602,7 @@ export const AgentsPage: React.FC = () => { temperature, top_p: topP, prompt: prompt.trim() || undefined, - permission: (() => { - const overrides = pruneRedundantPermissionOverrides(permissionEntries, defaultOverride); - const combined: Record = defaultOverride === 'default' - ? overrides - : { '*': defaultOverride, ...overrides }; - - // Always explicitly include doom_loop and external_directory - // These have special OpenCode defaults ("ask") that differ from the general default ("allow") - const specialPermissions = ['doom_loop', 'external_directory'] as const; - for (const perm of specialPermissions) { - if (!(perm in combined)) { - // Use explicit override if set, otherwise derive from effective default - const explicit = permissionEntries[perm]; - if (isPermissionAction(explicit)) { - combined[perm] = explicit; - } else { - combined[perm] = getAgentBaseDefaultActionForPermission(perm, defaultOverride); - } - } - } - - const keys = Object.keys(combined); - - if (keys.length === 0) { - return isNewAgent ? undefined : null; - } - - // Don't simplify to single action - always use object form when we have special permissions - return combined as unknown as AgentConfig['permission']; - })(), + permission: permissionConfig, scope: isNewAgent ? draftScope : undefined, }; @@ -919,94 +954,233 @@ export const AgentsPage: React.FC = () => { {}
-

Permissions

+
+

Permissions

+ +

- This editor only updates wildcard ("*") rules; existing pattern rules are preserved. + {showPermissionEditor + ? 'Set a global default; tools only saved when different from global.' + : 'Summary shows default actions; edit to manage granular rules.'}

-
-
-

Default Permissions

-

- Set the default behavior for all permissions for this agent. -

- -
- - - - -
- -
-

- {defaultOverride === 'default' - ? 'Uses OpenCode defaults unless overridden below.' - : 'Applies to any permission without an explicit override below.'} -

- - - - - -
-

Default: Follow OpenCode default behavior

-

Allow: Run without confirmation

-

Ask: Prompt for confirmation

-

Deny: Block the operation

+ {!showPermissionEditor ? ( +
+ {summaryPermissionNames.map((permissionName) => { + const { defaultAction, patternRulesCount, patternSummary, hasDefaultHint } = getPermissionSummary(permissionName); + const label = formatPermissionLabel(permissionName); + const summary = hasDefaultHint + ? `${defaultAction} (env blocked)` + : defaultAction; + return ( +
+
+ {label} + {permissionName}
- - -
-
- -
-

Overrides

-

- Add overrides for permissions that should behave differently for this agent. -

- -
-
- setGlobalPermissionAndPrune(value as PermissionAction)} + > + + {globalPermission} + + + Allow + Ask + Deny + + +
+
+ +
+ {summaryPermissionNames.filter((name) => name !== '*').map((permissionName) => { + const label = formatPermissionLabel(permissionName); + const { defaultAction, patternRulesCount, patternSummary } = getPermissionSummary(permissionName); + const wildcardOverride = getWildcardOverride(permissionName); + const wildcardValue: string = wildcardOverride ?? 'global'; + const patternRules = getPatternRules(permissionName); + + const wildcardOptions = (['allow', 'ask', 'deny'] as const).filter((action) => action !== globalPermission); + + return ( +
+
+
+ {label} + {permissionName} +
+
+ {patternRulesCount > 0 ? ( + Global: {defaultAction} + ) : ( + {defaultAction} + )} + {patternRulesCount > 0 ? ( + Patterns: {patternSummary} + ) : null} +
+
+ +
+
+
+ Pattern + * + {wildcardOverride ? ( + + ) : null} +
+ + +
+ + {patternRules.map((rule) => { + const ruleKey = buildRuleKey(rule.permission, rule.pattern); + const baselineRule = baselineRuleMap.get(ruleKey); + const isAdded = !baselineRule; + const isModified = Boolean(baselineRule && baselineRule.action !== rule.action); + + return ( +
+
+ Pattern + {rule.pattern} + {isAdded ? ( + New + ) : null} + {isModified ? ( + Modified + ) : null} + {isAdded ? ( + + ) : isModified ? ( + + ) : null} +
+ + +
+ ); + })} +
+
+ ); + })} +
+ +
+

Add Rule

+

+ Choose a permission key, set a pattern, and pick an action. +

+ +
+ + setPendingRulePattern(e.target.value)} + placeholder="Pattern (e.g. *)" + className="h-8 sm:w-64 font-mono" + /> +
- -
- - {overrides.length === 0 ? ( -

- {defaultOverride === 'default' - ? 'No overrides configured. Everything follows OpenCode defaults.' - : `No overrides configured. All permissions default to "${defaultOverride}" for this agent.`} -

- ) : ( -
- {overrides.map(([permissionName]) => { - const wildcardAction = getOverrideWildcardAction(permissionName); - const customPatternCount = getCustomPatternCount(permissionName); - const label = formatPermissionLabel(permissionName); - - return ( -
-
- - - -
- -
- - - -
- -
- ); - })} -
- )}
-
+ )} {}
@@ -1143,7 +1235,7 @@ export const AgentsPage: React.FC = () => { size="sm" variant="default" onClick={handleSave} - disabled={isSaving} + disabled={isSaving || !isDirty} className="gap-2 h-6 px-2 text-xs w-fit" > diff --git a/packages/ui/src/components/sections/agents/AgentsSidebar.tsx b/packages/ui/src/components/sections/agents/AgentsSidebar.tsx index 42c4867d..f9b8ef0d 100644 --- a/packages/ui/src/components/sections/agents/AgentsSidebar.tsx +++ b/packages/ui/src/components/sections/agents/AgentsSidebar.tsx @@ -35,14 +35,6 @@ type PermissionRule = { permission: string; pattern: string; action: PermissionA type PermissionConfigValue = PermissionAction | Record; -// OpenCode's built-in defaults for permissions that differ from "allow" -const getOpenCodeDefaultActionForPermission = (permissionName: string): PermissionAction => { - if (permissionName === 'doom_loop' || permissionName === 'external_directory') { - return 'ask'; - } - return 'allow'; -}; - const toPermissionRuleset = (ruleset: unknown): PermissionRule[] => { if (!Array.isArray(ruleset)) { return []; @@ -66,8 +58,22 @@ const toPermissionRuleset = (ruleset: unknown): PermissionRule[] => { return parsed; }; +const normalizeRuleset = (ruleset: PermissionRule[]): PermissionRule[] => { + const map = new Map(); + for (const rule of ruleset) { + if (!rule.permission || rule.permission === 'invalid') { + continue; + } + if (!rule.pattern) { + continue; + } + map.set(`${rule.permission}::${rule.pattern}`, rule); + } + return Array.from(map.values()); +}; + const rulesetToPermissionConfig = (ruleset: unknown): AgentDraft['permission'] => { - const parsed = toPermissionRuleset(ruleset); + const parsed = normalizeRuleset(toPermissionRuleset(ruleset)); if (parsed.length === 0) { return undefined; } @@ -80,48 +86,14 @@ const rulesetToPermissionConfig = (ruleset: unknown): AgentDraft['permission'] = (byPermission[rule.permission] ||= {})[rule.pattern] = rule.action; } - // Get the global default (wildcard * with pattern *) - const globalDefault = byPermission['*']?.['*']; - - const permissionNames = Object.keys(byPermission); - if ( - permissionNames.length === 1 && - permissionNames[0] === '*' && - Object.keys(byPermission['*'] || {}).length === 1 && - byPermission['*']?.['*'] - ) { - return byPermission['*']['*']; - } - const result: Record = {}; - for (const permissionName of permissionNames) { - const map = byPermission[permissionName]; + for (const [permissionName, map] of Object.entries(byPermission)) { const patterns = Object.keys(map); - - // For wildcard-only entries, check if they're redundant - if (patterns.length === 1 && patterns[0] === '*' && permissionName !== '*') { - const action = map['*']; - const opencodeDefault = getOpenCodeDefaultActionForPermission(permissionName); - - // Skip if this permission is redundant (matches effective default) - if (globalDefault) { - if (action === globalDefault) continue; - } else { - if (action === opencodeDefault) continue; - } - - result[permissionName] = action; - } else if (permissionName === '*') { - // Include global default - if (patterns.length === 1 && patterns[0] === '*') { - result[permissionName] = map['*']; - } else { - result[permissionName] = map; - } - } else { - // Non-wildcard patterns - include as-is - result[permissionName] = map; + if (patterns.length === 1 && patterns[0] === '*') { + result[permissionName] = map['*']; + continue; } + result[permissionName] = map; } return Object.keys(result).length > 0 ? (result as AgentDraft['permission']) : undefined; diff --git a/packages/ui/src/components/sections/commands/CommandsPage.tsx b/packages/ui/src/components/sections/commands/CommandsPage.tsx index 0dfe8432..15ce46c4 100644 --- a/packages/ui/src/components/sections/commands/CommandsPage.tsx +++ b/packages/ui/src/components/sections/commands/CommandsPage.tsx @@ -31,26 +31,89 @@ export const CommandsPage: React.FC = () => { const [template, setTemplate] = React.useState(''); const [subtask, setSubtask] = React.useState(false); const [isSaving, setIsSaving] = React.useState(false); + const initialStateRef = React.useRef<{ + draftName: string; + draftScope: CommandScope; + description: string; + agent: string; + model: string; + template: string; + subtask: boolean; + } | null>(null); React.useEffect(() => { if (isNewCommand && commandDraft) { // Prefill from draft (for new or duplicated commands) - setDraftName(commandDraft.name || ''); - setDraftScope(commandDraft.scope || 'user'); - setDescription(commandDraft.description || ''); - setAgent(commandDraft.agent || ''); - setModel(commandDraft.model || ''); - setTemplate(commandDraft.template || ''); - setSubtask(commandDraft.subtask || false); + const draftNameValue = commandDraft.name || ''; + const draftScopeValue = commandDraft.scope || 'user'; + const descriptionValue = commandDraft.description || ''; + const agentValue = commandDraft.agent || ''; + const modelValue = commandDraft.model || ''; + const templateValue = commandDraft.template || ''; + const subtaskValue = commandDraft.subtask || false; + + setDraftName(draftNameValue); + setDraftScope(draftScopeValue); + setDescription(descriptionValue); + setAgent(agentValue); + setModel(modelValue); + setTemplate(templateValue); + setSubtask(subtaskValue); + + initialStateRef.current = { + draftName: draftNameValue, + draftScope: draftScopeValue, + description: descriptionValue, + agent: agentValue, + model: modelValue, + template: templateValue, + subtask: subtaskValue, + }; } else if (selectedCommand) { - setDescription(selectedCommand.description || ''); - setAgent(selectedCommand.agent || ''); - setModel(selectedCommand.model || ''); - setTemplate(selectedCommand.template || ''); - setSubtask(selectedCommand.subtask || false); + const descriptionValue = selectedCommand.description || ''; + const agentValue = selectedCommand.agent || ''; + const modelValue = selectedCommand.model || ''; + const templateValue = selectedCommand.template || ''; + const subtaskValue = selectedCommand.subtask || false; + + setDescription(descriptionValue); + setAgent(agentValue); + setModel(modelValue); + setTemplate(templateValue); + setSubtask(subtaskValue); + + initialStateRef.current = { + draftName: '', + draftScope: 'user', + description: descriptionValue, + agent: agentValue, + model: modelValue, + template: templateValue, + subtask: subtaskValue, + }; } }, [selectedCommand, isNewCommand, selectedCommandName, commands, commandDraft]); + const isDirty = React.useMemo(() => { + const initial = initialStateRef.current; + if (!initial) { + return false; + } + + if (isNewCommand) { + if (draftName !== initial.draftName) return true; + if (draftScope !== initial.draftScope) return true; + } + + if (description !== initial.description) return true; + if (agent !== initial.agent) return true; + if (model !== initial.model) return true; + if (template !== initial.template) return true; + if (subtask !== initial.subtask) return true; + + return false; + }, [agent, description, draftName, draftScope, isNewCommand, model, subtask, template]); + const handleSave = async () => { const commandName = isNewCommand ? draftName.trim().replace(/\s+/g, '-') : selectedCommandName?.trim(); @@ -352,7 +415,7 @@ Use @filename to include file contents.`} size="sm" variant="default" onClick={handleSave} - disabled={isSaving} + disabled={isSaving || !isDirty} className="gap-2 h-6 px-2 text-xs w-fit" > diff --git a/packages/ui/src/lib/permissions/editModeColors.ts b/packages/ui/src/lib/permissions/editModeColors.ts index 3f7bfd53..3206d971 100644 --- a/packages/ui/src/lib/permissions/editModeColors.ts +++ b/packages/ui/src/lib/permissions/editModeColors.ts @@ -11,17 +11,17 @@ export const getEditModeColors = (mode?: EditPermissionMode | null): EditModeCol if (mode === 'full') { return { text: 'var(--status-info)', - border: 'var(--status-info-border)', - background: 'var(--status-info-background)', + border: 'color-mix(in srgb, var(--status-info) 25%, transparent)', + background: 'color-mix(in srgb, var(--status-info) 4%, transparent)', borderWidth: 1.5, }; } if (mode === 'allow') { return { - text: 'var(--status-success)', - border: 'var(--status-success-border)', - background: 'var(--status-success-background)', + text: 'var(--status-info)', + border: 'color-mix(in srgb, var(--status-info) 25%, transparent)', + background: 'color-mix(in srgb, var(--status-info) 4%, transparent)', borderWidth: 1.5, }; } diff --git a/packages/ui/src/stores/contextStore.ts b/packages/ui/src/stores/contextStore.ts index 52dc120d..0e01f05e 100644 --- a/packages/ui/src/stores/contextStore.ts +++ b/packages/ui/src/stores/contextStore.ts @@ -69,6 +69,7 @@ interface ContextActions { type ContextStore = ContextState & ContextActions; const EDIT_PERMISSION_SEQUENCE: EditPermissionMode[] = ['ask', 'allow', 'full']; +const GLOBAL_EDIT_MODE_SESSION_ID = '__global__'; export const useContextStore = create()( devtools( @@ -542,7 +543,20 @@ export const useContextStore = create()( const sessionMap = get().sessionAgentEditModes.get(sessionId); const override = sessionMap?.get(agentName); - return override ?? defaultMode; + if (override !== undefined) { + return override; + } + + // Fallback: global (applies to all sessions) + if (sessionId !== GLOBAL_EDIT_MODE_SESSION_ID) { + const globalMap = get().sessionAgentEditModes.get(GLOBAL_EDIT_MODE_SESSION_ID); + const globalOverride = globalMap?.get(agentName); + if (globalOverride !== undefined) { + return globalOverride; + } + } + + return defaultMode; }, setSessionAgentEditMode: (sessionId: string, agentName: string | undefined, mode: EditPermissionMode, defaultMode: EditPermissionMode = getAgentDefaultEditPermission(agentName)) => { @@ -598,6 +612,7 @@ export const useContextStore = create()( get().setSessionAgentEditMode(sessionId, agentName, nextMode, normalizedDefault); }, + }), { name: "context-store", diff --git a/packages/ui/src/stores/permissionStore.ts b/packages/ui/src/stores/permissionStore.ts index 8bad9db2..1d7a5a5b 100644 --- a/packages/ui/src/stores/permissionStore.ts +++ b/packages/ui/src/stores/permissionStore.ts @@ -88,8 +88,8 @@ export const usePermissionStore = create()( const defaultMode = getAgentDefaultEditPermission(agentName); const effectiveMode = contextData?.getSessionAgentEditMode?.(sessionId, agentName) ?? defaultMode; - const shouldAutoApprove = effectiveMode === 'full' - || (effectiveMode === 'allow' && isEditPermissionType(permissionType)); + const shouldAutoApprove = (effectiveMode === 'allow' || effectiveMode === 'full') + && isEditPermissionType(permissionType); if (shouldAutoApprove) { get().respondToPermission(sessionId, permission.id, 'once').catch(() => {