({
newState,
oldState,
}: {
newState: AppState
oldState: AppState
})
| 41 | } |
| 42 | |
| 43 | export function onChangeAppState({ |
| 44 | newState, |
| 45 | oldState, |
| 46 | }: { |
| 47 | newState: AppState |
| 48 | oldState: AppState |
| 49 | }) { |
| 50 | // toolPermissionContext.mode — single choke point for CCR/SDK mode sync. |
| 51 | // |
| 52 | // Prior to this block, mode changes were relayed to CCR by only 2 of 8+ |
| 53 | // mutation paths: a bespoke setAppState wrapper in print.ts (headless/SDK |
| 54 | // mode only) and a manual notify in the set_permission_mode handler. |
| 55 | // Every other path — Shift+Tab cycling, ExitPlanModePermissionRequest |
| 56 | // dialog options, the /plan slash command, rewind, the REPL bridge's |
| 57 | // onSetPermissionMode — mutated AppState without telling |
| 58 | // CCR, leaving external_metadata.permission_mode stale and the web UI out |
| 59 | // of sync with the CLI's actual mode. |
| 60 | // |
| 61 | // Hooking the diff here means ANY setAppState call that changes the mode |
| 62 | // notifies CCR (via notifySessionMetadataChanged → ccrClient.reportMetadata) |
| 63 | // and the SDK status stream (via notifyPermissionModeChanged → registered |
| 64 | // in print.ts). The scattered callsites above need zero changes. |
| 65 | const prevMode = oldState.toolPermissionContext.mode |
| 66 | const newMode = newState.toolPermissionContext.mode |
| 67 | if (prevMode !== newMode) { |
| 68 | // CCR external_metadata must not receive internal-only mode names |
| 69 | // (bubble, ungated auto). Externalize first — and skip |
| 70 | // the CCR notify if the EXTERNAL mode didn't change (e.g., |
| 71 | // default→bubble→default is noise from CCR's POV since both |
| 72 | // externalize to 'default'). The SDK channel (notifyPermissionModeChanged) |
| 73 | // passes raw mode; its listener in print.ts applies its own filter. |
| 74 | const prevExternal = toExternalPermissionMode(prevMode) |
| 75 | const newExternal = toExternalPermissionMode(newMode) |
| 76 | if (prevExternal !== newExternal) { |
| 77 | // Ultraplan = first plan cycle only. The initial control_request |
| 78 | // sets mode and isUltraplanMode atomically, so the flag's |
| 79 | // transition gates it. null per RFC 7396 (removes the key). |
| 80 | const isUltraplan = |
| 81 | newExternal === 'plan' && |
| 82 | newState.isUltraplanMode && |
| 83 | !oldState.isUltraplanMode |
| 84 | ? true |
| 85 | : null |
| 86 | notifySessionMetadataChanged({ |
| 87 | permission_mode: newExternal, |
| 88 | is_ultraplan_mode: isUltraplan, |
| 89 | }) |
| 90 | } |
| 91 | notifyPermissionModeChanged(newMode) |
| 92 | } |
| 93 | |
| 94 | // mainLoopModel: remove it from settings? |
| 95 | if ( |
| 96 | newState.mainLoopModel !== oldState.mainLoopModel && |
| 97 | newState.mainLoopModel === null |
| 98 | ) { |
| 99 | // Remove from settings |
| 100 | updateSettingsForSource('userSettings', { model: undefined }) |
nothing calls this directly
no test coverage detected