diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv new file mode 100644 index 000000000000..c29663f808a2 --- /dev/null +++ b/.github/swift-file-length-budget.tsv @@ -0,0 +1,418 @@ +# cmux-owned Swift file length budget. +# Format: max_linesrelative path +# Reduce counts as files shrink. CI fails if tracked files exceed this budget. +35819 CLI/cmux.swift +17948 Sources/AppDelegate.swift +17014 Sources/ContentView.swift +15297 Sources/TerminalController.swift +12530 Sources/Workspace.swift +12345 Sources/GhosttyTerminalView.swift +12327 cmuxTests/AppDelegateShortcutRoutingTests.swift +11567 Sources/Panels/BrowserPanel.swift +9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +8595 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +7955 CLI/cmux_open.swift +7849 Sources/Panels/BrowserPanelView.swift +7678 cmuxTests/WorkspaceUnitTests.swift +7305 cmuxTests/WorkspaceRemoteConnectionTests.swift +6425 cmuxTests/SessionPersistenceTests.swift +6374 cmuxTests/TerminalAndGhosttyTests.swift +6292 Sources/TabManager.swift +6264 cmuxTests/GhosttyConfigTests.swift +5758 Sources/TextBoxInput.swift +5713 cmuxTests/BrowserConfigTests.swift +4989 cmuxTests/WorkspaceForkConversationContextMenuTests.swift +4724 Sources/cmuxApp.swift +4424 Sources/Panels/FilePreviewPanel.swift +4386 cmuxTests/BrowserPanelTests.swift +4095 Sources/BrowserWindowPortal.swift +4007 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +4004 cmuxTests/TabManagerUnitTests.swift +3961 cmuxTests/WindowAndDragTests.swift +3934 Sources/Feed/FeedPanelView.swift +3855 cmuxTests/TabManagerSessionSnapshotTests.swift +3664 cmuxTests/CLIGenericHookPersistenceTests.swift +3288 Sources/CmuxConfig.swift +3130 cmuxTests/CMUXOpenCommandTests.swift +2882 Sources/Update/UpdateTitlebarAccessory.swift +2759 Sources/TerminalWindowPortal.swift +2705 Sources/SessionIndexView.swift +2638 Sources/Mobile/MobileHostService.swift +2569 Sources/Panels/CmuxWebView.swift +2546 cmuxTests/WorkspaceManualUnreadTests.swift +2524 cmuxTests/CommandPaletteSearchEngineTests.swift +2452 Sources/KeyboardShortcutSettings.swift +2391 Sources/TerminalNotificationStore.swift +2337 cmuxTests/CJKIMEInputTests.swift +2266 Sources/RestorableAgentSession.swift +2168 cmuxTests/ShortcutAndCommandPaletteTests.swift +2126 cmuxTests/CmuxConfigTests.swift +2029 Sources/SharedLiveAgentIndex.swift +1938 cmuxTests/TerminalControllerSocketSecurityTests.swift +1918 cmuxTests/NotificationAndMenuBarTests.swift +1909 Sources/KeyboardShortcutSettingsFileStore.swift +1909 Sources/SessionPersistence.swift +1866 Sources/Panels/BrowserWebAuthnSupport.swift +1822 cmuxTests/RestorableAgentSessionIndexTests.swift +1805 Sources/SessionIndexStore.swift +1760 Sources/WindowDragHandleView.swift +1748 cmuxTests/WorkspacePullRequestSidebarTests.swift +1702 cmuxTests/RemoteTmuxBonsplitImpositionRenderTests.swift +1690 cmuxTests/CMUXCLIErrorOutputRegressionTests.swift +1687 cmuxTests/MarkdownPanelTests.swift +1680 cmuxUITests/BrowserPaneNavigationKeybindUITests.swift +1665 cmuxTests/RemoteTmuxPaneSeedTransportTests.swift +1656 Sources/FileExplorerView.swift +1593 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift +1560 cmuxTests/TextBoxMentionCompletionTests.swift +1506 Sources/Feed/FeedCoordinator.swift +1499 cmuxTests/OmnibarAndToolsTests.swift +1498 cmuxUITests/MultiWindowNotificationsUITests.swift +1468 cmuxTests/AgentSessionAutoResumeSwiftTests.swift +1462 Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift +1420 cmuxTests/AppDelegateIssue2907RoutingTests.swift +1388 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift +1387 cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift +1382 Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift +1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift +1363 Sources/CMUXInstalledExtensionSidebarHostView.swift +1360 Sources/Feed/FeedButtonStyleDebugWindowController.swift +1352 Sources/Panels/BrowserScreenshotSnapshotter.swift +1332 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Config/GhosttyConfig.swift +1290 cmuxTests/TextBoxSubmitActionTests.swift +1286 Sources/FileExplorerStore.swift +1285 cmuxUITests/SidebarHelpMenuUITests.swift +1262 Sources/AgentForkSupport.swift +1240 cmuxTests/SidebarOrderingTests.swift +1237 cmuxTests/NotificationFeedHistoryTests.swift +1233 cmuxTests/RemoteTmuxMirrorFeedForwardTests.swift +1209 Packages/macOS/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteSearchEngineTests.swift +1208 Sources/Panels/AgentSessionWebRendererCoordinator.swift +1204 cmuxTests/FileExplorerStoreTests.swift +1199 cmuxTests/CodexAppServerSessionTests.swift +1197 cmuxTests/VMDefaultCloudCommandTests.swift +1190 cmuxTests/MobileHostAuthorizationTests.swift +1185 cmuxTests/RemoteTmuxNativeMirrorLayoutFuzzTests.swift +1172 cmuxTests/RemoteResumeBindingTests.swift +1151 Packages/macOS/CmuxSimulator/Sources/CmuxSimulator/Control/SimulatorControlService+Location.swift +1147 cmuxTests/PiVaultAgentPersistenceTests.swift +1137 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupTests.swift +1137 Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorControlServiceTests.swift +1130 cmuxTests/AgentHibernationTests.swift +1112 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift +1093 cmuxUITests/BonsplitTabDragUITests.swift +1087 Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift +1071 cmuxTests/WorkspaceGroupTests.swift +1043 Sources/AppDelegate+CmuxSSHURL.swift +1042 Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceMacSelectionTests.swift +1032 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalViewportResyncTests.swift +1029 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionRecovery.swift +1028 Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/NativeConversationTranscriptUIKitTests.swift +1024 Sources/RemoteTmuxControlConnection.swift +1021 cmuxUITests/TerminalCmdClickUITests.swift +1016 cmuxTests/AgentGUIJournalPipelineTests.swift +1009 cmuxTests/CmuxTopSnapshotScopeTests.swift +1006 cmuxTests/CmuxSSHURLRequestTests.swift +1001 Sources/App/ShortcutRoutingSupport.swift +997 cmuxTests/DockSocketLifecycleTests.swift +992 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift +988 Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore.swift +982 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift +959 Sources/Panels/BrowserPopupWindowController.swift +951 Sources/App/TerminalDirectoryOpenSupport.swift +949 Sources/Panels/TerminalPanel.swift +947 Sources/TerminalNotificationPolicy.swift +944 Sources/CommandPalette/CommandPaletteSettingsToggle.swift +943 Sources/Cloud/VMClient.swift +937 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionPoolTests.swift +932 Sources/SessionIndexRegisteredAgents.swift +925 cmuxTests/FeedCoordinatorTests.swift +915 cmuxTests/SSHStartupSignalLifecycleTests.swift +909 cmuxTests/MobileHostIrohAdmissionTests.swift +906 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputDeliveryQueueTests.swift +905 Sources/CmuxSSHURLRequest.swift +899 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeHideMacTests.swift +899 Sources/Panels/MarkdownWebRenderer.swift +896 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift +889 Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/CodexTranscriptDecoder.swift +885 cmuxTests/SidebarWorkspaceDropPlannerTests.swift +881 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +879 Sources/WorkspaceContentView.swift +879 cmuxTests/SimulatorPanelIntegrationTests.swift +871 Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCommands.swift +871 cmuxTests/ClaudeHookSurfaceResolutionSwiftTests.swift +870 cmuxTests/SidebarAppKitRowCellTests.swift +868 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift +867 cmuxTests/CLICodexHookTimeoutRegressionTests.swift +864 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift +862 Sources/VaultAgentProcessScanner.swift +858 Sources/PricingPlansScreen.swift +857 Sources/DockSplitStore.swift +856 Sources/TextBoxMentionIndexStore.swift +853 Sources/Debug/RemoteTmux/TerminalController+RemoteTmuxTestSupport.swift +849 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileShellComposite+IrohReleaseGate.swift +849 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerDirectoryPickerView.swift +847 cmuxTests/BrowserDesignModeScreenshotEvaluatorTests.swift +846 Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorWorkerClientCameraCleanupTests.swift +844 Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift +840 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift +834 Sources/MainWindowFocusController.swift +823 cmuxTests/AgentSessionAutoResumeSettingsTests.swift +810 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/SwiftViewInterpreterTests.swift +809 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListTableCoordinator.swift +804 Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTruthKitTests/TranscriptDecoderTests.swift +802 Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift +799 Sources/RemoteTmuxController.swift +797 Sources/TerminalController+ControlPaneContext.swift +795 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift +793 Sources/Mobile/MobileHostIrohApplicationLaneRouter.swift +793 Sources/Panels/BrowserDesignModePopover.swift +790 Packages/macOS/CmuxSimulator/Sources/CmuxSimulator/Mutations/SimulatorCameraAuthorizationStore.swift +786 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift +781 Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift +780 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore.swift +779 cmuxUITests/BrowserFixtureInteractionUITests.swift +779 cmuxUITests/BrowserOmnibarSuggestionsUITests.swift +773 Sources/App/MenuBarExtraController.swift +767 Sources/AgentGUI/AgentGUIJournalPipeline.swift +764 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationFeed.swift +759 Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorUITests/SimulatorPaneCoordinatorTests.swift +754 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift +754 cmuxTests/GhosttyTerminalStartupEnvironmentTests.swift +752 Sources/ClosedItemHistory.swift +752 cmuxUITests/CloseWorkspaceCmdDUITests.swift +750 Sources/RemoteTmuxWindowMirror+SizingTransaction.swift +746 Packages/iOS/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacStoreTests.swift +744 CLI/CMUXCLI+Simulator.swift +740 Sources/TerminalController+ControlWorkspaceContext.swift +739 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift +738 Packages/macOS/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift +736 Packages/macOS/CmuxSimulator/Sources/CmuxSimulatorUI/Coordinator/SimulatorPaneCoordinator+Lifecycle.swift +734 Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSimulatorTests.swift +731 cmuxTests/SessionPersistenceResumeBindingTests.swift +729 Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorUITests/SimulatorRemoteSurfaceLifecycleTests.swift +728 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohConnectionRecoveryOwnerTests.swift +726 Sources/RemoteTmuxWindowMirror+Bonsplit.swift +725 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift +723 Sources/Panels/AgentSessionProcessStore.swift +722 Sources/Panels/BrowserDesignModeController.swift +722 Sources/TaskManagerTypes.swift +721 Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Store/ConversationReplica.swift +718 Sources/Panels/BrowserNavigationDelegate.swift +716 Sources/TaskManagerSnapshot.swift +715 cmuxTests/MobileHostConnectionEventLaneTests.swift +714 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSessionPool.swift +714 Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession.swift +714 Sources/AppleScriptSupport.swift +713 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift +713 cmuxTests/DockPortalReconcileTests.swift +713 cmuxTests/UpdatePillReleaseVisibilityTests.swift +712 cmuxTests/KeyboardShortcutContextTests.swift +710 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift +710 Sources/TerminalSSHSessionDetector.swift +709 Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropResolver.swift +708 cmuxTests/ShellStartupMatrixTests.swift +706 CLI/CMUXCLI+Config.swift +706 cmuxTests/DockShortcutRoutingTests.swift +702 cmuxTests/TerminalWindowPortalEngineDivergenceTests.swift +699 cmuxTests/TerminalNotificationClearAllTests.swift +698 Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentSyncEngine.swift +698 cmuxTests/RestorableAgentHookProviderResumeTests.swift +696 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift +693 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePreviewTests.swift +693 Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorControlServiceLocationLifecycleTests.swift +693 Sources/ContentView+ForkAgentConversation.swift +691 Sources/NotificationSoundSettings.swift +691 cmuxTests/TaskManagerResourcesTests.swift +690 cmuxTests/SessionIndexViewTests.swift +684 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/NativeConversationTranscript+UIKit.swift +683 Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/SwiftViewInterpreter.swift +682 cmuxTests/FeedCoordinatorIngressTests.swift +681 Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/ClaudeTranscriptDecoder.swift +680 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift +680 Sources/FileExplorerSearchController.swift +680 Sources/PortScanner.swift +678 Sources/FeatureFlags.swift +677 Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceCoordinatorTests.swift +675 Sources/Panels/CodexAppServerSession.swift +672 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TaskComposer/TaskComposerSheet.swift +672 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift +666 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift +664 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeTests.swift +664 Sources/CmuxTopSnapshot.swift +663 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift +657 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift +650 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Import/Detection/BrowserInstalledBrowserDetector.swift +650 Sources/Panels/MarkdownRemoteImageLoader.swift +649 Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Tunnel/RemoteDaemonProxyTunnel.swift +648 cmuxTests/TerminalNotificationQueueTests.swift +647 Sources/Panels/WorkspaceTodoPanelView.swift +646 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderTests.swift +646 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift +644 Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift +644 Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift +642 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/RemoteTmux/Layout/RemoteTmuxNativeLayoutMetrics.swift +641 Sources/RemoteTmuxWindowMirror.swift +641 cmuxTests/CommandPaletteNucleoFFITests.swift +640 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileIrohSettingsView.swift +639 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/IrohNetworkingSection.swift +639 Sources/SettingsNavigation.swift +638 Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSupportViews.swift +636 Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/CodexHookInjectionStrippingTests.swift +635 cmuxUITests/RightSidebarChromeHeightUITests.swift +634 Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift +633 Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorWorkerTests/SimulatorCameraAdapterTests.swift +631 cmuxTests/ForkParentFallbackGeneralizationTests.swift +631 cmuxTests/WorkspaceCreateWorkingDirectoryTests.swift +630 Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGrid.swift +630 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift +630 Sources/RemoteTmuxSessionMirror.swift +628 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeHiddenMacRefreshTests.swift +627 cmuxTests/PostHogAnalyticsPropertiesTests.swift +626 Sources/NotificationFeedHistoryPersistence.swift +625 Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridReplay.swift +621 cmuxTests/CmuxEventBusTests.swift +620 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+Bootstrap.swift +620 cmuxTests/FinderFileDropRegressionTests.swift +619 Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/TranscriptProjectorTests.swift +616 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift +614 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlCommandCoordinator+Pane.swift +613 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohHostRuntime.swift +612 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift +611 cmuxUITests/AutomationSocketUITests.swift +608 Sources/SleepyFaceView.swift +608 Sources/TextBoxSubmitActions.swift +608 cmuxUITests/FeedSidebarUITests.swift +607 Sources/SessionIndexModels.swift +607 cmuxTests/BrowserDesignModeArtifactStoreTests.swift +604 Packages/macOS/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteNucleoFFITests.swift +601 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift +600 cmuxTests/WorkspaceRecoveryTests.swift +597 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohPrivatePathTransportGateTests.swift +596 cmuxTests/CLIHookNoResponseTests.swift +594 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohEndpointSupervisor.swift +594 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellNotificationFeedStateTests.swift +594 cmuxTests/PortalTabDragRoutingTests.swift +594 cmuxTests/RemoteTmuxRectPublicationTests.swift +593 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayCredentialCoordinatorTests.swift +592 Sources/Auth/AuthEnvironment.swift +591 Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/DefaultsValueModelLifecycleTests.swift +591 cmuxTests/CmuxConfigContextMenuTests.swift +588 Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowChecklistSection.swift +588 cmuxTests/CommandPaletteShortcutCustomizationTests.swift +587 cmuxTests/MobileWorkspaceListFidelityTests.swift +586 Sources/JSONCParser.swift +585 Packages/macOS/CmuxSimulator/Sources/CmuxSimulatorUI/Views/SimulatorRemoteSurfaceView.swift +585 cmuxTests/SettingsWindowPresenterTests.swift +583 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTrustBrokerClient.swift +583 Sources/Panels/BrowserAutomation.swift +582 Packages/macOS/CmuxSimulator/Sources/CmuxSimulatorWorker/Framebuffer/SimulatorFramebuffer.swift +582 cmuxTests/AppearanceSettingsTests.swift +581 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryTests.swift +581 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift +581 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift +581 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift +581 Sources/SidebarWorkspaceChecklistPopover.swift +580 Packages/macOS/CmuxExtensionKit/Tests/CmuxExtensionKitTests/CmuxExtensionKitTests.swift +580 cmuxTests/MobileHostConnectionLifecycleTests.swift +579 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientSessionTests.swift +578 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceActions.swift +577 CLI/CMUXCLI+AgentHookDefinitions.swift +577 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift +574 Sources/SidebarWorkspaceChecklistView.swift +574 Sources/TerminalController+ControlSimulatorOperations.swift +573 Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/DesignMode/BrowserDesignModePromptFormatterTests.swift +572 Sources/Feed/FeedTextEditorDebugWindowController.swift +570 Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticLogTests.swift +569 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskComposerSubmitTests.swift +569 Sources/TerminalController+MobileTerminalArtifacts.swift +567 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift +567 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigDiscovery/GhosttyConfigDiscovery.swift +567 Sources/AgentGUI/AgentGUIService.swift +565 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +564 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderPolicyTests.swift +564 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohTrustBrokerClientTests.swift +564 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalThemeRenderGridTests.swift +563 Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalRenderGridTests.swift +562 cmuxTests/AgentExecutableResolverTests.swift +561 cmuxTests/GhosttyConfigPathResolverTests.swift +560 cmuxTests/CLISSHPTYResizeInputTests.swift +560 cmuxTests/RemoteTmuxControlParserTests.swift +559 Sources/Mobile/MobileTerminalRenderObserver.swift +558 Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+Config.swift +556 Sources/KeyboardShortcutContext.swift +554 Sources/RightSidebarPanelView.swift +551 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntime.swift +551 Sources/Panels/DiffSidecarBridge.swift +550 Sources/CloudVMActionLauncher.swift +549 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlCommandCoordinator+SidebarMetadataV1.swift +547 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/IOSBuildScopedPairedMacStore.swift +546 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift +544 cmuxUITests/DisplayResolutionRegressionUITests.swift +543 cmuxTests/TabManagerTitleUpdateTests.swift +542 Packages/macOS/CmuxSimulator/Sources/CmuxSimulator/WorkerClient/SimulatorWorkerClient+Lifecycle.swift +542 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +541 cmuxTests/CMUXCLISessionsListForkDiagnosticsTests.swift +541 cmuxTests/CmuxDurableDeepLinkRestoreTests.swift +539 CLI/CMUXCLI+Themes.swift +539 CLI/CodexTeamsApprovalBridge.swift +536 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientRuntime.swift +536 Sources/TerminalController+ControlSurfaceContext2.swift +534 Sources/App/WorkspaceRuntimeSettings.swift +533 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohOnlineAdmissionRegistry.swift +532 Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorWorkerClientTests.swift +532 cmuxTests/AgentChatSessionRegistryLifecycleTests.swift +529 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointSupervisorTests.swift +528 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayPolicyService.swift +527 CLI/CLISocketPathResolver.swift +527 Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift +527 cmuxTests/BrowserHTTPBasicAuthPromptTests.swift +526 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamStore.swift +526 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift +525 Packages/macOS/CmuxSettings/Sources/CmuxSettings/SocketControl/SocketControlSettings.swift +525 Sources/CmuxSettingsJSONPathSupport.swift +524 Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxTransport.swift +524 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift +523 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRelayPolicyServiceTests.swift +523 Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/Navigation/FocusHistoryModelTests.swift +522 cmuxTests/SidebarWorkspaceTableTests.swift +520 CLI/CMUXCLI+AmpExtension.swift +520 Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptProjector.swift +520 cmuxTests/BrowserWebContentProcessTests.swift +520 cmuxTests/MainWindowVisibilityControllerTests.swift +519 CLI/CMUXCLI+AutoNaming.swift +519 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+ReverseRelay.swift +519 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/Corpus/stress-two-column-cockpit-sidebar.swift +518 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/Corpus/stress-git-review-queue-command-deck.swift +517 Packages/Shared/CmuxAgentSync/Tests/CmuxAgentSyncTests/AgentSyncEngineTests.swift +516 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView+WorkspaceActions.swift +516 Sources/HostSettingsActions.swift +516 Sources/TerminalImageTransfer.swift +516 cmuxTests/WorkspaceAdjacentPaneMoveTests.swift +514 Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/ExpressionEvaluator.swift +514 cmuxTests/AgentNotificationMoveRaceTests.swift +514 cmuxUITests/UpdatePillUITests.swift +512 Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohCustomRelayLiveTests.swift +511 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRelayCredentialCoordinator.swift +511 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift +511 Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptLiveView.swift +511 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlCommandCoordinator+SidebarReportsV1.swift +511 cmuxTests/WorkspaceSidebarObservationTests.swift +509 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerAdditionalPolicies.swift +509 Sources/TerminalPaneDropTargetView.swift +507 Sources/TerminalControllerTopSupport.swift +507 cmuxTests/CmuxConfigActionSaverTests.swift +507 cmuxTests/WindowTitleTemplateTests.swift +506 Sources/App/MainWindowVisibilityController.swift +506 Sources/CmuxEventPublishing.swift +505 Packages/macOS/CmuxSimulator/Sources/CmuxSimulatorWorker/Process/SimulatorWorkerCoordinator.swift +504 Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/UserDefaultsSettingsStoreTests.swift +504 Sources/VaultAgentRegistry.swift +504 cmuxTests/TerminalNotificationSocketActionTests.swift +503 Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohBrokerBackpressureGate.swift +503 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift +503 Sources/Settings/ConfigSource.swift +501 Sources/TerminalNotificationQueue.swift +500 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift +500 Packages/macOS/CmuxSimulator/Sources/CmuxSimulator/WorkerClient/SimulatorWorkerClient.swift +500 Sources/KeyboardShortcutRecorder.swift diff --git a/CLI/CMUXCLI+CommandSuggestions.swift b/CLI/CMUXCLI+CommandSuggestions.swift index d3b3dfbed022..4a5d89442bfe 100644 --- a/CLI/CMUXCLI+CommandSuggestions.swift +++ b/CLI/CMUXCLI+CommandSuggestions.swift @@ -56,6 +56,7 @@ extension CMUXCLI { "__sidebar_footer_icon_balance", "__tmux-compat", "agent-hibernation", + "agent-session", "ai-accounts", "auth", "bind-key", diff --git a/CLI/cmux.swift b/CLI/cmux.swift index d9153d497395..90b650ecdccd 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -4976,6 +4976,15 @@ struct CMUXCLI { let payload = try client.sendV2(method: "surface.create", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2CreationSummary(payload, idFormat: idFormat, kinds: ["surface", "pane", "dock_surface", "dock_pane", "workspace"])) + case "agent-session": + try runAgentSessionCommand( + commandArgs: commandArgs, + client: client, + jsonOutput: jsonOutput, + idFormat: idFormat, + windowOverride: windowId + ) + case "surface": try runSurfaceCommand( commandArgs: commandArgs, @@ -6986,6 +6995,80 @@ struct CMUXCLI { } } + private func runAgentSessionCommand( + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool, + idFormat: CLIIDFormat, + windowOverride: String? + ) throws { + guard let subcommand = commandArgs.first?.lowercased() else { + throw CLIError(message: "agent-session requires a subcommand. Try: cmux agent-session submit --surface surface:1 \"hello\"") + } + switch subcommand { + case "submit": + let args = Array(commandArgs.dropFirst()) + let (workspaceOpt, rem0) = parseOption(args, name: "--workspace") + let (surfaceOpt, rem1) = parseOption(rem0, name: "--surface") + let (windowOpt, rem2) = parseOption(rem1, name: "--window") + let (providerOpt, rem3) = parseOption(rem2, name: "--provider") + let (providerIDOpt, rem4) = parseOption(rem3, name: "--provider-id") + let (permissionOpt, rem5) = parseOption(rem4, name: "--permission-mode") + let (permissionCamelOpt, rem6) = parseOption(rem5, name: "--permissionMode") + let (textOpt, rem7) = parseOption(rem6, name: "--text") + let textArgs: [String] + if let separatorIndex = rem7.firstIndex(of: "--") { + textArgs = Array(rem7[rem7.index(after: separatorIndex)...]) + } else { + if let unknown = rem7.first(where: { $0.hasPrefix("--") }) { + throw CLIError(message: "agent-session submit: unknown flag '\(unknown)'") + } + textArgs = rem7 + } + let text = (textOpt ?? textArgs.joined(separator: " ")) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { + throw CLIError(message: "agent-session submit requires text") + } + + var params: [String: Any] = ["text": text] + let windowRaw = windowOpt ?? windowOverride + let winId = try normalizeWindowHandle(windowRaw, client: client) + if let winId { params["window_id"] = winId } + + let workspaceArg = workspaceOpt ?? (windowRaw == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, windowHandle: winId) + if let wsId { params["workspace_id"] = wsId } + + let sfId = try normalizeSurfaceHandle(surfaceOpt, client: client, workspaceHandle: wsId, windowHandle: winId) + if let sfId { params["surface_id"] = sfId } + + if let provider = providerOpt ?? providerIDOpt { + params["provider_id"] = provider + } + if let permissionMode = permissionOpt ?? permissionCamelOpt { + params["permission_mode"] = permissionMode + } + + let payload = try client.sendV2( + method: "agent_session.submit", + params: params, + responseTimeout: 125 + ) + if jsonOutput { + print(jsonString(formatIDs(payload, mode: idFormat))) + } else { + let provider = payload["provider_id"] as? String ?? "agent" + let started = (payload["started_provider"] as? Bool) == true + ? " started_provider=true" + : "" + print("\(v2OKSummary(payload, idFormat: idFormat, kinds: ["surface", "workspace"])) provider=\(provider)\(started)") + } + default: + throw CLIError(message: "Unsupported agent-session subcommand: \(subcommand)") + } + } + private func runSurfaceResumeCommand( commandArgs: [String], client: SocketClient, @@ -16712,6 +16795,27 @@ struct CMUXCLI { cmux new-surface --type agent-session --provider claude --renderer solid --focus true cmux new-surface --type browser --placement dock --url https://example.com """ + case "agent-session": + return """ + Usage: cmux agent-session submit [flags] [--] + + Submit text to an Agent Session surface. Starts the selected provider if it is not running. + + Flags: + --workspace Workspace context (default: $CMUX_WORKSPACE_ID) + --surface Agent Session surface to target + --window Window context for workspace/surface refs and indexes + --provider + Provider to start when no session is running + --permission-mode + Permission mode for the submitted turn + --text Text to submit instead of positional text + --json Structured JSON output + + Example: + cmux agent-session submit --surface surface:2 --provider codex "Say hello" + cmux agent-session submit --workspace workspace:1 -- --text that begins with dashes + """ case "close-surface": return """ Usage: cmux close-surface [flags] @@ -36118,6 +36222,7 @@ export default CMUXSessionRestore; focus-pane --pane [--workspace ] [--window ] new-pane [--type ] [--direction ] [--workspace ] [--window ] [--url ] \(String(localized: "cli.browser.profile.option", defaultValue: "[--profile ]")) [--focus ] new-surface [--type ] [--pane ] [--workspace ] [--window ] [--url ] [--provider ] [--renderer ] [--focus ] + agent-session submit [--workspace ] [--surface ] [--window ] [--provider ] [--permission-mode ] [--text |] close-surface [--surface ] [--workspace ] [--window ] move-surface --surface [--pane ] [--workspace ] [--window ] [--before ] [--after ] [--index ] [--focus ] split-off --surface [--workspace ] [--window ] [--focus ] diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxDeviceIDCanonicalization.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxDeviceIDCanonicalization.swift index 4b14c9619bc0..0ce817952ddf 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxDeviceIDCanonicalization.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxDeviceIDCanonicalization.swift @@ -4,6 +4,7 @@ import Foundation /// /// Device identifiers outside the UUID grammar are opaque protocol values and /// are returned byte-for-byte, including their original case and whitespace. +// lint:allow free-function - public cross-package canonicalization API, keep source compatibility. public func cmxCanonicalDeviceID(_ deviceID: String) -> String { guard let uuid = UUID(uuidString: deviceID) else { return deviceID } return uuid.uuidString.lowercased() diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLog.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLog.swift index 25f5e367033e..1deba18ae375 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLog.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLog.swift @@ -316,6 +316,7 @@ public final class DiagnosticLog: Sendable { commandContinuation: AsyncStream.Continuation ) { let (events, eventContinuation) = Self.makeEventSegment(capacity: capacity) + // lint:allow lock - same sanctioned synchronous DiagnosticLog state lock as the property above. self.state = OSAllocatedUnfairLock(initialState: State( capacity: capacity, commandContinuation: commandContinuation, diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileBrowserStreamCapability.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileBrowserStreamCapability.swift index 26cca408dcda..c5ba5de11408 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileBrowserStreamCapability.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileBrowserStreamCapability.swift @@ -1,4 +1,5 @@ /// Capability advertised by a Mac that supports browser-pane streaming. +// lint:allow namespace-type - existing static helper surface, preserving call sites during UI closeout. public struct MobileBrowserStreamCapability: Sendable { private init() {} diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalReplayViewportFence.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalReplayViewportFence.swift index bb5a26d74c32..a42f4d5e26a0 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalReplayViewportFence.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalReplayViewportFence.swift @@ -1,5 +1,6 @@ /// Validates that a terminal replay was captured after the host applied the /// viewport accepted for the requesting client. +// lint:allow namespace-type - existing static helper surface, preserving call sites during UI closeout. public enum MobileTerminalReplayViewportFence { /// Legacy replay requests do not carry a viewport and remain accepted. /// Viewport-aware requests are accepted only when both cell dimensions diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalScrollbackPreference.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalScrollbackPreference.swift index 0a160b4a991b..b7dbb9e5d073 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalScrollbackPreference.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalScrollbackPreference.swift @@ -9,6 +9,7 @@ import Foundation /// scroll further back at the cost of a proportionally larger one-time /// download at connect (~250 bytes per row); the visible screen never waits /// on it. +// lint:allow namespace-type - existing static helper surface, preserving call sites during UI closeout. public enum MobileTerminalScrollbackPreference { /// UserDefaults key, written by Settings and read at hydration time. public static let defaultsKey = "cmux.mobile.terminalScrollbackRows" diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileWorkspaceMetadataLimits.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileWorkspaceMetadataLimits.swift index e1cf8e562816..67b98464df1d 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileWorkspaceMetadataLimits.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileWorkspaceMetadataLimits.swift @@ -14,6 +14,7 @@ public struct MobileWorkspaceDescriptionProjection: Equatable, Sendable { } /// Shared bounds for workspace metadata that travels between Mac and iOS. +// lint:allow namespace-type - existing static helper surface, preserving call sites during UI closeout. public enum MobileWorkspaceMetadataLimits { /// Keep durable workspace descriptions well below the 8 MiB mobile frame cap. public static let customDescriptionMaxUTF8Bytes = 4096 diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift index aeb06002a95e..de8e041c8d01 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift @@ -64,16 +64,115 @@ public struct ArtifactByteReader: Sendable { isDirectory: isDirectory, isRegularFile: fileType == .typeRegular ) + let imageDimensions = imageDimensions(path: path, kind: kind) return ChatArtifactStat( exists: true, isDirectory: isDirectory, size: size, modifiedAt: modifiedAt, kind: kind, - mimeType: mimeType(path: path, isDirectory: isDirectory) + mimeType: mimeType(path: path, isDirectory: isDirectory), + pixelWidth: imageDimensions?.width, + pixelHeight: imageDimensions?.height ) } + private func imageDimensions(path: String, kind: ChatArtifactKind) -> (width: Int, height: Int)? { + guard kind == .image else { return nil } + let url = URL(fileURLWithPath: path) + if let source = CGImageSourceCreateWithURL(url as CFURL, nil), + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = Self.intProperty(properties[kCGImagePropertyPixelWidth]), + let height = Self.intProperty(properties[kCGImagePropertyPixelHeight]), + width > 0, + height > 0 { + return (width, height) + } + return svgDimensions(path: path) + } + + private static func intProperty(_ value: Any?) -> Int? { + switch value { + case let number as NSNumber: + return number.intValue + case let int as Int: + return int + default: + return nil + } + } + + private func svgDimensions(path: String) -> (width: Int, height: Int)? { + guard URL(fileURLWithPath: path).pathExtension.lowercased() == "svg", + let handle = try? FileHandle(forReadingFrom: URL(fileURLWithPath: path)) + else { return nil } + defer { try? handle.close() } + let data = (try? handle.read(upToCount: 128 * 1_024)) ?? Data() + guard let text = String(data: data, encoding: .utf8), + text.range(of: " 0, + height > 0 { + return (width, height) + } + guard let viewBox = svgAttribute(named: "viewBox", in: text) else { + return nil + } + let numbers = viewBox + .split { $0 == "," || $0 == " " || $0 == "\n" || $0 == "\t" } + .compactMap { Double($0) } + guard numbers.count >= 4, + numbers[2] > 0, + numbers[3] > 0 else { + return nil + } + return (roundedPixelDimension(numbers[2]), roundedPixelDimension(numbers[3])) + } + + private func svgLength(named name: String, in text: String) -> Int? { + guard let rawValue = svgAttribute(named: name, in: text), + let number = leadingNumber(rawValue), + number > 0 else { + return nil + } + return roundedPixelDimension(number) + } + + private func svgAttribute(named name: String, in text: String) -> String? { + let escapedName = NSRegularExpression.escapedPattern(for: name) + let pattern = #"\b\#(escapedName)\s*=\s*["']([^"']+)["']"# + guard let expression = try? NSRegularExpression( + pattern: pattern, + options: [.caseInsensitive] + ) else { return nil } + let range = NSRange(text.startIndex.. Double? { + let pattern = #"^[ \t\r\n]*([+-]?(?:\d+(?:\.\d+)?|\.\d+))"# + guard let expression = try? NSRegularExpression(pattern: pattern), + let match = expression.firstMatch( + in: value, + range: NSRange(value.startIndex.. Int { + max(1, Int(value.rounded())) + } + /// Generates a JPEG thumbnail for an already-authorized image path. public func thumbnail(path: String, maxDimension: Int) throws -> ChatArtifactThumbnail { let opened = try openVerifiedRegularFile(path: path) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryItem.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryItem.swift index 8faa746a0cd5..9a8f2e8aa681 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryItem.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryItem.swift @@ -18,6 +18,10 @@ public struct ChatArtifactGalleryItem: Sendable, Equatable, Codable, Identifiabl public let childCount: Int? /// Whether ``childCount`` is the listing cap rather than the exact count. public let childCountIsCapped: Bool + /// Intrinsic image width in pixels, when available. + public let pixelWidth: Int? + /// Intrinsic image height in pixels, when available. + public let pixelHeight: Int? /// Transcript provenance after precedence-based de-duplication. public let provenance: ChatArtifactProvenance @@ -34,6 +38,8 @@ public struct ChatArtifactGalleryItem: Sendable, Equatable, Codable, Identifiabl exists: Bool = true, childCount: Int? = nil, childCountIsCapped: Bool = false, + pixelWidth: Int? = nil, + pixelHeight: Int? = nil, provenance: ChatArtifactProvenance = .referenced ) { self.path = path @@ -44,6 +50,8 @@ public struct ChatArtifactGalleryItem: Sendable, Equatable, Codable, Identifiabl self.exists = exists self.childCount = childCount self.childCountIsCapped = childCountIsCapped + self.pixelWidth = Self.validPixelDimension(pixelWidth) + self.pixelHeight = Self.validPixelDimension(pixelHeight) self.provenance = provenance } @@ -56,6 +64,8 @@ public struct ChatArtifactGalleryItem: Sendable, Equatable, Codable, Identifiabl case exists case childCount = "child_count" case childCountIsCapped = "child_count_is_capped" + case pixelWidth = "pixel_width" + case pixelHeight = "pixel_height" case provenance } @@ -74,6 +84,8 @@ public struct ChatArtifactGalleryItem: Sendable, Equatable, Codable, Identifiabl exists = (try? container.decode(Bool.self, forKey: .exists)) ?? true childCount = try? container.decode(Int.self, forKey: .childCount) childCountIsCapped = (try? container.decode(Bool.self, forKey: .childCountIsCapped)) ?? false + pixelWidth = Self.validPixelDimension(try? container.decode(Int.self, forKey: .pixelWidth)) + pixelHeight = Self.validPixelDimension(try? container.decode(Int.self, forKey: .pixelHeight)) provenance = (try? container.decode(ChatArtifactProvenance.self, forKey: .provenance)) ?? .referenced } @@ -89,6 +101,13 @@ public struct ChatArtifactGalleryItem: Sendable, Equatable, Codable, Identifiabl try container.encode(exists, forKey: .exists) try container.encodeIfPresent(childCount, forKey: .childCount) try container.encode(childCountIsCapped, forKey: .childCountIsCapped) + try container.encodeIfPresent(pixelWidth, forKey: .pixelWidth) + try container.encodeIfPresent(pixelHeight, forKey: .pixelHeight) try container.encode(provenance, forKey: .provenance) } + + private static func validPixelDimension(_ value: Int?) -> Int? { + guard let value, value > 0 else { return nil } + return value + } } diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryRowEligibility.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryRowEligibility.swift index 65521b73e558..997ca7131f3c 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryRowEligibility.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryRowEligibility.swift @@ -143,6 +143,8 @@ public struct ChatArtifactGalleryRowEligibility: Sendable { exists: stat.exists, childCount: children?.count, childCountIsCapped: children?.isCapped ?? false, + pixelWidth: stat.pixelWidth, + pixelHeight: stat.pixelHeight, provenance: reference.provenance ) } catch { diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactStat.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactStat.swift index d2f7610687c6..85b200027bde 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactStat.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactStat.swift @@ -14,6 +14,12 @@ public struct ChatArtifactStat: Sendable, Equatable, Codable { public let kind: ChatArtifactKind /// Best-effort MIME type inferred by the Mac, when known. public let mimeType: String? + /// Intrinsic image width in pixels, when the artifact is an image and the + /// Mac can read dimensions without fetching thumbnail bytes. + public let pixelWidth: Int? + /// Intrinsic image height in pixels, when the artifact is an image and the + /// Mac can read dimensions without fetching thumbnail bytes. + public let pixelHeight: Int? /// Creates artifact metadata. /// @@ -24,13 +30,17 @@ public struct ChatArtifactStat: Sendable, Equatable, Codable { /// - modifiedAt: Last modification time. /// - kind: Preview category. /// - mimeType: Best-effort MIME type. + /// - pixelWidth: Intrinsic image width in pixels. + /// - pixelHeight: Intrinsic image height in pixels. public init( exists: Bool, isDirectory: Bool, size: Int64, modifiedAt: Date, kind: ChatArtifactKind, - mimeType: String? = nil + mimeType: String? = nil, + pixelWidth: Int? = nil, + pixelHeight: Int? = nil ) { self.exists = exists self.isDirectory = isDirectory @@ -38,6 +48,8 @@ public struct ChatArtifactStat: Sendable, Equatable, Codable { self.modifiedAt = modifiedAt self.kind = kind self.mimeType = mimeType + self.pixelWidth = Self.validPixelDimension(pixelWidth) + self.pixelHeight = Self.validPixelDimension(pixelHeight) } private enum CodingKeys: String, CodingKey { @@ -47,6 +59,8 @@ public struct ChatArtifactStat: Sendable, Equatable, Codable { case modifiedAt = "modified_at" case kind case mimeType = "mime_type" + case pixelWidth = "pixel_width" + case pixelHeight = "pixel_height" } public init(from decoder: any Decoder) throws { @@ -61,6 +75,8 @@ public struct ChatArtifactStat: Sendable, Equatable, Codable { } kind = try container.decode(ChatArtifactKind.self, forKey: .kind) mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType) + pixelWidth = Self.validPixelDimension(try container.decodeIfPresent(Int.self, forKey: .pixelWidth)) + pixelHeight = Self.validPixelDimension(try container.decodeIfPresent(Int.self, forKey: .pixelHeight)) } public func encode(to encoder: any Encoder) throws { @@ -71,5 +87,12 @@ public struct ChatArtifactStat: Sendable, Equatable, Codable { try container.encode(modifiedAt.timeIntervalSince1970, forKey: .modifiedAt) try container.encode(kind, forKey: .kind) try container.encodeIfPresent(mimeType, forKey: .mimeType) + try container.encodeIfPresent(pixelWidth, forKey: .pixelWidth) + try container.encodeIfPresent(pixelHeight, forKey: .pixelHeight) + } + + private static func validPixelDimension(_ value: Int?) -> Int? { + guard let value, value > 0 else { return nil } + return value } } diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/ChatAttachment.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/ChatAttachment.swift index b6b04880881b..30762f1a3921 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/ChatAttachment.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/ChatAttachment.swift @@ -1,3 +1,5 @@ +import Foundation + /// An image or file the user attached to a prompt. /// /// The binary payload travels out-of-band (image paste RPC); the transcript @@ -21,21 +23,226 @@ public struct ChatAttachment: Sendable, Equatable, Codable { /// Lets renderers reference what the agent sees. public let hostPath: String? + /// MIME media type reported by the transcript source, when known. + public let mimeType: String? + + /// Attachment size in bytes, when known. + public let byteCount: Int? + + /// Original image width in pixels, when known. + public let pixelWidth: Int? + + /// Original image height in pixels, when known. + public let pixelHeight: Int? + + /// Width divided by height, when exact pixel dimensions are unavailable. + public let aspectRatio: Double? + /// Creates attachment metadata. /// /// - Parameters: /// - media: The media category. /// - displayName: Display name when known. /// - hostPath: Host-side materialized path when known. - public init(media: Media, displayName: String? = nil, hostPath: String? = nil) { + /// - mimeType: MIME media type when known. + /// - byteCount: Attachment size in bytes when known. + /// - pixelWidth: Original image width in pixels when known. + /// - pixelHeight: Original image height in pixels when known. + /// - aspectRatio: Width divided by height when exact pixels are unknown. + public init( + media: Media, + displayName: String? = nil, + hostPath: String? = nil, + mimeType: String? = nil, + byteCount: Int? = nil, + pixelWidth: Int? = nil, + pixelHeight: Int? = nil, + aspectRatio: Double? = nil + ) { self.media = media self.displayName = displayName self.hostPath = hostPath + self.mimeType = mimeType + self.byteCount = byteCount + self.pixelWidth = pixelWidth + self.pixelHeight = pixelHeight + self.aspectRatio = Self.normalizedAspectRatio(aspectRatio) } private enum CodingKeys: String, CodingKey { case media case displayName = "display_name" case hostPath = "host_path" + case mimeType = "mime_type" + case byteCount = "byte_count" + case pixelWidth = "pixel_width" + case pixelHeight = "pixel_height" + case aspectRatio = "aspect_ratio" + } + + fileprivate enum AliasCodingKeys: String, CodingKey { + case media + case kind + case displayName + case fileName + case file_name + case name + case hostPath + case path + case file_path + case mimeType + case mediaType + case media_type + case byteCount + case size + case pixelWidth + case width + case pixelHeight + case height + case aspectRatio + case aspect_ratio + case previewAspectRatio + case preview_aspect_ratio + case ratio + } + + /// Decodes both current attachment metadata and older payloads that only + /// carried media, display name, and host path. Datasource aliases are + /// intentionally normalized here so renderers can reserve image preview + /// geometry before thumbnail bytes arrive. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let aliases = try decoder.container(keyedBy: AliasCodingKeys.self) + + let decodedDisplayName = try container.decodeIfPresent(String.self, forKey: .displayName) + ?? aliases.decodeString(forKey: .displayName) + ?? aliases.decodeString(forKey: .fileName) + ?? aliases.decodeString(forKey: .file_name) + ?? aliases.decodeString(forKey: .name) + let decodedHostPath = try container.decodeIfPresent(String.self, forKey: .hostPath) + ?? aliases.decodeString(forKey: .hostPath) + ?? aliases.decodeString(forKey: .path) + ?? aliases.decodeString(forKey: .file_path) + let decodedMimeType = try container.decodeIfPresent(String.self, forKey: .mimeType) + ?? aliases.decodeString(forKey: .mimeType) + ?? aliases.decodeString(forKey: .mediaType) + ?? aliases.decodeString(forKey: .media_type) + + media = (try? container.decode(Media.self, forKey: .media)) + ?? aliases.decodeMedia(forKey: .media) + ?? aliases.decodeMedia(forKey: .kind) + ?? Self.inferredMedia( + mimeType: decodedMimeType, + displayName: decodedDisplayName, + hostPath: decodedHostPath + ) + ?? .file + displayName = decodedDisplayName + hostPath = decodedHostPath + mimeType = decodedMimeType + byteCount = try container.decodeIfPresent(Int.self, forKey: .byteCount) + ?? aliases.decodeInt(forKey: .byteCount) + ?? aliases.decodeInt(forKey: .size) + pixelWidth = try container.decodeIfPresent(Int.self, forKey: .pixelWidth) + ?? aliases.decodeInt(forKey: .pixelWidth) + ?? aliases.decodeInt(forKey: .width) + pixelHeight = try container.decodeIfPresent(Int.self, forKey: .pixelHeight) + ?? aliases.decodeInt(forKey: .pixelHeight) + ?? aliases.decodeInt(forKey: .height) + aspectRatio = Self.normalizedAspectRatio( + try container.decodeIfPresent(Double.self, forKey: .aspectRatio) + ?? aliases.decodeDouble(forKey: .aspectRatio) + ?? aliases.decodeDouble(forKey: .aspect_ratio) + ?? aliases.decodeDouble(forKey: .previewAspectRatio) + ?? aliases.decodeDouble(forKey: .preview_aspect_ratio) + ?? aliases.decodeDouble(forKey: .ratio) + ) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(media, forKey: .media) + try container.encodeIfPresent(displayName, forKey: .displayName) + try container.encodeIfPresent(hostPath, forKey: .hostPath) + try container.encodeIfPresent(mimeType, forKey: .mimeType) + try container.encodeIfPresent(byteCount, forKey: .byteCount) + try container.encodeIfPresent(pixelWidth, forKey: .pixelWidth) + try container.encodeIfPresent(pixelHeight, forKey: .pixelHeight) + try container.encodeIfPresent(aspectRatio, forKey: .aspectRatio) + } + + private static func inferredMedia( + mimeType: String?, + displayName: String?, + hostPath: String? + ) -> Media? { + if let media = Media.normalized(from: mimeType) { + return media + } + let candidatePath = displayName ?? hostPath + guard let candidatePath else { return nil } + let lowercasedExtension = URL(fileURLWithPath: candidatePath).pathExtension.lowercased() + switch lowercasedExtension { + case "apng", "avif", "bmp", "gif", "heic", "heif", "jpeg", "jpg", "png", "svg", "tif", "tiff", "webp": + return .image + default: + return nil + } + } + + private static func normalizedAspectRatio(_ value: Double?) -> Double? { + guard let value, value.isFinite, value > 0 else { + return nil + } + return value + } +} + +private extension ChatAttachment.Media { + static func normalized(from rawValue: String?) -> Self? { + guard let rawValue else { return nil } + let normalized = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if let media = Self(rawValue: normalized) { + return media + } + if normalized.hasPrefix("image/") { + return .image + } + if normalized.hasPrefix("application/") || normalized.hasPrefix("text/") || normalized.hasPrefix("video/") { + return .file + } + return nil + } +} + +private extension KeyedDecodingContainer where Key == ChatAttachment.AliasCodingKeys { + func decodeString(forKey key: Key) -> String? { + try? decodeIfPresent(String.self, forKey: key) + } + + func decodeInt(forKey key: Key) -> Int? { + if let int = try? decodeIfPresent(Int.self, forKey: key) { + return int + } + if let int64 = try? decodeIfPresent(Int64.self, forKey: key), + int64 >= Int64(Int.min), + int64 <= Int64(Int.max) { + return Int(int64) + } + return nil + } + + func decodeDouble(forKey key: Key) -> Double? { + if let double = try? decodeIfPresent(Double.self, forKey: key) { + return double + } + if let int = try? decodeIfPresent(Int.self, forKey: key) { + return Double(int) + } + return nil + } + + func decodeMedia(forKey key: Key) -> ChatAttachment.Media? { + ChatAttachment.Media.normalized(from: decodeString(forKey: key)) } } diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ArtifactByteReaderTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ArtifactByteReaderTests.swift index 4f4e68f6c137..eaf49a4068a8 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ArtifactByteReaderTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ArtifactByteReaderTests.swift @@ -1,6 +1,9 @@ import Darwin +import CoreGraphics import Foundation +import ImageIO import Testing +import UniformTypeIdentifiers @testable import CmuxAgentChat @@ -210,6 +213,36 @@ struct ArtifactByteReaderTests { } } + @Test("image stat includes intrinsic pixel dimensions") + func imageStatIncludesPixelDimensions() throws { + try withTemporaryDirectory { directory in + let file = directory.appendingPathComponent("preview.png") + try Self.writePNG(width: 16, height: 9, to: file) + + let stat = try ArtifactByteReader().stat(path: file.path) + + #expect(stat.kind == .image) + #expect(stat.mimeType == "image/png") + #expect(stat.pixelWidth == 16) + #expect(stat.pixelHeight == 9) + } + } + + @Test("SVG stat includes declared pixel dimensions") + func svgStatIncludesDeclaredPixelDimensions() throws { + try withTemporaryDirectory { directory in + let file = directory.appendingPathComponent("preview.svg") + try Data(#""#.utf8).write(to: file) + + let stat = try ArtifactByteReader().stat(path: file.path) + + #expect(stat.kind == .image) + #expect(stat.mimeType == "image/svg+xml") + #expect(stat.pixelWidth == 480) + #expect(stat.pixelHeight == 270) + } + } + private func withTemporaryDirectory( _ operation: (URL) throws -> Void ) throws { @@ -219,4 +252,37 @@ struct ArtifactByteReaderTests { defer { try? FileManager.default.removeItem(at: directory) } try operation(directory) } + + private static func writePNG(width: Int, height: Int, to url: URL) throws { + let colorSpace = CGColorSpaceCreateDeviceRGB() + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + Issue.record("Failed to create test image context") + return + } + context.setFillColor(CGColor(red: 0.2, green: 0.4, blue: 0.8, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + guard let image = context.makeImage(), + let destination = CGImageDestinationCreateWithURL( + url as CFURL, + UTType.png.identifier as CFString, + 1, + nil + ) else { + Issue.record("Failed to create test image destination") + return + } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { + Issue.record("Failed to finalize test PNG") + return + } + } } diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryTests.swift index 2c23b6b64bda..faab5cc29495 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryTests.swift @@ -1,5 +1,8 @@ +import CoreGraphics import Foundation +import ImageIO import Testing +import UniformTypeIdentifiers @testable import CmuxAgentChat @Suite("Chat artifact gallery") @@ -15,6 +18,8 @@ struct ChatArtifactGalleryTests { exists: false, childCount: 500, childCountIsCapped: true, + pixelWidth: 1600, + pixelHeight: 900, provenance: .created ) let page = ChatArtifactGalleryPage( @@ -35,6 +40,8 @@ struct ChatArtifactGalleryTests { let created = try #require(json["created"] as? [[String: Any]]) #expect(created.first?["child_count"] as? Int == 500) #expect(created.first?["child_count_is_capped"] as? Bool == true) + #expect(created.first?["pixel_width"] as? Int == 1600) + #expect(created.first?["pixel_height"] as? Int == 900) #expect(try coding.decode(ChatArtifactGalleryPage.self, from: data) == page) let scan = TerminalArtifactScanResponse(artifacts: [], sessionID: "session-1") @@ -124,6 +131,34 @@ struct ChatArtifactGalleryTests { #expect(oldHostPage.referencedTotal == 0) } + @Test("image gallery rows include intrinsic pixel dimensions") + func imageGalleryRowsIncludePixelDimensions() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-gallery-image-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let image = root.appendingPathComponent("render.png") + try Self.writePNG(width: 16, height: 9, to: image) + + let page = ChatArtifactGalleryBuilder().page( + sessionID: "session", + items: [ChatArtifactIndexedReference( + path: image.path, + provenance: .created, + lastReferencedSeq: 1 + )], + generation: "generation", + cursor: nil, + pageSize: 10, + query: nil + ) + + let item = try #require(page.created.first) + #expect(item.kind == .image) + #expect(item.pixelWidth == 16) + #expect(item.pixelHeight == 9) + } + @Test("pages fill sections sequentially so loads only extend the list bottom") func allSectionsPageLazily() throws { let items = (1...3).flatMap { index in @@ -606,4 +641,37 @@ struct ChatArtifactGalleryTests { #expect(pages == 3) #expect(paths == (1...8).reversed().map { "/tmp/page-\($0).txt" }) } + + private static func writePNG(width: Int, height: Int, to url: URL) throws { + let colorSpace = CGColorSpaceCreateDeviceRGB() + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + Issue.record("Failed to create test image context") + return + } + context.setFillColor(CGColor(red: 0.9, green: 0.4, blue: 0.1, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + guard let image = context.makeImage(), + let destination = CGImageDestinationCreateWithURL( + url as CFURL, + UTType.png.identifier as CFString, + 1, + nil + ) else { + Issue.record("Failed to create test image destination") + return + } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { + Issue.record("Failed to finalize test PNG") + return + } + } } diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatMessageCodableTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatMessageCodableTests.swift index 12e6cd3cc358..b337c2549665 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatMessageCodableTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatMessageCodableTests.swift @@ -73,7 +73,16 @@ struct ChatMessageCodableTests { ) ), .status(ChatStatusTransition(event: .sessionStarted, detail: "claude")), - .attachment(ChatAttachment(media: .image, displayName: "design.png", hostPath: "/tmp/design.png")), + .attachment(ChatAttachment( + media: .image, + displayName: "design.png", + hostPath: "/tmp/design.png", + mimeType: "image/png", + byteCount: 456_789, + pixelWidth: 1_600, + pixelHeight: 900, + aspectRatio: 16.0 / 9.0 + )), ] for kind in kinds { let original = message(kind: kind) @@ -92,6 +101,62 @@ struct ChatMessageCodableTests { #expect(kind["command"] as? String == "ls") } + @Test("attachment aliases preserve preview metadata") + func attachmentAliasesPreservePreviewMetadata() throws { + let json = """ + {"id": "m12", "seq": 12, "role": "user", + "timestamp": "2026-06-11T00:00:00Z", + "kind": {"type": "attachment", "kind": "image", + "fileName": "frame.png", "path": "/tmp/frame.png", + "mediaType": "image/png", "byteCount": 456789, + "pixelWidth": 1600, "pixelHeight": 900, + "previewAspectRatio": 1.7777777778}} + """ + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let decoded = try decoder.decode(ChatMessage.self, from: Data(json.utf8)) + guard case .attachment(let attachment) = decoded.kind else { + Issue.record("Expected attachment, got \(decoded.kind)") + return + } + #expect(attachment.media == .image) + #expect(attachment.displayName == "frame.png") + #expect(attachment.hostPath == "/tmp/frame.png") + #expect(attachment.mimeType == "image/png") + #expect(attachment.byteCount == 456_789) + #expect(attachment.pixelWidth == 1_600) + #expect(attachment.pixelHeight == 900) + #expect(attachment.aspectRatio == 1.7777777778) + } + + @Test("attachment media can be inferred from MIME type and dimensions from width aliases") + func attachmentMediaInferredFromMetadata() throws { + let json = """ + {"id": "m13", "seq": 13, "role": "user", + "timestamp": "2026-06-11T00:00:00Z", + "kind": {"type": "attachment", + "displayName": "screenshot.webp", "hostPath": "/tmp/screenshot.webp", + "mime_type": "image/webp", "size": 2048, + "width": 1024, "height": 768}} + """ + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let decoded = try decoder.decode(ChatMessage.self, from: Data(json.utf8)) + guard case .attachment(let attachment) = decoded.kind else { + Issue.record("Expected attachment, got \(decoded.kind)") + return + } + #expect(attachment.media == .image) + #expect(attachment.displayName == "screenshot.webp") + #expect(attachment.hostPath == "/tmp/screenshot.webp") + #expect(attachment.mimeType == "image/webp") + #expect(attachment.byteCount == 2_048) + #expect(attachment.pixelWidth == 1_024) + #expect(attachment.pixelHeight == 768) + } + @Test("unknown kind decodes as unsupported, preserving the raw type") func unknownKindFailsOpen() throws { let json = """ diff --git a/Packages/Shared/CmuxAgentReplica/Package.swift b/Packages/Shared/CmuxAgentReplica/Package.swift new file mode 100644 index 000000000000..8d4d6f227bfd --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Package.swift @@ -0,0 +1,39 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "CmuxAgentReplica", + platforms: [ + .iOS(.v18), + .macOS(.v14), + ], + products: [ + .library( + name: "CmuxAgentReplica", + targets: ["CmuxAgentReplica"] + ), + ], + targets: [ + .target( + name: "CmuxAgentReplica", + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .testTarget( + name: "CmuxAgentReplicaTests", + dependencies: ["CmuxAgentReplica"], + resources: [ + .process("Resources"), + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + ] +) diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Delta/DeltaOrigin.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Delta/DeltaOrigin.swift new file mode 100644 index 000000000000..b6c790f67365 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Delta/DeltaOrigin.swift @@ -0,0 +1,9 @@ +import Foundation + +/// Describes whether a mutation arrived live or during reconciliation. +public enum DeltaOrigin: String, Codable, Hashable, Sendable { + /// A user-witnessed live mutation. + case live + /// A mutation replayed during resync. + case resync +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Delta/ReplicaDelta.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Delta/ReplicaDelta.swift new file mode 100644 index 000000000000..ea24b53b580b --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Delta/ReplicaDelta.swift @@ -0,0 +1,106 @@ +import Foundation + +/// Describes one typed replicated-state mutation. +public enum ReplicaDelta: Codable, Hashable, Sendable { + /// Upserts a session snapshot. + case sessionUpserted(AgentSessionSnapshot) + /// Removes a session when the removal version wins. + case sessionRemoved(id: AgentSessionID, version: EntityVersion) + /// Appends contiguous entries for one journal. + case entriesAppended(journalID: JournalID, entries: [EntrySnapshot]) + /// Replaces one already-loaded entry. + case entryReplaced(EntrySnapshot) + /// Rotates a conversation to a new journal and advertised tail. + case journalReset(sessionID: AgentSessionID, newJournal: JournalID, tailSeq: EntrySeq) + /// Changes one send ticket. + case sendTicketChanged(SendTicket) + /// Changes one pending ask. + case askChanged(PendingAsk) + /// Preserves an unknown mutation kind for fail-open decoding. + case unknown(kind: String) + + private enum CodingKeys: String, CodingKey { + case kind + case snapshot + case id + case version + case journalID + case entries + case entry + case sessionID + case newJournal + case tailSeq + case ticket + case ask + } + + /// Decodes a fail-open replicated mutation. + /// - Parameter decoder: The decoder to read from. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(String.self, forKey: .kind) + switch kind { + case "sessionUpserted": + self = .sessionUpserted(try container.decode(AgentSessionSnapshot.self, forKey: .snapshot)) + case "sessionRemoved": + self = .sessionRemoved( + id: try container.decode(AgentSessionID.self, forKey: .id), + version: try container.decode(EntityVersion.self, forKey: .version) + ) + case "entriesAppended": + self = .entriesAppended( + journalID: try container.decode(JournalID.self, forKey: .journalID), + entries: try container.decode([EntrySnapshot].self, forKey: .entries) + ) + case "entryReplaced": + self = .entryReplaced(try container.decode(EntrySnapshot.self, forKey: .entry)) + case "journalReset": + self = .journalReset( + sessionID: try container.decode(AgentSessionID.self, forKey: .sessionID), + newJournal: try container.decode(JournalID.self, forKey: .newJournal), + tailSeq: try container.decode(EntrySeq.self, forKey: .tailSeq) + ) + case "sendTicketChanged": + self = .sendTicketChanged(try container.decode(SendTicket.self, forKey: .ticket)) + case "askChanged": + self = .askChanged(try container.decode(PendingAsk.self, forKey: .ask)) + default: + self = .unknown(kind: kind) + } + } + + /// Encodes a replicated mutation. + /// - Parameter encoder: The encoder to write to. + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .sessionUpserted(let snapshot): + try container.encode("sessionUpserted", forKey: .kind) + try container.encode(snapshot, forKey: .snapshot) + case .sessionRemoved(let id, let version): + try container.encode("sessionRemoved", forKey: .kind) + try container.encode(id, forKey: .id) + try container.encode(version, forKey: .version) + case .entriesAppended(let journalID, let entries): + try container.encode("entriesAppended", forKey: .kind) + try container.encode(journalID, forKey: .journalID) + try container.encode(entries, forKey: .entries) + case .entryReplaced(let entry): + try container.encode("entryReplaced", forKey: .kind) + try container.encode(entry, forKey: .entry) + case .journalReset(let sessionID, let newJournal, let tailSeq): + try container.encode("journalReset", forKey: .kind) + try container.encode(sessionID, forKey: .sessionID) + try container.encode(newJournal, forKey: .newJournal) + try container.encode(tailSeq, forKey: .tailSeq) + case .sendTicketChanged(let ticket): + try container.encode("sendTicketChanged", forKey: .kind) + try container.encode(ticket, forKey: .ticket) + case .askChanged(let ask): + try container.encode("askChanged", forKey: .kind) + try container.encode(ask, forKey: .ask) + case .unknown(let kind): + try container.encode(kind, forKey: .kind) + } + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/AgentKind.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/AgentKind.swift new file mode 100644 index 000000000000..bc2d03757bf7 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/AgentKind.swift @@ -0,0 +1,44 @@ +import Foundation + +/// Describes the agent implementation that owns a session. +public enum AgentKind: Codable, Hashable, Sendable { + /// A Claude-backed agent. + case claude + /// A Codex-backed agent. + case codex + /// An unrecognized agent kind preserved for fail-open decoding. + case unknown(String) + + /// The raw string carried on the wire and in replay logs. + public var rawValue: String { + switch self { + case .claude: "claude" + case .codex: "codex" + case .unknown(let raw): raw + } + } + + /// Creates an agent kind from a raw string. + /// - Parameter rawValue: The raw kind value. + public init(rawValue: String) { + switch rawValue { + case "claude": self = .claude + case "codex": self = .codex + default: self = .unknown(rawValue) + } + } + + /// Decodes a fail-open agent kind. + /// - Parameter decoder: The decoder to read from. + public init(from decoder: any Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self.init(rawValue: raw) + } + + /// Encodes the raw agent kind string. + /// - Parameter encoder: The encoder to write to. + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/AgentProsePayload.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/AgentProsePayload.swift new file mode 100644 index 000000000000..8a21b8356f48 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/AgentProsePayload.swift @@ -0,0 +1,13 @@ +import Foundation + +/// Carries markdown prose emitted by the agent. +public struct AgentProsePayload: Codable, Hashable, Sendable { + /// The markdown text to display. + public let markdown: String + + /// Creates an agent prose payload. + /// - Parameter markdown: The markdown text to display. + public init(markdown: String) { + self.markdown = markdown + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/AgentSessionID.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/AgentSessionID.swift new file mode 100644 index 000000000000..f500c0867c00 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/AgentSessionID.swift @@ -0,0 +1,13 @@ +import Foundation + +/// Identifies one agent session within a Mac. +public struct AgentSessionID: Codable, Hashable, Sendable, RawRepresentable { + /// The opaque session identifier minted by the Mac. + public let rawValue: String + + /// Creates an agent session identifier. + /// - Parameter rawValue: The opaque identifier value. + public init(rawValue: String) { + self.rawValue = rawValue + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/AgentSessionSnapshot.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/AgentSessionSnapshot.swift new file mode 100644 index 000000000000..f711150328d3 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/AgentSessionSnapshot.swift @@ -0,0 +1,80 @@ +import Foundation + +/// Captures the replaceable replicated metadata for one agent session. +public struct AgentSessionSnapshot: Codable, Hashable, Sendable { + private enum CodingKeys: String, CodingKey { + case id + case macDeviceID = "mac_device_id" + case kind + case phase + case tier + case surfaceID = "surface_id" + case cwd + case title + case workspaceName = "workspace_name" + case version + case lastActivityHint = "last_activity_hint" + } + + /// The session identifier, scoped by ``macDeviceID``. + public let id: AgentSessionID + /// The Mac that owns this session. + public let macDeviceID: MacDeviceID + /// The agent implementation kind. + public let kind: AgentKind + /// The current display phase. + public let phase: SessionPhase + /// The current detection tier. + public let tier: DetectionTier + /// The optional surface identifier that displays the session. + public let surfaceID: String? + /// The current working directory display value. + public let cwd: String + /// The session title display value. + public let title: String + /// The workspace name display value. + public let workspaceName: String + /// The entity version for this snapshot in the current epoch. + public let version: EntityVersion + /// An opaque recency hint used only for display ordering. + public let lastActivityHint: Int + + /// Creates a session snapshot. + /// - Parameters: + /// - id: The session identifier. + /// - macDeviceID: The owning Mac identifier. + /// - kind: The agent kind. + /// - phase: The display phase. + /// - tier: The detection tier. + /// - surfaceID: The optional surface identifier. + /// - cwd: The working directory display value. + /// - title: The session title. + /// - workspaceName: The workspace display value. + /// - version: The entity version in the current epoch. + /// - lastActivityHint: The opaque recency hint. + public init( + id: AgentSessionID, + macDeviceID: MacDeviceID, + kind: AgentKind, + phase: SessionPhase, + tier: DetectionTier, + surfaceID: String?, + cwd: String, + title: String, + workspaceName: String, + version: EntityVersion, + lastActivityHint: Int + ) { + self.id = id + self.macDeviceID = macDeviceID + self.kind = kind + self.phase = phase + self.tier = tier + self.surfaceID = surfaceID + self.cwd = cwd + self.title = title + self.workspaceName = workspaceName + self.version = version + self.lastActivityHint = lastActivityHint + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/AttachmentPayload.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/AttachmentPayload.swift new file mode 100644 index 000000000000..dc518939e0ba --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/AttachmentPayload.swift @@ -0,0 +1,187 @@ +import Foundation + +/// Carries an attachment transcript entry. +public struct AttachmentPayload: Codable, Hashable, Sendable { + /// The attachment kind identifier. + public let kind: String + /// A compact attachment summary. + public let summary: String + /// Stable attachment identifier, when reported. + public let attachmentID: String? + /// User-facing filename or label. + public let displayName: String? + /// Materialized host path, when reported. + public let hostPath: String? + /// MIME media type, when reported. + public let mimeType: String? + /// Payload size in bytes, when reported. + public let byteCount: Int? + /// Pixel width for image attachments. + public let width: Int? + /// Pixel height for image attachments. + public let height: Int? + /// Width divided by height, when a producer can provide preview geometry + /// without exact pixel dimensions. + public let aspectRatio: Double? + /// Author role for generated attachment rows, when the transcript source + /// can distinguish user attachments from agent-produced previews. + public let authorRole: String? + + private enum CodingKeys: String, CodingKey { + case kind = "attachment_kind" + case summary + case attachmentID = "attachment_id" + case displayName = "display_name" + case hostPath = "host_path" + case mimeType = "mime_type" + case byteCount = "byte_count" + case width + case height + case aspectRatio = "aspect_ratio" + case authorRole = "author_role" + } + + private enum AliasCodingKeys: String, CodingKey { + case kind + case type + case id + case fileName + case file_name + case name + case path + case file_path + case mediaType + case media_type + case byteCount + case size + case pixelWidth + case pixel_width + case pixelHeight + case pixel_height + case aspectRatio + case aspect_ratio + case previewAspectRatio + case preview_aspect_ratio + case ratio + case authorRole + case role + } + + /// Creates an attachment payload. + /// - Parameters: + /// - kind: The attachment kind identifier. + /// - summary: A compact attachment summary. + public init( + kind: String, + summary: String, + attachmentID: String? = nil, + displayName: String? = nil, + hostPath: String? = nil, + mimeType: String? = nil, + byteCount: Int? = nil, + width: Int? = nil, + height: Int? = nil, + aspectRatio: Double? = nil, + authorRole: String? = nil + ) { + self.kind = kind + self.summary = summary + self.attachmentID = attachmentID + self.displayName = displayName + self.hostPath = hostPath + self.mimeType = mimeType + self.byteCount = byteCount + self.width = width + self.height = height + self.aspectRatio = Self.normalizedAspectRatio(aspectRatio) + self.authorRole = authorRole + } + + /// Decodes canonical payloads plus datasource aliases used by transcript + /// sources and artifact metadata APIs. Keeping this tolerant at the replica + /// boundary lets iOS reserve the correct preview geometry before loading + /// thumbnail bytes. + public init(from decoder: any Decoder) throws { + let canonical = try decoder.container(keyedBy: CodingKeys.self) + let aliases = try decoder.container(keyedBy: AliasCodingKeys.self) + + let decodedDisplayName = try canonical.decodeIfPresent(String.self, forKey: .displayName) + ?? aliases.decodeIfPresent(String.self, forKey: .fileName) + ?? aliases.decodeIfPresent(String.self, forKey: .file_name) + ?? aliases.decodeIfPresent(String.self, forKey: .name) + let decodedHostPath = try canonical.decodeIfPresent(String.self, forKey: .hostPath) + ?? aliases.decodeIfPresent(String.self, forKey: .path) + ?? aliases.decodeIfPresent(String.self, forKey: .file_path) + let decodedKind = try canonical.decodeIfPresent(String.self, forKey: .kind) + ?? aliases.decodeIfPresent(String.self, forKey: .type) + ?? aliases.decodeIfPresent(String.self, forKey: .kind) + ?? "file" + let decodedSummary = try canonical.decodeIfPresent(String.self, forKey: .summary) + ?? decodedDisplayName + ?? decodedHostPath + ?? "\(decodedKind.capitalized) attachment" + + kind = decodedKind + summary = decodedSummary + attachmentID = try canonical.decodeIfPresent(String.self, forKey: .attachmentID) + ?? aliases.decodeIfPresent(String.self, forKey: .id) + displayName = decodedDisplayName + hostPath = decodedHostPath + mimeType = try canonical.decodeIfPresent(String.self, forKey: .mimeType) + ?? aliases.decodeIfPresent(String.self, forKey: .mediaType) + ?? aliases.decodeIfPresent(String.self, forKey: .media_type) + byteCount = try canonical.decodeIfPresent(Int.self, forKey: .byteCount) + ?? aliases.decodeIfPresent(Int.self, forKey: .byteCount) + ?? aliases.decodeIfPresent(Int.self, forKey: .size) + width = try canonical.decodeIfPresent(Int.self, forKey: .width) + ?? aliases.decodeIfPresent(Int.self, forKey: .pixelWidth) + ?? aliases.decodeIfPresent(Int.self, forKey: .pixel_width) + height = try canonical.decodeIfPresent(Int.self, forKey: .height) + ?? aliases.decodeIfPresent(Int.self, forKey: .pixelHeight) + ?? aliases.decodeIfPresent(Int.self, forKey: .pixel_height) + var decodedAspectRatio = try canonical.decodeIfPresent(Double.self, forKey: .aspectRatio) + if decodedAspectRatio == nil { + decodedAspectRatio = try aliases.decodeIfPresent(Double.self, forKey: .aspectRatio) + } + if decodedAspectRatio == nil { + decodedAspectRatio = try aliases.decodeIfPresent(Double.self, forKey: .aspect_ratio) + } + if decodedAspectRatio == nil { + decodedAspectRatio = try aliases.decodeIfPresent(Double.self, forKey: .previewAspectRatio) + } + if decodedAspectRatio == nil { + decodedAspectRatio = try aliases.decodeIfPresent(Double.self, forKey: .preview_aspect_ratio) + } + if decodedAspectRatio == nil { + decodedAspectRatio = try aliases.decodeIfPresent(Double.self, forKey: .ratio) + } + aspectRatio = Self.normalizedAspectRatio(decodedAspectRatio) + authorRole = try canonical.decodeIfPresent(String.self, forKey: .authorRole) + ?? aliases.decodeIfPresent(String.self, forKey: .authorRole) + ?? aliases.decodeIfPresent(String.self, forKey: .role) + } + + /// Encodes the canonical replica payload. Aliases are read-only migration + /// support so the wire format stays deterministic. + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(kind, forKey: .kind) + try container.encode(summary, forKey: .summary) + try container.encodeIfPresent(attachmentID, forKey: .attachmentID) + try container.encodeIfPresent(displayName, forKey: .displayName) + try container.encodeIfPresent(hostPath, forKey: .hostPath) + try container.encodeIfPresent(mimeType, forKey: .mimeType) + try container.encodeIfPresent(byteCount, forKey: .byteCount) + try container.encodeIfPresent(width, forKey: .width) + try container.encodeIfPresent(height, forKey: .height) + try container.encodeIfPresent(aspectRatio, forKey: .aspectRatio) + try container.encodeIfPresent(authorRole, forKey: .authorRole) + } + + private static func normalizedAspectRatio(_ value: Double?) -> Double? { + guard let value, value.isFinite, value > 0 else { + return nil + } + return value + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/DetectionTier.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/DetectionTier.swift new file mode 100644 index 000000000000..bd204bacd2d4 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/DetectionTier.swift @@ -0,0 +1,13 @@ +import Foundation + +/// Describes how strongly cmux can observe a session. +public enum DetectionTier: String, Codable, Hashable, Sendable { + /// The session is fully wrapped. + case wrapped + /// The session is hooked by integration points. + case hooked + /// The session is observed without strong integration. + case observed + /// The session is visible through degraded detection. + case degraded +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/EntityVersion.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/EntityVersion.swift new file mode 100644 index 000000000000..1e7b3e501c0c --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/EntityVersion.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Carries a monotonically increasing version for one entity inside an epoch. +public struct EntityVersion: Codable, Comparable, Hashable, Sendable, RawRepresentable { + /// The unsigned integer version value. + public let rawValue: UInt64 + + /// Creates an entity version wrapper. + /// - Parameter rawValue: The version value for one entity. + public init(rawValue: UInt64) { + self.rawValue = rawValue + } + + /// Orders versions by their integer values. + /// - Parameters: + /// - lhs: The left version. + /// - rhs: The right version. + /// - Returns: Whether `lhs` is older than `rhs`. + public static func < (lhs: EntityVersion, rhs: EntityVersion) -> Bool { + lhs.rawValue < rhs.rawValue + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/EntryContent.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/EntryContent.swift new file mode 100644 index 000000000000..e0367d26f4a6 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/EntryContent.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Holds id-stable entry content and its wire-carriable payload. +public struct EntryContent: Codable, Hashable, Sendable { + /// The stable content hash used for identity and replacement checks. + public let contentHash: Int + /// The rich transcript payload carried with the entry. + public let payload: EntryPayload + + private enum CodingKeys: String, CodingKey { + case contentHash = "content_hash" + case payload + } + + /// Creates entry content with a producer-supplied content hash. + /// - Parameters: + /// - contentHash: The id-stable content hash. + /// - payload: The rich transcript payload. + public init(contentHash: Int, payload: EntryPayload) { + self.contentHash = contentHash + self.payload = payload + } + + /// Decodes entry content, defaulting missing payloads to an unknown value for old replay logs. + /// - Parameter decoder: The decoder to read from. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.contentHash = try container.decode(Int.self, forKey: .contentHash) + self.payload = (try? container.decode(EntryPayload.self, forKey: .payload)) ?? .unknown(UnknownPayload(rawKind: "unknown")) + } + + /// Encodes entry content. + /// - Parameter encoder: The encoder to write to. + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(contentHash, forKey: .contentHash) + try container.encode(payload, forKey: .payload) + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/EntryKind.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/EntryKind.swift new file mode 100644 index 000000000000..eeb9c1cf98a2 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/EntryKind.swift @@ -0,0 +1,72 @@ +import Foundation + +/// Describes the semantic kind of a transcript entry. +public enum EntryKind: Codable, Hashable, Sendable { + /// A user-authored message. + case userMessage + /// Agent prose intended for display. + case agentProse + /// Agent thought or reasoning content. + case thought + /// A tool execution entry. + case toolRun + /// A file-change entry. + case fileChange + /// A question entry. + case question + /// A permission request entry. + case permission + /// A status entry. + case status + /// An attachment entry. + case attachment + /// An unrecognized entry kind preserved for fail-open decoding. + case unknown(String) + + /// The raw string carried in replay logs. + public var rawValue: String { + switch self { + case .userMessage: "userMessage" + case .agentProse: "agentProse" + case .thought: "thought" + case .toolRun: "toolRun" + case .fileChange: "fileChange" + case .question: "question" + case .permission: "permission" + case .status: "status" + case .attachment: "attachment" + case .unknown(let raw): raw + } + } + + /// Creates an entry kind from a raw string. + /// - Parameter rawValue: The raw entry kind. + public init(rawValue: String) { + switch rawValue { + case "userMessage": self = .userMessage + case "agentProse": self = .agentProse + case "thought": self = .thought + case "toolRun": self = .toolRun + case "fileChange": self = .fileChange + case "question": self = .question + case "permission": self = .permission + case "status": self = .status + case "attachment": self = .attachment + default: self = .unknown(rawValue) + } + } + + /// Decodes a fail-open entry kind. + /// - Parameter decoder: The decoder to read from. + public init(from decoder: any Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self.init(rawValue: raw) + } + + /// Encodes the raw entry kind string. + /// - Parameter encoder: The encoder to write to. + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/EntryPayload.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/EntryPayload.swift new file mode 100644 index 000000000000..d27a513efe9e --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/EntryPayload.swift @@ -0,0 +1,181 @@ +import Foundation + +/// Carries the rich, wire-carriable content for a transcript entry. +public enum EntryPayload: Codable, Hashable, Sendable { + /// A user-authored message. + case userMessage(UserMessagePayload) + /// Agent prose intended for display. + case agentProse(AgentProsePayload) + /// Agent thought or reasoning content. + case thought(ThoughtPayload) + /// A tool execution entry. + case toolRun(ToolRunPayload) + /// A file-change entry. + case fileChange(FileChangePayload) + /// A question entry. + case question(QuestionPayload) + /// A permission request entry. + case permission(PermissionPayload) + /// A status entry. + case status(StatusPayload) + /// An attachment entry. + case attachment(AttachmentPayload) + /// An unrecognized payload preserved for fail-open decoding. + case unknown(UnknownPayload) + + private enum CodingKeys: String, CodingKey { + case kind + case rawKind = "raw_kind" + case summary + } + + /// A deterministic FNV-1a hash over the payload's canonical JSON encoding. + /// + /// Producers may use this value as ``EntryContent/contentHash`` when they do + /// not have a stronger domain-specific hash. Stores still treat the producer + /// supplied hash as authoritative. + public var stableHash: Int { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = (try? encoder.encode(self)) ?? Data() + var hash: UInt64 = 14_695_981_039_346_656_037 + for byte in data { + hash ^= UInt64(byte) + hash &*= 1_099_511_628_211 + } + return Int(truncatingIfNeeded: hash) + } + + /// Decodes a fail-open entry payload, preserving non-object values as unknown content. + /// - Parameter decoder: The decoder to read from. + public init(from decoder: any Decoder) throws { + let raw = RawJSONValue.boundedCanonicalString(from: decoder) + guard let container = try? decoder.container(keyedBy: CodingKeys.self) else { + self = .unknown(UnknownPayload( + rawKind: "unknown", + rawJSON: raw.value, + rawJSONTruncated: raw.truncated + )) + return + } + let kind = (try? container.decode(String.self, forKey: .kind)) ?? "unknown" + let fallbackSummary = try? container.decodeIfPresent(String.self, forKey: .summary) + self = EntryPayload.decodeKnown( + kind: kind, + hasRawKind: container.contains(.rawKind), + decoder: decoder, + rawJSON: raw.value, + rawJSONTruncated: raw.truncated, + summary: fallbackSummary ?? nil + ) + } + + /// Encodes a tagged entry payload. + /// - Parameter encoder: The encoder to write to. + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(kind.rawValue, forKey: .kind) + switch self { + case .userMessage(let payload): + try payload.encode(to: encoder) + case .agentProse(let payload): + try payload.encode(to: encoder) + case .thought(let payload): + try payload.encode(to: encoder) + case .toolRun(let payload): + try payload.encode(to: encoder) + case .fileChange(let payload): + try payload.encode(to: encoder) + case .question(let payload): + try payload.encode(to: encoder) + case .permission(let payload): + try payload.encode(to: encoder) + case .status(let payload): + try payload.encode(to: encoder) + case .attachment(let payload): + try payload.encode(to: encoder) + case .unknown(let payload): + try payload.encode(to: encoder) + } + } + + /// The matching entry kind. + public var kind: EntryKind { + switch self { + case .userMessage: + .userMessage + case .agentProse: + .agentProse + case .thought: + .thought + case .toolRun: + .toolRun + case .fileChange: + .fileChange + case .question: + .question + case .permission: + .permission + case .status: + .status + case .attachment: + .attachment + case .unknown(let payload): + .unknown(payload.rawKind) + } + } + + private static func decodeKnown( + kind: String, + hasRawKind: Bool, + decoder: any Decoder, + rawJSON: String?, + rawJSONTruncated: Bool, + summary: String? + ) -> EntryPayload { + if hasRawKind, let payload = try? UnknownPayload(from: decoder) { + return .unknown(payload) + } + switch kind { + case EntryKind.userMessage.rawValue: + if let payload = try? UserMessagePayload(from: decoder) { + return .userMessage(payload) + } + case EntryKind.agentProse.rawValue: + if let payload = try? AgentProsePayload(from: decoder) { + return .agentProse(payload) + } + case EntryKind.thought.rawValue: + if let payload = try? ThoughtPayload(from: decoder) { + return .thought(payload) + } + case EntryKind.toolRun.rawValue: + if let payload = try? ToolRunPayload(from: decoder) { + return .toolRun(payload) + } + case EntryKind.fileChange.rawValue: + if let payload = try? FileChangePayload(from: decoder) { + return .fileChange(payload) + } + case EntryKind.question.rawValue: + if let payload = try? QuestionPayload(from: decoder) { + return .question(payload) + } + case EntryKind.permission.rawValue: + if let payload = try? PermissionPayload(from: decoder) { + return .permission(payload) + } + case EntryKind.status.rawValue: + if let payload = try? StatusPayload(from: decoder) { + return .status(payload) + } + case EntryKind.attachment.rawValue: + if let payload = try? AttachmentPayload(from: decoder) { + return .attachment(payload) + } + default: + break + } + return .unknown(UnknownPayload(rawKind: kind, summary: summary, rawJSON: rawJSON, rawJSONTruncated: rawJSONTruncated)) + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/EntrySeq.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/EntrySeq.swift new file mode 100644 index 000000000000..115728c0b01d --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/EntrySeq.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Identifies an entry's sequence number within a journal. +public struct EntrySeq: Codable, Comparable, Hashable, Sendable, RawRepresentable { + /// The integer sequence value. + public let rawValue: Int + + /// Creates an entry sequence wrapper. + /// - Parameter rawValue: The journal-local sequence number. + public init(rawValue: Int) { + self.rawValue = rawValue + } + + /// Orders entry sequences by their integer values. + /// - Parameters: + /// - lhs: The left sequence. + /// - rhs: The right sequence. + /// - Returns: Whether `lhs` precedes `rhs`. + public static func < (lhs: EntrySeq, rhs: EntrySeq) -> Bool { + lhs.rawValue < rhs.rawValue + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/EntrySnapshot.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/EntrySnapshot.swift new file mode 100644 index 000000000000..1b2f8ddee36b --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/EntrySnapshot.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Captures one replaceable transcript entry value. +public struct EntrySnapshot: Codable, Hashable, Sendable { + private enum CodingKeys: String, CodingKey { + case journalID = "journal_id" + case seq + case kind + case content + case version + case timestampMilliseconds = "timestamp_ms" + } + + /// The journal that owns this entry. + public let journalID: JournalID + /// The sequence number within the journal. + public let seq: EntrySeq + /// The entry kind. + public let kind: EntryKind + /// The opaque content for this slice. + public let content: EntryContent + /// The entity version for this entry in the current epoch. + public let version: EntityVersion + /// Source timestamp in Unix milliseconds, when reported. + public let timestampMilliseconds: Int64? + + /// Creates an entry snapshot. + /// - Parameters: + /// - journalID: The owning journal identifier. + /// - seq: The journal-local sequence. + /// - kind: The entry kind. + /// - content: The opaque content. + /// - version: The entity version in the current epoch. + public init( + journalID: JournalID, + seq: EntrySeq, + kind: EntryKind, + content: EntryContent, + version: EntityVersion, + timestampMilliseconds: Int64? = nil + ) { + self.journalID = journalID + self.seq = seq + self.kind = kind + self.content = content + self.version = version + self.timestampMilliseconds = timestampMilliseconds + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/FileChangeKind.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/FileChangeKind.swift new file mode 100644 index 000000000000..6d95d3a72677 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/FileChangeKind.swift @@ -0,0 +1,55 @@ +import Foundation + +/// Describes the kind of file mutation reported by a transcript entry. +public enum FileChangeKind: Codable, Hashable, Sendable { + /// A file write. + case write + /// A file edit. + case edit + /// A patch application. + case patch + /// A notebook edit. + case notebook + /// An unclassified file mutation. + case unknown + /// A future file mutation kind preserved for fail-open decoding. + case other(String) + + /// The raw wire identifier. + public var rawValue: String { + switch self { + case .write: "write" + case .edit: "edit" + case .patch: "patch" + case .notebook: "notebook" + case .unknown: "unknown" + case .other(let raw): raw + } + } + + /// Creates a file change kind from its raw wire identifier. + /// - Parameter rawValue: The raw wire identifier. + public init(rawValue: String) { + switch rawValue { + case "write": self = .write + case "edit": self = .edit + case "patch": self = .patch + case "notebook": self = .notebook + case "unknown": self = .unknown + default: self = .other(rawValue) + } + } + + /// Decodes a fail-open file change kind. + /// - Parameter decoder: The decoder to read from. + public init(from decoder: any Decoder) throws { + self.init(rawValue: try decoder.singleValueContainer().decode(String.self)) + } + + /// Encodes the raw file change kind identifier. + /// - Parameter encoder: The encoder to write to. + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/FileChangePayload.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/FileChangePayload.swift new file mode 100644 index 000000000000..d2baee9becd1 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/FileChangePayload.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Carries one file mutation reported by an agent transcript. +public struct FileChangePayload: Codable, Hashable, Sendable { + /// The changed path, when the transcript exposes it. + public let path: String + /// The kind of mutation. + public let changeKind: FileChangeKind + /// A compact summary of the mutation result, when known. + public let resultSummary: String? + /// Runtime correlation identifier, when reported. + public let toolCallID: String? + /// Original path for a move or rename. + public let oldPath: String? + /// Result path for a move or rename. + public let newPath: String? + /// Added-line count, when computable. + public let additions: Int? + /// Removed-line count, when computable. + public let deletions: Int? + /// Bounded unified diff or patch text for a detail surface. + public let unifiedDiff: String? + + private enum CodingKeys: String, CodingKey { + case path + case changeKind = "change_kind" + case resultSummary = "result_summary" + case toolCallID = "tool_call_id" + case oldPath = "old_path" + case newPath = "new_path" + case additions + case deletions + case unifiedDiff = "unified_diff" + } + + /// Creates a file change payload. + /// - Parameters: + /// - path: The changed path, when the transcript exposes it. + /// - changeKind: The kind of mutation. + /// - resultSummary: A compact summary of the mutation result, when known. + public init( + path: String, + changeKind: FileChangeKind, + resultSummary: String? = nil, + toolCallID: String? = nil, + oldPath: String? = nil, + newPath: String? = nil, + additions: Int? = nil, + deletions: Int? = nil, + unifiedDiff: String? = nil + ) { + self.path = path + self.changeKind = changeKind + self.resultSummary = resultSummary + self.toolCallID = toolCallID + self.oldPath = oldPath + self.newPath = newPath + self.additions = additions + self.deletions = deletions + self.unifiedDiff = unifiedDiff + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/JournalCursor.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/JournalCursor.swift new file mode 100644 index 000000000000..3e3744a4d188 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/JournalCursor.swift @@ -0,0 +1,16 @@ +import Foundation + +/// An opaque server-issued position in a journal. +/// +/// Clients persist and return cursors verbatim. The encoded value is not a +/// sequence number and its representation may change between server versions. +public struct JournalCursor: Codable, Hashable, Sendable, RawRepresentable { + /// The opaque wire value. + public let rawValue: String + + /// Creates an opaque journal cursor. + /// - Parameter rawValue: The server-issued wire value. + public init(rawValue: String) { + self.rawValue = rawValue + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/JournalID.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/JournalID.swift new file mode 100644 index 000000000000..9a95288d6cc7 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/JournalID.swift @@ -0,0 +1,13 @@ +import Foundation + +/// Identifies one transcript journal for a session. +public struct JournalID: Codable, Hashable, Sendable, RawRepresentable { + /// The opaque journal identifier minted by the Mac. + public let rawValue: String + + /// Creates a journal identifier. + /// - Parameter rawValue: The opaque identifier value. + public init(rawValue: String) { + self.rawValue = rawValue + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/MacDeviceID.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/MacDeviceID.swift new file mode 100644 index 000000000000..6ce3519857ca --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/MacDeviceID.swift @@ -0,0 +1,13 @@ +import Foundation + +/// Identifies the Mac that owns replicated agent state. +public struct MacDeviceID: Codable, Hashable, Sendable, RawRepresentable { + /// The opaque Mac device identifier. + public let rawValue: String + + /// Creates a Mac device identifier. + /// - Parameter rawValue: The opaque identifier value. + public init(rawValue: String) { + self.rawValue = rawValue + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/PendingAsk.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/PendingAsk.swift new file mode 100644 index 000000000000..d0a076f36a92 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/PendingAsk.swift @@ -0,0 +1,66 @@ +import Foundation + +/// Records one question or permission ask awaiting user disposition. +public struct PendingAsk: Codable, Hashable, Sendable, Identifiable { + private enum CodingKeys: String, CodingKey { + case id + case sessionID = "session_id" + case kind + case promptSummary = "prompt_summary" + case options + case optionsCount = "options_count" + case state + } + + /// The stable ask identifier. + public let id: String + /// The session this ask belongs to. + public let sessionID: AgentSessionID + /// The ask kind. + public let kind: PendingAskKind + /// The prompt summary suitable for compact display. + public let promptSummary: String + /// Choice labels in runtime order. Empty means the terminal must answer. + public let options: [String] + /// The number of available choices retained for older wire peers. + public let optionsCount: Int + /// The ask state. + public let state: PendingAskState + + /// Creates a pending ask. + /// - Parameters: + /// - id: The stable ask identifier. + /// - sessionID: The owning session identifier. + /// - kind: The ask kind. + /// - promptSummary: The compact prompt summary. + /// - options: Choice labels in runtime order, or empty for terminal-only asks. + /// - state: The ask state. + public init( + id: String, + sessionID: AgentSessionID, + kind: PendingAskKind, + promptSummary: String, + options: [String], + state: PendingAskState + ) { + self.id = id + self.sessionID = sessionID + self.kind = kind + self.promptSummary = promptSummary + self.options = options + self.optionsCount = options.count + self.state = state + } + + /// Decodes both current option labels and the prior count-only wire shape. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + sessionID = try container.decode(AgentSessionID.self, forKey: .sessionID) + kind = try container.decode(PendingAskKind.self, forKey: .kind) + promptSummary = try container.decode(String.self, forKey: .promptSummary) + options = try container.decodeIfPresent([String].self, forKey: .options) ?? [] + optionsCount = try container.decodeIfPresent(Int.self, forKey: .optionsCount) ?? options.count + state = try container.decode(PendingAskState.self, forKey: .state) + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/PendingAskKind.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/PendingAskKind.swift new file mode 100644 index 000000000000..3289eebfc723 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/PendingAskKind.swift @@ -0,0 +1,9 @@ +import Foundation + +/// Describes the kind of pending user ask. +public enum PendingAskKind: String, Codable, Hashable, Sendable { + /// A question requiring an answer. + case question + /// A permission request requiring a decision. + case permission +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/PendingAskState.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/PendingAskState.swift new file mode 100644 index 000000000000..d0cfa804a798 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/PendingAskState.swift @@ -0,0 +1,48 @@ +import Foundation + +/// Describes the lifecycle of a pending ask. +public enum PendingAskState: Codable, Hashable, Sendable { + /// The ask is active. + case active + /// The ask was answered with the selected choice index. + case answered(choice: Int) + /// The ask expired. + case expired + /// The ask was superseded by later state. + case superseded + + private enum CodingKeys: String, CodingKey { + case type + case choice + } + + /// Decodes a pending ask state. + /// - Parameter decoder: The decoder to read from. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(String.self, forKey: .type) { + case "active": self = .active + case "answered": self = .answered(choice: try container.decode(Int.self, forKey: .choice)) + case "expired": self = .expired + case "superseded": self = .superseded + default: self = .superseded + } + } + + /// Encodes a pending ask state. + /// - Parameter encoder: The encoder to write to. + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .active: + try container.encode("active", forKey: .type) + case .answered(let choice): + try container.encode("answered", forKey: .type) + try container.encode(choice, forKey: .choice) + case .expired: + try container.encode("expired", forKey: .type) + case .superseded: + try container.encode("superseded", forKey: .type) + } + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/PermissionPayload.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/PermissionPayload.swift new file mode 100644 index 000000000000..ff313833168f --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/PermissionPayload.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Carries a permission request emitted by an agent runtime. +public struct PermissionPayload: Codable, Hashable, Sendable { + /// The tool requesting permission. + public let toolName: String + /// The permission detail shown to the user. + public let detail: String + /// The available permission choices. + public let options: [String] + + private enum CodingKeys: String, CodingKey { + case toolName = "tool_name" + case detail + case options + } + + /// Creates a permission payload. + /// - Parameters: + /// - toolName: The tool requesting permission. + /// - detail: The permission detail shown to the user. + /// - options: The available permission choices. + public init(toolName: String, detail: String, options: [String]) { + self.toolName = toolName + self.detail = detail + self.options = options + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/QuestionPayload.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/QuestionPayload.swift new file mode 100644 index 000000000000..7dbd9058b50d --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/QuestionPayload.swift @@ -0,0 +1,42 @@ +import Foundation + +/// Carries a question posed by the agent and optional answer state. +public struct QuestionPayload: Codable, Hashable, Sendable { + /// Runtime-stable identifier for this question, when exposed. + public let questionID: String? + /// Short question header, when exposed. + public let header: String? + /// The question prompt. + public let prompt: String + /// The available choices, if the runtime exposed them. + public let options: [String] + /// The answered choice index, if known. + public let answeredChoice: Int? + + private enum CodingKeys: String, CodingKey { + case questionID = "question_id" + case header + case prompt + case options + case answeredChoice = "answered_choice" + } + + /// Creates a question payload. + /// - Parameters: + /// - prompt: The question prompt. + /// - options: The available choices, if the runtime exposed them. + /// - answeredChoice: The answered choice index, if known. + public init( + questionID: String? = nil, + header: String? = nil, + prompt: String, + options: [String], + answeredChoice: Int? = nil + ) { + self.questionID = questionID + self.header = header + self.prompt = prompt + self.options = options + self.answeredChoice = answeredChoice + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/RawJSONValue.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/RawJSONValue.swift new file mode 100644 index 000000000000..1888bcb977a1 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/RawJSONValue.swift @@ -0,0 +1,59 @@ +import Foundation + +enum RawJSONValue: Codable, Hashable, Sendable { + case null + case bool(Bool) + case number(Double) + case string(String) + case array([RawJSONValue]) + case object([String: RawJSONValue]) + + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int.self) { + self = .number(Double(value)) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([RawJSONValue].self) { + self = .array(value) + } else { + self = .object(try container.decode([String: RawJSONValue].self)) + } + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: + try container.encodeNil() + case .bool(let value): + try container.encode(value) + case .number(let value): + try container.encode(value) + case .string(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + } + } + + static func boundedCanonicalString(from decoder: any Decoder) -> (value: String?, truncated: Bool) { + guard let value = try? RawJSONValue(from: decoder) else { + return (nil, false) + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(value), let string = String(data: data, encoding: .utf8) else { + return (nil, false) + } + return UnknownPayload.bounded(string) + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/ReplicaEpoch.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/ReplicaEpoch.swift new file mode 100644 index 000000000000..8a348427164c --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/ReplicaEpoch.swift @@ -0,0 +1,13 @@ +import Foundation + +/// Identifies one launch epoch of the Mac app. +public struct ReplicaEpoch: Codable, Hashable, Sendable, RawRepresentable { + /// The opaque epoch value minted by the Mac app. + public let rawValue: String + + /// Creates an epoch wrapper. + /// - Parameter rawValue: The opaque epoch value. + public init(rawValue: String) { + self.rawValue = rawValue + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/SendTicket.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/SendTicket.swift new file mode 100644 index 000000000000..a63644781b67 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/SendTicket.swift @@ -0,0 +1,50 @@ +public import Foundation + +/// Records one locally-originated send attempt. +public struct SendTicket: Codable, Hashable, Sendable, Identifiable { + private enum CodingKeys: String, CodingKey { + case id + case sessionID = "session_id" + case text + case attachmentCount = "attachment_count" + case state + case createdAt = "created_at" + } + + /// The stable ticket UUID. + public let id: UUID + /// The session this ticket belongs to. + public let sessionID: AgentSessionID + /// The text submitted by the user. + public let text: String + /// The number of attachments submitted with the ticket. + public let attachmentCount: Int + /// The replicated ticket state. + public let state: SendTicketState + /// The injected clock tick when the ticket was created. + public let createdAt: Int + + /// Creates a send ticket. + /// - Parameters: + /// - id: The stable UUID. + /// - sessionID: The owning session identifier. + /// - text: The submitted text. + /// - attachmentCount: The submitted attachment count. + /// - state: The current ticket state. + /// - createdAt: The injected clock tick. + public init( + id: UUID, + sessionID: AgentSessionID, + text: String, + attachmentCount: Int, + state: SendTicketState, + createdAt: Int + ) { + self.id = id + self.sessionID = sessionID + self.text = text + self.attachmentCount = attachmentCount + self.state = state + self.createdAt = createdAt + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/SendTicketState.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/SendTicketState.swift new file mode 100644 index 000000000000..b0ad59152544 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/SendTicketState.swift @@ -0,0 +1,69 @@ +import Foundation + +/// Describes the replicated lifecycle of a local send ticket. +public enum SendTicketState: Codable, Hashable, Sendable { + /// The ticket is queued locally. + case queuedLocal + /// The Mac accepted the ticket. + case acceptedByMac + /// The Mac injected the ticket into the session. + case injected + /// The ticket echoed as a transcript entry. + case echoed(EntrySeq) + /// The ticket failed with a stable error code. + case failed(code: String) + /// The ticket exists but the Mac has not confirmed its state. + case unconfirmed + + private enum CodingKeys: String, CodingKey { + case type + case seq + case code + } + + /// Decodes a send ticket state. + /// - Parameter decoder: The decoder to read from. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(String.self, forKey: .type) { + case "queuedLocal": self = .queuedLocal + case "acceptedByMac": self = .acceptedByMac + case "injected": self = .injected + case "echoed": self = .echoed(try container.decode(EntrySeq.self, forKey: .seq)) + case "failed": self = .failed(code: try container.decode(String.self, forKey: .code)) + default: self = .unconfirmed + } + } + + /// Encodes a send ticket state. + /// - Parameter encoder: The encoder to write to. + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .queuedLocal: + try container.encode("queuedLocal", forKey: .type) + case .acceptedByMac: + try container.encode("acceptedByMac", forKey: .type) + case .injected: + try container.encode("injected", forKey: .type) + case .echoed(let seq): + try container.encode("echoed", forKey: .type) + try container.encode(seq, forKey: .seq) + case .failed(let code): + try container.encode("failed", forKey: .type) + try container.encode(code, forKey: .code) + case .unconfirmed: + try container.encode("unconfirmed", forKey: .type) + } + } + + /// Whether the ticket reached a terminal success or failure state. + public var isResolved: Bool { + switch self { + case .echoed, .failed: + true + case .queuedLocal, .acceptedByMac, .injected, .unconfirmed: + false + } + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/SessionPhase.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/SessionPhase.swift new file mode 100644 index 000000000000..cd09e179a86b --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/SessionPhase.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Describes the current display phase of a session without monotonicity assumptions. +public enum SessionPhase: String, Codable, Hashable, Sendable { + /// The session is starting. + case starting + /// The session is idle. + case idle + /// The session is actively working. + case working + /// The session needs user input. + case needsInput + /// The session has ended. + case ended + /// The session phase is unknown. + case unknown +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/StatusCode.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/StatusCode.swift new file mode 100644 index 000000000000..2ae34570356e --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/StatusCode.swift @@ -0,0 +1,51 @@ +import Foundation + +/// Identifies a status entry while preserving future status codes. +public enum StatusCode: Codable, Hashable, Sendable { + /// Transcript context was compacted. + case compacted + /// The current turn was aborted. + case turnAborted + /// The agent reported an API error. + case apiError + /// Session metadata was observed. + case sessionMeta + /// A future status code preserved for fail-open decoding. + case other(String) + + /// The raw wire identifier. + public var rawValue: String { + switch self { + case .compacted: "compacted" + case .turnAborted: "turn_aborted" + case .apiError: "api_error" + case .sessionMeta: "session_meta" + case .other(let raw): raw + } + } + + /// Creates a status code from its raw wire identifier. + /// - Parameter rawValue: The raw wire identifier. + public init(rawValue: String) { + switch rawValue { + case "compacted": self = .compacted + case "turn_aborted": self = .turnAborted + case "api_error": self = .apiError + case "session_meta": self = .sessionMeta + default: self = .other(rawValue) + } + } + + /// Decodes a fail-open status code. + /// - Parameter decoder: The decoder to read from. + public init(from decoder: any Decoder) throws { + self.init(rawValue: try decoder.singleValueContainer().decode(String.self)) + } + + /// Encodes the raw status code identifier. + /// - Parameter encoder: The encoder to write to. + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/StatusPayload.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/StatusPayload.swift new file mode 100644 index 000000000000..3967ffe0151c --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/StatusPayload.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Carries a transcript status entry. +public struct StatusPayload: Codable, Hashable, Sendable { + /// The machine-readable status code. + public let code: StatusCode + /// Human-readable status detail, when available. + public let detail: String? + + /// Creates a status payload. + /// - Parameters: + /// - code: The machine-readable status code. + /// - detail: Human-readable status detail, when available. + public init(code: StatusCode, detail: String? = nil) { + self.code = code + self.detail = detail + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/ThoughtPayload.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/ThoughtPayload.swift new file mode 100644 index 000000000000..9539673fe009 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/ThoughtPayload.swift @@ -0,0 +1,13 @@ +import Foundation + +/// Carries agent thought or reasoning text. +public struct ThoughtPayload: Codable, Hashable, Sendable { + /// The thought text. + public let text: String + + /// Creates a thought payload. + /// - Parameter text: The thought text. + public init(text: String) { + self.text = text + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/ToolRunPayload.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/ToolRunPayload.swift new file mode 100644 index 000000000000..f9065f7cb0ad --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/ToolRunPayload.swift @@ -0,0 +1,89 @@ +import Foundation + +/// Carries one tool invocation or paired tool result. +public struct ToolRunPayload: Codable, Hashable, Sendable { + /// The tool name reported by the agent runtime. + public let toolName: String + /// A compact summary of the tool arguments. + public let argumentSummary: String + /// A compact summary of the tool result, when known. + public let resultSummary: String? + /// Whether the tool represents a terminal or shell command. + public let isTerminal: Bool + /// The tool exit code, when reported. + public let exitCode: Int? + /// Whether the tool is still running. + public let isRunning: Bool + /// Runtime correlation identifier, when reported. + public let toolCallID: String? + /// Bounded full input rendering for a detail surface. + public let inputDetail: String? + /// Shell command text for terminal tools. + public let command: String? + /// Bounded captured output for a detail surface. + public let output: String? + /// Wall-clock duration in seconds, when reported. + public let durationSeconds: Double? + /// Runtime lifecycle status, retained as a fail-open string. + public let status: String? + /// Inline preview attachments referenced by this tool run. + /// + /// Producers attach only lightweight metadata here. Binary bytes remain + /// out-of-band so mobile renderers can reserve image geometry before a + /// thumbnail request finishes. + public let previewAttachments: [AttachmentPayload]? + + private enum CodingKeys: String, CodingKey { + case toolName = "tool_name" + case argumentSummary = "argument_summary" + case resultSummary = "result_summary" + case isTerminal = "is_terminal" + case exitCode = "exit_code" + case isRunning = "is_running" + case toolCallID = "tool_call_id" + case inputDetail = "input_detail" + case command + case output + case durationSeconds = "duration_seconds" + case status + case previewAttachments = "preview_attachments" + } + + /// Creates a tool run payload. + /// - Parameters: + /// - toolName: The tool name reported by the agent runtime. + /// - argumentSummary: A compact summary of the tool arguments. + /// - resultSummary: A compact summary of the tool result, when known. + /// - isTerminal: Whether the tool represents a terminal or shell command. + /// - exitCode: The tool exit code, when reported. + /// - isRunning: Whether the tool is still running. + public init( + toolName: String, + argumentSummary: String, + resultSummary: String? = nil, + isTerminal: Bool, + exitCode: Int? = nil, + isRunning: Bool, + toolCallID: String? = nil, + inputDetail: String? = nil, + command: String? = nil, + output: String? = nil, + durationSeconds: Double? = nil, + status: String? = nil, + previewAttachments: [AttachmentPayload]? = nil + ) { + self.toolName = toolName + self.argumentSummary = argumentSummary + self.resultSummary = resultSummary + self.isTerminal = isTerminal + self.exitCode = exitCode + self.isRunning = isRunning + self.toolCallID = toolCallID + self.inputDetail = inputDetail + self.command = command + self.output = output + self.durationSeconds = durationSeconds + self.status = status + self.previewAttachments = previewAttachments + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/UnknownPayload.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/UnknownPayload.swift new file mode 100644 index 000000000000..223b2566d668 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/UnknownPayload.swift @@ -0,0 +1,70 @@ +import Foundation + +/// Carries fail-open transcript content that this version does not model. +public struct UnknownPayload: Codable, Hashable, Sendable { + /// The maximum number of UTF-8 bytes retained for raw JSON. + public static let rawJSONByteLimit = 8 * 1024 + + /// The raw kind identifier. + public let rawKind: String + /// A compact human-readable summary, when available. + public let summary: String? + /// Bounded raw JSON for diagnostics and future reprocessing. + public let rawJSON: String? + /// Whether ``rawJSON`` was truncated to ``rawJSONByteLimit``. + public let rawJSONTruncated: Bool + + private enum CodingKeys: String, CodingKey { + case rawKind = "raw_kind" + case summary + case rawJSON = "raw_json" + case rawJSONTruncated = "raw_json_truncated" + } + + /// Creates an unknown payload, bounding raw JSON to 8 KB. + /// - Parameters: + /// - rawKind: The raw kind identifier. + /// - summary: A compact human-readable summary, when available. + /// - rawJSON: Raw JSON for diagnostics and future reprocessing. + /// - rawJSONTruncated: Whether the provided raw JSON was already truncated. + public init(rawKind: String, summary: String? = nil, rawJSON: String? = nil, rawJSONTruncated: Bool = false) { + self.rawKind = rawKind + self.summary = summary + let bounded = UnknownPayload.bounded(rawJSON) + self.rawJSON = bounded.value + self.rawJSONTruncated = rawJSONTruncated || bounded.truncated + } + + /// Decodes an unknown payload while bounding raw JSON. + /// - Parameter decoder: The decoder to read from. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let rawKind = (try? container.decode(String.self, forKey: .rawKind)) ?? "unknown" + let summary = try? container.decodeIfPresent(String.self, forKey: .summary) + let rawJSON = try? container.decodeIfPresent(String.self, forKey: .rawJSON) + let rawJSONTruncated = (try? container.decodeIfPresent(Bool.self, forKey: .rawJSONTruncated)) ?? false + self.init(rawKind: rawKind, summary: summary ?? nil, rawJSON: rawJSON ?? nil, rawJSONTruncated: rawJSONTruncated) + } + + /// Encodes an unknown payload. + /// - Parameter encoder: The encoder to write to. + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(rawKind, forKey: .rawKind) + try container.encodeIfPresent(summary, forKey: .summary) + try container.encodeIfPresent(rawJSON, forKey: .rawJSON) + try container.encode(rawJSONTruncated, forKey: .rawJSONTruncated) + } + + static func bounded(_ value: String?) -> (value: String?, truncated: Bool) { + guard let value else { + return (nil, false) + } + let bytes = Array(value.utf8) + guard bytes.count > rawJSONByteLimit else { + return (value, false) + } + let prefix = bytes.prefix(rawJSONByteLimit) + return (String(decoding: prefix, as: UTF8.self), true) + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/UserMessagePayload.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/UserMessagePayload.swift new file mode 100644 index 000000000000..3f5bfb93f387 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Model/UserMessagePayload.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Carries the text and attachment flags for a user-authored transcript entry. +public struct UserMessagePayload: Codable, Hashable, Sendable { + /// The user-authored text. + public let text: String + /// The number of attachments associated with the message. + public let attachmentCount: Int + /// Whether any attachment is an image. + public let hasImage: Bool + + private enum CodingKeys: String, CodingKey { + case text + case attachmentCount = "attachment_count" + case hasImage = "has_image" + } + + /// Creates a user message payload. + /// - Parameters: + /// - text: The user-authored text. + /// - attachmentCount: The number of attachments associated with the message. + /// - hasImage: Whether any attachment is an image. + public init(text: String, attachmentCount: Int, hasImage: Bool) { + self.text = text + self.attachmentCount = attachmentCount + self.hasImage = hasImage + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Replay/FixtureReplicaSource.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Replay/FixtureReplicaSource.swift new file mode 100644 index 000000000000..4c880922b6cd --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Replay/FixtureReplicaSource.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Feeds scripted replay records into replica stores. +public struct FixtureReplicaSource: Sendable { + /// Scripted replay records. + public let records: [ReplicaReplayRecord] + + /// Creates a fixture source. + /// - Parameter records: Scripted replay records. + public init(records: [ReplicaReplayRecord]) { + self.records = records + } + + /// Applies all records to existing stores. + /// - Parameters: + /// - directory: The session directory store. + /// - conversations: Existing conversation stores keyed by session identifier. + @MainActor public func feed( + directory: SessionDirectoryReplica, + conversations: [AgentSessionID: ConversationReplica] + ) { + for record in records { + directory.apply(record.delta, origin: record.origin) + for conversation in conversations.values { + conversation.apply(record.delta, origin: record.origin) + } + } + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Replay/ReplicaReplayLog.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Replay/ReplicaReplayLog.swift new file mode 100644 index 000000000000..1ddb8e66c4ae --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Replay/ReplicaReplayLog.swift @@ -0,0 +1,52 @@ +public import Foundation + +/// Encodes and decodes newline-delimited replay records. +public struct ReplicaReplayLog: Codable, Hashable, Sendable { + /// Decoded replay records. + public let records: [ReplicaReplayRecord] + /// Count of lines skipped during decoding. + public let skippedLineCount: Int + + /// Creates a replay log value. + /// - Parameters: + /// - records: Decoded replay records. + /// - skippedLineCount: Count of skipped lines. + public init(records: [ReplicaReplayRecord], skippedLineCount: Int = 0) { + self.records = records + self.skippedLineCount = skippedLineCount + } + + /// Encodes records as JSONL data. + /// - Returns: UTF-8 JSONL data. + public func encodeJSONL() throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + var output = Data() + for record in records { + output.append(try encoder.encode(record)) + output.append(0x0A) + } + return output + } + + /// Decodes JSONL data, skipping invalid lines. + /// - Parameter data: UTF-8 JSONL data. + /// - Returns: A replay log with decoded records and a skipped-line count. + public static func decodeJSONL(_ data: Data) -> ReplicaReplayLog { + let decoder = JSONDecoder() + let text = String(decoding: data, as: UTF8.self) + var records: [ReplicaReplayRecord] = [] + var skipped = 0 + for line in text.split(separator: "\n", omittingEmptySubsequences: false) { + guard !line.isEmpty, let lineData = String(line).data(using: .utf8) else { + continue + } + do { + records.append(try decoder.decode(ReplicaReplayRecord.self, from: lineData)) + } catch { + skipped += 1 + } + } + return ReplicaReplayLog(records: records, skippedLineCount: skipped) + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Replay/ReplicaReplayRecord.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Replay/ReplicaReplayRecord.swift new file mode 100644 index 000000000000..6d3369fe750a --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Replay/ReplicaReplayRecord.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Records one replayable replicated mutation with timing metadata. +public struct ReplicaReplayRecord: Codable, Hashable, Sendable { + /// The deterministic display tick associated with the mutation. + public let tick: Int + /// The origin of the mutation. + public let origin: DeltaOrigin + /// The replayed mutation. + public let delta: ReplicaDelta + + /// Creates a replay record. + /// - Parameters: + /// - tick: The deterministic display tick. + /// - origin: The mutation origin. + /// - delta: The replayed mutation. + public init(tick: Int, origin: DeltaOrigin, delta: ReplicaDelta) { + self.tick = tick + self.origin = origin + self.delta = delta + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Resync/ResyncConversationState.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Resync/ResyncConversationState.swift new file mode 100644 index 000000000000..400d049d8fd9 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Resync/ResyncConversationState.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Describes one open conversation for resync planning. +public struct ResyncConversationState: Codable, Hashable, Sendable { + /// The open session identifier. + public let sessionID: AgentSessionID + /// The current journal identifier, if known. + public let journalID: JournalID? + /// Whether the conversation needs an explicit tail pull. + /// + /// The sync engine must pass `true` for every open conversation on transport-up, + /// because disconnection alone never mutates the store or sets this flag. + public let needsTailPull: Bool + + /// Creates an open-conversation planning value. + /// - Parameters: + /// - sessionID: The open session identifier. + /// - journalID: The current journal identifier, if known. + /// - needsTailPull: Whether tail reconciliation is needed; pass `true` for + /// open conversations when transport reconnects. + public init(sessionID: AgentSessionID, journalID: JournalID?, needsTailPull: Bool) { + self.sessionID = sessionID + self.journalID = journalID + self.needsTailPull = needsTailPull + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Resync/ResyncPlan.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Resync/ResyncPlan.swift new file mode 100644 index 000000000000..fdf4bb923287 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Resync/ResyncPlan.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Builds ordered reconciliation steps without performing I/O. +public struct ResyncPlan: Codable, Hashable, Sendable { + /// The ordered reconciliation steps. + public let steps: [ResyncPlanStep] + + /// Creates a resync plan. + /// - Parameter steps: Ordered reconciliation steps. + public init(steps: [ResyncPlanStep]) { + self.steps = steps + } + + /// Computes the resync plan for a hello handshake. + /// - Parameters: + /// - cachedEpoch: The cached epoch, if any. + /// - helloEpoch: The epoch reported by the Mac hello. + /// - openConversations: Open conversations that may need tail pages. + /// - ticketQueue: Tickets retained across reconnects and epoch changes. + /// - Returns: An ordered pure resync plan. + public static func make( + cachedEpoch: ReplicaEpoch?, + helloEpoch: ReplicaEpoch, + openConversations: [ResyncConversationState], + ticketQueue: [SendTicket] + ) -> ResyncPlan { + var steps: [ResyncPlanStep] = [] + let epochChanged = cachedEpoch != nil && cachedEpoch != helloEpoch + steps.append(epochChanged ? .dropAll : .keepState) + steps.append(.pullSessions) + + let conversationsToPull: [ResyncConversationState] + if epochChanged { + conversationsToPull = openConversations + } else { + conversationsToPull = openConversations.filter(\.needsTailPull) + } + for conversation in conversationsToPull.sorted(by: { $0.sessionID.rawValue < $1.sessionID.rawValue }) { + steps.append(.pullTailPage(conversation.sessionID)) + } + if !ticketQueue.isEmpty { + steps.append(.flushTickets) + } + return ResyncPlan(steps: steps) + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Resync/ResyncPlanStep.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Resync/ResyncPlanStep.swift new file mode 100644 index 000000000000..4f613ddef8f5 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Resync/ResyncPlanStep.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Describes one ordered resync step. +public enum ResyncPlanStep: Codable, Hashable, Sendable { + /// Drop replicated state because the Mac epoch changed. + case dropAll + /// Keep cached state because the epoch is unchanged. + case keepState + /// Pull the session directory. + case pullSessions + /// Pull the current tail page for an open session. + case pullTailPage(AgentSessionID) + /// Flush queued send tickets after state reconciliation. + case flushTickets + + private enum CodingKeys: String, CodingKey { + case type + case sessionID + } + + /// Decodes a resync step. + /// - Parameter decoder: The decoder to read from. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(String.self, forKey: .type) { + case "dropAll": self = .dropAll + case "keepState": self = .keepState + case "pullSessions": self = .pullSessions + case "pullTailPage": self = .pullTailPage(try container.decode(AgentSessionID.self, forKey: .sessionID)) + case "flushTickets": self = .flushTickets + default: self = .keepState + } + } + + /// Encodes a resync step. + /// - Parameter encoder: The encoder to write to. + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .dropAll: + try container.encode("dropAll", forKey: .type) + case .keepState: + try container.encode("keepState", forKey: .type) + case .pullSessions: + try container.encode("pullSessions", forKey: .type) + case .pullTailPage(let sessionID): + try container.encode("pullTailPage", forKey: .type) + try container.encode(sessionID, forKey: .sessionID) + case .flushTickets: + try container.encode("flushTickets", forKey: .type) + } + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Store/ConversationPageRetentionEdge.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Store/ConversationPageRetentionEdge.swift new file mode 100644 index 000000000000..b74f9af6adb9 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Store/ConversationPageRetentionEdge.swift @@ -0,0 +1,9 @@ +/// The edge a bounded conversation window must preserve when it exceeds its memory cap. +public enum ConversationPageRetentionEdge: Sendable, Equatable { + /// Infer the edge from the requested page and the currently retained range. + case automatic + /// Keep the oldest loaded entries, evicting newer entries first. + case oldest + /// Keep the newest loaded entries, evicting older entries first. + case newest +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Store/ConversationReplica.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Store/ConversationReplica.swift new file mode 100644 index 000000000000..8a717d22f481 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Store/ConversationReplica.swift @@ -0,0 +1,721 @@ +import Foundation +public import Observation + +/// Maintains the replicated entry window for one session. +@MainActor @Observable public final class ConversationReplica { + private struct CursorPageSegment { + var sequenceValues: Set + var startCursor: JournalCursor + var endCursor: JournalCursor + var hasMoreBefore: Bool + var hasMoreAfter: Bool + } + + /// The session this conversation belongs to. + public let sessionID: AgentSessionID + /// The current journal, if known. + public private(set) var journalID: JournalID? + /// The advertised tail sequence. + public private(set) var tailSeq: EntrySeq + /// Loaded contiguous entry ranges. + public private(set) var loadedRanges: [EntryRange] + /// Explicit holes in the entry window. + public private(set) var holes: [EntryRange] + /// Whether the sync layer should pull the tail. + public private(set) var needsTailPull: Bool + /// Opaque cursor at the oldest loaded source boundary. + public private(set) var startCursor: JournalCursor? + /// Opaque cursor at the newest loaded source boundary. + public private(set) var endCursor: JournalCursor? + /// Opaque cursor at the server's current committed tail. + public private(set) var tailCursor: JournalCursor? + /// Whether a page exists before the loaded cursor coverage. + public private(set) var hasMoreBefore: Bool + /// Whether a page exists after the loaded cursor coverage. + public private(set) var hasMoreAfter: Bool + /// The read pointer sequence. + public private(set) var readPointer: EntrySeq + /// The last origin observed by ``apply(_:origin:)`` or ``mergePage(journal:entries:windowStart:windowEnd:tailSeq:hasMoreBefore:)``. + public private(set) var lastAppliedOrigin: DeltaOrigin? + /// Count of journal reset marker row boundaries. + public private(set) var resetMarkerCount: Int + + @ObservationIgnored private let clock: any ReplicaClock + private let windowCap: Int + private var entriesBySeq: [EntrySeq: EntrySnapshot] + private var versionsBySeq: [EntrySeq: EntityVersion] + private var ticketLedger: TicketLedgerClient + private var asksByID: [String: PendingAsk] + private var hasMoreBeforeWindow: Bool + private var retentionEdge: ConversationPageRetentionEdge + private var usesCursorPaging: Bool + private var cursorPageSegments: [CursorPageSegment] + + /// Creates a conversation replica. + /// - Parameters: + /// - sessionID: The owning session identifier. + /// - journalID: The initial journal, if known. + /// - tailSeq: The initial advertised tail sequence. + /// - readPointer: The retained read pointer. + /// - windowCap: The maximum loaded entry count, defaulting to 600. + /// - clock: The deterministic clock used by store-owned timing decisions. + public init( + sessionID: AgentSessionID, + journalID: JournalID? = nil, + tailSeq: EntrySeq = EntrySeq(rawValue: 0), + readPointer: EntrySeq = EntrySeq(rawValue: 0), + windowCap: Int = 600, + usesCursorPaging: Bool = false, + clock: any ReplicaClock + ) { + self.sessionID = sessionID + self.journalID = journalID + self.tailSeq = tailSeq + self.readPointer = readPointer + self.windowCap = max(1, windowCap) + self.usesCursorPaging = usesCursorPaging + self.clock = clock + loadedRanges = [] + holes = [] + needsTailPull = false + startCursor = nil + endCursor = nil + tailCursor = nil + hasMoreBefore = false + hasMoreAfter = false + lastAppliedOrigin = nil + resetMarkerCount = 0 + entriesBySeq = [:] + versionsBySeq = [:] + ticketLedger = TicketLedgerClient() + asksByID = [:] + hasMoreBeforeWindow = false + retentionEdge = .newest + cursorPageSegments = [] + _ = clock.tick() + } + + /// Loaded entries sorted by sequence. + public var entries: [EntrySnapshot] { + entriesBySeq.values.sorted { $0.seq < $1.seq } + } + + /// FIFO send tickets. + public var sendTickets: [SendTicket] { + ticketLedger.tickets + } + + /// Pending asks sorted by identifier. + public var asks: [PendingAsk] { + asksByID.values.sorted { $0.id < $1.id } + } + + /// Count of illegal ticket transitions dropped by the ledger. + public var illegalTicketTransitionCount: Int { + ticketLedger.illegalTransitionCount + } + + /// The derived unread count, exact only when ``unreadIsExact`` is true. + public var unreadCount: Int { + if !usesCursorPaging { + let upper = unreadExactUpperBound() + return max(0, upper.rawValue - readPointer.rawValue) + } + return entriesBySeq.keys.count { $0 > readPointer } + } + + /// Whether ``unreadCount`` is exact rather than paused by a hole. + public var unreadIsExact: Bool { + if !usesCursorPaging { + return firstHoleAfterReadPointer() == nil + } + guard hasMoreBefore, let first = entriesBySeq.keys.min() else { return true } + return readPointer >= first + } + + /// Captures value state for deterministic tests and replay comparisons. + public var state: ConversationReplicaState { + ConversationReplicaState( + journalID: journalID, + tailSeq: tailSeq, + entries: entries, + loadedRanges: loadedRanges, + holes: holes, + needsTailPull: needsTailPull, + readPointer: readPointer, + unreadCount: unreadCount, + unreadIsExact: unreadIsExact, + sendTickets: sendTickets, + asks: asks, + resetMarkerCount: resetMarkerCount, + illegalTicketTransitionCount: illegalTicketTransitionCount + ) + } + + /// Applies one conversation-relevant delta. + /// - Parameters: + /// - delta: The incoming mutation. + /// - origin: The mutation origin to expose to observers. + public func apply(_ delta: ReplicaDelta, origin: DeltaOrigin) { + let didApply: Bool + switch delta { + case .entriesAppended(let journalID, let entries): + didApply = applyAppend(journalID: journalID, entries: entries) + case .entryReplaced(let entry): + didApply = applyReplacement(entry) + case .journalReset(let sessionID, let newJournal, let tailSeq): + guard sessionID == self.sessionID else { + return + } + reset(to: newJournal, tailSeq: tailSeq) + didApply = true + case .sendTicketChanged(let ticket): + guard ticket.sessionID == sessionID else { + return + } + didApply = ticketLedger.apply(ticket) + case .askChanged(let ask): + guard ask.sessionID == sessionID else { + return + } + asksByID[ask.id] = ask + didApply = true + default: + return + } + if didApply { + lastAppliedOrigin = origin + } + } + + /// Merges a pulled page into the loaded entry window. + /// - Parameters: + /// - journal: The journal the page belongs to. + /// - entries: Entries in the pulled page. + /// - windowStart: The inclusive page window start. + /// - windowEnd: The inclusive page window end. + /// - tailSeq: The advertised tail sequence. + /// - hasMoreBefore: Whether older entries exist before the page. + /// - retaining: The edge to preserve if the merged window exceeds its cap. + public func mergePage( + journal: JournalID, + entries: [EntrySnapshot], + windowStart: EntrySeq, + windowEnd: EntrySeq, + tailSeq: EntrySeq, + hasMoreBefore: Bool, + hasMoreAfter: Bool = false, + startCursor: JournalCursor? = nil, + endCursor: JournalCursor? = nil, + tailCursor: JournalCursor? = nil, + requiresPagingRestart: Bool = false, + replacingWindow: Bool = false, + retaining requestedEdge: ConversationPageRetentionEdge = .automatic + ) { + if startCursor != nil || endCursor != nil || tailCursor != nil { + usesCursorPaging = true + } + if !usesCursorPaging { + mergeLegacyPage( + journal: journal, + entries: entries, + windowStart: windowStart, + windowEnd: windowEnd, + tailSeq: tailSeq, + hasMoreBefore: hasMoreBefore, + retaining: requestedEdge + ) + return + } + lastAppliedOrigin = .resync + let previousRange = loadedRanges.first.map { first in + (lower: first.lowerBound, upper: loadedRanges.last?.upperBound ?? first.upperBound) + } + if journalID != journal || requiresPagingRestart || replacingWindow { + journalID = journal + entriesBySeq.removeAll() + versionsBySeq.removeAll() + loadedRanges.removeAll() + holes.removeAll() + self.startCursor = nil + self.endCursor = nil + self.hasMoreBefore = false + self.hasMoreAfter = false + cursorPageSegments.removeAll() + } + retentionEdge = resolvedRetentionEdge( + requestedEdge, + pageStart: windowStart, + pageEnd: windowEnd, + previousRange: previousRange + ) + self.tailSeq = tailSeq + self.tailCursor = tailCursor ?? self.tailCursor + + for entry in entries where entry.journalID == journal { + applyEntryValue(entry) + } + registerCursorPage( + entries: entries, + startCursor: startCursor, + endCursor: endCursor, + hasMoreBefore: hasMoreBefore, + hasMoreAfter: hasMoreAfter, + retaining: retentionEdge + ) + enforceCursorWindowCap(retaining: retentionEdge) + refreshCursorCoverage() + rebuildLoadedRangesFromEntries() + recomputeHoles() + // Cursor coverage is normal pagination state, not a repair gap. A + // successful middle page must remain anchored until the caller asks + // for another page; live malformed appends and resets set repair + // urgency through their dedicated paths. + needsTailPull = false + } + + /// Marks entries through a sequence as read. + /// - Parameter seq: The highest read sequence. + public func markReadThrough(_ seq: EntrySeq) { + if seq > readPointer { + readPointer = seq + } + } + + /// Applies the epoch-change drop rule for a conversation. + /// - Parameter epoch: The new Mac app epoch. + public func handleEpochChange(to epoch: ReplicaEpoch) { + _ = epoch + journalID = nil + tailSeq = EntrySeq(rawValue: 0) + loadedRanges.removeAll() + holes.removeAll() + needsTailPull = false + entriesBySeq.removeAll() + versionsBySeq.removeAll() + asksByID.removeAll() + resetMarkerCount = 0 + hasMoreBeforeWindow = false + startCursor = nil + endCursor = nil + tailCursor = nil + hasMoreBefore = false + hasMoreAfter = false + cursorPageSegments.removeAll() + retentionEdge = .newest + lastAppliedOrigin = .resync + } + + private func applyAppend(journalID: JournalID, entries: [EntrySnapshot]) -> Bool { + if !usesCursorPaging { + return applyLegacyAppend(journalID: journalID, entries: entries) + } + guard self.journalID == journalID else { + return false + } + guard !entries.isEmpty else { + return false + } + var previousSeq: EntrySeq? + var malformedOrder = false + for entry in entries { + guard entry.journalID == journalID else { + malformedOrder = true + continue + } + if let previousSeq, entry.seq <= previousSeq { + malformedOrder = true + } + applyEntryValue(entry) + previousSeq = entry.seq + } + if let lastSeq = entries.map(\.seq).max() { + tailSeq = max(tailSeq, lastSeq) + } + if retentionEdge == .oldest { + hasMoreAfter = true + } + enforceCursorWindowCap(retaining: retentionEdge) + refreshCursorCoverage() + rebuildLoadedRangesFromEntries() + recomputeHoles() + needsTailPull = malformedOrder + return true + } + + private func mergeLegacyPage( + journal: JournalID, + entries: [EntrySnapshot], + windowStart: EntrySeq, + windowEnd: EntrySeq, + tailSeq: EntrySeq, + hasMoreBefore: Bool, + retaining requestedEdge: ConversationPageRetentionEdge + ) { + lastAppliedOrigin = .resync + let previousRange = loadedRanges.first.map { first in + (lower: first.lowerBound, upper: loadedRanges.last?.upperBound ?? first.upperBound) + } + if journalID != journal { + journalID = journal + entriesBySeq.removeAll() + versionsBySeq.removeAll() + loadedRanges.removeAll() + holes.removeAll() + } + retentionEdge = resolvedRetentionEdge( + requestedEdge, + pageStart: windowStart, + pageEnd: windowEnd, + previousRange: previousRange + ) + self.tailSeq = tailSeq + hasMoreBeforeWindow = hasMoreBeforeWindow || hasMoreBefore + self.hasMoreBefore = hasMoreBeforeWindow + if windowStart.rawValue <= windowEnd.rawValue { + loadedRanges.append(EntryRange(lowerBound: windowStart, upperBound: windowEnd)) + loadedRanges = Self.coalesced(loadedRanges) + } + for entry in entries where entry.journalID == journal { + applyEntryValue(entry) + } + enforceWindowCap(retaining: retentionEdge) + recomputeHoles() + needsTailPull = hasRepairableHole + } + + private func applyLegacyAppend(journalID: JournalID, entries: [EntrySnapshot]) -> Bool { + guard self.journalID == journalID, let first = entries.first else { + return false + } + if first.seq.rawValue <= tailSeq.rawValue { + return false + } + guard first.seq.rawValue == tailSeq.rawValue + 1 else { + let lastSeq = entries[entries.count - 1].seq + tailSeq = max(tailSeq, lastSeq) + recomputeHoles() + needsTailPull = true + return true + } + var expected = first.seq.rawValue + for entry in entries { + guard entry.journalID == journalID, entry.seq.rawValue == expected else { + needsTailPull = true + return true + } + applyEntryValue(entry) + expected += 1 + } + let lastSeq = entries[entries.count - 1].seq + loadedRanges.append(EntryRange(lowerBound: first.seq, upperBound: lastSeq)) + loadedRanges = Self.coalesced(loadedRanges) + tailSeq = lastSeq + enforceWindowCap(retaining: retentionEdge) + recomputeHoles() + return true + } + + private func applyReplacement(_ entry: EntrySnapshot) -> Bool { + guard entry.journalID == journalID else { + return false + } + let isLoaded = usesCursorPaging + ? entriesBySeq[entry.seq] != nil + : loadedRanges.contains(where: { $0.contains(entry.seq) }) + guard isLoaded else { + return false + } + return applyEntryValue(entry) + } + + private func reset(to newJournal: JournalID, tailSeq: EntrySeq) { + journalID = newJournal + self.tailSeq = tailSeq + entriesBySeq.removeAll() + versionsBySeq.removeAll() + loadedRanges.removeAll() + holes.removeAll() + resetMarkerCount += 1 + hasMoreBeforeWindow = tailSeq.rawValue > 0 + startCursor = nil + endCursor = nil + tailCursor = nil + hasMoreBefore = tailSeq.rawValue > 0 + hasMoreAfter = false + cursorPageSegments.removeAll() + retentionEdge = .newest + recomputeHoles() + needsTailPull = tailSeq.rawValue > 0 + } + + @discardableResult + private func applyEntryValue(_ entry: EntrySnapshot) -> Bool { + guard versionsBySeq[entry.seq].map({ entry.version > $0 }) ?? true else { + return false + } + entriesBySeq[entry.seq] = entry + versionsBySeq[entry.seq] = entry.version + return true + } + + private func enforceWindowCap(retaining edge: ConversationPageRetentionEdge) { + let sorted = entries + guard sorted.count > windowCap else { + return + } + let removeCount = sorted.count - windowCap + let removed = edge == .oldest + ? sorted.suffix(removeCount) + : sorted.prefix(removeCount) + for entry in removed { + entriesBySeq[entry.seq] = nil + versionsBySeq[entry.seq] = nil + } + if edge != .oldest { + hasMoreBeforeWindow = true + } + rebuildLoadedRangesFromEntries() + } + + private func registerCursorPage( + entries: [EntrySnapshot], + startCursor: JournalCursor?, + endCursor: JournalCursor?, + hasMoreBefore: Bool, + hasMoreAfter: Bool, + retaining edge: ConversationPageRetentionEdge + ) { + guard let startCursor, let endCursor else { return } + cursorPageSegments.removeAll { + $0.startCursor == startCursor && $0.endCursor == endCursor + } + if entries.isEmpty, !cursorPageSegments.isEmpty { + if edge == .oldest { + cursorPageSegments[0].startCursor = startCursor + cursorPageSegments[0].hasMoreBefore = hasMoreBefore + } else { + let lastIndex = cursorPageSegments.index(before: cursorPageSegments.endIndex) + cursorPageSegments[lastIndex].endCursor = endCursor + cursorPageSegments[lastIndex].hasMoreAfter = hasMoreAfter + } + return + } + let segment = CursorPageSegment( + sequenceValues: Set(entries.map(\.seq)), + startCursor: startCursor, + endCursor: endCursor, + hasMoreBefore: hasMoreBefore, + hasMoreAfter: hasMoreAfter + ) + if edge == .oldest { + cursorPageSegments.insert(segment, at: 0) + } else { + cursorPageSegments.append(segment) + } + } + + private func enforceCursorWindowCap(retaining edge: ConversationPageRetentionEdge) { + if edge == .oldest, entriesBySeq.count > windowCap { + let segmented = Set(cursorPageSegments.flatMap(\.sequenceValues)) + let unsegmentedNewest = entries + .filter { !segmented.contains($0.seq) } + .sorted { $0.seq > $1.seq } + for entry in unsegmentedNewest where entriesBySeq.count > windowCap { + entriesBySeq[entry.seq] = nil + versionsBySeq[entry.seq] = nil + } + } + + while entriesBySeq.count > windowCap, cursorPageSegments.count > 1 { + let removed = edge == .oldest + ? cursorPageSegments.removeLast() + : cursorPageSegments.removeFirst() + let stillRetained = Set(cursorPageSegments.flatMap(\.sequenceValues)) + for seq in removed.sequenceValues where !stillRetained.contains(seq) { + entriesBySeq[seq] = nil + versionsBySeq[seq] = nil + } + } + + if entriesBySeq.count > windowCap { + let segmented = Set(cursorPageSegments.flatMap(\.sequenceValues)) + let removable = entries + .filter { !segmented.contains($0.seq) } + .sorted { edge == .oldest ? $0.seq > $1.seq : $0.seq < $1.seq } + for entry in removable where entriesBySeq.count > windowCap { + entriesBySeq[entry.seq] = nil + versionsBySeq[entry.seq] = nil + } + } + + // A malformed or future server may send one cursor segment larger + // than the negotiated window. Keep the requested edge bounded even + // when there is no whole neighboring segment to evict. + if entriesBySeq.count > windowCap { + let excess = entriesBySeq.count - windowCap + let ordered = entriesBySeq.keys.sorted() + let removed = edge == .oldest + ? ordered.suffix(excess) + : ordered.prefix(excess) + for seq in removed { + entriesBySeq[seq] = nil + versionsBySeq[seq] = nil + for index in cursorPageSegments.indices { + cursorPageSegments[index].sequenceValues.remove(seq) + } + } + } + } + + private func refreshCursorCoverage() { + guard let first = cursorPageSegments.first, let last = cursorPageSegments.last else { + startCursor = nil + endCursor = nil + hasMoreBefore = false + hasMoreAfter = false + hasMoreBeforeWindow = false + return + } + startCursor = first.startCursor + endCursor = last.endCursor + hasMoreBefore = first.hasMoreBefore + hasMoreAfter = last.hasMoreAfter + hasMoreBeforeWindow = hasMoreBefore + } + + private func resolvedRetentionEdge( + _ requested: ConversationPageRetentionEdge, + pageStart: EntrySeq, + pageEnd: EntrySeq, + previousRange: (lower: EntrySeq, upper: EntrySeq)? + ) -> ConversationPageRetentionEdge { + guard requested == .automatic else { return requested } + guard let previousRange else { return .newest } + if pageEnd < previousRange.lower { + return .oldest + } + if pageStart > previousRange.upper { + return .newest + } + if pageStart < previousRange.lower, pageEnd < previousRange.upper { + return .oldest + } + return retentionEdge == .automatic ? .newest : retentionEdge + } + + private func rebuildLoadedRangesFromEntries() { + let seqs = entries.map(\.seq.rawValue).sorted() + loadedRanges.removeAll() + if !usesCursorPaging { + guard var start = seqs.first else { return } + var previous = start + for seq in seqs.dropFirst() { + if seq == previous + 1 { + previous = seq + } else { + loadedRanges.append(EntryRange( + lowerBound: EntrySeq(rawValue: start), + upperBound: EntrySeq(rawValue: previous) + )) + start = seq + previous = seq + } + } + loadedRanges.append(EntryRange( + lowerBound: EntrySeq(rawValue: start), + upperBound: EntrySeq(rawValue: previous) + )) + return + } + guard let first = seqs.first, let last = seqs.last else { return } + loadedRanges.append(EntryRange( + lowerBound: EntrySeq(rawValue: first), + upperBound: EntrySeq(rawValue: last) + )) + } + + private func recomputeHoles() { + guard usesCursorPaging else { + var nextHoles: [EntryRange] = [] + let ranges = Self.coalesced(loadedRanges) + if let first = ranges.first, hasMoreBeforeWindow, first.lowerBound.rawValue > 1 { + nextHoles.append(EntryRange( + lowerBound: EntrySeq(rawValue: 1), + upperBound: EntrySeq(rawValue: first.lowerBound.rawValue - 1) + )) + } + for pair in zip(ranges, ranges.dropFirst()) { + let start = pair.0.upperBound.rawValue + 1 + let end = pair.1.lowerBound.rawValue - 1 + if start <= end { + nextHoles.append(EntryRange( + lowerBound: EntrySeq(rawValue: start), + upperBound: EntrySeq(rawValue: end) + )) + } + } + if let last = ranges.last { + let start = last.upperBound.rawValue + 1 + if start <= tailSeq.rawValue { + nextHoles.append(EntryRange(lowerBound: EntrySeq(rawValue: start), upperBound: tailSeq)) + } + } else if tailSeq.rawValue > 0 { + nextHoles.append(EntryRange(lowerBound: EntrySeq(rawValue: 1), upperBound: tailSeq)) + } + holes = Self.coalesced(nextHoles) + return + } + holes = [] + } + + private var hasRepairableHole: Bool { + guard let firstLoaded = loadedRanges.first?.lowerBound else { + return !holes.isEmpty + } + return holes.contains { hole in + let isPageablePrefix = hasMoreBeforeWindow + && hole.lowerBound.rawValue == 1 + && hole.upperBound.rawValue < firstLoaded.rawValue + return !isPageablePrefix + } + } + + private func firstHoleAfterReadPointer() -> EntryRange? { + holes.first { range in + range.upperBound.rawValue > readPointer.rawValue && range.lowerBound.rawValue <= tailSeq.rawValue + } + } + + private func unreadExactUpperBound() -> EntrySeq { + guard let hole = firstHoleAfterReadPointer() else { + return tailSeq + } + return EntrySeq(rawValue: max(readPointer.rawValue, hole.lowerBound.rawValue - 1)) + } + + private static func coalesced(_ ranges: [EntryRange]) -> [EntryRange] { + let sorted = ranges.sorted { + if $0.lowerBound != $1.lowerBound { + return $0.lowerBound < $1.lowerBound + } + return $0.upperBound < $1.upperBound + } + var output: [EntryRange] = [] + for range in sorted where range.lowerBound.rawValue <= range.upperBound.rawValue { + guard let last = output.last else { + output.append(range) + continue + } + if range.lowerBound.rawValue <= last.upperBound.rawValue + 1 { + output[output.count - 1] = EntryRange( + lowerBound: last.lowerBound, + upperBound: max(last.upperBound, range.upperBound) + ) + } else { + output.append(range) + } + } + return output + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Store/ConversationReplicaState.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Store/ConversationReplicaState.swift new file mode 100644 index 000000000000..80a525050a04 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Store/ConversationReplicaState.swift @@ -0,0 +1,76 @@ +import Foundation + +/// Captures the observable value state of a conversation replica for replay comparisons. +public struct ConversationReplicaState: Codable, Hashable, Sendable { + /// The current journal, if known. + public let journalID: JournalID? + /// The advertised tail sequence. + public let tailSeq: EntrySeq + /// Loaded entries in ascending sequence order. + public let entries: [EntrySnapshot] + /// Loaded contiguous ranges. + public let loadedRanges: [EntryRange] + /// Explicit holes in the loaded window. + public let holes: [EntryRange] + /// Whether the sync layer should pull the tail. + public let needsTailPull: Bool + /// The read pointer sequence. + public let readPointer: EntrySeq + /// The derived unread count when exact. + public let unreadCount: Int + /// Whether ``unreadCount`` is exact. + public let unreadIsExact: Bool + /// FIFO send tickets. + public let sendTickets: [SendTicket] + /// Pending asks in stable order. + public let asks: [PendingAsk] + /// Count of journal reset marker boundaries. + public let resetMarkerCount: Int + /// Count of illegal ticket transitions. + public let illegalTicketTransitionCount: Int + + /// Creates a conversation state snapshot. + /// - Parameters: + /// - journalID: The current journal. + /// - tailSeq: The advertised tail sequence. + /// - entries: Loaded entries. + /// - loadedRanges: Loaded contiguous ranges. + /// - holes: Explicit holes. + /// - needsTailPull: Whether tail pull is needed. + /// - readPointer: The read pointer. + /// - unreadCount: The unread count. + /// - unreadIsExact: Whether the unread count is exact. + /// - sendTickets: FIFO send tickets. + /// - asks: Pending asks. + /// - resetMarkerCount: Journal reset marker count. + /// - illegalTicketTransitionCount: Illegal ticket transition count. + public init( + journalID: JournalID?, + tailSeq: EntrySeq, + entries: [EntrySnapshot], + loadedRanges: [EntryRange], + holes: [EntryRange], + needsTailPull: Bool, + readPointer: EntrySeq, + unreadCount: Int, + unreadIsExact: Bool, + sendTickets: [SendTicket], + asks: [PendingAsk], + resetMarkerCount: Int, + illegalTicketTransitionCount: Int + ) { + self.journalID = journalID + self.tailSeq = tailSeq + self.entries = entries + self.loadedRanges = loadedRanges + self.holes = holes + self.needsTailPull = needsTailPull + self.readPointer = readPointer + self.unreadCount = unreadCount + self.unreadIsExact = unreadIsExact + self.sendTickets = sendTickets + self.asks = asks + self.resetMarkerCount = resetMarkerCount + self.illegalTicketTransitionCount = illegalTicketTransitionCount + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Store/EntryRange.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Store/EntryRange.swift new file mode 100644 index 000000000000..1781576b2d72 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Store/EntryRange.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Describes an inclusive range of journal entry sequences. +public struct EntryRange: Codable, Hashable, Sendable { + /// The inclusive lower bound. + public let lowerBound: EntrySeq + /// The inclusive upper bound. + public let upperBound: EntrySeq + + /// Creates an inclusive entry range. + /// - Parameters: + /// - lowerBound: The inclusive lower bound. + /// - upperBound: The inclusive upper bound. + public init(lowerBound: EntrySeq, upperBound: EntrySeq) { + self.lowerBound = lowerBound + self.upperBound = upperBound + } + + /// Returns whether the range contains a sequence. + /// - Parameter seq: The sequence to test. + /// - Returns: Whether `seq` is inside the range. + public func contains(_ seq: EntrySeq) -> Bool { + lowerBound.rawValue <= seq.rawValue && seq.rawValue <= upperBound.rawValue + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Store/ManualReplicaClock.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Store/ManualReplicaClock.swift new file mode 100644 index 000000000000..c9b3d6cd0f6b --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Store/ManualReplicaClock.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Provides a manually advanced deterministic replica clock for tests and previews. +public final class ManualReplicaClock: ReplicaClock { + /// The current tick returned by ``tick()``. + public var currentTick: Int + + /// Creates a manual clock. + /// - Parameter currentTick: The initial tick returned by ``tick()``. + public init(currentTick: Int) { + self.currentTick = currentTick + } + + /// Returns the configured tick. + /// - Returns: The configured integer tick. + public func tick() -> Int { + currentTick + } + + /// Advances the clock by a deterministic number of ticks. + /// - Parameter amount: The number of ticks to add. + public func advance(by amount: Int = 1) { + currentTick += amount + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Store/ReplicaClock.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Store/ReplicaClock.swift new file mode 100644 index 000000000000..b42ecb47dfa4 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Store/ReplicaClock.swift @@ -0,0 +1,8 @@ +import Foundation + +/// Supplies deterministic display ticks to replica stores. +public protocol ReplicaClock: AnyObject { + /// Returns the current display tick. + /// - Returns: A deterministic integer tick. + func tick() -> Int +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Store/SessionDirectoryReplica.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Store/SessionDirectoryReplica.swift new file mode 100644 index 000000000000..fb9817c2b669 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Store/SessionDirectoryReplica.swift @@ -0,0 +1,102 @@ +import Foundation +public import Observation + +/// Maintains replicated session metadata for one Mac. +@MainActor @Observable public final class SessionDirectoryReplica { + /// The current epoch for the directory. + public private(set) var epoch: ReplicaEpoch? + /// The last origin observed by ``apply(_:origin:)`` or ``replaceAll(_:epoch:)``. + public private(set) var lastAppliedOrigin: DeltaOrigin? + + private var snapshotsByID: [AgentSessionID: AgentSessionSnapshot] + private var versionsByID: [AgentSessionID: EntityVersion] + + /// Creates an empty session directory replica. + /// - Parameter epoch: The initial epoch, if known. + public init(epoch: ReplicaEpoch? = nil) { + self.epoch = epoch + snapshotsByID = [:] + versionsByID = [:] + } + + /// Sessions sorted for display by urgency and recency hint. + public var sessions: [AgentSessionSnapshot] { + snapshotsByID.values.sorted { lhs, rhs in + let leftRank = Self.phaseRank(lhs.phase) + let rightRank = Self.phaseRank(rhs.phase) + if leftRank != rightRank { + return leftRank < rightRank + } + if lhs.lastActivityHint != rhs.lastActivityHint { + return lhs.lastActivityHint > rhs.lastActivityHint + } + return lhs.id.rawValue < rhs.id.rawValue + } + } + + /// Applies one directory-relevant delta using entity-version gating. + /// - Parameters: + /// - delta: The incoming mutation. + /// - origin: The mutation origin to expose to observers. + public func apply(_ delta: ReplicaDelta, origin: DeltaOrigin) { + switch delta { + case .sessionUpserted(let snapshot): + guard shouldApply(id: snapshot.id, version: snapshot.version) else { + return + } + snapshotsByID[snapshot.id] = snapshot + versionsByID[snapshot.id] = snapshot.version + lastAppliedOrigin = origin + case .sessionRemoved(let id, let version): + guard shouldApply(id: id, version: version) else { + return + } + snapshotsByID[id] = nil + versionsByID[id] = version + lastAppliedOrigin = origin + default: + return + } + } + + /// Replaces the directory from a pull and resets version gates. + /// - Parameters: + /// - snapshots: The pulled session snapshots. + /// - epoch: The epoch that produced the pull. + public func replaceAll(_ snapshots: [AgentSessionSnapshot], epoch: ReplicaEpoch) { + self.epoch = epoch + lastAppliedOrigin = .resync + snapshotsByID = Dictionary(snapshots.map { ($0.id, $0) }, uniquingKeysWith: { existing, incoming in + incoming.version > existing.version ? incoming : existing + }) + versionsByID = snapshotsByID.mapValues(\.version) + } + + /// Applies the epoch-change drop rule for session metadata. + /// - Parameter epoch: The new Mac app epoch. + public func handleEpochChange(to epoch: ReplicaEpoch) { + guard self.epoch != epoch else { + return + } + self.epoch = epoch + snapshotsByID.removeAll() + versionsByID.removeAll() + lastAppliedOrigin = .resync + } + + private func shouldApply(id: AgentSessionID, version: EntityVersion) -> Bool { + guard let previous = versionsByID[id] else { + return true + } + return version > previous + } + + private static func phaseRank(_ phase: SessionPhase) -> Int { + switch phase { + case .needsInput: 0 + case .working: 1 + case .idle, .starting, .unknown: 2 + case .ended: 3 + } + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Store/TicketLedgerClient.swift b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Store/TicketLedgerClient.swift new file mode 100644 index 000000000000..d7ba69a20a1d --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Sources/CmuxAgentReplica/Store/TicketLedgerClient.swift @@ -0,0 +1,97 @@ +import Foundation + +/// Applies FIFO send-ticket updates with legal transition enforcement. +public struct TicketLedgerClient: Codable, Hashable, Sendable { + static let resolvedRetentionLimit = 256 + /// Tickets in FIFO order. + public private(set) var tickets: [SendTicket] + /// Count of illegal transition attempts that were dropped. + public private(set) var illegalTransitionCount: Int + + /// Creates a ticket ledger. + /// - Parameters: + /// - tickets: Initial FIFO tickets. + /// - illegalTransitionCount: Initial illegal transition count. + public init(tickets: [SendTicket] = [], illegalTransitionCount: Int = 0) { + self.tickets = tickets.sorted { $0.createdAt < $1.createdAt } + self.illegalTransitionCount = illegalTransitionCount + } + + /// Applies a ticket update if it is legal. + /// - Parameter ticket: The incoming ticket update. + /// - Returns: Whether the ledger changed. + @discardableResult + public mutating func apply(_ ticket: SendTicket) -> Bool { + guard let index = updateIndex(for: ticket) else { + tickets.append(ticket) + tickets.sort { $0.createdAt < $1.createdAt } + enforceResolvedRetentionLimit() + return true + } + + let current = tickets[index] + guard isLegalTransition(from: current.state, to: ticket.state) else { + illegalTransitionCount += 1 + return false + } + + tickets[index] = ticket + tickets.sort { $0.createdAt < $1.createdAt } + enforceResolvedRetentionLimit() + return true + } + + /// Tests whether a state transition is legal. + /// - Parameters: + /// - current: The current ticket state. + /// - next: The proposed ticket state. + /// - Returns: Whether the transition may be applied. + public func isLegalTransition(from current: SendTicketState, to next: SendTicketState) -> Bool { + if current == next { + return true + } + return switch (current, next) { + case (.queuedLocal, .acceptedByMac), + (.queuedLocal, .injected), + (.queuedLocal, .echoed), + (.queuedLocal, .failed), + (.queuedLocal, .unconfirmed), + (.acceptedByMac, .injected), + (.acceptedByMac, .echoed), + (.acceptedByMac, .failed), + (.acceptedByMac, .unconfirmed), + (.injected, .echoed), + (.injected, .failed), + (.injected, .unconfirmed), + (.unconfirmed, .acceptedByMac), + (.unconfirmed, .injected), + (.unconfirmed, .echoed), + (.unconfirmed, .failed), + (.failed, .queuedLocal): + true + default: + false + } + } + + private func updateIndex(for ticket: SendTicket) -> Int? { + if case .echoed = ticket.state { + if let unresolvedIndex = tickets.firstIndex(where: { existing in + existing.id == ticket.id && !existing.state.isResolved + }) { + return unresolvedIndex + } + } + return tickets.firstIndex { $0.id == ticket.id } + } + + private mutating func enforceResolvedRetentionLimit() { + var resolvedCount = tickets.count { $0.state.isResolved } + guard resolvedCount > Self.resolvedRetentionLimit else { return } + tickets.removeAll { ticket in + guard resolvedCount > Self.resolvedRetentionLimit, ticket.state.isResolved else { return false } + resolvedCount -= 1 + return true + } + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/ConversationReplicaTests.swift b/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/ConversationReplicaTests.swift new file mode 100644 index 000000000000..36046d1d6e3d --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/ConversationReplicaTests.swift @@ -0,0 +1,319 @@ +import Foundation +import Testing +@testable import CmuxAgentReplica + +@MainActor @Suite struct ConversationReplicaTests { + @Test func appendRequiresContiguousTailAndDuplicateIsDropped() { + let store = ConversationReplica(sessionID: ReplicaTestSupport.session, journalID: ReplicaTestSupport.journal, clock: ReplicaTestSupport.clock()) + + store.apply(.entriesAppended(journalID: ReplicaTestSupport.journal, entries: [ReplicaTestSupport.entry(1), ReplicaTestSupport.entry(2)]), origin: .live) + #expect(store.entries.map(\.seq) == [ReplicaTestSupport.seq(1), ReplicaTestSupport.seq(2)]) + #expect(store.tailSeq == ReplicaTestSupport.seq(2)) + + store.apply(.entriesAppended(journalID: ReplicaTestSupport.journal, entries: [ReplicaTestSupport.entry(2, version: 2, hash: 200)]), origin: .resync) + #expect(store.entries.map(\.content.contentHash) == [1, 2]) + #expect(!store.needsTailPull) + #expect(store.lastAppliedOrigin == .live) + + store.apply(.entriesAppended(journalID: ReplicaTestSupport.journal, entries: [ReplicaTestSupport.entry(4)]), origin: .live) + #expect(store.entries.map(\.seq) == [ReplicaTestSupport.seq(1), ReplicaTestSupport.seq(2)]) + #expect(store.tailSeq == ReplicaTestSupport.seq(4)) + #expect(store.needsTailPull) + #expect(!store.unreadIsExact) + #expect(store.holes == [EntryRange(lowerBound: ReplicaTestSupport.seq(3), upperBound: ReplicaTestSupport.seq(4))]) + } + + @Test func entryReplacementRequiresLoadedRangeAndHigherVersion() { + let store = ConversationReplica(sessionID: ReplicaTestSupport.session, journalID: ReplicaTestSupport.journal, clock: ReplicaTestSupport.clock()) + store.apply(.entriesAppended(journalID: ReplicaTestSupport.journal, entries: [ReplicaTestSupport.entry(1, version: 2)]), origin: .live) + + store.apply(.entryReplaced(ReplicaTestSupport.entry(1, version: 1, hash: 100)), origin: .live) + #expect(store.entries.first?.content.contentHash == 1) + store.apply(.entryReplaced(ReplicaTestSupport.entry(1, version: 3, hash: 300)), origin: .live) + #expect(store.entries.first?.content.contentHash == 300) + store.apply(.entryReplaced(ReplicaTestSupport.entry(5, version: 10, hash: 500)), origin: .live) + #expect(store.entries.count == 1) + } + + @Test func journalResetDropsWindowButKeepsTicketsAndReadPointer() { + let ticketID = UUID() + let store = ConversationReplica(sessionID: ReplicaTestSupport.session, journalID: ReplicaTestSupport.journal, clock: ReplicaTestSupport.clock()) + store.apply(.entriesAppended(journalID: ReplicaTestSupport.journal, entries: [ReplicaTestSupport.entry(1), ReplicaTestSupport.entry(2)]), origin: .live) + store.markReadThrough(ReplicaTestSupport.seq(2)) + store.apply(.sendTicketChanged(ReplicaTestSupport.ticket(id: ticketID, state: .queuedLocal, createdAt: 1)), origin: .live) + + store.apply(.journalReset(sessionID: ReplicaTestSupport.session, newJournal: ReplicaTestSupport.otherJournal, tailSeq: ReplicaTestSupport.seq(5)), origin: .live) + + #expect(store.journalID == ReplicaTestSupport.otherJournal) + #expect(store.entries.isEmpty) + #expect(store.sendTickets.map(\.id) == [ticketID]) + #expect(store.readPointer == ReplicaTestSupport.seq(2)) + #expect(store.resetMarkerCount == 1) + #expect(store.needsTailPull) + #expect(store.holes == [EntryRange(lowerBound: ReplicaTestSupport.seq(1), upperBound: ReplicaTestSupport.seq(5))]) + } + + @Test func mergePageCoalescesOverlapsAndKeepsHigherVersion() { + let store = ConversationReplica(sessionID: ReplicaTestSupport.session, journalID: ReplicaTestSupport.journal, clock: ReplicaTestSupport.clock()) + store.mergePage( + journal: ReplicaTestSupport.journal, + entries: [ReplicaTestSupport.entry(3, version: 1), ReplicaTestSupport.entry(4, version: 1)], + windowStart: ReplicaTestSupport.seq(3), + windowEnd: ReplicaTestSupport.seq(4), + tailSeq: ReplicaTestSupport.seq(6), + hasMoreBefore: true + ) + store.mergePage( + journal: ReplicaTestSupport.journal, + entries: [ReplicaTestSupport.entry(2, version: 1), ReplicaTestSupport.entry(3, version: 2, hash: 30)], + windowStart: ReplicaTestSupport.seq(2), + windowEnd: ReplicaTestSupport.seq(3), + tailSeq: ReplicaTestSupport.seq(6), + hasMoreBefore: true + ) + + #expect(store.loadedRanges == [EntryRange(lowerBound: ReplicaTestSupport.seq(2), upperBound: ReplicaTestSupport.seq(4))]) + #expect(store.entries.map(\.content.contentHash) == [2, 30, 4]) + #expect(store.holes == [ + EntryRange(lowerBound: ReplicaTestSupport.seq(1), upperBound: ReplicaTestSupport.seq(1)), + EntryRange(lowerBound: ReplicaTestSupport.seq(5), upperBound: ReplicaTestSupport.seq(6)), + ]) + } + + @Test func newestPageWithOlderHistoryDoesNotRequestTheSameTailAgain() { + let store = ConversationReplica( + sessionID: ReplicaTestSupport.session, + journalID: ReplicaTestSupport.journal, + clock: ReplicaTestSupport.clock() + ) + store.mergePage( + journal: ReplicaTestSupport.journal, + entries: (51...100).map { ReplicaTestSupport.entry($0) }, + windowStart: ReplicaTestSupport.seq(51), + windowEnd: ReplicaTestSupport.seq(100), + tailSeq: ReplicaTestSupport.seq(100), + hasMoreBefore: true + ) + + #expect(store.holes == [ + EntryRange(lowerBound: ReplicaTestSupport.seq(1), upperBound: ReplicaTestSupport.seq(50)), + ]) + #expect(!store.needsTailPull) + } + + @Test func mergePageInterleavedWithDuplicateLiveAppendKeepsPulledWindow() { + let store = ConversationReplica(sessionID: ReplicaTestSupport.session, journalID: ReplicaTestSupport.journal, clock: ReplicaTestSupport.clock()) + store.mergePage( + journal: ReplicaTestSupport.journal, + entries: [ReplicaTestSupport.entry(1), ReplicaTestSupport.entry(2)], + windowStart: ReplicaTestSupport.seq(1), + windowEnd: ReplicaTestSupport.seq(2), + tailSeq: ReplicaTestSupport.seq(2), + hasMoreBefore: false + ) + + store.apply(.entriesAppended(journalID: ReplicaTestSupport.journal, entries: [ + ReplicaTestSupport.entry(1, version: 2, hash: 100), + ReplicaTestSupport.entry(2, version: 2, hash: 200), + ]), origin: .live) + + #expect(store.entries.map(\.content.contentHash) == [1, 2]) + #expect(store.tailSeq == ReplicaTestSupport.seq(2)) + #expect(store.lastAppliedOrigin == .resync) + } + + @Test func epochColdConversationIgnoresUnrelatedAppend() { + let store = ConversationReplica(sessionID: ReplicaTestSupport.session, journalID: ReplicaTestSupport.journal, clock: ReplicaTestSupport.clock()) + store.handleEpochChange(to: ReplicaEpoch(rawValue: "e2")) + + store.apply(.entriesAppended(journalID: ReplicaTestSupport.otherJournal, entries: [ + ReplicaTestSupport.entry(1, journalID: ReplicaTestSupport.otherJournal), + ]), origin: .live) + + #expect(store.journalID == nil) + #expect(store.entries.isEmpty) + #expect(store.lastAppliedOrigin == .resync) + } + + @Test func evictionReinsertsHoleAndUnreadPausesAtHole() { + let store = ConversationReplica(sessionID: ReplicaTestSupport.session, journalID: ReplicaTestSupport.journal, windowCap: 3, clock: ReplicaTestSupport.clock()) + store.apply(.entriesAppended(journalID: ReplicaTestSupport.journal, entries: [ReplicaTestSupport.entry(1), ReplicaTestSupport.entry(2), ReplicaTestSupport.entry(3), ReplicaTestSupport.entry(4), ReplicaTestSupport.entry(5)]), origin: .live) + store.markReadThrough(ReplicaTestSupport.seq(1)) + + #expect(store.entries.map(\.seq) == [ReplicaTestSupport.seq(3), ReplicaTestSupport.seq(4), ReplicaTestSupport.seq(5)]) + #expect(store.holes == [EntryRange(lowerBound: ReplicaTestSupport.seq(1), upperBound: ReplicaTestSupport.seq(2))]) + #expect(!store.unreadIsExact) + #expect(store.unreadCount == 0) + + store.markReadThrough(ReplicaTestSupport.seq(2)) + #expect(store.unreadIsExact) + #expect(store.unreadCount == 3) + } + + @Test func loadingOlderHistoryRetainsTheRequestedPageAtTheWindowCap() { + let store = ConversationReplica( + sessionID: ReplicaTestSupport.session, + journalID: ReplicaTestSupport.journal, + windowCap: 3, + clock: ReplicaTestSupport.clock() + ) + store.mergePage( + journal: ReplicaTestSupport.journal, + entries: (100...102).map { ReplicaTestSupport.entry($0) }, + windowStart: ReplicaTestSupport.seq(100), + windowEnd: ReplicaTestSupport.seq(102), + tailSeq: ReplicaTestSupport.seq(102), + hasMoreBefore: true + ) + + store.mergePage( + journal: ReplicaTestSupport.journal, + entries: (97...99).map { ReplicaTestSupport.entry($0) }, + windowStart: ReplicaTestSupport.seq(97), + windowEnd: ReplicaTestSupport.seq(99), + tailSeq: ReplicaTestSupport.seq(102), + hasMoreBefore: true + ) + + #expect(store.entries.map(\.seq) == [ + ReplicaTestSupport.seq(97), + ReplicaTestSupport.seq(98), + ReplicaTestSupport.seq(99), + ]) + #expect(store.loadedRanges == [ + EntryRange(lowerBound: ReplicaTestSupport.seq(97), upperBound: ReplicaTestSupport.seq(99)), + ]) + } + + @Test func epochChangeKeepsTicketsAndReadPointerButDropsReplicatedWindow() { + let store = ConversationReplica(sessionID: ReplicaTestSupport.session, journalID: ReplicaTestSupport.journal, clock: ReplicaTestSupport.clock()) + let ticketID = UUID() + store.apply(.entriesAppended(journalID: ReplicaTestSupport.journal, entries: [ReplicaTestSupport.entry(1)]), origin: .live) + store.markReadThrough(ReplicaTestSupport.seq(1)) + store.apply(.sendTicketChanged(ReplicaTestSupport.ticket(id: ticketID, state: .queuedLocal, createdAt: 1)), origin: .live) + store.apply(.askChanged(PendingAsk(id: "ask", sessionID: ReplicaTestSupport.session, kind: .question, promptSummary: "q", options: ["A", "B"], state: .active)), origin: .live) + + store.handleEpochChange(to: ReplicaEpoch(rawValue: "e2")) + + #expect(store.journalID == nil) + #expect(store.entries.isEmpty) + #expect(store.asks.isEmpty) + #expect(store.sendTickets.count == 1) + #expect(store.readPointer == ReplicaTestSupport.seq(1)) + } + + @Test func cursorPagingTreatsSparseByteOffsetsAsObservedEntries() { + let store = ConversationReplica( + sessionID: ReplicaTestSupport.session, + journalID: ReplicaTestSupport.journal, + clock: ReplicaTestSupport.clock() + ) + store.mergePage( + journal: ReplicaTestSupport.journal, + entries: [ReplicaTestSupport.entry(1_024), ReplicaTestSupport.entry(8_192)], + windowStart: ReplicaTestSupport.seq(1_024), + windowEnd: ReplicaTestSupport.seq(8_192), + tailSeq: ReplicaTestSupport.seq(8_192), + hasMoreBefore: true, + hasMoreAfter: false, + startCursor: JournalCursor(rawValue: "start"), + endCursor: JournalCursor(rawValue: "end"), + tailCursor: JournalCursor(rawValue: "end") + ) + + #expect(store.holes.isEmpty) + #expect(store.unreadCount == 2) + #expect(!store.unreadIsExact) + #expect(!store.needsTailPull) + + store.markReadThrough(ReplicaTestSupport.seq(1_024)) + #expect(store.unreadCount == 1) + #expect(store.unreadIsExact) + + store.apply( + .entriesAppended( + journalID: ReplicaTestSupport.journal, + entries: [ReplicaTestSupport.entry(65_536)] + ), + origin: .live + ) + #expect(store.entries.map(\.seq.rawValue) == [1_024, 8_192, 65_536]) + #expect(store.holes.isEmpty) + #expect(store.unreadCount == 2) + #expect(!store.needsTailPull) + } + + @Test func cursorSegmentsRemainAlignedPastSixHundredRowsWhenPagingBackThenForward() { + let store = ConversationReplica( + sessionID: ReplicaTestSupport.session, + journalID: ReplicaTestSupport.journal, + windowCap: 600, + clock: ReplicaTestSupport.clock() + ) + + func mergePage(_ lower: Int, _ upper: Int, retaining edge: ConversationPageRetentionEdge) { + store.mergePage( + journal: ReplicaTestSupport.journal, + entries: (lower...upper).map { ReplicaTestSupport.entry($0) }, + windowStart: ReplicaTestSupport.seq(lower), + windowEnd: ReplicaTestSupport.seq(upper), + tailSeq: ReplicaTestSupport.seq(620), + hasMoreBefore: lower > 1, + hasMoreAfter: upper < 620, + startCursor: JournalCursor(rawValue: "c\(lower - 1)"), + endCursor: JournalCursor(rawValue: "c\(upper)"), + tailCursor: JournalCursor(rawValue: "c620"), + retaining: edge + ) + } + + mergePage(601, 620, retaining: .newest) + for upper in stride(from: 600, through: 20, by: -20) { + mergePage(upper - 19, upper, retaining: .oldest) + } + + #expect(store.entries.count == 600) + #expect(store.entries.map(\.seq.rawValue) == Array(1...600)) + #expect(store.startCursor == JournalCursor(rawValue: "c0")) + #expect(store.endCursor == JournalCursor(rawValue: "c600")) + #expect(!store.hasMoreBefore) + #expect(store.hasMoreAfter) + #expect(Set(store.entries.map(\.seq)).count == store.entries.count) + + mergePage(601, 620, retaining: .newest) + + #expect(store.entries.count == 600) + #expect(store.entries.map(\.seq.rawValue) == Array(21...620)) + #expect(store.startCursor == JournalCursor(rawValue: "c20")) + #expect(store.endCursor == JournalCursor(rawValue: "c620")) + #expect(store.hasMoreBefore) + #expect(!store.hasMoreAfter) + #expect(Set(store.entries.map(\.seq)).count == store.entries.count) + } + + @Test func oversizedSingleCursorSegmentCannotExceedWindowCap() { + let store = ConversationReplica( + sessionID: ReplicaTestSupport.session, + journalID: ReplicaTestSupport.journal, + windowCap: 600, + clock: ReplicaTestSupport.clock() + ) + + store.mergePage( + journal: ReplicaTestSupport.journal, + entries: (1...700).map { ReplicaTestSupport.entry($0) }, + windowStart: ReplicaTestSupport.seq(1), + windowEnd: ReplicaTestSupport.seq(700), + tailSeq: ReplicaTestSupport.seq(700), + hasMoreBefore: false, + hasMoreAfter: false, + startCursor: JournalCursor(rawValue: "c0"), + endCursor: JournalCursor(rawValue: "c700"), + tailCursor: JournalCursor(rawValue: "c700"), + retaining: .newest + ) + + #expect(store.entries.count == 600) + #expect(store.entries.map(\.seq.rawValue) == Array(101...700)) + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/EntryPayloadTests.swift b/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/EntryPayloadTests.swift new file mode 100644 index 000000000000..52f2d51a9847 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/EntryPayloadTests.swift @@ -0,0 +1,182 @@ +import Foundation +import Testing +@testable import CmuxAgentReplica + +@Suite struct EntryPayloadTests { + @Test func allCasesRoundTripThroughTheirTaggedWireFormat() throws { + let payloads: [EntryPayload] = [ + .userMessage(UserMessagePayload(text: "Hello", attachmentCount: 2, hasImage: true)), + .agentProse(AgentProsePayload(markdown: "**Response**")), + .thought(ThoughtPayload(text: "Reasoning")), + .toolRun(ToolRunPayload( + toolName: "Bash", + argumentSummary: "echo test", + resultSummary: "test", + isTerminal: true, + exitCode: 0, + isRunning: false + )), + .fileChange(FileChangePayload(path: "/tmp/example.swift", changeKind: .edit, resultSummary: "Updated")), + .question(QuestionPayload(prompt: "Choose", options: ["A", "B"], answeredChoice: 1)), + .permission(PermissionPayload(toolName: "Bash", detail: "Run command", options: ["Allow", "Deny"])), + .status(StatusPayload(code: .compacted, detail: "Context compacted")), + .attachment(AttachmentPayload(kind: "image", summary: "Screenshot")), + .unknown(UnknownPayload(rawKind: "future_payload", summary: "Future", rawJSON: #"{"value":1}"#)), + ] + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + + for payload in payloads { + let data = try encoder.encode(payload) + let decoded = try JSONDecoder().decode(EntryPayload.self, from: data) + + #expect(decoded == payload) + } + + let attachmentData = try encoder.encode(payloads[8]) + #expect(String(decoding: attachmentData, as: UTF8.self) == #"{"attachment_kind":"image","kind":"attachment","summary":"Screenshot"}"#) + } + + @Test func stableHashIsPinnedForFixedPayload() { + let payload = EntryPayload.toolRun(ToolRunPayload( + toolName: "Bash", + argumentSummary: "echo pinned", + resultSummary: "pinned", + isTerminal: true, + exitCode: 0, + isRunning: false + )) + + #expect(payload.stableHash == 218_440_145_013_379_715) + } + + @Test func contentDecodesOldLogsWithMissingPayloadAsUnknown() throws { + let data = Data(#"{"content_hash":42}"#.utf8) + let decoded = try JSONDecoder().decode(EntryContent.self, from: data) + + #expect(decoded.contentHash == 42) + #expect(decoded.payload == .unknown(UnknownPayload(rawKind: "unknown"))) + } + + @Test func knownPayloadDecodeFailureFallsBackToUnknownWithBoundedRawJSON() throws { + let data = Data(#"{"kind":"toolRun","tool_name":12,"argument_summary":false,"extra":"value"}"#.utf8) + let decoded = try JSONDecoder().decode(EntryPayload.self, from: data) + + guard case .unknown(let payload) = decoded else { + Issue.record("Expected unknown payload") + return + } + #expect(payload.rawKind == "toolRun") + #expect(payload.rawJSON?.contains(#""tool_name":12"#) == true) + #expect(!payload.rawJSONTruncated) + } + + @Test func attachmentPayloadDecodesPreviewMetadataAliases() throws { + let data = Data(""" + { + "kind": "attachment", + "type": "image", + "fileName": "preview.png", + "path": "/tmp/preview.png", + "media_type": "image/png", + "size": 456789, + "pixel_width": 1600, + "pixel_height": 900, + "preview_aspect_ratio": 1.7777777778 + } + """.utf8) + + let decoded = try JSONDecoder().decode(EntryPayload.self, from: data) + + guard case .attachment(let payload) = decoded else { + Issue.record("Expected attachment payload") + return + } + #expect(payload.kind == "image") + #expect(payload.summary == "preview.png") + #expect(payload.displayName == "preview.png") + #expect(payload.hostPath == "/tmp/preview.png") + #expect(payload.mimeType == "image/png") + #expect(payload.byteCount == 456_789) + #expect(payload.width == 1_600) + #expect(payload.height == 900) + #expect(payload.aspectRatio == 1.7777777778) + } + + @Test func toolRunPayloadPreservesPreviewAttachmentMetadataAndDecodesLegacyPayloads() throws { + let payload = EntryPayload.toolRun(ToolRunPayload( + toolName: "view_image", + argumentSummary: "/tmp/preview.png", + isTerminal: false, + isRunning: true, + previewAttachments: [ + AttachmentPayload( + kind: "image", + summary: "preview.png", + displayName: "preview.png", + hostPath: "/tmp/preview.png", + mimeType: "image/png", + byteCount: 68, + width: 1, + height: 1, + authorRole: "agent" + ), + ] + )) + let decoded = try JSONDecoder().decode(EntryPayload.self, from: try JSONEncoder().encode(payload)) + + guard case .toolRun(let tool) = decoded else { + Issue.record("Expected tool run payload") + return + } + #expect(tool.previewAttachments?.count == 1) + #expect(tool.previewAttachments?.first?.hostPath == "/tmp/preview.png") + #expect(tool.previewAttachments?.first?.width == 1) + #expect(tool.previewAttachments?.first?.height == 1) + + let legacy = Data(#"{"kind":"toolRun","tool_name":"Read","argument_summary":"file","is_terminal":false,"is_running":false}"#.utf8) + guard case .toolRun(let legacyTool) = try JSONDecoder().decode(EntryPayload.self, from: legacy) else { + Issue.record("Expected legacy tool run payload") + return + } + #expect(legacyTool.previewAttachments == nil) + } + + @Test func unknownSentinelRoundTripsWithoutRawJSONDrift() throws { + let payload = EntryPayload.unknown(UnknownPayload( + rawKind: "unknown", + summary: "Unclassified", + rawJSON: #"{"source":"fixture"}"# + )) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + + let firstEncoding = try encoder.encode(payload) + let firstDecode = try JSONDecoder().decode(EntryPayload.self, from: firstEncoding) + let secondEncoding = try encoder.encode(firstDecode) + let secondDecode = try JSONDecoder().decode(EntryPayload.self, from: secondEncoding) + + #expect(firstDecode == payload) + #expect(secondDecode == payload) + #expect(secondEncoding == firstEncoding) + #expect(secondDecode.stableHash == payload.stableHash) + } + + @Test func nonObjectPayloadDecodesDirectlyAsUnknown() throws { + let data = Data(#""unstructured payload""#.utf8) + let decoded = try JSONDecoder().decode(EntryPayload.self, from: data) + + #expect(decoded == .unknown(UnknownPayload( + rawKind: "unknown", + rawJSON: #""unstructured payload""# + ))) + } + + @Test func unknownPayloadRawJSONIsCappedAtEightKilobytes() { + let raw = String(repeating: "x", count: UnknownPayload.rawJSONByteLimit + 32) + let payload = UnknownPayload(rawKind: "future", rawJSON: raw) + + #expect(payload.rawJSON?.utf8.count == UnknownPayload.rawJSONByteLimit) + #expect(payload.rawJSONTruncated) + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/OpenEnumTests.swift b/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/OpenEnumTests.swift new file mode 100644 index 000000000000..438f58d35027 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/OpenEnumTests.swift @@ -0,0 +1,14 @@ +import Foundation +import Testing +@testable import CmuxAgentReplica + +@Suite struct OpenEnumTests { + @Test func agentAndEntryKindsDecodeFailOpen() throws { + let decoder = JSONDecoder() + let agent = try decoder.decode(AgentKind.self, from: Data(#""future-agent""#.utf8)) + let entry = try decoder.decode(EntryKind.self, from: Data(#""future-entry""#.utf8)) + + #expect(agent == .unknown("future-agent")) + #expect(entry == .unknown("future-entry")) + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/ReplayTests.swift b/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/ReplayTests.swift new file mode 100644 index 000000000000..f70d6b62a7b4 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/ReplayTests.swift @@ -0,0 +1,129 @@ +import Foundation +import Testing +@testable import CmuxAgentReplica + +@MainActor @Suite struct ReplayTests { + @Test func replayRoundTripEncodeDecodeProducesIdenticalStoreState() throws { + let records = [ + ReplicaReplayRecord(tick: 1, origin: .resync, delta: .sessionUpserted(ReplicaTestSupport.snapshot(version: 1))), + ReplicaReplayRecord(tick: 2, origin: .live, delta: .entriesAppended(journalID: ReplicaTestSupport.journal, entries: [ReplicaTestSupport.entry(1), ReplicaTestSupport.entry(2)])), + ReplicaReplayRecord(tick: 3, origin: .live, delta: .entryReplaced(ReplicaTestSupport.entry(2, version: 2, hash: 22))), + ] + let encoded = try ReplicaReplayLog(records: records).encodeJSONL() + let decoded = ReplicaReplayLog.decodeJSONL(encoded) + + #expect(decoded.records == records) + #expect(decoded.skippedLineCount == 0) + #expect(state(after: records) == state(after: decoded.records)) + } + + @Test func invalidJsonLinesAreSkippedWithCounter() { + let data = Data(#"{"tick":1,"origin":"live","delta":{"kind":"futureMutation"}}"#.utf8) + + Data("\nnot-json\n".utf8) + let log = ReplicaReplayLog.decodeJSONL(data) + + #expect(log.records == [ReplicaReplayRecord(tick: 1, origin: .live, delta: .unknown(kind: "futureMutation"))]) + #expect(log.skippedLineCount == 1) + } + + @Test func fixtureFilesDecodeAndFeedStores() throws { + for name in ["normal-session-lifecycle", "rotation-reset", "epoch-change-pending-tickets"] { + let url = try #require(Bundle.module.url(forResource: name, withExtension: "jsonl")) + let data = try Data(contentsOf: url) + let log = ReplicaReplayLog.decodeJSONL(data) + #expect(log.skippedLineCount == 0) + #expect(!log.records.isEmpty) + #expect( + String(decoding: try log.encodeJSONL(), as: UTF8.self) + == String(decoding: data, as: UTF8.self) + ) + + let directory = SessionDirectoryReplica(epoch: ReplicaEpoch(rawValue: "fixture")) + let conversation = ConversationReplica(sessionID: ReplicaTestSupport.session, journalID: ReplicaTestSupport.journal, clock: ReplicaTestSupport.clock()) + FixtureReplicaSource(records: log.records).feed(directory: directory, conversations: [ReplicaTestSupport.session: conversation]) + #expect(directory.lastAppliedOrigin != nil || conversation.lastAppliedOrigin != nil) + } + } + + @Test func epochChangeFixtureKeepsPendingTicketAcrossManualEpochDrop() throws { + let url = try #require(Bundle.module.url(forResource: "epoch-change-pending-tickets", withExtension: "jsonl")) + let log = ReplicaReplayLog.decodeJSONL(try Data(contentsOf: url)) + let directory = SessionDirectoryReplica(epoch: ReplicaEpoch(rawValue: "e1")) + let conversation = ConversationReplica(sessionID: ReplicaTestSupport.session, journalID: ReplicaTestSupport.journal, clock: ReplicaTestSupport.clock()) + + FixtureReplicaSource(records: Array(log.records.prefix(2))).feed(directory: directory, conversations: [ReplicaTestSupport.session: conversation]) + #expect(conversation.sendTickets.count == 1) + #expect(conversation.asks.count == 1) + + directory.handleEpochChange(to: ReplicaEpoch(rawValue: "e2")) + conversation.handleEpochChange(to: ReplicaEpoch(rawValue: "e2")) + FixtureReplicaSource(records: Array(log.records.dropFirst(2))).feed(directory: directory, conversations: [ReplicaTestSupport.session: conversation]) + + #expect(directory.sessions.count == 1) + #expect(conversation.sendTickets.count == 1) + #expect(conversation.asks.isEmpty) + } + + @Test func applyingAnyPrefixTwiceIsIdempotentAndIndependentEntitiesCommute() { + let records = propertyRecords() + for count in 0...records.count { + let prefix = Array(records.prefix(count)) + #expect(state(after: prefix + prefix) == state(after: prefix)) + } + + let setup = Array(records.prefix(2)) + let independentTail = Array(records.dropFirst(2).prefix(4)) + let expected = state(after: setup + independentTail) + for permutation in permutations(of: independentTail) { + #expect(state(after: setup + permutation) == expected) + } + } + + private func state(after records: [ReplicaReplayRecord]) -> ReplayState { + let directory = SessionDirectoryReplica(epoch: ReplicaEpoch(rawValue: "e1")) + let conversation = ConversationReplica(sessionID: ReplicaTestSupport.session, journalID: ReplicaTestSupport.journal, clock: ReplicaTestSupport.clock()) + let otherConversation = ConversationReplica(sessionID: ReplicaTestSupport.otherSession, journalID: ReplicaTestSupport.otherJournal, clock: ReplicaTestSupport.clock()) + FixtureReplicaSource(records: records).feed( + directory: directory, + conversations: [ReplicaTestSupport.session: conversation, ReplicaTestSupport.otherSession: otherConversation] + ) + return ReplayState( + sessions: directory.sessions, + conversation: conversation.state, + otherConversation: otherConversation.state + ) + } + + private func propertyRecords() -> [ReplicaReplayRecord] { + [ + ReplicaReplayRecord(tick: 1, origin: .resync, delta: .sessionUpserted(ReplicaTestSupport.snapshot(id: ReplicaTestSupport.session, version: 1))), + ReplicaReplayRecord(tick: 2, origin: .resync, delta: .sessionUpserted(ReplicaTestSupport.snapshot(id: ReplicaTestSupport.otherSession, version: 1))), + ReplicaReplayRecord(tick: 3, origin: .live, delta: .sessionUpserted(ReplicaTestSupport.snapshot(id: ReplicaTestSupport.session, phase: .working, version: 2))), + ReplicaReplayRecord(tick: 4, origin: .live, delta: .entriesAppended(journalID: ReplicaTestSupport.otherJournal, entries: [ + ReplicaTestSupport.entry(1, journalID: ReplicaTestSupport.otherJournal), + ])), + ReplicaReplayRecord(tick: 5, origin: .live, delta: .entriesAppended(journalID: ReplicaTestSupport.journal, entries: [ReplicaTestSupport.entry(1)])), + ReplicaReplayRecord(tick: 6, origin: .live, delta: .sessionUpserted(ReplicaTestSupport.snapshot(id: ReplicaTestSupport.otherSession, phase: .working, version: 2))), + ] + } + + private func permutations(of records: [ReplicaReplayRecord]) -> [[ReplicaReplayRecord]] { + guard let first = records.first else { + return [[]] + } + let rest = Array(records.dropFirst()) + return permutations(of: rest).flatMap { tail in + (0...tail.count).map { index in + var output = tail + output.insert(first, at: index) + return output + } + } + } +} + +private struct ReplayState: Hashable { + let sessions: [AgentSessionSnapshot] + let conversation: ConversationReplicaState + let otherConversation: ConversationReplicaState +} diff --git a/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/ReplicaTestSupport.swift b/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/ReplicaTestSupport.swift new file mode 100644 index 000000000000..da1c1d2bb88a --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/ReplicaTestSupport.swift @@ -0,0 +1,77 @@ +import Foundation +@testable import CmuxAgentReplica + +struct ReplicaTestSupport { + static let mac = MacDeviceID(rawValue: "mac-1") + static let session = AgentSessionID(rawValue: "session-1") + static let otherSession = AgentSessionID(rawValue: "session-2") + static let journal = JournalID(rawValue: "journal-1") + static let otherJournal = JournalID(rawValue: "journal-2") + + static func clock() -> ManualReplicaClock { + ManualReplicaClock(currentTick: 10) + } + + static func version(_ raw: UInt64) -> EntityVersion { + EntityVersion(rawValue: raw) + } + + static func seq(_ raw: Int) -> EntrySeq { + EntrySeq(rawValue: raw) + } + + static func snapshot( + id: AgentSessionID = session, + phase: SessionPhase = .idle, + version rawVersion: UInt64 = 1, + recency: Int = 0 + ) -> AgentSessionSnapshot { + AgentSessionSnapshot( + id: id, + macDeviceID: mac, + kind: .codex, + phase: phase, + tier: .wrapped, + surfaceID: nil, + cwd: "/repo", + title: "Session \(id.rawValue)", + workspaceName: "Workspace", + version: version(rawVersion), + lastActivityHint: recency + ) + } + + static func entry( + _ rawSeq: Int, + journalID: JournalID = journal, + version rawVersion: UInt64 = 1, + hash: Int? = nil + ) -> EntrySnapshot { + EntrySnapshot( + journalID: journalID, + seq: seq(rawSeq), + kind: .agentProse, + content: EntryContent( + contentHash: hash ?? rawSeq, + payload: .agentProse(AgentProsePayload(markdown: "entry \(rawSeq)")) + ), + version: version(rawVersion) + ) + } + + static func ticket( + id: UUID, + state: SendTicketState, + createdAt: Int, + sessionID: AgentSessionID = session + ) -> SendTicket { + SendTicket( + id: id, + sessionID: sessionID, + text: "hello", + attachmentCount: 0, + state: state, + createdAt: createdAt + ) + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/Resources/epoch-change-pending-tickets.jsonl b/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/Resources/epoch-change-pending-tickets.jsonl new file mode 100644 index 000000000000..98526f744000 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/Resources/epoch-change-pending-tickets.jsonl @@ -0,0 +1,3 @@ +{"delta":{"kind":"sendTicketChanged","ticket":{"attachment_count":0,"created_at":1,"id":"11111111-1111-1111-1111-111111111111","session_id":"session-1","state":{"type":"queuedLocal"},"text":"hello"}},"origin":"live","tick":1} +{"delta":{"ask":{"id":"ask-1","kind":"question","options":[],"options_count":2,"prompt_summary":"Choose","session_id":"session-1","state":{"type":"active"}},"kind":"askChanged"},"origin":"live","tick":2} +{"delta":{"kind":"sessionUpserted","snapshot":{"cwd":"\/repo","id":"session-1","kind":"codex","last_activity_hint":1,"mac_device_id":"mac-1","phase":"needsInput","tier":"observed","title":"Pending","version":1,"workspace_name":"Workspace"}},"origin":"resync","tick":3} diff --git a/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/Resources/normal-session-lifecycle.jsonl b/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/Resources/normal-session-lifecycle.jsonl new file mode 100644 index 000000000000..503a5c55ad22 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/Resources/normal-session-lifecycle.jsonl @@ -0,0 +1,3 @@ +{"delta":{"kind":"sessionUpserted","snapshot":{"cwd":"\/repo","id":"session-1","kind":"codex","last_activity_hint":1,"mac_device_id":"mac-1","phase":"starting","tier":"wrapped","title":"Normal","version":1,"workspace_name":"Workspace"}},"origin":"resync","tick":1} +{"delta":{"entries":[{"content":{"content_hash":1,"payload":{"attachment_count":0,"has_image":false,"kind":"userMessage","text":"Fixture prompt"}},"journal_id":"journal-1","kind":"userMessage","seq":1,"version":1},{"content":{"content_hash":2,"payload":{"kind":"agentProse","markdown":"Fixture response"}},"journal_id":"journal-1","kind":"agentProse","seq":2,"version":1}],"journalID":"journal-1","kind":"entriesAppended"},"origin":"live","tick":2} +{"delta":{"kind":"sessionUpserted","snapshot":{"cwd":"\/repo","id":"session-1","kind":"codex","last_activity_hint":2,"mac_device_id":"mac-1","phase":"idle","tier":"wrapped","title":"Normal","version":2,"workspace_name":"Workspace"}},"origin":"live","tick":3} diff --git a/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/Resources/rotation-reset.jsonl b/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/Resources/rotation-reset.jsonl new file mode 100644 index 000000000000..f9538f60794f --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/Resources/rotation-reset.jsonl @@ -0,0 +1,4 @@ +{"delta":{"kind":"sessionUpserted","snapshot":{"cwd":"\/repo","id":"session-1","kind":"claude","last_activity_hint":1,"mac_device_id":"mac-1","phase":"working","tier":"hooked","title":"Rotate","version":1,"workspace_name":"Workspace"}},"origin":"resync","tick":1} +{"delta":{"entries":[{"content":{"content_hash":1,"payload":{"kind":"agentProse","markdown":"Before rotation"}},"journal_id":"journal-1","kind":"agentProse","seq":1,"version":1}],"journalID":"journal-1","kind":"entriesAppended"},"origin":"live","tick":2} +{"delta":{"kind":"journalReset","newJournal":"journal-2","sessionID":"session-1","tailSeq":3},"origin":"live","tick":3} +{"delta":{"entries":[{"content":{"content_hash":4,"payload":{"code":"compacted","detail":"Context compacted","kind":"status"}},"journal_id":"journal-2","kind":"status","seq":4,"version":1}],"journalID":"journal-2","kind":"entriesAppended"},"origin":"resync","tick":4} diff --git a/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/ResyncPlanTests.swift b/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/ResyncPlanTests.swift new file mode 100644 index 000000000000..99d1913b7c38 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/ResyncPlanTests.swift @@ -0,0 +1,61 @@ +import Foundation +import Testing +@testable import CmuxAgentReplica + +@Suite struct ResyncPlanTests { + @Test func sameEpochReconnectWithGapPullsOnlyGapConversationThenFlushesTickets() { + let id = UUID() + let plan = ResyncPlan.make( + cachedEpoch: ReplicaEpoch(rawValue: "e1"), + helloEpoch: ReplicaEpoch(rawValue: "e1"), + openConversations: [ + ResyncConversationState(sessionID: ReplicaTestSupport.session, journalID: ReplicaTestSupport.journal, needsTailPull: true), + ResyncConversationState(sessionID: ReplicaTestSupport.otherSession, journalID: ReplicaTestSupport.otherJournal, needsTailPull: false), + ], + ticketQueue: [ReplicaTestSupport.ticket(id: id, state: .queuedLocal, createdAt: 1)] + ) + + #expect(plan.steps == [.keepState, .pullSessions, .pullTailPage(ReplicaTestSupport.session), .flushTickets]) + } + + @Test func journalRotationMidStreamPullsTailForConversationWithGap() { + let plan = ResyncPlan.make( + cachedEpoch: ReplicaEpoch(rawValue: "e1"), + helloEpoch: ReplicaEpoch(rawValue: "e1"), + openConversations: [ + ResyncConversationState(sessionID: ReplicaTestSupport.session, journalID: ReplicaTestSupport.otherJournal, needsTailPull: true), + ], + ticketQueue: [] + ) + + #expect(plan.steps == [.keepState, .pullSessions, .pullTailPage(ReplicaTestSupport.session)]) + } + + @Test func macRelaunchDropsAllThenPullsEveryOpenConversationAndFlushesTickets() { + let id = UUID() + let plan = ResyncPlan.make( + cachedEpoch: ReplicaEpoch(rawValue: "e1"), + helloEpoch: ReplicaEpoch(rawValue: "e2"), + openConversations: [ + ResyncConversationState(sessionID: ReplicaTestSupport.otherSession, journalID: nil, needsTailPull: false), + ResyncConversationState(sessionID: ReplicaTestSupport.session, journalID: ReplicaTestSupport.journal, needsTailPull: false), + ], + ticketQueue: [ReplicaTestSupport.ticket(id: id, state: .queuedLocal, createdAt: 1)] + ) + + #expect(plan.steps == [.dropAll, .pullSessions, .pullTailPage(ReplicaTestSupport.session), .pullTailPage(ReplicaTestSupport.otherSession), .flushTickets]) + } + + @Test func duplicateReorderedDeltasDuringInflightPullKeepStateAndDoNotAddTailPulls() { + let plan = ResyncPlan.make( + cachedEpoch: ReplicaEpoch(rawValue: "e1"), + helloEpoch: ReplicaEpoch(rawValue: "e1"), + openConversations: [ + ResyncConversationState(sessionID: ReplicaTestSupport.session, journalID: ReplicaTestSupport.journal, needsTailPull: false), + ], + ticketQueue: [] + ) + + #expect(plan.steps == [.keepState, .pullSessions]) + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/SessionDirectoryReplicaTests.swift b/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/SessionDirectoryReplicaTests.swift new file mode 100644 index 000000000000..89381bdbf649 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/SessionDirectoryReplicaTests.swift @@ -0,0 +1,65 @@ +import Testing +@testable import CmuxAgentReplica + +@MainActor @Suite struct SessionDirectoryReplicaTests { + @Test func versionGatingDropsRegressionAndEqualButAppliesHigher() { + let store = SessionDirectoryReplica(epoch: ReplicaEpoch(rawValue: "e1")) + + store.apply(.sessionUpserted(ReplicaTestSupport.snapshot(phase: .idle, version: 2)), origin: .resync) + store.apply(.sessionUpserted(ReplicaTestSupport.snapshot(phase: .working, version: 1)), origin: .live) + store.apply(.sessionUpserted(ReplicaTestSupport.snapshot(phase: .needsInput, version: 2)), origin: .live) + #expect(store.sessions.first?.phase == .idle) + #expect(store.lastAppliedOrigin == .resync) + + store.apply(.sessionUpserted(ReplicaTestSupport.snapshot(phase: .working, version: 3)), origin: .resync) + #expect(store.sessions.first?.phase == .working) + #expect(store.lastAppliedOrigin == .resync) + } + + @Test func removalIsVersionGated() { + let store = SessionDirectoryReplica(epoch: ReplicaEpoch(rawValue: "e1")) + store.apply(.sessionUpserted(ReplicaTestSupport.snapshot(version: 5)), origin: .live) + store.apply(.sessionRemoved(id: ReplicaTestSupport.session, version: ReplicaTestSupport.version(4)), origin: .live) + #expect(store.sessions.count == 1) + store.apply(.sessionRemoved(id: ReplicaTestSupport.session, version: ReplicaTestSupport.version(6)), origin: .live) + #expect(store.sessions.isEmpty) + } + + @Test func replaceAllResetsVersionGateAndSortsByUrgencyThenRecency() { + let store = SessionDirectoryReplica(epoch: ReplicaEpoch(rawValue: "old")) + store.apply(.sessionUpserted(ReplicaTestSupport.snapshot(version: 99)), origin: .live) + + store.replaceAll([ + ReplicaTestSupport.snapshot(id: ReplicaTestSupport.otherSession, phase: .working, version: 1, recency: 3), + ReplicaTestSupport.snapshot(id: ReplicaTestSupport.session, phase: .needsInput, version: 1, recency: 1), + ReplicaTestSupport.snapshot(id: AgentSessionID(rawValue: "ended"), phase: .ended, version: 1, recency: 100), + ], epoch: ReplicaEpoch(rawValue: "new")) + + #expect(store.sessions.map(\.id) == [ReplicaTestSupport.session, ReplicaTestSupport.otherSession, AgentSessionID(rawValue: "ended")]) + store.apply(.sessionUpserted(ReplicaTestSupport.snapshot(id: ReplicaTestSupport.session, phase: .idle, version: 2)), origin: .live) + #expect(store.sessions.first?.phase == .working) + } + + @Test func replaceAllKeepsHigherVersionWhenPullContainsDuplicateIDs() { + let store = SessionDirectoryReplica(epoch: ReplicaEpoch(rawValue: "old")) + store.replaceAll([ + ReplicaTestSupport.snapshot(id: ReplicaTestSupport.session, phase: .idle, version: 1), + ReplicaTestSupport.snapshot(id: ReplicaTestSupport.session, phase: .working, version: 3), + ReplicaTestSupport.snapshot(id: ReplicaTestSupport.session, phase: .needsInput, version: 2), + ], epoch: ReplicaEpoch(rawValue: "new")) + + #expect(store.sessions.count == 1) + #expect(store.sessions.first?.phase == .working) + } + + @Test func epochChangeDropsDirectoryState() { + let store = SessionDirectoryReplica(epoch: ReplicaEpoch(rawValue: "e1")) + store.apply(.sessionUpserted(ReplicaTestSupport.snapshot(version: 1)), origin: .live) + store.handleEpochChange(to: ReplicaEpoch(rawValue: "e2")) + + #expect(store.sessions.isEmpty) + #expect(store.epoch == ReplicaEpoch(rawValue: "e2")) + store.apply(.sessionUpserted(ReplicaTestSupport.snapshot(version: 1)), origin: .live) + #expect(store.sessions.count == 1) + } +} diff --git a/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/TicketLedgerClientTests.swift b/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/TicketLedgerClientTests.swift new file mode 100644 index 000000000000..5e7fa3ceac65 --- /dev/null +++ b/Packages/Shared/CmuxAgentReplica/Tests/CmuxAgentReplicaTests/TicketLedgerClientTests.swift @@ -0,0 +1,91 @@ +import Foundation +import Testing +@testable import CmuxAgentReplica + +@Suite struct TicketLedgerClientTests { + @Test func legalTransitionsApplyAndIllegalTransitionsAreCounted() { + let id = UUID() + var ledger = TicketLedgerClient() + let queuedApplied = ledger.apply(ReplicaTestSupport.ticket(id: id, state: .queuedLocal, createdAt: 1)) + let acceptedApplied = ledger.apply(ReplicaTestSupport.ticket(id: id, state: .acceptedByMac, createdAt: 1)) + let injectedApplied = ledger.apply(ReplicaTestSupport.ticket(id: id, state: .injected, createdAt: 1)) + let echoedApplied = ledger.apply(ReplicaTestSupport.ticket(id: id, state: .echoed(ReplicaTestSupport.seq(7)), createdAt: 1)) + + let illegalApplied = ledger.apply(ReplicaTestSupport.ticket(id: id, state: .acceptedByMac, createdAt: 1)) + #expect(queuedApplied) + #expect(acceptedApplied) + #expect(injectedApplied) + #expect(echoedApplied) + #expect(!illegalApplied) + #expect(ledger.illegalTransitionCount == 1) + #expect(ledger.tickets.first?.state == .echoed(ReplicaTestSupport.seq(7))) + } + + @Test func skipAheadFromQueuedLocalToEchoedApplies() { + let id = UUID() + var ledger = TicketLedgerClient() + _ = ledger.apply(ReplicaTestSupport.ticket(id: id, state: .queuedLocal, createdAt: 1)) + + let applied = ledger.apply(ReplicaTestSupport.ticket(id: id, state: .echoed(ReplicaTestSupport.seq(9)), createdAt: 1)) + + #expect(applied) + #expect(ledger.tickets.first?.state == .echoed(ReplicaTestSupport.seq(9))) + #expect(ledger.illegalTransitionCount == 0) + } + + @Test func failedTicketCanRequeueWithTheSameIdentity() { + let id = UUID() + var ledger = TicketLedgerClient() + _ = ledger.apply(ReplicaTestSupport.ticket(id: id, state: .failed(code: "offline"), createdAt: 1)) + + let requeued = ledger.apply(ReplicaTestSupport.ticket(id: id, state: .queuedLocal, createdAt: 1)) + let failedAgain = ledger.apply(ReplicaTestSupport.ticket(id: id, state: .failed(code: "offline-again"), createdAt: 1)) + + #expect(requeued) + #expect(failedAgain) + #expect(ledger.tickets.count == 1) + #expect(ledger.tickets.first?.id == id) + #expect(ledger.tickets.first?.state == .failed(code: "offline-again")) + } + + @Test func echoedResolvesOldestUnresolvedMatchingTicket() { + let id = UUID() + var ledger = TicketLedgerClient(tickets: [ + ReplicaTestSupport.ticket(id: id, state: .acceptedByMac, createdAt: 1), + ReplicaTestSupport.ticket(id: id, state: .queuedLocal, createdAt: 2), + ]) + + let applied = ledger.apply(ReplicaTestSupport.ticket(id: id, state: .echoed(ReplicaTestSupport.seq(4)), createdAt: 2)) + + #expect(applied) + #expect(ledger.tickets.map(\.state) == [.echoed(ReplicaTestSupport.seq(4)), .queuedLocal]) + } + + @Test func fifoDedupKeepsCreationOrder() { + let later = UUID() + let earlier = UUID() + var ledger = TicketLedgerClient() + _ = ledger.apply(ReplicaTestSupport.ticket(id: later, state: .queuedLocal, createdAt: 20)) + _ = ledger.apply(ReplicaTestSupport.ticket(id: earlier, state: .queuedLocal, createdAt: 10)) + _ = ledger.apply(ReplicaTestSupport.ticket(id: earlier, state: .acceptedByMac, createdAt: 10)) + + #expect(ledger.tickets.map(\.id) == [earlier, later]) + #expect(ledger.tickets.count == 2) + } + + @Test func retentionCapEvictsOnlyOldestResolvedTickets() { + let unresolved = ReplicaTestSupport.ticket(id: UUID(), state: .unconfirmed, createdAt: 0) + var ledger = TicketLedgerClient(tickets: [unresolved]) + for tick in 1...(TicketLedgerClient.resolvedRetentionLimit + 10) { + _ = ledger.apply(ReplicaTestSupport.ticket( + id: UUID(), + state: .failed(code: "test"), + createdAt: tick + )) + } + + #expect(ledger.tickets.count == TicketLedgerClient.resolvedRetentionLimit + 1) + #expect(ledger.tickets.first?.id == unresolved.id) + #expect(ledger.tickets.dropFirst().allSatisfy { $0.state.isResolved }) + } +} diff --git a/Packages/Shared/CmuxAgentSync/Package.swift b/Packages/Shared/CmuxAgentSync/Package.swift new file mode 100644 index 000000000000..5ce3a0fd0782 --- /dev/null +++ b/Packages/Shared/CmuxAgentSync/Package.swift @@ -0,0 +1,48 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "CmuxAgentSync", + platforms: [ + .iOS(.v18), + .macOS(.v14), + ], + products: [ + .library( + name: "CmuxAgentSync", + targets: ["CmuxAgentSync"] + ), + ], + dependencies: [ + .package(path: "../CmuxAgentReplica"), + .package(path: "../CmuxAgentWire"), + ], + targets: [ + .target( + name: "CmuxAgentSync", + dependencies: [ + "CmuxAgentReplica", + "CmuxAgentWire", + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .testTarget( + name: "CmuxAgentSyncTests", + dependencies: [ + "CmuxAgentSync", + "CmuxAgentReplica", + "CmuxAgentWire", + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + ] +) diff --git a/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentConnectivityPhase.swift b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentConnectivityPhase.swift new file mode 100644 index 000000000000..dd31907843b7 --- /dev/null +++ b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentConnectivityPhase.swift @@ -0,0 +1,11 @@ +/// User-visible connectivity phase for one Mac's agent GUI sync. +public enum AgentConnectivityPhase: Hashable, Sendable { + /// The engine has a synchronized replica and live event subscription. + case connected + /// The engine is waiting before retrying a failed sync attempt. + case connecting(backoffMilliseconds: Int) + /// The engine is actively reconciling state with the Mac. + case updating + /// The engine is offline and may retry. + case offline(reason: String?) +} diff --git a/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentConnectivityState.swift b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentConnectivityState.swift new file mode 100644 index 000000000000..c489b32d266b --- /dev/null +++ b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentConnectivityState.swift @@ -0,0 +1,37 @@ +public import Observation + +/// Observable connectivity state for one Mac's agent GUI sync. +@MainActor +@Observable +public final class AgentConnectivityState { + /// The current connectivity phase. + public private(set) var phase: AgentConnectivityPhase + /// Consecutive reconciliation failures since the last complete sync. + public private(set) var consecutiveFailureCount: Int + /// Latest bounded diagnostic reason, for logging rather than direct UI copy. + public private(set) var lastFailureReason: String? + + /// Creates a connectivity state holder. + /// - Parameter phase: The initial phase. + public init(phase: AgentConnectivityPhase = .offline(reason: nil)) { + self.phase = phase + consecutiveFailureCount = 0 + lastFailureReason = nil + } + + /// Replaces the current phase. + /// - Parameter phase: The next phase. + public func setPhase(_ phase: AgentConnectivityPhase) { + self.phase = phase + } + + func recordFailure(reason: String) { + consecutiveFailureCount += 1 + lastFailureReason = String(reason.prefix(256)) + } + + func recordSuccessfulSync() { + consecutiveFailureCount = 0 + lastFailureReason = nil + } +} diff --git a/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentStreamingTail.swift b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentStreamingTail.swift new file mode 100644 index 000000000000..525717c88d9f --- /dev/null +++ b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentStreamingTail.swift @@ -0,0 +1,26 @@ +public import CmuxAgentReplica + +/// Ephemeral streaming preview retained outside ``ConversationReplica``. +public struct AgentStreamingTail: Hashable, Sendable { + /// The journal receiving the streaming preview. + public let journalID: JournalID + /// The committed tail sequence the preview follows. + public let afterSeq: EntrySeq + /// The bounded text preview. + public let textTail: String + /// The preview revision. + public let revision: Int + + /// Creates a streaming-tail preview. + /// - Parameters: + /// - journalID: The journal receiving the preview. + /// - afterSeq: The committed tail sequence the preview follows. + /// - textTail: The bounded text preview. + /// - revision: The preview revision. + public init(journalID: JournalID, afterSeq: EntrySeq, textTail: String, revision: Int) { + self.journalID = journalID + self.afterSeq = afterSeq + self.textTail = textTail + self.revision = revision + } +} diff --git a/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentSyncEngine+Requests.swift b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentSyncEngine+Requests.swift new file mode 100644 index 000000000000..2897e1a29a2f --- /dev/null +++ b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentSyncEngine+Requests.swift @@ -0,0 +1,169 @@ +import CmuxAgentReplica +import CmuxAgentWire +import Foundation + +extension AgentSyncEngine { + func flushQueuedTickets() async throws { + let queued = conversations.values + .flatMap(\.sendTickets) + .filter { ticket in + if case .queuedLocal = ticket.state { return true } + return false + } + .sorted { $0.createdAt < $1.createdAt } + for ticket in queued { + try Task.checkCancellation() + try await submitQueuedTicket(ticket, origin: .resync) + } + } + + func submitQueuedTicket(_ ticket: SendTicket, origin: DeltaOrigin) async throws { + guard let conversation = conversations[ticket.sessionID] else { return } + let params = GuiSendParams(sessionID: ticket.sessionID, ticketID: ticket.id.uuidString, text: ticket.text) + do { + let data = try await request(method: GuiWireMethod.send, params: params) + let result = try decode(GuiSendResult.self, from: data) + let nextState: SendTicketState = result.accepted ? .acceptedByMac : .failed(code: "send_rejected") + conversation.apply(.sendTicketChanged(ticket.withState(nextState)), origin: origin) + } catch AgentSyncError.wire(let wireError) { + conversation.apply( + .sendTicketChanged(ticket.withState(.failed(code: wireError.code.rawValue))), + origin: origin + ) + } + } + + func requestHello() async throws -> GuiHelloResult { + try await request( + method: GuiWireMethod.hello, + params: GuiHelloParams(protocolMin: 1, protocolMax: 1, clientCaps: ["ios.agent_gui.sync.v1"]) + ) + } + + func requestSessions() async throws -> GuiSessionsResult { + try await request(method: GuiWireMethod.sessions, params: GuiSessionsParams()) + } + + func entries( + sessionID: AgentSessionID, + journalID: JournalID?, + beforeSeq: EntrySeq?, + afterSeq: EntrySeq?, + anchor: GuiEntriesAnchor? = nil, + cursor: JournalCursor? = nil, + limit: Int + ) async throws -> GuiEntriesResult { + try await request( + method: GuiWireMethod.entries, + params: GuiEntriesParams( + sessionID: sessionID, + journalID: journalID, + beforeSeq: beforeSeq, + afterSeq: afterSeq, + anchor: anchor, + cursor: cursor, + limit: limit + ) + ) + } + + func cursorEntries( + sessionID: AgentSessionID, + journalID: JournalID?, + anchor: GuiEntriesAnchor, + cursor: JournalCursor?, + limit: Int + ) async throws -> GuiEntriesResult { + try await entries( + sessionID: sessionID, + journalID: journalID, + beforeSeq: nil, + afterSeq: nil, + anchor: anchor, + cursor: cursor, + limit: limit + ) + } + + func request( + method: String, + params: Params + ) async throws -> Response { + let data = try await request(method: method, params: params) + return try decode(Response.self, from: data) + } + + func request(method: String, params: Params) async throws -> Data { + do { + return try await transport.request(method: method, params: encoder.encode(params)) + } catch let error as GuiWireError { + throw AgentSyncError.wire(error) + } catch let error as AgentSyncError { + throw error + } catch { + throw AgentSyncError.transport(Self.errorDescription(error)) + } + } + + func decode(_ type: Response.Type, from data: Data) throws -> Response { + do { + return try decoder.decode(type, from: data) + } catch { + throw AgentSyncError.malformedResponse + } + } + + func desiredTopics() -> [String] { + ([GuiWireTopic.sessions] + conversations.keys + .sorted { $0.rawValue < $1.rawValue } + .map { GuiWireTopic.journal(sessionID: $0) }) + } + + func sessionID(for event: GuiEventFrame, topic: String) -> AgentSessionID? { + if let sessionID = event.sessionID { + return sessionID + } + let prefix = GuiWireTopic.journalPrefix + guard topic.hasPrefix(prefix) else { return nil } + return AgentSessionID(rawValue: String(topic.dropFirst(prefix.count))) + } + + func retryDelayMilliseconds(attempt: Int) -> Int { + let exponent = max(0, attempt - 1) + let base = min(16_000, 500 * (1 << min(exponent, 6))) + let fraction = min(0.2, max(-0.2, jitter.retryJitterFraction())) + return max(0, Int((Double(base) * (1 + fraction)).rounded())) + } + + static func errorDescription(_ error: any Error) -> String { + if case AgentSyncError.wire(let wire) = error { + return wire.code.rawValue + } + if case AgentSyncError.transport(let message) = error { + return message + } + if case AgentSyncError.offline = error { + return "offline" + } + if case AgentSyncError.conversationNotOpen = error { + return "conversation_not_open" + } + if case AgentSyncError.malformedResponse = error { + return "malformed_response" + } + return String(describing: error) + } +} + +private extension SendTicket { + func withState(_ state: SendTicketState) -> SendTicket { + SendTicket( + id: id, + sessionID: sessionID, + text: text, + attachmentCount: attachmentCount, + state: state, + createdAt: createdAt + ) + } +} diff --git a/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentSyncEngine+Retry.swift b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentSyncEngine+Retry.swift new file mode 100644 index 000000000000..75984e8516c0 --- /dev/null +++ b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentSyncEngine+Retry.swift @@ -0,0 +1,8 @@ +extension AgentSyncEngine { + /// Cancels the current backoff and retries reconciliation immediately. + public func retryNow() { + retryTask?.cancel() + retryTask = nil + triggerResync() + } +} diff --git a/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentSyncEngine.swift b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentSyncEngine.swift new file mode 100644 index 000000000000..4b1c2aa9d6cc --- /dev/null +++ b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentSyncEngine.swift @@ -0,0 +1,698 @@ +public import CmuxAgentReplica +public import CmuxAgentWire +public import Foundation +public import Observation + +private struct AgentPagingRequestKey: Hashable { + let sessionID: AgentSessionID + let anchor: GuiEntriesAnchor + let cursor: JournalCursor? +} + +/// Drives agent GUI replica stores over an abstract transport. +@MainActor +@Observable +public final class AgentSyncEngine { + /// The session directory replica for the attached Mac. + public let directory: SessionDirectoryReplica + /// Observable connectivity state for the attached Mac. + public let connectivity: AgentConnectivityState + /// Open conversation replicas keyed by session id. + public private(set) var conversations: [AgentSessionID: ConversationReplica] + /// Engine-owned streaming previews keyed by session id. + public private(set) var streamingTails: [AgentSessionID: AgentStreamingTail] + /// Last `has_more_before` value reported for each open conversation. + public private(set) var hasMoreBeforeBySession: [AgentSessionID: Bool] + /// Last `has_more_after` value reported for each open conversation. + public private(set) var hasMoreAfterBySession: [AgentSessionID: Bool] + /// Last capability report returned per session. + public private(set) var cachedCapabilities: [AgentSessionID: GuiCapabilitiesResult] + /// Count of transport frames that could not be decoded as `gui.v1` frames. + public private(set) var malformedFrameCount: Int + + @ObservationIgnored let transport: any AgentSyncTransport + @ObservationIgnored private let syncClock: any SyncClock + @ObservationIgnored let replicaClock: any ReplicaClock + @ObservationIgnored private let ticketIDGenerator: any AgentSyncTicketIDGenerator + @ObservationIgnored let jitter: any AgentSyncJitter + @ObservationIgnored let encoder: JSONEncoder + @ObservationIgnored let decoder: JSONDecoder + @ObservationIgnored private var connectionTask: Task? + @ObservationIgnored private var frameTask: Task? + @ObservationIgnored var retryTask: Task? + @ObservationIgnored private var resyncTask: Task? + @ObservationIgnored private var tailPullTasks: [AgentSessionID: Task] + @ObservationIgnored private var pagingTasks: [AgentPagingRequestKey: Task] + @ObservationIgnored private var pagingGenerationBySession: [AgentSessionID: UInt64] + @ObservationIgnored private var conversationOwnerCounts: [AgentSessionID: Int] + @ObservationIgnored private var retryAttempt: Int + @ObservationIgnored private var resyncRequested: Bool + @ObservationIgnored private var started: Bool + @ObservationIgnored private var stopped: Bool + @ObservationIgnored private var malformedFrameTimes: [Int64] + + /// Creates an agent sync engine. + /// - Parameters: + /// - transport: The abstract transport to one attached Mac. + /// - syncClock: Clock used for retry and debounce timing. + /// - replicaClock: Clock passed into replica stores and local send tickets. + /// - ticketIDGenerator: Generator for client-minted send tickets. + /// - jitter: Retry jitter source. + public init( + transport: any AgentSyncTransport, + syncClock: any SyncClock = RealSyncClock(), + replicaClock: any ReplicaClock = AgentSyncReplicaClock(), + ticketIDGenerator: any AgentSyncTicketIDGenerator = UUIDAgentSyncTicketIDGenerator(), + jitter: any AgentSyncJitter = RandomAgentSyncJitter() + ) { + self.transport = transport + self.syncClock = syncClock + self.replicaClock = replicaClock + self.ticketIDGenerator = ticketIDGenerator + self.jitter = jitter + directory = SessionDirectoryReplica() + connectivity = AgentConnectivityState() + conversations = [:] + streamingTails = [:] + hasMoreBeforeBySession = [:] + hasMoreAfterBySession = [:] + cachedCapabilities = [:] + malformedFrameCount = 0 + encoder = JSONEncoder() + decoder = JSONDecoder() + tailPullTasks = [:] + pagingTasks = [:] + pagingGenerationBySession = [:] + conversationOwnerCounts = [:] + retryAttempt = 0 + resyncRequested = false + started = false + stopped = false + malformedFrameTimes = [] + } + + deinit { + connectionTask?.cancel() + frameTask?.cancel() + retryTask?.cancel() + resyncTask?.cancel() + for (_, task) in tailPullTasks { + task.cancel() + } + for (_, task) in pagingTasks { + task.cancel() + } + } + + /// Starts connection-event observation and an initial reconciliation. + /// + /// Calling this method after ``stop()`` has no effect; engines are single-use. + public func start() { + guard !started, !stopped else { return } + started = true + connectionTask = Task { @MainActor [weak self] in + guard let self else { return } + for await event in self.transport.connectionEvents { + guard !Task.isCancelled else { return } + self.handleConnectionEvent(event) + } + } + triggerResync() + } + + /// Permanently stops all engine-owned asynchronous work. + /// + /// A stopped engine cannot be restarted. Create a new engine for a replacement transport. + public func stop() { + stopped = true + started = false + connectivity.setPhase(.offline(reason: "stopped")) + connectionTask?.cancel() + connectionTask = nil + frameTask?.cancel() + frameTask = nil + retryTask?.cancel() + retryTask = nil + resyncTask?.cancel() + resyncTask = nil + for (_, task) in tailPullTasks { + task.cancel() + } + tailPullTasks.removeAll() + for (_, task) in pagingTasks { + task.cancel() + } + pagingTasks.removeAll() + } + + /// Opens or retains the conversation replica for a session. + /// + /// Each call claims one owner. Balance it with ``closeConversation(sessionID:)``; + /// the replica remains live until its final owner closes it. + /// - Parameter sessionID: The session to open. + /// - Returns: The conversation replica for the session. + public func openConversation(sessionID: AgentSessionID) -> ConversationReplica { + conversationOwnerCounts[sessionID, default: 0] += 1 + return conversationForUse(sessionID: sessionID) + } + /// Releases one conversation owner and drops its live subscription after the last release. + /// - Parameter sessionID: The session to close. + public func closeConversation(sessionID: AgentSessionID) { + guard let ownerCount = conversationOwnerCounts[sessionID] else { return } + if ownerCount > 1 { + conversationOwnerCounts[sessionID] = ownerCount - 1 + return + } + conversationOwnerCounts[sessionID] = nil + guard conversations.removeValue(forKey: sessionID) != nil else { return } + streamingTails[sessionID] = nil + hasMoreBeforeBySession[sessionID] = nil + hasMoreAfterBySession[sessionID] = nil + cachedCapabilities[sessionID] = nil + tailPullTasks.removeValue(forKey: sessionID)?.cancel() + cancelPagingRequests(sessionID: sessionID) + if started { + triggerResync() + } + } + + /// Queues a user send ticket and submits it immediately when connected. + /// - Parameters: + /// - sessionID: The destination session. + /// - text: User-authored text. + /// - Returns: The minted send-ticket identifier. + @discardableResult + public func send(sessionID: AgentSessionID, text: String) -> UUID { + let conversation = conversationForUse(sessionID: sessionID) + let ticketID = ticketIDGenerator.nextTicketID() + let ticket = SendTicket( + id: ticketID, + sessionID: sessionID, + text: text, + attachmentCount: 0, + state: .queuedLocal, + createdAt: replicaClock.tick() + ) + conversation.apply(.sendTicketChanged(ticket), origin: .live) + if connectivity.phase == .connected { + Task { @MainActor [weak self] in + guard let self else { return } + do { + try await self.submitQueuedTicket(ticket, origin: .live) + } catch { + self.scheduleRetry(reason: Self.errorDescription(error)) + } + } + } + return ticketID + } + + /// Requeues a failed send using its original idempotency key. + /// - Parameters: + /// - sessionID: The ticket's conversation. + /// - ticketID: The failed ticket identifier. + /// - Returns: Whether a matching failed ticket was requeued. + @discardableResult + public func retrySend(sessionID: AgentSessionID, ticketID: UUID) -> Bool { + guard let conversation = conversations[sessionID], + let failedTicket = conversation.sendTickets.first(where: { $0.id == ticketID }), + case .failed = failedTicket.state + else { return false } + let queuedTicket = SendTicket( + id: failedTicket.id, + sessionID: failedTicket.sessionID, + text: failedTicket.text, + attachmentCount: failedTicket.attachmentCount, + state: .queuedLocal, + createdAt: failedTicket.createdAt + ) + conversation.apply(.sendTicketChanged(queuedTicket), origin: .live) + if connectivity.phase == .connected { + Task { @MainActor [weak self] in + guard let self else { return } + do { + try await self.submitQueuedTicket(queuedTicket, origin: .live) + } catch { + self.scheduleRetry(reason: Self.errorDescription(error)) + } + } + } + return true + } + private func conversationForUse(sessionID: AgentSessionID) -> ConversationReplica { + if let conversation = conversations[sessionID] { + return conversation + } + conversationOwnerCounts[sessionID] = max(conversationOwnerCounts[sessionID] ?? 0, 1) + let conversation = ConversationReplica( + sessionID: sessionID, + clock: replicaClock + ) + conversations[sessionID] = conversation + if started { + triggerResync() + } + return conversation + } + + /// Interrupts a connected session without offline queueing. + /// - Parameters: + /// - sessionID: The session to interrupt. + /// - hard: Whether to request a hard interrupt. + /// - Throws: ``AgentSyncError/offline`` when the engine is not connected. + public func interrupt(sessionID: AgentSessionID, hard: Bool) async throws { + try ensureConnected() + let params = GuiInterruptParams(sessionID: sessionID, hard: hard) + let data = try await request(method: GuiWireMethod.interrupt, params: params) + _ = try decode(GuiInterruptResult.self, from: data) + } + + /// Answers a connected pending ask without offline queueing. + /// - Parameters: + /// - sessionID: The session that owns the ask. + /// - askID: The ask identifier. + /// - choice: The selected zero-based choice index. + /// - Throws: ``AgentSyncError/offline`` when the engine is not connected. + public func answer(sessionID: AgentSessionID, askID: String, choice: Int) async throws { + try ensureConnected() + let params = GuiAnswerParams(sessionID: sessionID, askID: askID, choiceIndex: choice) + let data = try await request(method: GuiWireMethod.answer, params: params) + _ = try decode(GuiAnswerResult.self, from: data) + } + + /// Loads the next older page for an open conversation. + /// - Parameter sessionID: The session whose older page should load. + /// - Throws: ``AgentSyncError/conversationNotOpen`` when the conversation is not open. + public func loadOlder(sessionID: AgentSessionID) async throws { + guard let conversation = conversations[sessionID] else { + throw AgentSyncError.conversationNotOpen + } + guard hasMoreBeforeBySession[sessionID] ?? conversation.hasMoreBefore else { return } + let generation = pagingGenerationBySession[sessionID, default: 0] + let result: GuiEntriesResult + if let cursor = conversation.startCursor { + result = try await coalescedPage( + sessionID: sessionID, + journalID: conversation.journalID, + anchor: .before, + cursor: cursor + ) + } else { + result = try await entries( + sessionID: sessionID, + journalID: conversation.journalID, + beforeSeq: conversation.loadedRanges.map(\.lowerBound).min(), + afterSeq: nil, + limit: 50 + ) + } + guard pagingGenerationBySession[sessionID, default: 0] == generation else { return } + merge(result, into: conversation, retaining: .oldest) + } + + /// Loads the next newer page for an open conversation. + /// - Parameter sessionID: The session whose newer page should load. + public func loadNewer(sessionID: AgentSessionID) async throws { + guard let conversation = conversations[sessionID] else { + throw AgentSyncError.conversationNotOpen + } + guard conversation.hasMoreAfter else { return } + let generation = pagingGenerationBySession[sessionID, default: 0] + let result: GuiEntriesResult + if let cursor = conversation.endCursor { + result = try await coalescedPage( + sessionID: sessionID, + journalID: conversation.journalID, + anchor: .after, + cursor: cursor + ) + } else { + result = try await entries( + sessionID: sessionID, + journalID: conversation.journalID, + beforeSeq: nil, + afterSeq: conversation.loadedRanges.map(\.upperBound).max(), + limit: 50 + ) + } + guard pagingGenerationBySession[sessionID, default: 0] == generation else { return } + merge(result, into: conversation, retaining: .newest) + } + + /// Replaces the loaded window with the first journal page. + /// - Parameter sessionID: The session to jump to the beginning of. + public func jumpToHead(sessionID: AgentSessionID) async throws { + guard let conversation = conversations[sessionID] else { + throw AgentSyncError.conversationNotOpen + } + let generation = beginNewPagingGeneration(sessionID: sessionID) + let result = try await coalescedPage( + sessionID: sessionID, + journalID: conversation.journalID, + anchor: .head, + cursor: nil + ) + guard pagingGenerationBySession[sessionID] == generation else { return } + merge(result, into: conversation, retaining: .oldest, replacingWindow: true) + guard result.requiresPagingRestart else { return } + + // A stale journal expectation resolves to the authoritative tail so + // the client can adopt the replacement journal. Preserve the user's + // semantic request by retrying head once with that new journal. + let retry = try await coalescedPage( + sessionID: sessionID, + journalID: conversation.journalID, + anchor: .head, + cursor: nil + ) + guard pagingGenerationBySession[sessionID] == generation else { return } + merge(retry, into: conversation, retaining: .oldest, replacingWindow: true) + } + + /// Replaces the loaded window with the current journal tail page. + /// - Parameter sessionID: The session to jump to the end of. + public func jumpToTail(sessionID: AgentSessionID) async throws { + guard let conversation = conversations[sessionID] else { + throw AgentSyncError.conversationNotOpen + } + let generation = beginNewPagingGeneration(sessionID: sessionID) + let result = try await cursorEntries( + sessionID: sessionID, + journalID: conversation.journalID, + anchor: .tail, + cursor: nil, + limit: 50 + ) + guard pagingGenerationBySession[sessionID] == generation else { return } + merge(result, into: conversation, retaining: .newest, replacingWindow: true) + } + + private func coalescedPage( + sessionID: AgentSessionID, + journalID: JournalID?, + anchor: GuiEntriesAnchor, + cursor: JournalCursor? + ) async throws -> GuiEntriesResult { + let key = AgentPagingRequestKey(sessionID: sessionID, anchor: anchor, cursor: cursor) + if let task = pagingTasks[key] { + return try await task.value + } + let task = Task { @MainActor [weak self] in + guard let self else { throw CancellationError() } + return try await self.cursorEntries( + sessionID: sessionID, + journalID: journalID, + anchor: anchor, + cursor: cursor, + limit: 50 + ) + } + pagingTasks[key] = task + defer { pagingTasks[key] = nil } + return try await task.value + } + + @discardableResult + private func beginNewPagingGeneration(sessionID: AgentSessionID) -> UInt64 { + cancelPagingRequests(sessionID: sessionID) + let generation = pagingGenerationBySession[sessionID, default: 0] &+ 1 + pagingGenerationBySession[sessionID] = generation + return generation + } + + private func cancelPagingRequests(sessionID: AgentSessionID) { + let keys = pagingTasks.keys.filter { $0.sessionID == sessionID } + for key in keys { + pagingTasks.removeValue(forKey: key)?.cancel() + } + } + + /// Fetches and caches a session capability report. + /// - Parameter sessionID: The session to inspect. + /// - Returns: The wire capability report. + public func capabilities(sessionID: AgentSessionID) async throws -> GuiCapabilitiesResult { + let data = try await request( + method: GuiWireMethod.capabilities, + params: GuiCapabilitiesParams(sessionID: sessionID) + ) + let result = try decode(GuiCapabilitiesResult.self, from: data) + cachedCapabilities[sessionID] = result + return result + } + + /// Triggers an immediate reconnect/resync after foregrounding. + public func noteAppForegrounded() { + triggerResync() + } + + /// Triggers an immediate reconnect/resync after a network-path change. + public func noteNetworkPathChanged() { + triggerResync() + } + + private func handleConnectionEvent(_ event: AgentSyncConnectionEvent) { + switch event { + case .up: + triggerResync() + case .down(let reason): + connectivity.setPhase(.offline(reason: reason)) + frameTask?.cancel() + frameTask = nil + case .reset: + connectivity.setPhase(.offline(reason: "reset")) + frameTask?.cancel() + frameTask = nil + triggerResync() + } + } + + func triggerResync() { + guard started, !stopped else { return } + retryTask?.cancel() + retryTask = nil + if resyncTask != nil { + resyncRequested = true + return + } + resyncTask = Task { @MainActor [weak self] in + guard let self else { return } + await self.runResyncUntilSettled() + } + } + + private func scheduleRetry(reason: String) { + guard !stopped else { return } + connectivity.recordFailure(reason: reason) + connectivity.setPhase(.offline(reason: reason)) + retryAttempt += 1 + retryTask?.cancel() + retryTask = Task { @MainActor [weak self] in + guard let self else { return } + let delay = self.retryDelayMilliseconds(attempt: self.retryAttempt) + self.connectivity.setPhase(.connecting(backoffMilliseconds: delay)) + await self.syncClock.sleep(milliseconds: delay) + guard !Task.isCancelled else { return } + self.triggerResync() + } + } + + private func runResyncUntilSettled() async { + repeat { + resyncRequested = false + do { + try await runResync() + retryAttempt = 0 + } catch { + resyncTask = nil + scheduleRetry(reason: Self.errorDescription(error)) + return + } + } while resyncRequested && !Task.isCancelled + resyncTask = nil + } + + private func runResync() async throws { + connectivity.setPhase(.updating) + let hello = try await requestHello() + applyEpochChangeIfNeeded(to: hello.epoch) + + let topics = desiredTopics() + let stream = try await transport.subscribe(topics: topics) + startFrameTask(stream: stream) + + let sessions = try await requestSessions() + applyEpochChangeIfNeeded(to: sessions.epoch) + directory.replaceAll(sessions.sessions, epoch: sessions.epoch) + + for sessionID in conversations.keys.sorted(by: { $0.rawValue < $1.rawValue }) { + try await pullTail(sessionID: sessionID) + } + try await flushQueuedTickets() + connectivity.recordSuccessfulSync() + connectivity.setPhase(.connected) + } + + private func applyEpochChangeIfNeeded(to epoch: ReplicaEpoch) { + guard directory.epoch != epoch else { return } + directory.handleEpochChange(to: epoch) + for conversation in conversations.values { + conversation.handleEpochChange(to: epoch) + streamingTails[conversation.sessionID] = nil + } + } + + private func startFrameTask(stream: AsyncStream) { + frameTask?.cancel() + frameTask = Task { @MainActor [weak self] in + guard let self else { return } + for await frame in stream { + guard !Task.isCancelled else { return } + await self.handle(frame) + } + } + } + + private func handle(_ frame: AgentSyncFrame) async { + let event: GuiEventFrame + do { + event = try decoder.decode(GuiEventFrame.self, from: frame.payload) + } catch { + await recordMalformedFrame() + return + } + guard event.epoch == directory.epoch else { + triggerResync() + return + } + switch event.payload { + case .sessionUpserted(let value): + directory.apply(.sessionUpserted(value.session), origin: .live) + case .sessionRemoved(let value): + directory.apply(.sessionRemoved(id: value.sessionID, version: value.version), origin: .live) + case .entriesAppended(let value): + guard let sessionID = sessionID(for: event, topic: frame.topic), + let conversation = conversations[sessionID] else { return } + conversation.apply(.entriesAppended(journalID: value.journalID, entries: value.entries), origin: .live) + clearStreamingTailIfCommitted(sessionID: sessionID, conversation: conversation) + scheduleTailPullIfNeeded(for: conversation) + case .entryReplaced(let value): + guard let sessionID = sessionID(for: event, topic: frame.topic), + let conversation = conversations[sessionID] else { return } + conversation.apply(.entryReplaced(value.entry), origin: .live) + case .journalReset(let value): + guard let conversation = conversations[value.sessionID] else { return } + conversation.apply( + .journalReset(sessionID: value.sessionID, newJournal: value.newJournalID, tailSeq: value.tailSeq), + origin: .live + ) + streamingTails[value.sessionID] = nil + scheduleTailPullIfNeeded(for: conversation) + case .sendState(let value): + guard let conversation = conversations[value.ticket.sessionID] else { return } + conversation.apply(.sendTicketChanged(value.ticket), origin: .live) + case .askState(let value): + guard let conversation = conversations[value.ask.sessionID] else { return } + conversation.apply(.askChanged(value.ask), origin: .live) + case .streamTick(let value): + guard let sessionID = sessionID(for: event, topic: frame.topic), + let conversation = conversations[sessionID], + conversation.journalID == value.journalID else { return } + guard !value.textTail.isEmpty else { + streamingTails[sessionID] = nil + return + } + guard conversation.tailSeq == value.afterSeq else { return } + streamingTails[sessionID] = AgentStreamingTail( + journalID: value.journalID, + afterSeq: value.afterSeq, + textTail: value.textTail, + revision: value.revision + ) + case .unknown: + return + } + } + + private func recordMalformedFrame() async { + malformedFrameCount += 1 + let now = await syncClock.nowMilliseconds() + malformedFrameTimes.append(now) + malformedFrameTimes = malformedFrameTimes.filter { now - $0 <= 10_000 } + if malformedFrameTimes.count >= 3 { + malformedFrameTimes.removeAll() + triggerResync() + } + } + + private func scheduleTailPullIfNeeded(for conversation: ConversationReplica) { + guard conversation.needsTailPull else { return } + let sessionID = conversation.sessionID + tailPullTasks[sessionID]?.cancel() + tailPullTasks[sessionID] = Task { @MainActor [weak self] in + guard let self else { return } + await self.syncClock.sleep(milliseconds: 250) + guard !Task.isCancelled else { return } + do { + try await self.pullTail(sessionID: sessionID) + } catch { + self.scheduleRetry(reason: Self.errorDescription(error)) + } + self.tailPullTasks[sessionID] = nil + } + } + + private func pullTail(sessionID: AgentSessionID) async throws { + guard let conversation = conversations[sessionID] else { + throw AgentSyncError.conversationNotOpen + } + let result = try await coalescedPage( + sessionID: sessionID, + journalID: conversation.journalID, + anchor: .tail, + cursor: nil + ) + merge(result, into: conversation, retaining: .newest) + } + + private func merge( + _ result: GuiEntriesResult, + into conversation: ConversationReplica, + retaining requestedEdge: ConversationPageRetentionEdge, + replacingWindow: Bool = false + ) { + let edge: ConversationPageRetentionEdge = result.requiresPagingRestart ? .newest : requestedEdge + conversation.mergePage( + journal: result.journalID, + entries: result.entries, + windowStart: result.windowStart, + windowEnd: result.windowEnd, + tailSeq: result.tailSeq, + hasMoreBefore: result.hasMoreBefore, + hasMoreAfter: result.hasMoreAfter, + startCursor: result.startCursor, + endCursor: result.endCursor, + tailCursor: result.tailCursor, + requiresPagingRestart: result.requiresPagingRestart, + replacingWindow: replacingWindow || result.requiresPagingRestart, + retaining: edge + ) + hasMoreBeforeBySession[conversation.sessionID] = result.hasMoreBefore + hasMoreAfterBySession[conversation.sessionID] = result.hasMoreAfter + clearStreamingTailIfCommitted(sessionID: conversation.sessionID, conversation: conversation) + scheduleTailPullIfNeeded(for: conversation) + } + + private func clearStreamingTailIfCommitted(sessionID: AgentSessionID, conversation: ConversationReplica) { + guard let tail = streamingTails[sessionID], + conversation.journalID == tail.journalID, + conversation.tailSeq.rawValue > tail.afterSeq.rawValue else { return } + streamingTails[sessionID] = nil + } + + private func ensureConnected() throws { + guard connectivity.phase == .connected else { + throw AgentSyncError.offline + } + } + +} diff --git a/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentSyncError.swift b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentSyncError.swift new file mode 100644 index 000000000000..d09e644d7404 --- /dev/null +++ b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentSyncError.swift @@ -0,0 +1,15 @@ +public import CmuxAgentWire + +/// Errors thrown by ``AgentSyncEngine`` before or after transport calls. +public enum AgentSyncError: Error, Hashable, Sendable { + /// The requested operation requires a connected engine. + case offline + /// The requested conversation is not open. + case conversationNotOpen + /// The RPC returned a malformed payload. + case malformedResponse + /// The transport failed with a GUI wire error. + case wire(GuiWireError) + /// The transport failed with an unclassified error. + case transport(String) +} diff --git a/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentSyncJitter.swift b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentSyncJitter.swift new file mode 100644 index 000000000000..19e9a8738357 --- /dev/null +++ b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentSyncJitter.swift @@ -0,0 +1,6 @@ +/// Supplies retry jitter for ``AgentSyncEngine``. +public protocol AgentSyncJitter: Sendable { + /// Returns a multiplier delta in the range `-0.2...0.2`. + /// - Returns: A fractional jitter value. + func retryJitterFraction() -> Double +} diff --git a/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentSyncReplicaClock.swift b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentSyncReplicaClock.swift new file mode 100644 index 000000000000..a1f3167fb038 --- /dev/null +++ b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentSyncReplicaClock.swift @@ -0,0 +1,22 @@ +public import CmuxAgentReplica + +/// Monotonic replica clock used for locally-created send tickets. +public final class AgentSyncReplicaClock: ReplicaClock, @unchecked Sendable { + // The sync engine and replica stores call this clock from the main actor; the + // ReplicaClock protocol itself is nonisolated, so the stored tick is narrowed + // to this escape hatch instead of marking broader engine state unsafe. + private nonisolated(unsafe) var value: Int + + /// Creates a monotonic replica clock. + /// - Parameter initialValue: The initial tick value. + public init(initialValue: Int = 0) { + self.value = initialValue + } + + /// Returns and advances the next tick. + /// - Returns: A monotonic integer tick. + public nonisolated func tick() -> Int { + value += 1 + return value + } +} diff --git a/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentSyncTicketIDGenerator.swift b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentSyncTicketIDGenerator.swift new file mode 100644 index 000000000000..106aa2033454 --- /dev/null +++ b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/AgentSyncTicketIDGenerator.swift @@ -0,0 +1,8 @@ +public import Foundation + +/// Mints stable client-side send-ticket identifiers. +public protocol AgentSyncTicketIDGenerator: Sendable { + /// Creates the next ticket UUID. + /// - Returns: A stable UUID for one send ticket. + func nextTicketID() -> UUID +} diff --git a/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/RandomAgentSyncJitter.swift b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/RandomAgentSyncJitter.swift new file mode 100644 index 000000000000..28b3c5225b6f --- /dev/null +++ b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/RandomAgentSyncJitter.swift @@ -0,0 +1,13 @@ +import Foundation + +/// Production retry jitter source. +public struct RandomAgentSyncJitter: AgentSyncJitter { + /// Creates a random jitter source. + public init() {} + + /// Returns a random retry jitter fraction clamped to the supported bounds. + /// - Returns: A fractional jitter value. + public func retryJitterFraction() -> Double { + Double.random(in: -0.2...0.2) + } +} diff --git a/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/RealSyncClock.swift b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/RealSyncClock.swift new file mode 100644 index 000000000000..4b866c779f82 --- /dev/null +++ b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/RealSyncClock.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Production sync clock backed by Foundation time and `Task.sleep`. +public struct RealSyncClock: SyncClock { + /// Creates a production sync clock. + public init() {} + + /// Returns monotonic milliseconds based on continuous uptime. + /// - Returns: Monotonic milliseconds. + public func nowMilliseconds() async -> Int64 { + Int64(ProcessInfo.processInfo.systemUptime * 1_000) + } + + /// Sleeps for the requested duration. + /// - Parameter milliseconds: Duration in milliseconds. + public func sleep(milliseconds: Int) async { + let nanoseconds = UInt64(max(0, milliseconds)) * 1_000_000 + // Intended retry/debounce delay behind the injected clock seam. + try? await Task.sleep(nanoseconds: nanoseconds) + } +} diff --git a/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/SyncClock.swift b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/SyncClock.swift new file mode 100644 index 000000000000..5770588f29de --- /dev/null +++ b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/SyncClock.swift @@ -0,0 +1,10 @@ +/// Supplies deterministic time and sleeps for ``AgentSyncEngine``. +public protocol SyncClock: Sendable { + /// Returns monotonic milliseconds used for retry and malformed-frame windows. + /// - Returns: Monotonic milliseconds. + func nowMilliseconds() async -> Int64 + + /// Sleeps for the requested duration. + /// - Parameter milliseconds: Duration in milliseconds. + func sleep(milliseconds: Int) async +} diff --git a/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/UUIDAgentSyncTicketIDGenerator.swift b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/UUIDAgentSyncTicketIDGenerator.swift new file mode 100644 index 000000000000..bdf8739ef540 --- /dev/null +++ b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Engine/UUIDAgentSyncTicketIDGenerator.swift @@ -0,0 +1,13 @@ +public import Foundation + +/// Production ticket id generator backed by `UUID()`. +public struct UUIDAgentSyncTicketIDGenerator: AgentSyncTicketIDGenerator { + /// Creates a UUID ticket generator. + public init() {} + + /// Creates the next ticket UUID. + /// - Returns: A fresh UUID. + public func nextTicketID() -> UUID { + UUID() + } +} diff --git a/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Fixture/AgentSyncTransportCall.swift b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Fixture/AgentSyncTransportCall.swift new file mode 100644 index 000000000000..3dd3d559daea --- /dev/null +++ b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Fixture/AgentSyncTransportCall.swift @@ -0,0 +1,36 @@ +public import Foundation + +/// One recorded fixture transport operation. +public struct AgentSyncTransportCall: Hashable, Sendable { + /// The operation kind. + public enum Kind: Hashable, Sendable { + /// A request operation. + case request + /// A subscribe operation. + case subscribe + /// An unsubscribe operation. + case unsubscribe + } + + /// The operation kind. + public let kind: Kind + /// The RPC method for request operations. + public let method: String? + /// The topics for subscription operations. + public let topics: [String] + /// Raw request parameters for request operations. + public let params: Data? + + /// Creates a fixture transport call record. + /// - Parameters: + /// - kind: The operation kind. + /// - method: The RPC method for request operations. + /// - topics: The topics for subscription operations. + /// - params: Raw request parameters for request operations. + public init(kind: Kind, method: String? = nil, topics: [String] = [], params: Data? = nil) { + self.kind = kind + self.method = method + self.topics = topics + self.params = params + } +} diff --git a/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Fixture/FixtureSyncTransport.swift b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Fixture/FixtureSyncTransport.swift new file mode 100644 index 000000000000..cd84445edf37 --- /dev/null +++ b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Fixture/FixtureSyncTransport.swift @@ -0,0 +1,108 @@ +public import Foundation + +/// Scriptable in-memory transport for ``AgentSyncEngine`` tests. +public actor FixtureSyncTransport: AgentSyncTransport { + /// Request handler closure used by the fixture transport. + public typealias RequestHandler = @Sendable (_ params: Data) async throws -> Data + + /// Stream of injected connection events. + public nonisolated let connectionEvents: AsyncStream + + private let connectionContinuation: AsyncStream.Continuation + private var handlers: [String: RequestHandler] + private var subscription: (id: UUID, topics: Set, continuation: AsyncStream.Continuation)? + private var recordedCalls: [AgentSyncTransportCall] + + /// Creates an empty fixture transport. + public init() { + let pair = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(64)) + connectionEvents = pair.stream + connectionContinuation = pair.continuation + handlers = [:] + subscription = nil + recordedCalls = [] + } + + /// Registers a request handler for one method. + /// - Parameters: + /// - method: The RPC method to handle. + /// - handler: The scripted request handler. + public func setHandler(method: String, handler: @escaping RequestHandler) { + handlers[method] = handler + } + + /// Returns the recorded transport calls. + /// - Returns: The call log. + public func calls() -> [AgentSyncTransportCall] { + recordedCalls + } + + /// Removes all recorded transport calls. + public func resetCalls() { + recordedCalls.removeAll() + } + + /// Injects a connection event into ``connectionEvents``. + /// - Parameter event: The event to inject. + public func injectConnectionEvent(_ event: AgentSyncConnectionEvent) { + connectionContinuation.yield(event) + } + + /// Injects a raw event frame to matching subscribers. + /// - Parameters: + /// - topic: The topic carrying the frame. + /// - payload: The raw JSON payload. + public func injectFrame(topic: String, payload: Data) { + guard let subscription, subscription.topics.contains(topic) else { return } + let frame = AgentSyncFrame(topic: topic, payload: payload) + subscription.continuation.yield(frame) + } + + /// Sends a scripted request. + /// - Parameters: + /// - method: The RPC method name. + /// - params: Raw JSON parameters. + /// - Returns: The scripted response. + public func request(method: String, params: Data) async throws -> Data { + recordedCalls.append(AgentSyncTransportCall(kind: .request, method: method, params: params)) + guard let handler = handlers[method] else { + throw FixtureSyncTransportError.unhandledRequest(method) + } + return try await handler(params) + } + + /// Creates a fixture subscription. + /// - Parameter topics: Topics to subscribe to. + /// - Returns: A stream receiving injected matching frames. + public func subscribe(topics: [String]) async throws -> AsyncStream { + recordedCalls.append(AgentSyncTransportCall(kind: .subscribe, topics: topics)) + subscription?.continuation.finish() + let id = UUID() + let pair = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(256)) + subscription = (id, Set(topics), pair.continuation) + pair.continuation.onTermination = { [weak self] _ in + guard let self else { return } + Task { await self.removeSubscription(id: id) } + } + return pair.stream + } + + /// Records an unsubscribe operation and replaces the active topic set. + /// - Parameter topics: Topics to unsubscribe from. + public func unsubscribe(topics: [String]) async { + recordedCalls.append(AgentSyncTransportCall(kind: .unsubscribe, topics: topics)) + guard let active = subscription else { return } + let remaining = active.topics.subtracting(topics) + if remaining.isEmpty { + active.continuation.finish() + subscription = nil + } else { + subscription = (active.id, remaining, active.continuation) + } + } + + private func removeSubscription(id: UUID) { + guard subscription?.id == id else { return } + subscription = nil + } +} diff --git a/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Fixture/FixtureSyncTransportError.swift b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Fixture/FixtureSyncTransportError.swift new file mode 100644 index 000000000000..9f64ca8b98d0 --- /dev/null +++ b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Fixture/FixtureSyncTransportError.swift @@ -0,0 +1,5 @@ +/// Errors thrown by ``FixtureSyncTransport``. +public enum FixtureSyncTransportError: Error, Hashable, Sendable { + /// No handler was registered for the requested method. + case unhandledRequest(String) +} diff --git a/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Transport/AgentSyncConnectionEvent.swift b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Transport/AgentSyncConnectionEvent.swift new file mode 100644 index 000000000000..fb06e6ea093f --- /dev/null +++ b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Transport/AgentSyncConnectionEvent.swift @@ -0,0 +1,9 @@ +/// Connectivity edges observed by an ``AgentSyncTransport``. +public enum AgentSyncConnectionEvent: Hashable, Sendable { + /// The transport is available for RPC and event subscription. + case up + /// The transport is unavailable. + case down(reason: String) + /// The underlying transport was replaced and all subscriptions should be rebuilt. + case reset +} diff --git a/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Transport/AgentSyncFrame.swift b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Transport/AgentSyncFrame.swift new file mode 100644 index 000000000000..45e7be84fb19 --- /dev/null +++ b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Transport/AgentSyncFrame.swift @@ -0,0 +1,18 @@ +public import Foundation + +/// A raw server-pushed `gui.v1` event delivered by an ``AgentSyncTransport``. +public struct AgentSyncFrame: Hashable, Sendable { + /// The subscribed event topic that carried the payload. + public let topic: String + /// The raw JSON payload for the `gui.v1` frame. + public let payload: Data + + /// Creates a sync frame. + /// - Parameters: + /// - topic: The subscribed event topic. + /// - payload: The raw JSON frame payload. + public init(topic: String, payload: Data) { + self.topic = topic + self.payload = payload + } +} diff --git a/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Transport/AgentSyncTransport.swift b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Transport/AgentSyncTransport.swift new file mode 100644 index 000000000000..3fde08eb1faa --- /dev/null +++ b/Packages/Shared/CmuxAgentSync/Sources/CmuxAgentSync/Transport/AgentSyncTransport.swift @@ -0,0 +1,25 @@ +public import Foundation + +/// Abstract request/response and server-pushed-event transport for agent GUI sync. +public protocol AgentSyncTransport: Sendable { + /// Stream of connection edges emitted by the transport. + var connectionEvents: AsyncStream { get } + + /// Sends a raw JSON request to the Mac and returns the raw JSON result. + /// - Parameters: + /// - method: The `gui.v1` RPC method name. + /// - params: Raw JSON request parameters. + /// - Returns: Raw JSON response payload. + /// - Throws: A transport or GUI wire error. + func request(method: String, params: Data) async throws -> Data + + /// Subscribes to the requested event topics and returns matching raw frames. + /// - Parameter topics: Event topics to subscribe to. + /// - Returns: A stream of raw frames for the requested topics. + /// - Throws: A transport or GUI wire error. + func subscribe(topics: [String]) async throws -> AsyncStream + + /// Unsubscribes from event topics that are no longer needed. + /// - Parameter topics: Event topics to unsubscribe from. + func unsubscribe(topics: [String]) async +} diff --git a/Packages/Shared/CmuxAgentSync/Tests/CmuxAgentSyncTests/AgentSyncEngineResilienceTests.swift b/Packages/Shared/CmuxAgentSync/Tests/CmuxAgentSyncTests/AgentSyncEngineResilienceTests.swift new file mode 100644 index 000000000000..09eecb5c1e77 --- /dev/null +++ b/Packages/Shared/CmuxAgentSync/Tests/CmuxAgentSyncTests/AgentSyncEngineResilienceTests.swift @@ -0,0 +1,447 @@ +import CmuxAgentReplica +import CmuxAgentSync +import CmuxAgentWire +import Foundation +import Testing + +@MainActor +extension AgentSyncEngineTests { + @Test + func needsTailPullDebounceCoalescesGapFrames() async throws { + let transport = FixtureSyncTransport() + let server = AgentSyncTestServer(entries: [AgentSyncTestSupport.entry(1)]) + await server.install(on: transport) + let clock = TestSyncClock() + let engine = AgentSyncEngine(transport: transport, syncClock: clock) + let conversation = engine.openConversation(sessionID: AgentSyncTestSupport.session) + defer { engine.stop() } + engine.start() + #expect(await AgentSyncTestSupport.waitUntil { engine.connectivity.phase == .connected }) + await server.setEntriesResult(AgentSyncTestSupport.page(entries: [ + AgentSyncTestSupport.entry(1), + AgentSyncTestSupport.entry(2), + AgentSyncTestSupport.entry(3), + AgentSyncTestSupport.entry(4), + ])) + + for sequence in [3, 4] { + let frame = try AgentSyncTestSupport.eventData(.entriesAppended( + GuiEntriesAppendedEvent( + journalID: AgentSyncTestSupport.journalOne, + entries: [AgentSyncTestSupport.entry(sequence)] + ) + )) + await transport.injectFrame( + topic: GuiWireTopic.journal(sessionID: AgentSyncTestSupport.session), + payload: frame + ) + #expect(await AgentSyncTestSupport.waitUntil { await clock.pendingSleepCount() == 1 }) + } + #expect(conversation.needsTailPull) + #expect(await server.requestCount(method: GuiWireMethod.entries) == 1) + + await clock.advance(milliseconds: 250) + #expect(await AgentSyncTestSupport.waitUntil { + await server.requestCount(method: GuiWireMethod.entries) == 2 + }) + #expect(await AgentSyncTestSupport.waitUntil { conversation.entries.count == 4 }) + #expect(conversation.entries.map(\.seq.rawValue) == [1, 2, 3, 4]) + } + + @Test + func streamTickRequiresContiguousTextAndClearsOnMismatchedEmptyTickOrAppend() async throws { + let transport = FixtureSyncTransport() + let server = AgentSyncTestServer(entries: [ + AgentSyncTestSupport.entry(1), + AgentSyncTestSupport.entry(2), + ]) + await server.install(on: transport) + let engine = AgentSyncEngine(transport: transport) + _ = engine.openConversation(sessionID: AgentSyncTestSupport.session) + defer { engine.stop() } + engine.start() + #expect(await AgentSyncTestSupport.waitUntil { engine.connectivity.phase == .connected }) + + let contiguous = try AgentSyncTestSupport.eventData(.streamTick( + GuiStreamTickEvent( + journalID: AgentSyncTestSupport.journalOne, + afterSeq: EntrySeq(rawValue: 2), + textTail: "working", + revision: 1 + ) + )) + await transport.injectFrame( + topic: GuiWireTopic.journal(sessionID: AgentSyncTestSupport.session), + payload: contiguous + ) + #expect(await AgentSyncTestSupport.waitUntil { + engine.streamingTails[AgentSyncTestSupport.session]?.revision == 1 + }) + + let noncontiguous = try AgentSyncTestSupport.eventData(.streamTick( + GuiStreamTickEvent( + journalID: AgentSyncTestSupport.journalOne, + afterSeq: EntrySeq(rawValue: 3), + textTail: "wrong tail", + revision: 2 + ) + )) + await transport.injectFrame( + topic: GuiWireTopic.journal(sessionID: AgentSyncTestSupport.session), + payload: noncontiguous + ) + for _ in 0..<20 { await Task.yield() } + #expect(engine.streamingTails[AgentSyncTestSupport.session]?.revision == 1) + + let mismatchedClear = try AgentSyncTestSupport.eventData(.streamTick( + GuiStreamTickEvent( + journalID: AgentSyncTestSupport.journalOne, + afterSeq: EntrySeq(rawValue: 3), + textTail: "", + revision: 2 + ) + )) + await transport.injectFrame( + topic: GuiWireTopic.journal(sessionID: AgentSyncTestSupport.session), + payload: mismatchedClear + ) + #expect(await AgentSyncTestSupport.waitUntil { + engine.streamingTails[AgentSyncTestSupport.session] == nil + }) + + await transport.injectFrame( + topic: GuiWireTopic.journal(sessionID: AgentSyncTestSupport.session), + payload: contiguous + ) + #expect(await AgentSyncTestSupport.waitUntil { + engine.streamingTails[AgentSyncTestSupport.session]?.revision == 1 + }) + + let append = try AgentSyncTestSupport.eventData(.entriesAppended( + GuiEntriesAppendedEvent( + journalID: AgentSyncTestSupport.journalOne, + entries: [AgentSyncTestSupport.entry(3)] + ) + )) + await transport.injectFrame( + topic: GuiWireTopic.journal(sessionID: AgentSyncTestSupport.session), + payload: append + ) + #expect(await AgentSyncTestSupport.waitUntil { + engine.streamingTails[AgentSyncTestSupport.session] == nil + }) + } + + @Test + func unknownFrameIsIgnored() async throws { + let transport = FixtureSyncTransport() + let server = AgentSyncTestServer(entries: [AgentSyncTestSupport.entry(1)]) + await server.install(on: transport) + let engine = AgentSyncEngine(transport: transport) + let conversation = engine.openConversation(sessionID: AgentSyncTestSupport.session) + defer { engine.stop() } + engine.start() + #expect(await AgentSyncTestSupport.waitUntil { engine.connectivity.phase == .connected }) + let state = conversation.state + let helloCount = await server.requestCount(method: GuiWireMethod.hello) + + let unknown = try AgentSyncTestSupport.eventData(.unknown("future_event")) + await transport.injectFrame(topic: GuiWireTopic.sessions, payload: unknown) + for _ in 0..<50 { await Task.yield() } + + #expect(conversation.state == state) + #expect(await server.requestCount(method: GuiWireMethod.hello) == helloCount) + #expect(engine.malformedFrameCount == 0) + } + + @Test + func threeMalformedFramesWithinTenSecondsForceResync() async { + let transport = FixtureSyncTransport() + let server = AgentSyncTestServer() + await server.install(on: transport) + let clock = TestSyncClock(currentMilliseconds: 9_000) + let engine = AgentSyncEngine(transport: transport, syncClock: clock) + defer { engine.stop() } + engine.start() + #expect(await AgentSyncTestSupport.waitUntil { engine.connectivity.phase == .connected }) + + for _ in 0..<3 { + await transport.injectFrame( + topic: GuiWireTopic.sessions, + payload: Data("{".utf8) + ) + } + #expect(await AgentSyncTestSupport.waitUntil { + await server.requestCount(method: GuiWireMethod.hello) == 2 + && engine.connectivity.phase == .connected + }) + #expect(engine.malformedFrameCount == 3) + #expect(engine.connectivity.phase == .connected) + } + + @Test + func malformedFrameWindowExpiresBeforeThreshold() async { + let transport = FixtureSyncTransport() + let server = AgentSyncTestServer() + await server.install(on: transport) + let clock = TestSyncClock() + let engine = AgentSyncEngine(transport: transport, syncClock: clock) + defer { engine.stop() } + engine.start() + #expect(await AgentSyncTestSupport.waitUntil { engine.connectivity.phase == .connected }) + + for _ in 0..<2 { + await transport.injectFrame(topic: GuiWireTopic.sessions, payload: Data("{".utf8)) + } + #expect(await AgentSyncTestSupport.waitUntil { engine.malformedFrameCount == 2 }) + await clock.advance(milliseconds: 10_001) + await transport.injectFrame(topic: GuiWireTopic.sessions, payload: Data("{".utf8)) + #expect(await AgentSyncTestSupport.waitUntil { engine.malformedFrameCount == 3 }) + for _ in 0..<20 { await Task.yield() } + #expect(await server.requestCount(method: GuiWireMethod.hello) == 1) + + for _ in 0..<2 { + await transport.injectFrame(topic: GuiWireTopic.sessions, payload: Data("{".utf8)) + } + #expect(await AgentSyncTestSupport.waitUntil { + await server.requestCount(method: GuiWireMethod.hello) == 2 + && engine.connectivity.phase == .connected + }) + } + + @Test + func capabilitiesResultIsCachedPerSession() async throws { + let transport = FixtureSyncTransport() + let server = AgentSyncTestServer() + let report = GuiCapabilitiesResult( + tier: .hooked, + reasons: [GuiCapabilityReason(code: "limited", detail: "fixture")], + cliVersion: "2.3.4", + steerable: true, + answerable: false + ) + await server.setCapabilitiesResult(report) + await server.install(on: transport) + let engine = AgentSyncEngine(transport: transport) + defer { engine.stop() } + engine.start() + #expect(await AgentSyncTestSupport.waitUntil { engine.connectivity.phase == .connected }) + + let returned = try await engine.capabilities(sessionID: AgentSyncTestSupport.session) + + #expect(returned == report) + #expect(engine.cachedCapabilities[AgentSyncTestSupport.session] == report) + #expect(await server.requestCount(method: GuiWireMethod.capabilities) == 1) + } + + @Test + func interruptAndAnswerRejectOfflineWithoutRequests() async { + let transport = FixtureSyncTransport() + let engine = AgentSyncEngine(transport: transport) + defer { engine.stop() } + + await #expect(throws: AgentSyncError.offline) { + try await engine.interrupt(sessionID: AgentSyncTestSupport.session, hard: true) + } + await #expect(throws: AgentSyncError.offline) { + try await engine.answer(sessionID: AgentSyncTestSupport.session, askID: "ask-1", choice: 0) + } + #expect(await transport.calls().isEmpty) + } + + @Test + func loadOlderUsesWindowMinimumAndExposesHasMoreBefore() async throws { + let transport = FixtureSyncTransport() + let server = AgentSyncTestServer() + await server.setEntriesResult(AgentSyncTestSupport.page( + entries: [AgentSyncTestSupport.entry(3), AgentSyncTestSupport.entry(4)], + windowStart: 3, + windowEnd: 4, + tail: 4, + hasMoreBefore: true + )) + await server.install(on: transport) + let clock = TestSyncClock() + let engine = AgentSyncEngine(transport: transport, syncClock: clock) + let conversation = engine.openConversation(sessionID: AgentSyncTestSupport.session) + defer { engine.stop() } + engine.start() + #expect(await AgentSyncTestSupport.waitUntil { engine.connectivity.phase == .connected }) + #expect(engine.hasMoreBeforeBySession[AgentSyncTestSupport.session] == true) + + await server.setEntriesResult(AgentSyncTestSupport.page( + entries: [AgentSyncTestSupport.entry(1), AgentSyncTestSupport.entry(2)], + windowStart: 1, + windowEnd: 2, + tail: 4, + hasMoreBefore: false + )) + try await engine.loadOlder(sessionID: AgentSyncTestSupport.session) + + let params = await server.entriesParams() + #expect(params.last?.beforeSeq == EntrySeq(rawValue: 3)) + #expect(conversation.entries.map(\.seq.rawValue) == [1, 2, 3, 4]) + #expect(engine.hasMoreBeforeBySession[AgentSyncTestSupport.session] == false) + } + + @Test + func cursorLoadOlderCoalescesConcurrentRequestsAndMergesSparseOffsets() async throws { + let transport = FixtureSyncTransport() + let server = AgentSyncTestServer() + await server.setEntriesResult(AgentSyncTestSupport.page( + entries: [AgentSyncTestSupport.entry(10_000), AgentSyncTestSupport.entry(20_000)], + tail: 20_000, + hasMoreBefore: true, + startCursor: "start-10000", + endCursor: "tail-20000", + tailCursor: "tail-20000" + )) + await server.install(on: transport) + let engine = AgentSyncEngine(transport: transport, syncClock: TestSyncClock()) + let conversation = engine.openConversation(sessionID: AgentSyncTestSupport.session) + defer { engine.stop() } + engine.start() + #expect(await AgentSyncTestSupport.waitUntil { engine.connectivity.phase == .connected }) + + await server.setEntriesResult(AgentSyncTestSupport.page( + entries: [AgentSyncTestSupport.entry(1_000), AgentSyncTestSupport.entry(5_000)], + tail: 20_000, + hasMoreBefore: false, + hasMoreAfter: true, + startCursor: "head-1000", + endCursor: "start-10000", + tailCursor: "tail-20000" + )) + await server.gateNextEntriesRequest() + async let first: Void = engine.loadOlder(sessionID: AgentSyncTestSupport.session) + async let second: Void = engine.loadOlder(sessionID: AgentSyncTestSupport.session) + #expect(await AgentSyncTestSupport.waitUntil { + await server.requestCount(method: GuiWireMethod.entries) == 2 + }) + await server.resumeEntriesRequest() + try await first + try await second + + let params = await server.entriesParams() + #expect(params.last?.anchor == .before) + #expect(params.last?.cursor == JournalCursor(rawValue: "start-10000")) + #expect(await server.requestCount(method: GuiWireMethod.entries) == 2) + #expect(conversation.entries.map(\.seq.rawValue) == [1_000, 5_000, 10_000, 20_000]) + #expect(conversation.holes.isEmpty) + #expect(conversation.unreadCount == 4) + #expect(!conversation.needsTailPull) + } + + @Test + func cursorLoadNewerAdvancesOnePageWithoutSchedulingTailJump() async throws { + let transport = FixtureSyncTransport() + let server = AgentSyncTestServer() + await server.setEntriesResult(AgentSyncTestSupport.page( + entries: [AgentSyncTestSupport.entry(151), AgentSyncTestSupport.entry(200)], + tail: 200, + hasMoreBefore: true, + startCursor: "c150", + endCursor: "c200", + tailCursor: "c200" + )) + await server.install(on: transport) + let clock = TestSyncClock() + let engine = AgentSyncEngine(transport: transport, syncClock: clock) + let conversation = engine.openConversation(sessionID: AgentSyncTestSupport.session) + defer { engine.stop() } + engine.start() + #expect(await AgentSyncTestSupport.waitUntil { engine.connectivity.phase == .connected }) + + await server.setEntriesResult(AgentSyncTestSupport.page( + entries: [AgentSyncTestSupport.entry(1), AgentSyncTestSupport.entry(50)], + tail: 200, + hasMoreAfter: true, + startCursor: "c0", + endCursor: "c50", + tailCursor: "c200" + )) + try await engine.jumpToHead(sessionID: AgentSyncTestSupport.session) + + await server.setEntriesResult(AgentSyncTestSupport.page( + entries: [AgentSyncTestSupport.entry(51), AgentSyncTestSupport.entry(100)], + tail: 200, + hasMoreBefore: true, + hasMoreAfter: true, + startCursor: "c50", + endCursor: "c100", + tailCursor: "c200" + )) + try await engine.loadNewer(sessionID: AgentSyncTestSupport.session) + + #expect(conversation.entries.map(\.seq.rawValue) == [1, 50, 51, 100]) + #expect(conversation.endCursor == JournalCursor(rawValue: "c100")) + #expect(conversation.hasMoreAfter) + #expect(!conversation.needsTailPull) + #expect(await clock.pendingSleepCount() == 0) + #expect(await server.requestCount(method: GuiWireMethod.entries) == 3) + + await clock.advance(milliseconds: 250) + for _ in 0..<50 { await Task.yield() } + #expect(await server.requestCount(method: GuiWireMethod.entries) == 3) + } + + @Test + func jumpToHeadRetriesOnceAfterJournalRotationRestart() async throws { + let transport = FixtureSyncTransport() + let server = AgentSyncTestServer() + await server.setEntriesResult(AgentSyncTestSupport.page( + entries: [AgentSyncTestSupport.entry(90), AgentSyncTestSupport.entry(100)], + tail: 100, + hasMoreBefore: true, + startCursor: "old-90", + endCursor: "old-100", + tailCursor: "old-100" + )) + await server.install(on: transport) + let engine = AgentSyncEngine(transport: transport, syncClock: TestSyncClock()) + let conversation = engine.openConversation(sessionID: AgentSyncTestSupport.session) + defer { engine.stop() } + engine.start() + #expect(await AgentSyncTestSupport.waitUntil { engine.connectivity.phase == .connected }) + + await server.enqueueEntriesResult(AgentSyncTestSupport.page( + journalID: AgentSyncTestSupport.journalTwo, + entries: [ + AgentSyncTestSupport.entry(900, journalID: AgentSyncTestSupport.journalTwo), + AgentSyncTestSupport.entry(1_000, journalID: AgentSyncTestSupport.journalTwo), + ], + tail: 1_000, + hasMoreBefore: true, + startCursor: "new-900", + endCursor: "new-1000", + tailCursor: "new-1000", + requiresPagingRestart: true + )) + await server.enqueueEntriesResult(AgentSyncTestSupport.page( + journalID: AgentSyncTestSupport.journalTwo, + entries: [ + AgentSyncTestSupport.entry(10, journalID: AgentSyncTestSupport.journalTwo), + AgentSyncTestSupport.entry(20, journalID: AgentSyncTestSupport.journalTwo), + ], + tail: 1_000, + hasMoreAfter: true, + startCursor: "new-0", + endCursor: "new-20", + tailCursor: "new-1000" + )) + + try await engine.jumpToHead(sessionID: AgentSyncTestSupport.session) + + #expect(conversation.journalID == AgentSyncTestSupport.journalTwo) + #expect(conversation.entries.map(\.seq.rawValue) == [10, 20]) + #expect(conversation.startCursor == JournalCursor(rawValue: "new-0")) + #expect(conversation.endCursor == JournalCursor(rawValue: "new-20")) + let params = await server.entriesParams() + #expect(params.suffix(2).map(\.anchor) == [.head, .head]) + #expect(params.suffix(2).map(\.journalID) == [ + AgentSyncTestSupport.journalOne, + AgentSyncTestSupport.journalTwo, + ]) + #expect(await server.requestCount(method: GuiWireMethod.entries) == 3) + } +} diff --git a/Packages/Shared/CmuxAgentSync/Tests/CmuxAgentSyncTests/AgentSyncEngineTests.swift b/Packages/Shared/CmuxAgentSync/Tests/CmuxAgentSyncTests/AgentSyncEngineTests.swift new file mode 100644 index 000000000000..c42d50657f6a --- /dev/null +++ b/Packages/Shared/CmuxAgentSync/Tests/CmuxAgentSyncTests/AgentSyncEngineTests.swift @@ -0,0 +1,517 @@ +import CmuxAgentReplica +import CmuxAgentSync +import CmuxAgentWire +import Foundation +import Testing + +@MainActor +@Suite +struct AgentSyncEngineTests { + @Test + func subscribeHappensBeforeAuthoritativePulls() async throws { + let transport = FixtureSyncTransport() + let server = AgentSyncTestServer(entries: [ + AgentSyncTestSupport.entry(1), + AgentSyncTestSupport.entry(2), + ]) + await server.install(on: transport) + let engine = AgentSyncEngine(transport: transport) + _ = engine.openConversation(sessionID: AgentSyncTestSupport.session) + defer { engine.stop() } + + engine.start() + #expect(await AgentSyncTestSupport.waitUntil { engine.connectivity.phase == .connected }) + + let calls = await transport.calls() + let helloIndex = try #require(calls.firstIndex { $0.method == GuiWireMethod.hello }) + let subscribeIndex = try #require(calls.firstIndex { $0.kind == .subscribe }) + let sessionsIndex = try #require(calls.firstIndex { $0.method == GuiWireMethod.sessions }) + let entriesIndex = try #require(calls.firstIndex { $0.method == GuiWireMethod.entries }) + #expect(helloIndex < subscribeIndex) + #expect(subscribeIndex < sessionsIndex) + #expect(subscribeIndex < entriesIndex) + #expect(calls[subscribeIndex].topics == [ + GuiWireTopic.sessions, + GuiWireTopic.journal(sessionID: AgentSyncTestSupport.session), + ]) + } + + @Test + func closingOneConversationKeepsOtherConversationLive() async throws { + let transport = FixtureSyncTransport() + let server = AgentSyncTestServer(entries: [AgentSyncTestSupport.entry(1)]) + await server.install(on: transport) + let engine = AgentSyncEngine(transport: transport) + _ = engine.openConversation(sessionID: AgentSyncTestSupport.session) + let remainingConversation = engine.openConversation(sessionID: AgentSyncTestSupport.otherSession) + defer { engine.stop() } + + engine.start() + #expect(await AgentSyncTestSupport.waitUntil { + let entriesCount = await server.requestCount(method: GuiWireMethod.entries) + return engine.connectivity.phase == .connected && entriesCount == 2 + }) + + engine.closeConversation(sessionID: AgentSyncTestSupport.session) + #expect(await AgentSyncTestSupport.waitUntil { + let entriesCount = await server.requestCount(method: GuiWireMethod.entries) + return engine.connectivity.phase == .connected && entriesCount == 3 + }) + + let append = try AgentSyncTestSupport.eventData( + .entriesAppended(GuiEntriesAppendedEvent( + journalID: AgentSyncTestSupport.journalOne, + entries: [AgentSyncTestSupport.entry(2)] + )), + sessionID: AgentSyncTestSupport.otherSession + ) + await transport.injectFrame( + topic: GuiWireTopic.journal(sessionID: AgentSyncTestSupport.otherSession), + payload: append + ) + #expect(await AgentSyncTestSupport.waitUntil { remainingConversation.entries.count == 2 }) + + let latestSubscription = await transport.calls().last { $0.kind == .subscribe } + #expect(latestSubscription?.topics == [ + GuiWireTopic.sessions, + GuiWireTopic.journal(sessionID: AgentSyncTestSupport.otherSession), + ]) + #expect(remainingConversation.entries.map(\.seq.rawValue) == [1, 2]) + } + + @Test + func offlineSendQueueFlushesFIFOWithPerItemAcknowledgements() async { + let transport = FixtureSyncTransport() + let server = AgentSyncTestServer() + await server.setSendOutcomes([.accepted, .failure(.inputQueueFull), .rejected]) + await server.install(on: transport) + let engine = AgentSyncEngine( + transport: transport, + replicaClock: AgentSyncReplicaClock(initialValue: 10), + ticketIDGenerator: SequentialTicketIDGenerator() + ) + let conversation = engine.openConversation(sessionID: AgentSyncTestSupport.session) + defer { engine.stop() } + + _ = engine.send(sessionID: AgentSyncTestSupport.session, text: "one") + _ = engine.send(sessionID: AgentSyncTestSupport.session, text: "two") + _ = engine.send(sessionID: AgentSyncTestSupport.session, text: "three") + #expect(conversation.sendTickets.map(\.state) == [.queuedLocal, .queuedLocal, .queuedLocal]) + + engine.start() + #expect(await AgentSyncTestSupport.waitUntil { engine.connectivity.phase == .connected }) + + let sends = await server.sendParams() + #expect(sends.compactMap(\.text) == ["one", "two", "three"]) + #expect(conversation.sendTickets.map(\.state) == [ + .acceptedByMac, + .failed(code: "input_queue_full"), + .failed(code: "send_rejected"), + ]) + } + + @Test + func failedSendRetriesWithTheSameIdempotencyKey() async { + let transport = FixtureSyncTransport() + let server = AgentSyncTestServer() + await server.setSendOutcomes([.rejected, .accepted]) + await server.install(on: transport) + let engine = AgentSyncEngine( + transport: transport, + ticketIDGenerator: SequentialTicketIDGenerator() + ) + let conversation = engine.openConversation(sessionID: AgentSyncTestSupport.session) + defer { engine.stop() } + engine.start() + #expect(await AgentSyncTestSupport.waitUntil { engine.connectivity.phase == .connected }) + + let ticketID = engine.send(sessionID: AgentSyncTestSupport.session, text: "retry me") + #expect(await AgentSyncTestSupport.waitUntil { + conversation.sendTickets.first(where: { $0.id == ticketID })?.state == .failed(code: "send_rejected") + }) + #expect(engine.retrySend(sessionID: AgentSyncTestSupport.session, ticketID: ticketID)) + #expect(await AgentSyncTestSupport.waitUntil { + conversation.sendTickets.first(where: { $0.id == ticketID })?.state == .acceptedByMac + }) + + let sends = await server.sendParams() + #expect(sends.map(\.ticketID) == [ticketID.uuidString, ticketID.uuidString]) + #expect(conversation.sendTickets.count == 1) + } + + @Test + func transportFailureMidFlushLeavesRemainingTicketsQueuedForRetry() async { + let transport = FixtureSyncTransport() + let server = AgentSyncTestServer() + await server.setSendOutcomes([.accepted, .transportFailure, .accepted]) + await server.install(on: transport) + let clock = TestSyncClock() + let engine = AgentSyncEngine( + transport: transport, + syncClock: clock, + replicaClock: AgentSyncReplicaClock(initialValue: 20), + ticketIDGenerator: SequentialTicketIDGenerator() + ) + let conversation = engine.openConversation(sessionID: AgentSyncTestSupport.session) + defer { engine.stop() } + _ = engine.send(sessionID: AgentSyncTestSupport.session, text: "one") + _ = engine.send(sessionID: AgentSyncTestSupport.session, text: "two") + _ = engine.send(sessionID: AgentSyncTestSupport.session, text: "three") + + engine.start() + #expect(await AgentSyncTestSupport.waitUntil { + let sendCount = await server.requestCount(method: GuiWireMethod.send) + let sleepCount = await clock.sleepRequests().count + return sendCount == 2 && sleepCount == 1 + }) + + #expect(engine.connectivity.phase != .connected) + #expect(await server.sendParams().compactMap(\.text) == ["one", "two"]) + #expect(conversation.sendTickets.map(\.state) == [ + .acceptedByMac, + .queuedLocal, + .queuedLocal, + ]) + } + + @Test + func reconnectWithGapPullsTailAndFlushesQueuedTicket() async throws { + let transport = FixtureSyncTransport() + let server = AgentSyncTestServer(entries: [ + AgentSyncTestSupport.entry(1), + AgentSyncTestSupport.entry(2), + ]) + await server.install(on: transport) + let clock = TestSyncClock() + let engine = AgentSyncEngine( + transport: transport, + syncClock: clock, + ticketIDGenerator: SequentialTicketIDGenerator() + ) + let conversation = engine.openConversation(sessionID: AgentSyncTestSupport.session) + defer { engine.stop() } + engine.start() + #expect(await AgentSyncTestSupport.waitUntil { engine.connectivity.phase == .connected }) + + await transport.injectConnectionEvent(.down(reason: "link lost")) + #expect(await AgentSyncTestSupport.waitUntil { + engine.connectivity.phase == .offline(reason: "link lost") + }) + let ticketID = engine.send(sessionID: AgentSyncTestSupport.session, text: "queued offline") + await server.setEntriesResult(AgentSyncTestSupport.page(entries: [ + AgentSyncTestSupport.entry(1), + AgentSyncTestSupport.entry(2), + AgentSyncTestSupport.entry(3), + AgentSyncTestSupport.entry(4), + ])) + let gapFrame = try AgentSyncTestSupport.eventData(.entriesAppended( + GuiEntriesAppendedEvent( + journalID: AgentSyncTestSupport.journalOne, + entries: [AgentSyncTestSupport.entry(4)] + ) + )) + await server.setSessionsRequestAction { + await transport.injectFrame( + topic: GuiWireTopic.journal(sessionID: AgentSyncTestSupport.session), + payload: gapFrame + ) + #expect(await AgentSyncTestSupport.waitUntil { conversation.needsTailPull }) + } + + await transport.injectConnectionEvent(.up) + #expect(await AgentSyncTestSupport.waitUntil { + engine.connectivity.phase == .connected && conversation.entries.count == 4 + }) + + #expect(conversation.entries.map(\.seq.rawValue) == [1, 2, 3, 4]) + #expect(conversation.holes.isEmpty) + #expect(!conversation.needsTailPull) + #expect(conversation.sendTickets.first(where: { $0.id == ticketID })?.state == .acceptedByMac) + #expect(await server.requestCount(method: GuiWireMethod.entries) >= 2) + } + + @Test + func journalRotationMidStreamSwapsWindowAndKeepsTickets() async throws { + let transport = FixtureSyncTransport() + let server = AgentSyncTestServer(entries: [ + AgentSyncTestSupport.entry(1), + AgentSyncTestSupport.entry(2), + ]) + await server.install(on: transport) + let clock = TestSyncClock() + let engine = AgentSyncEngine( + transport: transport, + syncClock: clock, + ticketIDGenerator: SequentialTicketIDGenerator() + ) + let conversation = engine.openConversation(sessionID: AgentSyncTestSupport.session) + defer { engine.stop() } + engine.start() + #expect(await AgentSyncTestSupport.waitUntil { engine.connectivity.phase == .connected }) + let ticketID = engine.send(sessionID: AgentSyncTestSupport.session, text: "keep me") + #expect(await AgentSyncTestSupport.waitUntil { + conversation.sendTickets.first(where: { $0.id == ticketID })?.state == .acceptedByMac + }) + #expect(conversation.lastAppliedOrigin == .live) + + await server.setEntriesResult(AgentSyncTestSupport.page( + journalID: AgentSyncTestSupport.journalTwo, + entries: [ + AgentSyncTestSupport.entry(1, journalID: AgentSyncTestSupport.journalTwo, hash: 201), + AgentSyncTestSupport.entry(2, journalID: AgentSyncTestSupport.journalTwo, hash: 202), + AgentSyncTestSupport.entry(3, journalID: AgentSyncTestSupport.journalTwo, hash: 203), + ] + )) + let reset = try AgentSyncTestSupport.eventData(.journalReset( + GuiJournalResetEvent( + sessionID: AgentSyncTestSupport.session, + newJournalID: AgentSyncTestSupport.journalTwo, + tailSeq: EntrySeq(rawValue: 3) + ) + )) + await transport.injectFrame( + topic: GuiWireTopic.journal(sessionID: AgentSyncTestSupport.session), + payload: reset + ) + #expect(await AgentSyncTestSupport.waitUntil { await clock.pendingSleepCount() == 1 }) + await clock.advance(milliseconds: 250) + #expect(await AgentSyncTestSupport.waitUntil { + conversation.journalID == AgentSyncTestSupport.journalTwo && conversation.entries.count == 3 + }) + + #expect(conversation.entries.map(\.content.contentHash) == [201, 202, 203]) + #expect(conversation.resetMarkerCount == 1) + #expect(conversation.sendTickets.map(\.id).contains(ticketID)) + } + + @Test + func macRelaunchDropsReplicatedStateButKeepsTicketsAndReadPointer() async { + let transport = FixtureSyncTransport() + let server = AgentSyncTestServer(entries: [ + AgentSyncTestSupport.entry(1, hash: 101), + AgentSyncTestSupport.entry(2, hash: 102), + ]) + await server.install(on: transport) + let engine = AgentSyncEngine( + transport: transport, + ticketIDGenerator: SequentialTicketIDGenerator() + ) + let conversation = engine.openConversation(sessionID: AgentSyncTestSupport.session) + defer { engine.stop() } + engine.start() + #expect(await AgentSyncTestSupport.waitUntil { engine.connectivity.phase == .connected }) + conversation.markReadThrough(EntrySeq(rawValue: 2)) + let ticketID = engine.send(sessionID: AgentSyncTestSupport.session, text: "survive relaunch") + #expect(await AgentSyncTestSupport.waitUntil { + conversation.sendTickets.first(where: { $0.id == ticketID })?.state == .acceptedByMac + }) + + await server.setEpoch(AgentSyncTestSupport.epochTwo) + await server.setSessions([ + AgentSyncTestSupport.sessionSnapshot(title: "Relaunched", version: 2), + ], epoch: AgentSyncTestSupport.epochTwo) + await server.setEntriesResult(AgentSyncTestSupport.page( + journalID: AgentSyncTestSupport.journalTwo, + entries: [ + AgentSyncTestSupport.entry(1, journalID: AgentSyncTestSupport.journalTwo, hash: 301), + AgentSyncTestSupport.entry(2, journalID: AgentSyncTestSupport.journalTwo, hash: 302), + AgentSyncTestSupport.entry(3, journalID: AgentSyncTestSupport.journalTwo, hash: 303), + ] + )) + + await transport.injectConnectionEvent(.reset) + #expect(await AgentSyncTestSupport.waitUntil { + engine.connectivity.phase == .connected + && engine.directory.epoch == AgentSyncTestSupport.epochTwo + && conversation.journalID == AgentSyncTestSupport.journalTwo + }) + + #expect(engine.directory.sessions.map(\.title) == ["Relaunched"]) + #expect(conversation.entries.map(\.content.contentHash) == [301, 302, 303]) + #expect(conversation.readPointer == EntrySeq(rawValue: 2)) + #expect(conversation.sendTickets.map(\.id).contains(ticketID)) + } + + @Test + func sessionsEpochChangeDropsOpenConversationBeforeMergingNewVersions() async throws { + let transport = FixtureSyncTransport() + let server = AgentSyncTestServer(entries: [ + AgentSyncTestSupport.entry(1, version: 10, hash: 100), + ]) + await server.install(on: transport) + let engine = AgentSyncEngine(transport: transport) + let conversation = engine.openConversation(sessionID: AgentSyncTestSupport.session) + defer { engine.stop() } + engine.start() + #expect(await AgentSyncTestSupport.waitUntil { engine.connectivity.phase == .connected }) + + let tick = try AgentSyncTestSupport.eventData(.streamTick(GuiStreamTickEvent( + journalID: AgentSyncTestSupport.journalOne, + afterSeq: EntrySeq(rawValue: 1), + textTail: "old epoch", + revision: 1 + ))) + await transport.injectFrame( + topic: GuiWireTopic.journal(sessionID: AgentSyncTestSupport.session), + payload: tick + ) + #expect(await AgentSyncTestSupport.waitUntil { + engine.streamingTails[AgentSyncTestSupport.session] != nil + }) + + await server.setHelloEpoch(AgentSyncTestSupport.epochOne) + await server.setSessions( + [AgentSyncTestSupport.sessionSnapshot(title: "New epoch")], + epoch: AgentSyncTestSupport.epochTwo + ) + await server.setEntriesResult(AgentSyncTestSupport.page(entries: [ + AgentSyncTestSupport.entry(1, version: 1, hash: 200), + ])) + engine.noteAppForegrounded() + + #expect(await AgentSyncTestSupport.waitUntil { + engine.connectivity.phase == .connected + && engine.directory.epoch == AgentSyncTestSupport.epochTwo + && conversation.entries.first?.content.contentHash == 200 + }) + #expect(engine.streamingTails[AgentSyncTestSupport.session] == nil) + #expect(conversation.entries.map(\.version.rawValue) == [1]) + } + + @Test + func duplicateAndReorderedFramesDuringPullConvergeToCleanState() async throws { + let transport = FixtureSyncTransport() + let server = AgentSyncTestServer(entries: [AgentSyncTestSupport.entry(1)]) + await server.install(on: transport) + let clock = TestSyncClock() + let replicaClock = ManualReplicaClock(currentTick: 10) + let engine = AgentSyncEngine( + transport: transport, + syncClock: clock, + replicaClock: replicaClock + ) + let conversation = engine.openConversation(sessionID: AgentSyncTestSupport.session) + defer { engine.stop() } + engine.start() + #expect(await AgentSyncTestSupport.waitUntil { engine.connectivity.phase == .connected }) + + let cleanEntries = [ + AgentSyncTestSupport.entry(1), + AgentSyncTestSupport.entry(2), + AgentSyncTestSupport.entry(3), + ] + await server.setEntriesResult(AgentSyncTestSupport.page(entries: cleanEntries)) + await server.gateNextEntriesRequest() + engine.noteNetworkPathChanged() + #expect(await AgentSyncTestSupport.waitUntil { + await server.requestCount(method: GuiWireMethod.entries) == 2 + }) + + for sequence in [3, 2, 3] { + let frame = try AgentSyncTestSupport.eventData(.entriesAppended( + GuiEntriesAppendedEvent( + journalID: AgentSyncTestSupport.journalOne, + entries: [AgentSyncTestSupport.entry(sequence)] + ) + )) + await transport.injectFrame( + topic: GuiWireTopic.journal(sessionID: AgentSyncTestSupport.session), + payload: frame + ) + } + #expect(await AgentSyncTestSupport.waitUntil { conversation.needsTailPull }) + await server.resumeEntriesRequest() + #expect(await AgentSyncTestSupport.waitUntil { + engine.connectivity.phase == .connected && conversation.entries.count == 3 + }) + + let clean = ConversationReplica( + sessionID: AgentSyncTestSupport.session, + clock: ManualReplicaClock(currentTick: 10) + ) + clean.mergePage( + journal: AgentSyncTestSupport.journalOne, + entries: cleanEntries, + windowStart: EntrySeq(rawValue: 1), + windowEnd: EntrySeq(rawValue: 3), + tailSeq: EntrySeq(rawValue: 3), + hasMoreBefore: false + ) + #expect(conversation.state == clean.state) + } + + @Test + func exponentialBackoffUsesClockAndCapsBeforeJitter() async { + let transport = FixtureSyncTransport() + let server = AgentSyncTestServer() + await server.failNextHelloRequests(20) + await server.install(on: transport) + let clock = TestSyncClock() + let engine = AgentSyncEngine( + transport: transport, + syncClock: clock, + jitter: FixedAgentSyncJitter(fraction: 0.2) + ) + defer { engine.stop() } + engine.start() + + let expected = [600, 1_200, 2_400, 4_800, 9_600, 19_200, 19_200] + for (index, delay) in expected.enumerated() { + #expect(await AgentSyncTestSupport.waitUntil { + await clock.sleepRequests().count == index + 1 + }) + #expect(await clock.sleepRequests()[index] == delay) + await clock.advance(milliseconds: delay) + } + } + + @Test + func foregroundEventSkipsPendingBackoff() async { + let transport = FixtureSyncTransport() + let server = AgentSyncTestServer() + await server.failNextHelloRequests(1) + await server.install(on: transport) + let clock = TestSyncClock() + let engine = AgentSyncEngine( + transport: transport, + syncClock: clock, + jitter: FixedAgentSyncJitter(fraction: 0) + ) + defer { engine.stop() } + engine.start() + #expect(await AgentSyncTestSupport.waitUntil { await clock.sleepRequests() == [500] }) + + engine.noteAppForegrounded() + #expect(await AgentSyncTestSupport.waitUntil { + let helloCount = await server.requestCount(method: GuiWireMethod.hello) + return engine.connectivity.phase == .connected && helloCount == 2 + }) + #expect(await clock.nowMilliseconds() == 0) + } + + @Test(arguments: [ + (-1.0, 400), + (-0.2, 400), + (0.0, 500), + (0.2, 600), + (1.0, 600), + ]) + func retryJitterIsClampedToTwentyPercent(fraction: Double, expectedDelay: Int) async { + let transport = FixtureSyncTransport() + let server = AgentSyncTestServer() + await server.failNextHelloRequests(1) + await server.install(on: transport) + let clock = TestSyncClock() + let engine = AgentSyncEngine( + transport: transport, + syncClock: clock, + jitter: FixedAgentSyncJitter(fraction: fraction) + ) + defer { engine.stop() } + engine.start() + + #expect(await AgentSyncTestSupport.waitUntil { await clock.sleepRequests().count == 1 }) + #expect(await clock.sleepRequests() == [expectedDelay]) + } + +} diff --git a/Packages/Shared/CmuxAgentSync/Tests/CmuxAgentSyncTests/AgentSyncTestServer.swift b/Packages/Shared/CmuxAgentSync/Tests/CmuxAgentSyncTests/AgentSyncTestServer.swift new file mode 100644 index 000000000000..6eb3a2e9818e --- /dev/null +++ b/Packages/Shared/CmuxAgentSync/Tests/CmuxAgentSyncTests/AgentSyncTestServer.swift @@ -0,0 +1,198 @@ +import CmuxAgentReplica +import CmuxAgentSync +import CmuxAgentWire +import Foundation + +actor AgentSyncTestServer { + enum SendOutcome: Sendable { + case accepted + case rejected + case failure(GuiWireErrorCode) + case transportFailure + case malformedResponse + } + + private var helloResult: GuiHelloResult + private var sessionsResult: GuiSessionsResult + private var capabilitiesResult: GuiCapabilitiesResult + private var fallbackEntriesResult: GuiEntriesResult + private var queuedEntriesResults: [GuiEntriesResult] + private var queuedSendOutcomes: [SendOutcome] + private var decodedEntriesParams: [GuiEntriesParams] + private var decodedSendParams: [GuiSendParams] + private var requestCounts: [String: Int] + private var sessionsRequestAction: (@Sendable () async -> Void)? + private var helloFailuresRemaining: Int + private var shouldGateNextEntries: Bool + private var entriesGate: CheckedContinuation? + + init( + epoch: ReplicaEpoch = AgentSyncTestSupport.epochOne, + journalID: JournalID = AgentSyncTestSupport.journalOne, + entries: [EntrySnapshot] = [] + ) { + helloResult = AgentSyncTestSupport.hello(epoch: epoch) + sessionsResult = GuiSessionsResult( + epoch: epoch, + sessions: [AgentSyncTestSupport.sessionSnapshot()] + ) + capabilitiesResult = GuiCapabilitiesResult( + tier: .wrapped, + reasons: [], + cliVersion: "1.0.0", + steerable: true, + answerable: true + ) + fallbackEntriesResult = AgentSyncTestSupport.page(journalID: journalID, entries: entries) + queuedEntriesResults = [] + queuedSendOutcomes = [] + decodedEntriesParams = [] + decodedSendParams = [] + requestCounts = [:] + sessionsRequestAction = nil + helloFailuresRemaining = 0 + shouldGateNextEntries = false + entriesGate = nil + } + + func install(on transport: FixtureSyncTransport) async { + await transport.setHandler(method: GuiWireMethod.hello) { [weak self] params in + guard let self else { throw FixtureSyncTransportError.unhandledRequest(GuiWireMethod.hello) } + return try await self.handleHello(params) + } + await transport.setHandler(method: GuiWireMethod.sessions) { [weak self] params in + guard let self else { throw FixtureSyncTransportError.unhandledRequest(GuiWireMethod.sessions) } + return try await self.handleSessions(params) + } + await transport.setHandler(method: GuiWireMethod.entries) { [weak self] params in + guard let self else { throw FixtureSyncTransportError.unhandledRequest(GuiWireMethod.entries) } + return try await self.handleEntries(params) + } + await transport.setHandler(method: GuiWireMethod.send) { [weak self] params in + guard let self else { throw FixtureSyncTransportError.unhandledRequest(GuiWireMethod.send) } + return try await self.handleSend(params) + } + await transport.setHandler(method: GuiWireMethod.capabilities) { [weak self] params in + guard let self else { throw FixtureSyncTransportError.unhandledRequest(GuiWireMethod.capabilities) } + return try await self.handleCapabilities(params) + } + } + + func setEpoch(_ epoch: ReplicaEpoch) { + helloResult = AgentSyncTestSupport.hello(epoch: epoch) + sessionsResult = GuiSessionsResult(epoch: epoch, sessions: sessionsResult.sessions) + } + + func setHelloEpoch(_ epoch: ReplicaEpoch) { + helloResult = AgentSyncTestSupport.hello(epoch: epoch) + } + + func setSessions(_ sessions: [AgentSessionSnapshot], epoch: ReplicaEpoch? = nil) { + sessionsResult = GuiSessionsResult(epoch: epoch ?? sessionsResult.epoch, sessions: sessions) + } + + func setEntriesResult(_ result: GuiEntriesResult) { + fallbackEntriesResult = result + } + + func enqueueEntriesResult(_ result: GuiEntriesResult) { + queuedEntriesResults.append(result) + } + + func setSendOutcomes(_ outcomes: [SendOutcome]) { + queuedSendOutcomes = outcomes + } + + func setCapabilitiesResult(_ result: GuiCapabilitiesResult) { + capabilitiesResult = result + } + + func setSessionsRequestAction(_ action: (@Sendable () async -> Void)?) { + sessionsRequestAction = action + } + + func failNextHelloRequests(_ count: Int) { + helloFailuresRemaining = count + } + + func gateNextEntriesRequest() { + shouldGateNextEntries = true + } + + func resumeEntriesRequest() { + entriesGate?.resume() + entriesGate = nil + } + + func entriesParams() -> [GuiEntriesParams] { + decodedEntriesParams + } + + func sendParams() -> [GuiSendParams] { + decodedSendParams + } + + func requestCount(method: String) -> Int { + requestCounts[method, default: 0] + } + + private func handleHello(_ params: Data) async throws -> Data { + _ = try JSONDecoder().decode(GuiHelloParams.self, from: params) + requestCounts[GuiWireMethod.hello, default: 0] += 1 + if helloFailuresRemaining > 0 { + helloFailuresRemaining -= 1 + throw GuiWireError(code: .internalError, message: "scripted hello failure") + } + return try JSONEncoder().encode(helloResult) + } + + private func handleSessions(_ params: Data) async throws -> Data { + _ = try JSONDecoder().decode(GuiSessionsParams.self, from: params) + requestCounts[GuiWireMethod.sessions, default: 0] += 1 + if let sessionsRequestAction { + await sessionsRequestAction() + } + return try JSONEncoder().encode(sessionsResult) + } + + private func handleEntries(_ params: Data) async throws -> Data { + let decoded = try JSONDecoder().decode(GuiEntriesParams.self, from: params) + decodedEntriesParams.append(decoded) + requestCounts[GuiWireMethod.entries, default: 0] += 1 + if shouldGateNextEntries { + shouldGateNextEntries = false + await withCheckedContinuation { continuation in + entriesGate = continuation + } + } + let result = queuedEntriesResults.isEmpty + ? fallbackEntriesResult + : queuedEntriesResults.removeFirst() + return try JSONEncoder().encode(result) + } + + private func handleSend(_ params: Data) async throws -> Data { + let decoded = try JSONDecoder().decode(GuiSendParams.self, from: params) + decodedSendParams.append(decoded) + requestCounts[GuiWireMethod.send, default: 0] += 1 + let outcome = queuedSendOutcomes.isEmpty ? .accepted : queuedSendOutcomes.removeFirst() + switch outcome { + case .accepted: + return try JSONEncoder().encode(GuiSendResult(accepted: true, queuedOnMac: true)) + case .rejected: + return try JSONEncoder().encode(GuiSendResult(accepted: false, queuedOnMac: false)) + case .failure(let code): + throw GuiWireError(code: code, message: "scripted send failure") + case .transportFailure: + throw FixtureSyncTransportError.unhandledRequest("scripted transport failure") + case .malformedResponse: + return Data("{".utf8) + } + } + + private func handleCapabilities(_ params: Data) async throws -> Data { + _ = try JSONDecoder().decode(GuiCapabilitiesParams.self, from: params) + requestCounts[GuiWireMethod.capabilities, default: 0] += 1 + return try JSONEncoder().encode(capabilitiesResult) + } +} diff --git a/Packages/Shared/CmuxAgentSync/Tests/CmuxAgentSyncTests/AgentSyncTestSupport.swift b/Packages/Shared/CmuxAgentSync/Tests/CmuxAgentSyncTests/AgentSyncTestSupport.swift new file mode 100644 index 000000000000..1d93642d86de --- /dev/null +++ b/Packages/Shared/CmuxAgentSync/Tests/CmuxAgentSyncTests/AgentSyncTestSupport.swift @@ -0,0 +1,113 @@ +import CmuxAgentReplica +import CmuxAgentSync +import CmuxAgentWire +import Foundation + +enum AgentSyncTestSupport { + static let mac = MacDeviceID(rawValue: "mac-1") + static let session = AgentSessionID(rawValue: "session-1") + static let otherSession = AgentSessionID(rawValue: "session-2") + static let epochOne = ReplicaEpoch(rawValue: "epoch-1") + static let epochTwo = ReplicaEpoch(rawValue: "epoch-2") + static let journalOne = JournalID(rawValue: "journal-1") + static let journalTwo = JournalID(rawValue: "journal-2") + + static func hello(epoch: ReplicaEpoch) -> GuiHelloResult { + GuiHelloResult( + protocol: 1, + serverCaps: ["gui.v1.sessions", "gui.v1.journal"], + epoch: epoch, + macDeviceID: mac, + serverTimeMS: 1_000 + ) + } + + static func sessionSnapshot( + title: String = "Session", + version: UInt64 = 1 + ) -> AgentSessionSnapshot { + AgentSessionSnapshot( + id: session, + macDeviceID: mac, + kind: .codex, + phase: .working, + tier: .wrapped, + surfaceID: "surface-1", + cwd: "/repo", + title: title, + workspaceName: "Workspace", + version: EntityVersion(rawValue: version), + lastActivityHint: Int(version) + ) + } + + static func entry( + _ rawSequence: Int, + journalID: JournalID = journalOne, + version: UInt64 = 1, + hash: Int? = nil + ) -> EntrySnapshot { + EntrySnapshot( + journalID: journalID, + seq: EntrySeq(rawValue: rawSequence), + kind: .agentProse, + content: EntryContent( + contentHash: hash ?? rawSequence, + payload: .agentProse(AgentProsePayload(markdown: "entry \(rawSequence)")) + ), + version: EntityVersion(rawValue: version) + ) + } + + static func page( + journalID: JournalID = journalOne, + entries: [EntrySnapshot], + windowStart: Int? = nil, + windowEnd: Int? = nil, + tail: Int? = nil, + hasMoreBefore: Bool = false, + hasMoreAfter: Bool = false, + startCursor: String? = nil, + endCursor: String? = nil, + tailCursor: String? = nil, + requiresPagingRestart: Bool = false + ) -> GuiEntriesResult { + let first = windowStart ?? entries.first?.seq.rawValue ?? 0 + let last = windowEnd ?? entries.last?.seq.rawValue ?? 0 + return GuiEntriesResult( + journalID: journalID, + entries: entries, + windowStart: EntrySeq(rawValue: first), + windowEnd: EntrySeq(rawValue: last), + tailSeq: EntrySeq(rawValue: tail ?? last), + hasMoreBefore: hasMoreBefore, + hasMoreAfter: hasMoreAfter, + startCursor: startCursor.map { JournalCursor(rawValue: $0) }, + endCursor: endCursor.map { JournalCursor(rawValue: $0) }, + tailCursor: tailCursor.map { JournalCursor(rawValue: $0) }, + requiresPagingRestart: requiresPagingRestart + ) + } + + static func eventData( + _ payload: GuiEventPayload, + epoch: ReplicaEpoch = epochOne, + sessionID: AgentSessionID = session + ) throws -> Data { + try JSONEncoder().encode(GuiEventFrame(epoch: epoch, sessionID: sessionID, payload: payload)) + } + + @MainActor + static func waitUntil( + iterations: Int = 5_000, + _ condition: @MainActor () async -> Bool + ) async -> Bool { + for _ in 0.. Double { + fraction + } +} diff --git a/Packages/Shared/CmuxAgentSync/Tests/CmuxAgentSyncTests/SequentialTicketIDGenerator.swift b/Packages/Shared/CmuxAgentSync/Tests/CmuxAgentSyncTests/SequentialTicketIDGenerator.swift new file mode 100644 index 000000000000..2cbdd7452a4e --- /dev/null +++ b/Packages/Shared/CmuxAgentSync/Tests/CmuxAgentSyncTests/SequentialTicketIDGenerator.swift @@ -0,0 +1,11 @@ +import CmuxAgentSync +import Foundation + +final class SequentialTicketIDGenerator: AgentSyncTicketIDGenerator, @unchecked Sendable { + private nonisolated(unsafe) var nextValue: UInt64 = 1 + + func nextTicketID() -> UUID { + defer { nextValue += 1 } + return UUID(uuidString: String(format: "00000000-0000-0000-0000-%012llu", nextValue))! + } +} diff --git a/Packages/Shared/CmuxAgentSync/Tests/CmuxAgentSyncTests/TestSyncClock.swift b/Packages/Shared/CmuxAgentSync/Tests/CmuxAgentSyncTests/TestSyncClock.swift new file mode 100644 index 000000000000..1db64eb460dd --- /dev/null +++ b/Packages/Shared/CmuxAgentSync/Tests/CmuxAgentSyncTests/TestSyncClock.swift @@ -0,0 +1,65 @@ +import CmuxAgentSync +import Foundation + +actor TestSyncClock: SyncClock { + private struct Waiter { + let deadline: Int64 + let continuation: AsyncStream.Continuation + } + + private var currentMilliseconds: Int64 + private var waiters: [UUID: Waiter] + private var requestedSleeps: [Int] + + init(currentMilliseconds: Int64 = 0) { + self.currentMilliseconds = currentMilliseconds + waiters = [:] + requestedSleeps = [] + } + + func nowMilliseconds() async -> Int64 { + currentMilliseconds + } + + func sleep(milliseconds: Int) async { + requestedSleeps.append(milliseconds) + guard milliseconds > 0 else { return } + + let id = UUID() + let pair = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + pair.continuation.onTermination = { [weak self] _ in + Task { await self?.removeWaiter(id: id) } + } + waiters[id] = Waiter( + deadline: currentMilliseconds + Int64(milliseconds), + continuation: pair.continuation + ) + for await _ in pair.stream { + break + } + } + + func advance(milliseconds: Int) { + currentMilliseconds += Int64(milliseconds) + let dueIDs = waiters.compactMap { id, waiter in + waiter.deadline <= currentMilliseconds ? id : nil + } + for id in dueIDs { + guard let waiter = waiters.removeValue(forKey: id) else { continue } + waiter.continuation.yield() + waiter.continuation.finish() + } + } + + func sleepRequests() -> [Int] { + requestedSleeps + } + + func pendingSleepCount() -> Int { + waiters.count + } + + private func removeWaiter(id: UUID) { + waiters[id] = nil + } +} diff --git a/Packages/Shared/CmuxAgentWire/Package.swift b/Packages/Shared/CmuxAgentWire/Package.swift new file mode 100644 index 000000000000..69554b8b4ef1 --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Package.swift @@ -0,0 +1,40 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "CmuxAgentWire", + platforms: [ + .iOS(.v18), + .macOS(.v14), + ], + products: [ + .library( + name: "CmuxAgentWire", + targets: ["CmuxAgentWire"] + ), + ], + dependencies: [ + .package(path: "../CmuxAgentReplica"), + ], + targets: [ + .target( + name: "CmuxAgentWire", + dependencies: ["CmuxAgentReplica"], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .testTarget( + name: "CmuxAgentWireTests", + dependencies: ["CmuxAgentWire", "CmuxAgentReplica"], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + ] +) diff --git a/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Constants/GuiWireCaps.swift b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Constants/GuiWireCaps.swift new file mode 100644 index 000000000000..18e2f69148b0 --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Constants/GuiWireCaps.swift @@ -0,0 +1,12 @@ +/// Open capability-string constants advertised during GUI negotiation. +// lint:allow namespace-type - The wire contract requires one capability namespace. +public enum GuiWireCaps { + /// Bounded journal paging is supported. + public static let entriesPaging = "entries-paging" + /// Idempotent send tickets are supported. + public static let sendTickets = "send-tickets" + /// Structured pending-ask answers are supported. + public static let answers = "answers" + /// Machine-readable session capability reports are supported. + public static let capabilitiesReport = "capabilities-report" +} diff --git a/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Constants/GuiWireMethod.swift b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Constants/GuiWireMethod.swift new file mode 100644 index 000000000000..bcd036d3df39 --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Constants/GuiWireMethod.swift @@ -0,0 +1,20 @@ +/// Method-name constants for the `gui.v1` RPC namespace. +// lint:allow namespace-type - The wire contract requires one method-name namespace. +public enum GuiWireMethod { + /// Negotiates protocol and capability overlap. + public static let hello = "gui.v1.hello" + /// Pulls all current sessions. + public static let sessions = "gui.v1.sessions" + /// Pulls one current session. + public static let session = "gui.v1.session" + /// Pulls a bounded journal page. + public static let entries = "gui.v1.entries" + /// Submits an idempotent send ticket. + public static let send = "gui.v1.send" + /// Interrupts an agent session. + public static let interrupt = "gui.v1.interrupt" + /// Answers a pending ask. + public static let answer = "gui.v1.answer" + /// Pulls one session's capability report. + public static let capabilities = "gui.v1.capabilities" +} diff --git a/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Constants/GuiWireTopic.swift b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Constants/GuiWireTopic.swift new file mode 100644 index 000000000000..44920fcf496e --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Constants/GuiWireTopic.swift @@ -0,0 +1,18 @@ +public import CmuxAgentReplica + +/// Topic constants and builders for `gui.v1` server events. +// lint:allow namespace-type - The wire contract requires one topic namespace. +public enum GuiWireTopic { + /// The foreground session-directory event topic. + public static let sessions = "gui.v1.sessions" + + /// Prefix shared by all session-scoped journal event topics. + public static let journalPrefix = "gui.v1.journal." + + /// Builds the journal topic for one session. + /// - Parameter sessionID: The session whose journal events are requested. + /// - Returns: The session-scoped journal topic string. + public static func journal(sessionID: AgentSessionID) -> String { + "\(journalPrefix)\(sessionID.rawValue)" + } +} diff --git a/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Errors/GuiWireError.swift b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Errors/GuiWireError.swift new file mode 100644 index 000000000000..24a0aec7ca64 --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Errors/GuiWireError.swift @@ -0,0 +1,24 @@ +/// A GUI RPC error mapped from the transport envelope's error object. +public struct GuiWireError: Codable, Error, Hashable, Sendable { + /// The fail-open machine-readable error code. + public let code: GuiWireErrorCode + /// The server-provided message passed through unchanged. + public let message: String + + /// Creates a wire error from a typed code. + /// - Parameters: + /// - code: The typed, fail-open error code. + /// - message: The server-provided message. + public init(code: GuiWireErrorCode, message: String) { + self.code = code + self.message = message + } + + /// Maps an RPC envelope error object into the wire error taxonomy. + /// - Parameters: + /// - code: The raw open code from the RPC envelope. + /// - message: The message from the RPC envelope. + public init(code: String, message: String) { + self.init(code: GuiWireErrorCode(rawValue: code), message: message) + } +} diff --git a/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Errors/GuiWireErrorCode.swift b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Errors/GuiWireErrorCode.swift new file mode 100644 index 000000000000..5f339bd5a12f --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Errors/GuiWireErrorCode.swift @@ -0,0 +1,69 @@ +/// A fail-open GUI RPC error code. +public enum GuiWireErrorCode: Codable, Hashable, Sendable { + /// Request parameters were invalid. + case invalidParams + /// Client and server protocol ranges do not overlap. + case unsupportedProtocol + /// The requested resource was not found. + case notFound + /// The session-to-surface binding was lost. + case bindingLost + /// The agent input queue is full. + case inputQueueFull + /// The agent process has exited. + case processExited + /// The Mac rejected a send request. + case sendRejected + /// The caller is rate limited. + case rateLimited + /// An internal server error occurred. + case internalError + /// An unrecognized future code preserved verbatim. + case unknown(String) + + /// The open string carried by the RPC envelope. + public var rawValue: String { + switch self { + case .invalidParams: "invalid_params" + case .unsupportedProtocol: "unsupported_protocol" + case .notFound: "not_found" + case .bindingLost: "binding_lost" + case .inputQueueFull: "input_queue_full" + case .processExited: "process_exited" + case .sendRejected: "send_rejected" + case .rateLimited: "rate_limited" + case .internalError: "internal_error" + case .unknown(let rawValue): rawValue + } + } + + /// Creates an error code from an open RPC string. + /// - Parameter rawValue: The raw RPC error code. + public init(rawValue: String) { + switch rawValue { + case "invalid_params": self = .invalidParams + case "unsupported_protocol": self = .unsupportedProtocol + case "not_found": self = .notFound + case "binding_lost": self = .bindingLost + case "input_queue_full": self = .inputQueueFull + case "process_exited": self = .processExited + case "send_rejected": self = .sendRejected + case "rate_limited": self = .rateLimited + case "internal_error": self = .internalError + default: self = .unknown(rawValue) + } + } + + /// Decodes an open RPC error code. + /// - Parameter decoder: The decoder to read from. + public init(from decoder: any Decoder) throws { + self.init(rawValue: try decoder.singleValueContainer().decode(String.self)) + } + + /// Encodes the raw RPC error code. + /// - Parameter encoder: The encoder to write to. + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} diff --git a/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiAskStateEvent.swift b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiAskStateEvent.swift new file mode 100644 index 000000000000..92c48df8125c --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiAskStateEvent.swift @@ -0,0 +1,13 @@ +public import CmuxAgentReplica + +/// Event payload carrying the whole current pending-ask value. +public struct GuiAskStateEvent: Codable, Hashable, Sendable { + /// The whole current pending-ask value. + public let ask: PendingAsk + + /// Creates an ask-state payload. + /// - Parameter ask: The whole current pending-ask value. + public init(ask: PendingAsk) { + self.ask = ask + } +} diff --git a/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiEntriesAppendedEvent.swift b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiEntriesAppendedEvent.swift new file mode 100644 index 000000000000..b136c6698655 --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiEntriesAppendedEvent.swift @@ -0,0 +1,23 @@ +public import CmuxAgentReplica + +/// Event payload appending whole-value entries to one journal. +public struct GuiEntriesAppendedEvent: Codable, Hashable, Sendable { + /// The journal receiving the entries. + public let journalID: JournalID + /// The appended whole-value entries. + public let entries: [EntrySnapshot] + + private enum CodingKeys: String, CodingKey { + case journalID = "journal_id" + case entries + } + + /// Creates an entries-appended payload. + /// - Parameters: + /// - journalID: The journal receiving the entries. + /// - entries: The appended whole-value entries. + public init(journalID: JournalID, entries: [EntrySnapshot]) { + self.journalID = journalID + self.entries = entries + } +} diff --git a/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiEntryReplacedEvent.swift b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiEntryReplacedEvent.swift new file mode 100644 index 000000000000..715159364e94 --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiEntryReplacedEvent.swift @@ -0,0 +1,23 @@ +public import CmuxAgentReplica + +/// Event payload replacing one previously identified entry. +public struct GuiEntryReplacedEvent: Codable, Hashable, Sendable { + /// The journal that owns the replacement. + public let journalID: JournalID + /// The replacement whole-value entry. + public let entry: EntrySnapshot + + private enum CodingKeys: String, CodingKey { + case journalID = "journal_id" + case entry + } + + /// Creates an entry-replacement payload. + /// - Parameters: + /// - journalID: The journal that owns the entry. + /// - entry: The replacement whole-value entry. + public init(journalID: JournalID, entry: EntrySnapshot) { + self.journalID = journalID + self.entry = entry + } +} diff --git a/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiEventFrame.swift b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiEventFrame.swift new file mode 100644 index 000000000000..ad240a2ae203 --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiEventFrame.swift @@ -0,0 +1,90 @@ +public import CmuxAgentReplica + +/// The `gui.v1` event payload carried inside the existing transport event envelope. +public struct GuiEventFrame: Codable, Hashable, Sendable { + /// The Mac process epoch that scopes the event. + public let epoch: ReplicaEpoch + /// The session scoped by the event, when applicable. + public let sessionID: AgentSessionID? + /// The payload decoded according to the open event kind. + public let payload: GuiEventPayload + /// The open event-kind string. + public var kind: String { payload.kind } + + private enum CodingKeys: String, CodingKey { + case epoch + case sessionID = "session_id" + case kind + case payload + } + + private enum EmptyCodingKeys: CodingKey {} + + /// Creates an event frame payload. + /// - Parameters: + /// - epoch: The Mac process epoch. + /// - sessionID: The scoped session identifier, when applicable. + /// - payload: The typed or unknown event payload. + public init(epoch: ReplicaEpoch, sessionID: AgentSessionID? = nil, payload: GuiEventPayload) { + self.epoch = epoch + self.sessionID = sessionID + self.payload = payload + } + + /// Decodes a frame, mapping unknown kinds or malformed known payloads to `unknown`. + /// - Parameter decoder: The decoder to read from. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.epoch = try container.decode(ReplicaEpoch.self, forKey: .epoch) + self.sessionID = try? container.decodeIfPresent(AgentSessionID.self, forKey: .sessionID) + let kind = (try? container.decode(String.self, forKey: .kind)) ?? "unknown" + switch kind { + case "session_upserted": + self.payload = Self.decode(GuiSessionUpsertedEvent.self, from: container).map(GuiEventPayload.sessionUpserted) ?? .unknown(kind) + case "session_removed": + self.payload = Self.decode(GuiSessionRemovedEvent.self, from: container).map(GuiEventPayload.sessionRemoved) ?? .unknown(kind) + case "entries_appended": + self.payload = Self.decode(GuiEntriesAppendedEvent.self, from: container).map(GuiEventPayload.entriesAppended) ?? .unknown(kind) + case "entry_replaced": + self.payload = Self.decode(GuiEntryReplacedEvent.self, from: container).map(GuiEventPayload.entryReplaced) ?? .unknown(kind) + case "journal_reset": + self.payload = Self.decode(GuiJournalResetEvent.self, from: container).map(GuiEventPayload.journalReset) ?? .unknown(kind) + case "send_state": + self.payload = Self.decode(GuiSendStateEvent.self, from: container).map(GuiEventPayload.sendState) ?? .unknown(kind) + case "ask_state": + self.payload = Self.decode(GuiAskStateEvent.self, from: container).map(GuiEventPayload.askState) ?? .unknown(kind) + case "stream_tick": + self.payload = Self.decode(GuiStreamTickEvent.self, from: container).map(GuiEventPayload.streamTick) ?? .unknown(kind) + default: + self.payload = .unknown(kind) + } + } + + /// Encodes a typed frame using its stable snake_case event kind. + /// - Parameter encoder: The encoder to write to. + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(epoch, forKey: .epoch) + try container.encodeIfPresent(sessionID, forKey: .sessionID) + try container.encode(kind, forKey: .kind) + switch payload { + case .sessionUpserted(let value): try container.encode(value, forKey: .payload) + case .sessionRemoved(let value): try container.encode(value, forKey: .payload) + case .entriesAppended(let value): try container.encode(value, forKey: .payload) + case .entryReplaced(let value): try container.encode(value, forKey: .payload) + case .journalReset(let value): try container.encode(value, forKey: .payload) + case .sendState(let value): try container.encode(value, forKey: .payload) + case .askState(let value): try container.encode(value, forKey: .payload) + case .streamTick(let value): try container.encode(value, forKey: .payload) + case .unknown: + _ = container.nestedContainer(keyedBy: EmptyCodingKeys.self, forKey: .payload) + } + } + + private static func decode( + _ type: Value.Type, + from container: KeyedDecodingContainer + ) -> Value? { + try? container.decode(type, forKey: .payload) + } +} diff --git a/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiEventPayload.swift b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiEventPayload.swift new file mode 100644 index 000000000000..b4e3e7d8f60c --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiEventPayload.swift @@ -0,0 +1,36 @@ +/// A typed, fail-open payload carried by ``GuiEventFrame``. +public enum GuiEventPayload: Hashable, Sendable { + /// A session snapshot was upserted. + case sessionUpserted(GuiSessionUpsertedEvent) + /// A versioned session was removed. + case sessionRemoved(GuiSessionRemovedEvent) + /// Whole-value entries were appended. + case entriesAppended(GuiEntriesAppendedEvent) + /// One whole-value entry was replaced. + case entryReplaced(GuiEntryReplacedEvent) + /// A session rotated to a new journal. + case journalReset(GuiJournalResetEvent) + /// A send ticket changed state. + case sendState(GuiSendStateEvent) + /// A pending ask changed state. + case askState(GuiAskStateEvent) + /// An ephemeral streaming preview changed. + case streamTick(GuiStreamTickEvent) + /// An unknown or malformed future event kind preserved without failing the stream. + case unknown(String) + + /// The open event-kind string carried on the wire. + public var kind: String { + switch self { + case .sessionUpserted: "session_upserted" + case .sessionRemoved: "session_removed" + case .entriesAppended: "entries_appended" + case .entryReplaced: "entry_replaced" + case .journalReset: "journal_reset" + case .sendState: "send_state" + case .askState: "ask_state" + case .streamTick: "stream_tick" + case .unknown(let kind): kind + } + } +} diff --git a/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiJournalResetEvent.swift b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiJournalResetEvent.swift new file mode 100644 index 000000000000..854e07775320 --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiJournalResetEvent.swift @@ -0,0 +1,28 @@ +public import CmuxAgentReplica + +/// Event payload rotating a session to a newly identified journal. +public struct GuiJournalResetEvent: Codable, Hashable, Sendable { + /// The session whose journal rotated. + public let sessionID: AgentSessionID + /// The new journal identifier. + public let newJournalID: JournalID + /// The advertised tail sequence in the new journal. + public let tailSeq: EntrySeq + + private enum CodingKeys: String, CodingKey { + case sessionID = "session_id" + case newJournalID = "new_journal_id" + case tailSeq = "tail_seq" + } + + /// Creates a journal-reset payload. + /// - Parameters: + /// - sessionID: The session whose journal rotated. + /// - newJournalID: The new journal identifier. + /// - tailSeq: The advertised tail sequence. + public init(sessionID: AgentSessionID, newJournalID: JournalID, tailSeq: EntrySeq) { + self.sessionID = sessionID + self.newJournalID = newJournalID + self.tailSeq = tailSeq + } +} diff --git a/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiSendStateEvent.swift b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiSendStateEvent.swift new file mode 100644 index 000000000000..99fcab686074 --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiSendStateEvent.swift @@ -0,0 +1,13 @@ +public import CmuxAgentReplica + +/// Event payload carrying the whole current send-ticket value. +public struct GuiSendStateEvent: Codable, Hashable, Sendable { + /// The whole current send-ticket value. + public let ticket: SendTicket + + /// Creates a send-state payload. + /// - Parameter ticket: The whole current send-ticket value. + public init(ticket: SendTicket) { + self.ticket = ticket + } +} diff --git a/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiSessionRemovedEvent.swift b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiSessionRemovedEvent.swift new file mode 100644 index 000000000000..9e259383be1a --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiSessionRemovedEvent.swift @@ -0,0 +1,23 @@ +public import CmuxAgentReplica + +/// Event payload removing one versioned session entity. +public struct GuiSessionRemovedEvent: Codable, Hashable, Sendable { + /// The removed session identifier. + public let sessionID: AgentSessionID + /// The removal entity version. + public let version: EntityVersion + + private enum CodingKeys: String, CodingKey { + case sessionID = "session_id" + case version + } + + /// Creates a session-removal payload. + /// - Parameters: + /// - sessionID: The removed session identifier. + /// - version: The removal entity version. + public init(sessionID: AgentSessionID, version: EntityVersion) { + self.sessionID = sessionID + self.version = version + } +} diff --git a/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiSessionUpsertedEvent.swift b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiSessionUpsertedEvent.swift new file mode 100644 index 000000000000..191225629b16 --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiSessionUpsertedEvent.swift @@ -0,0 +1,13 @@ +public import CmuxAgentReplica + +/// Event payload carrying one whole-value session upsert. +public struct GuiSessionUpsertedEvent: Codable, Hashable, Sendable { + /// The upserted session snapshot. + public let session: AgentSessionSnapshot + + /// Creates a session-upsert payload. + /// - Parameter session: The upserted session snapshot. + public init(session: AgentSessionSnapshot) { + self.session = session + } +} diff --git a/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiStreamTickEvent.swift b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiStreamTickEvent.swift new file mode 100644 index 000000000000..0ecaf563c3d4 --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Events/GuiStreamTickEvent.swift @@ -0,0 +1,57 @@ +public import CmuxAgentReplica + +/// Ephemeral streaming-preview payload for one journal. +public struct GuiStreamTickEvent: Codable, Hashable, Sendable { + /// The maximum UTF-8 byte count retained for ``textTail``. + public static let textTailByteLimit = 16 * 1_024 + + /// The journal receiving the preview. + public let journalID: JournalID + /// The committed sequence after which this preview belongs. + public let afterSeq: EntrySeq + /// A bounded trailing text preview. + public let textTail: String + /// The preview revision within the current stream. + public let revision: Int + + private enum CodingKeys: String, CodingKey { + case journalID = "journal_id" + case afterSeq = "after_seq" + case textTail = "text_tail" + case revision + } + + /// Creates a stream-tick payload, retaining at most the trailing 16 KB of text. + /// - Parameters: + /// - journalID: The journal receiving the preview. + /// - afterSeq: The committed sequence before the preview. + /// - textTail: The trailing text preview. + /// - revision: The stream revision. + public init(journalID: JournalID, afterSeq: EntrySeq, textTail: String, revision: Int) { + self.journalID = journalID + self.afterSeq = afterSeq + self.textTail = Self.truncatedTail(textTail) + self.revision = revision + } + + /// Decodes a stream tick while enforcing the 16 KB preview bound. + /// - Parameter decoder: The decoder to read from. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.journalID = try container.decode(JournalID.self, forKey: .journalID) + self.afterSeq = try container.decode(EntrySeq.self, forKey: .afterSeq) + self.textTail = Self.truncatedTail(try container.decode(String.self, forKey: .textTail)) + self.revision = try container.decode(Int.self, forKey: .revision) + } + + private static func truncatedTail(_ value: String) -> String { + guard value.utf8.count > textTailByteLimit else { + return value + } + var bytes = Array(value.utf8.suffix(textTailByteLimit)) + while let first = bytes.first, first & 0b1100_0000 == 0b1000_0000 { + bytes.removeFirst() + } + return String(decoding: bytes, as: UTF8.self) + } +} diff --git a/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiAnswer.swift b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiAnswer.swift new file mode 100644 index 000000000000..58bb76b063bd --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiAnswer.swift @@ -0,0 +1,40 @@ +public import CmuxAgentReplica + +/// Parameters for answering a pending ask idempotently. +public struct GuiAnswerParams: Codable, Hashable, Sendable { + /// The session that owns the ask. + public let sessionID: AgentSessionID + /// The stable pending-ask identifier. + public let askID: String + /// The selected zero-based choice index. + public let choiceIndex: Int + + private enum CodingKeys: String, CodingKey { + case sessionID = "session_id" + case askID = "ask_id" + case choiceIndex = "choice_index" + } + + /// Creates answer parameters. + /// - Parameters: + /// - sessionID: The session that owns the ask. + /// - askID: The stable ask identifier. + /// - choiceIndex: The selected zero-based choice index. + public init(sessionID: AgentSessionID, askID: String, choiceIndex: Int) { + self.sessionID = sessionID + self.askID = askID + self.choiceIndex = choiceIndex + } +} + +/// Result reporting whether an answer was applied. +public struct GuiAnswerResult: Codable, Hashable, Sendable { + /// Whether the ask was answered. + public let answered: Bool + + /// Creates an answer result. + /// - Parameter answered: Whether the ask was answered. + public init(answered: Bool) { + self.answered = answered + } +} diff --git a/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiCapabilities.swift b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiCapabilities.swift new file mode 100644 index 000000000000..fbf71cfdad9a --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiCapabilities.swift @@ -0,0 +1,63 @@ +public import CmuxAgentReplica + +/// Parameters for requesting one session's GUI capability report. +public struct GuiCapabilitiesParams: Codable, Hashable, Sendable { + /// The session whose capabilities are requested. + public let sessionID: AgentSessionID + + private enum CodingKeys: String, CodingKey { + case sessionID = "session_id" + } + + /// Creates capability-report parameters. + /// - Parameter sessionID: The session whose capabilities are requested. + public init(sessionID: AgentSessionID) { + self.sessionID = sessionID + } +} + +/// Wire-owned GUI capability report for one agent session. +/// +/// Mac-only truth types must map into this value instead of crossing the shared +/// package boundary. +public struct GuiCapabilitiesResult: Codable, Hashable, Sendable { + /// The session's current detection tier. + public let tier: DetectionTier + /// Open machine-readable reasons explaining capability limitations. + public let reasons: [GuiCapabilityReason] + /// The detected agent CLI version, when available. + public let cliVersion: String? + /// Whether the session accepts steering input. + public let steerable: Bool + /// Whether the session accepts structured answers. + public let answerable: Bool + + private enum CodingKeys: String, CodingKey { + case tier + case reasons + case cliVersion = "cli_version" + case steerable + case answerable + } + + /// Creates a GUI capability report. + /// - Parameters: + /// - tier: The session's current detection tier. + /// - reasons: Open machine-readable capability reasons. + /// - cliVersion: The detected CLI version, when available. + /// - steerable: Whether the session accepts steering input. + /// - answerable: Whether the session accepts structured answers. + public init( + tier: DetectionTier, + reasons: [GuiCapabilityReason], + cliVersion: String? = nil, + steerable: Bool, + answerable: Bool + ) { + self.tier = tier + self.reasons = reasons + self.cliVersion = cliVersion + self.steerable = steerable + self.answerable = answerable + } +} diff --git a/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiCapabilityReason.swift b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiCapabilityReason.swift new file mode 100644 index 000000000000..4264f6c175f4 --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiCapabilityReason.swift @@ -0,0 +1,19 @@ +/// Describes one machine-readable reason in a GUI capability report. +/// +/// The code is intentionally an open string. `CmuxAgentTruthKit` is macOS-only +/// and maps its capability reasons into this wire-owned value at the boundary. +public struct GuiCapabilityReason: Codable, Hashable, Sendable { + /// The open machine-readable reason code. + public let code: String + /// Optional detail suitable for diagnostics or explanatory UI. + public let detail: String? + + /// Creates a wire capability reason. + /// - Parameters: + /// - code: The open machine-readable reason code. + /// - detail: Optional diagnostic detail. + public init(code: String, detail: String? = nil) { + self.code = code + self.detail = detail + } +} diff --git a/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiEntries.swift b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiEntries.swift new file mode 100644 index 000000000000..a42ee46d40f3 --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiEntries.swift @@ -0,0 +1,193 @@ +public import CmuxAgentReplica + +/// Selects a semantic edge or direction for a journal page request. +public enum GuiEntriesAnchor: String, Codable, Hashable, Sendable { + /// The first page in the journal. + case head + /// The last page in the journal. + case tail + /// The page immediately preceding ``GuiEntriesParams/cursor``. + case before + /// The page immediately following ``GuiEntriesParams/cursor``. + case after +} + +/// Parameters for requesting a bounded journal page. +public struct GuiEntriesParams: Codable, Hashable, Sendable { + /// The session whose journal is requested. + public let sessionID: AgentSessionID + /// The expected journal, or `nil` to accept the current journal. + public let journalID: JournalID? + /// The exclusive upper sequence bound for backward paging. + public let beforeSeq: EntrySeq? + /// The exclusive lower sequence bound for forward paging. + public let afterSeq: EntrySeq? + /// The semantic page edge or direction. When absent, legacy sequence + /// bounds retain their original behavior. + public let anchor: GuiEntriesAnchor? + /// An opaque boundary returned by a previous page. + public let cursor: JournalCursor? + /// The client-requested page size; the server may clamp it. + public let limit: Int + + private enum CodingKeys: String, CodingKey { + case sessionID = "session_id" + case journalID = "journal_id" + case beforeSeq = "before_seq" + case afterSeq = "after_seq" + case anchor + case cursor + case limit + } + + /// Creates journal-page parameters. + /// - Parameters: + /// - sessionID: The session whose entries are requested. + /// - journalID: The expected journal, if known. + /// - beforeSeq: The exclusive upper bound for backward paging. + /// - afterSeq: The exclusive lower bound for forward paging. + /// - limit: The requested page size before server clamping. + public init( + sessionID: AgentSessionID, + journalID: JournalID? = nil, + beforeSeq: EntrySeq? = nil, + afterSeq: EntrySeq? = nil, + anchor: GuiEntriesAnchor? = nil, + cursor: JournalCursor? = nil, + limit: Int + ) { + self.sessionID = sessionID + self.journalID = journalID + self.beforeSeq = beforeSeq + self.afterSeq = afterSeq + self.anchor = anchor + self.cursor = cursor + self.limit = limit + } +} + +/// Result containing a bounded journal window and its advertised boundaries. +public struct GuiEntriesResult: Codable, Hashable, Sendable { + /// The journal that owns every returned entry. + public let journalID: JournalID + /// Valid whole-value entries decoded from the page. + public let entries: [EntrySnapshot] + /// The first sequence represented by the returned window. + public let windowStart: EntrySeq + /// The last sequence represented by the returned window. + public let windowEnd: EntrySeq + /// The server's current journal tail sequence. + public let tailSeq: EntrySeq + /// Whether an earlier page may be requested. + public let hasMoreBefore: Bool + /// Whether a later page may be requested. + public let hasMoreAfter: Bool + /// Cursor at the leading edge of this page. + public let startCursor: JournalCursor? + /// Cursor at the trailing edge of this page. + public let endCursor: JournalCursor? + /// Cursor at the server's current journal tail. + public let tailCursor: JournalCursor? + /// Whether the request cursor belonged to a replaced journal or was + /// otherwise invalid. Clients should discard paging state and restart. + public let requiresPagingRestart: Bool + /// The number of malformed entry elements discarded while decoding. + public let malformedEntryCount: Int + + private enum CodingKeys: String, CodingKey { + case journalID = "journal_id" + case entries + case windowStart = "window_start" + case windowEnd = "window_end" + case tailSeq = "tail_seq" + case hasMoreBefore = "has_more_before" + case hasMoreAfter = "has_more_after" + case startCursor = "start_cursor" + case endCursor = "end_cursor" + case tailCursor = "tail_cursor" + case requiresPagingRestart = "requires_paging_restart" + } + + /// Creates a journal-page result with no decode diagnostics. + /// - Parameters: + /// - journalID: The journal that owns the entries. + /// - entries: Whole-value entries in the page. + /// - windowStart: The first represented sequence. + /// - windowEnd: The last represented sequence. + /// - tailSeq: The server's current tail sequence. + /// - hasMoreBefore: Whether an earlier page may be requested. + public init( + journalID: JournalID, + entries: [EntrySnapshot], + windowStart: EntrySeq, + windowEnd: EntrySeq, + tailSeq: EntrySeq, + hasMoreBefore: Bool, + hasMoreAfter: Bool = false, + startCursor: JournalCursor? = nil, + endCursor: JournalCursor? = nil, + tailCursor: JournalCursor? = nil, + requiresPagingRestart: Bool = false + ) { + self.journalID = journalID + self.entries = entries + self.windowStart = windowStart + self.windowEnd = windowEnd + self.tailSeq = tailSeq + self.hasMoreBefore = hasMoreBefore + self.hasMoreAfter = hasMoreAfter + self.startCursor = startCursor + self.endCursor = endCursor + self.tailCursor = tailCursor + self.requiresPagingRestart = requiresPagingRestart + self.malformedEntryCount = 0 + } + + /// Decodes a journal page while discarding only malformed entry elements. + /// - Parameter decoder: The decoder to read from. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let decodedEntries = (try? container.decode([LossyEntry].self, forKey: .entries)) ?? [] + self.journalID = try container.decode(JournalID.self, forKey: .journalID) + self.entries = decodedEntries.compactMap(\.value) + self.windowStart = try container.decode(EntrySeq.self, forKey: .windowStart) + self.windowEnd = try container.decode(EntrySeq.self, forKey: .windowEnd) + self.tailSeq = try container.decode(EntrySeq.self, forKey: .tailSeq) + self.hasMoreBefore = try container.decode(Bool.self, forKey: .hasMoreBefore) + self.hasMoreAfter = (try? container.decode(Bool.self, forKey: .hasMoreAfter)) ?? false + self.startCursor = try? container.decode(JournalCursor.self, forKey: .startCursor) + self.endCursor = try? container.decode(JournalCursor.self, forKey: .endCursor) + self.tailCursor = try? container.decode(JournalCursor.self, forKey: .tailCursor) + self.requiresPagingRestart = (try? container.decode(Bool.self, forKey: .requiresPagingRestart)) ?? false + self.malformedEntryCount = decodedEntries.count - entries.count + } + + /// Encodes the wire page without local decode diagnostics. + /// - Parameter encoder: The encoder to write to. + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(journalID, forKey: .journalID) + try container.encode(entries, forKey: .entries) + try container.encode(windowStart, forKey: .windowStart) + try container.encode(windowEnd, forKey: .windowEnd) + try container.encode(tailSeq, forKey: .tailSeq) + try container.encode(hasMoreBefore, forKey: .hasMoreBefore) + if hasMoreAfter { + try container.encode(true, forKey: .hasMoreAfter) + } + try container.encodeIfPresent(startCursor, forKey: .startCursor) + try container.encodeIfPresent(endCursor, forKey: .endCursor) + try container.encodeIfPresent(tailCursor, forKey: .tailCursor) + if requiresPagingRestart { + try container.encode(true, forKey: .requiresPagingRestart) + } + } +} + +private struct LossyEntry: Decodable { + let value: EntrySnapshot? + + init(from decoder: any Decoder) throws { + self.value = try? EntrySnapshot(from: decoder) + } +} diff --git a/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiHello.swift b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiHello.swift new file mode 100644 index 000000000000..2860909d995b --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiHello.swift @@ -0,0 +1,71 @@ +public import CmuxAgentReplica + +/// Parameters sent when negotiating the `gui.v1` protocol. +public struct GuiHelloParams: Codable, Hashable, Sendable { + /// The oldest protocol version the client supports. + public let protocolMin: Int + /// The newest protocol version the client supports. + public let protocolMax: Int + /// Open capability strings advertised by the client. + public let clientCaps: [String] + + private enum CodingKeys: String, CodingKey { + case protocolMin = "protocol_min" + case protocolMax = "protocol_max" + case clientCaps = "client_caps" + } + + /// Creates hello parameters. + /// - Parameters: + /// - protocolMin: The oldest supported protocol version. + /// - protocolMax: The newest supported protocol version. + /// - clientCaps: Open client capability strings. + public init(protocolMin: Int, protocolMax: Int, clientCaps: [String]) { + self.protocolMin = protocolMin + self.protocolMax = protocolMax + self.clientCaps = clientCaps + } +} + +/// Result returned after negotiating the `gui.v1` protocol. +public struct GuiHelloResult: Codable, Hashable, Sendable { + /// The negotiated protocol version. + public let `protocol`: Int + /// Open capability strings advertised by the server. + public let serverCaps: [String] + /// The current Mac process epoch. + public let epoch: ReplicaEpoch + /// The stable identifier of the serving Mac. + public let macDeviceID: MacDeviceID + /// Server epoch milliseconds for display offset calculations only. + public let serverTimeMS: Int64 + + private enum CodingKeys: String, CodingKey { + case `protocol` + case serverCaps = "server_caps" + case epoch + case macDeviceID = "mac_device_id" + case serverTimeMS = "server_time_ms" + } + + /// Creates a hello result. + /// - Parameters: + /// - protocol: The negotiated protocol version. + /// - serverCaps: Open server capability strings. + /// - epoch: The current Mac process epoch. + /// - macDeviceID: The stable serving Mac identifier. + /// - serverTimeMS: Epoch milliseconds used only to calculate display offsets. + public init( + protocol: Int, + serverCaps: [String], + epoch: ReplicaEpoch, + macDeviceID: MacDeviceID, + serverTimeMS: Int64 + ) { + self.protocol = `protocol` + self.serverCaps = serverCaps + self.epoch = epoch + self.macDeviceID = macDeviceID + self.serverTimeMS = serverTimeMS + } +} diff --git a/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiInterrupt.swift b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiInterrupt.swift new file mode 100644 index 000000000000..d71138d14134 --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiInterrupt.swift @@ -0,0 +1,35 @@ +public import CmuxAgentReplica + +/// Parameters for interrupting an agent session. +public struct GuiInterruptParams: Codable, Hashable, Sendable { + /// The session to interrupt. + public let sessionID: AgentSessionID + /// Whether the server should request a hard interrupt. + public let hard: Bool + + private enum CodingKeys: String, CodingKey { + case sessionID = "session_id" + case hard + } + + /// Creates interrupt parameters. + /// - Parameters: + /// - sessionID: The session to interrupt. + /// - hard: Whether to request a hard interrupt. + public init(sessionID: AgentSessionID, hard: Bool) { + self.sessionID = sessionID + self.hard = hard + } +} + +/// Result reporting whether an interrupt was applied. +public struct GuiInterruptResult: Codable, Hashable, Sendable { + /// Whether the session was interrupted. + public let interrupted: Bool + + /// Creates an interrupt result. + /// - Parameter interrupted: Whether the session was interrupted. + public init(interrupted: Bool) { + self.interrupted = interrupted + } +} diff --git a/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiSend.swift b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiSend.swift new file mode 100644 index 000000000000..453982f3d800 --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiSend.swift @@ -0,0 +1,60 @@ +public import CmuxAgentReplica + +/// Parameters for submitting an idempotent send ticket. +public struct GuiSendParams: Codable, Hashable, Sendable { + /// The destination session. + public let sessionID: AgentSessionID + /// The client-minted UUID string used for idempotency. + public let ticketID: String + /// Optional user text. + public let text: String? + /// Optional attachment descriptors; binary transfer is outside this contract. + public let attachments: [GuiSendAttachment]? + + private enum CodingKeys: String, CodingKey { + case sessionID = "session_id" + case ticketID = "ticket_id" + case text + case attachments + } + + /// Creates send parameters. + /// - Parameters: + /// - sessionID: The destination session. + /// - ticketID: The client-minted UUID string. + /// - text: Optional user text. + /// - attachments: Optional attachment descriptors. + public init( + sessionID: AgentSessionID, + ticketID: String, + text: String? = nil, + attachments: [GuiSendAttachment]? = nil + ) { + self.sessionID = sessionID + self.ticketID = ticketID + self.text = text + self.attachments = attachments + } +} + +/// Result acknowledging Mac-side acceptance of a send ticket. +public struct GuiSendResult: Codable, Hashable, Sendable { + /// Whether the Mac accepted the ticket. + public let accepted: Bool + /// Whether the accepted ticket is queued on the Mac. + public let queuedOnMac: Bool + + private enum CodingKeys: String, CodingKey { + case accepted + case queuedOnMac = "queued_on_mac" + } + + /// Creates a send result. + /// - Parameters: + /// - accepted: Whether the Mac accepted the ticket. + /// - queuedOnMac: Whether the accepted ticket is queued on the Mac. + public init(accepted: Bool, queuedOnMac: Bool) { + self.accepted = accepted + self.queuedOnMac = queuedOnMac + } +} diff --git a/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiSendAttachment.swift b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiSendAttachment.swift new file mode 100644 index 000000000000..aea19fbff7d1 --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiSendAttachment.swift @@ -0,0 +1,21 @@ +/// Describes one attachment associated with a send request. +public struct GuiSendAttachment: Codable, Hashable, Sendable { + /// The open attachment kind string. + public let kind: String + /// The attachment's encoded byte count. + public let byteCount: Int + + private enum CodingKeys: String, CodingKey { + case kind + case byteCount = "byte_count" + } + + /// Creates an attachment descriptor. + /// - Parameters: + /// - kind: The open attachment kind string. + /// - byteCount: The encoded byte count. + public init(kind: String, byteCount: Int) { + self.kind = kind + self.byteCount = byteCount + } +} diff --git a/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiSession.swift b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiSession.swift new file mode 100644 index 000000000000..6108f8d41152 --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiSession.swift @@ -0,0 +1,34 @@ +public import CmuxAgentReplica + +/// Parameters for requesting one agent session. +public struct GuiSessionParams: Codable, Hashable, Sendable { + /// The requested session identifier. + public let sessionID: AgentSessionID + + private enum CodingKeys: String, CodingKey { + case sessionID = "session_id" + } + + /// Creates a session request. + /// - Parameter sessionID: The requested session identifier. + public init(sessionID: AgentSessionID) { + self.sessionID = sessionID + } +} + +/// Result containing one whole-value agent session snapshot. +public struct GuiSessionResult: Codable, Hashable, Sendable { + /// The epoch that scopes the returned snapshot. + public let epoch: ReplicaEpoch + /// The requested whole-value session snapshot. + public let session: AgentSessionSnapshot + + /// Creates a session result. + /// - Parameters: + /// - epoch: The epoch that scopes the snapshot. + /// - session: The requested session snapshot. + public init(epoch: ReplicaEpoch, session: AgentSessionSnapshot) { + self.epoch = epoch + self.session = session + } +} diff --git a/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiSessions.swift b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiSessions.swift new file mode 100644 index 000000000000..ef4e84bb664a --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Sources/CmuxAgentWire/Methods/GuiSessions.swift @@ -0,0 +1,24 @@ +public import CmuxAgentReplica + +/// Parameters for requesting all current agent sessions. +public struct GuiSessionsParams: Codable, Hashable, Sendable { + /// Creates an empty sessions request. + public init() {} +} + +/// Pull-authoritative result containing all current agent sessions. +public struct GuiSessionsResult: Codable, Hashable, Sendable { + /// The epoch that scopes the returned snapshots. + public let epoch: ReplicaEpoch + /// Whole-value session snapshots. + public let sessions: [AgentSessionSnapshot] + + /// Creates a sessions result. + /// - Parameters: + /// - epoch: The epoch that scopes the snapshots. + /// - sessions: Whole-value session snapshots. + public init(epoch: ReplicaEpoch, sessions: [AgentSessionSnapshot]) { + self.epoch = epoch + self.sessions = sessions + } +} diff --git a/Packages/Shared/CmuxAgentWire/Tests/CmuxAgentWireTests/ErrorAndConstantsTests.swift b/Packages/Shared/CmuxAgentWire/Tests/CmuxAgentWireTests/ErrorAndConstantsTests.swift new file mode 100644 index 000000000000..fd0c20e4e752 --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Tests/CmuxAgentWireTests/ErrorAndConstantsTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing +import CmuxAgentReplica +@testable import CmuxAgentWire + +@Suite struct ErrorAndConstantsTests { + @Test(arguments: [ + ("invalid_params", GuiWireErrorCode.invalidParams), + ("unsupported_protocol", GuiWireErrorCode.unsupportedProtocol), + ("not_found", GuiWireErrorCode.notFound), + ("binding_lost", GuiWireErrorCode.bindingLost), + ("input_queue_full", GuiWireErrorCode.inputQueueFull), + ("process_exited", GuiWireErrorCode.processExited), + ("send_rejected", GuiWireErrorCode.sendRejected), + ("rate_limited", GuiWireErrorCode.rateLimited), + ("internal_error", GuiWireErrorCode.internalError), + ]) + func knownErrorCodesMapFromRPC(rawValue: String, expected: GuiWireErrorCode) throws { + let error = GuiWireError(code: rawValue, message: "Message") + + #expect(error.code == expected) + try WireTestSupport.assertGolden( + error, + json: #"{"code":"\#(rawValue)","message":"Message"}"# + ) + } + + @Test func unknownErrorCodeFailsOpenAndRoundTrips() throws { + let error = GuiWireError(code: "future_error", message: "Future") + + #expect(error.code == .unknown("future_error")) + try WireTestSupport.assertGolden( + error, + json: #"{"code":"future_error","message":"Future"}"# + ) + } + + @Test func methodTopicAndCapabilityConstantsArePinned() { + #expect(GuiWireMethod.hello == "gui.v1.hello") + #expect(GuiWireMethod.sessions == "gui.v1.sessions") + #expect(GuiWireMethod.session == "gui.v1.session") + #expect(GuiWireMethod.entries == "gui.v1.entries") + #expect(GuiWireMethod.send == "gui.v1.send") + #expect(GuiWireMethod.interrupt == "gui.v1.interrupt") + #expect(GuiWireMethod.answer == "gui.v1.answer") + #expect(GuiWireMethod.capabilities == "gui.v1.capabilities") + #expect(GuiWireTopic.sessions == "gui.v1.sessions") + #expect(GuiWireTopic.journalPrefix == "gui.v1.journal.") + #expect(GuiWireTopic.journal(sessionID: WireTestSupport.sessionID) == "gui.v1.journal.session-1") + #expect(GuiWireCaps.entriesPaging == "entries-paging") + #expect(GuiWireCaps.sendTickets == "send-tickets") + #expect(GuiWireCaps.answers == "answers") + #expect(GuiWireCaps.capabilitiesReport == "capabilities-report") + } +} diff --git a/Packages/Shared/CmuxAgentWire/Tests/CmuxAgentWireTests/EventGoldenTests.swift b/Packages/Shared/CmuxAgentWire/Tests/CmuxAgentWireTests/EventGoldenTests.swift new file mode 100644 index 000000000000..fbce9900048f --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Tests/CmuxAgentWireTests/EventGoldenTests.swift @@ -0,0 +1,135 @@ +import Foundation +import Testing +import CmuxAgentReplica +@testable import CmuxAgentWire + +@Suite struct EventGoldenTests { + @Test func sessionUpsertedFrameIsGoldenAndRoundTrips() throws { + try WireTestSupport.assertGolden( + GuiEventFrame( + epoch: WireTestSupport.epoch, + payload: .sessionUpserted(GuiSessionUpsertedEvent(session: WireTestSupport.session)) + ), + json: #"{"epoch":"epoch-1","kind":"session_upserted","payload":{"session":\#(WireTestSupport.sessionJSON)}}"# + ) + } + + @Test func sessionRemovedFrameIsGoldenAndRoundTrips() throws { + try WireTestSupport.assertGolden( + GuiEventFrame( + epoch: WireTestSupport.epoch, + payload: .sessionRemoved(GuiSessionRemovedEvent( + sessionID: WireTestSupport.sessionID, + version: EntityVersion(rawValue: 4) + )) + ), + json: #"{"epoch":"epoch-1","kind":"session_removed","payload":{"session_id":"session-1","version":4}}"# + ) + } + + @Test func entriesAppendedFrameIsGoldenAndRoundTrips() throws { + try WireTestSupport.assertGolden( + GuiEventFrame( + epoch: WireTestSupport.epoch, + sessionID: WireTestSupport.sessionID, + payload: .entriesAppended(GuiEntriesAppendedEvent( + journalID: WireTestSupport.journalID, + entries: [WireTestSupport.entry] + )) + ), + json: #"{"epoch":"epoch-1","kind":"entries_appended","payload":{"entries":[\#(WireTestSupport.entryJSON)],"journal_id":"journal-1"},"session_id":"session-1"}"# + ) + } + + @Test func entryReplacedFrameIsGoldenAndRoundTrips() throws { + try WireTestSupport.assertGolden( + GuiEventFrame( + epoch: WireTestSupport.epoch, + sessionID: WireTestSupport.sessionID, + payload: .entryReplaced(GuiEntryReplacedEvent( + journalID: WireTestSupport.journalID, + entry: WireTestSupport.entry + )) + ), + json: #"{"epoch":"epoch-1","kind":"entry_replaced","payload":{"entry":\#(WireTestSupport.entryJSON),"journal_id":"journal-1"},"session_id":"session-1"}"# + ) + } + + @Test func journalResetFrameIsGoldenAndRoundTrips() throws { + try WireTestSupport.assertGolden( + GuiEventFrame( + epoch: WireTestSupport.epoch, + sessionID: WireTestSupport.sessionID, + payload: .journalReset(GuiJournalResetEvent( + sessionID: WireTestSupport.sessionID, + newJournalID: JournalID(rawValue: "journal-2"), + tailSeq: EntrySeq(rawValue: 44) + )) + ), + json: #"{"epoch":"epoch-1","kind":"journal_reset","payload":{"new_journal_id":"journal-2","session_id":"session-1","tail_seq":44},"session_id":"session-1"}"# + ) + } + + @Test func sendStateFrameIsGoldenAndRoundTrips() throws { + try WireTestSupport.assertGolden( + GuiEventFrame( + epoch: WireTestSupport.epoch, + sessionID: WireTestSupport.sessionID, + payload: .sendState(GuiSendStateEvent(ticket: WireTestSupport.ticket)) + ), + json: #"{"epoch":"epoch-1","kind":"send_state","payload":{"ticket":\#(WireTestSupport.ticketJSON)},"session_id":"session-1"}"# + ) + } + + @Test func askStateFrameIsGoldenAndRoundTrips() throws { + try WireTestSupport.assertGolden( + GuiEventFrame( + epoch: WireTestSupport.epoch, + sessionID: WireTestSupport.sessionID, + payload: .askState(GuiAskStateEvent(ask: WireTestSupport.ask)) + ), + json: #"{"epoch":"epoch-1","kind":"ask_state","payload":{"ask":\#(WireTestSupport.askJSON)},"session_id":"session-1"}"# + ) + } + + @Test func streamTickFrameIsGoldenAndRoundTrips() throws { + try WireTestSupport.assertGolden( + GuiEventFrame( + epoch: WireTestSupport.epoch, + sessionID: WireTestSupport.sessionID, + payload: .streamTick(GuiStreamTickEvent( + journalID: WireTestSupport.journalID, + afterSeq: EntrySeq(rawValue: 10), + textTail: "Working", + revision: 2 + )) + ), + json: #"{"epoch":"epoch-1","kind":"stream_tick","payload":{"after_seq":10,"journal_id":"journal-1","revision":2,"text_tail":"Working"},"session_id":"session-1"}"# + ) + } + + @Test func unknownFrameIsGoldenAndRoundTrips() throws { + try WireTestSupport.assertGolden( + GuiEventFrame(epoch: WireTestSupport.epoch, payload: .unknown("future_kind")), + json: #"{"epoch":"epoch-1","kind":"future_kind","payload":{}}"# + ) + } + + @Test func unknownKindAndExtraFieldsDecodeWithoutThrowing() throws { + let data = Data(#"{"epoch":"epoch-1","extra":true,"kind":"future_kind","payload":{"opaque":1},"session_id":"session-1"}"#.utf8) + let decoded = try JSONDecoder().decode(GuiEventFrame.self, from: data) + + #expect(decoded == GuiEventFrame( + epoch: WireTestSupport.epoch, + sessionID: WireTestSupport.sessionID, + payload: .unknown("future_kind") + )) + } + + @Test func malformedKnownPayloadFailsOpenToUnknownKind() throws { + let data = Data(#"{"epoch":"epoch-1","kind":"entry_replaced","payload":{"journal_id":"journal-1"}}"#.utf8) + let decoded = try JSONDecoder().decode(GuiEventFrame.self, from: data) + + #expect(decoded.payload == .unknown("entry_replaced")) + } +} diff --git a/Packages/Shared/CmuxAgentWire/Tests/CmuxAgentWireTests/MethodGoldenTests.swift b/Packages/Shared/CmuxAgentWire/Tests/CmuxAgentWireTests/MethodGoldenTests.swift new file mode 100644 index 000000000000..0e92b8660879 --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Tests/CmuxAgentWireTests/MethodGoldenTests.swift @@ -0,0 +1,160 @@ +import Foundation +import Testing +import CmuxAgentReplica +@testable import CmuxAgentWire + +@Suite struct MethodGoldenTests { + @Test func helloParamsAndResultAreGoldenAndRoundTrip() throws { + try WireTestSupport.assertGolden( + GuiHelloParams(protocolMin: 1, protocolMax: 2, clientCaps: [GuiWireCaps.entriesPaging]), + json: #"{"client_caps":["entries-paging"],"protocol_max":2,"protocol_min":1}"# + ) + try WireTestSupport.assertGolden( + GuiHelloResult( + protocol: 1, + serverCaps: [GuiWireCaps.sendTickets, GuiWireCaps.answers], + epoch: WireTestSupport.epoch, + macDeviceID: WireTestSupport.mac, + serverTimeMS: 1_725_000_000_123 + ), + json: #"{"epoch":"epoch-1","mac_device_id":"mac-1","protocol":1,"server_caps":["send-tickets","answers"],"server_time_ms":1725000000123}"# + ) + } + + @Test func sessionsParamsAndResultAreGoldenAndRoundTrip() throws { + try WireTestSupport.assertGolden(GuiSessionsParams(), json: #"{}"#) + try WireTestSupport.assertGolden( + GuiSessionsResult(epoch: WireTestSupport.epoch, sessions: [WireTestSupport.session]), + json: #"{"epoch":"epoch-1","sessions":[\#(WireTestSupport.sessionJSON)]}"# + ) + } + + @Test func sessionParamsAndResultAreGoldenAndRoundTrip() throws { + try WireTestSupport.assertGolden( + GuiSessionParams(sessionID: WireTestSupport.sessionID), + json: #"{"session_id":"session-1"}"# + ) + try WireTestSupport.assertGolden( + GuiSessionResult(epoch: WireTestSupport.epoch, session: WireTestSupport.session), + json: #"{"epoch":"epoch-1","session":\#(WireTestSupport.sessionJSON)}"# + ) + } + + @Test func entriesParamsAndResultAreGoldenAndRoundTrip() throws { + try WireTestSupport.assertGolden( + GuiEntriesParams( + sessionID: WireTestSupport.sessionID, + journalID: WireTestSupport.journalID, + beforeSeq: EntrySeq(rawValue: 20), + afterSeq: EntrySeq(rawValue: 9), + limit: 50 + ), + json: #"{"after_seq":9,"before_seq":20,"journal_id":"journal-1","limit":50,"session_id":"session-1"}"# + ) + try WireTestSupport.assertGolden( + GuiEntriesResult( + journalID: WireTestSupport.journalID, + entries: [WireTestSupport.entry], + windowStart: EntrySeq(rawValue: 10), + windowEnd: EntrySeq(rawValue: 10), + tailSeq: EntrySeq(rawValue: 12), + hasMoreBefore: true + ), + json: #"{"entries":[\#(WireTestSupport.entryJSON)],"has_more_before":true,"journal_id":"journal-1","tail_seq":12,"window_end":10,"window_start":10}"# + ) + } + + @Test func cursorEntriesAreGoldenAndRoundTrip() throws { + try WireTestSupport.assertGolden( + GuiEntriesParams( + sessionID: WireTestSupport.sessionID, + journalID: WireTestSupport.journalID, + anchor: .before, + cursor: JournalCursor(rawValue: "opaque-start"), + limit: 50 + ), + json: #"{"anchor":"before","cursor":"opaque-start","journal_id":"journal-1","limit":50,"session_id":"session-1"}"# + ) + try WireTestSupport.assertGolden( + GuiEntriesResult( + journalID: WireTestSupport.journalID, + entries: [WireTestSupport.entry], + windowStart: EntrySeq(rawValue: 10), + windowEnd: EntrySeq(rawValue: 10), + tailSeq: EntrySeq(rawValue: 12), + hasMoreBefore: true, + hasMoreAfter: true, + startCursor: JournalCursor(rawValue: "start"), + endCursor: JournalCursor(rawValue: "end"), + tailCursor: JournalCursor(rawValue: "tail"), + requiresPagingRestart: true + ), + json: #"{"end_cursor":"end","entries":[\#(WireTestSupport.entryJSON)],"has_more_after":true,"has_more_before":true,"journal_id":"journal-1","requires_paging_restart":true,"start_cursor":"start","tail_cursor":"tail","tail_seq":12,"window_end":10,"window_start":10}"# + ) + } + + @Test func sendParamsAndResultAreGoldenAndRoundTrip() throws { + try WireTestSupport.assertGolden( + GuiSendParams( + sessionID: WireTestSupport.sessionID, + ticketID: "11111111-1111-1111-1111-111111111111", + text: "Queue this", + attachments: [GuiSendAttachment(kind: "image", byteCount: 2048)] + ), + json: #"{"attachments":[{"byte_count":2048,"kind":"image"}],"session_id":"session-1","text":"Queue this","ticket_id":"11111111-1111-1111-1111-111111111111"}"# + ) + try WireTestSupport.assertGolden( + GuiSendResult(accepted: true, queuedOnMac: true), + json: #"{"accepted":true,"queued_on_mac":true}"# + ) + } + + @Test func interruptParamsAndResultAreGoldenAndRoundTrip() throws { + try WireTestSupport.assertGolden( + GuiInterruptParams(sessionID: WireTestSupport.sessionID, hard: true), + json: #"{"hard":true,"session_id":"session-1"}"# + ) + try WireTestSupport.assertGolden( + GuiInterruptResult(interrupted: true), + json: #"{"interrupted":true}"# + ) + } + + @Test func answerParamsAndResultAreGoldenAndRoundTrip() throws { + try WireTestSupport.assertGolden( + GuiAnswerParams(sessionID: WireTestSupport.sessionID, askID: "ask-1", choiceIndex: 1), + json: #"{"ask_id":"ask-1","choice_index":1,"session_id":"session-1"}"# + ) + try WireTestSupport.assertGolden( + GuiAnswerResult(answered: true), + json: #"{"answered":true}"# + ) + } + + @Test func capabilitiesParamsAndResultAreGoldenAndRoundTrip() throws { + try WireTestSupport.assertGolden( + GuiCapabilitiesParams(sessionID: WireTestSupport.sessionID), + json: #"{"session_id":"session-1"}"# + ) + try WireTestSupport.assertGolden( + GuiCapabilitiesResult( + tier: .degraded, + reasons: [ + GuiCapabilityReason(code: "hooks_disabled", detail: "Safe mode"), + GuiCapabilityReason(code: "cli_too_old"), + ], + cliVersion: "0.128.0", + steerable: false, + answerable: true + ), + json: #"{"answerable":true,"cli_version":"0.128.0","reasons":[{"code":"hooks_disabled","detail":"Safe mode"},{"code":"cli_too_old"}],"steerable":false,"tier":"degraded"}"# + ) + } + + @Test func unknownFieldsAreIgnoredAndMissingOptionalsDefaultToNil() throws { + let data = Data(#"{"session_id":"session-1","ticket_id":"ticket-1","future":true}"#.utf8) + let decoded = try JSONDecoder().decode(GuiSendParams.self, from: data) + + #expect(decoded == GuiSendParams(sessionID: WireTestSupport.sessionID, ticketID: "ticket-1")) + } +} diff --git a/Packages/Shared/CmuxAgentWire/Tests/CmuxAgentWireTests/ReplicaGoldenTests.swift b/Packages/Shared/CmuxAgentWire/Tests/CmuxAgentWireTests/ReplicaGoldenTests.swift new file mode 100644 index 000000000000..f4fb221a4598 --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Tests/CmuxAgentWireTests/ReplicaGoldenTests.swift @@ -0,0 +1,49 @@ +import Testing +import CmuxAgentReplica +import Foundation +@testable import CmuxAgentWire + +@Suite struct ReplicaGoldenTests { + @Test func agentSessionSnapshotEncodingIsPinned() throws { + try WireTestSupport.assertGolden( + WireTestSupport.session, + json: WireTestSupport.sessionJSON + ) + } + + @Test func entrySnapshotEncodingIsPinned() throws { + try WireTestSupport.assertGolden( + WireTestSupport.entry, + json: WireTestSupport.entryJSON + ) + } + + @Test func entryPayloadEncodingIsPinned() throws { + try WireTestSupport.assertGolden( + WireTestSupport.entryPayload, + json: WireTestSupport.entryPayloadJSON + ) + } + + @Test func sendTicketEncodingIsPinned() throws { + try WireTestSupport.assertGolden( + WireTestSupport.ticket, + json: WireTestSupport.ticketJSON + ) + } + + @Test func pendingAskEncodingIsPinned() throws { + try WireTestSupport.assertGolden( + WireTestSupport.ask, + json: WireTestSupport.askJSON + ) + } + + @Test func pendingAskDecodesLegacyCountOnlyShape() throws { + let data = Data(#"{"id":"ask-1","kind":"question","options_count":2,"prompt_summary":"Choose","session_id":"session-1","state":{"type":"active"}}"#.utf8) + let ask = try JSONDecoder().decode(PendingAsk.self, from: data) + + #expect(ask.options.isEmpty) + #expect(ask.optionsCount == 2) + } +} diff --git a/Packages/Shared/CmuxAgentWire/Tests/CmuxAgentWireTests/ToleranceTests.swift b/Packages/Shared/CmuxAgentWire/Tests/CmuxAgentWireTests/ToleranceTests.swift new file mode 100644 index 000000000000..ebda872d70bb --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Tests/CmuxAgentWireTests/ToleranceTests.swift @@ -0,0 +1,33 @@ +import Foundation +import Testing +import CmuxAgentReplica +@testable import CmuxAgentWire + +@Suite struct ToleranceTests { + @Test func entriesDropsOnlyMalformedElementsAndReportsCount() throws { + let json = #"{"entries":[\#(WireTestSupport.entryJSON),{"journal_id":"journal-1","seq":"bad"}],"has_more_before":false,"journal_id":"journal-1","tail_seq":10,"window_end":10,"window_start":10}"# + let decoded = try JSONDecoder().decode(GuiEntriesResult.self, from: Data(json.utf8)) + + #expect(decoded.entries == [WireTestSupport.entry]) + #expect(decoded.malformedEntryCount == 1) + #expect(decoded.tailSeq == EntrySeq(rawValue: 10)) + } + + @Test func streamTickDecodeTruncatesTextTailToSixteenKilobytes() throws { + let oversized = String(repeating: "x", count: GuiStreamTickEvent.textTailByteLimit + 512) + let json = #"{"after_seq":10,"journal_id":"journal-1","revision":3,"text_tail":"\#(oversized)"}"# + let decoded = try JSONDecoder().decode(GuiStreamTickEvent.self, from: Data(json.utf8)) + + #expect(decoded.textTail.utf8.count == GuiStreamTickEvent.textTailByteLimit) + #expect(decoded.textTail == String(repeating: "x", count: GuiStreamTickEvent.textTailByteLimit)) + } + + @Test func streamTickTruncationPreservesValidUTF8Tail() throws { + let oversized = "😀" + String(repeating: "x", count: GuiStreamTickEvent.textTailByteLimit - 2) + let json = #"{"after_seq":10,"journal_id":"journal-1","revision":3,"text_tail":"\#(oversized)"}"# + let decoded = try JSONDecoder().decode(GuiStreamTickEvent.self, from: Data(json.utf8)) + + #expect(decoded.textTail == String(repeating: "x", count: GuiStreamTickEvent.textTailByteLimit - 2)) + #expect(decoded.textTail.utf8.count <= GuiStreamTickEvent.textTailByteLimit) + } +} diff --git a/Packages/Shared/CmuxAgentWire/Tests/CmuxAgentWireTests/WireTestSupport.swift b/Packages/Shared/CmuxAgentWire/Tests/CmuxAgentWireTests/WireTestSupport.swift new file mode 100644 index 000000000000..7e731842f3d0 --- /dev/null +++ b/Packages/Shared/CmuxAgentWire/Tests/CmuxAgentWireTests/WireTestSupport.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing +import CmuxAgentReplica +@testable import CmuxAgentWire + +struct WireTestSupport { + static let epoch = ReplicaEpoch(rawValue: "epoch-1") + static let mac = MacDeviceID(rawValue: "mac-1") + static let sessionID = AgentSessionID(rawValue: "session-1") + static let journalID = JournalID(rawValue: "journal-1") + + static let session = AgentSessionSnapshot( + id: sessionID, + macDeviceID: mac, + kind: .codex, + phase: .needsInput, + tier: .observed, + surfaceID: "surface-1", + cwd: "/repo", + title: "Agent", + workspaceName: "Workspace", + version: EntityVersion(rawValue: 3), + lastActivityHint: 7 + ) + + static let entryPayload = EntryPayload.userMessage(UserMessagePayload( + text: "Hello", + attachmentCount: 1, + hasImage: true + )) + + static let entry = EntrySnapshot( + journalID: journalID, + seq: EntrySeq(rawValue: 10), + kind: .userMessage, + content: EntryContent(contentHash: 101, payload: entryPayload), + version: EntityVersion(rawValue: 2) + ) + + static let ticket = SendTicket( + id: UUID(uuidString: "11111111-1111-1111-1111-111111111111")!, + sessionID: sessionID, + text: "Queue this", + attachmentCount: 2, + state: .acceptedByMac, + createdAt: 123 + ) + + static let ask = PendingAsk( + id: "ask-1", + sessionID: sessionID, + kind: .question, + promptSummary: "Choose", + options: ["Allow", "Deny"], + state: .answered(choice: 1) + ) + + static let sessionJSON = #"{"cwd":"\/repo","id":"session-1","kind":"codex","last_activity_hint":7,"mac_device_id":"mac-1","phase":"needsInput","surface_id":"surface-1","tier":"observed","title":"Agent","version":3,"workspace_name":"Workspace"}"# + static let entryPayloadJSON = #"{"attachment_count":1,"has_image":true,"kind":"userMessage","text":"Hello"}"# + static let entryJSON = #"{"content":{"content_hash":101,"payload":{"attachment_count":1,"has_image":true,"kind":"userMessage","text":"Hello"}},"journal_id":"journal-1","kind":"userMessage","seq":10,"version":2}"# + static let ticketJSON = #"{"attachment_count":2,"created_at":123,"id":"11111111-1111-1111-1111-111111111111","session_id":"session-1","state":{"type":"acceptedByMac"},"text":"Queue this"}"# + static let askJSON = #"{"id":"ask-1","kind":"question","options":["Allow","Deny"],"options_count":2,"prompt_summary":"Choose","session_id":"session-1","state":{"choice":1,"type":"answered"}}"# + + static func assertGolden( + _ value: Value, + json: String, + sourceLocation: SourceLocation = #_sourceLocation + ) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let encoded = try encoder.encode(value) + #expect(encoded == Data(json.utf8), sourceLocation: sourceLocation) + let decoded = try JSONDecoder().decode(Value.self, from: Data(json.utf8)) + #expect(decoded == value, sourceLocation: sourceLocation) + } +} diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTCPFirstActivation.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTCPFirstActivation.swift index 136963a6df7f..827e605a78a4 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTCPFirstActivation.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTCPFirstActivation.swift @@ -4,6 +4,7 @@ /// `scheduleIroh` must enqueue asynchronous activation and return immediately. /// Keeping that boundary synchronous makes it impossible for a relay-policy or /// Keychain suspension to delay the existing TCP listener. +// lint:allow namespace-type - existing static helper surface, preserving call sites during UI closeout. public enum CmxIrohTCPFirstActivation { public static func start( startTCP: () -> Void, diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerCapabilities.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerCapabilities.swift new file mode 100644 index 000000000000..d6872f613594 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerCapabilities.swift @@ -0,0 +1,42 @@ +/// Backend capabilities that change which composer actions are truthful. +public struct ChatComposerCapabilities: Sendable, Equatable { + public let allowsTextSend: Bool + public let allowsAttachments: Bool + public let allowsInterrupt: Bool + public let allowsHardInterrupt: Bool + public let allowsOfflineSendQueue: Bool + + public init( + allowsTextSend: Bool = true, + allowsAttachments: Bool = true, + allowsInterrupt: Bool = true, + allowsHardInterrupt: Bool = true, + allowsOfflineSendQueue: Bool = false + ) { + self.allowsTextSend = allowsTextSend + self.allowsAttachments = allowsAttachments + self.allowsInterrupt = allowsInterrupt + self.allowsHardInterrupt = allowsHardInterrupt + self.allowsOfflineSendQueue = allowsOfflineSendQueue + } + + public static let all = ChatComposerCapabilities() + public static let textOnly = ChatComposerCapabilities( + allowsAttachments: false, + allowsOfflineSendQueue: true + ) + public static let readOnly = ChatComposerCapabilities( + allowsTextSend: false, + allowsAttachments: false, + allowsInterrupt: false, + allowsHardInterrupt: false, + allowsOfflineSendQueue: false + ) +} + +struct ChatComposerSendPolicy { + static func canSubmit(isConnected: Bool, capabilities: ChatComposerCapabilities) -> Bool { + capabilities.allowsTextSend + && (isConnected || capabilities.allowsOfflineSendQueue) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerTerminalFallbackRow.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerTerminalFallbackRow.swift new file mode 100644 index 000000000000..57ce1a5ee109 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerTerminalFallbackRow.swift @@ -0,0 +1,49 @@ +import SwiftUI + +struct ChatComposerTerminalFallbackRow: View { + enum Kind { + case ended + case readOnly + } + + let kind: Kind + let action: () -> Void + + var body: some View { + HStack(spacing: 12) { + Text(title) + .font(.subheadline) + .foregroundStyle(.secondary) + Spacer() + Button(action: action) { + Text( + String( + localized: "chat.composer.open_terminal", + defaultValue: "Open terminal", + bundle: .module + ) + ) + .font(.subheadline.weight(.medium)) + } + .buttonStyle(.bordered) + } + .padding(.vertical, 4) + } + + private var title: String { + switch kind { + case .ended: + String( + localized: "chat.composer.session_ended", + defaultValue: "Session ended", + bundle: .module + ) + case .readOnly: + String( + localized: "chat.composer.read_only", + defaultValue: "Read-only conversation", + bundle: .module + ) + } + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift index eb256c0299ce..c5ad3a8114c4 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift @@ -16,6 +16,7 @@ public struct ChatComposerView: View { private let isConnected: Bool private let accessoryLeadingShortcuts: [ChatAccessoryShortcut] private let accessoryShortcuts: [ChatAccessoryShortcut] + private let capabilities: ChatComposerCapabilities private let onSend: (String, [ChatOutboundAttachment]) -> Void private let onInterrupt: (Bool) -> Void private let onOpenTerminal: () -> Void @@ -48,6 +49,7 @@ public struct ChatComposerView: View { isConnected: Bool, accessoryLeadingShortcuts: [ChatAccessoryShortcut] = [], accessoryShortcuts: [ChatAccessoryShortcut] = [], + capabilities: ChatComposerCapabilities = .all, draft: Binding, onSend: @escaping (String, [ChatOutboundAttachment]) -> Void, onInterrupt: @escaping (Bool) -> Void, @@ -59,6 +61,7 @@ public struct ChatComposerView: View { self.isConnected = isConnected self.accessoryLeadingShortcuts = accessoryLeadingShortcuts self.accessoryShortcuts = accessoryShortcuts + self.capabilities = capabilities _draft = draft self.onSend = onSend self.onInterrupt = onInterrupt @@ -114,6 +117,8 @@ public struct ChatComposerView: View { VStack(spacing: 8) { if isEnded { endedRow + } else if !capabilities.allowsTextSend { + readOnlyRow } else { ChatAccessoryChipRow( agentState: agentState, @@ -123,7 +128,7 @@ public struct ChatComposerView: View { onOpenTerminal: onOpenTerminal ) #if os(iOS) - if !attachments.isEmpty { + if capabilities.allowsAttachments, !attachments.isEmpty { attachmentStrip } #endif @@ -170,7 +175,9 @@ public struct ChatComposerView: View { private var fieldRow: some View { HStack(alignment: .bottom, spacing: 8) { #if os(iOS) - attachButton + if capabilities.allowsAttachments { + attachButton + } micButton #endif MobileComposerFieldContainer { @@ -211,12 +218,16 @@ public struct ChatComposerView: View { private var hasContent: Bool { #if os(iOS) - return !trimmedDraft.isEmpty || !attachments.isEmpty + return !trimmedDraft.isEmpty || (capabilities.allowsAttachments && !attachments.isEmpty) #else return !trimmedDraft.isEmpty #endif } + private var canSubmit: Bool { + ChatComposerSendPolicy.canSubmit(isConnected: isConnected, capabilities: capabilities) + } + private var isWorking: Bool { if case .working = agentState { return true } return false @@ -227,30 +238,11 @@ public struct ChatComposerView: View { } private var endedRow: some View { - HStack(spacing: 12) { - Text( - String( - localized: "chat.composer.session_ended", - defaultValue: "Session ended", - bundle: .module - ) - ) - .font(.subheadline) - .foregroundStyle(.secondary) - Spacer() - Button(action: onOpenTerminal) { - Text( - String( - localized: "chat.composer.open_terminal", - defaultValue: "Open terminal", - bundle: .module - ) - ) - .font(.subheadline.weight(.medium)) - } - .buttonStyle(.bordered) - } - .padding(.vertical, 4) + ChatComposerTerminalFallbackRow(kind: .ended, action: onOpenTerminal) + } + + private var readOnlyRow: some View { + ChatComposerTerminalFallbackRow(kind: .readOnly, action: onOpenTerminal) } // MARK: - Send / stop button @@ -261,18 +253,18 @@ public struct ChatComposerView: View { Button(action: performSend) { Image(systemName: "arrow.up") .font(.system(size: 15, weight: .bold)) - .foregroundStyle(isConnected ? Color.white : Color.secondary.opacity(0.35)) + .foregroundStyle(canSubmit ? Color.white : Color.secondary.opacity(0.35)) .frame(width: sendButtonSize - 8, height: sendButtonSize - 8) .background( Circle().fill( - isConnected + canSubmit ? AnyShapeStyle(theme.accent) : AnyShapeStyle(Color.secondary.opacity(0.12)) ) ) } .buttonStyle(.plain) - .disabled(!isConnected || isStagingAttachments) + .disabled(!canSubmit || isStagingAttachments) .accessibilityIdentifier("ChatComposerSend") .accessibilityLabel( String( @@ -281,7 +273,7 @@ public struct ChatComposerView: View { bundle: .module ) ) - } else if isWorking { + } else if isWorking, capabilities.allowsInterrupt { Button(action: performStop) { ZStack { Circle() @@ -321,10 +313,10 @@ public struct ChatComposerView: View { } private func performSend() { - guard hasContent, !isStagingAttachments else { return } + guard hasContent, canSubmit, !isStagingAttachments else { return } #if os(iOS) dictation.cancel() - let outbound = attachments.map(\.outbound) + let outbound = capabilities.allowsAttachments ? attachments.map(\.outbound) : [] MobileHapticFeedback().impact(style: .light) #else let outbound: [ChatOutboundAttachment] = [] @@ -342,7 +334,9 @@ public struct ChatComposerView: View { MobileHapticFeedback().impact(style: .rigid) #endif let now = Date() - if let last = lastStopTap, now.timeIntervalSince(last) < Self.hardStopWindow { + if capabilities.allowsHardInterrupt, + let last = lastStopTap, + now.timeIntervalSince(last) < Self.hardStopWindow { onInterrupt(true) } else { onInterrupt(false) @@ -360,7 +354,8 @@ public struct ChatComposerView: View { private func performPaste() { let pasteboard = UIPasteboard.general - if attachments.count < 4, + if capabilities.allowsAttachments, + attachments.count < 4, let attachment = pasteboard.chatComposerAttachment( maxDimension: Self.maxAttachmentDimension, jpegQuality: Self.jpegQuality diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings index 0242e66e0517..bea5ab6a3e79 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings @@ -936,6 +936,23 @@ } } }, + "chat.attachment.file": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "File" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイル" + } + } + } + }, "chat.attachment.image": { "extractionState": "manual", "localizations": { @@ -953,6 +970,23 @@ } } }, + "chat.attachment.open_preview_hint": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Opens the full preview" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完全なプレビューを開きます" + } + } + } + }, "chat.bubble.copy": { "extractionState": "manual", "localizations": { @@ -1157,6 +1191,23 @@ } } }, + "chat.composer.read_only": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read-only conversation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "読み取り専用の会話" + } + } + } + }, "chat.composer.remove_attachment.accessibility": { "extractionState": "manual", "localizations": { diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ConversationKeyboardContainer.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ConversationKeyboardContainer.swift new file mode 100644 index 000000000000..34d8b899315d --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ConversationKeyboardContainer.swift @@ -0,0 +1,35 @@ +import SwiftUI + +/// Public façade for the single-owner transcript/composer keyboard geometry. +public struct ConversationKeyboardContainer: View { + private let transcript: Transcript + private let composer: Composer + private let showsComposer: Bool + + public init( + showsComposer: Bool = true, + @ViewBuilder transcript: () -> Transcript, + @ViewBuilder composer: () -> Composer + ) { + self.transcript = transcript() + self.composer = composer() + self.showsComposer = showsComposer + } + + public var body: some View { + #if os(iOS) + ChatKeyboardTrackingContainer( + transcript: transcript, + composer: composer, + showsComposer: showsComposer + ) + #else + VStack(spacing: 0) { + transcript + if showsComposer { + composer + } + } + #endif + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/KeyboardDismissTap.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/KeyboardDismissTap.swift index 6958a062dad2..a7f1b3c8ccd8 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/KeyboardDismissTap.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/KeyboardDismissTap.swift @@ -150,7 +150,7 @@ extension View { /// The scroll button extends its hit area 3pt beyond its frame /// (`contentShape(Circle().inset(by: -3))`); match that here so a tap in /// the halo scrolls without also dismissing the keyboard. - func excludedFromKeyboardDismiss() -> some View { + public func excludedFromKeyboardDismiss() -> some View { background( GeometryReader { proxy in Color.clear.preference( diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatCodeBlockDetailSheet.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatCodeBlockDetailSheet.swift new file mode 100644 index 000000000000..7939f5a6b586 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatCodeBlockDetailSheet.swift @@ -0,0 +1,24 @@ +import SwiftUI + +/// Out-of-flow detail presentation for a fenced prose code block. +public struct ChatCodeBlockDetailSheet: View { + private let id: String + private let code: String + private let language: String? + + public init(id: String, code: String, language: String?) { + self.id = id + self.code = code + self.language = language + } + + public var body: some View { + ChatBlockDetailSheetView( + detail: ChatBlockDetailBuilder().codeBlock( + id: id, + code: code, + language: language + ) + ) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatMessageRowView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatMessageRowView.swift index 0bf7a728f845..0808450552a0 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatMessageRowView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatMessageRowView.swift @@ -69,6 +69,7 @@ public struct ChatMessageRowView: View { case .attachment(let attachment): ChatAttachmentBubbleView( attachment: attachment, + role: snapshot.message.role, groupPosition: snapshot.groupPosition, showsTimestamp: snapshot.showsTimestamp, timestamp: snapshot.message.timestamp, diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptOverlayGeometry.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptOverlayGeometry.swift index 104ceb8c5a34..664162905b22 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptOverlayGeometry.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptOverlayGeometry.swift @@ -4,7 +4,7 @@ import Observation @MainActor @Observable -final class ChatTranscriptOverlayGeometry { - var composerBottomInset: CGFloat = 0 +public final class ChatTranscriptOverlayGeometry { + public internal(set) var composerBottomInset: CGFloat = 0 } #endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptOverlayGeometryKey.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptOverlayGeometryKey.swift index 68b6fdb2365f..8df6485105b6 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptOverlayGeometryKey.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptOverlayGeometryKey.swift @@ -6,7 +6,7 @@ private struct ChatTranscriptOverlayGeometryKey: EnvironmentKey { } extension EnvironmentValues { - var chatTranscriptOverlayGeometry: ChatTranscriptOverlayGeometry? { + public var chatTranscriptOverlayGeometry: ChatTranscriptOverlayGeometry? { get { self[ChatTranscriptOverlayGeometryKey.self] } set { self[ChatTranscriptOverlayGeometryKey.self] = newValue } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift index 52804e5e8cd2..83f1abc30a4a 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift @@ -170,6 +170,7 @@ struct ChatTranscriptTableView: UIViewRepresentable { let tableWidth = ChatContainerWidth(tableView: tableView).effectiveWidth cell.contentConfiguration = UIHostingConfiguration { configuration.view(for: item, tableWidth: tableWidth) + .id(item.id) } .margins(.all, 0) return cell @@ -246,7 +247,7 @@ struct ChatTranscriptTableView: UIViewRepresentable { } private func restore(_ anchor: ChatTranscriptTableAnchor, in tableView: UITableView) { - guard let row = items.firstIndex(where: { $0.id == anchor.id }) else { return } + guard let row = items.firstIndex(where: { AnyHashable($0.id) == anchor.id }) else { return } let indexPath = IndexPath(row: row, section: 0) let rect = tableView.rectForRow(at: indexPath) let offset = CGPoint( diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift index 35f9971a0627..5e0245a5536b 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift @@ -315,8 +315,8 @@ final class ChatTranscriptUITableView: UITableView { #endif } -struct ChatTranscriptTableAnchor { - let id: String +struct ChatTranscriptTableAnchor: @unchecked Sendable { + let id: AnyHashable let offsetFromRowTop: CGFloat } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ConversationTailSettlePolicy.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ConversationTailSettlePolicy.swift new file mode 100644 index 000000000000..8d0f61114fb3 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ConversationTailSettlePolicy.swift @@ -0,0 +1,52 @@ +struct ConversationTailSettlePolicy: Equatable, Sendable { + struct State: Equatable, Sendable { + private let requiredBottomConfirmations: Int + private var remainingPasses: Int + private var bottomConfirmations = 0 + + init(maximumPasses: Int, requiredBottomConfirmations: Int) { + remainingPasses = max(0, maximumPasses) + self.requiredBottomConfirmations = max(1, requiredBottomConfirmations) + } + + mutating func observe(isAtBottom: Bool) -> Decision { + if isAtBottom { + bottomConfirmations += 1 + } else { + bottomConfirmations = 0 + } + + if bottomConfirmations >= requiredBottomConfirmations { + return .finishedAtBottom + } + + guard remainingPasses > 0 else { + return isAtBottom ? .finishedAtBottom : .expiredAwayFromBottom + } + + remainingPasses -= 1 + return .continueSettling + } + } + + enum Decision: Equatable, Sendable { + case continueSettling + case finishedAtBottom + case expiredAwayFromBottom + } + + private let maximumPasses: Int + private let requiredBottomConfirmations: Int + + init(maximumPasses: Int = 32, requiredBottomConfirmations: Int = 2) { + self.maximumPasses = maximumPasses + self.requiredBottomConfirmations = requiredBottomConfirmations + } + + func makeState() -> State { + State( + maximumPasses: maximumPasses, + requiredBottomConfirmations: requiredBottomConfirmations + ) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/NativeConversationTranscript+UIKit.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/NativeConversationTranscript+UIKit.swift new file mode 100644 index 000000000000..223fecf6ae1e --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/NativeConversationTranscript+UIKit.swift @@ -0,0 +1,684 @@ +#if os(iOS) +import CmuxMobileSupport +import SwiftUI +import UIKit + +struct NativeConversationTableRepresentable: UIViewRepresentable +where Row: Identifiable & Equatable, Row.ID: Hashable & Sendable, RowContent: View { + let rows: [Row] + let hasMoreBefore: Bool + let hasMoreAfter: Bool + let followState: Binding> + let command: ConversationScrollCommand? + let renderGeneration: Int + let isActive: Bool + let beforePageID: String? + let afterPageID: String? + let prefetchResetGeneration: Int + let onLoadBefore: () -> Void + let onLoadAfter: () -> Void + let onSemanticHead: () -> Void + let onSemanticTail: () -> Void + let rowContent: (Row) -> RowContent + + func makeCoordinator() -> Coordinator { + Coordinator(followState: followState) + } + + func makeUIView(context: Context) -> ChatTranscriptUITableView { + let tableView = ChatTranscriptUITableView(frame: .zero, style: .plain) + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.keyboardDismissMode = .interactive + tableView.contentInsetAdjustmentBehavior = .never + tableView.estimatedRowHeight = 96 + tableView.rowHeight = UITableView.automaticDimension + tableView.allowsSelection = false + tableView.accessibilityIdentifier = "NativeConversationTranscript" + tableView.delegate = context.coordinator + context.coordinator.attach(to: tableView) + return tableView + } + + func updateUIView(_ tableView: ChatTranscriptUITableView, context: Context) { + context.coordinator.update( + rows: rows, + hasMoreBefore: hasMoreBefore, + hasMoreAfter: hasMoreAfter, + command: command, + renderGeneration: renderGeneration, + isActive: isActive, + beforePageID: beforePageID, + afterPageID: afterPageID, + prefetchResetGeneration: prefetchResetGeneration, + onLoadBefore: onLoadBefore, + onLoadAfter: onLoadAfter, + onSemanticHead: onSemanticHead, + onSemanticTail: onSemanticTail, + rowContent: rowContent, + in: tableView + ) + } + + @MainActor + final class Coordinator: NSObject, UITableViewDelegate { + private var rowsByID: [Row.ID: Row] = [:] + private var orderedIDs: [Row.ID] = [] + private var dataSource: UITableViewDiffableDataSource? + private var rowContent: ((Row) -> RowContent)? + private var followState: Binding> + private var hasMoreBefore = false + private var hasMoreAfter = false + private var onLoadBefore: () -> Void = {} + private var onLoadAfter: () -> Void = {} + private var onSemanticHead: () -> Void = {} + private var onSemanticTail: () -> Void = {} + private var lastCommandGeneration: Int? + private var pendingSemanticCommand: PendingSemanticCommand? + private var prefetchGate = ConversationPrefetchGate>() + private let tailSettlePolicy = ConversationTailSettlePolicy() + private var beforePageID: String? + private var afterPageID: String? + private var prefetchResetGeneration = 0 + private var isApplyingUpdate = false + private var isHandlingLayout = false + private var pendingContentUpdateAnchor: Anchor? + private var pendingUpdate: (() -> Void)? + private var lastRenderGeneration: Int? + private var pendingSemanticScrollTarget: ConversationScrollTarget? + private var tailSettleGeneration = 0 + private var activeTailSettleGeneration: Int? + private var tailSettleState: ConversationTailSettlePolicy.State? + private var tailSettleTask: Task? + + init(followState: Binding>) { + self.followState = followState + } + + deinit { + tailSettleTask?.cancel() + } + + func attach(to tableView: ChatTranscriptUITableView) { + dataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, _, rowID in + guard let self, + let row = self.rowsByID[rowID], + let rowContent = self.rowContent + else { return nil } + let cell = tableView.dequeueReusableCell(withIdentifier: "NativeConversationCell") + ?? UITableViewCell(style: .default, reuseIdentifier: "NativeConversationCell") + cell.backgroundColor = .clear + cell.contentView.backgroundColor = .clear + cell.selectionStyle = .none + cell.contentConfiguration = UIHostingConfiguration { + rowContent(row) + .id(row.id) + } + .margins(.all, 0) + return cell + } + tableView.anchorBeforeLayout = { [weak self, weak tableView] in + guard let self, let tableView, let anchor = self.firstVisibleAnchor(in: tableView) else { return nil } + return ChatTranscriptTableAnchor(id: AnyHashable(anchor.id), offsetFromRowTop: anchor.offset) + } + tableView.afterLayout = { [weak self, weak tableView] oldBounds, oldContent, oldViewport, oldAnchor in + guard let self, let tableView else { return } + self.handleLayoutChange( + in: tableView, + oldBoundsSize: oldBounds, + oldContentSize: oldContent, + oldViewport: oldViewport, + oldAnchor: oldAnchor + ) + } + } + + func update( + rows: [Row], + hasMoreBefore: Bool, + hasMoreAfter: Bool, + command: ConversationScrollCommand?, + renderGeneration: Int, + isActive: Bool, + beforePageID: String?, + afterPageID: String?, + prefetchResetGeneration: Int, + onLoadBefore: @escaping () -> Void, + onLoadAfter: @escaping () -> Void, + onSemanticHead: @escaping () -> Void, + onSemanticTail: @escaping () -> Void, + rowContent: @escaping (Row) -> RowContent, + in tableView: ChatTranscriptUITableView + ) { + if isApplyingUpdate { + pendingUpdate = { [weak self, weak tableView] in + guard let self, let tableView else { return } + self.update( + rows: rows, + hasMoreBefore: hasMoreBefore, + hasMoreAfter: hasMoreAfter, + command: command, + renderGeneration: renderGeneration, + isActive: isActive, + beforePageID: beforePageID, + afterPageID: afterPageID, + prefetchResetGeneration: prefetchResetGeneration, + onLoadBefore: onLoadBefore, + onLoadAfter: onLoadAfter, + onSemanticHead: onSemanticHead, + onSemanticTail: onSemanticTail, + rowContent: rowContent, + in: tableView + ) + } + return + } + self.hasMoreBefore = hasMoreBefore + self.hasMoreAfter = hasMoreAfter + self.onLoadBefore = onLoadBefore + self.onLoadAfter = onLoadAfter + self.onSemanticHead = onSemanticHead + self.onSemanticTail = onSemanticTail + self.rowContent = rowContent + tableView.scrollsToTop = isActive + + let pageBoundaryChanged = self.beforePageID != beforePageID + || self.afterPageID != afterPageID + let prefetchWasReset = self.prefetchResetGeneration != prefetchResetGeneration + self.beforePageID = beforePageID + self.afterPageID = afterPageID + self.prefetchResetGeneration = prefetchResetGeneration + if prefetchWasReset { + prefetchGate.reset() + } + + let priorRows = rowsByID + let priorIDs = orderedIDs + let anchor = firstVisibleAnchor(in: tableView) + let nextRowsByID = Dictionary(uniqueKeysWithValues: rows.map { ($0.id, $0) }) + let nextIDs = rows.map(\.id) + if priorRows == nextRowsByID, + priorIDs == nextIDs, + lastRenderGeneration == renderGeneration { + apply(command, in: tableView) + if pageBoundaryChanged, !prefetchWasReset { + evaluatePrefetch(in: tableView) + } + return + } + let renderGenerationChanged = lastRenderGeneration != renderGeneration + lastRenderGeneration = renderGeneration + rowsByID = nextRowsByID + orderedIDs = nextIDs + let changedIDs = rows.compactMap { row in + priorRows[row.id].map { $0 == row ? nil : row.id } ?? nil + } + let appendedCount = ConversationAppendDelta.count(previous: priorIDs, current: orderedIDs) + + if case .detached = followState.wrappedValue, appendedCount > 0 { + incrementUnseenCount(by: appendedCount, anchor: anchor) + } + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems(orderedIDs) + let reconfigurable = renderGenerationChanged + ? orderedIDs.filter { priorRows[$0] != nil } + : changedIDs.filter { priorRows[$0] != nil } + if !reconfigurable.isEmpty { + snapshot.reconfigureItems(reconfigurable) + } + + isApplyingUpdate = true + dataSource?.apply(snapshot, animatingDifferences: false) { [weak self, weak tableView] in + guard let self, let tableView else { return } + tableView.layoutIfNeeded() + let invalidatedVisibleHeights = self.invalidateVisibleHeightsIfNeeded( + reconfiguredIDs: reconfigurable, + in: tableView + ) + if invalidatedVisibleHeights { + tableView.layoutIfNeeded() + } + if self.isFollowingTail, !tableView.isUserScrollMomentumActive { + self.scrollToTail(in: tableView, animated: false) + } else if self.isTailSettling, !tableView.isUserScrollMomentumActive { + self.continueTailSettle(in: tableView) + } else if let anchor, !tableView.isUserScrollMomentumActive { + self.restore(anchor, in: tableView) + self.pendingContentUpdateAnchor = anchor + } + self.isApplyingUpdate = false + self.apply(command, in: tableView) + if pageBoundaryChanged, !prefetchWasReset { + self.evaluatePrefetch(in: tableView) + } + let pendingUpdate = self.pendingUpdate + self.pendingUpdate = nil + pendingUpdate?() + } + if dataSource == nil { + isApplyingUpdate = false + } + apply(command, in: tableView) + } + + private func invalidateVisibleHeightsIfNeeded( + reconfiguredIDs: [Row.ID], + in tableView: UITableView + ) -> Bool { + guard !reconfiguredIDs.isEmpty, + let visibleRows = tableView.indexPathsForVisibleRows, + !visibleRows.isEmpty + else { return false } + let reconfiguredIDSet = Set(reconfiguredIDs) + let hasVisibleReconfiguredRow = visibleRows.contains { indexPath in + orderedIDs.indices.contains(indexPath.row) + && reconfiguredIDSet.contains(orderedIDs[indexPath.row]) + } + guard hasVisibleReconfiguredRow else { return false } + + UIView.performWithoutAnimation { + tableView.beginUpdates() + tableView.endUpdates() + tableView.layoutIfNeeded() + } + (tableView as? ChatTranscriptUITableView)?.recordCurrentViewport() + return true + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + guard let tableView = scrollView as? UITableView else { return } + pendingSemanticScrollTarget = nil + cancelTailSettle() + let anchor = firstVisibleAnchor(in: tableView) + followState.wrappedValue = .detached( + anchorID: anchor?.id, + offset: anchor?.offset ?? 0, + unseenCount: currentUnseenCount + ) + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard !isApplyingUpdate, let tableView = scrollView as? UITableView else { return } + evaluatePrefetch(in: tableView) + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + guard !decelerate, let tableView = scrollView as? UITableView else { return } + attachToTailIfReached(in: tableView) + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + guard let tableView = scrollView as? UITableView else { return } + attachToTailIfReached(in: tableView) + } + + private func evaluatePrefetch(in tableView: UITableView) { + guard !isApplyingUpdate else { return } + let firstBoundary = beforePageID.map(ConversationPrefetchBoundary.page) + ?? orderedIDs.first.map(ConversationPrefetchBoundary.row) + let lastBoundary = afterPageID.map(ConversationPrefetchBoundary.page) + ?? orderedIDs.last.map(ConversationPrefetchBoundary.row) + if prefetchGate.shouldLoadBefore( + hasMore: hasMoreBefore, + distance: tableView.contentOffset.y + tableView.adjustedContentInset.top, + firstID: firstBoundary + ) { + onLoadBefore() + } + if prefetchGate.shouldLoadAfter( + hasMore: hasMoreAfter, + distance: distanceFromTail(in: tableView), + lastID: lastBoundary + ) { + onLoadAfter() + } + } + + private func attachToTailIfReached(in tableView: UITableView) { + guard tailIsVisiblyReached(in: tableView) else { return } + followState.wrappedValue = .followingTail + } + + func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { + cancelTailSettle() + pendingSemanticScrollTarget = nil + followState.wrappedValue = .jumpingToHead + guard !hasMoreBefore else { + onSemanticHead() + return false + } + return true + } + + func scrollViewDidScrollToTop(_ scrollView: UIScrollView) { + guard let first = orderedIDs.first else { return } + followState.wrappedValue = .detached(anchorID: first, offset: 0, unseenCount: currentUnseenCount) + } + + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + guard let tableView = scrollView as? UITableView, + let target = pendingSemanticScrollTarget + else { return } + pendingSemanticScrollTarget = nil + switch target { + case .head: + guard let first = orderedIDs.first else { return } + followState.wrappedValue = .detached( + anchorID: first, + offset: 0, + unseenCount: currentUnseenCount + ) + case .tail: + attachToTailIfReached(in: tableView) + if !isFollowingTail { + scrollToTail(in: tableView, animated: false) + } + } + (tableView as? ChatTranscriptUITableView)?.recordCurrentViewport() + } + + private var isFollowingTail: Bool { + if case .followingTail = followState.wrappedValue { return true } + return false + } + + private var isTailSettling: Bool { + activeTailSettleGeneration != nil + } + + private var currentUnseenCount: Int { + if case .detached(_, _, let unseenCount) = followState.wrappedValue { + return unseenCount + } + return 0 + } + + private func incrementUnseenCount(by count: Int, anchor: Anchor?) { + let oldCount = currentUnseenCount + followState.wrappedValue = .detached( + anchorID: anchor?.id, + offset: anchor?.offset ?? 0, + unseenCount: oldCount + count + ) + } + + private func detachAtCurrentViewport(in tableView: UITableView) { + let anchor = firstVisibleAnchor(in: tableView) + followState.wrappedValue = .detached( + anchorID: anchor?.id, + offset: anchor?.offset ?? 0, + unseenCount: currentUnseenCount + ) + } + + private func apply(_ command: ConversationScrollCommand?, in tableView: UITableView) { + guard !isApplyingUpdate, + let command, + command.generation != lastCommandGeneration + else { return } + switch command.target { + case .head: + cancelTailSettle() + pendingSemanticScrollTarget = nil + guard !hasMoreBefore else { + followState.wrappedValue = .jumpingToHead + if shouldRequestSemanticLoad(for: command) { + onSemanticHead() + } + return + } + pendingSemanticCommand = nil + lastCommandGeneration = command.generation + followState.wrappedValue = .jumpingToHead + scrollToHead(in: tableView, animated: command.animated) + case .tail: + guard !hasMoreAfter else { + followState.wrappedValue = .jumpingToTail + if shouldRequestSemanticLoad(for: command) { + onSemanticTail() + } + return + } + pendingSemanticCommand = nil + lastCommandGeneration = command.generation + scrollToTail(in: tableView, animated: command.animated) + } + } + + private func shouldRequestSemanticLoad(for command: ConversationScrollCommand) -> Bool { + let nextCommand = PendingSemanticCommand( + generation: command.generation, + target: command.target, + boundary: semanticBoundary(for: command.target) + ) + guard pendingSemanticCommand != nextCommand else { return false } + pendingSemanticCommand = nextCommand + return true + } + + private func semanticBoundary( + for target: ConversationScrollTarget + ) -> ConversationPrefetchBoundary? { + switch target { + case .head: + beforePageID.map(ConversationPrefetchBoundary.page) + ?? orderedIDs.first.map(ConversationPrefetchBoundary.row) + case .tail: + afterPageID.map(ConversationPrefetchBoundary.page) + ?? orderedIDs.last.map(ConversationPrefetchBoundary.row) + } + } + + private func scrollToHead(in tableView: UITableView, animated: Bool) { + guard !orderedIDs.isEmpty else { return } + pendingSemanticScrollTarget = animated ? .head : nil + tableView.setContentOffset( + CGPoint(x: tableView.contentOffset.x, y: -tableView.adjustedContentInset.top), + animated: animated + ) + if let first = orderedIDs.first { + followState.wrappedValue = .detached( + anchorID: first, + offset: 0, + unseenCount: currentUnseenCount + ) + } + } + + private func scrollToTail(in tableView: UITableView, animated: Bool) { + guard !orderedIDs.isEmpty else { return } + tailSettleTask?.cancel() + tailSettleGeneration += 1 + activeTailSettleGeneration = tailSettleGeneration + tailSettleState = tailSettlePolicy.makeState() + pendingSemanticScrollTarget = animated ? .tail : nil + settleTail( + in: tableView, + generation: tailSettleGeneration, + animated: animated + ) + (tableView as? ChatTranscriptUITableView)?.recordCurrentViewport() + } + + private func cancelTailSettle() { + tailSettleTask?.cancel() + tailSettleTask = nil + tailSettleGeneration += 1 + activeTailSettleGeneration = nil + tailSettleState = nil + if pendingSemanticScrollTarget == .tail { + pendingSemanticScrollTarget = nil + } + } + + private func continueTailSettle(in tableView: UITableView) { + guard let generation = activeTailSettleGeneration else { return } + settleTail(in: tableView, generation: generation, animated: false) + } + + private func settleTail( + in tableView: UITableView, + generation: Int, + animated: Bool + ) { + guard generation == tailSettleGeneration, + generation == activeTailSettleGeneration, + !orderedIDs.isEmpty + else { return } + tableView.layoutIfNeeded() + let lastIndexPath = IndexPath(row: orderedIDs.count - 1, section: 0) + if tableView.numberOfRows(inSection: 0) > lastIndexPath.row { + tableView.scrollToRow(at: lastIndexPath, at: .bottom, animated: animated) + tableView.layoutIfNeeded() + } + tableView.setContentOffset( + CGPoint(x: tableView.contentOffset.x, y: maxOffsetY(in: tableView)), + animated: animated + ) + (tableView as? ChatTranscriptUITableView)?.recordCurrentViewport() + + guard var tailSettleState else { return } + switch tailSettleState.observe(isAtBottom: tailIsVisiblyReached(in: tableView)) { + case .finishedAtBottom: + finishTailSettle(atBottom: true, in: tableView) + case .expiredAwayFromBottom: + finishTailSettle(atBottom: false, in: tableView) + case .continueSettling: + self.tailSettleState = tailSettleState + scheduleTailSettle(in: tableView, generation: generation) + } + } + + private func finishTailSettle(atBottom: Bool, in tableView: UITableView) { + tailSettleTask?.cancel() + tailSettleTask = nil + activeTailSettleGeneration = nil + tailSettleState = nil + pendingSemanticScrollTarget = nil + if atBottom { + followState.wrappedValue = .followingTail + } else { + detachAtCurrentViewport(in: tableView) + } + } + + private func scheduleTailSettle( + in tableView: UITableView, + generation: Int + ) { + tailSettleTask?.cancel() + tailSettleTask = Task { @MainActor [weak self, weak tableView] in + await Task.yield() + guard let self, let tableView else { return } + guard !Task.isCancelled, + generation == self.activeTailSettleGeneration + else { return } + self.tailSettleTask = nil + self.settleTail( + in: tableView, + generation: generation, + animated: false + ) + } + } + + private func firstVisibleAnchor(in tableView: UITableView) -> Anchor? { + guard let indexPath = tableView.indexPathsForVisibleRows?.min(), + orderedIDs.indices.contains(indexPath.row) + else { return nil } + let rowID = orderedIDs[indexPath.row] + let offset = tableView.contentOffset.y - tableView.rectForRow(at: indexPath).minY + return Anchor(id: rowID, offset: offset) + } + + private func restore(_ anchor: Anchor, in tableView: UITableView) { + guard let row = orderedIDs.firstIndex(of: anchor.id) else { return } + let rowTop = tableView.rectForRow(at: IndexPath(row: row, section: 0)).minY + let desired = rowTop + anchor.offset + let clamped = min(max(desired, -tableView.adjustedContentInset.top), maxOffsetY(in: tableView)) + tableView.setContentOffset(CGPoint(x: tableView.contentOffset.x, y: clamped), animated: false) + } + + private func distanceFromTail(in tableView: UITableView) -> CGFloat { + max(0, maxOffsetY(in: tableView) - tableView.contentOffset.y) + } + + private func tailIsVisiblyReached(in tableView: UITableView) -> Bool { + guard !orderedIDs.isEmpty else { return true } + let lastIndexPath = IndexPath(row: orderedIDs.count - 1, section: 0) + guard tableView.numberOfRows(inSection: 0) > lastIndexPath.row else { return false } + let visibleRows = tableView.indexPathsForVisibleRows ?? [] + guard visibleRows.contains(lastIndexPath) else { return false } + let visibleBottomY = tableView.contentOffset.y + + tableView.bounds.height + - tableView.adjustedContentInset.bottom + let lastRowBottomY = tableView.rectForRow(at: lastIndexPath).maxY + return distanceFromTail(in: tableView) <= chatTranscriptAtBottomThreshold + && lastRowBottomY <= visibleBottomY + chatTranscriptAtBottomThreshold + } + + private func maxOffsetY(in tableView: UITableView) -> CGFloat { + ConversationTailGeometry.maximumOffset( + contentHeight: tableView.contentSize.height, + viewportHeight: tableView.bounds.height, + topInset: tableView.adjustedContentInset.top, + bottomInset: tableView.adjustedContentInset.bottom + ) + } + + private func handleLayoutChange( + in tableView: ChatTranscriptUITableView, + oldBoundsSize: CGSize, + oldContentSize: CGSize, + oldViewport: MobileScrollViewportSnapshot?, + oldAnchor: ChatTranscriptTableAnchor? + ) { + guard !isHandlingLayout else { return } + let heightChanged = abs(oldBoundsSize.height - tableView.bounds.height) > 0.5 + let widthChanged = abs(oldBoundsSize.width - tableView.bounds.width) > 0.5 + let contentChanged = abs(oldContentSize.height - tableView.contentSize.height) > 0.5 + guard heightChanged || widthChanged || contentChanged else { return } + guard !tableView.isUserScrollMomentumActive, + !tableView.isViewportInsetsExternallyDriven, + !isApplyingUpdate + else { return } + + isHandlingLayout = true + defer { isHandlingLayout = false } + if isTailSettling { + continueTailSettle(in: tableView) + } else if widthChanged, + !isFollowingTail, + let oldAnchor, + let id = oldAnchor.id.base as? Row.ID { + restore(Anchor(id: id, offset: oldAnchor.offsetFromRowTop), in: tableView) + } else if heightChanged, let oldViewport { + tableView.restoreKeyboardViewport(oldViewport) + } else if isFollowingTail || oldViewport?.wasAtBottom == true { + scrollToTail(in: tableView, animated: false) + } else if contentChanged, let pendingContentUpdateAnchor { + restore(pendingContentUpdateAnchor, in: tableView) + self.pendingContentUpdateAnchor = nil + } else if contentChanged, + let oldAnchor, + let id = oldAnchor.id.base as? Row.ID { + restore(Anchor(id: id, offset: oldAnchor.offsetFromRowTop), in: tableView) + } + evaluatePrefetch(in: tableView) + } + + private struct Anchor { + let id: Row.ID + let offset: CGFloat + } + + private struct PendingSemanticCommand: Equatable { + let generation: Int + let target: ConversationScrollTarget + let boundary: ConversationPrefetchBoundary? + } + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/NativeConversationTranscript.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/NativeConversationTranscript.swift new file mode 100644 index 000000000000..f0260bbdcebf --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/NativeConversationTranscript.swift @@ -0,0 +1,188 @@ +import SwiftUI + +/// Stable transcript attachment state. Appends follow the tail only while +/// `followingTail`; every other state preserves the visible row anchor. +public enum ConversationFollowState: Equatable { + case followingTail + case detached(anchorID: ID?, offset: CGFloat, unseenCount: Int) + case jumpingToHead + case jumpingToTail +} + +public enum ConversationScrollTarget: Equatable, Sendable { + case head + case tail +} + +/// A generation-keyed semantic scroll request. +public struct ConversationScrollCommand: Equatable, Sendable { + public let generation: Int + public let target: ConversationScrollTarget + public let animated: Bool + + public init(generation: Int, target: ConversationScrollTarget, animated: Bool = true) { + self.generation = generation + self.target = target + self.animated = animated + } +} + +struct ConversationPrefetchGate { + private(set) var beforeKey: ID? + private(set) var afterKey: ID? + + mutating func shouldLoadBefore(hasMore: Bool, distance: CGFloat, firstID: ID?) -> Bool { + guard hasMore else { + beforeKey = nil + return false + } + guard distance <= 160, beforeKey != firstID else { return false } + beforeKey = firstID + return true + } + + mutating func shouldLoadAfter(hasMore: Bool, distance: CGFloat, lastID: ID?) -> Bool { + guard hasMore else { + afterKey = nil + return false + } + guard distance <= 160, afterKey != lastID else { return false } + afterKey = lastID + return true + } + + mutating func reset() { + beforeKey = nil + afterKey = nil + } +} + +enum ConversationPrefetchBoundary: Hashable { + case row(ID) + case page(String) +} + +struct ConversationAppendDelta { + static func count(previous: [ID], current: [ID]) -> Int { + guard !previous.isEmpty else { return 0 } + let currentIDs = Set(current) + guard let commonID = previous.reversed().first(where: currentIDs.contains), + let previousIndex = previous.lastIndex(of: commonID), + let currentIndex = current.lastIndex(of: commonID) + else { return 0 } + let removedSuffixCount = previous.distance(from: previous.index(after: previousIndex), to: previous.endIndex) + let newSuffixCount = current.distance(from: current.index(after: currentIndex), to: current.endIndex) + return max(0, newSuffixCount - removedSuffixCount) + } +} + +struct ConversationTailGeometry { + static func maximumOffset( + contentHeight: CGFloat, + viewportHeight: CGFloat, + topInset: CGFloat, + bottomInset: CGFloat + ) -> CGFloat { + max(-topInset, contentHeight - viewportHeight + bottomInset) + } + + static func distance( + contentOffset: CGFloat, + contentHeight: CGFloat, + viewportHeight: CGFloat, + topInset: CGFloat, + bottomInset: CGFloat + ) -> CGFloat { + max(0, maximumOffset( + contentHeight: contentHeight, + viewportHeight: viewportHeight, + topInset: topInset, + bottomInset: bottomInset + ) - contentOffset) + } +} + +/// Chronological, self-sizing native transcript optimized for large windows. +/// Rows keep stable identity and UIKit creates only visible hosting cells. +public struct NativeConversationTranscript: View +where Row: Identifiable & Equatable, Row.ID: Hashable & Sendable, RowContent: View { + private let rows: [Row] + private let hasMoreBefore: Bool + private let hasMoreAfter: Bool + private let followState: Binding> + private let command: ConversationScrollCommand? + private let renderGeneration: Int + private let isActive: Bool + private let beforePageID: String? + private let afterPageID: String? + private let prefetchResetGeneration: Int + private let onLoadBefore: () -> Void + private let onLoadAfter: () -> Void + private let onSemanticHead: () -> Void + private let onSemanticTail: () -> Void + private let rowContent: (Row) -> RowContent + + public init( + rows: [Row], + hasMoreBefore: Bool, + hasMoreAfter: Bool = false, + followState: Binding>, + command: ConversationScrollCommand? = nil, + renderGeneration: Int = 0, + isActive: Bool = true, + beforePageID: String? = nil, + afterPageID: String? = nil, + prefetchResetGeneration: Int = 0, + onLoadBefore: @escaping () -> Void = {}, + onLoadAfter: @escaping () -> Void = {}, + onSemanticHead: @escaping () -> Void = {}, + onSemanticTail: @escaping () -> Void = {}, + @ViewBuilder rowContent: @escaping (Row) -> RowContent + ) { + self.rows = rows + self.hasMoreBefore = hasMoreBefore + self.hasMoreAfter = hasMoreAfter + self.followState = followState + self.command = command + self.renderGeneration = renderGeneration + self.isActive = isActive + self.beforePageID = beforePageID + self.afterPageID = afterPageID + self.prefetchResetGeneration = prefetchResetGeneration + self.onLoadBefore = onLoadBefore + self.onLoadAfter = onLoadAfter + self.onSemanticHead = onSemanticHead + self.onSemanticTail = onSemanticTail + self.rowContent = rowContent + } + + public var body: some View { + #if os(iOS) + NativeConversationTableRepresentable( + rows: rows, + hasMoreBefore: hasMoreBefore, + hasMoreAfter: hasMoreAfter, + followState: followState, + command: command, + renderGeneration: renderGeneration, + isActive: isActive, + beforePageID: beforePageID, + afterPageID: afterPageID, + prefetchResetGeneration: prefetchResetGeneration, + onLoadBefore: onLoadBefore, + onLoadAfter: onLoadAfter, + onSemanticHead: onSemanticHead, + onSemanticTail: onSemanticTail, + rowContent: rowContent + ) + #else + ScrollView { + LazyVStack(spacing: 0) { + ForEach(rows) { row in + rowContent(row) + } + } + } + #endif + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatAttachmentBubbleView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatAttachmentBubbleView.swift index 7d03df8cba80..dacc8639f346 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatAttachmentBubbleView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatAttachmentBubbleView.swift @@ -7,10 +7,11 @@ import UIKit import AppKit #endif -/// An outgoing attachment bubble: a photo glyph plus the attachment's -/// display name, with the host-side path when known. +/// An outgoing attachment bubble. Images reserve a stable inline preview; +/// other files keep a compact filename-and-path treatment. public struct ChatAttachmentBubbleView: View { private let attachment: ChatAttachment + private let role: ChatRole private let groupPosition: ChatGroupPosition private let showsTimestamp: Bool private let timestamp: Date @@ -22,13 +23,15 @@ public struct ChatAttachmentBubbleView: View { @State private var thumbnailData: Data? @State private var thumbnailFailed = false - @State private var thumbnailPath: String? + @State private var thumbnailRequest: ChatAttachmentThumbnailRequest? + @State private var thumbnailRetryAttempt = 0 @State private var fallbackSelection: ChatArtifactPathSelection? /// Creates an attachment bubble. /// /// - Parameters: /// - attachment: The attachment metadata. + /// - role: Who authored the attachment row. /// - groupPosition: Position inside the visual bubble group. /// - showsTimestamp: Whether the group timestamp renders under this /// bubble. @@ -37,12 +40,14 @@ public struct ChatAttachmentBubbleView: View { /// navigation stack. When omitted, the standalone bubble uses a sheet. public init( attachment: ChatAttachment, + role: ChatRole = .user, groupPosition: ChatGroupPosition, showsTimestamp: Bool, timestamp: Date, onOpenArtifact: ((String) -> Void)? = nil ) { self.attachment = attachment + self.role = role self.groupPosition = groupPosition self.showsTimestamp = showsTimestamp self.timestamp = timestamp @@ -51,10 +56,10 @@ public struct ChatAttachmentBubbleView: View { public var body: some View { HStack(spacing: 0) { - Spacer(minLength: 64) - VStack(alignment: .trailing, spacing: 3) { - artifactAwareBubble - .frame(maxWidth: bubbleMaxWidth, alignment: .trailing) + if isUser { Spacer(minLength: 64) } + VStack(alignment: isUser ? .trailing : .leading, spacing: 3) { + attachmentContent + .frame(maxWidth: bubbleMaxWidth, alignment: isUser ? .trailing : .leading) if showsTimestamp { Text(timestamp.formatted(.dateTime.hour().minute())) .font(.caption2) @@ -62,123 +67,203 @@ public struct ChatAttachmentBubbleView: View { .padding(.horizontal, 4) } } - .accessibilityElement(children: .combine) + if !isUser { Spacer(minLength: 64) } } - .frame(maxWidth: .infinity, alignment: .trailing) + .frame(maxWidth: .infinity, alignment: isUser ? .trailing : .leading) .sheet(item: $fallbackSelection) { selection in ChatArtifactViewerSheet(path: selection.path) + .environment(\.chatArtifactLoader, artifactLoader) + } + } + + private var isUser: Bool { role == .user } + + @ViewBuilder + private var attachmentContent: some View { + switch attachment.media { + case .image: + imageAttachment + case .file: + fileAttachment } } @ViewBuilder - private var artifactAwareBubble: some View { - if artifactLoader.supportsArtifacts, let hostPath = attachment.hostPath, !hostPath.isEmpty { + private var imageAttachment: some View { + if let hostPath { Button { - if let onOpenArtifact { - onOpenArtifact(hostPath) - } else { - fallbackSelection = ChatArtifactPathSelection(path: hostPath) - } + openArtifact(path: hostPath) } label: { - if thumbnailFailed { - bubble - } else { - thumbnailBubble(hostPath: hostPath) - } + imagePreview } .buttonStyle(.plain) - .task(id: hostPath) { - await loadThumbnail(path: hostPath) + .accessibilityLabel(displayName) + .accessibilityHint(openPreviewHint) + .accessibilityIdentifier("ChatAttachmentImagePreview") + .task(id: currentThumbnailTaskID) { + guard let taskID = currentThumbnailTaskID else { return } + await loadThumbnail(taskID: taskID) } } else { - bubble + imagePreview + .accessibilityElement(children: .ignore) + .accessibilityLabel(displayName) + .accessibilityIdentifier("ChatAttachmentImagePreview") } } - private var bubble: some View { + @ViewBuilder + private var fileAttachment: some View { + if artifactLoader.supportsArtifacts, let hostPath { + Button { + openArtifact(path: hostPath) + } label: { + fileBubble + } + .buttonStyle(.plain) + .accessibilityLabel(displayName) + .accessibilityHint(openPreviewHint) + } else { + fileBubble + .accessibilityElement(children: .combine) + .accessibilityLabel(displayName) + } + } + + private var fileBubble: some View { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { - Image(systemName: "photo") + Image(systemName: "doc") .font(.caption) Text(displayName) .font(.caption) .lineLimit(1) .truncationMode(.middle) } - .foregroundStyle(.white) + .foregroundStyle(fileBubbleTextStyle) if let hostPath = attachment.hostPath, !hostPath.isEmpty { Text(hostPath) .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.white.opacity(0.7)) + .foregroundStyle(fileBubbleSecondaryTextStyle) .lineLimit(1) .truncationMode(.middle) } } .padding(.horizontal, 12) .padding(.vertical, 8) - .background(theme.outgoingBubbleFill, in: bubbleShape) - } - - private func thumbnailBubble(hostPath: String) -> some View { - HStack(spacing: 8) { - thumbnailImage - .frame(width: 48, height: 48) - .background(.white.opacity(0.16), in: .rect(cornerRadius: 6)) - .clipShape(.rect(cornerRadius: 6)) - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 6) { - Image(systemName: "photo") - .font(.caption) - Text(displayName) - .font(.caption) - .lineLimit(1) - .truncationMode(.middle) - } - Text(hostPath) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.white.opacity(0.7)) - .lineLimit(1) - .truncationMode(.middle) - } + .background(isUser ? theme.outgoingBubbleFill : theme.incomingBubbleFill, in: bubbleShape) + } + + private var fileBubbleTextStyle: Color { + isUser ? .white : .primary + } + + private var fileBubbleSecondaryTextStyle: Color { + isUser ? .white.opacity(0.7) : .secondary + } + + private var imagePreview: some View { + ZStack { + theme.terminalCardFill + imagePreviewContent } - .foregroundStyle(.white) - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background(theme.outgoingBubbleFill, in: bubbleShape) + .frame(width: previewSize.width, height: previewSize.height) + .clipShape(bubbleShape) + .overlay { + bubbleShape + .stroke(theme.hairline.opacity(0.7), lineWidth: 0.5) + } + .contentShape(bubbleShape) } @ViewBuilder - private var thumbnailImage: some View { - if let thumbnailData { + private var imagePreviewContent: some View { + if let currentThumbnailRequest, + thumbnailRequest == currentThumbnailRequest, + let thumbnailData { #if canImport(UIKit) if let image = UIImage(data: thumbnailData) { Image(uiImage: image) .resizable() - .scaledToFill() + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - placeholderThumbnail + unavailablePreview } #elseif canImport(AppKit) if let image = NSImage(data: thumbnailData) { Image(nsImage: image) .resizable() - .scaledToFill() + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - placeholderThumbnail + unavailablePreview } #else - placeholderThumbnail + unavailablePreview #endif + } else if !artifactLoader.supportsArtifacts + || hostPath == nil + || (thumbnailRequest == currentThumbnailRequest && thumbnailFailed) { + unavailablePreview } else { - placeholderThumbnail + ProgressView() + .controlSize(.small) + .tint(.white.opacity(0.82)) + } + } + + private var unavailablePreview: some View { + VStack(spacing: 7) { + Image(systemName: "photo") + .font(.title2) + Text( + String( + localized: "chat.artifact.preview_unavailable.title", + defaultValue: "Preview unavailable", + bundle: .module + ) + ) + .font(.caption) } + .foregroundStyle(.white.opacity(0.82)) + .frame(maxWidth: .infinity, maxHeight: .infinity) } - private var placeholderThumbnail: some View { - Image(systemName: "photo") - .font(.title3) - .foregroundStyle(.white.opacity(0.82)) - .frame(maxWidth: .infinity, maxHeight: .infinity) + private var previewLayout: ChatAttachmentPreviewLayout { + ChatAttachmentPreviewLayout( + pixelWidth: attachment.pixelWidth, + pixelHeight: attachment.pixelHeight, + aspectRatio: attachment.aspectRatio + ) + } + + private var previewSize: CGSize { + let resolvedMaxWidth = bubbleMaxWidth.isFinite && bubbleMaxWidth > 0 ? bubbleMaxWidth : 320 + return previewLayout.size(maxWidth: resolvedMaxWidth) + } + + private var hostPath: String? { + guard let hostPath = attachment.hostPath, !hostPath.isEmpty else { return nil } + return hostPath + } + + private var currentThumbnailRequest: ChatAttachmentThumbnailRequest? { + guard let hostPath else { return nil } + return ChatAttachmentThumbnailRequest( + path: hostPath, + scope: artifactLoader.scope, + supportsArtifacts: artifactLoader.supportsArtifacts, + byteCount: attachment.byteCount.map(Int64.init) + ) + } + + private var currentThumbnailTaskID: ChatAttachmentThumbnailTaskID? { + guard let request = currentThumbnailRequest else { return nil } + return ChatAttachmentThumbnailTaskID( + request: request, + retryAttempt: thumbnailRetryAttempt + ) } /// Trailing-side grouped-corner shape matching the prose bubble rules. @@ -187,6 +272,14 @@ public struct ChatAttachmentBubbleView: View { let tight = theme.bubbleGroupedCornerRadius let tightTop = groupPosition == .middle || groupPosition == .last let tightBottom = groupPosition == .first || groupPosition == .middle + if !isUser { + return UnevenRoundedRectangle( + topLeadingRadius: tightTop ? tight : full, + bottomLeadingRadius: tightBottom ? tight : full, + bottomTrailingRadius: full, + topTrailingRadius: full + ) + } return UnevenRoundedRectangle( topLeadingRadius: full, bottomLeadingRadius: full, @@ -199,20 +292,90 @@ public struct ChatAttachmentBubbleView: View { if let name = attachment.displayName, !name.isEmpty { return name } - return String(localized: "chat.attachment.image", defaultValue: "Image", bundle: .module) + switch attachment.media { + case .image: + return String(localized: "chat.attachment.image", defaultValue: "Image", bundle: .module) + case .file: + return String(localized: "chat.attachment.file", defaultValue: "File", bundle: .module) + } } - private func loadThumbnail(path: String) async { - if thumbnailPath != path { - thumbnailPath = path + private var openPreviewHint: String { + String( + localized: "chat.attachment.open_preview_hint", + defaultValue: "Opens the full preview", + bundle: .module + ) + } + + private func openArtifact(path: String) { + if let onOpenArtifact { + onOpenArtifact(path) + } else { + fallbackSelection = ChatArtifactPathSelection(path: path) + } + } + + private func loadThumbnail(taskID: ChatAttachmentThumbnailTaskID) async { + let request = taskID.request + if thumbnailRequest != request { + thumbnailRequest = request thumbnailData = nil thumbnailFailed = false + thumbnailRetryAttempt = 0 + } + guard thumbnailRetryAttempt == taskID.retryAttempt else { return } + guard request.supportsArtifacts else { + thumbnailFailed = true + return } guard thumbnailData == nil, !thumbnailFailed else { return } do { - thumbnailData = try await artifactLoader.thumbnail(path: path, maxDimension: 256).data + let thumbnail = try await artifactLoader.thumbnail( + path: request.path, + maxDimension: 1_024, + size: request.byteCount + ) + guard !Task.isCancelled, thumbnailRequest == request else { return } + thumbnailData = thumbnail.data + } catch is CancellationError { + return } catch { - thumbnailFailed = true + guard thumbnailRequest == request else { return } + guard let delay = ChatAttachmentThumbnailRetryPolicy.delayNanoseconds( + forAttempt: taskID.retryAttempt + ) else { + thumbnailFailed = true + return + } + thumbnailFailed = false + try? await Task.sleep(nanoseconds: delay) + guard !Task.isCancelled, + thumbnailRequest == request, + thumbnailData == nil, + thumbnailRetryAttempt == taskID.retryAttempt else { return } + thumbnailRetryAttempt = taskID.retryAttempt + 1 } } } + +private struct ChatAttachmentThumbnailRequest: Hashable { + let path: String + let scope: ChatArtifactLoaderScope + let supportsArtifacts: Bool + let byteCount: Int64? +} + +private struct ChatAttachmentThumbnailTaskID: Hashable { + let request: ChatAttachmentThumbnailRequest + let retryAttempt: Int +} + +enum ChatAttachmentThumbnailRetryPolicy { + private static let delaysInMilliseconds: [UInt64] = [250, 600, 1_200, 2_400] + + static func delayNanoseconds(forAttempt attempt: Int) -> UInt64? { + guard delaysInMilliseconds.indices.contains(attempt) else { return nil } + return delaysInMilliseconds[attempt] * 1_000_000 + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatAttachmentPreviewLayout.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatAttachmentPreviewLayout.swift new file mode 100644 index 000000000000..2ce1bf4f7080 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatAttachmentPreviewLayout.swift @@ -0,0 +1,38 @@ +import CoreGraphics + +/// Stable inline-image geometry derived only from transcript metadata. +/// +/// Loading a thumbnail never changes the reserved footprint. Missing or +/// invalid dimensions use a deterministic 4:3 fallback. Extreme aspect ratios +/// keep their source ratio and shrink one dimension to stay bounded. +struct ChatAttachmentPreviewLayout: Equatable, Sendable { + private static let fallbackAspectRatio: CGFloat = 4 / 3 + private static let maximumHeightToWidthRatio: CGFloat = 1.25 + + let aspectRatio: CGFloat + + init(pixelWidth: Int?, pixelHeight: Int?, aspectRatio providedAspectRatio: Double? = nil) { + if let pixelWidth, + let pixelHeight, + pixelWidth > 0, + pixelHeight > 0 { + aspectRatio = CGFloat(pixelWidth) / CGFloat(pixelHeight) + } else if let providedAspectRatio, + providedAspectRatio.isFinite, + providedAspectRatio > 0 { + aspectRatio = CGFloat(providedAspectRatio) + } else { + aspectRatio = Self.fallbackAspectRatio + } + } + + func size(maxWidth: CGFloat) -> CGSize { + guard maxWidth.isFinite, maxWidth > 0 else { return .zero } + let idealHeight = maxWidth / aspectRatio + let maximumHeight = maxWidth * Self.maximumHeightToWidthRatio + if idealHeight <= maximumHeight { + return CGSize(width: maxWidth, height: idealHeight) + } + return CGSize(width: maximumHeight * aspectRatio, height: maximumHeight) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatProseBubbleView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatProseBubbleView.swift index 8b8d9ae0689b..aaf24c28adcb 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatProseBubbleView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatProseBubbleView.swift @@ -119,6 +119,7 @@ public struct ChatProseBubbleView: View, Equatable { .font(.body) .foregroundStyle(.white) .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) } else { VStack(alignment: .leading, spacing: 8) { ForEach(proseSegments) { segment in @@ -249,12 +250,14 @@ public struct ChatProseBubbleView: View, Equatable { .font(headingFont(level: level)) .foregroundStyle(.primary) .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) .padding(.top, block.index == 0 ? 0 : 2) case .paragraph: Text(inline) .font(.body) .foregroundStyle(.primary) .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) case .bullet(let indent): listRow(marker: "•", inline: inline, indent: indent) case .ordered(let marker, let indent): @@ -268,6 +271,7 @@ public struct ChatProseBubbleView: View, Equatable { .font(.body) .foregroundStyle(.secondary) .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) } case .rule: Rectangle() @@ -290,6 +294,7 @@ public struct ChatProseBubbleView: View, Equatable { .foregroundStyle(.primary) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) } .padding(.leading, CGFloat(min(indent, 4)) * 14) } diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLoaderTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLoaderTests.swift index e4a20d201f69..f48708b5fcc3 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLoaderTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLoaderTests.swift @@ -40,6 +40,21 @@ struct ChatArtifactLoaderTests { #expect(await source.thumbnailRequestCount() == 2) } + @Test func thumbnailRetryPolicyIsBoundedForTransientMissingFiles() { + let delays = (0..<5).map { + ChatAttachmentThumbnailRetryPolicy.delayNanoseconds(forAttempt: $0) + } + + #expect(delays == [ + 250_000_000, + 600_000_000, + 1_200_000_000, + 2_400_000_000, + nil, + ]) + #expect(delays.compactMap(\.self).reduce(0, +) < 5_000_000_000) + } + @Test func terminalScopeUsesDistinctCacheAndRoutesToTerminalClosures() async throws { let cache = ChatArtifactThumbnailCache() let chatSource = CountingArtifactSource() diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatAttachmentPreviewLayoutTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatAttachmentPreviewLayoutTests.swift new file mode 100644 index 000000000000..d86338d3dcc1 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatAttachmentPreviewLayoutTests.swift @@ -0,0 +1,60 @@ +import CoreGraphics +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Inline image preview layout") +struct ChatAttachmentPreviewLayoutTests { + @Test("known image dimensions reserve their aspect-fit footprint before loading") + func knownDimensionsReserveAspectFitFootprint() { + let layout = ChatAttachmentPreviewLayout(pixelWidth: 1_600, pixelHeight: 900) + + let beforeThumbnailLoads = layout.size(maxWidth: 320) + let afterThumbnailLoads = layout.size(maxWidth: 320) + + #expect(beforeThumbnailLoads == CGSize(width: 320, height: 180)) + #expect(afterThumbnailLoads == beforeThumbnailLoads) + } + + @Test("missing or invalid dimensions use one deterministic fallback footprint") + func missingDimensionsUseFallbackFootprint() { + let missing = ChatAttachmentPreviewLayout(pixelWidth: nil, pixelHeight: nil) + let zero = ChatAttachmentPreviewLayout(pixelWidth: 0, pixelHeight: 900) + let negative = ChatAttachmentPreviewLayout(pixelWidth: 1_600, pixelHeight: -1) + + #expect(missing.size(maxWidth: 320) == CGSize(width: 320, height: 240)) + #expect(zero.size(maxWidth: 320) == missing.size(maxWidth: 320)) + #expect(negative.size(maxWidth: 320) == missing.size(maxWidth: 320)) + } + + @Test("ratio-only metadata reserves the final preview aspect") + func ratioOnlyMetadataReservesFinalPreviewAspect() { + let layout = ChatAttachmentPreviewLayout( + pixelWidth: nil, + pixelHeight: nil, + aspectRatio: 9.0 / 16.0 + ) + + #expect(layout.size(maxWidth: 320) == CGSize(width: 225, height: 400)) + } + + @Test("pixel dimensions override ratio metadata") + func pixelDimensionsOverrideRatioMetadata() { + let layout = ChatAttachmentPreviewLayout( + pixelWidth: 1_600, + pixelHeight: 900, + aspectRatio: 1 + ) + + #expect(layout.size(maxWidth: 320) == CGSize(width: 320, height: 180)) + } + + @Test("extreme aspect ratios preserve source shape while staying bounded") + func extremeAspectRatiosPreserveSourceShapeWhileBounded() { + let tall = ChatAttachmentPreviewLayout(pixelWidth: 1_000, pixelHeight: 5_000) + let panorama = ChatAttachmentPreviewLayout(pixelWidth: 5_000, pixelHeight: 1_000) + + #expect(tall.size(maxWidth: 320) == CGSize(width: 80, height: 400)) + #expect(panorama.size(maxWidth: 320) == CGSize(width: 320, height: 64)) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatComposerSendPolicyTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatComposerSendPolicyTests.swift new file mode 100644 index 000000000000..bbfd2f59b98f --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatComposerSendPolicyTests.swift @@ -0,0 +1,20 @@ +import Testing +@testable import CmuxAgentChatUI + +@Suite("Chat composer send policy") +struct ChatComposerSendPolicyTests { + @Test("default composer remains disabled while disconnected") + func defaultDisconnected() { + #expect(!ChatComposerSendPolicy.canSubmit(isConnected: false, capabilities: .all)) + } + + @Test("Agent GUI text composer can enqueue while disconnected") + func offlineQueue() { + #expect(ChatComposerSendPolicy.canSubmit(isConnected: false, capabilities: .textOnly)) + } + + @Test("read-only sessions cannot submit while connected") + func readOnly() { + #expect(!ChatComposerSendPolicy.canSubmit(isConnected: true, capabilities: .readOnly)) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ConversationPrefetchGateTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ConversationPrefetchGateTests.swift new file mode 100644 index 000000000000..07ddcbed8a01 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ConversationPrefetchGateTests.swift @@ -0,0 +1,122 @@ +import Testing +@testable import CmuxAgentChatUI + +@Suite("Conversation transcript prefetch") +struct ConversationPrefetchGateTests { + @Test("a head window can page forward as its loaded tail approaches") + func headWindowPagesForward() { + var gate = ConversationPrefetchGate() + + let tooFar = gate.shouldLoadAfter(hasMore: true, distance: 161, lastID: "50") + let firstBoundary = gate.shouldLoadAfter(hasMore: true, distance: 160, lastID: "50") + let duplicateBoundary = gate.shouldLoadAfter(hasMore: true, distance: 0, lastID: "50") + let advancedBoundary = gate.shouldLoadAfter(hasMore: true, distance: 120, lastID: "100") + #expect(!tooFar) + #expect(firstBoundary) + #expect(!duplicateBoundary) + #expect(advancedBoundary) + } + + @Test("both edge callbacks coalesce until their boundary identity advances") + func callbacksCoalesceByBoundaryIdentity() { + var gate = ConversationPrefetchGate() + + let firstHead = gate.shouldLoadBefore(hasMore: true, distance: 20, firstID: 100) + let duplicateHead = gate.shouldLoadBefore(hasMore: true, distance: 0, firstID: 100) + let advancedHead = gate.shouldLoadBefore(hasMore: true, distance: 80, firstID: 50) + let firstTail = gate.shouldLoadAfter(hasMore: true, distance: 20, lastID: 150) + let duplicateTail = gate.shouldLoadAfter(hasMore: true, distance: 0, lastID: 150) + let advancedTail = gate.shouldLoadAfter(hasMore: true, distance: 80, lastID: 200) + #expect(firstHead) + #expect(!duplicateHead) + #expect(advancedHead) + #expect(firstTail) + #expect(!duplicateTail) + #expect(advancedTail) + } + + @Test("a completed edge can request the same boundary again after paging restarts") + func resetAfterCompletion() { + var gate = ConversationPrefetchGate() + let first = gate.shouldLoadBefore(hasMore: true, distance: 0, firstID: 10) + _ = gate.shouldLoadBefore(hasMore: false, distance: 0, firstID: 10) + let restarted = gate.shouldLoadBefore(hasMore: true, distance: 0, firstID: 10) + #expect(first) + #expect(restarted) + } +} + +@Suite("Conversation transcript append accounting") +struct ConversationAppendDeltaTests { + @Test("true appends count only the new suffix") + func trueAppend() { + #expect(ConversationAppendDelta.count(previous: ["a", "b"], current: ["a", "b", "c", "d"]) == 2) + } + + @Test("stream completion replaces the old tail without creating unread rows") + func streamingCommit() { + #expect(ConversationAppendDelta.count(previous: ["message", "stream"], current: ["message", "committed"]) == 0) + } + + @Test("ticket echo replaces the old tail without creating unread rows") + func ticketEcho() { + #expect(ConversationAppendDelta.count(previous: ["message", "ticket"], current: ["message", "echo"]) == 0) + } + + @Test("disjoint windows do not manufacture unread counts") + func disjointWindows() { + #expect(ConversationAppendDelta.count(previous: [1, 2], current: [100, 101]) == 0) + } +} + +@Suite("Conversation transcript tail geometry") +struct ConversationTailGeometryTests { + @Test("tail pin reconverges after estimated wrapped rows resolve") + func estimatedHeightResolution() { + let estimatedOffset = ConversationTailGeometry.maximumOffset( + contentHeight: 20_000, + viewportHeight: 800, + topInset: 44, + bottomInset: 96 + ) + #expect(estimatedOffset == 19_296) + + let resolvedOffset = ConversationTailGeometry.maximumOffset( + contentHeight: 60_000, + viewportHeight: 800, + topInset: 44, + bottomInset: 96 + ) + #expect(ConversationTailGeometry.distance( + contentOffset: resolvedOffset, + contentHeight: 60_000, + viewportHeight: 800, + topInset: 44, + bottomInset: 96 + ) == 0) + } +} + +@Suite("Conversation transcript tail settle policy") +struct ConversationTailSettlePolicyTests { + @Test("tail settling keeps correcting through late non-bottom passes") + func keepsCorrectingThroughLateNonBottomPasses() { + var state = ConversationTailSettlePolicy(maximumPasses: 12).makeState() + + for _ in 0..<11 { + #expect(state.observe(isAtBottom: false) == .continueSettling) + } + + #expect(state.observe(isAtBottom: true) == .continueSettling) + #expect(state.observe(isAtBottom: true) == .finishedAtBottom) + } + + @Test("tail settling expires detached when the bottom never becomes visible") + func expiresDetachedWhenBottomNeverBecomesVisible() { + var state = ConversationTailSettlePolicy(maximumPasses: 2).makeState() + + #expect(state.observe(isAtBottom: false) == .continueSettling) + #expect(state.observe(isAtBottom: false) == .continueSettling) + #expect(state.observe(isAtBottom: false) == .expiredAwayFromBottom) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/NativeConversationTranscriptUIKitTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/NativeConversationTranscriptUIKitTests.swift new file mode 100644 index 000000000000..4183c9cee9c1 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/NativeConversationTranscriptUIKitTests.swift @@ -0,0 +1,1028 @@ +#if os(iOS) +import CmuxAgentChat +import SwiftUI +import Testing +import UIKit + +@testable import CmuxAgentChatUI + +@MainActor +@Suite("Native conversation transcript UIKit behavior", .serialized) +struct NativeConversationTranscriptUIKitTests { + @Test("ten thousand rows keep a bounded native cell set") + func tenThousandRowsKeepBoundedCells() async throws { + let rows = (0..<10_000).map { + TranscriptTestRow(id: $0, text: "Message \($0)") + } + let harness = TranscriptTestHarness(rows: rows) + let mounted = mount(harness) + defer { mounted.window.isHidden = true } + + await settle(mounted.host, passes: 16) + + let table = try #require(transcriptTable(in: mounted.host.view)) + #expect(table.numberOfRows(inSection: 0) == 10_000) + #expect(!table.visibleCells.isEmpty) + #expect(table.visibleCells.count < 80) + #expect(descendants(of: UITableViewCell.self, in: table).count < 80) + } + + @Test("adjacent very long wrapped rows have disjoint native row rects") + func longWrappedRowsDoNotOverlap() async throws { + let firstText = Array(repeating: "A long first response wraps at the transcript width.", count: 36) + .joined(separator: " ") + let secondText = Array(repeating: "A long second response also wraps without covering its neighbor.", count: 36) + .joined(separator: " ") + let rows = [ + TranscriptTestRow(id: 0, text: firstText), + TranscriptTestRow(id: 1, text: secondText), + TranscriptTestRow(id: 2, text: "Trailing row"), + ] + let mounted = mount( + TranscriptTestHarness(rows: rows), + size: CGSize(width: 390, height: 1_400) + ) + defer { mounted.window.isHidden = true } + + await settle(mounted.host, passes: 20) + + let table = try #require(transcriptTable(in: mounted.host.view)) + let firstRect = table.rectForRow(at: IndexPath(row: 0, section: 0)) + let secondRect = table.rectForRow(at: IndexPath(row: 1, section: 0)) + #expect(firstRect.height > 200) + #expect(secondRect.height > 200) + #expect(firstRect.maxY <= secondRect.minY + 0.5) + #expect(!firstRect.insetBy(dx: 0, dy: 0.5).intersects(secondRect.insetBy(dx: 0, dy: 0.5))) + + let firstCell = try #require(table.cellForRow(at: IndexPath(row: 0, section: 0))) + let secondCell = try #require(table.cellForRow(at: IndexPath(row: 1, section: 0))) + #expect(firstCell.frame.maxY <= secondCell.frame.minY + 0.5) + } + + @Test("real prose bubble rows report full multiline height to native sizing") + func realProseBubbleRowsReportFullMultilineHeight() async throws { + let firstText = Array( + repeating: "This actual agent bubble contains enough markdown prose to wrap across many physical lines.", + count: 42 + ).joined(separator: " ") + let secondText = Array( + repeating: "The following user bubble also wraps and must start after the first bubble's painted pixels.", + count: 30 + ).joined(separator: " ") + let rows = [ + TranscriptTestRow(id: 0, snapshot: chatSnapshot(id: 0, role: .agent, text: firstText)), + TranscriptTestRow(id: 1, snapshot: chatSnapshot(id: 1, role: .user, text: secondText)), + TranscriptTestRow(id: 2, text: "Trailing row"), + ] + let mounted = mount( + TranscriptTestHarness(rows: rows), + size: CGSize(width: 390, height: 1_400) + ) + defer { mounted.window.isHidden = true } + + await settle(mounted.host, passes: 24) + + let table = try #require(transcriptTable(in: mounted.host.view)) + let firstRect = table.rectForRow(at: IndexPath(row: 0, section: 0)) + let secondRect = table.rectForRow(at: IndexPath(row: 1, section: 0)) + #expect(firstRect.height > 280) + #expect(secondRect.height > 180) + #expect(firstRect.maxY <= secondRect.minY + 0.5) + #expect(!firstRect.insetBy(dx: 0, dy: 0.5).intersects(secondRect.insetBy(dx: 0, dy: 0.5))) + + let firstCell = try #require(table.cellForRow(at: IndexPath(row: 0, section: 0))) + let secondCell = try #require(table.cellForRow(at: IndexPath(row: 1, section: 0))) + let firstPaintedFrame = descendantFrameUnion(of: firstCell, in: table) + let secondPaintedFrame = descendantFrameUnion(of: secondCell, in: table) + #expect(firstPaintedFrame.maxY <= secondRect.minY + 0.5) + #expect(!firstPaintedFrame.insetBy(dx: 0, dy: 0.5).intersects(secondPaintedFrame.insetBy(dx: 0, dy: 0.5))) + } + + @Test("inline image preview reserves a full row without covering its neighbor") + func inlineImagePreviewDoesNotOverlapFollowingRow() async throws { + let rows = [ + TranscriptTestRow( + id: 0, + attachment: ChatAttachment( + media: .image, + displayName: "screen.png", + hostPath: "/tmp/screen.png", + mimeType: "image/png", + byteCount: 456_789, + pixelWidth: 1_600, + pixelHeight: 900 + ) + ), + TranscriptTestRow(id: 1, text: "Following response"), + ] + let mounted = mount( + TranscriptTestHarness(rows: rows), + size: CGSize(width: 390, height: 844) + ) + defer { mounted.window.isHidden = true } + + await settle(mounted.host, passes: 20) + + let table = try #require(transcriptTable(in: mounted.host.view)) + let imageRect = table.rectForRow(at: IndexPath(row: 0, section: 0)) + let followingRect = table.rectForRow(at: IndexPath(row: 1, section: 0)) + #expect(imageRect.height >= 180) + #expect(imageRect.maxY <= followingRect.minY + 0.5) + #expect(!imageRect.insetBy(dx: 0, dy: 0.5).intersects(followingRect.insetBy(dx: 0, dy: 0.5))) + } + + @Test("reconfigured visible rows invalidate native height before drawing over neighbors") + func reconfiguredVisibleRowsInvalidateHeightBeforeDrawingOverNeighbors() async throws { + let state = TranscriptFollowStateBox(.detached(anchorID: 0, offset: 0, unseenCount: 0)) + let initialRows = [ + TranscriptTestRow(id: 0, text: "Short streamed answer."), + TranscriptTestRow(id: 1, text: "Following prompt bubble."), + ] + var harness = TranscriptTestHarness(rows: initialRows, followState: state) + let mounted = mount( + harness, + size: CGSize(width: 390, height: 1_400) + ) + defer { mounted.window.isHidden = true } + + await settle(mounted.host, passes: 16) + + let table = try #require(transcriptTable(in: mounted.host.view)) + let initialHeight = table.rectForRow(at: IndexPath(row: 0, section: 0)).height + let expandedText = Array( + repeating: "The same visible row later resolves into a much taller markdown answer.", + count: 52 + ).joined(separator: " ") + harness = TranscriptTestHarness( + rows: [ + TranscriptTestRow(id: 0, text: expandedText), + TranscriptTestRow(id: 1, text: "Following prompt bubble."), + ], + followState: state + ) + mounted.host.rootView = harness + + await settle(mounted.host, passes: 32) + + let updatedTable = try #require(transcriptTable(in: mounted.host.view)) + #expect(updatedTable === table) + let expandedRect = updatedTable.rectForRow(at: IndexPath(row: 0, section: 0)) + let followingRect = updatedTable.rectForRow(at: IndexPath(row: 1, section: 0)) + #expect(expandedRect.height > initialHeight + 240) + #expect(expandedRect.maxY <= followingRect.minY + 0.5) + #expect(!expandedRect.insetBy(dx: 0, dy: 0.5).intersects(followingRect.insetBy(dx: 0, dy: 0.5))) + + let expandedCell = try #require(updatedTable.cellForRow(at: IndexPath(row: 0, section: 0))) + let paintedFrame = descendantFrameUnion(of: expandedCell, in: updatedTable) + #expect(paintedFrame.maxY <= followingRect.minY + 0.5) + } + + @Test("detached first-visible anchor survives a prepend") + func detachedAnchorSurvivesPrepend() async throws { + let initialRows = (100..<240).map { + TranscriptTestRow(id: $0, text: "Existing response \($0)") + } + let state = TranscriptFollowStateBox(.detached(anchorID: 160, offset: 13, unseenCount: 0)) + var harness = TranscriptTestHarness(rows: initialRows, followState: state) + let mounted = mount(harness) + defer { mounted.window.isHidden = true } + await settle(mounted.host, passes: 16) + + let table = try #require(transcriptTable(in: mounted.host.view)) + let requestedIndex = IndexPath(row: 60, section: 0) + table.layoutIfNeeded() + table.setContentOffset( + CGPoint(x: 0, y: table.rectForRow(at: requestedIndex).minY + 13), + animated: false + ) + await settle(mounted.host, passes: 4) + + let before = try #require(firstVisibleAnchor(in: table, rows: initialRows)) + state.value = .detached(anchorID: before.id, offset: before.offset, unseenCount: 0) + + let prependedRows = (0..<100).map { + TranscriptTestRow(id: $0, text: "Older response \($0)") + } + initialRows + harness = TranscriptTestHarness(rows: prependedRows, followState: state) + mounted.host.rootView = harness + await settle(mounted.host, passes: 24) + + let updatedTable = try #require(transcriptTable(in: mounted.host.view)) + #expect(updatedTable === table) + let after = try #require(firstVisibleAnchor(in: updatedTable, rows: prependedRows)) + #expect(after.id == before.id) + #expect(abs(after.offset - before.offset) <= 1.5) + } + + @Test("following tail converges after appended wrapped rows self-size") + func followingTailConvergesAfterSelfSizingAppend() async throws { + let state = TranscriptFollowStateBox(.followingTail) + let initialRows = (0..<160).map { + TranscriptTestRow(id: $0, text: "Compact response \($0)") + } + var harness = TranscriptTestHarness(rows: initialRows, followState: state) + let mounted = mount(harness) + defer { mounted.window.isHidden = true } + await settle(mounted.host, passes: 20) + + let table = try #require(transcriptTable(in: mounted.host.view)) + #expect(distanceFromTail(in: table) <= 1.5) + + let wrappedText = Array(repeating: "The final streamed answer resolves into substantially more wrapped content.", count: 120) + .joined(separator: " ") + harness = TranscriptTestHarness( + rows: initialRows + [TranscriptTestRow(id: 160, text: wrappedText)], + followState: state + ) + mounted.host.rootView = harness + await settle(mounted.host, passes: 32) + + let updatedTable = try #require(transcriptTable(in: mounted.host.view)) + #expect(updatedTable === table) + #expect(updatedTable.contentSize.height > 10_000) + #expect(distanceFromTail(in: updatedTable) <= 1.5) + #expect(abs(updatedTable.contentOffset.y - maximumOffset(in: updatedTable)) <= 1.5) + } + + @Test("tail command reaches the real last row in a ten-thousand-row window") + func tailCommandReachesRealLastRowInTenThousandRowWindow() async throws { + let state = TranscriptFollowStateBox(.detached(anchorID: 5_000, offset: 0, unseenCount: 0)) + let wrappedTailText = Array( + repeating: "The final row is much taller than the estimated row height.", + count: 120 + ).joined(separator: " ") + let rows = (0..<10_000).map { + TranscriptTestRow( + id: $0, + text: $0 == 9_999 ? wrappedTailText : "Compact response \($0)" + ) + } + let harness = TranscriptTestHarness( + rows: rows, + followState: state, + command: ConversationScrollCommand(generation: 1, target: .tail, animated: false) + ) + let mounted = mount(harness) + defer { mounted.window.isHidden = true } + + await settle(mounted.host, passes: 64) + + let table = try #require(transcriptTable(in: mounted.host.view)) + let visibleRows = try #require(table.indexPathsForVisibleRows) + #expect(table.numberOfRows(inSection: 0) == 10_000) + #expect(visibleRows.contains(IndexPath(row: 9_999, section: 0))) + #expect(distanceFromTail(in: table) <= 1.5) + #expect(state.value == .followingTail) + #expect(table.visibleCells.count < 80) + } + + @Test("reused hosted cells reset row-local SwiftUI state by row identity") + func reusedHostedCellsResetRowLocalSwiftUIStateByRowIdentity() async throws { + let rows = (0..<220).map { TranscriptTestRow(id: $0, stateProbe: true) } + let mounted = mount( + TranscriptTestHarness(rows: rows), + size: CGSize(width: 390, height: 260) + ) + defer { mounted.window.isHidden = true } + + await settle(mounted.host, passes: 20) + + let table = try #require(transcriptTable(in: mounted.host.view)) + table.scrollToRow(at: IndexPath(row: 180, section: 0), at: .top, animated: false) + await settle(mounted.host, passes: 24) + + let visibleRows = try #require(table.indexPathsForVisibleRows) + #expect(!visibleRows.isEmpty) + #expect(table.visibleCells.count < 20) + for indexPath in visibleRows { + #expect(table.rectForRow(at: indexPath).height < 100) + } + } + + @Test("detached animated tail command converges after wrapped rows resolve") + func detachedAnimatedTailCommandConvergesAfterWrappedRowsResolve() async throws { + let state = TranscriptFollowStateBox(.detached(anchorID: 120, offset: 0, unseenCount: 0)) + let wrappedText = Array(repeating: "Resolved transcript text can be much taller than its estimated table height.", count: 120) + .joined(separator: " ") + let rows = (0..<180).map { + TranscriptTestRow(id: $0, text: $0 == 179 ? wrappedText : "Compact response \($0)") + } + var harness = TranscriptTestHarness(rows: rows, followState: state) + let mounted = mount(harness) + defer { mounted.window.isHidden = true } + await settle(mounted.host, passes: 20) + + let table = try #require(transcriptTable(in: mounted.host.view)) + table.layoutIfNeeded() + table.setContentOffset( + CGPoint(x: 0, y: table.rectForRow(at: IndexPath(row: 120, section: 0)).minY), + animated: false + ) + await settle(mounted.host, passes: 4) + #expect(distanceFromTail(in: table) > 400) + + harness = TranscriptTestHarness( + rows: rows, + followState: state, + command: ConversationScrollCommand(generation: 1, target: .tail) + ) + mounted.host.rootView = harness + await settle(mounted.host, passes: 48) + + let updatedTable = try #require(transcriptTable(in: mounted.host.view)) + #expect(updatedTable === table) + #expect(state.value == .followingTail) + #expect(distanceFromTail(in: updatedTable) <= 1.5) + } + + @Test("tail command does not report following while the last row is not visible") + func tailCommandWaitsForVisibleLastRowBeforeFollowing() async throws { + let state = TranscriptFollowStateBox(.detached(anchorID: 120, offset: 0, unseenCount: 0)) + let wrappedText = Array(repeating: "Resolved transcript text can be much taller than its estimated table height.", count: 120) + .joined(separator: " ") + let rows = (0..<180).map { + TranscriptTestRow(id: $0, text: $0 == 179 ? wrappedText : "Compact response \($0)") + } + var harness = TranscriptTestHarness(rows: rows, followState: state) + let mounted = mount(harness) + defer { mounted.window.isHidden = true } + await settle(mounted.host, passes: 20) + + let table = try #require(transcriptTable(in: mounted.host.view)) + table.layoutIfNeeded() + table.setContentOffset( + CGPoint(x: 0, y: table.rectForRow(at: IndexPath(row: 120, section: 0)).minY), + animated: false + ) + await settle(mounted.host, passes: 4) + + harness = TranscriptTestHarness( + rows: rows, + followState: state, + command: ConversationScrollCommand(generation: 1, target: .tail) + ) + mounted.host.rootView = harness + + for _ in 0..<12 { + await settle(mounted.host, passes: 1) + if state.value == .followingTail { + let visibleRows = table.indexPathsForVisibleRows ?? [] + #expect(visibleRows.contains(IndexPath(row: rows.count - 1, section: 0))) + #expect(distanceFromTail(in: table) <= 1.5) + } + } + } + + @Test("only the active transcript owns native status-bar scroll-to-top") + func activeTranscriptOwnsScrollToTop() async throws { + let rows = (0..<20).map { TranscriptTestRow(id: $0, text: "Response \($0)") } + var harness = TranscriptTestHarness(rows: rows, isActive: false) + let mounted = mount(harness) + defer { mounted.window.isHidden = true } + await settle(mounted.host, passes: 12) + + let table = try #require(transcriptTable(in: mounted.host.view)) + #expect(!table.scrollsToTop) + + harness = TranscriptTestHarness(rows: rows, isActive: true) + mounted.host.rootView = harness + await settle(mounted.host, passes: 8) + + let updatedTable = try #require(transcriptTable(in: mounted.host.view)) + #expect(updatedTable === table) + #expect(updatedTable.scrollsToTop) + } + + @Test("status-bar scrolling detaches before UIKit begins its animation") + func statusBarScrollingDetachesBeforeAnimation() async throws { + let rows = (0..<80).map { TranscriptTestRow(id: $0, text: "Response \($0)") } + let state = TranscriptFollowStateBox(.followingTail) + let mounted = mount(TranscriptTestHarness(rows: rows, followState: state)) + defer { mounted.window.isHidden = true } + await settle(mounted.host, passes: 12) + + let table = try #require(transcriptTable(in: mounted.host.view)) + #expect(table.delegate?.scrollViewShouldScrollToTop?(table) == true) + #expect(state.value == .jumpingToHead) + } + + @Test("status-bar scrolling cancels an in-flight tail settle") + func statusBarScrollingCancelsInFlightTailSettle() async throws { + let state = TranscriptFollowStateBox(.detached(anchorID: 120, offset: 0, unseenCount: 0)) + let wrappedTailText = Array( + repeating: "The final streamed answer resolves into a much taller row after the first tail jump.", + count: 160 + ).joined(separator: " ") + let rows = (0..<240).map { + TranscriptTestRow( + id: $0, + text: $0 == 239 ? wrappedTailText : "Compact response \($0)" + ) + } + let mounted = mount(TranscriptTestHarness( + rows: rows, + followState: state, + command: ConversationScrollCommand(generation: 1, target: .tail, animated: false) + )) + defer { mounted.window.isHidden = true } + await settle(mounted.host, passes: 1) + + let table = try #require(transcriptTable(in: mounted.host.view)) + #expect(distanceFromTail(in: table) <= 1.5) + #expect(table.delegate?.scrollViewShouldScrollToTop?(table) == true) + table.setContentOffset( + CGPoint(x: table.contentOffset.x, y: -table.adjustedContentInset.top), + animated: false + ) + table.delegate?.scrollViewDidScrollToTop?(table) + await settle(mounted.host, passes: 8) + + #expect(abs(table.contentOffset.y + table.adjustedContentInset.top) <= 1.5) + #expect(distanceFromTail(in: table) > 1_000) + #expect(state.value == .detached(anchorID: rows[0].id, offset: 0, unseenCount: 0)) + } + + @Test("status-bar scrolling requests the authoritative head when history is paged") + func statusBarScrollingRequestsAuthoritativeHead() async throws { + let callbacks = TranscriptCallbackBox() + let state = TranscriptFollowStateBox(.followingTail) + let mounted = mount(TranscriptTestHarness( + rows: (80..<160).map { TranscriptTestRow(id: $0, text: "Response \($0)") }, + hasMoreBefore: true, + followState: state, + onSemanticHead: { callbacks.semanticHeadCount += 1 } + )) + defer { mounted.window.isHidden = true } + await settle(mounted.host, passes: 12) + + let table = try #require(transcriptTable(in: mounted.host.view)) + #expect(table.delegate?.scrollViewShouldScrollToTop?(table) == false) + #expect(callbacks.semanticHeadCount == 1) + #expect(state.value == .jumpingToHead) + } + + @Test("semantic head loads the authoritative window then reaches its real top") + func semanticHeadLoadsAuthoritativeWindowAndReachesTop() async throws { + let state = TranscriptFollowStateBox(.detached(anchorID: 9_960, offset: 0, unseenCount: 0)) + let callbacks = TranscriptCallbackBox() + var harness = TranscriptTestHarness( + rows: (9_920..<10_000).map { TranscriptTestRow(id: $0, text: "Loaded response \($0)") }, + hasMoreBefore: true, + followState: state, + command: ConversationScrollCommand(generation: 1, target: .head, animated: false), + onSemanticHead: { callbacks.semanticHeadCount += 1 } + ) + let mounted = mount(harness) + defer { mounted.window.isHidden = true } + await settle(mounted.host, passes: 20) + + let table = try #require(transcriptTable(in: mounted.host.view)) + #expect(callbacks.semanticHeadCount == 1) + #expect(state.value == .jumpingToHead) + + let authoritativeRows = (0..<80).map { + TranscriptTestRow(id: $0, text: "Authoritative head response \($0)") + } + harness = TranscriptTestHarness( + rows: authoritativeRows, + hasMoreBefore: false, + followState: state, + command: ConversationScrollCommand(generation: 1, target: .head, animated: false), + onSemanticHead: { callbacks.semanticHeadCount += 1 } + ) + mounted.host.rootView = harness + await settle(mounted.host, passes: 24) + + let updatedTable = try #require(transcriptTable(in: mounted.host.view)) + #expect(updatedTable === table) + #expect(callbacks.semanticHeadCount == 1) + #expect(state.value == .detached(anchorID: authoritativeRows[0].id, offset: 0, unseenCount: 0)) + #expect(abs(updatedTable.contentOffset.y + updatedTable.adjustedContentInset.top) <= 1.5) + let visibleRows = try #require(updatedTable.indexPathsForVisibleRows) + #expect(visibleRows.contains(IndexPath(row: 0, section: 0))) + } + + @Test("semantic head command advances across multiple page boundaries") + func semanticHeadCommandAdvancesAcrossMultiplePageBoundaries() async throws { + let state = TranscriptFollowStateBox(.detached(anchorID: 200, offset: 0, unseenCount: 0)) + let callbacks = TranscriptCallbackBox() + var harness = TranscriptTestHarness( + rows: (160..<240).map { TranscriptTestRow(id: $0, text: "Loaded response \($0)") }, + hasMoreBefore: true, + beforePageID: "older-c", + followState: state, + command: ConversationScrollCommand(generation: 1, target: .head, animated: false), + onSemanticHead: { callbacks.semanticHeadCount += 1 } + ) + let mounted = mount(harness) + defer { mounted.window.isHidden = true } + await settle(mounted.host, passes: 20) + + #expect(callbacks.semanticHeadCount == 1) + #expect(state.value == .jumpingToHead) + + harness = TranscriptTestHarness( + rows: (80..<160).map { TranscriptTestRow(id: $0, text: "Loaded response \($0)") }, + hasMoreBefore: true, + beforePageID: "older-b", + followState: state, + command: ConversationScrollCommand(generation: 1, target: .head, animated: false), + onSemanticHead: { callbacks.semanticHeadCount += 1 } + ) + mounted.host.rootView = harness + await settle(mounted.host, passes: 20) + + #expect(callbacks.semanticHeadCount == 2) + #expect(state.value == .jumpingToHead) + + mounted.host.rootView = harness + await settle(mounted.host, passes: 8) + #expect(callbacks.semanticHeadCount == 2) + + let authoritativeRows = (0..<80).map { + TranscriptTestRow(id: $0, text: "Authoritative head response \($0)") + } + harness = TranscriptTestHarness( + rows: authoritativeRows, + hasMoreBefore: false, + followState: state, + command: ConversationScrollCommand(generation: 1, target: .head, animated: false), + onSemanticHead: { callbacks.semanticHeadCount += 1 } + ) + mounted.host.rootView = harness + await settle(mounted.host, passes: 24) + + let table = try #require(transcriptTable(in: mounted.host.view)) + #expect(callbacks.semanticHeadCount == 2) + #expect(state.value == .detached(anchorID: authoritativeRows[0].id, offset: 0, unseenCount: 0)) + #expect(abs(table.contentOffset.y + table.adjustedContentInset.top) <= 1.5) + } + + @Test("opaque page boundaries and reset generations permit edge retries") + func opaquePageBoundariesAndResetPermitRetries() async throws { + let rows = (0..<12).map { TranscriptTestRow(id: $0, text: "Response \($0)") } + let callbacks = TranscriptCallbackBox() + let state = TranscriptFollowStateBox(.detached(anchorID: 0, offset: 0, unseenCount: 0)) + var harness = TranscriptTestHarness( + rows: rows, + hasMoreAfter: true, + afterPageID: "opaque-page-a", + followState: state, + onLoadAfter: { callbacks.loadAfterCount += 1 } + ) + let mounted = mount(harness) + defer { mounted.window.isHidden = true } + await settle(mounted.host, passes: 16) + + let table = try #require(transcriptTable(in: mounted.host.view)) + table.setContentOffset(CGPoint(x: 0, y: maximumOffset(in: table)), animated: false) + table.delegate?.scrollViewDidScroll?(table) + #expect(callbacks.loadAfterCount == 1) + #expect(isDetached(state.value)) + + harness = TranscriptTestHarness( + rows: rows, + hasMoreAfter: true, + afterPageID: "opaque-page-b", + followState: state, + onLoadAfter: { callbacks.loadAfterCount += 1 } + ) + mounted.host.rootView = harness + await settle(mounted.host, passes: 10) + #expect(callbacks.loadAfterCount == 2) + + harness = TranscriptTestHarness( + rows: rows, + hasMoreAfter: true, + afterPageID: "opaque-page-b", + prefetchResetGeneration: 1, + followState: state, + onLoadAfter: { callbacks.loadAfterCount += 1 } + ) + mounted.host.rootView = harness + await settle(mounted.host, passes: 8) + table.delegate?.scrollViewDidScroll?(table) + #expect(callbacks.loadAfterCount == 3) + } + + @Test("top prefetch uses opaque page boundaries and reset retries") + func topPrefetchUsesOpaquePageBoundariesAndResetRetries() async throws { + let rows = (80..<160).map { TranscriptTestRow(id: $0, text: "Response \($0)") } + let callbacks = TranscriptCallbackBox() + let state = TranscriptFollowStateBox(.detached(anchorID: 100, offset: 0, unseenCount: 0)) + var harness = TranscriptTestHarness( + rows: rows, + hasMoreBefore: true, + beforePageID: "older-page-a", + followState: state, + onLoadBefore: { callbacks.loadBeforeCount += 1 } + ) + let mounted = mount(harness) + defer { mounted.window.isHidden = true } + await settle(mounted.host, passes: 16) + + let table = try #require(transcriptTable(in: mounted.host.view)) + table.setContentOffset(CGPoint(x: 0, y: -table.adjustedContentInset.top), animated: false) + table.delegate?.scrollViewDidScroll?(table) + #expect(callbacks.loadBeforeCount == 1) + #expect(isDetached(state.value)) + + table.delegate?.scrollViewDidScroll?(table) + #expect(callbacks.loadBeforeCount == 1) + + harness = TranscriptTestHarness( + rows: rows, + hasMoreBefore: true, + beforePageID: "older-page-b", + followState: state, + onLoadBefore: { callbacks.loadBeforeCount += 1 } + ) + mounted.host.rootView = harness + await settle(mounted.host, passes: 10) + #expect(callbacks.loadBeforeCount == 2) + + harness = TranscriptTestHarness( + rows: rows, + hasMoreBefore: true, + beforePageID: "older-page-b", + prefetchResetGeneration: 1, + followState: state, + onLoadBefore: { callbacks.loadBeforeCount += 1 } + ) + mounted.host.rootView = harness + await settle(mounted.host, passes: 8) + table.delegate?.scrollViewDidScroll?(table) + #expect(callbacks.loadBeforeCount == 3) + } + + @Test("semantic tail loads the authoritative window then reaches its real bottom") + func semanticTailLoadsAuthoritativeWindowAndReachesBottom() async throws { + let state = TranscriptFollowStateBox(.detached(anchorID: 40, offset: 0, unseenCount: 0)) + let callbacks = TranscriptCallbackBox() + var harness = TranscriptTestHarness( + rows: (0..<80).map { TranscriptTestRow(id: $0, text: "Loaded response \($0)") }, + hasMoreAfter: true, + followState: state, + command: ConversationScrollCommand(generation: 1, target: .tail, animated: false), + onSemanticTail: { callbacks.semanticTailCount += 1 } + ) + let mounted = mount(harness) + defer { mounted.window.isHidden = true } + await settle(mounted.host, passes: 20) + + let table = try #require(transcriptTable(in: mounted.host.view)) + #expect(callbacks.semanticTailCount == 1) + #expect(state.value == .jumpingToTail) + + let authoritativeRows = (9_920..<10_000).map { + TranscriptTestRow(id: $0, text: "Authoritative tail response \($0)") + } + harness = TranscriptTestHarness( + rows: authoritativeRows, + hasMoreAfter: false, + followState: state, + command: ConversationScrollCommand(generation: 1, target: .tail, animated: false), + onSemanticTail: { callbacks.semanticTailCount += 1 } + ) + mounted.host.rootView = harness + await settle(mounted.host, passes: 24) + + let updatedTable = try #require(transcriptTable(in: mounted.host.view)) + #expect(updatedTable === table) + #expect(callbacks.semanticTailCount == 1) + #expect(state.value == .followingTail) + #expect(distanceFromTail(in: updatedTable) <= 1.5) + let visibleRows = try #require(updatedTable.indexPathsForVisibleRows) + #expect(visibleRows.contains(IndexPath(row: authoritativeRows.count - 1, section: 0))) + } + + @Test("semantic tail command advances across multiple page boundaries") + func semanticTailCommandAdvancesAcrossMultiplePageBoundaries() async throws { + let state = TranscriptFollowStateBox(.detached(anchorID: 40, offset: 0, unseenCount: 0)) + let callbacks = TranscriptCallbackBox() + var harness = TranscriptTestHarness( + rows: (0..<80).map { TranscriptTestRow(id: $0, text: "Loaded response \($0)") }, + hasMoreAfter: true, + afterPageID: "newer-a", + followState: state, + command: ConversationScrollCommand(generation: 1, target: .tail, animated: false), + onSemanticTail: { callbacks.semanticTailCount += 1 } + ) + let mounted = mount(harness) + defer { mounted.window.isHidden = true } + await settle(mounted.host, passes: 20) + + #expect(callbacks.semanticTailCount == 1) + #expect(state.value == .jumpingToTail) + + harness = TranscriptTestHarness( + rows: (80..<160).map { TranscriptTestRow(id: $0, text: "Loaded response \($0)") }, + hasMoreAfter: true, + afterPageID: "newer-b", + followState: state, + command: ConversationScrollCommand(generation: 1, target: .tail, animated: false), + onSemanticTail: { callbacks.semanticTailCount += 1 } + ) + mounted.host.rootView = harness + await settle(mounted.host, passes: 20) + + #expect(callbacks.semanticTailCount == 2) + #expect(state.value == .jumpingToTail) + + mounted.host.rootView = harness + await settle(mounted.host, passes: 8) + #expect(callbacks.semanticTailCount == 2) + + let authoritativeRows = (160..<240).map { + TranscriptTestRow(id: $0, text: "Authoritative tail response \($0)") + } + harness = TranscriptTestHarness( + rows: authoritativeRows, + hasMoreAfter: false, + followState: state, + command: ConversationScrollCommand(generation: 1, target: .tail, animated: false), + onSemanticTail: { callbacks.semanticTailCount += 1 } + ) + mounted.host.rootView = harness + await settle(mounted.host, passes: 32) + + let table = try #require(transcriptTable(in: mounted.host.view)) + #expect(callbacks.semanticTailCount == 2) + #expect(state.value == .followingTail) + #expect(distanceFromTail(in: table) <= 1.5) + } + + private func mount( + _ harness: TranscriptTestHarness, + size: CGSize = CGSize(width: 390, height: 844) + ) -> (host: UIHostingController, window: UIWindow) { + let host = UIHostingController(rootView: harness) + let window = UIWindow(frame: CGRect(origin: .zero, size: size)) + window.rootViewController = host + window.makeKeyAndVisible() + host.view.frame = window.bounds + host.view.setNeedsLayout() + host.view.layoutIfNeeded() + return (host, window) + } + + private func settle( + _ host: UIHostingController, + passes: Int + ) async { + for _ in 0.. UITableView? { + if let table = view as? UITableView, + table.accessibilityIdentifier == "NativeConversationTranscript" { + return table + } + for subview in view.subviews { + if let table = transcriptTable(in: subview) { + return table + } + } + return nil + } + + private func descendants(of type: T.Type, in view: UIView) -> [T] { + var matches = view.subviews.compactMap { $0 as? T } + for subview in view.subviews { + matches.append(contentsOf: descendants(of: type, in: subview)) + } + return matches + } + + private func descendantFrameUnion(of view: UIView, in coordinateSpace: UIView) -> CGRect { + var union = CGRect.null + for subview in view.subviews { + union = union.union(subview.convert(subview.bounds, to: coordinateSpace)) + union = union.union(descendantFrameUnion(of: subview, in: coordinateSpace)) + } + return union.isNull ? view.convert(view.bounds, to: coordinateSpace) : union + } + + private func firstVisibleAnchor( + in table: UITableView, + rows: [TranscriptTestRow] + ) -> (id: Int, offset: CGFloat)? { + guard let indexPath = table.indexPathsForVisibleRows?.min(), + rows.indices.contains(indexPath.row) + else { return nil } + return ( + rows[indexPath.row].id, + table.contentOffset.y - table.rectForRow(at: indexPath).minY + ) + } + + private func maximumOffset(in table: UITableView) -> CGFloat { + max( + -table.adjustedContentInset.top, + table.contentSize.height - table.bounds.height + table.adjustedContentInset.bottom + ) + } + + private func distanceFromTail(in table: UITableView) -> CGFloat { + max(0, maximumOffset(in: table) - table.contentOffset.y) + } + + private func chatSnapshot(id: Int, role: ChatRole, text: String) -> ChatMessageRowSnapshot { + ChatMessageRowSnapshot( + message: ChatMessage( + id: "chat-\(id)", + seq: id, + role: role, + timestamp: Date(timeIntervalSince1970: TimeInterval(id)), + kind: .prose(ChatProse(text: text)) + ), + groupPosition: .solo, + showsTimestamp: false + ) + } + + private func isDetached(_ state: ConversationFollowState) -> Bool { + if case .detached = state { return true } + return false + } +} + +private struct TranscriptTestRow: Identifiable, Equatable, Sendable { + enum Content: Equatable, Sendable { + case text(String) + case message(ChatMessageRowSnapshot) + case attachment(ChatAttachment) + case stateProbe + } + + let id: Int + let content: Content + + init(id: Int, text: String) { + self.id = id + content = .text(text) + } + + init(id: Int, snapshot: ChatMessageRowSnapshot) { + self.id = id + content = .message(snapshot) + } + + init(id: Int, attachment: ChatAttachment) { + self.id = id + content = .attachment(attachment) + } + + init(id: Int, stateProbe: Bool) { + self.id = id + content = .stateProbe + } +} + +@MainActor +private final class TranscriptFollowStateBox { + var value: ConversationFollowState + + init(_ value: ConversationFollowState) { + self.value = value + } +} + +@MainActor +private final class TranscriptCallbackBox { + var semanticHeadCount = 0 + var semanticTailCount = 0 + var loadBeforeCount = 0 + var loadAfterCount = 0 +} + +@MainActor +private struct TranscriptTestHarness: View { + let rows: [TranscriptTestRow] + var hasMoreBefore = false + var hasMoreAfter = false + var isActive = true + var beforePageID: String? + var afterPageID: String? + var prefetchResetGeneration = 0 + var followState: TranscriptFollowStateBox + var command: ConversationScrollCommand? + var onLoadBefore: () -> Void + var onLoadAfter: () -> Void + var onSemanticHead: () -> Void + var onSemanticTail: () -> Void + + init( + rows: [TranscriptTestRow], + hasMoreBefore: Bool = false, + hasMoreAfter: Bool = false, + isActive: Bool = true, + beforePageID: String? = nil, + afterPageID: String? = nil, + prefetchResetGeneration: Int = 0, + followState: TranscriptFollowStateBox = TranscriptFollowStateBox(.detached( + anchorID: nil, + offset: 0, + unseenCount: 0 + )), + command: ConversationScrollCommand? = nil, + onLoadBefore: @escaping () -> Void = {}, + onLoadAfter: @escaping () -> Void = {}, + onSemanticHead: @escaping () -> Void = {}, + onSemanticTail: @escaping () -> Void = {} + ) { + self.rows = rows + self.hasMoreBefore = hasMoreBefore + self.hasMoreAfter = hasMoreAfter + self.isActive = isActive + self.beforePageID = beforePageID + self.afterPageID = afterPageID + self.prefetchResetGeneration = prefetchResetGeneration + self.followState = followState + self.command = command + self.onLoadBefore = onLoadBefore + self.onLoadAfter = onLoadAfter + self.onSemanticHead = onSemanticHead + self.onSemanticTail = onSemanticTail + } + + var body: some View { + NativeConversationTranscript( + rows: rows, + hasMoreBefore: hasMoreBefore, + hasMoreAfter: hasMoreAfter, + followState: Binding( + get: { followState.value }, + set: { followState.value = $0 } + ), + command: command, + isActive: isActive, + beforePageID: beforePageID, + afterPageID: afterPageID, + prefetchResetGeneration: prefetchResetGeneration, + onLoadBefore: onLoadBefore, + onLoadAfter: onLoadAfter, + onSemanticHead: onSemanticHead, + onSemanticTail: onSemanticTail + ) { row in + switch row.content { + case .text(let text): + Text(text) + .font(.system(size: 16)) + .foregroundStyle(Color.primary) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 16) + .padding(.vertical, 10) + case .message(let snapshot): + ChatMessageRowView( + snapshot: snapshot, + actions: ChatRowActions() + ) + .padding(.horizontal, 16) + .padding(.vertical, 10) + case .attachment(let attachment): + ChatAttachmentBubbleView( + attachment: attachment, + groupPosition: .solo, + showsTimestamp: false, + timestamp: Date(timeIntervalSince1970: 0) + ) + .padding(.horizontal, 16) + .padding(.vertical, 10) + case .stateProbe: + TranscriptStateProbeRowView(rowID: row.id) + } + } + } +} + +private struct TranscriptStateProbeRowView: View { + let rowID: Int + @State private var stateOwnerRowID: Int? + + var body: some View { + Text(verbatim: "State probe \(rowID)") + .font(.system(size: 16)) + .foregroundStyle(Color.primary) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: stateLeaked ? 240 : 36) + .padding(.horizontal, 16) + .onAppear { + if stateOwnerRowID == nil { + stateOwnerRowID = rowID + } + } + } + + private var stateLeaked: Bool { + guard let stateOwnerRowID else { return false } + return stateOwnerRowID != rowID + } +} +#endif diff --git a/Packages/iOS/CmuxAgentGUIUI/Package.resolved b/Packages/iOS/CmuxAgentGUIUI/Package.resolved new file mode 100644 index 000000000000..e7d7132e5f2b --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "e8e77486c9fa5dba9e3ddc8c74d368d08366508e9c11698248a5e0070f2b130e", + "pins" : [ + { + "identity" : "highlightr", + "kind" : "remoteSourceControl", + "location" : "https://github.com/raspu/Highlightr.git", + "state" : { + "revision" : "05e7fcc63b33925cd0c1faaa205cdd5681e7bbef", + "version" : "2.3.0" + } + } + ], + "version" : 3 +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Package.swift b/Packages/iOS/CmuxAgentGUIUI/Package.swift new file mode 100644 index 000000000000..4a12abd0692f --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Package.swift @@ -0,0 +1,81 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "CmuxAgentGUIUI", + platforms: [ + .iOS(.v18), + .macOS(.v14), + ], + products: [ + .library( + name: "CmuxAgentGUIProjection", + targets: ["CmuxAgentGUIProjection"] + ), + .library( + name: "CmuxAgentGUIUI", + targets: ["CmuxAgentGUIUI"] + ), + ], + dependencies: [ + .package(path: "../../Shared/CMUXMobileCore"), + .package(path: "../../Shared/CmuxAgentReplica"), + .package(path: "../../Shared/CmuxAgentChat"), + .package(path: "../../Shared/CmuxAgentSync"), + .package(path: "../../Shared/CmuxAgentWire"), + .package(path: "../CmuxMobileSupport"), + .package(path: "../CmuxAgentChatUI"), + ], + targets: [ + .target( + name: "CmuxAgentGUIProjection", + dependencies: [ + "CMUXMobileCore", + "CmuxAgentReplica", + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .target( + name: "CmuxAgentGUIUI", + dependencies: [ + "CMUXMobileCore", + "CmuxAgentGUIProjection", + "CmuxAgentChat", + "CmuxAgentChatUI", + "CmuxAgentReplica", + "CmuxAgentSync", + "CmuxAgentWire", + "CmuxMobileSupport", + ], + resources: [ + .process("Resources"), + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .testTarget( + name: "CmuxAgentGUIProjectionTests", + dependencies: [ + "CMUXMobileCore", + "CmuxAgentGUIProjection", + "CmuxAgentGUIUI", + "CmuxAgentReplica", + "CmuxAgentSync", + "CmuxAgentWire", + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + ] +) diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/AgentGUIRGBColor.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/AgentGUIRGBColor.swift new file mode 100644 index 000000000000..ac7b7d9e11c0 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/AgentGUIRGBColor.swift @@ -0,0 +1,91 @@ +import Foundation + +/// A platform-neutral sRGB color used by the agent GUI theme projection. +public struct AgentGUIRGBColor: Hashable, Sendable { + /// Red component in the closed `0...1` range. + public let red: Double + /// Green component in the closed `0...1` range. + public let green: Double + /// Blue component in the closed `0...1` range. + public let blue: Double + + /// Creates a clamped sRGB color. + /// - Parameters: + /// - red: Red component. + /// - green: Green component. + /// - blue: Blue component. + public init(red: Double, green: Double, blue: Double) { + self.red = min(max(red, 0), 1) + self.green = min(max(green, 0), 1) + self.blue = min(max(blue, 0), 1) + } + + /// Parses a six-digit terminal-theme color. + /// - Parameter hex: A `#rrggbb` or `rrggbb` value. + public init?(hex: String) { + var value = hex + if value.hasPrefix("#") { + value.removeFirst() + } + guard value.count == 6, let raw = Int(value, radix: 16) else { + return nil + } + self.init( + red: Double((raw >> 16) & 0xff) / 255, + green: Double((raw >> 8) & 0xff) / 255, + blue: Double(raw & 0xff) / 255 + ) + } + + /// Mixes this color with another color directly in sRGB component space. + /// - Parameters: + /// - other: The second color. + /// - ownWeight: This color's weight in the closed `0...1` range. + /// - Returns: The weighted sRGB mix. + public func mixed(with other: Self, ownWeight: Double) -> Self { + let ownWeight = min(max(ownWeight, 0), 1) + let otherWeight = 1 - ownWeight + return Self( + red: red * ownWeight + other.red * otherWeight, + green: green * ownWeight + other.green * otherWeight, + blue: blue * ownWeight + other.blue * otherWeight + ) + } + + var relativeLuminance: Double { + 0.2126 * Self.linearized(red) + + 0.7152 * Self.linearized(green) + + 0.0722 * Self.linearized(blue) + } + + private static func linearized(_ component: Double) -> Double { + if component <= 0.04045 { + return component / 12.92 + } + return pow((component + 0.055) / 1.055, 2.4) + } + + var hueAndSaturation: (hue: Double, saturation: Double) { + let maximum = max(red, green, blue) + let minimum = min(red, green, blue) + let lightness = (maximum + minimum) / 2 + let delta = maximum - minimum + guard delta > 0 else { + return (0, 0) + } + let saturation = delta / (1 - abs(2 * lightness - 1)) + var hue: Double + if maximum == red { + hue = ((green - blue) / delta).truncatingRemainder(dividingBy: 6) + } else if maximum == green { + hue = (blue - red) / delta + 2 + } else { + hue = (red - green) / delta + 4 + } + hue *= 60 + if hue < 0 { + hue += 360 + } + return (hue, saturation) + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/AgentGUITheme.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/AgentGUITheme.swift new file mode 100644 index 000000000000..fe6fc74acfd4 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/AgentGUITheme.swift @@ -0,0 +1,63 @@ +public import CMUXMobileCore + +/// Agent GUI colors derived only from the resolved Ghostty terminal theme. +public struct AgentGUITheme: Hashable, Sendable { + /// Terminal background. + public let background: AgentGUIRGBColor + /// Terminal foreground. + public let foreground: AgentGUIRGBColor + /// Foreground mixed 60/40 with the background. + public let dimForeground: AgentGUIRGBColor + /// Foreground mixed 40/60 with the background. + public let faintForeground: AgentGUIRGBColor + /// Foreground mixed 5/95 with the background. + public let raisedBackground: AgentGUIRGBColor + /// Foreground mixed 8/92 with the background. + public let inputBackground: AgentGUIRGBColor + /// Foreground mixed 12/88 with the background. + public let hoverBackground: AgentGUIRGBColor + /// Foreground mixed 15/85 with the background. + public let border: AgentGUIRGBColor + /// First qualifying cool ANSI color, or the terminal foreground. + public let accent: AgentGUIRGBColor + + /// Whether adaptive system colors should use their dark-appearance values. + public var prefersDarkAppearance: Bool { + let luminance = background.relativeLuminance + let whiteContrast = 1.05 / (luminance + 0.05) + let blackContrast = (luminance + 0.05) / 0.05 + return whiteContrast > blackContrast + } + + /// Derives the GUI palette from one terminal theme value. + /// - Parameter terminalTheme: The Ghostty theme transported from the Mac. + public init(terminalTheme: TerminalTheme) { + let terminalTheme = terminalTheme.validatedOrDefault() + let background = AgentGUIRGBColor(hex: terminalTheme.background) + ?? AgentGUIRGBColor(hex: TerminalTheme.monokai.background)! + let foreground = AgentGUIRGBColor(hex: terminalTheme.foreground) + ?? AgentGUIRGBColor(hex: TerminalTheme.monokai.foreground)! + self.background = background + self.foreground = foreground + dimForeground = foreground.mixed(with: background, ownWeight: 0.60) + faintForeground = foreground.mixed(with: background, ownWeight: 0.40) + raisedBackground = foreground.mixed(with: background, ownWeight: 0.05) + inputBackground = foreground.mixed(with: background, ownWeight: 0.08) + hoverBackground = foreground.mixed(with: background, ownWeight: 0.12) + border = foreground.mixed(with: background, ownWeight: 0.15) + accent = Self.accent(palette: terminalTheme.palette, fallback: foreground) + } + + private static func accent(palette: [String], fallback: AgentGUIRGBColor) -> AgentGUIRGBColor { + for index in [4, 12, 6, 14, 5, 13, 2, 10] where palette.indices.contains(index) { + guard let candidate = AgentGUIRGBColor(hex: palette[index]) else { + continue + } + let hsl = candidate.hueAndSaturation + if hsl.saturation >= 0.22, hsl.hue >= 180, hsl.hue <= 290 { + return candidate + } + } + return fallback + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/EntryContext.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/EntryContext.swift new file mode 100644 index 000000000000..519d35c75419 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/EntryContext.swift @@ -0,0 +1,18 @@ +import CmuxAgentReplica + +struct EntryContext: Sendable { + let entry: EntrySnapshot + let tick: Int + let dayKey: String? + + var proseRole: TranscriptProseRole? { + switch entry.content.payload { + case .userMessage: + .user + case .agentProse: + .agent + case .thought, .toolRun, .fileChange, .question, .permission, .status, .attachment, .unknown: + nil + } + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptActivityItem.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptActivityItem.swift new file mode 100644 index 000000000000..4ac289617b27 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptActivityItem.swift @@ -0,0 +1,30 @@ +/// Stable compact presentation of one entry inside a turn's activity. +public import CmuxAgentReplica + +public struct TranscriptActivityItem: Hashable, Identifiable, Sendable { + /// Underlying entry identity. + public let id: TranscriptRowID + /// Semantic activity kind. + public let kind: TranscriptActivityKind + /// One-line activity detail. + public let summary: String + /// Whether this activity is still running. + public let isRunning: Bool + /// Full typed source entry retained for out-of-flow detail presentation. + public let sourceEntry: EntrySnapshot? + + /// Creates a compact activity item. + public init( + id: TranscriptRowID, + kind: TranscriptActivityKind, + summary: String, + isRunning: Bool, + sourceEntry: EntrySnapshot? = nil + ) { + self.id = id + self.kind = kind + self.summary = summary + self.isRunning = isRunning + self.sourceEntry = sourceEntry + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptActivityKind.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptActivityKind.swift new file mode 100644 index 000000000000..715e5d17a48f --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptActivityKind.swift @@ -0,0 +1,23 @@ +/// Semantic kind of one compact turn activity item. +public enum TranscriptActivityKind: Hashable, Sendable { + /// Earlier assistant prose demoted by a later answer. + case assistant + /// Agent reasoning. + case thought + /// Terminal or shell command. + case command + /// Non-terminal tool invocation. + case tool + /// File mutation. + case file + /// Agent question. + case question + /// Permission request. + case permission + /// Session status event. + case status + /// Attachment event. + case attachment + /// Future activity kind preserved verbatim. + case unknown(String) +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptActivitySummary.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptActivitySummary.swift new file mode 100644 index 000000000000..5919b4ed31b8 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptActivitySummary.swift @@ -0,0 +1,44 @@ +/// Deterministic aggregate plus ordered items for one completed turn. +public struct TranscriptActivitySummary: Hashable, Sendable { + /// Number of edit-like events. + public let editedFileCount: Int + /// Number of read-like tool events. + public let readFileCount: Int + /// Whether a search-like tool ran. + public let searchedCode: Bool + /// Whether a list-like tool ran. + public let listedFiles: Bool + /// Number of tool or command events. + public let commandCount: Int + /// Number of other non-prose events. + public let eventCount: Int + /// Ordered activity items with stable source identities. + public let items: [TranscriptActivityItem] + + /// Creates a completed activity summary. + /// - Parameters: + /// - editedFileCount: Number of edit-like events. + /// - readFileCount: Number of read-like tool events. + /// - searchedCode: Whether a search-like tool ran. + /// - listedFiles: Whether a list-like tool ran. + /// - commandCount: Number of tool or command events. + /// - eventCount: Number of other non-prose events. + /// - items: Ordered stable activity items. + public init( + editedFileCount: Int, + readFileCount: Int, + searchedCode: Bool, + listedFiles: Bool, + commandCount: Int, + eventCount: Int, + items: [TranscriptActivityItem] + ) { + self.editedFileCount = editedFileCount + self.readFileCount = readFileCount + self.searchedCode = searchedCode + self.listedFiles = listedFiles + self.commandCount = commandCount + self.eventCount = eventCount + self.items = items + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptGenericActivity.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptGenericActivity.swift new file mode 100644 index 000000000000..bb69ea59d73d --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptGenericActivity.swift @@ -0,0 +1,16 @@ +/// Compact fallback presentation for rich entry kinds that are not expanded in this slice. +public struct TranscriptGenericActivity: Hashable, Sendable { + /// The short kind label shown beside the activity icon. + public let kindLabel: String + /// The one-line summary of the activity. + public let summary: String + + /// Creates a compact generic activity row payload. + /// - Parameters: + /// - kindLabel: The short kind label shown beside the activity icon. + /// - summary: The one-line summary of the activity. + public init(kindLabel: String, summary: String) { + self.kindLabel = kindLabel + self.summary = summary + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptProjection.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptProjection.swift new file mode 100644 index 000000000000..d09b80bfcf9d --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptProjection.swift @@ -0,0 +1,16 @@ +/// Rows plus the identity diff from a previous projection. +public struct TranscriptProjection: Hashable, Sendable { + /// Rows in chronological order, with the oldest visual row at index zero. + public let rows: [TranscriptRow] + /// Identity-level diff from the previous projection. + public let diff: TranscriptProjectionDiff + + /// Creates a projection result. + /// - Parameters: + /// - rows: Rows in collection-view order. + /// - diff: Identity-level diff from the previous projection. + public init(rows: [TranscriptRow], diff: TranscriptProjectionDiff) { + self.rows = rows + self.diff = diff + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptProjectionDiff.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptProjectionDiff.swift new file mode 100644 index 000000000000..d5d02f3b99d1 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptProjectionDiff.swift @@ -0,0 +1,32 @@ +/// Identity-level diff emitted by ``TranscriptProjector``. +public struct TranscriptProjectionDiff: Hashable, Sendable { + /// Inserted row identities and their indexes in the new projection. + public let inserted: [TranscriptRowID: Int] + /// Removed row identities and their indexes in the old projection. + public let removed: [TranscriptRowID: Int] + /// Moved row identities with old and new indexes. + public let moved: [TranscriptRowID: TranscriptRowMove] + /// Reconfigured row identities whose identity stayed stable but value changed. + public let updated: Set + /// Observable operation count used by incrementality tests. + public let appliedOperationCount: Int + + /// Creates a projection diff. + /// - Parameters: + /// - inserted: Inserted row identities and new indexes. + /// - removed: Removed row identities and old indexes. + /// - moved: Moved row identities and old/new indexes. + /// - updated: Reconfigured row identities. + public init( + inserted: [TranscriptRowID: Int], + removed: [TranscriptRowID: Int], + moved: [TranscriptRowID: TranscriptRowMove], + updated: Set + ) { + self.inserted = inserted + self.removed = removed + self.moved = moved + self.updated = updated + appliedOperationCount = inserted.count + removed.count + moved.count + updated.count + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptProjectionInput.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptProjectionInput.swift new file mode 100644 index 000000000000..32b0e9c20aae --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptProjectionInput.swift @@ -0,0 +1,160 @@ +public import CmuxAgentReplica + +/// Value input consumed by ``TranscriptProjector``. +public struct TranscriptProjectionInput: Sendable { + /// Whether the replica has received its first authoritative journal page. + public let hasCompletedInitialSync: Bool + /// Loaded entries in ascending journal sequence order. + public let entries: [EntrySnapshot] + /// Known holes in the local entry window. + public let holes: [EntryRange] + /// Whether older history exists on the Mac before the retained window. + public let hasMoreBefore: Bool + /// Whether newer history exists on the Mac after the retained window. + public let hasMoreAfter: Bool + /// Opaque boundary for the next older page request. + public let startCursor: JournalCursor? + /// Opaque boundary for the next newer page request. + public let endCursor: JournalCursor? + /// FIFO local send tickets. + public let sendTickets: [SendTicket] + /// Pending asks in stable order. + public let asks: [PendingAsk] + /// The optional streaming tail preview. + public let streamingTail: TranscriptStreamingTail? + /// Current session phase used to keep live activity unfolded. + public let sessionPhase: SessionPhase + /// The read pointer sequence. + public let unreadPointer: EntrySeq + /// Maps an entry to a deterministic display tick. + public let displayTick: @Sendable (EntrySnapshot) -> Int + /// Maps a display tick to a display day key. + public let dayKey: @Sendable (Int) -> String? + + /// Whether the projector has any durable or transient row content to display. + public var hasVisibleContent: Bool { + entries.contains { !$0.content.payload.isTranscriptInternal } + || hasMoreBefore + || !holes.isEmpty + || sendTickets.contains { ticket in + if case .echoed = ticket.state { return false } + return true + } + || asks.contains { $0.state == .active } + || streamingTail?.textTail.isEmpty == false + } + + /// Whether the transcript has actual row content beyond a remote history boundary. + public var hasRenderableTranscriptContent: Bool { + entries.contains { !$0.content.payload.isTranscriptInternal } + || !holes.isEmpty + || sendTickets.contains { ticket in + if case .echoed = ticket.state { return false } + return true + } + || asks.contains { $0.state == .active } + || streamingTail?.textTail.isEmpty == false + } + + /// Creates projection input. + /// - Parameters: + /// - hasCompletedInitialSync: Whether the first authoritative journal page arrived. + /// - entries: Loaded entries in ascending journal sequence order. + /// - holes: Known holes in the local entry window. + /// - hasMoreBefore: Whether older history exists before the retained window. + /// - hasMoreAfter: Whether newer history exists after the retained window. + /// - startCursor: Opaque oldest loaded page boundary. + /// - endCursor: Opaque newest loaded page boundary. + /// - sendTickets: FIFO local send tickets. + /// - asks: Pending asks in stable order. + /// - streamingTail: Optional streaming tail preview. + /// - sessionPhase: Current phase used to keep live activity unfolded. + /// - unreadPointer: Read pointer sequence. + /// - displayTick: Deterministic display tick provider. + /// - dayKey: Display day key provider for a tick. + public init( + hasCompletedInitialSync: Bool = false, + entries: [EntrySnapshot], + holes: [EntryRange] = [], + hasMoreBefore: Bool = false, + hasMoreAfter: Bool = false, + startCursor: JournalCursor? = nil, + endCursor: JournalCursor? = nil, + sendTickets: [SendTicket] = [], + asks: [PendingAsk] = [], + streamingTail: TranscriptStreamingTail? = nil, + sessionPhase: SessionPhase = .idle, + unreadPointer: EntrySeq = EntrySeq(rawValue: 0), + displayTick: @escaping @Sendable (EntrySnapshot) -> Int = { $0.seq.rawValue }, + dayKey: @escaping @Sendable (Int) -> String? = { _ in nil } + ) { + self.hasCompletedInitialSync = hasCompletedInitialSync + self.entries = entries.sorted { $0.seq < $1.seq } + self.holes = holes.sorted { $0.lowerBound < $1.lowerBound } + self.hasMoreBefore = hasMoreBefore + self.hasMoreAfter = hasMoreAfter + self.startCursor = startCursor + self.endCursor = endCursor + self.sendTickets = sendTickets + self.asks = asks + self.streamingTail = streamingTail + self.sessionPhase = sessionPhase + self.unreadPointer = unreadPointer + self.displayTick = displayTick + self.dayKey = dayKey + } + + /// Creates projection input from a conversation replica snapshot. + /// - Parameters: + /// - state: The replica state snapshot. + /// - hasMoreBefore: Whether older history exists before the retained window. + /// - hasMoreAfter: Whether newer history exists after the retained window. + /// - startCursor: Opaque oldest loaded page boundary. + /// - endCursor: Opaque newest loaded page boundary. + /// - streamingTail: Optional streaming tail preview. + /// - sessionPhase: Current phase used to keep live activity unfolded. + /// - displayTick: Deterministic display tick provider. + /// - dayKey: Display day key provider for a tick. + public init( + state: ConversationReplicaState, + hasMoreBefore: Bool, + hasMoreAfter: Bool = false, + startCursor: JournalCursor? = nil, + endCursor: JournalCursor? = nil, + streamingTail: TranscriptStreamingTail? = nil, + sessionPhase: SessionPhase = .idle, + displayTick: @escaping @Sendable (EntrySnapshot) -> Int = { $0.seq.rawValue }, + dayKey: @escaping @Sendable (Int) -> String? = { _ in nil } + ) { + self.init( + hasCompletedInitialSync: state.journalID != nil, + entries: state.entries, + holes: state.holes, + hasMoreBefore: hasMoreBefore, + hasMoreAfter: hasMoreAfter, + startCursor: startCursor, + endCursor: endCursor, + sendTickets: state.sendTickets, + asks: state.asks, + streamingTail: streamingTail, + sessionPhase: sessionPhase, + unreadPointer: state.readPointer, + displayTick: displayTick, + dayKey: dayKey + ) + } +} + +extension EntryPayload { + var isTranscriptInternal: Bool { + guard case .status(let status) = self else { return false } + switch status.code { + case .sessionMeta: + return true + case .other(let rawCode): + return rawCode == "stop_hook_summary" + case .compacted, .turnAborted, .apiError: + return false + } + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptProjector.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptProjector.swift new file mode 100644 index 000000000000..ecb27c203da0 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptProjector.swift @@ -0,0 +1,520 @@ +import CmuxAgentReplica +import Foundation + +/// Projects replica snapshots into immutable transcript row values. +public struct TranscriptProjector: Sendable { + /// Creates a transcript projector. + public init() {} + + /// Projects input into chronological rows and computes an identity diff. + /// - Parameters: + /// - input: Value input from a replica snapshot. + /// - previousRows: The prior projection, if any. + /// - Returns: Oldest-first rows and a row-identity diff. + public func project( + _ input: TranscriptProjectionInput, + previousRows: [TranscriptRow] = [] + ) -> TranscriptProjection { + var chronological = [TranscriptRow]() + if input.hasMoreBefore { + chronological.append(TranscriptRow(rowID: .boundary, rowKind: .boundary)) + } + + let entryContexts = input.entries.filter { entry in + !entry.content.payload.isTranscriptInternal + }.map { entry in + EntryContext( + entry: entry, + tick: input.displayTick(entry), + dayKey: input.dayKey(input.displayTick(entry)) + ) + } + var entryIndex = 0 + var lastDayKey: String? + var lastPromptSeqByJournal: [JournalID: EntrySeq] = [:] + var turn: TranscriptTurnAccumulator? + let latestTurnID = Self.latestTurnID(in: entryContexts, holes: input.holes) + for hole in input.holes { + while entryIndex < entryContexts.count, + entryContexts[entryIndex].entry.seq < hole.lowerBound { + Self.appendContext( + entryContexts[entryIndex], + input: input, + latestTurnID: latestTurnID, + lastDayKey: &lastDayKey, + lastPromptSeqByJournal: &lastPromptSeqByJournal, + turn: &turn, + rows: &chronological + ) + entryIndex += 1 + } + Self.flush( + &turn, + input: input, + latestTurnID: latestTurnID, + rows: &chronological + ) + chronological.append(TranscriptRow(rowID: .hole(hole), rowKind: .hole(range: hole))) + while entryIndex < entryContexts.count, + hole.contains(entryContexts[entryIndex].entry.seq) { + entryIndex += 1 + } + } + while entryIndex < entryContexts.count { + Self.appendContext( + entryContexts[entryIndex], + input: input, + latestTurnID: latestTurnID, + lastDayKey: &lastDayKey, + lastPromptSeqByJournal: &lastPromptSeqByJournal, + turn: &turn, + rows: &chronological + ) + entryIndex += 1 + } + Self.flush(&turn, input: input, latestTurnID: latestTurnID, rows: &chronological) + + if let tail = input.streamingTail, !tail.textTail.isEmpty { + chronological.append(TranscriptRow( + rowID: .streaming(journalID: tail.journalID, afterSeq: tail.afterSeq), + rowKind: .streaming(textTail: tail.textTail), + isUnread: true, + turnID: latestTurnID, + endsTurn: true + )) + } + for ask in input.asks where Self.isActive(ask.state) { + chronological.append(TranscriptRow( + rowID: .pendingAsk(ask.id), + rowKind: .pendingAsk(ask), + isUnread: true + )) + } + for ticket in input.sendTickets where Self.shouldProject(ticket.state) { + chronological.append(TranscriptRow( + rowID: .pendingTicket(ticket.id), + rowKind: .pendingTicket(ticket), + isUnread: true + )) + } + + let projected = Self.deduplicatedRows(chronological) + return TranscriptProjection(rows: projected, diff: Self.diff(previous: previousRows, current: projected)) + } + + private static func appendContext( + _ context: EntryContext, + input: TranscriptProjectionInput, + latestTurnID: TranscriptTurnID?, + lastDayKey: inout String?, + lastPromptSeqByJournal: inout [JournalID: EntrySeq], + turn: inout TranscriptTurnAccumulator?, + rows: inout [TranscriptRow] + ) { + if let dayKey = context.dayKey, lastDayKey != dayKey { + flush(&turn, input: input, latestTurnID: latestTurnID, rows: &rows) + rows.append(TranscriptRow( + rowID: .dateHeader(dayKey), + rowKind: .dateHeader(dayKey: dayKey) + )) + lastDayKey = dayKey + } + if case .userMessage = context.entry.content.payload { + flush(&turn, input: input, latestTurnID: latestTurnID, rows: &rows) + lastPromptSeqByJournal[context.entry.journalID] = context.entry.seq + turn = TranscriptTurnAccumulator( + id: TranscriptTurnID( + journalID: context.entry.journalID, + promptSeq: context.entry.seq, + segmentAnchorSeq: context.entry.seq + ), + user: context + ) + return + } + if turn?.id.journalID != context.entry.journalID { + flush(&turn, input: input, latestTurnID: latestTurnID, rows: &rows) + turn = TranscriptTurnAccumulator( + id: TranscriptTurnID( + journalID: context.entry.journalID, + promptSeq: lastPromptSeqByJournal[context.entry.journalID], + segmentAnchorSeq: context.entry.seq + ) + ) + } + turn?.append(context) + } + + private static func flush( + _ turn: inout TranscriptTurnAccumulator?, + input: TranscriptProjectionInput, + latestTurnID: TranscriptTurnID?, + rows: inout [TranscriptRow] + ) { + guard let current = turn else { + return + } + turn = nil + let hasStreaming = current.id == latestTurnID && input.streamingTail?.textTail.isEmpty == false + var turnRows = [TranscriptRow]() + if let user = current.user, case .userMessage(let payload) = user.entry.content.payload { + turnRows.append(entryRow( + user, + kind: .proseUser(text: payload.text, ticketState: nil, grouping: .single), + turnID: current.id, + unreadPointer: input.unreadPointer + )) + } + var activityRun = [EntryContext]() + var emittedActivitySummary = false + func flushActivityRun() { + guard let anchor = activityRun.first else { return } + let items = activityRun.map(activityItem) + turnRows.append(TranscriptRow( + rowID: emittedActivitySummary + ? .activitySegment(turnID: current.id, anchorSeq: anchor.entry.seq) + : .activitySummary(current.id), + rowKind: .activitySummary(activitySummary(items: items)), + isUnread: activityRun.contains { $0.entry.seq > input.unreadPointer }, + turnID: current.id + )) + emittedActivitySummary = true + activityRun.removeAll(keepingCapacity: true) + } + for event in current.events { + switch event.entry.content.payload { + case .agentProse(let payload): + flushActivityRun() + turnRows.append(entryRow( + event, + kind: .proseAgent(text: payload.markdown, grouping: .single), + turnID: current.id, + unreadPointer: input.unreadPointer + )) + case .attachment(let payload): + flushActivityRun() + turnRows.append(entryRow( + event, + kind: .attachment(payload), + turnID: current.id, + unreadPointer: input.unreadPointer + )) + case .toolRun(let payload): + let previews = Self.previewAttachments(in: payload) + guard !previews.isEmpty else { + activityRun.append(event) + break + } + flushActivityRun() + turnRows.append(contentsOf: previews.enumerated().map { index, attachment in + Self.previewAttachmentRow( + event, + attachment: attachment, + index: index, + turnID: current.id, + unreadPointer: input.unreadPointer + ) + }) + default: + activityRun.append(event) + } + } + flushActivityRun() + if !hasStreaming, let last = turnRows.popLast() { + turnRows.append(TranscriptRow( + rowID: last.rowID, + rowKind: last.rowKind, + isUnread: last.isUnread, + turnID: last.turnID, + endsTurn: true, + sourceEntry: last.sourceEntry, + displayTick: last.displayTick + )) + } + rows.append(contentsOf: turnRows) + } + + private static func latestTurnID( + in contexts: [EntryContext], + holes: [EntryRange] + ) -> TranscriptTurnID? { + var entryIndex = 0 + var lastDayKey: String? + var lastPromptSeqByJournal: [JournalID: EntrySeq] = [:] + var currentTurnID: TranscriptTurnID? + var latestSeenTurnID: TranscriptTurnID? + + func appendIdentity(_ context: EntryContext) { + if let dayKey = context.dayKey, lastDayKey != dayKey { + currentTurnID = nil + lastDayKey = dayKey + } + if case .userMessage = context.entry.content.payload { + lastPromptSeqByJournal[context.entry.journalID] = context.entry.seq + currentTurnID = TranscriptTurnID( + journalID: context.entry.journalID, + promptSeq: context.entry.seq, + segmentAnchorSeq: context.entry.seq + ) + } else if currentTurnID?.journalID != context.entry.journalID { + currentTurnID = TranscriptTurnID( + journalID: context.entry.journalID, + promptSeq: lastPromptSeqByJournal[context.entry.journalID], + segmentAnchorSeq: context.entry.seq + ) + } + latestSeenTurnID = currentTurnID + } + + for hole in holes { + while entryIndex < contexts.count, + contexts[entryIndex].entry.seq < hole.lowerBound { + appendIdentity(contexts[entryIndex]) + entryIndex += 1 + } + currentTurnID = nil + while entryIndex < contexts.count, + hole.contains(contexts[entryIndex].entry.seq) { + entryIndex += 1 + } + } + while entryIndex < contexts.count { + appendIdentity(contexts[entryIndex]) + entryIndex += 1 + } + return latestSeenTurnID + } + + private static func entryRow( + _ context: EntryContext, + kind: TranscriptRowKind, + turnID: TranscriptTurnID, + unreadPointer: EntrySeq + ) -> TranscriptRow { + TranscriptRow( + rowID: .entry(journalID: context.entry.journalID, seq: context.entry.seq), + rowKind: kind, + isUnread: context.entry.seq > unreadPointer, + turnID: turnID, + sourceEntry: context.entry, + displayTick: context.tick + ) + } + + private static func previewAttachmentRow( + _ context: EntryContext, + attachment: AttachmentPayload, + index: Int, + turnID: TranscriptTurnID, + unreadPointer: EntrySeq + ) -> TranscriptRow { + TranscriptRow( + rowID: .entryAttachment(journalID: context.entry.journalID, seq: context.entry.seq, index: index), + rowKind: .attachment(attachment), + isUnread: context.entry.seq > unreadPointer, + turnID: turnID, + sourceEntry: context.entry, + displayTick: context.tick + ) + } + + private static func previewAttachments(in payload: ToolRunPayload) -> [AttachmentPayload] { + payload.previewAttachments?.filter(isImageAttachment) ?? [] + } + + private static func isImageAttachment(_ attachment: AttachmentPayload) -> Bool { + if attachment.mimeType?.lowercased().hasPrefix("image/") == true { + return true + } + if attachment.kind.lowercased().contains("image") { + return true + } + if (attachment.width ?? 0) > 0, (attachment.height ?? 0) > 0 { + return true + } + guard let path = attachment.hostPath ?? attachment.displayName else { + return false + } + switch URL(fileURLWithPath: path).pathExtension.lowercased() { + case "apng", "avif", "bmp", "gif", "heic", "heif", "jpeg", "jpg", "png", "svg", "tif", "tiff", "webp": + return true + default: + return false + } + } + + private static func activityItem(_ context: EntryContext) -> TranscriptActivityItem { + let entry = context.entry + let id = TranscriptRowID.entry(journalID: entry.journalID, seq: entry.seq) + return switch entry.content.payload { + case .userMessage(let payload): + TranscriptActivityItem(id: id, kind: .unknown("user"), summary: payload.text, isRunning: false, sourceEntry: entry) + case .agentProse(let payload): + TranscriptActivityItem(id: id, kind: .assistant, summary: payload.markdown, isRunning: false, sourceEntry: entry) + case .thought(let payload): + TranscriptActivityItem(id: id, kind: .thought, summary: payload.text, isRunning: false, sourceEntry: entry) + case .toolRun(let payload): + TranscriptActivityItem( + id: id, + kind: payload.isTerminal ? .command : .tool, + summary: joined([payload.toolName, payload.argumentSummary, payload.resultSummary]), + isRunning: payload.isRunning, + sourceEntry: entry + ) + case .fileChange(let payload): + TranscriptActivityItem( + id: id, + kind: .file, + summary: joined([payload.changeKind.rawValue, payload.path, payload.resultSummary]), + isRunning: false, + sourceEntry: entry + ) + case .question(let payload): + TranscriptActivityItem(id: id, kind: .question, summary: payload.prompt, isRunning: false, sourceEntry: entry) + case .permission(let payload): + TranscriptActivityItem( + id: id, + kind: .permission, + summary: joined([payload.toolName, payload.detail]), + isRunning: false, + sourceEntry: entry + ) + case .status(let payload): + TranscriptActivityItem(id: id, kind: .status, summary: joined([payload.code.rawValue, payload.detail]), isRunning: false, sourceEntry: entry) + case .attachment(let payload): + TranscriptActivityItem(id: id, kind: .attachment, summary: joined([payload.kind, payload.summary]), isRunning: false, sourceEntry: entry) + case .unknown(let payload): + TranscriptActivityItem( + id: id, + kind: .unknown(payload.rawKind), + summary: payload.summary ?? payload.rawKind, + isRunning: false, + sourceEntry: entry + ) + } + } + + private static func activitySummary(items: [TranscriptActivityItem]) -> TranscriptActivitySummary { + var fileEditCount = 0 + var toolEditCount = 0 + var readFileCount = 0 + var searchedCode = false + var listedFiles = false + var commandCount = 0 + var eventCount = 0 + for item in items { + switch item.kind { + case .file: + fileEditCount += 1 + case .tool, .command: + commandCount += 1 + let tokens = Set(item.summary.lowercased().split { !$0.isLetter && !$0.isNumber }.map(String.init)) + if !tokens.isDisjoint(with: ["read", "cat", "sed", "nl", "open"]) { readFileCount += 1 } + if !tokens.isDisjoint(with: ["rg", "grep", "search"]) { searchedCode = true } + if !tokens.isDisjoint(with: ["ls", "find", "list"]) { listedFiles = true } + if !tokens.isDisjoint(with: ["edit", "write", "apply_patch", "patch"]) { toolEditCount += 1 } + case .assistant: + break + case .thought, .question, .permission, .status, .attachment, .unknown: + eventCount += 1 + } + } + return TranscriptActivitySummary( + editedFileCount: max(fileEditCount, toolEditCount), + readFileCount: readFileCount, + searchedCode: searchedCode, + listedFiles: listedFiles, + commandCount: commandCount, + eventCount: eventCount, + items: items + ) + } + + private static func isRunningActivity(_ context: EntryContext) -> Bool { + if case .toolRun(let payload) = context.entry.content.payload { + return payload.isRunning + } + return false + } + + private static func diff(previous: [TranscriptRow], current: [TranscriptRow]) -> TranscriptProjectionDiff { + let previousIndex = Self.firstIndexes(in: previous) + let currentIndex = Self.firstIndexes(in: current) + var inserted: [TranscriptRowID: Int] = [:] + var removed: [TranscriptRowID: Int] = [:] + var moved: [TranscriptRowID: TranscriptRowMove] = [:] + var updated = Set() + + for row in current { + let newIndex = currentIndex[row.rowID] ?? 0 + guard let oldIndex = previousIndex[row.rowID] else { + inserted[row.rowID] = newIndex + continue + } + if previous[oldIndex] != row { + updated.insert(row.rowID) + } + } + let previousCommon = Self.uniqueRowIDs(in: previous).filter { currentIndex[$0] != nil } + let currentCommon = Self.uniqueRowIDs(in: current).filter { previousIndex[$0] != nil } + if previousCommon != currentCommon { + for rowID in currentCommon where previousIndex[rowID] != currentIndex[rowID] { + moved[rowID] = TranscriptRowMove(from: previousIndex[rowID] ?? 0, to: currentIndex[rowID] ?? 0) + } + } + for row in previous where currentIndex[row.rowID] == nil { + removed[row.rowID] = previousIndex[row.rowID] ?? 0 + } + return TranscriptProjectionDiff(inserted: inserted, removed: removed, moved: moved, updated: updated) + } + + private static func deduplicatedRows(_ rows: [TranscriptRow]) -> [TranscriptRow] { + var seen = Set() + return rows.filter { seen.insert($0.rowID).inserted } + } + + private static func firstIndexes(in rows: [TranscriptRow]) -> [TranscriptRowID: Int] { + var result: [TranscriptRowID: Int] = [:] + for (index, row) in rows.enumerated() where result[row.rowID] == nil { + result[row.rowID] = index + } + return result + } + + private static func uniqueRowIDs(in rows: [TranscriptRow]) -> [TranscriptRowID] { + var seen = Set() + return rows.compactMap { seen.insert($0.rowID).inserted ? $0.rowID : nil } + } + + private static func isActive(_ state: PendingAskState) -> Bool { + switch state { + case .active: + true + case .answered, .expired, .superseded: + false + } + } + + private static func shouldProject(_ state: SendTicketState) -> Bool { + switch state { + case .echoed: + false + case .queuedLocal, .acceptedByMac, .injected, .unconfirmed, .failed: + true + } + } + + private static func joined(_ parts: [String?]) -> String { + parts.compactMap { part in + let trimmed = part?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + }.joined(separator: " · ") + } +} + +private extension EntryPayload { + var isAttachment: Bool { + if case .attachment = self { return true } + return false + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptProseGrouping.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptProseGrouping.swift new file mode 100644 index 000000000000..55037fb32e8a --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptProseGrouping.swift @@ -0,0 +1,11 @@ +/// Corner grouping metadata for adjacent prose rows from the same role. +public enum TranscriptProseGrouping: Hashable, Sendable { + /// The row is not grouped with adjacent prose rows. + case single + /// The row starts a same-role group. + case first + /// The row is between the first and last rows in a same-role group. + case middle + /// The row ends a same-role group. + case last +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptProseRole.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptProseRole.swift new file mode 100644 index 000000000000..a55bf2001482 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptProseRole.swift @@ -0,0 +1,4 @@ +enum TranscriptProseRole: Hashable, Sendable { + case agent + case user +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptRow.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptRow.swift new file mode 100644 index 000000000000..b24444d1d125 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptRow.swift @@ -0,0 +1,47 @@ +public import CmuxAgentReplica + +/// Immutable view-state for one transcript list row. +public struct TranscriptRow: Hashable, Identifiable, Sendable { + /// Stable identity used by diffable data sources. + public let rowID: TranscriptRowID + /// Renderable row payload. + public let rowKind: TranscriptRowKind + /// Whether this row is newer than the read pointer. + public let isUnread: Bool + /// Stable prompt-led turn identity, when the row belongs to a turn. + public let turnID: TranscriptTurnID? + /// Whether this row is the chronological end of its turn. + public let endsTurn: Bool + /// Full typed source entry retained for stable rendering and detail sheets. + public let sourceEntry: EntrySnapshot? + /// Deterministic display time fallback when the source has no timestamp. + public let displayTick: Int? + + /// The `Identifiable` identity. + public var id: TranscriptRowID { rowID } + + /// Creates a transcript row value. + /// - Parameters: + /// - rowID: Stable identity used by diffable data sources. + /// - rowKind: Renderable row payload. + /// - isUnread: Whether this row is newer than the read pointer. + /// - turnID: Stable prompt-led turn identity. + /// - endsTurn: Whether this row ends its turn chronologically. + public init( + rowID: TranscriptRowID, + rowKind: TranscriptRowKind, + isUnread: Bool = false, + turnID: TranscriptTurnID? = nil, + endsTurn: Bool = false, + sourceEntry: EntrySnapshot? = nil, + displayTick: Int? = nil + ) { + self.rowID = rowID + self.rowKind = rowKind + self.isUnread = isUnread + self.turnID = turnID + self.endsTurn = endsTurn + self.sourceEntry = sourceEntry + self.displayTick = displayTick + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptRowID.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptRowID.swift new file mode 100644 index 000000000000..108c64e0f094 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptRowID.swift @@ -0,0 +1,52 @@ +public import Foundation +public import CmuxAgentReplica + +/// Stable identity for one projected transcript row. +public enum TranscriptRowID: Hashable, Sendable, CustomStringConvertible { + /// A row derived from a journal entry. + case entry(journalID: JournalID, seq: EntrySeq) + /// A synthetic inline preview attachment derived from a journal entry. + case entryAttachment(journalID: JournalID, seq: EntrySeq, index: Int) + /// A synthetic date header for a display day. + case dateHeader(String) + /// The oldest-history boundary marker. + case boundary + /// A known gap in the local entry window. + case hole(EntryRange) + /// A pending local send ticket. + case pendingTicket(UUID) + /// A pending ask projected as compact activity. + case pendingAsk(String) + /// The single ephemeral streaming preview row. + case streaming(journalID: JournalID, afterSeq: EntrySeq) + /// The folded activity for a completed turn. + case activitySummary(TranscriptTurnID) + /// A later folded activity run separated by visible prose or attachments. + case activitySegment(turnID: TranscriptTurnID, anchorSeq: EntrySeq) + + /// A deterministic string form suitable for diff diagnostics and UI reuse identifiers. + public var description: String { + switch self { + case .entry(let journalID, let seq): + "entry:\(journalID.rawValue):\(seq.rawValue)" + case .entryAttachment(let journalID, let seq, let index): + "entry:\(journalID.rawValue):\(seq.rawValue):attachment:\(index)" + case .dateHeader(let dayKey): + "date:\(dayKey)" + case .boundary: + "boundary" + case .hole(let range): + "hole:\(range.lowerBound.rawValue)-\(range.upperBound.rawValue)" + case .pendingTicket(let id): + "ticket:\(id.uuidString)" + case .pendingAsk(let id): + "ask:\(id)" + case .streaming(let journalID, let afterSeq): + "streaming:\(journalID.rawValue):\(afterSeq.rawValue)" + case .activitySummary(let turnID): + "activity:\(turnID.description)" + case .activitySegment(let turnID, let anchorSeq): + "activity:\(turnID.description):\(anchorSeq.rawValue)" + } + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptRowKind.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptRowKind.swift new file mode 100644 index 000000000000..c7eb156c4d86 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptRowKind.swift @@ -0,0 +1,33 @@ +public import CmuxAgentReplica + +/// Renderable payload for one transcript row. +public enum TranscriptRowKind: Hashable, Sendable { + /// Agent-authored prose. + case proseAgent(text: String, grouping: TranscriptProseGrouping) + /// User-authored prose. + case proseUser(text: String, ticketState: SendTicketState?, grouping: TranscriptProseGrouping) + /// Stable attachment metadata associated with a prompt. + case attachment(AttachmentPayload) + /// A transcript status event. + case status(code: StatusCode, detail: String?) + /// A display date separator. + case dateHeader(dayKey: String) + /// The oldest-history boundary marker. + case boundary + /// A gap in the retained entry window. + case hole(range: EntryRange) + /// A local send waiting for transcript confirmation. + case pendingTicket(SendTicket) + /// An active question or permission request with ordered choices. + case pendingAsk(PendingAsk) + /// The single live streaming tail preview. + case streaming(textTail: String) + /// Compact display for known rich activity kinds not expanded in this slice. + case genericActivity(TranscriptGenericActivity) + /// Folded completed activity for one turn. + case activitySummary(TranscriptActivitySummary) + /// One visible live activity item. + case activityItem(TranscriptActivityItem) + /// Fail-open display for an unrecognized payload. + case unsupported(rawKind: String, summary: String) +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptRowMove.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptRowMove.swift new file mode 100644 index 000000000000..3c67f0b13a33 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptRowMove.swift @@ -0,0 +1,16 @@ +/// Index movement for a stable row identity. +public struct TranscriptRowMove: Hashable, Sendable { + /// The index in the previous projection. + public let from: Int + /// The index in the new projection. + public let to: Int + + /// Creates row movement metadata. + /// - Parameters: + /// - from: The index in the previous projection. + /// - to: The index in the new projection. + public init(from: Int, to: Int) { + self.from = from + self.to = to + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptStreamingTail.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptStreamingTail.swift new file mode 100644 index 000000000000..5a0aa9b2df9d --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptStreamingTail.swift @@ -0,0 +1,26 @@ +public import CmuxAgentReplica + +/// Platform-neutral streaming tail input for the transcript projector. +public struct TranscriptStreamingTail: Hashable, Sendable { + /// The journal receiving the streaming preview. + public let journalID: JournalID + /// The committed tail sequence the preview follows. + public let afterSeq: EntrySeq + /// The bounded text preview. + public let textTail: String + /// The preview revision. + public let revision: Int + + /// Creates a streaming-tail preview. + /// - Parameters: + /// - journalID: The journal receiving the preview. + /// - afterSeq: The committed tail sequence the preview follows. + /// - textTail: The bounded text preview. + /// - revision: The preview revision. + public init(journalID: JournalID, afterSeq: EntrySeq, textTail: String, revision: Int) { + self.journalID = journalID + self.afterSeq = afterSeq + self.textTail = textTail + self.revision = revision + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptTurnAccumulator.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptTurnAccumulator.swift new file mode 100644 index 000000000000..46f5097e1d61 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptTurnAccumulator.swift @@ -0,0 +1,17 @@ +import CmuxAgentReplica + +struct TranscriptTurnAccumulator: Sendable { + let id: TranscriptTurnID + var user: EntryContext? + var events: [EntryContext] + + init(id: TranscriptTurnID, user: EntryContext? = nil) { + self.id = id + self.user = user + events = [] + } + + mutating func append(_ context: EntryContext) { + events.append(context) + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptTurnID.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptTurnID.swift new file mode 100644 index 000000000000..32d7888644d8 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIProjection/TranscriptTurnID.swift @@ -0,0 +1,29 @@ +public import CmuxAgentReplica + +/// Stable identity for one projected segment of a prompt-led conversation turn or prelude. +public struct TranscriptTurnID: Hashable, Sendable, CustomStringConvertible { + /// Journal containing the turn. + public let journalID: JournalID + /// User prompt sequence, or `nil` for prelude content. + public let promptSeq: EntrySeq? + /// First retained entry sequence in this projected segment. + public let segmentAnchorSeq: EntrySeq? + + /// Creates a stable turn identity. + /// - Parameters: + /// - journalID: Journal containing the turn. + /// - promptSeq: Prompt sequence, or `nil` for prelude content. + /// - segmentAnchorSeq: First retained entry sequence in the segment, when known. + public init(journalID: JournalID, promptSeq: EntrySeq?, segmentAnchorSeq: EntrySeq? = nil) { + self.journalID = journalID + self.promptSeq = promptSeq + self.segmentAnchorSeq = segmentAnchorSeq + } + + /// Deterministic diagnostic form. + public var description: String { + let prompt = promptSeq?.rawValue.description ?? "prelude" + let segment = segmentAnchorSeq?.rawValue.description ?? "unanchored" + return "turn:\(journalID.rawValue):\(prompt):segment:\(segment)" + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentAskSheet.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentAskSheet.swift new file mode 100644 index 000000000000..7479c1f8fbb8 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentAskSheet.swift @@ -0,0 +1,71 @@ +#if os(iOS) +import CmuxAgentGUIProjection +import CmuxAgentReplica +import SwiftUI + +struct AgentAskSheet: View { + let ask: PendingAsk + let theme: AgentGUITheme + let canAnswer: Bool + let isAnswering: Bool + let errorMessage: String? + let onAnswer: (Int) -> Void + let onShowTerminal: () -> Void + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + Text(ask.promptSummary) + .font(.body) + .textSelection(.enabled) + if !canAnswer { + Text(AgentGUIL10n.string( + "agent.ask.terminalRequired", + defaultValue: "Answer this request in Terminal." + )) + .font(.footnote) + .foregroundStyle(Color(theme.dimForeground)) + Button( + AgentGUIL10n.string("agent.ask.showTerminal", defaultValue: "Show Terminal"), + action: onShowTerminal + ) + .buttonStyle(.borderedProminent) + } else if ask.options.isEmpty { + Button( + AgentGUIL10n.string("agent.ask.showTerminal", defaultValue: "Show Terminal"), + action: onShowTerminal + ) + .buttonStyle(.borderedProminent) + } else { + ForEach(Array(ask.options.enumerated()), id: \.offset) { index, option in + Button { + onAnswer(index) + } label: { + HStack { + Text(option).frame(maxWidth: .infinity, alignment: .leading) + if isAnswering { ProgressView().controlSize(.small) } + } + } + .buttonStyle(.bordered) + .disabled(isAnswering) + .accessibilityIdentifier("AgentAskOption-\(index)") + } + } + if let errorMessage { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(.red) + } + } + .padding(24) + } + .background(Color(theme.background)) + .navigationTitle(ask.kind == .permission + ? AgentGUIL10n.string("agent.ask.permission", defaultValue: "Permission needed") + : AgentGUIL10n.string("agent.ask.question", defaultValue: "Question")) + .navigationBarTitleDisplayMode(.inline) + } + } +} +#endif diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentAskSheetPolicy.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentAskSheetPolicy.swift new file mode 100644 index 000000000000..f145676e042d --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentAskSheetPolicy.swift @@ -0,0 +1,13 @@ +import CmuxAgentReplica + +struct AgentAskSheetPolicy { + static func shouldResetError(previousAskID: String?, nextAskID: String) -> Bool { + previousAskID != nextAskID + } + + static func shouldDismiss(selectedAskID: String, asks: [PendingAsk]) -> Bool { + !asks.contains { ask in + ask.id == selectedAskID && ask.state == .active + } + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentGUIL10n.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentGUIL10n.swift new file mode 100644 index 000000000000..86a4d451cd5f --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentGUIL10n.swift @@ -0,0 +1,235 @@ +import CmuxAgentReplica +import CmuxAgentGUIProjection +import Foundation + +enum AgentGUIL10n { + static func string(_ key: StaticString, defaultValue: String.LocalizationValue) -> String { + String(localized: key, defaultValue: defaultValue, bundle: .module) + } + + static func unreadValue(_ count: Int) -> String { + if count == 1 { + return string("agent.transcript.pill.unread.one", defaultValue: "1 unread") + } + return String( + format: string("agent.transcript.pill.unread.other", defaultValue: "%d unread"), + count + ) + } + + static func rowsPerSecond(_ count: Int) -> String { + String(format: string("agent.demo.speedFormat", defaultValue: "%d rows/s"), count) + } + + static func activityKind(_ kind: String) -> String { + let trimmed = kind.trimmingCharacters(in: .whitespacesAndNewlines) + return switch trimmed.lowercased() { + case "command": string("agent.activity.command", defaultValue: "Command") + case "tool": string("agent.activity.tool", defaultValue: "Tool") + case "file": string("agent.activity.file", defaultValue: "File") + case "question": string("agent.activity.question", defaultValue: "Question") + case "permission": string("agent.activity.permission", defaultValue: "Permission") + case "thought": string("agent.activity.thought", defaultValue: "Thought") + default: trimmed.isEmpty + ? string("agent.activity.details.title", defaultValue: "Activity") + : trimmed + } + } + + static func compactActivityTitle(kindLabel: String, summary: String) -> String { + nonempty(summary) ?? knownActivityKindTitle(kindLabel) ?? activityFallbackTitle() + } + + static func compactUnsupportedTitle(rawKind: String, summary: String) -> String { + let raw = nonempty(rawKind) + if let summary = nonempty(summary), summary != raw { + return summary + } + return activityFallbackTitle() + } + + static func compactStatusTitle(code: StatusCode, detail: String?) -> String { + let title = statusCode(code) + guard let detail = nonempty(detail), detail != code.rawValue, detail != title else { + return title + } + switch code { + case .other: + return detail + case .compacted, .turnAborted, .apiError, .sessionMeta: + return [title, detail].joined(separator: " · ") + } + } + + static func activityKind(_ kind: TranscriptActivityKind) -> String { + switch kind { + case .assistant: string("agent.activity.assistant", defaultValue: "Assistant") + case .thought: string("agent.activity.thought", defaultValue: "Thought") + case .command: string("agent.activity.command", defaultValue: "Command") + case .tool: string("agent.activity.tool", defaultValue: "Tool") + case .file: string("agent.activity.file", defaultValue: "File") + case .question: string("agent.activity.question", defaultValue: "Question") + case .permission: string("agent.activity.permission", defaultValue: "Permission") + case .status: string("agent.activity.status", defaultValue: "Status") + case .attachment: string("agent.activity.attachment", defaultValue: "Attachment") + case .unknown(let rawKind): activityKind(rawKind) + } + } + + static func activityAccessibility(_ item: TranscriptActivityItem) -> String { + [activityKind(item.kind), item.summary].filter { !$0.isEmpty }.joined(separator: ", ") + } + + static func activityDetailLabel(_ label: TranscriptActivityDetailModel.Label) -> String { + switch label { + case .summary: string("agent.activity.detail.summary", defaultValue: "Summary") + case .thought: string("agent.activity.detail.thought", defaultValue: "Thought") + case .tool: string("agent.activity.detail.tool", defaultValue: "Tool") + case .arguments: string("agent.activity.detail.arguments", defaultValue: "Arguments") + case .command: string("agent.activity.detail.command", defaultValue: "Command") + case .result: string("agent.activity.detail.result", defaultValue: "Result") + case .output: string("agent.activity.detail.output", defaultValue: "Output") + case .status: string("agent.activity.detail.status", defaultValue: "Status") + case .duration: string("agent.activity.detail.duration", defaultValue: "Duration") + case .path: string("agent.activity.detail.path", defaultValue: "Path") + case .changes: string("agent.activity.detail.changes", defaultValue: "Changes") + case .diff: string("agent.activity.detail.diff", defaultValue: "Diff") + case .prompt: string("agent.activity.detail.prompt", defaultValue: "Prompt") + case .options: string("agent.activity.detail.options", defaultValue: "Options") + case .attachment: string("agent.activity.detail.attachment", defaultValue: "Attachment") + case .metadata: string("agent.activity.detail.metadata", defaultValue: "Metadata") + case .diagnostic: string("agent.activity.detail.diagnostic", defaultValue: "Diagnostic") + } + } + + static func activityDetailTruncated() -> String { + string( + "agent.activity.detail.truncated", + defaultValue: "Detail truncated for performance. Open Terminal for the full output." + ) + } + + static func activityDetailOmittedItems(_ count: Int) -> String { + guard count != 1 else { + return string("agent.activity.detail.omitted.one", defaultValue: "1 more activity item hidden") + } + return String( + format: string("agent.activity.detail.omitted.other", defaultValue: "%d more activity items hidden"), + count + ) + } + + static func activitySummary(_ summary: TranscriptActivitySummary) -> String { + var parts = [String]() + if summary.editedFileCount > 0 { + parts.append(activityCount( + summary.editedFileCount, + oneKey: "agent.activity.summary.edited.one", + oneDefault: "Edited 1 file", + otherKey: "agent.activity.summary.edited.other", + otherDefault: "Edited %d files" + )) + } + if summary.readFileCount > 0 { + parts.append(activityCount( + summary.readFileCount, + oneKey: "agent.activity.summary.read.one", + oneDefault: "Read 1 file", + otherKey: "agent.activity.summary.read.other", + otherDefault: "Read %d files" + )) + } + if summary.searchedCode { + parts.append(string("agent.activity.summary.searched", defaultValue: "Searched code")) + } + if summary.listedFiles { + parts.append(string("agent.activity.summary.listed", defaultValue: "Listed files")) + } + if summary.commandCount > 0 { + parts.append(activityCount( + summary.commandCount, + oneKey: "agent.activity.summary.command.one", + oneDefault: "Ran 1 command", + otherKey: "agent.activity.summary.command.other", + otherDefault: "Ran %d commands" + )) + } + if summary.commandCount == 0, summary.eventCount > 0 { + parts.append(activityCount( + summary.eventCount, + oneKey: "agent.activity.summary.event.one", + oneDefault: "Processed 1 event", + otherKey: "agent.activity.summary.event.other", + otherDefault: "Processed %d events" + )) + } + return parts.isEmpty + ? string("agent.activity.summary.none", defaultValue: "No activity") + : parts.formatted(.list(type: .and)) + } + + private static func activityCount( + _ count: Int, + oneKey: StaticString, + oneDefault: String.LocalizationValue, + otherKey: StaticString, + otherDefault: String.LocalizationValue + ) -> String { + guard count != 1 else { + return string(oneKey, defaultValue: oneDefault) + } + return String(format: string(otherKey, defaultValue: otherDefault), count) + } + + static func statusCode(_ code: StatusCode) -> String { + switch code { + case .compacted: string("agent.status.compacted", defaultValue: "Context compacted") + case .turnAborted: string("agent.status.turnAborted", defaultValue: "Turn aborted") + case .apiError: string("agent.status.apiError", defaultValue: "API error") + case .sessionMeta: string("agent.status.sessionMeta", defaultValue: "Session updated") + case .other: string("agent.activity.status", defaultValue: "Status") + } + } + + private static func knownActivityKindTitle(_ kind: String) -> String? { + let trimmed = kind.trimmingCharacters(in: .whitespacesAndNewlines) + switch trimmed.lowercased() { + case "command", "tool", "file", "question", "permission", "thought": + return activityKind(trimmed) + default: + return nil + } + } + + private static func activityFallbackTitle() -> String { + string("agent.activity.details.title", defaultValue: "Activity") + } + + private static func nonempty(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + static func hole(lowerBound: Int, upperBound: Int) -> String { + String( + format: string("agent.transcript.holeFormat", defaultValue: "Missing entries %lld-%lld"), + Int64(lowerBound), + Int64(upperBound) + ) + } + + static func agentAccessibilityLabel(_ text: String) -> String { + String( + format: string("agent.transcript.accessibility.agent", defaultValue: "Agent: %@"), + text + ) + } + + static func userAccessibilityLabel(_ text: String) -> String { + String( + format: string("agent.transcript.accessibility.user", defaultValue: "You: %@"), + text + ) + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentHistoryLoadFailurePolicy.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentHistoryLoadFailurePolicy.swift new file mode 100644 index 000000000000..f6bef997b371 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentHistoryLoadFailurePolicy.swift @@ -0,0 +1,12 @@ +import CmuxAgentGUIProjection + +enum AgentHistoryLoadFailurePolicy { + static func shouldShowBanner( + failure: AgentHistoryLoadFailure?, + input: TranscriptProjectionInput + ) -> Bool { + guard failure != nil else { return false } + return !input.hasRenderableTranscriptContent + } +} + diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentMarkdownImageReferenceParser.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentMarkdownImageReferenceParser.swift new file mode 100644 index 000000000000..6b98258a2b74 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentMarkdownImageReferenceParser.swift @@ -0,0 +1,304 @@ +import Foundation + +struct AgentMarkdownImageReference: Hashable, Sendable { + let altText: String? + let hostPath: String + let pixelWidth: Int? + let pixelHeight: Int? + let aspectRatio: Double? +} + +enum AgentMarkdownImageSegment: Hashable, Sendable { + case text(String) + case image(AgentMarkdownImageReference) +} + +enum AgentMarkdownImageReferenceParser { + static func segments(in markdown: String) -> [AgentMarkdownImageSegment] { + guard markdown.contains("]("), markdown.contains("![") else { + return [.text(markdown)] + } + let pattern = #"!\[([^\]]*)\]\(\s*(?:<([^>]+)>|([^)\s]+))(?:\s+(?:"([^"]*)"|'([^']*)'))?\s*\)"# + guard let expression = try? NSRegularExpression(pattern: pattern) else { + return [.text(markdown)] + } + let fullRange = NSRange(markdown.startIndex ..< markdown.endIndex, in: markdown) + let matches = expression.matches(in: markdown, range: fullRange) + guard !matches.isEmpty else { + return [.text(markdown)] + } + + let source = markdown as NSString + var parsed: [AgentMarkdownImageSegment] = [] + var pendingTextStart = markdown.startIndex + + for match in matches { + guard let matchRange = Range(match.range, in: markdown), + let reference = reference(from: match, source: source) else { + continue + } + appendText( + String(markdown[pendingTextStart ..< matchRange.lowerBound]), + to: &parsed + ) + parsed.append(.image(reference)) + pendingTextStart = matchRange.upperBound + } + + appendText(String(markdown[pendingTextStart...]), to: &parsed) + return parsed.isEmpty ? [.text(markdown)] : parsed + } + + private static func reference( + from match: NSTextCheckingResult, + source: NSString + ) -> AgentMarkdownImageReference? { + guard let rawPath = firstCapture(in: match, indexes: [2, 3], source: source), + let hostPath = normalizedHostPath(rawPath), + isLikelyImagePath(hostPath) else { + return nil + } + let altText = firstCapture(in: match, indexes: [1], source: source)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let title = firstCapture(in: match, indexes: [4, 5], source: source) + let dimensions = inferredDimensions(rawPath: rawPath, title: title) + let aspectRatio = inferredAspectRatio(rawPath: rawPath, title: title) + return AgentMarkdownImageReference( + altText: altText?.isEmpty == false ? altText : nil, + hostPath: hostPath, + pixelWidth: dimensions?.width, + pixelHeight: dimensions?.height, + aspectRatio: aspectRatio + ) + } + + private static func firstCapture( + in match: NSTextCheckingResult, + indexes: [Int], + source: NSString + ) -> String? { + for index in indexes where match.range(at: index).location != NSNotFound { + return source.substring(with: match.range(at: index)) + } + return nil + } + + private static func normalizedHostPath(_ rawPath: String) -> String? { + let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if let url = URL(string: trimmed), url.isFileURL { + return url.path + } + if let components = URLComponents(string: trimmed), + components.scheme == nil, + components.path.hasPrefix("/") { + return components.path + } + guard trimmed.hasPrefix("/") else { return nil } + return trimmed + } + + private static func isLikelyImagePath(_ path: String) -> Bool { + switch URL(fileURLWithPath: path).pathExtension.lowercased() { + case "apng", "avif", "bmp", "gif", "heic", "heif", "jpeg", "jpg", "png", "svg", "tif", "tiff", "webp": + return true + default: + return false + } + } + + private static func appendText(_ text: String, to segments: inout [AgentMarkdownImageSegment]) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + if case .text(let previous) = segments.last { + segments[segments.count - 1] = .text("\(previous)\n\(trimmed)") + } else { + segments.append(.text(trimmed)) + } + } + + private static func inferredDimensions( + rawPath: String, + title: String? + ) -> (width: Int, height: Int)? { + if let dimensions = title.flatMap(dimensions(in:)) { + return dimensions + } + if let dimensions = dimensionsFromQueryItems(in: rawPath) { + return dimensions + } + if let dimensions = dimensions(in: rawPath.removingPercentEncoding ?? rawPath) { + return dimensions + } + return dimensionsFromTrailingPathComponents(in: rawPath) + } + + private static func inferredAspectRatio( + rawPath: String, + title: String? + ) -> Double? { + if let aspectRatio = title.flatMap(aspectRatio(in:)) { + return aspectRatio + } + if let aspectRatio = aspectRatioFromQueryItems(in: rawPath) { + return aspectRatio + } + return aspectRatio(in: rawPath.removingPercentEncoding ?? rawPath) + } + + private static func dimensionsFromQueryItems(in rawPath: String) -> (width: Int, height: Int)? { + guard let components = URLComponents(string: rawPath), + let queryItems = components.queryItems, + !queryItems.isEmpty else { + return nil + } + var values: [String: Int] = [:] + for item in queryItems { + guard let value = item.value.flatMap(Int.init), + isValidDimension(value) else { + continue + } + values[item.name.lowercased()] = value + } + let width = values["w"] ?? values["width"] ?? values["pixel_width"] ?? values["pixelwidth"] + let height = values["h"] ?? values["height"] ?? values["pixel_height"] ?? values["pixelheight"] + guard let width, let height else { return nil } + return (width, height) + } + + private static func aspectRatioFromQueryItems(in rawPath: String) -> Double? { + guard let components = URLComponents(string: rawPath), + let queryItems = components.queryItems, + !queryItems.isEmpty else { + return nil + } + for item in queryItems where isAspectRatioKey(item.name) { + if let aspectRatio = item.value.flatMap(aspectRatioValue) { + return aspectRatio + } + } + return nil + } + + private static func dimensionsFromTrailingPathComponents(in rawPath: String) -> (width: Int, height: Int)? { + guard let components = URLComponents(string: rawPath) else { return nil } + let parts = components.path + .split(separator: "/") + .map(String.init) + .filter { !$0.isEmpty } + guard parts.count >= 2, + let width = Int(parts[parts.count - 2]), + let height = Int(parts[parts.count - 1]), + isValidDimension(width), + isValidDimension(height) else { + return nil + } + return (width, height) + } + + private static func dimensions(in text: String) -> (width: Int, height: Int)? { + let patterns = [ + #"(?i)(?:^|[^0-9])(?:w|width|pixel_width|pixelwidth)\s*[:=_-]?\s*([0-9]{1,6})[^0-9]+(?:h|height|pixel_height|pixelheight)\s*[:=_-]?\s*([0-9]{1,6})(?:[^0-9]|$)"#, + #"(?i)(?:^|[^0-9])(?:h|height|pixel_height|pixelheight)\s*[:=_-]?\s*([0-9]{1,6})[^0-9]+(?:w|width|pixel_width|pixelwidth)\s*[:=_-]?\s*([0-9]{1,6})(?:[^0-9]|$)"#, + #"(?i)(?:^|[^0-9])([0-9]{1,6})\s*(?:x|×)\s*([0-9]{1,6})(?:[^0-9]|$)"#, + ] + for (index, pattern) in patterns.enumerated() { + guard let expression = try? NSRegularExpression(pattern: pattern) else { + continue + } + let range = NSRange(text.startIndex ..< text.endIndex, in: text) + guard let match = expression.firstMatch(in: text, range: range), + match.numberOfRanges >= 3, + let first = capturedInt(in: match, at: 1, text: text), + let second = capturedInt(in: match, at: 2, text: text) else { + continue + } + let width = index == 1 ? second : first + let height = index == 1 ? first : second + guard isValidDimension(width), isValidDimension(height) else { + continue + } + return (width, height) + } + return nil + } + + private static func aspectRatio(in text: String) -> Double? { + let patterns = [ + #"(?i)(?:^|[^A-Za-z0-9])(?:aspect_ratio|aspectratio|preview_aspect_ratio|previewaspectratio|ratio)\s*[:=_-]?\s*([0-9]{1,6})\s*[:/]\s*([0-9]{1,6})(?:[^0-9]|$)"#, + #"(?i)(?:^|[^A-Za-z0-9])(?:aspect_ratio|aspectratio|preview_aspect_ratio|previewaspectratio|ratio)\s*[:=_-]?\s*([0-9]{1,6}(?:\.[0-9]{1,6})?)(?:[^0-9.]|$)"#, + ] + for (index, pattern) in patterns.enumerated() { + guard let expression = try? NSRegularExpression(pattern: pattern) else { + continue + } + let range = NSRange(text.startIndex ..< text.endIndex, in: text) + guard let match = expression.firstMatch(in: text, range: range) else { + continue + } + if index == 0, + let width = capturedDouble(in: match, at: 1, text: text), + let height = capturedDouble(in: match, at: 2, text: text) { + return normalizedAspectRatio(width / height) + } + if let value = capturedDouble(in: match, at: 1, text: text) { + return normalizedAspectRatio(value) + } + } + return nil + } + + private static func aspectRatioValue(_ rawValue: String) -> Double? { + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + if let direct = Double(value).flatMap(normalizedAspectRatio) { + return direct + } + let parts = value.split { $0 == ":" || $0 == "/" }.compactMap { Double($0) } + guard parts.count == 2, parts[1] > 0 else { return nil } + return normalizedAspectRatio(parts[0] / parts[1]) + } + + private static func capturedInt( + in match: NSTextCheckingResult, + at index: Int, + text: String + ) -> Int? { + guard match.range(at: index).location != NSNotFound, + let range = Range(match.range(at: index), in: text) else { + return nil + } + return Int(text[range]) + } + + private static func capturedDouble( + in match: NSTextCheckingResult, + at index: Int, + text: String + ) -> Double? { + guard match.range(at: index).location != NSNotFound, + let range = Range(match.range(at: index), in: text) else { + return nil + } + return Double(text[range]) + } + + private static func isAspectRatioKey(_ value: String) -> Bool { + switch value.lowercased() { + case "aspect_ratio", "aspectratio", "preview_aspect_ratio", "previewaspectratio", "ratio": + return true + default: + return false + } + } + + private static func normalizedAspectRatio(_ value: Double?) -> Double? { + guard let value, value.isFinite, value > 0, value <= 1_000 else { + return nil + } + return value + } + + private static func isValidDimension(_ value: Int) -> Bool { + value > 0 && value <= 200_000 + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentTranscriptAppearance.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentTranscriptAppearance.swift new file mode 100644 index 000000000000..cf8481c4b162 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentTranscriptAppearance.swift @@ -0,0 +1,26 @@ +#if os(iOS) +import CmuxAgentChatUI +import CmuxAgentGUIProjection +import SwiftUI + +struct AgentTranscriptAppearance { + let chatTheme: ChatTheme + let colorScheme: ColorScheme + + init(theme: AgentGUITheme, density: TranscriptDensity) { + chatTheme = ChatTheme( + accent: Color(theme.accent), + incomingBubbleFill: .clear, + terminalCardText: Color(theme.foreground), + outgoingBubbleFill: Color(theme.inputBackground), + terminalCardFill: Color(theme.raisedBackground), + hairline: Color(theme.border), + horizontalMargin: 12, + groupSpacing: density == .compact ? 8 : 12, + intraGroupSpacing: 4, + bubbleMaxWidthFraction: 0.94 + ) + colorScheme = theme.prefersDarkAppearance ? .dark : .light + } +} +#endif diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentTranscriptRenderAdapter.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentTranscriptRenderAdapter.swift new file mode 100644 index 000000000000..1402235dc362 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentTranscriptRenderAdapter.swift @@ -0,0 +1,285 @@ +import CmuxAgentChat +import CmuxAgentGUIProjection +import CmuxAgentReplica +import Foundation + +struct AgentTranscriptRenderAdapter { + func rows(from projection: [TranscriptRow]) -> [AgentTranscriptRenderRow] { + projection.flatMap(adapt) + } + + private func adapt(_ row: TranscriptRow) -> [AgentTranscriptRenderRow] { + let content: AgentTranscriptRenderRow.Content + switch row.rowKind { + case .proseAgent(let text, let grouping): + return proseRows(row: row, role: .agent, text: text, grouping: grouping) + case .proseUser(let text, _, let grouping): + return proseRows(row: row, role: .user, text: text, grouping: grouping) + case .attachment(let attachment): + content = .message(attachmentMessage(row: row, attachment: attachment)) + case .streaming(let textTail): + content = .message(message(row: row, role: .agent, text: textTail, grouping: .single)) + case .pendingTicket(let ticket): + content = .pendingTicket(ticket) + case .pendingAsk(let ask): + content = .ask(ask) + case .activitySummary(let summary): + guard let turnID = row.turnID else { return [] } + content = .activity(TranscriptActivityDetails(turnID: turnID, summary: summary)) + case .activityItem(let item): + guard let turnID = row.turnID else { return [] } + content = .activity(TranscriptActivityDetails( + turnID: turnID, + summary: TranscriptActivitySummary( + editedFileCount: item.kind == .file ? 1 : 0, + readFileCount: 0, + searchedCode: false, + listedFiles: false, + commandCount: item.kind == .tool || item.kind == .command ? 1 : 0, + eventCount: 0, + items: [item] + ) + )) + case .genericActivity(let activity): + content = .metadata(AgentGUIL10n.compactActivityTitle( + kindLabel: activity.kindLabel, + summary: activity.summary + )) + case .status(let code, let detail): + content = .metadata(AgentGUIL10n.compactStatusTitle(code: code, detail: detail)) + case .boundary: + content = .metadata(AgentGUIL10n.string( + "agent.transcript.boundary", + defaultValue: "Earlier history is on your Mac" + )) + case .hole(let range): + content = .metadata(AgentGUIL10n.hole( + lowerBound: range.lowerBound.rawValue, + upperBound: range.upperBound.rawValue + )) + case .unsupported(let rawKind, let summary): + content = .metadata(AgentGUIL10n.compactUnsupportedTitle(rawKind: rawKind, summary: summary)) + case .dateHeader: + return [] + } + return [AgentTranscriptRenderRow(id: row.rowID.description, content: content)] + } + + private func proseRows( + row: TranscriptRow, + role: ChatRole, + text: String, + grouping: TranscriptProseGrouping + ) -> [AgentTranscriptRenderRow] { + let segments = AgentMarkdownImageReferenceParser.segments(in: text) + guard segments.contains(where: { + if case .image = $0 { return true } + return false + }) else { + return [AgentTranscriptRenderRow( + id: row.rowID.description, + content: .message(message(row: row, role: role, text: text, grouping: grouping)) + )] + } + + return segments.enumerated().map { index, segment in + let isLast = index == segments.count - 1 + switch segment { + case .text(let text): + return AgentTranscriptRenderRow( + id: "\(row.rowID.description)#text-\(index)", + content: .message(message( + row: row, + role: role, + text: text, + grouping: .single, + idSuffix: "text-\(index)", + showsTimestampOverride: isLast ? nil : false + )) + ) + case .image(let reference): + return AgentTranscriptRenderRow( + id: "\(row.rowID.description)#image-\(index)", + content: .message(markdownImageMessage( + row: row, + role: role, + reference: reference, + idSuffix: "image-\(index)", + showsTimestamp: isLast && row.sourceEntry?.timestampMilliseconds != nil + )) + ) + } + } + } + + private func message( + row: TranscriptRow, + role: ChatRole, + text: String, + grouping: TranscriptProseGrouping, + idSuffix: String? = nil, + showsTimestampOverride: Bool? = nil + ) -> ChatMessageRowSnapshot { + let timestamp: Date + if let milliseconds = row.sourceEntry?.timestampMilliseconds { + timestamp = Date(timeIntervalSince1970: Double(milliseconds) / 1_000) + } else { + timestamp = Date(timeIntervalSince1970: TimeInterval(row.displayTick ?? 0) / 1_000) + } + let rowID = row.rowID.description + let messageID = idSuffix.map { "\(rowID)#\($0)" } ?? rowID + let message = ChatMessage( + id: messageID, + seq: row.sourceEntry?.seq.rawValue ?? row.displayTick ?? 0, + role: role, + timestamp: timestamp, + kind: .prose(ChatProse(text: text)) + ) + return ChatMessageRowSnapshot( + message: message, + groupPosition: groupPosition(grouping), + showsTimestamp: showsTimestampOverride + ?? (row.sourceEntry?.timestampMilliseconds != nil + && (grouping == .single || grouping == .last) + ) + ) + } + + private func groupPosition(_ grouping: TranscriptProseGrouping) -> ChatGroupPosition { + switch grouping { + case .single: .solo + case .first: .first + case .middle: .middle + case .last: .last + } + } + + private func attachmentMessage( + row: TranscriptRow, + attachment: AttachmentPayload + ) -> ChatMessageRowSnapshot { + let isImage = isImageAttachment(attachment) + let role = chatRole(from: attachment.authorRole) ?? .user + let message = ChatMessage( + id: row.rowID.description, + seq: row.sourceEntry?.seq.rawValue ?? row.displayTick ?? 0, + role: role, + timestamp: row.sourceEntry?.timestampMilliseconds.map { + Date(timeIntervalSince1970: Double($0) / 1_000) + } ?? Date(timeIntervalSince1970: 0), + kind: .attachment(ChatAttachment( + media: isImage ? .image : .file, + displayName: attachment.displayName ?? attachment.summary, + hostPath: attachment.hostPath, + mimeType: attachment.mimeType, + byteCount: attachment.byteCount, + pixelWidth: attachment.width, + pixelHeight: attachment.height, + aspectRatio: attachment.aspectRatio + )) + ) + return ChatMessageRowSnapshot( + message: message, + groupPosition: .solo, + showsTimestamp: row.sourceEntry?.timestampMilliseconds != nil + ) + } + + private func markdownImageMessage( + row: TranscriptRow, + role: ChatRole, + reference: AgentMarkdownImageReference, + idSuffix: String, + showsTimestamp: Bool + ) -> ChatMessageRowSnapshot { + let timestamp = row.sourceEntry?.timestampMilliseconds.map { + Date(timeIntervalSince1970: Double($0) / 1_000) + } ?? Date(timeIntervalSince1970: TimeInterval(row.displayTick ?? 0) / 1_000) + let rowID = row.rowID.description + let displayName = reference.altText + ?? imageDisplayName(for: reference.hostPath) + ?? AgentGUIL10n.string("agent.transcript.image", defaultValue: "Image") + let message = ChatMessage( + id: "\(rowID)#\(idSuffix)", + seq: row.sourceEntry?.seq.rawValue ?? row.displayTick ?? 0, + role: role, + timestamp: timestamp, + kind: .attachment(ChatAttachment( + media: .image, + displayName: displayName, + hostPath: reference.hostPath, + mimeType: imageMIMEType(for: reference.hostPath), + pixelWidth: reference.pixelWidth, + pixelHeight: reference.pixelHeight, + aspectRatio: reference.aspectRatio + )) + ) + return ChatMessageRowSnapshot( + message: message, + groupPosition: .solo, + showsTimestamp: showsTimestamp + ) + } + + private func chatRole(from rawRole: String?) -> ChatRole? { + guard let rawRole else { return nil } + switch rawRole.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "agent", "assistant", "model": + return .agent + case "user", "human": + return .user + case "system": + return .system + default: + return nil + } + } + + private func isImageAttachment(_ attachment: AttachmentPayload) -> Bool { + if attachment.mimeType?.lowercased().hasPrefix("image/") == true { + return true + } + if attachment.kind.lowercased().contains("image") { + return true + } + if (attachment.width ?? 0) > 0, (attachment.height ?? 0) > 0 { + return true + } + if Self.hasImageExtension(attachment.displayName) || Self.hasImageExtension(attachment.hostPath) { + return true + } + return false + } + + private static func hasImageExtension(_ value: String?) -> Bool { + guard let value, !value.isEmpty else { return false } + switch URL(fileURLWithPath: value).pathExtension.lowercased() { + case "apng", "avif", "bmp", "gif", "heic", "heif", "jpeg", "jpg", "png", "svg", "tif", "tiff", "webp": + return true + default: + return false + } + } + + private func imageDisplayName(for path: String) -> String? { + let displayName = URL(fileURLWithPath: path).lastPathComponent + return displayName.isEmpty ? nil : displayName + } + + private func imageMIMEType(for path: String) -> String? { + switch URL(fileURLWithPath: path).pathExtension.lowercased() { + case "apng": "image/apng" + case "avif": "image/avif" + case "bmp": "image/bmp" + case "gif": "image/gif" + case "heic": "image/heic" + case "heif": "image/heif" + case "jpeg", "jpg": "image/jpeg" + case "png": "image/png" + case "svg": "image/svg+xml" + case "tif", "tiff": "image/tiff" + case "webp": "image/webp" + default: nil + } + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentTranscriptRenderRow.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentTranscriptRenderRow.swift new file mode 100644 index 000000000000..8d9aea9a7fb5 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentTranscriptRenderRow.swift @@ -0,0 +1,16 @@ +import CmuxAgentChat +import CmuxAgentReplica + +struct AgentTranscriptRenderRow: Identifiable, Equatable { + let id: String + let content: Content + + enum Content: Equatable { + case message(ChatMessageRowSnapshot) + case activity(TranscriptActivityDetails) + case ask(PendingAsk) + case metadata(String) + case pendingTicket(SendTicket) + case empty(TranscriptSyncPresentation) + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentTranscriptRowSelectionResolver.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentTranscriptRowSelectionResolver.swift new file mode 100644 index 000000000000..de94db0e42b6 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentTranscriptRowSelectionResolver.swift @@ -0,0 +1,25 @@ +import CmuxAgentReplica + +struct AgentTranscriptRowSelectionResolver { + static func ask(rowID: String, rows: [AgentTranscriptRenderRow]) -> PendingAsk? { + guard case .ask(let ask)? = content(rowID: rowID, rows: rows) else { return nil } + return ask + } + + static func activity(rowID: String, rows: [AgentTranscriptRenderRow]) -> TranscriptActivityDetails? { + guard case .activity(let details)? = content(rowID: rowID, rows: rows) else { return nil } + return details + } + + static func failedTicket(rowID: String, rows: [AgentTranscriptRenderRow]) -> SendTicket? { + guard case .pendingTicket(let ticket)? = content(rowID: rowID, rows: rows) else { return nil } + return ticket + } + + private static func content( + rowID: String, + rows: [AgentTranscriptRenderRow] + ) -> AgentTranscriptRenderRow.Content? { + rows.first { $0.id == rowID }?.content + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentTranscriptRowView.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentTranscriptRowView.swift new file mode 100644 index 000000000000..c9429a8f231b --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentTranscriptRowView.swift @@ -0,0 +1,221 @@ +#if os(iOS) +import CmuxAgentChatUI +import CmuxAgentGUIProjection +import CmuxAgentReplica +import SwiftUI + +struct AgentTranscriptRowView: View { + let row: AgentTranscriptRenderRow + let theme: AgentGUITheme + let density: TranscriptDensity + let onOpenAsk: (String) -> Void + let onOpenActivity: (String) -> Void + let onOpenFailedTicket: (String) -> Void + let onRetrySync: () -> Void + let onShowTerminal: () -> Void + let onOpenArtifact: (String) -> Void + let onShowCodeBlock: (String, Int) -> Void + + var body: some View { + switch row.content { + case .message(let snapshot): + ChatMessageRowView( + snapshot: snapshot, + actions: ChatRowActions( + openTerminal: onShowTerminal, + openArtifact: onOpenArtifact, + showCodeBlockDetail: onShowCodeBlock + ) + ) + case .activity(let details): + AgentActivitySummaryRow( + details: details, + theme: theme, + density: density, + onOpen: { onOpenActivity(row.id) } + ) + case .ask(let ask): + AgentAskSummaryRow(ask: ask, theme: theme, onOpen: { onOpenAsk(row.id) }) + case .metadata(let text): + Text(text) + .font(density.metadataFont) + .foregroundStyle(Color(theme.faintForeground)) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.horizontal, 20) + .padding(.vertical, 8) + .frame(minHeight: 44) + case .pendingTicket(let ticket): + AgentPendingTicketRow( + ticket: ticket, + theme: theme, + onOpenFailure: { onOpenFailedTicket(row.id) } + ) + case .empty(let presentation): + AgentTranscriptEmptyState( + presentation: presentation, + theme: theme, + onRetry: onRetrySync, + onShowTerminal: onShowTerminal + ) + } + } +} + +struct AgentPendingTicketRow: View { + let ticket: SendTicket + let theme: AgentGUITheme + let onOpenFailure: () -> Void + + private var isFailed: Bool { + if case .failed = ticket.state { return true } + return false + } + + var body: some View { + Group { + if isFailed { + Button(action: onOpenFailure) { + content + } + .buttonStyle(.plain) + } else { + content + } + } + .accessibilityIdentifier("AgentPendingTicket") + } + + private var content: some View { + HStack(spacing: 10) { + if isFailed { + Image(systemName: "exclamationmark.circle.fill").foregroundStyle(.red) + } else { + ProgressView().controlSize(.mini) + } + Text(ticket.text) + .font(.subheadline) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 8) + Text(isFailed + ? AgentGUIL10n.string("agent.send.failed", defaultValue: "Send failed") + : AgentGUIL10n.string("agent.send.pending", defaultValue: "Sending")) + .font(.caption) + .foregroundStyle(Color(theme.faintForeground)) + } + .foregroundStyle(Color(theme.foreground)) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .frame(minHeight: 52) + .background(Color(theme.raisedBackground), in: RoundedRectangle(cornerRadius: 14)) + .padding(.horizontal, 18) + } +} + +struct AgentAskSummaryRow: View { + let ask: PendingAsk + let theme: AgentGUITheme + let onOpen: () -> Void + + var body: some View { + Button(action: onOpen) { + HStack(spacing: 10) { + Image(systemName: ask.kind == .permission ? "hand.raised.fill" : "questionmark.circle.fill") + .foregroundStyle(Color(theme.accent)) + VStack(alignment: .leading, spacing: 2) { + Text(ask.kind == .permission + ? AgentGUIL10n.string("agent.ask.permission", defaultValue: "Permission needed") + : AgentGUIL10n.string("agent.ask.question", defaultValue: "Question")) + .font(.caption.weight(.semibold)) + Text(ask.promptSummary) + .font(.subheadline) + .lineLimit(2) + .truncationMode(.tail) + } + .frame(maxWidth: .infinity, alignment: .leading) + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(Color(theme.faintForeground)) + } + .foregroundStyle(Color(theme.foreground)) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .frame(minHeight: 60) + .background(Color(theme.hoverBackground).opacity(0.55), in: RoundedRectangle(cornerRadius: 14)) + .padding(.horizontal, 18) + } + .buttonStyle(.plain) + .accessibilityIdentifier("AgentAskSummary") + .accessibilityLabel(ask.promptSummary) + } +} + +struct AgentActivitySummaryRow: View { + let details: TranscriptActivityDetails + let theme: AgentGUITheme + let density: TranscriptDensity + let onOpen: () -> Void + + var body: some View { + Button(action: onOpen) { + HStack(spacing: 8) { + if details.summary.items.contains(where: \.isRunning) { + ProgressView().controlSize(.mini) + } else { + Image(systemName: "checkmark.circle") + .foregroundStyle(Color(theme.faintForeground)) + } + Text(AgentGUIL10n.activitySummary(details.summary)) + .font(density.metadataFont) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + } + .foregroundStyle(Color(theme.faintForeground)) + .padding(.horizontal, 24) + .padding(.vertical, 8) + .frame(minHeight: 44) + } + .buttonStyle(.plain) + .accessibilityIdentifier("AgentActivitySummary") + } +} + +struct AgentTranscriptEmptyState: View { + let presentation: TranscriptSyncPresentation + let theme: AgentGUITheme + let onRetry: () -> Void + let onShowTerminal: () -> Void + + var body: some View { + VStack(spacing: 12) { + switch presentation { + case .loading: + ProgressView() + Text(AgentGUIL10n.string("agent.sync.loading", defaultValue: "Loading conversation…")) + case .error: + Text(AgentGUIL10n.string("agent.sync.failed.title", defaultValue: "Conversation unavailable")) + .font(.headline) + HStack { + Button(AgentGUIL10n.string("agent.sync.retry", defaultValue: "Retry"), action: onRetry) + Button(AgentGUIL10n.string("agent.ask.showTerminal", defaultValue: "Show Terminal"), action: onShowTerminal) + } + .buttonStyle(.bordered) + case .empty: + Text(AgentGUIL10n.string("agent.transcript.empty", defaultValue: "No messages yet. Say something.")) + case .hidden, .stale: + EmptyView() + } + } + .font(.footnote) + .foregroundStyle(Color(theme.dimForeground)) + .frame(maxWidth: .infinity, minHeight: 240) + .padding(24) + .accessibilityIdentifier("AgentTranscriptEmptyState") + } +} +#endif diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentTranscriptScrollPillLayoutPolicy.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentTranscriptScrollPillLayoutPolicy.swift new file mode 100644 index 000000000000..2ccdeaef305d --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentTranscriptScrollPillLayoutPolicy.swift @@ -0,0 +1,10 @@ +#if os(iOS) +import CoreGraphics + +enum AgentTranscriptScrollPillLayoutPolicy { + static func bottomPadding(composerBottomInset: CGFloat) -> CGFloat { + let clearance = max(0, ceil(composerBottomInset)) + return max(10, clearance + 10) + } +} +#endif diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentTranscriptTailRequestPolicy.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentTranscriptTailRequestPolicy.swift new file mode 100644 index 000000000000..db3d24cb34d9 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/AgentTranscriptTailRequestPolicy.swift @@ -0,0 +1,26 @@ +#if os(iOS) +import CmuxAgentChatUI + +enum AgentTranscriptTailRequestAction: Equatable { + case localScroll + case semanticTail +} + +struct AgentTranscriptTailRequestPolicy { + static func action(hasMoreAfter: Bool) -> AgentTranscriptTailRequestAction { + hasMoreAfter ? .semanticTail : .localScroll + } + + static func followStateBeforeCommand( + current: ConversationFollowState, + action: AgentTranscriptTailRequestAction + ) -> ConversationFollowState { + switch action { + case .localScroll: + current + case .semanticTail: + .jumpingToTail + } + } +} +#endif diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/BubbleStyle.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/BubbleStyle.swift new file mode 100644 index 000000000000..f01ca039e388 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/BubbleStyle.swift @@ -0,0 +1,44 @@ +#if os(iOS) +import CmuxAgentGUIProjection +import SwiftUI +import UIKit + +enum BubbleStyle { + case agent + case user + case pending + case streaming + + func foreground(theme: AgentGUITheme) -> Color { + switch self { + case .agent, .user, .pending, .streaming: + Color(theme.foreground) + } + } + + func background(theme: AgentGUITheme) -> Color { + switch self { + case .agent: + .clear + case .user: + Color(theme.inputBackground) + case .pending: + Color(theme.raisedBackground) + case .streaming: + .clear + } + } +} + +extension Color { + init(_ color: AgentGUIRGBColor) { + self.init(red: color.red, green: color.green, blue: color.blue) + } +} + +extension UIColor { + convenience init(_ color: AgentGUIRGBColor) { + self.init(red: CGFloat(color.red), green: CGFloat(color.green), blue: CGFloat(color.blue), alpha: 1) + } +} +#endif diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/Resources/Localizable.xcstrings new file mode 100644 index 000000000000..52ca49af9ecf --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/Resources/Localizable.xcstrings @@ -0,0 +1,1348 @@ +{ + "sourceLanguage": "en", + "strings": { + "agent.activity.assistant": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Assistant" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アシスタント" + } + } + } + }, + "agent.activity.attachment": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Attachment" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "添付ファイル" + } + } + } + }, + "agent.activity.detail.attachment": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Attachment" } }, + "ja": { "stringUnit": { "state": "translated", "value": "添付ファイル" } } + } + }, + "agent.activity.detail.arguments": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Arguments" } }, + "ja": { "stringUnit": { "state": "translated", "value": "引数" } } + } + }, + "agent.activity.detail.changes": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Changes" } }, + "ja": { "stringUnit": { "state": "translated", "value": "変更" } } + } + }, + "agent.activity.detail.command": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Command" } }, + "ja": { "stringUnit": { "state": "translated", "value": "コマンド" } } + } + }, + "agent.activity.detail.diagnostic": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Diagnostic" } }, + "ja": { "stringUnit": { "state": "translated", "value": "診断情報" } } + } + }, + "agent.activity.detail.diff": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Diff" } }, + "ja": { "stringUnit": { "state": "translated", "value": "差分" } } + } + }, + "agent.activity.detail.duration": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Duration" } }, + "ja": { "stringUnit": { "state": "translated", "value": "所要時間" } } + } + }, + "agent.activity.detail.exitCodeFormat": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Exit %d" } }, + "ja": { "stringUnit": { "state": "translated", "value": "終了コード %d" } } + } + }, + "agent.activity.detail.metadata": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Metadata" } }, + "ja": { "stringUnit": { "state": "translated", "value": "メタデータ" } } + } + }, + "agent.activity.detail.options": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Options" } }, + "ja": { "stringUnit": { "state": "translated", "value": "選択肢" } } + } + }, + "agent.activity.detail.output": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Output" } }, + "ja": { "stringUnit": { "state": "translated", "value": "出力" } } + } + }, + "agent.activity.detail.omitted.one": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "1 more activity item hidden" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ほか 1 件のアクティビティ項目を非表示" } } + } + }, + "agent.activity.detail.omitted.other": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%d more activity items hidden" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ほか %d 件のアクティビティ項目を非表示" } } + } + }, + "agent.activity.detail.path": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Path" } }, + "ja": { "stringUnit": { "state": "translated", "value": "パス" } } + } + }, + "agent.activity.detail.prompt": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Prompt" } }, + "ja": { "stringUnit": { "state": "translated", "value": "プロンプト" } } + } + }, + "agent.activity.detail.result": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Result" } }, + "ja": { "stringUnit": { "state": "translated", "value": "結果" } } + } + }, + "agent.activity.detail.summary": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Summary" } }, + "ja": { "stringUnit": { "state": "translated", "value": "概要" } } + } + }, + "agent.activity.detail.status": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Status" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ステータス" } } + } + }, + "agent.activity.detail.thought": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Thought" } }, + "ja": { "stringUnit": { "state": "translated", "value": "思考" } } + } + }, + "agent.activity.detail.truncated": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Detail truncated for performance. Open Terminal for the full output." } }, + "ja": { "stringUnit": { "state": "translated", "value": "パフォーマンス維持のため詳細を省略しました。完全な出力はターミナルで確認してください。" } } + } + }, + "agent.activity.detail.tool": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Tool" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ツール" } } + } + }, + "agent.activity.details.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Activity" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクティビティ" + } + } + } + }, + "agent.activity.command": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Command" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コマンド" + } + } + } + }, + "agent.activity.openHint": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Opens activity details" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクティビティの詳細を開きます" + } + } + } + }, + "agent.activity.file": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "File" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイル" + } + } + } + }, + "agent.activity.permission": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Permission" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "権限" + } + } + } + }, + "agent.activity.question": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Question" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "質問" + } + } + } + }, + "agent.activity.status": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Status" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "状態" + } + } + } + }, + "agent.activity.summary.command.one": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ran 1 command" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1個のコマンドを実行" + } + } + } + }, + "agent.activity.summary.command.other": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ran %d commands" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d個のコマンドを実行" + } + } + } + }, + "agent.activity.summary.edited.one": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edited 1 file" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1個のファイルを編集" + } + } + } + }, + "agent.activity.summary.edited.other": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edited %d files" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d個のファイルを編集" + } + } + } + }, + "agent.activity.summary.event.one": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Processed 1 event" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1件のイベントを処理" + } + } + } + }, + "agent.activity.summary.event.other": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Processed %d events" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d件のイベントを処理" + } + } + } + }, + "agent.activity.summary.listed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Listed files" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイル一覧を表示" + } + } + } + }, + "agent.activity.summary.none": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No activity" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクティビティなし" + } + } + } + }, + "agent.activity.summary.read.one": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read 1 file" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1個のファイルを読み取り" + } + } + } + }, + "agent.activity.summary.read.other": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Read %d files" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d個のファイルを読み取り" + } + } + } + }, + "agent.activity.summary.searched": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Searched code" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コードを検索" + } + } + } + }, + "agent.activity.thought": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Thought" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "思考" + } + } + } + }, + "agent.activity.tool": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tool" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ツール" + } + } + } + }, + "agent.ask.permission": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Permission needed" } }, + "ja": { "stringUnit": { "state": "translated", "value": "許可が必要です" } } + } + }, + "agent.ask.question": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Question" } }, + "ja": { "stringUnit": { "state": "translated", "value": "質問" } } + } + }, + "agent.ask.failed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The answer could not be sent. Try again or use Terminal." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "回答を送信できませんでした。もう一度試すか、ターミナルを使用してください。" + } + } + } + }, + "agent.ask.showTerminal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルを表示" + } + } + } + }, + "agent.ask.terminalRequired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Answer this request in Terminal." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このリクエストにはターミナルで回答してください。" + } + } + } + }, + "agent.demo.burstAppend": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Burst append five rows" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "5行を一括追加" + } + } + } + }, + "agent.demo.day.later": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Later" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "後で" + } + } + } + }, + "agent.demo.day.today": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Today" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今日" + } + } + } + }, + "agent.demo.density.comfortable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Comfortable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ゆったり" + } + } + } + }, + "agent.demo.density.compact": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Compact" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンパクト" + } + } + } + }, + "agent.demo.densityToggle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle transcript density" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トランスクリプトの密度を切り替え" + } + } + } + }, + "agent.demo.fieldPlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Demo keyboard field" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デモ用キーボード欄" + } + } + } + }, + "agent.demo.fixture.agentFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Synthetic %@ fixture row %d with enough prose to exercise realistic scroll physics." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "実際のスクロール挙動を確認するための十分な文章を含む%@フィクスチャ行%dです。" + } + } + } + }, + "agent.demo.fixture.label.burst": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "burst" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一括" + } + } + } + }, + "agent.demo.fixture.label.tall": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "tall" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "縦長" + } + } + } + }, + "agent.demo.fixture.toolName": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "fixture" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィクスチャ" + } + } + } + }, + "agent.demo.fixture.toolResult": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "fixture rows added" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィクスチャ行を追加しました" + } + } + } + }, + "agent.demo.fixture.toolSummary": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "append transcript rows" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トランスクリプト行を追加" + } + } + } + }, + "agent.demo.jump": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Jump to bottom" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一番下へ移動" + } + } + } + }, + "agent.demo.keyboard": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle keyboard" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キーボードを切り替え" + } + } + } + }, + "agent.demo.pause": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pause replay" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リプレイを一時停止" + } + } + } + }, + "agent.demo.play": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Play replay" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リプレイを再生" + } + } + } + }, + "agent.demo.speed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Speed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "速度" + } + } + } + }, + "agent.demo.speedFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%d rows/s" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d 行/秒" + } + } + } + }, + "agent.demo.streaming": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Inject streaming tick" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ストリーミングを追加" + } + } + } + }, + "agent.demo.streamingText": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Drafting the next update..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "次の更新を作成中..." + } + } + } + }, + "agent.demo.tallFixture": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tall fixture" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "縦長フィクスチャ" + } + } + } + }, + "agent.demo.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Transcript Demo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トランスクリプトデモ" + } + } + } + }, + "agent.status.apiError": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "API error" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "APIエラー" + } + } + } + }, + "agent.status.compacted": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Context compacted" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンテキストを圧縮しました" + } + } + } + }, + "agent.status.sessionMeta": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Session updated" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "セッションを更新しました" + } + } + } + }, + "agent.status.turnAborted": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Turn aborted" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターンを中止しました" + } + } + } + }, + "agent.history.load.failed": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Conversation history could not be loaded" } }, + "ja": { "stringUnit": { "state": "translated", "value": "会話履歴を読み込めませんでした" } } + } + }, + "agent.send.failed": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Send failed" } }, + "ja": { "stringUnit": { "state": "translated", "value": "送信に失敗" } } + } + }, + "agent.send.failed.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "This message was not delivered. Retry it or continue in Terminal." } }, + "ja": { "stringUnit": { "state": "translated", "value": "このメッセージは配信されませんでした。再送するか、ターミナルで続けてください。" } } + } + }, + "agent.send.pending": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Sending" } }, + "ja": { "stringUnit": { "state": "translated", "value": "送信中" } } + } + }, + "agent.send.retry": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Retry message" } }, + "ja": { "stringUnit": { "state": "translated", "value": "メッセージを再送" } } + } + }, + "agent.sync.failed.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux could not update this conversation. Retry or continue in Terminal." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmuxはこの会話を更新できませんでした。再試行するか、ターミナルで続けてください。" + } + } + } + }, + "agent.sync.failed.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Conversation unavailable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "会話を利用できません" + } + } + } + }, + "agent.sync.loading": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading conversation…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "会話を読み込み中…" + } + } + } + }, + "agent.sync.updating": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Updating conversation" } }, + "ja": { "stringUnit": { "state": "translated", "value": "会話を更新中" } } + } + }, + "agent.sync.retry": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再試行" + } + } + } + }, + "agent.sync.stale.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New activity is not available yet." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいアクティビティはまだ利用できません。" + } + } + } + }, + "agent.sync.stale.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Showing saved conversation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存済みの会話を表示中" + } + } + } + }, + "agent.transcript.accessibility.agent": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Agent: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェント: %@" + } + } + } + }, + "agent.transcript.accessibility.user": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "あなた: %@" + } + } + } + }, + "agent.transcript.boundary": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Earlier history is on your Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "以前の履歴はMacにあります" + } + } + } + }, + "agent.transcript.image": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Image" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "画像" + } + } + } + }, + "agent.transcript.empty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No messages yet. Say something." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メッセージはまだありません — 話しかけてみましょう" + } + } + } + }, + "agent.transcript.holeFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Missing entries %lld-%lld" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未取得の項目 %lld-%lld" + } + } + } + }, + "agent.transcript.pill.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Jump to latest message" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最新メッセージへ移動" + } + } + } + }, + "agent.transcript.pill.unread.one": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "1 unread" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未読1件" + } + } + } + }, + "agent.transcript.pill.unread.other": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%d unread" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未読%d件" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/Resources/demo-transcript.jsonl b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/Resources/demo-transcript.jsonl new file mode 100644 index 000000000000..4a2f704a2053 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/Resources/demo-transcript.jsonl @@ -0,0 +1,17 @@ +{"delta":{"kind":"journalReset","newJournal":"demo-journal","sessionID":"demo-session","tailSeq":0},"origin":"resync","tick":0} +{"delta":{"entries":[{"content":{"content_hash":-8363162374014101589,"payload":{"attachment_count":0,"has_image":false,"kind":"userMessage","text":"Can you summarize the changes from the latest agent run?"}},"journal_id":"demo-journal","kind":"userMessage","seq":1,"version":1}],"journalID":"demo-journal","kind":"entriesAppended"},"origin":"live","tick":1} +{"delta":{"entries":[{"content":{"content_hash":-593186496794954687,"payload":{"kind":"agentProse","markdown":"Sure. The run touched the sync engine and the transcript pipeline. The engine now retries transport failures with backoff, and queued sends survive reconnects."}},"journal_id":"demo-journal","kind":"agentProse","seq":2,"version":1}],"journalID":"demo-journal","kind":"entriesAppended"},"origin":"live","tick":2} +{"delta":{"entries":[{"content":{"content_hash":4230577364947195246,"payload":{"kind":"thought","text":"The user probably wants the file list too."}},"journal_id":"demo-journal","kind":"thought","seq":3,"version":1}],"journalID":"demo-journal","kind":"entriesAppended"},"origin":"live","tick":3} +{"delta":{"entries":[{"content":{"content_hash":6314496067244279065,"payload":{"argument_summary":"swift test --package-path Packages\/Shared\/CmuxAgentSync","exit_code":0,"is_running":false,"is_terminal":true,"kind":"toolRun","result_summary":"20 tests passed","tool_name":"Bash"}},"journal_id":"demo-journal","kind":"toolRun","seq":4,"version":1}],"journalID":"demo-journal","kind":"entriesAppended"},"origin":"live","tick":4} +{"delta":{"entries":[{"content":{"content_hash":6792917160675199043,"payload":{"change_kind":"edit","kind":"fileChange","path":"Sources\/Engine\/AgentSyncEngine.swift","result_summary":"+42 -7"}},"journal_id":"demo-journal","kind":"fileChange","seq":5,"version":1}],"journalID":"demo-journal","kind":"entriesAppended"},"origin":"live","tick":5} +{"delta":{"entries":[{"content":{"content_hash":-786678256482932432,"payload":{"kind":"agentProse","markdown":"Two files changed. The engine change is the important one; the other is a test fixture refresh."}},"journal_id":"demo-journal","kind":"agentProse","seq":6,"version":1}],"journalID":"demo-journal","kind":"entriesAppended"},"origin":"live","tick":6} +{"delta":{"entries":[{"content":{"content_hash":-1008667034778846566,"payload":{"code":"context_compacted","detail":"Context compacted","kind":"status"}},"journal_id":"demo-journal","kind":"status","seq":7,"version":1}],"journalID":"demo-journal","kind":"entriesAppended"},"origin":"live","tick":7} +{"delta":{"entries":[{"content":{"content_hash":889487317219179340,"payload":{"attachment_count":0,"has_image":false,"kind":"userMessage","text":"Great. Are there any remaining risks?"}},"journal_id":"demo-journal","kind":"userMessage","seq":8,"version":1}],"journalID":"demo-journal","kind":"entriesAppended"},"origin":"live","tick":8} +{"delta":{"entries":[{"content":{"content_hash":-4002599461756376576,"payload":{"kind":"agentProse","markdown":"One: duplicate frames after reconnect are deduplicated by sequence, but the UI must apply them idempotently. That is covered by the store's version gate."}},"journal_id":"demo-journal","kind":"agentProse","seq":9,"version":1}],"journalID":"demo-journal","kind":"entriesAppended"},"origin":"live","tick":9} +{"delta":{"entries":[{"content":{"content_hash":-6544864907927995847,"payload":{"kind":"question","options":["Yes, add it","No, later"],"prompt":"Should I add a regression test for the reconnect path?"}},"journal_id":"demo-journal","kind":"question","seq":10,"version":1}],"journalID":"demo-journal","kind":"entriesAppended"},"origin":"live","tick":10} +{"delta":{"entries":[{"content":{"content_hash":-6408430100023318367,"payload":{"attachment_count":0,"has_image":false,"kind":"userMessage","text":"Yes, add it."}},"journal_id":"demo-journal","kind":"userMessage","seq":11,"version":1}],"journalID":"demo-journal","kind":"entriesAppended"},"origin":"live","tick":11} +{"delta":{"entries":[{"content":{"content_hash":919002073821352700,"payload":{"argument_summary":"Tests\/AgentSyncEngineTests.swift","is_running":true,"is_terminal":false,"kind":"toolRun","tool_name":"Edit"}},"journal_id":"demo-journal","kind":"toolRun","seq":12,"version":1}],"journalID":"demo-journal","kind":"entriesAppended"},"origin":"live","tick":12} +{"delta":{"entries":[{"content":{"content_hash":7497594049452614929,"payload":{"kind":"agentProse","markdown":"Added a reconnect regression test. Running the suite now."}},"journal_id":"demo-journal","kind":"agentProse","seq":13,"version":1}],"journalID":"demo-journal","kind":"entriesAppended"},"origin":"live","tick":13} +{"delta":{"entries":[{"content":{"content_hash":3179750579389241605,"payload":{"argument_summary":"swift test","exit_code":0,"is_running":false,"is_terminal":true,"kind":"toolRun","result_summary":"21 tests passed","tool_name":"Bash"}},"journal_id":"demo-journal","kind":"toolRun","seq":14,"version":1}],"journalID":"demo-journal","kind":"entriesAppended"},"origin":"live","tick":14} +{"delta":{"entries":[{"content":{"content_hash":-7363439623061824436,"payload":{"kind":"agentProse","markdown":"All green. The regression test fails without the fix and passes with it."}},"journal_id":"demo-journal","kind":"agentProse","seq":15,"version":1}],"journalID":"demo-journal","kind":"entriesAppended"},"origin":"live","tick":15} +{"delta":{"entries":[{"content":{"content_hash":349947157127786484,"payload":{"kind":"future_kind","raw_json_truncated":false,"raw_kind":"future_kind","summary":"A record kind this build does not model yet"}},"journal_id":"demo-journal","kind":"future_kind","seq":16,"version":1}],"journalID":"demo-journal","kind":"entriesAppended"},"origin":"live","tick":16} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/ScrollToBottomPill.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/ScrollToBottomPill.swift new file mode 100644 index 000000000000..79dcc44e359c --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/ScrollToBottomPill.swift @@ -0,0 +1,64 @@ +#if os(iOS) +import CmuxAgentGUIProjection +import CmuxMobileSupport +import SwiftUI + +struct ScrollToBottomPill: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + let theme: AgentGUITheme + let unreadCount: Int + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + Image(systemName: "chevron.down") + if unreadCount > 0 { + Text(AgentGUIL10n.unreadValue(unreadCount)) + .font(.caption.weight(.semibold)) + .monospacedDigit() + .contentTransition(reduceMotion ? .opacity : .numericText()) + .animation( + reduceMotion ? .easeOut(duration: 0.12) : .snappy(duration: 0.22), + value: unreadCount + ) + } + } + .font(.callout.weight(.semibold)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(minHeight: 44) + } + .foregroundStyle(Color(theme.foreground)) + .background(Color(theme.raisedBackground), in: Capsule()) + .mobileGlassPill() + .accessibilityLabel(AgentGUIL10n.string( + "agent.transcript.pill.label", + defaultValue: "Jump to latest message" + )) + .accessibilityValue(unreadCount > 0 ? AgentGUIL10n.unreadValue(unreadCount) : "") + } +} + +struct AgentTranscriptScrollToBottomPillOverlay: View { + @Environment(\.chatTranscriptOverlayGeometry) private var overlayGeometry + + let theme: AgentGUITheme + let unreadCount: Int + let action: () -> Void + + var body: some View { + ScrollToBottomPill(theme: theme, unreadCount: unreadCount, action: action) + .padding(.trailing, 12) + .padding(.bottom, bottomPadding) + .excludedFromKeyboardDismiss() + } + + private var bottomPadding: CGFloat { + AgentTranscriptScrollPillLayoutPolicy.bottomPadding( + composerBottomInset: overlayGeometry?.composerBottomInset ?? 0 + ) + } +} +#endif diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptActivityDetailModel.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptActivityDetailModel.swift new file mode 100644 index 000000000000..c8645e7a0615 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptActivityDetailModel.swift @@ -0,0 +1,227 @@ +import CmuxAgentGUIProjection +import CmuxAgentReplica +import Foundation + +struct TranscriptActivityDetailModel: Equatable, Identifiable { + struct ID: Equatable, Hashable, CustomStringConvertible { + let sourceID: TranscriptRowID + let ordinal: Int + + var description: String { + "\(sourceID.description)#\(ordinal)" + } + } + + struct Section: Equatable, Identifiable { + let id: String + let label: Label + let value: String + let isCode: Bool + } + + enum Label: String, Equatable { + case summary + case thought + case tool + case arguments + case command + case result + case output + case status + case duration + case path + case changes + case diff + case prompt + case options + case attachment + case metadata + case diagnostic + } + + let id: ID + let sourceID: TranscriptRowID + let kind: TranscriptActivityKind + let title: String + let sections: [Section] + + private static let maximumTitleCharacters = 180 + private static let maximumSectionCharacters = 40_000 + + init(item: TranscriptActivityItem, ordinal: Int = 0) { + id = ID(sourceID: item.id, ordinal: ordinal) + sourceID = item.id + kind = item.kind + title = Self.title(for: item) + guard let payload = item.sourceEntry?.content.payload else { + sections = Self.compact([Self.text(.summary, item.summary)], fallback: title) + return + } + sections = Self.sections(payload: payload, fallback: title) + } + + private static func sections(payload: EntryPayload, fallback: String) -> [Section] { + switch payload { + case .userMessage(let value): + return compact([text(.prompt, value.text)], fallback: fallback) + case .agentProse(let value): + return compact([text(.summary, value.markdown)], fallback: fallback) + case .thought(let value): + return compact([text(.thought, value.text)], fallback: fallback) + case .toolRun(let value): + return compact([ + text(.tool, value.toolName), + code(.arguments, value.inputDetail ?? value.argumentSummary), + code(.command, value.command), + text(.result, value.resultSummary), + code(.output, value.output), + text(.status, value.status ?? value.exitCode.map(exitCode)), + text(.duration, value.durationSeconds.map(duration)), + ], fallback: fallback) + case .fileChange(let value): + let path = [value.oldPath, value.newPath].compactMap(\.self).isEmpty + ? value.path + : [value.oldPath, value.newPath].compactMap(\.self).joined(separator: " → ") + let counts = [ + value.additions.map { "+\($0)" }, + value.deletions.map { "-\($0)" }, + ].compactMap(\.self).joined(separator: " ") + return compact([ + text(.path, path), + text(.changes, counts), + text(.result, value.resultSummary), + code(.diff, value.unifiedDiff), + ], fallback: fallback) + case .question(let value): + return compact([ + text(.prompt, [value.header, value.prompt].compactMap(\.self).joined(separator: "\n")), + text(.options, value.options.enumerated().map { "\($0.offset + 1). \($0.element)" }.joined(separator: "\n")), + text(.result, value.answeredChoice.flatMap { value.options.indices.contains($0) ? value.options[$0] : nil }), + ], fallback: fallback) + case .permission(let value): + return compact([ + text(.tool, value.toolName), + text(.prompt, value.detail), + text(.options, value.options.enumerated().map { "\($0.offset + 1). \($0.element)" }.joined(separator: "\n")), + ], fallback: fallback) + case .status(let value): + return compact([ + text(.status, value.code.rawValue), + text(.result, value.detail), + ], fallback: fallback) + case .attachment(let value): + let dimensions: String? = if let width = value.width, let height = value.height { + "\(width) × \(height)" + } else { + nil + } + return compact([ + text(.attachment, value.displayName ?? value.summary), + text(.path, value.hostPath), + text(.metadata, [ + value.mimeType, + value.byteCount.map(byteCount), + dimensions, + ].compactMap(\.self).joined(separator: " · ")), + ], fallback: fallback) + case .unknown(let value): + var sections = compact([ + text(.summary, value.summary), + text(.metadata, value.rawKind), + ], fallback: fallback) + if nonempty(value.summary) == nil, let rawJSON = value.rawJSON { + if let diagnostic = code(.diagnostic, rawJSON) { + sections.append(diagnostic) + } + } + return sections + } + } + + private static func compact(_ candidates: [Section?], fallback: String) -> [Section] { + let sections = candidates.compactMap(\.self) + let resolved = sections.isEmpty ? [fallbackSection(fallback)] : sections + return resolved.enumerated().map { index, section in + Section( + id: "\(index):\(section.label.rawValue)", + label: section.label, + value: boundedSectionValue(section.value), + isCode: section.isCode + ) + } + } + + private static func text(_ label: Label, _ value: String?) -> Section? { + guard let value = nonempty(value) else { return nil } + return Section(id: label.rawValue, label: label, value: value, isCode: false) + } + + private static func code(_ label: Label, _ value: String?) -> Section? { + guard let value = nonempty(value) else { return nil } + return Section(id: label.rawValue, label: label, value: value, isCode: true) + } + + private static func fallbackSection(_ fallback: String) -> Section { + Section( + id: Label.summary.rawValue, + label: .summary, + value: boundedTitle(nonempty(fallback) ?? fallbackTitle()), + isCode: false + ) + } + + private static func title(for item: TranscriptActivityItem) -> String { + boundedTitle(nonempty(item.summary) + ?? nonempty(AgentGUIL10n.activityKind(item.kind)) + ?? fallbackTitle()) + } + + private static func fallbackTitle() -> String { + AgentGUIL10n.string("agent.activity.details.title", defaultValue: "Activity") + } + + private static func nonempty(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func exitCode(_ value: Int) -> String { + String( + format: AgentGUIL10n.string( + "agent.activity.detail.exitCodeFormat", + defaultValue: "Exit %d" + ), + value + ) + } + + private static func duration(_ value: Double) -> String { + Measurement(value: value, unit: UnitDuration.seconds) + .formatted(.measurement(width: .abbreviated, usage: .asProvided)) + } + + private static func byteCount(_ value: Int) -> String { + ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .file) + } + + private static func boundedTitle(_ value: String) -> String { + bounded(value, limit: maximumTitleCharacters, suffix: "…") + } + + private static func boundedSectionValue(_ value: String) -> String { + bounded( + value, + limit: maximumSectionCharacters, + suffix: "\n\n\(AgentGUIL10n.activityDetailTruncated())" + ) + } + + private static func bounded(_ value: String, limit: Int, suffix: String) -> String { + guard limit > 0, + let end = value.index(value.startIndex, offsetBy: limit, limitedBy: value.endIndex), + end < value.endIndex + else { return value } + return String(value[.. 0 { + AgentActivityOmittedItemsView( + count: presentation.omittedCount, + theme: theme + ) + } + } + .padding(.horizontal, 24) + .padding(.vertical, 20) + } + .background(Color(theme.background)) + .navigationTitle(AgentGUIL10n.string( + "agent.activity.details.title", + defaultValue: "Activity" + )) + .navigationBarTitleDisplayMode(.inline) + } + } +} + +private struct AgentActivityOmittedItemsView: View { + let count: Int + let theme: AgentGUITheme + + var body: some View { + Text(AgentGUIL10n.activityDetailOmittedItems(count)) + .font(.footnote) + .foregroundStyle(Color(theme.faintForeground)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(Color(theme.raisedBackground), in: RoundedRectangle(cornerRadius: 12)) + } +} + +private struct AgentActivityDetailItemView: View { + let model: TranscriptActivityDetailModel + let theme: AgentGUITheme + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Label(model.title, systemImage: symbol) + .font(.subheadline.weight(.semibold)) + .lineLimit(2) + ForEach(model.sections) { section in + VStack(alignment: .leading, spacing: 4) { + Text(AgentGUIL10n.activityDetailLabel(section.label)) + .font(.caption.weight(.semibold)) + .foregroundStyle(Color(theme.faintForeground)) + Text(section.value) + .font(section.isCode ? .system(.footnote, design: .monospaced) : .footnote) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .foregroundStyle(Color(theme.foreground)) + .padding(14) + .background(Color(theme.raisedBackground), in: RoundedRectangle(cornerRadius: 12)) + } + + private var symbol: String { + switch model.kind { + case .assistant: "text.bubble" + case .thought: "brain" + case .command: "terminal" + case .tool: "wrench.and.screwdriver" + case .file: "doc.text" + case .question: "questionmark.circle" + case .permission: "hand.raised" + case .status: "info.circle" + case .attachment: "paperclip" + case .unknown: "ellipsis.circle" + } + } +} +#endif diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptDemoComposerView.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptDemoComposerView.swift new file mode 100644 index 000000000000..e92dab96ae2b --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptDemoComposerView.swift @@ -0,0 +1,234 @@ +#if DEBUG && os(iOS) +import CmuxAgentGUIProjection +import CmuxMobileSupport +import Observation +import SwiftUI + +struct TranscriptDemoComposerView: View { + @Bindable var model: TranscriptDemoModel + @Binding var density: TranscriptDensity + let jumpToBottom: () -> Void + + @ScaledMetric(relativeTo: .body) private var scaledControlSize: CGFloat = 44 + @ScaledMetric(relativeTo: .body) private var scaledIconSize: CGFloat = 16 + @ScaledMetric(relativeTo: .body) private var scaledPickerWidth: CGFloat = 180 + + @State private var demoText = "" + @State private var didApplyAutomationFocus = false + @FocusState private var demoFieldFocused: Bool + + private var controlSize: CGFloat { + min(max(scaledControlSize, 44), 56) + } + + private var iconSize: CGFloat { + min(max(scaledIconSize, 16), 22) + } + + private var pickerWidth: CGFloat { + min(max(scaledPickerWidth, 180), 260) + } + + private var shouldWrapControls: Bool { + controlSize > 50 || pickerWidth > 220 + } + + var body: some View { + composerSurface + .task { + await applyAutomationFocusIfNeeded() + } + } + + @ViewBuilder + private var composerSurface: some View { + if #available(iOS 26.0, *) { + composerChrome + } else { + composerChrome + .background(.bar) + } + } + + private var composerChrome: some View { + VStack(spacing: 8) { + MobileComposerFieldContainer(minHeight: 44) { + TextField( + AgentGUIL10n.string("agent.demo.fieldPlaceholder", defaultValue: "Demo keyboard field"), + text: $demoText + ) + .focused($demoFieldFocused) + .textInputAutocapitalization(.sentences) + .autocorrectionDisabled(false) + .submitLabel(.done) + .onSubmit { + demoFieldFocused = false + } + } trailing: { + EmptyView() + } + + controls + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) + } + + private var controls: some View { + Group { + if shouldWrapControls { + wrappedControls + } else { + scrollingControls + } + } + .padding(6) + .frame(maxWidth: .infinity, alignment: .leading) + .mobileGlassField(cornerRadius: 24) + } + + private var scrollingControls: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + speedPicker + playbackButton + streamingButton + burstButton + tallToggle + keyboardButton + jumpButton + densityButton + } + } + } + + private var wrappedControls: some View { + VStack(alignment: .leading, spacing: 8) { + speedPicker + + HStack(spacing: 8) { + playbackButton + streamingButton + burstButton + tallToggle + } + + HStack(spacing: 8) { + keyboardButton + jumpButton + densityButton + } + } + } + + private var speedPicker: some View { + Picker(AgentGUIL10n.string("agent.demo.speed", defaultValue: "Speed"), selection: $model.speed) { + Text(AgentGUIL10n.rowsPerSecond(2)).tag(2) + Text(AgentGUIL10n.rowsPerSecond(10)).tag(10) + } + .pickerStyle(.segmented) + .frame(width: pickerWidth) + } + + private var playbackButton: some View { + Button { + model.togglePlayback() + } label: { + controlIcon(model.isPlaying ? "pause.fill" : "play.fill") + } + .buttonStyle(.plain) + .disabled(!model.isPlaybackAvailable) + .accessibilityLabel(model.isPlaying + ? AgentGUIL10n.string("agent.demo.pause", defaultValue: "Pause replay") + : AgentGUIL10n.string("agent.demo.play", defaultValue: "Play replay")) + } + + private var streamingButton: some View { + Button { + model.injectStreamingTick() + } label: { + controlIcon("dot.radiowaves.left.and.right") + } + .buttonStyle(.plain) + .accessibilityLabel(AgentGUIL10n.string("agent.demo.streaming", defaultValue: "Inject streaming tick")) + } + + private var burstButton: some View { + Button { + model.appendBurstRows() + } label: { + controlIcon("plus.message") + } + .buttonStyle(.plain) + .accessibilityLabel(AgentGUIL10n.string("agent.demo.burstAppend", defaultValue: "Burst append five rows")) + } + + private var tallToggle: some View { + Toggle(isOn: Binding( + get: { model.tallFixtureEnabled }, + set: { model.setTallFixtureEnabled($0) } + )) { + controlIcon("text.append") + } + .toggleStyle(.button) + .accessibilityLabel(AgentGUIL10n.string("agent.demo.tallFixture", defaultValue: "Tall fixture")) + } + + private var keyboardButton: some View { + Button { + demoFieldFocused.toggle() + } label: { + controlIcon("keyboard") + } + .buttonStyle(.plain) + .accessibilityLabel(AgentGUIL10n.string("agent.demo.keyboard", defaultValue: "Toggle keyboard")) + } + + private var jumpButton: some View { + Button(action: jumpToBottom) { + controlIcon("arrow.down.to.line") + } + .buttonStyle(.plain) + .accessibilityLabel(AgentGUIL10n.string("agent.demo.jump", defaultValue: "Jump to bottom")) + } + + private var densityButton: some View { + Button { + density = density == .comfortable ? .compact : .comfortable + } label: { + controlIcon(density == .comfortable + ? "rectangle.compress.vertical" + : "rectangle.expand.vertical") + } + .buttonStyle(.plain) + .accessibilityLabel(AgentGUIL10n.string( + "agent.demo.densityToggle", + defaultValue: "Toggle transcript density" + )) + .accessibilityValue(density == .comfortable + ? AgentGUIL10n.string("agent.demo.density.comfortable", defaultValue: "Comfortable") + : AgentGUIL10n.string("agent.demo.density.compact", defaultValue: "Compact")) + .accessibilityIdentifier("TranscriptDemoDensityToggle") + } + + private func controlIcon(_ systemName: String) -> some View { + Image(systemName: systemName) + .font(.system(size: iconSize, weight: .semibold)) + .frame(width: controlSize, height: controlSize) + .mobileGlassCircle() + } + + private func applyAutomationFocusIfNeeded() async { + guard !didApplyAutomationFocus, + ProcessInfo.processInfo.environment["CMUX_UITEST_TRANSCRIPT_DEMO_KEYBOARD"] == "1" else { + return + } + didApplyAutomationFocus = true + let delayMs = ProcessInfo.processInfo.environment["CMUX_UITEST_TRANSCRIPT_DEMO_KEYBOARD_DELAY_MS"] + .flatMap(Int.init) ?? 600 + try? await Task.sleep(for: .milliseconds(max(1, delayMs))) + demoFieldFocused = true + } +} +#endif diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptDemoModel.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptDemoModel.swift new file mode 100644 index 000000000000..2a5b9a8e7d36 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptDemoModel.swift @@ -0,0 +1,235 @@ +#if DEBUG && os(iOS) +import CmuxAgentGUIProjection +import CmuxAgentReplica +import Foundation +import Observation + +@MainActor @Observable final class TranscriptDemoModel { + var input = TranscriptProjectionInput(entries: []) + var renderedRows: [AgentTranscriptRenderRow] = [] + var speed = 2 + var isPlaying = false + var tallFixtureEnabled = false + var isPlaybackAvailable = true + + private let sessionID = AgentSessionID(rawValue: "demo-session") + private let directory = SessionDirectoryReplica() + private let clock = ManualReplicaClock(currentTick: 0) + private let conversation: ConversationReplica + private let records: [ReplicaReplayRecord] + private var index = 0 + private var streamingRevision = 0 + private var syntheticRevision = 0 + private var streamingTail: TranscriptStreamingTail? + private var playbackTask: Task? + + init() { + conversation = ConversationReplica(sessionID: sessionID, clock: clock) + if let url = Bundle.module.url(forResource: "demo-transcript", withExtension: "jsonl"), + let data = try? Data(contentsOf: url) { + records = ReplicaReplayLog.decodeJSONL(data).records + } else { + records = [] + } + updateInput() + } + + func step() { + guard index < records.count else { + isPlaying = false + playbackTask?.cancel() + playbackTask = nil + return + } + let record = records[index] + clock.currentTick = record.tick + FixtureReplicaSource(records: [record]).feed(directory: directory, conversations: [sessionID: conversation]) + index += 1 + streamingTail = nil + updateInput() + } + + func togglePlayback() { + guard isPlaybackAvailable else { return } + if isPlaying { + playbackTask?.cancel() + playbackTask = nil + isPlaying = false + return + } + isPlaying = true + playbackTask = Task { @MainActor [weak self] in + guard let self else { + return + } + while !Task.isCancelled { + self.step() + guard self.isPlaying else { + return + } + let delayMs = self.speed == 10 ? 100 : 500 + // Demo pacing helper; production transcript UI is event-driven. + try? await Task.sleep(for: .milliseconds(delayMs)) + } + } + } + + func injectStreamingTick() { + streamingRevision += 1 + let tail = max(conversation.tailSeq.rawValue, 0) + streamingTail = TranscriptStreamingTail( + journalID: conversation.journalID ?? JournalID(rawValue: "demo-journal"), + afterSeq: EntrySeq(rawValue: tail), + textTail: AgentGUIL10n.string("agent.demo.streamingText", defaultValue: "Drafting the next update..."), + revision: streamingRevision + ) + updateInput() + } + + func appendBurstRows() { + appendSyntheticRows( + count: 5, + label: AgentGUIL10n.string("agent.demo.fixture.label.burst", defaultValue: "burst") + ) + } + + func seedReplayForAutomation() { + playbackTask?.cancel() + playbackTask = nil + isPlaying = false + while index < records.count { + step() + } + } + + func playReplayForAutomation(delayMs: Int) async { + guard isPlaybackAvailable else { + return + } + playbackTask?.cancel() + playbackTask = nil + isPlaying = true + while index < records.count { + step() + guard isPlaying else { + return + } + try? await Task.sleep(for: .milliseconds(max(1, delayMs))) + } + isPlaying = false + } + + func setTallFixtureEnabled(_ enabled: Bool) { + tallFixtureEnabled = enabled + guard enabled else { + return + } + appendSyntheticRows( + count: 220, + label: AgentGUIL10n.string("agent.demo.fixture.label.tall", defaultValue: "tall") + ) + } + + func tearDown() { + playbackTask?.cancel() + playbackTask = nil + isPlaying = false + } + + private func updateInput() { + let nextInput = TranscriptProjectionInput( + state: conversation.state, + hasMoreBefore: false, + streamingTail: streamingTail, + displayTick: { entry in entry.seq.rawValue * 600 }, + dayKey: { tick in + tick < 1_800 + ? AgentGUIL10n.string("agent.demo.day.today", defaultValue: "Today") + : AgentGUIL10n.string("agent.demo.day.later", defaultValue: "Later") + } + ) + input = nextInput + renderedRows = AgentTranscriptRenderAdapter().rows( + from: TranscriptProjector().project(nextInput).rows + ) + } + + private func appendSyntheticRows(count: Int, label: String) { + playbackTask?.cancel() + playbackTask = nil + isPlaying = false + isPlaybackAvailable = false + let journalID = ensureSyntheticJournal() + let firstSeq = conversation.tailSeq.rawValue + 1 + let entries = (0.. JournalID { + if let journalID = conversation.journalID { + return journalID + } + let journalID = JournalID(rawValue: "demo-journal") + let reset = ReplicaReplayRecord( + tick: clock.currentTick, + origin: .resync, + delta: .journalReset(sessionID: sessionID, newJournal: journalID, tailSeq: EntrySeq(rawValue: 0)) + ) + FixtureReplicaSource(records: [reset]).feed( + directory: directory, + conversations: [sessionID: conversation] + ) + return journalID + } + + private func syntheticEntry( + journalID: JournalID, + seq: EntrySeq, + label: String, + ordinal: Int + ) -> EntrySnapshot { + let payload: EntryPayload + if ordinal.isMultiple(of: 5) { + payload = .toolRun(ToolRunPayload( + toolName: AgentGUIL10n.string("agent.demo.fixture.toolName", defaultValue: "fixture"), + argumentSummary: AgentGUIL10n.string("agent.demo.fixture.toolSummary", defaultValue: "append transcript rows"), + resultSummary: AgentGUIL10n.string("agent.demo.fixture.toolResult", defaultValue: "fixture rows added"), + isTerminal: false, + exitCode: 0, + isRunning: false + )) + } else { + payload = .agentProse(AgentProsePayload(markdown: String( + format: AgentGUIL10n.string( + "agent.demo.fixture.agentFormat", + defaultValue: "Synthetic %@ fixture row %d with enough prose to exercise realistic scroll physics." + ), + label, + ordinal + ))) + } + return EntrySnapshot( + journalID: journalID, + seq: seq, + kind: payload.kind, + content: EntryContent(contentHash: 10_000 + ordinal, payload: payload), + version: EntityVersion(rawValue: UInt64(10_000 + ordinal)) + ) + } +} +#endif diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptDemoScreen.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptDemoScreen.swift new file mode 100644 index 000000000000..bc233276b4a8 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptDemoScreen.swift @@ -0,0 +1,151 @@ +#if DEBUG && os(iOS) +import CMUXMobileCore +import CmuxAgentChatUI +import CmuxAgentGUIProjection +import CmuxMobileSupport +public import SwiftUI + +/// DEBUG-only replay-driven transcript demo using the production native list. +public struct TranscriptDemoScreen: View { + @State private var model = TranscriptDemoModel() + @State private var density: TranscriptDensity + @State private var activityDetails: TranscriptActivityDetails? + @State private var followState: ConversationFollowState = .followingTail + @State private var scrollCommand: ConversationScrollCommand? + @State private var scrollGeneration = 0 + @State private var markdownRenderer = ChatMarkdownRenderer() + @State private var contentCache = ChatContentCache() + @State private var didStartAutomation = false + + public init() { + let rawDensity = UITestEnvironmentConfig( + environment: ProcessInfo.processInfo.environment + ).transcriptDensity + _density = State(initialValue: rawDensity.flatMap(TranscriptDensity.init(rawValue:)) ?? .compact) + } + + public var body: some View { + let theme = AgentGUITheme(terminalTheme: .monokai) + let appearance = AgentTranscriptAppearance(theme: theme, density: density) + let title = AgentGUIL10n.string("agent.demo.title", defaultValue: "Transcript Demo") + ConversationKeyboardContainer { + NativeConversationTranscript( + rows: model.renderedRows, + hasMoreBefore: model.input.hasMoreBefore, + followState: $followState, + command: scrollCommand, + isActive: activityDetails == nil + ) { row in + AgentTranscriptRowView( + row: row, + theme: theme, + density: density, + onOpenAsk: { _ in }, + onOpenActivity: { rowID in + activityDetails = AgentTranscriptRowSelectionResolver.activity( + rowID: rowID, + rows: model.renderedRows + ) + }, + onOpenFailedTicket: { _ in }, + onRetrySync: {}, + onShowTerminal: {}, + onOpenArtifact: { _ in }, + onShowCodeBlock: { _, _ in } + ) + } + } composer: { + TranscriptDemoComposerView( + model: model, + density: $density, + jumpToBottom: jumpToBottom + ) + } + .environment(\.chatTheme, appearance.chatTheme) + .environment(\.chatMarkdownRenderer, markdownRenderer) + .environment(\.chatContentCache, contentCache) + .environment(\.colorScheme, appearance.colorScheme) + .ignoresSafeArea(.keyboard, edges: .bottom) + .background(Color(theme.background).ignoresSafeArea()) + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text(title) + .font(.headline.weight(.semibold)) + .foregroundStyle(Color(theme.foreground)) + .accessibilityAddTraits(.isHeader) + } + } + .toolbarBackground(Color(theme.background), for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .toolbarColorScheme(appearance.colorScheme, for: .navigationBar) + .sheet(item: $activityDetails) { details in + TranscriptActivityTimelineView(details: details, terminalTheme: .monokai) + .presentationDetents([.medium, .large]) + } + .task { + await runAutomationIfRequested() + } + .onDisappear { model.tearDown() } + } + + private func jumpToBottom() { + followState = .followingTail + scrollGeneration += 1 + scrollCommand = ConversationScrollCommand(generation: scrollGeneration, target: .tail) + } + + private func runAutomationIfRequested() async { + guard !didStartAutomation else { + return + } + let env = ProcessInfo.processInfo.environment + let enabled = env["CMUX_UITEST_TRANSCRIPT_DEMO_SEED"] == "full" + || env["CMUX_UITEST_TRANSCRIPT_DEMO_AUTOPLAY"] == "1" + || env["CMUX_UITEST_TRANSCRIPT_DEMO_STREAMING"] == "1" + || env["CMUX_UITEST_TRANSCRIPT_DEMO_BURST"] == "1" + || env["CMUX_UITEST_TRANSCRIPT_DEMO_TALL"] == "1" + || env["CMUX_UITEST_TRANSCRIPT_DEMO_TOGGLE_DENSITY"] == "1" + guard enabled else { + return + } + didStartAutomation = true + + if env["CMUX_UITEST_TRANSCRIPT_DEMO_SEED"] == "full" { + model.seedReplayForAutomation() + } + + let startDelayMs = env["CMUX_UITEST_TRANSCRIPT_DEMO_START_DELAY_MS"].flatMap(Int.init) ?? 0 + if startDelayMs > 0 { + try? await Task.sleep(for: .milliseconds(max(1, startDelayMs))) + } + + if env["CMUX_UITEST_TRANSCRIPT_DEMO_AUTOPLAY"] == "1" { + let delayMs = env["CMUX_UITEST_TRANSCRIPT_DEMO_DELAY_MS"].flatMap(Int.init) ?? 180 + await model.playReplayForAutomation(delayMs: delayMs) + } + + if env["CMUX_UITEST_TRANSCRIPT_DEMO_STREAMING"] == "1" { + try? await Task.sleep(for: .milliseconds(450)) + model.injectStreamingTick() + } + + if env["CMUX_UITEST_TRANSCRIPT_DEMO_BURST"] == "1" { + try? await Task.sleep(for: .milliseconds(450)) + model.appendBurstRows() + } + + if env["CMUX_UITEST_TRANSCRIPT_DEMO_TALL"] == "1" { + try? await Task.sleep(for: .milliseconds(450)) + model.setTallFixtureEnabled(true) + } + + if env["CMUX_UITEST_TRANSCRIPT_DEMO_TOGGLE_DENSITY"] == "1" { + let delayMs = env["CMUX_UITEST_TRANSCRIPT_DEMO_TOGGLE_DELAY_MS"].flatMap(Int.init) ?? 700 + try? await Task.sleep(for: .milliseconds(max(1, delayMs))) + density = density == .comfortable ? .compact : .comfortable + } + } +} +#endif diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptDensity.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptDensity.swift new file mode 100644 index 000000000000..2a844515d740 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptDensity.swift @@ -0,0 +1,20 @@ +/// The vertical and metadata-type density used by transcript rows. +public enum TranscriptDensity: String, CaseIterable, Sendable { + /// A relaxed reading rhythm available when extra vertical space is preferred. + case comfortable + /// The default register, compressing transcript chrome without shrinking prose. + case compact +} + +#if os(iOS) +import SwiftUI + +extension TranscriptDensity { + var metadataFont: Font { + switch self { + case .comfortable: .footnote + case .compact: .caption + } + } +} +#endif diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptLiveAuxiliaryViews.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptLiveAuxiliaryViews.swift new file mode 100644 index 000000000000..d1139155fa6d --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptLiveAuxiliaryViews.swift @@ -0,0 +1,162 @@ +import CmuxAgentGUIProjection +import CmuxAgentChatUI +import CmuxAgentReplica +import CmuxAgentSync +import CmuxAgentWire +#if os(iOS) +import SwiftUI +#endif + +struct AgentSessionInteractionCapabilities: Equatable { + let canSteer: Bool + let canAnswer: Bool + + init(canSteer: Bool, canAnswer: Bool) { + self.canSteer = canSteer + self.canAnswer = canAnswer + } + + init(report: GuiCapabilitiesResult?, tier: DetectionTier?) { + if let report { + canSteer = report.steerable + canAnswer = report.answerable + return + } + canSteer = tier == .wrapped || tier == .hooked + canAnswer = false + } +} + +/// Keeps the selected host path coupled to the loader that authorizes it +/// when SwiftUI presents the artifact outside the transcript row hierarchy. +struct AgentTranscriptArtifactRoute { + let path: String + let loader: ChatArtifactLoader +} + +enum AgentHistoryLoadFailure { + case older + case newer + case head + case tail +} + +#if os(iOS) +struct AgentCapabilityRequestKey: Hashable { + let sessionID: AgentSessionID + let sessionVersion: UInt64 + let isConnected: Bool +} + +enum AgentTranscriptSheet: Identifiable { + case ask(PendingAsk) + case activity(TranscriptActivityDetails) + case codeBlock(AgentCodeBlockSelection) + case failedTicket(SendTicket) + case artifact(AgentTranscriptArtifactRoute) + + var id: String { + switch self { + case .ask(let ask): "ask:\(ask.id)" + case .activity(let details): "activity:\(details.id.description)" + case .codeBlock(let selection): selection.id + case .failedTicket(let ticket): "failed-ticket:\(ticket.id.uuidString)" + case .artifact(let route): "artifact:\(route.path)" + } + } +} + +struct AgentSendFailureSheet: View { + let ticket: SendTicket + let theme: AgentGUITheme + let retry: () -> Void + let showTerminal: () -> Void + + var body: some View { + NavigationStack { + VStack(alignment: .leading, spacing: 18) { + Text(ticket.text) + .textSelection(.enabled) + Text(AgentGUIL10n.string( + "agent.send.failed.message", + defaultValue: "This message was not delivered. Retry it or continue in Terminal." + )) + .font(.footnote) + .foregroundStyle(Color(theme.dimForeground)) + Button(AgentGUIL10n.string("agent.send.retry", defaultValue: "Retry message"), action: retry) + .buttonStyle(.borderedProminent) + Button( + AgentGUIL10n.string("agent.ask.showTerminal", defaultValue: "Show Terminal"), + action: showTerminal + ) + .buttonStyle(.bordered) + Spacer() + } + .padding(24) + .background(Color(theme.background)) + .navigationTitle(AgentGUIL10n.string("agent.send.failed", defaultValue: "Send failed")) + .navigationBarTitleDisplayMode(.inline) + } + } +} + +struct AgentCodeBlockSelection { + let id: String + let code: String + let language: String? +} + +struct AgentSyncInsetBanner: View { + let presentation: TranscriptSyncPresentation + let phase: AgentConnectivityPhase + let historyLoadFailed: Bool + let theme: AgentGUITheme + let retrySync: () -> Void + let retryHistory: () -> Void + + var body: some View { + HStack(spacing: 8) { + if presentation == .stale || presentation == .error { + Image(systemName: "wifi.slash") + Text(presentation == .stale + ? AgentGUIL10n.string("agent.sync.stale.title", defaultValue: "Showing saved conversation") + : AgentGUIL10n.string("agent.sync.failed.title", defaultValue: "Conversation unavailable")) + .lineLimit(1) + Spacer(minLength: 8) + Button(AgentGUIL10n.string("agent.sync.retry", defaultValue: "Retry"), action: retrySync) + .fontWeight(.semibold) + } else if historyLoadFailed { + Image(systemName: "exclamationmark.arrow.triangle.2.circlepath") + Text(AgentGUIL10n.string( + "agent.history.load.failed", + defaultValue: "Conversation history could not be loaded" + )) + .lineLimit(1) + Spacer(minLength: 8) + Button(AgentGUIL10n.string("agent.sync.retry", defaultValue: "Retry"), action: retryHistory) + .fontWeight(.semibold) + } else if case .updating = phase { + ProgressView().controlSize(.mini) + Text(AgentGUIL10n.string("agent.sync.updating", defaultValue: "Updating conversation")) + .lineLimit(1) + Spacer(minLength: 0) + } else { + Color.clear + .accessibilityHidden(true) + } + } + .font(.caption) + .foregroundStyle(Color(theme.dimForeground)) + .padding(.horizontal, 14) + .frame(height: 36) + .background(Color(theme.raisedBackground)) + .accessibilityIdentifier("AgentSyncInsetBanner") + .accessibilityHidden( + presentation != .stale + && presentation != .error + && !historyLoadFailed + && phase != .updating + ) + } +} +#endif diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptLiveDriverKey.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptLiveDriverKey.swift new file mode 100644 index 000000000000..9a0b0d03fc8a --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptLiveDriverKey.swift @@ -0,0 +1,12 @@ +import CmuxAgentReplica +import CmuxAgentSync + +struct TranscriptLiveDriverKey: Equatable { + let engineID: ObjectIdentifier + let sessionID: AgentSessionID + + init(engine: AgentSyncEngine, sessionID: AgentSessionID) { + self.engineID = ObjectIdentifier(engine) + self.sessionID = sessionID + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptLiveView.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptLiveView.swift new file mode 100644 index 000000000000..76150990922d --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptLiveView.swift @@ -0,0 +1,511 @@ +#if os(iOS) +public import CMUXMobileCore +import CmuxAgentChat +public import CmuxAgentChatUI +import CmuxAgentGUIProjection +public import CmuxAgentReplica +public import CmuxAgentSync +import CmuxAgentWire +public import SwiftUI + +/// Live chronological conversation surface with its own transcript, composer, +/// keyboard geometry, and scroll-edge ownership. +public struct TranscriptLiveView: View { + private let engine: AgentSyncEngine + private let sessionID: AgentSessionID + private let terminalTheme: TerminalTheme + private let terminalThemeGeneration: UInt64 + private let density: TranscriptDensity + private let artifactLoader: ChatArtifactLoader + private let onShowTerminal: () -> Void + + @State private var input = TranscriptProjectionInput(entries: []) + @State private var renderedRows: [AgentTranscriptRenderRow] = [] + @State private var driver: TranscriptProjectionDriver? + @State private var followState: ConversationFollowState = .followingTail + @State private var scrollCommand: ConversationScrollCommand? + @State private var scrollGeneration = 0 + @State private var prefetchResetGeneration = 0 + @State private var historyLoadFailure: AgentHistoryLoadFailure? + @State private var lastStableFollowState: ConversationFollowState = .followingTail + @State private var historyLifecycleGeneration = 0 + @Binding private var draft: String + @State private var selectedSheet: AgentTranscriptSheet? + @State private var answeringAskID: String? + @State private var askError: String? + @State private var markdownRenderer = ChatMarkdownRenderer() + @State private var contentCache = ChatContentCache() + @State private var capabilityReport: GuiCapabilitiesResult? + @State private var capabilityReportSessionVersion: UInt64? + + private var driverKey: TranscriptLiveDriverKey { + TranscriptLiveDriverKey(engine: engine, sessionID: sessionID) + } + + public init( + engine: AgentSyncEngine, + sessionID: AgentSessionID, + terminalTheme: TerminalTheme, + terminalThemeGeneration: UInt64, + density: TranscriptDensity, + draft: Binding, + artifactLoader: ChatArtifactLoader = .unsupported(), + onShowTerminal: @escaping () -> Void = {} + ) { + self.engine = engine + self.sessionID = sessionID + self.terminalTheme = terminalTheme + self.terminalThemeGeneration = terminalThemeGeneration + self.density = density + self.artifactLoader = artifactLoader + _draft = draft + self.onShowTerminal = onShowTerminal + } + + public var body: some View { + let theme = AgentGUITheme(terminalTheme: terminalTheme) + let appearance = AgentTranscriptAppearance(theme: theme, density: density) + let syncPresentation = TranscriptSyncPresentation( + phase: engine.connectivity.phase, + consecutiveFailures: engine.connectivity.consecutiveFailureCount, + hasVisibleContent: !renderedRows.isEmpty, + hasCompletedInitialSync: input.hasCompletedInitialSync, + hasMoreAfter: input.hasMoreAfter + ) + let shouldShowHistoryLoadFailure = AgentHistoryLoadFailurePolicy.shouldShowBanner( + failure: historyLoadFailure, + input: input + ) + let rows = renderedRows.isEmpty && syncPresentation.showsPlaceholderRow + ? [AgentTranscriptRenderRow(id: "empty-state", content: .empty(syncPresentation))] + : renderedRows + + ConversationKeyboardContainer { + VStack(spacing: 0) { + AgentSyncInsetBanner( + presentation: syncPresentation, + phase: engine.connectivity.phase, + historyLoadFailed: shouldShowHistoryLoadFailure, + theme: theme, + retrySync: engine.retryNow, + retryHistory: retryHistoryLoad + ) + ZStack(alignment: .bottomTrailing) { + NativeConversationTranscript( + rows: rows, + hasMoreBefore: input.hasMoreBefore, + hasMoreAfter: input.hasMoreAfter, + followState: $followState, + command: scrollCommand, + renderGeneration: Int(truncatingIfNeeded: terminalThemeGeneration), + isActive: selectedSheet == nil, + beforePageID: input.startCursor?.rawValue, + afterPageID: input.endCursor?.rawValue, + prefetchResetGeneration: prefetchResetGeneration, + onLoadBefore: loadOlder, + onLoadAfter: loadNewer, + onSemanticHead: jumpToHead, + onSemanticTail: jumpToTail + ) { row in + AgentTranscriptRowView( + row: row, + theme: theme, + density: density, + onOpenAsk: { openAsk(rowID: $0) }, + onOpenActivity: { openActivity(rowID: $0) }, + onOpenFailedTicket: { openFailedTicket(rowID: $0) }, + onRetrySync: engine.retryNow, + onShowTerminal: onShowTerminal, + onOpenArtifact: { + selectedSheet = .artifact(AgentTranscriptArtifactRoute( + path: $0, + loader: artifactLoader + )) + }, + onShowCodeBlock: showCodeBlock + ) + } + if case .detached(_, _, let unseenCount) = followState { + AgentTranscriptScrollToBottomPillOverlay( + theme: theme, + unreadCount: unseenCount, + action: requestTail + ) + } + } + } + } composer: { + ChatComposerView( + agentState: agentState, + agentKind: agentKind, + isConnected: isConnected, + capabilities: composerCapabilities, + draft: $draft, + onSend: { text, _ in send(text) }, + onInterrupt: interrupt, + onOpenTerminal: onShowTerminal + ) + } + .environment(\.chatTheme, appearance.chatTheme) + .environment(\.chatMarkdownRenderer, markdownRenderer) + .environment(\.chatContentCache, contentCache) + .environment(\.chatArtifactLoader, artifactLoader) + .environment(\.colorScheme, appearance.colorScheme) + .background(Color(theme.background)) + .ignoresSafeArea(.keyboard, edges: .bottom) + .sheet(item: $selectedSheet) { sheet in + switch sheet { + case .ask(let ask): + AgentAskSheet( + ask: ask, + theme: theme, + canAnswer: canAnswer, + isAnswering: answeringAskID == ask.id, + errorMessage: askError, + onAnswer: { answer(ask, choice: $0) }, + onShowTerminal: onShowTerminal + ) + case .activity(let details): + TranscriptActivityTimelineView(details: details, terminalTheme: terminalTheme) + case .codeBlock(let selection): + ChatCodeBlockDetailSheet( + id: selection.id, + code: selection.code, + language: selection.language + ) + case .failedTicket(let ticket): + AgentSendFailureSheet( + ticket: ticket, + theme: theme, + retry: { + if engine.retrySend(sessionID: sessionID, ticketID: ticket.id) { + selectedSheet = nil + requestTail() + } + }, + showTerminal: onShowTerminal + ) + case .artifact(let route): + ChatArtifactViewerSheet(path: route.path) + .environment(\.chatArtifactLoader, route.loader) + } + } + .onAppear(perform: startDriverIfNeeded) + .onDisappear(perform: stopDriver) + .task(id: capabilityRequestKey) { + await refreshCapabilities() + } + .onChange(of: driverKey) { _, _ in restartDriver() } + .onChange(of: followState) { _, nextState in + switch nextState { + case .followingTail, .detached: + lastStableFollowState = nextState + case .jumpingToHead, .jumpingToTail: + break + } + } + .onChange(of: input.asks) { _, asks in + guard case .ask(let selectedAsk) = selectedSheet, + AgentAskSheetPolicy.shouldDismiss(selectedAskID: selectedAsk.id, asks: asks) + else { return } + selectedSheet = nil + answeringAskID = nil + askError = nil + } + } + + private var session: AgentSessionSnapshot? { + engine.directory.sessions.first { $0.id == sessionID } + } + + private var agentState: ChatAgentState { + switch session?.phase ?? input.sessionPhase { + case .starting, .working: .working(since: .now) + case .needsInput: .needsInput(since: .now) + case .ended: .ended + case .idle, .unknown: .idle + } + } + + private var agentKind: ChatAgentKind { + ChatAgentKind(source: session?.kind.rawValue ?? "agent") + } + + private var isConnected: Bool { + if case .connected = engine.connectivity.phase { return true } + return false + } + + private var capabilityRequestKey: AgentCapabilityRequestKey { + AgentCapabilityRequestKey( + sessionID: sessionID, + sessionVersion: session?.version.rawValue ?? 0, + isConnected: isConnected + ) + } + + private var interactionCapabilities: AgentSessionInteractionCapabilities { + let currentVersion = session?.version.rawValue + let currentReport = capabilityReportSessionVersion == currentVersion ? capabilityReport : nil + return AgentSessionInteractionCapabilities(report: currentReport, tier: session?.tier) + } + + private var canSteer: Bool { + interactionCapabilities.canSteer + } + + private var canAnswer: Bool { + isConnected && interactionCapabilities.canAnswer + } + + private var composerCapabilities: ChatComposerCapabilities { + guard canSteer else { return .readOnly } + return ChatComposerCapabilities( + allowsAttachments: false, + allowsInterrupt: isConnected, + allowsHardInterrupt: isConnected, + allowsOfflineSendQueue: true + ) + } + + private func startDriverIfNeeded() { + guard driver == nil else { return } + let nextDriver = TranscriptProjectionDriver(engine: engine, sessionID: sessionID) { nextInput in + input = nextInput + renderedRows = Self.renderRows(from: nextInput) + if nextInput.hasRenderableTranscriptContent { + historyLoadFailure = nil + } + } + driver = nextDriver + nextDriver.start() + } + + private func stopDriver() { + historyLifecycleGeneration &+= 1 + driver?.stop() + driver = nil + } + + private func refreshCapabilities() async { + guard isConnected else { return } + let lifecycleGeneration = historyLifecycleGeneration + let requestedSessionVersion = session?.version.rawValue + do { + let report = try await engine.capabilities(sessionID: sessionID) + guard lifecycleGeneration == historyLifecycleGeneration, + requestedSessionVersion == session?.version.rawValue + else { return } + capabilityReport = report + capabilityReportSessionVersion = requestedSessionVersion + } catch { + // Directory tier remains the conservative fallback until the next + // connection or session revision triggers another capability pull. + } + } + + private func restartDriver() { + stopDriver() + input = TranscriptProjectionInput(entries: []) + renderedRows = [] + followState = .followingTail + lastStableFollowState = .followingTail + historyLoadFailure = nil + capabilityReport = nil + capabilityReportSessionVersion = nil + prefetchResetGeneration += 1 + startDriverIfNeeded() + } + + private func loadOlder() { + historyLoadFailure = nil + let lifecycleGeneration = historyLifecycleGeneration + Task { @MainActor in + do { + try await engine.loadOlder(sessionID: sessionID) + } catch { + guard lifecycleGeneration == historyLifecycleGeneration else { return } + recordHistoryLoadFailure(.older) + prefetchResetGeneration += 1 + } + } + } + + private func loadNewer() { + historyLoadFailure = nil + let lifecycleGeneration = historyLifecycleGeneration + Task { @MainActor in + do { + try await engine.loadNewer(sessionID: sessionID) + } catch { + guard lifecycleGeneration == historyLifecycleGeneration else { return } + recordHistoryLoadFailure(.newer) + prefetchResetGeneration += 1 + } + } + } + + private func jumpToHead() { + historyLoadFailure = nil + let lifecycleGeneration = historyLifecycleGeneration + Task { @MainActor in + do { + try await engine.jumpToHead(sessionID: sessionID) + } catch { + guard lifecycleGeneration == historyLifecycleGeneration else { return } + recordHistoryLoadFailure(.head) + followState = lastStableFollowState + return + } + requestScroll(.head) + } + } + + private func jumpToTail() { + historyLoadFailure = nil + let lifecycleGeneration = historyLifecycleGeneration + Task { @MainActor in + do { + try await engine.jumpToTail(sessionID: sessionID) + } catch { + guard lifecycleGeneration == historyLifecycleGeneration else { return } + recordHistoryLoadFailure(.tail) + followState = lastStableFollowState + return + } + requestScroll(.tail) + } + } + + private func retryHistoryLoad() { + guard let historyLoadFailure else { return } + switch historyLoadFailure { + case .older: + loadOlder() + case .newer: + loadNewer() + case .head: + followState = .jumpingToHead + jumpToHead() + case .tail: + followState = .jumpingToTail + jumpToTail() + } + } + + private func recordHistoryLoadFailure(_ failure: AgentHistoryLoadFailure) { + historyLoadFailure = input.hasRenderableTranscriptContent ? nil : failure + } + + private func requestTail() { + let action = AgentTranscriptTailRequestPolicy.action(hasMoreAfter: input.hasMoreAfter) + followState = AgentTranscriptTailRequestPolicy.followStateBeforeCommand( + current: followState, + action: action + ) + switch action { + case .localScroll: + requestScroll(.tail) + case .semanticTail: + jumpToTail() + } + } + + private func requestScroll(_ target: ConversationScrollTarget) { + scrollGeneration += 1 + scrollCommand = ConversationScrollCommand(generation: scrollGeneration, target: target) + } + + private func send(_ text: String) { + guard canSteer else { return } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + engine.send(sessionID: sessionID, text: trimmed) + followState = .followingTail + requestScroll(.tail) + } + + private func interrupt(hard: Bool) { + guard canSteer, isConnected else { return } + Task { try? await engine.interrupt(sessionID: sessionID, hard: hard) } + } + + private func openActivity(rowID: String) { + guard let details = AgentTranscriptRowSelectionResolver.activity( + rowID: rowID, + rows: renderedRows + ) else { return } + selectedSheet = .activity(details) + } + + private func openAsk(rowID: String) { + guard let ask = AgentTranscriptRowSelectionResolver.ask( + rowID: rowID, + rows: renderedRows + ) else { return } + let previousAskID: String? = if case .ask(let selectedAsk) = selectedSheet { + selectedAsk.id + } else { + nil + } + if AgentAskSheetPolicy.shouldResetError(previousAskID: previousAskID, nextAskID: ask.id) { + askError = nil + } + selectedSheet = .ask(ask) + } + + private func openFailedTicket(rowID: String) { + guard let ticket = AgentTranscriptRowSelectionResolver.failedTicket( + rowID: rowID, + rows: renderedRows + ) else { return } + selectedSheet = .failedTicket(ticket) + } + + private func showCodeBlock(messageID: String, segmentIndex: Int) { + guard let message = renderedRows.compactMap({ row -> ChatMessage? in + guard case .message(let snapshot) = row.content, + snapshot.message.id == messageID + else { return nil } + return snapshot.message + }).first, + case .prose(let prose) = message.kind, + let segment = contentCache + .proseSegments(messageID: messageID, text: prose.text) + .first(where: { $0.index == segmentIndex }), + case .code(let language) = segment.kind + else { return } + selectedSheet = .codeBlock(AgentCodeBlockSelection( + id: "code-\(messageID)-\(segmentIndex)", + code: segment.content, + language: language + )) + } + + private static func renderRows(from input: TranscriptProjectionInput) -> [AgentTranscriptRenderRow] { + let projection = TranscriptProjector().project(input) + return AgentTranscriptRenderAdapter().rows(from: projection.rows) + } + + private func answer(_ ask: PendingAsk, choice: Int) { + guard canAnswer, + answeringAskID == nil, + ask.options.indices.contains(choice) + else { return } + answeringAskID = ask.id + askError = nil + Task { @MainActor in + defer { answeringAskID = nil } + do { + try await engine.answer(sessionID: sessionID, askID: ask.id, choice: choice) + selectedSheet = nil + } catch { + askError = AgentGUIL10n.string( + "agent.ask.failed", + defaultValue: "The answer could not be sent. Try again or use Terminal." + ) + } + } + } +} +#endif diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptProjectionDriver.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptProjectionDriver.swift new file mode 100644 index 000000000000..23a19c4ae20f --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptProjectionDriver.swift @@ -0,0 +1,67 @@ +import CmuxAgentGUIProjection +import CmuxAgentReplica +import CmuxAgentSync +import Observation + +@MainActor +final class TranscriptProjectionDriver { + private let engine: AgentSyncEngine + private let sessionID: AgentSessionID + private let sink: (TranscriptProjectionInput) -> Void + private var conversation: ConversationReplica? + private var isStarted = false + + init( + engine: AgentSyncEngine, + sessionID: AgentSessionID, + sink: @escaping (TranscriptProjectionInput) -> Void + ) { + self.engine = engine + self.sessionID = sessionID + self.sink = sink + } + + func start() { + guard !isStarted else { return } + isStarted = true + conversation = engine.openConversation(sessionID: sessionID) + observe() + } + + func stop() { + guard isStarted else { return } + isStarted = false + conversation = nil + engine.closeConversation(sessionID: sessionID) + } + + private func observe() { + guard isStarted, let conversation else { return } + var nextInput: TranscriptProjectionInput? + withObservationTracking { + let state = conversation.state + let hasMoreBefore = conversation.hasMoreBefore + let hasMoreAfter = conversation.hasMoreAfter + let startCursor = conversation.startCursor + let endCursor = conversation.endCursor + let streamingTail = engine.streamingTails[sessionID].map(TranscriptStreamingTail.init) + let sessionPhase = engine.directory.sessions.first { $0.id == sessionID }?.phase ?? .unknown + nextInput = TranscriptProjectionInput( + state: state, + hasMoreBefore: hasMoreBefore, + hasMoreAfter: hasMoreAfter, + startCursor: startCursor, + endCursor: endCursor, + streamingTail: streamingTail, + sessionPhase: sessionPhase + ) + } onChange: { [weak self] in + Task { @MainActor in + self?.observe() + } + } + guard let nextInput else { return } + sink(nextInput) + } + +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptRow+Accessibility.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptRow+Accessibility.swift new file mode 100644 index 000000000000..791d4004bd19 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptRow+Accessibility.swift @@ -0,0 +1,42 @@ +#if os(iOS) +import CmuxAgentGUIProjection + +extension TranscriptRow { + var accessibilityLabel: String { + switch rowKind { + case .proseAgent(let text, _), .streaming(let text): + AgentGUIL10n.agentAccessibilityLabel(text) + case .proseUser(let text, _, _): + AgentGUIL10n.userAccessibilityLabel(text) + case .attachment(let attachment): + attachment.displayName ?? attachment.summary + case .pendingTicket(let ticket): + AgentGUIL10n.userAccessibilityLabel(ticket.text) + case .pendingAsk(let ask): + ask.promptSummary + case .status(let code, let detail): + AgentGUIL10n.compactStatusTitle(code: code, detail: detail) + case .dateHeader(let dayKey): + dayKey + case .boundary: + AgentGUIL10n.string( + "agent.transcript.boundary", + defaultValue: "Earlier history is on your Mac" + ) + case .hole(let range): + AgentGUIL10n.hole( + lowerBound: range.lowerBound.rawValue, + upperBound: range.upperBound.rawValue + ) + case .genericActivity(let activity): + AgentGUIL10n.compactActivityTitle(kindLabel: activity.kindLabel, summary: activity.summary) + case .activitySummary(let summary): + AgentGUIL10n.activitySummary(summary) + case .activityItem(let item): + AgentGUIL10n.activityAccessibility(item) + case .unsupported(let rawKind, let summary): + AgentGUIL10n.compactUnsupportedTitle(rawKind: rawKind, summary: summary) + } + } +} +#endif diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptStreamingTail+AgentStreamingTail.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptStreamingTail+AgentStreamingTail.swift new file mode 100644 index 000000000000..f821cad85cee --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptStreamingTail+AgentStreamingTail.swift @@ -0,0 +1,13 @@ +import CmuxAgentGUIProjection +import CmuxAgentSync + +extension TranscriptStreamingTail { + init(_ tail: AgentStreamingTail) { + self.init( + journalID: tail.journalID, + afterSeq: tail.afterSeq, + textTail: tail.textTail, + revision: tail.revision + ) + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptSyncPresentation.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptSyncPresentation.swift new file mode 100644 index 000000000000..413921f7d7f1 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptSyncPresentation.swift @@ -0,0 +1,53 @@ +import CmuxAgentGUIProjection +import CmuxAgentSync + +enum TranscriptSyncPresentation: Equatable { + case hidden + case empty + case loading + case error + case stale + + var showsPlaceholderRow: Bool { + switch self { + case .empty, .loading, .error: + true + case .hidden, .stale: + false + } + } + + init( + phase: AgentConnectivityPhase, + consecutiveFailures: Int, + input: TranscriptProjectionInput + ) { + self.init( + phase: phase, + consecutiveFailures: consecutiveFailures, + hasVisibleContent: input.hasVisibleContent, + hasCompletedInitialSync: input.hasCompletedInitialSync, + hasMoreAfter: input.hasMoreAfter + ) + } + + init( + phase: AgentConnectivityPhase, + consecutiveFailures: Int, + hasVisibleContent: Bool, + hasCompletedInitialSync: Bool, + hasMoreAfter: Bool + ) { + if consecutiveFailures >= 2 { + self = hasVisibleContent ? .stale : .error + } else if !hasVisibleContent, phase != .connected { + self = .loading + } else if !hasVisibleContent, hasMoreAfter { + self = .loading + } else if !hasVisibleContent, hasCompletedInitialSync { + self = .empty + } else { + self = .hidden + } + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptSyncStatusView.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptSyncStatusView.swift new file mode 100644 index 000000000000..e8c1c68504a5 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptSyncStatusView.swift @@ -0,0 +1,76 @@ +#if os(iOS) +import CmuxAgentGUIProjection +import SwiftUI + +struct TranscriptSyncStatusView: View { + let presentation: TranscriptSyncPresentation + let theme: AgentGUITheme + let retry: () -> Void + let showTerminal: () -> Void + + var body: some View { + switch presentation { + case .hidden: + EmptyView() + case .empty: + Text(AgentGUIL10n.string( + "agent.transcript.empty", + defaultValue: "No messages yet. Say something." + )) + .font(.footnote) + .foregroundStyle(Color(theme.faintForeground)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityIdentifier("AgentTranscriptEmptyState") + .allowsHitTesting(false) + case .loading: + VStack(spacing: 10) { + ProgressView() + Text(AgentGUIL10n.string("agent.sync.loading", defaultValue: "Loading conversation…")) + .font(.footnote) + } + .foregroundStyle(Color(theme.dimForeground)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .error: + statusCard( + title: AgentGUIL10n.string("agent.sync.failed.title", defaultValue: "Conversation unavailable"), + message: AgentGUIL10n.string( + "agent.sync.failed.message", + defaultValue: "cmux could not update this conversation. Retry or continue in Terminal." + ) + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .stale: + statusCard( + title: AgentGUIL10n.string("agent.sync.stale.title", defaultValue: "Showing saved conversation"), + message: AgentGUIL10n.string( + "agent.sync.stale.message", + defaultValue: "New activity is not available yet." + ) + ) + .frame(maxHeight: .infinity, alignment: .top) + } + } + + private func statusCard(title: String, message: String) -> some View { + VStack(spacing: 8) { + Text(title).font(.headline) + Text(message) + .font(.footnote) + .foregroundStyle(Color(theme.dimForeground)) + .multilineTextAlignment(.center) + HStack { + Button(AgentGUIL10n.string("agent.sync.retry", defaultValue: "Retry"), action: retry) + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("AgentSyncRetry") + Button(AgentGUIL10n.string("agent.ask.showTerminal", defaultValue: "Show Terminal"), action: showTerminal) + .buttonStyle(.bordered) + .accessibilityIdentifier("AgentSyncShowTerminal") + } + } + .padding(16) + .background(Color(theme.raisedBackground).opacity(0.96), in: RoundedRectangle(cornerRadius: 14)) + .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color(theme.border), lineWidth: 1)) + .padding(18) + } +} +#endif diff --git a/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptUnreadTracker.swift b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptUnreadTracker.swift new file mode 100644 index 000000000000..aaba25b74106 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Sources/CmuxAgentGUIUI/TranscriptUnreadTracker.swift @@ -0,0 +1,27 @@ +import CmuxAgentGUIProjection + +struct TranscriptUnreadTracker: Sendable { + private var newestVisibleRowID: TranscriptRowID? + + mutating func unreadCount( + rows: [TranscriptRow], + visibleRowIDs: Set + ) -> Int { + let indexes = Dictionary(uniqueKeysWithValues: rows.enumerated().map { ($1.rowID, $0) }) + if let candidate = visibleRowIDs.min(by: { + (indexes[$0] ?? .max) < (indexes[$1] ?? .max) + }), let candidateIndex = indexes[candidate] { + if let existingID = newestVisibleRowID, let existingIndex = indexes[existingID] { + if candidateIndex < existingIndex { + newestVisibleRowID = candidate + } + } else { + newestVisibleRowID = candidate + } + } + guard let newestVisibleRowID, let boundary = indexes[newestVisibleRowID] else { + return 0 + } + return rows[.. TerminalTheme { + TerminalTheme( + background: background, + foreground: foreground, + cursor: foreground, + selectionBackground: background, + selectionForeground: foreground, + palette: palette + ) + } + + private func expect(_ color: AgentGUIRGBColor, equals component: Double) { + #expect(abs(color.red - component) < 0.000_001) + #expect(abs(color.green - component) < 0.000_001) + #expect(abs(color.blue - component) < 0.000_001) + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/AgentHistoryLoadFailurePolicyTests.swift b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/AgentHistoryLoadFailurePolicyTests.swift new file mode 100644 index 000000000000..7b927322fbb8 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/AgentHistoryLoadFailurePolicyTests.swift @@ -0,0 +1,41 @@ +import CmuxAgentGUIProjection +import CmuxAgentReplica +@testable import CmuxAgentGUIUI +import Testing + +@Suite +struct AgentHistoryLoadFailurePolicyTests { + @Test + func hidesHistoryBannerWhenTranscriptRowsRemainUsable() { + let input = TranscriptProjectionInput(entries: [ + EntrySnapshot( + journalID: JournalID(rawValue: "journal"), + seq: EntrySeq(rawValue: 1), + kind: .agentProse, + content: EntryContent( + contentHash: 1, + payload: .agentProse(AgentProsePayload(markdown: "visible")) + ), + version: EntityVersion(rawValue: 1), + timestampMilliseconds: 1 + ) + ], hasMoreBefore: true) + + #expect(!AgentHistoryLoadFailurePolicy.shouldShowBanner(failure: .older, input: input)) + #expect(!AgentHistoryLoadFailurePolicy.shouldShowBanner(failure: .head, input: input)) + } + + @Test + func showsHistoryBannerWhenNoTranscriptRowsCanRender() { + let input = TranscriptProjectionInput(entries: [], hasMoreBefore: true) + + #expect(AgentHistoryLoadFailurePolicy.shouldShowBanner(failure: .older, input: input)) + } + + @Test + func noFailureNeverShowsHistoryBanner() { + let input = TranscriptProjectionInput(entries: [], hasMoreBefore: true) + + #expect(!AgentHistoryLoadFailurePolicy.shouldShowBanner(failure: nil, input: input)) + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/AgentTranscriptArtifactRouteTests.swift b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/AgentTranscriptArtifactRouteTests.swift new file mode 100644 index 000000000000..c2b80c6368ac --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/AgentTranscriptArtifactRouteTests.swift @@ -0,0 +1,35 @@ +import CmuxAgentChat +import CmuxAgentChatUI +import Foundation +import Testing + +@testable import CmuxAgentGUIUI + +@Suite("Agent transcript artifact routing") +struct AgentTranscriptArtifactRouteTests { + @Test("presented artifacts retain the live transcript loader") + func retainsLiveLoader() async throws { + let expectedPath = "/tmp/agent-output.png" + let loader = ChatArtifactLoader( + supportsArtifacts: true, + scope: .chat(sessionID: "agent-session"), + stat: { path in + #expect(path == expectedPath) + return ChatArtifactStat( + exists: true, + isDirectory: false, + size: 128, + modifiedAt: Date(timeIntervalSince1970: 1), + kind: .image, + mimeType: "image/png" + ) + } + ) + + let route = AgentTranscriptArtifactRoute(path: expectedPath, loader: loader) + + #expect(route.path == expectedPath) + #expect(route.loader.scope == .chat(sessionID: "agent-session")) + #expect(try await route.loader.stat(path: route.path).exists) + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/AgentTranscriptRenderAdapterAttachmentTests.swift b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/AgentTranscriptRenderAdapterAttachmentTests.swift new file mode 100644 index 000000000000..26d0a69d63b9 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/AgentTranscriptRenderAdapterAttachmentTests.swift @@ -0,0 +1,253 @@ +import CmuxAgentChat +import CmuxAgentGUIProjection +import CmuxAgentReplica +import Testing + +@testable import CmuxAgentGUIUI + +@Suite("Agent transcript attachment rendering") +struct AgentTranscriptRenderAdapterAttachmentTests { + @Test("image metadata survives replica-to-chat projection") + func imageMetadataSurvivesProjection() throws { + let journalID = JournalID(rawValue: "image-metadata") + let seq = EntrySeq(rawValue: 9) + let row = TranscriptRow( + rowID: .entry(journalID: journalID, seq: seq), + rowKind: .attachment(AttachmentPayload( + kind: "image", + summary: "Screenshot", + attachmentID: "attachment-9", + displayName: "screen.png", + hostPath: "/tmp/screen.png", + mimeType: "image/png", + byteCount: 456_789, + width: 1_600, + height: 900, + aspectRatio: 16.0 / 9.0 + )) + ) + + let rendered = try #require(AgentTranscriptRenderAdapter().rows(from: [row]).first) + guard case .message(let snapshot) = rendered.content, + case .attachment(let attachment) = snapshot.message.kind else { + Issue.record("Expected the attachment row to render as a chat attachment") + return + } + + #expect(attachment.media == .image) + #expect(attachment.displayName == "screen.png") + #expect(attachment.hostPath == "/tmp/screen.png") + #expect(attachment.mimeType == "image/png") + #expect(attachment.byteCount == 456_789) + #expect(attachment.pixelWidth == 1_600) + #expect(attachment.pixelHeight == 900) + #expect(attachment.aspectRatio == 16.0 / 9.0) + } + + @Test("image extension without MIME type still renders inline") + func imageExtensionWithoutMIMETypeStillRendersInline() throws { + let attachment = try Self.projectedAttachment(AttachmentPayload( + kind: "file", + summary: "Screenshot", + displayName: "screen.PNG", + hostPath: "/tmp/screen.PNG", + mimeType: nil + )) + + #expect(attachment.media == .image) + #expect(attachment.displayName == "screen.PNG") + #expect(attachment.hostPath == "/tmp/screen.PNG") + } + + @Test("image dimensions without MIME type still render inline") + func imageDimensionsWithoutMIMETypeStillRenderInline() throws { + let attachment = try Self.projectedAttachment(AttachmentPayload( + kind: "artifact", + summary: "Generated preview", + displayName: nil, + hostPath: "/tmp/generated-preview", + mimeType: nil, + width: 512, + height: 768 + )) + + #expect(attachment.media == .image) + #expect(attachment.pixelWidth == 512) + #expect(attachment.pixelHeight == 768) + } + + @Test("ratio-only image metadata survives projection") + func ratioOnlyImageMetadataSurvivesProjection() throws { + let attachment = try Self.projectedAttachment(AttachmentPayload( + kind: "image", + summary: "Generated preview", + displayName: "preview", + hostPath: "/tmp/generated-preview", + mimeType: "image/png", + aspectRatio: 9.0 / 16.0 + )) + + #expect(attachment.media == .image) + #expect(attachment.pixelWidth == nil) + #expect(attachment.pixelHeight == nil) + #expect(attachment.aspectRatio == 9.0 / 16.0) + } + + @Test("SVG artifact paths render as inline image previews") + func svgArtifactPathsRenderAsInlineImagePreviews() throws { + let attachment = try Self.projectedAttachment(AttachmentPayload( + kind: "file", + summary: "Vector preview", + displayName: "diagram.svg", + hostPath: "/tmp/diagram.svg", + mimeType: "image/svg+xml", + byteCount: 1_024, + width: 480, + height: 270 + )) + + #expect(attachment.media == .image) + #expect(attachment.displayName == "diagram.svg") + #expect(attachment.hostPath == "/tmp/diagram.svg") + #expect(attachment.mimeType == "image/svg+xml") + #expect(attachment.byteCount == 1_024) + #expect(attachment.pixelWidth == 480) + #expect(attachment.pixelHeight == 270) + } + + @Test("markdown image references split into inline agent image rows") + func markdownImageReferencesSplitIntoInlineAgentImageRows() throws { + let journalID = JournalID(rawValue: "markdown-image") + let row = TranscriptRow( + rowID: .entry(journalID: journalID, seq: EntrySeq(rawValue: 12)), + rowKind: .proseAgent( + text: "Here is the preview.\n\n![Agent GUI preview](/tmp/cmux-agent-gui-preview.png)\n\nDone.", + grouping: .single + ) + ) + + let rendered = AgentTranscriptRenderAdapter().rows(from: [row]) + #expect(rendered.count == 3) + + guard case .message(let first) = rendered[0].content, + case .prose(let firstProse) = first.message.kind, + case .message(let image) = rendered[1].content, + case .attachment(let attachment) = image.message.kind, + case .message(let last) = rendered[2].content, + case .prose(let lastProse) = last.message.kind else { + Issue.record("Expected prose, image attachment, prose render rows") + return + } + + #expect(first.message.role == .agent) + #expect(firstProse.text == "Here is the preview.") + #expect(image.message.role == .agent) + #expect(attachment.media == .image) + #expect(attachment.displayName == "Agent GUI preview") + #expect(attachment.hostPath == "/tmp/cmux-agent-gui-preview.png") + #expect(attachment.mimeType == "image/png") + #expect(last.message.role == .agent) + #expect(lastProse.text == "Done.") + } + + @Test("markdown image title dimensions survive projection") + func markdownImageTitleDimensionsSurviveProjection() throws { + let attachment = try Self.projectedMarkdownImageAttachment( + "![Agent GUI preview](/tmp/cmux-agent-gui-preview.png \"640x360\")" + ) + + #expect(attachment.media == .image) + #expect(attachment.pixelWidth == 640) + #expect(attachment.pixelHeight == 360) + } + + @Test("markdown image filename dimensions survive projection") + func markdownImageFilenameDimensionsSurviveProjection() throws { + let attachment = try Self.projectedMarkdownImageAttachment( + "![Agent GUI preview](/tmp/cmux-agent-gui-preview-480x270.png)" + ) + + #expect(attachment.media == .image) + #expect(attachment.pixelWidth == 480) + #expect(attachment.pixelHeight == 270) + } + + @Test("markdown image query dimensions survive projection without polluting host path") + func markdownImageQueryDimensionsSurviveProjectionWithoutPollutingHostPath() throws { + let attachment = try Self.projectedMarkdownImageAttachment( + "![Agent GUI preview](/tmp/cmux-agent-gui-preview.png?width=1024&height=768)" + ) + + #expect(attachment.media == .image) + #expect(attachment.hostPath == "/tmp/cmux-agent-gui-preview.png") + #expect(attachment.pixelWidth == 1024) + #expect(attachment.pixelHeight == 768) + } + + @Test("markdown image ratio metadata survives projection without dimensions") + func markdownImageRatioMetadataSurvivesProjectionWithoutDimensions() throws { + let attachment = try Self.projectedMarkdownImageAttachment( + "![Agent GUI preview](/tmp/cmux-agent-gui-preview.png?aspect_ratio=1.333333)" + ) + + #expect(attachment.media == .image) + #expect(attachment.hostPath == "/tmp/cmux-agent-gui-preview.png") + #expect(attachment.pixelWidth == nil) + #expect(attachment.pixelHeight == nil) + #expect(attachment.aspectRatio == 1.333333) + } + + @Test("attachment author role controls rendered message role") + func attachmentAuthorRoleControlsRenderedMessageRole() throws { + let row = TranscriptRow( + rowID: .entry(journalID: JournalID(rawValue: "agent-attachment"), seq: EntrySeq(rawValue: 1)), + rowKind: .attachment(AttachmentPayload( + kind: "image", + summary: "Preview", + hostPath: "/tmp/preview.png", + mimeType: "image/png", + authorRole: "agent" + )) + ) + + let rendered = try #require(AgentTranscriptRenderAdapter().rows(from: [row]).first) + guard case .message(let snapshot) = rendered.content, + case .attachment = snapshot.message.kind else { + Issue.record("Expected an attachment message") + return + } + #expect(snapshot.message.role == .agent) + } + + private static func projectedAttachment(_ payload: AttachmentPayload) throws -> ChatAttachment { + let row = TranscriptRow( + rowID: .entry(journalID: JournalID(rawValue: "image-fallback"), seq: EntrySeq(rawValue: 1)), + rowKind: .attachment(payload) + ) + let rendered = try #require(AgentTranscriptRenderAdapter().rows(from: [row]).first) + guard case .message(let snapshot) = rendered.content, + case .attachment(let attachment) = snapshot.message.kind else { + Issue.record("Expected the attachment row to render as a chat attachment") + throw AgentTranscriptRenderAdapterAttachmentTestError.expectedAttachment + } + return attachment + } + + private static func projectedMarkdownImageAttachment(_ markdown: String) throws -> ChatAttachment { + let row = TranscriptRow( + rowID: .entry(journalID: JournalID(rawValue: "markdown-image-metadata"), seq: EntrySeq(rawValue: 1)), + rowKind: .proseAgent(text: markdown, grouping: .single) + ) + let rendered = try #require(AgentTranscriptRenderAdapter().rows(from: [row]).first) + guard case .message(let snapshot) = rendered.content, + case .attachment(let attachment) = snapshot.message.kind else { + Issue.record("Expected the markdown image row to render as a chat attachment") + throw AgentTranscriptRenderAdapterAttachmentTestError.expectedAttachment + } + return attachment + } +} + +private enum AgentTranscriptRenderAdapterAttachmentTestError: Error { + case expectedAttachment +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/AgentTranscriptRenderAdapterFallbackTests.swift b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/AgentTranscriptRenderAdapterFallbackTests.swift new file mode 100644 index 000000000000..30228fc28352 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/AgentTranscriptRenderAdapterFallbackTests.swift @@ -0,0 +1,153 @@ +import CmuxAgentReplica +import Testing + +@testable import CmuxAgentGUIUI + +@Suite("Agent transcript fallback title rendering") +struct AgentTranscriptFallbackTitleTests { + @Test("fallback activity titles prefer useful summaries over raw event kinds") + func fallbackActivityTitlesPreferUsefulSummaries() { + let title = AgentGUIL10n.compactActivityTitle( + kindLabel: "future_agent_internal_signal", + summary: "Indexed 12 files" + ) + + #expect(title == "Indexed 12 files") + #expect(!title.contains("future_agent_internal_signal")) + } + + @Test("fallback activity titles without summaries only show known useful kinds") + func fallbackActivityTitlesWithoutSummariesOnlyShowKnownUsefulKinds() { + #expect(AgentGUIL10n.compactActivityTitle(kindLabel: "command", summary: "") == "Command") + #expect(AgentGUIL10n.compactActivityTitle( + kindLabel: "future_agent_internal_signal", + summary: "" + ) == "Activity") + } + + @Test("unsupported titles never fall back to raw event kinds") + func unsupportedTitlesNeverFallBackToRawEventKinds() { + let withSummary = AgentGUIL10n.compactUnsupportedTitle( + rawKind: "future_agent_internal_signal", + summary: "Generated an image preview" + ) + let withoutSummary = AgentGUIL10n.compactUnsupportedTitle( + rawKind: "future_agent_internal_signal", + summary: "future_agent_internal_signal" + ) + + #expect(withSummary == "Generated an image preview") + #expect(!withSummary.contains("future_agent_internal_signal")) + #expect(withoutSummary == "Activity") + } + + @Test("future status titles use detail or localized status instead of raw codes") + func futureStatusTitlesUseDetailOrLocalizedStatus() { + let withDetail = AgentGUIL10n.compactStatusTitle( + code: .other("future_agent_status"), + detail: "Waiting for approval" + ) + let withoutUsefulDetail = AgentGUIL10n.compactStatusTitle( + code: .other("future_agent_status"), + detail: "future_agent_status" + ) + + #expect(withDetail == "Waiting for approval") + #expect(!withDetail.contains("future_agent_status")) + #expect(withoutUsefulDetail == "Status") + } +} + +#if os(iOS) +import CmuxAgentGUIProjection + +@Suite("Agent transcript fallback row rendering") +struct AgentTranscriptRenderAdapterFallbackTests { + @Test("generic activity rows prefer useful summaries over raw event kinds") + func genericActivityRowsPreferUsefulSummaries() throws { + let row = Self.row(.genericActivity(TranscriptGenericActivity( + kindLabel: "future_agent_internal_signal", + summary: "Indexed 12 files" + ))) + + let metadata = try Self.metadata(from: row) + + #expect(metadata == "Indexed 12 files") + #expect(!metadata.contains("future_agent_internal_signal")) + #expect(row.accessibilityLabel == "Indexed 12 files") + } + + @Test("generic activity rows without a summary only show known useful kinds") + func genericActivityRowsWithoutSummaryOnlyShowKnownUsefulKinds() throws { + let command = Self.row(.genericActivity(TranscriptGenericActivity( + kindLabel: "command", + summary: "" + ))) + let unknown = Self.row(.genericActivity(TranscriptGenericActivity( + kindLabel: "future_agent_internal_signal", + summary: "" + ))) + + #expect(try Self.metadata(from: command) == "Command") + #expect(try Self.metadata(from: unknown) == "Activity") + #expect(unknown.accessibilityLabel == "Activity") + } + + @Test("unsupported rows do not expose raw event kinds as transcript titles") + func unsupportedRowsDoNotExposeRawEventKindsAsTitles() throws { + let withSummary = Self.row(.unsupported( + rawKind: "future_agent_internal_signal", + summary: "Generated an image preview" + )) + let withoutSummary = Self.row(.unsupported( + rawKind: "future_agent_internal_signal", + summary: "future_agent_internal_signal" + )) + + #expect(try Self.metadata(from: withSummary) == "Generated an image preview") + #expect(!((try Self.metadata(from: withSummary)).contains("future_agent_internal_signal"))) + #expect(try Self.metadata(from: withoutSummary) == "Activity") + #expect(withoutSummary.accessibilityLabel == "Activity") + } + + @Test("future status rows use detail or a localized status title instead of raw codes") + func futureStatusRowsUseDetailOrLocalizedStatusTitle() throws { + let withDetail = Self.row(.status( + code: .other("future_agent_status"), + detail: "Waiting for approval" + )) + let withoutUsefulDetail = Self.row(.status( + code: .other("future_agent_status"), + detail: "future_agent_status" + )) + + #expect(try Self.metadata(from: withDetail) == "Waiting for approval") + #expect(!((try Self.metadata(from: withDetail)).contains("future_agent_status"))) + #expect(try Self.metadata(from: withoutUsefulDetail) == "Status") + #expect(withoutUsefulDetail.accessibilityLabel == "Status") + } + + private static func row(_ kind: TranscriptRowKind) -> TranscriptRow { + TranscriptRow( + rowID: .entry( + journalID: JournalID(rawValue: "fallback-rows"), + seq: EntrySeq(rawValue: 1) + ), + rowKind: kind + ) + } + + private static func metadata(from row: TranscriptRow) throws -> String { + let rendered = try #require(AgentTranscriptRenderAdapter().rows(from: [row]).first) + guard case .metadata(let value) = rendered.content else { + Issue.record("Expected a metadata render row") + throw AgentTranscriptRenderAdapterFallbackTestError.expectedMetadata + } + return value + } +} + +private enum AgentTranscriptRenderAdapterFallbackTestError: Error { + case expectedMetadata +} +#endif diff --git a/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/AgentTranscriptRowSelectionResolverTests.swift b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/AgentTranscriptRowSelectionResolverTests.swift new file mode 100644 index 000000000000..f7603fae6ea4 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/AgentTranscriptRowSelectionResolverTests.swift @@ -0,0 +1,92 @@ +import CmuxAgentGUIProjection +import CmuxAgentReplica +import Foundation +import Testing + +@testable import CmuxAgentGUIUI + +@Suite("Agent transcript row selection resolver") +struct AgentTranscriptRowSelectionResolverTests { + @Test("activity row taps resolve against current rows") + func activityRowTapsResolveAgainstCurrentRows() throws { + let details = Self.activityDetails() + let rows = [ + AgentTranscriptRenderRow(id: "activity-row", content: .activity(details)), + ] + + let resolved = try #require(AgentTranscriptRowSelectionResolver.activity( + rowID: "activity-row", + rows: rows + )) + + #expect(resolved == details) + } + + @Test("stale activity row taps fail closed") + func staleActivityRowTapsFailClosed() { + let rows = [ + AgentTranscriptRenderRow(id: "replacement-row", content: .metadata("Updated")), + ] + + #expect(AgentTranscriptRowSelectionResolver.activity(rowID: "activity-row", rows: rows) == nil) + } + + @Test("ask and failed ticket taps resolve by row identity") + func askAndFailedTicketTapsResolveByRowIdentity() throws { + let ask = PendingAsk( + id: "ask-1", + sessionID: AgentSessionID(rawValue: "session"), + kind: .question, + promptSummary: "Choose", + options: ["A", "B"], + state: .active + ) + let ticketID = try #require(UUID(uuidString: "00000000-0000-4000-8000-000000000001")) + let ticket = SendTicket( + id: ticketID, + sessionID: AgentSessionID(rawValue: "session"), + text: "retry me", + attachmentCount: 0, + state: .failed(code: "offline"), + createdAt: 1 + ) + let rows = [ + AgentTranscriptRenderRow(id: "ask-row", content: .ask(ask)), + AgentTranscriptRenderRow(id: "ticket-row", content: .pendingTicket(ticket)), + ] + + #expect(try #require(AgentTranscriptRowSelectionResolver.ask(rowID: "ask-row", rows: rows)) == ask) + #expect(try #require(AgentTranscriptRowSelectionResolver.failedTicket( + rowID: "ticket-row", + rows: rows + )) == ticket) + } + + private static func activityDetails() -> TranscriptActivityDetails { + let journalID = JournalID(rawValue: "selection") + let turnID = TranscriptTurnID( + journalID: journalID, + promptSeq: EntrySeq(rawValue: 1), + segmentAnchorSeq: EntrySeq(rawValue: 1) + ) + return TranscriptActivityDetails( + turnID: turnID, + summary: TranscriptActivitySummary( + editedFileCount: 0, + readFileCount: 0, + searchedCode: false, + listedFiles: false, + commandCount: 0, + eventCount: 1, + items: [ + TranscriptActivityItem( + id: .entry(journalID: journalID, seq: EntrySeq(rawValue: 2)), + kind: .unknown("event"), + summary: "Processed event", + isRunning: false + ), + ] + ) + ) + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/AgentTranscriptScrollPillLayoutPolicyTests.swift b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/AgentTranscriptScrollPillLayoutPolicyTests.swift new file mode 100644 index 000000000000..573701bd8494 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/AgentTranscriptScrollPillLayoutPolicyTests.swift @@ -0,0 +1,23 @@ +#if os(iOS) +import Testing + +@testable import CmuxAgentGUIUI + +@Suite("Agent transcript scroll pill layout policy") +struct AgentTranscriptScrollPillLayoutPolicyTests { + @Test("keeps the baseline visual gap when no composer is overlaid") + func baselineGapWithoutComposerOverlay() { + #expect(AgentTranscriptScrollPillLayoutPolicy.bottomPadding(composerBottomInset: 0) == 10) + } + + @Test("clears the composer overlay with a stable visual gap") + func clearsComposerOverlay() { + #expect(AgentTranscriptScrollPillLayoutPolicy.bottomPadding(composerBottomInset: 115.2) == 126) + } + + @Test("clamps negative overlay measurements") + func clampsNegativeOverlayMeasurements() { + #expect(AgentTranscriptScrollPillLayoutPolicy.bottomPadding(composerBottomInset: -12) == 10) + } +} +#endif diff --git a/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/AgentTranscriptTailRequestPolicyTests.swift b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/AgentTranscriptTailRequestPolicyTests.swift new file mode 100644 index 000000000000..8c97153f4882 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/AgentTranscriptTailRequestPolicyTests.swift @@ -0,0 +1,51 @@ +#if os(iOS) +import Testing +import CmuxAgentChatUI + +@testable import CmuxAgentGUIUI + +@Suite("Agent transcript tail request policy") +struct AgentTranscriptTailRequestPolicyTests { + @Test("loaded tail jumps locally without waiting for semantic paging") + func loadedTailUsesLocalScroll() { + #expect(AgentTranscriptTailRequestPolicy.action(hasMoreAfter: false) == .localScroll) + } + + @Test("paged newer history uses authoritative semantic tail") + func pagedTailUsesSemanticTail() { + #expect(AgentTranscriptTailRequestPolicy.action(hasMoreAfter: true) == .semanticTail) + } + + @Test("local tail waits for native viewport confirmation before attaching") + func localTailPreservesDetachedFollowStateBeforeCommand() { + let current = ConversationFollowState.detached( + anchorID: "middle-row", + offset: 24, + unseenCount: 3 + ) + + #expect( + AgentTranscriptTailRequestPolicy.followStateBeforeCommand( + current: current, + action: .localScroll + ) == current + ) + } + + @Test("semantic tail marks the pending authoritative load") + func semanticTailMarksJumpingBeforeCommand() { + let current = ConversationFollowState.detached( + anchorID: "middle-row", + offset: 24, + unseenCount: 3 + ) + + #expect( + AgentTranscriptTailRequestPolicy.followStateBeforeCommand( + current: current, + action: .semanticTail + ) == .jumpingToTail + ) + } +} +#endif diff --git a/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/DemoFixtureDecodingTests.swift b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/DemoFixtureDecodingTests.swift new file mode 100644 index 000000000000..18a54368beca --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/DemoFixtureDecodingTests.swift @@ -0,0 +1,20 @@ +import CmuxAgentReplica +import Foundation +import Testing + +@Suite struct DemoFixtureDecodingTests { + /// The bundled demo transcript must decode with the CURRENT replay + /// encoding; a hand-edited or stale fixture silently breaks the demo + /// screen's playback, which is this program's UI evidence surface. + @Test func bundledDemoTranscriptDecodesWithoutSkippedLines() throws { + let fixtureURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/CmuxAgentGUIUI/Resources/demo-transcript.jsonl") + let data = try Data(contentsOf: fixtureURL) + let log = ReplicaReplayLog.decodeJSONL(data) + #expect(log.records.count >= 10) + #expect(log.skippedLineCount == 0) + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/TranscriptActivityDetailModelTests.swift b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/TranscriptActivityDetailModelTests.swift new file mode 100644 index 000000000000..94cf3aef4869 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/TranscriptActivityDetailModelTests.swift @@ -0,0 +1,219 @@ +@testable import CmuxAgentGUIUI +import CmuxAgentGUIProjection +import CmuxAgentReplica +import Testing + +@Suite("Transcript activity detail model") +struct TranscriptActivityDetailModelTests { + @Test("tool details retain command input output status and duration") + func toolDetails() { + let model = Self.model(payload: .toolRun(ToolRunPayload( + toolName: "bash", + argumentSummary: "swift test", + resultSummary: "Passed", + isTerminal: true, + exitCode: 0, + isRunning: false, + inputDetail: "{\"cwd\":\"/repo\"}", + command: "swift test", + output: "107 tests passed", + durationSeconds: 3.25, + status: "completed" + ))) + + #expect(model.sections.contains { $0.label == .tool && $0.value == "bash" }) + #expect(model.sections.contains { $0.label == .arguments && $0.isCode }) + #expect(model.sections.contains { $0.label == .command && $0.value == "swift test" }) + #expect(model.sections.contains { $0.label == .output && $0.value == "107 tests passed" }) + #expect(model.sections.contains { $0.label == .status && $0.value == "completed" }) + #expect(model.sections.contains { $0.label == .duration }) + } + + @Test("file details retain path counts result and unified diff") + func fileDetails() { + let model = Self.model(payload: .fileChange(FileChangePayload( + path: "Sources/App.swift", + changeKind: .edit, + resultSummary: "Updated renderer", + additions: 8, + deletions: 2, + unifiedDiff: "@@ -1 +1 @@\n-old\n+new" + ))) + + #expect(model.sections.contains { $0.label == .path && $0.value == "Sources/App.swift" }) + #expect(model.sections.contains { $0.label == .changes && $0.value.contains("+8") }) + #expect(model.sections.contains { $0.label == .result && $0.value == "Updated renderer" }) + #expect(model.sections.contains { $0.label == .diff && $0.isCode }) + } + + @Test("unknown raw JSON appears only as a fallback without a summary") + func unknownFallback() { + let summarized = Self.model(payload: .unknown(UnknownPayload( + rawKind: "future", + summary: "Useful summary", + rawJSON: "{\"secret\":true}" + ))) + #expect(!summarized.sections.contains { $0.label == .diagnostic }) + + let fallback = Self.model(payload: .unknown(UnknownPayload( + rawKind: "future", + rawJSON: "{\"value\":1}" + ))) + #expect(fallback.sections.contains { $0.label == .diagnostic && $0.isCode }) + } + + @Test("attachment details format metadata instead of exposing a raw byte integer") + func attachmentMetadata() { + let model = Self.model(payload: .attachment(AttachmentPayload( + kind: "image", + summary: "Screenshot", + displayName: "screen.png", + hostPath: "/tmp/screen.png", + mimeType: "image/png", + byteCount: 1_024, + width: 800, + height: 600 + ))) + + let metadata = model.sections.first { $0.label == .metadata }?.value + #expect(metadata?.contains("image/png") == true) + #expect(metadata?.contains("800 × 600") == true) + #expect(metadata != "1024") + } + + @Test("sparse activity details fail open instead of trapping") + func sparseActivityDetailsFailOpen() { + let model = Self.model( + payload: .unknown(UnknownPayload(rawKind: "")), + itemKind: .unknown(""), + itemSummary: "" + ) + + #expect(model.title == "Activity") + #expect(model.sections.count == 1) + #expect(model.sections.first?.label == .summary) + #expect(model.sections.first?.value == "Activity") + #expect(model.sections.first?.isCode == false) + } + + @Test("large activity details are bounded before sheet presentation") + func largeActivityDetailsAreBounded() { + let output = String(repeating: "0123456789", count: 7_000) + let model = Self.model(payload: .toolRun(ToolRunPayload( + toolName: "bash", + argumentSummary: "large output", + resultSummary: "done", + isTerminal: true, + exitCode: 0, + isRunning: false, + output: output + ))) + + let renderedOutput = model.sections.first { $0.label == .output }?.value + #expect(renderedOutput != nil) + #expect(renderedOutput?.hasPrefix("0123456789") == true) + #expect(renderedOutput?.contains("Detail truncated for performance") == true) + #expect((renderedOutput?.count ?? 0) < output.count) + } + + @Test("activity timeline presentation caps instantiated detail rows") + func activityTimelinePresentationCapsInstantiatedRows() { + let journal = JournalID(rawValue: "detail") + let items = (0..<5).map { index in + TranscriptActivityItem( + id: .entry(journalID: journal, seq: EntrySeq(rawValue: index + 1)), + kind: .unknown("event"), + summary: "Event \(index)", + isRunning: false, + sourceEntry: nil + ) + } + let details = TranscriptActivityDetails( + turnID: TranscriptTurnID(journalID: journal, promptSeq: nil, segmentAnchorSeq: EntrySeq(rawValue: 1)), + summary: TranscriptActivitySummary( + editedFileCount: 0, + readFileCount: 0, + searchedCode: false, + listedFiles: false, + commandCount: 0, + eventCount: items.count, + items: items + ) + ) + + let presentation = TranscriptActivityTimelinePresentation(details: details, itemLimit: 3) + + #expect(presentation.models.map(\.title) == ["Event 0", "Event 1", "Event 2"]) + #expect(presentation.omittedCount == 2) + } + + @Test("activity timeline presentation disambiguates duplicate source identities") + func activityTimelinePresentationDisambiguatesDuplicateSourceIdentities() { + let journal = JournalID(rawValue: "detail") + let duplicateID = TranscriptRowID.entry(journalID: journal, seq: EntrySeq(rawValue: 7)) + let items = [ + TranscriptActivityItem( + id: duplicateID, + kind: .tool, + summary: "Processed 1 event", + isRunning: false, + sourceEntry: nil + ), + TranscriptActivityItem( + id: duplicateID, + kind: .attachment, + summary: "Hook_Success attachment", + isRunning: false, + sourceEntry: nil + ), + ] + let details = TranscriptActivityDetails( + turnID: TranscriptTurnID(journalID: journal, promptSeq: nil, segmentAnchorSeq: EntrySeq(rawValue: 1)), + summary: TranscriptActivitySummary( + editedFileCount: 0, + readFileCount: 0, + searchedCode: false, + listedFiles: false, + commandCount: 0, + eventCount: items.count, + items: items + ) + ) + + let presentation = TranscriptActivityTimelinePresentation(details: details) + + #expect(presentation.models.map(\.sourceID) == [duplicateID, duplicateID]) + #expect(Set(presentation.models.map(\.id)).count == presentation.models.count) + #expect(presentation.models.map(\.id.description) == [ + "entry:detail:7#0", + "entry:detail:7#1", + ]) + } + + private static func model(payload: EntryPayload) -> TranscriptActivityDetailModel { + model(payload: payload, itemKind: .tool, itemSummary: "summary") + } + + private static func model( + payload: EntryPayload, + itemKind: TranscriptActivityKind, + itemSummary: String + ) -> TranscriptActivityDetailModel { + let journal = JournalID(rawValue: "detail") + let entry = EntrySnapshot( + journalID: journal, + seq: EntrySeq(rawValue: 1), + kind: payload.kind, + content: EntryContent(contentHash: 1, payload: payload), + version: EntityVersion(rawValue: 1) + ) + let item = TranscriptActivityItem( + id: .entry(journalID: journal, seq: entry.seq), + kind: itemKind, + summary: itemSummary, + isRunning: false, + sourceEntry: entry + ) + return TranscriptActivityDetailModel(item: item) + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/TranscriptProjectionDriverTests.swift b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/TranscriptProjectionDriverTests.swift new file mode 100644 index 000000000000..65ffe65c7756 --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/TranscriptProjectionDriverTests.swift @@ -0,0 +1,262 @@ +@testable import CmuxAgentGUIUI +import CmuxAgentGUIProjection +import CmuxAgentReplica +import CmuxAgentSync +import CmuxAgentWire +import Foundation +import Testing + +@MainActor +@Suite struct TranscriptProjectionDriverTests { + @Test func startAndStopOpenAndCloseConversationIdempotently() { + let engine = AgentSyncEngine(transport: FixtureSyncTransport()) + let driver = TranscriptProjectionDriver(engine: engine, sessionID: Self.sessionID) { _ in } + + driver.start() + driver.start() + #expect(engine.conversations[Self.sessionID] != nil) + #expect(engine.conversations.count == 1) + + driver.stop() + driver.stop() + #expect(engine.conversations[Self.sessionID] == nil) + #expect(engine.conversations.isEmpty) + } + + @Test func stoppingOlderDriverKeepsOverlappingDriverConversationOpen() throws { + let engine = AgentSyncEngine(transport: FixtureSyncTransport()) + let driverA = TranscriptProjectionDriver(engine: engine, sessionID: Self.sessionID) { _ in } + let driverB = TranscriptProjectionDriver(engine: engine, sessionID: Self.sessionID) { _ in } + + driverA.start() + let conversation = try #require(engine.conversations[Self.sessionID]) + driverB.start() + driverA.stop() + + #expect(engine.conversations[Self.sessionID] === conversation) + driverB.stop() + #expect(engine.conversations[Self.sessionID] == nil) + } + + @Test func replicaMutationRebuildsInput() async throws { + let engine = AgentSyncEngine(transport: FixtureSyncTransport()) + var inputs: [TranscriptProjectionInput] = [] + let driver = TranscriptProjectionDriver(engine: engine, sessionID: Self.sessionID) { input in + inputs.append(input) + } + driver.start() + + let conversation = try #require(engine.conversations[Self.sessionID]) + conversation.mergePage( + journal: Self.journalID, + entries: [Self.entry(seq: 1)], + windowStart: EntrySeq(rawValue: 1), + windowEnd: EntrySeq(rawValue: 1), + tailSeq: EntrySeq(rawValue: 1), + hasMoreBefore: false + ) + + let observed = await Self.waitUntil { + inputs.last?.entries.map(\.seq.rawValue) == [1] + && inputs.last?.hasCompletedInitialSync == true + } + #expect(observed) + driver.stop() + } + + @Test func emptyProjectionBecomesEligibleOnlyAfterJournalIsKnown() async throws { + let engine = AgentSyncEngine(transport: FixtureSyncTransport()) + var inputs: [TranscriptProjectionInput] = [] + let driver = TranscriptProjectionDriver(engine: engine, sessionID: Self.sessionID) { input in + inputs.append(input) + } + driver.start() + #expect(inputs.last?.hasCompletedInitialSync == false) + + let conversation = try #require(engine.conversations[Self.sessionID]) + conversation.mergePage( + journal: Self.journalID, + entries: [], + windowStart: EntrySeq(rawValue: 0), + windowEnd: EntrySeq(rawValue: 0), + tailSeq: EntrySeq(rawValue: 0), + hasMoreBefore: false + ) + + #expect(await Self.waitUntil { + inputs.last?.hasCompletedInitialSync == true + }) + driver.stop() + } + + @Test func pagingBoundaryChangeRebuildsInput() async throws { + let engine = AgentSyncEngine(transport: FixtureSyncTransport()) + var inputs: [TranscriptProjectionInput] = [] + let driver = TranscriptProjectionDriver(engine: engine, sessionID: Self.sessionID) { input in + inputs.append(input) + } + driver.start() + let conversation = try #require(engine.conversations[Self.sessionID]) + conversation.mergePage( + journal: Self.journalID, + entries: [Self.entry(seq: 1)], + windowStart: EntrySeq(rawValue: 1), + windowEnd: EntrySeq(rawValue: 1), + tailSeq: EntrySeq(rawValue: 2), + hasMoreBefore: true, + hasMoreAfter: true, + startCursor: JournalCursor(rawValue: "before-page"), + endCursor: JournalCursor(rawValue: "after-page") + ) + + let observed = await Self.waitUntil { + inputs.last?.hasMoreBefore == true + && inputs.last?.hasMoreAfter == true + && inputs.last?.startCursor == JournalCursor(rawValue: "before-page") + && inputs.last?.endCursor == JournalCursor(rawValue: "after-page") + } + #expect(observed) + driver.stop() + } + + @Test func streamTickMutationRebuildsInput() async throws { + let transport = FixtureSyncTransport() + try await Self.installLiveSessionFixture(on: transport) + let engine = AgentSyncEngine(transport: transport) + var inputs: [TranscriptProjectionInput] = [] + let driver = TranscriptProjectionDriver(engine: engine, sessionID: Self.sessionID) { input in + inputs.append(input) + } + driver.start() + engine.start() + + #expect(await Self.waitUntil { + inputs.last?.entries.map(\.seq.rawValue) == [1] + && engine.connectivity.phase == .connected + }) + + let tick = try JSONEncoder().encode(GuiEventFrame( + epoch: Self.epoch, + sessionID: Self.sessionID, + payload: .streamTick(GuiStreamTickEvent( + journalID: Self.journalID, + afterSeq: EntrySeq(rawValue: 1), + textTail: "streaming tail", + revision: 1 + )) + )) + await transport.injectFrame( + topic: GuiWireTopic.journal(sessionID: Self.sessionID), + payload: tick + ) + + let observed = await Self.waitUntil { + inputs.last?.streamingTail == TranscriptStreamingTail( + journalID: Self.journalID, + afterSeq: EntrySeq(rawValue: 1), + textTail: "streaming tail", + revision: 1 + ) + } + #expect(observed) + driver.stop() + engine.stop() + } + + @Test func sessionPhaseMutationRebuildsInput() async { + let engine = AgentSyncEngine(transport: FixtureSyncTransport()) + engine.directory.apply(.sessionUpserted(Self.session(phase: .idle, version: 1)), origin: .live) + var inputs: [TranscriptProjectionInput] = [] + let driver = TranscriptProjectionDriver(engine: engine, sessionID: Self.sessionID) { input in + inputs.append(input) + } + driver.start() + #expect(inputs.last?.sessionPhase == .idle) + + engine.directory.apply(.sessionUpserted(Self.session(phase: .working, version: 2)), origin: .live) + + let observed = await Self.waitUntil { + inputs.last?.sessionPhase == .working + } + #expect(observed) + driver.stop() + } + + private static let sessionID = AgentSessionID(rawValue: "session-1") + private static let journalID = JournalID(rawValue: "journal-1") + private static let macDeviceID = MacDeviceID(rawValue: "mac-1") + private static let epoch = ReplicaEpoch(rawValue: "epoch-1") + + private static func installLiveSessionFixture(on transport: FixtureSyncTransport) async throws { + let epoch = Self.epoch + let macDeviceID = Self.macDeviceID + let session = Self.session(phase: .working, version: 1) + let page = GuiEntriesResult( + journalID: journalID, + entries: [Self.entry(seq: 1)], + windowStart: EntrySeq(rawValue: 1), + windowEnd: EntrySeq(rawValue: 1), + tailSeq: EntrySeq(rawValue: 1), + hasMoreBefore: false, + hasMoreAfter: false, + startCursor: nil, + endCursor: nil, + tailCursor: nil, + requiresPagingRestart: false + ) + await transport.setHandler(method: GuiWireMethod.hello) { _ in + try JSONEncoder().encode(GuiHelloResult( + protocol: 1, + serverCaps: ["gui.v1.sessions", "gui.v1.journal"], + epoch: epoch, + macDeviceID: macDeviceID, + serverTimeMS: 1_000 + )) + } + await transport.setHandler(method: GuiWireMethod.sessions) { _ in + try JSONEncoder().encode(GuiSessionsResult(epoch: epoch, sessions: [session])) + } + await transport.setHandler(method: GuiWireMethod.entries) { _ in + try JSONEncoder().encode(page) + } + } + + private static func entry(seq: Int) -> EntrySnapshot { + EntrySnapshot( + journalID: journalID, + seq: EntrySeq(rawValue: seq), + kind: .agentProse, + content: EntryContent( + contentHash: seq, + payload: .agentProse(AgentProsePayload(markdown: "entry \(seq)")) + ), + version: EntityVersion(rawValue: UInt64(seq)) + ) + } + + private static func session(phase: SessionPhase, version: UInt64) -> AgentSessionSnapshot { + AgentSessionSnapshot( + id: sessionID, + macDeviceID: macDeviceID, + kind: .codex, + phase: phase, + tier: .wrapped, + surfaceID: "surface-1", + cwd: "/repo", + title: "Session", + workspaceName: "Workspace", + version: EntityVersion(rawValue: version), + lastActivityHint: Int(version) + ) + } + + private static func waitUntil(_ condition: @MainActor () -> Bool) async -> Bool { + for _ in 0..<1_000 { + if condition() { + return true + } + await Task.yield() + } + return false + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/TranscriptProjectorInternalStatusTests.swift b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/TranscriptProjectorInternalStatusTests.swift new file mode 100644 index 000000000000..404997279b3e --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/TranscriptProjectorInternalStatusTests.swift @@ -0,0 +1,78 @@ +import CmuxAgentGUIProjection +import CmuxAgentReplica +import Testing + +@Suite +struct TranscriptProjectorInternalStatusTests { + @Test + func knownInternalStatusesAreFilteredWhileUnknownAndUserVisibleStatusesFailOpen() { + let journalID = JournalID(rawValue: "internal-statuses") + let entries = [ + Self.entry(journalID: journalID, seq: 1, payload: .userMessage( + UserMessagePayload(text: "prompt", attachmentCount: 0, hasImage: false) + )), + Self.status(journalID: journalID, seq: 2, code: .sessionMeta), + Self.status(journalID: journalID, seq: 3, code: .other("stop_hook_summary")), + Self.status(journalID: journalID, seq: 4, code: .apiError), + Self.entry(journalID: journalID, seq: 5, payload: .unknown( + UnknownPayload(rawKind: "future-event", summary: "preserved") + )), + Self.entry(journalID: journalID, seq: 6, payload: .agentProse( + AgentProsePayload(markdown: "answer") + )), + ] + + let rows = TranscriptProjector().project(TranscriptProjectionInput( + entries: entries, + sessionPhase: .working + )).rows + + #expect(Self.row(seq: 2, journalID: journalID, in: rows) == nil) + #expect(Self.row(seq: 3, journalID: journalID, in: rows) == nil) + let activityIDs = rows.compactMap { row -> [TranscriptRowID]? in + guard case .activitySummary(let summary) = row.rowKind else { return nil } + return summary.items.map(\.id) + }.flatMap { $0 } + #expect(activityIDs.contains(.entry(journalID: journalID, seq: EntrySeq(rawValue: 4)))) + #expect(activityIDs.contains(.entry(journalID: journalID, seq: EntrySeq(rawValue: 5)))) + #expect(Self.row(seq: 6, journalID: journalID, in: rows)?.agentText == "answer") + } + + private static func status(journalID: JournalID, seq: Int, code: StatusCode) -> EntrySnapshot { + entry( + journalID: journalID, + seq: seq, + payload: .status(StatusPayload(code: code, detail: code.rawValue)) + ) + } + + private static func entry(journalID: JournalID, seq: Int, payload: EntryPayload) -> EntrySnapshot { + EntrySnapshot( + journalID: journalID, + seq: EntrySeq(rawValue: seq), + kind: payload.kind, + content: EntryContent(contentHash: seq, payload: payload), + version: EntityVersion(rawValue: UInt64(seq)) + ) + } + + private static func row( + seq: Int, + journalID: JournalID, + in rows: [TranscriptRow] + ) -> TranscriptRow? { + rows.first { $0.rowID == .entry(journalID: journalID, seq: EntrySeq(rawValue: seq)) } + } +} + +private extension TranscriptRow { + var isActivityItem: Bool { + if case .activityItem = rowKind { return true } + return false + } + + var agentText: String? { + if case .proseAgent(let text, _) = rowKind { return text } + return nil + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/TranscriptProjectorStreamingCompletionTests.swift b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/TranscriptProjectorStreamingCompletionTests.swift new file mode 100644 index 000000000000..634db7962e8d --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/TranscriptProjectorStreamingCompletionTests.swift @@ -0,0 +1,60 @@ +import CmuxAgentGUIProjection +import CmuxAgentReplica +import Testing + +@Suite +struct TranscriptProjectorStreamingCompletionTests { + @Test + func durableAgentEntryReplacesStreamingStyle() throws { + let projector = TranscriptProjector() + let confirmed = Self.agent(seq: 1, text: "confirmed") + let streaming = projector.project(TranscriptProjectionInput( + entries: [confirmed], + streamingTail: TranscriptStreamingTail( + journalID: Self.journal, + afterSeq: EntrySeq(rawValue: 1), + textTail: "Hello there, happy to help!", + revision: 1 + ) + )) + let durable = Self.agent(seq: 2, text: "Hello there, happy to help!") + let completed = projector.project( + TranscriptProjectionInput(entries: [confirmed, durable]), + previousRows: streaming.rows + ) + let durableID = TranscriptRowID.entry( + journalID: Self.journal, + seq: EntrySeq(rawValue: 2) + ) + let durableRow = try #require(completed.rows.first { $0.rowID == durableID }) + + guard case .proseAgent(let text, _) = durableRow.rowKind else { + Issue.record("completed reply should use durable prose styling") + return + } + #expect(text == "Hello there, happy to help!") + #expect(completed.rows.allSatisfy { row in + if case .streaming = row.rowKind { return false } + return true + }) + #expect(completed.diff.removed.keys.contains(.streaming( + journalID: Self.journal, + afterSeq: EntrySeq(rawValue: 1) + ))) + } + + private static let journal = JournalID(rawValue: "journal-stream-completion") + + private static func agent(seq: Int, text: String) -> EntrySnapshot { + EntrySnapshot( + journalID: journal, + seq: EntrySeq(rawValue: seq), + kind: .agentProse, + content: EntryContent( + contentHash: seq, + payload: .agentProse(AgentProsePayload(markdown: text)) + ), + version: EntityVersion(rawValue: UInt64(seq)) + ) + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/TranscriptProjectorTests.swift b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/TranscriptProjectorTests.swift new file mode 100644 index 000000000000..f55094a6111b --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/TranscriptProjectorTests.swift @@ -0,0 +1,619 @@ +import CmuxAgentGUIProjection +import CmuxAgentReplica +import Foundation +import Testing + +@Suite +struct TranscriptProjectorTests { + private let projector = TranscriptProjector() + + @Test + func appendOneToLargeWindowHasSmallDiff() { + let previousInput = TranscriptProjectionInput(entries: Self.entries(count: 500)) + let previous = projector.project(previousInput) + let nextInput = TranscriptProjectionInput(entries: Self.entries(count: 501)) + let next = projector.project(nextInput, previousRows: previous.rows) + + #expect(next.diff.inserted[.entry(journalID: Self.journal, seq: EntrySeq(rawValue: 501))] == 500) + #expect(next.diff.appliedOperationCount <= 3) + } + + @Test + func burstAppendUsesFiveNewIdentitiesWithoutReplacingExistingRows() { + let previous = projector.project(TranscriptProjectionInput(entries: Self.entries(count: 220))) + let next = projector.project( + TranscriptProjectionInput(entries: Self.entries(count: 225)), + previousRows: previous.rows + ) + let insertedIDs = Set((221...225).map { seq in + TranscriptRowID.entry(journalID: Self.journal, seq: EntrySeq(rawValue: seq)) + }) + + #expect(Set(next.diff.inserted.keys) == insertedIDs) + #expect(next.diff.removed.isEmpty) + #expect(next.diff.updated.isEmpty) + #expect(Set(previous.rows.map(\.rowID)).isSubset(of: Set(next.rows.map(\.rowID)))) + } + + @Test + func promptPreservesEveryAssistantProseRunInOrder() throws { + let rows = projector.project(TranscriptProjectionInput(entries: [ + Self.user(seq: 1, text: "prompt"), + Self.agent(seq: 2, text: "draft"), + Self.tool(seq: 3, name: "rg", detail: "Theme"), + Self.agent(seq: 4, text: "final"), + ])).rows + let summaryRow = try #require(rows.first { if case .activitySummary = $0.rowKind { true } else { false } }) + let expectedTurn = TranscriptTurnID( + journalID: Self.journal, + promptSeq: EntrySeq(rawValue: 1), + segmentAnchorSeq: EntrySeq(rawValue: 1) + ) + + #expect(summaryRow.rowID == .activitySummary(expectedTurn)) + #expect(summaryRow.turnID == expectedTurn) + guard case .activitySummary(let summary) = summaryRow.rowKind else { return } + #expect(summary.items.map(\.id) == [ + .entry(journalID: Self.journal, seq: EntrySeq(rawValue: 3)), + ]) + #expect(rows.compactMap(\.agentText) == ["draft", "final"]) + #expect(rows.firstIndex { $0.agentText == "draft" }! < rows.firstIndex { if case .activitySummary = $0.rowKind { true } else { false } }!) + #expect(rows.firstIndex { if case .activitySummary = $0.rowKind { true } else { false } }! < rows.firstIndex { $0.agentText == "final" }!) + #expect(rows.row(seq: 4)?.turnID == expectedTurn) + #expect(rows.row(seq: 4)?.endsTurn == true) + } + + @Test + func proseToolsProseKeepsNarrationVisibleAndCompactsTools() throws { + let rows = projector.project(TranscriptProjectionInput(entries: [ + Self.user(seq: 1, text: "prompt"), + Self.agent(seq: 2, text: "I will inspect this."), + Self.tool(seq: 3, name: "rg", detail: "Transcript"), + Self.tool(seq: 4, name: "bash", detail: "swift test"), + Self.agent(seq: 5, text: "The fix is ready."), + ])).rows + + #expect(rows.compactMap(\.agentText) == ["I will inspect this.", "The fix is ready."]) + let summaries = rows.compactMap { row -> TranscriptActivitySummary? in + guard case .activitySummary(let summary) = row.rowKind else { return nil } + return summary + } + let summary = try #require(summaries.single) + #expect(summary.items.map(\.id) == [ + .entry(journalID: Self.journal, seq: EntrySeq(rawValue: 3)), + .entry(journalID: Self.journal, seq: EntrySeq(rawValue: 4)), + ]) + } + + @Test + func preludeUsesStableJournalIdentityAndKeepsFinalAnswerVisible() throws { + let rows = projector.project(TranscriptProjectionInput(entries: [ + Self.tool(seq: 1, name: "ls", detail: "Sources"), + Self.agent(seq: 2, text: "answer"), + ])).rows + let prelude = TranscriptTurnID( + journalID: Self.journal, + promptSeq: nil, + segmentAnchorSeq: EntrySeq(rawValue: 1) + ) + + #expect(rows.contains { $0.rowID == .activitySummary(prelude) }) + #expect(rows.row(seq: 2)?.turnID == prelude) + #expect(rows.row(seq: 2)?.agentText == "answer") + } + + @Test + func latestTurnDoesNotBorrowPromptIdentityFromAnOlderJournal() { + let olderJournal = JournalID(rawValue: "older") + let latestJournal = JournalID(rawValue: "latest") + let rows = projector.project(TranscriptProjectionInput(entries: [ + Self.entry(seq: 1, journalID: olderJournal, payload: .userMessage(UserMessagePayload( + text: "old prompt", + attachmentCount: 0, + hasImage: false + ))), + Self.entry(seq: 2, journalID: latestJournal, payload: .toolRun(ToolRunPayload( + toolName: "bash", + argumentSummary: "work", + isTerminal: true, + isRunning: true + ))), + ], sessionPhase: .working)) + + let latestTurn = TranscriptTurnID( + journalID: latestJournal, + promptSeq: nil, + segmentAnchorSeq: EntrySeq(rawValue: 2) + ) + #expect(rows.rows.contains { $0.turnID == latestTurn }) + #expect(!rows.rows.contains { row in + row.turnID?.journalID == latestJournal && row.turnID?.promptSeq == EntrySeq(rawValue: 1) + }) + } + + @Test + func runningActivityRemainsVisibleAndKeepsEntryIdentity() { + let rows = projector.project(TranscriptProjectionInput( + entries: [ + Self.user(seq: 1, text: "prompt"), + Self.tool(seq: 2, name: "bash", detail: "swift test", isRunning: true), + ], + sessionPhase: .working + )).rows + + let summary = rows.activitySummaries.first + #expect(summary?.items.map(\.id) == [ + .entry(journalID: Self.journal, seq: EntrySeq(rawValue: 2)), + ]) + #expect(summary?.items.first?.isRunning == true) + } + + @Test + func toolPreviewAttachmentsRenderInlineInsteadOfActivityText() throws { + let rows = projector.project(TranscriptProjectionInput(entries: [ + Self.user(seq: 1, text: "prompt"), + Self.entry(seq: 2, payload: .toolRun(ToolRunPayload( + toolName: "view_image", + argumentSummary: "/tmp/preview.png", + isTerminal: false, + isRunning: false, + previewAttachments: [ + AttachmentPayload( + kind: "image", + summary: "preview.png", + displayName: "preview.png", + hostPath: "/tmp/preview.png", + mimeType: "image/png", + byteCount: 68, + width: 1, + height: 1, + authorRole: "agent" + ), + ] + ))), + ])).rows + + #expect(rows.activitySummaries.isEmpty) + let preview = try #require(rows.first { + $0.rowID == .entryAttachment(journalID: Self.journal, seq: EntrySeq(rawValue: 2), index: 0) + }) + guard case .attachment(let attachment) = preview.rowKind else { + Issue.record("Expected preview attachment row") + return + } + #expect(attachment.hostPath == "/tmp/preview.png") + #expect(attachment.width == 1) + #expect(attachment.height == 1) + #expect(attachment.authorRole == "agent") + #expect(preview.endsTurn) + } + + @Test + func liveTurnKeepsPromptIdentityAcrossHole() { + let hole = EntryRange(lowerBound: EntrySeq(rawValue: 3), upperBound: EntrySeq(rawValue: 4)) + let rows = projector.project(TranscriptProjectionInput( + entries: [ + Self.user(seq: 1, text: "prompt"), + Self.tool(seq: 2, name: "rg", detail: "before hole"), + Self.tool(seq: 5, name: "bash", detail: "after hole"), + ], + holes: [hole], + sessionPhase: .working + )).rows + let liveTurn = TranscriptTurnID( + journalID: Self.journal, + promptSeq: EntrySeq(rawValue: 1), + segmentAnchorSeq: EntrySeq(rawValue: 5) + ) + + #expect(rows.activitySummaries.contains { summary in + summary.turnID == liveTurn + && summary.items.map(\.id) == [ + .entry(journalID: Self.journal, seq: EntrySeq(rawValue: 5)), + ] + }) + #expect(rows.row(seq: 2) == nil) + #expect(rows.activitySummaries.flatMap(\.items).map(\.id).contains( + .entry(journalID: Self.journal, seq: EntrySeq(rawValue: 2)) + )) + } + + @Test + func noPromptPreludeSegmentsAcrossHoleBothRender() { + let hole = EntryRange(lowerBound: EntrySeq(rawValue: 2), upperBound: EntrySeq(rawValue: 4)) + let rows = projector.project(TranscriptProjectionInput( + entries: [ + Self.tool(seq: 1, name: "rg", detail: "first prelude segment"), + Self.tool(seq: 5, name: "ls", detail: "second prelude segment"), + ], + holes: [hole] + )).rows + + #expect(rows.activitySummaries.count == 2) + #expect(Set(rows.activitySummaries.compactMap { $0.turnID }).count == 2) + #expect(Set(rows.activitySummaries.flatMap(\.items).map(\.id)) == Set([ + .entry(journalID: Self.journal, seq: EntrySeq(rawValue: 1)), + .entry(journalID: Self.journal, seq: EntrySeq(rawValue: 5)), + ])) + #expect(rows.activitySummaries.allSatisfy { $0.turnID?.promptSeq == nil }) + } + + @Test + func idlePromptedTurnAcrossHoleKeepsBothActivitySegments() { + let hole = EntryRange(lowerBound: EntrySeq(rawValue: 3), upperBound: EntrySeq(rawValue: 4)) + let input = TranscriptProjectionInput( + entries: [ + Self.user(seq: 1, text: "prompt"), + Self.tool(seq: 2, name: "rg", detail: "before hole"), + Self.tool(seq: 5, name: "ls", detail: "after hole"), + ], + holes: [hole] + ) + let first = projector.project(input) + let rows = first.rows + + #expect(rows.activitySummaries.count == 2) + #expect(Set(rows.activitySummaries.compactMap { $0.turnID }).count == 2) + #expect(Set(rows.activitySummaries.flatMap(\.items).map(\.id)) == Set([ + .entry(journalID: Self.journal, seq: EntrySeq(rawValue: 2)), + .entry(journalID: Self.journal, seq: EntrySeq(rawValue: 5)), + ])) + #expect(rows.activitySummaries.allSatisfy { + $0.turnID?.promptSeq == EntrySeq(rawValue: 1) + }) + let second = projector.project(input, previousRows: rows) + #expect(second.rows.map(\.rowID) == rows.map(\.rowID)) + #expect(second.diff.appliedOperationCount == 0) + } + + @Test + func idlePromptedTurnAcrossDayHeaderKeepsBothActivitySegments() { + let rows = projector.project(TranscriptProjectionInput( + entries: [ + Self.user(seq: 1, text: "prompt"), + Self.tool(seq: 2, name: "rg", detail: "first day"), + Self.tool(seq: 3, name: "ls", detail: "second day"), + ], + dayKey: { tick in tick < 3 ? "first day" : "second day" } + )).rows + + #expect(rows.activitySummaries.count == 2) + #expect(Set(rows.activitySummaries.compactMap { $0.turnID }).count == 2) + #expect(Set(rows.activitySummaries.flatMap(\.items).map(\.id)) == Set([ + .entry(journalID: Self.journal, seq: EntrySeq(rawValue: 2)), + .entry(journalID: Self.journal, seq: EntrySeq(rawValue: 3)), + ])) + } + + @Test + func completedActivitySummarizesDeterministicallyAndUnknownFailsOpen() throws { + let rows = projector.project(TranscriptProjectionInput(entries: [ + Self.user(seq: 1, text: "prompt"), + Self.tool(seq: 2, name: "rg", detail: "Theme"), + Self.file(seq: 3, path: "Theme.swift"), + Self.tool(seq: 4, name: "apply_patch", detail: "Theme.swift"), + Self.unknown(seq: 5, rawKind: "future-event"), + ])).rows + let row = try #require(rows.first { if case .activitySummary = $0.rowKind { true } else { false } }) + guard case .activitySummary(let summary) = row.rowKind else { return } + + #expect(summary.commandCount == 2) + #expect(summary.searchedCode) + #expect(summary.editedFileCount == 1) + #expect(summary.eventCount == 1) + #expect(summary.items.last?.kind == .unknown("future-event")) + #expect(Set(summary.items.map(\.id)).count == summary.items.count) + } + + @Test + func pendingTicketsDoNotSplitCurrentStreamingAnswer() throws { + let firstTicket = SendTicket( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, + sessionID: AgentSessionID(rawValue: "session"), + text: "queued first", + attachmentCount: 0, + state: .queuedLocal, + createdAt: 10 + ) + let secondTicket = SendTicket( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, + sessionID: AgentSessionID(rawValue: "session"), + text: "queued second", + attachmentCount: 0, + state: .acceptedByMac, + createdAt: 20 + ) + let rows = projector.project(TranscriptProjectionInput( + entries: [Self.agent(seq: 1, text: "confirmed")], + sendTickets: [firstTicket, secondTicket], + streamingTail: TranscriptStreamingTail( + journalID: Self.journal, + afterSeq: EntrySeq(rawValue: 1), + textTail: "streaming", + revision: 1 + ) + )).rows + + let streamingID = TranscriptRowID.streaming( + journalID: Self.journal, + afterSeq: EntrySeq(rawValue: 1) + ) + #expect(rows[1].rowID == streamingID) + #expect(rows[2].rowID == TranscriptRowID.pendingTicket(firstTicket.id)) + #expect(rows[3].rowID == TranscriptRowID.pendingTicket(secondTicket.id)) + #expect(rows.filter { row in + if case .streaming = row.rowKind { + return true + } + return false + }.count == 1) + let streamingIndex = try #require(rows.firstIndex { $0.rowID == streamingID }) + let firstTicketIndex = try #require(rows.firstIndex { $0.rowID == .pendingTicket(firstTicket.id) }) + #expect(streamingIndex < firstTicketIndex) + #expect(rows.contains { + $0.rowID == TranscriptRowID.entry(journalID: Self.journal, seq: EntrySeq(rawValue: 1)) + }) + } + + @Test + func failedTicketRemainsVisibleUntilEchoed() { + let id = UUID(uuidString: "00000000-0000-0000-0000-000000000003")! + func ticket(_ state: SendTicketState) -> SendTicket { + SendTicket( + id: id, + sessionID: AgentSessionID(rawValue: "session"), + text: "retry me", + attachmentCount: 0, + state: state, + createdAt: 30 + ) + } + + for state in [SendTicketState.queuedLocal, .acceptedByMac, .failed(code: "transport")] { + let rows = projector.project(TranscriptProjectionInput(entries: [], sendTickets: [ticket(state)])).rows + #expect(rows.contains { $0.rowID == TranscriptRowID.pendingTicket(id) }) + } + + let echoedRows = projector.project(TranscriptProjectionInput( + entries: [], + sendTickets: [ticket(.echoed(EntrySeq(rawValue: 42)))] + )).rows + #expect(!echoedRows.contains { $0.rowID == TranscriptRowID.pendingTicket(id) }) + } + + @Test + func failedRetryLifecycleKeepsOneStableRowUntilEcho() { + let id = UUID(uuidString: "00000000-0000-0000-0000-000000000004")! + func projected(_ state: SendTicketState) -> [TranscriptRow] { + projector.project(TranscriptProjectionInput( + entries: [], + sendTickets: [SendTicket( + id: id, + sessionID: AgentSessionID(rawValue: "session"), + text: "retry", + attachmentCount: 0, + state: state, + createdAt: 40 + )] + )).rows + } + + #expect(projected(.failed(code: "offline")).map(\.rowID) == [.pendingTicket(id)]) + #expect(projected(.queuedLocal).map(\.rowID) == [.pendingTicket(id)]) + #expect(projected(.failed(code: "offline-again")).map(\.rowID) == [.pendingTicket(id)]) + #expect(projected(.echoed(EntrySeq(rawValue: 50))).isEmpty) + } + + @Test + func activeAskProjectsOrderedActionableOptions() throws { + let ask = PendingAsk( + id: "ask-1", + sessionID: AgentSessionID(rawValue: "session"), + kind: .question, + promptSummary: "Pick one", + options: ["A", "B"], + state: .active + ) + let rows = projector.project(TranscriptProjectionInput(entries: [], asks: [ask])).rows + let row = try #require(rows.first) + + #expect(row.rowID == .pendingAsk("ask-1")) + guard case .pendingAsk(let projected) = row.rowKind else { + Issue.record("active ask should remain actionable") + return + } + #expect(projected.options == ["A", "B"]) + } + + @Test + func streamingUpdatesKeepStableIdentity() throws { + let firstInput = TranscriptProjectionInput( + entries: [Self.agent(seq: 1, text: "confirmed")], + streamingTail: TranscriptStreamingTail( + journalID: Self.journal, + afterSeq: EntrySeq(rawValue: 1), + textTail: "first tail", + revision: 1 + ) + ) + let first = projector.project(firstInput) + let second = projector.project(TranscriptProjectionInput( + entries: firstInput.entries, + streamingTail: TranscriptStreamingTail( + journalID: Self.journal, + afterSeq: EntrySeq(rawValue: 1), + textTail: "expanded tail", + revision: 2 + ) + ), previousRows: first.rows) + let streamingID = TranscriptRowID.streaming( + journalID: Self.journal, + afterSeq: EntrySeq(rawValue: 1) + ) + + #expect(try #require(first.rows.last).rowID == streamingID) + #expect(try #require(second.rows.last).rowID == streamingID) + #expect(second.diff.updated == Set([streamingID])) + #expect(second.diff.inserted.isEmpty) + #expect(second.diff.removed.isEmpty) + } + + @Test + func holesAndBoundaryRowsAreProjected() { + let hole = EntryRange(lowerBound: EntrySeq(rawValue: 2), upperBound: EntrySeq(rawValue: 4)) + let rows = projector.project(TranscriptProjectionInput( + entries: [ + Self.user(seq: 1, text: "before"), + Self.agent(seq: 5, text: "after"), + ], + holes: [hole], + hasMoreBefore: true + )).rows + + #expect(rows.contains { $0.rowID == .hole(hole) }) + #expect(rows.first?.rowID == .boundary) + } + + @Test + func rowIDsRemainStableAcrossReprojection() { + let input = TranscriptProjectionInput(entries: [ + Self.user(seq: 1, text: "hello"), + Self.agent(seq: 2, text: "world"), + ]) + let first = projector.project(input) + let second = projector.project(input, previousRows: first.rows) + + #expect(first.rows.map(\.rowID) == second.rows.map(\.rowID)) + #expect(second.diff.appliedOperationCount == 0) + } + + @Test + func productionProjectionOmitsDateHeadersWithoutRealTimestamps() { + let rows = projector.project(TranscriptProjectionInput(entries: [ + Self.user(seq: 1, text: "hello"), + Self.agent(seq: 2, text: "world"), + ])).rows + + #expect(!rows.contains { row in + if case .dateHeader = row.rowKind { return true } + return false + }) + } + + @Test + func nonMonotonicDayKeysAreDeduplicatedWithoutTrapping() { + let input = TranscriptProjectionInput( + entries: [ + Self.user(seq: 1, text: "first"), + Self.agent(seq: 2, text: "second"), + Self.user(seq: 3, text: "third"), + ], + dayKey: { tick in tick == 2 ? "tomorrow" : "today" } + ) + let first = projector.project(input) + let dateHeaders = first.rows.compactMap { row -> TranscriptRowID? in + if case .dateHeader = row.rowKind { + return row.rowID + } + return nil + } + + #expect(dateHeaders == [.dateHeader("today"), .dateHeader("tomorrow")]) + #expect(Set(first.rows.map(\.rowID)).count == first.rows.count) + + let second = projector.project(input, previousRows: first.rows + [first.rows[0]]) + #expect(Set(second.rows.map(\.rowID)).count == second.rows.count) + } + + private static let journal = JournalID(rawValue: "journal") + + private static func entries(count: Int) -> [EntrySnapshot] { + (1...count).map { seq in + seq.isMultiple(of: 2) ? agent(seq: seq, text: "agent \(seq)") : user(seq: seq, text: "user \(seq)") + } + } + + private static func agent(seq: Int, text: String) -> EntrySnapshot { + entry(seq: seq, payload: .agentProse(AgentProsePayload(markdown: text))) + } + + private static func user(seq: Int, text: String) -> EntrySnapshot { + entry(seq: seq, payload: .userMessage(UserMessagePayload(text: text, attachmentCount: 0, hasImage: false))) + } + + private static func tool(seq: Int, name: String, detail: String, isRunning: Bool = false) -> EntrySnapshot { + entry(seq: seq, payload: .toolRun(ToolRunPayload( + toolName: name, + argumentSummary: detail, + isTerminal: name == "bash", + isRunning: isRunning + ))) + } + + private static func file(seq: Int, path: String) -> EntrySnapshot { + entry(seq: seq, payload: .fileChange(FileChangePayload(path: path, changeKind: .edit))) + } + + private static func unknown(seq: Int, rawKind: String) -> EntrySnapshot { + entry(seq: seq, payload: .unknown(UnknownPayload(rawKind: rawKind, summary: "preserved"))) + } + + private static func entry(seq: Int, journalID: JournalID = Self.journal, payload: EntryPayload) -> EntrySnapshot { + EntrySnapshot( + journalID: journalID, + seq: EntrySeq(rawValue: seq), + kind: payload.kind, + content: EntryContent(contentHash: seq, payload: payload), + version: EntityVersion(rawValue: UInt64(seq)) + ) + } +} + +private extension [TranscriptRow] { + func row(seq: Int) -> TranscriptRow? { + first { $0.rowID == .entry(journalID: JournalID(rawValue: "journal"), seq: EntrySeq(rawValue: seq)) } + } + + var activitySummaries: [(turnID: TranscriptTurnID?, items: [TranscriptActivityItem])] { + compactMap { row in + guard case .activitySummary(let summary) = row.rowKind else { return nil } + return (turnID: row.turnID, items: summary.items) + } + } +} + +private extension Collection { + var single: Element? { + count == 1 ? first : nil + } +} + +private extension TranscriptRow { + var isActivityItem: Bool { + if case .activityItem = rowKind { + return true + } + return false + } + + var agentText: String? { + if case .proseAgent(let text, _) = rowKind { + return text + } + return nil + } + + var agentGrouping: TranscriptProseGrouping? { + if case .proseAgent(_, let grouping) = rowKind { + return grouping + } + return nil + } + + var userGrouping: TranscriptProseGrouping? { + if case .proseUser(_, _, let grouping) = rowKind { + return grouping + } + return nil + } +} diff --git a/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/TranscriptSyncPresentationTests.swift b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/TranscriptSyncPresentationTests.swift new file mode 100644 index 000000000000..adf08730e92d --- /dev/null +++ b/Packages/iOS/CmuxAgentGUIUI/Tests/CmuxAgentGUIProjectionTests/TranscriptSyncPresentationTests.swift @@ -0,0 +1,175 @@ +import CmuxAgentGUIProjection +import CmuxAgentReplica +import CmuxAgentSync +@testable import CmuxAgentGUIUI +import Foundation +import Testing + +@Suite +struct TranscriptSyncPresentationTests { + @Test(arguments: [ + (AgentConnectivityPhase.updating, 0, false, TranscriptSyncPresentation.loading), + (.connecting(backoffMilliseconds: 500), 1, false, .loading), + (.connecting(backoffMilliseconds: 500), 2, false, .error), + (.connecting(backoffMilliseconds: 500), 2, true, .stale), + ]) + func connectivityPresentation( + phase: AgentConnectivityPhase, + failures: Int, + hasContent: Bool, + expected: TranscriptSyncPresentation + ) { + let input = TranscriptProjectionInput( + hasCompletedInitialSync: false, + entries: [], + sendTickets: hasContent ? [Self.ticket] : [] + ) + + #expect(Self.presentation(phase: phase, failures: failures, input: input) == expected) + } + + @Test + func openExistingConversationDoesNotFlashEmptyBeforeInitialPull() { + let input = TranscriptProjectionInput( + hasCompletedInitialSync: false, + entries: [] + ) + + #expect(Self.presentation(input: input) == .hidden) + #expect(!Self.presentation(input: input).showsPlaceholderRow) + } + + @Test + func pendingFirstSendSuppressesEmptyPlaceholder() { + let input = TranscriptProjectionInput( + hasCompletedInitialSync: true, + entries: [], + sendTickets: [Self.ticket] + ) + + #expect(input.hasVisibleContent) + #expect(Self.presentation(input: input) == .hidden) + } + + @Test + func activeAskOnlySuppressesEmptyPlaceholder() { + let input = TranscriptProjectionInput( + hasCompletedInitialSync: true, + entries: [], + asks: [PendingAsk( + id: "ask-1", + sessionID: Self.sessionID, + kind: .question, + promptSummary: "Choose", + options: ["A", "B"], + state: .active + )] + ) + + #expect(input.hasVisibleContent) + #expect(Self.presentation(input: input) == .hidden) + } + + @Test + func streamingTailOnlySuppressesEmptyPlaceholder() { + let input = TranscriptProjectionInput( + hasCompletedInitialSync: true, + entries: [], + streamingTail: TranscriptStreamingTail( + journalID: Self.journalID, + afterSeq: EntrySeq(rawValue: 0), + textTail: "Working answer", + revision: 1 + ) + ) + + #expect(input.hasVisibleContent) + #expect(Self.presentation(input: input) == .hidden) + } + + @Test + func genuinelyEmptyConversationShowsPlaceholderAfterInitialSync() { + let input = TranscriptProjectionInput( + hasCompletedInitialSync: true, + entries: [] + ) + + #expect(!input.hasVisibleContent) + #expect(Self.presentation(input: input) == .empty) + } + + @Test + func emptyFilteredPageShowsLoadingWhileNewerHistoryExists() { + let input = TranscriptProjectionInput( + hasCompletedInitialSync: true, + entries: [], + hasMoreAfter: true, + endCursor: JournalCursor(rawValue: "next-page") + ) + + #expect(!input.hasVisibleContent) + #expect(Self.presentation(input: input) == .loading) + #expect(Self.presentation(input: input).showsPlaceholderRow) + } + + @Test + func internalOnlyPageKeepsLoadingInsteadOfShowingAnEmptyConversation() { + let internalEntry = EntrySnapshot( + journalID: Self.journalID, + seq: EntrySeq(rawValue: 10), + kind: .status, + content: EntryContent( + contentHash: 10, + payload: .status(StatusPayload(code: .sessionMeta)) + ), + version: EntityVersion(rawValue: 1) + ) + let input = TranscriptProjectionInput( + hasCompletedInitialSync: true, + entries: [internalEntry], + hasMoreAfter: true, + endCursor: JournalCursor(rawValue: "next-page") + ) + + #expect(!input.hasVisibleContent) + #expect(Self.presentation(input: input) == .loading) + #expect(Self.presentation(input: input).showsPlaceholderRow) + } + + @Test + func cachedVisibleRowsDrivePresentationWithoutInspectingEntries() { + let presentation = TranscriptSyncPresentation( + phase: .connected, + consecutiveFailures: 0, + hasVisibleContent: true, + hasCompletedInitialSync: true, + hasMoreAfter: false + ) + + #expect(presentation == .hidden) + #expect(!presentation.showsPlaceholderRow) + } + + private static let sessionID = AgentSessionID(rawValue: "presentation-session") + private static let journalID = JournalID(rawValue: "presentation-journal") + private static let ticket = SendTicket( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, + sessionID: sessionID, + text: "Hello", + attachmentCount: 0, + state: .queuedLocal, + createdAt: 1 + ) + + private static func presentation( + phase: AgentConnectivityPhase = .connected, + failures: Int = 0, + input: TranscriptProjectionInput + ) -> TranscriptSyncPresentation { + TranscriptSyncPresentation( + phase: phase, + consecutiveFailures: failures, + input: input + ) + } +} diff --git a/Packages/iOS/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileLatencyTrace.swift b/Packages/iOS/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileLatencyTrace.swift index 471d55aaab58..c1015efcff9b 100644 --- a/Packages/iOS/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileLatencyTrace.swift +++ b/Packages/iOS/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileLatencyTrace.swift @@ -2,6 +2,7 @@ import Dispatch import Foundation /// Low-overhead, opt-in latency stamps for DEBUG mobile builds. +// lint:allow namespace-type - existing static helper surface, preserving call sites during UI closeout. public enum MobileLatencyTrace { #if DEBUG private static let writer = MobileLatencyTraceWriter(capacity: 4_096) diff --git a/Packages/iOS/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileLatencyTraceWriter.swift b/Packages/iOS/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileLatencyTraceWriter.swift index 8bac267f0851..383b7af31442 100644 --- a/Packages/iOS/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileLatencyTraceWriter.swift +++ b/Packages/iOS/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileLatencyTraceWriter.swift @@ -57,6 +57,7 @@ final class MobileLatencyTraceWriter: Sendable { init(capacity: Int) { precondition(capacity > 0) + // lint:allow lock - same sanctioned hot-path ring-buffer lock as the property above. state = OSAllocatedUnfairLock(initialState: State(capacity: capacity)) (signals, signalContinuation) = AsyncStream.makeStream( bufferingPolicy: .bufferingNewest(1) diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedDecodeOptions.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedDecodeOptions.swift index e5f4e9554656..350e3b4394d2 100644 --- a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedDecodeOptions.swift +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedDecodeOptions.swift @@ -5,6 +5,7 @@ struct MobileNotificationFeedListBoundedDecodeOptions: Sendable { let stringLimits: MobileNotificationFeedListStringLimits } +// lint:allow free-function - internal package helper mobileNotificationFeedListBoundedDecodeOptions, preserving existing call sites during UI closeout. func mobileNotificationFeedListBoundedDecodeOptions( from decoder: any Decoder ) throws -> MobileNotificationFeedListBoundedDecodeOptions { diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedItem.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedItem.swift index ecdc03bb0293..7fddba682bb8 100644 --- a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedItem.swift +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedItem.swift @@ -75,6 +75,7 @@ struct MobileNotificationFeedListBoundedItem: Decodable { } } +// lint:allow free-function - file-local helper mobileNotificationFeedListIdentityString, kept as a pure helper to avoid unrelated type migration in this UI closeout. private func mobileNotificationFeedListIdentityString( from container: KeyedDecodingContainer, forKey key: MobileNotificationFeedListBoundedItem.CodingKeys, @@ -85,6 +86,7 @@ private func mobileNotificationFeedListIdentityString( return value } +// lint:allow free-function - file-local helper mobileNotificationFeedListString, kept as a pure helper to avoid unrelated type migration in this UI closeout. private func mobileNotificationFeedListString( from container: KeyedDecodingContainer, forKey key: MobileNotificationFeedListBoundedItem.CodingKeys, @@ -96,6 +98,7 @@ private func mobileNotificationFeedListString( ) } +// lint:allow free-function - file-local helper mobileNotificationFeedListOptionalString, kept as a pure helper to avoid unrelated type migration in this UI closeout. private func mobileNotificationFeedListOptionalString( from container: KeyedDecodingContainer, forKey key: MobileNotificationFeedListBoundedItem.CodingKeys, @@ -107,6 +110,7 @@ private func mobileNotificationFeedListOptionalString( return mobileNotificationFeedListString(value, limitedToUTF8Bytes: maxBytes) } +// lint:allow free-function - file-local helper mobileNotificationFeedListString, kept as a pure helper to avoid unrelated type migration in this UI closeout. private func mobileNotificationFeedListString(_ value: String, limitedToUTF8Bytes maxBytes: Int) -> String { guard maxBytes >= 0, value.utf8.count > maxBytes else { return value } var byteCount = 0 diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptKey.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptKey.swift index 504c0775a4e5..78f2c57f8b46 100644 --- a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptKey.swift +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptKey.swift @@ -31,6 +31,7 @@ struct MobileRPCConnectAttemptKey: Hashable, Sendable { } } +// lint:allow free-function - file-local pure URL identity helper for MobileRPCConnectAttemptKey. private func stableURLIdentity(_ value: String) -> String { guard let components = URLComponents(string: value), let scheme = components.scheme?.lowercased(), @@ -48,6 +49,7 @@ private func stableURLIdentity(_ value: String) -> String { """ } +// lint:allow free-function - file-local URL scheme table for MobileRPCConnectAttemptKey. private func defaultPort(for scheme: String) -> Int? { switch scheme { case "http", "ws": @@ -59,6 +61,7 @@ private func defaultPort(for scheme: String) -> Int? { } } +// lint:allow free-function - file-local host canonicalization helper for MobileRPCConnectAttemptKey. private func canonicalHostIdentity(_ value: String) -> String { var host = value.trimmingCharacters(in: .whitespacesAndNewlines) if host.hasPrefix("["), host.hasSuffix("]") { @@ -85,6 +88,7 @@ private func canonicalHostIdentity(_ value: String) -> String { return dnsHost } +// lint:allow free-function - file-local IPv4 normalization helper for MobileRPCConnectAttemptKey. private func canonicalIPv4Address(_ value: String) -> String? { var address = in_addr() guard value.withCString({ @@ -106,6 +110,7 @@ private func canonicalIPv4Address(_ value: String) -> String? { } } +// lint:allow free-function - file-local IPv6 normalization helper for MobileRPCConnectAttemptKey. private func canonicalIPv6Address(_ value: String) -> String? { var address = in6_addr() guard value.withCString({ diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCTaskTimeoutCancellation.swift b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCTaskTimeoutCancellation.swift index 9430b150b253..9958d5c37d14 100644 --- a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCTaskTimeoutCancellation.swift +++ b/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCTaskTimeoutCancellation.swift @@ -10,6 +10,7 @@ import Foundation /// properties are only touched inside `lock.withLock`, and the continuation /// is finished at most once because every finish path must first win `race`. final class RPCTaskTimeoutCancellation: @unchecked Sendable { + // lint:allow lock - cancellation is synchronous and may run on any thread, so this cannot await an actor. private let lock = NSLock() private var continuation: AsyncThrowingStream.Continuation? private var isCancelled = false diff --git a/Packages/iOS/CmuxMobileShell/Package.swift b/Packages/iOS/CmuxMobileShell/Package.swift index c6b11e364ea8..6afcb815c2cc 100644 --- a/Packages/iOS/CmuxMobileShell/Package.swift +++ b/Packages/iOS/CmuxMobileShell/Package.swift @@ -19,8 +19,11 @@ let package = Package( ), ], dependencies: [ - .package(path: "../../Shared/CMUXMobileCore"), + .package(path: "../../Shared/CmuxAgentReplica"), + .package(path: "../../Shared/CmuxAgentSync"), + .package(path: "../../Shared/CmuxAgentWire"), .package(path: "../../Shared/CmuxAgentChat"), + .package(path: "../../Shared/CMUXMobileCore"), .package(path: "../CmuxMobileChanges"), .package(path: "../CmuxMobileDiagnostics"), .package(path: "../CmuxMobileBrowserStream"), @@ -35,6 +38,9 @@ let package = Package( name: "CmuxMobileShell", dependencies: [ "CMUXMobileCore", + "CmuxAgentReplica", + "CmuxAgentSync", + "CmuxAgentWire", "CmuxAgentChat", "CmuxMobileChanges", "CmuxMobileDiagnostics", @@ -73,6 +79,7 @@ let package = Package( "CmuxMobileShellReleaseGateSupport", "CMUXMobileCore", "CmuxAgentChat", + "CmuxAgentReplica", "CmuxMobileBrowserStream", "CmuxMobileChanges", "CmuxMobilePairedMac", diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/AgentGUI/AgentGUIConnectionEventRelay.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/AgentGUI/AgentGUIConnectionEventRelay.swift new file mode 100644 index 000000000000..7e6a11126b3d --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/AgentGUI/AgentGUIConnectionEventRelay.swift @@ -0,0 +1,24 @@ +import CmuxAgentSync + +@MainActor +final class AgentGUIConnectionEventRelay { + let events: AsyncStream + + private let continuation: AsyncStream.Continuation + + init() { + let pair = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(32) + ) + events = pair.stream + continuation = pair.continuation + } + + deinit { + continuation.finish() + } + + func yield(_ event: AgentSyncConnectionEvent) { + continuation.yield(event) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/AgentGUI/AgentGUISyncTransportRPC.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/AgentGUI/AgentGUISyncTransportRPC.swift new file mode 100644 index 000000000000..ddb28a1ff8f7 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/AgentGUI/AgentGUISyncTransportRPC.swift @@ -0,0 +1,147 @@ +public import CmuxAgentSync +public import CmuxAgentWire +public import CmuxMobileRPC +public import Foundation + +/// Bridges agent GUI sync requests and events onto a paired Mac RPC client. +public actor AgentGUISyncTransportRPC: AgentSyncTransport { + /// Connection lifecycle events supplied by the mobile shell composite. + public nonisolated let connectionEvents: AsyncStream + + private let client: MobileCoreRPCClient + private let streamID: String + private var currentTopics: Set + + /// Creates an RPC-backed agent GUI transport. + /// - Parameters: + /// - client: The paired Mac RPC client for the foreground connection. + /// - streamID: The server-side mobile event stream identifier. + /// - connectionEvents: Lifecycle events for this connection generation. + public init( + client: MobileCoreRPCClient, + streamID: String, + connectionEvents: AsyncStream + ) { + self.client = client + self.streamID = streamID + self.connectionEvents = connectionEvents + currentTopics = [] + } + + /// Sends one `gui.v1` request while preserving its raw JSON parameters. + /// - Parameters: + /// - method: The GUI wire method name. + /// - params: Raw JSON parameters encoded by `CmuxAgentWire`. + /// - Returns: The raw JSON result payload. + public func request(method: String, params: Data) async throws -> Data { + do { + return try await client.sendRequest(Self.requestData(method: method, params: params)) + } catch let error as MobileShellConnectionError { + if case .rpcError(let code, let message) = error { + throw GuiWireError(code: code ?? GuiWireErrorCode.internalError.rawValue, message: message) + } + throw error + } + } + + /// Registers GUI event topics before exposing their filtered event stream. + /// - Parameter topics: Requested `gui.v1` topics. + /// - Returns: A stream of raw agent sync frames. + public func subscribe(topics: [String]) async throws -> AsyncStream { + let filteredTopics = Self.filteredTopics(topics) + guard !filteredTopics.isEmpty else { + if !currentTopics.isEmpty { + try await removeServerSubscription() + } + return AsyncStream { continuation in continuation.finish() } + } + + // Install the local listener first so events emitted during the server + // acknowledgement cannot fall between listener creation and the pull. + let topicSet = Set(filteredTopics) + let source = await client.subscribe(to: topicSet) + let pair = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(256)) + let expectedStreamID = streamID + let forwardingTask = Task { + for await envelope in source { + guard !Task.isCancelled else { break } + guard topicSet.contains(envelope.topic), + envelope.streamID == nil || envelope.streamID == expectedStreamID, + let payload = envelope.payloadJSON else { + continue + } + pair.continuation.yield(AgentSyncFrame(topic: envelope.topic, payload: payload)) + } + pair.continuation.finish() + } + pair.continuation.onTermination = { _ in forwardingTask.cancel() } + + do { + try await replaceServerSubscription(with: topicSet) + return pair.stream + } catch { + // Cancelling iteration terminates MobileCoreRPCClient's local + // listener, so a failed server acknowledgement cannot orphan it. + forwardingTask.cancel() + pair.continuation.finish() + throw error + } + } + + /// Removes GUI topics by replacing the server's full per-stream registration. + /// - Parameter topics: GUI topics to remove. + public func unsubscribe(topics: [String]) async { + let removedTopics = Set(Self.filteredTopics(topics)) + guard !removedTopics.isEmpty else { return } + let remainingTopics = currentTopics.subtracting(removedTopics) + guard remainingTopics != currentTopics else { return } + + if remainingTopics.isEmpty { + try? await removeServerSubscription() + } else { + try? await replaceServerSubscription(with: remainingTopics) + } + } + + private static func requestData(method: String, params: Data) throws -> Data { + let rawParams = try JSONSerialization.jsonObject(with: params, options: [.fragmentsAllowed]) + return try JSONSerialization.data(withJSONObject: [ + "id": UUID().uuidString, + "method": method, + "params": rawParams, + ]) + } + + private static func filteredTopics(_ topics: [String]) -> [String] { + Array(Set(topics.filter { $0.hasPrefix("gui.v1.") })).sorted() + } + + private func replaceServerSubscription(with topics: Set) async throws { + let acknowledgement = try await client.sendRequest( + MobileCoreRPCClient.requestData( + method: "mobile.events.subscribe", + params: [ + "stream_id": streamID, + "topics": topics.sorted(), + ] + ) + ) + let response = try MobileEventSubscribeResponse.decode(acknowledgement) + guard response.streamID == streamID else { + throw GuiWireError( + code: .internalError, + message: "Invalid agent GUI event subscription acknowledgement" + ) + } + currentTopics = topics + } + + private func removeServerSubscription() async throws { + let request = try MobileCoreRPCClient.requestData( + method: "mobile.events.unsubscribe", + params: ["stream_id": streamID] + ) + _ = try await client.sendRequest(request) + currentTopics = [] + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/AgentGUI/MobileShellComposite+AgentGUI.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/AgentGUI/MobileShellComposite+AgentGUI.swift new file mode 100644 index 000000000000..0d736edb303f --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/AgentGUI/MobileShellComposite+AgentGUI.swift @@ -0,0 +1,56 @@ +import CmuxAgentSync +import CmuxMobileRPC +import Foundation + +@MainActor +extension MobileShellComposite { + func installAgentGUIEngine( + client: MobileCoreRPCClient, + generation: UUID + ) { + guard agentGUIConnectionGeneration != generation || agentSyncEngine == nil else { return } + + agentGUIConnectionEventRelay?.yield(.reset) + agentSyncEngine?.stop() + + let relay = AgentGUIConnectionEventRelay() + let transport = AgentGUISyncTransportRPC( + client: client, + streamID: "ios-agent-gui-events-\(clientID)-\(generation.uuidString)", + connectionEvents: relay.events + ) + let engine = AgentSyncEngine(transport: transport) + agentGUIConnectionGeneration = generation + agentGUIConnectionEventRelay = relay + agentSyncEngine = engine + engine.start() + } + + func clearAgentGUIEngine(reason: String) { + agentGUIConnectionEventRelay?.yield(.down(reason: reason)) + agentSyncEngine?.stop() + agentSyncEngine = nil + agentGUIConnectionGeneration = nil + agentGUIConnectionEventRelay = nil + } + + func resetAgentGUIEngine() { + agentGUIConnectionEventRelay?.yield(.reset) + agentSyncEngine?.stop() + agentSyncEngine = nil + agentGUIConnectionGeneration = nil + agentGUIConnectionEventRelay = nil + } + + func noteAgentGUIConnectionHealthy() { + agentGUIConnectionEventRelay?.yield(.up) + } + + func noteAgentGUIConnectionReconnecting() { + agentGUIConnectionEventRelay?.yield(.down(reason: "reconnecting")) + } + + func noteAgentGUIConnectionUnavailable() { + agentGUIConnectionEventRelay?.yield(.down(reason: "unavailable")) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/AgentGUIAvailability.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/AgentGUIAvailability.swift new file mode 100644 index 000000000000..6f24e38756f5 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/AgentGUIAvailability.swift @@ -0,0 +1,42 @@ +public import CmuxAgentReplica + +public struct AgentGUIAvailability: Equatable, Sendable { + public let sessionID: AgentSessionID + public let kind: AgentKind + + public static func derive( + sessions: [AgentSessionSnapshot], + selectedTerminalID: String? + ) -> AgentGUIAvailability? { + guard let selectedTerminalID else { return nil } + guard let session = sessions + .filter({ $0.surfaceID == selectedTerminalID && $0.phase.offersAgentGUI }) + .max(by: Self.isOlderSession) + else { + return nil + } + return AgentGUIAvailability(sessionID: session.id, kind: session.kind) + } + + private static func isOlderSession( + _ lhs: AgentSessionSnapshot, + than rhs: AgentSessionSnapshot + ) -> Bool { + if lhs.lastActivityHint != rhs.lastActivityHint { + return lhs.lastActivityHint < rhs.lastActivityHint + } + return lhs.id.rawValue > rhs.id.rawValue + } +} + +extension SessionPhase { + /// Ended is the only terminal session phase currently defined by the replica model. + var offersAgentGUI: Bool { + switch self { + case .starting, .idle, .working, .needsInput, .unknown: + true + case .ended: + false + } + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileLatencyProbe.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileLatencyProbe.swift index 4d145e49f4dd..6ecf9cac3c24 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileLatencyProbe.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileLatencyProbe.swift @@ -3,6 +3,7 @@ import Foundation /// Process-scoped configuration and claim gate for the DEBUG typing probe. @MainActor +// lint:allow namespace-type - existing static helper surface, preserving call sites during UI closeout. enum MobileLatencyProbe { struct Configuration { let count: Int diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionRegistry.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionRegistry.swift index 131014b57a62..5cab3ea033d7 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionRegistry.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionRegistry.swift @@ -268,6 +268,7 @@ final class MobileMacConnectionRegistry { } +// lint:allow free-function - file-local helper mobileMacConnectionDisplayName, kept as a pure helper to avoid unrelated type migration in this UI closeout. private func mobileMacConnectionDisplayName( _ value: String?, fallback: String diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionRecovery.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionRecovery.swift index aeedc4cfa41c..1e8950448411 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionRecovery.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionRecovery.swift @@ -83,6 +83,9 @@ extension MobileShellComposite { pendingInactiveRecoveryTrigger = trigger return } + if case .networkChange = trigger { + agentSyncEngine?.noteNetworkPathChanged() + } if let accountID = identityProvider?.currentUserID { switch trigger { case .manual, .networkChange, .foreground: diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationFeed.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationFeed.swift index ed8bb43fe484..a7b00fbcdfa1 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationFeed.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationFeed.swift @@ -1080,6 +1080,7 @@ nonisolated private func mobileShellNotificationFeedListStringLimits() -> Mobile ) } +// lint:allow free-function - file-local helper mobileShellNotificationFeedString, kept as a pure helper to avoid unrelated type migration in this UI closeout. private func mobileShellNotificationFeedString(_ value: String, limitedToUTF8Bytes maxBytes: Int) -> String { guard maxBytes >= 0, value.utf8.count > maxBytes else { return value } var byteCount = 0 diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacCoalescing.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacCoalescing.swift index 5247c5ee6e57..100c874dc584 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacCoalescing.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacCoalescing.swift @@ -194,6 +194,7 @@ extension MobileShellComposite { /// the cryptographic Iroh endpoint joins renamed rows that still compete /// for one physical control connection. @MainActor +// lint:allow free-function - internal package helper physicalMacAliasCanonicalIDsByCanonicalID, preserving existing call sites during UI closeout. func physicalMacAliasCanonicalIDsByCanonicalID( in macs: [MobilePairedMac], supportedKinds: [CmxAttachTransportKind], diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift index 16691b421521..477a847e5bc8 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift @@ -304,6 +304,7 @@ extension MobileShellComposite { foregroundRefreshIsActive = true foregroundResumeEpoch &+= 1 startObservingNetworkPathChanges() + agentSyncEngine?.noteAppForegrounded() // Covers stores constructed already-signed-in (no isSignedIn edge) and // restarts a subscription torn down while backgrounded. evaluatePresenceSubscription() diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SecondaryPromotion.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SecondaryPromotion.swift index 4fc947322849..800608fdd8af 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SecondaryPromotion.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SecondaryPromotion.swift @@ -687,6 +687,7 @@ extension MobileShellComposite { activeRoute = sub.route connectionState = .connected markMacConnectionHealthy() + installAgentGUIEngine(client: sub.client, generation: generation) // Establish the foreground listener before fetching the snapshot that // focus will publish. This closes the control-unsubscribe/terminal- // subscribe gap for legacy Macs that have no state-sync cursor repair. diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 8ff72a7b1d5f..b4829510f551 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1,5 +1,6 @@ public import CMUXMobileCore public import CmuxAgentChat +public import CmuxAgentSync internal import CmuxMobileDiagnostics public import CmuxMobileBrowserStream public import CmuxMobilePairedMac @@ -807,6 +808,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// `public` so the DEV feedback-submit affordance can ``DiagnosticLog/export()`` /// it. public let diagnosticLog: DiagnosticLog? + /// Agent GUI synchronization engine for the current foreground Mac. + public internal(set) var agentSyncEngine: AgentSyncEngine? = nil + @ObservationIgnored var agentGUIConnectionGeneration: UUID? = nil + @ObservationIgnored var agentGUIConnectionEventRelay: AgentGUIConnectionEventRelay? = nil package var remoteClient: MobileCoreRPCClient? { didSet { if remoteClient == nil { @@ -1153,6 +1158,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { var terminalColdReplayNeedsBarrierUpgradeSurfaceIDs: Set var terminalOutputTransport: TerminalOutputTransport var terminalByteContinuationsBySurfaceID: [String: AsyncStream.Continuation] + var terminalOutputRegistrationTokensBySurfaceID: [String: UUID] var terminalOutputStreamTokensBySurfaceID: [String: UUID] var terminalOutputQueuesBySurfaceID: [String: TerminalOutputDeliveryQueue] let terminalLaneCoordinator: MobileTerminalLaneCoordinator? @@ -1479,6 +1485,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self.terminalColdReplayNeedsBarrierUpgradeSurfaceIDs = [] self.terminalOutputTransport = .rawBytes self.terminalByteContinuationsBySurfaceID = [:] + self.terminalOutputRegistrationTokensBySurfaceID = [:] self.terminalOutputStreamTokensBySurfaceID = [:] self.terminalOutputQueuesBySurfaceID = [:] if let terminalLaneProvider = runtime?.terminalLaneProvider { @@ -8302,6 +8309,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { activeRoute = candidateRoute connectionState = .connected markMacConnectionHealthy() + installAgentGUIEngine(client: client, generation: generation) // Reuse the authenticated status response that bound this // route to its Mac instance. The event listener needs the // same payload for capability negotiation, so asking again @@ -8743,6 +8751,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } if previous !== newValue { terminalSubscriptionHandoffFenceClientID = nil + if newValue == nil { + clearAgentGUIEngine(reason: "disconnected") + } else if previous != nil { + resetAgentGUIEngine() + } } return previous !== newValue ? previous : nil } @@ -9357,6 +9370,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { isRecoveringConnection = false connectionRecoveryFailed = false connectionRequiresReauth = false + noteAgentGUIConnectionHealthy() } @discardableResult @@ -9387,6 +9401,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { macConnectionStatus = .reconnecting isRecoveringConnection = true connectionRecoveryFailed = false + noteAgentGUIConnectionReconnecting() } private func markMacConnectionUnavailable() { @@ -9397,6 +9412,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { macConnectionStatus = .unavailable isRecoveringConnection = false connectionRecoveryFailed = true + noteAgentGUIConnectionUnavailable() } func markMacConnectionUnavailableIfNeeded(after error: any Error) { @@ -11089,8 +11105,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { surfaceID: String, continuation: AsyncStream.Continuation ) -> UUID { + let registrationToken = UUID() let streamToken = UUID() terminalByteContinuationsBySurfaceID[surfaceID] = continuation + terminalOutputRegistrationTokensBySurfaceID[surfaceID] = registrationToken terminalOutputStreamTokensBySurfaceID[surfaceID] = streamToken terminalOutputQueuesBySurfaceID[surfaceID] = TerminalOutputDeliveryQueue() deliveredTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID) @@ -11110,18 +11128,19 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { #endif requestColdAttachTerminalReplay(surfaceID: surfaceID) ensureTerminalLane(surfaceID: surfaceID) - return streamToken + return registrationToken } - private func unregisterTerminalOutput(surfaceID: String, streamToken: UUID) { - guard terminalOutputStreamTokensBySurfaceID[surfaceID] == streamToken else { return } + private func unregisterTerminalOutput(surfaceID: String, registrationToken: UUID) { + guard terminalOutputRegistrationTokensBySurfaceID[surfaceID] == registrationToken else { return } terminalLaneOutputReadySurfaceIDs.remove(surfaceID) if let terminalLaneCoordinator { Task { await terminalLaneCoordinator.deactivate(surfaceID: surfaceID) } } cancelTerminalReplayInFlight(surfaceID: surfaceID) terminalColdReplayNeedsBarrierUpgradeSurfaceIDs.remove(surfaceID) - terminalByteContinuationsBySurfaceID.removeValue(forKey: surfaceID) + let continuation = terminalByteContinuationsBySurfaceID.removeValue(forKey: surfaceID) + terminalOutputRegistrationTokensBySurfaceID.removeValue(forKey: surfaceID) terminalOutputStreamTokensBySurfaceID.removeValue(forKey: surfaceID) terminalOutputQueuesBySurfaceID.removeValue(forKey: surfaceID) terminalReplayBarrierTokensBySurfaceID.removeValue(forKey: surfaceID) @@ -11164,29 +11183,35 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { terminalMirrorHydrationNeededSurfaceIDs.remove(surfaceID) // Tell the Mac this device is no longer viewing the surface so it can unpin and clear its border. clearTerminalViewport(surfaceID: surfaceID) + continuation?.finish() } /// The output byte stream for a terminal surface. /// /// Obtaining the stream arms a cold-attach replay so the surface catches up - /// to current state; ending iteration (or cancelling the consuming task) - /// unregisters the surface and clears its viewport pin on the Mac. + /// to current state. The mounted view must cancel the returned lease when + /// it detaches so the store unregisters the surface and clears its viewport + /// pin on the Mac. /// - Parameter surfaceID: The terminal surface identifier. - /// - Returns: An `AsyncStream` of output byte chunks. - public func terminalOutputStream(surfaceID: String) -> AsyncStream { - AsyncStream { continuation in - let streamToken = registerTerminalOutput( + /// - Returns: A cancellable stream lease of output byte chunks. + public func terminalOutputStream(surfaceID: String) -> MobileTerminalOutputStream { + MobileTerminalOutputStream { continuation, cancellation in + let registrationToken = registerTerminalOutput( surfaceID: surfaceID, continuation: continuation ) - continuation.onTermination = { [weak self] _ in + cancellation.install { [weak self] in Task { @MainActor in self?.unregisterTerminalOutput( surfaceID: surfaceID, - streamToken: streamToken + registrationToken: registrationToken ) } } + continuation.onTermination = { [weak self] _ in + guard self != nil else { return } + cancellation.cancel() + } } } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryControlAttemptPolicy.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryControlAttemptPolicy.swift index 999831cea370..b770fc0dc4ce 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryControlAttemptPolicy.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryControlAttemptPolicy.swift @@ -2,6 +2,7 @@ import CmuxMobileTransport import CmuxMobileRPC import Foundation +// lint:allow free-function - internal package helper secondaryControlAttemptIsTransient, preserving existing call sites during UI closeout. func secondaryControlAttemptIsTransient(_ error: any Error) -> Bool { if error is CancellationError || error is DecodingError { return false diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WarmControlPoolCapacity.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WarmControlPoolCapacity.swift index edd155949e05..289418072953 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WarmControlPoolCapacity.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WarmControlPoolCapacity.swift @@ -1,4 +1,5 @@ @MainActor +// lint:allow free-function - internal package helper warmControlPoolHasCapacity, preserving existing call sites during UI closeout. func warmControlPoolHasCapacity( currentControlCount: Int, vacatesControlSlot: Bool diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateResponseValidator.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateResponseValidator.swift index be28225f5374..b24f118fcafd 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateResponseValidator.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateResponseValidator.swift @@ -9,6 +9,7 @@ internal import CmuxMobileShell /// The probe discards decoded values and publishes only operation booleans, so /// chat descriptors, notification identifiers, session identifiers, and /// artifact metadata never enter the report. +// lint:allow namespace-type - existing static helper surface, preserving call sites during UI closeout. enum MobileIrohReleaseGateResponseValidator { static func independentEventSubscription( _ data: Data, diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/AgentGUIAvailabilityTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/AgentGUIAvailabilityTests.swift new file mode 100644 index 000000000000..1aa166809dfe --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/AgentGUIAvailabilityTests.swift @@ -0,0 +1,94 @@ +import CmuxAgentReplica +import Testing +@testable import CmuxMobileShell + +@Suite struct AgentGUIAvailabilityTests { + @Test func matchesSelectedTerminalSurface() throws { + let availability = try #require(AgentGUIAvailability.derive( + sessions: [ + Self.session(id: "ignored", surfaceID: "other-terminal"), + Self.session(id: "matched", surfaceID: "terminal-1", kind: .claude), + ], + selectedTerminalID: "terminal-1" + )) + + #expect(availability.sessionID == AgentSessionID(rawValue: "matched")) + #expect(availability.kind == .claude) + } + + @Test func newestActivityHintWinsWhenMultipleSessionsMatch() throws { + let availability = try #require(AgentGUIAvailability.derive( + sessions: [ + Self.session(id: "older", surfaceID: "terminal-1", lastActivityHint: 20), + Self.session(id: "newer", surfaceID: "terminal-1", lastActivityHint: 30), + ], + selectedTerminalID: "terminal-1" + )) + + #expect(availability.sessionID == AgentSessionID(rawValue: "newer")) + } + + @Test func endedSessionDoesNotOfferGUI() { + let availability = AgentGUIAvailability.derive( + sessions: [ + Self.session(id: "ended", surfaceID: "terminal-1", phase: .ended), + ], + selectedTerminalID: "terminal-1" + ) + + #expect(availability == nil) + } + + @Test func sessionWithoutSurfaceIsIgnored() { + let availability = AgentGUIAvailability.derive( + sessions: [ + Self.session(id: "no-surface", surfaceID: nil), + ], + selectedTerminalID: "terminal-1" + ) + + #expect(availability == nil) + } + + @Test func disconnectedDirectoryProducesNoAvailability() { + let availability = AgentGUIAvailability.derive( + sessions: [], + selectedTerminalID: "terminal-1" + ) + + #expect(availability == nil) + } + + @Test func missingSelectedTerminalProducesNoAvailability() { + let availability = AgentGUIAvailability.derive( + sessions: [ + Self.session(id: "matched", surfaceID: "terminal-1"), + ], + selectedTerminalID: nil + ) + + #expect(availability == nil) + } + + private static func session( + id: String, + surfaceID: String?, + kind: AgentKind = .codex, + phase: SessionPhase = .working, + lastActivityHint: Int = 10 + ) -> AgentSessionSnapshot { + AgentSessionSnapshot( + id: AgentSessionID(rawValue: id), + macDeviceID: MacDeviceID(rawValue: "mac-1"), + kind: kind, + phase: phase, + tier: .wrapped, + surfaceID: surfaceID, + cwd: "/repo", + title: id, + workspaceName: "Workspace", + version: EntityVersion(rawValue: UInt64(lastActivityHint)), + lastActivityHint: lastActivityHint + ) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift index 83b185da2280..d887f9abb490 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift @@ -856,10 +856,13 @@ final class OutputCollector { private(set) var lines: [String] = [] private(set) var viewportPolicies: [MobileTerminalOutputViewportPolicy?] = [] private var task: Task? + private var stream: MobileTerminalOutputStream? func mount(store: MobileShellComposite, surfaceID: String) { + let stream = store.terminalOutputStream(surfaceID: surfaceID) + self.stream = stream task = Task { @MainActor [weak self] in - for await chunk in store.terminalOutputStream(surfaceID: surfaceID) { + for await chunk in stream { self?.lines.append(String(decoding: chunk.data, as: UTF8.self)) self?.viewportPolicies.append(chunk.viewportPolicy) store.terminalOutputDidProcess( @@ -871,6 +874,8 @@ final class OutputCollector { } func unmount() { + stream?.cancel() + stream = nil task?.cancel() task = nil } diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalViewportResyncTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalViewportResyncTests.swift index 8a819d706bd4..dead0eb1a953 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalViewportResyncTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalViewportResyncTests.swift @@ -1018,7 +1018,9 @@ private func mountOutputAndReportViewport( router: LivenessHostRouter, surfaceID: String ) async throws { - var iterator = store.terminalOutputStream(surfaceID: surfaceID).makeAsyncIterator() + let stream = store.terminalOutputStream(surfaceID: surfaceID) + defer { stream.cancel() } + var iterator = stream.makeAsyncIterator() await router.waitForCount(of: "mobile.terminal.replay", atLeast: 1) let coldReplayChunk = try #require(await iterator.next()) store.terminalOutputDidProcess(surfaceID: surfaceID, streamToken: coldReplayChunk.streamToken) diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregation.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregation.swift index 899ed53c1b80..ff38ef8e99f4 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregation.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregation.swift @@ -96,6 +96,7 @@ public struct MobileNotificationFeedAggregation: Sendable { } } +// lint:allow free-function - internal package helper mobileNotificationFeedItemPrecedes, preserving existing call sites during UI closeout. func mobileNotificationFeedItemPrecedes( _ lhs: MobileNotificationFeedItem, _ rhs: MobileNotificationFeedItem diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift index 9ef194cc1b97..51b97ebadcca 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift @@ -63,13 +63,124 @@ public struct MobileTerminalOutputChunk: Sendable { } } +/// Cancellable terminal-output sequence with deterministic surface teardown. +/// +/// `AsyncStream` termination is not prompt enough when the producer stores the +/// continuation while a replay RPC is suspended. This wrapper keeps the same +/// `for await` call site and exposes an explicit `cancel()` lease for view +/// teardown. +public struct MobileTerminalOutputStream: AsyncSequence, Sendable { + public typealias Element = MobileTerminalOutputChunk + + public final class Cancellation: @unchecked Sendable { + // lint:allow lock - explicit cancel is synchronous teardown state, so callers cannot await an actor here. + private let lock = NSLock() + private var didCancel = false + private var handler: (@Sendable () -> Void)? + + public init() {} + + public func install(_ handler: @escaping @Sendable () -> Void) { + var shouldRunNow = false + lock.lock() + if didCancel { + shouldRunNow = true + } else { + self.handler = handler + } + lock.unlock() + if shouldRunNow { + handler() + } + } + + public func cancel() { + let handlerToRun: (@Sendable () -> Void)? + lock.lock() + if didCancel { + handlerToRun = nil + } else { + didCancel = true + handlerToRun = handler + handler = nil + } + lock.unlock() + handlerToRun?() + } + } + + public final class Iterator: AsyncIteratorProtocol, @unchecked Sendable { + private let cancellation: Cancellation + private var baseIterator: AsyncStream.Iterator? + + fileprivate init( + baseIterator: AsyncStream.Iterator, + cancellation: Cancellation + ) { + self.baseIterator = baseIterator + self.cancellation = cancellation + } + + deinit { + cancellation.cancel() + } + + public func next() async -> Element? { + if Task.isCancelled { + cancellation.cancel() + return nil + } + let cancellation = cancellation + let element: Element? = await withTaskCancellationHandler { + guard var iterator = baseIterator else { return nil } + let element = await iterator.next() + baseIterator = iterator + return element + } onCancel: { + cancellation.cancel() + } + if element == nil { + cancellation.cancel() + } + return element + } + } + + private let stream: AsyncStream + private let cancellation: Cancellation + + public init( + _ build: ( + AsyncStream.Continuation, + Cancellation + ) -> Void + ) { + let cancellation = Cancellation() + self.cancellation = cancellation + self.stream = AsyncStream { continuation in + build(continuation, cancellation) + } + } + + public func makeAsyncIterator() -> Iterator { + Iterator( + baseIterator: stream.makeAsyncIterator(), + cancellation: cancellation + ) + } + + public func cancel() { + cancellation.cancel() + } +} + public protocol MobileTerminalOutputSinking: Sendable { /// The output byte stream for a terminal surface. /// /// - Parameter surfaceID: The terminal surface identifier. - /// - Returns: An `AsyncStream` of output chunks. Ending iteration (or - /// cancelling the consuming task) unregisters the surface. - @MainActor func terminalOutputStream(surfaceID: String) -> AsyncStream + /// - Returns: A cancellable output sequence. The mounted view must call + /// `cancel()` when the surface detaches. + @MainActor func terminalOutputStream(surfaceID: String) -> MobileTerminalOutputStream /// Mark the current yielded chunk as applied, allowing the next buffered /// chunk for the same surface to be yielded. diff --git a/Packages/iOS/CmuxMobileShellUI/Package.resolved b/Packages/iOS/CmuxMobileShellUI/Package.resolved index 8dfa8c37170b..a54d5dcc5735 100644 --- a/Packages/iOS/CmuxMobileShellUI/Package.resolved +++ b/Packages/iOS/CmuxMobileShellUI/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "8e4d600dd748d7cf440352cd9cd6219c40b399f51b2f821c0c4ddf4fae27d332", + "originHash" : "def74a5441fe55f3ee892bd397355f06ba7a03d2d3970d76dce0e505bd2a4511", "pins" : [ { "identity" : "highlightr", diff --git a/Packages/iOS/CmuxMobileShellUI/Package.swift b/Packages/iOS/CmuxMobileShellUI/Package.swift index 66f152487ad1..94af396fe7c0 100644 --- a/Packages/iOS/CmuxMobileShellUI/Package.swift +++ b/Packages/iOS/CmuxMobileShellUI/Package.swift @@ -17,8 +17,9 @@ let package = Package( dependencies: [ .package(path: "../../Shared/CMUXMobileCore"), .package(path: "../../Shared/CmuxAgentChat"), - .package(path: "../CmuxAgentChatUI"), .package(path: "../../Shared/CmuxAuthRuntime"), + .package(path: "../CmuxAgentChatUI"), + .package(path: "../CmuxAgentGUIUI"), .package(path: "../CmuxMobileBrowser"), .package(path: "../CmuxMobileBrowserStream"), .package(path: "../CmuxMobileCamera"), @@ -42,6 +43,7 @@ let package = Package( "CmuxAgentChat", "CmuxAgentChatUI", "CmuxAuthRuntime", + "CmuxAgentGUIUI", "CmuxMobileBrowser", "CmuxMobileBrowserStream", "CmuxMobileCamera", @@ -67,10 +69,10 @@ let package = Package( name: "CmuxMobileShellUITests", dependencies: [ "CMUXMobileCore", + "CmuxAgentChat", "CmuxAuthRuntime", "CmuxMobilePairedMac", "CmuxMobileShellUI", - "CmuxAgentChat", "CmuxMobileShell", "CmuxMobileShellModel", "CmuxMobileSupport", diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentGUIToggleButton.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentGUIToggleButton.swift new file mode 100644 index 000000000000..befcb7b0f5f5 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentGUIToggleButton.swift @@ -0,0 +1,33 @@ +#if os(iOS) +import CMUXMobileCore +import CmuxMobileSupport +import SwiftUI + +struct AgentGUIToggleButton: View { + @Binding var isSelected: Bool + let terminalTheme: TerminalTheme + + private var title: String { + isSelected + ? L10n.string("mobile.agentGUI.showTerminal", defaultValue: "Show Terminal") + : L10n.string("mobile.agentGUI.showTranscript", defaultValue: "Show Agent Transcript") + } + + var body: some View { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isSelected.toggle() + } + } label: { + Label( + title, + systemImage: isSelected ? "terminal" : "bubble.left.and.text.bubble.right" + ) + .labelStyle(.iconOnly) + } + .foregroundStyle(terminalTheme.terminalChromeForegroundColor) + .accessibilityLabel(title) + .accessibilityIdentifier("MobileAgentGUIToggle") + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift index fd00d5d49f2c..2ae9f60e5e83 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift @@ -1,3 +1,6 @@ +#if os(iOS) && DEBUG +import CmuxAgentGUIUI +#endif import Foundation import CMUXMobileCore import CmuxAuthRuntime @@ -83,6 +86,24 @@ struct CMUXMobileRootView: View { #endif } + private var shouldShowTranscriptDemoPreview: Bool { + #if os(iOS) && DEBUG + return UITestConfig.transcriptDemoPreviewEnabled + #else + return false + #endif + } + + @ViewBuilder private var transcriptDemoPreview: some View { + #if os(iOS) && DEBUG + NavigationStack { + TranscriptDemoScreen() + } + #else + EmptyView() + #endif + } + private var shouldShowWorkspaceListLayoutPreview: Bool { #if os(iOS) && DEBUG return UITestConfig.workspaceListLayoutPreviewEnabled @@ -316,6 +337,8 @@ struct CMUXMobileRootView: View { agentChatDemoPreview } else if shouldShowTerminalLayoutPreview { terminalLayoutPreview + } else if shouldShowTranscriptDemoPreview { + transcriptDemoPreview } else if shouldShowWorkspaceListLayoutPreview { workspaceListLayoutPreview } else if shouldShowHiddenComputersPreview { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift index dbb3bd78d09a..235f8e058f74 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift @@ -182,6 +182,7 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { var onVisibleArtifactCountChanged: @MainActor (_ count: Int) -> Void var onArtifactGalleryRefreshSignal: @MainActor (TerminalArtifactGalleryRefreshSignal) -> Void private var outputTask: Task? + private var outputStream: MobileTerminalOutputStream? var outputStartContinuation: AsyncStream.Continuation? var preparedViewportReportsByReportID: [UInt64: MobileTerminalViewportPreparation] = [:] private var liveFontTask: Task? @@ -324,13 +325,15 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { } ) // Drive every output chunk into the libghostty surface. Ending this - // task terminates the stream, which unregisters the surface and - // clears its viewport pin on the Mac (see `terminalOutputStream`). + // stream lease unregisters the surface and clears its viewport pin + // on the Mac (see `terminalOutputStream`). + let terminalOutputStream = store.terminalOutputStream(surfaceID: surfaceID) + outputStream = terminalOutputStream outputTask = Task { @MainActor [weak self, weak surfaceView, weak store] in for await _ in outputStartSignal { break } guard !Task.isCancelled else { return } guard let store else { return } - for await chunk in store.terminalOutputStream(surfaceID: surfaceID) { + for await chunk in terminalOutputStream { guard !Task.isCancelled else { return } guard let self else { return } guard let surfaceView else { return } @@ -483,6 +486,8 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { outputStartContinuation?.finish() outputStartContinuation = nil preparedViewportReportsByReportID.removeAll() + outputStream?.cancel() + outputStream = nil outputTask?.cancel() outputTask = nil verifiedReplayState.invalidate() diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/HiddenComputerRow.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/HiddenComputerRow.swift index f27bf69269a0..f908b0d904d3 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/HiddenComputerRow.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/HiddenComputerRow.swift @@ -172,6 +172,7 @@ struct HiddenComputerRow: View { /// Shared localized copy for every Hidden Computers surface so the strings /// cannot drift between the Computers screen, the disconnected shell, and its /// empty state. +// lint:allow namespace-type - existing static helper surface, preserving call sites during UI closeout. enum HiddenComputersCopy { static var title: String { L10n.string("mobile.computers.hidden.title", defaultValue: "Hidden Computers") diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift index d56747747ba4..dfdb3302024c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift @@ -1,4 +1,5 @@ import CMUXMobileCore +import CmuxAgentGUIUI import CmuxMobileSupport import Foundation import Observation @@ -30,6 +31,7 @@ public final class MobileDisplaySettings { private static let terminalFilesChipEnabledKey = "cmux.mobile.terminalFilesChipEnabled" private static let taskComposerEnabledKey = "cmux.mobile.taskComposerEnabled" private static let workspacePreviewLineCountKey = "cmux.mobile.workspacePreviewLineCount" + private static let transcriptDensityKey = "cmux.mobile.transcriptDensity" private static let unreadIndicatorLeftShiftKey = "cmux.mobile.debug.unreadIndicatorLeftShift.v2" #if DEBUG private static let taskComposerLayoutStyleKey = "cmux.mobile.debug.taskComposerLayoutStyle.v1" @@ -129,6 +131,12 @@ public final class MobileDisplaySettings { } } + /// The transcript's vertical and metadata-type register. Defaults to compact. + /// Mutating this writes the raw value through to the injected ``UserDefaults``. + public var transcriptDensity: TranscriptDensity { + didSet { defaults.set(transcriptDensity.rawValue, forKey: Self.transcriptDensityKey) } + } + /// DEBUG-only layout tuning value, exposed in Settings > Developer. Positive /// values move the unread indicator left without changing row column widths. public var unreadIndicatorLeftShift: Double { @@ -206,6 +214,8 @@ public final class MobileDisplaySettings { self.workspacePreviewLineCount = Self.clampedWorkspacePreviewLineCount( storedPreviewLines ?? Self.defaultWorkspacePreviewLineCount ) + self.transcriptDensity = defaults.string(forKey: Self.transcriptDensityKey) + .flatMap(TranscriptDensity.init(rawValue:)) ?? .compact let storedUnreadLeftShift = defaults.object(forKey: Self.unreadIndicatorLeftShiftKey) as? Double self.unreadIndicatorLeftShift = Self.clamped( storedUnreadLeftShift ?? Self.defaultUnreadIndicatorLeftShift, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 629ad80755ef..ce0eae3f0813 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -1,5 +1,6 @@ #if os(iOS) import CmuxAuthRuntime +import CmuxAgentGUIUI import CmuxMobileShell import CmuxMobileShellModel import CmuxMobileSupport @@ -43,7 +44,6 @@ struct MobileSettingsView: View { @State private var showingOnboarding = false @State private var showingSetupHelp = false #if DEBUG - @State private var showingChatDemo = false @State private var showingTerminalDemo = false @State private var showingToastGallery = false /// Seconds between tapping "Run Toast Demo" and the first toast, so you @@ -240,15 +240,15 @@ struct MobileSettingsView: View { #if DEBUG Section(L10n.string("mobile.settings.developer", defaultValue: "Developer")) { - Button { - showingChatDemo = true + NavigationLink { + TranscriptDemoScreen() } label: { Label( - L10n.string("mobile.settings.agentChatDemo", defaultValue: "Agent Chat Demo"), - systemImage: "bubble.left.and.bubble.right" + L10n.string("mobile.settings.transcriptDemo", defaultValue: "Transcript Demo"), + systemImage: "bubble.left.and.text.bubble.right" ) } - .accessibilityIdentifier("MobileSettingsAgentChatDemo") + .accessibilityIdentifier("MobileSettingsTranscriptDemo") Button { showingTerminalDemo = true } label: { @@ -341,6 +341,25 @@ struct MobileSettingsView: View { #endif Section(L10n.string("mobile.settings.display", defaultValue: "Display")) { + Picker(selection: $displaySettings.transcriptDensity) { + Text(L10n.string( + "mobile.settings.transcriptDensity.comfortable", + defaultValue: "Comfortable" + )) + .tag(TranscriptDensity.comfortable) + Text(L10n.string( + "mobile.settings.transcriptDensity.compact", + defaultValue: "Compact" + )) + .tag(TranscriptDensity.compact) + } label: { + Text(L10n.string( + "mobile.settings.transcriptDensity", + defaultValue: "Transcript Density" + )) + } + .accessibilityIdentifier("MobileSettingsTranscriptDensityPicker") + Toggle(isOn: $displaySettings.showMissingFiles) { Text(L10n.string( "mobile.settings.showMissingFiles", @@ -456,9 +475,6 @@ struct MobileSettingsView: View { TerminalShortcutsSettingsView() } #if DEBUG - .fullScreenCover(isPresented: $showingChatDemo) { - AgentChatDemoScreen() - } .fullScreenCover(isPresented: $showingTerminalDemo) { TerminalLogDemoScreen() } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedPreviewView.swift index 7ae35d9c43a9..ac03ba8076d6 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedPreviewView.swift @@ -272,6 +272,7 @@ public struct NotificationFeedPreviewView: View { } +// lint:allow free-function - file-local helper makeNotificationFeedPreviewFixtureItems, kept as a pure helper to avoid unrelated type migration in this UI closeout. private func makeNotificationFeedPreviewFixtureItems(referenceDate: Date) -> [MobileNotificationFeedItem] { let calendar = Calendar.autoupdatingCurrent let startOfToday = calendar.startOfDay(for: referenceDate) @@ -428,6 +429,7 @@ private func makeNotificationFeedPreviewFixtureItems(referenceDate: Date) -> [Mo /// title-matches-workspace layout branch. Index-derived values keep every run /// identical; plain strings are fine here because the fixture is DEBUG-only /// synthetic data, never product UI copy. +// lint:allow free-function - file-local helper makeNotificationFeedPreviewStressItems, kept as a pure helper to avoid unrelated type migration in this UI closeout. private func makeNotificationFeedPreviewStressItems( referenceDate: Date, count: Int diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedRowPresentation.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedRowPresentation.swift index f0493b092053..09e83eecfd2c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedRowPresentation.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedRowPresentation.swift @@ -61,6 +61,7 @@ struct NotificationFeedRowPresentation: Equatable, Sendable { } } +// lint:allow free-function - file-local helper notificationFeedRowAccessibilityDetails, kept as a pure helper to avoid unrelated type migration in this UI closeout. private func notificationFeedRowAccessibilityDetails( item: MobileNotificationFeedItem, workspaceName: String, @@ -91,6 +92,7 @@ private func notificationFeedRowAccessibilityDetails( // concurrent hot paths (the PR 5347 regression class). The catalog values // keep their positional placeholders; interpolation arguments bind to them // in order. +// lint:allow free-function - file-local helper notificationFeedRowAccessibilityField, kept as a pure helper to avoid unrelated type migration in this UI closeout. private func notificationFeedRowAccessibilityField(label: String, value: String) -> String { L10n.string( "mobile.notificationFeed.row.fieldFormat", @@ -98,6 +100,7 @@ private func notificationFeedRowAccessibilityField(label: String, value: String) ) } +// lint:allow free-function - file-local helper notificationFeedRowApplyingConnectionStatus, kept as a pure helper to avoid unrelated type migration in this UI closeout. private func notificationFeedRowApplyingConnectionStatus( _ connectionStatus: MobileMacConnectionStatus, to value: String @@ -118,20 +121,24 @@ private func notificationFeedRowApplyingConnectionStatus( } } +// lint:allow free-function - file-local helper notificationFeedRowNormalized, kept as a pure helper to avoid unrelated type migration in this UI closeout. private func notificationFeedRowNormalized(_ value: String?) -> String? { guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil } return value } +// lint:allow free-function - file-local helper notificationFeedRowMatchesAny, kept as a pure helper to avoid unrelated type migration in this UI closeout. private func notificationFeedRowMatchesAny(_ candidate: String, _ values: [String]) -> Bool { values.contains { notificationFeedRowMatches(candidate, $0) } } +// lint:allow free-function - file-local helper notificationFeedRowMatches, kept as a pure helper to avoid unrelated type migration in this UI closeout. private func notificationFeedRowMatches(_ lhs: String, _ rhs: String) -> Bool { notificationFeedRowCanonical(lhs) == notificationFeedRowCanonical(rhs) } +// lint:allow free-function - file-local helper notificationFeedRowCanonical, kept as a pure helper to avoid unrelated type migration in this UI closeout. private func notificationFeedRowCanonical(_ value: String) -> String { value .split(whereSeparator: \.isWhitespace) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingScreenshot.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingScreenshot.swift index 36cfbae31980..18ac84bf7c87 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingScreenshot.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingScreenshot.swift @@ -250,6 +250,7 @@ private struct OnboardingIPhoneScreenshotFrame: View { } } +// lint:allow namespace-type - existing static helper surface, preserving call sites during UI closeout. private enum OnboardingIPhoneScreenshotFrameMetrics { static let outerAspectRatio: CGFloat = 78.0 / 163.4 static let screenAspectRatio: CGFloat = 1206 / 2622 diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactChildCountFormatter.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactChildCountFormatter.swift index 340821834e07..56b21df7ef32 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactChildCountFormatter.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactChildCountFormatter.swift @@ -10,21 +10,31 @@ struct TerminalArtifactChildCountFormatter: Sendable { } func string(count: Int, isCapped: Bool) -> String { + let bundle = localizedBundle if isCapped { return String( localized: "terminal.artifact.gallery.child_count_capped", defaultValue: "\(count)+ items", - bundle: .module, + bundle: bundle, locale: locale ) } let attributed = AttributedString( localized: "terminal.artifact.gallery.child_count", defaultValue: "^[\(count) item](inflect: true)", - bundle: .module, + bundle: bundle, locale: locale ) return String(attributed.characters) } + + private var localizedBundle: Bundle { + guard let languageCode = locale.language.languageCode?.identifier, + let url = Bundle.module.url(forResource: languageCode, withExtension: "lproj"), + let bundle = Bundle(url: url) else { + return .module + } + return bundle + } } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalOutputApplicationPath.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalOutputApplicationPath.swift index 7151344e6a60..c6c369c22731 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalOutputApplicationPath.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalOutputApplicationPath.swift @@ -6,6 +6,7 @@ enum TerminalOutputApplicationPath: Equatable { case legacy } +// lint:allow free-function - internal package helper terminalOutputApplicationPath, preserving existing call sites during UI closeout. func terminalOutputApplicationPath( for chunk: MobileTerminalOutputChunk, expectedSurfaceID: String diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenu.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenu.swift index 7e48e37a7c16..0e0d0929166a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenu.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenu.swift @@ -111,7 +111,7 @@ struct TerminalPickerMenu: View, Equatable { #if canImport(UIKit) Section { - if !value.hasActiveBrowser && !value.isChatMode { + if !value.hasActiveBrowser { Button(action: actions.openTextSheet) { Label( L10n.string("mobile.terminal.viewAsText", defaultValue: "View as Text"), diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenuValue.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenuValue.swift index eb41f85fed97..3c58d5d5491c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenuValue.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalPickerMenuValue.swift @@ -7,7 +7,6 @@ struct TerminalPickerMenuValue: Equatable { let selectedName: String? let canCreateWorkspace: Bool let hasActiveBrowser: Bool - let isChatMode: Bool let browserStreamRows: [BrowserStreamPickerRow] let supportsBrowserStream: Bool let activeBrowserStreamPanelID: String? @@ -18,7 +17,6 @@ struct TerminalPickerMenuValue: Equatable { selectedID: MobileTerminalPreview.ID?, canCreateWorkspace: Bool, hasActiveBrowser: Bool, - isChatMode: Bool, browserStreamRows: [BrowserStreamPickerRow] = [], supportsBrowserStream: Bool = false, activeBrowserStreamPanelID: String? = nil @@ -31,7 +29,6 @@ struct TerminalPickerMenuValue: Equatable { selectedName = selection?.name self.canCreateWorkspace = canCreateWorkspace self.hasActiveBrowser = hasActiveBrowser - self.isChatMode = isChatMode self.browserStreamRows = browserStreamRows self.supportsBrowserStream = supportsBrowserStream self.activeBrowserStreamPanelID = activeBrowserStreamPanelID diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceActiveSurface.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceActiveSurface.swift index 65c2b3f9997c..59727831ab51 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceActiveSurface.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceActiveSurface.swift @@ -2,19 +2,13 @@ import Foundation enum WorkspaceActiveSurface: Equatable { case terminal - case chat case browser case browserStream static func derive( - isChatMode: Bool, - hasChosenChatSession: Bool, hasActiveBrowser: Bool, hasActiveBrowserStream: Bool = false ) -> Self { - if isChatMode, hasChosenChatSession { - return .chat - } if hasActiveBrowser { return .browser } @@ -24,7 +18,7 @@ enum WorkspaceActiveSurface: Equatable { return .terminal } - /// The terminal to refocus when chrome (chat/browser) returns to the + /// The terminal to refocus when chrome (the browser) returns to the /// terminal surface, or nil when autofocus must stay suppressed. /// /// The terminal stays mounted under chrome (an opacity swap, not a diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChangesToolbarButton.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChangesToolbarButton.swift index ecd46471755b..8407b78a9fcb 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChangesToolbarButton.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChangesToolbarButton.swift @@ -9,11 +9,12 @@ import SwiftUI struct WorkspaceChangesToolbarButton: View { let chip: MobileWorkspaceChangesChip? let workspaceID: String + var compact = false let action: @MainActor () -> Void var body: some View { Button(action: action) { - if let chip, chip.filesChanged > 0 { + if let chip, chip.filesChanged > 0, !compact { WorkspaceChangesChipLabel( chip: chip, workspaceID: workspaceID, @@ -22,24 +23,21 @@ struct WorkspaceChangesToolbarButton: View { ) .frame(minWidth: 30, minHeight: 30) } else { - Label( - String( - localized: "workspace.changes.title", - defaultValue: "Changes", - bundle: .module - ), - systemImage: "plus.forwardslash.minus" - ) + Label(changesTitle, systemImage: "plus.forwardslash.minus") .labelStyle(.iconOnly) .frame(width: 30, height: 30) - .accessibilityLabel(String( - localized: "workspace.changes.title", - defaultValue: "Changes", - bundle: .module - )) + .accessibilityLabel(changesTitle) } } .accessibilityIdentifier("MobileChangesButton") } + + private var changesTitle: String { + String( + localized: "workspace.changes.title", + defaultValue: "Changes", + bundle: .module + ) + } } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailDelayedTerminalPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailDelayedTerminalPreviewView.swift index a3fd47a5ab8f..b21266c85593 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailDelayedTerminalPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailDelayedTerminalPreviewView.swift @@ -1,5 +1,4 @@ import CMUXMobileCore -import CmuxAgentChat import CmuxMobileBrowser import CmuxMobileBrowserStream import CmuxMobileShell @@ -72,22 +71,6 @@ struct WorkspaceDetailDelayedTerminalPreviewView: View { if Self.showsThemeParitySequence { await runThemeParitySequence() } - if Self.showsChatToggle { - store.rememberChatSessions( - [ - ChatSessionDescriptor( - id: "preview-chat-session", - agentKind: .claude, - title: "Preview Agent", - workspaceID: Self.workspaceID.rawValue, - terminalID: Self.terminalID.rawValue, - state: .working(since: Date()), - lastActivityAt: Date() - ), - ], - workspaceID: Self.workspaceID.rawValue - ) - } } } @@ -95,10 +78,6 @@ struct WorkspaceDetailDelayedTerminalPreviewView: View { ProcessInfo.processInfo.environment["CMUX_UITEST_WORKSPACE_DETAIL_LONG_TITLE"] == "1" } - private static var showsChatToggle: Bool { - ProcessInfo.processInfo.environment["CMUX_UITEST_WORKSPACE_DETAIL_CHAT_TOGGLE"] == "1" - } - private static var showsThemeParitySequence: Bool { ProcessInfo.processInfo.environment["CMUX_UITEST_THEME_PARITY_PREVIEW"] == "1" } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+AgentChat.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+AgentChat.swift deleted file mode 100644 index b32cc2b8c7b8..000000000000 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+AgentChat.swift +++ /dev/null @@ -1,417 +0,0 @@ -import CmuxAgentChat -import CmuxMobileShell -import CmuxMobileSupport -import SwiftUI - -#if os(iOS) -extension WorkspaceDetailView { - var selectedTerminalID: String? { - selectedTerminal?.id.rawValue - } - - /// Chat sessions this view should render from right now. On first render - /// after returning to a workspace, local `@State` is empty, but the shell - /// store still has the last authoritative GUI-history snapshot for this - /// workspace. Use that immediately so the toolbar does not flicker while the - /// refresh task reconnects. - private var visibleChatSessions: [ChatSessionDescriptor] { - if chatSessionsWorkspaceID == workspace.id.rawValue { - return chatSessions - } - return store.cachedChatSessions(workspaceID: workspace.id.rawValue) - } - - /// The chat session belonging to the currently visible tab/terminal, if - /// any. The toggle and the chat bind to THIS: the tab the user is looking - /// at. A tab with no agent session yields nil; a past agent that has since - /// ended still matches here because its record keeps the terminal binding. - private var sessionForSelectedTerminal: ChatSessionDescriptor? { - guard let terminalID = selectedTerminalID else { return nil } - return visibleChatSessions.first { $0.terminalID == terminalID } - } - - /// The session backing the toolbar toggle. Prefer the currently selected - /// terminal's live match, but fall back to the last cached match while the - /// selected terminal is temporarily unavailable during mode transitions. - private var chatToggleSession: ChatSessionDescriptor? { - if let sessionForSelectedTerminal { - return sessionForSelectedTerminal - } - guard let terminalID = cachedChatToggleTerminalID else { return nil } - if let selectedTerminalID, selectedTerminalID != terminalID { - return nil - } - return visibleChatSessions.first { $0.terminalID == terminalID } - } - - var shouldShowChatToggle: Bool { - isChatMode || chatToggleSession != nil - } - - /// The session chat mode opens: the visible tab's session, or the pinned - /// session while chat mode is on. - var chosenChatSession: ChatSessionDescriptor? { - if let pinnedChatSessionID { - return visibleChatSessions.first { $0.id == pinnedChatSessionID } - } - return chatToggleSession - } - - /// The session whose full chat model should stay warm while this detail is - /// visible. In terminal mode this is the selected terminal's session; in - /// chat mode it is the pinned session. - private var warmChatSession: ChatSessionDescriptor? { - chosenChatSession ?? chatToggleSession - } - - /// Identity for the session refetch: workspace, installed chat source, and a - /// foreground epoch. A change re-runs `.task(id:)`, which re-subscribes to - /// the push stream and re-pulls the authoritative session list. - var chatRefreshKey: String { - let connected = store.connectionState == .connected ? 1 : 0 - let foreground = scenePhase == .background ? 0 : 1 - return "\(workspace.id.rawValue)#\(store.agentChatEventSourceIdentity)#\(connected)#\(foreground)" - } - - /// Identity for the selected chat model's event stream. Descriptor updates - /// reconcile through `applyDescriptorSnapshot`, so list refreshes do not - /// restart the same long-lived subscription. - var chatConversationWarmKey: String { - let connected = store.connectionState == .connected ? 1 : 0 - let foreground = scenePhase == .background ? 0 : 1 - guard let session = warmChatSession else { - return "\(workspace.id.rawValue)#none#\(store.agentChatEventSourceIdentity)#\(connected)#\(foreground)" - } - return "\(workspace.id.rawValue)#\(session.id)#\(store.agentChatEventSourceIdentity)#\(connected)#\(foreground)" - } - - @ViewBuilder - func chatContent(_ session: ChatSessionDescriptor) -> some View { - if let conversation = chatConversationStores[session.id] { - WorkspaceChatPane( - session: session, - conversation: conversation, - store: store, - draft: Binding( - get: { chatDrafts[session.id] ?? "" }, - set: { chatDrafts[session.id] = $0 } - ), - onExitChat: { - withAnimation(.snappy(duration: 0.28)) { - isChatMode = false - } - pinnedChatSessionID = nil - } - ) - .id(session.id) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .mobileChatTopScrollEdgeLayout(legacyTopPadding: terminalTopPadding) - } else { - Color.clear - .task(id: session.id) { - _ = ensureChatConversationStore(for: session) - } - } - } - - @ViewBuilder - var toolbarTrailingCluster: some View { - HStack(spacing: 8) { - if shouldShowChatToggle { - chatToggleButton - .frame(width: 44, height: 44) - .transition(.scale(scale: 0.82, anchor: .trailing).combined(with: .opacity)) - } - terminalPickerToolbarButton - .frame(width: 44, height: 44) - } - .frame(width: shouldShowChatToggle ? 96 : 44, height: 44, alignment: .trailing) - .animation(.snappy(duration: 0.25), value: shouldShowChatToggle) - } - - var chatToggleButton: some View { - Button(action: toggleChatMode) { - Image(systemName: isChatMode - ? "bubble.left.and.bubble.right.fill" - : "bubble.left.and.bubble.right") - } - .accessibilityLabel(L10n.string("mobile.workspace.agentChat", defaultValue: "Agent Chat")) - .accessibilityIdentifier("MobileWorkspaceAgentChatButton") - .disabled(!isChatMode && chatToggleSession == nil) - } - - /// Keeps the chat-capable session list current while this workspace is - /// shown, so the GUI toggle appears as soon as a coding agent becomes - /// active, without polling. The Mac pushes a `chat.message` frame on every - /// descriptor/state change; we register the push stream first, seed the list - /// once, then fold each subsequent frame in. - func refreshChatSessions() async { - let workspaceID = workspace.id.rawValue - let sourceIdentity = store.agentChatEventSourceIdentity - guard let source = store.makeChatEventSource() else { - applyChatModeFallback(canInvalidateSelection: false) - return - } - var reducer = ChatSessionListReducer(workspaceID: workspaceID) - let stream = await source.sessionEvents() - let seedOutcome: WorkspaceChatSessionRefreshOutcome - do { - seedOutcome = .authoritative(try await source.sessions(workspaceID: workspaceID)) - } catch { - seedOutcome = store.chatSessionListFailureMeansUnsupported(error) - ? .authoritative([]) - : .unavailable - } - guard !Task.isCancelled, - workspaceID == workspace.id.rawValue, - sourceIdentity == store.agentChatEventSourceIdentity - else { return } - let nextSessions = seedOutcome.applying(to: visibleChatSessions) - withAnimation(.snappy(duration: 0.25)) { - chatSessionsWorkspaceID = workspaceID - chatSessions = nextSessions - } - if seedOutcome.canInvalidateSelection { - store.rememberChatSessions(nextSessions, workspaceID: workspaceID) - } - reconcileChatSessionSnapshot(seedOutcomeCanInvalidateSelection: seedOutcome.canInvalidateSelection) - for await frame in stream { - guard !Task.isCancelled, - workspaceID == workspace.id.rawValue, - sourceIdentity == store.agentChatEventSourceIdentity - else { break } - let current = visibleChatSessions - let reduced = reducer.applying(frame, to: current) - let next = reduced.preservingPinnedPendingAliasRemoval( - previous: current, - frame: frame, - pinnedID: pinnedChatSessionID, - cachedTerminalID: cachedChatToggleTerminalID - ) - guard next != current else { - _ = await refreshAfterIgnoredChatSessionFrameIfNeeded( - frame, - source: source, - workspaceID: workspaceID, - sourceIdentity: sourceIdentity - ) - continue - } - withAnimation(.snappy(duration: 0.25)) { - chatSessionsWorkspaceID = workspaceID - chatSessions = next - } - store.rememberChatSessions(next, workspaceID: workspaceID) - reconcileChatSessionSnapshot(seedOutcomeCanInvalidateSelection: true) - } - } - - /// If a live descriptor push names the selected terminal but carries a stale - /// or missing workspace id, a scoped reducer correctly ignores it. Pull the - /// authoritative workspace snapshot once so the toolbar toggle appears - /// without requiring the user to leave and re-enter the workspace. - private func refreshAfterIgnoredChatSessionFrameIfNeeded( - _ frame: ChatSessionEventFrame, - source: MobileChatEventSource, - workspaceID: String, - sourceIdentity: String - ) async -> Bool { - guard frame.shouldPullAuthoritativeSnapshotForIgnoredWorkspaceFrame( - workspaceID: workspaceID, - selectedTerminalID: selectedTerminalID, - cachedChatToggleTerminalID: cachedChatToggleTerminalID - ) - else { return false } - guard !Task.isCancelled else { return false } - let sessions: [ChatSessionDescriptor] - guard let refreshed = await coalescedIgnoredChatSessionSnapshot( - source: source, - workspaceID: workspaceID, - sourceIdentity: sourceIdentity - ) else { - return false - } - sessions = refreshed - guard !Task.isCancelled, - workspaceID == workspace.id.rawValue, - sourceIdentity == store.agentChatEventSourceIdentity - else { return false } - let next = WorkspaceChatSessionRefreshOutcome.authoritative(sessions) - .applying(to: visibleChatSessions) - guard next != visibleChatSessions else { return true } - withAnimation(.snappy(duration: 0.25)) { - chatSessionsWorkspaceID = workspaceID - chatSessions = next - } - store.rememberChatSessions(next, workspaceID: workspaceID) - reconcileChatSessionSnapshot(seedOutcomeCanInvalidateSelection: true) - return true - } - - private func coalescedIgnoredChatSessionSnapshot( - source: MobileChatEventSource, - workspaceID: String, - sourceIdentity: String - ) async -> [ChatSessionDescriptor]? { - let key = "\(workspaceID)#\(sourceIdentity)" - if let task = ignoredChatSessionRefreshTask, - ignoredChatSessionRefreshKey == key { - return await withTaskCancellationHandler { - await task.value - } onCancel: { - task.cancel() - } - } - let taskID = UUID() - let task = Task { () -> [ChatSessionDescriptor]? in - do { - return try await source.sessions(workspaceID: workspaceID) - } catch { - return nil - } - } - ignoredChatSessionRefreshKey = key - ignoredChatSessionRefreshID = taskID - ignoredChatSessionRefreshTask = task - let result = await withTaskCancellationHandler { - await task.value - } onCancel: { - task.cancel() - } - if ignoredChatSessionRefreshKey == key, - ignoredChatSessionRefreshID == taskID { - ignoredChatSessionRefreshKey = nil - ignoredChatSessionRefreshID = nil - ignoredChatSessionRefreshTask = nil - } - return result - } - - /// Runs the selected terminal's chat store while terminal mode is visible. - /// Opening chat reuses the same store, so there is only one subscription and - /// the transcript/history loaded in the background remains available. - func runWarmChatConversation() async { - guard scenePhase != .background, - let session = warmChatSession, - let conversation = ensureChatConversationStore(for: session, requiresCurrentSource: true) - else { return } - pruneCachedChatConversations() - await conversation.run() - } - - /// Keeps the toolbar's chat affordance anchored to the latest session - /// snapshot for the selected terminal. Authoritative empty snapshots clear - /// the anchor; unavailable refreshes preserve `chatSessions`, so the anchor - /// naturally survives reconnects. - func refreshCachedChatToggleAnchor() { - if let terminalID = sessionForSelectedTerminal?.terminalID { - cachedChatToggleTerminalID = terminalID - return - } - - if let selectedTerminalID { - cachedChatToggleTerminalID = nil - return - } - - guard let terminalID = cachedChatToggleTerminalID else { return } - if !visibleChatSessions.contains(where: { $0.terminalID == terminalID }) { - cachedChatToggleTerminalID = nil - } - } - - /// Creates or updates the cached conversation store for a session. This is - /// called from tasks/actions, not from `body`, so the body remains a pure - /// projection of state. - @discardableResult - private func ensureChatConversationStore( - for session: ChatSessionDescriptor, - requiresCurrentSource: Bool = false - ) -> ChatConversationStore? { - let source = store.makeChatEventSource() - if let existing = chatConversationStores[session.id] { - if let source { - existing.replaceSource( - source, - descriptor: session, - sourceIdentity: store.agentChatEventSourceIdentity - ) - } else if requiresCurrentSource { - return nil - } - return existing - } - guard let source else { return nil } - let conversation = ChatConversationStore( - descriptor: session, - source: source, - sourceIdentity: store.agentChatEventSourceIdentity - ) - chatConversationStores[session.id] = conversation - return conversation - } - - /// Flip between the terminal and the inline agent chat, pinning/unpinning the - /// chosen session. Shared by the toolbar button and the menu row. - private func toggleChatMode() { - if isChatMode { - withAnimation(.snappy(duration: 0.28)) { isChatMode = false } - pinnedChatSessionID = nil - return - } - guard let openingSession = chatToggleSession, - ensureChatConversationStore(for: openingSession) != nil else { return } - withAnimation(.snappy(duration: 0.28)) { - isChatMode = true - } - pinnedChatSessionID = openingSession.id - } - - /// Keeps the active transcript store warm without retaining stores for every - /// ended session the host keeps in history. During transport loss the warm - /// session is still derived from the preserved session list, so reconnects do - /// not evict the GUI state the user can currently open. - private func pruneCachedChatConversations() { - guard let warmID = warmChatSession?.id else { - chatConversationStores.removeAll() - return - } - chatConversationStores = chatConversationStores.filter { $0.key == warmID } - } - - /// While chat is open and pinned to a session that has ended, if the agent - /// was reopened on the same terminal, re-pin to the newer non-ended session - /// so the GUI becomes editable again. - private func repinToReopenedSession() { - guard isChatMode, - let pinnedID = pinnedChatSessionID else { return } - if let replacementID = visibleChatSessions.replacementSessionIDForPinnedChat( - pinnedID: pinnedID, - cachedTerminalID: cachedChatToggleTerminalID - ) { - pinnedChatSessionID = replacementID - } - } - - /// If the session backing chat mode disappeared, fall back to the terminal - /// rather than showing an empty chat. - private func applyChatModeFallback(canInvalidateSelection: Bool) { - guard canInvalidateSelection else { return } - if isChatMode, chosenChatSession == nil { - isChatMode = false - pinnedChatSessionID = nil - } - } - - private func reconcileChatSessionSnapshot(seedOutcomeCanInvalidateSelection: Bool) { - refreshCachedChatToggleAnchor() - pruneCachedChatConversations() - if let warmChatSession { - _ = ensureChatConversationStore(for: warmChatSession) - } - repinToReopenedSession() - applyChatModeFallback(canInvalidateSelection: seedOutcomeCanInvalidateSelection) - } - -} -#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+DerivedState.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+DerivedState.swift index 2ae94e8e4cf0..65a7a895a509 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+DerivedState.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+DerivedState.swift @@ -1,13 +1,20 @@ -import CmuxAgentChat +import CmuxAgentGUIUI +import CmuxAgentReplica +import CmuxMobileShell import CmuxMobileShellModel import CmuxMobileWorkspace import CoreGraphics +import SwiftUI extension WorkspaceDetailView { var selectedTerminal: MobileTerminalPreview? { workspace.terminals.first { $0.id == store.selectedTerminalID } ?? workspace.terminals.first } + var selectedTerminalID: String? { + selectedTerminal?.id.rawValue + } + var selectedToolbarSubtitle: String? { guard let selectedTerminalID = store.selectedTerminalID else { return nil } return workspace.terminals.first { $0.id == selectedTerminalID }?.name @@ -26,9 +33,34 @@ extension WorkspaceDetailView { } #if os(iOS) - /// The tab/terminal name for a session, for the chat header subtitle. - func tabName(for session: ChatSessionDescriptor) -> String? { - workspace.terminals.first { $0.id.rawValue == session.terminalID }?.name + var agentGUIAvailability: AgentGUIAvailability? { + guard let engine = store.agentSyncEngine else { return nil } + return AgentGUIAvailability.derive( + sessions: engine.directory.sessions, + selectedTerminalID: selectedTerminalID + ) + } + + var isAgentGUIVisible: Bool { + activeSurface == .terminal && guiModeSelected && agentGUIAvailability != nil + } + + func agentGUIDraftBinding(for sessionID: AgentSessionID) -> Binding { + Binding( + get: { agentGUIDrafts[sessionID] }, + set: { agentGUIDrafts[sessionID] = $0 } + ) } #endif } + +#if os(iOS) +struct AgentGUIDraftState: Equatable { + private var drafts: [AgentSessionID: String] = [:] + + subscript(sessionID: AgentSessionID) -> String { + get { drafts[sessionID, default: ""] } + set { drafts[sessionID] = newValue } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+Surfaces.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+Surfaces.swift index 5f1f7e40dde3..8c4e33d5057d 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+Surfaces.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+Surfaces.swift @@ -1,5 +1,9 @@ +import CMUXMobileCore +import CmuxAgentChatUI +import CmuxAgentGUIUI import CmuxMobileBrowser import CmuxMobileBrowserStream +import CmuxMobileShell import CmuxMobileTerminal import SwiftUI @@ -18,21 +22,34 @@ extension WorkspaceDetailView { shouldAutoFocusTerminal: { store.shouldAutoFocusTerminalSurface($0) }, isComposerPresented: store.isComposerPresented ) - ZStack { + WorkspaceDetailSurfaceStack( + activeSurface: surface, + isAgentGUIVisible: isAgentGUIVisible + ) { detailContent() - .opacity(surface == .terminal ? 1 : 0) - .allowsHitTesting(surface == .terminal) - .accessibilityHidden(surface != .terminal) - if surface == .chat, let session = chosenChatSession { - chatContent(session) - .background(store.activeTerminalTheme.terminalBackgroundColor) - } else if surface == .browser, let browser = activeBrowser { + } overlays: { + if surface == .browser, let browser = activeBrowser { browserContent(browser) .background(store.activeTerminalTheme.terminalBackgroundColor) } else if surface == .browserStream, let browser = activeBrowserStream { browserStreamContent(browser) .background(store.activeTerminalTheme.terminalBackgroundColor) } + if isAgentGUIVisible, + let engine = store.agentSyncEngine, + let availability = agentGUIAvailability { + TranscriptLiveView( + engine: engine, + sessionID: availability.sessionID, + terminalTheme: store.activeTerminalTheme, + terminalThemeGeneration: store.terminalThemeGeneration, + density: displaySettings.transcriptDensity, + draft: agentGUIDraftBinding(for: availability.sessionID), + artifactLoader: agentGUIArtifactLoader(sessionID: availability.sessionID.rawValue), + onShowTerminal: { guiModeSelected = false } + ) + .transition(.opacity) + } } .safeAreaInset(edge: .top, spacing: 0) { if workspaceChangesHint != nil { @@ -62,6 +79,18 @@ extension WorkspaceDetailView { } #if os(iOS) + func agentGUIArtifactLoader(sessionID: String) -> ChatArtifactLoader { + guard store.supportsChatArtifacts, + let source = store.makeChatEventSource() else { + return .unsupported(cache: terminalArtifactThumbnailCache) + } + return ChatArtifactLoader( + source: source, + sessionID: sessionID, + cache: terminalArtifactThumbnailCache + ) + } + @ViewBuilder func browserContent(_ browser: BrowserSurfaceState) -> some View { MobileBrowserPane( @@ -105,3 +134,42 @@ extension WorkspaceDetailView { } #endif } + +#if os(iOS) +/// Keeps the terminal surface at a stable structural position while browser or +/// Agent GUI chrome is presented above it. Hiding changes interaction and +/// accessibility only; the terminal-owned composer and toolbar stay mounted in +/// their original `GhosttySurfaceView` hierarchy. +struct WorkspaceDetailSurfaceStack: View { + let activeSurface: WorkspaceActiveSurface + let isAgentGUIVisible: Bool + private let terminalContent: TerminalContent + private let overlayContent: OverlayContent + + init( + activeSurface: WorkspaceActiveSurface, + isAgentGUIVisible: Bool, + @ViewBuilder terminal: () -> TerminalContent, + @ViewBuilder overlays: () -> OverlayContent + ) { + self.activeSurface = activeSurface + self.isAgentGUIVisible = isAgentGUIVisible + terminalContent = terminal() + overlayContent = overlays() + } + + private var terminalIsPresented: Bool { + activeSurface == .terminal && !isAgentGUIVisible + } + + var body: some View { + ZStack { + terminalContent + .opacity(terminalIsPresented ? 1 : 0) + .allowsHitTesting(terminalIsPresented) + .accessibilityHidden(!terminalIsPresented) + overlayContent + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift index 6760beae3f78..d5e8cb1621ab 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift @@ -46,7 +46,7 @@ extension WorkspaceDetailView { workspaceID: workspace.id.rawValue, surfaceID: terminalID, path: path, - session: chosenChatSession + session: nil ) }, onVisibleArtifactCountChanged: { count in diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+WorkspaceChanges.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+WorkspaceChanges.swift index 225f7b11a01c..687451dee95d 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+WorkspaceChanges.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+WorkspaceChanges.swift @@ -15,9 +15,7 @@ extension WorkspaceDetailView { /// Dirty terminal-title entry point. Chat and browser headers keep their /// existing labels and chrome unchanged. var workspaceTitleChangesChip: MobileWorkspaceChangesChip? { - let showsChatHeader = isChatMode - && chosenChatSession.map { chatConversationStores[$0.id] != nil } == true - guard !showsChatHeader, + guard !isAgentGUIVisible, activeBrowser == nil, workspaceChangesAreAvailable, let chip = workspaceChangesChip, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 1344ed8683b2..f55435f010d2 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -1,6 +1,7 @@ import CMUXMobileCore import CmuxAgentChat import CmuxAgentChatUI +import CmuxAgentGUIUI import CmuxMobileBrowser import CmuxMobileBrowserStream import CmuxMobileDiagnostics @@ -16,7 +17,6 @@ import SwiftUI #elseif os(macOS) import AppKit #endif - struct WorkspaceDetailView: View { static func reconnectAction( connectionRequiresReauth: Bool, @@ -45,7 +45,7 @@ struct WorkspaceDetailView: View { let signOut: (@MainActor @Sendable () -> Void)? @Environment(BrowserSurfaceStore.self) var browserStore @Environment(BrowserStreamStore.self) var browserStreamStore - @Environment(MobileDisplaySettings.self) private var displaySettings + @Environment(MobileDisplaySettings.self) var displaySettings @Environment(ToastCenter.self) private var toasts /// Drives the destructive close-workspace confirmation dialog. @State var isConfirmingClose = false @@ -67,22 +67,8 @@ struct WorkspaceDetailView: View { /// Terminal captured for the current "View as Text" sheet presentation. @State private var textSheetSurfaceID: String? @State var terminalPickerRows: [TerminalPickerMenuRow] = [] - /// Chat-mode toggle for inline agent chat in place of the terminal. - @State var isChatMode = false - /// The session chat mode was entered on, pinned so sorting cannot swap the conversation - /// out from under the user mid-read. Cleared when chat mode turns off. - @State var pinnedChatSessionID: String? - @State var chatSessions: [ChatSessionDescriptor] = [] - @State var chatSessionsWorkspaceID: String? - /// Last terminal id whose cached snapshot said it had a chat session. - @State var cachedChatToggleTerminalID: String? - @State var ignoredChatSessionRefreshKey: String? - @State var ignoredChatSessionRefreshID: UUID? - @State var ignoredChatSessionRefreshTask: Task<[ChatSessionDescriptor]?, Never>? - /// Per-session chat stores kept warm while the workspace detail is visible. - @State var chatConversationStores: [String: ChatConversationStore] = [:] - /// Per-session composer drafts, surviving toggles back to the terminal. - @State var chatDrafts: [String: String] = [:] + @State var guiModeSelected = false + @State var agentGUIDrafts = AgentGUIDraftState() @State var terminalArtifactFilesContext: TerminalArtifactContext? @State var selectedTerminalArtifact: TerminalArtifactSelection? @State var terminalArtifactThumbnailCache = ChatArtifactThumbnailCache() @@ -113,8 +99,6 @@ struct WorkspaceDetailView: View { } var activeSurface: WorkspaceActiveSurface { WorkspaceActiveSurface.derive( - isChatMode: isChatMode, - hasChosenChatSession: chosenChatSession != nil, hasActiveBrowser: activeBrowser != nil, hasActiveBrowserStream: activeBrowserStream != nil ) @@ -122,33 +106,41 @@ struct WorkspaceDetailView: View { #endif var body: some View { let content = Group { detailSurfaceContent } - #if os(iOS) content .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { contentWidth = $0 } .navigationTitle(systemNavigationTitle) .mobileTerminalNavigationChrome(theme: store.activeTerminalTheme) .toolbar { workspaceDetailToolbar } - .task(id: chatRefreshKey) { await refreshChatSessions() } .task(id: workspace.rpcWorkspaceID.rawValue) { await store.refreshMobileBrowserPanels(workspaceID: workspace.rpcWorkspaceID.rawValue) } - .task(id: chatConversationWarmKey) { await runWarmChatConversation() } .onAppear { refreshWorkspaceChangesHint() } .onChange(of: workspaceChangesHintEligibilityKey) { _, _ in refreshWorkspaceChangesHint() } .onChange(of: selectedTerminalID) { _, _ in visibleArtifactCount = 0 - refreshCachedChatToggleAnchor() syncTerminalPickerRows(includeTitleChanges: true) } - .onChange(of: store.supportsTerminalArtifacts) { _, supportsArtifacts in + .onChange(of: store.supportsTerminalArtifacts) { _, _ in visibleArtifactCount = 0 } .onChange(of: store.supportsChatArtifactGallery) { _, _ in visibleArtifactCount = 0 } + .onChange(of: guiModeSelected) { _, isSelected in + if isSelected { + dismissTerminalKeyboardForChrome() + } + } + .onChange(of: agentGUIAvailability) { _, availability in + withAnimation(.easeInOut(duration: 0.2)) { + if availability == nil { + guiModeSelected = false + } + } + } .closeWorkspaceConfirmation( isPresented: $isConfirmingClose, confirm: confirmCloseWorkspaceFromMenu @@ -159,6 +151,20 @@ struct WorkspaceDetailView: View { .sheet(isPresented: $isTextSheetPresented) { TerminalTextSheetView(surfaceID: textSheetSurfaceID) } + .navigationDestination(isPresented: terminalArtifactIsPresented) { + if let selectedTerminalArtifact { + ChatArtifactViewerDestination( + path: selectedTerminalArtifact.path, + scope: selectedTerminalArtifact.usesSessionAuthorization ? .chat : .terminal + ) { + self.selectedTerminalArtifact = nil + } + .environment( + \.chatArtifactLoader, + artifactLoader(for: selectedTerminalArtifact) + ) + } + } .sheet(isPresented: $isWorkspaceChangesSheetPresented) { WorkspaceChangesSheet( store: store, @@ -189,7 +195,6 @@ struct WorkspaceDetailView: View { .mobileConnectionRecoveryOverlay(store: store, signOut: signOut) #endif } - #if os(iOS) @ToolbarContentBuilder private var workspaceDetailToolbar: some ToolbarContent { @@ -218,6 +223,7 @@ struct WorkspaceDetailView: View { WorkspaceChangesToolbarButton( chip: workspaceChangesChip, workspaceID: workspace.rpcWorkspaceID.rawValue, + compact: agentGUIAvailability != nil, action: openWorkspaceChanges ) // The chrome sits on the terminal theme's background, not the @@ -229,13 +235,12 @@ struct WorkspaceDetailView: View { toolbarTrailingCluster } } - private var workspaceTitleToolbarMenu: some View { let value = WorkspaceTitleMenuValue( contentWidth: contentWidth, hasBackButton: backButtonConfiguration != nil, hasTrailingCluster: true, - hasChatToggle: shouldShowChatToggle, + hasChatToggle: agentGUIAvailability != nil, isEnabled: hasTitleMenuActions, workspaceName: workspace.name, hasUnread: workspace.hasUnread, @@ -293,18 +298,26 @@ struct WorkspaceDetailView: View { .equatable() } + @ViewBuilder + private var toolbarTrailingCluster: some View { + HStack(spacing: 8) { + if agentGUIAvailability != nil { + AgentGUIToggleButton( + isSelected: $guiModeSelected, + terminalTheme: store.activeTerminalTheme + ) + .frame(width: 44, height: 44) + .transition(.scale.combined(with: .opacity)) + } + terminalPickerToolbarButton + .frame(width: 44, height: 44) + } + .animation(.easeInOut(duration: 0.2), value: agentGUIAvailability) + .frame(width: agentGUIAvailability == nil ? 44 : 96, height: 44, alignment: .trailing) + } + private var toolbarTitleLabelToken: WorkspaceTitleMenuLabelToken { - if isChatMode, - let session = chosenChatSession, - let conversation = chatConversationStores[session.id] { - return .chat( - descriptor: conversation.descriptor, - agentState: conversation.agentState, - isConnected: conversation.isConnected, - titleOverride: workspace.name, - subtitle: tabName(for: session) - ) - } else if let browser = activeBrowser { + if let browser = activeBrowser { return .browser(title: browser.title ?? workspace.name) } else if let browser = activeBrowserStream { return .browser(title: browser.title ?? workspace.name) @@ -313,14 +326,58 @@ struct WorkspaceDetailView: View { } } #endif - func detailContent() -> some View { // `GhosttySurfaceView` owns the bottom accessory bar and reserves its // height in the terminal grid. Group { #if os(iOS) if let terminalID = selectedTerminal?.id.rawValue { - terminalArtifactSurface(terminalID: terminalID) + let shouldAutoFocus = activeSurface == .terminal + && !isAgentGUIVisible + && store.shouldAutoFocusTerminalSurface(terminalID) + && !store.isComposerPresented + GhosttySurfaceRepresentable( + workspaceID: workspace.id.rawValue, + surfaceID: terminalID, + store: store, + fontSize: MobileTerminalFontPreference.defaultSize, + // Do not let a terminal reattach steal focus while the + // composer owns or intentionally withholds the keyboard. + autoFocusOnWindowAttach: shouldAutoFocus, + isComposerActive: store.isComposerPresented, + terminalTheme: store.activeTerminalTheme, + terminalConfigTheme: store.activeTerminalConfigTheme, + // Drives the live recolor: when the synced theme changes the + // shell bumps this, and the representable rebuilds the runtime + // config + recolors the mounted surface in place (background, + // letterbox, default cell colors) without a remount, so + // scrollback survives a theme change. + configThemeGeneration: store.terminalConfigThemeGeneration + ) + // Identity must track the selected terminal. The representable's + // coordinator binds its byte sink to the surfaceID at make time and + // `updateUIView` is a no-op, so without a per-terminal id SwiftUI + // reuses the first terminal's surface and the dropdown never switches. + // Keying on terminalID tears down the old surface (unregistering its + // sink via dismantleUIView) and builds the newly-selected one. + // + // The theme is NOT folded into the identity: a theme change recolors + // the live surface in place (config rebuild + view recolor driven by + // `configThemeGeneration`), so remounting would only throw away scrollback + // for no visual benefit. + .id(terminalID) + .onAppear { + store.consumeTerminalAutoFocusSuppression(for: terminalID) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(store.activeTerminalTheme.terminalBackgroundColor) + // The surface positions its grid + docked toolbar from + // `keyboardHeight` directly, so opt out of SwiftUI keyboard + // avoidance; otherwise the view ALSO shrinks for the keyboard + // and the reservation double-counts (extra gap when open). + .ignoresSafeArea(.keyboard, edges: .bottom) + // Keep the grid clear of the Dynamic Island and nav bar. + .padding(.top, terminalTopPadding) } else { store.activeTerminalTheme.terminalBackgroundColor .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) @@ -387,20 +444,6 @@ struct WorkspaceDetailView: View { store.activeTerminalTheme.terminalBackgroundColor .ignoresSafeArea(.container, edges: [.horizontal, .top, .bottom]) } - .navigationDestination(isPresented: terminalArtifactIsPresented) { - if let selectedTerminalArtifact { - ChatArtifactViewerDestination( - path: selectedTerminalArtifact.path, - scope: selectedTerminalArtifact.usesSessionAuthorization ? .chat : .terminal - ) { - self.selectedTerminalArtifact = nil - } - .environment( - \.chatArtifactLoader, - artifactLoader(for: selectedTerminalArtifact) - ) - } - } #else .background(store.activeTerminalTheme.terminalBackgroundColor) #endif @@ -476,6 +519,12 @@ struct WorkspaceDetailView: View { } #endif + @ViewBuilder + private var terminalToolbarButtons: some View { + newWorkspaceToolbarButton + terminalPickerToolbarButton + } + #if os(iOS) private var terminalArtifactIsPresented: Binding { Binding( @@ -497,11 +546,7 @@ struct WorkspaceDetailView: View { supportsDirectoryBrowsing: store.supportsTerminalArtifactList, cache: terminalArtifactThumbnailCache, stat: { path in - try await source.terminalArtifactStat( - workspaceID: workspaceID, - surfaceID: surfaceID, - path: path - ) + try await source.terminalArtifactStat(workspaceID: workspaceID, surfaceID: surfaceID, path: path) }, fetch: { path, progress in try await source.terminalArtifactFetch( @@ -554,15 +599,7 @@ struct WorkspaceDetailView: View { cache: terminalArtifactThumbnailCache ) } - #endif - - @ViewBuilder - private var terminalToolbarButtons: some View { - newWorkspaceToolbarButton - terminalPickerToolbarButton - } - #if os(iOS) /// Leading back-button island; iOS 26 supplies toolbar glass. @ViewBuilder private var workspaceBackToolbarButton: some View { @@ -597,7 +634,6 @@ struct WorkspaceDetailView: View { selectedID: store.selectedTerminalID, canCreateWorkspace: canCreateWorkspace, hasActiveBrowser: activeBrowser != nil, - isChatMode: isChatMode, browserStreamRows: browserStreamStore.panels(in: workspace.rpcWorkspaceID.rawValue).map(BrowserStreamPickerRow.init), supportsBrowserStream: store.supportsBrowserStream, activeBrowserStreamPanelID: activeBrowserStream?.id @@ -624,6 +660,80 @@ struct WorkspaceDetailView: View { .onChange(of: terminalPickerLiveMembership) { _, _ in syncTerminalPickerRows() } } + @ViewBuilder + private func terminalPickerMenuContent( + rows: [TerminalPickerMenuRow], + selectedID: MobileTerminalPreview.ID? + ) -> some View { + Section(L10n.string("mobile.terminal.picker.title", defaultValue: "Terminals")) { + ForEach(rows) { terminal in + Button { + selectTerminalFromPicker(terminal.id) + } label: { + Label( + terminal.name, + systemImage: terminal.id == selectedID && activeBrowser == nil + ? "checkmark.circle.fill" + : "terminal" + ) + } + .accessibilityIdentifier("MobileTerminalMenuItem-\(terminal.id.rawValue)") + } + } + + Section { + Button(action: createWorkspaceFromToolbar) { + Label(L10n.string("mobile.workspace.new", defaultValue: "New Workspace"), systemImage: "plus.square.on.square") + } + .disabled(!canCreateWorkspace) + .accessibilityIdentifier("MobileNewWorkspaceMenuItem") + + Button(action: createTerminalFromToolbar) { + Label(L10n.string("mobile.terminal.new", defaultValue: "New Terminal"), systemImage: "plus") + } + .accessibilityIdentifier("MobileNewTerminalMenuItem") + + Button(action: openBrowserFromToolbar) { + Label( + L10n.string("mobile.browser.new", defaultValue: "New Browser"), + systemImage: activeBrowser == nil ? "globe" : "checkmark.circle.fill" + ) + } + .accessibilityIdentifier("MobileNewBrowserMenuItem") + } + + #if canImport(UIKit) + Section { + // Only while the terminal pane is showing: browser mode does not + // mount a terminal surface for text capture. + if activeBrowser == nil { + Button(action: openTextSheetFromMenu) { + Label( + L10n.string("mobile.terminal.viewAsText", defaultValue: "View as Text"), + systemImage: "doc.plaintext" + ) + } + .accessibilityIdentifier("MobileViewAsTextMenuItem") + } + + #if DEBUG + Button(action: copyDebugLogsFromMenu) { + Label(L10n.string("mobile.debug.copyLogs", defaultValue: "Copy Debug Logs"), systemImage: "doc.on.clipboard") + } + .accessibilityIdentifier("MobileCopyDebugLogsMenuItem") + #endif + + Button(action: openFeedbackComposerFromMenu) { + Label( + L10n.string("mobile.feedback.send", defaultValue: "Send Feedback"), + systemImage: "paperplane" + ) + } + .accessibilityIdentifier("MobileSendFeedbackMenuItem") + } + #endif + } + #if canImport(UIKit) #if DEBUG private func copyDebugLogsFromMenu() { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift index 655545535b83..1acfdf302635 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift @@ -46,6 +46,7 @@ extension EnvironmentValues { } } +// lint:allow namespace-type - existing static helper surface, preserving call sites during UI closeout. private enum WorkspaceRootToolbarSizing { static let minimumPickerWidth: CGFloat = 98 static let maximumPickerWidth: CGFloat = 124 diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift index 29ef66f90e3f..3c0ba4b795ce 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift @@ -20,6 +20,27 @@ import Testing #expect(defaults.object(forKey: "cmux.mobile.workspacePreviewLineCount") == nil) } + @Test func transcriptDensityDefaultsToCompactWithoutAWrite() throws { + let defaults = try makeDefaults("transcriptDensityDefaults") + let settings = MobileDisplaySettings(defaults: defaults) + #expect(settings.transcriptDensity == .compact) + #expect(defaults.object(forKey: "cmux.mobile.transcriptDensity") == nil) + } + + @Test func transcriptDensityPersistsAcrossInstances() throws { + let defaults = try makeDefaults("transcriptDensityPersists") + let settings = MobileDisplaySettings(defaults: defaults) + settings.transcriptDensity = .compact + #expect(MobileDisplaySettings(defaults: defaults).transcriptDensity == .compact) + #expect(defaults.string(forKey: "cmux.mobile.transcriptDensity") == "compact") + } + + @Test func invalidStoredTranscriptDensityFallsBackToCompact() throws { + let defaults = try makeDefaults("transcriptDensityInvalid") + defaults.set("dense", forKey: "cmux.mobile.transcriptDensity") + #expect(MobileDisplaySettings(defaults: defaults).transcriptDensity == .compact) + } + @Test func showAltScreenNoticeDefaultsToTrueWithoutAWrite() throws { let defaults = try makeDefaults("altScreenNoticeDefaults") let settings = MobileDisplaySettings(defaults: defaults) diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalPickerMenuValueTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalPickerMenuValueTests.swift index 518085bed429..014a07afb0c9 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalPickerMenuValueTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalPickerMenuValueTests.swift @@ -85,8 +85,7 @@ import Testing snapshotRows: snapshotRows, selectedID: selectedID, canCreateWorkspace: true, - hasActiveBrowser: false, - isChatMode: false + hasActiveBrowser: false ) } } diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalSurfaceMountOwnershipTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalSurfaceMountOwnershipTests.swift index d3afba78dc13..39254ddaa6e8 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalSurfaceMountOwnershipTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalSurfaceMountOwnershipTests.swift @@ -1,5 +1,6 @@ #if canImport(UIKit) import CMUXMobileCore +import CmuxAgentReplica import CmuxMobileTerminal import CmuxMobileShellModel import SwiftUI @@ -10,6 +11,82 @@ import UIKit @Suite("Terminal surface mount ownership", .serialized) struct TerminalSurfaceMountOwnershipTests { + @Test("Agent GUI drafts survive view remounts and remain session scoped") + func agentGUIDraftsAreHostOwnedAndSessionScoped() { + let first = AgentSessionID(rawValue: "first") + let second = AgentSessionID(rawValue: "second") + var state = AgentGUIDraftState() + + state[first] = "unfinished prompt" + let remountedState = state + + #expect(remountedState[first] == "unfinished prompt") + #expect(remountedState[second].isEmpty) + } + + @MainActor + @Test("Agent GUI visibility preserves terminal composer and chrome ownership") + func agentGUIVisibilityPreservesTerminalComposerAndChromeOwnership() async throws { + let store = MobileShellComposite.preview() + let workspace = try #require(store.workspaces.first { !$0.terminals.isEmpty }) + let terminal = try #require(workspace.terminals.first) + let host = UIHostingController(rootView: AgentGUIOwnershipHarness( + store: store, + workspaceID: workspace.id.rawValue, + surfaceID: terminal.id.rawValue, + isAgentGUIVisible: false + )) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = host + window.makeKeyAndVisible() + defer { + window.isHidden = true + } + + host.view.frame = window.bounds + host.view.setNeedsLayout() + host.view.layoutIfNeeded() + await settleViewUpdates() + + let surface = try #require(descendant(of: GhosttySurfaceView.self, in: host.view)) + let ownedSubviews = surface.subviews + #expect(ownedSubviews.count >= 2) + + host.rootView = AgentGUIOwnershipHarness( + store: store, + workspaceID: workspace.id.rawValue, + surfaceID: terminal.id.rawValue, + isAgentGUIVisible: true + ) + host.view.setNeedsLayout() + host.view.layoutIfNeeded() + await settleViewUpdates() + + let hiddenSurface = try #require(descendant(of: GhosttySurfaceView.self, in: host.view)) + #expect(hiddenSurface === surface) + for ownedSubview in ownedSubviews { + #expect(hiddenSurface.subviews.contains { $0 === ownedSubview }) + #expect(ownedSubview.superview === hiddenSurface) + } + + host.rootView = AgentGUIOwnershipHarness( + store: store, + workspaceID: workspace.id.rawValue, + surfaceID: terminal.id.rawValue, + isAgentGUIVisible: false + ) + host.view.setNeedsLayout() + host.view.layoutIfNeeded() + await settleViewUpdates() + + let restoredSurface = try #require(descendant(of: GhosttySurfaceView.self, in: host.view)) + #expect(restoredSurface === surface) + for ownedSubview in ownedSubviews { + #expect(restoredSurface.subviews.contains { $0 === ownedSubview }) + #expect(ownedSubview.superview === restoredSurface) + } + } + @MainActor @Test("off-window terminal does not claim the output stream") func offWindowTerminalDoesNotClaimOutputStream() async throws { @@ -151,5 +228,55 @@ struct TerminalSurfaceMountOwnershipTests { } return predicate() } + + @MainActor + private func settleViewUpdates() async { + for _ in 0..<20 { + await Task.yield() + } + } + + @MainActor + private func descendant(of type: T.Type, in root: UIView) -> T? { + if let match = root as? T { return match } + for child in root.subviews { + if let match = descendant(of: type, in: child) { return match } + } + return nil + } + +} + +@MainActor +private struct AgentGUIOwnershipHarness: View { + let store: CMUXMobileShellStore + let workspaceID: String + let surfaceID: String + let isAgentGUIVisible: Bool + + var body: some View { + WorkspaceDetailSurfaceStack( + activeSurface: .terminal, + isAgentGUIVisible: isAgentGUIVisible + ) { + GhosttySurfaceRepresentable( + workspaceID: workspaceID, + surfaceID: surfaceID, + store: store, + fontSize: MobileTerminalFontPreference.defaultSize, + autoFocusOnWindowAttach: false, + isComposerActive: true, + terminalTheme: store.activeTerminalTheme, + terminalConfigTheme: store.activeTerminalConfigTheme, + configThemeGeneration: store.terminalConfigThemeGeneration + ) + .id(surfaceID) + } overlays: { + if isAgentGUIVisible { + Color.black + .accessibilityIdentifier("AgentGUIOwnershipOverlay") + } + } + } } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceActiveSurfaceTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceActiveSurfaceTests.swift index d023b1774723..ceba494e97a4 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceActiveSurfaceTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceActiveSurfaceTests.swift @@ -2,34 +2,16 @@ import Testing @testable import CmuxMobileShellUI @Suite struct WorkspaceActiveSurfaceTests { - @Test func chatTakesPrecedenceOverBrowserWhenSessionIsChosen() { - #expect(WorkspaceActiveSurface.derive( - isChatMode: true, - hasChosenChatSession: true, - hasActiveBrowser: true - ) == .chat) - } - - @Test func browserTakesPrecedenceWhenChatHasNoChosenSession() { - #expect(WorkspaceActiveSurface.derive( - isChatMode: true, - hasChosenChatSession: false, - hasActiveBrowser: true - ) == .browser) + @Test func browserTakesPrecedenceOverTerminal() { + #expect(WorkspaceActiveSurface.derive(hasActiveBrowser: true) == .browser) } @Test func terminalIsDefaultSurface() { - #expect(WorkspaceActiveSurface.derive( - isChatMode: false, - hasChosenChatSession: false, - hasActiveBrowser: false - ) == .terminal) + #expect(WorkspaceActiveSurface.derive(hasActiveBrowser: false) == .terminal) } @Test func browserStreamActivatesWhenNoLocalBrowserIsOpen() { #expect(WorkspaceActiveSurface.derive( - isChatMode: false, - hasChosenChatSession: false, hasActiveBrowser: false, hasActiveBrowserStream: true ) == .browserStream) diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift index 8b4ca15ff47c..633b4f908b19 100644 --- a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift +++ b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift @@ -79,6 +79,20 @@ public struct UITestConfig { #endif } + /// Whether the standalone transcript-demo preview is enabled. + /// + /// When `CMUX_UITEST_TRANSCRIPT_DEMO=1`, the root view renders the agent + /// GUI transcript demo (replay-fixture driven, no sign-in or Mac pairing) + /// so transcript physics can be exercised and recorded on the simulator. + /// DEBUG-only. + public static var transcriptDemoPreviewEnabled: Bool { + #if DEBUG + return ProcessInfo.processInfo.environment["CMUX_UITEST_TRANSCRIPT_DEMO"] == "1" + #else + return false + #endif + } + /// Whether the standalone workspace-list layout preview is enabled. /// /// When `CMUX_UITEST_WORKSPACE_LIST_PREVIEW=1`, the root view renders a @@ -193,11 +207,6 @@ public struct UITestConfig { } /// Whether the standalone streaming-chat preview is enabled. - /// - /// When `CMUX_UITEST_STREAMING_CHAT_PREVIEW=1`, the root view renders a - /// self-playing agent chat that drives the real conversation store with live - /// `streamingProse` events, so the incremental streaming preview can be - /// recorded on the simulator without sign-in or Mac pairing. DEBUG-only. public static var streamingChatPreviewEnabled: Bool { #if DEBUG return ProcessInfo.processInfo.environment["CMUX_UITEST_STREAMING_CHAT_PREVIEW"] == "1" @@ -207,20 +216,12 @@ public struct UITestConfig { } /// Whether the standalone agent-chat preview is enabled. - /// - /// When `CMUX_UITEST_AGENT_CHAT_PREVIEW=1`, the root view renders the - /// debug chat fixture directly so XCUITest can exercise chat layout without - /// sign-in, pairing, or workspace-shell timing. DEBUG-only. public static var agentChatPreviewEnabled: Bool { UITestEnvironmentConfig(environment: ProcessInfo.processInfo.environment) .agentChatPreviewEnabled } - /// Whether the inline workspace-shaped agent-chat preview is enabled. - /// - /// When `CMUX_UITEST_AGENT_CHAT_INLINE_PREVIEW=1`, the root view renders the - /// debug chat fixture with the same outer navigation/padding shape as the - /// in-place workspace chat pane, without sign-in or Mac pairing. DEBUG-only. + /// Whether the workspace-shaped agent-chat preview is enabled. public static var agentChatInlinePreviewEnabled: Bool { UITestEnvironmentConfig(environment: ProcessInfo.processInfo.environment) .agentChatInlinePreviewEnabled diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestEnvironmentConfig.swift b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestEnvironmentConfig.swift index dc7aa2950a64..5fa2ac2b7c97 100644 --- a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestEnvironmentConfig.swift +++ b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestEnvironmentConfig.swift @@ -18,7 +18,7 @@ public struct UITestEnvironmentConfig: Equatable, Sendable { #endif } - /// Whether the inline workspace-shaped agent-chat preview is enabled. + /// Whether the workspace-shaped agent-chat preview is enabled. public var agentChatInlinePreviewEnabled: Bool { #if DEBUG return environment["CMUX_UITEST_AGENT_CHAT_INLINE_PREVIEW"] == "1" @@ -27,6 +27,15 @@ public struct UITestEnvironmentConfig: Equatable, Sendable { #endif } + /// The requested transcript density when the launch value is supported. + public var transcriptDensity: String? { + let value = environment["CMUX_UITEST_TRANSCRIPT_DENSITY"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard value == "comfortable" || value == "compact" else { return nil } + return value + } + /// Whether the deterministic onboarding preview is enabled. public var onboardingPreviewEnabled: Bool { #if DEBUG diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index 618038032376..92731313be85 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -225,6 +225,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { var appliedInteractionGeneration: UInt64 = 0 var activeRestoreTicket: UInt64? } + // lint:allow lock - same sanctioned cross-queue viewport restore gate described above. nonisolated let viewportRestoreGate = OSAllocatedUnfairLock(initialState: .init()) var userViewportInteractionGeneration: UInt64 { diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/VerifiedReplayFrameCapture.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/VerifiedReplayFrameCapture.swift index 91f4dd357020..40732965f557 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/VerifiedReplayFrameCapture.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/VerifiedReplayFrameCapture.swift @@ -192,6 +192,7 @@ nonisolated struct VerifiedReplayPresentationFence: Sendable { } } +// lint:allow free-function - internal package helper verifiedReplayPresentationGeometry, preserving existing call sites during UI closeout. func verifiedReplayPresentationGeometry( renderer: CALayer?, host: CALayer, @@ -225,6 +226,7 @@ nonisolated func verifiedReplayExportThenSubmit( return frame } +// lint:allow free-function - internal package helper verifiedReplayRendererIdentity, preserving existing call sites during UI closeout. func verifiedReplayRendererIdentity( from contents: Any? ) -> VerifiedReplayRendererSurfaceIdentity? { @@ -240,11 +242,13 @@ func verifiedReplayRendererIdentity( /// Copies the current renderer target into Data-backed immutable pixels. /// The resulting CGImage cannot be changed when Ghostty reuses its three /// IOSurface swap-chain targets. +// lint:allow free-function - internal package helper copyVerifiedReplayCGImage, preserving existing call sites during UI closeout. func copyVerifiedReplayCGImage(from contents: Any?) -> CGImage? { guard let capture = verifiedReplaySurfaceCapture(from: contents) else { return nil } return copyVerifiedReplayCGImage(from: capture) } +// lint:allow free-function - internal package helper copyVerifiedReplayCGImage, preserving existing call sites during UI closeout. func copyVerifiedReplayCGImage(from capture: VerifiedReplaySurfaceCapture) -> CGImage? { let surface = capture.surface let width = IOSurfaceGetWidth(surface) @@ -282,6 +286,7 @@ func copyVerifiedReplayCGImage(from capture: VerifiedReplaySurfaceCapture) -> CG ) } +// lint:allow free-function - internal package helper verifiedReplaySurfaceCapture, preserving existing call sites during UI closeout. func verifiedReplaySurfaceCapture(from contents: Any?) -> VerifiedReplaySurfaceCapture? { guard let contents else { return nil } let value = contents as CFTypeRef @@ -290,6 +295,7 @@ func verifiedReplaySurfaceCapture(from contents: Any?) -> VerifiedReplaySurfaceC return VerifiedReplaySurfaceCapture(surface: surface) } +// lint:allow free-function - file-local helper verifiedReplayTransformScalars, kept as a pure helper to avoid unrelated type migration in this UI closeout. private func verifiedReplayTransformScalars(_ transform: CATransform3D) -> [CGFloat] { [ transform.m11, transform.m12, transform.m13, transform.m14, @@ -299,6 +305,7 @@ private func verifiedReplayTransformScalars(_ transform: CATransform3D) -> [CGFl ] } +// lint:allow free-function - file-local helper verifiedReplaySurfaceExtentMatchesGeometry, kept as a pure helper to avoid unrelated type migration in this UI closeout. private func verifiedReplaySurfaceExtentMatchesGeometry( _ identity: VerifiedReplayRendererSurfaceIdentity, geometry: VerifiedReplayPresentationGeometry diff --git a/Packages/iOS/CmuxMobileToast/Sources/CmuxMobileToast/ToastDemo.swift b/Packages/iOS/CmuxMobileToast/Sources/CmuxMobileToast/ToastDemo.swift index 6ce85bd03ca2..a31ad3d7a997 100644 --- a/Packages/iOS/CmuxMobileToast/Sources/CmuxMobileToast/ToastDemo.swift +++ b/Packages/iOS/CmuxMobileToast/Sources/CmuxMobileToast/ToastDemo.swift @@ -8,6 +8,7 @@ public import Foundation /// script. Wall-clock pacing is deliberate: this is demo choreography, not /// synchronization. @MainActor +// lint:allow namespace-type - existing static helper surface, preserving call sites during UI closeout. public enum ToastDemo { private static var currentTask: Task? diff --git a/Packages/iOS/CmuxMobileToast/Sources/CmuxMobileToast/ToastMotion.swift b/Packages/iOS/CmuxMobileToast/Sources/CmuxMobileToast/ToastMotion.swift index ffb7a48c534d..bfaf1a88b7d6 100644 --- a/Packages/iOS/CmuxMobileToast/Sources/CmuxMobileToast/ToastMotion.swift +++ b/Packages/iOS/CmuxMobileToast/Sources/CmuxMobileToast/ToastMotion.swift @@ -6,6 +6,7 @@ import SwiftUI /// 0.86 and de-blurring, on a spring loose enough for one soft overshoot. /// Departure: a faster, fully damped settle back toward the edge. Reduce /// Motion collapses both to plain cross-fades. +// lint:allow namespace-type - existing static helper surface, preserving call sites during UI closeout. enum ToastMotion { /// The ambient animation that drives presence changes on the overlay. static func driver(reduceMotion: Bool) -> Animation { diff --git a/Packages/macOS/CmuxAgentTruthKit/Package.swift b/Packages/macOS/CmuxAgentTruthKit/Package.swift new file mode 100644 index 000000000000..08fae70c6c3a --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Package.swift @@ -0,0 +1,73 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "CmuxAgentTruthKit", + platforms: [ + .macOS(.v14), + ], + products: [ + .library( + name: "CmuxAgentTruthKit", + targets: ["CmuxAgentTruthKit"] + ), + .executable( + name: "agent-transcript-harvest", + targets: ["agent-transcript-harvest"] + ), + ], + dependencies: [ + .package(path: "../../Shared/CmuxAgentReplica"), + ], + targets: [ + .target( + name: "CmuxAgentTruthKit", + dependencies: ["CmuxAgentReplica"], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .target( + name: "CmuxAgentTranscriptHarvest", + dependencies: ["CmuxAgentReplica", "CmuxAgentTruthKit"], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .executableTarget( + name: "agent-transcript-harvest", + dependencies: ["CmuxAgentTranscriptHarvest"], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .testTarget( + name: "CmuxAgentTruthKitTests", + dependencies: ["CmuxAgentTruthKit"], + resources: [ + .process("Resources"), + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .testTarget( + name: "CmuxAgentTranscriptHarvestTests", + dependencies: ["CmuxAgentTranscriptHarvest"], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + ] +) diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/JSONLLineReader.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/JSONLLineReader.swift new file mode 100644 index 000000000000..24ea8d9faed0 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/JSONLLineReader.swift @@ -0,0 +1,41 @@ +import Foundation + +final class JSONLLineReader { + private let handle: FileHandle + private var buffer: Data + private var reachedEOF: Bool + + init(url: URL) throws { + self.handle = try FileHandle(forReadingFrom: url) + self.buffer = Data() + self.reachedEOF = false + } + + func nextLine() throws -> String? { + while true { + if let newlineIndex = buffer.firstIndex(of: 10) { + let lineData = buffer[.. + private var counts: [TranscriptDecoderUnknownKey: Int] + + init() { + self.markerKeys = [] + self.counts = [:] + } + + mutating func record(source: TranscriptHarvestSource, rawLine: String, diagnostics: TranscriptDecoderDiagnostics) { + for (rawKind, count) in diagnostics.unknownKindCounts { + guard count > 0 else { + continue + } + for mapped in TranscriptDecoderGapMapper.map(source: source, rawKind: rawKind, rawLine: rawLine) { + let sanitizedValue = TranscriptPrivacySanitizer.identifier(mapped.value) + markerKeys.insert(TranscriptDecoderGapKey(source: source, dimension: mapped.dimension, value: sanitizedValue)) + } + let key = TranscriptDecoderUnknownKey(source: source, value: TranscriptPrivacySanitizer.identifier(rawKind)) + counts[key, default: 0] += count + } + } + + func contains(source: TranscriptHarvestSource, dimension: String, value: String) -> Bool { + markerKeys.contains(TranscriptDecoderGapKey(source: source, dimension: dimension, value: value)) + } + + func summaryRows() -> [TranscriptShapeRow] { + counts.map { key, count in + TranscriptShapeRow( + source: key.source, + dimension: "decoder_unknown", + value: key.value, + count: count, + marker: "DECODER-GAP" + ) + } + .sorted { lhs, rhs in + if lhs.source.rawValue != rhs.source.rawValue { + return lhs.source.rawValue < rhs.source.rawValue + } + if lhs.dimension != rhs.dimension { + return lhs.dimension < rhs.dimension + } + return lhs.value < rhs.value + } + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptDecoderGapKey.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptDecoderGapKey.swift new file mode 100644 index 000000000000..fc8e8845d72e --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptDecoderGapKey.swift @@ -0,0 +1,7 @@ +import Foundation + +struct TranscriptDecoderGapKey: Codable, Hashable, Sendable { + var source: TranscriptHarvestSource + var dimension: String + var value: String +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptDecoderGapMapper.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptDecoderGapMapper.swift new file mode 100644 index 000000000000..f63b4ddc2622 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptDecoderGapMapper.swift @@ -0,0 +1,91 @@ +import CmuxAgentTruthKit +import Foundation + +struct TranscriptDecoderGapMapper { + static func map(source: TranscriptHarvestSource, rawKind: String, rawLine: String) -> [TranscriptDecoderGapKey] { + switch source { + case .claude: + return mapClaude(rawKind, rawLine: rawLine) + case .codex: + return mapCodex(rawKind, rawLine: rawLine) + } + } + + private static func mapClaude(_ rawKind: String, rawLine: String) -> [TranscriptDecoderGapKey] { + switch rawKind { + case "sidechain": + [TranscriptDecoderGapKey(source: .claude, dimension: "flag", value: "isSidechain")] + case "meta": + [TranscriptDecoderGapKey(source: .claude, dimension: "flag", value: "isMeta")] + case "malformed", "missing_type": + [TranscriptDecoderGapKey(source: .claude, dimension: "record_type", value: rawKind)] + case "block", "content", "missing_content", "tool_result": + [TranscriptDecoderGapKey(source: .claude, dimension: "block_type", value: rawKind)] + default: + mapClaudeUnknownKind(rawKind, rawLine: rawLine) + } + } + + private static func mapClaudeUnknownKind(_ rawKind: String, rawLine: String) -> [TranscriptDecoderGapKey] { + guard let root = JSONLineDecoder().decode(rawLine)?.object else { + return [ + TranscriptDecoderGapKey(source: .claude, dimension: "record_type", value: rawKind), + TranscriptDecoderGapKey(source: .claude, dimension: "block_type", value: rawKind), + ] + } + if root["type"]?.string == rawKind { + return [TranscriptDecoderGapKey(source: .claude, dimension: "record_type", value: rawKind)] + } + if claudeBlockTypes(in: root).contains(rawKind) { + return [TranscriptDecoderGapKey(source: .claude, dimension: "block_type", value: rawKind)] + } + return [ + TranscriptDecoderGapKey(source: .claude, dimension: "record_type", value: rawKind), + TranscriptDecoderGapKey(source: .claude, dimension: "block_type", value: rawKind), + ] + } + + private static func claudeBlockTypes(in root: [String: JSONValue]) -> Set { + let message = root["message"]?.object + let content = message?["content"] ?? root["content"] + guard let blocks = content?.array else { + return [] + } + return Set(blocks.compactMap { $0.object?["type"]?.string }) + } + + private static func mapCodex(_ rawKind: String, rawLine: String) -> [TranscriptDecoderGapKey] { + if rawKind.hasPrefix("event_msg.") { + let value = String(rawKind.dropFirst("event_msg.".count)) + return [TranscriptDecoderGapKey(source: .codex, dimension: "event_msg_type", value: value)] + } + switch rawKind { + case "malformed", "missing_type": + return [TranscriptDecoderGapKey(source: .codex, dimension: "record_type", value: rawKind)] + case "missing_response_item_type", "function_call_output": + return [TranscriptDecoderGapKey(source: .codex, dimension: "payload_type", value: rawKind)] + default: + return mapCodexUnknownKind(rawKind, rawLine: rawLine) + } + } + + private static func mapCodexUnknownKind(_ rawKind: String, rawLine: String) -> [TranscriptDecoderGapKey] { + guard let root = JSONLineDecoder().decode(rawLine)?.object else { + return [ + TranscriptDecoderGapKey(source: .codex, dimension: "record_type", value: rawKind), + TranscriptDecoderGapKey(source: .codex, dimension: "payload_type", value: rawKind), + ] + } + if root["type"]?.string == rawKind { + return [TranscriptDecoderGapKey(source: .codex, dimension: "record_type", value: rawKind)] + } + let payload = root["payload"]?.object ?? root + if payload["type"]?.string == rawKind { + return [TranscriptDecoderGapKey(source: .codex, dimension: "payload_type", value: rawKind)] + } + return [ + TranscriptDecoderGapKey(source: .codex, dimension: "record_type", value: rawKind), + TranscriptDecoderGapKey(source: .codex, dimension: "payload_type", value: rawKind), + ] + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptDecoderUnknownKey.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptDecoderUnknownKey.swift new file mode 100644 index 000000000000..3caa93b58a4e --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptDecoderUnknownKey.swift @@ -0,0 +1,6 @@ +import Foundation + +struct TranscriptDecoderUnknownKey: Codable, Hashable, Sendable { + var source: TranscriptHarvestSource + var value: String +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptHarvestFormatter.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptHarvestFormatter.swift new file mode 100644 index 000000000000..3e6b16fd6d83 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptHarvestFormatter.swift @@ -0,0 +1,27 @@ +import Foundation + +package struct TranscriptHarvestFormatter { + package init() { + } + + package func tsv(_ result: TranscriptHarvestResult) -> String { + var lines = ["source\tdimension\tvalue\tcount\tmarker"] + lines.append(contentsOf: result.outputRows().map { row in + [ + row.source.rawValue, + row.dimension, + row.value, + String(row.count), + row.marker ?? "", + ].joined(separator: "\t") + }) + return lines.joined(separator: "\n") + } + + package func json(_ result: TranscriptHarvestResult) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(result.outputRows()) + return String(data: data, encoding: .utf8) ?? "[]" + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptHarvestResult.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptHarvestResult.swift new file mode 100644 index 000000000000..8bbbcc7d1d55 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptHarvestResult.swift @@ -0,0 +1,29 @@ +import Foundation + +package struct TranscriptHarvestResult: Codable, Equatable, Sendable { + package var rows: [TranscriptShapeRow] + package var summaries: [TranscriptHarvestSource: TranscriptHarvestSourceSummary] + package var decoderGapRows: [TranscriptShapeRow] + + package init( + rows: [TranscriptShapeRow], + summaries: [TranscriptHarvestSource: TranscriptHarvestSourceSummary], + decoderGapRows: [TranscriptShapeRow] + ) { + self.rows = rows + self.summaries = summaries + self.decoderGapRows = decoderGapRows + } + + package func outputRows() -> [TranscriptShapeRow] { + var output = rows + for source in TranscriptHarvestSource.allCases { + let summary = summaries[source] ?? TranscriptHarvestSourceSummary() + output.append(TranscriptShapeRow(source: source, dimension: "summary", value: "files_scanned", count: summary.filesScanned)) + output.append(TranscriptShapeRow(source: source, dimension: "summary", value: "lines_scanned", count: summary.linesScanned)) + output.append(TranscriptShapeRow(source: source, dimension: "summary", value: "unreadable_files", count: summary.unreadableFiles)) + } + output.append(contentsOf: decoderGapRows) + return output + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptHarvestScanner.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptHarvestScanner.swift new file mode 100644 index 000000000000..9ae369a91c85 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptHarvestScanner.swift @@ -0,0 +1,121 @@ +import CmuxAgentReplica +import CmuxAgentTruthKit +package import Foundation + +package struct TranscriptHarvestScanner { + private let fileManager: FileManager + + package init(fileManager: FileManager = .default) { + self.fileManager = fileManager + } + + package func scan( + claudeRoot: URL, + codexRoot: URL, + maxFiles: Int?, + modifiedSince: Date? + ) -> TranscriptHarvestResult { + var inventory = TranscriptShapeInventory() + var gaps = TranscriptDecoderGapInventory() + var summaries: [TranscriptHarvestSource: TranscriptHarvestSourceSummary] = [ + .claude: TranscriptHarvestSourceSummary(), + .codex: TranscriptHarvestSourceSummary(), + ] + for source in TranscriptHarvestSource.allCases { + let root = source == .claude ? claudeRoot : codexRoot + let files = jsonlFiles(under: root, limit: maxFiles, modifiedSince: modifiedSince) + for file in files { + scanFile(file, source: source, inventory: &inventory, gaps: &gaps, summary: &summaries[source, default: TranscriptHarvestSourceSummary()]) + } + } + return TranscriptHarvestResult( + rows: inventory.rows(gaps: gaps), + summaries: summaries, + decoderGapRows: gaps.summaryRows() + ) + } + + private func jsonlFiles(under root: URL, limit: Int?, modifiedSince: Date?) -> [URL] { + guard let enumerator = fileManager.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { + return [] + } + var files: [URL] = [] + for case let url as URL in enumerator { + guard limit.map({ files.count < $0 }) ?? true else { + break + } + guard url.pathExtension == "jsonl" else { + continue + } + guard isRecentEnough(url: url, modifiedSince: modifiedSince) else { + continue + } + files.append(url) + } + return files.sorted { $0.path < $1.path } + } + + private func isRecentEnough(url: URL, modifiedSince: Date?) -> Bool { + guard let modifiedSince else { + return true + } + let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]) + guard let modificationDate = values?.contentModificationDate else { + return true + } + return modificationDate >= modifiedSince + } + + private func scanFile( + _ url: URL, + source: TranscriptHarvestSource, + inventory: inout TranscriptShapeInventory, + gaps: inout TranscriptDecoderGapInventory, + summary: inout TranscriptHarvestSourceSummary + ) { + summary.filesScanned += 1 + var lineIndex = 0 + var claudeDecoder = ClaudeTranscriptDecoder() + var codexDecoder = CodexTranscriptDecoder() + do { + let reader = try JSONLLineReader(url: url) + defer { + reader.close() + } + while let line = try reader.nextLine() { + inventory.feed(source: source, rawLine: line) + let diagnostics = decoderDiagnostics( + source: source, + line: line, + lineIndex: lineIndex, + claudeDecoder: &claudeDecoder, + codexDecoder: &codexDecoder + ) + gaps.record(source: source, rawLine: line, diagnostics: diagnostics) + summary.linesScanned += 1 + lineIndex += 1 + } + } catch { + summary.unreadableFiles += 1 + } + } + + private func decoderDiagnostics( + source: TranscriptHarvestSource, + line: String, + lineIndex: Int, + claudeDecoder: inout ClaudeTranscriptDecoder, + codexDecoder: inout CodexTranscriptDecoder + ) -> TranscriptDecoderDiagnostics { + switch source { + case .claude: + claudeDecoder.feed([line], startingAt: lineIndex, journalID: JournalID(rawValue: "harvest")).diagnostics + case .codex: + codexDecoder.feed([line], startingAt: lineIndex, journalID: JournalID(rawValue: "harvest")).diagnostics + } + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptHarvestSource.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptHarvestSource.swift new file mode 100644 index 000000000000..34d6586fece1 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptHarvestSource.swift @@ -0,0 +1,6 @@ +import Foundation + +package enum TranscriptHarvestSource: String, Codable, CaseIterable, Hashable, Sendable { + case claude + case codex +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptHarvestSourceSummary.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptHarvestSourceSummary.swift new file mode 100644 index 000000000000..802d11642c2e --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptHarvestSourceSummary.swift @@ -0,0 +1,13 @@ +import Foundation + +package struct TranscriptHarvestSourceSummary: Codable, Equatable, Sendable { + package var filesScanned: Int + package var linesScanned: Int + package var unreadableFiles: Int + + package init(filesScanned: Int = 0, linesScanned: Int = 0, unreadableFiles: Int = 0) { + self.filesScanned = filesScanned + self.linesScanned = linesScanned + self.unreadableFiles = unreadableFiles + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptKnownKeys.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptKnownKeys.swift new file mode 100644 index 000000000000..f3d3d3c8ed9a --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptKnownKeys.swift @@ -0,0 +1,28 @@ +import Foundation + +struct TranscriptKnownKeys { + static let claudeTopLevel: Set = [ + "cwd", + "isMeta", + "isSidechain", + "leafUuid", + "message", + "parentUuid", + "requestId", + "sessionId", + "summary", + "timestamp", + "toolUseID", + "toolUseResult", + "type", + "userType", + "uuid", + "version", + ] + + static let codexFunctionPayloadTypes: Set = [ + "custom_tool_call", + "function_call", + "web_search_call", + ] +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptPrivacySanitizer.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptPrivacySanitizer.swift new file mode 100644 index 000000000000..19212311183d --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptPrivacySanitizer.swift @@ -0,0 +1,17 @@ +import Foundation + +struct TranscriptPrivacySanitizer { + static func identifier(_ value: String) -> String { + guard !value.isEmpty, value.count <= 120 else { + return "non_identifier" + } + for scalar in value.unicodeScalars { + guard allowedScalars.contains(scalar) else { + return "non_identifier" + } + } + return value + } + + private static let allowedScalars = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.:-") +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptShapeInventory.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptShapeInventory.swift new file mode 100644 index 000000000000..c51a6ac0ca3b --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptShapeInventory.swift @@ -0,0 +1,106 @@ +import CmuxAgentTruthKit +import Foundation + +struct TranscriptShapeInventory { + private let lineDecoder: JSONLineDecoder + private var counts: [TranscriptShapeKey: Int] + + init() { + self.lineDecoder = JSONLineDecoder() + self.counts = [:] + } + + mutating func feed(source: TranscriptHarvestSource, rawLine: String) { + guard let root = lineDecoder.decode(rawLine)?.object else { + increment(source: source, dimension: "record_type", value: "malformed") + return + } + switch source { + case .claude: + feedClaude(root) + case .codex: + feedCodex(root) + } + } + + func rows(gaps: TranscriptDecoderGapInventory = TranscriptDecoderGapInventory()) -> [TranscriptShapeRow] { + counts.map { key, count in + TranscriptShapeRow( + source: key.source, + dimension: key.dimension, + value: key.value, + count: count, + marker: gaps.contains(source: key.source, dimension: key.dimension, value: key.value) ? "DECODER-GAP" : nil + ) + } + .sorted { lhs, rhs in + if lhs.source.rawValue != rhs.source.rawValue { + return lhs.source.rawValue < rhs.source.rawValue + } + if lhs.dimension != rhs.dimension { + return lhs.dimension < rhs.dimension + } + return lhs.value < rhs.value + } + } + + private mutating func feedClaude(_ root: [String: JSONValue]) { + if let recordType = root["type"]?.string { + increment(source: .claude, dimension: "record_type", value: recordType) + } else { + increment(source: .claude, dimension: "record_type", value: "missing_type") + } + if root["isSidechain"]?.bool == true { + increment(source: .claude, dimension: "flag", value: "isSidechain") + } + if root["isMeta"]?.bool == true { + increment(source: .claude, dimension: "flag", value: "isMeta") + } + for key in root.keys where !TranscriptKnownKeys.claudeTopLevel.contains(key) { + increment(source: .claude, dimension: "unfamiliar_top_level_key", value: key) + } + let message = root["message"]?.object + let content = message?["content"] ?? root["content"] + guard let blocks = content?.array else { + return + } + for block in blocks { + guard let object = block.object else { + increment(source: .claude, dimension: "block_type", value: "non_object") + continue + } + let blockType = object["type"]?.string ?? "missing_type" + increment(source: .claude, dimension: "block_type", value: blockType) + if blockType == "tool_use", let toolName = object["name"]?.string { + increment(source: .claude, dimension: "tool_name", value: toolName) + } + } + } + + private mutating func feedCodex(_ root: [String: JSONValue]) { + let recordType = root["type"]?.string ?? "missing_type" + increment(source: .codex, dimension: "record_type", value: recordType) + let payload = root["payload"]?.object ?? root + switch recordType { + case "session_meta": + if let version = payload["cli_version"]?.string { + increment(source: .codex, dimension: "cli_version", value: version) + } + case "response_item": + let payloadType = payload["type"]?.string ?? "missing_type" + increment(source: .codex, dimension: "payload_type", value: payloadType) + if TranscriptKnownKeys.codexFunctionPayloadTypes.contains(payloadType), let name = payload["name"]?.string { + increment(source: .codex, dimension: "function_call_name", value: name) + } + case "event_msg": + increment(source: .codex, dimension: "event_msg_type", value: payload["type"]?.string ?? "missing_type") + default: + return + } + } + + private mutating func increment(source: TranscriptHarvestSource, dimension: String, value: String) { + let key = TranscriptShapeKey(source: source, dimension: dimension, value: TranscriptPrivacySanitizer.identifier(value)) + counts[key, default: 0] += 1 + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptShapeKey.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptShapeKey.swift new file mode 100644 index 000000000000..e702ab2f469b --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptShapeKey.swift @@ -0,0 +1,7 @@ +import Foundation + +struct TranscriptShapeKey: Hashable, Sendable { + var source: TranscriptHarvestSource + var dimension: String + var value: String +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptShapeRow.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptShapeRow.swift new file mode 100644 index 000000000000..12fd2a5eff54 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTranscriptHarvest/TranscriptShapeRow.swift @@ -0,0 +1,27 @@ +import Foundation + +package struct TranscriptShapeRow: Codable, Equatable, Sendable { + package var source: TranscriptHarvestSource + package var dimension: String + package var value: String + package var count: Int + /// `DECODER-GAP` when decoders counted at least one occurrence as unknown. + /// + /// The marker is row-level annotation only; exact unknown counts are emitted + /// in the separate `decoder_unknown` rows. + package var marker: String? + + package init( + source: TranscriptHarvestSource, + dimension: String, + value: String, + count: Int, + marker: String? = nil + ) { + self.source = source + self.dimension = dimension + self.value = value + self.count = count + self.marker = marker + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/ClaudeDecodedBlock.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/ClaudeDecodedBlock.swift new file mode 100644 index 000000000000..e19486a4e914 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/ClaudeDecodedBlock.swift @@ -0,0 +1,22 @@ +import CmuxAgentReplica +import Foundation + +struct ClaudeDecodedBlock: Sendable { + let summary: String + let payload: EntryPayload + let embeddedImage: TranscriptEmbeddedImageSource? + + var kind: EntryKind { + payload.kind + } + + init( + summary: String, + payload: EntryPayload, + embeddedImage: TranscriptEmbeddedImageSource? = nil + ) { + self.summary = summary + self.payload = payload + self.embeddedImage = embeddedImage + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/ClaudeTranscriptDecoder.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/ClaudeTranscriptDecoder.swift new file mode 100644 index 000000000000..c867e1f8f560 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/ClaudeTranscriptDecoder.swift @@ -0,0 +1,681 @@ +public import CmuxAgentReplica +import Foundation + +/// Decodes Claude Code project JSONL transcripts into fail-open entries. +/// +/// Claude records can contain multiple content blocks on one JSONL line. This +/// decoder preserves every user-visible block as an independently pairable +/// entry in source order. +/// A `tool_result` without a numeric exit code synthesizes +/// ``ToolRunPayload/exitCode`` from `is_error`: `false` maps to `0` and `true` +/// maps to `1`, including for non-shell tools. +public struct ClaudeTranscriptDecoder: TranscriptDecoder, Sendable { + private let lineDecoder: JSONLineDecoder + private let textBudget: TranscriptTextBudget + private let previewAttachments: TranscriptToolPreviewAttachmentExtractor + private var pendingTools: [String: PendingToolUse] + + /// Creates a Claude transcript decoder. + public init() { + self.lineDecoder = JSONLineDecoder() + self.textBudget = TranscriptTextBudget() + self.previewAttachments = TranscriptToolPreviewAttachmentExtractor() + self.pendingTools = [:] + } + + /// Feeds Claude transcript lines at their absolute line index. + /// - Parameters: + /// - lines: The raw JSONL lines. + /// - startingAt: The absolute line index for the first line. + /// - journalID: The journal id that owns emitted entries. + /// - Returns: The decoded entries and diagnostics emitted by this feed. + public mutating func feed(_ lines: [String], startingAt: Int, journalID: JournalID) -> TranscriptDecodeBatch { + var accumulator = TranscriptDecodeAccumulator() + for (offset, line) in lines.enumerated() { + decodeLine(line, lineIndex: startingAt + offset, journalID: journalID, accumulator: &accumulator) + } + return accumulator.batch() + } + + private mutating func decodeLine( + _ line: String, + lineIndex: Int, + journalID: JournalID, + accumulator: inout TranscriptDecodeAccumulator + ) { + accumulator.beginSourceLine(timestampMilliseconds: nil) + guard let root = lineDecoder.decode(line)?.object else { + accumulator.countUnknown("malformed") + accumulator.emit(payload: unknownPayload(rawKind: "malformed", summary: "Malformed transcript line", raw: line), journalID: journalID, lineIndex: lineIndex) + return + } + accumulator.beginSourceLine(timestampMilliseconds: TranscriptTimestampParser.milliseconds(root["timestamp"])) + if root["isApiErrorMessage"]?.bool == true { + accumulator.recordAPIError() + } + guard let recordType = root["type"]?.string else { + accumulator.countUnknown("missing_type") + accumulator.emit(payload: unknownPayload(rawKind: "missing_type", summary: "Missing Claude record type", raw: line), journalID: journalID, lineIndex: lineIndex) + return + } + if recordType == "attachment" { + accumulator.countBookkeeping(recordType) + let attachment = root["attachment"]?.object ?? root + accumulator.emit( + payload: .attachment(attachmentPayload(in: attachment)), + journalID: journalID, + lineIndex: lineIndex + ) + return + } + if decodeBookkeepingRecord(root, recordType: recordType, lineIndex: lineIndex, accumulator: &accumulator) { + return + } + if root["isSidechain"]?.bool == true || root["isMeta"]?.bool == true { + accumulator.countModeled(root["isMeta"]?.bool == true ? "meta" : "sidechain") + return + } + switch recordType { + case "user", "assistant": + decodeMessageRecord(root, roleHint: recordType, raw: line, lineIndex: lineIndex, journalID: journalID, accumulator: &accumulator) + case "system": + decodeSystemRecord(root, raw: line, lineIndex: lineIndex, journalID: journalID, accumulator: &accumulator) + case "summary": + accumulator.countUnknown(recordType) + default: + accumulator.countUnknown(recordType) + accumulator.emit(payload: unknownPayload(rawKind: recordType, summary: "Unknown Claude record: \(recordType)", raw: line), journalID: journalID, lineIndex: lineIndex) + } + } + + private func decodeBookkeepingRecord( + _ root: [String: JSONValue], + recordType: String, + lineIndex: Int, + accumulator: inout TranscriptDecodeAccumulator + ) -> Bool { + guard bookkeepingRecordTypes.contains(recordType) else { + return false + } + accumulator.countBookkeeping(recordType) + if let title = sensitiveTitleValue(in: root, recordType: recordType) { + accumulator.recordSensitiveSessionTitle(SensitiveSessionTitleFact(line: lineIndex, source: recordType, sensitiveValue: title)) + } + return true + } + + private func decodeSystemRecord( + _ root: [String: JSONValue], + raw: String, + lineIndex: Int, + journalID: JournalID, + accumulator: inout TranscriptDecodeAccumulator + ) { + let subtype = root["subtype"]?.string ?? "system" + accumulator.countModeled("system.\(subtype)") + if subtype == "api_error" || root["isApiErrorMessage"]?.bool == true { + accumulator.recordAPIError() + } + // System records with user-visible failure/progress content emit status + // rows. Pure telemetry subtypes are modeled diagnostics only. + if systemTelemetrySubtypes.contains(subtype) { + return + } + let code: StatusCode = subtype == "api_error" ? .apiError : .other(subtype) + accumulator.emit(payload: .status(StatusPayload(code: code, detail: "System \(subtype)")), journalID: journalID, lineIndex: lineIndex) + } + + private mutating func decodeMessageRecord( + _ root: [String: JSONValue], + roleHint: String, + raw: String, + lineIndex: Int, + journalID: JournalID, + accumulator: inout TranscriptDecodeAccumulator + ) { + let message = root["message"]?.object + let role = message?["role"]?.string ?? roleHint + guard let content = message?["content"] ?? root["content"] else { + accumulator.countUnknown("missing_content") + accumulator.emit(payload: unknownPayload(rawKind: "missing_content", summary: "Missing Claude content", raw: raw), journalID: journalID, lineIndex: lineIndex) + return + } + if let text = content.string { + let payloads = decodedTextPayloads(text, role: role) + guard !payloads.isEmpty else { return } + if payloads.count == 1 { + accumulator.emit(payload: payloads[0], journalID: journalID, lineIndex: lineIndex) + } else { + accumulator.emit(payloads: payloads, journalID: journalID, lineIndex: lineIndex) + } + return + } + guard let blocks = content.array else { + accumulator.countUnknown("content") + accumulator.emit(payload: unknownPayload(rawKind: "content", summary: "Unknown Claude content shape", raw: raw), journalID: journalID, lineIndex: lineIndex) + return + } + var decodedBlocks: [ClaudeDecodedBlock] = [] + for block in blocks { + decodedBlocks.append(contentsOf: decodeBlock(block, role: role, raw: raw, accumulator: &accumulator)) + } + guard !decodedBlocks.isEmpty else { + return + } + var embeddedImagesByOrdinal: [Int: TranscriptEmbeddedImageSource] = [:] + for (ordinal, block) in decodedBlocks.enumerated() { + if let embeddedImage = block.embeddedImage { + embeddedImagesByOrdinal[ordinal] = embeddedImage + } + } + accumulator.emit( + payloads: decodedBlocks.map(\.payload), + embeddedImagesByOrdinal: embeddedImagesByOrdinal, + journalID: journalID, + lineIndex: lineIndex + ) + } + + private mutating func decodeBlock( + _ block: JSONValue, + role: String, + raw: String, + accumulator: inout TranscriptDecodeAccumulator + ) -> [ClaudeDecodedBlock] { + guard let object = block.object, let type = object["type"]?.string else { + accumulator.countUnknown("block") + return [ClaudeDecodedBlock(summary: "Unknown Claude block", payload: .unknown(UnknownPayload(rawKind: "block", summary: "Unknown Claude block", rawJSON: raw)))] + } + switch type { + case "text": + let text = object["text"]?.string ?? "" + return decodedTextPayloads(text, role: role).map { + ClaudeDecodedBlock(summary: summary(for: $0), payload: $0) + } + case "image": + return [decodeImageBlock(object, role: role)] + case "thinking": + let text = object["thinking"]?.string ?? object["text"]?.string ?? "" + let bounded = textBudget.body(text) + return [ClaudeDecodedBlock(summary: bounded, payload: .thought(ThoughtPayload(text: bounded)))] + case "tool_use": + return [decodeToolUse(object, raw: raw)] + case "tool_result": + return decodeToolResult(object, accumulator: &accumulator) + default: + accumulator.countUnknown(type) + return [ClaudeDecodedBlock(summary: "Unknown Claude block: \(type)", payload: .unknown(UnknownPayload(rawKind: type, summary: "Unknown Claude block: \(type)", rawJSON: raw)))] + } + } + + private func decodeImageBlock(_ object: [String: JSONValue], role: String = "agent") -> ClaudeDecodedBlock { + let source = object["source"]?.object + let hostPath = source?["path"]?.string ?? object["path"]?.string + let mimeType = source?["media_type"]?.string + ?? object["media_type"]?.string + ?? object["mime_type"]?.string + ?? hostPath.flatMap(imageMIMEType) + let base64EncodedData = source?["data"]?.string + ?? object["data"]?.string + let metadata = TranscriptImageMetadataProbe.metadata( + hostPath: hostPath, + base64EncodedData: base64EncodedData + ) + let attachment = AttachmentPayload( + kind: "image", + summary: "Image attachment", + attachmentID: object["id"]?.string, + displayName: object["file_name"]?.string ?? object["fileName"]?.string ?? object["name"]?.string, + hostPath: hostPath, + mimeType: mimeType, + byteCount: metadata.byteCount ?? base64EncodedData.map(estimatedDecodedByteCount), + width: object["width"]?.int ?? metadata.width, + height: object["height"]?.int ?? metadata.height, + aspectRatio: aspectRatio(in: object), + authorRole: normalizedAttachmentRole(role) + ) + return ClaudeDecodedBlock( + summary: "Image attachment", + payload: .attachment(attachment), + embeddedImage: base64EncodedData.map { + TranscriptEmbeddedImageSource( + mimeType: mimeType, + base64EncodedData: $0 + ) + } + ) + } + + private func decodedTextPayloads(_ text: String, role: String) -> [EntryPayload] { + guard normalizedAttachmentRole(role) != "user" else { + return [.userMessage(UserMessagePayload(text: textBudget.body(text), attachmentCount: 0, hasImage: false))] + } + let segments = TranscriptMarkdownImageReferenceExtractor.segments(in: text) + guard segments.contains(where: { + if case .image = $0 { return true } + return false + }) else { + return [.agentProse(AgentProsePayload(markdown: textBudget.body(text)))] + } + + return segments.compactMap { segment in + switch segment { + case .text(let markdown): + let bounded = textBudget.body(markdown) + return bounded.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil + : .agentProse(AgentProsePayload(markdown: bounded)) + case .image(let reference): + return .attachment(markdownImageAttachment(reference: reference, role: role)) + } + } + } + + private func markdownImageAttachment( + reference: TranscriptMarkdownImageReference, + role: String + ) -> AttachmentPayload { + let mimeType = imageMIMEType(for: reference.hostPath) + let metadata = TranscriptImageMetadataProbe.metadata(hostPath: reference.hostPath, base64EncodedData: nil) + let displayName = reference.altText ?? imageDisplayName(for: reference.hostPath) + return AttachmentPayload( + kind: "image", + summary: displayName ?? "Image attachment", + displayName: displayName, + hostPath: reference.hostPath, + mimeType: mimeType, + byteCount: metadata.byteCount, + width: reference.pixelWidth ?? metadata.width, + height: reference.pixelHeight ?? metadata.height, + aspectRatio: reference.aspectRatio, + authorRole: normalizedAttachmentRole(role) + ) + } + + private mutating func decodeToolUse( + _ object: [String: JSONValue], + raw: String + ) -> ClaudeDecodedBlock { + let toolName = object["name"]?.string ?? "unknown" + let input = object["input"] + let id = object["id"]?.string + let payload = payloadForToolUse(name: toolName, input: input, toolCallID: id) + if let id { + pendingTools[id] = PendingToolUse(payload: payload, raw: raw) + } + return ClaudeDecodedBlock(summary: summary(for: payload), payload: payload) + } + + private mutating func decodeToolResult( + _ object: [String: JSONValue], + accumulator: inout TranscriptDecodeAccumulator + ) -> [ClaudeDecodedBlock] { + let id = object["tool_use_id"]?.string + let fragments = textBudget.body(object["content"]?.textFragments().joined(separator: "\n") ?? "") + let attachments = toolResultAttachmentBlocks(in: object["content"]) + let exitCode = exitCode(in: object) + let duration = durationSeconds(in: object) + let reportedStatus = object["status"]?.string + ?? (exitCode.map { $0 == 0 ? "succeeded" : "failed" } ?? "completed") + guard let id, let pending = pendingTools.removeValue(forKey: id) else { + accumulator.countUnknown("tool_result") + let payload = EntryPayload.toolRun(ToolRunPayload( + toolName: "Unknown tool", + argumentSummary: "Missing invocation metadata", + resultSummary: fragments, + isTerminal: false, + exitCode: exitCode, + isRunning: false, + toolCallID: id, + output: fragments, + durationSeconds: duration, + status: "unpaired_result:\(reportedStatus)" + )) + return [ClaudeDecodedBlock(summary: summary(for: payload), payload: payload)] + attachments + } + let payload = payloadByAddingResult( + pending.payload, + resultSummary: fragments, + exitCode: exitCode, + durationSeconds: duration, + status: reportedStatus + ) + return [ClaudeDecodedBlock(summary: summary(for: payload), payload: payload)] + attachments + } + + private func toolResultAttachmentBlocks(in content: JSONValue?) -> [ClaudeDecodedBlock] { + guard let values = content?.array else { return [] } + return values.compactMap { value in + guard let object = value.object, + let type = object["type"]?.string else { + return nil + } + switch type { + case "image": + return decodeImageBlock(object, role: "agent") + case "attachment": + let attachmentObject = object["attachment"]?.object ?? object + return ClaudeDecodedBlock( + summary: "Attachment", + payload: .attachment(attachmentPayload(in: attachmentObject, authorRole: "agent")) + ) + default: + return nil + } + } + } + + private func payloadForToolUse(name: String, input: JSONValue?, toolCallID: String?) -> EntryPayload { + let detail = textBudget.inputDetail(rendered(input)) + return switch name { + case "Write": + .fileChange(FileChangePayload( + path: textBudget.inputDetail(filePath(in: input) ?? ""), + changeKind: .write, + toolCallID: toolCallID + )) + case "Edit", "MultiEdit": + .fileChange(FileChangePayload( + path: textBudget.inputDetail(filePath(in: input) ?? ""), + changeKind: .edit, + toolCallID: toolCallID, + unifiedDiff: editDetail(in: input) + )) + case "NotebookEdit": + .fileChange(FileChangePayload( + path: textBudget.inputDetail(filePath(in: input) ?? ""), + changeKind: .notebook, + toolCallID: toolCallID, + unifiedDiff: textBudget.body(detail) + )) + case "AskUserQuestion": + .question(QuestionPayload(prompt: questionPrompt(in: input), options: questionOptions(in: input))) + case "Bash": + .toolRun(ToolRunPayload( + toolName: name, + argumentSummary: textBudget.summaryArgument(commandSummary(in: input)), + isTerminal: true, + isRunning: true, + toolCallID: toolCallID, + inputDetail: detail, + command: textBudget.inputDetail(commandSummary(in: input)), + status: "running", + previewAttachments: previewAttachments.previewAttachments( + toolName: name, + input: input, + authorRole: "agent" + ) + )) + default: + .toolRun(ToolRunPayload( + toolName: name, + argumentSummary: textBudget.summaryArgument(commandSummary(in: input)), + isTerminal: false, + isRunning: true, + toolCallID: toolCallID, + inputDetail: detail, + status: "running", + previewAttachments: previewAttachments.previewAttachments( + toolName: name, + input: input, + authorRole: "agent" + ) + )) + } + } + + private var bookkeepingRecordTypes: Set { + [ + "ai-title", + "custom-title", + "agent-name", + "last-prompt", + "mode", + "permission-mode", + "pr-link", + "queue-operation", + "bridge-session", + "file-history-snapshot", + ] + } + + private var systemTelemetrySubtypes: Set { + [ + "turn_duration", + ] + } + + private func sensitiveTitleValue(in root: [String: JSONValue], recordType: String) -> String? { + switch recordType { + case "ai-title": + root["aiTitle"]?.string + case "custom-title": + root["customTitle"]?.string + case "agent-name": + root["agentName"]?.string + default: + nil + } + } + + private func unknownPayload(rawKind: String, summary: String, raw: String) -> EntryPayload { + .unknown(UnknownPayload(rawKind: rawKind, summary: summary, rawJSON: raw)) + } + + private func commandSummary(in input: JSONValue?) -> String { + guard let input else { + return "" + } + if let command = input.object?["command"]?.string { + return command + } + if let command = input.object?["command"]?.array?.compactMap(\.string).joined(separator: " ") { + return command + } + return input.textFragments().joined(separator: " ") + } + + private func filePath(in input: JSONValue?) -> String? { + input?.object?["file_path"]?.string + ?? input?.object?["path"]?.string + ?? input?.object?["notebook_path"]?.string + } + + private func questionPrompt(in input: JSONValue?) -> String { + input?.object?["question"]?.string + ?? input?.object?["prompt"]?.string + ?? commandSummary(in: input) + } + + private func questionOptions(in input: JSONValue?) -> [String] { + input?.object?["options"]?.array?.compactMap(\.string) ?? [] + } + + private func exitCode(in object: [String: JSONValue]) -> Int? { + if let exitCode = object["exit_code"]?.int ?? object["exitCode"]?.int { + return exitCode + } + if let isError = object["is_error"]?.bool { + return isError ? 1 : 0 + } + return nil + } + + private func payloadByAddingResult( + _ payload: EntryPayload, + resultSummary: String, + exitCode: Int?, + durationSeconds: Double?, + status: String? + ) -> EntryPayload { + let boundedResult = textBudget.body(resultSummary) + return switch payload { + case .toolRun(let tool): + .toolRun(ToolRunPayload( + toolName: tool.toolName, + argumentSummary: tool.argumentSummary, + resultSummary: boundedResult, + isTerminal: tool.isTerminal, + exitCode: exitCode, + isRunning: false, + toolCallID: tool.toolCallID, + inputDetail: tool.inputDetail, + command: tool.command, + output: boundedResult, + durationSeconds: durationSeconds, + status: status, + previewAttachments: tool.previewAttachments + )) + case .fileChange(let file): + .fileChange(FileChangePayload( + path: file.path, + changeKind: file.changeKind, + resultSummary: boundedResult, + toolCallID: file.toolCallID, + oldPath: file.oldPath, + newPath: file.newPath, + additions: file.additions, + deletions: file.deletions, + unifiedDiff: file.unifiedDiff + )) + default: + payload + } + } + + private func durationSeconds(in object: [String: JSONValue]) -> Double? { + object["duration_seconds"]?.number + ?? object["durationSeconds"]?.number + ?? object["duration_ms"]?.number.map { $0 / 1_000 } + ?? object["durationMs"]?.number.map { $0 / 1_000 } + } + + private func rendered(_ value: JSONValue?) -> String { + guard let value, let data = try? JSONEncoder().encode(value) else { return "" } + return String(decoding: data, as: UTF8.self) + } + + private func editDetail(in input: JSONValue?) -> String? { + guard let object = input?.object else { return nil } + if let diff = object["patch"]?.string ?? object["diff"]?.string { + return textBudget.body(diff) + } + let old = object["old_string"]?.string + let new = object["new_string"]?.string + guard old != nil || new != nil else { return nil } + return textBudget.body("--- before\n\(old ?? "")\n+++ after\n\(new ?? "")") + } + + private func attachmentPayload(in object: [String: JSONValue], authorRole: String? = nil) -> AttachmentPayload { + let kind = object["type"]?.string ?? object["kind"]?.string ?? "file" + let displayName = object["fileName"]?.string ?? object["file_name"]?.string ?? object["name"]?.string + let hostPath = object["path"]?.string ?? object["file_path"]?.string + let mimeType = object["mediaType"]?.string + ?? object["media_type"]?.string + ?? object["mime_type"]?.string + ?? hostPath.flatMap(imageMIMEType) + let metadata = shouldProbeImageMetadata(kind: kind, mimeType: mimeType, hostPath: hostPath) + ? TranscriptImageMetadataProbe.metadata(hostPath: hostPath, base64EncodedData: nil) + : TranscriptImageMetadataProbeResult(byteCount: nil, width: nil, height: nil) + return AttachmentPayload( + kind: kind, + summary: displayName ?? hostPath ?? "\(kind.capitalized) attachment", + attachmentID: object["id"]?.string, + displayName: displayName, + hostPath: hostPath, + mimeType: mimeType, + byteCount: object["size"]?.int ?? object["byte_count"]?.int ?? metadata.byteCount, + width: object["width"]?.int ?? metadata.width, + height: object["height"]?.int ?? metadata.height, + aspectRatio: aspectRatio(in: object), + authorRole: authorRole + ) + } + + private func aspectRatio(in object: [String: JSONValue]) -> Double? { + object["aspect_ratio"]?.number + ?? object["aspectRatio"]?.number + ?? object["preview_aspect_ratio"]?.number + ?? object["previewAspectRatio"]?.number + ?? object["ratio"]?.number + } + + private func shouldProbeImageMetadata(kind: String, mimeType: String?, hostPath: String?) -> Bool { + if kind.lowercased().contains("image") { + return true + } + if mimeType?.lowercased().hasPrefix("image/") == true { + return true + } + guard let hostPath else { + return false + } + switch URL(fileURLWithPath: hostPath).pathExtension.lowercased() { + case "png", "jpg", "jpeg", "gif", "webp", "heic", "heif", "tif", "tiff", "bmp", "svg": + return true + default: + return false + } + } + + private func imageMIMEType(for path: String) -> String? { + switch URL(fileURLWithPath: path).pathExtension.lowercased() { + case "png": "image/png" + case "jpg", "jpeg": "image/jpeg" + case "gif": "image/gif" + case "webp": "image/webp" + case "heic": "image/heic" + case "heif": "image/heif" + case "tif", "tiff": "image/tiff" + case "bmp": "image/bmp" + case "svg": "image/svg+xml" + default: nil + } + } + + private func imageDisplayName(for path: String) -> String? { + let displayName = URL(fileURLWithPath: path).lastPathComponent + return displayName.isEmpty ? nil : displayName + } + + private func normalizedAttachmentRole(_ role: String) -> String? { + switch role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "assistant", "agent", "model": + return "agent" + case "user", "human": + return "user" + default: + return nil + } + } + + private func estimatedDecodedByteCount(_ base64: String) -> Int { + let padding = base64.suffix(2).filter { $0 == "=" }.count + return max(0, base64.utf8.count * 3 / 4 - padding) + } + + private func summary(for payload: EntryPayload) -> String { + switch payload { + case .toolRun(let tool): + [tool.toolName, tool.argumentSummary, tool.resultSummary].compactMap { value in + guard let value, !value.isEmpty else { + return nil + } + return value + }.joined(separator: " ") + case .fileChange(let file): + [file.path, file.resultSummary].compactMap { value in + guard let value, !value.isEmpty else { + return nil + } + return value + }.joined(separator: " ") + case .question(let question): + question.prompt + case .unknown(let unknown): + unknown.summary ?? unknown.rawKind + default: + "" + } + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/CodexTranscriptDecoder.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/CodexTranscriptDecoder.swift new file mode 100644 index 000000000000..578895967093 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/CodexTranscriptDecoder.swift @@ -0,0 +1,889 @@ +public import CmuxAgentReplica +import Foundation + +/// Decodes Codex rollout JSONL transcripts into fail-open entries. +public struct CodexTranscriptDecoder: TranscriptDecoder, Sendable { + private let lineDecoder: JSONLineDecoder + private let textBudget: TranscriptTextBudget + private let previewAttachments: TranscriptToolPreviewAttachmentExtractor + private var pendingCalls: [String: PendingToolUse] + private var seenQuestionCallIDs: Set + private var sawCompactedRecord: Bool + + /// Creates a Codex transcript decoder. + public init() { + self.lineDecoder = JSONLineDecoder() + self.textBudget = TranscriptTextBudget() + self.previewAttachments = TranscriptToolPreviewAttachmentExtractor() + self.pendingCalls = [:] + self.seenQuestionCallIDs = [] + self.sawCompactedRecord = false + } + + /// Feeds Codex rollout lines at their absolute line index. + /// - Parameters: + /// - lines: The raw JSONL lines. + /// - startingAt: The absolute line index for the first line. + /// - journalID: The journal id that owns emitted entries. + /// - Returns: The decoded entries and diagnostics emitted by this feed. + public mutating func feed(_ lines: [String], startingAt: Int, journalID: JournalID) -> TranscriptDecodeBatch { + var accumulator = TranscriptDecodeAccumulator() + for (offset, line) in lines.enumerated() { + decodeLine(line, lineIndex: startingAt + offset, journalID: journalID, accumulator: &accumulator) + } + return accumulator.batch() + } + + private mutating func decodeLine( + _ line: String, + lineIndex: Int, + journalID: JournalID, + accumulator: inout TranscriptDecodeAccumulator + ) { + accumulator.beginSourceLine(timestampMilliseconds: nil) + guard let root = lineDecoder.decode(line)?.object else { + accumulator.countUnknown("malformed") + accumulator.emit(payload: unknownPayload(rawKind: "malformed", summary: "Malformed transcript line", raw: line), journalID: journalID, lineIndex: lineIndex) + return + } + accumulator.beginSourceLine(timestampMilliseconds: TranscriptTimestampParser.milliseconds(root["timestamp"])) + guard let type = root["type"]?.string else { + accumulator.countUnknown("missing_type") + accumulator.emit(payload: unknownPayload(rawKind: "missing_type", summary: "Missing Codex record type", raw: line), journalID: journalID, lineIndex: lineIndex) + return + } + switch type { + case "session_meta": + decodeSessionMeta(root, raw: line, lineIndex: lineIndex, journalID: journalID, accumulator: &accumulator) + case "response_item": + decodeResponseItem(root["payload"]?.object ?? root, raw: line, lineIndex: lineIndex, journalID: journalID, accumulator: &accumulator) + case "event_msg": + decodeEventMessage(root["payload"]?.object ?? root, raw: line, lineIndex: lineIndex, journalID: journalID, accumulator: &accumulator) + case "compacted": + sawCompactedRecord = true + accumulator.emit(payload: .status(StatusPayload(code: .compacted, detail: "Context compacted")), journalID: journalID, lineIndex: lineIndex) + case "turn_context": + decodeTurnContext(root["payload"]?.object ?? root, lineIndex: lineIndex, accumulator: &accumulator) + default: + accumulator.countUnknown(type) + accumulator.emit(payload: unknownPayload(rawKind: type, summary: "Unknown Codex record: \(type)", raw: line), journalID: journalID, lineIndex: lineIndex) + } + } + + private mutating func decodeSessionMeta( + _ root: [String: JSONValue], + raw: String, + lineIndex: Int, + journalID: JournalID, + accumulator: inout TranscriptDecodeAccumulator + ) { + let payload = root["payload"]?.object ?? root + let version = payload["cli_version"]?.string ?? "unknown" + if version != "unknown" { + accumulator.recordCLIVersion(version) + } + accumulator.emit(payload: .status(StatusPayload(code: .sessionMeta, detail: "Codex session \(version)")), journalID: journalID, lineIndex: lineIndex) + } + + private mutating func decodeEventMessage( + _ payload: [String: JSONValue], + raw: String, + lineIndex: Int, + journalID: JournalID, + accumulator: inout TranscriptDecodeAccumulator + ) { + let eventType = payload["type"]?.string ?? "event" + switch eventType { + case "turn_aborted": + accumulator.recordPhaseFact(.turnAborted(line: lineIndex)) + accumulator.countModeled("event_msg.\(eventType)") + accumulator.emit(payload: .status(StatusPayload(code: .turnAborted, detail: "Turn aborted")), journalID: journalID, lineIndex: lineIndex) + case "task_started": + accumulator.recordPhaseFact(.taskStarted(line: lineIndex)) + accumulator.countModeled("event_msg.\(eventType)") + case "task_complete": + accumulator.recordPhaseFact(.taskCompleted(line: lineIndex)) + accumulator.countModeled("event_msg.\(eventType)") + case "context_compacted", "compacted", "compact_complete": + accumulator.countModeled("event_msg.\(eventType)") + // The `compacted` record is the authoritative row when both streams + // describe the same compaction, so a later event duplicate is kept + // diagnostic-only. + if !sawCompactedRecord { + accumulator.emit(payload: .status(StatusPayload(code: .compacted, detail: "Context compacted")), journalID: journalID, lineIndex: lineIndex) + } + case "user_message", "agent_message": + accumulator.countDuplicateStream("event_msg.\(eventType)") + case "request_user_input": + decodeRequestUserInput( + payload, + callID: payload["call_id"]?.string, + lineIndex: lineIndex, + journalID: journalID, + accumulator: &accumulator + ) + case "patch_apply_end", "web_search_end", "mcp_tool_call_end", "entered_review_mode", "exited_review_mode", "thread_goal_updated", "thread_rolled_back", "token_count": + accumulator.countModeled("event_msg.\(eventType)") + default: + accumulator.countUnknown("event_msg.\(eventType)") + } + } + + private func decodeTurnContext( + _ payload: [String: JSONValue], + lineIndex: Int, + accumulator: inout TranscriptDecodeAccumulator + ) { + accumulator.countModeled("turn_context") + accumulator.recordTurnContextFact(TurnContextFact( + line: lineIndex, + model: payload["model"]?.string, + sandboxPolicy: sandboxPolicy(in: payload), + approvalPolicy: payload["approval_policy"]?.string + )) + } + + private func sandboxPolicy(in payload: [String: JSONValue]) -> String? { + payload["sandbox_policy"]?.object?["type"]?.string + ?? payload["sandbox_policy"]?.string + ?? payload["sandbox"]?.string + ?? payload["sandbox_mode"]?.string + } + + private mutating func decodeResponseItem( + _ payload: [String: JSONValue], + raw: String, + lineIndex: Int, + journalID: JournalID, + accumulator: inout TranscriptDecodeAccumulator + ) { + guard let itemType = payload["type"]?.string else { + accumulator.countUnknown("missing_response_item_type") + accumulator.emit(payload: unknownPayload(rawKind: "missing_response_item_type", summary: "Missing Codex response item type", raw: raw), journalID: journalID, lineIndex: lineIndex) + return + } + switch itemType { + case "message": + decodeMessage(payload, raw: raw, lineIndex: lineIndex, journalID: journalID, accumulator: &accumulator) + case "reasoning": + let text = payload["summary"]?.textFragments().joined(separator: "\n") + ?? payload["content"]?.textFragments().joined(separator: "\n") + ?? "" + accumulator.emit(payload: .thought(ThoughtPayload(text: textBudget.body(text))), journalID: journalID, lineIndex: lineIndex) + case "function_call": + decodeFunctionCall(payload, raw: raw, lineIndex: lineIndex, journalID: journalID, accumulator: &accumulator) + case "function_call_output": + decodeFunctionOutput(payload, raw: raw, lineIndex: lineIndex, journalID: journalID, accumulator: &accumulator) + case "custom_tool_call", "web_search_call", "tool_search_call": + decodeFunctionCall(payload, raw: raw, lineIndex: lineIndex, journalID: journalID, accumulator: &accumulator) + case "custom_tool_call_output", "tool_search_output": + decodeFunctionOutput(payload, raw: raw, lineIndex: lineIndex, journalID: journalID, accumulator: &accumulator) + default: + accumulator.countUnknown(itemType) + accumulator.emit(payload: unknownPayload(rawKind: itemType, summary: "Unknown Codex response item: \(itemType)", raw: raw), journalID: journalID, lineIndex: lineIndex) + } + } + + private mutating func decodeMessage( + _ payload: [String: JSONValue], + raw: String, + lineIndex: Int, + journalID: JournalID, + accumulator: inout TranscriptDecodeAccumulator + ) { + let role = payload["role"]?.string ?? "assistant" + guard normalizedAttachmentRole(role) == "user" else { + let text = payload["content"]?.textFragments().joined(separator: "\n") ?? "" + let payloads = decodedAgentTextPayloads(text, role: role) + guard !payloads.isEmpty else { return } + if payloads.count == 1 { + accumulator.emit(payload: payloads[0], journalID: journalID, lineIndex: lineIndex) + } else { + accumulator.emit(payloads: payloads, journalID: journalID, lineIndex: lineIndex) + } + return + } + + let decodedContent = decodeUserMessageContent(payload["content"]) + var payloads: [EntryPayload] = [] + if !decodedContent.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + payloads.append(.userMessage(UserMessagePayload( + text: textBudget.body(decodedContent.text), + attachmentCount: decodedContent.images.count, + hasImage: !decodedContent.images.isEmpty + ))) + } + let imageOrdinalStart = payloads.count + payloads.append(contentsOf: decodedContent.images.map { .attachment($0.attachment) }) + guard !payloads.isEmpty else { return } + var embeddedImagesByOrdinal: [Int: TranscriptEmbeddedImageSource] = [:] + for (imageIndex, image) in decodedContent.images.enumerated() { + if let embeddedImage = image.embeddedImage { + embeddedImagesByOrdinal[imageIndex + imageOrdinalStart] = embeddedImage + } + } + accumulator.emit( + payloads: payloads, + embeddedImagesByOrdinal: embeddedImagesByOrdinal, + journalID: journalID, + lineIndex: lineIndex + ) + } + + private mutating func decodeFunctionCall( + _ payload: [String: JSONValue], + raw: String, + lineIndex: Int, + journalID: JournalID, + accumulator: inout TranscriptDecodeAccumulator + ) { + let itemType = payload["type"]?.string + let name = payload["name"]?.string ?? toolName(for: itemType) ?? payload["call_id"]?.string ?? "call" + let callID = payload["call_id"]?.string + let argumentValue = payload["arguments"] ?? payload["input"] ?? payload["action"] + let argumentSummary = summarizeArguments(argumentValue) + if name == "request_user_input" { + let callID = payload["call_id"]?.string + let question = requestUserInputQuestion(in: argumentValue) + if let callID, let question { + pendingCalls[callID] = PendingToolUse(payload: .question(question), raw: raw) + } + decodeRequestUserInput( + parsedRequestUserInput(in: argumentValue)?.object ?? [:], + callID: callID, + lineIndex: lineIndex, + journalID: journalID, + accumulator: &accumulator + ) + return + } + let entryPayload: EntryPayload + if name == "apply_patch" { + entryPayload = .fileChange(fileChangePayload(arguments: argumentValue, toolCallID: callID)) + } else { + let terminal = isTerminalTool(name: name, arguments: argumentValue) + entryPayload = .toolRun(ToolRunPayload( + toolName: name, + argumentSummary: textBudget.summaryArgument(argumentSummary), + isTerminal: terminal, + isRunning: true, + toolCallID: callID, + inputDetail: textBudget.inputDetail(rendered(argumentValue)), + command: terminal ? textBudget.inputDetail(command(in: argumentValue)) : nil, + status: payload["status"]?.string ?? "running", + previewAttachments: previewAttachments.previewAttachments( + toolName: name, + input: argumentValue, + authorRole: "agent" + ) + )) + } + if let callID { + pendingCalls[callID] = PendingToolUse(payload: entryPayload, raw: raw) + } + accumulator.emit(payload: entryPayload, journalID: journalID, lineIndex: lineIndex) + } + + private mutating func decodeFunctionOutput( + _ payload: [String: JSONValue], + raw: String, + lineIndex: Int, + journalID: JournalID, + accumulator: inout TranscriptDecodeAccumulator + ) { + let outputType = payload["type"]?.string ?? "function_call_output" + let callID = payload["call_id"]?.string + let output = textBudget.body( + payload["output"]?.textFragments().joined(separator: "\n") + ?? rendered(payload["tools"]) + ) + let exitCode = exitCode(in: payload) + let duration = durationSeconds(in: payload) + let reportedStatus = payload["status"]?.string + ?? (exitCode.map { $0 == 0 ? "succeeded" : "failed" } ?? "completed") + guard let callID, let pending = pendingCalls.removeValue(forKey: callID) else { + accumulator.countUnknown(outputType) + accumulator.emit( + payload: .toolRun(ToolRunPayload( + toolName: "Unknown tool", + argumentSummary: "Missing invocation metadata", + resultSummary: output, + isTerminal: false, + exitCode: exitCode, + isRunning: false, + toolCallID: callID, + output: output, + durationSeconds: duration, + status: "unpaired_result:\(reportedStatus)" + )), + journalID: journalID, + lineIndex: lineIndex + ) + return + } + if case .question = pending.payload { + accumulator.countModeled("request_user_input.output") + return + } + accumulator.emit( + payload: payloadByAddingResult( + pending.payload, + resultSummary: output, + exitCode: exitCode, + durationSeconds: duration, + status: reportedStatus + ), + journalID: journalID, + lineIndex: lineIndex + ) + } + + private func toolName(for itemType: String?) -> String? { + switch itemType { + case "tool_search_call": + "tool_search" + case "web_search_call": + "web_search" + default: + nil + } + } + + private func summarizeArguments(_ value: JSONValue?) -> String { + guard let value else { + return "" + } + if let string = value.string { + return summarizeArgumentValue(lineDecoder.decode(string) ?? .string(string)) + } + return summarizeArgumentValue(value) + } + + private func parsedRequestUserInput(in value: JSONValue?) -> JSONValue? { + if let encoded = value?.string { + return lineDecoder.decode(encoded) + } + return value + } + + private func requestUserInputQuestion(in value: JSONValue?) -> QuestionPayload? { + questionPayload(in: parsedRequestUserInput(in: value)?.object ?? [:]) + } + + private mutating func decodeRequestUserInput( + _ payload: [String: JSONValue], + callID: String?, + lineIndex: Int, + journalID: JournalID, + accumulator: inout TranscriptDecodeAccumulator + ) { + if let callID, !seenQuestionCallIDs.insert(callID).inserted { + accumulator.countDuplicateStream("request_user_input") + return + } + guard let question = questionPayload(in: payload) else { + accumulator.countUnknown("request_user_input") + return + } + accumulator.emit(payload: .question(question), journalID: journalID, lineIndex: lineIndex) + } + + private func questionPayload(in payload: [String: JSONValue]) -> QuestionPayload? { + guard let questions = payload["questions"]?.array, !questions.isEmpty else { + return nil + } + guard questions.count == 1, + let question = questions.first?.object, + let prompt = question["question"]?.string else { + let prompts = questions.compactMap { value -> String? in + guard let object = value.object, let prompt = object["question"]?.string else { return nil } + if let header = object["header"]?.string, !header.isEmpty { + return "\(header): \(prompt)" + } + return prompt + } + guard !prompts.isEmpty else { return nil } + return QuestionPayload(prompt: prompts.joined(separator: "\n"), options: []) + } + let options = question["options"]?.array?.compactMap { option in + option.object?["label"]?.string ?? option.string + } ?? [] + let indexedOptions = options.count <= 9 ? options : [] + return QuestionPayload( + questionID: question["id"]?.string, + header: question["header"]?.string, + prompt: prompt, + options: indexedOptions + ) + } + + private func summarizeArgumentValue(_ value: JSONValue) -> String { + if let object = value.object { + if let command = object["command"] { + return summarizeCommand(command) + } + if let cmd = object["cmd"]?.string { + return cmd + } + if let query = object["query"]?.string { + return query + } + } + return summarizeCommand(value) + } + + private func summarizeCommand(_ value: JSONValue) -> String { + if let command = value.string { + return command + } + guard let array = value.array else { + return value.textFragments().joined(separator: " ") + } + let parts = array.compactMap(\.string) + if parts.count >= 3, parts[0] == "bash", parts[1] == "-lc" { + return parts[2] + } + return parts.joined(separator: " ") + } + + private func unknownPayload(rawKind: String, summary: String, raw: String) -> EntryPayload { + .unknown(UnknownPayload(rawKind: rawKind, summary: summary, rawJSON: raw)) + } + + private func decodeUserMessageContent(_ content: JSONValue?) -> CodexDecodedUserMessage { + guard let items = content?.array else { + return CodexDecodedUserMessage( + text: content?.textFragments().joined(separator: "\n") ?? "", + images: [] + ) + } + + var proseFragments: [String] = [] + var imageReferences: [CodexImageReference] = [] + var imageInputs: [CodexImageInput] = [] + for item in items { + guard let object = item.object else { + proseFragments.append(contentsOf: item.textFragments()) + continue + } + let type = object["type"]?.string ?? "" + if type == "input_image" || type == "image" { + imageInputs.append(codexImageInput(in: object)) + continue + } + for fragment in item.textFragments() { + let parsed = codexImageReferences(in: fragment) + if !parsed.text.isEmpty { + proseFragments.append(parsed.text) + } + imageReferences.append(contentsOf: parsed.references) + } + } + + let imageCount = max(imageReferences.count, imageInputs.count) + var images: [CodexDecodedImage] = [] + images.reserveCapacity(imageCount) + for imageIndex in 0 ..< imageCount { + let reference = imageReferences.indices.contains(imageIndex) ? imageReferences[imageIndex] : nil + let input = imageInputs.indices.contains(imageIndex) ? imageInputs[imageIndex] : nil + let hostPath = input?.hostPath ?? reference?.hostPath + let mimeType = input?.mimeType ?? hostPath.flatMap(imageMIMEType) + let displayName = hostPath.flatMap(imageDisplayName) ?? input?.displayName + let metadata = TranscriptImageMetadataProbe.metadata( + hostPath: hostPath, + base64EncodedData: input?.base64EncodedData + ) + let attachment = AttachmentPayload( + kind: "image", + summary: displayName ?? "Image attachment", + attachmentID: input?.attachmentID, + displayName: displayName, + hostPath: hostPath, + mimeType: mimeType, + byteCount: metadata.byteCount ?? input?.base64EncodedData.map(estimatedDecodedByteCount), + width: input?.width ?? metadata.width, + height: input?.height ?? metadata.height, + aspectRatio: input?.aspectRatio + ) + let embeddedImage: TranscriptEmbeddedImageSource? = if let base64EncodedData = input?.base64EncodedData { + TranscriptEmbeddedImageSource( + mimeType: mimeType, + base64EncodedData: base64EncodedData + ) + } else { + nil + } + images.append(CodexDecodedImage( + attachment: attachment, + embeddedImage: embeddedImage + )) + } + + return CodexDecodedUserMessage( + text: proseFragments.joined(separator: "\n"), + images: images + ) + } + + private func codexImageInput(in object: [String: JSONValue]) -> CodexImageInput { + let imageURL = object["image_url"]?.string + ?? object["image_url"]?.object?["url"]?.string + ?? object["url"]?.string + let dataURL = imageURL.flatMap(parseImageDataURL) + let directPath = object["path"]?.string + ?? object["file_path"]?.string + ?? object["local_path"]?.string + let fileURLPath: String? = if directPath == nil, + let imageURL, + let url = URL(string: imageURL), + url.isFileURL { + url.path + } else { + nil + } + return CodexImageInput( + attachmentID: object["id"]?.string, + displayName: object["file_name"]?.string + ?? object["fileName"]?.string + ?? object["name"]?.string, + hostPath: directPath ?? fileURLPath, + mimeType: object["mime_type"]?.string + ?? object["media_type"]?.string + ?? dataURL?.mimeType, + base64EncodedData: dataURL?.base64EncodedData, + width: object["width"]?.int, + height: object["height"]?.int, + aspectRatio: aspectRatio(in: object) + ) + } + + private func aspectRatio(in object: [String: JSONValue]) -> Double? { + object["aspect_ratio"]?.number + ?? object["aspectRatio"]?.number + ?? object["preview_aspect_ratio"]?.number + ?? object["previewAspectRatio"]?.number + ?? object["ratio"]?.number + } + + private func parseImageDataURL(_ value: String) -> CodexImageDataURL? { + guard value.hasPrefix("data:"), let comma = value.firstIndex(of: ",") else { + return nil + } + let metadata = value[value.index(value.startIndex, offsetBy: 5) ..< comma] + let components = metadata.split(separator: ";", omittingEmptySubsequences: false) + let declaredMIMEType = components.first.map(String.init).flatMap { $0.contains("/") ? $0 : nil } + guard components.dropFirst().contains(where: { $0.lowercased() == "base64" }) else { + return CodexImageDataURL(mimeType: declaredMIMEType, base64EncodedData: nil) + } + let dataStart = value.index(after: comma) + return CodexImageDataURL( + mimeType: declaredMIMEType, + base64EncodedData: String(value[dataStart...]) + ) + } + + private func codexImageReferences(in text: String) -> (text: String, references: [CodexImageReference]) { + guard text.contains("]*\bpath\s*=\s*(?:"([^"]+)"|'([^']+)')[^>]*>"# + guard let expression = try? NSRegularExpression(pattern: pattern) else { + return (text, []) + } + let fullRange = NSRange(text.startIndex ..< text.endIndex, in: text) + let matches = expression.matches(in: text, range: fullRange) + guard !matches.isEmpty else { + return (text, []) + } + + let source = text as NSString + let references = matches.compactMap { match -> CodexImageReference? in + for captureIndex in 1 ... 2 where match.range(at: captureIndex).location != NSNotFound { + return CodexImageReference(hostPath: source.substring(with: match.range(at: captureIndex))) + } + return nil + } + let stripped = NSMutableString(string: text) + for match in matches.reversed() { + stripped.replaceCharacters(in: match.range, with: "") + } + return ( + String(stripped).trimmingCharacters(in: .whitespacesAndNewlines), + references + ) + } + + private func imageDisplayName(for path: String) -> String? { + let displayName = URL(fileURLWithPath: path).lastPathComponent + return displayName.isEmpty ? nil : displayName + } + + private func decodedAgentTextPayloads(_ text: String, role: String) -> [EntryPayload] { + let segments = TranscriptMarkdownImageReferenceExtractor.segments(in: text) + guard segments.contains(where: { + if case .image = $0 { return true } + return false + }) else { + return [.agentProse(AgentProsePayload(markdown: textBudget.body(text)))] + } + + return segments.compactMap { segment in + switch segment { + case .text(let markdown): + let bounded = textBudget.body(markdown) + return bounded.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil + : .agentProse(AgentProsePayload(markdown: bounded)) + case .image(let reference): + return .attachment(markdownImageAttachment(reference: reference, role: role)) + } + } + } + + private func markdownImageAttachment( + reference: TranscriptMarkdownImageReference, + role: String + ) -> AttachmentPayload { + let mimeType = imageMIMEType(for: reference.hostPath) + let metadata = TranscriptImageMetadataProbe.metadata(hostPath: reference.hostPath, base64EncodedData: nil) + let displayName = reference.altText ?? imageDisplayName(for: reference.hostPath) + return AttachmentPayload( + kind: "image", + summary: displayName ?? "Image attachment", + displayName: displayName, + hostPath: reference.hostPath, + mimeType: mimeType, + byteCount: metadata.byteCount, + width: reference.pixelWidth ?? metadata.width, + height: reference.pixelHeight ?? metadata.height, + aspectRatio: reference.aspectRatio, + authorRole: normalizedAttachmentRole(role) + ) + } + + private func imageMIMEType(for path: String) -> String? { + switch URL(fileURLWithPath: path).pathExtension.lowercased() { + case "png": "image/png" + case "jpg", "jpeg": "image/jpeg" + case "gif": "image/gif" + case "webp": "image/webp" + case "heic": "image/heic" + case "heif": "image/heif" + case "tif", "tiff": "image/tiff" + case "bmp": "image/bmp" + case "svg": "image/svg+xml" + default: nil + } + } + + private func normalizedAttachmentRole(_ role: String) -> String? { + switch role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "assistant", "agent", "model": + return "agent" + case "user", "human": + return "user" + default: + return nil + } + } + + private func estimatedDecodedByteCount(_ base64: String) -> Int { + let padding = base64.suffix(2).filter { $0 == "=" }.count + return max(0, base64.utf8.count * 3 / 4 - padding) + } + + private func isTerminalTool(name: String, arguments: JSONValue?) -> Bool { + let lowercased = name.lowercased() + if lowercased == "bash" || lowercased == "shell" || lowercased == "terminal" { + return true + } + guard let executable = parsedCommandArray(in: arguments)?.first?.string else { + return false + } + let executableName = executable.split(separator: "/").last?.lowercased() ?? executable.lowercased() + return executableName == "bash" || executableName == "sh" || executableName == "zsh" || executableName == "fish" + } + + private func parsedCommandArray(in arguments: JSONValue?) -> [JSONValue]? { + let parsedArguments: JSONValue? + if let encodedArguments = arguments?.string { + parsedArguments = lineDecoder.decode(encodedArguments) + } else { + parsedArguments = arguments + } + return parsedArguments?.object?["command"]?.array ?? parsedArguments?.array + } + + private func filePath(in value: JSONValue?) -> String? { + let object: [String: JSONValue]? + if let string = value?.string { + object = lineDecoder.decode(string)?.object + } else { + object = value?.object + } + return object?["file_path"]?.string + ?? object?["path"]?.string + ?? object?["target_file"]?.string + } + + private func exitCode(in payload: [String: JSONValue]) -> Int? { + payload["exit_code"]?.int + ?? payload["exitCode"]?.int + ?? payload["status_code"]?.int + ?? payload["statusCode"]?.int + } + + private func payloadByAddingResult( + _ payload: EntryPayload, + resultSummary: String, + exitCode: Int?, + durationSeconds: Double?, + status: String? + ) -> EntryPayload { + let boundedResult = textBudget.body(resultSummary) + return switch payload { + case .toolRun(let tool): + .toolRun(ToolRunPayload( + toolName: tool.toolName, + argumentSummary: tool.argumentSummary, + resultSummary: boundedResult, + isTerminal: tool.isTerminal, + exitCode: exitCode, + isRunning: false, + toolCallID: tool.toolCallID, + inputDetail: tool.inputDetail, + command: tool.command, + output: boundedResult, + durationSeconds: durationSeconds, + status: status, + previewAttachments: tool.previewAttachments + )) + case .fileChange(let file): + .fileChange(FileChangePayload( + path: file.path, + changeKind: file.changeKind, + resultSummary: boundedResult, + toolCallID: file.toolCallID, + oldPath: file.oldPath, + newPath: file.newPath, + additions: file.additions, + deletions: file.deletions, + unifiedDiff: file.unifiedDiff + )) + default: + payload + } + } + + private func rendered(_ value: JSONValue?) -> String { + guard let value else { return "" } + if let string = value.string { return string } + guard let data = try? JSONEncoder().encode(value) else { return "" } + return String(decoding: data, as: UTF8.self) + } + + private func command(in value: JSONValue?) -> String { + let parsed: JSONValue? + if let string = value?.string { + parsed = lineDecoder.decode(string) ?? value + } else { + parsed = value + } + guard let parsed else { return "" } + if let command = parsed.object?["cmd"]?.string { + return command + } + if let command = parsed.object?["command"] { + return summarizeCommand(command) + } + return summarizeCommand(parsed) + } + + private func fileChangePayload(arguments: JSONValue?, toolCallID: String?) -> FileChangePayload { + let patch = rendered(arguments) + let paths = patchPaths(in: patch) + let counts = patchLineCounts(in: patch) + return FileChangePayload( + path: textBudget.inputDetail(filePath(in: arguments) ?? paths.new ?? paths.old ?? ""), + changeKind: .patch, + toolCallID: toolCallID, + oldPath: paths.old, + newPath: paths.new, + additions: counts.additions, + deletions: counts.deletions, + unifiedDiff: patch.isEmpty ? nil : textBudget.body(patch) + ) + } + + private func patchPaths(in patch: String) -> (old: String?, new: String?) { + var oldPath: String? + var newPath: String? + for line in patch.split(separator: "\n", omittingEmptySubsequences: false) { + if line.hasPrefix("*** Update File: ") { + let path = String(line.dropFirst("*** Update File: ".count)) + oldPath = path + newPath = path + } else if line.hasPrefix("*** Add File: ") { + newPath = String(line.dropFirst("*** Add File: ".count)) + } else if line.hasPrefix("*** Delete File: ") { + oldPath = String(line.dropFirst("*** Delete File: ".count)) + } else if line.hasPrefix("--- "), oldPath == nil { + oldPath = String(line.dropFirst(4)) + } else if line.hasPrefix("+++ "), newPath == nil { + newPath = String(line.dropFirst(4)) + } + } + return (oldPath, newPath) + } + + private func patchLineCounts(in patch: String) -> (additions: Int?, deletions: Int?) { + guard !patch.isEmpty else { return (nil, nil) } + var additions = 0 + var deletions = 0 + for line in patch.split(separator: "\n", omittingEmptySubsequences: false) { + if line.hasPrefix("+") && !line.hasPrefix("+++") { + additions += 1 + } else if line.hasPrefix("-") && !line.hasPrefix("---") { + deletions += 1 + } + } + return (additions, deletions) + } + + private func durationSeconds(in payload: [String: JSONValue]) -> Double? { + payload["duration_seconds"]?.number + ?? payload["durationSeconds"]?.number + ?? payload["duration_ms"]?.number.map { $0 / 1_000 } + ?? payload["durationMs"]?.number.map { $0 / 1_000 } + } +} + +private struct CodexDecodedUserMessage { + let text: String + let images: [CodexDecodedImage] +} + +private struct CodexDecodedImage { + let attachment: AttachmentPayload + let embeddedImage: TranscriptEmbeddedImageSource? +} + +private struct CodexImageReference { + let hostPath: String +} + +private struct CodexImageInput { + let attachmentID: String? + let displayName: String? + let hostPath: String? + let mimeType: String? + let base64EncodedData: String? + let width: Int? + let height: Int? + let aspectRatio: Double? +} + +private struct CodexImageDataURL { + let mimeType: String? + let base64EncodedData: String? +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/JSONLineDecoder.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/JSONLineDecoder.swift new file mode 100644 index 000000000000..851ac3abc419 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/JSONLineDecoder.swift @@ -0,0 +1,13 @@ +import Foundation + +package struct JSONLineDecoder { + package init() { + } + + package func decode(_ line: String) -> JSONValue? { + guard let data = line.data(using: .utf8) else { + return nil + } + return try? JSONDecoder().decode(JSONValue.self, from: data) + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/JSONValue.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/JSONValue.swift new file mode 100644 index 000000000000..4966b530d29f --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/JSONValue.swift @@ -0,0 +1,105 @@ +import Foundation + +package enum JSONValue: Codable, Hashable, Sendable { + case null + case bool(Bool) + case number(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) + + package init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int.self) { + self = .number(Double(value)) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else { + self = .object(try container.decode([String: JSONValue].self)) + } + } + + package func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: + try container.encodeNil() + case .bool(let value): + try container.encode(value) + case .number(let value): + try container.encode(value) + case .string(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + } + } + + package var string: String? { + if case .string(let value) = self { + return value + } + return nil + } + + package var object: [String: JSONValue]? { + if case .object(let value) = self { + return value + } + return nil + } + + package var array: [JSONValue]? { + if case .array(let value) = self { + return value + } + return nil + } + + package var bool: Bool? { + if case .bool(let value) = self { + return value + } + return nil + } + + package var number: Double? { + if case .number(let value) = self { + return value + } + return nil + } + + package var int: Int? { + number.flatMap { Int(exactly: $0.rounded()) } + } + + package func textFragments() -> [String] { + switch self { + case .string(let value): + [value] + case .array(let values): + values.flatMap { $0.textFragments() } + case .object(let object): + if let text = object["text"]?.string { + [text] + } else if let content = object["content"] { + content.textFragments() + } else { + [] + } + default: + [] + } + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/PendingToolUse.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/PendingToolUse.swift new file mode 100644 index 000000000000..1ca78958e4ea --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/PendingToolUse.swift @@ -0,0 +1,12 @@ +import CmuxAgentReplica +import Foundation + +struct PendingToolUse: Hashable, Sendable { + let payload: EntryPayload + let raw: String + + init(payload: EntryPayload, raw: String) { + self.payload = payload + self.raw = raw + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/PhaseFact.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/PhaseFact.swift new file mode 100644 index 000000000000..e3b652841bdf --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/PhaseFact.swift @@ -0,0 +1,11 @@ +import Foundation + +/// Captures transcript-derived phase corroboration facts. +public enum PhaseFact: Hashable, Sendable { + /// A turn was aborted at the given transcript line. + case turnAborted(line: Int) + /// A task started at the given transcript line. + case taskStarted(line: Int) + /// A task completed at the given transcript line. + case taskCompleted(line: Int) +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/SensitiveSessionTitleFact.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/SensitiveSessionTitleFact.swift new file mode 100644 index 000000000000..8daa8aa0b6fb --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/SensitiveSessionTitleFact.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Captures sensitive Claude session-title values for later session-list enrichment. +public struct SensitiveSessionTitleFact: Hashable, Sendable { + /// The transcript line that carried the title-like value. + public let line: Int + /// The bookkeeping source field, such as `ai-title` or `agent-name`. + public let source: String + /// The sensitive title-like value. Do not use this value as a counter key. + public let sensitiveValue: String + + /// Creates a sensitive session-title fact. + /// - Parameters: + /// - line: The transcript line that carried the title-like value. + /// - source: The bookkeeping source field. + /// - sensitiveValue: The sensitive title-like value. + public init(line: Int, source: String, sensitiveValue: String) { + self.line = line + self.source = source + self.sensitiveValue = sensitiveValue + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TranscriptDecodeAccumulator.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TranscriptDecodeAccumulator.swift new file mode 100644 index 000000000000..66a5bc73b5ae --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TranscriptDecodeAccumulator.swift @@ -0,0 +1,157 @@ +import CmuxAgentReplica +import Foundation + +struct TranscriptDecodeAccumulator { + // Keep a single source record within the GUI entries transport's hard page + // limit. This lets callers preserve the record boundary without returning + // an over-limit page, while reserving the final row for a visible + // truncation diagnostic. + static let maxEntriesPerSourceRecord = 200 + + private(set) var entries: [EntrySnapshot] + private(set) var embeddedImages: [TranscriptEmbeddedImage] + private(set) var unknownKindCounts: [String: Int] + private(set) var modeledKindCounts: [String: Int] + private(set) var duplicateStreamCounts: [String: Int] + private(set) var bookkeepingKindCounts: [String: Int] + private(set) var cliVersion: String? + private(set) var phaseFacts: [PhaseFact] + private(set) var turnContextFacts: [TurnContextFact] + private(set) var sawApiError: Bool + private(set) var sensitiveSessionTitles: [SensitiveSessionTitleFact] + private var currentTimestampMilliseconds: Int64? + + init() { + self.entries = [] + self.embeddedImages = [] + self.unknownKindCounts = [:] + self.modeledKindCounts = [:] + self.duplicateStreamCounts = [:] + self.bookkeepingKindCounts = [:] + self.cliVersion = nil + self.phaseFacts = [] + self.turnContextFacts = [] + self.sawApiError = false + self.sensitiveSessionTitles = [] + self.currentTimestampMilliseconds = nil + } + + mutating func beginSourceLine(timestampMilliseconds: Int64?) { + currentTimestampMilliseconds = timestampMilliseconds + } + + mutating func emit( + payload: EntryPayload, + journalID: JournalID, + lineIndex: Int + ) { + let seq = EntrySeq(rawValue: lineIndex) + entries.append(EntrySnapshot( + journalID: journalID, + seq: seq, + kind: payload.kind, + content: EntryContent(contentHash: payload.stableHash, payload: payload), + version: EntityVersion(rawValue: 1), + timestampMilliseconds: currentTimestampMilliseconds + )) + } + + /// Emits every user-visible block from one source record without losing + /// source order. The first block retains the record's absolute byte + /// offset; later blocks use successive byte positions inside that record. + /// A valid JSON block occupies more than one byte, so these ordinals remain + /// strictly before the next source record's byte offset. + mutating func emit( + payloads: [EntryPayload], + embeddedImagesByOrdinal: [Int: TranscriptEmbeddedImageSource] = [:], + journalID: JournalID, + lineIndex: Int + ) { + let boundedPayloads: [EntryPayload] + let retainedSourcePayloadCount: Int + if payloads.count > Self.maxEntriesPerSourceRecord { + let retainedCount = Self.maxEntriesPerSourceRecord - 1 + countUnknown("source_record_entries_truncated") + retainedSourcePayloadCount = retainedCount + boundedPayloads = Array(payloads.prefix(retainedCount)) + [ + .unknown(UnknownPayload( + rawKind: "source_record_entries_truncated", + summary: "Source record contains \(payloads.count) visible blocks; retained the first \(retainedCount)." + )), + ] + } else { + retainedSourcePayloadCount = payloads.count + boundedPayloads = payloads + } + for (ordinal, payload) in boundedPayloads.enumerated() { + emit( + payload: payload, + journalID: journalID, + lineIndex: lineIndex + ordinal + ) + if ordinal < retainedSourcePayloadCount, + let embeddedImage = embeddedImagesByOrdinal[ordinal] { + embeddedImages.append(TranscriptEmbeddedImage( + journalID: journalID, + entrySeq: EntrySeq(rawValue: lineIndex + ordinal), + mimeType: embeddedImage.mimeType, + base64EncodedData: embeddedImage.base64EncodedData + )) + } + } + } + + mutating func countUnknown(_ rawKind: String) { + unknownKindCounts[rawKind, default: 0] += 1 + } + + mutating func countModeled(_ rawKind: String) { + modeledKindCounts[rawKind, default: 0] += 1 + } + + mutating func countDuplicateStream(_ rawKind: String) { + duplicateStreamCounts[rawKind, default: 0] += 1 + } + + mutating func countBookkeeping(_ rawKind: String) { + bookkeepingKindCounts[rawKind, default: 0] += 1 + } + + mutating func recordCLIVersion(_ version: String) { + cliVersion = version + } + + mutating func recordPhaseFact(_ fact: PhaseFact) { + phaseFacts.append(fact) + } + + mutating func recordTurnContextFact(_ fact: TurnContextFact) { + turnContextFacts.append(fact) + } + + mutating func recordAPIError() { + sawApiError = true + } + + mutating func recordSensitiveSessionTitle(_ fact: SensitiveSessionTitleFact) { + sensitiveSessionTitles.append(fact) + } + + func batch() -> TranscriptDecodeBatch { + TranscriptDecodeBatch( + entries: entries, + embeddedImages: embeddedImages, + diagnostics: TranscriptDecoderDiagnostics( + unknownKindCounts: unknownKindCounts, + modeledKindCounts: modeledKindCounts, + duplicateStreamCounts: duplicateStreamCounts, + bookkeepingKindCounts: bookkeepingKindCounts, + cliVersion: cliVersion, + phaseFacts: phaseFacts, + turnContextFacts: turnContextFacts, + sawApiError: sawApiError, + sensitiveSessionTitles: sensitiveSessionTitles + ) + ) + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TranscriptDecodeBatch.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TranscriptDecodeBatch.swift new file mode 100644 index 000000000000..c38e2c341615 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TranscriptDecodeBatch.swift @@ -0,0 +1,60 @@ +public import CmuxAgentReplica +import Foundation + +/// Image bytes retained only for host-side transcript materialization. +/// +/// This type deliberately does not conform to `Codable`: embedded image bytes +/// are a transient decoder side table and never enter the replica transport. +public struct TranscriptEmbeddedImage: Sendable { + /// Journal that owns the attachment entry. + public let journalID: JournalID + /// Sequence of the retained attachment entry. + public let entrySeq: EntrySeq + /// Declared media type, when the transcript supplies one. + public let mimeType: String? + /// Original base64 image bytes, without a data-URL prefix. + public let base64EncodedData: String + + /// Creates a transient embedded-image record. + public init( + journalID: JournalID, + entrySeq: EntrySeq, + mimeType: String?, + base64EncodedData: String + ) { + self.journalID = journalID + self.entrySeq = entrySeq + self.mimeType = mimeType + self.base64EncodedData = base64EncodedData + } +} + +struct TranscriptEmbeddedImageSource: Sendable { + let mimeType: String? + let base64EncodedData: String +} + +/// Captures one incremental transcript decoder output batch. +public struct TranscriptDecodeBatch: Sendable { + /// The decoded entries emitted by this batch. + public let entries: [EntrySnapshot] + /// Embedded image bytes keyed to retained attachment entries. + public let embeddedImages: [TranscriptEmbeddedImage] + /// Diagnostics emitted by this batch. + public let diagnostics: TranscriptDecoderDiagnostics + + /// Creates a transcript decode batch. + /// - Parameters: + /// - entries: Decoded entries. + /// - embeddedImages: Transient image bytes for host-side materialization. + /// - diagnostics: Decoder diagnostics. + public init( + entries: [EntrySnapshot], + embeddedImages: [TranscriptEmbeddedImage] = [], + diagnostics: TranscriptDecoderDiagnostics + ) { + self.entries = entries + self.embeddedImages = embeddedImages + self.diagnostics = diagnostics + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TranscriptDecoder.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TranscriptDecoder.swift new file mode 100644 index 000000000000..f863e250f793 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TranscriptDecoder.swift @@ -0,0 +1,13 @@ +public import CmuxAgentReplica +import Foundation + +/// Incrementally decodes agent transcript JSONL into journal entries. +public protocol TranscriptDecoder { + /// Feeds transcript lines at their absolute line index. + /// - Parameters: + /// - lines: The raw JSONL lines. + /// - startingAt: The absolute line index for the first line. + /// - journalID: The journal id that owns emitted entries. + /// - Returns: The entries, payload side table, and decoder diagnostics emitted by this feed. + mutating func feed(_ lines: [String], startingAt: Int, journalID: JournalID) -> TranscriptDecodeBatch +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TranscriptDecoderDiagnostics.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TranscriptDecoderDiagnostics.swift new file mode 100644 index 000000000000..a9c696969c4f --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TranscriptDecoderDiagnostics.swift @@ -0,0 +1,60 @@ +import Foundation + +/// Captures fail-open decoder diagnostics. +public struct TranscriptDecoderDiagnostics: Hashable, Sendable { + /// Counts of unrecognized record or block kinds by raw kind string. + public var unknownKindCounts: [String: Int] + /// Counts of recognized transcript shapes consumed as modeled decoder facts. + /// + /// Some modeled shapes emit entries, such as status rows. Others are + /// diagnostic-only skips, such as duplicate streams or telemetry. + public var modeledKindCounts: [String: Int] + /// Counts of duplicate stream records consumed without entries. + public var duplicateStreamCounts: [String: Int] + /// Counts of Claude bookkeeping records consumed without entries. + public var bookkeepingKindCounts: [String: Int] + /// The CLI version observed in transcript metadata, when present. + public var cliVersion: String? + /// Transcript-derived phase corroboration facts. + public var phaseFacts: [PhaseFact] + /// Codex turn-context capability facts. + public var turnContextFacts: [TurnContextFact] + /// Whether the decoder saw an API error marker. + public var sawApiError: Bool + /// Sensitive title-like values for later session-list enrichment. + public var sensitiveSessionTitles: [SensitiveSessionTitleFact] + + /// Creates decoder diagnostics. + /// - Parameters: + /// - unknownKindCounts: Counts of unrecognized kinds. + /// - modeledKindCounts: Counts of modeled decoder facts. + /// - duplicateStreamCounts: Counts of duplicate stream kinds. + /// - bookkeepingKindCounts: Counts of Claude bookkeeping kinds. + /// - cliVersion: The observed CLI version. + /// - phaseFacts: Transcript-derived phase facts. + /// - turnContextFacts: Codex turn-context facts. + /// - sawApiError: Whether an API error marker was observed. + /// - sensitiveSessionTitles: Sensitive title-like values. + public init( + unknownKindCounts: [String: Int] = [:], + modeledKindCounts: [String: Int] = [:], + duplicateStreamCounts: [String: Int] = [:], + bookkeepingKindCounts: [String: Int] = [:], + cliVersion: String? = nil, + phaseFacts: [PhaseFact] = [], + turnContextFacts: [TurnContextFact] = [], + sawApiError: Bool = false, + sensitiveSessionTitles: [SensitiveSessionTitleFact] = [] + ) { + self.unknownKindCounts = unknownKindCounts + self.modeledKindCounts = modeledKindCounts + self.duplicateStreamCounts = duplicateStreamCounts + self.bookkeepingKindCounts = bookkeepingKindCounts + self.cliVersion = cliVersion + self.phaseFacts = phaseFacts + self.turnContextFacts = turnContextFacts + self.sawApiError = sawApiError + self.sensitiveSessionTitles = sensitiveSessionTitles + } + +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TranscriptImageMetadataProbe.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TranscriptImageMetadataProbe.swift new file mode 100644 index 000000000000..6d1f2328cbb6 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TranscriptImageMetadataProbe.swift @@ -0,0 +1,164 @@ +import Foundation +import ImageIO + +public struct TranscriptImageMetadataProbeResult: Equatable, Sendable { + public let byteCount: Int? + public let width: Int? + public let height: Int? + + public init(byteCount: Int?, width: Int?, height: Int?) { + self.byteCount = byteCount + self.width = width + self.height = height + } +} + +// lint:allow namespace-type - existing static helper surface, preserving call sites during UI closeout. +public enum TranscriptImageMetadataProbe { + public static func metadata( + hostPath: String?, + base64EncodedData: String? + ) -> TranscriptImageMetadataProbeResult { + if let base64EncodedData, + let data = Data(base64Encoded: base64EncodedData, options: [.ignoreUnknownCharacters]) { + let dimensions = imageDimensions(data: data) ?? svgDimensions(data: data) + return TranscriptImageMetadataProbeResult( + byteCount: data.count, + width: dimensions?.width, + height: dimensions?.height + ) + } + + guard let hostPath else { + return TranscriptImageMetadataProbeResult(byteCount: nil, width: nil, height: nil) + } + let expandedPath = (hostPath as NSString).expandingTildeInPath + let dimensions = imageDimensions(url: URL(fileURLWithPath: expandedPath)) + ?? svgDimensions(path: expandedPath) + let byteCount = fileByteCount(path: expandedPath) + return TranscriptImageMetadataProbeResult( + byteCount: byteCount, + width: dimensions?.width, + height: dimensions?.height + ) + } + + private static func imageDimensions(data: Data) -> (width: Int, height: Int)? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { + return nil + } + return imageDimensions(source: source) + } + + private static func imageDimensions(url: URL) -> (width: Int, height: Int)? { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { + return nil + } + return imageDimensions(source: source) + } + + private static func imageDimensions(source: CGImageSource) -> (width: Int, height: Int)? { + guard let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = integer(properties[kCGImagePropertyPixelWidth]), + let height = integer(properties[kCGImagePropertyPixelHeight]), + width > 0, + height > 0 else { + return nil + } + return (width, height) + } + + private static func integer(_ value: Any?) -> Int? { + switch value { + case let number as NSNumber: + number.intValue + case let int as Int: + int + default: + nil + } + } + + private static func svgDimensions(path: String) -> (width: Int, height: Int)? { + guard URL(fileURLWithPath: path).pathExtension.lowercased() == "svg", + let handle = try? FileHandle(forReadingFrom: URL(fileURLWithPath: path)) + else { return nil } + defer { try? handle.close() } + let data = (try? handle.read(upToCount: 128 * 1_024)) ?? Data() + return svgDimensions(data: data) + } + + private static func svgDimensions(data: Data) -> (width: Int, height: Int)? { + guard let text = String(data: data.prefix(128 * 1_024), encoding: .utf8), + text.range(of: " 0, + height > 0 { + return (width, height) + } + guard let viewBox = svgAttribute(named: "viewBox", in: text) else { + return nil + } + let numbers = viewBox + .split { $0 == "," || $0 == " " || $0 == "\n" || $0 == "\t" } + .compactMap { Double($0) } + guard numbers.count >= 4, + numbers[2] > 0, + numbers[3] > 0 else { + return nil + } + return (roundedPixelDimension(numbers[2]), roundedPixelDimension(numbers[3])) + } + + private static func svgLength(named name: String, in text: String) -> Int? { + guard let rawValue = svgAttribute(named: name, in: text), + let number = leadingNumber(rawValue), + number > 0 else { + return nil + } + return roundedPixelDimension(number) + } + + private static func svgAttribute(named name: String, in text: String) -> String? { + let escapedName = NSRegularExpression.escapedPattern(for: name) + let pattern = #"\b\#(escapedName)\s*=\s*["']([^"']+)["']"# + guard let expression = try? NSRegularExpression( + pattern: pattern, + options: [.caseInsensitive] + ) else { return nil } + let range = NSRange(text.startIndex.. Double? { + let pattern = #"^[ \t\r\n]*([+-]?(?:\d+(?:\.\d+)?|\.\d+))"# + guard let expression = try? NSRegularExpression(pattern: pattern), + let match = expression.firstMatch( + in: value, + range: NSRange(value.startIndex.. Int { + max(1, Int(value.rounded())) + } + + private static func fileByteCount(path: String) -> Int? { + guard let size = try? FileManager.default + .attributesOfItem(atPath: path)[.size] as? NSNumber else { + return nil + } + return size.intValue + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TranscriptMarkdownImageReferenceExtractor.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TranscriptMarkdownImageReferenceExtractor.swift new file mode 100644 index 000000000000..0627936f4d84 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TranscriptMarkdownImageReferenceExtractor.swift @@ -0,0 +1,305 @@ +import Foundation + +struct TranscriptMarkdownImageReference: Hashable, Sendable { + let altText: String? + let hostPath: String + let pixelWidth: Int? + let pixelHeight: Int? + let aspectRatio: Double? +} + +enum TranscriptMarkdownImageSegment: Hashable, Sendable { + case text(String) + case image(TranscriptMarkdownImageReference) +} + +// lint:allow namespace-type - existing static helper surface, preserving call sites during UI closeout. +enum TranscriptMarkdownImageReferenceExtractor { + static func segments(in markdown: String) -> [TranscriptMarkdownImageSegment] { + guard markdown.contains("]("), markdown.contains("![") else { + return [.text(markdown)] + } + let pattern = #"!\[([^\]]*)\]\(\s*(?:<([^>]+)>|([^)\s]+))(?:\s+(?:"([^"]*)"|'([^']*)'))?\s*\)"# + guard let expression = try? NSRegularExpression(pattern: pattern) else { + return [.text(markdown)] + } + let fullRange = NSRange(markdown.startIndex ..< markdown.endIndex, in: markdown) + let matches = expression.matches(in: markdown, range: fullRange) + guard !matches.isEmpty else { + return [.text(markdown)] + } + + let source = markdown as NSString + var parsed: [TranscriptMarkdownImageSegment] = [] + var pendingTextStart = markdown.startIndex + + for match in matches { + guard let matchRange = Range(match.range, in: markdown), + let reference = reference(from: match, source: source) else { + continue + } + appendText( + String(markdown[pendingTextStart ..< matchRange.lowerBound]), + to: &parsed + ) + parsed.append(.image(reference)) + pendingTextStart = matchRange.upperBound + } + + appendText(String(markdown[pendingTextStart...]), to: &parsed) + return parsed.isEmpty ? [.text(markdown)] : parsed + } + + private static func reference( + from match: NSTextCheckingResult, + source: NSString + ) -> TranscriptMarkdownImageReference? { + guard let rawPath = firstCapture(in: match, indexes: [2, 3], source: source), + let hostPath = normalizedHostPath(rawPath), + isLikelyImagePath(hostPath) else { + return nil + } + let altText = firstCapture(in: match, indexes: [1], source: source)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let title = firstCapture(in: match, indexes: [4, 5], source: source) + let dimensions = inferredDimensions(rawPath: rawPath, title: title) + let aspectRatio = inferredAspectRatio(rawPath: rawPath, title: title) + return TranscriptMarkdownImageReference( + altText: altText?.isEmpty == false ? altText : nil, + hostPath: hostPath, + pixelWidth: dimensions?.width, + pixelHeight: dimensions?.height, + aspectRatio: aspectRatio + ) + } + + private static func firstCapture( + in match: NSTextCheckingResult, + indexes: [Int], + source: NSString + ) -> String? { + for index in indexes where match.range(at: index).location != NSNotFound { + return source.substring(with: match.range(at: index)) + } + return nil + } + + private static func normalizedHostPath(_ rawPath: String) -> String? { + let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if let url = URL(string: trimmed), url.isFileURL { + return url.path + } + if let components = URLComponents(string: trimmed), + components.scheme == nil, + components.path.hasPrefix("/") { + return components.path + } + guard trimmed.hasPrefix("/") else { return nil } + return trimmed + } + + private static func isLikelyImagePath(_ path: String) -> Bool { + switch URL(fileURLWithPath: path).pathExtension.lowercased() { + case "apng", "avif", "bmp", "gif", "heic", "heif", "jpeg", "jpg", "png", "svg", "tif", "tiff", "webp": + return true + default: + return false + } + } + + private static func appendText(_ text: String, to segments: inout [TranscriptMarkdownImageSegment]) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + if case .text(let previous) = segments.last { + segments[segments.count - 1] = .text("\(previous)\n\(trimmed)") + } else { + segments.append(.text(trimmed)) + } + } + + private static func inferredDimensions( + rawPath: String, + title: String? + ) -> (width: Int, height: Int)? { + if let dimensions = title.flatMap(dimensions(in:)) { + return dimensions + } + if let dimensions = dimensionsFromQueryItems(in: rawPath) { + return dimensions + } + if let dimensions = dimensions(in: rawPath.removingPercentEncoding ?? rawPath) { + return dimensions + } + return dimensionsFromTrailingPathComponents(in: rawPath) + } + + private static func inferredAspectRatio( + rawPath: String, + title: String? + ) -> Double? { + if let aspectRatio = title.flatMap(aspectRatio(in:)) { + return aspectRatio + } + if let aspectRatio = aspectRatioFromQueryItems(in: rawPath) { + return aspectRatio + } + return aspectRatio(in: rawPath.removingPercentEncoding ?? rawPath) + } + + private static func dimensionsFromQueryItems(in rawPath: String) -> (width: Int, height: Int)? { + guard let components = URLComponents(string: rawPath), + let queryItems = components.queryItems, + !queryItems.isEmpty else { + return nil + } + var values: [String: Int] = [:] + for item in queryItems { + guard let value = item.value.flatMap(Int.init), + isValidDimension(value) else { + continue + } + values[item.name.lowercased()] = value + } + let width = values["w"] ?? values["width"] ?? values["pixel_width"] ?? values["pixelwidth"] + let height = values["h"] ?? values["height"] ?? values["pixel_height"] ?? values["pixelheight"] + guard let width, let height else { return nil } + return (width, height) + } + + private static func dimensionsFromTrailingPathComponents(in rawPath: String) -> (width: Int, height: Int)? { + guard let components = URLComponents(string: rawPath) else { return nil } + let parts = components.path + .split(separator: "/") + .map(String.init) + .filter { !$0.isEmpty } + guard parts.count >= 2, + let width = Int(parts[parts.count - 2]), + let height = Int(parts[parts.count - 1]), + isValidDimension(width), + isValidDimension(height) else { + return nil + } + return (width, height) + } + + private static func dimensions(in text: String) -> (width: Int, height: Int)? { + let patterns = [ + #"(?i)(?:^|[^0-9])(?:w|width|pixel_width|pixelwidth)\s*[:=_-]?\s*([0-9]{1,6})[^0-9]+(?:h|height|pixel_height|pixelheight)\s*[:=_-]?\s*([0-9]{1,6})(?:[^0-9]|$)"#, + #"(?i)(?:^|[^0-9])(?:h|height|pixel_height|pixelheight)\s*[:=_-]?\s*([0-9]{1,6})[^0-9]+(?:w|width|pixel_width|pixelwidth)\s*[:=_-]?\s*([0-9]{1,6})(?:[^0-9]|$)"#, + #"(?i)(?:^|[^0-9])([0-9]{1,6})\s*(?:x|×)\s*([0-9]{1,6})(?:[^0-9]|$)"#, + ] + for (index, pattern) in patterns.enumerated() { + guard let expression = try? NSRegularExpression(pattern: pattern) else { + continue + } + let range = NSRange(text.startIndex ..< text.endIndex, in: text) + guard let match = expression.firstMatch(in: text, range: range), + match.numberOfRanges >= 3, + let first = capturedInt(in: match, at: 1, text: text), + let second = capturedInt(in: match, at: 2, text: text) else { + continue + } + let width = index == 1 ? second : first + let height = index == 1 ? first : second + guard isValidDimension(width), isValidDimension(height) else { + continue + } + return (width, height) + } + return nil + } + + private static func aspectRatioFromQueryItems(in rawPath: String) -> Double? { + guard let components = URLComponents(string: rawPath), + let queryItems = components.queryItems, + !queryItems.isEmpty else { + return nil + } + for item in queryItems where isAspectRatioKey(item.name) { + if let aspectRatio = item.value.flatMap(aspectRatioValue) { + return aspectRatio + } + } + return nil + } + + private static func aspectRatio(in text: String) -> Double? { + let patterns = [ + #"(?i)(?:^|[^A-Za-z0-9])(?:aspect_ratio|aspectratio|preview_aspect_ratio|previewaspectratio|ratio)\s*[:=_-]?\s*([0-9]{1,6})\s*[:/]\s*([0-9]{1,6})(?:[^0-9]|$)"#, + #"(?i)(?:^|[^A-Za-z0-9])(?:aspect_ratio|aspectratio|preview_aspect_ratio|previewaspectratio|ratio)\s*[:=_-]?\s*([0-9]{1,6}(?:\.[0-9]{1,6})?)(?:[^0-9.]|$)"#, + ] + for (index, pattern) in patterns.enumerated() { + guard let expression = try? NSRegularExpression(pattern: pattern) else { + continue + } + let range = NSRange(text.startIndex ..< text.endIndex, in: text) + guard let match = expression.firstMatch(in: text, range: range) else { + continue + } + if index == 0, + let width = capturedDouble(in: match, at: 1, text: text), + let height = capturedDouble(in: match, at: 2, text: text) { + return normalizedAspectRatio(width / height) + } + if let value = capturedDouble(in: match, at: 1, text: text) { + return normalizedAspectRatio(value) + } + } + return nil + } + + private static func aspectRatioValue(_ rawValue: String) -> Double? { + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + if let direct = Double(value).flatMap(normalizedAspectRatio) { + return direct + } + let parts = value.split { $0 == ":" || $0 == "/" }.compactMap { Double($0) } + guard parts.count == 2, parts[1] > 0 else { return nil } + return normalizedAspectRatio(parts[0] / parts[1]) + } + + private static func capturedInt( + in match: NSTextCheckingResult, + at index: Int, + text: String + ) -> Int? { + guard match.range(at: index).location != NSNotFound, + let range = Range(match.range(at: index), in: text) else { + return nil + } + return Int(text[range]) + } + + private static func capturedDouble( + in match: NSTextCheckingResult, + at index: Int, + text: String + ) -> Double? { + guard match.range(at: index).location != NSNotFound, + let range = Range(match.range(at: index), in: text) else { + return nil + } + return Double(text[range]) + } + + private static func isAspectRatioKey(_ value: String) -> Bool { + switch value.lowercased() { + case "aspect_ratio", "aspectratio", "preview_aspect_ratio", "previewaspectratio", "ratio": + return true + default: + return false + } + } + + private static func normalizedAspectRatio(_ value: Double?) -> Double? { + guard let value, value.isFinite, value > 0, value <= 1_000 else { + return nil + } + return value + } + + private static func isValidDimension(_ value: Int) -> Bool { + value > 0 && value <= 200_000 + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TranscriptTextBudget.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TranscriptTextBudget.swift new file mode 100644 index 000000000000..17e91a431404 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TranscriptTextBudget.swift @@ -0,0 +1,37 @@ +import Foundation + +/// UTF-8 byte budgets for transcript-derived strings retained in replica entries. +struct TranscriptTextBudget: Sendable { + static let bodyByteLimit = 16 * 1_024 + static let inputDetailByteLimit = 2 * 1_024 + static let summaryArgumentByteLimit = 80 + + func body(_ text: String) -> String { + truncated(text, byteLimit: Self.bodyByteLimit) + } + + func inputDetail(_ text: String) -> String { + truncated(text, byteLimit: Self.inputDetailByteLimit) + } + + func summaryArgument(_ text: String) -> String { + let oneLine = text + .replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespaces) + return truncated(oneLine, byteLimit: Self.summaryArgumentByteLimit) + } + + private func truncated(_ text: String, byteLimit: Int) -> String { + guard text.utf8.count > byteLimit else { return text } + let marker = "…" + let payloadLimit = max(0, byteLimit - marker.utf8.count) + var bytes = Array(text.utf8.prefix(payloadLimit)) + while let last = bytes.last, last & 0b1100_0000 == 0b1000_0000 { + bytes.removeLast() + } + while !bytes.isEmpty, String(bytes: bytes, encoding: .utf8) == nil { + bytes.removeLast() + } + return String(decoding: bytes, as: UTF8.self) + marker + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TranscriptTimestampParser.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TranscriptTimestampParser.swift new file mode 100644 index 000000000000..b9e81a46b79e --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TranscriptTimestampParser.swift @@ -0,0 +1,28 @@ +import Foundation + +// lint:allow namespace-type - existing static helper surface, preserving call sites during UI closeout. +enum TranscriptTimestampParser { + static func milliseconds(_ value: JSONValue?) -> Int64? { + if let number = value?.number { + let milliseconds = number > 10_000_000_000 ? number : number * 1_000 + guard milliseconds.isFinite, + milliseconds >= Double(Int64.min), + milliseconds <= Double(Int64.max) else { + return nil + } + return Int64(milliseconds.rounded()) + } + guard let string = value?.string else { return nil } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let date: Date? + if let fractional = formatter.date(from: string) { + date = fractional + } else { + formatter.formatOptions = [.withInternetDateTime] + date = formatter.date(from: string) + } + guard let date else { return nil } + return Int64((date.timeIntervalSince1970 * 1_000).rounded()) + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TranscriptToolPreviewAttachmentExtractor.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TranscriptToolPreviewAttachmentExtractor.swift new file mode 100644 index 000000000000..0469c12e002e --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TranscriptToolPreviewAttachmentExtractor.swift @@ -0,0 +1,407 @@ +import CmuxAgentReplica +import Foundation + +/// Extracts lightweight inline preview metadata from tool inputs. +/// +/// The transcript row should not wait for image bytes before reserving layout. +/// Tool inputs usually already carry the host path, and the TruthKit side can +/// cheaply stat local files while minting replica entries. +struct TranscriptToolPreviewAttachmentExtractor: Sendable { + private let lineDecoder: JSONLineDecoder + + init(lineDecoder: JSONLineDecoder = JSONLineDecoder()) { + self.lineDecoder = lineDecoder + } + + func previewAttachments( + toolName: String, + input: JSONValue?, + authorRole: String? + ) -> [AttachmentPayload]? { + let candidates = deduplicatedCandidates(candidateReferences(in: normalized(input))) + let attachments = candidates.compactMap { candidate -> AttachmentPayload? in + let path = candidate.hostPath + guard shouldPreview(path: path, toolName: toolName) else { + return nil + } + let metadata = TranscriptImageMetadataProbe.metadata(hostPath: path, base64EncodedData: nil) + let displayName = candidate.displayName ?? imageDisplayName(for: path) + return AttachmentPayload( + kind: "image", + summary: displayName ?? "Image attachment", + displayName: displayName, + hostPath: path, + mimeType: candidate.mimeType ?? imageMIMEType(for: path), + byteCount: candidate.byteCount ?? metadata.byteCount, + width: candidate.width ?? metadata.width, + height: candidate.height ?? metadata.height, + aspectRatio: candidate.aspectRatio, + authorRole: authorRole + ) + } + return attachments.isEmpty ? nil : attachments + } + + private func normalized(_ value: JSONValue?) -> JSONValue? { + guard let value else { return nil } + if let string = value.string { + return lineDecoder.decode(string) ?? value + } + return value + } + + private func candidateReferences(in value: JSONValue?) -> [PreviewAttachmentCandidate] { + guard let value else { return [] } + switch value { + case .string(let string): + return string.hasPrefix("/") || string.hasPrefix("file://") + ? [PreviewAttachmentCandidate(hostPath: filePath(from: string))] + : [] + case .array(let values): + return values.flatMap { candidateReferences(in: normalized($0)) } + case .object(let object): + var candidates: [PreviewAttachmentCandidate] = [] + for key in Self.pathKeys { + if let path = object[key]?.string { + candidates.append(previewCandidate(path: path, object: object)) + } + } + for key in Self.nestedPathContainerKeys { + candidates.append(contentsOf: candidateReferences(in: normalized(object[key]))) + } + return candidates + case .null, .bool, .number: + return [] + } + } + + private func previewCandidate( + path: String, + object: [String: JSONValue] + ) -> PreviewAttachmentCandidate { + let metadataObjects = previewMetadataObjects(from: object) + return PreviewAttachmentCandidate( + hostPath: filePath(from: path), + displayName: string(in: metadataObjects, keys: Self.displayNameKeys), + mimeType: string(in: metadataObjects, keys: Self.mimeTypeKeys), + byteCount: int(in: metadataObjects, keys: Self.byteCountKeys), + width: int(in: metadataObjects, keys: Self.widthKeys), + height: int(in: metadataObjects, keys: Self.heightKeys), + aspectRatio: aspectRatio(in: metadataObjects) + ) + } + + private func previewMetadataObjects(from object: [String: JSONValue]) -> [[String: JSONValue]] { + var objects = [object] + for key in Self.metadataContainerKeys { + guard let value = normalized(object[key]) else { continue } + objects.append(contentsOf: previewMetadataObjects(in: value)) + } + return objects + } + + private func previewMetadataObjects(in value: JSONValue) -> [[String: JSONValue]] { + switch value { + case .object(let object): + return previewMetadataObjects(from: object) + case .array(let values): + return values.flatMap { previewMetadataObjects(in: normalized($0) ?? $0) } + case .null, .bool, .number, .string: + return [] + } + } + + private func shouldPreview(path: String, toolName: String) -> Bool { + guard isLocalFilePath(path) else { + return false + } + if isImagePreviewTool(toolName) { + return true + } + if imageMIMEType(for: path)?.hasPrefix("image/") == true { + return true + } + return false + } + + private func isLocalFilePath(_ path: String) -> Bool { + if path.hasPrefix("/") { + return true + } + guard let url = URL(string: path) else { + return false + } + return url.isFileURL + } + + private func isImagePreviewTool(_ toolName: String) -> Bool { + let normalized = toolName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized == "view_image" + || normalized == "open_image" + || normalized == "screenshot" + || normalized.contains("view_image") + || normalized.contains("image_preview") + } + + private func filePath(from value: String) -> String { + guard let url = URL(string: value), url.isFileURL else { + return value + } + return url.path + } + + private func deduplicatedCandidates( + _ candidates: [PreviewAttachmentCandidate] + ) -> [PreviewAttachmentCandidate] { + var indexesByPath: [String: Int] = [:] + var deduplicated: [PreviewAttachmentCandidate] = [] + for candidate in candidates { + let trimmedPath = candidate.hostPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedPath.isEmpty else { + continue + } + let trimmed = candidate.withHostPath(trimmedPath) + if let existingIndex = indexesByPath[trimmedPath] { + deduplicated[existingIndex] = deduplicated[existingIndex].merged(with: trimmed) + } else { + indexesByPath[trimmedPath] = deduplicated.count + deduplicated.append(trimmed) + } + } + return deduplicated + } + + private func string(in objects: [[String: JSONValue]], keys: [String]) -> String? { + for object in objects { + for key in keys { + if let value = object[key]?.string?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty { + return value + } + } + } + return nil + } + + private func int(in objects: [[String: JSONValue]], keys: [String]) -> Int? { + for object in objects { + for key in keys { + if let value = object[key]?.int, value > 0 { + return value + } + if let string = object[key]?.string, + let value = Int(string.trimmingCharacters(in: .whitespacesAndNewlines)), + value > 0 { + return value + } + } + } + return nil + } + + private func aspectRatio(in objects: [[String: JSONValue]]) -> Double? { + for object in objects { + for key in Self.aspectRatioKeys { + guard let value = object[key] else { + continue + } + if let ratio = normalizedAspectRatio(value.number) { + return ratio + } + if let ratio = value.string.flatMap(aspectRatioValue) { + return ratio + } + } + } + return nil + } + + private func aspectRatioValue(_ rawValue: String) -> Double? { + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + if let direct = Double(value).flatMap(normalizedAspectRatio) { + return direct + } + let parts = value.split { $0 == ":" || $0 == "/" }.compactMap { Double($0) } + guard parts.count == 2, parts[1] > 0 else { + return nil + } + return normalizedAspectRatio(parts[0] / parts[1]) + } + + private func normalizedAspectRatio(_ value: Double?) -> Double? { + guard let value, value.isFinite, value > 0, value <= 1_000 else { + return nil + } + return value + } + + private func imageDisplayName(for path: String) -> String? { + let displayName = URL(fileURLWithPath: path).lastPathComponent + return displayName.isEmpty ? nil : displayName + } + + private func imageMIMEType(for path: String) -> String? { + switch URL(fileURLWithPath: path).pathExtension.lowercased() { + case "png": "image/png" + case "jpg", "jpeg": "image/jpeg" + case "gif": "image/gif" + case "webp": "image/webp" + case "heic": "image/heic" + case "heif": "image/heif" + case "tif", "tiff": "image/tiff" + case "bmp": "image/bmp" + case "svg": "image/svg+xml" + default: nil + } + } + + private struct PreviewAttachmentCandidate: Equatable, Sendable { + let hostPath: String + let displayName: String? + let mimeType: String? + let byteCount: Int? + let width: Int? + let height: Int? + let aspectRatio: Double? + + init( + hostPath: String, + displayName: String? = nil, + mimeType: String? = nil, + byteCount: Int? = nil, + width: Int? = nil, + height: Int? = nil, + aspectRatio: Double? = nil + ) { + self.hostPath = hostPath + self.displayName = displayName + self.mimeType = mimeType + self.byteCount = byteCount + self.width = width + self.height = height + self.aspectRatio = aspectRatio + } + + func withHostPath(_ hostPath: String) -> Self { + PreviewAttachmentCandidate( + hostPath: hostPath, + displayName: displayName, + mimeType: mimeType, + byteCount: byteCount, + width: width, + height: height, + aspectRatio: aspectRatio + ) + } + + func merged(with other: Self) -> Self { + PreviewAttachmentCandidate( + hostPath: hostPath, + displayName: displayName ?? other.displayName, + mimeType: mimeType ?? other.mimeType, + byteCount: byteCount ?? other.byteCount, + width: width ?? other.width, + height: height ?? other.height, + aspectRatio: aspectRatio ?? other.aspectRatio + ) + } + } + + private static let displayNameKeys = [ + "display_name", + "fileName", + "file_name", + "filename", + "name", + ] + + private static let mimeTypeKeys = [ + "contentType", + "content_type", + "mediaType", + "media_type", + "mime", + "mimeType", + "mime_type", + ] + + private static let byteCountKeys = [ + "byteLength", + "byte_length", + "bytes", + "byteCount", + "byte_count", + "contentLength", + "content_length", + "fileSize", + "file_size", + "size", + ] + + private static let widthKeys = [ + "imageWidth", + "image_width", + "naturalWidth", + "natural_width", + "previewWidth", + "preview_width", + "pixelWidth", + "pixel_width", + "width", + "w", + ] + + private static let heightKeys = [ + "imageHeight", + "image_height", + "naturalHeight", + "natural_height", + "previewHeight", + "preview_height", + "pixelHeight", + "pixel_height", + "height", + "h", + ] + + private static let aspectRatioKeys = [ + "aspectRatio", + "aspect_ratio", + "previewAspectRatio", + "preview_aspect_ratio", + "ratio", + ] + + private static let pathKeys = [ + "absolute_path", + "file_path", + "host_path", + "image_path", + "local_path", + "path", + "screenshot_path", + "uri", + "url", + ] + + private static let nestedPathContainerKeys = [ + "attachment", + "file", + "image", + "input_image", + "source", + ] + + private static let metadataContainerKeys = [ + "artifact", + "attachment", + "dimensions", + "file", + "image", + "image_metadata", + "metadata", + "meta", + "preview", + "preview_metadata", + ] +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TurnContextFact.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TurnContextFact.swift new file mode 100644 index 000000000000..9da1cba0d289 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Decoding/TurnContextFact.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Captures Codex per-turn capability identifiers from `turn_context` records. +public struct TurnContextFact: Hashable, Sendable { + /// The transcript line that carried the context. + public let line: Int + /// The model identifier reported by Codex, when present. + public let model: String? + /// The sandbox policy identifier reported by Codex, when present. + public let sandboxPolicy: String? + /// The approval policy identifier reported by Codex, when present. + public let approvalPolicy: String? + + /// Creates a turn context fact. + /// - Parameters: + /// - line: The transcript line that carried the context. + /// - model: The model identifier reported by Codex. + /// - sandboxPolicy: The sandbox policy identifier reported by Codex. + /// - approvalPolicy: The approval policy identifier reported by Codex. + public init(line: Int, model: String?, sandboxPolicy: String?, approvalPolicy: String?) { + self.line = line + self.model = model + self.sandboxPolicy = sandboxPolicy + self.approvalPolicy = approvalPolicy + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/AgentTruthChange.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/AgentTruthChange.swift new file mode 100644 index 000000000000..b4c081f1d22b --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/AgentTruthChange.swift @@ -0,0 +1,10 @@ +public import CmuxAgentReplica +import Foundation + +/// Describes the externally visible changes emitted by the truth reducer. +public enum AgentTruthChange: Hashable, Sendable { + /// A session snapshot was inserted or replaced. + case sessionUpserted(AgentSessionSnapshot) + /// A session snapshot was removed. + case sessionRemoved(AgentSessionID) +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/AgentTruthReducer.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/AgentTruthReducer.swift new file mode 100644 index 000000000000..b55980a52f28 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/AgentTruthReducer.swift @@ -0,0 +1,394 @@ +public import CmuxAgentReplica +import Foundation + +/// Folds heterogeneous truth-channel observations into versioned session snapshots. +@MainActor +public final class AgentTruthReducer { + private let macDeviceID: MacDeviceID + private var records: [AgentSessionID: SessionRecord] + private var snapshotCache: [AgentSessionID: AgentSessionSnapshot] + private var evidenceCache: [AgentSessionID: DetectionEvidence] + private var sessionIDsByPID: [Int32: Set] + private var pidBySessionID: [AgentSessionID: Int32] + + /// The current session snapshots keyed by session id. + public var snapshots: [AgentSessionID: AgentSessionSnapshot] { + snapshotCache + } + + /// The current detection evidence keyed by session id. + public var evidence: [AgentSessionID: DetectionEvidence] { + evidenceCache + } + + /// Returns the current snapshot for one session without materializing the full snapshot dictionary. + /// - Parameter sessionID: The session id to look up. + /// - Returns: The current snapshot, or `nil` when the session is unknown. + public func snapshot(for sessionID: AgentSessionID) -> AgentSessionSnapshot? { + snapshotCache[sessionID] + } + + /// Returns the current detection evidence for one session without materializing the full evidence dictionary. + /// - Parameter sessionID: The session id to look up. + /// - Returns: The current evidence, or `nil` when the session is unknown. + public func evidence(for sessionID: AgentSessionID) -> DetectionEvidence? { + evidenceCache[sessionID] + } + + /// Creates an agent truth reducer. + /// - Parameter macDeviceID: The Mac device id to stamp onto snapshots. + public init(macDeviceID: MacDeviceID) { + self.macDeviceID = macDeviceID + self.records = [:] + self.snapshotCache = [:] + self.evidenceCache = [:] + self.sessionIDsByPID = [:] + self.pidBySessionID = [:] + } + + /// Folds one truth signal into the current session set. + /// - Parameter signal: The channel signal to fold. + /// - Returns: The versioned changes emitted by the fold. + public func fold(_ signal: TruthChannelSignal) -> [AgentTruthChange] { + switch signal { + case .processObserved(let observation, let tick): + return foldProcessObserved(observation, tick: tick) + case .processGone(let pid, let startTick, let tick): + return foldProcessGone(pid: pid, startTick: startTick, tick: tick) + case .wrapperLaunched(let fact, let tick): + return foldWrapperLaunched(fact, tick: tick) + case .hookEvent(let fact, let tick): + return foldHookEvent(fact, tick: tick) + case .transcriptCorroboration(let sessionID, let fact, let tick): + return foldTranscriptCorroboration(sessionID: sessionID, fact: fact, tick: tick) + } + } + + private func foldProcessObserved(_ observation: ProcessObservation, tick: Int) -> [AgentTruthChange] { + let processIdentity = ProcessIdentity(pid: observation.pid, startTick: observation.startTick) + let id = identityForProcessObservation(observation) + var draft = records[id]?.draft ?? SessionRecordDraft( + id: id, + kind: observation.agentKindGuess, + phase: .starting, + surfaceID: observation.surfaceID, + cwd: observation.cwd, + title: observation.argvSummary, + workspaceName: "", + lastActivityHint: tick, + evidence: DetectionEvidence() + ) + draft.kind = preferredKind(existing: draft.kind, incoming: observation.agentKindGuess) + draft.surfaceID = draft.surfaceID ?? observation.surfaceID + draft.cwd = observation.cwd + draft.title = observation.argvSummary + draft.lastActivityHint = tick + draft.evidence.hasProcessObservation = true + draft.evidence.processIdentity = processIdentity + draft.evidence.transcriptPath = observation.openTranscriptPath ?? draft.evidence.transcriptPath + if draft.phase == .ended { + draft.phase = .idle + } + return commit(draft) + } + + private func foldProcessGone(pid: Int32, startTick: Int, tick: Int) -> [AgentTruthChange] { + guard let id = sessionIDsByPID[pid]?.first(where: { sessionID in + guard let identity = records[sessionID]?.draft.evidence.processIdentity else { return false } + return identity.startTick == nil || identity.startTick == startTick + }) else { + return [] + } + var draft = records[id]!.draft + draft.phase = .ended + draft.lastActivityHint = tick + return commit(draft) + } + + private func foldWrapperLaunched(_ fact: WrapperLaunchFact, tick: Int) -> [AgentTruthChange] { + let preferredID = fact.sessionID ?? matchingID(surfaceID: fact.surfaceID, pid: fact.pid) ?? AgentSessionID(rawValue: "prov:\(fact.surfaceID):\(tick)") + if let sessionID = fact.sessionID { + if let changes = mergeIfNeeded(realID: sessionID, surfaceID: fact.surfaceID, pid: fact.pid, applying: { draft in + applyWrapperLaunch(fact, tick: tick, to: &draft) + }) { + return changes + } + } + var draft = records[preferredID]?.draft ?? SessionRecordDraft( + id: preferredID, + kind: fact.agentKind, + phase: fact.launchArgvKind == .resume ? .idle : .starting, + surfaceID: fact.surfaceID, + cwd: fact.cwd, + title: fact.agentKind.rawValue, + workspaceName: "", + lastActivityHint: tick, + evidence: DetectionEvidence() + ) + draft.id = preferredID + applyWrapperLaunch(fact, tick: tick, to: &draft) + return commit(draft) + } + + private func foldHookEvent(_ fact: HookFact, tick: Int) -> [AgentTruthChange] { + if let changes = mergeIfNeeded(realID: fact.sessionID, surfaceID: fact.surfaceID, pid: fact.pid, applying: { draft in + applyHookEvent(fact, tick: tick, to: &draft) + }) { + return changes + } + var draft = records[fact.sessionID]?.draft ?? SessionRecordDraft( + id: fact.sessionID, + kind: .unknown("unknown"), + phase: .unknown, + surfaceID: fact.surfaceID, + cwd: fact.cwd ?? "", + title: "", + workspaceName: "", + lastActivityHint: tick, + evidence: DetectionEvidence() + ) + applyHookEvent(fact, tick: tick, to: &draft) + return commit(draft) + } + + private func foldTranscriptCorroboration( + sessionID: AgentSessionID, + fact: TranscriptCorroborationFact, + tick: Int + ) -> [AgentTruthChange] { + guard var draft = records[sessionID]?.draft else { + return [] + } + draft.evidence.hasTranscriptCorroboration = true + draft.lastActivityHint = tick + switch fact { + case .assistantTurnCompleted: + if draft.phase == .working { + draft.phase = .idle + } + case .userMessageAppended: + draft.phase = .working + } + return commit(draft) + } + + private func mergeIfNeeded( + realID: AgentSessionID, + surfaceID: String?, + pid: Int32?, + applying refine: (inout SessionRecordDraft) -> Void + ) -> [AgentTruthChange]? { + guard let surfaceID, let pid else { + return nil + } + guard let existingID = matchingID(surfaceID: surfaceID, pid: pid), existingID != realID else { + return nil + } + guard let existing = records.removeValue(forKey: existingID) else { + return nil + } + snapshotCache.removeValue(forKey: existingID) + evidenceCache.removeValue(forKey: existingID) + removeProcessIndex(sessionID: existingID) + let realRecord = records[realID] + var merged = realRecord?.draft ?? existing.draft + merged.id = realID + merged.kind = preferredKind(existing: merged.kind, incoming: existing.draft.kind) + merged.phase = existing.draft.phase == .unknown ? merged.phase : existing.draft.phase + merged.surfaceID = merged.surfaceID ?? existing.draft.surfaceID + merged.cwd = merged.cwd.isEmpty ? existing.draft.cwd : merged.cwd + merged.title = merged.title.isEmpty ? existing.draft.title : merged.title + merged.workspaceName = merged.workspaceName.isEmpty ? existing.draft.workspaceName : merged.workspaceName + merged.lastActivityHint = max(merged.lastActivityHint, existing.draft.lastActivityHint) + merged.evidence = mergeEvidence(merged.evidence, existing.draft.evidence) + refine(&merged) + return [.sessionRemoved(existingID)] + commit(merged, floorVersion: max(realRecord?.version.rawValue ?? 0, existing.version.rawValue)) + } + + private func commit(_ draft: SessionRecordDraft, floorVersion: UInt64 = 0) -> [AgentTruthChange] { + if let existing = records[draft.id], existing.draft == draft { + return [] + } + let nextVersion = EntityVersion(rawValue: max(records[draft.id]?.version.rawValue ?? 0, floorVersion) + 1) + records[draft.id] = SessionRecord(draft: draft, version: nextVersion) + let snapshot = draft.snapshot(macDeviceID: macDeviceID, version: nextVersion) + snapshotCache[draft.id] = snapshot + evidenceCache[draft.id] = draft.evidence + updateProcessIndex(sessionID: draft.id, pid: draft.evidence.processIdentity?.pid) + return [.sessionUpserted(snapshot)] + } + + private func identityForProcessObservation(_ observation: ProcessObservation) -> AgentSessionID { + if let existing = matchingID(surfaceID: observation.surfaceID, pid: observation.pid) { + return existing + } + if let transcriptPath = observation.openTranscriptPath { + return AgentSessionID(rawValue: "transcript:\(stableHash(transcriptPath))") + } + if let surfaceID = observation.surfaceID { + return AgentSessionID(rawValue: "prov:\(surfaceID):\(observation.startTick)") + } + return AgentSessionID(rawValue: "process:\(observation.pid):\(observation.startTick)") + } + + private func matchingID(surfaceID: String?, pid: Int32?) -> AgentSessionID? { + guard let surfaceID, let pid, let sessionIDs = sessionIDsByPID[pid] else { + return nil + } + return sessionIDs.first { records[$0]?.draft.surfaceID == surfaceID } + } + + private func updateProcessIndex(sessionID: AgentSessionID, pid: Int32?) { + if let previousPID = pidBySessionID[sessionID], previousPID != pid { + sessionIDsByPID[previousPID]?.remove(sessionID) + if sessionIDsByPID[previousPID]?.isEmpty == true { + sessionIDsByPID.removeValue(forKey: previousPID) + } + pidBySessionID.removeValue(forKey: sessionID) + } + guard let pid else { return } + sessionIDsByPID[pid, default: []].insert(sessionID) + pidBySessionID[sessionID] = pid + } + + private func removeProcessIndex(sessionID: AgentSessionID) { + guard let pid = pidBySessionID.removeValue(forKey: sessionID) else { return } + sessionIDsByPID[pid]?.remove(sessionID) + if sessionIDsByPID[pid]?.isEmpty == true { + sessionIDsByPID.removeValue(forKey: pid) + } + } + + private func preferredKind(existing: AgentKind, incoming: AgentKind) -> AgentKind { + switch incoming { + case .unknown: + return existing + default: + if case .unknown = existing { + return incoming + } + return existing == incoming ? existing : incoming + } + } + + private func applyWrapperLaunch(_ fact: WrapperLaunchFact, tick: Int, to draft: inout SessionRecordDraft) { + draft.kind = preferredKind(existing: draft.kind, incoming: fact.agentKind) + draft.surfaceID = fact.surfaceID + draft.cwd = fact.cwd + draft.title = draft.title.isEmpty ? fact.agentKind.rawValue : draft.title + draft.lastActivityHint = tick + if draft.phase == .ended, fact.launchArgvKind == .resume { + draft.phase = .idle + } + draft.evidence.hasWrapperLaunch = true + bindPid(fact.pid, to: &draft) + if fact.socketWasDown { + draft.evidence.reasons.insert(.launchedWhileSocketDown) + } + applyCapabilityFacts( + hooksUnavailableSafeMode: fact.hooksUnavailableSafeMode, + cliVersion: fact.cliVersion, + minimumCLIVersion: fact.minimumCLIVersion, + to: &draft + ) + } + + private func applyHookEvent(_ fact: HookFact, tick: Int, to draft: inout SessionRecordDraft) { + draft.id = fact.sessionID + draft.surfaceID = draft.surfaceID ?? fact.surfaceID + draft.cwd = fact.cwd ?? draft.cwd + if fact.eventName != .subagentStop { + draft.lastActivityHint = tick + } + draft.evidence.hasHookEvents = true + draft.evidence.transcriptPath = fact.transcriptPath ?? draft.evidence.transcriptPath + if let pid = fact.pid { + bindPid(pid, to: &draft) + } + applyCapabilityFacts( + hooksUnavailableSafeMode: fact.hooksUnavailableSafeMode, + cliVersion: fact.cliVersion, + minimumCLIVersion: fact.minimumCLIVersion, + to: &draft + ) + switch fact.eventName { + case .sessionStart: + draft.phase = .idle + case .userPromptSubmit, .preToolUse, .postToolUse: + draft.phase = .working + case .permissionRequest: + draft.phase = .needsInput + case .notification: + if fact.notificationRequiresInput { + draft.phase = .needsInput + } + case .stop: + draft.phase = .idle + case .subagentStop: + break + case .sessionEnd: + draft.phase = .ended + case .unknown: + if draft.phase == .unknown { + draft.phase = .unknown + } + } + } + + private func bindPid(_ pid: Int32, to draft: inout SessionRecordDraft) { + if let identity = draft.evidence.processIdentity, identity.pid != pid { + draft.evidence.reasons.insert(.evidenceConflict) + } else if draft.evidence.processIdentity == nil { + draft.evidence.processIdentity = ProcessIdentity(pid: pid, startTick: nil) + } + } + + private func applyCapabilityFacts( + hooksUnavailableSafeMode: Bool, + cliVersion: String?, + minimumCLIVersion: String?, + to draft: inout SessionRecordDraft + ) { + if hooksUnavailableSafeMode { + draft.evidence.reasons.insert(.hooksUnavailableSafeMode) + } + if let cliVersion, let minimumCLIVersion, version(cliVersion, isBelow: minimumCLIVersion) { + draft.evidence.reasons.insert(.cliVersionBelowMinimum(found: cliVersion, minimum: minimumCLIVersion)) + } + } + + private func version(_ found: String, isBelow minimum: String) -> Bool { + let foundParts = found.split(separator: ".").map { Int($0) ?? 0 } + let minimumParts = minimum.split(separator: ".").map { Int($0) ?? 0 } + let count = max(foundParts.count, minimumParts.count) + for index in 0 ..< count { + let foundPart = index < foundParts.count ? foundParts[index] : 0 + let minimumPart = index < minimumParts.count ? minimumParts[index] : 0 + if foundPart != minimumPart { + return foundPart < minimumPart + } + } + return false + } + + private func mergeEvidence(_ lhs: DetectionEvidence, _ rhs: DetectionEvidence) -> DetectionEvidence { + var merged = lhs + merged.hasProcessObservation = lhs.hasProcessObservation || rhs.hasProcessObservation + merged.hasWrapperLaunch = lhs.hasWrapperLaunch || rhs.hasWrapperLaunch + merged.hasHookEvents = lhs.hasHookEvents || rhs.hasHookEvents + merged.hasTranscriptCorroboration = lhs.hasTranscriptCorroboration || rhs.hasTranscriptCorroboration + merged.processIdentity = lhs.processIdentity ?? rhs.processIdentity + merged.transcriptPath = lhs.transcriptPath ?? rhs.transcriptPath + merged.reasons.formUnion(rhs.reasons) + return merged + } + + private func stableHash(_ value: String) -> String { + var hash: UInt64 = 14_695_981_039_346_656_037 + for byte in value.utf8 { + hash ^= UInt64(byte) + hash &*= 1_099_511_628_211 + } + return String(hash, radix: 16) + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/CapabilityReason.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/CapabilityReason.swift new file mode 100644 index 000000000000..3e349294e3bd --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/CapabilityReason.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Describes a machine-readable capability limitation reason. +public enum CapabilityReason: Hashable, Sendable { + /// Hooks have not yet contributed evidence for this session. + case hooksNotObserved + /// Hooks are unavailable because the agent or user configuration disables them. + case hooksUnavailableSafeMode + /// The session launched while the cmux socket was unavailable. + case launchedWhileSocketDown + /// The observed CLI version is below a required minimum. + case cliVersionBelowMinimum(found: String, minimum: String) + /// The transcript could not be read by the adapter. + case transcriptNotReadable + /// Multiple evidence channels disagreed about the same session identity. + case evidenceConflict + + /// The localization key that callers can map to user-facing help text. + public var localizationKey: String { + switch self { + case .hooksNotObserved: "agent.capability.hooksNotObserved" + case .hooksUnavailableSafeMode: "agent.capability.hooksUnavailableSafeMode" + case .launchedWhileSocketDown: "agent.capability.launchedWhileSocketDown" + case .cliVersionBelowMinimum: "agent.capability.cliVersionBelowMinimum" + case .transcriptNotReadable: "agent.capability.transcriptNotReadable" + case .evidenceConflict: "agent.capability.evidenceConflict" + } + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/CapabilityReportBuilder.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/CapabilityReportBuilder.swift new file mode 100644 index 000000000000..66c22ce89014 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/CapabilityReportBuilder.swift @@ -0,0 +1,24 @@ +import CmuxAgentReplica +import Foundation + +/// Builds phone-facing capability reports from reducer evidence. +public struct CapabilityReportBuilder: Sendable { + /// Creates a capability report builder. + public init() {} + + /// Builds a capability report for one session's evidence. + /// - Parameter evidence: The session evidence. + /// - Returns: A report describing tier, limitations, and allowed actions. + public func report(for evidence: DetectionEvidence) -> SessionCapabilityReport { + let tier = evidence.tier + var reasons = Array(evidence.reasons).sorted { lhs, rhs in + lhs.localizationKey < rhs.localizationKey + } + if !evidence.hasHookEvents { + reasons.append(.hooksNotObserved) + } + let steerable = tier == .wrapped || tier == .hooked + let answerable = evidence.hasHookEvents && tier != .degraded + return SessionCapabilityReport(tier: tier, reasons: reasons, steerable: steerable, answerable: answerable) + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/DetectionEvidence.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/DetectionEvidence.swift new file mode 100644 index 000000000000..38940b081401 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/DetectionEvidence.swift @@ -0,0 +1,61 @@ +public import CmuxAgentReplica +import Foundation + +/// Records which truth channels have contributed to a session. +public struct DetectionEvidence: Hashable, Sendable { + /// Whether process observation contributed. + public var hasProcessObservation: Bool + /// Whether wrapper launch evidence contributed. + public var hasWrapperLaunch: Bool + /// Whether hook events contributed. + public var hasHookEvents: Bool + /// Whether transcript corroboration contributed. + public var hasTranscriptCorroboration: Bool + /// The process identity currently bound to the session. + public var processIdentity: ProcessIdentity? + /// The transcript path currently associated with the session. + public var transcriptPath: String? + /// Capability and degradation reasons attached to the evidence. + public var reasons: Set + + /// Creates evidence with optional channel contributions. + /// - Parameters: + /// - hasProcessObservation: Whether process observation contributed. + /// - hasWrapperLaunch: Whether wrapper launch evidence contributed. + /// - hasHookEvents: Whether hook events contributed. + /// - hasTranscriptCorroboration: Whether transcript corroboration contributed. + /// - processIdentity: The bound process identity. + /// - transcriptPath: The transcript path. + /// - reasons: Capability and degradation reasons. + public init( + hasProcessObservation: Bool = false, + hasWrapperLaunch: Bool = false, + hasHookEvents: Bool = false, + hasTranscriptCorroboration: Bool = false, + processIdentity: ProcessIdentity? = nil, + transcriptPath: String? = nil, + reasons: Set = [] + ) { + self.hasProcessObservation = hasProcessObservation + self.hasWrapperLaunch = hasWrapperLaunch + self.hasHookEvents = hasHookEvents + self.hasTranscriptCorroboration = hasTranscriptCorroboration + self.processIdentity = processIdentity + self.transcriptPath = transcriptPath + self.reasons = reasons + } + + /// The detection tier implied by the current evidence. + public var tier: DetectionTier { + if reasons.contains(.launchedWhileSocketDown) || reasons.contains(.evidenceConflict) || reasons.contains(.transcriptNotReadable) { + return .degraded + } + if hasWrapperLaunch { + return .wrapped + } + if hasHookEvents { + return .hooked + } + return .observed + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/HookEventName.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/HookEventName.swift new file mode 100644 index 000000000000..a4e479aeb75e --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/HookEventName.swift @@ -0,0 +1,42 @@ +import Foundation + +/// Describes normalized hook event names while preserving unknown raw values. +public enum HookEventName: Hashable, Sendable { + /// The agent announced a session start. + case sessionStart + /// The user submitted a prompt. + case userPromptSubmit + /// A tool is about to run. + case preToolUse + /// A tool finished. + case postToolUse + /// The agent requested permission. + case permissionRequest + /// The agent emitted a notification. + case notification + /// The parent agent completed a response. + case stop + /// A subagent completed a response. + case subagentStop + /// The agent announced session end. + case sessionEnd + /// An unrecognized hook event. + case unknown(String) + + /// Creates a normalized hook event from a raw hook name. + /// - Parameter rawValue: The raw event name. + public init(rawValue: String) { + switch rawValue { + case "SessionStart", "session_start": self = .sessionStart + case "UserPromptSubmit", "user_prompt_submit": self = .userPromptSubmit + case "PreToolUse", "pre_tool_use", "beforeShellExecution": self = .preToolUse + case "PostToolUse", "post_tool_use": self = .postToolUse + case "PermissionRequest", "permission_request": self = .permissionRequest + case "Notification", "notification": self = .notification + case "Stop", "stop": self = .stop + case "SubagentStop", "subagent_stop": self = .subagentStop + case "SessionEnd", "session_end": self = .sessionEnd + default: self = .unknown(rawValue) + } + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/HookFact.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/HookFact.swift new file mode 100644 index 000000000000..06eef3c18643 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/HookFact.swift @@ -0,0 +1,62 @@ +public import CmuxAgentReplica +import Foundation + +/// Captures one hook event emitted by an agent integration. +public struct HookFact: Hashable, Sendable { + /// The agent session id carried by the hook. + public let sessionID: AgentSessionID + /// The normalized hook event name. + public let eventName: HookEventName + /// The stable cmux surface id, when carried by the hook. + public let surfaceID: String? + /// The transcript path, when carried by the hook. + public let transcriptPath: String? + /// The working directory, when carried by the hook. + public let cwd: String? + /// The process identifier, when carried by the hook. + public let pid: Int32? + /// Whether a notification represents an ask that needs user input. + public let notificationRequiresInput: Bool + /// Whether this hook reports that hooks are explicitly unavailable by safe mode. + public let hooksUnavailableSafeMode: Bool + /// The observed CLI version, when the hook can supply it. + public let cliVersion: String? + /// The minimum CLI version required for full capability, when known. + public let minimumCLIVersion: String? + + /// Creates a hook fact. + /// - Parameters: + /// - sessionID: The session id. + /// - eventName: The normalized hook event. + /// - surfaceID: The surface id, when known. + /// - transcriptPath: The transcript path, when known. + /// - cwd: The working directory, when known. + /// - pid: The process identifier, when known. + /// - notificationRequiresInput: Whether a notification is an ask. + /// - hooksUnavailableSafeMode: Whether hooks are explicitly unavailable by safe mode. + /// - cliVersion: The observed CLI version. + /// - minimumCLIVersion: The minimum CLI version for full capability. + public init( + sessionID: AgentSessionID, + eventName: HookEventName, + surfaceID: String?, + transcriptPath: String?, + cwd: String?, + pid: Int32?, + notificationRequiresInput: Bool = false, + hooksUnavailableSafeMode: Bool = false, + cliVersion: String? = nil, + minimumCLIVersion: String? = nil + ) { + self.sessionID = sessionID + self.eventName = eventName + self.surfaceID = surfaceID + self.transcriptPath = transcriptPath + self.cwd = cwd + self.pid = pid + self.notificationRequiresInput = notificationRequiresInput + self.hooksUnavailableSafeMode = hooksUnavailableSafeMode + self.cliVersion = cliVersion + self.minimumCLIVersion = minimumCLIVersion + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/LaunchArgvKind.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/LaunchArgvKind.swift new file mode 100644 index 000000000000..809b4f89ea72 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/LaunchArgvKind.swift @@ -0,0 +1,11 @@ +import Foundation + +/// Describes which command-line entrypoint started the agent process. +public enum LaunchArgvKind: String, Hashable, Sendable { + /// A new interactive session launch. + case new + /// A resume invocation for an existing session. + case resume + /// A non-interactive execution invocation. + case exec +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/ProcessIdentity.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/ProcessIdentity.swift new file mode 100644 index 000000000000..5d8a607353fd --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/ProcessIdentity.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Identifies a process by pid and optional start identity so pid reuse is safe when known. +public struct ProcessIdentity: Hashable, Sendable { + /// The process identifier. + public let pid: Int32 + /// The process start identity tick, when supplied by process observation. + public let startTick: Int? + + /// Creates a process identity. + /// - Parameters: + /// - pid: The process identifier. + /// - startTick: The process start identity tick, when known. + public init(pid: Int32, startTick: Int?) { + self.pid = pid + self.startTick = startTick + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/ProcessObservation.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/ProcessObservation.swift new file mode 100644 index 000000000000..f93e77197bb5 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/ProcessObservation.swift @@ -0,0 +1,52 @@ +public import CmuxAgentReplica +import Foundation + +/// Captures one process-observation sample supplied by the app adapter. +public struct ProcessObservation: Hashable, Sendable { + /// The observed process identifier. + public let pid: Int32 + /// The observed parent process identifier. + public let ppid: Int32 + /// The process start identity tick used to distinguish reused pids. + public let startTick: Int + /// A short argv summary for display and diagnostics. + public let argvSummary: String + /// The best-effort agent kind inferred from process metadata. + public let agentKindGuess: AgentKind + /// The observed current working directory. + public let cwd: String + /// The stable cmux surface id, when known. + public let surfaceID: String? + /// The transcript path held open by the process, when known. + public let openTranscriptPath: String? + + /// Creates a process observation. + /// - Parameters: + /// - pid: The process identifier. + /// - ppid: The parent process identifier. + /// - startTick: The process start identity tick. + /// - argvSummary: A short argv summary. + /// - agentKindGuess: The inferred agent kind. + /// - cwd: The current working directory. + /// - surfaceID: The stable cmux surface id, when known. + /// - openTranscriptPath: The transcript path held open by the process, when known. + public init( + pid: Int32, + ppid: Int32, + startTick: Int, + argvSummary: String, + agentKindGuess: AgentKind, + cwd: String, + surfaceID: String?, + openTranscriptPath: String? + ) { + self.pid = pid + self.ppid = ppid + self.startTick = startTick + self.argvSummary = argvSummary + self.agentKindGuess = agentKindGuess + self.cwd = cwd + self.surfaceID = surfaceID + self.openTranscriptPath = openTranscriptPath + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/SessionCapabilityReport.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/SessionCapabilityReport.swift new file mode 100644 index 000000000000..fa907b57c947 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/SessionCapabilityReport.swift @@ -0,0 +1,27 @@ +public import CmuxAgentReplica +import Foundation + +/// Summarizes what cmux can safely do with a detected session. +public struct SessionCapabilityReport: Hashable, Sendable { + /// The session's current detection tier. + public let tier: DetectionTier + /// Machine-readable reasons for any capability limits. + public let reasons: [CapabilityReason] + /// Whether cmux can steer the session by injecting input. + public let steerable: Bool + /// Whether cmux can answer pending questions or permissions. + public let answerable: Bool + + /// Creates a session capability report. + /// - Parameters: + /// - tier: The detection tier. + /// - reasons: Machine-readable limitation reasons. + /// - steerable: Whether the session is steerable. + /// - answerable: Whether pending asks are answerable. + public init(tier: DetectionTier, reasons: [CapabilityReason], steerable: Bool, answerable: Bool) { + self.tier = tier + self.reasons = reasons + self.steerable = steerable + self.answerable = answerable + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/SessionRecord.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/SessionRecord.swift new file mode 100644 index 000000000000..7c87db26f03c --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/SessionRecord.swift @@ -0,0 +1,16 @@ +import CmuxAgentReplica +import Foundation + +struct SessionRecord: Hashable, Sendable { + var draft: SessionRecordDraft + var version: EntityVersion + + init(draft: SessionRecordDraft, version: EntityVersion) { + self.draft = draft + self.version = version + } + + func snapshot(macDeviceID: MacDeviceID) -> AgentSessionSnapshot { + draft.snapshot(macDeviceID: macDeviceID, version: version) + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/SessionRecordDraft.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/SessionRecordDraft.swift new file mode 100644 index 000000000000..014326bda36d --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/SessionRecordDraft.swift @@ -0,0 +1,52 @@ +import CmuxAgentReplica +import Foundation + +struct SessionRecordDraft: Hashable, Sendable { + var id: AgentSessionID + var kind: AgentKind + var phase: SessionPhase + var surfaceID: String? + var cwd: String + var title: String + var workspaceName: String + var lastActivityHint: Int + var evidence: DetectionEvidence + + init( + id: AgentSessionID, + kind: AgentKind, + phase: SessionPhase, + surfaceID: String?, + cwd: String, + title: String, + workspaceName: String, + lastActivityHint: Int, + evidence: DetectionEvidence + ) { + self.id = id + self.kind = kind + self.phase = phase + self.surfaceID = surfaceID + self.cwd = cwd + self.title = title + self.workspaceName = workspaceName + self.lastActivityHint = lastActivityHint + self.evidence = evidence + } + + func snapshot(macDeviceID: MacDeviceID, version: EntityVersion) -> AgentSessionSnapshot { + AgentSessionSnapshot( + id: id, + macDeviceID: macDeviceID, + kind: kind, + phase: phase, + tier: evidence.tier, + surfaceID: surfaceID, + cwd: cwd, + title: title, + workspaceName: workspaceName, + version: version, + lastActivityHint: lastActivityHint + ) + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/TranscriptCorroborationFact.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/TranscriptCorroborationFact.swift new file mode 100644 index 000000000000..690e838dbff2 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/TranscriptCorroborationFact.swift @@ -0,0 +1,9 @@ +import Foundation + +/// Describes transcript-derived lifecycle facts used to heal missed hooks. +public enum TranscriptCorroborationFact: Hashable, Sendable { + /// An assistant turn completed in the transcript. + case assistantTurnCompleted + /// A user-authored message was appended to the transcript. + case userMessageAppended +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/TruthChannelSignal.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/TruthChannelSignal.swift new file mode 100644 index 000000000000..2305be24bbe4 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/TruthChannelSignal.swift @@ -0,0 +1,16 @@ +public import CmuxAgentReplica +import Foundation + +/// Represents one observation from exactly one truth channel. +public enum TruthChannelSignal: Hashable, Sendable { + /// A process observer saw a candidate agent process. + case processObserved(ProcessObservation, tick: Int) + /// A process observer saw a previously identified process exit. + case processGone(pid: Int32, startTick: Int, tick: Int) + /// A cmux wrapper reported a launch. + case wrapperLaunched(WrapperLaunchFact, tick: Int) + /// An agent hook emitted a lifecycle or attention event. + case hookEvent(HookFact, tick: Int) + /// Transcript parsing corroborated a lifecycle fact. + case transcriptCorroboration(sessionID: AgentSessionID, fact: TranscriptCorroborationFact, tick: Int) +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/WrapperLaunchFact.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/WrapperLaunchFact.swift new file mode 100644 index 000000000000..f8c7abebd2b4 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Detection/WrapperLaunchFact.swift @@ -0,0 +1,66 @@ +public import CmuxAgentReplica +import Foundation + +/// Captures one launch observation produced by a cmux wrapper. +public struct WrapperLaunchFact: Hashable, Sendable { + /// The stable cmux surface id that launched the process. + public let surfaceID: String + /// The launched agent kind. + public let agentKind: AgentKind + /// The process identifier that will become the real agent process after `exec`. + public let pid: Int32 + /// The launch working directory. + public let cwd: String + /// The real or wrapper-minted session id, when the wrapper knows one. + public let sessionID: AgentSessionID? + /// The launch argv category. + public let launchArgvKind: LaunchArgvKind + /// Whether the wrapper observed the cmux socket as unavailable at launch. + public let socketWasDown: Bool + /// Whether the wrapper knows hooks are explicitly unavailable by safe mode. + public let hooksUnavailableSafeMode: Bool + /// The observed CLI version, when the wrapper can supply it. + public let cliVersion: String? + /// The minimum CLI version required for full capability, when known. + public let minimumCLIVersion: String? + + /// Creates a wrapper launch fact. + /// + /// If ``sessionID`` is absent, the reducer may create a wrapper-path provisional + /// id using the fold tick because wrappers do not carry a process start tick. + /// + /// - Parameters: + /// - surfaceID: The stable cmux surface id. + /// - agentKind: The agent kind. + /// - pid: The process identifier. + /// - cwd: The launch working directory. + /// - sessionID: The session id, when known. + /// - launchArgvKind: The launch argv category. + /// - socketWasDown: Whether the wrapper reported a socket-down launch. + /// - hooksUnavailableSafeMode: Whether hooks are explicitly unavailable by safe mode. + /// - cliVersion: The observed CLI version. + /// - minimumCLIVersion: The minimum CLI version for full capability. + public init( + surfaceID: String, + agentKind: AgentKind, + pid: Int32, + cwd: String, + sessionID: AgentSessionID?, + launchArgvKind: LaunchArgvKind, + socketWasDown: Bool = false, + hooksUnavailableSafeMode: Bool = false, + cliVersion: String? = nil, + minimumCLIVersion: String? = nil + ) { + self.surfaceID = surfaceID + self.agentKind = agentKind + self.pid = pid + self.cwd = cwd + self.sessionID = sessionID + self.launchArgvKind = launchArgvKind + self.socketWasDown = socketWasDown + self.hooksUnavailableSafeMode = hooksUnavailableSafeMode + self.cliVersion = cliVersion + self.minimumCLIVersion = minimumCLIVersion + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Journal/JournalDecision.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Journal/JournalDecision.swift new file mode 100644 index 000000000000..e471bd4fc56a --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Journal/JournalDecision.swift @@ -0,0 +1,10 @@ +public import CmuxAgentReplica +import Foundation + +/// Describes the journal id decision for a successive transcript identity. +public enum JournalDecision: Hashable, Sendable { + /// The transcript identity still belongs to the existing journal. + case same(JournalID) + /// The transcript identity requires a newly minted journal. + case created(JournalID) +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Journal/JournalIdentity.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Journal/JournalIdentity.swift new file mode 100644 index 000000000000..bb8188ff892c --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Journal/JournalIdentity.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Captures the stable identity inputs for a transcript file. +public struct JournalIdentity: Hashable, Sendable { + /// The transcript path. + public let path: String + /// An adapter-supplied inode-like token. + public let inodeLikeToken: String + /// Whether the file head has been truncated or compacted. + public let headTruncated: Bool + + /// Creates a journal identity value. + /// - Parameters: + /// - path: The transcript path. + /// - inodeLikeToken: The inode-like token. + /// - headTruncated: Whether the file head was truncated. + public init(path: String, inodeLikeToken: String, headTruncated: Bool) { + self.path = path + self.inodeLikeToken = inodeLikeToken + self.headTruncated = headTruncated + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Journal/JournalMinter.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Journal/JournalMinter.swift new file mode 100644 index 000000000000..f269a86191e6 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/CmuxAgentTruthKit/Journal/JournalMinter.swift @@ -0,0 +1,37 @@ +public import CmuxAgentReplica +import Foundation + +/// Decides whether successive transcript identities reuse or mint journal ids. +public struct JournalMinter: Sendable { + private var generation: UInt64 + + /// Creates a journal minter. + public init() { + self.generation = 0 + } + + /// Decides whether a new transcript identity should reuse the current journal. + /// - Parameters: + /// - previous: The prior transcript identity, when one exists. + /// - current: The current transcript identity. + /// - currentJournalID: The existing journal id, when one exists. + /// - Returns: A same-or-new journal decision. + public mutating func decide( + previous: JournalIdentity?, + current: JournalIdentity, + currentJournalID: JournalID? + ) -> JournalDecision { + guard let previous, let currentJournalID else { + return .created(mint(for: current)) + } + if previous == current { + return .same(currentJournalID) + } + return .created(mint(for: current)) + } + + private mutating func mint(for _: JournalIdentity) -> JournalID { + generation += 1 + return JournalID(rawValue: "journal:\(generation)") + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/agent-transcript-harvest/AgentTranscriptHarvestCommand.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/agent-transcript-harvest/AgentTranscriptHarvestCommand.swift new file mode 100644 index 000000000000..eee2bc822a2d --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/agent-transcript-harvest/AgentTranscriptHarvestCommand.swift @@ -0,0 +1,26 @@ +import CmuxAgentTranscriptHarvest +import Foundation + +@main +struct AgentTranscriptHarvestCommand { + static func main() { + do { + let arguments = try TranscriptHarvestArguments.parse(CommandLine.arguments) + let scanner = TranscriptHarvestScanner() + let result = scanner.scan( + claudeRoot: arguments.claudeRoot, + codexRoot: arguments.codexRoot, + maxFiles: arguments.maxFiles, + modifiedSince: arguments.modifiedSince + ) + let formatter = TranscriptHarvestFormatter() + let output = try arguments.format == .json ? formatter.json(result) : formatter.tsv(result) + FileHandle.standardOutput.write(Data((output + "\n").utf8)) + } catch TranscriptHarvestArgumentError.help { + FileHandle.standardOutput.write(Data((TranscriptHarvestArgumentError.help.description + "\n").utf8)) + } catch { + FileHandle.standardError.write(Data("agent-transcript-harvest: \(error)\n".utf8)) + Foundation.exit(2) + } + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/agent-transcript-harvest/Optional+Throwing.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/agent-transcript-harvest/Optional+Throwing.swift new file mode 100644 index 000000000000..f8f77dc8b9e2 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/agent-transcript-harvest/Optional+Throwing.swift @@ -0,0 +1,10 @@ +import Foundation + +extension Optional { + func orThrow(_ error: any Error) throws -> Wrapped { + guard let self else { + throw error + } + return self + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/agent-transcript-harvest/TranscriptHarvestArgumentError.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/agent-transcript-harvest/TranscriptHarvestArgumentError.swift new file mode 100644 index 000000000000..45b44f995564 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/agent-transcript-harvest/TranscriptHarvestArgumentError.swift @@ -0,0 +1,24 @@ +import Foundation + +enum TranscriptHarvestArgumentError: Error, CustomStringConvertible { + case help + case invalidValue(String) + case missingValue(String) + case unknownFlag(String) + + var description: String { + switch self { + case .help: + """ + usage: agent-transcript-harvest [--claude-root PATH] [--codex-root PATH] [--max-files N] [--format tsv|json] [--recent-days N] + --max-files applies per source. DECODER-GAP means decoders counted >= 1 occurrence as unknown; exact counts are in decoder_unknown rows. + """ + case .invalidValue(let flag): + "invalid value for \(flag)" + case .missingValue(let flag): + "missing value after \(flag)" + case .unknownFlag(let flag): + "unknown flag \(flag)" + } + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/agent-transcript-harvest/TranscriptHarvestArgumentParser.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/agent-transcript-harvest/TranscriptHarvestArgumentParser.swift new file mode 100644 index 000000000000..27ee7a99ad9b --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/agent-transcript-harvest/TranscriptHarvestArgumentParser.swift @@ -0,0 +1,68 @@ +import Foundation + +struct TranscriptHarvestArgumentParser { + private var arguments: [String] + + init(arguments: [String]) { + self.arguments = arguments + } + + mutating func parse() throws -> TranscriptHarvestArguments { + var claudeRoot = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".claude") + .appendingPathComponent("projects") + var codexRoot = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codex") + .appendingPathComponent("sessions") + var maxFiles: Int? + var format = TranscriptHarvestOutputFormat.tsv + var recentDays: Int? + + while !arguments.isEmpty { + let flag = arguments.removeFirst() + switch flag { + case "--claude-root": + claudeRoot = URL(fileURLWithPath: try value(after: flag), isDirectory: true) + case "--codex-root": + codexRoot = URL(fileURLWithPath: try value(after: flag), isDirectory: true) + case "--max-files": + maxFiles = try positiveInteger(after: flag) + case "--format": + format = try TranscriptHarvestOutputFormat(rawValue: value(after: flag)) + .orThrow(TranscriptHarvestArgumentError.invalidValue(flag)) + case "--recent-days": + recentDays = try positiveInteger(after: flag) + case "--help", "-h": + throw TranscriptHarvestArgumentError.help + default: + throw TranscriptHarvestArgumentError.unknownFlag(flag) + } + } + + let modifiedSince = recentDays.map { + Date().addingTimeInterval(-Double($0) * 24 * 60 * 60) + } + return TranscriptHarvestArguments( + claudeRoot: claudeRoot, + codexRoot: codexRoot, + maxFiles: maxFiles, + format: format, + modifiedSince: modifiedSince + ) + } + + private mutating func value(after flag: String) throws -> String { + guard !arguments.isEmpty else { + throw TranscriptHarvestArgumentError.missingValue(flag) + } + return arguments.removeFirst() + } + + private mutating func positiveInteger(after flag: String) throws -> Int { + let rawValue = try value(after: flag) + guard let value = Int(rawValue), value >= 0 else { + throw TranscriptHarvestArgumentError.invalidValue(flag) + } + return value + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/agent-transcript-harvest/TranscriptHarvestArguments.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/agent-transcript-harvest/TranscriptHarvestArguments.swift new file mode 100644 index 000000000000..74207ad50513 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/agent-transcript-harvest/TranscriptHarvestArguments.swift @@ -0,0 +1,14 @@ +import Foundation + +struct TranscriptHarvestArguments { + var claudeRoot: URL + var codexRoot: URL + var maxFiles: Int? + var format: TranscriptHarvestOutputFormat + var modifiedSince: Date? + + static func parse(_ arguments: [String]) throws -> TranscriptHarvestArguments { + var parser = TranscriptHarvestArgumentParser(arguments: Array(arguments.dropFirst())) + return try parser.parse() + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Sources/agent-transcript-harvest/TranscriptHarvestOutputFormat.swift b/Packages/macOS/CmuxAgentTruthKit/Sources/agent-transcript-harvest/TranscriptHarvestOutputFormat.swift new file mode 100644 index 000000000000..98180d30c989 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Sources/agent-transcript-harvest/TranscriptHarvestOutputFormat.swift @@ -0,0 +1,6 @@ +import Foundation + +enum TranscriptHarvestOutputFormat: String { + case tsv + case json +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTranscriptHarvestTests/TemporaryHarvestFixture.swift b/Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTranscriptHarvestTests/TemporaryHarvestFixture.swift new file mode 100644 index 000000000000..01228219f0e5 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTranscriptHarvestTests/TemporaryHarvestFixture.swift @@ -0,0 +1,30 @@ +import Foundation + +struct TemporaryHarvestFixture { + let root: URL + let claudeRoot: URL + let codexRoot: URL + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("CmuxAgentTruthKitTests") + .appendingPathComponent(UUID().uuidString) + claudeRoot = root.appendingPathComponent("claude", isDirectory: true) + codexRoot = root.appendingPathComponent("codex", isDirectory: true) + try FileManager.default.createDirectory(at: claudeRoot, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: codexRoot, withIntermediateDirectories: true) + } + + func writeClaudeFile(named name: String, lines: [String]) throws { + try writeFile(root: claudeRoot, named: name, lines: lines) + } + + func writeCodexFile(named name: String, lines: [String]) throws { + try writeFile(root: codexRoot, named: name, lines: lines) + } + + private func writeFile(root: URL, named name: String, lines: [String]) throws { + let url = root.appendingPathComponent(name) + try lines.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8) + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTranscriptHarvestTests/TranscriptHarvestScannerTests.swift b/Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTranscriptHarvestTests/TranscriptHarvestScannerTests.swift new file mode 100644 index 000000000000..ab2484cddcfa --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTranscriptHarvestTests/TranscriptHarvestScannerTests.swift @@ -0,0 +1,62 @@ +import Foundation +@testable import CmuxAgentTranscriptHarvest +import Testing + +@Suite +struct TranscriptHarvestScannerTests { + @Test + func walksJsonlFixturesAndStreamsIntoInventory() throws { + let root = try TemporaryHarvestFixture() + try root.writeClaudeFile( + named: "claude.jsonl", + lines: [ + #"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash"}]}}"#, + #"{"type":"user","message":{"content":[{"type":"text","text":"SECRET_MARKER_SHOULD_NOT_LEAK"}]}}"#, + ] + ) + try root.writeCodexFile( + named: "codex.jsonl", + lines: [ + #"{"type":"session_meta","payload":{"cli_version":"0.140.0"}}"#, + #"{"type":"event_msg","payload":{"type":"task_started"}}"#, + ] + ) + + let result = TranscriptHarvestScanner(fileManager: .default).scan( + claudeRoot: root.claudeRoot, + codexRoot: root.codexRoot, + maxFiles: nil, + modifiedSince: nil + ) + + let rendered = TranscriptHarvestFormatter().tsv(result) + #expect(rendered.contains("claude\trecord_type\tassistant\t1")) + #expect(rendered.contains("claude\ttool_name\tBash\t1")) + #expect(rendered.contains("codex\tcli_version\t0.140.0\t1")) + #expect(rendered.contains("codex\tevent_msg_type\ttask_started\t1\t")) + #expect(rendered.contains("claude\tsummary\tfiles_scanned\t1")) + #expect(rendered.contains("codex\tsummary\tlines_scanned\t2")) + #expect(!rendered.contains("SECRET_MARKER_SHOULD_NOT_LEAK")) + // Nothing in this fixture is unknown to the decoders, so no row may + // carry the gap marker; modeled skips regressing to DECODER-GAP fails here. + #expect(!rendered.contains("DECODER-GAP")) + } + + @Test + func maxFilesLimitsEachSource() throws { + let root = try TemporaryHarvestFixture() + try root.writeClaudeFile(named: "a.jsonl", lines: [#"{"type":"assistant"}"#]) + try root.writeClaudeFile(named: "b.jsonl", lines: [#"{"type":"assistant"}"#]) + try root.writeCodexFile(named: "c.jsonl", lines: [#"{"type":"session_meta","payload":{"cli_version":"0.140.0"}}"#]) + + let result = TranscriptHarvestScanner(fileManager: .default).scan( + claudeRoot: root.claudeRoot, + codexRoot: root.codexRoot, + maxFiles: 2, + modifiedSince: nil + ) + + #expect(result.summaries[.claude]?.filesScanned == 2) + #expect(result.summaries[.codex]?.filesScanned == 1) + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTranscriptHarvestTests/TranscriptShapeInventoryTests.swift b/Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTranscriptHarvestTests/TranscriptShapeInventoryTests.swift new file mode 100644 index 000000000000..6dfb86703186 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTranscriptHarvestTests/TranscriptShapeInventoryTests.swift @@ -0,0 +1,100 @@ +import Foundation +import CmuxAgentTruthKit +@testable import CmuxAgentTranscriptHarvest +import Testing + +@Suite +struct TranscriptShapeInventoryTests { + @Test + func extractsClaudeShapeFactsWithoutContent() { + let plantedUserText = "SECRET_MARKER_SHOULD_NOT_LEAK" + let line = """ + {"type":"assistant","message":{"content":[{"type":"text","text":"\(plantedUserText)"},{"type":"tool_use","name":"Bash","input":{"command":"\(plantedUserText)"}},{"type":"future_block","text":"\(plantedUserText)"}]},"isSidechain":true,"futureKey":"\(plantedUserText)"} + """ + var inventory = TranscriptShapeInventory() + inventory.feed(source: .claude, rawLine: line) + + let rendered = renderedRows(inventory.rows()) + #expect(rendered.contains("claude|record_type|assistant|1")) + #expect(rendered.contains("claude|block_type|text|1")) + #expect(rendered.contains("claude|block_type|tool_use|1")) + #expect(rendered.contains("claude|block_type|future_block|1")) + #expect(rendered.contains("claude|tool_name|Bash|1")) + #expect(rendered.contains("claude|flag|isSidechain|1")) + #expect(rendered.contains("claude|unfamiliar_top_level_key|futureKey|1")) + #expect(!rendered.contains(plantedUserText)) + } + + @Test + func extractsCodexShapeFactsAndSanitizesNonIdentifiers() { + let plantedUserText = "SECRET_MARKER_SHOULD_NOT_LEAK" + let lines = [ + #"{"type":"session_meta","payload":{"cli_version":"0.140.0","cwd":"/private/secret"}}"#, + #"{"type":"response_item","payload":{"type":"function_call","name":"shell","arguments":"{\"cmd\":\"SECRET_MARKER_SHOULD_NOT_LEAK\"}"}}"#, + #"{"type":"response_item","payload":{"type":"custom_tool_call","name":"bad/name","input":"SECRET_MARKER_SHOULD_NOT_LEAK"}}"#, + #"{"type":"event_msg","payload":{"type":"task_started","note":"SECRET_MARKER_SHOULD_NOT_LEAK"}}"#, + ] + var inventory = TranscriptShapeInventory() + for line in lines { + inventory.feed(source: .codex, rawLine: line) + } + + let rendered = renderedRows(inventory.rows()) + #expect(rendered.contains("codex|record_type|session_meta|1")) + #expect(rendered.contains("codex|record_type|response_item|2")) + #expect(rendered.contains("codex|payload_type|function_call|1")) + #expect(rendered.contains("codex|payload_type|custom_tool_call|1")) + #expect(rendered.contains("codex|function_call_name|shell|1")) + #expect(rendered.contains("codex|function_call_name|non_identifier|1")) + #expect(rendered.contains("codex|event_msg_type|task_started|1")) + #expect(rendered.contains("codex|cli_version|0.140.0|1")) + #expect(!rendered.contains(plantedUserText)) + #expect(!rendered.contains("/private/secret")) + } + + @Test + func claudeDecoderGapMarkersUseRecoveredOriginDimension() { + var recordGap = TranscriptDecoderGapInventory() + recordGap.record( + source: .claude, + rawLine: #"{"type":"future_kind","message":{"content":[{"type":"text","text":"ignored"}]}}"#, + diagnostics: TranscriptDecoderDiagnostics(unknownKindCounts: ["future_kind": 1]) + ) + #expect(recordGap.contains(source: .claude, dimension: "record_type", value: "future_kind")) + #expect(!recordGap.contains(source: .claude, dimension: "block_type", value: "future_kind")) + + var blockGap = TranscriptDecoderGapInventory() + blockGap.record( + source: .claude, + rawLine: #"{"type":"assistant","message":{"content":[{"type":"future_kind","text":"ignored"}]}}"#, + diagnostics: TranscriptDecoderDiagnostics(unknownKindCounts: ["future_kind": 1]) + ) + #expect(!blockGap.contains(source: .claude, dimension: "record_type", value: "future_kind")) + #expect(blockGap.contains(source: .claude, dimension: "block_type", value: "future_kind")) + } + + @Test + func codexDecoderGapMarkersUseRecoveredOriginDimension() { + var recordGap = TranscriptDecoderGapInventory() + recordGap.record( + source: .codex, + rawLine: #"{"type":"future_record","payload":{"cwd":"/tmp/example"}}"#, + diagnostics: TranscriptDecoderDiagnostics(unknownKindCounts: ["future_record": 1]) + ) + #expect(recordGap.contains(source: .codex, dimension: "record_type", value: "future_record")) + #expect(!recordGap.contains(source: .codex, dimension: "payload_type", value: "future_record")) + + var payloadGap = TranscriptDecoderGapInventory() + payloadGap.record( + source: .codex, + rawLine: #"{"type":"response_item","payload":{"type":"future_item","query":"ignored"}}"#, + diagnostics: TranscriptDecoderDiagnostics(unknownKindCounts: ["future_item": 1]) + ) + #expect(!payloadGap.contains(source: .codex, dimension: "record_type", value: "future_item")) + #expect(payloadGap.contains(source: .codex, dimension: "payload_type", value: "future_item")) + } + + private func renderedRows(_ rows: [TranscriptShapeRow]) -> String { + rows.map { "\($0.source.rawValue)|\($0.dimension)|\($0.value)|\($0.count)" }.joined(separator: "\n") + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTruthKitTests/AgentTruthReducerTests.swift b/Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTruthKitTests/AgentTruthReducerTests.swift new file mode 100644 index 000000000000..e3a9e8d6a001 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTruthKitTests/AgentTruthReducerTests.swift @@ -0,0 +1,206 @@ +import CmuxAgentReplica +@testable import CmuxAgentTruthKit +import Testing + +@Suite(.serialized) +@MainActor +struct AgentTruthReducerTests { + @Test + func phaseRulesAndHealing() { + let reducer = AgentTruthReducer(macDeviceID: MacDeviceID(rawValue: "mac")) + let sessionID = AgentSessionID(rawValue: "real-session") + + let observed = reducer.fold(.processObserved(process(surface: "surface-a", startTick: 10), tick: 1)) + #expect(upsertedPhases(observed) == [.starting]) + + let start = reducer.fold(.hookEvent(hook(sessionID: sessionID, eventName: .sessionStart), tick: 2)) + #expect(start.contains(.sessionRemoved(AgentSessionID(rawValue: "prov:surface-a:10")))) + #expect(lastUpsert(start)?.phase == .idle) + + #expect(lastUpsert(reducer.fold(.hookEvent(hook(sessionID: sessionID, eventName: .userPromptSubmit), tick: 3)))?.phase == .working) + #expect(lastUpsert(reducer.fold(.hookEvent(hook(sessionID: sessionID, eventName: .permissionRequest), tick: 4)))?.phase == .needsInput) + #expect(lastUpsert(reducer.fold(.hookEvent(hook(sessionID: sessionID, eventName: .notification, notificationRequiresInput: true), tick: 5)))?.phase == .needsInput) + #expect(lastUpsert(reducer.fold(.hookEvent(hook(sessionID: sessionID, eventName: .stop), tick: 6)))?.phase == .idle) + #expect(lastUpsert(reducer.fold(.hookEvent(hook(sessionID: sessionID, eventName: .notification, notificationRequiresInput: false), tick: 7)))?.phase == .idle) + + let beforeSubagent = reducer.snapshots[sessionID]?.version + #expect(reducer.fold(.hookEvent(hook(sessionID: sessionID, eventName: .subagentStop), tick: 8)).isEmpty) + #expect(reducer.snapshots[sessionID]?.version == beforeSubagent) + + #expect(lastUpsert(reducer.fold(.hookEvent(hook(sessionID: sessionID, eventName: .userPromptSubmit), tick: 9)))?.phase == .working) + #expect(lastUpsert(reducer.fold(.transcriptCorroboration(sessionID: sessionID, fact: .assistantTurnCompleted, tick: 10)))?.phase == .idle) + #expect(lastUpsert(reducer.fold(.transcriptCorroboration(sessionID: sessionID, fact: .userMessageAppended, tick: 11)))?.phase == .working) + #expect(lastUpsert(reducer.fold(.hookEvent(hook(sessionID: sessionID, eventName: .sessionEnd), tick: 12)))?.phase == .ended) + #expect(lastUpsert(reducer.fold(.processGone(pid: 100, startTick: 10, tick: 13)))?.phase == .ended) + #expect(lastUpsert(reducer.fold(.processObserved(process(surface: "surface-a", startTick: 10), tick: 14)))?.phase == .idle) + #expect(lastUpsert(reducer.fold(.wrapperLaunched(wrapper(sessionID: sessionID, launchArgvKind: .resume), tick: 15)))?.phase == .idle) + } + + @Test + func provisionalMergePreservesVersionContinuityAndSurfaceBoundary() { + let reducer = AgentTruthReducer(macDeviceID: MacDeviceID(rawValue: "mac")) + let real = AgentSessionID(rawValue: "real") + let provisional = AgentSessionID(rawValue: "prov:surface-a:20") + + _ = reducer.fold(.processObserved(process(surface: "surface-a", startTick: 20), tick: 1)) + #expect(reducer.snapshots[provisional]?.version == EntityVersion(rawValue: 1)) + + let wrongSurface = reducer.fold(.hookEvent(hook(sessionID: real, eventName: .sessionStart, surfaceID: "surface-b"), tick: 2)) + #expect(!wrongSurface.contains(.sessionRemoved(provisional))) + #expect(reducer.snapshots[provisional] != nil) + + let merge = reducer.fold(.hookEvent(hook(sessionID: real, eventName: .sessionStart), tick: 3)) + #expect(merge.contains(.sessionRemoved(provisional))) + #expect(merge.filter { if case .sessionUpserted = $0 { true } else { false } }.count == 1) + #expect(reducer.snapshots[provisional] == nil) + #expect(reducer.snapshots[real]?.version == EntityVersion(rawValue: 2)) + } + + @Test + func sameTickMergeEmitsUpsertEvenWhenHookRefinementNoOps() { + let reducer = AgentTruthReducer(macDeviceID: MacDeviceID(rawValue: "mac")) + let real = AgentSessionID(rawValue: "real") + let provisional = AgentSessionID(rawValue: "prov:surface-a:50") + + _ = reducer.fold(.hookEvent(hook(sessionID: real, eventName: .sessionStart, pid: nil), tick: 1)) + _ = reducer.fold(.processObserved(process(surface: "surface-a", startTick: 50), tick: 2)) + let changes = reducer.fold(.hookEvent(hook(sessionID: real, eventName: .sessionStart), tick: 1)) + + #expect(changes.contains(.sessionRemoved(provisional))) + #expect(lastUpsert(changes)?.id == real) + #expect(lastUpsert(changes)?.version == EntityVersion(rawValue: 2)) + #expect(reducer.evidence[real]?.hasProcessObservation == true) + } + + @Test + func tierTransitionsAndNoOpFolds() { + let reducer = AgentTruthReducer(macDeviceID: MacDeviceID(rawValue: "mac")) + let sessionID = AgentSessionID(rawValue: "real") + + _ = reducer.fold(.processObserved(process(surface: "surface-a", startTick: 30), tick: 1)) + #expect(reducer.snapshots[AgentSessionID(rawValue: "prov:surface-a:30")]?.tier == .observed) + + _ = reducer.fold(.wrapperLaunched(wrapper(sessionID: sessionID), tick: 2)) + #expect(reducer.snapshots[sessionID]?.tier == .wrapped) + + let version = reducer.snapshots[sessionID]?.version + #expect(reducer.fold(.wrapperLaunched(wrapper(sessionID: sessionID), tick: 2)).isEmpty) + #expect(reducer.snapshots[sessionID]?.version == version) + + _ = reducer.fold(.wrapperLaunched(wrapper(sessionID: sessionID, socketWasDown: true), tick: 3)) + #expect(reducer.snapshots[sessionID]?.tier == .degraded) + + let capabilityID = AgentSessionID(rawValue: "capability") + _ = reducer.fold(.wrapperLaunched(wrapper( + sessionID: capabilityID, + cliVersion: "0.128", + minimumCLIVersion: "0.139", + hooksUnavailableSafeMode: true + ), tick: 4)) + #expect(reducer.evidence[capabilityID]?.reasons.contains(.cliVersionBelowMinimum(found: "0.128", minimum: "0.139")) == true) + #expect(reducer.evidence[capabilityID]?.reasons.contains(.hooksUnavailableSafeMode) == true) + + let hookedID = AgentSessionID(rawValue: "hooked") + _ = reducer.fold(.hookEvent(hook(sessionID: hookedID, eventName: .sessionStart, pid: 101), tick: 5)) + #expect(reducer.snapshots[hookedID]?.tier == .hooked) + + let conflictID = AgentSessionID(rawValue: "conflict") + _ = reducer.fold(.wrapperLaunched(wrapper(sessionID: conflictID, pid: 102), tick: 6)) + _ = reducer.fold(.hookEvent(hook(sessionID: conflictID, eventName: .sessionStart, surfaceID: "surface-a", pid: 103), tick: 7)) + #expect(reducer.snapshots[conflictID]?.tier == .degraded) + } + + @Test + func processGoneRequiresPidAndStartIdentity() { + let reducer = AgentTruthReducer(macDeviceID: MacDeviceID(rawValue: "mac")) + let provisional = AgentSessionID(rawValue: "prov:surface-a:40") + _ = reducer.fold(.processObserved(process(surface: "surface-a", startTick: 40), tick: 1)) + + #expect(reducer.fold(.processGone(pid: 100, startTick: 41, tick: 2)).isEmpty) + #expect(reducer.snapshots[provisional]?.phase == .starting) + #expect(lastUpsert(reducer.fold(.processGone(pid: 100, startTick: 40, tick: 3)))?.phase == .ended) + } + + @Test + func wrapperLaunchedSessionCanEndBeforeProcessObservation() { + let reducer = AgentTruthReducer(macDeviceID: MacDeviceID(rawValue: "mac")) + let sessionID = AgentSessionID(rawValue: "wrapper-only") + + _ = reducer.fold(.wrapperLaunched(wrapper(sessionID: sessionID), tick: 1)) + #expect(lastUpsert(reducer.fold(.processGone(pid: 100, startTick: 999, tick: 2)))?.phase == .ended) + } + + private func process(pid: Int32 = 100, surface: String, startTick: Int) -> ProcessObservation { + ProcessObservation( + pid: pid, + ppid: 10, + startTick: startTick, + argvSummary: "codex", + agentKindGuess: .codex, + cwd: "/tmp/example", + surfaceID: surface, + openTranscriptPath: nil + ) + } + + private func wrapper( + sessionID: AgentSessionID?, + pid: Int32 = 100, + launchArgvKind: LaunchArgvKind = .new, + socketWasDown: Bool = false, + cliVersion: String? = nil, + minimumCLIVersion: String? = nil, + hooksUnavailableSafeMode: Bool = false + ) -> WrapperLaunchFact { + WrapperLaunchFact( + surfaceID: "surface-a", + agentKind: .codex, + pid: pid, + cwd: "/tmp/example", + sessionID: sessionID, + launchArgvKind: launchArgvKind, + socketWasDown: socketWasDown, + hooksUnavailableSafeMode: hooksUnavailableSafeMode, + cliVersion: cliVersion, + minimumCLIVersion: minimumCLIVersion + ) + } + + private func hook( + sessionID: AgentSessionID, + eventName: HookEventName, + surfaceID: String? = "surface-a", + pid: Int32? = 100, + notificationRequiresInput: Bool = false + ) -> HookFact { + HookFact( + sessionID: sessionID, + eventName: eventName, + surfaceID: surfaceID, + transcriptPath: "/tmp/example/transcript.jsonl", + cwd: "/tmp/example", + pid: pid, + notificationRequiresInput: notificationRequiresInput + ) + } + + private func upsertedPhases(_ changes: [AgentTruthChange]) -> [SessionPhase] { + changes.compactMap { change in + if case .sessionUpserted(let snapshot) = change { + snapshot.phase + } else { + nil + } + } + } + + private func lastUpsert(_ changes: [AgentTruthChange]) -> AgentSessionSnapshot? { + changes.compactMap { change in + if case .sessionUpserted(let snapshot) = change { + snapshot + } else { + nil + } + }.last + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTruthKitTests/CapabilityReportBuilderTests.swift b/Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTruthKitTests/CapabilityReportBuilderTests.swift new file mode 100644 index 000000000000..5393e45dbf8e --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTruthKitTests/CapabilityReportBuilderTests.swift @@ -0,0 +1,44 @@ +import CmuxAgentReplica +@testable import CmuxAgentTruthKit +import Testing + +@Suite +struct CapabilityReportBuilderTests { + @Test + func reasonsAndBooleansFollowEvidence() { + let builder = CapabilityReportBuilder() + + var observed = DetectionEvidence(hasProcessObservation: true) + let observedReport = builder.report(for: observed) + #expect(observedReport.tier == .observed) + #expect(observedReport.reasons.contains(.hooksNotObserved)) + #expect(!observedReport.steerable) + #expect(!observedReport.answerable) + + observed.hasHookEvents = true + let hooked = builder.report(for: observed) + #expect(hooked.tier == .hooked) + #expect(hooked.steerable) + #expect(hooked.answerable) + + observed.hasWrapperLaunch = true + let wrapped = builder.report(for: observed) + #expect(wrapped.tier == .wrapped) + #expect(wrapped.steerable) + + observed.reasons.insert(.launchedWhileSocketDown) + observed.reasons.insert(.cliVersionBelowMinimum(found: "0.128", minimum: "0.139")) + observed.reasons.insert(.transcriptNotReadable) + observed.reasons.insert(.evidenceConflict) + observed.reasons.insert(.hooksUnavailableSafeMode) + let degraded = builder.report(for: observed) + #expect(degraded.tier == .degraded) + #expect(degraded.reasons.contains(.launchedWhileSocketDown)) + #expect(degraded.reasons.contains(.cliVersionBelowMinimum(found: "0.128", minimum: "0.139"))) + #expect(degraded.reasons.contains(.transcriptNotReadable)) + #expect(degraded.reasons.contains(.evidenceConflict)) + #expect(degraded.reasons.contains(.hooksUnavailableSafeMode)) + #expect(!degraded.steerable) + #expect(!degraded.answerable) + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTruthKitTests/JournalMinterTests.swift b/Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTruthKitTests/JournalMinterTests.swift new file mode 100644 index 000000000000..d853efd53e61 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTruthKitTests/JournalMinterTests.swift @@ -0,0 +1,46 @@ +import CmuxAgentReplica +@testable import CmuxAgentTruthKit +import Testing + +@Suite +struct JournalMinterTests { + @Test + func journalIdentityDecisions() { + var minter = JournalMinter() + let first = JournalIdentity(path: "/tmp/example/rollout.jsonl", inodeLikeToken: "inode-1", headTruncated: false) + let firstDecision = minter.decide(previous: nil, current: first, currentJournalID: nil) + let firstID = id(from: firstDecision) + + #expect(minter.decide(previous: first, current: first, currentJournalID: firstID) == .same(firstID)) + + let inodeChange = JournalIdentity(path: first.path, inodeLikeToken: "inode-2", headTruncated: false) + let inodeID = id(from: minter.decide(previous: first, current: inodeChange, currentJournalID: firstID)) + #expect(inodeID != firstID) + + let truncated = JournalIdentity(path: first.path, inodeLikeToken: first.inodeLikeToken, headTruncated: true) + let truncatedID = id(from: minter.decide(previous: first, current: truncated, currentJournalID: firstID)) + #expect(truncatedID != firstID) + #expect(truncatedID != inodeID) + #expect(minter.decide(previous: truncated, current: truncated, currentJournalID: truncatedID) == .same(truncatedID)) + + let secondTruncation = JournalIdentity(path: first.path, inodeLikeToken: "inode-3", headTruncated: true) + let secondTruncatedID = id(from: minter.decide(previous: truncated, current: secondTruncation, currentJournalID: truncatedID)) + #expect(secondTruncatedID != truncatedID) + #expect(secondTruncatedID != firstID) + + let pathSwap = JournalIdentity(path: "/tmp/example/new.jsonl", inodeLikeToken: first.inodeLikeToken, headTruncated: false) + let pathSwapID = id(from: minter.decide(previous: first, current: pathSwap, currentJournalID: firstID)) + #expect(pathSwapID != firstID) + + let backToAID = id(from: minter.decide(previous: pathSwap, current: first, currentJournalID: pathSwapID)) + #expect(backToAID != firstID) + #expect(backToAID != pathSwapID) + } + + private func id(from decision: JournalDecision) -> JournalID { + switch decision { + case .same(let id), .created(let id): + id + } + } +} diff --git a/Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTruthKitTests/Resources/claude-synthetic.jsonl b/Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTruthKitTests/Resources/claude-synthetic.jsonl new file mode 100644 index 000000000000..372a598cf094 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTruthKitTests/Resources/claude-synthetic.jsonl @@ -0,0 +1,29 @@ +{"type":"fixture_comment","note":"Mirrors Claude Code 2.1.x project JSONL: message records, content blocks including image, system subtype records, sidechain records, tool_use/tool_result pairing, and title/bookkeeping records."} +{"type":"user","message":{"role":"user","content":"Lorem ipsum prompt for the example project."},"sessionId":"00000000-0000-4000-8000-000000000001","cwd":"/tmp/example"} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Lorem ipsum assistant prose."}]},"sessionId":"00000000-0000-4000-8000-000000000001"} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Private reasoning placeholder."}]},"sessionId":"00000000-0000-4000-8000-000000000001"} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_001","name":"Bash","input":{"command":"echo example"}}]},"sessionId":"00000000-0000-4000-8000-000000000001"} +{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_001","content":"example output","is_error":false}]},"sessionId":"00000000-0000-4000-8000-000000000001"} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_002","name":"Edit","input":{"file_path":"/tmp/example/file.txt","old_string":"old","new_string":"new"}}]},"sessionId":"00000000-0000-4000-8000-000000000001"} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_003","name":"AskUserQuestion","input":{"question":"Choose an example option?","options":["Alpha","Beta"]}}]},"sessionId":"00000000-0000-4000-8000-000000000001"} +{"type":"user","isSidechain":true,"message":{"role":"user","content":"Sidechain placeholder skipped."},"sessionId":"00000000-0000-4000-8000-000000000001"} +{"type":"assistant","isMeta":true,"message":{"role":"assistant","content":"Meta placeholder skipped."},"sessionId":"00000000-0000-4000-8000-000000000001"} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Tool introduction placeholder."},{"type":"tool_use","id":"toolu_004","name":"Bash","input":{"command":"echo combined"}}]},"sessionId":"00000000-0000-4000-8000-000000000001"} +{"type":"system","subtype":"api_error","isApiErrorMessage":true,"content":"Example API error placeholder.","sessionId":"00000000-0000-4000-8000-000000000001"} +{"type":"system","subtype":"stop_hook_summary","content":"Example hook summary placeholder.","sessionId":"00000000-0000-4000-8000-000000000001"} +{"type":"system","subtype":"turn_duration","durationMs":1234,"messageCount":2,"sessionId":"00000000-0000-4000-8000-000000000001"} +{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Lorem ipsum prompt with attachment."},{"type":"image","source":{"type":"base64","media_type":"image/png","data":""}}]},"sessionId":"00000000-0000-4000-8000-000000000001"} +{"type":"ai-title","aiTitle":"Example AI Title","sessionId":"00000000-0000-4000-8000-000000000001"} +{"type":"custom-title","customTitle":"Example Custom Title","sessionId":"00000000-0000-4000-8000-000000000001"} +{"type":"agent-name","agentName":"Example Agent Name","sessionId":"00000000-0000-4000-8000-000000000001"} +{"type":"last-prompt","lastPrompt":"Example last prompt.","sessionId":"00000000-0000-4000-8000-000000000001","leafUuid":"00000000-0000-4000-8000-000000000002"} +{"type":"mode","mode":"default","sessionId":"00000000-0000-4000-8000-000000000001"} +{"type":"permission-mode","permissionMode":"acceptEdits","sessionId":"00000000-0000-4000-8000-000000000001"} +{"type":"pr-link","sessionId":"00000000-0000-4000-8000-000000000001","prNumber":1,"prUrl":"https://example.invalid/pr/1","prRepository":"example/repo","timestamp":"2026-07-01T00:00:00.000Z"} +{"type":"queue-operation","operation":"enqueue","timestamp":"2026-07-01T00:00:00.000Z","sessionId":"00000000-0000-4000-8000-000000000001"} +{"type":"bridge-session","sessionId":"00000000-0000-4000-8000-000000000001","bridgeSessionId":"bridge-example","lastSequenceNum":1} +{"type":"file-history-snapshot","messageId":"00000000-0000-4000-8000-000000000003","snapshot":[],"isSnapshotUpdate":false} +{"type":"attachment","attachment":{"type":"image","fileName":"example.png"},"sessionId":"00000000-0000-4000-8000-000000000001"} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"future_block","value":"placeholder"}]},"sessionId":"00000000-0000-4000-8000-000000000001"} +{malformed json +{"type":"future_record","payload":{"value":"placeholder"}} diff --git a/Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTruthKitTests/Resources/codex-synthetic.jsonl b/Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTruthKitTests/Resources/codex-synthetic.jsonl new file mode 100644 index 000000000000..d87a34c09167 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTruthKitTests/Resources/codex-synthetic.jsonl @@ -0,0 +1,29 @@ +{"type":"fixture_comment","note":"Mirrors Codex 0.14x rollout JSONL: session_meta, turn_context, event_msg phase/duplicate/bookkeeping streams, response_item message/reasoning/function_call/function_call_output/custom_tool_call/custom_tool_call_output/tool_search/web_search, and compaction."} +{"timestamp":"2026-07-01T00:00:00.000Z","type":"session_meta","payload":{"id":"00000000-0000-4000-8000-000000000010","timestamp":"2026-07-01T00:00:00.000Z","cwd":"/tmp/example","originator":"codex","cli_version":"0.140.0","model_provider":"openai"}} +{"timestamp":"2026-07-01T00:00:01.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"Lorem ipsum codex prompt."}]}} +{"timestamp":"2026-07-01T00:00:02.000Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Lorem ipsum codex answer."}]}} +{"timestamp":"2026-07-01T00:00:03.000Z","type":"response_item","payload":{"type":"reasoning","summary":[{"type":"summary_text","text":"Reasoning placeholder."}]}} +{"timestamp":"2026-07-01T00:00:04.000Z","type":"response_item","payload":{"type":"function_call","name":"shell","call_id":"call_001","arguments":"{\"command\":[\"bash\",\"-lc\",\"echo example\"],\"cwd\":\"/tmp/example\"}"}} +{"timestamp":"2026-07-01T00:00:05.000Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call_001","output":"example output"}} +{"timestamp":"2026-07-01T00:00:06.000Z","type":"response_item","payload":{"type":"custom_tool_call","name":"apply_patch","call_id":"call_002","input":"patch placeholder"}} +{"timestamp":"2026-07-01T00:00:07.000Z","type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call_002","output":"patch applied"}} +{"timestamp":"2026-07-01T00:00:08.000Z","type":"response_item","payload":{"type":"web_search_call","call_id":"call_003","action":{"query":"example query"}}} +{"timestamp":"2026-07-01T00:00:09.000Z","type":"response_item","payload":{"type":"tool_search_call","call_id":"call_004","status":"completed","arguments":{"query":"example tool"}}} +{"timestamp":"2026-07-01T00:00:10.000Z","type":"response_item","payload":{"type":"tool_search_output","call_id":"call_004","status":"completed","tools":[{"name":"example_tool"}]}} +{"timestamp":"2026-07-01T00:00:11.000Z","type":"turn_context","payload":{"turn_id":"00000000-0000-4000-8000-000000000021","cwd":"/tmp/example","model":"gpt-5.5","approval_policy":"never","sandbox_policy":{"type":"danger-full-access","writable_roots":["/tmp/example"]}}} +{"timestamp":"2026-07-01T00:00:12.000Z","type":"event_msg","payload":{"type":"task_started","turn_id":"00000000-0000-4000-8000-000000000020"}} +{"timestamp":"2026-07-01T00:00:13.000Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"00000000-0000-4000-8000-000000000020"}} +{"timestamp":"2026-07-01T00:00:14.000Z","type":"event_msg","payload":{"type":"turn_aborted","turn_id":"00000000-0000-4000-8000-000000000020","reason":"example"}} +{"timestamp":"2026-07-01T00:00:15.000Z","type":"compacted","payload":{"reason":"manual"}} +{"timestamp":"2026-07-01T00:00:16.000Z","type":"event_msg","payload":{"type":"context_compacted"}} +{"timestamp":"2026-07-01T00:00:17.000Z","type":"event_msg","payload":{"type":"user_message","message":"Lorem ipsum duplicate prompt.","images":[],"local_images":[],"text_elements":[]}} +{"timestamp":"2026-07-01T00:00:18.000Z","type":"event_msg","payload":{"type":"agent_message","message":"Lorem ipsum duplicate answer.","phase":"final_answer"}} +{"timestamp":"2026-07-01T00:00:19.000Z","type":"event_msg","payload":{"type":"patch_apply_end","call_id":"call_002","stdout":"ok","stderr":"","success":true,"changes":{}}} +{"timestamp":"2026-07-01T00:00:20.000Z","type":"event_msg","payload":{"type":"web_search_end","call_id":"call_003","query":"example query","action":"search"}} +{"timestamp":"2026-07-01T00:00:21.000Z","type":"event_msg","payload":{"type":"mcp_tool_call_end","call_id":"call_005","status":"success"}} +{"timestamp":"2026-07-01T00:00:22.000Z","type":"event_msg","payload":{"type":"entered_review_mode"}} +{"timestamp":"2026-07-01T00:00:23.000Z","type":"event_msg","payload":{"type":"exited_review_mode"}} +{"timestamp":"2026-07-01T00:00:24.000Z","type":"event_msg","payload":{"type":"thread_goal_updated","threadId":"thread-example","goal":"Example goal."}} +{"timestamp":"2026-07-01T00:00:25.000Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1,"output_tokens":1}}}} +{malformed json +{"timestamp":"2026-07-01T00:00:26.000Z","type":"response_item","payload":{"type":"future_item","content":"placeholder"}} diff --git a/Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTruthKitTests/TranscriptDecoderTests.swift b/Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTruthKitTests/TranscriptDecoderTests.swift new file mode 100644 index 000000000000..7e33e8bca2d0 --- /dev/null +++ b/Packages/macOS/CmuxAgentTruthKit/Tests/CmuxAgentTruthKitTests/TranscriptDecoderTests.swift @@ -0,0 +1,804 @@ +import CmuxAgentReplica +import Foundation +@testable import CmuxAgentTruthKit +import Testing + +@Suite +struct TranscriptDecoderTests { + private static let png1x1Base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=" + private static let png1x1Data = Data(base64Encoded: png1x1Base64)! + + @Test + func claudeGoldenFixture() throws { + let lines = try fixtureLines("claude-synthetic") + var decoder = ClaudeTranscriptDecoder() + let batch = decoder.feed(lines, startingAt: 0, journalID: JournalID(rawValue: "claude-journal")) + + #expect(kindTable(batch.entries) == [ + "0:fixture_comment", + "1:userMessage", + "2:agentProse", + "3:thought", + "4:toolRun", + "5:toolRun", + "6:fileChange", + "7:question", + "10:agentProse", + "11:toolRun", + "11:status", + "12:status", + "14:userMessage", + "15:attachment", + "25:attachment", + "26:future_block", + "27:malformed", + "28:future_record", + ]) + #expect(batch.diagnostics.unknownKindCounts["malformed"] == 1) + #expect(batch.diagnostics.unknownKindCounts["future_block"] == 1) + #expect(batch.diagnostics.modeledKindCounts["sidechain"] == 1) + #expect(batch.diagnostics.modeledKindCounts["meta"] == 1) + #expect(batch.diagnostics.modeledKindCounts["system.api_error"] == 1) + #expect(batch.diagnostics.modeledKindCounts["system.stop_hook_summary"] == 1) + #expect(batch.diagnostics.modeledKindCounts["system.turn_duration"] == 1) + #expect(batch.diagnostics.bookkeepingKindCounts["ai-title"] == 1) + #expect(batch.diagnostics.bookkeepingKindCounts["attachment"] == 1) + #expect(batch.diagnostics.sawApiError) + #expect(batch.diagnostics.sensitiveSessionTitles.map(\.source) == ["ai-title", "custom-title", "agent-name"]) + #expect(userMessagePayload(in: batch, seq: 14)?.hasImage == false) + #expect(attachmentPayload(in: batch, seq: 15)?.mimeType == "image/png") + #expect(toolRunPayload(in: batch, seq: 5)?.exitCode == 0) + #expect(toolRunPayload(in: batch, seq: 5)?.toolCallID == "toolu_001") + #expect(toolRunPayload(in: batch, seq: 5)?.command == "echo example") + #expect(toolRunPayload(in: batch, seq: 5)?.output == "example output") + #expect(fileChangePayload(in: batch, seq: 6)?.path == "/tmp/example/file.txt") + #expect(fileChangePayload(in: batch, seq: 6)?.toolCallID == "toolu_002") + #expect(fileChangePayload(in: batch, seq: 6)?.unifiedDiff?.contains("--- before") == true) + #expect(attachmentPayload(in: batch, seq: 25)?.displayName == "example.png") + #expect(questionPayload(in: batch, seq: 7)?.options == ["Alpha", "Beta"]) + #expect(batch.entries.contains { entry in + if case .status(let status) = entry.content.payload { return status.code == .apiError } + return false + }) + #expect(unknownPayload(in: batch, seq: 26)?.rawKind == "future_block") + } + + @Test + func codexGoldenFixture() throws { + let lines = try fixtureLines("codex-synthetic") + var decoder = CodexTranscriptDecoder() + let batch = decoder.feed(lines, startingAt: 0, journalID: JournalID(rawValue: "codex-journal")) + + #expect(kindTable(batch.entries) == [ + "0:fixture_comment", + "1:status", + "2:userMessage", + "3:agentProse", + "4:thought", + "5:toolRun", + "6:toolRun", + "7:fileChange", + "8:fileChange", + "9:toolRun", + "10:toolRun", + "11:toolRun", + "15:status", + "16:status", + "27:malformed", + "28:future_item", + ]) + #expect(batch.diagnostics.unknownKindCounts["malformed"] == 1) + #expect(batch.diagnostics.unknownKindCounts["future_item"] == 1) + #expect(batch.diagnostics.modeledKindCounts["event_msg.task_started"] == 1) + #expect(batch.diagnostics.modeledKindCounts["event_msg.task_complete"] == 1) + #expect(batch.diagnostics.modeledKindCounts["event_msg.context_compacted"] == 1) + #expect(batch.diagnostics.duplicateStreamCounts["event_msg.user_message"] == 1) + #expect(batch.diagnostics.duplicateStreamCounts["event_msg.agent_message"] == 1) + #expect(batch.diagnostics.phaseFacts == [.taskStarted(line: 13), .taskCompleted(line: 14), .turnAborted(line: 15)]) + #expect(batch.diagnostics.turnContextFacts == [TurnContextFact(line: 12, model: "gpt-5.5", sandboxPolicy: "danger-full-access", approvalPolicy: "never")]) + #expect(batch.diagnostics.cliVersion == "0.140.0") + #expect(toolRunPayload(in: batch, seq: 5)?.argumentSummary.contains("echo example") == true) + #expect(toolRunPayload(in: batch, seq: 5)?.toolCallID == "call_001") + #expect(toolRunPayload(in: batch, seq: 5)?.command == "echo example") + #expect(toolRunPayload(in: batch, seq: 6)?.output == "example output") + #expect(batch.entries.first { $0.seq.rawValue == 2 }?.timestampMilliseconds != nil) + #expect(fileChangePayload(in: batch, seq: 8)?.resultSummary?.contains("patch applied") == true) + #expect(statusPayload(in: batch, seq: 15)?.code == .turnAborted) + } + + @Test + func codexUserImageEmitsStructuredAttachmentWithLocalPath() throws { + let line = #"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"Inspect this image.\n"},{"type":"input_image","image_url":"data:image/png;base64,\#(Self.png1x1Base64)"}]}}"# + var decoder = CodexTranscriptDecoder() + let batch = decoder.feed([line], startingAt: 0, journalID: JournalID(rawValue: "journal")) + + #expect(kindTable(batch.entries) == ["0:userMessage", "1:attachment"]) + #expect(userMessagePayload(in: batch, seq: 0)?.attachmentCount == 1) + #expect(userMessagePayload(in: batch, seq: 0)?.hasImage == true) + let attachment = try #require(attachmentPayload(in: batch, seq: 1)) + #expect(attachment.kind == "image") + #expect(attachment.displayName == "codex-inline-test.png") + #expect(attachment.hostPath == "/tmp/codex-inline-test.png") + #expect(attachment.mimeType == "image/png") + #expect(attachment.width == 1) + #expect(attachment.height == 1) + #expect(attachment.byteCount == Self.png1x1Data.count) + } + + @Test + func codexCustomToolOutputPairsByCallID() { + let lines = [ + #"{"type":"response_item","payload":{"type":"custom_tool_call","name":"apply_patch","call_id":"call_patch","input":"patch placeholder"}}"#, + #"{"type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call_patch","output":"patch complete"}}"#, + ] + var decoder = CodexTranscriptDecoder() + let batch = decoder.feed(lines, startingAt: 0, journalID: JournalID(rawValue: "journal")) + + #expect(kindTable(batch.entries) == ["0:fileChange", "1:fileChange"]) + #expect(fileChangePayload(in: batch, seq: 1)?.resultSummary?.contains("patch complete") == true) + #expect(batch.diagnostics.unknownKindCounts.isEmpty) + } + + @Test + func codexToolSearchOutputPairsByCallID() { + let lines = [ + #"{"type":"response_item","payload":{"type":"tool_search_call","call_id":"call_search","arguments":{"query":"example"}}}"#, + #"{"type":"response_item","payload":{"type":"tool_search_output","call_id":"call_search","status":"completed","tools":[{"name":"example_tool"}]}}"#, + ] + var decoder = CodexTranscriptDecoder() + let batch = decoder.feed(lines, startingAt: 0, journalID: JournalID(rawValue: "journal")) + + #expect(kindTable(batch.entries) == ["0:toolRun", "1:toolRun"]) + #expect(toolRunPayload(in: batch, seq: 1)?.toolName == "tool_search") + #expect(batch.diagnostics.unknownKindCounts.isEmpty) + } + + @Test + func codexUnpairedOutputsRemainVisibleAsCompletedToolRows() { + let lines = [ + #"{"type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"missing_patch","output":"patch complete"}}"#, + #"{"type":"response_item","payload":{"type":"tool_search_output","call_id":"missing_search","status":"completed","tools":[]}}"#, + ] + var decoder = CodexTranscriptDecoder() + let batch = decoder.feed(lines, startingAt: 0, journalID: JournalID(rawValue: "journal")) + + #expect(kindTable(batch.entries) == ["0:toolRun", "1:toolRun"]) + #expect(toolRunPayload(in: batch, seq: 0)?.toolCallID == "missing_patch") + #expect(toolRunPayload(in: batch, seq: 0)?.output == "patch complete") + #expect(toolRunPayload(in: batch, seq: 0)?.status == "unpaired_result:completed") + #expect(toolRunPayload(in: batch, seq: 1)?.toolCallID == "missing_search") + #expect(batch.diagnostics.unknownKindCounts["custom_tool_call_output"] == 1) + #expect(batch.diagnostics.unknownKindCounts["tool_search_output"] == 1) + #expect(batch.diagnostics.unknownKindCounts["function_call_output"] == nil) + } + + @Test + func claudeUnpairedToolResultRemainsVisibleAsCompletedToolRow() { + let lines = [ + #"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"missing_tool","content":"visible output","is_error":true}]}}"#, + ] + var decoder = ClaudeTranscriptDecoder() + let batch = decoder.feed(lines, startingAt: 0, journalID: JournalID(rawValue: "journal")) + + #expect(kindTable(batch.entries) == ["0:toolRun"]) + #expect(toolRunPayload(in: batch, seq: 0)?.toolCallID == "missing_tool") + #expect(toolRunPayload(in: batch, seq: 0)?.output == "visible output") + #expect(toolRunPayload(in: batch, seq: 0)?.status == "unpaired_result:failed") + #expect(batch.diagnostics.unknownKindCounts["tool_result"] == 1) + } + + @Test + func codexPhaseFactsAndEventCompactionStatus() { + let lines = [ + #"{"type":"event_msg","payload":{"type":"task_started","turn_id":"turn_example"}}"#, + #"{"type":"event_msg","payload":{"type":"task_complete","turn_id":"turn_example"}}"#, + #"{"type":"event_msg","payload":{"type":"turn_aborted","turn_id":"turn_example"}}"#, + #"{"type":"event_msg","payload":{"type":"context_compacted"}}"#, + ] + var decoder = CodexTranscriptDecoder() + let batch = decoder.feed(lines, startingAt: 40, journalID: JournalID(rawValue: "journal")) + + #expect(kindTable(batch.entries) == ["42:status", "43:status"]) + #expect(batch.diagnostics.phaseFacts == [.taskStarted(line: 40), .taskCompleted(line: 41), .turnAborted(line: 42)]) + } + + @Test + func codexOutOfRangeExitCodeFailsOpen() { + let lines = [ + #"{"type":"response_item","payload":{"type":"function_call","name":"shell","call_id":"call_huge","arguments":{"command":["bash","-lc","echo done"]}}}"#, + #"{"type":"response_item","payload":{"type":"function_call_output","call_id":"call_huge","output":"done","exit_code":1e300}}"#, + ] + var decoder = CodexTranscriptDecoder() + let batch = decoder.feed(lines, startingAt: 0, journalID: JournalID(rawValue: "journal")) + + #expect(toolRunPayload(in: batch, seq: 1)?.exitCode == nil) + #expect(toolRunPayload(in: batch, seq: 1)?.isRunning == false) + } + + @Test + func codexTerminalClassificationUsesNameAndParsedCommandArray() { + let lines = [ + #"{"type":"response_item","payload":{"type":"function_call","name":"search_docs","arguments":"{\"query\":\"bash reference\"}"}}"#, + #"{"type":"response_item","payload":{"type":"function_call","name":"custom_runner","arguments":"{\"command\":[\"bash\",\"-lc\",\"echo done\"]}"}}"#, + #"{"type":"response_item","payload":{"type":"function_call","name":"shell","arguments":"{\"query\":\"reference\"}"}}"#, + ] + var decoder = CodexTranscriptDecoder() + let batch = decoder.feed(lines, startingAt: 0, journalID: JournalID(rawValue: "journal")) + + #expect(toolRunPayload(in: batch, seq: 0)?.isTerminal == false) + #expect(toolRunPayload(in: batch, seq: 1)?.isTerminal == true) + #expect(toolRunPayload(in: batch, seq: 2)?.isTerminal == true) + } + + @Test + func codexRequestUserInputPreservesPromptAndOptionLabels() { + let arguments = #"{"questions":[{"header":"Color","id":"color","question":"Pick a color family.","options":[{"label":"Warm","description":"Reds and oranges."},{"label":"Cool","description":"Blues and greens."}]}]}"# + let lines = [ + #"{"type":"response_item","payload":{"type":"function_call","name":"request_user_input","call_id":"call_question","arguments":"\#(arguments.replacingOccurrences(of: "\"", with: "\\\""))"}}"#, + ] + var decoder = CodexTranscriptDecoder() + let batch = decoder.feed(lines, startingAt: 0, journalID: JournalID(rawValue: "journal")) + + #expect(questionPayload(in: batch, seq: 0)?.prompt == "Pick a color family.") + #expect(questionPayload(in: batch, seq: 0)?.options == ["Warm", "Cool"]) + } + + @Test + func codexToolOutputIsBoundedBeforeEnteringTheReplica() { + let oversized = String(repeating: "x", count: 32_000) + let lines = [ + #"{"type":"response_item","payload":{"type":"function_call","name":"shell","call_id":"call_large","arguments":{"command":["bash","-lc","echo done"]}}}"#, + #"{"type":"response_item","payload":{"type":"function_call_output","call_id":"call_large","output":"\#(oversized)"}}"#, + ] + var decoder = CodexTranscriptDecoder() + let batch = decoder.feed(lines, startingAt: 0, journalID: JournalID(rawValue: "journal")) + + #expect((toolRunPayload(in: batch, seq: 1)?.resultSummary?.utf8.count ?? .max) <= 16_384) + } + + @Test + func claudeNonShellToolResultSynthesizesZeroExitCode() { + let lines = [ + #"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"tool_non_shell","name":"WebSearch","input":{"query":"example"}}]}}"#, + #"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool_non_shell","content":"result","is_error":false}]}}"#, + ] + var decoder = ClaudeTranscriptDecoder() + let batch = decoder.feed(lines, startingAt: 0, journalID: JournalID(rawValue: "journal")) + + #expect(toolRunPayload(in: batch, seq: 1)?.isTerminal == false) + #expect(toolRunPayload(in: batch, seq: 1)?.exitCode == 0) + } + + @Test + func claudeImageBlockPreservesTextAndStructuredAttachment() throws { + let plain = [ + #"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Same prompt."}]}}"#, + ] + let withImage = [ + #"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Same prompt."},{"type":"image","source":{"type":"base64","media_type":"image/png","data":""}}]}}"#, + ] + var plainDecoder = ClaudeTranscriptDecoder() + var imageDecoder = ClaudeTranscriptDecoder() + let plainEntry = try #require(plainDecoder.feed(plain, startingAt: 0, journalID: JournalID(rawValue: "journal")).entries.first) + let imageBatch = imageDecoder.feed(withImage, startingAt: 0, journalID: JournalID(rawValue: "journal")) + let imageEntry = try #require(imageBatch.entries.first) + + #expect(plainEntry.kind == .userMessage) + #expect(imageEntry.kind == .userMessage) + #expect(plainEntry.content == imageEntry.content) + #expect(imageBatch.entries.count == 2) + #expect(userMessagePayload(in: imageBatch, seq: 0)?.hasImage == false) + #expect(attachmentPayload(in: imageBatch, seq: 1)?.mimeType == "image/png") + } + + @Test + func claudeBase64ImagePublishesDeferredImageSideTable() throws { + let encodedImage = Self.png1x1Base64 + var decoder = ClaudeTranscriptDecoder() + let line = #"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Inspect this image."},{"type":"image","file_name":"claude-inline-test.png","source":{"type":"base64","media_type":"image/png","data":"\#(encodedImage)"}}]}}"# + let journalID = JournalID(rawValue: "journal") + let batch = decoder.feed([line], startingAt: 0, journalID: journalID) + + #expect(kindTable(batch.entries) == ["0:userMessage", "1:attachment"]) + let attachment = try #require(attachmentPayload(in: batch, seq: 1)) + #expect(attachment.hostPath == nil) + #expect(attachment.mimeType == "image/png") + #expect(attachment.byteCount == Self.png1x1Data.count) + #expect(attachment.width == 1) + #expect(attachment.height == 1) + let embedded = try #require(batch.embeddedImages.first) + #expect(batch.embeddedImages.count == 1) + #expect(embedded.journalID == journalID) + #expect(embedded.entrySeq == EntrySeq(rawValue: 1)) + #expect(embedded.mimeType == "image/png") + #expect(embedded.base64EncodedData == encodedImage) + #expect(!String(decoding: try JSONEncoder().encode(batch.entries), as: UTF8.self).contains(encodedImage)) + } + + @Test + func codexImageWithoutLocalPathPublishesDeferredImageSideTable() throws { + let encodedImage = Self.png1x1Base64 + let line = #"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"Inspect this image."},{"type":"input_image","image_url":"data:image/png;base64,\#(encodedImage)"}]}}"# + let journalID = JournalID(rawValue: "codex-journal") + var decoder = CodexTranscriptDecoder() + let batch = decoder.feed([line], startingAt: 40, journalID: journalID) + + #expect(kindTable(batch.entries) == ["40:userMessage", "41:attachment"]) + let attachment = try #require(attachmentPayload(in: batch, seq: 41)) + #expect(attachment.hostPath == nil) + #expect(attachment.mimeType == "image/png") + #expect(attachment.width == 1) + #expect(attachment.height == 1) + #expect(attachment.byteCount == Self.png1x1Data.count) + let embedded = try #require(batch.embeddedImages.first) + #expect(batch.embeddedImages.count == 1) + #expect(embedded.journalID == journalID) + #expect(embedded.entrySeq == EntrySeq(rawValue: 41)) + #expect(embedded.mimeType == "image/png") + #expect(embedded.base64EncodedData == encodedImage) + } + + @Test + func codexRatioOnlyImageMetadataSurvivesDecoding() throws { + let line = #"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"Preview this artifact."},{"type":"input_image","name":"artifact-preview","aspect_ratio":0.5625}]}}"# + var decoder = CodexTranscriptDecoder() + let batch = decoder.feed([line], startingAt: 0, journalID: JournalID(rawValue: "journal")) + + #expect(kindTable(batch.entries) == ["0:userMessage", "1:attachment"]) + let attachment = try #require(attachmentPayload(in: batch, seq: 1)) + #expect(attachment.kind == "image") + #expect(attachment.displayName == "artifact-preview") + #expect(attachment.width == nil) + #expect(attachment.height == nil) + #expect(attachment.aspectRatio == 0.5625) + } + + @Test + func codexPathOnlyImageDerivesDimensionsFromFileHeader() throws { + let imageURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-path-only-\(UUID().uuidString).png") + try Self.png1x1Data.write(to: imageURL) + defer { try? FileManager.default.removeItem(at: imageURL) } + let line = #"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"Inspect.\n"}]}}"# + var decoder = CodexTranscriptDecoder() + let batch = decoder.feed([line], startingAt: 0, journalID: JournalID(rawValue: "journal")) + + #expect(kindTable(batch.entries) == ["0:userMessage", "1:attachment"]) + let attachment = try #require(attachmentPayload(in: batch, seq: 1)) + #expect(attachment.hostPath == imageURL.path) + #expect(attachment.width == 1) + #expect(attachment.height == 1) + #expect(attachment.byteCount == Self.png1x1Data.count) + } + + @Test + func codexAssistantMarkdownImageEmitsPreviewMetadataInSourceOrder() throws { + let imageURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-markdown-\(UUID().uuidString).png") + try Self.png1x1Data.write(to: imageURL) + defer { try? FileManager.default.removeItem(at: imageURL) } + let line = #"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Before\n\n![Codex Preview](\#(imageURL.path))\n\nAfter"}]}}"# + var decoder = CodexTranscriptDecoder() + let batch = decoder.feed([line], startingAt: 0, journalID: JournalID(rawValue: "journal")) + + #expect(kindTable(batch.entries) == ["0:agentProse", "1:attachment", "2:agentProse"]) + let attachment = try #require(attachmentPayload(in: batch, seq: 1)) + #expect(attachment.hostPath == imageURL.path) + #expect(attachment.displayName == "Codex Preview") + #expect(attachment.mimeType == "image/png") + #expect(attachment.width == 1) + #expect(attachment.height == 1) + #expect(attachment.byteCount == Self.png1x1Data.count) + #expect(attachment.authorRole == "agent") + } + + @Test + func codexAssistantMarkdownImageRatioMetadataSurvivesWithoutImageFile() throws { + let line = #"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"![Codex Preview](/tmp/codex-preview.png?aspect_ratio=1.333333)"}]}}"# + var decoder = CodexTranscriptDecoder() + let batch = decoder.feed([line], startingAt: 0, journalID: JournalID(rawValue: "journal")) + + #expect(kindTable(batch.entries) == ["0:attachment"]) + let attachment = try #require(attachmentPayload(in: batch, seq: 0)) + #expect(attachment.hostPath == "/tmp/codex-preview.png") + #expect(attachment.width == nil) + #expect(attachment.height == nil) + #expect(attachment.aspectRatio == 1.333333) + } + + @Test + func codexViewImageToolCarriesPreviewMetadataThroughCompletion() throws { + let imageURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-view-image-\(UUID().uuidString).png") + try Self.png1x1Data.write(to: imageURL) + defer { try? FileManager.default.removeItem(at: imageURL) } + let callLine = #"{"type":"response_item","payload":{"type":"function_call","name":"view_image","call_id":"call_image","arguments":"{\"path\":\"\#(imageURL.path)\"}"}}"# + let resultLine = #"{"type":"response_item","payload":{"type":"function_call_output","call_id":"call_image","output":"loaded preview"}}"# + var decoder = CodexTranscriptDecoder() + + let call = decoder.feed([callLine], startingAt: 0, journalID: JournalID(rawValue: "journal")) + let result = decoder.feed([resultLine], startingAt: 1, journalID: JournalID(rawValue: "journal")) + + let running = try #require(toolRunPayload(in: call, seq: 0)) + let completed = try #require(toolRunPayload(in: result, seq: 1)) + #expect(running.previewAttachments?.count == 1) + #expect(completed.previewAttachments?.count == 1) + let attachment = try #require(completed.previewAttachments?.first) + #expect(attachment.kind == "image") + #expect(attachment.hostPath == imageURL.path) + #expect(attachment.displayName == imageURL.lastPathComponent) + #expect(attachment.mimeType == "image/png") + #expect(attachment.width == 1) + #expect(attachment.height == 1) + #expect(attachment.byteCount == Self.png1x1Data.count) + #expect(attachment.authorRole == "agent") + } + + @Test + func codexViewImageToolCarriesInputGeometryBeforeFileLoads() throws { + let imagePath = "/tmp/cmux-codex-pending-preview.png" + let arguments = #"{"path":"\#(imagePath)","display_name":"pending-preview.png","mime_type":"image/png","width":640,"height":360,"aspect_ratio":1.7777777778}"# + let callLine = #"{"type":"response_item","payload":{"type":"function_call","name":"view_image","call_id":"call_image","arguments":"\#(arguments.replacingOccurrences(of: "\"", with: "\\\""))"}}"# + let resultLine = #"{"type":"response_item","payload":{"type":"function_call_output","call_id":"call_image","output":"queued preview"}}"# + var decoder = CodexTranscriptDecoder() + + let call = decoder.feed([callLine], startingAt: 0, journalID: JournalID(rawValue: "journal")) + let result = decoder.feed([resultLine], startingAt: 1, journalID: JournalID(rawValue: "journal")) + + let running = try #require(toolRunPayload(in: call, seq: 0)) + let completed = try #require(toolRunPayload(in: result, seq: 1)) + #expect(running.previewAttachments?.count == 1) + #expect(completed.previewAttachments?.count == 1) + let attachment = try #require(completed.previewAttachments?.first) + #expect(attachment.kind == "image") + #expect(attachment.hostPath == imagePath) + #expect(attachment.displayName == "pending-preview.png") + #expect(attachment.mimeType == "image/png") + #expect(attachment.width == 640) + #expect(attachment.height == 360) + #expect(attachment.aspectRatio == 1.7777777778) + #expect(attachment.byteCount == nil) + #expect(attachment.authorRole == "agent") + } + + @Test + func codexViewImageToolMergesNestedPreviewMetadataBeforeFileLoads() throws { + let imagePath = "/tmp/cmux-codex-nested-preview.png" + let arguments = #"{"path":"\#(imagePath)","display_name":"nested-preview.png","metadata":{"content_type":"image/png","bytes":2048,"dimensions":{"image_width":640,"image_height":360},"preview":{"preview_aspect_ratio":"16:9"}}}"# + let callLine = #"{"type":"response_item","payload":{"type":"function_call","name":"view_image","call_id":"call_image","arguments":"\#(arguments.replacingOccurrences(of: "\"", with: "\\\""))"}}"# + let resultLine = #"{"type":"response_item","payload":{"type":"function_call_output","call_id":"call_image","output":"queued preview"}}"# + var decoder = CodexTranscriptDecoder() + + let call = decoder.feed([callLine], startingAt: 0, journalID: JournalID(rawValue: "journal")) + let result = decoder.feed([resultLine], startingAt: 1, journalID: JournalID(rawValue: "journal")) + + let running = try #require(toolRunPayload(in: call, seq: 0)) + let completed = try #require(toolRunPayload(in: result, seq: 1)) + #expect(running.previewAttachments?.count == 1) + #expect(completed.previewAttachments?.count == 1) + let attachment = try #require(completed.previewAttachments?.first) + #expect(attachment.kind == "image") + #expect(attachment.hostPath == imagePath) + #expect(attachment.displayName == "nested-preview.png") + #expect(attachment.mimeType == "image/png") + #expect(attachment.width == 640) + #expect(attachment.height == 360) + #expect(attachment.aspectRatio == 16.0 / 9.0) + #expect(attachment.byteCount == 2_048) + #expect(attachment.authorRole == "agent") + } + + @Test + func claudeAttachmentRecordDerivesImageDimensionsFromFileHeader() throws { + let imageURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-claude-attachment-\(UUID().uuidString).png") + try Self.png1x1Data.write(to: imageURL) + defer { try? FileManager.default.removeItem(at: imageURL) } + let line = #"{"type":"attachment","attachment":{"fileName":"fixture.png","path":"\#(imageURL.path)","mime_type":"image/png"}}"# + var decoder = ClaudeTranscriptDecoder() + let batch = decoder.feed([line], startingAt: 0, journalID: JournalID(rawValue: "journal")) + + #expect(kindTable(batch.entries) == ["0:attachment"]) + let attachment = try #require(attachmentPayload(in: batch, seq: 0)) + #expect(attachment.kind == "file") + #expect(attachment.hostPath == imageURL.path) + #expect(attachment.width == 1) + #expect(attachment.height == 1) + #expect(attachment.byteCount == Self.png1x1Data.count) + } + + @Test + func claudeSVGAttachmentRecordCarriesPreviewMetadata() throws { + let imageURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-claude-svg-\(UUID().uuidString).svg") + let svg = #""# + let data = Data(svg.utf8) + try data.write(to: imageURL) + defer { try? FileManager.default.removeItem(at: imageURL) } + let line = #"{"type":"attachment","attachment":{"fileName":"preview.svg","path":"\#(imageURL.path)"}}"# + var decoder = ClaudeTranscriptDecoder() + let batch = decoder.feed([line], startingAt: 0, journalID: JournalID(rawValue: "journal")) + + #expect(kindTable(batch.entries) == ["0:attachment"]) + let attachment = try #require(attachmentPayload(in: batch, seq: 0)) + #expect(attachment.hostPath == imageURL.path) + #expect(attachment.mimeType == "image/svg+xml") + #expect(attachment.width == 480) + #expect(attachment.height == 270) + #expect(attachment.byteCount == data.count) + } + + @Test + func claudeAssistantMarkdownImageEmitsPreviewMetadataInSourceOrder() throws { + let imageURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-claude-markdown-\(UUID().uuidString).png") + try Self.png1x1Data.write(to: imageURL) + defer { try? FileManager.default.removeItem(at: imageURL) } + let line = #"{"type":"assistant","message":{"role":"assistant","content":"Before\n\n![Claude Preview](\#(imageURL.path))\n\nAfter"}}"# + var decoder = ClaudeTranscriptDecoder() + let batch = decoder.feed([line], startingAt: 0, journalID: JournalID(rawValue: "journal")) + + #expect(kindTable(batch.entries) == ["0:agentProse", "1:attachment", "2:agentProse"]) + let attachment = try #require(attachmentPayload(in: batch, seq: 1)) + #expect(attachment.hostPath == imageURL.path) + #expect(attachment.displayName == "Claude Preview") + #expect(attachment.mimeType == "image/png") + #expect(attachment.width == 1) + #expect(attachment.height == 1) + #expect(attachment.byteCount == Self.png1x1Data.count) + #expect(attachment.authorRole == "agent") + } + + @Test + func claudeViewImageToolCarriesPreviewMetadataThroughCompletion() throws { + let imageURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-claude-view-image-\(UUID().uuidString).png") + try Self.png1x1Data.write(to: imageURL) + defer { try? FileManager.default.removeItem(at: imageURL) } + let callLine = #"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"tool_image","name":"view_image","input":{"path":"\#(imageURL.path)"}}]}}"# + let resultLine = #"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool_image","content":"loaded preview","is_error":false}]}}"# + var decoder = ClaudeTranscriptDecoder() + + let call = decoder.feed([callLine], startingAt: 0, journalID: JournalID(rawValue: "journal")) + let result = decoder.feed([resultLine], startingAt: 10, journalID: JournalID(rawValue: "journal")) + + let running = try #require(toolRunPayload(in: call, seq: 0)) + let completed = try #require(toolRunPayload(in: result, seq: 10)) + #expect(running.previewAttachments?.count == 1) + #expect(completed.previewAttachments?.count == 1) + let attachment = try #require(completed.previewAttachments?.first) + #expect(attachment.kind == "image") + #expect(attachment.hostPath == imageURL.path) + #expect(attachment.displayName == imageURL.lastPathComponent) + #expect(attachment.mimeType == "image/png") + #expect(attachment.width == 1) + #expect(attachment.height == 1) + #expect(attachment.byteCount == Self.png1x1Data.count) + #expect(attachment.authorRole == "agent") + } + + @Test + func claudeViewImageToolCarriesInputAspectRatioBeforeFileLoads() throws { + let imagePath = "/tmp/cmux-claude-pending-preview.png" + let callLine = #"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"tool_image","name":"view_image","input":{"path":"\#(imagePath)","name":"pending-preview.png","media_type":"image/png","preview_aspect_ratio":"9:16"}}]}}"# + let resultLine = #"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool_image","content":"queued preview","is_error":false}]}}"# + var decoder = ClaudeTranscriptDecoder() + + let call = decoder.feed([callLine], startingAt: 0, journalID: JournalID(rawValue: "journal")) + let result = decoder.feed([resultLine], startingAt: 10, journalID: JournalID(rawValue: "journal")) + + let running = try #require(toolRunPayload(in: call, seq: 0)) + let completed = try #require(toolRunPayload(in: result, seq: 10)) + #expect(running.previewAttachments?.count == 1) + #expect(completed.previewAttachments?.count == 1) + let attachment = try #require(completed.previewAttachments?.first) + #expect(attachment.kind == "image") + #expect(attachment.hostPath == imagePath) + #expect(attachment.displayName == "pending-preview.png") + #expect(attachment.mimeType == "image/png") + #expect(attachment.width == nil) + #expect(attachment.height == nil) + #expect(attachment.aspectRatio == 9.0 / 16.0) + #expect(attachment.byteCount == nil) + #expect(attachment.authorRole == "agent") + } + + @Test + func claudeMixedProseAndTwoToolCallsRemainIndependentThroughResults() throws { + let callLine = #"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I will inspect both."},{"type":"tool_use","id":"tool_a","name":"Bash","input":{"command":"echo a"}},{"type":"tool_use","id":"tool_b","name":"WebSearch","input":{"query":"b"}}]}}"# + let firstResultLine = #"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool_a","content":"result a","is_error":false}]}}"# + let secondResultLine = #"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool_b","content":"result b","is_error":false}]}}"# + let journalID = JournalID(rawValue: "journal") + var decoder = ClaudeTranscriptDecoder() + + let calls = decoder.feed([callLine], startingAt: 100, journalID: journalID) + #expect(kindTable(calls.entries) == ["100:agentProse", "101:toolRun", "102:toolRun"]) + #expect(toolRunPayload(in: calls, seq: 101)?.toolCallID == "tool_a") + #expect(toolRunPayload(in: calls, seq: 102)?.toolCallID == "tool_b") + + let firstResultOffset = 100 + (callLine + "\n").utf8.count + let firstResult = decoder.feed([firstResultLine], startingAt: firstResultOffset, journalID: journalID) + let secondResultOffset = firstResultOffset + (firstResultLine + "\n").utf8.count + let secondResult = decoder.feed([secondResultLine], startingAt: secondResultOffset, journalID: journalID) + let firstTool = try #require(firstResult.entries.first).content.payload + let secondTool = try #require(secondResult.entries.first).content.payload + guard case .toolRun(let completedA) = firstTool, + case .toolRun(let completedB) = secondTool else { + Issue.record("both results should retain independent structured tool payloads") + return + } + #expect(completedA.toolCallID == "tool_a") + #expect(completedA.output == "result a") + #expect(completedB.toolCallID == "tool_b") + #expect(completedB.output == "result b") + } + + @Test + func claudeToolResultImageContentEmitsPreviewAttachment() throws { + let callLine = #"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"tool_image","name":"Hook_Success","input":{"message":"created image"}}]}}"# + let resultLine = #"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool_image","content":[{"type":"text","text":"created preview"},{"type":"image","file_name":"hook-success.png","source":{"type":"base64","media_type":"image/png","data":"\#(Self.png1x1Base64)"}}],"is_error":false}]}}"# + let journalID = JournalID(rawValue: "journal") + var decoder = ClaudeTranscriptDecoder() + + let call = decoder.feed([callLine], startingAt: 0, journalID: journalID) + let result = decoder.feed([resultLine], startingAt: 10, journalID: journalID) + + #expect(kindTable(call.entries) == ["0:toolRun"]) + #expect(kindTable(result.entries) == ["10:toolRun", "11:attachment"]) + #expect(toolRunPayload(in: result, seq: 10)?.toolName == "Hook_Success") + #expect(toolRunPayload(in: result, seq: 10)?.resultSummary == "created preview") + let attachment = try #require(attachmentPayload(in: result, seq: 11)) + #expect(attachment.kind == "image") + #expect(attachment.displayName == "hook-success.png") + #expect(attachment.mimeType == "image/png") + #expect(attachment.width == 1) + #expect(attachment.height == 1) + #expect(attachment.byteCount == Self.png1x1Data.count) + #expect(result.embeddedImages.map(\.entrySeq) == [EntrySeq(rawValue: 11)]) + } + + @Test + func claudeToolResultGenericAttachmentCarriesAgentRoleAndAspectRatio() throws { + let callLine = #"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"tool_attachment","name":"Hook_Success","input":{"message":"created preview"}}]}}"# + let resultLine = #"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool_attachment","content":[{"type":"attachment","attachment":{"type":"image","fileName":"hook-preview","aspect_ratio":0.75}}],"is_error":false}]}}"# + var decoder = ClaudeTranscriptDecoder() + + let call = decoder.feed([callLine], startingAt: 0, journalID: JournalID(rawValue: "journal")) + let result = decoder.feed([resultLine], startingAt: 10, journalID: JournalID(rawValue: "journal")) + + #expect(kindTable(call.entries) == ["0:toolRun"]) + #expect(kindTable(result.entries) == ["10:toolRun", "11:attachment"]) + let attachment = try #require(attachmentPayload(in: result, seq: 11)) + #expect(attachment.kind == "image") + #expect(attachment.displayName == "hook-preview") + #expect(attachment.aspectRatio == 0.75) + #expect(attachment.authorRole == "agent") + } + + @Test + func claudeAssistantMarkdownImageRatioMetadataSurvivesWithoutImageFile() throws { + let line = #"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"![Claude Preview](/tmp/claude-preview.png \"ratio=0.75\")"}]}}"# + var decoder = ClaudeTranscriptDecoder() + let batch = decoder.feed([line], startingAt: 0, journalID: JournalID(rawValue: "journal")) + + #expect(kindTable(batch.entries) == ["0:attachment"]) + let attachment = try #require(attachmentPayload(in: batch, seq: 0)) + #expect(attachment.hostPath == "/tmp/claude-preview.png") + #expect(attachment.width == nil) + #expect(attachment.height == nil) + #expect(attachment.aspectRatio == 0.75) + } + + @Test + func claudeIncrementalBoundariesMatchOneShot() throws { + let lines = try fixtureLines("claude-synthetic") + var oneShot = ClaudeTranscriptDecoder() + let expected = oneShot.feed(lines, startingAt: 0, journalID: JournalID(rawValue: "journal")) + for split in 0 ... lines.count { + var decoder = ClaudeTranscriptDecoder() + let first = decoder.feed(Array(lines.prefix(split)), startingAt: 0, journalID: JournalID(rawValue: "journal")) + let second = decoder.feed(Array(lines.dropFirst(split)), startingAt: split, journalID: JournalID(rawValue: "journal")) + #expect(first.entries + second.entries == expected.entries) + #expect(mergedDiagnostics(first.diagnostics, second.diagnostics) == expected.diagnostics) + } + } + + @Test + func codexIncrementalBoundariesMatchOneShot() throws { + let lines = try fixtureLines("codex-synthetic") + var oneShot = CodexTranscriptDecoder() + let expected = oneShot.feed(lines, startingAt: 0, journalID: JournalID(rawValue: "journal")) + for split in 0 ... lines.count { + var decoder = CodexTranscriptDecoder() + let first = decoder.feed(Array(lines.prefix(split)), startingAt: 0, journalID: JournalID(rawValue: "journal")) + let second = decoder.feed(Array(lines.dropFirst(split)), startingAt: split, journalID: JournalID(rawValue: "journal")) + #expect(first.entries + second.entries == expected.entries) + #expect(mergedDiagnostics(first.diagnostics, second.diagnostics) == expected.diagnostics) + } + } + + private func fixtureLines(_ name: String) throws -> [String] { + let url = try #require(Bundle.module.url(forResource: name, withExtension: "jsonl")) + return try String(contentsOf: url, encoding: .utf8) + .split(separator: "\n", omittingEmptySubsequences: true) + .map(String.init) + } + + private func kindTable(_ entries: [EntrySnapshot]) -> [String] { + entries.map { "\($0.seq.rawValue):\($0.kind.rawValue)" } + } + + private func payload(in batch: TranscriptDecodeBatch, seq: Int) -> EntryPayload? { + batch.entries.first { $0.seq == EntrySeq(rawValue: seq) }?.content.payload + } + + private func userMessagePayload(in batch: TranscriptDecodeBatch, seq: Int) -> UserMessagePayload? { + if case .userMessage(let payload) = payload(in: batch, seq: seq) { + return payload + } + return nil + } + + private func toolRunPayload(in batch: TranscriptDecodeBatch, seq: Int) -> ToolRunPayload? { + if case .toolRun(let payload) = payload(in: batch, seq: seq) { + return payload + } + return nil + } + + private func fileChangePayload(in batch: TranscriptDecodeBatch, seq: Int) -> FileChangePayload? { + if case .fileChange(let payload) = payload(in: batch, seq: seq) { + return payload + } + return nil + } + + private func questionPayload(in batch: TranscriptDecodeBatch, seq: Int) -> QuestionPayload? { + if case .question(let payload) = payload(in: batch, seq: seq) { + return payload + } + return nil + } + + private func attachmentPayload(in batch: TranscriptDecodeBatch, seq: Int) -> AttachmentPayload? { + if case .attachment(let payload) = payload(in: batch, seq: seq) { + return payload + } + return nil + } + + private func statusPayload(in batch: TranscriptDecodeBatch, seq: Int) -> StatusPayload? { + if case .status(let payload) = payload(in: batch, seq: seq) { + return payload + } + return nil + } + + private func unknownPayload(in batch: TranscriptDecodeBatch, seq: Int) -> UnknownPayload? { + if case .unknown(let payload) = payload(in: batch, seq: seq) { + return payload + } + return nil + } + + private func mergedDiagnostics( + _ first: TranscriptDecoderDiagnostics, + _ second: TranscriptDecoderDiagnostics + ) -> TranscriptDecoderDiagnostics { + TranscriptDecoderDiagnostics( + unknownKindCounts: mergedCounts(first.unknownKindCounts, second.unknownKindCounts), + modeledKindCounts: mergedCounts(first.modeledKindCounts, second.modeledKindCounts), + duplicateStreamCounts: mergedCounts(first.duplicateStreamCounts, second.duplicateStreamCounts), + bookkeepingKindCounts: mergedCounts(first.bookkeepingKindCounts, second.bookkeepingKindCounts), + cliVersion: second.cliVersion ?? first.cliVersion, + phaseFacts: first.phaseFacts + second.phaseFacts, + turnContextFacts: first.turnContextFacts + second.turnContextFacts, + sawApiError: first.sawApiError || second.sawApiError, + sensitiveSessionTitles: first.sensitiveSessionTitles + second.sensitiveSessionTitles + ) + } + + private func mergedCounts(_ first: [String: Int], _ second: [String: Int]) -> [String: Int] { + first.merging(second) { lhs, rhs in lhs + rhs } + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/CmuxPopoverMutation.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/CmuxPopoverMutation.swift index fc82e4d9cd6c..0cf62f189752 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/CmuxPopoverMutation.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/CmuxPopoverMutation.swift @@ -38,6 +38,7 @@ public final class CmuxPopoverVisibleUpdateScheduler { } @MainActor +// lint:allow namespace-type - existing static helper surface, preserving call sites during UI closeout. public enum CmuxPopoverMutation { public static func performWithoutImplicitAnimation(_ body: () -> Void) { NSAnimationContext.runAnimationGroup { context in diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift index 12a5e864c25b..bad1ea600ce8 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift @@ -16,9 +16,9 @@ internal import Foundation /// The aliases mirror `processV2Command` exactly: `mobile.workspace.list` (the /// bare `workspace.list` stays on the legacy `v2WorkspaceList`), the /// `mobile.terminal.*` verbs each with their bare `terminal.*` alias, plus -/// `mobile.terminal.paste` / `terminal.paste` and the local debug -/// `chat.sessions.dump`. The worker-lane `mobile.attach_ticket.create` and the -/// mobile-data-plane-only verbs are NOT handled here: they reach the Mac only +/// `mobile.terminal.paste` / `terminal.paste`. The worker-lane +/// `mobile.attach_ticket.create` and mobile-data-plane-only verbs are NOT +/// handled here: they reach the Mac only /// through the mobile data-plane RPC (`TerminalController.mobileHostHandleRPC`), /// which dispatches its `v2Mobile*` bodies directly — it speaks /// `MobileHostRPCRequest` / `MobileHostRPCResult`, so routing it through this @@ -73,8 +73,6 @@ extension ControlCommandCoordinator { return context?.controlMobileTerminalPaste(params: request.params) case "mobile.task.attachment.upload": return context?.controlMobileTaskAttachmentUpload(params: request.params) - case "chat.sessions.dump": - return context?.controlMobileChatSessionsDump() default: return nil } diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift index e2c845c91c88..a5df8855d285 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift @@ -35,8 +35,8 @@ /// /// This seam serves only the v2 control socket (`processV2Command`, sync, /// main-actor), dispatched by ``ControlCommandCoordinator/handleMobileHost(_:)``: -/// the eight shared verbs plus `mobile.terminal.paste` / `terminal.paste` and the -/// local debug `chat.sessions.dump`. Every method is a thin pass-through; the app +/// the eight shared verbs plus `mobile.terminal.paste` / `terminal.paste`. +/// Every method is a thin pass-through; the app /// conformance runs the EXACT legacy body and bridges its Foundation payload to a /// ``JSONValue``. /// @@ -135,12 +135,6 @@ public protocol ControlMobileHostContext: AnyObject { nonisolated func controlMobileTaskModelsList( params: [String: JSONValue] ) async -> ControlCallResult - - /// `chat.sessions.dump` (local debug socket) — the full chat-session registry - /// dump, for diagnosing inconsistent phone-side chat state. - /// - /// - Returns: The fully-built command result. - func controlMobileChatSessionsDump() -> ControlCallResult } public extension ControlMobileHostContext { diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceHealthEntry.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceHealthEntry.swift index d522a26df971..2b3731b06a77 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceHealthEntry.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceHealthEntry.swift @@ -3,17 +3,17 @@ public import Foundation /// A read-only render-health row for one surface in the `surface.health` payload. /// /// Mirrors the legacy per-surface dictionary the `v2SurfaceHealth` body built. The -/// `inWindow` value is optional: the legacy body wrote a Bool for terminal/browser -/// panels and `NSNull` for any other panel type, so `nil` here maps to the same -/// JSON `null`. The coordinator mints the surface ref and writes the index. +/// `inWindow` value is optional: the app reports a Bool for render-backed panels +/// with inspectable host views and `NSNull` for panel kinds without a health +/// witness. The coordinator mints the surface ref and writes the index. public struct ControlSurfaceHealthEntry: Sendable, Equatable { /// The surface's panel identifier. public let surfaceID: UUID /// The panel type's raw value. public let typeRawValue: String - /// Whether the surface's hosting view is in a window: a Bool for terminal - /// (`isViewInWindow`) and browser (`webView.window != nil`) panels, `nil` - /// (JSON `null`) for any other panel type. + /// Whether the surface's hosting view is in a window: a Bool for terminal, + /// browser, and Agent Session panels, `nil` (JSON `null`) for panel kinds + /// without a health witness. public let inWindow: Bool? /// Creates a surface-health entry. @@ -22,7 +22,7 @@ public struct ControlSurfaceHealthEntry: Sendable, Equatable { /// - surfaceID: The surface's panel identifier. /// - typeRawValue: The panel type's raw value. /// - inWindow: Whether the surface's hosting view is in a window, or `nil` - /// for non-terminal/browser panels. + /// for panel kinds without a health witness. public init( surfaceID: UUID, typeRawValue: String, diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift index 33859e1841c9..e5db4478bbb9 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift @@ -94,6 +94,11 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { "browser.profiles.clear", "browser.profiles.delete", "browser.import.cookies", + // Starts an Agent Session provider if needed and writes user input to + // its stdin. Provider startup may resolve executables and spawn a + // subprocess, so the socket worker owns the wait instead of the main + // actor. + "agent_session.submit", "mobile.attach_ticket.create", // Provider discovery may read configuration or run `opencode models`; // it must never hold the main actor while waiting for process I/O. diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift index 026f35760b99..b62c7fe56920 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift @@ -568,5 +568,4 @@ extension ControlMobileHostContext { func controlMobileTerminalMouse(params: [String: JSONValue]) -> ControlCallResult { mobileHostStubResult } func controlMobileTerminalPaste(params: [String: JSONValue]) -> ControlCallResult { mobileHostStubResult } func controlMobileTaskAttachmentUpload(params: [String: JSONValue]) -> ControlCallResult { mobileHostStubResult } - func controlMobileChatSessionsDump() -> ControlCallResult { mobileHostStubResult } } diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorMobileHostTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorMobileHostTests.swift index 6989e69173d6..a5b8d665275e 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorMobileHostTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorMobileHostTests.swift @@ -68,10 +68,6 @@ private final class FakeMobileHostControlCommandContext: ControlCommandContext { record("task.models.list", params) } } - - func controlMobileChatSessionsDump() -> ControlCallResult { - record("chat.sessions.dump", [:]) - } } @MainActor @@ -97,12 +93,6 @@ struct ControlCommandCoordinatorMobileHostTests { #expect(context.lastMarker == "terminal.paste") } - @Test func v2SurfaceRoutesChatSessionsDumpThroughSeam() { - let (coordinator, context) = makeCoordinator() - #expect(coordinator.handle(request("chat.sessions.dump")) != nil) - #expect(context.lastMarker == "chat.sessions.dump") - } - @Test func v2SurfaceRoutesTaskAttachmentUploadThroughSeam() { let (coordinator, context) = makeCoordinator() #expect(coordinator.handle(request("mobile.task.attachment.upload")) != nil) @@ -127,6 +117,7 @@ struct ControlCommandCoordinatorMobileHostTests { ) } + @Test func v2SurfaceUsesPrivateHostStatusVariant() { let (coordinator, context) = makeCoordinator() #expect(coordinator.handle(request("mobile.host.status")) != nil) @@ -149,8 +140,8 @@ struct ControlCommandCoordinatorMobileHostTests { @Test func v2SurfaceMobileHostHandlerIgnoresDataPlaneOnlyVerbs() { let (coordinator, context) = makeCoordinator() // The mobile-host v2 dispatcher must NOT own the data-plane-only verbs - // (`mobile.chat.*`, `dogfood.feedback.submit`, the mobile workspace - // wrappers). The phone reaches those only through the mobile data-plane RPC + // (`dogfood.feedback.submit` and the mobile workspace wrappers). The + // phone reaches those only through the mobile data-plane RPC // (`TerminalController.mobileHostHandleRPC`), which dispatches its // `v2Mobile*` bodies directly and never transits this coordinator. // `handleMobileHost` returns nil for them so they never touch a mobile-host @@ -159,7 +150,6 @@ struct ControlCommandCoordinatorMobileHostTests { // `workspace.close` — so this asserts the mobile-host handler specifically, // not the umbrella `handle(_:)`.) for method in [ - "mobile.chat.sessions", "dogfood.feedback.submit", "mobile.attach_ticket.create", "workspace.action", diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift index 091b112fd6df..19c3474b7d5b 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift @@ -35,6 +35,7 @@ struct ControlCommandExecutionPolicyTests { "debug.sidebar.simulate_drag", "debug.mobile.transport.disconnect", "mobile.attach_ticket.create", "mobile.terminal.set_font", "mobile.task.models.list", + "agent_session.submit", // JavaScript-evaluating browser methods block on page JS and must // not hold the main actor (see socketWorkerMethods rationale). "browser.eval", "browser.wait", "browser.snapshot", "browser.click", @@ -144,6 +145,7 @@ struct ControlCommandExecutionPolicyTests { #expect(ControlCommandExecutionPolicy(forMethod: "system.top") == .socketWorker(mainThreadCallable: false)) #expect(ControlCommandExecutionPolicy(forMethod: "mobile.task.models.list") == .socketWorker(mainThreadCallable: false)) #expect(ControlCommandExecutionPolicy(forMethod: "vm.create") == .socketWorker(mainThreadCallable: false)) + #expect(ControlCommandExecutionPolicy(forMethod: "agent_session.submit") == .socketWorker(mainThreadCallable: false)) } @Test func terminalReadsRunOnTheWorkerAndAreNotMainThreadCallable() { diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index caf13dbf2156..96bc34051fe5 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -258013,4457 +258013,393 @@ } } }, - "notification.agentChat.serverUnavailable.subtitleLaunchFailed": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Could not start Agent Chat" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "エージェントチャットを起動できませんでした" - } - } - } - }, - "agent.campfire.capability.fallback": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "do something" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "操作を実行する" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "فعل شيء ما" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "uradi nešto" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "gøre noget" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "etwas tun" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "hacer algo" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "faire quelque chose" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "fare qualcosa" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "ធ្វើអ្វីមួយ" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "무언가 하기" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "gjøre noe" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "zrobić coś" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "fazer algo" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "сделать что-нибудь" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "ทำบางอย่าง" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "bir şey yapmak" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "зробити щось" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "执行操作" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "執行操作" - } - } - } - }, - "agent.campfire.capability.filesList": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "browse files" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ファイルを参照する" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "تصفح الملفات" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "pregleda datoteke" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "gennemse filer" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Dateien durchsuchen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "explorar archivos" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "parcourir les fichiers" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "sfogliare i file" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "រកមើលឯកសារ" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "파일 찾아보기" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "bla gjennom filer" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "przeglądać pliki" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "procurar arquivos" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "просматривать файлы" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "เรียกดูไฟล์" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "dosyalara göz atmak" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "переглядати файли" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "浏览文件" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "瀏覽檔案" - } - } - } - }, - "agent.campfire.capability.queueAdd": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "queue a prompt" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "プロンプトをキューに追加する" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "إضافة مطالبة إلى قائمة الانتظار" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "doda prompt u red" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "sætte en prompt i kø" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "einen Prompt in die Warteschlange stellen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "poner una instrucción en cola" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "mettre une invite en file d’attente" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "mettere un prompt in coda" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "ដាក់ប្រអប់បញ្ចូលក្នុងជួរ" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "프롬프트를 대기열에 추가" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "legge en ledetekst i kø" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "dodać prompt do kolejki" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "colocar um prompt na fila" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "добавить запрос в очередь" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "เพิ่มพรอมต์ในคิว" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "bir istemi kuyruğa almak" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "додати запит до черги" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "将提示加入队列" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "將提示加入佇列" - } - } - } - }, - "agent.campfire.capability.queueRunNow": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "run a prompt now" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "プロンプトを今すぐ実行する" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "تشغيل مطالبة الآن" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "odmah pokrene prompt" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "køre en prompt nu" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "einen Prompt jetzt ausführen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "ejecutar una instrucción ahora" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "exécuter une invite maintenant" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "eseguire un prompt ora" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "ដំណើរការប្រអប់បញ្ចូលឥឡូវនេះ" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "지금 프롬프트 실행" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "kjøre en ledetekst nå" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "uruchomić prompt teraz" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "executar um prompt agora" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "запустить запрос сейчас" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "เรียกใช้พรอมต์ตอนนี้" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "bir istemi şimdi çalıştırmak" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "запустити запит зараз" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "立即运行提示" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "立即執行提示" - } - } - } - }, - "agent.campfire.capability.sessionInterrupt": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "interrupt the agent" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "エージェントを中断する" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "مقاطعة الوكيل" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "prekine agenta" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "afbryde agenten" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "den Agenten unterbrechen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "interrumpir el agente" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "interrompre l’agent" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "interrompere l’agente" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "រំខានភ្នាក់ងារ" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "에이전트 중단" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "avbryte agenten" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "przerwać agenta" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "interromper o agente" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "прервать агента" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "ขัดจังหวะเอเจนต์" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "aracıyı kesintiye uğratmak" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "перервати агента" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "中断代理" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "中斷代理" - } - } - } - }, - "agent.campfire.capability.shellExec": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "run a shell command" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "シェルコマンドを実行する" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "تشغيل أمر shell" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "pokrene shell naredbu" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "køre en shell-kommando" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "einen Shell-Befehl ausführen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "ejecutar un comando de shell" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "exécuter une commande shell" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "eseguire un comando shell" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "ដំណើរការពាក្យបញ្ជា shell" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "셸 명령 실행" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "kjøre en skallkommando" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "uruchomić polecenie powłoki" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "executar um comando shell" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "выполнить команду оболочки" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "เรียกใช้คำสั่งเชลล์" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "bir kabuk komutu çalıştırmak" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "виконати команду оболонки" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "运行 shell 命令" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "執行 shell 指令" - } - } - } - }, - "agent.campfire.capability.toolsContribute": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "add tools or skills" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ツールまたはスキルを追加する" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "إضافة أدوات أو مهارات" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "doda alate ili vještine" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "tilføje værktøjer eller færdigheder" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Tools oder Fähigkeiten hinzufügen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "agregar herramientas o habilidades" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "ajouter des outils ou des compétences" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "aggiungere strumenti o competenze" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "បន្ថែមឧបករណ៍ ឬជំនាញ" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "도구 또는 스킬 추가" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "legge til verktøy eller ferdigheter" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "dodać narzędzia lub umiejętności" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "adicionar ferramentas ou habilidades" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "добавить инструменты или навыки" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "เพิ่มเครื่องมือหรือทักษะ" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "araçlar veya beceriler eklemek" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "додати інструменти або навички" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "添加工具或技能" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "新增工具或技能" - } - } - } - }, - "agent.campfire.notification.body.joinRequested": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%@ is waiting to join the Campfire session" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ が Campfire セッションへの参加を待っています" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "%@ ينتظر الانضمام إلى جلسة Campfire" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "%@ čeka da se pridruži Campfire sesiji" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "%@ venter på at deltage i Campfire-sessionen" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "%@ wartet darauf, der Campfire-Sitzung beizutreten" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "%@ está esperando unirse a la sesión de Campfire" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "%@ attend de rejoindre la session Campfire" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "%@ sta aspettando di unirsi alla sessione Campfire" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "%@ កំពុងរង់ចាំចូលរួមសម័យ Campfire" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@님이 Campfire 세션 참여를 기다리고 있습니다" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "%@ venter på å bli med i Campfire-økten" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "%@ czeka na dołączenie do sesji Campfire" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "%@ está aguardando para entrar na sessão do Campfire" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "%@ ожидает присоединения к сеансу Campfire" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "%@ กำลังรอเข้าร่วมเซสชัน Campfire" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "%@ Campfire oturumuna katılmayı bekliyor" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "%@ очікує на приєднання до сеансу Campfire" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%@ 正在等待加入 Campfire 会话" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%@ 正在等待加入 Campfire 工作階段" - } - } - } - }, - "agent.campfire.notification.body.permissionAsked": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%1$@ asked for permission to %2$@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%1$@ が %2$@ 権限を求めています" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "طلب %1$@ إذنًا لـ %2$@" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "%1$@ je zatražio dozvolu da %2$@" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "%1$@ bad om tilladelse til at %2$@" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "%1$@ hat um Erlaubnis gebeten, %2$@" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "%1$@ pidió permiso para %2$@" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "%1$@ a demandé l’autorisation de %2$@" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "%1$@ ha chiesto l’autorizzazione a %2$@" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "%1$@ បានស្នើសុំការអនុញ្ញាតដើម្បី %2$@" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%1$@님이 %2$@ 권한을 요청했습니다" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "%1$@ ba om tillatelse til å %2$@" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "%1$@ poprosił o pozwolenie, aby %2$@" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "%1$@ pediu permissão para %2$@" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "%1$@ запросил разрешение: %2$@" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "%1$@ ขออนุญาตเพื่อ%2$@" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "%1$@ %2$@ için izin istedi" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "%1$@ запитав дозвіл: %2$@" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%1$@ 请求权限以%2$@" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%1$@ 要求權限以%2$@" - } - } - } - }, - "agent.campfire.notification.body.relayError": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Campfire relay connection failed" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Campfire リレー接続に失敗しました" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "فشل اتصال ترحيل Campfire" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Veza s Campfire relejem nije uspjela" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Campfire-relayforbindelsen mislykkedes" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Campfire-Relay-Verbindung fehlgeschlagen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Falló la conexión de retransmisión de Campfire" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "La connexion au relais Campfire a échoué" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Connessione al relay Campfire non riuscita" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "ការតភ្ជាប់រ៉ីឡេ Campfire បានបរាជ័យ" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "Campfire 릴레이 연결에 실패했습니다" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Campfire-relétilkoblingen mislyktes" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Połączenie przekaźnika Campfire nie powiodło się" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Falha na conexão de retransmissão do Campfire" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Не удалось подключиться к ретранслятору Campfire" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "การเชื่อมต่อรีเลย์ Campfire ล้มเหลว" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Campfire aktarma bağlantısı başarısız oldu" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Не вдалося підключитися до ретранслятора Campfire" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "Campfire 中继连接失败" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "Campfire 轉送連線失敗" - } - } - } - }, - "agent.campfire.notification.participantFallback": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Someone" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "参加者" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "شخص ما" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Neko" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Nogen" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Jemand" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Alguien" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Quelqu’un" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Qualcuno" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "នរណាម្នាក់" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "누군가" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Noen" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Ktoś" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Alguém" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Кто-то" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "บางคน" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Birisi" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Хтось" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "有人" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "有人" - } - } - } - }, - "cli.hooks.campfire.aborted": { - "extractionState": "manual", - "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "أُلغيَت العملية." - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Prekinuto." - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Afbrudt." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Abgebrochen." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Aborted." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Operación cancelada." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Opération annulée." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Operazione annullata." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "中止しました。" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "បានបោះបង់។" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "중단했습니다." - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Avbrutt." - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Przerwano." - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Abortado." - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Операция прервана." - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "ยกเลิกแล้ว" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "İptal edildi." - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Операцію скасовано." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "已中止。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "已中止。" - } - } - } - }, - "cli.hooks.campfire.alreadyUpToDate": { - "extractionState": "manual", - "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "خطافات Campfire محدَّثة بالفعل في %@" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Campfire hookovi su već ažurirani u %@" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Campfire-hooks er allerede opdaterede i %@" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Campfire-Hooks sind in %@ bereits auf dem neuesten Stand" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Campfire hooks already up to date at %@" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Los hooks de Campfire ya están actualizados en %@" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Les hooks Campfire sont déjà à jour dans %@" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Gli hook di Campfire sono già aggiornati in %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ の Campfire フックはすでに最新です" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "hooks របស់ Campfire នៅ %@ ទាន់សម័យរួចហើយ" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@의 Campfire 훅은 이미 최신 상태입니다" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Campfire-hooks er allerede oppdatert i %@" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Hooki Campfire są już aktualne w %@" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Os hooks do Campfire já estão atualizados em %@" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Хуки Campfire в %@ уже актуальны" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "ฮุก Campfire ที่ %@ เป็นเวอร์ชันล่าสุดอยู่แล้ว" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Campfire kancaları %@ konumunda zaten güncel" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Хуки Campfire у %@ вже актуальні" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%@ 中的 Campfire hooks 已是最新" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%@ 中的 Campfire hooks 已是最新" - } - } - } - }, - "cli.hooks.campfire.confirmProceed": { - "extractionState": "manual", - "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "\nهل تريد المتابعة؟ [y/N] " - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "\nNastaviti? [y/N] " - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "\nFortsæt? [y/N] " - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "\nFortfahren? [y/N] " - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "\nProceed? [y/N] " - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "\n¿Continuar? [y/N] " - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "\nContinuer ? [y/N] " - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "\nProcedere? [y/N] " - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "\n続行しますか? [y/N] " - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "\nបន្តឬ? [y/N] " - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "\n계속하시겠습니까? [y/N] " - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "\nFortsette? [y/N] " - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "\nKontynuować? [y/N] " - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "\nProsseguir? [y/N] " - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "\nПродолжить? [y/N] " - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "\nดำเนินการต่อหรือไม่? [y/N] " - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "\nDevam edilsin mi? [y/N] " - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "\nПродовжити? [y/N] " - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "\n是否继续? [y/N] " - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "\n是否繼續? [y/N] " - } - } - } - }, - "cli.hooks.campfire.error.notCmuxExtension": { - "extractionState": "manual", - "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "%@ موجود وليس امتداد cmux؛ سيتم تركه كما هو" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "%@ postoji i nije cmux ekstenzija; ostavljam ga netaknutim" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "%@ findes og er ikke en cmux-udvidelse; lader den være" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "%@ existiert und ist keine cmux-Erweiterung; wird unverändert belassen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "%@ exists and is not a cmux extension; leaving it alone" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "%@ existe y no es una extensión de cmux; se dejará intacto" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "%@ existe et n’est pas une extension cmux ; aucun changement n’y sera apporté" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "%@ esiste e non è un'estensione cmux; verrà lasciato invariato" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ は存在しますが cmux 拡張ではありません。そのままにします" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "%@ មានរួចហើយ ហើយមិនមែនជា​ផ្នែកបន្ថែម cmux ទេ; ទុកវាដូចដើម" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@이(가) 존재하지만 cmux 확장이 아니므로 그대로 둡니다" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "%@ finnes og er ikke en cmux-utvidelse; lar den være i fred" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "%@ istnieje i nie jest rozszerzeniem cmux; pozostawiam bez zmian" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "%@ existe e não é uma extensão do cmux; será deixado como está" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "%@ существует и не является расширением cmux; оставляем без изменений" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "%@ มีอยู่แล้วและไม่ใช่ส่วนขยาย cmux; จะปล่อยไว้ตามเดิม" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "%@ mevcut ve bir cmux uzantısı değil; olduğu gibi bırakılıyor" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "%@ існує і не є розширенням cmux; залишаємо без змін" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%@ 已存在且不是 cmux 扩展;将保持不变" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%@ 已存在且不是 cmux 擴充功能;將維持原樣" - } - } - } - }, - "cli.hooks.campfire.error.readFailed": { - "extractionState": "manual", - "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "فشل في قراءة %@" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Čitanje %@ nije uspjelo" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Kunne ikke læse %@" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Fehler beim Lesen von %@" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Failed to read %@" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No se pudo leer %@" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Échec de la lecture de %@" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Impossibile leggere %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ の読み取りに失敗しました" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "អាន %@ មិនបានសម្រេច" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@을(를) 읽지 못했습니다" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Klarte ikke å lese %@" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Nie udało się odczytać %@" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Falha ao ler %@" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Не удалось прочитать %@" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "อ่าน %@ ไม่สำเร็จ" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "%@ okunamadı" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Не вдалося прочитати %@" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "读取 %@ 失败" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "讀取 %@ 失敗" - } - } - } - }, - "cli.hooks.campfire.installed": { - "extractionState": "manual", - "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "تم تثبيت خطافات Campfire في %@" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Campfire hookovi su instalirani u %@" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Campfire-hooks er installeret i %@" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Campfire-Hooks wurden in %@ installiert" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Campfire hooks installed at %@" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Hooks de Campfire instalados en %@" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Hooks Campfire installés dans %@" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Hook di Campfire installati in %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Campfire フックを %@ にインストールしました" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "បានដំឡើង hooks របស់ Campfire នៅ %@" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@에 Campfire 훅을 설치했습니다" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Campfire-hooks er installert i %@" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Zainstalowano hooki Campfire w %@" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Hooks do Campfire instalados em %@" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Хуки Campfire установлены в %@" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "ติดตั้งฮุก Campfire ที่ %@ แล้ว" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Campfire kancaları %@ konumuna kuruldu" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Хуки Campfire встановлено в %@" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "已在 %@ 安装 Campfire hooks" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "已在 %@ 安裝 Campfire hooks" - } - } - } - }, - "cli.hooks.campfire.noneFound": { - "extractionState": "manual", - "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "لم يتم العثور على امتداد Campfire cmux في %@" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Nijedna Campfire cmux ekstenzija nije pronađena u %@" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Ingen Campfire cmux-udvidelse fundet i %@" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Keine Campfire-cmux-Erweiterung in %@ gefunden" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "No Campfire cmux extension found at %@" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No se encontró ninguna extensión Campfire cmux en %@" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Aucune extension Campfire cmux trouvée dans %@" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Nessuna estensione Campfire cmux trovata in %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ に Campfire cmux 拡張が見つかりません" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "រកមិនឃើញ​ផ្នែកបន្ថែម Campfire cmux នៅ %@" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@에서 Campfire cmux 확장을 찾을 수 없습니다" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Fant ingen Campfire cmux-utvidelse i %@" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Nie znaleziono rozszerzenia Campfire cmux w %@" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Nenhuma extensão Campfire cmux encontrada em %@" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Расширение Campfire cmux в %@ не найдено" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "ไม่พบส่วนขยาย Campfire cmux ที่ %@" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "%@ konumunda Campfire cmux uzantısı bulunamadı" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Розширення Campfire cmux у %@ не знайдено" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "未在 %@ 找到 Campfire cmux 扩展" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "未在 %@ 找到 Campfire cmux 擴充功能" - } - } - } - }, - "cli.hooks.campfire.refuseRemoveMissingMarker": { - "extractionState": "manual", - "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "تم رفض إزالة %@: علامة cmux مفقودة" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Odbijam uklanjanje %@: nedostaje cmux oznaka" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Nægter at fjerne %@: manglende cmux-markør" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "%@ wird nicht entfernt: cmux-Markierung fehlt" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Refusing to remove %@: missing cmux marker" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Se rechaza eliminar %@: falta el marcador de cmux" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Suppression de %@ refusée : marqueur cmux manquant" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Rimozione di %@ rifiutata: marcatore cmux mancante" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ の削除を拒否しました: cmux マーカーがありません" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "បដិសេធមិនលុប %@៖ បាត់សញ្ញាសម្គាល់ cmux" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@ 삭제를 거부합니다: cmux 마커가 없습니다" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Nekter å fjerne %@: manglende cmux-markør" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Odmowa usunięcia %@: brak znacznika cmux" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Recusando remover %@: marcador do cmux ausente" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Отказ в удалении %@: отсутствует маркер cmux" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "ปฏิเสธที่จะลบ %@: ไม่มีเครื่องหมาย cmux" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "%@ kaldırılmayı reddediliyor: cmux işareti eksik" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Відмовлено у видаленні %@: відсутній маркер cmux" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "拒绝移除 %@:缺少 cmux 标记" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "拒絕移除 %@:缺少 cmux 標記" - } - } - } - }, - "cli.hooks.campfire.removed": { - "extractionState": "manual", - "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "تمت إزالة امتداد Campfire cmux من %@" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Campfire cmux ekstenzija je uklonjena iz %@" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Campfire cmux-udvidelse blev fjernet fra %@" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Campfire-cmux-Erweiterung aus %@ entfernt" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Removed Campfire cmux extension from %@" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Se eliminó la extensión Campfire cmux de %@" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Extension Campfire cmux supprimée de %@" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Estensione Campfire cmux rimossa da %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ から Campfire cmux 拡張を削除しました" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "បានលុប​ផ្នែកបន្ថែម Campfire cmux ចេញពី %@" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@에서 Campfire cmux 확장을 제거했습니다" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Fjernet Campfire cmux-utvidelse fra %@" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Usunięto rozszerzenie Campfire cmux z %@" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Extensão Campfire cmux removida de %@" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Расширение Campfire cmux удалено из %@" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "ลบส่วนขยาย Campfire cmux ออกจาก %@ แล้ว" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Campfire cmux uzantısı %@ konumundan kaldırıldı" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Розширення Campfire cmux видалено з %@" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "已从 %@ 移除 Campfire cmux 扩展" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "已從 %@ 移除 Campfire cmux 擴充功能" - } - } - } - }, - "settings.search.alias.section.networking": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "iroh relay relays server fleet provider region custom self hosted private network tailscale vpn direct peer" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "iroh リレー サーバー フリート プロバイダー リージョン カスタム セルフホスト プライベートネットワーク tailscale vpn 直接 ピア" - } - } - } - }, - "settings.search.alias.setting.networking.customRelays": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "iroh custom relay self hosted server url secret credential device authentication" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "iroh カスタム リレー セルフホスト サーバー url シークレット 認証情報 デバイス 認証" - } - } - } - }, - "settings.search.alias.setting.networking.policy": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "relay policy signed verified catalog cached server expiry sequence security" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "リレー ポリシー 署名 検証済み カタログ キャッシュ サーバー 有効期限 シーケンス セキュリティ" - } - } - } - }, - "settings.search.alias.setting.networking.privateNetworks": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "private network lan vpn direct peer path local address generic provider automatic" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "プライベートネットワーク lan vpn 直接 ピア パス ローカル アドレス 汎用 プロバイダー 自動" - } - } - } - }, - "settings.search.alias.setting.networking.relayPreference": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "iroh relay preference automatic managed selected cmux fleet provider region account" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "iroh リレー 設定 自動 管理 選択 cmux フリート プロバイダー リージョン アカウント" - } - } - } - }, - "settings.search.alias.setting.networking.status": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "iroh status diagnostics direct peer relay degraded active inactive refresh" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "iroh ステータス 診断 直接 ピア リレー 縮退 有効 無効 更新" - } - } - } - }, - "settings.search.alias.setting.networking.tailscaleCompatibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "tailscale tailnet private ip compatibility vpn legacy tcp automatic" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "tailscale tailnet プライベート ip 互換性 vpn レガシー tcp 自動" - } - } - } - }, - "settings.common.cancel": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Cancel" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "キャンセル" - } - } - } - }, - "settings.common.edit": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Edit" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "編集" - } - } - } - }, - "settings.common.ok": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "OK" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "OK" - } - } - } - }, - "settings.common.remove": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Remove" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "削除" - } - } - } - }, - "settings.common.save": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Save" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "保存" - } - } - } - }, - "settings.section.networking": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Networking" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ネットワーク" - } - } - } - }, - "settings.networking.attention": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Your saved relay choice needs attention. Direct Iroh remains available, but cmux will not substitute an unselected relay." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "保存したリレーの選択を確認してください。Iroh の直接接続は利用できますが、cmux が未選択のリレーへ切り替えることはありません。" - } - } - } - }, - "settings.networking.custom.add": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Add Custom Relay" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "カスタムリレーを追加" - } - } - } - }, - "settings.networking.custom.add.short": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Add…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "追加…" - } - } - } - }, - "settings.networking.custom.authentication": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Authentication" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "認証" - } - } - } - }, - "settings.networking.custom.authentication.none": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "None" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "なし" - } - } - } - }, - "settings.networking.custom.authentication.secret": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Device Secret" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "デバイスシークレット" - } - } - } - }, - "settings.networking.custom.edit": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Edit Custom Relay" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "カスタムリレーを編集" - } - } - } - }, - "settings.networking.custom.name": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Name" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "名前" - } - } - } - }, - "settings.networking.custom.note": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Relay addresses sync with your account. Provider secrets stay only in each device's secure storage. Missing secrets never fall back to cmux relays." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "リレーアドレスはアカウントと同期されます。プロバイダーのシークレットは各デバイスの安全なストレージにのみ保存されます。シークレットがない場合に cmux リレーへ切り替わることはありません。" - } - } - } - }, - "settings.networking.custom.provider": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Provider" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "プロバイダー" - } - } - } - }, - "settings.networking.custom.region": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Region" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "リージョン" - } - } - } - }, - "settings.networking.custom.remove.confirm": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Remove this custom relay?" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "このカスタムリレーを削除しますか?" - } - } - } - }, - "settings.networking.custom.secret": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Relay Secret" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "リレーシークレット" - } - } - } - }, - "settings.networking.custom.secret.keep": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "New Secret (leave blank to keep current)" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "新しいシークレット(現在の値を維持する場合は空欄)" - } - } - } - }, - "settings.networking.custom.secret.note": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "The secret stays in this device's secure storage and never syncs with your account." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "シークレットはこのデバイスの安全なストレージに保存され、アカウントとは同期されません。" - } - } - } - }, - "settings.networking.custom.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Use relays you operate or obtain from another provider." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "自分で運用するリレー、または別のプロバイダーのリレーを使用します。" - } - } - } - }, - "settings.networking.custom.summary": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%1$@ · %2$@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%1$@ · %2$@" - } - } - } - }, - "settings.networking.custom.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Custom Relays" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "カスタムリレー" - } - } - } - }, - "settings.networking.custom.url": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Relay URL" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "リレー URL" - } - } - } - }, - "settings.networking.policy": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Relay Policy" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "リレーポリシー" - } - } - } - }, - "settings.networking.policy.cached": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Using the last verified catalog" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "最後に検証済みのカタログを使用中" - } - } - } - }, - "settings.networking.policy.server": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Verified from cmux" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "cmux から検証済み" - } - } - } - }, - "settings.networking.policy.unavailable": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No valid catalog available" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "有効なカタログがありません" - } - } - } - }, - "settings.networking.preference.automatic": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Automatic" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "自動" - } - } - } - }, - "settings.networking.preference.custom": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Custom Relays" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "カスタムリレー" - } - } - } - }, - "settings.networking.preference.managed": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Selected cmux Relays" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "選択した cmux リレー" - } - } - } - }, - "settings.networking.private.add": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Add Private Network" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "プライベートネットワークを追加" - } - } - } - }, - "settings.networking.private.add.short": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Add…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "追加…" - } - } - } - }, - "settings.networking.private.edit": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Edit Private Network" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "プライベートネットワークを編集" - } - } - } - }, - "settings.networking.private.enabled": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Enabled" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "有効" - } - } - } - }, - "settings.networking.private.host": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Host or IP Address" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ホストまたは IP アドレス" - } - } - } - }, - "settings.networking.private.name": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Name" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "名前" - } - } - } - }, - "settings.networking.private.note": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Use this for Tailscale alternatives, a company VPN, or a private network you operate. Authentication still happens before cmux accepts the route." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Tailscale の代替、会社の VPN、または自分で運用するプライベートネットワークに使用します。cmux がルートを受け入れる前に認証が行われます。" - } - } - } - }, - "settings.networking.private.note.short": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Custom raw TCP routes are not accepted because they cannot prove the remote Mac. Iroh private paths stay encrypted and bound to its exact EndpointID." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "カスタムの生 TCP ルートはリモート Mac を証明できないため受け付けません。Iroh のプライベート経路は暗号化され、正確な EndpointID に結び付けられます。" - } - } - } - }, - "settings.networking.private.port": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Port" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ポート" - } - } - } - }, - "settings.networking.private.provider": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Provider" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "プロバイダー" - } - } - } - }, - "settings.networking.diagnostics.clear": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Clear Report" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "レポートを消去" - } - } - } - }, - "settings.networking.diagnostics.clear.confirm": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Clear this diagnostic report?" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "この診断レポートを消去しますか?" - } - } - } - }, - "settings.networking.diagnostics.clear.message": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "This permanently removes the connection timeline stored on this Mac." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "この Mac に保存された接続タイムラインを完全に削除します。" - } - } - } - }, - "settings.networking.diagnostics.eventCount": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Recorded Events" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "記録済みイベント" - } - } - } - }, - "settings.networking.diagnostics.failure.accountMismatch": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Account Mismatch" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アカウントが一致しません" - } - } - } - }, - "settings.networking.diagnostics.failure.admissionDenied": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Connection Admission Denied" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "接続の受け入れが拒否されました" - } - } - } - }, - "settings.networking.diagnostics.failure.admissionLeaseExpired": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Admission Lease Expired" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "受け入れリースの期限切れ" - } - } - } - }, - "settings.networking.diagnostics.failure.admissionRevalidationFailed": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Admission Revalidation Failed" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "受け入れの再検証に失敗しました" - } - } - } - }, - "settings.networking.diagnostics.failure.authorizationFailed": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Authorization Failed" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "認可に失敗" - } - } - } - }, - "settings.networking.diagnostics.failure.cancelled": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Cancelled" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "キャンセル" - } - } - } - }, - "settings.networking.diagnostics.failure.connectionClosed": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Connection Closed" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "接続が終了しました" - } - } - } - }, - "settings.networking.diagnostics.failure.connectionRefused": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Connection Refused" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "接続が拒否されました" - } - } - } - }, - "settings.networking.diagnostics.failure.credentialUnavailable": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Credentials Unavailable" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "認証情報を利用できません" - } - } - } - }, - "settings.networking.diagnostics.failure.dnsFailed": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Name Resolution Failed" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "名前解決に失敗" - } - } - } - }, - "settings.networking.diagnostics.failure.endpointUnavailable": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Endpoint Unavailable" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "エンドポイントを利用できません" - } - } - } - }, - "settings.networking.diagnostics.failure.hostUnreachable": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Host Unreachable" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ホストに到達できません" - } - } - } - }, - "settings.networking.diagnostics.failure.identityMismatch": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Endpoint Identity Mismatch" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "エンドポイント ID が一致しません" - } - } - } - }, - "settings.networking.diagnostics.failure.noRoute": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No Route Available" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "利用可能な経路がありません" - } - } - } - }, - "settings.networking.diagnostics.failure.none": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "None" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "なし" - } - } - } - }, - "settings.networking.diagnostics.failure.offline": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Offline" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "オフライン" - } - } - } - }, - "settings.networking.diagnostics.failure.permissionDenied": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Permission Denied" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "権限がありません" - } - } - } - }, - "settings.networking.diagnostics.failure.policyUnavailable": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Relay Policy Unavailable" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "リレーポリシーを利用できません" - } - } - } - }, - "settings.networking.diagnostics.failure.protocolViolation": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Protocol Error" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "プロトコルエラー" - } - } - } - }, - "settings.networking.diagnostics.failure.routeGated": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Connection Attempt Held" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "接続試行が一時的に保留されました" - } - } - } - }, - "settings.networking.diagnostics.failure.secureChannelFailed": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Secure Channel Failed" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "安全なチャネルの確立に失敗" - } - } - } - }, - "settings.networking.diagnostics.failure.sendQueueOverflow": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Send Queue Overflow" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "送信キューオーバーフロー" - } - } - } - }, - "settings.networking.diagnostics.failure.superseded": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Replaced by a Newer Attempt" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "新しい接続試行に置き換えられました" - } - } - } - }, - "settings.networking.diagnostics.failure.timedOut": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Timed Out" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "タイムアウト" - } - } - } - }, - "settings.networking.diagnostics.failure.transportIdleTimedOut": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Transport Idle Timeout" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "接続アイドルタイムアウト" - } - } - } - }, - "settings.networking.diagnostics.failure.unknown": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Unknown" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "不明" - } - } - } - }, - "settings.networking.diagnostics.failure.unsupportedRoute": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Unsupported Route" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "サポートされていない経路" - } - } - } - }, - "settings.networking.diagnostics.lastFailure": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Last Failure" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "最後のエラー" - } - } - } - }, - "settings.networking.diagnostics.lastSuccess": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Last Successful Connection" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "最終接続成功" - } - } - } - }, - "settings.networking.diagnostics.notRecorded": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Not Recorded" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "記録なし" - } - } - } - }, - "settings.networking.diagnostics.privacy": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "The report stays on this Mac until you share it. It excludes terminal content, account and endpoint identities, network addresses, relay URLs, credentials, and raw errors." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "このレポートは共有するまでこの Mac にのみ保存されます。ターミナルの内容、アカウントおよびエンドポイントの識別情報、ネットワークアドレス、リレー URL、認証情報、生のエラーは含まれません。" - } - } - } - }, - "settings.networking.diagnostics.report": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Connection Report" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "接続レポート" - } - } - } - }, - "settings.networking.diagnostics.report.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Share a bounded, privacy-safe connection timeline with support." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "範囲が限定されたプライバシー保護済みの接続タイムラインをサポートと共有します。" - } - } - } - }, - "settings.networking.diagnostics.share": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Share…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "共有…" - } - } - } - }, - "settings.networking.private.remove.confirm": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Remove this private network?" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "このプライベートネットワークを削除しますか?" - } - } - } - }, - "settings.networking.private.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Iroh discovers usable LAN and VPN paths after authenticating the other device." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Iroh は相手デバイスを認証した後、利用可能な LAN および VPN 経路を検出します。" - } - } - } - }, - "settings.networking.private.automatic": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Automatic" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "自動" - } - } - } - }, - "settings.networking.private.tailscale": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Tailscale Compatibility" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Tailscale 互換" - } - } - } - }, - "settings.networking.private.tailscale.subtitle": { + "settings.networking.diagnostics.failure.admissionLeaseExpired": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Retained for released clients and networks where Iroh cannot connect." + "value": "Admission Lease Expired" } }, "ja": { "stringUnit": { "state": "translated", - "value": "リリース済みクライアントと Iroh が接続できないネットワーク向けに維持されます。" + "value": "受け入れリースの期限切れ" } } } }, - "settings.networking.private.title": { + "settings.networking.diagnostics.failure.admissionRevalidationFailed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Private Networks" + "value": "Admission Revalidation Failed" } }, "ja": { "stringUnit": { "state": "translated", - "value": "プライベートネットワーク" + "value": "受け入れの再検証に失敗しました" } } } }, - "settings.networking.refresh": { + "settings.networking.diagnostics.failure.authorizationFailed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Refresh" + "value": "Authorization Failed" } }, "ja": { "stringUnit": { "state": "translated", - "value": "更新" + "value": "認可に失敗" } } } }, - "settings.networking.relayOnly": { + "settings.networking.diagnostics.failure.cancelled": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Relay Only" + "value": "Cancelled" } }, "ja": { "stringUnit": { "state": "translated", - "value": "リレーのみ" + "value": "キャンセル" } } } }, - "settings.networking.relayOnly.subtitle": { + "settings.networking.diagnostics.failure.connectionClosed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Keeps Iroh connections to this Mac on cmux relays instead of direct or local-network paths. Applies on the next reconnect." + "value": "Connection Closed" } }, "ja": { "stringUnit": { "state": "translated", - "value": "この Mac への Iroh 接続を、直接経路やローカルネットワーク経路ではなく cmux リレー経由に固定します。次回の再接続時に適用されます。" + "value": "接続が終了しました" } } } }, - "settings.networking.relayPolicy.note": { + "settings.networking.diagnostics.failure.connectionRefused": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "cmux downloads a signed relay catalog. Fleet additions, removals, and regional changes do not require an app update." + "value": "Connection Refused" } }, "ja": { "stringUnit": { "state": "translated", - "value": "cmux は署名済みリレーカタログを取得します。フリートの追加、削除、リージョン変更にアプリ更新は不要です。" + "value": "接続が拒否されました" } } } }, - "settings.networking.relayPreference": { + "settings.networking.diagnostics.failure.credentialUnavailable": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Relay Preference" + "value": "Credentials Unavailable" } }, "ja": { "stringUnit": { "state": "translated", - "value": "リレー設定" + "value": "認証情報を利用できません" } } } }, - "settings.networking.relayPreference.subtitle": { + "settings.networking.diagnostics.failure.dnsFailed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Direct peer-to-peer stays enabled. This controls which relays Iroh may use when a direct path is unavailable." + "value": "Name Resolution Failed" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ピアツーピアの直接接続は有効なままです。直接経路が利用できない場合に Iroh が使用できるリレーを指定します。" + "value": "名前解決に失敗" } } } }, - "settings.networking.saveFailed": { + "settings.networking.diagnostics.failure.endpointUnavailable": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Could Not Save Networking Settings" + "value": "Endpoint Unavailable" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ネットワーク設定を保存できませんでした" + "value": "エンドポイントを利用できません" } } } }, - "settings.networking.saveFailed.message": { + "settings.networking.diagnostics.failure.hostUnreachable": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Your previous networking configuration is still active. Check your account connection and relay values, then try again." + "value": "Host Unreachable" } }, "ja": { "stringUnit": { "state": "translated", - "value": "以前のネットワーク設定が引き続き有効です。アカウント接続とリレーの値を確認して、もう一度お試しください。" + "value": "ホストに到達できません" } } } }, - "settings.networking.status": { + "settings.networking.diagnostics.failure.identityMismatch": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Iroh Status" + "value": "Endpoint Identity Mismatch" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Iroh の状態" + "value": "エンドポイント ID が一致しません" } } } }, - "settings.networking.status.active": { + "settings.networking.diagnostics.failure.noRoute": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Iroh endpoint active" + "value": "No Route Available" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Iroh エンドポイントは稼働中" + "value": "利用可能な経路がありません" } } } }, - "settings.networking.status.degraded": { + "settings.networking.diagnostics.failure.none": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Direct-only until relay settings recover" + "value": "None" } }, "ja": { "stringUnit": { "state": "translated", - "value": "リレー設定が復旧するまで直接接続のみ" + "value": "なし" } } } }, - "settings.networking.status.direct": { + "settings.networking.diagnostics.failure.offline": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Connected directly peer-to-peer" + "value": "Offline" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ピアツーピアで直接接続" + "value": "オフライン" } } } }, - "settings.networking.status.inactive": { + "settings.networking.diagnostics.failure.permissionDenied": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Inactive" + "value": "Permission Denied" } }, "ja": { "stringUnit": { "state": "translated", - "value": "停止中" + "value": "権限がありません" } } } }, - "settings.networking.status.private": { + "settings.networking.diagnostics.failure.policyUnavailable": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Connected through %@" + "value": "Relay Policy Unavailable" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@ 経由で接続" + "value": "リレーポリシーを利用できません" } } } }, - "settings.networking.status.private.generic": { + "settings.networking.diagnostics.failure.protocolViolation": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Connected through a private network" + "value": "Protocol Error" } }, "ja": { "stringUnit": { "state": "translated", - "value": "プライベートネットワーク経由で接続" + "value": "プロトコルエラー" } } } }, - "settings.networking.status.relayed": { + "settings.networking.diagnostics.failure.routeGated": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Connected through %1$@, %2$@" + "value": "Connection Attempt Held" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%1$@(%2$@)経由で接続" + "value": "接続試行が一時的に保留されました" } } } }, - "settings.networking.status.starting": { + "settings.networking.diagnostics.failure.secureChannelFailed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Starting" + "value": "Secure Channel Failed" } }, "ja": { "stringUnit": { "state": "translated", - "value": "起動中" + "value": "安全なチャネルの確立に失敗" } } } }, - "settings.networking.test": { + "settings.networking.diagnostics.failure.sendQueueOverflow": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Test" + "value": "Send Queue Overflow" } }, "ja": { "stringUnit": { "state": "translated", - "value": "テスト" + "value": "送信キューオーバーフロー" } } } }, - "settings.networking.test.failed": { + "settings.networking.diagnostics.failure.transportIdleTimedOut": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "unreachable" + "value": "Transport Idle Timeout" } }, "ja": { "stringUnit": { "state": "translated", - "value": "到達不可" + "value": "接続アイドルタイムアウト" } } } }, - "settings.networking.test.incomplete": { + "settings.networking.relayOnly": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "test unavailable" + "value": "Relay Only" } }, "ja": { "stringUnit": { "state": "translated", - "value": "テスト利用不可" + "value": "リレーのみ" } } } }, - "settings.networking.test.reachable": { + "settings.networking.relayOnly.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "reachable" + "value": "Keeps Iroh connections to this Mac on cmux relays instead of direct or local-network paths. Applies on the next reconnect." } }, "ja": { "stringUnit": { "state": "translated", - "value": "到達可能" + "value": "この Mac への Iroh 接続を、直接経路やローカルネットワーク経路ではなく cmux リレー経由に固定します。次回の再接続時に適用されます。" } } } diff --git a/Resources/agent-session-react/assets/app.js b/Resources/agent-session-react/assets/app.js index 7ca1c3b716b2..2e3afc51cb9f 100644 --- a/Resources/agent-session-react/assets/app.js +++ b/Resources/agent-session-react/assets/app.js @@ -1,53 +1,53 @@ -var G3=Object.create;var xh=Object.defineProperty;var X3=Object.getOwnPropertyDescriptor;var K3=Object.getOwnPropertyNames;var I3=Object.getPrototypeOf,Q3=Object.prototype.hasOwnProperty;var Cn=(t,e)=>()=>(e||t((e={exports:{}}).exports,e),e.exports);var J3=(t,e,n,l)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of K3(e))!Q3.call(t,i)&&i!==n&&xh(t,i,{get:()=>e[i],enumerable:!(l=X3(e,i))||l.enumerable});return t};var Ao=(t,e,n)=>(n=t!=null?G3(I3(t)):{},J3(e||!t||!t.__esModule?xh(n,"default",{value:t,enumerable:!0}):n,t));var wh=Cn(L=>{"use strict";var su=Symbol.for("react.transitional.element"),F3=Symbol.for("react.portal"),W3=Symbol.for("react.fragment"),P3=Symbol.for("react.strict_mode"),$3=Symbol.for("react.profiler"),e4=Symbol.for("react.consumer"),t4=Symbol.for("react.context"),n4=Symbol.for("react.forward_ref"),l4=Symbol.for("react.suspense"),i4=Symbol.for("react.memo"),Eh=Symbol.for("react.lazy"),r4=Symbol.for("react.activity"),vh=Symbol.iterator;function o4(t){return t===null||typeof t!="object"?null:(t=vh&&t[vh]||t["@@iterator"],typeof t=="function"?t:null)}var Nh={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Oh=Object.assign,Ah={};function jl(t,e,n){this.props=t,this.context=e,this.refs=Ah,this.updater=n||Nh}jl.prototype.isReactComponent={};jl.prototype.setState=function(t,e){if(typeof t!="object"&&typeof t!="function"&&t!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,t,e,"setState")};jl.prototype.forceUpdate=function(t){this.updater.enqueueForceUpdate(this,t,"forceUpdate")};function Dh(){}Dh.prototype=jl.prototype;function uu(t,e,n){this.props=t,this.context=e,this.refs=Ah,this.updater=n||Nh}var cu=uu.prototype=new Dh;cu.constructor=uu;Oh(cu,jl.prototype);cu.isPureReactComponent=!0;var Ch=Array.isArray;function au(){}var ee={H:null,A:null,T:null,S:null},kh=Object.prototype.hasOwnProperty;function fu(t,e,n){var l=n.ref;return{$$typeof:su,type:t,key:e,ref:l!==void 0?l:null,props:n}}function a4(t,e){return fu(t.type,e,t.props)}function du(t){return typeof t=="object"&&t!==null&&t.$$typeof===su}function s4(t){var e={"=":"=0",":":"=2"};return"$"+t.replace(/[=:]/g,function(n){return e[n]})}var Mh=/\/+/g;function ou(t,e){return typeof t=="object"&&t!==null&&t.key!=null?s4(""+t.key):e.toString(36)}function u4(t){switch(t.status){case"fulfilled":return t.value;case"rejected":throw t.reason;default:switch(typeof t.status=="string"?t.then(au,au):(t.status="pending",t.then(function(e){t.status==="pending"&&(t.status="fulfilled",t.value=e)},function(e){t.status==="pending"&&(t.status="rejected",t.reason=e)})),t.status){case"fulfilled":return t.value;case"rejected":throw t.reason}}throw t}function Zl(t,e,n,l,i){var r=typeof t;(r==="undefined"||r==="boolean")&&(t=null);var o=!1;if(t===null)o=!0;else switch(r){case"bigint":case"string":case"number":o=!0;break;case"object":switch(t.$$typeof){case su:case F3:o=!0;break;case Eh:return o=t._init,Zl(o(t._payload),e,n,l,i)}}if(o)return i=i(t),o=l===""?"."+ou(t,0):l,Ch(i)?(n="",o!=null&&(n=o.replace(Mh,"$&/")+"/"),Zl(i,e,n,"",function(u){return u})):i!=null&&(du(i)&&(i=a4(i,n+(i.key==null||t&&t.key===i.key?"":(""+i.key).replace(Mh,"$&/")+"/")+o)),e.push(i)),1;o=0;var a=l===""?".":l+":";if(Ch(t))for(var s=0;s{"use strict";zh.exports=wh()});var Zh=Cn(ie=>{"use strict";function gu(t,e){var n=t.length;t.push(e);e:for(;0>>1,i=t[l];if(0>>1;lko(a,n))sko(u,a)?(t[l]=u,t[s]=n,l=s):(t[l]=a,t[o]=n,l=o);else if(sko(u,n))t[l]=u,t[s]=n,l=s;else break e}}return e}function ko(t,e){var n=t.sortIndex-e.sortIndex;return n!==0?n:t.id-e.id}ie.unstable_now=void 0;typeof performance=="object"&&typeof performance.now=="function"?(Rh=performance,ie.unstable_now=function(){return Rh.now()}):(hu=Date,Lh=hu.now(),ie.unstable_now=function(){return hu.now()-Lh});var Rh,hu,Lh,It=[],Mn=[],d4=1,ft=null,ze=3,yu=!1,Wi=!1,Pi=!1,bu=!1,Hh=typeof setTimeout=="function"?setTimeout:null,Uh=typeof clearTimeout=="function"?clearTimeout:null,_h=typeof setImmediate<"u"?setImmediate:null;function wo(t){for(var e=Lt(Mn);e!==null;){if(e.callback===null)zo(Mn);else if(e.startTime<=t)zo(Mn),e.sortIndex=e.expirationTime,gu(It,e);else break;e=Lt(Mn)}}function Su(t){if(Pi=!1,wo(t),!Wi)if(Lt(It)!==null)Wi=!0,Kl||(Kl=!0,Xl());else{var e=Lt(Mn);e!==null&&xu(Su,e.startTime-t)}}var Kl=!1,$i=-1,Vh=5,qh=-1;function Yh(){return bu?!0:!(ie.unstable_now()-qht&&Yh());){var l=ft.callback;if(typeof l=="function"){ft.callback=null,ze=ft.priorityLevel;var i=l(ft.expirationTime<=t);if(t=ie.unstable_now(),typeof i=="function"){ft.callback=i,wo(t),e=!0;break t}ft===Lt(It)&&zo(It),wo(t)}else zo(It);ft=Lt(It)}if(ft!==null)e=!0;else{var r=Lt(Mn);r!==null&&xu(Su,r.startTime-t),e=!1}}break e}finally{ft=null,ze=n,yu=!1}e=void 0}}finally{e?Xl():Kl=!1}}}var Xl;typeof _h=="function"?Xl=function(){_h(pu)}:typeof MessageChannel<"u"?(mu=new MessageChannel,Bh=mu.port2,mu.port1.onmessage=pu,Xl=function(){Bh.postMessage(null)}):Xl=function(){Hh(pu,0)};var mu,Bh;function xu(t,e){$i=Hh(function(){t(ie.unstable_now())},e)}ie.unstable_IdlePriority=5;ie.unstable_ImmediatePriority=1;ie.unstable_LowPriority=4;ie.unstable_NormalPriority=3;ie.unstable_Profiling=null;ie.unstable_UserBlockingPriority=2;ie.unstable_cancelCallback=function(t){t.callback=null};ie.unstable_forceFrameRate=function(t){0>t||125l?(t.sortIndex=n,gu(Mn,t),Lt(It)===null&&t===Lt(Mn)&&(Pi?(Uh($i),$i=-1):Pi=!0,xu(Su,n-l))):(t.sortIndex=i,gu(It,t),Wi||yu||(Wi=!0,Kl||(Kl=!0,Xl()))),t};ie.unstable_shouldYield=Yh;ie.unstable_wrapCallback=function(t){var e=ze;return function(){var n=ze;ze=e;try{return t.apply(this,arguments)}finally{ze=n}}}});var Gh=Cn((m9,jh)=>{"use strict";jh.exports=Zh()});var Kh=Cn(He=>{"use strict";var h4=Gl();function Xh(t){var e="https://react.dev/errors/"+t;if(1{"use strict";function Ih(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(Ih)}catch(t){console.error(t)}}Ih(),Qh.exports=Kh()});var ag=Cn(is=>{"use strict";var ge=Gh(),vm=Gl(),g4=Jh();function C(t){var e="https://react.dev/errors/"+t;if(1$l||(t.current=rc[$l],rc[$l]=null,$l--)}function $(t,e){$l++,rc[$l]=t.current,t.current=e}var Ut=Vt(null),Nr=Vt(null),Bn=Vt(null),ha=Vt(null);function pa(t,e){switch($(Bn,e),$(Nr,t),$(Ut,null),e.nodeType){case 9:case 11:t=(t=e.documentElement)&&(t=t.namespaceURI)?im(t):0;break;default:if(t=e.tagName,e=e.namespaceURI)e=im(e),t=I0(e,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}ve(Ut),$(Ut,t)}function yi(){ve(Ut),ve(Nr),ve(Bn)}function oc(t){t.memoizedState!==null&&$(ha,t);var e=Ut.current,n=I0(e,t.type);e!==n&&($(Nr,t),$(Ut,n))}function ma(t){Nr.current===t&&(ve(Ut),ve(Nr)),ha.current===t&&(ve(ha),Hr._currentValue=fl)}var vu,Ph;function al(t){if(vu===void 0)try{throw Error()}catch(n){var e=n.stack.trim().match(/\n( *(at )?)/);vu=e&&e[1]||"",Ph=-1()=>(e||t((e={exports:{}}).exports,e),e.exports);var W3=(t,e,n,l)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of F3(e))!P3.call(t,i)&&i!==n&&vh(t,i,{get:()=>e[i],enumerable:!(l=Q3(e,i))||l.enumerable});return t};var wo=(t,e,n)=>(n=t!=null?K3(J3(t)):{},W3(e||!t||!t.__esModule?vh(n,"default",{value:t,enumerable:!0}):n,t));var zh=Cn(L=>{"use strict";var cu=Symbol.for("react.transitional.element"),$3=Symbol.for("react.portal"),e4=Symbol.for("react.fragment"),t4=Symbol.for("react.strict_mode"),n4=Symbol.for("react.profiler"),l4=Symbol.for("react.consumer"),i4=Symbol.for("react.context"),r4=Symbol.for("react.forward_ref"),o4=Symbol.for("react.suspense"),a4=Symbol.for("react.memo"),Nh=Symbol.for("react.lazy"),s4=Symbol.for("react.activity"),Ch=Symbol.iterator;function u4(t){return t===null||typeof t!="object"?null:(t=Ch&&t[Ch]||t["@@iterator"],typeof t=="function"?t:null)}var Ah={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Oh=Object.assign,Dh={};function jl(t,e,n){this.props=t,this.context=e,this.refs=Dh,this.updater=n||Ah}jl.prototype.isReactComponent={};jl.prototype.setState=function(t,e){if(typeof t!="object"&&typeof t!="function"&&t!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,t,e,"setState")};jl.prototype.forceUpdate=function(t){this.updater.enqueueForceUpdate(this,t,"forceUpdate")};function kh(){}kh.prototype=jl.prototype;function fu(t,e,n){this.props=t,this.context=e,this.refs=Dh,this.updater=n||Ah}var du=fu.prototype=new kh;du.constructor=fu;Oh(du,jl.prototype);du.isPureReactComponent=!0;var Mh=Array.isArray;function uu(){}var ee={H:null,A:null,T:null,S:null},wh=Object.prototype.hasOwnProperty;function hu(t,e,n){var l=n.ref;return{$$typeof:cu,type:t,key:e,ref:l!==void 0?l:null,props:n}}function c4(t,e){return hu(t.type,e,t.props)}function pu(t){return typeof t=="object"&&t!==null&&t.$$typeof===cu}function f4(t){var e={"=":"=0",":":"=2"};return"$"+t.replace(/[=:]/g,function(n){return e[n]})}var Th=/\/+/g;function su(t,e){return typeof t=="object"&&t!==null&&t.key!=null?f4(""+t.key):e.toString(36)}function d4(t){switch(t.status){case"fulfilled":return t.value;case"rejected":throw t.reason;default:switch(typeof t.status=="string"?t.then(uu,uu):(t.status="pending",t.then(function(e){t.status==="pending"&&(t.status="fulfilled",t.value=e)},function(e){t.status==="pending"&&(t.status="rejected",t.reason=e)})),t.status){case"fulfilled":return t.value;case"rejected":throw t.reason}}throw t}function Zl(t,e,n,l,i){var r=typeof t;(r==="undefined"||r==="boolean")&&(t=null);var o=!1;if(t===null)o=!0;else switch(r){case"bigint":case"string":case"number":o=!0;break;case"object":switch(t.$$typeof){case cu:case $3:o=!0;break;case Nh:return o=t._init,Zl(o(t._payload),e,n,l,i)}}if(o)return i=i(t),o=l===""?"."+su(t,0):l,Mh(i)?(n="",o!=null&&(n=o.replace(Th,"$&/")+"/"),Zl(i,e,n,"",function(u){return u})):i!=null&&(pu(i)&&(i=c4(i,n+(i.key==null||t&&t.key===i.key?"":(""+i.key).replace(Th,"$&/")+"/")+o)),e.push(i)),1;o=0;var a=l===""?".":l+":";if(Mh(t))for(var s=0;s{"use strict";Rh.exports=zh()});var jh=Cn(ie=>{"use strict";function bu(t,e){var n=t.length;t.push(e);e:for(;0>>1,i=t[l];if(0>>1;lRo(a,n))sRo(u,a)?(t[l]=u,t[s]=n,l=s):(t[l]=a,t[o]=n,l=o);else if(sRo(u,n))t[l]=u,t[s]=n,l=s;else break e}}return e}function Ro(t,e){var n=t.sortIndex-e.sortIndex;return n!==0?n:t.id-e.id}ie.unstable_now=void 0;typeof performance=="object"&&typeof performance.now=="function"?(Lh=performance,ie.unstable_now=function(){return Lh.now()}):(mu=Date,_h=mu.now(),ie.unstable_now=function(){return mu.now()-_h});var Lh,mu,_h,Kt=[],Mn=[],m4=1,ft=null,ze=3,Su=!1,$i=!1,er=!1,xu=!1,Uh=typeof setTimeout=="function"?setTimeout:null,Vh=typeof clearTimeout=="function"?clearTimeout:null,Bh=typeof setImmediate<"u"?setImmediate:null;function Lo(t){for(var e=Lt(Mn);e!==null;){if(e.callback===null)_o(Mn);else if(e.startTime<=t)_o(Mn),e.sortIndex=e.expirationTime,bu(Kt,e);else break;e=Lt(Mn)}}function vu(t){if(er=!1,Lo(t),!$i)if(Lt(Kt)!==null)$i=!0,Il||(Il=!0,Xl());else{var e=Lt(Mn);e!==null&&Cu(vu,e.startTime-t)}}var Il=!1,tr=-1,qh=5,Yh=-1;function Zh(){return xu?!0:!(ie.unstable_now()-Yht&&Zh());){var l=ft.callback;if(typeof l=="function"){ft.callback=null,ze=ft.priorityLevel;var i=l(ft.expirationTime<=t);if(t=ie.unstable_now(),typeof i=="function"){ft.callback=i,Lo(t),e=!0;break t}ft===Lt(Kt)&&_o(Kt),Lo(t)}else _o(Kt);ft=Lt(Kt)}if(ft!==null)e=!0;else{var r=Lt(Mn);r!==null&&Cu(vu,r.startTime-t),e=!1}}break e}finally{ft=null,ze=n,Su=!1}e=void 0}}finally{e?Xl():Il=!1}}}var Xl;typeof Bh=="function"?Xl=function(){Bh(gu)}:typeof MessageChannel<"u"?(yu=new MessageChannel,Hh=yu.port2,yu.port1.onmessage=gu,Xl=function(){Hh.postMessage(null)}):Xl=function(){Uh(gu,0)};var yu,Hh;function Cu(t,e){tr=Uh(function(){t(ie.unstable_now())},e)}ie.unstable_IdlePriority=5;ie.unstable_ImmediatePriority=1;ie.unstable_LowPriority=4;ie.unstable_NormalPriority=3;ie.unstable_Profiling=null;ie.unstable_UserBlockingPriority=2;ie.unstable_cancelCallback=function(t){t.callback=null};ie.unstable_forceFrameRate=function(t){0>t||125l?(t.sortIndex=n,bu(Mn,t),Lt(Kt)===null&&t===Lt(Mn)&&(er?(Vh(tr),tr=-1):er=!0,Cu(vu,n-l))):(t.sortIndex=i,bu(Kt,t),$i||Su||($i=!0,Il||(Il=!0,Xl()))),t};ie.unstable_shouldYield=Zh;ie.unstable_wrapCallback=function(t){var e=ze;return function(){var n=ze;ze=e;try{return t.apply(this,arguments)}finally{ze=n}}}});var Xh=Cn((x9,Gh)=>{"use strict";Gh.exports=jh()});var Kh=Cn(He=>{"use strict";var g4=Gl();function Ih(t){var e="https://react.dev/errors/"+t;if(1{"use strict";function Qh(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(Qh)}catch(t){console.error(t)}}Qh(),Fh.exports=Kh()});var sg=Cn(ss=>{"use strict";var ge=Xh(),Cm=Gl(),S4=Jh();function C(t){var e="https://react.dev/errors/"+t;if(1$l||(t.current=ac[$l],ac[$l]=null,$l--)}function $(t,e){$l++,ac[$l]=t.current,t.current=e}var Ut=Vt(null),Or=Vt(null),Bn=Vt(null),ga=Vt(null);function ya(t,e){switch($(Bn,e),$(Or,t),$(Ut,null),e.nodeType){case 9:case 11:t=(t=e.documentElement)&&(t=t.namespaceURI)?rm(t):0;break;default:if(t=e.tagName,e=e.namespaceURI)e=rm(e),t=Q0(e,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}ve(Ut),$(Ut,t)}function yi(){ve(Ut),ve(Or),ve(Bn)}function sc(t){t.memoizedState!==null&&$(ga,t);var e=Ut.current,n=Q0(e,t.type);e!==n&&($(Or,t),$(Ut,n))}function ba(t){Or.current===t&&(ve(Ut),ve(Or)),ga.current===t&&(ve(ga),Vr._currentValue=fl)}var Mu,$h;function al(t){if(Mu===void 0)try{throw Error()}catch(n){var e=n.stack.trim().match(/\n( *(at )?)/);Mu=e&&e[1]||"",$h=-1)":-1i||s[l]!==u[i]){var c=` -`+s[l].replace(" at new "," at ");return t.displayName&&c.includes("")&&(c=c.replace("",t.displayName)),c}while(1<=l&&0<=i);break}}}finally{Cu=!1,Error.prepareStackTrace=n}return(n=t?t.displayName||t.name:"")?al(n):""}function v4(t,e){switch(t.tag){case 26:case 27:case 5:return al(t.type);case 16:return al("Lazy");case 13:return t.child!==e&&e!==null?al("Suspense Fallback"):al("Suspense");case 19:return al("SuspenseList");case 0:case 15:return Mu(t.type,!1);case 11:return Mu(t.type.render,!1);case 1:return Mu(t.type,!0);case 31:return al("Activity");default:return""}}function $h(t){try{var e="",n=null;do e+=v4(t,n),n=t,t=t.return;while(t);return e}catch(l){return` +`+s[l].replace(" at new "," at ");return t.displayName&&c.includes("")&&(c=c.replace("",t.displayName)),c}while(1<=l&&0<=i);break}}}finally{Tu=!1,Error.prepareStackTrace=n}return(n=t?t.displayName||t.name:"")?al(n):""}function T4(t,e){switch(t.tag){case 26:case 27:case 5:return al(t.type);case 16:return al("Lazy");case 13:return t.child!==e&&e!==null?al("Suspense Fallback"):al("Suspense");case 19:return al("SuspenseList");case 0:case 15:return Eu(t.type,!1);case 11:return Eu(t.type.render,!1);case 1:return Eu(t.type,!0);case 31:return al("Activity");default:return""}}function ep(t){try{var e="",n=null;do e+=T4(t,n),n=t,t=t.return;while(t);return e}catch(l){return` Error generating stack: `+l.message+` -`+l.stack}}var ac=Object.prototype.hasOwnProperty,Wc=ge.unstable_scheduleCallback,Tu=ge.unstable_cancelCallback,C4=ge.unstable_shouldYield,M4=ge.unstable_requestPaint,lt=ge.unstable_now,T4=ge.unstable_getCurrentPriorityLevel,Am=ge.unstable_ImmediatePriority,Dm=ge.unstable_UserBlockingPriority,ga=ge.unstable_NormalPriority,E4=ge.unstable_LowPriority,km=ge.unstable_IdlePriority,N4=ge.log,O4=ge.unstable_setDisableYieldValue,Yr=null,it=null;function wn(t){if(typeof N4=="function"&&O4(t),it&&typeof it.setStrictMode=="function")try{it.setStrictMode(Yr,t)}catch{}}var rt=Math.clz32?Math.clz32:k4,A4=Math.log,D4=Math.LN2;function k4(t){return t>>>=0,t===0?32:31-(A4(t)/D4|0)|0}var _o=256,Bo=262144,Ho=4194304;function sl(t){var e=t&42;if(e!==0)return e;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&261888;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function Za(t,e,n){var l=t.pendingLanes;if(l===0)return 0;var i=0,r=t.suspendedLanes,o=t.pingedLanes;t=t.warmLanes;var a=l&134217727;return a!==0?(l=a&~r,l!==0?i=sl(l):(o&=a,o!==0?i=sl(o):n||(n=a&~t,n!==0&&(i=sl(n))))):(a=l&~r,a!==0?i=sl(a):o!==0?i=sl(o):n||(n=l&~t,n!==0&&(i=sl(n)))),i===0?0:e!==0&&e!==i&&(e&r)===0&&(r=i&-i,n=e&-e,r>=n||r===32&&(n&4194048)!==0)?e:i}function Zr(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function w4(t,e){switch(t){case 1:case 2:case 4:case 8:case 64:return e+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function wm(){var t=Ho;return Ho<<=1,(Ho&62914560)===0&&(Ho=4194304),t}function Eu(t){for(var e=[],n=0;31>n;n++)e.push(t);return e}function jr(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function z4(t,e,n,l,i,r){var o=t.pendingLanes;t.pendingLanes=n,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=n,t.entangledLanes&=n,t.errorRecoveryDisabledLanes&=n,t.shellSuspendCounter=0;var a=t.entanglements,s=t.expirationTimes,u=t.hiddenUpdates;for(n=o&~n;0"u")return null;try{return t.activeElement||t.body}catch{return t.body}}var U4=/[\n"\\]/g;function gt(t){return t.replace(U4,function(e){return"\\"+e.charCodeAt(0).toString(16)+" "})}function cc(t,e,n,l,i,r,o,a){t.name="",o!=null&&typeof o!="function"&&typeof o!="symbol"&&typeof o!="boolean"?t.type=o:t.removeAttribute("type"),e!=null?o==="number"?(e===0&&t.value===""||t.value!=e)&&(t.value=""+ht(e)):t.value!==""+ht(e)&&(t.value=""+ht(e)):o!=="submit"&&o!=="reset"||t.removeAttribute("value"),e!=null?fc(t,o,ht(e)):n!=null?fc(t,o,ht(n)):l!=null&&t.removeAttribute("value"),i==null&&r!=null&&(t.defaultChecked=!!r),i!=null&&(t.checked=i&&typeof i!="function"&&typeof i!="symbol"),a!=null&&typeof a!="function"&&typeof a!="symbol"&&typeof a!="boolean"?t.name=""+ht(a):t.removeAttribute("name")}function qm(t,e,n,l,i,r,o,a){if(r!=null&&typeof r!="function"&&typeof r!="symbol"&&typeof r!="boolean"&&(t.type=r),e!=null||n!=null){if(!(r!=="submit"&&r!=="reset"||e!=null)){uc(t);return}n=n!=null?""+ht(n):"",e=e!=null?""+ht(e):n,a||e===t.value||(t.value=e),t.defaultValue=e}l=l??i,l=typeof l!="function"&&typeof l!="symbol"&&!!l,t.checked=a?t.checked:!!l,t.defaultChecked=!!l,o!=null&&typeof o!="function"&&typeof o!="symbol"&&typeof o!="boolean"&&(t.name=o),uc(t)}function fc(t,e,n){e==="number"&&ya(t.ownerDocument)===t||t.defaultValue===""+n||(t.defaultValue=""+n)}function fi(t,e,n,l){if(t=t.options,e){e={};for(var i=0;i"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),hc=!1;if(sn)try{Il={},Object.defineProperty(Il,"passive",{get:function(){hc=!0}}),window.addEventListener("test",Il,Il),window.removeEventListener("test",Il,Il)}catch{hc=!1}var Il,zn=null,lf=null,ea=null;function Xm(){if(ea)return ea;var t,e=lf,n=e.length,l,i="value"in zn?zn.value:zn.textContent,r=i.length;for(t=0;t=hr),cp=" ",fp=!1;function Im(t,e){switch(t){case"keyup":return d6.indexOf(e.keyCode)!==-1;case"keydown":return e.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Qm(t){return t=t.detail,typeof t=="object"&&"data"in t?t.data:null}var ni=!1;function p6(t,e){switch(t){case"compositionend":return Qm(e);case"keypress":return e.which!==32?null:(fp=!0,cp);case"textInput":return t=e.data,t===cp&&fp?null:t;default:return null}}function m6(t,e){if(ni)return t==="compositionend"||!of&&Im(t,e)?(t=Xm(),ea=lf=zn=null,ni=!1,t):null;switch(t){case"paste":return null;case"keypress":if(!(e.ctrlKey||e.altKey||e.metaKey)||e.ctrlKey&&e.altKey){if(e.char&&1=e)return{node:n,offset:e-t};t=l}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=mp(n)}}function Pm(t,e){return t&&e?t===e?!0:t&&t.nodeType===3?!1:e&&e.nodeType===3?Pm(t,e.parentNode):"contains"in t?t.contains(e):t.compareDocumentPosition?!!(t.compareDocumentPosition(e)&16):!1:!1}function $m(t){t=t!=null&&t.ownerDocument!=null&&t.ownerDocument.defaultView!=null?t.ownerDocument.defaultView:window;for(var e=ya(t.document);e instanceof t.HTMLIFrameElement;){try{var n=typeof e.contentWindow.location.href=="string"}catch{n=!1}if(n)t=e.contentWindow;else break;e=ya(t.document)}return e}function af(t){var e=t&&t.nodeName&&t.nodeName.toLowerCase();return e&&(e==="input"&&(t.type==="text"||t.type==="search"||t.type==="tel"||t.type==="url"||t.type==="password")||e==="textarea"||t.contentEditable==="true")}var M6=sn&&"documentMode"in document&&11>=document.documentMode,li=null,pc=null,mr=null,mc=!1;function yp(t,e,n){var l=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;mc||li==null||li!==ya(l)||(l=li,"selectionStart"in l&&af(l)?l={start:l.selectionStart,end:l.selectionEnd}:(l=(l.ownerDocument&&l.ownerDocument.defaultView||window).getSelection(),l={anchorNode:l.anchorNode,anchorOffset:l.anchorOffset,focusNode:l.focusNode,focusOffset:l.focusOffset}),mr&&Dr(mr,l)||(mr=l,l=_a(pc,"onSelect"),0>=o,i-=o,_t=1<<32-rt(e)+i|n<N?(T=M,M=null):T=M.sibling;var A=d(g,M,S[N],x);if(A===null){M===null&&(M=T);break}t&&M&&A.alternate===null&&e(g,M),y=r(A,y,N),z===null?O=A:z.sibling=A,z=A,M=T}if(N===S.length)return n(g,M),q&&$t(g,N),O;if(M===null){for(;NN?(T=M,M=null):T=M.sibling;var G=d(g,M,A.value,x);if(G===null){M===null&&(M=T);break}t&&M&&G.alternate===null&&e(g,M),y=r(G,y,N),z===null?O=G:z.sibling=G,z=G,M=T}if(A.done)return n(g,M),q&&$t(g,N),O;if(M===null){for(;!A.done;N++,A=S.next())A=f(g,A.value,x),A!==null&&(y=r(A,y,N),z===null?O=A:z.sibling=A,z=A);return q&&$t(g,N),O}for(M=l(M);!A.done;N++,A=S.next())A=h(M,g,N,A.value,x),A!==null&&(t&&A.alternate!==null&&M.delete(A.key===null?N:A.key),y=r(A,y,N),z===null?O=A:z.sibling=A,z=A);return t&&M.forEach(function(J){return e(g,J)}),q&&$t(g,N),O}function v(g,y,S,x){if(typeof S=="object"&&S!==null&&S.type===Pl&&S.key===null&&(S=S.props.children),typeof S=="object"&&S!==null){switch(S.$$typeof){case Lo:e:{for(var O=S.key;y!==null;){if(y.key===O){if(O=S.type,O===Pl){if(y.tag===7){n(g,y.sibling),x=i(y,S.props.children),x.return=g,g=x;break e}}else if(y.elementType===O||typeof O=="object"&&O!==null&&O.$$typeof===En&&ul(O)===y.type){n(g,y.sibling),x=i(y,S.props),lr(x,S),x.return=g,g=x;break e}n(g,y);break}else e(g,y);y=y.sibling}S.type===Pl?(x=dl(S.props.children,g.mode,x,S.key),x.return=g,g=x):(x=na(S.type,S.key,S.props,null,g.mode,x),lr(x,S),x.return=g,g=x)}return o(g);case sr:e:{for(O=S.key;y!==null;){if(y.key===O)if(y.tag===4&&y.stateNode.containerInfo===S.containerInfo&&y.stateNode.implementation===S.implementation){n(g,y.sibling),x=i(y,S.children||[]),x.return=g,g=x;break e}else{n(g,y);break}else e(g,y);y=y.sibling}x=Ru(S,g.mode,x),x.return=g,g=x}return o(g);case En:return S=ul(S),v(g,y,S,x)}if(ur(S))return p(g,y,S,x);if(tr(S)){if(O=tr(S),typeof O!="function")throw Error(C(150));return S=O.call(S),b(g,y,S,x)}if(typeof S.then=="function")return v(g,y,jo(S),x);if(S.$$typeof===tn)return v(g,y,Zo(g,S),x);Go(g,S)}return typeof S=="string"&&S!==""||typeof S=="number"||typeof S=="bigint"?(S=""+S,y!==null&&y.tag===6?(n(g,y.sibling),x=i(y,S),x.return=g,g=x):(n(g,y),x=zu(S,g.mode,x),x.return=g,g=x),o(g)):n(g,y)}return function(g,y,S,x){try{zr=0;var O=v(g,y,S,x);return pi=null,O}catch(M){if(M===wi||M===Qa)throw M;var z=tt(29,M,null,g.mode);return z.lanes=x,z.return=g,z}finally{}}}var bl=p1(!0),m1=p1(!1),Nn=!1;function gf(t){t.updateQueue={baseState:t.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Cc(t,e){t=t.updateQueue,e.updateQueue===t&&(e.updateQueue={baseState:t.baseState,firstBaseUpdate:t.firstBaseUpdate,lastBaseUpdate:t.lastBaseUpdate,shared:t.shared,callbacks:null})}function Un(t){return{lane:t,tag:0,payload:null,callback:null,next:null}}function Vn(t,e,n){var l=t.updateQueue;if(l===null)return null;if(l=l.shared,(X&2)!==0){var i=l.pending;return i===null?e.next=e:(e.next=i.next,i.next=e),l.pending=e,e=Sa(t),o1(t,null,n),e}return Ia(t,l,e,n),Sa(t)}function yr(t,e,n){if(e=e.updateQueue,e!==null&&(e=e.shared,(n&4194048)!==0)){var l=e.lanes;l&=t.pendingLanes,n|=l,e.lanes=n,Rm(t,n)}}function _u(t,e){var n=t.updateQueue,l=t.alternate;if(l!==null&&(l=l.updateQueue,n===l)){var i=null,r=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};r===null?i=r=o:r=r.next=o,n=n.next}while(n!==null);r===null?i=r=e:r=r.next=e}else i=r=e;n={baseState:l.baseState,firstBaseUpdate:i,lastBaseUpdate:r,shared:l.shared,callbacks:l.callbacks},t.updateQueue=n;return}t=n.lastBaseUpdate,t===null?n.firstBaseUpdate=e:t.next=e,n.lastBaseUpdate=e}var Mc=!1;function br(){if(Mc){var t=hi;if(t!==null)throw t}}function Sr(t,e,n,l){Mc=!1;var i=t.updateQueue;Nn=!1;var r=i.firstBaseUpdate,o=i.lastBaseUpdate,a=i.shared.pending;if(a!==null){i.shared.pending=null;var s=a,u=s.next;s.next=null,o===null?r=u:o.next=u,o=s;var c=t.alternate;c!==null&&(c=c.updateQueue,a=c.lastBaseUpdate,a!==o&&(a===null?c.firstBaseUpdate=u:a.next=u,c.lastBaseUpdate=s))}if(r!==null){var f=i.baseState;o=0,c=u=s=null,a=r;do{var d=a.lane&-536870913,h=d!==a.lane;if(h?(V&d)===d:(l&d)===d){d!==0&&d===xi&&(Mc=!0),c!==null&&(c=c.next={lane:0,tag:a.tag,payload:a.payload,callback:null,next:null});e:{var p=t,b=a;d=e;var v=n;switch(b.tag){case 1:if(p=b.payload,typeof p=="function"){f=p.call(v,f,d);break e}f=p;break e;case 3:p.flags=p.flags&-65537|128;case 0:if(p=b.payload,d=typeof p=="function"?p.call(v,f,d):p,d==null)break e;f=le({},f,d);break e;case 2:Nn=!0}}d=a.callback,d!==null&&(t.flags|=64,h&&(t.flags|=8192),h=i.callbacks,h===null?i.callbacks=[d]:h.push(d))}else h={lane:d,tag:a.tag,payload:a.payload,callback:a.callback,next:null},c===null?(u=c=h,s=f):c=c.next=h,o|=d;if(a=a.next,a===null){if(a=i.shared.pending,a===null)break;h=a,a=h.next,h.next=null,i.lastBaseUpdate=h,i.shared.pending=null}}while(!0);c===null&&(s=f),i.baseState=s,i.firstBaseUpdate=u,i.lastBaseUpdate=c,r===null&&(i.shared.lanes=0),Qn|=o,t.lanes=o,t.memoizedState=f}}function g1(t,e){if(typeof t!="function")throw Error(C(191,t));t.call(e)}function y1(t,e){var n=t.callbacks;if(n!==null)for(t.callbacks=null,t=0;tr?r:8;var o=R.T,a={};R.T=a,Df(t,!1,e,n);try{var s=i(),u=R.S;if(u!==null&&u(a,s),s!==null&&typeof s=="object"&&typeof s.then=="function"){var c=z6(s,l);xr(t,e,c,ot(t))}else xr(t,e,l,ot(t))}catch(f){xr(t,e,{then:function(){},status:"rejected",reason:f},ot())}finally{K.p=r,o!==null&&a.types!==null&&(o.types=a.types),R.T=o}}function U6(){}function Ac(t,e,n,l){if(t.tag!==5)throw Error(C(476));var i=Z1(t).queue;Y1(t,i,e,fl,n===null?U6:function(){return j1(t),n(l)})}function Z1(t){var e=t.memoizedState;if(e!==null)return e;e={memoizedState:fl,baseState:fl,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:cn,lastRenderedState:fl},next:null};var n={};return e.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:cn,lastRenderedState:n},next:null},t.memoizedState=e,t=t.alternate,t!==null&&(t.memoizedState=e),e}function j1(t){var e=Z1(t);e.next===null&&(e=t.alternate.memoizedState),xr(t,e.next.queue,{},ot())}function Af(){return Oe(Hr)}function G1(){return ce().memoizedState}function X1(){return ce().memoizedState}function V6(t){for(var e=t.return;e!==null;){switch(e.tag){case 24:case 3:var n=ot();t=Un(n);var l=Vn(e,t,n);l!==null&&(Xe(l,e,n),yr(l,e,n)),e={cache:hf()},t.payload=e;return}e=e.return}}function q6(t,e,n){var l=ot();n={lane:l,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},Pa(t)?I1(e,n):(n=uf(t,e,n,l),n!==null&&(Xe(n,t,l),Q1(n,e,l)))}function K1(t,e,n){var l=ot();xr(t,e,n,l)}function xr(t,e,n,l){var i={lane:l,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(Pa(t))I1(e,i);else{var r=t.alternate;if(t.lanes===0&&(r===null||r.lanes===0)&&(r=e.lastRenderedReducer,r!==null))try{var o=e.lastRenderedState,a=r(o,n);if(i.hasEagerState=!0,i.eagerState=a,at(a,o))return Ia(t,e,i,0),P===null&&Ka(),!1}catch{}finally{}if(n=uf(t,e,i,l),n!==null)return Xe(n,t,l),Q1(n,e,l),!0}return!1}function Df(t,e,n,l){if(l={lane:2,revertLane:Uf(),gesture:null,action:l,hasEagerState:!1,eagerState:null,next:null},Pa(t)){if(e)throw Error(C(479))}else e=uf(t,n,l,2),e!==null&&Xe(e,t,2)}function Pa(t){var e=t.alternate;return t===_||e!==null&&e===_}function I1(t,e){mi=Ea=!0;var n=t.pending;n===null?e.next=e:(e.next=n.next,n.next=e),t.pending=e}function Q1(t,e,n){if((n&4194048)!==0){var l=e.lanes;l&=t.pendingLanes,n|=l,e.lanes=n,Rm(t,n)}}var Lr={readContext:Oe,use:Fa,useCallback:oe,useContext:oe,useEffect:oe,useImperativeHandle:oe,useLayoutEffect:oe,useInsertionEffect:oe,useMemo:oe,useReducer:oe,useRef:oe,useState:oe,useDebugValue:oe,useDeferredValue:oe,useTransition:oe,useSyncExternalStore:oe,useId:oe,useHostTransitionStatus:oe,useFormState:oe,useActionState:oe,useOptimistic:oe,useMemoCache:oe,useCacheRefresh:oe};Lr.useEffectEvent=oe;var J1={readContext:Oe,use:Fa,useCallback:function(t,e){return Ue().memoizedState=[t,e===void 0?null:e],t},useContext:Oe,useEffect:zp,useImperativeHandle:function(t,e,n){n=n!=null?n.concat([t]):null,ra(4194308,4,B1.bind(null,e,t),n)},useLayoutEffect:function(t,e){return ra(4194308,4,t,e)},useInsertionEffect:function(t,e){ra(4,2,t,e)},useMemo:function(t,e){var n=Ue();e=e===void 0?null:e;var l=t();if(Sl){wn(!0);try{t()}finally{wn(!1)}}return n.memoizedState=[l,e],l},useReducer:function(t,e,n){var l=Ue();if(n!==void 0){var i=n(e);if(Sl){wn(!0);try{n(e)}finally{wn(!1)}}}else i=e;return l.memoizedState=l.baseState=i,t={pending:null,lanes:0,dispatch:null,lastRenderedReducer:t,lastRenderedState:i},l.queue=t,t=t.dispatch=q6.bind(null,_,t),[l.memoizedState,t]},useRef:function(t){var e=Ue();return t={current:t},e.memoizedState=t},useState:function(t){t=Nc(t);var e=t.queue,n=K1.bind(null,_,e);return e.dispatch=n,[t.memoizedState,n]},useDebugValue:Nf,useDeferredValue:function(t,e){var n=Ue();return Of(n,t,e)},useTransition:function(){var t=Nc(!1);return t=Y1.bind(null,_,t.queue,!0,!1),Ue().memoizedState=t,[!1,t]},useSyncExternalStore:function(t,e,n){var l=_,i=Ue();if(q){if(n===void 0)throw Error(C(407));n=n()}else{if(n=e(),P===null)throw Error(C(349));(V&127)!==0||C1(l,e,n)}i.memoizedState=n;var r={value:n,getSnapshot:e};return i.queue=r,zp(T1.bind(null,l,r,t),[t]),l.flags|=2048,Ci(9,{destroy:void 0},M1.bind(null,l,r,n,e),null),n},useId:function(){var t=Ue(),e=P.identifierPrefix;if(q){var n=Bt,l=_t;n=(l&~(1<<32-rt(l)-1)).toString(32)+n,e="_"+e+"R_"+n,n=Na++,0<\/script>",r=r.removeChild(r.firstChild);break;case"select":r=typeof l.is=="string"?o.createElement("select",{is:l.is}):o.createElement("select"),l.multiple?r.multiple=!0:l.size&&(r.size=l.size);break;default:r=typeof l.is=="string"?o.createElement(i,{is:l.is}):o.createElement(i)}}r[Ee]=e,r[Ke]=l;e:for(o=e.child;o!==null;){if(o.tag===5||o.tag===6)r.appendChild(o.stateNode);else if(o.tag!==4&&o.tag!==27&&o.child!==null){o.child.return=o,o=o.child;continue}if(o===e)break e;for(;o.sibling===null;){if(o.return===null||o.return===e)break e;o=o.return}o.sibling.return=o.return,o=o.sibling}e.stateNode=r;e:switch(Ae(r,i,l),i){case"button":case"input":case"select":case"textarea":l=!!l.autoFocus;break e;case"img":l=!0;break e;default:l=!1}l&&Jt(e)}}return te(e),ju(e,e.type,t===null?null:t.memoizedProps,e.pendingProps,n),null;case 6:if(t&&e.stateNode!=null)t.memoizedProps!==l&&Jt(e);else{if(typeof l!="string"&&e.stateNode===null)throw Error(C(166));if(t=Bn.current,Ql(e)){if(t=e.stateNode,n=e.memoizedProps,l=null,i=Ne,i!==null)switch(i.tag){case 27:case 5:l=i.memoizedProps}t[Ee]=e,t=!!(t.nodeValue===n||l!==null&&l.suppressHydrationWarning===!0||K0(t.nodeValue,n)),t||Kn(e,!0)}else t=Ba(t).createTextNode(l),t[Ee]=e,e.stateNode=t}return te(e),null;case 31:if(n=e.memoizedState,t===null||t.memoizedState!==null){if(l=Ql(e),n!==null){if(t===null){if(!l)throw Error(C(318));if(t=e.memoizedState,t=t!==null?t.dehydrated:null,!t)throw Error(C(557));t[Ee]=e}else gl(),(e.flags&128)===0&&(e.memoizedState=null),e.flags|=4;te(e),t=!1}else n=Lu(),t!==null&&t.memoizedState!==null&&(t.memoizedState.hydrationErrors=n),t=!0;if(!t)return e.flags&256?(et(e),e):(et(e),null);if((e.flags&128)!==0)throw Error(C(558))}return te(e),null;case 13:if(l=e.memoizedState,t===null||t.memoizedState!==null&&t.memoizedState.dehydrated!==null){if(i=Ql(e),l!==null&&l.dehydrated!==null){if(t===null){if(!i)throw Error(C(318));if(i=e.memoizedState,i=i!==null?i.dehydrated:null,!i)throw Error(C(317));i[Ee]=e}else gl(),(e.flags&128)===0&&(e.memoizedState=null),e.flags|=4;te(e),i=!1}else i=Lu(),t!==null&&t.memoizedState!==null&&(t.memoizedState.hydrationErrors=i),i=!0;if(!i)return e.flags&256?(et(e),e):(et(e),null)}return et(e),(e.flags&128)!==0?(e.lanes=n,e):(n=l!==null,t=t!==null&&t.memoizedState!==null,n&&(l=e.child,i=null,l.alternate!==null&&l.alternate.memoizedState!==null&&l.alternate.memoizedState.cachePool!==null&&(i=l.alternate.memoizedState.cachePool.pool),r=null,l.memoizedState!==null&&l.memoizedState.cachePool!==null&&(r=l.memoizedState.cachePool.pool),r!==i&&(l.flags|=2048)),n!==t&&n&&(e.child.flags|=8192),Xo(e,e.updateQueue),te(e),null);case 4:return yi(),t===null&&Vf(e.stateNode.containerInfo),te(e),null;case 10:return on(e.type),te(e),null;case 19:if(ve(ue),l=e.memoizedState,l===null)return te(e),null;if(i=(e.flags&128)!==0,r=l.rendering,r===null)if(i)ir(l,!1);else{if(ae!==0||t!==null&&(t.flags&128)!==0)for(t=e.child;t!==null;){if(r=Ta(t),r!==null){for(e.flags|=128,ir(l,!1),t=r.updateQueue,e.updateQueue=t,Xo(e,t),e.subtreeFlags=0,t=n,n=e.child;n!==null;)a1(n,t),n=n.sibling;return $(ue,ue.current&1|2),q&&$t(e,l.treeForkCount),e.child}t=t.sibling}l.tail!==null&<()>ka&&(e.flags|=128,i=!0,ir(l,!1),e.lanes=4194304)}else{if(!i)if(t=Ta(r),t!==null){if(e.flags|=128,i=!0,t=t.updateQueue,e.updateQueue=t,Xo(e,t),ir(l,!0),l.tail===null&&l.tailMode==="hidden"&&!r.alternate&&!q)return te(e),null}else 2*lt()-l.renderingStartTime>ka&&n!==536870912&&(e.flags|=128,i=!0,ir(l,!1),e.lanes=4194304);l.isBackwards?(r.sibling=e.child,e.child=r):(t=l.last,t!==null?t.sibling=r:e.child=r,l.last=r)}return l.tail!==null?(t=l.tail,l.rendering=t,l.tail=t.sibling,l.renderingStartTime=lt(),t.sibling=null,n=ue.current,$(ue,i?n&1|2:n&1),q&&$t(e,l.treeForkCount),t):(te(e),null);case 22:case 23:return et(e),yf(),l=e.memoizedState!==null,t!==null?t.memoizedState!==null!==l&&(e.flags|=8192):l&&(e.flags|=8192),l?(n&536870912)!==0&&(e.flags&128)===0&&(te(e),e.subtreeFlags&6&&(e.flags|=8192)):te(e),n=e.updateQueue,n!==null&&Xo(e,n.retryQueue),n=null,t!==null&&t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(n=t.memoizedState.cachePool.pool),l=null,e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(l=e.memoizedState.cachePool.pool),l!==n&&(e.flags|=2048),t!==null&&ve(hl),null;case 24:return n=null,t!==null&&(n=t.memoizedState.cache),e.memoizedState.cache!==n&&(e.flags|=2048),on(he),te(e),null;case 25:return null;case 30:return null}throw Error(C(156,e.tag))}function X6(t,e){switch(df(e),e.tag){case 1:return t=e.flags,t&65536?(e.flags=t&-65537|128,e):null;case 3:return on(he),yi(),t=e.flags,(t&65536)!==0&&(t&128)===0?(e.flags=t&-65537|128,e):null;case 26:case 27:case 5:return ma(e),null;case 31:if(e.memoizedState!==null){if(et(e),e.alternate===null)throw Error(C(340));gl()}return t=e.flags,t&65536?(e.flags=t&-65537|128,e):null;case 13:if(et(e),t=e.memoizedState,t!==null&&t.dehydrated!==null){if(e.alternate===null)throw Error(C(340));gl()}return t=e.flags,t&65536?(e.flags=t&-65537|128,e):null;case 19:return ve(ue),null;case 4:return yi(),null;case 10:return on(e.type),null;case 22:case 23:return et(e),yf(),t!==null&&ve(hl),t=e.flags,t&65536?(e.flags=t&-65537|128,e):null;case 24:return on(he),null;case 25:return null;default:return null}}function a0(t,e){switch(df(e),e.tag){case 3:on(he),yi();break;case 26:case 27:case 5:ma(e);break;case 4:yi();break;case 31:e.memoizedState!==null&&et(e);break;case 13:et(e);break;case 19:ve(ue);break;case 10:on(e.type);break;case 22:case 23:et(e),yf(),t!==null&&ve(hl);break;case 24:on(he)}}function Qr(t,e){try{var n=e.updateQueue,l=n!==null?n.lastEffect:null;if(l!==null){var i=l.next;n=i;do{if((n.tag&t)===t){l=void 0;var r=n.create,o=n.inst;l=r(),o.destroy=l}n=n.next}while(n!==i)}}catch(a){Q(e,e.return,a)}}function In(t,e,n){try{var l=e.updateQueue,i=l!==null?l.lastEffect:null;if(i!==null){var r=i.next;l=r;do{if((l.tag&t)===t){var o=l.inst,a=o.destroy;if(a!==void 0){o.destroy=void 0,i=e;var s=n,u=a;try{u()}catch(c){Q(i,s,c)}}}l=l.next}while(l!==r)}}catch(c){Q(e,e.return,c)}}function s0(t){var e=t.updateQueue;if(e!==null){var n=t.stateNode;try{y1(e,n)}catch(l){Q(t,t.return,l)}}}function u0(t,e,n){n.props=xl(t.type,t.memoizedProps),n.state=t.memoizedState;try{n.componentWillUnmount()}catch(l){Q(t,e,l)}}function vr(t,e){try{var n=t.ref;if(n!==null){switch(t.tag){case 26:case 27:case 5:var l=t.stateNode;break;case 30:l=t.stateNode;break;default:l=t.stateNode}typeof n=="function"?t.refCleanup=n(l):n.current=l}}catch(i){Q(t,e,i)}}function Ht(t,e){var n=t.ref,l=t.refCleanup;if(n!==null)if(typeof l=="function")try{l()}catch(i){Q(t,e,i)}finally{t.refCleanup=null,t=t.alternate,t!=null&&(t.refCleanup=null)}else if(typeof n=="function")try{n(null)}catch(i){Q(t,e,i)}else n.current=null}function c0(t){var e=t.type,n=t.memoizedProps,l=t.stateNode;try{e:switch(e){case"button":case"input":case"select":case"textarea":n.autoFocus&&l.focus();break e;case"img":n.src?l.src=n.src:n.srcSet&&(l.srcset=n.srcSet)}}catch(i){Q(t,t.return,i)}}function Gu(t,e,n){try{var l=t.stateNode;f5(l,t.type,n,e),l[Ke]=e}catch(i){Q(t,t.return,i)}}function f0(t){return t.tag===5||t.tag===3||t.tag===26||t.tag===27&&Fn(t.type)||t.tag===4}function Xu(t){e:for(;;){for(;t.sibling===null;){if(t.return===null||f0(t.return))return null;t=t.return}for(t.sibling.return=t.return,t=t.sibling;t.tag!==5&&t.tag!==6&&t.tag!==18;){if(t.tag===27&&Fn(t.type)||t.flags&2||t.child===null||t.tag===4)continue e;t.child.return=t,t=t.child}if(!(t.flags&2))return t.stateNode}}function Rc(t,e,n){var l=t.tag;if(l===5||l===6)t=t.stateNode,e?(n.nodeType===9?n.body:n.nodeName==="HTML"?n.ownerDocument.body:n).insertBefore(t,e):(e=n.nodeType===9?n.body:n.nodeName==="HTML"?n.ownerDocument.body:n,e.appendChild(t),n=n._reactRootContainer,n!=null||e.onclick!==null||(e.onclick=nn));else if(l!==4&&(l===27&&Fn(t.type)&&(n=t.stateNode,e=null),t=t.child,t!==null))for(Rc(t,e,n),t=t.sibling;t!==null;)Rc(t,e,n),t=t.sibling}function Da(t,e,n){var l=t.tag;if(l===5||l===6)t=t.stateNode,e?n.insertBefore(t,e):n.appendChild(t);else if(l!==4&&(l===27&&Fn(t.type)&&(n=t.stateNode),t=t.child,t!==null))for(Da(t,e,n),t=t.sibling;t!==null;)Da(t,e,n),t=t.sibling}function d0(t){var e=t.stateNode,n=t.memoizedProps;try{for(var l=t.type,i=e.attributes;i.length;)e.removeAttributeNode(i[0]);Ae(e,l,n),e[Ee]=t,e[Ke]=n}catch(r){Q(t,t.return,r)}}var en=!1,de=!1,Ku=!1,Xp=typeof WeakSet=="function"?WeakSet:Set,Se=null;function K6(t,e){if(t=t.containerInfo,qc=qa,t=$m(t),af(t)){if("selectionStart"in t)var n={start:t.selectionStart,end:t.selectionEnd};else e:{n=(n=t.ownerDocument)&&n.defaultView||window;var l=n.getSelection&&n.getSelection();if(l&&l.rangeCount!==0){n=l.anchorNode;var i=l.anchorOffset,r=l.focusNode;l=l.focusOffset;try{n.nodeType,r.nodeType}catch{n=null;break e}var o=0,a=-1,s=-1,u=0,c=0,f=t,d=null;t:for(;;){for(var h;f!==n||i!==0&&f.nodeType!==3||(a=o+i),f!==r||l!==0&&f.nodeType!==3||(s=o+l),f.nodeType===3&&(o+=f.nodeValue.length),(h=f.firstChild)!==null;)d=f,f=h;for(;;){if(f===t)break t;if(d===n&&++u===i&&(a=o),d===r&&++c===l&&(s=o),(h=f.nextSibling)!==null)break;f=d,d=f.parentNode}f=h}n=a===-1||s===-1?null:{start:a,end:s}}else n=null}n=n||{start:0,end:0}}else n=null;for(Yc={focusedElem:t,selectionRange:n},qa=!1,Se=e;Se!==null;)if(e=Se,t=e.child,(e.subtreeFlags&1028)!==0&&t!==null)t.return=e,Se=t;else for(;Se!==null;){switch(e=Se,r=e.alternate,t=e.flags,e.tag){case 0:if((t&4)!==0&&(t=e.updateQueue,t=t!==null?t.events:null,t!==null))for(n=0;n title"))),Ae(r,l,n),r[Ee]=t,xe(r),l=r;break e;case"link":var o=hm("link","href",i).get(l+(n.href||""));if(o){for(var a=0;av&&(o=v,v=b,b=o);var g=gp(a,b),y=gp(a,v);if(g&&y&&(h.rangeCount!==1||h.anchorNode!==g.node||h.anchorOffset!==g.offset||h.focusNode!==y.node||h.focusOffset!==y.offset)){var S=f.createRange();S.setStart(g.node,g.offset),h.removeAllRanges(),b>v?(h.addRange(S),h.extend(y.node,y.offset)):(S.setEnd(y.node,y.offset),h.addRange(S))}}}}for(f=[],h=a;h=h.parentNode;)h.nodeType===1&&f.push({element:h,left:h.scrollLeft,top:h.scrollTop});for(typeof a.focus=="function"&&a.focus(),a=0;an?32:n,R.T=null,n=Bc,Bc=null;var r=Yn,o=an;if(me=0,Ti=Yn=null,an=0,(X&6)!==0)throw Error(C(331));var a=X;if(X|=4,M0(r.current),x0(r,r.current,o,n),X=a,Jr(0,!1),it&&typeof it.onPostCommitFiberRoot=="function")try{it.onPostCommitFiberRoot(Yr,r)}catch{}return!0}finally{K.p=i,R.T=l,U0(t,e)}}function Jp(t,e,n){e=yt(n,e),e=kc(t.stateNode,e,2),t=Vn(t,e,2),t!==null&&(jr(t,2),qt(t))}function Q(t,e,n){if(t.tag===3)Jp(t,t,n);else for(;e!==null;){if(e.tag===3){Jp(e,t,n);break}else if(e.tag===1){var l=e.stateNode;if(typeof e.type.getDerivedStateFromError=="function"||typeof l.componentDidCatch=="function"&&(qn===null||!qn.has(l))){t=yt(n,t),n=e0(2),l=Vn(e,n,2),l!==null&&(t0(n,l,e,t),jr(l,2),qt(l));break}}e=e.return}}function Qu(t,e,n){var l=t.pingCache;if(l===null){l=t.pingCache=new J6;var i=new Set;l.set(e,i)}else i=l.get(e),i===void 0&&(i=new Set,l.set(e,i));i.has(n)||(_f=!0,i.add(n),t=e5.bind(null,t,e,n),e.then(t,t))}function e5(t,e,n){var l=t.pingCache;l!==null&&l.delete(e),t.pingedLanes|=t.suspendedLanes&n,t.warmLanes&=~n,P===t&&(V&n)===n&&(ae===4||ae===3&&(V&62914560)===V&&300>lt()-$a?(X&2)===0&&Ei(t,0):Bf|=n,Mi===V&&(Mi=0)),qt(t)}function q0(t,e){e===0&&(e=wm()),t=Tl(t,e),t!==null&&(jr(t,e),qt(t))}function t5(t){var e=t.memoizedState,n=0;e!==null&&(n=e.retryLane),q0(t,n)}function n5(t,e){var n=0;switch(t.tag){case 31:case 13:var l=t.stateNode,i=t.memoizedState;i!==null&&(n=i.retryLane);break;case 19:l=t.stateNode;break;case 22:l=t.stateNode._retryCache;break;default:throw Error(C(314))}l!==null&&l.delete(e),q0(t,n)}function l5(t,e){return Wc(t,e)}var Ra=null,Wl=null,Uc=!1,La=!1,Ju=!1,_n=0;function qt(t){t!==Wl&&t.next===null&&(Wl===null?Ra=Wl=t:Wl=Wl.next=t),La=!0,Uc||(Uc=!0,r5())}function Jr(t,e){if(!Ju&&La){Ju=!0;do for(var n=!1,l=Ra;l!==null;){if(!e)if(t!==0){var i=l.pendingLanes;if(i===0)var r=0;else{var o=l.suspendedLanes,a=l.pingedLanes;r=(1<<31-rt(42|t)+1)-1,r&=i&~(o&~a),r=r&201326741?r&201326741|1:r?r|2:0}r!==0&&(n=!0,Fp(l,r))}else r=V,r=Za(l,l===P?r:0,l.cancelPendingCommit!==null||l.timeoutHandle!==-1),(r&3)===0||Zr(l,r)||(n=!0,Fp(l,r));l=l.next}while(n);Ju=!1}}function i5(){Y0()}function Y0(){La=Uc=!1;var t=0;_n!==0&&h5()&&(t=_n);for(var e=lt(),n=null,l=Ra;l!==null;){var i=l.next,r=Z0(l,e);r===0?(l.next=null,n===null?Ra=i:n.next=i,i===null&&(Wl=n)):(n=l,(t!==0||(r&3)!==0)&&(La=!0)),l=i}me!==0&&me!==5||Jr(t,!1),_n!==0&&(_n=0)}function Z0(t,e){for(var n=t.suspendedLanes,l=t.pingedLanes,i=t.expirationTimes,r=t.pendingLanes&-62914561;0a)break;var c=s.transferSize,f=s.initiatorType;c&&lm(f)&&(s=s.responseEnd,o+=c*(s"u"?null:document;function W0(t,e,n){var l=Ri;if(l&&typeof e=="string"&&e){var i=gt(e);i='link[rel="'+t+'"][href="'+i+'"]',typeof n=="string"&&(i+='[crossorigin="'+n+'"]'),cm.has(i)||(cm.add(i),t={rel:t,crossOrigin:n,href:e},l.querySelector(i)===null&&(e=l.createElement("link"),Ae(e,"link",t),xe(e),l.head.appendChild(e)))}}function C5(t){hn.D(t),W0("dns-prefetch",t,null)}function M5(t,e){hn.C(t,e),W0("preconnect",t,e)}function T5(t,e,n){hn.L(t,e,n);var l=Ri;if(l&&t&&e){var i='link[rel="preload"][as="'+gt(e)+'"]';e==="image"&&n&&n.imageSrcSet?(i+='[imagesrcset="'+gt(n.imageSrcSet)+'"]',typeof n.imageSizes=="string"&&(i+='[imagesizes="'+gt(n.imageSizes)+'"]')):i+='[href="'+gt(t)+'"]';var r=i;switch(e){case"style":r=Ni(t);break;case"script":r=Li(t)}vt.has(r)||(t=le({rel:"preload",href:e==="image"&&n&&n.imageSrcSet?void 0:t,as:e},n),vt.set(r,t),l.querySelector(i)!==null||e==="style"&&l.querySelector(Fr(r))||e==="script"&&l.querySelector(Wr(r))||(e=l.createElement("link"),Ae(e,"link",t),xe(e),l.head.appendChild(e)))}}function E5(t,e){hn.m(t,e);var n=Ri;if(n&&t){var l=e&&typeof e.as=="string"?e.as:"script",i='link[rel="modulepreload"][as="'+gt(l)+'"][href="'+gt(t)+'"]',r=i;switch(l){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":r=Li(t)}if(!vt.has(r)&&(t=le({rel:"modulepreload",href:t},e),vt.set(r,t),n.querySelector(i)===null)){switch(l){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(n.querySelector(Wr(r)))return}l=n.createElement("link"),Ae(l,"link",t),xe(l),n.head.appendChild(l)}}}function N5(t,e,n){hn.S(t,e,n);var l=Ri;if(l&&t){var i=ci(l).hoistableStyles,r=Ni(t);e=e||"default";var o=i.get(r);if(!o){var a={loading:0,preload:null};if(o=l.querySelector(Fr(r)))a.loading=5;else{t=le({rel:"stylesheet",href:t,"data-precedence":e},n),(n=vt.get(r))&&qf(t,n);var s=o=l.createElement("link");xe(s),Ae(s,"link",t),s._p=new Promise(function(u,c){s.onload=u,s.onerror=c}),s.addEventListener("load",function(){a.loading|=1}),s.addEventListener("error",function(){a.loading|=2}),a.loading|=4,ua(o,e,l)}o={type:"stylesheet",instance:o,count:1,state:a},i.set(r,o)}}}function O5(t,e){hn.X(t,e);var n=Ri;if(n&&t){var l=ci(n).hoistableScripts,i=Li(t),r=l.get(i);r||(r=n.querySelector(Wr(i)),r||(t=le({src:t,async:!0},e),(e=vt.get(i))&&Yf(t,e),r=n.createElement("script"),xe(r),Ae(r,"link",t),n.head.appendChild(r)),r={type:"script",instance:r,count:1,state:null},l.set(i,r))}}function A5(t,e){hn.M(t,e);var n=Ri;if(n&&t){var l=ci(n).hoistableScripts,i=Li(t),r=l.get(i);r||(r=n.querySelector(Wr(i)),r||(t=le({src:t,async:!0,type:"module"},e),(e=vt.get(i))&&Yf(t,e),r=n.createElement("script"),xe(r),Ae(r,"link",t),n.head.appendChild(r)),r={type:"script",instance:r,count:1,state:null},l.set(i,r))}}function fm(t,e,n,l){var i=(i=Bn.current)?Ha(i):null;if(!i)throw Error(C(446));switch(t){case"meta":case"title":return null;case"style":return typeof n.precedence=="string"&&typeof n.href=="string"?(e=Ni(n.href),n=ci(i).hoistableStyles,l=n.get(e),l||(l={type:"style",instance:null,count:0,state:null},n.set(e,l)),l):{type:"void",instance:null,count:0,state:null};case"link":if(n.rel==="stylesheet"&&typeof n.href=="string"&&typeof n.precedence=="string"){t=Ni(n.href);var r=ci(i).hoistableStyles,o=r.get(t);if(o||(i=i.ownerDocument||i,o={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},r.set(t,o),(r=i.querySelector(Fr(t)))&&!r._p&&(o.instance=r,o.state.loading=5),vt.has(t)||(n={rel:"preload",as:"style",href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},vt.set(t,n),r||D5(i,t,n,o.state))),e&&l===null)throw Error(C(528,""));return o}if(e&&l!==null)throw Error(C(529,""));return null;case"script":return e=n.async,n=n.src,typeof n=="string"&&e&&typeof e!="function"&&typeof e!="symbol"?(e=Li(n),n=ci(i).hoistableScripts,l=n.get(e),l||(l={type:"script",instance:null,count:0,state:null},n.set(e,l)),l):{type:"void",instance:null,count:0,state:null};default:throw Error(C(444,t))}}function Ni(t){return'href="'+gt(t)+'"'}function Fr(t){return'link[rel="stylesheet"]['+t+"]"}function P0(t){return le({},t,{"data-precedence":t.precedence,precedence:null})}function D5(t,e,n,l){t.querySelector('link[rel="preload"][as="style"]['+e+"]")?l.loading=1:(e=t.createElement("link"),l.preload=e,e.addEventListener("load",function(){return l.loading|=1}),e.addEventListener("error",function(){return l.loading|=2}),Ae(e,"link",n),xe(e),t.head.appendChild(e))}function Li(t){return'[src="'+gt(t)+'"]'}function Wr(t){return"script[async]"+t}function dm(t,e,n){if(e.count++,e.instance===null)switch(e.type){case"style":var l=t.querySelector('style[data-href~="'+gt(n.href)+'"]');if(l)return e.instance=l,xe(l),l;var i=le({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return l=(t.ownerDocument||t).createElement("style"),xe(l),Ae(l,"style",i),ua(l,n.precedence,t),e.instance=l;case"stylesheet":i=Ni(n.href);var r=t.querySelector(Fr(i));if(r)return e.state.loading|=4,e.instance=r,xe(r),r;l=P0(n),(i=vt.get(i))&&qf(l,i),r=(t.ownerDocument||t).createElement("link"),xe(r);var o=r;return o._p=new Promise(function(a,s){o.onload=a,o.onerror=s}),Ae(r,"link",l),e.state.loading|=4,ua(r,n.precedence,t),e.instance=r;case"script":return r=Li(n.src),(i=t.querySelector(Wr(r)))?(e.instance=i,xe(i),i):(l=n,(i=vt.get(r))&&(l=le({},n),Yf(l,i)),t=t.ownerDocument||t,i=t.createElement("script"),xe(i),Ae(i,"link",l),t.head.appendChild(i),e.instance=i);case"void":return null;default:throw Error(C(443,e.type))}else e.type==="stylesheet"&&(e.state.loading&4)===0&&(l=e.instance,e.state.loading|=4,ua(l,n.precedence,t));return e.instance}function ua(t,e,n){for(var l=n.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),i=l.length?l[l.length-1]:null,r=i,o=0;o title"):null)}function k5(t,e,n){if(n===1||e.itemProp!=null)return!1;switch(t){case"meta":case"title":return!0;case"style":if(typeof e.precedence!="string"||typeof e.href!="string"||e.href==="")break;return!0;case"link":if(typeof e.rel!="string"||typeof e.href!="string"||e.href===""||e.onLoad||e.onError)break;switch(e.rel){case"stylesheet":return t=e.disabled,typeof e.precedence=="string"&&t==null;default:return!0}case"script":if(e.async&&typeof e.async!="function"&&typeof e.async!="symbol"&&!e.onLoad&&!e.onError&&e.src&&typeof e.src=="string")return!0}return!1}function $0(t){return!(t.type==="stylesheet"&&(t.state.loading&3)===0)}function w5(t,e,n,l){if(n.type==="stylesheet"&&(typeof l.media!="string"||matchMedia(l.media).matches!==!1)&&(n.state.loading&4)===0){if(n.instance===null){var i=Ni(l.href),r=e.querySelector(Fr(i));if(r){e=r._p,e!==null&&typeof e=="object"&&typeof e.then=="function"&&(t.count++,t=Ua.bind(t),e.then(t,t)),n.state.loading|=4,n.instance=r,xe(r);return}r=e.ownerDocument||e,l=P0(l),(i=vt.get(i))&&qf(l,i),r=r.createElement("link"),xe(r);var o=r;o._p=new Promise(function(a,s){o.onload=a,o.onerror=s}),Ae(r,"link",l),n.instance=r}t.stylesheets===null&&(t.stylesheets=new Map),t.stylesheets.set(n,e),(e=n.state.preload)&&(n.state.loading&3)===0&&(t.count++,n=Ua.bind(t),e.addEventListener("load",n),e.addEventListener("error",n))}}var $u=0;function z5(t,e){return t.stylesheets&&t.count===0&&fa(t,t.stylesheets),0$u?50:800)+e);return t.unsuspend=n,function(){t.unsuspend=null,clearTimeout(l),clearTimeout(i)}}:null}function Ua(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)fa(this,this.stylesheets);else if(this.unsuspend){var t=this.unsuspend;this.unsuspend=null,t()}}}var Va=null;function fa(t,e){t.stylesheets=null,t.unsuspend!==null&&(t.count++,Va=new Map,e.forEach(R5,t),Va=null,Ua.call(t))}function R5(t,e){if(!(e.state.loading&4)){var n=Va.get(t);if(n)var l=n.get(null);else{n=new Map,Va.set(t,n);for(var i=t.querySelectorAll("link[data-precedence],style[data-precedence]"),r=0;r{"use strict";function sg(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(sg)}catch(t){console.error(t)}}sg(),ug.exports=ag()});var M3=Ao(Gl(),1),T3=Ao(cg(),1);var Y5={isDark:null,pageBackground:"--agent-page-bg",surfaceBackground:"--agent-surface",surfaceElevatedBackground:"--agent-surface-elevated",inputBackground:"--agent-input-bg",border:"--agent-border",borderStrong:"--agent-border-strong",text:"--agent-text",mutedText:"--agent-muted",softText:"--agent-soft",accent:"--agent-accent",accentSoft:"--agent-accent-soft",danger:"--agent-danger",shadow:"--agent-shadow"};function Pr(t){if(typeof document>"u")return;let e=document.documentElement;e.dataset.theme=t.isDark?"dark":"light",Kf(),e.classList.toggle("dark",t.isDark),e.classList.toggle("electron-dark",t.isDark),e.classList.toggle("light",!t.isDark),e.style.colorScheme=t.isDark?"dark":"light";for(let[n,l]of Object.entries(Y5))l&&e.style.setProperty(l,String(t[n]))}function Kf(){if(typeof document>"u")return;let t=document.documentElement;t.dataset.codexWindowType="electron",t.dataset.windowType="electron",t.dataset.codexOs=Z5(),document.body&&(document.body.dataset.codexWindowType="electron")}function Z5(){if(typeof navigator>"u")return"unknown";let t=navigator,e=(t.userAgentData?.platform??t.platform??t.userAgent).toLowerCase();return e.includes("win")?"win32":e.includes("mac")||e.includes("darwin")?"darwin":e.includes("linux")?"linux":"unknown"}var E=Ao(Gl(),1);function fg(t){if(t.activityStatus==="stopped"||t.activityStatus==="failed")return"!";switch(t.activityKind){case"command":return"$";case"fileChange":return"+";default:return"*"}}function Wn(){let t=globalThis.crypto;if(typeof t?.randomUUID=="function")return t.randomUUID();let e=t?.getRandomValues?.bind(t);if(typeof e=="function"){let n=new Uint8Array(16);return e(n),n[6]=n[6]&15|64,n[8]=n[8]&63|128,[...n].map((l,i)=>{let r=l.toString(16).padStart(2,"0");return[4,6,8,10].includes(i)?`-${r}`:r}).join("")}return`cmux-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}var If=new Set,$r=class extends Error{code;constructor(e,n){super(e),this.name="NativeBridgeError",this.code=n}};typeof window<"u"&&(window.cmuxAgentBridge={applyTheme(t){Pr(t)},receive(t){t.type==="app.theme"&&Pr(t.theme);for(let e of If)e(t)}});function dg(t){return If.add(t),()=>{If.delete(t)}}async function pn(t,e={}){if(typeof window>"u")throw new Error("Native bridge is unavailable.");let n=window.webkit?.messageHandlers?.agentSession;if(!n||typeof n.postMessage!="function")throw new Error("Native bridge is unavailable.");let l=await n.postMessage({id:Wn(),method:t,params:e});if(!l.ok)throw new $r(l.error?.userMessage||"Native bridge request failed.",l.error?.code);return l.value}var Qe="border-token-border user-select-none no-drag cursor-interaction flex items-center gap-1 border whitespace-nowrap focus:outline-none disabled:cursor-not-allowed disabled:opacity-40",Je="text-token-text-tertiary enabled:hover:bg-token-list-hover-background data-[state=open]:bg-token-list-hover-background border-transparent";var mn="h-token-button-composer px-2 py-0 text-sm leading-[18px]",hg="h-token-button-composer-sm px-1.5 py-0 text-sm leading-[18px]",eo="electron:p-1 electron:[&>svg]:icon-sm flex items-center justify-center p-0.5",to="aspect-square items-center justify-center !px-0",pg="focus-visible:outline-token-button-background cursor-interaction size-token-button-composer flex items-center justify-center rounded-full p-0.5 transition-opacity focus-visible:outline-2 bg-token-foreground",mg="agent-composer-stack",gg="codex-composer-frame relative",yg="codex-composer-inner relative z-10 flex min-h-0 flex-1 flex-col",bg="codex-composer-surface relative flex flex-col bg-token-input-background/90 backdrop-blur-lg extension:border extension:border-token-border/50 electron:ring electron:ring-black/10 electron:shadow-[0_4px_16px_0_rgba(0,0,0,0.05)] electron:dark:bg-token-dropdown-background",Sg="composer-footer grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 px-2 py-1",xg="composer-footer grid grid-cols-[minmax(0,auto)_auto_minmax(0,1fr)] items-center gap-[5px] mb-2 px-2";var rs="M16.6182 9.33203H3.38184V12.7002C3.38184 13.3753 3.38238 13.8438 3.41211 14.208C3.44124 14.5646 3.49494 14.766 3.57129 14.916L3.63867 15.0361C3.80618 15.3094 4.04683 15.5324 4.33399 15.6787L4.45703 15.7314C4.59362 15.7803 4.77411 15.816 5.04199 15.8379C5.40624 15.8676 5.87469 15.8682 6.54981 15.8682H13.4502C14.1253 15.8682 14.5938 15.8676 14.958 15.8379C15.3146 15.8088 15.516 15.7551 15.666 15.6787L15.7861 15.6113C16.0594 15.4438 16.2824 15.2032 16.4287 14.916L16.4814 14.793C16.5303 14.6564 16.566 14.4759 16.5879 14.208C16.6176 13.8438 16.6182 13.3753 16.6182 12.7002V9.33203ZM17.8818 12.7002C17.8818 13.3547 17.8826 13.8838 17.8477 14.3115C17.8165 14.6922 17.7543 15.0349 17.6172 15.3545L17.5537 15.4902C17.3015 15.9852 16.9182 16.3996 16.4473 16.6885L16.2402 16.8037C15.8824 16.9861 15.4966 17.0621 15.0615 17.0977C14.6338 17.1326 14.1047 17.1318 13.4502 17.1318H6.54981C5.89526 17.1318 5.36616 17.1326 4.93848 17.0977C4.55777 17.0665 4.21506 17.0043 3.89551 16.8672L3.75977 16.8037C3.26483 16.5515 2.85036 16.1682 2.56152 15.6973L2.44629 15.4902C2.26394 15.1324 2.1879 14.7466 2.15235 14.3115C2.1174 13.8838 2.11817 13.3547 2.11817 12.7002V7.29981C2.11817 6.64526 2.1174 6.11616 2.15235 5.68848C2.1879 5.25344 2.26394 4.86765 2.44629 4.50977L2.56152 4.30274C2.85036 3.83179 3.26483 3.44854 3.75977 3.19629L3.89551 3.13281C4.21506 2.99571 4.55777 2.93346 4.93848 2.90235C5.36616 2.8674 5.89526 2.86817 6.54981 2.86817H7.24512C7.38876 2.86816 7.48717 2.86807 7.58399 2.87402L7.83496 2.90039C8.41501 2.98537 8.96006 3.23832 9.40039 3.63086L9.64356 3.86817C9.75546 3.98103 9.79343 4.0181 9.83008 4.05078L9.94238 4.14356C10.2142 4.34787 10.5413 4.46917 10.8828 4.49024L11.1445 4.49317H13.4502C14.1047 4.49317 14.6338 4.4924 15.0615 4.52735C15.4966 4.5629 15.8824 4.63894 16.2402 4.82129L16.4473 4.93652C16.9182 5.22536 17.3015 5.63983 17.5537 6.13477L17.6172 6.27051C17.7543 6.59006 17.8165 6.93277 17.8477 7.31348C17.8826 7.74116 17.8818 8.27026 17.8818 8.92481V12.7002ZM3.38184 8.06836H16.6143C16.6105 7.81516 16.603 7.60256 16.5879 7.41699C16.566 7.14911 16.5303 6.96862 16.4814 6.83203L16.4287 6.70899C16.2824 6.42183 16.0594 6.18118 15.7861 6.01367L15.666 5.94629C15.516 5.86994 15.3146 5.81624 14.958 5.78711C14.5938 5.75738 14.1253 5.75684 13.4502 5.75684H11.1445L10.8047 5.75098C10.2158 5.71466 9.65236 5.50645 9.1836 5.1543L8.98926 4.99414C8.91673 4.92948 8.84746 4.85908 8.7461 4.75684L8.55957 4.57422C8.30416 4.34653 7.98784 4.19959 7.65137 4.15039L7.50684 4.13477C7.45779 4.13174 7.4043 4.13184 7.24512 4.13184H6.54981C5.87469 4.13184 5.40624 4.13238 5.04199 4.16211C4.77411 4.184 4.59362 4.21966 4.45703 4.26856L4.33399 4.32129C4.04683 4.4676 3.80618 4.69061 3.63867 4.96387L3.57129 5.08399C3.49494 5.23405 3.44124 5.43543 3.41211 5.79199C3.38238 6.15624 3.38184 6.62469 3.38184 7.29981V8.06836Z";function vg(t){return t.composerLayoutMode==="multiline"||t.hasVisibleAttachments||t.isEditorMultiline||t.isVoiceLayoutActive?!1:t.singleLineInputWidth==null?!0:t.singleLineTextWidth+32<=t.singleLineInputWidth}function Qf(t){let e={};for(let n of t)e[n.id]={hideControl:!1,hideLabel:!1};return e}function Cg(t){let e=t.items.filter(o=>o.enabled),n=Qf(t.items);if(e.length===0||t.availableWidth<=0)return n;let l=e.reduce((o,a)=>o+a.expandedWidth,0)+t.gap*Math.max(0,e.length-1);if(l<=0)return n;let i=l;for(let o of e)!o.canHideLabel||t.availableWidth>=i||(n[o.id].hideLabel=!0,i-=o.expandedWidth-o.compactWidth);if(j5(n,t.previousState,"hideLabel"))return n;let r=e.length;for(let o of e){let a=n[o.id],s=a.hideLabel?o.compactWidth:o.expandedWidth;(a.hideLabel&&!o.hasMeasuredCompactWidth||t.availableWidth1?t.gap:0),r-=1)}return n}function Mg(t,e){let n=Object.keys(t),l=Object.keys(e);if(n.length!==l.length)return!1;for(let i of n)if(t[i]?.hideControl!==e[i]?.hideControl||t[i]?.hideLabel!==e[i]?.hideLabel)return!1;return!0}function j5(t,e,n){let l=Object.keys(t);return l.length!==Object.keys(e).length?!0:l.some(i=>t[i]?.[n]!==e[i]?.[n])}var G5=new Set(["base","embed","form","iframe","link","meta","object","script","style"]),X5=new Set(["poster","src","srcset","xlink:href"]);function Eg(t){let e=typeof window>"u"?void 0:window.marked;if(e?.parse)try{let n=e.parse(K5(t),{async:!1,breaks:!0,gfm:!0});if(typeof n=="string")return W5(n)}catch{return Pn(t)}return Pn(t)}function K5(t){let e="",n=null,l=t.match(/[^\r\n]*(?:\r\n|\n|\r|$)/g)??[];for(let i of l){if(i==="")continue;let r=i.match(/(\r\n|\n|\r)$/)?.[0]??"",o=r?i.slice(0,-r.length):i;if(n){e+=o+r,J5(o,n)&&(n=null);continue}let a=Q5(o);if(a){n=a,e+=o+r;continue}e+=F5(o)+r}return e}function Pn(t){return I5(t).replace(/\n/g,"
")}function I5(t){return t.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function Q5(t){let e=/^( {0,3})(`{3,}|~{3,})/.exec(t);if(!e)return null;let n=e[2];return{character:n[0],length:n.length}}function J5(t,e){let n=/^( {0,3})(`{3,}|~{3,})\s*$/.exec(t);if(!n)return!1;let l=n[2];return l[0]===e.character&&l.length>=e.length}function F5(t){let e="",n=0,l=0;for(;l"u")return t;let e=document.createElement("template");e.innerHTML=t;for(let n of Array.from(e.content.querySelectorAll("*"))){if(G5.has(n.localName)){n.remove();continue}for(let l of Array.from(n.attributes)){let i=l.name.toLowerCase();if(i.startsWith("on")||i==="srcdoc"||i==="style"){n.removeAttribute(l.name);continue}let r=P5(n.localName,i,l.value);r===null?n.removeAttribute(l.name):typeof r=="string"&&r!==l.value&&n.setAttribute(l.name,r)}n.localName==="a"&&n.setAttribute("rel","noreferrer")}return e.innerHTML}function P5(t,e,n){let l=e.toLowerCase();if(X5.has(l))return null;if(l==="href")return t.toLowerCase()!=="a"?null:$5(n)?n:null}function $5(t){let e=t.trim();if(e.startsWith("#"))return!0;if(e.startsWith("/")||!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(e))return!1;try{let n=new URL(e);return n.protocol==="http:"||n.protocol==="https:"||n.protocol==="mailto:"}catch{return!1}}function as(t){return t.replace(/([\\[\]])/g,"\\$1")}function ss(t){return encodeURI(t).replace(/([\\()])/g,"\\$1")}function Ng(t,e){let n=e.map(l=>`[${as(l.label)}](${ss(l.path)})`).join(" ");return n?t.trim().length>0?`${n} +`+l.stack}}var uc=Object.prototype.hasOwnProperty,$c=ge.unstable_scheduleCallback,Nu=ge.unstable_cancelCallback,E4=ge.unstable_shouldYield,N4=ge.unstable_requestPaint,lt=ge.unstable_now,A4=ge.unstable_getCurrentPriorityLevel,Dm=ge.unstable_ImmediatePriority,km=ge.unstable_UserBlockingPriority,Sa=ge.unstable_NormalPriority,O4=ge.unstable_LowPriority,wm=ge.unstable_IdlePriority,D4=ge.log,k4=ge.unstable_setDisableYieldValue,jr=null,it=null;function wn(t){if(typeof D4=="function"&&k4(t),it&&typeof it.setStrictMode=="function")try{it.setStrictMode(jr,t)}catch{}}var rt=Math.clz32?Math.clz32:R4,w4=Math.log,z4=Math.LN2;function R4(t){return t>>>=0,t===0?32:31-(w4(t)/z4|0)|0}var Uo=256,Vo=262144,qo=4194304;function sl(t){var e=t&42;if(e!==0)return e;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&261888;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function Xa(t,e,n){var l=t.pendingLanes;if(l===0)return 0;var i=0,r=t.suspendedLanes,o=t.pingedLanes;t=t.warmLanes;var a=l&134217727;return a!==0?(l=a&~r,l!==0?i=sl(l):(o&=a,o!==0?i=sl(o):n||(n=a&~t,n!==0&&(i=sl(n))))):(a=l&~r,a!==0?i=sl(a):o!==0?i=sl(o):n||(n=l&~t,n!==0&&(i=sl(n)))),i===0?0:e!==0&&e!==i&&(e&r)===0&&(r=i&-i,n=e&-e,r>=n||r===32&&(n&4194048)!==0)?e:i}function Gr(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function L4(t,e){switch(t){case 1:case 2:case 4:case 8:case 64:return e+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function zm(){var t=qo;return qo<<=1,(qo&62914560)===0&&(qo=4194304),t}function Au(t){for(var e=[],n=0;31>n;n++)e.push(t);return e}function Xr(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function _4(t,e,n,l,i,r){var o=t.pendingLanes;t.pendingLanes=n,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=n,t.entangledLanes&=n,t.errorRecoveryDisabledLanes&=n,t.shellSuspendCounter=0;var a=t.entanglements,s=t.expirationTimes,u=t.hiddenUpdates;for(n=o&~n;0"u")return null;try{return t.activeElement||t.body}catch{return t.body}}var Y4=/[\n"\\]/g;function gt(t){return t.replace(Y4,function(e){return"\\"+e.charCodeAt(0).toString(16)+" "})}function dc(t,e,n,l,i,r,o,a){t.name="",o!=null&&typeof o!="function"&&typeof o!="symbol"&&typeof o!="boolean"?t.type=o:t.removeAttribute("type"),e!=null?o==="number"?(e===0&&t.value===""||t.value!=e)&&(t.value=""+ht(e)):t.value!==""+ht(e)&&(t.value=""+ht(e)):o!=="submit"&&o!=="reset"||t.removeAttribute("value"),e!=null?hc(t,o,ht(e)):n!=null?hc(t,o,ht(n)):l!=null&&t.removeAttribute("value"),i==null&&r!=null&&(t.defaultChecked=!!r),i!=null&&(t.checked=i&&typeof i!="function"&&typeof i!="symbol"),a!=null&&typeof a!="function"&&typeof a!="symbol"&&typeof a!="boolean"?t.name=""+ht(a):t.removeAttribute("name")}function Ym(t,e,n,l,i,r,o,a){if(r!=null&&typeof r!="function"&&typeof r!="symbol"&&typeof r!="boolean"&&(t.type=r),e!=null||n!=null){if(!(r!=="submit"&&r!=="reset"||e!=null)){fc(t);return}n=n!=null?""+ht(n):"",e=e!=null?""+ht(e):n,a||e===t.value||(t.value=e),t.defaultValue=e}l=l??i,l=typeof l!="function"&&typeof l!="symbol"&&!!l,t.checked=a?t.checked:!!l,t.defaultChecked=!!l,o!=null&&typeof o!="function"&&typeof o!="symbol"&&typeof o!="boolean"&&(t.name=o),fc(t)}function hc(t,e,n){e==="number"&&xa(t.ownerDocument)===t||t.defaultValue===""+n||(t.defaultValue=""+n)}function fi(t,e,n,l){if(t=t.options,e){e={};for(var i=0;i"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),mc=!1;if(sn)try{Kl={},Object.defineProperty(Kl,"passive",{get:function(){mc=!0}}),window.addEventListener("test",Kl,Kl),window.removeEventListener("test",Kl,Kl)}catch{mc=!1}var Kl,zn=null,of=null,la=null;function Im(){if(la)return la;var t,e=of,n=e.length,l,i="value"in zn?zn.value:zn.textContent,r=i.length;for(t=0;t=mr),fp=" ",dp=!1;function Qm(t,e){switch(t){case"keyup":return m6.indexOf(e.keyCode)!==-1;case"keydown":return e.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Fm(t){return t=t.detail,typeof t=="object"&&"data"in t?t.data:null}var ni=!1;function y6(t,e){switch(t){case"compositionend":return Fm(e);case"keypress":return e.which!==32?null:(dp=!0,fp);case"textInput":return t=e.data,t===fp&&dp?null:t;default:return null}}function b6(t,e){if(ni)return t==="compositionend"||!sf&&Qm(t,e)?(t=Im(),la=of=zn=null,ni=!1,t):null;switch(t){case"paste":return null;case"keypress":if(!(e.ctrlKey||e.altKey||e.metaKey)||e.ctrlKey&&e.altKey){if(e.char&&1=e)return{node:n,offset:e-t};t=l}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=gp(n)}}function $m(t,e){return t&&e?t===e?!0:t&&t.nodeType===3?!1:e&&e.nodeType===3?$m(t,e.parentNode):"contains"in t?t.contains(e):t.compareDocumentPosition?!!(t.compareDocumentPosition(e)&16):!1:!1}function e1(t){t=t!=null&&t.ownerDocument!=null&&t.ownerDocument.defaultView!=null?t.ownerDocument.defaultView:window;for(var e=xa(t.document);e instanceof t.HTMLIFrameElement;){try{var n=typeof e.contentWindow.location.href=="string"}catch{n=!1}if(n)t=e.contentWindow;else break;e=xa(t.document)}return e}function uf(t){var e=t&&t.nodeName&&t.nodeName.toLowerCase();return e&&(e==="input"&&(t.type==="text"||t.type==="search"||t.type==="tel"||t.type==="url"||t.type==="password")||e==="textarea"||t.contentEditable==="true")}var N6=sn&&"documentMode"in document&&11>=document.documentMode,li=null,gc=null,yr=null,yc=!1;function bp(t,e,n){var l=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;yc||li==null||li!==xa(l)||(l=li,"selectionStart"in l&&uf(l)?l={start:l.selectionStart,end:l.selectionEnd}:(l=(l.ownerDocument&&l.ownerDocument.defaultView||window).getSelection(),l={anchorNode:l.anchorNode,anchorOffset:l.anchorOffset,focusNode:l.focusNode,focusOffset:l.focusOffset}),yr&&wr(yr,l)||(yr=l,l=Ua(gc,"onSelect"),0>=o,i-=o,_t=1<<32-rt(e)+i|n<N?(T=M,M=null):T=M.sibling;var O=d(g,M,S[N],x);if(O===null){M===null&&(M=T);break}t&&M&&O.alternate===null&&e(g,M),y=r(O,y,N),z===null?A=O:z.sibling=O,z=O,M=T}if(N===S.length)return n(g,M),q&&$t(g,N),A;if(M===null){for(;NN?(T=M,M=null):T=M.sibling;var G=d(g,M,O.value,x);if(G===null){M===null&&(M=T);break}t&&M&&G.alternate===null&&e(g,M),y=r(G,y,N),z===null?A=G:z.sibling=G,z=G,M=T}if(O.done)return n(g,M),q&&$t(g,N),A;if(M===null){for(;!O.done;N++,O=S.next())O=f(g,O.value,x),O!==null&&(y=r(O,y,N),z===null?A=O:z.sibling=O,z=O);return q&&$t(g,N),A}for(M=l(M);!O.done;N++,O=S.next())O=h(M,g,N,O.value,x),O!==null&&(t&&O.alternate!==null&&M.delete(O.key===null?N:O.key),y=r(O,y,N),z===null?A=O:z.sibling=O,z=O);return t&&M.forEach(function(F){return e(g,F)}),q&&$t(g,N),A}function v(g,y,S,x){if(typeof S=="object"&&S!==null&&S.type===Wl&&S.key===null&&(S=S.props.children),typeof S=="object"&&S!==null){switch(S.$$typeof){case Ho:e:{for(var A=S.key;y!==null;){if(y.key===A){if(A=S.type,A===Wl){if(y.tag===7){n(g,y.sibling),x=i(y,S.props.children),x.return=g,g=x;break e}}else if(y.elementType===A||typeof A=="object"&&A!==null&&A.$$typeof===En&&ul(A)===y.type){n(g,y.sibling),x=i(y,S.props),rr(x,S),x.return=g,g=x;break e}n(g,y);break}else e(g,y);y=y.sibling}S.type===Wl?(x=dl(S.props.children,g.mode,x,S.key),x.return=g,g=x):(x=ra(S.type,S.key,S.props,null,g.mode,x),rr(x,S),x.return=g,g=x)}return o(g);case cr:e:{for(A=S.key;y!==null;){if(y.key===A)if(y.tag===4&&y.stateNode.containerInfo===S.containerInfo&&y.stateNode.implementation===S.implementation){n(g,y.sibling),x=i(y,S.children||[]),x.return=g,g=x;break e}else{n(g,y);break}else e(g,y);y=y.sibling}x=_u(S,g.mode,x),x.return=g,g=x}return o(g);case En:return S=ul(S),v(g,y,S,x)}if(fr(S))return p(g,y,S,x);if(lr(S)){if(A=lr(S),typeof A!="function")throw Error(C(150));return S=A.call(S),b(g,y,S,x)}if(typeof S.then=="function")return v(g,y,Io(S),x);if(S.$$typeof===tn)return v(g,y,Xo(g,S),x);Ko(g,S)}return typeof S=="string"&&S!==""||typeof S=="number"||typeof S=="bigint"?(S=""+S,y!==null&&y.tag===6?(n(g,y.sibling),x=i(y,S),x.return=g,g=x):(n(g,y),x=Lu(S,g.mode,x),x.return=g,g=x),o(g)):n(g,y)}return function(g,y,S,x){try{Lr=0;var A=v(g,y,S,x);return pi=null,A}catch(M){if(M===wi||M===Pa)throw M;var z=tt(29,M,null,g.mode);return z.lanes=x,z.return=g,z}finally{}}}var bl=m1(!0),g1=m1(!1),Nn=!1;function bf(t){t.updateQueue={baseState:t.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Tc(t,e){t=t.updateQueue,e.updateQueue===t&&(e.updateQueue={baseState:t.baseState,firstBaseUpdate:t.firstBaseUpdate,lastBaseUpdate:t.lastBaseUpdate,shared:t.shared,callbacks:null})}function Un(t){return{lane:t,tag:0,payload:null,callback:null,next:null}}function Vn(t,e,n){var l=t.updateQueue;if(l===null)return null;if(l=l.shared,(X&2)!==0){var i=l.pending;return i===null?e.next=e:(e.next=i.next,i.next=e),l.pending=e,e=Ca(t),a1(t,null,n),e}return Ja(t,l,e,n),Ca(t)}function Sr(t,e,n){if(e=e.updateQueue,e!==null&&(e=e.shared,(n&4194048)!==0)){var l=e.lanes;l&=t.pendingLanes,n|=l,e.lanes=n,Lm(t,n)}}function Hu(t,e){var n=t.updateQueue,l=t.alternate;if(l!==null&&(l=l.updateQueue,n===l)){var i=null,r=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};r===null?i=r=o:r=r.next=o,n=n.next}while(n!==null);r===null?i=r=e:r=r.next=e}else i=r=e;n={baseState:l.baseState,firstBaseUpdate:i,lastBaseUpdate:r,shared:l.shared,callbacks:l.callbacks},t.updateQueue=n;return}t=n.lastBaseUpdate,t===null?n.firstBaseUpdate=e:t.next=e,n.lastBaseUpdate=e}var Ec=!1;function xr(){if(Ec){var t=hi;if(t!==null)throw t}}function vr(t,e,n,l){Ec=!1;var i=t.updateQueue;Nn=!1;var r=i.firstBaseUpdate,o=i.lastBaseUpdate,a=i.shared.pending;if(a!==null){i.shared.pending=null;var s=a,u=s.next;s.next=null,o===null?r=u:o.next=u,o=s;var c=t.alternate;c!==null&&(c=c.updateQueue,a=c.lastBaseUpdate,a!==o&&(a===null?c.firstBaseUpdate=u:a.next=u,c.lastBaseUpdate=s))}if(r!==null){var f=i.baseState;o=0,c=u=s=null,a=r;do{var d=a.lane&-536870913,h=d!==a.lane;if(h?(V&d)===d:(l&d)===d){d!==0&&d===xi&&(Ec=!0),c!==null&&(c=c.next={lane:0,tag:a.tag,payload:a.payload,callback:null,next:null});e:{var p=t,b=a;d=e;var v=n;switch(b.tag){case 1:if(p=b.payload,typeof p=="function"){f=p.call(v,f,d);break e}f=p;break e;case 3:p.flags=p.flags&-65537|128;case 0:if(p=b.payload,d=typeof p=="function"?p.call(v,f,d):p,d==null)break e;f=le({},f,d);break e;case 2:Nn=!0}}d=a.callback,d!==null&&(t.flags|=64,h&&(t.flags|=8192),h=i.callbacks,h===null?i.callbacks=[d]:h.push(d))}else h={lane:d,tag:a.tag,payload:a.payload,callback:a.callback,next:null},c===null?(u=c=h,s=f):c=c.next=h,o|=d;if(a=a.next,a===null){if(a=i.shared.pending,a===null)break;h=a,a=h.next,h.next=null,i.lastBaseUpdate=h,i.shared.pending=null}}while(!0);c===null&&(s=f),i.baseState=s,i.firstBaseUpdate=u,i.lastBaseUpdate=c,r===null&&(i.shared.lanes=0),Qn|=o,t.lanes=o,t.memoizedState=f}}function y1(t,e){if(typeof t!="function")throw Error(C(191,t));t.call(e)}function b1(t,e){var n=t.callbacks;if(n!==null)for(t.callbacks=null,t=0;tr?r:8;var o=R.T,a={};R.T=a,wf(t,!1,e,n);try{var s=i(),u=R.S;if(u!==null&&u(a,s),s!==null&&typeof s=="object"&&typeof s.then=="function"){var c=_6(s,l);Cr(t,e,c,ot(t))}else Cr(t,e,l,ot(t))}catch(f){Cr(t,e,{then:function(){},status:"rejected",reason:f},ot())}finally{I.p=r,o!==null&&a.types!==null&&(o.types=a.types),R.T=o}}function Y6(){}function kc(t,e,n,l){if(t.tag!==5)throw Error(C(476));var i=j1(t).queue;Z1(t,i,e,fl,n===null?Y6:function(){return G1(t),n(l)})}function j1(t){var e=t.memoizedState;if(e!==null)return e;e={memoizedState:fl,baseState:fl,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:cn,lastRenderedState:fl},next:null};var n={};return e.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:cn,lastRenderedState:n},next:null},t.memoizedState=e,t=t.alternate,t!==null&&(t.memoizedState=e),e}function G1(t){var e=j1(t);e.next===null&&(e=t.alternate.memoizedState),Cr(t,e.next.queue,{},ot())}function kf(){return Ae(Vr)}function X1(){return ce().memoizedState}function I1(){return ce().memoizedState}function Z6(t){for(var e=t.return;e!==null;){switch(e.tag){case 24:case 3:var n=ot();t=Un(n);var l=Vn(e,t,n);l!==null&&(Xe(l,e,n),Sr(l,e,n)),e={cache:mf()},t.payload=e;return}e=e.return}}function j6(t,e,n){var l=ot();n={lane:l,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},ts(t)?Q1(e,n):(n=ff(t,e,n,l),n!==null&&(Xe(n,t,l),F1(n,e,l)))}function K1(t,e,n){var l=ot();Cr(t,e,n,l)}function Cr(t,e,n,l){var i={lane:l,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(ts(t))Q1(e,i);else{var r=t.alternate;if(t.lanes===0&&(r===null||r.lanes===0)&&(r=e.lastRenderedReducer,r!==null))try{var o=e.lastRenderedState,a=r(o,n);if(i.hasEagerState=!0,i.eagerState=a,at(a,o))return Ja(t,e,i,0),W===null&&Fa(),!1}catch{}finally{}if(n=ff(t,e,i,l),n!==null)return Xe(n,t,l),F1(n,e,l),!0}return!1}function wf(t,e,n,l){if(l={lane:2,revertLane:qf(),gesture:null,action:l,hasEagerState:!1,eagerState:null,next:null},ts(t)){if(e)throw Error(C(479))}else e=ff(t,n,l,2),e!==null&&Xe(e,t,2)}function ts(t){var e=t.alternate;return t===_||e!==null&&e===_}function Q1(t,e){mi=Oa=!0;var n=t.pending;n===null?e.next=e:(e.next=n.next,n.next=e),t.pending=e}function F1(t,e,n){if((n&4194048)!==0){var l=e.lanes;l&=t.pendingLanes,n|=l,e.lanes=n,Lm(t,n)}}var Br={readContext:Ae,use:$a,useCallback:oe,useContext:oe,useEffect:oe,useImperativeHandle:oe,useLayoutEffect:oe,useInsertionEffect:oe,useMemo:oe,useReducer:oe,useRef:oe,useState:oe,useDebugValue:oe,useDeferredValue:oe,useTransition:oe,useSyncExternalStore:oe,useId:oe,useHostTransitionStatus:oe,useFormState:oe,useActionState:oe,useOptimistic:oe,useMemoCache:oe,useCacheRefresh:oe};Br.useEffectEvent=oe;var J1={readContext:Ae,use:$a,useCallback:function(t,e){return Ue().memoizedState=[t,e===void 0?null:e],t},useContext:Ae,useEffect:Rp,useImperativeHandle:function(t,e,n){n=n!=null?n.concat([t]):null,sa(4194308,4,H1.bind(null,e,t),n)},useLayoutEffect:function(t,e){return sa(4194308,4,t,e)},useInsertionEffect:function(t,e){sa(4,2,t,e)},useMemo:function(t,e){var n=Ue();e=e===void 0?null:e;var l=t();if(Sl){wn(!0);try{t()}finally{wn(!1)}}return n.memoizedState=[l,e],l},useReducer:function(t,e,n){var l=Ue();if(n!==void 0){var i=n(e);if(Sl){wn(!0);try{n(e)}finally{wn(!1)}}}else i=e;return l.memoizedState=l.baseState=i,t={pending:null,lanes:0,dispatch:null,lastRenderedReducer:t,lastRenderedState:i},l.queue=t,t=t.dispatch=j6.bind(null,_,t),[l.memoizedState,t]},useRef:function(t){var e=Ue();return t={current:t},e.memoizedState=t},useState:function(t){t=Oc(t);var e=t.queue,n=K1.bind(null,_,e);return e.dispatch=n,[t.memoizedState,n]},useDebugValue:Of,useDeferredValue:function(t,e){var n=Ue();return Df(n,t,e)},useTransition:function(){var t=Oc(!1);return t=Z1.bind(null,_,t.queue,!0,!1),Ue().memoizedState=t,[!1,t]},useSyncExternalStore:function(t,e,n){var l=_,i=Ue();if(q){if(n===void 0)throw Error(C(407));n=n()}else{if(n=e(),W===null)throw Error(C(349));(V&127)!==0||M1(l,e,n)}i.memoizedState=n;var r={value:n,getSnapshot:e};return i.queue=r,Rp(E1.bind(null,l,r,t),[t]),l.flags|=2048,Ci(9,{destroy:void 0},T1.bind(null,l,r,n,e),null),n},useId:function(){var t=Ue(),e=W.identifierPrefix;if(q){var n=Bt,l=_t;n=(l&~(1<<32-rt(l)-1)).toString(32)+n,e="_"+e+"R_"+n,n=Da++,0<\/script>",r=r.removeChild(r.firstChild);break;case"select":r=typeof l.is=="string"?o.createElement("select",{is:l.is}):o.createElement("select"),l.multiple?r.multiple=!0:l.size&&(r.size=l.size);break;default:r=typeof l.is=="string"?o.createElement(i,{is:l.is}):o.createElement(i)}}r[Ee]=e,r[Ie]=l;e:for(o=e.child;o!==null;){if(o.tag===5||o.tag===6)r.appendChild(o.stateNode);else if(o.tag!==4&&o.tag!==27&&o.child!==null){o.child.return=o,o=o.child;continue}if(o===e)break e;for(;o.sibling===null;){if(o.return===null||o.return===e)break e;o=o.return}o.sibling.return=o.return,o=o.sibling}e.stateNode=r;e:switch(Oe(r,i,l),i){case"button":case"input":case"select":case"textarea":l=!!l.autoFocus;break e;case"img":l=!0;break e;default:l=!1}l&&Ft(e)}}return te(e),Xu(e,e.type,t===null?null:t.memoizedProps,e.pendingProps,n),null;case 6:if(t&&e.stateNode!=null)t.memoizedProps!==l&&Ft(e);else{if(typeof l!="string"&&e.stateNode===null)throw Error(C(166));if(t=Bn.current,Ql(e)){if(t=e.stateNode,n=e.memoizedProps,l=null,i=Ne,i!==null)switch(i.tag){case 27:case 5:l=i.memoizedProps}t[Ee]=e,t=!!(t.nodeValue===n||l!==null&&l.suppressHydrationWarning===!0||K0(t.nodeValue,n)),t||In(e,!0)}else t=Va(t).createTextNode(l),t[Ee]=e,e.stateNode=t}return te(e),null;case 31:if(n=e.memoizedState,t===null||t.memoizedState!==null){if(l=Ql(e),n!==null){if(t===null){if(!l)throw Error(C(318));if(t=e.memoizedState,t=t!==null?t.dehydrated:null,!t)throw Error(C(557));t[Ee]=e}else gl(),(e.flags&128)===0&&(e.memoizedState=null),e.flags|=4;te(e),t=!1}else n=Bu(),t!==null&&t.memoizedState!==null&&(t.memoizedState.hydrationErrors=n),t=!0;if(!t)return e.flags&256?(et(e),e):(et(e),null);if((e.flags&128)!==0)throw Error(C(558))}return te(e),null;case 13:if(l=e.memoizedState,t===null||t.memoizedState!==null&&t.memoizedState.dehydrated!==null){if(i=Ql(e),l!==null&&l.dehydrated!==null){if(t===null){if(!i)throw Error(C(318));if(i=e.memoizedState,i=i!==null?i.dehydrated:null,!i)throw Error(C(317));i[Ee]=e}else gl(),(e.flags&128)===0&&(e.memoizedState=null),e.flags|=4;te(e),i=!1}else i=Bu(),t!==null&&t.memoizedState!==null&&(t.memoizedState.hydrationErrors=i),i=!0;if(!i)return e.flags&256?(et(e),e):(et(e),null)}return et(e),(e.flags&128)!==0?(e.lanes=n,e):(n=l!==null,t=t!==null&&t.memoizedState!==null,n&&(l=e.child,i=null,l.alternate!==null&&l.alternate.memoizedState!==null&&l.alternate.memoizedState.cachePool!==null&&(i=l.alternate.memoizedState.cachePool.pool),r=null,l.memoizedState!==null&&l.memoizedState.cachePool!==null&&(r=l.memoizedState.cachePool.pool),r!==i&&(l.flags|=2048)),n!==t&&n&&(e.child.flags|=8192),Qo(e,e.updateQueue),te(e),null);case 4:return yi(),t===null&&Yf(e.stateNode.containerInfo),te(e),null;case 10:return on(e.type),te(e),null;case 19:if(ve(ue),l=e.memoizedState,l===null)return te(e),null;if(i=(e.flags&128)!==0,r=l.rendering,r===null)if(i)or(l,!1);else{if(ae!==0||t!==null&&(t.flags&128)!==0)for(t=e.child;t!==null;){if(r=Aa(t),r!==null){for(e.flags|=128,or(l,!1),t=r.updateQueue,e.updateQueue=t,Qo(e,t),e.subtreeFlags=0,t=n,n=e.child;n!==null;)s1(n,t),n=n.sibling;return $(ue,ue.current&1|2),q&&$t(e,l.treeForkCount),e.child}t=t.sibling}l.tail!==null&<()>Ra&&(e.flags|=128,i=!0,or(l,!1),e.lanes=4194304)}else{if(!i)if(t=Aa(r),t!==null){if(e.flags|=128,i=!0,t=t.updateQueue,e.updateQueue=t,Qo(e,t),or(l,!0),l.tail===null&&l.tailMode==="hidden"&&!r.alternate&&!q)return te(e),null}else 2*lt()-l.renderingStartTime>Ra&&n!==536870912&&(e.flags|=128,i=!0,or(l,!1),e.lanes=4194304);l.isBackwards?(r.sibling=e.child,e.child=r):(t=l.last,t!==null?t.sibling=r:e.child=r,l.last=r)}return l.tail!==null?(t=l.tail,l.rendering=t,l.tail=t.sibling,l.renderingStartTime=lt(),t.sibling=null,n=ue.current,$(ue,i?n&1|2:n&1),q&&$t(e,l.treeForkCount),t):(te(e),null);case 22:case 23:return et(e),Sf(),l=e.memoizedState!==null,t!==null?t.memoizedState!==null!==l&&(e.flags|=8192):l&&(e.flags|=8192),l?(n&536870912)!==0&&(e.flags&128)===0&&(te(e),e.subtreeFlags&6&&(e.flags|=8192)):te(e),n=e.updateQueue,n!==null&&Qo(e,n.retryQueue),n=null,t!==null&&t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(n=t.memoizedState.cachePool.pool),l=null,e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(l=e.memoizedState.cachePool.pool),l!==n&&(e.flags|=2048),t!==null&&ve(hl),null;case 24:return n=null,t!==null&&(n=t.memoizedState.cache),e.memoizedState.cache!==n&&(e.flags|=2048),on(he),te(e),null;case 25:return null;case 30:return null}throw Error(C(156,e.tag))}function Q6(t,e){switch(pf(e),e.tag){case 1:return t=e.flags,t&65536?(e.flags=t&-65537|128,e):null;case 3:return on(he),yi(),t=e.flags,(t&65536)!==0&&(t&128)===0?(e.flags=t&-65537|128,e):null;case 26:case 27:case 5:return ba(e),null;case 31:if(e.memoizedState!==null){if(et(e),e.alternate===null)throw Error(C(340));gl()}return t=e.flags,t&65536?(e.flags=t&-65537|128,e):null;case 13:if(et(e),t=e.memoizedState,t!==null&&t.dehydrated!==null){if(e.alternate===null)throw Error(C(340));gl()}return t=e.flags,t&65536?(e.flags=t&-65537|128,e):null;case 19:return ve(ue),null;case 4:return yi(),null;case 10:return on(e.type),null;case 22:case 23:return et(e),Sf(),t!==null&&ve(hl),t=e.flags,t&65536?(e.flags=t&-65537|128,e):null;case 24:return on(he),null;case 25:return null;default:return null}}function s0(t,e){switch(pf(e),e.tag){case 3:on(he),yi();break;case 26:case 27:case 5:ba(e);break;case 4:yi();break;case 31:e.memoizedState!==null&&et(e);break;case 13:et(e);break;case 19:ve(ue);break;case 10:on(e.type);break;case 22:case 23:et(e),Sf(),t!==null&&ve(hl);break;case 24:on(he)}}function Jr(t,e){try{var n=e.updateQueue,l=n!==null?n.lastEffect:null;if(l!==null){var i=l.next;n=i;do{if((n.tag&t)===t){l=void 0;var r=n.create,o=n.inst;l=r(),o.destroy=l}n=n.next}while(n!==i)}}catch(a){Q(e,e.return,a)}}function Kn(t,e,n){try{var l=e.updateQueue,i=l!==null?l.lastEffect:null;if(i!==null){var r=i.next;l=r;do{if((l.tag&t)===t){var o=l.inst,a=o.destroy;if(a!==void 0){o.destroy=void 0,i=e;var s=n,u=a;try{u()}catch(c){Q(i,s,c)}}}l=l.next}while(l!==r)}}catch(c){Q(e,e.return,c)}}function u0(t){var e=t.updateQueue;if(e!==null){var n=t.stateNode;try{b1(e,n)}catch(l){Q(t,t.return,l)}}}function c0(t,e,n){n.props=xl(t.type,t.memoizedProps),n.state=t.memoizedState;try{n.componentWillUnmount()}catch(l){Q(t,e,l)}}function Mr(t,e){try{var n=t.ref;if(n!==null){switch(t.tag){case 26:case 27:case 5:var l=t.stateNode;break;case 30:l=t.stateNode;break;default:l=t.stateNode}typeof n=="function"?t.refCleanup=n(l):n.current=l}}catch(i){Q(t,e,i)}}function Ht(t,e){var n=t.ref,l=t.refCleanup;if(n!==null)if(typeof l=="function")try{l()}catch(i){Q(t,e,i)}finally{t.refCleanup=null,t=t.alternate,t!=null&&(t.refCleanup=null)}else if(typeof n=="function")try{n(null)}catch(i){Q(t,e,i)}else n.current=null}function f0(t){var e=t.type,n=t.memoizedProps,l=t.stateNode;try{e:switch(e){case"button":case"input":case"select":case"textarea":n.autoFocus&&l.focus();break e;case"img":n.src?l.src=n.src:n.srcSet&&(l.srcset=n.srcSet)}}catch(i){Q(t,t.return,i)}}function Iu(t,e,n){try{var l=t.stateNode;p5(l,t.type,n,e),l[Ie]=e}catch(i){Q(t,t.return,i)}}function d0(t){return t.tag===5||t.tag===3||t.tag===26||t.tag===27&&Jn(t.type)||t.tag===4}function Ku(t){e:for(;;){for(;t.sibling===null;){if(t.return===null||d0(t.return))return null;t=t.return}for(t.sibling.return=t.return,t=t.sibling;t.tag!==5&&t.tag!==6&&t.tag!==18;){if(t.tag===27&&Jn(t.type)||t.flags&2||t.child===null||t.tag===4)continue e;t.child.return=t,t=t.child}if(!(t.flags&2))return t.stateNode}}function _c(t,e,n){var l=t.tag;if(l===5||l===6)t=t.stateNode,e?(n.nodeType===9?n.body:n.nodeName==="HTML"?n.ownerDocument.body:n).insertBefore(t,e):(e=n.nodeType===9?n.body:n.nodeName==="HTML"?n.ownerDocument.body:n,e.appendChild(t),n=n._reactRootContainer,n!=null||e.onclick!==null||(e.onclick=nn));else if(l!==4&&(l===27&&Jn(t.type)&&(n=t.stateNode,e=null),t=t.child,t!==null))for(_c(t,e,n),t=t.sibling;t!==null;)_c(t,e,n),t=t.sibling}function za(t,e,n){var l=t.tag;if(l===5||l===6)t=t.stateNode,e?n.insertBefore(t,e):n.appendChild(t);else if(l!==4&&(l===27&&Jn(t.type)&&(n=t.stateNode),t=t.child,t!==null))for(za(t,e,n),t=t.sibling;t!==null;)za(t,e,n),t=t.sibling}function h0(t){var e=t.stateNode,n=t.memoizedProps;try{for(var l=t.type,i=e.attributes;i.length;)e.removeAttributeNode(i[0]);Oe(e,l,n),e[Ee]=t,e[Ie]=n}catch(r){Q(t,t.return,r)}}var en=!1,de=!1,Qu=!1,Ip=typeof WeakSet=="function"?WeakSet:Set,Se=null;function F6(t,e){if(t=t.containerInfo,Zc=ja,t=e1(t),uf(t)){if("selectionStart"in t)var n={start:t.selectionStart,end:t.selectionEnd};else e:{n=(n=t.ownerDocument)&&n.defaultView||window;var l=n.getSelection&&n.getSelection();if(l&&l.rangeCount!==0){n=l.anchorNode;var i=l.anchorOffset,r=l.focusNode;l=l.focusOffset;try{n.nodeType,r.nodeType}catch{n=null;break e}var o=0,a=-1,s=-1,u=0,c=0,f=t,d=null;t:for(;;){for(var h;f!==n||i!==0&&f.nodeType!==3||(a=o+i),f!==r||l!==0&&f.nodeType!==3||(s=o+l),f.nodeType===3&&(o+=f.nodeValue.length),(h=f.firstChild)!==null;)d=f,f=h;for(;;){if(f===t)break t;if(d===n&&++u===i&&(a=o),d===r&&++c===l&&(s=o),(h=f.nextSibling)!==null)break;f=d,d=f.parentNode}f=h}n=a===-1||s===-1?null:{start:a,end:s}}else n=null}n=n||{start:0,end:0}}else n=null;for(jc={focusedElem:t,selectionRange:n},ja=!1,Se=e;Se!==null;)if(e=Se,t=e.child,(e.subtreeFlags&1028)!==0&&t!==null)t.return=e,Se=t;else for(;Se!==null;){switch(e=Se,r=e.alternate,t=e.flags,e.tag){case 0:if((t&4)!==0&&(t=e.updateQueue,t=t!==null?t.events:null,t!==null))for(n=0;n title"))),Oe(r,l,n),r[Ee]=t,xe(r),l=r;break e;case"link":var o=pm("link","href",i).get(l+(n.href||""));if(o){for(var a=0;av&&(o=v,v=b,b=o);var g=yp(a,b),y=yp(a,v);if(g&&y&&(h.rangeCount!==1||h.anchorNode!==g.node||h.anchorOffset!==g.offset||h.focusNode!==y.node||h.focusOffset!==y.offset)){var S=f.createRange();S.setStart(g.node,g.offset),h.removeAllRanges(),b>v?(h.addRange(S),h.extend(y.node,y.offset)):(S.setEnd(y.node,y.offset),h.addRange(S))}}}}for(f=[],h=a;h=h.parentNode;)h.nodeType===1&&f.push({element:h,left:h.scrollLeft,top:h.scrollTop});for(typeof a.focus=="function"&&a.focus(),a=0;an?32:n,R.T=null,n=Uc,Uc=null;var r=Yn,o=an;if(me=0,Ti=Yn=null,an=0,(X&6)!==0)throw Error(C(331));var a=X;if(X|=4,T0(r.current),v0(r,r.current,o,n),X=a,Pr(0,!1),it&&typeof it.onPostCommitFiberRoot=="function")try{it.onPostCommitFiberRoot(jr,r)}catch{}return!0}finally{I.p=i,R.T=l,V0(t,e)}}function Jp(t,e,n){e=yt(n,e),e=zc(t.stateNode,e,2),t=Vn(t,e,2),t!==null&&(Xr(t,2),qt(t))}function Q(t,e,n){if(t.tag===3)Jp(t,t,n);else for(;e!==null;){if(e.tag===3){Jp(e,t,n);break}else if(e.tag===1){var l=e.stateNode;if(typeof e.type.getDerivedStateFromError=="function"||typeof l.componentDidCatch=="function"&&(qn===null||!qn.has(l))){t=yt(n,t),n=t0(2),l=Vn(e,n,2),l!==null&&(n0(n,l,e,t),Xr(l,2),qt(l));break}}e=e.return}}function Ju(t,e,n){var l=t.pingCache;if(l===null){l=t.pingCache=new W6;var i=new Set;l.set(e,i)}else i=l.get(e),i===void 0&&(i=new Set,l.set(e,i));i.has(n)||(Hf=!0,i.add(n),t=l5.bind(null,t,e,n),e.then(t,t))}function l5(t,e,n){var l=t.pingCache;l!==null&&l.delete(e),t.pingedLanes|=t.suspendedLanes&n,t.warmLanes&=~n,W===t&&(V&n)===n&&(ae===4||ae===3&&(V&62914560)===V&&300>lt()-ns?(X&2)===0&&Ei(t,0):Uf|=n,Mi===V&&(Mi=0)),qt(t)}function Y0(t,e){e===0&&(e=zm()),t=Tl(t,e),t!==null&&(Xr(t,e),qt(t))}function i5(t){var e=t.memoizedState,n=0;e!==null&&(n=e.retryLane),Y0(t,n)}function r5(t,e){var n=0;switch(t.tag){case 31:case 13:var l=t.stateNode,i=t.memoizedState;i!==null&&(n=i.retryLane);break;case 19:l=t.stateNode;break;case 22:l=t.stateNode._retryCache;break;default:throw Error(C(314))}l!==null&&l.delete(e),Y0(t,n)}function o5(t,e){return $c(t,e)}var Ba=null,Pl=null,qc=!1,Ha=!1,Pu=!1,_n=0;function qt(t){t!==Pl&&t.next===null&&(Pl===null?Ba=Pl=t:Pl=Pl.next=t),Ha=!0,qc||(qc=!0,s5())}function Pr(t,e){if(!Pu&&Ha){Pu=!0;do for(var n=!1,l=Ba;l!==null;){if(!e)if(t!==0){var i=l.pendingLanes;if(i===0)var r=0;else{var o=l.suspendedLanes,a=l.pingedLanes;r=(1<<31-rt(42|t)+1)-1,r&=i&~(o&~a),r=r&201326741?r&201326741|1:r?r|2:0}r!==0&&(n=!0,Pp(l,r))}else r=V,r=Xa(l,l===W?r:0,l.cancelPendingCommit!==null||l.timeoutHandle!==-1),(r&3)===0||Gr(l,r)||(n=!0,Pp(l,r));l=l.next}while(n);Pu=!1}}function a5(){Z0()}function Z0(){Ha=qc=!1;var t=0;_n!==0&&g5()&&(t=_n);for(var e=lt(),n=null,l=Ba;l!==null;){var i=l.next,r=j0(l,e);r===0?(l.next=null,n===null?Ba=i:n.next=i,i===null&&(Pl=n)):(n=l,(t!==0||(r&3)!==0)&&(Ha=!0)),l=i}me!==0&&me!==5||Pr(t,!1),_n!==0&&(_n=0)}function j0(t,e){for(var n=t.suspendedLanes,l=t.pingedLanes,i=t.expirationTimes,r=t.pendingLanes&-62914561;0a)break;var c=s.transferSize,f=s.initiatorType;c&&im(f)&&(s=s.responseEnd,o+=c*(s"u"?null:document;function W0(t,e,n){var l=Ri;if(l&&typeof e=="string"&&e){var i=gt(e);i='link[rel="'+t+'"][href="'+i+'"]',typeof n=="string"&&(i+='[crossorigin="'+n+'"]'),fm.has(i)||(fm.add(i),t={rel:t,crossOrigin:n,href:e},l.querySelector(i)===null&&(e=l.createElement("link"),Oe(e,"link",t),xe(e),l.head.appendChild(e)))}}function E5(t){hn.D(t),W0("dns-prefetch",t,null)}function N5(t,e){hn.C(t,e),W0("preconnect",t,e)}function A5(t,e,n){hn.L(t,e,n);var l=Ri;if(l&&t&&e){var i='link[rel="preload"][as="'+gt(e)+'"]';e==="image"&&n&&n.imageSrcSet?(i+='[imagesrcset="'+gt(n.imageSrcSet)+'"]',typeof n.imageSizes=="string"&&(i+='[imagesizes="'+gt(n.imageSizes)+'"]')):i+='[href="'+gt(t)+'"]';var r=i;switch(e){case"style":r=Ni(t);break;case"script":r=Li(t)}vt.has(r)||(t=le({rel:"preload",href:e==="image"&&n&&n.imageSrcSet?void 0:t,as:e},n),vt.set(r,t),l.querySelector(i)!==null||e==="style"&&l.querySelector(Wr(r))||e==="script"&&l.querySelector($r(r))||(e=l.createElement("link"),Oe(e,"link",t),xe(e),l.head.appendChild(e)))}}function O5(t,e){hn.m(t,e);var n=Ri;if(n&&t){var l=e&&typeof e.as=="string"?e.as:"script",i='link[rel="modulepreload"][as="'+gt(l)+'"][href="'+gt(t)+'"]',r=i;switch(l){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":r=Li(t)}if(!vt.has(r)&&(t=le({rel:"modulepreload",href:t},e),vt.set(r,t),n.querySelector(i)===null)){switch(l){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(n.querySelector($r(r)))return}l=n.createElement("link"),Oe(l,"link",t),xe(l),n.head.appendChild(l)}}}function D5(t,e,n){hn.S(t,e,n);var l=Ri;if(l&&t){var i=ci(l).hoistableStyles,r=Ni(t);e=e||"default";var o=i.get(r);if(!o){var a={loading:0,preload:null};if(o=l.querySelector(Wr(r)))a.loading=5;else{t=le({rel:"stylesheet",href:t,"data-precedence":e},n),(n=vt.get(r))&&Zf(t,n);var s=o=l.createElement("link");xe(s),Oe(s,"link",t),s._p=new Promise(function(u,c){s.onload=u,s.onerror=c}),s.addEventListener("load",function(){a.loading|=1}),s.addEventListener("error",function(){a.loading|=2}),a.loading|=4,da(o,e,l)}o={type:"stylesheet",instance:o,count:1,state:a},i.set(r,o)}}}function k5(t,e){hn.X(t,e);var n=Ri;if(n&&t){var l=ci(n).hoistableScripts,i=Li(t),r=l.get(i);r||(r=n.querySelector($r(i)),r||(t=le({src:t,async:!0},e),(e=vt.get(i))&&jf(t,e),r=n.createElement("script"),xe(r),Oe(r,"link",t),n.head.appendChild(r)),r={type:"script",instance:r,count:1,state:null},l.set(i,r))}}function w5(t,e){hn.M(t,e);var n=Ri;if(n&&t){var l=ci(n).hoistableScripts,i=Li(t),r=l.get(i);r||(r=n.querySelector($r(i)),r||(t=le({src:t,async:!0,type:"module"},e),(e=vt.get(i))&&jf(t,e),r=n.createElement("script"),xe(r),Oe(r,"link",t),n.head.appendChild(r)),r={type:"script",instance:r,count:1,state:null},l.set(i,r))}}function dm(t,e,n,l){var i=(i=Bn.current)?qa(i):null;if(!i)throw Error(C(446));switch(t){case"meta":case"title":return null;case"style":return typeof n.precedence=="string"&&typeof n.href=="string"?(e=Ni(n.href),n=ci(i).hoistableStyles,l=n.get(e),l||(l={type:"style",instance:null,count:0,state:null},n.set(e,l)),l):{type:"void",instance:null,count:0,state:null};case"link":if(n.rel==="stylesheet"&&typeof n.href=="string"&&typeof n.precedence=="string"){t=Ni(n.href);var r=ci(i).hoistableStyles,o=r.get(t);if(o||(i=i.ownerDocument||i,o={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},r.set(t,o),(r=i.querySelector(Wr(t)))&&!r._p&&(o.instance=r,o.state.loading=5),vt.has(t)||(n={rel:"preload",as:"style",href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},vt.set(t,n),r||z5(i,t,n,o.state))),e&&l===null)throw Error(C(528,""));return o}if(e&&l!==null)throw Error(C(529,""));return null;case"script":return e=n.async,n=n.src,typeof n=="string"&&e&&typeof e!="function"&&typeof e!="symbol"?(e=Li(n),n=ci(i).hoistableScripts,l=n.get(e),l||(l={type:"script",instance:null,count:0,state:null},n.set(e,l)),l):{type:"void",instance:null,count:0,state:null};default:throw Error(C(444,t))}}function Ni(t){return'href="'+gt(t)+'"'}function Wr(t){return'link[rel="stylesheet"]['+t+"]"}function $0(t){return le({},t,{"data-precedence":t.precedence,precedence:null})}function z5(t,e,n,l){t.querySelector('link[rel="preload"][as="style"]['+e+"]")?l.loading=1:(e=t.createElement("link"),l.preload=e,e.addEventListener("load",function(){return l.loading|=1}),e.addEventListener("error",function(){return l.loading|=2}),Oe(e,"link",n),xe(e),t.head.appendChild(e))}function Li(t){return'[src="'+gt(t)+'"]'}function $r(t){return"script[async]"+t}function hm(t,e,n){if(e.count++,e.instance===null)switch(e.type){case"style":var l=t.querySelector('style[data-href~="'+gt(n.href)+'"]');if(l)return e.instance=l,xe(l),l;var i=le({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return l=(t.ownerDocument||t).createElement("style"),xe(l),Oe(l,"style",i),da(l,n.precedence,t),e.instance=l;case"stylesheet":i=Ni(n.href);var r=t.querySelector(Wr(i));if(r)return e.state.loading|=4,e.instance=r,xe(r),r;l=$0(n),(i=vt.get(i))&&Zf(l,i),r=(t.ownerDocument||t).createElement("link"),xe(r);var o=r;return o._p=new Promise(function(a,s){o.onload=a,o.onerror=s}),Oe(r,"link",l),e.state.loading|=4,da(r,n.precedence,t),e.instance=r;case"script":return r=Li(n.src),(i=t.querySelector($r(r)))?(e.instance=i,xe(i),i):(l=n,(i=vt.get(r))&&(l=le({},n),jf(l,i)),t=t.ownerDocument||t,i=t.createElement("script"),xe(i),Oe(i,"link",l),t.head.appendChild(i),e.instance=i);case"void":return null;default:throw Error(C(443,e.type))}else e.type==="stylesheet"&&(e.state.loading&4)===0&&(l=e.instance,e.state.loading|=4,da(l,n.precedence,t));return e.instance}function da(t,e,n){for(var l=n.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),i=l.length?l[l.length-1]:null,r=i,o=0;o title"):null)}function R5(t,e,n){if(n===1||e.itemProp!=null)return!1;switch(t){case"meta":case"title":return!0;case"style":if(typeof e.precedence!="string"||typeof e.href!="string"||e.href==="")break;return!0;case"link":if(typeof e.rel!="string"||typeof e.href!="string"||e.href===""||e.onLoad||e.onError)break;switch(e.rel){case"stylesheet":return t=e.disabled,typeof e.precedence=="string"&&t==null;default:return!0}case"script":if(e.async&&typeof e.async!="function"&&typeof e.async!="symbol"&&!e.onLoad&&!e.onError&&e.src&&typeof e.src=="string")return!0}return!1}function eg(t){return!(t.type==="stylesheet"&&(t.state.loading&3)===0)}function L5(t,e,n,l){if(n.type==="stylesheet"&&(typeof l.media!="string"||matchMedia(l.media).matches!==!1)&&(n.state.loading&4)===0){if(n.instance===null){var i=Ni(l.href),r=e.querySelector(Wr(i));if(r){e=r._p,e!==null&&typeof e=="object"&&typeof e.then=="function"&&(t.count++,t=Ya.bind(t),e.then(t,t)),n.state.loading|=4,n.instance=r,xe(r);return}r=e.ownerDocument||e,l=$0(l),(i=vt.get(i))&&Zf(l,i),r=r.createElement("link"),xe(r);var o=r;o._p=new Promise(function(a,s){o.onload=a,o.onerror=s}),Oe(r,"link",l),n.instance=r}t.stylesheets===null&&(t.stylesheets=new Map),t.stylesheets.set(n,e),(e=n.state.preload)&&(n.state.loading&3)===0&&(t.count++,n=Ya.bind(t),e.addEventListener("load",n),e.addEventListener("error",n))}}var tc=0;function _5(t,e){return t.stylesheets&&t.count===0&&pa(t,t.stylesheets),0tc?50:800)+e);return t.unsuspend=n,function(){t.unsuspend=null,clearTimeout(l),clearTimeout(i)}}:null}function Ya(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)pa(this,this.stylesheets);else if(this.unsuspend){var t=this.unsuspend;this.unsuspend=null,t()}}}var Za=null;function pa(t,e){t.stylesheets=null,t.unsuspend!==null&&(t.count++,Za=new Map,e.forEach(B5,t),Za=null,Ya.call(t))}function B5(t,e){if(!(e.state.loading&4)){var n=Za.get(t);if(n)var l=n.get(null);else{n=new Map,Za.set(t,n);for(var i=t.querySelectorAll("link[data-precedence],style[data-precedence]"),r=0;r{"use strict";function ug(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(ug)}catch(t){console.error(t)}}ug(),cg.exports=sg()});var N3=wo(Gl(),1),A3=wo(fg(),1);var G5={isDark:null,pageBackground:"--agent-page-bg",surfaceBackground:"--agent-surface",surfaceElevatedBackground:"--agent-surface-elevated",inputBackground:"--agent-input-bg",border:"--agent-border",borderStrong:"--agent-border-strong",text:"--agent-text",mutedText:"--agent-muted",softText:"--agent-soft",accent:"--agent-accent",accentSoft:"--agent-accent-soft",danger:"--agent-danger",shadow:"--agent-shadow"};function eo(t){if(typeof document>"u")return;let e=document.documentElement;e.dataset.theme=t.isDark?"dark":"light",Qf(),e.classList.toggle("dark",t.isDark),e.classList.toggle("electron-dark",t.isDark),e.classList.toggle("light",!t.isDark),e.style.colorScheme=t.isDark?"dark":"light";for(let[n,l]of Object.entries(G5))l&&e.style.setProperty(l,String(t[n]))}function Qf(){if(typeof document>"u")return;let t=document.documentElement;t.dataset.codexWindowType="electron",t.dataset.windowType="electron",t.dataset.codexOs=X5(),document.body&&(document.body.dataset.codexWindowType="electron")}function X5(){if(typeof navigator>"u")return"unknown";let t=navigator,e=(t.userAgentData?.platform??t.platform??t.userAgent).toLowerCase();return e.includes("win")?"win32":e.includes("mac")||e.includes("darwin")?"darwin":e.includes("linux")?"linux":"unknown"}var E=wo(Gl(),1);function dg(t){if(t.activityStatus==="stopped"||t.activityStatus==="failed")return"!";switch(t.activityKind){case"command":return"$";case"fileChange":return"+";default:return"*"}}function Pn(){let t=globalThis.crypto;if(typeof t?.randomUUID=="function")return t.randomUUID();let e=t?.getRandomValues?.bind(t);if(typeof e=="function"){let n=new Uint8Array(16);return e(n),n[6]=n[6]&15|64,n[8]=n[8]&63|128,[...n].map((l,i)=>{let r=l.toString(16).padStart(2,"0");return[4,6,8,10].includes(i)?`-${r}`:r}).join("")}return`cmux-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}var us=new Set,_i=[],hg=512,to=class extends Error{code;constructor(e,n){super(e),this.name="NativeBridgeError",this.code=n}};typeof window<"u"&&(window.cmuxAgentBridge={applyTheme(t){eo(t)},receive:I5});function pg(t){if(us.add(t),_i.length>0){let e=_i.splice(0);for(let n of e)t(n)}return()=>{us.delete(t)}}function I5(t){if(t.type==="app.theme"&&eo(t.theme),us.size===0){_i.push(t),_i.length>hg&&_i.splice(0,_i.length-hg);return}for(let e of us)e(t)}async function pn(t,e={}){if(typeof window>"u")throw new Error("Native bridge is unavailable.");let n=window.webkit?.messageHandlers?.agentSession;if(!n||typeof n.postMessage!="function")throw new Error("Native bridge is unavailable.");let l=await n.postMessage({id:Pn(),method:t,params:e});if(!l.ok)throw new to(l.error?.userMessage||"Native bridge request failed.",l.error?.code);return l.value}var Qe="border-token-border user-select-none no-drag cursor-interaction flex items-center gap-1 border whitespace-nowrap focus:outline-none disabled:cursor-not-allowed disabled:opacity-40",Fe="text-token-text-tertiary enabled:hover:bg-token-list-hover-background data-[state=open]:bg-token-list-hover-background border-transparent";var mn="h-token-button-composer px-2 py-0 text-sm leading-[18px]",mg="h-token-button-composer-sm px-1.5 py-0 text-sm leading-[18px]",no="electron:p-1 electron:[&>svg]:icon-sm flex items-center justify-center p-0.5",lo="aspect-square items-center justify-center !px-0",gg="focus-visible:outline-token-button-background cursor-interaction size-token-button-composer flex items-center justify-center rounded-full p-0.5 transition-opacity focus-visible:outline-2 bg-token-foreground",yg="agent-composer-stack",bg="codex-composer-frame relative",Sg="codex-composer-inner relative z-10 flex min-h-0 flex-1 flex-col",xg="codex-composer-surface relative flex flex-col bg-token-input-background/90 backdrop-blur-lg extension:border extension:border-token-border/50 electron:ring electron:ring-black/10 electron:shadow-[0_4px_16px_0_rgba(0,0,0,0.05)] electron:dark:bg-token-dropdown-background",vg="composer-footer grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 px-2 py-1",Cg="composer-footer grid grid-cols-[minmax(0,auto)_auto_minmax(0,1fr)] items-center gap-[5px] mb-2 px-2";var cs="M16.6182 9.33203H3.38184V12.7002C3.38184 13.3753 3.38238 13.8438 3.41211 14.208C3.44124 14.5646 3.49494 14.766 3.57129 14.916L3.63867 15.0361C3.80618 15.3094 4.04683 15.5324 4.33399 15.6787L4.45703 15.7314C4.59362 15.7803 4.77411 15.816 5.04199 15.8379C5.40624 15.8676 5.87469 15.8682 6.54981 15.8682H13.4502C14.1253 15.8682 14.5938 15.8676 14.958 15.8379C15.3146 15.8088 15.516 15.7551 15.666 15.6787L15.7861 15.6113C16.0594 15.4438 16.2824 15.2032 16.4287 14.916L16.4814 14.793C16.5303 14.6564 16.566 14.4759 16.5879 14.208C16.6176 13.8438 16.6182 13.3753 16.6182 12.7002V9.33203ZM17.8818 12.7002C17.8818 13.3547 17.8826 13.8838 17.8477 14.3115C17.8165 14.6922 17.7543 15.0349 17.6172 15.3545L17.5537 15.4902C17.3015 15.9852 16.9182 16.3996 16.4473 16.6885L16.2402 16.8037C15.8824 16.9861 15.4966 17.0621 15.0615 17.0977C14.6338 17.1326 14.1047 17.1318 13.4502 17.1318H6.54981C5.89526 17.1318 5.36616 17.1326 4.93848 17.0977C4.55777 17.0665 4.21506 17.0043 3.89551 16.8672L3.75977 16.8037C3.26483 16.5515 2.85036 16.1682 2.56152 15.6973L2.44629 15.4902C2.26394 15.1324 2.1879 14.7466 2.15235 14.3115C2.1174 13.8838 2.11817 13.3547 2.11817 12.7002V7.29981C2.11817 6.64526 2.1174 6.11616 2.15235 5.68848C2.1879 5.25344 2.26394 4.86765 2.44629 4.50977L2.56152 4.30274C2.85036 3.83179 3.26483 3.44854 3.75977 3.19629L3.89551 3.13281C4.21506 2.99571 4.55777 2.93346 4.93848 2.90235C5.36616 2.8674 5.89526 2.86817 6.54981 2.86817H7.24512C7.38876 2.86816 7.48717 2.86807 7.58399 2.87402L7.83496 2.90039C8.41501 2.98537 8.96006 3.23832 9.40039 3.63086L9.64356 3.86817C9.75546 3.98103 9.79343 4.0181 9.83008 4.05078L9.94238 4.14356C10.2142 4.34787 10.5413 4.46917 10.8828 4.49024L11.1445 4.49317H13.4502C14.1047 4.49317 14.6338 4.4924 15.0615 4.52735C15.4966 4.5629 15.8824 4.63894 16.2402 4.82129L16.4473 4.93652C16.9182 5.22536 17.3015 5.63983 17.5537 6.13477L17.6172 6.27051C17.7543 6.59006 17.8165 6.93277 17.8477 7.31348C17.8826 7.74116 17.8818 8.27026 17.8818 8.92481V12.7002ZM3.38184 8.06836H16.6143C16.6105 7.81516 16.603 7.60256 16.5879 7.41699C16.566 7.14911 16.5303 6.96862 16.4814 6.83203L16.4287 6.70899C16.2824 6.42183 16.0594 6.18118 15.7861 6.01367L15.666 5.94629C15.516 5.86994 15.3146 5.81624 14.958 5.78711C14.5938 5.75738 14.1253 5.75684 13.4502 5.75684H11.1445L10.8047 5.75098C10.2158 5.71466 9.65236 5.50645 9.1836 5.1543L8.98926 4.99414C8.91673 4.92948 8.84746 4.85908 8.7461 4.75684L8.55957 4.57422C8.30416 4.34653 7.98784 4.19959 7.65137 4.15039L7.50684 4.13477C7.45779 4.13174 7.4043 4.13184 7.24512 4.13184H6.54981C5.87469 4.13184 5.40624 4.13238 5.04199 4.16211C4.77411 4.184 4.59362 4.21966 4.45703 4.26856L4.33399 4.32129C4.04683 4.4676 3.80618 4.69061 3.63867 4.96387L3.57129 5.08399C3.49494 5.23405 3.44124 5.43543 3.41211 5.79199C3.38238 6.15624 3.38184 6.62469 3.38184 7.29981V8.06836Z";function Mg(t){return t.composerLayoutMode==="multiline"||t.hasVisibleAttachments||t.isEditorMultiline||t.isVoiceLayoutActive?!1:t.singleLineInputWidth==null?!0:t.singleLineTextWidth+32<=t.singleLineInputWidth}function Ff(t){let e={};for(let n of t)e[n.id]={hideControl:!1,hideLabel:!1};return e}function Tg(t){let e=t.items.filter(o=>o.enabled),n=Ff(t.items);if(e.length===0||t.availableWidth<=0)return n;let l=e.reduce((o,a)=>o+a.expandedWidth,0)+t.gap*Math.max(0,e.length-1);if(l<=0)return n;let i=l;for(let o of e)!o.canHideLabel||t.availableWidth>=i||(n[o.id].hideLabel=!0,i-=o.expandedWidth-o.compactWidth);if(K5(n,t.previousState,"hideLabel"))return n;let r=e.length;for(let o of e){let a=n[o.id],s=a.hideLabel?o.compactWidth:o.expandedWidth;(a.hideLabel&&!o.hasMeasuredCompactWidth||t.availableWidth1?t.gap:0),r-=1)}return n}function Eg(t,e){let n=Object.keys(t),l=Object.keys(e);if(n.length!==l.length)return!1;for(let i of n)if(t[i]?.hideControl!==e[i]?.hideControl||t[i]?.hideLabel!==e[i]?.hideLabel)return!1;return!0}function K5(t,e,n){let l=Object.keys(t);return l.length!==Object.keys(e).length?!0:l.some(i=>t[i]?.[n]!==e[i]?.[n])}var Q5=new Set(["base","embed","form","iframe","link","meta","object","script","style"]),F5=new Set(["poster","src","srcset","xlink:href"]);function Ag(t){let e=typeof window>"u"?void 0:window.marked;if(e?.parse)try{let n=e.parse(J5(t),{async:!1,breaks:!0,gfm:!0});if(typeof n=="string")return tb(n)}catch{return Wn(t)}return Wn(t)}function J5(t){let e="",n=null,l=t.match(/[^\r\n]*(?:\r\n|\n|\r|$)/g)??[];for(let i of l){if(i==="")continue;let r=i.match(/(\r\n|\n|\r)$/)?.[0]??"",o=r?i.slice(0,-r.length):i;if(n){e+=o+r,$5(o,n)&&(n=null);continue}let a=W5(o);if(a){n=a,e+=o+r;continue}e+=eb(o)+r}return e}function Wn(t){return P5(t).replace(/\n/g,"
")}function P5(t){return t.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function W5(t){let e=/^( {0,3})(`{3,}|~{3,})/.exec(t);if(!e)return null;let n=e[2];return{character:n[0],length:n.length}}function $5(t,e){let n=/^( {0,3})(`{3,}|~{3,})\s*$/.exec(t);if(!n)return!1;let l=n[2];return l[0]===e.character&&l.length>=e.length}function eb(t){let e="",n=0,l=0;for(;l"u")return t;let e=document.createElement("template");e.innerHTML=t;for(let n of Array.from(e.content.querySelectorAll("*"))){if(Q5.has(n.localName)){n.remove();continue}for(let l of Array.from(n.attributes)){let i=l.name.toLowerCase();if(i.startsWith("on")||i==="srcdoc"||i==="style"){n.removeAttribute(l.name);continue}let r=nb(n.localName,i,l.value);r===null?n.removeAttribute(l.name):typeof r=="string"&&r!==l.value&&n.setAttribute(l.name,r)}n.localName==="a"&&n.setAttribute("rel","noreferrer")}return e.innerHTML}function nb(t,e,n){let l=e.toLowerCase();if(F5.has(l))return null;if(l==="href")return t.toLowerCase()!=="a"?null:lb(n)?n:null}function lb(t){let e=t.trim();if(e.startsWith("#"))return!0;if(e.startsWith("/")||!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(e))return!1;try{let n=new URL(e);return n.protocol==="http:"||n.protocol==="https:"||n.protocol==="mailto:"}catch{return!1}}function fs(t){return t.replace(/([\\[\]])/g,"\\$1")}function ds(t){return encodeURI(t).replace(/([\\()])/g,"\\$1")}function Og(t,e){let n=e.map(l=>`[${fs(l.label)}](${ds(l.path)})`).join(" ");return n?t.trim().length>0?`${n} -${t}`:n:t}function no(t){switch(t.kind){case"at":return Jf(t.label??t.name,t.path);case"agent":return Jf(`@${t.displayName||t.name}`,t.path);case"skill":return Jf(`$${t.name}`,t.path)}}function Og(t,e,n){if(!n||e==null)return t;let l=no(e);return t.includes(l)||t.includes(e.path)?t:t.trim().length>0?`${l} +${t}`:n:t}function io(t){switch(t.kind){case"at":return Jf(t.label??t.name,t.path);case"agent":return Jf(`@${t.displayName||t.name}`,t.path);case"skill":return Jf(`$${t.name}`,t.path)}}function Dg(t,e,n){if(!n||e==null)return t;let l=io(e);return t.includes(l)||t.includes(e.path)?t:t.trim().length>0?`${l} -${t}`:l}function Jf(t,e){return`[${as(t)}](${ss(e)})`}var Ag="[$plan](skill://plan)";function Dg(t,e){return!e||eb(t)?t:t.trim().length>0?`${t} +${t}`:l}function Jf(t,e){return`[${fs(t)}](${ds(e)})`}var kg="[$plan](skill://plan)";function wg(t,e){return!e||ib(t)?t:t.trim().length>0?`${t} -${Ag}`:Ag}function eb(t){return t.includes("skill://plan")||/(?:^|\s)\$plan(?:\s|$)/.test(t)}function kg(t){return t?.id==="codex"?"GPT-5.5":t?.displayName??"GPT-5.5"}function wg(t){let e=t.displayName,n=e.toLowerCase();return t.id==="claude"||n.includes("claude")?"Cl":t.id==="opencode"||n.includes("open")?"O":n==="pi"||n.includes(" pi")?"Pi":e.trim().slice(0,1).toUpperCase()||"C"}function Rg(t){let e=t.usedPercent,n=t.remainingPercent,l=e!=null&&Number.isFinite(e)?lo(e):lo(100-(n??0)),i=n!=null&&Number.isFinite(n)?lo(n):lo(100-l);return{...t,remainingPercent:i,usedPercent:l,windowDurationMins:Number.isFinite(t.windowDurationMins)?t.windowDurationMins:void 0}}function Wf(t){return Number.isFinite(t)?`${Math.round(lo(t))}%`:"100%"}function Pf(t,e=new Date){if(t==null||!Number.isFinite(t))return null;let n=new Date(t*1e3);if(!Number.isFinite(n.getTime()))return null;let l=Math.floor((n.getTime()-e.getTime())/1e3);return l>0&&l<3600?new Intl.DateTimeFormat(void 0,{timeStyle:"short"}).format(n):tb(n,e)?new Intl.DateTimeFormat(void 0,{timeStyle:"short"}).format(n):new Intl.DateTimeFormat(void 0,{month:"short",day:"numeric"}).format(n)}function $f(t,e,n){if(t==null||!Number.isFinite(t)||t<=0)return e;let l=Math.round(t);return zg(t,720*60)?n?.monthly??e:zg(t,10080)?n?.weekly??e:l>=1440?Ff(n?.daysFormat,Math.ceil(l/1440),e):l>=60?Ff(n?.hoursFormat,Math.ceil(l/60),e):Ff(n?.minutesFormat,Math.max(1,Math.ceil(l)),e)}function lo(t){return Number.isFinite(t)?Math.min(Math.max(t,0),100):100}function zg(t,e){return t>=e*.95&&t<=e*1.05}function tb(t,e){return t.getFullYear()===e.getFullYear()&&t.getMonth()===e.getMonth()&&t.getDate()===e.getDate()}function Ff(t,e,n){let l=new Intl.NumberFormat(void 0,{maximumFractionDigits:0}).format(e);return t==null||t.length===0?n:t.replaceAll("%@",l).replaceAll("%d",l).replaceAll("{value}",l)}var Lg=256*1024,_g=64*1024,nb=8*1024,Bg=`[earlier assistant output truncated] -`,Hg=`[earlier command output truncated] -`,lb=`[earlier log output truncated] -`;function Ug(t){return{selectedProviderId:"codex",status:"loading",input:"",providers:[],log:[],transcript:[],autoStartAttemptedProviderIds:[],seenSessionIds:[]}}function Vg(t,e){switch(e.type){case"context":return ab({...t,context:e.context,selectedProviderId:e.context.initialProviderId,status:"idle"});case"providers":return{...t,providers:e.providers};case"selectProvider":return hs(t)?{...t,selectedProviderId:e.providerId}:t;case"setInput":return{...t,input:e.input};case"autoStartAttempted":return t.autoStartAttemptedProviderIds.includes(e.providerId)?t:{...t,autoStartAttemptedProviderIds:[...t.autoStartAttemptedProviderIds,e.providerId]};case"starting":return{...t,status:"starting",log:kt(t,"info",wt(t,"startingStatus","Starting"))};case"startAccepted":return t.status!=="starting"||t.runningSessionId?t:{...t,runningSessionId:e.sessionId,requestedStopSessionId:void 0,seenSessionIds:cs(t,e.sessionId)};case"stopping":return{...t,status:"stopping",requestedStopSessionId:e.sessionId,log:kt(t,"info",wt(t,"stoppingStatus","Stopping"))};case"failed":return{...t,status:"failed",log:kt(t,"error",e.message),transcript:io(t,e.message,"error")};case"failedForSession":case"sendFailed":return t.runningSessionId!==e.sessionId||t.requestedStopSessionId===e.sessionId?t:{...t,status:"failed",log:kt(t,"error",e.message),transcript:io(t,e.message,"error")};case"stopFailed":return t.runningSessionId!==e.sessionId&&t.requestedStopSessionId!==e.sessionId?t:{...t,requestedStopSessionId:void 0,status:"failed",log:kt(t,"error",e.message),transcript:io(t,e.message,"error")};case"sent":return t.runningSessionId!==e.sessionId||t.requestedStopSessionId===e.sessionId?t:{...t,input:t.input===e.submittedInput?"":t.input,log:kt(t,"info",fs(t,"sentCharsFormat","Sent %d chars",e.text.length)),transcript:sb(t,e.displayText??e.text,e.attachments,e.sentAtMs)};case"event":return rb(t,e.event);default:return t}}async function qg(t){try{let[e,n]=await Promise.all([pn("app.context"),pn("provider.list")]);Pr(e.theme),t({type:"context",context:e}),t({type:"providers",providers:n})}catch(e){t({type:"failed",message:_i(e)})}}async function Yg(t,e){ds(t)&&await jg(Zg(t),e)}function Zg(t){return{providerId:t.selectedProviderId,workingDirectory:t.context?.workingDirectory,copy:t.context?.copy}}async function jg(t,e){e({type:"starting"});try{let n=await pn("provider.start",{providerId:t.providerId,workingDirectory:t.workingDirectory});e({type:"startAccepted",sessionId:n.sessionId})}catch(n){e({type:"failed",message:_i(n,t.copy)})}}function ib(t){return!ds(t)||t.autoStartAttemptedProviderIds.includes(t.selectedProviderId)?!1:t.providers.find(n=>n.id===t.selectedProviderId)?.autoStart===!0}async function Gg(t,e){if(!ib(t))return;let n=t.selectedProviderId,l=Zg(t);e({type:"autoStartAttempted",providerId:n}),await jg(l,e)}function Xg(t,e,n){hs(e)&&(n({type:"selectProvider",providerId:t}),pn("provider.select",{providerId:t}).catch(()=>{}))}async function Kg(t,e,n={}){let l=n.text??t.input,i=n.clearInput??l;if(l.length===0||!t.runningSessionId||t.status!=="running")return!1;let r=t.runningSessionId;try{return await pn("provider.writeLine",{permissionMode:n.permissionMode??"default",sessionId:r,text:l}),e({type:"sent",attachments:n.attachments,displayText:n.displayText,sentAtMs:Date.now(),sessionId:r,text:l,submittedInput:i}),!0}catch(o){return db(o)||e({type:"sendFailed",sessionId:r,message:_i(o,t)}),!1}}async function Ig(t,e){if(!t.runningSessionId||t.status==="stopping")return;let n=t.runningSessionId;e({type:"stopping",sessionId:n});try{await pn("provider.stop",{sessionId:n})}catch(l){e({type:"stopFailed",sessionId:n,message:_i(l,t)})}}function Qg(t){switch(t.status){case"loading":return wt(t,"loadingStatus","Loading");case"idle":return wt(t,"idleStatus","Idle");case"starting":return wt(t,"startingStatus","Starting");case"running":return wt(t,"runningStatus","Running");case"stopping":return wt(t,"stoppingStatus","Stopping");case"failed":return wt(t,"failedStatus","Failed")}}function ds(t){return(t.status==="idle"||t.status==="failed")&&!t.runningSessionId&&!!t.context}function hs(t){return!t.runningSessionId&&t.status!=="starting"&&t.status!=="stopping"}function Jg(t){return!!t.runningSessionId&&t.status!=="stopping"}function rb(t,e){switch(e.type){case"app.theme":return t.context?{...t,context:{...t.context,theme:e.theme}}:t;case"app.rateLimitRows":return t.context?{...t,context:{...t.context,rateLimitRows:e.rateLimitRows}}:t;case"provider.started":return e.sessionId===t.requestedStopSessionId||t.runningSessionId&&e.sessionId!==t.runningSessionId||!t.runningSessionId&&t.status!=="starting"||!t.runningSessionId&&e.providerId!==t.selectedProviderId?t:{...t,runningSessionId:e.sessionId,requestedStopSessionId:void 0,seenSessionIds:cs(t,e.sessionId),status:"running",log:kt(t,"info",wt(t,"providerStarted","Provider started"))};case"provider.output":return e.sessionId!==t.runningSessionId?t:{...t,log:kt(t,e.stream,e.text),transcript:ub(t,e)};case"provider.activity":return e.sessionId!==t.runningSessionId?t:{...t,transcript:cb(t,e)};case"provider.turnComplete":return e.sessionId!==t.runningSessionId?t:{...t,transcript:us(t.transcript,e.sessionId)};case"provider.exit":return ob(t,e)?e.sessionId===t.requestedStopSessionId?{...t,runningSessionId:void 0,requestedStopSessionId:void 0,seenSessionIds:cs(t,e.sessionId),status:"idle",log:kt(t,"info",wt(t,"stopped","Stopped")),transcript:us(t.transcript,e.sessionId)}:{...t,runningSessionId:void 0,requestedStopSessionId:void 0,seenSessionIds:cs(t,e.sessionId),status:e.status===0?"idle":"failed",log:kt(t,e.status===0?"info":"error",fs(t,"providerExitedFormat","Provider exited %d",e.status)),transcript:e.status===0?us(t.transcript,e.sessionId):io({...t,transcript:us(t.transcript,e.sessionId)},fs(t,"providerExitedFormat","Provider exited %d",e.status),"error")}:t;default:return t}}function ob(t,e){return e.sessionId===t.runningSessionId?!0:!t.seenSessionIds.includes(e.sessionId)&&t.status==="starting"&&!t.runningSessionId&&e.providerId===t.selectedProviderId}function cs(t,e){return t.seenSessionIds.includes(e)?t.seenSessionIds:[...t.seenSessionIds,e].slice(-50)}function ab(t){let e=t.context?.renderer==="solid"?"Solid":"React";return{...t,log:kt(t,"info",fs(t,"rendererReadyFormat","%@ ready",e))}}function kt(t,e,n){return[...t.log,{id:Wn(),level:e,text:ms(n,nb,lb)}].slice(-300)}function sb(t,e,n,l){return ps(t.transcript,{attachments:n,id:Wn(),role:"user",sentAtMs:l,text:e})}function ub(t,e){if(e.stream!=="stdout")return io(t,e.text,"warning");let n=t.transcript.at(-1);return n?.role==="assistant"&&n.sessionId===e.sessionId?[...t.transcript.slice(0,-1),{...n,isComplete:!1,text:Fg(n.text,e.text,Lg,Bg)}]:ps(t.transcript,{id:Wn(),role:"assistant",isComplete:!1,sessionId:e.sessionId,sentAtMs:Date.now(),text:ms(e.text,Lg,Bg)})}function us(t,e){let n=!1,l=t.map(i=>i.role!=="assistant"||i.sessionId!==e||i.isComplete===!0?i:(n=!0,{...i,isComplete:!0}));return n?l:t}function cb(t,e){let n=fb(t.transcript,e.sessionId,e.activityId);if(n>=0){let l=t.transcript[n];return[...t.transcript.slice(0,n),{...l,text:e.action,detail:e.detail??l.detail,activityKind:e.kind,activityStatus:e.status,output:e.outputDelta===void 0?l.output:Fg(l.output??"",e.outputDelta,_g,Hg)},...t.transcript.slice(n+1)]}return ps(t.transcript,{id:Wn(),role:"activity",text:e.action,detail:e.detail,sessionId:e.sessionId,activityId:e.activityId,activityKind:e.kind,activityStatus:e.status,output:e.outputDelta===void 0?void 0:ms(e.outputDelta,_g,Hg)})}function fb(t,e,n){for(let l=t.length-1;l>=0;l-=1){let i=t[l];if(i.role==="activity"&&i.sessionId===e&&i.activityId===n)return l}return-1}function io(t,e,n){return ps(t.transcript,{id:Wn(),role:"notice",tone:n,text:e})}function ps(t,e){return[...t,e].slice(-200)}function Fg(t,e,n,l){let i=t.startsWith(l),r=t.startsWith(l)?t.slice(l.length):t;return i?l+(r+e).slice(-(n-l.length)):ms(r+e,n,l)}function ms(t,e,n){return t.length<=e?t:n+t.slice(-(e-n.length))}function wt(t,e,n){return t.context?.copy[e]??n}function db(t){return t instanceof $r&&t.code==="providerNotReady"}function fs(t,e,n,...l){return hb(wt(t,e,n),l)}function hb(t,e){let n=0;return t.replace(/%(\d+\$)?[@d]/g,(l,i)=>{let r=i?Number(i.slice(0,-1))-1:n++;return String(e[r]??"")})}function _i(t,e){let n=pb(e);return t instanceof Error&&t.message?n&&t.message==="Native bridge request failed."?n.requestFailed:t.message:n?.requestFailed??"Native bridge request failed."}function pb(t){if(t)return"providers"in t?t.context?.copy:t}var se=Ao(Gl(),1);function Re(t){this.content=t}Re.prototype={constructor:Re,find:function(t){for(var e=0;e>1}};Re.from=function(t){if(t instanceof Re)return t;var e=[];if(t)for(var n in t)e.push(n,t[n]);return new Re(e)};var ed=Re;function oy(t,e,n){for(let l=0;;l++){if(l==t.childCount||l==e.childCount)return t.childCount==e.childCount?null:n;let i=t.child(l),r=e.child(l);if(i==r){n+=i.nodeSize;continue}if(!i.sameMarkup(r))return n;if(i.isText&&i.text!=r.text){for(let o=0;i.text[o]==r.text[o];o++)n++;return n}if(i.content.size||r.content.size){let o=oy(i.content,r.content,n+1);if(o!=null)return o}n+=i.nodeSize}}function ay(t,e,n,l){for(let i=t.childCount,r=e.childCount;;){if(i==0||r==0)return i==r?null:{a:n,b:l};let o=t.child(--i),a=e.child(--r),s=o.nodeSize;if(o==a){n-=s,l-=s;continue}if(!o.sameMarkup(a))return{a:n,b:l};if(o.isText&&o.text!=a.text){let u=0,c=Math.min(o.text.length,a.text.length);for(;ue&&l(s,i+a,r||null,o)!==!1&&s.content.size){let c=a+1;s.nodesBetween(Math.max(0,e-c),Math.min(s.content.size,n-c),l,i+c)}a=u}}descendants(e){this.nodesBetween(0,this.size,e)}textBetween(e,n,l,i){let r="",o=!0;return this.nodesBetween(e,n,(a,s)=>{let u=a.isText?a.text.slice(Math.max(e,s)-s,n-s):a.isLeaf?i?typeof i=="function"?i(a):i:a.type.spec.leafText?a.type.spec.leafText(a):"":"";a.isBlock&&(a.isLeaf&&u||a.isTextblock)&&l&&(o?o=!1:r+=l),r+=u},0),r}append(e){if(!e.size)return this;if(!this.size)return e;let n=this.lastChild,l=e.firstChild,i=this.content.slice(),r=0;for(n.isText&&n.sameMarkup(l)&&(i[i.length-1]=n.withText(n.text+l.text),r=1);re)for(let r=0,o=0;oe&&((on)&&(a.isText?a=a.cut(Math.max(0,e-o),Math.min(a.text.length,n-o)):a=a.cut(Math.max(0,e-o-1),Math.min(a.content.size,n-o-1))),l.push(a),i+=a.nodeSize),o=s}return new t(l,i)}cutByIndex(e,n){return e==n?t.empty:e==0&&n==this.content.length?this:new t(this.content.slice(e,n))}replaceChild(e,n){let l=this.content[e];if(l==n)return this;let i=this.content.slice(),r=this.size+n.nodeSize-l.nodeSize;return i[e]=n,new t(i,r)}addToStart(e){return new t([e].concat(this.content),this.size+e.nodeSize)}addToEnd(e){return new t(this.content.concat(e),this.size+e.nodeSize)}eq(e){if(this.content.length!=e.content.length)return!1;for(let n=0;nthis.size||e<0)throw new RangeError(`Position ${e} outside of fragment (${this})`);for(let n=0,l=0;;n++){let i=this.child(n),r=l+i.nodeSize;if(r>=e)return r==e?gs(n+1,r):gs(n,l);l=r}}toString(){return"<"+this.toStringInner()+">"}toStringInner(){return this.content.join(", ")}toJSON(){return this.content.length?this.content.map(e=>e.toJSON()):null}static fromJSON(e,n){if(!n)return t.empty;if(!Array.isArray(n))throw new RangeError("Invalid input for Fragment.fromJSON");return t.fromArray(n.map(e.nodeFromJSON))}static fromArray(e){if(!e.length)return t.empty;let n,l=0;for(let i=0;ithis.type.rank&&(n||(n=e.slice(0,i)),n.push(this),l=!0),n&&n.push(r)}}return n||(n=e.slice()),l||n.push(this),n}removeFromSet(e){for(let n=0;nl.type.rank-i.type.rank),n}};Z.none=[];var Al=class extends Error{},k=class t{constructor(e,n,l){this.content=e,this.openStart=n,this.openEnd=l}get size(){return this.content.size-this.openStart-this.openEnd}insertAt(e,n){let l=uy(this.content,e+this.openStart,n,this.openStart+1,this.openEnd+1);return l&&new t(l,this.openStart,this.openEnd)}removeBetween(e,n){return new t(sy(this.content,e+this.openStart,n+this.openStart),this.openStart,this.openEnd)}eq(e){return this.content.eq(e.content)&&this.openStart==e.openStart&&this.openEnd==e.openEnd}toString(){return this.content+"("+this.openStart+","+this.openEnd+")"}toJSON(){if(!this.content.size)return null;let e={content:this.content.toJSON()};return this.openStart>0&&(e.openStart=this.openStart),this.openEnd>0&&(e.openEnd=this.openEnd),e}static fromJSON(e,n){if(!n)return t.empty;let l=n.openStart||0,i=n.openEnd||0;if(typeof l!="number"||typeof i!="number")throw new RangeError("Invalid input for Slice.fromJSON");return new t(D.fromJSON(e,n.content),l,i)}static maxOpen(e,n=!0){let l=0,i=0;for(let r=e.firstChild;r&&!r.isLeaf&&(n||!r.type.spec.isolating);r=r.firstChild)l++;for(let r=e.lastChild;r&&!r.isLeaf&&(n||!r.type.spec.isolating);r=r.lastChild)i++;return new t(e,l,i)}};k.empty=new k(D.empty,0,0);function sy(t,e,n){let{index:l,offset:i}=t.findIndex(e),r=t.maybeChild(l),{index:o,offset:a}=t.findIndex(n);if(i==e||r.isText){if(a!=n&&!t.child(o).isText)throw new RangeError("Removing non-flat range");return t.cut(0,e).append(t.cut(n))}if(l!=o)throw new RangeError("Removing non-flat range");return t.replaceChild(l,r.copy(sy(r.content,e-i-1,n-i-1)))}function uy(t,e,n,l,i,r){let{index:o,offset:a}=t.findIndex(e),s=t.maybeChild(o);if(a==e||s.isText)return r&&l<=0&&i<=0&&!r.canReplace(o,o,n)?null:t.cut(0,e).append(n).append(t.cut(e));let u=uy(s.content,e-a-1,n,o==0?l-1:0,o==t.childCount-1?i-1:0,s);return u&&t.replaceChild(o,s.copy(u))}function mb(t,e,n){if(n.openStart>t.depth)throw new Al("Inserted content deeper than insertion position");if(t.depth-n.openStart!=e.depth-n.openEnd)throw new Al("Inconsistent open depths");return cy(t,e,n,0)}function cy(t,e,n,l){let i=t.index(l),r=t.node(l);if(i==e.index(l)&&l=0&&t.isText&&t.sameMarkup(e[n])?e[n]=t.withText(e[n].text+t.text):e.push(t)}function ro(t,e,n,l){let i=(e||t).node(n),r=0,o=e?e.index(n):i.childCount;t&&(r=t.index(n),t.depth>n?r++:t.textOffset&&(Nl(t.nodeAfter,l),r++));for(let a=r;ai&&nd(t,e,i+1),o=l.depth>i&&nd(n,l,i+1),a=[];return ro(null,t,i,a),r&&o&&e.index(i)==n.index(i)?(fy(r,o),Nl(Ol(r,dy(t,e,n,l,i+1)),a)):(r&&Nl(Ol(r,xs(t,e,i+1)),a),ro(e,n,i,a),o&&Nl(Ol(o,xs(n,l,i+1)),a)),ro(l,null,i,a),new D(a)}function xs(t,e,n){let l=[];if(ro(null,t,n,l),t.depth>n){let i=nd(t,e,n+1);Nl(Ol(i,xs(t,e,n+1)),l)}return ro(e,null,n,l),new D(l)}function gb(t,e){let n=e.depth-t.openStart,i=e.node(n).copy(t.content);for(let r=n-1;r>=0;r--)i=e.node(r).copy(D.from(i));return{start:i.resolveNoCache(t.openStart+n),end:i.resolveNoCache(i.content.size-t.openEnd-n)}}var vs=class t{constructor(e,n,l){this.pos=e,this.path=n,this.parentOffset=l,this.depth=n.length/3-1}resolveDepth(e){return e==null?this.depth:e<0?this.depth+e:e}get parent(){return this.node(this.depth)}get doc(){return this.node(0)}node(e){return this.path[this.resolveDepth(e)*3]}index(e){return this.path[this.resolveDepth(e)*3+1]}indexAfter(e){return e=this.resolveDepth(e),this.index(e)+(e==this.depth&&!this.textOffset?0:1)}start(e){return e=this.resolveDepth(e),e==0?0:this.path[e*3-1]+1}end(e){return e=this.resolveDepth(e),this.start(e)+this.node(e).content.size}before(e){if(e=this.resolveDepth(e),!e)throw new RangeError("There is no position before the top-level node");return e==this.depth+1?this.pos:this.path[e*3-1]}after(e){if(e=this.resolveDepth(e),!e)throw new RangeError("There is no position after the top-level node");return e==this.depth+1?this.pos:this.path[e*3-1]+this.path[e*3].nodeSize}get textOffset(){return this.pos-this.path[this.path.length-1]}get nodeAfter(){let e=this.parent,n=this.index(this.depth);if(n==e.childCount)return null;let l=this.pos-this.path[this.path.length-1],i=e.child(n);return l?e.child(n).cut(l):i}get nodeBefore(){let e=this.index(this.depth),n=this.pos-this.path[this.path.length-1];return n?this.parent.child(e).cut(0,n):e==0?null:this.parent.child(e-1)}posAtIndex(e,n){n=this.resolveDepth(n);let l=this.path[n*3],i=n==0?0:this.path[n*3-1]+1;for(let r=0;r0;n--)if(this.start(n)<=e&&this.end(n)>=e)return n;return 0}blockRange(e=this,n){if(e.pos=0;l--)if(e.pos<=this.end(l)&&(!n||n(this.node(l))))return new id(this,e,l);return null}sameParent(e){return this.pos-this.parentOffset==e.pos-e.parentOffset}max(e){return e.pos>this.pos?e:this}min(e){return e.pos=0&&n<=e.content.size))throw new RangeError("Position "+n+" out of range");let l=[],i=0,r=n;for(let o=e;;){let{index:a,offset:s}=o.content.findIndex(r),u=r-s;if(l.push(o,a,i+s),!u||(o=o.child(a),o.isText))break;r=u-1,i+=s+1}return new t(n,l,r)}static resolveCached(e,n){let l=Wg.get(e);if(l)for(let r=0;re&&this.nodesBetween(e,n,r=>(l.isInSet(r.marks)&&(i=!0),!i)),i}get isBlock(){return this.type.isBlock}get isTextblock(){return this.type.isTextblock}get inlineContent(){return this.type.inlineContent}get isInline(){return this.type.isInline}get isText(){return this.type.isText}get isLeaf(){return this.type.isLeaf}get isAtom(){return this.type.isAtom}toString(){if(this.type.spec.toDebugString)return this.type.spec.toDebugString(this);let e=this.type.name;return this.content.size&&(e+="("+this.content.toStringInner()+")"),hy(this.marks,e)}contentMatchAt(e){let n=this.type.contentMatch.matchFragment(this.content,0,e);if(!n)throw new Error("Called contentMatchAt on a node with invalid content");return n}canReplace(e,n,l=D.empty,i=0,r=l.childCount){let o=this.contentMatchAt(e).matchFragment(l,i,r),a=o&&o.matchFragment(this.content,n);if(!a||!a.validEnd)return!1;for(let s=i;sn.type.name)}`);this.content.forEach(n=>n.check())}toJSON(){let e={type:this.type.name};for(let n in this.attrs){e.attrs=this.attrs;break}return this.content.size&&(e.content=this.content.toJSON()),this.marks.length&&(e.marks=this.marks.map(n=>n.toJSON())),e}static fromJSON(e,n){if(!n)throw new RangeError("Invalid input for Node.fromJSON");let l;if(n.marks){if(!Array.isArray(n.marks))throw new RangeError("Invalid mark data for Node.fromJSON");l=n.marks.map(e.markFromJSON)}if(n.type=="text"){if(typeof n.text!="string")throw new RangeError("Invalid text node in JSON");return e.text(n.text,l)}let i=D.fromJSON(e,n.content),r=e.nodeType(n.type).create(n.attrs,i,l);return r.type.checkAttrs(r.attrs),r}};Yt.prototype.text=void 0;var rd=class t extends Yt{constructor(e,n,l,i){if(super(e,n,null,i),!l)throw new RangeError("Empty text nodes are not allowed");this.text=l}toString(){return this.type.spec.toDebugString?this.type.spec.toDebugString(this):hy(this.marks,JSON.stringify(this.text))}get textContent(){return this.text}textBetween(e,n){return this.text.slice(e,n)}get nodeSize(){return this.text.length}mark(e){return e==this.marks?this:new t(this.type,this.attrs,this.text,e)}withText(e){return e==this.text?this:new t(this.type,this.attrs,e,this.marks)}cut(e=0,n=this.text.length){return e==0&&n==this.text.length?this:this.withText(this.text.slice(e,n))}eq(e){return this.sameMarkup(e)&&this.text==e.text}toJSON(){let e=super.toJSON();return e.text=this.text,e}};function hy(t,e){for(let n=t.length-1;n>=0;n--)e=t[n].type.name+"("+e+")";return e}var Dl=class t{constructor(e){this.validEnd=e,this.next=[],this.wrapCache=[]}static parse(e,n){let l=new od(e,n);if(l.next==null)return t.empty;let i=py(l);l.next&&l.err("Unexpected trailing text");let r=Eb(Tb(i));return Nb(r,l),r}matchType(e){for(let n=0;nu.createAndFill()));for(let u=0;u=this.next.length)throw new RangeError(`There's no ${e}th edge in this content match`);return this.next[e]}toString(){let e=[];function n(l){e.push(l);for(let i=0;i{let r=i+(l.validEnd?"*":" ")+" ";for(let o=0;o"+e.indexOf(l.next[o].next);return r}).join(` -`)}};Dl.empty=new Dl(!0);var od=class{constructor(e,n){this.string=e,this.nodeTypes=n,this.inline=null,this.pos=0,this.tokens=e.split(/\s*(?=\b|\W|$)/),this.tokens[this.tokens.length-1]==""&&this.tokens.pop(),this.tokens[0]==""&&this.tokens.shift()}get next(){return this.tokens[this.pos]}eat(e){return this.next==e&&(this.pos++||!0)}err(e){throw new SyntaxError(e+" (in content expression '"+this.string+"')")}};function py(t){let e=[];do e.push(Sb(t));while(t.eat("|"));return e.length==1?e[0]:{type:"choice",exprs:e}}function Sb(t){let e=[];do e.push(xb(t));while(t.next&&t.next!=")"&&t.next!="|");return e.length==1?e[0]:{type:"seq",exprs:e}}function xb(t){let e=Mb(t);for(;;)if(t.eat("+"))e={type:"plus",expr:e};else if(t.eat("*"))e={type:"star",expr:e};else if(t.eat("?"))e={type:"opt",expr:e};else if(t.eat("{"))e=vb(t,e);else break;return e}function Pg(t){/\D/.test(t.next)&&t.err("Expected number, got '"+t.next+"'");let e=Number(t.next);return t.pos++,e}function vb(t,e){let n=Pg(t),l=n;return t.eat(",")&&(t.next!="}"?l=Pg(t):l=-1),t.eat("}")||t.err("Unclosed braced range"),{type:"range",min:n,max:l,expr:e}}function Cb(t,e){let n=t.nodeTypes,l=n[e];if(l)return[l];let i=[];for(let r in n){let o=n[r];o.isInGroup(e)&&i.push(o)}return i.length==0&&t.err("No node type or group '"+e+"' found"),i}function Mb(t){if(t.eat("(")){let e=py(t);return t.eat(")")||t.err("Missing closing paren"),e}else if(/\W/.test(t.next))t.err("Unexpected token '"+t.next+"'");else{let e=Cb(t,t.next).map(n=>(t.inline==null?t.inline=n.isInline:t.inline!=n.isInline&&t.err("Mixing inline and block content"),{type:"name",value:n}));return t.pos++,e.length==1?e[0]:{type:"choice",exprs:e}}}function Tb(t){let e=[[]];return i(r(t,0),n()),e;function n(){return e.push([])-1}function l(o,a,s){let u={term:s,to:a};return e[o].push(u),u}function i(o,a){o.forEach(s=>s.to=a)}function r(o,a){if(o.type=="choice")return o.exprs.reduce((s,u)=>s.concat(r(u,a)),[]);if(o.type=="seq")for(let s=0;;s++){let u=r(o.exprs[s],a);if(s==o.exprs.length-1)return u;i(u,a=n())}else if(o.type=="star"){let s=n();return l(a,s),i(r(o.expr,s),s),[l(s)]}else if(o.type=="plus"){let s=n();return i(r(o.expr,a),s),i(r(o.expr,s),s),[l(s)]}else{if(o.type=="opt")return[l(a)].concat(r(o.expr,a));if(o.type=="range"){let s=a;for(let u=0;u{t[o].forEach(({term:a,to:s})=>{if(!a)return;let u;for(let c=0;c{u||i.push([a,u=[]]),u.indexOf(c)==-1&&u.push(c)})})});let r=e[l.join(",")]=new Dl(l.indexOf(t.length-1)>-1);for(let o=0;o-1}get whitespace(){return this.spec.whitespace||(this.spec.code?"pre":"normal")}hasRequiredAttrs(){for(let e in this.attrs)if(this.attrs[e].isRequired)return!0;return!1}compatibleContent(e){return this==e||this.contentMatch.compatible(e.contentMatch)}computeAttrs(e){return!e&&this.defaultAttrs?this.defaultAttrs:yy(this.attrs,e)}create(e=null,n,l){if(this.isText)throw new Error("NodeType.create can't construct text nodes");return new Yt(this,this.computeAttrs(e),D.from(n),Z.setFrom(l))}createChecked(e=null,n,l){return n=D.from(n),this.checkContent(n),new Yt(this,this.computeAttrs(e),n,Z.setFrom(l))}createAndFill(e=null,n,l){if(e=this.computeAttrs(e),n=D.from(n),n.size){let o=this.contentMatch.fillBefore(n);if(!o)return null;n=o.append(n)}let i=this.contentMatch.matchFragment(n),r=i&&i.fillBefore(D.empty,!0);return r?new Yt(this,e,n.append(r),Z.setFrom(l)):null}validContent(e){let n=this.contentMatch.matchFragment(e);if(!n||!n.validEnd)return!1;for(let l=0;l-1}allowsMarks(e){if(this.markSet==null)return!0;for(let n=0;nl[r]=new t(r,n,o));let i=n.spec.topNode||"doc";if(!l[i])throw new RangeError("Schema is missing its top node type ('"+i+"')");if(!l.text)throw new RangeError("Every schema needs a 'text' type");for(let r in l.text.attrs)throw new RangeError("The text node type should not have attributes");return l}};function Ob(t,e,n){let l=n.split("|");return i=>{let r=i===null?"null":typeof i;if(l.indexOf(r)<0)throw new RangeError(`Expected value of type ${l} for attribute ${e} on type ${t}, got ${r}`)}}var ad=class{constructor(e,n,l){this.hasDefault=Object.prototype.hasOwnProperty.call(l,"default"),this.default=l.default,this.validate=typeof l.validate=="string"?Ob(e,n,l.validate):l.validate}get isRequired(){return!this.hasDefault}},ao=class t{constructor(e,n,l,i){this.name=e,this.rank=n,this.schema=l,this.spec=i,this.attrs=Sy(e,i.attrs),this.excluded=null;let r=gy(this.attrs);this.instance=r?new Z(this,r):null}create(e=null){return!e&&this.instance?this.instance:new Z(this,yy(this.attrs,e))}static compile(e,n){let l=Object.create(null),i=0;return e.forEach((r,o)=>l[r]=new t(r,i++,n,o)),l}removeFromSet(e){for(var n=0;n-1}},Ms=class{constructor(e){this.linebreakReplacement=null,this.cached=Object.create(null);let n=this.spec={};for(let i in e)n[i]=e[i];n.nodes=ed.from(e.nodes),n.marks=ed.from(e.marks||{}),this.nodes=Cs.compile(this.spec.nodes,this),this.marks=ao.compile(this.spec.marks,this);let l=Object.create(null);for(let i in this.nodes){if(i in this.marks)throw new RangeError(i+" can not be both a node and a mark");let r=this.nodes[i],o=r.spec.content||"",a=r.spec.marks;if(r.contentMatch=l[o]||(l[o]=Dl.parse(o,this.nodes)),r.inlineContent=r.contentMatch.inlineContent,r.spec.linebreakReplacement){if(this.linebreakReplacement)throw new RangeError("Multiple linebreak nodes defined");if(!r.isInline||!r.isLeaf)throw new RangeError("Linebreak replacement nodes must be inline leaf nodes");this.linebreakReplacement=r}r.markSet=a=="_"?null:a?ey(this,a.split(" ")):a==""||!r.inlineContent?[]:null}for(let i in this.marks){let r=this.marks[i],o=r.spec.excludes;r.excluded=o==null?[r]:o==""?[]:ey(this,o.split(" "))}this.nodeFromJSON=i=>Yt.fromJSON(this,i),this.markFromJSON=i=>Z.fromJSON(this,i),this.topNodeType=this.nodes[this.spec.topNode||"doc"],this.cached.wrappings=Object.create(null)}node(e,n=null,l,i){if(typeof e=="string")e=this.nodeType(e);else if(e instanceof Cs){if(e.schema!=this)throw new RangeError("Node type from different schema used ("+e.name+")")}else throw new RangeError("Invalid node type: "+e);return e.createChecked(n,l,i)}text(e,n){let l=this.nodes.text;return new rd(l,l.defaultAttrs,e,Z.setFrom(n))}mark(e,n){return typeof e=="string"&&(e=this.marks[e]),e.create(n)}nodeType(e){let n=this.nodes[e];if(!n)throw new RangeError("Unknown node type: "+e);return n}};function ey(t,e){let n=[];for(let l=0;l-1)&&n.push(o=s)}if(!o)throw new SyntaxError("Unknown mark type: '"+e[l]+"'")}return n}function Ab(t){return t.tag!=null}function Db(t){return t.style!=null}var so=class t{constructor(e,n){this.schema=e,this.rules=n,this.tags=[],this.styles=[];let l=this.matchedStyles=[];n.forEach(i=>{if(Ab(i))this.tags.push(i);else if(Db(i)){let r=/[^=]*/.exec(i.style)[0];l.indexOf(r)<0&&l.push(r),this.styles.push(i)}}),this.normalizeLists=!this.tags.some(i=>{if(!/^(ul|ol)\b/.test(i.tag)||!i.node)return!1;let r=e.nodes[i.node];return r.contentMatch.matchType(r)})}parse(e,n={}){let l=new Ts(this,n,!1);return l.addAll(e,Z.none,n.from,n.to),l.finish()}parseSlice(e,n={}){let l=new Ts(this,n,!0);return l.addAll(e,Z.none,n.from,n.to),k.maxOpen(l.finish())}matchTag(e,n,l){for(let i=l?this.tags.indexOf(l)+1:0;ie.length&&(a.charCodeAt(e.length)!=61||a.slice(e.length+1)!=n))){if(o.getAttrs){let s=o.getAttrs(n);if(s===!1)continue;o.attrs=s||void 0}return o}}}static schemaRules(e){let n=[];function l(i){let r=i.priority==null?50:i.priority,o=0;for(;o{l(o=ny(o)),o.mark||o.ignore||o.clearMark||(o.mark=i)})}for(let i in e.nodes){let r=e.nodes[i].spec.parseDOM;r&&r.forEach(o=>{l(o=ny(o)),o.node||o.ignore||o.mark||(o.node=i)})}return n}static fromSchema(e){return e.cached.domParser||(e.cached.domParser=new t(e,t.schemaRules(e)))}},xy={address:!0,article:!0,aside:!0,blockquote:!0,canvas:!0,dd:!0,div:!0,dl:!0,fieldset:!0,figcaption:!0,figure:!0,footer:!0,form:!0,h1:!0,h2:!0,h3:!0,h4:!0,h5:!0,h6:!0,header:!0,hgroup:!0,hr:!0,li:!0,noscript:!0,ol:!0,output:!0,p:!0,pre:!0,section:!0,table:!0,tfoot:!0,ul:!0},kb={head:!0,noscript:!0,object:!0,script:!0,style:!0,title:!0},vy={ol:!0,ul:!0},uo=1,sd=2,oo=4;function ty(t,e,n){return e!=null?(e?uo:0)|(e==="full"?sd:0):t&&t.whitespace=="pre"?uo|sd:n&~oo}var Bi=class{constructor(e,n,l,i,r,o){this.type=e,this.attrs=n,this.marks=l,this.solid=i,this.options=o,this.content=[],this.activeMarks=Z.none,this.match=r||(o&oo?null:e.contentMatch)}findWrapping(e){if(!this.match){if(!this.type)return[];let n=this.type.contentMatch.fillBefore(D.from(e));if(n)this.match=this.type.contentMatch.matchFragment(n);else{let l=this.type.contentMatch,i;return(i=l.findWrapping(e.type))?(this.match=l,i):null}}return this.match.findWrapping(e.type)}finish(e){if(!(this.options&uo)){let l=this.content[this.content.length-1],i;if(l&&l.isText&&(i=/[ \t\r\n\u000c]+$/.exec(l.text))){let r=l;l.text.length==i[0].length?this.content.pop():this.content[this.content.length-1]=r.withText(r.text.slice(0,r.text.length-i[0].length))}}let n=D.from(this.content);return!e&&this.match&&(n=n.append(this.match.fillBefore(D.empty,!0))),this.type?this.type.create(this.attrs,n,this.marks):n}inlineContext(e){return this.type?this.type.inlineContent:this.content.length?this.content[0].isInline:e.parentNode&&!xy.hasOwnProperty(e.parentNode.nodeName.toLowerCase())}},Ts=class{constructor(e,n,l){this.parser=e,this.options=n,this.isOpen=l,this.open=0,this.localPreserveWS=!1;let i=n.topNode,r,o=ty(null,n.preserveWhitespace,0)|(l?oo:0);i?r=new Bi(i.type,i.attrs,Z.none,!0,n.topMatch||i.type.contentMatch,o):l?r=new Bi(null,null,Z.none,!0,null,o):r=new Bi(e.schema.topNodeType,null,Z.none,!0,null,o),this.nodes=[r],this.find=n.findPositions,this.needsBlock=!1}get top(){return this.nodes[this.open]}addDOM(e,n){e.nodeType==3?this.addTextNode(e,n):e.nodeType==1&&this.addElement(e,n)}addTextNode(e,n){let l=e.nodeValue,i=this.top,r=i.options&sd?"full":this.localPreserveWS||(i.options&uo)>0,{schema:o}=this.parser;if(r==="full"||i.inlineContext(e)||/[^ \t\r\n\u000c]/.test(l)){if(r)if(r==="full")l=l.replace(/\r\n?/g,` -`);else if(o.linebreakReplacement&&/[\r\n]/.test(l)&&this.top.findWrapping(o.linebreakReplacement.create())){let a=l.split(/\r?\n|\r/);for(let s=0;s!s.clearMark(u)):n=n.concat(this.parser.schema.marks[s.mark].create(s.attrs)),s.consuming===!1)a=s;else break}}return n}addElementByRule(e,n,l,i){let r,o;if(n.node)if(o=this.parser.schema.nodes[n.node],o.isLeaf)this.insertNode(o.create(n.attrs),l,e.nodeName=="BR")||this.leafFallback(e,l);else{let s=this.enter(o,n.attrs||null,l,n.preserveWhitespace);s&&(r=!0,l=s)}else{let s=this.parser.schema.marks[n.mark];l=l.concat(s.create(n.attrs))}let a=this.top;if(o&&o.isLeaf)this.findInside(e);else if(i)this.addElement(e,l,i);else if(n.getContent)this.findInside(e),n.getContent(e,this.parser.schema).forEach(s=>this.insertNode(s,l,!1));else{let s=e;typeof n.contentElement=="string"?s=e.querySelector(n.contentElement):typeof n.contentElement=="function"?s=n.contentElement(e):n.contentElement&&(s=n.contentElement),this.findAround(e,s,!0),this.addAll(s,l),this.findAround(e,s,!1)}r&&this.sync(a)&&this.open--}addAll(e,n,l,i){let r=l||0;for(let o=l?e.childNodes[l]:e.firstChild,a=i==null?null:e.childNodes[i];o!=a;o=o.nextSibling,++r)this.findAtPoint(e,r),this.addDOM(o,n);this.findAtPoint(e,r)}findPlace(e,n,l){let i,r;for(let o=this.open,a=0;o>=0;o--){let s=this.nodes[o],u=s.findWrapping(e);if(u&&(!i||i.length>u.length+a)&&(i=u,r=s,!u.length))break;if(s.solid){if(l)break;a+=2}}if(!i)return null;this.sync(r);for(let o=0;o(o.type?o.type.allowsMarkType(u.type):ly(u.type,e))?(s=u.addToSet(s),!1):!0),this.nodes.push(new Bi(e,n,s,i,null,a)),this.open++,l}closeExtra(e=!1){let n=this.nodes.length-1;if(n>this.open){for(;n>this.open;n--)this.nodes[n-1].content.push(this.nodes[n].finish(e));this.nodes.length=this.open+1}}finish(){return this.open=0,this.closeExtra(this.isOpen),this.nodes[0].finish(!!(this.isOpen||this.options.topOpen))}sync(e){for(let n=this.open;n>=0;n--){if(this.nodes[n]==e)return this.open=n,!0;this.localPreserveWS&&(this.nodes[n].options|=uo)}return!1}get currentPos(){this.closeExtra();let e=0;for(let n=this.open;n>=0;n--){let l=this.nodes[n].content;for(let i=l.length-1;i>=0;i--)e+=l[i].nodeSize;n&&e++}return e}findAtPoint(e,n){if(this.find)for(let l=0;l-1)return e.split(/\s*\|\s*/).some(this.matchesContext,this);let n=e.split("/"),l=this.options.context,i=!this.isOpen&&(!l||l.parent.type==this.nodes[0].type),r=-(l?l.depth+1:0)+(i?0:1),o=(a,s)=>{for(;a>=0;a--){let u=n[a];if(u==""){if(a==n.length-1||a==0)continue;for(;s>=r;s--)if(o(a-1,s))return!0;return!1}else{let c=s>0||s==0&&i?this.nodes[s].type:l&&s>=r?l.node(s-r).type:null;if(!c||c.name!=u&&!c.isInGroup(u))return!1;s--}}return!0};return o(n.length-1,this.open)}textblockFromContext(){let e=this.options.context;if(e)for(let n=e.depth;n>=0;n--){let l=e.node(n).contentMatchAt(e.indexAfter(n)).defaultType;if(l&&l.isTextblock&&l.defaultAttrs)return l}for(let n in this.parser.schema.nodes){let l=this.parser.schema.nodes[n];if(l.isTextblock&&l.defaultAttrs)return l}}};function wb(t){for(let e=t.firstChild,n=null;e;e=e.nextSibling){let l=e.nodeType==1?e.nodeName.toLowerCase():null;l&&vy.hasOwnProperty(l)&&n?(n.appendChild(e),e=n):l=="li"?n=e:l&&(n=null)}}function zb(t,e){return(t.matches||t.msMatchesSelector||t.webkitMatchesSelector||t.mozMatchesSelector).call(t,e)}function ny(t){let e={};for(let n in t)e[n]=t[n];return e}function ly(t,e){let n=e.schema.nodes;for(let l in n){let i=n[l];if(!i.allowsMarkType(t))continue;let r=[],o=a=>{r.push(a);for(let s=0;s{if(r.length||o.marks.length){let a=0,s=0;for(;a=0;i--){let r=this.serializeMark(e.marks[i],e.isInline,n);r&&((r.contentDOM||r.dom).appendChild(l),l=r.dom)}return l}serializeMark(e,n,l={}){let i=this.marks[e.type.name];return i&&bs(ys(l),i(e,n),null,e.attrs)}static renderSpec(e,n,l=null,i){return typeof n=="string"?{dom:e.createTextNode(n)}:bs(e,n,l,i)}static fromSchema(e){return e.cached.domSerializer||(e.cached.domSerializer=new t(this.nodesFromSchema(e),this.marksFromSchema(e)))}static nodesFromSchema(e){let n=iy(e.nodes);return n.text||(n.text=l=>l.text),n}static marksFromSchema(e){return iy(e.marks)}};function iy(t){let e={};for(let n in t){let l=t[n].spec.toDOM;l&&(e[n]=l)}return e}function ys(t){return t.document||window.document}var ry=new WeakMap;function Rb(t){let e=ry.get(t);return e===void 0&&ry.set(t,e=Lb(t)),e}function Lb(t){let e=null;function n(l){if(l&&typeof l=="object")if(Array.isArray(l))if(typeof l[0]=="string")e||(e=[]),e.push(l);else for(let i=0;i-1)throw new RangeError("Using an array from an attribute object as a DOM spec. This may be an attempted cross site scripting attack.");let o=i.indexOf(" ");o>0&&(n=i.slice(0,o),i=i.slice(o+1));let a,s=n?t.createElementNS(n,i):t.createElement(i),u=e[1],c=1;if(u&&typeof u=="object"&&u.nodeType==null&&!Array.isArray(u)){c=2;for(let f in u)if(u[f]!=null){let d=f.indexOf(" ");d>0?s.setAttributeNS(f.slice(0,d),f.slice(d+1),u[f]):f=="style"&&s.style?s.style.cssText=u[f]:s.setAttribute(f,u[f])}}for(let f=c;fc)throw new RangeError("Content hole must be the only child of its parent node");return{dom:s,contentDOM:s}}else if(typeof d=="string")s.appendChild(t.createTextNode(d));else{let{dom:h,contentDOM:p}=bs(t,d,n,l);if(s.appendChild(h),p){if(a)throw new RangeError("Multiple content holes");a=p}}}return{dom:s,contentDOM:a}}var My=65535,Ty=Math.pow(2,16);function _b(t,e){return t+e*Ty}function Cy(t){return t&My}function Bb(t){return(t-(t&My))/Ty}var Ey=1,Ny=2,Es=4,Oy=8,ho=class{constructor(e,n,l){this.pos=e,this.delInfo=n,this.recover=l}get deleted(){return(this.delInfo&Oy)>0}get deletedBefore(){return(this.delInfo&(Ey|Es))>0}get deletedAfter(){return(this.delInfo&(Ny|Es))>0}get deletedAcross(){return(this.delInfo&Es)>0}},gn=class t{constructor(e,n=!1){if(this.ranges=e,this.inverted=n,!e.length&&t.empty)return t.empty}recover(e){let n=0,l=Cy(e);if(!this.inverted)for(let i=0;ie)break;let u=this.ranges[a+r],c=this.ranges[a+o],f=s+u;if(e<=f){let d=u?e==s?-1:e==f?1:n:n,h=s+i+(d<0?0:c);if(l)return h;let p=e==(n<0?s:f)?null:_b(a/3,e-s),b=e==s?Ny:e==f?Ey:Es;return(n<0?e!=s:e!=f)&&(b|=Oy),new ho(h,b,p)}i+=c-u}return l?e+i:new ho(e+i,0,null)}touches(e,n){let l=0,i=Cy(n),r=this.inverted?2:1,o=this.inverted?1:2;for(let a=0;ae)break;let u=this.ranges[a+r],c=s+u;if(e<=c&&a==i*3)return!0;l+=this.ranges[a+o]-u}return!1}forEach(e){let n=this.inverted?2:1,l=this.inverted?1:2;for(let i=0,r=0;i=0;n--){let i=e.getMirror(n);this.appendMap(e._maps[n].invert(),i!=null&&i>n?l-i-1:void 0)}}invert(){let e=new t;return e.appendMappingInverted(this),e}map(e,n=1){if(this.mirror)return this._map(e,n,!0);for(let l=this.from;lr&&s!o.isAtom||!a.type.allowsMarkType(this.mark.type)?o:o.mark(this.mark.addToSet(o.marks)),i),n.openStart,n.openEnd);return De.fromReplace(e,this.from,this.to,r)}invert(){return new wl(this.from,this.to,this.mark)}map(e){let n=e.mapResult(this.from,1),l=e.mapResult(this.to,-1);return n.deleted&&l.deleted||n.pos>=l.pos?null:new t(n.pos,l.pos,this.mark)}merge(e){return e instanceof t&&e.mark.eq(this.mark)&&this.from<=e.to&&this.to>=e.from?new t(Math.min(this.from,e.from),Math.max(this.to,e.to),this.mark):null}toJSON(){return{stepType:"addMark",mark:this.mark.toJSON(),from:this.from,to:this.to}}static fromJSON(e,n){if(typeof n.from!="number"||typeof n.to!="number")throw new RangeError("Invalid input for AddMarkStep.fromJSON");return new t(n.from,n.to,e.markFromJSON(n.mark))}};Ce.jsonID("addMark",po);var wl=class t extends Ce{constructor(e,n,l){super(),this.from=e,this.to=n,this.mark=l}apply(e){let n=e.slice(this.from,this.to),l=new k(md(n.content,i=>i.mark(this.mark.removeFromSet(i.marks)),e),n.openStart,n.openEnd);return De.fromReplace(e,this.from,this.to,l)}invert(){return new po(this.from,this.to,this.mark)}map(e){let n=e.mapResult(this.from,1),l=e.mapResult(this.to,-1);return n.deleted&&l.deleted||n.pos>=l.pos?null:new t(n.pos,l.pos,this.mark)}merge(e){return e instanceof t&&e.mark.eq(this.mark)&&this.from<=e.to&&this.to>=e.from?new t(Math.min(this.from,e.from),Math.max(this.to,e.to),this.mark):null}toJSON(){return{stepType:"removeMark",mark:this.mark.toJSON(),from:this.from,to:this.to}}static fromJSON(e,n){if(typeof n.from!="number"||typeof n.to!="number")throw new RangeError("Invalid input for RemoveMarkStep.fromJSON");return new t(n.from,n.to,e.markFromJSON(n.mark))}};Ce.jsonID("removeMark",wl);var mo=class t extends Ce{constructor(e,n){super(),this.pos=e,this.mark=n}apply(e){let n=e.nodeAt(this.pos);if(!n)return De.fail("No node at mark step's position");let l=n.type.create(n.attrs,null,this.mark.addToSet(n.marks));return De.fromReplace(e,this.pos,this.pos+1,new k(D.from(l),0,n.isLeaf?0:1))}invert(e){let n=e.nodeAt(this.pos);if(n){let l=this.mark.addToSet(n.marks);if(l.length==n.marks.length){for(let i=0;il.pos?null:new t(n.pos,l.pos,i,r,this.slice,this.insert,this.structure)}toJSON(){let e={stepType:"replaceAround",from:this.from,to:this.to,gapFrom:this.gapFrom,gapTo:this.gapTo,insert:this.insert};return this.slice.size&&(e.slice=this.slice.toJSON()),this.structure&&(e.structure=!0),e}static fromJSON(e,n){if(typeof n.from!="number"||typeof n.to!="number"||typeof n.gapFrom!="number"||typeof n.gapTo!="number"||typeof n.insert!="number")throw new RangeError("Invalid input for ReplaceAroundStep.fromJSON");return new t(n.from,n.to,n.gapFrom,n.gapTo,k.fromJSON(e,n.slice),n.insert,!!n.structure)}};Ce.jsonID("replaceAround",ut);function hd(t,e,n){let l=t.resolve(e),i=n-e,r=l.depth;for(;i>0&&r>0&&l.indexAfter(r)==l.node(r).childCount;)r--,i--;if(i>0){let o=l.node(r).maybeChild(l.indexAfter(r));for(;i>0;){if(!o||o.isLeaf)return!0;o=o.firstChild,i--}}return!1}function Hb(t,e,n,l){let i=[],r=[],o,a;t.doc.nodesBetween(e,n,(s,u,c)=>{if(!s.isInline)return;let f=s.marks;if(!l.isInSet(f)&&c.type.allowsMarkType(l.type)){let d=Math.max(u,e),h=Math.min(u+s.nodeSize,n),p=l.addToSet(f);for(let b=0;bt.step(s)),r.forEach(s=>t.step(s))}function Ub(t,e,n,l){let i=[],r=0;t.doc.nodesBetween(e,n,(o,a)=>{if(!o.isInline)return;r++;let s=null;if(l instanceof ao){let u=o.marks,c;for(;c=l.isInSet(u);)(s||(s=[])).push(c),u=c.removeFromSet(u)}else l?l.isInSet(o.marks)&&(s=[l]):s=o.marks;if(s&&s.length){let u=Math.min(a+o.nodeSize,n);for(let c=0;ct.step(new wl(o.from,o.to,o.style)))}function gd(t,e,n,l=n.contentMatch,i=!0){let r=t.doc.nodeAt(e),o=[],a=e+1;for(let s=0;s=0;s--)t.step(o[s])}function Vb(t,e,n){return(e==0||t.canReplace(e,t.childCount))&&(n==t.childCount||t.canReplace(0,n))}function Ds(t){let n=t.parent.content.cutByIndex(t.startIndex,t.endIndex);for(let l=t.depth,i=0,r=0;;--l){let o=t.$from.node(l),a=t.$from.index(l)+i,s=t.$to.indexAfter(l)-r;if(ln;p--)b||l.index(p)>0?(b=!0,c=D.from(l.node(p).copy(c)),f++):s--;let d=D.empty,h=0;for(let p=r,b=!1;p>n;p--)b||i.after(p+1)=0;o--){if(l.size){let a=n[o].type.contentMatch.matchFragment(l);if(!a||!a.validEnd)throw new RangeError("Wrapper type given to Transform.wrap does not form valid content of its parent wrapper")}l=D.from(n[o].type.create(n[o].attrs,l))}let i=e.start,r=e.end;t.step(new ut(i,r,i,r,new k(l,0,0),n.length,!0))}function Zb(t,e,n,l,i){if(!l.isTextblock)throw new RangeError("Type given to setBlockType should be a textblock");let r=t.steps.length;t.doc.nodesBetween(e,n,(o,a)=>{let s=typeof i=="function"?i(o):i;if(o.isTextblock&&!o.hasMarkup(l,s)&&jb(t.doc,t.mapping.slice(r).map(a),l)){let u=null;if(l.schema.linebreakReplacement){let h=l.whitespace=="pre",p=!!l.contentMatch.matchType(l.schema.linebreakReplacement);h&&!p?u=!1:!h&&p&&(u=!0)}u===!1&&Dy(t,o,a,r),gd(t,t.mapping.slice(r).map(a,1),l,void 0,u===null);let c=t.mapping.slice(r),f=c.map(a,1),d=c.map(a+o.nodeSize,1);return t.step(new ut(f,d,f+1,d-1,new k(D.from(l.create(s,null,o.marks)),0,0),1,!0)),u===!0&&Ay(t,o,a,r),!1}})}function Ay(t,e,n,l){e.forEach((i,r)=>{if(i.isText){let o,a=/\r?\n|\r/g;for(;o=a.exec(i.text);){let s=t.mapping.slice(l).map(n+1+r+o.index);t.replaceWith(s,s+1,e.type.schema.linebreakReplacement.create())}}})}function Dy(t,e,n,l){e.forEach((i,r)=>{if(i.type==i.type.schema.linebreakReplacement){let o=t.mapping.slice(l).map(n+1+r);t.replaceWith(o,o+1,e.type.schema.text(` -`))}})}function jb(t,e,n){let l=t.resolve(e),i=l.index();return l.parent.canReplaceWith(i,i+1,n)}function Gb(t,e,n,l,i){let r=t.doc.nodeAt(e);if(!r)throw new RangeError("No node at given position");n||(n=r.type);let o=n.create(l,null,i||r.marks);if(r.isLeaf)return t.replaceWith(e,e+r.nodeSize,o);if(!n.validContent(r.content))throw new RangeError("Invalid content for node type "+n.name);t.step(new ut(e,e+r.nodeSize,e+1,e+r.nodeSize-1,new k(D.from(o),0,0),1,!0))}function go(t,e,n=1,l){let i=t.resolve(e),r=i.depth-n,o=l&&l[l.length-1]||i.parent;if(r<0||i.parent.type.spec.isolating||!i.parent.canReplace(i.index(),i.parent.childCount)||!o.type.validContent(i.parent.content.cutByIndex(i.index(),i.parent.childCount)))return!1;for(let u=i.depth-1,c=n-2;u>r;u--,c--){let f=i.node(u),d=i.index(u);if(f.type.spec.isolating)return!1;let h=f.content.cutByIndex(d,f.childCount),p=l&&l[c+1];p&&(h=h.replaceChild(0,p.type.create(p.attrs)));let b=l&&l[c]||f;if(!f.canReplace(d+1,f.childCount)||!b.type.validContent(h))return!1}let a=i.indexAfter(r),s=l&&l[0];return i.node(r).canReplaceWith(a,a,s?s.type:i.node(r+1).type)}function Xb(t,e,n=1,l){let i=t.doc.resolve(e),r=D.empty,o=D.empty;for(let a=i.depth,s=i.depth-n,u=n-1;a>s;a--,u--){r=D.from(i.node(a).copy(r));let c=l&&l[u];o=D.from(c?c.type.create(c.attrs,o):i.node(a).copy(o))}t.step(new Fe(e,e,new k(r.append(o),n,n),!0))}function yd(t,e){let n=t.resolve(e),l=n.index();return Ib(n.nodeBefore,n.nodeAfter)&&n.parent.canReplace(l,l+1)}function Kb(t,e){e.content.size||t.type.compatibleContent(e.type);let n=t.contentMatchAt(t.childCount),{linebreakReplacement:l}=t.type.schema;for(let i=0;i=0;i--){let r=l.index(i);if(l.node(i).canReplaceWith(r,r,n))return l.before(i+1);if(r>0)return null}if(l.parentOffset==l.parent.content.size)for(let i=l.depth-1;i>=0;i--){let r=l.indexAfter(i);if(l.node(i).canReplaceWith(r,r,n))return l.after(i+1);if(r=0;o--){let a=o==l.depth?0:l.pos<=(l.start(o+1)+l.end(o+1))/2?-1:1,s=l.index(o)+(a>0?1:0),u=l.node(o),c=!1;if(r==1)c=u.canReplace(s,s,i);else{let f=u.contentMatchAt(s).findWrapping(i.firstChild.type);c=f&&u.canReplaceWith(s,s,f[0])}if(c)return a==0?l.pos:a<0?l.before(o+1):l.after(o+1)}return null}function ks(t,e,n=e,l=k.empty){if(e==n&&!l.size)return null;let i=t.resolve(e),r=t.resolve(n);return wy(i,r,l)?new Fe(e,n,l):new pd(i,r,l).fit()}function wy(t,e,n){return!n.openStart&&!n.openEnd&&t.start()==e.start()&&t.parent.canReplace(t.index(),e.index(),n.content)}var pd=class{constructor(e,n,l){this.$from=e,this.$to=n,this.unplaced=l,this.frontier=[],this.placed=D.empty;for(let i=0;i<=e.depth;i++){let r=e.node(i);this.frontier.push({type:r.type,match:r.contentMatchAt(e.indexAfter(i))})}for(let i=e.depth;i>0;i--)this.placed=D.from(e.node(i).copy(this.placed))}get depth(){return this.frontier.length-1}fit(){for(;this.unplaced.size;){let u=this.findFittable();u?this.placeNodes(u):this.openMore()||this.dropNode()}let e=this.mustMoveInline(),n=this.placed.size-this.depth-this.$from.depth,l=this.$from,i=this.close(e<0?this.$to:l.doc.resolve(e));if(!i)return null;let r=this.placed,o=l.depth,a=i.depth;for(;o&&a&&r.childCount==1;)r=r.firstChild.content,o--,a--;let s=new k(r,o,a);return e>-1?new ut(l.pos,e,this.$to.pos,this.$to.end(),s,n):s.size||l.pos!=this.$to.pos?new Fe(l.pos,i.pos,s):null}findFittable(){let e=this.unplaced.openStart;for(let n=this.unplaced.content,l=0,i=this.unplaced.openEnd;l1&&(i=0),r.type.spec.isolating&&i<=l){e=l;break}n=r.content}for(let n=1;n<=2;n++)for(let l=n==1?e:this.unplaced.openStart;l>=0;l--){let i,r=null;l?(r=cd(this.unplaced.content,l-1).firstChild,i=r.content):i=this.unplaced.content;let o=i.firstChild;for(let a=this.depth;a>=0;a--){let{type:s,match:u}=this.frontier[a],c,f=null;if(n==1&&(o?u.matchType(o.type)||(f=u.fillBefore(D.from(o),!1)):r&&s.compatibleContent(r.type)))return{sliceDepth:l,frontierDepth:a,parent:r,inject:f};if(n==2&&o&&(c=u.findWrapping(o.type)))return{sliceDepth:l,frontierDepth:a,parent:r,wrap:c};if(r&&u.matchType(r.type))break}}}openMore(){let{content:e,openStart:n,openEnd:l}=this.unplaced,i=cd(e,n);return!i.childCount||i.firstChild.isLeaf?!1:(this.unplaced=new k(e,n+1,Math.max(l,i.size+n>=e.size-l?n+1:0)),!0)}dropNode(){let{content:e,openStart:n,openEnd:l}=this.unplaced,i=cd(e,n);if(i.childCount<=1&&n>0){let r=e.size-n<=n+i.size;this.unplaced=new k(co(e,n-1,1),n-1,r?n-1:l)}else this.unplaced=new k(co(e,n,1),n,l)}placeNodes({sliceDepth:e,frontierDepth:n,parent:l,inject:i,wrap:r}){for(;this.depth>n;)this.closeFrontierNode();if(r)for(let b=0;b1||s==0||b.content.size)&&(f=v,c.push(zy(b.mark(d.allowedMarks(b.marks)),u==1?s:0,u==a.childCount?h:-1)))}let p=u==a.childCount;p||(h=-1),this.placed=fo(this.placed,n,D.from(c)),this.frontier[n].match=f,p&&h<0&&l&&l.type==this.frontier[this.depth].type&&this.frontier.length>1&&this.closeFrontierNode();for(let b=0,v=a;b1&&i==this.$to.end(--l);)++i;return i}findCloseLevel(e){e:for(let n=Math.min(this.depth,e.depth);n>=0;n--){let{match:l,type:i}=this.frontier[n],r=n=0;a--){let{match:s,type:u}=this.frontier[a],c=fd(e,a,u,s,!0);if(!c||c.childCount)continue e}return{depth:n,fit:o,move:r?e.doc.resolve(e.after(n+1)):e}}}}close(e){let n=this.findCloseLevel(e);if(!n)return null;for(;this.depth>n.depth;)this.closeFrontierNode();n.fit.childCount&&(this.placed=fo(this.placed,n.depth,n.fit)),e=n.move;for(let l=n.depth+1;l<=e.depth;l++){let i=e.node(l),r=i.type.contentMatch.fillBefore(i.content,!0,e.index(l));this.openFrontierNode(i.type,i.attrs,r)}return e}openFrontierNode(e,n=null,l){let i=this.frontier[this.depth];i.match=i.match.matchType(e),this.placed=fo(this.placed,this.depth,D.from(e.create(n,l))),this.frontier.push({type:e,match:e.contentMatch})}closeFrontierNode(){let n=this.frontier.pop().match.fillBefore(D.empty,!0);n.childCount&&(this.placed=fo(this.placed,this.frontier.length,n))}};function co(t,e,n){return e==0?t.cutByIndex(n,t.childCount):t.replaceChild(0,t.firstChild.copy(co(t.firstChild.content,e-1,n)))}function fo(t,e,n){return e==0?t.append(n):t.replaceChild(t.childCount-1,t.lastChild.copy(fo(t.lastChild.content,e-1,n)))}function cd(t,e){for(let n=0;n1&&(l=l.replaceChild(0,zy(l.firstChild,e-1,l.childCount==1?n-1:0))),e>0&&(l=t.type.contentMatch.fillBefore(l).append(l),n<=0&&(l=l.append(t.type.contentMatch.matchFragment(l).fillBefore(D.empty,!0)))),t.copy(l)}function fd(t,e,n,l,i){let r=t.node(e),o=i?t.indexAfter(e):t.index(e);if(o==r.childCount&&!n.compatibleContent(r.type))return null;let a=l.fillBefore(r.content,!0,o);return a&&!Fb(n,r.content,o)?a:null}function Fb(t,e,n){for(let l=n;l0;d--,h--){let p=i.node(d).type.spec;if(p.defining||p.definingAsContext||p.isolating)break;o.indexOf(d)>-1?a=d:i.before(d)==h&&o.splice(1,0,-d)}let s=o.indexOf(a),u=[],c=l.openStart;for(let d=l.content,h=0;;h++){let p=d.firstChild;if(u.push(p),h==l.openStart)break;d=p.content}for(let d=c-1;d>=0;d--){let h=u[d],p=Wb(h.type);if(p&&!h.sameMarkup(i.node(Math.abs(a)-1)))c=d;else if(p||!h.type.isTextblock)break}for(let d=l.openStart;d>=0;d--){let h=(d+c+1)%(l.openStart+1),p=u[h];if(p)for(let b=0;b=0&&(t.replace(e,n,l),!(t.steps.length>f));d--){let h=o[d];h<0||(e=i.before(h),n=r.after(h))}}function Ry(t,e,n,l,i){if(el){let r=i.contentMatchAt(0),o=r.fillBefore(t).append(t);t=o.append(r.matchFragment(o).fillBefore(D.empty,!0))}return t}function $b(t,e,n,l){if(!l.isInline&&e==n&&t.doc.resolve(e).parent.content.size){let i=Jb(t.doc,e,l.type);i!=null&&(e=n=i)}t.replaceRange(e,n,new k(D.from(l),0,0))}function eS(t,e,n){let l=t.doc.resolve(e),i=t.doc.resolve(n);if(l.parent.isTextblock&&i.parent.isTextblock&&l.start()!=i.start()&&l.parentOffset==0&&i.parentOffset==0){let o=l.sharedDepth(n),a=!1;for(let s=l.depth;s>o;s--)l.node(s).type.spec.isolating&&(a=!0);for(let s=i.depth;s>o;s--)i.node(s).type.spec.isolating&&(a=!0);if(!a){for(let s=l.depth;s>0&&e==l.start(s);s--)e=l.before(s);for(let s=i.depth;s>0&&n==i.start(s);s--)n=i.before(s);l=t.doc.resolve(e),i=t.doc.resolve(n)}}let r=Ly(l,i);for(let o=0;o0&&(s||l.node(a-1).canReplace(l.index(a-1),i.indexAfter(a-1))))return t.delete(l.before(a),i.after(a))}for(let o=1;o<=l.depth&&o<=i.depth;o++)if(e-l.start(o)==l.depth-o&&n>l.end(o)&&i.end(o)-n!=i.depth-o&&l.start(o-1)==i.start(o-1)&&l.node(o-1).canReplace(l.index(o-1),i.index(o-1)))return t.delete(l.before(o),n);t.delete(e,n)}function Ly(t,e){let n=[],l=Math.min(t.depth,e.depth);for(let i=l;i>=0;i--){let r=t.start(i);if(re.pos+(e.depth-i)||t.node(i).type.spec.isolating||e.node(i).type.spec.isolating)break;(r==e.start(i)||i==t.depth&&i==e.depth&&t.parent.inlineContent&&e.parent.inlineContent&&i&&e.start(i-1)==r-1)&&n.push(i)}return n}var Ns=class t extends Ce{constructor(e,n,l){super(),this.pos=e,this.attr=n,this.value=l}apply(e){let n=e.nodeAt(this.pos);if(!n)return De.fail("No node at attribute step's position");let l=Object.create(null);for(let r in n.attrs)l[r]=n.attrs[r];l[this.attr]=this.value;let i=n.type.create(l,null,n.marks);return De.fromReplace(e,this.pos,this.pos+1,new k(D.from(i),0,n.isLeaf?0:1))}getMap(){return gn.empty}invert(e){return new t(this.pos,this.attr,e.nodeAt(this.pos).attrs[this.attr])}map(e){let n=e.mapResult(this.pos,1);return n.deletedAfter?null:new t(n.pos,this.attr,this.value)}toJSON(){return{stepType:"attr",pos:this.pos,attr:this.attr,value:this.value}}static fromJSON(e,n){if(typeof n.pos!="number"||typeof n.attr!="string")throw new RangeError("Invalid input for AttrStep.fromJSON");return new t(n.pos,n.attr,n.value)}};Ce.jsonID("attr",Ns);var Os=class t extends Ce{constructor(e,n){super(),this.attr=e,this.value=n}apply(e){let n=Object.create(null);for(let i in e.attrs)n[i]=e.attrs[i];n[this.attr]=this.value;let l=e.type.create(n,e.content,e.marks);return De.ok(l)}getMap(){return gn.empty}invert(e){return new t(this.attr,e.attrs[this.attr])}map(e){return this}toJSON(){return{stepType:"docAttr",attr:this.attr,value:this.value}}static fromJSON(e,n){if(typeof n.attr!="string")throw new RangeError("Invalid input for DocAttrStep.fromJSON");return new t(n.attr,n.value)}};Ce.jsonID("docAttr",Os);var Ui=class extends Error{};Ui=function t(e){let n=Error.call(this,e);return n.__proto__=t.prototype,n};Ui.prototype=Object.create(Error.prototype);Ui.prototype.constructor=Ui;Ui.prototype.name="TransformError";var As=class{constructor(e){this.doc=e,this.steps=[],this.docs=[],this.mapping=new dd}get before(){return this.docs.length?this.docs[0]:this.doc}step(e){let n=this.maybeStep(e);if(n.failed)throw new Ui(n.failed);return this}maybeStep(e){let n=e.apply(this.doc);return n.failed||this.addStep(e,n.doc),n}get docChanged(){return this.steps.length>0}changedRange(){let e=1e9,n=-1e9;for(let l=0;l{e=Math.min(e,a),n=Math.max(n,s)})}return e==1e9?null:{from:e,to:n}}addStep(e,n){this.docs.push(this.doc),this.steps.push(e),this.mapping.appendMap(e.getMap()),this.doc=n}replace(e,n=e,l=k.empty){let i=ks(this.doc,e,n,l);return i&&this.step(i),this}replaceWith(e,n,l){return this.replace(e,n,new k(D.from(l),0,0))}delete(e,n){return this.replace(e,n,k.empty)}insert(e,n){return this.replaceWith(e,e,n)}replaceRange(e,n,l){return Pb(this,e,n,l),this}replaceRangeWith(e,n,l){return $b(this,e,n,l),this}deleteRange(e,n){return eS(this,e,n),this}lift(e,n){return qb(this,e,n),this}join(e,n=1){return Qb(this,e,n),this}wrap(e,n){return Yb(this,e,n),this}setBlockType(e,n=e,l,i=null){return Zb(this,e,n,l,i),this}setNodeMarkup(e,n,l=null,i){return Gb(this,e,n,l,i),this}setNodeAttribute(e,n,l){return this.step(new Ns(e,n,l)),this}setDocAttribute(e,n){return this.step(new Os(e,n)),this}addNodeMark(e,n){return this.step(new mo(e,n)),this}removeNodeMark(e,n){let l=this.doc.nodeAt(e);if(!l)throw new RangeError("No node at position "+e);if(n instanceof Z)n.isInSet(l.marks)&&this.step(new Hi(e,n));else{let i=l.marks,r,o=[];for(;r=n.isInSet(i);)o.push(new Hi(e,r)),i=r.removeFromSet(i);for(let a=o.length-1;a>=0;a--)this.step(o[a])}return this}split(e,n=1,l){return Xb(this,e,n,l),this}addMark(e,n,l){return Hb(this,e,n,l),this}removeMark(e,n,l){return Ub(this,e,n,l),this}clearIncompatible(e,n,l){return gd(this,e,n,l),this}};var bd=Object.create(null),Y=class{constructor(e,n,l){this.$anchor=e,this.$head=n,this.ranges=l||[new zs(e.min(n),e.max(n))]}get anchor(){return this.$anchor.pos}get head(){return this.$head.pos}get from(){return this.$from.pos}get to(){return this.$to.pos}get $from(){return this.ranges[0].$from}get $to(){return this.ranges[0].$to}get empty(){let e=this.ranges;for(let n=0;n=0;r--){let o=n<0?Vi(e.node(0),e.node(r),e.before(r+1),e.index(r),n,l):Vi(e.node(0),e.node(r),e.after(r+1),e.index(r)+1,n,l);if(o)return o}return null}static near(e,n=1){return this.findFrom(e,n)||this.findFrom(e,-n)||new ct(e.node(0))}static atStart(e){return Vi(e,e,0,0,1)||new ct(e)}static atEnd(e){return Vi(e,e,e.content.size,e.childCount,-1)||new ct(e)}static fromJSON(e,n){if(!n||!n.type)throw new RangeError("Invalid input for Selection.fromJSON");let l=bd[n.type];if(!l)throw new RangeError(`No selection type ${n.type} defined`);return l.fromJSON(e,n)}static jsonID(e,n){if(e in bd)throw new RangeError("Duplicate use of selection JSON ID "+e);return bd[e]=n,n.prototype.jsonID=e,n}getBookmark(){return j.between(this.$anchor,this.$head).getBookmark()}};Y.prototype.visible=!0;var zs=class{constructor(e,n){this.$from=e,this.$to=n}},_y=!1;function By(t){!_y&&!t.parent.inlineContent&&(_y=!0,console.warn("TextSelection endpoint not pointing into a node with inline content ("+t.parent.type.name+")"))}var j=class t extends Y{constructor(e,n=e){By(e),By(n),super(e,n)}get $cursor(){return this.$anchor.pos==this.$head.pos?this.$head:null}map(e,n){let l=e.resolve(n.map(this.head));if(!l.parent.inlineContent)return Y.near(l);let i=e.resolve(n.map(this.anchor));return new t(i.parent.inlineContent?i:l,l)}replace(e,n=k.empty){if(super.replace(e,n),n==k.empty){let l=this.$from.marksAcross(this.$to);l&&e.ensureMarks(l)}}eq(e){return e instanceof t&&e.anchor==this.anchor&&e.head==this.head}getBookmark(){return new Rs(this.anchor,this.head)}toJSON(){return{type:"text",anchor:this.anchor,head:this.head}}static fromJSON(e,n){if(typeof n.anchor!="number"||typeof n.head!="number")throw new RangeError("Invalid input for TextSelection.fromJSON");return new t(e.resolve(n.anchor),e.resolve(n.head))}static create(e,n,l=n){let i=e.resolve(n);return new this(i,l==n?i:e.resolve(l))}static between(e,n,l){let i=e.pos-n.pos;if((!l||i)&&(l=i>=0?1:-1),!n.parent.inlineContent){let r=Y.findFrom(n,l,!0)||Y.findFrom(n,-l,!0);if(r)n=r.$head;else return Y.near(n,l)}return e.parent.inlineContent||(i==0?e=n:(e=(Y.findFrom(e,-l,!0)||Y.findFrom(e,l,!0)).$anchor,e.pos0?0:1);i>0?o=0;o+=i){let a=e.child(o);if(a.isAtom){if(!r&&B.isSelectable(a))return B.create(t,n-(i<0?a.nodeSize:0))}else{let s=Vi(t,a,n+i,i<0?a.childCount:0,i,r);if(s)return s}n+=a.nodeSize*i}return null}function Hy(t,e,n){let l=t.steps.length-1;if(l{o==null&&(o=c)}),t.setSelection(Y.near(t.doc.resolve(o),n))}var Uy=1,ws=2,Vy=4,vd=class extends As{constructor(e){super(e.doc),this.curSelectionFor=0,this.updated=0,this.meta=Object.create(null),this.time=Date.now(),this.curSelection=e.selection,this.storedMarks=e.storedMarks}get selection(){return this.curSelectionFor0}setStoredMarks(e){return this.storedMarks=e,this.updated|=ws,this}ensureMarks(e){return Z.sameSet(this.storedMarks||this.selection.$from.marks(),e)||this.setStoredMarks(e),this}addStoredMark(e){return this.ensureMarks(e.addToSet(this.storedMarks||this.selection.$head.marks()))}removeStoredMark(e){return this.ensureMarks(e.removeFromSet(this.storedMarks||this.selection.$head.marks()))}get storedMarksSet(){return(this.updated&ws)>0}addStep(e,n){super.addStep(e,n),this.updated=this.updated&~ws,this.storedMarks=null}setTime(e){return this.time=e,this}replaceSelection(e){return this.selection.replace(this,e),this}replaceSelectionWith(e,n=!0){let l=this.selection;return n&&(e=e.mark(this.storedMarks||(l.empty?l.$from.marks():l.$from.marksAcross(l.$to)||Z.none))),l.replaceWith(this,e),this}deleteSelection(){return this.selection.replace(this),this}insertText(e,n,l){let i=this.doc.type.schema;if(n==null)return e?this.replaceSelectionWith(i.text(e),!0):this.deleteSelection();{if(l==null&&(l=n),!e)return this.deleteRange(n,l);let r=this.storedMarks;if(!r){let o=this.doc.resolve(n);r=l==n?o.marks():o.marksAcross(this.doc.resolve(l))}return this.replaceRangeWith(n,l,i.text(e,r)),!this.selection.empty&&this.selection.to==n+e.length&&this.setSelection(Y.near(this.selection.$to)),this}}setMeta(e,n){return this.meta[typeof e=="string"?e:e.key]=n,this}getMeta(e){return this.meta[typeof e=="string"?e:e.key]}get isGeneric(){for(let e in this.meta)return!1;return!0}scrollIntoView(){return this.updated|=Vy,this}get scrolledIntoView(){return(this.updated&Vy)>0}};function qy(t,e){return!e||!t?t:t.bind(e)}var zl=class{constructor(e,n,l){this.name=e,this.init=qy(n.init,l),this.apply=qy(n.apply,l)}},nS=[new zl("doc",{init(t){return t.doc||t.schema.topNodeType.createAndFill()},apply(t){return t.doc}}),new zl("selection",{init(t,e){return t.selection||Y.atStart(e.doc)},apply(t){return t.selection}}),new zl("storedMarks",{init(t){return t.storedMarks||null},apply(t,e,n,l){return l.selection.$cursor?t.storedMarks:null}}),new zl("scrollToSelection",{init(){return 0},apply(t,e){return t.scrolledIntoView?e+1:e}})],yo=class{constructor(e,n){this.schema=e,this.plugins=[],this.pluginsByKey=Object.create(null),this.fields=nS.slice(),n&&n.forEach(l=>{if(this.pluginsByKey[l.key])throw new RangeError("Adding different instances of a keyed plugin ("+l.key+")");this.plugins.push(l),this.pluginsByKey[l.key]=l,l.spec.state&&this.fields.push(new zl(l.key,l.spec.state,l))})}},Ls=class t{constructor(e){this.config=e}get schema(){return this.config.schema}get plugins(){return this.config.plugins}apply(e){return this.applyTransaction(e).state}filterTransaction(e,n=-1){for(let l=0;ll.toJSON())),e&&typeof e=="object")for(let l in e){if(l=="doc"||l=="selection")throw new RangeError("The JSON fields `doc` and `selection` are reserved");let i=e[l],r=i.spec.state;r&&r.toJSON&&(n[l]=r.toJSON.call(i,this[i.key]))}return n}static fromJSON(e,n,l){if(!n)throw new RangeError("Invalid input for EditorState.fromJSON");if(!e.schema)throw new RangeError("Required config field 'schema' missing");let i=new yo(e.schema,e.plugins),r=new t(i);return i.fields.forEach(o=>{if(o.name=="doc")r.doc=Yt.fromJSON(e.schema,n.doc);else if(o.name=="selection")r.selection=Y.fromJSON(r.doc,n.selection);else if(o.name=="storedMarks")n.storedMarks&&(r.storedMarks=n.storedMarks.map(e.schema.markFromJSON));else{if(l)for(let a in l){let s=l[a],u=s.spec.state;if(s.key==o.name&&u&&u.fromJSON&&Object.prototype.hasOwnProperty.call(n,a)){r[o.name]=u.fromJSON.call(s,e,n[a],r);return}}r[o.name]=o.init(e,r)}}),r}};function Yy(t,e,n){for(let l in t){let i=t[l];i instanceof Function?i=i.bind(e):l=="handleDOMEvents"&&(i=Yy(i,e,{})),n[l]=i}return n}var _s=class{constructor(e){this.spec=e,this.props={},e.props&&Yy(e.props,this,this.props),this.key=e.key?e.key.key:Zy("plugin")}getState(e){return e[this.key]}},Sd=Object.create(null);function Zy(t){return t in Sd?t+"$"+ ++Sd[t]:(Sd[t]=0,t+"$")}var Bs=class{constructor(e="key"){this.key=Zy(e)}get(e){return e.config.pluginsByKey[this.key]}getState(e){return e[this.key]}};var Gy=(t,e)=>t.selection.empty?!1:(e&&e(t.tr.deleteSelection().scrollIntoView()),!0);function lS(t,e){let{$cursor:n}=t.selection;return!n||(e?!e.endOfTextblock("backward",t):n.parentOffset>0)?null:n}var iS=(t,e,n)=>{let l=lS(t,n);if(!l)return!1;let i=Xy(l);if(!i){let o=l.blockRange(),a=o&&Ds(o);return a==null?!1:(e&&e(t.tr.lift(o,a).scrollIntoView()),!0)}let r=i.nodeBefore;if(Iy(t,i,e,-1))return!0;if(l.parent.content.size==0&&(qi(r,"end")||B.isSelectable(r)))for(let o=l.depth;;o--){let a=ks(t.doc,l.before(o),l.after(o),k.empty);if(a&&a.slice.size1)break}return r.isAtom&&i.depth==l.depth-1?(e&&e(t.tr.delete(i.pos-r.nodeSize,i.pos).scrollIntoView()),!0):!1};function qi(t,e,n=!1){for(let l=t;l;l=e=="start"?l.firstChild:l.lastChild){if(l.isTextblock)return!0;if(n&&l.childCount!=1)return!1}return!1}var rS=(t,e,n)=>{let{$head:l,empty:i}=t.selection,r=l;if(!i)return!1;if(l.parent.isTextblock){if(n?!n.endOfTextblock("backward",t):l.parentOffset>0)return!1;r=Xy(l)}let o=r&&r.nodeBefore;return!o||!B.isSelectable(o)?!1:(e&&e(t.tr.setSelection(B.create(t.doc,r.pos-o.nodeSize)).scrollIntoView()),!0)};function Xy(t){if(!t.parent.type.spec.isolating)for(let e=t.depth-1;e>=0;e--){if(t.index(e)>0)return t.doc.resolve(t.before(e+1));if(t.node(e).type.spec.isolating)break}return null}function oS(t,e){let{$cursor:n}=t.selection;return!n||(e?!e.endOfTextblock("forward",t):n.parentOffset{let l=oS(t,n);if(!l)return!1;let i=Ky(l);if(!i)return!1;let r=i.nodeAfter;if(Iy(t,i,e,1))return!0;if(l.parent.content.size==0&&(qi(r,"start")||B.isSelectable(r))){let o=ks(t.doc,l.before(),l.after(),k.empty);if(o&&o.slice.size{let{$head:l,empty:i}=t.selection,r=l;if(!i)return!1;if(l.parent.isTextblock){if(n?!n.endOfTextblock("forward",t):l.parentOffset=0;e--){let n=t.node(e);if(t.index(e)+1{let{$head:n,$anchor:l}=t.selection;return!n.parent.type.spec.code||!n.sameParent(l)?!1:(e&&e(t.tr.insertText(` -`).scrollIntoView()),!0)};function Md(t){for(let e=0;e{let{$head:n,$anchor:l}=t.selection;if(!n.parent.type.spec.code||!n.sameParent(l))return!1;let i=n.node(-1),r=n.indexAfter(-1),o=Md(i.contentMatchAt(r));if(!o||!i.canReplaceWith(r,r,o))return!1;if(e){let a=n.after(),s=t.tr.replaceWith(a,a,o.createAndFill());s.setSelection(Y.near(s.doc.resolve(a),1)),e(s.scrollIntoView())}return!0},fS=(t,e)=>{let n=t.selection,{$from:l,$to:i}=n;if(n instanceof ct||l.parent.inlineContent||i.parent.inlineContent)return!1;let r=Md(i.parent.contentMatchAt(i.indexAfter()));if(!r||!r.isTextblock)return!1;if(e){let o=(!l.parentOffset&&i.index(){let{$cursor:n}=t.selection;if(!n||n.parent.content.size)return!1;if(n.depth>1&&n.after()!=n.end(-1)){let r=n.before();if(go(t.doc,r))return e&&e(t.tr.split(r).scrollIntoView()),!0}let l=n.blockRange(),i=l&&Ds(l);return i==null?!1:(e&&e(t.tr.lift(l,i).scrollIntoView()),!0)};function hS(t){return(e,n)=>{let{$from:l,$to:i}=e.selection;if(e.selection instanceof B&&e.selection.node.isBlock)return!l.parentOffset||!go(e.doc,l.pos)?!1:(n&&n(e.tr.split(l.pos).scrollIntoView()),!0);if(!l.depth)return!1;let r=[],o,a,s=!1,u=!1;for(let h=l.depth;;h--)if(l.node(h).isBlock){s=l.end(h)==l.pos+(l.depth-h),u=l.start(h)==l.pos-(l.depth-h),a=Md(l.node(h-1).contentMatchAt(l.indexAfter(h-1)));let b=t&&t(i.parent,s,l);r.unshift(b||(s&&a?{type:a}:null)),o=h;break}else{if(h==1)return!1;r.unshift(null)}let c=e.tr;(e.selection instanceof j||e.selection instanceof ct)&&c.deleteSelection();let f=c.mapping.map(l.pos),d=go(c.doc,f,r.length,r);if(d||(r[0]=a?{type:a}:null,d=go(c.doc,f,r.length,r)),!d)return!1;if(c.split(f,r.length,r),!s&&u&&l.node(o).type!=a){let h=c.mapping.map(l.before(o)),p=c.doc.resolve(h);a&&l.node(o-1).canReplaceWith(p.index(),p.index()+1,a)&&c.setNodeMarkup(c.mapping.map(l.before(o)),a)}return n&&n(c.scrollIntoView()),!0}}var Td=hS();var pS=(t,e)=>(e&&e(t.tr.setSelection(new ct(t.doc))),!0);function mS(t,e,n){let l=e.nodeBefore,i=e.nodeAfter,r=e.index();return!l||!i||!l.type.compatibleContent(i.type)?!1:!l.content.size&&e.parent.canReplace(r-1,r)?(n&&n(t.tr.delete(e.pos-l.nodeSize,e.pos).scrollIntoView()),!0):!e.parent.canReplace(r,r+1)||!(i.isTextblock||yd(t.doc,e.pos))?!1:(n&&n(t.tr.join(e.pos).scrollIntoView()),!0)}function Iy(t,e,n,l){let i=e.nodeBefore,r=e.nodeAfter,o,a,s=i.type.spec.isolating||r.type.spec.isolating;if(!s&&mS(t,e,n))return!0;let u=!s&&e.parent.canReplace(e.index(),e.index()+1);if(u&&(o=(a=i.contentMatchAt(i.childCount)).findWrapping(r.type))&&a.matchType(o[0]||r.type).validEnd){if(n){let h=e.pos+r.nodeSize,p=D.empty;for(let g=o.length-1;g>=0;g--)p=D.from(o[g].create(null,p));p=D.from(i.copy(p));let b=t.tr.step(new ut(e.pos-1,h,e.pos,h,new k(p,1,0),o.length,!0)),v=b.doc.resolve(h+2*o.length);v.nodeAfter&&v.nodeAfter.type==i.type&&yd(b.doc,v.pos)&&b.join(v.pos),n(b.scrollIntoView())}return!0}let c=r.type.spec.isolating||l>0&&s?null:Y.findFrom(e,1),f=c&&c.$from.blockRange(c.$to),d=f&&Ds(f);if(d!=null&&d>=e.depth)return n&&n(t.tr.lift(f,d).scrollIntoView()),!0;if(u&&qi(r,"start",!0)&&qi(i,"end")){let h=i,p=[];for(;p.push(h),!h.isTextblock;)h=h.lastChild;let b=r,v=1;for(;!b.isTextblock;b=b.firstChild)v++;if(h.canReplace(h.childCount,h.childCount,b.content)){if(n){let g=D.empty;for(let S=p.length-1;S>=0;S--)g=D.from(p[S].copy(g));let y=t.tr.step(new ut(e.pos-p.length,e.pos+r.nodeSize,e.pos+v,e.pos+r.nodeSize-v,new k(g,p.length,0),0,!0));n(y.scrollIntoView())}return!0}}return!1}function Qy(t){return function(e,n){let l=e.selection,i=t<0?l.$from:l.$to,r=i.depth;for(;i.node(r).isInline;){if(!r)return!1;r--}return i.node(r).isTextblock?(n&&n(e.tr.setSelection(j.create(e.doc,t<0?i.start(r):i.end(r)))),!0):!1}}var gS=Qy(-1),yS=Qy(1);function Ed(...t){return function(e,n,l){for(let i=0;i0){if(t.contentEditable=="false")return null;t=t.childNodes[e-1],e=Mt(t)}else if(t.parentNode&&!Eo(t))e=ke(t),t=t.parentNode;else return null}}function CS(t,e){for(;;){if(t.nodeType==3&&e2),Ct=Ki||(Zt?/Mac/.test(Zt.platform):!1),k2=Zt?/Win/.test(Zt.platform):!1,Sn=/Android \d/.test(rl),No=!!Fy&&"webkitFontSmoothing"in Fy.documentElement.style,NS=No?+(/\bAppleWebKit\/(\d+)/.exec(navigator.userAgent)||[0,0])[1]:0;function OS(t){let e=t.defaultView&&t.defaultView.visualViewport;return e?{left:0,right:e.width,top:0,bottom:e.height}:{left:0,right:t.documentElement.clientWidth,top:0,bottom:t.documentElement.clientHeight}}function yn(t,e){return typeof t=="number"?t:t[e]}function AS(t){let e=t.getBoundingClientRect(),n=e.width/t.offsetWidth||1,l=e.height/t.offsetHeight||1;return{left:e.left,right:e.left+t.clientWidth*n,top:e.top,bottom:e.top+t.clientHeight*l}}function Wy(t,e,n){let l=t.someProp("scrollThreshold")||0,i=t.someProp("scrollMargin")||5,r=t.dom.ownerDocument;for(let o=n||t.dom;o;){if(o.nodeType!=1){o=Xi(o);continue}let a=o,s=a==r.body,u=s?OS(r):AS(a),c=0,f=0;if(e.topu.bottom-yn(l,"bottom")&&(f=e.bottom-e.top>u.bottom-u.top?e.top+yn(i,"top")-u.top:e.bottom-u.bottom+yn(i,"bottom")),e.leftu.right-yn(l,"right")&&(c=e.right-u.right+yn(i,"right")),c||f)if(s)r.defaultView.scrollBy(c,f);else{let h=a.scrollLeft,p=a.scrollTop;f&&(a.scrollTop+=f),c&&(a.scrollLeft+=c);let b=a.scrollLeft-h,v=a.scrollTop-p;e={left:e.left-b,top:e.top-v,right:e.right-b,bottom:e.bottom-v}}let d=s?"fixed":getComputedStyle(o).position;if(/^(fixed|sticky)$/.test(d))break;o=d=="absolute"?o.offsetParent:Xi(o)}}function DS(t){let e=t.dom.getBoundingClientRect(),n=Math.max(0,e.top),l,i;for(let r=(e.left+e.right)/2,o=n+1;o=n-20){l=a,i=s.top;break}}return{refDOM:l,refTop:i,stack:w2(t.dom)}}function w2(t){let e=[],n=t.ownerDocument;for(let l=t;l&&(e.push({dom:l,top:l.scrollTop,left:l.scrollLeft}),t!=n);l=Xi(l));return e}function kS({refDOM:t,refTop:e,stack:n}){let l=t?t.getBoundingClientRect().top:0;z2(n,l==0?0:l-e)}function z2(t,e){for(let n=0;n=a){o=Math.max(p.bottom,o),a=Math.min(p.top,a);let b=p.left>e.left?p.left-e.left:p.right=(p.left+p.right)/2?1:0));continue}}else p.top>e.top&&!s&&p.left<=e.left&&p.right>=e.left&&(s=c,u={left:Math.max(p.left,Math.min(p.right,e.left)),top:p.top});!n&&(e.left>=p.right&&e.top>=p.top||e.left>=p.left&&e.top>=p.bottom)&&(r=f+1)}}return!n&&s&&(n=s,i=u,l=0),n&&n.nodeType==3?zS(n,i):!n||l&&n.nodeType==1?{node:t,offset:r}:R2(n,i)}function zS(t,e){let n=t.nodeValue.length,l=document.createRange(),i;for(let r=0;r=(o.left+o.right)/2?1:0)};break}}return l.detach(),i||{node:t,offset:0}}function Jd(t,e){return t.left>=e.left-1&&t.left<=e.right+1&&t.top>=e.top-1&&t.top<=e.bottom+1}function RS(t,e){let n=t.parentNode;return n&&/^li$/i.test(n.nodeName)&&e.left(o.left+o.right)/2?1:-1}return t.docView.posFromDOM(l,i,r)}function _S(t,e,n,l){let i=-1;for(let r=e,o=!1;r!=t.dom;){let a=t.docView.nearestDesc(r,!0),s;if(!a)return null;if(a.dom.nodeType==1&&(a.node.isBlock&&a.parent||!a.contentDOM)&&((s=a.dom.getBoundingClientRect()).width||s.height)&&(a.node.isBlock&&a.parent&&!/^T(R|BODY|HEAD|FOOT)$/.test(a.dom.nodeName)&&(!o&&s.left>l.left||s.top>l.top?i=a.posBefore:(!o&&s.right-1?i:t.docView.posFromDOM(e,n,-1)}function L2(t,e,n){let l=t.childNodes.length;if(l&&n.tope.top&&i++}let u;No&&i&&l.nodeType==1&&(u=l.childNodes[i-1]).nodeType==1&&u.contentEditable=="false"&&u.getBoundingClientRect().top>=e.top&&i--,l==t.dom&&i==l.childNodes.length-1&&l.lastChild.nodeType==1&&e.top>l.lastChild.getBoundingClientRect().bottom?a=t.state.doc.content.size:(i==0||l.nodeType!=1||l.childNodes[i-1].nodeName!="BR")&&(a=_S(t,l,i,e))}a==null&&(a=LS(t,o,e));let s=t.docView.nearestDesc(o,!0);return{pos:a,inside:s?s.posAtStart-s.border:-1}}function Py(t){return t.top=0&&i==l.nodeValue.length?(s--,c=1):n<0?s--:u++,bo(el(bn(l,s,u),c),c<0)}if(!t.state.doc.resolve(e-(r||0)).parent.inlineContent){if(r==null&&i&&(n<0||i==Mt(l))){let s=l.childNodes[i-1];if(s.nodeType==1)return Nd(s.getBoundingClientRect(),!1)}if(r==null&&i=0)}if(r==null&&i&&(n<0||i==Mt(l))){let s=l.childNodes[i-1],u=s.nodeType==3?bn(s,Mt(s)-(o?0:1)):s.nodeType==1&&(s.nodeName!="BR"||!s.nextSibling)?s:null;if(u)return bo(el(u,1),!1)}if(r==null&&i=0)}function bo(t,e){if(t.width==0)return t;let n=e?t.left:t.right;return{top:t.top,bottom:t.bottom,left:n,right:n}}function Nd(t,e){if(t.height==0)return t;let n=e?t.top:t.bottom;return{top:n,bottom:n,left:t.left,right:t.right}}function B2(t,e,n){let l=t.state,i=t.root.activeElement;l!=e&&t.updateState(e),i!=t.dom&&t.focus();try{return n()}finally{l!=e&&t.updateState(l),i!=t.dom&&i&&i.focus()}}function US(t,e,n){let l=e.selection,i=n=="up"?l.$from:l.$to;return B2(t,e,()=>{let{node:r}=t.docView.domFromPos(i.pos,n=="up"?-1:1);for(;;){let a=t.docView.nearestDesc(r,!0);if(!a)break;if(a.node.isBlock){r=a.contentDOM||a.dom;break}r=a.dom.parentNode}let o=_2(t,i.pos,1);for(let a=r.firstChild;a;a=a.nextSibling){let s;if(a.nodeType==1)s=a.getClientRects();else if(a.nodeType==3)s=bn(a,0,a.nodeValue.length).getClientRects();else continue;for(let u=0;uc.top+1&&(n=="up"?o.top-c.top>(c.bottom-o.top)*2:c.bottom-o.bottom>(o.bottom-c.top)*2))return!1}}return!0})}var VS=/[\u0590-\u08ac]/;function qS(t,e,n){let{$head:l}=e.selection;if(!l.parent.isTextblock)return!1;let i=l.parentOffset,r=!i,o=i==l.parent.content.size,a=t.domSelection();return a?!VS.test(l.parent.textContent)||!a.modify?n=="left"||n=="backward"?r:o:B2(t,e,()=>{let{focusNode:s,focusOffset:u,anchorNode:c,anchorOffset:f}=t.domSelectionRange(),d=a.caretBidiLevel;a.modify("move",n,"character");let h=l.depth?t.docView.domAfterPos(l.before()):t.dom,{focusNode:p,focusOffset:b}=t.domSelectionRange(),v=p&&!h.contains(p.nodeType==1?p:p.parentNode)||s==p&&u==b;try{a.collapse(c,f),s&&(s!=c||u!=f)&&a.extend&&a.extend(s,u)}catch{}return d!=null&&(a.caretBidiLevel=d),v}):l.pos==l.start()||l.pos==l.end()}var $y=null,e2=null,t2=!1;function YS(t,e,n){return $y==e&&e2==n?t2:($y=e,e2=n,t2=n=="up"||n=="down"?US(t,e,n):qS(t,e,n))}var Et=0,n2=1,Ll=2,jt=3,ql=class{constructor(e,n,l,i){this.parent=e,this.children=n,this.dom=l,this.contentDOM=i,this.dirty=Et,l.pmViewDesc=this}matchesWidget(e){return!1}matchesMark(e){return!1}matchesNode(e,n,l){return!1}matchesHack(e){return!1}parseRule(){return null}stopEvent(e){return!1}get size(){let e=0;for(let n=0;nke(this.contentDOM);else if(this.contentDOM&&this.contentDOM!=this.dom&&this.dom.contains(this.contentDOM))i=e.compareDocumentPosition(this.contentDOM)&2;else if(this.dom.firstChild){if(n==0)for(let r=e;;r=r.parentNode){if(r==this.dom){i=!1;break}if(r.previousSibling)break}if(i==null&&n==e.childNodes.length)for(let r=e;;r=r.parentNode){if(r==this.dom){i=!0;break}if(r.nextSibling)break}}return i??l>0?this.posAtEnd:this.posAtStart}nearestDesc(e,n=!1){for(let l=!0,i=e;i;i=i.parentNode){let r=this.getDesc(i),o;if(r&&(!n||r.node))if(l&&(o=r.nodeDOM)&&!(o.nodeType==1?o.contains(e.nodeType==1?e:e.parentNode):o==e))l=!1;else return r}}getDesc(e){let n=e.pmViewDesc;for(let l=n;l;l=l.parent)if(l==this)return n}posFromDOM(e,n,l){for(let i=e;i;i=i.parentNode){let r=this.getDesc(i);if(r)return r.localPosFromDOM(e,n,l)}return-1}descAt(e){for(let n=0,l=0;ne||o instanceof Vs){i=e-r;break}r=a}if(i)return this.children[l].domFromPos(i-this.children[l].border,n);for(let r;l&&!(r=this.children[l-1]).size&&r instanceof Hs&&r.side>=0;l--);if(n<=0){let r,o=!0;for(;r=l?this.children[l-1]:null,!(!r||r.dom.parentNode==this.contentDOM);l--,o=!1);return r&&n&&o&&!r.border&&!r.domAtom?r.domFromPos(r.size,n):{node:this.contentDOM,offset:r?ke(r.dom)+1:0}}else{let r,o=!0;for(;r=l=c&&n<=u-s.border&&s.node&&s.contentDOM&&this.contentDOM.contains(s.contentDOM))return s.parseRange(e,n,c);e=o;for(let f=a;f>0;f--){let d=this.children[f-1];if(d.size&&d.dom.parentNode==this.contentDOM&&!d.emptyChildAt(1)){i=ke(d.dom)+1;break}e-=d.size}i==-1&&(i=0)}if(i>-1&&(u>n||a==this.children.length-1)){n=u;for(let c=a+1;cp&&on){let p=a;a=s,s=p}let h=document.createRange();h.setEnd(s.node,s.offset),h.setStart(a.node,a.offset),u.removeAllRanges(),u.addRange(h)}}ignoreMutation(e){return!this.contentDOM&&e.type!="selection"}get contentLost(){return this.contentDOM&&this.contentDOM!=this.dom&&!this.dom.contains(this.contentDOM)}markDirty(e,n){for(let l=0,i=0;i=l:el){let a=l+r.border,s=o-r.border;if(e>=a&&n<=s){this.dirty=e==l||n==o?Ll:n2,e==a&&n==s&&(r.contentLost||r.dom.parentNode!=this.contentDOM)?r.dirty=jt:r.markDirty(e-a,n-a);return}else r.dirty=r.dom==r.contentDOM&&r.dom.parentNode==this.contentDOM&&!r.children.length?Ll:jt}l=o}this.dirty=Ll}markParentsDirty(){let e=1;for(let n=this.parent;n;n=n.parent,e++){let l=e==1?Ll:n2;n.dirty{if(!r)return i;if(r.parent)return r.parent.posBeforeChild(r)})),!n.type.spec.raw){if(o.nodeType!=1){let a=document.createElement("span");a.appendChild(o),o=a}o.contentEditable="false",o.classList.add("ProseMirror-widget")}super(e,[],o,null),this.widget=n,this.widget=n,r=this}matchesWidget(e){return this.dirty==Et&&e.type.eq(this.widget.type)}parseRule(){return{ignore:!0}}stopEvent(e){let n=this.widget.spec.stopEvent;return n?n(e):!1}ignoreMutation(e){return e.type!="selection"||this.widget.spec.ignoreSelection}destroy(){this.widget.type.destroy(this.dom),super.destroy()}get domAtom(){return!0}get ignoreForSelection(){return!!this.widget.type.spec.relaxedSide}get side(){return this.widget.type.side}},_d=class extends ql{constructor(e,n,l,i){super(e,[],n,null),this.textDOM=l,this.text=i}get size(){return this.text.length}localPosFromDOM(e,n){return e!=this.textDOM?this.posAtStart+(n?this.size:0):this.posAtStart+n}domFromPos(e){return{node:this.textDOM,offset:e}}ignoreMutation(e){return e.type==="characterData"&&e.target.nodeValue==e.oldValue}},Ii=class t extends ql{constructor(e,n,l,i,r){super(e,[],l,i),this.mark=n,this.spec=r}static create(e,n,l,i){let r=i.nodeViews[n.type.name],o=r&&r(n,i,l);return(!o||!o.dom)&&(o=kl.renderSpec(document,n.type.spec.toDOM(n,l),null,n.attrs)),new t(e,n,o.dom,o.contentDOM||o.dom,o)}parseRule(){return this.dirty&jt||this.mark.type.spec.reparseInView?null:{mark:this.mark.type.name,attrs:this.mark.attrs,contentElement:this.contentDOM}}matchesMark(e){return this.dirty!=jt&&this.mark.eq(e)}markDirty(e,n){if(super.markDirty(e,n),this.dirty!=Et){let l=this.parent;for(;!l.node;)l=l.parent;l.dirty0&&(r=Vd(r,0,e,l));for(let a=0;a{if(!s)return o;if(s.parent)return s.parent.posBeforeChild(s)},l,i),c=u&&u.dom,f=u&&u.contentDOM;if(n.isText){if(!c)c=document.createTextNode(n.text);else if(c.nodeType!=3)throw new RangeError("Text must be rendered as a DOM text node")}else c||({dom:c,contentDOM:f}=kl.renderSpec(document,n.type.spec.toDOM(n),null,n.attrs));!f&&!n.isText&&c.nodeName!="BR"&&(c.hasAttribute("contenteditable")||(c.contentEditable="false"),n.type.spec.draggable&&(c.draggable=!0));let d=c;return c=V2(c,l,n),u?s=new Bd(e,n,l,i,c,f||null,d,u,r,o+1):n.isText?new Us(e,n,l,i,c,d,r):new t(e,n,l,i,c,f||null,d,r,o+1)}parseRule(){if(this.node.type.spec.reparseInView)return null;let e={node:this.node.type.name,attrs:this.node.attrs};if(this.node.type.whitespace=="pre"&&(e.preserveWhitespace="full"),!this.contentDOM)e.getContent=()=>this.node.content;else if(!this.contentLost)e.contentElement=this.contentDOM;else{for(let n=this.children.length-1;n>=0;n--){let l=this.children[n];if(this.dom.contains(l.dom.parentNode)){e.contentElement=l.dom.parentNode;break}}e.contentElement||(e.getContent=()=>D.empty)}return e}matchesNode(e,n,l){return this.dirty==Et&&e.eq(this.node)&&qs(n,this.outerDeco)&&l.eq(this.innerDeco)}get size(){return this.node.nodeSize}get border(){return this.node.isLeaf?0:1}updateChildren(e,n){let l=this.node.inlineContent,i=n,r=e.composing?this.localCompositionInfo(e,n):null,o=r&&r.pos>-1?r:null,a=r&&r.pos<0,s=new Ud(this,o&&o.node,e);XS(this.node,this.innerDeco,(u,c,f)=>{u.spec.marks?s.syncToMarks(u.spec.marks,l,e,c):u.type.side>=0&&!f&&s.syncToMarks(c==this.node.childCount?Z.none:this.node.child(c).marks,l,e,c),s.placeWidget(u,e,i)},(u,c,f,d)=>{s.syncToMarks(u.marks,l,e,d);let h;s.findNodeMatch(u,c,f,d)||a&&e.state.selection.from>i&&e.state.selection.to-1&&s.updateNodeAt(u,c,f,h,e)||s.updateNextNode(u,c,f,e,d,i)||s.addNode(u,c,f,e,i),i+=u.nodeSize}),s.syncToMarks([],l,e,0),this.node.isTextblock&&s.addTextblockHacks(),s.destroyRest(),(s.changed||this.dirty==Ll)&&(o&&this.protectLocalComposition(e,o),H2(this.contentDOM,this.children,e),Ki&&KS(this.dom))}localCompositionInfo(e,n){let{from:l,to:i}=e.state.selection;if(!(e.state.selection instanceof j)||ln+this.node.content.size)return null;let r=e.input.compositionNode;if(!r||!this.dom.contains(r.parentNode))return null;if(this.node.inlineContent){let o=r.nodeValue,a=IS(this.node.content,o,l-n,i-n);return a<0?null:{node:r,pos:a,text:o}}else return{node:r,pos:-1,text:""}}protectLocalComposition(e,{node:n,pos:l,text:i}){if(this.getDesc(n))return;let r=n;for(;r.parentNode!=this.contentDOM;r=r.parentNode){for(;r.previousSibling;)r.parentNode.removeChild(r.previousSibling);for(;r.nextSibling;)r.parentNode.removeChild(r.nextSibling);r.pmViewDesc&&(r.pmViewDesc=void 0)}let o=new _d(this,r,n,i);e.input.compositionNodes.push(o),this.children=Vd(this.children,l,l+i.length,e,o)}update(e,n,l,i){return this.dirty==jt||!e.sameMarkup(this.node)?!1:(this.updateInner(e,n,l,i),!0)}updateInner(e,n,l,i){this.updateOuterDeco(n),this.node=e,this.innerDeco=l,this.contentDOM&&this.updateChildren(i,this.posAtStart),this.dirty=Et}updateOuterDeco(e){if(qs(e,this.outerDeco))return;let n=this.nodeDOM.nodeType!=1,l=this.dom;this.dom=U2(this.dom,this.nodeDOM,Hd(this.outerDeco,this.node,n),Hd(e,this.node,n)),this.dom!=l&&(l.pmViewDesc=void 0,this.dom.pmViewDesc=this),this.outerDeco=e}selectNode(){this.nodeDOM.nodeType==1&&(this.nodeDOM.classList.add("ProseMirror-selectednode"),(this.contentDOM||!this.node.type.spec.draggable)&&(this.nodeDOM.draggable=!0))}deselectNode(){this.nodeDOM.nodeType==1&&(this.nodeDOM.classList.remove("ProseMirror-selectednode"),(this.contentDOM||!this.node.type.spec.draggable)&&this.nodeDOM.removeAttribute("draggable"))}get domAtom(){return this.node.isAtom}};function l2(t,e,n,l,i){V2(l,e,t);let r=new il(void 0,t,e,n,l,l,l,i,0);return r.contentDOM&&r.updateChildren(i,0),r}var Us=class t extends il{constructor(e,n,l,i,r,o,a){super(e,n,l,i,r,null,o,a,0)}parseRule(){let e=this.nodeDOM.parentNode;for(;e&&e!=this.dom&&!e.pmIsDeco;)e=e.parentNode;return{skip:e||!0}}update(e,n,l,i){return this.dirty==jt||this.dirty!=Et&&!this.inParent()||!e.sameMarkup(this.node)?!1:(this.updateOuterDeco(n),(this.dirty!=Et||e.text!=this.node.text)&&e.text!=this.nodeDOM.nodeValue&&(this.nodeDOM.nodeValue=e.text,i.trackWrites==this.nodeDOM&&(i.trackWrites=null)),this.node=e,this.dirty=Et,!0)}inParent(){let e=this.parent.contentDOM;for(let n=this.nodeDOM;n;n=n.parentNode)if(n==e)return!0;return!1}domFromPos(e){return{node:this.nodeDOM,offset:e}}localPosFromDOM(e,n,l){return e==this.nodeDOM?this.posAtStart+Math.min(n,this.node.text.length):super.localPosFromDOM(e,n,l)}ignoreMutation(e){return e.type!="characterData"&&e.type!="selection"}slice(e,n,l){let i=this.node.cut(e,n),r=document.createTextNode(i.text);return new t(this.parent,i,this.outerDeco,this.innerDeco,r,r,l)}markDirty(e,n){super.markDirty(e,n),this.dom!=this.nodeDOM&&(e==0||n==this.nodeDOM.nodeValue.length)&&(this.dirty=jt)}get domAtom(){return!1}isText(e){return this.node.text==e}},Vs=class extends ql{parseRule(){return{ignore:!0}}matchesHack(e){return this.dirty==Et&&this.dom.nodeName==e}get domAtom(){return!0}get ignoreForCoords(){return this.dom.nodeName=="IMG"}},Bd=class extends il{constructor(e,n,l,i,r,o,a,s,u,c){super(e,n,l,i,r,o,a,u,c),this.spec=s}update(e,n,l,i){if(this.dirty==jt)return!1;if(this.spec.update&&(this.node.type==e.type||this.spec.multiType)){let r=this.spec.update(e,n,l);return r&&this.updateInner(e,n,l,i),r}else return!this.contentDOM&&!e.isLeaf?!1:super.update(e,n,l,i)}selectNode(){this.spec.selectNode?this.spec.selectNode():super.selectNode()}deselectNode(){this.spec.deselectNode?this.spec.deselectNode():super.deselectNode()}setSelection(e,n,l,i){this.spec.setSelection?this.spec.setSelection(e,n,l.root):super.setSelection(e,n,l,i)}destroy(){this.spec.destroy&&this.spec.destroy(),super.destroy()}stopEvent(e){return this.spec.stopEvent?this.spec.stopEvent(e):!1}ignoreMutation(e){return this.spec.ignoreMutation?this.spec.ignoreMutation(e):super.ignoreMutation(e)}};function H2(t,e,n){let l=t.firstChild,i=!1;for(let r=0;r>1,a=Math.min(o,e.length);for(;r-1)s>this.index&&(this.changed=!0,this.destroyBetween(this.index,s)),this.top=this.top.children[this.index];else{let c=Ii.create(this.top,e[o],n,l);this.top.children.splice(this.index,0,c),this.top=c,this.changed=!0}this.index=0,o++}}findNodeMatch(e,n,l,i){let r=-1,o;if(i>=this.preMatch.index&&(o=this.preMatch.matches[i-this.preMatch.index]).parent==this.top&&o.matchesNode(e,n,l))r=this.top.children.indexOf(o,this.index);else for(let a=this.index,s=Math.min(this.top.children.length,a+5);a0;){let a;for(;;)if(l){let u=n.children[l-1];if(u instanceof Ii)n=u,l=u.children.length;else{a=u,l--;break}}else{if(n==e)break e;l=n.parent.children.indexOf(n),n=n.parent}let s=a.node;if(s){if(s!=t.child(i-1))break;--i,r.set(a,i),o.push(a)}}return{index:i,matched:r,matches:o.reverse()}}function GS(t,e){return t.type.side-e.type.side}function XS(t,e,n,l){let i=e.locals(t),r=0;if(i.length==0){for(let u=0;ur;)a.push(i[o++]);let p=r+d.nodeSize;if(d.isText){let v=p;o!v.inline):a.slice();l(d,b,e.forChild(r,d),h),r=p}}function KS(t){if(t.nodeName=="UL"||t.nodeName=="OL"){let e=t.style.cssText;t.style.cssText=e+"; list-style: square !important",window.getComputedStyle(t).listStyle,t.style.cssText=e}}function IS(t,e,n,l){for(let i=0,r=0;i=n){if(r>=l&&s.slice(l-e.length-a,l-a)==e)return l-e.length;let u=a=0&&u+e.length+a>=n)return a+u;if(n==l&&s.length>=l+e.length-a&&s.slice(l-a,l-a+e.length)==e)return l}}return-1}function Vd(t,e,n,l,i){let r=[];for(let o=0,a=0;o=n||c<=e?r.push(s):(un&&r.push(s.slice(n-u,s.size,l)))}return r}function Fd(t,e=null){let n=t.domSelectionRange(),l=t.state.doc;if(!n.focusNode)return null;let i=t.docView.nearestDesc(n.focusNode),r=i&&i.size==0,o=t.docView.posFromDOM(n.focusNode,n.focusOffset,1);if(o<0)return null;let a=l.resolve(o),s,u;if(Is(n)){for(s=o;i&&!i.node;)i=i.parent;let f=i.node;if(i&&f.isAtom&&B.isSelectable(f)&&i.parent&&!(f.isInline&&MS(n.focusNode,n.focusOffset,i.dom))){let d=i.posBefore;u=new B(o==d?a:l.resolve(d))}}else{if(n instanceof t.dom.ownerDocument.defaultView.Selection&&n.rangeCount>1){let f=o,d=o;for(let h=0;h{(n.anchorNode!=l||n.anchorOffset!=i)&&(e.removeEventListener("selectionchange",t.input.hideSelectionGuard),setTimeout(()=>{(!q2(t)||t.state.selection.visible)&&t.dom.classList.remove("ProseMirror-hideselection")},20))})}function JS(t){let e=t.domSelection();if(!e)return;let n=t.cursorWrapper.dom,l=n.nodeName=="IMG";l?e.collapse(n.parentNode,ke(n)+1):e.collapse(n,0),!l&&!t.state.selection.visible&&We&&ll<=11&&(n.disabled=!0,n.disabled=!1)}function Y2(t,e){if(e instanceof B){let n=t.docView.descAt(e.from);n!=t.lastSelectedViewDesc&&(s2(t),n&&n.selectNode(),t.lastSelectedViewDesc=n)}else s2(t)}function s2(t){t.lastSelectedViewDesc&&(t.lastSelectedViewDesc.parent&&t.lastSelectedViewDesc.deselectNode(),t.lastSelectedViewDesc=void 0)}function Wd(t,e,n,l){return t.someProp("createSelectionBetween",i=>i(t,e,n))||j.between(e,n,l)}function u2(t){return t.editable&&!t.hasFocus()?!1:Z2(t)}function Z2(t){let e=t.domSelectionRange();if(!e.anchorNode)return!1;try{return t.dom.contains(e.anchorNode.nodeType==3?e.anchorNode.parentNode:e.anchorNode)&&(t.editable||t.dom.contains(e.focusNode.nodeType==3?e.focusNode.parentNode:e.focusNode))}catch{return!1}}function FS(t){let e=t.docView.domFromPos(t.state.selection.anchor,0),n=t.domSelectionRange();return Vl(e.node,e.offset,n.anchorNode,n.anchorOffset)}function qd(t,e){let{$anchor:n,$head:l}=t.selection,i=e>0?n.max(l):n.min(l),r=i.parent.inlineContent?i.depth?t.doc.resolve(e>0?i.after():i.before()):null:i;return r&&Y.findFrom(r,e)}function tl(t,e){return t.dispatch(t.state.tr.setSelection(e).scrollIntoView()),!0}function c2(t,e,n){let l=t.state.selection;if(l instanceof j)if(n.indexOf("s")>-1){let{$head:i}=l,r=i.textOffset?null:e<0?i.nodeBefore:i.nodeAfter;if(!r||r.isText||!r.isLeaf)return!1;let o=t.state.doc.resolve(i.pos+r.nodeSize*(e<0?-1:1));return tl(t,new j(l.$anchor,o))}else if(l.empty){if(t.endOfTextblock(e>0?"forward":"backward")){let i=qd(t.state,e);return i&&i instanceof B?tl(t,i):!1}else if(!(Ct&&n.indexOf("m")>-1)){let i=l.$head,r=i.textOffset?null:e<0?i.nodeBefore:i.nodeAfter,o;if(!r||r.isText)return!1;let a=e<0?i.pos-r.nodeSize:i.pos;return r.isAtom||(o=t.docView.descAt(a))&&!o.contentDOM?B.isSelectable(r)?tl(t,new B(e<0?t.state.doc.resolve(i.pos-r.nodeSize):i)):No?tl(t,new j(t.state.doc.resolve(e<0?a:a+r.nodeSize))):!1:!1}}else return!1;else{if(l instanceof B&&l.node.isInline)return tl(t,new j(e>0?l.$to:l.$from));{let i=qd(t.state,e);return i?tl(t,i):!1}}}function Ys(t){return t.nodeType==3?t.nodeValue.length:t.childNodes.length}function xo(t,e){let n=t.pmViewDesc;return n&&n.size==0&&(e<0||t.nextSibling||t.nodeName!="BR")}function Zi(t,e){return e<0?WS(t):PS(t)}function WS(t){let e=t.domSelectionRange(),n=e.focusNode,l=e.focusOffset;if(!n)return;let i,r,o=!1;for(Tt&&n.nodeType==1&&l0){if(n.nodeType!=1)break;{let a=n.childNodes[l-1];if(xo(a,-1))i=n,r=--l;else if(a.nodeType==3)n=a,l=n.nodeValue.length;else break}}else{if(j2(n))break;{let a=n.previousSibling;for(;a&&xo(a,-1);)i=n.parentNode,r=ke(a),a=a.previousSibling;if(a)n=a,l=Ys(n);else{if(n=n.parentNode,n==t.dom)break;l=0}}}o?Yd(t,n,l):i&&Yd(t,i,r)}function PS(t){let e=t.domSelectionRange(),n=e.focusNode,l=e.focusOffset;if(!n)return;let i=Ys(n),r,o;for(;;)if(l{t.state==i&&xn(t)},50)}function f2(t,e){let n=t.state.doc.resolve(e);if(!(we||k2)&&n.parent.inlineContent){let i=t.coordsAtPos(e);if(e>n.start()){let r=t.coordsAtPos(e-1),o=(r.top+r.bottom)/2;if(o>i.top&&o1)return r.lefti.top&&o1)return r.left>i.left?"ltr":"rtl"}}return getComputedStyle(t.dom).direction=="rtl"?"rtl":"ltr"}function d2(t,e,n){let l=t.state.selection;if(l instanceof j&&!l.empty||n.indexOf("s")>-1||Ct&&n.indexOf("m")>-1)return!1;let{$from:i,$to:r}=l;if(!i.parent.inlineContent||t.endOfTextblock(e<0?"up":"down")){let o=qd(t.state,e);if(o&&o instanceof B)return tl(t,o)}if(!i.parent.inlineContent){let o=e<0?i:r,a=l instanceof ct?Y.near(o,e):Y.findFrom(o,e);return a?tl(t,a):!1}return!1}function h2(t,e){if(!(t.state.selection instanceof j))return!0;let{$head:n,$anchor:l,empty:i}=t.state.selection;if(!n.sameParent(l))return!0;if(!i)return!1;if(t.endOfTextblock(e>0?"forward":"backward"))return!0;let r=!n.textOffset&&(e<0?n.nodeBefore:n.nodeAfter);if(r&&!r.isText){let o=t.state.tr;return e<0?o.delete(n.pos-r.nodeSize,n.pos):o.delete(n.pos,n.pos+r.nodeSize),t.dispatch(o),!0}return!1}function p2(t,e,n){t.domObserver.stop(),e.contentEditable=n,t.domObserver.start()}function t8(t){if(!_e||t.state.selection.$head.parentOffset>0)return!1;let{focusNode:e,focusOffset:n}=t.domSelectionRange();if(e&&e.nodeType==1&&n==0&&e.firstChild&&e.firstChild.contentEditable=="false"){let l=e.firstChild;p2(t,l,"true"),setTimeout(()=>p2(t,l,"false"),20)}return!1}function n8(t){let e="";return t.ctrlKey&&(e+="c"),t.metaKey&&(e+="m"),t.altKey&&(e+="a"),t.shiftKey&&(e+="s"),e}function l8(t,e){let n=e.keyCode,l=n8(e);if(n==8||Ct&&n==72&&l=="c")return h2(t,-1)||Zi(t,-1);if(n==46&&!e.shiftKey||Ct&&n==68&&l=="c")return h2(t,1)||Zi(t,1);if(n==13||n==27)return!0;if(n==37||Ct&&n==66&&l=="c"){let i=n==37?f2(t,t.state.selection.from)=="ltr"?-1:1:-1;return c2(t,i,l)||Zi(t,i)}else if(n==39||Ct&&n==70&&l=="c"){let i=n==39?f2(t,t.state.selection.from)=="ltr"?1:-1:1;return c2(t,i,l)||Zi(t,i)}else{if(n==38||Ct&&n==80&&l=="c")return d2(t,-1,l)||Zi(t,-1);if(n==40||Ct&&n==78&&l=="c")return t8(t)||d2(t,1,l)||Zi(t,1);if(l==(Ct?"m":"c")&&(n==66||n==73||n==89||n==90))return!0}return!1}function Pd(t,e){t.someProp("transformCopied",h=>{e=h(e,t)});let n=[],{content:l,openStart:i,openEnd:r}=e;for(;i>1&&r>1&&l.childCount==1&&l.firstChild.childCount==1;){i--,r--;let h=l.firstChild;n.push(h.type.name,h.attrs!=h.type.defaultAttrs?h.attrs:null),l=h.content}let o=t.someProp("clipboardSerializer")||kl.fromSchema(t.state.schema),a=J2(),s=a.createElement("div");s.appendChild(o.serializeFragment(l,{document:a}));let u=s.firstChild,c,f=0;for(;u&&u.nodeType==1&&(c=Q2[u.nodeName.toLowerCase()]);){for(let h=c.length-1;h>=0;h--){let p=a.createElement(c[h]);for(;s.firstChild;)p.appendChild(s.firstChild);s.appendChild(p),f++}u=s.firstChild}u&&u.nodeType==1&&u.setAttribute("data-pm-slice",`${i} ${r}${f?` -${f}`:""} ${JSON.stringify(n)}`);let d=t.someProp("clipboardTextSerializer",h=>h(e,t))||e.content.textBetween(0,e.content.size,` +${kg}`:kg}function ib(t){return t.includes("skill://plan")||/(?:^|\s)\$plan(?:\s|$)/.test(t)}function zg(t){return t?.id==="codex"?"GPT-5.5":t?.displayName??"GPT-5.5"}function Rg(t){let e=t.displayName,n=e.toLowerCase();return t.id==="claude"||n.includes("claude")?"Cl":t.id==="opencode"||n.includes("open")?"O":n==="pi"||n.includes(" pi")?"Pi":e.trim().slice(0,1).toUpperCase()||"C"}function _g(t){let e=t.usedPercent,n=t.remainingPercent,l=e!=null&&Number.isFinite(e)?ro(e):ro(100-(n??0)),i=n!=null&&Number.isFinite(n)?ro(n):ro(100-l);return{...t,remainingPercent:i,usedPercent:l,windowDurationMins:Number.isFinite(t.windowDurationMins)?t.windowDurationMins:void 0}}function Wf(t){return Number.isFinite(t)?`${Math.round(ro(t))}%`:"100%"}function $f(t,e=new Date){if(t==null||!Number.isFinite(t))return null;let n=new Date(t*1e3);if(!Number.isFinite(n.getTime()))return null;let l=Math.floor((n.getTime()-e.getTime())/1e3);return l>0&&l<3600?new Intl.DateTimeFormat(void 0,{timeStyle:"short"}).format(n):rb(n,e)?new Intl.DateTimeFormat(void 0,{timeStyle:"short"}).format(n):new Intl.DateTimeFormat(void 0,{month:"short",day:"numeric"}).format(n)}function ed(t,e,n){if(t==null||!Number.isFinite(t)||t<=0)return e;let l=Math.round(t);return Lg(t,720*60)?n?.monthly??e:Lg(t,10080)?n?.weekly??e:l>=1440?Pf(n?.daysFormat,Math.ceil(l/1440),e):l>=60?Pf(n?.hoursFormat,Math.ceil(l/60),e):Pf(n?.minutesFormat,Math.max(1,Math.ceil(l)),e)}function ro(t){return Number.isFinite(t)?Math.min(Math.max(t,0),100):100}function Lg(t,e){return t>=e*.95&&t<=e*1.05}function rb(t,e){return t.getFullYear()===e.getFullYear()&&t.getMonth()===e.getMonth()&&t.getDate()===e.getDate()}function Pf(t,e,n){let l=new Intl.NumberFormat(void 0,{maximumFractionDigits:0}).format(e);return t==null||t.length===0?n:t.replaceAll("%@",l).replaceAll("%d",l).replaceAll("{value}",l)}var Bg=256*1024,Hg=64*1024,ob=8*1024,Ug=`[earlier assistant output truncated] +`,Vg=`[earlier command output truncated] +`,ab=`[earlier log output truncated] +`;function qg(t){return{selectedProviderId:"codex",status:"loading",input:"",providers:[],log:[],transcript:[],autoStartAttemptedProviderIds:[],seenSessionIds:[]}}function Yg(t,e){switch(e.type){case"context":return hb(sb(t,e.context));case"providers":return{...t,providers:e.providers};case"selectProvider":return ms(t)?{...t,selectedProviderId:e.providerId}:t;case"setInput":return{...t,input:e.input};case"autoStartAttempted":return t.autoStartAttemptedProviderIds.includes(e.providerId)?t:{...t,autoStartAttemptedProviderIds:[...t.autoStartAttemptedProviderIds,e.providerId]};case"starting":return{...t,status:"starting",log:Ct(t,"info",wt(t,"startingStatus","Starting"))};case"startAccepted":return t.status!=="starting"||t.runningSessionId?t:{...t,runningSessionId:e.sessionId,requestedStopSessionId:void 0,seenSessionIds:Bi(t,e.sessionId),status:"running"};case"stopping":return{...t,status:"stopping",requestedStopSessionId:e.sessionId,log:Ct(t,"info",wt(t,"stoppingStatus","Stopping"))};case"failed":return{...t,status:"failed",log:Ct(t,"error",e.message),transcript:oo(t,e.message,"error")};case"failedForSession":case"sendFailed":return t.runningSessionId!==e.sessionId||t.requestedStopSessionId===e.sessionId?t:{...t,status:"failed",log:Ct(t,"error",e.message),transcript:oo(t,e.message,"error")};case"stopFailed":return t.runningSessionId!==e.sessionId&&t.requestedStopSessionId!==e.sessionId?t:{...t,requestedStopSessionId:void 0,status:"failed",log:Ct(t,"error",e.message),transcript:oo(t,e.message,"error")};case"sent":return t.runningSessionId!==e.sessionId||t.requestedStopSessionId===e.sessionId?t:{...t,input:t.input===e.submittedInput?"":t.input,log:Ct(t,"info",ao(t,"sentCharsFormat","Sent %d chars",e.text.length)),transcript:Wg(t,e.displayText??e.text,e.attachments,e.sentAtMs)};case"event":return cb(t,e.event);default:return t}}function sb(t,e){return e.activeSession?{...t,context:e,runningSessionId:e.activeSession.sessionId,requestedStopSessionId:void 0,selectedProviderId:e.activeSession.providerId,seenSessionIds:Bi(t,e.activeSession.sessionId),status:"running"}:t.runningSessionId?{...t,context:e,status:t.status==="loading"?"running":t.status}:{...t,context:e,selectedProviderId:e.initialProviderId,status:"idle"}}async function Zg(t){try{let[e,n]=await Promise.all([pn("app.context"),pn("provider.list")]);eo(e.theme),t({type:"context",context:e}),t({type:"providers",providers:n})}catch(e){t({type:"failed",message:Hi(e)})}}async function jg(t,e){ps(t)&&await Xg(Gg(t),e)}function Gg(t){return{providerId:t.selectedProviderId,workingDirectory:t.context?.workingDirectory,copy:t.context?.copy}}async function Xg(t,e){e({type:"starting"});try{let n=await pn("provider.start",{providerId:t.providerId,workingDirectory:t.workingDirectory});e({type:"startAccepted",sessionId:n.sessionId})}catch(n){e({type:"failed",message:Hi(n,t.copy)})}}function ub(t){return!ps(t)||t.autoStartAttemptedProviderIds.includes(t.selectedProviderId)?!1:t.providers.find(n=>n.id===t.selectedProviderId)?.autoStart===!0}async function Ig(t,e){if(!ub(t))return;let n=t.selectedProviderId,l=Gg(t);e({type:"autoStartAttempted",providerId:n}),await Xg(l,e)}function Kg(t,e,n){ms(e)&&(n({type:"selectProvider",providerId:t}),pn("provider.select",{providerId:t}).catch(()=>{}))}async function Qg(t,e,n={}){let l=n.text??t.input,i=n.clearInput??l;if(l.length===0||!t.runningSessionId||t.status!=="running")return!1;let r=t.runningSessionId;try{return await pn("provider.writeLine",{permissionMode:n.permissionMode??"default",sessionId:r,text:l}),e({type:"sent",attachments:n.attachments,displayText:n.displayText,sentAtMs:Date.now(),sessionId:r,text:l,submittedInput:i}),!0}catch(o){return yb(o)||e({type:"sendFailed",sessionId:r,message:Hi(o,t)}),!1}}async function Fg(t,e){if(!t.runningSessionId||t.status==="stopping")return;let n=t.runningSessionId;e({type:"stopping",sessionId:n});try{await pn("provider.stop",{sessionId:n})}catch(l){e({type:"stopFailed",sessionId:n,message:Hi(l,t)})}}function Jg(t){switch(t.status){case"loading":return wt(t,"loadingStatus","Loading");case"idle":return wt(t,"idleStatus","Idle");case"starting":return wt(t,"startingStatus","Starting");case"running":return wt(t,"runningStatus","Running");case"stopping":return wt(t,"stoppingStatus","Stopping");case"failed":return wt(t,"failedStatus","Failed")}}function ps(t){return(t.status==="idle"||t.status==="failed")&&!t.runningSessionId&&!!t.context}function ms(t){return!t.runningSessionId&&t.status!=="starting"&&t.status!=="stopping"}function Pg(t){return!!t.runningSessionId&&t.status!=="stopping"}function cb(t,e){switch(e.type){case"app.theme":return t.context?{...t,context:{...t.context,theme:e.theme}}:t;case"app.rateLimitRows":return t.context?{...t,context:{...t.context,rateLimitRows:e.rateLimitRows}}:t;case"provider.started":return e.sessionId===t.requestedStopSessionId||t.runningSessionId&&e.sessionId!==t.runningSessionId||!t.runningSessionId&&t.status!=="starting"&&t.status!=="idle"&&t.status!=="failed"&&t.status!=="loading"||!t.runningSessionId&&t.status==="starting"&&e.providerId!==t.selectedProviderId?t:{...t,runningSessionId:e.sessionId,requestedStopSessionId:void 0,selectedProviderId:e.providerId,seenSessionIds:Bi(t,e.sessionId),status:"running",log:Ct(t,"info",wt(t,"providerStarted","Provider started"))};case"provider.inputAccepted":let n=db(t,e);return n?{...n,log:Ct(n,"info",ao(n,"sentCharsFormat","Sent %d chars",e.text.length)),transcript:Wg(n,e.text,void 0,e.sentAtMs??Date.now())}:t;case"provider.output":return e.sessionId!==t.runningSessionId?t:{...t,log:Ct(t,e.stream,e.text),transcript:pb(t,e)};case"provider.activity":return e.sessionId!==t.runningSessionId?t:{...t,transcript:mb(t,e)};case"provider.turnComplete":return e.sessionId!==t.runningSessionId?t:{...t,transcript:hs(t.transcript,e.sessionId)};case"provider.exit":return fb(t,e)?e.sessionId===t.requestedStopSessionId?{...t,runningSessionId:void 0,requestedStopSessionId:void 0,seenSessionIds:Bi(t,e.sessionId),status:"idle",log:Ct(t,"info",wt(t,"stopped","Stopped")),transcript:hs(t.transcript,e.sessionId)}:{...t,runningSessionId:void 0,requestedStopSessionId:void 0,seenSessionIds:Bi(t,e.sessionId),status:e.status===0?"idle":"failed",log:Ct(t,e.status===0?"info":"error",ao(t,"providerExitedFormat","Provider exited %d",e.status)),transcript:e.status===0?hs(t.transcript,e.sessionId):oo({...t,transcript:hs(t.transcript,e.sessionId)},ao(t,"providerExitedFormat","Provider exited %d",e.status),"error")}:t;default:return t}}function fb(t,e){return e.sessionId===t.runningSessionId?!0:!t.seenSessionIds.includes(e.sessionId)&&t.status==="starting"&&!t.runningSessionId&&e.providerId===t.selectedProviderId}function db(t,e){if(e.sessionId===t.runningSessionId)return t;if(!(t.runningSessionId||e.sessionId===t.requestedStopSessionId||t.status==="stopping"))return{...t,runningSessionId:e.sessionId,requestedStopSessionId:void 0,selectedProviderId:e.providerId,seenSessionIds:Bi(t,e.sessionId),status:"running"}}function Bi(t,e){return t.seenSessionIds.includes(e)?t.seenSessionIds:[...t.seenSessionIds,e].slice(-50)}function hb(t){let e=t.context?.renderer==="solid"?"Solid":"React";return{...t,log:Ct(t,"info",ao(t,"rendererReadyFormat","%@ ready",e))}}function Ct(t,e,n){return[...t.log,{id:Pn(),level:e,text:ys(n,ob,ab)}].slice(-300)}function Wg(t,e,n,l){return gs(t.transcript,{attachments:n,id:Pn(),role:"user",sentAtMs:l,text:e})}function pb(t,e){if(e.stream!=="stdout")return oo(t,e.text,"warning");let n=t.transcript.at(-1);return n?.role==="assistant"&&n.sessionId===e.sessionId?[...t.transcript.slice(0,-1),{...n,isComplete:!1,text:$g(n.text,e.text,Bg,Ug)}]:gs(t.transcript,{id:Pn(),role:"assistant",isComplete:!1,sessionId:e.sessionId,sentAtMs:Date.now(),text:ys(e.text,Bg,Ug)})}function hs(t,e){let n=!1,l=t.map(i=>i.role!=="assistant"||i.sessionId!==e||i.isComplete===!0?i:(n=!0,{...i,isComplete:!0}));return n?l:t}function mb(t,e){let n=gb(t.transcript,e.sessionId,e.activityId);if(n>=0){let l=t.transcript[n];return[...t.transcript.slice(0,n),{...l,text:e.action,detail:e.detail??l.detail,activityKind:e.kind,activityStatus:e.status,output:e.outputDelta===void 0?l.output:$g(l.output??"",e.outputDelta,Hg,Vg)},...t.transcript.slice(n+1)]}return gs(t.transcript,{id:Pn(),role:"activity",text:e.action,detail:e.detail,sessionId:e.sessionId,activityId:e.activityId,activityKind:e.kind,activityStatus:e.status,output:e.outputDelta===void 0?void 0:ys(e.outputDelta,Hg,Vg)})}function gb(t,e,n){for(let l=t.length-1;l>=0;l-=1){let i=t[l];if(i.role==="activity"&&i.sessionId===e&&i.activityId===n)return l}return-1}function oo(t,e,n){return gs(t.transcript,{id:Pn(),role:"notice",tone:n,text:e})}function gs(t,e){return[...t,e].slice(-200)}function $g(t,e,n,l){let i=t.startsWith(l),r=t.startsWith(l)?t.slice(l.length):t;return i?l+(r+e).slice(-(n-l.length)):ys(r+e,n,l)}function ys(t,e,n){return t.length<=e?t:n+t.slice(-(e-n.length))}function wt(t,e,n){return t.context?.copy[e]??n}function yb(t){return t instanceof to&&t.code==="providerNotReady"}function ao(t,e,n,...l){return bb(wt(t,e,n),l)}function bb(t,e){let n=0;return t.replace(/%(\d+\$)?[@d]/g,(l,i)=>{let r=i?Number(i.slice(0,-1))-1:n++;return String(e[r]??"")})}function Hi(t,e){let n=Sb(e);return t instanceof Error&&t.message?n&&t.message==="Native bridge request failed."?n.requestFailed:t.message:n?.requestFailed??"Native bridge request failed."}function Sb(t){if(t)return"providers"in t?t.context?.copy:t}var se=wo(Gl(),1);function Re(t){this.content=t}Re.prototype={constructor:Re,find:function(t){for(var e=0;e>1}};Re.from=function(t){if(t instanceof Re)return t;var e=[];if(t)for(var n in t)e.push(n,t[n]);return new Re(e)};var td=Re;function uy(t,e,n){for(let l=0;;l++){if(l==t.childCount||l==e.childCount)return t.childCount==e.childCount?null:n;let i=t.child(l),r=e.child(l);if(i==r){n+=i.nodeSize;continue}if(!i.sameMarkup(r))return n;if(i.isText&&i.text!=r.text){for(let o=0;i.text[o]==r.text[o];o++)n++;return n}if(i.content.size||r.content.size){let o=uy(i.content,r.content,n+1);if(o!=null)return o}n+=i.nodeSize}}function cy(t,e,n,l){for(let i=t.childCount,r=e.childCount;;){if(i==0||r==0)return i==r?null:{a:n,b:l};let o=t.child(--i),a=e.child(--r),s=o.nodeSize;if(o==a){n-=s,l-=s;continue}if(!o.sameMarkup(a))return{a:n,b:l};if(o.isText&&o.text!=a.text){let u=0,c=Math.min(o.text.length,a.text.length);for(;ue&&l(s,i+a,r||null,o)!==!1&&s.content.size){let c=a+1;s.nodesBetween(Math.max(0,e-c),Math.min(s.content.size,n-c),l,i+c)}a=u}}descendants(e){this.nodesBetween(0,this.size,e)}textBetween(e,n,l,i){let r="",o=!0;return this.nodesBetween(e,n,(a,s)=>{let u=a.isText?a.text.slice(Math.max(e,s)-s,n-s):a.isLeaf?i?typeof i=="function"?i(a):i:a.type.spec.leafText?a.type.spec.leafText(a):"":"";a.isBlock&&(a.isLeaf&&u||a.isTextblock)&&l&&(o?o=!1:r+=l),r+=u},0),r}append(e){if(!e.size)return this;if(!this.size)return e;let n=this.lastChild,l=e.firstChild,i=this.content.slice(),r=0;for(n.isText&&n.sameMarkup(l)&&(i[i.length-1]=n.withText(n.text+l.text),r=1);re)for(let r=0,o=0;oe&&((on)&&(a.isText?a=a.cut(Math.max(0,e-o),Math.min(a.text.length,n-o)):a=a.cut(Math.max(0,e-o-1),Math.min(a.content.size,n-o-1))),l.push(a),i+=a.nodeSize),o=s}return new t(l,i)}cutByIndex(e,n){return e==n?t.empty:e==0&&n==this.content.length?this:new t(this.content.slice(e,n))}replaceChild(e,n){let l=this.content[e];if(l==n)return this;let i=this.content.slice(),r=this.size+n.nodeSize-l.nodeSize;return i[e]=n,new t(i,r)}addToStart(e){return new t([e].concat(this.content),this.size+e.nodeSize)}addToEnd(e){return new t(this.content.concat(e),this.size+e.nodeSize)}eq(e){if(this.content.length!=e.content.length)return!1;for(let n=0;nthis.size||e<0)throw new RangeError(`Position ${e} outside of fragment (${this})`);for(let n=0,l=0;;n++){let i=this.child(n),r=l+i.nodeSize;if(r>=e)return r==e?bs(n+1,r):bs(n,l);l=r}}toString(){return"<"+this.toStringInner()+">"}toStringInner(){return this.content.join(", ")}toJSON(){return this.content.length?this.content.map(e=>e.toJSON()):null}static fromJSON(e,n){if(!n)return t.empty;if(!Array.isArray(n))throw new RangeError("Invalid input for Fragment.fromJSON");return t.fromArray(n.map(e.nodeFromJSON))}static fromArray(e){if(!e.length)return t.empty;let n,l=0;for(let i=0;ithis.type.rank&&(n||(n=e.slice(0,i)),n.push(this),l=!0),n&&n.push(r)}}return n||(n=e.slice()),l||n.push(this),n}removeFromSet(e){for(let n=0;nl.type.rank-i.type.rank),n}};Z.none=[];var Ol=class extends Error{},k=class t{constructor(e,n,l){this.content=e,this.openStart=n,this.openEnd=l}get size(){return this.content.size-this.openStart-this.openEnd}insertAt(e,n){let l=dy(this.content,e+this.openStart,n,this.openStart+1,this.openEnd+1);return l&&new t(l,this.openStart,this.openEnd)}removeBetween(e,n){return new t(fy(this.content,e+this.openStart,n+this.openStart),this.openStart,this.openEnd)}eq(e){return this.content.eq(e.content)&&this.openStart==e.openStart&&this.openEnd==e.openEnd}toString(){return this.content+"("+this.openStart+","+this.openEnd+")"}toJSON(){if(!this.content.size)return null;let e={content:this.content.toJSON()};return this.openStart>0&&(e.openStart=this.openStart),this.openEnd>0&&(e.openEnd=this.openEnd),e}static fromJSON(e,n){if(!n)return t.empty;let l=n.openStart||0,i=n.openEnd||0;if(typeof l!="number"||typeof i!="number")throw new RangeError("Invalid input for Slice.fromJSON");return new t(D.fromJSON(e,n.content),l,i)}static maxOpen(e,n=!0){let l=0,i=0;for(let r=e.firstChild;r&&!r.isLeaf&&(n||!r.type.spec.isolating);r=r.firstChild)l++;for(let r=e.lastChild;r&&!r.isLeaf&&(n||!r.type.spec.isolating);r=r.lastChild)i++;return new t(e,l,i)}};k.empty=new k(D.empty,0,0);function fy(t,e,n){let{index:l,offset:i}=t.findIndex(e),r=t.maybeChild(l),{index:o,offset:a}=t.findIndex(n);if(i==e||r.isText){if(a!=n&&!t.child(o).isText)throw new RangeError("Removing non-flat range");return t.cut(0,e).append(t.cut(n))}if(l!=o)throw new RangeError("Removing non-flat range");return t.replaceChild(l,r.copy(fy(r.content,e-i-1,n-i-1)))}function dy(t,e,n,l,i,r){let{index:o,offset:a}=t.findIndex(e),s=t.maybeChild(o);if(a==e||s.isText)return r&&l<=0&&i<=0&&!r.canReplace(o,o,n)?null:t.cut(0,e).append(n).append(t.cut(e));let u=dy(s.content,e-a-1,n,o==0?l-1:0,o==t.childCount-1?i-1:0,s);return u&&t.replaceChild(o,s.copy(u))}function xb(t,e,n){if(n.openStart>t.depth)throw new Ol("Inserted content deeper than insertion position");if(t.depth-n.openStart!=e.depth-n.openEnd)throw new Ol("Inconsistent open depths");return hy(t,e,n,0)}function hy(t,e,n,l){let i=t.index(l),r=t.node(l);if(i==e.index(l)&&l=0&&t.isText&&t.sameMarkup(e[n])?e[n]=t.withText(e[n].text+t.text):e.push(t)}function so(t,e,n,l){let i=(e||t).node(n),r=0,o=e?e.index(n):i.childCount;t&&(r=t.index(n),t.depth>n?r++:t.textOffset&&(Nl(t.nodeAfter,l),r++));for(let a=r;ai&&ld(t,e,i+1),o=l.depth>i&&ld(n,l,i+1),a=[];return so(null,t,i,a),r&&o&&e.index(i)==n.index(i)?(py(r,o),Nl(Al(r,my(t,e,n,l,i+1)),a)):(r&&Nl(Al(r,Cs(t,e,i+1)),a),so(e,n,i,a),o&&Nl(Al(o,Cs(n,l,i+1)),a)),so(l,null,i,a),new D(a)}function Cs(t,e,n){let l=[];if(so(null,t,n,l),t.depth>n){let i=ld(t,e,n+1);Nl(Al(i,Cs(t,e,n+1)),l)}return so(e,null,n,l),new D(l)}function vb(t,e){let n=e.depth-t.openStart,i=e.node(n).copy(t.content);for(let r=n-1;r>=0;r--)i=e.node(r).copy(D.from(i));return{start:i.resolveNoCache(t.openStart+n),end:i.resolveNoCache(i.content.size-t.openEnd-n)}}var Ms=class t{constructor(e,n,l){this.pos=e,this.path=n,this.parentOffset=l,this.depth=n.length/3-1}resolveDepth(e){return e==null?this.depth:e<0?this.depth+e:e}get parent(){return this.node(this.depth)}get doc(){return this.node(0)}node(e){return this.path[this.resolveDepth(e)*3]}index(e){return this.path[this.resolveDepth(e)*3+1]}indexAfter(e){return e=this.resolveDepth(e),this.index(e)+(e==this.depth&&!this.textOffset?0:1)}start(e){return e=this.resolveDepth(e),e==0?0:this.path[e*3-1]+1}end(e){return e=this.resolveDepth(e),this.start(e)+this.node(e).content.size}before(e){if(e=this.resolveDepth(e),!e)throw new RangeError("There is no position before the top-level node");return e==this.depth+1?this.pos:this.path[e*3-1]}after(e){if(e=this.resolveDepth(e),!e)throw new RangeError("There is no position after the top-level node");return e==this.depth+1?this.pos:this.path[e*3-1]+this.path[e*3].nodeSize}get textOffset(){return this.pos-this.path[this.path.length-1]}get nodeAfter(){let e=this.parent,n=this.index(this.depth);if(n==e.childCount)return null;let l=this.pos-this.path[this.path.length-1],i=e.child(n);return l?e.child(n).cut(l):i}get nodeBefore(){let e=this.index(this.depth),n=this.pos-this.path[this.path.length-1];return n?this.parent.child(e).cut(0,n):e==0?null:this.parent.child(e-1)}posAtIndex(e,n){n=this.resolveDepth(n);let l=this.path[n*3],i=n==0?0:this.path[n*3-1]+1;for(let r=0;r0;n--)if(this.start(n)<=e&&this.end(n)>=e)return n;return 0}blockRange(e=this,n){if(e.pos=0;l--)if(e.pos<=this.end(l)&&(!n||n(this.node(l))))return new rd(this,e,l);return null}sameParent(e){return this.pos-this.parentOffset==e.pos-e.parentOffset}max(e){return e.pos>this.pos?e:this}min(e){return e.pos=0&&n<=e.content.size))throw new RangeError("Position "+n+" out of range");let l=[],i=0,r=n;for(let o=e;;){let{index:a,offset:s}=o.content.findIndex(r),u=r-s;if(l.push(o,a,i+s),!u||(o=o.child(a),o.isText))break;r=u-1,i+=s+1}return new t(n,l,r)}static resolveCached(e,n){let l=ey.get(e);if(l)for(let r=0;re&&this.nodesBetween(e,n,r=>(l.isInSet(r.marks)&&(i=!0),!i)),i}get isBlock(){return this.type.isBlock}get isTextblock(){return this.type.isTextblock}get inlineContent(){return this.type.inlineContent}get isInline(){return this.type.isInline}get isText(){return this.type.isText}get isLeaf(){return this.type.isLeaf}get isAtom(){return this.type.isAtom}toString(){if(this.type.spec.toDebugString)return this.type.spec.toDebugString(this);let e=this.type.name;return this.content.size&&(e+="("+this.content.toStringInner()+")"),gy(this.marks,e)}contentMatchAt(e){let n=this.type.contentMatch.matchFragment(this.content,0,e);if(!n)throw new Error("Called contentMatchAt on a node with invalid content");return n}canReplace(e,n,l=D.empty,i=0,r=l.childCount){let o=this.contentMatchAt(e).matchFragment(l,i,r),a=o&&o.matchFragment(this.content,n);if(!a||!a.validEnd)return!1;for(let s=i;sn.type.name)}`);this.content.forEach(n=>n.check())}toJSON(){let e={type:this.type.name};for(let n in this.attrs){e.attrs=this.attrs;break}return this.content.size&&(e.content=this.content.toJSON()),this.marks.length&&(e.marks=this.marks.map(n=>n.toJSON())),e}static fromJSON(e,n){if(!n)throw new RangeError("Invalid input for Node.fromJSON");let l;if(n.marks){if(!Array.isArray(n.marks))throw new RangeError("Invalid mark data for Node.fromJSON");l=n.marks.map(e.markFromJSON)}if(n.type=="text"){if(typeof n.text!="string")throw new RangeError("Invalid text node in JSON");return e.text(n.text,l)}let i=D.fromJSON(e,n.content),r=e.nodeType(n.type).create(n.attrs,i,l);return r.type.checkAttrs(r.attrs),r}};Yt.prototype.text=void 0;var od=class t extends Yt{constructor(e,n,l,i){if(super(e,n,null,i),!l)throw new RangeError("Empty text nodes are not allowed");this.text=l}toString(){return this.type.spec.toDebugString?this.type.spec.toDebugString(this):gy(this.marks,JSON.stringify(this.text))}get textContent(){return this.text}textBetween(e,n){return this.text.slice(e,n)}get nodeSize(){return this.text.length}mark(e){return e==this.marks?this:new t(this.type,this.attrs,this.text,e)}withText(e){return e==this.text?this:new t(this.type,this.attrs,e,this.marks)}cut(e=0,n=this.text.length){return e==0&&n==this.text.length?this:this.withText(this.text.slice(e,n))}eq(e){return this.sameMarkup(e)&&this.text==e.text}toJSON(){let e=super.toJSON();return e.text=this.text,e}};function gy(t,e){for(let n=t.length-1;n>=0;n--)e=t[n].type.name+"("+e+")";return e}var Dl=class t{constructor(e){this.validEnd=e,this.next=[],this.wrapCache=[]}static parse(e,n){let l=new ad(e,n);if(l.next==null)return t.empty;let i=yy(l);l.next&&l.err("Unexpected trailing text");let r=kb(Db(i));return wb(r,l),r}matchType(e){for(let n=0;nu.createAndFill()));for(let u=0;u=this.next.length)throw new RangeError(`There's no ${e}th edge in this content match`);return this.next[e]}toString(){let e=[];function n(l){e.push(l);for(let i=0;i{let r=i+(l.validEnd?"*":" ")+" ";for(let o=0;o"+e.indexOf(l.next[o].next);return r}).join(` +`)}};Dl.empty=new Dl(!0);var ad=class{constructor(e,n){this.string=e,this.nodeTypes=n,this.inline=null,this.pos=0,this.tokens=e.split(/\s*(?=\b|\W|$)/),this.tokens[this.tokens.length-1]==""&&this.tokens.pop(),this.tokens[0]==""&&this.tokens.shift()}get next(){return this.tokens[this.pos]}eat(e){return this.next==e&&(this.pos++||!0)}err(e){throw new SyntaxError(e+" (in content expression '"+this.string+"')")}};function yy(t){let e=[];do e.push(Tb(t));while(t.eat("|"));return e.length==1?e[0]:{type:"choice",exprs:e}}function Tb(t){let e=[];do e.push(Eb(t));while(t.next&&t.next!=")"&&t.next!="|");return e.length==1?e[0]:{type:"seq",exprs:e}}function Eb(t){let e=Ob(t);for(;;)if(t.eat("+"))e={type:"plus",expr:e};else if(t.eat("*"))e={type:"star",expr:e};else if(t.eat("?"))e={type:"opt",expr:e};else if(t.eat("{"))e=Nb(t,e);else break;return e}function ty(t){/\D/.test(t.next)&&t.err("Expected number, got '"+t.next+"'");let e=Number(t.next);return t.pos++,e}function Nb(t,e){let n=ty(t),l=n;return t.eat(",")&&(t.next!="}"?l=ty(t):l=-1),t.eat("}")||t.err("Unclosed braced range"),{type:"range",min:n,max:l,expr:e}}function Ab(t,e){let n=t.nodeTypes,l=n[e];if(l)return[l];let i=[];for(let r in n){let o=n[r];o.isInGroup(e)&&i.push(o)}return i.length==0&&t.err("No node type or group '"+e+"' found"),i}function Ob(t){if(t.eat("(")){let e=yy(t);return t.eat(")")||t.err("Missing closing paren"),e}else if(/\W/.test(t.next))t.err("Unexpected token '"+t.next+"'");else{let e=Ab(t,t.next).map(n=>(t.inline==null?t.inline=n.isInline:t.inline!=n.isInline&&t.err("Mixing inline and block content"),{type:"name",value:n}));return t.pos++,e.length==1?e[0]:{type:"choice",exprs:e}}}function Db(t){let e=[[]];return i(r(t,0),n()),e;function n(){return e.push([])-1}function l(o,a,s){let u={term:s,to:a};return e[o].push(u),u}function i(o,a){o.forEach(s=>s.to=a)}function r(o,a){if(o.type=="choice")return o.exprs.reduce((s,u)=>s.concat(r(u,a)),[]);if(o.type=="seq")for(let s=0;;s++){let u=r(o.exprs[s],a);if(s==o.exprs.length-1)return u;i(u,a=n())}else if(o.type=="star"){let s=n();return l(a,s),i(r(o.expr,s),s),[l(s)]}else if(o.type=="plus"){let s=n();return i(r(o.expr,a),s),i(r(o.expr,s),s),[l(s)]}else{if(o.type=="opt")return[l(a)].concat(r(o.expr,a));if(o.type=="range"){let s=a;for(let u=0;u{t[o].forEach(({term:a,to:s})=>{if(!a)return;let u;for(let c=0;c{u||i.push([a,u=[]]),u.indexOf(c)==-1&&u.push(c)})})});let r=e[l.join(",")]=new Dl(l.indexOf(t.length-1)>-1);for(let o=0;o-1}get whitespace(){return this.spec.whitespace||(this.spec.code?"pre":"normal")}hasRequiredAttrs(){for(let e in this.attrs)if(this.attrs[e].isRequired)return!0;return!1}compatibleContent(e){return this==e||this.contentMatch.compatible(e.contentMatch)}computeAttrs(e){return!e&&this.defaultAttrs?this.defaultAttrs:xy(this.attrs,e)}create(e=null,n,l){if(this.isText)throw new Error("NodeType.create can't construct text nodes");return new Yt(this,this.computeAttrs(e),D.from(n),Z.setFrom(l))}createChecked(e=null,n,l){return n=D.from(n),this.checkContent(n),new Yt(this,this.computeAttrs(e),n,Z.setFrom(l))}createAndFill(e=null,n,l){if(e=this.computeAttrs(e),n=D.from(n),n.size){let o=this.contentMatch.fillBefore(n);if(!o)return null;n=o.append(n)}let i=this.contentMatch.matchFragment(n),r=i&&i.fillBefore(D.empty,!0);return r?new Yt(this,e,n.append(r),Z.setFrom(l)):null}validContent(e){let n=this.contentMatch.matchFragment(e);if(!n||!n.validEnd)return!1;for(let l=0;l-1}allowsMarks(e){if(this.markSet==null)return!0;for(let n=0;nl[r]=new t(r,n,o));let i=n.spec.topNode||"doc";if(!l[i])throw new RangeError("Schema is missing its top node type ('"+i+"')");if(!l.text)throw new RangeError("Every schema needs a 'text' type");for(let r in l.text.attrs)throw new RangeError("The text node type should not have attributes");return l}};function zb(t,e,n){let l=n.split("|");return i=>{let r=i===null?"null":typeof i;if(l.indexOf(r)<0)throw new RangeError(`Expected value of type ${l} for attribute ${e} on type ${t}, got ${r}`)}}var sd=class{constructor(e,n,l){this.hasDefault=Object.prototype.hasOwnProperty.call(l,"default"),this.default=l.default,this.validate=typeof l.validate=="string"?zb(e,n,l.validate):l.validate}get isRequired(){return!this.hasDefault}},co=class t{constructor(e,n,l,i){this.name=e,this.rank=n,this.schema=l,this.spec=i,this.attrs=Cy(e,i.attrs),this.excluded=null;let r=Sy(this.attrs);this.instance=r?new Z(this,r):null}create(e=null){return!e&&this.instance?this.instance:new Z(this,xy(this.attrs,e))}static compile(e,n){let l=Object.create(null),i=0;return e.forEach((r,o)=>l[r]=new t(r,i++,n,o)),l}removeFromSet(e){for(var n=0;n-1}},Es=class{constructor(e){this.linebreakReplacement=null,this.cached=Object.create(null);let n=this.spec={};for(let i in e)n[i]=e[i];n.nodes=td.from(e.nodes),n.marks=td.from(e.marks||{}),this.nodes=Ts.compile(this.spec.nodes,this),this.marks=co.compile(this.spec.marks,this);let l=Object.create(null);for(let i in this.nodes){if(i in this.marks)throw new RangeError(i+" can not be both a node and a mark");let r=this.nodes[i],o=r.spec.content||"",a=r.spec.marks;if(r.contentMatch=l[o]||(l[o]=Dl.parse(o,this.nodes)),r.inlineContent=r.contentMatch.inlineContent,r.spec.linebreakReplacement){if(this.linebreakReplacement)throw new RangeError("Multiple linebreak nodes defined");if(!r.isInline||!r.isLeaf)throw new RangeError("Linebreak replacement nodes must be inline leaf nodes");this.linebreakReplacement=r}r.markSet=a=="_"?null:a?ly(this,a.split(" ")):a==""||!r.inlineContent?[]:null}for(let i in this.marks){let r=this.marks[i],o=r.spec.excludes;r.excluded=o==null?[r]:o==""?[]:ly(this,o.split(" "))}this.nodeFromJSON=i=>Yt.fromJSON(this,i),this.markFromJSON=i=>Z.fromJSON(this,i),this.topNodeType=this.nodes[this.spec.topNode||"doc"],this.cached.wrappings=Object.create(null)}node(e,n=null,l,i){if(typeof e=="string")e=this.nodeType(e);else if(e instanceof Ts){if(e.schema!=this)throw new RangeError("Node type from different schema used ("+e.name+")")}else throw new RangeError("Invalid node type: "+e);return e.createChecked(n,l,i)}text(e,n){let l=this.nodes.text;return new od(l,l.defaultAttrs,e,Z.setFrom(n))}mark(e,n){return typeof e=="string"&&(e=this.marks[e]),e.create(n)}nodeType(e){let n=this.nodes[e];if(!n)throw new RangeError("Unknown node type: "+e);return n}};function ly(t,e){let n=[];for(let l=0;l-1)&&n.push(o=s)}if(!o)throw new SyntaxError("Unknown mark type: '"+e[l]+"'")}return n}function Rb(t){return t.tag!=null}function Lb(t){return t.style!=null}var fo=class t{constructor(e,n){this.schema=e,this.rules=n,this.tags=[],this.styles=[];let l=this.matchedStyles=[];n.forEach(i=>{if(Rb(i))this.tags.push(i);else if(Lb(i)){let r=/[^=]*/.exec(i.style)[0];l.indexOf(r)<0&&l.push(r),this.styles.push(i)}}),this.normalizeLists=!this.tags.some(i=>{if(!/^(ul|ol)\b/.test(i.tag)||!i.node)return!1;let r=e.nodes[i.node];return r.contentMatch.matchType(r)})}parse(e,n={}){let l=new Ns(this,n,!1);return l.addAll(e,Z.none,n.from,n.to),l.finish()}parseSlice(e,n={}){let l=new Ns(this,n,!0);return l.addAll(e,Z.none,n.from,n.to),k.maxOpen(l.finish())}matchTag(e,n,l){for(let i=l?this.tags.indexOf(l)+1:0;ie.length&&(a.charCodeAt(e.length)!=61||a.slice(e.length+1)!=n))){if(o.getAttrs){let s=o.getAttrs(n);if(s===!1)continue;o.attrs=s||void 0}return o}}}static schemaRules(e){let n=[];function l(i){let r=i.priority==null?50:i.priority,o=0;for(;o{l(o=ry(o)),o.mark||o.ignore||o.clearMark||(o.mark=i)})}for(let i in e.nodes){let r=e.nodes[i].spec.parseDOM;r&&r.forEach(o=>{l(o=ry(o)),o.node||o.ignore||o.mark||(o.node=i)})}return n}static fromSchema(e){return e.cached.domParser||(e.cached.domParser=new t(e,t.schemaRules(e)))}},My={address:!0,article:!0,aside:!0,blockquote:!0,canvas:!0,dd:!0,div:!0,dl:!0,fieldset:!0,figcaption:!0,figure:!0,footer:!0,form:!0,h1:!0,h2:!0,h3:!0,h4:!0,h5:!0,h6:!0,header:!0,hgroup:!0,hr:!0,li:!0,noscript:!0,ol:!0,output:!0,p:!0,pre:!0,section:!0,table:!0,tfoot:!0,ul:!0},_b={head:!0,noscript:!0,object:!0,script:!0,style:!0,title:!0},Ty={ol:!0,ul:!0},ho=1,ud=2,uo=4;function iy(t,e,n){return e!=null?(e?ho:0)|(e==="full"?ud:0):t&&t.whitespace=="pre"?ho|ud:n&~uo}var Ui=class{constructor(e,n,l,i,r,o){this.type=e,this.attrs=n,this.marks=l,this.solid=i,this.options=o,this.content=[],this.activeMarks=Z.none,this.match=r||(o&uo?null:e.contentMatch)}findWrapping(e){if(!this.match){if(!this.type)return[];let n=this.type.contentMatch.fillBefore(D.from(e));if(n)this.match=this.type.contentMatch.matchFragment(n);else{let l=this.type.contentMatch,i;return(i=l.findWrapping(e.type))?(this.match=l,i):null}}return this.match.findWrapping(e.type)}finish(e){if(!(this.options&ho)){let l=this.content[this.content.length-1],i;if(l&&l.isText&&(i=/[ \t\r\n\u000c]+$/.exec(l.text))){let r=l;l.text.length==i[0].length?this.content.pop():this.content[this.content.length-1]=r.withText(r.text.slice(0,r.text.length-i[0].length))}}let n=D.from(this.content);return!e&&this.match&&(n=n.append(this.match.fillBefore(D.empty,!0))),this.type?this.type.create(this.attrs,n,this.marks):n}inlineContext(e){return this.type?this.type.inlineContent:this.content.length?this.content[0].isInline:e.parentNode&&!My.hasOwnProperty(e.parentNode.nodeName.toLowerCase())}},Ns=class{constructor(e,n,l){this.parser=e,this.options=n,this.isOpen=l,this.open=0,this.localPreserveWS=!1;let i=n.topNode,r,o=iy(null,n.preserveWhitespace,0)|(l?uo:0);i?r=new Ui(i.type,i.attrs,Z.none,!0,n.topMatch||i.type.contentMatch,o):l?r=new Ui(null,null,Z.none,!0,null,o):r=new Ui(e.schema.topNodeType,null,Z.none,!0,null,o),this.nodes=[r],this.find=n.findPositions,this.needsBlock=!1}get top(){return this.nodes[this.open]}addDOM(e,n){e.nodeType==3?this.addTextNode(e,n):e.nodeType==1&&this.addElement(e,n)}addTextNode(e,n){let l=e.nodeValue,i=this.top,r=i.options&ud?"full":this.localPreserveWS||(i.options&ho)>0,{schema:o}=this.parser;if(r==="full"||i.inlineContext(e)||/[^ \t\r\n\u000c]/.test(l)){if(r)if(r==="full")l=l.replace(/\r\n?/g,` +`);else if(o.linebreakReplacement&&/[\r\n]/.test(l)&&this.top.findWrapping(o.linebreakReplacement.create())){let a=l.split(/\r?\n|\r/);for(let s=0;s!s.clearMark(u)):n=n.concat(this.parser.schema.marks[s.mark].create(s.attrs)),s.consuming===!1)a=s;else break}}return n}addElementByRule(e,n,l,i){let r,o;if(n.node)if(o=this.parser.schema.nodes[n.node],o.isLeaf)this.insertNode(o.create(n.attrs),l,e.nodeName=="BR")||this.leafFallback(e,l);else{let s=this.enter(o,n.attrs||null,l,n.preserveWhitespace);s&&(r=!0,l=s)}else{let s=this.parser.schema.marks[n.mark];l=l.concat(s.create(n.attrs))}let a=this.top;if(o&&o.isLeaf)this.findInside(e);else if(i)this.addElement(e,l,i);else if(n.getContent)this.findInside(e),n.getContent(e,this.parser.schema).forEach(s=>this.insertNode(s,l,!1));else{let s=e;typeof n.contentElement=="string"?s=e.querySelector(n.contentElement):typeof n.contentElement=="function"?s=n.contentElement(e):n.contentElement&&(s=n.contentElement),this.findAround(e,s,!0),this.addAll(s,l),this.findAround(e,s,!1)}r&&this.sync(a)&&this.open--}addAll(e,n,l,i){let r=l||0;for(let o=l?e.childNodes[l]:e.firstChild,a=i==null?null:e.childNodes[i];o!=a;o=o.nextSibling,++r)this.findAtPoint(e,r),this.addDOM(o,n);this.findAtPoint(e,r)}findPlace(e,n,l){let i,r;for(let o=this.open,a=0;o>=0;o--){let s=this.nodes[o],u=s.findWrapping(e);if(u&&(!i||i.length>u.length+a)&&(i=u,r=s,!u.length))break;if(s.solid){if(l)break;a+=2}}if(!i)return null;this.sync(r);for(let o=0;o(o.type?o.type.allowsMarkType(u.type):oy(u.type,e))?(s=u.addToSet(s),!1):!0),this.nodes.push(new Ui(e,n,s,i,null,a)),this.open++,l}closeExtra(e=!1){let n=this.nodes.length-1;if(n>this.open){for(;n>this.open;n--)this.nodes[n-1].content.push(this.nodes[n].finish(e));this.nodes.length=this.open+1}}finish(){return this.open=0,this.closeExtra(this.isOpen),this.nodes[0].finish(!!(this.isOpen||this.options.topOpen))}sync(e){for(let n=this.open;n>=0;n--){if(this.nodes[n]==e)return this.open=n,!0;this.localPreserveWS&&(this.nodes[n].options|=ho)}return!1}get currentPos(){this.closeExtra();let e=0;for(let n=this.open;n>=0;n--){let l=this.nodes[n].content;for(let i=l.length-1;i>=0;i--)e+=l[i].nodeSize;n&&e++}return e}findAtPoint(e,n){if(this.find)for(let l=0;l-1)return e.split(/\s*\|\s*/).some(this.matchesContext,this);let n=e.split("/"),l=this.options.context,i=!this.isOpen&&(!l||l.parent.type==this.nodes[0].type),r=-(l?l.depth+1:0)+(i?0:1),o=(a,s)=>{for(;a>=0;a--){let u=n[a];if(u==""){if(a==n.length-1||a==0)continue;for(;s>=r;s--)if(o(a-1,s))return!0;return!1}else{let c=s>0||s==0&&i?this.nodes[s].type:l&&s>=r?l.node(s-r).type:null;if(!c||c.name!=u&&!c.isInGroup(u))return!1;s--}}return!0};return o(n.length-1,this.open)}textblockFromContext(){let e=this.options.context;if(e)for(let n=e.depth;n>=0;n--){let l=e.node(n).contentMatchAt(e.indexAfter(n)).defaultType;if(l&&l.isTextblock&&l.defaultAttrs)return l}for(let n in this.parser.schema.nodes){let l=this.parser.schema.nodes[n];if(l.isTextblock&&l.defaultAttrs)return l}}};function Bb(t){for(let e=t.firstChild,n=null;e;e=e.nextSibling){let l=e.nodeType==1?e.nodeName.toLowerCase():null;l&&Ty.hasOwnProperty(l)&&n?(n.appendChild(e),e=n):l=="li"?n=e:l&&(n=null)}}function Hb(t,e){return(t.matches||t.msMatchesSelector||t.webkitMatchesSelector||t.mozMatchesSelector).call(t,e)}function ry(t){let e={};for(let n in t)e[n]=t[n];return e}function oy(t,e){let n=e.schema.nodes;for(let l in n){let i=n[l];if(!i.allowsMarkType(t))continue;let r=[],o=a=>{r.push(a);for(let s=0;s{if(r.length||o.marks.length){let a=0,s=0;for(;a=0;i--){let r=this.serializeMark(e.marks[i],e.isInline,n);r&&((r.contentDOM||r.dom).appendChild(l),l=r.dom)}return l}serializeMark(e,n,l={}){let i=this.marks[e.type.name];return i&&xs(Ss(l),i(e,n),null,e.attrs)}static renderSpec(e,n,l=null,i){return typeof n=="string"?{dom:e.createTextNode(n)}:xs(e,n,l,i)}static fromSchema(e){return e.cached.domSerializer||(e.cached.domSerializer=new t(this.nodesFromSchema(e),this.marksFromSchema(e)))}static nodesFromSchema(e){let n=ay(e.nodes);return n.text||(n.text=l=>l.text),n}static marksFromSchema(e){return ay(e.marks)}};function ay(t){let e={};for(let n in t){let l=t[n].spec.toDOM;l&&(e[n]=l)}return e}function Ss(t){return t.document||window.document}var sy=new WeakMap;function Ub(t){let e=sy.get(t);return e===void 0&&sy.set(t,e=Vb(t)),e}function Vb(t){let e=null;function n(l){if(l&&typeof l=="object")if(Array.isArray(l))if(typeof l[0]=="string")e||(e=[]),e.push(l);else for(let i=0;i-1)throw new RangeError("Using an array from an attribute object as a DOM spec. This may be an attempted cross site scripting attack.");let o=i.indexOf(" ");o>0&&(n=i.slice(0,o),i=i.slice(o+1));let a,s=n?t.createElementNS(n,i):t.createElement(i),u=e[1],c=1;if(u&&typeof u=="object"&&u.nodeType==null&&!Array.isArray(u)){c=2;for(let f in u)if(u[f]!=null){let d=f.indexOf(" ");d>0?s.setAttributeNS(f.slice(0,d),f.slice(d+1),u[f]):f=="style"&&s.style?s.style.cssText=u[f]:s.setAttribute(f,u[f])}}for(let f=c;fc)throw new RangeError("Content hole must be the only child of its parent node");return{dom:s,contentDOM:s}}else if(typeof d=="string")s.appendChild(t.createTextNode(d));else{let{dom:h,contentDOM:p}=xs(t,d,n,l);if(s.appendChild(h),p){if(a)throw new RangeError("Multiple content holes");a=p}}}return{dom:s,contentDOM:a}}var Ny=65535,Ay=Math.pow(2,16);function qb(t,e){return t+e*Ay}function Ey(t){return t&Ny}function Yb(t){return(t-(t&Ny))/Ay}var Oy=1,Dy=2,As=4,ky=8,go=class{constructor(e,n,l){this.pos=e,this.delInfo=n,this.recover=l}get deleted(){return(this.delInfo&ky)>0}get deletedBefore(){return(this.delInfo&(Oy|As))>0}get deletedAfter(){return(this.delInfo&(Dy|As))>0}get deletedAcross(){return(this.delInfo&As)>0}},gn=class t{constructor(e,n=!1){if(this.ranges=e,this.inverted=n,!e.length&&t.empty)return t.empty}recover(e){let n=0,l=Ey(e);if(!this.inverted)for(let i=0;ie)break;let u=this.ranges[a+r],c=this.ranges[a+o],f=s+u;if(e<=f){let d=u?e==s?-1:e==f?1:n:n,h=s+i+(d<0?0:c);if(l)return h;let p=e==(n<0?s:f)?null:qb(a/3,e-s),b=e==s?Dy:e==f?Oy:As;return(n<0?e!=s:e!=f)&&(b|=ky),new go(h,b,p)}i+=c-u}return l?e+i:new go(e+i,0,null)}touches(e,n){let l=0,i=Ey(n),r=this.inverted?2:1,o=this.inverted?1:2;for(let a=0;ae)break;let u=this.ranges[a+r],c=s+u;if(e<=c&&a==i*3)return!0;l+=this.ranges[a+o]-u}return!1}forEach(e){let n=this.inverted?2:1,l=this.inverted?1:2;for(let i=0,r=0;i=0;n--){let i=e.getMirror(n);this.appendMap(e._maps[n].invert(),i!=null&&i>n?l-i-1:void 0)}}invert(){let e=new t;return e.appendMappingInverted(this),e}map(e,n=1){if(this.mirror)return this._map(e,n,!0);for(let l=this.from;lr&&s!o.isAtom||!a.type.allowsMarkType(this.mark.type)?o:o.mark(this.mark.addToSet(o.marks)),i),n.openStart,n.openEnd);return De.fromReplace(e,this.from,this.to,r)}invert(){return new wl(this.from,this.to,this.mark)}map(e){let n=e.mapResult(this.from,1),l=e.mapResult(this.to,-1);return n.deleted&&l.deleted||n.pos>=l.pos?null:new t(n.pos,l.pos,this.mark)}merge(e){return e instanceof t&&e.mark.eq(this.mark)&&this.from<=e.to&&this.to>=e.from?new t(Math.min(this.from,e.from),Math.max(this.to,e.to),this.mark):null}toJSON(){return{stepType:"addMark",mark:this.mark.toJSON(),from:this.from,to:this.to}}static fromJSON(e,n){if(typeof n.from!="number"||typeof n.to!="number")throw new RangeError("Invalid input for AddMarkStep.fromJSON");return new t(n.from,n.to,e.markFromJSON(n.mark))}};Ce.jsonID("addMark",yo);var wl=class t extends Ce{constructor(e,n,l){super(),this.from=e,this.to=n,this.mark=l}apply(e){let n=e.slice(this.from,this.to),l=new k(gd(n.content,i=>i.mark(this.mark.removeFromSet(i.marks)),e),n.openStart,n.openEnd);return De.fromReplace(e,this.from,this.to,l)}invert(){return new yo(this.from,this.to,this.mark)}map(e){let n=e.mapResult(this.from,1),l=e.mapResult(this.to,-1);return n.deleted&&l.deleted||n.pos>=l.pos?null:new t(n.pos,l.pos,this.mark)}merge(e){return e instanceof t&&e.mark.eq(this.mark)&&this.from<=e.to&&this.to>=e.from?new t(Math.min(this.from,e.from),Math.max(this.to,e.to),this.mark):null}toJSON(){return{stepType:"removeMark",mark:this.mark.toJSON(),from:this.from,to:this.to}}static fromJSON(e,n){if(typeof n.from!="number"||typeof n.to!="number")throw new RangeError("Invalid input for RemoveMarkStep.fromJSON");return new t(n.from,n.to,e.markFromJSON(n.mark))}};Ce.jsonID("removeMark",wl);var bo=class t extends Ce{constructor(e,n){super(),this.pos=e,this.mark=n}apply(e){let n=e.nodeAt(this.pos);if(!n)return De.fail("No node at mark step's position");let l=n.type.create(n.attrs,null,this.mark.addToSet(n.marks));return De.fromReplace(e,this.pos,this.pos+1,new k(D.from(l),0,n.isLeaf?0:1))}invert(e){let n=e.nodeAt(this.pos);if(n){let l=this.mark.addToSet(n.marks);if(l.length==n.marks.length){for(let i=0;il.pos?null:new t(n.pos,l.pos,i,r,this.slice,this.insert,this.structure)}toJSON(){let e={stepType:"replaceAround",from:this.from,to:this.to,gapFrom:this.gapFrom,gapTo:this.gapTo,insert:this.insert};return this.slice.size&&(e.slice=this.slice.toJSON()),this.structure&&(e.structure=!0),e}static fromJSON(e,n){if(typeof n.from!="number"||typeof n.to!="number"||typeof n.gapFrom!="number"||typeof n.gapTo!="number"||typeof n.insert!="number")throw new RangeError("Invalid input for ReplaceAroundStep.fromJSON");return new t(n.from,n.to,n.gapFrom,n.gapTo,k.fromJSON(e,n.slice),n.insert,!!n.structure)}};Ce.jsonID("replaceAround",ut);function pd(t,e,n){let l=t.resolve(e),i=n-e,r=l.depth;for(;i>0&&r>0&&l.indexAfter(r)==l.node(r).childCount;)r--,i--;if(i>0){let o=l.node(r).maybeChild(l.indexAfter(r));for(;i>0;){if(!o||o.isLeaf)return!0;o=o.firstChild,i--}}return!1}function Zb(t,e,n,l){let i=[],r=[],o,a;t.doc.nodesBetween(e,n,(s,u,c)=>{if(!s.isInline)return;let f=s.marks;if(!l.isInSet(f)&&c.type.allowsMarkType(l.type)){let d=Math.max(u,e),h=Math.min(u+s.nodeSize,n),p=l.addToSet(f);for(let b=0;bt.step(s)),r.forEach(s=>t.step(s))}function jb(t,e,n,l){let i=[],r=0;t.doc.nodesBetween(e,n,(o,a)=>{if(!o.isInline)return;r++;let s=null;if(l instanceof co){let u=o.marks,c;for(;c=l.isInSet(u);)(s||(s=[])).push(c),u=c.removeFromSet(u)}else l?l.isInSet(o.marks)&&(s=[l]):s=o.marks;if(s&&s.length){let u=Math.min(a+o.nodeSize,n);for(let c=0;ct.step(new wl(o.from,o.to,o.style)))}function yd(t,e,n,l=n.contentMatch,i=!0){let r=t.doc.nodeAt(e),o=[],a=e+1;for(let s=0;s=0;s--)t.step(o[s])}function Gb(t,e,n){return(e==0||t.canReplace(e,t.childCount))&&(n==t.childCount||t.canReplace(0,n))}function ws(t){let n=t.parent.content.cutByIndex(t.startIndex,t.endIndex);for(let l=t.depth,i=0,r=0;;--l){let o=t.$from.node(l),a=t.$from.index(l)+i,s=t.$to.indexAfter(l)-r;if(ln;p--)b||l.index(p)>0?(b=!0,c=D.from(l.node(p).copy(c)),f++):s--;let d=D.empty,h=0;for(let p=r,b=!1;p>n;p--)b||i.after(p+1)=0;o--){if(l.size){let a=n[o].type.contentMatch.matchFragment(l);if(!a||!a.validEnd)throw new RangeError("Wrapper type given to Transform.wrap does not form valid content of its parent wrapper")}l=D.from(n[o].type.create(n[o].attrs,l))}let i=e.start,r=e.end;t.step(new ut(i,r,i,r,new k(l,0,0),n.length,!0))}function Kb(t,e,n,l,i){if(!l.isTextblock)throw new RangeError("Type given to setBlockType should be a textblock");let r=t.steps.length;t.doc.nodesBetween(e,n,(o,a)=>{let s=typeof i=="function"?i(o):i;if(o.isTextblock&&!o.hasMarkup(l,s)&&Qb(t.doc,t.mapping.slice(r).map(a),l)){let u=null;if(l.schema.linebreakReplacement){let h=l.whitespace=="pre",p=!!l.contentMatch.matchType(l.schema.linebreakReplacement);h&&!p?u=!1:!h&&p&&(u=!0)}u===!1&&zy(t,o,a,r),yd(t,t.mapping.slice(r).map(a,1),l,void 0,u===null);let c=t.mapping.slice(r),f=c.map(a,1),d=c.map(a+o.nodeSize,1);return t.step(new ut(f,d,f+1,d-1,new k(D.from(l.create(s,null,o.marks)),0,0),1,!0)),u===!0&&wy(t,o,a,r),!1}})}function wy(t,e,n,l){e.forEach((i,r)=>{if(i.isText){let o,a=/\r?\n|\r/g;for(;o=a.exec(i.text);){let s=t.mapping.slice(l).map(n+1+r+o.index);t.replaceWith(s,s+1,e.type.schema.linebreakReplacement.create())}}})}function zy(t,e,n,l){e.forEach((i,r)=>{if(i.type==i.type.schema.linebreakReplacement){let o=t.mapping.slice(l).map(n+1+r);t.replaceWith(o,o+1,e.type.schema.text(` +`))}})}function Qb(t,e,n){let l=t.resolve(e),i=l.index();return l.parent.canReplaceWith(i,i+1,n)}function Fb(t,e,n,l,i){let r=t.doc.nodeAt(e);if(!r)throw new RangeError("No node at given position");n||(n=r.type);let o=n.create(l,null,i||r.marks);if(r.isLeaf)return t.replaceWith(e,e+r.nodeSize,o);if(!n.validContent(r.content))throw new RangeError("Invalid content for node type "+n.name);t.step(new ut(e,e+r.nodeSize,e+1,e+r.nodeSize-1,new k(D.from(o),0,0),1,!0))}function So(t,e,n=1,l){let i=t.resolve(e),r=i.depth-n,o=l&&l[l.length-1]||i.parent;if(r<0||i.parent.type.spec.isolating||!i.parent.canReplace(i.index(),i.parent.childCount)||!o.type.validContent(i.parent.content.cutByIndex(i.index(),i.parent.childCount)))return!1;for(let u=i.depth-1,c=n-2;u>r;u--,c--){let f=i.node(u),d=i.index(u);if(f.type.spec.isolating)return!1;let h=f.content.cutByIndex(d,f.childCount),p=l&&l[c+1];p&&(h=h.replaceChild(0,p.type.create(p.attrs)));let b=l&&l[c]||f;if(!f.canReplace(d+1,f.childCount)||!b.type.validContent(h))return!1}let a=i.indexAfter(r),s=l&&l[0];return i.node(r).canReplaceWith(a,a,s?s.type:i.node(r+1).type)}function Jb(t,e,n=1,l){let i=t.doc.resolve(e),r=D.empty,o=D.empty;for(let a=i.depth,s=i.depth-n,u=n-1;a>s;a--,u--){r=D.from(i.node(a).copy(r));let c=l&&l[u];o=D.from(c?c.type.create(c.attrs,o):i.node(a).copy(o))}t.step(new Je(e,e,new k(r.append(o),n,n),!0))}function bd(t,e){let n=t.resolve(e),l=n.index();return Wb(n.nodeBefore,n.nodeAfter)&&n.parent.canReplace(l,l+1)}function Pb(t,e){e.content.size||t.type.compatibleContent(e.type);let n=t.contentMatchAt(t.childCount),{linebreakReplacement:l}=t.type.schema;for(let i=0;i=0;i--){let r=l.index(i);if(l.node(i).canReplaceWith(r,r,n))return l.before(i+1);if(r>0)return null}if(l.parentOffset==l.parent.content.size)for(let i=l.depth-1;i>=0;i--){let r=l.indexAfter(i);if(l.node(i).canReplaceWith(r,r,n))return l.after(i+1);if(r=0;o--){let a=o==l.depth?0:l.pos<=(l.start(o+1)+l.end(o+1))/2?-1:1,s=l.index(o)+(a>0?1:0),u=l.node(o),c=!1;if(r==1)c=u.canReplace(s,s,i);else{let f=u.contentMatchAt(s).findWrapping(i.firstChild.type);c=f&&u.canReplaceWith(s,s,f[0])}if(c)return a==0?l.pos:a<0?l.before(o+1):l.after(o+1)}return null}function zs(t,e,n=e,l=k.empty){if(e==n&&!l.size)return null;let i=t.resolve(e),r=t.resolve(n);return Ly(i,r,l)?new Je(e,n,l):new md(i,r,l).fit()}function Ly(t,e,n){return!n.openStart&&!n.openEnd&&t.start()==e.start()&&t.parent.canReplace(t.index(),e.index(),n.content)}var md=class{constructor(e,n,l){this.$from=e,this.$to=n,this.unplaced=l,this.frontier=[],this.placed=D.empty;for(let i=0;i<=e.depth;i++){let r=e.node(i);this.frontier.push({type:r.type,match:r.contentMatchAt(e.indexAfter(i))})}for(let i=e.depth;i>0;i--)this.placed=D.from(e.node(i).copy(this.placed))}get depth(){return this.frontier.length-1}fit(){for(;this.unplaced.size;){let u=this.findFittable();u?this.placeNodes(u):this.openMore()||this.dropNode()}let e=this.mustMoveInline(),n=this.placed.size-this.depth-this.$from.depth,l=this.$from,i=this.close(e<0?this.$to:l.doc.resolve(e));if(!i)return null;let r=this.placed,o=l.depth,a=i.depth;for(;o&&a&&r.childCount==1;)r=r.firstChild.content,o--,a--;let s=new k(r,o,a);return e>-1?new ut(l.pos,e,this.$to.pos,this.$to.end(),s,n):s.size||l.pos!=this.$to.pos?new Je(l.pos,i.pos,s):null}findFittable(){let e=this.unplaced.openStart;for(let n=this.unplaced.content,l=0,i=this.unplaced.openEnd;l1&&(i=0),r.type.spec.isolating&&i<=l){e=l;break}n=r.content}for(let n=1;n<=2;n++)for(let l=n==1?e:this.unplaced.openStart;l>=0;l--){let i,r=null;l?(r=fd(this.unplaced.content,l-1).firstChild,i=r.content):i=this.unplaced.content;let o=i.firstChild;for(let a=this.depth;a>=0;a--){let{type:s,match:u}=this.frontier[a],c,f=null;if(n==1&&(o?u.matchType(o.type)||(f=u.fillBefore(D.from(o),!1)):r&&s.compatibleContent(r.type)))return{sliceDepth:l,frontierDepth:a,parent:r,inject:f};if(n==2&&o&&(c=u.findWrapping(o.type)))return{sliceDepth:l,frontierDepth:a,parent:r,wrap:c};if(r&&u.matchType(r.type))break}}}openMore(){let{content:e,openStart:n,openEnd:l}=this.unplaced,i=fd(e,n);return!i.childCount||i.firstChild.isLeaf?!1:(this.unplaced=new k(e,n+1,Math.max(l,i.size+n>=e.size-l?n+1:0)),!0)}dropNode(){let{content:e,openStart:n,openEnd:l}=this.unplaced,i=fd(e,n);if(i.childCount<=1&&n>0){let r=e.size-n<=n+i.size;this.unplaced=new k(po(e,n-1,1),n-1,r?n-1:l)}else this.unplaced=new k(po(e,n,1),n,l)}placeNodes({sliceDepth:e,frontierDepth:n,parent:l,inject:i,wrap:r}){for(;this.depth>n;)this.closeFrontierNode();if(r)for(let b=0;b1||s==0||b.content.size)&&(f=v,c.push(_y(b.mark(d.allowedMarks(b.marks)),u==1?s:0,u==a.childCount?h:-1)))}let p=u==a.childCount;p||(h=-1),this.placed=mo(this.placed,n,D.from(c)),this.frontier[n].match=f,p&&h<0&&l&&l.type==this.frontier[this.depth].type&&this.frontier.length>1&&this.closeFrontierNode();for(let b=0,v=a;b1&&i==this.$to.end(--l);)++i;return i}findCloseLevel(e){e:for(let n=Math.min(this.depth,e.depth);n>=0;n--){let{match:l,type:i}=this.frontier[n],r=n=0;a--){let{match:s,type:u}=this.frontier[a],c=dd(e,a,u,s,!0);if(!c||c.childCount)continue e}return{depth:n,fit:o,move:r?e.doc.resolve(e.after(n+1)):e}}}}close(e){let n=this.findCloseLevel(e);if(!n)return null;for(;this.depth>n.depth;)this.closeFrontierNode();n.fit.childCount&&(this.placed=mo(this.placed,n.depth,n.fit)),e=n.move;for(let l=n.depth+1;l<=e.depth;l++){let i=e.node(l),r=i.type.contentMatch.fillBefore(i.content,!0,e.index(l));this.openFrontierNode(i.type,i.attrs,r)}return e}openFrontierNode(e,n=null,l){let i=this.frontier[this.depth];i.match=i.match.matchType(e),this.placed=mo(this.placed,this.depth,D.from(e.create(n,l))),this.frontier.push({type:e,match:e.contentMatch})}closeFrontierNode(){let n=this.frontier.pop().match.fillBefore(D.empty,!0);n.childCount&&(this.placed=mo(this.placed,this.frontier.length,n))}};function po(t,e,n){return e==0?t.cutByIndex(n,t.childCount):t.replaceChild(0,t.firstChild.copy(po(t.firstChild.content,e-1,n)))}function mo(t,e,n){return e==0?t.append(n):t.replaceChild(t.childCount-1,t.lastChild.copy(mo(t.lastChild.content,e-1,n)))}function fd(t,e){for(let n=0;n1&&(l=l.replaceChild(0,_y(l.firstChild,e-1,l.childCount==1?n-1:0))),e>0&&(l=t.type.contentMatch.fillBefore(l).append(l),n<=0&&(l=l.append(t.type.contentMatch.matchFragment(l).fillBefore(D.empty,!0)))),t.copy(l)}function dd(t,e,n,l,i){let r=t.node(e),o=i?t.indexAfter(e):t.index(e);if(o==r.childCount&&!n.compatibleContent(r.type))return null;let a=l.fillBefore(r.content,!0,o);return a&&!tS(n,r.content,o)?a:null}function tS(t,e,n){for(let l=n;l0;d--,h--){let p=i.node(d).type.spec;if(p.defining||p.definingAsContext||p.isolating)break;o.indexOf(d)>-1?a=d:i.before(d)==h&&o.splice(1,0,-d)}let s=o.indexOf(a),u=[],c=l.openStart;for(let d=l.content,h=0;;h++){let p=d.firstChild;if(u.push(p),h==l.openStart)break;d=p.content}for(let d=c-1;d>=0;d--){let h=u[d],p=nS(h.type);if(p&&!h.sameMarkup(i.node(Math.abs(a)-1)))c=d;else if(p||!h.type.isTextblock)break}for(let d=l.openStart;d>=0;d--){let h=(d+c+1)%(l.openStart+1),p=u[h];if(p)for(let b=0;b=0&&(t.replace(e,n,l),!(t.steps.length>f));d--){let h=o[d];h<0||(e=i.before(h),n=r.after(h))}}function By(t,e,n,l,i){if(el){let r=i.contentMatchAt(0),o=r.fillBefore(t).append(t);t=o.append(r.matchFragment(o).fillBefore(D.empty,!0))}return t}function iS(t,e,n,l){if(!l.isInline&&e==n&&t.doc.resolve(e).parent.content.size){let i=eS(t.doc,e,l.type);i!=null&&(e=n=i)}t.replaceRange(e,n,new k(D.from(l),0,0))}function rS(t,e,n){let l=t.doc.resolve(e),i=t.doc.resolve(n);if(l.parent.isTextblock&&i.parent.isTextblock&&l.start()!=i.start()&&l.parentOffset==0&&i.parentOffset==0){let o=l.sharedDepth(n),a=!1;for(let s=l.depth;s>o;s--)l.node(s).type.spec.isolating&&(a=!0);for(let s=i.depth;s>o;s--)i.node(s).type.spec.isolating&&(a=!0);if(!a){for(let s=l.depth;s>0&&e==l.start(s);s--)e=l.before(s);for(let s=i.depth;s>0&&n==i.start(s);s--)n=i.before(s);l=t.doc.resolve(e),i=t.doc.resolve(n)}}let r=Hy(l,i);for(let o=0;o0&&(s||l.node(a-1).canReplace(l.index(a-1),i.indexAfter(a-1))))return t.delete(l.before(a),i.after(a))}for(let o=1;o<=l.depth&&o<=i.depth;o++)if(e-l.start(o)==l.depth-o&&n>l.end(o)&&i.end(o)-n!=i.depth-o&&l.start(o-1)==i.start(o-1)&&l.node(o-1).canReplace(l.index(o-1),i.index(o-1)))return t.delete(l.before(o),n);t.delete(e,n)}function Hy(t,e){let n=[],l=Math.min(t.depth,e.depth);for(let i=l;i>=0;i--){let r=t.start(i);if(re.pos+(e.depth-i)||t.node(i).type.spec.isolating||e.node(i).type.spec.isolating)break;(r==e.start(i)||i==t.depth&&i==e.depth&&t.parent.inlineContent&&e.parent.inlineContent&&i&&e.start(i-1)==r-1)&&n.push(i)}return n}var Os=class t extends Ce{constructor(e,n,l){super(),this.pos=e,this.attr=n,this.value=l}apply(e){let n=e.nodeAt(this.pos);if(!n)return De.fail("No node at attribute step's position");let l=Object.create(null);for(let r in n.attrs)l[r]=n.attrs[r];l[this.attr]=this.value;let i=n.type.create(l,null,n.marks);return De.fromReplace(e,this.pos,this.pos+1,new k(D.from(i),0,n.isLeaf?0:1))}getMap(){return gn.empty}invert(e){return new t(this.pos,this.attr,e.nodeAt(this.pos).attrs[this.attr])}map(e){let n=e.mapResult(this.pos,1);return n.deletedAfter?null:new t(n.pos,this.attr,this.value)}toJSON(){return{stepType:"attr",pos:this.pos,attr:this.attr,value:this.value}}static fromJSON(e,n){if(typeof n.pos!="number"||typeof n.attr!="string")throw new RangeError("Invalid input for AttrStep.fromJSON");return new t(n.pos,n.attr,n.value)}};Ce.jsonID("attr",Os);var Ds=class t extends Ce{constructor(e,n){super(),this.attr=e,this.value=n}apply(e){let n=Object.create(null);for(let i in e.attrs)n[i]=e.attrs[i];n[this.attr]=this.value;let l=e.type.create(n,e.content,e.marks);return De.ok(l)}getMap(){return gn.empty}invert(e){return new t(this.attr,e.attrs[this.attr])}map(e){return this}toJSON(){return{stepType:"docAttr",attr:this.attr,value:this.value}}static fromJSON(e,n){if(typeof n.attr!="string")throw new RangeError("Invalid input for DocAttrStep.fromJSON");return new t(n.attr,n.value)}};Ce.jsonID("docAttr",Ds);var qi=class extends Error{};qi=function t(e){let n=Error.call(this,e);return n.__proto__=t.prototype,n};qi.prototype=Object.create(Error.prototype);qi.prototype.constructor=qi;qi.prototype.name="TransformError";var ks=class{constructor(e){this.doc=e,this.steps=[],this.docs=[],this.mapping=new hd}get before(){return this.docs.length?this.docs[0]:this.doc}step(e){let n=this.maybeStep(e);if(n.failed)throw new qi(n.failed);return this}maybeStep(e){let n=e.apply(this.doc);return n.failed||this.addStep(e,n.doc),n}get docChanged(){return this.steps.length>0}changedRange(){let e=1e9,n=-1e9;for(let l=0;l{e=Math.min(e,a),n=Math.max(n,s)})}return e==1e9?null:{from:e,to:n}}addStep(e,n){this.docs.push(this.doc),this.steps.push(e),this.mapping.appendMap(e.getMap()),this.doc=n}replace(e,n=e,l=k.empty){let i=zs(this.doc,e,n,l);return i&&this.step(i),this}replaceWith(e,n,l){return this.replace(e,n,new k(D.from(l),0,0))}delete(e,n){return this.replace(e,n,k.empty)}insert(e,n){return this.replaceWith(e,e,n)}replaceRange(e,n,l){return lS(this,e,n,l),this}replaceRangeWith(e,n,l){return iS(this,e,n,l),this}deleteRange(e,n){return rS(this,e,n),this}lift(e,n){return Xb(this,e,n),this}join(e,n=1){return $b(this,e,n),this}wrap(e,n){return Ib(this,e,n),this}setBlockType(e,n=e,l,i=null){return Kb(this,e,n,l,i),this}setNodeMarkup(e,n,l=null,i){return Fb(this,e,n,l,i),this}setNodeAttribute(e,n,l){return this.step(new Os(e,n,l)),this}setDocAttribute(e,n){return this.step(new Ds(e,n)),this}addNodeMark(e,n){return this.step(new bo(e,n)),this}removeNodeMark(e,n){let l=this.doc.nodeAt(e);if(!l)throw new RangeError("No node at position "+e);if(n instanceof Z)n.isInSet(l.marks)&&this.step(new Vi(e,n));else{let i=l.marks,r,o=[];for(;r=n.isInSet(i);)o.push(new Vi(e,r)),i=r.removeFromSet(i);for(let a=o.length-1;a>=0;a--)this.step(o[a])}return this}split(e,n=1,l){return Jb(this,e,n,l),this}addMark(e,n,l){return Zb(this,e,n,l),this}removeMark(e,n,l){return jb(this,e,n,l),this}clearIncompatible(e,n,l){return yd(this,e,n,l),this}};var Sd=Object.create(null),Y=class{constructor(e,n,l){this.$anchor=e,this.$head=n,this.ranges=l||[new Ls(e.min(n),e.max(n))]}get anchor(){return this.$anchor.pos}get head(){return this.$head.pos}get from(){return this.$from.pos}get to(){return this.$to.pos}get $from(){return this.ranges[0].$from}get $to(){return this.ranges[0].$to}get empty(){let e=this.ranges;for(let n=0;n=0;r--){let o=n<0?Yi(e.node(0),e.node(r),e.before(r+1),e.index(r),n,l):Yi(e.node(0),e.node(r),e.after(r+1),e.index(r)+1,n,l);if(o)return o}return null}static near(e,n=1){return this.findFrom(e,n)||this.findFrom(e,-n)||new ct(e.node(0))}static atStart(e){return Yi(e,e,0,0,1)||new ct(e)}static atEnd(e){return Yi(e,e,e.content.size,e.childCount,-1)||new ct(e)}static fromJSON(e,n){if(!n||!n.type)throw new RangeError("Invalid input for Selection.fromJSON");let l=Sd[n.type];if(!l)throw new RangeError(`No selection type ${n.type} defined`);return l.fromJSON(e,n)}static jsonID(e,n){if(e in Sd)throw new RangeError("Duplicate use of selection JSON ID "+e);return Sd[e]=n,n.prototype.jsonID=e,n}getBookmark(){return j.between(this.$anchor,this.$head).getBookmark()}};Y.prototype.visible=!0;var Ls=class{constructor(e,n){this.$from=e,this.$to=n}},Uy=!1;function Vy(t){!Uy&&!t.parent.inlineContent&&(Uy=!0,console.warn("TextSelection endpoint not pointing into a node with inline content ("+t.parent.type.name+")"))}var j=class t extends Y{constructor(e,n=e){Vy(e),Vy(n),super(e,n)}get $cursor(){return this.$anchor.pos==this.$head.pos?this.$head:null}map(e,n){let l=e.resolve(n.map(this.head));if(!l.parent.inlineContent)return Y.near(l);let i=e.resolve(n.map(this.anchor));return new t(i.parent.inlineContent?i:l,l)}replace(e,n=k.empty){if(super.replace(e,n),n==k.empty){let l=this.$from.marksAcross(this.$to);l&&e.ensureMarks(l)}}eq(e){return e instanceof t&&e.anchor==this.anchor&&e.head==this.head}getBookmark(){return new _s(this.anchor,this.head)}toJSON(){return{type:"text",anchor:this.anchor,head:this.head}}static fromJSON(e,n){if(typeof n.anchor!="number"||typeof n.head!="number")throw new RangeError("Invalid input for TextSelection.fromJSON");return new t(e.resolve(n.anchor),e.resolve(n.head))}static create(e,n,l=n){let i=e.resolve(n);return new this(i,l==n?i:e.resolve(l))}static between(e,n,l){let i=e.pos-n.pos;if((!l||i)&&(l=i>=0?1:-1),!n.parent.inlineContent){let r=Y.findFrom(n,l,!0)||Y.findFrom(n,-l,!0);if(r)n=r.$head;else return Y.near(n,l)}return e.parent.inlineContent||(i==0?e=n:(e=(Y.findFrom(e,-l,!0)||Y.findFrom(e,l,!0)).$anchor,e.pos0?0:1);i>0?o=0;o+=i){let a=e.child(o);if(a.isAtom){if(!r&&B.isSelectable(a))return B.create(t,n-(i<0?a.nodeSize:0))}else{let s=Yi(t,a,n+i,i<0?a.childCount:0,i,r);if(s)return s}n+=a.nodeSize*i}return null}function qy(t,e,n){let l=t.steps.length-1;if(l{o==null&&(o=c)}),t.setSelection(Y.near(t.doc.resolve(o),n))}var Yy=1,Rs=2,Zy=4,Cd=class extends ks{constructor(e){super(e.doc),this.curSelectionFor=0,this.updated=0,this.meta=Object.create(null),this.time=Date.now(),this.curSelection=e.selection,this.storedMarks=e.storedMarks}get selection(){return this.curSelectionFor0}setStoredMarks(e){return this.storedMarks=e,this.updated|=Rs,this}ensureMarks(e){return Z.sameSet(this.storedMarks||this.selection.$from.marks(),e)||this.setStoredMarks(e),this}addStoredMark(e){return this.ensureMarks(e.addToSet(this.storedMarks||this.selection.$head.marks()))}removeStoredMark(e){return this.ensureMarks(e.removeFromSet(this.storedMarks||this.selection.$head.marks()))}get storedMarksSet(){return(this.updated&Rs)>0}addStep(e,n){super.addStep(e,n),this.updated=this.updated&~Rs,this.storedMarks=null}setTime(e){return this.time=e,this}replaceSelection(e){return this.selection.replace(this,e),this}replaceSelectionWith(e,n=!0){let l=this.selection;return n&&(e=e.mark(this.storedMarks||(l.empty?l.$from.marks():l.$from.marksAcross(l.$to)||Z.none))),l.replaceWith(this,e),this}deleteSelection(){return this.selection.replace(this),this}insertText(e,n,l){let i=this.doc.type.schema;if(n==null)return e?this.replaceSelectionWith(i.text(e),!0):this.deleteSelection();{if(l==null&&(l=n),!e)return this.deleteRange(n,l);let r=this.storedMarks;if(!r){let o=this.doc.resolve(n);r=l==n?o.marks():o.marksAcross(this.doc.resolve(l))}return this.replaceRangeWith(n,l,i.text(e,r)),!this.selection.empty&&this.selection.to==n+e.length&&this.setSelection(Y.near(this.selection.$to)),this}}setMeta(e,n){return this.meta[typeof e=="string"?e:e.key]=n,this}getMeta(e){return this.meta[typeof e=="string"?e:e.key]}get isGeneric(){for(let e in this.meta)return!1;return!0}scrollIntoView(){return this.updated|=Zy,this}get scrolledIntoView(){return(this.updated&Zy)>0}};function jy(t,e){return!e||!t?t:t.bind(e)}var zl=class{constructor(e,n,l){this.name=e,this.init=jy(n.init,l),this.apply=jy(n.apply,l)}},aS=[new zl("doc",{init(t){return t.doc||t.schema.topNodeType.createAndFill()},apply(t){return t.doc}}),new zl("selection",{init(t,e){return t.selection||Y.atStart(e.doc)},apply(t){return t.selection}}),new zl("storedMarks",{init(t){return t.storedMarks||null},apply(t,e,n,l){return l.selection.$cursor?t.storedMarks:null}}),new zl("scrollToSelection",{init(){return 0},apply(t,e){return t.scrolledIntoView?e+1:e}})],xo=class{constructor(e,n){this.schema=e,this.plugins=[],this.pluginsByKey=Object.create(null),this.fields=aS.slice(),n&&n.forEach(l=>{if(this.pluginsByKey[l.key])throw new RangeError("Adding different instances of a keyed plugin ("+l.key+")");this.plugins.push(l),this.pluginsByKey[l.key]=l,l.spec.state&&this.fields.push(new zl(l.key,l.spec.state,l))})}},Bs=class t{constructor(e){this.config=e}get schema(){return this.config.schema}get plugins(){return this.config.plugins}apply(e){return this.applyTransaction(e).state}filterTransaction(e,n=-1){for(let l=0;ll.toJSON())),e&&typeof e=="object")for(let l in e){if(l=="doc"||l=="selection")throw new RangeError("The JSON fields `doc` and `selection` are reserved");let i=e[l],r=i.spec.state;r&&r.toJSON&&(n[l]=r.toJSON.call(i,this[i.key]))}return n}static fromJSON(e,n,l){if(!n)throw new RangeError("Invalid input for EditorState.fromJSON");if(!e.schema)throw new RangeError("Required config field 'schema' missing");let i=new xo(e.schema,e.plugins),r=new t(i);return i.fields.forEach(o=>{if(o.name=="doc")r.doc=Yt.fromJSON(e.schema,n.doc);else if(o.name=="selection")r.selection=Y.fromJSON(r.doc,n.selection);else if(o.name=="storedMarks")n.storedMarks&&(r.storedMarks=n.storedMarks.map(e.schema.markFromJSON));else{if(l)for(let a in l){let s=l[a],u=s.spec.state;if(s.key==o.name&&u&&u.fromJSON&&Object.prototype.hasOwnProperty.call(n,a)){r[o.name]=u.fromJSON.call(s,e,n[a],r);return}}r[o.name]=o.init(e,r)}}),r}};function Gy(t,e,n){for(let l in t){let i=t[l];i instanceof Function?i=i.bind(e):l=="handleDOMEvents"&&(i=Gy(i,e,{})),n[l]=i}return n}var Hs=class{constructor(e){this.spec=e,this.props={},e.props&&Gy(e.props,this,this.props),this.key=e.key?e.key.key:Xy("plugin")}getState(e){return e[this.key]}},xd=Object.create(null);function Xy(t){return t in xd?t+"$"+ ++xd[t]:(xd[t]=0,t+"$")}var Us=class{constructor(e="key"){this.key=Xy(e)}get(e){return e.config.pluginsByKey[this.key]}getState(e){return e[this.key]}};var Ky=(t,e)=>t.selection.empty?!1:(e&&e(t.tr.deleteSelection().scrollIntoView()),!0);function sS(t,e){let{$cursor:n}=t.selection;return!n||(e?!e.endOfTextblock("backward",t):n.parentOffset>0)?null:n}var uS=(t,e,n)=>{let l=sS(t,n);if(!l)return!1;let i=Qy(l);if(!i){let o=l.blockRange(),a=o&&ws(o);return a==null?!1:(e&&e(t.tr.lift(o,a).scrollIntoView()),!0)}let r=i.nodeBefore;if(Jy(t,i,e,-1))return!0;if(l.parent.content.size==0&&(Zi(r,"end")||B.isSelectable(r)))for(let o=l.depth;;o--){let a=zs(t.doc,l.before(o),l.after(o),k.empty);if(a&&a.slice.size1)break}return r.isAtom&&i.depth==l.depth-1?(e&&e(t.tr.delete(i.pos-r.nodeSize,i.pos).scrollIntoView()),!0):!1};function Zi(t,e,n=!1){for(let l=t;l;l=e=="start"?l.firstChild:l.lastChild){if(l.isTextblock)return!0;if(n&&l.childCount!=1)return!1}return!1}var cS=(t,e,n)=>{let{$head:l,empty:i}=t.selection,r=l;if(!i)return!1;if(l.parent.isTextblock){if(n?!n.endOfTextblock("backward",t):l.parentOffset>0)return!1;r=Qy(l)}let o=r&&r.nodeBefore;return!o||!B.isSelectable(o)?!1:(e&&e(t.tr.setSelection(B.create(t.doc,r.pos-o.nodeSize)).scrollIntoView()),!0)};function Qy(t){if(!t.parent.type.spec.isolating)for(let e=t.depth-1;e>=0;e--){if(t.index(e)>0)return t.doc.resolve(t.before(e+1));if(t.node(e).type.spec.isolating)break}return null}function fS(t,e){let{$cursor:n}=t.selection;return!n||(e?!e.endOfTextblock("forward",t):n.parentOffset{let l=fS(t,n);if(!l)return!1;let i=Fy(l);if(!i)return!1;let r=i.nodeAfter;if(Jy(t,i,e,1))return!0;if(l.parent.content.size==0&&(Zi(r,"start")||B.isSelectable(r))){let o=zs(t.doc,l.before(),l.after(),k.empty);if(o&&o.slice.size{let{$head:l,empty:i}=t.selection,r=l;if(!i)return!1;if(l.parent.isTextblock){if(n?!n.endOfTextblock("forward",t):l.parentOffset=0;e--){let n=t.node(e);if(t.index(e)+1{let{$head:n,$anchor:l}=t.selection;return!n.parent.type.spec.code||!n.sameParent(l)?!1:(e&&e(t.tr.insertText(` +`).scrollIntoView()),!0)};function Td(t){for(let e=0;e{let{$head:n,$anchor:l}=t.selection;if(!n.parent.type.spec.code||!n.sameParent(l))return!1;let i=n.node(-1),r=n.indexAfter(-1),o=Td(i.contentMatchAt(r));if(!o||!i.canReplaceWith(r,r,o))return!1;if(e){let a=n.after(),s=t.tr.replaceWith(a,a,o.createAndFill());s.setSelection(Y.near(s.doc.resolve(a),1)),e(s.scrollIntoView())}return!0},gS=(t,e)=>{let n=t.selection,{$from:l,$to:i}=n;if(n instanceof ct||l.parent.inlineContent||i.parent.inlineContent)return!1;let r=Td(i.parent.contentMatchAt(i.indexAfter()));if(!r||!r.isTextblock)return!1;if(e){let o=(!l.parentOffset&&i.index(){let{$cursor:n}=t.selection;if(!n||n.parent.content.size)return!1;if(n.depth>1&&n.after()!=n.end(-1)){let r=n.before();if(So(t.doc,r))return e&&e(t.tr.split(r).scrollIntoView()),!0}let l=n.blockRange(),i=l&&ws(l);return i==null?!1:(e&&e(t.tr.lift(l,i).scrollIntoView()),!0)};function bS(t){return(e,n)=>{let{$from:l,$to:i}=e.selection;if(e.selection instanceof B&&e.selection.node.isBlock)return!l.parentOffset||!So(e.doc,l.pos)?!1:(n&&n(e.tr.split(l.pos).scrollIntoView()),!0);if(!l.depth)return!1;let r=[],o,a,s=!1,u=!1;for(let h=l.depth;;h--)if(l.node(h).isBlock){s=l.end(h)==l.pos+(l.depth-h),u=l.start(h)==l.pos-(l.depth-h),a=Td(l.node(h-1).contentMatchAt(l.indexAfter(h-1)));let b=t&&t(i.parent,s,l);r.unshift(b||(s&&a?{type:a}:null)),o=h;break}else{if(h==1)return!1;r.unshift(null)}let c=e.tr;(e.selection instanceof j||e.selection instanceof ct)&&c.deleteSelection();let f=c.mapping.map(l.pos),d=So(c.doc,f,r.length,r);if(d||(r[0]=a?{type:a}:null,d=So(c.doc,f,r.length,r)),!d)return!1;if(c.split(f,r.length,r),!s&&u&&l.node(o).type!=a){let h=c.mapping.map(l.before(o)),p=c.doc.resolve(h);a&&l.node(o-1).canReplaceWith(p.index(),p.index()+1,a)&&c.setNodeMarkup(c.mapping.map(l.before(o)),a)}return n&&n(c.scrollIntoView()),!0}}var Ed=bS();var SS=(t,e)=>(e&&e(t.tr.setSelection(new ct(t.doc))),!0);function xS(t,e,n){let l=e.nodeBefore,i=e.nodeAfter,r=e.index();return!l||!i||!l.type.compatibleContent(i.type)?!1:!l.content.size&&e.parent.canReplace(r-1,r)?(n&&n(t.tr.delete(e.pos-l.nodeSize,e.pos).scrollIntoView()),!0):!e.parent.canReplace(r,r+1)||!(i.isTextblock||bd(t.doc,e.pos))?!1:(n&&n(t.tr.join(e.pos).scrollIntoView()),!0)}function Jy(t,e,n,l){let i=e.nodeBefore,r=e.nodeAfter,o,a,s=i.type.spec.isolating||r.type.spec.isolating;if(!s&&xS(t,e,n))return!0;let u=!s&&e.parent.canReplace(e.index(),e.index()+1);if(u&&(o=(a=i.contentMatchAt(i.childCount)).findWrapping(r.type))&&a.matchType(o[0]||r.type).validEnd){if(n){let h=e.pos+r.nodeSize,p=D.empty;for(let g=o.length-1;g>=0;g--)p=D.from(o[g].create(null,p));p=D.from(i.copy(p));let b=t.tr.step(new ut(e.pos-1,h,e.pos,h,new k(p,1,0),o.length,!0)),v=b.doc.resolve(h+2*o.length);v.nodeAfter&&v.nodeAfter.type==i.type&&bd(b.doc,v.pos)&&b.join(v.pos),n(b.scrollIntoView())}return!0}let c=r.type.spec.isolating||l>0&&s?null:Y.findFrom(e,1),f=c&&c.$from.blockRange(c.$to),d=f&&ws(f);if(d!=null&&d>=e.depth)return n&&n(t.tr.lift(f,d).scrollIntoView()),!0;if(u&&Zi(r,"start",!0)&&Zi(i,"end")){let h=i,p=[];for(;p.push(h),!h.isTextblock;)h=h.lastChild;let b=r,v=1;for(;!b.isTextblock;b=b.firstChild)v++;if(h.canReplace(h.childCount,h.childCount,b.content)){if(n){let g=D.empty;for(let S=p.length-1;S>=0;S--)g=D.from(p[S].copy(g));let y=t.tr.step(new ut(e.pos-p.length,e.pos+r.nodeSize,e.pos+v,e.pos+r.nodeSize-v,new k(g,p.length,0),0,!0));n(y.scrollIntoView())}return!0}}return!1}function Py(t){return function(e,n){let l=e.selection,i=t<0?l.$from:l.$to,r=i.depth;for(;i.node(r).isInline;){if(!r)return!1;r--}return i.node(r).isTextblock?(n&&n(e.tr.setSelection(j.create(e.doc,t<0?i.start(r):i.end(r)))),!0):!1}}var vS=Py(-1),CS=Py(1);function Nd(...t){return function(e,n,l){for(let i=0;i0){if(t.contentEditable=="false")return null;t=t.childNodes[e-1],e=Tt(t)}else if(t.parentNode&&!Oo(t))e=ke(t),t=t.parentNode;else return null}}function AS(t,e){for(;;){if(t.nodeType==3&&e2),Mt=Qi||(Zt?/Mac/.test(Zt.platform):!1),R2=Zt?/Win/.test(Zt.platform):!1,Sn=/Android \d/.test(rl),Do=!!$y&&"webkitFontSmoothing"in $y.documentElement.style,wS=Do?+(/\bAppleWebKit\/(\d+)/.exec(navigator.userAgent)||[0,0])[1]:0;function zS(t){let e=t.defaultView&&t.defaultView.visualViewport;return e?{left:0,right:e.width,top:0,bottom:e.height}:{left:0,right:t.documentElement.clientWidth,top:0,bottom:t.documentElement.clientHeight}}function yn(t,e){return typeof t=="number"?t:t[e]}function RS(t){let e=t.getBoundingClientRect(),n=e.width/t.offsetWidth||1,l=e.height/t.offsetHeight||1;return{left:e.left,right:e.left+t.clientWidth*n,top:e.top,bottom:e.top+t.clientHeight*l}}function e2(t,e,n){let l=t.someProp("scrollThreshold")||0,i=t.someProp("scrollMargin")||5,r=t.dom.ownerDocument;for(let o=n||t.dom;o;){if(o.nodeType!=1){o=Ki(o);continue}let a=o,s=a==r.body,u=s?zS(r):RS(a),c=0,f=0;if(e.topu.bottom-yn(l,"bottom")&&(f=e.bottom-e.top>u.bottom-u.top?e.top+yn(i,"top")-u.top:e.bottom-u.bottom+yn(i,"bottom")),e.leftu.right-yn(l,"right")&&(c=e.right-u.right+yn(i,"right")),c||f)if(s)r.defaultView.scrollBy(c,f);else{let h=a.scrollLeft,p=a.scrollTop;f&&(a.scrollTop+=f),c&&(a.scrollLeft+=c);let b=a.scrollLeft-h,v=a.scrollTop-p;e={left:e.left-b,top:e.top-v,right:e.right-b,bottom:e.bottom-v}}let d=s?"fixed":getComputedStyle(o).position;if(/^(fixed|sticky)$/.test(d))break;o=d=="absolute"?o.offsetParent:Ki(o)}}function LS(t){let e=t.dom.getBoundingClientRect(),n=Math.max(0,e.top),l,i;for(let r=(e.left+e.right)/2,o=n+1;o=n-20){l=a,i=s.top;break}}return{refDOM:l,refTop:i,stack:L2(t.dom)}}function L2(t){let e=[],n=t.ownerDocument;for(let l=t;l&&(e.push({dom:l,top:l.scrollTop,left:l.scrollLeft}),t!=n);l=Ki(l));return e}function _S({refDOM:t,refTop:e,stack:n}){let l=t?t.getBoundingClientRect().top:0;_2(n,l==0?0:l-e)}function _2(t,e){for(let n=0;n=a){o=Math.max(p.bottom,o),a=Math.min(p.top,a);let b=p.left>e.left?p.left-e.left:p.right=(p.left+p.right)/2?1:0));continue}}else p.top>e.top&&!s&&p.left<=e.left&&p.right>=e.left&&(s=c,u={left:Math.max(p.left,Math.min(p.right,e.left)),top:p.top});!n&&(e.left>=p.right&&e.top>=p.top||e.left>=p.left&&e.top>=p.bottom)&&(r=f+1)}}return!n&&s&&(n=s,i=u,l=0),n&&n.nodeType==3?HS(n,i):!n||l&&n.nodeType==1?{node:t,offset:r}:B2(n,i)}function HS(t,e){let n=t.nodeValue.length,l=document.createRange(),i;for(let r=0;r=(o.left+o.right)/2?1:0)};break}}return l.detach(),i||{node:t,offset:0}}function Jd(t,e){return t.left>=e.left-1&&t.left<=e.right+1&&t.top>=e.top-1&&t.top<=e.bottom+1}function US(t,e){let n=t.parentNode;return n&&/^li$/i.test(n.nodeName)&&e.left(o.left+o.right)/2?1:-1}return t.docView.posFromDOM(l,i,r)}function qS(t,e,n,l){let i=-1;for(let r=e,o=!1;r!=t.dom;){let a=t.docView.nearestDesc(r,!0),s;if(!a)return null;if(a.dom.nodeType==1&&(a.node.isBlock&&a.parent||!a.contentDOM)&&((s=a.dom.getBoundingClientRect()).width||s.height)&&(a.node.isBlock&&a.parent&&!/^T(R|BODY|HEAD|FOOT)$/.test(a.dom.nodeName)&&(!o&&s.left>l.left||s.top>l.top?i=a.posBefore:(!o&&s.right-1?i:t.docView.posFromDOM(e,n,-1)}function H2(t,e,n){let l=t.childNodes.length;if(l&&n.tope.top&&i++}let u;Do&&i&&l.nodeType==1&&(u=l.childNodes[i-1]).nodeType==1&&u.contentEditable=="false"&&u.getBoundingClientRect().top>=e.top&&i--,l==t.dom&&i==l.childNodes.length-1&&l.lastChild.nodeType==1&&e.top>l.lastChild.getBoundingClientRect().bottom?a=t.state.doc.content.size:(i==0||l.nodeType!=1||l.childNodes[i-1].nodeName!="BR")&&(a=qS(t,l,i,e))}a==null&&(a=VS(t,o,e));let s=t.docView.nearestDesc(o,!0);return{pos:a,inside:s?s.posAtStart-s.border:-1}}function t2(t){return t.top=0&&i==l.nodeValue.length?(s--,c=1):n<0?s--:u++,vo(el(bn(l,s,u),c),c<0)}if(!t.state.doc.resolve(e-(r||0)).parent.inlineContent){if(r==null&&i&&(n<0||i==Tt(l))){let s=l.childNodes[i-1];if(s.nodeType==1)return Ad(s.getBoundingClientRect(),!1)}if(r==null&&i=0)}if(r==null&&i&&(n<0||i==Tt(l))){let s=l.childNodes[i-1],u=s.nodeType==3?bn(s,Tt(s)-(o?0:1)):s.nodeType==1&&(s.nodeName!="BR"||!s.nextSibling)?s:null;if(u)return vo(el(u,1),!1)}if(r==null&&i=0)}function vo(t,e){if(t.width==0)return t;let n=e?t.left:t.right;return{top:t.top,bottom:t.bottom,left:n,right:n}}function Ad(t,e){if(t.height==0)return t;let n=e?t.top:t.bottom;return{top:n,bottom:n,left:t.left,right:t.right}}function V2(t,e,n){let l=t.state,i=t.root.activeElement;l!=e&&t.updateState(e),i!=t.dom&&t.focus();try{return n()}finally{l!=e&&t.updateState(l),i!=t.dom&&i&&i.focus()}}function jS(t,e,n){let l=e.selection,i=n=="up"?l.$from:l.$to;return V2(t,e,()=>{let{node:r}=t.docView.domFromPos(i.pos,n=="up"?-1:1);for(;;){let a=t.docView.nearestDesc(r,!0);if(!a)break;if(a.node.isBlock){r=a.contentDOM||a.dom;break}r=a.dom.parentNode}let o=U2(t,i.pos,1);for(let a=r.firstChild;a;a=a.nextSibling){let s;if(a.nodeType==1)s=a.getClientRects();else if(a.nodeType==3)s=bn(a,0,a.nodeValue.length).getClientRects();else continue;for(let u=0;uc.top+1&&(n=="up"?o.top-c.top>(c.bottom-o.top)*2:c.bottom-o.bottom>(o.bottom-c.top)*2))return!1}}return!0})}var GS=/[\u0590-\u08ac]/;function XS(t,e,n){let{$head:l}=e.selection;if(!l.parent.isTextblock)return!1;let i=l.parentOffset,r=!i,o=i==l.parent.content.size,a=t.domSelection();return a?!GS.test(l.parent.textContent)||!a.modify?n=="left"||n=="backward"?r:o:V2(t,e,()=>{let{focusNode:s,focusOffset:u,anchorNode:c,anchorOffset:f}=t.domSelectionRange(),d=a.caretBidiLevel;a.modify("move",n,"character");let h=l.depth?t.docView.domAfterPos(l.before()):t.dom,{focusNode:p,focusOffset:b}=t.domSelectionRange(),v=p&&!h.contains(p.nodeType==1?p:p.parentNode)||s==p&&u==b;try{a.collapse(c,f),s&&(s!=c||u!=f)&&a.extend&&a.extend(s,u)}catch{}return d!=null&&(a.caretBidiLevel=d),v}):l.pos==l.start()||l.pos==l.end()}var n2=null,l2=null,i2=!1;function IS(t,e,n){return n2==e&&l2==n?i2:(n2=e,l2=n,i2=n=="up"||n=="down"?jS(t,e,n):XS(t,e,n))}var Nt=0,r2=1,Ll=2,jt=3,ql=class{constructor(e,n,l,i){this.parent=e,this.children=n,this.dom=l,this.contentDOM=i,this.dirty=Nt,l.pmViewDesc=this}matchesWidget(e){return!1}matchesMark(e){return!1}matchesNode(e,n,l){return!1}matchesHack(e){return!1}parseRule(){return null}stopEvent(e){return!1}get size(){let e=0;for(let n=0;nke(this.contentDOM);else if(this.contentDOM&&this.contentDOM!=this.dom&&this.dom.contains(this.contentDOM))i=e.compareDocumentPosition(this.contentDOM)&2;else if(this.dom.firstChild){if(n==0)for(let r=e;;r=r.parentNode){if(r==this.dom){i=!1;break}if(r.previousSibling)break}if(i==null&&n==e.childNodes.length)for(let r=e;;r=r.parentNode){if(r==this.dom){i=!0;break}if(r.nextSibling)break}}return i??l>0?this.posAtEnd:this.posAtStart}nearestDesc(e,n=!1){for(let l=!0,i=e;i;i=i.parentNode){let r=this.getDesc(i),o;if(r&&(!n||r.node))if(l&&(o=r.nodeDOM)&&!(o.nodeType==1?o.contains(e.nodeType==1?e:e.parentNode):o==e))l=!1;else return r}}getDesc(e){let n=e.pmViewDesc;for(let l=n;l;l=l.parent)if(l==this)return n}posFromDOM(e,n,l){for(let i=e;i;i=i.parentNode){let r=this.getDesc(i);if(r)return r.localPosFromDOM(e,n,l)}return-1}descAt(e){for(let n=0,l=0;ne||o instanceof Ys){i=e-r;break}r=a}if(i)return this.children[l].domFromPos(i-this.children[l].border,n);for(let r;l&&!(r=this.children[l-1]).size&&r instanceof Vs&&r.side>=0;l--);if(n<=0){let r,o=!0;for(;r=l?this.children[l-1]:null,!(!r||r.dom.parentNode==this.contentDOM);l--,o=!1);return r&&n&&o&&!r.border&&!r.domAtom?r.domFromPos(r.size,n):{node:this.contentDOM,offset:r?ke(r.dom)+1:0}}else{let r,o=!0;for(;r=l=c&&n<=u-s.border&&s.node&&s.contentDOM&&this.contentDOM.contains(s.contentDOM))return s.parseRange(e,n,c);e=o;for(let f=a;f>0;f--){let d=this.children[f-1];if(d.size&&d.dom.parentNode==this.contentDOM&&!d.emptyChildAt(1)){i=ke(d.dom)+1;break}e-=d.size}i==-1&&(i=0)}if(i>-1&&(u>n||a==this.children.length-1)){n=u;for(let c=a+1;cp&&on){let p=a;a=s,s=p}let h=document.createRange();h.setEnd(s.node,s.offset),h.setStart(a.node,a.offset),u.removeAllRanges(),u.addRange(h)}}ignoreMutation(e){return!this.contentDOM&&e.type!="selection"}get contentLost(){return this.contentDOM&&this.contentDOM!=this.dom&&!this.dom.contains(this.contentDOM)}markDirty(e,n){for(let l=0,i=0;i=l:el){let a=l+r.border,s=o-r.border;if(e>=a&&n<=s){this.dirty=e==l||n==o?Ll:r2,e==a&&n==s&&(r.contentLost||r.dom.parentNode!=this.contentDOM)?r.dirty=jt:r.markDirty(e-a,n-a);return}else r.dirty=r.dom==r.contentDOM&&r.dom.parentNode==this.contentDOM&&!r.children.length?Ll:jt}l=o}this.dirty=Ll}markParentsDirty(){let e=1;for(let n=this.parent;n;n=n.parent,e++){let l=e==1?Ll:r2;n.dirty{if(!r)return i;if(r.parent)return r.parent.posBeforeChild(r)})),!n.type.spec.raw){if(o.nodeType!=1){let a=document.createElement("span");a.appendChild(o),o=a}o.contentEditable="false",o.classList.add("ProseMirror-widget")}super(e,[],o,null),this.widget=n,this.widget=n,r=this}matchesWidget(e){return this.dirty==Nt&&e.type.eq(this.widget.type)}parseRule(){return{ignore:!0}}stopEvent(e){let n=this.widget.spec.stopEvent;return n?n(e):!1}ignoreMutation(e){return e.type!="selection"||this.widget.spec.ignoreSelection}destroy(){this.widget.type.destroy(this.dom),super.destroy()}get domAtom(){return!0}get ignoreForSelection(){return!!this.widget.type.spec.relaxedSide}get side(){return this.widget.type.side}},Bd=class extends ql{constructor(e,n,l,i){super(e,[],n,null),this.textDOM=l,this.text=i}get size(){return this.text.length}localPosFromDOM(e,n){return e!=this.textDOM?this.posAtStart+(n?this.size:0):this.posAtStart+n}domFromPos(e){return{node:this.textDOM,offset:e}}ignoreMutation(e){return e.type==="characterData"&&e.target.nodeValue==e.oldValue}},Fi=class t extends ql{constructor(e,n,l,i,r){super(e,[],l,i),this.mark=n,this.spec=r}static create(e,n,l,i){let r=i.nodeViews[n.type.name],o=r&&r(n,i,l);return(!o||!o.dom)&&(o=kl.renderSpec(document,n.type.spec.toDOM(n,l),null,n.attrs)),new t(e,n,o.dom,o.contentDOM||o.dom,o)}parseRule(){return this.dirty&jt||this.mark.type.spec.reparseInView?null:{mark:this.mark.type.name,attrs:this.mark.attrs,contentElement:this.contentDOM}}matchesMark(e){return this.dirty!=jt&&this.mark.eq(e)}markDirty(e,n){if(super.markDirty(e,n),this.dirty!=Nt){let l=this.parent;for(;!l.node;)l=l.parent;l.dirty0&&(r=qd(r,0,e,l));for(let a=0;a{if(!s)return o;if(s.parent)return s.parent.posBeforeChild(s)},l,i),c=u&&u.dom,f=u&&u.contentDOM;if(n.isText){if(!c)c=document.createTextNode(n.text);else if(c.nodeType!=3)throw new RangeError("Text must be rendered as a DOM text node")}else c||({dom:c,contentDOM:f}=kl.renderSpec(document,n.type.spec.toDOM(n),null,n.attrs));!f&&!n.isText&&c.nodeName!="BR"&&(c.hasAttribute("contenteditable")||(c.contentEditable="false"),n.type.spec.draggable&&(c.draggable=!0));let d=c;return c=Z2(c,l,n),u?s=new Hd(e,n,l,i,c,f||null,d,u,r,o+1):n.isText?new qs(e,n,l,i,c,d,r):new t(e,n,l,i,c,f||null,d,r,o+1)}parseRule(){if(this.node.type.spec.reparseInView)return null;let e={node:this.node.type.name,attrs:this.node.attrs};if(this.node.type.whitespace=="pre"&&(e.preserveWhitespace="full"),!this.contentDOM)e.getContent=()=>this.node.content;else if(!this.contentLost)e.contentElement=this.contentDOM;else{for(let n=this.children.length-1;n>=0;n--){let l=this.children[n];if(this.dom.contains(l.dom.parentNode)){e.contentElement=l.dom.parentNode;break}}e.contentElement||(e.getContent=()=>D.empty)}return e}matchesNode(e,n,l){return this.dirty==Nt&&e.eq(this.node)&&Zs(n,this.outerDeco)&&l.eq(this.innerDeco)}get size(){return this.node.nodeSize}get border(){return this.node.isLeaf?0:1}updateChildren(e,n){let l=this.node.inlineContent,i=n,r=e.composing?this.localCompositionInfo(e,n):null,o=r&&r.pos>-1?r:null,a=r&&r.pos<0,s=new Vd(this,o&&o.node,e);JS(this.node,this.innerDeco,(u,c,f)=>{u.spec.marks?s.syncToMarks(u.spec.marks,l,e,c):u.type.side>=0&&!f&&s.syncToMarks(c==this.node.childCount?Z.none:this.node.child(c).marks,l,e,c),s.placeWidget(u,e,i)},(u,c,f,d)=>{s.syncToMarks(u.marks,l,e,d);let h;s.findNodeMatch(u,c,f,d)||a&&e.state.selection.from>i&&e.state.selection.to-1&&s.updateNodeAt(u,c,f,h,e)||s.updateNextNode(u,c,f,e,d,i)||s.addNode(u,c,f,e,i),i+=u.nodeSize}),s.syncToMarks([],l,e,0),this.node.isTextblock&&s.addTextblockHacks(),s.destroyRest(),(s.changed||this.dirty==Ll)&&(o&&this.protectLocalComposition(e,o),q2(this.contentDOM,this.children,e),Qi&&PS(this.dom))}localCompositionInfo(e,n){let{from:l,to:i}=e.state.selection;if(!(e.state.selection instanceof j)||ln+this.node.content.size)return null;let r=e.input.compositionNode;if(!r||!this.dom.contains(r.parentNode))return null;if(this.node.inlineContent){let o=r.nodeValue,a=WS(this.node.content,o,l-n,i-n);return a<0?null:{node:r,pos:a,text:o}}else return{node:r,pos:-1,text:""}}protectLocalComposition(e,{node:n,pos:l,text:i}){if(this.getDesc(n))return;let r=n;for(;r.parentNode!=this.contentDOM;r=r.parentNode){for(;r.previousSibling;)r.parentNode.removeChild(r.previousSibling);for(;r.nextSibling;)r.parentNode.removeChild(r.nextSibling);r.pmViewDesc&&(r.pmViewDesc=void 0)}let o=new Bd(this,r,n,i);e.input.compositionNodes.push(o),this.children=qd(this.children,l,l+i.length,e,o)}update(e,n,l,i){return this.dirty==jt||!e.sameMarkup(this.node)?!1:(this.updateInner(e,n,l,i),!0)}updateInner(e,n,l,i){this.updateOuterDeco(n),this.node=e,this.innerDeco=l,this.contentDOM&&this.updateChildren(i,this.posAtStart),this.dirty=Nt}updateOuterDeco(e){if(Zs(e,this.outerDeco))return;let n=this.nodeDOM.nodeType!=1,l=this.dom;this.dom=Y2(this.dom,this.nodeDOM,Ud(this.outerDeco,this.node,n),Ud(e,this.node,n)),this.dom!=l&&(l.pmViewDesc=void 0,this.dom.pmViewDesc=this),this.outerDeco=e}selectNode(){this.nodeDOM.nodeType==1&&(this.nodeDOM.classList.add("ProseMirror-selectednode"),(this.contentDOM||!this.node.type.spec.draggable)&&(this.nodeDOM.draggable=!0))}deselectNode(){this.nodeDOM.nodeType==1&&(this.nodeDOM.classList.remove("ProseMirror-selectednode"),(this.contentDOM||!this.node.type.spec.draggable)&&this.nodeDOM.removeAttribute("draggable"))}get domAtom(){return this.node.isAtom}};function o2(t,e,n,l,i){Z2(l,e,t);let r=new il(void 0,t,e,n,l,l,l,i,0);return r.contentDOM&&r.updateChildren(i,0),r}var qs=class t extends il{constructor(e,n,l,i,r,o,a){super(e,n,l,i,r,null,o,a,0)}parseRule(){let e=this.nodeDOM.parentNode;for(;e&&e!=this.dom&&!e.pmIsDeco;)e=e.parentNode;return{skip:e||!0}}update(e,n,l,i){return this.dirty==jt||this.dirty!=Nt&&!this.inParent()||!e.sameMarkup(this.node)?!1:(this.updateOuterDeco(n),(this.dirty!=Nt||e.text!=this.node.text)&&e.text!=this.nodeDOM.nodeValue&&(this.nodeDOM.nodeValue=e.text,i.trackWrites==this.nodeDOM&&(i.trackWrites=null)),this.node=e,this.dirty=Nt,!0)}inParent(){let e=this.parent.contentDOM;for(let n=this.nodeDOM;n;n=n.parentNode)if(n==e)return!0;return!1}domFromPos(e){return{node:this.nodeDOM,offset:e}}localPosFromDOM(e,n,l){return e==this.nodeDOM?this.posAtStart+Math.min(n,this.node.text.length):super.localPosFromDOM(e,n,l)}ignoreMutation(e){return e.type!="characterData"&&e.type!="selection"}slice(e,n,l){let i=this.node.cut(e,n),r=document.createTextNode(i.text);return new t(this.parent,i,this.outerDeco,this.innerDeco,r,r,l)}markDirty(e,n){super.markDirty(e,n),this.dom!=this.nodeDOM&&(e==0||n==this.nodeDOM.nodeValue.length)&&(this.dirty=jt)}get domAtom(){return!1}isText(e){return this.node.text==e}},Ys=class extends ql{parseRule(){return{ignore:!0}}matchesHack(e){return this.dirty==Nt&&this.dom.nodeName==e}get domAtom(){return!0}get ignoreForCoords(){return this.dom.nodeName=="IMG"}},Hd=class extends il{constructor(e,n,l,i,r,o,a,s,u,c){super(e,n,l,i,r,o,a,u,c),this.spec=s}update(e,n,l,i){if(this.dirty==jt)return!1;if(this.spec.update&&(this.node.type==e.type||this.spec.multiType)){let r=this.spec.update(e,n,l);return r&&this.updateInner(e,n,l,i),r}else return!this.contentDOM&&!e.isLeaf?!1:super.update(e,n,l,i)}selectNode(){this.spec.selectNode?this.spec.selectNode():super.selectNode()}deselectNode(){this.spec.deselectNode?this.spec.deselectNode():super.deselectNode()}setSelection(e,n,l,i){this.spec.setSelection?this.spec.setSelection(e,n,l.root):super.setSelection(e,n,l,i)}destroy(){this.spec.destroy&&this.spec.destroy(),super.destroy()}stopEvent(e){return this.spec.stopEvent?this.spec.stopEvent(e):!1}ignoreMutation(e){return this.spec.ignoreMutation?this.spec.ignoreMutation(e):super.ignoreMutation(e)}};function q2(t,e,n){let l=t.firstChild,i=!1;for(let r=0;r>1,a=Math.min(o,e.length);for(;r-1)s>this.index&&(this.changed=!0,this.destroyBetween(this.index,s)),this.top=this.top.children[this.index];else{let c=Fi.create(this.top,e[o],n,l);this.top.children.splice(this.index,0,c),this.top=c,this.changed=!0}this.index=0,o++}}findNodeMatch(e,n,l,i){let r=-1,o;if(i>=this.preMatch.index&&(o=this.preMatch.matches[i-this.preMatch.index]).parent==this.top&&o.matchesNode(e,n,l))r=this.top.children.indexOf(o,this.index);else for(let a=this.index,s=Math.min(this.top.children.length,a+5);a0;){let a;for(;;)if(l){let u=n.children[l-1];if(u instanceof Fi)n=u,l=u.children.length;else{a=u,l--;break}}else{if(n==e)break e;l=n.parent.children.indexOf(n),n=n.parent}let s=a.node;if(s){if(s!=t.child(i-1))break;--i,r.set(a,i),o.push(a)}}return{index:i,matched:r,matches:o.reverse()}}function FS(t,e){return t.type.side-e.type.side}function JS(t,e,n,l){let i=e.locals(t),r=0;if(i.length==0){for(let u=0;ur;)a.push(i[o++]);let p=r+d.nodeSize;if(d.isText){let v=p;o!v.inline):a.slice();l(d,b,e.forChild(r,d),h),r=p}}function PS(t){if(t.nodeName=="UL"||t.nodeName=="OL"){let e=t.style.cssText;t.style.cssText=e+"; list-style: square !important",window.getComputedStyle(t).listStyle,t.style.cssText=e}}function WS(t,e,n,l){for(let i=0,r=0;i=n){if(r>=l&&s.slice(l-e.length-a,l-a)==e)return l-e.length;let u=a=0&&u+e.length+a>=n)return a+u;if(n==l&&s.length>=l+e.length-a&&s.slice(l-a,l-a+e.length)==e)return l}}return-1}function qd(t,e,n,l,i){let r=[];for(let o=0,a=0;o=n||c<=e?r.push(s):(un&&r.push(s.slice(n-u,s.size,l)))}return r}function Pd(t,e=null){let n=t.domSelectionRange(),l=t.state.doc;if(!n.focusNode)return null;let i=t.docView.nearestDesc(n.focusNode),r=i&&i.size==0,o=t.docView.posFromDOM(n.focusNode,n.focusOffset,1);if(o<0)return null;let a=l.resolve(o),s,u;if(Fs(n)){for(s=o;i&&!i.node;)i=i.parent;let f=i.node;if(i&&f.isAtom&&B.isSelectable(f)&&i.parent&&!(f.isInline&&OS(n.focusNode,n.focusOffset,i.dom))){let d=i.posBefore;u=new B(o==d?a:l.resolve(d))}}else{if(n instanceof t.dom.ownerDocument.defaultView.Selection&&n.rangeCount>1){let f=o,d=o;for(let h=0;h{(n.anchorNode!=l||n.anchorOffset!=i)&&(e.removeEventListener("selectionchange",t.input.hideSelectionGuard),setTimeout(()=>{(!j2(t)||t.state.selection.visible)&&t.dom.classList.remove("ProseMirror-hideselection")},20))})}function e8(t){let e=t.domSelection();if(!e)return;let n=t.cursorWrapper.dom,l=n.nodeName=="IMG";l?e.collapse(n.parentNode,ke(n)+1):e.collapse(n,0),!l&&!t.state.selection.visible&&Pe&&ll<=11&&(n.disabled=!0,n.disabled=!1)}function G2(t,e){if(e instanceof B){let n=t.docView.descAt(e.from);n!=t.lastSelectedViewDesc&&(f2(t),n&&n.selectNode(),t.lastSelectedViewDesc=n)}else f2(t)}function f2(t){t.lastSelectedViewDesc&&(t.lastSelectedViewDesc.parent&&t.lastSelectedViewDesc.deselectNode(),t.lastSelectedViewDesc=void 0)}function Wd(t,e,n,l){return t.someProp("createSelectionBetween",i=>i(t,e,n))||j.between(e,n,l)}function d2(t){return t.editable&&!t.hasFocus()?!1:X2(t)}function X2(t){let e=t.domSelectionRange();if(!e.anchorNode)return!1;try{return t.dom.contains(e.anchorNode.nodeType==3?e.anchorNode.parentNode:e.anchorNode)&&(t.editable||t.dom.contains(e.focusNode.nodeType==3?e.focusNode.parentNode:e.focusNode))}catch{return!1}}function t8(t){let e=t.docView.domFromPos(t.state.selection.anchor,0),n=t.domSelectionRange();return Vl(e.node,e.offset,n.anchorNode,n.anchorOffset)}function Yd(t,e){let{$anchor:n,$head:l}=t.selection,i=e>0?n.max(l):n.min(l),r=i.parent.inlineContent?i.depth?t.doc.resolve(e>0?i.after():i.before()):null:i;return r&&Y.findFrom(r,e)}function tl(t,e){return t.dispatch(t.state.tr.setSelection(e).scrollIntoView()),!0}function h2(t,e,n){let l=t.state.selection;if(l instanceof j)if(n.indexOf("s")>-1){let{$head:i}=l,r=i.textOffset?null:e<0?i.nodeBefore:i.nodeAfter;if(!r||r.isText||!r.isLeaf)return!1;let o=t.state.doc.resolve(i.pos+r.nodeSize*(e<0?-1:1));return tl(t,new j(l.$anchor,o))}else if(l.empty){if(t.endOfTextblock(e>0?"forward":"backward")){let i=Yd(t.state,e);return i&&i instanceof B?tl(t,i):!1}else if(!(Mt&&n.indexOf("m")>-1)){let i=l.$head,r=i.textOffset?null:e<0?i.nodeBefore:i.nodeAfter,o;if(!r||r.isText)return!1;let a=e<0?i.pos-r.nodeSize:i.pos;return r.isAtom||(o=t.docView.descAt(a))&&!o.contentDOM?B.isSelectable(r)?tl(t,new B(e<0?t.state.doc.resolve(i.pos-r.nodeSize):i)):Do?tl(t,new j(t.state.doc.resolve(e<0?a:a+r.nodeSize))):!1:!1}}else return!1;else{if(l instanceof B&&l.node.isInline)return tl(t,new j(e>0?l.$to:l.$from));{let i=Yd(t.state,e);return i?tl(t,i):!1}}}function js(t){return t.nodeType==3?t.nodeValue.length:t.childNodes.length}function Mo(t,e){let n=t.pmViewDesc;return n&&n.size==0&&(e<0||t.nextSibling||t.nodeName!="BR")}function Gi(t,e){return e<0?n8(t):l8(t)}function n8(t){let e=t.domSelectionRange(),n=e.focusNode,l=e.focusOffset;if(!n)return;let i,r,o=!1;for(Et&&n.nodeType==1&&l0){if(n.nodeType!=1)break;{let a=n.childNodes[l-1];if(Mo(a,-1))i=n,r=--l;else if(a.nodeType==3)n=a,l=n.nodeValue.length;else break}}else{if(I2(n))break;{let a=n.previousSibling;for(;a&&Mo(a,-1);)i=n.parentNode,r=ke(a),a=a.previousSibling;if(a)n=a,l=js(n);else{if(n=n.parentNode,n==t.dom)break;l=0}}}o?Zd(t,n,l):i&&Zd(t,i,r)}function l8(t){let e=t.domSelectionRange(),n=e.focusNode,l=e.focusOffset;if(!n)return;let i=js(n),r,o;for(;;)if(l{t.state==i&&xn(t)},50)}function p2(t,e){let n=t.state.doc.resolve(e);if(!(we||R2)&&n.parent.inlineContent){let i=t.coordsAtPos(e);if(e>n.start()){let r=t.coordsAtPos(e-1),o=(r.top+r.bottom)/2;if(o>i.top&&o1)return r.lefti.top&&o1)return r.left>i.left?"ltr":"rtl"}}return getComputedStyle(t.dom).direction=="rtl"?"rtl":"ltr"}function m2(t,e,n){let l=t.state.selection;if(l instanceof j&&!l.empty||n.indexOf("s")>-1||Mt&&n.indexOf("m")>-1)return!1;let{$from:i,$to:r}=l;if(!i.parent.inlineContent||t.endOfTextblock(e<0?"up":"down")){let o=Yd(t.state,e);if(o&&o instanceof B)return tl(t,o)}if(!i.parent.inlineContent){let o=e<0?i:r,a=l instanceof ct?Y.near(o,e):Y.findFrom(o,e);return a?tl(t,a):!1}return!1}function g2(t,e){if(!(t.state.selection instanceof j))return!0;let{$head:n,$anchor:l,empty:i}=t.state.selection;if(!n.sameParent(l))return!0;if(!i)return!1;if(t.endOfTextblock(e>0?"forward":"backward"))return!0;let r=!n.textOffset&&(e<0?n.nodeBefore:n.nodeAfter);if(r&&!r.isText){let o=t.state.tr;return e<0?o.delete(n.pos-r.nodeSize,n.pos):o.delete(n.pos,n.pos+r.nodeSize),t.dispatch(o),!0}return!1}function y2(t,e,n){t.domObserver.stop(),e.contentEditable=n,t.domObserver.start()}function o8(t){if(!_e||t.state.selection.$head.parentOffset>0)return!1;let{focusNode:e,focusOffset:n}=t.domSelectionRange();if(e&&e.nodeType==1&&n==0&&e.firstChild&&e.firstChild.contentEditable=="false"){let l=e.firstChild;y2(t,l,"true"),setTimeout(()=>y2(t,l,"false"),20)}return!1}function a8(t){let e="";return t.ctrlKey&&(e+="c"),t.metaKey&&(e+="m"),t.altKey&&(e+="a"),t.shiftKey&&(e+="s"),e}function s8(t,e){let n=e.keyCode,l=a8(e);if(n==8||Mt&&n==72&&l=="c")return g2(t,-1)||Gi(t,-1);if(n==46&&!e.shiftKey||Mt&&n==68&&l=="c")return g2(t,1)||Gi(t,1);if(n==13||n==27)return!0;if(n==37||Mt&&n==66&&l=="c"){let i=n==37?p2(t,t.state.selection.from)=="ltr"?-1:1:-1;return h2(t,i,l)||Gi(t,i)}else if(n==39||Mt&&n==70&&l=="c"){let i=n==39?p2(t,t.state.selection.from)=="ltr"?1:-1:1;return h2(t,i,l)||Gi(t,i)}else{if(n==38||Mt&&n==80&&l=="c")return m2(t,-1,l)||Gi(t,-1);if(n==40||Mt&&n==78&&l=="c")return o8(t)||m2(t,1,l)||Gi(t,1);if(l==(Mt?"m":"c")&&(n==66||n==73||n==89||n==90))return!0}return!1}function $d(t,e){t.someProp("transformCopied",h=>{e=h(e,t)});let n=[],{content:l,openStart:i,openEnd:r}=e;for(;i>1&&r>1&&l.childCount==1&&l.firstChild.childCount==1;){i--,r--;let h=l.firstChild;n.push(h.type.name,h.attrs!=h.type.defaultAttrs?h.attrs:null),l=h.content}let o=t.someProp("clipboardSerializer")||kl.fromSchema(t.state.schema),a=W2(),s=a.createElement("div");s.appendChild(o.serializeFragment(l,{document:a}));let u=s.firstChild,c,f=0;for(;u&&u.nodeType==1&&(c=P2[u.nodeName.toLowerCase()]);){for(let h=c.length-1;h>=0;h--){let p=a.createElement(c[h]);for(;s.firstChild;)p.appendChild(s.firstChild);s.appendChild(p),f++}u=s.firstChild}u&&u.nodeType==1&&u.setAttribute("data-pm-slice",`${i} ${r}${f?` -${f}`:""} ${JSON.stringify(n)}`);let d=t.someProp("clipboardTextSerializer",h=>h(e,t))||e.content.textBetween(0,e.content.size,` -`);return{dom:s,text:d,slice:e}}function G2(t,e,n,l,i){let r=i.parent.type.spec.code,o,a;if(!n&&!e)return null;let s=!!e&&(l||r||!n);if(s){if(t.someProp("transformPastedText",d=>{e=d(e,r||l,t)}),r)return a=new k(D.from(t.state.schema.text(e.replace(/\r\n?/g,` -`))),0,0),t.someProp("transformPasted",d=>{a=d(a,t,!0)}),a;let f=t.someProp("clipboardTextParser",d=>d(e,i,l,t));if(f)a=f;else{let d=i.marks(),{schema:h}=t.state,p=kl.fromSchema(h);o=document.createElement("div"),e.split(/(?:\r\n?|\n)+/).forEach(b=>{let v=o.appendChild(document.createElement("p"));b&&v.appendChild(p.serializeNode(h.text(b,d)))})}}else t.someProp("transformPastedHTML",f=>{n=f(n,t)}),o=a8(n),No&&s8(o);let u=o&&o.querySelector("[data-pm-slice]"),c=u&&/^(\d+) (\d+)(?: -(\d+))? (.*)/.exec(u.getAttribute("data-pm-slice")||"");if(c&&c[3])for(let f=+c[3];f>0;f--){let d=o.firstChild;for(;d&&d.nodeType!=1;)d=d.nextSibling;if(!d)break;o=d}if(a||(a=(t.someProp("clipboardParser")||t.someProp("domParser")||so.fromSchema(t.state.schema)).parseSlice(o,{preserveWhitespace:!!(s||c),context:i,ruleFromNode(d){return d.nodeName=="BR"&&!d.nextSibling&&d.parentNode&&!i8.test(d.parentNode.nodeName)?{ignore:!0}:null}})),c)a=u8(m2(a,+c[1],+c[2]),c[4]);else if(a=k.maxOpen(r8(a.content,i),!0),a.openStart||a.openEnd){let f=0,d=0;for(let h=a.content.firstChild;f{a=f(a,t,s)}),a}var i8=/^(a|abbr|acronym|b|cite|code|del|em|i|ins|kbd|label|output|q|ruby|s|samp|span|strong|sub|sup|time|u|tt|var)$/i;function r8(t,e){if(t.childCount<2)return t;for(let n=e.depth;n>=0;n--){let i=e.node(n).contentMatchAt(e.index(n)),r,o=[];if(t.forEach(a=>{if(!o)return;let s=i.findWrapping(a.type),u;if(!s)return o=null;if(u=o.length&&r.length&&K2(s,r,a,o[o.length-1],0))o[o.length-1]=u;else{o.length&&(o[o.length-1]=I2(o[o.length-1],r.length));let c=X2(a,s);o.push(c),i=i.matchType(c.type),r=s}}),o)return D.from(o)}return t}function X2(t,e,n=0){for(let l=e.length-1;l>=n;l--)t=e[l].create(null,D.from(t));return t}function K2(t,e,n,l,i){if(i1&&(r=0),i=n&&(a=e<0?o.contentMatchAt(0).fillBefore(a,r<=i).append(a):a.append(o.contentMatchAt(o.childCount).fillBefore(D.empty,!0))),t.replaceChild(e<0?0:t.childCount-1,o.copy(a))}function m2(t,e,n){return en})),Ad.createHTML(t)):t}function a8(t){let e=/^(\s*]*>)*/.exec(t);e&&(t=t.slice(e[0].length));let n=J2().createElement("div"),l=/<([a-z][^>\s]+)/i.exec(t),i;if((i=l&&Q2[l[1].toLowerCase()])&&(t=i.map(r=>"<"+r+">").join("")+t+i.map(r=>"").reverse().join("")),n.innerHTML=o8(t),i)for(let r=0;r=0;a-=2){let s=n.nodes[l[a]];if(!s||s.hasRequiredAttrs())break;i=D.from(s.create(l[a+1],i)),r++,o++}return new k(i,r,o)}var Ve={},qe={},c8={touchstart:!0,touchmove:!0},jd=class{constructor(){this.shiftKey=!1,this.mouseDown=null,this.lastKeyCode=null,this.lastKeyCodeTime=0,this.lastClick={time:0,x:0,y:0,type:"",button:0},this.lastSelectionOrigin=null,this.lastSelectionTime=0,this.lastIOSEnter=0,this.lastIOSEnterFallbackTimeout=-1,this.lastFocus=0,this.lastTouch=0,this.lastChromeDelete=0,this.composing=!1,this.compositionNode=null,this.composingTimeout=-1,this.compositionNodes=[],this.compositionEndedAt=-2e8,this.compositionID=1,this.badSafariComposition=!1,this.compositionPendingChanges=0,this.domChangeCount=0,this.eventHandlers=Object.create(null),this.hideSelectionGuard=null}};function f8(t){for(let e in Ve){let n=Ve[e];t.dom.addEventListener(e,t.input.eventHandlers[e]=l=>{h8(t,l)&&!$d(t,l)&&(t.editable||!(l.type in qe))&&n(t,l)},c8[e]?{passive:!0}:void 0)}_e&&t.dom.addEventListener("input",()=>null),Gd(t)}function nl(t,e){t.input.lastSelectionOrigin=e,t.input.lastSelectionTime=Date.now()}function d8(t){t.domObserver.stop();for(let e in t.input.eventHandlers)t.dom.removeEventListener(e,t.input.eventHandlers[e]);clearTimeout(t.input.composingTimeout),clearTimeout(t.input.lastIOSEnterFallbackTimeout)}function Gd(t){t.someProp("handleDOMEvents",e=>{for(let n in e)t.input.eventHandlers[n]||t.dom.addEventListener(n,t.input.eventHandlers[n]=l=>$d(t,l))})}function $d(t,e){return t.someProp("handleDOMEvents",n=>{let l=n[e.type];return l?l(t,e)||e.defaultPrevented:!1})}function h8(t,e){if(!e.bubbles)return!0;if(e.defaultPrevented)return!1;for(let n=e.target;n!=t.dom;n=n.parentNode)if(!n||n.nodeType==11||n.pmViewDesc&&n.pmViewDesc.stopEvent(e))return!1;return!0}function p8(t,e){!$d(t,e)&&Ve[e.type]&&(t.editable||!(e.type in qe))&&Ve[e.type](t,e)}qe.keydown=(t,e)=>{let n=e;if(t.input.shiftKey=n.keyCode==16||n.shiftKey,!W2(t,n)&&(t.input.lastKeyCode=n.keyCode,t.input.lastKeyCodeTime=Date.now(),!(Sn&&we&&n.keyCode==13)))if(n.keyCode!=229&&t.domObserver.forceFlush(),Ki&&n.keyCode==13&&!n.ctrlKey&&!n.altKey&&!n.metaKey){let l=Date.now();t.input.lastIOSEnter=l,t.input.lastIOSEnterFallbackTimeout=setTimeout(()=>{t.input.lastIOSEnter==l&&(t.someProp("handleKeyDown",i=>i(t,Rl(13,"Enter"))),t.input.lastIOSEnter=0)},200)}else t.someProp("handleKeyDown",l=>l(t,n))||l8(t,n)?n.preventDefault():nl(t,"key")};qe.keyup=(t,e)=>{e.keyCode==16&&(t.input.shiftKey=!1)};qe.keypress=(t,e)=>{let n=e;if(W2(t,n)||!n.charCode||n.ctrlKey&&!n.altKey||Ct&&n.metaKey)return;if(t.someProp("handleKeyPress",i=>i(t,n))){n.preventDefault();return}let l=t.state.selection;if(!(l instanceof j)||!l.$from.sameParent(l.$to)){let i=String.fromCharCode(n.charCode),r=()=>t.state.tr.insertText(i).scrollIntoView();!/[\r\n]/.test(i)&&!t.someProp("handleTextInput",o=>o(t,l.$from.pos,l.$to.pos,i,r))&&t.dispatch(r()),n.preventDefault()}};function Qs(t){return{left:t.clientX,top:t.clientY}}function m8(t,e){let n=e.x-t.clientX,l=e.y-t.clientY;return n*n+l*l<100}function eh(t,e,n,l,i){if(l==-1)return!1;let r=t.state.doc.resolve(l);for(let o=r.depth+1;o>0;o--)if(t.someProp(e,a=>o>r.depth?a(t,n,r.nodeAfter,r.before(o),i,!0):a(t,n,r.node(o),r.before(o),i,!1)))return!0;return!1}function Gi(t,e,n){if(t.focused||t.focus(),t.state.selection.eq(e))return;let l=t.state.tr.setSelection(e);n=="pointer"&&l.setMeta("pointer",!0),t.dispatch(l)}function g8(t,e){if(e==-1)return!1;let n=t.state.doc.resolve(e),l=n.nodeAfter;return l&&l.isAtom&&B.isSelectable(l)?(Gi(t,new B(n),"pointer"),!0):!1}function y8(t,e){if(e==-1)return!1;let n=t.state.selection,l,i;n instanceof B&&(l=n.node);let r=t.state.doc.resolve(e);for(let o=r.depth+1;o>0;o--){let a=o>r.depth?r.nodeAfter:r.node(o);if(B.isSelectable(a)){l&&n.$from.depth>0&&o>=n.$from.depth&&r.before(n.$from.depth+1)==n.$from.pos?i=r.before(n.$from.depth):i=r.before(o);break}}return i!=null?(Gi(t,B.create(t.state.doc,i),"pointer"),!0):!1}function b8(t,e,n,l,i){return eh(t,"handleClickOn",e,n,l)||t.someProp("handleClick",r=>r(t,e,l))||(i?y8(t,n):g8(t,n))}function S8(t,e,n,l){return eh(t,"handleDoubleClickOn",e,n,l)||t.someProp("handleDoubleClick",i=>i(t,e,l))}function x8(t,e,n,l){return eh(t,"handleTripleClickOn",e,n,l)||t.someProp("handleTripleClick",i=>i(t,e,l))||v8(t,n,l)}function v8(t,e,n){if(n.button!=0)return!1;let l=t.state.doc;if(e==-1)return l.inlineContent?(Gi(t,j.create(l,0,l.content.size),"pointer"),!0):!1;let i=l.resolve(e);for(let r=i.depth+1;r>0;r--){let o=r>i.depth?i.nodeAfter:i.node(r),a=i.before(r);if(o.inlineContent)Gi(t,j.create(l,a+1,a+1+o.content.size),"pointer");else if(B.isSelectable(o))Gi(t,B.create(l,a),"pointer");else continue;return!0}}function th(t){return Zs(t)}var F2=Ct?"metaKey":"ctrlKey";Ve.mousedown=(t,e)=>{let n=e;t.input.shiftKey=n.shiftKey;let l=th(t),i=Date.now(),r="singleClick";i-t.input.lastClick.time<500&&m8(n,t.input.lastClick)&&!n[F2]&&t.input.lastClick.button==n.button&&(t.input.lastClick.type=="singleClick"?r="doubleClick":t.input.lastClick.type=="doubleClick"&&(r="tripleClick")),t.input.lastClick={time:i,x:n.clientX,y:n.clientY,type:r,button:n.button};let o=t.posAtCoords(Qs(n));o&&(r=="singleClick"?(t.input.mouseDown&&t.input.mouseDown.done(),t.input.mouseDown=new Xd(t,o,n,!!l)):(r=="doubleClick"?S8:x8)(t,o.pos,o.inside,n)?n.preventDefault():nl(t,"pointer"))};var Xd=class{constructor(e,n,l,i){this.view=e,this.pos=n,this.event=l,this.flushed=i,this.delayedSelectionSync=!1,this.mightDrag=null,this.startDoc=e.state.doc,this.selectNode=!!l[F2],this.allowDefault=l.shiftKey;let r,o;if(n.inside>-1)r=e.state.doc.nodeAt(n.inside),o=n.inside;else{let c=e.state.doc.resolve(n.pos);r=c.parent,o=c.depth?c.before():0}let a=i?null:l.target,s=a?e.docView.nearestDesc(a,!0):null;this.target=s&&s.nodeDOM.nodeType==1?s.nodeDOM:null;let{selection:u}=e.state;l.button==0&&(r.type.spec.draggable&&r.type.spec.selectable!==!1||u instanceof B&&u.from<=o&&u.to>o)&&(this.mightDrag={node:r,pos:o,addAttr:!!(this.target&&!this.target.draggable),setUneditable:!!(this.target&&Tt&&!this.target.hasAttribute("contentEditable"))}),this.target&&this.mightDrag&&(this.mightDrag.addAttr||this.mightDrag.setUneditable)&&(this.view.domObserver.stop(),this.mightDrag.addAttr&&(this.target.draggable=!0),this.mightDrag.setUneditable&&setTimeout(()=>{this.view.input.mouseDown==this&&this.target.setAttribute("contentEditable","false")},20),this.view.domObserver.start()),e.root.addEventListener("mouseup",this.up=this.up.bind(this)),e.root.addEventListener("mousemove",this.move=this.move.bind(this)),nl(e,"pointer")}done(){this.view.root.removeEventListener("mouseup",this.up),this.view.root.removeEventListener("mousemove",this.move),this.mightDrag&&this.target&&(this.view.domObserver.stop(),this.mightDrag.addAttr&&this.target.removeAttribute("draggable"),this.mightDrag.setUneditable&&this.target.removeAttribute("contentEditable"),this.view.domObserver.start()),this.delayedSelectionSync&&setTimeout(()=>xn(this.view)),this.view.input.mouseDown=null}up(e){if(this.done(),!this.view.dom.contains(e.target))return;let n=this.pos;this.view.state.doc!=this.startDoc&&(n=this.view.posAtCoords(Qs(e))),this.updateAllowDefault(e),this.allowDefault||!n?nl(this.view,"pointer"):b8(this.view,n.pos,n.inside,e,this.selectNode)?e.preventDefault():e.button==0&&(this.flushed||_e&&this.mightDrag&&!this.mightDrag.node.isAtom||we&&!this.view.state.selection.visible&&Math.min(Math.abs(n.pos-this.view.state.selection.from),Math.abs(n.pos-this.view.state.selection.to))<=2)?(Gi(this.view,Y.near(this.view.state.doc.resolve(n.pos)),"pointer"),e.preventDefault()):nl(this.view,"pointer")}move(e){this.updateAllowDefault(e),nl(this.view,"pointer"),e.buttons==0&&this.done()}updateAllowDefault(e){!this.allowDefault&&(Math.abs(this.event.x-e.clientX)>4||Math.abs(this.event.y-e.clientY)>4)&&(this.allowDefault=!0)}};Ve.touchstart=t=>{t.input.lastTouch=Date.now(),th(t),nl(t,"pointer")};Ve.touchmove=t=>{t.input.lastTouch=Date.now(),nl(t,"pointer")};Ve.contextmenu=t=>th(t);function W2(t,e){return t.composing?!0:_e&&Math.abs(e.timeStamp-t.input.compositionEndedAt)<500?(t.input.compositionEndedAt=-2e8,!0):!1}var C8=Sn?5e3:-1;qe.compositionstart=qe.compositionupdate=t=>{if(!t.composing){t.domObserver.flush();let{state:e}=t,n=e.selection.$to;if(e.selection instanceof j&&(e.storedMarks||!n.textOffset&&n.parentOffset&&n.nodeBefore.marks.some(l=>l.type.spec.inclusive===!1)||we&&k2&&M8(t)))t.markCursor=t.state.storedMarks||n.marks(),Zs(t,!0),t.markCursor=null;else if(Zs(t,!e.selection.empty),Tt&&e.selection.empty&&n.parentOffset&&!n.textOffset&&n.nodeBefore.marks.length){let l=t.domSelectionRange();for(let i=l.focusNode,r=l.focusOffset;i&&i.nodeType==1&&r!=0;){let o=r<0?i.lastChild:i.childNodes[r-1];if(!o)break;if(o.nodeType==3){let a=t.domSelection();a&&a.collapse(o,o.nodeValue.length);break}else i=o,r=-1}}t.input.composing=!0}P2(t,C8)};function M8(t){let{focusNode:e,focusOffset:n}=t.domSelectionRange();if(!e||e.nodeType!=1||n>=e.childNodes.length)return!1;let l=e.childNodes[n];return l.nodeType==1&&l.contentEditable=="false"}qe.compositionend=(t,e)=>{t.composing&&(t.input.composing=!1,t.input.compositionEndedAt=e.timeStamp,t.input.compositionPendingChanges=t.domObserver.pendingRecords().length?t.input.compositionID:0,t.input.compositionNode=null,t.input.badSafariComposition?t.domObserver.forceFlush():t.input.compositionPendingChanges&&Promise.resolve().then(()=>t.domObserver.flush()),t.input.compositionID++,P2(t,20))};function P2(t,e){clearTimeout(t.input.composingTimeout),e>-1&&(t.input.composingTimeout=setTimeout(()=>Zs(t),e))}function $2(t){for(t.composing&&(t.input.composing=!1,t.input.compositionEndedAt=E8());t.input.compositionNodes.length>0;)t.input.compositionNodes.pop().markParentsDirty()}function T8(t){let e=t.domSelectionRange();if(!e.focusNode)return null;let n=vS(e.focusNode,e.focusOffset),l=CS(e.focusNode,e.focusOffset);if(n&&l&&n!=l){let i=l.pmViewDesc,r=t.domObserver.lastChangedTextNode;if(n==r||l==r)return r;if(!i||!i.isText(l.nodeValue))return l;if(t.input.compositionNode==l){let o=n.pmViewDesc;if(!(!o||!o.isText(n.nodeValue)))return l}}return n||l}function E8(){let t=document.createEvent("Event");return t.initEvent("event",!0,!0),t.timeStamp}function Zs(t,e=!1){if(!(Sn&&t.domObserver.flushingSoon>=0)){if(t.domObserver.forceFlush(),$2(t),e||t.docView&&t.docView.dirty){let n=Fd(t),l=t.state.selection;return n&&!n.eq(l)?t.dispatch(t.state.tr.setSelection(n)):(t.markCursor||e)&&!l.$from.node(l.$from.sharedDepth(l.to)).inlineContent?t.dispatch(t.state.tr.deleteSelection()):t.updateState(t.state),!0}return!1}}function N8(t,e){if(!t.dom.parentNode)return;let n=t.dom.parentNode.appendChild(document.createElement("div"));n.appendChild(e),n.style.cssText="position: fixed; left: -10000px; top: 10px";let l=getSelection(),i=document.createRange();i.selectNodeContents(e),t.dom.blur(),l.removeAllRanges(),l.addRange(i),setTimeout(()=>{n.parentNode&&n.parentNode.removeChild(n),t.focus()},50)}var vo=We&&ll<15||Ki&&NS<604;Ve.copy=qe.cut=(t,e)=>{let n=e,l=t.state.selection,i=n.type=="cut";if(l.empty)return;let r=vo?null:n.clipboardData,o=l.content(),{dom:a,text:s}=Pd(t,o);r?(n.preventDefault(),r.clearData(),r.setData("text/html",a.innerHTML),r.setData("text/plain",s)):N8(t,a),i&&t.dispatch(t.state.tr.deleteSelection().scrollIntoView().setMeta("uiEvent","cut"))};function O8(t){return t.openStart==0&&t.openEnd==0&&t.content.childCount==1?t.content.firstChild:null}function A8(t,e){if(!t.dom.parentNode)return;let n=t.input.shiftKey||t.state.selection.$from.parent.type.spec.code,l=t.dom.parentNode.appendChild(document.createElement(n?"textarea":"div"));n||(l.contentEditable="true"),l.style.cssText="position: fixed; left: -10000px; top: 10px",l.focus();let i=t.input.shiftKey&&t.input.lastKeyCode!=45;setTimeout(()=>{t.focus(),l.parentNode&&l.parentNode.removeChild(l),n?Co(t,l.value,null,i,e):Co(t,l.textContent,l.innerHTML,i,e)},50)}function Co(t,e,n,l,i){let r=G2(t,e,n,l,t.state.selection.$from);if(t.someProp("handlePaste",s=>s(t,i,r||k.empty)))return!0;if(!r)return!1;let o=O8(r),a=o?t.state.tr.replaceSelectionWith(o,l):t.state.tr.replaceSelection(r);return t.dispatch(a.scrollIntoView().setMeta("paste",!0).setMeta("uiEvent","paste")),!0}function e3(t){let e=t.getData("text/plain")||t.getData("Text");if(e)return e;let n=t.getData("text/uri-list");return n?n.replace(/\r?\n/g," "):""}qe.paste=(t,e)=>{let n=e;if(t.composing&&!Sn)return;let l=vo?null:n.clipboardData,i=t.input.shiftKey&&t.input.lastKeyCode!=45;l&&Co(t,e3(l),l.getData("text/html"),i,n)?n.preventDefault():A8(t,n)};var js=class{constructor(e,n,l){this.slice=e,this.move=n,this.node=l}},D8=Ct?"altKey":"ctrlKey";function t3(t,e){let n;return t.someProp("dragCopies",l=>{n=n||l(e)}),n!=null?!n:!e[D8]}Ve.dragstart=(t,e)=>{let n=e,l=t.input.mouseDown;if(l&&l.done(),!n.dataTransfer)return;let i=t.state.selection,r=i.empty?null:t.posAtCoords(Qs(n)),o;if(!(r&&r.pos>=i.from&&r.pos<=(i instanceof B?i.to-1:i.to))){if(l&&l.mightDrag)o=B.create(t.state.doc,l.mightDrag.pos);else if(n.target&&n.target.nodeType==1){let f=t.docView.nearestDesc(n.target,!0);f&&f.node.type.spec.draggable&&f!=t.docView&&(o=B.create(t.state.doc,f.posBefore))}}let a=(o||t.state.selection).content(),{dom:s,text:u,slice:c}=Pd(t,a);(!n.dataTransfer.files.length||!we||D2>120)&&n.dataTransfer.clearData(),n.dataTransfer.setData(vo?"Text":"text/html",s.innerHTML),n.dataTransfer.effectAllowed="copyMove",vo||n.dataTransfer.setData("text/plain",u),t.dragging=new js(c,t3(t,n),o)};Ve.dragend=t=>{let e=t.dragging;window.setTimeout(()=>{t.dragging==e&&(t.dragging=null)},50)};qe.dragover=qe.dragenter=(t,e)=>e.preventDefault();qe.drop=(t,e)=>{try{k8(t,e,t.dragging)}finally{t.dragging=null}};function k8(t,e,n){if(!e.dataTransfer)return;let l=t.posAtCoords(Qs(e));if(!l)return;let i=t.state.doc.resolve(l.pos),r=n&&n.slice;r?t.someProp("transformPasted",h=>{r=h(r,t,!1)}):r=G2(t,e3(e.dataTransfer),vo?null:e.dataTransfer.getData("text/html"),!1,i);let o=!!(n&&t3(t,e));if(t.someProp("handleDrop",h=>h(t,e,r||k.empty,o))){e.preventDefault();return}if(!r)return;e.preventDefault();let a=r?ky(t.state.doc,i.pos,r):i.pos;a==null&&(a=i.pos);let s=t.state.tr;if(o){let{node:h}=n;h?h.replace(s):s.deleteSelection()}let u=s.mapping.map(a),c=r.openStart==0&&r.openEnd==0&&r.content.childCount==1,f=s.doc;if(c?s.replaceRangeWith(u,u,r.content.firstChild):s.replaceRange(u,u,r),s.doc.eq(f))return;let d=s.doc.resolve(u);if(c&&B.isSelectable(r.content.firstChild)&&d.nodeAfter&&d.nodeAfter.sameMarkup(r.content.firstChild))s.setSelection(new B(d));else{let h=s.mapping.map(a);s.mapping.maps[s.mapping.maps.length-1].forEach((p,b,v,g)=>h=g),s.setSelection(Wd(t,d,s.doc.resolve(h)))}t.focus(),t.dispatch(s.setMeta("uiEvent","drop"))}Ve.focus=t=>{t.input.lastFocus=Date.now(),t.focused||(t.domObserver.stop(),t.dom.classList.add("ProseMirror-focused"),t.domObserver.start(),t.focused=!0,setTimeout(()=>{t.docView&&t.hasFocus()&&!t.domObserver.currentSelection.eq(t.domSelectionRange())&&xn(t)},20))};Ve.blur=(t,e)=>{let n=e;t.focused&&(t.domObserver.stop(),t.dom.classList.remove("ProseMirror-focused"),t.domObserver.start(),n.relatedTarget&&t.dom.contains(n.relatedTarget)&&t.domObserver.currentSelection.clear(),t.focused=!1)};Ve.beforeinput=(t,e)=>{if(we&&Sn&&e.inputType=="deleteContentBackward"){t.domObserver.flushSoon();let{domChangeCount:l}=t.input;setTimeout(()=>{if(t.input.domChangeCount!=l||(t.dom.blur(),t.focus(),t.someProp("handleKeyDown",r=>r(t,Rl(8,"Backspace")))))return;let{$cursor:i}=t.state.selection;i&&i.pos>0&&t.dispatch(t.state.tr.delete(i.pos-1,i.pos).scrollIntoView())},50)}};for(let t in qe)Ve[t]=qe[t];function Mo(t,e){if(t==e)return!0;for(let n in t)if(t[n]!==e[n])return!1;for(let n in e)if(!(n in t))return!1;return!0}var Gs=class t{constructor(e,n){this.toDOM=e,this.spec=n||Hl,this.side=this.spec.side||0}map(e,n,l,i){let{pos:r,deleted:o}=e.mapResult(n.from+i,this.side<0?-1:1);return o?null:new Gt(r-l,r-l,this)}valid(){return!0}eq(e){return this==e||e instanceof t&&(this.spec.key&&this.spec.key==e.spec.key||this.toDOM==e.toDOM&&Mo(this.spec,e.spec))}destroy(e){this.spec.destroy&&this.spec.destroy(e)}},Bl=class t{constructor(e,n){this.attrs=e,this.spec=n||Hl}map(e,n,l,i){let r=e.map(n.from+i,this.spec.inclusiveStart?-1:1)-l,o=e.map(n.to+i,this.spec.inclusiveEnd?1:-1)-l;return r>=o?null:new Gt(r,o,this)}valid(e,n){return n.from=e&&(!r||r(a.spec))&&l.push(a.copy(a.from+i,a.to+i))}for(let o=0;oe){let a=this.children[o]+1;this.children[o+2].findInner(e-a,n-a,l,i+a,r)}}map(e,n,l){return this==Le||e.maps.length==0?this:this.mapInner(e,n,0,0,l||Hl)}mapInner(e,n,l,i,r){let o;for(let a=0;a{let u=s+l,c;if(c=l3(n,a,u)){for(i||(i=this.children.slice());ra&&f.to=e){this.children[a]==e&&(l=this.children[a+2]);break}let r=e+1,o=r+n.content.size;for(let a=0;ar&&s.type instanceof Bl){let u=Math.max(r,s.from)-r,c=Math.min(o,s.to)-r;ui.map(e,n,Hl));return t.from(l)}forChild(e,n){if(n.isLeaf)return Pe.empty;let l=[];for(let i=0;in instanceof Pe)?e:e.reduce((n,l)=>n.concat(l instanceof Pe?l:l.members),[]))}}forEachSet(e){for(let n=0;n{let v=b-p-(h-d);for(let g=0;gy+c-f)continue;let S=a[g]+c-f;h>=S?a[g+1]=d<=S?-2:-1:d>=c&&v&&(a[g]+=v,a[g+1]+=v)}f+=v}),c=n.maps[u].map(c,-1)}let s=!1;for(let u=0;u=l.content.size){s=!0;continue}let d=n.map(t[u+1]+r,-1),h=d-i,{index:p,offset:b}=l.content.findIndex(f),v=l.maybeChild(p);if(v&&b==f&&b+v.nodeSize==h){let g=a[u+2].mapInner(n,v,c+1,t[u]+r+1,o);g!=Le?(a[u]=f,a[u+1]=h,a[u+2]=g):(a[u+1]=-2,s=!0)}else s=!0}if(s){let u=z8(a,t,e,n,i,r,o),c=Ks(u,l,0,o);e=c.local;for(let f=0;fn&&o.to{let u=l3(t,a,s+n);if(u){r=!0;let c=Ks(u,a,n+s+1,l);c!=Le&&i.push(s,s+a.nodeSize,c)}});let o=n3(r?i3(t):t,-n).sort(Ul);for(let a=0;a0;)e++;t.splice(e,0,n)}function Dd(t){let e=[];return t.someProp("decorations",n=>{let l=n(t.state);l&&l!=Le&&e.push(l)}),t.cursorWrapper&&e.push(Pe.create(t.state.doc,[t.cursorWrapper.deco])),Xs.from(e)}var R8={childList:!0,characterData:!0,characterDataOldValue:!0,attributes:!0,attributeOldValue:!0,subtree:!0},L8=We&&ll<=11,Id=class{constructor(){this.anchorNode=null,this.anchorOffset=0,this.focusNode=null,this.focusOffset=0}set(e){this.anchorNode=e.anchorNode,this.anchorOffset=e.anchorOffset,this.focusNode=e.focusNode,this.focusOffset=e.focusOffset}clear(){this.anchorNode=this.focusNode=null}eq(e){return e.anchorNode==this.anchorNode&&e.anchorOffset==this.anchorOffset&&e.focusNode==this.focusNode&&e.focusOffset==this.focusOffset}},Qd=class{constructor(e,n){this.view=e,this.handleDOMChange=n,this.queue=[],this.flushingSoon=-1,this.observer=null,this.currentSelection=new Id,this.onCharData=null,this.suppressingSelectionUpdates=!1,this.lastChangedTextNode=null,this.observer=window.MutationObserver&&new window.MutationObserver(l=>{for(let i=0;ii.type=="childList"&&i.removedNodes.length||i.type=="characterData"&&i.oldValue.length>i.target.nodeValue.length)?this.flushSoon():_e&&e.composing&&l.some(i=>i.type=="childList"&&i.target.nodeName=="TR")?(e.input.badSafariComposition=!0,this.flushSoon()):this.flush()}),L8&&(this.onCharData=l=>{this.queue.push({target:l.target,type:"characterData",oldValue:l.prevValue}),this.flushSoon()}),this.onSelectionChange=this.onSelectionChange.bind(this)}flushSoon(){this.flushingSoon<0&&(this.flushingSoon=window.setTimeout(()=>{this.flushingSoon=-1,this.flush()},20))}forceFlush(){this.flushingSoon>-1&&(window.clearTimeout(this.flushingSoon),this.flushingSoon=-1,this.flush())}start(){this.observer&&(this.observer.takeRecords(),this.observer.observe(this.view.dom,R8)),this.onCharData&&this.view.dom.addEventListener("DOMCharacterDataModified",this.onCharData),this.connectSelection()}stop(){if(this.observer){let e=this.observer.takeRecords();if(e.length){for(let n=0;nthis.flush(),20)}this.observer.disconnect()}this.onCharData&&this.view.dom.removeEventListener("DOMCharacterDataModified",this.onCharData),this.disconnectSelection()}connectSelection(){this.view.dom.ownerDocument.addEventListener("selectionchange",this.onSelectionChange)}disconnectSelection(){this.view.dom.ownerDocument.removeEventListener("selectionchange",this.onSelectionChange)}suppressSelectionUpdates(){this.suppressingSelectionUpdates=!0,setTimeout(()=>this.suppressingSelectionUpdates=!1,50)}onSelectionChange(){if(u2(this.view)){if(this.suppressingSelectionUpdates)return xn(this.view);if(We&&ll<=11&&!this.view.state.selection.empty){let e=this.view.domSelectionRange();if(e.focusNode&&Vl(e.focusNode,e.focusOffset,e.anchorNode,e.anchorOffset))return this.flushSoon()}this.flush()}}setCurSelection(){this.currentSelection.set(this.view.domSelectionRange())}ignoreSelectionChange(e){if(!e.focusNode)return!0;let n=new Set,l;for(let r=e.focusNode;r;r=Xi(r))n.add(r);for(let r=e.anchorNode;r;r=Xi(r))if(n.has(r)){l=r;break}let i=l&&this.view.docView.nearestDesc(l);if(i&&i.ignoreMutation({type:"selection",target:l.nodeType==3?l.parentNode:l}))return this.setCurSelection(),!0}pendingRecords(){if(this.observer)for(let e of this.observer.takeRecords())this.queue.push(e);return this.queue}flush(){let{view:e}=this;if(!e.docView||this.flushingSoon>-1)return;let n=this.pendingRecords();n.length&&(this.queue=[]);let l=e.domSelectionRange(),i=!this.suppressingSelectionUpdates&&!this.currentSelection.eq(l)&&u2(e)&&!this.ignoreSelectionChange(l),r=-1,o=-1,a=!1,s=[];if(e.editable)for(let c=0;cc.nodeName=="BR")&&(e.input.lastKeyCode==8||e.input.lastKeyCode==46)){for(let c of s)if(c.nodeName=="BR"&&c.parentNode){let f=c.nextSibling;for(;f&&f.nodeType==1;){if(f.contentEditable=="false"){c.parentNode.removeChild(c);break}f=f.firstChild}}}else if(Tt&&s.length){let c=s.filter(f=>f.nodeName=="BR");if(c.length==2){let[f,d]=c;f.parentNode&&f.parentNode.parentNode==d.parentNode?d.remove():f.remove()}else{let{focusNode:f}=this.currentSelection;for(let d of c){let h=d.parentNode;h&&h.nodeName=="LI"&&(!f||H8(e,f)!=h)&&d.remove()}}}let u=null;r<0&&i&&e.input.lastFocus>Date.now()-200&&Math.max(e.input.lastTouch,e.input.lastClick.time)-1||i)&&(r>-1&&(e.docView.markDirty(r,o),_8(e)),e.input.badSafariComposition&&(e.input.badSafariComposition=!1,U8(e,s)),this.handleDOMChange(r,o,a,s),e.docView&&e.docView.dirty?e.updateState(e.state):this.currentSelection.eq(l)||xn(e),this.currentSelection.set(l))}registerMutation(e,n){if(n.indexOf(e.target)>-1)return null;let l=this.view.docView.nearestDesc(e.target);if(e.type=="attributes"&&(l==this.view.docView||e.attributeName=="contenteditable"||e.attributeName=="style"&&!e.oldValue&&!e.target.getAttribute("style"))||!l||l.ignoreMutation(e))return null;if(e.type=="childList"){for(let c=0;ci;v--){let g=l.childNodes[v-1],y=g.pmViewDesc;if(g.nodeName=="BR"&&!y){r=v;break}if(!y||y.size)break}let f=t.state.doc,d=t.someProp("domParser")||so.fromSchema(t.state.schema),h=f.resolve(o),p=null,b=d.parse(l,{topNode:h.parent,topMatch:h.parent.contentMatchAt(h.index()),topOpen:!0,from:i,to:r,preserveWhitespace:h.parent.type.whitespace=="pre"?"full":!0,findPositions:u,ruleFromNode:q8,context:h});if(u&&u[0].pos!=null){let v=u[0].pos,g=u[1]&&u[1].pos;g==null&&(g=v),p={anchor:v+o,head:g+o}}return{doc:b,sel:p,from:o,to:a}}function q8(t){let e=t.pmViewDesc;if(e)return e.parseRule();if(t.nodeName=="BR"&&t.parentNode){if(_e&&/^(ul|ol)$/i.test(t.parentNode.nodeName)){let n=document.createElement("div");return n.appendChild(document.createElement("li")),{skip:n}}else if(t.parentNode.lastChild==t||_e&&/^(tr|table)$/i.test(t.parentNode.nodeName))return{ignore:!0}}else if(t.nodeName=="IMG"&&t.getAttribute("mark-placeholder"))return{ignore:!0};return null}var Y8=/^(a|abbr|acronym|b|bd[io]|big|br|button|cite|code|data(list)?|del|dfn|em|i|img|ins|kbd|label|map|mark|meter|output|q|ruby|s|samp|small|span|strong|su[bp]|time|u|tt|var)$/i;function Z8(t,e,n,l,i){let r=t.input.compositionPendingChanges||(t.composing?t.input.compositionID:0);if(t.input.compositionPendingChanges=0,e<0){let M=t.input.lastSelectionTime>Date.now()-50?t.input.lastSelectionOrigin:null,N=Fd(t,M);if(N&&!t.state.selection.eq(N)){if(we&&Sn&&t.input.lastKeyCode===13&&Date.now()-100A(t,Rl(13,"Enter"))))return;let T=t.state.tr.setSelection(N);M=="pointer"?T.setMeta("pointer",!0):M=="key"&&T.scrollIntoView(),r&&T.setMeta("composition",r),t.dispatch(T)}return}let o=t.state.doc.resolve(e),a=o.sharedDepth(n);e=o.before(a+1),n=t.state.doc.resolve(n).after(a+1);let s=t.state.selection,u=V8(t,e,n),c=t.state.doc,f=c.slice(u.from,u.to),d,h;t.input.lastKeyCode===8&&Date.now()-100Date.now()-225||Sn)&&i.some(M=>M.nodeType==1&&!Y8.test(M.nodeName))&&(!p||p.endA>=p.endB)&&t.someProp("handleKeyDown",M=>M(t,Rl(13,"Enter")))){t.input.lastIOSEnter=0;return}if(!p)if(l&&s instanceof j&&!s.empty&&s.$head.sameParent(s.$anchor)&&!t.composing&&!(u.sel&&u.sel.anchor!=u.sel.head))p={start:s.from,endA:s.to,endB:s.to};else{if(u.sel){let M=v2(t,t.state.doc,u.sel);if(M&&!M.eq(t.state.selection)){let N=t.state.tr.setSelection(M);r&&N.setMeta("composition",r),t.dispatch(N)}}return}t.state.selection.fromt.state.selection.from&&p.start<=t.state.selection.from+2&&t.state.selection.from>=u.from?p.start=t.state.selection.from:p.endA=t.state.selection.to-2&&t.state.selection.to<=u.to&&(p.endB+=t.state.selection.to-p.endA,p.endA=t.state.selection.to)),We&&ll<=11&&p.endB==p.start+1&&p.endA==p.start&&p.start>u.from&&u.doc.textBetween(p.start-u.from-1,p.start-u.from+1)==" \xA0"&&(p.start--,p.endA--,p.endB--);let b=u.doc.resolveNoCache(p.start-u.from),v=u.doc.resolveNoCache(p.endB-u.from),g=c.resolve(p.start),y=b.sameParent(v)&&b.parent.inlineContent&&g.end()>=p.endA;if((Ki&&t.input.lastIOSEnter>Date.now()-225&&(!y||i.some(M=>M.nodeName=="DIV"||M.nodeName=="P"))||!y&&b.posM(t,Rl(13,"Enter")))){t.input.lastIOSEnter=0;return}if(t.state.selection.anchor>p.start&&G8(c,p.start,p.endA,b,v)&&t.someProp("handleKeyDown",M=>M(t,Rl(8,"Backspace")))){Sn&&we&&t.domObserver.suppressSelectionUpdates();return}we&&p.endB==p.start&&(t.input.lastChromeDelete=Date.now()),Sn&&!y&&b.start()!=v.start()&&v.parentOffset==0&&b.depth==v.depth&&u.sel&&u.sel.anchor==u.sel.head&&u.sel.head==p.endA&&(p.endB-=2,v=u.doc.resolveNoCache(p.endB-u.from),setTimeout(()=>{t.someProp("handleKeyDown",function(M){return M(t,Rl(13,"Enter"))})},20));let S=p.start,x=p.endA,O=M=>{let N=M||t.state.tr.replace(S,x,u.doc.slice(p.start-u.from,p.endB-u.from));if(u.sel){let T=v2(t,N.doc,u.sel);T&&!(we&&t.composing&&T.empty&&(p.start!=p.endB||t.input.lastChromeDeletexn(t),20));let M=O(t.state.tr.delete(S,x)),N=c.resolve(p.start).marksAcross(c.resolve(p.endA));N&&M.ensureMarks(N),t.dispatch(M)}else if(p.endA==p.endB&&(z=j8(b.parent.content.cut(b.parentOffset,v.parentOffset),g.parent.content.cut(g.parentOffset,p.endA-g.start())))){let M=O(t.state.tr);z.type=="add"?M.addMark(S,x,z.mark):M.removeMark(S,x,z.mark),t.dispatch(M)}else if(b.parent.child(b.index()).isText&&b.index()==v.index()-(v.textOffset?0:1)){let M=b.parent.textBetween(b.parentOffset,v.parentOffset),N=()=>O(t.state.tr.insertText(M,S,x));t.someProp("handleTextInput",T=>T(t,S,x,M,N))||t.dispatch(N())}else t.dispatch(O());else t.dispatch(O())}function v2(t,e,n){return Math.max(n.anchor,n.head)>e.content.size?null:Wd(t,e.resolve(n.anchor),e.resolve(n.head))}function j8(t,e){let n=t.firstChild.marks,l=e.firstChild.marks,i=n,r=l,o,a,s;for(let c=0;cc.mark(a.addToSet(c.marks));else if(i.length==0&&r.length==1)a=r[0],o="remove",s=c=>c.mark(a.removeFromSet(c.marks));else return null;let u=[];for(let c=0;cn||kd(o,!0,!1)0&&(e||t.indexAfter(l)==t.node(l).childCount);)l--,i++,e=!1;if(n){let r=t.node(l).maybeChild(t.indexAfter(l));for(;r&&!r.isLeaf;)r=r.firstChild,i++}return i}function X8(t,e,n,l,i){let r=t.findDiffStart(e,n);if(r==null)return null;let{a:o,b:a}=t.findDiffEnd(e,n+t.size,n+e.size);if(i=="end"){let s=Math.max(0,r-Math.min(o,a));l-=o+s-r}if(o=o?r-l:0;r-=s,r&&r=a?r-l:0;r-=s,r&&r=56320&&e<=57343&&n>=55296&&n<=56319}var To=class{constructor(e,n){this._root=null,this.focused=!1,this.trackWrites=null,this.mounted=!1,this.markCursor=null,this.cursorWrapper=null,this.lastSelectedViewDesc=void 0,this.input=new jd,this.prevDirectPlugins=[],this.pluginViews=[],this.requiresGeckoHackNode=!1,this.dragging=null,this._props=n,this.state=n.state,this.directPlugins=n.plugins||[],this.directPlugins.forEach(O2),this.dispatch=this.dispatch.bind(this),this.dom=e&&e.mount||document.createElement("div"),e&&(e.appendChild?e.appendChild(this.dom):typeof e=="function"?e(this.dom):e.mount&&(this.mounted=!0)),this.editable=E2(this),T2(this),this.nodeViews=N2(this),this.docView=l2(this.state.doc,M2(this),Dd(this),this.dom,this),this.domObserver=new Qd(this,(l,i,r,o)=>Z8(this,l,i,r,o)),this.domObserver.start(),f8(this),this.updatePluginViews()}get composing(){return this.input.composing}get props(){if(this._props.state!=this.state){let e=this._props;this._props={};for(let n in e)this._props[n]=e[n];this._props.state=this.state}return this._props}update(e){e.handleDOMEvents!=this._props.handleDOMEvents&&Gd(this);let n=this._props;this._props=e,e.plugins&&(e.plugins.forEach(O2),this.directPlugins=e.plugins),this.updateStateInner(e.state,n)}setProps(e){let n={};for(let l in this._props)n[l]=this._props[l];n.state=this.state;for(let l in e)n[l]=e[l];this.update(n)}updateState(e){this.updateStateInner(e,this._props)}updateStateInner(e,n){var l;let i=this.state,r=!1,o=!1;e.storedMarks&&this.composing&&($2(this),o=!0),this.state=e;let a=i.plugins!=e.plugins||this._props.plugins!=n.plugins;if(a||this._props.plugins!=n.plugins||this._props.nodeViews!=n.nodeViews){let h=N2(this);I8(h,this.nodeViews)&&(this.nodeViews=h,r=!0)}(a||n.handleDOMEvents!=this._props.handleDOMEvents)&&Gd(this),this.editable=E2(this),T2(this);let s=Dd(this),u=M2(this),c=i.plugins!=e.plugins&&!i.doc.eq(e.doc)?"reset":e.scrollToSelection>i.scrollToSelection?"to selection":"preserve",f=r||!this.docView.matchesNode(e.doc,u,s);(f||!e.selection.eq(i.selection))&&(o=!0);let d=c=="preserve"&&o&&this.dom.style.overflowAnchor==null&&DS(this);if(o){this.domObserver.stop();let h=f&&(We||we)&&!this.composing&&!i.selection.empty&&!e.selection.empty&&K8(i.selection,e.selection);if(f){let p=we?this.trackWrites=this.domSelectionRange().focusNode:null;this.composing&&(this.input.compositionNode=T8(this)),(r||!this.docView.update(e.doc,u,s,this))&&(this.docView.updateOuterDeco(u),this.docView.destroy(),this.docView=l2(e.doc,u,s,this.dom,this)),p&&(!this.trackWrites||!this.dom.contains(this.trackWrites))&&(h=!0)}h||!(this.input.mouseDown&&this.domObserver.currentSelection.eq(this.domSelectionRange())&&FS(this))?xn(this,h):(Y2(this,e.selection),this.domObserver.setCurSelection()),this.domObserver.start()}this.updatePluginViews(i),!((l=this.dragging)===null||l===void 0)&&l.node&&!i.doc.eq(e.doc)&&this.updateDraggedNode(this.dragging,i),c=="reset"?this.dom.scrollTop=0:c=="to selection"?this.scrollToSelection():d&&kS(d)}scrollToSelection(){let e=this.domSelectionRange().focusNode;if(!(!e||!this.dom.contains(e.nodeType==1?e:e.parentNode))){if(!this.someProp("handleScrollToSelection",n=>n(this)))if(this.state.selection instanceof B){let n=this.docView.domAfterPos(this.state.selection.from);n.nodeType==1&&Wy(this,n.getBoundingClientRect(),e)}else Wy(this,this.coordsAtPos(this.state.selection.head,1),e)}}destroyPluginViews(){let e;for(;e=this.pluginViews.pop();)e.destroy&&e.destroy()}updatePluginViews(e){if(!e||e.plugins!=this.state.plugins||this.directPlugins!=this.prevDirectPlugins){this.prevDirectPlugins=this.directPlugins,this.destroyPluginViews();for(let n=0;n0&&rn.ownerDocument.getSelection()),this._root=n}return e||document}updateRoot(){this._root=null}posAtCoords(e){return BS(this,e)}coordsAtPos(e,n=1){return _2(this,e,n)}domAtPos(e,n=0){return this.docView.domFromPos(e,n)}nodeDOM(e){let n=this.docView.descAt(e);return n?n.nodeDOM:null}posAtDOM(e,n,l=-1){let i=this.docView.posFromDOM(e,n,l);if(i==null)throw new RangeError("DOM position not inside the editor");return i}endOfTextblock(e,n){return YS(this,n||this.state,e)}pasteHTML(e,n){return Co(this,"",e,!1,n||new ClipboardEvent("paste"))}pasteText(e,n){return Co(this,e,null,!0,n||new ClipboardEvent("paste"))}serializeForClipboard(e){return Pd(this,e)}destroy(){this.docView&&(d8(this),this.destroyPluginViews(),this.mounted?(this.docView.update(this.state.doc,[],Dd(this),this),this.dom.textContent=""):this.dom.parentNode&&this.dom.parentNode.removeChild(this.dom),this.docView.destroy(),this.docView=null,SS())}get isDestroyed(){return this.docView==null}dispatchEvent(e){return p8(this,e)}domSelectionRange(){let e=this.domSelection();return e?_e&&this.root.nodeType===11&&TS(this.dom.ownerDocument)==this.dom&&B8(this,e)||e:{focusNode:null,focusOffset:0,anchorNode:null,anchorOffset:0}}domSelection(){return this.root.getSelection()}};To.prototype.dispatch=function(t){let e=this._props.dispatchTransaction;e?e.call(this,t):this.updateState(this.state.apply(t))};function M2(t){let e=Object.create(null);return e.class="ProseMirror",e.contenteditable=String(t.editable),t.someProp("attributes",n=>{if(typeof n=="function"&&(n=n(t.state)),n)for(let l in n)l=="class"?e.class+=" "+n[l]:l=="style"?e.style=(e.style?e.style+";":"")+n[l]:!e[l]&&l!="contenteditable"&&l!="nodeName"&&(e[l]=String(n[l]))}),e.translate||(e.translate="no"),[Gt.node(0,t.state.doc.content.size,e)]}function T2(t){if(t.markCursor){let e=document.createElement("img");e.className="ProseMirror-separator",e.setAttribute("mark-placeholder","true"),e.setAttribute("alt",""),t.cursorWrapper={dom:e,deco:Gt.widget(t.state.selection.from,e,{raw:!0,marks:t.markCursor})}}else t.cursorWrapper=null}function E2(t){return!t.someProp("editable",e=>e(t.state)===!1)}function K8(t,e){let n=Math.min(t.$anchor.sharedDepth(t.head),e.$anchor.sharedDepth(e.head));return t.$anchor.start(n)!=e.$anchor.start(n)}function N2(t){let e=Object.create(null);function n(l){for(let i in l)Object.prototype.hasOwnProperty.call(e,i)||(e[i]=l[i])}return t.someProp("nodeViews",n),t.someProp("markViews",n),e}function I8(t,e){let n=0,l=0;for(let i in t){if(t[i]!=e[i])return!0;n++}for(let i in e)l++;return n!=l}function O2(t){if(t.spec.state||t.spec.filterTransaction||t.spec.appendTransaction)throw new RangeError("Plugins passed directly to the view must not have a state component")}function r3(t,e=!1){return t.key==="Enter"&&(t.isComposing===!0||e||t.keyCode===229)}function o3(t){return t.key==="Tab"&&t.shiftKey===!0&&!t.altKey&&!t.ctrlKey&&!t.metaKey}var lh=new Ms({nodes:{doc:{content:"paragraph+"},paragraph:{content:"inline*",group:"block",parseDOM:[{tag:"p"}],toDOM:()=>["p",0]},text:{group:"inline"},atMention:{attrs:{label:{validate:"string"},path:{validate:"string"},fsPath:{validate:"string",default:""}},inline:!0,group:"inline",draggable:!1,selectable:!1,toDOM:t=>ih({text:t.attrs.label,iconNode:l7(),dataAttributes:{"at-mention-label":t.attrs.label,"at-mention-path":t.attrs.path,"at-mention-fs-path":t.attrs.fsPath}}),parseDOM:[{tag:"span[at-mention-label][at-mention-path]",getAttrs:t=>{let e=t;return{label:e.getAttribute("at-mention-label"),path:e.getAttribute("at-mention-path"),fsPath:e.getAttribute("at-mention-fs-path")??""}}}]},agentMention:{attrs:{name:{validate:"string"},displayName:{validate:"string",default:""},path:{validate:"string"}},inline:!0,group:"inline",draggable:!1,selectable:!1,toDOM:t=>{let e=t.attrs.displayName||t.attrs.name;return ih({text:`@${e}`,dataAttributes:{"agent-mention-name":t.attrs.name,"agent-mention-display-name":e,"agent-mention-path":t.attrs.path}})},parseDOM:[{tag:"span[agent-mention-name][agent-mention-path]",getAttrs:t=>{let e=t,n=e.getAttribute("agent-mention-name")??"";return{name:n,displayName:e.getAttribute("agent-mention-display-name")??n,path:e.getAttribute("agent-mention-path")??""}}}]},skillMention:{attrs:{name:{validate:"string"},displayName:{validate:"string",default:""},path:{validate:"string"},description:{validate:"string",default:""}},inline:!0,group:"inline",draggable:!1,selectable:!1,toDOM:t=>{let e=t.attrs.displayName||t.attrs.name;return ih({text:e,icon:"$",dataAttributes:{"skill-mention-name":t.attrs.name,"skill-mention-display-name":e,"skill-mention-path":t.attrs.path},title:t.attrs.description})},parseDOM:[{tag:"span[skill-mention-name][skill-mention-path]",getAttrs:t=>{let e=t,n=e.getAttribute("skill-mention-name")??"";return{name:n,displayName:e.getAttribute("skill-mention-display-name")??n,path:e.getAttribute("skill-mention-path")??"",description:e.getAttribute("title")??""}}}]}},marks:{}}),Js=new Bs("agentPromptPlaceholder"),a3=se.default.forwardRef(function({ariaLabel:e,className:n,minHeight:l="2.75rem",onAutocompleteChange:i,onAutocompleteKeyDown:r,onPlanModeShortcut:o,onSubmit:a,onTextChange:s,onTriggerToken:u,placeholder:c,singleLine:f=!1,value:d},h){let p=(0,se.useRef)(null),b=(0,se.useRef)(null),v=(0,se.useRef)(a),g=(0,se.useRef)(i),y=(0,se.useRef)(r),S=(0,se.useRef)(o),x=(0,se.useRef)(s),O=(0,se.useRef)(u),z=(0,se.useRef)(d),M=(0,se.useRef)(f);v.current=a,g.current=i,y.current=r,S.current=o,x.current=s,O.current=u,M.current=f,(0,se.useImperativeHandle)(h,()=>({focus(){b.current?.focus()},getText(){return z.current},insertMention(T){let A=b.current;A&&e7(A,T)},insertMentions(T){let A=b.current;!A||T.length===0||t7(A,T)},insertText(T){let A=b.current;A&&$8(A,T)}}),[]),(0,se.useLayoutEffect)(()=>{let T=p.current;if(!T)return;let A=new To(T,{state:Ls.create({doc:s3(z.current),plugins:[J8("")]}),attributes:{"aria-label":"","data-codex-composer":"true","data-virtualkeyboard":"true",role:"textbox",class:"ProseMirror prompt-editor-view",style:"min-height: 2.75rem; font-size: var(--codex-chat-font-size); height: auto; resize: none;"},dispatchTransaction(G){let J=A.state.apply(G);A.updateState(J);let ye=F8(J.doc),Qi=z.current;if(ye!==Qi){z.current=ye;let zt=Q8(Qi,ye);zt&&O.current?.(zt),x.current(ye)}g.current?.(c3(A))},handleKeyDown(G,J){return r3(J,G.composing)?!1:o3(J)&&S.current?(J.preventDefault(),S.current(),!0):n7(J.key)&&y.current?.(J.key)?(J.preventDefault(),!0):(J.metaKey||J.ctrlKey)&&J.key==="Enter"?(J.preventDefault(),v.current(),!0):J.key!=="Enter"?!1:J.shiftKey||J.altKey?(J.preventDefault(),M.current?!0:Td(G.state,G.dispatch,G)):(J.preventDefault(),v.current(),!0)}});return b.current=A,()=>{A.destroy(),b.current=null}},[]),(0,se.useLayoutEffect)(()=>{let T=b.current;T&&T.dispatch(T.state.tr.setMeta(Js,c))},[c]),(0,se.useLayoutEffect)(()=>{let T=b.current;if(!T)return;let A=e??c;A?T.dom.setAttribute("aria-label",A):T.dom.removeAttribute("aria-label")},[e,c]),(0,se.useLayoutEffect)(()=>{let T=b.current;T&&(T.dom.style.minHeight=l)},[l]),(0,se.useLayoutEffect)(()=>{let T=b.current;!T||d===z.current||(z.current=d,P8(T,d))},[d]);let N=["text-size-chat","[&_.ProseMirror]:focus-visible:outline-none","text-token-foreground",f?"flex h-9 max-h-none items-center overflow-hidden [&_.ProseMirror]:!h-5 [&_.ProseMirror]:!min-h-5 [&_.ProseMirror]:min-w-0 [&_.ProseMirror]:flex-1 [&_.ProseMirror]:overflow-hidden [&_.ProseMirror]:whitespace-nowrap [&_.ProseMirror_p]:overflow-hidden [&_.ProseMirror_p]:text-ellipsis [&_.ProseMirror_p]:whitespace-nowrap":"h-auto max-h-[25dvh] overflow-y-auto [&_.ProseMirror]:h-auto [&_.ProseMirror]:min-h-[2rem]","[&_.ProseMirror]:resize-none","[&_.ProseMirror_p]:m-0",n].filter(Boolean).join(" ");return se.default.createElement("div",{className:N,onMouseDown:T=>{let A=b.current;A&&T.target instanceof Node&&!A.dom.contains(T.target)&&(T.preventDefault(),A.focus())},ref:p})});function Q8(t,e){if(e.length!==t.length+1)return null;let n=0;for(;nt,apply(e,n){return e.getMeta(Js)??n}},props:{decorations(e){let n=Js.getState(e)??"";if(!n||e.doc.childCount!==1)return null;let l=e.doc.firstChild;return!l?.isTextblock||l.content.size!==0?null:Pe.create(e.doc,[Gt.node(0,l.nodeSize,{class:"placeholder","data-placeholder":n})])}}})}function s3(t){let e=t.split(` -`);return lh.nodes.doc.create(null,e.map(n=>lh.nodes.paragraph.create(null,n.length>0?lh.text(n):null)))}function F8(t){let e=[];return t.forEach(n=>{let l=[];n.forEach(i=>{l.push(W8(i))}),e.push(l.join(""))}),e.join(` -`)}function W8(t){switch(t.type.name){case"atMention":return no({kind:"at",label:t.attrs.label,name:t.attrs.label,path:t.attrs.path});case"agentMention":return no({kind:"agent",displayName:t.attrs.displayName||t.attrs.name,name:t.attrs.name,path:t.attrs.path});case"skillMention":return no({kind:"skill",displayName:t.attrs.displayName||t.attrs.name,name:t.attrs.name,path:t.attrs.path});default:return t.textContent}}function P8(t,e){let n=s3(e),l=t.state.tr.replaceWith(0,t.state.doc.content.size,n.content);l.setSelection(j.atEnd(l.doc)),t.dispatch(l)}function $8(t,e){let{state:n}=t,{from:l,to:i}=n.selection,r=e.startsWith("@")?"@":e.startsWith("$")?"$":null,o=n.doc.textBetween(Math.max(0,l-1),l,` +`);return{dom:s,text:d,slice:e}}function K2(t,e,n,l,i){let r=i.parent.type.spec.code,o,a;if(!n&&!e)return null;let s=!!e&&(l||r||!n);if(s){if(t.someProp("transformPastedText",d=>{e=d(e,r||l,t)}),r)return a=new k(D.from(t.state.schema.text(e.replace(/\r\n?/g,` +`))),0,0),t.someProp("transformPasted",d=>{a=d(a,t,!0)}),a;let f=t.someProp("clipboardTextParser",d=>d(e,i,l,t));if(f)a=f;else{let d=i.marks(),{schema:h}=t.state,p=kl.fromSchema(h);o=document.createElement("div"),e.split(/(?:\r\n?|\n)+/).forEach(b=>{let v=o.appendChild(document.createElement("p"));b&&v.appendChild(p.serializeNode(h.text(b,d)))})}}else t.someProp("transformPastedHTML",f=>{n=f(n,t)}),o=d8(n),Do&&h8(o);let u=o&&o.querySelector("[data-pm-slice]"),c=u&&/^(\d+) (\d+)(?: -(\d+))? (.*)/.exec(u.getAttribute("data-pm-slice")||"");if(c&&c[3])for(let f=+c[3];f>0;f--){let d=o.firstChild;for(;d&&d.nodeType!=1;)d=d.nextSibling;if(!d)break;o=d}if(a||(a=(t.someProp("clipboardParser")||t.someProp("domParser")||fo.fromSchema(t.state.schema)).parseSlice(o,{preserveWhitespace:!!(s||c),context:i,ruleFromNode(d){return d.nodeName=="BR"&&!d.nextSibling&&d.parentNode&&!u8.test(d.parentNode.nodeName)?{ignore:!0}:null}})),c)a=p8(b2(a,+c[1],+c[2]),c[4]);else if(a=k.maxOpen(c8(a.content,i),!0),a.openStart||a.openEnd){let f=0,d=0;for(let h=a.content.firstChild;f{a=f(a,t,s)}),a}var u8=/^(a|abbr|acronym|b|cite|code|del|em|i|ins|kbd|label|output|q|ruby|s|samp|span|strong|sub|sup|time|u|tt|var)$/i;function c8(t,e){if(t.childCount<2)return t;for(let n=e.depth;n>=0;n--){let i=e.node(n).contentMatchAt(e.index(n)),r,o=[];if(t.forEach(a=>{if(!o)return;let s=i.findWrapping(a.type),u;if(!s)return o=null;if(u=o.length&&r.length&&F2(s,r,a,o[o.length-1],0))o[o.length-1]=u;else{o.length&&(o[o.length-1]=J2(o[o.length-1],r.length));let c=Q2(a,s);o.push(c),i=i.matchType(c.type),r=s}}),o)return D.from(o)}return t}function Q2(t,e,n=0){for(let l=e.length-1;l>=n;l--)t=e[l].create(null,D.from(t));return t}function F2(t,e,n,l,i){if(i1&&(r=0),i=n&&(a=e<0?o.contentMatchAt(0).fillBefore(a,r<=i).append(a):a.append(o.contentMatchAt(o.childCount).fillBefore(D.empty,!0))),t.replaceChild(e<0?0:t.childCount-1,o.copy(a))}function b2(t,e,n){return en})),Dd.createHTML(t)):t}function d8(t){let e=/^(\s*]*>)*/.exec(t);e&&(t=t.slice(e[0].length));let n=W2().createElement("div"),l=/<([a-z][^>\s]+)/i.exec(t),i;if((i=l&&P2[l[1].toLowerCase()])&&(t=i.map(r=>"<"+r+">").join("")+t+i.map(r=>"").reverse().join("")),n.innerHTML=f8(t),i)for(let r=0;r=0;a-=2){let s=n.nodes[l[a]];if(!s||s.hasRequiredAttrs())break;i=D.from(s.create(l[a+1],i)),r++,o++}return new k(i,r,o)}var Ve={},qe={},m8={touchstart:!0,touchmove:!0},Gd=class{constructor(){this.shiftKey=!1,this.mouseDown=null,this.lastKeyCode=null,this.lastKeyCodeTime=0,this.lastClick={time:0,x:0,y:0,type:"",button:0},this.lastSelectionOrigin=null,this.lastSelectionTime=0,this.lastIOSEnter=0,this.lastIOSEnterFallbackTimeout=-1,this.lastFocus=0,this.lastTouch=0,this.lastChromeDelete=0,this.composing=!1,this.compositionNode=null,this.composingTimeout=-1,this.compositionNodes=[],this.compositionEndedAt=-2e8,this.compositionID=1,this.badSafariComposition=!1,this.compositionPendingChanges=0,this.domChangeCount=0,this.eventHandlers=Object.create(null),this.hideSelectionGuard=null}};function g8(t){for(let e in Ve){let n=Ve[e];t.dom.addEventListener(e,t.input.eventHandlers[e]=l=>{b8(t,l)&&!eh(t,l)&&(t.editable||!(l.type in qe))&&n(t,l)},m8[e]?{passive:!0}:void 0)}_e&&t.dom.addEventListener("input",()=>null),Xd(t)}function nl(t,e){t.input.lastSelectionOrigin=e,t.input.lastSelectionTime=Date.now()}function y8(t){t.domObserver.stop();for(let e in t.input.eventHandlers)t.dom.removeEventListener(e,t.input.eventHandlers[e]);clearTimeout(t.input.composingTimeout),clearTimeout(t.input.lastIOSEnterFallbackTimeout)}function Xd(t){t.someProp("handleDOMEvents",e=>{for(let n in e)t.input.eventHandlers[n]||t.dom.addEventListener(n,t.input.eventHandlers[n]=l=>eh(t,l))})}function eh(t,e){return t.someProp("handleDOMEvents",n=>{let l=n[e.type];return l?l(t,e)||e.defaultPrevented:!1})}function b8(t,e){if(!e.bubbles)return!0;if(e.defaultPrevented)return!1;for(let n=e.target;n!=t.dom;n=n.parentNode)if(!n||n.nodeType==11||n.pmViewDesc&&n.pmViewDesc.stopEvent(e))return!1;return!0}function S8(t,e){!eh(t,e)&&Ve[e.type]&&(t.editable||!(e.type in qe))&&Ve[e.type](t,e)}qe.keydown=(t,e)=>{let n=e;if(t.input.shiftKey=n.keyCode==16||n.shiftKey,!e3(t,n)&&(t.input.lastKeyCode=n.keyCode,t.input.lastKeyCodeTime=Date.now(),!(Sn&&we&&n.keyCode==13)))if(n.keyCode!=229&&t.domObserver.forceFlush(),Qi&&n.keyCode==13&&!n.ctrlKey&&!n.altKey&&!n.metaKey){let l=Date.now();t.input.lastIOSEnter=l,t.input.lastIOSEnterFallbackTimeout=setTimeout(()=>{t.input.lastIOSEnter==l&&(t.someProp("handleKeyDown",i=>i(t,Rl(13,"Enter"))),t.input.lastIOSEnter=0)},200)}else t.someProp("handleKeyDown",l=>l(t,n))||s8(t,n)?n.preventDefault():nl(t,"key")};qe.keyup=(t,e)=>{e.keyCode==16&&(t.input.shiftKey=!1)};qe.keypress=(t,e)=>{let n=e;if(e3(t,n)||!n.charCode||n.ctrlKey&&!n.altKey||Mt&&n.metaKey)return;if(t.someProp("handleKeyPress",i=>i(t,n))){n.preventDefault();return}let l=t.state.selection;if(!(l instanceof j)||!l.$from.sameParent(l.$to)){let i=String.fromCharCode(n.charCode),r=()=>t.state.tr.insertText(i).scrollIntoView();!/[\r\n]/.test(i)&&!t.someProp("handleTextInput",o=>o(t,l.$from.pos,l.$to.pos,i,r))&&t.dispatch(r()),n.preventDefault()}};function Js(t){return{left:t.clientX,top:t.clientY}}function x8(t,e){let n=e.x-t.clientX,l=e.y-t.clientY;return n*n+l*l<100}function th(t,e,n,l,i){if(l==-1)return!1;let r=t.state.doc.resolve(l);for(let o=r.depth+1;o>0;o--)if(t.someProp(e,a=>o>r.depth?a(t,n,r.nodeAfter,r.before(o),i,!0):a(t,n,r.node(o),r.before(o),i,!1)))return!0;return!1}function Ii(t,e,n){if(t.focused||t.focus(),t.state.selection.eq(e))return;let l=t.state.tr.setSelection(e);n=="pointer"&&l.setMeta("pointer",!0),t.dispatch(l)}function v8(t,e){if(e==-1)return!1;let n=t.state.doc.resolve(e),l=n.nodeAfter;return l&&l.isAtom&&B.isSelectable(l)?(Ii(t,new B(n),"pointer"),!0):!1}function C8(t,e){if(e==-1)return!1;let n=t.state.selection,l,i;n instanceof B&&(l=n.node);let r=t.state.doc.resolve(e);for(let o=r.depth+1;o>0;o--){let a=o>r.depth?r.nodeAfter:r.node(o);if(B.isSelectable(a)){l&&n.$from.depth>0&&o>=n.$from.depth&&r.before(n.$from.depth+1)==n.$from.pos?i=r.before(n.$from.depth):i=r.before(o);break}}return i!=null?(Ii(t,B.create(t.state.doc,i),"pointer"),!0):!1}function M8(t,e,n,l,i){return th(t,"handleClickOn",e,n,l)||t.someProp("handleClick",r=>r(t,e,l))||(i?C8(t,n):v8(t,n))}function T8(t,e,n,l){return th(t,"handleDoubleClickOn",e,n,l)||t.someProp("handleDoubleClick",i=>i(t,e,l))}function E8(t,e,n,l){return th(t,"handleTripleClickOn",e,n,l)||t.someProp("handleTripleClick",i=>i(t,e,l))||N8(t,n,l)}function N8(t,e,n){if(n.button!=0)return!1;let l=t.state.doc;if(e==-1)return l.inlineContent?(Ii(t,j.create(l,0,l.content.size),"pointer"),!0):!1;let i=l.resolve(e);for(let r=i.depth+1;r>0;r--){let o=r>i.depth?i.nodeAfter:i.node(r),a=i.before(r);if(o.inlineContent)Ii(t,j.create(l,a+1,a+1+o.content.size),"pointer");else if(B.isSelectable(o))Ii(t,B.create(l,a),"pointer");else continue;return!0}}function nh(t){return Gs(t)}var $2=Mt?"metaKey":"ctrlKey";Ve.mousedown=(t,e)=>{let n=e;t.input.shiftKey=n.shiftKey;let l=nh(t),i=Date.now(),r="singleClick";i-t.input.lastClick.time<500&&x8(n,t.input.lastClick)&&!n[$2]&&t.input.lastClick.button==n.button&&(t.input.lastClick.type=="singleClick"?r="doubleClick":t.input.lastClick.type=="doubleClick"&&(r="tripleClick")),t.input.lastClick={time:i,x:n.clientX,y:n.clientY,type:r,button:n.button};let o=t.posAtCoords(Js(n));o&&(r=="singleClick"?(t.input.mouseDown&&t.input.mouseDown.done(),t.input.mouseDown=new Id(t,o,n,!!l)):(r=="doubleClick"?T8:E8)(t,o.pos,o.inside,n)?n.preventDefault():nl(t,"pointer"))};var Id=class{constructor(e,n,l,i){this.view=e,this.pos=n,this.event=l,this.flushed=i,this.delayedSelectionSync=!1,this.mightDrag=null,this.startDoc=e.state.doc,this.selectNode=!!l[$2],this.allowDefault=l.shiftKey;let r,o;if(n.inside>-1)r=e.state.doc.nodeAt(n.inside),o=n.inside;else{let c=e.state.doc.resolve(n.pos);r=c.parent,o=c.depth?c.before():0}let a=i?null:l.target,s=a?e.docView.nearestDesc(a,!0):null;this.target=s&&s.nodeDOM.nodeType==1?s.nodeDOM:null;let{selection:u}=e.state;l.button==0&&(r.type.spec.draggable&&r.type.spec.selectable!==!1||u instanceof B&&u.from<=o&&u.to>o)&&(this.mightDrag={node:r,pos:o,addAttr:!!(this.target&&!this.target.draggable),setUneditable:!!(this.target&&Et&&!this.target.hasAttribute("contentEditable"))}),this.target&&this.mightDrag&&(this.mightDrag.addAttr||this.mightDrag.setUneditable)&&(this.view.domObserver.stop(),this.mightDrag.addAttr&&(this.target.draggable=!0),this.mightDrag.setUneditable&&setTimeout(()=>{this.view.input.mouseDown==this&&this.target.setAttribute("contentEditable","false")},20),this.view.domObserver.start()),e.root.addEventListener("mouseup",this.up=this.up.bind(this)),e.root.addEventListener("mousemove",this.move=this.move.bind(this)),nl(e,"pointer")}done(){this.view.root.removeEventListener("mouseup",this.up),this.view.root.removeEventListener("mousemove",this.move),this.mightDrag&&this.target&&(this.view.domObserver.stop(),this.mightDrag.addAttr&&this.target.removeAttribute("draggable"),this.mightDrag.setUneditable&&this.target.removeAttribute("contentEditable"),this.view.domObserver.start()),this.delayedSelectionSync&&setTimeout(()=>xn(this.view)),this.view.input.mouseDown=null}up(e){if(this.done(),!this.view.dom.contains(e.target))return;let n=this.pos;this.view.state.doc!=this.startDoc&&(n=this.view.posAtCoords(Js(e))),this.updateAllowDefault(e),this.allowDefault||!n?nl(this.view,"pointer"):M8(this.view,n.pos,n.inside,e,this.selectNode)?e.preventDefault():e.button==0&&(this.flushed||_e&&this.mightDrag&&!this.mightDrag.node.isAtom||we&&!this.view.state.selection.visible&&Math.min(Math.abs(n.pos-this.view.state.selection.from),Math.abs(n.pos-this.view.state.selection.to))<=2)?(Ii(this.view,Y.near(this.view.state.doc.resolve(n.pos)),"pointer"),e.preventDefault()):nl(this.view,"pointer")}move(e){this.updateAllowDefault(e),nl(this.view,"pointer"),e.buttons==0&&this.done()}updateAllowDefault(e){!this.allowDefault&&(Math.abs(this.event.x-e.clientX)>4||Math.abs(this.event.y-e.clientY)>4)&&(this.allowDefault=!0)}};Ve.touchstart=t=>{t.input.lastTouch=Date.now(),nh(t),nl(t,"pointer")};Ve.touchmove=t=>{t.input.lastTouch=Date.now(),nl(t,"pointer")};Ve.contextmenu=t=>nh(t);function e3(t,e){return t.composing?!0:_e&&Math.abs(e.timeStamp-t.input.compositionEndedAt)<500?(t.input.compositionEndedAt=-2e8,!0):!1}var A8=Sn?5e3:-1;qe.compositionstart=qe.compositionupdate=t=>{if(!t.composing){t.domObserver.flush();let{state:e}=t,n=e.selection.$to;if(e.selection instanceof j&&(e.storedMarks||!n.textOffset&&n.parentOffset&&n.nodeBefore.marks.some(l=>l.type.spec.inclusive===!1)||we&&R2&&O8(t)))t.markCursor=t.state.storedMarks||n.marks(),Gs(t,!0),t.markCursor=null;else if(Gs(t,!e.selection.empty),Et&&e.selection.empty&&n.parentOffset&&!n.textOffset&&n.nodeBefore.marks.length){let l=t.domSelectionRange();for(let i=l.focusNode,r=l.focusOffset;i&&i.nodeType==1&&r!=0;){let o=r<0?i.lastChild:i.childNodes[r-1];if(!o)break;if(o.nodeType==3){let a=t.domSelection();a&&a.collapse(o,o.nodeValue.length);break}else i=o,r=-1}}t.input.composing=!0}t3(t,A8)};function O8(t){let{focusNode:e,focusOffset:n}=t.domSelectionRange();if(!e||e.nodeType!=1||n>=e.childNodes.length)return!1;let l=e.childNodes[n];return l.nodeType==1&&l.contentEditable=="false"}qe.compositionend=(t,e)=>{t.composing&&(t.input.composing=!1,t.input.compositionEndedAt=e.timeStamp,t.input.compositionPendingChanges=t.domObserver.pendingRecords().length?t.input.compositionID:0,t.input.compositionNode=null,t.input.badSafariComposition?t.domObserver.forceFlush():t.input.compositionPendingChanges&&Promise.resolve().then(()=>t.domObserver.flush()),t.input.compositionID++,t3(t,20))};function t3(t,e){clearTimeout(t.input.composingTimeout),e>-1&&(t.input.composingTimeout=setTimeout(()=>Gs(t),e))}function n3(t){for(t.composing&&(t.input.composing=!1,t.input.compositionEndedAt=k8());t.input.compositionNodes.length>0;)t.input.compositionNodes.pop().markParentsDirty()}function D8(t){let e=t.domSelectionRange();if(!e.focusNode)return null;let n=NS(e.focusNode,e.focusOffset),l=AS(e.focusNode,e.focusOffset);if(n&&l&&n!=l){let i=l.pmViewDesc,r=t.domObserver.lastChangedTextNode;if(n==r||l==r)return r;if(!i||!i.isText(l.nodeValue))return l;if(t.input.compositionNode==l){let o=n.pmViewDesc;if(!(!o||!o.isText(n.nodeValue)))return l}}return n||l}function k8(){let t=document.createEvent("Event");return t.initEvent("event",!0,!0),t.timeStamp}function Gs(t,e=!1){if(!(Sn&&t.domObserver.flushingSoon>=0)){if(t.domObserver.forceFlush(),n3(t),e||t.docView&&t.docView.dirty){let n=Pd(t),l=t.state.selection;return n&&!n.eq(l)?t.dispatch(t.state.tr.setSelection(n)):(t.markCursor||e)&&!l.$from.node(l.$from.sharedDepth(l.to)).inlineContent?t.dispatch(t.state.tr.deleteSelection()):t.updateState(t.state),!0}return!1}}function w8(t,e){if(!t.dom.parentNode)return;let n=t.dom.parentNode.appendChild(document.createElement("div"));n.appendChild(e),n.style.cssText="position: fixed; left: -10000px; top: 10px";let l=getSelection(),i=document.createRange();i.selectNodeContents(e),t.dom.blur(),l.removeAllRanges(),l.addRange(i),setTimeout(()=>{n.parentNode&&n.parentNode.removeChild(n),t.focus()},50)}var To=Pe&&ll<15||Qi&&wS<604;Ve.copy=qe.cut=(t,e)=>{let n=e,l=t.state.selection,i=n.type=="cut";if(l.empty)return;let r=To?null:n.clipboardData,o=l.content(),{dom:a,text:s}=$d(t,o);r?(n.preventDefault(),r.clearData(),r.setData("text/html",a.innerHTML),r.setData("text/plain",s)):w8(t,a),i&&t.dispatch(t.state.tr.deleteSelection().scrollIntoView().setMeta("uiEvent","cut"))};function z8(t){return t.openStart==0&&t.openEnd==0&&t.content.childCount==1?t.content.firstChild:null}function R8(t,e){if(!t.dom.parentNode)return;let n=t.input.shiftKey||t.state.selection.$from.parent.type.spec.code,l=t.dom.parentNode.appendChild(document.createElement(n?"textarea":"div"));n||(l.contentEditable="true"),l.style.cssText="position: fixed; left: -10000px; top: 10px",l.focus();let i=t.input.shiftKey&&t.input.lastKeyCode!=45;setTimeout(()=>{t.focus(),l.parentNode&&l.parentNode.removeChild(l),n?Eo(t,l.value,null,i,e):Eo(t,l.textContent,l.innerHTML,i,e)},50)}function Eo(t,e,n,l,i){let r=K2(t,e,n,l,t.state.selection.$from);if(t.someProp("handlePaste",s=>s(t,i,r||k.empty)))return!0;if(!r)return!1;let o=z8(r),a=o?t.state.tr.replaceSelectionWith(o,l):t.state.tr.replaceSelection(r);return t.dispatch(a.scrollIntoView().setMeta("paste",!0).setMeta("uiEvent","paste")),!0}function l3(t){let e=t.getData("text/plain")||t.getData("Text");if(e)return e;let n=t.getData("text/uri-list");return n?n.replace(/\r?\n/g," "):""}qe.paste=(t,e)=>{let n=e;if(t.composing&&!Sn)return;let l=To?null:n.clipboardData,i=t.input.shiftKey&&t.input.lastKeyCode!=45;l&&Eo(t,l3(l),l.getData("text/html"),i,n)?n.preventDefault():R8(t,n)};var Xs=class{constructor(e,n,l){this.slice=e,this.move=n,this.node=l}},L8=Mt?"altKey":"ctrlKey";function i3(t,e){let n;return t.someProp("dragCopies",l=>{n=n||l(e)}),n!=null?!n:!e[L8]}Ve.dragstart=(t,e)=>{let n=e,l=t.input.mouseDown;if(l&&l.done(),!n.dataTransfer)return;let i=t.state.selection,r=i.empty?null:t.posAtCoords(Js(n)),o;if(!(r&&r.pos>=i.from&&r.pos<=(i instanceof B?i.to-1:i.to))){if(l&&l.mightDrag)o=B.create(t.state.doc,l.mightDrag.pos);else if(n.target&&n.target.nodeType==1){let f=t.docView.nearestDesc(n.target,!0);f&&f.node.type.spec.draggable&&f!=t.docView&&(o=B.create(t.state.doc,f.posBefore))}}let a=(o||t.state.selection).content(),{dom:s,text:u,slice:c}=$d(t,a);(!n.dataTransfer.files.length||!we||z2>120)&&n.dataTransfer.clearData(),n.dataTransfer.setData(To?"Text":"text/html",s.innerHTML),n.dataTransfer.effectAllowed="copyMove",To||n.dataTransfer.setData("text/plain",u),t.dragging=new Xs(c,i3(t,n),o)};Ve.dragend=t=>{let e=t.dragging;window.setTimeout(()=>{t.dragging==e&&(t.dragging=null)},50)};qe.dragover=qe.dragenter=(t,e)=>e.preventDefault();qe.drop=(t,e)=>{try{_8(t,e,t.dragging)}finally{t.dragging=null}};function _8(t,e,n){if(!e.dataTransfer)return;let l=t.posAtCoords(Js(e));if(!l)return;let i=t.state.doc.resolve(l.pos),r=n&&n.slice;r?t.someProp("transformPasted",h=>{r=h(r,t,!1)}):r=K2(t,l3(e.dataTransfer),To?null:e.dataTransfer.getData("text/html"),!1,i);let o=!!(n&&i3(t,e));if(t.someProp("handleDrop",h=>h(t,e,r||k.empty,o))){e.preventDefault();return}if(!r)return;e.preventDefault();let a=r?Ry(t.state.doc,i.pos,r):i.pos;a==null&&(a=i.pos);let s=t.state.tr;if(o){let{node:h}=n;h?h.replace(s):s.deleteSelection()}let u=s.mapping.map(a),c=r.openStart==0&&r.openEnd==0&&r.content.childCount==1,f=s.doc;if(c?s.replaceRangeWith(u,u,r.content.firstChild):s.replaceRange(u,u,r),s.doc.eq(f))return;let d=s.doc.resolve(u);if(c&&B.isSelectable(r.content.firstChild)&&d.nodeAfter&&d.nodeAfter.sameMarkup(r.content.firstChild))s.setSelection(new B(d));else{let h=s.mapping.map(a);s.mapping.maps[s.mapping.maps.length-1].forEach((p,b,v,g)=>h=g),s.setSelection(Wd(t,d,s.doc.resolve(h)))}t.focus(),t.dispatch(s.setMeta("uiEvent","drop"))}Ve.focus=t=>{t.input.lastFocus=Date.now(),t.focused||(t.domObserver.stop(),t.dom.classList.add("ProseMirror-focused"),t.domObserver.start(),t.focused=!0,setTimeout(()=>{t.docView&&t.hasFocus()&&!t.domObserver.currentSelection.eq(t.domSelectionRange())&&xn(t)},20))};Ve.blur=(t,e)=>{let n=e;t.focused&&(t.domObserver.stop(),t.dom.classList.remove("ProseMirror-focused"),t.domObserver.start(),n.relatedTarget&&t.dom.contains(n.relatedTarget)&&t.domObserver.currentSelection.clear(),t.focused=!1)};Ve.beforeinput=(t,e)=>{if(we&&Sn&&e.inputType=="deleteContentBackward"){t.domObserver.flushSoon();let{domChangeCount:l}=t.input;setTimeout(()=>{if(t.input.domChangeCount!=l||(t.dom.blur(),t.focus(),t.someProp("handleKeyDown",r=>r(t,Rl(8,"Backspace")))))return;let{$cursor:i}=t.state.selection;i&&i.pos>0&&t.dispatch(t.state.tr.delete(i.pos-1,i.pos).scrollIntoView())},50)}};for(let t in qe)Ve[t]=qe[t];function No(t,e){if(t==e)return!0;for(let n in t)if(t[n]!==e[n])return!1;for(let n in e)if(!(n in t))return!1;return!0}var Is=class t{constructor(e,n){this.toDOM=e,this.spec=n||Hl,this.side=this.spec.side||0}map(e,n,l,i){let{pos:r,deleted:o}=e.mapResult(n.from+i,this.side<0?-1:1);return o?null:new Gt(r-l,r-l,this)}valid(){return!0}eq(e){return this==e||e instanceof t&&(this.spec.key&&this.spec.key==e.spec.key||this.toDOM==e.toDOM&&No(this.spec,e.spec))}destroy(e){this.spec.destroy&&this.spec.destroy(e)}},Bl=class t{constructor(e,n){this.attrs=e,this.spec=n||Hl}map(e,n,l,i){let r=e.map(n.from+i,this.spec.inclusiveStart?-1:1)-l,o=e.map(n.to+i,this.spec.inclusiveEnd?1:-1)-l;return r>=o?null:new Gt(r,o,this)}valid(e,n){return n.from=e&&(!r||r(a.spec))&&l.push(a.copy(a.from+i,a.to+i))}for(let o=0;oe){let a=this.children[o]+1;this.children[o+2].findInner(e-a,n-a,l,i+a,r)}}map(e,n,l){return this==Le||e.maps.length==0?this:this.mapInner(e,n,0,0,l||Hl)}mapInner(e,n,l,i,r){let o;for(let a=0;a{let u=s+l,c;if(c=o3(n,a,u)){for(i||(i=this.children.slice());ra&&f.to=e){this.children[a]==e&&(l=this.children[a+2]);break}let r=e+1,o=r+n.content.size;for(let a=0;ar&&s.type instanceof Bl){let u=Math.max(r,s.from)-r,c=Math.min(o,s.to)-r;ui.map(e,n,Hl));return t.from(l)}forChild(e,n){if(n.isLeaf)return We.empty;let l=[];for(let i=0;in instanceof We)?e:e.reduce((n,l)=>n.concat(l instanceof We?l:l.members),[]))}}forEachSet(e){for(let n=0;n{let v=b-p-(h-d);for(let g=0;gy+c-f)continue;let S=a[g]+c-f;h>=S?a[g+1]=d<=S?-2:-1:d>=c&&v&&(a[g]+=v,a[g+1]+=v)}f+=v}),c=n.maps[u].map(c,-1)}let s=!1;for(let u=0;u=l.content.size){s=!0;continue}let d=n.map(t[u+1]+r,-1),h=d-i,{index:p,offset:b}=l.content.findIndex(f),v=l.maybeChild(p);if(v&&b==f&&b+v.nodeSize==h){let g=a[u+2].mapInner(n,v,c+1,t[u]+r+1,o);g!=Le?(a[u]=f,a[u+1]=h,a[u+2]=g):(a[u+1]=-2,s=!0)}else s=!0}if(s){let u=H8(a,t,e,n,i,r,o),c=Qs(u,l,0,o);e=c.local;for(let f=0;fn&&o.to{let u=o3(t,a,s+n);if(u){r=!0;let c=Qs(u,a,n+s+1,l);c!=Le&&i.push(s,s+a.nodeSize,c)}});let o=r3(r?a3(t):t,-n).sort(Ul);for(let a=0;a0;)e++;t.splice(e,0,n)}function kd(t){let e=[];return t.someProp("decorations",n=>{let l=n(t.state);l&&l!=Le&&e.push(l)}),t.cursorWrapper&&e.push(We.create(t.state.doc,[t.cursorWrapper.deco])),Ks.from(e)}var U8={childList:!0,characterData:!0,characterDataOldValue:!0,attributes:!0,attributeOldValue:!0,subtree:!0},V8=Pe&&ll<=11,Qd=class{constructor(){this.anchorNode=null,this.anchorOffset=0,this.focusNode=null,this.focusOffset=0}set(e){this.anchorNode=e.anchorNode,this.anchorOffset=e.anchorOffset,this.focusNode=e.focusNode,this.focusOffset=e.focusOffset}clear(){this.anchorNode=this.focusNode=null}eq(e){return e.anchorNode==this.anchorNode&&e.anchorOffset==this.anchorOffset&&e.focusNode==this.focusNode&&e.focusOffset==this.focusOffset}},Fd=class{constructor(e,n){this.view=e,this.handleDOMChange=n,this.queue=[],this.flushingSoon=-1,this.observer=null,this.currentSelection=new Qd,this.onCharData=null,this.suppressingSelectionUpdates=!1,this.lastChangedTextNode=null,this.observer=window.MutationObserver&&new window.MutationObserver(l=>{for(let i=0;ii.type=="childList"&&i.removedNodes.length||i.type=="characterData"&&i.oldValue.length>i.target.nodeValue.length)?this.flushSoon():_e&&e.composing&&l.some(i=>i.type=="childList"&&i.target.nodeName=="TR")?(e.input.badSafariComposition=!0,this.flushSoon()):this.flush()}),V8&&(this.onCharData=l=>{this.queue.push({target:l.target,type:"characterData",oldValue:l.prevValue}),this.flushSoon()}),this.onSelectionChange=this.onSelectionChange.bind(this)}flushSoon(){this.flushingSoon<0&&(this.flushingSoon=window.setTimeout(()=>{this.flushingSoon=-1,this.flush()},20))}forceFlush(){this.flushingSoon>-1&&(window.clearTimeout(this.flushingSoon),this.flushingSoon=-1,this.flush())}start(){this.observer&&(this.observer.takeRecords(),this.observer.observe(this.view.dom,U8)),this.onCharData&&this.view.dom.addEventListener("DOMCharacterDataModified",this.onCharData),this.connectSelection()}stop(){if(this.observer){let e=this.observer.takeRecords();if(e.length){for(let n=0;nthis.flush(),20)}this.observer.disconnect()}this.onCharData&&this.view.dom.removeEventListener("DOMCharacterDataModified",this.onCharData),this.disconnectSelection()}connectSelection(){this.view.dom.ownerDocument.addEventListener("selectionchange",this.onSelectionChange)}disconnectSelection(){this.view.dom.ownerDocument.removeEventListener("selectionchange",this.onSelectionChange)}suppressSelectionUpdates(){this.suppressingSelectionUpdates=!0,setTimeout(()=>this.suppressingSelectionUpdates=!1,50)}onSelectionChange(){if(d2(this.view)){if(this.suppressingSelectionUpdates)return xn(this.view);if(Pe&&ll<=11&&!this.view.state.selection.empty){let e=this.view.domSelectionRange();if(e.focusNode&&Vl(e.focusNode,e.focusOffset,e.anchorNode,e.anchorOffset))return this.flushSoon()}this.flush()}}setCurSelection(){this.currentSelection.set(this.view.domSelectionRange())}ignoreSelectionChange(e){if(!e.focusNode)return!0;let n=new Set,l;for(let r=e.focusNode;r;r=Ki(r))n.add(r);for(let r=e.anchorNode;r;r=Ki(r))if(n.has(r)){l=r;break}let i=l&&this.view.docView.nearestDesc(l);if(i&&i.ignoreMutation({type:"selection",target:l.nodeType==3?l.parentNode:l}))return this.setCurSelection(),!0}pendingRecords(){if(this.observer)for(let e of this.observer.takeRecords())this.queue.push(e);return this.queue}flush(){let{view:e}=this;if(!e.docView||this.flushingSoon>-1)return;let n=this.pendingRecords();n.length&&(this.queue=[]);let l=e.domSelectionRange(),i=!this.suppressingSelectionUpdates&&!this.currentSelection.eq(l)&&d2(e)&&!this.ignoreSelectionChange(l),r=-1,o=-1,a=!1,s=[];if(e.editable)for(let c=0;cc.nodeName=="BR")&&(e.input.lastKeyCode==8||e.input.lastKeyCode==46)){for(let c of s)if(c.nodeName=="BR"&&c.parentNode){let f=c.nextSibling;for(;f&&f.nodeType==1;){if(f.contentEditable=="false"){c.parentNode.removeChild(c);break}f=f.firstChild}}}else if(Et&&s.length){let c=s.filter(f=>f.nodeName=="BR");if(c.length==2){let[f,d]=c;f.parentNode&&f.parentNode.parentNode==d.parentNode?d.remove():f.remove()}else{let{focusNode:f}=this.currentSelection;for(let d of c){let h=d.parentNode;h&&h.nodeName=="LI"&&(!f||Z8(e,f)!=h)&&d.remove()}}}let u=null;r<0&&i&&e.input.lastFocus>Date.now()-200&&Math.max(e.input.lastTouch,e.input.lastClick.time)-1||i)&&(r>-1&&(e.docView.markDirty(r,o),q8(e)),e.input.badSafariComposition&&(e.input.badSafariComposition=!1,j8(e,s)),this.handleDOMChange(r,o,a,s),e.docView&&e.docView.dirty?e.updateState(e.state):this.currentSelection.eq(l)||xn(e),this.currentSelection.set(l))}registerMutation(e,n){if(n.indexOf(e.target)>-1)return null;let l=this.view.docView.nearestDesc(e.target);if(e.type=="attributes"&&(l==this.view.docView||e.attributeName=="contenteditable"||e.attributeName=="style"&&!e.oldValue&&!e.target.getAttribute("style"))||!l||l.ignoreMutation(e))return null;if(e.type=="childList"){for(let c=0;ci;v--){let g=l.childNodes[v-1],y=g.pmViewDesc;if(g.nodeName=="BR"&&!y){r=v;break}if(!y||y.size)break}let f=t.state.doc,d=t.someProp("domParser")||fo.fromSchema(t.state.schema),h=f.resolve(o),p=null,b=d.parse(l,{topNode:h.parent,topMatch:h.parent.contentMatchAt(h.index()),topOpen:!0,from:i,to:r,preserveWhitespace:h.parent.type.whitespace=="pre"?"full":!0,findPositions:u,ruleFromNode:X8,context:h});if(u&&u[0].pos!=null){let v=u[0].pos,g=u[1]&&u[1].pos;g==null&&(g=v),p={anchor:v+o,head:g+o}}return{doc:b,sel:p,from:o,to:a}}function X8(t){let e=t.pmViewDesc;if(e)return e.parseRule();if(t.nodeName=="BR"&&t.parentNode){if(_e&&/^(ul|ol)$/i.test(t.parentNode.nodeName)){let n=document.createElement("div");return n.appendChild(document.createElement("li")),{skip:n}}else if(t.parentNode.lastChild==t||_e&&/^(tr|table)$/i.test(t.parentNode.nodeName))return{ignore:!0}}else if(t.nodeName=="IMG"&&t.getAttribute("mark-placeholder"))return{ignore:!0};return null}var I8=/^(a|abbr|acronym|b|bd[io]|big|br|button|cite|code|data(list)?|del|dfn|em|i|img|ins|kbd|label|map|mark|meter|output|q|ruby|s|samp|small|span|strong|su[bp]|time|u|tt|var)$/i;function K8(t,e,n,l,i){let r=t.input.compositionPendingChanges||(t.composing?t.input.compositionID:0);if(t.input.compositionPendingChanges=0,e<0){let M=t.input.lastSelectionTime>Date.now()-50?t.input.lastSelectionOrigin:null,N=Pd(t,M);if(N&&!t.state.selection.eq(N)){if(we&&Sn&&t.input.lastKeyCode===13&&Date.now()-100O(t,Rl(13,"Enter"))))return;let T=t.state.tr.setSelection(N);M=="pointer"?T.setMeta("pointer",!0):M=="key"&&T.scrollIntoView(),r&&T.setMeta("composition",r),t.dispatch(T)}return}let o=t.state.doc.resolve(e),a=o.sharedDepth(n);e=o.before(a+1),n=t.state.doc.resolve(n).after(a+1);let s=t.state.selection,u=G8(t,e,n),c=t.state.doc,f=c.slice(u.from,u.to),d,h;t.input.lastKeyCode===8&&Date.now()-100Date.now()-225||Sn)&&i.some(M=>M.nodeType==1&&!I8.test(M.nodeName))&&(!p||p.endA>=p.endB)&&t.someProp("handleKeyDown",M=>M(t,Rl(13,"Enter")))){t.input.lastIOSEnter=0;return}if(!p)if(l&&s instanceof j&&!s.empty&&s.$head.sameParent(s.$anchor)&&!t.composing&&!(u.sel&&u.sel.anchor!=u.sel.head))p={start:s.from,endA:s.to,endB:s.to};else{if(u.sel){let M=T2(t,t.state.doc,u.sel);if(M&&!M.eq(t.state.selection)){let N=t.state.tr.setSelection(M);r&&N.setMeta("composition",r),t.dispatch(N)}}return}t.state.selection.fromt.state.selection.from&&p.start<=t.state.selection.from+2&&t.state.selection.from>=u.from?p.start=t.state.selection.from:p.endA=t.state.selection.to-2&&t.state.selection.to<=u.to&&(p.endB+=t.state.selection.to-p.endA,p.endA=t.state.selection.to)),Pe&&ll<=11&&p.endB==p.start+1&&p.endA==p.start&&p.start>u.from&&u.doc.textBetween(p.start-u.from-1,p.start-u.from+1)==" \xA0"&&(p.start--,p.endA--,p.endB--);let b=u.doc.resolveNoCache(p.start-u.from),v=u.doc.resolveNoCache(p.endB-u.from),g=c.resolve(p.start),y=b.sameParent(v)&&b.parent.inlineContent&&g.end()>=p.endA;if((Qi&&t.input.lastIOSEnter>Date.now()-225&&(!y||i.some(M=>M.nodeName=="DIV"||M.nodeName=="P"))||!y&&b.posM(t,Rl(13,"Enter")))){t.input.lastIOSEnter=0;return}if(t.state.selection.anchor>p.start&&F8(c,p.start,p.endA,b,v)&&t.someProp("handleKeyDown",M=>M(t,Rl(8,"Backspace")))){Sn&&we&&t.domObserver.suppressSelectionUpdates();return}we&&p.endB==p.start&&(t.input.lastChromeDelete=Date.now()),Sn&&!y&&b.start()!=v.start()&&v.parentOffset==0&&b.depth==v.depth&&u.sel&&u.sel.anchor==u.sel.head&&u.sel.head==p.endA&&(p.endB-=2,v=u.doc.resolveNoCache(p.endB-u.from),setTimeout(()=>{t.someProp("handleKeyDown",function(M){return M(t,Rl(13,"Enter"))})},20));let S=p.start,x=p.endA,A=M=>{let N=M||t.state.tr.replace(S,x,u.doc.slice(p.start-u.from,p.endB-u.from));if(u.sel){let T=T2(t,N.doc,u.sel);T&&!(we&&t.composing&&T.empty&&(p.start!=p.endB||t.input.lastChromeDeletexn(t),20));let M=A(t.state.tr.delete(S,x)),N=c.resolve(p.start).marksAcross(c.resolve(p.endA));N&&M.ensureMarks(N),t.dispatch(M)}else if(p.endA==p.endB&&(z=Q8(b.parent.content.cut(b.parentOffset,v.parentOffset),g.parent.content.cut(g.parentOffset,p.endA-g.start())))){let M=A(t.state.tr);z.type=="add"?M.addMark(S,x,z.mark):M.removeMark(S,x,z.mark),t.dispatch(M)}else if(b.parent.child(b.index()).isText&&b.index()==v.index()-(v.textOffset?0:1)){let M=b.parent.textBetween(b.parentOffset,v.parentOffset),N=()=>A(t.state.tr.insertText(M,S,x));t.someProp("handleTextInput",T=>T(t,S,x,M,N))||t.dispatch(N())}else t.dispatch(A());else t.dispatch(A())}function T2(t,e,n){return Math.max(n.anchor,n.head)>e.content.size?null:Wd(t,e.resolve(n.anchor),e.resolve(n.head))}function Q8(t,e){let n=t.firstChild.marks,l=e.firstChild.marks,i=n,r=l,o,a,s;for(let c=0;cc.mark(a.addToSet(c.marks));else if(i.length==0&&r.length==1)a=r[0],o="remove",s=c=>c.mark(a.removeFromSet(c.marks));else return null;let u=[];for(let c=0;cn||wd(o,!0,!1)0&&(e||t.indexAfter(l)==t.node(l).childCount);)l--,i++,e=!1;if(n){let r=t.node(l).maybeChild(t.indexAfter(l));for(;r&&!r.isLeaf;)r=r.firstChild,i++}return i}function J8(t,e,n,l,i){let r=t.findDiffStart(e,n);if(r==null)return null;let{a:o,b:a}=t.findDiffEnd(e,n+t.size,n+e.size);if(i=="end"){let s=Math.max(0,r-Math.min(o,a));l-=o+s-r}if(o=o?r-l:0;r-=s,r&&r=a?r-l:0;r-=s,r&&r=56320&&e<=57343&&n>=55296&&n<=56319}var Ao=class{constructor(e,n){this._root=null,this.focused=!1,this.trackWrites=null,this.mounted=!1,this.markCursor=null,this.cursorWrapper=null,this.lastSelectedViewDesc=void 0,this.input=new Gd,this.prevDirectPlugins=[],this.pluginViews=[],this.requiresGeckoHackNode=!1,this.dragging=null,this._props=n,this.state=n.state,this.directPlugins=n.plugins||[],this.directPlugins.forEach(k2),this.dispatch=this.dispatch.bind(this),this.dom=e&&e.mount||document.createElement("div"),e&&(e.appendChild?e.appendChild(this.dom):typeof e=="function"?e(this.dom):e.mount&&(this.mounted=!0)),this.editable=O2(this),A2(this),this.nodeViews=D2(this),this.docView=o2(this.state.doc,N2(this),kd(this),this.dom,this),this.domObserver=new Fd(this,(l,i,r,o)=>K8(this,l,i,r,o)),this.domObserver.start(),g8(this),this.updatePluginViews()}get composing(){return this.input.composing}get props(){if(this._props.state!=this.state){let e=this._props;this._props={};for(let n in e)this._props[n]=e[n];this._props.state=this.state}return this._props}update(e){e.handleDOMEvents!=this._props.handleDOMEvents&&Xd(this);let n=this._props;this._props=e,e.plugins&&(e.plugins.forEach(k2),this.directPlugins=e.plugins),this.updateStateInner(e.state,n)}setProps(e){let n={};for(let l in this._props)n[l]=this._props[l];n.state=this.state;for(let l in e)n[l]=e[l];this.update(n)}updateState(e){this.updateStateInner(e,this._props)}updateStateInner(e,n){var l;let i=this.state,r=!1,o=!1;e.storedMarks&&this.composing&&(n3(this),o=!0),this.state=e;let a=i.plugins!=e.plugins||this._props.plugins!=n.plugins;if(a||this._props.plugins!=n.plugins||this._props.nodeViews!=n.nodeViews){let h=D2(this);W8(h,this.nodeViews)&&(this.nodeViews=h,r=!0)}(a||n.handleDOMEvents!=this._props.handleDOMEvents)&&Xd(this),this.editable=O2(this),A2(this);let s=kd(this),u=N2(this),c=i.plugins!=e.plugins&&!i.doc.eq(e.doc)?"reset":e.scrollToSelection>i.scrollToSelection?"to selection":"preserve",f=r||!this.docView.matchesNode(e.doc,u,s);(f||!e.selection.eq(i.selection))&&(o=!0);let d=c=="preserve"&&o&&this.dom.style.overflowAnchor==null&&LS(this);if(o){this.domObserver.stop();let h=f&&(Pe||we)&&!this.composing&&!i.selection.empty&&!e.selection.empty&&P8(i.selection,e.selection);if(f){let p=we?this.trackWrites=this.domSelectionRange().focusNode:null;this.composing&&(this.input.compositionNode=D8(this)),(r||!this.docView.update(e.doc,u,s,this))&&(this.docView.updateOuterDeco(u),this.docView.destroy(),this.docView=o2(e.doc,u,s,this.dom,this)),p&&(!this.trackWrites||!this.dom.contains(this.trackWrites))&&(h=!0)}h||!(this.input.mouseDown&&this.domObserver.currentSelection.eq(this.domSelectionRange())&&t8(this))?xn(this,h):(G2(this,e.selection),this.domObserver.setCurSelection()),this.domObserver.start()}this.updatePluginViews(i),!((l=this.dragging)===null||l===void 0)&&l.node&&!i.doc.eq(e.doc)&&this.updateDraggedNode(this.dragging,i),c=="reset"?this.dom.scrollTop=0:c=="to selection"?this.scrollToSelection():d&&_S(d)}scrollToSelection(){let e=this.domSelectionRange().focusNode;if(!(!e||!this.dom.contains(e.nodeType==1?e:e.parentNode))){if(!this.someProp("handleScrollToSelection",n=>n(this)))if(this.state.selection instanceof B){let n=this.docView.domAfterPos(this.state.selection.from);n.nodeType==1&&e2(this,n.getBoundingClientRect(),e)}else e2(this,this.coordsAtPos(this.state.selection.head,1),e)}}destroyPluginViews(){let e;for(;e=this.pluginViews.pop();)e.destroy&&e.destroy()}updatePluginViews(e){if(!e||e.plugins!=this.state.plugins||this.directPlugins!=this.prevDirectPlugins){this.prevDirectPlugins=this.directPlugins,this.destroyPluginViews();for(let n=0;n0&&rn.ownerDocument.getSelection()),this._root=n}return e||document}updateRoot(){this._root=null}posAtCoords(e){return YS(this,e)}coordsAtPos(e,n=1){return U2(this,e,n)}domAtPos(e,n=0){return this.docView.domFromPos(e,n)}nodeDOM(e){let n=this.docView.descAt(e);return n?n.nodeDOM:null}posAtDOM(e,n,l=-1){let i=this.docView.posFromDOM(e,n,l);if(i==null)throw new RangeError("DOM position not inside the editor");return i}endOfTextblock(e,n){return IS(this,n||this.state,e)}pasteHTML(e,n){return Eo(this,"",e,!1,n||new ClipboardEvent("paste"))}pasteText(e,n){return Eo(this,e,null,!0,n||new ClipboardEvent("paste"))}serializeForClipboard(e){return $d(this,e)}destroy(){this.docView&&(y8(this),this.destroyPluginViews(),this.mounted?(this.docView.update(this.state.doc,[],kd(this),this),this.dom.textContent=""):this.dom.parentNode&&this.dom.parentNode.removeChild(this.dom),this.docView.destroy(),this.docView=null,TS())}get isDestroyed(){return this.docView==null}dispatchEvent(e){return S8(this,e)}domSelectionRange(){let e=this.domSelection();return e?_e&&this.root.nodeType===11&&DS(this.dom.ownerDocument)==this.dom&&Y8(this,e)||e:{focusNode:null,focusOffset:0,anchorNode:null,anchorOffset:0}}domSelection(){return this.root.getSelection()}};Ao.prototype.dispatch=function(t){let e=this._props.dispatchTransaction;e?e.call(this,t):this.updateState(this.state.apply(t))};function N2(t){let e=Object.create(null);return e.class="ProseMirror",e.contenteditable=String(t.editable),t.someProp("attributes",n=>{if(typeof n=="function"&&(n=n(t.state)),n)for(let l in n)l=="class"?e.class+=" "+n[l]:l=="style"?e.style=(e.style?e.style+";":"")+n[l]:!e[l]&&l!="contenteditable"&&l!="nodeName"&&(e[l]=String(n[l]))}),e.translate||(e.translate="no"),[Gt.node(0,t.state.doc.content.size,e)]}function A2(t){if(t.markCursor){let e=document.createElement("img");e.className="ProseMirror-separator",e.setAttribute("mark-placeholder","true"),e.setAttribute("alt",""),t.cursorWrapper={dom:e,deco:Gt.widget(t.state.selection.from,e,{raw:!0,marks:t.markCursor})}}else t.cursorWrapper=null}function O2(t){return!t.someProp("editable",e=>e(t.state)===!1)}function P8(t,e){let n=Math.min(t.$anchor.sharedDepth(t.head),e.$anchor.sharedDepth(e.head));return t.$anchor.start(n)!=e.$anchor.start(n)}function D2(t){let e=Object.create(null);function n(l){for(let i in l)Object.prototype.hasOwnProperty.call(e,i)||(e[i]=l[i])}return t.someProp("nodeViews",n),t.someProp("markViews",n),e}function W8(t,e){let n=0,l=0;for(let i in t){if(t[i]!=e[i])return!0;n++}for(let i in e)l++;return n!=l}function k2(t){if(t.spec.state||t.spec.filterTransaction||t.spec.appendTransaction)throw new RangeError("Plugins passed directly to the view must not have a state component")}function s3(t,e=!1){return t.key==="Enter"&&(t.isComposing===!0||e||t.keyCode===229)}function u3(t){return t.key==="Tab"&&t.shiftKey===!0&&!t.altKey&&!t.ctrlKey&&!t.metaKey}var ih=new Es({nodes:{doc:{content:"paragraph+"},paragraph:{content:"inline*",group:"block",parseDOM:[{tag:"p"}],toDOM:()=>["p",0]},text:{group:"inline"},atMention:{attrs:{label:{validate:"string"},path:{validate:"string"},fsPath:{validate:"string",default:""}},inline:!0,group:"inline",draggable:!1,selectable:!1,toDOM:t=>rh({text:t.attrs.label,iconNode:s7(),dataAttributes:{"at-mention-label":t.attrs.label,"at-mention-path":t.attrs.path,"at-mention-fs-path":t.attrs.fsPath}}),parseDOM:[{tag:"span[at-mention-label][at-mention-path]",getAttrs:t=>{let e=t;return{label:e.getAttribute("at-mention-label"),path:e.getAttribute("at-mention-path"),fsPath:e.getAttribute("at-mention-fs-path")??""}}}]},agentMention:{attrs:{name:{validate:"string"},displayName:{validate:"string",default:""},path:{validate:"string"}},inline:!0,group:"inline",draggable:!1,selectable:!1,toDOM:t=>{let e=t.attrs.displayName||t.attrs.name;return rh({text:`@${e}`,dataAttributes:{"agent-mention-name":t.attrs.name,"agent-mention-display-name":e,"agent-mention-path":t.attrs.path}})},parseDOM:[{tag:"span[agent-mention-name][agent-mention-path]",getAttrs:t=>{let e=t,n=e.getAttribute("agent-mention-name")??"";return{name:n,displayName:e.getAttribute("agent-mention-display-name")??n,path:e.getAttribute("agent-mention-path")??""}}}]},skillMention:{attrs:{name:{validate:"string"},displayName:{validate:"string",default:""},path:{validate:"string"},description:{validate:"string",default:""}},inline:!0,group:"inline",draggable:!1,selectable:!1,toDOM:t=>{let e=t.attrs.displayName||t.attrs.name;return rh({text:e,icon:"$",dataAttributes:{"skill-mention-name":t.attrs.name,"skill-mention-display-name":e,"skill-mention-path":t.attrs.path},title:t.attrs.description})},parseDOM:[{tag:"span[skill-mention-name][skill-mention-path]",getAttrs:t=>{let e=t,n=e.getAttribute("skill-mention-name")??"";return{name:n,displayName:e.getAttribute("skill-mention-display-name")??n,path:e.getAttribute("skill-mention-path")??"",description:e.getAttribute("title")??""}}}]}},marks:{}}),Ps=new Us("agentPromptPlaceholder"),c3=se.default.forwardRef(function({ariaLabel:e,className:n,minHeight:l="2.75rem",onAutocompleteChange:i,onAutocompleteKeyDown:r,onPlanModeShortcut:o,onSubmit:a,onTextChange:s,onTriggerToken:u,placeholder:c,singleLine:f=!1,value:d},h){let p=(0,se.useRef)(null),b=(0,se.useRef)(null),v=(0,se.useRef)(a),g=(0,se.useRef)(i),y=(0,se.useRef)(r),S=(0,se.useRef)(o),x=(0,se.useRef)(s),A=(0,se.useRef)(u),z=(0,se.useRef)(d),M=(0,se.useRef)(f);v.current=a,g.current=i,y.current=r,S.current=o,x.current=s,A.current=u,M.current=f,(0,se.useImperativeHandle)(h,()=>({focus(){b.current?.focus()},getText(){return z.current},insertMention(T){let O=b.current;O&&r7(O,T)},insertMentions(T){let O=b.current;!O||T.length===0||o7(O,T)},insertText(T){let O=b.current;O&&i7(O,T)}}),[]),(0,se.useLayoutEffect)(()=>{let T=p.current;if(!T)return;let O=new Ao(T,{state:Bs.create({doc:f3(z.current),plugins:[e7("")]}),attributes:{"aria-label":"","data-codex-composer":"true","data-virtualkeyboard":"true",role:"textbox",class:"ProseMirror prompt-editor-view",style:"min-height: 2.75rem; font-size: var(--codex-chat-font-size); height: auto; resize: none;"},dispatchTransaction(G){let F=O.state.apply(G);O.updateState(F);let ye=t7(F.doc),Ji=z.current;if(ye!==Ji){z.current=ye;let zt=$8(Ji,ye);zt&&A.current?.(zt),x.current(ye)}g.current?.(h3(O))},handleKeyDown(G,F){return s3(F,G.composing)?!1:u3(F)&&S.current?(F.preventDefault(),S.current(),!0):a7(F.key)&&y.current?.(F.key)?(F.preventDefault(),!0):(F.metaKey||F.ctrlKey)&&F.key==="Enter"?(F.preventDefault(),v.current(),!0):F.key!=="Enter"?!1:F.shiftKey||F.altKey?(F.preventDefault(),M.current?!0:Ed(G.state,G.dispatch,G)):(F.preventDefault(),v.current(),!0)}});return b.current=O,()=>{O.destroy(),b.current=null}},[]),(0,se.useLayoutEffect)(()=>{let T=b.current;T&&T.dispatch(T.state.tr.setMeta(Ps,c))},[c]),(0,se.useLayoutEffect)(()=>{let T=b.current;if(!T)return;let O=e??c;O?T.dom.setAttribute("aria-label",O):T.dom.removeAttribute("aria-label")},[e,c]),(0,se.useLayoutEffect)(()=>{let T=b.current;T&&(T.dom.style.minHeight=l)},[l]),(0,se.useLayoutEffect)(()=>{let T=b.current;!T||d===z.current||(z.current=d,l7(T,d))},[d]);let N=["text-size-chat","[&_.ProseMirror]:focus-visible:outline-none","text-token-foreground",f?"flex h-9 max-h-none items-center overflow-hidden [&_.ProseMirror]:!h-5 [&_.ProseMirror]:!min-h-5 [&_.ProseMirror]:min-w-0 [&_.ProseMirror]:flex-1 [&_.ProseMirror]:overflow-hidden [&_.ProseMirror]:whitespace-nowrap [&_.ProseMirror_p]:overflow-hidden [&_.ProseMirror_p]:text-ellipsis [&_.ProseMirror_p]:whitespace-nowrap":"h-auto max-h-[25dvh] overflow-y-auto [&_.ProseMirror]:h-auto [&_.ProseMirror]:min-h-[2rem]","[&_.ProseMirror]:resize-none","[&_.ProseMirror_p]:m-0",n].filter(Boolean).join(" ");return se.default.createElement("div",{className:N,onMouseDown:T=>{let O=b.current;O&&T.target instanceof Node&&!O.dom.contains(T.target)&&(T.preventDefault(),O.focus())},ref:p})});function $8(t,e){if(e.length!==t.length+1)return null;let n=0;for(;nt,apply(e,n){return e.getMeta(Ps)??n}},props:{decorations(e){let n=Ps.getState(e)??"";if(!n||e.doc.childCount!==1)return null;let l=e.doc.firstChild;return!l?.isTextblock||l.content.size!==0?null:We.create(e.doc,[Gt.node(0,l.nodeSize,{class:"placeholder","data-placeholder":n})])}}})}function f3(t){let e=t.split(` +`);return ih.nodes.doc.create(null,e.map(n=>ih.nodes.paragraph.create(null,n.length>0?ih.text(n):null)))}function t7(t){let e=[];return t.forEach(n=>{let l=[];n.forEach(i=>{l.push(n7(i))}),e.push(l.join(""))}),e.join(` +`)}function n7(t){switch(t.type.name){case"atMention":return io({kind:"at",label:t.attrs.label,name:t.attrs.label,path:t.attrs.path});case"agentMention":return io({kind:"agent",displayName:t.attrs.displayName||t.attrs.name,name:t.attrs.name,path:t.attrs.path});case"skillMention":return io({kind:"skill",displayName:t.attrs.displayName||t.attrs.name,name:t.attrs.name,path:t.attrs.path});default:return t.textContent}}function l7(t,e){let n=f3(e),l=t.state.tr.replaceWith(0,t.state.doc.content.size,n.content);l.setSelection(j.atEnd(l.doc)),t.dispatch(l)}function i7(t,e){let{state:n}=t,{from:l,to:i}=n.selection,r=e.startsWith("@")?"@":e.startsWith("$")?"$":null,o=n.doc.textBetween(Math.max(0,l-1),l,` `,` `),a=r&&o===r?l-1:l,s=n.doc.textBetween(Math.max(0,a-2),a,` `,` `),u=n.doc.textBetween(i,Math.min(n.doc.content.size,i+2),` `,` -`),c=s.length>0&&!/\s$/.test(s)?" ":"",f=u.length>0&&!/^\s/.test(u)?" ":"",d=`${c}${e}${f}`,h=n.tr.insertText(d,a,i),p=a+d.length;h.setSelection(j.create(h.doc,p)),t.dispatch(h),t.focus()}function e7(t,e){let{state:n}=t,{from:l,to:i}=n.selection,r=e.kind==="skill"?"$":"@",o=c3(t)?.anchorPos??(n.doc.textBetween(Math.max(0,l-1),l,` +`),c=s.length>0&&!/\s$/.test(s)?" ":"",f=u.length>0&&!/^\s/.test(u)?" ":"",d=`${c}${e}${f}`,h=n.tr.insertText(d,a,i),p=a+d.length;h.setSelection(j.create(h.doc,p)),t.dispatch(h),t.focus()}function r7(t,e){let{state:n}=t,{from:l,to:i}=n.selection,r=e.kind==="skill"?"$":"@",o=h3(t)?.anchorPos??(n.doc.textBetween(Math.max(0,l-1),l,` `,` `)===r?l-1:l),a=n.doc.textBetween(Math.max(0,o-2),o,` `,` `),s=n.doc.textBetween(i,Math.min(n.doc.content.size,i+2),` `,` -`),u=a.length>0&&!/\s$/.test(a)?" ":"",c=s.length>0&&!/^\s/.test(s)?" ":"",f=[];u&&f.push(n.schema.text(u)),f.push(u3(n.schema,e)),c&&f.push(n.schema.text(c));let d=n.tr.replaceWith(o,i,f),h=o+f.reduce((p,b)=>p+b.nodeSize,0);d.setSelection(j.create(d.doc,h)),t.dispatch(d),t.focus()}function t7(t,e){let{state:n}=t,{from:l,to:i}=n.selection,r=n.doc.textBetween(Math.max(0,l-2),l,` +`),u=a.length>0&&!/\s$/.test(a)?" ":"",c=s.length>0&&!/^\s/.test(s)?" ":"",f=[];u&&f.push(n.schema.text(u)),f.push(d3(n.schema,e)),c&&f.push(n.schema.text(c));let d=n.tr.replaceWith(o,i,f),h=o+f.reduce((p,b)=>p+b.nodeSize,0);d.setSelection(j.create(d.doc,h)),t.dispatch(d),t.focus()}function o7(t,e){let{state:n}=t,{from:l,to:i}=n.selection,r=n.doc.textBetween(Math.max(0,l-2),l,` `,` `),o=n.doc.textBetween(i,Math.min(n.doc.content.size,i+2),` `,` -`),a=[];r.length>0&&!/\s$/.test(r)&&a.push(n.schema.text(" ")),e.forEach((c,f)=>{f>0&&a.push(n.schema.text(" ")),a.push(u3(n.schema,c))}),o.length>0&&!/^\s/.test(o)&&a.push(n.schema.text(" "));let s=n.tr.replaceWith(l,i,a),u=l+a.reduce((c,f)=>c+f.nodeSize,0);s.setSelection(j.create(s.doc,u)),t.dispatch(s),t.focus()}function u3(t,e){switch(e.kind){case"skill":return t.nodes.skillMention.create({name:e.name,displayName:e.displayName??e.label,path:e.path,description:e.description??""});case"agent":return t.nodes.agentMention.create({name:e.name,displayName:e.displayName??e.label,path:e.path});case"at":return t.nodes.atMention.create({label:e.label,path:e.path,fsPath:e.fsPath??e.path})}}function c3(t){let{state:e}=t,{from:n,empty:l}=e.selection;if(!l)return null;let i=e.doc.textBetween(0,n,` +`),a=[];r.length>0&&!/\s$/.test(r)&&a.push(n.schema.text(" ")),e.forEach((c,f)=>{f>0&&a.push(n.schema.text(" ")),a.push(d3(n.schema,c))}),o.length>0&&!/^\s/.test(o)&&a.push(n.schema.text(" "));let s=n.tr.replaceWith(l,i,a),u=l+a.reduce((c,f)=>c+f.nodeSize,0);s.setSelection(j.create(s.doc,u)),t.dispatch(s),t.focus()}function d3(t,e){switch(e.kind){case"skill":return t.nodes.skillMention.create({name:e.name,displayName:e.displayName??e.label,path:e.path,description:e.description??""});case"agent":return t.nodes.agentMention.create({name:e.name,displayName:e.displayName??e.label,path:e.path});case"at":return t.nodes.atMention.create({label:e.label,path:e.path,fsPath:e.fsPath??e.path})}}function h3(t){let{state:e}=t,{from:n,empty:l}=e.selection;if(!l)return null;let i=e.doc.textBetween(0,n,` `,` -`),r=/(^|\s)([@$][^\s@$]*)$/.exec(i);if(!r)return null;let o=r[2]??"",a=o.charAt(0),s=o.slice(1);return{anchorPos:n-o.length,kind:a==="$"?"skill":"mention",query:s}}function n7(t){return t==="ArrowDown"||t==="ArrowUp"||t==="Enter"||t==="Tab"||t==="Escape"}function ih({dataAttributes:t,icon:e,iconNode:n,text:l,title:i}){let r=document.createElement("span");r.className="prompt-editor-mention group/inline-mention cursor-pointer inline-mention-brand-aware font-medium px-0.5 cursor-interaction",i&&(r.title=i);for(let[a,s]of Object.entries(t))r.setAttribute(a,s);if(e||n){let a=document.createElement("span");if(a.className="prompt-editor-mention-icon relative mr-[3px] inline-block h-[1lh] w-4 align-bottom",n)a.append(n);else if(e){let s=document.createElement("span");s.className="icon-xs absolute top-1/2 -translate-y-1/2",s.textContent=e,a.append(s)}r.append(a)}let o=document.createElement("span");return o.className="min-w-0 break-words",o.textContent=l,r.append(o),r}function l7(){let t=document.createElementNS("http://www.w3.org/2000/svg","svg");t.setAttribute("class","icon-xs absolute top-1/2 -translate-y-1/2"),t.setAttribute("width","20"),t.setAttribute("height","20"),t.setAttribute("viewBox","0 0 20 20"),t.setAttribute("fill","none"),t.setAttribute("aria-hidden","true");let e=document.createElementNS("http://www.w3.org/2000/svg","path");return e.setAttribute("d",rs),e.setAttribute("fill","currentColor"),t.append(e),t}var m=E.default.createElement,i7=20,r7={background:"linear-gradient(to bottom, var(--color-token-editor-background), transparent)"},o7={background:"linear-gradient(to top, var(--color-token-editor-background), transparent)"};function a7(t,e){"use no memo";let[n,l]=(0,E.useState)(null),[i,r]=(0,E.useState)(0),[o,a]=(0,E.useState)(null),s=(0,E.useRef)(null),u=(0,E.useCallback)(c=>{a(c)},[]);return(0,E.useLayoutEffect)(()=>{let c=o;if(!c)return;let f=()=>l(c.getBoundingClientRect().width);if(f(),typeof ResizeObserver>"u")return;let d=new ResizeObserver(f);return d.observe(c),()=>d.disconnect()},[o]),(0,E.useLayoutEffect)(()=>{let c=s.current;c&&r(c.getBoundingClientRect().width)},[t]),{inputMeasureRef:u,isSingleLine:vg({composerLayoutMode:"auto-single-line",hasVisibleAttachments:e,isEditorMultiline:t.includes(` -`),isVoiceLayoutActive:!1,singleLineInputWidth:n,singleLineTextWidth:i}),textMeasureRef:s}}function s7(t){"use no memo";let[e,n]=(0,E.useState)(()=>Qf(t)),l=(0,E.useRef)(null),i=(0,E.useRef)(new Map),r=(0,E.useRef)(new Map),o=(0,E.useRef)(new Map),a=(0,E.useRef)(t),s=(0,E.useRef)(e);a.current=t,s.current=e;let u=(0,E.useCallback)(()=>{let d=l.current;if(!d)return;let h=a.current.map(b=>{let g=(i.current.get(b.id)??null)?.offsetWidth??0;g>0&&(s.current[b.id]?.hideLabel===!0?o.current.set(b.id,g):r.current.set(b.id,g));let y=r.current.get(b.id)??o.current.get(b.id)??g,S=o.current.get(b.id),x=b.canHideLabel?Math.min(y,S??y):y;return{...b,compactWidth:x,expandedWidth:y,hasMeasuredCompactWidth:S!=null}}),p=Cg({availableWidth:d.getBoundingClientRect().width,gap:f3(window.getComputedStyle(d).columnGap)??f3(window.getComputedStyle(d).gap)??0,items:h,previousState:s.current});Mg(p,s.current)||n(p)},[]),c=(0,E.useCallback)(d=>{l.current=d,d&&u()},[u]),f=(0,E.useCallback)((d,h)=>{h?(i.current.set(d,h),u()):i.current.delete(d)},[u]);return(0,E.useLayoutEffect)(()=>{u()},[u,t,e]),(0,E.useLayoutEffect)(()=>{if(typeof ResizeObserver>"u")return;let d=new ResizeObserver(u);l.current&&d.observe(l.current);for(let h of i.current.values())d.observe(h);return()=>d.disconnect()},[u,t,e]),{state:e,setContainerRef:c,setItemRef:f}}function f3(t){let e=Number.parseFloat(t);return Number.isFinite(e)?e:null}function u7(t){(0,E.useEffect)(()=>{qg(t)},[t])}function c7(t){(0,E.useEffect)(()=>dg(e=>t({type:"event",event:e})),[t])}function f7(t,e){(0,E.useEffect)(()=>{Gg(t,e)},[t,e])}function y3(){let[t,e]=(0,E.useReducer)(Vg,Ug("react"));return u7(e),c7(e),f7(t,e),m(d7,{state:t,dispatch:e,renderer:"React"})}function d7({state:t,dispatch:e,renderer:n}){"use no memo";let l=t.providers.find(w=>w.id===t.selectedProviderId),i=hs(t),r=ds(t),o=Jg(t),[a,s]=(0,E.useState)([]),u=t.status==="running"&&(t.input.length>0||a.length>0),c=l?t.autoStartAttemptedProviderIds.includes(l.id):!1,f=r&&(l?.autoStart!==!0||c),d=l?.id==="codex",h=kg(l),p=l?.id==="codex"?t.context?.copy.reasoningEffortHigh??"High":null,[b,v]=(0,E.useState)("default"),[g,y]=(0,E.useState)(null),S=Y7(t),x=g??S!=null,O=S!=null&&x,z=a7(t.input,a.length>0),M=z.isSingleLine,N=s7([{canHideLabel:p!=null,enabled:l!=null,id:"intelligence"},{canHideLabel:!0,enabled:O,id:"ide-context"}]),T=M?{hideControl:!1,hideLabel:!1}:N.state.intelligence??{hideControl:!1,hideLabel:!1},A=M?{hideControl:!1,hideLabel:!1}:N.state["ide-context"]??{hideControl:!1,hideLabel:!1},G=(0,E.useRef)(null),[J,ye]=(0,E.useState)(null),[Qi,zt]=(0,E.useState)(""),[E3,Xt]=(0,E.useState)(0),[tu,Nt]=(0,E.useState)(!1),[N3,Rt]=(0,E.useState)(!1),[ch,fh]=(0,E.useState)(!1),[Ji,Oo]=(0,E.useState)(!1),[O3,dh]=(0,E.useState)(!1),[A3,Kt]=(0,E.useState)(!1),vn=J?sh(J,t,Qi):[],nu=vn.length===0?-1:Math.min(E3,vn.length-1),lu=()=>{let w=G.current?.getText()??t.input;if(!(t.status==="running"&&(w.length>0||a.length>0)))return;w!==t.input&&e({type:"setInput",input:w}),ye(null),zt(""),Nt(!1),Rt(!1),Kt(!1);let Fi=Og(Dg(w,Ji),S?.mention??null,x),be=Ng(Fi,a),iu=new Set(a.map(ru=>ru.id));Kg(t,e,{attachments:a,clearInput:w,displayText:w,permissionMode:d?b:"default",text:be}).then(ru=>{ru&&s(Z3=>Z3.filter(j3=>!iu.has(j3.id)))})},hh=w=>{G.current?.insertMention(w.mention),ye(null),zt(""),Xt(0),Rt(!1)},ph=()=>{Oo(w=>!w),Rt(!1),Kt(!1),Nt(!1),ye(null),G.current?.focus()},D3=()=>{y(w=>!(w??S!=null)),Rt(!1),Kt(!1),Nt(!1),ye(null),G.current?.focus()},k3=()=>{y(!1),Rt(!1),Kt(!1),Nt(!1),ye(null),G.current?.focus()},w3=()=>{Oo(!0),dh(!0),Rt(!1),Kt(!1),Nt(!1),ye(null),G.current?.focus()},z3=async()=>{if(ch)return;let w=t.runningSessionId;fh(!0),ye(null),zt(""),Xt(0),Rt(!1),Kt(!1);try{let Fi=((await pn("app.pickFiles")).files??[]).filter(be=>be.path.trim().length>0).map(be=>{let iu=be.label&&be.label.trim().length>0?be.label:Fs(be.path);return{dataUrl:be.dataUrl,fsPath:be.fsPath??be.path,id:`${be.path}-${Date.now()}-${Math.random().toString(36).slice(2)}`,kind:be.isImage||be.dataUrl?.startsWith("data:image/")?"image":"file",label:iu,mimeType:be.mimeType,path:be.path}});s(be=>$7([...be,...Fi])),G.current?.focus()}catch(Me){w||e({type:"failed",message:_i(Me,t)})}finally{fh(!1)}},R3=w=>{if(!w){ye(null),zt(""),Xt(0);return}ye(w.kind),zt(w.query),Xt(0)},L3=w=>{if(!J)return!1;if(w==="Escape")return ye(null),zt(""),Xt(0),!0;if(vn.length===0)return w==="Enter"||w==="Tab";if(w==="ArrowDown")return Xt(Me=>(Me+1)%vn.length),!0;if(w==="ArrowUp")return Xt(Me=>(Me-1+vn.length)%vn.length),!0;if(w==="Enter"||w==="Tab"){let Me=vn[nu>=0?nu:0];return Me&&hh(Me),!0}return!1},_3=w=>{Xg(w,t,e),Nt(!1)},B3=T.hideControl?null:m("div",{className:"model-picker-root relative min-w-0",ref:w=>N.setItemRef("intelligence",w),onBlur:w=>{w.currentTarget.contains(w.relatedTarget)||Nt(!1)}},m("button",{className:`model-picker ${Qe} ${Je} ${mn} min-w-0 rounded-full`,type:"button",disabled:!i,"aria-haspopup":"menu","aria-expanded":tu,"data-state":tu?"open":"closed","data-codex-intelligence-trigger":!0,"data-selected-reasoning-effort":"high",onClick:()=>{i&&(Rt(!1),Kt(!1),Nt(w=>!w))},onKeyDown:w=>{w.key==="ArrowDown"&&(w.preventDefault(),Nt(i))}},T.hideLabel?l9("icon-2xs"):m("span",{className:"model-picker-content flex max-w-40 min-w-0 items-center gap-1.5"},m("span",{className:"model-display flex min-w-0 items-center gap-1 tabular-nums"},m("span",{className:"model-label truncate whitespace-nowrap text-token-foreground"},h)),p?m("span",{className:"composer-footer__label--sm shrink-0 text-token-description-foreground"},p):null),m("span",{className:"model-chevron composer-footer__secondary-chevron icon-2xs text-token-input-placeholder-foreground","aria-hidden":!0},Ps())),tu?m("div",{className:"provider-dropdown _content_1hiti_1 no-drag bg-token-dropdown-background/90 text-token-foreground ring-token-border z-50 m-px flex select-none flex-col overflow-y-auto rounded-xl ring-[0.5px] px-1 py-1 shadow-xl-spread backdrop-blur-sm w-52",role:"menu","aria-label":t.context?.copy.provider??"","data-state":"open","data-side":"top",style:{"--radix-dropdown-menu-content-transform-origin":"left bottom"}},m("div",{className:"provider-dropdown-title"},t.context?.copy.provider??"Provider"),t.providers.map(w=>m("button",{key:w.id,className:"provider-dropdown-item no-drag group hover:bg-token-list-hover-background focus:bg-token-list-hover-background cursor-interaction text-token-foreground outline-hidden rounded-lg px-[var(--padding-row-x)] py-[var(--padding-row-y)] text-sm",type:"button",role:"menuitem","data-selected":w.id===t.selectedProviderId?"true":void 0,onMouseDown:Me=>Me.preventDefault(),onClick:()=>_3(w.id)},m("span",{className:"provider-dropdown-item-content flex w-full items-center gap-1.5"},m("span",{className:"min-w-0 flex-1 truncate"},w.displayName),w.id===t.selectedProviderId?m("span",{className:"provider-dropdown-check icon-xs shrink-0","aria-hidden":!0},Yl()):null)))):null),H3=m(a3,{ref:G,className:M?"text-base":"text-base [&_.ProseMirror]:leading-5",minHeight:M?"1.25rem":"2.75rem",singleLine:M,value:t.input,ariaLabel:t.context?.copy.promptPlaceholder??"",placeholder:t.context?.copy.promptPlaceholder??"",onAutocompleteChange:R3,onAutocompleteKeyDown:L3,onPlanModeShortcut:ph,onTextChange:w=>e({type:"setInput",input:w}),onSubmit:lu,onTriggerToken:w=>{ye(w==="@"?"mention":"skill"),zt(""),Xt(0)}}),mh=(w=!1)=>d?m(B7,{copy:t.context?.copy,hideLabel:w,isEnabled:!0,isOpen:A3,mode:b,onModeChange:v,onOpenChange:Me=>{Kt(Me),Me&&(Rt(!1),Nt(!1),ye(null))}}):null,gh=m("div",{className:"codex-left-rail flex min-w-0 items-center gap-[5px]"},m(U7,{hasIdeContext:S!=null,isAutoContextOn:x,isOpen:N3,isPickingFiles:ch,onOpenChange:w=>{Rt(w),w&&(Kt(!1),Nt(!1),ye(null))},onPickFiles:()=>void z3(),onToggleAutoContext:D3,onTogglePlanMode:ph,isPlanMode:Ji,state:t}),M?null:mh(!1),!M&&Ji?m(d3,{icon:Ws("icon-xs"),label:t.context?.copy.skillPlan??"Plan",onClear:()=>Oo(!1)}):null),yh=m("div",{className:"codex-secondary-controls flex min-w-0 items-center gap-1",ref:N.setContainerRef},B3,O&&!A.hideControl?m("span",{className:"composer-context-indicator flex min-w-0 items-center gap-1",ref:w=>N.setItemRef("ide-context",w)},m("div",{className:"composer-context-divider h-4 w-px bg-token-border/70","aria-hidden":!0}),m(_7,{hideLabel:A.hideLabel,label:t.context?.copy.ideContext??"IDE context",onClear:k3})):null),bh=m("div",{className:"codex-action-cluster flex shrink-0 items-center gap-2"},f?m("button",{className:`codex-action codex-start ${Qe} ${Je} ${mn} rounded-full`,type:"button",disabled:!r,onClick:()=>void Yg(t,e)},t.context?.copy.start??"Start"):null,o?m("button",{className:`codex-action codex-stop ${Qe} ${Je} ${mn} ${to} rounded-full`,type:"button","aria-label":t.context?.copy.stop??"Stop",onClick:()=>void Ig(t,e)},t9()):null,m("button",{className:`codex-action codex-mic ${Qe} ${Je} ${mn} ${to} rounded-full`,type:"button",disabled:!0,"aria-label":t.context?.copy.voiceInput??""},n9()),m("button",{className:`codex-action send-button ${pg}${u?"":" cursor-default opacity-50"}`,type:"button",disabled:!u,"aria-label":t.context?.copy.send??"Send",onClick:lu},e9("icon-sm text-token-dropdown-background"))),U3=m("div",{className:"flex min-w-0 shrink-0 items-center justify-end gap-2"},yh,mh(!0),M&&Ji?m(d3,{icon:Ws("icon-xs"),label:t.context?.copy.skillPlan??"Plan",onClear:()=>Oo(!1)}):null,bh),Sh=m("div",{key:"composer-input",ref:z.inputMeasureRef,className:M?"min-w-0":"mb-1 flex-grow overflow-y-auto px-3"},H3),V3=M?m("div",{className:Sg},gh,Sh,U3):m("div",{className:"contents"},a.length>0?m("div",{className:"px-2 py-1.5"},m(D7,{attachments:a,copy:t.context?.copy,onRemove:w=>{s(Me=>Me.filter(Fi=>Fi.id!==w))}})):null,Sh,m("div",{className:xg},gh,m("div",{className:"flex items-center"}),m("div",{className:"flex w-full min-w-0 items-center justify-end gap-2"},m("div",{className:"flex min-w-0 flex-1 justify-end"},yh),m("div",{className:"flex shrink-0 items-center gap-2"},bh)))),q3=m("div",{className:yg},V3),Y3=!Ji&&!O3&&/\bplan\b/i.test(t.input);return m("section",{className:"agent-shell","data-codex-window-type":"electron"},m(p7,{entries:t.transcript,copy:t.context?.copy}),m("div",{className:mg},m("div",{className:"relative flex w-full flex-col gap-2"},m("form",{className:"w-full min-w-0",onSubmit:w=>{w.preventDefault(),lu()}},m("div",{className:gg},m("span",{ref:z.textMeasureRef,className:"composer-single-line-measure text-size-chat pointer-events-none invisible absolute h-0 w-max max-w-none overflow-hidden whitespace-pre","aria-hidden":!0},t.input),Y3?m(h7,{copy:t.context?.copy,onAction:w3,onDismiss:()=>dh(!0)}):null,J?m(L7,{emptyLabel:t.context?.copy?.composerNoResults??"No results",highlightedIndex:nu,items:vn,onChoose:hh,onHighlight:Xt,query:Qi}):null,m("div",{className:bg+" "+(M?"overflow-visible rounded-full":"overflow-y-auto rounded-3xl")},q3)))),m(w7,{state:t,providerDisplayName:l?.displayName??n})))}function h7({copy:t,onAction:e,onDismiss:n}){return m("div",{className:"above-composer-suggestion-shell pointer-events-none absolute inset-x-0 bottom-full z-20 mb-2 flex justify-center"},m("div",{className:"pointer-events-auto flex w-full max-w-full justify-center"},m("div",{className:"relative inline-flex max-w-full min-w-0 items-center justify-between gap-4 overflow-hidden rounded-3xl border border-token-border/80 bg-token-dropdown-background/90 py-1.5 pr-2 pl-3 text-token-foreground shadow-md backdrop-blur-sm","data-codex-above-composer-suggestion":"keyword-plan-mode"},m("div",{className:"flex min-w-0 flex-1 items-center gap-2"},m("span",{className:"flex items-center justify-center text-token-foreground","aria-hidden":!0},Ws("icon-xs shrink-0")),m("div",{className:"min-w-0 flex-1 flex items-center gap-2"},m("span",{className:"truncate text-sm font-medium leading-[18px] text-token-foreground"},t?.planSuggestionTitle??"Create a plan"),m("span",{className:"hidden leading-none text-sm text-token-description-foreground @[500px]:inline","aria-hidden":!0},m("span",{className:"pointer-events-none !h-auto rounded-md border border-token-border/80 px-1 py-0.5 text-xs !leading-none text-token-description-foreground"},t?.planSuggestionShortcut??"Shift + Tab")))),m("div",{className:"flex shrink-0 items-center gap-1"},m("button",{className:"user-select-none no-drag cursor-interaction flex h-8 items-center justify-center rounded-full border border-transparent bg-token-button-secondary-background px-2.5 text-sm text-token-button-secondary-foreground hover:bg-token-button-secondary-hover-background focus:outline-none",type:"button",onClick:l=>{l.stopPropagation(),e()}},t?.planSuggestionAction??"Use plan mode"),m("button",{className:"user-select-none no-drag flex size-[22px] shrink-0 cursor-interaction items-center justify-center rounded-full border border-transparent text-token-description-foreground hover:bg-token-list-hover-background focus:outline-none",type:"button","aria-label":t?.planSuggestionDismiss??"Dismiss suggestion",onClick:l=>{l.stopPropagation(),n()}},eu("icon-xs"))))))}var p7=E.default.memo(function({entries:e,copy:n}){return m("div",{className:"agent-thread","data-empty":e.length===0?"true":void 0},e.map(l=>m(m7,{copy:n,entry:l,key:l.id})))}),m7=E.default.memo(function({copy:e,entry:n}){switch(n.role){case"user":{let l=n.attachments??[],i=n.text.trim().length>0;return m("div",{className:"codex-user-turn group flex w-full flex-col items-end justify-end gap-1"},l.length>0?m(O7,{attachments:l}):null,i?m("div",{className:"codex-user-bubble bg-token-foreground/5 max-w-[77%] min-w-0 overflow-hidden break-words rounded-2xl px-3 py-2 [&_.contain-inline-size]:[contain:initial]"},m(E7,{copy:e,text:n.text})):null,i?m(v7,{copy:e,sentAtMs:n.sentAtMs,text:n.text}):null)}case"assistant":return n.isComplete===!1?m("div",{className:"codex-assistant-turn group flex min-w-0 flex-col"},m("div",{className:"codex-assistant-message codex-assistant-message-streaming text-size-chat leading-[calc(var(--codex-chat-font-size)+8px)]"},n.text)):m("div",{className:"codex-assistant-turn group flex min-w-0 flex-col"},m("div",{className:"codex-assistant-message text-size-chat leading-[calc(var(--codex-chat-font-size)+8px)] [&>*:last-child]:mb-0 [&>ol:first-child]:mt-0 [&>ul:first-child]:mt-0",dangerouslySetInnerHTML:{__html:Eg(n.text)}}),n.text.trim().length>0?m(C7,{copy:e,sentAtMs:n.sentAtMs,text:n.text}):null);case"notice":return m("div",{className:`codex-notice-turn ${n.tone??"warning"}`},m("div",{className:"codex-notice-content text-size-chat-sm",dangerouslySetInnerHTML:{__html:Pn(n.text)}}));case"activity":return m(g7,{copy:e,entry:n})}});function g7({copy:t,entry:e}){"use no memo";let[n,l]=(0,E.useState)(!1),[i,r]=(0,E.useState)({bottom:!1,top:!1}),o=(0,E.useRef)(null),a=t?.copyOutput??"Copy output",s=t?.shellLabel??"",u=t?.copyShellContents??a,c=t?.copiedShellContents??u,f=t?.collapseShell??"",d=t?.shellSuccess??"",h=t?.stopped??"",p=t?.failedStatus??"",b=!!e.output,v=(0,E.useCallback)(()=>{l(T=>!T)},[]),g=(0,E.useCallback)(()=>{l(!1)},[]),y=(0,E.useCallback)(T=>{let A=T===null?{bottom:!1,top:!1}:{bottom:T.scrollTop+T.clientHeight1};r(G=>G.bottom===A.bottom&&G.top===A.top?G:A)},[]),S=(0,E.useCallback)(()=>{o.current?.mutation?.disconnect(),o.current?.resize?.disconnect(),o.current=null},[]),x=(0,E.useCallback)(T=>{if(S(),T===null){y(null);return}let A=()=>y(T);A();let G=typeof ResizeObserver>"u"?void 0:new ResizeObserver(A);G?.observe(T);let J=typeof MutationObserver>"u"?void 0:new MutationObserver(A);J?.observe(T,{characterData:!0,childList:!0,subtree:!0}),o.current={mutation:J,resize:G}},[S,y]),O=(0,E.useCallback)(T=>{y(T.currentTarget)},[y]),z=b7(e),M=m(E.default.Fragment,null,m("span",{className:"codex-tool-activity-icon icon-xs shrink-0","aria-hidden":!0},fg(e)),m("span",{className:"codex-tool-activity-text shrink overflow-hidden [mask-image:linear-gradient(to_right,black_calc(100%_-_0.25rem),transparent)] [mask-repeat:no-repeat] pr-1 group-hover/collapsed-tool-activity:text-token-foreground"},m("span",{className:"codex-tool-activity-action",dangerouslySetInnerHTML:{__html:Pn(e.text)}}),e.detail?m("span",{className:"codex-tool-activity-detail",dangerouslySetInnerHTML:{__html:` ${Pn(e.detail)}`}}):null)),N=b?m("button",{type:"button",className:"codex-tool-activity-summary group/collapsed-tool-activity group/summary inline-flex w-fit max-w-full cursor-interaction items-center gap-1 self-start text-left","aria-expanded":n,onClick:v},M,m("span",{className:`codex-tool-activity-chevron inline-chevron flex-shrink-0 text-token-input-placeholder-foreground opacity-0 group-hover/summary:opacity-100${n?" opacity-100":""}`},i9(`icon-2xs text-current transition-transform duration-300${n?" rotate-90":""}`))):m("div",{className:"codex-tool-activity-summary group/collapsed-tool-activity group/summary inline-flex w-fit max-w-full items-center gap-1 self-start text-left"},M);return m("div",{className:`codex-tool-activity-turn ${e.activityKind??"other"} ${e.activityStatus??"completed"}`},N,e.output?m("div",{"aria-hidden":!n,className:"codex-tool-activity-output-shell relative overflow-hidden","data-expanded":n?"true":"false"},m("div",{className:"codex-tool-activity-output-shell-inner"},m("div",{className:"codex-tool-activity-output-frame group/output relative pr-0 min-h-[1.25rem] flex flex-col overflow-clip rounded-lg border border-token-border"},m("div",{className:"codex-shell-header flex items-center justify-between bg-token-side-bar-background pl-2 text-sm font-medium text-ellipsis hover:bg-token-editor-background/40"},m("div",{className:"flex min-w-0 items-center"},m("span",{className:"truncate"},s)),m("div",{className:"flex items-center"},m(S7,{copiedLabel:c,copyLabel:u,text:z}),m("button",{type:"button",className:`codex-shell-collapse ${Qe} ${Je} ${eo} rounded-full electron:rounded-md hover:bg-transparent hover:text-token-button-foreground`,"aria-label":f,title:f,onClick:g},Ps("icon-2xs rotate-180")))),m("div",{className:"relative overflow-hidden"},m("div",{className:"codex-tool-activity-output vertical-scroll-fade-mask [--edge-fade-distance:2rem] box-border flex flex-col gap-1.5 overflow-x-auto overflow-y-auto whitespace-pre p-2 font-vscode-editor font-medium text-size-code-sm text-token-description-foreground max-h-[140px]",onScroll:O,ref:x,dangerouslySetInnerHTML:{__html:Pn(e.output)}}),i.top?m("div",{"aria-hidden":!0,className:"pointer-events-none absolute inset-x-0 top-0 h-6",style:r7}):null,i.bottom?m("div",{"aria-hidden":!0,className:"pointer-events-none absolute inset-x-0 bottom-0 h-6",style:o7}):null),m(y7,{failedLabel:p,status:e.activityStatus??"completed",stoppedLabel:h,successLabel:d}),m(x7,{label:a,output:e.output})))):null)}function y7({failedLabel:t,status:e,stoppedLabel:n,successLabel:l}){return e==="inProgress"?m("div",{className:"codex-shell-footer text-size-chat px-2.5 pt-0.5 pb-1"}):e==="stopped"?m("div",{className:"codex-shell-footer text-size-chat flex items-center gap-2 px-2.5 pt-0.5 pb-1 text-token-input-placeholder-foreground"},m("span",{className:"ml-auto"},n)):e==="failed"?m("div",{className:"codex-shell-footer text-size-chat flex items-center gap-2 px-2.5 pt-0.5 pb-1 text-token-input-placeholder-foreground"},m("span",{className:"ml-auto"},t)):m("div",{className:"codex-shell-footer text-size-chat flex items-center gap-2 px-2.5 pt-0.5 pb-1 text-token-input-placeholder-foreground"},m("span",{className:"ml-auto flex items-center gap-1"},Yl("icon-xxs"),l))}function b7(t){let e=(t.detail??t.text).trim(),n=t.output?.trimEnd()??"";return[e,n].filter(l=>l.length>0).join(` +`),r=/(^|\s)([@$][^\s@$]*)$/.exec(i);if(!r)return null;let o=r[2]??"",a=o.charAt(0),s=o.slice(1);return{anchorPos:n-o.length,kind:a==="$"?"skill":"mention",query:s}}function a7(t){return t==="ArrowDown"||t==="ArrowUp"||t==="Enter"||t==="Tab"||t==="Escape"}function rh({dataAttributes:t,icon:e,iconNode:n,text:l,title:i}){let r=document.createElement("span");r.className="prompt-editor-mention group/inline-mention cursor-pointer inline-mention-brand-aware font-medium px-0.5 cursor-interaction",i&&(r.title=i);for(let[a,s]of Object.entries(t))r.setAttribute(a,s);if(e||n){let a=document.createElement("span");if(a.className="prompt-editor-mention-icon relative mr-[3px] inline-block h-[1lh] w-4 align-bottom",n)a.append(n);else if(e){let s=document.createElement("span");s.className="icon-xs absolute top-1/2 -translate-y-1/2",s.textContent=e,a.append(s)}r.append(a)}let o=document.createElement("span");return o.className="min-w-0 break-words",o.textContent=l,r.append(o),r}function s7(){let t=document.createElementNS("http://www.w3.org/2000/svg","svg");t.setAttribute("class","icon-xs absolute top-1/2 -translate-y-1/2"),t.setAttribute("width","20"),t.setAttribute("height","20"),t.setAttribute("viewBox","0 0 20 20"),t.setAttribute("fill","none"),t.setAttribute("aria-hidden","true");let e=document.createElementNS("http://www.w3.org/2000/svg","path");return e.setAttribute("d",cs),e.setAttribute("fill","currentColor"),t.append(e),t}var m=E.default.createElement,u7=20,c7={background:"linear-gradient(to bottom, var(--color-token-editor-background), transparent)"},f7={background:"linear-gradient(to top, var(--color-token-editor-background), transparent)"};function d7(t,e){"use no memo";let[n,l]=(0,E.useState)(null),[i,r]=(0,E.useState)(0),[o,a]=(0,E.useState)(null),s=(0,E.useRef)(null),u=(0,E.useCallback)(c=>{a(c)},[]);return(0,E.useLayoutEffect)(()=>{let c=o;if(!c)return;let f=()=>l(c.getBoundingClientRect().width);if(f(),typeof ResizeObserver>"u")return;let d=new ResizeObserver(f);return d.observe(c),()=>d.disconnect()},[o]),(0,E.useLayoutEffect)(()=>{let c=s.current;c&&r(c.getBoundingClientRect().width)},[t]),{inputMeasureRef:u,isSingleLine:Mg({composerLayoutMode:"auto-single-line",hasVisibleAttachments:e,isEditorMultiline:t.includes(` +`),isVoiceLayoutActive:!1,singleLineInputWidth:n,singleLineTextWidth:i}),textMeasureRef:s}}function h7(t){"use no memo";let[e,n]=(0,E.useState)(()=>Ff(t)),l=(0,E.useRef)(null),i=(0,E.useRef)(new Map),r=(0,E.useRef)(new Map),o=(0,E.useRef)(new Map),a=(0,E.useRef)(t),s=(0,E.useRef)(e);a.current=t,s.current=e;let u=(0,E.useCallback)(()=>{let d=l.current;if(!d)return;let h=a.current.map(b=>{let g=(i.current.get(b.id)??null)?.offsetWidth??0;g>0&&(s.current[b.id]?.hideLabel===!0?o.current.set(b.id,g):r.current.set(b.id,g));let y=r.current.get(b.id)??o.current.get(b.id)??g,S=o.current.get(b.id),x=b.canHideLabel?Math.min(y,S??y):y;return{...b,compactWidth:x,expandedWidth:y,hasMeasuredCompactWidth:S!=null}}),p=Tg({availableWidth:d.getBoundingClientRect().width,gap:p3(window.getComputedStyle(d).columnGap)??p3(window.getComputedStyle(d).gap)??0,items:h,previousState:s.current});Eg(p,s.current)||n(p)},[]),c=(0,E.useCallback)(d=>{l.current=d,d&&u()},[u]),f=(0,E.useCallback)((d,h)=>{h?(i.current.set(d,h),u()):i.current.delete(d)},[u]);return(0,E.useLayoutEffect)(()=>{u()},[u,t,e]),(0,E.useLayoutEffect)(()=>{if(typeof ResizeObserver>"u")return;let d=new ResizeObserver(u);l.current&&d.observe(l.current);for(let h of i.current.values())d.observe(h);return()=>d.disconnect()},[u,t,e]),{state:e,setContainerRef:c,setItemRef:f}}function p3(t){let e=Number.parseFloat(t);return Number.isFinite(e)?e:null}function p7(t){(0,E.useEffect)(()=>{Zg(t)},[t])}function m7(t){(0,E.useEffect)(()=>pg(e=>t({type:"event",event:e})),[t])}function g7(t,e){(0,E.useEffect)(()=>{Ig(t,e)},[t,e])}function x3(){let[t,e]=(0,E.useReducer)(Yg,qg("react"));return p7(e),m7(e),g7(t,e),m(y7,{state:t,dispatch:e,renderer:"React"})}function y7({state:t,dispatch:e,renderer:n}){"use no memo";let l=t.providers.find(w=>w.id===t.selectedProviderId),i=ms(t),r=ps(t),o=Pg(t),[a,s]=(0,E.useState)([]),u=t.status==="running"&&(t.input.length>0||a.length>0),c=l?t.autoStartAttemptedProviderIds.includes(l.id):!1,f=r&&(l?.autoStart!==!0||c),d=l?.id==="codex",h=zg(l),p=l?.id==="codex"?t.context?.copy.reasoningEffortHigh??"High":null,[b,v]=(0,E.useState)("default"),[g,y]=(0,E.useState)(null),S=I7(t),x=g??S!=null,A=S!=null&&x,z=d7(t.input,a.length>0),M=z.isSingleLine,N=h7([{canHideLabel:p!=null,enabled:l!=null,id:"intelligence"},{canHideLabel:!0,enabled:A,id:"ide-context"}]),T=M?{hideControl:!1,hideLabel:!1}:N.state.intelligence??{hideControl:!1,hideLabel:!1},O=M?{hideControl:!1,hideLabel:!1}:N.state["ide-context"]??{hideControl:!1,hideLabel:!1},G=(0,E.useRef)(null),[F,ye]=(0,E.useState)(null),[Ji,zt]=(0,E.useState)(""),[O3,Xt]=(0,E.useState)(0),[lu,At]=(0,E.useState)(!1),[D3,Rt]=(0,E.useState)(!1),[fh,dh]=(0,E.useState)(!1),[Pi,ko]=(0,E.useState)(!1),[k3,hh]=(0,E.useState)(!1),[w3,It]=(0,E.useState)(!1),vn=F?uh(F,t,Ji):[],iu=vn.length===0?-1:Math.min(O3,vn.length-1),ru=()=>{let w=G.current?.getText()??t.input;if(!(t.status==="running"&&(w.length>0||a.length>0)))return;w!==t.input&&e({type:"setInput",input:w}),ye(null),zt(""),At(!1),Rt(!1),It(!1);let Wi=Dg(wg(w,Pi),S?.mention??null,x),be=Og(Wi,a),ou=new Set(a.map(au=>au.id));Qg(t,e,{attachments:a,clearInput:w,displayText:w,permissionMode:d?b:"default",text:be}).then(au=>{au&&s(X3=>X3.filter(I3=>!ou.has(I3.id)))})},ph=w=>{G.current?.insertMention(w.mention),ye(null),zt(""),Xt(0),Rt(!1)},mh=()=>{ko(w=>!w),Rt(!1),It(!1),At(!1),ye(null),G.current?.focus()},z3=()=>{y(w=>!(w??S!=null)),Rt(!1),It(!1),At(!1),ye(null),G.current?.focus()},R3=()=>{y(!1),Rt(!1),It(!1),At(!1),ye(null),G.current?.focus()},L3=()=>{ko(!0),hh(!0),Rt(!1),It(!1),At(!1),ye(null),G.current?.focus()},_3=async()=>{if(fh)return;let w=t.runningSessionId;dh(!0),ye(null),zt(""),Xt(0),Rt(!1),It(!1);try{let Wi=((await pn("app.pickFiles")).files??[]).filter(be=>be.path.trim().length>0).map(be=>{let ou=be.label&&be.label.trim().length>0?be.label:Ws(be.path);return{dataUrl:be.dataUrl,fsPath:be.fsPath??be.path,id:`${be.path}-${Date.now()}-${Math.random().toString(36).slice(2)}`,kind:be.isImage||be.dataUrl?.startsWith("data:image/")?"image":"file",label:ou,mimeType:be.mimeType,path:be.path}});s(be=>i9([...be,...Wi])),G.current?.focus()}catch(Me){w||e({type:"failed",message:Hi(Me,t)})}finally{dh(!1)}},B3=w=>{if(!w){ye(null),zt(""),Xt(0);return}ye(w.kind),zt(w.query),Xt(0)},H3=w=>{if(!F)return!1;if(w==="Escape")return ye(null),zt(""),Xt(0),!0;if(vn.length===0)return w==="Enter"||w==="Tab";if(w==="ArrowDown")return Xt(Me=>(Me+1)%vn.length),!0;if(w==="ArrowUp")return Xt(Me=>(Me-1+vn.length)%vn.length),!0;if(w==="Enter"||w==="Tab"){let Me=vn[iu>=0?iu:0];return Me&&ph(Me),!0}return!1},U3=w=>{Kg(w,t,e),At(!1)},V3=T.hideControl?null:m("div",{className:"model-picker-root relative min-w-0",ref:w=>N.setItemRef("intelligence",w),onBlur:w=>{w.currentTarget.contains(w.relatedTarget)||At(!1)}},m("button",{className:`model-picker ${Qe} ${Fe} ${mn} min-w-0 rounded-full`,type:"button",disabled:!i,"aria-haspopup":"menu","aria-expanded":lu,"data-state":lu?"open":"closed","data-codex-intelligence-trigger":!0,"data-selected-reasoning-effort":"high",onClick:()=>{i&&(Rt(!1),It(!1),At(w=>!w))},onKeyDown:w=>{w.key==="ArrowDown"&&(w.preventDefault(),At(i))}},T.hideLabel?s9("icon-2xs"):m("span",{className:"model-picker-content flex max-w-40 min-w-0 items-center gap-1.5"},m("span",{className:"model-display flex min-w-0 items-center gap-1 tabular-nums"},m("span",{className:"model-label truncate whitespace-nowrap text-token-foreground"},h)),p?m("span",{className:"composer-footer__label--sm shrink-0 text-token-description-foreground"},p):null),m("span",{className:"model-chevron composer-footer__secondary-chevron icon-2xs text-token-input-placeholder-foreground","aria-hidden":!0},eu())),lu?m("div",{className:"provider-dropdown _content_1hiti_1 no-drag bg-token-dropdown-background/90 text-token-foreground ring-token-border z-50 m-px flex select-none flex-col overflow-y-auto rounded-xl ring-[0.5px] px-1 py-1 shadow-xl-spread backdrop-blur-sm w-52",role:"menu","aria-label":t.context?.copy.provider??"","data-state":"open","data-side":"top",style:{"--radix-dropdown-menu-content-transform-origin":"left bottom"}},m("div",{className:"provider-dropdown-title"},t.context?.copy.provider??"Provider"),t.providers.map(w=>m("button",{key:w.id,className:"provider-dropdown-item no-drag group hover:bg-token-list-hover-background focus:bg-token-list-hover-background cursor-interaction text-token-foreground outline-hidden rounded-lg px-[var(--padding-row-x)] py-[var(--padding-row-y)] text-sm",type:"button",role:"menuitem","data-selected":w.id===t.selectedProviderId?"true":void 0,onMouseDown:Me=>Me.preventDefault(),onClick:()=>U3(w.id)},m("span",{className:"provider-dropdown-item-content flex w-full items-center gap-1.5"},m("span",{className:"min-w-0 flex-1 truncate"},w.displayName),w.id===t.selectedProviderId?m("span",{className:"provider-dropdown-check icon-xs shrink-0","aria-hidden":!0},Yl()):null)))):null),q3=m(c3,{ref:G,className:M?"text-base":"text-base [&_.ProseMirror]:leading-5",minHeight:M?"1.25rem":"2.75rem",singleLine:M,value:t.input,ariaLabel:t.context?.copy.promptPlaceholder??"",placeholder:t.context?.copy.promptPlaceholder??"",onAutocompleteChange:B3,onAutocompleteKeyDown:H3,onPlanModeShortcut:mh,onTextChange:w=>e({type:"setInput",input:w}),onSubmit:ru,onTriggerToken:w=>{ye(w==="@"?"mention":"skill"),zt(""),Xt(0)}}),gh=(w=!1)=>d?m(Y7,{copy:t.context?.copy,hideLabel:w,isEnabled:!0,isOpen:w3,mode:b,onModeChange:v,onOpenChange:Me=>{It(Me),Me&&(Rt(!1),At(!1),ye(null))}}):null,yh=m("div",{className:"codex-left-rail flex min-w-0 items-center gap-[5px]"},m(j7,{hasIdeContext:S!=null,isAutoContextOn:x,isOpen:D3,isPickingFiles:fh,onOpenChange:w=>{Rt(w),w&&(It(!1),At(!1),ye(null))},onPickFiles:()=>void _3(),onToggleAutoContext:z3,onTogglePlanMode:mh,isPlanMode:Pi,state:t}),M?null:gh(!1),!M&&Pi?m(m3,{icon:$s("icon-xs"),label:t.context?.copy.skillPlan??"Plan",onClear:()=>ko(!1)}):null),bh=m("div",{className:"codex-secondary-controls flex min-w-0 items-center gap-1",ref:N.setContainerRef},V3,A&&!O.hideControl?m("span",{className:"composer-context-indicator flex min-w-0 items-center gap-1",ref:w=>N.setItemRef("ide-context",w)},m("div",{className:"composer-context-divider h-4 w-px bg-token-border/70","aria-hidden":!0}),m(q7,{hideLabel:O.hideLabel,label:t.context?.copy.ideContext??"IDE context",onClear:R3})):null),Sh=m("div",{className:"codex-action-cluster flex shrink-0 items-center gap-2"},f?m("button",{className:`codex-action codex-start ${Qe} ${Fe} ${mn} rounded-full`,type:"button",disabled:!r,onClick:()=>void jg(t,e)},t.context?.copy.start??"Start"):null,o?m("button",{className:`codex-action codex-stop ${Qe} ${Fe} ${mn} ${lo} rounded-full`,type:"button","aria-label":t.context?.copy.stop??"Stop",onClick:()=>void Fg(t,e)},o9()):null,m("button",{className:`codex-action codex-mic ${Qe} ${Fe} ${mn} ${lo} rounded-full`,type:"button",disabled:!0,"aria-label":t.context?.copy.voiceInput??""},a9()),m("button",{className:`codex-action send-button ${gg}${u?"":" cursor-default opacity-50"}`,type:"button",disabled:!u,"aria-label":t.context?.copy.send??"Send",onClick:ru},r9("icon-sm text-token-dropdown-background"))),Y3=m("div",{className:"flex min-w-0 shrink-0 items-center justify-end gap-2"},bh,gh(!0),M&&Pi?m(m3,{icon:$s("icon-xs"),label:t.context?.copy.skillPlan??"Plan",onClear:()=>ko(!1)}):null,Sh),xh=m("div",{key:"composer-input",ref:z.inputMeasureRef,className:M?"min-w-0":"mb-1 flex-grow overflow-y-auto px-3"},q3),Z3=M?m("div",{className:vg},yh,xh,Y3):m("div",{className:"contents"},a.length>0?m("div",{className:"px-2 py-1.5"},m(L7,{attachments:a,copy:t.context?.copy,onRemove:w=>{s(Me=>Me.filter(Wi=>Wi.id!==w))}})):null,xh,m("div",{className:Cg},yh,m("div",{className:"flex items-center"}),m("div",{className:"flex w-full min-w-0 items-center justify-end gap-2"},m("div",{className:"flex min-w-0 flex-1 justify-end"},bh),m("div",{className:"flex shrink-0 items-center gap-2"},Sh)))),j3=m("div",{className:Sg},Z3),G3=!Pi&&!k3&&/\bplan\b/i.test(t.input);return m("section",{className:"agent-shell","data-codex-window-type":"electron"},m(S7,{entries:t.transcript,copy:t.context?.copy}),m("div",{className:yg},m("div",{className:"relative flex w-full flex-col gap-2"},m("form",{className:"w-full min-w-0",onSubmit:w=>{w.preventDefault(),ru()}},m("div",{className:bg},m("span",{ref:z.textMeasureRef,className:"composer-single-line-measure text-size-chat pointer-events-none invisible absolute h-0 w-max max-w-none overflow-hidden whitespace-pre","aria-hidden":!0},t.input),G3?m(b7,{copy:t.context?.copy,onAction:L3,onDismiss:()=>hh(!0)}):null,F?m(V7,{emptyLabel:t.context?.copy?.composerNoResults??"No results",highlightedIndex:iu,items:vn,onChoose:ph,onHighlight:Xt,query:Ji}):null,m("div",{className:xg+" "+(M?"overflow-visible rounded-full":"overflow-y-auto rounded-3xl")},j3)))),m(B7,{state:t,providerDisplayName:l?.displayName??n})))}function b7({copy:t,onAction:e,onDismiss:n}){return m("div",{className:"above-composer-suggestion-shell pointer-events-none absolute inset-x-0 bottom-full z-20 mb-2 flex justify-center"},m("div",{className:"pointer-events-auto flex w-full max-w-full justify-center"},m("div",{className:"relative inline-flex max-w-full min-w-0 items-center justify-between gap-4 overflow-hidden rounded-3xl border border-token-border/80 bg-token-dropdown-background/90 py-1.5 pr-2 pl-3 text-token-foreground shadow-md backdrop-blur-sm","data-codex-above-composer-suggestion":"keyword-plan-mode"},m("div",{className:"flex min-w-0 flex-1 items-center gap-2"},m("span",{className:"flex items-center justify-center text-token-foreground","aria-hidden":!0},$s("icon-xs shrink-0")),m("div",{className:"min-w-0 flex-1 flex items-center gap-2"},m("span",{className:"truncate text-sm font-medium leading-[18px] text-token-foreground"},t?.planSuggestionTitle??"Create a plan"),m("span",{className:"hidden leading-none text-sm text-token-description-foreground @[500px]:inline","aria-hidden":!0},m("span",{className:"pointer-events-none !h-auto rounded-md border border-token-border/80 px-1 py-0.5 text-xs !leading-none text-token-description-foreground"},t?.planSuggestionShortcut??"Shift + Tab")))),m("div",{className:"flex shrink-0 items-center gap-1"},m("button",{className:"user-select-none no-drag cursor-interaction flex h-8 items-center justify-center rounded-full border border-transparent bg-token-button-secondary-background px-2.5 text-sm text-token-button-secondary-foreground hover:bg-token-button-secondary-hover-background focus:outline-none",type:"button",onClick:l=>{l.stopPropagation(),e()}},t?.planSuggestionAction??"Use plan mode"),m("button",{className:"user-select-none no-drag flex size-[22px] shrink-0 cursor-interaction items-center justify-center rounded-full border border-transparent text-token-description-foreground hover:bg-token-list-hover-background focus:outline-none",type:"button","aria-label":t?.planSuggestionDismiss??"Dismiss suggestion",onClick:l=>{l.stopPropagation(),n()}},nu("icon-xs"))))))}var S7=E.default.memo(function({entries:e,copy:n}){return m("div",{className:"agent-thread","data-empty":e.length===0?"true":void 0},e.map(l=>m(x7,{copy:n,entry:l,key:l.id})))}),x7=E.default.memo(function({copy:e,entry:n}){switch(n.role){case"user":{let l=n.attachments??[],i=n.text.trim().length>0;return m("div",{className:"codex-user-turn group flex w-full flex-col items-end justify-end gap-1"},l.length>0?m(z7,{attachments:l}):null,i?m("div",{className:"codex-user-bubble bg-token-foreground/5 max-w-[77%] min-w-0 overflow-hidden break-words rounded-2xl px-3 py-2 [&_.contain-inline-size]:[contain:initial]"},m(k7,{copy:e,text:n.text})):null,i?m(N7,{copy:e,sentAtMs:n.sentAtMs,text:n.text}):null)}case"assistant":return n.isComplete===!1?m("div",{className:"codex-assistant-turn group flex min-w-0 flex-col"},m("div",{className:"codex-assistant-message codex-assistant-message-streaming text-size-chat leading-[calc(var(--codex-chat-font-size)+8px)]"},n.text)):m("div",{className:"codex-assistant-turn group flex min-w-0 flex-col"},m("div",{className:"codex-assistant-message text-size-chat leading-[calc(var(--codex-chat-font-size)+8px)] [&>*:last-child]:mb-0 [&>ol:first-child]:mt-0 [&>ul:first-child]:mt-0",dangerouslySetInnerHTML:{__html:Ag(n.text)}}),n.text.trim().length>0?m(A7,{copy:e,sentAtMs:n.sentAtMs,text:n.text}):null);case"notice":return m("div",{className:`codex-notice-turn ${n.tone??"warning"}`},m("div",{className:"codex-notice-content text-size-chat-sm",dangerouslySetInnerHTML:{__html:Wn(n.text)}}));case"activity":return m(v7,{copy:e,entry:n})}});function v7({copy:t,entry:e}){"use no memo";let[n,l]=(0,E.useState)(!1),[i,r]=(0,E.useState)({bottom:!1,top:!1}),o=(0,E.useRef)(null),a=t?.copyOutput??"Copy output",s=t?.shellLabel??"",u=t?.copyShellContents??a,c=t?.copiedShellContents??u,f=t?.collapseShell??"",d=t?.shellSuccess??"",h=t?.stopped??"",p=t?.failedStatus??"",b=!!e.output,v=(0,E.useCallback)(()=>{l(T=>!T)},[]),g=(0,E.useCallback)(()=>{l(!1)},[]),y=(0,E.useCallback)(T=>{let O=T===null?{bottom:!1,top:!1}:{bottom:T.scrollTop+T.clientHeight1};r(G=>G.bottom===O.bottom&&G.top===O.top?G:O)},[]),S=(0,E.useCallback)(()=>{o.current?.mutation?.disconnect(),o.current?.resize?.disconnect(),o.current=null},[]),x=(0,E.useCallback)(T=>{if(S(),T===null){y(null);return}let O=()=>y(T);O();let G=typeof ResizeObserver>"u"?void 0:new ResizeObserver(O);G?.observe(T);let F=typeof MutationObserver>"u"?void 0:new MutationObserver(O);F?.observe(T,{characterData:!0,childList:!0,subtree:!0}),o.current={mutation:F,resize:G}},[S,y]),A=(0,E.useCallback)(T=>{y(T.currentTarget)},[y]),z=M7(e),M=m(E.default.Fragment,null,m("span",{className:"codex-tool-activity-icon icon-xs shrink-0","aria-hidden":!0},dg(e)),m("span",{className:"codex-tool-activity-text shrink overflow-hidden [mask-image:linear-gradient(to_right,black_calc(100%_-_0.25rem),transparent)] [mask-repeat:no-repeat] pr-1 group-hover/collapsed-tool-activity:text-token-foreground"},m("span",{className:"codex-tool-activity-action",dangerouslySetInnerHTML:{__html:Wn(e.text)}}),e.detail?m("span",{className:"codex-tool-activity-detail",dangerouslySetInnerHTML:{__html:` ${Wn(e.detail)}`}}):null)),N=b?m("button",{type:"button",className:"codex-tool-activity-summary group/collapsed-tool-activity group/summary inline-flex w-fit max-w-full cursor-interaction items-center gap-1 self-start text-left","aria-expanded":n,onClick:v},M,m("span",{className:`codex-tool-activity-chevron inline-chevron flex-shrink-0 text-token-input-placeholder-foreground opacity-0 group-hover/summary:opacity-100${n?" opacity-100":""}`},u9(`icon-2xs text-current transition-transform duration-300${n?" rotate-90":""}`))):m("div",{className:"codex-tool-activity-summary group/collapsed-tool-activity group/summary inline-flex w-fit max-w-full items-center gap-1 self-start text-left"},M);return m("div",{className:`codex-tool-activity-turn ${e.activityKind??"other"} ${e.activityStatus??"completed"}`},N,e.output?m("div",{"aria-hidden":!n,className:"codex-tool-activity-output-shell relative overflow-hidden","data-expanded":n?"true":"false"},m("div",{className:"codex-tool-activity-output-shell-inner"},m("div",{className:"codex-tool-activity-output-frame group/output relative pr-0 min-h-[1.25rem] flex flex-col overflow-clip rounded-lg border border-token-border"},m("div",{className:"codex-shell-header flex items-center justify-between bg-token-side-bar-background pl-2 text-sm font-medium text-ellipsis hover:bg-token-editor-background/40"},m("div",{className:"flex min-w-0 items-center"},m("span",{className:"truncate"},s)),m("div",{className:"flex items-center"},m(T7,{copiedLabel:c,copyLabel:u,text:z}),m("button",{type:"button",className:`codex-shell-collapse ${Qe} ${Fe} ${no} rounded-full electron:rounded-md hover:bg-transparent hover:text-token-button-foreground`,"aria-label":f,title:f,onClick:g},eu("icon-2xs rotate-180")))),m("div",{className:"relative overflow-hidden"},m("div",{className:"codex-tool-activity-output vertical-scroll-fade-mask [--edge-fade-distance:2rem] box-border flex flex-col gap-1.5 overflow-x-auto overflow-y-auto whitespace-pre p-2 font-vscode-editor font-medium text-size-code-sm text-token-description-foreground max-h-[140px]",onScroll:A,ref:x,dangerouslySetInnerHTML:{__html:Wn(e.output)}}),i.top?m("div",{"aria-hidden":!0,className:"pointer-events-none absolute inset-x-0 top-0 h-6",style:c7}):null,i.bottom?m("div",{"aria-hidden":!0,className:"pointer-events-none absolute inset-x-0 bottom-0 h-6",style:f7}):null),m(C7,{failedLabel:p,status:e.activityStatus??"completed",stoppedLabel:h,successLabel:d}),m(E7,{label:a,output:e.output})))):null)}function C7({failedLabel:t,status:e,stoppedLabel:n,successLabel:l}){return e==="inProgress"?m("div",{className:"codex-shell-footer text-size-chat px-2.5 pt-0.5 pb-1"}):e==="stopped"?m("div",{className:"codex-shell-footer text-size-chat flex items-center gap-2 px-2.5 pt-0.5 pb-1 text-token-input-placeholder-foreground"},m("span",{className:"ml-auto"},n)):e==="failed"?m("div",{className:"codex-shell-footer text-size-chat flex items-center gap-2 px-2.5 pt-0.5 pb-1 text-token-input-placeholder-foreground"},m("span",{className:"ml-auto"},t)):m("div",{className:"codex-shell-footer text-size-chat flex items-center gap-2 px-2.5 pt-0.5 pb-1 text-token-input-placeholder-foreground"},m("span",{className:"ml-auto flex items-center gap-1"},Yl("icon-xxs"),l))}function M7(t){let e=(t.detail??t.text).trim(),n=t.output?.trimEnd()??"";return[e,n].filter(l=>l.length>0).join(` -`)}function S7({copiedLabel:t,copyLabel:e,text:n}){"use no memo";let[l,i]=(0,E.useState)(!1),r=(0,E.useRef)(null),o=l?t:e,a=(0,E.useCallback)(u=>{u!==null||r.current===null||(window.clearTimeout(r.current),r.current=null)},[]),s=(0,E.useCallback)(u=>{u.stopPropagation(),navigator.clipboard?.writeText(n)?.catch(()=>{}),i(!0),r.current!==null&&window.clearTimeout(r.current),r.current=window.setTimeout(()=>{i(!1),r.current=null},2e3)},[n]);return m("button",{ref:a,type:"button",className:`codex-shell-copy ${Qe} ${Je} ${eo} rounded-full electron:rounded-md hover:bg-transparent hover:text-token-button-foreground`,"aria-label":o,title:o,onClick:s},l?Yl("icon-xxs"):$s("icon-xxs"))}function x7({label:t,output:e}){"use no memo";let[n,l]=(0,E.useState)(!1),i=(0,E.useRef)(null),r=(0,E.useCallback)(a=>{a!==null||i.current===null||(window.clearTimeout(i.current),i.current=null)},[]),o=(0,E.useCallback)(a=>{a.stopPropagation(),navigator.clipboard?.writeText(e)?.catch(()=>{}),l(!0),i.current!==null&&window.clearTimeout(i.current),i.current=window.setTimeout(()=>{l(!1),i.current=null},2e3)},[e]);return m("button",{ref:r,type:"button",className:`codex-tool-output-copy ${Qe} ${Je} rounded-full electron:rounded-md electron:p-1 electron:[&>svg]:icon-sm flex items-center justify-center p-0.5 absolute top-0 right-2.5 opacity-0 transition-opacity duration-200 group-hover/output:opacity-100${n?" text-token-foreground opacity-100":""}`,"aria-label":t,title:t,onClick:o},n?Yl("icon-2xs"):$s("icon-2xs"))}function v7({copy:t,sentAtMs:e,text:n}){"use no memo";let[l,i]=(0,E.useState)(!1),r=(0,E.useRef)(null),o=t?.copyUserMessage??"",a=t?.copiedUserMessage??"",s=l?a:o,u=e==null?null:b3(e),c=(0,E.useCallback)(d=>{d!==null||r.current===null||(window.clearTimeout(r.current),r.current=null)},[]),f=(0,E.useCallback)(()=>{let d=n.trim();if(!d)return;navigator.clipboard?.writeText(d)?.catch(()=>{}),i(!0),r.current!==null&&window.clearTimeout(r.current),r.current=window.setTimeout(()=>{i(!1),r.current=null},1500)},[n]);return m("div",{className:"codex-user-message-actions flex flex-row-reverse items-center gap-1"},m("div",{className:"mr-1 ms-1 flex items-center gap-2 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100"},u==null?null:m("span",{className:"text-xs text-token-text-tertiary"},u),m("button",{ref:c,type:"button",className:`codex-user-message-action-button ${Qe} ${Je} ${eo} rounded-full electron:rounded-md`,"aria-label":s,title:s,onClick:f},l?Yl("icon-xs"):$s("icon-xs"))))}function C7({copy:t,sentAtMs:e,text:n}){"use no memo";let[l,i]=(0,E.useState)(!1),r=(0,E.useRef)(null),o=t?.copyAssistantMessage??"",a=t?.copiedAssistantMessage??o,s=l?a:o,u=e==null?null:b3(e),c=(0,E.useCallback)(d=>{d!==null||r.current===null||(window.clearTimeout(r.current),r.current=null)},[]),f=(0,E.useCallback)(()=>{let d=n.trim();if(!d)return;navigator.clipboard?.writeText(d)?.catch(()=>{}),i(!0),r.current!==null&&window.clearTimeout(r.current),r.current=window.setTimeout(()=>{i(!1),r.current=null},2e3)},[n]);return m("div",{className:"codex-assistant-message-actions mt-1.5 flex h-5 items-center justify-start gap-0.5 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100"},m("button",{ref:c,type:"button",className:`codex-assistant-message-action-button ${Qe} ${Je} ${eo} rounded-full electron:rounded-md`,"aria-label":s,title:s,onClick:f},l?Yl("icon-xs"):$s("icon-xs")),u==null?null:m("span",{className:"ml-1.5 flex h-full items-center text-size-chat leading-5 text-token-input-placeholder-foreground"},u))}var M7=7;function b3(t,e=new Date){let n=new Date(t),l=T7(n,e);return l===0?new Intl.DateTimeFormat(void 0,{hour:"numeric",minute:"2-digit"}).format(n):l<0&&l>-M7?new Intl.DateTimeFormat(void 0,{hour:"numeric",minute:"2-digit",weekday:"long"}).format(n):new Intl.DateTimeFormat(void 0,{day:"numeric",hour:"numeric",minute:"2-digit",month:"short"}).format(n)}function T7(t,e){let n=new Date(t.getFullYear(),t.getMonth(),t.getDate()),l=new Date(e.getFullYear(),e.getMonth(),e.getDate());return Math.round((n.getTime()-l.getTime())/864e5)}function E7({copy:t,text:e}){"use no memo";let[n,l]=(0,E.useState)(!1),i=N7(e),r=i&&!n,o=t?.showMore??"",a=t?.showLess??"",s=(0,E.useCallback)(()=>{l(u=>!u)},[]);return m("div",{className:"flex flex-col items-end gap-1"},m("div",{className:"codex-user-message-content text-size-chat relative w-full min-w-0 mb-px","data-collapsed":r?"true":void 0,dangerouslySetInnerHTML:{__html:Pn(e)}}),i?m("button",{type:"button","aria-expanded":n,className:"codex-user-message-toggle text-size-chat mt-1.5 inline-flex cursor-interaction items-center gap-1 self-start text-token-description-foreground hover:text-token-foreground",onClick:s},m("span",null,n?a:o),Ps(`icon-2xs${n?" rotate-180":""}`)):null)}function N7(t){return t.split(/\r\n|\r|\n/).length>i7||t.length>1600}function O7({attachments:t}){return m("div",{className:"codex-user-message-attachments flex max-w-[77%] flex-row-reverse flex-wrap items-center gap-1"},t.map(e=>m(A7,{attachment:e,key:e.id})))}function A7({attachment:t}){return t.kind==="image"&&t.dataUrl?m("div",{className:"user-message-image-attachment flex size-16 items-center justify-center rounded-lg border border-token-border",title:t.label},m("img",{alt:t.label,className:"h-full w-full rounded-md object-cover",referrerPolicy:"no-referrer",src:t.dataUrl})):m("div",{className:"user-message-file-attachment bg-token-dropdown-background border-token-border inline-flex max-w-[320px] items-center gap-1 rounded-full border px-2 py-1.5 text-sm",title:t.path},m("span",{className:"text-token-input-placeholder-foreground flex-shrink-0","aria-hidden":!0},uh("icon-2xs")),m("span",{className:"flex max-w-full min-w-0 items-center gap-1"},m("span",{className:"truncate"},t.label)))}function D7({attachments:t,className:e,copy:n,onRemove:l}){return m("div",{className:`codex-attachment-tray w-full overflow-x-auto${e?` ${e}`:""}`,"data-composer-attachments-row":!0},m("div",{className:"codex-attachment-row flex min-w-max items-end gap-2"},t.map(i=>m(k7,{attachment:i,copy:n,key:i.id,onRemove:l}))))}function k7({attachment:t,copy:e,onRemove:n}){let l=`${e?.removeAttachment??"Remove attachment"} ${t.label}`,i=n?m("button",{className:"composer-attachment-remove",type:"button","aria-label":l,onClick:()=>n(t.id),onMouseDown:r=>r.preventDefault()},eu("icon-2xs")):null;return t.kind==="image"&&t.dataUrl?m("div",{className:"composer-attachment-image",title:t.label},m("img",{alt:"","aria-hidden":!0,src:t.dataUrl}),i):m("div",{className:"composer-attachment-file",title:t.path},m("span",{className:"composer-attachment-file-icon","aria-hidden":!0},uh("icon-xs")),m("span",{className:"composer-attachment-file-label"},t.label),i)}function w7({state:t,providerDisplayName:e}){let l=(t.context?.rateLimitRows??[]).map(Rg),i=t.context?.copy,[r,o]=(0,E.useState)(!1);if(l.length===0)return null;let a=i?.rateLimits??"Rate limits";return m("div",{className:"rate-line codex-rate-limit-summary relative",role:"status","aria-label":`${e} ${Qg(t)}`.trim(),onBlur:s=>{s.currentTarget.contains(s.relatedTarget)||o(!1)}},m("button",{className:"rate-limit-trigger rate-limit-trigger-inline flex min-w-0 items-center gap-1",type:"button","aria-expanded":r,onClick:()=>o(s=>!s)},m("span",{className:"rate-line-heading"},a),l.flatMap((s,u)=>[u>0?m("span",{key:`${s.role}-separator`,className:"rate-limit-inline-separator","aria-hidden":!0},"\u2022"):null,m(z7,{key:s.role,row:s,state:t})])),r?m("div",{className:"rate-limit-popover absolute bottom-[calc(100%+6px)] left-0 z-50 flex min-w-56 flex-col gap-1 rounded-xl border border-token-border bg-token-dropdown-background/95 px-3 py-2 text-sm shadow-xl-spread backdrop-blur-sm"},m("div",{className:"rate-limit-popover-title"},a),l.map(s=>m(R7,{key:s.role,row:s,state:t}))):null)}function z7({row:t,state:e}){let n=t,l=e.context?.copy,i=n.role==="primary"?l?.rateLimitPrimary??"Primary":l?.rateLimitSecondary??"Secondary",r=$f(n.windowDurationMins,i,{weekly:l?.rateLimitWeekly??"Weekly",monthly:l?.rateLimitMonthly??"Monthly",daysFormat:l?.rateLimitDaysFormat??"",hoursFormat:l?.rateLimitHoursFormat??"",minutesFormat:l?.rateLimitMinutesFormat??""}),o=Pf(n.resetsAt);return m("span",{className:"rate-limit-inline-segment"},m("span",{className:"rate-limit-window"},r),m("span",{className:"rate-limit-percent"},Wf(n.remainingPercent)),o?m("span",{className:"rate-limit-reset"},`${l?.rateLimitResets??"resets"} ${o}`):null)}function R7({row:t,state:e}){let n=t,l=e.context?.copy,i=n.role==="primary"?l?.rateLimitPrimary??"Primary":l?.rateLimitSecondary??"Secondary",r=$f(n.windowDurationMins,i,{weekly:l?.rateLimitWeekly??"Weekly",monthly:l?.rateLimitMonthly??"Monthly",daysFormat:l?.rateLimitDaysFormat??"",hoursFormat:l?.rateLimitHoursFormat??"",minutesFormat:l?.rateLimitMinutesFormat??""}),o=Pf(n.resetsAt);return m("div",{className:"rate-limit-popover-row"},m("span",{className:"rate-limit-window"},r),m("span",{className:"rate-limit-row-value"},m("span",{className:"rate-limit-percent"},Wf(n.remainingPercent)),o?m("span",{className:"rate-limit-reset"},`${l?.rateLimitResets??"resets"} ${o}`):null))}function L7({emptyLabel:t,highlightedIndex:e,items:n,onChoose:l,onHighlight:i,query:r}){return m("div",{className:"codex-top-tray-shell absolute z-20"},m("div",{className:"codex-top-tray-panel","cmdk-root":"","data-cmdk-root":!0},m("div",{className:"codex-top-tray-list","cmdk-list":"","data-cmdk-list":!0},n.length===0?m("div",{className:"codex-top-tray-empty","cmdk-empty":"","data-cmdk-empty":!0,"data-command-menu-empty-state":"true"},t):n.map((o,a)=>{let s=o.detail?"codex-top-tray-label max-w-[60%] flex-none truncate":"codex-top-tray-label min-w-0 flex-1 truncate",u=P7(o.label,r),c=u.some(f=>!f.isMatch);return m("button",{key:o.id,className:"codex-top-tray-item",type:"button","aria-selected":a===e?"true":void 0,"cmdk-item":"","data-selected":a===e?"true":void 0,"data-list-navigation-item":!0,onMouseEnter:()=>i(a),onMouseDown:f=>f.preventDefault(),onClick:()=>l(o)},m("div",{className:"codex-top-tray-copy flex w-full items-center gap-2"},m("span",{className:"codex-top-tray-icon icon-xs shrink-0","aria-hidden":!0},o.icon),m("div",{className:s},u.map((f,d)=>m("span",{key:`${f.text}-${d}`,className:!f.isMatch&&c?"text-token-description-foreground":void 0},f.text))),o.detail?m("span",{className:"codex-top-tray-detail min-w-0 flex-1 truncate text-sm text-token-description-foreground"},o.detail):null))}))))}function d3({icon:t,label:e,onClear:n}){return m("div",{className:"composer-mode-indicator flex min-w-0 items-center gap-1"},m("div",{className:"composer-mode-divider","aria-hidden":!0}),m("button",{className:`composer-mode-button group ${Qe} ${Je} ${mn} rounded-full`,type:"button","aria-label":e,onClick:n},m("span",{className:"composer-mode-icon composer-mode-icon-default icon-xs shrink-0","aria-hidden":!0},t),m("span",{className:"composer-mode-icon composer-mode-icon-hover icon-xs shrink-0","aria-hidden":!0},eu("icon-xs")),m("span",{className:"composer-footer__label--sm composer-mode-label max-w-16 truncate"},e)))}function _7({hideLabel:t,label:e,onClear:n}){return m("button",{className:`composer-context-button group ${Qe} ${Je} ${mn} min-w-0 rounded-full`,type:"button","aria-label":e,title:e,onClick:n},m("span",{className:"composer-context-icon composer-context-icon-default icon-xs shrink-0","aria-hidden":!0},v3("icon-xs")),m("span",{className:"composer-context-icon composer-context-icon-hover icon-xs shrink-0","aria-hidden":!0},eu("icon-xs")),t?null:m("span",{className:"composer-footer__label--sm composer-context-label max-w-20 truncate"},e))}function B7({copy:t,hideLabel:e=!1,isEnabled:n,isOpen:l,mode:i,onModeChange:r,onOpenChange:o}){let a=t?.changePermissions??"Change permissions",s=g3(t,i),u=n?["default","auto-review","full-access","custom"]:[],c=e?hg:mn,f=h=>{r(h),o(!1)},d=n&&l;return m("div",{className:"permissions-root relative inline-flex",onBlur:h=>{n&&!h.currentTarget.contains(h.relatedTarget)&&o(!1)}},m("button",{className:`permissions-trigger ${Qe} ${Je} ${c} ${e?to:"min-w-0"} rounded-full`,type:"button","aria-label":a,"aria-haspopup":n?"menu":void 0,"aria-expanded":n?d:void 0,"aria-disabled":n?void 0:!0,"data-permission-mode":i,"data-state":d?"open":"closed",disabled:!n,onClick:()=>{n&&o(!d)},onKeyDown:h=>{n&&(h.key==="ArrowDown"&&(h.preventDefault(),o(!0)),h.key==="Escape"&&(h.preventDefault(),o(!1)))}},x3(i,"icon-xs shrink-0"),e?null:m("span",{className:"permissions-trigger-label composer-footer__label--xs max-w-40 truncate whitespace-nowrap text-left"},s),n?m("span",{className:"permissions-trigger-chevron icon-2xs shrink-0","aria-hidden":!0},Ps()):null),d?m("div",{className:"permissions-dropdown _content_1hiti_1 no-drag bg-token-dropdown-background/90 text-token-foreground ring-token-border z-50 m-px flex select-none flex-col overflow-y-auto rounded-xl ring-[0.5px] px-1 py-1 shadow-xl-spread backdrop-blur-sm",role:"menu","aria-label":a,"data-state":"open","data-side":"top",style:{"--radix-dropdown-menu-content-transform-origin":"left bottom"}},u.map(h=>m(H7,{isSelected:h===i,key:h,label:g3(t,h),mode:h,onSelect:()=>f(h)}))):null)}function H7({isSelected:t,label:e,mode:n,onSelect:l}){return m("button",{className:"permissions-item no-drag group hover:bg-token-list-hover-background focus:bg-token-list-hover-background cursor-interaction text-token-foreground outline-hidden rounded-lg px-[var(--padding-row-x)] py-[var(--padding-row-y)] text-sm",type:"button",role:"menuitemradio","aria-checked":t,"data-permission-mode":n,"data-selected":t?"true":void 0,onMouseDown:i=>i.preventDefault(),onClick:l},m("span",{className:"permissions-item-icon icon-xs shrink-0","aria-hidden":!0},x3(n,"icon-xs shrink-0")),m("span",{className:"permissions-item-label min-w-0 flex-1 truncate"},e),t?m("span",{className:"permissions-item-check icon-xs shrink-0","aria-hidden":!0},Yl()):null)}function U7({hasIdeContext:t,isAutoContextOn:e,isOpen:n,isPickingFiles:l,isPlanMode:i,onOpenChange:r,onPickFiles:o,onToggleAutoContext:a,onTogglePlanMode:s,state:u}){let c=u.context?.copy,f=c?.addFilesAndMore??"Add files and more",d=c?.addPhotosAndFiles??"Add photos & files",p=sh("skill",u,"").find(b=>b.id==="plan")??null;return m("div",{className:"add-context-root relative inline-flex",onBlur:b=>{b.currentTarget.contains(b.relatedTarget)||r(!1)}},m("button",{className:`codex-tool codex-tool-plus ${Qe} ${Je} ${mn} ${to} rounded-full`,type:"button","aria-label":f,"aria-haspopup":"menu","aria-expanded":n,"data-state":n?"open":"closed",onClick:()=>r(!n),onKeyDown:b=>{b.key==="ArrowDown"&&(b.preventDefault(),r(!0)),b.key==="Escape"&&(b.preventDefault(),r(!1))}},r9()),n?m("div",{className:"add-context-dropdown _content_1hiti_1 no-drag bg-token-dropdown-background/90 text-token-foreground ring-token-border z-50 m-px flex select-none flex-col overflow-y-auto rounded-xl ring-[0.5px] px-1 py-1 shadow-xl-spread backdrop-blur-sm",role:"menu","aria-label":f,"data-state":"open","data-side":"top",style:{"--radix-dropdown-menu-content-transform-origin":"left bottom"}},m(V7,{disabled:l,icon:uh("icon-xs"),label:d,onSelect:o}),t||p?m(q7):null,t?m(h3,{checked:e,icon:v3("icon-sm"),label:c?.includeIdeContext??"Include IDE context",onSelect:a}):null,p?m(h3,{checked:i,icon:Ws(),label:c?.planMode??"Plan mode",onSelect:s}):null):null)}function V7({detail:t,disabled:e=!1,icon:n,label:l,onSelect:i}){return m("button",{className:"add-context-item group no-drag hover:bg-token-list-hover-background focus:bg-token-list-hover-background cursor-interaction text-token-foreground outline-hidden rounded-lg px-[var(--padding-row-x)] py-[var(--padding-row-y)] text-sm",disabled:e,type:"button",role:"menuitem",onMouseDown:r=>r.preventDefault(),onClick:e?void 0:i},m("span",{className:"add-context-item-icon icon-xs shrink-0","aria-hidden":!0},n),m("span",{className:"add-context-item-copy flex min-w-0 flex-1 items-center gap-2"},m("span",{className:"add-context-item-label min-w-0 truncate"},l),t?m("span",{className:"add-context-item-detail min-w-0 flex-1 truncate text-token-description-foreground"},t):null))}function h3({checked:t,icon:e,label:n,onSelect:l}){let i=t?"checked":"unchecked",r=t?"relative inline-flex h-4 w-7 shrink-0 items-center rounded-full bg-token-charts-blue transition-colors duration-200 ease-out":"relative inline-flex h-4 w-7 shrink-0 items-center rounded-full bg-token-foreground/10 transition-colors duration-200 ease-out";return m("button",{className:"add-context-item group no-drag hover:bg-token-list-hover-background focus:bg-token-list-hover-background cursor-interaction text-token-foreground outline-hidden rounded-lg px-[var(--padding-row-x)] py-[var(--padding-row-y)] text-sm",type:"button",role:"menuitemcheckbox","aria-checked":t,onMouseDown:o=>o.preventDefault(),onClick:l},m("span",{className:"add-context-item-icon icon-xs shrink-0","aria-hidden":!0},e),m("span",{className:"add-context-switch-row flex w-full min-w-0 items-center justify-between gap-2"},m("span",{className:"flex min-w-0 items-center gap-2"},m("span",{className:"add-context-item-label min-w-0 truncate"},n)),m("span",{className:r,"data-state":i,"aria-hidden":!0},m("span",{className:"h-3 w-3 rounded-full border border-[color:var(--gray-0)] bg-[color:var(--gray-0)] shadow-sm transition-transform duration-200 ease-out data-[state=unchecked]:translate-x-[2px] data-[state=checked]:translate-x-[14px]","data-state":i}))))}function q7(){return m("div",{className:"add-context-separator w-full px-[var(--padding-row-x)] py-1",role:"separator"},m("div",{className:"add-context-separator-line h-[1px] w-full bg-token-menu-border"}))}function Y7(t){return sh("mention",t,"").find(e=>e.id==="workspace")??null}function sh(t,e,n){let l=e.context?.copy,i=t==="mention"?[...e.context?.workingDirectory?[{id:"workspace",icon:o9("icon-xs"),label:l?.mentionCurrentWorkspace??"Current workspace",detail:Fs(e.context.workingDirectory),mention:{kind:"at",label:Fs(e.context.workingDirectory),name:Fs(e.context.workingDirectory),path:e.context.workingDirectory,fsPath:e.context.workingDirectory}}]:[],...e.providers.map(r=>({id:r.id,icon:wg(r),label:r.displayName,detail:r.executableName,mention:{kind:"agent",label:r.displayName,name:r.id,displayName:r.displayName,path:`provider://${r.id}`,description:r.executableName}}))]:[{id:"plan",icon:"$",label:l?.skillPlan??"Plan",detail:"$plan",mention:{kind:"skill",label:"Plan",name:"plan",displayName:"Plan",path:"skill://plan"}},{id:"review",icon:"$",label:l?.skillCodeReview??"Code review",detail:"$codex-review",mention:{kind:"skill",label:"Code review",name:"codex-review",displayName:"Code review",path:"skill://codex-review"}},{id:"research",icon:"$",label:l?.skillResearch??"Research",detail:"$research",mention:{kind:"skill",label:"Research",name:"research",displayName:"Research",path:"skill://research"}}];return Z7(i,n)}function Z7(t,e){let n=e.trim();return n?t.map(l=>({item:l,score:W7(l.label,n,[l.detail,l.mention.name])})).filter(({score:l})=>l>0).sort((l,i)=>i.score-l.score).map(({item:l})=>l):t}var p3=1,j7=.9,G7=.8,X7=.17,rh=.1,oh=.999,K7=.9999,I7=.99,Q7=/[\\/_+.#"@[({&]/,J7=/[\\/_+.#"@[({&]/g,F7=/[\s-]/,S3=/[\s-]/g;function W7(t,e,n){let l=n.length>0?`${t} ${n.join(" ")}`:t;return ah(l,e,m3(l),m3(e),0,0,{})}function m3(t){return t.toLowerCase().replace(S3," ")}function ah(t,e,n,l,i,r,o){if(r===e.length)return i===t.length?p3:I7;let a=`${i},${r}`;if(o[a]!==void 0)return o[a];let s=l.charAt(r),u=n.indexOf(s,i),c=0;for(;u>=0;){let f=ah(t,e,n,l,u+1,r+1,o);if(f>c){if(u===i)f*=p3;else if(Q7.test(t.charAt(u-1))){f*=G7;let d=t.slice(i,u-1).match(J7);d&&i>0&&(f*=oh**d.length)}else if(F7.test(t.charAt(u-1))){f*=j7;let d=t.slice(i,u-1).match(S3);d&&i>0&&(f*=oh**d.length)}else f*=X7,i>0&&(f*=oh**(u-i));t.charAt(u)!==e.charAt(r)&&(f*=K7)}if(ff&&(f=d*rh)}f>c&&(c=f),u=n.indexOf(s,u+1)}return o[a]=c,c}function P7(t,e){let n=Array.from(t),l=e.trim();if(!l)return n.map(s=>({isMatch:!0,text:s}));let i=n.map(s=>s.toLowerCase()),r=Array.from(l.toLowerCase()),o=-1;for(let s=0;s<=i.length-r.length;s+=1)if(r.every((u,c)=>i[s+c]===u)){o=s;break}if(o>=0){let s=o+r.length;return n.map((u,c)=>({isMatch:c>=o&&c{let u=a{let l=`${n.kind}:${n.path}`;return e.has(l)?!1:(e.add(l),!0)})}function e9(t="icon-sm"){return m("svg",{className:t,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},m("path",{d:"M9.33467 16.6663V4.93978L4.6374 9.63704L4.1667 9.16634L3.69599 8.69661L9.52998 2.86263L9.63447 2.77767C9.8925 2.60753 10.2433 2.63564 10.4704 2.86263L16.3034 8.69661L16.3884 8.80111C16.5588 9.05922 16.5306 9.40982 16.3034 9.63704C16.0762 9.86414 15.7255 9.89242 15.4675 9.722L15.363 9.63704L10.6647 4.9388V16.6663C10.6647 17.0336 10.367 17.3314 9.99971 17.3314C9.63259 17.3312 9.33467 17.0335 9.33467 16.6663ZM4.6374 9.63704C4.3777 9.89674 3.95569 9.89674 3.69599 9.63704C3.43657 9.37744 3.43668 8.95628 3.69599 8.69661L4.6374 9.63704Z",fill:"currentColor"}))}function t9(){return m("svg",{width:"16",height:"16",viewBox:"0 0 16 16",fill:"none","aria-hidden":!0},m("rect",{x:"4.75",y:"4.75",width:"6.5",height:"6.5",rx:"1",fill:"currentColor"}))}function n9(){return m("svg",{width:"16",height:"16",viewBox:"0 0 16 16",fill:"none","aria-hidden":!0},m("path",{d:"M8 2.25a2 2 0 0 0-2 2v3.25a2 2 0 1 0 4 0V4.25a2 2 0 0 0-2-2ZM4 7.5a4 4 0 0 0 8 0M8 11.5v2.25",stroke:"currentColor",strokeWidth:"1.4",strokeLinecap:"round"}))}function l9(t="icon-sm"){return m("svg",{className:t,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},m("path",{d:"M10 5.14295V14.8441M10 5.14295C10 1.98209 14.8475 1.94468 14.8475 5.07797L14.8473 5.1067C17.1367 5.43539 18.0436 7.54498 16.4225 9.14006C17.8633 10.5577 17.3503 13.9054 15.4449 14.6301C14.8475 17.6527 10.8056 18.4821 10 14.8441M10 5.14295C10 1.98209 5.15249 1.94468 5.15249 5.07797L5.15265 5.1067C2.86325 5.43539 1.95642 7.54498 3.57746 9.14006C2.13674 10.5577 2.64974 13.9054 4.5551 14.6301C5.15249 17.6527 9.19444 18.4821 10 14.8441",stroke:"currentColor",strokeLinejoin:"round",strokeWidth:"1.33"}))}function Ps(t="icon-2xs"){return m("svg",{className:t,width:"20",height:"21",viewBox:"0 0 20 21",fill:"none","aria-hidden":!0},m("path",{d:"M15.2793 7.71101C15.539 7.45131 15.961 7.45131 16.2207 7.71101C16.4804 7.97071 16.4804 8.39272 16.2207 8.65242L10.4707 14.4024C10.211 14.6621 9.78902 14.6621 9.52932 14.4024L3.77932 8.65242L3.69436 8.54792C3.52385 8.28979 3.55205 7.93828 3.77932 7.71101C4.00659 7.48374 4.3581 7.45554 4.61623 7.62605L4.72073 7.71101L10 12.9903L15.2793 7.71101Z",fill:"currentColor",stroke:"currentColor",strokeWidth:"0.6"}))}function i9(t="icon-2xs"){return m("svg",{className:t,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},m("path",{d:"M7.52925 3.7793C7.75652 3.55203 8.10803 3.52383 8.36616 3.69434L8.47065 3.7793L14.2207 9.5293C14.4804 9.789 14.4804 10.211 14.2207 10.4707L8.47065 16.2207C8.21095 16.4804 7.78895 16.4804 7.52925 16.2207C7.26955 15.961 7.26955 15.539 7.52925 15.2793L12.8085 10L7.52925 4.7207L7.44429 4.61621C7.27378 4.35808 7.30198 4.00657 7.52925 3.7793Z",fill:"currentColor"}))}function $s(t="icon-sm"){return m("svg",{className:t,width:"21",height:"21",viewBox:"0 0 21 21",fill:"none","aria-hidden":!0},m("path",{d:"M13.468 11.1216C13.468 10.4107 13.468 9.91717 13.4367 9.53369C13.4137 9.25191 13.3758 9.0622 13.3244 8.91846L13.2687 8.78858C13.1148 8.48652 12.8803 8.23344 12.593 8.05713L12.466 7.98584C12.308 7.90546 12.0963 7.84854 11.7209 7.81787C11.3374 7.78656 10.8439 7.78662 10.133 7.78662H7.29999C6.58895 7.78662 6.09562 7.78654 5.7121 7.81787C5.43015 7.84091 5.24064 7.87872 5.09686 7.93018L4.96698 7.98584C4.66487 8.13977 4.41184 8.37419 4.23554 8.66162L4.16522 8.78858C4.08477 8.94657 4.02794 9.15811 3.99725 9.53369C3.96594 9.91718 3.96503 10.4107 3.96503 11.1216V13.9546C3.96503 14.6656 3.96592 15.159 3.99725 15.5425C4.02796 15.9182 4.08471 16.1296 4.16522 16.2876L4.23554 16.4136C4.41185 16.7012 4.66472 16.9353 4.96698 17.0894L5.09686 17.146C5.24061 17.1974 5.43024 17.2343 5.7121 17.2573C6.09562 17.2887 6.58895 17.2896 7.29999 17.2896H10.133C10.8439 17.2896 11.3374 17.2886 11.7209 17.2573C12.0965 17.2266 12.308 17.1698 12.466 17.0894L12.593 17.019C12.8804 16.8427 13.1148 16.5897 13.2687 16.2876L13.3244 16.1577C13.3759 16.0139 13.4137 15.8244 13.4367 15.5425C13.468 15.159 13.468 14.6656 13.468 13.9546V11.1216ZM14.798 13.1196C15.2528 13.118 15.6011 13.1147 15.8879 13.0913C16.2634 13.0606 16.475 13.0038 16.633 12.9233L16.759 12.8521C17.0466 12.6757 17.2808 12.4228 17.4348 12.1206L17.4914 11.9907C17.5428 11.847 17.5797 11.6572 17.6027 11.3755C17.634 10.992 17.6349 10.4985 17.6349 9.7876V6.95459C17.6349 6.24355 17.6341 5.75022 17.6027 5.3667C17.5797 5.08484 17.5428 4.89522 17.4914 4.75147L17.4348 4.62158C17.2807 4.31933 17.0466 4.06645 16.759 3.89014L16.633 3.81982C16.475 3.73932 16.2636 3.68256 15.8879 3.65186C15.5044 3.62052 15.011 3.61963 14.3 3.61963H11.467C10.7561 3.61963 10.2626 3.62054 9.87909 3.65186C9.59738 3.67487 9.40759 3.71179 9.26386 3.76318L9.13397 3.81982C8.83175 3.97382 8.57885 4.20802 8.40253 4.49561L8.33124 4.62158C8.25079 4.77957 8.19396 4.99114 8.16327 5.3667C8.13984 5.65352 8.13561 6.00178 8.13397 6.45654H10.133C10.822 6.45654 11.3791 6.4559 11.8293 6.49268C12.2873 6.5301 12.6937 6.6093 13.0705 6.80127L13.2883 6.92334C13.7839 7.22739 14.1878 7.66313 14.4533 8.18408L14.5197 8.32666C14.6642 8.66318 14.7291 9.02433 14.7619 9.42529C14.7987 9.8755 14.798 10.4326 14.798 11.1216V13.1196ZM18.965 9.7876C18.965 10.4766 18.9657 11.0337 18.9289 11.4839C18.8961 11.8848 18.8311 12.246 18.6867 12.5825L18.6203 12.7251C18.3548 13.246 17.9509 13.6818 17.4553 13.9858L17.2365 14.1079C16.8599 14.2998 16.4541 14.3791 15.9963 14.4165C15.6592 14.444 15.2624 14.4481 14.7951 14.4497C14.7935 14.917 14.7894 15.3138 14.7619 15.6509C14.7292 16.0516 14.664 16.4122 14.5197 16.7485L14.4533 16.8911C14.1878 17.4122 13.7841 17.8487 13.2883 18.1528L13.0705 18.2749C12.6937 18.4669 12.2873 18.5461 11.8293 18.5835C11.3791 18.6203 10.822 18.6196 10.133 18.6196H7.29999C6.6109 18.6196 6.05394 18.6203 5.6037 18.5835C5.20305 18.5508 4.84233 18.4855 4.50604 18.3413L4.36347 18.2749C3.84243 18.0094 3.40584 17.6056 3.10175 17.1099L2.97968 16.8911C2.78787 16.5145 2.70849 16.1087 2.67108 15.6509C2.6343 15.2006 2.63495 14.6437 2.63495 13.9546V11.1216C2.63495 10.4326 2.63431 9.8755 2.67108 9.42529C2.7085 8.96729 2.78771 8.56084 2.97968 8.18408L3.10175 7.96631C3.40585 7.47049 3.84235 7.06679 4.36347 6.80127L4.50604 6.73486C4.84236 6.59059 5.20302 6.52542 5.6037 6.49268C5.9405 6.46516 6.33707 6.4601 6.80389 6.4585C6.8055 5.99167 6.81056 5.5951 6.83807 5.2583C6.87549 4.80047 6.95482 4.39471 7.14667 4.01807L7.26874 3.79932C7.5728 3.30371 8.00855 2.89973 8.52948 2.63428L8.67206 2.56787C9.00854 2.42345 9.36978 2.35844 9.77069 2.32568C10.2209 2.28891 10.778 2.28955 11.467 2.28955H14.3C14.9891 2.28955 15.546 2.2889 15.9963 2.32568C16.4541 2.3631 16.8599 2.44247 17.2365 2.63428L17.4553 2.75635C17.951 3.06044 18.3548 3.49703 18.6203 4.01807L18.6867 4.16065C18.8309 4.49694 18.8962 4.85765 18.9289 5.2583C18.9657 5.70854 18.965 6.2655 18.965 6.95459V9.7876Z",fill:"currentColor"}))}function Yl(t){return m("svg",{className:t,width:"17",height:"17",viewBox:"0 0 17 17",fill:"none","aria-hidden":!0},m("path",{d:"M12.8961 3.64101C13.1297 3.41418 13.4984 3.37523 13.7779 3.56581C14.0571 3.75635 14.1554 4.11331 14.0299 4.41347L13.9615 4.53847L7.71151 13.7045C7.59411 13.8767 7.4063 13.9877 7.19881 14.0072C6.99136 14.0267 6.78564 13.9533 6.63826 13.806L2.88826 10.056L2.79842 9.9457C2.6192 9.67407 2.64927 9.30496 2.88826 9.06581C3.12738 8.82669 3.49647 8.79676 3.76815 8.97597L3.8785 9.06581L7.03084 12.2182L12.8053 3.74941L12.8961 3.64101Z",fill:"currentColor"}))}function eu(t="icon-sm"){return m("svg",{className:t,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},m("path",{d:"M5.96967 5.96967C6.26256 5.67678 6.73744 5.67678 7.03033 5.96967L10 8.93934L12.9697 5.96967C13.2626 5.67678 13.7374 5.67678 14.0303 5.96967C14.3232 6.26256 14.3232 6.73744 14.0303 7.03033L11.0607 10L14.0303 12.9697C14.3232 13.2626 14.3232 13.7374 14.0303 14.0303C13.7374 14.3232 13.2626 14.3232 12.9697 14.0303L10 11.0607L7.03033 14.0303C6.73744 14.3232 6.26256 14.3232 5.96967 14.0303C5.67678 13.7374 5.67678 13.2626 5.96967 12.9697L8.93934 10L5.96967 7.03033C5.67678 6.73744 5.67678 6.26256 5.96967 5.96967Z",fill:"currentColor"}))}function r9(){return m("svg",{className:"icon-sm",width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},m("path",{d:"M9.33496 16.5V10.665H3.5C3.13273 10.665 2.83496 10.3673 2.83496 10C2.83496 9.63273 3.13273 9.33496 3.5 9.33496H9.33496V3.5C9.33496 3.13273 9.63273 2.83496 10 2.83496C10.3673 2.83496 10.665 3.13273 10.665 3.5V9.33496H16.5L16.6338 9.34863C16.9369 9.41057 17.165 9.67857 17.165 10C17.165 10.3214 16.9369 10.5894 16.6338 10.6514L16.5 10.665H10.665V16.5C10.665 16.8673 10.3673 17.165 10 17.165C9.63273 17.165 9.33496 16.8673 9.33496 16.5Z",fill:"currentColor"}))}function uh(t="icon-sm"){return m("svg",{className:t,width:"21",height:"21",viewBox:"0 0 21 21",fill:"none","aria-hidden":!0},m("path",{d:"M4.43945 12.8041V7.68261C4.43945 7.30642 4.74446 7.00141 5.12066 7.00141C5.49685 7.00141 5.80186 7.30642 5.80186 7.68261V12.8041C5.80186 15.2565 7.78984 17.2445 10.2422 17.2445C12.6945 17.2445 14.6825 15.2565 14.6825 12.8041V5.9751C14.6823 4.46587 13.4589 3.24247 11.9497 3.24229C10.4403 3.24229 9.21606 4.46576 9.21588 5.9751V12.8041C9.21588 13.3708 9.67553 13.8304 10.2422 13.8304C10.8088 13.8304 11.2685 13.3708 11.2685 12.8041V7.68261C11.2685 7.30642 11.5735 7.00141 11.9497 7.00141C12.3257 7.00159 12.6309 7.30653 12.6309 7.68261V12.8041C12.6309 14.1232 11.5612 15.1929 10.2422 15.1929C8.92314 15.1929 7.85347 14.1232 7.85347 12.8041V5.9751C7.85365 3.71337 9.68791 1.87988 11.9497 1.87988C14.2113 1.88006 16.0447 3.71348 16.0449 5.9751V12.8041C16.0449 16.0089 13.4469 18.6069 10.2422 18.6069C7.03745 18.6069 4.43945 16.0089 4.43945 12.8041Z",fill:"currentColor"}))}function o9(t="icon-sm"){return m("svg",{className:t,width:20,height:20,viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},m("path",{d:rs,fill:"currentColor"}))}function Ws(t="icon-sm"){return m("svg",{className:t,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},m("path",{fillRule:"evenodd",clipRule:"evenodd",d:"M5.69336 11.0557C7.05891 11.1944 8.12484 12.3479 8.125 13.75L8.11035 14.0273C7.97144 15.3928 6.81814 16.459 5.41602 16.459L5.13965 16.4443C3.86514 16.3149 2.85128 15.3018 2.72168 14.0273L2.70801 13.75C2.70818 12.2546 3.92061 11.0423 5.41602 11.042L5.69336 11.0557ZM5.41602 12.3721C4.65515 12.3724 4.03826 12.9891 4.03809 13.75C4.03826 14.5109 4.65515 15.1286 5.41602 15.1289C6.17714 15.1289 6.79475 14.5111 6.79492 13.75C6.79475 12.9889 6.17714 12.3721 5.41602 12.3721Z",fill:"currentColor"}),m("path",{d:"M16.8008 13.0986C17.1036 13.1608 17.3311 13.4288 17.3311 13.75C17.3311 14.0712 17.1036 14.3392 16.8008 14.4014L16.666 14.415H10.833C10.4659 14.4149 10.168 14.1172 10.168 13.75C10.168 13.3828 10.4659 13.0851 10.833 13.085H16.666L16.8008 13.0986Z",fill:"currentColor"}),m("path",{d:"M16.8008 5.59863C17.1036 5.66081 17.3311 5.92879 17.3311 6.25C17.3311 6.57121 17.1036 6.83919 16.8008 6.90137L16.666 6.91504H10.833C10.4659 6.91491 10.168 6.61719 10.168 6.25C10.168 5.88281 10.4659 5.58509 10.833 5.58496H16.666L16.8008 5.59863Z",fill:"currentColor"}),m("path",{d:"M7.13311 3.76578C7.35346 3.47216 7.771 3.4128 8.06475 3.63297C8.35843 3.85336 8.41789 4.27084 8.19757 4.56461L5.19757 8.56461C5.0819 8.71866 4.90439 8.81462 4.71221 8.82828C4.5201 8.84178 4.33083 8.77209 4.19464 8.6359L2.69464 7.1359C2.43512 6.87623 2.43512 6.45415 2.69464 6.19449C2.95429 5.93484 3.37633 5.93493 3.63604 6.19449L4.59307 7.15152L7.13311 3.76578Z",fill:"currentColor"}))}function a9(t="icon-sm"){return m("svg",{className:t,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},m("path",{d:"M12.6683 4.16699C12.6683 3.84391 12.4065 3.58203 12.0834 3.58203C11.7603 3.58203 11.4984 3.84391 11.4984 4.16699V7.91699L11.4847 8.05078C11.4227 8.35375 11.1547 8.58203 10.8334 8.58203C10.4662 8.58203 10.1685 8.28411 10.1683 7.91699V3.75C10.1683 3.42691 9.90646 3.16504 9.58337 3.16504C9.26029 3.16504 8.99841 3.42691 8.99841 3.75V7.91699C8.99824 8.28411 8.70053 8.58203 8.33337 8.58203C7.96621 8.58203 7.66851 8.28411 7.66833 7.91699V5C7.66833 4.67691 7.40646 4.41504 7.08337 4.41504C6.76029 4.41504 6.49841 4.67691 6.49841 5V9.30371C6.53326 9.3429 6.56715 9.38359 6.59998 9.42578L8.02478 11.2588C8.25005 11.5486 8.19821 11.9659 7.90857 12.1914C7.6187 12.4169 7.20048 12.365 6.97498 12.0752L5.55017 10.2432C5.15812 9.7391 4.41813 9.73637 4.01501 10.1924C4.04396 10.426 4.11486 10.8323 4.25525 11.3486C4.44664 12.0525 4.75404 12.9113 5.21619 13.7383C6.14103 15.3931 7.62465 16.835 10.0004 16.835C12.8545 16.8348 15.1682 14.5211 15.1683 11.667V6.25C15.1683 5.92691 14.9065 5.66504 14.5834 5.66504C14.2603 5.66504 13.9984 5.92691 13.9984 6.25V9.16699C13.9982 9.53411 13.7005 9.83203 13.3334 9.83203C12.9662 9.83203 12.6685 9.53411 12.6683 9.16699V4.16699ZM13.9984 4.42578C14.1828 4.36671 14.3794 4.33496 14.5834 4.33496C15.641 4.33496 16.4984 5.19237 16.4984 6.25V11.667C16.4982 15.2557 13.589 18.1649 10.0004 18.165C6.95953 18.165 5.10939 16.2734 4.05505 14.3867C3.52774 13.4431 3.1843 12.4787 2.97205 11.6982C2.76447 10.9349 2.66834 10.2954 2.66833 10C2.66833 9.87959 2.70117 9.76148 2.76306 9.6582C3.28988 8.78018 4.26555 8.40372 5.16833 8.56152V5C5.16833 3.94237 6.02575 3.08496 7.08337 3.08496C7.31706 3.08496 7.54039 3.12845 7.74744 3.20508C7.98218 2.41297 8.7151 1.83496 9.58337 1.83496C10.1836 1.83496 10.7186 2.11176 11.0697 2.54395C11.3639 2.35978 11.7107 2.25195 12.0834 2.25195C13.141 2.25195 13.9984 3.10937 13.9984 4.16699V4.42578Z",fill:"currentColor"}))}function s9(t="icon-sm"){return m("svg",{className:t,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},m("path",{fillRule:"evenodd",clipRule:"evenodd",d:"M9.06543 1.95123C9.66107 1.69076 10.3389 1.69071 10.9346 1.95123L15.9346 4.13873C16.7832 4.51008 17.3311 5.34917 17.3311 6.27545V10.5528C17.3309 14.6017 14.0489 17.8847 10 17.8848C5.95108 17.8846 2.66813 14.6017 2.66797 10.5528V6.27545C2.66797 5.34924 3.21695 4.51012 4.06543 4.13873L9.06543 1.95123ZM10.4014 3.16998C10.1456 3.05814 9.85444 3.05819 9.59863 3.16998L4.59863 5.35748C4.23427 5.51708 3.99805 5.87764 3.99805 6.27545V10.5528C3.99821 13.8671 6.68563 16.5546 10 16.5547C13.3144 16.5546 16.0008 13.8671 16.001 10.5528V6.27545C16.001 5.87756 15.7658 5.51703 15.4014 5.35748L10.4014 3.16998Z",fill:"currentColor"}),m("path",{d:"M13.4678 11.4318L13.333 11.4182H10.833C10.466 11.4183 10.1682 11.7162 10.168 12.0832C10.168 12.4504 10.4659 12.7481 10.833 12.7482H13.333L13.4678 12.7346C13.7706 12.6724 13.9981 12.4044 13.9981 12.0832C13.9979 11.7621 13.7706 11.494 13.4678 11.4318Z",fill:"currentColor"}),m("path",{d:"M7.65336 12.426C7.46431 12.7406 7.05607 12.8424 6.74125 12.6535C6.42646 12.4646 6.32395 12.0563 6.51274 11.7414L7.55668 10.0002L6.51274 8.25899C6.32395 7.94412 6.42646 7.53583 6.74125 7.34688C7.05607 7.15799 7.46431 7.25975 7.65336 7.57442L8.90336 9.6584C9.0296 9.86893 9.0296 10.1315 8.90336 10.342L7.65336 12.426Z",fill:"currentColor"}))}function u9(t="icon-sm"){return m("svg",{className:t,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},m("path",{fillRule:"evenodd",clipRule:"evenodd",d:"M9.06543 1.95123C9.66107 1.69076 10.3389 1.69071 10.9346 1.95123L15.9346 4.13873C16.7832 4.51008 17.3311 5.34917 17.3311 6.27545V10.5528C17.3309 14.6017 14.0489 17.8847 10 17.8848C5.95108 17.8846 2.66813 14.6017 2.66797 10.5528V6.27545C2.66797 5.34924 3.21695 4.51012 4.06543 4.13873L9.06543 1.95123ZM10.4014 3.16998C10.1456 3.05814 9.85444 3.05819 9.59863 3.16998L4.59863 5.35748C4.23427 5.51708 3.99805 5.87764 3.99805 6.27545V10.5528C3.99821 13.8671 6.68563 16.5546 10 16.5547C13.3144 16.5546 16.0008 13.8671 16.001 10.5528V6.27545C16.001 5.87756 15.7658 5.51703 15.4014 5.35748L10.4014 3.16998Z",fill:"currentColor"}),m("path",{d:"M10.8883 13.1116C10.8883 13.6025 10.4903 14.0005 9.99936 14.0005C9.50844 14.0005 9.11047 13.6025 9.11047 13.1116C9.11047 12.6207 9.50844 12.2227 9.99936 12.2227C10.4903 12.2227 10.8883 12.6207 10.8883 13.1116Z",fill:"currentColor"}),m("path",{d:"M10.5169 10.8949L11.1135 7.31519C11.2283 6.62672 10.6974 6 9.99941 6C9.30145 6 8.77053 6.62672 8.88528 7.31519L9.4819 10.8949C9.52406 11.1479 9.74294 11.3333 9.99941 11.3333C10.2559 11.3333 10.4748 11.1479 10.5169 10.8949Z",fill:"currentColor"}))}function c9(t="icon-sm"){return m("svg",{className:t,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},m("path",{fillRule:"evenodd",clipRule:"evenodd",d:"M9.99944 7.24939C11.5169 7.2495 12.7473 8.47995 12.7475 9.99744C12.7475 11.5151 11.517 12.7454 9.99944 12.7455C8.48176 12.7455 7.2514 11.5151 7.2514 9.99744C7.25155 8.47988 8.48186 7.24939 9.99944 7.24939ZM9.99944 8.57947C9.2164 8.57947 8.58163 9.21442 8.58148 9.99744C8.58148 10.7806 9.2163 11.4154 9.99944 11.4154C10.7825 11.4153 11.4174 10.7805 11.4174 9.99744C11.4173 9.21449 10.7824 8.57958 9.99944 8.57947Z",fill:"currentColor"}),m("path",{fillRule:"evenodd",clipRule:"evenodd",d:"M10.6391 1.67517C11.2939 1.67532 11.8991 2.02577 12.226 2.59314L13.2485 4.36755H15.2963C15.9505 4.36758 16.555 4.71709 16.8823 5.28357L17.5219 6.39001C17.8489 6.95668 17.8481 7.65542 17.5209 8.22205L16.4975 9.99451L17.5239 11.7689C17.8519 12.3357 17.8521 13.0347 17.5248 13.6019L16.8862 14.7084C16.559 15.2747 15.9543 15.6243 15.3002 15.6244H13.2514L12.2299 17.3988C11.9029 17.9663 11.297 18.3168 10.642 18.3168L9.3637 18.3158C8.71064 18.3155 8.10718 17.9678 7.77972 17.4027L6.74847 15.6234L4.69964 15.6244C4.04558 15.6242 3.44087 15.2747 3.1137 14.7084L2.47503 13.6019C2.14791 13.0349 2.14836 12.3366 2.47601 11.7699L3.50237 9.99548L2.47894 8.22205C2.15175 7.65533 2.15174 6.95673 2.47894 6.39001L3.11761 5.28259C3.44458 4.71663 4.04894 4.36813 4.70257 4.36755L6.75042 4.36658L7.77581 2.59119C8.10301 2.02476 8.7076 1.67527 9.36175 1.67517H10.6391ZM9.36273 3.00623C9.1835 3.00623 9.01679 3.10199 8.92718 3.2572L7.82659 5.16345C7.63652 5.49253 7.28473 5.69529 6.90472 5.69568L4.70355 5.69763C4.52451 5.69782 4.3585 5.79355 4.26898 5.94861L3.6303 7.05505C3.54091 7.2102 3.54077 7.40192 3.6303 7.55701L4.73089 9.46326C4.92108 9.7929 4.92135 10.1992 4.73089 10.5287L3.62737 12.4359C3.5378 12.591 3.53792 12.7817 3.62737 12.9369L4.26605 14.0433C4.35567 14.1982 4.52067 14.2932 4.69964 14.2933L6.90276 14.2943C7.28242 14.2946 7.63335 14.497 7.82366 14.8256L8.93011 16.7357C9.01984 16.8905 9.18578 16.9857 9.36468 16.9857H10.642C10.8213 16.9857 10.987 16.89 11.0766 16.7347L12.1752 14.8275C12.3653 14.4975 12.7182 14.2943 13.0991 14.2943H15.3002C15.4794 14.2942 15.6452 14.1985 15.7348 14.0433L16.3725 12.9379C16.4621 12.7826 16.4621 12.5911 16.3725 12.4359L15.27 10.5287C15.1032 10.2404 15.0808 9.89331 15.2055 9.59021L15.269 9.46326L16.3696 7.55701C16.4591 7.40189 16.459 7.21022 16.3696 7.05505L15.7309 5.94861C15.6412 5.79363 15.4754 5.69863 15.2963 5.69861L13.0951 5.69763L12.9535 5.68884C12.6751 5.65158 12.4217 5.50519 12.2504 5.28259L12.1723 5.16443L11.0737 3.2572C10.9841 3.10175 10.8175 3.00525 10.6381 3.00525L9.36273 3.00623Z",fill:"currentColor"}))}function v3(t="icon-sm"){return m("svg",{className:t,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},m("path",{d:"M10.6878 9.46029L10.8421 9.49545L17.2913 11.43L17.4642 11.4974C18.2215 11.8649 18.2705 12.9544 17.5492 13.388L17.3822 13.4701L14.5872 14.5872L13.4701 17.3822C13.1135 18.2734 11.8913 18.2756 11.4974 17.4642L11.43 17.2913L9.49544 10.8421C9.26342 10.0687 9.92452 9.34418 10.6878 9.46029ZM12.4984 16.2288L13.3929 13.9954L13.4388 13.8949C13.5579 13.6675 13.7549 13.4891 13.9954 13.3929L16.2288 12.4984L10.9007 10.9007L12.4984 16.2288ZM5.90365 12.9749C6.16329 12.7153 6.58436 12.7154 6.84408 12.9749C7.10378 13.2346 7.10378 13.6557 6.84408 13.9154L5.0765 15.6829C4.8168 15.9426 4.39577 15.9426 4.13607 15.6829C3.87654 15.4232 3.87643 15.0022 4.13607 14.7425L5.90365 12.9749ZM2.83724 7.3265L5.25228 7.97299L5.37826 8.02084C5.65484 8.1591 5.80597 8.47712 5.72298 8.78744C5.63984 9.09774 5.34997 9.298 5.04134 9.27963L4.90853 9.25814L2.49349 8.61068L2.36752 8.56283C2.09082 8.42452 1.93961 8.10666 2.02279 7.79623C2.10599 7.4859 2.39574 7.28652 2.70443 7.30502L2.83724 7.3265ZM14.847 4.05111C15.1051 3.88059 15.4556 3.90894 15.6829 4.13607C15.9426 4.39577 15.9426 4.8168 15.6829 5.0765L13.9154 6.84408C13.6557 7.10378 13.2346 7.10378 12.9749 6.84408C12.7154 6.58437 12.7153 6.16329 12.9749 5.90365L14.7425 4.13607L14.847 4.05111ZM7.79623 2.02279C8.15098 1.92773 8.51562 2.13874 8.61068 2.49349L9.25814 4.90853L9.27962 5.04135C9.298 5.34998 9.09774 5.63984 8.78744 5.72299C8.47713 5.80592 8.15908 5.65484 8.02084 5.37826L7.97298 5.25228L7.3265 2.83724L7.30502 2.70443C7.28652 2.39577 7.48595 2.10603 7.79623 2.02279Z",fill:"currentColor"}))}var C3=document.getElementById("root");C3&&(Kf(),(0,T3.createRoot)(C3).render(M3.default.createElement(y3))); +`)}function T7({copiedLabel:t,copyLabel:e,text:n}){"use no memo";let[l,i]=(0,E.useState)(!1),r=(0,E.useRef)(null),o=l?t:e,a=(0,E.useCallback)(u=>{u!==null||r.current===null||(window.clearTimeout(r.current),r.current=null)},[]),s=(0,E.useCallback)(u=>{u.stopPropagation(),navigator.clipboard?.writeText(n)?.catch(()=>{}),i(!0),r.current!==null&&window.clearTimeout(r.current),r.current=window.setTimeout(()=>{i(!1),r.current=null},2e3)},[n]);return m("button",{ref:a,type:"button",className:`codex-shell-copy ${Qe} ${Fe} ${no} rounded-full electron:rounded-md hover:bg-transparent hover:text-token-button-foreground`,"aria-label":o,title:o,onClick:s},l?Yl("icon-xxs"):tu("icon-xxs"))}function E7({label:t,output:e}){"use no memo";let[n,l]=(0,E.useState)(!1),i=(0,E.useRef)(null),r=(0,E.useCallback)(a=>{a!==null||i.current===null||(window.clearTimeout(i.current),i.current=null)},[]),o=(0,E.useCallback)(a=>{a.stopPropagation(),navigator.clipboard?.writeText(e)?.catch(()=>{}),l(!0),i.current!==null&&window.clearTimeout(i.current),i.current=window.setTimeout(()=>{l(!1),i.current=null},2e3)},[e]);return m("button",{ref:r,type:"button",className:`codex-tool-output-copy ${Qe} ${Fe} rounded-full electron:rounded-md electron:p-1 electron:[&>svg]:icon-sm flex items-center justify-center p-0.5 absolute top-0 right-2.5 opacity-0 transition-opacity duration-200 group-hover/output:opacity-100${n?" text-token-foreground opacity-100":""}`,"aria-label":t,title:t,onClick:o},n?Yl("icon-2xs"):tu("icon-2xs"))}function N7({copy:t,sentAtMs:e,text:n}){"use no memo";let[l,i]=(0,E.useState)(!1),r=(0,E.useRef)(null),o=t?.copyUserMessage??"",a=t?.copiedUserMessage??"",s=l?a:o,u=e==null?null:v3(e),c=(0,E.useCallback)(d=>{d!==null||r.current===null||(window.clearTimeout(r.current),r.current=null)},[]),f=(0,E.useCallback)(()=>{let d=n.trim();if(!d)return;navigator.clipboard?.writeText(d)?.catch(()=>{}),i(!0),r.current!==null&&window.clearTimeout(r.current),r.current=window.setTimeout(()=>{i(!1),r.current=null},1500)},[n]);return m("div",{className:"codex-user-message-actions flex flex-row-reverse items-center gap-1"},m("div",{className:"mr-1 ms-1 flex items-center gap-2 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100"},u==null?null:m("span",{className:"text-xs text-token-text-tertiary"},u),m("button",{ref:c,type:"button",className:`codex-user-message-action-button ${Qe} ${Fe} ${no} rounded-full electron:rounded-md`,"aria-label":s,title:s,onClick:f},l?Yl("icon-xs"):tu("icon-xs"))))}function A7({copy:t,sentAtMs:e,text:n}){"use no memo";let[l,i]=(0,E.useState)(!1),r=(0,E.useRef)(null),o=t?.copyAssistantMessage??"",a=t?.copiedAssistantMessage??o,s=l?a:o,u=e==null?null:v3(e),c=(0,E.useCallback)(d=>{d!==null||r.current===null||(window.clearTimeout(r.current),r.current=null)},[]),f=(0,E.useCallback)(()=>{let d=n.trim();if(!d)return;navigator.clipboard?.writeText(d)?.catch(()=>{}),i(!0),r.current!==null&&window.clearTimeout(r.current),r.current=window.setTimeout(()=>{i(!1),r.current=null},2e3)},[n]);return m("div",{className:"codex-assistant-message-actions mt-1.5 flex h-5 items-center justify-start gap-0.5 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100"},m("button",{ref:c,type:"button",className:`codex-assistant-message-action-button ${Qe} ${Fe} ${no} rounded-full electron:rounded-md`,"aria-label":s,title:s,onClick:f},l?Yl("icon-xs"):tu("icon-xs")),u==null?null:m("span",{className:"ml-1.5 flex h-full items-center text-size-chat leading-5 text-token-input-placeholder-foreground"},u))}var O7=7;function v3(t,e=new Date){let n=new Date(t),l=D7(n,e);return l===0?new Intl.DateTimeFormat(void 0,{hour:"numeric",minute:"2-digit"}).format(n):l<0&&l>-O7?new Intl.DateTimeFormat(void 0,{hour:"numeric",minute:"2-digit",weekday:"long"}).format(n):new Intl.DateTimeFormat(void 0,{day:"numeric",hour:"numeric",minute:"2-digit",month:"short"}).format(n)}function D7(t,e){let n=new Date(t.getFullYear(),t.getMonth(),t.getDate()),l=new Date(e.getFullYear(),e.getMonth(),e.getDate());return Math.round((n.getTime()-l.getTime())/864e5)}function k7({copy:t,text:e}){"use no memo";let[n,l]=(0,E.useState)(!1),i=w7(e),r=i&&!n,o=t?.showMore??"",a=t?.showLess??"",s=(0,E.useCallback)(()=>{l(u=>!u)},[]);return m("div",{className:"flex flex-col items-end gap-1"},m("div",{className:"codex-user-message-content text-size-chat relative w-full min-w-0 mb-px","data-collapsed":r?"true":void 0,dangerouslySetInnerHTML:{__html:Wn(e)}}),i?m("button",{type:"button","aria-expanded":n,className:"codex-user-message-toggle text-size-chat mt-1.5 inline-flex cursor-interaction items-center gap-1 self-start text-token-description-foreground hover:text-token-foreground",onClick:s},m("span",null,n?a:o),eu(`icon-2xs${n?" rotate-180":""}`)):null)}function w7(t){return t.split(/\r\n|\r|\n/).length>u7||t.length>1600}function z7({attachments:t}){return m("div",{className:"codex-user-message-attachments flex max-w-[77%] flex-row-reverse flex-wrap items-center gap-1"},t.map(e=>m(R7,{attachment:e,key:e.id})))}function R7({attachment:t}){return t.kind==="image"&&t.dataUrl?m("div",{className:"user-message-image-attachment flex size-16 items-center justify-center rounded-lg border border-token-border",title:t.label},m("img",{alt:t.label,className:"h-full w-full rounded-md object-cover",referrerPolicy:"no-referrer",src:t.dataUrl})):m("div",{className:"user-message-file-attachment bg-token-dropdown-background border-token-border inline-flex max-w-[320px] items-center gap-1 rounded-full border px-2 py-1.5 text-sm",title:t.path},m("span",{className:"text-token-input-placeholder-foreground flex-shrink-0","aria-hidden":!0},ch("icon-2xs")),m("span",{className:"flex max-w-full min-w-0 items-center gap-1"},m("span",{className:"truncate"},t.label)))}function L7({attachments:t,className:e,copy:n,onRemove:l}){return m("div",{className:`codex-attachment-tray w-full overflow-x-auto${e?` ${e}`:""}`,"data-composer-attachments-row":!0},m("div",{className:"codex-attachment-row flex min-w-max items-end gap-2"},t.map(i=>m(_7,{attachment:i,copy:n,key:i.id,onRemove:l}))))}function _7({attachment:t,copy:e,onRemove:n}){let l=`${e?.removeAttachment??"Remove attachment"} ${t.label}`,i=n?m("button",{className:"composer-attachment-remove",type:"button","aria-label":l,onClick:()=>n(t.id),onMouseDown:r=>r.preventDefault()},nu("icon-2xs")):null;return t.kind==="image"&&t.dataUrl?m("div",{className:"composer-attachment-image",title:t.label},m("img",{alt:"","aria-hidden":!0,src:t.dataUrl}),i):m("div",{className:"composer-attachment-file",title:t.path},m("span",{className:"composer-attachment-file-icon","aria-hidden":!0},ch("icon-xs")),m("span",{className:"composer-attachment-file-label"},t.label),i)}function B7({state:t,providerDisplayName:e}){let l=(t.context?.rateLimitRows??[]).map(_g),i=t.context?.copy,[r,o]=(0,E.useState)(!1);if(l.length===0)return null;let a=i?.rateLimits??"Rate limits";return m("div",{className:"rate-line codex-rate-limit-summary relative",role:"status","aria-label":`${e} ${Jg(t)}`.trim(),onBlur:s=>{s.currentTarget.contains(s.relatedTarget)||o(!1)}},m("button",{className:"rate-limit-trigger rate-limit-trigger-inline flex min-w-0 items-center gap-1",type:"button","aria-expanded":r,onClick:()=>o(s=>!s)},m("span",{className:"rate-line-heading"},a),l.flatMap((s,u)=>[u>0?m("span",{key:`${s.role}-separator`,className:"rate-limit-inline-separator","aria-hidden":!0},"\u2022"):null,m(H7,{key:s.role,row:s,state:t})])),r?m("div",{className:"rate-limit-popover absolute bottom-[calc(100%+6px)] left-0 z-50 flex min-w-56 flex-col gap-1 rounded-xl border border-token-border bg-token-dropdown-background/95 px-3 py-2 text-sm shadow-xl-spread backdrop-blur-sm"},m("div",{className:"rate-limit-popover-title"},a),l.map(s=>m(U7,{key:s.role,row:s,state:t}))):null)}function H7({row:t,state:e}){let n=t,l=e.context?.copy,i=n.role==="primary"?l?.rateLimitPrimary??"Primary":l?.rateLimitSecondary??"Secondary",r=ed(n.windowDurationMins,i,{weekly:l?.rateLimitWeekly??"Weekly",monthly:l?.rateLimitMonthly??"Monthly",daysFormat:l?.rateLimitDaysFormat??"",hoursFormat:l?.rateLimitHoursFormat??"",minutesFormat:l?.rateLimitMinutesFormat??""}),o=$f(n.resetsAt);return m("span",{className:"rate-limit-inline-segment"},m("span",{className:"rate-limit-window"},r),m("span",{className:"rate-limit-percent"},Wf(n.remainingPercent)),o?m("span",{className:"rate-limit-reset"},`${l?.rateLimitResets??"resets"} ${o}`):null)}function U7({row:t,state:e}){let n=t,l=e.context?.copy,i=n.role==="primary"?l?.rateLimitPrimary??"Primary":l?.rateLimitSecondary??"Secondary",r=ed(n.windowDurationMins,i,{weekly:l?.rateLimitWeekly??"Weekly",monthly:l?.rateLimitMonthly??"Monthly",daysFormat:l?.rateLimitDaysFormat??"",hoursFormat:l?.rateLimitHoursFormat??"",minutesFormat:l?.rateLimitMinutesFormat??""}),o=$f(n.resetsAt);return m("div",{className:"rate-limit-popover-row"},m("span",{className:"rate-limit-window"},r),m("span",{className:"rate-limit-row-value"},m("span",{className:"rate-limit-percent"},Wf(n.remainingPercent)),o?m("span",{className:"rate-limit-reset"},`${l?.rateLimitResets??"resets"} ${o}`):null))}function V7({emptyLabel:t,highlightedIndex:e,items:n,onChoose:l,onHighlight:i,query:r}){return m("div",{className:"codex-top-tray-shell absolute z-20"},m("div",{className:"codex-top-tray-panel","cmdk-root":"","data-cmdk-root":!0},m("div",{className:"codex-top-tray-list","cmdk-list":"","data-cmdk-list":!0},n.length===0?m("div",{className:"codex-top-tray-empty","cmdk-empty":"","data-cmdk-empty":!0,"data-command-menu-empty-state":"true"},t):n.map((o,a)=>{let s=o.detail?"codex-top-tray-label max-w-[60%] flex-none truncate":"codex-top-tray-label min-w-0 flex-1 truncate",u=l9(o.label,r),c=u.some(f=>!f.isMatch);return m("button",{key:o.id,className:"codex-top-tray-item",type:"button","aria-selected":a===e?"true":void 0,"cmdk-item":"","data-selected":a===e?"true":void 0,"data-list-navigation-item":!0,onMouseEnter:()=>i(a),onMouseDown:f=>f.preventDefault(),onClick:()=>l(o)},m("div",{className:"codex-top-tray-copy flex w-full items-center gap-2"},m("span",{className:"codex-top-tray-icon icon-xs shrink-0","aria-hidden":!0},o.icon),m("div",{className:s},u.map((f,d)=>m("span",{key:`${f.text}-${d}`,className:!f.isMatch&&c?"text-token-description-foreground":void 0},f.text))),o.detail?m("span",{className:"codex-top-tray-detail min-w-0 flex-1 truncate text-sm text-token-description-foreground"},o.detail):null))}))))}function m3({icon:t,label:e,onClear:n}){return m("div",{className:"composer-mode-indicator flex min-w-0 items-center gap-1"},m("div",{className:"composer-mode-divider","aria-hidden":!0}),m("button",{className:`composer-mode-button group ${Qe} ${Fe} ${mn} rounded-full`,type:"button","aria-label":e,onClick:n},m("span",{className:"composer-mode-icon composer-mode-icon-default icon-xs shrink-0","aria-hidden":!0},t),m("span",{className:"composer-mode-icon composer-mode-icon-hover icon-xs shrink-0","aria-hidden":!0},nu("icon-xs")),m("span",{className:"composer-footer__label--sm composer-mode-label max-w-16 truncate"},e)))}function q7({hideLabel:t,label:e,onClear:n}){return m("button",{className:`composer-context-button group ${Qe} ${Fe} ${mn} min-w-0 rounded-full`,type:"button","aria-label":e,title:e,onClick:n},m("span",{className:"composer-context-icon composer-context-icon-default icon-xs shrink-0","aria-hidden":!0},T3("icon-xs")),m("span",{className:"composer-context-icon composer-context-icon-hover icon-xs shrink-0","aria-hidden":!0},nu("icon-xs")),t?null:m("span",{className:"composer-footer__label--sm composer-context-label max-w-20 truncate"},e))}function Y7({copy:t,hideLabel:e=!1,isEnabled:n,isOpen:l,mode:i,onModeChange:r,onOpenChange:o}){let a=t?.changePermissions??"Change permissions",s=S3(t,i),u=n?["default","auto-review","full-access","custom"]:[],c=e?mg:mn,f=h=>{r(h),o(!1)},d=n&&l;return m("div",{className:"permissions-root relative inline-flex",onBlur:h=>{n&&!h.currentTarget.contains(h.relatedTarget)&&o(!1)}},m("button",{className:`permissions-trigger ${Qe} ${Fe} ${c} ${e?lo:"min-w-0"} rounded-full`,type:"button","aria-label":a,"aria-haspopup":n?"menu":void 0,"aria-expanded":n?d:void 0,"aria-disabled":n?void 0:!0,"data-permission-mode":i,"data-state":d?"open":"closed",disabled:!n,onClick:()=>{n&&o(!d)},onKeyDown:h=>{n&&(h.key==="ArrowDown"&&(h.preventDefault(),o(!0)),h.key==="Escape"&&(h.preventDefault(),o(!1)))}},M3(i,"icon-xs shrink-0"),e?null:m("span",{className:"permissions-trigger-label composer-footer__label--xs max-w-40 truncate whitespace-nowrap text-left"},s),n?m("span",{className:"permissions-trigger-chevron icon-2xs shrink-0","aria-hidden":!0},eu()):null),d?m("div",{className:"permissions-dropdown _content_1hiti_1 no-drag bg-token-dropdown-background/90 text-token-foreground ring-token-border z-50 m-px flex select-none flex-col overflow-y-auto rounded-xl ring-[0.5px] px-1 py-1 shadow-xl-spread backdrop-blur-sm",role:"menu","aria-label":a,"data-state":"open","data-side":"top",style:{"--radix-dropdown-menu-content-transform-origin":"left bottom"}},u.map(h=>m(Z7,{isSelected:h===i,key:h,label:S3(t,h),mode:h,onSelect:()=>f(h)}))):null)}function Z7({isSelected:t,label:e,mode:n,onSelect:l}){return m("button",{className:"permissions-item no-drag group hover:bg-token-list-hover-background focus:bg-token-list-hover-background cursor-interaction text-token-foreground outline-hidden rounded-lg px-[var(--padding-row-x)] py-[var(--padding-row-y)] text-sm",type:"button",role:"menuitemradio","aria-checked":t,"data-permission-mode":n,"data-selected":t?"true":void 0,onMouseDown:i=>i.preventDefault(),onClick:l},m("span",{className:"permissions-item-icon icon-xs shrink-0","aria-hidden":!0},M3(n,"icon-xs shrink-0")),m("span",{className:"permissions-item-label min-w-0 flex-1 truncate"},e),t?m("span",{className:"permissions-item-check icon-xs shrink-0","aria-hidden":!0},Yl()):null)}function j7({hasIdeContext:t,isAutoContextOn:e,isOpen:n,isPickingFiles:l,isPlanMode:i,onOpenChange:r,onPickFiles:o,onToggleAutoContext:a,onTogglePlanMode:s,state:u}){let c=u.context?.copy,f=c?.addFilesAndMore??"Add files and more",d=c?.addPhotosAndFiles??"Add photos & files",p=uh("skill",u,"").find(b=>b.id==="plan")??null;return m("div",{className:"add-context-root relative inline-flex",onBlur:b=>{b.currentTarget.contains(b.relatedTarget)||r(!1)}},m("button",{className:`codex-tool codex-tool-plus ${Qe} ${Fe} ${mn} ${lo} rounded-full`,type:"button","aria-label":f,"aria-haspopup":"menu","aria-expanded":n,"data-state":n?"open":"closed",onClick:()=>r(!n),onKeyDown:b=>{b.key==="ArrowDown"&&(b.preventDefault(),r(!0)),b.key==="Escape"&&(b.preventDefault(),r(!1))}},c9()),n?m("div",{className:"add-context-dropdown _content_1hiti_1 no-drag bg-token-dropdown-background/90 text-token-foreground ring-token-border z-50 m-px flex select-none flex-col overflow-y-auto rounded-xl ring-[0.5px] px-1 py-1 shadow-xl-spread backdrop-blur-sm",role:"menu","aria-label":f,"data-state":"open","data-side":"top",style:{"--radix-dropdown-menu-content-transform-origin":"left bottom"}},m(G7,{disabled:l,icon:ch("icon-xs"),label:d,onSelect:o}),t||p?m(X7):null,t?m(g3,{checked:e,icon:T3("icon-sm"),label:c?.includeIdeContext??"Include IDE context",onSelect:a}):null,p?m(g3,{checked:i,icon:$s(),label:c?.planMode??"Plan mode",onSelect:s}):null):null)}function G7({detail:t,disabled:e=!1,icon:n,label:l,onSelect:i}){return m("button",{className:"add-context-item group no-drag hover:bg-token-list-hover-background focus:bg-token-list-hover-background cursor-interaction text-token-foreground outline-hidden rounded-lg px-[var(--padding-row-x)] py-[var(--padding-row-y)] text-sm",disabled:e,type:"button",role:"menuitem",onMouseDown:r=>r.preventDefault(),onClick:e?void 0:i},m("span",{className:"add-context-item-icon icon-xs shrink-0","aria-hidden":!0},n),m("span",{className:"add-context-item-copy flex min-w-0 flex-1 items-center gap-2"},m("span",{className:"add-context-item-label min-w-0 truncate"},l),t?m("span",{className:"add-context-item-detail min-w-0 flex-1 truncate text-token-description-foreground"},t):null))}function g3({checked:t,icon:e,label:n,onSelect:l}){let i=t?"checked":"unchecked",r=t?"relative inline-flex h-4 w-7 shrink-0 items-center rounded-full bg-token-charts-blue transition-colors duration-200 ease-out":"relative inline-flex h-4 w-7 shrink-0 items-center rounded-full bg-token-foreground/10 transition-colors duration-200 ease-out";return m("button",{className:"add-context-item group no-drag hover:bg-token-list-hover-background focus:bg-token-list-hover-background cursor-interaction text-token-foreground outline-hidden rounded-lg px-[var(--padding-row-x)] py-[var(--padding-row-y)] text-sm",type:"button",role:"menuitemcheckbox","aria-checked":t,onMouseDown:o=>o.preventDefault(),onClick:l},m("span",{className:"add-context-item-icon icon-xs shrink-0","aria-hidden":!0},e),m("span",{className:"add-context-switch-row flex w-full min-w-0 items-center justify-between gap-2"},m("span",{className:"flex min-w-0 items-center gap-2"},m("span",{className:"add-context-item-label min-w-0 truncate"},n)),m("span",{className:r,"data-state":i,"aria-hidden":!0},m("span",{className:"h-3 w-3 rounded-full border border-[color:var(--gray-0)] bg-[color:var(--gray-0)] shadow-sm transition-transform duration-200 ease-out data-[state=unchecked]:translate-x-[2px] data-[state=checked]:translate-x-[14px]","data-state":i}))))}function X7(){return m("div",{className:"add-context-separator w-full px-[var(--padding-row-x)] py-1",role:"separator"},m("div",{className:"add-context-separator-line h-[1px] w-full bg-token-menu-border"}))}function I7(t){return uh("mention",t,"").find(e=>e.id==="workspace")??null}function uh(t,e,n){let l=e.context?.copy,i=t==="mention"?[...e.context?.workingDirectory?[{id:"workspace",icon:f9("icon-xs"),label:l?.mentionCurrentWorkspace??"Current workspace",detail:Ws(e.context.workingDirectory),mention:{kind:"at",label:Ws(e.context.workingDirectory),name:Ws(e.context.workingDirectory),path:e.context.workingDirectory,fsPath:e.context.workingDirectory}}]:[],...e.providers.map(r=>({id:r.id,icon:Rg(r),label:r.displayName,detail:r.executableName,mention:{kind:"agent",label:r.displayName,name:r.id,displayName:r.displayName,path:`provider://${r.id}`,description:r.executableName}}))]:[{id:"plan",icon:"$",label:l?.skillPlan??"Plan",detail:"$plan",mention:{kind:"skill",label:"Plan",name:"plan",displayName:"Plan",path:"skill://plan"}},{id:"review",icon:"$",label:l?.skillCodeReview??"Code review",detail:"$codex-review",mention:{kind:"skill",label:"Code review",name:"codex-review",displayName:"Code review",path:"skill://codex-review"}},{id:"research",icon:"$",label:l?.skillResearch??"Research",detail:"$research",mention:{kind:"skill",label:"Research",name:"research",displayName:"Research",path:"skill://research"}}];return K7(i,n)}function K7(t,e){let n=e.trim();return n?t.map(l=>({item:l,score:n9(l.label,n,[l.detail,l.mention.name])})).filter(({score:l})=>l>0).sort((l,i)=>i.score-l.score).map(({item:l})=>l):t}var y3=1,Q7=.9,F7=.8,J7=.17,oh=.1,ah=.999,P7=.9999,W7=.99,$7=/[\\/_+.#"@[({&]/,e9=/[\\/_+.#"@[({&]/g,t9=/[\s-]/,C3=/[\s-]/g;function n9(t,e,n){let l=n.length>0?`${t} ${n.join(" ")}`:t;return sh(l,e,b3(l),b3(e),0,0,{})}function b3(t){return t.toLowerCase().replace(C3," ")}function sh(t,e,n,l,i,r,o){if(r===e.length)return i===t.length?y3:W7;let a=`${i},${r}`;if(o[a]!==void 0)return o[a];let s=l.charAt(r),u=n.indexOf(s,i),c=0;for(;u>=0;){let f=sh(t,e,n,l,u+1,r+1,o);if(f>c){if(u===i)f*=y3;else if($7.test(t.charAt(u-1))){f*=F7;let d=t.slice(i,u-1).match(e9);d&&i>0&&(f*=ah**d.length)}else if(t9.test(t.charAt(u-1))){f*=Q7;let d=t.slice(i,u-1).match(C3);d&&i>0&&(f*=ah**d.length)}else f*=J7,i>0&&(f*=ah**(u-i));t.charAt(u)!==e.charAt(r)&&(f*=P7)}if(ff&&(f=d*oh)}f>c&&(c=f),u=n.indexOf(s,u+1)}return o[a]=c,c}function l9(t,e){let n=Array.from(t),l=e.trim();if(!l)return n.map(s=>({isMatch:!0,text:s}));let i=n.map(s=>s.toLowerCase()),r=Array.from(l.toLowerCase()),o=-1;for(let s=0;s<=i.length-r.length;s+=1)if(r.every((u,c)=>i[s+c]===u)){o=s;break}if(o>=0){let s=o+r.length;return n.map((u,c)=>({isMatch:c>=o&&c{let u=a{let l=`${n.kind}:${n.path}`;return e.has(l)?!1:(e.add(l),!0)})}function r9(t="icon-sm"){return m("svg",{className:t,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},m("path",{d:"M9.33467 16.6663V4.93978L4.6374 9.63704L4.1667 9.16634L3.69599 8.69661L9.52998 2.86263L9.63447 2.77767C9.8925 2.60753 10.2433 2.63564 10.4704 2.86263L16.3034 8.69661L16.3884 8.80111C16.5588 9.05922 16.5306 9.40982 16.3034 9.63704C16.0762 9.86414 15.7255 9.89242 15.4675 9.722L15.363 9.63704L10.6647 4.9388V16.6663C10.6647 17.0336 10.367 17.3314 9.99971 17.3314C9.63259 17.3312 9.33467 17.0335 9.33467 16.6663ZM4.6374 9.63704C4.3777 9.89674 3.95569 9.89674 3.69599 9.63704C3.43657 9.37744 3.43668 8.95628 3.69599 8.69661L4.6374 9.63704Z",fill:"currentColor"}))}function o9(){return m("svg",{width:"16",height:"16",viewBox:"0 0 16 16",fill:"none","aria-hidden":!0},m("rect",{x:"4.75",y:"4.75",width:"6.5",height:"6.5",rx:"1",fill:"currentColor"}))}function a9(){return m("svg",{width:"16",height:"16",viewBox:"0 0 16 16",fill:"none","aria-hidden":!0},m("path",{d:"M8 2.25a2 2 0 0 0-2 2v3.25a2 2 0 1 0 4 0V4.25a2 2 0 0 0-2-2ZM4 7.5a4 4 0 0 0 8 0M8 11.5v2.25",stroke:"currentColor",strokeWidth:"1.4",strokeLinecap:"round"}))}function s9(t="icon-sm"){return m("svg",{className:t,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},m("path",{d:"M10 5.14295V14.8441M10 5.14295C10 1.98209 14.8475 1.94468 14.8475 5.07797L14.8473 5.1067C17.1367 5.43539 18.0436 7.54498 16.4225 9.14006C17.8633 10.5577 17.3503 13.9054 15.4449 14.6301C14.8475 17.6527 10.8056 18.4821 10 14.8441M10 5.14295C10 1.98209 5.15249 1.94468 5.15249 5.07797L5.15265 5.1067C2.86325 5.43539 1.95642 7.54498 3.57746 9.14006C2.13674 10.5577 2.64974 13.9054 4.5551 14.6301C5.15249 17.6527 9.19444 18.4821 10 14.8441",stroke:"currentColor",strokeLinejoin:"round",strokeWidth:"1.33"}))}function eu(t="icon-2xs"){return m("svg",{className:t,width:"20",height:"21",viewBox:"0 0 20 21",fill:"none","aria-hidden":!0},m("path",{d:"M15.2793 7.71101C15.539 7.45131 15.961 7.45131 16.2207 7.71101C16.4804 7.97071 16.4804 8.39272 16.2207 8.65242L10.4707 14.4024C10.211 14.6621 9.78902 14.6621 9.52932 14.4024L3.77932 8.65242L3.69436 8.54792C3.52385 8.28979 3.55205 7.93828 3.77932 7.71101C4.00659 7.48374 4.3581 7.45554 4.61623 7.62605L4.72073 7.71101L10 12.9903L15.2793 7.71101Z",fill:"currentColor",stroke:"currentColor",strokeWidth:"0.6"}))}function u9(t="icon-2xs"){return m("svg",{className:t,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},m("path",{d:"M7.52925 3.7793C7.75652 3.55203 8.10803 3.52383 8.36616 3.69434L8.47065 3.7793L14.2207 9.5293C14.4804 9.789 14.4804 10.211 14.2207 10.4707L8.47065 16.2207C8.21095 16.4804 7.78895 16.4804 7.52925 16.2207C7.26955 15.961 7.26955 15.539 7.52925 15.2793L12.8085 10L7.52925 4.7207L7.44429 4.61621C7.27378 4.35808 7.30198 4.00657 7.52925 3.7793Z",fill:"currentColor"}))}function tu(t="icon-sm"){return m("svg",{className:t,width:"21",height:"21",viewBox:"0 0 21 21",fill:"none","aria-hidden":!0},m("path",{d:"M13.468 11.1216C13.468 10.4107 13.468 9.91717 13.4367 9.53369C13.4137 9.25191 13.3758 9.0622 13.3244 8.91846L13.2687 8.78858C13.1148 8.48652 12.8803 8.23344 12.593 8.05713L12.466 7.98584C12.308 7.90546 12.0963 7.84854 11.7209 7.81787C11.3374 7.78656 10.8439 7.78662 10.133 7.78662H7.29999C6.58895 7.78662 6.09562 7.78654 5.7121 7.81787C5.43015 7.84091 5.24064 7.87872 5.09686 7.93018L4.96698 7.98584C4.66487 8.13977 4.41184 8.37419 4.23554 8.66162L4.16522 8.78858C4.08477 8.94657 4.02794 9.15811 3.99725 9.53369C3.96594 9.91718 3.96503 10.4107 3.96503 11.1216V13.9546C3.96503 14.6656 3.96592 15.159 3.99725 15.5425C4.02796 15.9182 4.08471 16.1296 4.16522 16.2876L4.23554 16.4136C4.41185 16.7012 4.66472 16.9353 4.96698 17.0894L5.09686 17.146C5.24061 17.1974 5.43024 17.2343 5.7121 17.2573C6.09562 17.2887 6.58895 17.2896 7.29999 17.2896H10.133C10.8439 17.2896 11.3374 17.2886 11.7209 17.2573C12.0965 17.2266 12.308 17.1698 12.466 17.0894L12.593 17.019C12.8804 16.8427 13.1148 16.5897 13.2687 16.2876L13.3244 16.1577C13.3759 16.0139 13.4137 15.8244 13.4367 15.5425C13.468 15.159 13.468 14.6656 13.468 13.9546V11.1216ZM14.798 13.1196C15.2528 13.118 15.6011 13.1147 15.8879 13.0913C16.2634 13.0606 16.475 13.0038 16.633 12.9233L16.759 12.8521C17.0466 12.6757 17.2808 12.4228 17.4348 12.1206L17.4914 11.9907C17.5428 11.847 17.5797 11.6572 17.6027 11.3755C17.634 10.992 17.6349 10.4985 17.6349 9.7876V6.95459C17.6349 6.24355 17.6341 5.75022 17.6027 5.3667C17.5797 5.08484 17.5428 4.89522 17.4914 4.75147L17.4348 4.62158C17.2807 4.31933 17.0466 4.06645 16.759 3.89014L16.633 3.81982C16.475 3.73932 16.2636 3.68256 15.8879 3.65186C15.5044 3.62052 15.011 3.61963 14.3 3.61963H11.467C10.7561 3.61963 10.2626 3.62054 9.87909 3.65186C9.59738 3.67487 9.40759 3.71179 9.26386 3.76318L9.13397 3.81982C8.83175 3.97382 8.57885 4.20802 8.40253 4.49561L8.33124 4.62158C8.25079 4.77957 8.19396 4.99114 8.16327 5.3667C8.13984 5.65352 8.13561 6.00178 8.13397 6.45654H10.133C10.822 6.45654 11.3791 6.4559 11.8293 6.49268C12.2873 6.5301 12.6937 6.6093 13.0705 6.80127L13.2883 6.92334C13.7839 7.22739 14.1878 7.66313 14.4533 8.18408L14.5197 8.32666C14.6642 8.66318 14.7291 9.02433 14.7619 9.42529C14.7987 9.8755 14.798 10.4326 14.798 11.1216V13.1196ZM18.965 9.7876C18.965 10.4766 18.9657 11.0337 18.9289 11.4839C18.8961 11.8848 18.8311 12.246 18.6867 12.5825L18.6203 12.7251C18.3548 13.246 17.9509 13.6818 17.4553 13.9858L17.2365 14.1079C16.8599 14.2998 16.4541 14.3791 15.9963 14.4165C15.6592 14.444 15.2624 14.4481 14.7951 14.4497C14.7935 14.917 14.7894 15.3138 14.7619 15.6509C14.7292 16.0516 14.664 16.4122 14.5197 16.7485L14.4533 16.8911C14.1878 17.4122 13.7841 17.8487 13.2883 18.1528L13.0705 18.2749C12.6937 18.4669 12.2873 18.5461 11.8293 18.5835C11.3791 18.6203 10.822 18.6196 10.133 18.6196H7.29999C6.6109 18.6196 6.05394 18.6203 5.6037 18.5835C5.20305 18.5508 4.84233 18.4855 4.50604 18.3413L4.36347 18.2749C3.84243 18.0094 3.40584 17.6056 3.10175 17.1099L2.97968 16.8911C2.78787 16.5145 2.70849 16.1087 2.67108 15.6509C2.6343 15.2006 2.63495 14.6437 2.63495 13.9546V11.1216C2.63495 10.4326 2.63431 9.8755 2.67108 9.42529C2.7085 8.96729 2.78771 8.56084 2.97968 8.18408L3.10175 7.96631C3.40585 7.47049 3.84235 7.06679 4.36347 6.80127L4.50604 6.73486C4.84236 6.59059 5.20302 6.52542 5.6037 6.49268C5.9405 6.46516 6.33707 6.4601 6.80389 6.4585C6.8055 5.99167 6.81056 5.5951 6.83807 5.2583C6.87549 4.80047 6.95482 4.39471 7.14667 4.01807L7.26874 3.79932C7.5728 3.30371 8.00855 2.89973 8.52948 2.63428L8.67206 2.56787C9.00854 2.42345 9.36978 2.35844 9.77069 2.32568C10.2209 2.28891 10.778 2.28955 11.467 2.28955H14.3C14.9891 2.28955 15.546 2.2889 15.9963 2.32568C16.4541 2.3631 16.8599 2.44247 17.2365 2.63428L17.4553 2.75635C17.951 3.06044 18.3548 3.49703 18.6203 4.01807L18.6867 4.16065C18.8309 4.49694 18.8962 4.85765 18.9289 5.2583C18.9657 5.70854 18.965 6.2655 18.965 6.95459V9.7876Z",fill:"currentColor"}))}function Yl(t){return m("svg",{className:t,width:"17",height:"17",viewBox:"0 0 17 17",fill:"none","aria-hidden":!0},m("path",{d:"M12.8961 3.64101C13.1297 3.41418 13.4984 3.37523 13.7779 3.56581C14.0571 3.75635 14.1554 4.11331 14.0299 4.41347L13.9615 4.53847L7.71151 13.7045C7.59411 13.8767 7.4063 13.9877 7.19881 14.0072C6.99136 14.0267 6.78564 13.9533 6.63826 13.806L2.88826 10.056L2.79842 9.9457C2.6192 9.67407 2.64927 9.30496 2.88826 9.06581C3.12738 8.82669 3.49647 8.79676 3.76815 8.97597L3.8785 9.06581L7.03084 12.2182L12.8053 3.74941L12.8961 3.64101Z",fill:"currentColor"}))}function nu(t="icon-sm"){return m("svg",{className:t,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},m("path",{d:"M5.96967 5.96967C6.26256 5.67678 6.73744 5.67678 7.03033 5.96967L10 8.93934L12.9697 5.96967C13.2626 5.67678 13.7374 5.67678 14.0303 5.96967C14.3232 6.26256 14.3232 6.73744 14.0303 7.03033L11.0607 10L14.0303 12.9697C14.3232 13.2626 14.3232 13.7374 14.0303 14.0303C13.7374 14.3232 13.2626 14.3232 12.9697 14.0303L10 11.0607L7.03033 14.0303C6.73744 14.3232 6.26256 14.3232 5.96967 14.0303C5.67678 13.7374 5.67678 13.2626 5.96967 12.9697L8.93934 10L5.96967 7.03033C5.67678 6.73744 5.67678 6.26256 5.96967 5.96967Z",fill:"currentColor"}))}function c9(){return m("svg",{className:"icon-sm",width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},m("path",{d:"M9.33496 16.5V10.665H3.5C3.13273 10.665 2.83496 10.3673 2.83496 10C2.83496 9.63273 3.13273 9.33496 3.5 9.33496H9.33496V3.5C9.33496 3.13273 9.63273 2.83496 10 2.83496C10.3673 2.83496 10.665 3.13273 10.665 3.5V9.33496H16.5L16.6338 9.34863C16.9369 9.41057 17.165 9.67857 17.165 10C17.165 10.3214 16.9369 10.5894 16.6338 10.6514L16.5 10.665H10.665V16.5C10.665 16.8673 10.3673 17.165 10 17.165C9.63273 17.165 9.33496 16.8673 9.33496 16.5Z",fill:"currentColor"}))}function ch(t="icon-sm"){return m("svg",{className:t,width:"21",height:"21",viewBox:"0 0 21 21",fill:"none","aria-hidden":!0},m("path",{d:"M4.43945 12.8041V7.68261C4.43945 7.30642 4.74446 7.00141 5.12066 7.00141C5.49685 7.00141 5.80186 7.30642 5.80186 7.68261V12.8041C5.80186 15.2565 7.78984 17.2445 10.2422 17.2445C12.6945 17.2445 14.6825 15.2565 14.6825 12.8041V5.9751C14.6823 4.46587 13.4589 3.24247 11.9497 3.24229C10.4403 3.24229 9.21606 4.46576 9.21588 5.9751V12.8041C9.21588 13.3708 9.67553 13.8304 10.2422 13.8304C10.8088 13.8304 11.2685 13.3708 11.2685 12.8041V7.68261C11.2685 7.30642 11.5735 7.00141 11.9497 7.00141C12.3257 7.00159 12.6309 7.30653 12.6309 7.68261V12.8041C12.6309 14.1232 11.5612 15.1929 10.2422 15.1929C8.92314 15.1929 7.85347 14.1232 7.85347 12.8041V5.9751C7.85365 3.71337 9.68791 1.87988 11.9497 1.87988C14.2113 1.88006 16.0447 3.71348 16.0449 5.9751V12.8041C16.0449 16.0089 13.4469 18.6069 10.2422 18.6069C7.03745 18.6069 4.43945 16.0089 4.43945 12.8041Z",fill:"currentColor"}))}function f9(t="icon-sm"){return m("svg",{className:t,width:20,height:20,viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},m("path",{d:cs,fill:"currentColor"}))}function $s(t="icon-sm"){return m("svg",{className:t,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},m("path",{fillRule:"evenodd",clipRule:"evenodd",d:"M5.69336 11.0557C7.05891 11.1944 8.12484 12.3479 8.125 13.75L8.11035 14.0273C7.97144 15.3928 6.81814 16.459 5.41602 16.459L5.13965 16.4443C3.86514 16.3149 2.85128 15.3018 2.72168 14.0273L2.70801 13.75C2.70818 12.2546 3.92061 11.0423 5.41602 11.042L5.69336 11.0557ZM5.41602 12.3721C4.65515 12.3724 4.03826 12.9891 4.03809 13.75C4.03826 14.5109 4.65515 15.1286 5.41602 15.1289C6.17714 15.1289 6.79475 14.5111 6.79492 13.75C6.79475 12.9889 6.17714 12.3721 5.41602 12.3721Z",fill:"currentColor"}),m("path",{d:"M16.8008 13.0986C17.1036 13.1608 17.3311 13.4288 17.3311 13.75C17.3311 14.0712 17.1036 14.3392 16.8008 14.4014L16.666 14.415H10.833C10.4659 14.4149 10.168 14.1172 10.168 13.75C10.168 13.3828 10.4659 13.0851 10.833 13.085H16.666L16.8008 13.0986Z",fill:"currentColor"}),m("path",{d:"M16.8008 5.59863C17.1036 5.66081 17.3311 5.92879 17.3311 6.25C17.3311 6.57121 17.1036 6.83919 16.8008 6.90137L16.666 6.91504H10.833C10.4659 6.91491 10.168 6.61719 10.168 6.25C10.168 5.88281 10.4659 5.58509 10.833 5.58496H16.666L16.8008 5.59863Z",fill:"currentColor"}),m("path",{d:"M7.13311 3.76578C7.35346 3.47216 7.771 3.4128 8.06475 3.63297C8.35843 3.85336 8.41789 4.27084 8.19757 4.56461L5.19757 8.56461C5.0819 8.71866 4.90439 8.81462 4.71221 8.82828C4.5201 8.84178 4.33083 8.77209 4.19464 8.6359L2.69464 7.1359C2.43512 6.87623 2.43512 6.45415 2.69464 6.19449C2.95429 5.93484 3.37633 5.93493 3.63604 6.19449L4.59307 7.15152L7.13311 3.76578Z",fill:"currentColor"}))}function d9(t="icon-sm"){return m("svg",{className:t,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},m("path",{d:"M12.6683 4.16699C12.6683 3.84391 12.4065 3.58203 12.0834 3.58203C11.7603 3.58203 11.4984 3.84391 11.4984 4.16699V7.91699L11.4847 8.05078C11.4227 8.35375 11.1547 8.58203 10.8334 8.58203C10.4662 8.58203 10.1685 8.28411 10.1683 7.91699V3.75C10.1683 3.42691 9.90646 3.16504 9.58337 3.16504C9.26029 3.16504 8.99841 3.42691 8.99841 3.75V7.91699C8.99824 8.28411 8.70053 8.58203 8.33337 8.58203C7.96621 8.58203 7.66851 8.28411 7.66833 7.91699V5C7.66833 4.67691 7.40646 4.41504 7.08337 4.41504C6.76029 4.41504 6.49841 4.67691 6.49841 5V9.30371C6.53326 9.3429 6.56715 9.38359 6.59998 9.42578L8.02478 11.2588C8.25005 11.5486 8.19821 11.9659 7.90857 12.1914C7.6187 12.4169 7.20048 12.365 6.97498 12.0752L5.55017 10.2432C5.15812 9.7391 4.41813 9.73637 4.01501 10.1924C4.04396 10.426 4.11486 10.8323 4.25525 11.3486C4.44664 12.0525 4.75404 12.9113 5.21619 13.7383C6.14103 15.3931 7.62465 16.835 10.0004 16.835C12.8545 16.8348 15.1682 14.5211 15.1683 11.667V6.25C15.1683 5.92691 14.9065 5.66504 14.5834 5.66504C14.2603 5.66504 13.9984 5.92691 13.9984 6.25V9.16699C13.9982 9.53411 13.7005 9.83203 13.3334 9.83203C12.9662 9.83203 12.6685 9.53411 12.6683 9.16699V4.16699ZM13.9984 4.42578C14.1828 4.36671 14.3794 4.33496 14.5834 4.33496C15.641 4.33496 16.4984 5.19237 16.4984 6.25V11.667C16.4982 15.2557 13.589 18.1649 10.0004 18.165C6.95953 18.165 5.10939 16.2734 4.05505 14.3867C3.52774 13.4431 3.1843 12.4787 2.97205 11.6982C2.76447 10.9349 2.66834 10.2954 2.66833 10C2.66833 9.87959 2.70117 9.76148 2.76306 9.6582C3.28988 8.78018 4.26555 8.40372 5.16833 8.56152V5C5.16833 3.94237 6.02575 3.08496 7.08337 3.08496C7.31706 3.08496 7.54039 3.12845 7.74744 3.20508C7.98218 2.41297 8.7151 1.83496 9.58337 1.83496C10.1836 1.83496 10.7186 2.11176 11.0697 2.54395C11.3639 2.35978 11.7107 2.25195 12.0834 2.25195C13.141 2.25195 13.9984 3.10937 13.9984 4.16699V4.42578Z",fill:"currentColor"}))}function h9(t="icon-sm"){return m("svg",{className:t,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},m("path",{fillRule:"evenodd",clipRule:"evenodd",d:"M9.06543 1.95123C9.66107 1.69076 10.3389 1.69071 10.9346 1.95123L15.9346 4.13873C16.7832 4.51008 17.3311 5.34917 17.3311 6.27545V10.5528C17.3309 14.6017 14.0489 17.8847 10 17.8848C5.95108 17.8846 2.66813 14.6017 2.66797 10.5528V6.27545C2.66797 5.34924 3.21695 4.51012 4.06543 4.13873L9.06543 1.95123ZM10.4014 3.16998C10.1456 3.05814 9.85444 3.05819 9.59863 3.16998L4.59863 5.35748C4.23427 5.51708 3.99805 5.87764 3.99805 6.27545V10.5528C3.99821 13.8671 6.68563 16.5546 10 16.5547C13.3144 16.5546 16.0008 13.8671 16.001 10.5528V6.27545C16.001 5.87756 15.7658 5.51703 15.4014 5.35748L10.4014 3.16998Z",fill:"currentColor"}),m("path",{d:"M13.4678 11.4318L13.333 11.4182H10.833C10.466 11.4183 10.1682 11.7162 10.168 12.0832C10.168 12.4504 10.4659 12.7481 10.833 12.7482H13.333L13.4678 12.7346C13.7706 12.6724 13.9981 12.4044 13.9981 12.0832C13.9979 11.7621 13.7706 11.494 13.4678 11.4318Z",fill:"currentColor"}),m("path",{d:"M7.65336 12.426C7.46431 12.7406 7.05607 12.8424 6.74125 12.6535C6.42646 12.4646 6.32395 12.0563 6.51274 11.7414L7.55668 10.0002L6.51274 8.25899C6.32395 7.94412 6.42646 7.53583 6.74125 7.34688C7.05607 7.15799 7.46431 7.25975 7.65336 7.57442L8.90336 9.6584C9.0296 9.86893 9.0296 10.1315 8.90336 10.342L7.65336 12.426Z",fill:"currentColor"}))}function p9(t="icon-sm"){return m("svg",{className:t,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},m("path",{fillRule:"evenodd",clipRule:"evenodd",d:"M9.06543 1.95123C9.66107 1.69076 10.3389 1.69071 10.9346 1.95123L15.9346 4.13873C16.7832 4.51008 17.3311 5.34917 17.3311 6.27545V10.5528C17.3309 14.6017 14.0489 17.8847 10 17.8848C5.95108 17.8846 2.66813 14.6017 2.66797 10.5528V6.27545C2.66797 5.34924 3.21695 4.51012 4.06543 4.13873L9.06543 1.95123ZM10.4014 3.16998C10.1456 3.05814 9.85444 3.05819 9.59863 3.16998L4.59863 5.35748C4.23427 5.51708 3.99805 5.87764 3.99805 6.27545V10.5528C3.99821 13.8671 6.68563 16.5546 10 16.5547C13.3144 16.5546 16.0008 13.8671 16.001 10.5528V6.27545C16.001 5.87756 15.7658 5.51703 15.4014 5.35748L10.4014 3.16998Z",fill:"currentColor"}),m("path",{d:"M10.8883 13.1116C10.8883 13.6025 10.4903 14.0005 9.99936 14.0005C9.50844 14.0005 9.11047 13.6025 9.11047 13.1116C9.11047 12.6207 9.50844 12.2227 9.99936 12.2227C10.4903 12.2227 10.8883 12.6207 10.8883 13.1116Z",fill:"currentColor"}),m("path",{d:"M10.5169 10.8949L11.1135 7.31519C11.2283 6.62672 10.6974 6 9.99941 6C9.30145 6 8.77053 6.62672 8.88528 7.31519L9.4819 10.8949C9.52406 11.1479 9.74294 11.3333 9.99941 11.3333C10.2559 11.3333 10.4748 11.1479 10.5169 10.8949Z",fill:"currentColor"}))}function m9(t="icon-sm"){return m("svg",{className:t,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},m("path",{fillRule:"evenodd",clipRule:"evenodd",d:"M9.99944 7.24939C11.5169 7.2495 12.7473 8.47995 12.7475 9.99744C12.7475 11.5151 11.517 12.7454 9.99944 12.7455C8.48176 12.7455 7.2514 11.5151 7.2514 9.99744C7.25155 8.47988 8.48186 7.24939 9.99944 7.24939ZM9.99944 8.57947C9.2164 8.57947 8.58163 9.21442 8.58148 9.99744C8.58148 10.7806 9.2163 11.4154 9.99944 11.4154C10.7825 11.4153 11.4174 10.7805 11.4174 9.99744C11.4173 9.21449 10.7824 8.57958 9.99944 8.57947Z",fill:"currentColor"}),m("path",{fillRule:"evenodd",clipRule:"evenodd",d:"M10.6391 1.67517C11.2939 1.67532 11.8991 2.02577 12.226 2.59314L13.2485 4.36755H15.2963C15.9505 4.36758 16.555 4.71709 16.8823 5.28357L17.5219 6.39001C17.8489 6.95668 17.8481 7.65542 17.5209 8.22205L16.4975 9.99451L17.5239 11.7689C17.8519 12.3357 17.8521 13.0347 17.5248 13.6019L16.8862 14.7084C16.559 15.2747 15.9543 15.6243 15.3002 15.6244H13.2514L12.2299 17.3988C11.9029 17.9663 11.297 18.3168 10.642 18.3168L9.3637 18.3158C8.71064 18.3155 8.10718 17.9678 7.77972 17.4027L6.74847 15.6234L4.69964 15.6244C4.04558 15.6242 3.44087 15.2747 3.1137 14.7084L2.47503 13.6019C2.14791 13.0349 2.14836 12.3366 2.47601 11.7699L3.50237 9.99548L2.47894 8.22205C2.15175 7.65533 2.15174 6.95673 2.47894 6.39001L3.11761 5.28259C3.44458 4.71663 4.04894 4.36813 4.70257 4.36755L6.75042 4.36658L7.77581 2.59119C8.10301 2.02476 8.7076 1.67527 9.36175 1.67517H10.6391ZM9.36273 3.00623C9.1835 3.00623 9.01679 3.10199 8.92718 3.2572L7.82659 5.16345C7.63652 5.49253 7.28473 5.69529 6.90472 5.69568L4.70355 5.69763C4.52451 5.69782 4.3585 5.79355 4.26898 5.94861L3.6303 7.05505C3.54091 7.2102 3.54077 7.40192 3.6303 7.55701L4.73089 9.46326C4.92108 9.7929 4.92135 10.1992 4.73089 10.5287L3.62737 12.4359C3.5378 12.591 3.53792 12.7817 3.62737 12.9369L4.26605 14.0433C4.35567 14.1982 4.52067 14.2932 4.69964 14.2933L6.90276 14.2943C7.28242 14.2946 7.63335 14.497 7.82366 14.8256L8.93011 16.7357C9.01984 16.8905 9.18578 16.9857 9.36468 16.9857H10.642C10.8213 16.9857 10.987 16.89 11.0766 16.7347L12.1752 14.8275C12.3653 14.4975 12.7182 14.2943 13.0991 14.2943H15.3002C15.4794 14.2942 15.6452 14.1985 15.7348 14.0433L16.3725 12.9379C16.4621 12.7826 16.4621 12.5911 16.3725 12.4359L15.27 10.5287C15.1032 10.2404 15.0808 9.89331 15.2055 9.59021L15.269 9.46326L16.3696 7.55701C16.4591 7.40189 16.459 7.21022 16.3696 7.05505L15.7309 5.94861C15.6412 5.79363 15.4754 5.69863 15.2963 5.69861L13.0951 5.69763L12.9535 5.68884C12.6751 5.65158 12.4217 5.50519 12.2504 5.28259L12.1723 5.16443L11.0737 3.2572C10.9841 3.10175 10.8175 3.00525 10.6381 3.00525L9.36273 3.00623Z",fill:"currentColor"}))}function T3(t="icon-sm"){return m("svg",{className:t,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},m("path",{d:"M10.6878 9.46029L10.8421 9.49545L17.2913 11.43L17.4642 11.4974C18.2215 11.8649 18.2705 12.9544 17.5492 13.388L17.3822 13.4701L14.5872 14.5872L13.4701 17.3822C13.1135 18.2734 11.8913 18.2756 11.4974 17.4642L11.43 17.2913L9.49544 10.8421C9.26342 10.0687 9.92452 9.34418 10.6878 9.46029ZM12.4984 16.2288L13.3929 13.9954L13.4388 13.8949C13.5579 13.6675 13.7549 13.4891 13.9954 13.3929L16.2288 12.4984L10.9007 10.9007L12.4984 16.2288ZM5.90365 12.9749C6.16329 12.7153 6.58436 12.7154 6.84408 12.9749C7.10378 13.2346 7.10378 13.6557 6.84408 13.9154L5.0765 15.6829C4.8168 15.9426 4.39577 15.9426 4.13607 15.6829C3.87654 15.4232 3.87643 15.0022 4.13607 14.7425L5.90365 12.9749ZM2.83724 7.3265L5.25228 7.97299L5.37826 8.02084C5.65484 8.1591 5.80597 8.47712 5.72298 8.78744C5.63984 9.09774 5.34997 9.298 5.04134 9.27963L4.90853 9.25814L2.49349 8.61068L2.36752 8.56283C2.09082 8.42452 1.93961 8.10666 2.02279 7.79623C2.10599 7.4859 2.39574 7.28652 2.70443 7.30502L2.83724 7.3265ZM14.847 4.05111C15.1051 3.88059 15.4556 3.90894 15.6829 4.13607C15.9426 4.39577 15.9426 4.8168 15.6829 5.0765L13.9154 6.84408C13.6557 7.10378 13.2346 7.10378 12.9749 6.84408C12.7154 6.58437 12.7153 6.16329 12.9749 5.90365L14.7425 4.13607L14.847 4.05111ZM7.79623 2.02279C8.15098 1.92773 8.51562 2.13874 8.61068 2.49349L9.25814 4.90853L9.27962 5.04135C9.298 5.34998 9.09774 5.63984 8.78744 5.72299C8.47713 5.80592 8.15908 5.65484 8.02084 5.37826L7.97298 5.25228L7.3265 2.83724L7.30502 2.70443C7.28652 2.39577 7.48595 2.10603 7.79623 2.02279Z",fill:"currentColor"}))}var E3=document.getElementById("root");E3&&(Qf(),(0,A3.createRoot)(E3).render(N3.default.createElement(x3))); /*! Bundled license information: react/cjs/react.production.js: diff --git a/Resources/agent-session-react/index.html b/Resources/agent-session-react/index.html index b4fa81f258f7..0dfe648455f8 100644 --- a/Resources/agent-session-react/index.html +++ b/Resources/agent-session-react/index.html @@ -24,56 +24,56 @@ diff --git a/Resources/markdown-viewer/webviews-app/chunks/agentSessionSurface.mjs b/Resources/markdown-viewer/webviews-app/chunks/agentSessionSurface.mjs index 5ec667e582f0..a94d79e0f5b6 100644 --- a/Resources/markdown-viewer/webviews-app/chunks/agentSessionSurface.mjs +++ b/Resources/markdown-viewer/webviews-app/chunks/agentSessionSurface.mjs @@ -1,42 +1,42 @@ -import{R as rt,i as b,b as Et,c as Ys,j as Pr,a as Gs}from"./vendor.mjs";import{m as lt,i as Xs,c as Qs}from"./installWebviewStyles.mjs";function ea(n){if(n.activityStatus==="stopped"||n.activityStatus==="failed")return"!";switch(n.activityKind){case"command":return"$";case"fileChange":return"+";default:return"*"}}const ta={isDark:null,pageBackground:"--agent-page-bg",surfaceBackground:"--agent-surface",surfaceElevatedBackground:"--agent-surface-elevated",inputBackground:"--agent-input-bg",border:"--agent-border",borderStrong:"--agent-border-strong",text:"--agent-text",mutedText:"--agent-muted",softText:"--agent-soft",accent:"--agent-accent",accentSoft:"--agent-accent-soft",danger:"--agent-danger",shadow:"--agent-shadow"};function Wn(n){if(typeof document>"u")return;const e=document.documentElement;e.dataset.theme=n.isDark?"dark":"light",jo(),e.classList.toggle("dark",n.isDark),e.classList.toggle("electron-dark",n.isDark),e.classList.toggle("light",!n.isDark),e.style.colorScheme=n.isDark?"dark":"light";for(const[t,r]of Object.entries(ta))r&&e.style.setProperty(r,String(n[t]))}function jo(){if(typeof document>"u")return;const n=document.documentElement;n.dataset.codexWindowType="electron",n.dataset.windowType="electron",n.dataset.codexOs=na(),document.body&&(document.body.dataset.codexWindowType="electron")}function na(){if(typeof navigator>"u")return"unknown";const n=navigator,e=(n.userAgentData?.platform??n.platform??n.userAgent).toLowerCase();return e.includes("win")?"win32":e.includes("mac")||e.includes("darwin")?"darwin":e.includes("linux")?"linux":"unknown"}const Hn=new Set;class Ko extends Error{code;constructor(e,t){super(e),this.name="NativeBridgeError",this.code=t}}typeof window<"u"&&(window.cmuxAgentBridge={applyTheme(n){Wn(n)},receive(n){n.type==="app.theme"&&Wn(n.theme);for(const e of Hn)e(n)}});function ra(n){return Hn.add(n),()=>{Hn.delete(n)}}async function qe(n,e={}){if(typeof window>"u")throw new Error("Native bridge is unavailable.");const t=window.webkit?.messageHandlers?.agentSession;if(!t||typeof t.postMessage!="function")throw new Error("Native bridge is unavailable.");const r=await t.postMessage({id:lt(),method:n,params:e});if(!r.ok)throw new Ko(r.error?.userMessage||"Native bridge request failed.",r.error?.code);return r.value}const ee="border-token-border user-select-none no-drag cursor-interaction flex items-center gap-1 border whitespace-nowrap focus:outline-none disabled:cursor-not-allowed disabled:opacity-40",te="text-token-text-tertiary enabled:hover:bg-token-list-hover-background data-[state=open]:bg-token-list-hover-background border-transparent",Oe="h-token-button-composer px-2 py-0 text-sm leading-[18px]",oa="h-token-button-composer-sm px-1.5 py-0 text-sm leading-[18px]",ln="electron:p-1 electron:[&>svg]:icon-sm flex items-center justify-center p-0.5",Kt="aspect-square items-center justify-center !px-0",ia="focus-visible:outline-token-button-background cursor-interaction size-token-button-composer flex items-center justify-center rounded-full p-0.5 transition-opacity focus-visible:outline-2 bg-token-foreground",sa="agent-composer-stack",aa="codex-composer-frame relative",la="codex-composer-inner relative z-10 flex min-h-0 flex-1 flex-col",ca="codex-composer-surface relative flex flex-col bg-token-input-background/90 backdrop-blur-lg extension:border extension:border-token-border/50 electron:ring electron:ring-black/10 electron:shadow-[0_4px_16px_0_rgba(0,0,0,0.05)] electron:dark:bg-token-dropdown-background",da="composer-footer grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 px-2 py-1",ua="composer-footer grid grid-cols-[minmax(0,auto)_auto_minmax(0,1fr)] items-center gap-[5px] mb-2 px-2",Zo="M16.6182 9.33203H3.38184V12.7002C3.38184 13.3753 3.38238 13.8438 3.41211 14.208C3.44124 14.5646 3.49494 14.766 3.57129 14.916L3.63867 15.0361C3.80618 15.3094 4.04683 15.5324 4.33399 15.6787L4.45703 15.7314C4.59362 15.7803 4.77411 15.816 5.04199 15.8379C5.40624 15.8676 5.87469 15.8682 6.54981 15.8682H13.4502C14.1253 15.8682 14.5938 15.8676 14.958 15.8379C15.3146 15.8088 15.516 15.7551 15.666 15.6787L15.7861 15.6113C16.0594 15.4438 16.2824 15.2032 16.4287 14.916L16.4814 14.793C16.5303 14.6564 16.566 14.4759 16.5879 14.208C16.6176 13.8438 16.6182 13.3753 16.6182 12.7002V9.33203ZM17.8818 12.7002C17.8818 13.3547 17.8826 13.8838 17.8477 14.3115C17.8165 14.6922 17.7543 15.0349 17.6172 15.3545L17.5537 15.4902C17.3015 15.9852 16.9182 16.3996 16.4473 16.6885L16.2402 16.8037C15.8824 16.9861 15.4966 17.0621 15.0615 17.0977C14.6338 17.1326 14.1047 17.1318 13.4502 17.1318H6.54981C5.89526 17.1318 5.36616 17.1326 4.93848 17.0977C4.55777 17.0665 4.21506 17.0043 3.89551 16.8672L3.75977 16.8037C3.26483 16.5515 2.85036 16.1682 2.56152 15.6973L2.44629 15.4902C2.26394 15.1324 2.1879 14.7466 2.15235 14.3115C2.1174 13.8838 2.11817 13.3547 2.11817 12.7002V7.29981C2.11817 6.64526 2.1174 6.11616 2.15235 5.68848C2.1879 5.25344 2.26394 4.86765 2.44629 4.50977L2.56152 4.30274C2.85036 3.83179 3.26483 3.44854 3.75977 3.19629L3.89551 3.13281C4.21506 2.99571 4.55777 2.93346 4.93848 2.90235C5.36616 2.8674 5.89526 2.86817 6.54981 2.86817H7.24512C7.38876 2.86816 7.48717 2.86807 7.58399 2.87402L7.83496 2.90039C8.41501 2.98537 8.96006 3.23832 9.40039 3.63086L9.64356 3.86817C9.75546 3.98103 9.79343 4.0181 9.83008 4.05078L9.94238 4.14356C10.2142 4.34787 10.5413 4.46917 10.8828 4.49024L11.1445 4.49317H13.4502C14.1047 4.49317 14.6338 4.4924 15.0615 4.52735C15.4966 4.5629 15.8824 4.63894 16.2402 4.82129L16.4473 4.93652C16.9182 5.22536 17.3015 5.63983 17.5537 6.13477L17.6172 6.27051C17.7543 6.59006 17.8165 6.93277 17.8477 7.31348C17.8826 7.74116 17.8818 8.27026 17.8818 8.92481V12.7002ZM3.38184 8.06836H16.6143C16.6105 7.81516 16.603 7.60256 16.5879 7.41699C16.566 7.14911 16.5303 6.96862 16.4814 6.83203L16.4287 6.70899C16.2824 6.42183 16.0594 6.18118 15.7861 6.01367L15.666 5.94629C15.516 5.86994 15.3146 5.81624 14.958 5.78711C14.5938 5.75738 14.1253 5.75684 13.4502 5.75684H11.1445L10.8047 5.75098C10.2158 5.71466 9.65236 5.50645 9.1836 5.1543L8.98926 4.99414C8.91673 4.92948 8.84746 4.85908 8.7461 4.75684L8.55957 4.57422C8.30416 4.34653 7.98784 4.19959 7.65137 4.15039L7.50684 4.13477C7.45779 4.13174 7.4043 4.13184 7.24512 4.13184H6.54981C5.87469 4.13184 5.40624 4.13238 5.04199 4.16211C4.77411 4.184 4.59362 4.21966 4.45703 4.26856L4.33399 4.32129C4.04683 4.4676 3.80618 4.69061 3.63867 4.96387L3.57129 5.08399C3.49494 5.23405 3.44124 5.43543 3.41211 5.79199C3.38238 6.15624 3.38184 6.62469 3.38184 7.29981V8.06836Z",pa=32;function fa(n){return n.hasVisibleAttachments||n.isEditorMultiline||n.isVoiceLayoutActive?!1:n.singleLineInputWidth==null?!0:n.singleLineTextWidth+pa<=n.singleLineInputWidth}function Yo(n){const e={};for(const t of n)e[t.id]={hideControl:!1,hideLabel:!1};return e}function ha(n){const e=n.items.filter(s=>s.enabled),t=Yo(n.items);if(e.length===0||n.availableWidth<=0)return t;const r=e.reduce((s,a)=>s+a.expandedWidth,0)+n.gap*Math.max(0,e.length-1);if(r<=0)return t;let o=r;for(const s of e)!s.canHideLabel||n.availableWidth>=o||(t[s.id].hideLabel=!0,o-=s.expandedWidth-s.compactWidth);if(ga(t,n.previousState,"hideLabel"))return t;let i=e.length;for(const s of e){const a=t[s.id],l=a.hideLabel?s.compactWidth:s.expandedWidth;(a.hideLabel&&!s.hasMeasuredCompactWidth||n.availableWidth1?n.gap:0),i-=1)}return t}function ma(n,e){const t=Object.keys(n),r=Object.keys(e);if(t.length!==r.length)return!1;for(const o of t)if(n[o]?.hideControl!==e[o]?.hideControl||n[o]?.hideLabel!==e[o]?.hideLabel)return!1;return!0}function ga(n,e,t){const r=Object.keys(n);return r.length!==Object.keys(e).length?!0:r.some(o=>n[o]?.[t]!==e[o]?.[t])}const xa=new Set(["base","embed","form","iframe","link","meta","object","script","style"]),ba=new Set(["poster","src","srcset","xlink:href"]);function ya(n){const e=typeof window>"u"?void 0:window.marked;if(e?.parse)try{const t=e.parse(ka(n),{async:!1,breaks:!0,gfm:!0});if(typeof t=="string")return Ma(t)}catch{return Be(n)}return Be(n)}function ka(n){let e="",t=null;const r=n.match(/[^\r\n]*(?:\r\n|\n|\r|$)/g)??[];for(const o of r){if(o==="")continue;const i=o.match(/(\r\n|\n|\r)$/)?.[0]??"",s=i?o.slice(0,-i.length):o;if(t){e+=s+i,Ca(s,t)&&(t=null);continue}const a=va(s);if(a){t=a,e+=s+i;continue}e+=Sa(s)+i}return e}function Be(n){return wa(n).replace(/\n/g,"
")}function wa(n){return n.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function va(n){const e=/^( {0,3})(`{3,}|~{3,})/.exec(n);if(!e)return null;const t=e[2];return{character:t[0],length:t.length}}function Ca(n,e){const t=/^( {0,3})(`{3,}|~{3,})\s*$/.exec(n);if(!t)return!1;const r=t[2];return r[0]===e.character&&r.length>=e.length}function Sa(n){let e="",t=0,r=0;for(;r"u")return n;const e=document.createElement("template");e.innerHTML=n;for(const t of Array.from(e.content.querySelectorAll("*"))){if(xa.has(t.localName)){t.remove();continue}for(const r of Array.from(t.attributes)){const o=r.name.toLowerCase();if(o.startsWith("on")||o==="srcdoc"||o==="style"){t.removeAttribute(r.name);continue}const i=Na(t.localName,o,r.value);i===null?t.removeAttribute(r.name):typeof i=="string"&&i!==r.value&&t.setAttribute(r.name,i)}t.localName==="a"&&t.setAttribute("rel","noreferrer")}return e.innerHTML}function Na(n,e,t){const r=e.toLowerCase();if(ba.has(r))return null;if(r==="href")return n.toLowerCase()!=="a"?null:Oa(t)?t:null}function Oa(n){const e=n.trim();if(e.startsWith("#"))return!0;if(e.startsWith("/")||!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(e))return!1;try{const t=new URL(e);return t.protocol==="http:"||t.protocol==="https:"||t.protocol==="mailto:"}catch{return!1}}function Go(n){return n.replace(/([\\[\]])/g,"\\$1")}function Xo(n){return encodeURI(n).replace(/([\\()])/g,"\\$1")}function Ta(n,e){const t=e.map(r=>`[${Go(r.label)}](${Xo(r.path)})`).join(" ");return t?n.trim().length>0?`${t} +import{R as it,i as b,b as Rt,c as Qs,j as zr,a as ea}from"./vendor.mjs";import{m as dt,i as ta,c as na}from"./installWebviewStyles.mjs";function ra(n){if(n.activityStatus==="stopped"||n.activityStatus==="failed")return"!";switch(n.activityKind){case"command":return"$";case"fileChange":return"+";default:return"*"}}const oa={isDark:null,pageBackground:"--agent-page-bg",surfaceBackground:"--agent-surface",surfaceElevatedBackground:"--agent-surface-elevated",inputBackground:"--agent-input-bg",border:"--agent-border",borderStrong:"--agent-border-strong",text:"--agent-text",mutedText:"--agent-muted",softText:"--agent-soft",accent:"--agent-accent",accentSoft:"--agent-accent-soft",danger:"--agent-danger",shadow:"--agent-shadow"};function sr(n){if(typeof document>"u")return;const e=document.documentElement;e.dataset.theme=n.isDark?"dark":"light",Zo(),e.classList.toggle("dark",n.isDark),e.classList.toggle("electron-dark",n.isDark),e.classList.toggle("light",!n.isDark),e.style.colorScheme=n.isDark?"dark":"light";for(const[t,r]of Object.entries(oa))r&&e.style.setProperty(r,String(n[t]))}function Zo(){if(typeof document>"u")return;const n=document.documentElement;n.dataset.codexWindowType="electron",n.dataset.windowType="electron",n.dataset.codexOs=ia(),document.body&&(document.body.dataset.codexWindowType="electron")}function ia(){if(typeof navigator>"u")return"unknown";const n=navigator,e=(n.userAgentData?.platform??n.platform??n.userAgent).toLowerCase();return e.includes("win")?"win32":e.includes("mac")||e.includes("darwin")?"darwin":e.includes("linux")?"linux":"unknown"}const Yt=new Set,et=[],Fr=512;class Yo extends Error{code;constructor(e,t){super(e),this.name="NativeBridgeError",this.code=t}}typeof window<"u"&&(window.cmuxAgentBridge={applyTheme(n){sr(n)},receive:aa});function sa(n){if(Yt.add(n),et.length>0){const e=et.splice(0);for(const t of e)n(t)}return()=>{Yt.delete(n)}}function aa(n){if(n.type==="app.theme"&&sr(n.theme),Yt.size===0){et.push(n),et.length>Fr&&et.splice(0,et.length-Fr);return}for(const e of Yt)e(n)}async function qe(n,e={}){if(typeof window>"u")throw new Error("Native bridge is unavailable.");const t=window.webkit?.messageHandlers?.agentSession;if(!t||typeof t.postMessage!="function")throw new Error("Native bridge is unavailable.");const r=await t.postMessage({id:dt(),method:n,params:e});if(!r.ok)throw new Yo(r.error?.userMessage||"Native bridge request failed.",r.error?.code);return r.value}const ee="border-token-border user-select-none no-drag cursor-interaction flex items-center gap-1 border whitespace-nowrap focus:outline-none disabled:cursor-not-allowed disabled:opacity-40",te="text-token-text-tertiary enabled:hover:bg-token-list-hover-background data-[state=open]:bg-token-list-hover-background border-transparent",Oe="h-token-button-composer px-2 py-0 text-sm leading-[18px]",la="h-token-button-composer-sm px-1.5 py-0 text-sm leading-[18px]",dn="electron:p-1 electron:[&>svg]:icon-sm flex items-center justify-center p-0.5",Gt="aspect-square items-center justify-center !px-0",ca="focus-visible:outline-token-button-background cursor-interaction size-token-button-composer flex items-center justify-center rounded-full p-0.5 transition-opacity focus-visible:outline-2 bg-token-foreground",da="agent-composer-stack",ua="codex-composer-frame relative",pa="codex-composer-inner relative z-10 flex min-h-0 flex-1 flex-col",fa="codex-composer-surface relative flex flex-col bg-token-input-background/90 backdrop-blur-lg extension:border extension:border-token-border/50 electron:ring electron:ring-black/10 electron:shadow-[0_4px_16px_0_rgba(0,0,0,0.05)] electron:dark:bg-token-dropdown-background",ha="composer-footer grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 px-2 py-1",ma="composer-footer grid grid-cols-[minmax(0,auto)_auto_minmax(0,1fr)] items-center gap-[5px] mb-2 px-2",Go="M16.6182 9.33203H3.38184V12.7002C3.38184 13.3753 3.38238 13.8438 3.41211 14.208C3.44124 14.5646 3.49494 14.766 3.57129 14.916L3.63867 15.0361C3.80618 15.3094 4.04683 15.5324 4.33399 15.6787L4.45703 15.7314C4.59362 15.7803 4.77411 15.816 5.04199 15.8379C5.40624 15.8676 5.87469 15.8682 6.54981 15.8682H13.4502C14.1253 15.8682 14.5938 15.8676 14.958 15.8379C15.3146 15.8088 15.516 15.7551 15.666 15.6787L15.7861 15.6113C16.0594 15.4438 16.2824 15.2032 16.4287 14.916L16.4814 14.793C16.5303 14.6564 16.566 14.4759 16.5879 14.208C16.6176 13.8438 16.6182 13.3753 16.6182 12.7002V9.33203ZM17.8818 12.7002C17.8818 13.3547 17.8826 13.8838 17.8477 14.3115C17.8165 14.6922 17.7543 15.0349 17.6172 15.3545L17.5537 15.4902C17.3015 15.9852 16.9182 16.3996 16.4473 16.6885L16.2402 16.8037C15.8824 16.9861 15.4966 17.0621 15.0615 17.0977C14.6338 17.1326 14.1047 17.1318 13.4502 17.1318H6.54981C5.89526 17.1318 5.36616 17.1326 4.93848 17.0977C4.55777 17.0665 4.21506 17.0043 3.89551 16.8672L3.75977 16.8037C3.26483 16.5515 2.85036 16.1682 2.56152 15.6973L2.44629 15.4902C2.26394 15.1324 2.1879 14.7466 2.15235 14.3115C2.1174 13.8838 2.11817 13.3547 2.11817 12.7002V7.29981C2.11817 6.64526 2.1174 6.11616 2.15235 5.68848C2.1879 5.25344 2.26394 4.86765 2.44629 4.50977L2.56152 4.30274C2.85036 3.83179 3.26483 3.44854 3.75977 3.19629L3.89551 3.13281C4.21506 2.99571 4.55777 2.93346 4.93848 2.90235C5.36616 2.8674 5.89526 2.86817 6.54981 2.86817H7.24512C7.38876 2.86816 7.48717 2.86807 7.58399 2.87402L7.83496 2.90039C8.41501 2.98537 8.96006 3.23832 9.40039 3.63086L9.64356 3.86817C9.75546 3.98103 9.79343 4.0181 9.83008 4.05078L9.94238 4.14356C10.2142 4.34787 10.5413 4.46917 10.8828 4.49024L11.1445 4.49317H13.4502C14.1047 4.49317 14.6338 4.4924 15.0615 4.52735C15.4966 4.5629 15.8824 4.63894 16.2402 4.82129L16.4473 4.93652C16.9182 5.22536 17.3015 5.63983 17.5537 6.13477L17.6172 6.27051C17.7543 6.59006 17.8165 6.93277 17.8477 7.31348C17.8826 7.74116 17.8818 8.27026 17.8818 8.92481V12.7002ZM3.38184 8.06836H16.6143C16.6105 7.81516 16.603 7.60256 16.5879 7.41699C16.566 7.14911 16.5303 6.96862 16.4814 6.83203L16.4287 6.70899C16.2824 6.42183 16.0594 6.18118 15.7861 6.01367L15.666 5.94629C15.516 5.86994 15.3146 5.81624 14.958 5.78711C14.5938 5.75738 14.1253 5.75684 13.4502 5.75684H11.1445L10.8047 5.75098C10.2158 5.71466 9.65236 5.50645 9.1836 5.1543L8.98926 4.99414C8.91673 4.92948 8.84746 4.85908 8.7461 4.75684L8.55957 4.57422C8.30416 4.34653 7.98784 4.19959 7.65137 4.15039L7.50684 4.13477C7.45779 4.13174 7.4043 4.13184 7.24512 4.13184H6.54981C5.87469 4.13184 5.40624 4.13238 5.04199 4.16211C4.77411 4.184 4.59362 4.21966 4.45703 4.26856L4.33399 4.32129C4.04683 4.4676 3.80618 4.69061 3.63867 4.96387L3.57129 5.08399C3.49494 5.23405 3.44124 5.43543 3.41211 5.79199C3.38238 6.15624 3.38184 6.62469 3.38184 7.29981V8.06836Z",ga=32;function xa(n){return n.hasVisibleAttachments||n.isEditorMultiline||n.isVoiceLayoutActive?!1:n.singleLineInputWidth==null?!0:n.singleLineTextWidth+ga<=n.singleLineInputWidth}function Xo(n){const e={};for(const t of n)e[t.id]={hideControl:!1,hideLabel:!1};return e}function ba(n){const e=n.items.filter(s=>s.enabled),t=Xo(n.items);if(e.length===0||n.availableWidth<=0)return t;const r=e.reduce((s,a)=>s+a.expandedWidth,0)+n.gap*Math.max(0,e.length-1);if(r<=0)return t;let o=r;for(const s of e)!s.canHideLabel||n.availableWidth>=o||(t[s.id].hideLabel=!0,o-=s.expandedWidth-s.compactWidth);if(ka(t,n.previousState,"hideLabel"))return t;let i=e.length;for(const s of e){const a=t[s.id],l=a.hideLabel?s.compactWidth:s.expandedWidth;(a.hideLabel&&!s.hasMeasuredCompactWidth||n.availableWidth1?n.gap:0),i-=1)}return t}function ya(n,e){const t=Object.keys(n),r=Object.keys(e);if(t.length!==r.length)return!1;for(const o of t)if(n[o]?.hideControl!==e[o]?.hideControl||n[o]?.hideLabel!==e[o]?.hideLabel)return!1;return!0}function ka(n,e,t){const r=Object.keys(n);return r.length!==Object.keys(e).length?!0:r.some(o=>n[o]?.[t]!==e[o]?.[t])}const wa=new Set(["base","embed","form","iframe","link","meta","object","script","style"]),va=new Set(["poster","src","srcset","xlink:href"]);function Ca(n){const e=typeof window>"u"?void 0:window.marked;if(e?.parse)try{const t=e.parse(Sa(n),{async:!1,breaks:!0,gfm:!0});if(typeof t=="string")return Da(t)}catch{return Be(n)}return Be(n)}function Sa(n){let e="",t=null;const r=n.match(/[^\r\n]*(?:\r\n|\n|\r|$)/g)??[];for(const o of r){if(o==="")continue;const i=o.match(/(\r\n|\n|\r)$/)?.[0]??"",s=i?o.slice(0,-i.length):o;if(t){e+=s+i,Oa(s,t)&&(t=null);continue}const a=Na(s);if(a){t=a,e+=s+i;continue}e+=Ta(s)+i}return e}function Be(n){return Ma(n).replace(/\n/g,"
")}function Ma(n){return n.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function Na(n){const e=/^( {0,3})(`{3,}|~{3,})/.exec(n);if(!e)return null;const t=e[2];return{character:t[0],length:t.length}}function Oa(n,e){const t=/^( {0,3})(`{3,}|~{3,})\s*$/.exec(n);if(!t)return!1;const r=t[2];return r[0]===e.character&&r.length>=e.length}function Ta(n){let e="",t=0,r=0;for(;r"u")return n;const e=document.createElement("template");e.innerHTML=n;for(const t of Array.from(e.content.querySelectorAll("*"))){if(wa.has(t.localName)){t.remove();continue}for(const r of Array.from(t.attributes)){const o=r.name.toLowerCase();if(o.startsWith("on")||o==="srcdoc"||o==="style"){t.removeAttribute(r.name);continue}const i=Ea(t.localName,o,r.value);i===null?t.removeAttribute(r.name):typeof i=="string"&&i!==r.value&&t.setAttribute(r.name,i)}t.localName==="a"&&t.setAttribute("rel","noreferrer")}return e.innerHTML}function Ea(n,e,t){const r=e.toLowerCase();if(va.has(r))return null;if(r==="href")return n.toLowerCase()!=="a"?null:Ia(t)?t:null}function Ia(n){const e=n.trim();if(e.startsWith("#"))return!0;if(e.startsWith("/")||!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(e))return!1;try{const t=new URL(e);return t.protocol==="http:"||t.protocol==="https:"||t.protocol==="mailto:"}catch{return!1}}function Qo(n){return n.replace(/([\\[\]])/g,"\\$1")}function ei(n){return encodeURI(n).replace(/([\\()])/g,"\\$1")}function Aa(n,e){const t=e.map(r=>`[${Qo(r.label)}](${ei(r.path)})`).join(" ");return t?n.trim().length>0?`${t} -${n}`:t:n}function $t(n){switch(n.kind){case"at":return Sn(n.label??n.name,n.path);case"agent":return Sn(`@${n.displayName||n.name}`,n.path);case"skill":return Sn(`$${n.name}`,n.path)}}function Da(n,e,t){if(!t||e==null)return n;const r=$t(e);return n.includes(r)||n.includes(e.path)?n:n.trim().length>0?`${r} +${n}`:t:n}function qt(n){switch(n.kind){case"at":return Nn(n.label??n.name,n.path);case"agent":return Nn(`@${n.displayName||n.name}`,n.path);case"skill":return Nn(`$${n.name}`,n.path)}}function Ra(n,e,t){if(!t||e==null)return n;const r=qt(e);return n.includes(r)||n.includes(e.path)?n:n.trim().length>0?`${r} -${n}`:r}function Sn(n,e){return`[${Go(n)}](${Xo(e)})`}const Fr="[$plan](skill://plan)";function Ea(n,e){return!e||Aa(n)?n:n.trim().length>0?`${n} +${n}`:r}function Nn(n,e){return`[${Qo(n)}](${ei(e)})`}const Vr="[$plan](skill://plan)";function La(n,e){return!e||Pa(n)?n:n.trim().length>0?`${n} -${Fr}`:Fr}function Aa(n){return n.includes("skill://plan")||/(?:^|\s)\$plan(?:\s|$)/.test(n)}function Ra(n){return n?.id==="codex"?"GPT-5.5":n?.displayName??"GPT-5.5"}function Ia(n){const e=n.displayName,t=e.toLowerCase();return n.id==="claude"||t.includes("claude")?"Cl":n.id==="opencode"||t.includes("open")?"O":t==="pi"||t.includes(" pi")?"Pi":e.trim().slice(0,1).toUpperCase()||"C"}function La(n){const e=n.usedPercent,t=n.remainingPercent,r=e!=null&&Number.isFinite(e)?ht(e):ht(100-(t??0)),o=t!=null&&Number.isFinite(t)?ht(t):ht(100-r);return{...n,remainingPercent:o,usedPercent:r,windowDurationMins:Number.isFinite(n.windowDurationMins)?n.windowDurationMins:void 0}}function Qo(n){return Number.isFinite(n)?`${Math.round(ht(n))}%`:"100%"}function ei(n,e=new Date){if(n==null||!Number.isFinite(n))return null;const t=new Date(n*1e3);if(!Number.isFinite(t.getTime()))return null;const r=Math.floor((t.getTime()-e.getTime())/1e3);return r>0&&r<3600?new Intl.DateTimeFormat(void 0,{timeStyle:"short"}).format(t):Pa(t,e)?new Intl.DateTimeFormat(void 0,{timeStyle:"short"}).format(t):new Intl.DateTimeFormat(void 0,{month:"short",day:"numeric"}).format(t)}function ti(n,e,t){if(n==null||!Number.isFinite(n)||n<=0)return e;const r=Math.round(n);return Br(n,720*60)?t?.monthly??e:Br(n,10080)?t?.weekly??e:r>=1440?Mn(t?.daysFormat,Math.ceil(r/1440),e):r>=60?Mn(t?.hoursFormat,Math.ceil(r/60),e):Mn(t?.minutesFormat,Math.max(1,Math.ceil(r)),e)}function ht(n){return Number.isFinite(n)?Math.min(Math.max(n,0),100):100}function Br(n,e){return n>=e*.95&&n<=e*1.05}function Pa(n,e){return n.getFullYear()===e.getFullYear()&&n.getMonth()===e.getMonth()&&n.getDate()===e.getDate()}function Mn(n,e,t){const r=new Intl.NumberFormat(void 0,{maximumFractionDigits:0}).format(e);return n==null||n.length===0?t:n.replaceAll("%@",r).replaceAll("%d",r).replaceAll("{value}",r)}const Vr=256*1024,_r=64*1024,za=8*1024,$r=`[earlier assistant output truncated] -`,Wr=`[earlier command output truncated] -`,Fa=`[earlier log output truncated] -`;function Ba(n){return{selectedProviderId:"codex",status:"loading",input:"",providers:[],log:[],transcript:[],autoStartAttemptedProviderIds:[],seenSessionIds:[]}}function Va(n,e){switch(e.type){case"context":return Ga({...n,context:e.context,selectedProviderId:e.context.initialProviderId,status:"idle"});case"providers":return{...n,providers:e.providers};case"selectProvider":return ar(n)?{...n,selectedProviderId:e.providerId}:n;case"setInput":return{...n,input:e.input};case"autoStartAttempted":return n.autoStartAttemptedProviderIds.includes(e.providerId)?n:{...n,autoStartAttemptedProviderIds:[...n.autoStartAttemptedProviderIds,e.providerId]};case"starting":return{...n,status:"starting",log:de(n,"info",ue(n,"startingStatus","Starting"))};case"startAccepted":return n.status!=="starting"||n.runningSessionId?n:{...n,runningSessionId:e.sessionId,requestedStopSessionId:void 0,seenSessionIds:Wt(n,e.sessionId)};case"stopping":return{...n,status:"stopping",requestedStopSessionId:e.sessionId,log:de(n,"info",ue(n,"stoppingStatus","Stopping"))};case"failed":return{...n,status:"failed",log:de(n,"error",e.message),transcript:bt(n,e.message,"error")};case"failedForSession":case"sendFailed":return n.runningSessionId!==e.sessionId||n.requestedStopSessionId===e.sessionId?n:{...n,status:"failed",log:de(n,"error",e.message),transcript:bt(n,e.message,"error")};case"stopFailed":return n.runningSessionId!==e.sessionId&&n.requestedStopSessionId!==e.sessionId?n:{...n,requestedStopSessionId:void 0,status:"failed",log:de(n,"error",e.message),transcript:bt(n,e.message,"error")};case"sent":return n.runningSessionId!==e.sessionId||n.requestedStopSessionId===e.sessionId?n:{...n,input:n.input===e.submittedInput?"":n.input,log:de(n,"info",Zt(n,"sentCharsFormat","Sent %d chars",e.text.length)),transcript:Xa(n,e.displayText??e.text,e.attachments,e.sentAtMs)};case"event":return Za(n,e.event);default:return n}}async function _a(n){try{const[e,t]=await Promise.all([qe("app.context"),qe("provider.list")]);Wn(e.theme),n({type:"context",context:e}),n({type:"providers",providers:t})}catch(e){n({type:"failed",message:At(e)})}}async function $a(n,e){sr(n)&&await ri(ni(n),e)}function ni(n){return{providerId:n.selectedProviderId,workingDirectory:n.context?.workingDirectory,copy:n.context?.copy}}async function ri(n,e){e({type:"starting"});try{const t=await qe("provider.start",{providerId:n.providerId,workingDirectory:n.workingDirectory});e({type:"startAccepted",sessionId:t.sessionId})}catch(t){e({type:"failed",message:At(t,n.copy)})}}function Wa(n){return!sr(n)||n.autoStartAttemptedProviderIds.includes(n.selectedProviderId)?!1:n.providers.find(t=>t.id===n.selectedProviderId)?.autoStart===!0}async function Ha(n,e){if(!Wa(n))return;const t=n.selectedProviderId,r=ni(n);e({type:"autoStartAttempted",providerId:t}),await ri(r,e)}function qa(n,e,t){ar(e)&&(t({type:"selectProvider",providerId:n}),qe("provider.select",{providerId:n}).catch(()=>{}))}async function Ja(n,e,t={}){const r=t.text??n.input,o=t.clearInput??r;if(r.length===0||!n.runningSessionId||n.status!=="running")return!1;const i=n.runningSessionId;try{return await qe("provider.writeLine",{permissionMode:t.permissionMode??"default",sessionId:i,text:r}),e({type:"sent",attachments:t.attachments,displayText:t.displayText,sentAtMs:Date.now(),sessionId:i,text:r,submittedInput:o}),!0}catch(s){return nl(s)||e({type:"sendFailed",sessionId:i,message:At(s,n)}),!1}}async function Ua(n,e){if(!n.runningSessionId||n.status==="stopping")return;const t=n.runningSessionId;e({type:"stopping",sessionId:t});try{await qe("provider.stop",{sessionId:t})}catch(r){e({type:"stopFailed",sessionId:t,message:At(r,n)})}}function ja(n){switch(n.status){case"loading":return ue(n,"loadingStatus","Loading");case"idle":return ue(n,"idleStatus","Idle");case"starting":return ue(n,"startingStatus","Starting");case"running":return ue(n,"runningStatus","Running");case"stopping":return ue(n,"stoppingStatus","Stopping");case"failed":return ue(n,"failedStatus","Failed")}}function sr(n){return(n.status==="idle"||n.status==="failed")&&!n.runningSessionId&&!!n.context}function ar(n){return!n.runningSessionId&&n.status!=="starting"&&n.status!=="stopping"}function Ka(n){return!!n.runningSessionId&&n.status!=="stopping"}function Za(n,e){switch(e.type){case"app.theme":return n.context?{...n,context:{...n.context,theme:e.theme}}:n;case"app.rateLimitRows":return n.context?{...n,context:{...n.context,rateLimitRows:e.rateLimitRows}}:n;case"provider.started":return e.sessionId===n.requestedStopSessionId||n.runningSessionId&&e.sessionId!==n.runningSessionId||!n.runningSessionId&&n.status!=="starting"||!n.runningSessionId&&e.providerId!==n.selectedProviderId?n:{...n,runningSessionId:e.sessionId,requestedStopSessionId:void 0,seenSessionIds:Wt(n,e.sessionId),status:"running",log:de(n,"info",ue(n,"providerStarted","Provider started"))};case"provider.output":return e.sessionId!==n.runningSessionId?n:{...n,log:de(n,e.stream,e.text),transcript:Qa(n,e)};case"provider.activity":return e.sessionId!==n.runningSessionId?n:{...n,transcript:el(n,e)};case"provider.turnComplete":return e.sessionId!==n.runningSessionId?n:{...n,transcript:zt(n.transcript,e.sessionId)};case"provider.exit":return Ya(n,e)?e.sessionId===n.requestedStopSessionId?{...n,runningSessionId:void 0,requestedStopSessionId:void 0,seenSessionIds:Wt(n,e.sessionId),status:"idle",log:de(n,"info",ue(n,"stopped","Stopped")),transcript:zt(n.transcript,e.sessionId)}:{...n,runningSessionId:void 0,requestedStopSessionId:void 0,seenSessionIds:Wt(n,e.sessionId),status:e.status===0?"idle":"failed",log:de(n,e.status===0?"info":"error",Zt(n,"providerExitedFormat","Provider exited %d",e.status)),transcript:e.status===0?zt(n.transcript,e.sessionId):bt({...n,transcript:zt(n.transcript,e.sessionId)},Zt(n,"providerExitedFormat","Provider exited %d",e.status),"error")}:n;default:return n}}function Ya(n,e){return e.sessionId===n.runningSessionId?!0:!n.seenSessionIds.includes(e.sessionId)&&n.status==="starting"&&!n.runningSessionId&&e.providerId===n.selectedProviderId}function Wt(n,e){return n.seenSessionIds.includes(e)?n.seenSessionIds:[...n.seenSessionIds,e].slice(-50)}function Ga(n){const e=n.context?.renderer==="solid"?"Solid":"React";return{...n,log:de(n,"info",Zt(n,"rendererReadyFormat","%@ ready",e))}}function de(n,e,t){return[...n.log,{id:lt(),level:e,text:dn(t,za,Fa)}].slice(-300)}function Xa(n,e,t,r){return cn(n.transcript,{attachments:t,id:lt(),role:"user",sentAtMs:r,text:e})}function Qa(n,e){if(e.stream!=="stdout")return bt(n,e.text,"warning");const t=n.transcript.at(-1);return t?.role==="assistant"&&t.sessionId===e.sessionId?[...n.transcript.slice(0,-1),{...t,isComplete:!1,text:oi(t.text,e.text,Vr,$r)}]:cn(n.transcript,{id:lt(),role:"assistant",isComplete:!1,sessionId:e.sessionId,sentAtMs:Date.now(),text:dn(e.text,Vr,$r)})}function zt(n,e){let t=!1;const r=n.map(o=>o.role!=="assistant"||o.sessionId!==e||o.isComplete===!0?o:(t=!0,{...o,isComplete:!0}));return t?r:n}function el(n,e){const t=tl(n.transcript,e.sessionId,e.activityId);if(t>=0){const r=n.transcript[t];return[...n.transcript.slice(0,t),{...r,text:e.action,detail:e.detail??r.detail,activityKind:e.kind,activityStatus:e.status,output:e.outputDelta===void 0?r.output:oi(r.output??"",e.outputDelta,_r,Wr)},...n.transcript.slice(t+1)]}return cn(n.transcript,{id:lt(),role:"activity",text:e.action,detail:e.detail,sessionId:e.sessionId,activityId:e.activityId,activityKind:e.kind,activityStatus:e.status,output:e.outputDelta===void 0?void 0:dn(e.outputDelta,_r,Wr)})}function tl(n,e,t){for(let r=n.length-1;r>=0;r-=1){const o=n[r];if(o.role==="activity"&&o.sessionId===e&&o.activityId===t)return r}return-1}function bt(n,e,t){return cn(n.transcript,{id:lt(),role:"notice",tone:t,text:e})}function cn(n,e){return[...n,e].slice(-200)}function oi(n,e,t,r){const o=n.startsWith(r),i=n.startsWith(r)?n.slice(r.length):n;return o?r+(i+e).slice(-(t-r.length)):dn(i+e,t,r)}function dn(n,e,t){return n.length<=e?n:t+n.slice(-(e-t.length))}function ue(n,e,t){return n.context?.copy[e]??t}function nl(n){return n instanceof Ko&&n.code==="providerNotReady"}function Zt(n,e,t,...r){return rl(ue(n,e,t),r)}function rl(n,e){let t=0;return n.replace(/%(\d+\$)?[@d]/g,(r,o)=>{const i=o?Number(o.slice(0,-1))-1:t++;return String(e[i]??"")})}function At(n,e){const t=ol(e);return n instanceof Error&&n.message?t&&n.message==="Native bridge request failed."?t.requestFailed:n.message:t?.requestFailed??"Native bridge request failed."}function ol(n){if(n)return"providers"in n?n.context?.copy:n}function H(n){this.content=n}H.prototype={constructor:H,find:function(n){for(var e=0;e>1}};H.from=function(n){if(n instanceof H)return n;var e=[];if(n)for(var t in n)e.push(t,n[t]);return new H(e)};function ii(n,e,t){for(let r=0;;r++){if(r==n.childCount||r==e.childCount)return n.childCount==e.childCount?null:t;let o=n.child(r),i=e.child(r);if(o==i){t+=o.nodeSize;continue}if(!o.sameMarkup(i))return t;if(o.isText&&o.text!=i.text){for(let s=0;o.text[s]==i.text[s];s++)t++;return t}if(o.content.size||i.content.size){let s=ii(o.content,i.content,t+1);if(s!=null)return s}t+=o.nodeSize}}function si(n,e,t,r){for(let o=n.childCount,i=e.childCount;;){if(o==0||i==0)return o==i?null:{a:t,b:r};let s=n.child(--o),a=e.child(--i),l=s.nodeSize;if(s==a){t-=l,r-=l;continue}if(!s.sameMarkup(a))return{a:t,b:r};if(s.isText&&s.text!=a.text){let c=0,d=Math.min(s.text.length,a.text.length);for(;ce&&r(l,o+a,i||null,s)!==!1&&l.content.size){let d=a+1;l.nodesBetween(Math.max(0,e-d),Math.min(l.content.size,t-d),r,o+d)}a=c}}descendants(e){this.nodesBetween(0,this.size,e)}textBetween(e,t,r,o){let i="",s=!0;return this.nodesBetween(e,t,(a,l)=>{let c=a.isText?a.text.slice(Math.max(e,l)-l,t-l):a.isLeaf?o?typeof o=="function"?o(a):o:a.type.spec.leafText?a.type.spec.leafText(a):"":"";a.isBlock&&(a.isLeaf&&c||a.isTextblock)&&r&&(s?s=!1:i+=r),i+=c},0),i}append(e){if(!e.size)return this;if(!this.size)return e;let t=this.lastChild,r=e.firstChild,o=this.content.slice(),i=0;for(t.isText&&t.sameMarkup(r)&&(o[o.length-1]=t.withText(t.text+r.text),i=1);ie)for(let i=0,s=0;se&&((st)&&(a.isText?a=a.cut(Math.max(0,e-s),Math.min(a.text.length,t-s)):a=a.cut(Math.max(0,e-s-1),Math.min(a.content.size,t-s-1))),r.push(a),o+=a.nodeSize),s=l}return new y(r,o)}cutByIndex(e,t){return e==t?y.empty:e==0&&t==this.content.length?this:new y(this.content.slice(e,t))}replaceChild(e,t){let r=this.content[e];if(r==t)return this;let o=this.content.slice(),i=this.size+t.nodeSize-r.nodeSize;return o[e]=t,new y(o,i)}addToStart(e){return new y([e].concat(this.content),this.size+e.nodeSize)}addToEnd(e){return new y(this.content.concat(e),this.size+e.nodeSize)}eq(e){if(this.content.length!=e.content.length)return!1;for(let t=0;tthis.size||e<0)throw new RangeError(`Position ${e} outside of fragment (${this})`);for(let t=0,r=0;;t++){let o=this.child(t),i=r+o.nodeSize;if(i>=e)return i==e?Ft(t+1,i):Ft(t,r);r=i}}toString(){return"<"+this.toStringInner()+">"}toStringInner(){return this.content.join(", ")}toJSON(){return this.content.length?this.content.map(e=>e.toJSON()):null}static fromJSON(e,t){if(!t)return y.empty;if(!Array.isArray(t))throw new RangeError("Invalid input for Fragment.fromJSON");return y.fromArray(t.map(e.nodeFromJSON))}static fromArray(e){if(!e.length)return y.empty;let t,r=0;for(let o=0;othis.type.rank&&(t||(t=e.slice(0,o)),t.push(this),r=!0),t&&t.push(i)}}return t||(t=e.slice()),r||t.push(this),t}removeFromSet(e){for(let t=0;tr.type.rank-o.type.rank),t}}E.none=[];class Gt extends Error{}class w{constructor(e,t,r){this.content=e,this.openStart=t,this.openEnd=r}get size(){return this.content.size-this.openStart-this.openEnd}insertAt(e,t){let r=li(this.content,e+this.openStart,t,this.openStart+1,this.openEnd+1);return r&&new w(r,this.openStart,this.openEnd)}removeBetween(e,t){return new w(ai(this.content,e+this.openStart,t+this.openStart),this.openStart,this.openEnd)}eq(e){return this.content.eq(e.content)&&this.openStart==e.openStart&&this.openEnd==e.openEnd}toString(){return this.content+"("+this.openStart+","+this.openEnd+")"}toJSON(){if(!this.content.size)return null;let e={content:this.content.toJSON()};return this.openStart>0&&(e.openStart=this.openStart),this.openEnd>0&&(e.openEnd=this.openEnd),e}static fromJSON(e,t){if(!t)return w.empty;let r=t.openStart||0,o=t.openEnd||0;if(typeof r!="number"||typeof o!="number")throw new RangeError("Invalid input for Slice.fromJSON");return new w(y.fromJSON(e,t.content),r,o)}static maxOpen(e,t=!0){let r=0,o=0;for(let i=e.firstChild;i&&!i.isLeaf&&(t||!i.type.spec.isolating);i=i.firstChild)r++;for(let i=e.lastChild;i&&!i.isLeaf&&(t||!i.type.spec.isolating);i=i.lastChild)o++;return new w(e,r,o)}}w.empty=new w(y.empty,0,0);function ai(n,e,t){let{index:r,offset:o}=n.findIndex(e),i=n.maybeChild(r),{index:s,offset:a}=n.findIndex(t);if(o==e||i.isText){if(a!=t&&!n.child(s).isText)throw new RangeError("Removing non-flat range");return n.cut(0,e).append(n.cut(t))}if(r!=s)throw new RangeError("Removing non-flat range");return n.replaceChild(r,i.copy(ai(i.content,e-o-1,t-o-1)))}function li(n,e,t,r,o,i){let{index:s,offset:a}=n.findIndex(e),l=n.maybeChild(s);if(a==e||l.isText)return i&&r<=0&&o<=0&&!i.canReplace(s,s,t)?null:n.cut(0,e).append(t).append(n.cut(e));let c=li(l.content,e-a-1,t,s==0?r-1:0,s==n.childCount-1?o-1:0,l);return c&&n.replaceChild(s,l.copy(c))}function il(n,e,t){if(t.openStart>n.depth)throw new Gt("Inserted content deeper than insertion position");if(n.depth-t.openStart!=e.depth-t.openEnd)throw new Gt("Inconsistent open depths");return ci(n,e,t,0)}function ci(n,e,t,r){let o=n.index(r),i=n.node(r);if(o==e.index(r)&&r=0&&n.isText&&n.sameMarkup(e[t])?e[t]=n.withText(e[t].text+n.text):e.push(n)}function yt(n,e,t,r){let o=(e||n).node(t),i=0,s=e?e.index(t):o.childCount;n&&(i=n.index(t),n.depth>t?i++:n.textOffset&&(Ve(n.nodeAfter,r),i++));for(let a=i;ao&&qn(n,e,o+1),s=r.depth>o&&qn(t,r,o+1),a=[];return yt(null,n,o,a),i&&s&&e.index(o)==t.index(o)?(di(i,s),Ve(_e(i,ui(n,e,t,r,o+1)),a)):(i&&Ve(_e(i,Xt(n,e,o+1)),a),yt(e,t,o,a),s&&Ve(_e(s,Xt(t,r,o+1)),a)),yt(r,null,o,a),new y(a)}function Xt(n,e,t){let r=[];if(yt(null,n,t,r),n.depth>t){let o=qn(n,e,t+1);Ve(_e(o,Xt(n,e,t+1)),r)}return yt(e,null,t,r),new y(r)}function sl(n,e){let t=e.depth-n.openStart,o=e.node(t).copy(n.content);for(let i=t-1;i>=0;i--)o=e.node(i).copy(y.from(o));return{start:o.resolveNoCache(n.openStart+t),end:o.resolveNoCache(o.content.size-n.openEnd-t)}}class Ct{constructor(e,t,r){this.pos=e,this.path=t,this.parentOffset=r,this.depth=t.length/3-1}resolveDepth(e){return e==null?this.depth:e<0?this.depth+e:e}get parent(){return this.node(this.depth)}get doc(){return this.node(0)}node(e){return this.path[this.resolveDepth(e)*3]}index(e){return this.path[this.resolveDepth(e)*3+1]}indexAfter(e){return e=this.resolveDepth(e),this.index(e)+(e==this.depth&&!this.textOffset?0:1)}start(e){return e=this.resolveDepth(e),e==0?0:this.path[e*3-1]+1}end(e){return e=this.resolveDepth(e),this.start(e)+this.node(e).content.size}before(e){if(e=this.resolveDepth(e),!e)throw new RangeError("There is no position before the top-level node");return e==this.depth+1?this.pos:this.path[e*3-1]}after(e){if(e=this.resolveDepth(e),!e)throw new RangeError("There is no position after the top-level node");return e==this.depth+1?this.pos:this.path[e*3-1]+this.path[e*3].nodeSize}get textOffset(){return this.pos-this.path[this.path.length-1]}get nodeAfter(){let e=this.parent,t=this.index(this.depth);if(t==e.childCount)return null;let r=this.pos-this.path[this.path.length-1],o=e.child(t);return r?e.child(t).cut(r):o}get nodeBefore(){let e=this.index(this.depth),t=this.pos-this.path[this.path.length-1];return t?this.parent.child(e).cut(0,t):e==0?null:this.parent.child(e-1)}posAtIndex(e,t){t=this.resolveDepth(t);let r=this.path[t*3],o=t==0?0:this.path[t*3-1]+1;for(let i=0;i0;t--)if(this.start(t)<=e&&this.end(t)>=e)return t;return 0}blockRange(e=this,t){if(e.pos=0;r--)if(e.pos<=this.end(r)&&(!t||t(this.node(r))))return new cl(this,e,r);return null}sameParent(e){return this.pos-this.parentOffset==e.pos-e.parentOffset}max(e){return e.pos>this.pos?e:this}min(e){return e.pos=0&&t<=e.content.size))throw new RangeError("Position "+t+" out of range");let r=[],o=0,i=t;for(let s=e;;){let{index:a,offset:l}=s.content.findIndex(i),c=i-l;if(r.push(s,a,o+l),!c||(s=s.child(a),s.isText))break;i=c-1,o+=l+1}return new Ct(t,r,i)}static resolveCached(e,t){let r=Hr.get(e);if(r)for(let i=0;ie&&this.nodesBetween(e,t,i=>(r.isInSet(i.marks)&&(o=!0),!o)),o}get isBlock(){return this.type.isBlock}get isTextblock(){return this.type.isTextblock}get inlineContent(){return this.type.inlineContent}get isInline(){return this.type.isInline}get isText(){return this.type.isText}get isLeaf(){return this.type.isLeaf}get isAtom(){return this.type.isAtom}toString(){if(this.type.spec.toDebugString)return this.type.spec.toDebugString(this);let e=this.type.name;return this.content.size&&(e+="("+this.content.toStringInner()+")"),pi(this.marks,e)}contentMatchAt(e){let t=this.type.contentMatch.matchFragment(this.content,0,e);if(!t)throw new Error("Called contentMatchAt on a node with invalid content");return t}canReplace(e,t,r=y.empty,o=0,i=r.childCount){let s=this.contentMatchAt(e).matchFragment(r,o,i),a=s&&s.matchFragment(this.content,t);if(!a||!a.validEnd)return!1;for(let l=o;lt.type.name)}`);this.content.forEach(t=>t.check())}toJSON(){let e={type:this.type.name};for(let t in this.attrs){e.attrs=this.attrs;break}return this.content.size&&(e.content=this.content.toJSON()),this.marks.length&&(e.marks=this.marks.map(t=>t.toJSON())),e}static fromJSON(e,t){if(!t)throw new RangeError("Invalid input for Node.fromJSON");let r;if(t.marks){if(!Array.isArray(t.marks))throw new RangeError("Invalid mark data for Node.fromJSON");r=t.marks.map(e.markFromJSON)}if(t.type=="text"){if(typeof t.text!="string")throw new RangeError("Invalid text node in JSON");return e.text(t.text,r)}let o=y.fromJSON(e,t.content),i=e.nodeType(t.type).create(t.attrs,o,r);return i.type.checkAttrs(i.attrs),i}};$e.prototype.text=void 0;class Qt extends $e{constructor(e,t,r,o){if(super(e,t,null,o),!r)throw new RangeError("Empty text nodes are not allowed");this.text=r}toString(){return this.type.spec.toDebugString?this.type.spec.toDebugString(this):pi(this.marks,JSON.stringify(this.text))}get textContent(){return this.text}textBetween(e,t){return this.text.slice(e,t)}get nodeSize(){return this.text.length}mark(e){return e==this.marks?this:new Qt(this.type,this.attrs,this.text,e)}withText(e){return e==this.text?this:new Qt(this.type,this.attrs,e,this.marks)}cut(e=0,t=this.text.length){return e==0&&t==this.text.length?this:this.withText(this.text.slice(e,t))}eq(e){return this.sameMarkup(e)&&this.text==e.text}toJSON(){let e=super.toJSON();return e.text=this.text,e}}function pi(n,e){for(let t=n.length-1;t>=0;t--)e=n[t].type.name+"("+e+")";return e}class Je{constructor(e){this.validEnd=e,this.next=[],this.wrapCache=[]}static parse(e,t){let r=new ul(e,t);if(r.next==null)return Je.empty;let o=fi(r);r.next&&r.err("Unexpected trailing text");let i=bl(xl(o));return yl(i,r),i}matchType(e){for(let t=0;tc.createAndFill()));for(let c=0;c=this.next.length)throw new RangeError(`There's no ${e}th edge in this content match`);return this.next[e]}toString(){let e=[];function t(r){e.push(r);for(let o=0;o{let i=o+(r.validEnd?"*":" ")+" ";for(let s=0;s"+e.indexOf(r.next[s].next);return i}).join(` -`)}}Je.empty=new Je(!0);class ul{constructor(e,t){this.string=e,this.nodeTypes=t,this.inline=null,this.pos=0,this.tokens=e.split(/\s*(?=\b|\W|$)/),this.tokens[this.tokens.length-1]==""&&this.tokens.pop(),this.tokens[0]==""&&this.tokens.shift()}get next(){return this.tokens[this.pos]}eat(e){return this.next==e&&(this.pos++||!0)}err(e){throw new SyntaxError(e+" (in content expression '"+this.string+"')")}}function fi(n){let e=[];do e.push(pl(n));while(n.eat("|"));return e.length==1?e[0]:{type:"choice",exprs:e}}function pl(n){let e=[];do e.push(fl(n));while(n.next&&n.next!=")"&&n.next!="|");return e.length==1?e[0]:{type:"seq",exprs:e}}function fl(n){let e=gl(n);for(;;)if(n.eat("+"))e={type:"plus",expr:e};else if(n.eat("*"))e={type:"star",expr:e};else if(n.eat("?"))e={type:"opt",expr:e};else if(n.eat("{"))e=hl(n,e);else break;return e}function qr(n){/\D/.test(n.next)&&n.err("Expected number, got '"+n.next+"'");let e=Number(n.next);return n.pos++,e}function hl(n,e){let t=qr(n),r=t;return n.eat(",")&&(n.next!="}"?r=qr(n):r=-1),n.eat("}")||n.err("Unclosed braced range"),{type:"range",min:t,max:r,expr:e}}function ml(n,e){let t=n.nodeTypes,r=t[e];if(r)return[r];let o=[];for(let i in t){let s=t[i];s.isInGroup(e)&&o.push(s)}return o.length==0&&n.err("No node type or group '"+e+"' found"),o}function gl(n){if(n.eat("(")){let e=fi(n);return n.eat(")")||n.err("Missing closing paren"),e}else if(/\W/.test(n.next))n.err("Unexpected token '"+n.next+"'");else{let e=ml(n,n.next).map(t=>(n.inline==null?n.inline=t.isInline:n.inline!=t.isInline&&n.err("Mixing inline and block content"),{type:"name",value:t}));return n.pos++,e.length==1?e[0]:{type:"choice",exprs:e}}}function xl(n){let e=[[]];return o(i(n,0),t()),e;function t(){return e.push([])-1}function r(s,a,l){let c={term:l,to:a};return e[s].push(c),c}function o(s,a){s.forEach(l=>l.to=a)}function i(s,a){if(s.type=="choice")return s.exprs.reduce((l,c)=>l.concat(i(c,a)),[]);if(s.type=="seq")for(let l=0;;l++){let c=i(s.exprs[l],a);if(l==s.exprs.length-1)return c;o(c,a=t())}else if(s.type=="star"){let l=t();return r(a,l),o(i(s.expr,l),l),[r(l)]}else if(s.type=="plus"){let l=t();return o(i(s.expr,a),l),o(i(s.expr,l),l),[r(l)]}else{if(s.type=="opt")return[r(a)].concat(i(s.expr,a));if(s.type=="range"){let l=a;for(let c=0;c{n[s].forEach(({term:a,to:l})=>{if(!a)return;let c;for(let d=0;d{c||o.push([a,c=[]]),c.indexOf(d)==-1&&c.push(d)})})});let i=e[r.join(",")]=new Je(r.indexOf(n.length-1)>-1);for(let s=0;s-1}get whitespace(){return this.spec.whitespace||(this.spec.code?"pre":"normal")}hasRequiredAttrs(){for(let e in this.attrs)if(this.attrs[e].isRequired)return!0;return!1}compatibleContent(e){return this==e||this.contentMatch.compatible(e.contentMatch)}computeAttrs(e){return!e&&this.defaultAttrs?this.defaultAttrs:gi(this.attrs,e)}create(e=null,t,r){if(this.isText)throw new Error("NodeType.create can't construct text nodes");return new $e(this,this.computeAttrs(e),y.from(t),E.setFrom(r))}createChecked(e=null,t,r){return t=y.from(t),this.checkContent(t),new $e(this,this.computeAttrs(e),t,E.setFrom(r))}createAndFill(e=null,t,r){if(e=this.computeAttrs(e),t=y.from(t),t.size){let s=this.contentMatch.fillBefore(t);if(!s)return null;t=s.append(t)}let o=this.contentMatch.matchFragment(t),i=o&&o.fillBefore(y.empty,!0);return i?new $e(this,e,t.append(i),E.setFrom(r)):null}validContent(e){let t=this.contentMatch.matchFragment(e);if(!t||!t.validEnd)return!1;for(let r=0;r-1}allowsMarks(e){if(this.markSet==null)return!0;for(let t=0;tr[i]=new yi(i,t,s));let o=t.spec.topNode||"doc";if(!r[o])throw new RangeError("Schema is missing its top node type ('"+o+"')");if(!r.text)throw new RangeError("Every schema needs a 'text' type");for(let i in r.text.attrs)throw new RangeError("The text node type should not have attributes");return r}};function kl(n,e,t){let r=t.split("|");return o=>{let i=o===null?"null":typeof o;if(r.indexOf(i)<0)throw new RangeError(`Expected value of type ${r} for attribute ${e} on type ${n}, got ${i}`)}}class wl{constructor(e,t,r){this.hasDefault=Object.prototype.hasOwnProperty.call(r,"default"),this.default=r.default,this.validate=typeof r.validate=="string"?kl(e,t,r.validate):r.validate}get isRequired(){return!this.hasDefault}}class un{constructor(e,t,r,o){this.name=e,this.rank=t,this.schema=r,this.spec=o,this.attrs=bi(e,o.attrs),this.excluded=null;let i=mi(this.attrs);this.instance=i?new E(this,i):null}create(e=null){return!e&&this.instance?this.instance:new E(this,gi(this.attrs,e))}static compile(e,t){let r=Object.create(null),o=0;return e.forEach((i,s)=>r[i]=new un(i,o++,t,s)),r}removeFromSet(e){for(var t=0;t-1}}class vl{constructor(e){this.linebreakReplacement=null,this.cached=Object.create(null);let t=this.spec={};for(let o in e)t[o]=e[o];t.nodes=H.from(e.nodes),t.marks=H.from(e.marks||{}),this.nodes=Ur.compile(this.spec.nodes,this),this.marks=un.compile(this.spec.marks,this);let r=Object.create(null);for(let o in this.nodes){if(o in this.marks)throw new RangeError(o+" can not be both a node and a mark");let i=this.nodes[o],s=i.spec.content||"",a=i.spec.marks;if(i.contentMatch=r[s]||(r[s]=Je.parse(s,this.nodes)),i.inlineContent=i.contentMatch.inlineContent,i.spec.linebreakReplacement){if(this.linebreakReplacement)throw new RangeError("Multiple linebreak nodes defined");if(!i.isInline||!i.isLeaf)throw new RangeError("Linebreak replacement nodes must be inline leaf nodes");this.linebreakReplacement=i}i.markSet=a=="_"?null:a?jr(this,a.split(" ")):a==""||!i.inlineContent?[]:null}for(let o in this.marks){let i=this.marks[o],s=i.spec.excludes;i.excluded=s==null?[i]:s==""?[]:jr(this,s.split(" "))}this.nodeFromJSON=o=>$e.fromJSON(this,o),this.markFromJSON=o=>E.fromJSON(this,o),this.topNodeType=this.nodes[this.spec.topNode||"doc"],this.cached.wrappings=Object.create(null)}node(e,t=null,r,o){if(typeof e=="string")e=this.nodeType(e);else if(e instanceof Ur){if(e.schema!=this)throw new RangeError("Node type from different schema used ("+e.name+")")}else throw new RangeError("Invalid node type: "+e);return e.createChecked(t,r,o)}text(e,t){let r=this.nodes.text;return new Qt(r,r.defaultAttrs,e,E.setFrom(t))}mark(e,t){return typeof e=="string"&&(e=this.marks[e]),e.create(t)}nodeType(e){let t=this.nodes[e];if(!t)throw new RangeError("Unknown node type: "+e);return t}}function jr(n,e){let t=[];for(let r=0;r-1)&&t.push(s=l)}if(!s)throw new SyntaxError("Unknown mark type: '"+e[r]+"'")}return t}function Cl(n){return n.tag!=null}function Sl(n){return n.style!=null}class St{constructor(e,t){this.schema=e,this.rules=t,this.tags=[],this.styles=[];let r=this.matchedStyles=[];t.forEach(o=>{if(Cl(o))this.tags.push(o);else if(Sl(o)){let i=/[^=]*/.exec(o.style)[0];r.indexOf(i)<0&&r.push(i),this.styles.push(o)}}),this.normalizeLists=!this.tags.some(o=>{if(!/^(ul|ol)\b/.test(o.tag)||!o.node)return!1;let i=e.nodes[o.node];return i.contentMatch.matchType(i)})}parse(e,t={}){let r=new Zr(this,t,!1);return r.addAll(e,E.none,t.from,t.to),r.finish()}parseSlice(e,t={}){let r=new Zr(this,t,!0);return r.addAll(e,E.none,t.from,t.to),w.maxOpen(r.finish())}matchTag(e,t,r){for(let o=r?this.tags.indexOf(r)+1:0;oe.length&&(a.charCodeAt(e.length)!=61||a.slice(e.length+1)!=t))){if(s.getAttrs){let l=s.getAttrs(t);if(l===!1)continue;s.attrs=l||void 0}return s}}}static schemaRules(e){let t=[];function r(o){let i=o.priority==null?50:o.priority,s=0;for(;s{r(s=Yr(s)),s.mark||s.ignore||s.clearMark||(s.mark=o)})}for(let o in e.nodes){let i=e.nodes[o].spec.parseDOM;i&&i.forEach(s=>{r(s=Yr(s)),s.node||s.ignore||s.mark||(s.node=o)})}return t}static fromSchema(e){return e.cached.domParser||(e.cached.domParser=new St(e,St.schemaRules(e)))}}const ki={address:!0,article:!0,aside:!0,blockquote:!0,canvas:!0,dd:!0,div:!0,dl:!0,fieldset:!0,figcaption:!0,figure:!0,footer:!0,form:!0,h1:!0,h2:!0,h3:!0,h4:!0,h5:!0,h6:!0,header:!0,hgroup:!0,hr:!0,li:!0,noscript:!0,ol:!0,output:!0,p:!0,pre:!0,section:!0,table:!0,tfoot:!0,ul:!0},Ml={head:!0,noscript:!0,object:!0,script:!0,style:!0,title:!0},wi={ol:!0,ul:!0},Mt=1,Un=2,kt=4;function Kr(n,e,t){return e!=null?(e?Mt:0)|(e==="full"?Un:0):n&&n.whitespace=="pre"?Mt|Un:t&~kt}class Bt{constructor(e,t,r,o,i,s){this.type=e,this.attrs=t,this.marks=r,this.solid=o,this.options=s,this.content=[],this.activeMarks=E.none,this.match=i||(s&kt?null:e.contentMatch)}findWrapping(e){if(!this.match){if(!this.type)return[];let t=this.type.contentMatch.fillBefore(y.from(e));if(t)this.match=this.type.contentMatch.matchFragment(t);else{let r=this.type.contentMatch,o;return(o=r.findWrapping(e.type))?(this.match=r,o):null}}return this.match.findWrapping(e.type)}finish(e){if(!(this.options&Mt)){let r=this.content[this.content.length-1],o;if(r&&r.isText&&(o=/[ \t\r\n\u000c]+$/.exec(r.text))){let i=r;r.text.length==o[0].length?this.content.pop():this.content[this.content.length-1]=i.withText(i.text.slice(0,i.text.length-o[0].length))}}let t=y.from(this.content);return!e&&this.match&&(t=t.append(this.match.fillBefore(y.empty,!0))),this.type?this.type.create(this.attrs,t,this.marks):t}inlineContext(e){return this.type?this.type.inlineContent:this.content.length?this.content[0].isInline:e.parentNode&&!ki.hasOwnProperty(e.parentNode.nodeName.toLowerCase())}}class Zr{constructor(e,t,r){this.parser=e,this.options=t,this.isOpen=r,this.open=0,this.localPreserveWS=!1;let o=t.topNode,i,s=Kr(null,t.preserveWhitespace,0)|(r?kt:0);o?i=new Bt(o.type,o.attrs,E.none,!0,t.topMatch||o.type.contentMatch,s):r?i=new Bt(null,null,E.none,!0,null,s):i=new Bt(e.schema.topNodeType,null,E.none,!0,null,s),this.nodes=[i],this.find=t.findPositions,this.needsBlock=!1}get top(){return this.nodes[this.open]}addDOM(e,t){e.nodeType==3?this.addTextNode(e,t):e.nodeType==1&&this.addElement(e,t)}addTextNode(e,t){let r=e.nodeValue,o=this.top,i=o.options&Un?"full":this.localPreserveWS||(o.options&Mt)>0,{schema:s}=this.parser;if(i==="full"||o.inlineContext(e)||/[^ \t\r\n\u000c]/.test(r)){if(i)if(i==="full")r=r.replace(/\r\n?/g,` -`);else if(s.linebreakReplacement&&/[\r\n]/.test(r)&&this.top.findWrapping(s.linebreakReplacement.create())){let a=r.split(/\r?\n|\r/);for(let l=0;l!l.clearMark(c)):t=t.concat(this.parser.schema.marks[l.mark].create(l.attrs)),l.consuming===!1)a=l;else break}}return t}addElementByRule(e,t,r,o){let i,s;if(t.node)if(s=this.parser.schema.nodes[t.node],s.isLeaf)this.insertNode(s.create(t.attrs),r,e.nodeName=="BR")||this.leafFallback(e,r);else{let l=this.enter(s,t.attrs||null,r,t.preserveWhitespace);l&&(i=!0,r=l)}else{let l=this.parser.schema.marks[t.mark];r=r.concat(l.create(t.attrs))}let a=this.top;if(s&&s.isLeaf)this.findInside(e);else if(o)this.addElement(e,r,o);else if(t.getContent)this.findInside(e),t.getContent(e,this.parser.schema).forEach(l=>this.insertNode(l,r,!1));else{let l=e;typeof t.contentElement=="string"?l=e.querySelector(t.contentElement):typeof t.contentElement=="function"?l=t.contentElement(e):t.contentElement&&(l=t.contentElement),this.findAround(e,l,!0),this.addAll(l,r),this.findAround(e,l,!1)}i&&this.sync(a)&&this.open--}addAll(e,t,r,o){let i=r||0;for(let s=r?e.childNodes[r]:e.firstChild,a=o==null?null:e.childNodes[o];s!=a;s=s.nextSibling,++i)this.findAtPoint(e,i),this.addDOM(s,t);this.findAtPoint(e,i)}findPlace(e,t,r){let o,i;for(let s=this.open,a=0;s>=0;s--){let l=this.nodes[s],c=l.findWrapping(e);if(c&&(!o||o.length>c.length+a)&&(o=c,i=l,!c.length))break;if(l.solid){if(r)break;a+=2}}if(!o)return null;this.sync(i);for(let s=0;s(s.type?s.type.allowsMarkType(c.type):Gr(c.type,e))?(l=c.addToSet(l),!1):!0),this.nodes.push(new Bt(e,t,l,o,null,a)),this.open++,r}closeExtra(e=!1){let t=this.nodes.length-1;if(t>this.open){for(;t>this.open;t--)this.nodes[t-1].content.push(this.nodes[t].finish(e));this.nodes.length=this.open+1}}finish(){return this.open=0,this.closeExtra(this.isOpen),this.nodes[0].finish(!!(this.isOpen||this.options.topOpen))}sync(e){for(let t=this.open;t>=0;t--){if(this.nodes[t]==e)return this.open=t,!0;this.localPreserveWS&&(this.nodes[t].options|=Mt)}return!1}get currentPos(){this.closeExtra();let e=0;for(let t=this.open;t>=0;t--){let r=this.nodes[t].content;for(let o=r.length-1;o>=0;o--)e+=r[o].nodeSize;t&&e++}return e}findAtPoint(e,t){if(this.find)for(let r=0;r-1)return e.split(/\s*\|\s*/).some(this.matchesContext,this);let t=e.split("/"),r=this.options.context,o=!this.isOpen&&(!r||r.parent.type==this.nodes[0].type),i=-(r?r.depth+1:0)+(o?0:1),s=(a,l)=>{for(;a>=0;a--){let c=t[a];if(c==""){if(a==t.length-1||a==0)continue;for(;l>=i;l--)if(s(a-1,l))return!0;return!1}else{let d=l>0||l==0&&o?this.nodes[l].type:r&&l>=i?r.node(l-i).type:null;if(!d||d.name!=c&&!d.isInGroup(c))return!1;l--}}return!0};return s(t.length-1,this.open)}textblockFromContext(){let e=this.options.context;if(e)for(let t=e.depth;t>=0;t--){let r=e.node(t).contentMatchAt(e.indexAfter(t)).defaultType;if(r&&r.isTextblock&&r.defaultAttrs)return r}for(let t in this.parser.schema.nodes){let r=this.parser.schema.nodes[t];if(r.isTextblock&&r.defaultAttrs)return r}}}function Nl(n){for(let e=n.firstChild,t=null;e;e=e.nextSibling){let r=e.nodeType==1?e.nodeName.toLowerCase():null;r&&wi.hasOwnProperty(r)&&t?(t.appendChild(e),e=t):r=="li"?t=e:r&&(t=null)}}function Ol(n,e){return(n.matches||n.msMatchesSelector||n.webkitMatchesSelector||n.mozMatchesSelector).call(n,e)}function Yr(n){let e={};for(let t in n)e[t]=n[t];return e}function Gr(n,e){let t=e.schema.nodes;for(let r in t){let o=t[r];if(!o.allowsMarkType(n))continue;let i=[],s=a=>{i.push(a);for(let l=0;l{if(i.length||s.marks.length){let a=0,l=0;for(;a=0;o--){let i=this.serializeMark(e.marks[o],e.isInline,t);i&&((i.contentDOM||i.dom).appendChild(r),r=i.dom)}return r}serializeMark(e,t,r={}){let o=this.marks[e.type.name];return o&&Ht(Vt(r),o(e,t),null,e.attrs)}static renderSpec(e,t,r=null,o){return typeof t=="string"?{dom:e.createTextNode(t)}:Ht(e,t,r,o)}static fromSchema(e){return e.cached.domSerializer||(e.cached.domSerializer=new ct(this.nodesFromSchema(e),this.marksFromSchema(e)))}static nodesFromSchema(e){let t=Xr(e.nodes);return t.text||(t.text=r=>r.text),t}static marksFromSchema(e){return Xr(e.marks)}}function Xr(n){let e={};for(let t in n){let r=n[t].spec.toDOM;r&&(e[t]=r)}return e}function Vt(n){return n.document||window.document}const Qr=new WeakMap;function Tl(n){let e=Qr.get(n);return e===void 0&&Qr.set(n,e=Dl(n)),e}function Dl(n){let e=null;function t(r){if(r&&typeof r=="object")if(Array.isArray(r))if(typeof r[0]=="string")e||(e=[]),e.push(r);else for(let o=0;o-1)throw new RangeError("Using an array from an attribute object as a DOM spec. This may be an attempted cross site scripting attack.");let s=o.indexOf(" ");s>0&&(t=o.slice(0,s),o=o.slice(s+1));let a,l=t?n.createElementNS(t,o):n.createElement(o),c=e[1],d=1;if(c&&typeof c=="object"&&c.nodeType==null&&!Array.isArray(c)){d=2;for(let u in c)if(c[u]!=null){let p=u.indexOf(" ");p>0?l.setAttributeNS(u.slice(0,p),u.slice(p+1),c[u]):u=="style"&&l.style?l.style.cssText=c[u]:l.setAttribute(u,c[u])}}for(let u=d;ud)throw new RangeError("Content hole must be the only child of its parent node");return{dom:l,contentDOM:l}}else if(typeof p=="string")l.appendChild(n.createTextNode(p));else{let{dom:h,contentDOM:m}=Ht(n,p,t,r);if(l.appendChild(h),m){if(a)throw new RangeError("Multiple content holes");a=m}}}return{dom:l,contentDOM:a}}const vi=65535,Ci=Math.pow(2,16);function El(n,e){return n+e*Ci}function eo(n){return n&vi}function Al(n){return(n-(n&vi))/Ci}const Si=1,Mi=2,qt=4,Ni=8;class jn{constructor(e,t,r){this.pos=e,this.delInfo=t,this.recover=r}get deleted(){return(this.delInfo&Ni)>0}get deletedBefore(){return(this.delInfo&(Si|qt))>0}get deletedAfter(){return(this.delInfo&(Mi|qt))>0}get deletedAcross(){return(this.delInfo&qt)>0}}class Q{constructor(e,t=!1){if(this.ranges=e,this.inverted=t,!e.length&&Q.empty)return Q.empty}recover(e){let t=0,r=eo(e);if(!this.inverted)for(let o=0;oe)break;let c=this.ranges[a+i],d=this.ranges[a+s],u=l+c;if(e<=u){let p=c?e==l?-1:e==u?1:t:t,h=l+o+(p<0?0:d);if(r)return h;let m=e==(t<0?l:u)?null:El(a/3,e-l),g=e==l?Mi:e==u?Si:qt;return(t<0?e!=l:e!=u)&&(g|=Ni),new jn(h,g,m)}o+=d-c}return r?e+o:new jn(e+o,0,null)}touches(e,t){let r=0,o=eo(t),i=this.inverted?2:1,s=this.inverted?1:2;for(let a=0;ae)break;let c=this.ranges[a+i],d=l+c;if(e<=d&&a==o*3)return!0;r+=this.ranges[a+s]-c}return!1}forEach(e){let t=this.inverted?2:1,r=this.inverted?1:2;for(let o=0,i=0;o=0;t--){let o=e.getMirror(t);this.appendMap(e._maps[t].invert(),o!=null&&o>t?r-o-1:void 0)}}invert(){let e=new en;return e.appendMappingInverted(this),e}map(e,t=1){if(this.mirror)return this._map(e,t,!0);for(let r=this.from;ri&&l!s.isAtom||!a.type.allowsMarkType(this.mark.type)?s:s.mark(this.mark.addToSet(s.marks)),o),t.openStart,t.openEnd);return P.fromReplace(e,this.from,this.to,i)}invert(){return new he(this.from,this.to,this.mark)}map(e){let t=e.mapResult(this.from,1),r=e.mapResult(this.to,-1);return t.deleted&&r.deleted||t.pos>=r.pos?null:new Te(t.pos,r.pos,this.mark)}merge(e){return e instanceof Te&&e.mark.eq(this.mark)&&this.from<=e.to&&this.to>=e.from?new Te(Math.min(this.from,e.from),Math.max(this.to,e.to),this.mark):null}toJSON(){return{stepType:"addMark",mark:this.mark.toJSON(),from:this.from,to:this.to}}static fromJSON(e,t){if(typeof t.from!="number"||typeof t.to!="number")throw new RangeError("Invalid input for AddMarkStep.fromJSON");return new Te(t.from,t.to,e.markFromJSON(t.mark))}}K.jsonID("addMark",Te);class he extends K{constructor(e,t,r){super(),this.from=e,this.to=t,this.mark=r}apply(e){let t=e.slice(this.from,this.to),r=new w(lr(t.content,o=>o.mark(this.mark.removeFromSet(o.marks)),e),t.openStart,t.openEnd);return P.fromReplace(e,this.from,this.to,r)}invert(){return new Te(this.from,this.to,this.mark)}map(e){let t=e.mapResult(this.from,1),r=e.mapResult(this.to,-1);return t.deleted&&r.deleted||t.pos>=r.pos?null:new he(t.pos,r.pos,this.mark)}merge(e){return e instanceof he&&e.mark.eq(this.mark)&&this.from<=e.to&&this.to>=e.from?new he(Math.min(this.from,e.from),Math.max(this.to,e.to),this.mark):null}toJSON(){return{stepType:"removeMark",mark:this.mark.toJSON(),from:this.from,to:this.to}}static fromJSON(e,t){if(typeof t.from!="number"||typeof t.to!="number")throw new RangeError("Invalid input for RemoveMarkStep.fromJSON");return new he(t.from,t.to,e.markFromJSON(t.mark))}}K.jsonID("removeMark",he);class De extends K{constructor(e,t){super(),this.pos=e,this.mark=t}apply(e){let t=e.nodeAt(this.pos);if(!t)return P.fail("No node at mark step's position");let r=t.type.create(t.attrs,null,this.mark.addToSet(t.marks));return P.fromReplace(e,this.pos,this.pos+1,new w(y.from(r),0,t.isLeaf?0:1))}invert(e){let t=e.nodeAt(this.pos);if(t){let r=this.mark.addToSet(t.marks);if(r.length==t.marks.length){for(let o=0;or.pos?null:new ne(t.pos,r.pos,o,i,this.slice,this.insert,this.structure)}toJSON(){let e={stepType:"replaceAround",from:this.from,to:this.to,gapFrom:this.gapFrom,gapTo:this.gapTo,insert:this.insert};return this.slice.size&&(e.slice=this.slice.toJSON()),this.structure&&(e.structure=!0),e}static fromJSON(e,t){if(typeof t.from!="number"||typeof t.to!="number"||typeof t.gapFrom!="number"||typeof t.gapTo!="number"||typeof t.insert!="number")throw new RangeError("Invalid input for ReplaceAroundStep.fromJSON");return new ne(t.from,t.to,t.gapFrom,t.gapTo,w.fromJSON(e,t.slice),t.insert,!!t.structure)}}K.jsonID("replaceAround",ne);function Kn(n,e,t){let r=n.resolve(e),o=t-e,i=r.depth;for(;o>0&&i>0&&r.indexAfter(i)==r.node(i).childCount;)i--,o--;if(o>0){let s=r.node(i).maybeChild(r.indexAfter(i));for(;o>0;){if(!s||s.isLeaf)return!0;s=s.firstChild,o--}}return!1}function Rl(n,e,t,r){let o=[],i=[],s,a;n.doc.nodesBetween(e,t,(l,c,d)=>{if(!l.isInline)return;let u=l.marks;if(!r.isInSet(u)&&d.type.allowsMarkType(r.type)){let p=Math.max(c,e),h=Math.min(c+l.nodeSize,t),m=r.addToSet(u);for(let g=0;gn.step(l)),i.forEach(l=>n.step(l))}function Il(n,e,t,r){let o=[],i=0;n.doc.nodesBetween(e,t,(s,a)=>{if(!s.isInline)return;i++;let l=null;if(r instanceof un){let c=s.marks,d;for(;d=r.isInSet(c);)(l||(l=[])).push(d),c=d.removeFromSet(c)}else r?r.isInSet(s.marks)&&(l=[r]):l=s.marks;if(l&&l.length){let c=Math.min(a+s.nodeSize,t);for(let d=0;dn.step(new he(s.from,s.to,s.style)))}function cr(n,e,t,r=t.contentMatch,o=!0){let i=n.doc.nodeAt(e),s=[],a=e+1;for(let l=0;l=0;l--)n.step(s[l])}function Ll(n,e,t){return(e==0||n.canReplace(e,n.childCount))&&(t==n.childCount||n.canReplace(0,t))}function dr(n){let t=n.parent.content.cutByIndex(n.startIndex,n.endIndex);for(let r=n.depth,o=0,i=0;;--r){let s=n.$from.node(r),a=n.$from.index(r)+o,l=n.$to.indexAfter(r)-i;if(rt;m--)g||r.index(m)>0?(g=!0,d=y.from(r.node(m).copy(d)),u++):l--;let p=y.empty,h=0;for(let m=i,g=!1;m>t;m--)g||o.after(m+1)=0;s--){if(r.size){let a=t[s].type.contentMatch.matchFragment(r);if(!a||!a.validEnd)throw new RangeError("Wrapper type given to Transform.wrap does not form valid content of its parent wrapper")}r=y.from(t[s].type.create(t[s].attrs,r))}let o=e.start,i=e.end;n.step(new ne(o,i,o,i,new w(r,0,0),t.length,!0))}function Fl(n,e,t,r,o){if(!r.isTextblock)throw new RangeError("Type given to setBlockType should be a textblock");let i=n.steps.length;n.doc.nodesBetween(e,t,(s,a)=>{let l=typeof o=="function"?o(s):o;if(s.isTextblock&&!s.hasMarkup(r,l)&&Bl(n.doc,n.mapping.slice(i).map(a),r)){let c=null;if(r.schema.linebreakReplacement){let h=r.whitespace=="pre",m=!!r.contentMatch.matchType(r.schema.linebreakReplacement);h&&!m?c=!1:!h&&m&&(c=!0)}c===!1&&Ti(n,s,a,i),cr(n,n.mapping.slice(i).map(a,1),r,void 0,c===null);let d=n.mapping.slice(i),u=d.map(a,1),p=d.map(a+s.nodeSize,1);return n.step(new ne(u,p,u+1,p-1,new w(y.from(r.create(l,null,s.marks)),0,0),1,!0)),c===!0&&Oi(n,s,a,i),!1}})}function Oi(n,e,t,r){e.forEach((o,i)=>{if(o.isText){let s,a=/\r?\n|\r/g;for(;s=a.exec(o.text);){let l=n.mapping.slice(r).map(t+1+i+s.index);n.replaceWith(l,l+1,e.type.schema.linebreakReplacement.create())}}})}function Ti(n,e,t,r){e.forEach((o,i)=>{if(o.type==o.type.schema.linebreakReplacement){let s=n.mapping.slice(r).map(t+1+i);n.replaceWith(s,s+1,e.type.schema.text(` -`))}})}function Bl(n,e,t){let r=n.resolve(e),o=r.index();return r.parent.canReplaceWith(o,o+1,t)}function Vl(n,e,t,r,o){let i=n.doc.nodeAt(e);if(!i)throw new RangeError("No node at given position");t||(t=i.type);let s=t.create(r,null,o||i.marks);if(i.isLeaf)return n.replaceWith(e,e+i.nodeSize,s);if(!t.validContent(i.content))throw new RangeError("Invalid content for node type "+t.name);n.step(new ne(e,e+i.nodeSize,e+1,e+i.nodeSize-1,new w(y.from(s),0,0),1,!0))}function Jt(n,e,t=1,r){let o=n.resolve(e),i=o.depth-t,s=r&&r[r.length-1]||o.parent;if(i<0||o.parent.type.spec.isolating||!o.parent.canReplace(o.index(),o.parent.childCount)||!s.type.validContent(o.parent.content.cutByIndex(o.index(),o.parent.childCount)))return!1;for(let c=o.depth-1,d=t-2;c>i;c--,d--){let u=o.node(c),p=o.index(c);if(u.type.spec.isolating)return!1;let h=u.content.cutByIndex(p,u.childCount),m=r&&r[d+1];m&&(h=h.replaceChild(0,m.type.create(m.attrs)));let g=r&&r[d]||u;if(!u.canReplace(p+1,u.childCount)||!g.type.validContent(h))return!1}let a=o.indexAfter(i),l=r&&r[0];return o.node(i).canReplaceWith(a,a,l?l.type:o.node(i+1).type)}function _l(n,e,t=1,r){let o=n.doc.resolve(e),i=y.empty,s=y.empty;for(let a=o.depth,l=o.depth-t,c=t-1;a>l;a--,c--){i=y.from(o.node(a).copy(i));let d=r&&r[c];s=y.from(d?d.type.create(d.attrs,s):o.node(a).copy(s))}n.step(new _(e,e,new w(i.append(s),t,t),!0))}function Di(n,e){let t=n.resolve(e),r=t.index();return Wl(t.nodeBefore,t.nodeAfter)&&t.parent.canReplace(r,r+1)}function $l(n,e){e.content.size||n.type.compatibleContent(e.type);let t=n.contentMatchAt(n.childCount),{linebreakReplacement:r}=n.type.schema;for(let o=0;o=0;o--){let i=r.index(o);if(r.node(o).canReplaceWith(i,i,t))return r.before(o+1);if(i>0)return null}if(r.parentOffset==r.parent.content.size)for(let o=r.depth-1;o>=0;o--){let i=r.indexAfter(o);if(r.node(o).canReplaceWith(i,i,t))return r.after(o+1);if(i=0;s--){let a=s==r.depth?0:r.pos<=(r.start(s+1)+r.end(s+1))/2?-1:1,l=r.index(s)+(a>0?1:0),c=r.node(s),d=!1;if(i==1)d=c.canReplace(l,l,o);else{let u=c.contentMatchAt(l).findWrapping(o.firstChild.type);d=u&&c.canReplaceWith(l,l,u[0])}if(d)return a==0?r.pos:a<0?r.before(s+1):r.after(s+1)}return null}function ur(n,e,t=e,r=w.empty){if(e==t&&!r.size)return null;let o=n.resolve(e),i=n.resolve(t);return Ei(o,i,r)?new _(e,t,r):new Ul(o,i,r).fit()}function Ei(n,e,t){return!t.openStart&&!t.openEnd&&n.start()==e.start()&&n.parent.canReplace(n.index(),e.index(),t.content)}class Ul{constructor(e,t,r){this.$from=e,this.$to=t,this.unplaced=r,this.frontier=[],this.placed=y.empty;for(let o=0;o<=e.depth;o++){let i=e.node(o);this.frontier.push({type:i.type,match:i.contentMatchAt(e.indexAfter(o))})}for(let o=e.depth;o>0;o--)this.placed=y.from(e.node(o).copy(this.placed))}get depth(){return this.frontier.length-1}fit(){for(;this.unplaced.size;){let c=this.findFittable();c?this.placeNodes(c):this.openMore()||this.dropNode()}let e=this.mustMoveInline(),t=this.placed.size-this.depth-this.$from.depth,r=this.$from,o=this.close(e<0?this.$to:r.doc.resolve(e));if(!o)return null;let i=this.placed,s=r.depth,a=o.depth;for(;s&&a&&i.childCount==1;)i=i.firstChild.content,s--,a--;let l=new w(i,s,a);return e>-1?new ne(r.pos,e,this.$to.pos,this.$to.end(),l,t):l.size||r.pos!=this.$to.pos?new _(r.pos,o.pos,l):null}findFittable(){let e=this.unplaced.openStart;for(let t=this.unplaced.content,r=0,o=this.unplaced.openEnd;r1&&(o=0),i.type.spec.isolating&&o<=r){e=r;break}t=i.content}for(let t=1;t<=2;t++)for(let r=t==1?e:this.unplaced.openStart;r>=0;r--){let o,i=null;r?(i=Tn(this.unplaced.content,r-1).firstChild,o=i.content):o=this.unplaced.content;let s=o.firstChild;for(let a=this.depth;a>=0;a--){let{type:l,match:c}=this.frontier[a],d,u=null;if(t==1&&(s?c.matchType(s.type)||(u=c.fillBefore(y.from(s),!1)):i&&l.compatibleContent(i.type)))return{sliceDepth:r,frontierDepth:a,parent:i,inject:u};if(t==2&&s&&(d=c.findWrapping(s.type)))return{sliceDepth:r,frontierDepth:a,parent:i,wrap:d};if(i&&c.matchType(i.type))break}}}openMore(){let{content:e,openStart:t,openEnd:r}=this.unplaced,o=Tn(e,t);return!o.childCount||o.firstChild.isLeaf?!1:(this.unplaced=new w(e,t+1,Math.max(r,o.size+t>=e.size-r?t+1:0)),!0)}dropNode(){let{content:e,openStart:t,openEnd:r}=this.unplaced,o=Tn(e,t);if(o.childCount<=1&&t>0){let i=e.size-t<=t+o.size;this.unplaced=new w(mt(e,t-1,1),t-1,i?t-1:r)}else this.unplaced=new w(mt(e,t,1),t,r)}placeNodes({sliceDepth:e,frontierDepth:t,parent:r,inject:o,wrap:i}){for(;this.depth>t;)this.closeFrontierNode();if(i)for(let g=0;g1||l==0||g.content.size)&&(u=x,d.push(Ai(g.mark(p.allowedMarks(g.marks)),c==1?l:0,c==a.childCount?h:-1)))}let m=c==a.childCount;m||(h=-1),this.placed=gt(this.placed,t,y.from(d)),this.frontier[t].match=u,m&&h<0&&r&&r.type==this.frontier[this.depth].type&&this.frontier.length>1&&this.closeFrontierNode();for(let g=0,x=a;g1&&o==this.$to.end(--r);)++o;return o}findCloseLevel(e){e:for(let t=Math.min(this.depth,e.depth);t>=0;t--){let{match:r,type:o}=this.frontier[t],i=t=0;a--){let{match:l,type:c}=this.frontier[a],d=Dn(e,a,c,l,!0);if(!d||d.childCount)continue e}return{depth:t,fit:s,move:i?e.doc.resolve(e.after(t+1)):e}}}}close(e){let t=this.findCloseLevel(e);if(!t)return null;for(;this.depth>t.depth;)this.closeFrontierNode();t.fit.childCount&&(this.placed=gt(this.placed,t.depth,t.fit)),e=t.move;for(let r=t.depth+1;r<=e.depth;r++){let o=e.node(r),i=o.type.contentMatch.fillBefore(o.content,!0,e.index(r));this.openFrontierNode(o.type,o.attrs,i)}return e}openFrontierNode(e,t=null,r){let o=this.frontier[this.depth];o.match=o.match.matchType(e),this.placed=gt(this.placed,this.depth,y.from(e.create(t,r))),this.frontier.push({type:e,match:e.contentMatch})}closeFrontierNode(){let t=this.frontier.pop().match.fillBefore(y.empty,!0);t.childCount&&(this.placed=gt(this.placed,this.frontier.length,t))}}function mt(n,e,t){return e==0?n.cutByIndex(t,n.childCount):n.replaceChild(0,n.firstChild.copy(mt(n.firstChild.content,e-1,t)))}function gt(n,e,t){return e==0?n.append(t):n.replaceChild(n.childCount-1,n.lastChild.copy(gt(n.lastChild.content,e-1,t)))}function Tn(n,e){for(let t=0;t1&&(r=r.replaceChild(0,Ai(r.firstChild,e-1,r.childCount==1?t-1:0))),e>0&&(r=n.type.contentMatch.fillBefore(r).append(r),t<=0&&(r=r.append(n.type.contentMatch.matchFragment(r).fillBefore(y.empty,!0)))),n.copy(r)}function Dn(n,e,t,r,o){let i=n.node(e),s=o?n.indexAfter(e):n.index(e);if(s==i.childCount&&!t.compatibleContent(i.type))return null;let a=r.fillBefore(i.content,!0,s);return a&&!jl(t,i.content,s)?a:null}function jl(n,e,t){for(let r=t;r0;p--,h--){let m=o.node(p).type.spec;if(m.defining||m.definingAsContext||m.isolating)break;s.indexOf(p)>-1?a=p:o.before(p)==h&&s.splice(1,0,-p)}let l=s.indexOf(a),c=[],d=r.openStart;for(let p=r.content,h=0;;h++){let m=p.firstChild;if(c.push(m),h==r.openStart)break;p=m.content}for(let p=d-1;p>=0;p--){let h=c[p],m=Kl(h.type);if(m&&!h.sameMarkup(o.node(Math.abs(a)-1)))d=p;else if(m||!h.type.isTextblock)break}for(let p=r.openStart;p>=0;p--){let h=(p+d+1)%(r.openStart+1),m=c[h];if(m)for(let g=0;g=0&&(n.replace(e,t,r),!(n.steps.length>u));p--){let h=s[p];h<0||(e=o.before(h),t=i.after(h))}}function Ri(n,e,t,r,o){if(er){let i=o.contentMatchAt(0),s=i.fillBefore(n).append(n);n=s.append(i.matchFragment(s).fillBefore(y.empty,!0))}return n}function Yl(n,e,t,r){if(!r.isInline&&e==t&&n.doc.resolve(e).parent.content.size){let o=ql(n.doc,e,r.type);o!=null&&(e=t=o)}n.replaceRange(e,t,new w(y.from(r),0,0))}function Gl(n,e,t){let r=n.doc.resolve(e),o=n.doc.resolve(t);if(r.parent.isTextblock&&o.parent.isTextblock&&r.start()!=o.start()&&r.parentOffset==0&&o.parentOffset==0){let s=r.sharedDepth(t),a=!1;for(let l=r.depth;l>s;l--)r.node(l).type.spec.isolating&&(a=!0);for(let l=o.depth;l>s;l--)o.node(l).type.spec.isolating&&(a=!0);if(!a){for(let l=r.depth;l>0&&e==r.start(l);l--)e=r.before(l);for(let l=o.depth;l>0&&t==o.start(l);l--)t=o.before(l);r=n.doc.resolve(e),o=n.doc.resolve(t)}}let i=Ii(r,o);for(let s=0;s0&&(l||r.node(a-1).canReplace(r.index(a-1),o.indexAfter(a-1))))return n.delete(r.before(a),o.after(a))}for(let s=1;s<=r.depth&&s<=o.depth;s++)if(e-r.start(s)==r.depth-s&&t>r.end(s)&&o.end(s)-t!=o.depth-s&&r.start(s-1)==o.start(s-1)&&r.node(s-1).canReplace(r.index(s-1),o.index(s-1)))return n.delete(r.before(s),t);n.delete(e,t)}function Ii(n,e){let t=[],r=Math.min(n.depth,e.depth);for(let o=r;o>=0;o--){let i=n.start(o);if(ie.pos+(e.depth-o)||n.node(o).type.spec.isolating||e.node(o).type.spec.isolating)break;(i==e.start(o)||o==n.depth&&o==e.depth&&n.parent.inlineContent&&e.parent.inlineContent&&o&&e.start(o-1)==i-1)&&t.push(o)}return t}class tt extends K{constructor(e,t,r){super(),this.pos=e,this.attr=t,this.value=r}apply(e){let t=e.nodeAt(this.pos);if(!t)return P.fail("No node at attribute step's position");let r=Object.create(null);for(let i in t.attrs)r[i]=t.attrs[i];r[this.attr]=this.value;let o=t.type.create(r,null,t.marks);return P.fromReplace(e,this.pos,this.pos+1,new w(y.from(o),0,t.isLeaf?0:1))}getMap(){return Q.empty}invert(e){return new tt(this.pos,this.attr,e.nodeAt(this.pos).attrs[this.attr])}map(e){let t=e.mapResult(this.pos,1);return t.deletedAfter?null:new tt(t.pos,this.attr,this.value)}toJSON(){return{stepType:"attr",pos:this.pos,attr:this.attr,value:this.value}}static fromJSON(e,t){if(typeof t.pos!="number"||typeof t.attr!="string")throw new RangeError("Invalid input for AttrStep.fromJSON");return new tt(t.pos,t.attr,t.value)}}K.jsonID("attr",tt);class Nt extends K{constructor(e,t){super(),this.attr=e,this.value=t}apply(e){let t=Object.create(null);for(let o in e.attrs)t[o]=e.attrs[o];t[this.attr]=this.value;let r=e.type.create(t,e.content,e.marks);return P.ok(r)}getMap(){return Q.empty}invert(e){return new Nt(this.attr,e.attrs[this.attr])}map(e){return this}toJSON(){return{stepType:"docAttr",attr:this.attr,value:this.value}}static fromJSON(e,t){if(typeof t.attr!="string")throw new RangeError("Invalid input for DocAttrStep.fromJSON");return new Nt(t.attr,t.value)}}K.jsonID("docAttr",Nt);let ot=class extends Error{};ot=function n(e){let t=Error.call(this,e);return t.__proto__=n.prototype,t};ot.prototype=Object.create(Error.prototype);ot.prototype.constructor=ot;ot.prototype.name="TransformError";class Xl{constructor(e){this.doc=e,this.steps=[],this.docs=[],this.mapping=new en}get before(){return this.docs.length?this.docs[0]:this.doc}step(e){let t=this.maybeStep(e);if(t.failed)throw new ot(t.failed);return this}maybeStep(e){let t=e.apply(this.doc);return t.failed||this.addStep(e,t.doc),t}get docChanged(){return this.steps.length>0}changedRange(){let e=1e9,t=-1e9;for(let r=0;r{e=Math.min(e,a),t=Math.max(t,l)})}return e==1e9?null:{from:e,to:t}}addStep(e,t){this.docs.push(this.doc),this.steps.push(e),this.mapping.appendMap(e.getMap()),this.doc=t}replace(e,t=e,r=w.empty){let o=ur(this.doc,e,t,r);return o&&this.step(o),this}replaceWith(e,t,r){return this.replace(e,t,new w(y.from(r),0,0))}delete(e,t){return this.replace(e,t,w.empty)}insert(e,t){return this.replaceWith(e,e,t)}replaceRange(e,t,r){return Zl(this,e,t,r),this}replaceRangeWith(e,t,r){return Yl(this,e,t,r),this}deleteRange(e,t){return Gl(this,e,t),this}lift(e,t){return Pl(this,e,t),this}join(e,t=1){return Hl(this,e,t),this}wrap(e,t){return zl(this,e,t),this}setBlockType(e,t=e,r,o=null){return Fl(this,e,t,r,o),this}setNodeMarkup(e,t,r=null,o){return Vl(this,e,t,r,o),this}setNodeAttribute(e,t,r){return this.step(new tt(e,t,r)),this}setDocAttribute(e,t){return this.step(new Nt(e,t)),this}addNodeMark(e,t){return this.step(new De(e,t)),this}removeNodeMark(e,t){let r=this.doc.nodeAt(e);if(!r)throw new RangeError("No node at position "+e);if(t instanceof E)t.isInSet(r.marks)&&this.step(new Ue(e,t));else{let o=r.marks,i,s=[];for(;i=t.isInSet(o);)s.push(new Ue(e,i)),o=i.removeFromSet(o);for(let a=s.length-1;a>=0;a--)this.step(s[a])}return this}split(e,t=1,r){return _l(this,e,t,r),this}addMark(e,t,r){return Rl(this,e,t,r),this}removeMark(e,t,r){return Il(this,e,t,r),this}clearIncompatible(e,t,r){return cr(this,e,t,r),this}}const En=Object.create(null);class R{constructor(e,t,r){this.$anchor=e,this.$head=t,this.ranges=r||[new Ql(e.min(t),e.max(t))]}get anchor(){return this.$anchor.pos}get head(){return this.$head.pos}get from(){return this.$from.pos}get to(){return this.$to.pos}get $from(){return this.ranges[0].$from}get $to(){return this.ranges[0].$to}get empty(){let e=this.ranges;for(let t=0;t=0;i--){let s=t<0?Xe(e.node(0),e.node(i),e.before(i+1),e.index(i),t,r):Xe(e.node(0),e.node(i),e.after(i+1),e.index(i)+1,t,r);if(s)return s}return null}static near(e,t=1){return this.findFrom(e,t)||this.findFrom(e,-t)||new se(e.node(0))}static atStart(e){return Xe(e,e,0,0,1)||new se(e)}static atEnd(e){return Xe(e,e,e.content.size,e.childCount,-1)||new se(e)}static fromJSON(e,t){if(!t||!t.type)throw new RangeError("Invalid input for Selection.fromJSON");let r=En[t.type];if(!r)throw new RangeError(`No selection type ${t.type} defined`);return r.fromJSON(e,t)}static jsonID(e,t){if(e in En)throw new RangeError("Duplicate use of selection JSON ID "+e);return En[e]=t,t.prototype.jsonID=e,t}getBookmark(){return D.between(this.$anchor,this.$head).getBookmark()}}R.prototype.visible=!0;class Ql{constructor(e,t){this.$from=e,this.$to=t}}let to=!1;function no(n){!to&&!n.parent.inlineContent&&(to=!0,console.warn("TextSelection endpoint not pointing into a node with inline content ("+n.parent.type.name+")"))}class D extends R{constructor(e,t=e){no(e),no(t),super(e,t)}get $cursor(){return this.$anchor.pos==this.$head.pos?this.$head:null}map(e,t){let r=e.resolve(t.map(this.head));if(!r.parent.inlineContent)return R.near(r);let o=e.resolve(t.map(this.anchor));return new D(o.parent.inlineContent?o:r,r)}replace(e,t=w.empty){if(super.replace(e,t),t==w.empty){let r=this.$from.marksAcross(this.$to);r&&e.ensureMarks(r)}}eq(e){return e instanceof D&&e.anchor==this.anchor&&e.head==this.head}getBookmark(){return new pn(this.anchor,this.head)}toJSON(){return{type:"text",anchor:this.anchor,head:this.head}}static fromJSON(e,t){if(typeof t.anchor!="number"||typeof t.head!="number")throw new RangeError("Invalid input for TextSelection.fromJSON");return new D(e.resolve(t.anchor),e.resolve(t.head))}static create(e,t,r=t){let o=e.resolve(t);return new this(o,r==t?o:e.resolve(r))}static between(e,t,r){let o=e.pos-t.pos;if((!r||o)&&(r=o>=0?1:-1),!t.parent.inlineContent){let i=R.findFrom(t,r,!0)||R.findFrom(t,-r,!0);if(i)t=i.$head;else return R.near(t,r)}return e.parent.inlineContent||(o==0?e=t:(e=(R.findFrom(e,-r,!0)||R.findFrom(e,r,!0)).$anchor,e.pos0?0:1);o>0?s=0;s+=o){let a=e.child(s);if(a.isAtom){if(!i&&M.isSelectable(a))return M.create(n,t-(o<0?a.nodeSize:0))}else{let l=Xe(n,a,t+o,o<0?a.childCount:0,o,i);if(l)return l}t+=a.nodeSize*o}return null}function ro(n,e,t){let r=n.steps.length-1;if(r{s==null&&(s=d)}),n.setSelection(R.near(n.doc.resolve(s),t))}const oo=1,_t=2,io=4;class tc extends Xl{constructor(e){super(e.doc),this.curSelectionFor=0,this.updated=0,this.meta=Object.create(null),this.time=Date.now(),this.curSelection=e.selection,this.storedMarks=e.storedMarks}get selection(){return this.curSelectionFor0}setStoredMarks(e){return this.storedMarks=e,this.updated|=_t,this}ensureMarks(e){return E.sameSet(this.storedMarks||this.selection.$from.marks(),e)||this.setStoredMarks(e),this}addStoredMark(e){return this.ensureMarks(e.addToSet(this.storedMarks||this.selection.$head.marks()))}removeStoredMark(e){return this.ensureMarks(e.removeFromSet(this.storedMarks||this.selection.$head.marks()))}get storedMarksSet(){return(this.updated&_t)>0}addStep(e,t){super.addStep(e,t),this.updated=this.updated&~_t,this.storedMarks=null}setTime(e){return this.time=e,this}replaceSelection(e){return this.selection.replace(this,e),this}replaceSelectionWith(e,t=!0){let r=this.selection;return t&&(e=e.mark(this.storedMarks||(r.empty?r.$from.marks():r.$from.marksAcross(r.$to)||E.none))),r.replaceWith(this,e),this}deleteSelection(){return this.selection.replace(this),this}insertText(e,t,r){let o=this.doc.type.schema;if(t==null)return e?this.replaceSelectionWith(o.text(e),!0):this.deleteSelection();{if(r==null&&(r=t),!e)return this.deleteRange(t,r);let i=this.storedMarks;if(!i){let s=this.doc.resolve(t);i=r==t?s.marks():s.marksAcross(this.doc.resolve(r))}return this.replaceRangeWith(t,r,o.text(e,i)),!this.selection.empty&&this.selection.to==t+e.length&&this.setSelection(R.near(this.selection.$to)),this}}setMeta(e,t){return this.meta[typeof e=="string"?e:e.key]=t,this}getMeta(e){return this.meta[typeof e=="string"?e:e.key]}get isGeneric(){for(let e in this.meta)return!1;return!0}scrollIntoView(){return this.updated|=io,this}get scrolledIntoView(){return(this.updated&io)>0}}function so(n,e){return!e||!n?n:n.bind(e)}class xt{constructor(e,t,r){this.name=e,this.init=so(t.init,r),this.apply=so(t.apply,r)}}const nc=[new xt("doc",{init(n){return n.doc||n.schema.topNodeType.createAndFill()},apply(n){return n.doc}}),new xt("selection",{init(n,e){return n.selection||R.atStart(e.doc)},apply(n){return n.selection}}),new xt("storedMarks",{init(n){return n.storedMarks||null},apply(n,e,t,r){return r.selection.$cursor?n.storedMarks:null}}),new xt("scrollToSelection",{init(){return 0},apply(n,e){return n.scrolledIntoView?e+1:e}})];class An{constructor(e,t){this.schema=e,this.plugins=[],this.pluginsByKey=Object.create(null),this.fields=nc.slice(),t&&t.forEach(r=>{if(this.pluginsByKey[r.key])throw new RangeError("Adding different instances of a keyed plugin ("+r.key+")");this.plugins.push(r),this.pluginsByKey[r.key]=r,r.spec.state&&this.fields.push(new xt(r.key,r.spec.state,r))})}}class et{constructor(e){this.config=e}get schema(){return this.config.schema}get plugins(){return this.config.plugins}apply(e){return this.applyTransaction(e).state}filterTransaction(e,t=-1){for(let r=0;rr.toJSON())),e&&typeof e=="object")for(let r in e){if(r=="doc"||r=="selection")throw new RangeError("The JSON fields `doc` and `selection` are reserved");let o=e[r],i=o.spec.state;i&&i.toJSON&&(t[r]=i.toJSON.call(o,this[o.key]))}return t}static fromJSON(e,t,r){if(!t)throw new RangeError("Invalid input for EditorState.fromJSON");if(!e.schema)throw new RangeError("Required config field 'schema' missing");let o=new An(e.schema,e.plugins),i=new et(o);return o.fields.forEach(s=>{if(s.name=="doc")i.doc=$e.fromJSON(e.schema,t.doc);else if(s.name=="selection")i.selection=R.fromJSON(i.doc,t.selection);else if(s.name=="storedMarks")t.storedMarks&&(i.storedMarks=t.storedMarks.map(e.schema.markFromJSON));else{if(r)for(let a in r){let l=r[a],c=l.spec.state;if(l.key==s.name&&c&&c.fromJSON&&Object.prototype.hasOwnProperty.call(t,a)){i[s.name]=c.fromJSON.call(l,e,t[a],i);return}}i[s.name]=s.init(e,i)}}),i}}function Li(n,e,t){for(let r in n){let o=n[r];o instanceof Function?o=o.bind(e):r=="handleDOMEvents"&&(o=Li(o,e,{})),t[r]=o}return t}class rc{constructor(e){this.spec=e,this.props={},e.props&&Li(e.props,this,this.props),this.key=e.key?e.key.key:Pi("plugin")}getState(e){return e[this.key]}}const Rn=Object.create(null);function Pi(n){return n in Rn?n+"$"+ ++Rn[n]:(Rn[n]=0,n+"$")}class oc{constructor(e="key"){this.key=Pi(e)}get(e){return e.config.pluginsByKey[this.key]}getState(e){return e[this.key]}}const zi=(n,e)=>n.selection.empty?!1:(e&&e(n.tr.deleteSelection().scrollIntoView()),!0);function ic(n,e){let{$cursor:t}=n.selection;return!t||(e?!e.endOfTextblock("backward",n):t.parentOffset>0)?null:t}const sc=(n,e,t)=>{let r=ic(n,t);if(!r)return!1;let o=Fi(r);if(!o){let s=r.blockRange(),a=s&&dr(s);return a==null?!1:(e&&e(n.tr.lift(s,a).scrollIntoView()),!0)}let i=o.nodeBefore;if($i(n,o,e,-1))return!0;if(r.parent.content.size==0&&(it(i,"end")||M.isSelectable(i)))for(let s=r.depth;;s--){let a=ur(n.doc,r.before(s),r.after(s),w.empty);if(a&&a.slice.size1)break}return i.isAtom&&o.depth==r.depth-1?(e&&e(n.tr.delete(o.pos-i.nodeSize,o.pos).scrollIntoView()),!0):!1};function it(n,e,t=!1){for(let r=n;r;r=e=="start"?r.firstChild:r.lastChild){if(r.isTextblock)return!0;if(t&&r.childCount!=1)return!1}return!1}const ac=(n,e,t)=>{let{$head:r,empty:o}=n.selection,i=r;if(!o)return!1;if(r.parent.isTextblock){if(t?!t.endOfTextblock("backward",n):r.parentOffset>0)return!1;i=Fi(r)}let s=i&&i.nodeBefore;return!s||!M.isSelectable(s)?!1:(e&&e(n.tr.setSelection(M.create(n.doc,i.pos-s.nodeSize)).scrollIntoView()),!0)};function Fi(n){if(!n.parent.type.spec.isolating)for(let e=n.depth-1;e>=0;e--){if(n.index(e)>0)return n.doc.resolve(n.before(e+1));if(n.node(e).type.spec.isolating)break}return null}function lc(n,e){let{$cursor:t}=n.selection;return!t||(e?!e.endOfTextblock("forward",n):t.parentOffset{let r=lc(n,t);if(!r)return!1;let o=Bi(r);if(!o)return!1;let i=o.nodeAfter;if($i(n,o,e,1))return!0;if(r.parent.content.size==0&&(it(i,"start")||M.isSelectable(i))){let s=ur(n.doc,r.before(),r.after(),w.empty);if(s&&s.slice.size{let{$head:r,empty:o}=n.selection,i=r;if(!o)return!1;if(r.parent.isTextblock){if(t?!t.endOfTextblock("forward",n):r.parentOffset=0;e--){let t=n.node(e);if(n.index(e)+1{let{$head:t,$anchor:r}=n.selection;return!t.parent.type.spec.code||!t.sameParent(r)?!1:(e&&e(n.tr.insertText(` -`).scrollIntoView()),!0)};function Vi(n){for(let e=0;e{let t=n.selection,{$from:r,$to:o}=t;if(t instanceof se||r.parent.inlineContent||o.parent.inlineContent)return!1;let i=Vi(o.parent.contentMatchAt(o.indexAfter()));if(!i||!i.isTextblock)return!1;if(e){let s=(!r.parentOffset&&o.index(){let{$cursor:t}=n.selection;if(!t||t.parent.content.size)return!1;if(t.depth>1&&t.after()!=t.end(-1)){let i=t.before();if(Jt(n.doc,i))return e&&e(n.tr.split(i).scrollIntoView()),!0}let r=t.blockRange(),o=r&&dr(r);return o==null?!1:(e&&e(n.tr.lift(r,o).scrollIntoView()),!0)};function hc(n){return(e,t)=>{let{$from:r,$to:o}=e.selection;if(e.selection instanceof M&&e.selection.node.isBlock)return!r.parentOffset||!Jt(e.doc,r.pos)?!1:(t&&t(e.tr.split(r.pos).scrollIntoView()),!0);if(!r.depth)return!1;let i=[],s,a,l=!1,c=!1;for(let h=r.depth;;h--)if(r.node(h).isBlock){l=r.end(h)==r.pos+(r.depth-h),c=r.start(h)==r.pos-(r.depth-h),a=Vi(r.node(h-1).contentMatchAt(r.indexAfter(h-1))),i.unshift(l&&a?{type:a}:null),s=h;break}else{if(h==1)return!1;i.unshift(null)}let d=e.tr;(e.selection instanceof D||e.selection instanceof se)&&d.deleteSelection();let u=d.mapping.map(r.pos),p=Jt(d.doc,u,i.length,i);if(p||(i[0]=a?{type:a}:null,p=Jt(d.doc,u,i.length,i)),!p)return!1;if(d.split(u,i.length,i),!l&&c&&r.node(s).type!=a){let h=d.mapping.map(r.before(s)),m=d.doc.resolve(h);a&&r.node(s-1).canReplaceWith(m.index(),m.index()+1,a)&&d.setNodeMarkup(d.mapping.map(r.before(s)),a)}return t&&t(d.scrollIntoView()),!0}}const _i=hc();function mc(n,e,t){let r=e.nodeBefore,o=e.nodeAfter,i=e.index();return!r||!o||!r.type.compatibleContent(o.type)?!1:!r.content.size&&e.parent.canReplace(i-1,i)?(t&&t(n.tr.delete(e.pos-r.nodeSize,e.pos).scrollIntoView()),!0):!e.parent.canReplace(i,i+1)||!(o.isTextblock||Di(n.doc,e.pos))?!1:(t&&t(n.tr.join(e.pos).scrollIntoView()),!0)}function $i(n,e,t,r){let o=e.nodeBefore,i=e.nodeAfter,s,a,l=o.type.spec.isolating||i.type.spec.isolating;if(!l&&mc(n,e,t))return!0;let c=!l&&e.parent.canReplace(e.index(),e.index()+1);if(c&&(s=(a=o.contentMatchAt(o.childCount)).findWrapping(i.type))&&a.matchType(s[0]||i.type).validEnd){if(t){let h=e.pos+i.nodeSize,m=y.empty;for(let k=s.length-1;k>=0;k--)m=y.from(s[k].create(null,m));m=y.from(o.copy(m));let g=n.tr.step(new ne(e.pos-1,h,e.pos,h,new w(m,1,0),s.length,!0)),x=g.doc.resolve(h+2*s.length);x.nodeAfter&&x.nodeAfter.type==o.type&&Di(g.doc,x.pos)&&g.join(x.pos),t(g.scrollIntoView())}return!0}let d=i.type.spec.isolating||r>0&&l?null:R.findFrom(e,1),u=d&&d.$from.blockRange(d.$to),p=u&&dr(u);if(p!=null&&p>=e.depth)return t&&t(n.tr.lift(u,p).scrollIntoView()),!0;if(c&&it(i,"start",!0)&&it(o,"end")){let h=o,m=[];for(;m.push(h),!h.isTextblock;)h=h.lastChild;let g=i,x=1;for(;!g.isTextblock;g=g.firstChild)x++;if(h.canReplace(h.childCount,h.childCount,g.content)){if(t){let k=y.empty;for(let N=m.length-1;N>=0;N--)k=y.from(m[N].copy(k));let T=n.tr.step(new ne(e.pos-m.length,e.pos+i.nodeSize,e.pos+x,e.pos+i.nodeSize-x,new w(k,m.length,0),0,!0));t(T.scrollIntoView())}return!0}}return!1}function fr(...n){return function(e,t,r){for(let o=0;o0){if(n.contentEditable=="false")return null;n=n.childNodes[e-1],e=oe(n)}else if(n.parentNode&&!Rt(n))e=q(n),n=n.parentNode;else return null}}function yc(n,e){for(;;){if(n.nodeType==3&&e2),re=at||(me?/Mac/.test(me.platform):!1),qi=me?/Win/.test(me.platform):!1,we=/Android \d/.test(Le),It=!!lo&&"webkitFontSmoothing"in lo.documentElement.style,Cc=It?+(/\bAppleWebKit\/(\d+)/.exec(navigator.userAgent)||[0,0])[1]:0;function Sc(n){let e=n.defaultView&&n.defaultView.visualViewport;return e?{left:0,right:e.width,top:0,bottom:e.height}:{left:0,right:n.documentElement.clientWidth,top:0,bottom:n.documentElement.clientHeight}}function ye(n,e){return typeof n=="number"?n:n[e]}function Mc(n){let e=n.getBoundingClientRect(),t=e.width/n.offsetWidth||1,r=e.height/n.offsetHeight||1;return{left:e.left,right:e.left+n.clientWidth*t,top:e.top,bottom:e.top+n.clientHeight*r}}function co(n,e,t){let r=n.someProp("scrollThreshold")||0,o=n.someProp("scrollMargin")||5,i=n.dom.ownerDocument;for(let s=t||n.dom;s;){if(s.nodeType!=1){s=st(s);continue}let a=s,l=a==i.body,c=l?Sc(i):Mc(a),d=0,u=0;if(e.topc.bottom-ye(r,"bottom")&&(u=e.bottom-e.top>c.bottom-c.top?e.top+ye(o,"top")-c.top:e.bottom-c.bottom+ye(o,"bottom")),e.leftc.right-ye(r,"right")&&(d=e.right-c.right+ye(o,"right")),d||u)if(l)i.defaultView.scrollBy(d,u);else{let h=a.scrollLeft,m=a.scrollTop;u&&(a.scrollTop+=u),d&&(a.scrollLeft+=d);let g=a.scrollLeft-h,x=a.scrollTop-m;e={left:e.left-g,top:e.top-x,right:e.right-g,bottom:e.bottom-x}}let p=l?"fixed":getComputedStyle(s).position;if(/^(fixed|sticky)$/.test(p))break;s=p=="absolute"?s.offsetParent:st(s)}}function Nc(n){let e=n.dom.getBoundingClientRect(),t=Math.max(0,e.top),r,o;for(let i=(e.left+e.right)/2,s=t+1;s=t-20){r=a,o=l.top;break}}return{refDOM:r,refTop:o,stack:Ji(n.dom)}}function Ji(n){let e=[],t=n.ownerDocument;for(let r=n;r&&(e.push({dom:r,top:r.scrollTop,left:r.scrollLeft}),n!=t);r=st(r));return e}function Oc({refDOM:n,refTop:e,stack:t}){let r=n?n.getBoundingClientRect().top:0;Ui(t,r==0?0:r-e)}function Ui(n,e){for(let t=0;t=a){s=Math.max(m.bottom,s),a=Math.min(m.top,a);let g=m.left>e.left?m.left-e.left:m.right=(m.left+m.right)/2?1:0));continue}}else m.top>e.top&&!l&&m.left<=e.left&&m.right>=e.left&&(l=d,c={left:Math.max(m.left,Math.min(m.right,e.left)),top:m.top});!t&&(e.left>=m.right&&e.top>=m.top||e.left>=m.left&&e.top>=m.bottom)&&(i=u+1)}}return!t&&l&&(t=l,o=c,r=0),t&&t.nodeType==3?Dc(t,o):!t||r&&t.nodeType==1?{node:n,offset:i}:ji(t,o)}function Dc(n,e){let t=n.nodeValue.length,r=document.createRange(),o;for(let i=0;i=(s.left+s.right)/2?1:0)};break}}return r.detach(),o||{node:n,offset:0}}function hr(n,e){return n.left>=e.left-1&&n.left<=e.right+1&&n.top>=e.top-1&&n.top<=e.bottom+1}function Ec(n,e){let t=n.parentNode;return t&&/^li$/i.test(t.nodeName)&&e.left(s.left+s.right)/2?1:-1}return n.docView.posFromDOM(r,o,i)}function Rc(n,e,t,r){let o=-1;for(let i=e,s=!1;i!=n.dom;){let a=n.docView.nearestDesc(i,!0),l;if(!a)return null;if(a.dom.nodeType==1&&(a.node.isBlock&&a.parent||!a.contentDOM)&&((l=a.dom.getBoundingClientRect()).width||l.height)&&(a.node.isBlock&&a.parent&&!/^T(R|BODY|HEAD|FOOT)$/.test(a.dom.nodeName)&&(!s&&l.left>r.left||l.top>r.top?o=a.posBefore:(!s&&l.right-1?o:n.docView.posFromDOM(e,t,-1)}function Ki(n,e,t){let r=n.childNodes.length;if(r&&t.tope.top&&o++}let c;It&&o&&r.nodeType==1&&(c=r.childNodes[o-1]).nodeType==1&&c.contentEditable=="false"&&c.getBoundingClientRect().top>=e.top&&o--,r==n.dom&&o==r.childNodes.length-1&&r.lastChild.nodeType==1&&e.top>r.lastChild.getBoundingClientRect().bottom?a=n.state.doc.content.size:(o==0||r.nodeType!=1||r.childNodes[o-1].nodeName!="BR")&&(a=Rc(n,r,o,e))}a==null&&(a=Ac(n,s,e));let l=n.docView.nearestDesc(s,!0);return{pos:a,inside:l?l.posAtStart-l.border:-1}}function uo(n){return n.top=0&&o==r.nodeValue.length?(l--,d=1):t<0?l--:c++,ft(Se(ke(r,l,c),d),d<0)}if(!n.state.doc.resolve(e-(i||0)).parent.inlineContent){if(i==null&&o&&(t<0||o==oe(r))){let l=r.childNodes[o-1];if(l.nodeType==1)return In(l.getBoundingClientRect(),!1)}if(i==null&&o=0)}if(i==null&&o&&(t<0||o==oe(r))){let l=r.childNodes[o-1],c=l.nodeType==3?ke(l,oe(l)-(s?0:1)):l.nodeType==1&&(l.nodeName!="BR"||!l.nextSibling)?l:null;if(c)return ft(Se(c,1),!1)}if(i==null&&o=0)}function ft(n,e){if(n.width==0)return n;let t=e?n.left:n.right;return{top:n.top,bottom:n.bottom,left:t,right:t}}function In(n,e){if(n.height==0)return n;let t=e?n.top:n.bottom;return{top:t,bottom:t,left:n.left,right:n.right}}function Yi(n,e,t){let r=n.state,o=n.root.activeElement;r!=e&&n.updateState(e),o!=n.dom&&n.focus();try{return t()}finally{r!=e&&n.updateState(r),o!=n.dom&&o&&o.focus()}}function Pc(n,e,t){let r=e.selection,o=t=="up"?r.$from:r.$to;return Yi(n,e,()=>{let{node:i}=n.docView.domFromPos(o.pos,t=="up"?-1:1);for(;;){let a=n.docView.nearestDesc(i,!0);if(!a)break;if(a.node.isBlock){i=a.contentDOM||a.dom;break}i=a.dom.parentNode}let s=Zi(n,o.pos,1);for(let a=i.firstChild;a;a=a.nextSibling){let l;if(a.nodeType==1)l=a.getClientRects();else if(a.nodeType==3)l=ke(a,0,a.nodeValue.length).getClientRects();else continue;for(let c=0;cd.top+1&&(t=="up"?s.top-d.top>(d.bottom-s.top)*2:d.bottom-s.bottom>(s.bottom-d.top)*2))return!1}}return!0})}const zc=/[\u0590-\u08ac]/;function Fc(n,e,t){let{$head:r}=e.selection;if(!r.parent.isTextblock)return!1;let o=r.parentOffset,i=!o,s=o==r.parent.content.size,a=n.domSelection();return a?!zc.test(r.parent.textContent)||!a.modify?t=="left"||t=="backward"?i:s:Yi(n,e,()=>{let{focusNode:l,focusOffset:c,anchorNode:d,anchorOffset:u}=n.domSelectionRange(),p=a.caretBidiLevel;a.modify("move",t,"character");let h=r.depth?n.docView.domAfterPos(r.before()):n.dom,{focusNode:m,focusOffset:g}=n.domSelectionRange(),x=m&&!h.contains(m.nodeType==1?m:m.parentNode)||l==m&&c==g;try{a.collapse(d,u),l&&(l!=d||c!=u)&&a.extend&&a.extend(l,c)}catch{}return p!=null&&(a.caretBidiLevel=p),x}):r.pos==r.start()||r.pos==r.end()}let po=null,fo=null,ho=!1;function Bc(n,e,t){return po==e&&fo==t?ho:(po=e,fo=t,ho=t=="up"||t=="down"?Pc(n,e,t):Fc(n,e,t))}const le=0,mo=1,ze=2,ge=3;class Lt{constructor(e,t,r,o){this.parent=e,this.children=t,this.dom=r,this.contentDOM=o,this.dirty=le,r.pmViewDesc=this}matchesWidget(e){return!1}matchesMark(e){return!1}matchesNode(e,t,r){return!1}matchesHack(e){return!1}parseRule(){return null}stopEvent(e){return!1}get size(){let e=0;for(let t=0;tq(this.contentDOM);else if(this.contentDOM&&this.contentDOM!=this.dom&&this.dom.contains(this.contentDOM))o=e.compareDocumentPosition(this.contentDOM)&2;else if(this.dom.firstChild){if(t==0)for(let i=e;;i=i.parentNode){if(i==this.dom){o=!1;break}if(i.previousSibling)break}if(o==null&&t==e.childNodes.length)for(let i=e;;i=i.parentNode){if(i==this.dom){o=!0;break}if(i.nextSibling)break}}return o??r>0?this.posAtEnd:this.posAtStart}nearestDesc(e,t=!1){for(let r=!0,o=e;o;o=o.parentNode){let i=this.getDesc(o),s;if(i&&(!t||i.node))if(r&&(s=i.nodeDOM)&&!(s.nodeType==1?s.contains(e.nodeType==1?e:e.parentNode):s==e))r=!1;else return i}}getDesc(e){let t=e.pmViewDesc;for(let r=t;r;r=r.parent)if(r==this)return t}posFromDOM(e,t,r){for(let o=e;o;o=o.parentNode){let i=this.getDesc(o);if(i)return i.localPosFromDOM(e,t,r)}return-1}descAt(e){for(let t=0,r=0;te||s instanceof Xi){o=e-i;break}i=a}if(o)return this.children[r].domFromPos(o-this.children[r].border,t);for(let i;r&&!(i=this.children[r-1]).size&&i instanceof Gi&&i.side>=0;r--);if(t<=0){let i,s=!0;for(;i=r?this.children[r-1]:null,!(!i||i.dom.parentNode==this.contentDOM);r--,s=!1);return i&&t&&s&&!i.border&&!i.domAtom?i.domFromPos(i.size,t):{node:this.contentDOM,offset:i?q(i.dom)+1:0}}else{let i,s=!0;for(;i=r=d&&t<=c-l.border&&l.node&&l.contentDOM&&this.contentDOM.contains(l.contentDOM))return l.parseRange(e,t,d);e=s;for(let u=a;u>0;u--){let p=this.children[u-1];if(p.size&&p.dom.parentNode==this.contentDOM&&!p.emptyChildAt(1)){o=q(p.dom)+1;break}e-=p.size}o==-1&&(o=0)}if(o>-1&&(c>t||a==this.children.length-1)){t=c;for(let d=a+1;dm&&st){let m=a;a=l,l=m}let h=document.createRange();h.setEnd(l.node,l.offset),h.setStart(a.node,a.offset),c.removeAllRanges(),c.addRange(h)}}ignoreMutation(e){return!this.contentDOM&&e.type!="selection"}get contentLost(){return this.contentDOM&&this.contentDOM!=this.dom&&!this.dom.contains(this.contentDOM)}markDirty(e,t){for(let r=0,o=0;o=r:er){let a=r+i.border,l=s-i.border;if(e>=a&&t<=l){this.dirty=e==r||t==s?ze:mo,e==a&&t==l&&(i.contentLost||i.dom.parentNode!=this.contentDOM)?i.dirty=ge:i.markDirty(e-a,t-a);return}else i.dirty=i.dom==i.contentDOM&&i.dom.parentNode==this.contentDOM&&!i.children.length?ze:ge}r=s}this.dirty=ze}markParentsDirty(){let e=1;for(let t=this.parent;t;t=t.parent,e++){let r=e==1?ze:mo;t.dirty{if(!i)return o;if(i.parent)return i.parent.posBeforeChild(i)})),!t.type.spec.raw){if(s.nodeType!=1){let a=document.createElement("span");a.appendChild(s),s=a}s.contentEditable="false",s.classList.add("ProseMirror-widget")}super(e,[],s,null),this.widget=t,this.widget=t,i=this}matchesWidget(e){return this.dirty==le&&e.type.eq(this.widget.type)}parseRule(){return{ignore:!0}}stopEvent(e){let t=this.widget.spec.stopEvent;return t?t(e):!1}ignoreMutation(e){return e.type!="selection"||this.widget.spec.ignoreSelection}destroy(){this.widget.type.destroy(this.dom),super.destroy()}get domAtom(){return!0}get ignoreForSelection(){return!!this.widget.type.spec.relaxedSide}get side(){return this.widget.type.side}}class Vc extends Lt{constructor(e,t,r,o){super(e,[],t,null),this.textDOM=r,this.text=o}get size(){return this.text.length}localPosFromDOM(e,t){return e!=this.textDOM?this.posAtStart+(t?this.size:0):this.posAtStart+t}domFromPos(e){return{node:this.textDOM,offset:e}}ignoreMutation(e){return e.type==="characterData"&&e.target.nodeValue==e.oldValue}}class Ke extends Lt{constructor(e,t,r,o,i){super(e,[],r,o),this.mark=t,this.spec=i}static create(e,t,r,o){let i=o.nodeViews[t.type.name],s=i&&i(t,o,r);return(!s||!s.dom)&&(s=ct.renderSpec(document,t.type.spec.toDOM(t,r),null,t.attrs)),new Ke(e,t,s.dom,s.contentDOM||s.dom,s)}parseRule(){return this.dirty&ge||this.mark.type.spec.reparseInView?null:{mark:this.mark.type.name,attrs:this.mark.attrs,contentElement:this.contentDOM}}matchesMark(e){return this.dirty!=ge&&this.mark.eq(e)}markDirty(e,t){if(super.markDirty(e,t),this.dirty!=le){let r=this.parent;for(;!r.node;)r=r.parent;r.dirty0&&(i=er(i,0,e,r));for(let a=0;a{if(!l)return s;if(l.parent)return l.parent.posBeforeChild(l)},r,o),d=c&&c.dom,u=c&&c.contentDOM;if(t.isText){if(!d)d=document.createTextNode(t.text);else if(d.nodeType!=3)throw new RangeError("Text must be rendered as a DOM text node")}else d||({dom:d,contentDOM:u}=ct.renderSpec(document,t.type.spec.toDOM(t),null,t.attrs));!u&&!t.isText&&d.nodeName!="BR"&&(d.hasAttribute("contenteditable")||(d.contentEditable="false"),t.type.spec.draggable&&(d.draggable=!0));let p=d;return d=ts(d,r,t),c?l=new _c(e,t,r,o,d,u||null,p,c,i,s+1):t.isText?new hn(e,t,r,o,d,p,i):new Re(e,t,r,o,d,u||null,p,i,s+1)}parseRule(){if(this.node.type.spec.reparseInView)return null;let e={node:this.node.type.name,attrs:this.node.attrs};if(this.node.type.whitespace=="pre"&&(e.preserveWhitespace="full"),!this.contentDOM)e.getContent=()=>this.node.content;else if(!this.contentLost)e.contentElement=this.contentDOM;else{for(let t=this.children.length-1;t>=0;t--){let r=this.children[t];if(this.dom.contains(r.dom.parentNode)){e.contentElement=r.dom.parentNode;break}}e.contentElement||(e.getContent=()=>y.empty)}return e}matchesNode(e,t,r){return this.dirty==le&&e.eq(this.node)&&tn(t,this.outerDeco)&&r.eq(this.innerDeco)}get size(){return this.node.nodeSize}get border(){return this.node.isLeaf?0:1}updateChildren(e,t){let r=this.node.inlineContent,o=t,i=e.composing?this.localCompositionInfo(e,t):null,s=i&&i.pos>-1?i:null,a=i&&i.pos<0,l=new Wc(this,s&&s.node,e);Jc(this.node,this.innerDeco,(c,d,u)=>{c.spec.marks?l.syncToMarks(c.spec.marks,r,e,d):c.type.side>=0&&!u&&l.syncToMarks(d==this.node.childCount?E.none:this.node.child(d).marks,r,e,d),l.placeWidget(c,e,o)},(c,d,u,p)=>{l.syncToMarks(c.marks,r,e,p);let h;l.findNodeMatch(c,d,u,p)||a&&e.state.selection.from>o&&e.state.selection.to-1&&l.updateNodeAt(c,d,u,h,e)||l.updateNextNode(c,d,u,e,p,o)||l.addNode(c,d,u,e,o),o+=c.nodeSize}),l.syncToMarks([],r,e,0),this.node.isTextblock&&l.addTextblockHacks(),l.destroyRest(),(l.changed||this.dirty==ze)&&(s&&this.protectLocalComposition(e,s),Qi(this.contentDOM,this.children,e),at&&Uc(this.dom))}localCompositionInfo(e,t){let{from:r,to:o}=e.state.selection;if(!(e.state.selection instanceof D)||rt+this.node.content.size)return null;let i=e.input.compositionNode;if(!i||!this.dom.contains(i.parentNode))return null;if(this.node.inlineContent){let s=i.nodeValue,a=jc(this.node.content,s,r-t,o-t);return a<0?null:{node:i,pos:a,text:s}}else return{node:i,pos:-1,text:""}}protectLocalComposition(e,{node:t,pos:r,text:o}){if(this.getDesc(t))return;let i=t;for(;i.parentNode!=this.contentDOM;i=i.parentNode){for(;i.previousSibling;)i.parentNode.removeChild(i.previousSibling);for(;i.nextSibling;)i.parentNode.removeChild(i.nextSibling);i.pmViewDesc&&(i.pmViewDesc=void 0)}let s=new Vc(this,i,t,o);e.input.compositionNodes.push(s),this.children=er(this.children,r,r+o.length,e,s)}update(e,t,r,o){return this.dirty==ge||!e.sameMarkup(this.node)?!1:(this.updateInner(e,t,r,o),!0)}updateInner(e,t,r,o){this.updateOuterDeco(t),this.node=e,this.innerDeco=r,this.contentDOM&&this.updateChildren(o,this.posAtStart),this.dirty=le}updateOuterDeco(e){if(tn(e,this.outerDeco))return;let t=this.nodeDOM.nodeType!=1,r=this.dom;this.dom=es(this.dom,this.nodeDOM,Qn(this.outerDeco,this.node,t),Qn(e,this.node,t)),this.dom!=r&&(r.pmViewDesc=void 0,this.dom.pmViewDesc=this),this.outerDeco=e}selectNode(){this.nodeDOM.nodeType==1&&(this.nodeDOM.classList.add("ProseMirror-selectednode"),(this.contentDOM||!this.node.type.spec.draggable)&&(this.nodeDOM.draggable=!0))}deselectNode(){this.nodeDOM.nodeType==1&&(this.nodeDOM.classList.remove("ProseMirror-selectednode"),(this.contentDOM||!this.node.type.spec.draggable)&&this.nodeDOM.removeAttribute("draggable"))}get domAtom(){return this.node.isAtom}}function go(n,e,t,r,o){ts(r,e,n);let i=new Re(void 0,n,e,t,r,r,r,o,0);return i.contentDOM&&i.updateChildren(o,0),i}class hn extends Re{constructor(e,t,r,o,i,s,a){super(e,t,r,o,i,null,s,a,0)}parseRule(){let e=this.nodeDOM.parentNode;for(;e&&e!=this.dom&&!e.pmIsDeco;)e=e.parentNode;return{skip:e||!0}}update(e,t,r,o){return this.dirty==ge||this.dirty!=le&&!this.inParent()||!e.sameMarkup(this.node)?!1:(this.updateOuterDeco(t),(this.dirty!=le||e.text!=this.node.text)&&e.text!=this.nodeDOM.nodeValue&&(this.nodeDOM.nodeValue=e.text,o.trackWrites==this.nodeDOM&&(o.trackWrites=null)),this.node=e,this.dirty=le,!0)}inParent(){let e=this.parent.contentDOM;for(let t=this.nodeDOM;t;t=t.parentNode)if(t==e)return!0;return!1}domFromPos(e){return{node:this.nodeDOM,offset:e}}localPosFromDOM(e,t,r){return e==this.nodeDOM?this.posAtStart+Math.min(t,this.node.text.length):super.localPosFromDOM(e,t,r)}ignoreMutation(e){return e.type!="characterData"&&e.type!="selection"}slice(e,t,r){let o=this.node.cut(e,t),i=document.createTextNode(o.text);return new hn(this.parent,o,this.outerDeco,this.innerDeco,i,i,r)}markDirty(e,t){super.markDirty(e,t),this.dom!=this.nodeDOM&&(e==0||t==this.nodeDOM.nodeValue.length)&&(this.dirty=ge)}get domAtom(){return!1}isText(e){return this.node.text==e}}class Xi extends Lt{parseRule(){return{ignore:!0}}matchesHack(e){return this.dirty==le&&this.dom.nodeName==e}get domAtom(){return!0}get ignoreForCoords(){return this.dom.nodeName=="IMG"}}class _c extends Re{constructor(e,t,r,o,i,s,a,l,c,d){super(e,t,r,o,i,s,a,c,d),this.spec=l}update(e,t,r,o){if(this.dirty==ge)return!1;if(this.spec.update&&(this.node.type==e.type||this.spec.multiType)){let i=this.spec.update(e,t,r);return i&&this.updateInner(e,t,r,o),i}else return!this.contentDOM&&!e.isLeaf?!1:super.update(e,t,r,o)}selectNode(){this.spec.selectNode?this.spec.selectNode():super.selectNode()}deselectNode(){this.spec.deselectNode?this.spec.deselectNode():super.deselectNode()}setSelection(e,t,r,o){this.spec.setSelection?this.spec.setSelection(e,t,r.root):super.setSelection(e,t,r,o)}destroy(){this.spec.destroy&&this.spec.destroy(),super.destroy()}stopEvent(e){return this.spec.stopEvent?this.spec.stopEvent(e):!1}ignoreMutation(e){return this.spec.ignoreMutation?this.spec.ignoreMutation(e):super.ignoreMutation(e)}}function Qi(n,e,t){let r=n.firstChild,o=!1;for(let i=0;i>1,a=Math.min(s,e.length);for(;i-1)l>this.index&&(this.changed=!0,this.destroyBetween(this.index,l)),this.top=this.top.children[this.index];else{let d=Ke.create(this.top,e[s],t,r);this.top.children.splice(this.index,0,d),this.top=d,this.changed=!0}this.index=0,s++}}findNodeMatch(e,t,r,o){let i=-1,s;if(o>=this.preMatch.index&&(s=this.preMatch.matches[o-this.preMatch.index]).parent==this.top&&s.matchesNode(e,t,r))i=this.top.children.indexOf(s,this.index);else for(let a=this.index,l=Math.min(this.top.children.length,a+5);a0;){let a;for(;;)if(r){let c=t.children[r-1];if(c instanceof Ke)t=c,r=c.children.length;else{a=c,r--;break}}else{if(t==e)break e;r=t.parent.children.indexOf(t),t=t.parent}let l=a.node;if(l){if(l!=n.child(o-1))break;--o,i.set(a,o),s.push(a)}}return{index:o,matched:i,matches:s.reverse()}}function qc(n,e){return n.type.side-e.type.side}function Jc(n,e,t,r){let o=e.locals(n),i=0;if(o.length==0){for(let c=0;ci;)a.push(o[s++]);let m=i+p.nodeSize;if(p.isText){let x=m;s!x.inline):a.slice();r(p,g,e.forChild(i,p),h),i=m}}function Uc(n){if(n.nodeName=="UL"||n.nodeName=="OL"){let e=n.style.cssText;n.style.cssText=e+"; list-style: square !important",window.getComputedStyle(n).listStyle,n.style.cssText=e}}function jc(n,e,t,r){for(let o=0,i=0;o=t){if(i>=r&&l.slice(r-e.length-a,r-a)==e)return r-e.length;let c=a=0&&c+e.length+a>=t)return a+c;if(t==r&&l.length>=r+e.length-a&&l.slice(r-a,r-a+e.length)==e)return r}}return-1}function er(n,e,t,r,o){let i=[];for(let s=0,a=0;s=t||d<=e?i.push(l):(ct&&i.push(l.slice(t-c,l.size,r)))}return i}function mr(n,e=null){let t=n.domSelectionRange(),r=n.state.doc;if(!t.focusNode)return null;let o=n.docView.nearestDesc(t.focusNode),i=o&&o.size==0,s=n.docView.posFromDOM(t.focusNode,t.focusOffset,1);if(s<0)return null;let a=r.resolve(s),l,c;if(fn(t)){for(l=s;o&&!o.node;)o=o.parent;let u=o.node;if(o&&u.isAtom&&M.isSelectable(u)&&o.parent&&!(u.isInline&&kc(t.focusNode,t.focusOffset,o.dom))){let p=o.posBefore;c=new M(s==p?a:r.resolve(p))}}else{if(t instanceof n.dom.ownerDocument.defaultView.Selection&&t.rangeCount>1){let u=s,p=s;for(let h=0;h{(t.anchorNode!=r||t.anchorOffset!=o)&&(e.removeEventListener("selectionchange",n.input.hideSelectionGuard),setTimeout(()=>{(!ns(n)||n.state.selection.visible)&&n.dom.classList.remove("ProseMirror-hideselection")},20))})}function Zc(n){let e=n.domSelection();if(!e)return;let t=n.cursorWrapper.dom,r=t.nodeName=="IMG";r?e.collapse(t.parentNode,q(t)+1):e.collapse(t,0),!r&&!n.state.selection.visible&&G&&Ae<=11&&(t.disabled=!0,t.disabled=!1)}function rs(n,e){if(e instanceof M){let t=n.docView.descAt(e.from);t!=n.lastSelectedViewDesc&&(wo(n),t&&t.selectNode(),n.lastSelectedViewDesc=t)}else wo(n)}function wo(n){n.lastSelectedViewDesc&&(n.lastSelectedViewDesc.parent&&n.lastSelectedViewDesc.deselectNode(),n.lastSelectedViewDesc=void 0)}function gr(n,e,t,r){return n.someProp("createSelectionBetween",o=>o(n,e,t))||D.between(e,t,r)}function vo(n){return n.editable&&!n.hasFocus()?!1:is(n)}function is(n){let e=n.domSelectionRange();if(!e.anchorNode)return!1;try{return n.dom.contains(e.anchorNode.nodeType==3?e.anchorNode.parentNode:e.anchorNode)&&(n.editable||n.dom.contains(e.focusNode.nodeType==3?e.focusNode.parentNode:e.focusNode))}catch{return!1}}function Yc(n){let e=n.docView.domFromPos(n.state.selection.anchor,0),t=n.domSelectionRange();return je(e.node,e.offset,t.anchorNode,t.anchorOffset)}function tr(n,e){let{$anchor:t,$head:r}=n.selection,o=e>0?t.max(r):t.min(r),i=o.parent.inlineContent?o.depth?n.doc.resolve(e>0?o.after():o.before()):null:o;return i&&R.findFrom(i,e)}function Me(n,e){return n.dispatch(n.state.tr.setSelection(e).scrollIntoView()),!0}function Co(n,e,t){let r=n.state.selection;if(r instanceof D)if(t.indexOf("s")>-1){let{$head:o}=r,i=o.textOffset?null:e<0?o.nodeBefore:o.nodeAfter;if(!i||i.isText||!i.isLeaf)return!1;let s=n.state.doc.resolve(o.pos+i.nodeSize*(e<0?-1:1));return Me(n,new D(r.$anchor,s))}else if(r.empty){if(n.endOfTextblock(e>0?"forward":"backward")){let o=tr(n.state,e);return o&&o instanceof M?Me(n,o):!1}else if(!(re&&t.indexOf("m")>-1)){let o=r.$head,i=o.textOffset?null:e<0?o.nodeBefore:o.nodeAfter,s;if(!i||i.isText)return!1;let a=e<0?o.pos-i.nodeSize:o.pos;return i.isAtom||(s=n.docView.descAt(a))&&!s.contentDOM?M.isSelectable(i)?Me(n,new M(e<0?n.state.doc.resolve(o.pos-i.nodeSize):o)):It?Me(n,new D(n.state.doc.resolve(e<0?a:a+i.nodeSize))):!1:!1}}else return!1;else{if(r instanceof M&&r.node.isInline)return Me(n,new D(e>0?r.$to:r.$from));{let o=tr(n.state,e);return o?Me(n,o):!1}}}function nn(n){return n.nodeType==3?n.nodeValue.length:n.childNodes.length}function vt(n,e){let t=n.pmViewDesc;return t&&t.size==0&&(e<0||n.nextSibling||n.nodeName!="BR")}function Ge(n,e){return e<0?Gc(n):Xc(n)}function Gc(n){let e=n.domSelectionRange(),t=e.focusNode,r=e.focusOffset;if(!t)return;let o,i,s=!1;for(ae&&t.nodeType==1&&r0){if(t.nodeType!=1)break;{let a=t.childNodes[r-1];if(vt(a,-1))o=t,i=--r;else if(a.nodeType==3)t=a,r=t.nodeValue.length;else break}}else{if(ss(t))break;{let a=t.previousSibling;for(;a&&vt(a,-1);)o=t.parentNode,i=q(a),a=a.previousSibling;if(a)t=a,r=nn(t);else{if(t=t.parentNode,t==n.dom)break;r=0}}}s?nr(n,t,r):o&&nr(n,o,i)}function Xc(n){let e=n.domSelectionRange(),t=e.focusNode,r=e.focusOffset;if(!t)return;let o=nn(t),i,s;for(;;)if(r{n.state==o&&ve(n)},50)}function So(n,e){let t=n.state.doc.resolve(e);if(!(J||qi)&&t.parent.inlineContent){let o=n.coordsAtPos(e);if(e>t.start()){let i=n.coordsAtPos(e-1),s=(i.top+i.bottom)/2;if(s>o.top&&s1)return i.lefto.top&&s1)return i.left>o.left?"ltr":"rtl"}}return getComputedStyle(n.dom).direction=="rtl"?"rtl":"ltr"}function Mo(n,e,t){let r=n.state.selection;if(r instanceof D&&!r.empty||t.indexOf("s")>-1||re&&t.indexOf("m")>-1)return!1;let{$from:o,$to:i}=r;if(!o.parent.inlineContent||n.endOfTextblock(e<0?"up":"down")){let s=tr(n.state,e);if(s&&s instanceof M)return Me(n,s)}if(!o.parent.inlineContent){let s=e<0?o:i,a=r instanceof se?R.near(s,e):R.findFrom(s,e);return a?Me(n,a):!1}return!1}function No(n,e){if(!(n.state.selection instanceof D))return!0;let{$head:t,$anchor:r,empty:o}=n.state.selection;if(!t.sameParent(r))return!0;if(!o)return!1;if(n.endOfTextblock(e>0?"forward":"backward"))return!0;let i=!t.textOffset&&(e<0?t.nodeBefore:t.nodeAfter);if(i&&!i.isText){let s=n.state.tr;return e<0?s.delete(t.pos-i.nodeSize,t.pos):s.delete(t.pos,t.pos+i.nodeSize),n.dispatch(s),!0}return!1}function Oo(n,e,t){n.domObserver.stop(),e.contentEditable=t,n.domObserver.start()}function td(n){if(!j||n.state.selection.$head.parentOffset>0)return!1;let{focusNode:e,focusOffset:t}=n.domSelectionRange();if(e&&e.nodeType==1&&t==0&&e.firstChild&&e.firstChild.contentEditable=="false"){let r=e.firstChild;Oo(n,r,"true"),setTimeout(()=>Oo(n,r,"false"),20)}return!1}function nd(n){let e="";return n.ctrlKey&&(e+="c"),n.metaKey&&(e+="m"),n.altKey&&(e+="a"),n.shiftKey&&(e+="s"),e}function rd(n,e){let t=e.keyCode,r=nd(e);if(t==8||re&&t==72&&r=="c")return No(n,-1)||Ge(n,-1);if(t==46&&!e.shiftKey||re&&t==68&&r=="c")return No(n,1)||Ge(n,1);if(t==13||t==27)return!0;if(t==37||re&&t==66&&r=="c"){let o=t==37?So(n,n.state.selection.from)=="ltr"?-1:1:-1;return Co(n,o,r)||Ge(n,o)}else if(t==39||re&&t==70&&r=="c"){let o=t==39?So(n,n.state.selection.from)=="ltr"?1:-1:1;return Co(n,o,r)||Ge(n,o)}else{if(t==38||re&&t==80&&r=="c")return Mo(n,-1,r)||Ge(n,-1);if(t==40||re&&t==78&&r=="c")return td(n)||Mo(n,1,r)||Ge(n,1);if(r==(re?"m":"c")&&(t==66||t==73||t==89||t==90))return!0}return!1}function xr(n,e){n.someProp("transformCopied",h=>{e=h(e,n)});let t=[],{content:r,openStart:o,openEnd:i}=e;for(;o>1&&i>1&&r.childCount==1&&r.firstChild.childCount==1;){o--,i--;let h=r.firstChild;t.push(h.type.name,h.attrs!=h.type.defaultAttrs?h.attrs:null),r=h.content}let s=n.someProp("clipboardSerializer")||ct.fromSchema(n.state.schema),a=ps(),l=a.createElement("div");l.appendChild(s.serializeFragment(r,{document:a}));let c=l.firstChild,d,u=0;for(;c&&c.nodeType==1&&(d=us[c.nodeName.toLowerCase()]);){for(let h=d.length-1;h>=0;h--){let m=a.createElement(d[h]);for(;l.firstChild;)m.appendChild(l.firstChild);l.appendChild(m),u++}c=l.firstChild}c&&c.nodeType==1&&c.setAttribute("data-pm-slice",`${o} ${i}${u?` -${u}`:""} ${JSON.stringify(t)}`);let p=n.someProp("clipboardTextSerializer",h=>h(e,n))||e.content.textBetween(0,e.content.size,` +${Vr}`:Vr}function Pa(n){return n.includes("skill://plan")||/(?:^|\s)\$plan(?:\s|$)/.test(n)}function za(n){return n?.id==="codex"?"GPT-5.5":n?.displayName??"GPT-5.5"}function Fa(n){const e=n.displayName,t=e.toLowerCase();return n.id==="claude"||t.includes("claude")?"Cl":n.id==="opencode"||t.includes("open")?"O":t==="pi"||t.includes(" pi")?"Pi":e.trim().slice(0,1).toUpperCase()||"C"}function Ba(n){const e=n.usedPercent,t=n.remainingPercent,r=e!=null&&Number.isFinite(e)?gt(e):gt(100-(t??0)),o=t!=null&&Number.isFinite(t)?gt(t):gt(100-r);return{...n,remainingPercent:o,usedPercent:r,windowDurationMins:Number.isFinite(n.windowDurationMins)?n.windowDurationMins:void 0}}function ti(n){return Number.isFinite(n)?`${Math.round(gt(n))}%`:"100%"}function ni(n,e=new Date){if(n==null||!Number.isFinite(n))return null;const t=new Date(n*1e3);if(!Number.isFinite(t.getTime()))return null;const r=Math.floor((t.getTime()-e.getTime())/1e3);return r>0&&r<3600?new Intl.DateTimeFormat(void 0,{timeStyle:"short"}).format(t):Va(t,e)?new Intl.DateTimeFormat(void 0,{timeStyle:"short"}).format(t):new Intl.DateTimeFormat(void 0,{month:"short",day:"numeric"}).format(t)}function ri(n,e,t){if(n==null||!Number.isFinite(n)||n<=0)return e;const r=Math.round(n);return _r(n,720*60)?t?.monthly??e:_r(n,10080)?t?.weekly??e:r>=1440?On(t?.daysFormat,Math.ceil(r/1440),e):r>=60?On(t?.hoursFormat,Math.ceil(r/60),e):On(t?.minutesFormat,Math.max(1,Math.ceil(r)),e)}function gt(n){return Number.isFinite(n)?Math.min(Math.max(n,0),100):100}function _r(n,e){return n>=e*.95&&n<=e*1.05}function Va(n,e){return n.getFullYear()===e.getFullYear()&&n.getMonth()===e.getMonth()&&n.getDate()===e.getDate()}function On(n,e,t){const r=new Intl.NumberFormat(void 0,{maximumFractionDigits:0}).format(e);return n==null||n.length===0?t:n.replaceAll("%@",r).replaceAll("%d",r).replaceAll("{value}",r)}const $r=256*1024,Wr=64*1024,_a=8*1024,Hr=`[earlier assistant output truncated] +`,qr=`[earlier command output truncated] +`,$a=`[earlier log output truncated] +`;function Wa(n){return{selectedProviderId:"codex",status:"loading",input:"",providers:[],log:[],transcript:[],autoStartAttemptedProviderIds:[],seenSessionIds:[]}}function Ha(n,e){switch(e.type){case"context":return rl(qa(n,e.context));case"providers":return{...n,providers:e.providers};case"selectProvider":return lr(n)?{...n,selectedProviderId:e.providerId}:n;case"setInput":return{...n,input:e.input};case"autoStartAttempted":return n.autoStartAttemptedProviderIds.includes(e.providerId)?n:{...n,autoStartAttemptedProviderIds:[...n.autoStartAttemptedProviderIds,e.providerId]};case"starting":return{...n,status:"starting",log:oe(n,"info",ue(n,"startingStatus","Starting"))};case"startAccepted":return n.status!=="starting"||n.runningSessionId?n:{...n,runningSessionId:e.sessionId,requestedStopSessionId:void 0,seenSessionIds:nt(n,e.sessionId),status:"running"};case"stopping":return{...n,status:"stopping",requestedStopSessionId:e.sessionId,log:oe(n,"info",ue(n,"stoppingStatus","Stopping"))};case"failed":return{...n,status:"failed",log:oe(n,"error",e.message),transcript:kt(n,e.message,"error")};case"failedForSession":case"sendFailed":return n.runningSessionId!==e.sessionId||n.requestedStopSessionId===e.sessionId?n:{...n,status:"failed",log:oe(n,"error",e.message),transcript:kt(n,e.message,"error")};case"stopFailed":return n.runningSessionId!==e.sessionId&&n.requestedStopSessionId!==e.sessionId?n:{...n,requestedStopSessionId:void 0,status:"failed",log:oe(n,"error",e.message),transcript:kt(n,e.message,"error")};case"sent":return n.runningSessionId!==e.sessionId||n.requestedStopSessionId===e.sessionId?n:{...n,input:n.input===e.submittedInput?"":n.input,log:oe(n,"info",wt(n,"sentCharsFormat","Sent %d chars",e.text.length)),transcript:si(n,e.displayText??e.text,e.attachments,e.sentAtMs)};case"event":return el(n,e.event);default:return n}}function qa(n,e){return e.activeSession?{...n,context:e,runningSessionId:e.activeSession.sessionId,requestedStopSessionId:void 0,selectedProviderId:e.activeSession.providerId,seenSessionIds:nt(n,e.activeSession.sessionId),status:"running"}:n.runningSessionId?{...n,context:e,status:n.status==="loading"?"running":n.status}:{...n,context:e,selectedProviderId:e.initialProviderId,status:"idle"}}async function Ja(n){try{const[e,t]=await Promise.all([qe("app.context"),qe("provider.list")]);sr(e.theme),n({type:"context",context:e}),n({type:"providers",providers:t})}catch(e){n({type:"failed",message:Lt(e)})}}async function Ua(n,e){ar(n)&&await ii(oi(n),e)}function oi(n){return{providerId:n.selectedProviderId,workingDirectory:n.context?.workingDirectory,copy:n.context?.copy}}async function ii(n,e){e({type:"starting"});try{const t=await qe("provider.start",{providerId:n.providerId,workingDirectory:n.workingDirectory});e({type:"startAccepted",sessionId:t.sessionId})}catch(t){e({type:"failed",message:Lt(t,n.copy)})}}function ja(n){return!ar(n)||n.autoStartAttemptedProviderIds.includes(n.selectedProviderId)?!1:n.providers.find(t=>t.id===n.selectedProviderId)?.autoStart===!0}async function Ka(n,e){if(!ja(n))return;const t=n.selectedProviderId,r=oi(n);e({type:"autoStartAttempted",providerId:t}),await ii(r,e)}function Za(n,e,t){lr(e)&&(t({type:"selectProvider",providerId:n}),qe("provider.select",{providerId:n}).catch(()=>{}))}async function Ya(n,e,t={}){const r=t.text??n.input,o=t.clearInput??r;if(r.length===0||!n.runningSessionId||n.status!=="running")return!1;const i=n.runningSessionId;try{return await qe("provider.writeLine",{permissionMode:t.permissionMode??"default",sessionId:i,text:r}),e({type:"sent",attachments:t.attachments,displayText:t.displayText,sentAtMs:Date.now(),sessionId:i,text:r,submittedInput:o}),!0}catch(s){return al(s)||e({type:"sendFailed",sessionId:i,message:Lt(s,n)}),!1}}async function Ga(n,e){if(!n.runningSessionId||n.status==="stopping")return;const t=n.runningSessionId;e({type:"stopping",sessionId:t});try{await qe("provider.stop",{sessionId:t})}catch(r){e({type:"stopFailed",sessionId:t,message:Lt(r,n)})}}function Xa(n){switch(n.status){case"loading":return ue(n,"loadingStatus","Loading");case"idle":return ue(n,"idleStatus","Idle");case"starting":return ue(n,"startingStatus","Starting");case"running":return ue(n,"runningStatus","Running");case"stopping":return ue(n,"stoppingStatus","Stopping");case"failed":return ue(n,"failedStatus","Failed")}}function ar(n){return(n.status==="idle"||n.status==="failed")&&!n.runningSessionId&&!!n.context}function lr(n){return!n.runningSessionId&&n.status!=="starting"&&n.status!=="stopping"}function Qa(n){return!!n.runningSessionId&&n.status!=="stopping"}function el(n,e){switch(e.type){case"app.theme":return n.context?{...n,context:{...n.context,theme:e.theme}}:n;case"app.rateLimitRows":return n.context?{...n,context:{...n.context,rateLimitRows:e.rateLimitRows}}:n;case"provider.started":return e.sessionId===n.requestedStopSessionId||n.runningSessionId&&e.sessionId!==n.runningSessionId||!n.runningSessionId&&n.status!=="starting"&&n.status!=="idle"&&n.status!=="failed"&&n.status!=="loading"||!n.runningSessionId&&n.status==="starting"&&e.providerId!==n.selectedProviderId?n:{...n,runningSessionId:e.sessionId,requestedStopSessionId:void 0,selectedProviderId:e.providerId,seenSessionIds:nt(n,e.sessionId),status:"running",log:oe(n,"info",ue(n,"providerStarted","Provider started"))};case"provider.inputAccepted":const t=nl(n,e);return t?{...t,log:oe(t,"info",wt(t,"sentCharsFormat","Sent %d chars",e.text.length)),transcript:si(t,e.text,void 0,e.sentAtMs??Date.now())}:n;case"provider.output":return e.sessionId!==n.runningSessionId?n:{...n,log:oe(n,e.stream,e.text),transcript:ol(n,e)};case"provider.activity":return e.sessionId!==n.runningSessionId?n:{...n,transcript:il(n,e)};case"provider.turnComplete":return e.sessionId!==n.runningSessionId?n:{...n,transcript:Vt(n.transcript,e.sessionId)};case"provider.exit":return tl(n,e)?e.sessionId===n.requestedStopSessionId?{...n,runningSessionId:void 0,requestedStopSessionId:void 0,seenSessionIds:nt(n,e.sessionId),status:"idle",log:oe(n,"info",ue(n,"stopped","Stopped")),transcript:Vt(n.transcript,e.sessionId)}:{...n,runningSessionId:void 0,requestedStopSessionId:void 0,seenSessionIds:nt(n,e.sessionId),status:e.status===0?"idle":"failed",log:oe(n,e.status===0?"info":"error",wt(n,"providerExitedFormat","Provider exited %d",e.status)),transcript:e.status===0?Vt(n.transcript,e.sessionId):kt({...n,transcript:Vt(n.transcript,e.sessionId)},wt(n,"providerExitedFormat","Provider exited %d",e.status),"error")}:n;default:return n}}function tl(n,e){return e.sessionId===n.runningSessionId?!0:!n.seenSessionIds.includes(e.sessionId)&&n.status==="starting"&&!n.runningSessionId&&e.providerId===n.selectedProviderId}function nl(n,e){if(e.sessionId===n.runningSessionId)return n;if(!(n.runningSessionId||e.sessionId===n.requestedStopSessionId||n.status==="stopping"))return{...n,runningSessionId:e.sessionId,requestedStopSessionId:void 0,selectedProviderId:e.providerId,seenSessionIds:nt(n,e.sessionId),status:"running"}}function nt(n,e){return n.seenSessionIds.includes(e)?n.seenSessionIds:[...n.seenSessionIds,e].slice(-50)}function rl(n){const e=n.context?.renderer==="solid"?"Solid":"React";return{...n,log:oe(n,"info",wt(n,"rendererReadyFormat","%@ ready",e))}}function oe(n,e,t){return[...n.log,{id:dt(),level:e,text:pn(t,_a,$a)}].slice(-300)}function si(n,e,t,r){return un(n.transcript,{attachments:t,id:dt(),role:"user",sentAtMs:r,text:e})}function ol(n,e){if(e.stream!=="stdout")return kt(n,e.text,"warning");const t=n.transcript.at(-1);return t?.role==="assistant"&&t.sessionId===e.sessionId?[...n.transcript.slice(0,-1),{...t,isComplete:!1,text:ai(t.text,e.text,$r,Hr)}]:un(n.transcript,{id:dt(),role:"assistant",isComplete:!1,sessionId:e.sessionId,sentAtMs:Date.now(),text:pn(e.text,$r,Hr)})}function Vt(n,e){let t=!1;const r=n.map(o=>o.role!=="assistant"||o.sessionId!==e||o.isComplete===!0?o:(t=!0,{...o,isComplete:!0}));return t?r:n}function il(n,e){const t=sl(n.transcript,e.sessionId,e.activityId);if(t>=0){const r=n.transcript[t];return[...n.transcript.slice(0,t),{...r,text:e.action,detail:e.detail??r.detail,activityKind:e.kind,activityStatus:e.status,output:e.outputDelta===void 0?r.output:ai(r.output??"",e.outputDelta,Wr,qr)},...n.transcript.slice(t+1)]}return un(n.transcript,{id:dt(),role:"activity",text:e.action,detail:e.detail,sessionId:e.sessionId,activityId:e.activityId,activityKind:e.kind,activityStatus:e.status,output:e.outputDelta===void 0?void 0:pn(e.outputDelta,Wr,qr)})}function sl(n,e,t){for(let r=n.length-1;r>=0;r-=1){const o=n[r];if(o.role==="activity"&&o.sessionId===e&&o.activityId===t)return r}return-1}function kt(n,e,t){return un(n.transcript,{id:dt(),role:"notice",tone:t,text:e})}function un(n,e){return[...n,e].slice(-200)}function ai(n,e,t,r){const o=n.startsWith(r),i=n.startsWith(r)?n.slice(r.length):n;return o?r+(i+e).slice(-(t-r.length)):pn(i+e,t,r)}function pn(n,e,t){return n.length<=e?n:t+n.slice(-(e-t.length))}function ue(n,e,t){return n.context?.copy[e]??t}function al(n){return n instanceof Yo&&n.code==="providerNotReady"}function wt(n,e,t,...r){return ll(ue(n,e,t),r)}function ll(n,e){let t=0;return n.replace(/%(\d+\$)?[@d]/g,(r,o)=>{const i=o?Number(o.slice(0,-1))-1:t++;return String(e[i]??"")})}function Lt(n,e){const t=cl(e);return n instanceof Error&&n.message?t&&n.message==="Native bridge request failed."?t.requestFailed:n.message:t?.requestFailed??"Native bridge request failed."}function cl(n){if(n)return"providers"in n?n.context?.copy:n}function H(n){this.content=n}H.prototype={constructor:H,find:function(n){for(var e=0;e>1}};H.from=function(n){if(n instanceof H)return n;var e=[];if(n)for(var t in n)e.push(t,n[t]);return new H(e)};function li(n,e,t){for(let r=0;;r++){if(r==n.childCount||r==e.childCount)return n.childCount==e.childCount?null:t;let o=n.child(r),i=e.child(r);if(o==i){t+=o.nodeSize;continue}if(!o.sameMarkup(i))return t;if(o.isText&&o.text!=i.text){for(let s=0;o.text[s]==i.text[s];s++)t++;return t}if(o.content.size||i.content.size){let s=li(o.content,i.content,t+1);if(s!=null)return s}t+=o.nodeSize}}function ci(n,e,t,r){for(let o=n.childCount,i=e.childCount;;){if(o==0||i==0)return o==i?null:{a:t,b:r};let s=n.child(--o),a=e.child(--i),l=s.nodeSize;if(s==a){t-=l,r-=l;continue}if(!s.sameMarkup(a))return{a:t,b:r};if(s.isText&&s.text!=a.text){let c=0,d=Math.min(s.text.length,a.text.length);for(;ce&&r(l,o+a,i||null,s)!==!1&&l.content.size){let d=a+1;l.nodesBetween(Math.max(0,e-d),Math.min(l.content.size,t-d),r,o+d)}a=c}}descendants(e){this.nodesBetween(0,this.size,e)}textBetween(e,t,r,o){let i="",s=!0;return this.nodesBetween(e,t,(a,l)=>{let c=a.isText?a.text.slice(Math.max(e,l)-l,t-l):a.isLeaf?o?typeof o=="function"?o(a):o:a.type.spec.leafText?a.type.spec.leafText(a):"":"";a.isBlock&&(a.isLeaf&&c||a.isTextblock)&&r&&(s?s=!1:i+=r),i+=c},0),i}append(e){if(!e.size)return this;if(!this.size)return e;let t=this.lastChild,r=e.firstChild,o=this.content.slice(),i=0;for(t.isText&&t.sameMarkup(r)&&(o[o.length-1]=t.withText(t.text+r.text),i=1);ie)for(let i=0,s=0;se&&((st)&&(a.isText?a=a.cut(Math.max(0,e-s),Math.min(a.text.length,t-s)):a=a.cut(Math.max(0,e-s-1),Math.min(a.content.size,t-s-1))),r.push(a),o+=a.nodeSize),s=l}return new y(r,o)}cutByIndex(e,t){return e==t?y.empty:e==0&&t==this.content.length?this:new y(this.content.slice(e,t))}replaceChild(e,t){let r=this.content[e];if(r==t)return this;let o=this.content.slice(),i=this.size+t.nodeSize-r.nodeSize;return o[e]=t,new y(o,i)}addToStart(e){return new y([e].concat(this.content),this.size+e.nodeSize)}addToEnd(e){return new y(this.content.concat(e),this.size+e.nodeSize)}eq(e){if(this.content.length!=e.content.length)return!1;for(let t=0;tthis.size||e<0)throw new RangeError(`Position ${e} outside of fragment (${this})`);for(let t=0,r=0;;t++){let o=this.child(t),i=r+o.nodeSize;if(i>=e)return i==e?_t(t+1,i):_t(t,r);r=i}}toString(){return"<"+this.toStringInner()+">"}toStringInner(){return this.content.join(", ")}toJSON(){return this.content.length?this.content.map(e=>e.toJSON()):null}static fromJSON(e,t){if(!t)return y.empty;if(!Array.isArray(t))throw new RangeError("Invalid input for Fragment.fromJSON");return y.fromArray(t.map(e.nodeFromJSON))}static fromArray(e){if(!e.length)return y.empty;let t,r=0;for(let o=0;othis.type.rank&&(t||(t=e.slice(0,o)),t.push(this),r=!0),t&&t.push(i)}}return t||(t=e.slice()),r||t.push(this),t}removeFromSet(e){for(let t=0;tr.type.rank-o.type.rank),t}}E.none=[];class Qt extends Error{}class w{constructor(e,t,r){this.content=e,this.openStart=t,this.openEnd=r}get size(){return this.content.size-this.openStart-this.openEnd}insertAt(e,t){let r=ui(this.content,e+this.openStart,t,this.openStart+1,this.openEnd+1);return r&&new w(r,this.openStart,this.openEnd)}removeBetween(e,t){return new w(di(this.content,e+this.openStart,t+this.openStart),this.openStart,this.openEnd)}eq(e){return this.content.eq(e.content)&&this.openStart==e.openStart&&this.openEnd==e.openEnd}toString(){return this.content+"("+this.openStart+","+this.openEnd+")"}toJSON(){if(!this.content.size)return null;let e={content:this.content.toJSON()};return this.openStart>0&&(e.openStart=this.openStart),this.openEnd>0&&(e.openEnd=this.openEnd),e}static fromJSON(e,t){if(!t)return w.empty;let r=t.openStart||0,o=t.openEnd||0;if(typeof r!="number"||typeof o!="number")throw new RangeError("Invalid input for Slice.fromJSON");return new w(y.fromJSON(e,t.content),r,o)}static maxOpen(e,t=!0){let r=0,o=0;for(let i=e.firstChild;i&&!i.isLeaf&&(t||!i.type.spec.isolating);i=i.firstChild)r++;for(let i=e.lastChild;i&&!i.isLeaf&&(t||!i.type.spec.isolating);i=i.lastChild)o++;return new w(e,r,o)}}w.empty=new w(y.empty,0,0);function di(n,e,t){let{index:r,offset:o}=n.findIndex(e),i=n.maybeChild(r),{index:s,offset:a}=n.findIndex(t);if(o==e||i.isText){if(a!=t&&!n.child(s).isText)throw new RangeError("Removing non-flat range");return n.cut(0,e).append(n.cut(t))}if(r!=s)throw new RangeError("Removing non-flat range");return n.replaceChild(r,i.copy(di(i.content,e-o-1,t-o-1)))}function ui(n,e,t,r,o,i){let{index:s,offset:a}=n.findIndex(e),l=n.maybeChild(s);if(a==e||l.isText)return i&&r<=0&&o<=0&&!i.canReplace(s,s,t)?null:n.cut(0,e).append(t).append(n.cut(e));let c=ui(l.content,e-a-1,t,s==0?r-1:0,s==n.childCount-1?o-1:0,l);return c&&n.replaceChild(s,l.copy(c))}function dl(n,e,t){if(t.openStart>n.depth)throw new Qt("Inserted content deeper than insertion position");if(n.depth-t.openStart!=e.depth-t.openEnd)throw new Qt("Inconsistent open depths");return pi(n,e,t,0)}function pi(n,e,t,r){let o=n.index(r),i=n.node(r);if(o==e.index(r)&&r=0&&n.isText&&n.sameMarkup(e[t])?e[t]=n.withText(e[t].text+n.text):e.push(n)}function vt(n,e,t,r){let o=(e||n).node(t),i=0,s=e?e.index(t):o.childCount;n&&(i=n.index(t),n.depth>t?i++:n.textOffset&&(Ve(n.nodeAfter,r),i++));for(let a=i;ao&&qn(n,e,o+1),s=r.depth>o&&qn(t,r,o+1),a=[];return vt(null,n,o,a),i&&s&&e.index(o)==t.index(o)?(fi(i,s),Ve(_e(i,hi(n,e,t,r,o+1)),a)):(i&&Ve(_e(i,en(n,e,o+1)),a),vt(e,t,o,a),s&&Ve(_e(s,en(t,r,o+1)),a)),vt(r,null,o,a),new y(a)}function en(n,e,t){let r=[];if(vt(null,n,t,r),n.depth>t){let o=qn(n,e,t+1);Ve(_e(o,en(n,e,t+1)),r)}return vt(e,null,t,r),new y(r)}function ul(n,e){let t=e.depth-n.openStart,o=e.node(t).copy(n.content);for(let i=t-1;i>=0;i--)o=e.node(i).copy(y.from(o));return{start:o.resolveNoCache(n.openStart+t),end:o.resolveNoCache(o.content.size-n.openEnd-t)}}class Nt{constructor(e,t,r){this.pos=e,this.path=t,this.parentOffset=r,this.depth=t.length/3-1}resolveDepth(e){return e==null?this.depth:e<0?this.depth+e:e}get parent(){return this.node(this.depth)}get doc(){return this.node(0)}node(e){return this.path[this.resolveDepth(e)*3]}index(e){return this.path[this.resolveDepth(e)*3+1]}indexAfter(e){return e=this.resolveDepth(e),this.index(e)+(e==this.depth&&!this.textOffset?0:1)}start(e){return e=this.resolveDepth(e),e==0?0:this.path[e*3-1]+1}end(e){return e=this.resolveDepth(e),this.start(e)+this.node(e).content.size}before(e){if(e=this.resolveDepth(e),!e)throw new RangeError("There is no position before the top-level node");return e==this.depth+1?this.pos:this.path[e*3-1]}after(e){if(e=this.resolveDepth(e),!e)throw new RangeError("There is no position after the top-level node");return e==this.depth+1?this.pos:this.path[e*3-1]+this.path[e*3].nodeSize}get textOffset(){return this.pos-this.path[this.path.length-1]}get nodeAfter(){let e=this.parent,t=this.index(this.depth);if(t==e.childCount)return null;let r=this.pos-this.path[this.path.length-1],o=e.child(t);return r?e.child(t).cut(r):o}get nodeBefore(){let e=this.index(this.depth),t=this.pos-this.path[this.path.length-1];return t?this.parent.child(e).cut(0,t):e==0?null:this.parent.child(e-1)}posAtIndex(e,t){t=this.resolveDepth(t);let r=this.path[t*3],o=t==0?0:this.path[t*3-1]+1;for(let i=0;i0;t--)if(this.start(t)<=e&&this.end(t)>=e)return t;return 0}blockRange(e=this,t){if(e.pos=0;r--)if(e.pos<=this.end(r)&&(!t||t(this.node(r))))return new hl(this,e,r);return null}sameParent(e){return this.pos-this.parentOffset==e.pos-e.parentOffset}max(e){return e.pos>this.pos?e:this}min(e){return e.pos=0&&t<=e.content.size))throw new RangeError("Position "+t+" out of range");let r=[],o=0,i=t;for(let s=e;;){let{index:a,offset:l}=s.content.findIndex(i),c=i-l;if(r.push(s,a,o+l),!c||(s=s.child(a),s.isText))break;i=c-1,o+=l+1}return new Nt(t,r,i)}static resolveCached(e,t){let r=Jr.get(e);if(r)for(let i=0;ie&&this.nodesBetween(e,t,i=>(r.isInSet(i.marks)&&(o=!0),!o)),o}get isBlock(){return this.type.isBlock}get isTextblock(){return this.type.isTextblock}get inlineContent(){return this.type.inlineContent}get isInline(){return this.type.isInline}get isText(){return this.type.isText}get isLeaf(){return this.type.isLeaf}get isAtom(){return this.type.isAtom}toString(){if(this.type.spec.toDebugString)return this.type.spec.toDebugString(this);let e=this.type.name;return this.content.size&&(e+="("+this.content.toStringInner()+")"),mi(this.marks,e)}contentMatchAt(e){let t=this.type.contentMatch.matchFragment(this.content,0,e);if(!t)throw new Error("Called contentMatchAt on a node with invalid content");return t}canReplace(e,t,r=y.empty,o=0,i=r.childCount){let s=this.contentMatchAt(e).matchFragment(r,o,i),a=s&&s.matchFragment(this.content,t);if(!a||!a.validEnd)return!1;for(let l=o;lt.type.name)}`);this.content.forEach(t=>t.check())}toJSON(){let e={type:this.type.name};for(let t in this.attrs){e.attrs=this.attrs;break}return this.content.size&&(e.content=this.content.toJSON()),this.marks.length&&(e.marks=this.marks.map(t=>t.toJSON())),e}static fromJSON(e,t){if(!t)throw new RangeError("Invalid input for Node.fromJSON");let r;if(t.marks){if(!Array.isArray(t.marks))throw new RangeError("Invalid mark data for Node.fromJSON");r=t.marks.map(e.markFromJSON)}if(t.type=="text"){if(typeof t.text!="string")throw new RangeError("Invalid text node in JSON");return e.text(t.text,r)}let o=y.fromJSON(e,t.content),i=e.nodeType(t.type).create(t.attrs,o,r);return i.type.checkAttrs(i.attrs),i}};$e.prototype.text=void 0;class tn extends $e{constructor(e,t,r,o){if(super(e,t,null,o),!r)throw new RangeError("Empty text nodes are not allowed");this.text=r}toString(){return this.type.spec.toDebugString?this.type.spec.toDebugString(this):mi(this.marks,JSON.stringify(this.text))}get textContent(){return this.text}textBetween(e,t){return this.text.slice(e,t)}get nodeSize(){return this.text.length}mark(e){return e==this.marks?this:new tn(this.type,this.attrs,this.text,e)}withText(e){return e==this.text?this:new tn(this.type,this.attrs,e,this.marks)}cut(e=0,t=this.text.length){return e==0&&t==this.text.length?this:this.withText(this.text.slice(e,t))}eq(e){return this.sameMarkup(e)&&this.text==e.text}toJSON(){let e=super.toJSON();return e.text=this.text,e}}function mi(n,e){for(let t=n.length-1;t>=0;t--)e=n[t].type.name+"("+e+")";return e}class Je{constructor(e){this.validEnd=e,this.next=[],this.wrapCache=[]}static parse(e,t){let r=new gl(e,t);if(r.next==null)return Je.empty;let o=gi(r);r.next&&r.err("Unexpected trailing text");let i=Cl(vl(o));return Sl(i,r),i}matchType(e){for(let t=0;tc.createAndFill()));for(let c=0;c=this.next.length)throw new RangeError(`There's no ${e}th edge in this content match`);return this.next[e]}toString(){let e=[];function t(r){e.push(r);for(let o=0;o{let i=o+(r.validEnd?"*":" ")+" ";for(let s=0;s"+e.indexOf(r.next[s].next);return i}).join(` +`)}}Je.empty=new Je(!0);class gl{constructor(e,t){this.string=e,this.nodeTypes=t,this.inline=null,this.pos=0,this.tokens=e.split(/\s*(?=\b|\W|$)/),this.tokens[this.tokens.length-1]==""&&this.tokens.pop(),this.tokens[0]==""&&this.tokens.shift()}get next(){return this.tokens[this.pos]}eat(e){return this.next==e&&(this.pos++||!0)}err(e){throw new SyntaxError(e+" (in content expression '"+this.string+"')")}}function gi(n){let e=[];do e.push(xl(n));while(n.eat("|"));return e.length==1?e[0]:{type:"choice",exprs:e}}function xl(n){let e=[];do e.push(bl(n));while(n.next&&n.next!=")"&&n.next!="|");return e.length==1?e[0]:{type:"seq",exprs:e}}function bl(n){let e=wl(n);for(;;)if(n.eat("+"))e={type:"plus",expr:e};else if(n.eat("*"))e={type:"star",expr:e};else if(n.eat("?"))e={type:"opt",expr:e};else if(n.eat("{"))e=yl(n,e);else break;return e}function Ur(n){/\D/.test(n.next)&&n.err("Expected number, got '"+n.next+"'");let e=Number(n.next);return n.pos++,e}function yl(n,e){let t=Ur(n),r=t;return n.eat(",")&&(n.next!="}"?r=Ur(n):r=-1),n.eat("}")||n.err("Unclosed braced range"),{type:"range",min:t,max:r,expr:e}}function kl(n,e){let t=n.nodeTypes,r=t[e];if(r)return[r];let o=[];for(let i in t){let s=t[i];s.isInGroup(e)&&o.push(s)}return o.length==0&&n.err("No node type or group '"+e+"' found"),o}function wl(n){if(n.eat("(")){let e=gi(n);return n.eat(")")||n.err("Missing closing paren"),e}else if(/\W/.test(n.next))n.err("Unexpected token '"+n.next+"'");else{let e=kl(n,n.next).map(t=>(n.inline==null?n.inline=t.isInline:n.inline!=t.isInline&&n.err("Mixing inline and block content"),{type:"name",value:t}));return n.pos++,e.length==1?e[0]:{type:"choice",exprs:e}}}function vl(n){let e=[[]];return o(i(n,0),t()),e;function t(){return e.push([])-1}function r(s,a,l){let c={term:l,to:a};return e[s].push(c),c}function o(s,a){s.forEach(l=>l.to=a)}function i(s,a){if(s.type=="choice")return s.exprs.reduce((l,c)=>l.concat(i(c,a)),[]);if(s.type=="seq")for(let l=0;;l++){let c=i(s.exprs[l],a);if(l==s.exprs.length-1)return c;o(c,a=t())}else if(s.type=="star"){let l=t();return r(a,l),o(i(s.expr,l),l),[r(l)]}else if(s.type=="plus"){let l=t();return o(i(s.expr,a),l),o(i(s.expr,l),l),[r(l)]}else{if(s.type=="opt")return[r(a)].concat(i(s.expr,a));if(s.type=="range"){let l=a;for(let c=0;c{n[s].forEach(({term:a,to:l})=>{if(!a)return;let c;for(let d=0;d{c||o.push([a,c=[]]),c.indexOf(d)==-1&&c.push(d)})})});let i=e[r.join(",")]=new Je(r.indexOf(n.length-1)>-1);for(let s=0;s-1}get whitespace(){return this.spec.whitespace||(this.spec.code?"pre":"normal")}hasRequiredAttrs(){for(let e in this.attrs)if(this.attrs[e].isRequired)return!0;return!1}compatibleContent(e){return this==e||this.contentMatch.compatible(e.contentMatch)}computeAttrs(e){return!e&&this.defaultAttrs?this.defaultAttrs:yi(this.attrs,e)}create(e=null,t,r){if(this.isText)throw new Error("NodeType.create can't construct text nodes");return new $e(this,this.computeAttrs(e),y.from(t),E.setFrom(r))}createChecked(e=null,t,r){return t=y.from(t),this.checkContent(t),new $e(this,this.computeAttrs(e),t,E.setFrom(r))}createAndFill(e=null,t,r){if(e=this.computeAttrs(e),t=y.from(t),t.size){let s=this.contentMatch.fillBefore(t);if(!s)return null;t=s.append(t)}let o=this.contentMatch.matchFragment(t),i=o&&o.fillBefore(y.empty,!0);return i?new $e(this,e,t.append(i),E.setFrom(r)):null}validContent(e){let t=this.contentMatch.matchFragment(e);if(!t||!t.validEnd)return!1;for(let r=0;r-1}allowsMarks(e){if(this.markSet==null)return!0;for(let t=0;tr[i]=new vi(i,t,s));let o=t.spec.topNode||"doc";if(!r[o])throw new RangeError("Schema is missing its top node type ('"+o+"')");if(!r.text)throw new RangeError("Every schema needs a 'text' type");for(let i in r.text.attrs)throw new RangeError("The text node type should not have attributes");return r}};function Ml(n,e,t){let r=t.split("|");return o=>{let i=o===null?"null":typeof o;if(r.indexOf(i)<0)throw new RangeError(`Expected value of type ${r} for attribute ${e} on type ${n}, got ${i}`)}}class Nl{constructor(e,t,r){this.hasDefault=Object.prototype.hasOwnProperty.call(r,"default"),this.default=r.default,this.validate=typeof r.validate=="string"?Ml(e,t,r.validate):r.validate}get isRequired(){return!this.hasDefault}}class fn{constructor(e,t,r,o){this.name=e,this.rank=t,this.schema=r,this.spec=o,this.attrs=wi(e,o.attrs),this.excluded=null;let i=bi(this.attrs);this.instance=i?new E(this,i):null}create(e=null){return!e&&this.instance?this.instance:new E(this,yi(this.attrs,e))}static compile(e,t){let r=Object.create(null),o=0;return e.forEach((i,s)=>r[i]=new fn(i,o++,t,s)),r}removeFromSet(e){for(var t=0;t-1}}class Ol{constructor(e){this.linebreakReplacement=null,this.cached=Object.create(null);let t=this.spec={};for(let o in e)t[o]=e[o];t.nodes=H.from(e.nodes),t.marks=H.from(e.marks||{}),this.nodes=Kr.compile(this.spec.nodes,this),this.marks=fn.compile(this.spec.marks,this);let r=Object.create(null);for(let o in this.nodes){if(o in this.marks)throw new RangeError(o+" can not be both a node and a mark");let i=this.nodes[o],s=i.spec.content||"",a=i.spec.marks;if(i.contentMatch=r[s]||(r[s]=Je.parse(s,this.nodes)),i.inlineContent=i.contentMatch.inlineContent,i.spec.linebreakReplacement){if(this.linebreakReplacement)throw new RangeError("Multiple linebreak nodes defined");if(!i.isInline||!i.isLeaf)throw new RangeError("Linebreak replacement nodes must be inline leaf nodes");this.linebreakReplacement=i}i.markSet=a=="_"?null:a?Zr(this,a.split(" ")):a==""||!i.inlineContent?[]:null}for(let o in this.marks){let i=this.marks[o],s=i.spec.excludes;i.excluded=s==null?[i]:s==""?[]:Zr(this,s.split(" "))}this.nodeFromJSON=o=>$e.fromJSON(this,o),this.markFromJSON=o=>E.fromJSON(this,o),this.topNodeType=this.nodes[this.spec.topNode||"doc"],this.cached.wrappings=Object.create(null)}node(e,t=null,r,o){if(typeof e=="string")e=this.nodeType(e);else if(e instanceof Kr){if(e.schema!=this)throw new RangeError("Node type from different schema used ("+e.name+")")}else throw new RangeError("Invalid node type: "+e);return e.createChecked(t,r,o)}text(e,t){let r=this.nodes.text;return new tn(r,r.defaultAttrs,e,E.setFrom(t))}mark(e,t){return typeof e=="string"&&(e=this.marks[e]),e.create(t)}nodeType(e){let t=this.nodes[e];if(!t)throw new RangeError("Unknown node type: "+e);return t}}function Zr(n,e){let t=[];for(let r=0;r-1)&&t.push(s=l)}if(!s)throw new SyntaxError("Unknown mark type: '"+e[r]+"'")}return t}function Tl(n){return n.tag!=null}function Dl(n){return n.style!=null}class Ot{constructor(e,t){this.schema=e,this.rules=t,this.tags=[],this.styles=[];let r=this.matchedStyles=[];t.forEach(o=>{if(Tl(o))this.tags.push(o);else if(Dl(o)){let i=/[^=]*/.exec(o.style)[0];r.indexOf(i)<0&&r.push(i),this.styles.push(o)}}),this.normalizeLists=!this.tags.some(o=>{if(!/^(ul|ol)\b/.test(o.tag)||!o.node)return!1;let i=e.nodes[o.node];return i.contentMatch.matchType(i)})}parse(e,t={}){let r=new Gr(this,t,!1);return r.addAll(e,E.none,t.from,t.to),r.finish()}parseSlice(e,t={}){let r=new Gr(this,t,!0);return r.addAll(e,E.none,t.from,t.to),w.maxOpen(r.finish())}matchTag(e,t,r){for(let o=r?this.tags.indexOf(r)+1:0;oe.length&&(a.charCodeAt(e.length)!=61||a.slice(e.length+1)!=t))){if(s.getAttrs){let l=s.getAttrs(t);if(l===!1)continue;s.attrs=l||void 0}return s}}}static schemaRules(e){let t=[];function r(o){let i=o.priority==null?50:o.priority,s=0;for(;s{r(s=Xr(s)),s.mark||s.ignore||s.clearMark||(s.mark=o)})}for(let o in e.nodes){let i=e.nodes[o].spec.parseDOM;i&&i.forEach(s=>{r(s=Xr(s)),s.node||s.ignore||s.mark||(s.node=o)})}return t}static fromSchema(e){return e.cached.domParser||(e.cached.domParser=new Ot(e,Ot.schemaRules(e)))}}const Ci={address:!0,article:!0,aside:!0,blockquote:!0,canvas:!0,dd:!0,div:!0,dl:!0,fieldset:!0,figcaption:!0,figure:!0,footer:!0,form:!0,h1:!0,h2:!0,h3:!0,h4:!0,h5:!0,h6:!0,header:!0,hgroup:!0,hr:!0,li:!0,noscript:!0,ol:!0,output:!0,p:!0,pre:!0,section:!0,table:!0,tfoot:!0,ul:!0},El={head:!0,noscript:!0,object:!0,script:!0,style:!0,title:!0},Si={ol:!0,ul:!0},Tt=1,Un=2,Ct=4;function Yr(n,e,t){return e!=null?(e?Tt:0)|(e==="full"?Un:0):n&&n.whitespace=="pre"?Tt|Un:t&~Ct}class $t{constructor(e,t,r,o,i,s){this.type=e,this.attrs=t,this.marks=r,this.solid=o,this.options=s,this.content=[],this.activeMarks=E.none,this.match=i||(s&Ct?null:e.contentMatch)}findWrapping(e){if(!this.match){if(!this.type)return[];let t=this.type.contentMatch.fillBefore(y.from(e));if(t)this.match=this.type.contentMatch.matchFragment(t);else{let r=this.type.contentMatch,o;return(o=r.findWrapping(e.type))?(this.match=r,o):null}}return this.match.findWrapping(e.type)}finish(e){if(!(this.options&Tt)){let r=this.content[this.content.length-1],o;if(r&&r.isText&&(o=/[ \t\r\n\u000c]+$/.exec(r.text))){let i=r;r.text.length==o[0].length?this.content.pop():this.content[this.content.length-1]=i.withText(i.text.slice(0,i.text.length-o[0].length))}}let t=y.from(this.content);return!e&&this.match&&(t=t.append(this.match.fillBefore(y.empty,!0))),this.type?this.type.create(this.attrs,t,this.marks):t}inlineContext(e){return this.type?this.type.inlineContent:this.content.length?this.content[0].isInline:e.parentNode&&!Ci.hasOwnProperty(e.parentNode.nodeName.toLowerCase())}}class Gr{constructor(e,t,r){this.parser=e,this.options=t,this.isOpen=r,this.open=0,this.localPreserveWS=!1;let o=t.topNode,i,s=Yr(null,t.preserveWhitespace,0)|(r?Ct:0);o?i=new $t(o.type,o.attrs,E.none,!0,t.topMatch||o.type.contentMatch,s):r?i=new $t(null,null,E.none,!0,null,s):i=new $t(e.schema.topNodeType,null,E.none,!0,null,s),this.nodes=[i],this.find=t.findPositions,this.needsBlock=!1}get top(){return this.nodes[this.open]}addDOM(e,t){e.nodeType==3?this.addTextNode(e,t):e.nodeType==1&&this.addElement(e,t)}addTextNode(e,t){let r=e.nodeValue,o=this.top,i=o.options&Un?"full":this.localPreserveWS||(o.options&Tt)>0,{schema:s}=this.parser;if(i==="full"||o.inlineContext(e)||/[^ \t\r\n\u000c]/.test(r)){if(i)if(i==="full")r=r.replace(/\r\n?/g,` +`);else if(s.linebreakReplacement&&/[\r\n]/.test(r)&&this.top.findWrapping(s.linebreakReplacement.create())){let a=r.split(/\r?\n|\r/);for(let l=0;l!l.clearMark(c)):t=t.concat(this.parser.schema.marks[l.mark].create(l.attrs)),l.consuming===!1)a=l;else break}}return t}addElementByRule(e,t,r,o){let i,s;if(t.node)if(s=this.parser.schema.nodes[t.node],s.isLeaf)this.insertNode(s.create(t.attrs),r,e.nodeName=="BR")||this.leafFallback(e,r);else{let l=this.enter(s,t.attrs||null,r,t.preserveWhitespace);l&&(i=!0,r=l)}else{let l=this.parser.schema.marks[t.mark];r=r.concat(l.create(t.attrs))}let a=this.top;if(s&&s.isLeaf)this.findInside(e);else if(o)this.addElement(e,r,o);else if(t.getContent)this.findInside(e),t.getContent(e,this.parser.schema).forEach(l=>this.insertNode(l,r,!1));else{let l=e;typeof t.contentElement=="string"?l=e.querySelector(t.contentElement):typeof t.contentElement=="function"?l=t.contentElement(e):t.contentElement&&(l=t.contentElement),this.findAround(e,l,!0),this.addAll(l,r),this.findAround(e,l,!1)}i&&this.sync(a)&&this.open--}addAll(e,t,r,o){let i=r||0;for(let s=r?e.childNodes[r]:e.firstChild,a=o==null?null:e.childNodes[o];s!=a;s=s.nextSibling,++i)this.findAtPoint(e,i),this.addDOM(s,t);this.findAtPoint(e,i)}findPlace(e,t,r){let o,i;for(let s=this.open,a=0;s>=0;s--){let l=this.nodes[s],c=l.findWrapping(e);if(c&&(!o||o.length>c.length+a)&&(o=c,i=l,!c.length))break;if(l.solid){if(r)break;a+=2}}if(!o)return null;this.sync(i);for(let s=0;s(s.type?s.type.allowsMarkType(c.type):Qr(c.type,e))?(l=c.addToSet(l),!1):!0),this.nodes.push(new $t(e,t,l,o,null,a)),this.open++,r}closeExtra(e=!1){let t=this.nodes.length-1;if(t>this.open){for(;t>this.open;t--)this.nodes[t-1].content.push(this.nodes[t].finish(e));this.nodes.length=this.open+1}}finish(){return this.open=0,this.closeExtra(this.isOpen),this.nodes[0].finish(!!(this.isOpen||this.options.topOpen))}sync(e){for(let t=this.open;t>=0;t--){if(this.nodes[t]==e)return this.open=t,!0;this.localPreserveWS&&(this.nodes[t].options|=Tt)}return!1}get currentPos(){this.closeExtra();let e=0;for(let t=this.open;t>=0;t--){let r=this.nodes[t].content;for(let o=r.length-1;o>=0;o--)e+=r[o].nodeSize;t&&e++}return e}findAtPoint(e,t){if(this.find)for(let r=0;r-1)return e.split(/\s*\|\s*/).some(this.matchesContext,this);let t=e.split("/"),r=this.options.context,o=!this.isOpen&&(!r||r.parent.type==this.nodes[0].type),i=-(r?r.depth+1:0)+(o?0:1),s=(a,l)=>{for(;a>=0;a--){let c=t[a];if(c==""){if(a==t.length-1||a==0)continue;for(;l>=i;l--)if(s(a-1,l))return!0;return!1}else{let d=l>0||l==0&&o?this.nodes[l].type:r&&l>=i?r.node(l-i).type:null;if(!d||d.name!=c&&!d.isInGroup(c))return!1;l--}}return!0};return s(t.length-1,this.open)}textblockFromContext(){let e=this.options.context;if(e)for(let t=e.depth;t>=0;t--){let r=e.node(t).contentMatchAt(e.indexAfter(t)).defaultType;if(r&&r.isTextblock&&r.defaultAttrs)return r}for(let t in this.parser.schema.nodes){let r=this.parser.schema.nodes[t];if(r.isTextblock&&r.defaultAttrs)return r}}}function Il(n){for(let e=n.firstChild,t=null;e;e=e.nextSibling){let r=e.nodeType==1?e.nodeName.toLowerCase():null;r&&Si.hasOwnProperty(r)&&t?(t.appendChild(e),e=t):r=="li"?t=e:r&&(t=null)}}function Al(n,e){return(n.matches||n.msMatchesSelector||n.webkitMatchesSelector||n.mozMatchesSelector).call(n,e)}function Xr(n){let e={};for(let t in n)e[t]=n[t];return e}function Qr(n,e){let t=e.schema.nodes;for(let r in t){let o=t[r];if(!o.allowsMarkType(n))continue;let i=[],s=a=>{i.push(a);for(let l=0;l{if(i.length||s.marks.length){let a=0,l=0;for(;a=0;o--){let i=this.serializeMark(e.marks[o],e.isInline,t);i&&((i.contentDOM||i.dom).appendChild(r),r=i.dom)}return r}serializeMark(e,t,r={}){let o=this.marks[e.type.name];return o&&Jt(Wt(r),o(e,t),null,e.attrs)}static renderSpec(e,t,r=null,o){return typeof t=="string"?{dom:e.createTextNode(t)}:Jt(e,t,r,o)}static fromSchema(e){return e.cached.domSerializer||(e.cached.domSerializer=new ut(this.nodesFromSchema(e),this.marksFromSchema(e)))}static nodesFromSchema(e){let t=eo(e.nodes);return t.text||(t.text=r=>r.text),t}static marksFromSchema(e){return eo(e.marks)}}function eo(n){let e={};for(let t in n){let r=n[t].spec.toDOM;r&&(e[t]=r)}return e}function Wt(n){return n.document||window.document}const to=new WeakMap;function Rl(n){let e=to.get(n);return e===void 0&&to.set(n,e=Ll(n)),e}function Ll(n){let e=null;function t(r){if(r&&typeof r=="object")if(Array.isArray(r))if(typeof r[0]=="string")e||(e=[]),e.push(r);else for(let o=0;o-1)throw new RangeError("Using an array from an attribute object as a DOM spec. This may be an attempted cross site scripting attack.");let s=o.indexOf(" ");s>0&&(t=o.slice(0,s),o=o.slice(s+1));let a,l=t?n.createElementNS(t,o):n.createElement(o),c=e[1],d=1;if(c&&typeof c=="object"&&c.nodeType==null&&!Array.isArray(c)){d=2;for(let u in c)if(c[u]!=null){let p=u.indexOf(" ");p>0?l.setAttributeNS(u.slice(0,p),u.slice(p+1),c[u]):u=="style"&&l.style?l.style.cssText=c[u]:l.setAttribute(u,c[u])}}for(let u=d;ud)throw new RangeError("Content hole must be the only child of its parent node");return{dom:l,contentDOM:l}}else if(typeof p=="string")l.appendChild(n.createTextNode(p));else{let{dom:h,contentDOM:m}=Jt(n,p,t,r);if(l.appendChild(h),m){if(a)throw new RangeError("Multiple content holes");a=m}}}return{dom:l,contentDOM:a}}const Mi=65535,Ni=Math.pow(2,16);function Pl(n,e){return n+e*Ni}function no(n){return n&Mi}function zl(n){return(n-(n&Mi))/Ni}const Oi=1,Ti=2,Ut=4,Di=8;class jn{constructor(e,t,r){this.pos=e,this.delInfo=t,this.recover=r}get deleted(){return(this.delInfo&Di)>0}get deletedBefore(){return(this.delInfo&(Oi|Ut))>0}get deletedAfter(){return(this.delInfo&(Ti|Ut))>0}get deletedAcross(){return(this.delInfo&Ut)>0}}class Q{constructor(e,t=!1){if(this.ranges=e,this.inverted=t,!e.length&&Q.empty)return Q.empty}recover(e){let t=0,r=no(e);if(!this.inverted)for(let o=0;oe)break;let c=this.ranges[a+i],d=this.ranges[a+s],u=l+c;if(e<=u){let p=c?e==l?-1:e==u?1:t:t,h=l+o+(p<0?0:d);if(r)return h;let m=e==(t<0?l:u)?null:Pl(a/3,e-l),g=e==l?Ti:e==u?Oi:Ut;return(t<0?e!=l:e!=u)&&(g|=Di),new jn(h,g,m)}o+=d-c}return r?e+o:new jn(e+o,0,null)}touches(e,t){let r=0,o=no(t),i=this.inverted?2:1,s=this.inverted?1:2;for(let a=0;ae)break;let c=this.ranges[a+i],d=l+c;if(e<=d&&a==o*3)return!0;r+=this.ranges[a+s]-c}return!1}forEach(e){let t=this.inverted?2:1,r=this.inverted?1:2;for(let o=0,i=0;o=0;t--){let o=e.getMirror(t);this.appendMap(e._maps[t].invert(),o!=null&&o>t?r-o-1:void 0)}}invert(){let e=new nn;return e.appendMappingInverted(this),e}map(e,t=1){if(this.mirror)return this._map(e,t,!0);for(let r=this.from;ri&&l!s.isAtom||!a.type.allowsMarkType(this.mark.type)?s:s.mark(this.mark.addToSet(s.marks)),o),t.openStart,t.openEnd);return P.fromReplace(e,this.from,this.to,i)}invert(){return new he(this.from,this.to,this.mark)}map(e){let t=e.mapResult(this.from,1),r=e.mapResult(this.to,-1);return t.deleted&&r.deleted||t.pos>=r.pos?null:new Te(t.pos,r.pos,this.mark)}merge(e){return e instanceof Te&&e.mark.eq(this.mark)&&this.from<=e.to&&this.to>=e.from?new Te(Math.min(this.from,e.from),Math.max(this.to,e.to),this.mark):null}toJSON(){return{stepType:"addMark",mark:this.mark.toJSON(),from:this.from,to:this.to}}static fromJSON(e,t){if(typeof t.from!="number"||typeof t.to!="number")throw new RangeError("Invalid input for AddMarkStep.fromJSON");return new Te(t.from,t.to,e.markFromJSON(t.mark))}}K.jsonID("addMark",Te);class he extends K{constructor(e,t,r){super(),this.from=e,this.to=t,this.mark=r}apply(e){let t=e.slice(this.from,this.to),r=new w(cr(t.content,o=>o.mark(this.mark.removeFromSet(o.marks)),e),t.openStart,t.openEnd);return P.fromReplace(e,this.from,this.to,r)}invert(){return new Te(this.from,this.to,this.mark)}map(e){let t=e.mapResult(this.from,1),r=e.mapResult(this.to,-1);return t.deleted&&r.deleted||t.pos>=r.pos?null:new he(t.pos,r.pos,this.mark)}merge(e){return e instanceof he&&e.mark.eq(this.mark)&&this.from<=e.to&&this.to>=e.from?new he(Math.min(this.from,e.from),Math.max(this.to,e.to),this.mark):null}toJSON(){return{stepType:"removeMark",mark:this.mark.toJSON(),from:this.from,to:this.to}}static fromJSON(e,t){if(typeof t.from!="number"||typeof t.to!="number")throw new RangeError("Invalid input for RemoveMarkStep.fromJSON");return new he(t.from,t.to,e.markFromJSON(t.mark))}}K.jsonID("removeMark",he);class De extends K{constructor(e,t){super(),this.pos=e,this.mark=t}apply(e){let t=e.nodeAt(this.pos);if(!t)return P.fail("No node at mark step's position");let r=t.type.create(t.attrs,null,this.mark.addToSet(t.marks));return P.fromReplace(e,this.pos,this.pos+1,new w(y.from(r),0,t.isLeaf?0:1))}invert(e){let t=e.nodeAt(this.pos);if(t){let r=this.mark.addToSet(t.marks);if(r.length==t.marks.length){for(let o=0;or.pos?null:new ne(t.pos,r.pos,o,i,this.slice,this.insert,this.structure)}toJSON(){let e={stepType:"replaceAround",from:this.from,to:this.to,gapFrom:this.gapFrom,gapTo:this.gapTo,insert:this.insert};return this.slice.size&&(e.slice=this.slice.toJSON()),this.structure&&(e.structure=!0),e}static fromJSON(e,t){if(typeof t.from!="number"||typeof t.to!="number"||typeof t.gapFrom!="number"||typeof t.gapTo!="number"||typeof t.insert!="number")throw new RangeError("Invalid input for ReplaceAroundStep.fromJSON");return new ne(t.from,t.to,t.gapFrom,t.gapTo,w.fromJSON(e,t.slice),t.insert,!!t.structure)}}K.jsonID("replaceAround",ne);function Kn(n,e,t){let r=n.resolve(e),o=t-e,i=r.depth;for(;o>0&&i>0&&r.indexAfter(i)==r.node(i).childCount;)i--,o--;if(o>0){let s=r.node(i).maybeChild(r.indexAfter(i));for(;o>0;){if(!s||s.isLeaf)return!0;s=s.firstChild,o--}}return!1}function Fl(n,e,t,r){let o=[],i=[],s,a;n.doc.nodesBetween(e,t,(l,c,d)=>{if(!l.isInline)return;let u=l.marks;if(!r.isInSet(u)&&d.type.allowsMarkType(r.type)){let p=Math.max(c,e),h=Math.min(c+l.nodeSize,t),m=r.addToSet(u);for(let g=0;gn.step(l)),i.forEach(l=>n.step(l))}function Bl(n,e,t,r){let o=[],i=0;n.doc.nodesBetween(e,t,(s,a)=>{if(!s.isInline)return;i++;let l=null;if(r instanceof fn){let c=s.marks,d;for(;d=r.isInSet(c);)(l||(l=[])).push(d),c=d.removeFromSet(c)}else r?r.isInSet(s.marks)&&(l=[r]):l=s.marks;if(l&&l.length){let c=Math.min(a+s.nodeSize,t);for(let d=0;dn.step(new he(s.from,s.to,s.style)))}function dr(n,e,t,r=t.contentMatch,o=!0){let i=n.doc.nodeAt(e),s=[],a=e+1;for(let l=0;l=0;l--)n.step(s[l])}function Vl(n,e,t){return(e==0||n.canReplace(e,n.childCount))&&(t==n.childCount||n.canReplace(0,t))}function ur(n){let t=n.parent.content.cutByIndex(n.startIndex,n.endIndex);for(let r=n.depth,o=0,i=0;;--r){let s=n.$from.node(r),a=n.$from.index(r)+o,l=n.$to.indexAfter(r)-i;if(rt;m--)g||r.index(m)>0?(g=!0,d=y.from(r.node(m).copy(d)),u++):l--;let p=y.empty,h=0;for(let m=i,g=!1;m>t;m--)g||o.after(m+1)=0;s--){if(r.size){let a=t[s].type.contentMatch.matchFragment(r);if(!a||!a.validEnd)throw new RangeError("Wrapper type given to Transform.wrap does not form valid content of its parent wrapper")}r=y.from(t[s].type.create(t[s].attrs,r))}let o=e.start,i=e.end;n.step(new ne(o,i,o,i,new w(r,0,0),t.length,!0))}function Wl(n,e,t,r,o){if(!r.isTextblock)throw new RangeError("Type given to setBlockType should be a textblock");let i=n.steps.length;n.doc.nodesBetween(e,t,(s,a)=>{let l=typeof o=="function"?o(s):o;if(s.isTextblock&&!s.hasMarkup(r,l)&&Hl(n.doc,n.mapping.slice(i).map(a),r)){let c=null;if(r.schema.linebreakReplacement){let h=r.whitespace=="pre",m=!!r.contentMatch.matchType(r.schema.linebreakReplacement);h&&!m?c=!1:!h&&m&&(c=!0)}c===!1&&Ii(n,s,a,i),dr(n,n.mapping.slice(i).map(a,1),r,void 0,c===null);let d=n.mapping.slice(i),u=d.map(a,1),p=d.map(a+s.nodeSize,1);return n.step(new ne(u,p,u+1,p-1,new w(y.from(r.create(l,null,s.marks)),0,0),1,!0)),c===!0&&Ei(n,s,a,i),!1}})}function Ei(n,e,t,r){e.forEach((o,i)=>{if(o.isText){let s,a=/\r?\n|\r/g;for(;s=a.exec(o.text);){let l=n.mapping.slice(r).map(t+1+i+s.index);n.replaceWith(l,l+1,e.type.schema.linebreakReplacement.create())}}})}function Ii(n,e,t,r){e.forEach((o,i)=>{if(o.type==o.type.schema.linebreakReplacement){let s=n.mapping.slice(r).map(t+1+i);n.replaceWith(s,s+1,e.type.schema.text(` +`))}})}function Hl(n,e,t){let r=n.resolve(e),o=r.index();return r.parent.canReplaceWith(o,o+1,t)}function ql(n,e,t,r,o){let i=n.doc.nodeAt(e);if(!i)throw new RangeError("No node at given position");t||(t=i.type);let s=t.create(r,null,o||i.marks);if(i.isLeaf)return n.replaceWith(e,e+i.nodeSize,s);if(!t.validContent(i.content))throw new RangeError("Invalid content for node type "+t.name);n.step(new ne(e,e+i.nodeSize,e+1,e+i.nodeSize-1,new w(y.from(s),0,0),1,!0))}function jt(n,e,t=1,r){let o=n.resolve(e),i=o.depth-t,s=r&&r[r.length-1]||o.parent;if(i<0||o.parent.type.spec.isolating||!o.parent.canReplace(o.index(),o.parent.childCount)||!s.type.validContent(o.parent.content.cutByIndex(o.index(),o.parent.childCount)))return!1;for(let c=o.depth-1,d=t-2;c>i;c--,d--){let u=o.node(c),p=o.index(c);if(u.type.spec.isolating)return!1;let h=u.content.cutByIndex(p,u.childCount),m=r&&r[d+1];m&&(h=h.replaceChild(0,m.type.create(m.attrs)));let g=r&&r[d]||u;if(!u.canReplace(p+1,u.childCount)||!g.type.validContent(h))return!1}let a=o.indexAfter(i),l=r&&r[0];return o.node(i).canReplaceWith(a,a,l?l.type:o.node(i+1).type)}function Jl(n,e,t=1,r){let o=n.doc.resolve(e),i=y.empty,s=y.empty;for(let a=o.depth,l=o.depth-t,c=t-1;a>l;a--,c--){i=y.from(o.node(a).copy(i));let d=r&&r[c];s=y.from(d?d.type.create(d.attrs,s):o.node(a).copy(s))}n.step(new _(e,e,new w(i.append(s),t,t),!0))}function Ai(n,e){let t=n.resolve(e),r=t.index();return jl(t.nodeBefore,t.nodeAfter)&&t.parent.canReplace(r,r+1)}function Ul(n,e){e.content.size||n.type.compatibleContent(e.type);let t=n.contentMatchAt(n.childCount),{linebreakReplacement:r}=n.type.schema;for(let o=0;o=0;o--){let i=r.index(o);if(r.node(o).canReplaceWith(i,i,t))return r.before(o+1);if(i>0)return null}if(r.parentOffset==r.parent.content.size)for(let o=r.depth-1;o>=0;o--){let i=r.indexAfter(o);if(r.node(o).canReplaceWith(i,i,t))return r.after(o+1);if(i=0;s--){let a=s==r.depth?0:r.pos<=(r.start(s+1)+r.end(s+1))/2?-1:1,l=r.index(s)+(a>0?1:0),c=r.node(s),d=!1;if(i==1)d=c.canReplace(l,l,o);else{let u=c.contentMatchAt(l).findWrapping(o.firstChild.type);d=u&&c.canReplaceWith(l,l,u[0])}if(d)return a==0?r.pos:a<0?r.before(s+1):r.after(s+1)}return null}function pr(n,e,t=e,r=w.empty){if(e==t&&!r.size)return null;let o=n.resolve(e),i=n.resolve(t);return Ri(o,i,r)?new _(e,t,r):new Gl(o,i,r).fit()}function Ri(n,e,t){return!t.openStart&&!t.openEnd&&n.start()==e.start()&&n.parent.canReplace(n.index(),e.index(),t.content)}class Gl{constructor(e,t,r){this.$from=e,this.$to=t,this.unplaced=r,this.frontier=[],this.placed=y.empty;for(let o=0;o<=e.depth;o++){let i=e.node(o);this.frontier.push({type:i.type,match:i.contentMatchAt(e.indexAfter(o))})}for(let o=e.depth;o>0;o--)this.placed=y.from(e.node(o).copy(this.placed))}get depth(){return this.frontier.length-1}fit(){for(;this.unplaced.size;){let c=this.findFittable();c?this.placeNodes(c):this.openMore()||this.dropNode()}let e=this.mustMoveInline(),t=this.placed.size-this.depth-this.$from.depth,r=this.$from,o=this.close(e<0?this.$to:r.doc.resolve(e));if(!o)return null;let i=this.placed,s=r.depth,a=o.depth;for(;s&&a&&i.childCount==1;)i=i.firstChild.content,s--,a--;let l=new w(i,s,a);return e>-1?new ne(r.pos,e,this.$to.pos,this.$to.end(),l,t):l.size||r.pos!=this.$to.pos?new _(r.pos,o.pos,l):null}findFittable(){let e=this.unplaced.openStart;for(let t=this.unplaced.content,r=0,o=this.unplaced.openEnd;r1&&(o=0),i.type.spec.isolating&&o<=r){e=r;break}t=i.content}for(let t=1;t<=2;t++)for(let r=t==1?e:this.unplaced.openStart;r>=0;r--){let o,i=null;r?(i=En(this.unplaced.content,r-1).firstChild,o=i.content):o=this.unplaced.content;let s=o.firstChild;for(let a=this.depth;a>=0;a--){let{type:l,match:c}=this.frontier[a],d,u=null;if(t==1&&(s?c.matchType(s.type)||(u=c.fillBefore(y.from(s),!1)):i&&l.compatibleContent(i.type)))return{sliceDepth:r,frontierDepth:a,parent:i,inject:u};if(t==2&&s&&(d=c.findWrapping(s.type)))return{sliceDepth:r,frontierDepth:a,parent:i,wrap:d};if(i&&c.matchType(i.type))break}}}openMore(){let{content:e,openStart:t,openEnd:r}=this.unplaced,o=En(e,t);return!o.childCount||o.firstChild.isLeaf?!1:(this.unplaced=new w(e,t+1,Math.max(r,o.size+t>=e.size-r?t+1:0)),!0)}dropNode(){let{content:e,openStart:t,openEnd:r}=this.unplaced,o=En(e,t);if(o.childCount<=1&&t>0){let i=e.size-t<=t+o.size;this.unplaced=new w(xt(e,t-1,1),t-1,i?t-1:r)}else this.unplaced=new w(xt(e,t,1),t,r)}placeNodes({sliceDepth:e,frontierDepth:t,parent:r,inject:o,wrap:i}){for(;this.depth>t;)this.closeFrontierNode();if(i)for(let g=0;g1||l==0||g.content.size)&&(u=x,d.push(Li(g.mark(p.allowedMarks(g.marks)),c==1?l:0,c==a.childCount?h:-1)))}let m=c==a.childCount;m||(h=-1),this.placed=bt(this.placed,t,y.from(d)),this.frontier[t].match=u,m&&h<0&&r&&r.type==this.frontier[this.depth].type&&this.frontier.length>1&&this.closeFrontierNode();for(let g=0,x=a;g1&&o==this.$to.end(--r);)++o;return o}findCloseLevel(e){e:for(let t=Math.min(this.depth,e.depth);t>=0;t--){let{match:r,type:o}=this.frontier[t],i=t=0;a--){let{match:l,type:c}=this.frontier[a],d=In(e,a,c,l,!0);if(!d||d.childCount)continue e}return{depth:t,fit:s,move:i?e.doc.resolve(e.after(t+1)):e}}}}close(e){let t=this.findCloseLevel(e);if(!t)return null;for(;this.depth>t.depth;)this.closeFrontierNode();t.fit.childCount&&(this.placed=bt(this.placed,t.depth,t.fit)),e=t.move;for(let r=t.depth+1;r<=e.depth;r++){let o=e.node(r),i=o.type.contentMatch.fillBefore(o.content,!0,e.index(r));this.openFrontierNode(o.type,o.attrs,i)}return e}openFrontierNode(e,t=null,r){let o=this.frontier[this.depth];o.match=o.match.matchType(e),this.placed=bt(this.placed,this.depth,y.from(e.create(t,r))),this.frontier.push({type:e,match:e.contentMatch})}closeFrontierNode(){let t=this.frontier.pop().match.fillBefore(y.empty,!0);t.childCount&&(this.placed=bt(this.placed,this.frontier.length,t))}}function xt(n,e,t){return e==0?n.cutByIndex(t,n.childCount):n.replaceChild(0,n.firstChild.copy(xt(n.firstChild.content,e-1,t)))}function bt(n,e,t){return e==0?n.append(t):n.replaceChild(n.childCount-1,n.lastChild.copy(bt(n.lastChild.content,e-1,t)))}function En(n,e){for(let t=0;t1&&(r=r.replaceChild(0,Li(r.firstChild,e-1,r.childCount==1?t-1:0))),e>0&&(r=n.type.contentMatch.fillBefore(r).append(r),t<=0&&(r=r.append(n.type.contentMatch.matchFragment(r).fillBefore(y.empty,!0)))),n.copy(r)}function In(n,e,t,r,o){let i=n.node(e),s=o?n.indexAfter(e):n.index(e);if(s==i.childCount&&!t.compatibleContent(i.type))return null;let a=r.fillBefore(i.content,!0,s);return a&&!Xl(t,i.content,s)?a:null}function Xl(n,e,t){for(let r=t;r0;p--,h--){let m=o.node(p).type.spec;if(m.defining||m.definingAsContext||m.isolating)break;s.indexOf(p)>-1?a=p:o.before(p)==h&&s.splice(1,0,-p)}let l=s.indexOf(a),c=[],d=r.openStart;for(let p=r.content,h=0;;h++){let m=p.firstChild;if(c.push(m),h==r.openStart)break;p=m.content}for(let p=d-1;p>=0;p--){let h=c[p],m=Ql(h.type);if(m&&!h.sameMarkup(o.node(Math.abs(a)-1)))d=p;else if(m||!h.type.isTextblock)break}for(let p=r.openStart;p>=0;p--){let h=(p+d+1)%(r.openStart+1),m=c[h];if(m)for(let g=0;g=0&&(n.replace(e,t,r),!(n.steps.length>u));p--){let h=s[p];h<0||(e=o.before(h),t=i.after(h))}}function Pi(n,e,t,r,o){if(er){let i=o.contentMatchAt(0),s=i.fillBefore(n).append(n);n=s.append(i.matchFragment(s).fillBefore(y.empty,!0))}return n}function tc(n,e,t,r){if(!r.isInline&&e==t&&n.doc.resolve(e).parent.content.size){let o=Zl(n.doc,e,r.type);o!=null&&(e=t=o)}n.replaceRange(e,t,new w(y.from(r),0,0))}function nc(n,e,t){let r=n.doc.resolve(e),o=n.doc.resolve(t);if(r.parent.isTextblock&&o.parent.isTextblock&&r.start()!=o.start()&&r.parentOffset==0&&o.parentOffset==0){let s=r.sharedDepth(t),a=!1;for(let l=r.depth;l>s;l--)r.node(l).type.spec.isolating&&(a=!0);for(let l=o.depth;l>s;l--)o.node(l).type.spec.isolating&&(a=!0);if(!a){for(let l=r.depth;l>0&&e==r.start(l);l--)e=r.before(l);for(let l=o.depth;l>0&&t==o.start(l);l--)t=o.before(l);r=n.doc.resolve(e),o=n.doc.resolve(t)}}let i=zi(r,o);for(let s=0;s0&&(l||r.node(a-1).canReplace(r.index(a-1),o.indexAfter(a-1))))return n.delete(r.before(a),o.after(a))}for(let s=1;s<=r.depth&&s<=o.depth;s++)if(e-r.start(s)==r.depth-s&&t>r.end(s)&&o.end(s)-t!=o.depth-s&&r.start(s-1)==o.start(s-1)&&r.node(s-1).canReplace(r.index(s-1),o.index(s-1)))return n.delete(r.before(s),t);n.delete(e,t)}function zi(n,e){let t=[],r=Math.min(n.depth,e.depth);for(let o=r;o>=0;o--){let i=n.start(o);if(ie.pos+(e.depth-o)||n.node(o).type.spec.isolating||e.node(o).type.spec.isolating)break;(i==e.start(o)||o==n.depth&&o==e.depth&&n.parent.inlineContent&&e.parent.inlineContent&&o&&e.start(o-1)==i-1)&&t.push(o)}return t}class rt extends K{constructor(e,t,r){super(),this.pos=e,this.attr=t,this.value=r}apply(e){let t=e.nodeAt(this.pos);if(!t)return P.fail("No node at attribute step's position");let r=Object.create(null);for(let i in t.attrs)r[i]=t.attrs[i];r[this.attr]=this.value;let o=t.type.create(r,null,t.marks);return P.fromReplace(e,this.pos,this.pos+1,new w(y.from(o),0,t.isLeaf?0:1))}getMap(){return Q.empty}invert(e){return new rt(this.pos,this.attr,e.nodeAt(this.pos).attrs[this.attr])}map(e){let t=e.mapResult(this.pos,1);return t.deletedAfter?null:new rt(t.pos,this.attr,this.value)}toJSON(){return{stepType:"attr",pos:this.pos,attr:this.attr,value:this.value}}static fromJSON(e,t){if(typeof t.pos!="number"||typeof t.attr!="string")throw new RangeError("Invalid input for AttrStep.fromJSON");return new rt(t.pos,t.attr,t.value)}}K.jsonID("attr",rt);class Dt extends K{constructor(e,t){super(),this.attr=e,this.value=t}apply(e){let t=Object.create(null);for(let o in e.attrs)t[o]=e.attrs[o];t[this.attr]=this.value;let r=e.type.create(t,e.content,e.marks);return P.ok(r)}getMap(){return Q.empty}invert(e){return new Dt(this.attr,e.attrs[this.attr])}map(e){return this}toJSON(){return{stepType:"docAttr",attr:this.attr,value:this.value}}static fromJSON(e,t){if(typeof t.attr!="string")throw new RangeError("Invalid input for DocAttrStep.fromJSON");return new Dt(t.attr,t.value)}}K.jsonID("docAttr",Dt);let st=class extends Error{};st=function n(e){let t=Error.call(this,e);return t.__proto__=n.prototype,t};st.prototype=Object.create(Error.prototype);st.prototype.constructor=st;st.prototype.name="TransformError";class rc{constructor(e){this.doc=e,this.steps=[],this.docs=[],this.mapping=new nn}get before(){return this.docs.length?this.docs[0]:this.doc}step(e){let t=this.maybeStep(e);if(t.failed)throw new st(t.failed);return this}maybeStep(e){let t=e.apply(this.doc);return t.failed||this.addStep(e,t.doc),t}get docChanged(){return this.steps.length>0}changedRange(){let e=1e9,t=-1e9;for(let r=0;r{e=Math.min(e,a),t=Math.max(t,l)})}return e==1e9?null:{from:e,to:t}}addStep(e,t){this.docs.push(this.doc),this.steps.push(e),this.mapping.appendMap(e.getMap()),this.doc=t}replace(e,t=e,r=w.empty){let o=pr(this.doc,e,t,r);return o&&this.step(o),this}replaceWith(e,t,r){return this.replace(e,t,new w(y.from(r),0,0))}delete(e,t){return this.replace(e,t,w.empty)}insert(e,t){return this.replaceWith(e,e,t)}replaceRange(e,t,r){return ec(this,e,t,r),this}replaceRangeWith(e,t,r){return tc(this,e,t,r),this}deleteRange(e,t){return nc(this,e,t),this}lift(e,t){return _l(this,e,t),this}join(e,t=1){return Kl(this,e,t),this}wrap(e,t){return $l(this,e,t),this}setBlockType(e,t=e,r,o=null){return Wl(this,e,t,r,o),this}setNodeMarkup(e,t,r=null,o){return ql(this,e,t,r,o),this}setNodeAttribute(e,t,r){return this.step(new rt(e,t,r)),this}setDocAttribute(e,t){return this.step(new Dt(e,t)),this}addNodeMark(e,t){return this.step(new De(e,t)),this}removeNodeMark(e,t){let r=this.doc.nodeAt(e);if(!r)throw new RangeError("No node at position "+e);if(t instanceof E)t.isInSet(r.marks)&&this.step(new Ue(e,t));else{let o=r.marks,i,s=[];for(;i=t.isInSet(o);)s.push(new Ue(e,i)),o=i.removeFromSet(o);for(let a=s.length-1;a>=0;a--)this.step(s[a])}return this}split(e,t=1,r){return Jl(this,e,t,r),this}addMark(e,t,r){return Fl(this,e,t,r),this}removeMark(e,t,r){return Bl(this,e,t,r),this}clearIncompatible(e,t,r){return dr(this,e,t,r),this}}const An=Object.create(null);class A{constructor(e,t,r){this.$anchor=e,this.$head=t,this.ranges=r||[new oc(e.min(t),e.max(t))]}get anchor(){return this.$anchor.pos}get head(){return this.$head.pos}get from(){return this.$from.pos}get to(){return this.$to.pos}get $from(){return this.ranges[0].$from}get $to(){return this.ranges[0].$to}get empty(){let e=this.ranges;for(let t=0;t=0;i--){let s=t<0?Xe(e.node(0),e.node(i),e.before(i+1),e.index(i),t,r):Xe(e.node(0),e.node(i),e.after(i+1),e.index(i)+1,t,r);if(s)return s}return null}static near(e,t=1){return this.findFrom(e,t)||this.findFrom(e,-t)||new ae(e.node(0))}static atStart(e){return Xe(e,e,0,0,1)||new ae(e)}static atEnd(e){return Xe(e,e,e.content.size,e.childCount,-1)||new ae(e)}static fromJSON(e,t){if(!t||!t.type)throw new RangeError("Invalid input for Selection.fromJSON");let r=An[t.type];if(!r)throw new RangeError(`No selection type ${t.type} defined`);return r.fromJSON(e,t)}static jsonID(e,t){if(e in An)throw new RangeError("Duplicate use of selection JSON ID "+e);return An[e]=t,t.prototype.jsonID=e,t}getBookmark(){return D.between(this.$anchor,this.$head).getBookmark()}}A.prototype.visible=!0;class oc{constructor(e,t){this.$from=e,this.$to=t}}let ro=!1;function oo(n){!ro&&!n.parent.inlineContent&&(ro=!0,console.warn("TextSelection endpoint not pointing into a node with inline content ("+n.parent.type.name+")"))}class D extends A{constructor(e,t=e){oo(e),oo(t),super(e,t)}get $cursor(){return this.$anchor.pos==this.$head.pos?this.$head:null}map(e,t){let r=e.resolve(t.map(this.head));if(!r.parent.inlineContent)return A.near(r);let o=e.resolve(t.map(this.anchor));return new D(o.parent.inlineContent?o:r,r)}replace(e,t=w.empty){if(super.replace(e,t),t==w.empty){let r=this.$from.marksAcross(this.$to);r&&e.ensureMarks(r)}}eq(e){return e instanceof D&&e.anchor==this.anchor&&e.head==this.head}getBookmark(){return new hn(this.anchor,this.head)}toJSON(){return{type:"text",anchor:this.anchor,head:this.head}}static fromJSON(e,t){if(typeof t.anchor!="number"||typeof t.head!="number")throw new RangeError("Invalid input for TextSelection.fromJSON");return new D(e.resolve(t.anchor),e.resolve(t.head))}static create(e,t,r=t){let o=e.resolve(t);return new this(o,r==t?o:e.resolve(r))}static between(e,t,r){let o=e.pos-t.pos;if((!r||o)&&(r=o>=0?1:-1),!t.parent.inlineContent){let i=A.findFrom(t,r,!0)||A.findFrom(t,-r,!0);if(i)t=i.$head;else return A.near(t,r)}return e.parent.inlineContent||(o==0?e=t:(e=(A.findFrom(e,-r,!0)||A.findFrom(e,r,!0)).$anchor,e.pos0?0:1);o>0?s=0;s+=o){let a=e.child(s);if(a.isAtom){if(!i&&M.isSelectable(a))return M.create(n,t-(o<0?a.nodeSize:0))}else{let l=Xe(n,a,t+o,o<0?a.childCount:0,o,i);if(l)return l}t+=a.nodeSize*o}return null}function io(n,e,t){let r=n.steps.length-1;if(r{s==null&&(s=d)}),n.setSelection(A.near(n.doc.resolve(s),t))}const so=1,Ht=2,ao=4;class sc extends rc{constructor(e){super(e.doc),this.curSelectionFor=0,this.updated=0,this.meta=Object.create(null),this.time=Date.now(),this.curSelection=e.selection,this.storedMarks=e.storedMarks}get selection(){return this.curSelectionFor0}setStoredMarks(e){return this.storedMarks=e,this.updated|=Ht,this}ensureMarks(e){return E.sameSet(this.storedMarks||this.selection.$from.marks(),e)||this.setStoredMarks(e),this}addStoredMark(e){return this.ensureMarks(e.addToSet(this.storedMarks||this.selection.$head.marks()))}removeStoredMark(e){return this.ensureMarks(e.removeFromSet(this.storedMarks||this.selection.$head.marks()))}get storedMarksSet(){return(this.updated&Ht)>0}addStep(e,t){super.addStep(e,t),this.updated=this.updated&~Ht,this.storedMarks=null}setTime(e){return this.time=e,this}replaceSelection(e){return this.selection.replace(this,e),this}replaceSelectionWith(e,t=!0){let r=this.selection;return t&&(e=e.mark(this.storedMarks||(r.empty?r.$from.marks():r.$from.marksAcross(r.$to)||E.none))),r.replaceWith(this,e),this}deleteSelection(){return this.selection.replace(this),this}insertText(e,t,r){let o=this.doc.type.schema;if(t==null)return e?this.replaceSelectionWith(o.text(e),!0):this.deleteSelection();{if(r==null&&(r=t),!e)return this.deleteRange(t,r);let i=this.storedMarks;if(!i){let s=this.doc.resolve(t);i=r==t?s.marks():s.marksAcross(this.doc.resolve(r))}return this.replaceRangeWith(t,r,o.text(e,i)),!this.selection.empty&&this.selection.to==t+e.length&&this.setSelection(A.near(this.selection.$to)),this}}setMeta(e,t){return this.meta[typeof e=="string"?e:e.key]=t,this}getMeta(e){return this.meta[typeof e=="string"?e:e.key]}get isGeneric(){for(let e in this.meta)return!1;return!0}scrollIntoView(){return this.updated|=ao,this}get scrolledIntoView(){return(this.updated&ao)>0}}function lo(n,e){return!e||!n?n:n.bind(e)}class yt{constructor(e,t,r){this.name=e,this.init=lo(t.init,r),this.apply=lo(t.apply,r)}}const ac=[new yt("doc",{init(n){return n.doc||n.schema.topNodeType.createAndFill()},apply(n){return n.doc}}),new yt("selection",{init(n,e){return n.selection||A.atStart(e.doc)},apply(n){return n.selection}}),new yt("storedMarks",{init(n){return n.storedMarks||null},apply(n,e,t,r){return r.selection.$cursor?n.storedMarks:null}}),new yt("scrollToSelection",{init(){return 0},apply(n,e){return n.scrolledIntoView?e+1:e}})];class Rn{constructor(e,t){this.schema=e,this.plugins=[],this.pluginsByKey=Object.create(null),this.fields=ac.slice(),t&&t.forEach(r=>{if(this.pluginsByKey[r.key])throw new RangeError("Adding different instances of a keyed plugin ("+r.key+")");this.plugins.push(r),this.pluginsByKey[r.key]=r,r.spec.state&&this.fields.push(new yt(r.key,r.spec.state,r))})}}class tt{constructor(e){this.config=e}get schema(){return this.config.schema}get plugins(){return this.config.plugins}apply(e){return this.applyTransaction(e).state}filterTransaction(e,t=-1){for(let r=0;rr.toJSON())),e&&typeof e=="object")for(let r in e){if(r=="doc"||r=="selection")throw new RangeError("The JSON fields `doc` and `selection` are reserved");let o=e[r],i=o.spec.state;i&&i.toJSON&&(t[r]=i.toJSON.call(o,this[o.key]))}return t}static fromJSON(e,t,r){if(!t)throw new RangeError("Invalid input for EditorState.fromJSON");if(!e.schema)throw new RangeError("Required config field 'schema' missing");let o=new Rn(e.schema,e.plugins),i=new tt(o);return o.fields.forEach(s=>{if(s.name=="doc")i.doc=$e.fromJSON(e.schema,t.doc);else if(s.name=="selection")i.selection=A.fromJSON(i.doc,t.selection);else if(s.name=="storedMarks")t.storedMarks&&(i.storedMarks=t.storedMarks.map(e.schema.markFromJSON));else{if(r)for(let a in r){let l=r[a],c=l.spec.state;if(l.key==s.name&&c&&c.fromJSON&&Object.prototype.hasOwnProperty.call(t,a)){i[s.name]=c.fromJSON.call(l,e,t[a],i);return}}i[s.name]=s.init(e,i)}}),i}}function Fi(n,e,t){for(let r in n){let o=n[r];o instanceof Function?o=o.bind(e):r=="handleDOMEvents"&&(o=Fi(o,e,{})),t[r]=o}return t}class lc{constructor(e){this.spec=e,this.props={},e.props&&Fi(e.props,this,this.props),this.key=e.key?e.key.key:Bi("plugin")}getState(e){return e[this.key]}}const Ln=Object.create(null);function Bi(n){return n in Ln?n+"$"+ ++Ln[n]:(Ln[n]=0,n+"$")}class cc{constructor(e="key"){this.key=Bi(e)}get(e){return e.config.pluginsByKey[this.key]}getState(e){return e[this.key]}}const Vi=(n,e)=>n.selection.empty?!1:(e&&e(n.tr.deleteSelection().scrollIntoView()),!0);function dc(n,e){let{$cursor:t}=n.selection;return!t||(e?!e.endOfTextblock("backward",n):t.parentOffset>0)?null:t}const uc=(n,e,t)=>{let r=dc(n,t);if(!r)return!1;let o=_i(r);if(!o){let s=r.blockRange(),a=s&&ur(s);return a==null?!1:(e&&e(n.tr.lift(s,a).scrollIntoView()),!0)}let i=o.nodeBefore;if(qi(n,o,e,-1))return!0;if(r.parent.content.size==0&&(at(i,"end")||M.isSelectable(i)))for(let s=r.depth;;s--){let a=pr(n.doc,r.before(s),r.after(s),w.empty);if(a&&a.slice.size1)break}return i.isAtom&&o.depth==r.depth-1?(e&&e(n.tr.delete(o.pos-i.nodeSize,o.pos).scrollIntoView()),!0):!1};function at(n,e,t=!1){for(let r=n;r;r=e=="start"?r.firstChild:r.lastChild){if(r.isTextblock)return!0;if(t&&r.childCount!=1)return!1}return!1}const pc=(n,e,t)=>{let{$head:r,empty:o}=n.selection,i=r;if(!o)return!1;if(r.parent.isTextblock){if(t?!t.endOfTextblock("backward",n):r.parentOffset>0)return!1;i=_i(r)}let s=i&&i.nodeBefore;return!s||!M.isSelectable(s)?!1:(e&&e(n.tr.setSelection(M.create(n.doc,i.pos-s.nodeSize)).scrollIntoView()),!0)};function _i(n){if(!n.parent.type.spec.isolating)for(let e=n.depth-1;e>=0;e--){if(n.index(e)>0)return n.doc.resolve(n.before(e+1));if(n.node(e).type.spec.isolating)break}return null}function fc(n,e){let{$cursor:t}=n.selection;return!t||(e?!e.endOfTextblock("forward",n):t.parentOffset{let r=fc(n,t);if(!r)return!1;let o=$i(r);if(!o)return!1;let i=o.nodeAfter;if(qi(n,o,e,1))return!0;if(r.parent.content.size==0&&(at(i,"start")||M.isSelectable(i))){let s=pr(n.doc,r.before(),r.after(),w.empty);if(s&&s.slice.size{let{$head:r,empty:o}=n.selection,i=r;if(!o)return!1;if(r.parent.isTextblock){if(t?!t.endOfTextblock("forward",n):r.parentOffset=0;e--){let t=n.node(e);if(n.index(e)+1{let{$head:t,$anchor:r}=n.selection;return!t.parent.type.spec.code||!t.sameParent(r)?!1:(e&&e(n.tr.insertText(` +`).scrollIntoView()),!0)};function Wi(n){for(let e=0;e{let t=n.selection,{$from:r,$to:o}=t;if(t instanceof ae||r.parent.inlineContent||o.parent.inlineContent)return!1;let i=Wi(o.parent.contentMatchAt(o.indexAfter()));if(!i||!i.isTextblock)return!1;if(e){let s=(!r.parentOffset&&o.index(){let{$cursor:t}=n.selection;if(!t||t.parent.content.size)return!1;if(t.depth>1&&t.after()!=t.end(-1)){let i=t.before();if(jt(n.doc,i))return e&&e(n.tr.split(i).scrollIntoView()),!0}let r=t.blockRange(),o=r&&ur(r);return o==null?!1:(e&&e(n.tr.lift(r,o).scrollIntoView()),!0)};function yc(n){return(e,t)=>{let{$from:r,$to:o}=e.selection;if(e.selection instanceof M&&e.selection.node.isBlock)return!r.parentOffset||!jt(e.doc,r.pos)?!1:(t&&t(e.tr.split(r.pos).scrollIntoView()),!0);if(!r.depth)return!1;let i=[],s,a,l=!1,c=!1;for(let h=r.depth;;h--)if(r.node(h).isBlock){l=r.end(h)==r.pos+(r.depth-h),c=r.start(h)==r.pos-(r.depth-h),a=Wi(r.node(h-1).contentMatchAt(r.indexAfter(h-1))),i.unshift(l&&a?{type:a}:null),s=h;break}else{if(h==1)return!1;i.unshift(null)}let d=e.tr;(e.selection instanceof D||e.selection instanceof ae)&&d.deleteSelection();let u=d.mapping.map(r.pos),p=jt(d.doc,u,i.length,i);if(p||(i[0]=a?{type:a}:null,p=jt(d.doc,u,i.length,i)),!p)return!1;if(d.split(u,i.length,i),!l&&c&&r.node(s).type!=a){let h=d.mapping.map(r.before(s)),m=d.doc.resolve(h);a&&r.node(s-1).canReplaceWith(m.index(),m.index()+1,a)&&d.setNodeMarkup(d.mapping.map(r.before(s)),a)}return t&&t(d.scrollIntoView()),!0}}const Hi=yc();function kc(n,e,t){let r=e.nodeBefore,o=e.nodeAfter,i=e.index();return!r||!o||!r.type.compatibleContent(o.type)?!1:!r.content.size&&e.parent.canReplace(i-1,i)?(t&&t(n.tr.delete(e.pos-r.nodeSize,e.pos).scrollIntoView()),!0):!e.parent.canReplace(i,i+1)||!(o.isTextblock||Ai(n.doc,e.pos))?!1:(t&&t(n.tr.join(e.pos).scrollIntoView()),!0)}function qi(n,e,t,r){let o=e.nodeBefore,i=e.nodeAfter,s,a,l=o.type.spec.isolating||i.type.spec.isolating;if(!l&&kc(n,e,t))return!0;let c=!l&&e.parent.canReplace(e.index(),e.index()+1);if(c&&(s=(a=o.contentMatchAt(o.childCount)).findWrapping(i.type))&&a.matchType(s[0]||i.type).validEnd){if(t){let h=e.pos+i.nodeSize,m=y.empty;for(let k=s.length-1;k>=0;k--)m=y.from(s[k].create(null,m));m=y.from(o.copy(m));let g=n.tr.step(new ne(e.pos-1,h,e.pos,h,new w(m,1,0),s.length,!0)),x=g.doc.resolve(h+2*s.length);x.nodeAfter&&x.nodeAfter.type==o.type&&Ai(g.doc,x.pos)&&g.join(x.pos),t(g.scrollIntoView())}return!0}let d=i.type.spec.isolating||r>0&&l?null:A.findFrom(e,1),u=d&&d.$from.blockRange(d.$to),p=u&&ur(u);if(p!=null&&p>=e.depth)return t&&t(n.tr.lift(u,p).scrollIntoView()),!0;if(c&&at(i,"start",!0)&&at(o,"end")){let h=o,m=[];for(;m.push(h),!h.isTextblock;)h=h.lastChild;let g=i,x=1;for(;!g.isTextblock;g=g.firstChild)x++;if(h.canReplace(h.childCount,h.childCount,g.content)){if(t){let k=y.empty;for(let N=m.length-1;N>=0;N--)k=y.from(m[N].copy(k));let T=n.tr.step(new ne(e.pos-m.length,e.pos+i.nodeSize,e.pos+x,e.pos+i.nodeSize-x,new w(k,m.length,0),0,!0));t(T.scrollIntoView())}return!0}}return!1}function hr(...n){return function(e,t,r){for(let o=0;o0){if(n.contentEditable=="false")return null;n=n.childNodes[e-1],e=ie(n)}else if(n.parentNode&&!Pt(n))e=q(n),n=n.parentNode;else return null}}function Sc(n,e){for(;;){if(n.nodeType==3&&e2),re=ct||(me?/Mac/.test(me.platform):!1),ji=me?/Win/.test(me.platform):!1,we=/Android \d/.test(Le),zt=!!uo&&"webkitFontSmoothing"in uo.documentElement.style,Tc=zt?+(/\bAppleWebKit\/(\d+)/.exec(navigator.userAgent)||[0,0])[1]:0;function Dc(n){let e=n.defaultView&&n.defaultView.visualViewport;return e?{left:0,right:e.width,top:0,bottom:e.height}:{left:0,right:n.documentElement.clientWidth,top:0,bottom:n.documentElement.clientHeight}}function ye(n,e){return typeof n=="number"?n:n[e]}function Ec(n){let e=n.getBoundingClientRect(),t=e.width/n.offsetWidth||1,r=e.height/n.offsetHeight||1;return{left:e.left,right:e.left+n.clientWidth*t,top:e.top,bottom:e.top+n.clientHeight*r}}function po(n,e,t){let r=n.someProp("scrollThreshold")||0,o=n.someProp("scrollMargin")||5,i=n.dom.ownerDocument;for(let s=t||n.dom;s;){if(s.nodeType!=1){s=lt(s);continue}let a=s,l=a==i.body,c=l?Dc(i):Ec(a),d=0,u=0;if(e.topc.bottom-ye(r,"bottom")&&(u=e.bottom-e.top>c.bottom-c.top?e.top+ye(o,"top")-c.top:e.bottom-c.bottom+ye(o,"bottom")),e.leftc.right-ye(r,"right")&&(d=e.right-c.right+ye(o,"right")),d||u)if(l)i.defaultView.scrollBy(d,u);else{let h=a.scrollLeft,m=a.scrollTop;u&&(a.scrollTop+=u),d&&(a.scrollLeft+=d);let g=a.scrollLeft-h,x=a.scrollTop-m;e={left:e.left-g,top:e.top-x,right:e.right-g,bottom:e.bottom-x}}let p=l?"fixed":getComputedStyle(s).position;if(/^(fixed|sticky)$/.test(p))break;s=p=="absolute"?s.offsetParent:lt(s)}}function Ic(n){let e=n.dom.getBoundingClientRect(),t=Math.max(0,e.top),r,o;for(let i=(e.left+e.right)/2,s=t+1;s=t-20){r=a,o=l.top;break}}return{refDOM:r,refTop:o,stack:Ki(n.dom)}}function Ki(n){let e=[],t=n.ownerDocument;for(let r=n;r&&(e.push({dom:r,top:r.scrollTop,left:r.scrollLeft}),n!=t);r=lt(r));return e}function Ac({refDOM:n,refTop:e,stack:t}){let r=n?n.getBoundingClientRect().top:0;Zi(t,r==0?0:r-e)}function Zi(n,e){for(let t=0;t=a){s=Math.max(m.bottom,s),a=Math.min(m.top,a);let g=m.left>e.left?m.left-e.left:m.right=(m.left+m.right)/2?1:0));continue}}else m.top>e.top&&!l&&m.left<=e.left&&m.right>=e.left&&(l=d,c={left:Math.max(m.left,Math.min(m.right,e.left)),top:m.top});!t&&(e.left>=m.right&&e.top>=m.top||e.left>=m.left&&e.top>=m.bottom)&&(i=u+1)}}return!t&&l&&(t=l,o=c,r=0),t&&t.nodeType==3?Lc(t,o):!t||r&&t.nodeType==1?{node:n,offset:i}:Yi(t,o)}function Lc(n,e){let t=n.nodeValue.length,r=document.createRange(),o;for(let i=0;i=(s.left+s.right)/2?1:0)};break}}return r.detach(),o||{node:n,offset:0}}function mr(n,e){return n.left>=e.left-1&&n.left<=e.right+1&&n.top>=e.top-1&&n.top<=e.bottom+1}function Pc(n,e){let t=n.parentNode;return t&&/^li$/i.test(t.nodeName)&&e.left(s.left+s.right)/2?1:-1}return n.docView.posFromDOM(r,o,i)}function Fc(n,e,t,r){let o=-1;for(let i=e,s=!1;i!=n.dom;){let a=n.docView.nearestDesc(i,!0),l;if(!a)return null;if(a.dom.nodeType==1&&(a.node.isBlock&&a.parent||!a.contentDOM)&&((l=a.dom.getBoundingClientRect()).width||l.height)&&(a.node.isBlock&&a.parent&&!/^T(R|BODY|HEAD|FOOT)$/.test(a.dom.nodeName)&&(!s&&l.left>r.left||l.top>r.top?o=a.posBefore:(!s&&l.right-1?o:n.docView.posFromDOM(e,t,-1)}function Gi(n,e,t){let r=n.childNodes.length;if(r&&t.tope.top&&o++}let c;zt&&o&&r.nodeType==1&&(c=r.childNodes[o-1]).nodeType==1&&c.contentEditable=="false"&&c.getBoundingClientRect().top>=e.top&&o--,r==n.dom&&o==r.childNodes.length-1&&r.lastChild.nodeType==1&&e.top>r.lastChild.getBoundingClientRect().bottom?a=n.state.doc.content.size:(o==0||r.nodeType!=1||r.childNodes[o-1].nodeName!="BR")&&(a=Fc(n,r,o,e))}a==null&&(a=zc(n,s,e));let l=n.docView.nearestDesc(s,!0);return{pos:a,inside:l?l.posAtStart-l.border:-1}}function fo(n){return n.top=0&&o==r.nodeValue.length?(l--,d=1):t<0?l--:c++,mt(Se(ke(r,l,c),d),d<0)}if(!n.state.doc.resolve(e-(i||0)).parent.inlineContent){if(i==null&&o&&(t<0||o==ie(r))){let l=r.childNodes[o-1];if(l.nodeType==1)return Pn(l.getBoundingClientRect(),!1)}if(i==null&&o=0)}if(i==null&&o&&(t<0||o==ie(r))){let l=r.childNodes[o-1],c=l.nodeType==3?ke(l,ie(l)-(s?0:1)):l.nodeType==1&&(l.nodeName!="BR"||!l.nextSibling)?l:null;if(c)return mt(Se(c,1),!1)}if(i==null&&o=0)}function mt(n,e){if(n.width==0)return n;let t=e?n.left:n.right;return{top:n.top,bottom:n.bottom,left:t,right:t}}function Pn(n,e){if(n.height==0)return n;let t=e?n.top:n.bottom;return{top:t,bottom:t,left:n.left,right:n.right}}function Qi(n,e,t){let r=n.state,o=n.root.activeElement;r!=e&&n.updateState(e),o!=n.dom&&n.focus();try{return t()}finally{r!=e&&n.updateState(r),o!=n.dom&&o&&o.focus()}}function _c(n,e,t){let r=e.selection,o=t=="up"?r.$from:r.$to;return Qi(n,e,()=>{let{node:i}=n.docView.domFromPos(o.pos,t=="up"?-1:1);for(;;){let a=n.docView.nearestDesc(i,!0);if(!a)break;if(a.node.isBlock){i=a.contentDOM||a.dom;break}i=a.dom.parentNode}let s=Xi(n,o.pos,1);for(let a=i.firstChild;a;a=a.nextSibling){let l;if(a.nodeType==1)l=a.getClientRects();else if(a.nodeType==3)l=ke(a,0,a.nodeValue.length).getClientRects();else continue;for(let c=0;cd.top+1&&(t=="up"?s.top-d.top>(d.bottom-s.top)*2:d.bottom-s.bottom>(s.bottom-d.top)*2))return!1}}return!0})}const $c=/[\u0590-\u08ac]/;function Wc(n,e,t){let{$head:r}=e.selection;if(!r.parent.isTextblock)return!1;let o=r.parentOffset,i=!o,s=o==r.parent.content.size,a=n.domSelection();return a?!$c.test(r.parent.textContent)||!a.modify?t=="left"||t=="backward"?i:s:Qi(n,e,()=>{let{focusNode:l,focusOffset:c,anchorNode:d,anchorOffset:u}=n.domSelectionRange(),p=a.caretBidiLevel;a.modify("move",t,"character");let h=r.depth?n.docView.domAfterPos(r.before()):n.dom,{focusNode:m,focusOffset:g}=n.domSelectionRange(),x=m&&!h.contains(m.nodeType==1?m:m.parentNode)||l==m&&c==g;try{a.collapse(d,u),l&&(l!=d||c!=u)&&a.extend&&a.extend(l,c)}catch{}return p!=null&&(a.caretBidiLevel=p),x}):r.pos==r.start()||r.pos==r.end()}let ho=null,mo=null,go=!1;function Hc(n,e,t){return ho==e&&mo==t?go:(ho=e,mo=t,go=t=="up"||t=="down"?_c(n,e,t):Wc(n,e,t))}const ce=0,xo=1,ze=2,ge=3;class Ft{constructor(e,t,r,o){this.parent=e,this.children=t,this.dom=r,this.contentDOM=o,this.dirty=ce,r.pmViewDesc=this}matchesWidget(e){return!1}matchesMark(e){return!1}matchesNode(e,t,r){return!1}matchesHack(e){return!1}parseRule(){return null}stopEvent(e){return!1}get size(){let e=0;for(let t=0;tq(this.contentDOM);else if(this.contentDOM&&this.contentDOM!=this.dom&&this.dom.contains(this.contentDOM))o=e.compareDocumentPosition(this.contentDOM)&2;else if(this.dom.firstChild){if(t==0)for(let i=e;;i=i.parentNode){if(i==this.dom){o=!1;break}if(i.previousSibling)break}if(o==null&&t==e.childNodes.length)for(let i=e;;i=i.parentNode){if(i==this.dom){o=!0;break}if(i.nextSibling)break}}return o??r>0?this.posAtEnd:this.posAtStart}nearestDesc(e,t=!1){for(let r=!0,o=e;o;o=o.parentNode){let i=this.getDesc(o),s;if(i&&(!t||i.node))if(r&&(s=i.nodeDOM)&&!(s.nodeType==1?s.contains(e.nodeType==1?e:e.parentNode):s==e))r=!1;else return i}}getDesc(e){let t=e.pmViewDesc;for(let r=t;r;r=r.parent)if(r==this)return t}posFromDOM(e,t,r){for(let o=e;o;o=o.parentNode){let i=this.getDesc(o);if(i)return i.localPosFromDOM(e,t,r)}return-1}descAt(e){for(let t=0,r=0;te||s instanceof ts){o=e-i;break}i=a}if(o)return this.children[r].domFromPos(o-this.children[r].border,t);for(let i;r&&!(i=this.children[r-1]).size&&i instanceof es&&i.side>=0;r--);if(t<=0){let i,s=!0;for(;i=r?this.children[r-1]:null,!(!i||i.dom.parentNode==this.contentDOM);r--,s=!1);return i&&t&&s&&!i.border&&!i.domAtom?i.domFromPos(i.size,t):{node:this.contentDOM,offset:i?q(i.dom)+1:0}}else{let i,s=!0;for(;i=r=d&&t<=c-l.border&&l.node&&l.contentDOM&&this.contentDOM.contains(l.contentDOM))return l.parseRange(e,t,d);e=s;for(let u=a;u>0;u--){let p=this.children[u-1];if(p.size&&p.dom.parentNode==this.contentDOM&&!p.emptyChildAt(1)){o=q(p.dom)+1;break}e-=p.size}o==-1&&(o=0)}if(o>-1&&(c>t||a==this.children.length-1)){t=c;for(let d=a+1;dm&&st){let m=a;a=l,l=m}let h=document.createRange();h.setEnd(l.node,l.offset),h.setStart(a.node,a.offset),c.removeAllRanges(),c.addRange(h)}}ignoreMutation(e){return!this.contentDOM&&e.type!="selection"}get contentLost(){return this.contentDOM&&this.contentDOM!=this.dom&&!this.dom.contains(this.contentDOM)}markDirty(e,t){for(let r=0,o=0;o=r:er){let a=r+i.border,l=s-i.border;if(e>=a&&t<=l){this.dirty=e==r||t==s?ze:xo,e==a&&t==l&&(i.contentLost||i.dom.parentNode!=this.contentDOM)?i.dirty=ge:i.markDirty(e-a,t-a);return}else i.dirty=i.dom==i.contentDOM&&i.dom.parentNode==this.contentDOM&&!i.children.length?ze:ge}r=s}this.dirty=ze}markParentsDirty(){let e=1;for(let t=this.parent;t;t=t.parent,e++){let r=e==1?ze:xo;t.dirty{if(!i)return o;if(i.parent)return i.parent.posBeforeChild(i)})),!t.type.spec.raw){if(s.nodeType!=1){let a=document.createElement("span");a.appendChild(s),s=a}s.contentEditable="false",s.classList.add("ProseMirror-widget")}super(e,[],s,null),this.widget=t,this.widget=t,i=this}matchesWidget(e){return this.dirty==ce&&e.type.eq(this.widget.type)}parseRule(){return{ignore:!0}}stopEvent(e){let t=this.widget.spec.stopEvent;return t?t(e):!1}ignoreMutation(e){return e.type!="selection"||this.widget.spec.ignoreSelection}destroy(){this.widget.type.destroy(this.dom),super.destroy()}get domAtom(){return!0}get ignoreForSelection(){return!!this.widget.type.spec.relaxedSide}get side(){return this.widget.type.side}}class qc extends Ft{constructor(e,t,r,o){super(e,[],t,null),this.textDOM=r,this.text=o}get size(){return this.text.length}localPosFromDOM(e,t){return e!=this.textDOM?this.posAtStart+(t?this.size:0):this.posAtStart+t}domFromPos(e){return{node:this.textDOM,offset:e}}ignoreMutation(e){return e.type==="characterData"&&e.target.nodeValue==e.oldValue}}class Ke extends Ft{constructor(e,t,r,o,i){super(e,[],r,o),this.mark=t,this.spec=i}static create(e,t,r,o){let i=o.nodeViews[t.type.name],s=i&&i(t,o,r);return(!s||!s.dom)&&(s=ut.renderSpec(document,t.type.spec.toDOM(t,r),null,t.attrs)),new Ke(e,t,s.dom,s.contentDOM||s.dom,s)}parseRule(){return this.dirty&ge||this.mark.type.spec.reparseInView?null:{mark:this.mark.type.name,attrs:this.mark.attrs,contentElement:this.contentDOM}}matchesMark(e){return this.dirty!=ge&&this.mark.eq(e)}markDirty(e,t){if(super.markDirty(e,t),this.dirty!=ce){let r=this.parent;for(;!r.node;)r=r.parent;r.dirty0&&(i=er(i,0,e,r));for(let a=0;a{if(!l)return s;if(l.parent)return l.parent.posBeforeChild(l)},r,o),d=c&&c.dom,u=c&&c.contentDOM;if(t.isText){if(!d)d=document.createTextNode(t.text);else if(d.nodeType!=3)throw new RangeError("Text must be rendered as a DOM text node")}else d||({dom:d,contentDOM:u}=ut.renderSpec(document,t.type.spec.toDOM(t),null,t.attrs));!u&&!t.isText&&d.nodeName!="BR"&&(d.hasAttribute("contenteditable")||(d.contentEditable="false"),t.type.spec.draggable&&(d.draggable=!0));let p=d;return d=is(d,r,t),c?l=new Jc(e,t,r,o,d,u||null,p,c,i,s+1):t.isText?new gn(e,t,r,o,d,p,i):new Ae(e,t,r,o,d,u||null,p,i,s+1)}parseRule(){if(this.node.type.spec.reparseInView)return null;let e={node:this.node.type.name,attrs:this.node.attrs};if(this.node.type.whitespace=="pre"&&(e.preserveWhitespace="full"),!this.contentDOM)e.getContent=()=>this.node.content;else if(!this.contentLost)e.contentElement=this.contentDOM;else{for(let t=this.children.length-1;t>=0;t--){let r=this.children[t];if(this.dom.contains(r.dom.parentNode)){e.contentElement=r.dom.parentNode;break}}e.contentElement||(e.getContent=()=>y.empty)}return e}matchesNode(e,t,r){return this.dirty==ce&&e.eq(this.node)&&rn(t,this.outerDeco)&&r.eq(this.innerDeco)}get size(){return this.node.nodeSize}get border(){return this.node.isLeaf?0:1}updateChildren(e,t){let r=this.node.inlineContent,o=t,i=e.composing?this.localCompositionInfo(e,t):null,s=i&&i.pos>-1?i:null,a=i&&i.pos<0,l=new jc(this,s&&s.node,e);Yc(this.node,this.innerDeco,(c,d,u)=>{c.spec.marks?l.syncToMarks(c.spec.marks,r,e,d):c.type.side>=0&&!u&&l.syncToMarks(d==this.node.childCount?E.none:this.node.child(d).marks,r,e,d),l.placeWidget(c,e,o)},(c,d,u,p)=>{l.syncToMarks(c.marks,r,e,p);let h;l.findNodeMatch(c,d,u,p)||a&&e.state.selection.from>o&&e.state.selection.to-1&&l.updateNodeAt(c,d,u,h,e)||l.updateNextNode(c,d,u,e,p,o)||l.addNode(c,d,u,e,o),o+=c.nodeSize}),l.syncToMarks([],r,e,0),this.node.isTextblock&&l.addTextblockHacks(),l.destroyRest(),(l.changed||this.dirty==ze)&&(s&&this.protectLocalComposition(e,s),ns(this.contentDOM,this.children,e),ct&&Gc(this.dom))}localCompositionInfo(e,t){let{from:r,to:o}=e.state.selection;if(!(e.state.selection instanceof D)||rt+this.node.content.size)return null;let i=e.input.compositionNode;if(!i||!this.dom.contains(i.parentNode))return null;if(this.node.inlineContent){let s=i.nodeValue,a=Xc(this.node.content,s,r-t,o-t);return a<0?null:{node:i,pos:a,text:s}}else return{node:i,pos:-1,text:""}}protectLocalComposition(e,{node:t,pos:r,text:o}){if(this.getDesc(t))return;let i=t;for(;i.parentNode!=this.contentDOM;i=i.parentNode){for(;i.previousSibling;)i.parentNode.removeChild(i.previousSibling);for(;i.nextSibling;)i.parentNode.removeChild(i.nextSibling);i.pmViewDesc&&(i.pmViewDesc=void 0)}let s=new qc(this,i,t,o);e.input.compositionNodes.push(s),this.children=er(this.children,r,r+o.length,e,s)}update(e,t,r,o){return this.dirty==ge||!e.sameMarkup(this.node)?!1:(this.updateInner(e,t,r,o),!0)}updateInner(e,t,r,o){this.updateOuterDeco(t),this.node=e,this.innerDeco=r,this.contentDOM&&this.updateChildren(o,this.posAtStart),this.dirty=ce}updateOuterDeco(e){if(rn(e,this.outerDeco))return;let t=this.nodeDOM.nodeType!=1,r=this.dom;this.dom=rs(this.dom,this.nodeDOM,Qn(this.outerDeco,this.node,t),Qn(e,this.node,t)),this.dom!=r&&(r.pmViewDesc=void 0,this.dom.pmViewDesc=this),this.outerDeco=e}selectNode(){this.nodeDOM.nodeType==1&&(this.nodeDOM.classList.add("ProseMirror-selectednode"),(this.contentDOM||!this.node.type.spec.draggable)&&(this.nodeDOM.draggable=!0))}deselectNode(){this.nodeDOM.nodeType==1&&(this.nodeDOM.classList.remove("ProseMirror-selectednode"),(this.contentDOM||!this.node.type.spec.draggable)&&this.nodeDOM.removeAttribute("draggable"))}get domAtom(){return this.node.isAtom}}function bo(n,e,t,r,o){is(r,e,n);let i=new Ae(void 0,n,e,t,r,r,r,o,0);return i.contentDOM&&i.updateChildren(o,0),i}class gn extends Ae{constructor(e,t,r,o,i,s,a){super(e,t,r,o,i,null,s,a,0)}parseRule(){let e=this.nodeDOM.parentNode;for(;e&&e!=this.dom&&!e.pmIsDeco;)e=e.parentNode;return{skip:e||!0}}update(e,t,r,o){return this.dirty==ge||this.dirty!=ce&&!this.inParent()||!e.sameMarkup(this.node)?!1:(this.updateOuterDeco(t),(this.dirty!=ce||e.text!=this.node.text)&&e.text!=this.nodeDOM.nodeValue&&(this.nodeDOM.nodeValue=e.text,o.trackWrites==this.nodeDOM&&(o.trackWrites=null)),this.node=e,this.dirty=ce,!0)}inParent(){let e=this.parent.contentDOM;for(let t=this.nodeDOM;t;t=t.parentNode)if(t==e)return!0;return!1}domFromPos(e){return{node:this.nodeDOM,offset:e}}localPosFromDOM(e,t,r){return e==this.nodeDOM?this.posAtStart+Math.min(t,this.node.text.length):super.localPosFromDOM(e,t,r)}ignoreMutation(e){return e.type!="characterData"&&e.type!="selection"}slice(e,t,r){let o=this.node.cut(e,t),i=document.createTextNode(o.text);return new gn(this.parent,o,this.outerDeco,this.innerDeco,i,i,r)}markDirty(e,t){super.markDirty(e,t),this.dom!=this.nodeDOM&&(e==0||t==this.nodeDOM.nodeValue.length)&&(this.dirty=ge)}get domAtom(){return!1}isText(e){return this.node.text==e}}class ts extends Ft{parseRule(){return{ignore:!0}}matchesHack(e){return this.dirty==ce&&this.dom.nodeName==e}get domAtom(){return!0}get ignoreForCoords(){return this.dom.nodeName=="IMG"}}class Jc extends Ae{constructor(e,t,r,o,i,s,a,l,c,d){super(e,t,r,o,i,s,a,c,d),this.spec=l}update(e,t,r,o){if(this.dirty==ge)return!1;if(this.spec.update&&(this.node.type==e.type||this.spec.multiType)){let i=this.spec.update(e,t,r);return i&&this.updateInner(e,t,r,o),i}else return!this.contentDOM&&!e.isLeaf?!1:super.update(e,t,r,o)}selectNode(){this.spec.selectNode?this.spec.selectNode():super.selectNode()}deselectNode(){this.spec.deselectNode?this.spec.deselectNode():super.deselectNode()}setSelection(e,t,r,o){this.spec.setSelection?this.spec.setSelection(e,t,r.root):super.setSelection(e,t,r,o)}destroy(){this.spec.destroy&&this.spec.destroy(),super.destroy()}stopEvent(e){return this.spec.stopEvent?this.spec.stopEvent(e):!1}ignoreMutation(e){return this.spec.ignoreMutation?this.spec.ignoreMutation(e):super.ignoreMutation(e)}}function ns(n,e,t){let r=n.firstChild,o=!1;for(let i=0;i>1,a=Math.min(s,e.length);for(;i-1)l>this.index&&(this.changed=!0,this.destroyBetween(this.index,l)),this.top=this.top.children[this.index];else{let d=Ke.create(this.top,e[s],t,r);this.top.children.splice(this.index,0,d),this.top=d,this.changed=!0}this.index=0,s++}}findNodeMatch(e,t,r,o){let i=-1,s;if(o>=this.preMatch.index&&(s=this.preMatch.matches[o-this.preMatch.index]).parent==this.top&&s.matchesNode(e,t,r))i=this.top.children.indexOf(s,this.index);else for(let a=this.index,l=Math.min(this.top.children.length,a+5);a0;){let a;for(;;)if(r){let c=t.children[r-1];if(c instanceof Ke)t=c,r=c.children.length;else{a=c,r--;break}}else{if(t==e)break e;r=t.parent.children.indexOf(t),t=t.parent}let l=a.node;if(l){if(l!=n.child(o-1))break;--o,i.set(a,o),s.push(a)}}return{index:o,matched:i,matches:s.reverse()}}function Zc(n,e){return n.type.side-e.type.side}function Yc(n,e,t,r){let o=e.locals(n),i=0;if(o.length==0){for(let c=0;ci;)a.push(o[s++]);let m=i+p.nodeSize;if(p.isText){let x=m;s!x.inline):a.slice();r(p,g,e.forChild(i,p),h),i=m}}function Gc(n){if(n.nodeName=="UL"||n.nodeName=="OL"){let e=n.style.cssText;n.style.cssText=e+"; list-style: square !important",window.getComputedStyle(n).listStyle,n.style.cssText=e}}function Xc(n,e,t,r){for(let o=0,i=0;o=t){if(i>=r&&l.slice(r-e.length-a,r-a)==e)return r-e.length;let c=a=0&&c+e.length+a>=t)return a+c;if(t==r&&l.length>=r+e.length-a&&l.slice(r-a,r-a+e.length)==e)return r}}return-1}function er(n,e,t,r,o){let i=[];for(let s=0,a=0;s=t||d<=e?i.push(l):(ct&&i.push(l.slice(t-c,l.size,r)))}return i}function gr(n,e=null){let t=n.domSelectionRange(),r=n.state.doc;if(!t.focusNode)return null;let o=n.docView.nearestDesc(t.focusNode),i=o&&o.size==0,s=n.docView.posFromDOM(t.focusNode,t.focusOffset,1);if(s<0)return null;let a=r.resolve(s),l,c;if(mn(t)){for(l=s;o&&!o.node;)o=o.parent;let u=o.node;if(o&&u.isAtom&&M.isSelectable(u)&&o.parent&&!(u.isInline&&Mc(t.focusNode,t.focusOffset,o.dom))){let p=o.posBefore;c=new M(s==p?a:r.resolve(p))}}else{if(t instanceof n.dom.ownerDocument.defaultView.Selection&&t.rangeCount>1){let u=s,p=s;for(let h=0;h{(t.anchorNode!=r||t.anchorOffset!=o)&&(e.removeEventListener("selectionchange",n.input.hideSelectionGuard),setTimeout(()=>{(!ss(n)||n.state.selection.visible)&&n.dom.classList.remove("ProseMirror-hideselection")},20))})}function ed(n){let e=n.domSelection();if(!e)return;let t=n.cursorWrapper.dom,r=t.nodeName=="IMG";r?e.collapse(t.parentNode,q(t)+1):e.collapse(t,0),!r&&!n.state.selection.visible&&G&&Ie<=11&&(t.disabled=!0,t.disabled=!1)}function as(n,e){if(e instanceof M){let t=n.docView.descAt(e.from);t!=n.lastSelectedViewDesc&&(Co(n),t&&t.selectNode(),n.lastSelectedViewDesc=t)}else Co(n)}function Co(n){n.lastSelectedViewDesc&&(n.lastSelectedViewDesc.parent&&n.lastSelectedViewDesc.deselectNode(),n.lastSelectedViewDesc=void 0)}function xr(n,e,t,r){return n.someProp("createSelectionBetween",o=>o(n,e,t))||D.between(e,t,r)}function So(n){return n.editable&&!n.hasFocus()?!1:ls(n)}function ls(n){let e=n.domSelectionRange();if(!e.anchorNode)return!1;try{return n.dom.contains(e.anchorNode.nodeType==3?e.anchorNode.parentNode:e.anchorNode)&&(n.editable||n.dom.contains(e.focusNode.nodeType==3?e.focusNode.parentNode:e.focusNode))}catch{return!1}}function td(n){let e=n.docView.domFromPos(n.state.selection.anchor,0),t=n.domSelectionRange();return je(e.node,e.offset,t.anchorNode,t.anchorOffset)}function tr(n,e){let{$anchor:t,$head:r}=n.selection,o=e>0?t.max(r):t.min(r),i=o.parent.inlineContent?o.depth?n.doc.resolve(e>0?o.after():o.before()):null:o;return i&&A.findFrom(i,e)}function Me(n,e){return n.dispatch(n.state.tr.setSelection(e).scrollIntoView()),!0}function Mo(n,e,t){let r=n.state.selection;if(r instanceof D)if(t.indexOf("s")>-1){let{$head:o}=r,i=o.textOffset?null:e<0?o.nodeBefore:o.nodeAfter;if(!i||i.isText||!i.isLeaf)return!1;let s=n.state.doc.resolve(o.pos+i.nodeSize*(e<0?-1:1));return Me(n,new D(r.$anchor,s))}else if(r.empty){if(n.endOfTextblock(e>0?"forward":"backward")){let o=tr(n.state,e);return o&&o instanceof M?Me(n,o):!1}else if(!(re&&t.indexOf("m")>-1)){let o=r.$head,i=o.textOffset?null:e<0?o.nodeBefore:o.nodeAfter,s;if(!i||i.isText)return!1;let a=e<0?o.pos-i.nodeSize:o.pos;return i.isAtom||(s=n.docView.descAt(a))&&!s.contentDOM?M.isSelectable(i)?Me(n,new M(e<0?n.state.doc.resolve(o.pos-i.nodeSize):o)):zt?Me(n,new D(n.state.doc.resolve(e<0?a:a+i.nodeSize))):!1:!1}}else return!1;else{if(r instanceof M&&r.node.isInline)return Me(n,new D(e>0?r.$to:r.$from));{let o=tr(n.state,e);return o?Me(n,o):!1}}}function on(n){return n.nodeType==3?n.nodeValue.length:n.childNodes.length}function Mt(n,e){let t=n.pmViewDesc;return t&&t.size==0&&(e<0||n.nextSibling||n.nodeName!="BR")}function Ge(n,e){return e<0?nd(n):rd(n)}function nd(n){let e=n.domSelectionRange(),t=e.focusNode,r=e.focusOffset;if(!t)return;let o,i,s=!1;for(le&&t.nodeType==1&&r0){if(t.nodeType!=1)break;{let a=t.childNodes[r-1];if(Mt(a,-1))o=t,i=--r;else if(a.nodeType==3)t=a,r=t.nodeValue.length;else break}}else{if(cs(t))break;{let a=t.previousSibling;for(;a&&Mt(a,-1);)o=t.parentNode,i=q(a),a=a.previousSibling;if(a)t=a,r=on(t);else{if(t=t.parentNode,t==n.dom)break;r=0}}}s?nr(n,t,r):o&&nr(n,o,i)}function rd(n){let e=n.domSelectionRange(),t=e.focusNode,r=e.focusOffset;if(!t)return;let o=on(t),i,s;for(;;)if(r{n.state==o&&ve(n)},50)}function No(n,e){let t=n.state.doc.resolve(e);if(!(J||ji)&&t.parent.inlineContent){let o=n.coordsAtPos(e);if(e>t.start()){let i=n.coordsAtPos(e-1),s=(i.top+i.bottom)/2;if(s>o.top&&s1)return i.lefto.top&&s1)return i.left>o.left?"ltr":"rtl"}}return getComputedStyle(n.dom).direction=="rtl"?"rtl":"ltr"}function Oo(n,e,t){let r=n.state.selection;if(r instanceof D&&!r.empty||t.indexOf("s")>-1||re&&t.indexOf("m")>-1)return!1;let{$from:o,$to:i}=r;if(!o.parent.inlineContent||n.endOfTextblock(e<0?"up":"down")){let s=tr(n.state,e);if(s&&s instanceof M)return Me(n,s)}if(!o.parent.inlineContent){let s=e<0?o:i,a=r instanceof ae?A.near(s,e):A.findFrom(s,e);return a?Me(n,a):!1}return!1}function To(n,e){if(!(n.state.selection instanceof D))return!0;let{$head:t,$anchor:r,empty:o}=n.state.selection;if(!t.sameParent(r))return!0;if(!o)return!1;if(n.endOfTextblock(e>0?"forward":"backward"))return!0;let i=!t.textOffset&&(e<0?t.nodeBefore:t.nodeAfter);if(i&&!i.isText){let s=n.state.tr;return e<0?s.delete(t.pos-i.nodeSize,t.pos):s.delete(t.pos,t.pos+i.nodeSize),n.dispatch(s),!0}return!1}function Do(n,e,t){n.domObserver.stop(),e.contentEditable=t,n.domObserver.start()}function sd(n){if(!j||n.state.selection.$head.parentOffset>0)return!1;let{focusNode:e,focusOffset:t}=n.domSelectionRange();if(e&&e.nodeType==1&&t==0&&e.firstChild&&e.firstChild.contentEditable=="false"){let r=e.firstChild;Do(n,r,"true"),setTimeout(()=>Do(n,r,"false"),20)}return!1}function ad(n){let e="";return n.ctrlKey&&(e+="c"),n.metaKey&&(e+="m"),n.altKey&&(e+="a"),n.shiftKey&&(e+="s"),e}function ld(n,e){let t=e.keyCode,r=ad(e);if(t==8||re&&t==72&&r=="c")return To(n,-1)||Ge(n,-1);if(t==46&&!e.shiftKey||re&&t==68&&r=="c")return To(n,1)||Ge(n,1);if(t==13||t==27)return!0;if(t==37||re&&t==66&&r=="c"){let o=t==37?No(n,n.state.selection.from)=="ltr"?-1:1:-1;return Mo(n,o,r)||Ge(n,o)}else if(t==39||re&&t==70&&r=="c"){let o=t==39?No(n,n.state.selection.from)=="ltr"?1:-1:1;return Mo(n,o,r)||Ge(n,o)}else{if(t==38||re&&t==80&&r=="c")return Oo(n,-1,r)||Ge(n,-1);if(t==40||re&&t==78&&r=="c")return sd(n)||Oo(n,1,r)||Ge(n,1);if(r==(re?"m":"c")&&(t==66||t==73||t==89||t==90))return!0}return!1}function br(n,e){n.someProp("transformCopied",h=>{e=h(e,n)});let t=[],{content:r,openStart:o,openEnd:i}=e;for(;o>1&&i>1&&r.childCount==1&&r.firstChild.childCount==1;){o--,i--;let h=r.firstChild;t.push(h.type.name,h.attrs!=h.type.defaultAttrs?h.attrs:null),r=h.content}let s=n.someProp("clipboardSerializer")||ut.fromSchema(n.state.schema),a=ms(),l=a.createElement("div");l.appendChild(s.serializeFragment(r,{document:a}));let c=l.firstChild,d,u=0;for(;c&&c.nodeType==1&&(d=hs[c.nodeName.toLowerCase()]);){for(let h=d.length-1;h>=0;h--){let m=a.createElement(d[h]);for(;l.firstChild;)m.appendChild(l.firstChild);l.appendChild(m),u++}c=l.firstChild}c&&c.nodeType==1&&c.setAttribute("data-pm-slice",`${o} ${i}${u?` -${u}`:""} ${JSON.stringify(t)}`);let p=n.someProp("clipboardTextSerializer",h=>h(e,n))||e.content.textBetween(0,e.content.size,` -`);return{dom:l,text:p,slice:e}}function as(n,e,t,r,o){let i=o.parent.type.spec.code,s,a;if(!t&&!e)return null;let l=!!e&&(r||i||!t);if(l){if(n.someProp("transformPastedText",p=>{e=p(e,i||r,n)}),i)return a=new w(y.from(n.state.schema.text(e.replace(/\r\n?/g,` -`))),0,0),n.someProp("transformPasted",p=>{a=p(a,n,!0)}),a;let u=n.someProp("clipboardTextParser",p=>p(e,o,r,n));if(u)a=u;else{let p=o.marks(),{schema:h}=n.state,m=ct.fromSchema(h);s=document.createElement("div"),e.split(/(?:\r\n?|\n)+/).forEach(g=>{let x=s.appendChild(document.createElement("p"));g&&x.appendChild(m.serializeNode(h.text(g,p)))})}}else n.someProp("transformPastedHTML",u=>{t=u(t,n)}),s=ad(t),It&&ld(s);let c=s&&s.querySelector("[data-pm-slice]"),d=c&&/^(\d+) (\d+)(?: -(\d+))? (.*)/.exec(c.getAttribute("data-pm-slice")||"");if(d&&d[3])for(let u=+d[3];u>0;u--){let p=s.firstChild;for(;p&&p.nodeType!=1;)p=p.nextSibling;if(!p)break;s=p}if(a||(a=(n.someProp("clipboardParser")||n.someProp("domParser")||St.fromSchema(n.state.schema)).parseSlice(s,{preserveWhitespace:!!(l||d),context:o,ruleFromNode(p){return p.nodeName=="BR"&&!p.nextSibling&&p.parentNode&&!od.test(p.parentNode.nodeName)?{ignore:!0}:null}})),d)a=cd(To(a,+d[1],+d[2]),d[4]);else if(a=w.maxOpen(id(a.content,o),!0),a.openStart||a.openEnd){let u=0,p=0;for(let h=a.content.firstChild;u{a=u(a,n,l)}),a}const od=/^(a|abbr|acronym|b|cite|code|del|em|i|ins|kbd|label|output|q|ruby|s|samp|span|strong|sub|sup|time|u|tt|var)$/i;function id(n,e){if(n.childCount<2)return n;for(let t=e.depth;t>=0;t--){let o=e.node(t).contentMatchAt(e.index(t)),i,s=[];if(n.forEach(a=>{if(!s)return;let l=o.findWrapping(a.type),c;if(!l)return s=null;if(c=s.length&&i.length&&cs(l,i,a,s[s.length-1],0))s[s.length-1]=c;else{s.length&&(s[s.length-1]=ds(s[s.length-1],i.length));let d=ls(a,l);s.push(d),o=o.matchType(d.type),i=l}}),s)return y.from(s)}return n}function ls(n,e,t=0){for(let r=e.length-1;r>=t;r--)n=e[r].create(null,y.from(n));return n}function cs(n,e,t,r,o){if(o1&&(i=0),o=t&&(a=e<0?s.contentMatchAt(0).fillBefore(a,i<=o).append(a):a.append(s.contentMatchAt(s.childCount).fillBefore(y.empty,!0))),n.replaceChild(e<0?0:n.childCount-1,s.copy(a))}function To(n,e,t){return et})),Pn.createHTML(n)):n}function ad(n){let e=/^(\s*]*>)*/.exec(n);e&&(n=n.slice(e[0].length));let t=ps().createElement("div"),r=/<([a-z][^>\s]+)/i.exec(n),o;if((o=r&&us[r[1].toLowerCase()])&&(n=o.map(i=>"<"+i+">").join("")+n+o.map(i=>"").reverse().join("")),t.innerHTML=sd(n),o)for(let i=0;i=0;a-=2){let l=t.nodes[r[a]];if(!l||l.hasRequiredAttrs())break;o=y.from(l.create(r[a+1],o)),i++,s++}return new w(o,i,s)}const Z={},Y={},dd={touchstart:!0,touchmove:!0};class ud{constructor(){this.shiftKey=!1,this.mouseDown=null,this.lastKeyCode=null,this.lastKeyCodeTime=0,this.lastClick={time:0,x:0,y:0,type:"",button:0},this.lastSelectionOrigin=null,this.lastSelectionTime=0,this.lastIOSEnter=0,this.lastIOSEnterFallbackTimeout=-1,this.lastFocus=0,this.lastTouch=0,this.lastChromeDelete=0,this.composing=!1,this.compositionNode=null,this.composingTimeout=-1,this.compositionNodes=[],this.compositionEndedAt=-2e8,this.compositionID=1,this.badSafariComposition=!1,this.compositionPendingChanges=0,this.domChangeCount=0,this.eventHandlers=Object.create(null),this.hideSelectionGuard=null}}function pd(n){for(let e in Z){let t=Z[e];n.dom.addEventListener(e,n.input.eventHandlers[e]=r=>{hd(n,r)&&!br(n,r)&&(n.editable||!(r.type in Y))&&t(n,r)},dd[e]?{passive:!0}:void 0)}j&&n.dom.addEventListener("input",()=>null),or(n)}function Ee(n,e){n.input.lastSelectionOrigin=e,n.input.lastSelectionTime=Date.now()}function fd(n){n.domObserver.stop();for(let e in n.input.eventHandlers)n.dom.removeEventListener(e,n.input.eventHandlers[e]);clearTimeout(n.input.composingTimeout),clearTimeout(n.input.lastIOSEnterFallbackTimeout)}function or(n){n.someProp("handleDOMEvents",e=>{for(let t in e)n.input.eventHandlers[t]||n.dom.addEventListener(t,n.input.eventHandlers[t]=r=>br(n,r))})}function br(n,e){return n.someProp("handleDOMEvents",t=>{let r=t[e.type];return r?r(n,e)||e.defaultPrevented:!1})}function hd(n,e){if(!e.bubbles)return!0;if(e.defaultPrevented)return!1;for(let t=e.target;t!=n.dom;t=t.parentNode)if(!t||t.nodeType==11||t.pmViewDesc&&t.pmViewDesc.stopEvent(e))return!1;return!0}function md(n,e){!br(n,e)&&Z[e.type]&&(n.editable||!(e.type in Y))&&Z[e.type](n,e)}Y.keydown=(n,e)=>{let t=e;if(n.input.shiftKey=t.keyCode==16||t.shiftKey,!hs(n,t)&&(n.input.lastKeyCode=t.keyCode,n.input.lastKeyCodeTime=Date.now(),!(we&&J&&t.keyCode==13)))if(t.keyCode!=229&&n.domObserver.forceFlush(),at&&t.keyCode==13&&!t.ctrlKey&&!t.altKey&&!t.metaKey){let r=Date.now();n.input.lastIOSEnter=r,n.input.lastIOSEnterFallbackTimeout=setTimeout(()=>{n.input.lastIOSEnter==r&&(n.someProp("handleKeyDown",o=>o(n,Pe(13,"Enter"))),n.input.lastIOSEnter=0)},200)}else n.someProp("handleKeyDown",r=>r(n,t))||rd(n,t)?t.preventDefault():Ee(n,"key")};Y.keyup=(n,e)=>{e.keyCode==16&&(n.input.shiftKey=!1)};Y.keypress=(n,e)=>{let t=e;if(hs(n,t)||!t.charCode||t.ctrlKey&&!t.altKey||re&&t.metaKey)return;if(n.someProp("handleKeyPress",o=>o(n,t))){t.preventDefault();return}let r=n.state.selection;if(!(r instanceof D)||!r.$from.sameParent(r.$to)){let o=String.fromCharCode(t.charCode),i=()=>n.state.tr.insertText(o).scrollIntoView();!/[\r\n]/.test(o)&&!n.someProp("handleTextInput",s=>s(n,r.$from.pos,r.$to.pos,o,i))&&n.dispatch(i()),t.preventDefault()}};function mn(n){return{left:n.clientX,top:n.clientY}}function gd(n,e){let t=e.x-n.clientX,r=e.y-n.clientY;return t*t+r*r<100}function yr(n,e,t,r,o){if(r==-1)return!1;let i=n.state.doc.resolve(r);for(let s=i.depth+1;s>0;s--)if(n.someProp(e,a=>s>i.depth?a(n,t,i.nodeAfter,i.before(s),o,!0):a(n,t,i.node(s),i.before(s),o,!1)))return!0;return!1}function nt(n,e,t){if(n.focused||n.focus(),n.state.selection.eq(e))return;let r=n.state.tr.setSelection(e);r.setMeta("pointer",!0),n.dispatch(r)}function xd(n,e){if(e==-1)return!1;let t=n.state.doc.resolve(e),r=t.nodeAfter;return r&&r.isAtom&&M.isSelectable(r)?(nt(n,new M(t)),!0):!1}function bd(n,e){if(e==-1)return!1;let t=n.state.selection,r,o;t instanceof M&&(r=t.node);let i=n.state.doc.resolve(e);for(let s=i.depth+1;s>0;s--){let a=s>i.depth?i.nodeAfter:i.node(s);if(M.isSelectable(a)){r&&t.$from.depth>0&&s>=t.$from.depth&&i.before(t.$from.depth+1)==t.$from.pos?o=i.before(t.$from.depth):o=i.before(s);break}}return o!=null?(nt(n,M.create(n.state.doc,o)),!0):!1}function yd(n,e,t,r,o){return yr(n,"handleClickOn",e,t,r)||n.someProp("handleClick",i=>i(n,e,r))||(o?bd(n,t):xd(n,t))}function kd(n,e,t,r){return yr(n,"handleDoubleClickOn",e,t,r)||n.someProp("handleDoubleClick",o=>o(n,e,r))}function wd(n,e,t,r){return yr(n,"handleTripleClickOn",e,t,r)||n.someProp("handleTripleClick",o=>o(n,e,r))||vd(n,t,r)}function vd(n,e,t){if(t.button!=0)return!1;let r=n.state.doc;if(e==-1)return r.inlineContent?(nt(n,D.create(r,0,r.content.size)),!0):!1;let o=r.resolve(e);for(let i=o.depth+1;i>0;i--){let s=i>o.depth?o.nodeAfter:o.node(i),a=o.before(i);if(s.inlineContent)nt(n,D.create(r,a+1,a+1+s.content.size));else if(M.isSelectable(s))nt(n,M.create(r,a));else continue;return!0}}function kr(n){return rn(n)}const fs=re?"metaKey":"ctrlKey";Z.mousedown=(n,e)=>{let t=e;n.input.shiftKey=t.shiftKey;let r=kr(n),o=Date.now(),i="singleClick";o-n.input.lastClick.time<500&&gd(t,n.input.lastClick)&&!t[fs]&&n.input.lastClick.button==t.button&&(n.input.lastClick.type=="singleClick"?i="doubleClick":n.input.lastClick.type=="doubleClick"&&(i="tripleClick")),n.input.lastClick={time:o,x:t.clientX,y:t.clientY,type:i,button:t.button};let s=n.posAtCoords(mn(t));s&&(i=="singleClick"?(n.input.mouseDown&&n.input.mouseDown.done(),n.input.mouseDown=new Cd(n,s,t,!!r)):(i=="doubleClick"?kd:wd)(n,s.pos,s.inside,t)?t.preventDefault():Ee(n,"pointer"))};class Cd{constructor(e,t,r,o){this.view=e,this.pos=t,this.event=r,this.flushed=o,this.delayedSelectionSync=!1,this.mightDrag=null,this.startDoc=e.state.doc,this.selectNode=!!r[fs],this.allowDefault=r.shiftKey;let i,s;if(t.inside>-1)i=e.state.doc.nodeAt(t.inside),s=t.inside;else{let d=e.state.doc.resolve(t.pos);i=d.parent,s=d.depth?d.before():0}const a=o?null:r.target,l=a?e.docView.nearestDesc(a,!0):null;this.target=l&&l.nodeDOM.nodeType==1?l.nodeDOM:null;let{selection:c}=e.state;r.button==0&&(i.type.spec.draggable&&i.type.spec.selectable!==!1||c instanceof M&&c.from<=s&&c.to>s)&&(this.mightDrag={node:i,pos:s,addAttr:!!(this.target&&!this.target.draggable),setUneditable:!!(this.target&&ae&&!this.target.hasAttribute("contentEditable"))}),this.target&&this.mightDrag&&(this.mightDrag.addAttr||this.mightDrag.setUneditable)&&(this.view.domObserver.stop(),this.mightDrag.addAttr&&(this.target.draggable=!0),this.mightDrag.setUneditable&&setTimeout(()=>{this.view.input.mouseDown==this&&this.target.setAttribute("contentEditable","false")},20),this.view.domObserver.start()),e.root.addEventListener("mouseup",this.up=this.up.bind(this)),e.root.addEventListener("mousemove",this.move=this.move.bind(this)),Ee(e,"pointer")}done(){this.view.root.removeEventListener("mouseup",this.up),this.view.root.removeEventListener("mousemove",this.move),this.mightDrag&&this.target&&(this.view.domObserver.stop(),this.mightDrag.addAttr&&this.target.removeAttribute("draggable"),this.mightDrag.setUneditable&&this.target.removeAttribute("contentEditable"),this.view.domObserver.start()),this.delayedSelectionSync&&setTimeout(()=>ve(this.view)),this.view.input.mouseDown=null}up(e){if(this.done(),!this.view.dom.contains(e.target))return;let t=this.pos;this.view.state.doc!=this.startDoc&&(t=this.view.posAtCoords(mn(e))),this.updateAllowDefault(e),this.allowDefault||!t?Ee(this.view,"pointer"):yd(this.view,t.pos,t.inside,e,this.selectNode)?e.preventDefault():e.button==0&&(this.flushed||j&&this.mightDrag&&!this.mightDrag.node.isAtom||J&&!this.view.state.selection.visible&&Math.min(Math.abs(t.pos-this.view.state.selection.from),Math.abs(t.pos-this.view.state.selection.to))<=2)?(nt(this.view,R.near(this.view.state.doc.resolve(t.pos))),e.preventDefault()):Ee(this.view,"pointer")}move(e){this.updateAllowDefault(e),Ee(this.view,"pointer"),e.buttons==0&&this.done()}updateAllowDefault(e){!this.allowDefault&&(Math.abs(this.event.x-e.clientX)>4||Math.abs(this.event.y-e.clientY)>4)&&(this.allowDefault=!0)}}Z.touchstart=n=>{n.input.lastTouch=Date.now(),kr(n),Ee(n,"pointer")};Z.touchmove=n=>{n.input.lastTouch=Date.now(),Ee(n,"pointer")};Z.contextmenu=n=>kr(n);function hs(n,e){return n.composing?!0:j&&Math.abs(e.timeStamp-n.input.compositionEndedAt)<500?(n.input.compositionEndedAt=-2e8,!0):!1}const Sd=we?5e3:-1;Y.compositionstart=Y.compositionupdate=n=>{if(!n.composing){n.domObserver.flush();let{state:e}=n,t=e.selection.$to;if(e.selection instanceof D&&(e.storedMarks||!t.textOffset&&t.parentOffset&&t.nodeBefore.marks.some(r=>r.type.spec.inclusive===!1)||J&&qi&&Md(n)))n.markCursor=n.state.storedMarks||t.marks(),rn(n,!0),n.markCursor=null;else if(rn(n,!e.selection.empty),ae&&e.selection.empty&&t.parentOffset&&!t.textOffset&&t.nodeBefore.marks.length){let r=n.domSelectionRange();for(let o=r.focusNode,i=r.focusOffset;o&&o.nodeType==1&&i!=0;){let s=i<0?o.lastChild:o.childNodes[i-1];if(!s)break;if(s.nodeType==3){let a=n.domSelection();a&&a.collapse(s,s.nodeValue.length);break}else o=s,i=-1}}n.input.composing=!0}ms(n,Sd)};function Md(n){let{focusNode:e,focusOffset:t}=n.domSelectionRange();if(!e||e.nodeType!=1||t>=e.childNodes.length)return!1;let r=e.childNodes[t];return r.nodeType==1&&r.contentEditable=="false"}Y.compositionend=(n,e)=>{n.composing&&(n.input.composing=!1,n.input.compositionEndedAt=e.timeStamp,n.input.compositionPendingChanges=n.domObserver.pendingRecords().length?n.input.compositionID:0,n.input.compositionNode=null,n.input.badSafariComposition?n.domObserver.forceFlush():n.input.compositionPendingChanges&&Promise.resolve().then(()=>n.domObserver.flush()),n.input.compositionID++,ms(n,20))};function ms(n,e){clearTimeout(n.input.composingTimeout),e>-1&&(n.input.composingTimeout=setTimeout(()=>rn(n),e))}function gs(n){for(n.composing&&(n.input.composing=!1,n.input.compositionEndedAt=Od());n.input.compositionNodes.length>0;)n.input.compositionNodes.pop().markParentsDirty()}function Nd(n){let e=n.domSelectionRange();if(!e.focusNode)return null;let t=bc(e.focusNode,e.focusOffset),r=yc(e.focusNode,e.focusOffset);if(t&&r&&t!=r){let o=r.pmViewDesc,i=n.domObserver.lastChangedTextNode;if(t==i||r==i)return i;if(!o||!o.isText(r.nodeValue))return r;if(n.input.compositionNode==r){let s=t.pmViewDesc;if(!(!s||!s.isText(t.nodeValue)))return r}}return t||r}function Od(){let n=document.createEvent("Event");return n.initEvent("event",!0,!0),n.timeStamp}function rn(n,e=!1){if(!(we&&n.domObserver.flushingSoon>=0)){if(n.domObserver.forceFlush(),gs(n),e||n.docView&&n.docView.dirty){let t=mr(n),r=n.state.selection;return t&&!t.eq(r)?n.dispatch(n.state.tr.setSelection(t)):(n.markCursor||e)&&!r.$from.node(r.$from.sharedDepth(r.to)).inlineContent?n.dispatch(n.state.tr.deleteSelection()):n.updateState(n.state),!0}return!1}}function Td(n,e){if(!n.dom.parentNode)return;let t=n.dom.parentNode.appendChild(document.createElement("div"));t.appendChild(e),t.style.cssText="position: fixed; left: -10000px; top: 10px";let r=getSelection(),o=document.createRange();o.selectNodeContents(e),n.dom.blur(),r.removeAllRanges(),r.addRange(o),setTimeout(()=>{t.parentNode&&t.parentNode.removeChild(t),n.focus()},50)}const Ot=G&&Ae<15||at&&Cc<604;Z.copy=Y.cut=(n,e)=>{let t=e,r=n.state.selection,o=t.type=="cut";if(r.empty)return;let i=Ot?null:t.clipboardData,s=r.content(),{dom:a,text:l}=xr(n,s);i?(t.preventDefault(),i.clearData(),i.setData("text/html",a.innerHTML),i.setData("text/plain",l)):Td(n,a),o&&n.dispatch(n.state.tr.deleteSelection().scrollIntoView().setMeta("uiEvent","cut"))};function Dd(n){return n.openStart==0&&n.openEnd==0&&n.content.childCount==1?n.content.firstChild:null}function Ed(n,e){if(!n.dom.parentNode)return;let t=n.input.shiftKey||n.state.selection.$from.parent.type.spec.code,r=n.dom.parentNode.appendChild(document.createElement(t?"textarea":"div"));t||(r.contentEditable="true"),r.style.cssText="position: fixed; left: -10000px; top: 10px",r.focus();let o=n.input.shiftKey&&n.input.lastKeyCode!=45;setTimeout(()=>{n.focus(),r.parentNode&&r.parentNode.removeChild(r),t?Tt(n,r.value,null,o,e):Tt(n,r.textContent,r.innerHTML,o,e)},50)}function Tt(n,e,t,r,o){let i=as(n,e,t,r,n.state.selection.$from);if(n.someProp("handlePaste",l=>l(n,o,i||w.empty)))return!0;if(!i)return!1;let s=Dd(i),a=s?n.state.tr.replaceSelectionWith(s,r):n.state.tr.replaceSelection(i);return n.dispatch(a.scrollIntoView().setMeta("paste",!0).setMeta("uiEvent","paste")),!0}function xs(n){let e=n.getData("text/plain")||n.getData("Text");if(e)return e;let t=n.getData("text/uri-list");return t?t.replace(/\r?\n/g," "):""}Y.paste=(n,e)=>{let t=e;if(n.composing&&!we)return;let r=Ot?null:t.clipboardData,o=n.input.shiftKey&&n.input.lastKeyCode!=45;r&&Tt(n,xs(r),r.getData("text/html"),o,t)?t.preventDefault():Ed(n,t)};class bs{constructor(e,t,r){this.slice=e,this.move=t,this.node=r}}const Ad=re?"altKey":"ctrlKey";function ys(n,e){let t;return n.someProp("dragCopies",r=>{t=t||r(e)}),t!=null?!t:!e[Ad]}Z.dragstart=(n,e)=>{let t=e,r=n.input.mouseDown;if(r&&r.done(),!t.dataTransfer)return;let o=n.state.selection,i=o.empty?null:n.posAtCoords(mn(t)),s;if(!(i&&i.pos>=o.from&&i.pos<=(o instanceof M?o.to-1:o.to))){if(r&&r.mightDrag)s=M.create(n.state.doc,r.mightDrag.pos);else if(t.target&&t.target.nodeType==1){let u=n.docView.nearestDesc(t.target,!0);u&&u.node.type.spec.draggable&&u!=n.docView&&(s=M.create(n.state.doc,u.posBefore))}}let a=(s||n.state.selection).content(),{dom:l,text:c,slice:d}=xr(n,a);(!t.dataTransfer.files.length||!J||Hi>120)&&t.dataTransfer.clearData(),t.dataTransfer.setData(Ot?"Text":"text/html",l.innerHTML),t.dataTransfer.effectAllowed="copyMove",Ot||t.dataTransfer.setData("text/plain",c),n.dragging=new bs(d,ys(n,t),s)};Z.dragend=n=>{let e=n.dragging;window.setTimeout(()=>{n.dragging==e&&(n.dragging=null)},50)};Y.dragover=Y.dragenter=(n,e)=>e.preventDefault();Y.drop=(n,e)=>{try{Rd(n,e,n.dragging)}finally{n.dragging=null}};function Rd(n,e,t){if(!e.dataTransfer)return;let r=n.posAtCoords(mn(e));if(!r)return;let o=n.state.doc.resolve(r.pos),i=t&&t.slice;i?n.someProp("transformPasted",h=>{i=h(i,n,!1)}):i=as(n,xs(e.dataTransfer),Ot?null:e.dataTransfer.getData("text/html"),!1,o);let s=!!(t&&ys(n,e));if(n.someProp("handleDrop",h=>h(n,e,i||w.empty,s))){e.preventDefault();return}if(!i)return;e.preventDefault();let a=i?Jl(n.state.doc,o.pos,i):o.pos;a==null&&(a=o.pos);let l=n.state.tr;if(s){let{node:h}=t;h?h.replace(l):l.deleteSelection()}let c=l.mapping.map(a),d=i.openStart==0&&i.openEnd==0&&i.content.childCount==1,u=l.doc;if(d?l.replaceRangeWith(c,c,i.content.firstChild):l.replaceRange(c,c,i),l.doc.eq(u))return;let p=l.doc.resolve(c);if(d&&M.isSelectable(i.content.firstChild)&&p.nodeAfter&&p.nodeAfter.sameMarkup(i.content.firstChild))l.setSelection(new M(p));else{let h=l.mapping.map(a);l.mapping.maps[l.mapping.maps.length-1].forEach((m,g,x,k)=>h=k),l.setSelection(gr(n,p,l.doc.resolve(h)))}n.focus(),n.dispatch(l.setMeta("uiEvent","drop"))}Z.focus=n=>{n.input.lastFocus=Date.now(),n.focused||(n.domObserver.stop(),n.dom.classList.add("ProseMirror-focused"),n.domObserver.start(),n.focused=!0,setTimeout(()=>{n.docView&&n.hasFocus()&&!n.domObserver.currentSelection.eq(n.domSelectionRange())&&ve(n)},20))};Z.blur=(n,e)=>{let t=e;n.focused&&(n.domObserver.stop(),n.dom.classList.remove("ProseMirror-focused"),n.domObserver.start(),t.relatedTarget&&n.dom.contains(t.relatedTarget)&&n.domObserver.currentSelection.clear(),n.focused=!1)};Z.beforeinput=(n,e)=>{if(J&&we&&e.inputType=="deleteContentBackward"){n.domObserver.flushSoon();let{domChangeCount:r}=n.input;setTimeout(()=>{if(n.input.domChangeCount!=r||(n.dom.blur(),n.focus(),n.someProp("handleKeyDown",i=>i(n,Pe(8,"Backspace")))))return;let{$cursor:o}=n.state.selection;o&&o.pos>0&&n.dispatch(n.state.tr.delete(o.pos-1,o.pos).scrollIntoView())},50)}};for(let n in Y)Z[n]=Y[n];function Dt(n,e){if(n==e)return!0;for(let t in n)if(n[t]!==e[t])return!1;for(let t in e)if(!(t in n))return!1;return!0}class on{constructor(e,t){this.toDOM=e,this.spec=t||We,this.side=this.spec.side||0}map(e,t,r,o){let{pos:i,deleted:s}=e.mapResult(t.from+o,this.side<0?-1:1);return s?null:new ie(i-r,i-r,this)}valid(){return!0}eq(e){return this==e||e instanceof on&&(this.spec.key&&this.spec.key==e.spec.key||this.toDOM==e.toDOM&&Dt(this.spec,e.spec))}destroy(e){this.spec.destroy&&this.spec.destroy(e)}}class Ie{constructor(e,t){this.attrs=e,this.spec=t||We}map(e,t,r,o){let i=e.map(t.from+o,this.spec.inclusiveStart?-1:1)-r,s=e.map(t.to+o,this.spec.inclusiveEnd?1:-1)-r;return i>=s?null:new ie(i,s,this)}valid(e,t){return t.from=e&&(!i||i(a.spec))&&r.push(a.copy(a.from+o,a.to+o))}for(let s=0;se){let a=this.children[s]+1;this.children[s+2].findInner(e-a,t-a,r,o+a,i)}}map(e,t,r){return this==U||e.maps.length==0?this:this.mapInner(e,t,0,0,r||We)}mapInner(e,t,r,o,i){let s;for(let a=0;a{let c=l+r,d;if(d=ws(t,a,c)){for(o||(o=this.children.slice());ia&&u.to=e){this.children[a]==e&&(r=this.children[a+2]);break}let i=e+1,s=i+t.content.size;for(let a=0;ai&&l.type instanceof Ie){let c=Math.max(i,l.from)-i,d=Math.min(s,l.to)-i;co.map(e,t,We));return Ne.from(r)}forChild(e,t){if(t.isLeaf)return z.empty;let r=[];for(let o=0;ot instanceof z)?e:e.reduce((t,r)=>t.concat(r instanceof z?r:r.members),[]))}}forEachSet(e){for(let t=0;t{let x=g-m-(h-p);for(let k=0;kT+d-u)continue;let N=a[k]+d-u;h>=N?a[k+1]=p<=N?-2:-1:p>=d&&x&&(a[k]+=x,a[k+1]+=x)}u+=x}),d=t.maps[c].map(d,-1)}let l=!1;for(let c=0;c=r.content.size){l=!0;continue}let p=t.map(n[c+1]+i,-1),h=p-o,{index:m,offset:g}=r.content.findIndex(u),x=r.maybeChild(m);if(x&&g==u&&g+x.nodeSize==h){let k=a[c+2].mapInner(t,x,d+1,n[c]+i+1,s);k!=U?(a[c]=u,a[c+1]=h,a[c+2]=k):(a[c+1]=-2,l=!0)}else l=!0}if(l){let c=Ld(a,n,e,t,o,i,s),d=sn(c,r,0,s);e=d.local;for(let u=0;ut&&s.to{let c=ws(n,a,l+t);if(c){i=!0;let d=sn(c,a,t+l+1,r);d!=U&&o.push(l,l+a.nodeSize,d)}});let s=ks(i?vs(n):n,-t).sort(He);for(let a=0;a0;)e++;n.splice(e,0,t)}function zn(n){let e=[];return n.someProp("decorations",t=>{let r=t(n.state);r&&r!=U&&e.push(r)}),n.cursorWrapper&&e.push(z.create(n.state.doc,[n.cursorWrapper.deco])),Ne.from(e)}const Pd={childList:!0,characterData:!0,characterDataOldValue:!0,attributes:!0,attributeOldValue:!0,subtree:!0},zd=G&&Ae<=11;class Fd{constructor(){this.anchorNode=null,this.anchorOffset=0,this.focusNode=null,this.focusOffset=0}set(e){this.anchorNode=e.anchorNode,this.anchorOffset=e.anchorOffset,this.focusNode=e.focusNode,this.focusOffset=e.focusOffset}clear(){this.anchorNode=this.focusNode=null}eq(e){return e.anchorNode==this.anchorNode&&e.anchorOffset==this.anchorOffset&&e.focusNode==this.focusNode&&e.focusOffset==this.focusOffset}}class Bd{constructor(e,t){this.view=e,this.handleDOMChange=t,this.queue=[],this.flushingSoon=-1,this.observer=null,this.currentSelection=new Fd,this.onCharData=null,this.suppressingSelectionUpdates=!1,this.lastChangedTextNode=null,this.observer=window.MutationObserver&&new window.MutationObserver(r=>{for(let o=0;oo.type=="childList"&&o.removedNodes.length||o.type=="characterData"&&o.oldValue.length>o.target.nodeValue.length)?this.flushSoon():j&&e.composing&&r.some(o=>o.type=="childList"&&o.target.nodeName=="TR")?(e.input.badSafariComposition=!0,this.flushSoon()):this.flush()}),zd&&(this.onCharData=r=>{this.queue.push({target:r.target,type:"characterData",oldValue:r.prevValue}),this.flushSoon()}),this.onSelectionChange=this.onSelectionChange.bind(this)}flushSoon(){this.flushingSoon<0&&(this.flushingSoon=window.setTimeout(()=>{this.flushingSoon=-1,this.flush()},20))}forceFlush(){this.flushingSoon>-1&&(window.clearTimeout(this.flushingSoon),this.flushingSoon=-1,this.flush())}start(){this.observer&&(this.observer.takeRecords(),this.observer.observe(this.view.dom,Pd)),this.onCharData&&this.view.dom.addEventListener("DOMCharacterDataModified",this.onCharData),this.connectSelection()}stop(){if(this.observer){let e=this.observer.takeRecords();if(e.length){for(let t=0;tthis.flush(),20)}this.observer.disconnect()}this.onCharData&&this.view.dom.removeEventListener("DOMCharacterDataModified",this.onCharData),this.disconnectSelection()}connectSelection(){this.view.dom.ownerDocument.addEventListener("selectionchange",this.onSelectionChange)}disconnectSelection(){this.view.dom.ownerDocument.removeEventListener("selectionchange",this.onSelectionChange)}suppressSelectionUpdates(){this.suppressingSelectionUpdates=!0,setTimeout(()=>this.suppressingSelectionUpdates=!1,50)}onSelectionChange(){if(vo(this.view)){if(this.suppressingSelectionUpdates)return ve(this.view);if(G&&Ae<=11&&!this.view.state.selection.empty){let e=this.view.domSelectionRange();if(e.focusNode&&je(e.focusNode,e.focusOffset,e.anchorNode,e.anchorOffset))return this.flushSoon()}this.flush()}}setCurSelection(){this.currentSelection.set(this.view.domSelectionRange())}ignoreSelectionChange(e){if(!e.focusNode)return!0;let t=new Set,r;for(let i=e.focusNode;i;i=st(i))t.add(i);for(let i=e.anchorNode;i;i=st(i))if(t.has(i)){r=i;break}let o=r&&this.view.docView.nearestDesc(r);if(o&&o.ignoreMutation({type:"selection",target:r.nodeType==3?r.parentNode:r}))return this.setCurSelection(),!0}pendingRecords(){if(this.observer)for(let e of this.observer.takeRecords())this.queue.push(e);return this.queue}flush(){let{view:e}=this;if(!e.docView||this.flushingSoon>-1)return;let t=this.pendingRecords();t.length&&(this.queue=[]);let r=e.domSelectionRange(),o=!this.suppressingSelectionUpdates&&!this.currentSelection.eq(r)&&vo(e)&&!this.ignoreSelectionChange(r),i=-1,s=-1,a=!1,l=[];if(e.editable)for(let d=0;dd.nodeName=="BR")&&(e.input.lastKeyCode==8||e.input.lastKeyCode==46)){for(let d of l)if(d.nodeName=="BR"&&d.parentNode){let u=d.nextSibling;for(;u&&u.nodeType==1;){if(u.contentEditable=="false"){d.parentNode.removeChild(d);break}u=u.firstChild}}}else if(ae&&l.length){let d=l.filter(u=>u.nodeName=="BR");if(d.length==2){let[u,p]=d;u.parentNode&&u.parentNode.parentNode==p.parentNode?p.remove():u.remove()}else{let{focusNode:u}=this.currentSelection;for(let p of d){let h=p.parentNode;h&&h.nodeName=="LI"&&(!u||$d(e,u)!=h)&&p.remove()}}}let c=null;i<0&&o&&e.input.lastFocus>Date.now()-200&&Math.max(e.input.lastTouch,e.input.lastClick.time)-1||o)&&(i>-1&&(e.docView.markDirty(i,s),Vd(e)),e.input.badSafariComposition&&(e.input.badSafariComposition=!1,Wd(e,l)),this.handleDOMChange(i,s,a,l),e.docView&&e.docView.dirty?e.updateState(e.state):this.currentSelection.eq(r)||ve(e),this.currentSelection.set(r))}registerMutation(e,t){if(t.indexOf(e.target)>-1)return null;let r=this.view.docView.nearestDesc(e.target);if(e.type=="attributes"&&(r==this.view.docView||e.attributeName=="contenteditable"||e.attributeName=="style"&&!e.oldValue&&!e.target.getAttribute("style"))||!r||r.ignoreMutation(e))return null;if(e.type=="childList"){for(let d=0;do;x--){let k=r.childNodes[x-1],T=k.pmViewDesc;if(k.nodeName=="BR"&&!T){i=x;break}if(!T||T.size)break}let u=n.state.doc,p=n.someProp("domParser")||St.fromSchema(n.state.schema),h=u.resolve(s),m=null,g=p.parse(r,{topNode:h.parent,topMatch:h.parent.contentMatchAt(h.index()),topOpen:!0,from:o,to:i,preserveWhitespace:h.parent.type.whitespace=="pre"?"full":!0,findPositions:c,ruleFromNode:qd,context:h});if(c&&c[0].pos!=null){let x=c[0].pos,k=c[1]&&c[1].pos;k==null&&(k=x),m={anchor:x+s,head:k+s}}return{doc:g,sel:m,from:s,to:a}}function qd(n){let e=n.pmViewDesc;if(e)return e.parseRule();if(n.nodeName=="BR"&&n.parentNode){if(j&&/^(ul|ol)$/i.test(n.parentNode.nodeName)){let t=document.createElement("div");return t.appendChild(document.createElement("li")),{skip:t}}else if(n.parentNode.lastChild==n||j&&/^(tr|table)$/i.test(n.parentNode.nodeName))return{ignore:!0}}else if(n.nodeName=="IMG"&&n.getAttribute("mark-placeholder"))return{ignore:!0};return null}const Jd=/^(a|abbr|acronym|b|bd[io]|big|br|button|cite|code|data(list)?|del|dfn|em|i|img|ins|kbd|label|map|mark|meter|output|q|ruby|s|samp|small|span|strong|su[bp]|time|u|tt|var)$/i;function Ud(n,e,t,r,o){let i=n.input.compositionPendingChanges||(n.composing?n.input.compositionID:0);if(n.input.compositionPendingChanges=0,e<0){let S=n.input.lastSelectionTime>Date.now()-50?n.input.lastSelectionOrigin:null,A=mr(n,S);if(A&&!n.state.selection.eq(A)){if(J&&we&&n.input.lastKeyCode===13&&Date.now()-100O(n,Pe(13,"Enter"))))return;let C=n.state.tr.setSelection(A);S=="pointer"?C.setMeta("pointer",!0):S=="key"&&C.scrollIntoView(),i&&C.setMeta("composition",i),n.dispatch(C)}return}let s=n.state.doc.resolve(e),a=s.sharedDepth(t);e=s.before(a+1),t=n.state.doc.resolve(t).after(a+1);let l=n.state.selection,c=Hd(n,e,t),d=n.state.doc,u=d.slice(c.from,c.to),p,h;n.input.lastKeyCode===8&&Date.now()-100Date.now()-225||we)&&o.some(S=>S.nodeType==1&&!Jd.test(S.nodeName))&&(!m||m.endA>=m.endB)&&n.someProp("handleKeyDown",S=>S(n,Pe(13,"Enter")))){n.input.lastIOSEnter=0;return}if(!m)if(r&&l instanceof D&&!l.empty&&l.$head.sameParent(l.$anchor)&&!n.composing&&!(c.sel&&c.sel.anchor!=c.sel.head))m={start:l.from,endA:l.to,endB:l.to};else{if(c.sel){let S=Lo(n,n.state.doc,c.sel);if(S&&!S.eq(n.state.selection)){let A=n.state.tr.setSelection(S);i&&A.setMeta("composition",i),n.dispatch(A)}}return}n.state.selection.fromn.state.selection.from&&m.start<=n.state.selection.from+2&&n.state.selection.from>=c.from?m.start=n.state.selection.from:m.endA=n.state.selection.to-2&&n.state.selection.to<=c.to&&(m.endB+=n.state.selection.to-m.endA,m.endA=n.state.selection.to)),G&&Ae<=11&&m.endB==m.start+1&&m.endA==m.start&&m.start>c.from&&c.doc.textBetween(m.start-c.from-1,m.start-c.from+1)=="  "&&(m.start--,m.endA--,m.endB--);let g=c.doc.resolveNoCache(m.start-c.from),x=c.doc.resolveNoCache(m.endB-c.from),k=d.resolve(m.start),T=g.sameParent(x)&&g.parent.inlineContent&&k.end()>=m.endA;if((at&&n.input.lastIOSEnter>Date.now()-225&&(!T||o.some(S=>S.nodeName=="DIV"||S.nodeName=="P"))||!T&&g.posS(n,Pe(13,"Enter")))){n.input.lastIOSEnter=0;return}if(n.state.selection.anchor>m.start&&Kd(d,m.start,m.endA,g,x)&&n.someProp("handleKeyDown",S=>S(n,Pe(8,"Backspace")))){we&&J&&n.domObserver.suppressSelectionUpdates();return}J&&m.endB==m.start&&(n.input.lastChromeDelete=Date.now()),we&&!T&&g.start()!=x.start()&&x.parentOffset==0&&g.depth==x.depth&&c.sel&&c.sel.anchor==c.sel.head&&c.sel.head==m.endA&&(m.endB-=2,x=c.doc.resolveNoCache(m.endB-c.from),setTimeout(()=>{n.someProp("handleKeyDown",function(S){return S(n,Pe(13,"Enter"))})},20));let N=m.start,F=m.endA,X=S=>{let A=S||n.state.tr.replace(N,F,c.doc.slice(m.start-c.from,m.endB-c.from));if(c.sel){let C=Lo(n,A.doc,c.sel);C&&!(J&&n.composing&&C.empty&&(m.start!=m.endB||n.input.lastChromeDeleteve(n),20));let S=X(n.state.tr.delete(N,F)),A=d.resolve(m.start).marksAcross(d.resolve(m.endA));A&&S.ensureMarks(A),n.dispatch(S)}else if(m.endA==m.endB&&($=jd(g.parent.content.cut(g.parentOffset,x.parentOffset),k.parent.content.cut(k.parentOffset,m.endA-k.start())))){let S=X(n.state.tr);$.type=="add"?S.addMark(N,F,$.mark):S.removeMark(N,F,$.mark),n.dispatch(S)}else if(g.parent.child(g.index()).isText&&g.index()==x.index()-(x.textOffset?0:1)){let S=g.parent.textBetween(g.parentOffset,x.parentOffset),A=()=>X(n.state.tr.insertText(S,N,F));n.someProp("handleTextInput",C=>C(n,N,F,S,A))||n.dispatch(A())}else n.dispatch(X());else n.dispatch(X())}function Lo(n,e,t){return Math.max(t.anchor,t.head)>e.content.size?null:gr(n,e.resolve(t.anchor),e.resolve(t.head))}function jd(n,e){let t=n.firstChild.marks,r=e.firstChild.marks,o=t,i=r,s,a,l;for(let d=0;dd.mark(a.addToSet(d.marks));else if(o.length==0&&i.length==1)a=i[0],s="remove",l=d=>d.mark(a.removeFromSet(d.marks));else return null;let c=[];for(let d=0;dt||Fn(s,!0,!1)0&&(e||n.indexAfter(r)==n.node(r).childCount);)r--,o++,e=!1;if(t){let i=n.node(r).maybeChild(n.indexAfter(r));for(;i&&!i.isLeaf;)i=i.firstChild,o++}return o}function Zd(n,e,t,r,o){let i=n.findDiffStart(e,t);if(i==null)return null;let{a:s,b:a}=n.findDiffEnd(e,t+n.size,t+e.size);if(o=="end"){let l=Math.max(0,i-Math.min(s,a));r-=s+l-i}if(s=s?i-r:0;i-=l,i&&i=a?i-r:0;i-=l,i&&i=56320&&e<=57343&&t>=55296&&t<=56319}class Cs{constructor(e,t){this._root=null,this.focused=!1,this.trackWrites=null,this.mounted=!1,this.markCursor=null,this.cursorWrapper=null,this.lastSelectedViewDesc=void 0,this.input=new ud,this.prevDirectPlugins=[],this.pluginViews=[],this.requiresGeckoHackNode=!1,this.dragging=null,this._props=t,this.state=t.state,this.directPlugins=t.plugins||[],this.directPlugins.forEach(_o),this.dispatch=this.dispatch.bind(this),this.dom=e&&e.mount||document.createElement("div"),e&&(e.appendChild?e.appendChild(this.dom):typeof e=="function"?e(this.dom):e.mount&&(this.mounted=!0)),this.editable=Bo(this),Fo(this),this.nodeViews=Vo(this),this.docView=go(this.state.doc,zo(this),zn(this),this.dom,this),this.domObserver=new Bd(this,(r,o,i,s)=>Ud(this,r,o,i,s)),this.domObserver.start(),pd(this),this.updatePluginViews()}get composing(){return this.input.composing}get props(){if(this._props.state!=this.state){let e=this._props;this._props={};for(let t in e)this._props[t]=e[t];this._props.state=this.state}return this._props}update(e){e.handleDOMEvents!=this._props.handleDOMEvents&&or(this);let t=this._props;this._props=e,e.plugins&&(e.plugins.forEach(_o),this.directPlugins=e.plugins),this.updateStateInner(e.state,t)}setProps(e){let t={};for(let r in this._props)t[r]=this._props[r];t.state=this.state;for(let r in e)t[r]=e[r];this.update(t)}updateState(e){this.updateStateInner(e,this._props)}updateStateInner(e,t){var r;let o=this.state,i=!1,s=!1;e.storedMarks&&this.composing&&(gs(this),s=!0),this.state=e;let a=o.plugins!=e.plugins||this._props.plugins!=t.plugins;if(a||this._props.plugins!=t.plugins||this._props.nodeViews!=t.nodeViews){let h=Vo(this);Gd(h,this.nodeViews)&&(this.nodeViews=h,i=!0)}(a||t.handleDOMEvents!=this._props.handleDOMEvents)&&or(this),this.editable=Bo(this),Fo(this);let l=zn(this),c=zo(this),d=o.plugins!=e.plugins&&!o.doc.eq(e.doc)?"reset":e.scrollToSelection>o.scrollToSelection?"to selection":"preserve",u=i||!this.docView.matchesNode(e.doc,c,l);(u||!e.selection.eq(o.selection))&&(s=!0);let p=d=="preserve"&&s&&this.dom.style.overflowAnchor==null&&Nc(this);if(s){this.domObserver.stop();let h=u&&(G||J)&&!this.composing&&!o.selection.empty&&!e.selection.empty&&Yd(o.selection,e.selection);if(u){let m=J?this.trackWrites=this.domSelectionRange().focusNode:null;this.composing&&(this.input.compositionNode=Nd(this)),(i||!this.docView.update(e.doc,c,l,this))&&(this.docView.updateOuterDeco(c),this.docView.destroy(),this.docView=go(e.doc,c,l,this.dom,this)),m&&(!this.trackWrites||!this.dom.contains(this.trackWrites))&&(h=!0)}h||!(this.input.mouseDown&&this.domObserver.currentSelection.eq(this.domSelectionRange())&&Yc(this))?ve(this,h):(rs(this,e.selection),this.domObserver.setCurSelection()),this.domObserver.start()}this.updatePluginViews(o),!((r=this.dragging)===null||r===void 0)&&r.node&&!o.doc.eq(e.doc)&&this.updateDraggedNode(this.dragging,o),d=="reset"?this.dom.scrollTop=0:d=="to selection"?this.scrollToSelection():p&&Oc(p)}scrollToSelection(){let e=this.domSelectionRange().focusNode;if(!(!e||!this.dom.contains(e.nodeType==1?e:e.parentNode))){if(!this.someProp("handleScrollToSelection",t=>t(this)))if(this.state.selection instanceof M){let t=this.docView.domAfterPos(this.state.selection.from);t.nodeType==1&&co(this,t.getBoundingClientRect(),e)}else co(this,this.coordsAtPos(this.state.selection.head,1),e)}}destroyPluginViews(){let e;for(;e=this.pluginViews.pop();)e.destroy&&e.destroy()}updatePluginViews(e){if(!e||e.plugins!=this.state.plugins||this.directPlugins!=this.prevDirectPlugins){this.prevDirectPlugins=this.directPlugins,this.destroyPluginViews();for(let t=0;t0&&it.ownerDocument.getSelection()),this._root=t}return e||document}updateRoot(){this._root=null}posAtCoords(e){return Ic(this,e)}coordsAtPos(e,t=1){return Zi(this,e,t)}domAtPos(e,t=0){return this.docView.domFromPos(e,t)}nodeDOM(e){let t=this.docView.descAt(e);return t?t.nodeDOM:null}posAtDOM(e,t,r=-1){let o=this.docView.posFromDOM(e,t,r);if(o==null)throw new RangeError("DOM position not inside the editor");return o}endOfTextblock(e,t){return Bc(this,t||this.state,e)}pasteHTML(e,t){return Tt(this,"",e,!1,t||new ClipboardEvent("paste"))}pasteText(e,t){return Tt(this,e,null,!0,t||new ClipboardEvent("paste"))}serializeForClipboard(e){return xr(this,e)}destroy(){this.docView&&(fd(this),this.destroyPluginViews(),this.mounted?(this.docView.update(this.state.doc,[],zn(this),this),this.dom.textContent=""):this.dom.parentNode&&this.dom.parentNode.removeChild(this.dom),this.docView.destroy(),this.docView=null,gc())}get isDestroyed(){return this.docView==null}dispatchEvent(e){return md(this,e)}domSelectionRange(){let e=this.domSelection();return e?j&&this.root.nodeType===11&&wc(this.dom.ownerDocument)==this.dom&&_d(this,e)||e:{focusNode:null,focusOffset:0,anchorNode:null,anchorOffset:0}}domSelection(){return this.root.getSelection()}}Cs.prototype.dispatch=function(n){let e=this._props.dispatchTransaction;e?e.call(this,n):this.updateState(this.state.apply(n))};function zo(n){let e=Object.create(null);return e.class="ProseMirror",e.contenteditable=String(n.editable),n.someProp("attributes",t=>{if(typeof t=="function"&&(t=t(n.state)),t)for(let r in t)r=="class"?e.class+=" "+t[r]:r=="style"?e.style=(e.style?e.style+";":"")+t[r]:!e[r]&&r!="contenteditable"&&r!="nodeName"&&(e[r]=String(t[r]))}),e.translate||(e.translate="no"),[ie.node(0,n.state.doc.content.size,e)]}function Fo(n){if(n.markCursor){let e=document.createElement("img");e.className="ProseMirror-separator",e.setAttribute("mark-placeholder","true"),e.setAttribute("alt",""),n.cursorWrapper={dom:e,deco:ie.widget(n.state.selection.from,e,{raw:!0,marks:n.markCursor})}}else n.cursorWrapper=null}function Bo(n){return!n.someProp("editable",e=>e(n.state)===!1)}function Yd(n,e){let t=Math.min(n.$anchor.sharedDepth(n.head),e.$anchor.sharedDepth(e.head));return n.$anchor.start(t)!=e.$anchor.start(t)}function Vo(n){let e=Object.create(null);function t(r){for(let o in r)Object.prototype.hasOwnProperty.call(e,o)||(e[o]=r[o])}return n.someProp("nodeViews",t),n.someProp("markViews",t),e}function Gd(n,e){let t=0,r=0;for(let o in n){if(n[o]!=e[o])return!0;t++}for(let o in e)r++;return t!=r}function _o(n){if(n.spec.state||n.spec.filterTransaction||n.spec.appendTransaction)throw new RangeError("Plugins passed directly to the view must not have a state component")}function Xd(n,e=!1){return n.key==="Enter"&&(n.isComposing===!0||e||n.keyCode===229)}function Qd(n){return n.key==="Tab"&&n.shiftKey===!0&&!n.altKey&&!n.ctrlKey&&!n.metaKey}const Bn=new vl({nodes:{doc:{content:"paragraph+"},paragraph:{content:"inline*",group:"block",parseDOM:[{tag:"p"}],toDOM:()=>["p",0]},text:{group:"inline"},atMention:{attrs:{label:{validate:"string"},path:{validate:"string"},fsPath:{validate:"string",default:""}},inline:!0,group:"inline",draggable:!1,selectable:!1,toDOM:n=>Vn({text:n.attrs.label,iconNode:du(),dataAttributes:{"at-mention-label":n.attrs.label,"at-mention-path":n.attrs.path,"at-mention-fs-path":n.attrs.fsPath}}),parseDOM:[{tag:"span[at-mention-label][at-mention-path]",getAttrs:n=>{const e=n;return{label:e.getAttribute("at-mention-label"),path:e.getAttribute("at-mention-path"),fsPath:e.getAttribute("at-mention-fs-path")??""}}}]},agentMention:{attrs:{name:{validate:"string"},displayName:{validate:"string",default:""},path:{validate:"string"}},inline:!0,group:"inline",draggable:!1,selectable:!1,toDOM:n=>{const e=n.attrs.displayName||n.attrs.name;return Vn({text:`@${e}`,dataAttributes:{"agent-mention-name":n.attrs.name,"agent-mention-display-name":e,"agent-mention-path":n.attrs.path}})},parseDOM:[{tag:"span[agent-mention-name][agent-mention-path]",getAttrs:n=>{const e=n,t=e.getAttribute("agent-mention-name")??"";return{name:t,displayName:e.getAttribute("agent-mention-display-name")??t,path:e.getAttribute("agent-mention-path")??""}}}]},skillMention:{attrs:{name:{validate:"string"},displayName:{validate:"string",default:""},path:{validate:"string"},description:{validate:"string",default:""}},inline:!0,group:"inline",draggable:!1,selectable:!1,toDOM:n=>{const e=n.attrs.displayName||n.attrs.name;return Vn({text:e,icon:"$",dataAttributes:{"skill-mention-name":n.attrs.name,"skill-mention-display-name":e,"skill-mention-path":n.attrs.path},title:n.attrs.description})},parseDOM:[{tag:"span[skill-mention-name][skill-mention-path]",getAttrs:n=>{const e=n,t=e.getAttribute("skill-mention-name")??"";return{name:t,displayName:e.getAttribute("skill-mention-display-name")??t,path:e.getAttribute("skill-mention-path")??"",description:e.getAttribute("title")??""}}}]}},marks:{}}),Ut=new oc("agentPromptPlaceholder"),eu=rt.forwardRef(function({ariaLabel:e,className:t,minHeight:r="2.75rem",onAutocompleteChange:o,onAutocompleteKeyDown:i,onPlanModeShortcut:s,onSubmit:a,onTextChange:l,onTriggerToken:c,placeholder:d,singleLine:u=!1,value:p},h){const m=b.useRef(null),g=b.useRef(null),x=b.useRef(a),k=b.useRef(o),T=b.useRef(i),N=b.useRef(s),F=b.useRef(l),X=b.useRef(c),$=b.useRef(p),S=b.useRef(u);x.current=a,k.current=o,T.current=i,N.current=s,F.current=l,X.current=c,S.current=u,b.useImperativeHandle(h,()=>({focus(){g.current?.focus()},getText(){return $.current},insertMention(C){const O=g.current;O&&au(O,C)},insertMentions(C){const O=g.current;!O||C.length===0||lu(O,C)},insertText(C){const O=g.current;O&&su(O,C)}}),[]),b.useLayoutEffect(()=>{const C=m.current;if(!C)return;const O=new Cs(C,{state:et.create({doc:Ss($.current),plugins:[nu("")]}),attributes:{"aria-label":"","data-codex-composer":"true","data-virtualkeyboard":"true",role:"textbox",class:"ProseMirror prompt-editor-view",style:"min-height: 2.75rem; font-size: var(--codex-chat-font-size); height: auto; resize: none;"},dispatchTransaction(L){const I=O.state.apply(L);O.updateState(I);const B=ru(I.doc),dt=$.current;if(B!==dt){$.current=B;const pe=tu(dt,B);pe&&X.current?.(pe),F.current(B)}k.current?.(Ns(O))},handleKeyDown(L,I){return Xd(I,L.composing)?!1:Qd(I)&&N.current?(I.preventDefault(),N.current(),!0):cu(I.key)&&T.current?.(I.key)?(I.preventDefault(),!0):(I.metaKey||I.ctrlKey)&&I.key==="Enter"?(I.preventDefault(),x.current(),!0):I.key!=="Enter"?!1:I.shiftKey||I.altKey?(I.preventDefault(),S.current?!0:_i(L.state,L.dispatch,L)):(I.preventDefault(),x.current(),!0)}});return g.current=O,()=>{O.destroy(),g.current=null}},[]),b.useLayoutEffect(()=>{const C=g.current;C&&C.dispatch(C.state.tr.setMeta(Ut,d))},[d]),b.useLayoutEffect(()=>{const C=g.current;if(!C)return;const O=e??d;O?C.dom.setAttribute("aria-label",O):C.dom.removeAttribute("aria-label")},[e,d]),b.useLayoutEffect(()=>{const C=g.current;C&&(C.dom.style.minHeight=r)},[r]),b.useLayoutEffect(()=>{const C=g.current;!C||p===$.current||($.current=p,iu(C,p))},[p]);const A=["text-size-chat","[&_.ProseMirror]:focus-visible:outline-none","text-token-foreground",u?"flex h-9 max-h-none items-center overflow-hidden [&_.ProseMirror]:!h-5 [&_.ProseMirror]:!min-h-5 [&_.ProseMirror]:min-w-0 [&_.ProseMirror]:flex-1 [&_.ProseMirror]:overflow-hidden [&_.ProseMirror]:whitespace-nowrap [&_.ProseMirror_p]:overflow-hidden [&_.ProseMirror_p]:text-ellipsis [&_.ProseMirror_p]:whitespace-nowrap":"h-auto max-h-[25dvh] overflow-y-auto [&_.ProseMirror]:h-auto [&_.ProseMirror]:min-h-[2rem]","[&_.ProseMirror]:resize-none","[&_.ProseMirror_p]:m-0",t].filter(Boolean).join(" ");return rt.createElement("div",{className:A,onMouseDown:C=>{const O=g.current;O&&C.target instanceof Node&&!O.dom.contains(C.target)&&(C.preventDefault(),O.focus())},ref:m})});function tu(n,e){if(e.length!==n.length+1)return null;let t=0;for(;tn,apply(e,t){return e.getMeta(Ut)??t}},props:{decorations(e){const t=Ut.getState(e)??"";if(!t||e.doc.childCount!==1)return null;const r=e.doc.firstChild;return!r?.isTextblock||r.content.size!==0?null:z.create(e.doc,[ie.node(0,r.nodeSize,{class:"placeholder","data-placeholder":t})])}}})}function Ss(n){const e=n.split(` -`);return Bn.nodes.doc.create(null,e.map(t=>Bn.nodes.paragraph.create(null,t.length>0?Bn.text(t):null)))}function ru(n){const e=[];return n.forEach(t=>{const r=[];t.forEach(o=>{r.push(ou(o))}),e.push(r.join(""))}),e.join(` -`)}function ou(n){switch(n.type.name){case"atMention":return $t({kind:"at",label:n.attrs.label,name:n.attrs.label,path:n.attrs.path});case"agentMention":return $t({kind:"agent",displayName:n.attrs.displayName||n.attrs.name,name:n.attrs.name,path:n.attrs.path});case"skillMention":return $t({kind:"skill",displayName:n.attrs.displayName||n.attrs.name,name:n.attrs.name,path:n.attrs.path});default:return n.textContent}}function iu(n,e){const t=Ss(e),r=n.state.tr.replaceWith(0,n.state.doc.content.size,t.content);r.setSelection(D.atEnd(r.doc)),n.dispatch(r)}function su(n,e){const{state:t}=n,{from:r,to:o}=t.selection,i=e.startsWith("@")?"@":e.startsWith("$")?"$":null,s=t.doc.textBetween(Math.max(0,r-1),r,` +`);return{dom:l,text:p,slice:e}}function ds(n,e,t,r,o){let i=o.parent.type.spec.code,s,a;if(!t&&!e)return null;let l=!!e&&(r||i||!t);if(l){if(n.someProp("transformPastedText",p=>{e=p(e,i||r,n)}),i)return a=new w(y.from(n.state.schema.text(e.replace(/\r\n?/g,` +`))),0,0),n.someProp("transformPasted",p=>{a=p(a,n,!0)}),a;let u=n.someProp("clipboardTextParser",p=>p(e,o,r,n));if(u)a=u;else{let p=o.marks(),{schema:h}=n.state,m=ut.fromSchema(h);s=document.createElement("div"),e.split(/(?:\r\n?|\n)+/).forEach(g=>{let x=s.appendChild(document.createElement("p"));g&&x.appendChild(m.serializeNode(h.text(g,p)))})}}else n.someProp("transformPastedHTML",u=>{t=u(t,n)}),s=pd(t),zt&&fd(s);let c=s&&s.querySelector("[data-pm-slice]"),d=c&&/^(\d+) (\d+)(?: -(\d+))? (.*)/.exec(c.getAttribute("data-pm-slice")||"");if(d&&d[3])for(let u=+d[3];u>0;u--){let p=s.firstChild;for(;p&&p.nodeType!=1;)p=p.nextSibling;if(!p)break;s=p}if(a||(a=(n.someProp("clipboardParser")||n.someProp("domParser")||Ot.fromSchema(n.state.schema)).parseSlice(s,{preserveWhitespace:!!(l||d),context:o,ruleFromNode(p){return p.nodeName=="BR"&&!p.nextSibling&&p.parentNode&&!cd.test(p.parentNode.nodeName)?{ignore:!0}:null}})),d)a=hd(Eo(a,+d[1],+d[2]),d[4]);else if(a=w.maxOpen(dd(a.content,o),!0),a.openStart||a.openEnd){let u=0,p=0;for(let h=a.content.firstChild;u{a=u(a,n,l)}),a}const cd=/^(a|abbr|acronym|b|cite|code|del|em|i|ins|kbd|label|output|q|ruby|s|samp|span|strong|sub|sup|time|u|tt|var)$/i;function dd(n,e){if(n.childCount<2)return n;for(let t=e.depth;t>=0;t--){let o=e.node(t).contentMatchAt(e.index(t)),i,s=[];if(n.forEach(a=>{if(!s)return;let l=o.findWrapping(a.type),c;if(!l)return s=null;if(c=s.length&&i.length&&ps(l,i,a,s[s.length-1],0))s[s.length-1]=c;else{s.length&&(s[s.length-1]=fs(s[s.length-1],i.length));let d=us(a,l);s.push(d),o=o.matchType(d.type),i=l}}),s)return y.from(s)}return n}function us(n,e,t=0){for(let r=e.length-1;r>=t;r--)n=e[r].create(null,y.from(n));return n}function ps(n,e,t,r,o){if(o1&&(i=0),o=t&&(a=e<0?s.contentMatchAt(0).fillBefore(a,i<=o).append(a):a.append(s.contentMatchAt(s.childCount).fillBefore(y.empty,!0))),n.replaceChild(e<0?0:n.childCount-1,s.copy(a))}function Eo(n,e,t){return et})),Fn.createHTML(n)):n}function pd(n){let e=/^(\s*]*>)*/.exec(n);e&&(n=n.slice(e[0].length));let t=ms().createElement("div"),r=/<([a-z][^>\s]+)/i.exec(n),o;if((o=r&&hs[r[1].toLowerCase()])&&(n=o.map(i=>"<"+i+">").join("")+n+o.map(i=>"").reverse().join("")),t.innerHTML=ud(n),o)for(let i=0;i=0;a-=2){let l=t.nodes[r[a]];if(!l||l.hasRequiredAttrs())break;o=y.from(l.create(r[a+1],o)),i++,s++}return new w(o,i,s)}const Z={},Y={},md={touchstart:!0,touchmove:!0};class gd{constructor(){this.shiftKey=!1,this.mouseDown=null,this.lastKeyCode=null,this.lastKeyCodeTime=0,this.lastClick={time:0,x:0,y:0,type:"",button:0},this.lastSelectionOrigin=null,this.lastSelectionTime=0,this.lastIOSEnter=0,this.lastIOSEnterFallbackTimeout=-1,this.lastFocus=0,this.lastTouch=0,this.lastChromeDelete=0,this.composing=!1,this.compositionNode=null,this.composingTimeout=-1,this.compositionNodes=[],this.compositionEndedAt=-2e8,this.compositionID=1,this.badSafariComposition=!1,this.compositionPendingChanges=0,this.domChangeCount=0,this.eventHandlers=Object.create(null),this.hideSelectionGuard=null}}function xd(n){for(let e in Z){let t=Z[e];n.dom.addEventListener(e,n.input.eventHandlers[e]=r=>{yd(n,r)&&!yr(n,r)&&(n.editable||!(r.type in Y))&&t(n,r)},md[e]?{passive:!0}:void 0)}j&&n.dom.addEventListener("input",()=>null),or(n)}function Ee(n,e){n.input.lastSelectionOrigin=e,n.input.lastSelectionTime=Date.now()}function bd(n){n.domObserver.stop();for(let e in n.input.eventHandlers)n.dom.removeEventListener(e,n.input.eventHandlers[e]);clearTimeout(n.input.composingTimeout),clearTimeout(n.input.lastIOSEnterFallbackTimeout)}function or(n){n.someProp("handleDOMEvents",e=>{for(let t in e)n.input.eventHandlers[t]||n.dom.addEventListener(t,n.input.eventHandlers[t]=r=>yr(n,r))})}function yr(n,e){return n.someProp("handleDOMEvents",t=>{let r=t[e.type];return r?r(n,e)||e.defaultPrevented:!1})}function yd(n,e){if(!e.bubbles)return!0;if(e.defaultPrevented)return!1;for(let t=e.target;t!=n.dom;t=t.parentNode)if(!t||t.nodeType==11||t.pmViewDesc&&t.pmViewDesc.stopEvent(e))return!1;return!0}function kd(n,e){!yr(n,e)&&Z[e.type]&&(n.editable||!(e.type in Y))&&Z[e.type](n,e)}Y.keydown=(n,e)=>{let t=e;if(n.input.shiftKey=t.keyCode==16||t.shiftKey,!xs(n,t)&&(n.input.lastKeyCode=t.keyCode,n.input.lastKeyCodeTime=Date.now(),!(we&&J&&t.keyCode==13)))if(t.keyCode!=229&&n.domObserver.forceFlush(),ct&&t.keyCode==13&&!t.ctrlKey&&!t.altKey&&!t.metaKey){let r=Date.now();n.input.lastIOSEnter=r,n.input.lastIOSEnterFallbackTimeout=setTimeout(()=>{n.input.lastIOSEnter==r&&(n.someProp("handleKeyDown",o=>o(n,Pe(13,"Enter"))),n.input.lastIOSEnter=0)},200)}else n.someProp("handleKeyDown",r=>r(n,t))||ld(n,t)?t.preventDefault():Ee(n,"key")};Y.keyup=(n,e)=>{e.keyCode==16&&(n.input.shiftKey=!1)};Y.keypress=(n,e)=>{let t=e;if(xs(n,t)||!t.charCode||t.ctrlKey&&!t.altKey||re&&t.metaKey)return;if(n.someProp("handleKeyPress",o=>o(n,t))){t.preventDefault();return}let r=n.state.selection;if(!(r instanceof D)||!r.$from.sameParent(r.$to)){let o=String.fromCharCode(t.charCode),i=()=>n.state.tr.insertText(o).scrollIntoView();!/[\r\n]/.test(o)&&!n.someProp("handleTextInput",s=>s(n,r.$from.pos,r.$to.pos,o,i))&&n.dispatch(i()),t.preventDefault()}};function xn(n){return{left:n.clientX,top:n.clientY}}function wd(n,e){let t=e.x-n.clientX,r=e.y-n.clientY;return t*t+r*r<100}function kr(n,e,t,r,o){if(r==-1)return!1;let i=n.state.doc.resolve(r);for(let s=i.depth+1;s>0;s--)if(n.someProp(e,a=>s>i.depth?a(n,t,i.nodeAfter,i.before(s),o,!0):a(n,t,i.node(s),i.before(s),o,!1)))return!0;return!1}function ot(n,e,t){if(n.focused||n.focus(),n.state.selection.eq(e))return;let r=n.state.tr.setSelection(e);r.setMeta("pointer",!0),n.dispatch(r)}function vd(n,e){if(e==-1)return!1;let t=n.state.doc.resolve(e),r=t.nodeAfter;return r&&r.isAtom&&M.isSelectable(r)?(ot(n,new M(t)),!0):!1}function Cd(n,e){if(e==-1)return!1;let t=n.state.selection,r,o;t instanceof M&&(r=t.node);let i=n.state.doc.resolve(e);for(let s=i.depth+1;s>0;s--){let a=s>i.depth?i.nodeAfter:i.node(s);if(M.isSelectable(a)){r&&t.$from.depth>0&&s>=t.$from.depth&&i.before(t.$from.depth+1)==t.$from.pos?o=i.before(t.$from.depth):o=i.before(s);break}}return o!=null?(ot(n,M.create(n.state.doc,o)),!0):!1}function Sd(n,e,t,r,o){return kr(n,"handleClickOn",e,t,r)||n.someProp("handleClick",i=>i(n,e,r))||(o?Cd(n,t):vd(n,t))}function Md(n,e,t,r){return kr(n,"handleDoubleClickOn",e,t,r)||n.someProp("handleDoubleClick",o=>o(n,e,r))}function Nd(n,e,t,r){return kr(n,"handleTripleClickOn",e,t,r)||n.someProp("handleTripleClick",o=>o(n,e,r))||Od(n,t,r)}function Od(n,e,t){if(t.button!=0)return!1;let r=n.state.doc;if(e==-1)return r.inlineContent?(ot(n,D.create(r,0,r.content.size)),!0):!1;let o=r.resolve(e);for(let i=o.depth+1;i>0;i--){let s=i>o.depth?o.nodeAfter:o.node(i),a=o.before(i);if(s.inlineContent)ot(n,D.create(r,a+1,a+1+s.content.size));else if(M.isSelectable(s))ot(n,M.create(r,a));else continue;return!0}}function wr(n){return sn(n)}const gs=re?"metaKey":"ctrlKey";Z.mousedown=(n,e)=>{let t=e;n.input.shiftKey=t.shiftKey;let r=wr(n),o=Date.now(),i="singleClick";o-n.input.lastClick.time<500&&wd(t,n.input.lastClick)&&!t[gs]&&n.input.lastClick.button==t.button&&(n.input.lastClick.type=="singleClick"?i="doubleClick":n.input.lastClick.type=="doubleClick"&&(i="tripleClick")),n.input.lastClick={time:o,x:t.clientX,y:t.clientY,type:i,button:t.button};let s=n.posAtCoords(xn(t));s&&(i=="singleClick"?(n.input.mouseDown&&n.input.mouseDown.done(),n.input.mouseDown=new Td(n,s,t,!!r)):(i=="doubleClick"?Md:Nd)(n,s.pos,s.inside,t)?t.preventDefault():Ee(n,"pointer"))};class Td{constructor(e,t,r,o){this.view=e,this.pos=t,this.event=r,this.flushed=o,this.delayedSelectionSync=!1,this.mightDrag=null,this.startDoc=e.state.doc,this.selectNode=!!r[gs],this.allowDefault=r.shiftKey;let i,s;if(t.inside>-1)i=e.state.doc.nodeAt(t.inside),s=t.inside;else{let d=e.state.doc.resolve(t.pos);i=d.parent,s=d.depth?d.before():0}const a=o?null:r.target,l=a?e.docView.nearestDesc(a,!0):null;this.target=l&&l.nodeDOM.nodeType==1?l.nodeDOM:null;let{selection:c}=e.state;r.button==0&&(i.type.spec.draggable&&i.type.spec.selectable!==!1||c instanceof M&&c.from<=s&&c.to>s)&&(this.mightDrag={node:i,pos:s,addAttr:!!(this.target&&!this.target.draggable),setUneditable:!!(this.target&&le&&!this.target.hasAttribute("contentEditable"))}),this.target&&this.mightDrag&&(this.mightDrag.addAttr||this.mightDrag.setUneditable)&&(this.view.domObserver.stop(),this.mightDrag.addAttr&&(this.target.draggable=!0),this.mightDrag.setUneditable&&setTimeout(()=>{this.view.input.mouseDown==this&&this.target.setAttribute("contentEditable","false")},20),this.view.domObserver.start()),e.root.addEventListener("mouseup",this.up=this.up.bind(this)),e.root.addEventListener("mousemove",this.move=this.move.bind(this)),Ee(e,"pointer")}done(){this.view.root.removeEventListener("mouseup",this.up),this.view.root.removeEventListener("mousemove",this.move),this.mightDrag&&this.target&&(this.view.domObserver.stop(),this.mightDrag.addAttr&&this.target.removeAttribute("draggable"),this.mightDrag.setUneditable&&this.target.removeAttribute("contentEditable"),this.view.domObserver.start()),this.delayedSelectionSync&&setTimeout(()=>ve(this.view)),this.view.input.mouseDown=null}up(e){if(this.done(),!this.view.dom.contains(e.target))return;let t=this.pos;this.view.state.doc!=this.startDoc&&(t=this.view.posAtCoords(xn(e))),this.updateAllowDefault(e),this.allowDefault||!t?Ee(this.view,"pointer"):Sd(this.view,t.pos,t.inside,e,this.selectNode)?e.preventDefault():e.button==0&&(this.flushed||j&&this.mightDrag&&!this.mightDrag.node.isAtom||J&&!this.view.state.selection.visible&&Math.min(Math.abs(t.pos-this.view.state.selection.from),Math.abs(t.pos-this.view.state.selection.to))<=2)?(ot(this.view,A.near(this.view.state.doc.resolve(t.pos))),e.preventDefault()):Ee(this.view,"pointer")}move(e){this.updateAllowDefault(e),Ee(this.view,"pointer"),e.buttons==0&&this.done()}updateAllowDefault(e){!this.allowDefault&&(Math.abs(this.event.x-e.clientX)>4||Math.abs(this.event.y-e.clientY)>4)&&(this.allowDefault=!0)}}Z.touchstart=n=>{n.input.lastTouch=Date.now(),wr(n),Ee(n,"pointer")};Z.touchmove=n=>{n.input.lastTouch=Date.now(),Ee(n,"pointer")};Z.contextmenu=n=>wr(n);function xs(n,e){return n.composing?!0:j&&Math.abs(e.timeStamp-n.input.compositionEndedAt)<500?(n.input.compositionEndedAt=-2e8,!0):!1}const Dd=we?5e3:-1;Y.compositionstart=Y.compositionupdate=n=>{if(!n.composing){n.domObserver.flush();let{state:e}=n,t=e.selection.$to;if(e.selection instanceof D&&(e.storedMarks||!t.textOffset&&t.parentOffset&&t.nodeBefore.marks.some(r=>r.type.spec.inclusive===!1)||J&&ji&&Ed(n)))n.markCursor=n.state.storedMarks||t.marks(),sn(n,!0),n.markCursor=null;else if(sn(n,!e.selection.empty),le&&e.selection.empty&&t.parentOffset&&!t.textOffset&&t.nodeBefore.marks.length){let r=n.domSelectionRange();for(let o=r.focusNode,i=r.focusOffset;o&&o.nodeType==1&&i!=0;){let s=i<0?o.lastChild:o.childNodes[i-1];if(!s)break;if(s.nodeType==3){let a=n.domSelection();a&&a.collapse(s,s.nodeValue.length);break}else o=s,i=-1}}n.input.composing=!0}bs(n,Dd)};function Ed(n){let{focusNode:e,focusOffset:t}=n.domSelectionRange();if(!e||e.nodeType!=1||t>=e.childNodes.length)return!1;let r=e.childNodes[t];return r.nodeType==1&&r.contentEditable=="false"}Y.compositionend=(n,e)=>{n.composing&&(n.input.composing=!1,n.input.compositionEndedAt=e.timeStamp,n.input.compositionPendingChanges=n.domObserver.pendingRecords().length?n.input.compositionID:0,n.input.compositionNode=null,n.input.badSafariComposition?n.domObserver.forceFlush():n.input.compositionPendingChanges&&Promise.resolve().then(()=>n.domObserver.flush()),n.input.compositionID++,bs(n,20))};function bs(n,e){clearTimeout(n.input.composingTimeout),e>-1&&(n.input.composingTimeout=setTimeout(()=>sn(n),e))}function ys(n){for(n.composing&&(n.input.composing=!1,n.input.compositionEndedAt=Ad());n.input.compositionNodes.length>0;)n.input.compositionNodes.pop().markParentsDirty()}function Id(n){let e=n.domSelectionRange();if(!e.focusNode)return null;let t=Cc(e.focusNode,e.focusOffset),r=Sc(e.focusNode,e.focusOffset);if(t&&r&&t!=r){let o=r.pmViewDesc,i=n.domObserver.lastChangedTextNode;if(t==i||r==i)return i;if(!o||!o.isText(r.nodeValue))return r;if(n.input.compositionNode==r){let s=t.pmViewDesc;if(!(!s||!s.isText(t.nodeValue)))return r}}return t||r}function Ad(){let n=document.createEvent("Event");return n.initEvent("event",!0,!0),n.timeStamp}function sn(n,e=!1){if(!(we&&n.domObserver.flushingSoon>=0)){if(n.domObserver.forceFlush(),ys(n),e||n.docView&&n.docView.dirty){let t=gr(n),r=n.state.selection;return t&&!t.eq(r)?n.dispatch(n.state.tr.setSelection(t)):(n.markCursor||e)&&!r.$from.node(r.$from.sharedDepth(r.to)).inlineContent?n.dispatch(n.state.tr.deleteSelection()):n.updateState(n.state),!0}return!1}}function Rd(n,e){if(!n.dom.parentNode)return;let t=n.dom.parentNode.appendChild(document.createElement("div"));t.appendChild(e),t.style.cssText="position: fixed; left: -10000px; top: 10px";let r=getSelection(),o=document.createRange();o.selectNodeContents(e),n.dom.blur(),r.removeAllRanges(),r.addRange(o),setTimeout(()=>{t.parentNode&&t.parentNode.removeChild(t),n.focus()},50)}const Et=G&&Ie<15||ct&&Tc<604;Z.copy=Y.cut=(n,e)=>{let t=e,r=n.state.selection,o=t.type=="cut";if(r.empty)return;let i=Et?null:t.clipboardData,s=r.content(),{dom:a,text:l}=br(n,s);i?(t.preventDefault(),i.clearData(),i.setData("text/html",a.innerHTML),i.setData("text/plain",l)):Rd(n,a),o&&n.dispatch(n.state.tr.deleteSelection().scrollIntoView().setMeta("uiEvent","cut"))};function Ld(n){return n.openStart==0&&n.openEnd==0&&n.content.childCount==1?n.content.firstChild:null}function Pd(n,e){if(!n.dom.parentNode)return;let t=n.input.shiftKey||n.state.selection.$from.parent.type.spec.code,r=n.dom.parentNode.appendChild(document.createElement(t?"textarea":"div"));t||(r.contentEditable="true"),r.style.cssText="position: fixed; left: -10000px; top: 10px",r.focus();let o=n.input.shiftKey&&n.input.lastKeyCode!=45;setTimeout(()=>{n.focus(),r.parentNode&&r.parentNode.removeChild(r),t?It(n,r.value,null,o,e):It(n,r.textContent,r.innerHTML,o,e)},50)}function It(n,e,t,r,o){let i=ds(n,e,t,r,n.state.selection.$from);if(n.someProp("handlePaste",l=>l(n,o,i||w.empty)))return!0;if(!i)return!1;let s=Ld(i),a=s?n.state.tr.replaceSelectionWith(s,r):n.state.tr.replaceSelection(i);return n.dispatch(a.scrollIntoView().setMeta("paste",!0).setMeta("uiEvent","paste")),!0}function ks(n){let e=n.getData("text/plain")||n.getData("Text");if(e)return e;let t=n.getData("text/uri-list");return t?t.replace(/\r?\n/g," "):""}Y.paste=(n,e)=>{let t=e;if(n.composing&&!we)return;let r=Et?null:t.clipboardData,o=n.input.shiftKey&&n.input.lastKeyCode!=45;r&&It(n,ks(r),r.getData("text/html"),o,t)?t.preventDefault():Pd(n,t)};class ws{constructor(e,t,r){this.slice=e,this.move=t,this.node=r}}const zd=re?"altKey":"ctrlKey";function vs(n,e){let t;return n.someProp("dragCopies",r=>{t=t||r(e)}),t!=null?!t:!e[zd]}Z.dragstart=(n,e)=>{let t=e,r=n.input.mouseDown;if(r&&r.done(),!t.dataTransfer)return;let o=n.state.selection,i=o.empty?null:n.posAtCoords(xn(t)),s;if(!(i&&i.pos>=o.from&&i.pos<=(o instanceof M?o.to-1:o.to))){if(r&&r.mightDrag)s=M.create(n.state.doc,r.mightDrag.pos);else if(t.target&&t.target.nodeType==1){let u=n.docView.nearestDesc(t.target,!0);u&&u.node.type.spec.draggable&&u!=n.docView&&(s=M.create(n.state.doc,u.posBefore))}}let a=(s||n.state.selection).content(),{dom:l,text:c,slice:d}=br(n,a);(!t.dataTransfer.files.length||!J||Ui>120)&&t.dataTransfer.clearData(),t.dataTransfer.setData(Et?"Text":"text/html",l.innerHTML),t.dataTransfer.effectAllowed="copyMove",Et||t.dataTransfer.setData("text/plain",c),n.dragging=new ws(d,vs(n,t),s)};Z.dragend=n=>{let e=n.dragging;window.setTimeout(()=>{n.dragging==e&&(n.dragging=null)},50)};Y.dragover=Y.dragenter=(n,e)=>e.preventDefault();Y.drop=(n,e)=>{try{Fd(n,e,n.dragging)}finally{n.dragging=null}};function Fd(n,e,t){if(!e.dataTransfer)return;let r=n.posAtCoords(xn(e));if(!r)return;let o=n.state.doc.resolve(r.pos),i=t&&t.slice;i?n.someProp("transformPasted",h=>{i=h(i,n,!1)}):i=ds(n,ks(e.dataTransfer),Et?null:e.dataTransfer.getData("text/html"),!1,o);let s=!!(t&&vs(n,e));if(n.someProp("handleDrop",h=>h(n,e,i||w.empty,s))){e.preventDefault();return}if(!i)return;e.preventDefault();let a=i?Yl(n.state.doc,o.pos,i):o.pos;a==null&&(a=o.pos);let l=n.state.tr;if(s){let{node:h}=t;h?h.replace(l):l.deleteSelection()}let c=l.mapping.map(a),d=i.openStart==0&&i.openEnd==0&&i.content.childCount==1,u=l.doc;if(d?l.replaceRangeWith(c,c,i.content.firstChild):l.replaceRange(c,c,i),l.doc.eq(u))return;let p=l.doc.resolve(c);if(d&&M.isSelectable(i.content.firstChild)&&p.nodeAfter&&p.nodeAfter.sameMarkup(i.content.firstChild))l.setSelection(new M(p));else{let h=l.mapping.map(a);l.mapping.maps[l.mapping.maps.length-1].forEach((m,g,x,k)=>h=k),l.setSelection(xr(n,p,l.doc.resolve(h)))}n.focus(),n.dispatch(l.setMeta("uiEvent","drop"))}Z.focus=n=>{n.input.lastFocus=Date.now(),n.focused||(n.domObserver.stop(),n.dom.classList.add("ProseMirror-focused"),n.domObserver.start(),n.focused=!0,setTimeout(()=>{n.docView&&n.hasFocus()&&!n.domObserver.currentSelection.eq(n.domSelectionRange())&&ve(n)},20))};Z.blur=(n,e)=>{let t=e;n.focused&&(n.domObserver.stop(),n.dom.classList.remove("ProseMirror-focused"),n.domObserver.start(),t.relatedTarget&&n.dom.contains(t.relatedTarget)&&n.domObserver.currentSelection.clear(),n.focused=!1)};Z.beforeinput=(n,e)=>{if(J&&we&&e.inputType=="deleteContentBackward"){n.domObserver.flushSoon();let{domChangeCount:r}=n.input;setTimeout(()=>{if(n.input.domChangeCount!=r||(n.dom.blur(),n.focus(),n.someProp("handleKeyDown",i=>i(n,Pe(8,"Backspace")))))return;let{$cursor:o}=n.state.selection;o&&o.pos>0&&n.dispatch(n.state.tr.delete(o.pos-1,o.pos).scrollIntoView())},50)}};for(let n in Y)Z[n]=Y[n];function At(n,e){if(n==e)return!0;for(let t in n)if(n[t]!==e[t])return!1;for(let t in e)if(!(t in n))return!1;return!0}class an{constructor(e,t){this.toDOM=e,this.spec=t||We,this.side=this.spec.side||0}map(e,t,r,o){let{pos:i,deleted:s}=e.mapResult(t.from+o,this.side<0?-1:1);return s?null:new se(i-r,i-r,this)}valid(){return!0}eq(e){return this==e||e instanceof an&&(this.spec.key&&this.spec.key==e.spec.key||this.toDOM==e.toDOM&&At(this.spec,e.spec))}destroy(e){this.spec.destroy&&this.spec.destroy(e)}}class Re{constructor(e,t){this.attrs=e,this.spec=t||We}map(e,t,r,o){let i=e.map(t.from+o,this.spec.inclusiveStart?-1:1)-r,s=e.map(t.to+o,this.spec.inclusiveEnd?1:-1)-r;return i>=s?null:new se(i,s,this)}valid(e,t){return t.from=e&&(!i||i(a.spec))&&r.push(a.copy(a.from+o,a.to+o))}for(let s=0;se){let a=this.children[s]+1;this.children[s+2].findInner(e-a,t-a,r,o+a,i)}}map(e,t,r){return this==U||e.maps.length==0?this:this.mapInner(e,t,0,0,r||We)}mapInner(e,t,r,o,i){let s;for(let a=0;a{let c=l+r,d;if(d=Ss(t,a,c)){for(o||(o=this.children.slice());ia&&u.to=e){this.children[a]==e&&(r=this.children[a+2]);break}let i=e+1,s=i+t.content.size;for(let a=0;ai&&l.type instanceof Re){let c=Math.max(i,l.from)-i,d=Math.min(s,l.to)-i;co.map(e,t,We));return Ne.from(r)}forChild(e,t){if(t.isLeaf)return z.empty;let r=[];for(let o=0;ot instanceof z)?e:e.reduce((t,r)=>t.concat(r instanceof z?r:r.members),[]))}}forEachSet(e){for(let t=0;t{let x=g-m-(h-p);for(let k=0;kT+d-u)continue;let N=a[k]+d-u;h>=N?a[k+1]=p<=N?-2:-1:p>=d&&x&&(a[k]+=x,a[k+1]+=x)}u+=x}),d=t.maps[c].map(d,-1)}let l=!1;for(let c=0;c=r.content.size){l=!0;continue}let p=t.map(n[c+1]+i,-1),h=p-o,{index:m,offset:g}=r.content.findIndex(u),x=r.maybeChild(m);if(x&&g==u&&g+x.nodeSize==h){let k=a[c+2].mapInner(t,x,d+1,n[c]+i+1,s);k!=U?(a[c]=u,a[c+1]=h,a[c+2]=k):(a[c+1]=-2,l=!0)}else l=!0}if(l){let c=Vd(a,n,e,t,o,i,s),d=ln(c,r,0,s);e=d.local;for(let u=0;ut&&s.to{let c=Ss(n,a,l+t);if(c){i=!0;let d=ln(c,a,t+l+1,r);d!=U&&o.push(l,l+a.nodeSize,d)}});let s=Cs(i?Ms(n):n,-t).sort(He);for(let a=0;a0;)e++;n.splice(e,0,t)}function Bn(n){let e=[];return n.someProp("decorations",t=>{let r=t(n.state);r&&r!=U&&e.push(r)}),n.cursorWrapper&&e.push(z.create(n.state.doc,[n.cursorWrapper.deco])),Ne.from(e)}const _d={childList:!0,characterData:!0,characterDataOldValue:!0,attributes:!0,attributeOldValue:!0,subtree:!0},$d=G&&Ie<=11;class Wd{constructor(){this.anchorNode=null,this.anchorOffset=0,this.focusNode=null,this.focusOffset=0}set(e){this.anchorNode=e.anchorNode,this.anchorOffset=e.anchorOffset,this.focusNode=e.focusNode,this.focusOffset=e.focusOffset}clear(){this.anchorNode=this.focusNode=null}eq(e){return e.anchorNode==this.anchorNode&&e.anchorOffset==this.anchorOffset&&e.focusNode==this.focusNode&&e.focusOffset==this.focusOffset}}class Hd{constructor(e,t){this.view=e,this.handleDOMChange=t,this.queue=[],this.flushingSoon=-1,this.observer=null,this.currentSelection=new Wd,this.onCharData=null,this.suppressingSelectionUpdates=!1,this.lastChangedTextNode=null,this.observer=window.MutationObserver&&new window.MutationObserver(r=>{for(let o=0;oo.type=="childList"&&o.removedNodes.length||o.type=="characterData"&&o.oldValue.length>o.target.nodeValue.length)?this.flushSoon():j&&e.composing&&r.some(o=>o.type=="childList"&&o.target.nodeName=="TR")?(e.input.badSafariComposition=!0,this.flushSoon()):this.flush()}),$d&&(this.onCharData=r=>{this.queue.push({target:r.target,type:"characterData",oldValue:r.prevValue}),this.flushSoon()}),this.onSelectionChange=this.onSelectionChange.bind(this)}flushSoon(){this.flushingSoon<0&&(this.flushingSoon=window.setTimeout(()=>{this.flushingSoon=-1,this.flush()},20))}forceFlush(){this.flushingSoon>-1&&(window.clearTimeout(this.flushingSoon),this.flushingSoon=-1,this.flush())}start(){this.observer&&(this.observer.takeRecords(),this.observer.observe(this.view.dom,_d)),this.onCharData&&this.view.dom.addEventListener("DOMCharacterDataModified",this.onCharData),this.connectSelection()}stop(){if(this.observer){let e=this.observer.takeRecords();if(e.length){for(let t=0;tthis.flush(),20)}this.observer.disconnect()}this.onCharData&&this.view.dom.removeEventListener("DOMCharacterDataModified",this.onCharData),this.disconnectSelection()}connectSelection(){this.view.dom.ownerDocument.addEventListener("selectionchange",this.onSelectionChange)}disconnectSelection(){this.view.dom.ownerDocument.removeEventListener("selectionchange",this.onSelectionChange)}suppressSelectionUpdates(){this.suppressingSelectionUpdates=!0,setTimeout(()=>this.suppressingSelectionUpdates=!1,50)}onSelectionChange(){if(So(this.view)){if(this.suppressingSelectionUpdates)return ve(this.view);if(G&&Ie<=11&&!this.view.state.selection.empty){let e=this.view.domSelectionRange();if(e.focusNode&&je(e.focusNode,e.focusOffset,e.anchorNode,e.anchorOffset))return this.flushSoon()}this.flush()}}setCurSelection(){this.currentSelection.set(this.view.domSelectionRange())}ignoreSelectionChange(e){if(!e.focusNode)return!0;let t=new Set,r;for(let i=e.focusNode;i;i=lt(i))t.add(i);for(let i=e.anchorNode;i;i=lt(i))if(t.has(i)){r=i;break}let o=r&&this.view.docView.nearestDesc(r);if(o&&o.ignoreMutation({type:"selection",target:r.nodeType==3?r.parentNode:r}))return this.setCurSelection(),!0}pendingRecords(){if(this.observer)for(let e of this.observer.takeRecords())this.queue.push(e);return this.queue}flush(){let{view:e}=this;if(!e.docView||this.flushingSoon>-1)return;let t=this.pendingRecords();t.length&&(this.queue=[]);let r=e.domSelectionRange(),o=!this.suppressingSelectionUpdates&&!this.currentSelection.eq(r)&&So(e)&&!this.ignoreSelectionChange(r),i=-1,s=-1,a=!1,l=[];if(e.editable)for(let d=0;dd.nodeName=="BR")&&(e.input.lastKeyCode==8||e.input.lastKeyCode==46)){for(let d of l)if(d.nodeName=="BR"&&d.parentNode){let u=d.nextSibling;for(;u&&u.nodeType==1;){if(u.contentEditable=="false"){d.parentNode.removeChild(d);break}u=u.firstChild}}}else if(le&&l.length){let d=l.filter(u=>u.nodeName=="BR");if(d.length==2){let[u,p]=d;u.parentNode&&u.parentNode.parentNode==p.parentNode?p.remove():u.remove()}else{let{focusNode:u}=this.currentSelection;for(let p of d){let h=p.parentNode;h&&h.nodeName=="LI"&&(!u||Ud(e,u)!=h)&&p.remove()}}}let c=null;i<0&&o&&e.input.lastFocus>Date.now()-200&&Math.max(e.input.lastTouch,e.input.lastClick.time)-1||o)&&(i>-1&&(e.docView.markDirty(i,s),qd(e)),e.input.badSafariComposition&&(e.input.badSafariComposition=!1,jd(e,l)),this.handleDOMChange(i,s,a,l),e.docView&&e.docView.dirty?e.updateState(e.state):this.currentSelection.eq(r)||ve(e),this.currentSelection.set(r))}registerMutation(e,t){if(t.indexOf(e.target)>-1)return null;let r=this.view.docView.nearestDesc(e.target);if(e.type=="attributes"&&(r==this.view.docView||e.attributeName=="contenteditable"||e.attributeName=="style"&&!e.oldValue&&!e.target.getAttribute("style"))||!r||r.ignoreMutation(e))return null;if(e.type=="childList"){for(let d=0;do;x--){let k=r.childNodes[x-1],T=k.pmViewDesc;if(k.nodeName=="BR"&&!T){i=x;break}if(!T||T.size)break}let u=n.state.doc,p=n.someProp("domParser")||Ot.fromSchema(n.state.schema),h=u.resolve(s),m=null,g=p.parse(r,{topNode:h.parent,topMatch:h.parent.contentMatchAt(h.index()),topOpen:!0,from:o,to:i,preserveWhitespace:h.parent.type.whitespace=="pre"?"full":!0,findPositions:c,ruleFromNode:Zd,context:h});if(c&&c[0].pos!=null){let x=c[0].pos,k=c[1]&&c[1].pos;k==null&&(k=x),m={anchor:x+s,head:k+s}}return{doc:g,sel:m,from:s,to:a}}function Zd(n){let e=n.pmViewDesc;if(e)return e.parseRule();if(n.nodeName=="BR"&&n.parentNode){if(j&&/^(ul|ol)$/i.test(n.parentNode.nodeName)){let t=document.createElement("div");return t.appendChild(document.createElement("li")),{skip:t}}else if(n.parentNode.lastChild==n||j&&/^(tr|table)$/i.test(n.parentNode.nodeName))return{ignore:!0}}else if(n.nodeName=="IMG"&&n.getAttribute("mark-placeholder"))return{ignore:!0};return null}const Yd=/^(a|abbr|acronym|b|bd[io]|big|br|button|cite|code|data(list)?|del|dfn|em|i|img|ins|kbd|label|map|mark|meter|output|q|ruby|s|samp|small|span|strong|su[bp]|time|u|tt|var)$/i;function Gd(n,e,t,r,o){let i=n.input.compositionPendingChanges||(n.composing?n.input.compositionID:0);if(n.input.compositionPendingChanges=0,e<0){let S=n.input.lastSelectionTime>Date.now()-50?n.input.lastSelectionOrigin:null,I=gr(n,S);if(I&&!n.state.selection.eq(I)){if(J&&we&&n.input.lastKeyCode===13&&Date.now()-100O(n,Pe(13,"Enter"))))return;let C=n.state.tr.setSelection(I);S=="pointer"?C.setMeta("pointer",!0):S=="key"&&C.scrollIntoView(),i&&C.setMeta("composition",i),n.dispatch(C)}return}let s=n.state.doc.resolve(e),a=s.sharedDepth(t);e=s.before(a+1),t=n.state.doc.resolve(t).after(a+1);let l=n.state.selection,c=Kd(n,e,t),d=n.state.doc,u=d.slice(c.from,c.to),p,h;n.input.lastKeyCode===8&&Date.now()-100Date.now()-225||we)&&o.some(S=>S.nodeType==1&&!Yd.test(S.nodeName))&&(!m||m.endA>=m.endB)&&n.someProp("handleKeyDown",S=>S(n,Pe(13,"Enter")))){n.input.lastIOSEnter=0;return}if(!m)if(r&&l instanceof D&&!l.empty&&l.$head.sameParent(l.$anchor)&&!n.composing&&!(c.sel&&c.sel.anchor!=c.sel.head))m={start:l.from,endA:l.to,endB:l.to};else{if(c.sel){let S=zo(n,n.state.doc,c.sel);if(S&&!S.eq(n.state.selection)){let I=n.state.tr.setSelection(S);i&&I.setMeta("composition",i),n.dispatch(I)}}return}n.state.selection.fromn.state.selection.from&&m.start<=n.state.selection.from+2&&n.state.selection.from>=c.from?m.start=n.state.selection.from:m.endA=n.state.selection.to-2&&n.state.selection.to<=c.to&&(m.endB+=n.state.selection.to-m.endA,m.endA=n.state.selection.to)),G&&Ie<=11&&m.endB==m.start+1&&m.endA==m.start&&m.start>c.from&&c.doc.textBetween(m.start-c.from-1,m.start-c.from+1)=="  "&&(m.start--,m.endA--,m.endB--);let g=c.doc.resolveNoCache(m.start-c.from),x=c.doc.resolveNoCache(m.endB-c.from),k=d.resolve(m.start),T=g.sameParent(x)&&g.parent.inlineContent&&k.end()>=m.endA;if((ct&&n.input.lastIOSEnter>Date.now()-225&&(!T||o.some(S=>S.nodeName=="DIV"||S.nodeName=="P"))||!T&&g.posS(n,Pe(13,"Enter")))){n.input.lastIOSEnter=0;return}if(n.state.selection.anchor>m.start&&Qd(d,m.start,m.endA,g,x)&&n.someProp("handleKeyDown",S=>S(n,Pe(8,"Backspace")))){we&&J&&n.domObserver.suppressSelectionUpdates();return}J&&m.endB==m.start&&(n.input.lastChromeDelete=Date.now()),we&&!T&&g.start()!=x.start()&&x.parentOffset==0&&g.depth==x.depth&&c.sel&&c.sel.anchor==c.sel.head&&c.sel.head==m.endA&&(m.endB-=2,x=c.doc.resolveNoCache(m.endB-c.from),setTimeout(()=>{n.someProp("handleKeyDown",function(S){return S(n,Pe(13,"Enter"))})},20));let N=m.start,F=m.endA,X=S=>{let I=S||n.state.tr.replace(N,F,c.doc.slice(m.start-c.from,m.endB-c.from));if(c.sel){let C=zo(n,I.doc,c.sel);C&&!(J&&n.composing&&C.empty&&(m.start!=m.endB||n.input.lastChromeDeleteve(n),20));let S=X(n.state.tr.delete(N,F)),I=d.resolve(m.start).marksAcross(d.resolve(m.endA));I&&S.ensureMarks(I),n.dispatch(S)}else if(m.endA==m.endB&&($=Xd(g.parent.content.cut(g.parentOffset,x.parentOffset),k.parent.content.cut(k.parentOffset,m.endA-k.start())))){let S=X(n.state.tr);$.type=="add"?S.addMark(N,F,$.mark):S.removeMark(N,F,$.mark),n.dispatch(S)}else if(g.parent.child(g.index()).isText&&g.index()==x.index()-(x.textOffset?0:1)){let S=g.parent.textBetween(g.parentOffset,x.parentOffset),I=()=>X(n.state.tr.insertText(S,N,F));n.someProp("handleTextInput",C=>C(n,N,F,S,I))||n.dispatch(I())}else n.dispatch(X());else n.dispatch(X())}function zo(n,e,t){return Math.max(t.anchor,t.head)>e.content.size?null:xr(n,e.resolve(t.anchor),e.resolve(t.head))}function Xd(n,e){let t=n.firstChild.marks,r=e.firstChild.marks,o=t,i=r,s,a,l;for(let d=0;dd.mark(a.addToSet(d.marks));else if(o.length==0&&i.length==1)a=i[0],s="remove",l=d=>d.mark(a.removeFromSet(d.marks));else return null;let c=[];for(let d=0;dt||Vn(s,!0,!1)0&&(e||n.indexAfter(r)==n.node(r).childCount);)r--,o++,e=!1;if(t){let i=n.node(r).maybeChild(n.indexAfter(r));for(;i&&!i.isLeaf;)i=i.firstChild,o++}return o}function eu(n,e,t,r,o){let i=n.findDiffStart(e,t);if(i==null)return null;let{a:s,b:a}=n.findDiffEnd(e,t+n.size,t+e.size);if(o=="end"){let l=Math.max(0,i-Math.min(s,a));r-=s+l-i}if(s=s?i-r:0;i-=l,i&&i=a?i-r:0;i-=l,i&&i=56320&&e<=57343&&t>=55296&&t<=56319}class Ns{constructor(e,t){this._root=null,this.focused=!1,this.trackWrites=null,this.mounted=!1,this.markCursor=null,this.cursorWrapper=null,this.lastSelectedViewDesc=void 0,this.input=new gd,this.prevDirectPlugins=[],this.pluginViews=[],this.requiresGeckoHackNode=!1,this.dragging=null,this._props=t,this.state=t.state,this.directPlugins=t.plugins||[],this.directPlugins.forEach(Wo),this.dispatch=this.dispatch.bind(this),this.dom=e&&e.mount||document.createElement("div"),e&&(e.appendChild?e.appendChild(this.dom):typeof e=="function"?e(this.dom):e.mount&&(this.mounted=!0)),this.editable=_o(this),Vo(this),this.nodeViews=$o(this),this.docView=bo(this.state.doc,Bo(this),Bn(this),this.dom,this),this.domObserver=new Hd(this,(r,o,i,s)=>Gd(this,r,o,i,s)),this.domObserver.start(),xd(this),this.updatePluginViews()}get composing(){return this.input.composing}get props(){if(this._props.state!=this.state){let e=this._props;this._props={};for(let t in e)this._props[t]=e[t];this._props.state=this.state}return this._props}update(e){e.handleDOMEvents!=this._props.handleDOMEvents&&or(this);let t=this._props;this._props=e,e.plugins&&(e.plugins.forEach(Wo),this.directPlugins=e.plugins),this.updateStateInner(e.state,t)}setProps(e){let t={};for(let r in this._props)t[r]=this._props[r];t.state=this.state;for(let r in e)t[r]=e[r];this.update(t)}updateState(e){this.updateStateInner(e,this._props)}updateStateInner(e,t){var r;let o=this.state,i=!1,s=!1;e.storedMarks&&this.composing&&(ys(this),s=!0),this.state=e;let a=o.plugins!=e.plugins||this._props.plugins!=t.plugins;if(a||this._props.plugins!=t.plugins||this._props.nodeViews!=t.nodeViews){let h=$o(this);nu(h,this.nodeViews)&&(this.nodeViews=h,i=!0)}(a||t.handleDOMEvents!=this._props.handleDOMEvents)&&or(this),this.editable=_o(this),Vo(this);let l=Bn(this),c=Bo(this),d=o.plugins!=e.plugins&&!o.doc.eq(e.doc)?"reset":e.scrollToSelection>o.scrollToSelection?"to selection":"preserve",u=i||!this.docView.matchesNode(e.doc,c,l);(u||!e.selection.eq(o.selection))&&(s=!0);let p=d=="preserve"&&s&&this.dom.style.overflowAnchor==null&&Ic(this);if(s){this.domObserver.stop();let h=u&&(G||J)&&!this.composing&&!o.selection.empty&&!e.selection.empty&&tu(o.selection,e.selection);if(u){let m=J?this.trackWrites=this.domSelectionRange().focusNode:null;this.composing&&(this.input.compositionNode=Id(this)),(i||!this.docView.update(e.doc,c,l,this))&&(this.docView.updateOuterDeco(c),this.docView.destroy(),this.docView=bo(e.doc,c,l,this.dom,this)),m&&(!this.trackWrites||!this.dom.contains(this.trackWrites))&&(h=!0)}h||!(this.input.mouseDown&&this.domObserver.currentSelection.eq(this.domSelectionRange())&&td(this))?ve(this,h):(as(this,e.selection),this.domObserver.setCurSelection()),this.domObserver.start()}this.updatePluginViews(o),!((r=this.dragging)===null||r===void 0)&&r.node&&!o.doc.eq(e.doc)&&this.updateDraggedNode(this.dragging,o),d=="reset"?this.dom.scrollTop=0:d=="to selection"?this.scrollToSelection():p&&Ac(p)}scrollToSelection(){let e=this.domSelectionRange().focusNode;if(!(!e||!this.dom.contains(e.nodeType==1?e:e.parentNode))){if(!this.someProp("handleScrollToSelection",t=>t(this)))if(this.state.selection instanceof M){let t=this.docView.domAfterPos(this.state.selection.from);t.nodeType==1&&po(this,t.getBoundingClientRect(),e)}else po(this,this.coordsAtPos(this.state.selection.head,1),e)}}destroyPluginViews(){let e;for(;e=this.pluginViews.pop();)e.destroy&&e.destroy()}updatePluginViews(e){if(!e||e.plugins!=this.state.plugins||this.directPlugins!=this.prevDirectPlugins){this.prevDirectPlugins=this.directPlugins,this.destroyPluginViews();for(let t=0;t0&&it.ownerDocument.getSelection()),this._root=t}return e||document}updateRoot(){this._root=null}posAtCoords(e){return Bc(this,e)}coordsAtPos(e,t=1){return Xi(this,e,t)}domAtPos(e,t=0){return this.docView.domFromPos(e,t)}nodeDOM(e){let t=this.docView.descAt(e);return t?t.nodeDOM:null}posAtDOM(e,t,r=-1){let o=this.docView.posFromDOM(e,t,r);if(o==null)throw new RangeError("DOM position not inside the editor");return o}endOfTextblock(e,t){return Hc(this,t||this.state,e)}pasteHTML(e,t){return It(this,"",e,!1,t||new ClipboardEvent("paste"))}pasteText(e,t){return It(this,e,null,!0,t||new ClipboardEvent("paste"))}serializeForClipboard(e){return br(this,e)}destroy(){this.docView&&(bd(this),this.destroyPluginViews(),this.mounted?(this.docView.update(this.state.doc,[],Bn(this),this),this.dom.textContent=""):this.dom.parentNode&&this.dom.parentNode.removeChild(this.dom),this.docView.destroy(),this.docView=null,wc())}get isDestroyed(){return this.docView==null}dispatchEvent(e){return kd(this,e)}domSelectionRange(){let e=this.domSelection();return e?j&&this.root.nodeType===11&&Nc(this.dom.ownerDocument)==this.dom&&Jd(this,e)||e:{focusNode:null,focusOffset:0,anchorNode:null,anchorOffset:0}}domSelection(){return this.root.getSelection()}}Ns.prototype.dispatch=function(n){let e=this._props.dispatchTransaction;e?e.call(this,n):this.updateState(this.state.apply(n))};function Bo(n){let e=Object.create(null);return e.class="ProseMirror",e.contenteditable=String(n.editable),n.someProp("attributes",t=>{if(typeof t=="function"&&(t=t(n.state)),t)for(let r in t)r=="class"?e.class+=" "+t[r]:r=="style"?e.style=(e.style?e.style+";":"")+t[r]:!e[r]&&r!="contenteditable"&&r!="nodeName"&&(e[r]=String(t[r]))}),e.translate||(e.translate="no"),[se.node(0,n.state.doc.content.size,e)]}function Vo(n){if(n.markCursor){let e=document.createElement("img");e.className="ProseMirror-separator",e.setAttribute("mark-placeholder","true"),e.setAttribute("alt",""),n.cursorWrapper={dom:e,deco:se.widget(n.state.selection.from,e,{raw:!0,marks:n.markCursor})}}else n.cursorWrapper=null}function _o(n){return!n.someProp("editable",e=>e(n.state)===!1)}function tu(n,e){let t=Math.min(n.$anchor.sharedDepth(n.head),e.$anchor.sharedDepth(e.head));return n.$anchor.start(t)!=e.$anchor.start(t)}function $o(n){let e=Object.create(null);function t(r){for(let o in r)Object.prototype.hasOwnProperty.call(e,o)||(e[o]=r[o])}return n.someProp("nodeViews",t),n.someProp("markViews",t),e}function nu(n,e){let t=0,r=0;for(let o in n){if(n[o]!=e[o])return!0;t++}for(let o in e)r++;return t!=r}function Wo(n){if(n.spec.state||n.spec.filterTransaction||n.spec.appendTransaction)throw new RangeError("Plugins passed directly to the view must not have a state component")}function ru(n,e=!1){return n.key==="Enter"&&(n.isComposing===!0||e||n.keyCode===229)}function ou(n){return n.key==="Tab"&&n.shiftKey===!0&&!n.altKey&&!n.ctrlKey&&!n.metaKey}const _n=new Ol({nodes:{doc:{content:"paragraph+"},paragraph:{content:"inline*",group:"block",parseDOM:[{tag:"p"}],toDOM:()=>["p",0]},text:{group:"inline"},atMention:{attrs:{label:{validate:"string"},path:{validate:"string"},fsPath:{validate:"string",default:""}},inline:!0,group:"inline",draggable:!1,selectable:!1,toDOM:n=>$n({text:n.attrs.label,iconNode:mu(),dataAttributes:{"at-mention-label":n.attrs.label,"at-mention-path":n.attrs.path,"at-mention-fs-path":n.attrs.fsPath}}),parseDOM:[{tag:"span[at-mention-label][at-mention-path]",getAttrs:n=>{const e=n;return{label:e.getAttribute("at-mention-label"),path:e.getAttribute("at-mention-path"),fsPath:e.getAttribute("at-mention-fs-path")??""}}}]},agentMention:{attrs:{name:{validate:"string"},displayName:{validate:"string",default:""},path:{validate:"string"}},inline:!0,group:"inline",draggable:!1,selectable:!1,toDOM:n=>{const e=n.attrs.displayName||n.attrs.name;return $n({text:`@${e}`,dataAttributes:{"agent-mention-name":n.attrs.name,"agent-mention-display-name":e,"agent-mention-path":n.attrs.path}})},parseDOM:[{tag:"span[agent-mention-name][agent-mention-path]",getAttrs:n=>{const e=n,t=e.getAttribute("agent-mention-name")??"";return{name:t,displayName:e.getAttribute("agent-mention-display-name")??t,path:e.getAttribute("agent-mention-path")??""}}}]},skillMention:{attrs:{name:{validate:"string"},displayName:{validate:"string",default:""},path:{validate:"string"},description:{validate:"string",default:""}},inline:!0,group:"inline",draggable:!1,selectable:!1,toDOM:n=>{const e=n.attrs.displayName||n.attrs.name;return $n({text:e,icon:"$",dataAttributes:{"skill-mention-name":n.attrs.name,"skill-mention-display-name":e,"skill-mention-path":n.attrs.path},title:n.attrs.description})},parseDOM:[{tag:"span[skill-mention-name][skill-mention-path]",getAttrs:n=>{const e=n,t=e.getAttribute("skill-mention-name")??"";return{name:t,displayName:e.getAttribute("skill-mention-display-name")??t,path:e.getAttribute("skill-mention-path")??"",description:e.getAttribute("title")??""}}}]}},marks:{}}),Kt=new cc("agentPromptPlaceholder"),iu=it.forwardRef(function({ariaLabel:e,className:t,minHeight:r="2.75rem",onAutocompleteChange:o,onAutocompleteKeyDown:i,onPlanModeShortcut:s,onSubmit:a,onTextChange:l,onTriggerToken:c,placeholder:d,singleLine:u=!1,value:p},h){const m=b.useRef(null),g=b.useRef(null),x=b.useRef(a),k=b.useRef(o),T=b.useRef(i),N=b.useRef(s),F=b.useRef(l),X=b.useRef(c),$=b.useRef(p),S=b.useRef(u);x.current=a,k.current=o,T.current=i,N.current=s,F.current=l,X.current=c,S.current=u,b.useImperativeHandle(h,()=>({focus(){g.current?.focus()},getText(){return $.current},insertMention(C){const O=g.current;O&&pu(O,C)},insertMentions(C){const O=g.current;!O||C.length===0||fu(O,C)},insertText(C){const O=g.current;O&&uu(O,C)}}),[]),b.useLayoutEffect(()=>{const C=m.current;if(!C)return;const O=new Ns(C,{state:tt.create({doc:Os($.current),plugins:[au("")]}),attributes:{"aria-label":"","data-codex-composer":"true","data-virtualkeyboard":"true",role:"textbox",class:"ProseMirror prompt-editor-view",style:"min-height: 2.75rem; font-size: var(--codex-chat-font-size); height: auto; resize: none;"},dispatchTransaction(L){const R=O.state.apply(L);O.updateState(R);const B=lu(R.doc),pt=$.current;if(B!==pt){$.current=B;const pe=su(pt,B);pe&&X.current?.(pe),F.current(B)}k.current?.(Ds(O))},handleKeyDown(L,R){return ru(R,L.composing)?!1:ou(R)&&N.current?(R.preventDefault(),N.current(),!0):hu(R.key)&&T.current?.(R.key)?(R.preventDefault(),!0):(R.metaKey||R.ctrlKey)&&R.key==="Enter"?(R.preventDefault(),x.current(),!0):R.key!=="Enter"?!1:R.shiftKey||R.altKey?(R.preventDefault(),S.current?!0:Hi(L.state,L.dispatch,L)):(R.preventDefault(),x.current(),!0)}});return g.current=O,()=>{O.destroy(),g.current=null}},[]),b.useLayoutEffect(()=>{const C=g.current;C&&C.dispatch(C.state.tr.setMeta(Kt,d))},[d]),b.useLayoutEffect(()=>{const C=g.current;if(!C)return;const O=e??d;O?C.dom.setAttribute("aria-label",O):C.dom.removeAttribute("aria-label")},[e,d]),b.useLayoutEffect(()=>{const C=g.current;C&&(C.dom.style.minHeight=r)},[r]),b.useLayoutEffect(()=>{const C=g.current;!C||p===$.current||($.current=p,du(C,p))},[p]);const I=["text-size-chat","[&_.ProseMirror]:focus-visible:outline-none","text-token-foreground",u?"flex h-9 max-h-none items-center overflow-hidden [&_.ProseMirror]:!h-5 [&_.ProseMirror]:!min-h-5 [&_.ProseMirror]:min-w-0 [&_.ProseMirror]:flex-1 [&_.ProseMirror]:overflow-hidden [&_.ProseMirror]:whitespace-nowrap [&_.ProseMirror_p]:overflow-hidden [&_.ProseMirror_p]:text-ellipsis [&_.ProseMirror_p]:whitespace-nowrap":"h-auto max-h-[25dvh] overflow-y-auto [&_.ProseMirror]:h-auto [&_.ProseMirror]:min-h-[2rem]","[&_.ProseMirror]:resize-none","[&_.ProseMirror_p]:m-0",t].filter(Boolean).join(" ");return it.createElement("div",{className:I,onMouseDown:C=>{const O=g.current;O&&C.target instanceof Node&&!O.dom.contains(C.target)&&(C.preventDefault(),O.focus())},ref:m})});function su(n,e){if(e.length!==n.length+1)return null;let t=0;for(;tn,apply(e,t){return e.getMeta(Kt)??t}},props:{decorations(e){const t=Kt.getState(e)??"";if(!t||e.doc.childCount!==1)return null;const r=e.doc.firstChild;return!r?.isTextblock||r.content.size!==0?null:z.create(e.doc,[se.node(0,r.nodeSize,{class:"placeholder","data-placeholder":t})])}}})}function Os(n){const e=n.split(` +`);return _n.nodes.doc.create(null,e.map(t=>_n.nodes.paragraph.create(null,t.length>0?_n.text(t):null)))}function lu(n){const e=[];return n.forEach(t=>{const r=[];t.forEach(o=>{r.push(cu(o))}),e.push(r.join(""))}),e.join(` +`)}function cu(n){switch(n.type.name){case"atMention":return qt({kind:"at",label:n.attrs.label,name:n.attrs.label,path:n.attrs.path});case"agentMention":return qt({kind:"agent",displayName:n.attrs.displayName||n.attrs.name,name:n.attrs.name,path:n.attrs.path});case"skillMention":return qt({kind:"skill",displayName:n.attrs.displayName||n.attrs.name,name:n.attrs.name,path:n.attrs.path});default:return n.textContent}}function du(n,e){const t=Os(e),r=n.state.tr.replaceWith(0,n.state.doc.content.size,t.content);r.setSelection(D.atEnd(r.doc)),n.dispatch(r)}function uu(n,e){const{state:t}=n,{from:r,to:o}=t.selection,i=e.startsWith("@")?"@":e.startsWith("$")?"$":null,s=t.doc.textBetween(Math.max(0,r-1),r,` `,` `),a=i&&s===i?r-1:r,l=t.doc.textBetween(Math.max(0,a-2),a,` `,` `),c=t.doc.textBetween(o,Math.min(t.doc.content.size,o+2),` `,` -`),d=l.length>0&&!/\s$/.test(l)?" ":"",u=c.length>0&&!/^\s/.test(c)?" ":"",p=`${d}${e}${u}`,h=t.tr.insertText(p,a,o),m=a+p.length;h.setSelection(D.create(h.doc,m)),n.dispatch(h),n.focus()}function au(n,e){const{state:t}=n,{from:r,to:o}=t.selection,i=e.kind==="skill"?"$":"@",s=Ns(n)?.anchorPos??(t.doc.textBetween(Math.max(0,r-1),r,` +`),d=l.length>0&&!/\s$/.test(l)?" ":"",u=c.length>0&&!/^\s/.test(c)?" ":"",p=`${d}${e}${u}`,h=t.tr.insertText(p,a,o),m=a+p.length;h.setSelection(D.create(h.doc,m)),n.dispatch(h),n.focus()}function pu(n,e){const{state:t}=n,{from:r,to:o}=t.selection,i=e.kind==="skill"?"$":"@",s=Ds(n)?.anchorPos??(t.doc.textBetween(Math.max(0,r-1),r,` `,` `)===i?r-1:r),a=t.doc.textBetween(Math.max(0,s-2),s,` `,` `),l=t.doc.textBetween(o,Math.min(t.doc.content.size,o+2),` `,` -`),c=a.length>0&&!/\s$/.test(a)?" ":"",d=l.length>0&&!/^\s/.test(l)?" ":"",u=[];c&&u.push(t.schema.text(c)),u.push(Ms(t.schema,e)),d&&u.push(t.schema.text(d));const p=t.tr.replaceWith(s,o,u),h=s+u.reduce((m,g)=>m+g.nodeSize,0);p.setSelection(D.create(p.doc,h)),n.dispatch(p),n.focus()}function lu(n,e){const{state:t}=n,{from:r,to:o}=t.selection,i=t.doc.textBetween(Math.max(0,r-2),r,` +`),c=a.length>0&&!/\s$/.test(a)?" ":"",d=l.length>0&&!/^\s/.test(l)?" ":"",u=[];c&&u.push(t.schema.text(c)),u.push(Ts(t.schema,e)),d&&u.push(t.schema.text(d));const p=t.tr.replaceWith(s,o,u),h=s+u.reduce((m,g)=>m+g.nodeSize,0);p.setSelection(D.create(p.doc,h)),n.dispatch(p),n.focus()}function fu(n,e){const{state:t}=n,{from:r,to:o}=t.selection,i=t.doc.textBetween(Math.max(0,r-2),r,` `,` `),s=t.doc.textBetween(o,Math.min(t.doc.content.size,o+2),` `,` -`),a=[];i.length>0&&!/\s$/.test(i)&&a.push(t.schema.text(" ")),e.forEach((d,u)=>{u>0&&a.push(t.schema.text(" ")),a.push(Ms(t.schema,d))}),s.length>0&&!/^\s/.test(s)&&a.push(t.schema.text(" "));const l=t.tr.replaceWith(r,o,a),c=r+a.reduce((d,u)=>d+u.nodeSize,0);l.setSelection(D.create(l.doc,c)),n.dispatch(l),n.focus()}function Ms(n,e){switch(e.kind){case"skill":return n.nodes.skillMention.create({name:e.name,displayName:e.displayName??e.label,path:e.path,description:e.description??""});case"agent":return n.nodes.agentMention.create({name:e.name,displayName:e.displayName??e.label,path:e.path});case"at":return n.nodes.atMention.create({label:e.label,path:e.path,fsPath:e.fsPath??e.path})}}function Ns(n){const{state:e}=n,{from:t,empty:r}=e.selection;if(!r)return null;const o=e.doc.textBetween(0,t,` +`),a=[];i.length>0&&!/\s$/.test(i)&&a.push(t.schema.text(" ")),e.forEach((d,u)=>{u>0&&a.push(t.schema.text(" ")),a.push(Ts(t.schema,d))}),s.length>0&&!/^\s/.test(s)&&a.push(t.schema.text(" "));const l=t.tr.replaceWith(r,o,a),c=r+a.reduce((d,u)=>d+u.nodeSize,0);l.setSelection(D.create(l.doc,c)),n.dispatch(l),n.focus()}function Ts(n,e){switch(e.kind){case"skill":return n.nodes.skillMention.create({name:e.name,displayName:e.displayName??e.label,path:e.path,description:e.description??""});case"agent":return n.nodes.agentMention.create({name:e.name,displayName:e.displayName??e.label,path:e.path});case"at":return n.nodes.atMention.create({label:e.label,path:e.path,fsPath:e.fsPath??e.path})}}function Ds(n){const{state:e}=n,{from:t,empty:r}=e.selection;if(!r)return null;const o=e.doc.textBetween(0,t,` `,` -`),i=/(^|\s)([@$][^\s@$]*)$/.exec(o);if(!i)return null;const s=i[2]??"",a=s.charAt(0),l=s.slice(1);return{anchorPos:t-s.length,kind:a==="$"?"skill":"mention",query:l}}function cu(n){return n==="ArrowDown"||n==="ArrowUp"||n==="Enter"||n==="Tab"||n==="Escape"}function Vn({dataAttributes:n,icon:e,iconNode:t,text:r,title:o}){const i=document.createElement("span");i.className="prompt-editor-mention group/inline-mention cursor-pointer inline-mention-brand-aware font-medium px-0.5 cursor-interaction",o&&(i.title=o);for(const[a,l]of Object.entries(n))i.setAttribute(a,l);if(e||t){const a=document.createElement("span");if(a.className="prompt-editor-mention-icon relative mr-[3px] inline-block h-[1lh] w-4 align-bottom",t)a.append(t);else if(e){const l=document.createElement("span");l.className="icon-xs absolute top-1/2 -translate-y-1/2",l.textContent=e,a.append(l)}i.append(a)}const s=document.createElement("span");return s.className="min-w-0 break-words",s.textContent=r,i.append(s),i}function du(){const n=document.createElementNS("http://www.w3.org/2000/svg","svg");n.setAttribute("class","icon-xs absolute top-1/2 -translate-y-1/2"),n.setAttribute("width","20"),n.setAttribute("height","20"),n.setAttribute("viewBox","0 0 20 20"),n.setAttribute("fill","none"),n.setAttribute("aria-hidden","true");const e=document.createElementNS("http://www.w3.org/2000/svg","path");return e.setAttribute("d",Zo),e.setAttribute("fill","currentColor"),n.append(e),n}const f=rt.createElement,uu=20,pu={background:"linear-gradient(to bottom, var(--color-token-editor-background), transparent)"},fu={background:"linear-gradient(to top, var(--color-token-editor-background), transparent)"};function hu(n,e){"use no memo";const[t,r]=b.useState(null),[o,i]=b.useState(0),[s,a]=b.useState(null),l=b.useRef(null),c=b.useCallback(d=>{a(d)},[]);return b.useLayoutEffect(()=>{const d=s;if(!d)return;const u=()=>r(d.getBoundingClientRect().width);if(u(),typeof ResizeObserver>"u")return;const p=new ResizeObserver(u);return p.observe(d),()=>p.disconnect()},[s]),b.useLayoutEffect(()=>{const d=l.current;d&&i(d.getBoundingClientRect().width)},[n]),{inputMeasureRef:c,isSingleLine:fa({hasVisibleAttachments:e,isEditorMultiline:n.includes(` -`),isVoiceLayoutActive:!1,singleLineInputWidth:t,singleLineTextWidth:o}),textMeasureRef:l}}function mu(n){"use no memo";const[e,t]=b.useState(()=>Yo(n)),r=b.useRef(null),o=b.useRef(new Map),i=b.useRef(new Map),s=b.useRef(new Map),a=b.useRef(n),l=b.useRef(e);a.current=n,l.current=e;const c=b.useCallback(()=>{const p=r.current;if(!p)return;const h=a.current.map(g=>{const k=(o.current.get(g.id)??null)?.offsetWidth??0;k>0&&(l.current[g.id]?.hideLabel===!0?s.current.set(g.id,k):i.current.set(g.id,k));const T=i.current.get(g.id)??s.current.get(g.id)??k,N=s.current.get(g.id),F=g.canHideLabel?Math.min(T,N??T):T;return{...g,compactWidth:F,expandedWidth:T,hasMeasuredCompactWidth:N!=null}}),m=ha({availableWidth:p.getBoundingClientRect().width,gap:$o(window.getComputedStyle(p).columnGap)??$o(window.getComputedStyle(p).gap)??0,items:h,previousState:l.current});ma(m,l.current)||t(m)},[]),d=b.useCallback(p=>{r.current=p,p&&c()},[c]),u=b.useCallback((p,h)=>{h?(o.current.set(p,h),c()):o.current.delete(p)},[c]);return b.useLayoutEffect(()=>{c()},[c,n,e]),b.useLayoutEffect(()=>{if(typeof ResizeObserver>"u")return;const p=new ResizeObserver(c);r.current&&p.observe(r.current);for(const h of o.current.values())p.observe(h);return()=>p.disconnect()},[c,n,e]),{state:e,setContainerRef:d,setItemRef:u}}function $o(n){const e=Number.parseFloat(n);return Number.isFinite(e)?e:null}function gu(n){const e=Et.c(3);let t,r;e[0]!==n?(t=()=>{_a(n)},r=[n],e[0]=n,e[1]=t,e[2]=r):(t=e[1],r=e[2]),b.useEffect(t,r)}function xu(n){const e=Et.c(3);let t,r;e[0]!==n?(t=()=>ra(o=>n({type:"event",event:o})),r=[n],e[0]=n,e[1]=t,e[2]=r):(t=e[1],r=e[2]),b.useEffect(t,r)}function bu(n,e){const t=Et.c(4);let r,o;t[0]!==e||t[1]!==n?(r=()=>{Ha(n,e)},o=[n,e],t[0]=e,t[1]=n,t[2]=r,t[3]=o):(r=t[2],o=t[3]),b.useEffect(r,o)}function yu(){const n=Et.c(3);let e;n[0]===Symbol.for("react.memo_cache_sentinel")?(e=Ba(),n[0]=e):e=n[0];const[t,r]=b.useReducer(Va,e);gu(r),xu(r),bu(t,r);let o;return n[1]!==t?(o=f(ku,{state:t,dispatch:r,renderer:"React"}),n[1]=t,n[2]=o):o=n[2],o}function ku({state:n,dispatch:e,renderer:t}){"use no memo";const r=n.providers.find(v=>v.id===n.selectedProviderId),o=ar(n),i=sr(n),s=Ka(n),[a,l]=b.useState([]),c=n.status==="running"&&(n.input.length>0||a.length>0),d=r?n.autoStartAttemptedProviderIds.includes(r.id):!1,u=i&&(r?.autoStart!==!0||d),p=r?.id==="codex",h=Ra(r),m=r?.id==="codex"?n.context?.copy.reasoningEffortHigh??"High":null,[g,x]=b.useState("default"),[k,T]=b.useState(null),N=Zu(n),F=k??N!=null,X=N!=null&&F,$=hu(n.input,a.length>0),S=$.isSingleLine,A=mu([{canHideLabel:m!=null,enabled:r!=null,id:"intelligence"},{canHideLabel:!0,enabled:X,id:"ide-context"}]),C=S?{hideControl:!1,hideLabel:!1}:A.state.intelligence??{hideControl:!1,hideLabel:!1},O=S?{hideControl:!1,hideLabel:!1}:A.state["ide-context"]??{hideControl:!1,hideLabel:!1},L=b.useRef(null),[I,B]=b.useState(null),[dt,pe]=b.useState(""),[As,xe]=b.useState(0),[yn,ce]=b.useState(!1),[Rs,fe]=b.useState(!1),[Mr,Nr]=b.useState(!1),[ut,Pt]=b.useState(!1),[Is,Or]=b.useState(!1),[Ls,be]=b.useState(!1),Ce=I?Cr(I,n,dt):[],kn=Ce.length===0?-1:Math.min(As,Ce.length-1),wn=()=>{const v=L.current?.getText()??n.input;if(!(n.status==="running"&&(v.length>0||a.length>0)))return;v!==n.input&&e({type:"setInput",input:v}),B(null),pe(""),ce(!1),fe(!1),be(!1);const pt=Da(Ea(v,ut),N?.mention??null,F),V=Ta(pt,a),vn=new Set(a.map(Cn=>Cn.id));Ja(n,e,{attachments:a,clearInput:v,displayText:v,permissionMode:p?g:"default",text:V}).then(Cn=>{Cn&&l(Ks=>Ks.filter(Zs=>!vn.has(Zs.id)))})},Tr=v=>{L.current?.insertMention(v.mention),B(null),pe(""),xe(0),fe(!1)},Dr=()=>{Pt(v=>!v),fe(!1),be(!1),ce(!1),B(null),L.current?.focus()},Ps=()=>{T(v=>!(v??N!=null)),fe(!1),be(!1),ce(!1),B(null),L.current?.focus()},zs=()=>{T(!1),fe(!1),be(!1),ce(!1),B(null),L.current?.focus()},Fs=()=>{Pt(!0),Or(!0),fe(!1),be(!1),ce(!1),B(null),L.current?.focus()},Bs=async()=>{if(Mr)return;const v=n.runningSessionId;Nr(!0),B(null),pe(""),xe(0),fe(!1),be(!1);try{const pt=((await qe("app.pickFiles")).files??[]).filter(V=>V.path.trim().length>0).map(V=>{const vn=V.label&&V.label.trim().length>0?V.label:jt(V.path);return{dataUrl:V.dataUrl,fsPath:V.fsPath??V.path,id:`${V.path}-${Date.now()}-${Math.random().toString(36).slice(2)}`,kind:V.isImage||V.dataUrl?.startsWith("data:image/")?"image":"file",label:vn,mimeType:V.mimeType,path:V.path}});l(V=>ap([...V,...pt])),L.current?.focus()}catch(W){v||e({type:"failed",message:At(W,n)})}finally{Nr(!1)}},Vs=v=>{if(!v){B(null),pe(""),xe(0);return}B(v.kind),pe(v.query),xe(0)},_s=v=>{if(!I)return!1;if(v==="Escape")return B(null),pe(""),xe(0),!0;if(Ce.length===0)return v==="Enter"||v==="Tab";if(v==="ArrowDown")return xe(W=>(W+1)%Ce.length),!0;if(v==="ArrowUp")return xe(W=>(W-1+Ce.length)%Ce.length),!0;if(v==="Enter"||v==="Tab"){const W=Ce[kn>=0?kn:0];return W&&Tr(W),!0}return!1},$s=v=>{qa(v,n,e),ce(!1)},Ws=C.hideControl?null:f("div",{className:"model-picker-root relative min-w-0",ref:v=>A.setItemRef("intelligence",v),onBlur:v=>{v.currentTarget.contains(v.relatedTarget)||ce(!1)}},f("button",{className:`model-picker ${ee} ${te} ${Oe} min-w-0 rounded-full`,type:"button",disabled:!o,"aria-haspopup":"menu","aria-expanded":yn,"data-state":yn?"open":"closed","data-codex-intelligence-trigger":!0,"data-selected-reasoning-effort":"high",onClick:()=>{o&&(fe(!1),be(!1),ce(v=>!v))},onKeyDown:v=>{v.key==="ArrowDown"&&(v.preventDefault(),ce(o))}},C.hideLabel?up("icon-2xs"):f("span",{className:"model-picker-content flex max-w-40 min-w-0 items-center gap-1.5"},f("span",{className:"model-display flex min-w-0 items-center gap-1 tabular-nums"},f("span",{className:"model-label truncate whitespace-nowrap text-token-foreground"},h)),m?f("span",{className:"composer-footer__label--sm shrink-0 text-token-description-foreground"},m):null),f("span",{className:"model-chevron composer-footer__secondary-chevron icon-2xs text-token-input-placeholder-foreground","aria-hidden":!0},gn())),yn?f("div",{className:"provider-dropdown _content_1hiti_1 no-drag bg-token-dropdown-background/90 text-token-foreground ring-token-border z-50 m-px flex select-none flex-col overflow-y-auto rounded-xl ring-[0.5px] px-1 py-1 shadow-xl-spread backdrop-blur-sm w-52",role:"menu","aria-label":n.context?.copy.provider??"","data-state":"open","data-side":"top",style:{"--radix-dropdown-menu-content-transform-origin":"left bottom"}},f("div",{className:"provider-dropdown-title"},n.context?.copy.provider??"Provider"),n.providers.map(v=>f("button",{key:v.id,className:"provider-dropdown-item no-drag group hover:bg-token-list-hover-background focus:bg-token-list-hover-background cursor-interaction text-token-foreground outline-hidden rounded-lg px-[var(--padding-row-x)] py-[var(--padding-row-y)] text-sm",type:"button",role:"menuitem","data-selected":v.id===n.selectedProviderId?"true":void 0,onMouseDown:W=>W.preventDefault(),onClick:()=>$s(v.id)},f("span",{className:"provider-dropdown-item-content flex w-full items-center gap-1.5"},f("span",{className:"min-w-0 flex-1 truncate"},v.displayName),v.id===n.selectedProviderId?f("span",{className:"provider-dropdown-check icon-xs shrink-0","aria-hidden":!0},Ze()):null)))):null),Hs=f(eu,{ref:L,className:S?"text-base":"text-base [&_.ProseMirror]:leading-5",minHeight:S?"1.25rem":"2.75rem",singleLine:S,value:n.input,ariaLabel:n.context?.copy.promptPlaceholder??"",placeholder:n.context?.copy.promptPlaceholder??"",onAutocompleteChange:Vs,onAutocompleteKeyDown:_s,onPlanModeShortcut:Dr,onTextChange:v=>e({type:"setInput",input:v}),onSubmit:wn,onTriggerToken:v=>{B(v==="@"?"mention":"skill"),pe(""),xe(0)}}),Er=(v=!1)=>p?f(qu,{copy:n.context?.copy,hideLabel:v,isEnabled:!0,isOpen:Ls,mode:g,onModeChange:x,onOpenChange:W=>{be(W),W&&(fe(!1),ce(!1),B(null))}}):null,Ar=f("div",{className:"codex-left-rail flex min-w-0 items-center gap-[5px]"},f(Uu,{hasIdeContext:N!=null,isAutoContextOn:F,isOpen:Rs,isPickingFiles:Mr,onOpenChange:v=>{fe(v),v&&(be(!1),ce(!1),B(null))},onPickFiles:()=>{Bs()},onToggleAutoContext:Ps,onTogglePlanMode:Dr,isPlanMode:ut,state:n}),S?null:Er(!1),!S&&ut?f(Wo,{icon:an("icon-xs"),label:n.context?.copy.skillPlan??"Plan",onClear:()=>Pt(!1)}):null),Rr=f("div",{className:"codex-secondary-controls flex min-w-0 items-center gap-1",ref:A.setContainerRef},Ws,X&&!O.hideControl?f("span",{className:"composer-context-indicator flex min-w-0 items-center gap-1",ref:v=>A.setItemRef("ide-context",v)},f("div",{className:"composer-context-divider h-4 w-px bg-token-border/70","aria-hidden":!0}),f(Hu,{hideLabel:O.hideLabel,label:n.context?.copy.ideContext??"IDE context",onClear:zs})):null),Ir=f("div",{className:"codex-action-cluster flex shrink-0 items-center gap-2"},u?f("button",{className:`codex-action codex-start ${ee} ${te} ${Oe} rounded-full`,type:"button",disabled:!i,onClick:()=>{$a(n,e)}},n.context?.copy.start??"Start"):null,s?f("button",{className:`codex-action codex-stop ${ee} ${te} ${Oe} ${Kt} rounded-full`,type:"button","aria-label":n.context?.copy.stop??"Stop",onClick:()=>{Ua(n,e)}},cp()):null,f("button",{className:`codex-action codex-mic ${ee} ${te} ${Oe} ${Kt} rounded-full`,type:"button",disabled:!0,"aria-label":n.context?.copy.voiceInput??""},dp()),f("button",{className:`codex-action send-button ${ia}${c?"":" cursor-default opacity-50"}`,type:"button",disabled:!c,"aria-label":n.context?.copy.send??"Send",onClick:wn},lp("icon-sm text-token-dropdown-background"))),qs=f("div",{className:"flex min-w-0 shrink-0 items-center justify-end gap-2"},Rr,Er(!0),S&&ut?f(Wo,{icon:an("icon-xs"),label:n.context?.copy.skillPlan??"Plan",onClear:()=>Pt(!1)}):null,Ir),Lr=f("div",{key:"composer-input",ref:$.inputMeasureRef,className:S?"min-w-0":"mb-1 flex-grow overflow-y-auto px-3"},Hs),Js=S?f("div",{className:da},Ar,Lr,qs):f("div",{className:"contents"},a.length>0?f("div",{className:"px-2 py-1.5"},f(zu,{attachments:a,copy:n.context?.copy,onRemove:v=>{l(W=>W.filter(pt=>pt.id!==v))}})):null,Lr,f("div",{className:ua},Ar,f("div",{className:"flex items-center"}),f("div",{className:"flex w-full min-w-0 items-center justify-end gap-2"},f("div",{className:"flex min-w-0 flex-1 justify-end"},Rr),f("div",{className:"flex shrink-0 items-center gap-2"},Ir)))),Us=f("div",{className:la},Js),js=!ut&&!Is&&/\bplan\b/i.test(n.input);return f("section",{className:"agent-shell","data-codex-window-type":"electron"},f(vu,{entries:n.transcript,copy:n.context?.copy}),f("div",{className:sa},f("div",{className:"relative flex w-full flex-col gap-2"},f("form",{className:"w-full min-w-0",onSubmit:v=>{v.preventDefault(),wn()}},f("div",{className:aa},f("span",{ref:$.textMeasureRef,className:"composer-single-line-measure text-size-chat pointer-events-none invisible absolute h-0 w-max max-w-none overflow-hidden whitespace-pre","aria-hidden":!0},n.input),js?f(wu,{copy:n.context?.copy,onAction:Fs,onDismiss:()=>Or(!0)}):null,I?f(Wu,{emptyLabel:n.context?.copy?.composerNoResults??"No results",highlightedIndex:kn,items:Ce,onChoose:Tr,onHighlight:xe,query:dt}):null,f("div",{className:ca+" "+(S?"overflow-visible rounded-full":"overflow-y-auto rounded-3xl")},Us)))),f(Bu,{state:n,providerDisplayName:r?.displayName??t})))}function wu({copy:n,onAction:e,onDismiss:t}){return f("div",{className:"above-composer-suggestion-shell pointer-events-none absolute inset-x-0 bottom-full z-20 mb-2 flex justify-center"},f("div",{className:"pointer-events-auto flex w-full max-w-full justify-center"},f("div",{className:"relative inline-flex max-w-full min-w-0 items-center justify-between gap-4 overflow-hidden rounded-3xl border border-token-border/80 bg-token-dropdown-background/90 py-1.5 pr-2 pl-3 text-token-foreground shadow-md backdrop-blur-sm","data-codex-above-composer-suggestion":"keyword-plan-mode"},f("div",{className:"flex min-w-0 flex-1 items-center gap-2"},f("span",{className:"flex items-center justify-center text-token-foreground","aria-hidden":!0},an("icon-xs shrink-0")),f("div",{className:"min-w-0 flex-1 flex items-center gap-2"},f("span",{className:"truncate text-sm font-medium leading-[18px] text-token-foreground"},n?.planSuggestionTitle??"Create a plan"),f("span",{className:"hidden leading-none text-sm text-token-description-foreground @[500px]:inline","aria-hidden":!0},f("span",{className:"pointer-events-none !h-auto rounded-md border border-token-border/80 px-1 py-0.5 text-xs !leading-none text-token-description-foreground"},n?.planSuggestionShortcut??"Shift + Tab")))),f("div",{className:"flex shrink-0 items-center gap-1"},f("button",{className:"user-select-none no-drag cursor-interaction flex h-8 items-center justify-center rounded-full border border-transparent bg-token-button-secondary-background px-2.5 text-sm text-token-button-secondary-foreground hover:bg-token-button-secondary-hover-background focus:outline-none",type:"button",onClick:r=>{r.stopPropagation(),e()}},n?.planSuggestionAction??"Use plan mode"),f("button",{className:"user-select-none no-drag flex size-[22px] shrink-0 cursor-interaction items-center justify-center rounded-full border border-transparent text-token-description-foreground hover:bg-token-list-hover-background focus:outline-none",type:"button","aria-label":n?.planSuggestionDismiss??"Dismiss suggestion",onClick:r=>{r.stopPropagation(),t()}},bn("icon-xs"))))))}const vu=rt.memo(function({entries:e,copy:t}){return f("div",{className:"agent-thread","data-empty":e.length===0?"true":void 0},e.map(r=>f(Cu,{copy:t,entry:r,key:r.id})))}),Cu=rt.memo(function({copy:e,entry:t}){switch(t.role){case"user":{const r=t.attachments??[],o=t.text.trim().length>0;return f("div",{className:"codex-user-turn group flex w-full flex-col items-end justify-end gap-1"},r.length>0?f(Lu,{attachments:r}):null,o?f("div",{className:"codex-user-bubble bg-token-foreground/5 max-w-[77%] min-w-0 overflow-hidden break-words rounded-2xl px-3 py-2 [&_.contain-inline-size]:[contain:initial]"},f(Ru,{copy:e,text:t.text})):null,o?f(Du,{copy:e,sentAtMs:t.sentAtMs,text:t.text}):null)}case"assistant":return t.isComplete===!1?f("div",{className:"codex-assistant-turn group flex min-w-0 flex-col"},f("div",{className:"codex-assistant-message codex-assistant-message-streaming text-size-chat leading-[calc(var(--codex-chat-font-size)+8px)]"},t.text)):f("div",{className:"codex-assistant-turn group flex min-w-0 flex-col"},f("div",{className:"codex-assistant-message text-size-chat leading-[calc(var(--codex-chat-font-size)+8px)] [&>*:last-child]:mb-0 [&>ol:first-child]:mt-0 [&>ul:first-child]:mt-0",dangerouslySetInnerHTML:{__html:ya(t.text)}}),t.text.trim().length>0?f(Eu,{copy:e,sentAtMs:t.sentAtMs,text:t.text}):null);case"notice":return f("div",{className:`codex-notice-turn ${t.tone??"warning"}`},f("div",{className:"codex-notice-content text-size-chat-sm",dangerouslySetInnerHTML:{__html:Be(t.text)}}));case"activity":return f(Su,{copy:e,entry:t})}});function Su({copy:n,entry:e}){"use no memo";const[t,r]=b.useState(!1),[o,i]=b.useState({bottom:!1,top:!1}),s=b.useRef(null),a=n?.copyOutput??"Copy output",l=n?.shellLabel??"",c=n?.copyShellContents??a,d=n?.copiedShellContents??c,u=n?.collapseShell??"",p=n?.shellSuccess??"",h=n?.stopped??"",m=n?.failedStatus??"",g=!!e.output,x=b.useCallback(()=>{r(C=>!C)},[]),k=b.useCallback(()=>{r(!1)},[]),T=b.useCallback(C=>{const O=C===null?{bottom:!1,top:!1}:{bottom:C.scrollTop+C.clientHeight1};i(L=>L.bottom===O.bottom&&L.top===O.top?L:O)},[]),N=b.useCallback(()=>{s.current?.mutation?.disconnect(),s.current?.resize?.disconnect(),s.current=null},[]),F=b.useCallback(C=>{if(N(),C===null){T(null);return}const O=()=>T(C);O();const L=typeof ResizeObserver>"u"?void 0:new ResizeObserver(O);L?.observe(C);const I=typeof MutationObserver>"u"?void 0:new MutationObserver(O);I?.observe(C,{characterData:!0,childList:!0,subtree:!0}),s.current={mutation:I,resize:L}},[N,T]),X=b.useCallback(C=>{T(C.currentTarget)},[T]),$=Nu(e),S=f(rt.Fragment,null,f("span",{className:"codex-tool-activity-icon icon-xs shrink-0","aria-hidden":!0},ea(e)),f("span",{className:"codex-tool-activity-text shrink overflow-hidden [mask-image:linear-gradient(to_right,black_calc(100%_-_0.25rem),transparent)] [mask-repeat:no-repeat] pr-1 group-hover/collapsed-tool-activity:text-token-foreground"},f("span",{className:"codex-tool-activity-action",dangerouslySetInnerHTML:{__html:Be(e.text)}}),e.detail?f("span",{className:"codex-tool-activity-detail",dangerouslySetInnerHTML:{__html:` ${Be(e.detail)}`}}):null)),A=g?f("button",{type:"button",className:"codex-tool-activity-summary group/collapsed-tool-activity group/summary inline-flex w-fit max-w-full cursor-interaction items-center gap-1 self-start text-left","aria-expanded":t,onClick:x},S,f("span",{className:`codex-tool-activity-chevron inline-chevron flex-shrink-0 text-token-input-placeholder-foreground opacity-0 group-hover/summary:opacity-100${t?" opacity-100":""}`},pp(`icon-2xs text-current transition-transform duration-300${t?" rotate-90":""}`))):f("div",{className:"codex-tool-activity-summary group/collapsed-tool-activity group/summary inline-flex w-fit max-w-full items-center gap-1 self-start text-left"},S);return f("div",{className:`codex-tool-activity-turn ${e.activityKind??"other"} ${e.activityStatus??"completed"}`},A,e.output?f("div",{"aria-hidden":!t,className:"codex-tool-activity-output-shell relative overflow-hidden","data-expanded":t?"true":"false"},f("div",{className:"codex-tool-activity-output-shell-inner"},f("div",{className:"codex-tool-activity-output-frame group/output relative pr-0 min-h-[1.25rem] flex flex-col overflow-clip rounded-lg border border-token-border"},f("div",{className:"codex-shell-header flex items-center justify-between bg-token-side-bar-background pl-2 text-sm font-medium text-ellipsis hover:bg-token-editor-background/40"},f("div",{className:"flex min-w-0 items-center"},f("span",{className:"truncate"},l)),f("div",{className:"flex items-center"},f(Ou,{copiedLabel:d,copyLabel:c,text:$}),f("button",{type:"button",className:`codex-shell-collapse ${ee} ${te} ${ln} rounded-full electron:rounded-md hover:bg-transparent hover:text-token-button-foreground`,"aria-label":u,title:u,onClick:k},gn("icon-2xs rotate-180")))),f("div",{className:"relative overflow-hidden"},f("div",{className:"codex-tool-activity-output vertical-scroll-fade-mask [--edge-fade-distance:2rem] box-border flex flex-col gap-1.5 overflow-x-auto overflow-y-auto whitespace-pre p-2 font-vscode-editor font-medium text-size-code-sm text-token-description-foreground max-h-[140px]",onScroll:X,ref:F,dangerouslySetInnerHTML:{__html:Be(e.output)}}),o.top?f("div",{"aria-hidden":!0,className:"pointer-events-none absolute inset-x-0 top-0 h-6",style:pu}):null,o.bottom?f("div",{"aria-hidden":!0,className:"pointer-events-none absolute inset-x-0 bottom-0 h-6",style:fu}):null),f(Mu,{failedLabel:m,status:e.activityStatus??"completed",stoppedLabel:h,successLabel:p}),f(Tu,{label:a,output:e.output})))):null)}function Mu({failedLabel:n,status:e,stoppedLabel:t,successLabel:r}){return e==="inProgress"?f("div",{className:"codex-shell-footer text-size-chat px-2.5 pt-0.5 pb-1"}):e==="stopped"?f("div",{className:"codex-shell-footer text-size-chat flex items-center gap-2 px-2.5 pt-0.5 pb-1 text-token-input-placeholder-foreground"},f("span",{className:"ml-auto"},t)):e==="failed"?f("div",{className:"codex-shell-footer text-size-chat flex items-center gap-2 px-2.5 pt-0.5 pb-1 text-token-input-placeholder-foreground"},f("span",{className:"ml-auto"},n)):f("div",{className:"codex-shell-footer text-size-chat flex items-center gap-2 px-2.5 pt-0.5 pb-1 text-token-input-placeholder-foreground"},f("span",{className:"ml-auto flex items-center gap-1"},Ze("icon-xxs"),r))}function Nu(n){const e=(n.detail??n.text).trim(),t=n.output?.trimEnd()??"";return[e,t].filter(r=>r.length>0).join(` +`),i=/(^|\s)([@$][^\s@$]*)$/.exec(o);if(!i)return null;const s=i[2]??"",a=s.charAt(0),l=s.slice(1);return{anchorPos:t-s.length,kind:a==="$"?"skill":"mention",query:l}}function hu(n){return n==="ArrowDown"||n==="ArrowUp"||n==="Enter"||n==="Tab"||n==="Escape"}function $n({dataAttributes:n,icon:e,iconNode:t,text:r,title:o}){const i=document.createElement("span");i.className="prompt-editor-mention group/inline-mention cursor-pointer inline-mention-brand-aware font-medium px-0.5 cursor-interaction",o&&(i.title=o);for(const[a,l]of Object.entries(n))i.setAttribute(a,l);if(e||t){const a=document.createElement("span");if(a.className="prompt-editor-mention-icon relative mr-[3px] inline-block h-[1lh] w-4 align-bottom",t)a.append(t);else if(e){const l=document.createElement("span");l.className="icon-xs absolute top-1/2 -translate-y-1/2",l.textContent=e,a.append(l)}i.append(a)}const s=document.createElement("span");return s.className="min-w-0 break-words",s.textContent=r,i.append(s),i}function mu(){const n=document.createElementNS("http://www.w3.org/2000/svg","svg");n.setAttribute("class","icon-xs absolute top-1/2 -translate-y-1/2"),n.setAttribute("width","20"),n.setAttribute("height","20"),n.setAttribute("viewBox","0 0 20 20"),n.setAttribute("fill","none"),n.setAttribute("aria-hidden","true");const e=document.createElementNS("http://www.w3.org/2000/svg","path");return e.setAttribute("d",Go),e.setAttribute("fill","currentColor"),n.append(e),n}const f=it.createElement,gu=20,xu={background:"linear-gradient(to bottom, var(--color-token-editor-background), transparent)"},bu={background:"linear-gradient(to top, var(--color-token-editor-background), transparent)"};function yu(n,e){"use no memo";const[t,r]=b.useState(null),[o,i]=b.useState(0),[s,a]=b.useState(null),l=b.useRef(null),c=b.useCallback(d=>{a(d)},[]);return b.useLayoutEffect(()=>{const d=s;if(!d)return;const u=()=>r(d.getBoundingClientRect().width);if(u(),typeof ResizeObserver>"u")return;const p=new ResizeObserver(u);return p.observe(d),()=>p.disconnect()},[s]),b.useLayoutEffect(()=>{const d=l.current;d&&i(d.getBoundingClientRect().width)},[n]),{inputMeasureRef:c,isSingleLine:xa({hasVisibleAttachments:e,isEditorMultiline:n.includes(` +`),isVoiceLayoutActive:!1,singleLineInputWidth:t,singleLineTextWidth:o}),textMeasureRef:l}}function ku(n){"use no memo";const[e,t]=b.useState(()=>Xo(n)),r=b.useRef(null),o=b.useRef(new Map),i=b.useRef(new Map),s=b.useRef(new Map),a=b.useRef(n),l=b.useRef(e);a.current=n,l.current=e;const c=b.useCallback(()=>{const p=r.current;if(!p)return;const h=a.current.map(g=>{const k=(o.current.get(g.id)??null)?.offsetWidth??0;k>0&&(l.current[g.id]?.hideLabel===!0?s.current.set(g.id,k):i.current.set(g.id,k));const T=i.current.get(g.id)??s.current.get(g.id)??k,N=s.current.get(g.id),F=g.canHideLabel?Math.min(T,N??T):T;return{...g,compactWidth:F,expandedWidth:T,hasMeasuredCompactWidth:N!=null}}),m=ba({availableWidth:p.getBoundingClientRect().width,gap:Ho(window.getComputedStyle(p).columnGap)??Ho(window.getComputedStyle(p).gap)??0,items:h,previousState:l.current});ya(m,l.current)||t(m)},[]),d=b.useCallback(p=>{r.current=p,p&&c()},[c]),u=b.useCallback((p,h)=>{h?(o.current.set(p,h),c()):o.current.delete(p)},[c]);return b.useLayoutEffect(()=>{c()},[c,n,e]),b.useLayoutEffect(()=>{if(typeof ResizeObserver>"u")return;const p=new ResizeObserver(c);r.current&&p.observe(r.current);for(const h of o.current.values())p.observe(h);return()=>p.disconnect()},[c,n,e]),{state:e,setContainerRef:d,setItemRef:u}}function Ho(n){const e=Number.parseFloat(n);return Number.isFinite(e)?e:null}function wu(n){const e=Rt.c(3);let t,r;e[0]!==n?(t=()=>{Ja(n)},r=[n],e[0]=n,e[1]=t,e[2]=r):(t=e[1],r=e[2]),b.useEffect(t,r)}function vu(n){const e=Rt.c(3);let t,r;e[0]!==n?(t=()=>sa(o=>n({type:"event",event:o})),r=[n],e[0]=n,e[1]=t,e[2]=r):(t=e[1],r=e[2]),b.useEffect(t,r)}function Cu(n,e){const t=Rt.c(4);let r,o;t[0]!==e||t[1]!==n?(r=()=>{Ka(n,e)},o=[n,e],t[0]=e,t[1]=n,t[2]=r,t[3]=o):(r=t[2],o=t[3]),b.useEffect(r,o)}function Su(){const n=Rt.c(3);let e;n[0]===Symbol.for("react.memo_cache_sentinel")?(e=Wa(),n[0]=e):e=n[0];const[t,r]=b.useReducer(Ha,e);wu(r),vu(r),Cu(t,r);let o;return n[1]!==t?(o=f(Mu,{state:t,dispatch:r,renderer:"React"}),n[1]=t,n[2]=o):o=n[2],o}function Mu({state:n,dispatch:e,renderer:t}){"use no memo";const r=n.providers.find(v=>v.id===n.selectedProviderId),o=lr(n),i=ar(n),s=Qa(n),[a,l]=b.useState([]),c=n.status==="running"&&(n.input.length>0||a.length>0),d=r?n.autoStartAttemptedProviderIds.includes(r.id):!1,u=i&&(r?.autoStart!==!0||d),p=r?.id==="codex",h=za(r),m=r?.id==="codex"?n.context?.copy.reasoningEffortHigh??"High":null,[g,x]=b.useState("default"),[k,T]=b.useState(null),N=ep(n),F=k??N!=null,X=N!=null&&F,$=yu(n.input,a.length>0),S=$.isSingleLine,I=ku([{canHideLabel:m!=null,enabled:r!=null,id:"intelligence"},{canHideLabel:!0,enabled:X,id:"ide-context"}]),C=S?{hideControl:!1,hideLabel:!1}:I.state.intelligence??{hideControl:!1,hideLabel:!1},O=S?{hideControl:!1,hideLabel:!1}:I.state["ide-context"]??{hideControl:!1,hideLabel:!1},L=b.useRef(null),[R,B]=b.useState(null),[pt,pe]=b.useState(""),[Ls,xe]=b.useState(0),[wn,de]=b.useState(!1),[Ps,fe]=b.useState(!1),[Nr,Or]=b.useState(!1),[ft,Bt]=b.useState(!1),[zs,Tr]=b.useState(!1),[Fs,be]=b.useState(!1),Ce=R?Sr(R,n,pt):[],vn=Ce.length===0?-1:Math.min(Ls,Ce.length-1),Cn=()=>{const v=L.current?.getText()??n.input;if(!(n.status==="running"&&(v.length>0||a.length>0)))return;v!==n.input&&e({type:"setInput",input:v}),B(null),pe(""),de(!1),fe(!1),be(!1);const ht=Ra(La(v,ft),N?.mention??null,F),V=Aa(ht,a),Sn=new Set(a.map(Mn=>Mn.id));Ya(n,e,{attachments:a,clearInput:v,displayText:v,permissionMode:p?g:"default",text:V}).then(Mn=>{Mn&&l(Gs=>Gs.filter(Xs=>!Sn.has(Xs.id)))})},Dr=v=>{L.current?.insertMention(v.mention),B(null),pe(""),xe(0),fe(!1)},Er=()=>{Bt(v=>!v),fe(!1),be(!1),de(!1),B(null),L.current?.focus()},Bs=()=>{T(v=>!(v??N!=null)),fe(!1),be(!1),de(!1),B(null),L.current?.focus()},Vs=()=>{T(!1),fe(!1),be(!1),de(!1),B(null),L.current?.focus()},_s=()=>{Bt(!0),Tr(!0),fe(!1),be(!1),de(!1),B(null),L.current?.focus()},$s=async()=>{if(Nr)return;const v=n.runningSessionId;Or(!0),B(null),pe(""),xe(0),fe(!1),be(!1);try{const ht=((await qe("app.pickFiles")).files??[]).filter(V=>V.path.trim().length>0).map(V=>{const Sn=V.label&&V.label.trim().length>0?V.label:Zt(V.path);return{dataUrl:V.dataUrl,fsPath:V.fsPath??V.path,id:`${V.path}-${Date.now()}-${Math.random().toString(36).slice(2)}`,kind:V.isImage||V.dataUrl?.startsWith("data:image/")?"image":"file",label:Sn,mimeType:V.mimeType,path:V.path}});l(V=>pp([...V,...ht])),L.current?.focus()}catch(W){v||e({type:"failed",message:Lt(W,n)})}finally{Or(!1)}},Ws=v=>{if(!v){B(null),pe(""),xe(0);return}B(v.kind),pe(v.query),xe(0)},Hs=v=>{if(!R)return!1;if(v==="Escape")return B(null),pe(""),xe(0),!0;if(Ce.length===0)return v==="Enter"||v==="Tab";if(v==="ArrowDown")return xe(W=>(W+1)%Ce.length),!0;if(v==="ArrowUp")return xe(W=>(W-1+Ce.length)%Ce.length),!0;if(v==="Enter"||v==="Tab"){const W=Ce[vn>=0?vn:0];return W&&Dr(W),!0}return!1},qs=v=>{Za(v,n,e),de(!1)},Js=C.hideControl?null:f("div",{className:"model-picker-root relative min-w-0",ref:v=>I.setItemRef("intelligence",v),onBlur:v=>{v.currentTarget.contains(v.relatedTarget)||de(!1)}},f("button",{className:`model-picker ${ee} ${te} ${Oe} min-w-0 rounded-full`,type:"button",disabled:!o,"aria-haspopup":"menu","aria-expanded":wn,"data-state":wn?"open":"closed","data-codex-intelligence-trigger":!0,"data-selected-reasoning-effort":"high",onClick:()=>{o&&(fe(!1),be(!1),de(v=>!v))},onKeyDown:v=>{v.key==="ArrowDown"&&(v.preventDefault(),de(o))}},C.hideLabel?gp("icon-2xs"):f("span",{className:"model-picker-content flex max-w-40 min-w-0 items-center gap-1.5"},f("span",{className:"model-display flex min-w-0 items-center gap-1 tabular-nums"},f("span",{className:"model-label truncate whitespace-nowrap text-token-foreground"},h)),m?f("span",{className:"composer-footer__label--sm shrink-0 text-token-description-foreground"},m):null),f("span",{className:"model-chevron composer-footer__secondary-chevron icon-2xs text-token-input-placeholder-foreground","aria-hidden":!0},bn())),wn?f("div",{className:"provider-dropdown _content_1hiti_1 no-drag bg-token-dropdown-background/90 text-token-foreground ring-token-border z-50 m-px flex select-none flex-col overflow-y-auto rounded-xl ring-[0.5px] px-1 py-1 shadow-xl-spread backdrop-blur-sm w-52",role:"menu","aria-label":n.context?.copy.provider??"","data-state":"open","data-side":"top",style:{"--radix-dropdown-menu-content-transform-origin":"left bottom"}},f("div",{className:"provider-dropdown-title"},n.context?.copy.provider??"Provider"),n.providers.map(v=>f("button",{key:v.id,className:"provider-dropdown-item no-drag group hover:bg-token-list-hover-background focus:bg-token-list-hover-background cursor-interaction text-token-foreground outline-hidden rounded-lg px-[var(--padding-row-x)] py-[var(--padding-row-y)] text-sm",type:"button",role:"menuitem","data-selected":v.id===n.selectedProviderId?"true":void 0,onMouseDown:W=>W.preventDefault(),onClick:()=>qs(v.id)},f("span",{className:"provider-dropdown-item-content flex w-full items-center gap-1.5"},f("span",{className:"min-w-0 flex-1 truncate"},v.displayName),v.id===n.selectedProviderId?f("span",{className:"provider-dropdown-check icon-xs shrink-0","aria-hidden":!0},Ze()):null)))):null),Us=f(iu,{ref:L,className:S?"text-base":"text-base [&_.ProseMirror]:leading-5",minHeight:S?"1.25rem":"2.75rem",singleLine:S,value:n.input,ariaLabel:n.context?.copy.promptPlaceholder??"",placeholder:n.context?.copy.promptPlaceholder??"",onAutocompleteChange:Ws,onAutocompleteKeyDown:Hs,onPlanModeShortcut:Er,onTextChange:v=>e({type:"setInput",input:v}),onSubmit:Cn,onTriggerToken:v=>{B(v==="@"?"mention":"skill"),pe(""),xe(0)}}),Ir=(v=!1)=>p?f(Zu,{copy:n.context?.copy,hideLabel:v,isEnabled:!0,isOpen:Fs,mode:g,onModeChange:x,onOpenChange:W=>{be(W),W&&(fe(!1),de(!1),B(null))}}):null,Ar=f("div",{className:"codex-left-rail flex min-w-0 items-center gap-[5px]"},f(Gu,{hasIdeContext:N!=null,isAutoContextOn:F,isOpen:Ps,isPickingFiles:Nr,onOpenChange:v=>{fe(v),v&&(be(!1),de(!1),B(null))},onPickFiles:()=>{$s()},onToggleAutoContext:Bs,onTogglePlanMode:Er,isPlanMode:ft,state:n}),S?null:Ir(!1),!S&&ft?f(qo,{icon:cn("icon-xs"),label:n.context?.copy.skillPlan??"Plan",onClear:()=>Bt(!1)}):null),Rr=f("div",{className:"codex-secondary-controls flex min-w-0 items-center gap-1",ref:I.setContainerRef},Js,X&&!O.hideControl?f("span",{className:"composer-context-indicator flex min-w-0 items-center gap-1",ref:v=>I.setItemRef("ide-context",v)},f("div",{className:"composer-context-divider h-4 w-px bg-token-border/70","aria-hidden":!0}),f(Ku,{hideLabel:O.hideLabel,label:n.context?.copy.ideContext??"IDE context",onClear:Vs})):null),Lr=f("div",{className:"codex-action-cluster flex shrink-0 items-center gap-2"},u?f("button",{className:`codex-action codex-start ${ee} ${te} ${Oe} rounded-full`,type:"button",disabled:!i,onClick:()=>{Ua(n,e)}},n.context?.copy.start??"Start"):null,s?f("button",{className:`codex-action codex-stop ${ee} ${te} ${Oe} ${Gt} rounded-full`,type:"button","aria-label":n.context?.copy.stop??"Stop",onClick:()=>{Ga(n,e)}},hp()):null,f("button",{className:`codex-action codex-mic ${ee} ${te} ${Oe} ${Gt} rounded-full`,type:"button",disabled:!0,"aria-label":n.context?.copy.voiceInput??""},mp()),f("button",{className:`codex-action send-button ${ca}${c?"":" cursor-default opacity-50"}`,type:"button",disabled:!c,"aria-label":n.context?.copy.send??"Send",onClick:Cn},fp("icon-sm text-token-dropdown-background"))),js=f("div",{className:"flex min-w-0 shrink-0 items-center justify-end gap-2"},Rr,Ir(!0),S&&ft?f(qo,{icon:cn("icon-xs"),label:n.context?.copy.skillPlan??"Plan",onClear:()=>Bt(!1)}):null,Lr),Pr=f("div",{key:"composer-input",ref:$.inputMeasureRef,className:S?"min-w-0":"mb-1 flex-grow overflow-y-auto px-3"},Us),Ks=S?f("div",{className:ha},Ar,Pr,js):f("div",{className:"contents"},a.length>0?f("div",{className:"px-2 py-1.5"},f($u,{attachments:a,copy:n.context?.copy,onRemove:v=>{l(W=>W.filter(ht=>ht.id!==v))}})):null,Pr,f("div",{className:ma},Ar,f("div",{className:"flex items-center"}),f("div",{className:"flex w-full min-w-0 items-center justify-end gap-2"},f("div",{className:"flex min-w-0 flex-1 justify-end"},Rr),f("div",{className:"flex shrink-0 items-center gap-2"},Lr)))),Zs=f("div",{className:pa},Ks),Ys=!ft&&!zs&&/\bplan\b/i.test(n.input);return f("section",{className:"agent-shell","data-codex-window-type":"electron"},f(Ou,{entries:n.transcript,copy:n.context?.copy}),f("div",{className:da},f("div",{className:"relative flex w-full flex-col gap-2"},f("form",{className:"w-full min-w-0",onSubmit:v=>{v.preventDefault(),Cn()}},f("div",{className:ua},f("span",{ref:$.textMeasureRef,className:"composer-single-line-measure text-size-chat pointer-events-none invisible absolute h-0 w-max max-w-none overflow-hidden whitespace-pre","aria-hidden":!0},n.input),Ys?f(Nu,{copy:n.context?.copy,onAction:_s,onDismiss:()=>Tr(!0)}):null,R?f(ju,{emptyLabel:n.context?.copy?.composerNoResults??"No results",highlightedIndex:vn,items:Ce,onChoose:Dr,onHighlight:xe,query:pt}):null,f("div",{className:fa+" "+(S?"overflow-visible rounded-full":"overflow-y-auto rounded-3xl")},Zs)))),f(Hu,{state:n,providerDisplayName:r?.displayName??t})))}function Nu({copy:n,onAction:e,onDismiss:t}){return f("div",{className:"above-composer-suggestion-shell pointer-events-none absolute inset-x-0 bottom-full z-20 mb-2 flex justify-center"},f("div",{className:"pointer-events-auto flex w-full max-w-full justify-center"},f("div",{className:"relative inline-flex max-w-full min-w-0 items-center justify-between gap-4 overflow-hidden rounded-3xl border border-token-border/80 bg-token-dropdown-background/90 py-1.5 pr-2 pl-3 text-token-foreground shadow-md backdrop-blur-sm","data-codex-above-composer-suggestion":"keyword-plan-mode"},f("div",{className:"flex min-w-0 flex-1 items-center gap-2"},f("span",{className:"flex items-center justify-center text-token-foreground","aria-hidden":!0},cn("icon-xs shrink-0")),f("div",{className:"min-w-0 flex-1 flex items-center gap-2"},f("span",{className:"truncate text-sm font-medium leading-[18px] text-token-foreground"},n?.planSuggestionTitle??"Create a plan"),f("span",{className:"hidden leading-none text-sm text-token-description-foreground @[500px]:inline","aria-hidden":!0},f("span",{className:"pointer-events-none !h-auto rounded-md border border-token-border/80 px-1 py-0.5 text-xs !leading-none text-token-description-foreground"},n?.planSuggestionShortcut??"Shift + Tab")))),f("div",{className:"flex shrink-0 items-center gap-1"},f("button",{className:"user-select-none no-drag cursor-interaction flex h-8 items-center justify-center rounded-full border border-transparent bg-token-button-secondary-background px-2.5 text-sm text-token-button-secondary-foreground hover:bg-token-button-secondary-hover-background focus:outline-none",type:"button",onClick:r=>{r.stopPropagation(),e()}},n?.planSuggestionAction??"Use plan mode"),f("button",{className:"user-select-none no-drag flex size-[22px] shrink-0 cursor-interaction items-center justify-center rounded-full border border-transparent text-token-description-foreground hover:bg-token-list-hover-background focus:outline-none",type:"button","aria-label":n?.planSuggestionDismiss??"Dismiss suggestion",onClick:r=>{r.stopPropagation(),t()}},kn("icon-xs"))))))}const Ou=it.memo(function({entries:e,copy:t}){return f("div",{className:"agent-thread","data-empty":e.length===0?"true":void 0},e.map(r=>f(Tu,{copy:t,entry:r,key:r.id})))}),Tu=it.memo(function({copy:e,entry:t}){switch(t.role){case"user":{const r=t.attachments??[],o=t.text.trim().length>0;return f("div",{className:"codex-user-turn group flex w-full flex-col items-end justify-end gap-1"},r.length>0?f(Vu,{attachments:r}):null,o?f("div",{className:"codex-user-bubble bg-token-foreground/5 max-w-[77%] min-w-0 overflow-hidden break-words rounded-2xl px-3 py-2 [&_.contain-inline-size]:[contain:initial]"},f(Fu,{copy:e,text:t.text})):null,o?f(Lu,{copy:e,sentAtMs:t.sentAtMs,text:t.text}):null)}case"assistant":return t.isComplete===!1?f("div",{className:"codex-assistant-turn group flex min-w-0 flex-col"},f("div",{className:"codex-assistant-message codex-assistant-message-streaming text-size-chat leading-[calc(var(--codex-chat-font-size)+8px)]"},t.text)):f("div",{className:"codex-assistant-turn group flex min-w-0 flex-col"},f("div",{className:"codex-assistant-message text-size-chat leading-[calc(var(--codex-chat-font-size)+8px)] [&>*:last-child]:mb-0 [&>ol:first-child]:mt-0 [&>ul:first-child]:mt-0",dangerouslySetInnerHTML:{__html:Ca(t.text)}}),t.text.trim().length>0?f(Pu,{copy:e,sentAtMs:t.sentAtMs,text:t.text}):null);case"notice":return f("div",{className:`codex-notice-turn ${t.tone??"warning"}`},f("div",{className:"codex-notice-content text-size-chat-sm",dangerouslySetInnerHTML:{__html:Be(t.text)}}));case"activity":return f(Du,{copy:e,entry:t})}});function Du({copy:n,entry:e}){"use no memo";const[t,r]=b.useState(!1),[o,i]=b.useState({bottom:!1,top:!1}),s=b.useRef(null),a=n?.copyOutput??"Copy output",l=n?.shellLabel??"",c=n?.copyShellContents??a,d=n?.copiedShellContents??c,u=n?.collapseShell??"",p=n?.shellSuccess??"",h=n?.stopped??"",m=n?.failedStatus??"",g=!!e.output,x=b.useCallback(()=>{r(C=>!C)},[]),k=b.useCallback(()=>{r(!1)},[]),T=b.useCallback(C=>{const O=C===null?{bottom:!1,top:!1}:{bottom:C.scrollTop+C.clientHeight1};i(L=>L.bottom===O.bottom&&L.top===O.top?L:O)},[]),N=b.useCallback(()=>{s.current?.mutation?.disconnect(),s.current?.resize?.disconnect(),s.current=null},[]),F=b.useCallback(C=>{if(N(),C===null){T(null);return}const O=()=>T(C);O();const L=typeof ResizeObserver>"u"?void 0:new ResizeObserver(O);L?.observe(C);const R=typeof MutationObserver>"u"?void 0:new MutationObserver(O);R?.observe(C,{characterData:!0,childList:!0,subtree:!0}),s.current={mutation:R,resize:L}},[N,T]),X=b.useCallback(C=>{T(C.currentTarget)},[T]),$=Iu(e),S=f(it.Fragment,null,f("span",{className:"codex-tool-activity-icon icon-xs shrink-0","aria-hidden":!0},ra(e)),f("span",{className:"codex-tool-activity-text shrink overflow-hidden [mask-image:linear-gradient(to_right,black_calc(100%_-_0.25rem),transparent)] [mask-repeat:no-repeat] pr-1 group-hover/collapsed-tool-activity:text-token-foreground"},f("span",{className:"codex-tool-activity-action",dangerouslySetInnerHTML:{__html:Be(e.text)}}),e.detail?f("span",{className:"codex-tool-activity-detail",dangerouslySetInnerHTML:{__html:` ${Be(e.detail)}`}}):null)),I=g?f("button",{type:"button",className:"codex-tool-activity-summary group/collapsed-tool-activity group/summary inline-flex w-fit max-w-full cursor-interaction items-center gap-1 self-start text-left","aria-expanded":t,onClick:x},S,f("span",{className:`codex-tool-activity-chevron inline-chevron flex-shrink-0 text-token-input-placeholder-foreground opacity-0 group-hover/summary:opacity-100${t?" opacity-100":""}`},xp(`icon-2xs text-current transition-transform duration-300${t?" rotate-90":""}`))):f("div",{className:"codex-tool-activity-summary group/collapsed-tool-activity group/summary inline-flex w-fit max-w-full items-center gap-1 self-start text-left"},S);return f("div",{className:`codex-tool-activity-turn ${e.activityKind??"other"} ${e.activityStatus??"completed"}`},I,e.output?f("div",{"aria-hidden":!t,className:"codex-tool-activity-output-shell relative overflow-hidden","data-expanded":t?"true":"false"},f("div",{className:"codex-tool-activity-output-shell-inner"},f("div",{className:"codex-tool-activity-output-frame group/output relative pr-0 min-h-[1.25rem] flex flex-col overflow-clip rounded-lg border border-token-border"},f("div",{className:"codex-shell-header flex items-center justify-between bg-token-side-bar-background pl-2 text-sm font-medium text-ellipsis hover:bg-token-editor-background/40"},f("div",{className:"flex min-w-0 items-center"},f("span",{className:"truncate"},l)),f("div",{className:"flex items-center"},f(Au,{copiedLabel:d,copyLabel:c,text:$}),f("button",{type:"button",className:`codex-shell-collapse ${ee} ${te} ${dn} rounded-full electron:rounded-md hover:bg-transparent hover:text-token-button-foreground`,"aria-label":u,title:u,onClick:k},bn("icon-2xs rotate-180")))),f("div",{className:"relative overflow-hidden"},f("div",{className:"codex-tool-activity-output vertical-scroll-fade-mask [--edge-fade-distance:2rem] box-border flex flex-col gap-1.5 overflow-x-auto overflow-y-auto whitespace-pre p-2 font-vscode-editor font-medium text-size-code-sm text-token-description-foreground max-h-[140px]",onScroll:X,ref:F,dangerouslySetInnerHTML:{__html:Be(e.output)}}),o.top?f("div",{"aria-hidden":!0,className:"pointer-events-none absolute inset-x-0 top-0 h-6",style:xu}):null,o.bottom?f("div",{"aria-hidden":!0,className:"pointer-events-none absolute inset-x-0 bottom-0 h-6",style:bu}):null),f(Eu,{failedLabel:m,status:e.activityStatus??"completed",stoppedLabel:h,successLabel:p}),f(Ru,{label:a,output:e.output})))):null)}function Eu({failedLabel:n,status:e,stoppedLabel:t,successLabel:r}){return e==="inProgress"?f("div",{className:"codex-shell-footer text-size-chat px-2.5 pt-0.5 pb-1"}):e==="stopped"?f("div",{className:"codex-shell-footer text-size-chat flex items-center gap-2 px-2.5 pt-0.5 pb-1 text-token-input-placeholder-foreground"},f("span",{className:"ml-auto"},t)):e==="failed"?f("div",{className:"codex-shell-footer text-size-chat flex items-center gap-2 px-2.5 pt-0.5 pb-1 text-token-input-placeholder-foreground"},f("span",{className:"ml-auto"},n)):f("div",{className:"codex-shell-footer text-size-chat flex items-center gap-2 px-2.5 pt-0.5 pb-1 text-token-input-placeholder-foreground"},f("span",{className:"ml-auto flex items-center gap-1"},Ze("icon-xxs"),r))}function Iu(n){const e=(n.detail??n.text).trim(),t=n.output?.trimEnd()??"";return[e,t].filter(r=>r.length>0).join(` -`)}function Ou({copiedLabel:n,copyLabel:e,text:t}){"use no memo";const[r,o]=b.useState(!1),i=b.useRef(null),s=r?n:e,a=b.useCallback(c=>{c!==null||i.current===null||(window.clearTimeout(i.current),i.current=null)},[]),l=b.useCallback(c=>{c.stopPropagation(),navigator.clipboard?.writeText(t)?.catch(()=>{}),o(!0),i.current!==null&&window.clearTimeout(i.current),i.current=window.setTimeout(()=>{o(!1),i.current=null},2e3)},[t]);return f("button",{ref:a,type:"button",className:`codex-shell-copy ${ee} ${te} ${ln} rounded-full electron:rounded-md hover:bg-transparent hover:text-token-button-foreground`,"aria-label":s,title:s,onClick:l},r?Ze("icon-xxs"):xn("icon-xxs"))}function Tu({label:n,output:e}){"use no memo";const[t,r]=b.useState(!1),o=b.useRef(null),i=b.useCallback(a=>{a!==null||o.current===null||(window.clearTimeout(o.current),o.current=null)},[]),s=b.useCallback(a=>{a.stopPropagation(),navigator.clipboard?.writeText(e)?.catch(()=>{}),r(!0),o.current!==null&&window.clearTimeout(o.current),o.current=window.setTimeout(()=>{r(!1),o.current=null},2e3)},[e]);return f("button",{ref:i,type:"button",className:`codex-tool-output-copy ${ee} ${te} rounded-full electron:rounded-md electron:p-1 electron:[&>svg]:icon-sm flex items-center justify-center p-0.5 absolute top-0 right-2.5 opacity-0 transition-opacity duration-200 group-hover/output:opacity-100${t?" text-token-foreground opacity-100":""}`,"aria-label":n,title:n,onClick:s},t?Ze("icon-2xs"):xn("icon-2xs"))}function Du({copy:n,sentAtMs:e,text:t}){"use no memo";const[r,o]=b.useState(!1),i=b.useRef(null),s=n?.copyUserMessage??"",a=n?.copiedUserMessage??"",l=r?a:s,c=e==null?null:Os(e),d=b.useCallback(p=>{p!==null||i.current===null||(window.clearTimeout(i.current),i.current=null)},[]),u=b.useCallback(()=>{const p=t.trim();if(!p)return;navigator.clipboard?.writeText(p)?.catch(()=>{}),o(!0),i.current!==null&&window.clearTimeout(i.current),i.current=window.setTimeout(()=>{o(!1),i.current=null},1500)},[t]);return f("div",{className:"codex-user-message-actions flex flex-row-reverse items-center gap-1"},f("div",{className:"mr-1 ms-1 flex items-center gap-2 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100"},c==null?null:f("span",{className:"text-xs text-token-text-tertiary"},c),f("button",{ref:d,type:"button",className:`codex-user-message-action-button ${ee} ${te} ${ln} rounded-full electron:rounded-md`,"aria-label":l,title:l,onClick:u},r?Ze("icon-xs"):xn("icon-xs"))))}function Eu({copy:n,sentAtMs:e,text:t}){"use no memo";const[r,o]=b.useState(!1),i=b.useRef(null),s=n?.copyAssistantMessage??"",a=n?.copiedAssistantMessage??s,l=r?a:s,c=e==null?null:Os(e),d=b.useCallback(p=>{p!==null||i.current===null||(window.clearTimeout(i.current),i.current=null)},[]),u=b.useCallback(()=>{const p=t.trim();if(!p)return;navigator.clipboard?.writeText(p)?.catch(()=>{}),o(!0),i.current!==null&&window.clearTimeout(i.current),i.current=window.setTimeout(()=>{o(!1),i.current=null},2e3)},[t]);return f("div",{className:"codex-assistant-message-actions mt-1.5 flex h-5 items-center justify-start gap-0.5 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100"},f("button",{ref:d,type:"button",className:`codex-assistant-message-action-button ${ee} ${te} ${ln} rounded-full electron:rounded-md`,"aria-label":l,title:l,onClick:u},r?Ze("icon-xs"):xn("icon-xs")),c==null?null:f("span",{className:"ml-1.5 flex h-full items-center text-size-chat leading-5 text-token-input-placeholder-foreground"},c))}function Os(n,e=new Date){const t=new Date(n),r=Au(t,e);return r===0?new Intl.DateTimeFormat(void 0,{hour:"numeric",minute:"2-digit"}).format(t):r<0&&r>-7?new Intl.DateTimeFormat(void 0,{hour:"numeric",minute:"2-digit",weekday:"long"}).format(t):new Intl.DateTimeFormat(void 0,{day:"numeric",hour:"numeric",minute:"2-digit",month:"short"}).format(t)}function Au(n,e){const t=new Date(n.getFullYear(),n.getMonth(),n.getDate()),r=new Date(e.getFullYear(),e.getMonth(),e.getDate());return Math.round((t.getTime()-r.getTime())/864e5)}function Ru({copy:n,text:e}){"use no memo";const[t,r]=b.useState(!1),o=Iu(e),i=o&&!t,s=n?.showMore??"",a=n?.showLess??"",l=b.useCallback(()=>{r(c=>!c)},[]);return f("div",{className:"flex flex-col items-end gap-1"},f("div",{className:"codex-user-message-content text-size-chat relative w-full min-w-0 mb-px","data-collapsed":i?"true":void 0,dangerouslySetInnerHTML:{__html:Be(e)}}),o?f("button",{type:"button","aria-expanded":t,className:"codex-user-message-toggle text-size-chat mt-1.5 inline-flex cursor-interaction items-center gap-1 self-start text-token-description-foreground hover:text-token-foreground",onClick:l},f("span",null,t?a:s),gn(`icon-2xs${t?" rotate-180":""}`)):null)}function Iu(n){return n.split(/\r\n|\r|\n/).length>uu||n.length>1600}function Lu({attachments:n}){return f("div",{className:"codex-user-message-attachments flex max-w-[77%] flex-row-reverse flex-wrap items-center gap-1"},n.map(e=>f(Pu,{attachment:e,key:e.id})))}function Pu({attachment:n}){return n.kind==="image"&&n.dataUrl?f("div",{className:"user-message-image-attachment flex size-16 items-center justify-center rounded-lg border border-token-border",title:n.label},f("img",{alt:n.label,className:"h-full w-full rounded-md object-cover",referrerPolicy:"no-referrer",src:n.dataUrl})):f("div",{className:"user-message-file-attachment bg-token-dropdown-background border-token-border inline-flex max-w-[320px] items-center gap-1 rounded-full border px-2 py-1.5 text-sm",title:n.path},f("span",{className:"text-token-input-placeholder-foreground flex-shrink-0","aria-hidden":!0},Sr("icon-2xs")),f("span",{className:"flex max-w-full min-w-0 items-center gap-1"},f("span",{className:"truncate"},n.label)))}function zu({attachments:n,className:e,copy:t,onRemove:r}){return f("div",{className:`codex-attachment-tray w-full overflow-x-auto${e?` ${e}`:""}`,"data-composer-attachments-row":!0},f("div",{className:"codex-attachment-row flex min-w-max items-end gap-2"},n.map(o=>f(Fu,{attachment:o,copy:t,key:o.id,onRemove:r}))))}function Fu({attachment:n,copy:e,onRemove:t}){const r=`${e?.removeAttachment??"Remove attachment"} ${n.label}`,o=t?f("button",{className:"composer-attachment-remove",type:"button","aria-label":r,onClick:()=>t(n.id),onMouseDown:i=>i.preventDefault()},bn("icon-2xs")):null;return n.kind==="image"&&n.dataUrl?f("div",{className:"composer-attachment-image",title:n.label},f("img",{alt:"","aria-hidden":!0,src:n.dataUrl}),o):f("div",{className:"composer-attachment-file",title:n.path},f("span",{className:"composer-attachment-file-icon","aria-hidden":!0},Sr("icon-xs")),f("span",{className:"composer-attachment-file-label"},n.label),o)}function Bu(n){const e=Et.c(6),{state:t,providerDisplayName:r}=n,i=(t.context?.rateLimitRows??[]).map(La),s=t.context?.copy,[a,l]=b.useState(!1);if(i.length===0)return null;const c=s?.rateLimits??"Rate limits";let d;e[0]!==t?(d=ja(t),e[0]=t,e[1]=d):d=e[1];let u;e[2]===Symbol.for("react.memo_cache_sentinel")?(u=m=>{m.currentTarget.contains(m.relatedTarget)||l(!1)},e[2]=u):u=e[2];let p;e[3]===Symbol.for("react.memo_cache_sentinel")?(p=()=>l(Vu),e[3]=p):p=e[3];let h;return e[4]!==t?(h=(m,g)=>[g>0?f("span",{key:`${m.role}-separator`,className:"rate-limit-inline-separator","aria-hidden":!0},"•"):null,f(_u,{key:m.role,row:m,state:t})],e[4]=t,e[5]=h):h=e[5],f("div",{className:"rate-line codex-rate-limit-summary relative",role:"status","aria-label":`${r} ${d}`.trim(),onBlur:u},f("button",{className:"rate-limit-trigger rate-limit-trigger-inline flex min-w-0 items-center gap-1",type:"button","aria-expanded":a,onClick:p},f("span",{className:"rate-line-heading"},c),i.flatMap(h)),a?f("div",{className:"rate-limit-popover absolute bottom-[calc(100%+6px)] left-0 z-50 flex min-w-56 flex-col gap-1 rounded-xl border border-token-border bg-token-dropdown-background/95 px-3 py-2 text-sm shadow-xl-spread backdrop-blur-sm"},f("div",{className:"rate-limit-popover-title"},c),i.map(m=>f($u,{key:m.role,row:m,state:t}))):null)}function Vu(n){return!n}function _u({row:n,state:e}){const t=n,r=e.context?.copy,o=t.role==="primary"?r?.rateLimitPrimary??"Primary":r?.rateLimitSecondary??"Secondary",i=ti(t.windowDurationMins,o,{weekly:r?.rateLimitWeekly??"Weekly",monthly:r?.rateLimitMonthly??"Monthly",daysFormat:r?.rateLimitDaysFormat??"",hoursFormat:r?.rateLimitHoursFormat??"",minutesFormat:r?.rateLimitMinutesFormat??""}),s=ei(t.resetsAt);return f("span",{className:"rate-limit-inline-segment"},f("span",{className:"rate-limit-window"},i),f("span",{className:"rate-limit-percent"},Qo(t.remainingPercent)),s?f("span",{className:"rate-limit-reset"},`${r?.rateLimitResets??"resets"} ${s}`):null)}function $u({row:n,state:e}){const t=n,r=e.context?.copy,o=t.role==="primary"?r?.rateLimitPrimary??"Primary":r?.rateLimitSecondary??"Secondary",i=ti(t.windowDurationMins,o,{weekly:r?.rateLimitWeekly??"Weekly",monthly:r?.rateLimitMonthly??"Monthly",daysFormat:r?.rateLimitDaysFormat??"",hoursFormat:r?.rateLimitHoursFormat??"",minutesFormat:r?.rateLimitMinutesFormat??""}),s=ei(t.resetsAt);return f("div",{className:"rate-limit-popover-row"},f("span",{className:"rate-limit-window"},i),f("span",{className:"rate-limit-row-value"},f("span",{className:"rate-limit-percent"},Qo(t.remainingPercent)),s?f("span",{className:"rate-limit-reset"},`${r?.rateLimitResets??"resets"} ${s}`):null))}function Wu({emptyLabel:n,highlightedIndex:e,items:t,onChoose:r,onHighlight:o,query:i}){return f("div",{className:"codex-top-tray-shell absolute z-20"},f("div",{className:"codex-top-tray-panel","cmdk-root":"","data-cmdk-root":!0},f("div",{className:"codex-top-tray-list","cmdk-list":"","data-cmdk-list":!0},t.length===0?f("div",{className:"codex-top-tray-empty","cmdk-empty":"","data-cmdk-empty":!0,"data-command-menu-empty-state":"true"},n):t.map((s,a)=>{const l=s.detail?"codex-top-tray-label max-w-[60%] flex-none truncate":"codex-top-tray-label min-w-0 flex-1 truncate",c=sp(s.label,i),d=c.some(u=>!u.isMatch);return f("button",{key:s.id,className:"codex-top-tray-item",type:"button","aria-selected":a===e?"true":void 0,"cmdk-item":"","data-selected":a===e?"true":void 0,"data-list-navigation-item":!0,onMouseEnter:()=>o(a),onMouseDown:u=>u.preventDefault(),onClick:()=>r(s)},f("div",{className:"codex-top-tray-copy flex w-full items-center gap-2"},f("span",{className:"codex-top-tray-icon icon-xs shrink-0","aria-hidden":!0},s.icon),f("div",{className:l},c.map((u,p)=>f("span",{key:`${u.text}-${p}`,className:!u.isMatch&&d?"text-token-description-foreground":void 0},u.text))),s.detail?f("span",{className:"codex-top-tray-detail min-w-0 flex-1 truncate text-sm text-token-description-foreground"},s.detail):null))}))))}function Wo({icon:n,label:e,onClear:t}){return f("div",{className:"composer-mode-indicator flex min-w-0 items-center gap-1"},f("div",{className:"composer-mode-divider","aria-hidden":!0}),f("button",{className:`composer-mode-button group ${ee} ${te} ${Oe} rounded-full`,type:"button","aria-label":e,onClick:t},f("span",{className:"composer-mode-icon composer-mode-icon-default icon-xs shrink-0","aria-hidden":!0},n),f("span",{className:"composer-mode-icon composer-mode-icon-hover icon-xs shrink-0","aria-hidden":!0},bn("icon-xs")),f("span",{className:"composer-footer__label--sm composer-mode-label max-w-16 truncate"},e)))}function Hu({hideLabel:n,label:e,onClear:t}){return f("button",{className:`composer-context-button group ${ee} ${te} ${Oe} min-w-0 rounded-full`,type:"button","aria-label":e,title:e,onClick:t},f("span",{className:"composer-context-icon composer-context-icon-default icon-xs shrink-0","aria-hidden":!0},Es("icon-xs")),f("span",{className:"composer-context-icon composer-context-icon-hover icon-xs shrink-0","aria-hidden":!0},bn("icon-xs")),n?null:f("span",{className:"composer-footer__label--sm composer-context-label max-w-20 truncate"},e))}function qu({copy:n,hideLabel:e=!1,isEnabled:t,isOpen:r,mode:o,onModeChange:i,onOpenChange:s}){const a=n?.changePermissions??"Change permissions",l=Uo(n,o),c=t?["default","auto-review","full-access","custom"]:[],d=e?oa:Oe,u=h=>{i(h),s(!1)},p=t&&r;return f("div",{className:"permissions-root relative inline-flex",onBlur:h=>{t&&!h.currentTarget.contains(h.relatedTarget)&&s(!1)}},f("button",{className:`permissions-trigger ${ee} ${te} ${d} ${e?Kt:"min-w-0"} rounded-full`,type:"button","aria-label":a,"aria-haspopup":t?"menu":void 0,"aria-expanded":t?p:void 0,"aria-disabled":t?void 0:!0,"data-permission-mode":o,"data-state":p?"open":"closed",disabled:!t,onClick:()=>{t&&s(!p)},onKeyDown:h=>{t&&(h.key==="ArrowDown"&&(h.preventDefault(),s(!0)),h.key==="Escape"&&(h.preventDefault(),s(!1)))}},Ds(o,"icon-xs shrink-0"),e?null:f("span",{className:"permissions-trigger-label composer-footer__label--xs max-w-40 truncate whitespace-nowrap text-left"},l),t?f("span",{className:"permissions-trigger-chevron icon-2xs shrink-0","aria-hidden":!0},gn()):null),p?f("div",{className:"permissions-dropdown _content_1hiti_1 no-drag bg-token-dropdown-background/90 text-token-foreground ring-token-border z-50 m-px flex select-none flex-col overflow-y-auto rounded-xl ring-[0.5px] px-1 py-1 shadow-xl-spread backdrop-blur-sm",role:"menu","aria-label":a,"data-state":"open","data-side":"top",style:{"--radix-dropdown-menu-content-transform-origin":"left bottom"}},c.map(h=>f(Ju,{isSelected:h===o,key:h,label:Uo(n,h),mode:h,onSelect:()=>u(h)}))):null)}function Ju({isSelected:n,label:e,mode:t,onSelect:r}){return f("button",{className:"permissions-item no-drag group hover:bg-token-list-hover-background focus:bg-token-list-hover-background cursor-interaction text-token-foreground outline-hidden rounded-lg px-[var(--padding-row-x)] py-[var(--padding-row-y)] text-sm",type:"button",role:"menuitemradio","aria-checked":n,"data-permission-mode":t,"data-selected":n?"true":void 0,onMouseDown:o=>o.preventDefault(),onClick:r},f("span",{className:"permissions-item-icon icon-xs shrink-0","aria-hidden":!0},Ds(t,"icon-xs shrink-0")),f("span",{className:"permissions-item-label min-w-0 flex-1 truncate"},e),n?f("span",{className:"permissions-item-check icon-xs shrink-0","aria-hidden":!0},Ze()):null)}function Uu({hasIdeContext:n,isAutoContextOn:e,isOpen:t,isPickingFiles:r,isPlanMode:o,onOpenChange:i,onPickFiles:s,onToggleAutoContext:a,onTogglePlanMode:l,state:c}){const d=c.context?.copy,u=d?.addFilesAndMore??"Add files and more",p=d?.addPhotosAndFiles??"Add photos & files",m=Cr("skill",c,"").find(g=>g.id==="plan")??null;return f("div",{className:"add-context-root relative inline-flex",onBlur:g=>{g.currentTarget.contains(g.relatedTarget)||i(!1)}},f("button",{className:`codex-tool codex-tool-plus ${ee} ${te} ${Oe} ${Kt} rounded-full`,type:"button","aria-label":u,"aria-haspopup":"menu","aria-expanded":t,"data-state":t?"open":"closed",onClick:()=>i(!t),onKeyDown:g=>{g.key==="ArrowDown"&&(g.preventDefault(),i(!0)),g.key==="Escape"&&(g.preventDefault(),i(!1))}},fp()),t?f("div",{className:"add-context-dropdown _content_1hiti_1 no-drag bg-token-dropdown-background/90 text-token-foreground ring-token-border z-50 m-px flex select-none flex-col overflow-y-auto rounded-xl ring-[0.5px] px-1 py-1 shadow-xl-spread backdrop-blur-sm",role:"menu","aria-label":u,"data-state":"open","data-side":"top",style:{"--radix-dropdown-menu-content-transform-origin":"left bottom"}},f(ju,{disabled:r,icon:Sr("icon-xs"),label:p,onSelect:s}),n||m?f(Ku):null,n?f(Ho,{checked:e,icon:Es("icon-sm"),label:d?.includeIdeContext??"Include IDE context",onSelect:a}):null,m?f(Ho,{checked:o,icon:an(),label:d?.planMode??"Plan mode",onSelect:l}):null):null)}function ju({detail:n,disabled:e=!1,icon:t,label:r,onSelect:o}){return f("button",{className:"add-context-item group no-drag hover:bg-token-list-hover-background focus:bg-token-list-hover-background cursor-interaction text-token-foreground outline-hidden rounded-lg px-[var(--padding-row-x)] py-[var(--padding-row-y)] text-sm",disabled:e,type:"button",role:"menuitem",onMouseDown:i=>i.preventDefault(),onClick:e?void 0:o},f("span",{className:"add-context-item-icon icon-xs shrink-0","aria-hidden":!0},t),f("span",{className:"add-context-item-copy flex min-w-0 flex-1 items-center gap-2"},f("span",{className:"add-context-item-label min-w-0 truncate"},r),n?f("span",{className:"add-context-item-detail min-w-0 flex-1 truncate text-token-description-foreground"},n):null))}function Ho({checked:n,icon:e,label:t,onSelect:r}){const o=n?"checked":"unchecked",i=n?"relative inline-flex h-4 w-7 shrink-0 items-center rounded-full bg-token-charts-blue transition-colors duration-200 ease-out":"relative inline-flex h-4 w-7 shrink-0 items-center rounded-full bg-token-foreground/10 transition-colors duration-200 ease-out";return f("button",{className:"add-context-item group no-drag hover:bg-token-list-hover-background focus:bg-token-list-hover-background cursor-interaction text-token-foreground outline-hidden rounded-lg px-[var(--padding-row-x)] py-[var(--padding-row-y)] text-sm",type:"button",role:"menuitemcheckbox","aria-checked":n,onMouseDown:s=>s.preventDefault(),onClick:r},f("span",{className:"add-context-item-icon icon-xs shrink-0","aria-hidden":!0},e),f("span",{className:"add-context-switch-row flex w-full min-w-0 items-center justify-between gap-2"},f("span",{className:"flex min-w-0 items-center gap-2"},f("span",{className:"add-context-item-label min-w-0 truncate"},t)),f("span",{className:i,"data-state":o,"aria-hidden":!0},f("span",{className:"h-3 w-3 rounded-full border border-[color:var(--gray-0)] bg-[color:var(--gray-0)] shadow-sm transition-transform duration-200 ease-out data-[state=unchecked]:translate-x-[2px] data-[state=checked]:translate-x-[14px]","data-state":o}))))}function Ku(){return f("div",{className:"add-context-separator w-full px-[var(--padding-row-x)] py-1",role:"separator"},f("div",{className:"add-context-separator-line h-[1px] w-full bg-token-menu-border"}))}function Zu(n){return Cr("mention",n,"").find(e=>e.id==="workspace")??null}function Cr(n,e,t){const r=e.context?.copy,o=n==="mention"?[...e.context?.workingDirectory?[{id:"workspace",icon:hp("icon-xs"),label:r?.mentionCurrentWorkspace??"Current workspace",detail:jt(e.context.workingDirectory),mention:{kind:"at",label:jt(e.context.workingDirectory),name:jt(e.context.workingDirectory),path:e.context.workingDirectory,fsPath:e.context.workingDirectory}}]:[],...e.providers.map(i=>({id:i.id,icon:Ia(i),label:i.displayName,detail:i.executableName,mention:{kind:"agent",label:i.displayName,name:i.id,displayName:i.displayName,path:`provider://${i.id}`,description:i.executableName}}))]:[{id:"plan",icon:"$",label:r?.skillPlan??"Plan",detail:"$plan",mention:{kind:"skill",label:"Plan",name:"plan",displayName:"Plan",path:"skill://plan"}},{id:"review",icon:"$",label:r?.skillCodeReview??"Code review",detail:"$codex-review",mention:{kind:"skill",label:"Code review",name:"codex-review",displayName:"Code review",path:"skill://codex-review"}},{id:"research",icon:"$",label:r?.skillResearch??"Research",detail:"$research",mention:{kind:"skill",label:"Research",name:"research",displayName:"Research",path:"skill://research"}}];return Yu(o,t)}function Yu(n,e){const t=e.trim();return t?n.map(r=>({item:r,score:ip(r.label,t,[r.detail,r.mention.name])})).filter(({score:r})=>r>0).sort((r,o)=>o.score-r.score).map(({item:r})=>r):n}const qo=1,Gu=.9,Xu=.8,Qu=.17,_n=.1,$n=.999,ep=.9999,tp=.99,np=/[\\/_+.#"@[({&]/,rp=/[\\/_+.#"@[({&]/g,op=/[\s-]/,Ts=/[\s-]/g;function ip(n,e,t){const r=t.length>0?`${n} ${t.join(" ")}`:n;return ir(r,e,Jo(r),Jo(e),0,0,{})}function Jo(n){return n.toLowerCase().replace(Ts," ")}function ir(n,e,t,r,o,i,s){if(i===e.length)return o===n.length?qo:tp;const a=`${o},${i}`;if(s[a]!==void 0)return s[a];const l=r.charAt(i);let c=t.indexOf(l,o),d=0;for(;c>=0;){let u=ir(n,e,t,r,c+1,i+1,s);if(u>d){if(c===o)u*=qo;else if(np.test(n.charAt(c-1))){u*=Xu;const p=n.slice(o,c-1).match(rp);p&&o>0&&(u*=$n**p.length)}else if(op.test(n.charAt(c-1))){u*=Gu;const p=n.slice(o,c-1).match(Ts);p&&o>0&&(u*=$n**p.length)}else u*=Qu,o>0&&(u*=$n**(c-o));n.charAt(c)!==e.charAt(i)&&(u*=ep)}if(u<_n&&t.charAt(c-1)===r.charAt(i+1)||r.charAt(i+1)===r.charAt(i)&&t.charAt(c-1)!==r.charAt(i)){const p=ir(n,e,t,r,c+1,i+2,s);p*_n>u&&(u=p*_n)}u>d&&(d=u),c=t.indexOf(l,c+1)}return s[a]=d,d}function sp(n,e){const t=Array.from(n),r=e.trim();if(!r)return t.map(l=>({isMatch:!0,text:l}));const o=t.map(l=>l.toLowerCase()),i=Array.from(r.toLowerCase());let s=-1;for(let l=0;l<=o.length-i.length;l+=1)if(i.every((c,d)=>o[l+d]===c)){s=l;break}if(s>=0){const l=s+i.length;return t.map((c,d)=>({isMatch:d>=s&&d{const c=a{const r=`${t.kind}:${t.path}`;return e.has(r)?!1:(e.add(r),!0)})}function lp(n="icon-sm"){return f("svg",{className:n,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},f("path",{d:"M9.33467 16.6663V4.93978L4.6374 9.63704L4.1667 9.16634L3.69599 8.69661L9.52998 2.86263L9.63447 2.77767C9.8925 2.60753 10.2433 2.63564 10.4704 2.86263L16.3034 8.69661L16.3884 8.80111C16.5588 9.05922 16.5306 9.40982 16.3034 9.63704C16.0762 9.86414 15.7255 9.89242 15.4675 9.722L15.363 9.63704L10.6647 4.9388V16.6663C10.6647 17.0336 10.367 17.3314 9.99971 17.3314C9.63259 17.3312 9.33467 17.0335 9.33467 16.6663ZM4.6374 9.63704C4.3777 9.89674 3.95569 9.89674 3.69599 9.63704C3.43657 9.37744 3.43668 8.95628 3.69599 8.69661L4.6374 9.63704Z",fill:"currentColor"}))}function cp(){return f("svg",{width:"16",height:"16",viewBox:"0 0 16 16",fill:"none","aria-hidden":!0},f("rect",{x:"4.75",y:"4.75",width:"6.5",height:"6.5",rx:"1",fill:"currentColor"}))}function dp(){return f("svg",{width:"16",height:"16",viewBox:"0 0 16 16",fill:"none","aria-hidden":!0},f("path",{d:"M8 2.25a2 2 0 0 0-2 2v3.25a2 2 0 1 0 4 0V4.25a2 2 0 0 0-2-2ZM4 7.5a4 4 0 0 0 8 0M8 11.5v2.25",stroke:"currentColor",strokeWidth:"1.4",strokeLinecap:"round"}))}function up(n="icon-sm"){return f("svg",{className:n,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},f("path",{d:"M10 5.14295V14.8441M10 5.14295C10 1.98209 14.8475 1.94468 14.8475 5.07797L14.8473 5.1067C17.1367 5.43539 18.0436 7.54498 16.4225 9.14006C17.8633 10.5577 17.3503 13.9054 15.4449 14.6301C14.8475 17.6527 10.8056 18.4821 10 14.8441M10 5.14295C10 1.98209 5.15249 1.94468 5.15249 5.07797L5.15265 5.1067C2.86325 5.43539 1.95642 7.54498 3.57746 9.14006C2.13674 10.5577 2.64974 13.9054 4.5551 14.6301C5.15249 17.6527 9.19444 18.4821 10 14.8441",stroke:"currentColor",strokeLinejoin:"round",strokeWidth:"1.33"}))}function gn(n="icon-2xs"){return f("svg",{className:n,width:"20",height:"21",viewBox:"0 0 20 21",fill:"none","aria-hidden":!0},f("path",{d:"M15.2793 7.71101C15.539 7.45131 15.961 7.45131 16.2207 7.71101C16.4804 7.97071 16.4804 8.39272 16.2207 8.65242L10.4707 14.4024C10.211 14.6621 9.78902 14.6621 9.52932 14.4024L3.77932 8.65242L3.69436 8.54792C3.52385 8.28979 3.55205 7.93828 3.77932 7.71101C4.00659 7.48374 4.3581 7.45554 4.61623 7.62605L4.72073 7.71101L10 12.9903L15.2793 7.71101Z",fill:"currentColor",stroke:"currentColor",strokeWidth:"0.6"}))}function pp(n="icon-2xs"){return f("svg",{className:n,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},f("path",{d:"M7.52925 3.7793C7.75652 3.55203 8.10803 3.52383 8.36616 3.69434L8.47065 3.7793L14.2207 9.5293C14.4804 9.789 14.4804 10.211 14.2207 10.4707L8.47065 16.2207C8.21095 16.4804 7.78895 16.4804 7.52925 16.2207C7.26955 15.961 7.26955 15.539 7.52925 15.2793L12.8085 10L7.52925 4.7207L7.44429 4.61621C7.27378 4.35808 7.30198 4.00657 7.52925 3.7793Z",fill:"currentColor"}))}function xn(n="icon-sm"){return f("svg",{className:n,width:"21",height:"21",viewBox:"0 0 21 21",fill:"none","aria-hidden":!0},f("path",{d:"M13.468 11.1216C13.468 10.4107 13.468 9.91717 13.4367 9.53369C13.4137 9.25191 13.3758 9.0622 13.3244 8.91846L13.2687 8.78858C13.1148 8.48652 12.8803 8.23344 12.593 8.05713L12.466 7.98584C12.308 7.90546 12.0963 7.84854 11.7209 7.81787C11.3374 7.78656 10.8439 7.78662 10.133 7.78662H7.29999C6.58895 7.78662 6.09562 7.78654 5.7121 7.81787C5.43015 7.84091 5.24064 7.87872 5.09686 7.93018L4.96698 7.98584C4.66487 8.13977 4.41184 8.37419 4.23554 8.66162L4.16522 8.78858C4.08477 8.94657 4.02794 9.15811 3.99725 9.53369C3.96594 9.91718 3.96503 10.4107 3.96503 11.1216V13.9546C3.96503 14.6656 3.96592 15.159 3.99725 15.5425C4.02796 15.9182 4.08471 16.1296 4.16522 16.2876L4.23554 16.4136C4.41185 16.7012 4.66472 16.9353 4.96698 17.0894L5.09686 17.146C5.24061 17.1974 5.43024 17.2343 5.7121 17.2573C6.09562 17.2887 6.58895 17.2896 7.29999 17.2896H10.133C10.8439 17.2896 11.3374 17.2886 11.7209 17.2573C12.0965 17.2266 12.308 17.1698 12.466 17.0894L12.593 17.019C12.8804 16.8427 13.1148 16.5897 13.2687 16.2876L13.3244 16.1577C13.3759 16.0139 13.4137 15.8244 13.4367 15.5425C13.468 15.159 13.468 14.6656 13.468 13.9546V11.1216ZM14.798 13.1196C15.2528 13.118 15.6011 13.1147 15.8879 13.0913C16.2634 13.0606 16.475 13.0038 16.633 12.9233L16.759 12.8521C17.0466 12.6757 17.2808 12.4228 17.4348 12.1206L17.4914 11.9907C17.5428 11.847 17.5797 11.6572 17.6027 11.3755C17.634 10.992 17.6349 10.4985 17.6349 9.7876V6.95459C17.6349 6.24355 17.6341 5.75022 17.6027 5.3667C17.5797 5.08484 17.5428 4.89522 17.4914 4.75147L17.4348 4.62158C17.2807 4.31933 17.0466 4.06645 16.759 3.89014L16.633 3.81982C16.475 3.73932 16.2636 3.68256 15.8879 3.65186C15.5044 3.62052 15.011 3.61963 14.3 3.61963H11.467C10.7561 3.61963 10.2626 3.62054 9.87909 3.65186C9.59738 3.67487 9.40759 3.71179 9.26386 3.76318L9.13397 3.81982C8.83175 3.97382 8.57885 4.20802 8.40253 4.49561L8.33124 4.62158C8.25079 4.77957 8.19396 4.99114 8.16327 5.3667C8.13984 5.65352 8.13561 6.00178 8.13397 6.45654H10.133C10.822 6.45654 11.3791 6.4559 11.8293 6.49268C12.2873 6.5301 12.6937 6.6093 13.0705 6.80127L13.2883 6.92334C13.7839 7.22739 14.1878 7.66313 14.4533 8.18408L14.5197 8.32666C14.6642 8.66318 14.7291 9.02433 14.7619 9.42529C14.7987 9.8755 14.798 10.4326 14.798 11.1216V13.1196ZM18.965 9.7876C18.965 10.4766 18.9657 11.0337 18.9289 11.4839C18.8961 11.8848 18.8311 12.246 18.6867 12.5825L18.6203 12.7251C18.3548 13.246 17.9509 13.6818 17.4553 13.9858L17.2365 14.1079C16.8599 14.2998 16.4541 14.3791 15.9963 14.4165C15.6592 14.444 15.2624 14.4481 14.7951 14.4497C14.7935 14.917 14.7894 15.3138 14.7619 15.6509C14.7292 16.0516 14.664 16.4122 14.5197 16.7485L14.4533 16.8911C14.1878 17.4122 13.7841 17.8487 13.2883 18.1528L13.0705 18.2749C12.6937 18.4669 12.2873 18.5461 11.8293 18.5835C11.3791 18.6203 10.822 18.6196 10.133 18.6196H7.29999C6.6109 18.6196 6.05394 18.6203 5.6037 18.5835C5.20305 18.5508 4.84233 18.4855 4.50604 18.3413L4.36347 18.2749C3.84243 18.0094 3.40584 17.6056 3.10175 17.1099L2.97968 16.8911C2.78787 16.5145 2.70849 16.1087 2.67108 15.6509C2.6343 15.2006 2.63495 14.6437 2.63495 13.9546V11.1216C2.63495 10.4326 2.63431 9.8755 2.67108 9.42529C2.7085 8.96729 2.78771 8.56084 2.97968 8.18408L3.10175 7.96631C3.40585 7.47049 3.84235 7.06679 4.36347 6.80127L4.50604 6.73486C4.84236 6.59059 5.20302 6.52542 5.6037 6.49268C5.9405 6.46516 6.33707 6.4601 6.80389 6.4585C6.8055 5.99167 6.81056 5.5951 6.83807 5.2583C6.87549 4.80047 6.95482 4.39471 7.14667 4.01807L7.26874 3.79932C7.5728 3.30371 8.00855 2.89973 8.52948 2.63428L8.67206 2.56787C9.00854 2.42345 9.36978 2.35844 9.77069 2.32568C10.2209 2.28891 10.778 2.28955 11.467 2.28955H14.3C14.9891 2.28955 15.546 2.2889 15.9963 2.32568C16.4541 2.3631 16.8599 2.44247 17.2365 2.63428L17.4553 2.75635C17.951 3.06044 18.3548 3.49703 18.6203 4.01807L18.6867 4.16065C18.8309 4.49694 18.8962 4.85765 18.9289 5.2583C18.9657 5.70854 18.965 6.2655 18.965 6.95459V9.7876Z",fill:"currentColor"}))}function Ze(n){return f("svg",{className:n,width:"17",height:"17",viewBox:"0 0 17 17",fill:"none","aria-hidden":!0},f("path",{d:"M12.8961 3.64101C13.1297 3.41418 13.4984 3.37523 13.7779 3.56581C14.0571 3.75635 14.1554 4.11331 14.0299 4.41347L13.9615 4.53847L7.71151 13.7045C7.59411 13.8767 7.4063 13.9877 7.19881 14.0072C6.99136 14.0267 6.78564 13.9533 6.63826 13.806L2.88826 10.056L2.79842 9.9457C2.6192 9.67407 2.64927 9.30496 2.88826 9.06581C3.12738 8.82669 3.49647 8.79676 3.76815 8.97597L3.8785 9.06581L7.03084 12.2182L12.8053 3.74941L12.8961 3.64101Z",fill:"currentColor"}))}function bn(n="icon-sm"){return f("svg",{className:n,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},f("path",{d:"M5.96967 5.96967C6.26256 5.67678 6.73744 5.67678 7.03033 5.96967L10 8.93934L12.9697 5.96967C13.2626 5.67678 13.7374 5.67678 14.0303 5.96967C14.3232 6.26256 14.3232 6.73744 14.0303 7.03033L11.0607 10L14.0303 12.9697C14.3232 13.2626 14.3232 13.7374 14.0303 14.0303C13.7374 14.3232 13.2626 14.3232 12.9697 14.0303L10 11.0607L7.03033 14.0303C6.73744 14.3232 6.26256 14.3232 5.96967 14.0303C5.67678 13.7374 5.67678 13.2626 5.96967 12.9697L8.93934 10L5.96967 7.03033C5.67678 6.73744 5.67678 6.26256 5.96967 5.96967Z",fill:"currentColor"}))}function fp(){return f("svg",{className:"icon-sm",width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},f("path",{d:"M9.33496 16.5V10.665H3.5C3.13273 10.665 2.83496 10.3673 2.83496 10C2.83496 9.63273 3.13273 9.33496 3.5 9.33496H9.33496V3.5C9.33496 3.13273 9.63273 2.83496 10 2.83496C10.3673 2.83496 10.665 3.13273 10.665 3.5V9.33496H16.5L16.6338 9.34863C16.9369 9.41057 17.165 9.67857 17.165 10C17.165 10.3214 16.9369 10.5894 16.6338 10.6514L16.5 10.665H10.665V16.5C10.665 16.8673 10.3673 17.165 10 17.165C9.63273 17.165 9.33496 16.8673 9.33496 16.5Z",fill:"currentColor"}))}function Sr(n="icon-sm"){return f("svg",{className:n,width:"21",height:"21",viewBox:"0 0 21 21",fill:"none","aria-hidden":!0},f("path",{d:"M4.43945 12.8041V7.68261C4.43945 7.30642 4.74446 7.00141 5.12066 7.00141C5.49685 7.00141 5.80186 7.30642 5.80186 7.68261V12.8041C5.80186 15.2565 7.78984 17.2445 10.2422 17.2445C12.6945 17.2445 14.6825 15.2565 14.6825 12.8041V5.9751C14.6823 4.46587 13.4589 3.24247 11.9497 3.24229C10.4403 3.24229 9.21606 4.46576 9.21588 5.9751V12.8041C9.21588 13.3708 9.67553 13.8304 10.2422 13.8304C10.8088 13.8304 11.2685 13.3708 11.2685 12.8041V7.68261C11.2685 7.30642 11.5735 7.00141 11.9497 7.00141C12.3257 7.00159 12.6309 7.30653 12.6309 7.68261V12.8041C12.6309 14.1232 11.5612 15.1929 10.2422 15.1929C8.92314 15.1929 7.85347 14.1232 7.85347 12.8041V5.9751C7.85365 3.71337 9.68791 1.87988 11.9497 1.87988C14.2113 1.88006 16.0447 3.71348 16.0449 5.9751V12.8041C16.0449 16.0089 13.4469 18.6069 10.2422 18.6069C7.03745 18.6069 4.43945 16.0089 4.43945 12.8041Z",fill:"currentColor"}))}function hp(n="icon-sm"){return f("svg",{className:n,width:20,height:20,viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},f("path",{d:Zo,fill:"currentColor"}))}function an(n="icon-sm"){return f("svg",{className:n,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},f("path",{fillRule:"evenodd",clipRule:"evenodd",d:"M5.69336 11.0557C7.05891 11.1944 8.12484 12.3479 8.125 13.75L8.11035 14.0273C7.97144 15.3928 6.81814 16.459 5.41602 16.459L5.13965 16.4443C3.86514 16.3149 2.85128 15.3018 2.72168 14.0273L2.70801 13.75C2.70818 12.2546 3.92061 11.0423 5.41602 11.042L5.69336 11.0557ZM5.41602 12.3721C4.65515 12.3724 4.03826 12.9891 4.03809 13.75C4.03826 14.5109 4.65515 15.1286 5.41602 15.1289C6.17714 15.1289 6.79475 14.5111 6.79492 13.75C6.79475 12.9889 6.17714 12.3721 5.41602 12.3721Z",fill:"currentColor"}),f("path",{d:"M16.8008 13.0986C17.1036 13.1608 17.3311 13.4288 17.3311 13.75C17.3311 14.0712 17.1036 14.3392 16.8008 14.4014L16.666 14.415H10.833C10.4659 14.4149 10.168 14.1172 10.168 13.75C10.168 13.3828 10.4659 13.0851 10.833 13.085H16.666L16.8008 13.0986Z",fill:"currentColor"}),f("path",{d:"M16.8008 5.59863C17.1036 5.66081 17.3311 5.92879 17.3311 6.25C17.3311 6.57121 17.1036 6.83919 16.8008 6.90137L16.666 6.91504H10.833C10.4659 6.91491 10.168 6.61719 10.168 6.25C10.168 5.88281 10.4659 5.58509 10.833 5.58496H16.666L16.8008 5.59863Z",fill:"currentColor"}),f("path",{d:"M7.13311 3.76578C7.35346 3.47216 7.771 3.4128 8.06475 3.63297C8.35843 3.85336 8.41789 4.27084 8.19757 4.56461L5.19757 8.56461C5.0819 8.71866 4.90439 8.81462 4.71221 8.82828C4.5201 8.84178 4.33083 8.77209 4.19464 8.6359L2.69464 7.1359C2.43512 6.87623 2.43512 6.45415 2.69464 6.19449C2.95429 5.93484 3.37633 5.93493 3.63604 6.19449L4.59307 7.15152L7.13311 3.76578Z",fill:"currentColor"}))}function mp(n="icon-sm"){return f("svg",{className:n,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},f("path",{d:"M12.6683 4.16699C12.6683 3.84391 12.4065 3.58203 12.0834 3.58203C11.7603 3.58203 11.4984 3.84391 11.4984 4.16699V7.91699L11.4847 8.05078C11.4227 8.35375 11.1547 8.58203 10.8334 8.58203C10.4662 8.58203 10.1685 8.28411 10.1683 7.91699V3.75C10.1683 3.42691 9.90646 3.16504 9.58337 3.16504C9.26029 3.16504 8.99841 3.42691 8.99841 3.75V7.91699C8.99824 8.28411 8.70053 8.58203 8.33337 8.58203C7.96621 8.58203 7.66851 8.28411 7.66833 7.91699V5C7.66833 4.67691 7.40646 4.41504 7.08337 4.41504C6.76029 4.41504 6.49841 4.67691 6.49841 5V9.30371C6.53326 9.3429 6.56715 9.38359 6.59998 9.42578L8.02478 11.2588C8.25005 11.5486 8.19821 11.9659 7.90857 12.1914C7.6187 12.4169 7.20048 12.365 6.97498 12.0752L5.55017 10.2432C5.15812 9.7391 4.41813 9.73637 4.01501 10.1924C4.04396 10.426 4.11486 10.8323 4.25525 11.3486C4.44664 12.0525 4.75404 12.9113 5.21619 13.7383C6.14103 15.3931 7.62465 16.835 10.0004 16.835C12.8545 16.8348 15.1682 14.5211 15.1683 11.667V6.25C15.1683 5.92691 14.9065 5.66504 14.5834 5.66504C14.2603 5.66504 13.9984 5.92691 13.9984 6.25V9.16699C13.9982 9.53411 13.7005 9.83203 13.3334 9.83203C12.9662 9.83203 12.6685 9.53411 12.6683 9.16699V4.16699ZM13.9984 4.42578C14.1828 4.36671 14.3794 4.33496 14.5834 4.33496C15.641 4.33496 16.4984 5.19237 16.4984 6.25V11.667C16.4982 15.2557 13.589 18.1649 10.0004 18.165C6.95953 18.165 5.10939 16.2734 4.05505 14.3867C3.52774 13.4431 3.1843 12.4787 2.97205 11.6982C2.76447 10.9349 2.66834 10.2954 2.66833 10C2.66833 9.87959 2.70117 9.76148 2.76306 9.6582C3.28988 8.78018 4.26555 8.40372 5.16833 8.56152V5C5.16833 3.94237 6.02575 3.08496 7.08337 3.08496C7.31706 3.08496 7.54039 3.12845 7.74744 3.20508C7.98218 2.41297 8.7151 1.83496 9.58337 1.83496C10.1836 1.83496 10.7186 2.11176 11.0697 2.54395C11.3639 2.35978 11.7107 2.25195 12.0834 2.25195C13.141 2.25195 13.9984 3.10937 13.9984 4.16699V4.42578Z",fill:"currentColor"}))}function gp(n="icon-sm"){return f("svg",{className:n,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},f("path",{fillRule:"evenodd",clipRule:"evenodd",d:"M9.06543 1.95123C9.66107 1.69076 10.3389 1.69071 10.9346 1.95123L15.9346 4.13873C16.7832 4.51008 17.3311 5.34917 17.3311 6.27545V10.5528C17.3309 14.6017 14.0489 17.8847 10 17.8848C5.95108 17.8846 2.66813 14.6017 2.66797 10.5528V6.27545C2.66797 5.34924 3.21695 4.51012 4.06543 4.13873L9.06543 1.95123ZM10.4014 3.16998C10.1456 3.05814 9.85444 3.05819 9.59863 3.16998L4.59863 5.35748C4.23427 5.51708 3.99805 5.87764 3.99805 6.27545V10.5528C3.99821 13.8671 6.68563 16.5546 10 16.5547C13.3144 16.5546 16.0008 13.8671 16.001 10.5528V6.27545C16.001 5.87756 15.7658 5.51703 15.4014 5.35748L10.4014 3.16998Z",fill:"currentColor"}),f("path",{d:"M13.4678 11.4318L13.333 11.4182H10.833C10.466 11.4183 10.1682 11.7162 10.168 12.0832C10.168 12.4504 10.4659 12.7481 10.833 12.7482H13.333L13.4678 12.7346C13.7706 12.6724 13.9981 12.4044 13.9981 12.0832C13.9979 11.7621 13.7706 11.494 13.4678 11.4318Z",fill:"currentColor"}),f("path",{d:"M7.65336 12.426C7.46431 12.7406 7.05607 12.8424 6.74125 12.6535C6.42646 12.4646 6.32395 12.0563 6.51274 11.7414L7.55668 10.0002L6.51274 8.25899C6.32395 7.94412 6.42646 7.53583 6.74125 7.34688C7.05607 7.15799 7.46431 7.25975 7.65336 7.57442L8.90336 9.6584C9.0296 9.86893 9.0296 10.1315 8.90336 10.342L7.65336 12.426Z",fill:"currentColor"}))}function xp(n="icon-sm"){return f("svg",{className:n,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},f("path",{fillRule:"evenodd",clipRule:"evenodd",d:"M9.06543 1.95123C9.66107 1.69076 10.3389 1.69071 10.9346 1.95123L15.9346 4.13873C16.7832 4.51008 17.3311 5.34917 17.3311 6.27545V10.5528C17.3309 14.6017 14.0489 17.8847 10 17.8848C5.95108 17.8846 2.66813 14.6017 2.66797 10.5528V6.27545C2.66797 5.34924 3.21695 4.51012 4.06543 4.13873L9.06543 1.95123ZM10.4014 3.16998C10.1456 3.05814 9.85444 3.05819 9.59863 3.16998L4.59863 5.35748C4.23427 5.51708 3.99805 5.87764 3.99805 6.27545V10.5528C3.99821 13.8671 6.68563 16.5546 10 16.5547C13.3144 16.5546 16.0008 13.8671 16.001 10.5528V6.27545C16.001 5.87756 15.7658 5.51703 15.4014 5.35748L10.4014 3.16998Z",fill:"currentColor"}),f("path",{d:"M10.8883 13.1116C10.8883 13.6025 10.4903 14.0005 9.99936 14.0005C9.50844 14.0005 9.11047 13.6025 9.11047 13.1116C9.11047 12.6207 9.50844 12.2227 9.99936 12.2227C10.4903 12.2227 10.8883 12.6207 10.8883 13.1116Z",fill:"currentColor"}),f("path",{d:"M10.5169 10.8949L11.1135 7.31519C11.2283 6.62672 10.6974 6 9.99941 6C9.30145 6 8.77053 6.62672 8.88528 7.31519L9.4819 10.8949C9.52406 11.1479 9.74294 11.3333 9.99941 11.3333C10.2559 11.3333 10.4748 11.1479 10.5169 10.8949Z",fill:"currentColor"}))}function bp(n="icon-sm"){return f("svg",{className:n,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},f("path",{fillRule:"evenodd",clipRule:"evenodd",d:"M9.99944 7.24939C11.5169 7.2495 12.7473 8.47995 12.7475 9.99744C12.7475 11.5151 11.517 12.7454 9.99944 12.7455C8.48176 12.7455 7.2514 11.5151 7.2514 9.99744C7.25155 8.47988 8.48186 7.24939 9.99944 7.24939ZM9.99944 8.57947C9.2164 8.57947 8.58163 9.21442 8.58148 9.99744C8.58148 10.7806 9.2163 11.4154 9.99944 11.4154C10.7825 11.4153 11.4174 10.7805 11.4174 9.99744C11.4173 9.21449 10.7824 8.57958 9.99944 8.57947Z",fill:"currentColor"}),f("path",{fillRule:"evenodd",clipRule:"evenodd",d:"M10.6391 1.67517C11.2939 1.67532 11.8991 2.02577 12.226 2.59314L13.2485 4.36755H15.2963C15.9505 4.36758 16.555 4.71709 16.8823 5.28357L17.5219 6.39001C17.8489 6.95668 17.8481 7.65542 17.5209 8.22205L16.4975 9.99451L17.5239 11.7689C17.8519 12.3357 17.8521 13.0347 17.5248 13.6019L16.8862 14.7084C16.559 15.2747 15.9543 15.6243 15.3002 15.6244H13.2514L12.2299 17.3988C11.9029 17.9663 11.297 18.3168 10.642 18.3168L9.3637 18.3158C8.71064 18.3155 8.10718 17.9678 7.77972 17.4027L6.74847 15.6234L4.69964 15.6244C4.04558 15.6242 3.44087 15.2747 3.1137 14.7084L2.47503 13.6019C2.14791 13.0349 2.14836 12.3366 2.47601 11.7699L3.50237 9.99548L2.47894 8.22205C2.15175 7.65533 2.15174 6.95673 2.47894 6.39001L3.11761 5.28259C3.44458 4.71663 4.04894 4.36813 4.70257 4.36755L6.75042 4.36658L7.77581 2.59119C8.10301 2.02476 8.7076 1.67527 9.36175 1.67517H10.6391ZM9.36273 3.00623C9.1835 3.00623 9.01679 3.10199 8.92718 3.2572L7.82659 5.16345C7.63652 5.49253 7.28473 5.69529 6.90472 5.69568L4.70355 5.69763C4.52451 5.69782 4.3585 5.79355 4.26898 5.94861L3.6303 7.05505C3.54091 7.2102 3.54077 7.40192 3.6303 7.55701L4.73089 9.46326C4.92108 9.7929 4.92135 10.1992 4.73089 10.5287L3.62737 12.4359C3.5378 12.591 3.53792 12.7817 3.62737 12.9369L4.26605 14.0433C4.35567 14.1982 4.52067 14.2932 4.69964 14.2933L6.90276 14.2943C7.28242 14.2946 7.63335 14.497 7.82366 14.8256L8.93011 16.7357C9.01984 16.8905 9.18578 16.9857 9.36468 16.9857H10.642C10.8213 16.9857 10.987 16.89 11.0766 16.7347L12.1752 14.8275C12.3653 14.4975 12.7182 14.2943 13.0991 14.2943H15.3002C15.4794 14.2942 15.6452 14.1985 15.7348 14.0433L16.3725 12.9379C16.4621 12.7826 16.4621 12.5911 16.3725 12.4359L15.27 10.5287C15.1032 10.2404 15.0808 9.89331 15.2055 9.59021L15.269 9.46326L16.3696 7.55701C16.4591 7.40189 16.459 7.21022 16.3696 7.05505L15.7309 5.94861C15.6412 5.79363 15.4754 5.69863 15.2963 5.69861L13.0951 5.69763L12.9535 5.68884C12.6751 5.65158 12.4217 5.50519 12.2504 5.28259L12.1723 5.16443L11.0737 3.2572C10.9841 3.10175 10.8175 3.00525 10.6381 3.00525L9.36273 3.00623Z",fill:"currentColor"}))}function Es(n="icon-sm"){return f("svg",{className:n,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},f("path",{d:"M10.6878 9.46029L10.8421 9.49545L17.2913 11.43L17.4642 11.4974C18.2215 11.8649 18.2705 12.9544 17.5492 13.388L17.3822 13.4701L14.5872 14.5872L13.4701 17.3822C13.1135 18.2734 11.8913 18.2756 11.4974 17.4642L11.43 17.2913L9.49544 10.8421C9.26342 10.0687 9.92452 9.34418 10.6878 9.46029ZM12.4984 16.2288L13.3929 13.9954L13.4388 13.8949C13.5579 13.6675 13.7549 13.4891 13.9954 13.3929L16.2288 12.4984L10.9007 10.9007L12.4984 16.2288ZM5.90365 12.9749C6.16329 12.7153 6.58436 12.7154 6.84408 12.9749C7.10378 13.2346 7.10378 13.6557 6.84408 13.9154L5.0765 15.6829C4.8168 15.9426 4.39577 15.9426 4.13607 15.6829C3.87654 15.4232 3.87643 15.0022 4.13607 14.7425L5.90365 12.9749ZM2.83724 7.3265L5.25228 7.97299L5.37826 8.02084C5.65484 8.1591 5.80597 8.47712 5.72298 8.78744C5.63984 9.09774 5.34997 9.298 5.04134 9.27963L4.90853 9.25814L2.49349 8.61068L2.36752 8.56283C2.09082 8.42452 1.93961 8.10666 2.02279 7.79623C2.10599 7.4859 2.39574 7.28652 2.70443 7.30502L2.83724 7.3265ZM14.847 4.05111C15.1051 3.88059 15.4556 3.90894 15.6829 4.13607C15.9426 4.39577 15.9426 4.8168 15.6829 5.0765L13.9154 6.84408C13.6557 7.10378 13.2346 7.10378 12.9749 6.84408C12.7154 6.58437 12.7153 6.16329 12.9749 5.90365L14.7425 4.13607L14.847 4.05111ZM7.79623 2.02279C8.15098 1.92773 8.51562 2.13874 8.61068 2.49349L9.25814 4.90853L9.27962 5.04135C9.298 5.34998 9.09774 5.63984 8.78744 5.72299C8.47713 5.80592 8.15908 5.65484 8.02084 5.37826L7.97298 5.25228L7.3265 2.83724L7.30502 2.70443C7.28652 2.39577 7.48595 2.10603 7.79623 2.02279Z",fill:"currentColor"}))}const yp='@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-outline-style:solid;--top-fade:0;--bottom-fade:0;--edge-fade-distance:1rem}}}@layer theme{:root,:host{--font-sans:var(--vscode-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);--font-mono:var(--vscode-editor-font-family,ui-monospace, "SFMono-Regular", "SF Mono", Menlo, Consolas, monospace);--color-black:#000;--spacing:.25rem;--text-xs:11px;--text-xs--line-height:calc(1 / .75);--text-sm:12px;--text-sm--line-height:calc(1.25 / .875);--text-base:14px;--text-base--line-height: 1.5 ;--font-weight-medium:500;--radius-md:calc(var(--radius-md-base) * var(--corner-radius-scale));--radius-lg:calc(var(--radius-lg-base) * var(--corner-radius-scale));--radius-xl:calc(var(--radius-xl-base) * var(--corner-radius-scale));--radius-2xl:calc(var(--radius-2xl-base) * var(--corner-radius-scale));--radius-3xl:calc(var(--radius-3xl-base) * var(--corner-radius-scale));--shadow-xl:0px 8px 16px -4px #0000001f;--ease-out:cubic-bezier(0, 0, .2, 1);--blur-sm:8px;--blur-lg:16px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-token-foreground:var(--agent-text);--color-token-description-foreground:var(--agent-muted);--color-token-input-background:var(--agent-input-bg);--color-token-dropdown-background:var(--agent-surface-elevated);--color-token-border:var(--agent-border);--color-token-border-heavy:var(--agent-border-strong);--color-token-button-foreground:var(--agent-text);--color-token-button-background:var(--agent-accent);--color-token-button-secondary-foreground:var(--agent-text);--color-token-button-secondary-background:var(--agent-control);--color-token-button-secondary-hover-background:var(--agent-control-hover);--color-token-input-placeholder-foreground:var(--agent-muted);--color-token-charts-blue:var(--agent-accent);--color-token-text-tertiary:var(--agent-muted);--color-token-menu-border:var(--agent-border);--color-token-list-hover-background:var(--agent-control-hover);--color-token-side-bar-background:var(--agent-surface-elevated);--color-token-text-link-foreground:var(--agent-accent);--color-token-editor-background:var(--agent-page-bg);--corner-radius-scale:1;--radius-md-base:.5rem;--radius-lg-base:.625rem;--radius-xl-base:.75rem;--radius-2xl-base:1rem;--radius-3xl-base:1.25rem;--spacing-token-button-composer:calc(var(--spacing) * 7);--spacing-token-button-composer-sm:calc(var(--spacing) * 5);--spacing-token-button-composer-gap:var(--spacing);--h-token-button-composer-gap:var(--spacing-token-button-composer-gap)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.relative{position:relative}.inset-x-0{inset-inline:calc(var(--spacing) * 0)}.top-0{top:calc(var(--spacing) * 0)}.top-1\\/2{top:50%}.right-2\\.5{right:calc(var(--spacing) * 2.5)}.bottom-0{bottom:calc(var(--spacing) * 0)}.bottom-\\[calc\\(100\\%\\+6px\\)\\]{bottom:calc(100% + 6px)}.bottom-full{bottom:100%}.left-0{left:calc(var(--spacing) * 0)}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.container{width:100%}@media(min-width:40rem){.container{max-width:40rem}}@media(min-width:48rem){.container{max-width:48rem}}@media(min-width:64rem){.container{max-width:64rem}}@media(min-width:80rem){.container{max-width:80rem}}@media(min-width:96rem){.container{max-width:96rem}}.m-px{margin:1px}.ms-1{margin-inline-start:calc(var(--spacing) * 1)}.mt-1\\.5{margin-top:calc(var(--spacing) * 1.5)}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-\\[3px\\]{margin-right:3px}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-px{margin-bottom:1px}.ml-1\\.5{margin-left:calc(var(--spacing) * 1.5)}.ml-auto{margin-left:auto}.box-border{box-sizing:border-box}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.aspect-square{aspect-ratio:1}.size-16{width:calc(var(--spacing) * 16);height:calc(var(--spacing) * 16)}.size-\\[22px\\]{width:22px;height:22px}.size-token-button-composer{width:var(--spacing-token-button-composer);height:var(--spacing-token-button-composer)}.\\!h-auto{height:auto!important}.h-0{height:calc(var(--spacing) * 0)}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-\\[1lh\\]{height:1lh}.h-\\[1px\\]{height:1px}.h-auto{height:auto}.h-full{height:100%}.h-token-button-composer{height:var(--spacing-token-button-composer)}.h-token-button-composer-sm{height:var(--spacing-token-button-composer-sm)}.max-h-\\[25dvh\\]{max-height:25dvh}.max-h-\\[140px\\]{max-height:140px}.max-h-none{max-height:none}.min-h-0{min-height:calc(var(--spacing) * 0)}.min-h-\\[1\\.25rem\\]{min-height:1.25rem}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-7{width:calc(var(--spacing) * 7)}.w-52{width:calc(var(--spacing) * 52)}.w-fit{width:fit-content}.w-full{width:100%}.w-max{width:max-content}.w-px{width:1px}.max-w-16{max-width:calc(var(--spacing) * 16)}.max-w-20{max-width:calc(var(--spacing) * 20)}.max-w-40{max-width:calc(var(--spacing) * 40)}.max-w-\\[60\\%\\]{max-width:60%}.max-w-\\[77\\%\\]{max-width:77%}.max-w-\\[320px\\]{max-width:320px}.max-w-full{max-width:100%}.max-w-none{max-width:none}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-56{min-width:calc(var(--spacing) * 56)}.min-w-max{min-width:max-content}.flex-1{flex:1}.flex-none{flex:none}.flex-shrink-0{flex-shrink:0}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.-translate-y-1\\/2{--tw-translate-y: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.rotate-90{rotate:90deg}.rotate-180{rotate:180deg}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-\\[auto_minmax\\(0\\,1fr\\)_auto\\]{grid-template-columns:auto minmax(0,1fr) auto}.grid-cols-\\[minmax\\(0\\,auto\\)_auto_minmax\\(0\\,1fr\\)\\]{grid-template-columns:minmax(0,auto) auto minmax(0,1fr)}.flex-col{flex-direction:column}.flex-row-reverse{flex-direction:row-reverse}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0\\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-\\[5px\\]{gap:5px}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-clip{overflow:clip}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-3xl{border-radius:var(--radius-3xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-\\[color\\:var\\(--gray-0\\)\\]{border-color:var(--gray-0)}.border-token-border,.border-token-border\\/80{border-color:var(--color-token-border)}@supports (color:color-mix(in lab,red,red)){.border-token-border\\/80{border-color:color-mix(in oklab,var(--color-token-border) 80%,transparent)}}.border-transparent{border-color:#0000}.bg-\\[color\\:var\\(--gray-0\\)\\]{background-color:var(--gray-0)}.bg-token-border\\/70{background-color:var(--color-token-border)}@supports (color:color-mix(in lab,red,red)){.bg-token-border\\/70{background-color:color-mix(in oklab,var(--color-token-border) 70%,transparent)}}.bg-token-button-secondary-background{background-color:var(--color-token-button-secondary-background)}.bg-token-charts-blue{background-color:var(--color-token-charts-blue)}.bg-token-dropdown-background,.bg-token-dropdown-background\\/90{background-color:var(--color-token-dropdown-background)}@supports (color:color-mix(in lab,red,red)){.bg-token-dropdown-background\\/90{background-color:color-mix(in oklab,var(--color-token-dropdown-background) 90%,transparent)}}.bg-token-dropdown-background\\/95{background-color:var(--color-token-dropdown-background)}@supports (color:color-mix(in lab,red,red)){.bg-token-dropdown-background\\/95{background-color:color-mix(in oklab,var(--color-token-dropdown-background) 95%,transparent)}}.bg-token-foreground,.bg-token-foreground\\/5{background-color:var(--color-token-foreground)}@supports (color:color-mix(in lab,red,red)){.bg-token-foreground\\/5{background-color:color-mix(in oklab,var(--color-token-foreground) 5%,transparent)}}.bg-token-foreground\\/10{background-color:var(--color-token-foreground)}@supports (color:color-mix(in lab,red,red)){.bg-token-foreground\\/10{background-color:color-mix(in oklab,var(--color-token-foreground) 10%,transparent)}}.bg-token-input-background\\/90{background-color:var(--color-token-input-background)}@supports (color:color-mix(in lab,red,red)){.bg-token-input-background\\/90{background-color:color-mix(in oklab,var(--color-token-input-background) 90%,transparent)}}.bg-token-menu-border{background-color:var(--color-token-menu-border)}.bg-token-side-bar-background{background-color:var(--color-token-side-bar-background)}.\\[mask-image\\:linear-gradient\\(to_right\\,black_calc\\(100\\%_-_0\\.25rem\\)\\,transparent\\)\\]{-webkit-mask-image:linear-gradient(90deg,#000 calc(100% - .25rem),#0000);mask-image:linear-gradient(90deg,#000 calc(100% - .25rem),#0000)}.\\[mask-repeat\\:no-repeat\\]{-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.object-cover{object-fit:cover}.p-0\\.5{padding:calc(var(--spacing) * .5)}.p-2{padding:calc(var(--spacing) * 2)}.\\!px-0{padding-inline:calc(var(--spacing) * 0)!important}.px-0\\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-\\[var\\(--padding-row-x\\)\\]{padding-inline:var(--padding-row-x)}.py-0{padding-block:calc(var(--spacing) * 0)}.py-0\\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-\\[var\\(--padding-row-y\\)\\]{padding-block:var(--padding-row-y)}.pt-0\\.5{padding-top:calc(var(--spacing) * .5)}.pr-0{padding-right:calc(var(--spacing) * 0)}.pr-1{padding-right:calc(var(--spacing) * 1)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pb-1{padding-bottom:calc(var(--spacing) * 1)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.text-left{text-align:left}.align-bottom{vertical-align:bottom}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.\\!leading-none{--tw-leading:1!important;line-height:1!important}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-\\[18px\\]{--tw-leading:18px;line-height:18px}.leading-\\[calc\\(var\\(--codex-chat-font-size\\)\\+8px\\)\\]{--tw-leading:calc(var(--codex-chat-font-size) + 8px);line-height:calc(var(--codex-chat-font-size) + 8px)}.leading-none{--tw-leading:1;line-height:1}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.break-words{overflow-wrap:break-word}.text-ellipsis{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.text-current{color:currentColor}.text-token-button-secondary-foreground{color:var(--color-token-button-secondary-foreground)}.text-token-description-foreground{color:var(--color-token-description-foreground)}.text-token-dropdown-background{color:var(--color-token-dropdown-background)}.text-token-foreground{color:var(--color-token-foreground)}.text-token-input-placeholder-foreground{color:var(--color-token-input-placeholder-foreground)}.text-token-text-tertiary{color:var(--color-token-text-tertiary)}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-100{opacity:1}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0px 2px 4px -1px var(--tw-shadow-color,#00000014);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-\\[0\\.5px\\]{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(.5px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-token-border{--tw-ring-color:var(--color-token-border)}.outline-hidden{--tw-outline-style:none;outline-style:none}@media(forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.backdrop-blur-lg{--tw-backdrop-blur:blur(var(--blur-lg));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-200{--tw-duration:.2s;transition-duration:.2s}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.select-none{-webkit-user-select:none;user-select:none}.\\[--edge-fade-distance\\:2rem\\]{--edge-fade-distance:2rem}.group-focus-within\\:opacity-100:is(:where(.group):focus-within *){opacity:1}@media(hover:hover){.group-hover\\:opacity-100:is(:where(.group):hover *){opacity:1}.group-hover\\/collapsed-tool-activity\\:text-token-foreground:is(:where(.group\\/collapsed-tool-activity):hover *){color:var(--color-token-foreground)}.hover\\:bg-token-button-secondary-hover-background:hover{background-color:var(--color-token-button-secondary-hover-background)}.hover\\:bg-token-editor-background\\/40:hover{background-color:var(--color-token-editor-background)}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-token-editor-background\\/40:hover{background-color:color-mix(in oklab,var(--color-token-editor-background) 40%,transparent)}}.hover\\:bg-token-list-hover-background:hover{background-color:var(--color-token-list-hover-background)}.hover\\:bg-transparent:hover{background-color:#0000}.hover\\:text-token-button-foreground:hover{color:var(--color-token-button-foreground)}.hover\\:text-token-foreground:hover{color:var(--color-token-foreground)}}.focus\\:bg-token-list-hover-background:focus{background-color:var(--color-token-list-hover-background)}.focus\\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\\:outline-2:focus-visible{outline-style:var(--tw-outline-style);outline-width:2px}.focus-visible\\:outline-token-button-background:focus-visible{outline-color:var(--color-token-button-background)}@media(hover:hover){.enabled\\:hover\\:bg-token-foreground\\/80:enabled:hover{background-color:var(--color-token-foreground)}@supports (color:color-mix(in lab,red,red)){.enabled\\:hover\\:bg-token-foreground\\/80:enabled:hover{background-color:color-mix(in oklab,var(--color-token-foreground) 80%,transparent)}}.enabled\\:hover\\:bg-token-list-hover-background:enabled:hover{background-color:var(--color-token-list-hover-background)}}.disabled\\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\\:opacity-40:disabled{opacity:.4}.data-\\[state\\=checked\\]\\:translate-x-\\[14px\\][data-state=checked]{--tw-translate-x:14px;translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\\[state\\=open\\]\\:bg-token-foreground\\/80[data-state=open]{background-color:var(--color-token-foreground)}@supports (color:color-mix(in lab,red,red)){.data-\\[state\\=open\\]\\:bg-token-foreground\\/80[data-state=open]{background-color:color-mix(in oklab,var(--color-token-foreground) 80%,transparent)}}.data-\\[state\\=open\\]\\:bg-token-list-hover-background[data-state=open]{background-color:var(--color-token-list-hover-background)}.data-\\[state\\=unchecked\\]\\:translate-x-\\[2px\\][data-state=unchecked]{--tw-translate-x:2px;translate:var(--tw-translate-x) var(--tw-translate-y)}@container (min-width:500px){.\\@\\[500px\\]\\:inline{display:inline}}.electron\\:rounded-md:where([data-codex-window-type=electron],[data-codex-window-type=electron] *){border-radius:var(--radius-md)}.electron\\:p-1:where([data-codex-window-type=electron],[data-codex-window-type=electron] *){padding:calc(var(--spacing) * 1)}.electron\\:shadow-\\[0_4px_16px_0_rgba\\(0\\,0\\,0\\,0\\.05\\)\\]:where([data-codex-window-type=electron],[data-codex-window-type=electron] *){--tw-shadow:0 4px 16px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.electron\\:ring:where([data-codex-window-type=electron],[data-codex-window-type=electron] *){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.electron\\:ring-black\\/10:where([data-codex-window-type=electron],[data-codex-window-type=electron] *){--tw-ring-color:#0000001a}@supports (color:color-mix(in lab,red,red)){.electron\\:ring-black\\/10:where([data-codex-window-type=electron],[data-codex-window-type=electron] *){--tw-ring-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}@media(prefers-color-scheme:dark){.electron\\:dark\\:bg-token-dropdown-background:where([data-codex-window-type=electron],[data-codex-window-type=electron] *){background-color:var(--color-token-dropdown-background)}}.extension\\:border:where([data-codex-window-type=extension],[data-codex-window-type=extension] *){border-style:var(--tw-border-style);border-width:1px}.extension\\:border-token-border\\/50:where([data-codex-window-type=extension],[data-codex-window-type=extension] *){border-color:var(--color-token-border)}@supports (color:color-mix(in lab,red,red)){.extension\\:border-token-border\\/50:where([data-codex-window-type=extension],[data-codex-window-type=extension] *){border-color:color-mix(in oklab,var(--color-token-border) 50%,transparent)}}.\\[\\&_\\.ProseMirror\\]\\:\\!h-5 .ProseMirror{height:calc(var(--spacing) * 5)!important}.\\[\\&_\\.ProseMirror\\]\\:h-auto .ProseMirror{height:auto}.\\[\\&_\\.ProseMirror\\]\\:\\!min-h-5 .ProseMirror{min-height:calc(var(--spacing) * 5)!important}.\\[\\&_\\.ProseMirror\\]\\:min-h-\\[2rem\\] .ProseMirror{min-height:2rem}.\\[\\&_\\.ProseMirror\\]\\:min-w-0 .ProseMirror{min-width:calc(var(--spacing) * 0)}.\\[\\&_\\.ProseMirror\\]\\:flex-1 .ProseMirror{flex:1}.\\[\\&_\\.ProseMirror\\]\\:resize-none .ProseMirror{resize:none}.\\[\\&_\\.ProseMirror\\]\\:overflow-hidden .ProseMirror{overflow:hidden}.\\[\\&_\\.ProseMirror\\]\\:leading-5 .ProseMirror{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.\\[\\&_\\.ProseMirror\\]\\:whitespace-nowrap .ProseMirror{white-space:nowrap}.\\[\\&_\\.ProseMirror\\]\\:focus-visible\\:outline-none .ProseMirror:focus-visible{--tw-outline-style:none;outline-style:none}.\\[\\&_\\.ProseMirror_p\\]\\:m-0 .ProseMirror p{margin:calc(var(--spacing) * 0)}.\\[\\&_\\.ProseMirror_p\\]\\:overflow-hidden .ProseMirror p{overflow:hidden}.\\[\\&_\\.ProseMirror_p\\]\\:text-ellipsis .ProseMirror p{text-overflow:ellipsis}.\\[\\&_\\.ProseMirror_p\\]\\:whitespace-nowrap .ProseMirror p{white-space:nowrap}.\\[\\&_\\.contain-inline-size\\]\\:\\[contain\\:initial\\] .contain-inline-size{contain:initial}.\\[\\&\\>\\*\\:last-child\\]\\:mb-0>:last-child{margin-bottom:calc(var(--spacing) * 0)}.\\[\\&\\>ol\\:first-child\\]\\:mt-0>ol:first-child,.\\[\\&\\>ul\\:first-child\\]\\:mt-0>ul:first-child{margin-top:calc(var(--spacing) * 0)}}@property --top-fade{syntax:"";inherits:false;initial-value:0}@property --bottom-fade{syntax:"";inherits:false;initial-value:0}@property --edge-fade-distance{syntax:"";inherits:false;initial-value:1rem}@keyframes edge-fade{0%,1%{--top-fade:0;--bottom-fade:var(--edge-fade-distance,1rem)}99%,to{--top-fade:var(--edge-fade-distance,1rem);--bottom-fade:0}}@supports (animation-timeline:--scroll-fade){.vertical-scroll-fade-mask{-webkit-mask:linear-gradient(to bottom in oklch,oklch(60% 0 0/0),oklch(85% 0 0) var(--top-fade) calc(100% - var(--bottom-fade)),oklch(60% 0 0/0));mask:linear-gradient(to bottom in oklch,oklch(60% 0 0/0),oklch(85% 0 0) var(--top-fade) calc(100% - var(--bottom-fade)),oklch(60% 0 0/0));animation-name:edge-fade;animation-timing-function:linear;animation-fill-mode:both;animation-timeline:scroll(self y)}}:root{color-scheme:dark;font-family:var(--font-sans);color:var(--agent-text,#f1f0e8);--agent-page-bg:transparent;--agent-surface:#1c1f1b57;--agent-surface-elevated:#1c1f1b7a;--agent-input-bg:#080a085c;--agent-border:#e9e7d81f;--agent-border-strong:#e9e7d838;--agent-text:#f1f0e8;--agent-muted:#f1f0e894;--agent-soft:#f1f0e8c7;--agent-accent:#8ab4f8;--agent-accent-soft:#8ab4f833;--agent-danger:#ff8d7e;--agent-shadow:#0003;--agent-card:var(--agent-input-bg);background:0 0}@supports (color:color-mix(in lab,red,red)){:root{--agent-card:color-mix(in srgb, var(--agent-input-bg) 90%, transparent)}}:root{--agent-card-hover:var(--agent-input-bg)}@supports (color:color-mix(in lab,red,red)){:root{--agent-card-hover:color-mix(in srgb, var(--agent-input-bg) 96%, transparent)}}:root{--agent-control:var(--agent-input-bg)}@supports (color:color-mix(in lab,red,red)){:root{--agent-control:color-mix(in srgb, var(--agent-input-bg) 44%, transparent)}}:root{--agent-control-hover:var(--agent-soft)}@supports (color:color-mix(in lab,red,red)){:root{--agent-control-hover:color-mix(in srgb, var(--agent-soft) 8%, transparent)}}:root{--agent-primary:var(--agent-accent)}@supports (color:color-mix(in lab,red,red)){:root{--agent-primary:color-mix(in srgb, var(--agent-accent) 90%, var(--agent-text))}}:root{--agent-primary-text:#fff;--cubic-enter:cubic-bezier(.19, 1, .22, 1);--cubic-exit-snappy:cubic-bezier(.65, 0, .4, 1);--transition-duration-basic:.15s;--gray-0:#fff;--color-background-surface:transparent;--color-background-surface-under:transparent;--color-background-elevated-primary:var(--agent-surface-elevated);--color-background-elevated-primary-opaque:var(--agent-surface-elevated)}@supports (color:color-mix(in lab,red,red)){:root{--color-background-elevated-primary-opaque:color-mix(in srgb, var(--agent-surface-elevated) 100%, #111)}}:root{--color-background-elevated-secondary:var(--agent-card);--color-background-elevated-secondary-opaque:var(--agent-card)}@supports (color:color-mix(in lab,red,red)){:root{--color-background-elevated-secondary-opaque:color-mix(in srgb, var(--agent-card) 100%, #111)}}:root{--thread-content-max-width:none;--markdown-wide-block-max-width:56rem;--padding-toolbar:calc(var(--spacing) * 4);--vscode-foreground:var(--agent-text);--vscode-descriptionForeground:var(--agent-muted);--vscode-input-background:var(--agent-input-bg);--vscode-dropdown-background:var(--agent-surface-elevated);--vscode-input-placeholderForeground:var(--agent-muted);--vscode-textLink-foreground:var(--agent-accent);--vscode-textLink-activeForeground:var(--agent-accent);--vscode-list-hoverBackground:var(--agent-control-hover);--color-token-editor-foreground:var(--agent-text);--color-token-editor-background:var(--agent-page-bg);--color-token-side-bar-background:var(--agent-surface-elevated);--color-token-main-surface-primary:transparent;--color-token-text-code-block-background:var(--agent-input-bg);--color-token-bg-primary:var(--agent-surface-elevated);--color-token-bg-secondary:var(--agent-card);--color-token-bg-fog:var(--color-background-elevated-secondary);--color-token-bg-tertiary:var(--agent-control);--color-token-list-hover-background:var(--agent-control-hover);--markdown-wide-block-max-height:80vh;--conversation-block-gap:12px;--conversation-tool-assistant-gap:16px;--color-token-terminal-ansi-black:var(--vscode-terminal-ansiBlack,#000);--color-token-terminal-ansi-red:var(--vscode-terminal-ansiRed,#f66);--color-token-terminal-ansi-green:var(--vscode-terminal-ansiGreen,#94f494);--color-token-terminal-ansi-yellow:var(--vscode-terminal-ansiYellow,#f4f47b);--color-token-terminal-ansi-blue:var(--vscode-terminal-ansiBlue,#9e9eff);--color-token-terminal-ansi-magenta:var(--vscode-terminal-ansiMagenta,#db6bdb);--color-token-terminal-ansi-cyan:var(--vscode-terminal-ansiCyan,#81eeee);--color-token-terminal-ansi-white:var(--vscode-terminal-ansiWhite,#d6d6d6);--color-token-terminal-ansi-bright-black:var(--vscode-terminal-ansiBrightBlack,#6e6e6e);--color-token-terminal-ansi-bright-red:var(--vscode-terminal-ansiBrightRed,#ffa8a8);--color-token-terminal-ansi-bright-green:var(--vscode-terminal-ansiBrightGreen,#0f0);--color-token-terminal-ansi-bright-yellow:var(--vscode-terminal-ansiBrightYellow,#ffffa8);--color-token-terminal-ansi-bright-blue:var(--vscode-terminal-ansiBrightBlue,#9494ff);--color-token-terminal-ansi-bright-magenta:var(--vscode-terminal-ansiBrightMagenta,#ffb3ff);--color-token-terminal-ansi-bright-cyan:var(--vscode-terminal-ansiBrightCyan,#adffff);--color-token-terminal-ansi-bright-white:var(--vscode-terminal-ansiBrightWhite,#fff);--padding-row-x:calc(var(--spacing) * 2);--padding-row-y:calc(var(--spacing) * 1.25);--height-token-nav-row:32px;--codex-chat-font-size:var(--vscode-chat-font-size,var(--vscode-font-size,13px));--codex-chat-code-font-size:var(--vscode-chat-editor-font-size,var(--vscode-editor-font-size,12px))}:root[data-codex-window-type=electron]{background:0 0;overflow:hidden}:root[data-codex-window-type=electron] body{--padding-row-y:calc(var(--spacing) * 1.25);--height-token-nav-row:calc(var(--text-base) * 1.5 + var(--padding-row-y) * 2);--padding-panel-base:calc(var(--spacing) * 5);--padding-panel:var(--padding-panel-base);--thread-content-max-width:48rem;--markdown-wide-block-max-width:56rem;--markdown-wide-block-max-height:80vh;--conversation-block-gap:12px;--conversation-tool-assistant-gap:16px;--spacing-token-button-composer-sm:calc(var(--spacing) * 7);--text-heading-md:18px;--cursor-interaction:default;--vscode-editor-font-family:ui-monospace, "SFMono-Regular", "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;--vscode-font-size:var(--text-base);--vscode-editor-font-size:var(--text-sm);--vscode-chat-font-size:var(--vscode-font-size);--vscode-chat-editor-font-size:var(--vscode-editor-font-size);overscroll-behavior:none;overflow:hidden}:root[data-codex-window-type=electron]:not([data-codex-os=win32]) body{background:0 0}*{box-sizing:border-box}html,body,#root{background:0 0;height:100%}body{min-width:0;color:var(--agent-text);font-family:var(--font-sans);font-size:var(--text-base);font-weight:var(--vscode-font-weight,400);margin:0;overflow:hidden}button,select,textarea{font:inherit}button,select{cursor:default}.agent-shell{height:100%;min-height:100%;padding:var(--padding-toolbar);color:var(--agent-text);background:var(--agent-page-bg);flex-direction:column;display:flex}.agent-log,.log,.agent-thread{width:100%;min-height:0;max-width:var(--thread-content-max-width);scrollbar-width:thin;scrollbar-color:var(--agent-muted) transparent;background:0 0;flex:auto;margin:0 auto;padding:6px 2px 18px;overflow:auto}@supports (color:color-mix(in lab,red,red)){.agent-log,.log,.agent-thread{scrollbar-color:color-mix(in srgb,var(--agent-muted) 28%,transparent) transparent}}.agent-thread{gap:var(--conversation-block-gap,12px);flex-direction:column;padding-top:10px;padding-bottom:24px;display:flex;position:relative;overflow-x:hidden}:root[data-codex-window-type=electron] .agent-thread{--color-token-description-foreground:var(--color-token-foreground)}@supports (color:color-mix(in lab,red,red)){:root[data-codex-window-type=electron] .agent-thread{--color-token-description-foreground:color-mix(in srgb, var(--color-token-foreground) 70%, transparent)}}.agent-thread[data-empty]{justify-content:flex-end}.text-size-chat{font-size:var(--codex-chat-font-size)}.text-size-chat-sm{font-size:calc(var(--codex-chat-font-size) - 1px)}.text-size-code{font-size:var(--codex-chat-code-font-size)}.text-size-code-sm{font-size:calc(var(--codex-chat-code-font-size) - 1px)}.font-vscode-editor{font-family:var(--vscode-editor-font-family,var(--font-mono))}.codex-user-turn,.codex-assistant-turn,.codex-notice-turn{width:100%;min-width:0}.codex-user-bubble{color:var(--color-token-foreground);background:var(--color-token-foreground)}@supports (color:color-mix(in lab,red,red)){.codex-user-bubble{background:color-mix(in srgb,var(--color-token-foreground) 5%,transparent)}}.codex-user-message-content[data-collapsed=true]{-webkit-line-clamp:20;-webkit-box-orient:vertical;max-height:20lh;display:-webkit-box;overflow:hidden}.codex-user-message-toggle{background:0 0;border:0;padding:0}.codex-assistant-turn{justify-content:flex-start;display:flex}.codex-assistant-message{max-width:100%;color:var(--color-token-foreground);white-space:normal;overflow-wrap:anywhere}.codex-assistant-message-streaming{white-space:break-spaces}.codex-assistant-message :where(p){margin:0 0 .85em}.codex-assistant-message :where(p:last-child){margin-bottom:0}.codex-assistant-message :where(ul,ol){margin:.45em 0 .85em;padding-left:1.35rem}.codex-assistant-message :where(ul){list-style:outside}.codex-assistant-message :where(ol){list-style:decimal}.codex-assistant-message :where(li){margin:.25em 0}.codex-assistant-message :where(blockquote){border-left:2px solid var(--color-token-border);color:var(--agent-soft);margin:.75em 0;padding-left:.85rem}.codex-assistant-message :where(code){background:var(--agent-soft);border-radius:4px;padding:.12em .3em}@supports (color:color-mix(in lab,red,red)){.codex-assistant-message :where(code){background:color-mix(in srgb,var(--agent-soft) 10%,transparent)}}.codex-assistant-message :where(code){font-family:var(--font-mono);font-size:var(--codex-chat-code-font-size)}.codex-assistant-message :where(pre){border:1px solid var(--agent-border);margin:.75em 0;padding:.65rem .75rem;overflow-x:auto}@supports (color:color-mix(in lab,red,red)){.codex-assistant-message :where(pre){border:1px solid color-mix(in srgb,var(--agent-border) 72%,transparent)}}.codex-assistant-message :where(pre){border-radius:var(--radius-lg);background:var(--agent-input-bg)}@supports (color:color-mix(in lab,red,red)){.codex-assistant-message :where(pre){background:color-mix(in srgb,var(--agent-input-bg) 82%,transparent)}}.codex-assistant-message :where(pre code){white-space:pre;background:0 0;border-radius:0;padding:0;display:block}.codex-assistant-message :where(a){color:var(--agent-accent);text-underline-offset:2px;text-decoration:underline}.codex-assistant-message :where(hr){border-top:1px solid var(--color-token-border);margin:1rem 0}.codex-notice-turn{justify-content:flex-start;display:flex}.codex-notice-content{border:1px solid var(--agent-border);max-width:min(100%,44rem);padding:10px 12px}@supports (color:color-mix(in lab,red,red)){.codex-notice-content{border:1px solid color-mix(in srgb,var(--agent-border) 74%,transparent)}}.codex-notice-content{border-radius:var(--radius-lg);color:var(--agent-soft);background:var(--agent-surface-elevated)}@supports (color:color-mix(in lab,red,red)){.codex-notice-content{background:color-mix(in srgb,var(--agent-surface-elevated) 86%,transparent)}}.codex-notice-content{overflow-wrap:anywhere}.codex-notice-turn.error .codex-notice-content{border-color:var(--agent-danger)}@supports (color:color-mix(in lab,red,red)){.codex-notice-turn.error .codex-notice-content{border-color:color-mix(in srgb,var(--agent-danger) 46%,transparent)}}.codex-notice-turn.error .codex-notice-content{color:var(--agent-danger)}@supports (color:color-mix(in lab,red,red)){.codex-notice-turn.error .codex-notice-content{color:color-mix(in srgb,var(--agent-danger) 88%,var(--agent-text))}}.codex-notice-turn.error .codex-notice-content{background:var(--agent-danger)}@supports (color:color-mix(in lab,red,red)){.codex-notice-turn.error .codex-notice-content{background:color-mix(in srgb,var(--agent-danger) 11%,transparent)}}.codex-notice-turn.warning .codex-notice-content{border-color:var(--agent-accent)}@supports (color:color-mix(in lab,red,red)){.codex-notice-turn.warning .codex-notice-content{border-color:color-mix(in srgb,var(--agent-accent) 30%,transparent)}}.codex-tool-activity-turn{flex-direction:column;align-items:flex-start;gap:0;width:100%;min-width:0;display:flex}.codex-tool-activity-summary{max-width:100%;color:var(--color-token-foreground);border:0;padding:0}@supports (color:color-mix(in lab,red,red)){.codex-tool-activity-summary{color:color-mix(in srgb,var(--color-token-foreground) 40%,transparent)}}.codex-tool-activity-summary{font:inherit;font-size:var(--codex-chat-font-size);line-height:calc(var(--codex-chat-font-size) + 8px);background:0 0}.codex-tool-activity-summary:focus-visible{outline:none}.codex-tool-activity-turn.inProgress .codex-tool-activity-summary{color:var(--color-token-description-foreground)}@supports (color:color-mix(in lab,red,red)){.codex-tool-activity-turn.inProgress .codex-tool-activity-summary{color:color-mix(in srgb,var(--color-token-description-foreground) 90%,transparent)}}.codex-tool-activity-icon{color:var(--color-token-foreground);justify-content:center;align-items:center;display:inline-flex}@supports (color:color-mix(in lab,red,red)){.codex-tool-activity-icon{color:color-mix(in srgb,var(--color-token-foreground) 35%,transparent)}}.codex-tool-activity-icon{font-family:var(--font-mono);font-size:11px;line-height:1}.codex-tool-activity-turn.inProgress .codex-tool-activity-icon{animation:1.2s ease-in-out infinite codex-tool-activity-pulse}.codex-tool-activity-text{align-items:baseline;gap:4px;min-width:0;max-width:100%;display:inline-flex}.codex-tool-activity-action{color:var(--color-token-foreground);flex:none}@supports (color:color-mix(in lab,red,red)){.codex-tool-activity-action{color:color-mix(in srgb,var(--color-token-foreground) 58%,transparent)}}.codex-tool-activity-detail{min-width:0;color:var(--color-token-foreground);overflow:hidden}@supports (color:color-mix(in lab,red,red)){.codex-tool-activity-detail{color:color-mix(in srgb,var(--color-token-foreground) 40%,transparent)}}.codex-tool-activity-detail{font-family:var(--font-mono);text-overflow:ellipsis;white-space:nowrap}.codex-tool-activity-summary:hover .codex-tool-activity-action,.codex-tool-activity-summary:hover .codex-tool-activity-detail,.codex-tool-activity-summary:hover .codex-tool-activity-icon{color:var(--color-token-foreground)}.codex-tool-activity-output-shell{opacity:0;pointer-events:none;grid-template-rows:0fr;width:100%;min-width:0;transition:grid-template-rows .18s ease-out,opacity .18s ease-out;display:grid;overflow:hidden}.codex-tool-activity-output-shell[data-expanded=true]{opacity:1;pointer-events:auto;grid-template-rows:1fr}.codex-tool-activity-output-shell-inner{min-height:0;overflow:hidden}.codex-tool-activity-output-frame{min-height:1.25rem;padding-right:0;position:relative}.codex-tool-activity-output{box-sizing:border-box;max-width:min(100%,var(--markdown-wide-block-max-width));max-height:140px;color:var(--color-token-description-foreground);white-space:pre;--edge-fade-distance:2rem;background:0 0;border:0;border-radius:0;flex-direction:column;gap:6px;margin:0;padding:8px;font-weight:500;display:flex;overflow:auto}.codex-tool-output-copy:focus-visible{opacity:1}@keyframes codex-tool-activity-pulse{0%,to{opacity:.45}50%{opacity:1}}.agent-log-line,.log-line{border-bottom:1px solid var(--agent-border);grid-template-columns:4.5rem minmax(0,1fr);gap:12px;padding:9px 0;display:grid}@supports (color:color-mix(in lab,red,red)){.agent-log-line,.log-line{border-bottom:1px solid color-mix(in srgb,var(--agent-border) 55%,transparent)}}.agent-log-line,.log-line{color:var(--agent-soft);font-family:var(--font-mono);font-size:12px;line-height:1.5}.agent-log-line:last-child,.log-line:last-child{border-bottom-color:#0000}.agent-log-label,.log-label{color:var(--agent-muted);text-overflow:ellipsis;text-transform:uppercase;overflow:hidden}.agent-log-line.stdout .agent-log-label,.log-line.stdout .log-label{color:var(--agent-accent)}.agent-log-line.stderr .agent-log-label,.agent-log-line.error .agent-log-label,.log-line.stderr .log-label,.log-line.error .log-label{color:var(--agent-danger)}.agent-log-text,.log-text{overflow-wrap:anywhere;white-space:pre-wrap;word-break:normal;min-width:0}.agent-composer-stack{width:100%;max-width:var(--thread-content-max-width);gap:6px;margin:0 auto;display:grid}.above-composer-suggestion-shell{container-type:inline-size}.codex-composer-frame{--composer-top-tray-inline-padding:12px;--composer-top-tray-top-padding:14px;--composer-top-tray-max-height:177px}.codex-composer-surface{color:var(--color-token-foreground)}.codex-composer-inner{isolation:isolate}@supports (corner-shape:superellipse(1.5)){:root{--corner-radius-scale:1.25}.codex-composer-surface,.codex-user-bubble,.codex-notice-content,.provider-dropdown,.provider-dropdown-item,.add-context-dropdown,.add-context-item,.composer-attachment-file,.composer-attachment-image,.rate-limit-popover,.codex-top-tray-panel,.codex-top-tray-item{corner-shape:superellipse(1.5)}}.codex-composer-single-line{min-height:0}.composer-single-line-measure{pointer-events:none;visibility:hidden;white-space:pre;width:max-content;max-width:none;height:0;font-family:var(--font-sans);font-size:var(--text-base);line-height:20px;position:absolute;overflow:hidden}.h-token-button-composer{height:var(--spacing-token-button-composer);gap:var(--h-token-button-composer-gap)}.h-token-button-composer-sm{height:var(--spacing-token-button-composer-sm);gap:var(--h-token-button-composer-gap)}.size-token-button-composer{width:var(--spacing-token-button-composer);height:var(--spacing-token-button-composer)}.size-token-button-composer-sm{width:var(--spacing-token-button-composer-sm);height:var(--spacing-token-button-composer-sm)}.icon-3xs{width:10px;height:10px}.icon-xxs{width:12px;height:12px}.icon-2xs{width:14px;height:14px}.icon-xs{width:16px;height:16px}.icon-sm{width:18px;height:18px}.icon-base{width:20px;height:20px}.icon-md{width:24px;height:24px}.icon-lg{width:28px;height:28px}.cursor-interaction{cursor:var(--cursor-interaction)}.no-drag{-webkit-app-region:no-drag}.outline-hidden{outline-offset:2px;outline:2px solid #0000}.user-select-none{-webkit-user-select:none;user-select:none}.shadow-xl-spread{--tw-shadow:0px 0px 0px .5px var(--tw-shadow-color,var(--color-token-border)), var(--shadow-xl);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}._content_1hiti_1{--dropdown-content-inset:2px;--dropdown-content-start-transform:translateY(calc(var(--dropdown-content-inset) * -1)) scale(.98);transform-origin:var(--radix-dropdown-menu-content-transform-origin);will-change:opacity,transform}._content_1hiti_1[data-side=top]{--dropdown-content-start-transform:translateY(var(--dropdown-content-inset)) scale(.98)}._content_1hiti_1[data-side=right]{--dropdown-content-start-transform:translateX(calc(var(--dropdown-content-inset) * -1)) scale(.98)}._content_1hiti_1[data-side=left]{--dropdown-content-start-transform:translateX(var(--dropdown-content-inset)) scale(.98)}@media(prefers-reduced-motion:no-preference){._content_1hiti_1[data-state=open]{animation:_dropdown-content-enter_1hiti_1 var(--transition-duration-basic) var(--cubic-enter)}}@keyframes _dropdown-content-enter_1hiti_1{0%{opacity:0;transform:var(--dropdown-content-start-transform)}to{opacity:1;transform:translate(0)translateY(0)scale(1)}}.hide-scrollbar{scrollbar-width:none}.hide-scrollbar::-webkit-scrollbar{display:none}.prompt-input{resize:none;width:100%;min-height:2.75rem;max-height:200px;color:var(--color-token-foreground);font-size:var(--text-base);background:0 0;border:0;outline:none;padding:0;line-height:20px;display:block}.prompt-input::placeholder{color:var(--color-token-input-placeholder-foreground)}@supports (color:color-mix(in lab,red,red)){.prompt-input::placeholder{color:color-mix(in srgb,var(--color-token-input-placeholder-foreground) 50%,transparent)}}.ProseMirror{min-width:0;color:var(--agent-text);caret-color:var(--agent-text);font-variant-ligatures:none;font-feature-settings:"liga" 0;white-space:pre-wrap;white-space:break-spaces;word-wrap:break-word;word-break:normal;background:0 0;outline:none;position:relative;overflow-y:auto}.ProseMirror pre{white-space:pre-wrap}.ProseMirror li{position:relative}.ProseMirror-hideselection{caret-color:#0000;background:0 0}.ProseMirror-hideselection ::selection{caret-color:#0000;background:0 0}.ProseMirror [draggable][contenteditable=false]{-webkit-user-select:text;user-select:text}.ProseMirror-selectednode{outline:2px solid #8cf}li.ProseMirror-selectednode{outline:none}li.ProseMirror-selectednode:after{content:"";pointer-events:none;border:2px solid #8cf;position:absolute;inset:-2px -2px -2px -32px}img.ProseMirror-separator{border:none!important;margin:0!important;display:inline!important}.ProseMirror p{margin:0}.ProseMirror .placeholder{pointer-events:none;-webkit-user-select:none;user-select:none;position:relative}.ProseMirror .placeholder:after{color:var(--color-token-input-placeholder-foreground);content:attr(data-placeholder);opacity:.5;pointer-events:none;-webkit-user-select:none;user-select:none;white-space:nowrap;text-overflow:ellipsis;max-width:100%;position:absolute;inset:0 0 auto;overflow:hidden}.ProseMirror .placeholder::selection{color:inherit;background:0 0}.ProseMirror .placeholder ::selection{color:inherit;background:0 0}.ProseMirror .ProseMirror-selectednode:has([skill-mention-name]){outline:none}.prompt-editor-mention{--inline-mention-base-color:var(--color-token-text-link-foreground)}@supports (color:color-mix(in lab,red,red)){.prompt-editor-mention{--inline-mention-base-color:color-mix(in srgb, var(--color-token-text-link-foreground) 80%, var(--color-token-foreground) 20%)}}.prompt-editor-mention{--inline-mention-color:var(--inline-mention-resolved-base-color,var(--inline-mention-base-color));max-width:100%;color:var(--inline-mention-color);vertical-align:baseline;white-space:nowrap;align-items:baseline;display:inline-flex}.prompt-editor-mention:hover{text-underline-offset:2px;text-decoration-line:underline;text-decoration-style:dashed;text-decoration-thickness:.5px}.prompt-editor-mention-icon{color:currentColor}[cmdk-dialog],[cmdk-dialog]:focus-visible{outline:none}[cmdk-root],[data-cmdk-root]{z-index:1000;gap:var(--spacing);min-width:100%;padding:var(--spacing);border:1px solid var(--color-token-border);border-radius:var(--radius-3xl);color:var(--color-token-foreground);background-color:var(--color-token-dropdown-background);flex-direction:column;display:flex}@supports (color:color-mix(in lab,red,red)){[cmdk-root],[data-cmdk-root]{background-color:color-mix(in oklab,var(--color-token-dropdown-background) 95%,transparent)}}[cmdk-root],[data-cmdk-root]{font-size:var(--text-sm);opacity:1;-webkit-user-select:none;user-select:none;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);--cmdk-bottom-item-radius:calc(var(--radius-3xl) - var(--spacing));transform:translateZ(0)}[cmdk-item]{content-visibility:auto;width:100%;min-height:calc(var(--spacing) * 6);padding:var(--padding-row-y) var(--padding-row-x);border-radius:var(--radius-lg);color:var(--color-token-foreground);cursor:var(--cursor-interaction);opacity:.75;-webkit-user-select:none;user-select:none;outline:none;flex-direction:row;align-items:center;display:flex}[cmdk-item][aria-disabled=true],[cmdk-item][data-disabled=true]{cursor:default;opacity:.25}[cmdk-item]:hover:not([aria-disabled=true]),[cmdk-item][data-selected=true],[cmdk-item][aria-selected=true],[cmdk-item]:focus-visible,[cmdk-item]:active:not([aria-disabled=true]){background-color:var(--color-token-list-hover-background);opacity:1}[cmdk-item][data-command-menu-loading=true],[cmdk-item][data-command-menu-loading=true]:hover,[cmdk-item][data-command-menu-loading=true][data-selected=true],[cmdk-item][data-command-menu-loading=true][aria-selected=true],[cmdk-item][data-command-menu-loading=true]:focus-visible,[cmdk-item][data-command-menu-loading=true]:active{cursor:default;opacity:.6;background-color:#0000}[cmdk-item][data-command-menu-empty-state=true],[cmdk-item][data-command-menu-empty-state=true]:hover,[cmdk-item][data-command-menu-empty-state=true][data-selected=true],[cmdk-item][data-command-menu-empty-state=true][aria-selected=true],[cmdk-item][data-command-menu-empty-state=true]:focus-visible,[cmdk-item][data-command-menu-empty-state=true]:active{cursor:default;opacity:1;background-color:#0000}[cmdk-list]{min-height:0;max-height:min(300px,var(--cmdk-list-height,300px));gap:var(--spacing);overscroll-behavior:contain;scrollbar-width:none;flex-direction:column;transition:max-height .1s;display:flex;overflow-y:auto}[cmdk-list]:focus-visible{outline:none}.command-menu-dialog [cmdk-list]{max-height:var(--command-menu-list-max-height);scroll-padding-block-start:calc(var(--spacing) * 8)}.composer-home-top-menu [cmdk-list]{max-height:none}.composer-home-top-menu{max-height:min(320px,var(--composer-top-menu-max-height,320px));overflow:hidden}:where([cmdk-list])>[cmdk-item]:not(:has(~[cmdk-item],~[cmdk-group]:not([hidden]))),:where([cmdk-list])>[cmdk-group]:not([hidden]):not(:has(~[cmdk-item],~[cmdk-group]:not([hidden]))) [cmdk-item]:last-of-type,:where([cmdk-list])>[cmdk-list-sizer]>[cmdk-item]:not(:has(~[cmdk-item],~[cmdk-group]:not([hidden]))),:where([cmdk-list])>[cmdk-list-sizer]>[cmdk-group]:not([hidden]):not(:has(~[cmdk-item],~[cmdk-group]:not([hidden]))) [cmdk-item]:last-of-type{border-bottom-right-radius:var(--cmdk-bottom-item-radius);border-bottom-left-radius:var(--cmdk-bottom-item-radius)}[cmdk-input]{padding:calc(var(--spacing) * 1.5) calc(var(--spacing) * 2.5)}[cmdk-input]:focus-visible{outline:none}[cmdk-empty],[data-cmdk-empty]{min-height:calc(var(--spacing) * 8);padding:calc(var(--spacing) * 1.5) calc(var(--spacing) * 2.5);color:var(--color-token-description-foreground);text-align:center;white-space:pre-wrap;align-items:center;line-height:1.4;display:flex}[data-codex-window-type=electron] [cmdk-root],[data-codex-window-type=electron] [data-cmdk-root]{font-size:var(--text-base)}.codex-attachment-tray{scrollbar-width:none}.codex-attachment-tray::-webkit-scrollbar{display:none}.codex-user-attachment-tray{max-width:77%}.codex-user-attachment-tray .codex-attachment-row{justify-content:flex-end;min-width:0}.composer-attachment-image{border:1px solid var(--color-token-border-heavy);border-radius:var(--radius-lg);background:var(--color-token-input-background);flex:none;width:80px;height:80px;display:inline-flex;position:relative;overflow:visible}.composer-attachment-image img{border-radius:calc(var(--radius-lg) - 1px);object-fit:cover;width:100%;height:100%}.composer-attachment-file{border:.5px solid var(--color-token-border-heavy);flex:none;align-items:center;gap:7px;max-width:min(220px,70vw);height:32px;padding:0 30px 0 10px;display:inline-flex;position:relative}@supports (color:color-mix(in lab,red,red)){.composer-attachment-file{border:.5px solid color-mix(in srgb,var(--color-token-border-heavy) 80%,transparent)}}.composer-attachment-file{border-radius:var(--radius-lg);color:var(--color-token-foreground);background:var(--color-token-dropdown-background)}@supports (color:color-mix(in lab,red,red)){.composer-attachment-file{background:color-mix(in srgb,var(--color-token-dropdown-background) 90%,transparent)}}.composer-attachment-file-icon{color:var(--color-token-description-foreground);flex:none;display:inline-flex}.composer-attachment-file-label{min-width:0;font-size:var(--text-sm);text-overflow:ellipsis;white-space:nowrap;font-weight:500;line-height:1rem;overflow:hidden}.composer-attachment-remove{border:.5px solid var(--color-token-border-heavy);justify-content:center;align-items:center;width:18px;height:18px;display:inline-flex;position:absolute;top:-7px;right:-7px}@supports (color:color-mix(in lab,red,red)){.composer-attachment-remove{border:.5px solid color-mix(in srgb,var(--color-token-border-heavy) 70%,transparent)}}.composer-attachment-remove{color:var(--color-token-foreground);background:var(--color-token-dropdown-background);border-radius:999px;box-shadow:0 1px 3px #0000002e}.composer-attachment-remove:hover,.composer-attachment-remove:focus-visible{background:var(--color-token-list-hover-background)}.codex-left-rail,.codex-secondary-controls,.codex-action-cluster,.model-picker,.rate-line{align-items:center;display:flex}.codex-left-rail{scrollbar-width:none;flex:0 auto;min-width:0;margin:0;padding:0;overflow-x:auto}.codex-left-rail::-webkit-scrollbar{display:none}.composer-mode-indicator{color:var(--color-token-text-link-foreground);flex:0 auto}.composer-mode-divider{background:var(--color-token-border);width:1px;height:1rem;margin:0 1px}@supports (color:color-mix(in lab,red,red)){.composer-mode-divider{background:color-mix(in srgb,var(--color-token-border) 70%,transparent)}}.composer-mode-button{min-width:0;color:var(--color-token-text-link-foreground)}.composer-mode-icon{flex:none;justify-content:center;align-items:center;display:inline-flex}.composer-mode-icon-hover,.composer-mode-button:hover .composer-mode-icon-default,.composer-mode-button:focus-visible .composer-mode-icon-default{display:none}.composer-mode-button:hover .composer-mode-icon-hover,.composer-mode-button:focus-visible .composer-mode-icon-hover{display:inline-flex}.composer-mode-label{color:currentColor;font-weight:500}.composer-context-indicator{color:var(--color-token-text-link-foreground);flex:0 auto}.composer-context-divider{background:var(--color-token-border);margin:0 1px}@supports (color:color-mix(in lab,red,red)){.composer-context-divider{background:color-mix(in srgb,var(--color-token-border) 70%,transparent)}}.composer-context-button{color:var(--color-token-text-link-foreground)}.composer-context-icon{justify-content:center;align-items:center;display:inline-flex}.composer-context-icon-hover,.composer-context-button:hover .composer-context-icon-default,.composer-context-button:focus-visible .composer-context-icon-default{display:none}.composer-context-button:hover .composer-context-icon-hover,.composer-context-button:focus-visible .composer-context-icon-hover{display:inline-flex}.composer-context-label{color:currentColor;font-weight:500}.codex-secondary-controls{flex:0 auto;justify-content:flex-end}.codex-action-cluster{flex:none;justify-content:flex-end}.model-picker-root{flex:none;align-items:center;display:inline-flex}.model-picker,.codex-tool,.codex-action{outline:none;flex:none;justify-content:center;align-items:center;display:inline-flex;position:relative}.codex-tool,.codex-action{line-height:18px}.model-picker{letter-spacing:0;white-space:nowrap;min-width:0;max-width:10rem}.model-icon{border:1px solid var(--agent-border-strong);flex:none;justify-content:center;align-items:center;width:18px;height:18px;display:inline-flex}@supports (color:color-mix(in lab,red,red)){.model-icon{border:1px solid color-mix(in srgb,var(--agent-border-strong) 70%,transparent)}}.model-icon{color:var(--agent-text);background:var(--agent-control);border-radius:999px;font-size:10px;font-weight:750;line-height:1}.model-label{min-width:0;color:var(--color-token-foreground);text-overflow:ellipsis;overflow:hidden}.model-chevron{color:var(--color-token-input-placeholder-foreground);flex:none;line-height:1}.provider-dropdown{z-index:50;border:.5px solid var(--color-token-menu-border);width:13rem;max-height:min(250px,100vh - 16px);position:absolute;bottom:calc(100% + 6px);left:0}@supports (color:color-mix(in lab,red,red)){.provider-dropdown{border:.5px solid color-mix(in srgb,var(--color-token-menu-border) 78%,transparent)}}.provider-dropdown{border-radius:var(--radius-xl);color:var(--color-token-foreground);background:var(--color-token-dropdown-background)}@supports (color:color-mix(in lab,red,red)){.provider-dropdown{background:color-mix(in srgb,var(--color-token-dropdown-background) 90%,transparent)}}.provider-dropdown{-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}.provider-dropdown-title{min-height:24px;padding:var(--padding-row-y) var(--padding-row-x);color:var(--color-token-description-foreground);font-size:var(--text-sm);text-overflow:ellipsis;white-space:nowrap;align-items:center;line-height:1rem;display:flex;overflow:hidden}.provider-dropdown-item{width:100%;color:var(--color-token-foreground);text-align:left;background:0 0;border:0}.provider-dropdown-item:hover,.provider-dropdown-item:focus,.provider-dropdown-item[data-selected=true]{background:var(--color-token-list-hover-background)}.provider-dropdown-check{color:var(--color-token-foreground)}.permissions-root{flex:0 auto;min-width:0;display:inline-flex;position:relative}.permissions-trigger{color:var(--color-token-input-placeholder-foreground)}.permissions-trigger[data-permission-mode=auto-review],.permissions-item[data-permission-mode=auto-review] .permissions-item-icon{color:var(--color-token-text-link-foreground)}.permissions-trigger[data-permission-mode=full-access],.permissions-item[data-permission-mode=full-access] .permissions-item-icon{color:var(--agent-danger)}.permissions-trigger-label{color:currentColor;font-weight:500}.permissions-trigger-chevron{color:currentColor;opacity:.72}.permissions-dropdown{z-index:50;border:.5px solid var(--color-token-menu-border);width:13.5rem;max-height:min(250px,100vh - 16px);position:absolute;bottom:calc(100% + 6px);left:0;overflow-y:auto}@supports (color:color-mix(in lab,red,red)){.permissions-dropdown{border:.5px solid color-mix(in srgb,var(--color-token-menu-border) 78%,transparent)}}.permissions-dropdown{border-radius:var(--radius-xl);color:var(--color-token-foreground);background:var(--color-token-dropdown-background)}@supports (color:color-mix(in lab,red,red)){.permissions-dropdown{background:color-mix(in srgb,var(--color-token-dropdown-background) 90%,transparent)}}.permissions-dropdown{-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}.permissions-item{width:100%;min-height:calc(var(--spacing) * 8);align-items:center;gap:calc(var(--spacing) * 2);color:var(--color-token-foreground);text-align:left;background:0 0;border:0;display:flex}.permissions-item:hover,.permissions-item:focus-visible,.permissions-item[data-selected=true]{background:var(--color-token-list-hover-background)}.permissions-item-icon,.permissions-item-check{color:var(--color-token-description-foreground);justify-content:center;align-items:center;display:inline-flex}.permissions-item-label{color:var(--color-token-foreground);font-weight:500}.add-context-root{flex:none;display:inline-flex;position:relative}.add-context-dropdown{z-index:50;border:.5px solid var(--color-token-menu-border);width:max-content;min-width:120px;max-width:min(320px,100vw - 16px);max-height:min(270px,100vh - 16px);position:absolute;bottom:calc(100% + 6px);left:-8px;overflow-y:auto}@supports (color:color-mix(in lab,red,red)){.add-context-dropdown{border:.5px solid color-mix(in srgb,var(--color-token-menu-border) 78%,transparent)}}.add-context-dropdown{border-radius:var(--radius-xl);color:var(--color-token-foreground);background:var(--color-token-dropdown-background)}@supports (color:color-mix(in lab,red,red)){.add-context-dropdown{background:color-mix(in srgb,var(--color-token-dropdown-background) 90%,transparent)}}.add-context-dropdown{-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}.add-context-item{width:100%;min-height:calc(var(--spacing) * 8);align-items:center;gap:calc(var(--spacing) * 1.5);color:var(--color-token-foreground);cursor:var(--cursor-interaction);text-align:left;background:0 0;border:0;display:flex}.add-context-item:hover,.add-context-item:focus-visible{background:var(--color-token-list-hover-background)}.add-context-item:disabled{cursor:default;opacity:.5}.add-context-item:disabled:hover{background:0 0}.add-context-item-icon{color:var(--color-token-description-foreground);opacity:.75;justify-content:center;align-items:center;display:inline-flex}.add-context-item:hover .add-context-item-icon,.add-context-item:focus-visible .add-context-item-icon{opacity:1}.add-context-glyph{width:14px;height:14px;color:var(--color-token-description-foreground);font-size:var(--text-sm);justify-content:center;align-items:center;font-weight:700;line-height:1;display:inline-flex}.add-context-item-label{color:var(--color-token-foreground);font-weight:500}.add-context-item-detail{color:var(--color-token-description-foreground);font-size:var(--text-sm)}.add-context-separator{flex:none}.add-context-separator-line{background:var(--color-token-menu-border)}.provider-select{appearance:none;color:#0000;opacity:0;background:0 0;outline:none;width:100%;height:100%;position:absolute;inset:0}.composer-separator{background:var(--agent-border-strong);flex:none;width:1px;height:18px;margin:0 3px}@supports (color:color-mix(in lab,red,red)){.composer-separator{background:color-mix(in srgb,var(--agent-border-strong) 72%,transparent)}}.codex-start{width:auto;min-width:56px}.codex-circle-action:before{content:"";border:2px solid;border-radius:999px;width:13px;height:13px}.send-button{border-radius:999px}.provider-select:disabled{opacity:0}.rate-line{min-width:0;color:var(--agent-muted);gap:6px;padding:0 2px}@supports (color:color-mix(in lab,red,red)){.rate-line{color:color-mix(in srgb,var(--agent-muted) 88%,transparent)}}.rate-line{white-space:nowrap;font-size:12px;font-weight:500;line-height:1.35}.rate-line[hidden]{display:none}.rate-line :where(span){text-overflow:ellipsis;min-width:0;overflow:hidden}.codex-rate-limit-summary{flex-wrap:nowrap;justify-content:flex-start;overflow:hidden}.rate-limit-trigger{border-radius:var(--radius-md);max-width:100%;height:22px;color:var(--color-token-input-placeholder-foreground);border:0;padding:0 4px;display:inline-flex;position:relative}@supports (color:color-mix(in lab,red,red)){.rate-limit-trigger{color:color-mix(in srgb,var(--color-token-input-placeholder-foreground) 94%,transparent)}}.rate-limit-trigger{font-size:var(--text-sm);background:0 0;font-weight:500;line-height:1}.rate-limit-trigger-inline{gap:6px;overflow:hidden}.rate-limit-trigger:hover,.rate-limit-trigger:focus-visible,.rate-limit-trigger[aria-expanded=true]{background:var(--color-token-list-hover-background);outline:none}.rate-limit-speedometer,.rate-limit-chevron{color:var(--color-token-input-placeholder-foreground);flex:none}@supports (color:color-mix(in lab,red,red)){.rate-limit-speedometer,.rate-limit-chevron{color:color-mix(in srgb,var(--color-token-input-placeholder-foreground) 86%,transparent)}}.rate-line-heading{color:var(--color-token-input-placeholder-foreground);flex:none}@supports (color:color-mix(in lab,red,red)){.rate-line-heading{color:color-mix(in srgb,var(--color-token-input-placeholder-foreground) 92%,transparent)}}.rate-limit-inline-separator{color:var(--agent-accent);flex:none}@supports (color:color-mix(in lab,red,red)){.rate-limit-inline-separator{color:color-mix(in srgb,var(--agent-accent) 82%,transparent)}}.rate-limit-inline-segment{align-items:baseline;gap:5px;min-width:0;display:inline-flex}.rate-limit-popover{color:var(--color-token-foreground)}.rate-limit-popover-title{color:var(--color-token-description-foreground);font-size:var(--text-xs);padding:2px 0 5px;font-weight:600}.rate-limit-popover-row{min-width:0;min-height:24px;color:var(--color-token-foreground);grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:24px;display:grid}.rate-limit-window{min-width:0;color:var(--color-token-foreground);text-overflow:ellipsis;font-weight:500;overflow:hidden}.rate-limit-row-value{min-width:0;color:var(--color-token-description-foreground);text-align:right;justify-content:flex-end;align-items:center;gap:5px;display:inline-flex}.rate-limit-percent{color:var(--color-token-input-placeholder-foreground);flex:none}@supports (color:color-mix(in lab,red,red)){.rate-limit-percent{color:color-mix(in srgb,var(--color-token-input-placeholder-foreground) 92%,transparent)}}.rate-limit-percent{font-weight:600}.rate-limit-reset{min-width:0;color:var(--color-token-description-foreground)}.status-dot{background:var(--agent-muted);border-radius:999px;flex:none;width:6px;height:6px}.status-dot.running{background:var(--agent-accent)}.status-dot.failed{background:var(--agent-danger)}.codex-top-tray-shell{width:calc(var(--composer-top-tray-inline-padding,12px) * 2 + 100% + 8px);min-width:0;left:50%;bottom:var(--composer-top-tray-top-padding,14px);transform:translate(-50%)}.codex-top-tray-panel,.codex-top-tray-panel[cmdk-root],.codex-top-tray-panel[data-cmdk-root]{width:100%;min-width:0;max-height:var(--composer-top-tray-max-height,177px);padding:var(--spacing);padding-top:calc(var(--spacing) * 3);padding-left:calc(var(--spacing) * 2);border:1px solid var(--composer-top-tray-border,var(--color-token-border));flex-direction:column;display:flex;position:relative}@supports (color:color-mix(in lab,red,red)){.codex-top-tray-panel,.codex-top-tray-panel[cmdk-root],.codex-top-tray-panel[data-cmdk-root]{border:1px solid var(--composer-top-tray-border,color-mix(in srgb, var(--color-token-border) 72%, transparent))}}.codex-top-tray-panel,.codex-top-tray-panel[cmdk-root],.codex-top-tray-panel[data-cmdk-root]{border-top-left-radius:var(--radius-3xl);border-top-right-radius:var(--radius-3xl);color:var(--color-token-foreground);background:var(--composer-top-tray-background,var(--color-token-side-bar-background));border-top-color:#0000;border-bottom:none;border-bottom-right-radius:0;border-bottom-left-radius:0}@supports (color:color-mix(in lab,red,red)){.codex-top-tray-panel,.codex-top-tray-panel[cmdk-root],.codex-top-tray-panel[data-cmdk-root]{background:var(--composer-top-tray-background,color-mix(in oklab, var(--color-token-side-bar-background) 94%, transparent))}}.codex-top-tray-panel,.codex-top-tray-panel[cmdk-root],.codex-top-tray-panel[data-cmdk-root]{box-shadow:none;font-size:var(--text-sm);-webkit-backdrop-filter:none;backdrop-filter:none;isolation:isolate;-webkit-user-select:none;user-select:none;--cmdk-bottom-item-radius:0;overflow:hidden}.codex-top-tray-title{padding:0 var(--padding-row-x) calc(var(--spacing) * 1.25);color:var(--color-token-description-foreground);font-size:var(--text-xs);font-weight:600;line-height:1.2}.codex-top-tray-list,.codex-top-tray-panel [cmdk-list],.codex-top-tray-panel [data-cmdk-list]{gap:var(--spacing);scrollbar-width:none;flex-direction:column;flex:1;width:100%;min-height:0;max-height:none;transition:none;display:flex;overflow-y:auto}.codex-top-tray-list::-webkit-scrollbar{display:none}.codex-top-tray-empty,.codex-top-tray-panel [data-cmdk-empty]{padding:var(--padding-row-y) var(--padding-row-x);color:var(--color-token-input-placeholder-foreground);font-size:var(--text-sm);align-items:center;display:flex}.codex-top-tray-item,.codex-top-tray-item[cmdk-item],.codex-top-tray-item[data-list-navigation-item]{width:calc(100% - var(--spacing) * 2);min-height:calc(var(--spacing) * 6);padding:var(--padding-row-y) var(--padding-row-x);border-radius:var(--radius-lg);color:var(--color-token-foreground);cursor:var(--cursor-interaction,default);font-size:var(--text-sm);opacity:.75;text-align:left;background:0 0;border:0;outline:none;flex-shrink:0;align-items:center;gap:0;display:flex;overflow:hidden}.codex-top-tray-item:hover,.codex-top-tray-item[data-selected=true],.codex-top-tray-item:focus-visible{background:var(--color-token-list-hover-background);opacity:1}.codex-top-tray-icon{width:14px;height:14px;color:var(--color-token-description-foreground);font-size:var(--text-sm);flex:none;justify-content:center;align-items:center;font-weight:700;line-height:1;display:inline-flex}.codex-top-tray-copy{align-items:center;gap:calc(var(--spacing) * 2);min-width:0;display:flex}.codex-top-tray-label,.codex-top-tray-detail{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.codex-top-tray-label{color:var(--color-token-foreground);font-size:var(--text-sm)}.codex-top-tray-detail{color:var(--color-token-description-foreground);font-size:var(--text-sm);flex:1 1 0}@media(max-width:720px){.agent-shell{padding:10px}.prompt-input{min-height:2.75rem;font-size:16px}.model-picker,.codex-tool,.codex-action{height:var(--spacing-token-button-composer)}.codex-tool,.codex-action{width:var(--spacing-token-button-composer)}.codex-tool{width:var(--spacing-token-button-composer-sm);height:var(--spacing-token-button-composer-sm)}.codex-left-rail{min-width:0}.codex-action-cluster{margin-left:auto}}@media(max-width:520px){.agent-log-line,.log-line{grid-template-columns:1fr;gap:2px}.model-picker{min-width:104px;max-width:168px}.codex-tool-skill,.codex-mic,.rate-dot,.rate-limit-reset{display:none}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}';function vp(n){Xs("agent-session",yp),jo(),document.documentElement.dataset.cmuxWebviewKind="agent-session",document.body.dataset.cmuxWebviewKind="agent-session";const e=Qs(()=>Pr.jsx(yu,{}));Ys.createRoot(n).render(Pr.jsx(Gs,{router:e}))}export{vp as mountAgentSessionSurface}; +`)}function Au({copiedLabel:n,copyLabel:e,text:t}){"use no memo";const[r,o]=b.useState(!1),i=b.useRef(null),s=r?n:e,a=b.useCallback(c=>{c!==null||i.current===null||(window.clearTimeout(i.current),i.current=null)},[]),l=b.useCallback(c=>{c.stopPropagation(),navigator.clipboard?.writeText(t)?.catch(()=>{}),o(!0),i.current!==null&&window.clearTimeout(i.current),i.current=window.setTimeout(()=>{o(!1),i.current=null},2e3)},[t]);return f("button",{ref:a,type:"button",className:`codex-shell-copy ${ee} ${te} ${dn} rounded-full electron:rounded-md hover:bg-transparent hover:text-token-button-foreground`,"aria-label":s,title:s,onClick:l},r?Ze("icon-xxs"):yn("icon-xxs"))}function Ru({label:n,output:e}){"use no memo";const[t,r]=b.useState(!1),o=b.useRef(null),i=b.useCallback(a=>{a!==null||o.current===null||(window.clearTimeout(o.current),o.current=null)},[]),s=b.useCallback(a=>{a.stopPropagation(),navigator.clipboard?.writeText(e)?.catch(()=>{}),r(!0),o.current!==null&&window.clearTimeout(o.current),o.current=window.setTimeout(()=>{r(!1),o.current=null},2e3)},[e]);return f("button",{ref:i,type:"button",className:`codex-tool-output-copy ${ee} ${te} rounded-full electron:rounded-md electron:p-1 electron:[&>svg]:icon-sm flex items-center justify-center p-0.5 absolute top-0 right-2.5 opacity-0 transition-opacity duration-200 group-hover/output:opacity-100${t?" text-token-foreground opacity-100":""}`,"aria-label":n,title:n,onClick:s},t?Ze("icon-2xs"):yn("icon-2xs"))}function Lu({copy:n,sentAtMs:e,text:t}){"use no memo";const[r,o]=b.useState(!1),i=b.useRef(null),s=n?.copyUserMessage??"",a=n?.copiedUserMessage??"",l=r?a:s,c=e==null?null:Es(e),d=b.useCallback(p=>{p!==null||i.current===null||(window.clearTimeout(i.current),i.current=null)},[]),u=b.useCallback(()=>{const p=t.trim();if(!p)return;navigator.clipboard?.writeText(p)?.catch(()=>{}),o(!0),i.current!==null&&window.clearTimeout(i.current),i.current=window.setTimeout(()=>{o(!1),i.current=null},1500)},[t]);return f("div",{className:"codex-user-message-actions flex flex-row-reverse items-center gap-1"},f("div",{className:"mr-1 ms-1 flex items-center gap-2 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100"},c==null?null:f("span",{className:"text-xs text-token-text-tertiary"},c),f("button",{ref:d,type:"button",className:`codex-user-message-action-button ${ee} ${te} ${dn} rounded-full electron:rounded-md`,"aria-label":l,title:l,onClick:u},r?Ze("icon-xs"):yn("icon-xs"))))}function Pu({copy:n,sentAtMs:e,text:t}){"use no memo";const[r,o]=b.useState(!1),i=b.useRef(null),s=n?.copyAssistantMessage??"",a=n?.copiedAssistantMessage??s,l=r?a:s,c=e==null?null:Es(e),d=b.useCallback(p=>{p!==null||i.current===null||(window.clearTimeout(i.current),i.current=null)},[]),u=b.useCallback(()=>{const p=t.trim();if(!p)return;navigator.clipboard?.writeText(p)?.catch(()=>{}),o(!0),i.current!==null&&window.clearTimeout(i.current),i.current=window.setTimeout(()=>{o(!1),i.current=null},2e3)},[t]);return f("div",{className:"codex-assistant-message-actions mt-1.5 flex h-5 items-center justify-start gap-0.5 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100"},f("button",{ref:d,type:"button",className:`codex-assistant-message-action-button ${ee} ${te} ${dn} rounded-full electron:rounded-md`,"aria-label":l,title:l,onClick:u},r?Ze("icon-xs"):yn("icon-xs")),c==null?null:f("span",{className:"ml-1.5 flex h-full items-center text-size-chat leading-5 text-token-input-placeholder-foreground"},c))}function Es(n,e=new Date){const t=new Date(n),r=zu(t,e);return r===0?new Intl.DateTimeFormat(void 0,{hour:"numeric",minute:"2-digit"}).format(t):r<0&&r>-7?new Intl.DateTimeFormat(void 0,{hour:"numeric",minute:"2-digit",weekday:"long"}).format(t):new Intl.DateTimeFormat(void 0,{day:"numeric",hour:"numeric",minute:"2-digit",month:"short"}).format(t)}function zu(n,e){const t=new Date(n.getFullYear(),n.getMonth(),n.getDate()),r=new Date(e.getFullYear(),e.getMonth(),e.getDate());return Math.round((t.getTime()-r.getTime())/864e5)}function Fu({copy:n,text:e}){"use no memo";const[t,r]=b.useState(!1),o=Bu(e),i=o&&!t,s=n?.showMore??"",a=n?.showLess??"",l=b.useCallback(()=>{r(c=>!c)},[]);return f("div",{className:"flex flex-col items-end gap-1"},f("div",{className:"codex-user-message-content text-size-chat relative w-full min-w-0 mb-px","data-collapsed":i?"true":void 0,dangerouslySetInnerHTML:{__html:Be(e)}}),o?f("button",{type:"button","aria-expanded":t,className:"codex-user-message-toggle text-size-chat mt-1.5 inline-flex cursor-interaction items-center gap-1 self-start text-token-description-foreground hover:text-token-foreground",onClick:l},f("span",null,t?a:s),bn(`icon-2xs${t?" rotate-180":""}`)):null)}function Bu(n){return n.split(/\r\n|\r|\n/).length>gu||n.length>1600}function Vu({attachments:n}){return f("div",{className:"codex-user-message-attachments flex max-w-[77%] flex-row-reverse flex-wrap items-center gap-1"},n.map(e=>f(_u,{attachment:e,key:e.id})))}function _u({attachment:n}){return n.kind==="image"&&n.dataUrl?f("div",{className:"user-message-image-attachment flex size-16 items-center justify-center rounded-lg border border-token-border",title:n.label},f("img",{alt:n.label,className:"h-full w-full rounded-md object-cover",referrerPolicy:"no-referrer",src:n.dataUrl})):f("div",{className:"user-message-file-attachment bg-token-dropdown-background border-token-border inline-flex max-w-[320px] items-center gap-1 rounded-full border px-2 py-1.5 text-sm",title:n.path},f("span",{className:"text-token-input-placeholder-foreground flex-shrink-0","aria-hidden":!0},Mr("icon-2xs")),f("span",{className:"flex max-w-full min-w-0 items-center gap-1"},f("span",{className:"truncate"},n.label)))}function $u({attachments:n,className:e,copy:t,onRemove:r}){return f("div",{className:`codex-attachment-tray w-full overflow-x-auto${e?` ${e}`:""}`,"data-composer-attachments-row":!0},f("div",{className:"codex-attachment-row flex min-w-max items-end gap-2"},n.map(o=>f(Wu,{attachment:o,copy:t,key:o.id,onRemove:r}))))}function Wu({attachment:n,copy:e,onRemove:t}){const r=`${e?.removeAttachment??"Remove attachment"} ${n.label}`,o=t?f("button",{className:"composer-attachment-remove",type:"button","aria-label":r,onClick:()=>t(n.id),onMouseDown:i=>i.preventDefault()},kn("icon-2xs")):null;return n.kind==="image"&&n.dataUrl?f("div",{className:"composer-attachment-image",title:n.label},f("img",{alt:"","aria-hidden":!0,src:n.dataUrl}),o):f("div",{className:"composer-attachment-file",title:n.path},f("span",{className:"composer-attachment-file-icon","aria-hidden":!0},Mr("icon-xs")),f("span",{className:"composer-attachment-file-label"},n.label),o)}function Hu(n){const e=Rt.c(6),{state:t,providerDisplayName:r}=n,i=(t.context?.rateLimitRows??[]).map(Ba),s=t.context?.copy,[a,l]=b.useState(!1);if(i.length===0)return null;const c=s?.rateLimits??"Rate limits";let d;e[0]!==t?(d=Xa(t),e[0]=t,e[1]=d):d=e[1];let u;e[2]===Symbol.for("react.memo_cache_sentinel")?(u=m=>{m.currentTarget.contains(m.relatedTarget)||l(!1)},e[2]=u):u=e[2];let p;e[3]===Symbol.for("react.memo_cache_sentinel")?(p=()=>l(qu),e[3]=p):p=e[3];let h;return e[4]!==t?(h=(m,g)=>[g>0?f("span",{key:`${m.role}-separator`,className:"rate-limit-inline-separator","aria-hidden":!0},"•"):null,f(Ju,{key:m.role,row:m,state:t})],e[4]=t,e[5]=h):h=e[5],f("div",{className:"rate-line codex-rate-limit-summary relative",role:"status","aria-label":`${r} ${d}`.trim(),onBlur:u},f("button",{className:"rate-limit-trigger rate-limit-trigger-inline flex min-w-0 items-center gap-1",type:"button","aria-expanded":a,onClick:p},f("span",{className:"rate-line-heading"},c),i.flatMap(h)),a?f("div",{className:"rate-limit-popover absolute bottom-[calc(100%+6px)] left-0 z-50 flex min-w-56 flex-col gap-1 rounded-xl border border-token-border bg-token-dropdown-background/95 px-3 py-2 text-sm shadow-xl-spread backdrop-blur-sm"},f("div",{className:"rate-limit-popover-title"},c),i.map(m=>f(Uu,{key:m.role,row:m,state:t}))):null)}function qu(n){return!n}function Ju({row:n,state:e}){const t=n,r=e.context?.copy,o=t.role==="primary"?r?.rateLimitPrimary??"Primary":r?.rateLimitSecondary??"Secondary",i=ri(t.windowDurationMins,o,{weekly:r?.rateLimitWeekly??"Weekly",monthly:r?.rateLimitMonthly??"Monthly",daysFormat:r?.rateLimitDaysFormat??"",hoursFormat:r?.rateLimitHoursFormat??"",minutesFormat:r?.rateLimitMinutesFormat??""}),s=ni(t.resetsAt);return f("span",{className:"rate-limit-inline-segment"},f("span",{className:"rate-limit-window"},i),f("span",{className:"rate-limit-percent"},ti(t.remainingPercent)),s?f("span",{className:"rate-limit-reset"},`${r?.rateLimitResets??"resets"} ${s}`):null)}function Uu({row:n,state:e}){const t=n,r=e.context?.copy,o=t.role==="primary"?r?.rateLimitPrimary??"Primary":r?.rateLimitSecondary??"Secondary",i=ri(t.windowDurationMins,o,{weekly:r?.rateLimitWeekly??"Weekly",monthly:r?.rateLimitMonthly??"Monthly",daysFormat:r?.rateLimitDaysFormat??"",hoursFormat:r?.rateLimitHoursFormat??"",minutesFormat:r?.rateLimitMinutesFormat??""}),s=ni(t.resetsAt);return f("div",{className:"rate-limit-popover-row"},f("span",{className:"rate-limit-window"},i),f("span",{className:"rate-limit-row-value"},f("span",{className:"rate-limit-percent"},ti(t.remainingPercent)),s?f("span",{className:"rate-limit-reset"},`${r?.rateLimitResets??"resets"} ${s}`):null))}function ju({emptyLabel:n,highlightedIndex:e,items:t,onChoose:r,onHighlight:o,query:i}){return f("div",{className:"codex-top-tray-shell absolute z-20"},f("div",{className:"codex-top-tray-panel","cmdk-root":"","data-cmdk-root":!0},f("div",{className:"codex-top-tray-list","cmdk-list":"","data-cmdk-list":!0},t.length===0?f("div",{className:"codex-top-tray-empty","cmdk-empty":"","data-cmdk-empty":!0,"data-command-menu-empty-state":"true"},n):t.map((s,a)=>{const l=s.detail?"codex-top-tray-label max-w-[60%] flex-none truncate":"codex-top-tray-label min-w-0 flex-1 truncate",c=up(s.label,i),d=c.some(u=>!u.isMatch);return f("button",{key:s.id,className:"codex-top-tray-item",type:"button","aria-selected":a===e?"true":void 0,"cmdk-item":"","data-selected":a===e?"true":void 0,"data-list-navigation-item":!0,onMouseEnter:()=>o(a),onMouseDown:u=>u.preventDefault(),onClick:()=>r(s)},f("div",{className:"codex-top-tray-copy flex w-full items-center gap-2"},f("span",{className:"codex-top-tray-icon icon-xs shrink-0","aria-hidden":!0},s.icon),f("div",{className:l},c.map((u,p)=>f("span",{key:`${u.text}-${p}`,className:!u.isMatch&&d?"text-token-description-foreground":void 0},u.text))),s.detail?f("span",{className:"codex-top-tray-detail min-w-0 flex-1 truncate text-sm text-token-description-foreground"},s.detail):null))}))))}function qo({icon:n,label:e,onClear:t}){return f("div",{className:"composer-mode-indicator flex min-w-0 items-center gap-1"},f("div",{className:"composer-mode-divider","aria-hidden":!0}),f("button",{className:`composer-mode-button group ${ee} ${te} ${Oe} rounded-full`,type:"button","aria-label":e,onClick:t},f("span",{className:"composer-mode-icon composer-mode-icon-default icon-xs shrink-0","aria-hidden":!0},n),f("span",{className:"composer-mode-icon composer-mode-icon-hover icon-xs shrink-0","aria-hidden":!0},kn("icon-xs")),f("span",{className:"composer-footer__label--sm composer-mode-label max-w-16 truncate"},e)))}function Ku({hideLabel:n,label:e,onClear:t}){return f("button",{className:`composer-context-button group ${ee} ${te} ${Oe} min-w-0 rounded-full`,type:"button","aria-label":e,title:e,onClick:t},f("span",{className:"composer-context-icon composer-context-icon-default icon-xs shrink-0","aria-hidden":!0},Rs("icon-xs")),f("span",{className:"composer-context-icon composer-context-icon-hover icon-xs shrink-0","aria-hidden":!0},kn("icon-xs")),n?null:f("span",{className:"composer-footer__label--sm composer-context-label max-w-20 truncate"},e))}function Zu({copy:n,hideLabel:e=!1,isEnabled:t,isOpen:r,mode:o,onModeChange:i,onOpenChange:s}){const a=n?.changePermissions??"Change permissions",l=Ko(n,o),c=t?["default","auto-review","full-access","custom"]:[],d=e?la:Oe,u=h=>{i(h),s(!1)},p=t&&r;return f("div",{className:"permissions-root relative inline-flex",onBlur:h=>{t&&!h.currentTarget.contains(h.relatedTarget)&&s(!1)}},f("button",{className:`permissions-trigger ${ee} ${te} ${d} ${e?Gt:"min-w-0"} rounded-full`,type:"button","aria-label":a,"aria-haspopup":t?"menu":void 0,"aria-expanded":t?p:void 0,"aria-disabled":t?void 0:!0,"data-permission-mode":o,"data-state":p?"open":"closed",disabled:!t,onClick:()=>{t&&s(!p)},onKeyDown:h=>{t&&(h.key==="ArrowDown"&&(h.preventDefault(),s(!0)),h.key==="Escape"&&(h.preventDefault(),s(!1)))}},As(o,"icon-xs shrink-0"),e?null:f("span",{className:"permissions-trigger-label composer-footer__label--xs max-w-40 truncate whitespace-nowrap text-left"},l),t?f("span",{className:"permissions-trigger-chevron icon-2xs shrink-0","aria-hidden":!0},bn()):null),p?f("div",{className:"permissions-dropdown _content_1hiti_1 no-drag bg-token-dropdown-background/90 text-token-foreground ring-token-border z-50 m-px flex select-none flex-col overflow-y-auto rounded-xl ring-[0.5px] px-1 py-1 shadow-xl-spread backdrop-blur-sm",role:"menu","aria-label":a,"data-state":"open","data-side":"top",style:{"--radix-dropdown-menu-content-transform-origin":"left bottom"}},c.map(h=>f(Yu,{isSelected:h===o,key:h,label:Ko(n,h),mode:h,onSelect:()=>u(h)}))):null)}function Yu({isSelected:n,label:e,mode:t,onSelect:r}){return f("button",{className:"permissions-item no-drag group hover:bg-token-list-hover-background focus:bg-token-list-hover-background cursor-interaction text-token-foreground outline-hidden rounded-lg px-[var(--padding-row-x)] py-[var(--padding-row-y)] text-sm",type:"button",role:"menuitemradio","aria-checked":n,"data-permission-mode":t,"data-selected":n?"true":void 0,onMouseDown:o=>o.preventDefault(),onClick:r},f("span",{className:"permissions-item-icon icon-xs shrink-0","aria-hidden":!0},As(t,"icon-xs shrink-0")),f("span",{className:"permissions-item-label min-w-0 flex-1 truncate"},e),n?f("span",{className:"permissions-item-check icon-xs shrink-0","aria-hidden":!0},Ze()):null)}function Gu({hasIdeContext:n,isAutoContextOn:e,isOpen:t,isPickingFiles:r,isPlanMode:o,onOpenChange:i,onPickFiles:s,onToggleAutoContext:a,onTogglePlanMode:l,state:c}){const d=c.context?.copy,u=d?.addFilesAndMore??"Add files and more",p=d?.addPhotosAndFiles??"Add photos & files",m=Sr("skill",c,"").find(g=>g.id==="plan")??null;return f("div",{className:"add-context-root relative inline-flex",onBlur:g=>{g.currentTarget.contains(g.relatedTarget)||i(!1)}},f("button",{className:`codex-tool codex-tool-plus ${ee} ${te} ${Oe} ${Gt} rounded-full`,type:"button","aria-label":u,"aria-haspopup":"menu","aria-expanded":t,"data-state":t?"open":"closed",onClick:()=>i(!t),onKeyDown:g=>{g.key==="ArrowDown"&&(g.preventDefault(),i(!0)),g.key==="Escape"&&(g.preventDefault(),i(!1))}},bp()),t?f("div",{className:"add-context-dropdown _content_1hiti_1 no-drag bg-token-dropdown-background/90 text-token-foreground ring-token-border z-50 m-px flex select-none flex-col overflow-y-auto rounded-xl ring-[0.5px] px-1 py-1 shadow-xl-spread backdrop-blur-sm",role:"menu","aria-label":u,"data-state":"open","data-side":"top",style:{"--radix-dropdown-menu-content-transform-origin":"left bottom"}},f(Xu,{disabled:r,icon:Mr("icon-xs"),label:p,onSelect:s}),n||m?f(Qu):null,n?f(Jo,{checked:e,icon:Rs("icon-sm"),label:d?.includeIdeContext??"Include IDE context",onSelect:a}):null,m?f(Jo,{checked:o,icon:cn(),label:d?.planMode??"Plan mode",onSelect:l}):null):null)}function Xu({detail:n,disabled:e=!1,icon:t,label:r,onSelect:o}){return f("button",{className:"add-context-item group no-drag hover:bg-token-list-hover-background focus:bg-token-list-hover-background cursor-interaction text-token-foreground outline-hidden rounded-lg px-[var(--padding-row-x)] py-[var(--padding-row-y)] text-sm",disabled:e,type:"button",role:"menuitem",onMouseDown:i=>i.preventDefault(),onClick:e?void 0:o},f("span",{className:"add-context-item-icon icon-xs shrink-0","aria-hidden":!0},t),f("span",{className:"add-context-item-copy flex min-w-0 flex-1 items-center gap-2"},f("span",{className:"add-context-item-label min-w-0 truncate"},r),n?f("span",{className:"add-context-item-detail min-w-0 flex-1 truncate text-token-description-foreground"},n):null))}function Jo({checked:n,icon:e,label:t,onSelect:r}){const o=n?"checked":"unchecked",i=n?"relative inline-flex h-4 w-7 shrink-0 items-center rounded-full bg-token-charts-blue transition-colors duration-200 ease-out":"relative inline-flex h-4 w-7 shrink-0 items-center rounded-full bg-token-foreground/10 transition-colors duration-200 ease-out";return f("button",{className:"add-context-item group no-drag hover:bg-token-list-hover-background focus:bg-token-list-hover-background cursor-interaction text-token-foreground outline-hidden rounded-lg px-[var(--padding-row-x)] py-[var(--padding-row-y)] text-sm",type:"button",role:"menuitemcheckbox","aria-checked":n,onMouseDown:s=>s.preventDefault(),onClick:r},f("span",{className:"add-context-item-icon icon-xs shrink-0","aria-hidden":!0},e),f("span",{className:"add-context-switch-row flex w-full min-w-0 items-center justify-between gap-2"},f("span",{className:"flex min-w-0 items-center gap-2"},f("span",{className:"add-context-item-label min-w-0 truncate"},t)),f("span",{className:i,"data-state":o,"aria-hidden":!0},f("span",{className:"h-3 w-3 rounded-full border border-[color:var(--gray-0)] bg-[color:var(--gray-0)] shadow-sm transition-transform duration-200 ease-out data-[state=unchecked]:translate-x-[2px] data-[state=checked]:translate-x-[14px]","data-state":o}))))}function Qu(){return f("div",{className:"add-context-separator w-full px-[var(--padding-row-x)] py-1",role:"separator"},f("div",{className:"add-context-separator-line h-[1px] w-full bg-token-menu-border"}))}function ep(n){return Sr("mention",n,"").find(e=>e.id==="workspace")??null}function Sr(n,e,t){const r=e.context?.copy,o=n==="mention"?[...e.context?.workingDirectory?[{id:"workspace",icon:yp("icon-xs"),label:r?.mentionCurrentWorkspace??"Current workspace",detail:Zt(e.context.workingDirectory),mention:{kind:"at",label:Zt(e.context.workingDirectory),name:Zt(e.context.workingDirectory),path:e.context.workingDirectory,fsPath:e.context.workingDirectory}}]:[],...e.providers.map(i=>({id:i.id,icon:Fa(i),label:i.displayName,detail:i.executableName,mention:{kind:"agent",label:i.displayName,name:i.id,displayName:i.displayName,path:`provider://${i.id}`,description:i.executableName}}))]:[{id:"plan",icon:"$",label:r?.skillPlan??"Plan",detail:"$plan",mention:{kind:"skill",label:"Plan",name:"plan",displayName:"Plan",path:"skill://plan"}},{id:"review",icon:"$",label:r?.skillCodeReview??"Code review",detail:"$codex-review",mention:{kind:"skill",label:"Code review",name:"codex-review",displayName:"Code review",path:"skill://codex-review"}},{id:"research",icon:"$",label:r?.skillResearch??"Research",detail:"$research",mention:{kind:"skill",label:"Research",name:"research",displayName:"Research",path:"skill://research"}}];return tp(o,t)}function tp(n,e){const t=e.trim();return t?n.map(r=>({item:r,score:dp(r.label,t,[r.detail,r.mention.name])})).filter(({score:r})=>r>0).sort((r,o)=>o.score-r.score).map(({item:r})=>r):n}const Uo=1,np=.9,rp=.8,op=.17,Wn=.1,Hn=.999,ip=.9999,sp=.99,ap=/[\\/_+.#"@[({&]/,lp=/[\\/_+.#"@[({&]/g,cp=/[\s-]/,Is=/[\s-]/g;function dp(n,e,t){const r=t.length>0?`${n} ${t.join(" ")}`:n;return ir(r,e,jo(r),jo(e),0,0,{})}function jo(n){return n.toLowerCase().replace(Is," ")}function ir(n,e,t,r,o,i,s){if(i===e.length)return o===n.length?Uo:sp;const a=`${o},${i}`;if(s[a]!==void 0)return s[a];const l=r.charAt(i);let c=t.indexOf(l,o),d=0;for(;c>=0;){let u=ir(n,e,t,r,c+1,i+1,s);if(u>d){if(c===o)u*=Uo;else if(ap.test(n.charAt(c-1))){u*=rp;const p=n.slice(o,c-1).match(lp);p&&o>0&&(u*=Hn**p.length)}else if(cp.test(n.charAt(c-1))){u*=np;const p=n.slice(o,c-1).match(Is);p&&o>0&&(u*=Hn**p.length)}else u*=op,o>0&&(u*=Hn**(c-o));n.charAt(c)!==e.charAt(i)&&(u*=ip)}if(uu&&(u=p*Wn)}u>d&&(d=u),c=t.indexOf(l,c+1)}return s[a]=d,d}function up(n,e){const t=Array.from(n),r=e.trim();if(!r)return t.map(l=>({isMatch:!0,text:l}));const o=t.map(l=>l.toLowerCase()),i=Array.from(r.toLowerCase());let s=-1;for(let l=0;l<=o.length-i.length;l+=1)if(i.every((c,d)=>o[l+d]===c)){s=l;break}if(s>=0){const l=s+i.length;return t.map((c,d)=>({isMatch:d>=s&&d{const c=a{const r=`${t.kind}:${t.path}`;return e.has(r)?!1:(e.add(r),!0)})}function fp(n="icon-sm"){return f("svg",{className:n,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},f("path",{d:"M9.33467 16.6663V4.93978L4.6374 9.63704L4.1667 9.16634L3.69599 8.69661L9.52998 2.86263L9.63447 2.77767C9.8925 2.60753 10.2433 2.63564 10.4704 2.86263L16.3034 8.69661L16.3884 8.80111C16.5588 9.05922 16.5306 9.40982 16.3034 9.63704C16.0762 9.86414 15.7255 9.89242 15.4675 9.722L15.363 9.63704L10.6647 4.9388V16.6663C10.6647 17.0336 10.367 17.3314 9.99971 17.3314C9.63259 17.3312 9.33467 17.0335 9.33467 16.6663ZM4.6374 9.63704C4.3777 9.89674 3.95569 9.89674 3.69599 9.63704C3.43657 9.37744 3.43668 8.95628 3.69599 8.69661L4.6374 9.63704Z",fill:"currentColor"}))}function hp(){return f("svg",{width:"16",height:"16",viewBox:"0 0 16 16",fill:"none","aria-hidden":!0},f("rect",{x:"4.75",y:"4.75",width:"6.5",height:"6.5",rx:"1",fill:"currentColor"}))}function mp(){return f("svg",{width:"16",height:"16",viewBox:"0 0 16 16",fill:"none","aria-hidden":!0},f("path",{d:"M8 2.25a2 2 0 0 0-2 2v3.25a2 2 0 1 0 4 0V4.25a2 2 0 0 0-2-2ZM4 7.5a4 4 0 0 0 8 0M8 11.5v2.25",stroke:"currentColor",strokeWidth:"1.4",strokeLinecap:"round"}))}function gp(n="icon-sm"){return f("svg",{className:n,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},f("path",{d:"M10 5.14295V14.8441M10 5.14295C10 1.98209 14.8475 1.94468 14.8475 5.07797L14.8473 5.1067C17.1367 5.43539 18.0436 7.54498 16.4225 9.14006C17.8633 10.5577 17.3503 13.9054 15.4449 14.6301C14.8475 17.6527 10.8056 18.4821 10 14.8441M10 5.14295C10 1.98209 5.15249 1.94468 5.15249 5.07797L5.15265 5.1067C2.86325 5.43539 1.95642 7.54498 3.57746 9.14006C2.13674 10.5577 2.64974 13.9054 4.5551 14.6301C5.15249 17.6527 9.19444 18.4821 10 14.8441",stroke:"currentColor",strokeLinejoin:"round",strokeWidth:"1.33"}))}function bn(n="icon-2xs"){return f("svg",{className:n,width:"20",height:"21",viewBox:"0 0 20 21",fill:"none","aria-hidden":!0},f("path",{d:"M15.2793 7.71101C15.539 7.45131 15.961 7.45131 16.2207 7.71101C16.4804 7.97071 16.4804 8.39272 16.2207 8.65242L10.4707 14.4024C10.211 14.6621 9.78902 14.6621 9.52932 14.4024L3.77932 8.65242L3.69436 8.54792C3.52385 8.28979 3.55205 7.93828 3.77932 7.71101C4.00659 7.48374 4.3581 7.45554 4.61623 7.62605L4.72073 7.71101L10 12.9903L15.2793 7.71101Z",fill:"currentColor",stroke:"currentColor",strokeWidth:"0.6"}))}function xp(n="icon-2xs"){return f("svg",{className:n,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},f("path",{d:"M7.52925 3.7793C7.75652 3.55203 8.10803 3.52383 8.36616 3.69434L8.47065 3.7793L14.2207 9.5293C14.4804 9.789 14.4804 10.211 14.2207 10.4707L8.47065 16.2207C8.21095 16.4804 7.78895 16.4804 7.52925 16.2207C7.26955 15.961 7.26955 15.539 7.52925 15.2793L12.8085 10L7.52925 4.7207L7.44429 4.61621C7.27378 4.35808 7.30198 4.00657 7.52925 3.7793Z",fill:"currentColor"}))}function yn(n="icon-sm"){return f("svg",{className:n,width:"21",height:"21",viewBox:"0 0 21 21",fill:"none","aria-hidden":!0},f("path",{d:"M13.468 11.1216C13.468 10.4107 13.468 9.91717 13.4367 9.53369C13.4137 9.25191 13.3758 9.0622 13.3244 8.91846L13.2687 8.78858C13.1148 8.48652 12.8803 8.23344 12.593 8.05713L12.466 7.98584C12.308 7.90546 12.0963 7.84854 11.7209 7.81787C11.3374 7.78656 10.8439 7.78662 10.133 7.78662H7.29999C6.58895 7.78662 6.09562 7.78654 5.7121 7.81787C5.43015 7.84091 5.24064 7.87872 5.09686 7.93018L4.96698 7.98584C4.66487 8.13977 4.41184 8.37419 4.23554 8.66162L4.16522 8.78858C4.08477 8.94657 4.02794 9.15811 3.99725 9.53369C3.96594 9.91718 3.96503 10.4107 3.96503 11.1216V13.9546C3.96503 14.6656 3.96592 15.159 3.99725 15.5425C4.02796 15.9182 4.08471 16.1296 4.16522 16.2876L4.23554 16.4136C4.41185 16.7012 4.66472 16.9353 4.96698 17.0894L5.09686 17.146C5.24061 17.1974 5.43024 17.2343 5.7121 17.2573C6.09562 17.2887 6.58895 17.2896 7.29999 17.2896H10.133C10.8439 17.2896 11.3374 17.2886 11.7209 17.2573C12.0965 17.2266 12.308 17.1698 12.466 17.0894L12.593 17.019C12.8804 16.8427 13.1148 16.5897 13.2687 16.2876L13.3244 16.1577C13.3759 16.0139 13.4137 15.8244 13.4367 15.5425C13.468 15.159 13.468 14.6656 13.468 13.9546V11.1216ZM14.798 13.1196C15.2528 13.118 15.6011 13.1147 15.8879 13.0913C16.2634 13.0606 16.475 13.0038 16.633 12.9233L16.759 12.8521C17.0466 12.6757 17.2808 12.4228 17.4348 12.1206L17.4914 11.9907C17.5428 11.847 17.5797 11.6572 17.6027 11.3755C17.634 10.992 17.6349 10.4985 17.6349 9.7876V6.95459C17.6349 6.24355 17.6341 5.75022 17.6027 5.3667C17.5797 5.08484 17.5428 4.89522 17.4914 4.75147L17.4348 4.62158C17.2807 4.31933 17.0466 4.06645 16.759 3.89014L16.633 3.81982C16.475 3.73932 16.2636 3.68256 15.8879 3.65186C15.5044 3.62052 15.011 3.61963 14.3 3.61963H11.467C10.7561 3.61963 10.2626 3.62054 9.87909 3.65186C9.59738 3.67487 9.40759 3.71179 9.26386 3.76318L9.13397 3.81982C8.83175 3.97382 8.57885 4.20802 8.40253 4.49561L8.33124 4.62158C8.25079 4.77957 8.19396 4.99114 8.16327 5.3667C8.13984 5.65352 8.13561 6.00178 8.13397 6.45654H10.133C10.822 6.45654 11.3791 6.4559 11.8293 6.49268C12.2873 6.5301 12.6937 6.6093 13.0705 6.80127L13.2883 6.92334C13.7839 7.22739 14.1878 7.66313 14.4533 8.18408L14.5197 8.32666C14.6642 8.66318 14.7291 9.02433 14.7619 9.42529C14.7987 9.8755 14.798 10.4326 14.798 11.1216V13.1196ZM18.965 9.7876C18.965 10.4766 18.9657 11.0337 18.9289 11.4839C18.8961 11.8848 18.8311 12.246 18.6867 12.5825L18.6203 12.7251C18.3548 13.246 17.9509 13.6818 17.4553 13.9858L17.2365 14.1079C16.8599 14.2998 16.4541 14.3791 15.9963 14.4165C15.6592 14.444 15.2624 14.4481 14.7951 14.4497C14.7935 14.917 14.7894 15.3138 14.7619 15.6509C14.7292 16.0516 14.664 16.4122 14.5197 16.7485L14.4533 16.8911C14.1878 17.4122 13.7841 17.8487 13.2883 18.1528L13.0705 18.2749C12.6937 18.4669 12.2873 18.5461 11.8293 18.5835C11.3791 18.6203 10.822 18.6196 10.133 18.6196H7.29999C6.6109 18.6196 6.05394 18.6203 5.6037 18.5835C5.20305 18.5508 4.84233 18.4855 4.50604 18.3413L4.36347 18.2749C3.84243 18.0094 3.40584 17.6056 3.10175 17.1099L2.97968 16.8911C2.78787 16.5145 2.70849 16.1087 2.67108 15.6509C2.6343 15.2006 2.63495 14.6437 2.63495 13.9546V11.1216C2.63495 10.4326 2.63431 9.8755 2.67108 9.42529C2.7085 8.96729 2.78771 8.56084 2.97968 8.18408L3.10175 7.96631C3.40585 7.47049 3.84235 7.06679 4.36347 6.80127L4.50604 6.73486C4.84236 6.59059 5.20302 6.52542 5.6037 6.49268C5.9405 6.46516 6.33707 6.4601 6.80389 6.4585C6.8055 5.99167 6.81056 5.5951 6.83807 5.2583C6.87549 4.80047 6.95482 4.39471 7.14667 4.01807L7.26874 3.79932C7.5728 3.30371 8.00855 2.89973 8.52948 2.63428L8.67206 2.56787C9.00854 2.42345 9.36978 2.35844 9.77069 2.32568C10.2209 2.28891 10.778 2.28955 11.467 2.28955H14.3C14.9891 2.28955 15.546 2.2889 15.9963 2.32568C16.4541 2.3631 16.8599 2.44247 17.2365 2.63428L17.4553 2.75635C17.951 3.06044 18.3548 3.49703 18.6203 4.01807L18.6867 4.16065C18.8309 4.49694 18.8962 4.85765 18.9289 5.2583C18.9657 5.70854 18.965 6.2655 18.965 6.95459V9.7876Z",fill:"currentColor"}))}function Ze(n){return f("svg",{className:n,width:"17",height:"17",viewBox:"0 0 17 17",fill:"none","aria-hidden":!0},f("path",{d:"M12.8961 3.64101C13.1297 3.41418 13.4984 3.37523 13.7779 3.56581C14.0571 3.75635 14.1554 4.11331 14.0299 4.41347L13.9615 4.53847L7.71151 13.7045C7.59411 13.8767 7.4063 13.9877 7.19881 14.0072C6.99136 14.0267 6.78564 13.9533 6.63826 13.806L2.88826 10.056L2.79842 9.9457C2.6192 9.67407 2.64927 9.30496 2.88826 9.06581C3.12738 8.82669 3.49647 8.79676 3.76815 8.97597L3.8785 9.06581L7.03084 12.2182L12.8053 3.74941L12.8961 3.64101Z",fill:"currentColor"}))}function kn(n="icon-sm"){return f("svg",{className:n,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},f("path",{d:"M5.96967 5.96967C6.26256 5.67678 6.73744 5.67678 7.03033 5.96967L10 8.93934L12.9697 5.96967C13.2626 5.67678 13.7374 5.67678 14.0303 5.96967C14.3232 6.26256 14.3232 6.73744 14.0303 7.03033L11.0607 10L14.0303 12.9697C14.3232 13.2626 14.3232 13.7374 14.0303 14.0303C13.7374 14.3232 13.2626 14.3232 12.9697 14.0303L10 11.0607L7.03033 14.0303C6.73744 14.3232 6.26256 14.3232 5.96967 14.0303C5.67678 13.7374 5.67678 13.2626 5.96967 12.9697L8.93934 10L5.96967 7.03033C5.67678 6.73744 5.67678 6.26256 5.96967 5.96967Z",fill:"currentColor"}))}function bp(){return f("svg",{className:"icon-sm",width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},f("path",{d:"M9.33496 16.5V10.665H3.5C3.13273 10.665 2.83496 10.3673 2.83496 10C2.83496 9.63273 3.13273 9.33496 3.5 9.33496H9.33496V3.5C9.33496 3.13273 9.63273 2.83496 10 2.83496C10.3673 2.83496 10.665 3.13273 10.665 3.5V9.33496H16.5L16.6338 9.34863C16.9369 9.41057 17.165 9.67857 17.165 10C17.165 10.3214 16.9369 10.5894 16.6338 10.6514L16.5 10.665H10.665V16.5C10.665 16.8673 10.3673 17.165 10 17.165C9.63273 17.165 9.33496 16.8673 9.33496 16.5Z",fill:"currentColor"}))}function Mr(n="icon-sm"){return f("svg",{className:n,width:"21",height:"21",viewBox:"0 0 21 21",fill:"none","aria-hidden":!0},f("path",{d:"M4.43945 12.8041V7.68261C4.43945 7.30642 4.74446 7.00141 5.12066 7.00141C5.49685 7.00141 5.80186 7.30642 5.80186 7.68261V12.8041C5.80186 15.2565 7.78984 17.2445 10.2422 17.2445C12.6945 17.2445 14.6825 15.2565 14.6825 12.8041V5.9751C14.6823 4.46587 13.4589 3.24247 11.9497 3.24229C10.4403 3.24229 9.21606 4.46576 9.21588 5.9751V12.8041C9.21588 13.3708 9.67553 13.8304 10.2422 13.8304C10.8088 13.8304 11.2685 13.3708 11.2685 12.8041V7.68261C11.2685 7.30642 11.5735 7.00141 11.9497 7.00141C12.3257 7.00159 12.6309 7.30653 12.6309 7.68261V12.8041C12.6309 14.1232 11.5612 15.1929 10.2422 15.1929C8.92314 15.1929 7.85347 14.1232 7.85347 12.8041V5.9751C7.85365 3.71337 9.68791 1.87988 11.9497 1.87988C14.2113 1.88006 16.0447 3.71348 16.0449 5.9751V12.8041C16.0449 16.0089 13.4469 18.6069 10.2422 18.6069C7.03745 18.6069 4.43945 16.0089 4.43945 12.8041Z",fill:"currentColor"}))}function yp(n="icon-sm"){return f("svg",{className:n,width:20,height:20,viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},f("path",{d:Go,fill:"currentColor"}))}function cn(n="icon-sm"){return f("svg",{className:n,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},f("path",{fillRule:"evenodd",clipRule:"evenodd",d:"M5.69336 11.0557C7.05891 11.1944 8.12484 12.3479 8.125 13.75L8.11035 14.0273C7.97144 15.3928 6.81814 16.459 5.41602 16.459L5.13965 16.4443C3.86514 16.3149 2.85128 15.3018 2.72168 14.0273L2.70801 13.75C2.70818 12.2546 3.92061 11.0423 5.41602 11.042L5.69336 11.0557ZM5.41602 12.3721C4.65515 12.3724 4.03826 12.9891 4.03809 13.75C4.03826 14.5109 4.65515 15.1286 5.41602 15.1289C6.17714 15.1289 6.79475 14.5111 6.79492 13.75C6.79475 12.9889 6.17714 12.3721 5.41602 12.3721Z",fill:"currentColor"}),f("path",{d:"M16.8008 13.0986C17.1036 13.1608 17.3311 13.4288 17.3311 13.75C17.3311 14.0712 17.1036 14.3392 16.8008 14.4014L16.666 14.415H10.833C10.4659 14.4149 10.168 14.1172 10.168 13.75C10.168 13.3828 10.4659 13.0851 10.833 13.085H16.666L16.8008 13.0986Z",fill:"currentColor"}),f("path",{d:"M16.8008 5.59863C17.1036 5.66081 17.3311 5.92879 17.3311 6.25C17.3311 6.57121 17.1036 6.83919 16.8008 6.90137L16.666 6.91504H10.833C10.4659 6.91491 10.168 6.61719 10.168 6.25C10.168 5.88281 10.4659 5.58509 10.833 5.58496H16.666L16.8008 5.59863Z",fill:"currentColor"}),f("path",{d:"M7.13311 3.76578C7.35346 3.47216 7.771 3.4128 8.06475 3.63297C8.35843 3.85336 8.41789 4.27084 8.19757 4.56461L5.19757 8.56461C5.0819 8.71866 4.90439 8.81462 4.71221 8.82828C4.5201 8.84178 4.33083 8.77209 4.19464 8.6359L2.69464 7.1359C2.43512 6.87623 2.43512 6.45415 2.69464 6.19449C2.95429 5.93484 3.37633 5.93493 3.63604 6.19449L4.59307 7.15152L7.13311 3.76578Z",fill:"currentColor"}))}function kp(n="icon-sm"){return f("svg",{className:n,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},f("path",{d:"M12.6683 4.16699C12.6683 3.84391 12.4065 3.58203 12.0834 3.58203C11.7603 3.58203 11.4984 3.84391 11.4984 4.16699V7.91699L11.4847 8.05078C11.4227 8.35375 11.1547 8.58203 10.8334 8.58203C10.4662 8.58203 10.1685 8.28411 10.1683 7.91699V3.75C10.1683 3.42691 9.90646 3.16504 9.58337 3.16504C9.26029 3.16504 8.99841 3.42691 8.99841 3.75V7.91699C8.99824 8.28411 8.70053 8.58203 8.33337 8.58203C7.96621 8.58203 7.66851 8.28411 7.66833 7.91699V5C7.66833 4.67691 7.40646 4.41504 7.08337 4.41504C6.76029 4.41504 6.49841 4.67691 6.49841 5V9.30371C6.53326 9.3429 6.56715 9.38359 6.59998 9.42578L8.02478 11.2588C8.25005 11.5486 8.19821 11.9659 7.90857 12.1914C7.6187 12.4169 7.20048 12.365 6.97498 12.0752L5.55017 10.2432C5.15812 9.7391 4.41813 9.73637 4.01501 10.1924C4.04396 10.426 4.11486 10.8323 4.25525 11.3486C4.44664 12.0525 4.75404 12.9113 5.21619 13.7383C6.14103 15.3931 7.62465 16.835 10.0004 16.835C12.8545 16.8348 15.1682 14.5211 15.1683 11.667V6.25C15.1683 5.92691 14.9065 5.66504 14.5834 5.66504C14.2603 5.66504 13.9984 5.92691 13.9984 6.25V9.16699C13.9982 9.53411 13.7005 9.83203 13.3334 9.83203C12.9662 9.83203 12.6685 9.53411 12.6683 9.16699V4.16699ZM13.9984 4.42578C14.1828 4.36671 14.3794 4.33496 14.5834 4.33496C15.641 4.33496 16.4984 5.19237 16.4984 6.25V11.667C16.4982 15.2557 13.589 18.1649 10.0004 18.165C6.95953 18.165 5.10939 16.2734 4.05505 14.3867C3.52774 13.4431 3.1843 12.4787 2.97205 11.6982C2.76447 10.9349 2.66834 10.2954 2.66833 10C2.66833 9.87959 2.70117 9.76148 2.76306 9.6582C3.28988 8.78018 4.26555 8.40372 5.16833 8.56152V5C5.16833 3.94237 6.02575 3.08496 7.08337 3.08496C7.31706 3.08496 7.54039 3.12845 7.74744 3.20508C7.98218 2.41297 8.7151 1.83496 9.58337 1.83496C10.1836 1.83496 10.7186 2.11176 11.0697 2.54395C11.3639 2.35978 11.7107 2.25195 12.0834 2.25195C13.141 2.25195 13.9984 3.10937 13.9984 4.16699V4.42578Z",fill:"currentColor"}))}function wp(n="icon-sm"){return f("svg",{className:n,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},f("path",{fillRule:"evenodd",clipRule:"evenodd",d:"M9.06543 1.95123C9.66107 1.69076 10.3389 1.69071 10.9346 1.95123L15.9346 4.13873C16.7832 4.51008 17.3311 5.34917 17.3311 6.27545V10.5528C17.3309 14.6017 14.0489 17.8847 10 17.8848C5.95108 17.8846 2.66813 14.6017 2.66797 10.5528V6.27545C2.66797 5.34924 3.21695 4.51012 4.06543 4.13873L9.06543 1.95123ZM10.4014 3.16998C10.1456 3.05814 9.85444 3.05819 9.59863 3.16998L4.59863 5.35748C4.23427 5.51708 3.99805 5.87764 3.99805 6.27545V10.5528C3.99821 13.8671 6.68563 16.5546 10 16.5547C13.3144 16.5546 16.0008 13.8671 16.001 10.5528V6.27545C16.001 5.87756 15.7658 5.51703 15.4014 5.35748L10.4014 3.16998Z",fill:"currentColor"}),f("path",{d:"M13.4678 11.4318L13.333 11.4182H10.833C10.466 11.4183 10.1682 11.7162 10.168 12.0832C10.168 12.4504 10.4659 12.7481 10.833 12.7482H13.333L13.4678 12.7346C13.7706 12.6724 13.9981 12.4044 13.9981 12.0832C13.9979 11.7621 13.7706 11.494 13.4678 11.4318Z",fill:"currentColor"}),f("path",{d:"M7.65336 12.426C7.46431 12.7406 7.05607 12.8424 6.74125 12.6535C6.42646 12.4646 6.32395 12.0563 6.51274 11.7414L7.55668 10.0002L6.51274 8.25899C6.32395 7.94412 6.42646 7.53583 6.74125 7.34688C7.05607 7.15799 7.46431 7.25975 7.65336 7.57442L8.90336 9.6584C9.0296 9.86893 9.0296 10.1315 8.90336 10.342L7.65336 12.426Z",fill:"currentColor"}))}function vp(n="icon-sm"){return f("svg",{className:n,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},f("path",{fillRule:"evenodd",clipRule:"evenodd",d:"M9.06543 1.95123C9.66107 1.69076 10.3389 1.69071 10.9346 1.95123L15.9346 4.13873C16.7832 4.51008 17.3311 5.34917 17.3311 6.27545V10.5528C17.3309 14.6017 14.0489 17.8847 10 17.8848C5.95108 17.8846 2.66813 14.6017 2.66797 10.5528V6.27545C2.66797 5.34924 3.21695 4.51012 4.06543 4.13873L9.06543 1.95123ZM10.4014 3.16998C10.1456 3.05814 9.85444 3.05819 9.59863 3.16998L4.59863 5.35748C4.23427 5.51708 3.99805 5.87764 3.99805 6.27545V10.5528C3.99821 13.8671 6.68563 16.5546 10 16.5547C13.3144 16.5546 16.0008 13.8671 16.001 10.5528V6.27545C16.001 5.87756 15.7658 5.51703 15.4014 5.35748L10.4014 3.16998Z",fill:"currentColor"}),f("path",{d:"M10.8883 13.1116C10.8883 13.6025 10.4903 14.0005 9.99936 14.0005C9.50844 14.0005 9.11047 13.6025 9.11047 13.1116C9.11047 12.6207 9.50844 12.2227 9.99936 12.2227C10.4903 12.2227 10.8883 12.6207 10.8883 13.1116Z",fill:"currentColor"}),f("path",{d:"M10.5169 10.8949L11.1135 7.31519C11.2283 6.62672 10.6974 6 9.99941 6C9.30145 6 8.77053 6.62672 8.88528 7.31519L9.4819 10.8949C9.52406 11.1479 9.74294 11.3333 9.99941 11.3333C10.2559 11.3333 10.4748 11.1479 10.5169 10.8949Z",fill:"currentColor"}))}function Cp(n="icon-sm"){return f("svg",{className:n,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},f("path",{fillRule:"evenodd",clipRule:"evenodd",d:"M9.99944 7.24939C11.5169 7.2495 12.7473 8.47995 12.7475 9.99744C12.7475 11.5151 11.517 12.7454 9.99944 12.7455C8.48176 12.7455 7.2514 11.5151 7.2514 9.99744C7.25155 8.47988 8.48186 7.24939 9.99944 7.24939ZM9.99944 8.57947C9.2164 8.57947 8.58163 9.21442 8.58148 9.99744C8.58148 10.7806 9.2163 11.4154 9.99944 11.4154C10.7825 11.4153 11.4174 10.7805 11.4174 9.99744C11.4173 9.21449 10.7824 8.57958 9.99944 8.57947Z",fill:"currentColor"}),f("path",{fillRule:"evenodd",clipRule:"evenodd",d:"M10.6391 1.67517C11.2939 1.67532 11.8991 2.02577 12.226 2.59314L13.2485 4.36755H15.2963C15.9505 4.36758 16.555 4.71709 16.8823 5.28357L17.5219 6.39001C17.8489 6.95668 17.8481 7.65542 17.5209 8.22205L16.4975 9.99451L17.5239 11.7689C17.8519 12.3357 17.8521 13.0347 17.5248 13.6019L16.8862 14.7084C16.559 15.2747 15.9543 15.6243 15.3002 15.6244H13.2514L12.2299 17.3988C11.9029 17.9663 11.297 18.3168 10.642 18.3168L9.3637 18.3158C8.71064 18.3155 8.10718 17.9678 7.77972 17.4027L6.74847 15.6234L4.69964 15.6244C4.04558 15.6242 3.44087 15.2747 3.1137 14.7084L2.47503 13.6019C2.14791 13.0349 2.14836 12.3366 2.47601 11.7699L3.50237 9.99548L2.47894 8.22205C2.15175 7.65533 2.15174 6.95673 2.47894 6.39001L3.11761 5.28259C3.44458 4.71663 4.04894 4.36813 4.70257 4.36755L6.75042 4.36658L7.77581 2.59119C8.10301 2.02476 8.7076 1.67527 9.36175 1.67517H10.6391ZM9.36273 3.00623C9.1835 3.00623 9.01679 3.10199 8.92718 3.2572L7.82659 5.16345C7.63652 5.49253 7.28473 5.69529 6.90472 5.69568L4.70355 5.69763C4.52451 5.69782 4.3585 5.79355 4.26898 5.94861L3.6303 7.05505C3.54091 7.2102 3.54077 7.40192 3.6303 7.55701L4.73089 9.46326C4.92108 9.7929 4.92135 10.1992 4.73089 10.5287L3.62737 12.4359C3.5378 12.591 3.53792 12.7817 3.62737 12.9369L4.26605 14.0433C4.35567 14.1982 4.52067 14.2932 4.69964 14.2933L6.90276 14.2943C7.28242 14.2946 7.63335 14.497 7.82366 14.8256L8.93011 16.7357C9.01984 16.8905 9.18578 16.9857 9.36468 16.9857H10.642C10.8213 16.9857 10.987 16.89 11.0766 16.7347L12.1752 14.8275C12.3653 14.4975 12.7182 14.2943 13.0991 14.2943H15.3002C15.4794 14.2942 15.6452 14.1985 15.7348 14.0433L16.3725 12.9379C16.4621 12.7826 16.4621 12.5911 16.3725 12.4359L15.27 10.5287C15.1032 10.2404 15.0808 9.89331 15.2055 9.59021L15.269 9.46326L16.3696 7.55701C16.4591 7.40189 16.459 7.21022 16.3696 7.05505L15.7309 5.94861C15.6412 5.79363 15.4754 5.69863 15.2963 5.69861L13.0951 5.69763L12.9535 5.68884C12.6751 5.65158 12.4217 5.50519 12.2504 5.28259L12.1723 5.16443L11.0737 3.2572C10.9841 3.10175 10.8175 3.00525 10.6381 3.00525L9.36273 3.00623Z",fill:"currentColor"}))}function Rs(n="icon-sm"){return f("svg",{className:n,width:"20",height:"20",viewBox:"0 0 20 20",fill:"none","aria-hidden":!0},f("path",{d:"M10.6878 9.46029L10.8421 9.49545L17.2913 11.43L17.4642 11.4974C18.2215 11.8649 18.2705 12.9544 17.5492 13.388L17.3822 13.4701L14.5872 14.5872L13.4701 17.3822C13.1135 18.2734 11.8913 18.2756 11.4974 17.4642L11.43 17.2913L9.49544 10.8421C9.26342 10.0687 9.92452 9.34418 10.6878 9.46029ZM12.4984 16.2288L13.3929 13.9954L13.4388 13.8949C13.5579 13.6675 13.7549 13.4891 13.9954 13.3929L16.2288 12.4984L10.9007 10.9007L12.4984 16.2288ZM5.90365 12.9749C6.16329 12.7153 6.58436 12.7154 6.84408 12.9749C7.10378 13.2346 7.10378 13.6557 6.84408 13.9154L5.0765 15.6829C4.8168 15.9426 4.39577 15.9426 4.13607 15.6829C3.87654 15.4232 3.87643 15.0022 4.13607 14.7425L5.90365 12.9749ZM2.83724 7.3265L5.25228 7.97299L5.37826 8.02084C5.65484 8.1591 5.80597 8.47712 5.72298 8.78744C5.63984 9.09774 5.34997 9.298 5.04134 9.27963L4.90853 9.25814L2.49349 8.61068L2.36752 8.56283C2.09082 8.42452 1.93961 8.10666 2.02279 7.79623C2.10599 7.4859 2.39574 7.28652 2.70443 7.30502L2.83724 7.3265ZM14.847 4.05111C15.1051 3.88059 15.4556 3.90894 15.6829 4.13607C15.9426 4.39577 15.9426 4.8168 15.6829 5.0765L13.9154 6.84408C13.6557 7.10378 13.2346 7.10378 12.9749 6.84408C12.7154 6.58437 12.7153 6.16329 12.9749 5.90365L14.7425 4.13607L14.847 4.05111ZM7.79623 2.02279C8.15098 1.92773 8.51562 2.13874 8.61068 2.49349L9.25814 4.90853L9.27962 5.04135C9.298 5.34998 9.09774 5.63984 8.78744 5.72299C8.47713 5.80592 8.15908 5.65484 8.02084 5.37826L7.97298 5.25228L7.3265 2.83724L7.30502 2.70443C7.28652 2.39577 7.48595 2.10603 7.79623 2.02279Z",fill:"currentColor"}))}const Sp='@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-outline-style:solid;--top-fade:0;--bottom-fade:0;--edge-fade-distance:1rem}}}@layer theme{:root,:host{--font-sans:var(--vscode-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);--font-mono:var(--vscode-editor-font-family,ui-monospace, "SFMono-Regular", "SF Mono", Menlo, Consolas, monospace);--color-black:#000;--spacing:.25rem;--text-xs:11px;--text-xs--line-height:calc(1 / .75);--text-sm:12px;--text-sm--line-height:calc(1.25 / .875);--text-base:14px;--text-base--line-height: 1.5 ;--font-weight-medium:500;--radius-md:calc(var(--radius-md-base) * var(--corner-radius-scale));--radius-lg:calc(var(--radius-lg-base) * var(--corner-radius-scale));--radius-xl:calc(var(--radius-xl-base) * var(--corner-radius-scale));--radius-2xl:calc(var(--radius-2xl-base) * var(--corner-radius-scale));--radius-3xl:calc(var(--radius-3xl-base) * var(--corner-radius-scale));--shadow-xl:0px 8px 16px -4px #0000001f;--ease-out:cubic-bezier(0, 0, .2, 1);--blur-sm:8px;--blur-lg:16px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-token-foreground:var(--agent-text);--color-token-description-foreground:var(--agent-muted);--color-token-input-background:var(--agent-input-bg);--color-token-dropdown-background:var(--agent-surface-elevated);--color-token-border:var(--agent-border);--color-token-border-heavy:var(--agent-border-strong);--color-token-button-foreground:var(--agent-text);--color-token-button-background:var(--agent-accent);--color-token-button-secondary-foreground:var(--agent-text);--color-token-button-secondary-background:var(--agent-control);--color-token-button-secondary-hover-background:var(--agent-control-hover);--color-token-input-placeholder-foreground:var(--agent-muted);--color-token-charts-blue:var(--agent-accent);--color-token-text-tertiary:var(--agent-muted);--color-token-menu-border:var(--agent-border);--color-token-list-hover-background:var(--agent-control-hover);--color-token-side-bar-background:var(--agent-surface-elevated);--color-token-text-link-foreground:var(--agent-accent);--color-token-editor-background:var(--agent-page-bg);--corner-radius-scale:1;--radius-md-base:.5rem;--radius-lg-base:.625rem;--radius-xl-base:.75rem;--radius-2xl-base:1rem;--radius-3xl-base:1.25rem;--spacing-token-button-composer:calc(var(--spacing) * 7);--spacing-token-button-composer-sm:calc(var(--spacing) * 5);--spacing-token-button-composer-gap:var(--spacing);--h-token-button-composer-gap:var(--spacing-token-button-composer-gap)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.relative{position:relative}.inset-x-0{inset-inline:calc(var(--spacing) * 0)}.top-0{top:calc(var(--spacing) * 0)}.top-1\\/2{top:50%}.right-2\\.5{right:calc(var(--spacing) * 2.5)}.bottom-0{bottom:calc(var(--spacing) * 0)}.bottom-\\[calc\\(100\\%\\+6px\\)\\]{bottom:calc(100% + 6px)}.bottom-full{bottom:100%}.left-0{left:calc(var(--spacing) * 0)}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.container{width:100%}@media(min-width:40rem){.container{max-width:40rem}}@media(min-width:48rem){.container{max-width:48rem}}@media(min-width:64rem){.container{max-width:64rem}}@media(min-width:80rem){.container{max-width:80rem}}@media(min-width:96rem){.container{max-width:96rem}}.m-px{margin:1px}.ms-1{margin-inline-start:calc(var(--spacing) * 1)}.mt-1\\.5{margin-top:calc(var(--spacing) * 1.5)}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-\\[3px\\]{margin-right:3px}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-px{margin-bottom:1px}.ml-1\\.5{margin-left:calc(var(--spacing) * 1.5)}.ml-auto{margin-left:auto}.box-border{box-sizing:border-box}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.aspect-square{aspect-ratio:1}.size-16{width:calc(var(--spacing) * 16);height:calc(var(--spacing) * 16)}.size-\\[22px\\]{width:22px;height:22px}.size-token-button-composer{width:var(--spacing-token-button-composer);height:var(--spacing-token-button-composer)}.\\!h-auto{height:auto!important}.h-0{height:calc(var(--spacing) * 0)}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-\\[1lh\\]{height:1lh}.h-\\[1px\\]{height:1px}.h-auto{height:auto}.h-full{height:100%}.h-token-button-composer{height:var(--spacing-token-button-composer)}.h-token-button-composer-sm{height:var(--spacing-token-button-composer-sm)}.max-h-\\[25dvh\\]{max-height:25dvh}.max-h-\\[140px\\]{max-height:140px}.max-h-none{max-height:none}.min-h-0{min-height:calc(var(--spacing) * 0)}.min-h-\\[1\\.25rem\\]{min-height:1.25rem}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-7{width:calc(var(--spacing) * 7)}.w-52{width:calc(var(--spacing) * 52)}.w-fit{width:fit-content}.w-full{width:100%}.w-max{width:max-content}.w-px{width:1px}.max-w-16{max-width:calc(var(--spacing) * 16)}.max-w-20{max-width:calc(var(--spacing) * 20)}.max-w-40{max-width:calc(var(--spacing) * 40)}.max-w-\\[60\\%\\]{max-width:60%}.max-w-\\[77\\%\\]{max-width:77%}.max-w-\\[320px\\]{max-width:320px}.max-w-full{max-width:100%}.max-w-none{max-width:none}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-56{min-width:calc(var(--spacing) * 56)}.min-w-max{min-width:max-content}.flex-1{flex:1}.flex-none{flex:none}.flex-shrink-0{flex-shrink:0}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.-translate-y-1\\/2{--tw-translate-y: -50% ;translate:var(--tw-translate-x) var(--tw-translate-y)}.rotate-90{rotate:90deg}.rotate-180{rotate:180deg}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-\\[auto_minmax\\(0\\,1fr\\)_auto\\]{grid-template-columns:auto minmax(0,1fr) auto}.grid-cols-\\[minmax\\(0\\,auto\\)_auto_minmax\\(0\\,1fr\\)\\]{grid-template-columns:minmax(0,auto) auto minmax(0,1fr)}.flex-col{flex-direction:column}.flex-row-reverse{flex-direction:row-reverse}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0\\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-\\[5px\\]{gap:5px}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-clip{overflow:clip}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-3xl{border-radius:var(--radius-3xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-\\[color\\:var\\(--gray-0\\)\\]{border-color:var(--gray-0)}.border-token-border,.border-token-border\\/80{border-color:var(--color-token-border)}@supports (color:color-mix(in lab,red,red)){.border-token-border\\/80{border-color:color-mix(in oklab,var(--color-token-border) 80%,transparent)}}.border-transparent{border-color:#0000}.bg-\\[color\\:var\\(--gray-0\\)\\]{background-color:var(--gray-0)}.bg-token-border\\/70{background-color:var(--color-token-border)}@supports (color:color-mix(in lab,red,red)){.bg-token-border\\/70{background-color:color-mix(in oklab,var(--color-token-border) 70%,transparent)}}.bg-token-button-secondary-background{background-color:var(--color-token-button-secondary-background)}.bg-token-charts-blue{background-color:var(--color-token-charts-blue)}.bg-token-dropdown-background,.bg-token-dropdown-background\\/90{background-color:var(--color-token-dropdown-background)}@supports (color:color-mix(in lab,red,red)){.bg-token-dropdown-background\\/90{background-color:color-mix(in oklab,var(--color-token-dropdown-background) 90%,transparent)}}.bg-token-dropdown-background\\/95{background-color:var(--color-token-dropdown-background)}@supports (color:color-mix(in lab,red,red)){.bg-token-dropdown-background\\/95{background-color:color-mix(in oklab,var(--color-token-dropdown-background) 95%,transparent)}}.bg-token-foreground,.bg-token-foreground\\/5{background-color:var(--color-token-foreground)}@supports (color:color-mix(in lab,red,red)){.bg-token-foreground\\/5{background-color:color-mix(in oklab,var(--color-token-foreground) 5%,transparent)}}.bg-token-foreground\\/10{background-color:var(--color-token-foreground)}@supports (color:color-mix(in lab,red,red)){.bg-token-foreground\\/10{background-color:color-mix(in oklab,var(--color-token-foreground) 10%,transparent)}}.bg-token-input-background\\/90{background-color:var(--color-token-input-background)}@supports (color:color-mix(in lab,red,red)){.bg-token-input-background\\/90{background-color:color-mix(in oklab,var(--color-token-input-background) 90%,transparent)}}.bg-token-menu-border{background-color:var(--color-token-menu-border)}.bg-token-side-bar-background{background-color:var(--color-token-side-bar-background)}.\\[mask-image\\:linear-gradient\\(to_right\\,black_calc\\(100\\%_-_0\\.25rem\\)\\,transparent\\)\\]{-webkit-mask-image:linear-gradient(90deg,#000 calc(100% - .25rem),#0000);mask-image:linear-gradient(90deg,#000 calc(100% - .25rem),#0000)}.\\[mask-repeat\\:no-repeat\\]{-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.object-cover{object-fit:cover}.p-0\\.5{padding:calc(var(--spacing) * .5)}.p-2{padding:calc(var(--spacing) * 2)}.\\!px-0{padding-inline:calc(var(--spacing) * 0)!important}.px-0\\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-\\[var\\(--padding-row-x\\)\\]{padding-inline:var(--padding-row-x)}.py-0{padding-block:calc(var(--spacing) * 0)}.py-0\\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-\\[var\\(--padding-row-y\\)\\]{padding-block:var(--padding-row-y)}.pt-0\\.5{padding-top:calc(var(--spacing) * .5)}.pr-0{padding-right:calc(var(--spacing) * 0)}.pr-1{padding-right:calc(var(--spacing) * 1)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pb-1{padding-bottom:calc(var(--spacing) * 1)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.text-left{text-align:left}.align-bottom{vertical-align:bottom}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.\\!leading-none{--tw-leading:1!important;line-height:1!important}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-\\[18px\\]{--tw-leading:18px;line-height:18px}.leading-\\[calc\\(var\\(--codex-chat-font-size\\)\\+8px\\)\\]{--tw-leading:calc(var(--codex-chat-font-size) + 8px);line-height:calc(var(--codex-chat-font-size) + 8px)}.leading-none{--tw-leading:1;line-height:1}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.break-words{overflow-wrap:break-word}.text-ellipsis{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.text-current{color:currentColor}.text-token-button-secondary-foreground{color:var(--color-token-button-secondary-foreground)}.text-token-description-foreground{color:var(--color-token-description-foreground)}.text-token-dropdown-background{color:var(--color-token-dropdown-background)}.text-token-foreground{color:var(--color-token-foreground)}.text-token-input-placeholder-foreground{color:var(--color-token-input-placeholder-foreground)}.text-token-text-tertiary{color:var(--color-token-text-tertiary)}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-100{opacity:1}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0px 2px 4px -1px var(--tw-shadow-color,#00000014);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-\\[0\\.5px\\]{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(.5px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-token-border{--tw-ring-color:var(--color-token-border)}.outline-hidden{--tw-outline-style:none;outline-style:none}@media(forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.backdrop-blur-lg{--tw-backdrop-blur:blur(var(--blur-lg));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-200{--tw-duration:.2s;transition-duration:.2s}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.select-none{-webkit-user-select:none;user-select:none}.\\[--edge-fade-distance\\:2rem\\]{--edge-fade-distance:2rem}.group-focus-within\\:opacity-100:is(:where(.group):focus-within *){opacity:1}@media(hover:hover){.group-hover\\:opacity-100:is(:where(.group):hover *){opacity:1}.group-hover\\/collapsed-tool-activity\\:text-token-foreground:is(:where(.group\\/collapsed-tool-activity):hover *){color:var(--color-token-foreground)}.hover\\:bg-token-button-secondary-hover-background:hover{background-color:var(--color-token-button-secondary-hover-background)}.hover\\:bg-token-editor-background\\/40:hover{background-color:var(--color-token-editor-background)}@supports (color:color-mix(in lab,red,red)){.hover\\:bg-token-editor-background\\/40:hover{background-color:color-mix(in oklab,var(--color-token-editor-background) 40%,transparent)}}.hover\\:bg-token-list-hover-background:hover{background-color:var(--color-token-list-hover-background)}.hover\\:bg-transparent:hover{background-color:#0000}.hover\\:text-token-button-foreground:hover{color:var(--color-token-button-foreground)}.hover\\:text-token-foreground:hover{color:var(--color-token-foreground)}}.focus\\:bg-token-list-hover-background:focus{background-color:var(--color-token-list-hover-background)}.focus\\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\\:outline-2:focus-visible{outline-style:var(--tw-outline-style);outline-width:2px}.focus-visible\\:outline-token-button-background:focus-visible{outline-color:var(--color-token-button-background)}@media(hover:hover){.enabled\\:hover\\:bg-token-foreground\\/80:enabled:hover{background-color:var(--color-token-foreground)}@supports (color:color-mix(in lab,red,red)){.enabled\\:hover\\:bg-token-foreground\\/80:enabled:hover{background-color:color-mix(in oklab,var(--color-token-foreground) 80%,transparent)}}.enabled\\:hover\\:bg-token-list-hover-background:enabled:hover{background-color:var(--color-token-list-hover-background)}}.disabled\\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\\:opacity-40:disabled{opacity:.4}.data-\\[state\\=checked\\]\\:translate-x-\\[14px\\][data-state=checked]{--tw-translate-x:14px;translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\\[state\\=open\\]\\:bg-token-foreground\\/80[data-state=open]{background-color:var(--color-token-foreground)}@supports (color:color-mix(in lab,red,red)){.data-\\[state\\=open\\]\\:bg-token-foreground\\/80[data-state=open]{background-color:color-mix(in oklab,var(--color-token-foreground) 80%,transparent)}}.data-\\[state\\=open\\]\\:bg-token-list-hover-background[data-state=open]{background-color:var(--color-token-list-hover-background)}.data-\\[state\\=unchecked\\]\\:translate-x-\\[2px\\][data-state=unchecked]{--tw-translate-x:2px;translate:var(--tw-translate-x) var(--tw-translate-y)}@container (min-width:500px){.\\@\\[500px\\]\\:inline{display:inline}}.electron\\:rounded-md:where([data-codex-window-type=electron],[data-codex-window-type=electron] *){border-radius:var(--radius-md)}.electron\\:p-1:where([data-codex-window-type=electron],[data-codex-window-type=electron] *){padding:calc(var(--spacing) * 1)}.electron\\:shadow-\\[0_4px_16px_0_rgba\\(0\\,0\\,0\\,0\\.05\\)\\]:where([data-codex-window-type=electron],[data-codex-window-type=electron] *){--tw-shadow:0 4px 16px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.electron\\:ring:where([data-codex-window-type=electron],[data-codex-window-type=electron] *){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.electron\\:ring-black\\/10:where([data-codex-window-type=electron],[data-codex-window-type=electron] *){--tw-ring-color:#0000001a}@supports (color:color-mix(in lab,red,red)){.electron\\:ring-black\\/10:where([data-codex-window-type=electron],[data-codex-window-type=electron] *){--tw-ring-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}@media(prefers-color-scheme:dark){.electron\\:dark\\:bg-token-dropdown-background:where([data-codex-window-type=electron],[data-codex-window-type=electron] *){background-color:var(--color-token-dropdown-background)}}.extension\\:border:where([data-codex-window-type=extension],[data-codex-window-type=extension] *){border-style:var(--tw-border-style);border-width:1px}.extension\\:border-token-border\\/50:where([data-codex-window-type=extension],[data-codex-window-type=extension] *){border-color:var(--color-token-border)}@supports (color:color-mix(in lab,red,red)){.extension\\:border-token-border\\/50:where([data-codex-window-type=extension],[data-codex-window-type=extension] *){border-color:color-mix(in oklab,var(--color-token-border) 50%,transparent)}}.\\[\\&_\\.ProseMirror\\]\\:\\!h-5 .ProseMirror{height:calc(var(--spacing) * 5)!important}.\\[\\&_\\.ProseMirror\\]\\:h-auto .ProseMirror{height:auto}.\\[\\&_\\.ProseMirror\\]\\:\\!min-h-5 .ProseMirror{min-height:calc(var(--spacing) * 5)!important}.\\[\\&_\\.ProseMirror\\]\\:min-h-\\[2rem\\] .ProseMirror{min-height:2rem}.\\[\\&_\\.ProseMirror\\]\\:min-w-0 .ProseMirror{min-width:calc(var(--spacing) * 0)}.\\[\\&_\\.ProseMirror\\]\\:flex-1 .ProseMirror{flex:1}.\\[\\&_\\.ProseMirror\\]\\:resize-none .ProseMirror{resize:none}.\\[\\&_\\.ProseMirror\\]\\:overflow-hidden .ProseMirror{overflow:hidden}.\\[\\&_\\.ProseMirror\\]\\:leading-5 .ProseMirror{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.\\[\\&_\\.ProseMirror\\]\\:whitespace-nowrap .ProseMirror{white-space:nowrap}.\\[\\&_\\.ProseMirror\\]\\:focus-visible\\:outline-none .ProseMirror:focus-visible{--tw-outline-style:none;outline-style:none}.\\[\\&_\\.ProseMirror_p\\]\\:m-0 .ProseMirror p{margin:calc(var(--spacing) * 0)}.\\[\\&_\\.ProseMirror_p\\]\\:overflow-hidden .ProseMirror p{overflow:hidden}.\\[\\&_\\.ProseMirror_p\\]\\:text-ellipsis .ProseMirror p{text-overflow:ellipsis}.\\[\\&_\\.ProseMirror_p\\]\\:whitespace-nowrap .ProseMirror p{white-space:nowrap}.\\[\\&_\\.contain-inline-size\\]\\:\\[contain\\:initial\\] .contain-inline-size{contain:initial}.\\[\\&\\>\\*\\:last-child\\]\\:mb-0>:last-child{margin-bottom:calc(var(--spacing) * 0)}.\\[\\&\\>ol\\:first-child\\]\\:mt-0>ol:first-child,.\\[\\&\\>ul\\:first-child\\]\\:mt-0>ul:first-child{margin-top:calc(var(--spacing) * 0)}}@property --top-fade{syntax:"";inherits:false;initial-value:0}@property --bottom-fade{syntax:"";inherits:false;initial-value:0}@property --edge-fade-distance{syntax:"";inherits:false;initial-value:1rem}@keyframes edge-fade{0%,1%{--top-fade:0;--bottom-fade:var(--edge-fade-distance,1rem)}99%,to{--top-fade:var(--edge-fade-distance,1rem);--bottom-fade:0}}@supports (animation-timeline:--scroll-fade){.vertical-scroll-fade-mask{-webkit-mask:linear-gradient(to bottom in oklch,oklch(60% 0 0/0),oklch(85% 0 0) var(--top-fade) calc(100% - var(--bottom-fade)),oklch(60% 0 0/0));mask:linear-gradient(to bottom in oklch,oklch(60% 0 0/0),oklch(85% 0 0) var(--top-fade) calc(100% - var(--bottom-fade)),oklch(60% 0 0/0));animation-name:edge-fade;animation-timing-function:linear;animation-fill-mode:both;animation-timeline:scroll(self y)}}:root{color-scheme:dark;font-family:var(--font-sans);color:var(--agent-text,#f1f0e8);--agent-page-bg:transparent;--agent-surface:#1c1f1b57;--agent-surface-elevated:#1c1f1b7a;--agent-input-bg:#080a085c;--agent-border:#e9e7d81f;--agent-border-strong:#e9e7d838;--agent-text:#f1f0e8;--agent-muted:#f1f0e894;--agent-soft:#f1f0e8c7;--agent-accent:#8ab4f8;--agent-accent-soft:#8ab4f833;--agent-danger:#ff8d7e;--agent-shadow:#0003;--agent-card:var(--agent-input-bg);background:0 0}@supports (color:color-mix(in lab,red,red)){:root{--agent-card:color-mix(in srgb, var(--agent-input-bg) 90%, transparent)}}:root{--agent-card-hover:var(--agent-input-bg)}@supports (color:color-mix(in lab,red,red)){:root{--agent-card-hover:color-mix(in srgb, var(--agent-input-bg) 96%, transparent)}}:root{--agent-control:var(--agent-input-bg)}@supports (color:color-mix(in lab,red,red)){:root{--agent-control:color-mix(in srgb, var(--agent-input-bg) 44%, transparent)}}:root{--agent-control-hover:var(--agent-soft)}@supports (color:color-mix(in lab,red,red)){:root{--agent-control-hover:color-mix(in srgb, var(--agent-soft) 8%, transparent)}}:root{--agent-primary:var(--agent-accent)}@supports (color:color-mix(in lab,red,red)){:root{--agent-primary:color-mix(in srgb, var(--agent-accent) 90%, var(--agent-text))}}:root{--agent-primary-text:#fff;--cubic-enter:cubic-bezier(.19, 1, .22, 1);--cubic-exit-snappy:cubic-bezier(.65, 0, .4, 1);--transition-duration-basic:.15s;--gray-0:#fff;--color-background-surface:transparent;--color-background-surface-under:transparent;--color-background-elevated-primary:var(--agent-surface-elevated);--color-background-elevated-primary-opaque:var(--agent-surface-elevated)}@supports (color:color-mix(in lab,red,red)){:root{--color-background-elevated-primary-opaque:color-mix(in srgb, var(--agent-surface-elevated) 100%, #111)}}:root{--color-background-elevated-secondary:var(--agent-card);--color-background-elevated-secondary-opaque:var(--agent-card)}@supports (color:color-mix(in lab,red,red)){:root{--color-background-elevated-secondary-opaque:color-mix(in srgb, var(--agent-card) 100%, #111)}}:root{--thread-content-max-width:none;--markdown-wide-block-max-width:56rem;--padding-toolbar:calc(var(--spacing) * 4);--vscode-foreground:var(--agent-text);--vscode-descriptionForeground:var(--agent-muted);--vscode-input-background:var(--agent-input-bg);--vscode-dropdown-background:var(--agent-surface-elevated);--vscode-input-placeholderForeground:var(--agent-muted);--vscode-textLink-foreground:var(--agent-accent);--vscode-textLink-activeForeground:var(--agent-accent);--vscode-list-hoverBackground:var(--agent-control-hover);--color-token-editor-foreground:var(--agent-text);--color-token-editor-background:var(--agent-page-bg);--color-token-side-bar-background:var(--agent-surface-elevated);--color-token-main-surface-primary:transparent;--color-token-text-code-block-background:var(--agent-input-bg);--color-token-bg-primary:var(--agent-surface-elevated);--color-token-bg-secondary:var(--agent-card);--color-token-bg-fog:var(--color-background-elevated-secondary);--color-token-bg-tertiary:var(--agent-control);--color-token-list-hover-background:var(--agent-control-hover);--markdown-wide-block-max-height:80vh;--conversation-block-gap:12px;--conversation-tool-assistant-gap:16px;--color-token-terminal-ansi-black:var(--vscode-terminal-ansiBlack,#000);--color-token-terminal-ansi-red:var(--vscode-terminal-ansiRed,#f66);--color-token-terminal-ansi-green:var(--vscode-terminal-ansiGreen,#94f494);--color-token-terminal-ansi-yellow:var(--vscode-terminal-ansiYellow,#f4f47b);--color-token-terminal-ansi-blue:var(--vscode-terminal-ansiBlue,#9e9eff);--color-token-terminal-ansi-magenta:var(--vscode-terminal-ansiMagenta,#db6bdb);--color-token-terminal-ansi-cyan:var(--vscode-terminal-ansiCyan,#81eeee);--color-token-terminal-ansi-white:var(--vscode-terminal-ansiWhite,#d6d6d6);--color-token-terminal-ansi-bright-black:var(--vscode-terminal-ansiBrightBlack,#6e6e6e);--color-token-terminal-ansi-bright-red:var(--vscode-terminal-ansiBrightRed,#ffa8a8);--color-token-terminal-ansi-bright-green:var(--vscode-terminal-ansiBrightGreen,#0f0);--color-token-terminal-ansi-bright-yellow:var(--vscode-terminal-ansiBrightYellow,#ffffa8);--color-token-terminal-ansi-bright-blue:var(--vscode-terminal-ansiBrightBlue,#9494ff);--color-token-terminal-ansi-bright-magenta:var(--vscode-terminal-ansiBrightMagenta,#ffb3ff);--color-token-terminal-ansi-bright-cyan:var(--vscode-terminal-ansiBrightCyan,#adffff);--color-token-terminal-ansi-bright-white:var(--vscode-terminal-ansiBrightWhite,#fff);--padding-row-x:calc(var(--spacing) * 2);--padding-row-y:calc(var(--spacing) * 1.25);--height-token-nav-row:32px;--codex-chat-font-size:var(--vscode-chat-font-size,var(--vscode-font-size,13px));--codex-chat-code-font-size:var(--vscode-chat-editor-font-size,var(--vscode-editor-font-size,12px))}:root[data-codex-window-type=electron]{background:0 0;overflow:hidden}:root[data-codex-window-type=electron] body{--padding-row-y:calc(var(--spacing) * 1.25);--height-token-nav-row:calc(var(--text-base) * 1.5 + var(--padding-row-y) * 2);--padding-panel-base:calc(var(--spacing) * 5);--padding-panel:var(--padding-panel-base);--thread-content-max-width:48rem;--markdown-wide-block-max-width:56rem;--markdown-wide-block-max-height:80vh;--conversation-block-gap:12px;--conversation-tool-assistant-gap:16px;--spacing-token-button-composer-sm:calc(var(--spacing) * 7);--text-heading-md:18px;--cursor-interaction:default;--vscode-editor-font-family:ui-monospace, "SFMono-Regular", "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;--vscode-font-size:var(--text-base);--vscode-editor-font-size:var(--text-sm);--vscode-chat-font-size:var(--vscode-font-size);--vscode-chat-editor-font-size:var(--vscode-editor-font-size);overscroll-behavior:none;overflow:hidden}:root[data-codex-window-type=electron]:not([data-codex-os=win32]) body{background:0 0}*{box-sizing:border-box}html,body,#root{background:0 0;height:100%}body{min-width:0;color:var(--agent-text);font-family:var(--font-sans);font-size:var(--text-base);font-weight:var(--vscode-font-weight,400);margin:0;overflow:hidden}button,select,textarea{font:inherit}button,select{cursor:default}.agent-shell{height:100%;min-height:100%;padding:var(--padding-toolbar);color:var(--agent-text);background:var(--agent-page-bg);flex-direction:column;display:flex}.agent-log,.log,.agent-thread{width:100%;min-height:0;max-width:var(--thread-content-max-width);scrollbar-width:thin;scrollbar-color:var(--agent-muted) transparent;background:0 0;flex:auto;margin:0 auto;padding:6px 2px 18px;overflow:auto}@supports (color:color-mix(in lab,red,red)){.agent-log,.log,.agent-thread{scrollbar-color:color-mix(in srgb,var(--agent-muted) 28%,transparent) transparent}}.agent-thread{gap:var(--conversation-block-gap,12px);flex-direction:column;padding-top:10px;padding-bottom:24px;display:flex;position:relative;overflow-x:hidden}:root[data-codex-window-type=electron] .agent-thread{--color-token-description-foreground:var(--color-token-foreground)}@supports (color:color-mix(in lab,red,red)){:root[data-codex-window-type=electron] .agent-thread{--color-token-description-foreground:color-mix(in srgb, var(--color-token-foreground) 70%, transparent)}}.agent-thread[data-empty]{justify-content:flex-end}.text-size-chat{font-size:var(--codex-chat-font-size)}.text-size-chat-sm{font-size:calc(var(--codex-chat-font-size) - 1px)}.text-size-code{font-size:var(--codex-chat-code-font-size)}.text-size-code-sm{font-size:calc(var(--codex-chat-code-font-size) - 1px)}.font-vscode-editor{font-family:var(--vscode-editor-font-family,var(--font-mono))}.codex-user-turn,.codex-assistant-turn,.codex-notice-turn{width:100%;min-width:0}.codex-user-bubble{color:var(--color-token-foreground);background:var(--color-token-foreground)}@supports (color:color-mix(in lab,red,red)){.codex-user-bubble{background:color-mix(in srgb,var(--color-token-foreground) 5%,transparent)}}.codex-user-message-content[data-collapsed=true]{-webkit-line-clamp:20;-webkit-box-orient:vertical;max-height:20lh;display:-webkit-box;overflow:hidden}.codex-user-message-toggle{background:0 0;border:0;padding:0}.codex-assistant-turn{justify-content:flex-start;display:flex}.codex-assistant-message{max-width:100%;color:var(--color-token-foreground);white-space:normal;overflow-wrap:anywhere}.codex-assistant-message-streaming{white-space:break-spaces}.codex-assistant-message :where(p){margin:0 0 .85em}.codex-assistant-message :where(p:last-child){margin-bottom:0}.codex-assistant-message :where(ul,ol){margin:.45em 0 .85em;padding-left:1.35rem}.codex-assistant-message :where(ul){list-style:outside}.codex-assistant-message :where(ol){list-style:decimal}.codex-assistant-message :where(li){margin:.25em 0}.codex-assistant-message :where(blockquote){border-left:2px solid var(--color-token-border);color:var(--agent-soft);margin:.75em 0;padding-left:.85rem}.codex-assistant-message :where(code){background:var(--agent-soft);border-radius:4px;padding:.12em .3em}@supports (color:color-mix(in lab,red,red)){.codex-assistant-message :where(code){background:color-mix(in srgb,var(--agent-soft) 10%,transparent)}}.codex-assistant-message :where(code){font-family:var(--font-mono);font-size:var(--codex-chat-code-font-size)}.codex-assistant-message :where(pre){border:1px solid var(--agent-border);margin:.75em 0;padding:.65rem .75rem;overflow-x:auto}@supports (color:color-mix(in lab,red,red)){.codex-assistant-message :where(pre){border:1px solid color-mix(in srgb,var(--agent-border) 72%,transparent)}}.codex-assistant-message :where(pre){border-radius:var(--radius-lg);background:var(--agent-input-bg)}@supports (color:color-mix(in lab,red,red)){.codex-assistant-message :where(pre){background:color-mix(in srgb,var(--agent-input-bg) 82%,transparent)}}.codex-assistant-message :where(pre code){white-space:pre;background:0 0;border-radius:0;padding:0;display:block}.codex-assistant-message :where(a){color:var(--agent-accent);text-underline-offset:2px;text-decoration:underline}.codex-assistant-message :where(hr){border-top:1px solid var(--color-token-border);margin:1rem 0}.codex-notice-turn{justify-content:flex-start;display:flex}.codex-notice-content{border:1px solid var(--agent-border);max-width:min(100%,44rem);padding:10px 12px}@supports (color:color-mix(in lab,red,red)){.codex-notice-content{border:1px solid color-mix(in srgb,var(--agent-border) 74%,transparent)}}.codex-notice-content{border-radius:var(--radius-lg);color:var(--agent-soft);background:var(--agent-surface-elevated)}@supports (color:color-mix(in lab,red,red)){.codex-notice-content{background:color-mix(in srgb,var(--agent-surface-elevated) 86%,transparent)}}.codex-notice-content{overflow-wrap:anywhere}.codex-notice-turn.error .codex-notice-content{border-color:var(--agent-danger)}@supports (color:color-mix(in lab,red,red)){.codex-notice-turn.error .codex-notice-content{border-color:color-mix(in srgb,var(--agent-danger) 46%,transparent)}}.codex-notice-turn.error .codex-notice-content{color:var(--agent-danger)}@supports (color:color-mix(in lab,red,red)){.codex-notice-turn.error .codex-notice-content{color:color-mix(in srgb,var(--agent-danger) 88%,var(--agent-text))}}.codex-notice-turn.error .codex-notice-content{background:var(--agent-danger)}@supports (color:color-mix(in lab,red,red)){.codex-notice-turn.error .codex-notice-content{background:color-mix(in srgb,var(--agent-danger) 11%,transparent)}}.codex-notice-turn.warning .codex-notice-content{border-color:var(--agent-accent)}@supports (color:color-mix(in lab,red,red)){.codex-notice-turn.warning .codex-notice-content{border-color:color-mix(in srgb,var(--agent-accent) 30%,transparent)}}.codex-tool-activity-turn{flex-direction:column;align-items:flex-start;gap:0;width:100%;min-width:0;display:flex}.codex-tool-activity-summary{max-width:100%;color:var(--color-token-foreground);border:0;padding:0}@supports (color:color-mix(in lab,red,red)){.codex-tool-activity-summary{color:color-mix(in srgb,var(--color-token-foreground) 40%,transparent)}}.codex-tool-activity-summary{font:inherit;font-size:var(--codex-chat-font-size);line-height:calc(var(--codex-chat-font-size) + 8px);background:0 0}.codex-tool-activity-summary:focus-visible{outline:none}.codex-tool-activity-turn.inProgress .codex-tool-activity-summary{color:var(--color-token-description-foreground)}@supports (color:color-mix(in lab,red,red)){.codex-tool-activity-turn.inProgress .codex-tool-activity-summary{color:color-mix(in srgb,var(--color-token-description-foreground) 90%,transparent)}}.codex-tool-activity-icon{color:var(--color-token-foreground);justify-content:center;align-items:center;display:inline-flex}@supports (color:color-mix(in lab,red,red)){.codex-tool-activity-icon{color:color-mix(in srgb,var(--color-token-foreground) 35%,transparent)}}.codex-tool-activity-icon{font-family:var(--font-mono);font-size:11px;line-height:1}.codex-tool-activity-turn.inProgress .codex-tool-activity-icon{animation:1.2s ease-in-out infinite codex-tool-activity-pulse}.codex-tool-activity-text{align-items:baseline;gap:4px;min-width:0;max-width:100%;display:inline-flex}.codex-tool-activity-action{color:var(--color-token-foreground);flex:none}@supports (color:color-mix(in lab,red,red)){.codex-tool-activity-action{color:color-mix(in srgb,var(--color-token-foreground) 58%,transparent)}}.codex-tool-activity-detail{min-width:0;color:var(--color-token-foreground);overflow:hidden}@supports (color:color-mix(in lab,red,red)){.codex-tool-activity-detail{color:color-mix(in srgb,var(--color-token-foreground) 40%,transparent)}}.codex-tool-activity-detail{font-family:var(--font-mono);text-overflow:ellipsis;white-space:nowrap}.codex-tool-activity-summary:hover .codex-tool-activity-action,.codex-tool-activity-summary:hover .codex-tool-activity-detail,.codex-tool-activity-summary:hover .codex-tool-activity-icon{color:var(--color-token-foreground)}.codex-tool-activity-output-shell{opacity:0;pointer-events:none;grid-template-rows:0fr;width:100%;min-width:0;transition:grid-template-rows .18s ease-out,opacity .18s ease-out;display:grid;overflow:hidden}.codex-tool-activity-output-shell[data-expanded=true]{opacity:1;pointer-events:auto;grid-template-rows:1fr}.codex-tool-activity-output-shell-inner{min-height:0;overflow:hidden}.codex-tool-activity-output-frame{min-height:1.25rem;padding-right:0;position:relative}.codex-tool-activity-output{box-sizing:border-box;max-width:min(100%,var(--markdown-wide-block-max-width));max-height:140px;color:var(--color-token-description-foreground);white-space:pre;--edge-fade-distance:2rem;background:0 0;border:0;border-radius:0;flex-direction:column;gap:6px;margin:0;padding:8px;font-weight:500;display:flex;overflow:auto}.codex-tool-output-copy:focus-visible{opacity:1}@keyframes codex-tool-activity-pulse{0%,to{opacity:.45}50%{opacity:1}}.agent-log-line,.log-line{border-bottom:1px solid var(--agent-border);grid-template-columns:4.5rem minmax(0,1fr);gap:12px;padding:9px 0;display:grid}@supports (color:color-mix(in lab,red,red)){.agent-log-line,.log-line{border-bottom:1px solid color-mix(in srgb,var(--agent-border) 55%,transparent)}}.agent-log-line,.log-line{color:var(--agent-soft);font-family:var(--font-mono);font-size:12px;line-height:1.5}.agent-log-line:last-child,.log-line:last-child{border-bottom-color:#0000}.agent-log-label,.log-label{color:var(--agent-muted);text-overflow:ellipsis;text-transform:uppercase;overflow:hidden}.agent-log-line.stdout .agent-log-label,.log-line.stdout .log-label{color:var(--agent-accent)}.agent-log-line.stderr .agent-log-label,.agent-log-line.error .agent-log-label,.log-line.stderr .log-label,.log-line.error .log-label{color:var(--agent-danger)}.agent-log-text,.log-text{overflow-wrap:anywhere;white-space:pre-wrap;word-break:normal;min-width:0}.agent-composer-stack{width:100%;max-width:var(--thread-content-max-width);gap:6px;margin:0 auto;display:grid}.above-composer-suggestion-shell{container-type:inline-size}.codex-composer-frame{--composer-top-tray-inline-padding:12px;--composer-top-tray-top-padding:14px;--composer-top-tray-max-height:177px}.codex-composer-surface{color:var(--color-token-foreground)}.codex-composer-inner{isolation:isolate}@supports (corner-shape:superellipse(1.5)){:root{--corner-radius-scale:1.25}.codex-composer-surface,.codex-user-bubble,.codex-notice-content,.provider-dropdown,.provider-dropdown-item,.add-context-dropdown,.add-context-item,.composer-attachment-file,.composer-attachment-image,.rate-limit-popover,.codex-top-tray-panel,.codex-top-tray-item{corner-shape:superellipse(1.5)}}.codex-composer-single-line{min-height:0}.composer-single-line-measure{pointer-events:none;visibility:hidden;white-space:pre;width:max-content;max-width:none;height:0;font-family:var(--font-sans);font-size:var(--text-base);line-height:20px;position:absolute;overflow:hidden}.h-token-button-composer{height:var(--spacing-token-button-composer);gap:var(--h-token-button-composer-gap)}.h-token-button-composer-sm{height:var(--spacing-token-button-composer-sm);gap:var(--h-token-button-composer-gap)}.size-token-button-composer{width:var(--spacing-token-button-composer);height:var(--spacing-token-button-composer)}.size-token-button-composer-sm{width:var(--spacing-token-button-composer-sm);height:var(--spacing-token-button-composer-sm)}.icon-3xs{width:10px;height:10px}.icon-xxs{width:12px;height:12px}.icon-2xs{width:14px;height:14px}.icon-xs{width:16px;height:16px}.icon-sm{width:18px;height:18px}.icon-base{width:20px;height:20px}.icon-md{width:24px;height:24px}.icon-lg{width:28px;height:28px}.cursor-interaction{cursor:var(--cursor-interaction)}.no-drag{-webkit-app-region:no-drag}.outline-hidden{outline-offset:2px;outline:2px solid #0000}.user-select-none{-webkit-user-select:none;user-select:none}.shadow-xl-spread{--tw-shadow:0px 0px 0px .5px var(--tw-shadow-color,var(--color-token-border)), var(--shadow-xl);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}._content_1hiti_1{--dropdown-content-inset:2px;--dropdown-content-start-transform:translateY(calc(var(--dropdown-content-inset) * -1)) scale(.98);transform-origin:var(--radix-dropdown-menu-content-transform-origin);will-change:opacity,transform}._content_1hiti_1[data-side=top]{--dropdown-content-start-transform:translateY(var(--dropdown-content-inset)) scale(.98)}._content_1hiti_1[data-side=right]{--dropdown-content-start-transform:translateX(calc(var(--dropdown-content-inset) * -1)) scale(.98)}._content_1hiti_1[data-side=left]{--dropdown-content-start-transform:translateX(var(--dropdown-content-inset)) scale(.98)}@media(prefers-reduced-motion:no-preference){._content_1hiti_1[data-state=open]{animation:_dropdown-content-enter_1hiti_1 var(--transition-duration-basic) var(--cubic-enter)}}@keyframes _dropdown-content-enter_1hiti_1{0%{opacity:0;transform:var(--dropdown-content-start-transform)}to{opacity:1;transform:translate(0)translateY(0)scale(1)}}.hide-scrollbar{scrollbar-width:none}.hide-scrollbar::-webkit-scrollbar{display:none}.prompt-input{resize:none;width:100%;min-height:2.75rem;max-height:200px;color:var(--color-token-foreground);font-size:var(--text-base);background:0 0;border:0;outline:none;padding:0;line-height:20px;display:block}.prompt-input::placeholder{color:var(--color-token-input-placeholder-foreground)}@supports (color:color-mix(in lab,red,red)){.prompt-input::placeholder{color:color-mix(in srgb,var(--color-token-input-placeholder-foreground) 50%,transparent)}}.ProseMirror{min-width:0;color:var(--agent-text);caret-color:var(--agent-text);font-variant-ligatures:none;font-feature-settings:"liga" 0;white-space:pre-wrap;white-space:break-spaces;word-wrap:break-word;word-break:normal;background:0 0;outline:none;position:relative;overflow-y:auto}.ProseMirror pre{white-space:pre-wrap}.ProseMirror li{position:relative}.ProseMirror-hideselection{caret-color:#0000;background:0 0}.ProseMirror-hideselection ::selection{caret-color:#0000;background:0 0}.ProseMirror [draggable][contenteditable=false]{-webkit-user-select:text;user-select:text}.ProseMirror-selectednode{outline:2px solid #8cf}li.ProseMirror-selectednode{outline:none}li.ProseMirror-selectednode:after{content:"";pointer-events:none;border:2px solid #8cf;position:absolute;inset:-2px -2px -2px -32px}img.ProseMirror-separator{border:none!important;margin:0!important;display:inline!important}.ProseMirror p{margin:0}.ProseMirror .placeholder{pointer-events:none;-webkit-user-select:none;user-select:none;position:relative}.ProseMirror .placeholder:after{color:var(--color-token-input-placeholder-foreground);content:attr(data-placeholder);opacity:.5;pointer-events:none;-webkit-user-select:none;user-select:none;white-space:nowrap;text-overflow:ellipsis;max-width:100%;position:absolute;inset:0 0 auto;overflow:hidden}.ProseMirror .placeholder::selection{color:inherit;background:0 0}.ProseMirror .placeholder ::selection{color:inherit;background:0 0}.ProseMirror .ProseMirror-selectednode:has([skill-mention-name]){outline:none}.prompt-editor-mention{--inline-mention-base-color:var(--color-token-text-link-foreground)}@supports (color:color-mix(in lab,red,red)){.prompt-editor-mention{--inline-mention-base-color:color-mix(in srgb, var(--color-token-text-link-foreground) 80%, var(--color-token-foreground) 20%)}}.prompt-editor-mention{--inline-mention-color:var(--inline-mention-resolved-base-color,var(--inline-mention-base-color));max-width:100%;color:var(--inline-mention-color);vertical-align:baseline;white-space:nowrap;align-items:baseline;display:inline-flex}.prompt-editor-mention:hover{text-underline-offset:2px;text-decoration-line:underline;text-decoration-style:dashed;text-decoration-thickness:.5px}.prompt-editor-mention-icon{color:currentColor}[cmdk-dialog],[cmdk-dialog]:focus-visible{outline:none}[cmdk-root],[data-cmdk-root]{z-index:1000;gap:var(--spacing);min-width:100%;padding:var(--spacing);border:1px solid var(--color-token-border);border-radius:var(--radius-3xl);color:var(--color-token-foreground);background-color:var(--color-token-dropdown-background);flex-direction:column;display:flex}@supports (color:color-mix(in lab,red,red)){[cmdk-root],[data-cmdk-root]{background-color:color-mix(in oklab,var(--color-token-dropdown-background) 95%,transparent)}}[cmdk-root],[data-cmdk-root]{font-size:var(--text-sm);opacity:1;-webkit-user-select:none;user-select:none;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);--cmdk-bottom-item-radius:calc(var(--radius-3xl) - var(--spacing));transform:translateZ(0)}[cmdk-item]{content-visibility:auto;width:100%;min-height:calc(var(--spacing) * 6);padding:var(--padding-row-y) var(--padding-row-x);border-radius:var(--radius-lg);color:var(--color-token-foreground);cursor:var(--cursor-interaction);opacity:.75;-webkit-user-select:none;user-select:none;outline:none;flex-direction:row;align-items:center;display:flex}[cmdk-item][aria-disabled=true],[cmdk-item][data-disabled=true]{cursor:default;opacity:.25}[cmdk-item]:hover:not([aria-disabled=true]),[cmdk-item][data-selected=true],[cmdk-item][aria-selected=true],[cmdk-item]:focus-visible,[cmdk-item]:active:not([aria-disabled=true]){background-color:var(--color-token-list-hover-background);opacity:1}[cmdk-item][data-command-menu-loading=true],[cmdk-item][data-command-menu-loading=true]:hover,[cmdk-item][data-command-menu-loading=true][data-selected=true],[cmdk-item][data-command-menu-loading=true][aria-selected=true],[cmdk-item][data-command-menu-loading=true]:focus-visible,[cmdk-item][data-command-menu-loading=true]:active{cursor:default;opacity:.6;background-color:#0000}[cmdk-item][data-command-menu-empty-state=true],[cmdk-item][data-command-menu-empty-state=true]:hover,[cmdk-item][data-command-menu-empty-state=true][data-selected=true],[cmdk-item][data-command-menu-empty-state=true][aria-selected=true],[cmdk-item][data-command-menu-empty-state=true]:focus-visible,[cmdk-item][data-command-menu-empty-state=true]:active{cursor:default;opacity:1;background-color:#0000}[cmdk-list]{min-height:0;max-height:min(300px,var(--cmdk-list-height,300px));gap:var(--spacing);overscroll-behavior:contain;scrollbar-width:none;flex-direction:column;transition:max-height .1s;display:flex;overflow-y:auto}[cmdk-list]:focus-visible{outline:none}.command-menu-dialog [cmdk-list]{max-height:var(--command-menu-list-max-height);scroll-padding-block-start:calc(var(--spacing) * 8)}.composer-home-top-menu [cmdk-list]{max-height:none}.composer-home-top-menu{max-height:min(320px,var(--composer-top-menu-max-height,320px));overflow:hidden}:where([cmdk-list])>[cmdk-item]:not(:has(~[cmdk-item],~[cmdk-group]:not([hidden]))),:where([cmdk-list])>[cmdk-group]:not([hidden]):not(:has(~[cmdk-item],~[cmdk-group]:not([hidden]))) [cmdk-item]:last-of-type,:where([cmdk-list])>[cmdk-list-sizer]>[cmdk-item]:not(:has(~[cmdk-item],~[cmdk-group]:not([hidden]))),:where([cmdk-list])>[cmdk-list-sizer]>[cmdk-group]:not([hidden]):not(:has(~[cmdk-item],~[cmdk-group]:not([hidden]))) [cmdk-item]:last-of-type{border-bottom-right-radius:var(--cmdk-bottom-item-radius);border-bottom-left-radius:var(--cmdk-bottom-item-radius)}[cmdk-input]{padding:calc(var(--spacing) * 1.5) calc(var(--spacing) * 2.5)}[cmdk-input]:focus-visible{outline:none}[cmdk-empty],[data-cmdk-empty]{min-height:calc(var(--spacing) * 8);padding:calc(var(--spacing) * 1.5) calc(var(--spacing) * 2.5);color:var(--color-token-description-foreground);text-align:center;white-space:pre-wrap;align-items:center;line-height:1.4;display:flex}[data-codex-window-type=electron] [cmdk-root],[data-codex-window-type=electron] [data-cmdk-root]{font-size:var(--text-base)}.codex-attachment-tray{scrollbar-width:none}.codex-attachment-tray::-webkit-scrollbar{display:none}.codex-user-attachment-tray{max-width:77%}.codex-user-attachment-tray .codex-attachment-row{justify-content:flex-end;min-width:0}.composer-attachment-image{border:1px solid var(--color-token-border-heavy);border-radius:var(--radius-lg);background:var(--color-token-input-background);flex:none;width:80px;height:80px;display:inline-flex;position:relative;overflow:visible}.composer-attachment-image img{border-radius:calc(var(--radius-lg) - 1px);object-fit:cover;width:100%;height:100%}.composer-attachment-file{border:.5px solid var(--color-token-border-heavy);flex:none;align-items:center;gap:7px;max-width:min(220px,70vw);height:32px;padding:0 30px 0 10px;display:inline-flex;position:relative}@supports (color:color-mix(in lab,red,red)){.composer-attachment-file{border:.5px solid color-mix(in srgb,var(--color-token-border-heavy) 80%,transparent)}}.composer-attachment-file{border-radius:var(--radius-lg);color:var(--color-token-foreground);background:var(--color-token-dropdown-background)}@supports (color:color-mix(in lab,red,red)){.composer-attachment-file{background:color-mix(in srgb,var(--color-token-dropdown-background) 90%,transparent)}}.composer-attachment-file-icon{color:var(--color-token-description-foreground);flex:none;display:inline-flex}.composer-attachment-file-label{min-width:0;font-size:var(--text-sm);text-overflow:ellipsis;white-space:nowrap;font-weight:500;line-height:1rem;overflow:hidden}.composer-attachment-remove{border:.5px solid var(--color-token-border-heavy);justify-content:center;align-items:center;width:18px;height:18px;display:inline-flex;position:absolute;top:-7px;right:-7px}@supports (color:color-mix(in lab,red,red)){.composer-attachment-remove{border:.5px solid color-mix(in srgb,var(--color-token-border-heavy) 70%,transparent)}}.composer-attachment-remove{color:var(--color-token-foreground);background:var(--color-token-dropdown-background);border-radius:999px;box-shadow:0 1px 3px #0000002e}.composer-attachment-remove:hover,.composer-attachment-remove:focus-visible{background:var(--color-token-list-hover-background)}.codex-left-rail,.codex-secondary-controls,.codex-action-cluster,.model-picker,.rate-line{align-items:center;display:flex}.codex-left-rail{scrollbar-width:none;flex:0 auto;min-width:0;margin:0;padding:0;overflow-x:auto}.codex-left-rail::-webkit-scrollbar{display:none}.composer-mode-indicator{color:var(--color-token-text-link-foreground);flex:0 auto}.composer-mode-divider{background:var(--color-token-border);width:1px;height:1rem;margin:0 1px}@supports (color:color-mix(in lab,red,red)){.composer-mode-divider{background:color-mix(in srgb,var(--color-token-border) 70%,transparent)}}.composer-mode-button{min-width:0;color:var(--color-token-text-link-foreground)}.composer-mode-icon{flex:none;justify-content:center;align-items:center;display:inline-flex}.composer-mode-icon-hover,.composer-mode-button:hover .composer-mode-icon-default,.composer-mode-button:focus-visible .composer-mode-icon-default{display:none}.composer-mode-button:hover .composer-mode-icon-hover,.composer-mode-button:focus-visible .composer-mode-icon-hover{display:inline-flex}.composer-mode-label{color:currentColor;font-weight:500}.composer-context-indicator{color:var(--color-token-text-link-foreground);flex:0 auto}.composer-context-divider{background:var(--color-token-border);margin:0 1px}@supports (color:color-mix(in lab,red,red)){.composer-context-divider{background:color-mix(in srgb,var(--color-token-border) 70%,transparent)}}.composer-context-button{color:var(--color-token-text-link-foreground)}.composer-context-icon{justify-content:center;align-items:center;display:inline-flex}.composer-context-icon-hover,.composer-context-button:hover .composer-context-icon-default,.composer-context-button:focus-visible .composer-context-icon-default{display:none}.composer-context-button:hover .composer-context-icon-hover,.composer-context-button:focus-visible .composer-context-icon-hover{display:inline-flex}.composer-context-label{color:currentColor;font-weight:500}.codex-secondary-controls{flex:0 auto;justify-content:flex-end}.codex-action-cluster{flex:none;justify-content:flex-end}.model-picker-root{flex:none;align-items:center;display:inline-flex}.model-picker,.codex-tool,.codex-action{outline:none;flex:none;justify-content:center;align-items:center;display:inline-flex;position:relative}.codex-tool,.codex-action{line-height:18px}.model-picker{letter-spacing:0;white-space:nowrap;min-width:0;max-width:10rem}.model-icon{border:1px solid var(--agent-border-strong);flex:none;justify-content:center;align-items:center;width:18px;height:18px;display:inline-flex}@supports (color:color-mix(in lab,red,red)){.model-icon{border:1px solid color-mix(in srgb,var(--agent-border-strong) 70%,transparent)}}.model-icon{color:var(--agent-text);background:var(--agent-control);border-radius:999px;font-size:10px;font-weight:750;line-height:1}.model-label{min-width:0;color:var(--color-token-foreground);text-overflow:ellipsis;overflow:hidden}.model-chevron{color:var(--color-token-input-placeholder-foreground);flex:none;line-height:1}.provider-dropdown{z-index:50;border:.5px solid var(--color-token-menu-border);width:13rem;max-height:min(250px,100vh - 16px);position:absolute;bottom:calc(100% + 6px);left:0}@supports (color:color-mix(in lab,red,red)){.provider-dropdown{border:.5px solid color-mix(in srgb,var(--color-token-menu-border) 78%,transparent)}}.provider-dropdown{border-radius:var(--radius-xl);color:var(--color-token-foreground);background:var(--color-token-dropdown-background)}@supports (color:color-mix(in lab,red,red)){.provider-dropdown{background:color-mix(in srgb,var(--color-token-dropdown-background) 90%,transparent)}}.provider-dropdown{-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}.provider-dropdown-title{min-height:24px;padding:var(--padding-row-y) var(--padding-row-x);color:var(--color-token-description-foreground);font-size:var(--text-sm);text-overflow:ellipsis;white-space:nowrap;align-items:center;line-height:1rem;display:flex;overflow:hidden}.provider-dropdown-item{width:100%;color:var(--color-token-foreground);text-align:left;background:0 0;border:0}.provider-dropdown-item:hover,.provider-dropdown-item:focus,.provider-dropdown-item[data-selected=true]{background:var(--color-token-list-hover-background)}.provider-dropdown-check{color:var(--color-token-foreground)}.permissions-root{flex:0 auto;min-width:0;display:inline-flex;position:relative}.permissions-trigger{color:var(--color-token-input-placeholder-foreground)}.permissions-trigger[data-permission-mode=auto-review],.permissions-item[data-permission-mode=auto-review] .permissions-item-icon{color:var(--color-token-text-link-foreground)}.permissions-trigger[data-permission-mode=full-access],.permissions-item[data-permission-mode=full-access] .permissions-item-icon{color:var(--agent-danger)}.permissions-trigger-label{color:currentColor;font-weight:500}.permissions-trigger-chevron{color:currentColor;opacity:.72}.permissions-dropdown{z-index:50;border:.5px solid var(--color-token-menu-border);width:13.5rem;max-height:min(250px,100vh - 16px);position:absolute;bottom:calc(100% + 6px);left:0;overflow-y:auto}@supports (color:color-mix(in lab,red,red)){.permissions-dropdown{border:.5px solid color-mix(in srgb,var(--color-token-menu-border) 78%,transparent)}}.permissions-dropdown{border-radius:var(--radius-xl);color:var(--color-token-foreground);background:var(--color-token-dropdown-background)}@supports (color:color-mix(in lab,red,red)){.permissions-dropdown{background:color-mix(in srgb,var(--color-token-dropdown-background) 90%,transparent)}}.permissions-dropdown{-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}.permissions-item{width:100%;min-height:calc(var(--spacing) * 8);align-items:center;gap:calc(var(--spacing) * 2);color:var(--color-token-foreground);text-align:left;background:0 0;border:0;display:flex}.permissions-item:hover,.permissions-item:focus-visible,.permissions-item[data-selected=true]{background:var(--color-token-list-hover-background)}.permissions-item-icon,.permissions-item-check{color:var(--color-token-description-foreground);justify-content:center;align-items:center;display:inline-flex}.permissions-item-label{color:var(--color-token-foreground);font-weight:500}.add-context-root{flex:none;display:inline-flex;position:relative}.add-context-dropdown{z-index:50;border:.5px solid var(--color-token-menu-border);width:max-content;min-width:120px;max-width:min(320px,100vw - 16px);max-height:min(270px,100vh - 16px);position:absolute;bottom:calc(100% + 6px);left:-8px;overflow-y:auto}@supports (color:color-mix(in lab,red,red)){.add-context-dropdown{border:.5px solid color-mix(in srgb,var(--color-token-menu-border) 78%,transparent)}}.add-context-dropdown{border-radius:var(--radius-xl);color:var(--color-token-foreground);background:var(--color-token-dropdown-background)}@supports (color:color-mix(in lab,red,red)){.add-context-dropdown{background:color-mix(in srgb,var(--color-token-dropdown-background) 90%,transparent)}}.add-context-dropdown{-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}.add-context-item{width:100%;min-height:calc(var(--spacing) * 8);align-items:center;gap:calc(var(--spacing) * 1.5);color:var(--color-token-foreground);cursor:var(--cursor-interaction);text-align:left;background:0 0;border:0;display:flex}.add-context-item:hover,.add-context-item:focus-visible{background:var(--color-token-list-hover-background)}.add-context-item:disabled{cursor:default;opacity:.5}.add-context-item:disabled:hover{background:0 0}.add-context-item-icon{color:var(--color-token-description-foreground);opacity:.75;justify-content:center;align-items:center;display:inline-flex}.add-context-item:hover .add-context-item-icon,.add-context-item:focus-visible .add-context-item-icon{opacity:1}.add-context-glyph{width:14px;height:14px;color:var(--color-token-description-foreground);font-size:var(--text-sm);justify-content:center;align-items:center;font-weight:700;line-height:1;display:inline-flex}.add-context-item-label{color:var(--color-token-foreground);font-weight:500}.add-context-item-detail{color:var(--color-token-description-foreground);font-size:var(--text-sm)}.add-context-separator{flex:none}.add-context-separator-line{background:var(--color-token-menu-border)}.provider-select{appearance:none;color:#0000;opacity:0;background:0 0;outline:none;width:100%;height:100%;position:absolute;inset:0}.composer-separator{background:var(--agent-border-strong);flex:none;width:1px;height:18px;margin:0 3px}@supports (color:color-mix(in lab,red,red)){.composer-separator{background:color-mix(in srgb,var(--agent-border-strong) 72%,transparent)}}.codex-start{width:auto;min-width:56px}.codex-circle-action:before{content:"";border:2px solid;border-radius:999px;width:13px;height:13px}.send-button{border-radius:999px}.provider-select:disabled{opacity:0}.rate-line{min-width:0;color:var(--agent-muted);gap:6px;padding:0 2px}@supports (color:color-mix(in lab,red,red)){.rate-line{color:color-mix(in srgb,var(--agent-muted) 88%,transparent)}}.rate-line{white-space:nowrap;font-size:12px;font-weight:500;line-height:1.35}.rate-line[hidden]{display:none}.rate-line :where(span){text-overflow:ellipsis;min-width:0;overflow:hidden}.codex-rate-limit-summary{flex-wrap:nowrap;justify-content:flex-start;overflow:hidden}.rate-limit-trigger{border-radius:var(--radius-md);max-width:100%;height:22px;color:var(--color-token-input-placeholder-foreground);border:0;padding:0 4px;display:inline-flex;position:relative}@supports (color:color-mix(in lab,red,red)){.rate-limit-trigger{color:color-mix(in srgb,var(--color-token-input-placeholder-foreground) 94%,transparent)}}.rate-limit-trigger{font-size:var(--text-sm);background:0 0;font-weight:500;line-height:1}.rate-limit-trigger-inline{gap:6px;overflow:hidden}.rate-limit-trigger:hover,.rate-limit-trigger:focus-visible,.rate-limit-trigger[aria-expanded=true]{background:var(--color-token-list-hover-background);outline:none}.rate-limit-speedometer,.rate-limit-chevron{color:var(--color-token-input-placeholder-foreground);flex:none}@supports (color:color-mix(in lab,red,red)){.rate-limit-speedometer,.rate-limit-chevron{color:color-mix(in srgb,var(--color-token-input-placeholder-foreground) 86%,transparent)}}.rate-line-heading{color:var(--color-token-input-placeholder-foreground);flex:none}@supports (color:color-mix(in lab,red,red)){.rate-line-heading{color:color-mix(in srgb,var(--color-token-input-placeholder-foreground) 92%,transparent)}}.rate-limit-inline-separator{color:var(--agent-accent);flex:none}@supports (color:color-mix(in lab,red,red)){.rate-limit-inline-separator{color:color-mix(in srgb,var(--agent-accent) 82%,transparent)}}.rate-limit-inline-segment{align-items:baseline;gap:5px;min-width:0;display:inline-flex}.rate-limit-popover{color:var(--color-token-foreground)}.rate-limit-popover-title{color:var(--color-token-description-foreground);font-size:var(--text-xs);padding:2px 0 5px;font-weight:600}.rate-limit-popover-row{min-width:0;min-height:24px;color:var(--color-token-foreground);grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:24px;display:grid}.rate-limit-window{min-width:0;color:var(--color-token-foreground);text-overflow:ellipsis;font-weight:500;overflow:hidden}.rate-limit-row-value{min-width:0;color:var(--color-token-description-foreground);text-align:right;justify-content:flex-end;align-items:center;gap:5px;display:inline-flex}.rate-limit-percent{color:var(--color-token-input-placeholder-foreground);flex:none}@supports (color:color-mix(in lab,red,red)){.rate-limit-percent{color:color-mix(in srgb,var(--color-token-input-placeholder-foreground) 92%,transparent)}}.rate-limit-percent{font-weight:600}.rate-limit-reset{min-width:0;color:var(--color-token-description-foreground)}.status-dot{background:var(--agent-muted);border-radius:999px;flex:none;width:6px;height:6px}.status-dot.running{background:var(--agent-accent)}.status-dot.failed{background:var(--agent-danger)}.codex-top-tray-shell{width:calc(var(--composer-top-tray-inline-padding,12px) * 2 + 100% + 8px);min-width:0;left:50%;bottom:var(--composer-top-tray-top-padding,14px);transform:translate(-50%)}.codex-top-tray-panel,.codex-top-tray-panel[cmdk-root],.codex-top-tray-panel[data-cmdk-root]{width:100%;min-width:0;max-height:var(--composer-top-tray-max-height,177px);padding:var(--spacing);padding-top:calc(var(--spacing) * 3);padding-left:calc(var(--spacing) * 2);border:1px solid var(--composer-top-tray-border,var(--color-token-border));flex-direction:column;display:flex;position:relative}@supports (color:color-mix(in lab,red,red)){.codex-top-tray-panel,.codex-top-tray-panel[cmdk-root],.codex-top-tray-panel[data-cmdk-root]{border:1px solid var(--composer-top-tray-border,color-mix(in srgb, var(--color-token-border) 72%, transparent))}}.codex-top-tray-panel,.codex-top-tray-panel[cmdk-root],.codex-top-tray-panel[data-cmdk-root]{border-top-left-radius:var(--radius-3xl);border-top-right-radius:var(--radius-3xl);color:var(--color-token-foreground);background:var(--composer-top-tray-background,var(--color-token-side-bar-background));border-top-color:#0000;border-bottom:none;border-bottom-right-radius:0;border-bottom-left-radius:0}@supports (color:color-mix(in lab,red,red)){.codex-top-tray-panel,.codex-top-tray-panel[cmdk-root],.codex-top-tray-panel[data-cmdk-root]{background:var(--composer-top-tray-background,color-mix(in oklab, var(--color-token-side-bar-background) 94%, transparent))}}.codex-top-tray-panel,.codex-top-tray-panel[cmdk-root],.codex-top-tray-panel[data-cmdk-root]{box-shadow:none;font-size:var(--text-sm);-webkit-backdrop-filter:none;backdrop-filter:none;isolation:isolate;-webkit-user-select:none;user-select:none;--cmdk-bottom-item-radius:0;overflow:hidden}.codex-top-tray-title{padding:0 var(--padding-row-x) calc(var(--spacing) * 1.25);color:var(--color-token-description-foreground);font-size:var(--text-xs);font-weight:600;line-height:1.2}.codex-top-tray-list,.codex-top-tray-panel [cmdk-list],.codex-top-tray-panel [data-cmdk-list]{gap:var(--spacing);scrollbar-width:none;flex-direction:column;flex:1;width:100%;min-height:0;max-height:none;transition:none;display:flex;overflow-y:auto}.codex-top-tray-list::-webkit-scrollbar{display:none}.codex-top-tray-empty,.codex-top-tray-panel [data-cmdk-empty]{padding:var(--padding-row-y) var(--padding-row-x);color:var(--color-token-input-placeholder-foreground);font-size:var(--text-sm);align-items:center;display:flex}.codex-top-tray-item,.codex-top-tray-item[cmdk-item],.codex-top-tray-item[data-list-navigation-item]{width:calc(100% - var(--spacing) * 2);min-height:calc(var(--spacing) * 6);padding:var(--padding-row-y) var(--padding-row-x);border-radius:var(--radius-lg);color:var(--color-token-foreground);cursor:var(--cursor-interaction,default);font-size:var(--text-sm);opacity:.75;text-align:left;background:0 0;border:0;outline:none;flex-shrink:0;align-items:center;gap:0;display:flex;overflow:hidden}.codex-top-tray-item:hover,.codex-top-tray-item[data-selected=true],.codex-top-tray-item:focus-visible{background:var(--color-token-list-hover-background);opacity:1}.codex-top-tray-icon{width:14px;height:14px;color:var(--color-token-description-foreground);font-size:var(--text-sm);flex:none;justify-content:center;align-items:center;font-weight:700;line-height:1;display:inline-flex}.codex-top-tray-copy{align-items:center;gap:calc(var(--spacing) * 2);min-width:0;display:flex}.codex-top-tray-label,.codex-top-tray-detail{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.codex-top-tray-label{color:var(--color-token-foreground);font-size:var(--text-sm)}.codex-top-tray-detail{color:var(--color-token-description-foreground);font-size:var(--text-sm);flex:1 1 0}@media(max-width:720px){.agent-shell{padding:10px}.prompt-input{min-height:2.75rem;font-size:16px}.model-picker,.codex-tool,.codex-action{height:var(--spacing-token-button-composer)}.codex-tool,.codex-action{width:var(--spacing-token-button-composer)}.codex-tool{width:var(--spacing-token-button-composer-sm);height:var(--spacing-token-button-composer-sm)}.codex-left-rail{min-width:0}.codex-action-cluster{margin-left:auto}}@media(max-width:520px){.agent-log-line,.log-line{grid-template-columns:1fr;gap:2px}.model-picker{min-width:104px;max-width:168px}.codex-tool-skill,.codex-mic,.rate-dot,.rate-limit-reset{display:none}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}';function Op(n){ta("agent-session",Sp),Zo(),document.documentElement.dataset.cmuxWebviewKind="agent-session",document.body.dataset.cmuxWebviewKind="agent-session";const e=na(()=>zr.jsx(Su,{}));Qs.createRoot(n).render(zr.jsx(ea,{router:e}))}export{Op as mountAgentSessionSurface}; diff --git a/Sources/AgentGUI/AgentGUIAskRegistry.swift b/Sources/AgentGUI/AgentGUIAskRegistry.swift new file mode 100644 index 000000000000..e2ebf27568b5 --- /dev/null +++ b/Sources/AgentGUI/AgentGUIAskRegistry.swift @@ -0,0 +1,210 @@ +import CmuxAgentReplica +import CmuxAgentWire +import Foundation + +@MainActor +final class AgentGUIAskRegistry { + private struct JournalKey: Hashable { + let sessionID: AgentSessionID + let journalID: JournalID + } + + private struct Record { + var ask: PendingAsk + let journalID: JournalID + let seq: EntrySeq + let createdAt: Int + } + + private let clock: () -> Int + private let injector: any AgentGUITerminalInjecting + private let publish: (PendingAsk) -> Void + private var sessions: [AgentSessionID: AgentSessionSnapshot] = [:] + private var records: [String: Record] = [:] + private var pendingEntries: [JournalKey: EntrySnapshot] = [:] + + init( + clock: @escaping () -> Int, + injector: any AgentGUITerminalInjecting, + publish: @escaping (PendingAsk) -> Void + ) { + self.clock = clock + self.injector = injector + self.publish = publish + } + + var hasPendingExpirations: Bool { + records.values.contains { record in + record.ask.state == .active + } + } + + func handleSessionSnapshot(_ snapshot: AgentSessionSnapshot) { + sessions[snapshot.id] = snapshot + guard snapshot.phase == .needsInput else { return } + let keys = pendingEntries.keys + .filter { $0.sessionID == snapshot.id } + .sorted { $0.journalID.rawValue < $1.journalID.rawValue } + for key in keys { + createPendingAskIfNeeded(for: key) + } + } + + func removeSession(_ sessionID: AgentSessionID) { + sessions.removeValue(forKey: sessionID) + records = records.filter { $0.value.ask.sessionID != sessionID } + pendingEntries = pendingEntries.filter { $0.key.sessionID != sessionID } + } + + func handleJournalEvent(_ event: AgentGUIJournalPipelineEvent, sessionID: AgentSessionID) { + switch event { + case .appended(let journalID, let entries): + for entry in entries { + supersedeActiveAsks(beforeCreatingAskFrom: entry, journalID: journalID, sessionID: sessionID) + trackPendingAskEntry(entry, journalID: journalID, sessionID: sessionID) + } + case .reset: + pendingEntries = pendingEntries.filter { $0.key.sessionID != sessionID } + supersedeActiveAsks(for: sessionID) + case .replaced: + break + } + } + + func answer(params: GuiAnswerParams) throws -> GuiAnswerResult { + guard params.choiceIndex >= 0, params.choiceIndex < 9 else { + throw AgentGUIRPCError.invalidParams + } + guard let record = records[params.askID], record.ask.sessionID == params.sessionID else { + throw AgentGUIRPCError.notFound + } + switch record.ask.state { + case .answered: + return GuiAnswerResult(answered: true) + case .expired, .superseded: + return GuiAnswerResult(answered: false) + case .active: + break + } + guard params.choiceIndex < record.ask.optionsCount else { + throw AgentGUIRPCError.invalidParams + } + guard let surfaceID = sessions[params.sessionID]?.surfaceID, !surfaceID.isEmpty else { + throw AgentGUIRPCError.bindingLost + } + let digit = String(params.choiceIndex + 1) + let result = injector.sendInput(surfaceID: surfaceID, text: digit) + guard result.accepted else { + throw AgentGUIRPCError.fromInjectionFailure(result) + } + transition(params.askID, to: .answered(choice: params.choiceIndex)) + return GuiAnswerResult(answered: true) + } + + func expire(now: Int? = nil) { + let current = now ?? clock() + for (askID, record) in records where record.ask.state == .active { + guard current - record.createdAt >= AgentGUIConstants.askTimeoutMS else { continue } + transition(askID, to: .expired) + } + } + + private func trackPendingAskEntry(_ entry: EntrySnapshot, journalID: JournalID, sessionID: AgentSessionID) { + let key = JournalKey(sessionID: sessionID, journalID: journalID) + if let pending = pendingEntries[key], pending.seq > entry.seq { + return + } + switch entry.content.payload { + case .question, .permission: + pendingEntries[key] = entry + default: + if let pending = pendingEntries[key], pending.seq < entry.seq { + pendingEntries.removeValue(forKey: key) + } + return + } + createPendingAskIfNeeded(for: key) + } + + private func createPendingAskIfNeeded(for key: JournalKey) { + guard sessions[key.sessionID]?.phase == .needsInput, + let entry = pendingEntries[key] else { + return + } + let sessionID = key.sessionID + let journalID = key.journalID + let askID = Self.askID(journalID: journalID, seq: entry.seq) + guard records[askID] == nil else { + pendingEntries.removeValue(forKey: key) + return + } + let ask: PendingAsk + switch entry.content.payload { + case .question(let payload): + ask = PendingAsk( + id: askID, + sessionID: sessionID, + kind: .question, + promptSummary: payload.prompt, + options: payload.options, + state: payload.answeredChoice.map { .answered(choice: $0) } ?? .active + ) + case .permission(let payload): + let summary = payload.detail.isEmpty ? payload.toolName : payload.detail + ask = PendingAsk( + id: askID, + sessionID: sessionID, + kind: .permission, + promptSummary: summary, + options: payload.options, + state: .active + ) + default: + return + } + records[askID] = Record(ask: ask, journalID: journalID, seq: entry.seq, createdAt: clock()) + pendingEntries.removeValue(forKey: key) + publish(ask) + } + + private func supersedeActiveAsks(beforeCreatingAskFrom entry: EntrySnapshot, journalID: JournalID, sessionID: AgentSessionID) { + for (askID, record) in records { + guard record.ask.sessionID == sessionID, + record.journalID == journalID, + record.ask.state == .active, + record.seq.rawValue < entry.seq.rawValue else { + continue + } + transition(askID, to: .superseded) + } + } + + private func supersedeActiveAsks(for sessionID: AgentSessionID) { + let askIDs = records.compactMap { askID, record in + record.ask.sessionID == sessionID && record.ask.state == .active ? askID : nil + } + for askID in askIDs { + transition(askID, to: .superseded) + } + } + + private func transition(_ askID: String, to state: PendingAskState) { + guard var record = records[askID] else { return } + let current = record.ask + let next = PendingAsk( + id: current.id, + sessionID: current.sessionID, + kind: current.kind, + promptSummary: current.promptSummary, + options: current.options, + state: state + ) + record.ask = next + records[askID] = record + publish(next) + } + + static func askID(journalID: JournalID, seq: EntrySeq) -> String { + "\(journalID.rawValue):\(seq.rawValue)" + } +} diff --git a/Sources/AgentGUI/AgentGUICapabilityMapper.swift b/Sources/AgentGUI/AgentGUICapabilityMapper.swift new file mode 100644 index 000000000000..6a8629b0fd48 --- /dev/null +++ b/Sources/AgentGUI/AgentGUICapabilityMapper.swift @@ -0,0 +1,17 @@ +import CmuxAgentWire +import CmuxAgentTruthKit +import Foundation + +struct AgentGUICapabilityMapper { + func map(_ reason: CapabilityReason) -> GuiCapabilityReason { + switch reason { + case .cliVersionBelowMinimum(let found, let minimum): + GuiCapabilityReason( + code: reason.localizationKey, + detail: "found=\(found) minimum=\(minimum)" + ) + default: + GuiCapabilityReason(code: reason.localizationKey) + } + } +} diff --git a/Sources/AgentGUI/AgentGUICodableBridge.swift b/Sources/AgentGUI/AgentGUICodableBridge.swift new file mode 100644 index 000000000000..7fc87478e4b4 --- /dev/null +++ b/Sources/AgentGUI/AgentGUICodableBridge.swift @@ -0,0 +1,18 @@ +import Foundation + +enum AgentGUICodableBridge { + static func dictionary(_ value: Value) throws -> [String: Any] { + let encoder = JSONEncoder() + let data = try encoder.encode(value) + let object = try JSONSerialization.jsonObject(with: data) + guard let dictionary = object as? [String: Any] else { + throw AgentGUIRPCError.internalError + } + return dictionary + } + + static func decode(_ type: Value.Type, from params: [String: Any]) throws -> Value { + let data = try JSONSerialization.data(withJSONObject: params) + return try JSONDecoder().decode(type, from: data) + } +} diff --git a/Sources/AgentGUI/AgentGUIConstants.swift b/Sources/AgentGUI/AgentGUIConstants.swift new file mode 100644 index 000000000000..8439f67d0dff --- /dev/null +++ b/Sources/AgentGUI/AgentGUIConstants.swift @@ -0,0 +1,23 @@ +import Foundation + +enum AgentGUIConstants { + static let observationCadence: TimeInterval = 2 + static let liveRecentActivityWindowMS = 10 * 60 * 1_000 + static let gateReevaluationCadence: TimeInterval = 60 + static let journalWatchCoalescing: Duration = .milliseconds(200) + static let initialTailLineCap = 2_000 + static let initialTailByteCap = 4 * 1_024 * 1_024 + static let journalIncrementalByteCap = initialTailByteCap + static let journalPageByteCap = 4 * 1_024 * 1_024 + static let journalPageRawLineMultiplier = 4 + static let journalPageContextLineCap = 256 + static let journalPageContextByteCap = 1 * 1_024 * 1_024 + static let journalToolCallCacheCap = 512 + static let journalWindowEntryCap = initialTailLineCap + static let maxEntriesLimit = 200 + static let sendTicketIdempotencyWindowMS = 300 * 1_000 + static let resolvedSendTicketRetentionLimit = 256 + static let sendEchoTimeoutMS = 120 * 1_000 + static let sendEchoUnmatchedAppendLimit = 3 + static let askTimeoutMS = 120 * 1_000 +} diff --git a/Sources/AgentGUI/AgentGUIFileIdentity.swift b/Sources/AgentGUI/AgentGUIFileIdentity.swift new file mode 100644 index 000000000000..84a9815824ff --- /dev/null +++ b/Sources/AgentGUI/AgentGUIFileIdentity.swift @@ -0,0 +1,43 @@ +import CmuxAgentTruthKit +import Darwin +import Foundation + +struct AgentGUIFileIdentity: Sendable { + let path: String + let inodeLikeToken: String + let size: Int + let firstLine: String? + + func journalIdentity(baselineFirstLine: String?, forceHeadTruncated: Bool = false) -> JournalIdentity { + JournalIdentity( + path: path, + inodeLikeToken: inodeLikeToken, + headTruncated: forceHeadTruncated || (baselineFirstLine != nil && firstLine != baselineFirstLine) + ) + } + + static func capture(path: String) -> Result { + var info = stat() + guard stat(path, &info) == 0 else { + let code = POSIXErrorCode(rawValue: errno) ?? .EIO + return .failure(POSIXError(code)) + } + return .success(AgentGUIFileIdentity( + path: path, + inodeLikeToken: "\(info.st_dev):\(info.st_ino)", + size: Int(info.st_size), + firstLine: readFirstLine(path: path) + )) + } + + private static func readFirstLine(path: String) -> String? { + guard let handle = FileHandle(forReadingAtPath: path) else { + return nil + } + defer { try? handle.close() } + let data = (try? handle.read(upToCount: 64 * 1024)) ?? Data() + guard !data.isEmpty else { return nil } + let text = String(decoding: data, as: UTF8.self) + return text.split(separator: "\n", omittingEmptySubsequences: false).first.map(String.init) + } +} diff --git a/Sources/AgentGUI/AgentGUIHookFactMapper.swift b/Sources/AgentGUI/AgentGUIHookFactMapper.swift new file mode 100644 index 000000000000..957686e5270e --- /dev/null +++ b/Sources/AgentGUI/AgentGUIHookFactMapper.swift @@ -0,0 +1,119 @@ +import CMUXAgentLaunch +import CmuxAgentReplica +import CmuxAgentTruthKit +import Foundation + +struct AgentGUIHookFactMapper { + func hookFact(from event: WorkstreamEvent) -> HookFact { + hookFact( + sessionID: event.sessionId, + rawHookName: event.hookEventName.rawValue, + surfaceID: event.surfaceId, + transcriptPath: event.transcriptPath, + cwd: event.cwd, + pid: event.ppid.map(Int32.init), + source: event.source, + toolInputJSON: event.toolInputJSON, + extraFieldsJSON: event.extraFieldsJSON + ) + } + + func hookFact( + sessionID: String, + rawHookName: String, + surfaceID: String?, + transcriptPath: String?, + cwd: String?, + pid: Int32?, + source: String, + toolInputJSON: String?, + extraFieldsJSON: String? + ) -> HookFact { + HookFact( + sessionID: AgentSessionID(rawValue: sessionID), + eventName: HookEventName(rawValue: rawHookName), + surfaceID: surfaceID, + transcriptPath: transcriptPath, + cwd: cwd, + pid: pid, + notificationRequiresInput: notificationRequiresInput(rawHookName: rawHookName, toolInputJSON: toolInputJSON, extraFieldsJSON: extraFieldsJSON), + hooksUnavailableSafeMode: boolField("hooks_unavailable_safe_mode", in: [toolInputJSON, extraFieldsJSON]), + cliVersion: stringField("cli_version", in: [toolInputJSON, extraFieldsJSON]), + minimumCLIVersion: stringField("minimum_cli_version", in: [toolInputJSON, extraFieldsJSON]) + ) + } + + func wrapperLaunchFact(from event: WorkstreamEvent) -> WrapperLaunchFact? { + guard event.hookEventName == .sessionStart, + stringField("wrapper_origin", in: [event.toolInputJSON, event.extraFieldsJSON]) == "cmux-wrapper", + let surfaceID = event.surfaceId, + let rawKind = stringField("agent_kind", in: [event.toolInputJSON, event.extraFieldsJSON]) ?? event.source.nilIfBlank, + let pid = event.ppid.map(Int32.init), + let cwd = event.cwd?.nilIfBlank else { + return nil + } + return WrapperLaunchFact( + surfaceID: surfaceID, + agentKind: AgentKind(rawValue: rawKind), + pid: pid, + cwd: cwd, + sessionID: event.sessionId.nilIfBlank.map(AgentSessionID.init(rawValue:)), + launchArgvKind: launchArgvKind(in: [event.toolInputJSON, event.extraFieldsJSON]), + socketWasDown: boolField("socket_was_down", in: [event.toolInputJSON, event.extraFieldsJSON]), + hooksUnavailableSafeMode: boolField("hooks_unavailable_safe_mode", in: [event.toolInputJSON, event.extraFieldsJSON]), + cliVersion: stringField("cli_version", in: [event.toolInputJSON, event.extraFieldsJSON]), + minimumCLIVersion: stringField("minimum_cli_version", in: [event.toolInputJSON, event.extraFieldsJSON]) + ) + } + + private func launchArgvKind(in jsonStrings: [String?]) -> LaunchArgvKind { + switch stringField("launch_argv_kind", in: jsonStrings) { + case "resume": .resume + default: .new + } + } + + private func notificationRequiresInput(rawHookName: String, toolInputJSON: String?, extraFieldsJSON: String?) -> Bool { + if rawHookName == "PermissionRequest" || rawHookName == "AskUserQuestion" { + return true + } + return boolField("requires_input", in: [toolInputJSON, extraFieldsJSON]) + || boolField("notification_requires_input", in: [toolInputJSON, extraFieldsJSON]) + } + + private func stringField(_ key: String, in jsonStrings: [String?]) -> String? { + for json in jsonStrings { + guard let object = jsonObject(json), let value = object[key] as? String, let trimmed = value.nilIfBlank else { continue } + return trimmed + } + return nil + } + + private func boolField(_ key: String, in jsonStrings: [String?]) -> Bool { + for json in jsonStrings { + guard let object = jsonObject(json) else { continue } + if let value = object[key] as? Bool { + return value + } + if let value = object[key] as? String { + return value == "1" || value.caseInsensitiveCompare("true") == .orderedSame + } + } + return false + } + + private func jsonObject(_ json: String?) -> [String: Any]? { + guard let json, let data = json.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) as? [String: Any] else { + return nil + } + return object + } +} + +private extension String { + var nilIfBlank: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/Sources/AgentGUI/AgentGUIIncrementalState.swift b/Sources/AgentGUI/AgentGUIIncrementalState.swift new file mode 100644 index 000000000000..7e219d1fa102 --- /dev/null +++ b/Sources/AgentGUI/AgentGUIIncrementalState.swift @@ -0,0 +1,79 @@ +import CmuxAgentReplica + +@MainActor +final class AgentGUIIncrementalState { + private var sessionIDByProcess: [ObservedProcessKey: AgentSessionID] = [:] + private var processKeyBySessionID: [AgentSessionID: ObservedProcessKey] = [:] + private var liveSessionIDs: Set = [] + private var recentSessionExpirations: [AgentSessionID: Int] = [:] + private var latestRecentSessionExpiration = Int.min + private var nonEndedSessionIDs: Set = [] + + var hasNonEndedSessions: Bool { + !nonEndedSessionIDs.isEmpty + } + + func sessionID(pid: Int32, startTick: Int) -> AgentSessionID? { + sessionIDByProcess[ObservedProcessKey(pid: pid, startTick: startTick)] + } + + func bindProcess(pid: Int32, startTick: Int, to sessionID: AgentSessionID) { + let processKey = ObservedProcessKey(pid: pid, startTick: startTick) + if let previousSessionID = sessionIDByProcess.updateValue(sessionID, forKey: processKey), + previousSessionID != sessionID { + processKeyBySessionID.removeValue(forKey: previousSessionID) + } + if let previousKey = processKeyBySessionID.updateValue(processKey, forKey: sessionID), + previousKey != processKey { + sessionIDByProcess.removeValue(forKey: previousKey) + } + } + + func updateSession(_ session: AgentSessionSnapshot) { + let previousExpiration = recentSessionExpirations.removeValue(forKey: session.id) + liveSessionIDs.remove(session.id) + switch session.phase { + case .working, .needsInput: + liveSessionIDs.insert(session.id) + case .idle, .starting, .unknown: + let expiration = session.lastActivityHint + AgentGUIConstants.liveRecentActivityWindowMS + recentSessionExpirations[session.id] = expiration + latestRecentSessionExpiration = max(latestRecentSessionExpiration, expiration) + case .ended: + break + } + if session.phase == .ended { + nonEndedSessionIDs.remove(session.id) + } else { + nonEndedSessionIDs.insert(session.id) + } + if previousExpiration == latestRecentSessionExpiration, + recentSessionExpirations[session.id] != previousExpiration { + recomputeLatestRecentSessionExpiration() + } + } + + func removeSession(_ sessionID: AgentSessionID) { + liveSessionIDs.remove(sessionID) + nonEndedSessionIDs.remove(sessionID) + let removedExpiration = recentSessionExpirations.removeValue(forKey: sessionID) + if removedExpiration == latestRecentSessionExpiration { + recomputeLatestRecentSessionExpiration() + } + guard let processKey = processKeyBySessionID.removeValue(forKey: sessionID) else { return } + sessionIDByProcess.removeValue(forKey: processKey) + } + + func hasLiveOrRecentlyActiveSession(at tick: Int) -> Bool { + !liveSessionIDs.isEmpty || latestRecentSessionExpiration > tick + } + + private func recomputeLatestRecentSessionExpiration() { + latestRecentSessionExpiration = recentSessionExpirations.values.max() ?? Int.min + } + + private struct ObservedProcessKey: Hashable { + let pid: Int32 + let startTick: Int + } +} diff --git a/Sources/AgentGUI/AgentGUIJournalBookkeeper.swift b/Sources/AgentGUI/AgentGUIJournalBookkeeper.swift new file mode 100644 index 000000000000..ff9200e936af --- /dev/null +++ b/Sources/AgentGUI/AgentGUIJournalBookkeeper.swift @@ -0,0 +1,24 @@ +import CmuxAgentReplica +import Foundation + +struct AgentGUIJournalBookkeeper { + private var versionsBySeq: [EntrySeq: UInt64] = [:] + + mutating func stamp(_ entry: EntrySnapshot) -> AgentGUIStampedEntry { + let nextVersion = (versionsBySeq[entry.seq] ?? 0) + 1 + versionsBySeq[entry.seq] = nextVersion + let stamped = EntrySnapshot( + journalID: entry.journalID, + seq: entry.seq, + kind: entry.kind, + content: entry.content, + version: EntityVersion(rawValue: nextVersion), + timestampMilliseconds: entry.timestampMilliseconds + ) + return AgentGUIStampedEntry(entry: stamped, isReplacement: nextVersion > 1) + } + + mutating func reset() { + versionsBySeq.removeAll() + } +} diff --git a/Sources/AgentGUI/AgentGUIJournalPageReader.swift b/Sources/AgentGUI/AgentGUIJournalPageReader.swift new file mode 100644 index 000000000000..d5d58e0069f3 --- /dev/null +++ b/Sources/AgentGUI/AgentGUIJournalPageReader.swift @@ -0,0 +1,366 @@ +import CmuxAgentReplica +import Foundation + +/// One complete JSONL source record and its stable byte position. +struct AgentGUIJournalSourceLine: Sendable, Equatable { + let text: String + let startOffset: Int + let endOffset: Int +} + +struct AgentGUIJournalRawPage: Sendable, Equatable { + let lines: [AgentGUIJournalSourceLine] + let startOffset: Int + let endOffset: Int + let fileSize: Int + let hasMoreBefore: Bool + let hasMoreAfter: Bool + let readSucceeded: Bool +} + +enum AgentGUIJournalPageDirection: Sendable, Equatable { + case head + case tail + case before(Int) + case after(Int) +} + +/// Reads bounded JSONL pages directly from disk. The reader never grows its +/// allocation with journal size and returns only complete source records. +enum AgentGUIJournalPageReader { + static let maximumRecordByteCount = 8 * 1_024 * 1_024 + + static func read( + path: String, + direction: AgentGUIJournalPageDirection, + lineLimit: Int, + byteLimit: Int, + recoversOversizedRecords: Bool = true + ) -> AgentGUIJournalRawPage { + guard let handle = try? FileHandle(forReadingFrom: URL(fileURLWithPath: path)) else { + return failurePage() + } + defer { try? handle.close() } + + let fileSize = Int((try? handle.seekToEnd()) ?? 0) + let boundedLineLimit = max(1, lineLimit) + let boundedByteLimit = max(1, byteLimit) + switch direction { + case .head: + return readForward( + handle: handle, + from: 0, + fileSize: fileSize, + lineLimit: boundedLineLimit, + byteLimit: boundedByteLimit + ) + case .tail: + return readBackward( + handle: handle, + before: fileSize, + fileSize: fileSize, + lineLimit: boundedLineLimit, + byteLimit: boundedByteLimit, + dropsTrailingPartialLine: true, + recoversOversizedRecords: recoversOversizedRecords + ) + case .before(let offset): + guard (0...fileSize).contains(offset), + offset == 0 || isLineBoundary(handle: handle, offset: offset) else { + return failurePage(fileSize: fileSize) + } + return readBackward( + handle: handle, + before: offset, + fileSize: fileSize, + lineLimit: boundedLineLimit, + byteLimit: boundedByteLimit, + dropsTrailingPartialLine: false, + recoversOversizedRecords: recoversOversizedRecords + ) + case .after(let offset): + guard (0...fileSize).contains(offset), + offset == 0 || isLineBoundary(handle: handle, offset: offset) else { + return failurePage(fileSize: fileSize) + } + return readForward( + handle: handle, + from: offset, + fileSize: fileSize, + lineLimit: boundedLineLimit, + byteLimit: boundedByteLimit + ) + } + } + + private static func readForward( + handle: FileHandle, + from offset: Int, + fileSize: Int, + lineLimit: Int, + byteLimit: Int + ) -> AgentGUIJournalRawPage { + let readEnd = min(fileSize, offset + byteLimit) + guard let data = read(handle: handle, range: offset.. 0, + hasMoreAfter: skippedEnd < fileSize, + readSucceeded: true + ) + } + let endOffset = lines.last?.endOffset ?? offset + return AgentGUIJournalRawPage( + lines: lines, + startOffset: lines.first?.startOffset ?? offset, + endOffset: endOffset, + fileSize: fileSize, + hasMoreBefore: offset > 0, + hasMoreAfter: endOffset < fileSize, + readSucceeded: true + ) + } + + private static func readBackward( + handle: FileHandle, + before offset: Int, + fileSize: Int, + lineLimit: Int, + byteLimit: Int, + dropsTrailingPartialLine: Bool, + recoversOversizedRecords: Bool + ) -> AgentGUIJournalRawPage { + let readStart = max(0, offset - byteLimit) + guard let readData = read(handle: handle, range: readStart.. 0, !isLineBoundary(handle: handle, offset: readStart) { + guard let newline = data.firstIndex(of: 0x0A) else { + let skippedStart = recoversOversizedRecords + ? scanBackwardToLineStart(handle: handle, before: readStart) + : readStart + return AgentGUIJournalRawPage( + lines: [], + startOffset: skippedStart, + endOffset: offset, + fileSize: fileSize, + hasMoreBefore: skippedStart > 0, + hasMoreAfter: offset < fileSize, + readSucceeded: true + ) + } + let next = data.index(after: newline) + absoluteStart += data.distance(from: data.startIndex, to: next) + data = Data(data[next...]) + } + + if dropsTrailingPartialLine, data.last != 0x0A { + if let newline = data.lastIndex(of: 0x0A) { + data = Data(data[...newline]) + } else { + data.removeAll(keepingCapacity: false) + } + } + + let allLines = completeLines(in: data, absoluteStart: absoluteStart) + if allLines.isEmpty, readStart > 0 { + let skippedStart = recoversOversizedRecords + ? scanBackwardToLineStart(handle: handle, before: readStart) + : readStart + if dropsTrailingPartialLine, !endsAtLineBoundary { + return AgentGUIJournalRawPage( + lines: [], + startOffset: skippedStart, + endOffset: offset, + fileSize: fileSize, + hasMoreBefore: skippedStart > 0, + hasMoreAfter: offset < fileSize, + readSucceeded: true + ) + } + let oversized = sourceLine( + handle: handle, + startOffset: skippedStart, + endOffset: offset + ) + return AgentGUIJournalRawPage( + lines: [oversized], + startOffset: skippedStart, + endOffset: offset, + fileSize: fileSize, + hasMoreBefore: skippedStart > 0, + hasMoreAfter: offset < fileSize, + readSucceeded: true + ) + } + let lines = Array(allLines.suffix(lineLimit)) + let startOffset = lines.first?.startOffset ?? offset + let endOffset = lines.last?.endOffset ?? offset + return AgentGUIJournalRawPage( + lines: lines, + startOffset: startOffset, + endOffset: endOffset, + fileSize: fileSize, + hasMoreBefore: startOffset > 0, + hasMoreAfter: endOffset < fileSize, + readSucceeded: true + ) + } + + private static func read(handle: FileHandle, range: Range) -> Data? { + guard !range.isEmpty else { return Data() } + do { + try handle.seek(toOffset: UInt64(range.lowerBound)) + return try handle.read(upToCount: range.count) ?? Data() + } catch { + return nil + } + } + + private static func isLineBoundary(handle: FileHandle, offset: Int) -> Bool { + guard offset > 0, + let preceding = read(handle: handle, range: (offset - 1).. Int? { + let scanSize = 64 * 1_024 + var position = offset + while position < fileSize { + let end = min(fileSize, position + scanSize) + guard let data = read(handle: handle, range: position.. Int { + let scanSize = 64 * 1_024 + var end = offset + while end > 0 { + let start = max(0, end - scanSize) + guard let data = read(handle: handle, range: start.. [AgentGUIJournalSourceLine] { + var lines: [AgentGUIJournalSourceLine] = [] + var lineStart = data.startIndex + for newline in data.indices where data[newline] == 0x0A { + let relativeStart = data.distance(from: data.startIndex, to: lineStart) + let relativeEnd = data.distance(from: data.startIndex, to: data.index(after: newline)) + lines.append(AgentGUIJournalSourceLine( + text: String(decoding: data[lineStart.. AgentGUIJournalSourceLine { + let byteCount = max(0, endOffset - startOffset) + if byteCount <= maximumRecordByteCount, + var data = read(handle: handle, range: startOffset.. AgentGUIJournalRawPage { + AgentGUIJournalRawPage( + lines: [], + startOffset: 0, + endOffset: 0, + fileSize: fileSize, + hasMoreBefore: false, + hasMoreAfter: false, + readSucceeded: false + ) + } +} + +enum AgentGUIJournalCursorCodec { + private struct Payload: Codable { + let version: Int + let journalID: String + let byteOffset: Int + + private enum CodingKeys: String, CodingKey { + case version = "v" + case journalID = "j" + case byteOffset = "o" + } + } + + static func encode(journalID: JournalID, byteOffset: Int) -> JournalCursor { + let payload = Payload(version: 1, journalID: journalID.rawValue, byteOffset: byteOffset) + let data = (try? JSONEncoder().encode(payload)) ?? Data() + let encoded = data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return JournalCursor(rawValue: encoded) + } + + static func decode(_ cursor: JournalCursor, journalID: JournalID) -> Int? { + var encoded = cursor.rawValue + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let padding = (4 - encoded.count % 4) % 4 + encoded.append(String(repeating: "=", count: padding)) + guard let data = Data(base64Encoded: encoded), + let payload = try? JSONDecoder().decode(Payload.self, from: data), + payload.version == 1, + payload.journalID == journalID.rawValue, + payload.byteOffset >= 0 else { + return nil + } + return payload.byteOffset + } +} diff --git a/Sources/AgentGUI/AgentGUIJournalPipeline.swift b/Sources/AgentGUI/AgentGUIJournalPipeline.swift new file mode 100644 index 000000000000..8f1815f0adde --- /dev/null +++ b/Sources/AgentGUI/AgentGUIJournalPipeline.swift @@ -0,0 +1,767 @@ +import CmuxAgentReplica +import CmuxAgentTruthKit +import CmuxFoundation +import Foundation + +struct AgentGUIJournalDecodedPage: Sendable { + let journalID: JournalID + let entries: [EntrySnapshot] + let startOffset: Int + let endOffset: Int + let tailOffset: Int + let tailSeq: EntrySeq + let hasMoreBefore: Bool + let hasMoreAfter: Bool +} + +@MainActor +final class AgentGUIJournalPipeline { + let sessionID: AgentSessionID + private let kind: AgentKind + private let path: String + private let imageStore: AgentGUITranscriptImageStore + private let decodeWorker: AgentGUITranscriptDecodeWorker + private var minter = JournalMinter() + private var bookkeeper = AgentGUIJournalBookkeeper() + private var toolPairingIndex = AgentGUIToolPairingIndex() + private var currentIdentity: JournalIdentity? + private var currentByteOffset = 0 + private var pendingPartialLine = Data() + private var pendingPartialLineStartOffset = 0 + private var baselineFirstLine: String? + private var toolCallLinesByID: [String: AgentGUIJournalSourceLine] = [:] + private var toolCallCacheOrder: [String] = [] + private var inFlightIngestTask: Task<[AgentGUIJournalPipelineEvent], Never>? + private var watcher: FileWatcher? + private var watchTask: Task? + private(set) var window: AgentGUIJournalWindow? + private(set) var isWatching = false + private(set) var lastReadByteCount = 0 + private(set) var lastReadFailed = false + + init( + sessionID: AgentSessionID, + kind: AgentKind, + path: String, + imageStore: AgentGUITranscriptImageStore = .shared + ) { + self.sessionID = sessionID + self.kind = kind + self.path = path + self.imageStore = imageStore + self.decodeWorker = AgentGUITranscriptDecodeWorker(kind: kind) + } + + func setWatching(_ shouldWatch: Bool, onEvents: @escaping @MainActor ([AgentGUIJournalPipelineEvent]) -> Void) { + guard shouldWatch != isWatching else { return } + isWatching = shouldWatch + if shouldWatch { + startWatching(onEvents: onEvents) + } else { + stopWatching() + } + } + + func ingestInitial() async -> [AgentGUIJournalPipelineEvent] { + await ingest(fullRefresh: window == nil) + } + + func entries(beforeSeq: EntrySeq?, afterSeq: EntrySeq?, limit: Int) -> (journalID: JournalID, entries: [EntrySnapshot], windowStart: EntrySeq, windowEnd: EntrySeq, tailSeq: EntrySeq, hasMoreBefore: Bool)? { + guard !lastReadFailed, let window else { return nil } + let entries = window.page(beforeSeq: beforeSeq, afterSeq: afterSeq, limit: limit) + let start = entries.first?.seq ?? window.tailSeq + let end = entries.last?.seq ?? window.tailSeq + return (window.journalID, entries, start, end, window.tailSeq, window.hasMoreBefore(for: entries)) + } + + var currentJournalID: JournalID? { + window?.journalID + } + + func diskEntries(direction: AgentGUIJournalPageDirection, limit: Int) async -> AgentGUIJournalDecodedPage? { + guard !lastReadFailed, let window else { return nil } + let path = path + let rawLineLimit = max(limit, limit * AgentGUIConstants.journalPageRawLineMultiplier) + let rawPage = await Task.detached(priority: .utility) { + AgentGUIJournalPageReader.read( + path: path, + direction: direction, + lineLimit: rawLineLimit, + byteLimit: AgentGUIConstants.journalPageByteCap + ) + }.value + guard rawPage.readSucceeded else { + return emptyPendingDiskPage(from: window) + } + var contextLines: [AgentGUIJournalSourceLine] + if rawPage.startOffset > 0 { + let contextPage = await Task.detached(priority: .utility) { + AgentGUIJournalPageReader.read( + path: path, + direction: .before(rawPage.startOffset), + lineLimit: AgentGUIConstants.journalPageContextLineCap, + byteLimit: AgentGUIConstants.journalPageContextByteCap + ) + }.value + contextLines = contextPage.readSucceeded ? contextPage.lines : [] + } else { + contextLines = [] + } + let rawPageLines = rawPage.lines + let resultIDs = await Task.detached(priority: .utility) { + Set(rawPageLines.flatMap { AgentGUIJournalToolCorrelation.resultIDs(in: $0.text) }) + }.value + if !resultIDs.isEmpty, rawPage.startOffset > 0 { + var located: [String: AgentGUIJournalSourceLine] = [:] + for callID in resultIDs { + if let cached = toolCallLinesByID[callID], cached.startOffset < rawPage.startOffset { + located[callID] = cached + } + } + let missing = resultIDs.subtracting(located.keys) + if !missing.isEmpty { + let discovered = await Task.detached(priority: .utility) { + AgentGUIJournalToolCallLocator.locate( + callIDs: missing, + path: path, + before: rawPage.startOffset + ) + }.value + for (callID, sourceLine) in discovered.linesByCallID { + located[callID] = sourceLine + cacheToolCall(callID: callID, sourceLine: sourceLine) + } + } + var existingOffsets = Set(contextLines.map(\.startOffset)) + for sourceLine in located.values where existingOffsets.insert(sourceLine.startOffset).inserted { + contextLines.append(sourceLine) + } + contextLines.sort { $0.startOffset < $1.startOffset } + } + + struct LocatedEntry { + let entry: EntrySnapshot + let sourceStart: Int + let sourceEnd: Int + } + let pageDecodeWorker = AgentGUITranscriptDecodeWorker(kind: kind) + var pageBookkeeper = AgentGUIJournalBookkeeper() + var pagePairingIndex = AgentGUIToolPairingIndex() + var locatedEntries: [LocatedEntry] = [] + var embeddedImages: [TranscriptEmbeddedImage] = [] + let contextBatches = await pageDecodeWorker.feed(contextLines, journalID: window.journalID) + for decodedLine in contextBatches { + for decoded in decodedLine.batch.entries { + _ = pageBookkeeper.stamp(pagePairingIndex.normalize(decoded)) + } + } + let rawBatches = await pageDecodeWorker.feed(rawPage.lines, journalID: window.journalID) + for decodedLine in rawBatches { + cacheToolCalls( + in: decodedLine.sourceLine, + callIDs: decodedLine.toolCallIDs + ) + embeddedImages.append(contentsOf: decodedLine.batch.embeddedImages) + for decoded in decodedLine.batch.entries { + let stamped = pageBookkeeper.stamp(pagePairingIndex.normalize(decoded)).entry + locatedEntries.append(LocatedEntry( + entry: stamped, + sourceStart: decodedLine.sourceLine.startOffset, + sourceEnd: decodedLine.sourceLine.endOffset + )) + } + } + let ordered = locatedEntries.sorted { lhs, rhs in + if lhs.sourceStart != rhs.sourceStart { return lhs.sourceStart < rhs.sourceStart } + if lhs.entry.seq != rhs.entry.seq { return lhs.entry.seq < rhs.entry.seq } + return lhs.entry.version < rhs.entry.version + } + let selected: [LocatedEntry] + switch direction { + case .tail, .before: + let suffix = ordered.suffix(limit) + if let boundary = suffix.first?.sourceStart { + selected = ordered.filter { $0.sourceStart >= boundary } + } else { + selected = [] + } + case .head, .after: + let prefix = ordered.prefix(limit) + if let boundary = prefix.last?.sourceStart { + selected = ordered.filter { $0.sourceStart <= boundary } + } else { + selected = [] + } + } + let startOffset: Int + let endOffset: Int + if selected.isEmpty { + startOffset = rawPage.startOffset + endOffset = rawPage.endOffset + } else { + startOffset = selected.map(\.sourceStart).min() ?? rawPage.startOffset + endOffset = selected.map(\.sourceEnd).max() ?? rawPage.endOffset + } + let resolvedEntries = await resolveImages( + in: selected.map(\.entry), + embeddedImages: embeddedImages + ) + let committedTailOffset = pendingPartialLine.isEmpty ? currentByteOffset : pendingPartialLineStartOffset + return AgentGUIJournalDecodedPage( + journalID: window.journalID, + entries: resolvedEntries, + startOffset: startOffset, + endOffset: endOffset, + tailOffset: committedTailOffset, + tailSeq: window.tailSeq, + hasMoreBefore: rawPage.hasMoreBefore || startOffset > 0, + hasMoreAfter: endOffset < committedTailOffset + ) + } + + private func emptyPendingDiskPage(from window: AgentGUIJournalWindow) -> AgentGUIJournalDecodedPage? { + guard window.tailSeq.rawValue == 0 else { return nil } + return AgentGUIJournalDecodedPage( + journalID: window.journalID, + entries: [], + startOffset: 0, + endOffset: 0, + tailOffset: 0, + tailSeq: EntrySeq(rawValue: 0), + hasMoreBefore: false, + hasMoreAfter: false + ) + } + + private func startWatching(onEvents: @escaping @MainActor ([AgentGUIJournalPipelineEvent]) -> Void) { + let watcher = FileWatcher(path: path, throttle: AgentGUIConstants.journalWatchCoalescing) + self.watcher = watcher + watchTask = Task { @MainActor [weak self] in + for await _ in watcher.events { + guard let self else { return } + let events = await self.ingest(fullRefresh: false) + guard !events.isEmpty else { continue } + onEvents(events) + } + } + } + + private func stopWatching() { + watchTask?.cancel() + watchTask = nil + if let watcher { + Task { await watcher.stop() } + } + watcher = nil + } + + private func ingest(fullRefresh: Bool) async -> [AgentGUIJournalPipelineEvent] { + if let inFlightIngestTask { + return await inFlightIngestTask.value + } + let task = Task { @MainActor [self] in + await performIngest(fullRefresh: fullRefresh) + } + inFlightIngestTask = task + let events = await task.value + inFlightIngestTask = nil + return events + } + + private func performIngest(fullRefresh: Bool) async -> [AgentGUIJournalPipelineEvent] { + let capturedIdentity: AgentGUIFileIdentity + switch await fileIdentity() { + case .success(let captured): + capturedIdentity = captured + case .failure(let error) where error.code == .ENOENT || error.code == .ENOTDIR: + lastReadFailed = false + guard window == nil else { return [] } + let journalID = JournalID(rawValue: "pending:\(sessionID.rawValue)") + window = AgentGUIJournalWindow(journalID: journalID) + return [.reset(journalID: journalID, tailSeq: EntrySeq(rawValue: 0))] + case .failure: + lastReadFailed = true + return [] + } + let shrankInPlace = capturedIdentity.size < currentByteOffset + let identity = capturedIdentity.journalIdentity( + baselineFirstLine: baselineFirstLine, + forceHeadTruncated: shrankInPlace + ) + let decision = minter.decide(previous: currentIdentity, current: identity, currentJournalID: window?.journalID) + let journalID: JournalID + let didReset: Bool + switch decision { + case .same(let existing): + journalID = existing + didReset = false + case .created(let created): + journalID = created + didReset = true + } + + let shouldRebaseToTail = !didReset + && !fullRefresh + && capturedIdentity.size - currentByteOffset > AgentGUIConstants.journalIncrementalByteCap + let chunk = if didReset || fullRefresh || shouldRebaseToTail { + await readInitialTail() + } else { + await readChunk(from: currentByteOffset) + } + guard chunk.readSucceeded else { + lastReadFailed = true + return [] + } + lastReadFailed = false + if didReset || shouldRebaseToTail { + await decodeWorker.reset() + if didReset { + await AgentChatArtifactIndex.shared.removeSupplementalAttachments( + sessionID: sessionID.rawValue + ) + } + bookkeeper.reset() + toolPairingIndex.reset() + toolCallLinesByID.removeAll(keepingCapacity: true) + toolCallCacheOrder.removeAll(keepingCapacity: true) + currentByteOffset = 0 + pendingPartialLine.removeAll(keepingCapacity: true) + pendingPartialLineStartOffset = 0 + if didReset { + baselineFirstLine = capturedIdentity.firstLine + currentIdentity = capturedIdentity.journalIdentity(baselineFirstLine: baselineFirstLine) + } + } + lastReadByteCount = chunk.data.count + if didReset || fullRefresh || shouldRebaseToTail { + let result = await rebuildWindow(journalID: journalID, from: chunk) + currentByteOffset = chunk.endOffset + return result + } + + guard chunk.byteCount > 0 else { return [] } + currentByteOffset = chunk.endOffset + return await append(data: chunk.data, startingAt: chunk.startOffset, journalID: journalID) + } + + private func decode( + sourceLines: [AgentGUIJournalSourceLine], + journalID: JournalID + ) async -> [AgentGUIStampedEntry] { + var decodedEntries: [EntrySnapshot] = [] + var embeddedImages: [TranscriptEmbeddedImage] = [] + let decodedLines = await decodeWorker.feed(sourceLines, journalID: journalID) + for decodedLine in decodedLines { + cacheToolCalls( + in: decodedLine.sourceLine, + callIDs: decodedLine.toolCallIDs + ) + decodedEntries.append(contentsOf: decodedLine.batch.entries) + embeddedImages.append(contentsOf: decodedLine.batch.embeddedImages) + } + let resolvedEntries = await resolveImages( + in: decodedEntries, + embeddedImages: embeddedImages + ) + return resolvedEntries.map { entry in + bookkeeper.stamp(toolPairingIndex.normalize(entry)) + } + } + + private func resolveImages( + in entries: [EntrySnapshot], + embeddedImages: [TranscriptEmbeddedImage] + ) async -> [EntrySnapshot] { + guard !entries.isEmpty else { return [] } + let entryKeys = Set(entries.map { + AgentGUITranscriptImageEntryKey(journalID: $0.journalID, sequence: $0.seq) + }) + let relevantEmbeddedImages = embeddedImages.filter { + entryKeys.contains(AgentGUITranscriptImageEntryKey( + journalID: $0.journalID, + sequence: $0.entrySeq + )) + } + let referencedImages = entries.flatMap { entry -> [AgentGUITranscriptImageStore.ReferencedImage] in + switch entry.content.payload { + case .attachment(let attachment): + return referencedImage(for: attachment, entrySeq: entry.seq).map { [$0] } ?? [] + case .toolRun(let tool): + return (tool.previewAttachments ?? []).enumerated().compactMap { index, attachment in + referencedImage(for: attachment, entrySeq: entry.seq, attachmentIndex: index) + } + default: + return [] + } + } + var resolvedImages = await imageStore.inspect(referencedImages) + let materializedImages = await imageStore.materialize(relevantEmbeddedImages) + for (sequence, image) in materializedImages { + resolvedImages[AgentGUITranscriptImageStore.ReferenceKey(entrySeq: sequence)] = image + } + let resolved = entries.map { entry -> EntrySnapshot in + switch entry.content.payload { + case .attachment(let attachment): + guard let image = resolvedImages[AgentGUITranscriptImageStore.ReferenceKey(entrySeq: entry.seq)] else { + return entry + } + return entry.replacingPayload(.attachment(resolvedAttachment(attachment, image: image))) + case .toolRun(let tool): + guard let previewAttachments = tool.previewAttachments, + !previewAttachments.isEmpty else { + return entry + } + var didResolvePreview = false + let resolvedPreviews = previewAttachments.enumerated().map { index, attachment in + guard let image = resolvedImages[AgentGUITranscriptImageStore.ReferenceKey( + entrySeq: entry.seq, + attachmentIndex: index + )] else { + return attachment + } + didResolvePreview = true + return resolvedAttachment(attachment, image: image) + } + guard didResolvePreview else { return entry } + return entry.replacingPayload(.toolRun(ToolRunPayload( + toolName: tool.toolName, + argumentSummary: tool.argumentSummary, + resultSummary: tool.resultSummary, + isTerminal: tool.isTerminal, + exitCode: tool.exitCode, + isRunning: tool.isRunning, + toolCallID: tool.toolCallID, + inputDetail: tool.inputDetail, + command: tool.command, + output: tool.output, + durationSeconds: tool.durationSeconds, + status: tool.status, + previewAttachments: resolvedPreviews + ))) + default: + return entry + } + } + let attachments = resolvedImages.map { key, image in + return AgentChatArtifactIndex.SupplementalAttachment( + path: image.path, + sourceSeq: key.entrySeq.rawValue + ) + } + if !attachments.isEmpty { + await AgentChatArtifactIndex.shared.registerAttachments( + sessionID: sessionID.rawValue, + transcriptPath: path, + attachments: attachments + ) + } + return resolved + } + + private func referencedImage( + for attachment: AttachmentPayload, + entrySeq: EntrySeq, + attachmentIndex: Int? = nil + ) -> AgentGUITranscriptImageStore.ReferencedImage? { + guard isImageAttachmentCandidate(attachment), + let hostPath = attachment.hostPath, + !hostPath.isEmpty else { return nil } + return AgentGUITranscriptImageStore.ReferencedImage( + entrySeq: entrySeq, + attachmentIndex: attachmentIndex, + path: hostPath, + mimeType: attachment.mimeType + ) + } + + private func resolvedAttachment( + _ attachment: AttachmentPayload, + image: AgentGUITranscriptImageStore.MaterializedImage + ) -> AttachmentPayload { + AttachmentPayload( + kind: attachment.kind, + summary: attachment.summary, + attachmentID: attachment.attachmentID, + displayName: attachment.displayName, + hostPath: image.path, + mimeType: image.mimeType, + byteCount: image.byteCount, + width: image.width, + height: image.height, + aspectRatio: attachment.aspectRatio, + authorRole: attachment.authorRole + ) + } + + private func isImageAttachmentCandidate(_ attachment: AttachmentPayload) -> Bool { + if attachment.mimeType?.lowercased().hasPrefix("image/") == true { + return true + } + if attachment.kind.lowercased().contains("image") { + return true + } + if (attachment.width ?? 0) > 0, (attachment.height ?? 0) > 0 { + return true + } + if Self.hasImageExtension(attachment.displayName) || Self.hasImageExtension(attachment.hostPath) { + return true + } + return false + } + + private static func hasImageExtension(_ value: String?) -> Bool { + guard let value, !value.isEmpty else { return false } + switch URL(fileURLWithPath: value).pathExtension.lowercased() { + case "apng", "avif", "bmp", "gif", "heic", "heif", "jpeg", "jpg", "png", "svg", "tif", "tiff", "webp": + return true + default: + return false + } + } + + private func cacheToolCalls( + in sourceLine: AgentGUIJournalSourceLine, + callIDs: Set + ) { + for callID in callIDs { + cacheToolCall(callID: callID, sourceLine: sourceLine) + } + } + + private func cacheToolCall(callID: String, sourceLine: AgentGUIJournalSourceLine) { + if toolCallLinesByID[callID] == nil { + toolCallCacheOrder.append(callID) + } + toolCallLinesByID[callID] = sourceLine + while toolCallCacheOrder.count > AgentGUIConstants.journalToolCallCacheCap { + let evicted = toolCallCacheOrder.removeFirst() + toolCallLinesByID[evicted] = nil + } + } + + private func apply(_ stamped: [AgentGUIStampedEntry]) -> [AgentGUIJournalPipelineEvent] { + guard !stamped.isEmpty else { return [] } + if window == nil { + window = AgentGUIJournalWindow(journalID: stamped[0].entry.journalID) + } + var appended: [EntrySnapshot] = [] + var events: [AgentGUIJournalPipelineEvent] = [] + for item in stamped { + window?.apply(item.entry) + if item.isReplacement { + if !appended.isEmpty { + events.append(.appended(journalID: item.entry.journalID, entries: appended)) + appended.removeAll() + } + events.append(.replaced(journalID: item.entry.journalID, entry: item.entry)) + } else { + appended.append(item.entry) + } + } + if !appended.isEmpty, let journalID = appended.first?.journalID { + events.append(.appended(journalID: journalID, entries: appended)) + } + return events + } + + private func rebuildWindow( + journalID: JournalID, + from chunk: ReadChunk + ) async -> [AgentGUIJournalPipelineEvent] { + let lines = await consumeCompleteLines(from: chunk.data, startingAt: chunk.startOffset) + let stamped = await decode(sourceLines: lines, journalID: journalID) + var nextWindow = AgentGUIJournalWindow(journalID: journalID) + nextWindow.reset( + journalID: journalID, + entries: stamped.map(\.entry), + hasMoreBefore: chunk.discardedPrefix || stamped.count > AgentGUIConstants.journalWindowEntryCap + ) + window = nextWindow + return [.reset(journalID: journalID, tailSeq: nextWindow.tailSeq)] + } + + private func append( + data: Data, + startingAt byteOffset: Int, + journalID: JournalID + ) async -> [AgentGUIJournalPipelineEvent] { + let lines = await consumeCompleteLines(from: data, startingAt: byteOffset) + guard !lines.isEmpty else { return [] } + let stamped = await decode(sourceLines: lines, journalID: journalID) + return apply(stamped) + } + + private func fileIdentity() async -> Result { + let path = path + return await Task.detached(priority: .utility) { + AgentGUIFileIdentity.capture(path: path) + }.value + } + + private func readChunk(from byteOffset: Int) async -> ReadChunk { + let path = path + return await Task.detached(priority: .utility) { + guard let handle = try? FileHandle(forReadingFrom: URL(fileURLWithPath: path)) else { + return ReadChunk(data: Data(), startOffset: byteOffset, endOffset: byteOffset, discardedPrefix: false, readSucceeded: false) + } + defer { try? handle.close() } + do { try handle.seek(toOffset: UInt64(byteOffset)) } catch { + return ReadChunk(data: Data(), startOffset: byteOffset, endOffset: byteOffset, discardedPrefix: false, readSucceeded: false) + } + do { + let data = try handle.readToEnd() ?? Data() + return ReadChunk(data: data, startOffset: byteOffset, endOffset: byteOffset + data.count, discardedPrefix: false, readSucceeded: true) + } catch { + return ReadChunk(data: Data(), startOffset: byteOffset, endOffset: byteOffset, discardedPrefix: false, readSucceeded: false) + } + }.value + } + + private func readInitialTail() async -> ReadChunk { + let path = path + return await Task.detached(priority: .utility) { + AgentGUITranscriptTailReader.read( + path: path, + lineLimit: AgentGUIConstants.initialTailLineCap + AgentGUIConstants.journalPageContextLineCap, + byteLimit: AgentGUIConstants.initialTailByteCap + ) + }.value + } + + private func consumeCompleteLines( + from data: Data, + startingAt byteOffset: Int + ) async -> [AgentGUIJournalSourceLine] { + let priorPending = pendingPartialLine + let priorPendingStartOffset = pendingPartialLineStartOffset + let frame = await Task.detached(priority: .utility) { + AgentGUIJournalLineFramer.consume( + pendingData: priorPending, + pendingStartOffset: priorPendingStartOffset, + data: data, + startOffset: byteOffset + ) + }.value + pendingPartialLine = frame.pendingData + pendingPartialLineStartOffset = frame.pendingStartOffset + return frame.lines + } +} + +private struct AgentGUITranscriptImageEntryKey: Hashable { + let journalID: JournalID + let sequence: EntrySeq +} + +private extension EntrySnapshot { + func replacingPayload(_ payload: EntryPayload) -> EntrySnapshot { + EntrySnapshot( + journalID: journalID, + seq: seq, + kind: payload.kind, + content: EntryContent(contentHash: payload.stableHash, payload: payload), + version: version, + timestampMilliseconds: timestampMilliseconds + ) + } +} + +private struct AgentGUIJournalLineFrame: Sendable { + let lines: [AgentGUIJournalSourceLine] + let pendingData: Data + let pendingStartOffset: Int +} + +private enum AgentGUIJournalLineFramer { + static func consume( + pendingData: Data, + pendingStartOffset: Int, + data: Data, + startOffset: Int + ) -> AgentGUIJournalLineFrame { + let combinedStartOffset = pendingData.isEmpty ? startOffset : pendingStartOffset + var bytes = pendingData + bytes.append(data) + var lines: [AgentGUIJournalSourceLine] = [] + var lineStart = bytes.startIndex + for index in bytes.indices where bytes[index] == 0x0A { + let next = bytes.index(after: index) + let relativeStart = bytes.distance(from: bytes.startIndex, to: lineStart) + let relativeEnd = bytes.distance(from: bytes.startIndex, to: next) + lines.append(AgentGUIJournalSourceLine( + text: String(decoding: bytes[lineStart ..< index], as: UTF8.self), + startOffset: combinedStartOffset + relativeStart, + endOffset: combinedStartOffset + relativeEnd + )) + lineStart = next + } + return AgentGUIJournalLineFrame( + lines: lines, + pendingData: Data(bytes[lineStart...]), + pendingStartOffset: combinedStartOffset + + bytes.distance(from: bytes.startIndex, to: lineStart) + ) + } +} + +private struct ReadChunk: Sendable { + let data: Data + let startOffset: Int + let endOffset: Int + let discardedPrefix: Bool + let readSucceeded: Bool + var byteCount: Int { data.count } +} + +private enum AgentGUITranscriptTailReader { + static func read(path: String, lineLimit: Int, byteLimit: Int) -> ReadChunk { + guard let handle = try? FileHandle(forReadingFrom: URL(fileURLWithPath: path)) else { + return ReadChunk(data: Data(), startOffset: 0, endOffset: 0, discardedPrefix: false, readSucceeded: false) + } + defer { try? handle.close() } + let endOffset = Int((try? handle.seekToEnd()) ?? 0) + let startOffset = max(0, endOffset - max(1, byteLimit)) + let startsAtLineBoundary: Bool + if startOffset == 0 { + startsAtLineBoundary = true + } else { + do { + try handle.seek(toOffset: UInt64(startOffset - 1)) + startsAtLineBoundary = try handle.read(upToCount: 1)?.first == 0x0A + } catch { + startsAtLineBoundary = false + } + } + do { try handle.seek(toOffset: UInt64(startOffset)) } catch { + return ReadChunk(data: Data(), startOffset: startOffset, endOffset: endOffset, discardedPrefix: startOffset > 0, readSucceeded: false) + } + let readData: Data + do { + readData = try handle.readToEnd() ?? Data() + } catch { + return ReadChunk(data: Data(), startOffset: startOffset, endOffset: endOffset, discardedPrefix: startOffset > 0, readSucceeded: false) + } + var data = readData + var dataStartOffset = startOffset + var discardedPrefix = startOffset > 0 + if startOffset > 0, !startsAtLineBoundary { + if let firstNewline = data.firstIndex(of: 0x0A) { + let next = data.index(after: firstNewline) + dataStartOffset += data.distance(from: data.startIndex, to: next) + data = Data(data[next...]) + } else { + dataStartOffset = endOffset + data.removeAll() + } + } + let newlineIndexes = data.indices.filter { data[$0] == 0x0A } + if newlineIndexes.count > lineLimit { + let cutoff = newlineIndexes[newlineIndexes.count - lineLimit - 1] + let next = data.index(after: cutoff) + dataStartOffset += data.distance(from: data.startIndex, to: next) + data = Data(data[next...]) + discardedPrefix = true + } + return ReadChunk(data: data, startOffset: dataStartOffset, endOffset: endOffset, discardedPrefix: discardedPrefix, readSucceeded: true) + } +} diff --git a/Sources/AgentGUI/AgentGUIJournalPipelineEvent.swift b/Sources/AgentGUI/AgentGUIJournalPipelineEvent.swift new file mode 100644 index 000000000000..9e2cae3c9297 --- /dev/null +++ b/Sources/AgentGUI/AgentGUIJournalPipelineEvent.swift @@ -0,0 +1,8 @@ +import CmuxAgentReplica +import Foundation + +enum AgentGUIJournalPipelineEvent: Hashable, Sendable { + case reset(journalID: JournalID, tailSeq: EntrySeq) + case appended(journalID: JournalID, entries: [EntrySnapshot]) + case replaced(journalID: JournalID, entry: EntrySnapshot) +} diff --git a/Sources/AgentGUI/AgentGUIJournalToolCallLocator.swift b/Sources/AgentGUI/AgentGUIJournalToolCallLocator.swift new file mode 100644 index 000000000000..21b2b7bc0893 --- /dev/null +++ b/Sources/AgentGUI/AgentGUIJournalToolCallLocator.swift @@ -0,0 +1,112 @@ +import Foundation + +enum AgentGUIJournalToolCorrelation { + static func callIDs(in line: String) -> Set { + guard line.contains("\"tool_use\"") + || line.contains("\"function_call\"") + || line.contains("\"custom_tool_call\"") + || line.contains("\"web_search_call\"") + || line.contains("\"tool_search_call\"") else { return [] } + guard let root = object(in: line) else { return [] } + let payload = (root["payload"] as? [String: Any]) ?? root + if root["type"] as? String == "response_item", + let type = payload["type"] as? String, + ["function_call", "custom_tool_call", "web_search_call", "tool_search_call"].contains(type), + let callID = payload["call_id"] as? String { + return [callID] + } + return Set(claudeBlocks(in: root).compactMap { block in + guard block["type"] as? String == "tool_use" else { return nil } + return block["id"] as? String + }) + } + + static func resultIDs(in line: String) -> Set { + guard line.contains("\"tool_result\"") + || line.contains("\"function_call_output\"") + || line.contains("\"custom_tool_call_output\"") + || line.contains("\"tool_search_output\"") else { return [] } + guard let root = object(in: line) else { return [] } + let payload = (root["payload"] as? [String: Any]) ?? root + if root["type"] as? String == "response_item", + let type = payload["type"] as? String, + ["function_call_output", "custom_tool_call_output", "tool_search_output"].contains(type), + let callID = payload["call_id"] as? String { + return [callID] + } + return Set(claudeBlocks(in: root).compactMap { block in + guard block["type"] as? String == "tool_result" else { return nil } + return block["tool_use_id"] as? String + }) + } + + private static func object(in line: String) -> [String: Any]? { + guard let data = line.data(using: .utf8), + let value = try? JSONSerialization.jsonObject(with: data) else { + return nil + } + return value as? [String: Any] + } + + private static func claudeBlocks(in root: [String: Any]) -> [[String: Any]] { + let message = root["message"] as? [String: Any] + return message?["content"] as? [[String: Any]] ?? [] + } +} + +/// Finds source tool calls for result rows that begin outside the bounded +/// decoder overlap. It scans backward in bounded blocks and never loads a +/// journal-sized buffer. +enum AgentGUIJournalToolCallLocator { + struct Result: Sendable { + let linesByCallID: [String: AgentGUIJournalSourceLine] + let scannedByteCount: Int + let pageCount: Int + } + + static let scanByteCap = 4 * 1_024 * 1_024 + static let scanPageCap = 8 + private static let pageByteCap = 1 * 1_024 * 1_024 + private static let pageLineCap = 4_096 + + static func locate( + callIDs: Set, + path: String, + before offset: Int + ) -> Result { + var unresolved = callIDs + var found: [String: AgentGUIJournalSourceLine] = [:] + var boundary = offset + var scannedByteCount = 0 + var pageCount = 0 + while !unresolved.isEmpty, + boundary > 0, + scannedByteCount < scanByteCap, + pageCount < scanPageCap { + let remainingBytes = scanByteCap - scannedByteCount + let page = AgentGUIJournalPageReader.read( + path: path, + direction: .before(boundary), + lineLimit: pageLineCap, + byteLimit: min(pageByteCap, remainingBytes), + recoversOversizedRecords: false + ) + guard page.readSucceeded, page.startOffset < boundary else { break } + pageCount += 1 + scannedByteCount += boundary - page.startOffset + for line in page.lines.reversed() { + for callID in AgentGUIJournalToolCorrelation.callIDs(in: line.text) + where unresolved.contains(callID) { + found[callID] = line + unresolved.remove(callID) + } + } + boundary = page.startOffset + } + return Result( + linesByCallID: found, + scannedByteCount: scannedByteCount, + pageCount: pageCount + ) + } +} diff --git a/Sources/AgentGUI/AgentGUIJournalWindow.swift b/Sources/AgentGUI/AgentGUIJournalWindow.swift new file mode 100644 index 000000000000..c75fc2cfbe8d --- /dev/null +++ b/Sources/AgentGUI/AgentGUIJournalWindow.swift @@ -0,0 +1,52 @@ +import CmuxAgentReplica +import Foundation + +struct AgentGUIJournalWindow { + var journalID: JournalID + var entriesBySeq: [EntrySeq: EntrySnapshot] = [:] + var hasMoreBefore = false + + var tailSeq: EntrySeq { + entriesBySeq.keys.max() ?? EntrySeq(rawValue: 0) + } + + mutating func reset(journalID: JournalID, entries: [EntrySnapshot], hasMoreBefore: Bool) { + self.journalID = journalID + self.entriesBySeq = [:] + self.hasMoreBefore = hasMoreBefore + for entry in entries.sorted(by: { $0.seq < $1.seq }) { + insert(entry) + } + } + + mutating func apply(_ entry: EntrySnapshot) { + insert(entry) + } + + func page(beforeSeq: EntrySeq?, afterSeq: EntrySeq?, limit: Int) -> [EntrySnapshot] { + let clampedLimit = max(1, min(limit, AgentGUIConstants.maxEntriesLimit)) + let sorted = entriesBySeq.values.sorted { $0.seq < $1.seq } + if let beforeSeq { + return Array(sorted.filter { $0.seq < beforeSeq }.suffix(clampedLimit)) + } + if let afterSeq { + return Array(sorted.filter { $0.seq > afterSeq }.prefix(clampedLimit)) + } + return Array(sorted.suffix(clampedLimit)) + } + + func hasMoreBefore(for entries: [EntrySnapshot]) -> Bool { + guard let first = entries.first, let minimumSeq = entriesBySeq.keys.min() else { + return hasMoreBefore + } + return first.seq > minimumSeq || hasMoreBefore + } + + private mutating func insert(_ entry: EntrySnapshot) { + entriesBySeq[entry.seq] = entry + while entriesBySeq.count > AgentGUIConstants.journalWindowEntryCap, let oldest = entriesBySeq.keys.min() { + entriesBySeq.removeValue(forKey: oldest) + hasMoreBefore = true + } + } +} diff --git a/Sources/AgentGUI/AgentGUIOpenTranscriptPathScanner.swift b/Sources/AgentGUI/AgentGUIOpenTranscriptPathScanner.swift new file mode 100644 index 000000000000..451ffc9ded2d --- /dev/null +++ b/Sources/AgentGUI/AgentGUIOpenTranscriptPathScanner.swift @@ -0,0 +1,59 @@ +import Darwin +import Foundation + +struct AgentGUIOpenTranscriptPathScanner { + static func transcriptPath(pid: Int32, kind: String) -> String? { + openVnodePaths(pid: pid).first { path in + switch kind { + case "codex": + path.contains("/.codex/") && path.hasSuffix(".jsonl") + case "claude": + path.contains("/.claude/") && path.hasSuffix(".jsonl") + default: + path.hasSuffix(".jsonl") + } + } + } + + private static func openVnodePaths(pid: Int32) -> [String] { + let fdInfoSize = MemoryLayout.stride + let bufferSize = max(fdInfoSize * 256, 4096) + var buffer = [UInt8](repeating: 0, count: bufferSize) + let byteCount = buffer.withUnsafeMutableBytes { rawBuffer in + proc_pidinfo(pid_t(pid), PROC_PIDLISTFDS, 0, rawBuffer.baseAddress, Int32(bufferSize)) + } + guard byteCount > 0 else { return [] } + let count = Int(byteCount) / fdInfoSize + return buffer.withUnsafeBytes { rawBuffer -> [String] in + guard let base = rawBuffer.baseAddress?.assumingMemoryBound(to: proc_fdinfo.self) else { + return [] + } + var paths: [String] = [] + for index in 0.. String? { + var info = vnode_fdinfowithpath() + let expectedSize = MemoryLayout.stride + let size = withUnsafeMutableBytes(of: &info) { rawBuffer in + proc_pidfdinfo(pid_t(pid), fd, PROC_PIDFDVNODEPATHINFO, rawBuffer.baseAddress, Int32(expectedSize)) + } + guard size == expectedSize else { return nil } + var pathBuffer = info.pvip.vip_path + let capacity = MemoryLayout.size(ofValue: pathBuffer) + return withUnsafePointer(to: &pathBuffer) { pointer in + pointer.withMemoryRebound(to: CChar.self, capacity: capacity) { chars in + let path = String(cString: chars).trimmingCharacters(in: .whitespacesAndNewlines) + return path.isEmpty ? nil : path + } + } + } +} diff --git a/Sources/AgentGUI/AgentGUIProseScreenExtractor.swift b/Sources/AgentGUI/AgentGUIProseScreenExtractor.swift new file mode 100644 index 000000000000..39bd3e1365b3 --- /dev/null +++ b/Sources/AgentGUI/AgentGUIProseScreenExtractor.swift @@ -0,0 +1,191 @@ +import CmuxAgentReplica +import Foundation + +/// Conservatively extracts an in-flight answer from an agent terminal viewport. +struct AgentGUIProseScreenExtractor: Sendable { + private static let maxAnswerLines = 200 + private static let spinnerGlyphs: Set = [ + "✢", "✶", "✻", "✽", "✳", "·", "∗", "⟢", "✦", "✧", "◐", "◓", "◑", "◒", + ] + private static let spinnerLeadGlyphs = spinnerGlyphs.subtracting(["·"]) + + func extract(lines: [String], agentKind: AgentKind) -> String? { + let rows = lines.map(Self.trimTrailing) + guard let anchor = Self.statusLineIndex(in: rows), anchor > 0 else { return nil } + let answerTops = Self.answerTopBullets(for: agentKind) + let requiresAnswerTop = !answerTops.isEmpty + var collected: [String] = [] + var index = anchor - 1 + var foundAnswerTop = false + while index >= max(0, anchor - Self.maxAnswerLines) { + let row = rows[index] + if let first = row.trimmingCharacters(in: .whitespaces).first, + answerTops.contains(first) { + collected.append(Self.strippingLeadingBullet(row)) + foundAnswerTop = true + break + } + if Self.isBoundary(row, agentKind: agentKind) { break } + collected.append(row) + index -= 1 + } + guard !requiresAnswerTop || foundAnswerTop else { return nil } + collected.reverse() + if let first = collected.first { + collected[0] = Self.strippingLeadingBullet(first) + } + if requiresAnswerTop { + for index in collected.indices where index > 0 { + collected[index] = Self.strippingHangingIndent(collected[index]) + } + } + let cleaned = Self.collapsingBlankRuns(collected) + .joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + return cleaned.isEmpty ? nil : cleaned + } + + static func statusLineIndex(in rows: [String]) -> Int? { + for index in rows.indices.reversed() where isTimerStatusLine(rows[index]) { + return index + } + for index in rows.indices.reversed() where isGerundWorkingLine(rows[index]) { + return index + } + for index in rows.indices.reversed() + where isInterruptHintLine(rows[index]) && !isModeFooterLine(rows[index]) { + return index + } + return nil + } + + static func isTimerStatusLine(_ row: String) -> Bool { + let trimmed = row.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return false } + let lower = trimmed.lowercased() + let hasSpinner = trimmed.contains { spinnerGlyphs.contains($0) } + let hasThroughput = lower.contains("token") || trimmed.contains("↓") || trimmed.contains("↑") + guard hasSpinner || hasThroughput else { return false } + return hasThroughput ? containsElapsedTimer(trimmed) : containsParenthesizedTimer(trimmed) + } + + static func containsElapsedTimer(_ text: String) -> Bool { + let characters = Array(text) + var index = 0 + while index < characters.count { + guard characters[index].isNumber else { + index += 1 + continue + } + if index > 0, characters[index - 1].isLetter || characters[index - 1].isNumber { + index += 1 + continue + } + var cursor = index + while cursor < characters.count, characters[cursor].isNumber { cursor += 1 } + if cursor < characters.count, characters[cursor] == "m" { + let secondsStart = cursor + 1 + if secondsStart < characters.count, characters[secondsStart].isNumber { + cursor = secondsStart + while cursor < characters.count, characters[cursor].isNumber { cursor += 1 } + } + } + if cursor < characters.count, characters[cursor] == "s" { + let afterSeconds = cursor + 1 + if afterSeconds == characters.count || !characters[afterSeconds].isLetter { return true } + } + index = max(index + 1, cursor) + } + return false + } + + static func containsParenthesizedTimer(_ text: String) -> Bool { + let characters = Array(text) + for index in characters.indices where characters[index] == "(" { + var cursor = index + 1 + var sawDigits = false + while cursor < characters.count, characters[cursor].isNumber { + cursor += 1 + sawDigits = true + } + if sawDigits, cursor < characters.count, characters[cursor] == "m" { + cursor += 1 + while cursor < characters.count, characters[cursor].isNumber { cursor += 1 } + } + if sawDigits, cursor < characters.count, characters[cursor] == "s" { return true } + } + return false + } + + private static func isGerundWorkingLine(_ row: String) -> Bool { + let trimmed = row.trimmingCharacters(in: .whitespaces) + guard let first = trimmed.first, spinnerLeadGlyphs.contains(first) else { return false } + return trimmed.contains("…") + } + + private static func isInterruptHintLine(_ row: String) -> Bool { + let lower = row.lowercased() + return lower.contains("esc to interrupt") || lower.contains("esc to cancel") + } + + private static func isModeFooterLine(_ row: String) -> Bool { + let lower = row.lowercased() + return lower.contains("shift+tab") || lower.contains("for agents") + || lower.contains("auto mode") || lower.contains("⏵⏵") + } + + private static func isBoundary(_ row: String, agentKind: AgentKind) -> Bool { + guard let first = row.trimmingCharacters(in: .whitespaces).first else { return false } + switch agentKind { + case .claude, .unknown: + return ["●", "⎿", "❯", ">", "│"].contains(first) + case .codex: + return ["•", "›", "❯", ">", "│", "⎿"].contains(first) + } + } + + private static func answerTopBullets(for agentKind: AgentKind) -> Set { + switch agentKind { + case .claude, .unknown: ["⏺"] + case .codex: [] + } + } + + private static func strippingLeadingBullet(_ row: String) -> String { + var working = row + if let first = working.first, ["⏺", "●", "•", "›"].contains(first) { + working.removeFirst() + if working.first == " " { working.removeFirst() } + } + return working + } + + private static func strippingHangingIndent(_ row: String) -> String { + var working = Substring(row) + var removed = 0 + while removed < 2, working.first == " " { + working = working.dropFirst() + removed += 1 + } + return String(working) + } + + private static func trimTrailing(_ row: String) -> String { + var scalars = Array(row.unicodeScalars) + while let last = scalars.last, last == " " || last == "\t" { scalars.removeLast() } + return String(String.UnicodeScalarView(scalars)) + } + + private static func collapsingBlankRuns(_ rows: [String]) -> [String] { + var result: [String] = [] + var previousWasBlank = false + for row in rows { + let blank = row.trimmingCharacters(in: .whitespaces).isEmpty + if !blank || !previousWasBlank { result.append(row) } + previousWasBlank = blank + } + while result.first?.trimmingCharacters(in: .whitespaces).isEmpty == true { result.removeFirst() } + while result.last?.trimmingCharacters(in: .whitespaces).isEmpty == true { result.removeLast() } + return result + } +} diff --git a/Sources/AgentGUI/AgentGUIRPCError.swift b/Sources/AgentGUI/AgentGUIRPCError.swift new file mode 100644 index 000000000000..236ae867de27 --- /dev/null +++ b/Sources/AgentGUI/AgentGUIRPCError.swift @@ -0,0 +1,60 @@ +import Foundation + +enum AgentGUIRPCError: Error { + case invalidParams + case unsupportedProtocol + case notFound + case bindingLost + case inputQueueFull + case processExited + case sendRejected(detail: String) + case internalError + + var code: String { + switch self { + case .invalidParams: "invalid_params" + case .unsupportedProtocol: "unsupported_protocol" + case .notFound: "not_found" + case .bindingLost: "binding_lost" + case .inputQueueFull: "input_queue_full" + case .processExited: "process_exited" + case .sendRejected: "send_rejected" + case .internalError: "internal_error" + } + } + + var message: String { + switch self { + case .invalidParams: "Invalid GUI RPC parameters" + case .unsupportedProtocol: "Unsupported GUI protocol" + case .notFound: "GUI session not found" + case .bindingLost: "GUI session binding lost" + case .inputQueueFull: "GUI session input queue full" + case .processExited: "GUI session process exited" + case .sendRejected: "GUI send rejected" + case .internalError: "GUI RPC failed" + } + } + + var data: Any? { + switch self { + case .sendRejected(let detail): + ["detail": detail] + case .invalidParams, .unsupportedProtocol, .notFound, .bindingLost, .inputQueueFull, .processExited, .internalError: + nil + } + } + + static func fromInjectionFailure(_ result: AgentGUITerminalInjectionResult) -> AgentGUIRPCError { + switch result { + case .accepted: + .internalError + case .bindingLost: + .bindingLost + case .inputQueueFull: + .inputQueueFull + case .processExited: + .processExited + } + } +} diff --git a/Sources/AgentGUI/AgentGUIRPCHandler.swift b/Sources/AgentGUI/AgentGUIRPCHandler.swift new file mode 100644 index 000000000000..64604fa2dc89 --- /dev/null +++ b/Sources/AgentGUI/AgentGUIRPCHandler.swift @@ -0,0 +1,69 @@ +import CmuxAgentReplica +import CmuxAgentWire +import Foundation + +extension AgentGUIService { + func handleRPC(_ request: MobileHostRPCRequest) async -> MobileHostRPCResult? { + await AgentGUIRPCHandler(service: self).handle(request) + } +} + +@MainActor +struct AgentGUIRPCHandler { + private let service: AgentGUIService + + init(service: AgentGUIService) { + self.service = service + } + + func handle(_ request: MobileHostRPCRequest) async -> MobileHostRPCResult? { + do { + switch request.method { + case GuiWireMethod.hello: + return .ok(try hello(params: request.params)) + case GuiWireMethod.sessions: + service.forceProcessScan() + return .ok(try AgentGUICodableBridge.dictionary(service.sessionsResult())) + case GuiWireMethod.session: + let params = try AgentGUICodableBridge.decode(GuiSessionParams.self, from: request.params) + return .ok(try AgentGUICodableBridge.dictionary(service.sessionResult(id: params.sessionID))) + case GuiWireMethod.entries: + let params = try AgentGUICodableBridge.decode(GuiEntriesParams.self, from: request.params) + return .ok(try await AgentGUICodableBridge.dictionary(service.entriesResult(params: params))) + case GuiWireMethod.send: + let params = try AgentGUICodableBridge.decode(GuiSendParams.self, from: request.params) + return .ok(try AgentGUICodableBridge.dictionary(service.sendResult(params: params))) + case GuiWireMethod.interrupt: + let params = try AgentGUICodableBridge.decode(GuiInterruptParams.self, from: request.params) + return .ok(try AgentGUICodableBridge.dictionary(service.interruptResult(params: params))) + case GuiWireMethod.answer: + let params = try AgentGUICodableBridge.decode(GuiAnswerParams.self, from: request.params) + return .ok(try AgentGUICodableBridge.dictionary(service.answerResult(params: params))) + case GuiWireMethod.capabilities: + let params = try AgentGUICodableBridge.decode(GuiCapabilitiesParams.self, from: request.params) + return .ok(try AgentGUICodableBridge.dictionary(service.capabilitiesResult(params: params))) + default: + return nil + } + } catch let error as AgentGUIRPCError { + return .failure(MobileHostRPCError(code: error.code, message: error.message, data: error.data)) + } catch { + return .failure(MobileHostRPCError(code: AgentGUIRPCError.invalidParams.code, message: AgentGUIRPCError.invalidParams.message)) + } + } + + private func hello(params: [String: Any]) throws -> [String: Any] { + let hello = try AgentGUICodableBridge.decode(GuiHelloParams.self, from: params) + guard hello.protocolMin <= 1, hello.protocolMax >= 1 else { + throw AgentGUIRPCError.unsupportedProtocol + } + let result = GuiHelloResult( + protocol: 1, + serverCaps: [GuiWireCaps.entriesPaging, GuiWireCaps.sendTickets, GuiWireCaps.answers, GuiWireCaps.capabilitiesReport], + epoch: service.epoch, + macDeviceID: service.macDeviceID, + serverTimeMS: Int64(Date().timeIntervalSince1970 * 1_000) + ) + return try AgentGUICodableBridge.dictionary(result) + } +} diff --git a/Sources/AgentGUI/AgentGUISendLedger.swift b/Sources/AgentGUI/AgentGUISendLedger.swift new file mode 100644 index 000000000000..dddeb0d49c86 --- /dev/null +++ b/Sources/AgentGUI/AgentGUISendLedger.swift @@ -0,0 +1,251 @@ +import CmuxAgentReplica +import CmuxAgentWire +import Foundation + +@MainActor +final class AgentGUISendLedger { + private struct Record { + var ticket: SendTicket + var injectedText: String? + var injectedAt: Int? + var resolvedAt: Int? + var unmatchedUserMessageCount: Int + } + + private let sessionID: AgentSessionID + private let clock: () -> Int + private let injector: any AgentGUITerminalInjecting + private let publish: (SendTicket) -> Void + private var records: [UUID: Record] = [:] + private var order: [UUID] = [] + private var queued: [UUID] = [] + private var lastPhase: SessionPhase? + private var highestProcessedSeqByJournalID: [JournalID: EntrySeq] = [:] + + init( + sessionID: AgentSessionID, + clock: @escaping () -> Int, + injector: any AgentGUITerminalInjecting, + publish: @escaping (SendTicket) -> Void + ) { + self.sessionID = sessionID + self.clock = clock + self.injector = injector + self.publish = publish + } + + var hasPendingExpirations: Bool { + records.values.contains { record in + if case .injected = record.ticket.state { return true } + return record.resolvedAt != nil + } + } + + var retainedRecordCount: Int { records.count } + + func submit( + ticketID: UUID, + text: String, + attachmentCount: Int, + snapshot: AgentSessionSnapshot? + ) throws -> GuiSendResult { + pruneResolvedTickets(now: clock()) + if let existing = records[ticketID] { + return GuiSendResult(accepted: true, queuedOnMac: queued.contains(ticketID) && existing.ticket.state == .acceptedByMac) + } + + let ticket = SendTicket( + id: ticketID, + sessionID: sessionID, + text: text, + attachmentCount: attachmentCount, + state: .acceptedByMac, + createdAt: clock() + ) + records[ticketID] = Record(ticket: ticket, injectedText: nil, injectedAt: nil, resolvedAt: nil, unmatchedUserMessageCount: 0) + order.append(ticketID) + publish(ticket) + + guard let snapshot else { + transition(ticketID, to: .failed(code: "binding_lost")) + throw AgentGUIRPCError.bindingLost + } + if lastPhase == nil { + lastPhase = snapshot.phase + } + guard !shouldQueue(phase: snapshot.phase), queued.isEmpty else { + queued.append(ticketID) + return GuiSendResult(accepted: true, queuedOnMac: true) + } + let result = inject(ticketID, snapshot: snapshot) + if !result.accepted { + throw AgentGUIRPCError.fromInjectionFailure(result) + } + return GuiSendResult(accepted: true, queuedOnMac: false) + } + + func handleSessionSnapshot(_ snapshot: AgentSessionSnapshot) { + let previous = lastPhase + lastPhase = snapshot.phase + if previous != .ended, snapshot.phase == .ended { + failQueuedTickets() + return + } + guard previous != nil, previous != .idle, snapshot.phase == .idle else { return } + injectNextQueued(snapshot: snapshot) + } + + func handleJournalEvent(_ event: AgentGUIJournalPipelineEvent) { + switch event { + case .appended(let journalID, let entries): + for entry in entries.sorted(by: { $0.seq < $1.seq }) { + if let highest = highestProcessedSeqByJournalID[journalID], entry.seq <= highest { + continue + } + highestProcessedSeqByJournalID[journalID] = entry.seq + handleAppendedEntry(entry) + } + case .reset(let journalID, _): + highestProcessedSeqByJournalID.removeValue(forKey: journalID) + case .replaced: + break + } + } + + func expire(now: Int? = nil) { + let current = now ?? clock() + for ticketID in Array(order) { + guard let record = records[ticketID], + case .injected = record.ticket.state, + let injectedAt = record.injectedAt, + current - injectedAt >= AgentGUIConstants.sendEchoTimeoutMS else { + continue + } + transition(ticketID, to: .unconfirmed) + } + pruneResolvedTickets(now: current) + } + + private func injectNextQueued(snapshot: AgentSessionSnapshot) { + while !queued.isEmpty { + let ticketID = queued.removeFirst() + guard let record = records[ticketID], record.ticket.state == .acceptedByMac else { + continue + } + _ = inject(ticketID, snapshot: snapshot) + return + } + } + + private func failQueuedTickets() { + queued.removeAll() + for ticketID in order { + guard records[ticketID]?.ticket.state == .acceptedByMac else { continue } + transition(ticketID, to: .failed(code: AgentGUITerminalInjectionResult.processExited.failureCode)) + } + } + + private func inject(_ ticketID: UUID, snapshot: AgentSessionSnapshot) -> AgentGUITerminalInjectionResult { + guard let surfaceID = snapshot.surfaceID, !surfaceID.isEmpty, + let record = records[ticketID] else { + transition(ticketID, to: .failed(code: "binding_lost")) + return .bindingLost + } + let result = injector.submitPrompt(surfaceID: surfaceID, text: record.ticket.text) + guard result.accepted else { + transition(ticketID, to: .failed(code: result.failureCode)) + return result + } + updateRecord(ticketID) { current in + current.injectedText = current.ticket.text + current.injectedAt = clock() + current.unmatchedUserMessageCount = 0 + } + transition(ticketID, to: .injected) + return .accepted + } + + private func handleAppendedEntry(_ entry: EntrySnapshot) { + guard case .userMessage(let payload) = entry.content.payload, + let ticketID = oldestInjectedTicketID(), + var record = records[ticketID], + let injectedText = record.injectedText else { + return + } + if payload.text == injectedText || normalized(payload.text) == normalized(injectedText) { + transition(ticketID, to: .echoed(entry.seq)) + return + } + record.unmatchedUserMessageCount += 1 + records[ticketID] = record + if record.unmatchedUserMessageCount >= AgentGUIConstants.sendEchoUnmatchedAppendLimit { + transition(ticketID, to: .unconfirmed) + } + } + + private func oldestInjectedTicketID() -> UUID? { + order.first { ticketID in + guard let record = records[ticketID] else { return false } + if case .injected = record.ticket.state { + return true + } + return false + } + } + + private func pruneResolvedTickets(now: Int) { + for ticketID in Array(order) { + guard let resolvedAt = records[ticketID]?.resolvedAt, + now - resolvedAt >= AgentGUIConstants.sendTicketIdempotencyWindowMS else { continue } + remove(ticketID) + } + enforceResolvedRetentionLimit() + } + + private func enforceResolvedRetentionLimit() { + var resolvedIDs = order.filter { records[$0]?.resolvedAt != nil } + while resolvedIDs.count > AgentGUIConstants.resolvedSendTicketRetentionLimit { + remove(resolvedIDs.removeFirst()) + } + } + + private func remove(_ ticketID: UUID) { + records.removeValue(forKey: ticketID) + order.removeAll { $0 == ticketID } + queued.removeAll { $0 == ticketID } + } + + private func transition(_ ticketID: UUID, to state: SendTicketState) { + guard var record = records[ticketID] else { return } + let current = record.ticket + let next = SendTicket( + id: current.id, + sessionID: current.sessionID, + text: current.text, + attachmentCount: current.attachmentCount, + state: state, + createdAt: current.createdAt + ) + record.ticket = next + if state.isResolved, !current.state.isResolved { + record.resolvedAt = clock() + } + records[ticketID] = record + publish(next) + enforceResolvedRetentionLimit() + } + + private func updateRecord(_ ticketID: UUID, _ body: (inout Record) -> Void) { + guard var record = records[ticketID] else { return } + body(&record) + records[ticketID] = record + } + + private func shouldQueue(phase: SessionPhase) -> Bool { + phase == .working || phase == .needsInput + } + + private func normalized(_ value: String) -> String { + value.split(whereSeparator: \.isWhitespace).joined(separator: " ") + } +} diff --git a/Sources/AgentGUI/AgentGUIService.swift b/Sources/AgentGUI/AgentGUIService.swift new file mode 100644 index 000000000000..2c4e2bc6ec46 --- /dev/null +++ b/Sources/AgentGUI/AgentGUIService.swift @@ -0,0 +1,567 @@ +import CMUXAgentLaunch +import CmuxAgentReplica +import CmuxAgentTruthKit +import CmuxAgentWire +import Foundation + +@MainActor +final class AgentGUIService { + struct ResolvedPageRequest: Equatable { + let direction: AgentGUIJournalPageDirection + let requiresPagingRestart: Bool + } + + struct ArtifactSessionContext: Equatable { + let sessionID: String + let agentKind: AgentKind + let transcriptPath: String + let workingDirectory: String? + } + + // Assigned once during app startup before control-socket requests can arrive. + nonisolated(unsafe) static private(set) var shared: AgentGUIService? + + let epoch: ReplicaEpoch + let macDeviceID: MacDeviceID + private let reducer: AgentTruthReducer + private let publisher: AgentGUIWirePublisher + private let clock: () -> Int + private let hasEventSubscribers: (String) -> Bool + private let terminalInjector: any AgentGUITerminalInjecting + private let hookMapper = AgentGUIHookFactMapper() + private let transcriptResolver = AgentGUITranscriptResolver() + private let capabilityBuilder = CapabilityReportBuilder() + private let capabilityMapper = AgentGUICapabilityMapper() + private var processSource: AgentProcessObservationSource? + private var exitWatcher: AgentProcessExitWatcher? + private var reevaluationTimer: DispatchSourceTimer? + private var pipelines: [AgentSessionID: AgentGUIJournalPipeline] = [:] + private var sendLedgers: [AgentSessionID: AgentGUISendLedger] = [:] + private let askRegistry: AgentGUIAskRegistry + private let incrementalState = AgentGUIIncrementalState() + private var streamProducer: AgentGUIStreamProducer? + private var removalVersions: [AgentSessionID: UInt64] = [:] + private var subscriptionObserver: NSObjectProtocol? + private let hookTapStream: AsyncStream + private nonisolated let hookTapContinuation: AsyncStream.Continuation + private var hookTapTask: Task? + private var started = false + + init( + macDeviceID: String = MobileHostIdentity.deviceID(), + clock: @escaping () -> Int = { Int(Date().timeIntervalSince1970 * 1_000) }, + terminalInjector: (any AgentGUITerminalInjecting)? = nil, + hasEventSubscribers: @escaping (String) -> Bool = { topic in + MobileHostService.hasEventSubscribers(topic: topic) + } + ) { + self.epoch = ReplicaEpoch(rawValue: UUID().uuidString) + self.macDeviceID = MacDeviceID(rawValue: macDeviceID) + self.reducer = AgentTruthReducer(macDeviceID: self.macDeviceID) + self.publisher = AgentGUIWirePublisher(epoch: epoch) + self.clock = clock + self.hasEventSubscribers = hasEventSubscribers + let resolvedTerminalInjector = terminalInjector ?? AgentGUITerminalInjector() + self.terminalInjector = resolvedTerminalInjector + self.askRegistry = AgentGUIAskRegistry( + clock: clock, + injector: resolvedTerminalInjector, + publish: { [publisher] ask in + publisher.publishAskState(ask) + } + ) + let hookTap = AsyncStream.makeStream() + self.hookTapStream = hookTap.stream + self.hookTapContinuation = hookTap.continuation + self.streamProducer = AgentGUIStreamProducer( + publish: { [publisher] sessionID, event in + publisher.publishStreamTick(event, sessionID: sessionID) + }, + snapshot: { surfaceID in + GhosttyApp.terminalSurfaceRegistry.terminalSurface(id: surfaceID)? + .mobileRenderGridFrame(stateSeq: 0, full: true)?.rows + }, + hasSubscribers: { sessionID in + hasEventSubscribers(GuiWireTopic.journal(sessionID: sessionID)) + }, + context: { [weak self] sessionID in + guard let window = self?.pipelines[sessionID]?.window else { return nil } + return AgentGUIStreamProducer.Context(journalID: window.journalID, afterSeq: window.tailSeq) + } + ) + } + + func start() { + guard !started else { return } + started = true + Self.shared = self + processSource = AgentProcessObservationSource { [weak self] observations in + self?.handleProcessObservations(observations) + } + exitWatcher = AgentProcessExitWatcher { [weak self] pid, startTick in + self?.fold(.processGone(pid: pid, startTick: startTick, tick: self?.currentActivityHintMS() ?? 0)) + } + let stream = hookTapStream + hookTapTask = Task { @MainActor [weak self] in + for await event in stream { + guard let self else { return } + self.handleHookEventSerial(event) + } + } + subscriptionObserver = NotificationCenter.default.addObserver( + forName: .mobileHostEventSubscriptionsDidChange, + object: nil, + queue: nil + ) { [weak self] notification in + let changedTopics = notification.userInfo?["topics"] as? [String] + Task { @MainActor in + self?.handleSubscriptionChange(changedTopics: changedTopics) + } + } + updateGates() + } + + deinit { + let exitWatcher = exitWatcher + let processSource = processSource + let reevaluationTimer = reevaluationTimer + let hookTapContinuation = hookTapContinuation + let hookTapTask = hookTapTask + let subscriptionObserver = subscriptionObserver + let streamProducer = streamProducer + Task { @MainActor in + exitWatcher?.stopAll() + processSource?.setRunning(false) + reevaluationTimer?.cancel() + hookTapContinuation.finish() + hookTapTask?.cancel() + streamProducer?.stopAll() + if let subscriptionObserver { + NotificationCenter.default.removeObserver(subscriptionObserver) + } + } + } + + nonisolated func handleHookEvent(_ event: WorkstreamEvent) { + hookTapContinuation.yield(event) + } + + func forceProcessScan() { + processSource?.scanNow() + } + + #if DEBUG + func ingestProcessObservationsForTesting(_ observations: [ProcessObservation]) { + handleProcessObservations(observations) + } + + func refreshSubscriptionsForTesting(changedTopics: [String]) { + handleSubscriptionChange(changedTopics: changedTopics) + } + + var journalPipelineCountForTesting: Int { + pipelines.count + } + + var watchingJournalPipelineCountForTesting: Int { + pipelines.values.count(where: \.isWatching) + } + #endif + + func sessionsResult() -> GuiSessionsResult { + GuiSessionsResult(epoch: epoch, sessions: reducer.snapshots.values.sorted { $0.lastActivityHint > $1.lastActivityHint }) + } + + func sessionResult(id: AgentSessionID) throws -> GuiSessionResult { + guard let session = reducer.snapshot(for: id) else { + throw AgentGUIRPCError.notFound + } + return GuiSessionResult(epoch: epoch, session: session) + } + + func entriesResult(params: GuiEntriesParams) async throws -> GuiEntriesResult { + guard let pipeline = ensurePipeline(sessionID: params.sessionID) else { + throw AgentGUIRPCError.notFound + } + let initialEvents = await pipeline.ingestInitial() + handleJournalEvents(initialEvents, sessionID: params.sessionID) + let limit = max(1, min(params.limit, AgentGUIConstants.maxEntriesLimit)) + if let anchor = params.anchor { + guard let currentJournalID = pipeline.currentJournalID else { + throw AgentGUIRPCError.notFound + } + let request = Self.resolvePageRequest( + anchor: anchor, + cursor: params.cursor, + expectedJournalID: params.journalID, + currentJournalID: currentJournalID + ) + var page = await pipeline.diskEntries(direction: request.direction, limit: limit) + var requiresPagingRestart = request.requiresPagingRestart + if page == nil { + switch request.direction { + case .before, .after: + page = await pipeline.diskEntries(direction: .tail, limit: limit) + requiresPagingRestart = true + case .head, .tail: + break + } + } + guard let page else { + throw AgentGUIRPCError.notFound + } + let windowStart = page.entries.map(\.seq).min() ?? page.tailSeq + let windowEnd = page.entries.map(\.seq).max() ?? page.tailSeq + return GuiEntriesResult( + journalID: page.journalID, + entries: page.entries, + windowStart: windowStart, + windowEnd: windowEnd, + tailSeq: page.tailSeq, + hasMoreBefore: page.hasMoreBefore, + hasMoreAfter: page.hasMoreAfter, + startCursor: AgentGUIJournalCursorCodec.encode(journalID: page.journalID, byteOffset: page.startOffset), + endCursor: AgentGUIJournalCursorCodec.encode(journalID: page.journalID, byteOffset: page.endOffset), + tailCursor: AgentGUIJournalCursorCodec.encode(journalID: page.journalID, byteOffset: page.tailOffset), + requiresPagingRestart: requiresPagingRestart + ) + } + guard let page = pipeline.entries(beforeSeq: params.beforeSeq, afterSeq: params.afterSeq, limit: limit) else { + throw AgentGUIRPCError.notFound + } + if let expected = params.journalID, expected != page.journalID { + throw AgentGUIRPCError.notFound + } + return GuiEntriesResult( + journalID: page.journalID, + entries: page.entries, + windowStart: page.windowStart, + windowEnd: page.windowEnd, + tailSeq: page.tailSeq, + hasMoreBefore: page.hasMoreBefore + ) + } + + static func resolvePageRequest( + anchor: GuiEntriesAnchor, + cursor: JournalCursor?, + expectedJournalID: JournalID?, + currentJournalID: JournalID + ) -> ResolvedPageRequest { + if let expectedJournalID, expectedJournalID != currentJournalID { + return ResolvedPageRequest(direction: .tail, requiresPagingRestart: true) + } + switch anchor { + case .head: + return ResolvedPageRequest(direction: .head, requiresPagingRestart: false) + case .tail: + return ResolvedPageRequest(direction: .tail, requiresPagingRestart: false) + case .before: + guard let cursor, + let offset = AgentGUIJournalCursorCodec.decode(cursor, journalID: currentJournalID) else { + return ResolvedPageRequest(direction: .tail, requiresPagingRestart: true) + } + return ResolvedPageRequest(direction: .before(offset), requiresPagingRestart: false) + case .after: + guard let cursor, + let offset = AgentGUIJournalCursorCodec.decode(cursor, journalID: currentJournalID) else { + return ResolvedPageRequest(direction: .tail, requiresPagingRestart: true) + } + return ResolvedPageRequest(direction: .after(offset), requiresPagingRestart: false) + } + } + + func capabilitiesResult(params: GuiCapabilitiesParams) throws -> GuiCapabilitiesResult { + guard let evidence = reducer.evidence(for: params.sessionID) else { + throw AgentGUIRPCError.notFound + } + let report = capabilityBuilder.report(for: evidence) + return GuiCapabilitiesResult( + tier: report.tier, + reasons: report.reasons.map { capabilityMapper.map($0) }, + cliVersion: nil, + steerable: report.steerable, + answerable: report.answerable + ) + } + + /// Wire attachment descriptors are rejected with `send_rejected` and `attachment_unsupported` until binary transfer is implemented. + func sendResult(params: GuiSendParams) throws -> GuiSendResult { + guard let ticketID = UUID(uuidString: params.ticketID) else { + throw AgentGUIRPCError.invalidParams + } + let attachments = params.attachments ?? [] + guard attachments.isEmpty else { + throw AgentGUIRPCError.sendRejected(detail: "attachment_unsupported") + } + let text = params.text ?? "" + guard !text.isEmpty else { + throw AgentGUIRPCError.invalidParams + } + guard let snapshot = reducer.snapshot(for: params.sessionID), + let evidence = reducer.evidence(for: params.sessionID) else { + throw AgentGUIRPCError.notFound + } + guard capabilityBuilder.report(for: evidence).steerable else { + throw AgentGUIRPCError.sendRejected(detail: "session_not_steerable") + } + let result = try ledger(sessionID: params.sessionID).submit( + ticketID: ticketID, + text: text, + attachmentCount: attachments.count, + snapshot: snapshot + ) + updateGates() + return result + } + + func interruptResult(params: GuiInterruptParams) throws -> GuiInterruptResult { + guard let snapshot = reducer.snapshot(for: params.sessionID) else { + throw AgentGUIRPCError.notFound + } + guard let surfaceID = snapshot.surfaceID, !surfaceID.isEmpty else { + throw AgentGUIRPCError.bindingLost + } + let result = terminalInjector.sendKey(surfaceID: surfaceID, keyName: params.hard ? "ctrl+c" : "escape") + guard result.accepted else { + throw AgentGUIRPCError.fromInjectionFailure(result) + } + return GuiInterruptResult(interrupted: true) + } + + func answerResult(params: GuiAnswerParams) throws -> GuiAnswerResult { + let result = try askRegistry.answer(params: params) + updateGates() + return result + } + + private func handleProcessObservations(_ observations: [ProcessObservation]) { + for observation in observations { + let knownSessionID = incrementalState.sessionID(pid: observation.pid, startTick: observation.startTick) + let activityHint = knownSessionID.flatMap { reducer.snapshot(for: $0)?.lastActivityHint } + ?? currentActivityHintMS() + let changedSessionID = fold(.processObserved(observation, tick: activityHint)) + exitWatcher?.watch(pid: observation.pid, startTick: observation.startTick) + guard let sessionID = changedSessionID ?? knownSessionID else { continue } + incrementalState.bindProcess(pid: observation.pid, startTick: observation.startTick, to: sessionID) + ensurePipelineIfDemanded(sessionID: sessionID) + } + updateGates() + } + + func handleHookEventSerial(_ event: WorkstreamEvent) { + let tick = currentActivityHintMS() + if let wrapperFact = hookMapper.wrapperLaunchFact(from: event) { + fold(.wrapperLaunched(wrapperFact, tick: tick)) + } + let fact = hookMapper.hookFact(from: event) + fold(.hookEvent(fact, tick: tick)) + ensurePipelineIfDemanded(sessionID: fact.sessionID) + switch event.hookEventName { + case .userPromptSubmit: + if let snapshot = reducer.snapshot(for: fact.sessionID), + let rawSurfaceID = snapshot.surfaceID, + let surfaceID = UUID(uuidString: rawSurfaceID) { + streamProducer?.turnStarted(sessionID: fact.sessionID, surfaceID: surfaceID, agentKind: snapshot.kind) + } + case .stop, .sessionEnd: + streamProducer?.turnEnded(sessionID: fact.sessionID) + default: + break + } + updateGates() + } + + private func handleSubscriptionChange(changedTopics: [String]?) { + for topic in changedTopics ?? [] where topic.hasPrefix(GuiWireTopic.journalPrefix) { + let rawSessionID = String(topic.dropFirst(GuiWireTopic.journalPrefix.count)) + guard !rawSessionID.isEmpty else { continue } + let sessionID = AgentSessionID(rawValue: rawSessionID) + ensurePipelineIfDemanded(sessionID: sessionID) + } + updateGates() + } + + private func ensurePipelineIfDemanded(sessionID: AgentSessionID) { + let topic = GuiWireTopic.journal(sessionID: sessionID) + guard hasEventSubscribers(topic) else { return } + _ = ensurePipeline(sessionID: sessionID) + } + + private func ensurePipeline(sessionID: AgentSessionID) -> AgentGUIJournalPipeline? { + if let pipeline = pipelines[sessionID] { + return pipeline + } + guard let snapshot = reducer.snapshot(for: sessionID) else { return nil } + let evidencePath = reducer.evidence(for: sessionID)?.transcriptPath + guard let path = transcriptResolver.transcriptPath( + sessionID: sessionID, + kind: snapshot.kind, + cwd: snapshot.cwd, + evidencePath: evidencePath + ) else { + return nil + } + let pipeline = AgentGUIJournalPipeline(sessionID: sessionID, kind: snapshot.kind, path: path) + pipelines[sessionID] = pipeline + Task { @MainActor [weak self, weak pipeline] in + guard let pipeline else { return } + let events = await pipeline.ingestInitial() + guard let self else { return } + self.handleJournalEvents(events, sessionID: sessionID) + } + return pipeline + } + + func artifactSessionContext(sessionID: String) -> ArtifactSessionContext? { + let id = AgentSessionID(rawValue: sessionID) + guard let snapshot = reducer.snapshot(for: id), + let transcriptPath = transcriptResolver.transcriptPath( + sessionID: id, + kind: snapshot.kind, + cwd: snapshot.cwd, + evidencePath: reducer.evidence(for: id)?.transcriptPath + ) else { + return nil + } + return ArtifactSessionContext( + sessionID: sessionID, + agentKind: snapshot.kind, + transcriptPath: transcriptPath, + workingDirectory: snapshot.cwd + ) + } + + private func indexProcessIdentity(for sessionID: AgentSessionID) { + guard let identity = reducer.evidence(for: sessionID)?.processIdentity, + let startTick = identity.startTick else { + return + } + incrementalState.bindProcess(pid: identity.pid, startTick: startTick, to: sessionID) + } + + @discardableResult + private func fold(_ signal: TruthChannelSignal) -> AgentSessionID? { + var changedSessionID: AgentSessionID? + for change in reducer.fold(signal) { + switch change { + case .sessionUpserted(let session): + changedSessionID = session.id + incrementalState.updateSession(session) + indexProcessIdentity(for: session.id) + publisher.publishSessionUpserted(session) + sendLedgers[session.id]?.handleSessionSnapshot(session) + askRegistry.handleSessionSnapshot(session) + case .sessionRemoved(let sessionID): + incrementalState.removeSession(sessionID) + streamProducer?.turnEnded(sessionID: sessionID) + sendLedgers.removeValue(forKey: sessionID) + askRegistry.removeSession(sessionID) + if let pipeline = pipelines.removeValue(forKey: sessionID) { + pipeline.setWatching(false) { _ in } + } + Task { + await AgentChatArtifactIndex.shared.removeSupplementalAttachments( + sessionID: sessionID.rawValue + ) + } + let version = nextRemovalVersion(sessionID: sessionID) + publisher.publishSessionRemoved(sessionID, version: EntityVersion(rawValue: version)) + } + } + return changedSessionID + } + + private func updateGates() { + let nowTick = currentActivityHintMS() + expirePendingAgentGUIState(now: nowTick) + let hasSessionSubscribers = hasEventSubscribers(GuiWireTopic.sessions) + let hasLiveRecent = incrementalState.hasLiveOrRecentlyActiveSession(at: nowTick) + processSource?.setRunning(AgentGUISubscriptionPolicy.shouldRunObservation( + hasSessionSubscribers: hasSessionSubscribers, + hasLiveRecentSession: hasLiveRecent + )) + for (sessionID, pipeline) in pipelines { + let topic = GuiWireTopic.journal(sessionID: sessionID) + let shouldRun = AgentGUISubscriptionPolicy.shouldRunJournal( + hasJournalSubscribers: hasEventSubscribers(topic) + ) + pipeline.setWatching(shouldRun) { [weak self] events in + guard let self else { return } + self.handleJournalEvents(events, sessionID: sessionID) + } + } + updateReevaluationTimer(hasRunningSessions: incrementalState.hasNonEndedSessions || hasPendingAgentGUIExpirations) + } + + private func updateReevaluationTimer(hasRunningSessions: Bool) { + guard hasRunningSessions else { + reevaluationTimer?.cancel() + reevaluationTimer = nil + return + } + guard reevaluationTimer == nil else { return } + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + AgentGUIConstants.gateReevaluationCadence, repeating: AgentGUIConstants.gateReevaluationCadence) + timer.setEventHandler { [weak self] in + Task { @MainActor in + self?.updateGates() + } + } + reevaluationTimer = timer + timer.resume() + } + + private func nextRemovalVersion(sessionID: AgentSessionID) -> UInt64 { + let next = (removalVersions[sessionID] ?? 0) + 1 + removalVersions[sessionID] = next + return next + } + + private func currentActivityHintMS() -> Int { + clock() + } + + private var hasPendingAgentGUIExpirations: Bool { + askRegistry.hasPendingExpirations || sendLedgers.values.contains { $0.hasPendingExpirations } + } + + private func expirePendingAgentGUIState(now: Int) { + for ledger in sendLedgers.values { + ledger.expire(now: now) + } + askRegistry.expire(now: now) + } + + private func handleJournalEvents(_ events: [AgentGUIJournalPipelineEvent], sessionID: AgentSessionID) { + for event in events { + streamProducer?.journalEventArrived( + event, + sessionID: sessionID, + window: pipelines[sessionID]?.window + ) + publisher.publishJournalEvent(event, sessionID: sessionID) + sendLedgers[sessionID]?.handleJournalEvent(event) + askRegistry.handleJournalEvent(event, sessionID: sessionID) + } + } + + private func ledger(sessionID: AgentSessionID) -> AgentGUISendLedger { + if let ledger = sendLedgers[sessionID] { + return ledger + } + let ledger = AgentGUISendLedger( + sessionID: sessionID, + clock: clock, + injector: terminalInjector, + publish: { [publisher] ticket in + publisher.publishSendState(ticket) + } + ) + if let snapshot = reducer.snapshot(for: sessionID) { + ledger.handleSessionSnapshot(snapshot) + } + sendLedgers[sessionID] = ledger + return ledger + } +} diff --git a/Sources/AgentGUI/AgentGUIStampedEntry.swift b/Sources/AgentGUI/AgentGUIStampedEntry.swift new file mode 100644 index 000000000000..8be9529dfcd7 --- /dev/null +++ b/Sources/AgentGUI/AgentGUIStampedEntry.swift @@ -0,0 +1,7 @@ +import CmuxAgentReplica +import Foundation + +struct AgentGUIStampedEntry: Hashable, Sendable { + let entry: EntrySnapshot + let isReplacement: Bool +} diff --git a/Sources/AgentGUI/AgentGUIStreamProducer.swift b/Sources/AgentGUI/AgentGUIStreamProducer.swift new file mode 100644 index 000000000000..91365c9b8367 --- /dev/null +++ b/Sources/AgentGUI/AgentGUIStreamProducer.swift @@ -0,0 +1,129 @@ +import CmuxAgentReplica +import CmuxAgentWire +import Foundation + +/// Publishes best-effort terminal previews while a subscribed agent turn is active. +@MainActor +final class AgentGUIStreamProducer { + struct Context: Equatable { + let journalID: JournalID + let afterSeq: EntrySeq + } + + private struct ActiveTurn { + let surfaceID: UUID + let agentKind: AgentKind + var settled = false + var lastEmitted: String? + var revision = 0 + } + + private let extractor = AgentGUIProseScreenExtractor() + private let publish: @MainActor (AgentSessionID, GuiStreamTickEvent) -> Void + private let snapshot: @MainActor (UUID) -> [String]? + private let hasSubscribers: @MainActor (AgentSessionID) -> Bool + private let context: @MainActor (AgentSessionID) -> Context? + private let pollInterval: Duration + private let sleep: @Sendable (Duration) async -> Void + private var turns: [AgentSessionID: ActiveTurn] = [:] + private var tasks: [AgentSessionID: Task] = [:] + + init( + publish: @escaping @MainActor (AgentSessionID, GuiStreamTickEvent) -> Void, + snapshot: @escaping @MainActor (UUID) -> [String]?, + hasSubscribers: @escaping @MainActor (AgentSessionID) -> Bool, + context: @escaping @MainActor (AgentSessionID) -> Context?, + pollInterval: Duration = .milliseconds(150), + sleep: @escaping @Sendable (Duration) async -> Void = { try? await Task.sleep(for: $0) } + ) { + self.publish = publish + self.snapshot = snapshot + self.hasSubscribers = hasSubscribers + self.context = context + self.pollInterval = pollInterval + self.sleep = sleep + } + + func turnStarted(sessionID: AgentSessionID, surfaceID: UUID, agentKind: AgentKind) { + turns[sessionID] = ActiveTurn(surfaceID: surfaceID, agentKind: agentKind) + guard tasks[sessionID] == nil else { return } + tasks[sessionID] = Task { [weak self] in + await self?.runLoop(sessionID: sessionID) + } + } + + func authoritativeProseArrived(sessionID: AgentSessionID) { + guard turns[sessionID] != nil else { return } + turns[sessionID]?.settled = true + clearPreview(sessionID: sessionID) + } + + func journalEventArrived( + _ event: AgentGUIJournalPipelineEvent, + sessionID: AgentSessionID, + window: AgentGUIJournalWindow? + ) { + guard event.containsAgentProse(in: window) else { return } + authoritativeProseArrived(sessionID: sessionID) + } + + func turnEnded(sessionID: AgentSessionID) { + tasks.removeValue(forKey: sessionID)?.cancel() + guard turns.removeValue(forKey: sessionID) != nil else { return } + clearPreview(sessionID: sessionID) + } + + func stopAll() { + for sessionID in Array(turns.keys) { turnEnded(sessionID: sessionID) } + } + + func emitPreviewIfChanged(sessionID: AgentSessionID) { + guard var turn = turns[sessionID], !turn.settled, + hasSubscribers(sessionID), + let streamContext = context(sessionID), + let lines = snapshot(turn.surfaceID), + let prose = extractor.extract(lines: lines, agentKind: turn.agentKind), + prose != turn.lastEmitted else { return } + turn.revision += 1 + turn.lastEmitted = prose + turns[sessionID] = turn + publish(sessionID, GuiStreamTickEvent( + journalID: streamContext.journalID, + afterSeq: streamContext.afterSeq, + textTail: prose, + revision: turn.revision + )) + } + + private func runLoop(sessionID: AgentSessionID) async { + while !Task.isCancelled { + emitPreviewIfChanged(sessionID: sessionID) + await sleep(pollInterval) + } + } + + private func clearPreview(sessionID: AgentSessionID) { + guard let streamContext = context(sessionID) else { return } + let revision = (turns[sessionID]?.revision ?? 0) + 1 + publish(sessionID, GuiStreamTickEvent( + journalID: streamContext.journalID, + afterSeq: streamContext.afterSeq, + textTail: "", + revision: revision + )) + } +} + +private extension AgentGUIJournalPipelineEvent { + func containsAgentProse(in window: AgentGUIJournalWindow?) -> Bool { + switch self { + case .reset(let journalID, _): + guard window?.journalID == journalID else { return false } + return window?.entriesBySeq.values.contains { $0.kind == .agentProse } == true + case .appended(_, let entries): + return entries.contains { $0.kind == .agentProse } + case .replaced(_, let entry): + return entry.kind == .agentProse + } + } +} diff --git a/Sources/AgentGUI/AgentGUISubscriptionPolicy.swift b/Sources/AgentGUI/AgentGUISubscriptionPolicy.swift new file mode 100644 index 000000000000..c9abe0be5d78 --- /dev/null +++ b/Sources/AgentGUI/AgentGUISubscriptionPolicy.swift @@ -0,0 +1,29 @@ +import CmuxAgentReplica +import Foundation + +/// Centralizes gui.v1 idle gating. +/// +/// Process observation runs while a phone subscribes to `gui.v1.sessions` or +/// any session remains live/recent. Per-journal machinery is demand-driven and +/// runs only while a phone subscribes to that session's journal topic. +@MainActor +struct AgentGUISubscriptionPolicy { + static func isLiveOrRecentlyActive(_ session: AgentSessionSnapshot, nowMS: Int) -> Bool { + switch session.phase { + case .working, .needsInput: + return true + case .idle, .starting, .unknown: + return nowMS - session.lastActivityHint < AgentGUIConstants.liveRecentActivityWindowMS + case .ended: + return false + } + } + + static func shouldRunObservation(hasSessionSubscribers: Bool, hasLiveRecentSession: Bool) -> Bool { + hasSessionSubscribers || hasLiveRecentSession + } + + static func shouldRunJournal(hasJournalSubscribers: Bool) -> Bool { + hasJournalSubscribers + } +} diff --git a/Sources/AgentGUI/AgentGUITerminalInjecting.swift b/Sources/AgentGUI/AgentGUITerminalInjecting.swift new file mode 100644 index 000000000000..6b76895ca541 --- /dev/null +++ b/Sources/AgentGUI/AgentGUITerminalInjecting.swift @@ -0,0 +1,8 @@ +import Foundation + +@MainActor +protocol AgentGUITerminalInjecting: AnyObject { + func submitPrompt(surfaceID: String, text: String) -> AgentGUITerminalInjectionResult + func sendKey(surfaceID: String, keyName: String) -> AgentGUITerminalInjectionResult + func sendInput(surfaceID: String, text: String) -> AgentGUITerminalInjectionResult +} diff --git a/Sources/AgentGUI/AgentGUITerminalInjectionResult.swift b/Sources/AgentGUI/AgentGUITerminalInjectionResult.swift new file mode 100644 index 000000000000..4002bd56eb29 --- /dev/null +++ b/Sources/AgentGUI/AgentGUITerminalInjectionResult.swift @@ -0,0 +1,30 @@ +import Foundation + +enum AgentGUITerminalInjectionResult: Hashable, Sendable { + case accepted + case bindingLost + case inputQueueFull + case processExited + + var accepted: Bool { + switch self { + case .accepted: + true + case .bindingLost, .inputQueueFull, .processExited: + false + } + } + + var failureCode: String { + switch self { + case .accepted: + "accepted" + case .bindingLost: + "binding_lost" + case .inputQueueFull: + "input_queue_full" + case .processExited: + "process_exited" + } + } +} diff --git a/Sources/AgentGUI/AgentGUITerminalInjector.swift b/Sources/AgentGUI/AgentGUITerminalInjector.swift new file mode 100644 index 000000000000..05bce207985c --- /dev/null +++ b/Sources/AgentGUI/AgentGUITerminalInjector.swift @@ -0,0 +1,82 @@ +import CmuxTerminal +import Foundation + +@MainActor +final class AgentGUITerminalInjector: AgentGUITerminalInjecting { + func submitPrompt(surfaceID: String, text: String) -> AgentGUITerminalInjectionResult { + guard let resolved = terminalPanel(surfaceID: surfaceID) else { + return .bindingLost + } + let terminalPanel = resolved.panel + for keyName in ["ctrl+a", "ctrl+k", "ctrl+u"] { + let result = terminalPanel.sendNamedKeyResult(keyName) + guard result.accepted else { return injectionResult(result) } + } + guard terminalPanel.sendText(text) else { + return .bindingLost + } + let isClaudeMultiline = (text.contains("\n") || text.contains("\r")) && TextBoxAgentDetection.isClaudeCode( + context: WorkspaceContentView.terminalAgentContext(panel: terminalPanel, workspace: resolved.workspace) + ) + let submitResult = terminalPanel.sendNamedKeyResult(isClaudeMultiline ? "ctrl+enter" : "return") + guard submitResult.accepted else { return injectionResult(submitResult) } + terminalPanel.surface.forceRefresh(reason: "agentGUI.submitPrompt") + return .accepted + } + + func sendKey(surfaceID: String, keyName: String) -> AgentGUITerminalInjectionResult { + guard let terminalPanel = terminalPanel(surfaceID: surfaceID)?.panel else { + return .bindingLost + } + let result = terminalPanel.sendNamedKeyResult(keyName) + guard result.accepted else { return injectionResult(result) } + terminalPanel.surface.forceRefresh(reason: "agentGUI.sendKey") + return .accepted + } + + func sendInput(surfaceID: String, text: String) -> AgentGUITerminalInjectionResult { + guard let terminalPanel = terminalPanel(surfaceID: surfaceID)?.panel else { + return .bindingLost + } + let result = terminalPanel.sendInputResult(text) + guard result.accepted else { return injectionResult(result) } + terminalPanel.surface.forceRefresh(reason: "agentGUI.sendInput") + return .accepted + } + + private func terminalPanel(surfaceID: String) -> (panel: TerminalPanel, workspace: Workspace)? { + guard let surfaceUUID = UUID(uuidString: surfaceID), + let located = AppDelegate.shared?.locateSurface(surfaceId: surfaceUUID), + let workspace = located.tabManager.tabs.first(where: { $0.id == located.workspaceId }) else { + return nil + } + guard let panel = workspace.terminalPanel(for: surfaceUUID) else { return nil } + return (panel, workspace) + } + + private func injectionResult(_ result: TerminalSurface.NamedKeySendResult) -> AgentGUITerminalInjectionResult { + switch result { + case .sent, .queued: + .accepted + case .inputQueueFull: + .inputQueueFull + case .processExited: + .processExited + case .unknownKey, .surfaceUnavailable: + .bindingLost + } + } + + private func injectionResult(_ result: TerminalSurface.InputSendResult) -> AgentGUITerminalInjectionResult { + switch result { + case .sent, .queued: + .accepted + case .inputQueueFull: + .inputQueueFull + case .processExited: + .processExited + case .surfaceUnavailable: + .bindingLost + } + } +} diff --git a/Sources/AgentGUI/AgentGUIToolPairingIndex.swift b/Sources/AgentGUI/AgentGUIToolPairingIndex.swift new file mode 100644 index 000000000000..5f7dad4dd35d --- /dev/null +++ b/Sources/AgentGUI/AgentGUIToolPairingIndex.swift @@ -0,0 +1,59 @@ +import CmuxAgentReplica +import Foundation + +struct AgentGUIToolPairingIndex { + private var runningSeqsBySignature: [String: [EntrySeq]] = [:] + + mutating func normalize(_ entry: EntrySnapshot) -> EntrySnapshot { + guard let pairable = pairableState(for: entry.content.payload) else { return entry } + if pairable.isRunning { + runningSeqsBySignature[pairable.signature, default: []].append(entry.seq) + return entry + } + guard var seqs = runningSeqsBySignature[pairable.signature], !seqs.isEmpty else { + return entry + } + let originalSeq = seqs.removeFirst() + runningSeqsBySignature[pairable.signature] = seqs.isEmpty ? nil : seqs + return EntrySnapshot( + journalID: entry.journalID, + seq: originalSeq, + kind: entry.kind, + content: entry.content, + version: entry.version, + timestampMilliseconds: entry.timestampMilliseconds + ) + } + + mutating func reset() { + runningSeqsBySignature.removeAll() + } + + private func signature(for payload: ToolRunPayload) -> String { + if let toolCallID = payload.toolCallID, !toolCallID.isEmpty { + return "id\u{1f}\(toolCallID)" + } + return [ + payload.toolName, + payload.argumentSummary, + payload.isTerminal ? "terminal" : "tool" + ].joined(separator: "\u{1f}") + } + + private func pairableState(for payload: EntryPayload) -> (signature: String, isRunning: Bool)? { + switch payload { + case .toolRun(let tool): + return (signature(for: tool), tool.isRunning) + case .fileChange(let file): + let signature: String + if let toolCallID = file.toolCallID, !toolCallID.isEmpty { + signature = "id\u{1f}\(toolCallID)" + } else { + signature = ["file", file.changeKind.rawValue, file.path].joined(separator: "\u{1f}") + } + return (signature, file.resultSummary == nil) + default: + return nil + } + } +} diff --git a/Sources/AgentGUI/AgentGUITranscriptDecoderBox.swift b/Sources/AgentGUI/AgentGUITranscriptDecoderBox.swift new file mode 100644 index 000000000000..812745dd2cf4 --- /dev/null +++ b/Sources/AgentGUI/AgentGUITranscriptDecoderBox.swift @@ -0,0 +1,79 @@ +import CmuxAgentReplica +import CmuxAgentTruthKit +import Foundation + +struct AgentGUITranscriptDecoderBox: Sendable { + private var claude: ClaudeTranscriptDecoder? + private var codex: CodexTranscriptDecoder? + + init(kind: AgentKind) { + switch kind { + case .claude: + self.claude = ClaudeTranscriptDecoder() + self.codex = nil + case .codex: + self.claude = nil + self.codex = CodexTranscriptDecoder() + case .unknown: + self.claude = ClaudeTranscriptDecoder() + self.codex = nil + } + } + + mutating func feed(_ lines: [String], startingAt: Int, journalID: JournalID) -> TranscriptDecodeBatch { + if var decoder = claude { + let batch = decoder.feed(lines, startingAt: startingAt, journalID: journalID) + claude = decoder + return batch + } + if var decoder = codex { + let batch = decoder.feed(lines, startingAt: startingAt, journalID: journalID) + codex = decoder + return batch + } + var decoder = ClaudeTranscriptDecoder() + let batch = decoder.feed(lines, startingAt: startingAt, journalID: journalID) + claude = decoder + return batch + } +} + +struct AgentGUITranscriptDecodedSourceLine: Sendable { + let sourceLine: AgentGUIJournalSourceLine + let batch: TranscriptDecodeBatch + let toolCallIDs: Set +} + +/// Owns stateful transcript decoders away from the main actor. Large image +/// records therefore cannot block scrolling while JSON and base64 metadata are +/// parsed. +actor AgentGUITranscriptDecodeWorker { + private let kind: AgentKind + private var decoder: AgentGUITranscriptDecoderBox + + init(kind: AgentKind) { + self.kind = kind + self.decoder = AgentGUITranscriptDecoderBox(kind: kind) + } + + func reset() { + decoder = AgentGUITranscriptDecoderBox(kind: kind) + } + + func feed( + _ sourceLines: [AgentGUIJournalSourceLine], + journalID: JournalID + ) -> [AgentGUITranscriptDecodedSourceLine] { + sourceLines.map { sourceLine in + AgentGUITranscriptDecodedSourceLine( + sourceLine: sourceLine, + batch: decoder.feed( + [sourceLine.text], + startingAt: sourceLine.startOffset, + journalID: journalID + ), + toolCallIDs: AgentGUIJournalToolCorrelation.callIDs(in: sourceLine.text) + ) + } + } +} diff --git a/Sources/AgentGUI/AgentGUITranscriptImageStore.swift b/Sources/AgentGUI/AgentGUITranscriptImageStore.swift new file mode 100644 index 000000000000..a0b3d6bb292d --- /dev/null +++ b/Sources/AgentGUI/AgentGUITranscriptImageStore.swift @@ -0,0 +1,329 @@ +import CmuxAgentReplica +import CmuxAgentTruthKit +import CryptoKit +import Foundation +import ImageIO +import UniformTypeIdentifiers + +/// Validates and materializes embedded transcript images outside the main actor. +actor AgentGUITranscriptImageStore { + static let shared = AgentGUITranscriptImageStore() + + struct Limits: Sendable { + let maximumEncodedCharacterCount: Int + let maximumDecodedByteCount: Int + let maximumPixelCount: Int + let maximumPixelDimension: Int + let maximumCacheByteCount: Int + let maximumCacheFileCount: Int + let maximumCacheAge: TimeInterval + + static let `default` = Limits( + maximumEncodedCharacterCount: ((8 * 1_024 * 1_024 + 2) / 3) * 4, + maximumDecodedByteCount: 8 * 1_024 * 1_024, + maximumPixelCount: 100_000_000, + maximumPixelDimension: 32_768, + maximumCacheByteCount: 256 * 1_024 * 1_024, + maximumCacheFileCount: 2_048, + maximumCacheAge: 30 * 24 * 60 * 60 + ) + } + + struct MaterializedImage: Sendable { + let path: String + let mimeType: String + let byteCount: Int + let width: Int + let height: Int + } + + struct ReferenceKey: Hashable, Sendable { + let entrySeq: EntrySeq + let attachmentIndex: Int? + + init(entrySeq: EntrySeq, attachmentIndex: Int? = nil) { + self.entrySeq = entrySeq + self.attachmentIndex = attachmentIndex + } + } + + struct ReferencedImage: Sendable { + let key: ReferenceKey + let path: String + let mimeType: String? + + init( + entrySeq: EntrySeq, + attachmentIndex: Int? = nil, + path: String, + mimeType: String? + ) { + self.key = ReferenceKey(entrySeq: entrySeq, attachmentIndex: attachmentIndex) + self.path = path + self.mimeType = mimeType + } + } + + private struct CachedFile { + let url: URL + let byteCount: Int + let modifiedAt: Date + } + + private let rootURL: URL + private let limits: Limits + + init( + rootURL: URL? = nil, + limits: Limits = .default + ) { + self.rootURL = rootURL ?? Self.defaultRootURL + self.limits = limits + } + + func materialize( + _ images: [TranscriptEmbeddedImage] + ) -> [EntrySeq: MaterializedImage] { + guard !images.isEmpty, prepareRootDirectory() else { return [:] } + var materializedBySequence: [EntrySeq: MaterializedImage] = [:] + for image in images where materializedBySequence[image.entrySeq] == nil { + guard let materialized = materialize(image) else { continue } + materializedBySequence[image.entrySeq] = materialized + } + pruneCache(protecting: Set(materializedBySequence.values.map(\.path))) + return materializedBySequence + } + + /// Reads local-image metadata without copying the original file. This lets + /// path-backed Codex images reserve their real aspect ratio immediately. + func inspect( + _ images: [ReferencedImage] + ) -> [ReferenceKey: MaterializedImage] { + var inspectedByKey: [ReferenceKey: MaterializedImage] = [:] + for image in images where inspectedByKey[image.key] == nil { + guard image.path.hasPrefix("/"), + image.path.utf8.count <= 4_096 else { continue } + let url = URL(fileURLWithPath: image.path) + .resolvingSymlinksInPath() + .standardizedFileURL + guard let inspected = inspectImage(url: url, declaredMIMEType: image.mimeType) else { continue } + inspectedByKey[image.key] = inspected + } + return inspectedByKey + } + + private func inspectImage( + url: URL, + declaredMIMEType: String? + ) -> MaterializedImage? { + let typeFromExtension = UTType(filenameExtension: url.pathExtension) + if let source = CGImageSourceCreateWithURL(url as CFURL, nil), + CGImageSourceGetStatus(source) == .statusComplete, + let typeIdentifier = CGImageSourceGetType(source) as String?, + let type = UTType(typeIdentifier), + type.conforms(to: .image), + self.declaredMIMEType(declaredMIMEType, matches: type), + let mimeType = type.preferredMIMEType, + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) + as? [CFString: Any], + let width = integer(properties[kCGImagePropertyPixelWidth]), + let height = integer(properties[kCGImagePropertyPixelHeight]), + let materialized = materializedImage( + url: url, + mimeType: mimeType, + width: width, + height: height + ) { + return materialized + } + + guard let typeFromExtension, + typeFromExtension.conforms(to: .image), + self.declaredMIMEType(declaredMIMEType, matches: typeFromExtension), + let mimeType = typeFromExtension.preferredMIMEType else { + return nil + } + let metadata = TranscriptImageMetadataProbe.metadata( + hostPath: url.path, + base64EncodedData: nil + ) + guard let width = metadata.width, + let height = metadata.height else { + return nil + } + return materializedImage( + url: url, + mimeType: mimeType, + width: width, + height: height + ) + } + + private func materializedImage( + url: URL, + mimeType: String, + width: Int, + height: Int + ) -> MaterializedImage? { + guard width > 0, + height > 0, + width <= limits.maximumPixelDimension, + height <= limits.maximumPixelDimension, + width <= limits.maximumPixelCount / height, + let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), + let byteCount = (attributes[.size] as? NSNumber)?.intValue, + byteCount >= 0 else { + return nil + } + return MaterializedImage( + path: url.path, + mimeType: mimeType, + byteCount: byteCount, + width: width, + height: height + ) + } + + private func materialize(_ image: TranscriptEmbeddedImage) -> MaterializedImage? { + let encodedData = image.base64EncodedData + guard !encodedData.isEmpty, + encodedData.utf8.count <= limits.maximumEncodedCharacterCount, + estimatedDecodedByteCount(encodedData) <= limits.maximumDecodedByteCount, + let data = Data(base64Encoded: encodedData), + !data.isEmpty, + data.count <= limits.maximumDecodedByteCount, + let source = CGImageSourceCreateWithData(data as CFData, nil), + CGImageSourceGetStatus(source) == .statusComplete, + let typeIdentifier = CGImageSourceGetType(source) as String?, + let type = UTType(typeIdentifier), + type.conforms(to: .image), + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) + as? [CFString: Any], + let width = integer(properties[kCGImagePropertyPixelWidth]), + let height = integer(properties[kCGImagePropertyPixelHeight]), + width > 0, + height > 0, + width <= limits.maximumPixelDimension, + height <= limits.maximumPixelDimension, + width <= limits.maximumPixelCount / height, + declaredMIMEType(image.mimeType, matches: type), + let mimeType = type.preferredMIMEType, + let filenameExtension = type.preferredFilenameExtension else { return nil } + + let digest = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + let destination = rootURL.appendingPathComponent( + "\(digest).\(filenameExtension.lowercased())", + isDirectory: false + ) + if !FileManager.default.fileExists(atPath: destination.path) { + do { + try data.write(to: destination, options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o600))], + ofItemAtPath: destination.path + ) + } catch { + try? FileManager.default.removeItem(at: destination) + return nil + } + } else { + try? FileManager.default.setAttributes( + [.modificationDate: Date()], + ofItemAtPath: destination.path + ) + } + return MaterializedImage( + path: destination.path, + mimeType: mimeType, + byteCount: data.count, + width: width, + height: height + ) + } + + private func prepareRootDirectory() -> Bool { + do { + try FileManager.default.createDirectory( + at: rootURL, + withIntermediateDirectories: true, + attributes: [.posixPermissions: NSNumber(value: Int16(0o700))] + ) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o700))], + ofItemAtPath: rootURL.path + ) + return true + } catch { + return false + } + } + + private func pruneCache(protecting protectedPaths: Set) { + let keys: Set = [ + .isRegularFileKey, + .fileSizeKey, + .contentModificationDateKey, + ] + guard let urls = try? FileManager.default.contentsOfDirectory( + at: rootURL, + includingPropertiesForKeys: Array(keys), + options: [.skipsHiddenFiles] + ) else { return } + let now = Date() + var files: [CachedFile] = [] + for url in urls { + guard let values = try? url.resourceValues(forKeys: keys), + values.isRegularFile == true else { continue } + let file = CachedFile( + url: url, + byteCount: max(0, values.fileSize ?? 0), + modifiedAt: values.contentModificationDate ?? .distantPast + ) + if !protectedPaths.contains(url.path), + now.timeIntervalSince(file.modifiedAt) > limits.maximumCacheAge { + try? FileManager.default.removeItem(at: url) + } else { + files.append(file) + } + } + var totalBytes = files.reduce(0) { $0 + $1.byteCount } + var fileCount = files.count + for file in files.sorted(by: { $0.modifiedAt < $1.modifiedAt }) { + guard totalBytes > limits.maximumCacheByteCount + || fileCount > limits.maximumCacheFileCount else { break } + guard !protectedPaths.contains(file.url.path) else { continue } + guard (try? FileManager.default.removeItem(at: file.url)) != nil else { continue } + totalBytes -= file.byteCount + fileCount -= 1 + } + } + + private func estimatedDecodedByteCount(_ encoded: String) -> Int { + let count = encoded.utf8.count + guard count > 0 else { return 0 } + let padding = encoded.suffix(2).filter { $0 == "=" }.count + return max(0, ((count + 3) / 4) * 3 - padding) + } + + private func integer(_ value: Any?) -> Int? { + if let number = value as? NSNumber { return number.intValue } + return value as? Int + } + + private func declaredMIMEType(_ declared: String?, matches actualType: UTType) -> Bool { + guard let declared = declared?.lowercased(), !declared.isEmpty else { return true } + guard declared.hasPrefix("image/"), let actual = actualType.preferredMIMEType?.lowercased() else { + return false + } + if declared == actual { return true } + return Set([declared, actual]) == Set(["image/jpg", "image/jpeg"]) + } + + private static var defaultRootURL: URL { + let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + ?? FileManager.default.temporaryDirectory + return base + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("AgentTranscriptImages", isDirectory: true) + } +} diff --git a/Sources/AgentGUI/AgentGUITranscriptResolver.swift b/Sources/AgentGUI/AgentGUITranscriptResolver.swift new file mode 100644 index 000000000000..5d64d31ba592 --- /dev/null +++ b/Sources/AgentGUI/AgentGUITranscriptResolver.swift @@ -0,0 +1,29 @@ +import CmuxAgentReplica +import CmuxAgentTruthKit +import Foundation + +struct AgentGUITranscriptResolver { + func transcriptPath(sessionID: AgentSessionID, kind: AgentKind, cwd: String?, evidencePath: String?) -> String? { + if let evidencePath = evidencePath?.nilIfBlank { + return evidencePath + } + guard case .claude = kind, let cwd = cwd?.nilIfBlank else { + return nil + } + let project = cwd.replacingOccurrences(of: "/", with: "-") + .replacingOccurrences(of: ".", with: "-") + return NSHomeDirectory() + .appending("/.claude/projects/") + .appending(project) + .appending("/") + .appending(sessionID.rawValue) + .appending(".jsonl") + } +} + +private extension String { + var nilIfBlank: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/Sources/AgentGUI/AgentGUIWirePublisher.swift b/Sources/AgentGUI/AgentGUIWirePublisher.swift new file mode 100644 index 000000000000..2314d1f31c0d --- /dev/null +++ b/Sources/AgentGUI/AgentGUIWirePublisher.swift @@ -0,0 +1,106 @@ +import CmuxAgentReplica +import CmuxAgentWire +import Foundation + +@MainActor +final class AgentGUIWirePublisher { + private let epoch: ReplicaEpoch + + init(epoch: ReplicaEpoch) { + self.epoch = epoch + } + + func publishSessionUpserted(_ session: AgentSessionSnapshot) { + publish( + topic: GuiWireTopic.sessions, + frame: GuiEventFrame( + epoch: epoch, + sessionID: session.id, + payload: .sessionUpserted(GuiSessionUpsertedEvent(session: session)) + ) + ) + } + + func publishSessionRemoved(_ sessionID: AgentSessionID, version: EntityVersion) { + publish( + topic: GuiWireTopic.sessions, + frame: GuiEventFrame( + epoch: epoch, + sessionID: sessionID, + payload: .sessionRemoved(GuiSessionRemovedEvent(sessionID: sessionID, version: version)) + ) + ) + } + + func publishJournalEvent(_ event: AgentGUIJournalPipelineEvent, sessionID: AgentSessionID) { + let topic = GuiWireTopic.journal(sessionID: sessionID) + switch event { + case .reset(let journalID, let tailSeq): + publish( + topic: topic, + frame: GuiEventFrame( + epoch: epoch, + sessionID: sessionID, + payload: .journalReset(GuiJournalResetEvent(sessionID: sessionID, newJournalID: journalID, tailSeq: tailSeq)) + ) + ) + case .appended(let journalID, let entries): + publish( + topic: topic, + frame: GuiEventFrame( + epoch: epoch, + sessionID: sessionID, + payload: .entriesAppended(GuiEntriesAppendedEvent(journalID: journalID, entries: entries)) + ) + ) + case .replaced(let journalID, let entry): + publish( + topic: topic, + frame: GuiEventFrame( + epoch: epoch, + sessionID: sessionID, + payload: .entryReplaced(GuiEntryReplacedEvent(journalID: journalID, entry: entry)) + ) + ) + } + } + + func publishSendState(_ ticket: SendTicket) { + publish( + topic: GuiWireTopic.journal(sessionID: ticket.sessionID), + frame: GuiEventFrame( + epoch: epoch, + sessionID: ticket.sessionID, + payload: .sendState(GuiSendStateEvent(ticket: ticket)) + ) + ) + } + + func publishAskState(_ ask: PendingAsk) { + publish( + topic: GuiWireTopic.journal(sessionID: ask.sessionID), + frame: GuiEventFrame( + epoch: epoch, + sessionID: ask.sessionID, + payload: .askState(GuiAskStateEvent(ask: ask)) + ) + ) + } + + func publishStreamTick(_ event: GuiStreamTickEvent, sessionID: AgentSessionID) { + publish( + topic: GuiWireTopic.journal(sessionID: sessionID), + frame: GuiEventFrame(epoch: epoch, sessionID: sessionID, payload: .streamTick(event)) + ) + } + + private func publish(topic: String, frame: GuiEventFrame) { + guard MobileHostService.hasEventSubscribers(topic: topic) else { + return + } + guard let payload = try? AgentGUICodableBridge.dictionary(frame) else { + return + } + MobileHostService.emitEvent(topic: topic, payload: payload) + } +} diff --git a/Sources/AgentGUI/AgentProcessExitWatcher.swift b/Sources/AgentGUI/AgentProcessExitWatcher.swift new file mode 100644 index 000000000000..d3e48ea008ee --- /dev/null +++ b/Sources/AgentGUI/AgentProcessExitWatcher.swift @@ -0,0 +1,43 @@ +import Darwin +import Foundation + +@MainActor +final class AgentProcessExitWatcher { + private var sourcesByPID: [Int32: DispatchSourceProcess] = [:] + private let onExit: @MainActor (Int32, Int) -> Void + + init(onExit: @escaping @MainActor (Int32, Int) -> Void) { + self.onExit = onExit + } + + func watch(pid: Int32, startTick: Int) { + guard sourcesByPID[pid] == nil else { return } + if kill(pid, 0) == -1, errno == ESRCH { + onExit(pid, startTick) + return + } + let source = DispatchSource.makeProcessSource(identifier: pid_t(pid), eventMask: .exit, queue: .main) + source.setEventHandler { [weak self] in + Task { @MainActor in + guard let self else { return } + guard let source = self.sourcesByPID.removeValue(forKey: pid) else { return } + source.cancel() + self.onExit(pid, startTick) + } + } + sourcesByPID[pid] = source + source.resume() + if kill(pid, 0) == -1, errno == ESRCH, + let resumedSource = sourcesByPID.removeValue(forKey: pid) { + resumedSource.cancel() + onExit(pid, startTick) + } + } + + func stopAll() { + for source in sourcesByPID.values { + source.cancel() + } + sourcesByPID.removeAll() + } +} diff --git a/Sources/AgentGUI/AgentProcessObservationSource.swift b/Sources/AgentGUI/AgentProcessObservationSource.swift new file mode 100644 index 000000000000..4a941aa832df --- /dev/null +++ b/Sources/AgentGUI/AgentProcessObservationSource.swift @@ -0,0 +1,150 @@ +import CmuxAgentReplica +import CmuxAgentTruthKit +import Foundation + +@MainActor +final class AgentProcessObservationSource { + private var timer: DispatchSourceTimer? + private var scanTask: Task? + private var pendingScan = false + private var scanGeneration: UInt64 = 0 + private let utilityQueue = DispatchQueue(label: "dev.cmux.agentgui.process-observation", qos: .utility) + private let captureObservations: @Sendable () async -> [ProcessObservation] + private let onObservations: @MainActor ([ProcessObservation]) -> Void + + init( + captureObservations: @escaping @Sendable () async -> [ProcessObservation] = { + await AgentProcessObservationSource.captureObservations() + }, + onObservations: @escaping @MainActor ([ProcessObservation]) -> Void + ) { + self.captureObservations = captureObservations + self.onObservations = onObservations + } + + func setRunning(_ shouldRun: Bool) { + if shouldRun { + start() + } else { + stop() + } + } + + func scanNow() { + guard scanTask == nil else { + pendingScan = true + return + } + scanGeneration &+= 1 + let generation = scanGeneration + let captureObservations = captureObservations + scanTask = Task { @MainActor [weak self] in + let observations = await Task.detached(priority: .utility) { + await captureObservations() + }.value + guard let self, self.scanGeneration == generation, !Task.isCancelled else { return } + self.scanTask = nil + self.onObservations(observations) + guard self.scanGeneration == generation, self.pendingScan else { return } + self.pendingScan = false + self.scanNow() + } + } + + private func start() { + guard timer == nil else { return } + let timer = DispatchSource.makeTimerSource(queue: utilityQueue) + timer.schedule(deadline: .now(), repeating: AgentGUIConstants.observationCadence) + timer.setEventHandler { [weak self] in + Task { @MainActor in + guard let self else { return } + self.scanNow() + } + } + self.timer = timer + timer.resume() + } + + private func stop() { + timer?.cancel() + timer = nil + pendingScan = false + scanGeneration &+= 1 + scanTask?.cancel() + scanTask = nil + } + + #if DEBUG + func waitForIdleForTesting() async { + while let scanTask { + await scanTask.value + } + } + #endif + + private nonisolated static func captureObservations() async -> [ProcessObservation] { + let snapshot = CmuxTopProcessSnapshot.captureCached( + includeProcessDetails: true, + includeCMUXScope: true, + maximumAge: 1 + ) + return snapshot.cmuxScopedProcesses().compactMap { process in + observation(for: process) + } + } + + private nonisolated static func observation(for process: CmuxTopProcessInfo) -> ProcessObservation? { + guard let details = CmuxTopProcessSnapshot.processArgumentsAndEnvironment(for: process.pid) else { + return nil + } + let kind = agentKind(arguments: details.arguments, environment: details.environment, processName: process.name) + guard kind.rawValue == "claude" || kind.rawValue == "codex" else { + return nil + } + let identity = AgentPIDProcessIdentity(pid: pid_t(process.pid)) + let startTick = identity.map { Int($0.startSeconds &* 1_000_000 &+ $0.startMicroseconds) } ?? process.pid + let cwd = normalized(details.environment["CMUX_AGENT_LAUNCH_CWD"] ?? details.environment["PWD"]) ?? "" + let surfaceID = process.cmuxSurfaceID?.uuidString + ?? normalized(details.environment["CMUX_SURFACE_ID"]) + ?? normalized(details.environment["CMUX_PANEL_ID"]) + let argvSummary = details.arguments.prefix(3).joined(separator: " ") + return ProcessObservation( + pid: Int32(process.pid), + ppid: Int32(process.parentPID), + startTick: startTick, + argvSummary: argvSummary.isEmpty ? process.name : argvSummary, + agentKindGuess: kind, + cwd: cwd, + surfaceID: surfaceID, + openTranscriptPath: AgentGUIOpenTranscriptPathScanner.transcriptPath(pid: Int32(process.pid), kind: kind.rawValue) + ) + } + + nonisolated static func agentKind(arguments: [String], environment: [String: String], processName _: String) -> AgentKind { + if let launchKind = normalized(environment["CMUX_AGENT_LAUNCH_KIND"]) { + return AgentKind(rawValue: launchKind) + } + guard let executable = arguments.first else { + return .unknown("unknown") + } + let executableName = URL(fileURLWithPath: executable).lastPathComponent.lowercased() + var candidateNames = [executableName] + let runtimeLaunchers = Set(["node", "bun", "deno", "python", "python3", "sh", "bash", "zsh"]) + if runtimeLaunchers.contains(executableName), + let launchedPath = arguments.dropFirst().first(where: { !$0.hasPrefix("-") }) { + candidateNames.append(URL(fileURLWithPath: launchedPath).lastPathComponent.lowercased()) + } + if candidateNames.contains("claude") { + return .claude + } + if candidateNames.contains("codex") { + return .codex + } + return .unknown("unknown") + } + + private nonisolated static func normalized(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 2161150edeaa..d67f0f5305f8 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -551,6 +551,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let workspaceTerminalFontSizeArbiter = WorkspaceTerminalFontSizeArbiter() private let systemAppearanceObserver = SystemAppearanceObserver() + /// Owns the Mac-side gui.v1 truth/read path for paired phones. + private let agentGUIService = AgentGUIService() private static let reloadConfigurationMenuItemIdentifier = NSUserInterfaceItemIdentifier("com.cmux.reloadConfiguration") private static let cachedIsRunningUnderXCTest = MacSentryStartupPolicy.isRunningUnderXCTest( environment: ProcessInfo.processInfo.environment @@ -802,7 +804,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif } }() - private var splitButtonTooltipRefreshScheduled = false private var didScheduleGhosttyCrashBreadcrumbCheck = false private var ghosttyCrashBreadcrumbTask: Task? @@ -2212,6 +2213,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent AIAccountsClient.bootstrap(auth: auth.coordinator) PhonePushClient.shared.configure(auth: auth.coordinator) MobileHostService.shared.configure(auth: auth.coordinator) + agentGUIService.start() DeviceRegistryClient.shared.configure(auth: auth.coordinator) PresenceHeartbeatClient.shared.configure(auth: auth.coordinator) connectivityInvalidationSubscriberCoordinator.configure(auth: auth.coordinator) diff --git a/Sources/Mobile/AgentChat/AgentChatArtifactIndex.swift b/Sources/Mobile/AgentChat/AgentChatArtifactIndex.swift index 23d0d963288f..d3c4543430b7 100644 --- a/Sources/Mobile/AgentChat/AgentChatArtifactIndex.swift +++ b/Sources/Mobile/AgentChat/AgentChatArtifactIndex.swift @@ -3,6 +3,8 @@ import Foundation /// Builds and caches the transcript-derived artifact scope for chat sessions. actor AgentChatArtifactIndex { + static let shared = AgentChatArtifactIndex() + struct Snapshot: Sendable { let referencedPaths: Set let artifacts: [ChatArtifactIndexedReference] @@ -20,6 +22,11 @@ actor AgentChatArtifactIndex { case notInSet } + struct SupplementalAttachment: Sendable, Equatable { + let path: String + let sourceSeq: Int + } + private struct CacheKey: Sendable, Equatable { let transcriptPath: String let workingDirectory: String? @@ -36,7 +43,98 @@ actor AgentChatArtifactIndex { let snapshot: Snapshot } + private struct SupplementalRegistration: Sendable { + let transcriptPath: String + var attachmentsByPath: [String: RegisteredSupplementalAttachment] + var revision: UInt64 + } + + private struct RegisteredSupplementalAttachment: Sendable { + let attachment: SupplementalAttachment + let accessOrdinal: UInt64 + } + private var cacheBySessionID = ChatArtifactLRUCache(capacity: 8) + private var supplementalBySessionID: [String: SupplementalRegistration] = [:] + private var supplementalSessionOrder: [String] = [] + private var nextSupplementalAccessOrdinal: UInt64 = 0 + private static let supplementalSessionCapacity = 64 + private static let supplementalPathCapacity = 2_048 + + func registerAttachments( + sessionID: String, + transcriptPath: String, + attachments: [SupplementalAttachment] + ) { + guard !sessionID.isEmpty, + !transcriptPath.isEmpty, + !attachments.isEmpty else { return } + let normalizedTranscriptPath = URL(fileURLWithPath: transcriptPath) + .standardizedFileURL.path + var next = supplementalBySessionID[sessionID] + if next?.transcriptPath != normalizedTranscriptPath { + next = SupplementalRegistration( + transcriptPath: normalizedTranscriptPath, + attachmentsByPath: [:], + revision: 0 + ) + } + guard var registration = next else { return } + let resolver = ChatArtifactScope.FoundationResolver() + var changed = false + for attachment in attachments.sorted(by: { $0.sourceSeq > $1.sourceSeq }) { + guard attachment.path.utf8.count <= 4_096, + attachment.path.hasPrefix("/"), + let canonicalPath = ChatArtifactScope.canonicalizedPath( + attachment.path, + resolver: resolver + ), + resolver.isDirectory(canonicalPath) == false else { continue } + let candidate = SupplementalAttachment( + path: canonicalPath, + sourceSeq: max( + registration.attachmentsByPath[canonicalPath]?.attachment.sourceSeq ?? Int.min, + attachment.sourceSeq + ) + ) + let previous = registration.attachmentsByPath[canonicalPath] + nextSupplementalAccessOrdinal &+= 1 + registration.attachmentsByPath[canonicalPath] = RegisteredSupplementalAttachment( + attachment: candidate, + accessOrdinal: nextSupplementalAccessOrdinal + ) + if previous?.attachment != candidate { + changed = true + } + } + if registration.attachmentsByPath.count > Self.supplementalPathCapacity { + let overflow = registration.attachmentsByPath.count - Self.supplementalPathCapacity + let evictedPaths = registration.attachmentsByPath.values + .sorted { $0.accessOrdinal < $1.accessOrdinal } + .prefix(overflow) + .map(\.attachment.path) + for path in evictedPaths { + registration.attachmentsByPath[path] = nil + } + changed = true + } + guard !registration.attachmentsByPath.isEmpty else { return } + guard changed else { + supplementalBySessionID[sessionID] = registration + touchSupplementalSession(sessionID) + evictSupplementalSessionsIfNeeded() + return + } + registration.revision &+= 1 + supplementalBySessionID[sessionID] = registration + touchSupplementalSession(sessionID) + evictSupplementalSessionsIfNeeded() + } + + func removeSupplementalAttachments(sessionID: String) { + supplementalBySessionID[sessionID] = nil + supplementalSessionOrder.removeAll { $0 == sessionID } + } func snapshot( sessionID: String, @@ -46,7 +144,11 @@ actor AgentChatArtifactIndex { ) async throws -> Snapshot { let key = try Self.cacheKey(transcriptPath: transcriptPath, workingDirectory: workingDirectory) if let cached = cacheBySessionID.value(forKey: sessionID), cached.key == key { - return cached.snapshot + return snapshotByMergingSupplemental( + cached.snapshot, + sessionID: sessionID, + transcriptPath: transcriptPath + ) } let snapshot = try Self.buildSnapshot( agentKind: agentKind, @@ -55,7 +157,11 @@ actor AgentChatArtifactIndex { generation: key.generation ) cacheBySessionID.insert(CacheEntry(key: key, snapshot: snapshot), forKey: sessionID) - return snapshot + return snapshotByMergingSupplemental( + snapshot, + sessionID: sessionID, + transcriptPath: transcriptPath + ) } func canonicalPath( @@ -67,16 +173,33 @@ actor AgentChatArtifactIndex { operation: Operation, directoryAccessMode: ChatArtifactScope.DirectoryAccessMode ) async throws -> CanonicalPathResult { + let resolver = ChatArtifactScope.FoundationResolver() + guard let canonicalRequestedPath = ChatArtifactScope.canonicalizedPath( + requestedPath, + resolver: resolver + ) else { + return .canonicalizationFailed + } + if case .file = operation, + var registration = supplementalBySessionID[sessionID], + registration.transcriptPath + == URL(fileURLWithPath: transcriptPath).standardizedFileURL.path, + let registered = registration.attachmentsByPath[canonicalRequestedPath] { + nextSupplementalAccessOrdinal &+= 1 + registration.attachmentsByPath[canonicalRequestedPath] = RegisteredSupplementalAttachment( + attachment: registered.attachment, + accessOrdinal: nextSupplementalAccessOrdinal + ) + supplementalBySessionID[sessionID] = registration + touchSupplementalSession(sessionID) + return .success(canonicalRequestedPath) + } let snapshot = try await snapshot( sessionID: sessionID, agentKind: agentKind, transcriptPath: transcriptPath, workingDirectory: workingDirectory ) - let resolver = ChatArtifactScope.FoundationResolver() - guard ChatArtifactScope.canonicalizedPath(requestedPath, resolver: resolver) != nil else { - return .canonicalizationFailed - } let canonicalPath: String? let scope = ChatArtifactScope( referencedPaths: snapshot.referencedPaths, @@ -104,6 +227,47 @@ actor AgentChatArtifactIndex { ) } + private func snapshotByMergingSupplemental( + _ snapshot: Snapshot, + sessionID: String, + transcriptPath: String + ) -> Snapshot { + guard let registration = supplementalBySessionID[sessionID], + registration.transcriptPath == URL(fileURLWithPath: transcriptPath).standardizedFileURL.path, + !registration.attachmentsByPath.isEmpty else { return snapshot } + touchSupplementalSession(sessionID) + var artifactsByPath = Dictionary(uniqueKeysWithValues: snapshot.artifacts.map { ($0.path, $0) }) + for registered in registration.attachmentsByPath.values { + let attachment = registered.attachment + let previous = artifactsByPath[attachment.path] + let provenance: ChatArtifactProvenance = previous?.provenance == .created + ? .created + : .attached + artifactsByPath[attachment.path] = ChatArtifactIndexedReference( + path: attachment.path, + provenance: provenance, + lastReferencedSeq: max(previous?.lastReferencedSeq ?? Int.min, attachment.sourceSeq) + ) + } + return Snapshot( + referencedPaths: snapshot.referencedPaths.union(registration.attachmentsByPath.keys), + artifacts: Array(artifactsByPath.values), + generation: "\(snapshot.generation)-supplemental-\(registration.revision)" + ) + } + + private func touchSupplementalSession(_ sessionID: String) { + supplementalSessionOrder.removeAll { $0 == sessionID } + supplementalSessionOrder.append(sessionID) + } + + private func evictSupplementalSessionsIfNeeded() { + while supplementalSessionOrder.count > Self.supplementalSessionCapacity { + let evicted = supplementalSessionOrder.removeFirst() + supplementalBySessionID[evicted] = nil + } + } + private static func buildSnapshot( agentKind: ChatAgentKind, transcriptPath: String, diff --git a/Sources/Mobile/MobileAttachTarget.swift b/Sources/Mobile/MobileAttachTarget.swift index 072dfcab78ed..16938646204a 100644 --- a/Sources/Mobile/MobileAttachTarget.swift +++ b/Sources/Mobile/MobileAttachTarget.swift @@ -20,12 +20,12 @@ enum MobileAttachTarget: String, Sendable { case .ticketOnly: selected = routes case .simulatorInjection: - let irohRoutes = try Self.identityOnlyIrohRoutes(from: routes) - selected = irohRoutes.isEmpty - ? routes.filter { route in - route.kind == .debugLoopback && CmxLoopbackHost().matches(route) - } - : irohRoutes + let loopbackRoutes = routes.filter { route in + route.kind == .debugLoopback && CmxLoopbackHost().matches(route) + } + selected = loopbackRoutes.isEmpty + ? try Self.identityOnlyIrohRoutes(from: routes) + : loopbackRoutes case .physicalDevice: let irohRoutes = try Self.identityOnlyIrohRoutes(from: routes) guard irohRoutes.isEmpty else { diff --git a/Sources/Panels/AgentSessionPanel.swift b/Sources/Panels/AgentSessionPanel.swift index 637feaeaff63..434e117015c1 100644 --- a/Sources/Panels/AgentSessionPanel.swift +++ b/Sources/Panels/AgentSessionPanel.swift @@ -16,6 +16,20 @@ final class AgentSessionPanel: Panel { private(set) var displayTitle: String var displayIcon: String? { "sparkles.rectangle.stack" } private(set) var isDirty: Bool = false + var isWebViewInWindow: Bool { + rendererSession.isWebViewInWindow + } + func submitFromControl( + providerID: AgentSessionProviderID?, + permissionMode: AgentSessionPermissionMode, + text: String + ) async throws -> AgentSessionControlSubmitResult { + try await rendererSession.submitFromControl( + providerID: providerID, + permissionMode: permissionMode, + text: text + ) + } var onDisplayStateChanged: ((String, Bool) -> Void)? { didSet { onDisplayStateChanged?(displayTitle, isDirty) diff --git a/Sources/Panels/AgentSessionProcessStore.swift b/Sources/Panels/AgentSessionProcessStore.swift index 44b2c042e1e5..a9bded03d20d 100644 --- a/Sources/Panels/AgentSessionProcessStore.swift +++ b/Sources/Panels/AgentSessionProcessStore.swift @@ -1,6 +1,19 @@ import Foundation import Darwin +struct AgentSessionControlSessionInfo { + let sessionId: String + let providerID: AgentSessionProviderID + let executablePath: String + let arguments: [String] + let workingDirectory: String? +} + +struct AgentSessionControlSubmitResult { + let session: AgentSessionControlSessionInfo + let startedProvider: Bool +} + @MainActor final class AgentSessionProcessStore { var eventSink: (([String: Any]) -> Void)? @@ -16,6 +29,10 @@ final class AgentSessionProcessStore { private var lastEmittedHasActiveProviderSession: Bool? private static let terminationEscalationInterval: DispatchTimeInterval = .seconds(3) + var activeSessionInfo: AgentSessionControlSessionInfo? { + sessions.values.first.map(controlSessionInfo) + } + func start(plan: AgentSessionLaunchPlan, workingDirectory: String?) async throws -> AgentSessionStartedSession { guard sessions.isEmpty else { throw AgentSessionBridgeError.sessionAlreadyRunning @@ -141,6 +158,21 @@ final class AgentSessionProcessStore { } } + func writeLineToActive( + permissionMode: AgentSessionPermissionMode = .standard, + text: String + ) async throws -> AgentSessionControlSessionInfo { + guard let session = sessions.values.first else { + throw AgentSessionBridgeError.providerNotReady("Agent") + } + try await writeLine( + sessionId: session.sessionId, + permissionMode: permissionMode, + text: text + ) + return controlSessionInfo(session) + } + func stop(sessionId: String) throws { guard let session = sessions[sessionId] else { throw AgentSessionBridgeError.sessionNotFound(sessionId) @@ -272,6 +304,16 @@ final class AgentSessionProcessStore { session.openCodeEventTask = nil } + private func controlSessionInfo(_ session: AgentSessionRunningSession) -> AgentSessionControlSessionInfo { + AgentSessionControlSessionInfo( + sessionId: session.sessionId, + providerID: session.providerID, + executablePath: session.executablePath, + arguments: session.arguments, + workingDirectory: session.workingDirectory + ) + } + private func handleOutputLine(_ text: String, session: AgentSessionRunningSession, stream: String) { if session.providerID == .opencode { switch Self.openCodeProcessOutputDisposition(text: text, stream: stream) { diff --git a/Sources/Panels/AgentSessionWebRendererCoordinator.swift b/Sources/Panels/AgentSessionWebRendererCoordinator.swift index a8d69ee1dd55..af1f48232341 100644 --- a/Sources/Panels/AgentSessionWebRendererCoordinator.swift +++ b/Sources/Panels/AgentSessionWebRendererCoordinator.swift @@ -5,6 +5,7 @@ import WebKit @MainActor final class AgentSessionWebRendererCoordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandlerWithReply { var webView: AgentSessionWebView? + private let assetSchemeHandler = AgentSessionAssetSchemeHandler() private var panelId = UUID() private var workspaceId = UUID() private var rendererKind: AgentSessionRendererKind = .react @@ -20,9 +21,15 @@ final class AgentSessionWebRendererCoordinator: NSObject, WKNavigationDelegate, private var isPanelFocused = false private var isClosed = false private var isProviderStartPending = false + private var pendingBridgeEvents: [[String: Any]] = [] + private var isFlushingBridgeEvents = false + private var hasScheduledBridgeFlushRetry = false private var processStore = AgentSessionProcessStore() + nonisolated static let agentSessionAssetScheme = "cmux-agent-session" + nonisolated static let agentSessionAssetHost = "bundle" nonisolated private static let imagePreviewMaxBytes = 512 * 1024 nonisolated private static let imagePreviewTotalMaxBytes = 2 * 1024 * 1024 + nonisolated private static let maxPendingBridgeEvents = 512 var onHasActiveProviderChanged: ((Bool) -> Void)? { didSet { onHasActiveProviderChanged?(processStore.hasActiveProviderSession) @@ -30,6 +37,64 @@ final class AgentSessionWebRendererCoordinator: NSObject, WKNavigationDelegate, } var onProviderIDChanged: ((AgentSessionProviderID) -> Void)? + var isWebViewInWindow: Bool { + webView?.window != nil + } + + func submitFromControl( + providerID requestedProviderID: AgentSessionProviderID?, + permissionMode: AgentSessionPermissionMode, + text: String + ) async throws -> AgentSessionControlSubmitResult { + guard !isClosed else { + throw AgentSessionBridgeError.invalidRequest + } + guard !text.isEmpty else { + throw AgentSessionBridgeError.missingParameter("text") + } + + if let active = processStore.activeSessionInfo { + if let requestedProviderID, requestedProviderID != active.providerID { + throw AgentSessionBridgeError.sessionAlreadyRunning + } + let session = try await processStore.writeLineToActive( + permissionMode: permissionMode, + text: text + ) + emitControlInputAccepted(session: session, text: text) + return AgentSessionControlSubmitResult(session: session, startedProvider: false) + } + + guard !isProviderStartPending else { + throw AgentSessionBridgeError.sessionAlreadyRunning + } + isProviderStartPending = true + defer { + isProviderStartPending = false + } + let provider = requestedProviderID ?? initialProviderID + initialProviderID = provider + onProviderIDChanged?(provider) + let configuredExecutablePaths = AgentExecutableResolver.cmuxConfiguredExecutablePaths() + let plan = try await Task.detached(priority: .userInitiated) { + let resolver = AgentExecutableResolver(configuredExecutablePaths: configuredExecutablePaths) + return try resolver.resolve(provider) + }.value + guard !isClosed else { + throw AgentSessionBridgeError.invalidRequest + } + _ = try await processStore.start( + plan: plan, + workingDirectory: workingDirectory + ) + let session = try await processStore.writeLineToActive( + permissionMode: permissionMode, + text: text + ) + emitControlInputAccepted(session: session, text: text) + return AgentSessionControlSubmitResult(session: session, startedProvider: true) + } + func bind( panelId: UUID, workspaceId: UUID, @@ -72,6 +137,10 @@ final class AgentSessionWebRendererCoordinator: NSObject, WKNavigationDelegate, let configuration = WKWebViewConfiguration() configuration.suppressesIncrementalRendering = false + configuration.setURLSchemeHandler( + assetSchemeHandler, + forURLScheme: Self.agentSessionAssetScheme + ) configuration.userContentController.addScriptMessageHandler( self, contentWorld: .page, @@ -110,17 +179,19 @@ final class AgentSessionWebRendererCoordinator: NSObject, WKNavigationDelegate, rendererKind: rendererKind, resourceDirectoryURL: resourceDirectoryURL ) - trustedShellURL = Self.normalizedTrustedFileURL(indexURL) + trustedShellURL = indexURL #if DEBUG cmuxDebugLog( "agentSession.web.load renderer=\(rendererKind.rawValue) " + - "index=\(indexURL.path)" + "index=\(indexURL.absoluteString)" ) #endif - webView.loadFileURL(indexURL, allowingReadAccessTo: Bundle.main.resourceURL ?? resourceDirectoryURL) + webView.load(URLRequest(url: indexURL)) loadedRendererKind = rendererKind hasFinishedNavigation = false hasCompletedVisiblePaintFlush = false + isFlushingBridgeEvents = false + hasScheduledBridgeFlushRetry = false } func focus() { @@ -156,6 +227,9 @@ final class AgentSessionWebRendererCoordinator: NSObject, WKNavigationDelegate, trustedShellURL = nil hasFinishedNavigation = false hasCompletedVisiblePaintFlush = false + pendingBridgeEvents.removeAll() + isFlushingBridgeEvents = false + hasScheduledBridgeFlushRetry = false } func userContentController( @@ -188,6 +262,7 @@ final class AgentSessionWebRendererCoordinator: NSObject, WKNavigationDelegate, #endif hasFinishedNavigation = true applyThemeToLoadedPage() + flushPendingBridgeEvents() if isPanelFocused { focus() } @@ -320,12 +395,21 @@ final class AgentSessionWebRendererCoordinator: NSObject, WKNavigationDelegate, rendererKind: AgentSessionRendererKind, resourceDirectoryURL: URL ) -> URL { - rendererKind.resourceHTMLPathComponents.reduce(resourceDirectoryURL) { - $0.appendingPathComponent($1, isDirectory: false) + _ = resourceDirectoryURL + var components = URLComponents() + components.scheme = agentSessionAssetScheme + components.host = agentSessionAssetHost + components.percentEncodedPath = "/" + rendererKind.resourceHTMLPathComponents.joined(separator: "/") + guard let url = components.url else { + preconditionFailure("Invalid Agent Session shell URL") } + return url } nonisolated static func isTrustedShellURL(_ candidate: URL?, expected: URL?) -> Bool { + if candidate?.scheme == agentSessionAssetScheme || expected?.scheme == agentSessionAssetScheme { + return normalizedAgentSessionAssetURL(candidate) == normalizedAgentSessionAssetURL(expected) + } guard let candidate = normalizedTrustedFileURL(candidate), let expected = normalizedTrustedFileURL(expected) else { return false @@ -333,6 +417,57 @@ final class AgentSessionWebRendererCoordinator: NSObject, WKNavigationDelegate, return candidate == expected } + nonisolated static func agentSessionAssetFileURL( + for requestURL: URL?, + resourceDirectoryURL: URL? + ) -> URL? { + guard let requestURL, + requestURL.scheme == agentSessionAssetScheme, + requestURL.host == agentSessionAssetHost, + requestURL.query == nil, + requestURL.fragment == nil, + let resourceDirectoryURL, + let requestPathComponents = agentSessionAssetPathComponents(requestURL) else { + return nil + } + + let rootURL = resourceDirectoryURL.standardizedFileURL.resolvingSymlinksInPath() + let candidateURL = requestPathComponents.reduce(rootURL) { partialURL, component in + partialURL.appendingPathComponent(component, isDirectory: false) + }.standardizedFileURL.resolvingSymlinksInPath() + guard candidateURL.isDescendant(of: rootURL) else { + return nil + } + if let fileURL = readableRegularFileURL(candidateURL) { + return fileURL + } + + let deflatedURL = candidateURL.appendingPathExtension("deflate") + .standardizedFileURL + .resolvingSymlinksInPath() + guard deflatedURL.isDescendant(of: rootURL) else { + return nil + } + return readableRegularFileURL(deflatedURL) + } + + nonisolated static func agentSessionAssetMIMEType(for requestURL: URL) -> String { + switch requestURL.pathExtension.lowercased() { + case "html": + return "text/html" + case "mjs", "js": + return "text/javascript" + case "css": + return "text/css" + case "json": + return "application/json" + case "svg": + return "image/svg+xml" + default: + return UTType(filenameExtension: requestURL.pathExtension)?.preferredMIMEType ?? "application/octet-stream" + } + } + nonisolated static func normalizedTrustedFileURL(_ url: URL?) -> URL? { guard let url, url.isFileURL else { return nil @@ -340,6 +475,64 @@ final class AgentSessionWebRendererCoordinator: NSObject, WKNavigationDelegate, return url.standardizedFileURL.resolvingSymlinksInPath() } + nonisolated private static func normalizedAgentSessionAssetURL(_ url: URL?) -> URL? { + guard let url, + url.scheme == agentSessionAssetScheme, + url.host == agentSessionAssetHost, + url.query == nil, + url.fragment == nil, + let pathComponents = agentSessionAssetPathComponents(url) else { + return nil + } + var components = URLComponents() + components.scheme = agentSessionAssetScheme + components.host = agentSessionAssetHost + components.percentEncodedPath = "/" + pathComponents.joined(separator: "/") + return components.url + } + + nonisolated private static func agentSessionAssetPathComponents(_ url: URL) -> [String]? { + guard let encodedPath = URLComponents(url: url, resolvingAgainstBaseURL: false)?.percentEncodedPath, + encodedPath.hasPrefix("/"), + !encodedPath.contains("\\"), + !encodedPath.contains("//") else { + return nil + } + + let encodedComponents = encodedPath.split(separator: "/", omittingEmptySubsequences: true).map(String.init) + guard !encodedComponents.isEmpty else { + return nil + } + + var decodedComponents: [String] = [] + decodedComponents.reserveCapacity(encodedComponents.count) + for encodedComponent in encodedComponents { + let lowercased = encodedComponent.lowercased() + guard !lowercased.contains("%2f"), + !lowercased.contains("%5c"), + let component = encodedComponent.removingPercentEncoding, + !component.isEmpty, + component != ".", + component != "..", + !component.contains("/"), + !component.contains("\\") else { + return nil + } + decodedComponents.append(component) + } + return decodedComponents + } + + nonisolated private static func readableRegularFileURL(_ url: URL) -> URL? { + var isDirectory = ObjCBool(false) + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), + !isDirectory.boolValue, + FileManager.default.isReadableFile(atPath: url.path) else { + return nil + } + return url + } + private func handle(_ request: AgentSessionBridgeRequest) async throws -> Any { switch request.method { case "app.context": @@ -546,6 +739,9 @@ final class AgentSessionWebRendererCoordinator: NSObject, WKNavigationDelegate, if let workingDirectory { context["workingDirectory"] = workingDirectory } + if let activeSession = processStore.activeSessionInfo { + context["activeSession"] = Self.activeSessionContext(activeSession) + } return context case "app.pickFiles": return await pickLocalFiles() @@ -684,20 +880,123 @@ final class AgentSessionWebRendererCoordinator: NSObject, WKNavigationDelegate, } private func sendEvent(_ event: [String: Any]) { - guard let webView, - let data = try? JSONSerialization.data(withJSONObject: event), + pendingBridgeEvents.append(event) + if pendingBridgeEvents.count > Self.maxPendingBridgeEvents { + pendingBridgeEvents.removeFirst(pendingBridgeEvents.count - Self.maxPendingBridgeEvents) + } + flushPendingBridgeEvents() + } + + private func flushPendingBridgeEvents() { + guard !isFlushingBridgeEvents, + hasFinishedNavigation, + let webView, + !pendingBridgeEvents.isEmpty else { + return + } + isFlushingBridgeEvents = true + flushNextBridgeEvent(in: webView) + } + + private func flushNextBridgeEvent(in webView: WKWebView) { + guard self.webView === webView, + hasFinishedNavigation else { + isFlushingBridgeEvents = false + return + } + guard let event = pendingBridgeEvents.first else { + isFlushingBridgeEvents = false + return + } + guard let data = try? JSONSerialization.data(withJSONObject: event), let json = String(data: data, encoding: .utf8) else { + pendingBridgeEvents.removeFirst() + flushNextBridgeEvent(in: webView) return } - webView.evaluateJavaScript("window.cmuxAgentBridge?.receive(\(json));") { _, error in + webView.evaluateJavaScript(Self.bridgeEventDeliveryScript(json: json)) { [weak self, weak webView] result, error in + Task { @MainActor in + guard let self, + let webView, + self.webView === webView else { + return + } #if DEBUG - if let error { - cmuxDebugLog("agentSession.bridge.event.failed error=\(error.localizedDescription)") - } + if let error { + cmuxDebugLog("agentSession.bridge.event.failed error=\(error.localizedDescription)") + } #else - _ = error + _ = error #endif + if error != nil { + self.isFlushingBridgeEvents = false + self.scheduleBridgeFlushRetry() + return + } + guard (result as? Bool) == true else { + self.isFlushingBridgeEvents = false + self.scheduleBridgeFlushRetry() + return + } + if !self.pendingBridgeEvents.isEmpty { + self.pendingBridgeEvents.removeFirst() + } + self.flushNextBridgeEvent(in: webView) + } + } + } + + private func scheduleBridgeFlushRetry() { + guard !hasScheduledBridgeFlushRetry, + hasFinishedNavigation, + webView != nil, + !pendingBridgeEvents.isEmpty else { + return } + hasScheduledBridgeFlushRetry = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + Task { @MainActor in + guard let self else { return } + self.hasScheduledBridgeFlushRetry = false + self.flushPendingBridgeEvents() + } + } + } + + nonisolated static func bridgeEventDeliveryScript(json: String) -> String { + """ + (() => { + const bridge = window.cmuxAgentBridge; + if (!bridge || typeof bridge.receive !== "function") { + return false; + } + bridge.receive(\(json)); + return true; + })() + """ + } + + nonisolated static func activeSessionContext(_ session: AgentSessionControlSessionInfo) -> [String: Any] { + var context: [String: Any] = [ + "sessionId": session.sessionId, + "providerId": session.providerID.rawValue, + "executablePath": session.executablePath, + "arguments": session.arguments + ] + if let workingDirectory = session.workingDirectory { + context["workingDirectory"] = workingDirectory + } + return context + } + + private func emitControlInputAccepted(session: AgentSessionControlSessionInfo, text: String) { + sendEvent([ + "type": "provider.inputAccepted", + "sessionId": session.sessionId, + "providerId": session.providerID.rawValue, + "text": text, + "sentAtMs": Int(Date().timeIntervalSince1970 * 1000) + ]) } private func handleExternalLink(_ url: URL) { @@ -754,3 +1053,156 @@ final class AgentSessionWebRendererCoordinator: NSObject, WKNavigationDelegate, return false } } + +private final class AgentSessionAssetSchemeHandler: NSObject, WKURLSchemeHandler { + private final class SchemeTaskState: @unchecked Sendable { + let condition = NSCondition() + var isStopped = false + var callbacksInFlight = 0 + } + + private let lock = NSLock() + private var activeSchemeTasks: [ObjectIdentifier: SchemeTaskState] = [:] + private let streamQueue = DispatchQueue( + label: "com.manaflow.cmux.agent-session-assets", + qos: .userInitiated + ) + + func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + let taskID = ObjectIdentifier(urlSchemeTask as AnyObject) + let state = SchemeTaskState() + lock.lock() + activeSchemeTasks[taskID] = state + lock.unlock() + + streamQueue.async { [weak self] in + guard let self else { return } + guard let requestURL = urlSchemeTask.request.url, + let resourceDirectoryURL = Bundle.main.resourceURL, + let fileURL = AgentSessionWebRendererCoordinator.agentSessionAssetFileURL( + for: requestURL, + resourceDirectoryURL: resourceDirectoryURL + ) else { + self.failSchemeTask(taskID, urlSchemeTask, code: NSURLErrorFileDoesNotExist) + return + } + + do { + let response = URLResponse( + url: requestURL, + mimeType: AgentSessionWebRendererCoordinator.agentSessionAssetMIMEType(for: requestURL), + expectedContentLength: -1, + textEncodingName: "utf-8" + ) + guard self.performSchemeTaskCallback(taskID, { + urlSchemeTask.didReceive(response) + }) else { return } + + let reader = try DiffViewerAssetReader(fileURL: fileURL) + defer { + try? reader.close() + } + + while self.isSchemeTaskActive(taskID) { + let data = try reader.read(upToCount: 64 * 1024) + if data.isEmpty { + break + } + guard self.performSchemeTaskCallback(taskID, { + urlSchemeTask.didReceive(data) + }) else { return } + } + + guard self.performSchemeTaskCallback(taskID, { + urlSchemeTask.didFinish() + }) else { return } + self.finishSchemeTask(taskID) + } catch { + guard self.performSchemeTaskCallback(taskID, { + urlSchemeTask.didFailWithError(error) + }) else { return } + self.finishSchemeTask(taskID) + } + } + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) { + let taskID = ObjectIdentifier(urlSchemeTask as AnyObject) + stopSchemeTask(taskID) + } + + private func failSchemeTask( + _ taskID: ObjectIdentifier, + _ urlSchemeTask: WKURLSchemeTask, + code: Int + ) { + guard performSchemeTaskCallback(taskID, { + urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: code)) + }) else { return } + finishSchemeTask(taskID) + } + + private func isSchemeTaskActive(_ taskID: ObjectIdentifier) -> Bool { + lock.lock() + let state = activeSchemeTasks[taskID] + lock.unlock() + guard let state else { return false } + + state.condition.lock() + let active = !state.isStopped + state.condition.unlock() + return active + } + + private func performSchemeTaskCallback(_ taskID: ObjectIdentifier, _ callback: () -> Void) -> Bool { + lock.lock() + let state = activeSchemeTasks[taskID] + lock.unlock() + guard let state else { return false } + + state.condition.lock() + guard !state.isStopped else { + state.condition.unlock() + return false + } + state.callbacksInFlight += 1 + state.condition.unlock() + + callback() + + state.condition.lock() + state.callbacksInFlight -= 1 + if state.callbacksInFlight == 0 { + state.condition.broadcast() + } + let active = !state.isStopped + state.condition.unlock() + return active + } + + private func finishSchemeTask(_ taskID: ObjectIdentifier) { + stopSchemeTask(taskID) + } + + private func stopSchemeTask(_ taskID: ObjectIdentifier) { + lock.lock() + let state = activeSchemeTasks.removeValue(forKey: taskID) + lock.unlock() + guard let state else { return } + + state.condition.lock() + state.isStopped = true + while state.callbacksInFlight > 0 { + state.condition.wait() + } + state.condition.unlock() + } +} + +private extension URL { + func isDescendant(of rootURL: URL) -> Bool { + let path = standardizedFileURL.path + let rootPath = rootURL.standardizedFileURL.path + return path == rootPath || path.hasPrefix(rootPath + "/") + } +} diff --git a/Sources/Panels/AgentSessionWebRendererSession.swift b/Sources/Panels/AgentSessionWebRendererSession.swift index b2796f33157a..d46f2c36d9c3 100644 --- a/Sources/Panels/AgentSessionWebRendererSession.swift +++ b/Sources/Panels/AgentSessionWebRendererSession.swift @@ -13,6 +13,21 @@ final class AgentSessionWebRendererSession { ownedCoordinator.onProviderIDChanged = onProviderIDChanged } } + var isWebViewInWindow: Bool { + ownedCoordinator.isWebViewInWindow + } + + func submitFromControl( + providerID: AgentSessionProviderID?, + permissionMode: AgentSessionPermissionMode, + text: String + ) async throws -> AgentSessionControlSubmitResult { + try await ownedCoordinator.submitFromControl( + providerID: providerID, + permissionMode: permissionMode, + text: text + ) + } func coordinator( panelId: UUID, diff --git a/Sources/Panels/ClaudeStreamJSONAccumulator.swift b/Sources/Panels/ClaudeStreamJSONAccumulator.swift index 133a8b4dfcfc..cba16e4b1b12 100644 --- a/Sources/Panels/ClaudeStreamJSONAccumulator.swift +++ b/Sources/Panels/ClaudeStreamJSONAccumulator.swift @@ -28,7 +28,7 @@ struct ClaudeStreamJSONAccumulator { return [] } - if let delta = assistantTextDelta(from: object), !delta.isEmpty { + if let delta = Self.contentBlockTextDelta(from: object), !delta.isEmpty { emittedAnyAssistantText = true if let currentMessageID { rememberMessageID(currentMessageID) @@ -39,6 +39,11 @@ struct ClaudeStreamJSONAccumulator { return [delta] } + if let delta = assistantTextDelta(from: object), !delta.isEmpty { + emittedAnyAssistantText = true + return [delta] + } + if !emittedAnyAssistantText, object["type"] as? String == "result", let result = object["result"] as? String, @@ -91,13 +96,16 @@ struct ClaudeStreamJSONAccumulator { return messageID } - private mutating func assistantTextDelta(from object: [String: Any]) -> String? { + private static func contentBlockTextDelta(from object: [String: Any]) -> String? { if object["type"] as? String == "content_block_delta", let delta = object["delta"] as? [String: Any], let text = delta["text"] as? String { return text } + return nil + } + private mutating func assistantTextDelta(from object: [String: Any]) -> String? { guard object["type"] as? String == "assistant" else { return nil } @@ -154,4 +162,3 @@ struct ClaudeStreamJSONAccumulator { return "" } } - diff --git a/Sources/Panels/CodexAppServerQueuedInput.swift b/Sources/Panels/CodexAppServerQueuedInput.swift index 61de5ee898f4..1f5d5f7a9c41 100644 --- a/Sources/Panels/CodexAppServerQueuedInput.swift +++ b/Sources/Panels/CodexAppServerQueuedInput.swift @@ -3,13 +3,4 @@ import Foundation struct CodexAppServerQueuedInput { let text: String let permissionMode: AgentSessionPermissionMode - let continuation: CheckedContinuation - - func resume() { - continuation.resume() - } - - func resume(throwing error: Error) { - continuation.resume(throwing: error) - } } diff --git a/Sources/Panels/CodexAppServerSession.swift b/Sources/Panels/CodexAppServerSession.swift index 1fd0c0256b51..9f2b72bb9468 100644 --- a/Sources/Panels/CodexAppServerSession.swift +++ b/Sources/Panels/CodexAppServerSession.swift @@ -74,19 +74,17 @@ final class CodexAppServerSession { guard canQueueInput(text) else { throw AgentSessionBridgeError.providerNotReady(AgentSessionProviderID.codex.displayName) } - try await withCheckedThrowingContinuation { continuation in - queuedInputs.append(CodexAppServerQueuedInput( - text: text, - permissionMode: permissionMode, - continuation: continuation - )) - if didInitialize { - Task { @MainActor in - do { - try await startThreadIfNeeded() - } catch { - failQueuedInputs(error) - } + queuedInputs.append(CodexAppServerQueuedInput( + text: text, + permissionMode: permissionMode + )) + if didInitialize { + Task { @MainActor in + do { + try await startThreadIfNeeded() + } catch { + discardQueuedInputs() + emitCodexRPCFailure(error) } } } @@ -522,9 +520,7 @@ final class CodexAppServerSession { text: input.text, permissionMode: input.permissionMode ) - input.resume() } catch { - input.resume(throwing: error) emitCodexRPCFailure(error) } } @@ -556,17 +552,13 @@ final class CodexAppServerSession { isTurnInFlight = false activePermissionMode = .standard turnStartRequestIDs.removeAll() - failQueuedInputs(AgentSessionBridgeError.providerNotReady(AgentSessionProviderID.codex.displayName)) + discardQueuedInputs() emitCodexRPCFailure(details: details) failureSink(details) } - private func failQueuedInputs(_ error: Error) { - let inputs = queuedInputs + private func discardQueuedInputs() { queuedInputs.removeAll() - for input in inputs { - input.resume(throwing: error) - } } private func sendTurnStart( diff --git a/Sources/TerminalController+ControlMobileHostContext.swift b/Sources/TerminalController+ControlMobileHostContext.swift index 299f74563eb9..d0caa44ee2aa 100644 --- a/Sources/TerminalController+ControlMobileHostContext.swift +++ b/Sources/TerminalController+ControlMobileHostContext.swift @@ -77,10 +77,6 @@ extension TerminalController: ControlMobileHostContext { ) } - func controlMobileChatSessionsDump() -> ControlCallResult { - bridgeMobileResult(v2ChatSessionsDump()) - } - /// Reconstructs the legacy `[String: Any]` params from the coordinator's /// typed params. This is the exact inverse of the dispatcher's /// `request.params.mapValues { $0.foundationObject }`, so the legacy body diff --git a/Sources/TerminalController+ControlSurfaceContext.swift b/Sources/TerminalController+ControlSurfaceContext.swift index a19588992a83..6b83c7cb66a0 100644 --- a/Sources/TerminalController+ControlSurfaceContext.swift +++ b/Sources/TerminalController+ControlSurfaceContext.swift @@ -12,6 +12,167 @@ extension TerminalController { } } +extension TerminalController { + @MainActor + func v2AgentSessionSubmit(params: [String: Any]) async -> V2CallResult { + guard let text = v2String(params, "text") + ?? v2String(params, "message") + ?? v2String(params, "prompt") + ?? v2String(params, "body") else { + return .err(code: "invalid_params", message: "Missing text", data: nil) + } + let providerID: AgentSessionProviderID? + if let rawProvider = v2String(params, "provider_id") ?? v2String(params, "provider") { + guard let parsedProvider = AgentSessionProviderID(rawValue: rawProvider) else { + return .err( + code: "invalid_params", + message: "Invalid provider", + data: ["provider": rawProvider] + ) + } + providerID = parsedProvider + } else { + providerID = nil + } + let permissionMode: AgentSessionPermissionMode + if let rawPermissionMode = v2String(params, "permission_mode") ?? v2String(params, "permissionMode") { + guard let parsedPermissionMode = AgentSessionPermissionMode(rawValue: rawPermissionMode) else { + return .err( + code: "invalid_params", + message: "Invalid permission mode", + data: ["permission_mode": rawPermissionMode] + ) + } + permissionMode = parsedPermissionMode + } else { + permissionMode = .standard + } + + v2RefreshKnownRefs() + let routing = ControlRoutingSelectors( + hasWindowIDParam: v2HasNonNullParam(params, "window_id"), + windowID: v2UUID(params, "window_id"), + groupID: v2UUID(params, "group_id"), + workspaceID: v2UUID(params, "workspace_id"), + surfaceID: v2UUID(params, "surface_id") + ?? v2UUID(params, "terminal_id") + ?? v2UUID(params, "tab_id"), + paneID: v2UUID(params, "pane_id") + ) + guard let target = agentSessionSubmitTarget(routing: routing) else { + return .err(code: "not_found", message: "Agent Session surface not found", data: nil) + } + guard let agentPanel = target.panel as? AgentSessionPanel else { + return .err( + code: "invalid_params", + message: "Surface is not an Agent Session", + data: ["surface_id": target.panel.id.uuidString] + ) + } + + do { + let result = try await agentPanel.submitFromControl( + providerID: providerID, + permissionMode: permissionMode, + text: text + ) + let session = result.session + return .ok([ + "workspace_id": target.workspaceID.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: target.workspaceID), + "window_id": v2OrNull(target.windowID?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: target.windowID), + "surface_id": target.panel.id.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: target.panel.id), + "session_id": session.sessionId, + "provider_id": session.providerID.rawValue, + "started_provider": result.startedProvider, + "executable_path": session.executablePath, + "arguments": session.arguments, + "working_directory": v2OrNull(session.workingDirectory), + ]) + } catch let error as AgentExecutableResolverError { + return .err( + code: "provider_unavailable", + message: error.message, + data: nil + ) + } catch let error as AgentSessionBridgeError { + return .err( + code: error.code, + message: error.localizedDescription, + data: nil + ) + } catch { + return .err( + code: "agent_session_error", + message: String(describing: error), + data: nil + ) + } + } + + private struct AgentSessionSubmitTarget { + let workspaceID: UUID + let windowID: UUID? + let panel: any Panel + } + + @MainActor + private func agentSessionSubmitTarget(routing: ControlRoutingSelectors) -> AgentSessionSubmitTarget? { + guard let tabManager = resolveTabManager(routing: routing) else { + return nil + } + if let dock = windowDockForRouting(routing, tabManager: tabManager) { + let panels = orderedPanels(in: dock) + let panel = agentSessionPanelCandidate( + panels: panels, + explicitSurfaceID: routing.surfaceID, + focusedPanelID: dock.focusedPanelId + ) + return panel.map { + AgentSessionSubmitTarget( + workspaceID: dock.workspaceId, + windowID: dockResultWindowId(for: dock, tabManager: tabManager), + panel: $0 + ) + } + } + + guard let workspace = resolveSurfaceWorkspace(routing: routing, tabManager: tabManager) else { + return nil + } + let panels = controlSurfacePanels(workspace: workspace) + let panel = agentSessionPanelCandidate( + panels: panels, + explicitSurfaceID: routing.surfaceID, + focusedPanelID: workspace.focusedPanelId + ) + return panel.map { + AgentSessionSubmitTarget( + workspaceID: workspace.id, + windowID: v2ResolveWindowId(tabManager: tabManager), + panel: $0 + ) + } + } + + private func agentSessionPanelCandidate( + panels: [any Panel], + explicitSurfaceID: UUID?, + focusedPanelID: UUID? + ) -> (any Panel)? { + if let explicitSurfaceID { + return panels.first { $0.id == explicitSurfaceID } + } + if let focusedPanelID, + let focused = panels.first(where: { $0.id == focusedPanelID }) { + return focused + } + return panels.first { $0 is AgentSessionPanel } + } +} + /// The surface-domain witnesses are the byte-faithful bodies of the former /// `v2Surface*` / `v2DebugTerminals` dispatchers, minus the per-read `v2MainSync` /// hop: the coordinator already runs on the main actor inside the socket-command @@ -212,16 +373,10 @@ extension TerminalController: ControlSurfaceContext { } if let dock = windowDockForRouting(routing, tabManager: tabManager) { let items: [ControlSurfaceHealthEntry] = orderedPanels(in: dock).map { panel in - var inWindow: Bool? - if let tp = panel as? TerminalPanel { - inWindow = tp.surface.isViewInWindow - } else if let bp = panel as? BrowserPanel { - inWindow = bp.webView.window != nil - } return ControlSurfaceHealthEntry( surfaceID: panel.id, typeRawValue: panel.panelType.rawValue, - inWindow: inWindow + inWindow: controlSurfaceInWindow(panel) ) } return ControlSurfaceHealthSnapshot( @@ -232,16 +387,10 @@ extension TerminalController: ControlSurfaceContext { } guard let ws = resolveSurfaceWorkspace(routing: routing, tabManager: tabManager) else { return nil } let items: [ControlSurfaceHealthEntry] = controlSurfacePanels(workspace: ws).map { panel in - var inWindow: Bool? - if let tp = panel as? TerminalPanel { - inWindow = tp.surface.isViewInWindow - } else if let bp = panel as? BrowserPanel { - inWindow = bp.webView.window != nil - } return ControlSurfaceHealthEntry( surfaceID: panel.id, typeRawValue: panel.panelType.rawValue, - inWindow: inWindow + inWindow: controlSurfaceInWindow(panel) ) } return ControlSurfaceHealthSnapshot( @@ -251,6 +400,19 @@ extension TerminalController: ControlSurfaceContext { ) } + private func controlSurfaceInWindow(_ panel: any Panel) -> Bool? { + if let tp = panel as? TerminalPanel { + return tp.surface.isViewInWindow + } + if let bp = panel as? BrowserPanel { + return bp.webView.window != nil + } + if let ap = panel as? AgentSessionPanel { + return ap.isWebViewInWindow + } + return nil + } + // MARK: - focus func controlSurfaceFocus( diff --git a/Sources/TerminalController+FeedAcknowledgment.swift b/Sources/TerminalController+FeedAcknowledgment.swift index 73b376338009..5d1a1b09e224 100644 --- a/Sources/TerminalController+FeedAcknowledgment.swift +++ b/Sources/TerminalController+FeedAcknowledgment.swift @@ -141,6 +141,7 @@ extension TerminalController { self.v2ApplyIMessageModeSideEffects(for: authoritativeEvent) }, onAccepted: { authoritativeEvent in + AgentGUIService.shared?.handleHookEvent(authoritativeEvent) self.v2MainSync { self.agentChatTranscriptService?.noteHookEvent(authoritativeEvent) } diff --git a/Sources/TerminalController+MobileChatArtifacts.swift b/Sources/TerminalController+MobileChatArtifacts.swift index 658597779cd7..48ad39942116 100644 --- a/Sources/TerminalController+MobileChatArtifacts.swift +++ b/Sources/TerminalController+MobileChatArtifacts.swift @@ -3,11 +3,18 @@ import CmuxSettings import Foundation private enum TerminalControllerChatArtifactIndexProvider { - static let shared = AgentChatArtifactIndex() + static let shared = AgentChatArtifactIndex.shared static let ordering = ChatArtifactGalleryOrderingCache() static let rowCounts = ChatArtifactGalleryRowCountCache(maximumAge: 2) } +private struct TerminalControllerChatArtifactSessionContext { + let sessionID: String + let agentKind: ChatAgentKind + let transcriptPath: String + let workingDirectory: String? +} + extension TerminalController { func v2MobileChatArtifactGallery(params: [String: Any]) async -> V2CallResult { guard let sessionID = v2RawString(params, "session_id")?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -73,18 +80,16 @@ extension TerminalController { func mobileChatArtifactIndexedSession( sessionID: String ) async throws -> (sessionID: String, snapshot: AgentChatArtifactIndex.Snapshot)? { - guard let service = agentChatTranscriptService, - let record = service.sessionRecord(sessionID: sessionID), - let transcriptPath = service.resolver.transcriptPath(for: record) else { + guard let context = mobileChatArtifactSessionContext(sessionID: sessionID) else { return nil } let snapshot = try await TerminalControllerChatArtifactIndexProvider.shared.snapshot( - sessionID: record.sessionID, - agentKind: record.agentKind, - transcriptPath: transcriptPath, - workingDirectory: record.workingDirectory + sessionID: context.sessionID, + agentKind: context.agentKind, + transcriptPath: context.transcriptPath, + workingDirectory: context.workingDirectory ) - return (record.sessionID, snapshot) + return (context.sessionID, snapshot) } /// Returns the stat-filtered count for the gallery's default landing view. @@ -298,21 +303,15 @@ extension TerminalController { data: nil )) } - guard let service = agentChatTranscriptService else { - return .failure(.err(code: "unavailable", message: Self.chatServiceUnavailableErrorMessage, data: nil)) - } - guard let record = service.sessionRecord(sessionID: sessionID) else { - return .failure(mobileChatArtifactError(.notFound, path: requestedPath)) - } - guard let transcriptPath = service.resolver.transcriptPath(for: record) else { + guard let context = mobileChatArtifactSessionContext(sessionID: sessionID) else { return .failure(mobileChatArtifactError(.notFound, path: requestedPath)) } do { let pathResult = try await TerminalControllerChatArtifactIndexProvider.shared.canonicalPath( - sessionID: record.sessionID, - agentKind: record.agentKind, - transcriptPath: transcriptPath, - workingDirectory: record.workingDirectory, + sessionID: context.sessionID, + agentKind: context.agentKind, + transcriptPath: context.transcriptPath, + workingDirectory: context.workingDirectory, requestedPath: requestedPath, operation: operation.indexOperation, directoryAccessMode: mobileArtifactDirectoryAccessMode() @@ -339,6 +338,30 @@ extension TerminalController { } } + private func mobileChatArtifactSessionContext( + sessionID: String + ) -> TerminalControllerChatArtifactSessionContext? { + if let context = AgentGUIService.shared?.artifactSessionContext(sessionID: sessionID) { + return TerminalControllerChatArtifactSessionContext( + sessionID: context.sessionID, + agentKind: ChatAgentKind(source: context.agentKind.rawValue), + transcriptPath: context.transcriptPath, + workingDirectory: context.workingDirectory + ) + } + guard let service = agentChatTranscriptService, + let record = service.sessionRecord(sessionID: sessionID), + let transcriptPath = service.resolver.transcriptPath(for: record) else { + return nil + } + return TerminalControllerChatArtifactSessionContext( + sessionID: record.sessionID, + agentKind: record.agentKind, + transcriptPath: transcriptPath, + workingDirectory: record.workingDirectory + ) + } + /// Resolves the persisted mobile folder setting into the shared scope policy. func mobileArtifactDirectoryAccessMode( defaults: UserDefaults = .standard diff --git a/Sources/TerminalController+MobileWorkspaceList.swift b/Sources/TerminalController+MobileWorkspaceList.swift index a5f62edc9897..fd5751ca703a 100644 --- a/Sources/TerminalController+MobileWorkspaceList.swift +++ b/Sources/TerminalController+MobileWorkspaceList.swift @@ -10,6 +10,13 @@ import Foundation // group collapse/expand handler. Lives in its own file so the mobile list // payload code stays together without growing TerminalController.swift. extension TerminalController { + private func mobileWorkspaceNonEmpty(_ raw: String?) -> String? { + guard let value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value + } + /// Mobile-gated collapse/expand of a workspace group. This requires an /// explicit, resolvable `group_id` (it must never fall back to the Mac's /// selected group) and mutates through the same @@ -228,7 +235,7 @@ extension TerminalController { } let terminalDirectory = workspace.effectivePanelDirectory( panelId: terminal.id, - localFallback: mobileNonEmpty(terminal.directory) ?? mobileNonEmpty(terminal.requestedWorkingDirectory) + localFallback: mobileWorkspaceNonEmpty(terminal.directory) ?? mobileWorkspaceNonEmpty(terminal.requestedWorkingDirectory) ) return [ "id": terminal.id.uuidString, diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index c29f28dc91b2..d4fda58936f1 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1417,6 +1417,10 @@ class TerminalController { let outcome = try await BrowserImportAutomation.importCookies(params: request.params) return outcome.socketPayload } + case "agent_session.submit": + return v2AsyncResultCall(id: request.id, timeoutSeconds: 120) { + await self.v2AgentSessionSubmit(params: request.params) + } case "mobile.attach_ticket.create": return v2AsyncResultCall(id: request.id, timeoutSeconds: 30) { await self.v2MobileAttachTicketCreate(params: request.params) @@ -2336,7 +2340,7 @@ class TerminalController { case "system.capabilities": return v2Ok(id: id, result: v2CapabilitiesWithBrowserDesignMode()) // mobile.host.status/mobile.workspace.list/mobile.terminal.* (+terminal.* - // aliases), mobile.terminal.paste/terminal.paste, and chat.sessions.dump + // aliases), and mobile.terminal.paste/terminal.paste // handled by ControlCommandCoordinator (bodies stay; shared with // mobileHostHandleRPC). @@ -2545,6 +2549,7 @@ class TerminalController { "aiAccounts.list", "aiAccounts.upload", "aiAccounts.remove", + "agent_session.submit", "window.list", "window.current", "window.focus", @@ -14089,6 +14094,8 @@ class TerminalController { // MobileHostRPCResult` type round-trip with no behavior change. The v2 // control socket shares the same bodies through `handleMobileHost`, so the // wire bytes stay identical across both entrypoints without a bridge here. + if let result = await AgentGUIService.shared?.handleRPC(request) { return result } + let result: V2CallResult switch request.method { case "mobile.host.status": diff --git a/Sources/TextBoxAgentDetection.swift b/Sources/TextBoxAgentDetection.swift index 1d5c637074dc..2b94d95719d0 100644 --- a/Sources/TextBoxAgentDetection.swift +++ b/Sources/TextBoxAgentDetection.swift @@ -1,4 +1,3 @@ -import CmuxAgentChat import Foundation enum TextBoxAgentDetection: CaseIterable { @@ -83,10 +82,6 @@ enum TextBoxAgentDetection: CaseIterable { isClaudeCode(context: context) && containsNewline ? "ctrl+enter" : "return" } - static func composedPromptSubmitKey(containsNewline: Bool, agentKind: ChatAgentKind) -> String { - agentKind == .claude && containsNewline ? "ctrl+enter" : "return" - } - static func boundedLaunchCommandContext(from rawCommand: String) -> String? { let command = rawCommand.trimmingCharacters(in: .whitespacesAndNewlines) guard !command.isEmpty else { return nil } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 510fbedf74bd..ed5fbf5fd605 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -55,6 +55,42 @@ C0DEF0D30000000000000001 /* AgentForkExecutableIdentityResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0D30000000000000002 /* AgentForkExecutableIdentityResolver.swift */; }; C0DEF0C10000000000000001 /* AgentForkSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0C10000000000000002 /* AgentForkSupport.swift */; }; C0DEF0D40000000000000001 /* AgentForkTimeoutResumeGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0D40000000000000002 /* AgentForkTimeoutResumeGate.swift */; }; + A6E170000000000000000079 /* AgentGUIAskRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E17000000000000000001F /* AgentGUIAskRegistry.swift */; }; + A6E17000000000000000012F /* AgentGUIAskRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E1700000000000000000CB /* AgentGUIAskRegistryTests.swift */; }; + A6E170000000000000000064 /* AgentGUICapabilityMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E17000000000000000000A /* AgentGUICapabilityMapper.swift */; }; + A6E170000000000000000065 /* AgentGUICodableBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E17000000000000000000B /* AgentGUICodableBridge.swift */; }; + A6E170000000000000000066 /* AgentGUIConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E17000000000000000000C /* AgentGUIConstants.swift */; }; + A6E170000000000000000067 /* AgentGUIFileIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E17000000000000000000D /* AgentGUIFileIdentity.swift */; }; + A6E170000000000000000068 /* AgentGUIHookFactMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E17000000000000000000E /* AgentGUIHookFactMapper.swift */; }; + A6E17000000000000000012C /* AgentGUIHookFactMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E1700000000000000000C8 /* AgentGUIHookFactMapperTests.swift */; }; + A6E17000000000000000007E /* AgentGUIIncrementalState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E170000000000000000024 /* AgentGUIIncrementalState.swift */; }; + A6E170000000000000000069 /* AgentGUIJournalBookkeeper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E17000000000000000000F /* AgentGUIJournalBookkeeper.swift */; }; + DA7A00000000000000000001 /* AgentGUIJournalPageReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7A00000000000000000011 /* AgentGUIJournalPageReader.swift */; }; + A6E17000000000000000006A /* AgentGUIJournalPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E170000000000000000010 /* AgentGUIJournalPipeline.swift */; }; + A6E17000000000000000006B /* AgentGUIJournalPipelineEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E170000000000000000011 /* AgentGUIJournalPipelineEvent.swift */; }; + A6E17000000000000000012D /* AgentGUIJournalPipelineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E1700000000000000000C9 /* AgentGUIJournalPipelineTests.swift */; }; + DA7A00000000000000000002 /* AgentGUIJournalToolCallLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA7A00000000000000000012 /* AgentGUIJournalToolCallLocator.swift */; }; + A6E17000000000000000006C /* AgentGUIJournalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E170000000000000000012 /* AgentGUIJournalWindow.swift */; }; + A6E17000000000000000006D /* AgentGUIOpenTranscriptPathScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E170000000000000000013 /* AgentGUIOpenTranscriptPathScanner.swift */; }; + D0C0A3110000000000000001 /* AgentGUIProseScreenExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C0A3210000000000000001 /* AgentGUIProseScreenExtractor.swift */; }; + A6E17000000000000000006E /* AgentGUIRPCError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E170000000000000000014 /* AgentGUIRPCError.swift */; }; + A6E17000000000000000006F /* AgentGUIRPCHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E170000000000000000015 /* AgentGUIRPCHandler.swift */; }; + A6E17000000000000000012E /* AgentGUIRPCHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E1700000000000000000CA /* AgentGUIRPCHandlerTests.swift */; }; + A6E17000000000000000007A /* AgentGUISendLedger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E170000000000000000020 /* AgentGUISendLedger.swift */; }; + A6E170000000000000000130 /* AgentGUISendLedgerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E1700000000000000000CC /* AgentGUISendLedgerTests.swift */; }; + A6E170000000000000000070 /* AgentGUIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E170000000000000000016 /* AgentGUIService.swift */; }; + A6E170000000000000000071 /* AgentGUIStampedEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E170000000000000000017 /* AgentGUIStampedEntry.swift */; }; + D0C0A3110000000000000002 /* AgentGUIStreamProducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C0A3210000000000000002 /* AgentGUIStreamProducer.swift */; }; + D0C0A3110000000000000003 /* AgentGUIStreamProducerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C0A3210000000000000003 /* AgentGUIStreamProducerTests.swift */; }; + A6E170000000000000000072 /* AgentGUISubscriptionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E170000000000000000018 /* AgentGUISubscriptionPolicy.swift */; }; + A6E17000000000000000007B /* AgentGUITerminalInjecting.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E170000000000000000021 /* AgentGUITerminalInjecting.swift */; }; + A6E17000000000000000007C /* AgentGUITerminalInjectionResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E170000000000000000022 /* AgentGUITerminalInjectionResult.swift */; }; + A6E17000000000000000007D /* AgentGUITerminalInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E170000000000000000023 /* AgentGUITerminalInjector.swift */; }; + A6E170000000000000000073 /* AgentGUIToolPairingIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E170000000000000000019 /* AgentGUIToolPairingIndex.swift */; }; + A6E170000000000000000074 /* AgentGUITranscriptDecoderBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E17000000000000000001A /* AgentGUITranscriptDecoderBox.swift */; }; + E1A700000000000000000001 /* AgentGUITranscriptImageStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A700000000000000000002 /* AgentGUITranscriptImageStore.swift */; }; + A6E170000000000000000075 /* AgentGUITranscriptResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E17000000000000000001B /* AgentGUITranscriptResolver.swift */; }; + A6E170000000000000000076 /* AgentGUIWirePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E17000000000000000001C /* AgentGUIWirePublisher.swift */; }; 8997C00E8997C00E8997C00E /* AgentHibernationController+CommittedTerminationCleanup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8997D00E8997D00E8997D00E /* AgentHibernationController+CommittedTerminationCleanup.swift */; }; 8997C00F8997C00F8997C00F /* AgentHibernationController+CommittedTerminationObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8997D00F8997D00F8997D00F /* AgentHibernationController+CommittedTerminationObservation.swift */; }; F65760100000000000000001 /* AgentHibernationController+Confirmation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F65760100000000000000002 /* AgentHibernationController+Confirmation.swift */; }; @@ -132,6 +168,8 @@ B79500110000000000000001 /* AgentPortTrackingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79500110000000000000002 /* AgentPortTrackingState.swift */; }; 89930002AABBCCDDEEFF0001 /* AgentProcessBindingResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89930002AABBCCDDEEFF0003 /* AgentProcessBindingResolution.swift */; }; 89930002AABBCCDDEEFF0002 /* AgentProcessBindingResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89930002AABBCCDDEEFF0003 /* AgentProcessBindingResolution.swift */; }; + A6E170000000000000000077 /* AgentProcessExitWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E17000000000000000001D /* AgentProcessExitWatcher.swift */; }; + A6E170000000000000000078 /* AgentProcessObservationSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E17000000000000000001E /* AgentProcessObservationSource.swift */; }; 0A1107110000000000000012 /* AgentRelaunchCommandBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A1107110000000000000013 /* AgentRelaunchCommandBuilder.swift */; }; 93840005AA11BB22CC33EE05 /* AgentRelayTTYOwnershipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93840006AA11BB22CC33EE06 /* AgentRelayTTYOwnershipTests.swift */; }; 93840003AA11BB22CC33EE03 /* AgentRelayTTYTargetResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93840004AA11BB22CC33EE04 /* AgentRelayTTYTargetResolution.swift */; }; @@ -914,6 +952,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C3408A000000000000000005 /* ContentView+ViewCommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3408A000000000000000006 /* ContentView+ViewCommandPalette.swift */; }; A5001002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001012 /* ContentView.swift */; }; C0DE32470000000000000001 /* ContentViewIdentifierCopyCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE32470000000000000002 /* ContentViewIdentifierCopyCommands.swift */; }; + A6E170000000000000000131 /* ControlledAgentProcessObservationCapturer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E1700000000000000000CD /* ControlledAgentProcessObservationCapturer.swift */; }; BFC914190FEF400683D6DEBC /* ControlSidebarAgentLifecycleRegistryScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 085B717ACCEF4FDBA77A2FD9 /* ControlSidebarAgentLifecycleRegistryScope.swift */; }; BE979B868F4D42199E5400E2 /* ControlSidebarPanelOwner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8746A8206B6142D7B963CDFA /* ControlSidebarPanelOwner.swift */; }; C51A73B30000000000000002 /* ControlSimulatorPendingTextInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51A73B30000000000000001 /* ControlSimulatorPendingTextInput.swift */; }; @@ -2737,6 +2776,42 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C0DEF0D30000000000000002 /* AgentForkExecutableIdentityResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentForkExecutableIdentityResolver.swift; sourceTree = ""; }; C0DEF0C10000000000000002 /* AgentForkSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentForkSupport.swift; sourceTree = ""; }; C0DEF0D40000000000000002 /* AgentForkTimeoutResumeGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentForkTimeoutResumeGate.swift; sourceTree = ""; }; + A6E17000000000000000001F /* AgentGUIAskRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUIAskRegistry.swift; sourceTree = ""; }; + A6E1700000000000000000CB /* AgentGUIAskRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUIAskRegistryTests.swift; sourceTree = ""; }; + A6E17000000000000000000A /* AgentGUICapabilityMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUICapabilityMapper.swift; sourceTree = ""; }; + A6E17000000000000000000B /* AgentGUICodableBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUICodableBridge.swift; sourceTree = ""; }; + A6E17000000000000000000C /* AgentGUIConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUIConstants.swift; sourceTree = ""; }; + A6E17000000000000000000D /* AgentGUIFileIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUIFileIdentity.swift; sourceTree = ""; }; + A6E17000000000000000000E /* AgentGUIHookFactMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUIHookFactMapper.swift; sourceTree = ""; }; + A6E1700000000000000000C8 /* AgentGUIHookFactMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUIHookFactMapperTests.swift; sourceTree = ""; }; + A6E170000000000000000024 /* AgentGUIIncrementalState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUIIncrementalState.swift; sourceTree = ""; }; + A6E17000000000000000000F /* AgentGUIJournalBookkeeper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUIJournalBookkeeper.swift; sourceTree = ""; }; + DA7A00000000000000000011 /* AgentGUIJournalPageReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUIJournalPageReader.swift; sourceTree = ""; }; + A6E170000000000000000010 /* AgentGUIJournalPipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUIJournalPipeline.swift; sourceTree = ""; }; + A6E170000000000000000011 /* AgentGUIJournalPipelineEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUIJournalPipelineEvent.swift; sourceTree = ""; }; + A6E1700000000000000000C9 /* AgentGUIJournalPipelineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUIJournalPipelineTests.swift; sourceTree = ""; }; + DA7A00000000000000000012 /* AgentGUIJournalToolCallLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUIJournalToolCallLocator.swift; sourceTree = ""; }; + A6E170000000000000000012 /* AgentGUIJournalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUIJournalWindow.swift; sourceTree = ""; }; + A6E170000000000000000013 /* AgentGUIOpenTranscriptPathScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUIOpenTranscriptPathScanner.swift; sourceTree = ""; }; + D0C0A3210000000000000001 /* AgentGUIProseScreenExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUIProseScreenExtractor.swift; sourceTree = ""; }; + A6E170000000000000000014 /* AgentGUIRPCError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUIRPCError.swift; sourceTree = ""; }; + A6E170000000000000000015 /* AgentGUIRPCHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUIRPCHandler.swift; sourceTree = ""; }; + A6E1700000000000000000CA /* AgentGUIRPCHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUIRPCHandlerTests.swift; sourceTree = ""; }; + A6E170000000000000000020 /* AgentGUISendLedger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUISendLedger.swift; sourceTree = ""; }; + A6E1700000000000000000CC /* AgentGUISendLedgerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUISendLedgerTests.swift; sourceTree = ""; }; + A6E170000000000000000016 /* AgentGUIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUIService.swift; sourceTree = ""; }; + A6E170000000000000000017 /* AgentGUIStampedEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUIStampedEntry.swift; sourceTree = ""; }; + D0C0A3210000000000000002 /* AgentGUIStreamProducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUIStreamProducer.swift; sourceTree = ""; }; + D0C0A3210000000000000003 /* AgentGUIStreamProducerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUIStreamProducerTests.swift; sourceTree = ""; }; + A6E170000000000000000018 /* AgentGUISubscriptionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUISubscriptionPolicy.swift; sourceTree = ""; }; + A6E170000000000000000021 /* AgentGUITerminalInjecting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUITerminalInjecting.swift; sourceTree = ""; }; + A6E170000000000000000022 /* AgentGUITerminalInjectionResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUITerminalInjectionResult.swift; sourceTree = ""; }; + A6E170000000000000000023 /* AgentGUITerminalInjector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUITerminalInjector.swift; sourceTree = ""; }; + A6E170000000000000000019 /* AgentGUIToolPairingIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUIToolPairingIndex.swift; sourceTree = ""; }; + A6E17000000000000000001A /* AgentGUITranscriptDecoderBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUITranscriptDecoderBox.swift; sourceTree = ""; }; + E1A700000000000000000002 /* AgentGUITranscriptImageStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUITranscriptImageStore.swift; sourceTree = ""; }; + A6E17000000000000000001B /* AgentGUITranscriptResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUITranscriptResolver.swift; sourceTree = ""; }; + A6E17000000000000000001C /* AgentGUIWirePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentGUIWirePublisher.swift; sourceTree = ""; }; 8997D00E8997D00E8997D00E /* AgentHibernationController+CommittedTerminationCleanup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "App/AgentHibernationController+CommittedTerminationCleanup.swift"; sourceTree = ""; }; 8997D00F8997D00F8997D00F /* AgentHibernationController+CommittedTerminationObservation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "App/AgentHibernationController+CommittedTerminationObservation.swift"; sourceTree = ""; }; F65760100000000000000002 /* AgentHibernationController+Confirmation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "App/AgentHibernationController+Confirmation.swift"; sourceTree = ""; }; @@ -2811,6 +2886,8 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources B79500140000000000000002 /* AgentPortSnapshotReplacementState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentPortSnapshotReplacementState.swift; sourceTree = ""; }; B79500110000000000000002 /* AgentPortTrackingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentPortTrackingState.swift; sourceTree = ""; }; 89930002AABBCCDDEEFF0003 /* AgentProcessBindingResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentProcessBindingResolution.swift; sourceTree = ""; }; + A6E17000000000000000001D /* AgentProcessExitWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentProcessExitWatcher.swift; sourceTree = ""; }; + A6E17000000000000000001E /* AgentProcessObservationSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentProcessObservationSource.swift; sourceTree = ""; }; 0A1107110000000000000013 /* AgentRelaunchCommandBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentRelaunchCommandBuilder.swift; sourceTree = ""; }; 93840006AA11BB22CC33EE06 /* AgentRelayTTYOwnershipTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentRelayTTYOwnershipTests.swift; sourceTree = ""; }; 93840004AA11BB22CC33EE04 /* AgentRelayTTYTargetResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentRelayTTYTargetResolution.swift; sourceTree = ""; }; @@ -3519,6 +3596,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C3408A000000000000000006 /* ContentView+ViewCommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+ViewCommandPalette.swift"; sourceTree = ""; }; A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; C0DE32470000000000000002 /* ContentViewIdentifierCopyCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentViewIdentifierCopyCommands.swift; sourceTree = ""; }; + A6E1700000000000000000CD /* ControlledAgentProcessObservationCapturer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlledAgentProcessObservationCapturer.swift; sourceTree = ""; }; 085B717ACCEF4FDBA77A2FD9 /* ControlSidebarAgentLifecycleRegistryScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlSidebarAgentLifecycleRegistryScope.swift; sourceTree = ""; }; 8746A8206B6142D7B963CDFA /* ControlSidebarPanelOwner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlSidebarPanelOwner.swift; sourceTree = ""; }; C51A73B30000000000000001 /* ControlSimulatorPendingTextInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlSimulatorPendingTextInput.swift; sourceTree = ""; }; @@ -4641,7 +4719,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A7984AD10000000000000002 /* SocketConfigurationLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketConfigurationLifecycleTests.swift; sourceTree = ""; }; A5001225 /* SocketControlMode+Display.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SocketControlMode+Display.swift"; sourceTree = ""; }; F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = ""; }; - C510C1E00000000000000001 /* SocketOperationTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketOperationTelemetry.swift; sourceTree = ""; }; + C510C1E00000000000000001 /* SocketOperationTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketOperationTelemetry.swift; sourceTree = ""; }; F87920000000000000000002 /* SocketReloadConfigurationWaiterAdmission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketReloadConfigurationWaiterAdmission.swift; sourceTree = ""; }; F87920000000000000000004 /* SocketReloadConfigurationWaiterLease.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketReloadConfigurationWaiterLease.swift; sourceTree = ""; }; A6AC72050000000000000002 /* SpinnerCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpinnerCard.swift; sourceTree = ""; }; @@ -7112,6 +7190,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C57B00050000000000000002 /* CmuxExtensionSidebarSelection+CustomSidebarName.swift */, 59065015952E1B5BE5E23519 /* Mobile */, 31B04B98376C9BA8B9E44DCB /* TerminalViewportUITestRecorder.swift */, + A6E170000000000000000001 /* AgentGUI */, ); path = Sources; sourceTree = ""; @@ -7924,6 +8003,13 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B8B056D80000000000000002 /* MobileHostIdentityTests.swift */, A7C0F0010000000000000001 /* MacPairedMacBackupPublisherScopeTests.swift */, 5E2701030000000000000002 /* MacSentryStartupPolicyTests.swift */, + A6E1700000000000000000CB /* AgentGUIAskRegistryTests.swift */, + A6E1700000000000000000C8 /* AgentGUIHookFactMapperTests.swift */, + A6E1700000000000000000C9 /* AgentGUIJournalPipelineTests.swift */, + A6E1700000000000000000CA /* AgentGUIRPCHandlerTests.swift */, + A6E1700000000000000000CC /* AgentGUISendLedgerTests.swift */, + D0C0A3210000000000000003 /* AgentGUIStreamProducerTests.swift */, + A6E1700000000000000000CD /* ControlledAgentProcessObservationCapturer.swift */, ); path = cmuxTests; sourceTree = ""; @@ -7950,6 +8036,46 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = path = Feed; sourceTree = ""; }; + A6E170000000000000000001 /* AgentGUI */ = { + isa = PBXGroup; + children = ( + A6E17000000000000000000A /* AgentGUICapabilityMapper.swift */, + A6E17000000000000000000B /* AgentGUICodableBridge.swift */, + A6E17000000000000000000C /* AgentGUIConstants.swift */, + A6E17000000000000000000D /* AgentGUIFileIdentity.swift */, + A6E17000000000000000000E /* AgentGUIHookFactMapper.swift */, + A6E170000000000000000024 /* AgentGUIIncrementalState.swift */, + A6E17000000000000000001F /* AgentGUIAskRegistry.swift */, + A6E17000000000000000000F /* AgentGUIJournalBookkeeper.swift */, + DA7A00000000000000000011 /* AgentGUIJournalPageReader.swift */, + A6E170000000000000000010 /* AgentGUIJournalPipeline.swift */, + E1A700000000000000000002 /* AgentGUITranscriptImageStore.swift */, + DA7A00000000000000000012 /* AgentGUIJournalToolCallLocator.swift */, + A6E170000000000000000011 /* AgentGUIJournalPipelineEvent.swift */, + A6E170000000000000000012 /* AgentGUIJournalWindow.swift */, + A6E170000000000000000013 /* AgentGUIOpenTranscriptPathScanner.swift */, + A6E170000000000000000014 /* AgentGUIRPCError.swift */, + A6E170000000000000000015 /* AgentGUIRPCHandler.swift */, + A6E170000000000000000020 /* AgentGUISendLedger.swift */, + A6E170000000000000000016 /* AgentGUIService.swift */, + A6E170000000000000000017 /* AgentGUIStampedEntry.swift */, + A6E170000000000000000018 /* AgentGUISubscriptionPolicy.swift */, + A6E170000000000000000021 /* AgentGUITerminalInjecting.swift */, + A6E170000000000000000022 /* AgentGUITerminalInjectionResult.swift */, + A6E170000000000000000023 /* AgentGUITerminalInjector.swift */, + A6E170000000000000000019 /* AgentGUIToolPairingIndex.swift */, + A6E17000000000000000001A /* AgentGUITranscriptDecoderBox.swift */, + A6E17000000000000000001B /* AgentGUITranscriptResolver.swift */, + A6E17000000000000000001C /* AgentGUIWirePublisher.swift */, + D0C0A3210000000000000001 /* AgentGUIProseScreenExtractor.swift */, + D0C0A3210000000000000002 /* AgentGUIStreamProducer.swift */, + A6E17000000000000000001D /* AgentProcessExitWatcher.swift */, + A6E17000000000000000001E /* AgentProcessObservationSource.swift */, + ); + name = AgentGUI; + path = AgentGUI; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -8027,6 +8153,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A8BD195031FC4B82B4354297 /* StackAuth */, EFB18E3B3099DFE2ECA3C263 /* CMUXMobileCore */, 1A0B0C0D0E0F101112132002 /* CmuxMobileTransport */, + A6E17000000000000000019A /* CmuxAgentReplica */, + A6E17000000000000000019B /* CmuxAgentWire */, + A6E17000000000000000019C /* CmuxAgentTruthKit */, ); productName = cmux; productReference = A5001000 /* cmux.app */; @@ -8133,6 +8262,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C51A700000000000000000B2 /* CmuxSimulatorUI */, C51A700000000000000000C2 /* CmuxSimulatorWorker */, A500D013A1B2C3D4E5F60718 /* CMUXDebugLog */, + A6E17000000000000000019A /* CmuxAgentReplica */, + A6E17000000000000000019B /* CmuxAgentWire */, + A6E17000000000000000019C /* CmuxAgentTruthKit */, ); productName = cmuxTests; productReference = F1000002A1B2C3D4E5F60718 /* cmuxTests.xctest */; @@ -8225,6 +8357,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 1A0B0C0D0E0F101112132011 /* XCLocalSwiftPackageReference "CmuxIrohTransport" */, C1A072000000000000000001 /* XCLocalSwiftPackageReference "CmuxMobileRPC" */, 1A0B0C0D0E0F101112132001 /* XCLocalSwiftPackageReference "CmuxMobileTransport" */, + A6E170000000000000000190 /* XCLocalSwiftPackageReference "CmuxAgentReplica" */, + A6E170000000000000000191 /* XCLocalSwiftPackageReference "CmuxAgentWire" */, + A6E170000000000000000192 /* XCLocalSwiftPackageReference "CmuxAgentTruthKit" */, ); productRefGroup = A5001042 /* Products */; projectDirPath = ""; @@ -8459,6 +8594,36 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DEF0D30000000000000001 /* AgentForkExecutableIdentityResolver.swift in Sources */, C0DEF0C10000000000000001 /* AgentForkSupport.swift in Sources */, C0DEF0D40000000000000001 /* AgentForkTimeoutResumeGate.swift in Sources */, + A6E170000000000000000079 /* AgentGUIAskRegistry.swift in Sources */, + A6E170000000000000000064 /* AgentGUICapabilityMapper.swift in Sources */, + A6E170000000000000000065 /* AgentGUICodableBridge.swift in Sources */, + A6E170000000000000000066 /* AgentGUIConstants.swift in Sources */, + A6E170000000000000000067 /* AgentGUIFileIdentity.swift in Sources */, + A6E170000000000000000068 /* AgentGUIHookFactMapper.swift in Sources */, + A6E17000000000000000007E /* AgentGUIIncrementalState.swift in Sources */, + A6E170000000000000000069 /* AgentGUIJournalBookkeeper.swift in Sources */, + DA7A00000000000000000001 /* AgentGUIJournalPageReader.swift in Sources */, + A6E17000000000000000006A /* AgentGUIJournalPipeline.swift in Sources */, + A6E17000000000000000006B /* AgentGUIJournalPipelineEvent.swift in Sources */, + DA7A00000000000000000002 /* AgentGUIJournalToolCallLocator.swift in Sources */, + A6E17000000000000000006C /* AgentGUIJournalWindow.swift in Sources */, + A6E17000000000000000006D /* AgentGUIOpenTranscriptPathScanner.swift in Sources */, + D0C0A3110000000000000001 /* AgentGUIProseScreenExtractor.swift in Sources */, + A6E17000000000000000006E /* AgentGUIRPCError.swift in Sources */, + A6E17000000000000000006F /* AgentGUIRPCHandler.swift in Sources */, + A6E17000000000000000007A /* AgentGUISendLedger.swift in Sources */, + A6E170000000000000000070 /* AgentGUIService.swift in Sources */, + A6E170000000000000000071 /* AgentGUIStampedEntry.swift in Sources */, + D0C0A3110000000000000002 /* AgentGUIStreamProducer.swift in Sources */, + A6E170000000000000000072 /* AgentGUISubscriptionPolicy.swift in Sources */, + A6E17000000000000000007B /* AgentGUITerminalInjecting.swift in Sources */, + A6E17000000000000000007C /* AgentGUITerminalInjectionResult.swift in Sources */, + A6E17000000000000000007D /* AgentGUITerminalInjector.swift in Sources */, + A6E170000000000000000073 /* AgentGUIToolPairingIndex.swift in Sources */, + A6E170000000000000000074 /* AgentGUITranscriptDecoderBox.swift in Sources */, + E1A700000000000000000001 /* AgentGUITranscriptImageStore.swift in Sources */, + A6E170000000000000000075 /* AgentGUITranscriptResolver.swift in Sources */, + A6E170000000000000000076 /* AgentGUIWirePublisher.swift in Sources */, 8997C00E8997C00E8997C00E /* AgentHibernationController+CommittedTerminationCleanup.swift in Sources */, 8997C00F8997C00F8997C00F /* AgentHibernationController+CommittedTerminationObservation.swift in Sources */, F65760100000000000000001 /* AgentHibernationController+Confirmation.swift in Sources */, @@ -8511,6 +8676,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B79500140000000000000001 /* AgentPortSnapshotReplacementState.swift in Sources */, B79500110000000000000001 /* AgentPortTrackingState.swift in Sources */, 89930002AABBCCDDEEFF0001 /* AgentProcessBindingResolution.swift in Sources */, + A6E170000000000000000077 /* AgentProcessExitWatcher.swift in Sources */, + A6E170000000000000000078 /* AgentProcessObservationSource.swift in Sources */, 0A1107110000000000000012 /* AgentRelaunchCommandBuilder.swift in Sources */, 93840003AA11BB22CC33EE03 /* AgentRelayTTYTargetResolution.swift in Sources */, 0A1107110000000000000014 /* AgentRestoreMode.swift in Sources */, @@ -10306,6 +10473,12 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C7A51D000000000000000002 /* AgentChatSessionRegistryObservationTests.swift in Sources */, 93800001AA11BB22CC33EE01 /* AgentDeliveryTTYBindingTests.swift in Sources */, A9E020000000000000000005 /* AgentExecutableResolverTests.swift in Sources */, + A6E17000000000000000012F /* AgentGUIAskRegistryTests.swift in Sources */, + A6E17000000000000000012C /* AgentGUIHookFactMapperTests.swift in Sources */, + A6E17000000000000000012D /* AgentGUIJournalPipelineTests.swift in Sources */, + A6E17000000000000000012E /* AgentGUIRPCHandlerTests.swift in Sources */, + A6E170000000000000000130 /* AgentGUISendLedgerTests.swift in Sources */, + D0C0A3110000000000000003 /* AgentGUIStreamProducerTests.swift in Sources */, 8740E0018740E0018740E001 /* AgentHibernationEvaluationSchedulingTests.swift in Sources */, 9090E0039090E0039090E003 /* AgentHibernationPanelCloseCleanupTests.swift in Sources */, F65760010000000000000001 /* AgentHibernationPlannerSwiftTests.swift in Sources */, @@ -10523,6 +10696,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A91C0D110000000000000002 /* CompletedRestoredAgentForkAvailabilityTests.swift in Sources */, E5807290DBD44C26BFC5434E /* CompletedRestoredAgentGenerationTests.swift in Sources */, A91C0D100000000000000002 /* CompletedRestoredAgentInputRoutingTests.swift in Sources */, + A6E170000000000000000131 /* ControlledAgentProcessObservationCapturer.swift in Sources */, C65930010000000000000002 /* CrashDiagnosticSessionPolicyTests.swift in Sources */, DEBDADADADADADADAD000003 /* DebugDogfoodCredentialResolverTests.swift in Sources */, C0DE31410000000000000103 /* DebugEventLogSerializedAppendTests.swift in Sources */, @@ -11541,6 +11715,18 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = isa = XCLocalSwiftPackageReference; relativePath = Packages/macOS/CmuxFoundation; }; + A6E170000000000000000190 /* XCLocalSwiftPackageReference "CmuxAgentReplica" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/Shared/CmuxAgentReplica; + }; + A6E170000000000000000191 /* XCLocalSwiftPackageReference "CmuxAgentWire" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/Shared/CmuxAgentWire; + }; + A6E170000000000000000192 /* XCLocalSwiftPackageReference "CmuxAgentTruthKit" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/macOS/CmuxAgentTruthKit; + }; CE57E10000000000000000A1 /* XCLocalSwiftPackageReference "CmuxSentryTelemetry" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/Shared/CmuxSentryTelemetry; @@ -11892,6 +12078,21 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = package = C1A072000000000000000001 /* XCLocalSwiftPackageReference "CmuxMobileRPC" */; productName = CmuxMobileRPC; }; + A6E17000000000000000019A /* CmuxAgentReplica */ = { + isa = XCSwiftPackageProductDependency; + package = A6E170000000000000000190 /* XCLocalSwiftPackageReference "CmuxAgentReplica" */; + productName = CmuxAgentReplica; + }; + A6E17000000000000000019B /* CmuxAgentWire */ = { + isa = XCSwiftPackageProductDependency; + package = A6E170000000000000000191 /* XCLocalSwiftPackageReference "CmuxAgentWire" */; + productName = CmuxAgentWire; + }; + A6E17000000000000000019C /* CmuxAgentTruthKit */ = { + isa = XCSwiftPackageProductDependency; + package = A6E170000000000000000192 /* XCLocalSwiftPackageReference "CmuxAgentTruthKit" */; + productName = CmuxAgentTruthKit; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = A5001070 /* Project object */; diff --git a/cmux.xcworkspace/contents.xcworkspacedata b/cmux.xcworkspace/contents.xcworkspacedata index 8e4a48c4fd2f..a796067ef1bd 100644 --- a/cmux.xcworkspace/contents.xcworkspacedata +++ b/cmux.xcworkspace/contents.xcworkspacedata @@ -13,6 +13,15 @@ + + + + + + @@ -47,6 +56,9 @@ + + @@ -108,6 +120,9 @@ + + diff --git a/cmuxTests/AgentGUIAskRegistryTests.swift b/cmuxTests/AgentGUIAskRegistryTests.swift new file mode 100644 index 000000000000..76dfb7c5d043 --- /dev/null +++ b/cmuxTests/AgentGUIAskRegistryTests.swift @@ -0,0 +1,216 @@ +import CmuxAgentReplica +import CmuxAgentWire +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite(.serialized) +@MainActor +struct AgentGUIAskRegistryTests { + @Test func entryBeforeNeedsInputCreatesAskOnPhaseTransition() { + let injector = FakeAskRegistryInjector() + var published: [PendingAsk] = [] + let registry = AgentGUIAskRegistry(clock: { 1_000 }, injector: injector, publish: { published.append($0) }) + + registry.handleSessionSnapshot(Self.snapshot(phase: .working)) + registry.handleJournalEvent(.appended(journalID: Self.journalID, entries: [Self.questionEntry(seq: 1)]), sessionID: Self.sessionID) + #expect(published.isEmpty) + + registry.handleSessionSnapshot(Self.snapshot(phase: .needsInput)) + + #expect(published.count == 1) + #expect(published.first?.id == AgentGUIAskRegistry.askID(journalID: Self.journalID, seq: EntrySeq(rawValue: 1))) + #expect(published.first?.kind == .question) + #expect(published.first?.state == .active) + } + + @Test func createsQuestionAndPermissionAsksWhileNeedsInput() { + let injector = FakeAskRegistryInjector() + var published: [PendingAsk] = [] + let registry = AgentGUIAskRegistry(clock: { 1_000 }, injector: injector, publish: { published.append($0) }) + registry.handleSessionSnapshot(Self.snapshot(phase: .needsInput)) + + registry.handleJournalEvent(.appended(journalID: Self.journalID, entries: [Self.questionEntry(seq: 1)]), sessionID: Self.sessionID) + registry.handleJournalEvent(.appended(journalID: Self.journalID, entries: [Self.permissionEntry(seq: 2)]), sessionID: Self.sessionID) + + let activeAsks = published.filter { $0.state == .active } + #expect(activeAsks.map(\.kind) == [.question, .permission]) + #expect(activeAsks.map(\.optionsCount) == [2, 2]) + #expect(activeAsks.map(\.options) == [["A", "B"], ["Deny", "Allow"]]) + } + + @Test func answerSendsOneBasedDigitAndIsIdempotent() throws { + let injector = FakeAskRegistryInjector() + var published: [PendingAsk] = [] + let registry = AgentGUIAskRegistry(clock: { 1_000 }, injector: injector, publish: { published.append($0) }) + registry.handleSessionSnapshot(Self.snapshot(phase: .needsInput)) + registry.handleJournalEvent(.appended(journalID: Self.journalID, entries: [Self.questionEntry(seq: 1)]), sessionID: Self.sessionID) + let askID = AgentGUIAskRegistry.askID(journalID: Self.journalID, seq: EntrySeq(rawValue: 1)) + + let first = try registry.answer(params: GuiAnswerParams(sessionID: Self.sessionID, askID: askID, choiceIndex: 1)) + let second = try registry.answer(params: GuiAnswerParams(sessionID: Self.sessionID, askID: askID, choiceIndex: 1)) + + #expect(first == GuiAnswerResult(answered: true)) + #expect(second == GuiAnswerResult(answered: true)) + #expect(injector.inputs == ["2"]) + #expect(published.last?.state == .answered(choice: 1)) + } + + @Test func newerEntrySupersedesActiveAsk() { + let injector = FakeAskRegistryInjector() + var published: [PendingAsk] = [] + let registry = AgentGUIAskRegistry(clock: { 1_000 }, injector: injector, publish: { published.append($0) }) + registry.handleSessionSnapshot(Self.snapshot(phase: .needsInput)) + registry.handleJournalEvent(.appended(journalID: Self.journalID, entries: [Self.questionEntry(seq: 1)]), sessionID: Self.sessionID) + registry.handleJournalEvent(.appended(journalID: Self.journalID, entries: [Self.agentEntry(seq: 2)]), sessionID: Self.sessionID) + + #expect(published.last?.state == .superseded) + } + + @Test func journalResetSupersedesActiveAskFromReplacedJournal() throws { + let injector = FakeAskRegistryInjector() + var published: [PendingAsk] = [] + let registry = AgentGUIAskRegistry(clock: { 1_000 }, injector: injector, publish: { published.append($0) }) + registry.handleSessionSnapshot(Self.snapshot(phase: .needsInput)) + registry.handleJournalEvent( + .appended(journalID: Self.journalID, entries: [Self.questionEntry(seq: 1)]), + sessionID: Self.sessionID + ) + let askID = AgentGUIAskRegistry.askID(journalID: Self.journalID, seq: EntrySeq(rawValue: 1)) + + registry.handleJournalEvent( + .reset(journalID: JournalID(rawValue: "journal-2"), tailSeq: EntrySeq(rawValue: 0)), + sessionID: Self.sessionID + ) + + #expect(published.map(\.state) == [.active, .superseded]) + let answer = try registry.answer( + params: GuiAnswerParams(sessionID: Self.sessionID, askID: askID, choiceIndex: 0) + ) + #expect(answer == GuiAnswerResult(answered: false)) + #expect(injector.inputs.isEmpty) + } + + @Test func activeAskExpiresAfterTimeout() { + var now = 1_000 + let injector = FakeAskRegistryInjector() + var published: [PendingAsk] = [] + let registry = AgentGUIAskRegistry(clock: { now }, injector: injector, publish: { published.append($0) }) + registry.handleSessionSnapshot(Self.snapshot(phase: .needsInput)) + registry.handleJournalEvent(.appended(journalID: Self.journalID, entries: [Self.questionEntry(seq: 1)]), sessionID: Self.sessionID) + + now += AgentGUIConstants.askTimeoutMS + registry.expire() + + #expect(published.last?.state == .expired) + } + + @Test func removingSessionPrunesAnswersAndPendingEntries() { + let injector = FakeAskRegistryInjector() + var published: [PendingAsk] = [] + let registry = AgentGUIAskRegistry(clock: { 1_000 }, injector: injector, publish: { published.append($0) }) + registry.handleSessionSnapshot(Self.snapshot(phase: .needsInput)) + registry.handleJournalEvent(.appended(journalID: Self.journalID, entries: [Self.questionEntry(seq: 1)]), sessionID: Self.sessionID) + let askID = AgentGUIAskRegistry.askID(journalID: Self.journalID, seq: EntrySeq(rawValue: 1)) + registry.handleSessionSnapshot(Self.snapshot(phase: .working)) + registry.handleJournalEvent(.appended(journalID: Self.journalID, entries: [Self.permissionEntry(seq: 2)]), sessionID: Self.sessionID) + + registry.removeSession(Self.sessionID) + let publishedCountAtRemoval = published.count + registry.handleSessionSnapshot(Self.snapshot(phase: .needsInput)) + + do { + _ = try registry.answer(params: GuiAnswerParams(sessionID: Self.sessionID, askID: askID, choiceIndex: 0)) + Issue.record("removed ask should not be answerable") + } catch let error as AgentGUIRPCError { + #expect(error.code == "not_found") + } catch { + Issue.record("unexpected error: \(error)") + } + #expect(!registry.hasPendingExpirations) + #expect(published.count == publishedCountAtRemoval) + } + + private static let sessionID = AgentSessionID(rawValue: "session-1") + private static let journalID = JournalID(rawValue: "journal-1") + private static let surfaceID = UUID().uuidString + + private static func snapshot(phase: SessionPhase) -> AgentSessionSnapshot { + AgentSessionSnapshot( + id: Self.sessionID, + macDeviceID: MacDeviceID(rawValue: "mac-1"), + kind: .codex, + phase: phase, + tier: .wrapped, + surfaceID: Self.surfaceID, + cwd: "/repo", + title: "Session", + workspaceName: "Workspace", + version: EntityVersion(rawValue: 1), + lastActivityHint: 1 + ) + } + + private static func questionEntry(seq: Int) -> EntrySnapshot { + EntrySnapshot( + journalID: Self.journalID, + seq: EntrySeq(rawValue: seq), + kind: .question, + content: EntryContent( + contentHash: seq, + payload: .question(QuestionPayload(prompt: "Pick one", options: ["A", "B"])) + ), + version: EntityVersion(rawValue: 1) + ) + } + + private static func permissionEntry(seq: Int) -> EntrySnapshot { + EntrySnapshot( + journalID: Self.journalID, + seq: EntrySeq(rawValue: seq), + kind: .permission, + content: EntryContent( + contentHash: seq, + payload: .permission(PermissionPayload(toolName: "shell", detail: "Run command?", options: ["Deny", "Allow"])) + ), + version: EntityVersion(rawValue: 1) + ) + } + + private static func agentEntry(seq: Int) -> EntrySnapshot { + EntrySnapshot( + journalID: Self.journalID, + seq: EntrySeq(rawValue: seq), + kind: .agentProse, + content: EntryContent( + contentHash: seq, + payload: .agentProse(AgentProsePayload(markdown: "moving on")) + ), + version: EntityVersion(rawValue: 1) + ) + } +} + +@MainActor +private final class FakeAskRegistryInjector: AgentGUITerminalInjecting { + var inputs: [String] = [] + var result: AgentGUITerminalInjectionResult = .accepted + + func submitPrompt(surfaceID: String, text: String) -> AgentGUITerminalInjectionResult { + result + } + + func sendKey(surfaceID: String, keyName: String) -> AgentGUITerminalInjectionResult { + result + } + + func sendInput(surfaceID: String, text: String) -> AgentGUITerminalInjectionResult { + inputs.append(text) + return result + } +} diff --git a/cmuxTests/AgentGUIHookFactMapperTests.swift b/cmuxTests/AgentGUIHookFactMapperTests.swift new file mode 100644 index 000000000000..1ac20dfd0ec1 --- /dev/null +++ b/cmuxTests/AgentGUIHookFactMapperTests.swift @@ -0,0 +1,178 @@ +import CMUXAgentLaunch +import CmuxAgentReplica +import CmuxAgentTruthKit +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite struct AgentGUIHookFactMapperTests { + @Test @MainActor + func processObservationCoalescesInflightRequestsIntoOneFollowUp() async { + let capturer = ControlledAgentProcessObservationCapturer() + var deliveryCount = 0 + let source = AgentProcessObservationSource( + captureObservations: { await capturer.capture() }, + onObservations: { _ in deliveryCount += 1 } + ) + + source.scanNow() + await capturer.waitForCallCount(1) + source.scanNow() + source.scanNow() + source.scanNow() + #expect(await capturer.callCount() == 1) + + await capturer.release(call: 1) + await capturer.waitForCallCount(2) + #expect(await capturer.callCount() == 2) + await capturer.release(call: 2) + await source.waitForIdleForTesting() + + #expect(await capturer.callCount() == 2) + #expect(deliveryCount == 2) + } + + @Test @MainActor + func stoppingAnInflightObservationAllowsImmediateReplacementScan() async { + let capturer = ControlledAgentProcessObservationCapturer() + var deliveryCount = 0 + let source = AgentProcessObservationSource( + captureObservations: { await capturer.capture() }, + onObservations: { _ in deliveryCount += 1 } + ) + + source.scanNow() + await capturer.waitForCallCount(1) + source.setRunning(false) + source.scanNow() + await capturer.waitForCallCount(2) + + await capturer.release(call: 2) + await source.waitForIdleForTesting() + #expect(deliveryCount == 1) + + await capturer.release(call: 1) + await Task.yield() + #expect(deliveryCount == 1) + } + + @Test func processKindUsesPreciseExecutableNamesWithoutRequiringLaunchMetadata() { + #expect(AgentProcessObservationSource.agentKind( + arguments: ["/usr/local/bin/claude"], + environment: [:], + processName: "claude" + ) == .claude) + #expect(AgentProcessObservationSource.agentKind( + arguments: ["/usr/local/bin/node", "/x/claude"], + environment: [:], + processName: "node" + ) == .claude) + #expect(AgentProcessObservationSource.agentKind( + arguments: ["/usr/bin/grep", "claude"], + environment: [:], + processName: "grep" + ) == .unknown("unknown")) + #expect(AgentProcessObservationSource.agentKind( + arguments: ["/usr/local/bin/claude-notes"], + environment: [:], + processName: "claude-notes" + ) == .unknown("unknown")) + #expect(AgentProcessObservationSource.agentKind( + arguments: ["/usr/local/bin/my-codex-tool"], + environment: [:], + processName: "my-codex-tool" + ) == .unknown("unknown")) + #expect(AgentProcessObservationSource.agentKind( + arguments: ["/usr/local/bin/codex"], + environment: ["CMUX_AGENT_LAUNCH_KIND": "claude"], + processName: "codex" + ) == .claude) + #expect(AgentProcessObservationSource.agentKind( + arguments: ["/opt/homebrew/bin/codex"], + environment: [:], + processName: "codex" + ) == .codex) + } + + @Test func mapsKnownHookNamesAndUnknowns() { + let mapper = AgentGUIHookFactMapper() + + let start = mapper.hookFact( + sessionID: "session-1", + rawHookName: "SessionStart", + surfaceID: "surface-1", + transcriptPath: "/tmp/session.jsonl", + cwd: "/repo", + pid: 123, + source: "codex", + toolInputJSON: nil, + extraFieldsJSON: nil + ) + #expect(start.eventName == .sessionStart) + #expect(start.sessionID == AgentSessionID(rawValue: "session-1")) + #expect(start.surfaceID == "surface-1") + #expect(start.transcriptPath == "/tmp/session.jsonl") + #expect(start.cwd == "/repo") + #expect(start.pid == 123) + + let permission = mapper.hookFact( + sessionID: "session-1", + rawHookName: "PermissionRequest", + surfaceID: nil, + transcriptPath: nil, + cwd: nil, + pid: nil, + source: "claude", + toolInputJSON: nil, + extraFieldsJSON: nil + ) + #expect(permission.eventName == .permissionRequest) + #expect(permission.notificationRequiresInput) + + let unknown = mapper.hookFact( + sessionID: "session-1", + rawHookName: "FutureHook", + surfaceID: nil, + transcriptPath: nil, + cwd: nil, + pid: nil, + source: "codex", + toolInputJSON: nil, + extraFieldsJSON: "{\"requires_input\":true}" + ) + #expect(unknown.eventName == .unknown("FutureHook")) + #expect(unknown.notificationRequiresInput) + } + + @Test func wrapperLaunchFactRequiresExplicitWrapperOrigin() { + let mapper = AgentGUIHookFactMapper() + let plain = WorkstreamEvent( + sessionId: "session-1", + hookEventName: .sessionStart, + source: "codex", + surfaceId: "surface-1", + cwd: "/repo", + ppid: 123 + ) + #expect(mapper.wrapperLaunchFact(from: plain) == nil) + + let wrapped = WorkstreamEvent( + sessionId: "session-1", + hookEventName: .sessionStart, + source: "codex", + surfaceId: "surface-1", + cwd: "/repo", + ppid: 123, + extraFieldsJSON: "{\"wrapper_origin\":\"cmux-wrapper\",\"launch_argv_kind\":\"resume\"}" + ) + let fact = mapper.wrapperLaunchFact(from: wrapped) + #expect(fact?.surfaceID == "surface-1") + #expect(fact?.agentKind == .codex) + #expect(fact?.launchArgvKind == .resume) + } +} diff --git a/cmuxTests/AgentGUIJournalPipelineTests.swift b/cmuxTests/AgentGUIJournalPipelineTests.swift new file mode 100644 index 000000000000..aedac4c418da --- /dev/null +++ b/cmuxTests/AgentGUIJournalPipelineTests.swift @@ -0,0 +1,1016 @@ +import CmuxAgentChat +import CmuxAgentReplica +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite(.serialized) +@MainActor +struct AgentGUIJournalPipelineTests { + @Test func embeddedClaudeImageIsMaterializedAndAuthorizedBeforePublish() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("agent-gui-inline-image-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let transcript = directory.appendingPathComponent("claude.jsonl") + let cache = directory.appendingPathComponent("image-cache", isDirectory: true) + let sessionID = AgentSessionID(rawValue: "session-inline-image") + let base64PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" + let line = #"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"See screenshot."},{"type":"image","file_name":"screenshot.png","source":{"type":"base64","media_type":"image/png","data":"\#(base64PNG)"}}]}}"# + try write(lines: [line], to: transcript) + let imageStore = AgentGUITranscriptImageStore(rootURL: cache) + let pipeline = AgentGUIJournalPipeline( + sessionID: sessionID, + kind: .claude, + path: transcript.path, + imageStore: imageStore + ) + + _ = await pipeline.ingestInitial() + + let page = try #require(pipeline.entries(beforeSeq: nil, afterSeq: nil, limit: 10)) + let attachment = try #require(page.entries.compactMap { entry -> AttachmentPayload? in + guard case .attachment(let payload) = entry.content.payload else { return nil } + return payload + }.first) + let hostPath = try #require(attachment.hostPath) + #expect(FileManager.default.fileExists(atPath: hostPath)) + #expect(hostPath.hasPrefix(cache.path + "/")) + #expect(attachment.mimeType == "image/png") + #expect(attachment.byteCount != nil) + #expect(attachment.width == 1) + #expect(attachment.height == 1) + + let indexed = try await AgentChatArtifactIndex.shared.snapshot( + sessionID: sessionID.rawValue, + agentKind: .claude, + transcriptPath: transcript.path, + workingDirectory: directory.path + ) + #expect(indexed.referencedPaths.contains(hostPath)) + await AgentChatArtifactIndex.shared.removeSupplementalAttachments(sessionID: sessionID.rawValue) + } + + @Test func invalidClaudeImageDoesNotAuthorizeItsReferencedFile() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("agent-gui-invalid-image-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let transcript = directory.appendingPathComponent("claude.jsonl") + let invalidImage = directory.appendingPathComponent("not-an-image.png") + try Data("plain text".utf8).write(to: invalidImage) + let sessionID = AgentSessionID(rawValue: "session-invalid-image-\(UUID().uuidString)") + let line = #"{"type":"user","message":{"role":"user","content":[{"type":"image","source":{"type":"file","media_type":"image/png","path":"\#(invalidImage.path)"}}]}}"# + try write(lines: [line], to: transcript) + let pipeline = AgentGUIJournalPipeline( + sessionID: sessionID, + kind: .claude, + path: transcript.path + ) + + _ = await pipeline.ingestInitial() + + let result = try await AgentChatArtifactIndex.shared.canonicalPath( + sessionID: sessionID.rawValue, + agentKind: .claude, + transcriptPath: transcript.path, + workingDirectory: directory.path, + requestedPath: invalidImage.path, + operation: .file, + directoryAccessMode: .oneLevel + ) + if case .notInSet = result { + // Expected: failed image validation never expands the artifact scope. + } else { + Issue.record("invalid transcript image path entered the artifact allowlist") + } + await AgentChatArtifactIndex.shared.removeSupplementalAttachments( + sessionID: sessionID.rawValue + ) + } + + @Test func dimensionOnlyImageAttachmentIsEnrichedBeforePublish() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("agent-gui-dimension-image-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let transcript = directory.appendingPathComponent("claude.jsonl") + let image = directory.appendingPathComponent("opaque-preview.dat") + let pngData = try #require(Data(base64Encoded: Self.onePixelPNGBase64)) + try pngData.write(to: image) + let sessionID = AgentSessionID(rawValue: "session-dimension-image-\(UUID().uuidString)") + let line = #"{"type":"attachment","attachment":{"kind":"file","summary":"Generated preview","path":"\#(image.path)","width":1,"height":1}}"# + try write(lines: [line], to: transcript) + let pipeline = AgentGUIJournalPipeline( + sessionID: sessionID, + kind: .claude, + path: transcript.path + ) + + _ = await pipeline.ingestInitial() + + let attachment = try #require(pipeline.entries(beforeSeq: nil, afterSeq: nil, limit: 10)?.entries.compactMap { entry -> AttachmentPayload? in + guard case .attachment(let payload) = entry.content.payload else { return nil } + return payload + }.first) + #expect(attachment.hostPath == image.path) + #expect(attachment.mimeType == "image/png") + #expect(attachment.byteCount == pngData.count) + #expect(attachment.width == 1) + #expect(attachment.height == 1) + await AgentChatArtifactIndex.shared.removeSupplementalAttachments(sessionID: sessionID.rawValue) + } + + @Test func svgAttachmentIsEnrichedAndAuthorizedBeforePublish() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("agent-gui-svg-image-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let transcript = directory.appendingPathComponent("claude.jsonl") + let image = directory.appendingPathComponent("preview.svg") + let svg = #""# + let data = Data(svg.utf8) + try data.write(to: image) + let sessionID = AgentSessionID(rawValue: "session-svg-image-\(UUID().uuidString)") + let line = #"{"type":"attachment","attachment":{"fileName":"preview.svg","path":"\#(image.path)"}}"# + try write(lines: [line], to: transcript) + let pipeline = AgentGUIJournalPipeline( + sessionID: sessionID, + kind: .claude, + path: transcript.path + ) + + _ = await pipeline.ingestInitial() + + let attachment = try #require(pipeline.entries(beforeSeq: nil, afterSeq: nil, limit: 10)?.entries.compactMap { entry -> AttachmentPayload? in + guard case .attachment(let payload) = entry.content.payload else { return nil } + return payload + }.first) + #expect(attachment.hostPath == image.path) + #expect(attachment.mimeType == "image/svg+xml") + #expect(attachment.byteCount == data.count) + #expect(attachment.width == 480) + #expect(attachment.height == 270) + + let indexed = try await AgentChatArtifactIndex.shared.snapshot( + sessionID: sessionID.rawValue, + agentKind: .claude, + transcriptPath: transcript.path, + workingDirectory: directory.path + ) + #expect(indexed.referencedPaths.contains(image.path)) + await AgentChatArtifactIndex.shared.removeSupplementalAttachments(sessionID: sessionID.rawValue) + } + + @Test func codexToolPreviewImagesAreEnrichedBeforePublish() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("agent-gui-tool-preview-image-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let transcript = directory.appendingPathComponent("codex.jsonl") + let firstImage = directory.appendingPathComponent("preview-one") + let secondImage = directory.appendingPathComponent("preview-two") + let pngData = try #require(Data(base64Encoded: Self.onePixelPNGBase64)) + try pngData.write(to: firstImage) + try pngData.write(to: secondImage) + let sessionID = AgentSessionID(rawValue: "session-tool-preview-image-\(UUID().uuidString)") + let arguments = #"{"path":"\#(firstImage.path)","screenshot_path":"\#(secondImage.path)"}"# + let encodedArguments = arguments.replacingOccurrences(of: "\"", with: "\\\"") + let line = #"{"type":"response_item","payload":{"type":"function_call","name":"view_image","call_id":"call-preview","arguments":"\#(encodedArguments)"}}"# + try write(lines: [line], to: transcript) + let pipeline = AgentGUIJournalPipeline( + sessionID: sessionID, + kind: .codex, + path: transcript.path + ) + + _ = await pipeline.ingestInitial() + + let entry = try #require(pipeline.entries(beforeSeq: nil, afterSeq: nil, limit: 10)?.entries.first) + guard case .toolRun(let tool) = entry.content.payload else { + Issue.record("expected the view_image invocation to remain a tool row") + return + } + let previews = try #require(tool.previewAttachments) + #expect(Set(previews.compactMap(\.hostPath)) == Set([firstImage.path, secondImage.path])) + #expect(previews.allSatisfy { $0.mimeType == "image/png" }) + #expect(previews.allSatisfy { $0.byteCount == pngData.count }) + #expect(previews.allSatisfy { $0.width == 1 }) + #expect(previews.allSatisfy { $0.height == 1 }) + + let indexed = try await AgentChatArtifactIndex.shared.snapshot( + sessionID: sessionID.rawValue, + agentKind: .codex, + transcriptPath: transcript.path, + workingDirectory: directory.path + ) + #expect(indexed.referencedPaths.contains(firstImage.path)) + #expect(indexed.referencedPaths.contains(secondImage.path)) + await AgentChatArtifactIndex.shared.removeSupplementalAttachments(sessionID: sessionID.rawValue) + } + + @Test func missingJournalIsAnEmptyPageThenResetsWhenCreated() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("agent-gui-missing-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let transcript = directory.appendingPathComponent("not-created-yet.jsonl") + let pipeline = AgentGUIJournalPipeline( + sessionID: AgentSessionID(rawValue: "session-empty"), + kind: .codex, + path: transcript.path + ) + + let emptyEvents = await pipeline.ingestInitial() + let emptyPage = try #require(pipeline.entries(beforeSeq: nil, afterSeq: nil, limit: 10)) + #expect(emptyPage.entries.isEmpty) + #expect(emptyPage.tailSeq == EntrySeq(rawValue: 0)) + #expect(emptyPage.windowStart == EntrySeq(rawValue: 0)) + #expect(emptyPage.windowEnd == EntrySeq(rawValue: 0)) + #expect(emptyPage.hasMoreBefore == false) + let emptyDiskPage = try #require(await pipeline.diskEntries(direction: .tail, limit: 10)) + #expect(emptyDiskPage.entries.isEmpty) + #expect(emptyDiskPage.tailSeq == EntrySeq(rawValue: 0)) + #expect(emptyDiskPage.startOffset == 0) + #expect(emptyDiskPage.endOffset == 0) + #expect(emptyDiskPage.hasMoreBefore == false) + #expect(emptyDiskPage.hasMoreAfter == false) + guard case .reset(let pendingJournal, let tail)? = emptyEvents.first else { + Issue.record("missing journal should establish an empty replica journal") + return + } + #expect(tail == EntrySeq(rawValue: 0)) + + try write(lines: [Self.codexMessageLine], to: transcript) + let createdEvents = await pipeline.ingestInitial() + let createdPage = try #require(pipeline.entries(beforeSeq: nil, afterSeq: nil, limit: 10)) + #expect(createdPage.journalID != pendingJournal) + #expect(createdPage.entries.count == 1) + #expect(createdEvents.contains { event in + if case .reset(let journalID, _) = event { return journalID == createdPage.journalID } + return false + }) + } + + @Test func pathThatCannotBeReadDoesNotMasqueradeAsEmpty() async { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("agent-gui-unreadable-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let pipeline = AgentGUIJournalPipeline( + sessionID: AgentSessionID(rawValue: "session-unreadable"), + kind: .codex, + path: directory.path + ) + + _ = await pipeline.ingestInitial() + + #expect(pipeline.lastReadFailed) + #expect(pipeline.entries(beforeSeq: nil, afterSeq: nil, limit: 10) == nil) + #expect(await pipeline.diskEntries(direction: .tail, limit: 10) == nil) + } + + @Test func pairsToolResultsAsReplacementsAndResetsAfterRotation() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("agent-gui-journal-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let transcript = directory.appendingPathComponent("rollout.jsonl") + try write(lines: [Self.codexToolCallLine], to: transcript) + + let pipeline = AgentGUIJournalPipeline( + sessionID: AgentSessionID(rawValue: "session-1"), + kind: .codex, + path: transcript.path + ) + let initialEvents = await pipeline.ingestInitial() + guard case let .reset(initialJournal, initialTail)? = initialEvents.first else { + return #expect(Bool(false), "initial ingest should reset the journal") + } + #expect(initialTail == EntrySeq(rawValue: 0)) + + let initialPage = try #require(pipeline.entries(beforeSeq: nil, afterSeq: nil, limit: 10)) + #expect(initialPage.entries.map(\.seq) == [EntrySeq(rawValue: 0)]) + #expect(initialPage.entries.first?.version == EntityVersion(rawValue: 1)) + + try append(lines: [Self.codexToolOutputLine, Self.codexMessageLine], to: transcript) + let deltaEvents = await pipeline.ingestInitial() + let replaced = deltaEvents.compactMap { event -> EntrySnapshot? in + if case .replaced(_, let entry) = event { return entry } + return nil + } + let appended = deltaEvents.flatMap { event -> [EntrySnapshot] in + if case .appended(_, let entries) = event { return entries } + return [] + } + #expect(replaced.map(\.seq) == [EntrySeq(rawValue: 0)]) + #expect(replaced.first?.version == EntityVersion(rawValue: 2)) + let messageOffset = (Self.codexToolCallLine + "\n").utf8.count + + (Self.codexToolOutputLine + "\n").utf8.count + #expect(appended.map(\.seq) == [EntrySeq(rawValue: messageOffset)]) + + try FileManager.default.removeItem(at: transcript) + try write(lines: [Self.codexMessageLine], to: transcript) + let rotatedEvents = await pipeline.ingestInitial() + guard case let .reset(rotatedJournal, rotatedTail)? = rotatedEvents.first else { + return #expect(Bool(false), "rotated transcript should reset the journal") + } + #expect(rotatedJournal != initialJournal) + #expect(rotatedTail == EntrySeq(rawValue: 0)) + } + + @Test func pagesBackThroughWindowAndKeepsHasMoreBeforeHonest() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("agent-gui-paging-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let transcript = directory.appendingPathComponent("paging.jsonl") + try write(lines: Array(repeating: Self.codexMessageLine, count: 500), to: transcript) + + let pipeline = AgentGUIJournalPipeline( + sessionID: AgentSessionID(rawValue: "session-2"), + kind: .codex, + path: transcript.path + ) + _ = await pipeline.ingestInitial() + + let page1 = try #require(pipeline.entries(beforeSeq: nil, afterSeq: nil, limit: 200)) + #expect(page1.entries.count == 200) + #expect(page1.hasMoreBefore == true) + + let page2 = try #require(pipeline.entries(beforeSeq: page1.windowStart, afterSeq: nil, limit: 200)) + #expect(page2.entries.count == 200) + #expect(page2.hasMoreBefore == true) + + let page3 = try #require(pipeline.entries(beforeSeq: page2.windowStart, afterSeq: nil, limit: 200)) + #expect(page3.entries.count == 100) + #expect(page3.hasMoreBefore == false) + + try append(lines: [Self.codexMessageLine], to: transcript) + _ = await pipeline.ingestInitial() + #expect((pipeline.window?.entriesBySeq.count ?? 0) <= AgentGUIConstants.journalWindowEntryCap) + } + + @Test func initialIngestReadsABoundedTailAndAppendsContiguously() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("agent-gui-bounded-tail-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let transcript = directory.appendingPathComponent("bounded.jsonl") + try write(lines: Array(repeating: Self.codexMessageLine, count: 3_000), to: transcript) + let pipeline = AgentGUIJournalPipeline( + sessionID: AgentSessionID(rawValue: "session-bounded"), + kind: .codex, + path: transcript.path + ) + + _ = await pipeline.ingestInitial() + let initial = try #require(pipeline.entries(beforeSeq: nil, afterSeq: nil, limit: 200)) + #expect(pipeline.lastReadByteCount <= AgentGUIConstants.initialTailByteCap) + #expect(pipeline.window?.entriesBySeq.count == AgentGUIConstants.initialTailLineCap) + #expect(initial.hasMoreBefore) + #expect(initial.tailSeq == EntrySeq(rawValue: (3_000 - 1) * Self.messageStride)) + + try append(lines: [Self.codexMessageLine], to: transcript) + _ = await pipeline.ingestInitial() + let appended = try #require(pipeline.entries(beforeSeq: nil, afterSeq: nil, limit: 1)) + #expect(appended.entries.last?.seq == EntrySeq(rawValue: 3_000 * Self.messageStride)) + } + + @Test func oversizedIncrementalBacklogRebasesFromBoundedTail() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("agent-gui-bounded-backlog-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let transcript = directory.appendingPathComponent("backlog.jsonl") + try write(lines: [Self.codexMessageLine], to: transcript) + let pipeline = AgentGUIJournalPipeline( + sessionID: AgentSessionID(rawValue: "session-bounded-backlog"), + kind: .codex, + path: transcript.path + ) + _ = await pipeline.ingestInitial() + + let oversizedBookkeeping = #"{"type":"turn_context","payload":{"padding":""# + + String(repeating: "x", count: AgentGUIConstants.journalIncrementalByteCap + 1_024) + + #""}}"# + let trailingLines = (0..<20).map(Self.codexMessageLine(index:)) + try append(lines: [oversizedBookkeeping] + trailingLines, to: transcript) + let events = await pipeline.ingestInitial() + + #expect(pipeline.lastReadByteCount <= AgentGUIConstants.initialTailByteCap) + #expect(events.contains { event in + if case .reset = event { return true } + return false + }) + let page = try #require(pipeline.entries(beforeSeq: nil, afterSeq: nil, limit: 50)) + #expect(page.entries.count == trailingLines.count) + #expect(page.entries.first?.seq.rawValue ?? 0 > AgentGUIConstants.journalIncrementalByteCap) + } + + @Test func successiveInPlaceTruncationsMintDistinctJournals() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("agent-gui-truncate-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let transcript = directory.appendingPathComponent("truncate.jsonl") + try write(lines: [Self.codexMessageLine], to: transcript) + + let pipeline = AgentGUIJournalPipeline( + sessionID: AgentSessionID(rawValue: "session-3"), + kind: .codex, + path: transcript.path + ) + let firstReset = await pipeline.ingestInitial() + guard case let .reset(firstJournal, _)? = firstReset.first else { + return #expect(Bool(false), "initial ingest should reset the journal") + } + + try truncateAndRewrite(transcript, lines: [Self.codexToolCallLine]) + let secondReset = await pipeline.ingestInitial() + guard case let .reset(secondJournal, _)? = secondReset.first else { + return #expect(Bool(false), "first truncation should reset the journal") + } + #expect(secondJournal != firstJournal) + + try truncateAndRewrite(transcript, lines: [Self.codexToolOutputLine]) + let thirdReset = await pipeline.ingestInitial() + guard case let .reset(thirdJournal, _)? = thirdReset.first else { + return #expect(Bool(false), "second truncation should reset the journal") + } + #expect(thirdJournal != secondJournal) + #expect(thirdJournal != firstJournal) + } + + @Test func concurrentIngestsShareOneReadPass() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("agent-gui-concurrent-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let transcript = directory.appendingPathComponent("concurrent.jsonl") + try write(lines: Array(repeating: Self.codexMessageLine, count: 500), to: transcript) + let pipeline = AgentGUIJournalPipeline( + sessionID: AgentSessionID(rawValue: "session-4"), + kind: .codex, + path: transcript.path + ) + + async let firstIngest = pipeline.ingestInitial() + async let secondIngest = pipeline.ingestInitial() + _ = await (firstIngest, secondIngest) + + try append(lines: [Self.codexMessageLine], to: transcript) + _ = await pipeline.ingestInitial() + let page = try #require(pipeline.entries(beforeSeq: nil, afterSeq: nil, limit: 200)) + #expect(page.tailSeq == EntrySeq(rawValue: 500 * Self.messageStride)) + #expect(page.entries.last?.seq == EntrySeq(rawValue: 500 * Self.messageStride)) + } + + @Test func stableHeadShrinkResetsBeforeRegrowth() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("agent-gui-shrink-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let transcript = directory.appendingPathComponent("shrink.jsonl") + try write(lines: Array(repeating: Self.codexMessageLine, count: 3), to: transcript) + let pipeline = AgentGUIJournalPipeline( + sessionID: AgentSessionID(rawValue: "session-5"), + kind: .codex, + path: transcript.path + ) + let initialEvents = await pipeline.ingestInitial() + guard case let .reset(initialJournal, _)? = initialEvents.first else { + return #expect(Bool(false), "initial ingest should reset the journal") + } + + try truncateAndRewrite(transcript, lines: [Self.codexMessageLine]) + let shrinkEvents = await pipeline.ingestInitial() + guard case let .reset(shrunkenJournal, _)? = shrinkEvents.first else { + return #expect(Bool(false), "same-head shrink should reset the journal") + } + #expect(shrunkenJournal != initialJournal) + + try append(lines: [Self.codexMessageLine, Self.codexMessageLine], to: transcript) + _ = await pipeline.ingestInitial() + let page = try #require(pipeline.entries(beforeSeq: nil, afterSeq: nil, limit: 10)) + #expect(page.entries.map(\.seq) == [ + EntrySeq(rawValue: 0), + EntrySeq(rawValue: Self.messageStride), + EntrySeq(rawValue: 2 * Self.messageStride), + ]) + } + + @Test func buffersSplitUTF8ScalarUntilLineCompletion() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("agent-gui-utf8-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let transcript = directory.appendingPathComponent("utf8.jsonl") + try write(lines: [Self.codexMessageLine], to: transcript) + let pipeline = AgentGUIJournalPipeline( + sessionID: AgentSessionID(rawValue: "session-6"), + kind: .codex, + path: transcript.path + ) + _ = await pipeline.ingestInitial() + + let line = Self.codexMessageLineWithMultibyteText + let bytes = Data(line.utf8) + let leadIndex = try #require(bytes.firstIndex(of: 0xC3)) + let splitIndex = bytes.index(after: leadIndex) + try append(data: Data(bytes[.. 0) + let backward = AgentGUIJournalPageReader.read( + path: transcript.path, + direction: .before(first.endOffset), + lineLimit: 1, + byteLimit: 64 + ) + #expect(backward.lines.map(\.text) == [oversized]) + #expect(backward.startOffset == 0) + #expect(backward.endOffset == first.endOffset) + let next = AgentGUIJournalPageReader.read( + path: transcript.path, + direction: .after(first.endOffset), + lineLimit: 1, + byteLimit: 1_024 + ) + #expect(next.lines.map(\.text) == [Self.codexMessageLine]) + } + + @Test func recordsBeyondMaximumReadCapEmitVisibleDiagnosticInBothDirections() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("agent-gui-record-diagnostic-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let transcript = directory.appendingPathComponent("diagnostic.jsonl") + let unsupported = #"{"type":"turn_context","padding":""# + + String(repeating: "x", count: AgentGUIJournalPageReader.maximumRecordByteCount + 1) + + #""}"# + try write(lines: [unsupported, Self.codexMessageLine], to: transcript) + let unsupportedEnd = (unsupported + "\n").utf8.count + + let forward = AgentGUIJournalPageReader.read( + path: transcript.path, + direction: .head, + lineLimit: 1, + byteLimit: 64 + ) + let backward = AgentGUIJournalPageReader.read( + path: transcript.path, + direction: .before(unsupportedEnd), + lineLimit: 1, + byteLimit: 64 + ) + #expect(forward.lines.first?.text.contains("cmux_oversized_record") == true) + #expect(backward.lines.first?.text.contains("cmux_oversized_record") == true) + #expect(forward.endOffset == unsupportedEnd) + #expect(backward.startOffset == 0) + + let pipeline = AgentGUIJournalPipeline( + sessionID: AgentSessionID(rawValue: "session-record-diagnostic"), + kind: .codex, + path: transcript.path + ) + _ = await pipeline.ingestInitial() + let page = try #require(await pipeline.diskEntries(direction: .head, limit: 1)) + guard case .unknown(let diagnostic) = page.entries.first?.content.payload else { + Issue.record("unsupported source record should become a visible GUI diagnostic") + return + } + #expect(diagnostic.rawKind == "cmux_oversized_record") + } + + @Test func backwardReaderKeepsFirstLineWhenByteLimitStartsAtBoundary() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("agent-gui-page-boundary-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let transcript = directory.appendingPathComponent("boundary.jsonl") + let lines = ["first", "second", "third", "fourth"] + try write(lines: lines, to: transcript) + let suffixByteCount = (lines[2] + "\n" + lines[3] + "\n").utf8.count + let fileSize = try #require( + FileManager.default.attributesOfItem(atPath: transcript.path)[.size] as? NSNumber + ).intValue + + let page = AgentGUIJournalPageReader.read( + path: transcript.path, + direction: .before(fileSize), + lineLimit: 10, + byteLimit: suffixByteCount + ) + + #expect(page.readSucceeded) + #expect(page.lines.map(\.text) == ["third", "fourth"]) + #expect(page.startOffset == fileSize - suffixByteCount) + #expect(page.endOffset == fileSize) + } + + @Test func staleJournalAndInvalidCursorResolveToAuthoritativeTailRestart() { + let current = JournalID(rawValue: "current") + let stale = JournalID(rawValue: "stale") + let staleResolution = AgentGUIService.resolvePageRequest( + anchor: .before, + cursor: AgentGUIJournalCursorCodec.encode(journalID: stale, byteOffset: 100), + expectedJournalID: stale, + currentJournalID: current + ) + #expect(staleResolution == AgentGUIService.ResolvedPageRequest( + direction: .tail, + requiresPagingRestart: true + )) + let invalidResolution = AgentGUIService.resolvePageRequest( + anchor: .after, + cursor: JournalCursor(rawValue: "not-a-cursor"), + expectedJournalID: current, + currentJournalID: current + ) + #expect(invalidResolution.direction == .tail) + #expect(invalidResolution.requiresPagingRestart) + } + + private static let codexToolCallLine = """ + {"type":"response_item","payload":{"type":"function_call","call_id":"call-1","name":"shell","arguments":"{\\"command\\":[\\"bash\\",\\"-lc\\",\\"echo ok\\"]}"}} + """ + + private static let codexToolOutputLine = """ + {"type":"response_item","payload":{"type":"function_call_output","call_id":"call-1","output":"ok","exit_code":0}} + """ + + private static let claudeToolCallLine = """ + {"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"tool-1","name":"Bash","input":{"command":"echo ok"}}]}} + """ + + private static let claudeToolOutputLine = """ + {"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool-1","content":"ok","is_error":false}]}} + """ + + private static let codexMessageLine = """ + {"type":"response_item","payload":{"type":"message","role":"assistant","content":"done"}} + """ + + private static func codexMessageLine(index: Int) -> String { + #"{"type":"response_item","payload":{"type":"message","role":"assistant","content":"entry \#(index)"}}"# + } + + private static let codexMessageLineWithMultibyteText = """ + {"type":"response_item","payload":{"type":"message","role":"assistant","content":"caf\u{00E9}"}} + """ + + private static let onePixelPNGBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" + + private static var messageStride: Int { + (codexMessageLine + "\n").utf8.count + } + + private func write(lines: [String], to url: URL) throws { + let text = lines.joined(separator: "\n") + "\n" + try text.write(to: url, atomically: true, encoding: .utf8) + } + + private func append(lines: [String], to url: URL) throws { + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: Data((lines.joined(separator: "\n") + "\n").utf8)) + } + + private func append(data: Data, to url: URL) throws { + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: data) + } + + private func truncateAndRewrite(_ url: URL, lines: [String]) throws { + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + try handle.truncate(atOffset: 0) + try handle.seekToEnd() + try handle.write(contentsOf: Data((lines.joined(separator: "\n") + "\n").utf8)) + } +} diff --git a/cmuxTests/AgentGUIRPCHandlerTests.swift b/cmuxTests/AgentGUIRPCHandlerTests.swift new file mode 100644 index 000000000000..3a5b8dded3d2 --- /dev/null +++ b/cmuxTests/AgentGUIRPCHandlerTests.swift @@ -0,0 +1,299 @@ +import CMUXAgentLaunch +import CmuxAgentReplica +import CmuxAgentTruthKit +import CmuxAgentWire +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite(.serialized) +@MainActor +struct AgentGUIRPCHandlerTests { + @Test func helloAdvertisesSendAndAnswerCapabilities() async throws { + let service = AgentGUIService(macDeviceID: "mac-test") + let handler = AgentGUIRPCHandler(service: service) + + let result = await handler.handle(MobileHostRPCRequest( + id: nil, + method: GuiWireMethod.hello, + params: try AgentGUICodableBridge.dictionary(GuiHelloParams(protocolMin: 1, protocolMax: 1, clientCaps: [])), + auth: nil + )) + let payload = try Self.okPayload(result) + let data = try JSONSerialization.data(withJSONObject: payload) + let decoded = try JSONDecoder().decode(GuiHelloResult.self, from: data) + + #expect(decoded.serverCaps.contains(GuiWireCaps.sendTickets)) + #expect(decoded.serverCaps.contains(GuiWireCaps.answers)) + } + + @Test func sessionsResultEncodesWireShapeFromReducerSnapshot() throws { + let service = AgentGUIService(macDeviceID: "mac-test") + service.handleHookEventSerial(WorkstreamEvent( + sessionId: "session-1", + hookEventName: .sessionStart, + source: "codex", + surfaceId: "surface-1", + cwd: "/repo", + ppid: 123 + )) + + let dictionary = try AgentGUICodableBridge.dictionary(service.sessionsResult()) + let data = try JSONSerialization.data(withJSONObject: dictionary, options: [.sortedKeys]) + let decoded = try JSONDecoder().decode(GuiSessionsResult.self, from: data) + + #expect(decoded.epoch == service.epoch) + #expect(decoded.sessions.count == 1) + #expect(decoded.sessions.first?.id.rawValue == "session-1") + #expect(decoded.sessions.first?.macDeviceID.rawValue == "mac-test") + } + + @Test func fortyProcessObservationTickStaysWithinMainActorBudget() throws { + let service = AgentGUIService( + macDeviceID: "mac-performance", + clock: { 1_000_000 }, + hasEventSubscribers: { _ in false } + ) + let observations = Self.processObservations(count: 40) + service.ingestProcessObservationsForTesting(observations) + + let clock = ContinuousClock() + let durations = (0 ..< 5).map { _ in + let start = clock.now + service.ingestProcessObservationsForTesting(observations) + return start.duration(to: clock.now) + } + let best = try #require(durations.min()) + + #expect(best < .milliseconds(10), "40-session steady-state scan took \(best)") + #expect(service.journalPipelineCountForTesting == 0) + } + + @Test func processDetectionMintsJournalsOnlyForSubscribedSessions() throws { + let demand = RPCFakeAgentGUIJournalDemand() + let service = AgentGUIService( + macDeviceID: "mac-demand", + clock: { 1_000_000 }, + hasEventSubscribers: { demand.topics.contains($0) } + ) + service.ingestProcessObservationsForTesting(Self.processObservations(count: 40)) + + #expect(service.sessionsResult().sessions.count == 40) + #expect(service.journalPipelineCountForTesting == 0) + #expect(service.watchingJournalPipelineCountForTesting == 0) + + let subscribedSession = try #require(service.sessionsResult().sessions.first?.id) + let topic = GuiWireTopic.journal(sessionID: subscribedSession) + demand.topics = [topic] + service.refreshSubscriptionsForTesting(changedTopics: [topic]) + + #expect(service.journalPipelineCountForTesting == demand.topics.count) + #expect(service.watchingJournalPipelineCountForTesting == demand.topics.count) + + demand.topics = [] + service.refreshSubscriptionsForTesting(changedTopics: [topic]) + #expect(service.watchingJournalPipelineCountForTesting == 0) + } + + @Test func processDiscoveredSessionProvidesArtifactTranscriptContext() throws { + let transcriptPath = "/tmp/agent-gui-artifact/session.jsonl" + let service = AgentGUIService(macDeviceID: "mac-artifact") + service.ingestProcessObservationsForTesting([ + ProcessObservation( + pid: 41_001, + ppid: 1, + startTick: 52_001, + argvSummary: "claude --resume session-artifact", + agentKindGuess: .claude, + cwd: "/tmp/agent-gui-artifact", + surfaceID: "surface-artifact", + openTranscriptPath: transcriptPath + ), + ]) + service.handleHookEventSerial(WorkstreamEvent( + sessionId: "session-artifact", + hookEventName: .sessionStart, + source: "claude", + surfaceId: "surface-artifact", + transcriptPath: transcriptPath, + cwd: "/tmp/agent-gui-artifact", + ppid: 41_001 + )) + + let context = try #require(service.artifactSessionContext(sessionID: "session-artifact")) + #expect(context.sessionID == "session-artifact") + #expect(context.agentKind == .claude) + #expect(context.transcriptPath == transcriptPath) + #expect(context.workingDirectory == "/tmp/agent-gui-artifact") + } + + @Test func sendRPCShapesAcceptedAndQueuedResults() async throws { + let injector = RPCFakeAgentGUITerminalInjector() + let service = AgentGUIService(macDeviceID: "mac-test", terminalInjector: injector) + let handler = AgentGUIRPCHandler(service: service) + let surfaceID = UUID().uuidString + service.handleHookEventSerial(WorkstreamEvent( + sessionId: "session-accepted", + hookEventName: .sessionStart, + source: "codex", + surfaceId: surfaceID, + cwd: "/repo", + ppid: 123 + )) + + let accepted = await handler.handle(MobileHostRPCRequest( + id: nil, + method: GuiWireMethod.send, + params: try AgentGUICodableBridge.dictionary(GuiSendParams( + sessionID: AgentSessionID(rawValue: "session-accepted"), + ticketID: UUID().uuidString, + text: "hello" + )), + auth: nil + )) + #expect(try Self.sendResult(accepted) == GuiSendResult(accepted: true, queuedOnMac: false)) + #expect(injector.prompts == ["hello"]) + + service.handleHookEventSerial(WorkstreamEvent( + sessionId: "session-queued", + hookEventName: .sessionStart, + source: "codex", + surfaceId: surfaceID, + cwd: "/repo", + ppid: 123 + )) + service.handleHookEventSerial(WorkstreamEvent( + sessionId: "session-queued", + hookEventName: .permissionRequest, + source: "codex", + surfaceId: surfaceID, + cwd: "/repo", + ppid: 123 + )) + let queued = await handler.handle(MobileHostRPCRequest( + id: nil, + method: GuiWireMethod.send, + params: try AgentGUICodableBridge.dictionary(GuiSendParams( + sessionID: AgentSessionID(rawValue: "session-queued"), + ticketID: UUID().uuidString, + text: "queued" + )), + auth: nil + )) + #expect(try Self.sendResult(queued) == GuiSendResult(accepted: true, queuedOnMac: true)) + #expect(injector.prompts == ["hello"]) + } + + @Test func sendRPCErrorsForBindingLostAndAttachments() async throws { + let injector = RPCFakeAgentGUITerminalInjector() + injector.result = .bindingLost + let service = AgentGUIService(macDeviceID: "mac-test", terminalInjector: injector) + let handler = AgentGUIRPCHandler(service: service) + service.handleHookEventSerial(WorkstreamEvent( + sessionId: "session-1", + hookEventName: .sessionStart, + source: "codex", + surfaceId: UUID().uuidString, + cwd: "/repo", + ppid: 123 + )) + + let bindingLost = await handler.handle(MobileHostRPCRequest( + id: nil, + method: GuiWireMethod.send, + params: try AgentGUICodableBridge.dictionary(GuiSendParams( + sessionID: AgentSessionID(rawValue: "session-1"), + ticketID: UUID().uuidString, + text: "hello" + )), + auth: nil + )) + #expect(try Self.failure(bindingLost).code == "binding_lost") + + let attachmentRejected = await handler.handle(MobileHostRPCRequest( + id: nil, + method: GuiWireMethod.send, + params: try AgentGUICodableBridge.dictionary(GuiSendParams( + sessionID: AgentSessionID(rawValue: "session-1"), + ticketID: UUID().uuidString, + text: "hello", + attachments: [GuiSendAttachment(kind: "image", byteCount: 10)] + )), + auth: nil + )) + let error = try Self.failure(attachmentRejected) + #expect(error.code == "send_rejected") + #expect((error.data as? [String: String])?["detail"] == "attachment_unsupported") + } + + private static func okPayload(_ result: MobileHostRPCResult?) throws -> [String: Any] { + guard case .ok(let payload)? = result, + let dictionary = payload as? [String: Any] else { + Issue.record("expected ok dictionary") + throw AgentGUIRPCError.internalError + } + return dictionary + } + + private static func sendResult(_ result: MobileHostRPCResult?) throws -> GuiSendResult { + let payload = try Self.okPayload(result) + let data = try JSONSerialization.data(withJSONObject: payload) + return try JSONDecoder().decode(GuiSendResult.self, from: data) + } + + private static func failure(_ result: MobileHostRPCResult?) throws -> MobileHostRPCError { + guard case .failure(let error)? = result else { + Issue.record("expected failure") + throw AgentGUIRPCError.internalError + } + return error + } + + private static func processObservations(count: Int) -> [ProcessObservation] { + (0 ..< count).map { index in + ProcessObservation( + pid: Int32(10_000 + index), + ppid: 1, + startTick: 20_000 + index, + argvSummary: "codex --session \(index)", + agentKindGuess: .codex, + cwd: "/tmp/agent-gui-performance/\(index)", + surfaceID: "surface-\(index)", + openTranscriptPath: "/tmp/agent-gui-performance/\(index).jsonl" + ) + } + } +} + +@MainActor +private final class RPCFakeAgentGUIJournalDemand { + var topics: Set = [] +} + +@MainActor +private final class RPCFakeAgentGUITerminalInjector: AgentGUITerminalInjecting { + var prompts: [String] = [] + var keys: [String] = [] + var inputs: [String] = [] + var result: AgentGUITerminalInjectionResult = .accepted + + func submitPrompt(surfaceID: String, text: String) -> AgentGUITerminalInjectionResult { + prompts.append(text) + return result + } + + func sendKey(surfaceID: String, keyName: String) -> AgentGUITerminalInjectionResult { + keys.append(keyName) + return result + } + + func sendInput(surfaceID: String, text: String) -> AgentGUITerminalInjectionResult { + inputs.append(text) + return result + } +} diff --git a/cmuxTests/AgentGUISendLedgerTests.swift b/cmuxTests/AgentGUISendLedgerTests.swift new file mode 100644 index 000000000000..0bbe06c27d3b --- /dev/null +++ b/cmuxTests/AgentGUISendLedgerTests.swift @@ -0,0 +1,343 @@ +import CmuxAgentReplica +import CmuxAgentWire +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite(.serialized) +@MainActor +struct AgentGUISendLedgerTests { + @Test func resubmittingKnownTicketDoesNotInjectAgain() throws { + var now = 1_000 + let injector = FakeAgentGUITerminalInjector() + var published: [SendTicket] = [] + let ledger = AgentGUISendLedger( + sessionID: Self.sessionID, + clock: { now }, + injector: injector, + publish: { published.append($0) } + ) + let ticketID = UUID() + + let first = try ledger.submit(ticketID: ticketID, text: "hello", attachmentCount: 0, snapshot: Self.snapshot()) + now += 1 + let second = try ledger.submit(ticketID: ticketID, text: "hello again", attachmentCount: 0, snapshot: Self.snapshot()) + + #expect(first == GuiSendResult(accepted: true, queuedOnMac: false)) + #expect(second == GuiSendResult(accepted: true, queuedOnMac: false)) + #expect(injector.prompts == ["hello"]) + #expect(published.map(\.state) == [.acceptedByMac, .injected]) + } + + @Test func queuedTicketsInjectOnePerIdleTransitionInFIFOOrder() throws { + let injector = FakeAgentGUITerminalInjector() + var published: [SendTicket] = [] + let ledger = AgentGUISendLedger( + sessionID: Self.sessionID, + clock: { 1_000 }, + injector: injector, + publish: { published.append($0) } + ) + let firstID = UUID() + let secondID = UUID() + + let first = try ledger.submit(ticketID: firstID, text: "first", attachmentCount: 0, snapshot: Self.snapshot(phase: .working)) + let second = try ledger.submit(ticketID: secondID, text: "second", attachmentCount: 0, snapshot: Self.snapshot(phase: .working)) + ledger.handleSessionSnapshot(Self.snapshot(phase: .idle)) + ledger.handleSessionSnapshot(Self.snapshot(phase: .idle)) + ledger.handleSessionSnapshot(Self.snapshot(phase: .working)) + ledger.handleSessionSnapshot(Self.snapshot(phase: .idle)) + + #expect(first == GuiSendResult(accepted: true, queuedOnMac: true)) + #expect(second == GuiSendResult(accepted: true, queuedOnMac: true)) + #expect(injector.prompts == ["first", "second"]) + #expect(published.filter { $0.state == .injected }.map(\.text) == ["first", "second"]) + } + + @Test func idleSubmitQueuesBehindOlderAcceptedTicket() throws { + let injector = FakeAgentGUITerminalInjector() + let ledger = AgentGUISendLedger( + sessionID: Self.sessionID, + clock: { 1_000 }, + injector: injector, + publish: { _ in } + ) + _ = try ledger.submit(ticketID: UUID(), text: "first", attachmentCount: 0, snapshot: Self.snapshot(phase: .working)) + _ = try ledger.submit(ticketID: UUID(), text: "second", attachmentCount: 0, snapshot: Self.snapshot(phase: .working)) + ledger.handleSessionSnapshot(Self.snapshot(phase: .idle)) + + let third = try ledger.submit(ticketID: UUID(), text: "third", attachmentCount: 0, snapshot: Self.snapshot(phase: .idle)) + + #expect(third == GuiSendResult(accepted: true, queuedOnMac: true)) + #expect(injector.prompts == ["first"]) + ledger.handleSessionSnapshot(Self.snapshot(phase: .working)) + ledger.handleSessionSnapshot(Self.snapshot(phase: .idle)) + ledger.handleSessionSnapshot(Self.snapshot(phase: .working)) + ledger.handleSessionSnapshot(Self.snapshot(phase: .idle)) + #expect(injector.prompts == ["first", "second", "third"]) + } + + @Test func echoMatchingUsesOldestInjectedTicketAndWhitespaceFallback() throws { + let injector = FakeAgentGUITerminalInjector() + var published: [SendTicket] = [] + let ledger = AgentGUISendLedger( + sessionID: Self.sessionID, + clock: { 1_000 }, + injector: injector, + publish: { published.append($0) } + ) + let firstID = UUID() + let secondID = UUID() + _ = try ledger.submit(ticketID: firstID, text: "first prompt", attachmentCount: 0, snapshot: Self.snapshot()) + _ = try ledger.submit(ticketID: secondID, text: "second prompt", attachmentCount: 0, snapshot: Self.snapshot()) + + ledger.handleJournalEvent(.appended(journalID: Self.journalID, entries: [Self.userEntry(seq: 1, text: "second prompt")])) + ledger.handleJournalEvent(.appended(journalID: Self.journalID, entries: [Self.userEntry(seq: 2, text: "first prompt")])) + ledger.handleJournalEvent(.appended(journalID: Self.journalID, entries: [Self.userEntry(seq: 3, text: "second prompt")])) + + let echoed = published.compactMap { ticket -> (String, EntrySeq)? in + if case .echoed(let seq) = ticket.state { + return (ticket.text, seq) + } + return nil + } + #expect(echoed.map(\.0) == ["first prompt", "second prompt"]) + #expect(echoed.map(\.1) == [EntrySeq(rawValue: 2), EntrySeq(rawValue: 3)]) + } + + @Test func duplicateJournalDeltaDoesNotEchoNextIdenticalTicket() throws { + let injector = FakeAgentGUITerminalInjector() + var published: [SendTicket] = [] + let ledger = AgentGUISendLedger( + sessionID: Self.sessionID, + clock: { 1_000 }, + injector: injector, + publish: { published.append($0) } + ) + _ = try ledger.submit(ticketID: UUID(), text: "same", attachmentCount: 0, snapshot: Self.snapshot()) + _ = try ledger.submit(ticketID: UUID(), text: "same", attachmentCount: 0, snapshot: Self.snapshot()) + let firstDelta = AgentGUIJournalPipelineEvent.appended( + journalID: Self.journalID, + entries: [Self.userEntry(seq: 1, text: "same")] + ) + + ledger.handleJournalEvent(firstDelta) + ledger.handleJournalEvent(firstDelta) + + let echoedAfterDuplicate = published.filter { + if case .echoed = $0.state { return true } + return false + } + #expect(echoedAfterDuplicate.count == 1) + ledger.handleJournalEvent(.appended(journalID: Self.journalID, entries: [Self.userEntry(seq: 2, text: "same")])) + let echoedSequences = published.compactMap { ticket -> EntrySeq? in + if case .echoed(let seq) = ticket.state { return seq } + return nil + } + #expect(echoedSequences == [EntrySeq(rawValue: 1), EntrySeq(rawValue: 2)]) + } + + @Test func duplicateForeignDeltaCountsOnceTowardUnconfirmed() throws { + let injector = FakeAgentGUITerminalInjector() + var published: [SendTicket] = [] + let ledger = AgentGUISendLedger( + sessionID: Self.sessionID, + clock: { 1_000 }, + injector: injector, + publish: { published.append($0) } + ) + _ = try ledger.submit(ticketID: UUID(), text: "pending", attachmentCount: 0, snapshot: Self.snapshot()) + let duplicate = AgentGUIJournalPipelineEvent.appended( + journalID: Self.journalID, + entries: [Self.userEntry(seq: 1, text: "foreign")] + ) + + ledger.handleJournalEvent(duplicate) + ledger.handleJournalEvent(duplicate) + ledger.handleJournalEvent(duplicate) + ledger.handleJournalEvent(.appended(journalID: Self.journalID, entries: [Self.userEntry(seq: 2, text: "foreign")])) + #expect(published.last?.state == .injected) + + ledger.handleJournalEvent(.appended(journalID: Self.journalID, entries: [Self.userEntry(seq: 3, text: "foreign")])) + #expect(published.last?.state == .unconfirmed) + } + + @Test func injectionExpiresToUnconfirmedAfterTimeoutAndForeignAppends() throws { + var now = 10_000 + let injector = FakeAgentGUITerminalInjector() + var published: [SendTicket] = [] + let ledger = AgentGUISendLedger( + sessionID: Self.sessionID, + clock: { now }, + injector: injector, + publish: { published.append($0) } + ) + _ = try ledger.submit(ticketID: UUID(), text: "timeout", attachmentCount: 0, snapshot: Self.snapshot()) + now += AgentGUIConstants.sendEchoTimeoutMS + ledger.expire() + #expect(published.last?.state == .unconfirmed) + + published.removeAll() + now += 1 + _ = try ledger.submit(ticketID: UUID(), text: "foreign", attachmentCount: 0, snapshot: Self.snapshot()) + for seq in 1...3 { + ledger.handleJournalEvent(.appended(journalID: Self.journalID, entries: [Self.userEntry(seq: seq, text: "other \(seq)")])) + } + #expect(published.last?.state == .unconfirmed) + } + + @Test func resolvedTicketsExpireWithoutBeingResubmitted() throws { + var now = 1_000 + let injector = FakeAgentGUITerminalInjector() + let ledger = AgentGUISendLedger( + sessionID: Self.sessionID, + clock: { now }, + injector: injector, + publish: { _ in } + ) + _ = try ledger.submit(ticketID: UUID(), text: "done", attachmentCount: 0, snapshot: Self.snapshot()) + ledger.handleJournalEvent(.appended(journalID: Self.journalID, entries: [Self.userEntry(seq: 1, text: "done")])) + #expect(ledger.retainedRecordCount == 1) + + now += AgentGUIConstants.sendTicketIdempotencyWindowMS + ledger.expire() + + #expect(ledger.retainedRecordCount == 0) + } + + @Test func resolvedTicketRetentionHasAHardBound() throws { + var now = 1_000 + let injector = FakeAgentGUITerminalInjector() + let ledger = AgentGUISendLedger( + sessionID: Self.sessionID, + clock: { now }, + injector: injector, + publish: { _ in } + ) + for index in 1...(AgentGUIConstants.resolvedSendTicketRetentionLimit + 20) { + let text = "prompt \(index)" + _ = try ledger.submit(ticketID: UUID(), text: text, attachmentCount: 0, snapshot: Self.snapshot()) + ledger.handleJournalEvent(.appended( + journalID: Self.journalID, + entries: [Self.userEntry(seq: index, text: text)] + )) + now += 1 + } + + #expect(ledger.retainedRecordCount == AgentGUIConstants.resolvedSendTicketRetentionLimit) + } + + @Test func unboundSessionFailsWithBindingLostTicketState() { + let injector = FakeAgentGUITerminalInjector() + var published: [SendTicket] = [] + let ledger = AgentGUISendLedger( + sessionID: Self.sessionID, + clock: { 1_000 }, + injector: injector, + publish: { published.append($0) } + ) + + do { + _ = try ledger.submit(ticketID: UUID(), text: "hello", attachmentCount: 0, snapshot: Self.snapshot(surfaceID: nil)) + Issue.record("unbound session should throw") + } catch let error as AgentGUIRPCError { + #expect(error.code == "binding_lost") + } catch { + Issue.record("unexpected error: \(error)") + } + + guard case .failed(let code)? = published.last?.state else { + Issue.record("last published ticket should be failed") + return + } + #expect(code == "binding_lost") + #expect(injector.prompts.isEmpty) + } + + @Test func endedSessionFailsQueuedTicketsWithProcessExited() throws { + let injector = FakeAgentGUITerminalInjector() + var published: [SendTicket] = [] + let ledger = AgentGUISendLedger( + sessionID: Self.sessionID, + clock: { 1_000 }, + injector: injector, + publish: { published.append($0) } + ) + _ = try ledger.submit(ticketID: UUID(), text: "first", attachmentCount: 0, snapshot: Self.snapshot(phase: .working)) + _ = try ledger.submit(ticketID: UUID(), text: "second", attachmentCount: 0, snapshot: Self.snapshot(phase: .working)) + + ledger.handleSessionSnapshot(Self.snapshot(phase: .ended)) + + let failures = published.compactMap { ticket -> String? in + if case .failed(let code) = ticket.state { return code } + return nil + } + #expect(failures == ["process_exited", "process_exited"]) + #expect(injector.prompts.isEmpty) + } + + private static let sessionID = AgentSessionID(rawValue: "session-1") + private static let journalID = JournalID(rawValue: "journal-1") + private static let surfaceID = UUID().uuidString + + private static func snapshot(phase: SessionPhase = .idle) -> AgentSessionSnapshot { + snapshot(phase: phase, surfaceID: Self.surfaceID) + } + + private static func snapshot(phase: SessionPhase = .idle, surfaceID: String?) -> AgentSessionSnapshot { + AgentSessionSnapshot( + id: Self.sessionID, + macDeviceID: MacDeviceID(rawValue: "mac-1"), + kind: .codex, + phase: phase, + tier: .wrapped, + surfaceID: surfaceID, + cwd: "/repo", + title: "Session", + workspaceName: "Workspace", + version: EntityVersion(rawValue: 1), + lastActivityHint: 1 + ) + } + + private static func userEntry(seq: Int, text: String) -> EntrySnapshot { + EntrySnapshot( + journalID: Self.journalID, + seq: EntrySeq(rawValue: seq), + kind: .userMessage, + content: EntryContent( + contentHash: seq, + payload: .userMessage(UserMessagePayload(text: text, attachmentCount: 0, hasImage: false)) + ), + version: EntityVersion(rawValue: 1) + ) + } +} + +@MainActor +private final class FakeAgentGUITerminalInjector: AgentGUITerminalInjecting { + var prompts: [String] = [] + var keys: [String] = [] + var inputs: [String] = [] + var result: AgentGUITerminalInjectionResult = .accepted + + func submitPrompt(surfaceID: String, text: String) -> AgentGUITerminalInjectionResult { + prompts.append(text) + return result + } + + func sendKey(surfaceID: String, keyName: String) -> AgentGUITerminalInjectionResult { + keys.append(keyName) + return result + } + + func sendInput(surfaceID: String, text: String) -> AgentGUITerminalInjectionResult { + inputs.append(text) + return result + } +} diff --git a/cmuxTests/AgentGUIStreamProducerTests.swift b/cmuxTests/AgentGUIStreamProducerTests.swift new file mode 100644 index 000000000000..92c8fe4f0e7c --- /dev/null +++ b/cmuxTests/AgentGUIStreamProducerTests.swift @@ -0,0 +1,142 @@ +import CmuxAgentReplica +import CmuxAgentWire +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite struct AgentGUIStreamProducerTests { + @Test func extractsCodexAndClaudeStreamingAnswersWithoutTerminalChrome() { + let extractor = AgentGUIProseScreenExtractor() + let codex = extractor.extract(lines: [ + "› summarize this file", + "Streaming answer line one.", + "Streaming answer line two.", + "Working (3s • Esc to interrupt)", + "❯ ", + ], agentKind: .codex) + #expect(codex == "Streaming answer line one.\nStreaming answer line two.") + + let claude = extractor.extract(lines: [ + "❯ explain blue", + "⏺ Blue light scatters strongly.", + " This is why the sky looks blue.", + "✻ Forming… (4s · ↓ 21 tokens)", + "❯ ", + "⏵⏵ auto mode · esc to interrupt · ← for agents", + ], agentKind: .claude) + #expect(claude == "Blue light scatters strongly.\nThis is why the sky looks blue.") + } + + @Test func rejectsSettledAndPreAnswerScreens() { + let extractor = AgentGUIProseScreenExtractor() + #expect(extractor.extract(lines: [ + "⏺ Finished answer.", + "✻ Brewed for 3s", + ], agentKind: .claude) == nil) + #expect(extractor.extract(lines: [ + "❯ explain blue", + "✶ Thinking… (2s · esc to interrupt)", + ], agentKind: .claude) == nil) + } + + @Test func emitsChangedPreviewAndExplicitClearWithJournalContext() { + let sessionID = AgentSessionID(rawValue: "stream-session") + let surfaceID = UUID() + let journalID = JournalID(rawValue: "journal-stream") + var frames: [GuiStreamTickEvent] = [] + let producer = AgentGUIStreamProducer( + publish: { publishedSessionID, event in + #expect(publishedSessionID == sessionID) + frames.append(event) + }, + snapshot: { requestedSurfaceID in + #expect(requestedSurfaceID == surfaceID) + return [ + "› prompt", + "Live answer.", + "Working (3s • Esc to interrupt)", + ] + }, + hasSubscribers: { $0 == sessionID }, + context: { requestedSessionID in + guard requestedSessionID == sessionID else { return nil } + return .init(journalID: journalID, afterSeq: EntrySeq(rawValue: 7)) + }, + pollInterval: .seconds(60), + sleep: { _ in } + ) + + producer.turnStarted(sessionID: sessionID, surfaceID: surfaceID, agentKind: .codex) + producer.emitPreviewIfChanged(sessionID: sessionID) + producer.emitPreviewIfChanged(sessionID: sessionID) + producer.authoritativeProseArrived(sessionID: sessionID) + + #expect(frames.count == 2) + #expect(frames[0].journalID == journalID) + #expect(frames[0].afterSeq == EntrySeq(rawValue: 7)) + #expect(frames[0].textTail == "Live answer.") + #expect(frames[0].revision == 1) + #expect(frames[1].textTail.isEmpty) + #expect(frames[1].revision == 2) + producer.turnEnded(sessionID: sessionID) + } + + @Test func resetWindowContainingDurableProseSettlesPreviewAndEmitsClear() { + let sessionID = AgentSessionID(rawValue: "reset-stream-session") + let surfaceID = UUID() + let journalID = JournalID(rawValue: "reset-stream-journal") + var frames: [GuiStreamTickEvent] = [] + var screenLines = [ + "› prompt", + "Live answer.", + "Working (3s • Esc to interrupt)", + ] + let producer = AgentGUIStreamProducer( + publish: { _, event in frames.append(event) }, + snapshot: { requestedSurfaceID in + #expect(requestedSurfaceID == surfaceID) + return screenLines + }, + hasSubscribers: { $0 == sessionID }, + context: { _ in + .init(journalID: journalID, afterSeq: EntrySeq(rawValue: 1)) + }, + pollInterval: .seconds(60), + sleep: { _ in } + ) + producer.turnStarted(sessionID: sessionID, surfaceID: surfaceID, agentKind: .codex) + defer { producer.turnEnded(sessionID: sessionID) } + producer.emitPreviewIfChanged(sessionID: sessionID) + #expect(frames.count == 1) + #expect(frames[0].textTail == "Live answer.") + + var window = AgentGUIJournalWindow(journalID: journalID) + window.apply(EntrySnapshot( + journalID: journalID, + seq: EntrySeq(rawValue: 1), + kind: .agentProse, + content: EntryContent( + contentHash: 1, + payload: .agentProse(AgentProsePayload(markdown: "Live answer.")) + ), + version: EntityVersion(rawValue: 1) + )) + producer.journalEventArrived( + .reset(journalID: journalID, tailSeq: EntrySeq(rawValue: 1)), + sessionID: sessionID, + window: window + ) + + #expect(frames.count == 2) + #expect(frames[1].textTail.isEmpty) + screenLines[1] = "A preview that must not reappear." + producer.emitPreviewIfChanged(sessionID: sessionID) + #expect(frames.count == 2) + } +} diff --git a/cmuxTests/AgentSessionAutoResumeSwiftTests.swift b/cmuxTests/AgentSessionAutoResumeSwiftTests.swift index 45e2f6a485ce..c84e2a6566da 100644 --- a/cmuxTests/AgentSessionAutoResumeSwiftTests.swift +++ b/cmuxTests/AgentSessionAutoResumeSwiftTests.swift @@ -587,42 +587,6 @@ struct AgentSessionAutoResumeSwiftTests { } } - /// The cmux-authored chat rebind must record the resume launcher's real - /// target directory, not the persisted terminal cwd a stray report may - /// have parked on home: the Claude transcript fallback resolves - /// `~/.claude/projects//.jsonl` from the record's - /// cwd, so a clobbered value points the chat surface at the wrong - /// project (#7155). - @MainActor - @Test func secondRestoreOfClobberedRestorableAgentPaneRebindsChatSessionToAgentCwd() throws { - try withRestoredDefaults(key: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) { - UserDefaults.standard.set(true, forKey: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) - - let projectDir = try makeTemporaryProjectDirectory(prefix: "cmux-second-restore-chat-rebind") - defer { try? FileManager.default.removeItem(atPath: projectDir) } - - let (restored, _, homeDir) = try restoreResumedRestorableAgentOnlyWorkspaceWithClobberedTrackedCwd( - projectDir: projectDir - ) - let clobberedSnapshot = restored.sessionSnapshot(includeScrollback: false) - let clobberedTerminal = try #require(clobberedSnapshot.panels.first?.terminal) - try #require(clobberedTerminal.workingDirectory == homeDir) - let agentSessionId = try #require(clobberedTerminal.agent?.sessionId) - // The registry keys records on the session id with the - // `-` prefix stripped. - try #require(agentSessionId.hasPrefix("claude-")) - let registrySessionId = String(agentSessionId.dropFirst("claude-".count)) - - let secondRestore = Workspace() - secondRestore.restoreSessionSnapshot(clobberedSnapshot) - _ = try #require(secondRestore.focusedPanelId) - - let service = try #require(TerminalController.shared.agentChatTranscriptService) - let record = try #require(service.registry.record(sessionID: registrySessionId)) - #expect(record.workingDirectory == projectDir) - } - } - @MainActor @Test func registeredAgentWithCwdIgnoreDoesNotRescueFromLaunchCwd() throws { try withRestoredDefaults(key: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) { diff --git a/cmuxTests/AgentSessionWebRendererTests.swift b/cmuxTests/AgentSessionWebRendererTests.swift index 043d76c8a15f..ee3080e73b28 100644 --- a/cmuxTests/AgentSessionWebRendererTests.swift +++ b/cmuxTests/AgentSessionWebRendererTests.swift @@ -10,7 +10,23 @@ import Testing @Suite(.serialized) struct AgentSessionWebRendererTests { @Test - func testTrustedShellURLAcceptsOnlyMatchingFileURL() { + func testShellURLUsesAgentSessionAssetScheme() { + let resources = URL(fileURLWithPath: "/tmp/cmux DEV test.app/Contents/Resources", isDirectory: true) + let expected = AgentSessionWebRendererCoordinator.shellURL( + rendererKind: .react, + resourceDirectoryURL: resources + ) + let script = URL(string: "./main.mjs", relativeTo: expected)?.absoluteURL + + expectEqual(expected.scheme, "cmux-agent-session") + expectEqual(expected.host, "bundle") + expectEqual(expected.path, "/markdown-viewer/webviews-app/agent-session.html") + expectEqual(script?.scheme, "cmux-agent-session") + expectEqual(script?.path, "/markdown-viewer/webviews-app/main.mjs") + } + + @Test + func testTrustedShellURLAcceptsOnlyMatchingAgentSessionURL() { let resources = URL(fileURLWithPath: "/tmp/cmux DEV test.app/Contents/Resources", isDirectory: true) let expected = AgentSessionWebRendererCoordinator.shellURL( rendererKind: .react, @@ -22,14 +38,73 @@ struct AgentSessionWebRendererTests { .appendingPathComponent("..", isDirectory: true) .appendingPathComponent("webviews-app", isDirectory: true) .appendingPathComponent("agent-session.html", isDirectory: false) - let otherBundledFile = resources - .appendingPathComponent("markdown-viewer", isDirectory: true) - .appendingPathComponent("webviews-app", isDirectory: true) - .appendingPathComponent("diff-viewer.html", isDirectory: false) + let otherBundledFile = URL(string: "cmux-agent-session://bundle/markdown-viewer/webviews-app/main.mjs") expectTrue(AgentSessionWebRendererCoordinator.isTrustedShellURL(expected, expected: expected)) - expectTrue(AgentSessionWebRendererCoordinator.isTrustedShellURL(equivalent, expected: expected)) + expectFalse(AgentSessionWebRendererCoordinator.isTrustedShellURL(equivalent, expected: expected)) expectFalse(AgentSessionWebRendererCoordinator.isTrustedShellURL(otherBundledFile, expected: expected)) expectFalse(AgentSessionWebRendererCoordinator.isTrustedShellURL(URL(string: "https://example.com"), expected: expected)) } + + @Test + func testAgentSessionAssetLookupFallsBackToDeflatedModules() throws { + let root = try temporaryDirectory() + let main = root + .appendingPathComponent("markdown-viewer", isDirectory: true) + .appendingPathComponent("webviews-app", isDirectory: true) + .appendingPathComponent("main.mjs", isDirectory: false) + try FileManager.default.createDirectory( + at: main.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try DeflatedAssetTestSupport.writeText("console.log('agent')", to: main, addingDeflateExtension: true) + + let requestURL = URL(string: "cmux-agent-session://bundle/markdown-viewer/webviews-app/main.mjs") + let resolved = AgentSessionWebRendererCoordinator.agentSessionAssetFileURL( + for: requestURL, + resourceDirectoryURL: root + ) + + expectEqual(resolved, main.appendingPathExtension("deflate")) + } + + @Test + func testBridgeEventDeliveryScriptWaitsForReceiveFunction() { + let script = AgentSessionWebRendererCoordinator.bridgeEventDeliveryScript( + json: #"{"type":"provider.inputAccepted"}"# + ) + + expectTrue(script.contains("window.cmuxAgentBridge")) + expectTrue(script.contains(#"typeof bridge.receive !== "function""#)) + expectTrue(script.contains("return false")) + expectTrue(script.contains(#"bridge.receive({"type":"provider.inputAccepted"});"#)) + } + + @Test + func testActiveSessionContextCarriesNativeSessionMetadata() { + let context = AgentSessionWebRendererCoordinator.activeSessionContext( + AgentSessionControlSessionInfo( + sessionId: "session-1", + providerID: .codex, + executablePath: "/usr/local/bin/codex", + arguments: ["app-server", "--listen", "stdio://"], + workingDirectory: "/tmp/project" + ) + ) + + expectEqual(context["sessionId"] as? String, "session-1") + expectEqual(context["providerId"] as? String, "codex") + expectEqual(context["executablePath"] as? String, "/usr/local/bin/codex") + expectEqual(context["arguments"] as? [String], ["app-server", "--listen", "stdio://"]) + expectEqual(context["workingDirectory"] as? String, "/tmp/project") + } + + private func temporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-agent-session-web-renderer-tests-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } } diff --git a/cmuxTests/CodexAppServerSessionTests.swift b/cmuxTests/CodexAppServerSessionTests.swift index a7d97bd9445e..35142390adc8 100644 --- a/cmuxTests/CodexAppServerSessionTests.swift +++ b/cmuxTests/CodexAppServerSessionTests.swift @@ -743,11 +743,11 @@ struct CodexAppServerSessionTests { let threadParams = try #require(threadStart["params"] as? [String: Any]) expectEqual(threadParams["cwd"] as? String, "/tmp/cmux-agent-session-test") - let submitTask = Task { try await session.submit("hello codex", permissionMode: .fullAccess) } + try await session.submit("hello codex", permissionMode: .fullAccess) expectEqual(sentLines.count, 3, "Prompt should queue until thread/start returns a thread id.") session.consumeStdout(#"{"id":2,"result":{"thread":{"id":"thread-1"}}}"# + "\n") - try await submitTask.value + await Task.yield() let turnStart = jsonLine(sentLines[3]) expectEqual(turnStart["method"] as? String, "turn/start") let turnParams = try #require(turnStart["params"] as? [String: Any]) @@ -777,7 +777,7 @@ struct CodexAppServerSessionTests { ) try await session.start() - let submitTask = Task { try await session.submit("first prompt") } + try await session.submit("first prompt") await expectThrowsErrorAsync { try await session.submit("second prompt") } @@ -787,7 +787,7 @@ struct CodexAppServerSessionTests { + "\n") await Task.yield() session.consumeStdout(#"{"id":2,"result":{"thread":{"id":"thread-1"}}}"# + "\n") - try await submitTask.value + await Task.yield() expectEqual(sentLines.count, 4) let turnStart = jsonLine(sentLines[3]) @@ -902,14 +902,18 @@ struct CodexAppServerSessionTests { @Test func testCodexSubmitBlocksReentrantTurnWhileWriteIsPending() async throws { var sentLines: [String] = [] - var pendingTurnWrite: CheckedContinuation? - let session = CodexAppServerSession( + var didRejectReentrantSubmit = false + var session: CodexAppServerSession! + session = CodexAppServerSession( workingDirectory: nil, writeData: { data in let line = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .newlines) - if line.contains(#""method":"turn/start""#) { - await withCheckedContinuation { continuation in - pendingTurnWrite = continuation + if jsonLine(line)["method"] as? String == "turn/start" { + do { + try await session.submit("second prompt") + Issue.record("Expected reentrant submit to throw") + } catch { + didRejectReentrantSubmit = true } } sentLines.append(line) @@ -924,17 +928,8 @@ struct CodexAppServerSessionTests { await Task.yield() session.consumeStdout(#"{"id":2,"result":{"thread":{"id":"thread-1"}}}"# + "\n") - let firstSubmit = Task { try await session.submit("first prompt") } - while pendingTurnWrite == nil { - await Task.yield() - } - - await expectThrowsErrorAsync { - try await session.submit("second prompt") - } - - pendingTurnWrite?.resume() - try await firstSubmit.value + try await session.submit("first prompt") + expectTrue(didRejectReentrantSubmit) expectEqual(sentLines.count, 4) let turnParams = try #require(jsonLine(sentLines[3])["params"] as? [String: Any]) let input = try #require(turnParams["input"] as? [[String: Any]]) @@ -963,6 +958,7 @@ struct CodexAppServerSessionTests { #"{"id":"cmd-1","method":"item/commandExecution/requestApproval","params":{"threadId":"thread-1"}}"# + "\n") session.consumeStdout( #"{"id":"perm-1","method":"item/permissions/requestApproval","params":{"permissions":{"network":{"enabled":true}}}}"# + "\n") + await Task.yield() await expectThrowsErrorAsync { try await session.submit("blocked full access prompt", permissionMode: .fullAccess) } @@ -972,21 +968,22 @@ struct CodexAppServerSessionTests { #"{"id":"cmd-2","method":"item/commandExecution/requestApproval","params":{"threadId":"thread-1"}}"# + "\n") session.consumeStdout( #"{"id":"perm-2","method":"item/permissions/requestApproval","params":{"permissions":{"network":{"enabled":true}}}}"# + "\n") + await Task.yield() - let defaultCommandResponse = jsonLine(sentLines[4]) + let defaultCommandResponse = try responseLine(sentLines, id: "cmd-1") let defaultCommandResult = try #require(defaultCommandResponse["result"] as? [String: Any]) expectEqual(defaultCommandResult["decision"] as? String, "decline") - let defaultPermissionResponse = jsonLine(sentLines[5]) + let defaultPermissionResponse = try responseLine(sentLines, id: "perm-1") let defaultPermissionResult = try #require(defaultPermissionResponse["result"] as? [String: Any]) let defaultPermissions = try #require(defaultPermissionResult["permissions"] as? [String: Any]) expectTrue(defaultPermissions.isEmpty) - let fullAccessCommandResponse = jsonLine(sentLines[7]) + let fullAccessCommandResponse = try responseLine(sentLines, id: "cmd-2") let fullAccessCommandResult = try #require(fullAccessCommandResponse["result"] as? [String: Any]) expectEqual(fullAccessCommandResult["decision"] as? String, "acceptForSession") - let fullAccessPermissionResponse = jsonLine(sentLines[8]) + let fullAccessPermissionResponse = try responseLine(sentLines, id: "perm-2") let fullAccessPermissionResult = try #require(fullAccessPermissionResponse["result"] as? [String: Any]) let fullAccessPermissions = try #require(fullAccessPermissionResult["permissions"] as? [String: Any]) let networkPermissions = try #require(fullAccessPermissions["network"] as? [String: Any]) @@ -1136,11 +1133,8 @@ struct CodexAppServerSessionTests { ) try await session.start() - let submitTask = Task { try await session.submit("queued prompt") } + try await session.submit("queued prompt") session.consumeStdout(#"{"id":1,"error":{"message":"unsupported initialize"}}"# + "\n") - await expectThrowsErrorAsync { - try await submitTask.value - } expectEqual(sentLines.count, 1) expectEqual(failures.count, 1) @@ -1170,11 +1164,8 @@ struct CodexAppServerSessionTests { await Task.yield() expectEqual(jsonLine(sentLines[2])["method"] as? String, "thread/start") - let submitTask = Task { try await session.submit("queued prompt") } + try await session.submit("queued prompt") session.consumeStdout(#"{"id":2,"error":{"message":"bad cwd"}}"# + "\n") - await expectThrowsErrorAsync { - try await submitTask.value - } expectEqual(failures.count, 1) expectEqual(failures.first!, "bad cwd") @@ -1194,4 +1185,15 @@ struct CodexAppServerSessionTests { } return object } + + private func responseLine(_ rawLines: [String], id: String) throws -> [String: Any] { + for rawLine in rawLines { + let object = jsonLine(rawLine) + if object["id"] as? String == id { + return object + } + } + Issue.record("Expected response line for \(id)") + return [:] + } } diff --git a/cmuxTests/ControlledAgentProcessObservationCapturer.swift b/cmuxTests/ControlledAgentProcessObservationCapturer.swift new file mode 100644 index 000000000000..eadc08a6c5eb --- /dev/null +++ b/cmuxTests/ControlledAgentProcessObservationCapturer.swift @@ -0,0 +1,39 @@ +import CmuxAgentTruthKit + +actor ControlledAgentProcessObservationCapturer { + private var captureCount = 0 + private var releases: [Int: CheckedContinuation<[ProcessObservation], Never>] = [:] + private var callCountWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + + func capture() async -> [ProcessObservation] { + captureCount += 1 + let call = captureCount + return await withCheckedContinuation { continuation in + releases[call] = continuation + resumeSatisfiedCallCountWaiters() + } + } + + func waitForCallCount(_ count: Int) async { + guard captureCount < count else { return } + await withCheckedContinuation { continuation in + callCountWaiters.append((count, continuation)) + } + } + + func release(call: Int, observations: [ProcessObservation] = []) { + releases.removeValue(forKey: call)?.resume(returning: observations) + } + + func callCount() -> Int { + captureCount + } + + private func resumeSatisfiedCallCountWaiters() { + let satisfied = callCountWaiters.filter { captureCount >= $0.count } + callCountWaiters.removeAll { captureCount >= $0.count } + for waiter in satisfied { + waiter.continuation.resume() + } + } +} diff --git a/cmuxTests/MobileHostWorkspaceTicketAuthorizationTests.swift b/cmuxTests/MobileHostWorkspaceTicketAuthorizationTests.swift index c64630fb85b2..484b84640151 100644 --- a/cmuxTests/MobileHostWorkspaceTicketAuthorizationTests.swift +++ b/cmuxTests/MobileHostWorkspaceTicketAuthorizationTests.swift @@ -70,16 +70,20 @@ struct MobileHostWorkspaceTicketAuthorizationTests { return try CmxAttachTicketCompactCoder().decode(data) } - @Test func attachTargetsPreferSanitizedIrohThenUseDestinationFallbacks() throws { + @Test func attachTargetsUseDestinationSpecificRoutes() throws { let loopback = try loopbackRoute() let tailscale = try tailscaleRoute() let iroh = try irohRoute() let sanitizedIroh = try irohRoute(withPathHint: false) let routes = [loopback, tailscale, iroh] - #expect(try MobileAttachTarget.simulatorInjection.selectRoutes(from: routes) == [sanitizedIroh]) + #expect(try MobileAttachTarget.simulatorInjection.selectRoutes(from: routes) == [loopback]) #expect(try MobileAttachTarget.physicalDevice.selectRoutes(from: routes) == [sanitizedIroh]) #expect(try MobileAttachTarget.ticketOnly.selectRoutes(from: routes) == routes) + #expect( + try MobileAttachTarget.simulatorInjection.selectRoutes(from: [tailscale, iroh]) + == [sanitizedIroh] + ) #expect( try MobileAttachTarget.simulatorInjection.selectRoutes(from: [loopback, tailscale]) == [loopback] @@ -96,7 +100,6 @@ struct MobileHostWorkspaceTicketAuthorizationTests { for target in [MobileAttachTarget.simulatorInjection, .physicalDevice] { let selectedRoutes = try target.selectRoutes(from: [ - try loopbackRoute(), try tailscaleRoute(), originalRoute, ]) @@ -286,13 +289,16 @@ struct MobileHostWorkspaceTicketAuthorizationTests { } #if DEBUG - @Test func attachTicketWithoutListenerPreservesNoRoutesError() async { + @Test func attachTicketWithoutPublishedRoutesPreservesNoRoutesError() async { let service = MobileHostService.shared service.debugSetListenerStateForTesting( generation: UUID(), usesEphemeralFallback: false, port: nil ) + // A signed-in Mac can still publish an Iroh route with no legacy TCP listener. + // This test covers the distinct no-route contract. + MobileHostPublicStatusCache.removeAll() await #expect(throws: MobileAttachTicketStoreError.noRoutes) { try await service.createAttachTicket( diff --git a/docs/codex-agent-detection-plan.md b/docs/codex-agent-detection-plan.md index 593f003aa362..69c26757125f 100644 --- a/docs/codex-agent-detection-plan.md +++ b/docs/codex-agent-detection-plan.md @@ -1,36 +1,25 @@ # Codex Agent Detection Plan Status: IMPLEMENTED + live-verified (macOS/daemon side). Owner: Aziz. Last -updated: 2026-06-22. Branch `feat-codex-detection`, PR #6655 (do-not-merge -pending on-device dogfood). Base/return point: tag `agent-session-sot-landmark`. +updated: 2026-06-22. Branch `feat-codex-detection`, PR #6655. Done: `cmux-codex-wrapper` (PATH shim, Claude-parity per-invocation `[hooks]` injection via `--enable hooks --dangerously-bypass-hook-trust -c hooks.=...`), -codex PATH-shim install sibling to the claude shim, `WorkstreamEvent` + -`feed.push` + `noteHookEvent` now carry `surface_id`/`transcript_path`. Two -preflight-caught bugs fixed: phantom `fallback-*` duplicate (removed the -wrapper-fired empty-stdin launch signal) and unbound-surface on live -session-start. Live debug-socket proof: a real `codex exec` produced exactly one -codex session, surface + transcript bound, `idle -> ended` on exit. - -Remaining: on-device iOS GUI dogfood (the iPhone renders the registry; macOS -side proven). Codex `needsInput` is hook-driven via `PermissionRequest`. - -Expands Slice F of `agent-session-tracking-spec.md`: make Codex sessions track in -the iOS GUI as reliably as Claude, without forcing users to install anything and -without silently editing their `~/.codex` config. +codex PATH-shim install sibling to the claude shim, and `WorkstreamEvent` / +`feed.push` surface/transcript metadata. Two preflight-caught bugs fixed: +phantom `fallback-*` duplicate (removed the wrapper-fired empty-stdin launch +signal) and unbound-surface on live session-start. Live debug-socket proof: a +real `codex exec` produced one surface-bound Codex session lifecycle. ## Problem -Codex agents are not reliably detected in the GUI. Empirical root cause on a real -machine (Aziz's, 2026-06-22): +Codex agents were not reliably detected by cmux's hook/feed path. Empirical root +cause on a real machine (Aziz's, 2026-06-22): - cmux's Codex hooks (`~/.codex/hooks.json`) are NOT installed. - Codex has a single legacy `notify` slot, and it is taken by Computer Use: `notify = [".../SkyComputerUseClient", "turn-ended"]`. cmux's notify-based events cannot fire. -- Slice D removed the terminal-title / newest-jsonl-by-mtime fallback - (intentionally — no unreliable fallback), so a hook-less Codex is invisible. - The 13 "codex" entries in `~/.cmuxterm/codex-hook-sessions.json` are stale, not live-updating. @@ -72,8 +61,9 @@ Session lifecycle, with NO Codex hooks required: pid (the process's open file descriptors, or a launch-time-bounded match confirmed against the pid). This is an identity, NOT the deleted "newest jsonl by mtime in the cwd" guess. Then tail it. -- State (working / idle): derived from the transcript tail. `CodexTranscriptParser` - already exists. +- State (working / idle): derived from the transcript tail. A rollout transcript + parser needs to be rebuilt; the previous `CodexTranscriptParser` was removed + with the mobile chat pipeline. - ended: the Slice-B `DispatchSourceProcess(.exit)` watcher on the pid. - Codex's own hooks: optional enhancement for finer / faster state, never a requirement. @@ -119,9 +109,8 @@ identical limits): 3. The current Codex CLI per-invocation config surface (`-c` overrides; multi-hook support), to decide notify-chaining vs. skip vs. multi-hook. 4. Does cmux's existing Codex launch path (`codex-teams` / - `upsertCodexSessionStartIfFresh`) already register into the iOS chat registry - (`AgentChatSessionRegistry`), or only write the stale hook store? Determines - how much is wiring vs. new. + `upsertCodexSessionStartIfFresh`) emit the same hook/feed metadata as the + wrapper path? ## Implementation steps @@ -129,21 +118,12 @@ identical limits): 2. Add `cmux-codex-wrapper` to the same shim dir + PATH injection Claude uses. 3. Wrapper emits `cmux hooks codex session-start` (surface / pid / cwd) before exec, and no-ops cleanly outside cmux. -4. Wire the Codex session-start into `AgentChatSessionRegistry` (the iOS chat - registry), if it does not already land there. -5. pid-anchored Codex transcript resolution (open-fd / launch-bounded), replacing +4. pid-anchored Codex transcript resolution (open-fd / launch-bounded), replacing any reliance on session-id-from-hook for the typed case. -6. Confirm `CodexTranscriptParser` yields working / idle (/ needsInput) from the - rollout; fill gaps. -7. `notify` coexistence: skip, chain, or multi-hook per Q3. -8. Build + dogfood: type `codex` in a cmux terminal, confirm it appears in the - GUI, state tracks, and it ends on exit; confirm Computer Use's `notify` still +5. Rebuild rollout parsing that yields working / idle (/ needsInput) from the + transcript tail (the previous parser was removed with the mobile chat + pipeline). +6. `notify` coexistence: skip, chain, or multi-hook per Q3. +7. Build + dogfood: type `codex` in a cmux terminal, confirm hook/feed metadata + is emitted, lifecycle state tracks, and Computer Use's `notify` still fires. - -## Relationship to the main spec - -This is `agent-session-tracking-spec.md` Slice F, expanded. It replaces that -slice's "deferred — needs a product decision" note: the wrapper approach needs no -global install and edits no user config, so there is no product decision to gate -on. Detection does not depend on Codex's hook support, which is what makes it -principled rather than a per-agent hack. diff --git a/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved index 178b7c12ba6a..f5def153e129 100644 --- a/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "0831ab4190d7f43031cf5a15bf4e390348abcb33d575b0dbe3e3cf0cf51a3f2c", + "originHash" : "ae4409dcdc4dff473bb99499012c7e429e112fbbe8a7c3b65ffcb6fc2ead8607", "pins" : [ { "identity" : "highlightr", diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index ead0d6ad251a..2c2b24902112 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -18295,6 +18295,108 @@ } } } + }, + "mobile.agentGUI.showTerminal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルを表示" + } + } + } + }, + "mobile.agentGUI.showTranscript": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Agent Transcript" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントの会話を表示" + } + } + } + }, + "mobile.settings.transcriptDemo": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Transcript Demo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トランスクリプトデモ" + } + } + } + }, + "mobile.settings.transcriptDensity": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Transcript Density" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トランスクリプトの密度" + } + } + } + }, + "mobile.settings.transcriptDensity.comfortable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Comfortable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ゆったり" + } + } + } + }, + "mobile.settings.transcriptDensity.compact": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Compact" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンパクト" + } + } + } } }, "version": "1.0" diff --git a/reports.md b/reports.md index 68d820886411..bada265f950d 100644 --- a/reports.md +++ b/reports.md @@ -11,27 +11,6 @@ Categories: `wall-clock-timing-assert` 36, `sleep-as-sync` 20, `async-race` 17, --- -## Packages/Shared/CmuxAgentChat (3) - -### `Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift` :: `replayOverlappingHistoryDeduplicates` -- **async-race** · severity low · confidence medium -- **Evidence:** Line 554: `_ = await TestPoller.waitUntil { Self.snapshots(store.rows).count > 100 }`. The poller result is discarded. The condition (`count > 100`) represents the BROKEN behavior, so on a correct store it always times out after 400 iterations (~4 seconds of busy polling + sleep), making this test consistently slow and masking the timeout as normal. -- **Why flaky:** Not flaky in correctness terms, but the always-timeout path burns ~4 seconds of CI time per run. More importantly, if the store is temporarily slow to apply events (GC pause, scheduler starvation), the poller exits at false and the subsequent `#expect(count == 100)` could race a still-in-flight apply, reading a transient count. -- **Suggested fix:** Replace the discard pattern with `#expect(!(await TestPoller.waitUntil { Self.snapshots(store.rows).count > 100 }))` to explicitly assert the condition was never met, or better: poll for `count == 100` with `#expect` directly after the emit, which gives the store time to settle and asserts the correct final state. - -### `Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift` :: `failedPendingSurvivesForeignEchoes` -- **async-race** · severity low · confidence low -- **Evidence:** Lines 597 and 607: `_ = await TestPoller.waitUntil { Self.pendingItems(store.rows).count == 2 }` and `_ = await TestPoller.waitUntil { Self.pendingItems(store.rows).count == 1 }`. Both discard the boolean return. If either poll times out, subsequent assertions run against stale/intermediate state. -- **Why flaky:** If line 597's poll times out before the second pending is registered (extremely unlikely since `store.send` is `@MainActor` and completes synchronously), the test proceeds with only 1 pending and the echo logic is never exercised — the test would pass silently without actually verifying the failing-row-survives-foreign-echoes invariant. Under normal CI load this is extremely unlikely but remains a theoretical false-pass path. -- **Suggested fix:** Change both discards to `#expect(await TestPoller.waitUntil { ... })` so a timeout is reported as a test failure instead of silently continuing with incorrect preconditions. - -### `Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift` :: `slashCommandEchoDoesNotEatAttachmentPending` -- **async-race** · severity low · confidence low -- **Evidence:** Lines 627 and 637: `_ = await TestPoller.waitUntil { Self.pendingItems(store.rows).count == 2 }` and `_ = await TestPoller.waitUntil { Self.pendingItems(store.rows).count == 1 }`. Same discard pattern. Line 640 `#expect(Self.pendingItems(store.rows).first?.text.isEmpty == true)` runs against whatever the current count is if polls timeout. -- **Why flaky:** If line 637's poll times out before the slash-echo reconciliation completes (both sends still pending), `pendingItems.first` returns the attachment-only send (text is empty), and line 640 passes trivially — the test does not witness the echo consuming the slash-command pending as intended. -- **Suggested fix:** Replace `_ = await TestPoller.waitUntil` with `#expect(await TestPoller.waitUntil)` on both lines so a timeout is a test failure, forcing the test to wait for the correct precondition before asserting. - - ## Packages/Shared/CmuxAuthRuntime (2) ### `Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorTests.swift` :: `signOutJoinsAndCancelsSlowTeardownAtDeadline` diff --git a/scripts/lint-ios-package-conventions.sh b/scripts/lint-ios-package-conventions.sh index 9f758862cb73..8eeeab85b5a5 100755 --- a/scripts/lint-ios-package-conventions.sh +++ b/scripts/lint-ios-package-conventions.sh @@ -19,7 +19,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" SCOPES=() -for d in Packages/Shared/CMUXMobileCore Packages/iOS/CmuxMobile* Packages/Shared/CmuxAgentChat Packages/iOS/CmuxAgentChatUI Packages/Shared/CmuxSyncStore ios/cmuxPackage/Sources ios/cmux; do +for d in Packages/Shared/CMUXMobileCore Packages/iOS/CmuxMobile* Packages/Shared/CmuxSyncStore Packages/Shared/CmuxAgentReplica Packages/Shared/CmuxAgentWire Packages/Shared/CmuxAgentSync Packages/macOS/CmuxAgentTruthKit ios/cmuxPackage/Sources ios/cmux; do [ -d "$d" ] && SCOPES+=("$d") done diff --git a/scripts/swift_file_length_budget.py b/scripts/swift_file_length_budget.py new file mode 100755 index 000000000000..1a5f970cad23 --- /dev/null +++ b/scripts/swift_file_length_budget.py @@ -0,0 +1,565 @@ +#!/usr/bin/env python3 +"""Check cmux-owned Swift file lengths against a checked-in budget.""" + +from __future__ import annotations + +import argparse +import pathlib +import subprocess +import sys + + +DEFAULT_ROOTS = ("Sources", "CLI", "Packages", "cmuxTests", "cmuxUITests") +DEFAULT_THRESHOLD = 500 +DEFAULT_INCIDENTAL_GROWTH = 25 +DEFAULT_HARD_CAP = 900 +IGNORED_PATH_PARTS = ( + "/vendor/", + "/ghostty/", + "/homebrew-cmux/", + "/.build/", + "/SourcePackages/", + "/.ci-source-packages/", +) + + +FileLengthBudget = dict[str, int] + + +def is_ignored_path(path: pathlib.Path) -> bool: + normalized = "/" + path.as_posix().lstrip("/") + return any(part in normalized for part in IGNORED_PATH_PARTS) + + +def count_lines(path: pathlib.Path) -> int: + with path.open("r", encoding="utf-8", errors="replace") as handle: + return sum(1 for _ in handle) + + +def collect_file_lengths(repo_root: pathlib.Path, roots: tuple[str, ...]) -> FileLengthBudget: + budget: FileLengthBudget = {} + for root in roots: + root_path = repo_root / root + if not root_path.exists(): + continue + + for path in sorted(root_path.rglob("*.swift")): + rel_path = path.relative_to(repo_root) + if is_ignored_path(rel_path): + continue + budget[rel_path.as_posix()] = count_lines(path) + return budget + + +def is_in_roots(rel_path: str, roots: tuple[str, ...]) -> bool: + return any(rel_path == root or rel_path.startswith(f"{root}/") for root in roots) + + +def count_blob_lines(content: bytes) -> int: + return content.count(b"\n") + (0 if content.endswith(b"\n") or not content else 1) + + +def list_tree_swift_paths(repo_root: pathlib.Path, tree: str, roots: tuple[str, ...]) -> list[str]: + result = subprocess.run( + ["git", "-C", str(repo_root), "ls-tree", "-r", "--name-only", "-z", tree], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.decode("utf-8", errors="replace").strip()) + + paths: list[str] = [] + for raw_path in result.stdout.split(b"\0"): + if not raw_path: + continue + rel_path = raw_path.decode("utf-8", errors="surrogateescape") + if not rel_path.endswith(".swift"): + continue + if not is_in_roots(rel_path, roots): + continue + if is_ignored_path(pathlib.Path(rel_path)): + continue + paths.append(rel_path) + return sorted(paths) + + +def collect_file_lengths_at_ref(repo_root: pathlib.Path, ref: str, roots: tuple[str, ...]) -> FileLengthBudget: + paths = list_tree_swift_paths(repo_root, ref, roots) + if not paths: + return {} + + batch_input = "".join(f"{ref}:{rel_path}\n" for rel_path in paths).encode( + "utf-8", + errors="surrogateescape", + ) + try: + process = subprocess.run( + ["git", "-C", str(repo_root), "cat-file", "--batch"], + input=batch_input, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except OSError as exc: + raise RuntimeError(str(exc)) from exc + + if process.returncode != 0: + raise RuntimeError(process.stderr.decode("utf-8", errors="replace").strip()) + + budget: FileLengthBudget = {} + offset = 0 + stdout = process.stdout + for rel_path in paths: + header_end = stdout.find(b"\n", offset) + if header_end == -1: + raise RuntimeError(f"missing git cat-file header for {rel_path}") + header = stdout[offset:header_end] + header_parts = header.split() + if len(header_parts) == 2 and header_parts[1] == b"missing": + raise RuntimeError(f"missing object for {ref}:{rel_path}") + if len(header_parts) != 3: + raise RuntimeError(f"unexpected git cat-file header for {rel_path}: {header!r}") + try: + size = int(header_parts[2]) + except ValueError as exc: + raise RuntimeError(f"invalid git cat-file size for {rel_path}: {header!r}") from exc + content_start = header_end + 1 + content_end = content_start + size + if content_end > len(stdout): + raise RuntimeError(f"truncated git cat-file content for {rel_path}") + budget[rel_path] = count_blob_lines(stdout[content_start:content_end]) + offset = content_end + if offset < len(stdout) and stdout[offset : offset + 1] == b"\n": + offset += 1 + return budget + + +def tracked_file_lengths(file_lengths: FileLengthBudget, threshold: int) -> FileLengthBudget: + return { + rel_path: line_count + for rel_path, line_count in file_lengths.items() + if line_count >= threshold + } + + +def count_lines_at_ref(repo_root: pathlib.Path, ref: str, rel_path: str) -> int | None: + try: + result = subprocess.run( + ["git", "-C", str(repo_root), "show", f"{ref}:{rel_path}"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + except OSError: + return None + + if result.returncode != 0: + return None + return count_blob_lines(result.stdout) + + +def parse_budget(text: str, source: str) -> FileLengthBudget: + budget: FileLengthBudget = {} + for line_number, raw_line in enumerate(text.splitlines(), start=1): + line = raw_line.rstrip("\n") + if not line or line.startswith("#"): + continue + + parts = line.split("\t", 1) + if len(parts) != 2: + raise ValueError(f"{source}:{line_number}: expected max_linesrelative path") + + count_text, rel_path = parts + try: + count = int(count_text) + except ValueError as exc: + raise ValueError(f"{source}:{line_number}: invalid line count {count_text!r}") from exc + + if count < 0: + raise ValueError(f"{source}:{line_number}: line count must be non-negative") + if rel_path in budget: + raise ValueError(f"{source}:{line_number}: duplicate entry for {rel_path!r}") + budget[rel_path] = count + return budget + + +def load_budget(path: pathlib.Path) -> FileLengthBudget: + return parse_budget(path.read_text(encoding="utf-8"), str(path)) + + +def repo_relative_path(repo_root: pathlib.Path, path: pathlib.Path) -> str | None: + try: + return path.resolve(strict=False).relative_to(repo_root).as_posix() + except ValueError: + return None + + +def load_budget_at_ref(repo_root: pathlib.Path, ref: str, rel_path: str) -> FileLengthBudget | None: + try: + result = subprocess.run( + ["git", "-C", str(repo_root), "show", f"{ref}:{rel_path}"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ) + except OSError: + return None + + if result.returncode != 0: + return None + return parse_budget(result.stdout, f"{ref}:{rel_path}") + + +def write_budget(path: pathlib.Path, budget: FileLengthBudget) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as handle: + handle.write("# cmux-owned Swift file length budget.\n") + handle.write("# Format: max_linesrelative path\n") + handle.write("# Reduce counts as files shrink. CI fails if tracked files exceed this budget.\n") + for rel_path, line_count in sorted(budget.items(), key=lambda item: (-item[1], item[0])): + handle.write(f"{line_count}\t{rel_path}\n") + + +def print_file_summary(label: str, file_lengths: FileLengthBudget) -> None: + total = sum(file_lengths.values()) + print(f"{label}: {total} line(s) across {len(file_lengths)} Swift file(s)") + + +def speculative_merge_tree(repo_root: pathlib.Path, merge_ref: str, merge_head: str) -> str | None: + try: + result = subprocess.run( + ["git", "-C", str(repo_root), "merge-tree", "--write-tree", merge_ref, merge_head], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + except OSError as exc: + print( + f"Speculative merge of {merge_ref} and {merge_head} could not run; " + "falling back to working-tree evaluation.", + ) + print(str(exc), file=sys.stderr) + return None + + if result.returncode == 0: + tree = result.stdout.splitlines()[0] if result.stdout.splitlines() else "" + if tree: + return tree + print( + f"Speculative merge of {merge_ref} and {merge_head} did not produce a tree; " + "falling back to working-tree evaluation.", + ) + if result.stderr.strip(): + print(result.stderr.strip(), file=sys.stderr) + return None + + if result.returncode == 1: + print( + f"Speculative merge of {merge_ref} and {merge_head} conflicts; " + "falling back to working-tree evaluation.", + ) + return None + + print( + f"Speculative merge of {merge_ref} and {merge_head} failed; " + "falling back to working-tree evaluation.", + ) + if result.stderr.strip(): + print(result.stderr.strip(), file=sys.stderr) + return None + + +def compare_budget( + actual: FileLengthBudget, + allowed: FileLengthBudget, + base_allowed: FileLengthBudget | None, + threshold: int, + all_file_lengths: FileLengthBudget, + repo_root: pathlib.Path, + base_ref: str | None, + incidental_growth: int, + hard_cap: int, +) -> int: + failures: list[tuple[str, int, int | None, str]] = [] + incidental: list[tuple[str, int, int, int]] = [] + reductions: list[tuple[str, int, int]] = [] + + for rel_path in sorted(set(actual) | set(allowed)): + actual_count = actual.get(rel_path, all_file_lengths.get(rel_path, 0)) + allowed_count = allowed.get(rel_path) + + if base_ref and actual_count >= threshold: + base_count = count_lines_at_ref(repo_root, base_ref, rel_path) + if base_count is None: + failures.append((rel_path, actual_count, allowed_count, "new tracked file")) + continue + if base_count < threshold: + failures.append((rel_path, actual_count, allowed_count, "newly tracked file")) + continue + + base_growth = actual_count - base_count if base_count is not None else None + if actual_count > hard_cap and base_growth is not None and base_growth > 0: + failures.append((rel_path, actual_count, allowed_count, f"hard cap {hard_cap}")) + continue + if base_growth is not None and base_growth > incidental_growth: + failures.append( + ( + rel_path, + actual_count, + allowed_count, + f"PR growth +{base_growth} exceeds incidental allowance {incidental_growth}", + ) + ) + continue + + if allowed_count is None: + failures.append((rel_path, actual_count, None, "missing budget entry")) + continue + base_allowed_count = base_allowed.get(rel_path) if base_allowed is not None else None + if ( + base_allowed_count is not None + and allowed_count < base_allowed_count + and actual_count > allowed_count + ): + failures.append( + ( + rel_path, + actual_count, + allowed_count, + f"budget lowered below actual count (base budget {base_allowed_count})", + ) + ) + continue + if actual_count > allowed_count and base_growth is not None and base_growth > 0: + incidental.append((rel_path, actual_count, allowed_count, base_growth)) + continue + if actual_count > allowed_count: + continue + if actual_count < allowed_count: + reductions.append((rel_path, actual_count, allowed_count)) + continue + continue + + if allowed_count is None and actual_count >= threshold: + failures.append((rel_path, actual_count, None, "untracked")) + elif allowed_count is not None and actual_count > allowed_count: + failures.append((rel_path, actual_count, allowed_count, "exceeds checked-in budget")) + elif rel_path in allowed and actual_count < allowed_count: + reductions.append((rel_path, actual_count, allowed_count)) + + if failures: + print("Swift file length budget exceeded.") + print("") + for rel_path, actual_count, allowed_count, reason in sorted( + failures, + key=lambda item: ((item[2] if item[2] is not None else threshold) - item[1], item[0]), + ): + comparison_count = allowed_count if allowed_count is not None else threshold + delta = actual_count - comparison_count + if allowed_count is None: + prefix = f"+{delta}" if delta > 0 else "new" + print(f"{prefix} {rel_path}") + print(f" actual={actual_count} budget=untracked threshold={threshold}") + else: + print(f"+{delta} {rel_path}") + print(f" actual={actual_count} budget={allowed_count}") + print(f" reason={reason}") + print("") + print("Split the file, reduce the new growth, or refresh the budget only when accepting known debt.") + return 1 + + print("Swift file length budget respected.") + if incidental: + print("") + print("Incidental growth allowed by PR gate:") + for rel_path, actual_count, allowed_count, base_growth in sorted( + incidental, + key=lambda item: (item[3], item[0]), + reverse=True, + )[:20]: + print(f"+{base_growth} {rel_path}") + print(f" actual={actual_count} budget={allowed_count} allowance={incidental_growth}") + if reductions: + print("") + print("Budget can be reduced:") + for rel_path, actual_count, allowed_count in sorted( + reductions, + key=lambda item: (item[2] - item[1], item[0]), + reverse=True, + )[:20]: + delta = allowed_count - actual_count + print(f"-{delta} {rel_path}") + print(f" actual={actual_count} budget={allowed_count}") + if len(reductions) > 20: + print(f"... {len(reductions) - 20} more reduction(s)") + return 0 + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--repo-root", + default=pathlib.Path.cwd(), + type=pathlib.Path, + help="repository root to scan", + ) + parser.add_argument( + "--budget", + default=pathlib.Path(".github/swift-file-length-budget.tsv"), + type=pathlib.Path, + help="checked-in file length budget", + ) + parser.add_argument( + "--threshold", + default=DEFAULT_THRESHOLD, + type=int, + help="minimum line count tracked by the budget", + ) + parser.add_argument( + "--roots", + nargs="+", + default=list(DEFAULT_ROOTS), + help="repo-relative roots to scan", + ) + parser.add_argument( + "--write-budget", + action="store_true", + help="write the current file lengths as the budget instead of checking", + ) + parser.add_argument( + "--base-ref", + help="git ref used to allow small PR-local growth in files already over budget", + ) + parser.add_argument( + "--merge-ref", + help="git ref to speculatively merge with --merge-head before evaluating the budget", + ) + parser.add_argument( + "--merge-head", + default="HEAD", + help="git ref for the PR side of a speculative merge", + ) + parser.add_argument( + "--incidental-growth", + default=DEFAULT_INCIDENTAL_GROWTH, + type=int, + help="max lines a PR may add to an existing tracked file without refreshing the budget", + ) + parser.add_argument( + "--hard-cap", + default=DEFAULT_HARD_CAP, + type=int, + help="absolute max lines for an existing tracked file, even with incidental PR growth", + ) + args = parser.parse_args(argv) + + if args.threshold < 1: + print("--threshold must be at least 1", file=sys.stderr) + return 2 + if args.incidental_growth < 0: + print("--incidental-growth must be non-negative", file=sys.stderr) + return 2 + if args.hard_cap < args.threshold: + print("--hard-cap must be at least --threshold", file=sys.stderr) + return 2 + if args.write_budget and args.merge_ref: + print("--write-budget cannot be used with --merge-ref", file=sys.stderr) + return 2 + + repo_root = args.repo_root.resolve(strict=False) + budget_path = args.budget if args.budget.is_absolute() else repo_root / args.budget + + merged_tree: str | None = None + if args.merge_ref: + merged_tree = speculative_merge_tree(repo_root, args.merge_ref, args.merge_head) + + if merged_tree: + print( + f"Evaluating speculative merge of {args.merge_ref} and {args.merge_head} " + f"(tree {merged_tree})." + ) + try: + file_lengths = collect_file_lengths_at_ref(repo_root, merged_tree, tuple(args.roots)) + except RuntimeError as exc: + print(f"Error reading Swift files from merged tree: {exc}", file=sys.stderr) + return 2 + actual = tracked_file_lengths(file_lengths, args.threshold) + print_file_summary("All scanned cmux-owned Swift files", file_lengths) + print_file_summary(f"Tracked Swift files >= {args.threshold} lines", actual) + + budget_ref_path = repo_relative_path(repo_root, budget_path) + try: + allowed = load_budget_at_ref(repo_root, merged_tree, budget_ref_path) if budget_ref_path else None + except ValueError as exc: + print(f"Error reading Swift file length budget: {exc}", file=sys.stderr) + return 2 + if allowed is None: + print(f"Missing Swift file length budget: {budget_path}", file=sys.stderr) + return 2 + base_allowed: FileLengthBudget | None = None + try: + base_allowed = load_budget_at_ref(repo_root, args.merge_ref, budget_ref_path) + except ValueError as exc: + print(f"Error reading base Swift file length budget: {exc}", file=sys.stderr) + return 2 + print_file_summary("Allowed Swift file length budget", allowed) + return compare_budget( + actual, + allowed, + base_allowed, + args.threshold, + file_lengths, + repo_root, + args.merge_ref, + args.incidental_growth, + args.hard_cap, + ) + + file_lengths = collect_file_lengths(repo_root, tuple(args.roots)) + actual = tracked_file_lengths(file_lengths, args.threshold) + print_file_summary("All scanned cmux-owned Swift files", file_lengths) + print_file_summary(f"Tracked Swift files >= {args.threshold} lines", actual) + + if args.write_budget: + write_budget(budget_path, actual) + print(f"Wrote {budget_path}") + return 0 + + if not budget_path.exists(): + print(f"Missing Swift file length budget: {budget_path}", file=sys.stderr) + return 2 + + try: + allowed = load_budget(budget_path) + except ValueError as exc: + print(f"Error reading Swift file length budget: {exc}", file=sys.stderr) + return 2 + base_allowed: FileLengthBudget | None = None + if args.base_ref: + budget_ref_path = repo_relative_path(repo_root, budget_path) + if budget_ref_path is not None: + try: + base_allowed = load_budget_at_ref(repo_root, args.base_ref, budget_ref_path) + except ValueError as exc: + print(f"Error reading base Swift file length budget: {exc}", file=sys.stderr) + return 2 + print_file_summary("Allowed Swift file length budget", allowed) + return compare_budget( + actual, + allowed, + base_allowed, + args.threshold, + file_lengths, + repo_root, + args.base_ref, + args.incidental_growth, + args.hard_cap, + ) + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) + diff --git a/webviews/src/agent-session/shared/bridge.test.ts b/webviews/src/agent-session/shared/bridge.test.ts new file mode 100644 index 000000000000..a7a85f427071 --- /dev/null +++ b/webviews/src/agent-session/shared/bridge.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from "bun:test"; +import { + __receiveAgentEventForTests, + __resetAgentBridgeForTests, + subscribeToAgentEvents, +} from "./bridge"; +import type { AgentEvent } from "./types"; + +const inputAcceptedEvent: AgentEvent = { + type: "provider.inputAccepted", + providerId: "codex", + sessionId: "session-1", + text: "queued before listener", + sentAtMs: 1_850_000_000_001, +}; + +test("native events received before subscription replay once", () => { + __resetAgentBridgeForTests(); + __receiveAgentEventForTests(inputAcceptedEvent); + + const received: AgentEvent[] = []; + const unsubscribe = subscribeToAgentEvents((event) => received.push(event)); + unsubscribe(); + + const lateSubscriberEvents: AgentEvent[] = []; + subscribeToAgentEvents((event) => lateSubscriberEvents.push(event))(); + + expect(received).toEqual([inputAcceptedEvent]); + expect(lateSubscriberEvents).toEqual([]); + __resetAgentBridgeForTests(); +}); + +test("native events received after subscription deliver immediately", () => { + __resetAgentBridgeForTests(); + const received: AgentEvent[] = []; + const unsubscribe = subscribeToAgentEvents((event) => received.push(event)); + + __receiveAgentEventForTests(inputAcceptedEvent); + + expect(received).toEqual([inputAcceptedEvent]); + unsubscribe(); + __resetAgentBridgeForTests(); +}); diff --git a/webviews/src/agent-session/shared/bridge.ts b/webviews/src/agent-session/shared/bridge.ts index d2ff180f2b26..f005b6d5a241 100644 --- a/webviews/src/agent-session/shared/bridge.ts +++ b/webviews/src/agent-session/shared/bridge.ts @@ -18,6 +18,8 @@ declare global { } const listeners = new Set(); +const pendingEvents: AgentEvent[] = []; +const maxPendingEvents = 512; export class NativeBridgeError extends Error { readonly code?: string; @@ -34,24 +36,48 @@ if (typeof window !== "undefined") { applyTheme(theme: AgentSessionTheme) { applyAgentTheme(theme); }, - receive(event: AgentEvent) { - if (event.type === "app.theme") { - applyAgentTheme(event.theme); - } - for (const listener of listeners) { - listener(event); - } - }, + receive: receiveAgentEvent, }; } export function subscribeToAgentEvents(listener: EventListener): () => void { listeners.add(listener); + if (pendingEvents.length > 0) { + const replay = pendingEvents.splice(0); + for (const event of replay) { + listener(event); + } + } return () => { listeners.delete(listener); }; } +function receiveAgentEvent(event: AgentEvent): void { + if (event.type === "app.theme") { + applyAgentTheme(event.theme); + } + if (listeners.size === 0) { + pendingEvents.push(event); + if (pendingEvents.length > maxPendingEvents) { + pendingEvents.splice(0, pendingEvents.length - maxPendingEvents); + } + return; + } + for (const listener of listeners) { + listener(event); + } +} + +export function __receiveAgentEventForTests(event: AgentEvent): void { + receiveAgentEvent(event); +} + +export function __resetAgentBridgeForTests(): void { + listeners.clear(); + pendingEvents.splice(0); +} + export async function callNative(method: string, params: Record = {}): Promise { if (typeof window === "undefined") { throw new Error("Native bridge is unavailable."); diff --git a/webviews/src/agent-session/shared/sessionModel.test.ts b/webviews/src/agent-session/shared/sessionModel.test.ts index ea276c0a74cf..b9a15d84e414 100644 --- a/webviews/src/agent-session/shared/sessionModel.test.ts +++ b/webviews/src/agent-session/shared/sessionModel.test.ts @@ -149,6 +149,114 @@ test("provider started event records running session", () => { expect(state.log.at(-1)?.text).toBe("Provider started"); }); +test("provider started event from native submit attaches while idle", () => { + const idle = reduceSession(initialState("react"), { type: "context", context }); + const state = reduceSession(idle, { + type: "event", + event: { + type: "provider.started", + providerId: "claude", + sessionId: "session-2", + executablePath: "/usr/local/bin/claude", + arguments: ["-p"], + }, + }); + + expect(state.status).toBe("running"); + expect(state.runningSessionId).toBe("session-2"); + expect(state.selectedProviderId).toBe("claude"); + expect(state.log.at(-1)?.text).toBe("Provider started"); +}); + +test("provider input accepted event appends native submitted user row", () => { + const running = { + ...reduceSession(initialState("react"), { type: "context", context }), + status: "running" as const, + runningSessionId: "session-1", + }; + const state = reduceSession(running, { + type: "event", + event: { + type: "provider.inputAccepted", + providerId: "codex", + sessionId: "session-1", + text: "hello from socket", + sentAtMs: 1_850_000_000_000, + }, + }); + + expect(state.log.at(-1)?.text).toBe("Sent 17 chars"); + expect(state.transcript.at(-1)).toMatchObject({ + role: "user", + sentAtMs: 1_850_000_000_000, + text: "hello from socket", + }); +}); + +test("provider input accepted event from native submit attaches while idle", () => { + const idle = reduceSession(initialState("react"), { type: "context", context }); + const state = reduceSession(idle, { + type: "event", + event: { + type: "provider.inputAccepted", + providerId: "codex", + sessionId: "session-3", + text: "hello before started event", + sentAtMs: 1_850_000_000_001, + }, + }); + + expect(state.status).toBe("running"); + expect(state.runningSessionId).toBe("session-3"); + expect(state.selectedProviderId).toBe("codex"); + expect(state.log.at(-1)?.text).toBe("Sent 26 chars"); + expect(state.transcript.at(-1)).toMatchObject({ + role: "user", + sentAtMs: 1_850_000_000_001, + text: "hello before started event", + }); +}); + +test("context active session hydrates running state from native snapshot", () => { + const state = reduceSession(initialState("react"), { + type: "context", + context: { + ...context, + activeSession: { + sessionId: "session-restored", + providerId: "claude", + executablePath: "/usr/local/bin/claude", + arguments: ["-p"], + workingDirectory: "/tmp/project", + }, + }, + }); + + expect(state.status).toBe("running"); + expect(state.runningSessionId).toBe("session-restored"); + expect(state.selectedProviderId).toBe("claude"); + expect(state.context?.activeSession?.workingDirectory).toBe("/tmp/project"); + expect(shouldAutoStartProvider(state)).toBe(false); +}); + +test("context load preserves provider event that arrived before context", () => { + const startedBeforeContext = reduceSession(initialState("react"), { + type: "event", + event: { + type: "provider.started", + providerId: "codex", + sessionId: "session-before-context", + executablePath: "/usr/local/bin/codex", + arguments: ["app-server", "--listen", "stdio://"], + }, + }); + const state = reduceSession(startedBeforeContext, { type: "context", context }); + + expect(state.status).toBe("running"); + expect(state.runningSessionId).toBe("session-before-context"); + expect(state.selectedProviderId).toBe("codex"); +}); + test("rate limit row event updates context", () => { const initial = reduceSession(initialState("react"), { type: "context", context }); const state = reduceSession(initial, { @@ -556,7 +664,7 @@ test("accepted start reply tracks session before provider started event", () => ); const accepted = reduceSession(starting, { type: "startAccepted", sessionId: "session-1" }); - expect(accepted.status).toBe("starting"); + expect(accepted.status).toBe("running"); expect(accepted.runningSessionId).toBe("session-1"); expect(canStopProvider(accepted)).toBe(true); diff --git a/webviews/src/agent-session/shared/sessionModel.ts b/webviews/src/agent-session/shared/sessionModel.ts index be73f791259e..c1332272c70d 100644 --- a/webviews/src/agent-session/shared/sessionModel.ts +++ b/webviews/src/agent-session/shared/sessionModel.ts @@ -93,12 +93,7 @@ export function initialState(_renderer: AppContext["renderer"]): SessionState { export function reduceSession(state: SessionState, action: Action): SessionState { switch (action.type) { case "context": - return appendContextReadyLog({ - ...state, - context: action.context, - selectedProviderId: action.context.initialProviderId, - status: "idle", - }); + return appendContextReadyLog(sessionStateWithContext(state, action.context)); case "providers": return { ...state, providers: action.providers }; case "selectProvider": @@ -130,6 +125,7 @@ export function reduceSession(state: SessionState, action: Action): SessionState runningSessionId: action.sessionId, requestedStopSessionId: undefined, seenSessionIds: rememberSessionId(state, action.sessionId), + status: "running", }; case "stopping": return { @@ -184,6 +180,33 @@ export function reduceSession(state: SessionState, action: Action): SessionState } } +function sessionStateWithContext(state: SessionState, context: AppContext): SessionState { + if (context.activeSession) { + return { + ...state, + context, + runningSessionId: context.activeSession.sessionId, + requestedStopSessionId: undefined, + selectedProviderId: context.activeSession.providerId, + seenSessionIds: rememberSessionId(state, context.activeSession.sessionId), + status: "running", + }; + } + if (state.runningSessionId) { + return { + ...state, + context, + status: state.status === "loading" ? "running" : state.status, + }; + } + return { + ...state, + context, + selectedProviderId: context.initialProviderId, + status: "idle", + }; +} + export async function loadInitialData(dispatch: (action: Action) => void): Promise { try { const [context, providers] = await Promise.all([ @@ -381,20 +404,37 @@ function applyEvent(state: SessionState, event: AgentEvent): SessionState { if (state.runningSessionId && event.sessionId !== state.runningSessionId) { return state; } - if (!state.runningSessionId && state.status !== "starting") { + if ( + !state.runningSessionId && + state.status !== "starting" && + state.status !== "idle" && + state.status !== "failed" && + state.status !== "loading" + ) { return state; } - if (!state.runningSessionId && event.providerId !== state.selectedProviderId) { + if (!state.runningSessionId && state.status === "starting" && event.providerId !== state.selectedProviderId) { return state; } return { ...state, runningSessionId: event.sessionId, requestedStopSessionId: undefined, + selectedProviderId: event.providerId, seenSessionIds: rememberSessionId(state, event.sessionId), status: "running", log: appendLog(state, "info", copyText(state, "providerStarted", "Provider started")), }; + case "provider.inputAccepted": + const inputState = attachProviderEventSession(state, event); + if (!inputState) { + return state; + } + return { + ...inputState, + log: appendLog(inputState, "info", formatCopy(inputState, "sentCharsFormat", "Sent %d chars", event.text.length)), + transcript: appendUserTranscript(inputState, event.text, undefined, event.sentAtMs ?? Date.now()), + }; case "provider.output": if (event.sessionId !== state.runningSessionId) { return state; @@ -474,6 +514,26 @@ function isCurrentOrPendingStartExit(state: SessionState, event: Extract, +): SessionState | undefined { + if (event.sessionId === state.runningSessionId) { + return state; + } + if (state.runningSessionId || event.sessionId === state.requestedStopSessionId || state.status === "stopping") { + return undefined; + } + return { + ...state, + runningSessionId: event.sessionId, + requestedStopSessionId: undefined, + selectedProviderId: event.providerId, + seenSessionIds: rememberSessionId(state, event.sessionId), + status: "running", + }; +} + function rememberSessionId(state: SessionState, sessionId: string): string[] { if (state.seenSessionIds.includes(sessionId)) { return state.seenSessionIds; diff --git a/webviews/src/agent-session/shared/types.ts b/webviews/src/agent-session/shared/types.ts index 8961748907ae..e976f45368c3 100644 --- a/webviews/src/agent-session/shared/types.ts +++ b/webviews/src/agent-session/shared/types.ts @@ -35,12 +35,21 @@ export type AppContext = { workspaceId: string; renderer: RendererKind; initialProviderId: ProviderId; + activeSession?: AgentSessionActiveSession; workingDirectory?: string; rateLimitRows?: AgentSessionRateLimitRow[]; copy: AgentSessionCopy; theme: AgentSessionTheme; }; +export type AgentSessionActiveSession = { + sessionId: string; + providerId: ProviderId; + executablePath: string; + arguments: string[]; + workingDirectory?: string; +}; + export type AgentSessionRateLimitRow = { role: "primary" | "secondary"; remainingPercent: number; @@ -152,6 +161,13 @@ export type AgentEvent = stream: "stdout" | "stderr"; text: string; } + | { + type: "provider.inputAccepted"; + sessionId: string; + providerId: ProviderId; + text: string; + sentAtMs?: number; + } | { type: "provider.activity"; sessionId: string;