Repository: loocor/codmate Branch: main Commit: 2091c39609db Files: 472 Total size: 4.0 MB Directory structure: gitextract_0tk3_bnf/ ├── .gitignore ├── .vscode/ │ └── launch.json ├── AGENTS.md ├── CodMateApp.swift ├── Ghostty-header.h ├── LICENSE ├── Makefile ├── NOTICE ├── Package.resolved ├── Package.swift ├── PrivacyInfo.xcprivacy ├── README.md ├── THIRD-PARTY-NOTICES.md ├── Tests/ │ └── CodMateTests/ │ ├── ClaudeHooksAdapterTests.swift │ ├── CodexHooksAdapterTests.swift │ ├── GeminiHooksAdapterTests.swift │ ├── HooksStoreTests.swift │ ├── UpdateServiceTests.swift │ ├── UpdateSupportTests.swift │ ├── UpdateViewModelTests.swift │ └── WizardResponseParserTests.swift ├── assets/ │ ├── Assets.xcassets/ │ │ ├── AntigravityIcon.imageset/ │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset/ │ │ │ └── Contents.json │ │ ├── ChatGPTIcon.imageset/ │ │ │ └── Contents.json │ │ ├── ClaudeIcon.imageset/ │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── DeepSeekIcon.imageset/ │ │ │ └── Contents.json │ │ ├── GeminiIcon.imageset/ │ │ │ └── Contents.json │ │ ├── KimiIcon.imageset/ │ │ │ └── Contents.json │ │ ├── MCPMateLogo.imageset/ │ │ │ └── Contents.json │ │ ├── MiniMaxIcon.imageset/ │ │ │ └── Contents.json │ │ ├── OpenRouterIcon.imageset/ │ │ │ └── Contents.json │ │ ├── QwenIcon.imageset/ │ │ │ └── Contents.json │ │ └── ZaiIcon.imageset/ │ │ └── Contents.json │ ├── CodMate-Notify.entitlements │ ├── CodMate.entitlements │ └── Info.plist ├── docs/ │ ├── feature-inventory.md │ ├── icon.icon/ │ │ └── icon.json │ └── projects.md ├── ghostty/ │ ├── Package.swift │ ├── Resources/ │ │ └── themes/ │ │ ├── Apple Classic │ │ ├── Apple System Colors │ │ ├── Apple System Colors Light │ │ ├── Atom │ │ ├── Atom One Dark │ │ ├── Atom One Light │ │ ├── Dracula │ │ ├── Dracula+ │ │ ├── Farmhouse Dark │ │ ├── Farmhouse Light │ │ ├── Flexoki Dark │ │ ├── Flexoki Light │ │ ├── GitHub │ │ ├── GitHub Dark │ │ ├── GitHub Dark Colorblind │ │ ├── GitHub Dark Default │ │ ├── GitHub Dark Dimmed │ │ ├── GitHub Dark High Contrast │ │ ├── GitHub Light Colorblind │ │ ├── GitHub Light Default │ │ ├── GitHub Light High Contrast │ │ ├── GitLab Dark │ │ ├── GitLab Dark Grey │ │ ├── GitLab Light │ │ ├── Iceberg Dark │ │ ├── Iceberg Light │ │ ├── Material │ │ ├── Material Dark │ │ ├── Material Darker │ │ ├── Material Design Colors │ │ ├── Material Ocean │ │ ├── Melange Dark │ │ ├── Melange Light │ │ ├── Monokai Pro │ │ ├── Monokai Pro Light │ │ ├── Monokai Pro Light Sun │ │ ├── Monokai Pro Machine │ │ ├── Monokai Pro Octagon │ │ ├── Monokai Pro Ristretto │ │ ├── Monokai Pro Spectrum │ │ ├── Monokai Remastered │ │ ├── Neobones Dark │ │ ├── Neobones Light │ │ ├── Nvim Dark │ │ ├── Nvim Light │ │ ├── One Double Dark │ │ ├── One Double Light │ │ ├── One Half Dark │ │ ├── One Half Light │ │ ├── Pencil Dark │ │ ├── Pencil Light │ │ ├── Raycast Dark │ │ ├── Raycast Light │ │ ├── Selenized Dark │ │ ├── Selenized Light │ │ ├── Seoulbones Dark │ │ ├── Seoulbones Light │ │ ├── Tinacious Design Dark │ │ ├── Tinacious Design Light │ │ ├── TokyoNight │ │ ├── TokyoNight Day │ │ ├── TokyoNight Moon │ │ ├── TokyoNight Night │ │ ├── TokyoNight Storm │ │ ├── Tomorrow │ │ ├── Tomorrow Night │ │ ├── Tomorrow Night Blue │ │ ├── Tomorrow Night Bright │ │ ├── Tomorrow Night Burns │ │ ├── Tomorrow Night Eighties │ │ ├── Xcode Dark │ │ ├── Xcode Dark hc │ │ ├── Xcode Light │ │ ├── Xcode Light hc │ │ ├── Zenbones Dark │ │ ├── Zenbones Light │ │ ├── Zenwritten Dark │ │ ├── Zenwritten Light │ │ ├── iTerm2 Solarized Dark │ │ ├── iTerm2 Solarized Light │ │ ├── iTerm2 Tango Dark │ │ └── iTerm2 Tango Light │ ├── Sources/ │ │ ├── CGhostty/ │ │ │ └── module.modulemap │ │ └── GhosttyKit/ │ │ ├── Clipboard.swift │ │ ├── Ghostty.Action.swift │ │ ├── Ghostty.App.swift │ │ ├── Ghostty.Input.swift │ │ ├── Ghostty.Key.swift │ │ ├── Ghostty.KeyEvent.swift │ │ ├── Ghostty.Mods.swift │ │ ├── Ghostty.MouseEvent.swift │ │ ├── Ghostty.Surface.swift │ │ ├── GhosttyIMEHandler.swift │ │ ├── GhosttyInputHandler.swift │ │ ├── GhosttyProgressState.swift │ │ ├── GhosttyRenderingSetup.swift │ │ ├── GhosttyTerminalView.swift │ │ ├── GhosttyThemeLoader.swift │ │ ├── TerminalScrollView.swift │ │ └── TerminalTextCleaner.swift │ └── Vendor/ │ ├── VERSION │ └── include/ │ ├── ghostty/ │ │ ├── vt/ │ │ │ ├── allocator.h │ │ │ ├── color.h │ │ │ ├── key/ │ │ │ │ ├── encoder.h │ │ │ │ └── event.h │ │ │ ├── key.h │ │ │ ├── osc.h │ │ │ ├── paste.h │ │ │ ├── result.h │ │ │ ├── sgr.h │ │ │ └── wasm.h │ │ └── vt.h │ ├── ghostty.h │ └── module.modulemap ├── models/ │ ├── ActivityChartData.swift │ ├── AllOverviewViewModel.swift │ ├── CLIPathVM.swift │ ├── ClaudeCodeVM.swift │ ├── ClaudeUsageStatus.swift │ ├── CodexUsageStatus.swift │ ├── CodexVM.swift │ ├── CommandRecord.swift │ ├── CommandsViewModel.swift │ ├── ConversationTurn.swift │ ├── DateDimension.swift │ ├── DialecticsVM.swift │ ├── EditorApp.swift │ ├── EnvironmentContextInfo.swift │ ├── ExecutionPolicy.swift │ ├── ExtensionsImportModels.swift │ ├── ExtensionsSettingsTab.swift │ ├── ExternalTerminalProfile.swift │ ├── GeminiUsageStatus.swift │ ├── GeminiVM.swift │ ├── GitChangesViewModel.swift │ ├── GitGraphViewModel.swift │ ├── GitReviewTree.swift │ ├── GlobalSearchModels.swift │ ├── GlobalSearchViewModel.swift │ ├── HookCommandVariableCatalog.swift │ ├── HookEventCatalog.swift │ ├── HookSyncWarning.swift │ ├── Hooks.swift │ ├── HooksViewModel.swift │ ├── InternalSkill.swift │ ├── LocalAuthProvider.swift │ ├── MCPServer.swift │ ├── MCPServersViewModel.swift │ ├── OverviewAggregate.swift │ ├── PathTree.swift │ ├── Project.swift │ ├── ProjectExtensionsModels.swift │ ├── ProjectExtensionsViewModel.swift │ ├── ProjectOverviewViewModel.swift │ ├── ProjectWorkspaceMode.swift │ ├── ProjectWorkspaceViewModel+Generation.swift │ ├── ProjectWorkspaceViewModel.swift │ ├── RefreshRequest.swift │ ├── ReviewPanelState.swift │ ├── SessionEvent.swift │ ├── SessionLaunchProvider.swift │ ├── SessionListViewModel+Commands.swift │ ├── SessionListViewModel+Editor.swift │ ├── SessionListViewModel+Intents.swift │ ├── SessionListViewModel+Notes.swift │ ├── SessionListViewModel+Projects.swift │ ├── SessionListViewModel+SearchSupport.swift │ ├── SessionListViewModel.swift │ ├── SessionLoadScope.swift │ ├── SessionNavigation.swift │ ├── SessionPathConfig.swift │ ├── SessionSource+CaseIterable.swift │ ├── SessionSummary.swift │ ├── SettingCategory.swift │ ├── SidebarState.swift │ ├── SkillsLibraryViewModel.swift │ ├── SkillsModels.swift │ ├── StatusBarLogEntry.swift │ ├── StatusBarVisibility.swift │ ├── SystemMenuVisibility.swift │ ├── Task.swift │ ├── TerminalCursorStyleOption.swift │ ├── TimelineEvent.swift │ ├── UnifiedProviderCatalog.swift │ ├── UnifiedProviderID.swift │ ├── UpdateViewModel.swift │ ├── UsageProviderSnapshot.swift │ ├── WarpTitleBuilder.swift │ ├── WizardConversationViewModel.swift │ ├── WizardGuard.swift │ └── WizardModels.swift ├── notify/ │ └── NotifyMain.swift ├── payload/ │ ├── commands/ │ │ └── index.json │ ├── hook-events.json │ ├── hook-variables.json │ ├── internal-skills/ │ │ ├── commands-wizard/ │ │ │ ├── SKILL.md │ │ │ ├── prompt.md │ │ │ └── schema.json │ │ ├── hooks-wizard/ │ │ │ ├── SKILL.md │ │ │ ├── prompt.md │ │ │ └── schema.json │ │ ├── index.json │ │ ├── mcp-wizard/ │ │ │ ├── SKILL.md │ │ │ ├── prompt.md │ │ │ └── schema.json │ │ └── skills-wizard/ │ │ ├── SKILL.md │ │ ├── prompt.md │ │ └── schema.json │ ├── knowledge/ │ │ └── wizard-docs.json │ ├── prompts/ │ │ ├── commit-message.md │ │ ├── task-title-and-description.md │ │ ├── task-title-only.md │ │ └── title-and-comment.md │ ├── providers.json │ └── terminals.json ├── scripts/ │ ├── BUILD.md │ ├── build-libghostty-local.sh │ ├── create-app-bundle.sh │ ├── gen-third-party-notices.py │ ├── macos-build-notarized-dmg.sh │ └── test-commands-sync.sh ├── services/ │ ├── AppLogger.swift │ ├── AuthorizationHub.swift │ ├── BrowserCookies/ │ │ ├── ChromeCookieImporter.swift │ │ ├── CookieRecord.swift │ │ ├── DataReader.swift │ │ └── SafariCookieImporter.swift │ ├── CLIProxyBridge.swift │ ├── CLIProxyService.swift │ ├── ClaudeSessionParser.swift │ ├── ClaudeSessionProvider.swift │ ├── ClaudeSettingsService.swift │ ├── ClaudeUsageAPIClient.swift │ ├── ClaudeUsageAnalyzer.swift │ ├── ClaudeWebAPIClient.swift │ ├── CodexAppServerProbeService.swift │ ├── CodexConfigService.swift │ ├── CodexFeaturesService.swift │ ├── CodexOAuthUsageFetcher.swift │ ├── CommandsImportService.swift │ ├── CommandsStore.swift │ ├── CommandsSyncService.swift │ ├── ContextTreeshaker.swift │ ├── DirectoryMonitor.swift │ ├── DockOpenCoordinator.swift │ ├── EmbeddedNotifySniffer.swift │ ├── ExternalTerminalProfileStore.swift │ ├── ExternalURLRouter.swift │ ├── GeminiSessionParser.swift │ ├── GeminiSessionProvider.swift │ ├── GeminiSettingsService.swift │ ├── GeminiUsageAPIClient.swift │ ├── GhosttySessionManager.swift │ ├── GitService.swift │ ├── GlobalSearchService.swift │ ├── HooksImportService.swift │ ├── HooksStore.swift │ ├── HooksSyncService.swift │ ├── InternalSkillRunner.swift │ ├── InternalSkillsRegistry.swift │ ├── LLMHTTPService.swift │ ├── LaunchAtLoginService.swift │ ├── LocalServerBuiltInProvider.swift │ ├── MCPImportService.swift │ ├── MCPQuickTestService.swift │ ├── MCPServersStore.swift │ ├── MainWindowCoordinator.swift │ ├── MenuBarController.swift │ ├── PathTreeStore.swift │ ├── PresetPromptsStore.swift │ ├── ProjectExtensionsApplier.swift │ ├── ProjectExtensionsStore.swift │ ├── ProjectsStore.swift │ ├── ProvidersRegistryService.swift │ ├── RemoteSessionMirror.swift │ ├── RemoteSessionProvider+Adapter.swift │ ├── RemoteSessionProvider.swift │ ├── RepoContentSearchService.swift │ ├── RipgrepDiskCache.swift │ ├── RipgrepRunner.swift │ ├── SSHConfigResolver.swift │ ├── SandboxPermissionsManager.swift │ ├── SecurityScopedBookmarks.swift │ ├── SessionActions+Commands.swift │ ├── SessionActions+Config.swift │ ├── SessionActions+FileActions.swift │ ├── SessionActions+Terminal.swift │ ├── SessionActions.swift │ ├── SessionActivityTracker.swift │ ├── SessionCacheStore.swift │ ├── SessionCommandGenerator.swift │ ├── SessionEnrichmentService.swift │ ├── SessionIndexSQLiteStore.swift │ ├── SessionIndexer.swift │ ├── SessionNotesStore.swift │ ├── SessionPreferencesStore.swift │ ├── SessionProvider.swift │ ├── SessionRipgrepStore.swift │ ├── SessionTimelineLoader.swift │ ├── SessionsDiagnosticsService.swift │ ├── SkillsImportService.swift │ ├── SkillsStore.swift │ ├── SkillsSyncService.swift │ ├── StatusBarLogStore.swift │ ├── SystemNotifier.swift │ ├── TasksStore.swift │ ├── TimelineAttachmentDecoder.swift │ ├── TimelineAttachmentOpener.swift │ ├── UniImportMCPNormalizer.swift │ ├── UpdateService.swift │ ├── WindowStateStore.swift │ ├── WizardDocsService.swift │ └── WizardResponseParser.swift ├── utils/ │ ├── AppAvailability.swift │ ├── AppDistribution.swift │ ├── AppSandbox.swift │ ├── CLIEnvironment.swift │ ├── EmbeddedSessionNotification.swift │ ├── FilenameSanitizer.swift │ ├── FlexibleDecoders.swift │ ├── InternalWizardPaths.swift │ ├── MarkdownExportBuilder.swift │ ├── ModelNameSanitizer.swift │ ├── ProviderIconResource.swift │ ├── ProviderIconThemeHelper.swift │ ├── SessionPathFilter.swift │ ├── SessionSummaryMaterialBuilder.swift │ ├── ShellCommandRunner.swift │ ├── TagView.swift │ ├── TerminalFontResolver.swift │ ├── TimelineEventClassifier.swift │ ├── TokenFormatter.swift │ ├── UpdateSupport.swift │ ├── WarpTitlePrompt.swift │ └── WindowConfigurator.swift └── views/ ├── APIKeyProviderIconView.swift ├── AboutViews.swift ├── AdvancedPathPane.swift ├── AdvancedSettingsView.swift ├── AttributedTextView.swift ├── AutoAssignSheet.swift ├── CLIProxyAdvancedPane.swift ├── CalendarMonthView.swift ├── ClaudeCodeSettingsView.swift ├── ClaudeModelMappingSheet.swift ├── CodexSettingsView.swift ├── CommandsSettingsView.swift ├── Content/ │ ├── AllOverviewView.swift │ ├── ContentView+Detail.swift │ ├── ContentView+DetailActionBar.swift │ ├── ContentView+Helpers.swift │ ├── ContentView+MainDetail.swift │ ├── ContentView+Modifiers.swift │ ├── ContentView+Search.swift │ ├── ContentView+Sidebar.swift │ ├── ContentView.swift │ └── StatusBarOverlayView.swift ├── Controls/ │ ├── CollapseExpandButtonGroup.swift │ ├── FontPickerButton.swift │ ├── RainbowSpinnerView.swift │ └── TableSpacingRemover.swift ├── ConversationTimelineView.swift ├── DiagnosticsViews.swift ├── DialecticsPane.swift ├── EditSessionMetaView.swift ├── EditorMenuHelpers.swift ├── EmbeddedTerminalView.swift ├── EquatableContainers.swift ├── ExtensionsImportSheets.swift ├── ExtensionsSettingsView.swift ├── ExternalTerminalMenuHelpers.swift ├── GeminiSettingsView.swift ├── GitChanges/ │ ├── GitChangesPanel+Browser.swift │ ├── GitChangesPanel+Detail.swift │ ├── GitChangesPanel+DiffTree.swift │ ├── GitChangesPanel+Graph.swift │ ├── GitChangesPanel+Header.swift │ ├── GitChangesPanel+Helpers.swift │ ├── GitChangesPanel+LeftPane.swift │ ├── GitChangesPanel+Lifecycle.swift │ ├── GitChangesPanel+Menus.swift │ └── GitChangesPanel.swift ├── GitReviewSettingsView.swift ├── HookEditSheet.swift ├── HooksSettingsView.swift ├── LiveFileSizeText.swift ├── LocalAuthProviderIconView.swift ├── MCPServerTargetToggle.swift ├── MCPServersSettingsView.swift ├── ModelListEditorSheet.swift ├── NewTaskSheet.swift ├── OverviewActivityChart.swift ├── OverviewCard.swift ├── PathTreeView.swift ├── ProjectAgentsView.swift ├── ProjectOverviewView.swift ├── ProjectSpecificOverviewContainerView.swift ├── ProjectsListView.swift ├── ProviderEditorView.swift ├── ProviderIconView.swift ├── ProvidersSettingsView.swift ├── RecentSessionsListView.swift ├── RemoteHostsSettingsView.swift ├── SandboxApprovalEditor.swift ├── SandboxPermissionsView.swift ├── Search/ │ ├── GlobalSearchPanel.swift │ └── ToolbarSearchField.swift ├── SessionDetailView.swift ├── SessionListColumnView.swift ├── SessionListRowView.swift ├── SessionNavigationView.swift ├── SessionPathGroup.swift ├── SessionPathRow.swift ├── SessionsPathPane.swift ├── SettingsCompatibility.swift ├── SettingsTabContent.swift ├── SettingsView.swift ├── SimpleProviderPicker.swift ├── Skills/ │ └── SkillPackageExplorerView.swift ├── SkillsSettingsView.swift ├── SplitControls.swift ├── TaskListView.swift ├── TripleUsageDonutView.swift ├── UnavailableStateView.swift ├── UnifiedProviderPickerView.swift ├── UsageStatusControl.swift └── Wizard/ ├── CommandWizardSheet.swift ├── HookWizardSheet.swift ├── MCPWizardSheet.swift ├── SkillWizardSheet.swift └── WizardConversationView.swift ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ .build/ .swiftpm/ .DS_Store artifacts/ build/ build-mas/ logs/ .xcuserstate ._* CodMate.app # Xcode per-user state and caches **/xcuserdata/** **/*.xcuserstate **/*.xcbkptlist CodMate.xcodeproj/project.xcworkspace/xcuserdata/ codmate.xcodeproj/project.xcworkspace/xcuserdata/ CodMate.xcodeproj/xcuserdata/ codmate.xcodeproj/xcuserdata/ # Vendored or local packages to exclude SwiftTerm/ dist/ .env .comate .serena # Project-level AI assistant configs (generated at runtime) .claude/ .codex/ .gemini/ # Sisyphus (agent) workspace .sisyphus/ # Local build artifacts libghostty.a ================================================ FILE: .vscode/launch.json ================================================ { "configurations": [ { "type": "lldb", "request": "launch", "args": [], "cwd": "${workspaceFolder:CodMate}", "name": "Debug CodMate", "program": "${workspaceFolder:CodMate}/.build/debug/CodMate", "preLaunchTask": "swift: Build Debug CodMate" }, { "type": "lldb", "request": "launch", "args": [], "cwd": "${workspaceFolder:CodMate}", "name": "Release CodMate", "program": "${workspaceFolder:CodMate}/.build/release/CodMate", "preLaunchTask": "swift: Build Release CodMate" } ] } ================================================ FILE: AGENTS.md ================================================ CodMate – AGENTS Guidelines Purpose - This document tells AI/code agents how to work inside the CodMate repository (macOS desktop GUI for Codex session management). - Scope: applies to the entire repo. Prefer macOS SwiftUI/AppKit APIs; avoid iOS‑only placements or components. Architecture - App type: macOS SwiftUI app (min macOS 13.5). SwiftPM-only build (no Xcode project). - Layering (MVVM): - Models: pure data structures (SessionSummary, SessionEvent, DateDimension, SessionLoadScope, …) - Services: IO and side effects (SessionIndexer, SessionCacheStore, SessionActions, SessionTimelineLoader, LLMClient) - ViewModels: async orchestration, filtering, state (SessionListViewModel) - Views: SwiftUI views only (no business logic) UI Rules (macOS specific) - Use macOS SwiftUI and AppKit bridges; do NOT use iOS‑only placements such as `.navigationBarTrailing`. - Settings uses macOS 15's new TabView API (`Tab("…", systemImage: "…")`) when available; provide a macOS 13.5/14 fallback with `tabItem` + `tag`. Container padding is unified (horizontal 16pt, top 16pt). - Tab content uniformly uses `SettingsTabContent` container (top-aligned, overall 8pt padding) to ensure consistent layout and spacing across pages. - Notifications is a top-level Settings page between Terminal and Providers; sections are Common, Codex, Claude Code, and Gemini CLI. Common toggles commit message, title/comment, and copy New/Resume command notifications. - Providers has been separated from the Codex tab into a top-level Settings page: Settings › Providers manages API key providers, OAuth providers, and Codex/Claude bindings; Settings › Codex only retains Runtime/Privacy/Raw Config (notifications live in Settings › Notifications). - OAuth providers (Codex/Claude/Gemini/Antigravity/Qwen) are added from the Providers “Add” menu and appear under an OAuth list section with login status and info actions. - CLI Proxy API status, reroute, and public access live under Providers as shared capabilities; deep diagnostics and installation details live under Settings › Advanced › CLI Proxy API. - Built-in providers are auto-loaded from an app-bundled `payload/providers.json` (managedByCodMate=true). This avoids hardcoding and lets users simply provide API keys; base URLs/models come pre-filled. The list merges bundled entries with `~/.codmate/providers.json` (user overrides win). - Schema note: use a single provider-level `envKey` (preferred) for both Codex and Claude Code connectors. Connector-level `envKey` remains tolerated for backward compatibility but is considered deprecated and will be ignored at save time to avoid duplication. - Extensions page (aligned with Providers style): - Settings › Extensions replaces the old MCP Server page (icon: puzzlepiece.extension). - Tab 1: MCP Servers (existing list/editor/Uni‑Import UI kept as-is inside the tab); add an Import button to scan Home MCP configs into CodMate. - Tab 2: Skills (left list + right details split; Add menu supports folder/zip/URL; auto‑sync on changes); add an Import button to scan Home skills into CodMate. - Commands tab includes Add and Import buttons (Import scans Home command folders into CodMate). - Import sheets show a vertical list; each row has a right‑aligned strategy control (Skip/Overwrite/Rename) and a context menu “Open in…” to review source files. - MCP Servers tab keeps: enable toggle on left, edit on right, fixed "Add" button, Uni‑Import preview and confirmation. - Advanced capabilities (MCPMate download and instructions) remain as a footer/section in MCP Servers tab. - Search: prefer a toolbar `SearchField` in macOS, not `.searchable` when exact placement (far right) matters. - Toolbars: place refresh as the last ToolbarItem to pin it at the far right. Keep destructive actions in the detail pane, not in the main toolbar. Command+R and the refresh button also invalidate and recompute global sidebar statistics (projects/path tree and calendar day counts) to reflect new sessions immediately. - Menu Bar (status item): keep it lightweight with status + quick actions. Show provider/model/sandbox/approval, New/Resume/Search/Open, Recent Projects/Sessions (max 5), Usage summary, Provider switch, Settings/Quit; avoid destructive actions. - Sidebar (left): - Top (fixed): "All Sessions" row showing total count and selection state. - Middle (scrollable): path tree built from `cwd` counts. Rows are compact: default min row height 18, small control size, reduced insets. Single-click selects/expands; double-click applies filter (enter the directory). - Projects mode mirrors the compact list style; Cmd-click toggles multi-selection so users can filter sessions by several projects simultaneously (descendants remain included). - Bottom (fixed): calendar month view (240pt height) with per-day counts (created/last-updated switch). Always pinned to the bottom with 8pt spacing above. Supports multi-select via Command-click to toggle multiple days; plain click selects a single day (click the same day to clear). - Only the middle path tree scrolls; top "All Sessions" and bottom calendar remain fixed. - Sidebar width: min 220pt, max 25% of window width, ideal 260pt. - Content (middle): - Default scope loads “today” only for speed. - Sorting picker is left‑aligned with list content. - Each row shows: title, timestamps/duration, snippet, and compact metrics (user/assistant/tool/reasoning). - Status bar (bottom console): - Docked console bar spans the right-side area (list + detail); sidebar stays full height. - Resizable via a drag handle; single-line header collapses/expands to multi-line log history. - Auto mode collapses when idle (no interaction); View menu supports Always Show/Hide. - Detail (right): - Sticky action bar at top: Resume, Reveal in Finder, Delete, Export Markdown. - Add “New” button next to Resume to start a fresh Codex session using the current session’s working directory and model. - When an embedded terminal is running, show a “Prompts” button beside the folder (Reveal in Finder) icon. Clicking opens a searchable popover of preset command texts; selecting one inserts it into the embedded terminal input (does not auto-execute). User presses Return to run. - Project-level Extensions are configured in **Edit Project**: tabs are General, Profile, MCP Servers, Skills (auto‑sync; Gemini project-level toggles disabled). MCP Servers/Skills tabs include Import buttons that scan the project directory. Edit Project window should be resizable. - Review mode: the list.bullet.rectangle button toggles a full-area Review view (third mode, alongside Conversation and Internal Terminal). In Review mode the detail area is fully occupied by a Git Changes surface. It: - Auto-detects the Git repo at the session’s working directory (uses `/usr/bin/env git` and a robust PATH). - Lists changed files with stage/unstage toggles and shows a unified diff or a raw file preview (updates on save). Preview is text-only in phase 1. - Provides a commit box. In full-area mode it uses a multi-line editor with more space. - Repository authorization is on-demand: when opening Review, the app resolves the repository root (the folder containing `.git`) and, if needed, prompts the user with an NSOpenPanel to authorize that folder via a security-scoped bookmark. The Settings page no longer lists authorized repositories; authorization and revoke are managed inline in the Review header. - “Task Instructions” uses a DisclosureGroup; load lazily when expanded. - Conversation timeline uses LazyVStack; differentiate user/assistant/tool/info bubbles. - Timeline & Markdown visibility: Settings › General provides per-surface checkboxes to choose which message types are shown in the conversation timeline and included when exporting Markdown. Defaults: Timeline shows User, Assistant, Reasoning, and Code Edit; Tool Invocation, Token Usage, and Other Info are off by default. Markdown includes only User and Assistant. Environment Context and Turn Context are surfaced in dedicated sections and not configurable; Task Instructions remain in the detail DisclosureGroup; Ghost Snapshot is ignored. Code edits are surfaced as their own message type (extracted from tool calls) and have a separate toggle. - Turn Context is surfaced in the Environment Context card and is not exposed as a separate toggle or timeline item. - Context menu in list rows adds: “Generate Title & 100-char Summary” to run LLM on-demand for the selected session. - Embedded Terminal: One live shell per session when resumed in-app; switching sessions in the middle list switches the attached terminal. The shell keeps running when you navigate away. “Return to history” closes the running shell for the focused session. - Prompt picker: When embedded terminal is running, a Prompts button opens a searchable list. Prompts are merged from per-project `.codmate/prompts.json` (if present) and `~/.codmate/prompts.json` (user), de-duplicated by command, then layered with a few built‑ins. Items accept either `{ "label": "…", "command": "…" }` or a plain string (used for both). Selection inserts into the terminal input without executing. The header wrench button opens the preferred file (project if exists, else user). Typing a new command shows “Add …” to create a prompt in the preferred file. Deleting a built‑in prompt records it in a hidden list (`prompts-hidden.json` at project if project prompts exist, else at user), which suppresses that built‑in in the UI. - Terminal shortcuts: (none for now). Clearing via shortcut is not implemented. Performance Contract - Fast path indexing: memory‑mapped reads; parse first ~400 lines + read tail ~64KB to correct `lastUpdatedAt`. - Background enrichment: full parse in a constrained task group; batch UI updates (≈10 items per flush). - Full‑text search: chunked stream scan (128 KB), case‑insensitive; avoid `lowercased()` on whole file. - Disk cache: `~/Library/Caches/CodMate/sessionIndex-v1.json` keyed by path+mtime; prefer cache hits before parsing. - Sidebar statistics (calendar/tree) must be global and computed independently of the current list scope to keep navigation usable. - Embedded terminals: keep shells alive when not visible; only render the selected session’s terminal. Users explicitly close shells via “Return to history” to release resources. Coding Guidelines - Concurrency: use `actor` for services managing shared caches; UI updates on MainActor only. - Cancellation: cancel previous tasks on new search/scope changes. Name tasks (`fulltextTask`, `enrichmentTask`) and guard `Task.isCancelled` in loops. - File IO: prefer `Data(mappedIfSafe:)` or `FileHandle.read(upToCount:)`; never load huge files into Strings. - Error handling: surface user‑visible errors through `ViewModel.errorMessage` and macOS system notifications/alerts; do not crash the UI. - Testability: keep parsers and small helpers pure; avoid `Process()`/AppKit in ViewModel. - Provider Icon Theme Handling: - Use `ProviderIconThemeHelper` (in `utils/ProviderIconThemeHelper.swift`) for consistent dark/light mode icon adaptation. - Icons that are black or dark-colored (ChatGPTIcon/Codex, KimiIcon, ZaiIcon, OpenRouterIcon) must be inverted in dark mode for visibility. - For SwiftUI views: use `.providerIconTheme(iconName:)` modifier or `ProviderIconDarkModeModifier` directly. - For AppKit menus: use `ProviderIconThemeHelper.menuImage(named:)` which automatically handles resizing and dark mode inversion. - Do NOT manually check dark mode and invert icons; always use the helper to ensure consistency across the app. CLI Integration (codex) - Prefer invoking via `/usr/bin/env codex` (or `claude`) so resolution happens on system `PATH`. - Allow optional user-specified command path overrides; use the override when valid, otherwise fall back to PATH resolution. - New/Resume command strings must use the bare CLI name unless the user explicitly set a CLI Command Path override; only then emit an absolute path. - Always set `PATH` to include `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin` before launching for robustness. - `resume` runs with `currentDirectoryURL` = original session `cwd` when it exists (fallback: log file directory). - New command options exposed in Settings › Command: - Sandbox policy (`-s/--sandbox`): `read-only`, `workspace-write`, `danger-full-access`. - Approval policy (`-a/--ask-for-approval`): `untrusted`, `on-failure`, `on-request`, `never`. - `--full-auto` convenience alias (maps to `-a on-failure` + `--sandbox workspace-write`). - `--dangerously-bypass-approvals-and-sandbox` (overrides other flags; only for externally sandboxed envs). - UI adds a "Copy real command" button in the detail action bar when the embedded terminal is active; this copies the exact `codex resume ` invocation including flags. - Provide a “New” command (detail toolbar) that launches `codex` in the session’s working directory while preserving the configured sandbox/approval defaults and `SessionSummary.model`. Codex Settings - Settings › Codex only manages Codex CLI runtime-related configuration (Model & Reasoning, Sandbox & Approvals, Notifications, Privacy, Raw Config). - Providers page is independent: Settings › Providers (cross-application shared, for Codex and Claude Code selection/configuration, including OAuth). - Provider selection uses the unified provider picker (same list as Git Review), backed by CLI Proxy API when chosen; Auto keeps CLI defaults. - Provider tab includes a separate Model List setting under Active Provider with an editor to add/remove model IDs per provider (empty = default list). - Notifications: TUI notifications toggle; system notifications bridge via the bundled Swift `codmate-notify` helper (installed to `~/Library/Application Support/CodMate/bin/`). - Privacy: expose `shell_environment_policy`, reasoning visibility, OTEL exporter; do not surface history persistence in phase 1. - Projects auto‑create a same‑id Profile on creation; renaming a project synchronizes the profile name. Conflict prompts are required. Claude Settings - Settings › Claude splits into Provider, Runtime, Notifications, and Raw Config tabs. - Provider selection uses the unified provider picker (same list as Git Review), backed by CLI Proxy API when chosen; Auto keeps CLI defaults. - Provider tab includes a Model List setting with Default/Opus/Sonnet/Haiku mappings (edited via the mapping sheet); mappings write to Claude env keys in `~/.claude/settings.json`. - Notifications tab mirrors Codex UX: single toggle to install/remove macOS notification hooks, health indicator, and self-test button. - Hooks write to `~/.claude/settings.json` under `hooks.Notification` and `hooks.Stop`, pointing to `/usr/bin/open -g "codmate://notify?source=claude&event=…&title64=…&body64=…"`. - Always request Home directory access through `AuthorizationHub` before mutating the hooks file when sandboxed. Gemini Settings - Settings › Gemini adds a Provider tab with the unified provider picker (same list as Git Review); Gemini CLI model selection remains in the Model tab. - Provider tab includes a Model List setting for CLI Proxy providers; the Model tab remains the source of truth for Gemini CLI model selection. Session Metadata (Rename/Comment) - Users can rename any session and attach a short comment. - Trigger: click the title at the top-left of the detail pane to open the editor. - Persistence: stored per file under `~/.codmate/notes/.json`. A first-run migration copies entries from the legacy Application Support JSON and migrates from the legacy `~/.codex/notes` directory when present. - Display: the name replaces the ID in the detail header and list; the comment is used as the row snippet when present. About Surface - Settings › About shows app version, build timestamp (derived from the app executable’s modification date), and project URL. - Settings › About includes update checking/downloading for non-App Store builds and guides manual install. - “About CodMate” menu item should open Settings pre-selecting the About tab. - Include an “Open Source Licenses” entry that displays `THIRD-PARTY-NOTICES.md` (bundled if present; falls back to repository URL if missing). Diagnostics - Settings › General adds “Diagnose Data Directories” to probe Sessions (`~/.codex/sessions`, `.jsonl`), Notes (`~/.codmate/notes`, `.json`), and Projects (`~/.codmate/projects`, `.json`) — existence, counts, sample files, and enumerator errors. - Also probes Claude Code sessions (`~/.claude/projects`, `.jsonl`) for presence and counts. - When the current root has 0 sessions but the default has files, the UI suggests switching to the default path. - Users can “Save Report…” to export a JSON diagnostics file for troubleshooting. File/Folder Layout - assets/ – Assets + Info.plist - CodMateApp.swift – App entry point - models/ – data types - services/ – IO, indexing, cache, codex actions - utils/ – helpers - views/ – SwiftUI views only - payload/ – bundled presets (providers/terminals) - notify/ – Swift command-line helper (codmate-notify) - SwiftTerm/ – embedded terminal dependency (local package) - .github/workflows/ – CI + release pipelines - scripts/ – build/packaging scripts - docs/ – design notes and investigation docs Advanced Page - Settings › Advanced (between MCP Server and About) uses a TabView with Path, CLI Proxy API, and Dialectics tabs. - Path tab: - File paths (Projects/Notes) and CLI command path overrides (codex/claude/gemini) - CLI environment snapshot (auto-detected paths + PATH) - CLI Proxy API tab: - Binary location + install/reinstall - Config/Auth/Logs paths (reveal in Finder) - Dialectics tab aggregates diagnostics: - Codex sessions root probe (current vs default), counts and sample files, enumerator errors - Claude sessions directory probe (default path), counts and samples - Notes and Projects directories probes (current vs default), counts and sample files - Does not mutate config automatically; changes only happen via explicit user actions Build & Run - SwiftPM is the source of truth. Use `swift build` to validate compile. - Build the app bundle with `make app` or `BASE_VERSION=1.2.3 ./scripts/create-app-bundle.sh`. - Build a DMG with `make dmg` or `BASE_VERSION=1.2.3 ./scripts/macos-build-notarized-dmg.sh`. Commit Conventions Follow conventional commits pattern: - `feat:` - New feature - `fix:` - Bug fix - `docs:` - Documentation change - `style:` - Formatting, missing semicolons, etc. - `refactor:` - Code change that neither fixes a bug nor adds a feature - `perf:` - Performance improvement - `test:` - Adding or updating tests - `chore:` - Changes to build process or auxiliary tools > Tip: Before writing your commit message, first try to summarize the main theme and motivation of your staged changes (the "why" and "core focus"). This helps ensure your commit message highlights the real intent and impact of the change, making the subject and body more focused and valuable. For AI-assisted commit generation, always let the AI attempt this summary step first. Commit Subject Focus Principles - The commit subject (title) should concisely highlight the "core focus" or the most important substantive change of the commit. - Avoid generic descriptions like "update docs" or "fix bug"; the title should make the main purpose and impact of the change clear at a glance. - If the change involves bilingual documentation, syncing with code implementation, or architectural adjustments, make this explicit in the title. - Recommended format: "what was done + why/for what". For example: - `docs: sync EN/CN README and align config docs with codebase` - `feat: support multi-suit config management for flexible scenarios` - `fix: resolve SSE connection issue in bridge module` Example: ``` Feature: Expand MCP API documentation with detailed instance and system management Where: - Updated README.md files across the API, handlers, models, and routes directories to include comprehensive details on new instance and system management functionalities. - Added specific sections for MCP handlers, models, and routes to clarify the operations available for managing servers and instances. Why: - To enhance the clarity and usability of the API documentation, ensuring users can easily understand and utilize the new features. What: - Documented new API endpoints for instance management, including listing, retrieving, and managing instance health. - Provided detailed descriptions of the handlers and models associated with MCP server and instance management. - Updated routing information to reflect the new structure and capabilities of the API. Issues: - This documentation update supports ongoing development and user engagement by providing clear guidance on the API's capabilities. ``` PR / Change Policy for Agents - Keep changes minimal and focused; do not refactor broadly without need. - Maintain macOS compliance first; avoid iOS‑only modifiers/placements. - When changing UI structure, update this AGENTS.md and the in‑app Settings if applicable. - Validate performance: measure large session trees; ensure first paint is fast and enrichment is incremental. Known Pitfalls - `.searchable` may hijack the trailing toolbar slot on macOS; use `SearchField` in a `ToolbarItem` to control placement. - OutlineGroup row height is affected by control size and insets; tighten with `.environment(\.defaultMinListRowHeight, 18)` and `.listRowInsets(...)` inside the row content. - Swift KeyPath escaping when patching: do not double-escape the leading backslash in typed key paths. Always write single-backslash literals like `\ProvidersVM.codexBaseURL` in Swift sources. The apply_patch tool takes plain text; extra escaping (e.g., `\\ProvidersVM...`) will compile-fail and break symbol discovery across files. - Prefer dot-shorthand KeyPaths in Swift (clearer, avoids escaping pitfalls): use `\.codexBaseURL` instead of `\ProvidersVM.codexBaseURL` when the generic context already constrains the base type (e.g., `ReferenceWritableKeyPath`). This makes patches safer and reduces chances of accidental extra backslashes. - String interpolation gotcha: do not escape quotes inside `\( ... )`. Write `Text("Codex: \(dict["codex"] ?? "")")`, not `Text("Codex: \(dict[\"codex\"] ?? \"\")")`. Escaping quotes inside interpolation confuses the outer string literal and can cause “Unterminated string literal”. - SwiftUI view extensions live in separate files; properties that those extensions need must be internal (default) or `fileprivate`. Marking them `private` will make the extension fail to build (“is inaccessible due to 'private'”). - Toolbar popovers must manage their own `@State` visibility. Binding `isPresented` directly to a view model flag tied to focus/search states causes the popover to close immediately when other columns steal focus or the toolbar re-renders. ================================================ FILE: CodMateApp.swift ================================================ import SwiftUI import GhosttyKit #if os(macOS) import AppKit #endif @main struct CodMateApp: App { #if os(macOS) @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate #endif @StateObject private var listViewModel: SessionListViewModel @StateObject private var preferences: SessionPreferencesStore @State private var settingsSelection: SettingCategory = .general @State private var extensionsTabSelection: ExtensionsSettingsTab = .commands @Environment(\.openWindow) private var openWindow init() { let prefs = SessionPreferencesStore() let listVM = SessionListViewModel(preferences: prefs) _preferences = StateObject(wrappedValue: prefs) _listViewModel = StateObject(wrappedValue: listVM) // Prepare user notifications early so banners can show while app is active SystemNotifier.shared.bootstrap() // Setup menu bar before windows appear #if os(macOS) MenuBarController.shared.configure(viewModel: listVM, preferences: prefs) #endif // In App Sandbox, restore security-scoped access to user-selected directories SecurityScopedBookmarks.shared.restoreAndStartAccess() // Restore all dynamic bookmarks (e.g., repository directories for Git Review) SecurityScopedBookmarks.shared.restoreAllDynamicBookmarks() // Restore and check sandbox permissions for critical directories Task { @MainActor in SandboxPermissionsManager.shared.restoreAccess() } // Sync launch at login state with system Task { @MainActor in LaunchAtLoginService.shared.syncWithPreferences(prefs) } // Daily update check (non-App Store builds only) Task { _ = await UpdateService.shared.checkIfNeeded(trigger: .appLaunch) } // Log startup info to Status Bar Task { @MainActor in let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" AppLogger.shared.info("CodMate v\(version) started", source: "App") } } var bodyCommands: some Commands { Group { CommandGroup(replacing: .appInfo) { Button("About CodMate") { presentSettings(for: .about) } } CommandGroup(replacing: .appSettings) { Button("Settings…") { presentSettings(for: .general) } .keyboardShortcut(",", modifiers: [.command]) } CommandGroup(after: .appSettings) { Button("Global Search…") { NotificationCenter.default.post(name: .codMateFocusGlobalSearch, object: nil) } .keyboardShortcut("f", modifiers: [.command]) } // Integrate actions into the system View menu CommandGroup(after: .sidebar) { Button(action: { NotificationCenter.default.post( name: .codMateRefreshRequested, object: nil, userInfo: RefreshRequest.userInfo(for: .context) ) }) { Label("Refresh", systemImage: "arrow.clockwise") } .keyboardShortcut("r", modifiers: [.command]) Button(action: { NotificationCenter.default.post( name: .codMateRefreshRequested, object: nil, userInfo: RefreshRequest.userInfo(for: .global) ) }) { Label("Full Refresh", systemImage: "arrow.triangle.2.circlepath") } .keyboardShortcut("r", modifiers: [.command, .option]) Button(action: { NotificationCenter.default.post(name: .codMateToggleSidebar, object: nil) }) { Label("Toggle Sidebar", systemImage: "sidebar.left") } .keyboardShortcut("1", modifiers: [.command]) Button(action: { NotificationCenter.default.post(name: .codMateToggleList, object: nil) }) { Label("Toggle Session List", systemImage: "sidebar.leading") } .keyboardShortcut("2", modifiers: [.command]) Divider() Button(action: { withAnimation { if preferences.statusBarVisibility == .hidden { preferences.statusBarVisibility = .auto } else { preferences.statusBarVisibility = .hidden } } }) { if preferences.statusBarVisibility == .hidden { Label("Show Status Bar", systemImage: "rectangle.bottomthird.inset.filled") } else { Label("Hide Status Bar", systemImage: "rectangle.bottomthird.inset.filled") } } .keyboardShortcut("3", modifiers: [.command]) } // Override Cmd+Q to use smart quit behavior CommandGroup(replacing: .appTermination) { Button("Quit CodMate") { MenuBarController.shared.handleQuit() } .keyboardShortcut("q", modifiers: [.command]) } } } var body: some Scene { // Use Window instead of WindowGroup to enforce single instance Window("CodMate", id: "main") { ContentView(viewModel: listViewModel) .frame(minWidth: 880, minHeight: 600) .onReceive(NotificationCenter.default.publisher(for: .codMateOpenSettings)) { note in let raw = note.userInfo?["category"] as? String if let raw, let cat = SettingCategory(rawValue: raw) { settingsSelection = cat if cat == .mcpServer, let tab = note.userInfo?["extensionsTab"] as? String, let parsed = ExtensionsSettingsTab(rawValue: tab) { extensionsTabSelection = parsed } } else { settingsSelection = .general } if !bringWindow(identifier: "CodMateSettingsWindow") { openWindow(id: "settings") } } .onReceive(NotificationCenter.default.publisher(for: .codMateOpenMainWindow)) { _ in // Window is singleton, so openWindow is idempotent if !bringWindow(identifier: "CodMateMainWindow") { openWindow(id: "main") } } } .defaultSize(width: 1200, height: 780) .windowToolbarStyle(.unified) // Prevent toolbar KVO issues with Window singleton .handlesExternalEvents(matching: []) // Prevent URL scheme from triggering new window creation .commands { bodyCommands } #if os(macOS) Window("Settings", id: "settings") { SettingsWindowContainer( preferences: preferences, listViewModel: listViewModel, selection: $settingsSelection, extensionsTab: $extensionsTabSelection ) } .defaultSize(width: 800, height: 640) .windowStyle(.titleBar) .windowToolbarStyle(.automatic) .windowResizability(.contentMinSize) .handlesExternalEvents(matching: []) // Prevent URL scheme from triggering new window creation #endif } private func presentSettings(for category: SettingCategory) { settingsSelection = category if category == .mcpServer { extensionsTabSelection = .mcp } #if os(macOS) NSApplication.shared.activate(ignoringOtherApps: true) #endif if !bringWindow(identifier: "CodMateSettingsWindow") { openWindow(id: "settings") } } private func bringWindow(identifier: String) -> Bool { #if os(macOS) let id = NSUserInterfaceItemIdentifier(identifier) if let window = NSApplication.shared.windows.first(where: { $0.identifier == id }) { window.makeKeyAndOrderFront(nil) return true } #endif return false } } private struct SettingsWindowContainer: View { let preferences: SessionPreferencesStore let listViewModel: SessionListViewModel @Binding var selection: SettingCategory @Binding var extensionsTab: ExtensionsSettingsTab var body: some View { SettingsView(preferences: preferences, selection: $selection, extensionsTab: $extensionsTab) .environmentObject(listViewModel) } } #if os(macOS) @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { private var suppressNextReopenActivation = false private var suppressResetTask: Task? = nil func applicationDidFinishLaunching(_ notification: Notification) { // Set activation policy based on saved preference // Default to .visible (show Dock icon) unless user explicitly chose "Menu Bar Only" let defaults = UserDefaults.standard let rawVisibility = defaults.string(forKey: "codmate.systemMenu.visibility") ?? "visible" let visibility = SystemMenuVisibility(rawValue: rawVisibility) ?? .visible switch visibility { case .menuOnly: // Menu bar only mode - hide Dock icon NSApp.setActivationPolicy(.accessory) case .hidden, .visible: // Show Dock icon so user can access the app NSApp.setActivationPolicy(.regular) } // Start CLI Proxy Service if available Task { @MainActor in if CLIProxyService.shared.isBinaryInstalled { do { try await CLIProxyService.shared.start() AppLogger.shared.info("CLIProxyAPI started successfully", source: "AppDelegate") } catch { // Get detailed logs from the service let serviceLogs = CLIProxyService.shared.logs let recentLogs = serviceLogs.split(separator: "\n").suffix(10).joined(separator: "\n") AppLogger.shared.error("Failed to start CLIProxyAPI: \(error.localizedDescription)", source: "AppDelegate") if !recentLogs.isEmpty { AppLogger.shared.error("Recent service logs:\n\(recentLogs)", source: "AppDelegate") } CLIProxyService.shared.lastError = error.localizedDescription } } else { AppLogger.shared.warning("CLIProxyAPI binary not installed, service will not start", source: "AppDelegate") } } } func application(_ application: NSApplication, open urls: [URL]) { print("🔗 [AppDelegate] Received URLs: \(urls)") print("🪟 [AppDelegate] Current windows count: \(application.windows.count)") print("🪟 [AppDelegate] Visible windows: \(application.windows.filter { $0.isVisible }.count)") let fileURLs = urls.filter { $0.isFileURL } let nonFileURLs = urls.filter { !$0.isFileURL } if let directoryURL = firstDirectoryURL(in: fileURLs) { handleDockFolderDrop(directoryURL) } if nonFileURLs.contains(where: { $0.scheme?.lowercased() == "codmate" && ($0.host ?? "").lowercased() == "notify" }) { suppressNextReopenActivation = true suppressResetTask?.cancel() suppressResetTask = Task { @MainActor [weak self] in try? await Task.sleep(nanoseconds: 1_000_000_000) self?.suppressNextReopenActivation = false } } if !nonFileURLs.isEmpty { ExternalURLRouter.handle(nonFileURLs) } } func application(_ sender: NSApplication, openFile filename: String) -> Bool { handleDockFileOpenPaths([filename]) } func application(_ sender: NSApplication, openFiles filenames: [String]) { let handled = handleDockFileOpenPaths(filenames) sender.reply(toOpenOrPrint: handled ? .success : .failure) } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { print("🔄 [AppDelegate] applicationShouldHandleReopen called, hasVisibleWindows: \(flag)") if suppressNextReopenActivation { suppressNextReopenActivation = false return true } // Delegate to MenuBarController for unified window activation logic // This ensures consistent behavior between Dock clicks and menu bar actions MenuBarController.shared.handleDockIconClick() // Always return true to prevent the system from creating new windows // This is particularly important for notification forwarding triggered by URL scheme (codmate://) return true } func applicationWillTerminate(_ notification: Notification) { // Stop CLI Proxy Service CLIProxyService.shared.stop() // Clean up Ghostty sessions // Note: Ghostty manages its own cleanup via deinit // No explicit session termination needed here } func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { // Ghostty sessions will be cleaned up automatically // No need for confirmation dialog return .terminateNow } private func firstDirectoryURL(in urls: [URL]) -> URL? { for url in urls { guard url.isFileURL else { continue } var isDirectory: ObjCBool = false if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), isDirectory.boolValue { return url.standardizedFileURL } } return nil } @MainActor @discardableResult private func handleDockFileOpenPaths(_ paths: [String]) -> Bool { let urls = paths.map { URL(fileURLWithPath: $0) } guard let directoryURL = firstDirectoryURL(in: urls) else { return false } handleDockFolderDrop(directoryURL) return true } @MainActor private func handleDockFolderDrop(_ url: URL) { let directory = url.path let name = url.lastPathComponent guard !directory.isEmpty else { return } MenuBarController.shared.handleDockIconClick() NotificationCenter.default.post(name: .codMateOpenMainWindow, object: nil) Task { await waitForMainWindow() DockOpenCoordinator.shared.enqueueNewProject(directory: directory, name: name) } } @MainActor private func waitForMainWindow() async { if MainWindowCoordinator.shared.hasAttachedWindow { return } for _ in 0..<20 { try? await Task.sleep(nanoseconds: 100_000_000) if MainWindowCoordinator.shared.hasAttachedWindow { return } } } } #endif ================================================ FILE: Ghostty-header.h ================================================ // // Ghostty-header.h // CodMate // // Bridging header to expose Ghostty C API to Swift // #ifndef Ghostty_header_h #define Ghostty_header_h // Import the main Ghostty C API // Note: ghostty.h already includes all necessary definitions // Do NOT include ghostty/vt.h as it causes duplicate enum definitions // NOTE: This file is excluded from Package.swift and may not be in use. // The correct path is now ghostty/Vendor/include/ghostty.h #import "ghostty/Vendor/include/ghostty.h" #endif /* Ghostty_header_h */ ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2025 Loocor Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: Makefile ================================================ .PHONY: help build release test app dmg zip clean run debug debug-logs debug-app debug-file notices APP_NAME := CodMate VER ?= 0.1.0 BUILD_NUMBER_STRATEGY ?= date APP_DIR ?= build/CodMate.app OUTPUT_DIR ?= artifacts/release # Default arch for local builds ARCH_NATIVE := $(shell uname -m) ARCH ?= $(ARCH_NATIVE) help: ## Show this help message @echo "CodMate - macOS SwiftPM App" @echo "" @echo "Available targets:" @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' build: ## SwiftPM debug build @swift build release: ## SwiftPM release build @swift build -c release test: ## Run SwiftPM tests (if any) @swift test notices: ## Update THIRD-PARTY-NOTICES.md @python3 scripts/gen-third-party-notices.py app: ## Build CodMate.app (ARCH=arm64|x86_64|"arm64 x86_64") @if [ -z "$(VER)" ]; then echo "error: VER is required (e.g., VER=1.2.3)"; exit 1; fi @VER=$(VER) BUILD_NUMBER_STRATEGY=$(BUILD_NUMBER_STRATEGY) \ ARCH_MATRIX="$(ARCH)" APP_DIR=$(APP_DIR) \ ./scripts/create-app-bundle.sh run: ## Build and launch CodMate.app (native arch, inferred version) @VER_RUN=$${VER:-$$(git describe --tags --abbrev=0 2>/dev/null || echo 0.0.0)}; \ ARCH_NATIVE=$$(uname -m); \ VER="$$VER_RUN" BUILD_NUMBER_STRATEGY=$(BUILD_NUMBER_STRATEGY) \ ARCH_MATRIX="$$ARCH_NATIVE" APP_DIR=$(APP_DIR) STRIP=0 SWIFT_CONFIG=debug \ SIGN_ADHOC=1 \ ./scripts/create-app-bundle.sh; \ open "$(APP_DIR)" debug: ## Build and run with terminal output (prints to stdout/stderr) @echo "Building CodMate.app for debug..." @VER_RUN=$${VER:-$$(git describe --tags --abbrev=0 2>/dev/null || echo 0.0.0)}; \ ARCH_NATIVE=$$(uname -m); \ VER="$$VER_RUN" BUILD_NUMBER_STRATEGY=$(BUILD_NUMBER_STRATEGY) \ ARCH_MATRIX="$$ARCH_NATIVE" APP_DIR=$(APP_DIR) STRIP=0 SWIFT_CONFIG=debug \ SIGN_ADHOC=1 \ ./scripts/create-app-bundle.sh @echo "" @echo "Starting CodMate with terminal output..." @echo "Press Ctrl+C to stop" @echo "========================================" @"$(APP_DIR)/Contents/MacOS/CodMate" debug-app: ## Build, launch app in background, and stream logs in foreground @echo "Building and launching CodMate.app..." @VER_RUN=$${VER:-$$(git describe --tags --abbrev=0 2>/dev/null || echo 0.0.0)}; \ ARCH_NATIVE=$$(uname -m); \ VER="$$VER_RUN" BUILD_NUMBER_STRATEGY=$(BUILD_NUMBER_STRATEGY) \ ARCH_MATRIX="$$ARCH_NATIVE" APP_DIR=$(APP_DIR) STRIP=0 SWIFT_CONFIG=debug \ SIGN_ADHOC=1 \ ./scripts/create-app-bundle.sh @open "$(APP_DIR)" @sleep 1 @echo "" @echo "Streaming logs from CodMate (Ctrl+C to stop)..." @echo "========================================" @log stream --predicate 'processImagePath CONTAINS "CodMate"' --style compact debug-logs: ## Stream live logs from running CodMate app (use with 'make run' in another terminal) @echo "Streaming logs from CodMate (Ctrl+C to stop)..." @echo "========================================" @log stream --predicate 'processImagePath CONTAINS "CodMate"' --style compact debug-file: ## Build and run with output to both terminal and logs/debug.log @echo "Building CodMate.app for debug..." @VER_RUN=$${VER:-$$(git describe --tags --abbrev=0 2>/dev/null || echo 0.0.0)}; \ ARCH_NATIVE=$$(uname -m); \ VER="$$VER_RUN" BUILD_NUMBER_STRATEGY=$(BUILD_NUMBER_STRATEGY) \ ARCH_MATRIX="$$ARCH_NATIVE" APP_DIR=$(APP_DIR) STRIP=0 SWIFT_CONFIG=debug \ SIGN_ADHOC=1 \ ./scripts/create-app-bundle.sh @mkdir -p logs @echo "" @echo "Starting CodMate with output to terminal and file..." @echo "Log file: logs/debug.log" @echo "Press Ctrl+C to stop" @echo "========================================" @"$(APP_DIR)/Contents/MacOS/CodMate" 2>&1 | tee logs/debug.log dmg: ## Build Developer ID DMG (ARCH=arm64|x86_64|"arm64 x86_64") @if [ -z "$(VER)" ]; then echo "error: VER is required (e.g., VER=1.2.3)"; exit 1; fi @VER=$(VER) BUILD_NUMBER_STRATEGY=$(BUILD_NUMBER_STRATEGY) \ ARCH_MATRIX="$(ARCH)" APP_DIR=$(APP_DIR) OUTPUT_DIR=$(OUTPUT_DIR) \ ./scripts/macos-build-notarized-dmg.sh zip: ## Create zip archives from DMG files (one zip per arch, requires dmg first, VER=1.2.3) @if [ -z "$(VER)" ]; then echo "error: VER is required (e.g., VER=1.2.3)"; exit 1; fi @if [ ! -d "$(OUTPUT_DIR)" ]; then echo "error: OUTPUT_DIR $(OUTPUT_DIR) does not exist. Run 'make dmg' first."; exit 1; fi @DMG_FILES=$$(find "$(OUTPUT_DIR)" -name "codmate-*.dmg" 2>/dev/null | sort); \ if [ -z "$$DMG_FILES" ]; then \ echo "error: No DMG files found in $(OUTPUT_DIR). Run 'make dmg' first."; \ exit 1; \ fi; \ echo "Creating zip archives from DMG files..."; \ cd "$(OUTPUT_DIR)" && \ for dmg_file in $$DMG_FILES; do \ dmg_basename=$$(basename "$$dmg_file" .dmg); \ zip_name="$$dmg_basename.zip"; \ echo " Creating: $$zip_name"; \ zip -q "$$zip_name" "$$dmg_basename.dmg"; \ done; \ echo "Zip archives created in $(OUTPUT_DIR)" clean: ## Clean build artifacts @rm -rf .build build $(APP_DIR) artifacts ================================================ FILE: NOTICE ================================================ CodMate Copyright (c) 2025 Loocor ================================================ FILE: Package.resolved ================================================ { "originHash" : "06ecd22962877ed07b043a2d3eeba48fbdcada27c6982dc3012cc48805daf65f", "pins" : [ { "identity" : "eventsource", "kind" : "remoteSourceControl", "location" : "https://github.com/mattt/eventsource.git", "state" : { "revision" : "a2965424a4babeb0c8e4b5ec9708c3939bc52449", "version" : "1.2.0" } }, { "identity" : "swift-log", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", "version" : "1.6.4" } }, { "identity" : "swift-sdk", "kind" : "remoteSourceControl", "location" : "https://github.com/modelcontextprotocol/swift-sdk.git", "state" : { "revision" : "c0407a0b52677cb395d824cac2879b963075ba8c", "version" : "0.10.2" } }, { "identity" : "swift-system", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system", "state" : { "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db", "version" : "1.6.3" } } ], "version" : 3 } ================================================ FILE: Package.swift ================================================ // swift-tools-version: 6.0 import PackageDescription let package = Package( name: "CodMate", defaultLocalization: "en", platforms: [.macOS(.v13)], products: [ .executable( name: "CodMate", targets: ["CodMate"] ), .executable( name: "notify", targets: ["notify"] ), ], dependencies: [ // Ghostty GPU-accelerated terminal .package(path: "ghostty"), // MCP Swift SDK for real MCP client connections .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.10.2"), ], targets: [ .executableTarget( name: "CodMate", dependencies: [ .product(name: "GhosttyKit", package: "ghostty"), .product(name: "MCP", package: "swift-sdk"), ], path: ".", exclude: [ "ghostty", "notify", "build", ".build", "scripts", "docs", "payload", "Tests", "AGENTS.md", "LICENSE", "NOTICE", "README.md", "THIRD-PARTY-NOTICES.md", "Makefile", "screenshot.png", "PrivacyInfo.xcprivacy", "assets/Assets.xcassets", "assets/Info.plist", "assets/CodMate.entitlements", "assets/CodMate-Notify.entitlements", "Ghostty-header.h", ], sources: [ "CodMateApp.swift", "models", "services", "utils", "views", ] ), .executableTarget( name: "notify", path: "notify", sources: ["NotifyMain.swift"] ), .testTarget( name: "CodMateTests", dependencies: ["CodMate"] ), ], swiftLanguageModes: [.v5] ) ================================================ FILE: PrivacyInfo.xcprivacy ================================================ { "NSPrivacyTracking": false, "NSPrivacyTrackingDomains": [], "NSPrivacyCollectedDataTypes": [], "NSPrivacyAccessedAPITypes": [] } ================================================ FILE: README.md ================================================ # CodMate ![CodMate Screenshot](screenshot.png) CodMate is a macOS SwiftUI app for **managing CLI AI sessions**: browse, search, organize, resume, and review work produced by **Codex**, **Claude Code**, and **Gemini CLI**. It focuses on speed (incremental indexing + caching), a compact three-column UI, and “ship it” workflows like **Project Review (Git Changes)** and **one-click Resume/New**. Status: **macOS 13.5+**, **Swift 6 toolchain**. Universal binary (arm64 + x86_64). ## Project status (Archival note) I plan to **archive CodMate** after this update. Here is the reasoning that led me to this decision: - **Scope drift with limited new insight**: CodMate grew from a simple history viewer into a broader desktop GUI for experience, integration, and workflow consolidation. However, it has not pushed the core exploration of Agents/LLMs as much as I hoped. - **Traditional heavyweight GUI is no longer the right center of gravity**: the underlying Agent/LLM ecosystem evolves quickly, and a heavier desktop GUI becomes harder to justify when CLI/TUI approaches can iterate faster with less inertia. What feels more important to me now is a broader **HUI (Human Usability Interface)** direction: designing interfaces that help humans work effectively with rapidly improving AI systems, rather than anchoring exploration to one traditional app surface. - **Ecosystem already covers part of the gap**: Codex and Claude Code now offer VS Code/Zed extensions that share history with their CLI sessions, which increasingly addresses the original problem CodMate set out to solve. - **Model exploration now calls for a different center of gravity**: I want to spend more time learning how different model families are best used in practice — not just comparing open or low-cost options, but understanding where each type fits. The community already has strong examples such as `oh-my-openagent`, and there are also emerging Generative UI directions such as A2UI that I want to keep watching and learning from. In a time when AI/LLMs are accelerating what can be created through logic alone, I think the human-facing usability layer is becoming more urgent, not less. - **The architecture also boxed the project into macOS**: CodMate’s SwiftUI desktop design helped it move quickly at first, but it also constrained the project to one environment. For the next phase, I want more room for cross-platform orchestration and CLI-first experimentation rather than continuing from a macOS-only foundation. This does not diminish what CodMate achieved. If anything, the recent increase in attention around CodMate makes the tradeoff clearer to me: people are clearly interested in better ways to work with CLI agents, and I still believe interface design around these systems matters deeply. But I no longer think this particular macOS GUI is the best place for me to continue that exploration. The focus shift is toward orchestration, model-specific workflows, HUI, and more portable foundations. ## Where this exploration continues While I will keep many of the ideas behind CodMate in mind, the project I am actively building now is [MCPMate](https://github.com/loocor/mcpmate). MCPMate is not a brand-new project. I started shaping it around May last year, paused it around October, and recently returned to it with a clearer view of where MCP has irreplaceable value. It was previously closed-source and is now being reopened in public. At a high level, MCPMate is a management center for MCP servers and AI clients. The direction I care about most there is usability: building on its earlier profile-based approach for removing redundant capabilities in specific scenarios, and extending its hosted mode toward a more progressively disclosed smart mode. Part of the goal is to bring some of the lower first-token-cost and lower-friction qualities that people appreciated in skills- and CLI-shaped workflows into MCP as well. So while CodMate is ending here, the broader exploration is not. If CodMate resonated with you, I would love for you to take a look at MCPMate and share feedback. ## Download - **Latest release (DMG)**: [GitHub Releases](https://github.com/loocor/CodMate/releases/latest) ## Why CodMate - **Find anything fast**: a global search panel (⌘F) with scoped search + progress/cancel, plus quick list filtering. - **Keep work organized**: Projects + Tasks let you group sessions by repo and by goal, with a shareable task context file. - **Continue instantly**: Resume/New into Terminal/iTerm2/Warp (or embedded terminal in non-sandbox builds), with copyable exact commands. - **Review & commit without leaving the app**: Project Review shows diffs, staging state, and supports commit (with optional AI commit message generation). ## Features (organized by value) ### Organize and understand sessions across CLIs - **Multi-source session browsing**: - **Codex**: `~/.codex/sessions` (`.jsonl`) - **Claude Code**: `~/.claude/projects` (`.jsonl`) - **Gemini CLI**: `~/.gemini/tmp` (Gemini’s session storage) - **Sidebar navigation**: - **Projects** list with counts, including “All” and an **unassigned/Other** bucket. - **Calendar** (pinned bottom) with per-day counts and a Created/Updated toggle. - Directory-based navigation built from session `cwd` statistics. - **Session list**: - Default scope is **Today** for fast first paint. - Sorting: Most Recent (created/updated aware), Duration, Activity, etc. - Rows show title, timestamps/duration, snippet, and compact metrics (user/assistant/tool/reasoning), plus states like running/updating/awaiting follow-up. ### Projects + Tasks (workspaces instead of loose logs) - **Projects**: - Create/edit projects (name, directory, overview, trust level, optional runtime profile). - Assign sessions to projects via row actions/context menus. - Storage: `~/.codmate/projects/` (project metadata + memberships mapping). - “New” sessions started inside a project can be **auto-assigned** to that project. - **Tasks (within projects)**: - Create/edit/delete tasks, collapse/expand task groups, and assign/move sessions into tasks. - **Task context sync**: generates/updates a shareable context file and prepares a prompt pointing to it. - Storage: `~/.codmate/tasks/` (task metadata + relationships mapping). ### Resume/New (local, remote, embedded) - **Resume**: - Launch in **Terminal.app / iTerm2 / Warp**, or **embedded terminal** (non-App Store / non-sandbox builds). - When embedded is active, CodMate can show a **Copy real command** action for reproducibility. - **New**: - Start a fresh session from the focused session’s `cwd` (and project profile when available). - Start sessions directly from a selected project, even without a focused session. - **Remote Hosts (SSH mirroring)**: - Enable hosts from `~/.ssh/config`, then mirror remote sessions over SSH. - Remote bases: - Codex remote: `$HOME/.codex/sessions` - Claude remote: `$HOME/.claude/projects` - Mirror cache is stored under `~/Library/Caches/CodMate/remote/`. ### Search, export, and metadata (make history useful) - **Global Search (⌘F)**: - Floating window or toolbar popover style (configurable). - Scope picker + progress/cancel for long searches. - **Rename/comment**: - Click the session title in the detail pane to edit title/comment. - Storage: `~/.codmate/notes/.json` (with automatic migration from legacy locations). - **Conversation export**: - Export Markdown from the detail pane. - Settings allow choosing which message types appear in the timeline and which are included in Markdown export. ### Project Review (Git Changes) + AI commit message generation - **Git Changes surface** (Project Review mode): - Lists changed files, supports **stage/unstage**, and shows **unified diff** or raw preview. - Commit UI with message editor and **Commit** action. - Optional **AI generate commit message** (uses your selected Provider/Model and a prompt template from settings). - Repo authorization is **on-demand** (especially relevant in sandboxed builds). - **Settings › Git Review**: - Diff options (line numbers, soft wrap). - Commit generation: choose Provider/Model and an optional prompt template. ### Providers, MCP, notifications, diagnostics (make the ecosystem manageable) - **Providers (Settings › Providers)**: - Add/edit providers with Codex + Claude endpoints, shared API key env var, wire API (Chat/Responses), and model catalog with capability flags. - Built-in templates are bundled from `payload/providers.json`; user registry is stored at `~/.codmate/providers.json`. - Built-in health check: **Test** endpoints before saving. - **MCP Servers (Settings › MCP Server)**: - Uni-Import (paste/drag JSON), per-server enable toggle, per-target toggles (Codex/Claude/Gemini), and connectivity **Test**. - Storage: `~/.codmate/mcp-servers.json` - Exports enabled servers into `~/.claude/settings.json` (and writes a helper file `~/.codmate/mcp-enabled-claude.json`). - **Claude Code notifications (Settings › Claude Code › Notifications)**: - Installs/removes hooks that forward permission/completion events via `codmate://notify` and provides a self-test. - **Dialectics (Settings › Dialectics)**: - Deep diagnostics for session roots, notes/projects dirs, environment, and ripgrep indexes. - One-click “Save Report…” plus rebuild actions for coverage/session index. ## Keyboard shortcuts - **⌘,**: Settings - **⌘F**: Global Search - **⌘R**: Refresh (also recomputes global sidebar statistics) - **⌘1**: Toggle sidebar - **⌘2**: Toggle session list ## Data locations (quick reference) - **Codex sessions**: `~/.codex/sessions` - **Claude sessions**: `~/.claude/projects` - **Gemini sessions**: `~/.gemini/tmp` - **Notes**: `~/.codmate/notes/` - **Projects**: `~/.codmate/projects/` - **Tasks**: `~/.codmate/tasks/` - **Providers registry**: `~/.codmate/providers.json` - **MCP servers**: `~/.codmate/mcp-servers.json` - **Session index cache (SQLite)**: `~/.codmate/sessionIndex-v4.db` - **Additional caches**: `~/Library/Caches/CodMate/` (includes remote mirrors and best-effort caches) ## Performance - Fast path indexing: memory‑mapped reads; parse the first ~64 lines plus tail sampling (up to ~1 MB) to fix `lastUpdatedAt`. - Background enrichment: full parse in constrained task groups; batched UI updates. - Full‑text search: chunked scan (128 KB), case‑insensitive; avoids lowercasing the whole file. - Caching: persistent SQLite index + best-effort caches to keep subsequent launches fast. - Sidebar statistics are global and decoupled from the list scope to keep navigation snappy. ## Architecture - App: macOS SwiftUI (min macOS 13.5). SwiftPM-only build. - MVVM layering - Models: `SessionSummary`, `SessionEvent`, `DateDimension`, `SessionLoadScope`, … - Services: `SessionIndexer`, `SessionCacheStore`, `SessionActions`, `SessionTimelineLoader`, `CodexConfigService`, `SessionsDiagnosticsService` - ViewModel: `SessionListViewModel` - Views: SwiftUI only (no business logic) - Concurrency & IO - Services that share caches are `actor`s; UI updates on MainActor only. - Cancel previous tasks on search/scope changes; guard `Task.isCancelled` in loops. - File IO prefers `Data(mappedIfSafe:)` and chunked reads; avoids loading huge files into Strings. ## Build Prerequisites - macOS 13.5+, Swift 6 toolchain, Xcode Command Line Tools (for `xcrun actool`). - Install the CLIs you use (Codex / Claude / Gemini) somewhere on your `PATH`. Makefile (recommended) ```sh make build # SwiftPM debug build make test # SwiftPM tests (if any) make run # Build (debug, native arch) and launch for local testing make app VER=1.2.3 # Create CodMate.app in build/ (ARCH defaults to host) make app VER=1.2.3 ARCH=arm64 make app VER=1.2.3 ARCH=x86_64 make app VER=1.2.3 ARCH="arm64 x86_64" make dmg VER=1.2.3 # Create Developer ID DMG (ARCH defaults to host) make dmg VER=1.2.3 ARCH=arm64 make dmg VER=1.2.3 ARCH=x86_64 make dmg VER=1.2.3 ARCH="arm64 x86_64" # produces codmate-arm64.dmg + codmate-x86_64.dmg make notices # Regenerate THIRD-PARTY-NOTICES.md ``` Direct scripts ```sh VER=1.2.3 ./scripts/create-app-bundle.sh VER=1.2.3 ./scripts/macos-build-notarized-dmg.sh ``` ### Versioning strategy (build script) - Marketing version (CFBundleShortVersionString): set with `VER` (e.g., `1.4.0`). - Build number (CFBundleVersion): controlled by `BUILD_NUMBER_STRATEGY`: - `date` (default): `yyyymmddHHMM` (e.g., `202510291430`). - `git`: `git rev-list --count HEAD`. - `counter`: monotonically increments a file counter at `$BUILD_DIR/build-number` (override path via `BUILD_COUNTER_FILE`). - DMG name: `codmate-.dmg`. - Override via environment variables when running the build script: ```sh VER=1.4.0 BUILD_NUMBER_STRATEGY=date \ ./scripts/macos-build-notarized-dmg.sh ``` This sets CFBundleShortVersionString to `1.4.0`, CFBundleVersion to the computed build number, and names the DMG accordingly. ## CLI Integration (Codex / Claude / Gemini) - Executable resolution: CodMate launches CLIs via `/usr/bin/env codex` (and `claude` / `gemini`) to respect your system `PATH` (no user-configurable CLI path). - PATH robustness: before launching, CodMate ensures `PATH` includes `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin`. - Resume: - Uses the original session `cwd` when it exists; otherwise falls back to the log file directory. - Can launch into Terminal.app / iTerm2 / Warp, and (in non-sandbox builds) an embedded terminal. - When embedded is active, CodMate can copy the **exact** invocation it used (e.g. `codex resume `). - New: - Starts a fresh session in the focused session’s working directory (or the selected project directory). - When a Project Profile is present, its model/sandbox/approval defaults are applied when generating commands. - Command flags exposed by the UI: - Codex: sandbox policy (`-s/--sandbox`), approval policy (`-a/--ask-for-approval`), `--full-auto`, `--dangerously-bypass-approvals-and-sandbox`. - Claude: common runtime/permission flags plus MCP strict mode (see Settings › Claude Code and Settings › Command). - MCP integration: - CodMate can export enabled MCP servers into `~/.claude/settings.json` and also writes `~/.codmate/mcp-enabled-claude.json` for explicit `--mcp-config` usage. ## Project Layout ``` assets/ # Assets and Info.plist (not in Copy Bundle Resources) CodMateApp.swift # App entry point models/ # Data models (pure types) services/ # IO + indexing + integrations utils/ # Helpers (shell, sandbox, formatting, etc.) views/ # SwiftUI views payload/ # Bundled presets (e.g. providers.json templates) notify/ # Swift command-line helper installed as `codmate-notify` SwiftTerm/ # Embedded terminal dependency (local package) scripts/ # Helper scripts (icons, build flows) docs/ # Design notes and investigation docs ``` ## Known Pitfalls - Prefer a toolbar search field (far‑right aligned) over `.searchable` to avoid hijacking toolbar slots on macOS. - Outline row height needs explicit tightening (see `defaultMinListRowHeight` and insets in the row views). ## Development Tips - Run tests: `swift test`. - Formatting: follow existing code style; keep changes minimal and focused. - Performance: measure large trees; first paint should be fast; enrichment is incremental. ## License - Apache License 2.0. See `LICENSE` for full text. - `NOTICE` includes project attribution. SPDX: `Apache-2.0`. - Third-party attributions and license texts: see `THIRD-PARTY-NOTICES.md`. ================================================ FILE: THIRD-PARTY-NOTICES.md ================================================ Third-Party Notices This document lists third-party components included in CodMate distributions, along with their licenses and attributions. The original license texts are reproduced or referenced below. If you distribute CodMate binaries, keep this file together with `LICENSE`. --- Aizen (Ghostty embedding implementation reference) Repository: https://github.com/vivy-company/aizen License: GNU General Public License v3.0 The GhosttyKit integration code in CodMate was adapted from Aizen's Ghostty embedding implementation. While the code has been significantly modified and adapted for CodMate's use case, we acknowledge Aizen as the original source of the Ghostty integration approach. Copyright (C) 2025 Vivy Technologies Co., Limited This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. For the full GPL-3.0 license text, see: https://www.gnu.org/licenses/gpl-3.0.html --- eventsource (1.2.0) Repository: https://github.com/mattt/eventsource.git License file: LICENSE.md Copyright 2025 Mattt (https://mat.tt) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- Ghostty Repository: https://github.com/ghostty-org/ghostty License: MIT License CodMate uses libghostty, the embeddable terminal library from Ghostty, to provide fast, feature-rich terminal emulation with GPU acceleration. While the integration approach was adapted from Aizen, the core terminal functionality and performance benefits come from Ghostty itself. Copyright (c) 2022-2025 Ghostty contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- swift-argument-parser (1.6.2) Repository: https://github.com/apple/swift-argument-parser License file: LICENSE.txt Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ## Runtime Library Exception to the Apache 2.0 License: ## As an exception, if you use this Software to compile your source code and portions of this Software are embedded into the binary product as a result, you may redistribute such product without providing attribution as would otherwise be required by Sections 4(a), 4(b) and 4(d) of the License. --- swift-log (1.6.4) Repository: https://github.com/apple/swift-log.git License file: LICENSE.txt Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. NOTICE (NOTICE.txt) The SwiftLog Project ======================== Please visit the SwiftLog web site for more information: * https://github.com/apple/swift-log Copyright 2018, 2019 The SwiftLog Project The SwiftLog Project licenses this file to you under the Apache License, version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at: https://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. Also, please refer to each LICENSE..txt file, which is located in the 'license' directory of the distribution file, for the license terms of the components that this product depends on. --- swift-sdk (0.10.2) Repository: https://github.com/modelcontextprotocol/swift-sdk.git License file: LICENSE MIT License Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- swift-subprocess (main@00b0496) Repository: https://github.com/swiftlang/swift-subprocess License file: LICENSE Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. --- swift-system (1.6.3) Repository: https://github.com/apple/swift-system License file: LICENSE.txt Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ## Runtime Library Exception to the Apache 2.0 License: ## As an exception, if you use this Software to compile your source code and portions of this Software are embedded into the binary product as a result, you may redistribute such product without providing attribution as would otherwise be required by Sections 4(a), 4(b) and 4(d) of the License. --- - This product contains a derivation of the Tony Stone's 'process_test_files.rb'. * LICENSE (Apache License 2.0): * https://www.apache.org/licenses/LICENSE-2.0 * HOMEPAGE: * https://codegists.com/snippet/ruby/generate_xctest_linux_runnerrb_tonystone_ruby --- This product contains a derivation of the lock implementation and various scripts from SwiftNIO. * LICENSE (Apache License 2.0): * https://www.apache.org/licenses/LICENSE-2.0 * HOMEPAGE: * https://github.com/apple/swift-nio ================================================ FILE: Tests/CodMateTests/ClaudeHooksAdapterTests.swift ================================================ import XCTest @testable import CodMate final class ClaudeHooksAdapterTests: XCTestCase { func testApplyHooksWritesClaudeSettingsHooks() async throws { let fm = FileManager.default let tmp = fm.temporaryDirectory.appendingPathComponent("codmate-claude-hooks-\(UUID().uuidString)", isDirectory: true) try fm.createDirectory(at: tmp, withIntermediateDirectories: true) let settingsURL = tmp.appendingPathComponent("settings.json") let paths = ClaudeSettingsService.Paths(dir: tmp, file: settingsURL) let service = ClaudeSettingsService(fileManager: fm, paths: paths) let rule = HookRule( name: "PreToolUse · Write", event: "PreToolUse", matcher: "Write|Edit", commands: [HookCommand(command: "/usr/bin/echo", args: ["ok"], timeoutMs: 30_000)], enabled: true, targets: HookTargets(codex: false, claude: true, gemini: false) ) let warnings = try await service.applyHooksFromCodMate([rule]) XCTAssertTrue(warnings.isEmpty) let data = try Data(contentsOf: settingsURL) let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] let hooks = obj?["hooks"] as? [String: Any] let pre = hooks?["PreToolUse"] as? [[String: Any]] XCTAssertEqual(pre?.count, 1) XCTAssertEqual(pre?.first?["matcher"] as? String, "Write|Edit") let nested = pre?.first?["hooks"] as? [[String: Any]] XCTAssertEqual(nested?.count, 1) XCTAssertEqual(nested?.first?["type"] as? String, "command") XCTAssertEqual(nested?.first?["command"] as? String, "/usr/bin/echo") XCTAssertEqual(nested?.first?["timeout"] as? Int, 30_000) XCTAssertNotNil(nested?.first?["name"] as? String) } func testAllowManagedHooksOnlySkipsApply() async throws { let fm = FileManager.default let tmp = fm.temporaryDirectory.appendingPathComponent("codmate-claude-hooks-\(UUID().uuidString)", isDirectory: true) try fm.createDirectory(at: tmp, withIntermediateDirectories: true) let settingsURL = tmp.appendingPathComponent("settings.json") let initial = #"{"allowManagedHooksOnly":true}"# try initial.write(to: settingsURL, atomically: true, encoding: .utf8) let paths = ClaudeSettingsService.Paths(dir: tmp, file: settingsURL) let service = ClaudeSettingsService(fileManager: fm, paths: paths) let rule = HookRule(name: "Stop", event: "Stop", commands: [HookCommand(command: "/bin/echo")], enabled: true, targets: HookTargets(codex: false, claude: true, gemini: false)) let warnings = try await service.applyHooksFromCodMate([rule]) XCTAssertEqual(warnings.count, 1) let text = try String(contentsOf: settingsURL, encoding: .utf8) XCTAssertTrue(text.contains("allowManagedHooksOnly")) XCTAssertFalse(text.contains("codmate-hook:")) } func testApplyPrunesPreviouslyManagedHooks() async throws { let fm = FileManager.default let tmp = fm.temporaryDirectory.appendingPathComponent("codmate-claude-hooks-\(UUID().uuidString)", isDirectory: true) try fm.createDirectory(at: tmp, withIntermediateDirectories: true) let settingsURL = tmp.appendingPathComponent("settings.json") let paths = ClaudeSettingsService.Paths(dir: tmp, file: settingsURL) let service = ClaudeSettingsService(fileManager: fm, paths: paths) let rule = HookRule( name: "Stop", event: "Stop", commands: [HookCommand(command: "/usr/bin/echo")], enabled: true, targets: HookTargets(codex: false, claude: true, gemini: false) ) _ = try await service.applyHooksFromCodMate([rule]) _ = try await service.applyHooksFromCodMate([rule]) let data = try Data(contentsOf: settingsURL) let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] let hooks = obj?["hooks"] as? [String: Any] let stop = hooks?["Stop"] as? [[String: Any]] let nested = stop?.first?["hooks"] as? [[String: Any]] let managed = (nested ?? []).filter { ($0["name"] as? String)?.hasPrefix("codmate-hook:") == true } XCTAssertEqual(managed.count, 1) } } ================================================ FILE: Tests/CodMateTests/CodexHooksAdapterTests.swift ================================================ import XCTest @testable import CodMate final class CodexHooksAdapterTests: XCTestCase { func testApplySingleStopHookWritesNotifyArray() async throws { let fm = FileManager.default let tmp = fm.temporaryDirectory.appendingPathComponent("codmate-codex-hooks-\(UUID().uuidString)", isDirectory: true) let home = tmp.appendingPathComponent(".codex", isDirectory: true) try fm.createDirectory(at: home, withIntermediateDirectories: true) let configURL = home.appendingPathComponent("config.toml") try "".write(to: configURL, atomically: true, encoding: .utf8) let service = CodexConfigService(paths: .init(home: home, configURL: configURL), fileManager: fm) let rule = HookRule( name: "Stop · echo", event: "Stop", commands: [HookCommand(command: "/usr/bin/echo", args: ["hello"])], enabled: true, targets: HookTargets(codex: true, claude: false, gemini: false) ) let warnings = try await service.applyHooksFromCodMate([rule]) XCTAssertTrue(warnings.isEmpty) let text = try String(contentsOf: configURL, encoding: .utf8) XCTAssertTrue(text.contains("notify = [\"/usr/bin/echo\", \"hello\"]")) } func testApplyMultipleCodexRulesDoesNotOverwriteExistingNotify() async throws { let fm = FileManager.default let tmp = fm.temporaryDirectory.appendingPathComponent("codmate-codex-hooks-\(UUID().uuidString)", isDirectory: true) let home = tmp.appendingPathComponent(".codex", isDirectory: true) try fm.createDirectory(at: home, withIntermediateDirectories: true) let configURL = home.appendingPathComponent("config.toml") try "notify = [\"old-notify\"]\n".write(to: configURL, atomically: true, encoding: .utf8) let service = CodexConfigService(paths: .init(home: home, configURL: configURL), fileManager: fm) let rules = [ HookRule(name: "Stop A", event: "Stop", commands: [HookCommand(command: "/bin/echo", args: ["a"])], enabled: true, targets: HookTargets(codex: true, claude: false, gemini: false)), HookRule(name: "Stop B", event: "Stop", commands: [HookCommand(command: "/bin/echo", args: ["b"])], enabled: true, targets: HookTargets(codex: true, claude: false, gemini: false)), ] let warnings = try await service.applyHooksFromCodMate(rules) XCTAssertEqual(warnings.count, 1) let unchanged = await service.getNotifyArray() XCTAssertEqual(unchanged.first, "old-notify") } } ================================================ FILE: Tests/CodMateTests/GeminiHooksAdapterTests.swift ================================================ import XCTest @testable import CodMate final class GeminiHooksAdapterTests: XCTestCase { func testApplyHooksWritesGeminiSettingsHooksAndEnablesTools() async throws { let fm = FileManager.default let tmp = fm.temporaryDirectory.appendingPathComponent("codmate-gemini-hooks-\(UUID().uuidString)", isDirectory: true) try fm.createDirectory(at: tmp, withIntermediateDirectories: true) let settingsURL = tmp.appendingPathComponent("settings.json") let paths = GeminiSettingsService.Paths(directory: tmp, file: settingsURL) let service = GeminiSettingsService(paths: paths, fileManager: fm) let rule = HookRule( name: "PreToolUse", event: "PreToolUse", matcher: "Write", commands: [HookCommand(command: "/usr/bin/echo", args: ["ok"], timeoutMs: 10_000)], enabled: true, targets: HookTargets(codex: false, claude: false, gemini: true) ) let warnings = try await service.applyHooksFromCodMate([rule]) XCTAssertTrue(warnings.isEmpty) let data = try Data(contentsOf: settingsURL) let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] let hooks = obj?["hooks"] as? [String: Any] let pre = hooks?["PreToolUse"] as? [[String: Any]] XCTAssertEqual(pre?.count, 1) XCTAssertEqual(pre?.first?["matcher"] as? String, "Write") let nested = pre?.first?["hooks"] as? [[String: Any]] XCTAssertEqual(nested?.count, 1) XCTAssertTrue((nested?.first?["name"] as? String)?.hasPrefix("codmate-hook:") == true) let tools = obj?["tools"] as? [String: Any] XCTAssertEqual(tools?["enableHooks"] as? Bool, true) XCTAssertEqual(tools?["enableMessageBusIntegration"] as? Bool, true) } func testApplyPrunesPreviouslyManagedHooks() async throws { let fm = FileManager.default let tmp = fm.temporaryDirectory.appendingPathComponent("codmate-gemini-hooks-\(UUID().uuidString)", isDirectory: true) try fm.createDirectory(at: tmp, withIntermediateDirectories: true) let settingsURL = tmp.appendingPathComponent("settings.json") let paths = GeminiSettingsService.Paths(directory: tmp, file: settingsURL) let service = GeminiSettingsService(paths: paths, fileManager: fm) let rule = HookRule( name: "Stop", event: "Stop", commands: [HookCommand(command: "/usr/bin/echo")], enabled: true, targets: HookTargets(codex: false, claude: false, gemini: true) ) _ = try await service.applyHooksFromCodMate([rule]) _ = try await service.applyHooksFromCodMate([rule]) let data = try Data(contentsOf: settingsURL) let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] let hooks = obj?["hooks"] as? [String: Any] let stop = hooks?["Stop"] as? [[String: Any]] let nested = stop?.first?["hooks"] as? [[String: Any]] let managed = (nested ?? []).filter { ($0["name"] as? String)?.hasPrefix("codmate-hook:") == true } XCTAssertEqual(managed.count, 1) } } ================================================ FILE: Tests/CodMateTests/HooksStoreTests.swift ================================================ import XCTest @testable import CodMate final class HooksStoreTests: XCTestCase { func testUpsertListUpdateDelete() async throws { let fm = FileManager.default let tmp = fm.temporaryDirectory.appendingPathComponent("codmate-hooks-store-\(UUID().uuidString)", isDirectory: true) try fm.createDirectory(at: tmp, withIntermediateDirectories: true) let paths = HooksStore.Paths(home: tmp, fileURL: tmp.appendingPathComponent("hooks.json")) let store = HooksStore(paths: paths, fileManager: fm) let rule = HookRule( name: "Stop · echo", event: "Stop", commands: [HookCommand(command: "/usr/bin/echo", args: ["hello"])], enabled: true, targets: HookTargets(codex: true, claude: true, gemini: true), source: "test" ) try await store.upsert(rule) let list1 = await store.list() XCTAssertEqual(list1.count, 1) XCTAssertEqual(list1.first?.id, rule.id) try await store.update(id: rule.id) { r in r.enabled = false } let list2 = await store.list() XCTAssertEqual(list2.first?.enabled, false) try await store.delete(id: rule.id) let list3 = await store.list() XCTAssertEqual(list3.count, 0) } } ================================================ FILE: Tests/CodMateTests/UpdateServiceTests.swift ================================================ import XCTest @testable import CodMate final class UpdateServiceTests: XCTestCase { func testParseLatestRelease() throws { let json = """ { "tag_name": "v1.2.3", "html_url": "https://github.com/loocor/CodMate/releases/tag/v1.2.3", "draft": false, "prerelease": false, "assets": [ {"name": "codmate-arm64.dmg", "browser_download_url": "https://example.com/codmate-arm64.dmg"} ] } """ let data = Data(json.utf8) let release = try UpdateService.Release.decode(from: data) XCTAssertEqual(release.tagName, "v1.2.3") XCTAssertEqual(release.assets.first?.name, "codmate-arm64.dmg") } } ================================================ FILE: Tests/CodMateTests/UpdateSupportTests.swift ================================================ import XCTest @testable import CodMate final class UpdateSupportTests: XCTestCase { func testVersionCompare() { XCTAssertTrue(Version("1.2.3")! < Version("1.2.4")!) XCTAssertTrue(Version("1.2.3")! > Version("1.2.2")!) XCTAssertTrue(Version("1.2.0")! == Version("1.2")!) } func testAssetNameForArch() { XCTAssertEqual(UpdateAssetSelector.assetName(for: .arm64), "codmate-arm64.dmg") XCTAssertEqual(UpdateAssetSelector.assetName(for: .x86_64), "codmate-x86_64.dmg") } } ================================================ FILE: Tests/CodMateTests/UpdateViewModelTests.swift ================================================ import Foundation import XCTest @testable import CodMate final class UpdateViewModelTests: XCTestCase { @MainActor func testInstallInstructions() { let vm = UpdateViewModel(service: UpdateService()) XCTAssertTrue(vm.installInstructions.contains("Applications")) } @MainActor func testSandboxDownloadUsesTemporaryDirectory() async throws { let originalSandbox = getenv("APP_SANDBOX_CONTAINER_ID").map { String(cString: $0) } defer { if let originalSandbox { setenv("APP_SANDBOX_CONTAINER_ID", originalSandbox, 1) } else { unsetenv("APP_SANDBOX_CONTAINER_ID") } MockURLProtocol.requestHandler = nil } let fileManager = FileManager.default let downloadsDir = fileManager.urls(for: .downloadsDirectory, in: .userDomainMask).first ?? fileManager.temporaryDirectory let sourceURL = fileManager.temporaryDirectory.appendingPathComponent("codmate-test-source.dmg") try Data("test".utf8).write(to: sourceURL) let assetName = UpdateAssetSelector.assetName(for: .current) let releaseJSON = """ { "tag_name": "v999.0.0", "html_url": "https://example.com/release", "draft": false, "prerelease": false, "assets": [ { "name": "\(assetName)", "browser_download_url": "\(sourceURL.absoluteString)" } ] } """ let releaseData = Data(releaseJSON.utf8) MockURLProtocol.requestHandler = { request in let response = HTTPURLResponse( url: request.url ?? URL(string: "https://api.github.com/")!, statusCode: 200, httpVersion: nil, headerFields: nil )! return (response, releaseData) } let config = URLSessionConfiguration.ephemeral config.protocolClasses = [MockURLProtocol.self] let session = URLSession(configuration: config) let defaults = UserDefaults(suiteName: "UpdateViewModelTests")! defaults.removePersistentDomain(forName: "UpdateViewModelTests") let service = UpdateService(defaults: defaults, session: session, calendar: Calendar(identifier: .gregorian)) let vm = UpdateViewModel(service: service) vm.checkNow() let didUpdate = await waitUntil({ if case .updateAvailable = vm.state { return true } return false }, timeout: 2.0) XCTAssertTrue(didUpdate) setenv("APP_SANDBOX_CONTAINER_ID", "1", 1) let start = Date() vm.downloadIfNeeded() let didDownload = await waitUntil({ vm.showInstallInstructions || (!vm.isDownloading && vm.lastError != nil) }, timeout: 5.0) XCTAssertTrue(didDownload) XCTAssertNil(vm.lastError) XCTAssertTrue(vm.showInstallInstructions) let tempHit = findDownloadedFile(in: fileManager.temporaryDirectory, baseName: assetName, since: start) let downloadsHit = findDownloadedFile(in: downloadsDir, baseName: assetName, since: start) XCTAssertNotNil(tempHit) XCTAssertNil(downloadsHit) if let tempHit { try? fileManager.removeItem(at: tempHit) } if let downloadsHit { try? fileManager.removeItem(at: downloadsHit) } try? fileManager.removeItem(at: sourceURL) } } private final class MockURLProtocol: URLProtocol { static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))? override class func canInit(with request: URLRequest) -> Bool { true } override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } override func startLoading() { guard let handler = Self.requestHandler else { client?.urlProtocol(self, didFailWithError: URLError(.unsupportedURL)) return } do { let (response, data) = try handler(request) client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) client?.urlProtocol(self, didLoad: data) client?.urlProtocolDidFinishLoading(self) } catch { client?.urlProtocol(self, didFailWithError: error) } } override func stopLoading() {} } private func findDownloadedFile(in directory: URL, baseName: String, since: Date) -> URL? { let cutoff = since.addingTimeInterval(-2) guard let urls = try? FileManager.default.contentsOfDirectory( at: directory, includingPropertiesForKeys: [.contentModificationDateKey], options: [.skipsHiddenFiles] ) else { return nil } for url in urls { let name = url.lastPathComponent guard name == baseName || name.hasSuffix("-\(baseName)") else { continue } let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]) if let date = values?.contentModificationDate, date >= cutoff { return url } } return nil } private func waitUntil(_ condition: @escaping () -> Bool, timeout: TimeInterval) async -> Bool { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if condition() { return true } try? await Task.sleep(nanoseconds: 50_000_000) } return condition() } ================================================ FILE: Tests/CodMateTests/WizardResponseParserTests.swift ================================================ import XCTest @testable import CodMate final class WizardResponseParserTests: XCTestCase { func testDecodeEnvelope() { let raw = """ {"mode":"draft","draft":{"event":"Stop","commands":[{"command":"/bin/echo"}]}} """ let envelope: WizardDraftEnvelope? = WizardResponseParser.decodeEnvelope(raw) XCTAssertEqual(envelope?.mode, .draft) XCTAssertEqual(envelope?.draft?.event, "Stop") } func testDecodeWithCodeFence() { let raw = """ ```json {"mode":"draft","draft":{"event":"Stop","commands":[{"command":"/bin/echo"}]}} ``` """ let envelope: WizardDraftEnvelope? = WizardResponseParser.decodeEnvelope(raw) XCTAssertEqual(envelope?.mode, .draft) XCTAssertEqual(envelope?.draft?.commands.count, 1) } func testDecodeEnvelopeFromWrapper() { let raw = """ {"result":"{\\"mode\\":\\"draft\\",\\"draft\\":{\\"event\\":\\"Stop\\",\\"commands\\":[{\\"command\\":\\"/bin/echo\\"}]}}"} """ let envelope: WizardDraftEnvelope? = WizardResponseParser.decodeEnvelope(raw) XCTAssertEqual(envelope?.mode, .draft) XCTAssertEqual(envelope?.draft?.event, "Stop") } } ================================================ FILE: assets/Assets.xcassets/AntigravityIcon.imageset/Contents.json ================================================ { "images" : [ { "filename" : "antigravity.svg", "idiom" : "mac" } ], "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "preserves-vector-representation" : true, "template-rendering-intent" : "original" } } ================================================ FILE: assets/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "idiom" : "mac", "size" : "16x16", "scale" : "1x", "filename" : "icon_16x16.png" }, { "idiom" : "mac", "size" : "16x16", "scale" : "2x", "filename" : "icon_16x16@2x.png" }, { "idiom" : "mac", "size" : "32x32", "scale" : "1x", "filename" : "icon_32x32.png" }, { "idiom" : "mac", "size" : "32x32", "scale" : "2x", "filename" : "icon_32x32@2x.png" }, { "idiom" : "mac", "size" : "128x128", "scale" : "1x", "filename" : "icon_128x128.png" }, { "idiom" : "mac", "size" : "128x128", "scale" : "2x", "filename" : "icon_128x128@2x.png" }, { "idiom" : "mac", "size" : "256x256", "scale" : "1x", "filename" : "icon_256x256.png" }, { "idiom" : "mac", "size" : "256x256", "scale" : "2x", "filename" : "icon_256x256@2x.png" }, { "idiom" : "mac", "size" : "512x512", "scale" : "1x", "filename" : "icon_512x512.png" }, { "idiom" : "mac", "size" : "512x512", "scale" : "2x", "filename" : "icon_512x512@2x.png" } ], "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: assets/Assets.xcassets/ChatGPTIcon.imageset/Contents.json ================================================ { "images" : [ { "filename" : "chatgpt.svg", "idiom" : "mac" } ], "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "preserves-vector-representation" : true, "template-rendering-intent" : "original" } } ================================================ FILE: assets/Assets.xcassets/ClaudeIcon.imageset/Contents.json ================================================ { "images" : [ { "filename" : "claude.svg", "idiom" : "mac" } ], "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "preserves-vector-representation" : true, "template-rendering-intent" : "original" } } ================================================ FILE: assets/Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: assets/Assets.xcassets/DeepSeekIcon.imageset/Contents.json ================================================ { "images": [ { "filename": "deepseek.svg", "idiom": "mac" } ], "info": { "author": "xcode", "version": 1 }, "properties": { "preserves-vector-representation": true, "template-rendering-intent": "original" } } ================================================ FILE: assets/Assets.xcassets/GeminiIcon.imageset/Contents.json ================================================ { "images" : [ { "filename" : "gemini.svg", "idiom" : "mac" } ], "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "preserves-vector-representation" : true, "template-rendering-intent" : "original" } } ================================================ FILE: assets/Assets.xcassets/KimiIcon.imageset/Contents.json ================================================ { "images" : [ { "filename" : "kimi.svg", "idiom" : "mac" } ], "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "preserves-vector-representation" : true, "template-rendering-intent" : "original" } } ================================================ FILE: assets/Assets.xcassets/MCPMateLogo.imageset/Contents.json ================================================ { "images" : [ { "filename" : "MCPMate.svg", "idiom" : "mac" } ], "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "preserves-vector-representation" : true } } ================================================ FILE: assets/Assets.xcassets/MiniMaxIcon.imageset/Contents.json ================================================ { "images" : [ { "filename" : "minimax.svg", "idiom" : "mac" } ], "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "preserves-vector-representation" : true, "template-rendering-intent" : "original" } } ================================================ FILE: assets/Assets.xcassets/OpenRouterIcon.imageset/Contents.json ================================================ { "images" : [ { "filename" : "openrouter.svg", "idiom" : "mac" } ], "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "preserves-vector-representation" : true, "template-rendering-intent" : "original" } } ================================================ FILE: assets/Assets.xcassets/QwenIcon.imageset/Contents.json ================================================ { "images" : [ { "filename" : "qwen.svg", "idiom" : "mac" } ], "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "preserves-vector-representation" : true, "template-rendering-intent" : "original" } } ================================================ FILE: assets/Assets.xcassets/ZaiIcon.imageset/Contents.json ================================================ { "images" : [ { "filename" : "zai.svg", "idiom" : "mac" } ], "info" : { "author" : "xcode", "version" : 1 }, "properties" : { "preserves-vector-representation" : true, "template-rendering-intent" : "original" } } ================================================ FILE: assets/CodMate-Notify.entitlements ================================================ com.apple.security.app-sandbox com.apple.security.files.bookmarks.app-scope com.apple.security.files.user-selected.read-write com.apple.security.network.client com.apple.security.files.user-selected.read-only com.apple.security.temporary-exception.files.home-relative-path.read-only .ssh/config ================================================ FILE: assets/CodMate.entitlements ================================================ com.apple.security.app-sandbox com.apple.security.files.bookmarks.app-scope com.apple.security.files.user-selected.read-write com.apple.security.network.client com.apple.security.files.user-selected.read-only com.apple.security.temporary-exception.files.home-relative-path.read-only .ssh/config ================================================ FILE: assets/Info.plist ================================================ CFBundleDevelopmentRegion en CFBundleDisplayName CodMate CFBundleExecutable CodMate CFBundleIdentifier ai.umate.codmate CFBundleInfoDictionaryVersion 6.0 CFBundleName CodMate CFBundlePackageType APPL CFBundleShortVersionString 0.0.0 CFBundleVersion 1 CFBundleIconFile AppIcon CFBundleIconName AppIcon LSMinimumSystemVersion 13.5 LSApplicationCategoryType public.app-category.developer-tools NSMainStoryboardFile NSPrincipalClass NSApplication NSHumanReadableCopyright Copyright © 2025 CodMate NSSupportsAutomaticGraphicsSwitching NSAppleEventsUsageDescription CodMate may open Terminal or other apps on your request. CFBundleURLTypes CFBundleURLName ai.codmate.app CFBundleURLSchemes codmate CFBundleDocumentTypes CFBundleTypeName Folder CFBundleTypeRole Viewer LSHandlerRank Owner LSItemContentTypes public.folder public.directory CodMateGitTag CodMateGitCommit CodMateGitDirty ================================================ FILE: docs/feature-inventory.md ================================================ # CodMate Feature Inventory > Purpose: Provide a traceable feature inventory with code evidence (file paths) for value/advantage/use-case synthesis. Each feature includes code evidence (file paths). ## Coverage and Methodology - Coverage sources: `README.md`, `AGENTS.md`, `docs/` specification documents + `views/` (UI entry points) + `services/` (capability implementations) + `models/` (data/state). - Evidence format: Each feature is annotated with `Evidence:`, containing minimal necessary file paths (traceable item by item). - Note: This inventory focuses on "feature points", not marketing copy; it can be used to extract "value/advantage/scenarios" later. --- ## 1) Session Sources and Collection (Multi-CLI Unified Management) - Codex session parsing and provider (local `.jsonl`). Evidence: `services/SessionProvider.swift`, `services/CodexConfigService.swift`, `services/SessionIndexer.swift` - Claude Code session parsing and provider. Evidence: `services/ClaudeSessionParser.swift`, `services/ClaudeSessionProvider.swift` - Gemini CLI session parsing and provider. Evidence: `services/GeminiSessionParser.swift`, `services/GeminiSessionProvider.swift` - Remote session mirroring (SSH sync of remote sessions). Evidence: `services/RemoteSessionMirror.swift`, `services/RemoteSessionProvider.swift`, `services/SSHConfigResolver.swift` - Directory change monitoring (incremental session/index updates). Evidence: `services/DirectoryMonitor.swift` - Session activity tracking and statistics. Evidence: `services/SessionActivityTracker.swift`, `models/OverviewAggregate.swift` ## 2) Indexing, Caching, and Performance Paths - Session indexing (SQLite) with incremental updates. Evidence: `services/SessionIndexSQLiteStore.swift`, `services/SessionIndexer.swift` - Lightweight caching and disk cache strategies. Evidence: `services/SessionCacheStore.swift`, `services/RipgrepDiskCache.swift` - Full-text scanning (ripgrep) and search caching. Evidence: `services/SessionRipgrepStore.swift`, `services/RipgrepRunner.swift` - Session timeline loading and incremental parsing. Evidence: `services/SessionTimelineLoader.swift`, `services/SessionEnrichmentService.swift` - Context pruning and optimization (avoiding oversized contexts). Evidence: `services/ContextTreeshaker.swift` ## 3) Main Interface Structure and Navigation (3-Column Layout + Sidebar) - Three-column structure: Sidebar / List / Detail. Evidence: `views/Content/ContentView.swift`, `views/SessionNavigationView.swift`, `views/Content/ContentView+Sidebar.swift` - Sidebar top "All Sessions" entry and count. Evidence: `views/Content/ContentView+Sidebar.swift`, `models/SidebarState.swift` - Directory tree navigation (aggregated by cwd statistics). Evidence: `models/PathTree.swift`, `services/PathTreeStore.swift`, `views/PathTreeView.swift` - Calendar month view (daily statistics + multi-select). Evidence: `views/CalendarMonthView.swift`, `models/DateDimension.swift` - Overview cards and activity charts. Evidence: `views/OverviewCard.swift`, `views/OverviewActivityChart.swift`, `models/ActivityChartData.swift` ## 4) Session List and Filtering - Session list row information (title/time/snippet/metrics). Evidence: `views/SessionListRowView.swift`, `models/SessionSummary.swift` - Sorting and scope (Today/Recent, etc.). Evidence: `models/SessionLoadScope.swift`, `models/SessionListViewModel.swift` - List filtering, status indicators, running sessions. Evidence: `models/SessionListViewModel.swift`, `models/SessionNavigation.swift` - Multi-dimensional filtering by project/directory/date. Evidence: `models/SessionListViewModel+Projects.swift`, `models/SessionListViewModel.swift` ## 5) Session Details and Timeline - Conversation timeline view (user/assistant/tool/info). Evidence: `views/ConversationTimelineView.swift`, `models/TimelineEvent.swift`, `models/ConversationTurn.swift` - Timeline attachment parsing and opening. Evidence: `services/TimelineAttachmentDecoder.swift`, `services/TimelineAttachmentOpener.swift` - Environment Context/Turn Context display. Evidence: `models/EnvironmentContextInfo.swift`, `views/SessionDetailView.swift` - Task Instructions collapsible display. Evidence: `views/SessionDetailView.swift` ## 6) Global Search and Content Retrieval - Global search panel (shortcuts/progress/cancel). Evidence: `views/Search/GlobalSearchPanel.swift`, `models/GlobalSearchViewModel.swift` - Search index service and incremental scanning. Evidence: `services/GlobalSearchService.swift`, `services/SessionRipgrepStore.swift` - Repository content search (repo-level scanning). Evidence: `services/RepoContentSearchService.swift` - Toolbar search entry. Evidence: `views/Search/ToolbarSearchField.swift` ## 7) Projects / Tasks / Session Archiving - Project management (create/edit/select). Evidence: `views/ProjectsListView.swift`, `services/ProjectsStore.swift`, `models/Project.swift` - Task management and grouping. Evidence: `views/TaskListView.swift`, `services/TasksStore.swift`, `models/Task.swift` - Session assignment to projects/tasks. Evidence: `models/SessionListViewModel+Projects.swift`, `models/SessionListViewModel.swift` - Project-level Overview container and statistics. Evidence: `views/ProjectSpecificOverviewContainerView.swift`, `models/ProjectOverviewViewModel.swift` ## 8) Session Metadata (Rename/Comment) - Session title/comment editing. Evidence: `views/EditSessionMetaView.swift`, `models/SessionListViewModel+Notes.swift` - Notes storage and migration. Evidence: `services/SessionNotesStore.swift`, `utils/FilenameSanitizer.swift` ## 9) Resume / New / Terminal Workflow - Resume session (external terminal or embedded terminal). Evidence: `services/SessionActions+Terminal.swift`, `views/EmbeddedTerminalView.swift`, `views/CodMateTerminalView.swift` - New session (reuse cwd / model / policy). Evidence: `services/SessionActions+Commands.swift`, `models/SessionListViewModel+Commands.swift` - Terminal session management (keep running per session). Evidence: `services/TerminalSessionManager.swift` - External terminal configuration (Terminal/iTerm2/Warp). Evidence: `services/ExternalTerminalProfileStore.swift`, `views/ExternalTerminalMenuHelpers.swift` - Copy "real command" (full parameters). Evidence: `views/Content/ContentView+DetailActionBar.swift`, `services/SessionActions+Commands.swift` ## 10) Prompt System and Quick Commands - Prompts Picker (insert into terminal input). Evidence: `views/Content/ContentView.swift`, `views/Content/ContentView+DetailActionBar.swift` - Prompt presets and merging (project-level/user-level/built-in). Evidence: `services/PresetPromptsStore.swift` - Prompt maintenance (add/delete/hide). Evidence: `services/PresetPromptsStore.swift`, `views/Content/ContentView.swift` - Warp title prompt (prompt title). Evidence: `utils/WarpTitlePrompt.swift`, `services/SessionPreferencesStore.swift` ## 11) Git Review (Review Mode) - Git changes tree + diff preview. Evidence: `views/GitChanges/GitChangesPanel.swift`, `services/GitService.swift` - Stage / Unstage, operations by file/directory. Evidence: `views/GitChanges/GitChangesPanel+DiffTree.swift`, `services/GitService.swift` - Commit editing and execution. Evidence: `views/GitChanges/GitChangesPanel.swift`, `models/GitChangesViewModel.swift` - AI-generated Commit Message. Evidence: `models/GitChangesViewModel.swift`, `services/LLMHTTPService.swift` - Git history graph and commit details. Evidence: `views/GitChanges/GitChangesPanel+Graph.swift`, `models/GitGraphViewModel.swift` - Review panel state and persistence. Evidence: `models/ReviewPanelState.swift`, `views/GitChanges/GitChangesPanel+Lifecycle.swift` ## 12) Providers / Models / Usage - Providers registry (unified management of API Key / Base URL / models). Evidence: `services/ProvidersRegistryService.swift`, `views/ProvidersSettingsView.swift` - Provider icon/display. Evidence: `views/ProviderIconView.swift` - Usage API clients and status. Evidence: `services/ClaudeUsageAPIClient.swift`, `services/CodexFeaturesService.swift`, `services/GeminiUsageAPIClient.swift` - Usage status UI and triple-ring indicator. Evidence: `views/UsageStatusControl.swift`, `views/TripleUsageDonutView.swift` ## 13) MCP Servers and Extensions - MCP Servers list, enable, edit. Evidence: `models/MCPServer.swift`, `services/MCPServersStore.swift`, `views/MCPServersSettingsView.swift` - MCP Uni-Import (import/normalization). Evidence: `services/UniImportMCPNormalizer.swift`, `views/MCPServersSettingsView.swift` - MCP connection testing and capability detection. Evidence: `services/MCPQuickTestService.swift` - Extensions settings entry (MCP / Skills). Evidence: `views/ExtensionsSettingsView.swift`, `models/ExtensionsSettingsTab.swift` ## 14) Skills (Skill Packages) - Skills list, loading, configuration. Evidence: `services/SkillsStore.swift`, `views/SkillsSettingsView.swift` - Skills synchronization and application to projects. Evidence: `services/SkillsSyncService.swift`, `services/ProjectExtensionsApplier.swift` - Skills package preview and details. Evidence: `views/Skills/SkillPackageExplorerView.swift`, `models/SkillsModels.swift` - Project-level Skills settings. Evidence: `views/ProjectsListView.swift`, `models/ProjectExtensionsViewModel.swift` ## 15) Notification System - System notification wrapper. Evidence: `services/SystemNotifier.swift`, `services/EmbeddedNotifySniffer.swift` - Claude Code notification hook setup. Evidence: `models/ClaudeCodeVM.swift`, `services/ClaudeSettingsService.swift` - Gemini notification settings. Evidence: `views/GeminiSettingsView.swift`, `models/GeminiVM.swift` ## 16) Diagnostics and Advanced Settings - Dialectics diagnostics panel (data directories/index/reports). Evidence: `views/DialecticsPane.swift`, `services/SessionsDiagnosticsService.swift` - Advanced Settings: Path / Dialectics. Evidence: `views/AdvancedSettingsView.swift`, `views/AdvancedPathPane.swift` - Diagnostics report export. Evidence: `views/DiagnosticsViews.swift`, `services/SessionsDiagnosticsService.swift` ## 17) Settings System (Multi-Page/Multi-Tab) - Settings main entry and categorization. Evidence: `views/SettingsView.swift`, `models/SettingCategory.swift` ### 17.1 General - System menu bar icon display policy. Evidence: `views/SettingsView.swift`, `models/SystemMenuVisibility.swift` - Default editor selection (for quick opening in Review, etc.). Evidence: `views/SettingsView.swift`, `models/EditorApp.swift` - Global search panel style (⌘F display mode). Evidence: `views/SettingsView.swift`, `models/GlobalSearchModels.swift` - Timeline/Markdown message type visibility configuration (with "Restore Defaults"). Evidence: `views/SettingsView.swift`, `models/SessionPreferencesStore.swift`, `models/TimelineEvent.swift` ### 17.2 Terminal - Embedded terminal toggle (non-sandboxed version), CLI console mode. Evidence: `views/SettingsView.swift`, `services/TerminalSessionManager.swift` - Terminal font and cursor style selection. Evidence: `views/SettingsView.swift`, `utils/TerminalFontResolver.swift`, `models/TerminalCursorStyleOption.swift` - External terminal default app and auto-open. Evidence: `views/SettingsView.swift`, `services/ExternalTerminalProfileStore.swift` - New/resume command auto-copy to clipboard. Evidence: `views/SettingsView.swift`, `services/SessionPreferencesStore.swift` - Warp tab title prompt. Evidence: `views/SettingsView.swift`, `utils/WarpTitlePrompt.swift` ### 17.3 Command (Codex CLI Default Parameters) - Sandbox policy and Approval policy defaults. Evidence: `views/SettingsView.swift`, `models/ExecutionPolicy.swift` - `--full-auto` and dangerous bypass (bypass approvals/sandbox) toggle. Evidence: `views/SettingsView.swift` ### 17.4 Providers (Global Provider Management) - Provider list, template add, edit/delete. Evidence: `views/ProvidersSettingsView.swift`, `services/ProvidersRegistryService.swift` - Provider editor: Codex/Claude Base URL, API Key Env, Wire API. Evidence: `views/ProvidersSettingsView.swift` - Model catalog editing: add/delete, default model, capability tags (reasoning/tool/vision/long context). Evidence: `views/ProvidersSettingsView.swift`, `services/ProvidersRegistryService.swift` - Connection testing and documentation entry. Evidence: `views/ProvidersSettingsView.swift` ### 17.5 Codex Settings - Provider binding (Active Provider + Model). Evidence: `views/CodexSettingsView.swift`, `models/CodexVM.swift` - Runtime defaults: Reasoning Effort/Summary, Verbosity, Sandbox, Approval. Evidence: `views/CodexSettingsView.swift`, `models/CodexVM.swift` - Feature Flags: fetch and per-item override toggles. Evidence: `views/CodexSettingsView.swift`, `services/CodexFeaturesService.swift` - Notifications: TUI notifications, system notifications, notify bridge self-test. Evidence: `views/CodexSettingsView.swift`, `services/SystemNotifier.swift` - Privacy/environment policy: inheritance scope, include/exclude, environment variable overrides, hide/show reasoning. Evidence: `views/CodexSettingsView.swift`, `models/CodexVM.swift` - Raw Config read-only view and quick open. Evidence: `views/CodexSettingsView.swift` ### 17.6 Claude Code Settings - Provider: Active Provider, default model and aliases (Haiku/Sonnet/Opus), login method. Evidence: `views/ClaudeCodeSettingsView.swift`, `models/ClaudeCodeVM.swift` - Runtime: Permission Mode, Skip Permissions, Debug/Verbose, tool allow/deny, IDE auto-connect, Strict MCP, Fallback Model. Evidence: `views/ClaudeCodeSettingsView.swift`, `models/ClaudeCodeVM.swift` - Notifications: install hook, hook command preview and self-test. Evidence: `views/ClaudeCodeSettingsView.swift`, `services/ClaudeSettingsService.swift` - Raw Config: settings.json read-only view and open. Evidence: `views/ClaudeCodeSettingsView.swift` ### 17.7 Gemini CLI Settings - General: Preview Features, Prompt Completion, Vim Mode, Disable Auto Update, Session Retention. Evidence: `views/GeminiSettingsView.swift`, `models/GeminiVM.swift` - Runtime: Sandbox/Approval defaults. Evidence: `views/GeminiSettingsView.swift`, `models/ExecutionPolicy.swift` - Model: model selection, Max Session Turns, Compression Threshold, Skip Next Speaker Check. Evidence: `views/GeminiSettingsView.swift`, `models/GeminiVM.swift` - Notifications: system notifications and self-test. Evidence: `views/GeminiSettingsView.swift` - Raw Config: settings.json read-only view and open. Evidence: `views/GeminiSettingsView.swift` ### 17.8 Extensions Settings - MCP Servers: list, enable/disable, Uni‑Import, form/JSON editing, connection testing. Evidence: `views/MCPServersSettingsView.swift`, `services/MCPQuickTestService.swift` - Skills: search, install (folder/Zip/URL/drag-drop), enable/disable, reinstall/uninstall, details preview. Evidence: `views/SkillsSettingsView.swift`, `models/SkillsLibraryViewModel.swift` ### 17.9 Git Review Settings - Diff display (line numbers, soft wrap). Evidence: `views/GitReviewSettingsView.swift`, `services/GitService.swift` - Commit generation (Provider/Model selection). Evidence: `views/GitReviewSettingsView.swift`, `services/ProvidersRegistryService.swift` - Commit Prompt template. Evidence: `views/GitReviewSettingsView.swift`, `services/SessionPreferencesStore.swift` ### 17.10 Remote Hosts - SSH host list and enable toggle (from `~/.ssh/config`). Evidence: `views/RemoteHostsSettingsView.swift`, `services/SSHConfigResolver.swift` - One-click sync/refresh, unavailable host prompts and permission guidance. Evidence: `views/RemoteHostsSettingsView.swift`, `services/SandboxPermissionsManager.swift` ### 17.11 Advanced - Path: Projects/Notes root directory switching. Evidence: `views/AdvancedPathPane.swift`, `models/SessionPreferencesStore.swift` - CLI path overrides and auto-detection, PATH snapshot. Evidence: `views/AdvancedPathPane.swift`, `models/CLIPathVM.swift` - Dialectics: environment info, ripgrep statistics, index rebuild, sessions/notes/projects directory diagnostics, report export. Evidence: `views/DialecticsPane.swift`, `services/SessionsDiagnosticsService.swift`, `services/SessionRipgrepStore.swift` ### 17.12 About - Version/build time, project link, license viewing. Evidence: `views/AboutViews.swift` ## 18) Menu Bar (Status Bar) - Status bar menu and quick actions. Evidence: `services/MenuBarController.swift` - Provider/model/usage display. Evidence: `services/MenuBarController.swift`, `models/UsageProviderSnapshot.swift` - Recent projects/sessions entry. Evidence: `services/MenuBarController.swift`, `views/RecentSessionsListView.swift` ## 19) Security and Authorization - Security Scoped Bookmarks management. Evidence: `services/SecurityScopedBookmarks.swift`, `services/AuthorizationHub.swift` - Sandbox permissions management and prompts. Evidence: `services/SandboxPermissionsManager.swift`, `views/SandboxPermissionsView.swift` - External URL routing (codmate://). Evidence: `services/ExternalURLRouter.swift` ## 20) Data Export and Formatting - Markdown export builder. Evidence: `utils/MarkdownExportBuilder.swift`, `views/SessionDetailView.swift` - Token/time/duration formatting. Evidence: `utils/TokenFormatter.swift`, `models/SessionEvent.swift` - Configurable Timeline/Markdown visibility. Evidence: `models/SessionPreferencesStore.swift`, `views/SettingsView.swift` ## 21) Statistics and Display Helpers - Usage / Stats cards and aggregation. Evidence: `models/OverviewAggregate.swift`, `views/OverviewCard.swift` - Usage status models (Codex/Claude/Gemini). Evidence: `models/CodexUsageStatus.swift`, `models/ClaudeUsageStatus.swift`, `models/GeminiUsageStatus.swift` - Dual/triple-column statistics display components. Evidence: `views/TripleUsageDonutView.swift`, `views/UsageStatusControl.swift` ## 22) Compatibility and Runtime Environment - CLI PATH environment setup and snapshot. Evidence: `utils/CLIEnvironment.swift`, `views/AdvancedPathPane.swift` - App distribution/environment identification. Evidence: `utils/AppDistribution.swift`, `utils/AppAvailability.swift` - Window/state persistence. Evidence: `services/WindowStateStore.swift`, `utils/WindowConfigurator.swift` ## 23) Claude Web / Browser Integration (Auxiliary Capabilities) - Chrome/Safari cookie reading (Claude sessionKey). Evidence: `services/BrowserCookies/ChromeCookieImporter.swift`, `services/BrowserCookies/SafariCookieImporter.swift` - Claude Web API client (sessions/usage, etc.). Evidence: `services/ClaudeWebAPIClient.swift`, `services/LLMHTTPService.swift` --- ## 24) Value/Advantage Tag Library and Mapping (For Synthesis) > Note: The following tags can be used directly as "value point" titles or cards; features can be mapped to values later. ### 24.1 Value Tag Library (Suggested Terms) - Efficiency and Speed (fast retrieval/fast resume/fast navigation) - Context Continuity (uninterrupted across terminals, across CLIs) - Traceability and Knowledge Accumulation (searchable, exportable, reviewable) - Customization and Control (Provider/Model/policy/permissions) - Security and Compliance (Sandbox, permissions, environment variables) - Collaboration and Standardization (projects/tasks/skills/prompt library) - Quality and Delivery Loop (Review/Commit) - Operations and Remote (SSH mirroring, remote sessions) - Ecosystem Compatibility (Codex/Claude/Gemini multi-source) - Diagnosability and Recoverability (diagnostics/index rebuild/reports) ### 24.2 Feature → Value Mapping (Summary) - **Multi-source session unified management + remote mirroring** → Ecosystem compatibility, operations and remote, traceability Evidence: `services/SessionProvider.swift`, `services/RemoteSessionMirror.swift` - **Global search + high-performance indexing** → Efficiency and speed, traceability Evidence: `services/GlobalSearchService.swift`, `services/SessionIndexSQLiteStore.swift` - **Projects/Tasks organization** → Collaboration and standardization, context continuity Evidence: `services/ProjectsStore.swift`, `services/TasksStore.swift` - **Resume/New + terminal integration** → Context continuity, efficiency and speed Evidence: `services/SessionActions+Terminal.swift`, `views/EmbeddedTerminalView.swift` - **Review (Git Changes)** → Quality and delivery loop Evidence: `views/GitChanges/GitChangesPanel.swift`, `services/GitService.swift` - **Providers/Models/Policies** → Customization and control, ecosystem compatibility Evidence: `views/ProvidersSettingsView.swift`, `views/CodexSettingsView.swift` - **Notification system** → Efficiency and speed (reduced waiting and switching) Evidence: `services/SystemNotifier.swift`, `views/ClaudeCodeSettingsView.swift` - **Diagnostics/Dialectics** → Diagnosability and recoverability, stability Evidence: `views/DialecticsPane.swift`, `services/SessionsDiagnosticsService.swift` - **Sandbox/permissions/environment policy** → Security and compliance Evidence: `views/CodexSettingsView.swift`, `services/SandboxPermissionsManager.swift` --- ## 25) Use Case Matrix (Suggested) > Note: For "recommended use cases" synthesis; can be rewritten as marketing descriptions. | Scenario | Key Requirements | Corresponding Capabilities (Examples) | |---|---|---| | Personal daily development | Quick history retrieval/continue context | Global search, Resume/New, timeline | | Team collaboration and standardization | Shared standards and prompts | Projects/Tasks, Skills, Prompts, Providers | | Multi-model/multi-vendor switching | Unified API/model management | Providers Registry, model catalog/capability tags | | Security-sensitive environments | Access/permission control | Sandbox/Approval/permission management/environment policy | | Remote development/operations | Unified remote session archiving | SSH remote mirroring, Remote Hosts | | Code review and delivery | Diff/Commit loop | Review mode, Stage/Unstage, AI Commit | | Large-scale history review | Traceability and export | Timeline, Markdown export, Notes | | Diagnostics and repair | Index/data anomaly troubleshooting | Dialectics, index rebuild, report export | --- ## 26) Secondary Inventory Supplements (Search / Terminal / Review) ### 26.1 Search (Global Search) - Search scope switching (Scope segmented selection). Evidence: `views/Search/GlobalSearchPanel.swift`, `models/GlobalSearchModels.swift` - Search panel style (floating window / Popover). Evidence: `views/Search/GlobalSearchPanel.swift`, `models/GlobalSearchModels.swift` - Progress/statistics/cancel (files/matches). Evidence: `views/Search/GlobalSearchPanel.swift`, `services/SessionRipgrepStore.swift` - Result types and summaries (session/notes/project summaries). Evidence: `views/Search/GlobalSearchPanel.swift`, `models/GlobalSearchViewModel.swift` ### 26.2 Terminal - Embedded terminal (SwiftTerm) and external terminal coexistence. Evidence: `views/EmbeddedTerminalView.swift`, `views/CodMateTerminalView.swift` - Theme synchronization (dark/light) and font strategy (CJK-friendly). Evidence: `views/EmbeddedTerminalView.swift` - Initial command injection and one-click copy/open external terminal. Evidence: `views/EmbeddedTerminalView.swift` - Terminal running and session binding (no exit on switch). Evidence: `services/TerminalSessionManager.swift`, `views/Content/ContentView+Detail.swift` ### 26.3 Review (Git Changes) - Multi-mode layout: Diff / Graph / Explorer (or preview). Evidence: `views/GitChanges/GitChangesPanel.swift`, `views/GitChanges/GitChangesPanel+Graph.swift` - File tree and staged/unstaged view separation. Evidence: `views/GitChanges/GitChangesPanel+LeftPane.swift` - Line numbers/soft wrap settings. Evidence: `views/GitReviewSettingsView.swift` - Commit message generation and template. Evidence: `views/GitReviewSettingsView.swift`, `models/GitChangesViewModel.swift` --- ## To Be Refined Later (Optional) - Fine-grained UI entry inventory (function mapping for each Button/ToolbarItem). - Feature points → marketing copy (rewritten for different audiences). - Case-based implementation of typical scenarios (real project stories or flowcharts). ================================================ FILE: docs/icon.icon/icon.json ================================================ { "fill" : { "automatic-gradient" : "extended-srgb:0.00000,0.53333,1.00000,1.00000" }, "groups" : [ { "layers" : [ { "blend-mode" : "plus-lighter", "glass" : true, "image-name" : "4.4-–-layer.png", "name" : "4.4-–-layer" } ], "shadow" : { "kind" : "neutral", "opacity" : 0.5 }, "translucency" : { "enabled" : true, "value" : 0.5 } }, { "layers" : [ { "blend-mode" : "normal", "image-name" : "3.3-–-layer.png", "name" : "3.3-–-layer" }, { "glass" : true, "hidden" : false, "image-name" : "2.2-–-layer.png", "name" : "2.2-–-layer" } ], "shadow" : { "kind" : "neutral", "opacity" : 0.5 }, "translucency" : { "enabled" : true, "value" : 0.5 } }, { "layers" : [ { "image-name" : "1.background.png", "name" : "1.background", "opacity-specializations" : [ { "appearance" : "dark", "value" : 0.25 } ] } ], "shadow" : { "kind" : "neutral", "opacity" : 0.5 }, "translucency" : { "enabled" : true, "value" : 0.5 } } ], "supported-platforms" : { "circles" : [ "watchOS" ], "squares" : "shared" } } ================================================ FILE: docs/projects.md ================================================ Projects in CodMate (Phase 1) Overview - Introduces a virtual “Projects” view to organize Codex sessions conceptually, in addition to the existing physical directory view. - Projects map to the `projects` group in Codex `config.toml` and can also be assigned per-session. - Minimal viable goals: list projects, filter sessions by project, create a new project, and assign sessions to a project. Goals (v1) - Toggle sidebar middle area between Directories and Projects without changing top “All Sessions” and bottom Calendar. - Read/write projects from Codex config: `[projects.]` tables holding at least folder path and trust level. - Allow creating a project (name, folder, trust, overview, instructions, optional profile). - Assign sessions to a project (context menu; drag-and-drop planned for v1.1). - When a project is selected, filter the middle session list accordingly. Non-goals (deferred) - Automatic profile creation/rename sync. - Project-scoped overrides of all global runtime settings. - Cross-session knowledge linking UX; export/minify pipelines. - Drag-drop from middle list to sidebar project rows (v1.1). Data Model - Project (new model): - `id: String` – stable identifier used in config/notes - `name: String` – display name - `directory: String` – absolute path for project root - `trustLevel: String?` – e.g., `trusted` | `untrusted` (string passthrough) - `overview: String?` – short description - `instructions: String?` – default instructions for new sessions - `profileId: String?` – optional profile association (future use) - Session metadata extension (notes JSON per session id): - `projectId: String?` - `profileId: String?` (reserved) - Backward compatible with existing title/comment; missing keys are tolerated. Persistence - Projects: Codex config at `~/.codex/config.toml` via `[projects.]` tables. - Supported keys: `name`, `directory`, `trust_level`, `overview`, `instructions`, `profile`. - We read both `directory` and `path` for compatibility; we write `directory`. - Session-to-project mapping: stored in notes JSON under `~/.codmate/notes/.json` along with title/comment (with automatic migration from the legacy `~/.codex/notes`). View Model Changes - `SessionListViewModel` - New state: `projects: [Project]`, `selectedProjectIDs: Set` (Cmd-click enables multi-select filters). - Loads projects on startup and when config changes. - Filters sessions by selected project (matches notes.projectId; directory matching is a future enhancement). - New APIs: `assignSessions(to projectId: String, ids: [String])`, `loadProjects()`, `setSelectedProject(_:)`, `clearAllFilters()` resets both path and project. UI/UX - Sidebar (left): - Top fixed: “All Sessions” row (unchanged). Click clears both path and project filters. - Middle scrollable: segmented toggle – “Directories” | “Projects”. - Directories: existing Path tree. - Projects: list of projects with count badges; “New Project” button. - Bottom fixed: calendar month view (unchanged). - Only the middle area scrolls. Width rules unchanged. - Projects list interactions: - Click selects project → filters sessions. Cmd-click toggles multi-selection across projects; the filter matches any selected project (including descendants). - Context menu on session rows (middle column): “Assign to Project…” flyout that lists projects. - “New Project” opens a sheet to input: Name, Directory (choose…), Trust Level, Overview, Instructions, Profile (optional). CLI Integration (preparation) - New session from a Project will reuse the project’s directory and (later) its profile/config defaults. For v1, we only expose the project selection and filtering; project-scoped new-session is planned in a follow-up. Performance - Projects list is small; reads config once and caches in memory. Writes rewrite only the `projects` region similar to providers. - Session assignment uses existing notes store; no large scans. Filtering is O(n) over the already-loaded day scope. Error Handling - Config I/O surfaced via `SessionListViewModel.errorMessage` and non-blocking alerts. - Notes writes are best-effort; UI does not crash on failures. Extensibility (v1.1+) - Drag-and-drop from middle list to project rows (drop target) to assign sessions. - Directory inference: if a session’s `cwd` is under a project directory and no explicit assignment exists, consider it in that project (opt-in). - Project-level overrides for model, reasoning, sandbox/approval flags. - Auto-create and sync a same-id Profile; conflict prompts. ================================================ FILE: ghostty/Package.swift ================================================ // swift-tools-version: 6.0 import PackageDescription import Foundation let packageDir = URL(fileURLWithPath: #filePath).deletingLastPathComponent() let vendorLibDirDefault = packageDir.appendingPathComponent("Vendor/lib").path let vendorLibDir = ProcessInfo.processInfo.environment["GHOSTTY_VENDOR_LIB"] ?? vendorLibDirDefault let package = Package( name: "ghostty", platforms: [.macOS(.v13)], products: [ .library( name: "GhosttyKit", targets: ["GhosttyKit"] ), ], targets: [ // C library target for libghostty .systemLibrary( name: "CGhostty", path: "Sources/CGhostty", pkgConfig: nil ), // Swift wrapper target .target( name: "GhosttyKit", dependencies: ["CGhostty"], path: "Sources/GhosttyKit", resources: [ .process("../../Resources/themes"), ], linkerSettings: [ .linkedLibrary("ghostty"), .unsafeFlags([ "-L", vendorLibDir, "-Xlinker", "-rpath", "-Xlinker", "@executable_path/../Frameworks", // Enable dead code stripping to remove unused symbols from static library "-Xlinker", "-dead_strip", ]), .linkedFramework("Metal"), .linkedFramework("MetalKit"), .linkedFramework("IOSurface"), .linkedFramework("Carbon"), ] ), ], swiftLanguageModes: [.v5] ) ================================================ FILE: ghostty/Resources/themes/Apple Classic ================================================ palette = 0=#000000 palette = 1=#c91b00 palette = 2=#00c200 palette = 3=#c7c400 palette = 4=#1c3fe1 palette = 5=#ca30c7 palette = 6=#00c5c7 palette = 7=#c7c7c7 palette = 8=#686868 palette = 9=#ff6e67 palette = 10=#5ffa68 palette = 11=#fffc67 palette = 12=#6871ff palette = 13=#ff77ff palette = 14=#60fdff palette = 15=#ffffff background = #2c2b2b foreground = #d5a200 cursor-color = #c7c7c7 cursor-text = #ffffff selection-background = #6b5b02 selection-foreground = #67e000 ================================================ FILE: ghostty/Resources/themes/Apple System Colors ================================================ palette = 0=#1a1a1a palette = 1=#cc372e palette = 2=#26a439 palette = 3=#cdac08 palette = 4=#0869cb palette = 5=#9647bf palette = 6=#479ec2 palette = 7=#98989d palette = 8=#464646 palette = 9=#ff453a palette = 10=#32d74b palette = 11=#ffd60a palette = 12=#0a84ff palette = 13=#bf5af2 palette = 14=#76d6ff palette = 15=#ffffff background = #1e1e1e foreground = #ffffff cursor-color = #98989d cursor-text = #ffffff selection-background = #3f638b selection-foreground = #ffffff ================================================ FILE: ghostty/Resources/themes/Apple System Colors Light ================================================ palette = 0=#1a1a1a palette = 1=#cc372e palette = 2=#26a439 palette = 3=#cdac08 palette = 4=#0869cb palette = 5=#9647bf palette = 6=#479ec2 palette = 7=#98989d palette = 8=#464646 palette = 9=#ff453a palette = 10=#32d74b palette = 11=#edbb00 palette = 12=#0a84ff palette = 13=#bf5af2 palette = 14=#3accf7 palette = 15=#ffffff background = #feffff foreground = #000000 cursor-color = #98989d cursor-text = #ffffff selection-background = #abd8ff selection-foreground = #000000 ================================================ FILE: ghostty/Resources/themes/Atom ================================================ palette = 0=#000000 palette = 1=#fd5ff1 palette = 2=#87c38a palette = 3=#ffd7b1 palette = 4=#85befd palette = 5=#b9b6fc palette = 6=#85befd palette = 7=#e0e0e0 palette = 8=#4c4c4c palette = 9=#fd5ff1 palette = 10=#94fa36 palette = 11=#f5ffa8 palette = 12=#96cbfe palette = 13=#b9b6fc palette = 14=#85befd palette = 15=#e0e0e0 background = #161719 foreground = #c5c8c6 cursor-color = #d0d0d0 cursor-text = #151515 selection-background = #444444 selection-foreground = #c5c8c6 ================================================ FILE: ghostty/Resources/themes/Atom One Dark ================================================ palette = 0=#21252b palette = 1=#e06c75 palette = 2=#98c379 palette = 3=#e5c07b palette = 4=#61afef palette = 5=#c678dd palette = 6=#56b6c2 palette = 7=#abb2bf palette = 8=#767676 palette = 9=#e06c75 palette = 10=#98c379 palette = 11=#e5c07b palette = 12=#61afef palette = 13=#c678dd palette = 14=#56b6c2 palette = 15=#abb2bf background = #21252b foreground = #abb2bf cursor-color = #abb2bf cursor-text = #21252b selection-background = #323844 selection-foreground = #abb2bf ================================================ FILE: ghostty/Resources/themes/Atom One Light ================================================ palette = 0=#000000 palette = 1=#de3e35 palette = 2=#3f953a palette = 3=#d2b67c palette = 4=#2f5af3 palette = 5=#950095 palette = 6=#3f953a palette = 7=#bbbbbb palette = 8=#000000 palette = 9=#de3e35 palette = 10=#3f953a palette = 11=#d2b67c palette = 12=#2f5af3 palette = 13=#a00095 palette = 14=#3f953a palette = 15=#ffffff background = #f9f9f9 foreground = #2a2c33 cursor-color = #bbbbbb cursor-text = #ffffff selection-background = #ededed selection-foreground = #2a2c33 ================================================ FILE: ghostty/Resources/themes/Dracula ================================================ palette = 0=#21222c palette = 1=#ff5555 palette = 2=#50fa7b palette = 3=#f1fa8c palette = 4=#bd93f9 palette = 5=#ff79c6 palette = 6=#8be9fd palette = 7=#f8f8f2 palette = 8=#6272a4 palette = 9=#ff6e6e palette = 10=#69ff94 palette = 11=#ffffa5 palette = 12=#d6acff palette = 13=#ff92df palette = 14=#a4ffff palette = 15=#ffffff background = #282a36 foreground = #f8f8f2 cursor-color = #f8f8f2 cursor-text = #282a36 selection-background = #44475a selection-foreground = #ffffff ================================================ FILE: ghostty/Resources/themes/Dracula+ ================================================ palette = 0=#21222c palette = 1=#ff5555 palette = 2=#50fa7b palette = 3=#ffcb6b palette = 4=#82aaff palette = 5=#c792ea palette = 6=#8be9fd palette = 7=#f8f8f2 palette = 8=#545454 palette = 9=#ff6e6e palette = 10=#69ff94 palette = 11=#ffcb6b palette = 12=#d6acff palette = 13=#ff92df palette = 14=#a4ffff palette = 15=#f8f8f2 background = #212121 foreground = #f8f8f2 cursor-color = #eceff4 cursor-text = #282828 selection-background = #f8f8f2 selection-foreground = #545454 ================================================ FILE: ghostty/Resources/themes/Farmhouse Dark ================================================ palette = 0=#1d2027 palette = 1=#ba0004 palette = 2=#549d00 palette = 3=#c87300 palette = 4=#0049e6 palette = 5=#9f1b61 palette = 6=#1fb65c palette = 7=#e8e4e1 palette = 8=#464d54 palette = 9=#eb0009 palette = 10=#7ac100 palette = 11=#ea9a00 palette = 12=#006efe palette = 13=#bf3b7f palette = 14=#19e062 palette = 15=#f4eef0 background = #1d2027 foreground = #e8e4e1 cursor-color = #006efe cursor-text = #e8e4e1 selection-background = #4d5658 selection-foreground = #b3b1aa ================================================ FILE: ghostty/Resources/themes/Farmhouse Light ================================================ palette = 0=#1d2027 palette = 1=#8d0003 palette = 2=#3a7d00 palette = 3=#a95600 palette = 4=#092ccd palette = 5=#820046 palette = 6=#229256 palette = 7=#a8a4a1 palette = 8=#394047 palette = 9=#eb0009 palette = 10=#7ac100 palette = 11=#ea9a00 palette = 12=#006efe palette = 13=#bf3b7f palette = 14=#00c649 palette = 15=#f4eef0 background = #e8e4e1 foreground = #1d2027 cursor-color = #006efe cursor-text = #1d2027 selection-background = #b3b1aa selection-foreground = #4d5658 ================================================ FILE: ghostty/Resources/themes/Flexoki Dark ================================================ palette = 0=#100f0f palette = 1=#d14d41 palette = 2=#879a39 palette = 3=#d0a215 palette = 4=#4385be palette = 5=#ce5d97 palette = 6=#3aa99f palette = 7=#878580 palette = 8=#575653 palette = 9=#af3029 palette = 10=#66800b palette = 11=#ad8301 palette = 12=#205ea6 palette = 13=#a02f6f palette = 14=#24837b palette = 15=#cecdc3 background = #100f0f foreground = #cecdc3 cursor-color = #cecdc3 cursor-text = #100f0f selection-background = #403e3c selection-foreground = #cecdc3 ================================================ FILE: ghostty/Resources/themes/Flexoki Light ================================================ palette = 0=#100f0f palette = 1=#af3029 palette = 2=#66800b palette = 3=#ad8301 palette = 4=#205ea6 palette = 5=#a02f6f palette = 6=#24837b palette = 7=#6f6e69 palette = 8=#b7b5ac palette = 9=#d14d41 palette = 10=#879a39 palette = 11=#d0a215 palette = 12=#4385be palette = 13=#ce5d97 palette = 14=#3aa99f palette = 15=#cecdc3 background = #fffcf0 foreground = #100f0f cursor-color = #100f0f cursor-text = #fffcf0 selection-background = #cecdc3 selection-foreground = #100f0f ================================================ FILE: ghostty/Resources/themes/GitHub ================================================ palette = 0=#3e3e3e palette = 1=#970b16 palette = 2=#07962a palette = 3=#c5bb94 palette = 4=#003e8a palette = 5=#e94691 palette = 6=#7cc4df palette = 7=#b2b2b2 palette = 8=#666666 palette = 9=#de0000 palette = 10=#7ac895 palette = 11=#d7b600 palette = 12=#2e6cba palette = 13=#f29592 palette = 14=#00c7cb palette = 15=#ffffff background = #f4f4f4 foreground = #3e3e3e cursor-color = #3f3f3f cursor-text = #f4f4f4 selection-background = #a9c1e2 selection-foreground = #535353 ================================================ FILE: ghostty/Resources/themes/GitHub Dark ================================================ palette = 0=#000000 palette = 1=#f78166 palette = 2=#56d364 palette = 3=#e3b341 palette = 4=#6ca4f8 palette = 5=#db61a2 palette = 6=#2b7489 palette = 7=#ffffff palette = 8=#4d4d4d palette = 9=#f78166 palette = 10=#56d364 palette = 11=#e3b341 palette = 12=#6ca4f8 palette = 13=#db61a2 palette = 14=#2b7489 palette = 15=#ffffff background = #101216 foreground = #8b949e cursor-color = #c9d1d9 cursor-text = #101216 selection-background = #3b5070 selection-foreground = #ffffff ================================================ FILE: ghostty/Resources/themes/GitHub Dark Colorblind ================================================ palette = 0=#484f58 palette = 1=#ec8e2c palette = 2=#58a6ff palette = 3=#d29922 palette = 4=#58a6ff palette = 5=#bc8cff palette = 6=#39c5cf palette = 7=#b1bac4 palette = 8=#6e7681 palette = 9=#fdac54 palette = 10=#79c0ff palette = 11=#e3b341 palette = 12=#79c0ff palette = 13=#d2a8ff palette = 14=#56d4dd palette = 15=#ffffff background = #0d1117 foreground = #c9d1d9 cursor-color = #58a6ff cursor-text = #58a6ff selection-background = #c9d1d9 selection-foreground = #0d1117 ================================================ FILE: ghostty/Resources/themes/GitHub Dark Default ================================================ palette = 0=#484f58 palette = 1=#ff7b72 palette = 2=#3fb950 palette = 3=#d29922 palette = 4=#58a6ff palette = 5=#bc8cff palette = 6=#39c5cf palette = 7=#b1bac4 palette = 8=#6e7681 palette = 9=#ffa198 palette = 10=#56d364 palette = 11=#e3b341 palette = 12=#79c0ff palette = 13=#d2a8ff palette = 14=#56d4dd palette = 15=#ffffff background = #0d1117 foreground = #e6edf3 cursor-color = #2f81f7 cursor-text = #2f81f7 selection-background = #e6edf3 selection-foreground = #0d1117 ================================================ FILE: ghostty/Resources/themes/GitHub Dark Dimmed ================================================ palette = 0=#545d68 palette = 1=#f47067 palette = 2=#57ab5a palette = 3=#c69026 palette = 4=#539bf5 palette = 5=#b083f0 palette = 6=#39c5cf palette = 7=#909dab palette = 8=#636e7b palette = 9=#ff938a palette = 10=#6bc46d palette = 11=#daaa3f palette = 12=#6cb6ff palette = 13=#dcbdfb palette = 14=#56d4dd palette = 15=#cdd9e5 background = #22272e foreground = #adbac7 cursor-color = #539bf5 cursor-text = #539bf5 selection-background = #adbac7 selection-foreground = #22272e ================================================ FILE: ghostty/Resources/themes/GitHub Dark High Contrast ================================================ palette = 0=#7a828e palette = 1=#ff9492 palette = 2=#26cd4d palette = 3=#f0b72f palette = 4=#71b7ff palette = 5=#cb9eff palette = 6=#39c5cf palette = 7=#d9dee3 palette = 8=#9ea7b3 palette = 9=#ffb1af palette = 10=#4ae168 palette = 11=#f7c843 palette = 12=#91cbff palette = 13=#dbb7ff palette = 14=#56d4dd palette = 15=#ffffff background = #0a0c10 foreground = #f0f3f6 cursor-color = #71b7ff cursor-text = #71b7ff selection-background = #f0f3f6 selection-foreground = #0a0c10 ================================================ FILE: ghostty/Resources/themes/GitHub Light Colorblind ================================================ palette = 0=#24292f palette = 1=#b35900 palette = 2=#0550ae palette = 3=#4d2d00 palette = 4=#0969da palette = 5=#8250df palette = 6=#1b7c83 palette = 7=#6e7781 palette = 8=#57606a palette = 9=#8a4600 palette = 10=#0969da palette = 11=#633c01 palette = 12=#218bff palette = 13=#a475f9 palette = 14=#3192aa palette = 15=#8c959f background = #ffffff foreground = #24292f cursor-color = #0969da cursor-text = #0969da selection-background = #24292f selection-foreground = #ffffff ================================================ FILE: ghostty/Resources/themes/GitHub Light Default ================================================ palette = 0=#24292f palette = 1=#cf222e palette = 2=#116329 palette = 3=#4d2d00 palette = 4=#0969da palette = 5=#8250df palette = 6=#1b7c83 palette = 7=#6e7781 palette = 8=#57606a palette = 9=#a40e26 palette = 10=#1a7f37 palette = 11=#633c01 palette = 12=#218bff palette = 13=#a475f9 palette = 14=#3192aa palette = 15=#8c959f background = #ffffff foreground = #1f2328 cursor-color = #0969da cursor-text = #0969da selection-background = #1f2328 selection-foreground = #ffffff ================================================ FILE: ghostty/Resources/themes/GitHub Light High Contrast ================================================ palette = 0=#0e1116 palette = 1=#a0111f palette = 2=#024c1a palette = 3=#3f2200 palette = 4=#0349b4 palette = 5=#622cbc palette = 6=#1b7c83 palette = 7=#66707b palette = 8=#4b535d palette = 9=#86061d palette = 10=#055d20 palette = 11=#4e2c00 palette = 12=#1168e3 palette = 13=#844ae7 palette = 14=#3192aa palette = 15=#88929d background = #ffffff foreground = #0e1116 cursor-color = #0349b4 cursor-text = #0349b4 selection-background = #0e1116 selection-foreground = #ffffff ================================================ FILE: ghostty/Resources/themes/GitLab Dark ================================================ palette = 0=#000000 palette = 1=#f57f6c palette = 2=#52b87a palette = 3=#d99530 palette = 4=#7fb6ed palette = 5=#f88aaf palette = 6=#32c5d2 palette = 7=#ffffff palette = 8=#666666 palette = 9=#fcb5aa palette = 10=#91d4a8 palette = 11=#e9be74 palette = 12=#498dd1 palette = 13=#fcacc5 palette = 14=#5edee3 palette = 15=#ffffff background = #28262b foreground = #ffffff cursor-color = #ffffff cursor-text = #ffffff selection-background = #ad95e9 selection-foreground = #28262b ================================================ FILE: ghostty/Resources/themes/GitLab Dark Grey ================================================ palette = 0=#000000 palette = 1=#f57f6c palette = 2=#52b87a palette = 3=#d99530 palette = 4=#7fb6ed palette = 5=#f88aaf palette = 6=#32c5d2 palette = 7=#ffffff palette = 8=#666666 palette = 9=#fcb5aa palette = 10=#91d4a8 palette = 11=#e9be74 palette = 12=#498dd1 palette = 13=#fcacc5 palette = 14=#5edee3 palette = 15=#ffffff background = #222222 foreground = #ffffff cursor-color = #ffffff cursor-text = #ffffff selection-background = #ad95e9 selection-foreground = #222222 ================================================ FILE: ghostty/Resources/themes/GitLab Light ================================================ palette = 0=#303030 palette = 1=#a31700 palette = 2=#0a7f3d palette = 3=#af551d palette = 4=#006cd8 palette = 5=#583cac palette = 6=#00798a palette = 7=#303030 palette = 8=#303030 palette = 9=#a31700 palette = 10=#0a7f3d palette = 11=#af551d palette = 12=#006cd8 palette = 13=#583cac palette = 14=#00798a palette = 15=#303030 background = #fafaff foreground = #303030 cursor-color = #303030 cursor-text = #303030 selection-background = #ad95e9 selection-foreground = #fafaff ================================================ FILE: ghostty/Resources/themes/Iceberg Dark ================================================ palette = 0=#1e2132 palette = 1=#e27878 palette = 2=#b4be82 palette = 3=#e2a478 palette = 4=#84a0c6 palette = 5=#a093c7 palette = 6=#89b8c2 palette = 7=#c6c8d1 palette = 8=#6b7089 palette = 9=#e98989 palette = 10=#c0ca8e palette = 11=#e9b189 palette = 12=#91acd1 palette = 13=#ada0d3 palette = 14=#95c4ce palette = 15=#d2d4de background = #161821 foreground = #c6c8d1 cursor-color = #c6c8d1 cursor-text = #161821 selection-background = #c6c8d1 selection-foreground = #161821 ================================================ FILE: ghostty/Resources/themes/Iceberg Light ================================================ palette = 0=#dcdfe7 palette = 1=#cc517a palette = 2=#668e3d palette = 3=#c57339 palette = 4=#2d539e palette = 5=#7759b4 palette = 6=#3f83a6 palette = 7=#33374c palette = 8=#8389a3 palette = 9=#cc3768 palette = 10=#598030 palette = 11=#b6662d palette = 12=#22478e palette = 13=#6845ad palette = 14=#327698 palette = 15=#262a3f background = #e8e9ec foreground = #33374c cursor-color = #33374c cursor-text = #e8e9ec selection-background = #33374c selection-foreground = #e8e9ec ================================================ FILE: ghostty/Resources/themes/Material ================================================ palette = 0=#212121 palette = 1=#b7141f palette = 2=#457b24 palette = 3=#f6981e palette = 4=#134eb2 palette = 5=#560088 palette = 6=#0e717c palette = 7=#afafaf palette = 8=#424242 palette = 9=#e83b3f palette = 10=#7aba3a palette = 11=#bfaa00 palette = 12=#54a4f3 palette = 13=#aa4dbc palette = 14=#26bbd1 palette = 15=#d9d9d9 background = #eaeaea foreground = #232322 cursor-color = #16afca cursor-text = #2e2e2d selection-background = #c2c2c2 selection-foreground = #4e4e4e ================================================ FILE: ghostty/Resources/themes/Material Dark ================================================ palette = 0=#212121 palette = 1=#b7141f palette = 2=#457b24 palette = 3=#f6981e palette = 4=#134eb2 palette = 5=#6f1aa1 palette = 6=#0e717c palette = 7=#efefef palette = 8=#4f4f4f palette = 9=#e83b3f palette = 10=#7aba3a palette = 11=#ffea2e palette = 12=#54a4f3 palette = 13=#aa4dbc palette = 14=#26bbd1 palette = 15=#d9d9d9 background = #232322 foreground = #e5e5e5 cursor-color = #16afca cursor-text = #dfdfdf selection-background = #dfdfdf selection-foreground = #3d3d3d ================================================ FILE: ghostty/Resources/themes/Material Darker ================================================ palette = 0=#000000 palette = 1=#ff5370 palette = 2=#c3e88d palette = 3=#ffcb6b palette = 4=#82aaff palette = 5=#c792ea palette = 6=#89ddff palette = 7=#ffffff palette = 8=#545454 palette = 9=#ff5370 palette = 10=#c3e88d palette = 11=#ffcb6b palette = 12=#82aaff palette = 13=#c792ea palette = 14=#89ddff palette = 15=#ffffff background = #212121 foreground = #eeffff cursor-color = #ffffff cursor-text = #ffffff selection-background = #eeffff selection-foreground = #545454 ================================================ FILE: ghostty/Resources/themes/Material Design Colors ================================================ palette = 0=#435b67 palette = 1=#fc3841 palette = 2=#5cf19e palette = 3=#fed032 palette = 4=#37b6ff palette = 5=#fc226e palette = 6=#59ffd1 palette = 7=#ffffff palette = 8=#a1b0b8 palette = 9=#fc746d palette = 10=#adf7be palette = 11=#fee16c palette = 12=#70cfff palette = 13=#fc669b palette = 14=#9affe6 palette = 15=#ffffff background = #1d262a foreground = #e7ebed cursor-color = #eaeaea cursor-text = #000000 selection-background = #4e6a78 selection-foreground = #e7ebed ================================================ FILE: ghostty/Resources/themes/Material Ocean ================================================ palette = 0=#546e7a palette = 1=#ff5370 palette = 2=#c3e88d palette = 3=#ffcb6b palette = 4=#82aaff palette = 5=#c792ea palette = 6=#89ddff palette = 7=#ffffff palette = 8=#546e7a palette = 9=#ff5370 palette = 10=#c3e88d palette = 11=#ffcb6b palette = 12=#82aaff palette = 13=#c792ea palette = 14=#89ddff palette = 15=#ffffff background = #0f111a foreground = #8f93a2 cursor-color = #ffcc00 cursor-text = #0f111a selection-background = #1f2233 selection-foreground = #8f93a2 ================================================ FILE: ghostty/Resources/themes/Melange Dark ================================================ palette = 0=#34302c palette = 1=#bd8183 palette = 2=#78997a palette = 3=#e49b5d palette = 4=#7f91b2 palette = 5=#b380b0 palette = 6=#7b9695 palette = 7=#c1a78e palette = 8=#867462 palette = 9=#d47766 palette = 10=#85b695 palette = 11=#ebc06d palette = 12=#a3a9ce palette = 13=#cf9bc2 palette = 14=#89b3b6 palette = 15=#ece1d7 background = #292522 foreground = #ece1d7 cursor-color = #ece1d7 cursor-text = #292522 selection-background = #ece1d7 selection-foreground = #403a36 ================================================ FILE: ghostty/Resources/themes/Melange Light ================================================ palette = 0=#e9e1db palette = 1=#c77b8b palette = 2=#6e9b72 palette = 3=#bc5c00 palette = 4=#7892bd palette = 5=#be79bb palette = 6=#739797 palette = 7=#7d6658 palette = 8=#a98a78 palette = 9=#bf0021 palette = 10=#3a684a palette = 11=#a06d00 palette = 12=#465aa4 palette = 13=#904180 palette = 14=#3d6568 palette = 15=#54433a background = #f1f1f1 foreground = #54433a cursor-color = #54433a cursor-text = #f1f1f1 selection-background = #54433a selection-foreground = #d9d3ce ================================================ FILE: ghostty/Resources/themes/Monokai Pro ================================================ palette = 0=#2d2a2e palette = 1=#ff6188 palette = 2=#a9dc76 palette = 3=#ffd866 palette = 4=#fc9867 palette = 5=#ab9df2 palette = 6=#78dce8 palette = 7=#fcfcfa palette = 8=#727072 palette = 9=#ff6188 palette = 10=#a9dc76 palette = 11=#ffd866 palette = 12=#fc9867 palette = 13=#ab9df2 palette = 14=#78dce8 palette = 15=#fcfcfa background = #2d2a2e foreground = #fcfcfa cursor-color = #c1c0c0 cursor-text = #c1c0c0 selection-background = #5b595c selection-foreground = #fcfcfa ================================================ FILE: ghostty/Resources/themes/Monokai Pro Light ================================================ palette = 0=#faf4f2 palette = 1=#e14775 palette = 2=#269d69 palette = 3=#cc7a0a palette = 4=#e16032 palette = 5=#7058be palette = 6=#1c8ca8 palette = 7=#29242a palette = 8=#a59fa0 palette = 9=#e14775 palette = 10=#269d69 palette = 11=#cc7a0a palette = 12=#e16032 palette = 13=#7058be palette = 14=#1c8ca8 palette = 15=#29242a background = #faf4f2 foreground = #29242a cursor-color = #706b6e cursor-text = #706b6e selection-background = #bfb9ba selection-foreground = #29242a ================================================ FILE: ghostty/Resources/themes/Monokai Pro Light Sun ================================================ palette = 0=#f8efe7 palette = 1=#ce4770 palette = 2=#218871 palette = 3=#b16803 palette = 4=#d4572b palette = 5=#6851a2 palette = 6=#2473b6 palette = 7=#2c232e palette = 8=#a59c9c palette = 9=#ce4770 palette = 10=#218871 palette = 11=#b16803 palette = 12=#d4572b palette = 13=#6851a2 palette = 14=#2473b6 palette = 15=#2c232e background = #f8efe7 foreground = #2c232e cursor-color = #72696d cursor-text = #72696d selection-background = #beb5b3 selection-foreground = #2c232e ================================================ FILE: ghostty/Resources/themes/Monokai Pro Machine ================================================ palette = 0=#273136 palette = 1=#ff6d7e palette = 2=#a2e57b palette = 3=#ffed72 palette = 4=#ffb270 palette = 5=#baa0f8 palette = 6=#7cd5f1 palette = 7=#f2fffc palette = 8=#6b7678 palette = 9=#ff6d7e palette = 10=#a2e57b palette = 11=#ffed72 palette = 12=#ffb270 palette = 13=#baa0f8 palette = 14=#7cd5f1 palette = 15=#f2fffc background = #273136 foreground = #f2fffc cursor-color = #b8c4c3 cursor-text = #b8c4c3 selection-background = #545f62 selection-foreground = #f2fffc ================================================ FILE: ghostty/Resources/themes/Monokai Pro Octagon ================================================ palette = 0=#282a3a palette = 1=#ff657a palette = 2=#bad761 palette = 3=#ffd76d palette = 4=#ff9b5e palette = 5=#c39ac9 palette = 6=#9cd1bb palette = 7=#eaf2f1 palette = 8=#696d77 palette = 9=#ff657a palette = 10=#bad761 palette = 11=#ffd76d palette = 12=#ff9b5e palette = 13=#c39ac9 palette = 14=#9cd1bb palette = 15=#eaf2f1 background = #282a3a foreground = #eaf2f1 cursor-color = #b2b9bd cursor-text = #b2b9bd selection-background = #535763 selection-foreground = #eaf2f1 ================================================ FILE: ghostty/Resources/themes/Monokai Pro Ristretto ================================================ palette = 0=#2c2525 palette = 1=#fd6883 palette = 2=#adda78 palette = 3=#f9cc6c palette = 4=#f38d70 palette = 5=#a8a9eb palette = 6=#85dacc palette = 7=#fff1f3 palette = 8=#72696a palette = 9=#fd6883 palette = 10=#adda78 palette = 11=#f9cc6c palette = 12=#f38d70 palette = 13=#a8a9eb palette = 14=#85dacc palette = 15=#fff1f3 background = #2c2525 foreground = #fff1f3 cursor-color = #c3b7b8 cursor-text = #c3b7b8 selection-background = #5b5353 selection-foreground = #fff1f3 ================================================ FILE: ghostty/Resources/themes/Monokai Pro Spectrum ================================================ palette = 0=#222222 palette = 1=#fc618d palette = 2=#7bd88f palette = 3=#fce566 palette = 4=#fd9353 palette = 5=#948ae3 palette = 6=#5ad4e6 palette = 7=#f7f1ff palette = 8=#69676c palette = 9=#fc618d palette = 10=#7bd88f palette = 11=#fce566 palette = 12=#fd9353 palette = 13=#948ae3 palette = 14=#5ad4e6 palette = 15=#f7f1ff background = #222222 foreground = #f7f1ff cursor-color = #bab6c0 cursor-text = #bab6c0 selection-background = #525053 selection-foreground = #f7f1ff ================================================ FILE: ghostty/Resources/themes/Monokai Remastered ================================================ palette = 0=#1a1a1a palette = 1=#f4005f palette = 2=#98e024 palette = 3=#fd971f palette = 4=#9d65ff palette = 5=#f4005f palette = 6=#58d1eb palette = 7=#c4c5b5 palette = 8=#625e4c palette = 9=#f4005f palette = 10=#98e024 palette = 11=#e0d561 palette = 12=#9d65ff palette = 13=#f4005f palette = 14=#58d1eb palette = 15=#f6f6ef background = #0c0c0c foreground = #d9d9d9 cursor-color = #fc971f cursor-text = #000000 selection-background = #343434 selection-foreground = #ffffff ================================================ FILE: ghostty/Resources/themes/Neobones Dark ================================================ palette = 0=#0f191f palette = 1=#de6e7c palette = 2=#90ff6b palette = 3=#b77e64 palette = 4=#8190d4 palette = 5=#b279a7 palette = 6=#66a5ad palette = 7=#c6d5cf palette = 8=#334652 palette = 9=#e8838f palette = 10=#a0ff85 palette = 11=#d68c67 palette = 12=#92a0e2 palette = 13=#cf86c1 palette = 14=#65b8c1 palette = 15=#98a39e background = #0f191f foreground = #c6d5cf cursor-color = #ceddd7 cursor-text = #0f191f selection-background = #3a3e3d selection-foreground = #c6d5cf ================================================ FILE: ghostty/Resources/themes/Neobones Light ================================================ palette = 0=#e5ede6 palette = 1=#a8334c palette = 2=#567a30 palette = 3=#944927 palette = 4=#286486 palette = 5=#88507d palette = 6=#3b8992 palette = 7=#202e18 palette = 8=#99ac9c palette = 9=#94253e palette = 10=#3f5a22 palette = 11=#803d1c palette = 12=#1d5573 palette = 13=#7b3b70 palette = 14=#2b747c palette = 15=#415934 background = #e5ede6 foreground = #202e18 cursor-color = #202e18 cursor-text = #e5ede6 selection-background = #ade48c selection-foreground = #202e18 ================================================ FILE: ghostty/Resources/themes/Nvim Dark ================================================ palette = 0=#07080d palette = 1=#ffc0b9 palette = 2=#b3f6c0 palette = 3=#fce094 palette = 4=#a6dbff palette = 5=#ffcaff palette = 6=#8cf8f7 palette = 7=#eef1f8 palette = 8=#4f5258 palette = 9=#ffc0b9 palette = 10=#b3f6c0 palette = 11=#fce094 palette = 12=#a6dbff palette = 13=#ffcaff palette = 14=#8cf8f7 palette = 15=#eef1f8 background = #14161b foreground = #e0e2ea cursor-color = #9b9ea4 cursor-text = #e0e2ea selection-background = #4f5258 selection-foreground = #e0e2ea ================================================ FILE: ghostty/Resources/themes/Nvim Light ================================================ palette = 0=#07080d palette = 1=#590008 palette = 2=#005523 palette = 3=#6b5300 palette = 4=#004c73 palette = 5=#470045 palette = 6=#007373 palette = 7=#a2a5ac palette = 8=#4f5258 palette = 9=#590008 palette = 10=#005523 palette = 11=#6b5300 palette = 12=#004c73 palette = 13=#470045 palette = 14=#007373 palette = 15=#eef1f8 background = #e0e2ea foreground = #14161b cursor-color = #9b9ea4 cursor-text = #14161b selection-background = #9b9ea4 selection-foreground = #14161b ================================================ FILE: ghostty/Resources/themes/One Double Dark ================================================ palette = 0=#3d4452 palette = 1=#f16372 palette = 2=#8cc570 palette = 3=#ecbe70 palette = 4=#3fb1f5 palette = 5=#d373e3 palette = 6=#17b9c4 palette = 7=#dbdfe5 palette = 8=#525d6f palette = 9=#ff777b palette = 10=#82d882 palette = 11=#f5c065 palette = 12=#6dcaff palette = 13=#ff7bf4 palette = 14=#00e5fb palette = 15=#f7f9fc background = #282c34 foreground = #dbdfe5 cursor-color = #f5e0dc cursor-text = #cdd6f4 selection-background = #585b70 selection-foreground = #cdd6f4 ================================================ FILE: ghostty/Resources/themes/One Double Light ================================================ palette = 0=#454b58 palette = 1=#f74840 palette = 2=#25a343 palette = 3=#cc8100 palette = 4=#0087c1 palette = 5=#b50da9 palette = 6=#009ab7 palette = 7=#c9b1b2 palette = 8=#0e131f palette = 9=#ff3711 palette = 10=#00b90e palette = 11=#ec9900 palette = 12=#1065de palette = 13=#e500d8 palette = 14=#00b4dd palette = 15=#ffffff background = #fafafa foreground = #383a43 cursor-color = #1a1919 cursor-text = #dbdfe5 selection-background = #454e5e selection-foreground = #1a1919 ================================================ FILE: ghostty/Resources/themes/One Half Dark ================================================ palette = 0=#282c34 palette = 1=#e06c75 palette = 2=#98c379 palette = 3=#e5c07b palette = 4=#61afef palette = 5=#c678dd palette = 6=#56b6c2 palette = 7=#dcdfe4 palette = 8=#5d677a palette = 9=#e06c75 palette = 10=#98c379 palette = 11=#e5c07b palette = 12=#61afef palette = 13=#c678dd palette = 14=#56b6c2 palette = 15=#dcdfe4 background = #282c34 foreground = #dcdfe4 cursor-color = #a3b3cc cursor-text = #dcdfe4 selection-background = #474e5d selection-foreground = #dcdfe4 ================================================ FILE: ghostty/Resources/themes/One Half Light ================================================ palette = 0=#383a42 palette = 1=#e45649 palette = 2=#50a14f palette = 3=#c18401 palette = 4=#0184bc palette = 5=#a626a4 palette = 6=#0997b3 palette = 7=#bababa palette = 8=#4f525e palette = 9=#e06c75 palette = 10=#98c379 palette = 11=#d8b36e palette = 12=#61afef palette = 13=#c678dd palette = 14=#56b6c2 palette = 15=#ffffff background = #fafafa foreground = #383a42 cursor-color = #a5b5e5 cursor-text = #383a42 selection-background = #bfceff selection-foreground = #383a42 ================================================ FILE: ghostty/Resources/themes/Pencil Dark ================================================ palette = 0=#212121 palette = 1=#c30771 palette = 2=#10a778 palette = 3=#a89c14 palette = 4=#008ec4 palette = 5=#5f4986 palette = 6=#20a5ba palette = 7=#d9d9d9 palette = 8=#4f4f4f palette = 9=#fb007a palette = 10=#5fd7af palette = 11=#f3e430 palette = 12=#20bbfc palette = 13=#6855de palette = 14=#4fb8cc palette = 15=#f1f1f1 background = #212121 foreground = #f1f1f1 cursor-color = #20bbfc cursor-text = #f1f1f1 selection-background = #b6d6fd selection-foreground = #989898 ================================================ FILE: ghostty/Resources/themes/Pencil Light ================================================ palette = 0=#212121 palette = 1=#c30771 palette = 2=#10a778 palette = 3=#a89c14 palette = 4=#008ec4 palette = 5=#523c79 palette = 6=#20a5ba palette = 7=#b3b3b3 palette = 8=#424242 palette = 9=#fb007a palette = 10=#52caa2 palette = 11=#c0b100 palette = 12=#20bbfc palette = 13=#6855de palette = 14=#4fb8cc palette = 15=#f1f1f1 background = #f1f1f1 foreground = #424242 cursor-color = #20bbfc cursor-text = #424242 selection-background = #b6d6fd selection-foreground = #424242 ================================================ FILE: ghostty/Resources/themes/Raycast Dark ================================================ palette = 0=#000000 palette = 1=#ff5360 palette = 2=#59d499 palette = 3=#ffc531 palette = 4=#56c2ff palette = 5=#cf2f98 palette = 6=#52eee5 palette = 7=#ffffff palette = 8=#4c4c4c palette = 9=#ff6363 palette = 10=#59d499 palette = 11=#ffc531 palette = 12=#56c2ff palette = 13=#cf2f98 palette = 14=#52eee5 palette = 15=#ffffff background = #1a1a1a foreground = #ffffff cursor-color = #cccccc cursor-text = #ffffff selection-background = #333333 selection-foreground = #595959 ================================================ FILE: ghostty/Resources/themes/Raycast Light ================================================ palette = 0=#000000 palette = 1=#b12424 palette = 2=#006b4f palette = 3=#f8a300 palette = 4=#138af2 palette = 5=#9a1b6e palette = 6=#3eb8bf palette = 7=#bfbfbf palette = 8=#000000 palette = 9=#b12424 palette = 10=#006b4f palette = 11=#f8a300 palette = 12=#138af2 palette = 13=#9a1b6e palette = 14=#3eb8bf palette = 15=#ffffff background = #ffffff foreground = #000000 cursor-color = #000000 cursor-text = #000000 selection-background = #e5e5e5 selection-foreground = #000000 ================================================ FILE: ghostty/Resources/themes/Selenized Dark ================================================ palette = 0=#184956 palette = 1=#fa5750 palette = 2=#75b938 palette = 3=#dbb32d palette = 4=#4695f7 palette = 5=#f275be palette = 6=#41c7b9 palette = 7=#72898f palette = 8=#396775 palette = 9=#ff665c palette = 10=#84c747 palette = 11=#ebc13d palette = 12=#58a3ff palette = 13=#ff84cd palette = 14=#53d6c7 palette = 15=#cad8d9 background = #103c48 foreground = #adbcbc cursor-color = #adbcbc cursor-text = #adbcbc selection-background = #184956 selection-foreground = #53d6c7 ================================================ FILE: ghostty/Resources/themes/Selenized Light ================================================ palette = 0=#ece3cc palette = 1=#d2212d palette = 2=#489100 palette = 3=#ad8900 palette = 4=#0072d4 palette = 5=#ca4898 palette = 6=#009c8f palette = 7=#909995 palette = 8=#bbb39c palette = 9=#cc1729 palette = 10=#428b00 palette = 11=#a78300 palette = 12=#006dce palette = 13=#c44392 palette = 14=#00978a palette = 15=#3a4d53 background = #fbf3db foreground = #53676d cursor-color = #53676d cursor-text = #53676d selection-background = #ece3cc selection-foreground = #00978a ================================================ FILE: ghostty/Resources/themes/Seoulbones Dark ================================================ palette = 0=#4b4b4b palette = 1=#e388a3 palette = 2=#98bd99 palette = 3=#ffdf9b palette = 4=#97bdde palette = 5=#a5a6c5 palette = 6=#6fbdbe palette = 7=#dddddd palette = 8=#797172 palette = 9=#eb99b1 palette = 10=#8fcd92 palette = 11=#ffe5b3 palette = 12=#a2c8e9 palette = 13=#b2b3da palette = 14=#6bcacb palette = 15=#a8a8a8 background = #4b4b4b foreground = #dddddd cursor-color = #e2e2e2 cursor-text = #4b4b4b selection-background = #777777 selection-foreground = #dddddd ================================================ FILE: ghostty/Resources/themes/Seoulbones Light ================================================ palette = 0=#e2e2e2 palette = 1=#dc5284 palette = 2=#628562 palette = 3=#c48562 palette = 4=#0084a3 palette = 5=#896788 palette = 6=#008586 palette = 7=#555555 palette = 8=#a5a0a1 palette = 9=#be3c6d palette = 10=#487249 palette = 11=#a76b48 palette = 12=#006f89 palette = 13=#7f4c7e palette = 14=#006f70 palette = 15=#777777 background = #e2e2e2 foreground = #555555 cursor-color = #555555 cursor-text = #e2e2e2 selection-background = #cccccc selection-foreground = #555555 ================================================ FILE: ghostty/Resources/themes/Tinacious Design Dark ================================================ palette = 0=#1d1d26 palette = 1=#ff3399 palette = 2=#00d364 palette = 3=#ffcc66 palette = 4=#00cbff palette = 5=#cc66ff palette = 6=#00ceca palette = 7=#cbcbf0 palette = 8=#636667 palette = 9=#ff2f92 palette = 10=#00d364 palette = 11=#ffd479 palette = 12=#00cbff palette = 13=#d783ff palette = 14=#00d5d4 palette = 15=#d5d6f3 background = #1d1d26 foreground = #cbcbf0 cursor-color = #cbcbf0 cursor-text = #ffffff selection-background = #ff3399 selection-foreground = #ffffff ================================================ FILE: ghostty/Resources/themes/Tinacious Design Light ================================================ palette = 0=#1d1d26 palette = 1=#ff3399 palette = 2=#00d364 palette = 3=#e5b34d palette = 4=#00cbff palette = 5=#cc66ff palette = 6=#00ceca palette = 7=#b2b2d7 palette = 8=#636667 palette = 9=#ff2f92 palette = 10=#00d364 palette = 11=#d9ae52 palette = 12=#00cbff palette = 13=#d783ff palette = 14=#00c8c7 palette = 15=#d5d6f3 background = #f8f8ff foreground = #1d1d26 cursor-color = #b2b2d7 cursor-text = #ffffff selection-background = #ff3399 selection-foreground = #ffffff ================================================ FILE: ghostty/Resources/themes/TokyoNight ================================================ palette = 0=#15161e palette = 1=#f7768e palette = 2=#9ece6a palette = 3=#e0af68 palette = 4=#7aa2f7 palette = 5=#bb9af7 palette = 6=#7dcfff palette = 7=#a9b1d6 palette = 8=#414868 palette = 9=#f7768e palette = 10=#9ece6a palette = 11=#e0af68 palette = 12=#7aa2f7 palette = 13=#bb9af7 palette = 14=#7dcfff palette = 15=#c0caf5 background = #1a1b26 foreground = #c0caf5 cursor-color = #c0caf5 cursor-text = #15161e selection-background = #33467c selection-foreground = #c0caf5 ================================================ FILE: ghostty/Resources/themes/TokyoNight Day ================================================ palette = 0=#e9e9ed palette = 1=#f52a65 palette = 2=#587539 palette = 3=#8c6c3e palette = 4=#2e7de9 palette = 5=#9854f1 palette = 6=#007197 palette = 7=#6172b0 palette = 8=#a1a6c5 palette = 9=#f52a65 palette = 10=#587539 palette = 11=#8c6c3e palette = 12=#2e7de9 palette = 13=#9854f1 palette = 14=#007197 palette = 15=#3760bf background = #e1e2e7 foreground = #3760bf cursor-color = #3760bf cursor-text = #e1e2e7 selection-background = #99a7df selection-foreground = #3760bf ================================================ FILE: ghostty/Resources/themes/TokyoNight Moon ================================================ palette = 0=#1b1d2b palette = 1=#ff757f palette = 2=#c3e88d palette = 3=#ffc777 palette = 4=#82aaff palette = 5=#c099ff palette = 6=#86e1fc palette = 7=#828bb8 palette = 8=#444a73 palette = 9=#ff757f palette = 10=#c3e88d palette = 11=#ffc777 palette = 12=#82aaff palette = 13=#c099ff palette = 14=#86e1fc palette = 15=#c8d3f5 background = #222436 foreground = #c8d3f5 cursor-color = #c8d3f5 cursor-text = #222436 selection-background = #2d3f76 selection-foreground = #c8d3f5 ================================================ FILE: ghostty/Resources/themes/TokyoNight Night ================================================ palette = 0=#15161e palette = 1=#f7768e palette = 2=#9ece6a palette = 3=#e0af68 palette = 4=#7aa2f7 palette = 5=#bb9af7 palette = 6=#7dcfff palette = 7=#a9b1d6 palette = 8=#414868 palette = 9=#f7768e palette = 10=#9ece6a palette = 11=#e0af68 palette = 12=#7aa2f7 palette = 13=#bb9af7 palette = 14=#7dcfff palette = 15=#c0caf5 background = #1a1b26 foreground = #c0caf5 cursor-color = #c0caf5 cursor-text = #1a1b26 selection-background = #283457 selection-foreground = #c0caf5 ================================================ FILE: ghostty/Resources/themes/TokyoNight Storm ================================================ palette = 0=#1d202f palette = 1=#f7768e palette = 2=#9ece6a palette = 3=#e0af68 palette = 4=#7aa2f7 palette = 5=#bb9af7 palette = 6=#7dcfff palette = 7=#a9b1d6 palette = 8=#4e5575 palette = 9=#f7768e palette = 10=#9ece6a palette = 11=#e0af68 palette = 12=#7aa2f7 palette = 13=#bb9af7 palette = 14=#7dcfff palette = 15=#c0caf5 background = #24283b foreground = #c0caf5 cursor-color = #c0caf5 cursor-text = #1d202f selection-background = #364a82 selection-foreground = #c0caf5 ================================================ FILE: ghostty/Resources/themes/Tomorrow ================================================ palette = 0=#000000 palette = 1=#c82829 palette = 2=#718c00 palette = 3=#eab700 palette = 4=#4271ae palette = 5=#8959a8 palette = 6=#3e999f palette = 7=#bfbfbf palette = 8=#000000 palette = 9=#c82829 palette = 10=#718c00 palette = 11=#eab700 palette = 12=#4271ae palette = 13=#8959a8 palette = 14=#3e999f palette = 15=#ffffff background = #ffffff foreground = #4d4d4c cursor-color = #4d4d4c cursor-text = #ffffff selection-background = #d6d6d6 selection-foreground = #4d4d4c ================================================ FILE: ghostty/Resources/themes/Tomorrow Night ================================================ palette = 0=#000000 palette = 1=#cc6666 palette = 2=#b5bd68 palette = 3=#f0c674 palette = 4=#81a2be palette = 5=#b294bb palette = 6=#8abeb7 palette = 7=#ffffff palette = 8=#4c4c4c palette = 9=#cc6666 palette = 10=#b5bd68 palette = 11=#f0c674 palette = 12=#81a2be palette = 13=#b294bb palette = 14=#8abeb7 palette = 15=#ffffff background = #1d1f21 foreground = #c5c8c6 cursor-color = #c5c8c6 cursor-text = #1d1f21 selection-background = #373b41 selection-foreground = #c5c8c6 ================================================ FILE: ghostty/Resources/themes/Tomorrow Night Blue ================================================ palette = 0=#000000 palette = 1=#ff9da4 palette = 2=#d1f1a9 palette = 3=#ffeead palette = 4=#bbdaff palette = 5=#ebbbff palette = 6=#99ffff palette = 7=#ffffff palette = 8=#4c4c4c palette = 9=#ff9da4 palette = 10=#d1f1a9 palette = 11=#ffeead palette = 12=#bbdaff palette = 13=#ebbbff palette = 14=#99ffff palette = 15=#ffffff background = #002451 foreground = #ffffff cursor-color = #ffffff cursor-text = #003f8e selection-background = #003f8e selection-foreground = #ffffff ================================================ FILE: ghostty/Resources/themes/Tomorrow Night Bright ================================================ palette = 0=#000000 palette = 1=#d54e53 palette = 2=#b9ca4a palette = 3=#e7c547 palette = 4=#7aa6da palette = 5=#c397d8 palette = 6=#70c0b1 palette = 7=#ffffff palette = 8=#404040 palette = 9=#d54e53 palette = 10=#b9ca4a palette = 11=#e7c547 palette = 12=#7aa6da palette = 13=#c397d8 palette = 14=#70c0b1 palette = 15=#ffffff background = #000000 foreground = #eaeaea cursor-color = #eaeaea cursor-text = #000000 selection-background = #424242 selection-foreground = #eaeaea ================================================ FILE: ghostty/Resources/themes/Tomorrow Night Burns ================================================ palette = 0=#252525 palette = 1=#832e31 palette = 2=#a63c40 palette = 3=#d3494e palette = 4=#fc595f palette = 5=#df9395 palette = 6=#ba8586 palette = 7=#f5f5f5 palette = 8=#5d6f71 palette = 9=#832e31 palette = 10=#a63c40 palette = 11=#d2494e palette = 12=#fc595f palette = 13=#df9395 palette = 14=#ba8586 palette = 15=#f5f5f5 background = #151515 foreground = #a1b0b8 cursor-color = #ff443e cursor-text = #708284 selection-background = #b0bec5 selection-foreground = #2a2d32 ================================================ FILE: ghostty/Resources/themes/Tomorrow Night Eighties ================================================ palette = 0=#000000 palette = 1=#f2777a palette = 2=#99cc99 palette = 3=#ffcc66 palette = 4=#6699cc palette = 5=#cc99cc palette = 6=#66cccc palette = 7=#ffffff palette = 8=#595959 palette = 9=#f2777a palette = 10=#99cc99 palette = 11=#ffcc66 palette = 12=#6699cc palette = 13=#cc99cc palette = 14=#66cccc palette = 15=#ffffff background = #2d2d2d foreground = #cccccc cursor-color = #cccccc cursor-text = #2d2d2d selection-background = #515151 selection-foreground = #cccccc ================================================ FILE: ghostty/Resources/themes/Xcode Dark ================================================ palette = 0=#414453 palette = 1=#ff8170 palette = 2=#78c2b3 palette = 3=#d9c97c palette = 4=#4eb0cc palette = 5=#ff7ab2 palette = 6=#b281eb palette = 7=#dfdfe0 palette = 8=#7f8c98 palette = 9=#ff8170 palette = 10=#acf2e4 palette = 11=#ffa14f palette = 12=#6bdfff palette = 13=#ff7ab2 palette = 14=#dabaff palette = 15=#dfdfe0 background = #292a30 foreground = #dfdfe0 cursor-color = #dfdfe0 cursor-text = #292a30 selection-background = #414453 selection-foreground = #dfdfe0 ================================================ FILE: ghostty/Resources/themes/Xcode Dark hc ================================================ palette = 0=#43454b palette = 1=#ff8a7a palette = 2=#83c9bc palette = 3=#d9c668 palette = 4=#4ec4e6 palette = 5=#ff85b8 palette = 6=#cda1ff palette = 7=#ffffff palette = 8=#838991 palette = 9=#ff8a7a palette = 10=#b1faeb palette = 11=#ffa14f palette = 12=#6bdfff palette = 13=#ff85b8 palette = 14=#e5cfff palette = 15=#ffffff background = #1f1f24 foreground = #ffffff cursor-color = #ffffff cursor-text = #1f1f24 selection-background = #43454b selection-foreground = #ffffff ================================================ FILE: ghostty/Resources/themes/Xcode Light ================================================ palette = 0=#b4d8fd palette = 1=#d12f1b palette = 2=#3e8087 palette = 3=#78492a palette = 4=#0f68a0 palette = 5=#ad3da4 palette = 6=#804fb8 palette = 7=#262626 palette = 8=#8a99a6 palette = 9=#d12f1b palette = 10=#23575c palette = 11=#78492a palette = 12=#0b4f79 palette = 13=#ad3da4 palette = 14=#4b21b0 palette = 15=#262626 background = #ffffff foreground = #262626 cursor-color = #262626 cursor-text = #ffffff selection-background = #b4d8fd selection-foreground = #262626 ================================================ FILE: ghostty/Resources/themes/Xcode Light hc ================================================ palette = 0=#b4d8fd palette = 1=#ad1805 palette = 2=#355d61 palette = 3=#78492a palette = 4=#0058a1 palette = 5=#9c2191 palette = 6=#703daa palette = 7=#000000 palette = 8=#8a99a6 palette = 9=#ad1805 palette = 10=#174145 palette = 11=#78492a palette = 12=#003f73 palette = 13=#9c2191 palette = 14=#441ea1 palette = 15=#000000 background = #ffffff foreground = #000000 cursor-color = #000000 cursor-text = #ffffff selection-background = #b4d8fd selection-foreground = #000000 ================================================ FILE: ghostty/Resources/themes/Zenbones Dark ================================================ palette = 0=#1c1917 palette = 1=#de6e7c palette = 2=#819b69 palette = 3=#b77e64 palette = 4=#6099c0 palette = 5=#b279a7 palette = 6=#66a5ad palette = 7=#b4bdc3 palette = 8=#4d4540 palette = 9=#e8838f palette = 10=#8bae68 palette = 11=#d68c67 palette = 12=#61abda palette = 13=#cf86c1 palette = 14=#65b8c1 palette = 15=#888f94 background = #1c1917 foreground = #b4bdc3 cursor-color = #c4cacf cursor-text = #1c1917 selection-background = #3d4042 selection-foreground = #b4bdc3 ================================================ FILE: ghostty/Resources/themes/Zenbones Light ================================================ palette = 0=#f0edec palette = 1=#a8334c palette = 2=#4f6c31 palette = 3=#944927 palette = 4=#286486 palette = 5=#88507d palette = 6=#3b8992 palette = 7=#2c363c palette = 8=#b5a7a0 palette = 9=#94253e palette = 10=#3f5a22 palette = 11=#803d1c palette = 12=#1d5573 palette = 13=#7b3b70 palette = 14=#2b747c palette = 15=#4f5e68 background = #f0edec foreground = #2c363c cursor-color = #2c363c cursor-text = #f0edec selection-background = #cbd9e3 selection-foreground = #2c363c ================================================ FILE: ghostty/Resources/themes/Zenwritten Dark ================================================ palette = 0=#191919 palette = 1=#de6e7c palette = 2=#819b69 palette = 3=#b77e64 palette = 4=#6099c0 palette = 5=#b279a7 palette = 6=#66a5ad palette = 7=#bbbbbb palette = 8=#4a4546 palette = 9=#e8838f palette = 10=#8bae68 palette = 11=#d68c67 palette = 12=#61abda palette = 13=#cf86c1 palette = 14=#65b8c1 palette = 15=#8e8e8e background = #191919 foreground = #bbbbbb cursor-color = #c9c9c9 cursor-text = #191919 selection-background = #404040 selection-foreground = #bbbbbb ================================================ FILE: ghostty/Resources/themes/Zenwritten Light ================================================ palette = 0=#eeeeee palette = 1=#a8334c palette = 2=#4f6c31 palette = 3=#944927 palette = 4=#286486 palette = 5=#88507d palette = 6=#3b8992 palette = 7=#353535 palette = 8=#aca9a9 palette = 9=#94253e palette = 10=#3f5a22 palette = 11=#803d1c palette = 12=#1d5573 palette = 13=#7b3b70 palette = 14=#2b747c palette = 15=#5c5c5c background = #eeeeee foreground = #353535 cursor-color = #353535 cursor-text = #eeeeee selection-background = #d7d7d7 selection-foreground = #353535 ================================================ FILE: ghostty/Resources/themes/iTerm2 Solarized Dark ================================================ palette = 0=#073642 palette = 1=#dc322f palette = 2=#859900 palette = 3=#b58900 palette = 4=#268bd2 palette = 5=#d33682 palette = 6=#2aa198 palette = 7=#eee8d5 palette = 8=#335e69 palette = 9=#cb4b16 palette = 10=#586e75 palette = 11=#657b83 palette = 12=#839496 palette = 13=#6c71c4 palette = 14=#93a1a1 palette = 15=#fdf6e3 background = #002b36 foreground = #839496 cursor-color = #839496 cursor-text = #073642 selection-background = #073642 selection-foreground = #93a1a1 ================================================ FILE: ghostty/Resources/themes/iTerm2 Solarized Light ================================================ palette = 0=#073642 palette = 1=#dc322f palette = 2=#859900 palette = 3=#b58900 palette = 4=#268bd2 palette = 5=#d33682 palette = 6=#2aa198 palette = 7=#bbb5a2 palette = 8=#002b36 palette = 9=#cb4b16 palette = 10=#586e75 palette = 11=#657b83 palette = 12=#839496 palette = 13=#6c71c4 palette = 14=#93a1a1 palette = 15=#fdf6e3 background = #fdf6e3 foreground = #657b83 cursor-color = #657b83 cursor-text = #eee8d5 selection-background = #eee8d5 selection-foreground = #586e75 ================================================ FILE: ghostty/Resources/themes/iTerm2 Tango Dark ================================================ palette = 0=#000000 palette = 1=#d81e00 palette = 2=#5ea702 palette = 3=#cfae00 palette = 4=#427ab3 palette = 5=#89658e palette = 6=#00a7aa palette = 7=#dbded8 palette = 8=#686a66 palette = 9=#f54235 palette = 10=#99e343 palette = 11=#fdeb61 palette = 12=#84b0d8 palette = 13=#bc94b7 palette = 14=#37e6e8 palette = 15=#f1f1f0 background = #000000 foreground = #ffffff cursor-color = #ffffff cursor-text = #000000 selection-background = #c1deff selection-foreground = #000000 ================================================ FILE: ghostty/Resources/themes/iTerm2 Tango Light ================================================ palette = 0=#000000 palette = 1=#d81e00 palette = 2=#5ea702 palette = 3=#cfae00 palette = 4=#427ab3 palette = 5=#89658e palette = 6=#00a7aa palette = 7=#b5b8b2 palette = 8=#686a66 palette = 9=#f54235 palette = 10=#8cd736 palette = 11=#d7c53a palette = 12=#84b0d8 palette = 13=#bc94b7 palette = 14=#1eccce palette = 15=#f1f1f0 background = #ffffff foreground = #000000 cursor-color = #000000 cursor-text = #ffffff selection-background = #c1deff selection-foreground = #000000 ================================================ FILE: ghostty/Sources/CGhostty/module.modulemap ================================================ // Ghostty C API module definition module CGhostty { umbrella header "ghostty.h" export * link "ghostty" } ================================================ FILE: ghostty/Sources/GhosttyKit/Clipboard.swift ================================================ // // Clipboard.swift // GhosttyKit // // Shared pasteboard helper for simple text copies // import AppKit enum Clipboard { static func copy(_ text: String) { let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(text, forType: .string) } static func readString() -> String? { NSPasteboard.general.string(forType: .string) } static func copy(lines: [String], separator: String = "\n") { copy(lines.joined(separator: separator)) } } ================================================ FILE: ghostty/Sources/GhosttyKit/Ghostty.Action.swift ================================================ // // Ghostty.Action.swift // CodMate // // Action types for Ghostty terminal events // // This file is adapted from Aizen (https://github.com/vivy-company/aizen) // which provided the initial Ghostty embedding implementation. // import Foundation import CGhostty // MARK: - Ghostty.Action extension Ghostty { enum Action {} } // MARK: - Scrollbar extension Ghostty.Action { /// Represents the scrollbar state from the terminal core. /// /// ## Fields /// - `total`: Total rows in scrollback + active area /// - `offset`: First visible row (0 = top of history) /// - `len`: Number of visible rows (viewport height) struct Scrollbar { let total: UInt64 let offset: UInt64 let len: UInt64 init(c: ghostty_action_scrollbar_s) { total = c.total offset = c.offset len = c.len } init(total: UInt64, offset: UInt64, len: UInt64) { self.total = total self.offset = offset self.len = len } } } // MARK: - Notification Names extension Notification.Name { /// Posted when the terminal scrollbar state changes. /// userInfo contains ScrollbarKey with Ghostty.Action.Scrollbar value. static let ghosttyDidUpdateScrollbar = Notification.Name("ai.umate.codmate.ghostty.didUpdateScrollbar") /// Posted when Ghostty configuration is reloaded (e.g., font size changed). /// Terminal views should refresh their surface size to trigger reflow. static let ghosttyConfigDidReload = Notification.Name("ai.umate.codmate.ghostty.configDidReload") /// Key for scrollbar state in notification userInfo static let ScrollbarKey = ghosttyDidUpdateScrollbar.rawValue + ".scrollbar" } ================================================ FILE: ghostty/Sources/GhosttyKit/Ghostty.App.swift ================================================ // // Ghostty.App.swift // CodMate // // Minimal Ghostty app wrapper - Phase 1: Basic lifecycle // // This file is adapted from Aizen (https://github.com/vivy-company/aizen) // which provided the initial Ghostty embedding implementation. // import AppKit import CGhostty import Combine import Foundation import OSLog import SwiftUI // MARK: - Ghostty Namespace public enum Ghostty { static let logger = Logger( subsystem: Bundle.main.bundleIdentifier ?? "ai.umate.codmate", category: "Ghostty") /// Wrapper to hold reference to a surface for tracking /// Note: ghostty_surface_t is an opaque pointer, so we store it directly /// The surface is freed when the GhosttyTerminalView is deallocated class SurfaceReference { let surface: ghostty_surface_t var isValid: Bool = true init(_ surface: ghostty_surface_t) { self.surface = surface } func invalidate() { isValid = false } } /// Stable userdata container for Ghostty surface callbacks. /// Holds a weak terminal view reference to avoid use-after-free. final class SurfaceUserdata { weak var terminalView: GhosttyTerminalView? init(view: GhosttyTerminalView) { self.terminalView = view } } } // MARK: - Ghostty.App extension Ghostty { /// Minimal wrapper for ghostty_app_t lifecycle management @MainActor public class App: ObservableObject { public enum Readiness: String { case loading, error, ready } // MARK: - Published Properties /// The ghostty app instance @Published public var app: ghostty_app_t? = nil /// Readiness state @Published public var readiness: Readiness = .loading /// Track active surfaces for config propagation private var activeSurfaces: [Ghostty.SurfaceReference] = [] /// Track last known system appearance state to detect changes private var lastKnownIsDark: Bool? /// Track last known theme to detect changes private var lastKnownTheme: String? /// Track last known font settings to detect changes private var lastKnownFontName: String? private var lastKnownFontSize: Double? private var lastKnownCursorStyle: String? /// Observer for in-app appearance setting changes private var appearanceSettingObserver: NSObjectProtocol? private var appAppearanceObservation: NSKeyValueObservation? // MARK: - Terminal Settings from AppStorage // Note: Theme settings are managed via SessionPreferencesStore and synced here // We use AppStorage for backward compatibility with existing code @AppStorage("terminal.fontName") private var terminalFontName = "Menlo" @AppStorage("terminal.fontSize") private var terminalFontSize = 12.0 @AppStorage("terminal.cursorStyle") private var terminalCursorStyleRaw = "blinkBlock" @AppStorage("terminalThemeName") private var terminalThemeName = "Xcode Dark" @AppStorage("terminalThemeNameLight") private var terminalThemeNameLight = "Xcode Light" @AppStorage("terminalUsePerAppearanceTheme") private var usePerAppearanceTheme = true @AppStorage("appearanceMode") private var appearanceMode = "system" /// Parse cursor style raw value to Ghostty config values private var cursorStyleConfig: (style: String, blink: Bool) { // Map raw values to Ghostty config // Raw values: blinkBlock, steadyBlock, blinkUnderline, steadyUnderline, blinkBar, steadyBar let style: String let blink: Bool if terminalCursorStyleRaw.contains("Block") { style = "block" blink = terminalCursorStyleRaw.contains("blink") } else if terminalCursorStyleRaw.contains("Underline") { style = "underline" blink = terminalCursorStyleRaw.contains("blink") } else if terminalCursorStyleRaw.contains("Bar") { style = "bar" blink = terminalCursorStyleRaw.contains("blink") } else { // Default fallback style = "block" blink = true } return (style: style, blink: blink) } private var effectiveThemeName: String { guard usePerAppearanceTheme else { return terminalThemeName } switch appearanceMode { case "light": return terminalThemeNameLight case "dark": return terminalThemeName default: return currentSystemIsDark() ? terminalThemeName : terminalThemeNameLight } } // MARK: - Initialization public init() { // Migrate old theme names to new names if terminalThemeName == "Dark" && terminalThemeNameLight == "Light" { // Migrate from generic Dark/Light to Xcode themes if both are defaults terminalThemeName = "Xcode Dark" terminalThemeNameLight = "Xcode Light" } // Log initial theme configuration Ghostty.logger.info( "Ghostty.App initializing with usePerAppearanceTheme=\(self.usePerAppearanceTheme), dark=\(self.terminalThemeName), light=\(self.terminalThemeNameLight)" ) // CRITICAL: Initialize libghostty first let initResult = ghostty_init(0, nil) if initResult != GHOSTTY_SUCCESS { Ghostty.logger.critical("ghostty_init failed with code: \(initResult)") readiness = .error return } // Create runtime config with callbacks var runtime_cfg = ghostty_runtime_config_s( userdata: Unmanaged.passUnretained(self).toOpaque(), supports_selection_clipboard: true, wakeup_cb: { userdata in App.wakeup(userdata) }, action_cb: { app, target, action in App.action(app!, target: target, action: action) }, read_clipboard_cb: { userdata, loc, state in App.readClipboard(userdata, location: loc, state: state) }, confirm_read_clipboard_cb: { userdata, str, state, request in App.confirmReadClipboard(userdata, string: str, state: state, request: request) }, write_clipboard_cb: { userdata, loc, content, count, confirm in App.writeClipboard( userdata, location: loc, contents: content, count: count, confirm: confirm) }, close_surface_cb: { userdata, processAlive in App.closeSurface(userdata, processAlive: processAlive) } ) // Create config and load Ghostty terminal settings guard let config = ghostty_config_new() else { Ghostty.logger.critical("ghostty_config_new failed") readiness = .error return } // Load config from settings loadConfigIntoGhostty(config) // Finalize config (required before use) ghostty_config_finalize(config) // Create the ghostty app guard let app = ghostty_app_new(&runtime_cfg, config) else { Ghostty.logger.critical("ghostty_app_new failed") ghostty_config_free(config) readiness = .error return } // Free config after app creation (app clones it) ghostty_config_free(config) // CRITICAL: Unset XDG_CONFIG_HOME after app creation // If left set, fish will look for config.fish in the temp directory instead of ~/.config unsetenv("XDG_CONFIG_HOME") self.app = app self.readiness = .ready // Store initial appearance and theme lastKnownIsDark = currentSystemIsDark() lastKnownTheme = effectiveThemeName lastKnownFontName = terminalFontName lastKnownFontSize = terminalFontSize lastKnownCursorStyle = terminalCursorStyleRaw appAppearanceObservation = NSApp.observe(\.effectiveAppearance, options: [.new]) { [weak self] _, _ in Task { @MainActor [weak self] in self?.handleAppearanceChange() } } // Observe system appearance changes via DistributedNotificationCenter DistributedNotificationCenter.default().addObserver( self, selector: #selector(systemAppearanceDidChange), name: NSNotification.Name("AppleInterfaceThemeChangedNotification"), object: nil ) // Observe in-app setting changes (appearance, font, cursor) appearanceSettingObserver = NotificationCenter.default.addObserver( forName: UserDefaults.didChangeNotification, object: nil, queue: .main ) { [weak self] _ in Task { @MainActor [weak self] in self?.checkSettingsChange() } } Ghostty.logger.info("Ghostty app initialized successfully") // Delay theme verification to ensure NSApp is fully initialized // During @StateObject init, NSApp.effectiveAppearance may not be accurate yet Task { @MainActor [weak self] in // Wait a brief moment for the app to finish launching try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds self?.verifyAndCorrectThemeIfNeeded() } } /// Verify theme matches system appearance and reload if necessary private func verifyAndCorrectThemeIfNeeded() { guard usePerAppearanceTheme else { return } let currentIsDark = currentSystemIsDark() let expectedTheme = effectiveThemeName Ghostty.logger.info( "Theme verification: systemIsDark=\(currentIsDark), expectedTheme=\(expectedTheme), currentTheme=\(self.lastKnownTheme ?? "nil")" ) if expectedTheme != lastKnownTheme { Ghostty.logger.info( "Theme mismatch detected, reloading config with correct theme: \(expectedTheme)" ) lastKnownIsDark = currentIsDark lastKnownTheme = expectedTheme reloadConfig() } } @objc private func systemAppearanceDidChange(_ notification: Notification) { // DistributedNotificationCenter calls on a background thread // Must dispatch to MainActor for safe access to @MainActor-isolated methods Task { @MainActor [weak self] in self?.handleAppearanceChange() } } private func handleAppearanceChange() { guard usePerAppearanceTheme else { return } let currentIsDark = currentSystemIsDark() guard currentIsDark != lastKnownIsDark else { return } lastKnownIsDark = currentIsDark reloadIfThemeChanged() } private func checkSettingsChange() { // Check theme changes if usePerAppearanceTheme { reloadIfThemeChanged() } // Check font and cursor style changes let currentFontName = self.terminalFontName let currentFontSize = self.terminalFontSize let currentCursorStyle = self.terminalCursorStyleRaw let fontChanged = currentFontName != lastKnownFontName || currentFontSize != lastKnownFontSize let cursorChanged = currentCursorStyle != lastKnownCursorStyle if fontChanged || cursorChanged { if fontChanged { lastKnownFontName = currentFontName lastKnownFontSize = currentFontSize Ghostty.logger.info( "Font changed, reloading terminal config - Font: \(currentFontName) \(Int(currentFontSize))pt" ) } if cursorChanged { lastKnownCursorStyle = currentCursorStyle Ghostty.logger.info( "Cursor style changed, reloading terminal config - Style: \(currentCursorStyle)" ) } reloadConfig() } } private func reloadIfThemeChanged() { let newTheme = effectiveThemeName guard newTheme != lastKnownTheme else { return } lastKnownTheme = newTheme Ghostty.logger.info("Theme changed, reloading terminal config with theme: \(newTheme)") reloadConfig() } deinit { // Note: Cannot access @MainActor isolated properties in deinit // The app will be freed when the instance is deallocated // For proper cleanup, call a cleanup method before deinitialization } // MARK: - App Operations /// Clean up the ghostty app resources func cleanup() { appAppearanceObservation?.invalidate() appAppearanceObservation = nil DistributedNotificationCenter.default().removeObserver(self) if let observer = appearanceSettingObserver { NotificationCenter.default.removeObserver(observer) appearanceSettingObserver = nil } if let app = self.app { ghostty_app_free(app) self.app = nil } } func appTick() { guard let app = self.app else { return } ghostty_app_tick(app) } /// Register a surface for config update tracking /// Returns the surface reference that should be stored by the view @discardableResult func registerSurface(_ surface: ghostty_surface_t) -> Ghostty.SurfaceReference { let ref = Ghostty.SurfaceReference(surface) activeSurfaces.append(ref) // Clean up invalid surfaces activeSurfaces = activeSurfaces.filter { $0.isValid } return ref } /// Unregister a surface when it's being deallocated func unregisterSurface(_ ref: Ghostty.SurfaceReference) { ref.invalidate() activeSurfaces = activeSurfaces.filter { $0.isValid } } /// Reload configuration (call when settings change) func reloadConfig() { guard let app = self.app else { return } // Create new config with updated settings guard let config = ghostty_config_new() else { Ghostty.logger.error("ghostty_config_new failed during reload") return } // Load config from settings loadConfigIntoGhostty(config) // Finalize config (required before use) ghostty_config_finalize(config) // Update the app config ghostty_app_update_config(app, config) // Propagate config to all existing surfaces for surfaceRef in activeSurfaces where surfaceRef.isValid { ghostty_surface_update_config(surfaceRef.surface, config) } // Clean up invalid surfaces activeSurfaces = activeSurfaces.filter { $0.isValid } ghostty_config_free(config) // Unset XDG_CONFIG_HOME so it doesn't affect fish/shell config loading unsetenv("XDG_CONFIG_HOME") // Notify all terminal views to refresh (triggers reflow on font size changes) NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) Ghostty.logger.info( "Configuration reloaded and propagated to \(self.activeSurfaces.count) surfaces") } // MARK: - Private Helpers /// Generate and load config content into a ghostty_config_t private func loadConfigIntoGhostty(_ config: ghostty_config_t) { // Create temp config directory and use Ghostty themes let tempDir = NSTemporaryDirectory() let ghosttyConfigDir = (tempDir as NSString).appendingPathComponent(".config/ghostty") let configFilePath = (ghosttyConfigDir as NSString).appendingPathComponent("config") do { try FileManager.default.createDirectory( atPath: ghosttyConfigDir, withIntermediateDirectories: true) syncBundledThemes(into: ghosttyConfigDir) // Detect shell for integration let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh" let shellName = (shell as NSString).lastPathComponent // Determine theme based on current system appearance let isDark = currentSystemIsDark() let themeName = effectiveThemeName // Log theme selection Ghostty.logger.info( "Loading terminal config: systemIsDark=\(isDark), usePerAppearance=\(self.usePerAppearanceTheme), theme=\(themeName)" ) let themeURL = URL(fileURLWithPath: ghosttyConfigDir) .appendingPathComponent("themes", isDirectory: true) .appendingPathComponent(themeName) if !FileManager.default.fileExists(atPath: themeURL.path) { Ghostty.logger.warning("Ghostty theme file missing at: \(themeURL.path)") } let configContent = """ font-family = \(terminalFontName) font-size = \(Int(terminalFontSize)) window-inherit-font-size = false window-padding-balance = true window-padding-x = 0 window-padding-y = 0 window-padding-color = extend-always # Enable shell integration (resources dir auto-detected from app bundle) shell-integration = \(shellName) shell-integration-features = no-cursor,sudo,title # Cursor cursor-style = \(cursorStyleConfig.style) cursor-style-blink = \(cursorStyleConfig.blink) theme = \(themeName) # Disable audible bell audible-bell = false # Custom keybinds keybind = shift+enter=text:\\n """ Ghostty.logger.info("Loading Ghostty theme: \(themeName)") try configContent.write(toFile: configFilePath, atomically: true, encoding: .utf8) // Set XDG_CONFIG_HOME to our temp directory // With bundle ID "ai.umate.codmate", Ghostty will look for config at: // ~/Library/Application Support/ai.umate.codmate/config (won't exist) // So it will use our XDG config only setenv( "XDG_CONFIG_HOME", (tempDir as NSString).appendingPathComponent(".config"), 1) // Load default files - will load our XDG config // Will NOT load user's Ghostty config (com.mitchellh.ghostty) since bundle ID is different ghostty_config_load_default_files(config) Ghostty.logger.info( "Loaded Ghostty terminal settings - Font: \(self.terminalFontName) \(Int(self.terminalFontSize))pt, Theme: \(themeName)" ) } catch { Ghostty.logger.warning("Failed to write config: \(error)") } } private func currentSystemIsDark() -> Bool { // Primary detection: Use UserDefaults (most reliable, especially during app initialization) // AppleInterfaceStyle is only set when in Dark mode, absent in Light mode if UserDefaults.standard.string(forKey: "AppleInterfaceStyle") == "Dark" { return true } // Secondary detection: Use NSApp.effectiveAppearance (more accurate after app fully launches) // This may return incorrect value during early initialization let appearance = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) if appearance == .darkAqua { return true } if appearance == .aqua { return false } // Fallback: default to light mode return false } private func syncBundledThemes(into ghosttyConfigDir: String) { // Access themes from Package resources via Bundle.module guard let themesURL = Bundle.module.url( forResource: "themes", withExtension: nil, subdirectory: nil) else { Ghostty.logger.warning("Ghostty themes resource not found in Bundle.module") return } let sourceDir = themesURL.path let destDir = (ghosttyConfigDir as NSString).appendingPathComponent("themes") let fm = FileManager.default var isDir: ObjCBool = false guard fm.fileExists(atPath: sourceDir, isDirectory: &isDir), isDir.boolValue else { Ghostty.logger.warning("Ghostty themes directory not found at: \(sourceDir)") return } if fm.fileExists(atPath: destDir, isDirectory: &isDir) { guard isDir.boolValue else { return } } else { try? fm.createDirectory(atPath: destDir, withIntermediateDirectories: true) } guard let files = try? fm.contentsOfDirectory(atPath: sourceDir) else { return } for file in files { let from = (sourceDir as NSString).appendingPathComponent(file) let to = (destDir as NSString).appendingPathComponent(file) if fm.fileExists(atPath: to) { continue } _ = try? fm.copyItem(atPath: from, toPath: to) } } // MARK: - Callbacks (macOS) static func wakeup(_ userdata: UnsafeMutableRawPointer?) { guard let userdata = userdata else { return } let state = Unmanaged.fromOpaque(userdata).takeUnretainedValue() // Schedule tick on main thread DispatchQueue.main.async { state.appTick() } } static func action(_ app: ghostty_app_t, target: ghostty_target_s, action: ghostty_action_s) -> Bool { // Get the terminal view from surface userdata if target is a surface let terminalView: GhosttyTerminalView? = { guard target.tag == GHOSTTY_TARGET_SURFACE else { return nil } let surface = target.target.surface guard let userdata = ghostty_surface_userdata(surface) else { return nil } let surfaceUserdata = Unmanaged.fromOpaque(userdata) .takeUnretainedValue() return surfaceUserdata.terminalView }() NSLog( "[Ghostty.App] action callback: tag=%d, has terminalView=%@", action.tag.rawValue, terminalView != nil ? "YES" : "NO") switch action.tag { case GHOSTTY_ACTION_SET_TITLE: // Window/tab title change if let titlePtr = action.action.set_title.title, let terminalView = terminalView { let title = String(cString: titlePtr) Ghostty.logger.info("Title changed: \(title)") // Propagate to terminal view callback with weak capture DispatchQueue.main.async { [weak terminalView] in terminalView?.onTitleChange?(title) } } return true case GHOSTTY_ACTION_PWD: // Working directory change if let pwdPtr = action.action.pwd.pwd { let pwd = String(cString: pwdPtr) Ghostty.logger.info("PWD changed: \(pwd)") } return true case GHOSTTY_ACTION_PROMPT_TITLE: // Prompt title update (for shell integration) Ghostty.logger.debug("Prompt title action received") return true case GHOSTTY_ACTION_PROGRESS_REPORT: if let terminalView = terminalView { let report = action.action.progress_report let state = GhosttyProgressState(cState: report.state) let value = report.progress >= 0 ? Int(report.progress) : nil DispatchQueue.main.async { [weak terminalView] in terminalView?.onProgressReport?(state, value) } } return true case GHOSTTY_ACTION_CELL_SIZE: // Cell size update - used for row-to-pixel conversion in scrollbar if let terminalView = terminalView { let cellSize = action.action.cell_size let backingSize = NSSize( width: Double(cellSize.width), height: Double(cellSize.height)) DispatchQueue.main.async { [weak terminalView] in guard let terminalView = terminalView else { return } // Convert from backing (pixel) coordinates to points terminalView.cellSize = terminalView.convertFromBacking(backingSize) } } return true case GHOSTTY_ACTION_SCROLLBAR: // Scrollbar state update - post notification for scroll view let scrollbar = Ghostty.Action.Scrollbar(c: action.action.scrollbar) NotificationCenter.default.post( name: .ghosttyDidUpdateScrollbar, object: terminalView, userInfo: [Notification.Name.ScrollbarKey: scrollbar] ) return true default: // Log unhandled actions Ghostty.logger.debug( "Action received: \(action.tag.rawValue) on target: \(target.tag.rawValue)") return false } } static func readClipboard( _ userdata: UnsafeMutableRawPointer?, location: ghostty_clipboard_e, state: UnsafeMutableRawPointer? ) { // userdata is the GhosttyTerminalView instance guard let userdata = userdata else { return } let surfaceUserdata = Unmanaged.fromOpaque(userdata) .takeUnretainedValue() guard let terminalView = surfaceUserdata.terminalView else { return } guard let surface = terminalView.surface?.unsafeCValue else { return } // Read from macOS clipboard let clipboardString = Clipboard.readString() ?? "" // Complete the clipboard request by providing data to Ghostty clipboardString.withCString { ptr in ghostty_surface_complete_clipboard_request(surface, ptr, state, false) } Ghostty.logger.debug("Read clipboard: \(clipboardString.prefix(50))...") } static func confirmReadClipboard( _ userdata: UnsafeMutableRawPointer?, string: UnsafePointer?, state: UnsafeMutableRawPointer?, request: ghostty_clipboard_request_e ) { // Clipboard read confirmation // For security, apps can confirm before allowing clipboard access // For now, just log it Ghostty.logger.debug("Clipboard read confirmation requested") } static func writeClipboard( _ userdata: UnsafeMutableRawPointer?, location: ghostty_clipboard_e, contents: UnsafePointer?, count: Int, confirm: Bool ) { guard let contents = contents, count > 0 else { return } // The runtime passes an array of clipboard entries; prefer the first // textual entry. The API does not supply a byte length, so we treat // the data as a null-terminated UTF-8 C string. for idx in 0...fromOpaque(userdata) .takeUnretainedValue() let terminalView = surfaceUserdata.terminalView Ghostty.logger.info("Close surface: processAlive=\(processAlive)") // Trigger process exit callback on main thread with weak capture DispatchQueue.main.async { [weak terminalView] in terminalView?.onProcessExit?() } } } } ================================================ FILE: ghostty/Sources/GhosttyKit/Ghostty.Input.swift ================================================ import AppKit import SwiftUI import CGhostty extension SwiftUI.EventModifiers { /// Initialize EventModifiers from NSEvent.ModifierFlags init(nsFlags: NSEvent.ModifierFlags) { var modifiers = SwiftUI.EventModifiers() if nsFlags.contains(.shift) { modifiers.insert(.shift) } if nsFlags.contains(.control) { modifiers.insert(.control) } if nsFlags.contains(.option) { modifiers.insert(.option) } if nsFlags.contains(.command) { modifiers.insert(.command) } self = modifiers } } extension Ghostty { // Input types split into separate files: Ghostty.Key.swift, Ghostty.MouseEvent.swift, Ghostty.KeyEvent.swift, Ghostty.Mods.swift struct Input {} // MARK: Keyboard Shortcuts /// Return the key equivalent for the given trigger. /// /// Returns nil if the trigger doesn't have an equivalent KeyboardShortcut. This is possible /// because Ghostty input triggers are a superset of what can be represented by a macOS /// KeyboardShortcut. For example, macOS doesn't have any way to represent function keys /// (F1, F2, ...) with a KeyboardShortcut. This doesn't represent a practical issue because input /// handling for Ghostty is handled at a lower level (usually). This function should generally only /// be used for things like NSMenu that only support keyboard shortcuts anyways. static func keyboardShortcut(for trigger: ghostty_input_trigger_s) -> KeyboardShortcut? { let key: KeyEquivalent switch (trigger.tag) { case GHOSTTY_TRIGGER_PHYSICAL: // Only functional keys can be converted to a KeyboardShortcut. Other physical // mappings cannot because KeyboardShortcut in Swift is inherently layout-dependent. if let equiv = Self.keyToEquivalent[trigger.key.physical.rawValue] { key = equiv } else { return nil } case GHOSTTY_TRIGGER_UNICODE: guard let scalar = UnicodeScalar(trigger.key.unicode) else { return nil } key = KeyEquivalent(Character(scalar)) default: return nil } return KeyboardShortcut( key, modifiers: EventModifiers(nsFlags: Ghostty.eventModifierFlags(mods: trigger.mods))) } // MARK: Mods /// Returns the event modifier flags set for the Ghostty mods enum. static func eventModifierFlags(mods: ghostty_input_mods_e) -> NSEvent.ModifierFlags { var flags = NSEvent.ModifierFlags(rawValue: 0); if (mods.rawValue & GHOSTTY_MODS_SHIFT.rawValue != 0) { flags.insert(.shift) } if (mods.rawValue & GHOSTTY_MODS_CTRL.rawValue != 0) { flags.insert(.control) } if (mods.rawValue & GHOSTTY_MODS_ALT.rawValue != 0) { flags.insert(.option) } if (mods.rawValue & GHOSTTY_MODS_SUPER.rawValue != 0) { flags.insert(.command) } return flags } /// Translate event modifier flags to a ghostty mods enum. static func ghosttyMods(_ flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e { var mods: UInt32 = GHOSTTY_MODS_NONE.rawValue if (flags.contains(.shift)) { mods |= GHOSTTY_MODS_SHIFT.rawValue } if (flags.contains(.control)) { mods |= GHOSTTY_MODS_CTRL.rawValue } if (flags.contains(.option)) { mods |= GHOSTTY_MODS_ALT.rawValue } if (flags.contains(.command)) { mods |= GHOSTTY_MODS_SUPER.rawValue } if (flags.contains(.capsLock)) { mods |= GHOSTTY_MODS_CAPS.rawValue } // Handle sided input. We can't tell that both are pressed in the // Ghostty structure but thats okay -- we don't use that information. let rawFlags = flags.rawValue if (rawFlags & UInt(NX_DEVICERSHIFTKEYMASK) != 0) { mods |= GHOSTTY_MODS_SHIFT_RIGHT.rawValue } if (rawFlags & UInt(NX_DEVICERCTLKEYMASK) != 0) { mods |= GHOSTTY_MODS_CTRL_RIGHT.rawValue } if (rawFlags & UInt(NX_DEVICERALTKEYMASK) != 0) { mods |= GHOSTTY_MODS_ALT_RIGHT.rawValue } if (rawFlags & UInt(NX_DEVICERCMDKEYMASK) != 0) { mods |= GHOSTTY_MODS_SUPER_RIGHT.rawValue } return ghostty_input_mods_e(mods) } /// A map from the Ghostty key enum to the keyEquivalent string for shortcuts. Note that /// not all ghostty key enum values are represented here because not all of them can be /// mapped to a KeyEquivalent. static let keyToEquivalent: [UInt32 : KeyEquivalent] = [ // Function keys GHOSTTY_KEY_ARROW_UP.rawValue: .upArrow, GHOSTTY_KEY_ARROW_DOWN.rawValue: .downArrow, GHOSTTY_KEY_ARROW_LEFT.rawValue: .leftArrow, GHOSTTY_KEY_ARROW_RIGHT.rawValue: .rightArrow, GHOSTTY_KEY_HOME.rawValue: .home, GHOSTTY_KEY_END.rawValue: .end, GHOSTTY_KEY_DELETE.rawValue: .delete, GHOSTTY_KEY_PAGE_UP.rawValue: .pageUp, GHOSTTY_KEY_PAGE_DOWN.rawValue: .pageDown, GHOSTTY_KEY_ESCAPE.rawValue: .escape, GHOSTTY_KEY_ENTER.rawValue: .return, GHOSTTY_KEY_TAB.rawValue: .tab, GHOSTTY_KEY_BACKSPACE.rawValue: .delete, GHOSTTY_KEY_SPACE.rawValue: .space, ] } ================================================ FILE: ghostty/Sources/GhosttyKit/Ghostty.Key.swift ================================================ import Foundation import CGhostty extension Ghostty.Input { /// `ghostty_input_key_e` enum Key: String, CaseIterable { // Writing System Keys case backquote case backslash case bracketLeft case bracketRight case comma case digit0 case digit1 case digit2 case digit3 case digit4 case digit5 case digit6 case digit7 case digit8 case digit9 case equal case intlBackslash case intlRo case intlYen case a case b case c case d case e case f case g case h case i case j case k case l case m case n case o case p case q case r case s case t case u case v case w case x case y case z case minus case period case quote case semicolon case slash // Functional Keys case altLeft case altRight case backspace case capsLock case contextMenu case controlLeft case controlRight case enter case metaLeft case metaRight case shiftLeft case shiftRight case space case tab case convert case kanaMode case nonConvert // Control Pad Section case delete case end case help case home case insert case pageDown case pageUp // Arrow Pad Section case arrowDown case arrowLeft case arrowRight case arrowUp // Numpad Section case numLock case numpad0 case numpad1 case numpad2 case numpad3 case numpad4 case numpad5 case numpad6 case numpad7 case numpad8 case numpad9 case numpadAdd case numpadBackspace case numpadClear case numpadClearEntry case numpadComma case numpadDecimal case numpadDivide case numpadEnter case numpadEqual case numpadMemoryAdd case numpadMemoryClear case numpadMemoryRecall case numpadMemoryStore case numpadMemorySubtract case numpadMultiply case numpadParenLeft case numpadParenRight case numpadSubtract case numpadSeparator case numpadUp case numpadDown case numpadRight case numpadLeft case numpadBegin case numpadHome case numpadEnd case numpadInsert case numpadDelete case numpadPageUp case numpadPageDown // Function Section case escape case f1 case f2 case f3 case f4 case f5 case f6 case f7 case f8 case f9 case f10 case f11 case f12 case f13 case f14 case f15 case f16 case f17 case f18 case f19 case f20 case f21 case f22 case f23 case f24 case f25 case fn case fnLock case printScreen case scrollLock case pause // Media Keys case browserBack case browserFavorites case browserForward case browserHome case browserRefresh case browserSearch case browserStop case eject case launchApp1 case launchApp2 case launchMail case mediaPlayPause case mediaSelect case mediaStop case mediaTrackNext case mediaTrackPrevious case power case sleep case audioVolumeDown case audioVolumeMute case audioVolumeUp case wakeUp // Legacy, Non-standard, and Special Keys case copy case cut case paste /// Get a key from a keycode init?(keyCode: UInt16) { if let key = Key.allCases.first(where: { $0.keyCode == keyCode }) { self = key return } return nil } var cKey: ghostty_input_key_e { switch self { // Writing System Keys case .backquote: GHOSTTY_KEY_BACKQUOTE case .backslash: GHOSTTY_KEY_BACKSLASH case .bracketLeft: GHOSTTY_KEY_BRACKET_LEFT case .bracketRight: GHOSTTY_KEY_BRACKET_RIGHT case .comma: GHOSTTY_KEY_COMMA case .digit0: GHOSTTY_KEY_DIGIT_0 case .digit1: GHOSTTY_KEY_DIGIT_1 case .digit2: GHOSTTY_KEY_DIGIT_2 case .digit3: GHOSTTY_KEY_DIGIT_3 case .digit4: GHOSTTY_KEY_DIGIT_4 case .digit5: GHOSTTY_KEY_DIGIT_5 case .digit6: GHOSTTY_KEY_DIGIT_6 case .digit7: GHOSTTY_KEY_DIGIT_7 case .digit8: GHOSTTY_KEY_DIGIT_8 case .digit9: GHOSTTY_KEY_DIGIT_9 case .equal: GHOSTTY_KEY_EQUAL case .intlBackslash: GHOSTTY_KEY_INTL_BACKSLASH case .intlRo: GHOSTTY_KEY_INTL_RO case .intlYen: GHOSTTY_KEY_INTL_YEN case .a: GHOSTTY_KEY_A case .b: GHOSTTY_KEY_B case .c: GHOSTTY_KEY_C case .d: GHOSTTY_KEY_D case .e: GHOSTTY_KEY_E case .f: GHOSTTY_KEY_F case .g: GHOSTTY_KEY_G case .h: GHOSTTY_KEY_H case .i: GHOSTTY_KEY_I case .j: GHOSTTY_KEY_J case .k: GHOSTTY_KEY_K case .l: GHOSTTY_KEY_L case .m: GHOSTTY_KEY_M case .n: GHOSTTY_KEY_N case .o: GHOSTTY_KEY_O case .p: GHOSTTY_KEY_P case .q: GHOSTTY_KEY_Q case .r: GHOSTTY_KEY_R case .s: GHOSTTY_KEY_S case .t: GHOSTTY_KEY_T case .u: GHOSTTY_KEY_U case .v: GHOSTTY_KEY_V case .w: GHOSTTY_KEY_W case .x: GHOSTTY_KEY_X case .y: GHOSTTY_KEY_Y case .z: GHOSTTY_KEY_Z case .minus: GHOSTTY_KEY_MINUS case .period: GHOSTTY_KEY_PERIOD case .quote: GHOSTTY_KEY_QUOTE case .semicolon: GHOSTTY_KEY_SEMICOLON case .slash: GHOSTTY_KEY_SLASH // Functional Keys case .altLeft: GHOSTTY_KEY_ALT_LEFT case .altRight: GHOSTTY_KEY_ALT_RIGHT case .backspace: GHOSTTY_KEY_BACKSPACE case .capsLock: GHOSTTY_KEY_CAPS_LOCK case .contextMenu: GHOSTTY_KEY_CONTEXT_MENU case .controlLeft: GHOSTTY_KEY_CONTROL_LEFT case .controlRight: GHOSTTY_KEY_CONTROL_RIGHT case .enter: GHOSTTY_KEY_ENTER case .metaLeft: GHOSTTY_KEY_META_LEFT case .metaRight: GHOSTTY_KEY_META_RIGHT case .shiftLeft: GHOSTTY_KEY_SHIFT_LEFT case .shiftRight: GHOSTTY_KEY_SHIFT_RIGHT case .space: GHOSTTY_KEY_SPACE case .tab: GHOSTTY_KEY_TAB case .convert: GHOSTTY_KEY_CONVERT case .kanaMode: GHOSTTY_KEY_KANA_MODE case .nonConvert: GHOSTTY_KEY_NON_CONVERT // Control Pad Section case .delete: GHOSTTY_KEY_DELETE case .end: GHOSTTY_KEY_END case .help: GHOSTTY_KEY_HELP case .home: GHOSTTY_KEY_HOME case .insert: GHOSTTY_KEY_INSERT case .pageDown: GHOSTTY_KEY_PAGE_DOWN case .pageUp: GHOSTTY_KEY_PAGE_UP // Arrow Pad Section case .arrowDown: GHOSTTY_KEY_ARROW_DOWN case .arrowLeft: GHOSTTY_KEY_ARROW_LEFT case .arrowRight: GHOSTTY_KEY_ARROW_RIGHT case .arrowUp: GHOSTTY_KEY_ARROW_UP // Numpad Section case .numLock: GHOSTTY_KEY_NUM_LOCK case .numpad0: GHOSTTY_KEY_NUMPAD_0 case .numpad1: GHOSTTY_KEY_NUMPAD_1 case .numpad2: GHOSTTY_KEY_NUMPAD_2 case .numpad3: GHOSTTY_KEY_NUMPAD_3 case .numpad4: GHOSTTY_KEY_NUMPAD_4 case .numpad5: GHOSTTY_KEY_NUMPAD_5 case .numpad6: GHOSTTY_KEY_NUMPAD_6 case .numpad7: GHOSTTY_KEY_NUMPAD_7 case .numpad8: GHOSTTY_KEY_NUMPAD_8 case .numpad9: GHOSTTY_KEY_NUMPAD_9 case .numpadAdd: GHOSTTY_KEY_NUMPAD_ADD case .numpadBackspace: GHOSTTY_KEY_NUMPAD_BACKSPACE case .numpadClear: GHOSTTY_KEY_NUMPAD_CLEAR case .numpadClearEntry: GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY case .numpadComma: GHOSTTY_KEY_NUMPAD_COMMA case .numpadDecimal: GHOSTTY_KEY_NUMPAD_DECIMAL case .numpadDivide: GHOSTTY_KEY_NUMPAD_DIVIDE case .numpadEnter: GHOSTTY_KEY_NUMPAD_ENTER case .numpadEqual: GHOSTTY_KEY_NUMPAD_EQUAL case .numpadMemoryAdd: GHOSTTY_KEY_NUMPAD_MEMORY_ADD case .numpadMemoryClear: GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR case .numpadMemoryRecall: GHOSTTY_KEY_NUMPAD_MEMORY_RECALL case .numpadMemoryStore: GHOSTTY_KEY_NUMPAD_MEMORY_STORE case .numpadMemorySubtract: GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT case .numpadMultiply: GHOSTTY_KEY_NUMPAD_MULTIPLY case .numpadParenLeft: GHOSTTY_KEY_NUMPAD_PAREN_LEFT case .numpadParenRight: GHOSTTY_KEY_NUMPAD_PAREN_RIGHT case .numpadSubtract: GHOSTTY_KEY_NUMPAD_SUBTRACT case .numpadSeparator: GHOSTTY_KEY_NUMPAD_SEPARATOR case .numpadUp: GHOSTTY_KEY_NUMPAD_UP case .numpadDown: GHOSTTY_KEY_NUMPAD_DOWN case .numpadRight: GHOSTTY_KEY_NUMPAD_RIGHT case .numpadLeft: GHOSTTY_KEY_NUMPAD_LEFT case .numpadBegin: GHOSTTY_KEY_NUMPAD_BEGIN case .numpadHome: GHOSTTY_KEY_NUMPAD_HOME case .numpadEnd: GHOSTTY_KEY_NUMPAD_END case .numpadInsert: GHOSTTY_KEY_NUMPAD_INSERT case .numpadDelete: GHOSTTY_KEY_NUMPAD_DELETE case .numpadPageUp: GHOSTTY_KEY_NUMPAD_PAGE_UP case .numpadPageDown: GHOSTTY_KEY_NUMPAD_PAGE_DOWN // Function Section case .escape: GHOSTTY_KEY_ESCAPE case .f1: GHOSTTY_KEY_F1 case .f2: GHOSTTY_KEY_F2 case .f3: GHOSTTY_KEY_F3 case .f4: GHOSTTY_KEY_F4 case .f5: GHOSTTY_KEY_F5 case .f6: GHOSTTY_KEY_F6 case .f7: GHOSTTY_KEY_F7 case .f8: GHOSTTY_KEY_F8 case .f9: GHOSTTY_KEY_F9 case .f10: GHOSTTY_KEY_F10 case .f11: GHOSTTY_KEY_F11 case .f12: GHOSTTY_KEY_F12 case .f13: GHOSTTY_KEY_F13 case .f14: GHOSTTY_KEY_F14 case .f15: GHOSTTY_KEY_F15 case .f16: GHOSTTY_KEY_F16 case .f17: GHOSTTY_KEY_F17 case .f18: GHOSTTY_KEY_F18 case .f19: GHOSTTY_KEY_F19 case .f20: GHOSTTY_KEY_F20 case .f21: GHOSTTY_KEY_F21 case .f22: GHOSTTY_KEY_F22 case .f23: GHOSTTY_KEY_F23 case .f24: GHOSTTY_KEY_F24 case .f25: GHOSTTY_KEY_F25 case .fn: GHOSTTY_KEY_FN case .fnLock: GHOSTTY_KEY_FN_LOCK case .printScreen: GHOSTTY_KEY_PRINT_SCREEN case .scrollLock: GHOSTTY_KEY_SCROLL_LOCK case .pause: GHOSTTY_KEY_PAUSE // Media Keys case .browserBack: GHOSTTY_KEY_BROWSER_BACK case .browserFavorites: GHOSTTY_KEY_BROWSER_FAVORITES case .browserForward: GHOSTTY_KEY_BROWSER_FORWARD case .browserHome: GHOSTTY_KEY_BROWSER_HOME case .browserRefresh: GHOSTTY_KEY_BROWSER_REFRESH case .browserSearch: GHOSTTY_KEY_BROWSER_SEARCH case .browserStop: GHOSTTY_KEY_BROWSER_STOP case .eject: GHOSTTY_KEY_EJECT case .launchApp1: GHOSTTY_KEY_LAUNCH_APP_1 case .launchApp2: GHOSTTY_KEY_LAUNCH_APP_2 case .launchMail: GHOSTTY_KEY_LAUNCH_MAIL case .mediaPlayPause: GHOSTTY_KEY_MEDIA_PLAY_PAUSE case .mediaSelect: GHOSTTY_KEY_MEDIA_SELECT case .mediaStop: GHOSTTY_KEY_MEDIA_STOP case .mediaTrackNext: GHOSTTY_KEY_MEDIA_TRACK_NEXT case .mediaTrackPrevious: GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS case .power: GHOSTTY_KEY_POWER case .sleep: GHOSTTY_KEY_SLEEP case .audioVolumeDown: GHOSTTY_KEY_AUDIO_VOLUME_DOWN case .audioVolumeMute: GHOSTTY_KEY_AUDIO_VOLUME_MUTE case .audioVolumeUp: GHOSTTY_KEY_AUDIO_VOLUME_UP case .wakeUp: GHOSTTY_KEY_WAKE_UP // Legacy, Non-standard, and Special Keys case .copy: GHOSTTY_KEY_COPY case .cut: GHOSTTY_KEY_CUT case .paste: GHOSTTY_KEY_PASTE } } // Based on src/input/keycodes.zig var keyCode: UInt16? { switch self { // Writing System Keys case .backquote: return 0x0032 case .backslash: return 0x002a case .bracketLeft: return 0x0021 case .bracketRight: return 0x001e case .comma: return 0x002b case .digit0: return 0x001d case .digit1: return 0x0012 case .digit2: return 0x0013 case .digit3: return 0x0014 case .digit4: return 0x0015 case .digit5: return 0x0017 case .digit6: return 0x0016 case .digit7: return 0x001a case .digit8: return 0x001c case .digit9: return 0x0019 case .equal: return 0x0018 case .intlBackslash: return 0x000a case .intlRo: return 0x005e case .intlYen: return 0x005d case .a: return 0x0000 case .b: return 0x000b case .c: return 0x0008 case .d: return 0x0002 case .e: return 0x000e case .f: return 0x0003 case .g: return 0x0005 case .h: return 0x0004 case .i: return 0x0022 case .j: return 0x0026 case .k: return 0x0028 case .l: return 0x0025 case .m: return 0x002e case .n: return 0x002d case .o: return 0x001f case .p: return 0x0023 case .q: return 0x000c case .r: return 0x000f case .s: return 0x0001 case .t: return 0x0011 case .u: return 0x0020 case .v: return 0x0009 case .w: return 0x000d case .x: return 0x0007 case .y: return 0x0010 case .z: return 0x0006 case .minus: return 0x001b case .period: return 0x002f case .quote: return 0x0027 case .semicolon: return 0x0029 case .slash: return 0x002c // Functional Keys case .altLeft: return 0x003a case .altRight: return 0x003d case .backspace: return 0x0033 case .capsLock: return 0x0039 case .contextMenu: return 0x006e case .controlLeft: return 0x003b case .controlRight: return 0x003e case .enter: return 0x0024 case .metaLeft: return 0x0037 case .metaRight: return 0x0036 case .shiftLeft: return 0x0038 case .shiftRight: return 0x003c case .space: return 0x0031 case .tab: return 0x0030 case .convert: return nil // No Mac keycode case .kanaMode: return nil // No Mac keycode case .nonConvert: return nil // No Mac keycode // Control Pad Section case .delete: return 0x0075 case .end: return 0x0077 case .help: return nil // No Mac keycode case .home: return 0x0073 case .insert: return 0x0072 case .pageDown: return 0x0079 case .pageUp: return 0x0074 // Arrow Pad Section case .arrowDown: return 0x007d case .arrowLeft: return 0x007b case .arrowRight: return 0x007c case .arrowUp: return 0x007e // Numpad Section case .numLock: return 0x0047 case .numpad0: return 0x0052 case .numpad1: return 0x0053 case .numpad2: return 0x0054 case .numpad3: return 0x0055 case .numpad4: return 0x0056 case .numpad5: return 0x0057 case .numpad6: return 0x0058 case .numpad7: return 0x0059 case .numpad8: return 0x005b case .numpad9: return 0x005c case .numpadAdd: return 0x0045 case .numpadBackspace: return nil // No Mac keycode case .numpadClear: return nil // No Mac keycode case .numpadClearEntry: return nil // No Mac keycode case .numpadComma: return 0x005f case .numpadDecimal: return 0x0041 case .numpadDivide: return 0x004b case .numpadEnter: return 0x004c case .numpadEqual: return 0x0051 case .numpadMemoryAdd: return nil // No Mac keycode case .numpadMemoryClear: return nil // No Mac keycode case .numpadMemoryRecall: return nil // No Mac keycode case .numpadMemoryStore: return nil // No Mac keycode case .numpadMemorySubtract: return nil // No Mac keycode case .numpadMultiply: return 0x0043 case .numpadParenLeft: return nil // No Mac keycode case .numpadParenRight: return nil // No Mac keycode case .numpadSubtract: return 0x004e case .numpadSeparator: return nil // No Mac keycode case .numpadUp: return nil // No Mac keycode case .numpadDown: return nil // No Mac keycode case .numpadRight: return nil // No Mac keycode case .numpadLeft: return nil // No Mac keycode case .numpadBegin: return nil // No Mac keycode case .numpadHome: return nil // No Mac keycode case .numpadEnd: return nil // No Mac keycode case .numpadInsert: return nil // No Mac keycode case .numpadDelete: return nil // No Mac keycode case .numpadPageUp: return nil // No Mac keycode case .numpadPageDown: return nil // No Mac keycode // Function Section case .escape: return 0x0035 case .f1: return 0x007a case .f2: return 0x0078 case .f3: return 0x0063 case .f4: return 0x0076 case .f5: return 0x0060 case .f6: return 0x0061 case .f7: return 0x0062 case .f8: return 0x0064 case .f9: return 0x0065 case .f10: return 0x006d case .f11: return 0x0067 case .f12: return 0x006f case .f13: return 0x0069 case .f14: return 0x006b case .f15: return 0x0071 case .f16: return 0x006a case .f17: return 0x0040 case .f18: return 0x004f case .f19: return 0x0050 case .f20: return 0x005a case .f21: return nil // No Mac keycode case .f22: return nil // No Mac keycode case .f23: return nil // No Mac keycode case .f24: return nil // No Mac keycode case .f25: return nil // No Mac keycode case .fn: return nil // No Mac keycode case .fnLock: return nil // No Mac keycode case .printScreen: return nil // No Mac keycode case .scrollLock: return nil // No Mac keycode case .pause: return nil // No Mac keycode // Media Keys case .browserBack: return nil // No Mac keycode case .browserFavorites: return nil // No Mac keycode case .browserForward: return nil // No Mac keycode case .browserHome: return nil // No Mac keycode case .browserRefresh: return nil // No Mac keycode case .browserSearch: return nil // No Mac keycode case .browserStop: return nil // No Mac keycode case .eject: return nil // No Mac keycode case .launchApp1: return nil // No Mac keycode case .launchApp2: return nil // No Mac keycode case .launchMail: return nil // No Mac keycode case .mediaPlayPause: return nil // No Mac keycode case .mediaSelect: return nil // No Mac keycode case .mediaStop: return nil // No Mac keycode case .mediaTrackNext: return nil // No Mac keycode case .mediaTrackPrevious: return nil // No Mac keycode case .power: return nil // No Mac keycode case .sleep: return nil // No Mac keycode case .audioVolumeDown: return 0x0049 case .audioVolumeMute: return 0x004a case .audioVolumeUp: return 0x0048 case .wakeUp: return nil // No Mac keycode // Legacy, Non-standard, and Special Keys case .copy: return nil // No Mac keycode case .cut: return nil // No Mac keycode case .paste: return nil // No Mac keycode } } } } ================================================ FILE: ghostty/Sources/GhosttyKit/Ghostty.KeyEvent.swift ================================================ import Foundation import CGhostty extension Ghostty.Input { /// `ghostty_input_key_s` struct KeyEvent { let action: Action let key: Key let text: String? let composing: Bool let mods: Mods let consumedMods: Mods let unshiftedCodepoint: UInt32 init( key: Key, action: Action = .press, text: String? = nil, composing: Bool = false, mods: Mods = [], consumedMods: Mods = [], unshiftedCodepoint: UInt32 = 0 ) { self.key = key self.action = action self.text = text self.composing = composing self.mods = mods self.consumedMods = consumedMods self.unshiftedCodepoint = unshiftedCodepoint } init?(cValue: ghostty_input_key_s) { // Convert action switch cValue.action { case GHOSTTY_ACTION_PRESS: self.action = .press case GHOSTTY_ACTION_RELEASE: self.action = .release case GHOSTTY_ACTION_REPEAT: self.action = .repeat default: self.action = .press } // Convert key from keycode guard let key = Key(keyCode: UInt16(cValue.keycode)) else { return nil } self.key = key // Convert text if let textPtr = cValue.text { self.text = String(cString: textPtr) } else { self.text = nil } // Set composing state self.composing = cValue.composing // Convert modifiers self.mods = Mods(cMods: cValue.mods) self.consumedMods = Mods(cMods: cValue.consumed_mods) // Set unshifted codepoint self.unshiftedCodepoint = cValue.unshifted_codepoint } /// Executes a closure with a temporary C representation of this KeyEvent. /// /// This method safely converts the Swift KeyEntity to a C `ghostty_input_key_s` struct /// and passes it to the provided closure. The C struct is only valid within the closure's /// execution scope. The text field's C string pointer is managed automatically and will /// be invalid after the closure returns. /// /// - Parameter execute: A closure that receives the C struct and returns a value /// - Returns: The value returned by the closure @discardableResult func withCValue(execute: (ghostty_input_key_s) -> T) -> T { var keyEvent = ghostty_input_key_s() keyEvent.action = action.cAction keyEvent.keycode = UInt32(key.keyCode ?? 0) keyEvent.composing = composing keyEvent.mods = mods.cMods keyEvent.consumed_mods = consumedMods.cMods keyEvent.unshifted_codepoint = unshiftedCodepoint // Handle text with proper memory management if let text = text { return text.withCString { textPtr in keyEvent.text = textPtr return execute(keyEvent) } } else { keyEvent.text = nil return execute(keyEvent) } } } } // MARK: Ghostty.Input.Action extension Ghostty.Input { /// `ghostty_input_action_e` enum Action: String, CaseIterable { case release case press case `repeat` var cAction: ghostty_input_action_e { switch self { case .release: GHOSTTY_ACTION_RELEASE case .press: GHOSTTY_ACTION_PRESS case .repeat: GHOSTTY_ACTION_REPEAT } } } } ================================================ FILE: ghostty/Sources/GhosttyKit/Ghostty.Mods.swift ================================================ import AppKit import Foundation import CGhostty extension Ghostty.Input { /// `ghostty_input_mods_e` struct Mods: OptionSet { let rawValue: UInt32 static let none = Mods(rawValue: GHOSTTY_MODS_NONE.rawValue) static let shift = Mods(rawValue: GHOSTTY_MODS_SHIFT.rawValue) static let ctrl = Mods(rawValue: GHOSTTY_MODS_CTRL.rawValue) static let alt = Mods(rawValue: GHOSTTY_MODS_ALT.rawValue) static let `super` = Mods(rawValue: GHOSTTY_MODS_SUPER.rawValue) static let caps = Mods(rawValue: GHOSTTY_MODS_CAPS.rawValue) static let shiftRight = Mods(rawValue: GHOSTTY_MODS_SHIFT_RIGHT.rawValue) static let ctrlRight = Mods(rawValue: GHOSTTY_MODS_CTRL_RIGHT.rawValue) static let altRight = Mods(rawValue: GHOSTTY_MODS_ALT_RIGHT.rawValue) static let superRight = Mods(rawValue: GHOSTTY_MODS_SUPER_RIGHT.rawValue) var cMods: ghostty_input_mods_e { ghostty_input_mods_e(rawValue) } init(rawValue: UInt32) { self.rawValue = rawValue } init(cMods: ghostty_input_mods_e) { self.rawValue = cMods.rawValue } init(nsFlags: NSEvent.ModifierFlags) { self.init(cMods: Ghostty.ghosttyMods(nsFlags)) } var nsFlags: NSEvent.ModifierFlags { Ghostty.eventModifierFlags(mods: cMods) } } } // MARK: Ghostty.Input.ScrollMods extension Ghostty.Input { /// `ghostty_input_scroll_mods_t` - Scroll event modifiers /// /// This is a packed bitmask that contains precision and momentum information /// for scroll events, matching the Zig `ScrollMods` packed struct. struct ScrollMods { let rawValue: Int32 /// True if this is a high-precision scroll event (e.g., trackpad, Magic Mouse) var precision: Bool { rawValue & 0b0000_0001 != 0 } /// The momentum phase of the scroll event for inertial scrolling var momentum: Momentum { let momentumBits = (rawValue >> 1) & 0b0000_0111 return Momentum(rawValue: UInt8(momentumBits)) ?? .none } init(precision: Bool = false, momentum: Momentum = .none) { var value: Int32 = 0 if precision { value |= 0b0000_0001 } value |= Int32(momentum.rawValue) << 1 self.rawValue = value } init(rawValue: Int32) { self.rawValue = rawValue } var cScrollMods: ghostty_input_scroll_mods_t { rawValue } } } ================================================ FILE: ghostty/Sources/GhosttyKit/Ghostty.MouseEvent.swift ================================================ import AppKit import Foundation import CGhostty extension Ghostty.Input { /// Represents a mouse input event with button state, button type, and modifier keys. struct MouseButtonEvent { let action: MouseState let button: MouseButton let mods: Mods init( action: MouseState, button: MouseButton, mods: Mods = [] ) { self.action = action self.button = button self.mods = mods } /// Creates a MouseEvent from C enum values. /// /// This initializer converts C-style mouse input enums to Swift types. /// Returns nil if any of the C enum values are invalid or unsupported. /// /// - Parameters: /// - state: The mouse button state (press/release) /// - button: The mouse button that was pressed/released /// - mods: The modifier keys held during the mouse event init?(state: ghostty_input_mouse_state_e, button: ghostty_input_mouse_button_e, mods: ghostty_input_mods_e) { // Convert state switch state { case GHOSTTY_MOUSE_RELEASE: self.action = .release case GHOSTTY_MOUSE_PRESS: self.action = .press default: return nil } // Convert button switch button { case GHOSTTY_MOUSE_UNKNOWN: self.button = .unknown case GHOSTTY_MOUSE_LEFT: self.button = .left case GHOSTTY_MOUSE_RIGHT: self.button = .right case GHOSTTY_MOUSE_MIDDLE: self.button = .middle default: return nil } // Convert modifiers self.mods = Mods(cMods: mods) } } /// Represents a mouse position/movement event with coordinates and modifier keys. struct MousePosEvent { let x: Double let y: Double let mods: Mods init( x: Double, y: Double, mods: Mods = [] ) { self.x = x self.y = y self.mods = mods } } /// Represents a mouse scroll event with scroll deltas and modifier keys. struct MouseScrollEvent { let x: Double let y: Double let mods: ScrollMods init( x: Double, y: Double, mods: ScrollMods = .init(rawValue: 0) ) { self.x = x self.y = y self.mods = mods } } } // MARK: Ghostty.Input.MouseState extension Ghostty.Input { /// `ghostty_input_mouse_state_e` enum MouseState: String, CaseIterable { case release case press var cMouseState: ghostty_input_mouse_state_e { switch self { case .release: GHOSTTY_MOUSE_RELEASE case .press: GHOSTTY_MOUSE_PRESS } } } } // MARK: Ghostty.Input.MouseButton extension Ghostty.Input { /// `ghostty_input_mouse_button_e` enum MouseButton: String, CaseIterable { case unknown case left case right case middle var cMouseButton: ghostty_input_mouse_button_e { switch self { case .unknown: GHOSTTY_MOUSE_UNKNOWN case .left: GHOSTTY_MOUSE_LEFT case .right: GHOSTTY_MOUSE_RIGHT case .middle: GHOSTTY_MOUSE_MIDDLE } } } } // MARK: Ghostty.Input.Momentum extension Ghostty.Input { /// `ghostty_input_mouse_momentum_e` - Momentum phase for scroll events enum Momentum: UInt8, CaseIterable { case none = 0 case began = 1 case stationary = 2 case changed = 3 case ended = 4 case cancelled = 5 case mayBegin = 6 var cMomentum: ghostty_input_mouse_momentum_e { switch self { case .none: GHOSTTY_MOUSE_MOMENTUM_NONE case .began: GHOSTTY_MOUSE_MOMENTUM_BEGAN case .stationary: GHOSTTY_MOUSE_MOMENTUM_STATIONARY case .changed: GHOSTTY_MOUSE_MOMENTUM_CHANGED case .ended: GHOSTTY_MOUSE_MOMENTUM_ENDED case .cancelled: GHOSTTY_MOUSE_MOMENTUM_CANCELLED case .mayBegin: GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN } } } } extension Ghostty.Input.Momentum { /// Create a Momentum from an NSEvent.Phase init(_ phase: NSEvent.Phase) { switch phase { case .began: self = .began case .stationary: self = .stationary case .changed: self = .changed case .ended: self = .ended case .cancelled: self = .cancelled case .mayBegin: self = .mayBegin default: self = .none } } } ================================================ FILE: ghostty/Sources/GhosttyKit/Ghostty.Surface.swift ================================================ import Foundation import CGhostty extension Ghostty { /// Represents a single surface within Ghostty. /// /// Wraps a `ghostty_surface_t` final class Surface: @unchecked Sendable { private struct Handle: @unchecked Sendable { let value: ghostty_surface_t } private let handle: Handle private let userdataToRelease: UnsafeMutableRawPointer? /// Read the underlying C value for this surface. This is unsafe because the value will be /// freed when the Surface class is deinitialized. var unsafeCValue: ghostty_surface_t { handle.value } /// Initialize from the C structure. init(cSurface: ghostty_surface_t, userdataToRelease: UnsafeMutableRawPointer? = nil) { self.handle = Handle(value: cSurface) self.userdataToRelease = userdataToRelease } deinit { // deinit is not guaranteed to happen on the main actor and our API // calls into libghostty must happen there so we capture the surface // value so we don't capture `self` and then we detach it in a task. // We can't wait for the task to succeed so this will happen sometime // but that's okay. Task.detached { @MainActor [handle, userdataToRelease] in ghostty_surface_free(handle.value) if let userdataToRelease = userdataToRelease { Unmanaged.fromOpaque(userdataToRelease).release() } } } /// Send text to the terminal as if it was typed. This doesn't send the key events so keyboard /// shortcuts and other encodings do not take effect. @MainActor func sendText(_ text: String) { let len = text.utf8CString.count if (len == 0) { return } text.withCString { ptr in // len includes the null terminator so we do len - 1 ghostty_surface_text(handle.value, ptr, UInt(len - 1)) } } /// Send a key event to the terminal. /// /// This sends the full key event including modifiers, action type, and text to the terminal. /// Unlike `sendText`, this method processes keyboard shortcuts, key bindings, and terminal /// encoding based on the complete key event information. /// /// - Parameter event: The key event to send to the terminal @MainActor func sendKeyEvent(_ event: Input.KeyEvent) { event.withCValue { cEvent in ghostty_surface_key(handle.value, cEvent) } } /// Whether the terminal has captured mouse input. /// /// When the mouse is captured, the terminal application is receiving mouse events /// directly rather than the host system handling them. This typically occurs when /// a terminal application enables mouse reporting mode. @MainActor var mouseCaptured: Bool { ghostty_surface_mouse_captured(handle.value) } /// Whether closing this terminal requires user confirmation. /// /// Returns true if the terminal is busy (command running, cursor not at prompt). /// Uses Ghostty's internal prompt detection to avoid confirming idle shells. @MainActor var needsConfirmQuit: Bool { ghostty_surface_needs_confirm_quit(handle.value) } /// Send a mouse button event to the terminal. /// /// This sends a complete mouse button event including the button state (press/release), /// which button was pressed, and any modifier keys that were held during the event. /// The terminal processes this event according to its mouse handling configuration. /// /// - Parameter event: The mouse button event to send to the terminal @MainActor func sendMouseButton(_ event: Input.MouseButtonEvent) { ghostty_surface_mouse_button( handle.value, event.action.cMouseState, event.button.cMouseButton, event.mods.cMods) } /// Send a mouse position event to the terminal. /// /// This reports the current mouse position to the terminal, which may be used /// for mouse tracking, hover effects, or other position-dependent features. /// The terminal will only receive these events if mouse reporting is enabled. /// /// - Parameter event: The mouse position event to send to the terminal @MainActor func sendMousePos(_ event: Input.MousePosEvent) { ghostty_surface_mouse_pos( handle.value, event.x, event.y, event.mods.cMods) } /// Send a mouse scroll event to the terminal. /// /// This sends scroll wheel input to the terminal with delta values for both /// horizontal and vertical scrolling, along with precision and momentum information. /// The terminal processes this according to its scroll handling configuration. /// /// - Parameter event: The mouse scroll event to send to the terminal @MainActor func sendMouseScroll(_ event: Input.MouseScrollEvent) { ghostty_surface_mouse_scroll( handle.value, event.x, event.y, event.mods.cScrollMods) } /// Perform a keybinding action. /// /// The action can be any valid keybind parameter. e.g. `keybind = goto_tab:4` /// you can perform `goto_tab:4` with this. /// /// Returns true if the action was performed. Invalid actions return false. @MainActor func perform(action: String) -> Bool { let len = action.utf8CString.count if (len == 0) { return false } return action.withCString { cString in ghostty_surface_binding_action(handle.value, cString, UInt(len - 1)) } } /// Terminal grid size information struct TerminalSize { let columns: UInt16 let rows: UInt16 let widthPx: UInt32 let heightPx: UInt32 let cellWidthPx: UInt32 let cellHeightPx: UInt32 } /// Get current terminal size @MainActor func terminalSize() -> TerminalSize { let cSize = ghostty_surface_size(handle.value) return TerminalSize( columns: cSize.columns, rows: cSize.rows, widthPx: cSize.width_px, heightPx: cSize.height_px, cellWidthPx: cSize.cell_width_px, cellHeightPx: cSize.cell_height_px ) } } } ================================================ FILE: ghostty/Sources/GhosttyKit/GhosttyIMEHandler.swift ================================================ // // GhosttyIMEHandler.swift // CodMate // // Handles Input Method Editor (IME) support for Ghostty terminal // Enables proper input for Japanese, Chinese, Korean, etc. // // This file is adapted from Aizen (https://github.com/vivy-company/aizen) // which provided the initial Ghostty embedding implementation. // import AppKit import OSLog import CGhostty /// Manages IME (Input Method Editor) state and text input handling for Ghostty terminal @MainActor class GhosttyIMEHandler { // MARK: - Properties private weak var view: NSView? private weak var surface: Ghostty.Surface? /// Track marked text for IME composition private(set) var markedText: String = "" /// Attributes for displaying marked text private let markedTextAttributes: [NSAttributedString.Key: Any] = [ .underlineStyle: NSUnderlineStyle.single.rawValue, .underlineColor: NSColor.textColor ] /// Accumulates text from insertText calls during keyDown /// Set to non-nil during keyDown to track if IME inserted text private(set) var keyTextAccumulator: [String]? private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "ai.umate.codmate", category: "GhosttyIME") // MARK: - Initialization init(view: NSView, surface: Ghostty.Surface?) { self.view = view self.surface = surface } // MARK: - Public API /// Update surface reference func updateSurface(_ surface: Ghostty.Surface?) { self.surface = surface } /// Check if currently composing marked text var hasMarkedText: Bool { !markedText.isEmpty } /// Start accumulating text from insertText calls (call before interpretKeyEvents) func beginKeyTextAccumulation() { keyTextAccumulator = [] } /// End accumulation and return accumulated texts (call after interpretKeyEvents) func endKeyTextAccumulation() -> [String]? { defer { keyTextAccumulator = nil } return keyTextAccumulator } /// Clear marked text state func clearMarkedText() { if !markedText.isEmpty { markedText = "" view?.needsDisplay = true } } // MARK: - NSTextInputClient Methods func insertText(_ string: Any, replacementRange: NSRange) { guard let text = anyToString(string) else { return } // Clear any marked text when committing clearMarkedText() // If we're in a keyDown event (accumulator exists), accumulate the text // The keyDown handler will send it to the terminal if keyTextAccumulator != nil { keyTextAccumulator?.append(text) return } // Otherwise send directly to terminal (e.g., paste operation) surface?.sendText(text) } func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { guard let text = anyToString(string) else { return } // Update marked text state markedText = text // Tell system we've handled the marked text view?.inputContext?.invalidateCharacterCoordinates() view?.needsDisplay = true Self.logger.debug("IME marked text: \(text)") } func unmarkText() { // Commit any pending marked text if !markedText.isEmpty { surface?.sendText(markedText) markedText = "" view?.needsDisplay = true } } func selectedRange() -> NSRange { // Terminals don't have text selection in the traditional sense for IME return NSRange(location: NSNotFound, length: 0) } func markedRange() -> NSRange { // Return range of marked text if we have any if markedText.isEmpty { return NSRange(location: NSNotFound, length: 0) } return NSRange(location: 0, length: markedText.utf16.count) } func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { // Return attributed marked text for IME window guard !markedText.isEmpty else { return nil } let attributedString = NSAttributedString( string: markedText, attributes: markedTextAttributes ) if actualRange != nil { actualRange?.pointee = NSRange(location: 0, length: markedText.utf16.count) } return attributedString } func validAttributesForMarkedText() -> [NSAttributedString.Key] { return [ .underlineStyle, .underlineColor, .backgroundColor, .foregroundColor ] } func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?, viewFrame: NSRect, window: NSWindow?, surface: ghostty_surface_t?) -> NSRect { // Get cursor position from Ghostty for IME window placement guard let surface = surface else { return NSRect(x: viewFrame.origin.x, y: viewFrame.origin.y, width: 0, height: 0) } var x: Double = 0 var y: Double = 0 var width: Double = 0 var height: Double = 0 // Get IME cursor position from Ghostty ghostty_surface_ime_point(surface, &x, &y, &width, &height) // Ghostty coordinates are in top-left (0, 0) origin, but AppKit expects bottom-left // Convert Y coordinate by subtracting from frame height let viewRect = NSRect( x: x, y: viewFrame.size.height - y, width: range.length == 0 ? 0 : max(width, 1), height: max(height, 1) ) // Convert to window coordinates guard let view = view else { return viewRect } let windowRect = view.convert(viewRect, to: nil) // Convert to screen coordinates guard let window = window else { return windowRect } return window.convertToScreen(windowRect) } func characterIndex(for point: NSPoint) -> Int { return NSNotFound } // MARK: - Helper private func anyToString(_ string: Any) -> String? { switch string { case let string as NSString: return string as String case let string as NSAttributedString: return string.string default: return nil } } } ================================================ FILE: ghostty/Sources/GhosttyKit/GhosttyInputHandler.swift ================================================ // // GhosttyInputHandler.swift // CodMate // // Handles keyboard, mouse, and scroll input forwarding to Ghostty terminal // // This file is adapted from Aizen (https://github.com/vivy-company/aizen) // which provided the initial Ghostty embedding implementation. // import AppKit import OSLog import CGhostty /// Manages input event forwarding (keyboard, mouse, scroll) to Ghostty terminal @MainActor class GhosttyInputHandler { // MARK: - Properties private weak var view: NSView? private weak var surface: Ghostty.Surface? private weak var imeHandler: GhosttyIMEHandler? private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "ai.umate.codmate", category: "GhosttyInput") // MARK: - Initialization init(view: NSView, surface: Ghostty.Surface?, imeHandler: GhosttyIMEHandler) { self.view = view self.surface = surface self.imeHandler = imeHandler } // MARK: - Public API /// Update surface reference func updateSurface(_ surface: Ghostty.Surface?) { self.surface = surface } // MARK: - Keyboard Input func handleKeyDown(with event: NSEvent, interpretKeyEvents: @escaping ([NSEvent]) -> Void) { guard let surface = surface else { Self.logger.warning("keyDown: no surface") // Even without surface, call interpretKeyEvents for IME support interpretKeyEvents([event]) return } if let terminalView = view as? GhosttyTerminalView, terminalView.handlePasteCommandIfNeeded(event) { return } let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS // Track if we had marked text before this event // Important for handling ESC and backspace during IME composition let markedTextBefore = imeHandler?.hasMarkedText ?? false // Set up key text accumulator to track insertText calls imeHandler?.beginKeyTextAccumulation() defer { _ = imeHandler?.endKeyTextAccumulation() } // Call interpretKeyEvents to allow IME processing // This may call insertText (text committed) or setMarkedText (composing) interpretKeyEvents([event]) // If we have accumulated text, it means insertText was called // Send the composed text to the terminal if let texts = imeHandler?.endKeyTextAccumulation(), !texts.isEmpty { for text in texts { text.withCString { ptr in var keyEvent = event.ghosttyKeyEvent(action) keyEvent.text = ptr keyEvent.composing = false ghostty_surface_key(surface.unsafeCValue, keyEvent) } } return } // If we're still composing (have marked text), don't send key event // OR if we had marked text before and pressed a key like backspace/ESC, // we're still in composing mode let isComposing = (imeHandler?.hasMarkedText ?? false) || markedTextBefore if isComposing { // ESC or backspace during composition shouldn't be sent to terminal return } // Normal key event - no IME involvement // Call ghostty_surface_key directly (like Ghostty does) to avoid // potential issues with Swift wrapper conversions dropping events var keyEvent = event.ghosttyKeyEvent(action) // Set text field if we have printable characters // Control characters (< 0x20) are encoded by Ghostty itself if let chars = event.ghosttyCharacters, let codepoint = chars.utf8.first, codepoint >= 0x20 { chars.withCString { textPtr in keyEvent.text = textPtr keyEvent.composing = false ghostty_surface_key(surface.unsafeCValue, keyEvent) } } else { keyEvent.text = nil keyEvent.composing = false ghostty_surface_key(surface.unsafeCValue, keyEvent) } } func handleKeyUp(with event: NSEvent) { guard let surface = surface else { return } var keyEvent = event.ghosttyKeyEvent(GHOSTTY_ACTION_RELEASE) keyEvent.text = nil if let inputEvent = Ghostty.Input.KeyEvent(cValue: keyEvent) { surface.sendKeyEvent(inputEvent) } } func handleFlagsChanged(with event: NSEvent) { guard let surface = surface?.unsafeCValue else { return } // Determine which modifier key changed let mods = Ghostty.ghosttyMods(event.modifierFlags) let mod: UInt32 switch event.keyCode { case 0x39: mod = GHOSTTY_MODS_CAPS.rawValue case 0x38, 0x3C: mod = GHOSTTY_MODS_SHIFT.rawValue case 0x3B, 0x3E: mod = GHOSTTY_MODS_CTRL.rawValue case 0x3A, 0x3D: mod = GHOSTTY_MODS_ALT.rawValue case 0x37, 0x36: mod = GHOSTTY_MODS_SUPER.rawValue default: return } // Determine if press or release let action: ghostty_input_action_e = (mods.rawValue & mod != 0) ? GHOSTTY_ACTION_PRESS : GHOSTTY_ACTION_RELEASE // Send to Ghostty var keyEvent = event.ghosttyKeyEvent(action) keyEvent.text = nil ghostty_surface_key(surface, keyEvent) } // MARK: - Mouse Input func handleMouseDown(with event: NSEvent) { guard let surface = surface else { return } let mouseEvent = Ghostty.Input.MouseButtonEvent( action: .press, button: .left, mods: Ghostty.Input.Mods(nsFlags: event.modifierFlags) ) surface.sendMouseButton(mouseEvent) } func handleMouseUp(with event: NSEvent) { guard let surface = surface else { return } let mouseEvent = Ghostty.Input.MouseButtonEvent( action: .release, button: .left, mods: Ghostty.Input.Mods(nsFlags: event.modifierFlags) ) surface.sendMouseButton(mouseEvent) } func handleRightMouseDown(with event: NSEvent) { guard let surface = surface else { return } let mouseEvent = Ghostty.Input.MouseButtonEvent( action: .press, button: .right, mods: Ghostty.Input.Mods(nsFlags: event.modifierFlags) ) surface.sendMouseButton(mouseEvent) } func handleRightMouseUp(with event: NSEvent) { guard let surface = surface else { return } let mouseEvent = Ghostty.Input.MouseButtonEvent( action: .release, button: .right, mods: Ghostty.Input.Mods(nsFlags: event.modifierFlags) ) surface.sendMouseButton(mouseEvent) } func handleOtherMouseDown(with event: NSEvent) { guard event.buttonNumber == 2 else { return } guard let surface = surface else { return } let mouseEvent = Ghostty.Input.MouseButtonEvent( action: .press, button: .middle, mods: Ghostty.Input.Mods(nsFlags: event.modifierFlags) ) surface.sendMouseButton(mouseEvent) } func handleOtherMouseUp(with event: NSEvent) { guard event.buttonNumber == 2 else { return } guard let surface = surface else { return } let mouseEvent = Ghostty.Input.MouseButtonEvent( action: .release, button: .middle, mods: Ghostty.Input.Mods(nsFlags: event.modifierFlags) ) surface.sendMouseButton(mouseEvent) } func handleMouseMoved(with event: NSEvent, viewFrame: NSRect, convertPoint: (NSPoint, NSView?) -> NSPoint) { guard let surface = surface else { return } // Convert window coords to view coords // Ghostty expects top-left origin (y inverted from AppKit) let pos = convertPoint(event.locationInWindow, nil) let mouseEvent = Ghostty.Input.MousePosEvent( x: pos.x, y: viewFrame.height - pos.y, mods: Ghostty.Input.Mods(nsFlags: event.modifierFlags) ) surface.sendMousePos(mouseEvent) } func handleMouseEntered(with event: NSEvent, viewFrame: NSRect, convertPoint: (NSPoint, NSView?) -> NSPoint) { guard let surface = surface else { return } // Report mouse entering the viewport let pos = convertPoint(event.locationInWindow, nil) let mouseEvent = Ghostty.Input.MousePosEvent( x: pos.x, y: viewFrame.height - pos.y, mods: Ghostty.Input.Mods(nsFlags: event.modifierFlags) ) surface.sendMousePos(mouseEvent) } func handleMouseExited(with event: NSEvent) { guard let surface = surface else { return } // Negative values signal cursor left viewport let mouseEvent = Ghostty.Input.MousePosEvent( x: -1, y: -1, mods: Ghostty.Input.Mods(nsFlags: event.modifierFlags) ) surface.sendMousePos(mouseEvent) } // MARK: - Scroll Input func handleScrollWheel(with event: NSEvent) { guard let surface = surface else { return } var x = event.scrollingDeltaX var y = event.scrollingDeltaY let precision = event.hasPreciseScrollingDeltas if precision { // 2x speed multiplier for precise scrolling (trackpad) x *= 2 y *= 2 } let scrollEvent = Ghostty.Input.MouseScrollEvent( x: x, y: y, mods: Ghostty.Input.ScrollMods( precision: precision, momentum: Ghostty.Input.Momentum(event.momentumPhase) ) ) surface.sendMouseScroll(scrollEvent) } } // MARK: - NSEvent Extensions extension NSEvent { /// Create a Ghostty key event from NSEvent func ghosttyKeyEvent(_ action: ghostty_input_action_e) -> ghostty_input_key_s { var keyEvent = ghostty_input_key_s() keyEvent.action = action keyEvent.keycode = UInt32(keyCode) keyEvent.mods = Ghostty.ghosttyMods(modifierFlags) keyEvent.consumed_mods = Ghostty.ghosttyMods( modifierFlags.subtracting([.control, .command]) ) // Unshifted codepoint for key identification if type == .keyDown || type == .keyUp, let chars = characters(byApplyingModifiers: []), let codepoint = chars.unicodeScalars.first { keyEvent.unshifted_codepoint = codepoint.value } else { keyEvent.unshifted_codepoint = 0 } keyEvent.text = nil keyEvent.composing = false return keyEvent } /// Get characters appropriate for Ghostty (excluding control chars and PUA) var ghosttyCharacters: String? { guard let characters = characters else { return nil } if characters.count == 1, let scalar = characters.unicodeScalars.first { // Skip control characters (Ghostty handles internally) if scalar.value < 0x20 { return self.characters(byApplyingModifiers: modifierFlags.subtracting(.control)) } // Skip Private Use Area (function keys) if scalar.value >= 0xF700 && scalar.value <= 0xF8FF { return nil } } return characters } } ================================================ FILE: ghostty/Sources/GhosttyKit/GhosttyProgressState.swift ================================================ // // GhosttyProgressState.swift // CodMate // // This file is adapted from Aizen (https://github.com/vivy-company/aizen) // which provided the initial Ghostty embedding implementation. // import Foundation import CGhostty enum GhosttyProgressState { case remove case set case error case indeterminate case pause case unknown init(cState: ghostty_action_progress_report_state_e) { switch cState { case GHOSTTY_PROGRESS_STATE_REMOVE: self = .remove case GHOSTTY_PROGRESS_STATE_SET: self = .set case GHOSTTY_PROGRESS_STATE_ERROR: self = .error case GHOSTTY_PROGRESS_STATE_INDETERMINATE: self = .indeterminate case GHOSTTY_PROGRESS_STATE_PAUSE: self = .pause default: self = .unknown } } } ================================================ FILE: ghostty/Sources/GhosttyKit/GhosttyRenderingSetup.swift ================================================ // // GhosttyRenderingSetup.swift // CodMate // // Handles Metal layer setup and rendering configuration for Ghostty terminal // // This file is adapted from Aizen (https://github.com/vivy-company/aizen) // which provided the initial Ghostty embedding implementation. // import AppKit import Metal import OSLog import SwiftUI import CGhostty /// Manages Metal rendering setup and configuration for Ghostty terminal @MainActor class GhosttyRenderingSetup { nonisolated private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "ai.umate.codmate", category: "GhosttyRendering") // MARK: - Terminal Settings from AppStorage @AppStorage("terminal.fontName") private var terminalFontName = "Menlo" @AppStorage("terminal.fontSize") private var terminalFontSize = 12.0 @AppStorage("terminalBackgroundColor") private var terminalBackgroundColor = "#1e1e2e" @AppStorage("terminalForegroundColor") private var terminalForegroundColor = "#cdd6f4" @AppStorage("terminalCursorColor") private var terminalCursorColor = "#f5e0dc" @AppStorage("terminalSelectionBackground") private var terminalSelectionBackground = "#585b70" @AppStorage("terminalPalette") private var terminalPalette = "#45475a,#f38ba8,#a6e3a1,#f9e2af,#89b4fa,#f5c2e7,#94e2d5,#a6adc8,#585b70,#f37799,#89d88b,#ebd391,#74a8fc,#f2aede,#6bd7ca,#bac2de" @AppStorage("terminalSessionPersistence") private var sessionPersistence = false // MARK: - Layer Setup /// Configure the Metal-backed layer for terminal rendering /// /// CRITICAL: Must set layer property BEFORE setting wantsLayer = true /// This ensures Metal rendering works correctly func setupLayer(for view: NSView) { // Create Metal layer let metalLayer = CAMetalLayer() // CRITICAL: Validate Metal device availability guard let metalDevice = MTLCreateSystemDefaultDevice() else { Self.logger.error("FATAL: MTLCreateSystemDefaultDevice() returned nil - no Metal-compatible GPU available") fatalError("Cannot create Metal device - Metal support is required for Ghostty terminal") } metalLayer.device = metalDevice metalLayer.pixelFormat = .bgra8Unorm metalLayer.framebufferOnly = true metalLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0 // IMPORTANT: Set layer before wantsLayer for proper Metal initialization view.layer = metalLayer view.wantsLayer = true view.layerContentsRedrawPolicy = .duringViewResize Self.logger.debug("Metal layer configured successfully with device: \(metalDevice.name)") } // MARK: - Surface Setup /// Create and configure the Ghostty surface func setupSurface( view: NSView, ghosttyApp: ghostty_app_t, worktreePath: String, initialBounds: NSRect, window: NSWindow?, paneId: String? = nil, command: String? = nil, userdata: UnsafeMutableRawPointer? = nil ) -> ghostty_surface_t? { // Validate working directory exists and is accessible var isDirectory: ObjCBool = false if !FileManager.default.fileExists(atPath: worktreePath, isDirectory: &isDirectory) { Self.logger.error("Working directory does not exist: \(worktreePath)") return nil } if !isDirectory.boolValue { Self.logger.error("Working directory is not a directory: \(worktreePath)") return nil } if !FileManager.default.isReadableFile(atPath: worktreePath) { Self.logger.error("Working directory is not readable: \(worktreePath)") return nil } Self.logger.info("Creating Ghostty surface with working directory: \(worktreePath)") // Configure surface with working directory var surfaceConfig = ghostty_surface_config_new() // CRITICAL: Set platform information surfaceConfig.platform_tag = GHOSTTY_PLATFORM_MACOS surfaceConfig.platform.macos.nsview = Unmanaged.passUnretained(view).toOpaque() // Set userdata (defaults to view if none provided) surfaceConfig.userdata = userdata ?? Unmanaged.passUnretained(view).toOpaque() // Set scale factor for retina displays surfaceConfig.scale_factor = Double(window?.backingScaleFactor ?? 2.0) // Set font size from Ghostty settings surfaceConfig.font_size = Float(terminalFontSize) // Set working directory var workingDirPtr: UnsafeMutablePointer? if let workingDir = strdup(worktreePath) { workingDirPtr = workingDir surfaceConfig.working_directory = UnsafePointer(workingDir) } // NOTE: We do NOT use initial_input here. // initial_input is processed before shell startup, causing commands to be echoed twice: // 1. Pre-shell echo (as terminal is in echo mode before shell takes over) // 2. Shell execution display (after prompt) // Instead, GhosttyTerminalView sends commands via sendText() after shell startup delay. _ = command // Silence unused variable warning; command is passed but handled by caller defer { if let wd = workingDirPtr { free(wd) } } // Create the surface // NOTE: subprocess spawns during ghostty_surface_new, so size warnings may appear // if view frame isn't set yet - this is unavoidable with current API guard let cSurface = ghostty_surface_new(ghosttyApp, &surfaceConfig) else { Self.logger.error("ghostty_surface_new failed") return nil } // Immediately set size after creation to minimize "small grid" warnings let scaledSize = view.convertToBacking(initialBounds.size.width > 0 ? initialBounds.size : NSSize(width: 800, height: 600)) ghostty_surface_set_size( cSurface, UInt32(scaledSize.width), UInt32(scaledSize.height) ) // Set content scale for retina displays let scale = window?.backingScaleFactor ?? 1.0 ghostty_surface_set_content_scale(cSurface, scale, scale) Self.logger.info("Ghostty surface created at: \(worktreePath)") return cSurface } // MARK: - Appearance Observation /// Setup observation for system appearance changes (light/dark mode) /// Implementation copied from Ghostty's SurfaceView_AppKit.swift func setupAppearanceObservation(for view: NSView, surface: Ghostty.Surface?) -> NSKeyValueObservation? { return view.observe(\.effectiveAppearance, options: [.new, .initial]) { view, change in guard let appearance = change.newValue else { return } guard let surface = surface?.unsafeCValue else { return } let scheme: ghostty_color_scheme_e switch (appearance.name) { case .aqua, .vibrantLight: scheme = GHOSTTY_COLOR_SCHEME_LIGHT case .darkAqua, .vibrantDark: scheme = GHOSTTY_COLOR_SCHEME_DARK default: scheme = GHOSTTY_COLOR_SCHEME_DARK } ghostty_surface_set_color_scheme(surface, scheme) Self.logger.debug("Color scheme updated to: \(scheme == GHOSTTY_COLOR_SCHEME_DARK ? "dark" : "light")") } } // MARK: - Scale and Size Updates /// Update Metal layer content scale and surface scale factors func updateBackingProperties(view: NSView, surface: ghostty_surface_t?, window: NSWindow?) { guard let surface = surface else { return } // Update Metal layer content scale if let window = window { CATransaction.begin() CATransaction.setDisableActions(true) view.layer?.contentsScale = window.backingScaleFactor CATransaction.commit() } // Update surface scale factors let fbFrame = view.convertToBacking(view.frame) let xScale = fbFrame.size.width / view.frame.size.width let yScale = fbFrame.size.height / view.frame.size.height ghostty_surface_set_content_scale(surface, xScale, yScale) // Update surface size (framebuffer dimensions changed) ghostty_surface_set_size( surface, UInt32(fbFrame.size.width), UInt32(fbFrame.size.height) ) } /// Update Metal layer frame and Ghostty surface size func updateLayout(view: NSView, metalLayer: CAMetalLayer?, surface: ghostty_surface_t?, lastSize: inout CGSize) -> Bool { // Update Metal layer frame to match view bounds if let metalLayer = metalLayer { metalLayer.frame = view.bounds } // Update Ghostty surface size during layout pass // Only update if backing pixel size actually changed to prevent flicker guard let surface = surface else { return false } guard view.bounds.width > 0 && view.bounds.height > 0 else { return false } var scaledSize = view.convertToBacking(view.bounds.size) scaledSize = snapSizeToCell(surface: surface, scaledSize: scaledSize) // Only update if size changed by at least 1 pixel let widthChanged = abs(scaledSize.width - lastSize.width) >= 1.0 let heightChanged = abs(scaledSize.height - lastSize.height) >= 1.0 guard widthChanged || heightChanged else { return false } lastSize = scaledSize if let metalLayer = metalLayer { metalLayer.drawableSize = scaledSize } ghostty_surface_set_size( surface, UInt32(scaledSize.width), UInt32(scaledSize.height) ) ghostty_surface_refresh(surface) return true } /// Snap the desired pixel size down to the nearest full terminal cell to avoid partial-cell artifacts. /// Snap size to whole pixels (scaledSize is already in pixel units). func snapSizeToCell(surface: ghostty_surface_t, scaledSize: CGSize) -> CGSize { CGSize(width: floor(scaledSize.width), height: floor(scaledSize.height)) } } ================================================ FILE: ghostty/Sources/GhosttyKit/GhosttyTerminalView.swift ================================================ // // GhosttyTerminalView.swift // CodMate // // NSView subclass that integrates Ghostty terminal rendering // // This file is adapted from Aizen (https://github.com/vivy-company/aizen) // which provided the initial Ghostty embedding implementation. // import AppKit import Metal import OSLog import SwiftUI import CGhostty /// NSView that embeds a Ghostty terminal surface with Metal rendering /// /// This view handles: /// - Metal layer setup for terminal rendering /// - Input forwarding (keyboard, mouse, scroll) /// - Focus management /// - Surface lifecycle management @MainActor public class GhosttyTerminalView: NSView { // MARK: - Properties private var ghosttyApp: ghostty_app_t? private weak var ghosttyAppWrapper: Ghostty.App? internal var surface: Ghostty.Surface? private var surfaceReference: Ghostty.SurfaceReference? private var surfaceUserdata: Ghostty.SurfaceUserdata? private let worktreePath: String private let paneId: String? private let initialCommand: String? /// Callback invoked when the terminal process exits var onProcessExit: (() -> Void)? /// Callback invoked when the terminal title changes var onTitleChange: ((String) -> Void)? /// Callback when the surface has produced its first layout/draw (used to hide loading UI) public var onReady: (() -> Void)? /// Callback for OSC 9;4 progress reports var onProgressReport: ((GhosttyProgressState, Int?) -> Void)? private var didSignalReady = false /// Cell size in points for row-to-pixel conversion (used by scroll view) var cellSize: NSSize = .zero /// Current scrollbar state from Ghostty core (used by scroll view) var scrollbar: Ghostty.Action.Scrollbar? private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "ai.umate.codmate", category: "GhosttyTerminal") // MARK: - Handler Components private var imeHandler: GhosttyIMEHandler! private var inputHandler: GhosttyInputHandler! private let renderingSetup = GhosttyRenderingSetup() /// Observation for appearance changes private var appearanceObservation: NSKeyValueObservation? // MARK: - Initialization /// Create a new Ghostty terminal view /// /// - Parameters: /// - frame: The initial frame for the view /// - worktreePath: Working directory for the terminal session /// - ghosttyApp: The shared Ghostty app instance (C pointer) /// - appWrapper: The Ghostty.App wrapper for surface tracking (optional) /// - paneId: Unique identifier for this pane (used for tmux session persistence) /// - command: Optional command to run instead of default shell public init(frame: NSRect, worktreePath: String, ghosttyApp: ghostty_app_t, appWrapper: Ghostty.App? = nil, paneId: String? = nil, command: String? = nil) { NSLog("[GhosttyTerminalView] init called with worktreePath: %@", worktreePath) self.worktreePath = worktreePath self.ghosttyApp = ghosttyApp self.ghosttyAppWrapper = appWrapper self.paneId = paneId self.initialCommand = command // Use a reasonable default size if frame is zero let initialFrame = frame.width > 0 && frame.height > 0 ? frame : NSRect(x: 0, y: 0, width: 800, height: 600) NSLog("[GhosttyTerminalView] super.init with frame: %@", NSStringFromRect(initialFrame)) super.init(frame: initialFrame) registerForDraggedTypes([.fileURL, .tiff, .png]) // Initialize handlers before setup NSLog("[GhosttyTerminalView] creating IME handler") self.imeHandler = GhosttyIMEHandler(view: self, surface: nil) NSLog("[GhosttyTerminalView] creating input handler") self.inputHandler = GhosttyInputHandler(view: self, surface: nil, imeHandler: self.imeHandler) NSLog("[GhosttyTerminalView] setupLayer") setupLayer() NSLog("[GhosttyTerminalView] setupSurface") setupSurface() NSLog("[GhosttyTerminalView] setupTrackingArea") setupTrackingArea() NSLog("[GhosttyTerminalView] setupAppearanceObservation") setupAppearanceObservation() NSLog("[GhosttyTerminalView] setupFrameObservation") setupFrameObservation() // Send initial command after shell startup delay // This avoids the double-echo issue caused by initial_input if let command = initialCommand, !command.isEmpty { scheduleInitialCommand(command) } NSLog("[GhosttyTerminalView] init complete") } required init?(coder: NSCoder) { fatalError("init(coder:) not supported") } deinit { NSLog("[GhosttyTerminalView] deinit called - view being deallocated") // Surface cleanup happens via Surface's deinit // Note: Cannot access @MainActor properties in deinit // Tracking areas are automatically cleaned up by NSView // Appearance observation is automatically invalidated NotificationCenter.default.removeObserver(self) // Surface reference cleanup needs to happen on main actor // We capture the values before the Task to avoid capturing self let wrapper = self.ghosttyAppWrapper let ref = self.surfaceReference if let wrapper = wrapper, let ref = ref { Task { @MainActor in NSLog("[GhosttyTerminalView] deinit: unregistering surface") wrapper.unregisterSurface(ref) } } } // MARK: - Setup /// Configure the Metal-backed layer for terminal rendering private func setupLayer() { renderingSetup.setupLayer(for: self) } /// Create and configure the Ghostty surface private func setupSurface() { guard let app = ghosttyApp else { Self.logger.error("Cannot create surface: ghostty_app_t is nil") return } let surfaceUserdata = Ghostty.SurfaceUserdata(view: self) let surfaceUserdataPointer = Unmanaged.passRetained(surfaceUserdata).toOpaque() guard let cSurface = renderingSetup.setupSurface( view: self, ghosttyApp: app, worktreePath: worktreePath, initialBounds: bounds, window: window, paneId: paneId, command: initialCommand, userdata: surfaceUserdataPointer ) else { Unmanaged.fromOpaque(surfaceUserdataPointer).release() return } // Wrap in Swift Surface class self.surface = Ghostty.Surface(cSurface: cSurface, userdataToRelease: surfaceUserdataPointer) self.surfaceUserdata = surfaceUserdata // Update handlers with surface imeHandler.updateSurface(self.surface) inputHandler.updateSurface(self.surface) // Register surface with app wrapper for config update tracking if let wrapper = ghosttyAppWrapper { self.surfaceReference = wrapper.registerSurface(cSurface) } } /// Setup mouse tracking area for the entire view private func setupTrackingArea() { let options: NSTrackingArea.Options = [ .mouseEnteredAndExited, .mouseMoved, .inVisibleRect, .activeAlways // Track even when not focused ] let trackingArea = NSTrackingArea( rect: bounds, options: options, owner: self, userInfo: nil ) addTrackingArea(trackingArea) } /// Setup observation for system appearance changes (light/dark mode) private func setupAppearanceObservation() { appearanceObservation = renderingSetup.setupAppearanceObservation(for: self, surface: surface) } private func setupFrameObservation() { // We rely on layout() + updateLayout to resize the surface. self.postsFrameChangedNotifications = false // Listen for config reload notifications to trigger reflow on font size changes NotificationCenter.default.addObserver( self, selector: #selector(handleConfigReload), name: .ghosttyConfigDidReload, object: nil ) } @objc private func handleConfigReload() { // Force layout update when font size or cursor style changes // This ensures the surface size is recalculated with new settings lastSurfaceSize = .zero needsLayout = true layout() // Also force a refresh to ensure the surface is redrawn forceRefresh() } // MARK: - Initial Command /// Flag to track if initial command has been sent (prevents duplicate sends) private var initialCommandSent = false /// Schedule the initial command to be sent after shell startup /// Uses a delay to ensure shell has time to initialize and display its prompt private func scheduleInitialCommand(_ command: String) { // Use a delay to wait for shell startup // 300ms is typically enough for shell to initialize and display prompt Task { @MainActor [weak self] in try? await Task.sleep(nanoseconds: 300_000_000) // 300ms self?.sendInitialCommandIfNeeded(command) } } /// Send the initial command if not already sent private func sendInitialCommandIfNeeded(_ command: String) { guard !initialCommandSent else { NSLog("[GhosttyTerminalView] initial command already sent, skipping") return } guard let surface = surface else { NSLog("[GhosttyTerminalView] surface is nil, cannot send initial command") return } initialCommandSent = true // Normalize command: ensure it ends with exactly one newline var normalizedCommand = command while normalizedCommand.hasSuffix("\n") || normalizedCommand.hasSuffix("\r") { normalizedCommand.removeLast() } guard !normalizedCommand.isEmpty else { NSLog("[GhosttyTerminalView] normalized command is empty, skipping") return } NSLog("[GhosttyTerminalView] sending initial command: %@", normalizedCommand) surface.sendText(normalizedCommand + "\n") } // MARK: - NSView Overrides public override var acceptsFirstResponder: Bool { return true } public override func becomeFirstResponder() -> Bool { let result = super.becomeFirstResponder() if result, let surface = surface?.unsafeCValue { ghostty_surface_set_focus(surface, true) } return result } public override func resignFirstResponder() -> Bool { let result = super.resignFirstResponder() if result, let surface = surface?.unsafeCValue { ghostty_surface_set_focus(surface, false) } return result } public override func updateTrackingAreas() { super.updateTrackingAreas() // Remove old tracking areas trackingAreas.forEach { removeTrackingArea($0) } // Recreate with current bounds setupTrackingArea() } public override func viewDidChangeBackingProperties() { super.viewDidChangeBackingProperties() renderingSetup.updateBackingProperties(view: self, surface: surface?.unsafeCValue, window: window) } public override func viewDidMoveToWindow() { super.viewDidMoveToWindow() // Single refresh when view moves to window if window != nil { DispatchQueue.main.async { [weak self] in self?.forceRefresh() } } } // Track last size sent to Ghostty to avoid redundant updates private var lastSurfaceSize: CGSize = .zero // Override safe area insets to use full available space, including rounded corners // This matches Ghostty's SurfaceScrollView implementation public override var safeAreaInsets: NSEdgeInsets { return NSEdgeInsetsZero } public override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(newSize) // Force layout to be called to fix up subviews // This matches Ghostty's SurfaceScrollView.setFrameSize needsLayout = true } public override func layout() { super.layout() let didUpdate = renderingSetup.updateLayout( view: self, metalLayer: layer as? CAMetalLayer, surface: surface?.unsafeCValue, lastSize: &lastSurfaceSize ) if didUpdate { NSLog("[GhosttyTerminalView] layout: size updated to %@", NSStringFromSize(bounds.size)) if !didSignalReady { didSignalReady = true NSLog("[GhosttyTerminalView] layout: signaling onReady") onReady?() } } } // MARK: - Keyboard Input public override func keyDown(with event: NSEvent) { inputHandler.handleKeyDown(with: event) { [weak self] events in self?.interpretKeyEvents(events) } } public override func keyUp(with event: NSEvent) { inputHandler.handleKeyUp(with: event) } public override func flagsChanged(with event: NSEvent) { inputHandler.handleFlagsChanged(with: event) } public override func doCommand(by selector: Selector) { // Override to suppress NSBeep when interpretKeyEvents encounters unhandled commands // Without this, keys like delete at beginning of line, cmd+c with no selection, etc. cause beeps // Terminal handles all input via Ghostty, so we silently ignore unhandled commands } @objc func paste(_ sender: Any?) { if handlePasteboardAttachments(NSPasteboard.general, appendTrailingSpace: false) { return } if let surface = surface, surface.perform(action: "paste") { return } if let text = NSPasteboard.general.string(forType: .string), !text.isEmpty { surface?.sendText(text) } } public override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { let pasteboard = sender.draggingPasteboard if canHandlePasteboard(pasteboard) { return .copy } return [] } public override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { return handlePasteboardAttachments(sender.draggingPasteboard, appendTrailingSpace: true) } // MARK: - Mouse Input public override func mouseDown(with event: NSEvent) { inputHandler.handleMouseDown(with: event) } public override func mouseUp(with event: NSEvent) { inputHandler.handleMouseUp(with: event) } public override func rightMouseDown(with event: NSEvent) { inputHandler.handleRightMouseDown(with: event) } public override func rightMouseUp(with event: NSEvent) { inputHandler.handleRightMouseUp(with: event) } public override func otherMouseDown(with event: NSEvent) { inputHandler.handleOtherMouseDown(with: event) } public override func otherMouseUp(with event: NSEvent) { inputHandler.handleOtherMouseUp(with: event) } public override func mouseMoved(with event: NSEvent) { inputHandler.handleMouseMoved(with: event, viewFrame: frame) { [weak self] point, view in self?.convert(point, from: view) ?? .zero } } public override func mouseDragged(with event: NSEvent) { mouseMoved(with: event) } public override func rightMouseDragged(with event: NSEvent) { mouseMoved(with: event) } public override func otherMouseDragged(with event: NSEvent) { mouseMoved(with: event) } public override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) inputHandler.handleMouseEntered(with: event, viewFrame: frame) { [weak self] point, view in self?.convert(point, from: view) ?? .zero } } public override func mouseExited(with event: NSEvent) { inputHandler.handleMouseExited(with: event) } public override func scrollWheel(with event: NSEvent) { inputHandler.handleScrollWheel(with: event) } // MARK: - Process Lifecycle /// Check if the terminal process has exited var processExited: Bool { guard let surface = surface?.unsafeCValue else { return true } return ghostty_surface_process_exited(surface) } /// Check if closing this terminal needs confirmation public var needsConfirmQuit: Bool { guard let surface = surface else { return false } return surface.needsConfirmQuit } /// Get current terminal grid size func terminalSize() -> Ghostty.Surface.TerminalSize? { guard let surface = surface else { return nil } return surface.terminalSize() } /// Send text to the terminal as if typed (used for initial command injection). @MainActor public func sendText(_ text: String) { surface?.sendText(text) } /// Force the terminal surface to refresh/redraw /// Useful after tmux reattaches or when view becomes visible func forceRefresh() { guard let surface = surface?.unsafeCValue else { return } // Force a size update to trigger tmux redraw let scaledSize = convertToBacking(bounds.size) ghostty_surface_set_size( surface, UInt32(scaledSize.width), UInt32(scaledSize.height) ) ghostty_surface_refresh(surface) ghostty_surface_draw(surface) // Trigger app tick to process any pending updates ghosttyAppWrapper?.appTick() // Force Metal layer to redraw if let metalLayer = layer as? CAMetalLayer { metalLayer.setNeedsDisplay() } layer?.setNeedsDisplay() needsDisplay = true needsLayout = true displayIfNeeded() } // MARK: - Paste / Drop Helpers @MainActor func handlePasteCommandIfNeeded(_ event: NSEvent) -> Bool { guard event.modifierFlags.contains(.command), event.charactersIgnoringModifiers?.lowercased() == "v" else { return false } paste(nil) return true } @MainActor func handlePasteboardAttachments(_ pasteboard: NSPasteboard, appendTrailingSpace: Bool) -> Bool { if let urls = extractFileURLs(from: pasteboard), !urls.isEmpty { pasteFileURLs(urls, appendTrailingSpace: appendTrailingSpace) return true } if let image = NSImage(pasteboard: pasteboard), let url = writeImageToTemp(image) { pasteFileURLs([url], appendTrailingSpace: appendTrailingSpace) return true } return false } @MainActor func canHandlePasteboard(_ pasteboard: NSPasteboard) -> Bool { if let urls = extractFileURLs(from: pasteboard), !urls.isEmpty { return true } if NSImage(pasteboard: pasteboard) != nil { return true } return false } private func extractFileURLs(from pasteboard: NSPasteboard) -> [URL]? { guard let objects = pasteboard.readObjects(forClasses: [NSURL.self], options: [ .urlReadingFileURLsOnly: true ]) as? [URL] else { return nil } return objects.filter { $0.isFileURL } } @MainActor private func pasteFileURLs(_ urls: [URL], appendTrailingSpace: Bool) { let escaped = urls.map { shellEscapeForPaste($0.path) } guard !escaped.isEmpty else { return } var text = escaped.joined(separator: " ") if appendTrailingSpace { text += " " } surface?.sendText(text) } private func shellEscapeForPaste(_ path: String) -> String { let escaped = path.replacingOccurrences(of: "'", with: "'\\''") return "'" + escaped + "'" } private func writeImageToTemp(_ image: NSImage) -> URL? { guard let data = image.pngData() else { return nil } let dir = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("codmate-ghostty", isDirectory: true) do { try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) } catch { return nil } let name = "paste-\(Int(Date().timeIntervalSince1970))-\(UUID().uuidString.prefix(6)).png" let url = dir.appendingPathComponent(name) do { try data.write(to: url, options: .atomic) return url } catch { return nil } } } private extension NSImage { func pngData() -> Data? { guard let tiff = tiffRepresentation, let rep = NSBitmapImageRep(data: tiff) else { return nil } return rep.representation(using: .png, properties: [:]) } } // MARK: - NSTextInputClient Implementation /// NSTextInputClient protocol conformance for IME (Input Method Editor) support /// Use @preconcurrency to suppress Swift 6 actor isolation warnings since NSTextInputClient /// is an Objective-C protocol that predates Swift concurrency extension GhosttyTerminalView: @preconcurrency NSTextInputClient { public func insertText(_ string: Any, replacementRange: NSRange) { imeHandler.insertText(string, replacementRange: replacementRange) } public func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { imeHandler.setMarkedText(string, selectedRange: selectedRange, replacementRange: replacementRange) } public func unmarkText() { imeHandler.unmarkText() } public func selectedRange() -> NSRange { return imeHandler.selectedRange() } public func markedRange() -> NSRange { return imeHandler.markedRange() } public func hasMarkedText() -> Bool { return imeHandler.hasMarkedText } public func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { return imeHandler.attributedSubstring(forProposedRange: range, actualRange: actualRange) } public func validAttributesForMarkedText() -> [NSAttributedString.Key] { return imeHandler.validAttributesForMarkedText() } public func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { return imeHandler.firstRect( forCharacterRange: range, actualRange: actualRange, viewFrame: frame, window: window, surface: surface?.unsafeCValue ) } public func characterIndex(for point: NSPoint) -> Int { return imeHandler.characterIndex(for: point) } } ================================================ FILE: ghostty/Sources/GhosttyKit/GhosttyThemeLoader.swift ================================================ import Foundation import os.log /// Utility for loading and managing Ghostty terminal themes @MainActor public struct GhosttyThemeLoader { private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "ai.umate.codmate", category: "GhosttyThemeLoader") /// Curated list of popular/well-known themes to show in the picker /// Users can still use any theme by typing its name, but this list provides quick access public static let curatedThemeNames: [String] = [ // Default/System themes "Xcode Dark", "Xcode Light", "Dark", "Light", // Popular color schemes "Atom One Dark", "Atom One Light", "Nord", "Nord Light", "Dracula", "Monokai Pro", "Monokai Pro Light", "Solarized Dark Higher Contrast", "Solarized Light", "Gruvbox Dark", "Gruvbox Light", "One Dark", "One Light", ] /// Load all available theme names from the Package resources public static func loadAvailableThemes() -> [String] { guard let themesURL = Bundle.module.url(forResource: "themes", withExtension: nil, subdirectory: nil) else { logger.warning("Ghostty themes resource not found") return curatedThemeNames } let themesPath = themesURL.path let fm = FileManager.default guard let themeFiles = try? fm.contentsOfDirectory(atPath: themesPath) else { logger.warning("Unable to read themes from \(themesPath)") return curatedThemeNames } // Filter out directories and hidden files, sort alphabetically let availableThemes = themeFiles.filter { file in let path = (themesPath as NSString).appendingPathComponent(file) var isDir: ObjCBool = false guard fm.fileExists(atPath: path, isDirectory: &isDir) else { return false } return !isDir.boolValue && !file.hasPrefix(".") }.sorted() // Return curated themes that exist, plus any additional themes // Prioritize curated themes at the top var result: [String] = [] var seen = Set() // Add curated themes first (if they exist) for theme in curatedThemeNames { if availableThemes.contains(theme) { result.append(theme) seen.insert(theme) } } // Add a separator if we have both curated and other themes if !result.isEmpty && result.count < availableThemes.count { // Add other popular themes that aren't in curated list let additionalPopular = availableThemes.filter { theme in !seen.contains(theme) && ( theme.contains("Dark") || theme.contains("Light") || theme.contains("Nord") || theme.contains("Dracula") || theme.contains("Monokai") || theme.contains("Solarized") || theme.contains("Gruvbox") || theme.contains("Atom") ) } if !additionalPopular.isEmpty { result.append(contentsOf: additionalPopular.sorted()) additionalPopular.forEach { seen.insert($0) } } } logger.info("Loaded \(result.count) themes (curated: \(curatedThemeNames.count), total available: \(availableThemes.count))") return result } /// Check if a theme exists public static func themeExists(_ themeName: String) -> Bool { guard let themesURL = Bundle.module.url(forResource: "themes", withExtension: nil, subdirectory: nil) else { return false } let themePath = (themesURL.path as NSString).appendingPathComponent(themeName) return FileManager.default.fileExists(atPath: themePath) } } ================================================ FILE: ghostty/Sources/GhosttyKit/TerminalScrollView.swift ================================================ // // TerminalScrollView.swift // CodMate // // NSScrollView wrapper for terminal with native macOS scrollbar support. // Adapted from Ghostty's SurfaceScrollView.swift // // This file is adapted from Aizen (https://github.com/vivy-company/aizen) // which provided the initial Ghostty embedding implementation. // import AppKit import Combine import CGhostty /// Wraps a Ghostty terminal view in an NSScrollView to provide native macOS scrollbar support. /// /// ## Coordinate System /// AppKit uses a +Y-up coordinate system (origin at bottom-left), while terminals conceptually /// use +Y-down (row 0 at top). This class handles the inversion when converting between row /// offsets and pixel positions. /// /// ## Architecture /// - `scrollView`: The outermost NSScrollView that manages scrollbar rendering and behavior /// - `documentView`: A blank NSView whose height represents total scrollback (in pixels) /// - `surfaceView`: The actual Ghostty terminal renderer, positioned to fill the visible rect public class TerminalScrollView: NSView { private let scrollView: NSScrollView private let documentView: NSView public let surfaceView: GhosttyTerminalView private var observers: [NSObjectProtocol] = [] private var isLiveScrolling = false /// The last row position sent via scroll_to_row action. Used to avoid /// sending redundant actions when the user drags the scrollbar but stays /// on the same row. private var lastSentRow: Int? public init(contentSize: CGSize, surfaceView: GhosttyTerminalView) { self.surfaceView = surfaceView // The scroll view is our outermost view that controls all our scrollbar // rendering and behavior. scrollView = NSScrollView() scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = false scrollView.autohidesScrollers = false scrollView.usesPredominantAxisScrolling = true // Always use the overlay style. See mouseMoved for how we make // it usable without a scroll wheel or gestures. scrollView.scrollerStyle = .overlay // hide default background to show blur effect properly scrollView.drawsBackground = false // don't let the content view clip its subviews scrollView.contentView.clipsToBounds = false // The document view is what the scrollview is actually going // to be directly scrolling. We set it up to a "blank" NSView // with the desired content size. documentView = NSView(frame: NSRect(origin: .zero, size: contentSize)) scrollView.documentView = documentView // The document view contains our actual surface as a child. documentView.addSubview(surfaceView) super.init(frame: .zero) registerForDraggedTypes([.fileURL, .tiff, .png]) // Our scroll view is our only view addSubview(scrollView) // Apply initial scrollbar settings synchronizeAppearance() // We listen for scroll events through bounds notifications on our NSClipView. scrollView.contentView.postsBoundsChangedNotifications = true observers.append(NotificationCenter.default.addObserver( forName: NSView.boundsDidChangeNotification, object: scrollView.contentView, queue: .main ) { [weak self] notification in self?.handleScrollChange(notification) }) // Listen for scrollbar updates from Ghostty observers.append(NotificationCenter.default.addObserver( forName: .ghosttyDidUpdateScrollbar, object: surfaceView, queue: .main ) { [weak self] notification in self?.handleScrollbarUpdate(notification) }) // Listen for live scroll events observers.append(NotificationCenter.default.addObserver( forName: NSScrollView.willStartLiveScrollNotification, object: scrollView, queue: .main ) { [weak self] _ in self?.isLiveScrolling = true }) observers.append(NotificationCenter.default.addObserver( forName: NSScrollView.didEndLiveScrollNotification, object: scrollView, queue: .main ) { [weak self] _ in self?.isLiveScrolling = false }) observers.append(NotificationCenter.default.addObserver( forName: NSScrollView.didLiveScrollNotification, object: scrollView, queue: .main ) { [weak self] _ in self?.handleLiveScroll() }) observers.append(NotificationCenter.default.addObserver( forName: NSScroller.preferredScrollerStyleDidChangeNotification, object: nil, queue: nil ) { [weak self] _ in self?.handleScrollerStyleChange() }) } required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") } deinit { observers.forEach { NotificationCenter.default.removeObserver($0) } } // The entire bounds is a safe area, so we override any default insets. public override var safeAreaInsets: NSEdgeInsets { return NSEdgeInsetsZero } public override func layout() { super.layout() // Fill entire bounds with scroll view scrollView.frame = bounds surfaceView.frame.size = scrollView.bounds.size // We only set the width of the documentView here, as the height depends // on the scrollbar state and is updated in synchronizeScrollView documentView.frame.size.width = scrollView.bounds.width // When our scrollview changes make sure our scroller and surface views are synchronized synchronizeScrollView() synchronizeSurfaceView() synchronizeCoreSurface() } // MARK: - Scrolling private func synchronizeAppearance() { // Update scroller appearance based on terminal background let hasLightBackground = scrollView.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .aqua scrollView.appearance = NSAppearance(named: hasLightBackground ? .aqua : .darkAqua) updateTrackingAreas() } /// Positions the surface view to fill the currently visible rectangle. private func synchronizeSurfaceView() { let visibleRect = scrollView.contentView.documentVisibleRect surfaceView.frame.origin = visibleRect.origin } /// Inform the actual pty of our size change. private func synchronizeCoreSurface() { let width = scrollView.contentSize.width let height = surfaceView.frame.height if width > 0 && height > 0 { surfaceView.sizeDidChange(CGSize(width: width, height: height)) } } /// Sizes the document view and scrolls the content view according to the scrollbar state private func synchronizeScrollView() { // Update the document height to give our scroller the correct proportions documentView.frame.size.height = documentHeight() // Only update our actual scroll position if we're not actively scrolling. if !isLiveScrolling { // Convert row units to pixels using cell height, ignore zero height. let cellHeight = surfaceView.cellSize.height if cellHeight > 0, let scrollbar = surfaceView.scrollbar { // Invert coordinate system: terminal offset is from top, AppKit position from bottom let offsetY = CGFloat(scrollbar.total - scrollbar.offset - scrollbar.len) * cellHeight scrollView.contentView.scroll(to: CGPoint(x: 0, y: offsetY)) // Track the current row position to avoid redundant movements when we // move the scrollbar. lastSentRow = Int(scrollbar.offset) } } // Always update our scrolled view with the latest dimensions scrollView.reflectScrolledClipView(scrollView.contentView) } // MARK: - Notifications /// Handles bounds changes in the scroll view's clip view, keeping the surface view synchronized. private func handleScrollChange(_ notification: Notification) { synchronizeSurfaceView() } /// Handles scrollbar style changes private func handleScrollerStyleChange() { scrollView.scrollerStyle = .overlay synchronizeCoreSurface() } /// Handles live scroll events (user actively dragging the scrollbar). private func handleLiveScroll() { // If our cell height is currently zero then we avoid a div by zero below let cellHeight = surfaceView.cellSize.height guard cellHeight > 0 else { return } // AppKit views are +Y going up, so we calculate from the bottom let visibleRect = scrollView.contentView.documentVisibleRect let documentHeight = documentView.frame.height let scrollOffset = documentHeight - visibleRect.origin.y - visibleRect.height let row = Int(scrollOffset / cellHeight) // Only send action if the row changed to avoid action spam guard row != lastSentRow else { return } lastSentRow = row // Use the keybinding action to scroll. _ = surfaceView.surface?.perform(action: "scroll_to_row:\(row)") } /// Handles scrollbar state updates from the terminal core. private func handleScrollbarUpdate(_ notification: Notification) { guard let scrollbar = notification.userInfo?[Notification.Name.ScrollbarKey] as? Ghostty.Action.Scrollbar else { return } surfaceView.scrollbar = scrollbar synchronizeScrollView() } // MARK: - Calculations /// Calculate the appropriate document view height given a scrollbar state private func documentHeight() -> CGFloat { let contentHeight = scrollView.contentSize.height let cellHeight = surfaceView.cellSize.height if cellHeight > 0, let scrollbar = surfaceView.scrollbar { // The document view must have the same vertical padding around the // scrollback grid as the content view has around the terminal grid let documentGridHeight = CGFloat(scrollbar.total) * cellHeight let padding = contentHeight - (CGFloat(scrollbar.len) * cellHeight) return documentGridHeight + padding } return contentHeight } // MARK: - Mouse events public override func mouseMoved(with: NSEvent) { // When the OS preferred style is .legacy, the user should be able to // click and drag the scroller without using scroll wheels or gestures, // so we flash it when the mouse is moved over the scrollbar area. guard NSScroller.preferredScrollerStyle == .legacy else { return } scrollView.flashScrollers() } public override func updateTrackingAreas() { // To update our tracking area we just recreate it all. trackingAreas.forEach { removeTrackingArea($0) } super.updateTrackingAreas() // Our tracking area is the scroller frame guard let scroller = scrollView.verticalScroller else { return } addTrackingArea(NSTrackingArea( rect: convert(scroller.bounds, from: scroller), options: [ .mouseMoved, .activeInKeyWindow, ], owner: self, userInfo: nil)) } public override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { let pasteboard = sender.draggingPasteboard if surfaceView.canHandlePasteboard(pasteboard) { return .copy } return [] } public override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { return surfaceView.handlePasteboardAttachments(sender.draggingPasteboard, appendTrailingSpace: true) } } // MARK: - GhosttyTerminalView Extension extension GhosttyTerminalView { /// Notify the terminal of a size change (used by scroll view wrapper) func sizeDidChange(_ size: CGSize) { guard let surface = surface?.unsafeCValue else { return } let scaledSize = convertToBacking(size) ghostty_surface_set_size( surface, UInt32(scaledSize.width), UInt32(scaledSize.height) ) } } ================================================ FILE: ghostty/Sources/GhosttyKit/TerminalTextCleaner.swift ================================================ // // TerminalTextCleaner.swift // GhosttyKit // import Foundation struct TerminalCopySettings { var trimTrailingWhitespace: Bool = true var collapseBlankLines: Bool = false var stripShellPrompts: Bool = false var flattenCommands: Bool = false var removeBoxDrawing: Bool = false var stripAnsiCodes: Bool = true } nonisolated struct TerminalTextCleaner { private static let boxDrawingCharacterClass = "[│┃╎╏┆┇┊┋╽╿│|]" private static let knownCommandPrefixes: [String] = [ "sudo", "./", "~/", "apt", "brew", "git", "python", "pip", "pnpm", "npm", "yarn", "cargo", "bundle", "rails", "go", "make", "xcodebuild", "swift", "kubectl", "docker", "podman", "aws", "gcloud", "az", "ls", "cd", "cat", "echo", "env", "export", "open", "node", "java", "ruby", "perl", "bash", "zsh", "fish", "pwsh", "sh", ] static func cleanText(_ text: String, settings: TerminalCopySettings) -> String { var result = text if settings.stripAnsiCodes { result = stripAnsiCodes(result) } if settings.removeBoxDrawing { if let cleaned = stripBoxDrawingCharacters(in: result) { result = cleaned } } if settings.stripShellPrompts { if let stripped = stripPromptPrefixes(result) { result = stripped } } if settings.flattenCommands { if let flattened = flattenMultilineCommand(result) { result = flattened } } if settings.trimTrailingWhitespace { result = trimTrailingWhitespace(result) } if settings.collapseBlankLines { result = collapseBlankLines(result) } return result } // MARK: - ANSI Codes static func stripAnsiCodes(_ text: String) -> String { // Match ANSI escape sequences: ESC[ followed by params and command // Covers: colors, cursor movement, clearing, etc. let patterns = [ #"\x1b\[[0-9;]*[A-Za-z]"#, // CSI sequences (colors, cursor, etc.) #"\x1b\][^\x07]*\x07"#, // OSC sequences (title, etc.) #"\x1b\][^\x1b]*\x1b\\"#, // OSC with ST terminator #"\x1b[PX^_][^\x1b]*\x1b\\"#, // DCS, SOS, PM, APC sequences #"\x1b[@-Z\\-_]"#, // Fe escape sequences ] var result = text for pattern in patterns { result = result.replacingOccurrences( of: pattern, with: "", options: .regularExpression ) } return result } // MARK: - Trailing Whitespace static func trimTrailingWhitespace(_ text: String) -> String { let lines = text.split(separator: "\n", omittingEmptySubsequences: false) let trimmed = lines.map { line -> String in var s = String(line) while s.last?.isWhitespace == true && s.last != "\n" { s.removeLast() } return s } return trimmed.joined(separator: "\n") } // MARK: - Blank Lines static func collapseBlankLines(_ text: String) -> String { text.replacingOccurrences( of: #"\n{3,}"#, with: "\n\n", options: .regularExpression ) } // MARK: - Shell Prompts static func stripPromptPrefixes(_ text: String) -> String? { let lines = text.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) let nonEmptyLines = lines.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } guard !nonEmptyLines.isEmpty else { return nil } var strippedCount = 0 var rebuilt: [String] = [] rebuilt.reserveCapacity(lines.count) for line in lines { if let stripped = stripPrompt(in: line) { strippedCount += 1 rebuilt.append(stripped) } else { rebuilt.append(String(line)) } } let majorityThreshold = nonEmptyLines.count / 2 + 1 let shouldStrip = nonEmptyLines.count == 1 ? strippedCount == 1 : strippedCount >= majorityThreshold guard shouldStrip else { return nil } let result = rebuilt.joined(separator: "\n") return result == text ? nil : result } private static func stripPrompt(in line: Substring) -> String? { let leadingWhitespace = line.prefix { $0.isWhitespace } let remainder = line.dropFirst(leadingWhitespace.count) guard let first = remainder.first, first == "#" || first == "$" else { return nil } let afterPrompt = remainder.dropFirst().drop { $0.isWhitespace } guard isLikelyPromptCommand(afterPrompt) else { return nil } return String(leadingWhitespace) + String(afterPrompt) } private static func isLikelyPromptCommand(_ content: Substring) -> Bool { let trimmed = String(content.trimmingCharacters(in: .whitespaces)) guard !trimmed.isEmpty else { return false } if let last = trimmed.last, [".", "?", "!"].contains(last) { return false } let hasCommandPunctuation = trimmed.contains(where: { "-./~$".contains($0) }) || trimmed.contains(where: \.isNumber) let firstToken = trimmed.split(separator: " ").first?.lowercased() ?? "" let startsWithKnown = knownCommandPrefixes.contains(where: { firstToken.hasPrefix($0) }) guard hasCommandPunctuation || startsWithKnown else { return false } return isLikelyCommandLine(trimmed[...]) } // MARK: - Command Flattening static func flattenMultilineCommand(_ text: String) -> String? { guard text.contains("\n") else { return nil } let lines = text.split(whereSeparator: { $0.isNewline }) guard lines.count >= 2, lines.count <= 10 else { return nil } // Check for command-like patterns let hasLineContinuation = text.contains("\\\n") let hasLineJoinerAtEOL = text.range( of: #"(?m)(\\|[|&]{1,2}|;)\s*$"#, options: .regularExpression) != nil let hasIndentedPipeline = text.range( of: #"(?m)^\s*[|&]{1,2}\s+\S"#, options: .regularExpression) != nil let hasExplicitLineJoin = hasLineContinuation || hasLineJoinerAtEOL || hasIndentedPipeline // Only flatten if it looks like a command guard hasExplicitLineJoin || looksLikeCommand(text, lines: lines) else { return nil } let flattened = flatten(text) return flattened == text ? nil : flattened } private static func looksLikeCommand(_ text: String, lines: [Substring]) -> Bool { let nonEmptyLines = lines.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } // Check for strong command signals let strongSignals = text.contains("\\\n") || text.range(of: #"[|&]{1,2}"#, options: .regularExpression) != nil || text.range(of: #"(^|\n)\s*\$"#, options: .regularExpression) != nil if strongSignals { return true } // Check if lines look like commands let commandLineCount = nonEmptyLines.count(where: isLikelyCommandLine(_:)) if commandLineCount == nonEmptyLines.count { return true } // Check for known command prefixes let hasKnownPrefix = lines.contains { line in let trimmed = line.trimmingCharacters(in: .whitespaces) guard let firstToken = trimmed.split(separator: " ").first else { return false } let lower = firstToken.lowercased() return knownCommandPrefixes.contains(where: { lower.hasPrefix($0) }) } return hasKnownPrefix } private static func isLikelyCommandLine(_ lineSubstr: Substring) -> Bool { let line = lineSubstr.trimmingCharacters(in: .whitespaces) guard !line.isEmpty else { return false } if line.hasPrefix("[[") { return true } if line.last == "." { return false } let pattern = #"^(sudo\s+)?[A-Za-z0-9./~_-]+(?:\s+|\z)"# return line.range(of: pattern, options: .regularExpression) != nil } private static func flatten(_ text: String) -> String { var result = text // Join uppercase segment line breaks result = result.replacingOccurrences( of: #"(? String? { let boxRegex = try? NSRegularExpression(pattern: boxDrawingCharacterClass, options: []) if boxRegex?.firstMatch(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count)) == nil { return nil } var result = text if result.contains("│ │") { result = result.replacingOccurrences(of: "│ │", with: " ") } let lines = result.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) let nonEmptyLines = lines.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } if !nonEmptyLines.isEmpty { let leadingPattern = #"^\s*\#(boxDrawingCharacterClass)+ ?"# let trailingPattern = #" ?\#(boxDrawingCharacterClass)+\s*$"# let majorityThreshold = nonEmptyLines.count / 2 + 1 let leadingMatches = nonEmptyLines.count(where: { $0.range(of: leadingPattern, options: .regularExpression) != nil }) let trailingMatches = nonEmptyLines.count(where: { $0.range(of: trailingPattern, options: .regularExpression) != nil }) let stripLeading = leadingMatches >= majorityThreshold let stripTrailing = trailingMatches >= majorityThreshold if stripLeading || stripTrailing { var rebuilt: [String] = [] rebuilt.reserveCapacity(lines.count) for line in lines { var lineStr = String(line) if stripLeading { lineStr = lineStr.replacingOccurrences( of: leadingPattern, with: "", options: .regularExpression) } if stripTrailing { lineStr = lineStr.replacingOccurrences( of: trailingPattern, with: "", options: .regularExpression) } rebuilt.append(lineStr) } result = rebuilt.joined(separator: "\n") } } // Clean up box chars in mid-token positions let boxAfterPipePattern = #"\|\s*\#(boxDrawingCharacterClass)+\s*"# result = result.replacingOccurrences( of: boxAfterPipePattern, with: "| ", options: .regularExpression) let boxMidTokenPattern = #"(\S)\s*\#(boxDrawingCharacterClass)+\s*(\S)"# result = result.replacingOccurrences( of: boxMidTokenPattern, with: "$1 $2", options: .regularExpression) result = result.replacingOccurrences( of: #"\s*\#(boxDrawingCharacterClass)+\s*"#, with: " ", options: .regularExpression) let collapsed = result.replacingOccurrences( of: #" {2,}"#, with: " ", options: .regularExpression) let trimmed = collapsed.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed == text ? nil : trimmed } } ================================================ FILE: ghostty/Vendor/VERSION ================================================ 9fb03ba55c9e53901193187d5c43341f5b1b430d ================================================ FILE: ghostty/Vendor/include/ghostty/vt/allocator.h ================================================ /** * @file allocator.h * * Memory management interface for libghostty-vt. */ #ifndef GHOSTTY_VT_ALLOCATOR_H #define GHOSTTY_VT_ALLOCATOR_H #include #include #include /** @defgroup allocator Memory Management * * libghostty-vt does require memory allocation for various operations, * but is resilient to allocation failures and will gracefully handle * out-of-memory situations by returning error codes. * * The exact memory management semantics are documented in the relevant * functions and data structures. * * libghostty-vt uses explicit memory allocation via an allocator * interface provided by GhosttyAllocator. The interface is based on the * [Zig](https://ziglang.org) allocator interface, since this has been * shown to be a flexible and powerful interface in practice and enables * a wide variety of allocation strategies. * * **For the common case, you can pass NULL as the allocator for any * function that accepts one,** and libghostty will use a default allocator. * The default allocator will be libc malloc/free if libc is linked. * Otherwise, a custom allocator is used (currently Zig's SMP allocator) * that doesn't require any external dependencies. * * ## Basic Usage * * For simple use cases, you can ignore this interface entirely by passing NULL * as the allocator parameter to functions that accept one. This will use the * default allocator (typically libc malloc/free, if libc is linked, but * we provide our own default allocator if libc isn't linked). * * To use a custom allocator: * 1. Implement the GhosttyAllocatorVtable function pointers * 2. Create a GhosttyAllocator struct with your vtable and context * 3. Pass the allocator to functions that accept one * * @{ */ /** * Function table for custom memory allocator operations. * * This vtable defines the interface for a custom memory allocator. All * function pointers must be valid and non-NULL. * * @ingroup allocator * * If you're not going to use a custom allocator, you can ignore all of * this. All functions that take an allocator pointer allow NULL to use a * default allocator. * * The interface is based on the Zig allocator interface. I'll say up front * that it is easy to look at this interface and think "wow, this is really * overcomplicated". The reason for this complexity is well thought out by * the Zig folks, and it enables a diverse set of allocation strategies * as shown by the Zig ecosystem. As a consolation, please note that many * of the arguments are only needed for advanced use cases and can be * safely ignored in simple implementations. For example, if you look at * the Zig implementation of the libc allocator in `lib/std/heap.zig` * (search for CAllocator), you'll see it is very simple. * * We chose to align with the Zig allocator interface because: * * 1. It is a proven interface that serves a wide variety of use cases * in the real world via the Zig ecosystem. It's shown to work. * * 2. Our core implementation itself is Zig, and this lets us very * cheaply and easily convert between C and Zig allocators. * * NOTE(mitchellh): In the future, we can have default implementations of * resize/remap and allow those to be null. */ typedef struct { /** * Return a pointer to `len` bytes with specified `alignment`, or return * `NULL` indicating the allocation failed. * * @param ctx The allocator context * @param len Number of bytes to allocate * @param alignment Required alignment for the allocation. Guaranteed to * be a power of two between 1 and 16 inclusive. * @param ret_addr First return address of the allocation call stack (0 if not provided) * @return Pointer to allocated memory, or NULL if allocation failed */ void* (*alloc)(void *ctx, size_t len, uint8_t alignment, uintptr_t ret_addr); /** * Attempt to expand or shrink memory in place. * * `memory_len` must equal the length requested from the most recent * successful call to `alloc`, `resize`, or `remap`. `alignment` must * equal the same value that was passed as the `alignment` parameter to * the original `alloc` call. * * `new_len` must be greater than zero. * * @param ctx The allocator context * @param memory Pointer to the memory block to resize * @param memory_len Current size of the memory block * @param alignment Alignment (must match original allocation) * @param new_len New requested size * @param ret_addr First return address of the allocation call stack (0 if not provided) * @return true if resize was successful in-place, false if relocation would be required */ bool (*resize)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr); /** * Attempt to expand or shrink memory, allowing relocation. * * `memory_len` must equal the length requested from the most recent * successful call to `alloc`, `resize`, or `remap`. `alignment` must * equal the same value that was passed as the `alignment` parameter to * the original `alloc` call. * * A non-`NULL` return value indicates the resize was successful. The * allocation may have same address, or may have been relocated. In either * case, the allocation now has size of `new_len`. A `NULL` return value * indicates that the resize would be equivalent to allocating new memory, * copying the bytes from the old memory, and then freeing the old memory. * In such case, it is more efficient for the caller to perform the copy. * * `new_len` must be greater than zero. * * @param ctx The allocator context * @param memory Pointer to the memory block to remap * @param memory_len Current size of the memory block * @param alignment Alignment (must match original allocation) * @param new_len New requested size * @param ret_addr First return address of the allocation call stack (0 if not provided) * @return Pointer to resized memory (may be relocated), or NULL if manual copy is needed */ void* (*remap)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr); /** * Free and invalidate a region of memory. * * `memory_len` must equal the length requested from the most recent * successful call to `alloc`, `resize`, or `remap`. `alignment` must * equal the same value that was passed as the `alignment` parameter to * the original `alloc` call. * * @param ctx The allocator context * @param memory Pointer to the memory block to free * @param memory_len Size of the memory block * @param alignment Alignment (must match original allocation) * @param ret_addr First return address of the allocation call stack (0 if not provided) */ void (*free)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, uintptr_t ret_addr); } GhosttyAllocatorVtable; /** * Custom memory allocator. * * For functions that take an allocator pointer, a NULL pointer indicates * that the default allocator should be used. The default allocator will * be libc malloc/free if we're linking to libc. If libc isn't linked, * a custom allocator is used (currently Zig's SMP allocator). * * @ingroup allocator * * Usage example: * @code * GhosttyAllocator allocator = { * .vtable = &my_allocator_vtable, * .ctx = my_allocator_state * }; * @endcode */ typedef struct GhosttyAllocator { /** * Opaque context pointer passed to all vtable functions. * This allows the allocator implementation to maintain state * or reference external resources needed for memory management. */ void *ctx; /** * Pointer to the allocator's vtable containing function pointers * for memory operations (alloc, resize, remap, free). */ const GhosttyAllocatorVtable *vtable; } GhosttyAllocator; /** @} */ #endif /* GHOSTTY_VT_ALLOCATOR_H */ ================================================ FILE: ghostty/Vendor/include/ghostty/vt/color.h ================================================ /** * @file color.h * * Color types and utilities. */ #ifndef GHOSTTY_VT_COLOR_H #define GHOSTTY_VT_COLOR_H #include #ifdef __cplusplus extern "C" { #endif /** * RGB color value. * * @ingroup sgr */ typedef struct { uint8_t r; /**< Red component (0-255) */ uint8_t g; /**< Green component (0-255) */ uint8_t b; /**< Blue component (0-255) */ } GhosttyColorRgb; /** * Palette color index (0-255). * * @ingroup sgr */ typedef uint8_t GhosttyColorPaletteIndex; /** @addtogroup sgr * @{ */ /** Black color (0) @ingroup sgr */ #define GHOSTTY_COLOR_NAMED_BLACK 0 /** Red color (1) @ingroup sgr */ #define GHOSTTY_COLOR_NAMED_RED 1 /** Green color (2) @ingroup sgr */ #define GHOSTTY_COLOR_NAMED_GREEN 2 /** Yellow color (3) @ingroup sgr */ #define GHOSTTY_COLOR_NAMED_YELLOW 3 /** Blue color (4) @ingroup sgr */ #define GHOSTTY_COLOR_NAMED_BLUE 4 /** Magenta color (5) @ingroup sgr */ #define GHOSTTY_COLOR_NAMED_MAGENTA 5 /** Cyan color (6) @ingroup sgr */ #define GHOSTTY_COLOR_NAMED_CYAN 6 /** White color (7) @ingroup sgr */ #define GHOSTTY_COLOR_NAMED_WHITE 7 /** Bright black color (8) @ingroup sgr */ #define GHOSTTY_COLOR_NAMED_BRIGHT_BLACK 8 /** Bright red color (9) @ingroup sgr */ #define GHOSTTY_COLOR_NAMED_BRIGHT_RED 9 /** Bright green color (10) @ingroup sgr */ #define GHOSTTY_COLOR_NAMED_BRIGHT_GREEN 10 /** Bright yellow color (11) @ingroup sgr */ #define GHOSTTY_COLOR_NAMED_BRIGHT_YELLOW 11 /** Bright blue color (12) @ingroup sgr */ #define GHOSTTY_COLOR_NAMED_BRIGHT_BLUE 12 /** Bright magenta color (13) @ingroup sgr */ #define GHOSTTY_COLOR_NAMED_BRIGHT_MAGENTA 13 /** Bright cyan color (14) @ingroup sgr */ #define GHOSTTY_COLOR_NAMED_BRIGHT_CYAN 14 /** Bright white color (15) @ingroup sgr */ #define GHOSTTY_COLOR_NAMED_BRIGHT_WHITE 15 /** @} */ /** * Get the RGB color components. * * This function extracts the individual red, green, and blue components * from a GhosttyColorRgb value. Primarily useful in WebAssembly environments * where accessing struct fields directly is difficult. * * @param color The RGB color value * @param r Pointer to store the red component (0-255) * @param g Pointer to store the green component (0-255) * @param b Pointer to store the blue component (0-255) * * @ingroup sgr */ void ghostty_color_rgb_get(GhosttyColorRgb color, uint8_t* r, uint8_t* g, uint8_t* b); #ifdef __cplusplus } #endif #endif /* GHOSTTY_VT_COLOR_H */ ================================================ FILE: ghostty/Vendor/include/ghostty/vt/key/encoder.h ================================================ /** * @file encoder.h * * Key event encoding to terminal escape sequences. */ #ifndef GHOSTTY_VT_KEY_ENCODER_H #define GHOSTTY_VT_KEY_ENCODER_H #include #include #include #include #include /** * Opaque handle to a key encoder instance. * * This handle represents a key encoder that converts key events into terminal * escape sequences. * * @ingroup key */ typedef struct GhosttyKeyEncoder *GhosttyKeyEncoder; /** * Kitty keyboard protocol flags. * * Bitflags representing the various modes of the Kitty keyboard protocol. * These can be combined using bitwise OR operations. Valid values all * start with `GHOSTTY_KITTY_KEY_`. * * @ingroup key */ typedef uint8_t GhosttyKittyKeyFlags; /** Kitty keyboard protocol disabled (all flags off) */ #define GHOSTTY_KITTY_KEY_DISABLED 0 /** Disambiguate escape codes */ #define GHOSTTY_KITTY_KEY_DISAMBIGUATE (1 << 0) /** Report key press and release events */ #define GHOSTTY_KITTY_KEY_REPORT_EVENTS (1 << 1) /** Report alternate key codes */ #define GHOSTTY_KITTY_KEY_REPORT_ALTERNATES (1 << 2) /** Report all key events including those normally handled by the terminal */ #define GHOSTTY_KITTY_KEY_REPORT_ALL (1 << 3) /** Report associated text with key events */ #define GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED (1 << 4) /** All Kitty keyboard protocol flags enabled */ #define GHOSTTY_KITTY_KEY_ALL (GHOSTTY_KITTY_KEY_DISAMBIGUATE | GHOSTTY_KITTY_KEY_REPORT_EVENTS | GHOSTTY_KITTY_KEY_REPORT_ALTERNATES | GHOSTTY_KITTY_KEY_REPORT_ALL | GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED) /** * macOS option key behavior. * * Determines whether the "option" key on macOS is treated as "alt" or not. * See the Ghostty `macos-option-as-alt` configuration option for more details. * * @ingroup key */ typedef enum { /** Option key is not treated as alt */ GHOSTTY_OPTION_AS_ALT_FALSE = 0, /** Option key is treated as alt */ GHOSTTY_OPTION_AS_ALT_TRUE = 1, /** Only left option key is treated as alt */ GHOSTTY_OPTION_AS_ALT_LEFT = 2, /** Only right option key is treated as alt */ GHOSTTY_OPTION_AS_ALT_RIGHT = 3, } GhosttyOptionAsAlt; /** * Key encoder option identifiers. * * These values are used with ghostty_key_encoder_setopt() to configure * the behavior of the key encoder. * * @ingroup key */ typedef enum { /** Terminal DEC mode 1: cursor key application mode (value: bool) */ GHOSTTY_KEY_ENCODER_OPT_CURSOR_KEY_APPLICATION = 0, /** Terminal DEC mode 66: keypad key application mode (value: bool) */ GHOSTTY_KEY_ENCODER_OPT_KEYPAD_KEY_APPLICATION = 1, /** Terminal DEC mode 1035: ignore keypad with numlock (value: bool) */ GHOSTTY_KEY_ENCODER_OPT_IGNORE_KEYPAD_WITH_NUMLOCK = 2, /** Terminal DEC mode 1036: alt sends escape prefix (value: bool) */ GHOSTTY_KEY_ENCODER_OPT_ALT_ESC_PREFIX = 3, /** xterm modifyOtherKeys mode 2 (value: bool) */ GHOSTTY_KEY_ENCODER_OPT_MODIFY_OTHER_KEYS_STATE_2 = 4, /** Kitty keyboard protocol flags (value: GhosttyKittyKeyFlags bitmask) */ GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS = 5, /** macOS option-as-alt setting (value: GhosttyOptionAsAlt) */ GHOSTTY_KEY_ENCODER_OPT_MACOS_OPTION_AS_ALT = 6, } GhosttyKeyEncoderOption; /** * Create a new key encoder instance. * * Creates a new key encoder with default options. The encoder can be configured * using ghostty_key_encoder_setopt() and must be freed using * ghostty_key_encoder_free() when no longer needed. * * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator * @param encoder Pointer to store the created encoder handle * @return GHOSTTY_SUCCESS on success, or an error code on failure * * @ingroup key */ GhosttyResult ghostty_key_encoder_new(const GhosttyAllocator *allocator, GhosttyKeyEncoder *encoder); /** * Free a key encoder instance. * * Releases all resources associated with the key encoder. After this call, * the encoder handle becomes invalid and must not be used. * * @param encoder The encoder handle to free (may be NULL) * * @ingroup key */ void ghostty_key_encoder_free(GhosttyKeyEncoder encoder); /** * Set an option on the key encoder. * * Configures the behavior of the key encoder. Options control various aspects * of encoding such as terminal modes (cursor key application mode, keypad mode), * protocol selection (Kitty keyboard protocol flags), and platform-specific * behaviors (macOS option-as-alt). * * A null pointer value does nothing. It does not reset the value to the * default. The setopt call will do nothing. * * @param encoder The encoder handle, must not be NULL * @param option The option to set * @param value Pointer to the value to set (type depends on the option) * * @ingroup key */ void ghostty_key_encoder_setopt(GhosttyKeyEncoder encoder, GhosttyKeyEncoderOption option, const void *value); /** * Encode a key event into a terminal escape sequence. * * Converts a key event into the appropriate terminal escape sequence based on * the encoder's current options. The sequence is written to the provided buffer. * * Not all key events produce output. For example, unmodified modifier keys * typically don't generate escape sequences. Check the out_len parameter to * determine if any data was written. * * If the output buffer is too small, this function returns GHOSTTY_OUT_OF_MEMORY * and out_len will contain the required buffer size. The caller can then * allocate a larger buffer and call the function again. * * @param encoder The encoder handle, must not be NULL * @param event The key event to encode, must not be NULL * @param out_buf Buffer to write the encoded sequence to * @param out_buf_size Size of the output buffer in bytes * @param out_len Pointer to store the number of bytes written (may be NULL) * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if buffer too small, or other error code * * ## Example: Calculate required buffer size * * @code{.c} * // Query the required size with a NULL buffer (always returns OUT_OF_MEMORY) * size_t required = 0; * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, NULL, 0, &required); * assert(result == GHOSTTY_OUT_OF_MEMORY); * * // Allocate buffer of required size * char *buf = malloc(required); * * // Encode with properly sized buffer * size_t written = 0; * result = ghostty_key_encoder_encode(encoder, event, buf, required, &written); * assert(result == GHOSTTY_SUCCESS); * * // Use the encoded sequence... * * free(buf); * @endcode * * ## Example: Direct encoding with static buffer * * @code{.c} * // Most escape sequences are short, so a static buffer often suffices * char buf[128]; * size_t written = 0; * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); * * if (result == GHOSTTY_SUCCESS) { * // Write the encoded sequence to the terminal * write(pty_fd, buf, written); * } else if (result == GHOSTTY_OUT_OF_MEMORY) { * // Buffer too small, written contains required size * char *dynamic_buf = malloc(written); * result = ghostty_key_encoder_encode(encoder, event, dynamic_buf, written, &written); * assert(result == GHOSTTY_SUCCESS); * write(pty_fd, dynamic_buf, written); * free(dynamic_buf); * } * @endcode * * @ingroup key */ GhosttyResult ghostty_key_encoder_encode(GhosttyKeyEncoder encoder, GhosttyKeyEvent event, char *out_buf, size_t out_buf_size, size_t *out_len); #endif /* GHOSTTY_VT_KEY_ENCODER_H */ ================================================ FILE: ghostty/Vendor/include/ghostty/vt/key/event.h ================================================ /** * @file event.h * * Key event representation and manipulation. */ #ifndef GHOSTTY_VT_KEY_EVENT_H #define GHOSTTY_VT_KEY_EVENT_H #include #include #include #include #include /** * Opaque handle to a key event. * * This handle represents a keyboard input event containing information about * the physical key pressed, modifiers, and generated text. * * @ingroup key */ typedef struct GhosttyKeyEvent *GhosttyKeyEvent; /** * Keyboard input event types. * * @ingroup key */ typedef enum { /** Key was released */ GHOSTTY_KEY_ACTION_RELEASE = 0, /** Key was pressed */ GHOSTTY_KEY_ACTION_PRESS = 1, /** Key is being repeated (held down) */ GHOSTTY_KEY_ACTION_REPEAT = 2, } GhosttyKeyAction; /** * Keyboard modifier keys bitmask. * * A bitmask representing all keyboard modifiers. This tracks which modifier keys * are pressed and, where supported by the platform, which side (left or right) * of each modifier is active. * * Use the GHOSTTY_MODS_* constants to test and set individual modifiers. * * Modifier side bits are only meaningful when the corresponding modifier bit is set. * Not all platforms support distinguishing between left and right modifier * keys and Ghostty is built to expect that some platforms may not provide this * information. * * @ingroup key */ typedef uint16_t GhosttyMods; /** Shift key is pressed */ #define GHOSTTY_MODS_SHIFT (1 << 0) /** Control key is pressed */ #define GHOSTTY_MODS_CTRL (1 << 1) /** Alt/Option key is pressed */ #define GHOSTTY_MODS_ALT (1 << 2) /** Super/Command/Windows key is pressed */ #define GHOSTTY_MODS_SUPER (1 << 3) /** Caps Lock is active */ #define GHOSTTY_MODS_CAPS_LOCK (1 << 4) /** Num Lock is active */ #define GHOSTTY_MODS_NUM_LOCK (1 << 5) /** * Right shift is pressed (0 = left, 1 = right). * Only meaningful when GHOSTTY_MODS_SHIFT is set. */ #define GHOSTTY_MODS_SHIFT_SIDE (1 << 6) /** * Right ctrl is pressed (0 = left, 1 = right). * Only meaningful when GHOSTTY_MODS_CTRL is set. */ #define GHOSTTY_MODS_CTRL_SIDE (1 << 7) /** * Right alt is pressed (0 = left, 1 = right). * Only meaningful when GHOSTTY_MODS_ALT is set. */ #define GHOSTTY_MODS_ALT_SIDE (1 << 8) /** * Right super is pressed (0 = left, 1 = right). * Only meaningful when GHOSTTY_MODS_SUPER is set. */ #define GHOSTTY_MODS_SUPER_SIDE (1 << 9) /** * Physical key codes. * * The set of key codes that Ghostty is aware of. These represent physical keys * on the keyboard and are layout-independent. For example, the "a" key on a US * keyboard is the same as the "ф" key on a Russian keyboard, but both will * report the same key_a value. * * Layout-dependent strings are provided separately as UTF-8 text and are produced * by the platform. These values are based on the W3C UI Events KeyboardEvent code * standard. See: https://www.w3.org/TR/uievents-code * * @ingroup key */ typedef enum { GHOSTTY_KEY_UNIDENTIFIED = 0, // Writing System Keys (W3C § 3.1.1) GHOSTTY_KEY_BACKQUOTE, GHOSTTY_KEY_BACKSLASH, GHOSTTY_KEY_BRACKET_LEFT, GHOSTTY_KEY_BRACKET_RIGHT, GHOSTTY_KEY_COMMA, GHOSTTY_KEY_DIGIT_0, GHOSTTY_KEY_DIGIT_1, GHOSTTY_KEY_DIGIT_2, GHOSTTY_KEY_DIGIT_3, GHOSTTY_KEY_DIGIT_4, GHOSTTY_KEY_DIGIT_5, GHOSTTY_KEY_DIGIT_6, GHOSTTY_KEY_DIGIT_7, GHOSTTY_KEY_DIGIT_8, GHOSTTY_KEY_DIGIT_9, GHOSTTY_KEY_EQUAL, GHOSTTY_KEY_INTL_BACKSLASH, GHOSTTY_KEY_INTL_RO, GHOSTTY_KEY_INTL_YEN, GHOSTTY_KEY_A, GHOSTTY_KEY_B, GHOSTTY_KEY_C, GHOSTTY_KEY_D, GHOSTTY_KEY_E, GHOSTTY_KEY_F, GHOSTTY_KEY_G, GHOSTTY_KEY_H, GHOSTTY_KEY_I, GHOSTTY_KEY_J, GHOSTTY_KEY_K, GHOSTTY_KEY_L, GHOSTTY_KEY_M, GHOSTTY_KEY_N, GHOSTTY_KEY_O, GHOSTTY_KEY_P, GHOSTTY_KEY_Q, GHOSTTY_KEY_R, GHOSTTY_KEY_S, GHOSTTY_KEY_T, GHOSTTY_KEY_U, GHOSTTY_KEY_V, GHOSTTY_KEY_W, GHOSTTY_KEY_X, GHOSTTY_KEY_Y, GHOSTTY_KEY_Z, GHOSTTY_KEY_MINUS, GHOSTTY_KEY_PERIOD, GHOSTTY_KEY_QUOTE, GHOSTTY_KEY_SEMICOLON, GHOSTTY_KEY_SLASH, // Functional Keys (W3C § 3.1.2) GHOSTTY_KEY_ALT_LEFT, GHOSTTY_KEY_ALT_RIGHT, GHOSTTY_KEY_BACKSPACE, GHOSTTY_KEY_CAPS_LOCK, GHOSTTY_KEY_CONTEXT_MENU, GHOSTTY_KEY_CONTROL_LEFT, GHOSTTY_KEY_CONTROL_RIGHT, GHOSTTY_KEY_ENTER, GHOSTTY_KEY_META_LEFT, GHOSTTY_KEY_META_RIGHT, GHOSTTY_KEY_SHIFT_LEFT, GHOSTTY_KEY_SHIFT_RIGHT, GHOSTTY_KEY_SPACE, GHOSTTY_KEY_TAB, GHOSTTY_KEY_CONVERT, GHOSTTY_KEY_KANA_MODE, GHOSTTY_KEY_NON_CONVERT, // Control Pad Section (W3C § 3.2) GHOSTTY_KEY_DELETE, GHOSTTY_KEY_END, GHOSTTY_KEY_HELP, GHOSTTY_KEY_HOME, GHOSTTY_KEY_INSERT, GHOSTTY_KEY_PAGE_DOWN, GHOSTTY_KEY_PAGE_UP, // Arrow Pad Section (W3C § 3.3) GHOSTTY_KEY_ARROW_DOWN, GHOSTTY_KEY_ARROW_LEFT, GHOSTTY_KEY_ARROW_RIGHT, GHOSTTY_KEY_ARROW_UP, // Numpad Section (W3C § 3.4) GHOSTTY_KEY_NUM_LOCK, GHOSTTY_KEY_NUMPAD_0, GHOSTTY_KEY_NUMPAD_1, GHOSTTY_KEY_NUMPAD_2, GHOSTTY_KEY_NUMPAD_3, GHOSTTY_KEY_NUMPAD_4, GHOSTTY_KEY_NUMPAD_5, GHOSTTY_KEY_NUMPAD_6, GHOSTTY_KEY_NUMPAD_7, GHOSTTY_KEY_NUMPAD_8, GHOSTTY_KEY_NUMPAD_9, GHOSTTY_KEY_NUMPAD_ADD, GHOSTTY_KEY_NUMPAD_BACKSPACE, GHOSTTY_KEY_NUMPAD_CLEAR, GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY, GHOSTTY_KEY_NUMPAD_COMMA, GHOSTTY_KEY_NUMPAD_DECIMAL, GHOSTTY_KEY_NUMPAD_DIVIDE, GHOSTTY_KEY_NUMPAD_ENTER, GHOSTTY_KEY_NUMPAD_EQUAL, GHOSTTY_KEY_NUMPAD_MEMORY_ADD, GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR, GHOSTTY_KEY_NUMPAD_MEMORY_RECALL, GHOSTTY_KEY_NUMPAD_MEMORY_STORE, GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT, GHOSTTY_KEY_NUMPAD_MULTIPLY, GHOSTTY_KEY_NUMPAD_PAREN_LEFT, GHOSTTY_KEY_NUMPAD_PAREN_RIGHT, GHOSTTY_KEY_NUMPAD_SUBTRACT, GHOSTTY_KEY_NUMPAD_SEPARATOR, GHOSTTY_KEY_NUMPAD_UP, GHOSTTY_KEY_NUMPAD_DOWN, GHOSTTY_KEY_NUMPAD_RIGHT, GHOSTTY_KEY_NUMPAD_LEFT, GHOSTTY_KEY_NUMPAD_BEGIN, GHOSTTY_KEY_NUMPAD_HOME, GHOSTTY_KEY_NUMPAD_END, GHOSTTY_KEY_NUMPAD_INSERT, GHOSTTY_KEY_NUMPAD_DELETE, GHOSTTY_KEY_NUMPAD_PAGE_UP, GHOSTTY_KEY_NUMPAD_PAGE_DOWN, // Function Section (W3C § 3.5) GHOSTTY_KEY_ESCAPE, GHOSTTY_KEY_F1, GHOSTTY_KEY_F2, GHOSTTY_KEY_F3, GHOSTTY_KEY_F4, GHOSTTY_KEY_F5, GHOSTTY_KEY_F6, GHOSTTY_KEY_F7, GHOSTTY_KEY_F8, GHOSTTY_KEY_F9, GHOSTTY_KEY_F10, GHOSTTY_KEY_F11, GHOSTTY_KEY_F12, GHOSTTY_KEY_F13, GHOSTTY_KEY_F14, GHOSTTY_KEY_F15, GHOSTTY_KEY_F16, GHOSTTY_KEY_F17, GHOSTTY_KEY_F18, GHOSTTY_KEY_F19, GHOSTTY_KEY_F20, GHOSTTY_KEY_F21, GHOSTTY_KEY_F22, GHOSTTY_KEY_F23, GHOSTTY_KEY_F24, GHOSTTY_KEY_F25, GHOSTTY_KEY_FN, GHOSTTY_KEY_FN_LOCK, GHOSTTY_KEY_PRINT_SCREEN, GHOSTTY_KEY_SCROLL_LOCK, GHOSTTY_KEY_PAUSE, // Media Keys (W3C § 3.6) GHOSTTY_KEY_BROWSER_BACK, GHOSTTY_KEY_BROWSER_FAVORITES, GHOSTTY_KEY_BROWSER_FORWARD, GHOSTTY_KEY_BROWSER_HOME, GHOSTTY_KEY_BROWSER_REFRESH, GHOSTTY_KEY_BROWSER_SEARCH, GHOSTTY_KEY_BROWSER_STOP, GHOSTTY_KEY_EJECT, GHOSTTY_KEY_LAUNCH_APP_1, GHOSTTY_KEY_LAUNCH_APP_2, GHOSTTY_KEY_LAUNCH_MAIL, GHOSTTY_KEY_MEDIA_PLAY_PAUSE, GHOSTTY_KEY_MEDIA_SELECT, GHOSTTY_KEY_MEDIA_STOP, GHOSTTY_KEY_MEDIA_TRACK_NEXT, GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS, GHOSTTY_KEY_POWER, GHOSTTY_KEY_SLEEP, GHOSTTY_KEY_AUDIO_VOLUME_DOWN, GHOSTTY_KEY_AUDIO_VOLUME_MUTE, GHOSTTY_KEY_AUDIO_VOLUME_UP, GHOSTTY_KEY_WAKE_UP, // Legacy, Non-standard, and Special Keys (W3C § 3.7) GHOSTTY_KEY_COPY, GHOSTTY_KEY_CUT, GHOSTTY_KEY_PASTE, } GhosttyKey; /** * Create a new key event instance. * * Creates a new key event with default values. The event must be freed using * ghostty_key_event_free() when no longer needed. * * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator * @param event Pointer to store the created key event handle * @return GHOSTTY_SUCCESS on success, or an error code on failure * * @ingroup key */ GhosttyResult ghostty_key_event_new(const GhosttyAllocator *allocator, GhosttyKeyEvent *event); /** * Free a key event instance. * * Releases all resources associated with the key event. After this call, * the event handle becomes invalid and must not be used. * * @param event The key event handle to free (may be NULL) * * @ingroup key */ void ghostty_key_event_free(GhosttyKeyEvent event); /** * Set the key action (press, release, repeat). * * @param event The key event handle, must not be NULL * @param action The action to set * * @ingroup key */ void ghostty_key_event_set_action(GhosttyKeyEvent event, GhosttyKeyAction action); /** * Get the key action (press, release, repeat). * * @param event The key event handle, must not be NULL * @return The key action * * @ingroup key */ GhosttyKeyAction ghostty_key_event_get_action(GhosttyKeyEvent event); /** * Set the physical key code. * * @param event The key event handle, must not be NULL * @param key The physical key code to set * * @ingroup key */ void ghostty_key_event_set_key(GhosttyKeyEvent event, GhosttyKey key); /** * Get the physical key code. * * @param event The key event handle, must not be NULL * @return The physical key code * * @ingroup key */ GhosttyKey ghostty_key_event_get_key(GhosttyKeyEvent event); /** * Set the modifier keys bitmask. * * @param event The key event handle, must not be NULL * @param mods The modifier keys bitmask to set * * @ingroup key */ void ghostty_key_event_set_mods(GhosttyKeyEvent event, GhosttyMods mods); /** * Get the modifier keys bitmask. * * @param event The key event handle, must not be NULL * @return The modifier keys bitmask * * @ingroup key */ GhosttyMods ghostty_key_event_get_mods(GhosttyKeyEvent event); /** * Set the consumed modifiers bitmask. * * @param event The key event handle, must not be NULL * @param consumed_mods The consumed modifiers bitmask to set * * @ingroup key */ void ghostty_key_event_set_consumed_mods(GhosttyKeyEvent event, GhosttyMods consumed_mods); /** * Get the consumed modifiers bitmask. * * @param event The key event handle, must not be NULL * @return The consumed modifiers bitmask * * @ingroup key */ GhosttyMods ghostty_key_event_get_consumed_mods(GhosttyKeyEvent event); /** * Set whether the key event is part of a composition sequence. * * @param event The key event handle, must not be NULL * @param composing Whether the key event is part of a composition sequence * * @ingroup key */ void ghostty_key_event_set_composing(GhosttyKeyEvent event, bool composing); /** * Get whether the key event is part of a composition sequence. * * @param event The key event handle, must not be NULL * @return Whether the key event is part of a composition sequence * * @ingroup key */ bool ghostty_key_event_get_composing(GhosttyKeyEvent event); /** * Set the UTF-8 text generated by the key event. * * The key event does NOT take ownership of the text pointer. The caller * must ensure the string remains valid for the lifetime needed by the event. * * @param event The key event handle, must not be NULL * @param utf8 The UTF-8 text to set (or NULL for empty) * @param len Length of the UTF-8 text in bytes * * @ingroup key */ void ghostty_key_event_set_utf8(GhosttyKeyEvent event, const char *utf8, size_t len); /** * Get the UTF-8 text generated by the key event. * * The returned pointer is valid until the event is freed or the UTF-8 text is modified. * * @param event The key event handle, must not be NULL * @param len Pointer to store the length of the UTF-8 text in bytes (may be NULL) * @return The UTF-8 text (or NULL for empty) * * @ingroup key */ const char *ghostty_key_event_get_utf8(GhosttyKeyEvent event, size_t *len); /** * Set the unshifted Unicode codepoint. * * @param event The key event handle, must not be NULL * @param codepoint The unshifted Unicode codepoint to set * * @ingroup key */ void ghostty_key_event_set_unshifted_codepoint(GhosttyKeyEvent event, uint32_t codepoint); /** * Get the unshifted Unicode codepoint. * * @param event The key event handle, must not be NULL * @return The unshifted Unicode codepoint * * @ingroup key */ uint32_t ghostty_key_event_get_unshifted_codepoint(GhosttyKeyEvent event); #endif /* GHOSTTY_VT_KEY_EVENT_H */ ================================================ FILE: ghostty/Vendor/include/ghostty/vt/key.h ================================================ /** * @file key.h * * Key encoding module - encode key events into terminal escape sequences. */ #ifndef GHOSTTY_VT_KEY_H #define GHOSTTY_VT_KEY_H /** @defgroup key Key Encoding * * Utilities for encoding key events into terminal escape sequences, * supporting both legacy encoding as well as Kitty Keyboard Protocol. * * ## Basic Usage * * 1. Create an encoder instance with ghostty_key_encoder_new() * 2. Configure encoder options with ghostty_key_encoder_setopt(). * 3. For each key event: * - Create a key event with ghostty_key_event_new() * - Set event properties (action, key, modifiers, etc.) * - Encode with ghostty_key_encoder_encode() * - Free the event with ghostty_key_event_free() * - Note: You can also reuse the same key event multiple times by * changing its properties. * 4. Free the encoder with ghostty_key_encoder_free() when done * * ## Example * * @code{.c} * #include * #include * #include * * int main() { * // Create encoder * GhosttyKeyEncoder encoder; * GhosttyResult result = ghostty_key_encoder_new(NULL, &encoder); * assert(result == GHOSTTY_SUCCESS); * * // Enable Kitty keyboard protocol with all features * ghostty_key_encoder_setopt(encoder, GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS, * &(uint8_t){GHOSTTY_KITTY_KEY_ALL}); * * // Create and configure key event for Ctrl+C press * GhosttyKeyEvent event; * result = ghostty_key_event_new(NULL, &event); * assert(result == GHOSTTY_SUCCESS); * ghostty_key_event_set_action(event, GHOSTTY_KEY_ACTION_PRESS); * ghostty_key_event_set_key(event, GHOSTTY_KEY_C); * ghostty_key_event_set_mods(event, GHOSTTY_MODS_CTRL); * * // Encode the key event * char buf[128]; * size_t written = 0; * result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written); * assert(result == GHOSTTY_SUCCESS); * * // Use the encoded sequence (e.g., write to terminal) * fwrite(buf, 1, written, stdout); * * // Cleanup * ghostty_key_event_free(event); * ghostty_key_encoder_free(encoder); * return 0; * } * @endcode * * For a complete working example, see example/c-vt-key-encode in the * repository. * * @{ */ #include #include /** @} */ #endif /* GHOSTTY_VT_KEY_H */ ================================================ FILE: ghostty/Vendor/include/ghostty/vt/osc.h ================================================ /** * @file osc.h * * OSC (Operating System Command) sequence parser and command handling. */ #ifndef GHOSTTY_VT_OSC_H #define GHOSTTY_VT_OSC_H #include #include #include #include #include /** * Opaque handle to an OSC parser instance. * * This handle represents an OSC (Operating System Command) parser that can * be used to parse the contents of OSC sequences. * * @ingroup osc */ typedef struct GhosttyOscParser *GhosttyOscParser; /** * Opaque handle to a single OSC command. * * This handle represents a parsed OSC (Operating System Command) command. * The command can be queried for its type and associated data. * * @ingroup osc */ typedef struct GhosttyOscCommand *GhosttyOscCommand; /** @defgroup osc OSC Parser * * OSC (Operating System Command) sequence parser and command handling. * * The parser operates in a streaming fashion, processing input byte-by-byte * to handle OSC sequences that may arrive in fragments across multiple reads. * This interface makes it easy to integrate into most environments and avoids * over-allocating buffers. * * ## Basic Usage * * 1. Create a parser instance with ghostty_osc_new() * 2. Feed bytes to the parser using ghostty_osc_next() * 3. Finalize parsing with ghostty_osc_end() to get the command * 4. Query command type and extract data using ghostty_osc_command_type() * and ghostty_osc_command_data() * 5. Free the parser with ghostty_osc_free() when done * * @{ */ /** * OSC command types. * * @ingroup osc */ typedef enum { GHOSTTY_OSC_COMMAND_INVALID = 0, GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE = 1, GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_ICON = 2, GHOSTTY_OSC_COMMAND_PROMPT_START = 3, GHOSTTY_OSC_COMMAND_PROMPT_END = 4, GHOSTTY_OSC_COMMAND_END_OF_INPUT = 5, GHOSTTY_OSC_COMMAND_END_OF_COMMAND = 6, GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS = 7, GHOSTTY_OSC_COMMAND_REPORT_PWD = 8, GHOSTTY_OSC_COMMAND_MOUSE_SHAPE = 9, GHOSTTY_OSC_COMMAND_COLOR_OPERATION = 10, GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL = 11, GHOSTTY_OSC_COMMAND_SHOW_DESKTOP_NOTIFICATION = 12, GHOSTTY_OSC_COMMAND_HYPERLINK_START = 13, GHOSTTY_OSC_COMMAND_HYPERLINK_END = 14, GHOSTTY_OSC_COMMAND_CONEMU_SLEEP = 15, GHOSTTY_OSC_COMMAND_CONEMU_SHOW_MESSAGE_BOX = 16, GHOSTTY_OSC_COMMAND_CONEMU_CHANGE_TAB_TITLE = 17, GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT = 18, GHOSTTY_OSC_COMMAND_CONEMU_WAIT_INPUT = 19, GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 20, } GhosttyOscCommandType; /** * OSC command data types. * * These values specify what type of data to extract from an OSC command * using `ghostty_osc_command_data`. * * @ingroup osc */ typedef enum { /** Invalid data type. Never results in any data extraction. */ GHOSTTY_OSC_DATA_INVALID = 0, /** * Window title string data. * * Valid for: GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE * * Output type: const char ** (pointer to null-terminated string) * * Lifetime: Valid until the next call to any ghostty_osc_* function with * the same parser instance. Memory is owned by the parser. */ GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR = 1, } GhosttyOscCommandData; /** * Create a new OSC parser instance. * * Creates a new OSC (Operating System Command) parser using the provided * allocator. The parser must be freed using ghostty_vt_osc_free() when * no longer needed. * * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator * @param parser Pointer to store the created parser handle * @return GHOSTTY_SUCCESS on success, or an error code on failure * * @ingroup osc */ GhosttyResult ghostty_osc_new(const GhosttyAllocator *allocator, GhosttyOscParser *parser); /** * Free an OSC parser instance. * * Releases all resources associated with the OSC parser. After this call, * the parser handle becomes invalid and must not be used. * * @param parser The parser handle to free (may be NULL) * * @ingroup osc */ void ghostty_osc_free(GhosttyOscParser parser); /** * Reset an OSC parser instance to its initial state. * * Resets the parser state, clearing any partially parsed OSC sequences * and returning the parser to its initial state. This is useful for * reusing a parser instance or recovering from parse errors. * * @param parser The parser handle to reset, must not be null. * * @ingroup osc */ void ghostty_osc_reset(GhosttyOscParser parser); /** * Parse the next byte in an OSC sequence. * * Processes a single byte as part of an OSC sequence. The parser maintains * internal state to track the progress through the sequence. Call this * function for each byte in the sequence data. * * When finished pumping the parser with bytes, call ghostty_osc_end * to get the final result. * * @param parser The parser handle, must not be null. * @param byte The next byte to parse * * @ingroup osc */ void ghostty_osc_next(GhosttyOscParser parser, uint8_t byte); /** * Finalize OSC parsing and retrieve the parsed command. * * Call this function after feeding all bytes of an OSC sequence to the parser * using ghostty_osc_next() with the exception of the terminating character * (ESC or ST). This function finalizes the parsing process and returns the * parsed OSC command. * * The return value is never NULL. Invalid commands will return a command * with type GHOSTTY_OSC_COMMAND_INVALID. * * The terminator parameter specifies the byte that terminated the OSC sequence * (typically 0x07 for BEL or 0x5C for ST after ESC). This information is * preserved in the parsed command so that responses can use the same terminator * format for better compatibility with the calling program. For commands that * do not require a response, this parameter is ignored and the resulting * command will not retain the terminator information. * * The returned command handle is valid until the next call to any * `ghostty_osc_*` function with the same parser instance with the exception * of command introspection functions such as `ghostty_osc_command_type`. * * @param parser The parser handle, must not be null. * @param terminator The terminating byte of the OSC sequence (0x07 for BEL, 0x5C for ST) * @return Handle to the parsed OSC command * * @ingroup osc */ GhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser, uint8_t terminator); /** * Get the type of an OSC command. * * Returns the type identifier for the given OSC command. This can be used * to determine what kind of command was parsed and what data might be * available from it. * * @param command The OSC command handle to query (may be NULL) * @return The command type, or GHOSTTY_OSC_COMMAND_INVALID if command is NULL * * @ingroup osc */ GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command); /** * Extract data from an OSC command. * * Extracts typed data from the given OSC command based on the specified * data type. The output pointer must be of the appropriate type for the * requested data kind. Valid command types, output types, and memory * safety information are documented in the `GhosttyOscCommandData` enum. * * @param command The OSC command handle to query (may be NULL) * @param data The type of data to extract * @param out Pointer to store the extracted data (type depends on data parameter) * @return true if data extraction was successful, false otherwise * * @ingroup osc */ bool ghostty_osc_command_data(GhosttyOscCommand command, GhosttyOscCommandData data, void *out); /** @} */ #endif /* GHOSTTY_VT_OSC_H */ ================================================ FILE: ghostty/Vendor/include/ghostty/vt/paste.h ================================================ /** * @file paste.h * * Paste utilities - validate and encode paste data for terminal input. */ #ifndef GHOSTTY_VT_PASTE_H #define GHOSTTY_VT_PASTE_H /** @defgroup paste Paste Utilities * * Utilities for validating paste data safety. * * ## Basic Usage * * Use ghostty_paste_is_safe() to check if paste data contains potentially * dangerous sequences before sending it to the terminal. * * ## Example * * @code{.c} * #include * #include * #include * * int main() { * const char* safe_data = "hello world"; * const char* unsafe_data = "rm -rf /\n"; * * if (ghostty_paste_is_safe(safe_data, strlen(safe_data))) { * printf("Safe to paste\n"); * } * * if (!ghostty_paste_is_safe(unsafe_data, strlen(unsafe_data))) { * printf("Unsafe! Contains newline\n"); * } * * return 0; * } * @endcode * * @{ */ #include #include #ifdef __cplusplus extern "C" { #endif /** * Check if paste data is safe to paste into the terminal. * * Data is considered unsafe if it contains: * - Newlines (`\n`) which can inject commands * - The bracketed paste end sequence (`\x1b[201~`) which can be used * to exit bracketed paste mode and inject commands * * This check is conservative and considers data unsafe regardless of * current terminal state. * * @param data The paste data to check (must not be NULL) * @param len The length of the data in bytes * @return true if the data is safe to paste, false otherwise */ bool ghostty_paste_is_safe(const char* data, size_t len); #ifdef __cplusplus } #endif /** @} */ #endif /* GHOSTTY_VT_PASTE_H */ ================================================ FILE: ghostty/Vendor/include/ghostty/vt/result.h ================================================ /** * @file result.h * * Result codes for libghostty-vt operations. */ #ifndef GHOSTTY_VT_RESULT_H #define GHOSTTY_VT_RESULT_H /** * Result codes for libghostty-vt operations. */ typedef enum { /** Operation completed successfully */ GHOSTTY_SUCCESS = 0, /** Operation failed due to failed allocation */ GHOSTTY_OUT_OF_MEMORY = -1, /** Operation failed due to invalid value */ GHOSTTY_INVALID_VALUE = -2, } GhosttyResult; #endif /* GHOSTTY_VT_RESULT_H */ ================================================ FILE: ghostty/Vendor/include/ghostty/vt/sgr.h ================================================ /** * @file sgr.h * * SGR (Select Graphic Rendition) attribute parsing and handling. */ #ifndef GHOSTTY_VT_SGR_H #define GHOSTTY_VT_SGR_H /** @defgroup sgr SGR Parser * * SGR (Select Graphic Rendition) attribute parser. * * SGR sequences are the syntax used to set styling attributes such as * bold, italic, underline, and colors for text in terminal emulators. * For example, you may be familiar with sequences like `ESC[1;31m`. The * `1;31` is the SGR attribute list. * * The parser processes SGR parameters from CSI sequences (e.g., `ESC[1;31m`) * and returns individual text attributes like bold, italic, colors, etc. * It supports both semicolon (`;`) and colon (`:`) separators, possibly mixed, * and handles various color formats including 8-color, 16-color, 256-color, * X11 named colors, and RGB in multiple formats. * * ## Basic Usage * * 1. Create a parser instance with ghostty_sgr_new() * 2. Set SGR parameters with ghostty_sgr_set_params() * 3. Iterate through attributes using ghostty_sgr_next() * 4. Free the parser with ghostty_sgr_free() when done * * ## Example * * @code{.c} * #include * #include * #include * * int main() { * // Create parser * GhosttySgrParser parser; * GhosttyResult result = ghostty_sgr_new(NULL, &parser); * assert(result == GHOSTTY_SUCCESS); * * // Parse "bold, red foreground" sequence: ESC[1;31m * uint16_t params[] = {1, 31}; * result = ghostty_sgr_set_params(parser, params, NULL, 2); * assert(result == GHOSTTY_SUCCESS); * * // Iterate through attributes * GhosttySgrAttribute attr; * while (ghostty_sgr_next(parser, &attr)) { * switch (attr.tag) { * case GHOSTTY_SGR_ATTR_BOLD: * printf("Bold enabled\n"); * break; * case GHOSTTY_SGR_ATTR_FG_8: * printf("Foreground color: %d\n", attr.value.fg_8); * break; * default: * break; * } * } * * // Cleanup * ghostty_sgr_free(parser); * return 0; * } * @endcode * * @{ */ #include #include #include #include #include #include #ifdef __cplusplus extern "C" { #endif /** * Opaque handle to an SGR parser instance. * * This handle represents an SGR (Select Graphic Rendition) parser that can * be used to parse SGR sequences and extract individual text attributes. * * @ingroup sgr */ typedef struct GhosttySgrParser* GhosttySgrParser; /** * SGR attribute tags. * * These values identify the type of an SGR attribute in a tagged union. * Use the tag to determine which field in the attribute value union to access. * * @ingroup sgr */ typedef enum { GHOSTTY_SGR_ATTR_UNSET = 0, GHOSTTY_SGR_ATTR_UNKNOWN = 1, GHOSTTY_SGR_ATTR_BOLD = 2, GHOSTTY_SGR_ATTR_RESET_BOLD = 3, GHOSTTY_SGR_ATTR_ITALIC = 4, GHOSTTY_SGR_ATTR_RESET_ITALIC = 5, GHOSTTY_SGR_ATTR_FAINT = 6, GHOSTTY_SGR_ATTR_UNDERLINE = 7, GHOSTTY_SGR_ATTR_RESET_UNDERLINE = 8, GHOSTTY_SGR_ATTR_UNDERLINE_COLOR = 9, GHOSTTY_SGR_ATTR_UNDERLINE_COLOR_256 = 10, GHOSTTY_SGR_ATTR_RESET_UNDERLINE_COLOR = 11, GHOSTTY_SGR_ATTR_OVERLINE = 12, GHOSTTY_SGR_ATTR_RESET_OVERLINE = 13, GHOSTTY_SGR_ATTR_BLINK = 14, GHOSTTY_SGR_ATTR_RESET_BLINK = 15, GHOSTTY_SGR_ATTR_INVERSE = 16, GHOSTTY_SGR_ATTR_RESET_INVERSE = 17, GHOSTTY_SGR_ATTR_INVISIBLE = 18, GHOSTTY_SGR_ATTR_RESET_INVISIBLE = 19, GHOSTTY_SGR_ATTR_STRIKETHROUGH = 20, GHOSTTY_SGR_ATTR_RESET_STRIKETHROUGH = 21, GHOSTTY_SGR_ATTR_DIRECT_COLOR_FG = 22, GHOSTTY_SGR_ATTR_DIRECT_COLOR_BG = 23, GHOSTTY_SGR_ATTR_BG_8 = 24, GHOSTTY_SGR_ATTR_FG_8 = 25, GHOSTTY_SGR_ATTR_RESET_FG = 26, GHOSTTY_SGR_ATTR_RESET_BG = 27, GHOSTTY_SGR_ATTR_BRIGHT_BG_8 = 28, GHOSTTY_SGR_ATTR_BRIGHT_FG_8 = 29, GHOSTTY_SGR_ATTR_BG_256 = 30, GHOSTTY_SGR_ATTR_FG_256 = 31, } GhosttySgrAttributeTag; /** * Underline style types. * * @ingroup sgr */ typedef enum { GHOSTTY_SGR_UNDERLINE_NONE = 0, GHOSTTY_SGR_UNDERLINE_SINGLE = 1, GHOSTTY_SGR_UNDERLINE_DOUBLE = 2, GHOSTTY_SGR_UNDERLINE_CURLY = 3, GHOSTTY_SGR_UNDERLINE_DOTTED = 4, GHOSTTY_SGR_UNDERLINE_DASHED = 5, } GhosttySgrUnderline; /** * Unknown SGR attribute data. * * Contains the full parameter list and the partial list where parsing * encountered an unknown or invalid sequence. * * @ingroup sgr */ typedef struct { const uint16_t* full_ptr; size_t full_len; const uint16_t* partial_ptr; size_t partial_len; } GhosttySgrUnknown; /** * SGR attribute value union. * * This union contains all possible attribute values. Use the tag field * to determine which union member is active. Attributes without associated * data (like bold, italic) don't use the union value. * * @ingroup sgr */ typedef union { GhosttySgrUnknown unknown; GhosttySgrUnderline underline; GhosttyColorRgb underline_color; GhosttyColorPaletteIndex underline_color_256; GhosttyColorRgb direct_color_fg; GhosttyColorRgb direct_color_bg; GhosttyColorPaletteIndex bg_8; GhosttyColorPaletteIndex fg_8; GhosttyColorPaletteIndex bright_bg_8; GhosttyColorPaletteIndex bright_fg_8; GhosttyColorPaletteIndex bg_256; GhosttyColorPaletteIndex fg_256; uint64_t _padding[8]; } GhosttySgrAttributeValue; /** * SGR attribute (tagged union). * * A complete SGR attribute with both its type tag and associated value. * Always check the tag field to determine which value union member is valid. * * Attributes without associated data (e.g., GHOSTTY_SGR_ATTR_BOLD) can be * identified by tag alone; the value union is not used for these and * the memory in the value field is undefined. * * @ingroup sgr */ typedef struct { GhosttySgrAttributeTag tag; GhosttySgrAttributeValue value; } GhosttySgrAttribute; /** * Create a new SGR parser instance. * * Creates a new SGR (Select Graphic Rendition) parser using the provided * allocator. The parser must be freed using ghostty_sgr_free() when * no longer needed. * * @param allocator Pointer to the allocator to use for memory management, or * NULL to use the default allocator * @param parser Pointer to store the created parser handle * @return GHOSTTY_SUCCESS on success, or an error code on failure * * @ingroup sgr */ GhosttyResult ghostty_sgr_new(const GhosttyAllocator* allocator, GhosttySgrParser* parser); /** * Free an SGR parser instance. * * Releases all resources associated with the SGR parser. After this call, * the parser handle becomes invalid and must not be used. This includes * any attributes previously returned by ghostty_sgr_next(). * * @param parser The parser handle to free (may be NULL) * * @ingroup sgr */ void ghostty_sgr_free(GhosttySgrParser parser); /** * Reset an SGR parser instance to the beginning of the parameter list. * * Resets the parser's iteration state without clearing the parameters. * After calling this, ghostty_sgr_next() will start from the beginning * of the parameter list again. * * @param parser The parser handle to reset, must not be NULL * * @ingroup sgr */ void ghostty_sgr_reset(GhosttySgrParser parser); /** * Set SGR parameters for parsing. * * Sets the SGR parameter list to parse. Parameters are the numeric values * from a CSI SGR sequence (e.g., for `ESC[1;31m`, params would be {1, 31}). * * The separators array optionally specifies the separator type for each * parameter position. Each byte should be either ';' for semicolon or ':' * for colon. This is needed for certain color formats that use colon * separators (e.g., `ESC[4:3m` for curly underline). Any invalid separator * values are treated as semicolons. The separators array must have the same * length as the params array, if it is not NULL. * * If separators is NULL, all parameters are assumed to be semicolon-separated. * * This function makes an internal copy of the parameter and separator data, * so the caller can safely free or modify the input arrays after this call. * * After calling this function, the parser is automatically reset and ready * to iterate from the beginning. * * @param parser The parser handle, must not be NULL * @param params Array of SGR parameter values * @param separators Optional array of separator characters (';' or ':'), or * NULL * @param len Number of parameters (and separators if provided) * @return GHOSTTY_SUCCESS on success, or an error code on failure * * @ingroup sgr */ GhosttyResult ghostty_sgr_set_params(GhosttySgrParser parser, const uint16_t* params, const char* separators, size_t len); /** * Get the next SGR attribute. * * Parses and returns the next attribute from the parameter list. * Call this function repeatedly until it returns false to process * all attributes in the sequence. * * @param parser The parser handle, must not be NULL * @param attr Pointer to store the next attribute * @return true if an attribute was returned, false if no more attributes * * @ingroup sgr */ bool ghostty_sgr_next(GhosttySgrParser parser, GhosttySgrAttribute* attr); /** * Get the full parameter list from an unknown SGR attribute. * * This function retrieves the full parameter list that was provided to the * parser when an unknown attribute was encountered. Primarily useful in * WebAssembly environments where accessing struct fields directly is difficult. * * @param unknown The unknown attribute data * @param ptr Pointer to store the pointer to the parameter array (may be NULL) * @return The length of the full parameter array * * @ingroup sgr */ size_t ghostty_sgr_unknown_full(GhosttySgrUnknown unknown, const uint16_t** ptr); /** * Get the partial parameter list from an unknown SGR attribute. * * This function retrieves the partial parameter list where parsing stopped * when an unknown attribute was encountered. Primarily useful in WebAssembly * environments where accessing struct fields directly is difficult. * * @param unknown The unknown attribute data * @param ptr Pointer to store the pointer to the parameter array (may be NULL) * @return The length of the partial parameter array * * @ingroup sgr */ size_t ghostty_sgr_unknown_partial(GhosttySgrUnknown unknown, const uint16_t** ptr); /** * Get the tag from an SGR attribute. * * This function extracts the tag that identifies which type of attribute * this is. Primarily useful in WebAssembly environments where accessing * struct fields directly is difficult. * * @param attr The SGR attribute * @return The attribute tag * * @ingroup sgr */ GhosttySgrAttributeTag ghostty_sgr_attribute_tag(GhosttySgrAttribute attr); /** * Get the value from an SGR attribute. * * This function returns a pointer to the value union from an SGR attribute. Use * the tag to determine which field of the union is valid. Primarily useful in * WebAssembly environments where accessing struct fields directly is difficult. * * @param attr Pointer to the SGR attribute * @return Pointer to the attribute value union * * @ingroup sgr */ GhosttySgrAttributeValue* ghostty_sgr_attribute_value( GhosttySgrAttribute* attr); #ifdef __wasm__ /** * Allocate memory for an SGR attribute (WebAssembly only). * * This is a convenience function for WebAssembly environments to allocate * memory for an SGR attribute structure that can be passed to ghostty_sgr_next. * * @return Pointer to the allocated attribute structure * * @ingroup wasm */ GhosttySgrAttribute* ghostty_wasm_alloc_sgr_attribute(void); /** * Free memory for an SGR attribute (WebAssembly only). * * Frees memory allocated by ghostty_wasm_alloc_sgr_attribute. * * @param attr Pointer to the attribute structure to free * * @ingroup wasm */ void ghostty_wasm_free_sgr_attribute(GhosttySgrAttribute* attr); #endif #ifdef __cplusplus } #endif /** @} */ #endif /* GHOSTTY_VT_SGR_H */ ================================================ FILE: ghostty/Vendor/include/ghostty/vt/wasm.h ================================================ /** * @file wasm.h * * WebAssembly utility functions for libghostty-vt. */ #ifndef GHOSTTY_VT_WASM_H #define GHOSTTY_VT_WASM_H #ifdef __wasm__ #include #include /** @defgroup wasm WebAssembly Utilities * * Convenience functions for allocating various types in WebAssembly builds. * **These are only available the libghostty-vt wasm module.** * * Ghostty relies on pointers to various types for ABI compatibility, and * creating those pointers in Wasm can be tedious. These functions provide * a purely additive set of utilities that simplify memory management in * Wasm environments without changing the core C library API. * * @note These functions always use the default allocator. If you need * custom allocation strategies, you should allocate types manually using * your custom allocator. This is a very rare use case in the WebAssembly * world so these are optimized for simplicity. * * ## Example Usage * * Here's a simple example of using the Wasm utilities with the key encoder: * * @code * const { exports } = wasmInstance; * const view = new DataView(wasmMemory.buffer); * * // Create key encoder * const encoderPtr = exports.ghostty_wasm_alloc_opaque(); * exports.ghostty_key_encoder_new(null, encoderPtr); * const encoder = view.getUint32(encoder, true); * * // Configure encoder with Kitty protocol flags * const flagsPtr = exports.ghostty_wasm_alloc_u8(); * view.setUint8(flagsPtr, 0x1F); * exports.ghostty_key_encoder_setopt(encoder, 5, flagsPtr); * * // Allocate output buffer and size pointer * const bufferSize = 32; * const bufPtr = exports.ghostty_wasm_alloc_u8_array(bufferSize); * const writtenPtr = exports.ghostty_wasm_alloc_usize(); * * // Encode the key event * exports.ghostty_key_encoder_encode( * encoder, eventPtr, bufPtr, bufferSize, writtenPtr * ); * * // Read encoded output * const bytesWritten = view.getUint32(writtenPtr, true); * const encoded = new Uint8Array(wasmMemory.buffer, bufPtr, bytesWritten); * @endcode * * @remark The code above is pretty ugly! This is the lowest level interface * to the libghostty-vt Wasm module. In practice, this should be wrapped * in a higher-level API that abstracts away all this. * * @{ */ /** * Allocate an opaque pointer. This can be used for any opaque pointer * types such as GhosttyKeyEncoder, GhosttyKeyEvent, etc. * * @return Pointer to allocated opaque pointer, or NULL if allocation failed * @ingroup wasm */ void** ghostty_wasm_alloc_opaque(void); /** * Free an opaque pointer allocated by ghostty_wasm_alloc_opaque(). * * @param ptr Pointer to free, or NULL (NULL is safely ignored) * @ingroup wasm */ void ghostty_wasm_free_opaque(void **ptr); /** * Allocate an array of uint8_t values. * * @param len Number of uint8_t elements to allocate * @return Pointer to allocated array, or NULL if allocation failed * @ingroup wasm */ uint8_t* ghostty_wasm_alloc_u8_array(size_t len); /** * Free an array allocated by ghostty_wasm_alloc_u8_array(). * * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored) * @param len Length of the array (must match the length passed to alloc) * @ingroup wasm */ void ghostty_wasm_free_u8_array(uint8_t *ptr, size_t len); /** * Allocate an array of uint16_t values. * * @param len Number of uint16_t elements to allocate * @return Pointer to allocated array, or NULL if allocation failed * @ingroup wasm */ uint16_t* ghostty_wasm_alloc_u16_array(size_t len); /** * Free an array allocated by ghostty_wasm_alloc_u16_array(). * * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored) * @param len Length of the array (must match the length passed to alloc) * @ingroup wasm */ void ghostty_wasm_free_u16_array(uint16_t *ptr, size_t len); /** * Allocate a single uint8_t value. * * @return Pointer to allocated uint8_t, or NULL if allocation failed * @ingroup wasm */ uint8_t* ghostty_wasm_alloc_u8(void); /** * Free a uint8_t allocated by ghostty_wasm_alloc_u8(). * * @param ptr Pointer to free, or NULL (NULL is safely ignored) * @ingroup wasm */ void ghostty_wasm_free_u8(uint8_t *ptr); /** * Allocate a single size_t value. * * @return Pointer to allocated size_t, or NULL if allocation failed * @ingroup wasm */ size_t* ghostty_wasm_alloc_usize(void); /** * Free a size_t allocated by ghostty_wasm_alloc_usize(). * * @param ptr Pointer to free, or NULL (NULL is safely ignored) * @ingroup wasm */ void ghostty_wasm_free_usize(size_t *ptr); /** @} */ #endif /* __wasm__ */ #endif /* GHOSTTY_VT_WASM_H */ ================================================ FILE: ghostty/Vendor/include/ghostty/vt.h ================================================ /** * @file vt.h * * libghostty-vt - Virtual terminal emulator library * * This library provides functionality for parsing and handling terminal * escape sequences as well as maintaining terminal state such as styles, * cursor position, screen, scrollback, and more. * * WARNING: This is an incomplete, work-in-progress API. It is not yet * stable and is definitely going to change. */ /** * @mainpage libghostty-vt - Virtual Terminal Emulator Library * * libghostty-vt is a C library which implements a modern terminal emulator, * extracted from the [Ghostty](https://ghostty.org) terminal emulator. * * libghostty-vt contains the logic for handling the core parts of a terminal * emulator: parsing terminal escape sequences, maintaining terminal state, * encoding input events, etc. It can handle scrollback, line wrapping, * reflow on resize, and more. * * @warning This library is currently in development and the API is not yet stable. * Breaking changes are expected in future versions. Use with caution in production code. * * @section groups_sec API Reference * * The API is organized into the following groups: * - @ref key "Key Encoding" - Encode key events into terminal sequences * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences * - @ref paste "Paste Utilities" - Validate paste data safety * - @ref allocator "Memory Management" - Memory management and custom allocators * - @ref wasm "WebAssembly Utilities" - WebAssembly convenience functions * * @section examples_sec Examples * * Complete working examples: * - @ref c-vt/src/main.c - OSC parser example * - @ref c-vt-key-encode/src/main.c - Key encoding example * - @ref c-vt-paste/src/main.c - Paste safety check example * - @ref c-vt-sgr/src/main.c - SGR parser example * */ /** @example c-vt/src/main.c * This example demonstrates how to use the OSC parser to parse an OSC sequence, * extract command information, and retrieve command-specific data like window titles. */ /** @example c-vt-key-encode/src/main.c * This example demonstrates how to use the key encoder to convert key events * into terminal escape sequences using the Kitty keyboard protocol. */ /** @example c-vt-paste/src/main.c * This example demonstrates how to use the paste utilities to check if * paste data is safe before sending it to the terminal. */ /** @example c-vt-sgr/src/main.c * This example demonstrates how to use the SGR parser to parse terminal * styling sequences and extract text attributes like colors and underline styles. */ #ifndef GHOSTTY_VT_H #define GHOSTTY_VT_H #ifdef __cplusplus extern "C" { #endif #include #include #include #include #include #include #include #ifdef __cplusplus } #endif #endif /* GHOSTTY_VT_H */ ================================================ FILE: ghostty/Vendor/include/ghostty.h ================================================ // Ghostty embedding API. The documentation for the embedding API is // only within the Zig source files that define the implementations. This // isn't meant to be a general purpose embedding API (yet) so there hasn't // been documentation or example work beyond that. // // The only consumer of this API is the macOS app, but the API is built to // be more general purpose. #ifndef GHOSTTY_H #define GHOSTTY_H #ifdef __cplusplus extern "C" { #endif #include #include #include #include //------------------------------------------------------------------- // Macros #define GHOSTTY_SUCCESS 0 //------------------------------------------------------------------- // Types // Opaque types typedef void* ghostty_app_t; typedef void* ghostty_config_t; typedef void* ghostty_surface_t; typedef void* ghostty_inspector_t; // All the types below are fully defined and must be kept in sync with // their Zig counterparts. Any changes to these types MUST have an associated // Zig change. typedef enum { GHOSTTY_PLATFORM_INVALID, GHOSTTY_PLATFORM_MACOS, GHOSTTY_PLATFORM_IOS, } ghostty_platform_e; typedef enum { GHOSTTY_CLIPBOARD_STANDARD, GHOSTTY_CLIPBOARD_SELECTION, } ghostty_clipboard_e; typedef struct { const char *mime; const char *data; } ghostty_clipboard_content_s; typedef enum { GHOSTTY_CLIPBOARD_REQUEST_PASTE, GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ, GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE, } ghostty_clipboard_request_e; typedef enum { GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_PRESS, } ghostty_input_mouse_state_e; typedef enum { GHOSTTY_MOUSE_UNKNOWN, GHOSTTY_MOUSE_LEFT, GHOSTTY_MOUSE_RIGHT, GHOSTTY_MOUSE_MIDDLE, } ghostty_input_mouse_button_e; typedef enum { GHOSTTY_MOUSE_MOMENTUM_NONE, GHOSTTY_MOUSE_MOMENTUM_BEGAN, GHOSTTY_MOUSE_MOMENTUM_STATIONARY, GHOSTTY_MOUSE_MOMENTUM_CHANGED, GHOSTTY_MOUSE_MOMENTUM_ENDED, GHOSTTY_MOUSE_MOMENTUM_CANCELLED, GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN, } ghostty_input_mouse_momentum_e; typedef enum { GHOSTTY_COLOR_SCHEME_LIGHT = 0, GHOSTTY_COLOR_SCHEME_DARK = 1, } ghostty_color_scheme_e; // This is a packed struct (see src/input/mouse.zig) but the C standard // afaik doesn't let us reliably define packed structs so we build it up // from scratch. typedef int ghostty_input_scroll_mods_t; typedef enum { GHOSTTY_MODS_NONE = 0, GHOSTTY_MODS_SHIFT = 1 << 0, GHOSTTY_MODS_CTRL = 1 << 1, GHOSTTY_MODS_ALT = 1 << 2, GHOSTTY_MODS_SUPER = 1 << 3, GHOSTTY_MODS_CAPS = 1 << 4, GHOSTTY_MODS_NUM = 1 << 5, GHOSTTY_MODS_SHIFT_RIGHT = 1 << 6, GHOSTTY_MODS_CTRL_RIGHT = 1 << 7, GHOSTTY_MODS_ALT_RIGHT = 1 << 8, GHOSTTY_MODS_SUPER_RIGHT = 1 << 9, } ghostty_input_mods_e; typedef enum { GHOSTTY_BINDING_FLAGS_CONSUMED = 1 << 0, GHOSTTY_BINDING_FLAGS_ALL = 1 << 1, GHOSTTY_BINDING_FLAGS_GLOBAL = 1 << 2, GHOSTTY_BINDING_FLAGS_PERFORMABLE = 1 << 3, } ghostty_binding_flags_e; typedef enum { GHOSTTY_ACTION_RELEASE, GHOSTTY_ACTION_PRESS, GHOSTTY_ACTION_REPEAT, } ghostty_input_action_e; // Based on: https://www.w3.org/TR/uievents-code/ typedef enum { GHOSTTY_KEY_UNIDENTIFIED, // "Writing System Keys" § 3.1.1 GHOSTTY_KEY_BACKQUOTE, GHOSTTY_KEY_BACKSLASH, GHOSTTY_KEY_BRACKET_LEFT, GHOSTTY_KEY_BRACKET_RIGHT, GHOSTTY_KEY_COMMA, GHOSTTY_KEY_DIGIT_0, GHOSTTY_KEY_DIGIT_1, GHOSTTY_KEY_DIGIT_2, GHOSTTY_KEY_DIGIT_3, GHOSTTY_KEY_DIGIT_4, GHOSTTY_KEY_DIGIT_5, GHOSTTY_KEY_DIGIT_6, GHOSTTY_KEY_DIGIT_7, GHOSTTY_KEY_DIGIT_8, GHOSTTY_KEY_DIGIT_9, GHOSTTY_KEY_EQUAL, GHOSTTY_KEY_INTL_BACKSLASH, GHOSTTY_KEY_INTL_RO, GHOSTTY_KEY_INTL_YEN, GHOSTTY_KEY_A, GHOSTTY_KEY_B, GHOSTTY_KEY_C, GHOSTTY_KEY_D, GHOSTTY_KEY_E, GHOSTTY_KEY_F, GHOSTTY_KEY_G, GHOSTTY_KEY_H, GHOSTTY_KEY_I, GHOSTTY_KEY_J, GHOSTTY_KEY_K, GHOSTTY_KEY_L, GHOSTTY_KEY_M, GHOSTTY_KEY_N, GHOSTTY_KEY_O, GHOSTTY_KEY_P, GHOSTTY_KEY_Q, GHOSTTY_KEY_R, GHOSTTY_KEY_S, GHOSTTY_KEY_T, GHOSTTY_KEY_U, GHOSTTY_KEY_V, GHOSTTY_KEY_W, GHOSTTY_KEY_X, GHOSTTY_KEY_Y, GHOSTTY_KEY_Z, GHOSTTY_KEY_MINUS, GHOSTTY_KEY_PERIOD, GHOSTTY_KEY_QUOTE, GHOSTTY_KEY_SEMICOLON, GHOSTTY_KEY_SLASH, // "Functional Keys" § 3.1.2 GHOSTTY_KEY_ALT_LEFT, GHOSTTY_KEY_ALT_RIGHT, GHOSTTY_KEY_BACKSPACE, GHOSTTY_KEY_CAPS_LOCK, GHOSTTY_KEY_CONTEXT_MENU, GHOSTTY_KEY_CONTROL_LEFT, GHOSTTY_KEY_CONTROL_RIGHT, GHOSTTY_KEY_ENTER, GHOSTTY_KEY_META_LEFT, GHOSTTY_KEY_META_RIGHT, GHOSTTY_KEY_SHIFT_LEFT, GHOSTTY_KEY_SHIFT_RIGHT, GHOSTTY_KEY_SPACE, GHOSTTY_KEY_TAB, GHOSTTY_KEY_CONVERT, GHOSTTY_KEY_KANA_MODE, GHOSTTY_KEY_NON_CONVERT, // "Control Pad Section" § 3.2 GHOSTTY_KEY_DELETE, GHOSTTY_KEY_END, GHOSTTY_KEY_HELP, GHOSTTY_KEY_HOME, GHOSTTY_KEY_INSERT, GHOSTTY_KEY_PAGE_DOWN, GHOSTTY_KEY_PAGE_UP, // "Arrow Pad Section" § 3.3 GHOSTTY_KEY_ARROW_DOWN, GHOSTTY_KEY_ARROW_LEFT, GHOSTTY_KEY_ARROW_RIGHT, GHOSTTY_KEY_ARROW_UP, // "Numpad Section" § 3.4 GHOSTTY_KEY_NUM_LOCK, GHOSTTY_KEY_NUMPAD_0, GHOSTTY_KEY_NUMPAD_1, GHOSTTY_KEY_NUMPAD_2, GHOSTTY_KEY_NUMPAD_3, GHOSTTY_KEY_NUMPAD_4, GHOSTTY_KEY_NUMPAD_5, GHOSTTY_KEY_NUMPAD_6, GHOSTTY_KEY_NUMPAD_7, GHOSTTY_KEY_NUMPAD_8, GHOSTTY_KEY_NUMPAD_9, GHOSTTY_KEY_NUMPAD_ADD, GHOSTTY_KEY_NUMPAD_BACKSPACE, GHOSTTY_KEY_NUMPAD_CLEAR, GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY, GHOSTTY_KEY_NUMPAD_COMMA, GHOSTTY_KEY_NUMPAD_DECIMAL, GHOSTTY_KEY_NUMPAD_DIVIDE, GHOSTTY_KEY_NUMPAD_ENTER, GHOSTTY_KEY_NUMPAD_EQUAL, GHOSTTY_KEY_NUMPAD_MEMORY_ADD, GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR, GHOSTTY_KEY_NUMPAD_MEMORY_RECALL, GHOSTTY_KEY_NUMPAD_MEMORY_STORE, GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT, GHOSTTY_KEY_NUMPAD_MULTIPLY, GHOSTTY_KEY_NUMPAD_PAREN_LEFT, GHOSTTY_KEY_NUMPAD_PAREN_RIGHT, GHOSTTY_KEY_NUMPAD_SUBTRACT, GHOSTTY_KEY_NUMPAD_SEPARATOR, GHOSTTY_KEY_NUMPAD_UP, GHOSTTY_KEY_NUMPAD_DOWN, GHOSTTY_KEY_NUMPAD_RIGHT, GHOSTTY_KEY_NUMPAD_LEFT, GHOSTTY_KEY_NUMPAD_BEGIN, GHOSTTY_KEY_NUMPAD_HOME, GHOSTTY_KEY_NUMPAD_END, GHOSTTY_KEY_NUMPAD_INSERT, GHOSTTY_KEY_NUMPAD_DELETE, GHOSTTY_KEY_NUMPAD_PAGE_UP, GHOSTTY_KEY_NUMPAD_PAGE_DOWN, // "Function Section" § 3.5 GHOSTTY_KEY_ESCAPE, GHOSTTY_KEY_F1, GHOSTTY_KEY_F2, GHOSTTY_KEY_F3, GHOSTTY_KEY_F4, GHOSTTY_KEY_F5, GHOSTTY_KEY_F6, GHOSTTY_KEY_F7, GHOSTTY_KEY_F8, GHOSTTY_KEY_F9, GHOSTTY_KEY_F10, GHOSTTY_KEY_F11, GHOSTTY_KEY_F12, GHOSTTY_KEY_F13, GHOSTTY_KEY_F14, GHOSTTY_KEY_F15, GHOSTTY_KEY_F16, GHOSTTY_KEY_F17, GHOSTTY_KEY_F18, GHOSTTY_KEY_F19, GHOSTTY_KEY_F20, GHOSTTY_KEY_F21, GHOSTTY_KEY_F22, GHOSTTY_KEY_F23, GHOSTTY_KEY_F24, GHOSTTY_KEY_F25, GHOSTTY_KEY_FN, GHOSTTY_KEY_FN_LOCK, GHOSTTY_KEY_PRINT_SCREEN, GHOSTTY_KEY_SCROLL_LOCK, GHOSTTY_KEY_PAUSE, // "Media Keys" § 3.6 GHOSTTY_KEY_BROWSER_BACK, GHOSTTY_KEY_BROWSER_FAVORITES, GHOSTTY_KEY_BROWSER_FORWARD, GHOSTTY_KEY_BROWSER_HOME, GHOSTTY_KEY_BROWSER_REFRESH, GHOSTTY_KEY_BROWSER_SEARCH, GHOSTTY_KEY_BROWSER_STOP, GHOSTTY_KEY_EJECT, GHOSTTY_KEY_LAUNCH_APP_1, GHOSTTY_KEY_LAUNCH_APP_2, GHOSTTY_KEY_LAUNCH_MAIL, GHOSTTY_KEY_MEDIA_PLAY_PAUSE, GHOSTTY_KEY_MEDIA_SELECT, GHOSTTY_KEY_MEDIA_STOP, GHOSTTY_KEY_MEDIA_TRACK_NEXT, GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS, GHOSTTY_KEY_POWER, GHOSTTY_KEY_SLEEP, GHOSTTY_KEY_AUDIO_VOLUME_DOWN, GHOSTTY_KEY_AUDIO_VOLUME_MUTE, GHOSTTY_KEY_AUDIO_VOLUME_UP, GHOSTTY_KEY_WAKE_UP, // "Legacy, Non-standard, and Special Keys" § 3.7 GHOSTTY_KEY_COPY, GHOSTTY_KEY_CUT, GHOSTTY_KEY_PASTE, } ghostty_input_key_e; typedef struct { ghostty_input_action_e action; ghostty_input_mods_e mods; ghostty_input_mods_e consumed_mods; uint32_t keycode; const char* text; uint32_t unshifted_codepoint; bool composing; } ghostty_input_key_s; typedef enum { GHOSTTY_TRIGGER_PHYSICAL, GHOSTTY_TRIGGER_UNICODE, GHOSTTY_TRIGGER_CATCH_ALL, } ghostty_input_trigger_tag_e; typedef union { ghostty_input_key_e translated; ghostty_input_key_e physical; uint32_t unicode; // catch_all has no payload } ghostty_input_trigger_key_u; typedef struct { ghostty_input_trigger_tag_e tag; ghostty_input_trigger_key_u key; ghostty_input_mods_e mods; } ghostty_input_trigger_s; typedef struct { const char* action_key; const char* action; const char* title; const char* description; } ghostty_command_s; typedef enum { GHOSTTY_BUILD_MODE_DEBUG, GHOSTTY_BUILD_MODE_RELEASE_SAFE, GHOSTTY_BUILD_MODE_RELEASE_FAST, GHOSTTY_BUILD_MODE_RELEASE_SMALL, } ghostty_build_mode_e; typedef struct { ghostty_build_mode_e build_mode; const char* version; uintptr_t version_len; } ghostty_info_s; typedef struct { const char* message; } ghostty_diagnostic_s; typedef struct { const char* ptr; uintptr_t len; bool sentinel; } ghostty_string_s; typedef struct { double tl_px_x; double tl_px_y; uint32_t offset_start; uint32_t offset_len; const char* text; uintptr_t text_len; } ghostty_text_s; typedef enum { GHOSTTY_POINT_ACTIVE, GHOSTTY_POINT_VIEWPORT, GHOSTTY_POINT_SCREEN, GHOSTTY_POINT_SURFACE, } ghostty_point_tag_e; typedef enum { GHOSTTY_POINT_COORD_EXACT, GHOSTTY_POINT_COORD_TOP_LEFT, GHOSTTY_POINT_COORD_BOTTOM_RIGHT, } ghostty_point_coord_e; typedef struct { ghostty_point_tag_e tag; ghostty_point_coord_e coord; uint32_t x; uint32_t y; } ghostty_point_s; typedef struct { ghostty_point_s top_left; ghostty_point_s bottom_right; bool rectangle; } ghostty_selection_s; typedef struct { const char* key; const char* value; } ghostty_env_var_s; typedef struct { void* nsview; } ghostty_platform_macos_s; typedef struct { void* uiview; } ghostty_platform_ios_s; typedef union { ghostty_platform_macos_s macos; ghostty_platform_ios_s ios; } ghostty_platform_u; typedef enum { GHOSTTY_SURFACE_CONTEXT_WINDOW = 0, GHOSTTY_SURFACE_CONTEXT_TAB = 1, GHOSTTY_SURFACE_CONTEXT_SPLIT = 2, } ghostty_surface_context_e; typedef struct { ghostty_platform_e platform_tag; ghostty_platform_u platform; void* userdata; double scale_factor; float font_size; const char* working_directory; const char* command; ghostty_env_var_s* env_vars; size_t env_var_count; const char* initial_input; bool wait_after_command; ghostty_surface_context_e context; } ghostty_surface_config_s; typedef struct { uint16_t columns; uint16_t rows; uint32_t width_px; uint32_t height_px; uint32_t cell_width_px; uint32_t cell_height_px; } ghostty_surface_size_s; // Config types // config.Color typedef struct { uint8_t r; uint8_t g; uint8_t b; } ghostty_config_color_s; // config.ColorList typedef struct { const ghostty_config_color_s* colors; size_t len; } ghostty_config_color_list_s; // config.RepeatableCommand typedef struct { const ghostty_command_s* commands; size_t len; } ghostty_config_command_list_s; // config.Palette typedef struct { ghostty_config_color_s colors[256]; } ghostty_config_palette_s; // config.QuickTerminalSize typedef enum { GHOSTTY_QUICK_TERMINAL_SIZE_NONE, GHOSTTY_QUICK_TERMINAL_SIZE_PERCENTAGE, GHOSTTY_QUICK_TERMINAL_SIZE_PIXELS, } ghostty_quick_terminal_size_tag_e; typedef union { float percentage; uint32_t pixels; } ghostty_quick_terminal_size_value_u; typedef struct { ghostty_quick_terminal_size_tag_e tag; ghostty_quick_terminal_size_value_u value; } ghostty_quick_terminal_size_s; typedef struct { ghostty_quick_terminal_size_s primary; ghostty_quick_terminal_size_s secondary; } ghostty_config_quick_terminal_size_s; // apprt.Target.Key typedef enum { GHOSTTY_TARGET_APP, GHOSTTY_TARGET_SURFACE, } ghostty_target_tag_e; typedef union { ghostty_surface_t surface; } ghostty_target_u; typedef struct { ghostty_target_tag_e tag; ghostty_target_u target; } ghostty_target_s; // apprt.action.SplitDirection typedef enum { GHOSTTY_SPLIT_DIRECTION_RIGHT, GHOSTTY_SPLIT_DIRECTION_DOWN, GHOSTTY_SPLIT_DIRECTION_LEFT, GHOSTTY_SPLIT_DIRECTION_UP, } ghostty_action_split_direction_e; // apprt.action.GotoSplit typedef enum { GHOSTTY_GOTO_SPLIT_PREVIOUS, GHOSTTY_GOTO_SPLIT_NEXT, GHOSTTY_GOTO_SPLIT_UP, GHOSTTY_GOTO_SPLIT_LEFT, GHOSTTY_GOTO_SPLIT_DOWN, GHOSTTY_GOTO_SPLIT_RIGHT, } ghostty_action_goto_split_e; // apprt.action.GotoWindow typedef enum { GHOSTTY_GOTO_WINDOW_PREVIOUS, GHOSTTY_GOTO_WINDOW_NEXT, } ghostty_action_goto_window_e; // apprt.action.ResizeSplit.Direction typedef enum { GHOSTTY_RESIZE_SPLIT_UP, GHOSTTY_RESIZE_SPLIT_DOWN, GHOSTTY_RESIZE_SPLIT_LEFT, GHOSTTY_RESIZE_SPLIT_RIGHT, } ghostty_action_resize_split_direction_e; // apprt.action.ResizeSplit typedef struct { uint16_t amount; ghostty_action_resize_split_direction_e direction; } ghostty_action_resize_split_s; // apprt.action.MoveTab typedef struct { ssize_t amount; } ghostty_action_move_tab_s; // apprt.action.GotoTab typedef enum { GHOSTTY_GOTO_TAB_PREVIOUS = -1, GHOSTTY_GOTO_TAB_NEXT = -2, GHOSTTY_GOTO_TAB_LAST = -3, } ghostty_action_goto_tab_e; // apprt.action.Fullscreen typedef enum { GHOSTTY_FULLSCREEN_NATIVE, GHOSTTY_FULLSCREEN_NON_NATIVE, GHOSTTY_FULLSCREEN_NON_NATIVE_VISIBLE_MENU, GHOSTTY_FULLSCREEN_NON_NATIVE_PADDED_NOTCH, } ghostty_action_fullscreen_e; // apprt.action.FloatWindow typedef enum { GHOSTTY_FLOAT_WINDOW_ON, GHOSTTY_FLOAT_WINDOW_OFF, GHOSTTY_FLOAT_WINDOW_TOGGLE, } ghostty_action_float_window_e; // apprt.action.SecureInput typedef enum { GHOSTTY_SECURE_INPUT_ON, GHOSTTY_SECURE_INPUT_OFF, GHOSTTY_SECURE_INPUT_TOGGLE, } ghostty_action_secure_input_e; // apprt.action.Inspector typedef enum { GHOSTTY_INSPECTOR_TOGGLE, GHOSTTY_INSPECTOR_SHOW, GHOSTTY_INSPECTOR_HIDE, } ghostty_action_inspector_e; // apprt.action.QuitTimer typedef enum { GHOSTTY_QUIT_TIMER_START, GHOSTTY_QUIT_TIMER_STOP, } ghostty_action_quit_timer_e; // apprt.action.Readonly typedef enum { GHOSTTY_READONLY_OFF, GHOSTTY_READONLY_ON, } ghostty_action_readonly_e; // apprt.action.DesktopNotification.C typedef struct { const char* title; const char* body; } ghostty_action_desktop_notification_s; // apprt.action.SetTitle.C typedef struct { const char* title; } ghostty_action_set_title_s; // apprt.action.PromptTitle typedef enum { GHOSTTY_PROMPT_TITLE_SURFACE, GHOSTTY_PROMPT_TITLE_TAB, } ghostty_action_prompt_title_e; // apprt.action.Pwd.C typedef struct { const char* pwd; } ghostty_action_pwd_s; // terminal.MouseShape typedef enum { GHOSTTY_MOUSE_SHAPE_DEFAULT, GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU, GHOSTTY_MOUSE_SHAPE_HELP, GHOSTTY_MOUSE_SHAPE_POINTER, GHOSTTY_MOUSE_SHAPE_PROGRESS, GHOSTTY_MOUSE_SHAPE_WAIT, GHOSTTY_MOUSE_SHAPE_CELL, GHOSTTY_MOUSE_SHAPE_CROSSHAIR, GHOSTTY_MOUSE_SHAPE_TEXT, GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT, GHOSTTY_MOUSE_SHAPE_ALIAS, GHOSTTY_MOUSE_SHAPE_COPY, GHOSTTY_MOUSE_SHAPE_MOVE, GHOSTTY_MOUSE_SHAPE_NO_DROP, GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED, GHOSTTY_MOUSE_SHAPE_GRAB, GHOSTTY_MOUSE_SHAPE_GRABBING, GHOSTTY_MOUSE_SHAPE_ALL_SCROLL, GHOSTTY_MOUSE_SHAPE_COL_RESIZE, GHOSTTY_MOUSE_SHAPE_ROW_RESIZE, GHOSTTY_MOUSE_SHAPE_N_RESIZE, GHOSTTY_MOUSE_SHAPE_E_RESIZE, GHOSTTY_MOUSE_SHAPE_S_RESIZE, GHOSTTY_MOUSE_SHAPE_W_RESIZE, GHOSTTY_MOUSE_SHAPE_NE_RESIZE, GHOSTTY_MOUSE_SHAPE_NW_RESIZE, GHOSTTY_MOUSE_SHAPE_SE_RESIZE, GHOSTTY_MOUSE_SHAPE_SW_RESIZE, GHOSTTY_MOUSE_SHAPE_EW_RESIZE, GHOSTTY_MOUSE_SHAPE_NS_RESIZE, GHOSTTY_MOUSE_SHAPE_NESW_RESIZE, GHOSTTY_MOUSE_SHAPE_NWSE_RESIZE, GHOSTTY_MOUSE_SHAPE_ZOOM_IN, GHOSTTY_MOUSE_SHAPE_ZOOM_OUT, } ghostty_action_mouse_shape_e; // apprt.action.MouseVisibility typedef enum { GHOSTTY_MOUSE_VISIBLE, GHOSTTY_MOUSE_HIDDEN, } ghostty_action_mouse_visibility_e; // apprt.action.MouseOverLink typedef struct { const char* url; size_t len; } ghostty_action_mouse_over_link_s; // apprt.action.SizeLimit typedef struct { uint32_t min_width; uint32_t min_height; uint32_t max_width; uint32_t max_height; } ghostty_action_size_limit_s; // apprt.action.InitialSize typedef struct { uint32_t width; uint32_t height; } ghostty_action_initial_size_s; // apprt.action.CellSize typedef struct { uint32_t width; uint32_t height; } ghostty_action_cell_size_s; // renderer.Health typedef enum { GHOSTTY_RENDERER_HEALTH_OK, GHOSTTY_RENDERER_HEALTH_UNHEALTHY, } ghostty_action_renderer_health_e; // apprt.action.KeySequence typedef struct { bool active; ghostty_input_trigger_s trigger; } ghostty_action_key_sequence_s; // apprt.action.KeyTable.Tag typedef enum { GHOSTTY_KEY_TABLE_ACTIVATE, GHOSTTY_KEY_TABLE_DEACTIVATE, GHOSTTY_KEY_TABLE_DEACTIVATE_ALL, } ghostty_action_key_table_tag_e; // apprt.action.KeyTable.CValue typedef union { struct { const char *name; size_t len; } activate; } ghostty_action_key_table_u; // apprt.action.KeyTable.C typedef struct { ghostty_action_key_table_tag_e tag; ghostty_action_key_table_u value; } ghostty_action_key_table_s; // apprt.action.ColorKind typedef enum { GHOSTTY_ACTION_COLOR_KIND_FOREGROUND = -1, GHOSTTY_ACTION_COLOR_KIND_BACKGROUND = -2, GHOSTTY_ACTION_COLOR_KIND_CURSOR = -3, } ghostty_action_color_kind_e; // apprt.action.ColorChange typedef struct { ghostty_action_color_kind_e kind; uint8_t r; uint8_t g; uint8_t b; } ghostty_action_color_change_s; // apprt.action.ConfigChange typedef struct { ghostty_config_t config; } ghostty_action_config_change_s; // apprt.action.ReloadConfig typedef struct { bool soft; } ghostty_action_reload_config_s; // apprt.action.OpenUrlKind typedef enum { GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN, GHOSTTY_ACTION_OPEN_URL_KIND_TEXT, GHOSTTY_ACTION_OPEN_URL_KIND_HTML, } ghostty_action_open_url_kind_e; // apprt.action.OpenUrl.C typedef struct { ghostty_action_open_url_kind_e kind; const char* url; uintptr_t len; } ghostty_action_open_url_s; // apprt.action.CloseTabMode typedef enum { GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS, GHOSTTY_ACTION_CLOSE_TAB_MODE_OTHER, GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT, } ghostty_action_close_tab_mode_e; // apprt.surface.Message.ChildExited typedef struct { uint32_t exit_code; uint64_t timetime_ms; } ghostty_surface_message_childexited_s; // terminal.osc.Command.ProgressReport.State typedef enum { GHOSTTY_PROGRESS_STATE_REMOVE, GHOSTTY_PROGRESS_STATE_SET, GHOSTTY_PROGRESS_STATE_ERROR, GHOSTTY_PROGRESS_STATE_INDETERMINATE, GHOSTTY_PROGRESS_STATE_PAUSE, } ghostty_action_progress_report_state_e; // terminal.osc.Command.ProgressReport.C typedef struct { ghostty_action_progress_report_state_e state; // -1 if no progress was reported, otherwise 0-100 indicating percent // completeness. int8_t progress; } ghostty_action_progress_report_s; // apprt.action.CommandFinished.C typedef struct { // -1 if no exit code was reported, otherwise 0-255 int16_t exit_code; // number of nanoseconds that command was running for uint64_t duration; } ghostty_action_command_finished_s; // apprt.action.StartSearch.C typedef struct { const char* needle; } ghostty_action_start_search_s; // apprt.action.SearchTotal typedef struct { ssize_t total; } ghostty_action_search_total_s; // apprt.action.SearchSelected typedef struct { ssize_t selected; } ghostty_action_search_selected_s; // terminal.Scrollbar typedef struct { uint64_t total; uint64_t offset; uint64_t len; } ghostty_action_scrollbar_s; // apprt.Action.Key typedef enum { GHOSTTY_ACTION_QUIT, GHOSTTY_ACTION_NEW_WINDOW, GHOSTTY_ACTION_NEW_TAB, GHOSTTY_ACTION_CLOSE_TAB, GHOSTTY_ACTION_NEW_SPLIT, GHOSTTY_ACTION_CLOSE_ALL_WINDOWS, GHOSTTY_ACTION_TOGGLE_MAXIMIZE, GHOSTTY_ACTION_TOGGLE_FULLSCREEN, GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW, GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS, GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL, GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE, GHOSTTY_ACTION_TOGGLE_VISIBILITY, GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY, GHOSTTY_ACTION_MOVE_TAB, GHOSTTY_ACTION_GOTO_TAB, GHOSTTY_ACTION_GOTO_SPLIT, GHOSTTY_ACTION_GOTO_WINDOW, GHOSTTY_ACTION_RESIZE_SPLIT, GHOSTTY_ACTION_EQUALIZE_SPLITS, GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM, GHOSTTY_ACTION_PRESENT_TERMINAL, GHOSTTY_ACTION_SIZE_LIMIT, GHOSTTY_ACTION_RESET_WINDOW_SIZE, GHOSTTY_ACTION_INITIAL_SIZE, GHOSTTY_ACTION_CELL_SIZE, GHOSTTY_ACTION_SCROLLBAR, GHOSTTY_ACTION_RENDER, GHOSTTY_ACTION_INSPECTOR, GHOSTTY_ACTION_SHOW_GTK_INSPECTOR, GHOSTTY_ACTION_RENDER_INSPECTOR, GHOSTTY_ACTION_DESKTOP_NOTIFICATION, GHOSTTY_ACTION_SET_TITLE, GHOSTTY_ACTION_PROMPT_TITLE, GHOSTTY_ACTION_PWD, GHOSTTY_ACTION_MOUSE_SHAPE, GHOSTTY_ACTION_MOUSE_VISIBILITY, GHOSTTY_ACTION_MOUSE_OVER_LINK, GHOSTTY_ACTION_RENDERER_HEALTH, GHOSTTY_ACTION_OPEN_CONFIG, GHOSTTY_ACTION_QUIT_TIMER, GHOSTTY_ACTION_FLOAT_WINDOW, GHOSTTY_ACTION_SECURE_INPUT, GHOSTTY_ACTION_KEY_SEQUENCE, GHOSTTY_ACTION_KEY_TABLE, GHOSTTY_ACTION_COLOR_CHANGE, GHOSTTY_ACTION_RELOAD_CONFIG, GHOSTTY_ACTION_CONFIG_CHANGE, GHOSTTY_ACTION_CLOSE_WINDOW, GHOSTTY_ACTION_RING_BELL, GHOSTTY_ACTION_UNDO, GHOSTTY_ACTION_REDO, GHOSTTY_ACTION_CHECK_FOR_UPDATES, GHOSTTY_ACTION_OPEN_URL, GHOSTTY_ACTION_SHOW_CHILD_EXITED, GHOSTTY_ACTION_PROGRESS_REPORT, GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD, GHOSTTY_ACTION_COMMAND_FINISHED, GHOSTTY_ACTION_START_SEARCH, GHOSTTY_ACTION_END_SEARCH, GHOSTTY_ACTION_SEARCH_TOTAL, GHOSTTY_ACTION_SEARCH_SELECTED, GHOSTTY_ACTION_READONLY, } ghostty_action_tag_e; typedef union { ghostty_action_split_direction_e new_split; ghostty_action_fullscreen_e toggle_fullscreen; ghostty_action_move_tab_s move_tab; ghostty_action_goto_tab_e goto_tab; ghostty_action_goto_split_e goto_split; ghostty_action_goto_window_e goto_window; ghostty_action_resize_split_s resize_split; ghostty_action_size_limit_s size_limit; ghostty_action_initial_size_s initial_size; ghostty_action_cell_size_s cell_size; ghostty_action_scrollbar_s scrollbar; ghostty_action_inspector_e inspector; ghostty_action_desktop_notification_s desktop_notification; ghostty_action_set_title_s set_title; ghostty_action_prompt_title_e prompt_title; ghostty_action_pwd_s pwd; ghostty_action_mouse_shape_e mouse_shape; ghostty_action_mouse_visibility_e mouse_visibility; ghostty_action_mouse_over_link_s mouse_over_link; ghostty_action_renderer_health_e renderer_health; ghostty_action_quit_timer_e quit_timer; ghostty_action_float_window_e float_window; ghostty_action_secure_input_e secure_input; ghostty_action_key_sequence_s key_sequence; ghostty_action_key_table_s key_table; ghostty_action_color_change_s color_change; ghostty_action_reload_config_s reload_config; ghostty_action_config_change_s config_change; ghostty_action_open_url_s open_url; ghostty_action_close_tab_mode_e close_tab_mode; ghostty_surface_message_childexited_s child_exited; ghostty_action_progress_report_s progress_report; ghostty_action_command_finished_s command_finished; ghostty_action_start_search_s start_search; ghostty_action_search_total_s search_total; ghostty_action_search_selected_s search_selected; ghostty_action_readonly_e readonly; } ghostty_action_u; typedef struct { ghostty_action_tag_e tag; ghostty_action_u action; } ghostty_action_s; typedef void (*ghostty_runtime_wakeup_cb)(void*); typedef void (*ghostty_runtime_read_clipboard_cb)(void*, ghostty_clipboard_e, void*); typedef void (*ghostty_runtime_confirm_read_clipboard_cb)( void*, const char*, void*, ghostty_clipboard_request_e); typedef void (*ghostty_runtime_write_clipboard_cb)(void*, ghostty_clipboard_e, const ghostty_clipboard_content_s*, size_t, bool); typedef void (*ghostty_runtime_close_surface_cb)(void*, bool); typedef bool (*ghostty_runtime_action_cb)(ghostty_app_t, ghostty_target_s, ghostty_action_s); typedef struct { void* userdata; bool supports_selection_clipboard; ghostty_runtime_wakeup_cb wakeup_cb; ghostty_runtime_action_cb action_cb; ghostty_runtime_read_clipboard_cb read_clipboard_cb; ghostty_runtime_confirm_read_clipboard_cb confirm_read_clipboard_cb; ghostty_runtime_write_clipboard_cb write_clipboard_cb; ghostty_runtime_close_surface_cb close_surface_cb; } ghostty_runtime_config_s; // apprt.ipc.Target.Key typedef enum { GHOSTTY_IPC_TARGET_CLASS, GHOSTTY_IPC_TARGET_DETECT, } ghostty_ipc_target_tag_e; typedef union { char *klass; } ghostty_ipc_target_u; typedef struct { ghostty_ipc_target_tag_e tag; ghostty_ipc_target_u target; } chostty_ipc_target_s; // apprt.ipc.Action.NewWindow typedef struct { // This should be a null terminated list of strings. const char **arguments; } ghostty_ipc_action_new_window_s; typedef union { ghostty_ipc_action_new_window_s new_window; } ghostty_ipc_action_u; // apprt.ipc.Action.Key typedef enum { GHOSTTY_IPC_ACTION_NEW_WINDOW, } ghostty_ipc_action_tag_e; //------------------------------------------------------------------- // Published API int ghostty_init(uintptr_t, char**); void ghostty_cli_try_action(void); ghostty_info_s ghostty_info(void); const char* ghostty_translate(const char*); void ghostty_string_free(ghostty_string_s); ghostty_config_t ghostty_config_new(); void ghostty_config_free(ghostty_config_t); ghostty_config_t ghostty_config_clone(ghostty_config_t); void ghostty_config_load_cli_args(ghostty_config_t); void ghostty_config_load_default_files(ghostty_config_t); void ghostty_config_load_recursive_files(ghostty_config_t); void ghostty_config_finalize(ghostty_config_t); bool ghostty_config_get(ghostty_config_t, void*, const char*, uintptr_t); ghostty_input_trigger_s ghostty_config_trigger(ghostty_config_t, const char*, uintptr_t); uint32_t ghostty_config_diagnostics_count(ghostty_config_t); ghostty_diagnostic_s ghostty_config_get_diagnostic(ghostty_config_t, uint32_t); ghostty_string_s ghostty_config_open_path(void); ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*, ghostty_config_t); void ghostty_app_free(ghostty_app_t); void ghostty_app_tick(ghostty_app_t); void* ghostty_app_userdata(ghostty_app_t); void ghostty_app_set_focus(ghostty_app_t, bool); bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s); bool ghostty_app_key_is_binding(ghostty_app_t, ghostty_input_key_s); void ghostty_app_keyboard_changed(ghostty_app_t); void ghostty_app_open_config(ghostty_app_t); void ghostty_app_update_config(ghostty_app_t, ghostty_config_t); bool ghostty_app_needs_confirm_quit(ghostty_app_t); bool ghostty_app_has_global_keybinds(ghostty_app_t); void ghostty_app_set_color_scheme(ghostty_app_t, ghostty_color_scheme_e); ghostty_surface_config_s ghostty_surface_config_new(); ghostty_surface_t ghostty_surface_new(ghostty_app_t, const ghostty_surface_config_s*); void ghostty_surface_free(ghostty_surface_t); void* ghostty_surface_userdata(ghostty_surface_t); ghostty_app_t ghostty_surface_app(ghostty_surface_t); ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t, ghostty_surface_context_e); void ghostty_surface_update_config(ghostty_surface_t, ghostty_config_t); bool ghostty_surface_needs_confirm_quit(ghostty_surface_t); bool ghostty_surface_process_exited(ghostty_surface_t); void ghostty_surface_refresh(ghostty_surface_t); void ghostty_surface_draw(ghostty_surface_t); void ghostty_surface_set_content_scale(ghostty_surface_t, double, double); void ghostty_surface_set_focus(ghostty_surface_t, bool); void ghostty_surface_set_occlusion(ghostty_surface_t, bool); void ghostty_surface_set_size(ghostty_surface_t, uint32_t, uint32_t); ghostty_surface_size_s ghostty_surface_size(ghostty_surface_t); void ghostty_surface_set_color_scheme(ghostty_surface_t, ghostty_color_scheme_e); ghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t, ghostty_input_mods_e); bool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s); bool ghostty_surface_key_is_binding(ghostty_surface_t, ghostty_input_key_s, ghostty_binding_flags_e*); void ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t); void ghostty_surface_preedit(ghostty_surface_t, const char*, uintptr_t); bool ghostty_surface_mouse_captured(ghostty_surface_t); bool ghostty_surface_mouse_button(ghostty_surface_t, ghostty_input_mouse_state_e, ghostty_input_mouse_button_e, ghostty_input_mods_e); void ghostty_surface_mouse_pos(ghostty_surface_t, double, double, ghostty_input_mods_e); void ghostty_surface_mouse_scroll(ghostty_surface_t, double, double, ghostty_input_scroll_mods_t); void ghostty_surface_mouse_pressure(ghostty_surface_t, uint32_t, double); void ghostty_surface_ime_point(ghostty_surface_t, double*, double*, double*, double*); void ghostty_surface_request_close(ghostty_surface_t); void ghostty_surface_split(ghostty_surface_t, ghostty_action_split_direction_e); void ghostty_surface_split_focus(ghostty_surface_t, ghostty_action_goto_split_e); void ghostty_surface_split_resize(ghostty_surface_t, ghostty_action_resize_split_direction_e, uint16_t); void ghostty_surface_split_equalize(ghostty_surface_t); bool ghostty_surface_binding_action(ghostty_surface_t, const char*, uintptr_t); void ghostty_surface_complete_clipboard_request(ghostty_surface_t, const char*, void*, bool); bool ghostty_surface_has_selection(ghostty_surface_t); bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*); bool ghostty_surface_read_text(ghostty_surface_t, ghostty_selection_s, ghostty_text_s*); void ghostty_surface_free_text(ghostty_surface_t, ghostty_text_s*); #ifdef __APPLE__ void ghostty_surface_set_display_id(ghostty_surface_t, uint32_t); void* ghostty_surface_quicklook_font(ghostty_surface_t); bool ghostty_surface_quicklook_word(ghostty_surface_t, ghostty_text_s*); #endif ghostty_inspector_t ghostty_surface_inspector(ghostty_surface_t); void ghostty_inspector_free(ghostty_surface_t); void ghostty_inspector_set_focus(ghostty_inspector_t, bool); void ghostty_inspector_set_content_scale(ghostty_inspector_t, double, double); void ghostty_inspector_set_size(ghostty_inspector_t, uint32_t, uint32_t); void ghostty_inspector_mouse_button(ghostty_inspector_t, ghostty_input_mouse_state_e, ghostty_input_mouse_button_e, ghostty_input_mods_e); void ghostty_inspector_mouse_pos(ghostty_inspector_t, double, double); void ghostty_inspector_mouse_scroll(ghostty_inspector_t, double, double, ghostty_input_scroll_mods_t); void ghostty_inspector_key(ghostty_inspector_t, ghostty_input_action_e, ghostty_input_key_e, ghostty_input_mods_e); void ghostty_inspector_text(ghostty_inspector_t, const char*); #ifdef __APPLE__ bool ghostty_inspector_metal_init(ghostty_inspector_t, void*); void ghostty_inspector_metal_render(ghostty_inspector_t, void*, void*); bool ghostty_inspector_metal_shutdown(ghostty_inspector_t); #endif // APIs I'd like to get rid of eventually but are still needed for now. // Don't use these unless you know what you're doing. void ghostty_set_window_background_blur(ghostty_app_t, void*); // Benchmark API, if available. bool ghostty_benchmark_cli(const char*, const char*); #ifdef __cplusplus } #endif #endif /* GHOSTTY_H */ ================================================ FILE: ghostty/Vendor/include/module.modulemap ================================================ // This makes Ghostty available to the XCode build for the macOS app. // We append "Kit" to it not to be cute, but because targets have to have // unique names and we use Ghostty for other things. module GhosttyKit { umbrella header "ghostty.h" export * } ================================================ FILE: models/ActivityChartData.swift ================================================ import Foundation struct ActivityChartDataPoint: Identifiable, Equatable { let id = UUID() let date: Date let source: SessionSource.Kind let sessionCount: Int let duration: TimeInterval let totalTokens: Int } enum ActivityChartUnit: Equatable { case day case hour } struct ActivityChartData: Equatable { let points: [ActivityChartDataPoint] let unit: ActivityChartUnit static let empty = ActivityChartData(points: [], unit: .day) } extension Array where Element == SessionSummary { func generateChartData() -> ActivityChartData { guard !self.isEmpty else { return .empty } let dates = self.map { $0.startedAt } let minDate = dates.min() ?? Date() let maxDate = dates.max() ?? Date() // Heuristic: If all sessions are within the same calendar day, or span < 24h, use Hour. // Using Calendar to check "same day" is safer for "Today" view. let calendar = Calendar.current let isSameDay = calendar.isDate(minDate, inSameDayAs: maxDate) let range = maxDate.timeIntervalSince(minDate) // If explicit single day (same day) OR range < 24h, use Hour. let unit: ActivityChartUnit = (isSameDay || range < 86400) ? .hour : .day // Grouping var groups: [Date: [SessionSource.Kind: (count: Int, duration: TimeInterval, tokens: Int)]] = [:] for session in self { let date = session.startedAt let truncatedDate: Date if unit == .day { truncatedDate = calendar.startOfDay(for: date) } else { // Truncate to hour let components = calendar.dateComponents([.year, .month, .day, .hour], from: date) truncatedDate = calendar.date(from: components) ?? date } let kind = session.source.baseKind var current = groups[truncatedDate, default: [:]][kind, default: (0, 0, 0)] current.count += 1 current.duration += session.duration current.tokens += session.actualTotalTokens groups[truncatedDate, default: [:]][kind] = current } var points: [ActivityChartDataPoint] = [] for (date, sourceMap) in groups { for (kind, stats) in sourceMap { points.append(ActivityChartDataPoint( date: date, source: kind, sessionCount: stats.count, duration: stats.duration, totalTokens: stats.tokens )) } } return ActivityChartData(points: points.sorted { $0.date < $1.date }, unit: unit) } } ================================================ FILE: models/AllOverviewViewModel.swift ================================================ import Combine import Foundation import OSLog @MainActor final class AllOverviewViewModel: ObservableObject { @Published private(set) var snapshot: AllOverviewSnapshot = .empty @Published private(set) var cacheCoverage: SessionIndexCoverage? @Published private(set) var isLoading: Bool = false private let sessionListViewModel: SessionListViewModel private var cancellables: Set = [] private var pendingRefreshTask: Task? = nil private let logger = Logger(subsystem: "io.umate.codmate", category: "AllOverviewVM") init(sessionListViewModel: SessionListViewModel) { self.sessionListViewModel = sessionListViewModel bindPublishers() recomputeSnapshot() } deinit { pendingRefreshTask?.cancel() } func forceRefresh() { pendingRefreshTask?.cancel() pendingRefreshTask = nil recomputeSnapshot() } private func bindPublishers() { sessionListViewModel.$sections .sink { [weak self] _ in self?.scheduleSnapshotRefresh() } .store(in: &cancellables) sessionListViewModel.$usageSnapshots .sink { [weak self] _ in self?.scheduleSnapshotRefresh() } .store(in: &cancellables) sessionListViewModel.$projects .sink { [weak self] _ in self?.scheduleSnapshotRefresh() } .store(in: &cancellables) sessionListViewModel.$isLoading .receive(on: DispatchQueue.main) .sink { [weak self] value in self?.isLoading = value } .store(in: &cancellables) sessionListViewModel.$cacheCoverage .receive(on: DispatchQueue.main) .sink { [weak self] value in self?.cacheCoverage = value } .store(in: &cancellables) } private func scheduleSnapshotRefresh() { pendingRefreshTask?.cancel() pendingRefreshTask = Task { [weak self] in try? await Task.sleep(nanoseconds: 120_000_000) guard !Task.isCancelled else { return } guard let self else { return } let started = Date() // Capture data on MainActor let filteredSessions: [SessionSummary] = self.sessionListViewModel.sections.flatMap { $0.sessions } let usageSnapshots = self.sessionListViewModel.usageSnapshots let projectCount = self.sessionListViewModel.projects.count let scope = self.sessionListViewModel.overviewAggregateScope() let aggregate: OverviewAggregate? if let scope { aggregate = await self.sessionListViewModel.fetchOverviewAggregate(scope: scope) } else if self.sessionListViewModel.canUseGlobalOverviewAggregate { aggregate = await self.sessionListViewModel.fetchOverviewAggregate() } else { aggregate = nil } self.logger.log("overview snapshot refresh start sessions=\(filteredSessions.count, privacy: .public) scopeAggregate=\(scope != nil, privacy: .public) aggregateFetched=\(aggregate != nil, privacy: .public)") // Run computation in background let newSnapshot = await Self.computeSnapshot( sessions: filteredSessions, usageSnapshots: usageSnapshots, projectCount: projectCount, aggregate: aggregate ) guard !Task.isCancelled else { return } await MainActor.run { self.snapshot = newSnapshot } let elapsed = Date().timeIntervalSince(started) self.logger.log("overview snapshot refresh done in \(elapsed, format: .fixed(precision: 3))s sessions=\(newSnapshot.totalSessions, privacy: .public) aggregate=\(aggregate != nil, privacy: .public)") } } private static func computeSnapshot( sessions: [SessionSummary], usageSnapshots: [UsageProviderKind: UsageProviderSnapshot], projectCount: Int, aggregate: OverviewAggregate? ) async -> AllOverviewSnapshot { let now = Date() func anchorDate(for session: SessionSummary) -> Date { session.lastUpdatedAt ?? session.startedAt } let totalDuration = aggregate?.totalDuration ?? sessions.reduce(0) { $0 + $1.duration } let totalTokens = aggregate?.totalTokens ?? sessions.reduce(0) { $0 + $1.actualTotalTokens } let userMessages = aggregate?.userMessages ?? sessions.reduce(0) { $0 + $1.userMessageCount } let assistantMessages = aggregate?.assistantMessages ?? sessions.reduce(0) { $0 + $1.assistantMessageCount } let recentTop = Array( sessions .sorted { anchorDate(for: $0) > anchorDate(for: $1) } .prefix(5) ) let sourceStats = aggregate.map { buildSourceStats(from: $0) } ?? buildSourceStats(from: sessions) let activityData = aggregate.map { activityChartData(from: $0) } ?? sessions.generateChartData() return AllOverviewSnapshot( totalSessions: aggregate?.totalSessions ?? sessions.count, totalDuration: totalDuration, totalTokens: totalTokens, userMessages: userMessages, assistantMessages: assistantMessages, recentSessions: recentTop, sourceStats: sourceStats, activityChartData: activityData, usageSnapshots: usageSnapshots, projectCount: projectCount, lastUpdated: now ) } private static func buildSourceStats(from aggregate: OverviewAggregate) -> [AllOverviewSnapshot.SourceStat] { var stats: [AllOverviewSnapshot.SourceStat] = [] for item in aggregate.sources { stats.append( AllOverviewSnapshot.SourceStat( kind: item.kind, sessionCount: item.sessionCount, totalTokens: item.totalTokens, avgTokens: 0, avgDuration: item.sessionCount > 0 ? item.totalDuration / Double(item.sessionCount) : 0, isAll: false ) ) } if aggregate.totalSessions > 0 { let allStat = AllOverviewSnapshot.SourceStat( kind: .codex, // placeholder when isAll=true sessionCount: aggregate.totalSessions, totalTokens: aggregate.totalTokens, avgTokens: 0, avgDuration: aggregate.totalSessions > 0 ? aggregate.totalDuration / Double(aggregate.totalSessions) : 0, isAll: true ) stats.insert(allStat, at: 0) } return stats } private static func buildSourceStats(from sessions: [SessionSummary]) -> [AllOverviewSnapshot.SourceStat] { var groups: [SessionSource.Kind: [SessionSummary]] = [:] for session in sessions { groups[session.source.baseKind, default: []].append(session) } let kinds: [SessionSource.Kind] = [.codex, .claude, .gemini] var stats: [AllOverviewSnapshot.SourceStat] = kinds.compactMap { kind in let group = groups[kind] ?? [] let count = group.count guard count > 0 else { return nil } let totalDuration = group.reduce(0) { $0 + $1.duration } let totalTokens = group.reduce(0) { $0 + $1.actualTotalTokens } return AllOverviewSnapshot.SourceStat( kind: kind, sessionCount: count, totalTokens: totalTokens, avgTokens: 0, // Not used for display anymore avgDuration: count > 0 ? totalDuration / Double(count) : 0, isAll: false ) } // Add "All" summary if there's data if !sessions.isEmpty { let totalDuration = sessions.reduce(0) { $0 + $1.duration } let totalTokens = sessions.reduce(0) { $0 + $1.actualTotalTokens } let count = sessions.count let allStat = AllOverviewSnapshot.SourceStat( kind: .codex, // Placeholder kind, ignored when isAll is true sessionCount: count, totalTokens: totalTokens, avgTokens: 0, avgDuration: count > 0 ? totalDuration / Double(count) : 0, isAll: true ) stats.insert(allStat, at: 0) } return stats } private static func activityChartData(from aggregate: OverviewAggregate) -> ActivityChartData { guard !aggregate.daily.isEmpty else { return .empty } let points = aggregate.daily.map { ActivityChartDataPoint( date: $0.day, source: $0.kind, sessionCount: $0.sessionCount, duration: $0.totalDuration, totalTokens: $0.totalTokens ) } return ActivityChartData(points: points.sorted { $0.date < $1.date }, unit: .day) } private func recomputeSnapshot() { scheduleSnapshotRefresh() } func resolveProject(for session: SessionSummary) -> (id: String, name: String)? { let projectId = sessionListViewModel.projectId(for: session) if projectId == SessionListViewModel.otherProjectId { return (id: projectId, name: "Unassigned") as? (id: String, name: String) } if let project = sessionListViewModel.projects.first(where: { $0.id == projectId }) { return (id: project.id, name: project.name) } return nil } } struct AllOverviewSnapshot: Equatable { struct SourceStat: Identifiable, Equatable { let kind: SessionSource.Kind let sessionCount: Int let totalTokens: Int let avgTokens: Double let avgDuration: TimeInterval var isAll: Bool = false var id: String { isAll ? "all" : kind.rawValue } var displayName: String { if isAll { return "All" } switch kind { case .codex: return "Codex" case .claude: return "Claude" case .gemini: return "Gemini" } } } var totalSessions: Int var totalDuration: TimeInterval var totalTokens: Int var userMessages: Int var assistantMessages: Int var recentSessions: [SessionSummary] var sourceStats: [SourceStat] var activityChartData: ActivityChartData var usageSnapshots: [UsageProviderKind: UsageProviderSnapshot] var projectCount: Int var lastUpdated: Date static let empty = AllOverviewSnapshot( totalSessions: 0, totalDuration: 0, totalTokens: 0, userMessages: 0, assistantMessages: 0, recentSessions: [], sourceStats: [], activityChartData: .empty, usageSnapshots: [:], projectCount: 0, lastUpdated: .distantPast ) } ================================================ FILE: models/CLIPathVM.swift ================================================ import Foundation @MainActor final class CLIPathVM: ObservableObject { struct CLIInfo: Equatable { var path: String? var version: String? } @Published var codex: CLIInfo = .init(path: nil, version: nil) @Published var claude: CLIInfo = .init(path: nil, version: nil) @Published var gemini: CLIInfo = .init(path: nil, version: nil) @Published var pathEnv: String = "" @Published var sandboxOn: Bool = false func refresh() { let sandboxed = ProcessInfo.processInfo.environment["APP_SANDBOX_CONTAINER_ID"] != nil let fallbackPath = CLIEnvironment.buildBasePATH() self.pathEnv = fallbackPath self.sandboxOn = sandboxed if sandboxed { let brew = URL(fileURLWithPath: "/opt/homebrew/bin", isDirectory: true) let usrLocal = URL(fileURLWithPath: "/usr/local/bin", isDirectory: true) _ = SecurityScopedBookmarks.shared.startAccessDynamic(for: brew) _ = SecurityScopedBookmarks.shared.startAccessDynamic(for: usrLocal) } Task(priority: .userInitiated) { @MainActor in let snapshot = await Task.detached { let path = CLIEnvironment.resolvedPATHForCLI(sandboxed: sandboxed) let codexPath = CLIEnvironment.resolveExecutablePath("codex", path: path) let claudePath = CLIEnvironment.resolveExecutablePath("claude", path: path) let geminiPath = CLIEnvironment.resolveExecutablePath("gemini", path: path) let codexVersion = codexPath.flatMap { CLIEnvironment.version(atExecutablePath: $0, path: path) } let claudeVersion = claudePath.flatMap { CLIEnvironment.version(atExecutablePath: $0, path: path) } let geminiVersion = geminiPath.flatMap { CLIEnvironment.version(atExecutablePath: $0, path: path) } return ( path: path, sandboxOn: sandboxed, codex: CLIInfo(path: codexPath, version: codexVersion), claude: CLIInfo(path: claudePath, version: claudeVersion), gemini: CLIInfo(path: geminiPath, version: geminiVersion) ) }.value self.pathEnv = snapshot.path self.sandboxOn = snapshot.sandboxOn self.codex = snapshot.codex self.claude = snapshot.claude self.gemini = snapshot.gemini } } } ================================================ FILE: models/ClaudeCodeVM.swift ================================================ import Foundation import SwiftUI import AppKit @MainActor final class ClaudeCodeVM: ObservableObject { let builtinModels: [String] = [ "claude-3-5-sonnet-latest", "claude-3-haiku-latest", "claude-3-opus-latest", ] @Published var providers: [ProvidersRegistryService.Provider] = [] @Published var activeProviderId: String? enum LoginMethod: String, CaseIterable, Identifiable { case api, subscription; var id: String { rawValue } } @Published var loginMethod: LoginMethod = .api @Published var aliasDefault: String = "" @Published var aliasHaiku: String = "" @Published var aliasSonnet: String = "" @Published var aliasOpus: String = "" @Published var lastError: String? @Published var rawSettingsText: String = "" @Published var notificationsEnabled: Bool = false @Published var notificationBridgeHealthy: Bool = false @Published var notificationSelfTestResult: String? = nil private let registry = ProvidersRegistryService() private var saveDebounceTask: Task? = nil private var applyProviderDebounceTask: Task? = nil private var proxySelectionDebounceTask: Task? = nil private var defaultAliasDebounceTask: Task? = nil private var runtimeDebounceTask: Task? = nil private var notificationDebounceTask: Task? = nil func loadAll() async { let providerList = await registry.listProviders() let bindings = await registry.getBindings() await MainActor.run { self.providers = providerList self.activeProviderId = bindings.activeProvider?[ProvidersRegistryService.Consumer.claudeCode.rawValue] self.syncAliases() self.syncLoginMethod() } await loadNotificationSettings() } func loadProxyDefaults(preferences: SessionPreferencesStore) async { let settings = ClaudeSettingsService() let currentModel = await settings.currentModel() let env = await settings.envSnapshot() if preferences.claudeProxyModelId == nil { if let model = currentModel, !model.isEmpty { preferences.claudeProxyModelId = model } else if let envModel = env["ANTHROPIC_MODEL"] ?? env["ANTHROPIC_DEFAULT_SONNET_MODEL"] ?? env["ANTHROPIC_DEFAULT_OPUS_MODEL"] ?? env["ANTHROPIC_DEFAULT_HAIKU_MODEL"], !envModel.isEmpty { preferences.claudeProxyModelId = envModel } } if let providerId = preferences.claudeProxyProviderId { let existing = preferences.claudeProxyModelAliases[providerId] ?? [:] if existing.isEmpty { var aliases: [String: String] = [:] if let opus = env["ANTHROPIC_DEFAULT_OPUS_MODEL"] { aliases["opus"] = opus } if let sonnet = env["ANTHROPIC_DEFAULT_SONNET_MODEL"] { aliases["sonnet"] = sonnet } if let haiku = env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] { aliases["haiku"] = haiku } if !aliases.isEmpty { var stored = preferences.claudeProxyModelAliases stored[providerId] = aliases preferences.claudeProxyModelAliases = stored } } } } func availableModels() -> [String] { guard let id = activeProviderId, let provider = providers.first(where: { $0.id == id }) else { return [] } return (provider.catalog?.models ?? []).map { $0.vendorModelId } } func applyDefaultAlias(_ modelId: String) async { guard let id = activeProviderId else { await MainActor.run { self.aliasDefault = modelId } return } let providerList = await registry.listProviders() guard var provider = providerList.first(where: { $0.id == id }) else { return } var connector = provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue] ?? .init( baseURL: nil, wireAPI: nil, envKey: "ANTHROPIC_AUTH_TOKEN", loginMethod: nil, queryParams: nil, httpHeaders: nil, envHttpHeaders: nil, requestMaxRetries: nil, streamMaxRetries: nil, streamIdleTimeoutMs: nil, modelAliases: nil) var aliases = connector.modelAliases ?? [:] aliases["default"] = modelId connector.modelAliases = aliases provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue] = connector do { try await registry.upsertProvider(provider) await MainActor.run { self.aliasDefault = modelId; self.lastError = nil } // Persist to ~/.claude/settings.json → model only for third‑party providers if self.activeProviderId != nil { if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess, message: "Authorize your Home folder to update Claude settings") } let settings = ClaudeSettingsService() try? await settings.setModel(modelId) } } catch { await MainActor.run { self.lastError = "Failed to set default model" } } } func tokenMissingForCurrentSelection() -> Bool { if loginMethod == .subscription { return false } let env = ProcessInfo.processInfo.environment if let id = activeProviderId, let provider = providers.first(where: { $0.id == id }) { let key = provider.envKey ?? provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue]?.envKey ?? "ANTHROPIC_AUTH_TOKEN" let val = env[key] return (val == nil || val?.isEmpty == true) } let val = env["ANTHROPIC_AUTH_TOKEN"] return (val == nil || val?.isEmpty == true) } func applyActiveProvider() async { do { try await registry.setActiveProvider(.claudeCode, providerId: activeProviderId) await MainActor.run { self.lastError = nil } } catch { await MainActor.run { self.lastError = "Failed to set active provider" } } await MainActor.run { self.syncAliases() self.syncLoginMethod() } // Decide persistence policy let isBuiltin = (activeProviderId == nil) // Built‑in provider → clear provider-specific keys (model/env base URL/forceLogin/token) if isBuiltin { if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess) } let settings = ClaudeSettingsService() try? await settings.setModel(nil) try? await settings.setEnvBaseURL(nil) try? await settings.setForceLoginMethod(nil) try? await settings.setEnvToken(nil) return } if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess) } let settings = ClaudeSettingsService() // Base URL only for third‑party providers let base = isBuiltin ? nil : selectedClaudeBaseURL?.trimmingCharacters(in: .whitespacesAndNewlines) try? await settings.setEnvBaseURL((base?.isEmpty == false) ? base : nil) // Force login only for API; remove for subscription if loginMethod == .api { try? await settings.setForceLoginMethod("console") } else { try? await settings.setForceLoginMethod(nil) } // Token only for API if loginMethod == .api { var token: String? = nil if let id = activeProviderId, let provider = providers.first(where: { $0.id == id }) { let conn = provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue] let keyName = provider.envKey ?? conn?.envKey ?? "ANTHROPIC_AUTH_TOKEN" let env = ProcessInfo.processInfo.environment if let val = env[keyName], !val.isEmpty { token = val } else { let looksLikeToken = keyName.lowercased().contains("sk-") || keyName.hasPrefix("eyJ") || keyName.contains(".") if looksLikeToken { token = keyName } } } try? await settings.setEnvToken(token) } else { try? await settings.setEnvToken(nil) } } func applyProxySelection( providerId: String?, modelId: String?, preferences: SessionPreferencesStore ) async { if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess) } let settings = ClaudeSettingsService() do { if providerId == nil { try await settings.setModel(nil) try await settings.setEnvBaseURL(nil) try await settings.setForceLoginMethod(nil) try await settings.setEnvToken(nil) try await settings.setEnvValues([ "ANTHROPIC_DEFAULT_OPUS_MODEL": nil, "ANTHROPIC_DEFAULT_SONNET_MODEL": nil, "ANTHROPIC_DEFAULT_HAIKU_MODEL": nil, "ANTHROPIC_MODEL": nil, "ANTHROPIC_SMALL_FAST_MODEL": nil ]) await MainActor.run { self.lastError = nil } return } let port = preferences.localServerPort let baseURL = "http://127.0.0.1:\(port)" let trimmedModel = modelId?.trimmingCharacters(in: .whitespacesAndNewlines) let apiKey = CLIProxyService.shared.resolvePublicAPIKey() let trimmedKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) try await settings.setEnvBaseURL(baseURL) try await settings.setForceLoginMethod(nil) try await settings.setEnvToken(trimmedKey.isEmpty ? nil : trimmedKey) let resolved = await resolveProxyAliases( providerId: providerId, selectedModel: trimmedModel, preferences: preferences ) try await settings.setModel(resolved.defaultModel) try await settings.setEnvValues([ "ANTHROPIC_DEFAULT_OPUS_MODEL": resolved.opus, "ANTHROPIC_DEFAULT_SONNET_MODEL": resolved.sonnet, "ANTHROPIC_DEFAULT_HAIKU_MODEL": resolved.haiku, "ANTHROPIC_MODEL": resolved.defaultModel, "ANTHROPIC_SMALL_FAST_MODEL": resolved.haiku ?? resolved.defaultModel ]) await MainActor.run { self.lastError = nil } } catch { await MainActor.run { self.lastError = "Failed to apply CLI Proxy provider: \(error.localizedDescription)" } } } private struct ClaudeProxyAliasSet { var defaultModel: String? var opus: String? var sonnet: String? var haiku: String? } private func resolveProxyAliases( providerId: String?, selectedModel: String?, preferences: SessionPreferencesStore ) async -> ClaudeProxyAliasSet { let trimmedSelected = selectedModel?.trimmingCharacters(in: .whitespacesAndNewlines) var defaultModel = (trimmedSelected?.isEmpty == false) ? trimmedSelected : nil let storedAliases = providerId.flatMap { preferences.claudeProxyModelAliases[$0] } ?? [:] var opus = storedAliases["opus"] var sonnet = storedAliases["sonnet"] var haiku = storedAliases["haiku"] var fallbackAliases: [String: String] = [:] if let providerId { switch UnifiedProviderID.parse(providerId) { case .oauth(let authProvider, _): fallbackAliases = await proxyAliasDefaults( for: authProvider, fallbackModel: defaultModel ) case .api(let apiId): fallbackAliases = await registryAliasDefaults( for: apiId ) default: break } } if defaultModel == nil { defaultModel = fallbackAliases["default"] ?? fallbackAliases["sonnet"] ?? fallbackAliases["opus"] ?? fallbackAliases["haiku"] } if opus == nil { opus = fallbackAliases["opus"] ?? defaultModel } if sonnet == nil { sonnet = fallbackAliases["sonnet"] ?? defaultModel } if haiku == nil { haiku = fallbackAliases["haiku"] ?? defaultModel } return ClaudeProxyAliasSet( defaultModel: defaultModel, opus: opus, sonnet: sonnet, haiku: haiku ) } private func proxyAliasDefaults( for provider: LocalAuthProvider, fallbackModel: String? ) async -> [String: String] { let trimmedSelected = fallbackModel?.trimmingCharacters(in: .whitespacesAndNewlines) let fallback = (trimmedSelected?.isEmpty == false) ? trimmedSelected : nil var models: [String] = [] if let target = builtInProvider(for: provider), CLIProxyService.shared.isRunning { let localModels = await CLIProxyService.shared.fetchLocalModels() models = localModels.compactMap { model in let candidate = model.id.trimmingCharacters(in: .whitespacesAndNewlines) guard !candidate.isEmpty else { return nil } if builtInProvider(for: model) == target { return candidate } return nil } } let preferred = fallback ?? selectDefaultModel(from: models) let opus = selectModel(from: models, tokens: ["opus"]) ?? preferred let sonnet = selectModel(from: models, tokens: ["sonnet"]) ?? preferred let haiku = selectModel(from: models, tokens: ["haiku", "flash", "lite", "mini"]) ?? preferred var out: [String: String] = [:] if let preferred { out["default"] = preferred } if let opus { out["opus"] = opus } if let sonnet { out["sonnet"] = sonnet } if let haiku { out["haiku"] = haiku } return out } private func registryAliasDefaults(for providerId: String) async -> [String: String] { let providers = await registry.listAllProviders() guard let provider = providers.first(where: { $0.id == providerId }) else { return [:] } let connector = provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue] let aliases = connector?.modelAliases ?? [:] var out: [String: String] = [:] if let def = aliases["default"] { out["default"] = def } if let opus = aliases["opus"] { out["opus"] = opus } if let sonnet = aliases["sonnet"] { out["sonnet"] = sonnet } if let haiku = aliases["haiku"] { out["haiku"] = haiku } if let rec = provider.recommended?.defaultModelFor?[ProvidersRegistryService.Consumer.claudeCode.rawValue], out["default"] == nil { out["default"] = rec } if out["default"] == nil, let first = provider.catalog?.models?.first?.vendorModelId { out["default"] = first } return out } private func selectDefaultModel(from models: [String]) -> String? { if let match = selectModel(from: models, tokens: ["sonnet", "opus", "haiku"]) { return match } if let match = selectModel(from: models, tokens: ["pro", "latest", "preview"]) { return match } return models.first } private func selectModel(from models: [String], tokens: [String]) -> String? { guard !models.isEmpty else { return nil } // Find all models matching any token, then select the one with highest version var candidates: [String] = [] for token in tokens { let matching = models.filter { $0.localizedCaseInsensitiveContains(token) } candidates.append(contentsOf: matching) } guard !candidates.isEmpty else { return nil } // Use ModelNameSanitizer's version comparison logic to find the highest version var bestModel: String? = nil var bestVersion: ModelNameSanitizer.ModelVersion? = nil for model in candidates { let (baseName, version) = ModelNameSanitizer.extractModelVersion(model) // Check if this model's base name matches any token let matchesToken = tokens.contains { token in baseName.localizedCaseInsensitiveContains(token) } if matchesToken { if let existing = bestVersion { if version.isNewerThan(existing) { bestVersion = version bestModel = model } } else { bestVersion = version bestModel = model } } } // If no version-based match found, fall back to first match (for models without date suffixes) return bestModel ?? candidates.first } private func builtInProvider(for provider: LocalAuthProvider) -> LocalServerBuiltInProvider? { switch provider { case .codex: return .openai case .claude: return .anthropic case .gemini: return .gemini case .antigravity: return .antigravity case .qwen: return .qwen } } private func builtInProvider(for model: CLIProxyService.LocalModel) -> LocalServerBuiltInProvider? { let hint = model.provider ?? model.source ?? model.owned_by if let hint, let provider = LocalServerBuiltInProvider.allCases.first(where: { $0.matchesOwnedBy(hint) }) { return provider } let modelId = model.id if let provider = LocalServerBuiltInProvider.allCases.first(where: { $0.matchesModelId(modelId) }) { return provider } return nil } func save() async { guard let id = activeProviderId else { return } let providerList = await registry.listAllProviders() guard var provider = providerList.first(where: { $0.id == id }) else { return } var connector = provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue] ?? .init( baseURL: nil, wireAPI: nil, envKey: "ANTHROPIC_AUTH_TOKEN", queryParams: nil, httpHeaders: nil, envHttpHeaders: nil, requestMaxRetries: nil, streamMaxRetries: nil, streamIdleTimeoutMs: nil, modelAliases: nil) var aliases: [String: String] = [:] func assign(_ key: String, _ value: String) { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { aliases[key] = trimmed } } assign("default", aliasDefault) assign("haiku", aliasHaiku) assign("sonnet", aliasSonnet) assign("opus", aliasOpus) connector.modelAliases = aliases.isEmpty ? nil : aliases provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue] = connector do { try await registry.upsertProvider(provider) await MainActor.run { self.lastError = nil } // Persist model only for third‑party providers if self.activeProviderId != nil { if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess) } let settings = ClaudeSettingsService() let m = aliasDefault.trimmingCharacters(in: .whitespacesAndNewlines) try? await settings.setModel(m.isEmpty ? nil : m) } await loadAll() } catch { await MainActor.run { self.lastError = "Failed to save aliases" } } } func scheduleSaveDebounced(delayMs: UInt64 = 300) { // Cancel any in-flight debounce task and schedule a new one saveDebounceTask?.cancel() saveDebounceTask = Task { [weak self] in guard let self else { return } do { try await Task.sleep(nanoseconds: delayMs * 1_000_000) } catch { return } if Task.isCancelled { return } await self.save() } } // MARK: - Runtime settings writer func scheduleApplyRuntimeSettings(_ preferences: SessionPreferencesStore, delayMs: UInt64 = 250) { runtimeDebounceTask?.cancel() runtimeDebounceTask = Task { [weak self] in guard let self else { return } do { try await Task.sleep(nanoseconds: delayMs * 1_000_000) } catch { return } if Task.isCancelled { return } await self.applyRuntimeSettings(preferences) } } func applyRuntimeSettings(_ preferences: SessionPreferencesStore) async { if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess) } let settings = ClaudeSettingsService() let addDirs: [String]? = { let raw = preferences.claudeAddDirs.trimmingCharacters(in: .whitespacesAndNewlines) if raw.isEmpty { return nil } return raw.split(whereSeparator: { $0 == "," || $0.isWhitespace }).map { String($0) } }() let runtime = ClaudeSettingsService.Runtime( permissionMode: preferences.claudePermissionMode.rawValue, skipPermissions: preferences.claudeSkipPermissions, allowSkipPermissions: preferences.claudeAllowSkipPermissions, debug: preferences.claudeDebug, debugFilter: preferences.claudeDebugFilter, verbose: preferences.claudeVerbose, ide: preferences.claudeIDE, strictMCP: preferences.claudeStrictMCP, fallbackModel: preferences.claudeFallbackModel, allowedTools: preferences.claudeAllowedTools, disallowedTools: preferences.claudeDisallowedTools, addDirs: addDirs ) try? await settings.applyRuntime(runtime) } func loadNotificationSettings() async { let settings = ClaudeSettingsService() let status = await settings.codMateNotificationHooksStatus() await MainActor.run { let healthy = status.permissionHookInstalled && status.completionHookInstalled self.notificationsEnabled = healthy self.notificationBridgeHealthy = healthy if !healthy { self.notificationSelfTestResult = nil } } } private func syncAliases() { guard let id = activeProviderId, let provider = providers.first(where: { $0.id == id }) else { aliasDefault = "" aliasHaiku = "" aliasSonnet = "" aliasOpus = "" return } let connector = provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue] let aliases = connector?.modelAliases ?? [:] let recommended = provider.recommended?.defaultModelFor?[ProvidersRegistryService.Consumer.claudeCode.rawValue] aliasDefault = aliases["default"] ?? recommended ?? "" aliasHaiku = aliases["haiku"] ?? "" aliasSonnet = aliases["sonnet"] ?? "" aliasOpus = aliases["opus"] ?? "" } private func syncLoginMethod() { // Built-in (nil provider) defaults to subscription; third-party defaults to api guard let id = activeProviderId, let provider = providers.first(where: { $0.id == id }) else { loginMethod = .subscription return } let connector = provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue] if let lm = connector?.loginMethod, lm.lowercased() == "subscription" { loginMethod = .subscription } else { loginMethod = .api } } func setLoginMethod(_ method: LoginMethod) async { await MainActor.run { self.loginMethod = method } // Persist to registry for active provider (if any). Built-in (nil) has no connector; nothing to write. guard let id = activeProviderId else { return } let list = await registry.listProviders() guard var p = list.first(where: { $0.id == id }) else { return } var conn = p.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue] ?? .init( baseURL: nil, wireAPI: nil, envKey: nil, loginMethod: nil, queryParams: nil, httpHeaders: nil, envHttpHeaders: nil, requestMaxRetries: nil, streamMaxRetries: nil, streamIdleTimeoutMs: nil, modelAliases: nil) conn.loginMethod = method.rawValue // Restore default env key for API login if absent (prefer provider-level key) if method == .api && (p.envKey == nil || p.envKey?.isEmpty == true) { p.envKey = "ANTHROPIC_AUTH_TOKEN" } if method == .subscription { // No need to store token env mapping; leave as-is but it will be ignored at launch. } p.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue] = conn do { try await registry.upsertProvider(p) // Persist to settings: only when API; subscription removes forced key and token if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess) } let settings = ClaudeSettingsService() if method == .api { try? await settings.setForceLoginMethod("console") var token: String? = nil let env = ProcessInfo.processInfo.environment let keyName = p.envKey ?? p.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue]?.envKey ?? "ANTHROPIC_AUTH_TOKEN" if let val = env[keyName], !val.isEmpty { token = val } else { let looksLikeToken = keyName.lowercased().contains("sk-") || keyName.hasPrefix("eyJ") || keyName.contains(".") if looksLikeToken { token = keyName } } try? await settings.setEnvToken(token) } else { try? await settings.setForceLoginMethod(nil) try? await settings.setEnvToken(nil) } } catch { await MainActor.run { self.lastError = "Failed to save login method" } } } private func applyNotificationSettings() async { if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync( directory: home, purpose: .generalAccess, message: "Authorize ~/.claude to update Claude notifications" ) } let settings = ClaudeSettingsService() do { try await settings.setCodMateNotificationHooks(enabled: notificationsEnabled) await loadNotificationSettings() } catch { await MainActor.run { self.lastError = "Failed to update Claude notifications" } } } func runNotificationSelfTest() async { notificationSelfTestResult = nil var comps = URLComponents() comps.scheme = "codmate" comps.host = "notify" let title = "CodMate" let body = "Claude notifications self-test" var items = [ URLQueryItem(name: "source", value: "claude"), URLQueryItem(name: "event", value: "test") ] if let titleData = title.data(using: .utf8) { items.append(URLQueryItem(name: "title64", value: titleData.base64EncodedString())) } if let bodyData = body.data(using: .utf8) { items.append(URLQueryItem(name: "body64", value: bodyData.base64EncodedString())) } comps.queryItems = items guard let url = comps.url else { notificationSelfTestResult = "Invalid test URL" return } let success = NSWorkspace.shared.open(url) notificationSelfTestResult = success ? "Sent (check Notification Center)" : "Failed to open codmate:// URL" } // MARK: - Debounced operations func scheduleApplyActiveProviderDebounced(delayMs: UInt64 = 300) { applyProviderDebounceTask?.cancel() applyProviderDebounceTask = Task { [weak self] in guard let self else { return } do { try await Task.sleep(nanoseconds: delayMs * 1_000_000) } catch { return } if Task.isCancelled { return } await self.applyActiveProvider() } } func scheduleApplyProxySelectionDebounced( providerId: String?, modelId: String?, preferences: SessionPreferencesStore, delayMs: UInt64 = 300 ) { proxySelectionDebounceTask?.cancel() proxySelectionDebounceTask = Task { [weak self] in guard let self else { return } do { try await Task.sleep(nanoseconds: delayMs * 1_000_000) } catch { return } if Task.isCancelled { return } await self.applyProxySelection( providerId: providerId, modelId: modelId, preferences: preferences ) } } func scheduleApplyDefaultAliasDebounced(_ modelId: String, delayMs: UInt64 = 300) { defaultAliasDebounceTask?.cancel() defaultAliasDebounceTask = Task { [weak self] in guard let self else { return } do { try await Task.sleep(nanoseconds: delayMs * 1_000_000) } catch { return } if Task.isCancelled { return } await self.applyDefaultAlias(modelId) } } func scheduleApplyNotificationSettingsDebounced(delayMs: UInt64 = 250) { notificationDebounceTask?.cancel() notificationDebounceTask = Task { [weak self] in guard let self else { return } do { try await Task.sleep(nanoseconds: delayMs * 1_000_000) } catch { return } if Task.isCancelled { return } await self.applyNotificationSettings() } } // MARK: - Raw settings helpers func settingsFileURL() -> URL { SessionPreferencesStore.getRealUserHomeURL() .appendingPathComponent(".claude", isDirectory: true) .appendingPathComponent("settings.json") } func reloadRawSettings() async { let url = settingsFileURL() let text = (try? String(contentsOf: url, encoding: .utf8)) ?? "" await MainActor.run { self.rawSettingsText = text } } func openSettingsInEditor() { Task { @MainActor in NSWorkspace.shared.open(self.settingsFileURL()) } } } ================================================ FILE: models/ClaudeUsageStatus.swift ================================================ import Foundation struct ClaudeUsageStatus: Equatable { let updatedAt: Date let modelName: String? let contextUsedTokens: Int? let contextLimitTokens: Int? let fiveHourUsedMinutes: Double? let fiveHourWindowMinutes: Double let fiveHourResetAt: Date? let weeklyUsedMinutes: Double? let weeklyWindowMinutes: Double let weeklyResetAt: Date? let sessionExpiresAt: Date? let planType: String? // Subscription type (Pro, Max, Team, etc.) init( updatedAt: Date, modelName: String?, contextUsedTokens: Int?, contextLimitTokens: Int?, fiveHourUsedMinutes: Double?, fiveHourWindowMinutes: Double = 300, fiveHourResetAt: Date?, weeklyUsedMinutes: Double?, weeklyWindowMinutes: Double = 10_080, weeklyResetAt: Date?, sessionExpiresAt: Date? = nil, planType: String? = nil ) { self.updatedAt = updatedAt self.modelName = modelName self.contextUsedTokens = contextUsedTokens self.contextLimitTokens = contextLimitTokens self.fiveHourUsedMinutes = fiveHourUsedMinutes self.fiveHourWindowMinutes = fiveHourWindowMinutes self.fiveHourResetAt = fiveHourResetAt self.weeklyUsedMinutes = weeklyUsedMinutes self.weeklyWindowMinutes = weeklyWindowMinutes self.weeklyResetAt = weeklyResetAt self.sessionExpiresAt = sessionExpiresAt self.planType = planType } private var contextProgress: Double? { guard let used = contextUsedTokens, let limit = contextLimitTokens, limit > 0 else { return nil } return Double(used) / Double(limit) } private var fiveHourProgress: Double? { guard let used = fiveHourUsedMinutes, fiveHourWindowMinutes > 0 else { return nil } let remaining = max(0, fiveHourWindowMinutes - used) return remaining / fiveHourWindowMinutes } private var weeklyProgress: Double? { guard let used = weeklyUsedMinutes, weeklyWindowMinutes > 0 else { return nil } let remaining = max(0, weeklyWindowMinutes - used) return remaining / weeklyWindowMinutes } func asProviderSnapshot(titleBadge: String? = nil) -> UsageProviderSnapshot { var metrics: [UsageMetricSnapshot] = [] metrics.append( UsageMetricSnapshot( kind: .context, label: "Context", usageText: contextUsageText, percentText: contextPercentText, progress: contextProgress?.clamped01(), resetDate: nil, fallbackWindowMinutes: nil ) ) metrics.append( UsageMetricSnapshot( kind: .fiveHour, label: "5h limit", usageText: fiveHourUsageText, percentText: fiveHourPercentText, progress: fiveHourProgress?.clamped01(), resetDate: fiveHourResetAt, fallbackWindowMinutes: Int(fiveHourWindowMinutes) ) ) metrics.append( UsageMetricSnapshot( kind: .weekly, label: "Weekly limit", usageText: weeklyUsageText, percentText: weeklyPercentText, progress: weeklyProgress?.clamped01(), resetDate: weeklyResetAt, fallbackWindowMinutes: Int(weeklyWindowMinutes) ) ) // Session expiry removed - new Web API strategy auto-refreshes tokens return UsageProviderSnapshot( provider: .claude, title: UsageProviderKind.claude.displayName, titleBadge: titleBadge, availability: .ready, metrics: metrics, updatedAt: updatedAt, statusMessage: nil, origin: .builtin ) } private var contextUsageText: String? { guard let used = contextUsedTokens else { return nil } if let limit = contextLimitTokens { return "\(TokenFormatter.string(from: used)) used / \(TokenFormatter.string(from: limit)) total" } return "\(TokenFormatter.string(from: used)) used" } private var contextPercentText: String? { guard let ratio = contextProgress else { return nil } return NumberFormatter.compactPercentFormatter.string(from: NSNumber(value: ratio)) ?? String(format: "%.0f%%", ratio * 100) } private var fiveHourUsageText: String? { if let resetAt = fiveHourResetAt { let remaining = resetAt.timeIntervalSince(updatedAt) if remaining <= 0 { return "Reset" } let minutes = Int(remaining / 60) let hours = minutes / 60 let mins = minutes % 60 if hours > 0 { return "\(hours)h \(mins)m remaining" } else { return "\(mins)m remaining" } } // Fallback if no reset date guard let usedMinutes = fiveHourUsedMinutes else { return nil } let remainingMinutes = max(0, fiveHourWindowMinutes - usedMinutes) return "\(UsageDurationFormatter.string(minutes: remainingMinutes)) remaining" } private var fiveHourPercentText: String? { guard let progress = fiveHourProgress else { return nil } return NumberFormatter.compactPercentFormatter.string(from: NSNumber(value: progress)) ?? String(format: "%.0f%%", progress * 100) } private var weeklyUsageText: String? { if let resetAt = weeklyResetAt { let remaining = resetAt.timeIntervalSince(updatedAt) if remaining <= 0 { return "Reset" } let minutes = Int(remaining / 60) let hours = minutes / 60 let days = hours / 24 let remainingHours = hours % 24 let remainingMins = minutes % 60 if days > 0 { if remainingHours > 0 { return "\(days)d \(remainingHours)h remaining" } else { return "\(days)d remaining" } } else if hours > 0 { return "\(hours)h \(remainingMins)m remaining" } else { return "\(remainingMins)m remaining" } } // Fallback if no reset date guard let usedMinutes = weeklyUsedMinutes else { return nil } let remainingMinutes = max(0, weeklyWindowMinutes - usedMinutes) return "\(UsageDurationFormatter.string(minutes: remainingMinutes)) remaining" } private var weeklyPercentText: String? { guard let progress = weeklyProgress else { return nil } return NumberFormatter.compactPercentFormatter.string(from: NSNumber(value: progress)) ?? String(format: "%.0f%%", progress * 100) } } private extension Double { func clamped01() -> Double { if self.isNaN { return 0 } return max(0, min(self, 1)) } } ================================================ FILE: models/CodexUsageStatus.swift ================================================ import Foundation struct CodexUsageStatus: Equatable { let updatedAt: Date let contextUsedTokens: Int? let contextLimitTokens: Int? let primaryWindowUsedPercent: Double? let primaryWindowMinutes: Int? let primaryResetAt: Date? let secondaryWindowUsedPercent: Double? let secondaryWindowMinutes: Int? let secondaryResetAt: Date? var contextUsedPercent: Double? { guard let used = contextUsedTokens, let limit = contextLimitTokens, limit > 0 else { return nil } return Double(used) / Double(limit) } init( updatedAt: Date, contextUsedTokens: Int?, contextLimitTokens: Int?, primaryWindowUsedPercent: Double?, primaryWindowMinutes: Int?, primaryResetAt: Date?, secondaryWindowUsedPercent: Double?, secondaryWindowMinutes: Int?, secondaryResetAt: Date? ) { self.updatedAt = updatedAt self.contextUsedTokens = contextUsedTokens self.contextLimitTokens = contextLimitTokens self.primaryWindowUsedPercent = primaryWindowUsedPercent self.primaryWindowMinutes = primaryWindowMinutes self.primaryResetAt = primaryResetAt self.secondaryWindowUsedPercent = secondaryWindowUsedPercent self.secondaryWindowMinutes = secondaryWindowMinutes self.secondaryResetAt = secondaryResetAt } } extension CodexUsageStatus { var contextUsageText: String? { guard let used = contextUsedTokens, let limit = contextLimitTokens else { return nil } return "\(TokenFormatter.string(from: used)) used / \(TokenFormatter.string(from: limit)) total" } var contextPercentText: String? { guard let percent = contextUsedPercent else { return nil } return NumberFormatter.compactPercentFormatter.string(from: NSNumber(value: percent)) ?? String(format: "%.0f%%", percent * 100) } var primaryPercentText: String? { guard let percent = primaryWindowUsedPercent else { return nil } let remainingPercent = 100.0 - percent return NumberFormatter.compactPercentFormatter.string(from: NSNumber(value: remainingPercent / 100.0)) ?? String(format: "%.0f%%", remainingPercent) } var secondaryPercentText: String? { guard let percent = secondaryWindowUsedPercent else { return nil } let remainingPercent = 100.0 - percent return NumberFormatter.compactPercentFormatter.string(from: NSNumber(value: remainingPercent / 100.0)) ?? String(format: "%.0f%%", remainingPercent) } var primaryUsageText: String? { guard let percent = primaryWindowUsedPercent, let minutes = primaryWindowMinutes else { return nil } let usedMinutes = max(0, min(percent, 100)) / 100.0 * Double(minutes) let remainingMinutes = max(0, Double(minutes) - usedMinutes) return "\(UsageDurationFormatter.string(minutes: remainingMinutes)) remaining" } var secondaryUsageText: String? { guard let percent = secondaryWindowUsedPercent, let minutes = secondaryWindowMinutes else { return nil } let usedMinutes = max(0, min(percent, 100)) / 100.0 * Double(minutes) let remainingMinutes = max(0, Double(minutes) - usedMinutes) return "\(UsageDurationFormatter.string(minutes: remainingMinutes)) remaining" } var contextProgress: Double? { contextUsedPercent } var primaryProgress: Double? { guard let percent = primaryWindowUsedPercent else { return nil } let remainingPercent = 100.0 - percent return remainingPercent / 100.0 } var secondaryProgress: Double? { guard let percent = secondaryWindowUsedPercent else { return nil } let remainingPercent = 100.0 - percent return remainingPercent / 100.0 } func asProviderSnapshot(titleBadge: String? = nil) -> UsageProviderSnapshot { var metrics: [UsageMetricSnapshot] = [] if contextUsedTokens != nil || contextLimitTokens != nil { metrics.append( UsageMetricSnapshot( kind: .context, label: "Context", usageText: contextUsageText, percentText: contextPercentText, progress: contextProgress, resetDate: nil, fallbackWindowMinutes: nil ) ) } metrics.append( UsageMetricSnapshot( kind: .fiveHour, label: "5h limit", usageText: primaryUsageText, percentText: primaryPercentText, progress: primaryProgress, resetDate: validPrimaryResetAt, fallbackWindowMinutes: primaryWindowMinutes ) ) metrics.append( UsageMetricSnapshot( kind: .weekly, label: "Weekly limit", usageText: secondaryUsageText, percentText: secondaryPercentText, progress: secondaryProgress, resetDate: validSecondaryResetAt, fallbackWindowMinutes: secondaryWindowMinutes ) ) return UsageProviderSnapshot( provider: .codex, title: UsageProviderKind.codex.displayName, titleBadge: titleBadge, availability: .ready, metrics: metrics, updatedAt: updatedAt, statusMessage: nil, origin: .builtin ) } init(snapshot: TokenUsageSnapshot) { self.init( updatedAt: snapshot.timestamp, contextUsedTokens: snapshot.totalTokens, contextLimitTokens: snapshot.contextWindow, primaryWindowUsedPercent: snapshot.primaryPercent, primaryWindowMinutes: snapshot.primaryWindowMinutes, primaryResetAt: snapshot.primaryResetAt, secondaryWindowUsedPercent: snapshot.secondaryPercent, secondaryWindowMinutes: snapshot.secondaryWindowMinutes, secondaryResetAt: snapshot.secondaryResetAt ) } func overridingRateLimits( updatedAt: Date? = nil, primaryUsedPercent: Double?, primaryWindowMinutes: Int?, primaryResetAt: Date?, secondaryUsedPercent: Double?, secondaryWindowMinutes: Int?, secondaryResetAt: Date? ) -> CodexUsageStatus { CodexUsageStatus( updatedAt: updatedAt ?? self.updatedAt, contextUsedTokens: contextUsedTokens, contextLimitTokens: contextLimitTokens, primaryWindowUsedPercent: primaryUsedPercent ?? primaryWindowUsedPercent, primaryWindowMinutes: primaryWindowMinutes ?? self.primaryWindowMinutes, primaryResetAt: primaryResetAt ?? self.primaryResetAt, secondaryWindowUsedPercent: secondaryUsedPercent ?? secondaryWindowUsedPercent, secondaryWindowMinutes: secondaryWindowMinutes ?? self.secondaryWindowMinutes, secondaryResetAt: secondaryResetAt ?? self.secondaryResetAt ) } private var validPrimaryResetAt: Date? { guard let reset = primaryResetAt else { return nil } return reset > updatedAt ? reset : nil } private var validSecondaryResetAt: Date? { guard let reset = secondaryResetAt else { return nil } return reset > updatedAt ? reset : nil } } enum UsageDurationFormatter { static func string(minutes: Double) -> String { if minutes >= 1440 { let days = minutes / 1440.0 return days >= 10 ? String(format: "%.0fd", days) : String(format: "%.1fd", days) } if minutes >= 60 { let hours = minutes / 60.0 return hours >= 10 ? String(format: "%.0fh", hours) : String(format: "%.1fh", hours) } return String(format: "%.0fm", minutes) } } extension NumberFormatter { static let decimalFormatter: NumberFormatter = { let formatter = NumberFormatter() formatter.numberStyle = .decimal formatter.maximumFractionDigits = 0 return formatter }() static let compactPercentFormatter: NumberFormatter = { let formatter = NumberFormatter() formatter.numberStyle = .percent formatter.minimumFractionDigits = 0 formatter.maximumFractionDigits = 0 return formatter }() } ================================================ FILE: models/CodexVM.swift ================================================ import Foundation import SwiftUI @MainActor final class CodexVM: ObservableObject { let builtinModels: [String] = [ "gpt-5.2-codex", "gpt-5.1-codex-max", "gpt-5.1-codex-mini", "gpt-5.2" ] enum ReasoningEffort: String, CaseIterable, Identifiable { case minimal, low, medium, high var id: String { rawValue } } enum ReasoningSummary: String, CaseIterable, Identifiable { case auto, concise, detailed, none var id: String { rawValue } } enum ModelVerbosity: String, CaseIterable, Identifiable { case low, medium, high var id: String { rawValue } } enum FeatureOverrideState: String, Identifiable { case inherit, forceOn, forceOff var id: String { rawValue } } struct FeatureFlag: Identifiable, Equatable { let name: String let stage: String let defaultEnabled: Bool var overrideState: FeatureOverrideState var id: String { name } } enum OtelKind: String, Identifiable { case http, grpc var id: String { rawValue } } // Providers @Published var providers: [CodexProvider] = [] @Published var activeProviderId: String? @Published var registryProviders: [ProvidersRegistryService.Provider] = [] @Published var registryActiveProviderId: String? @Published var showProviderEditor = false @Published var providerDraft: CodexProvider = .init( id: "", name: nil, baseURL: nil, envKey: nil, wireAPI: nil, queryParamsRaw: nil, httpHeadersRaw: nil, envHttpHeadersRaw: nil, requestMaxRetries: nil, streamMaxRetries: nil, streamIdleTimeoutMs: nil, managedByCodMate: true) private var editingExistingId: String? = nil var editingKindIsNew: Bool { editingExistingId == nil } @Published var showDeleteAlert: Bool = false @Published var deleteTargetId: String? = nil // Runtime @Published var model: String = "" @Published var reasoningEffort: ReasoningEffort = .medium @Published var reasoningSummary: ReasoningSummary = .auto @Published var modelVerbosity: ModelVerbosity = .medium @Published var sandboxMode: SandboxMode = .workspaceWrite @Published var approvalPolicy: ApprovalPolicy = .onRequest @Published var runtimeDirty = false // Features @Published var featureFlags: [FeatureFlag] = [] @Published var featuresLoading: Bool = false @Published var featureError: String? // Notifications @Published var tuiNotifications: Bool = false @Published var systemNotifications: Bool = false @Published var notifyBridgePath: String? @Published var rawConfigText: String = "" // Privacy @Published var envInherit: String = "all" @Published var envIgnoreDefaults: Bool = false @Published var envIncludeOnly: String = "" @Published var envExclude: String = "" @Published var envSetPairs: String = "" @Published var hideAgentReasoning: Bool = false @Published var showRawAgentReasoning: Bool = false @Published var suppressUnstableFeaturesWarning: Bool = false @Published var fileOpener: String = "vscode" // OTEL @Published var otelEnabled: Bool = false @Published var otelKind: OtelKind = .http @Published var otelEndpoint: String = "" @Published var lastError: String? private let service = CodexConfigService() private let featuresService = CodexFeaturesService() private let providersRegistry = ProvidersRegistryService() private var featureDefaults: [String: Bool] = [:] // Debounce tasks private var debounceProviderTask: Task? = nil private var debounceModelTask: Task? = nil private var debounceProxySelectionTask: Task? = nil private var debounceReasoningTask: Task? = nil private var debounceTuiNotifTask: Task? = nil private var debounceSysNotifTask: Task? = nil private var debounceHideReasoningTask: Task? = nil private var debounceShowReasoningTask: Task? = nil private var debounceSandboxTask: Task? = nil private var debounceApprovalTask: Task? = nil private var debounceSuppressUnstableWarningTask: Task? = nil // Preset helper enum ProviderPreset { case k2, glm, deepseek } @Published var providerKeyApplyURL: String? = nil func loadAll() async { await loadProviders() await loadRuntime() await loadRegistryBindings() await loadNotifications() await loadPrivacy() await loadFeatures() await reloadRawConfig() } func loadProviders() async { providers = await service.listProviders() activeProviderId = await service.activeProvider() } func loadRegistryBindings() async { // Align with Claude Code: only show user-configured providers, // not bundled templates, to avoid confusing, incomplete entries. registryProviders = await providersRegistry.listProviders() let bindings = await providersRegistry.getBindings() registryActiveProviderId = bindings.activeProvider?[ ProvidersRegistryService.Consumer.codex.rawValue] if let defaultModel = bindings.defaultModel?[ ProvidersRegistryService.Consumer.codex.rawValue], !defaultModel.isEmpty { model = defaultModel } else if registryActiveProviderId == nil { model = builtinModels.first ?? "gpt-5.2-codex" } normalizeBuiltinModelIfNeeded() } func loadProxyDefaults(preferences: SessionPreferencesStore) async { let currentModel = await service.getTopLevelString("model") if let value = currentModel, !value.isEmpty { if preferences.codexProxyModelId == nil { preferences.codexProxyModelId = value } } } // MARK: - Debounced schedulers private func schedule( _ taskRef: inout Task?, delayMs: UInt64 = 300, action: @escaping @MainActor () async -> Void ) { taskRef?.cancel() taskRef = Task { [weak self] in guard self != nil else { return } do { try await Task.sleep(nanoseconds: delayMs * 1_000_000) } catch { return } if Task.isCancelled { return } await action() } } func scheduleApplyRegistryProviderSelectionDebounced() { schedule(&debounceProviderTask) { [weak self] in guard let self else { return } await self.applyRegistryProviderSelection() } } func scheduleApplyProxySelectionDebounced( providerId: String?, modelId: String?, preferences: SessionPreferencesStore ) { schedule(&debounceProxySelectionTask) { [weak self] in guard let self else { return } await self.applyProxySelection(providerId: providerId, modelId: modelId, preferences: preferences) } } func scheduleApplyModelDebounced() { schedule(&debounceModelTask) { [weak self] in guard let self else { return } await self.applyModel() } } func scheduleApplyReasoningDebounced() { schedule(&debounceReasoningTask) { [weak self] in guard let self else { return } await self.applyReasoning() } } func scheduleApplyTuiNotificationsDebounced() { schedule(&debounceTuiNotifTask) { [weak self] in guard let self else { return } await self.applyTuiNotifications() } } func scheduleApplySystemNotificationsDebounced() { schedule(&debounceSysNotifTask) { [weak self] in guard let self else { return } await self.applySystemNotifications() } } func scheduleApplyHideReasoningDebounced() { schedule(&debounceHideReasoningTask) { [weak self] in guard let self else { return } await self.applyHideReasoning() } } func scheduleApplyShowRawReasoningDebounced() { schedule(&debounceShowReasoningTask) { [weak self] in guard let self else { return } await self.applyShowRawReasoning() } } func scheduleApplySuppressUnstableWarningDebounced() { schedule(&debounceSuppressUnstableWarningTask) { [weak self] in guard let self else { return } await self.applySuppressUnstableWarning() } } func scheduleApplySandboxDebounced() { schedule(&debounceSandboxTask) { [weak self] in guard let self else { return } await self.applySandbox() } } func scheduleApplyApprovalDebounced() { schedule(&debounceApprovalTask) { [weak self] in guard let self else { return } await self.applyApproval() } } func presentAddProvider() { editingExistingId = nil providerDraft = .init( id: "", name: nil, baseURL: nil, envKey: nil, wireAPI: nil, queryParamsRaw: nil, httpHeadersRaw: nil, envHttpHeadersRaw: nil, requestMaxRetries: nil, streamMaxRetries: nil, streamIdleTimeoutMs: nil, managedByCodMate: true) providerKeyApplyURL = nil showProviderEditor = true } func presentAddProviderPreset(_ preset: ProviderPreset) { editingExistingId = nil switch preset { case .k2: providerDraft = .init( id: "", name: "K2", baseURL: "https://api.moonshot.cn/v1", envKey: nil, wireAPI: "responses", queryParamsRaw: nil, httpHeadersRaw: nil, envHttpHeadersRaw: nil, requestMaxRetries: nil, streamMaxRetries: nil, streamIdleTimeoutMs: nil, managedByCodMate: true) providerKeyApplyURL = "https://platform.moonshot.cn/console/api-keys" case .glm: providerDraft = .init( id: "", name: "GLM", baseURL: "https://open.bigmodel.cn/api/paas/v4/", envKey: nil, wireAPI: "responses", queryParamsRaw: nil, httpHeadersRaw: nil, envHttpHeadersRaw: nil, requestMaxRetries: nil, streamMaxRetries: nil, streamIdleTimeoutMs: nil, managedByCodMate: true) providerKeyApplyURL = "https://bigmodel.cn/usercenter/proj-mgmt/apikeys" case .deepseek: providerDraft = .init( id: "", name: "DeepSeek", baseURL: "https://api.deepseek.com/v1", envKey: nil, wireAPI: "responses", queryParamsRaw: nil, httpHeadersRaw: nil, envHttpHeadersRaw: nil, requestMaxRetries: nil, streamMaxRetries: nil, streamIdleTimeoutMs: nil, managedByCodMate: true) providerKeyApplyURL = "https://platform.deepseek.com/api_keys" } showProviderEditor = true } func presentEditProvider(_ p: CodexProvider) { editingExistingId = p.id providerDraft = p switch p.id.lowercased() { case "k2": providerKeyApplyURL = "https://platform.moonshot.cn/console/api-keys" case "glm": providerKeyApplyURL = "https://bigmodel.cn/usercenter/proj-mgmt/apikeys" case "deepseek": providerKeyApplyURL = "https://platform.deepseek.com/api_keys" default: providerKeyApplyURL = nil } showProviderEditor = true } func dismissEditor() { showProviderEditor = false } func saveProviderDraft() async { lastError = nil do { var provider = providerDraft // Trim and normalize func norm(_ s: String?) -> String? { let t = s?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return t.isEmpty ? nil : t } provider.name = norm(provider.name) provider.baseURL = norm(provider.baseURL) provider.envKey = norm(provider.envKey) // wire_api must be one of: responses, chat. If empty → nil; if invalid → keep as-is (user intent), but presets default to responses. if let w = norm(provider.wireAPI) { let lw = w.lowercased() provider.wireAPI = (lw == "responses" || lw == "chat") ? lw : w } else { provider.wireAPI = nil } provider.queryParamsRaw = norm(provider.queryParamsRaw) provider.httpHeadersRaw = norm(provider.httpHeadersRaw) provider.envHttpHeadersRaw = norm(provider.envHttpHeadersRaw) // Basic validation: require at least a base URL or name if provider.baseURL == nil && provider.name == nil { lastError = "Please enter at least a Name or Base URL." return } if editingKindIsNew { // Determine id: prefer existing non-empty id, otherwise slugify name/base let proposed = norm(provider.id) ?? provider.name ?? provider.baseURL ?? "provider" let baseSlug = Self.slugify(proposed) var candidate = baseSlug.isEmpty ? "provider" : baseSlug var n = 2 while providers.contains(where: { $0.id == candidate }) { candidate = "\(baseSlug)-\(n)" n += 1 } provider.id = candidate } else { provider.id = editingExistingId ?? provider.id } try await service.upsertProvider(provider) showProviderEditor = false await loadProviders() } catch { lastError = "Failed to save provider: \(error.localizedDescription)" } } func deleteProvider(id: String) { Task { [weak self] in do { try await self?.service.deleteProvider(id: id) await self?.loadProviders() } catch { await MainActor.run { self?.lastError = "Delete failed: \(error.localizedDescription)" } } } } func requestDeleteProvider(id: String) { deleteTargetId = id showDeleteAlert = true } func cancelDelete() { showDeleteAlert = false deleteTargetId = nil } func confirmDelete() async { guard let id = deleteTargetId else { return } deleteProvider(id: id) await MainActor.run { self.showDeleteAlert = false self.deleteTargetId = nil } } func applyActiveProvider() async { do { try await service.setActiveProvider(activeProviderId) } catch { lastError = "Failed to set active provider" } } func deleteEditingProviderViaEditor() async { guard let id = editingExistingId else { return } do { try await service.deleteProvider(id: id) await loadProviders() await MainActor.run { self.showProviderEditor = false } } catch { await MainActor.run { self.lastError = "Delete failed: \(error.localizedDescription)" } } } // Runtime func loadRuntime() async { model = await service.getTopLevelString("model") ?? model if let e = await service.getTopLevelString("model_reasoning_effort"), let v = ReasoningEffort(rawValue: e) { reasoningEffort = v } if let s = await service.getTopLevelString("model_reasoning_summary"), let v = ReasoningSummary(rawValue: s) { reasoningSummary = v } if let v = await service.getTopLevelString("model_verbosity"), let mv = ModelVerbosity(rawValue: v) { modelVerbosity = mv } if let s = await service.getTopLevelString("sandbox_mode"), let sm = SandboxMode(rawValue: s) { sandboxMode = sm } if let a = await service.getTopLevelString("approval_policy"), let ap = ApprovalPolicy(rawValue: a) { approvalPolicy = ap } } func applyModel() async { let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines) let value = trimmed.isEmpty ? nil : trimmed model = trimmed do { try await service.setTopLevelString("model", value: value) try await providersRegistry.setDefaultModel( .codex, modelId: value) runtimeDirty = false } catch { lastError = "Save failed" } } func selectedRegistryProvider() -> ProvidersRegistryService.Provider? { guard let id = registryActiveProviderId else { return nil } return registryProviders.first(where: { $0.id == id }) } func modelsForActiveRegistryProvider() -> [String] { guard let provider = selectedRegistryProvider() else { return [] } let ids = (provider.catalog?.models ?? []).map { $0.vendorModelId } var seen = Set() return ids.compactMap { id in let trimmed = id.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } if seen.insert(trimmed).inserted { return trimmed } return nil } } func registryDisplayName(for provider: ProvidersRegistryService.Provider) -> String { if let name = provider.name, !name.isEmpty { return name } return provider.id } func applyRegistryProviderSelection() async { do { try await providersRegistry.setActiveProvider( .codex, providerId: registryActiveProviderId) if let provider = selectedRegistryProvider() { try await service.applyProviderFromRegistry(provider) if let recommended = provider.recommended?.defaultModelFor?[ ProvidersRegistryService.Consumer.codex.rawValue], !recommended.isEmpty { model = recommended } else if let first = provider.catalog?.models?.first?.vendorModelId { model = first } } else { try await service.applyProviderFromRegistry(nil) model = builtinModels.first ?? "gpt-5.2-codex" } await applyModel() } catch { lastError = "Failed to apply provider" } await loadRegistryBindings() } func applyProxySelection( providerId: String?, modelId: String?, preferences: SessionPreferencesStore ) async { do { if providerId == nil { try await service.replaceProviders(with: []) try await service.setActiveProvider(nil) try await service.setTopLevelString("model", value: nil) await MainActor.run { self.lastError = nil } return } let key = CLIProxyService.shared.resolvePublicAPIKey() let port = preferences.localServerPort try await service.applyLocalProxyProvider( providerId: "codmate-proxy", port: port, apiKey: key, modelId: modelId ) await MainActor.run { self.lastError = nil } } catch { await MainActor.run { self.lastError = "Failed to apply CLI Proxy provider: \(error.localizedDescription)" } } } private func normalizeBuiltinModelIfNeeded() { guard registryActiveProviderId == nil else { return } if !builtinModels.contains(model) { model = builtinModels.first ?? "gpt-5.2-codex" } } func applyReasoning() async { do { try await service.setTopLevelString( "model_reasoning_effort", value: reasoningEffort.rawValue) try await service.setTopLevelString( "model_reasoning_summary", value: reasoningSummary.rawValue) try await service.setTopLevelString("model_verbosity", value: modelVerbosity.rawValue) } catch { lastError = "Save failed" } } func applySandbox() async { do { try await service.setSandboxMode(sandboxMode.rawValue) } catch { lastError = "Save failed" } } func applyApproval() async { do { try await service.setApprovalPolicy(approvalPolicy.rawValue) } catch { lastError = "Save failed" } } // Features func loadFeatures() async { featuresLoading = true featureError = nil do { suppressUnstableFeaturesWarning = await service.getBool("suppress_unstable_features_warning") async let overridesTask = service.featureOverrides() let infos = try await featuresService.listFeatures() let overrides = await overridesTask var defaults = featureDefaults var rows: [FeatureFlag] = [] let hiddenKeys: Set = [ "experimental_windows_sandbox", "elevated_windows_sandbox", "powershell_utf8", ] for info in infos where !hiddenKeys.contains(info.name) { let base = defaults[info.name] ?? info.enabled defaults[info.name] = base let state: FeatureOverrideState if let override = overrides[info.name] { state = override ? .forceOn : .forceOff } else { state = .inherit } rows.append(FeatureFlag(name: info.name, stage: info.stage, defaultEnabled: base, overrideState: state)) } featureDefaults = defaults featureFlags = rows } catch { featureFlags = [] if let localized = (error as? LocalizedError)?.errorDescription { featureError = localized } else { featureError = "Failed to load features" } } featuresLoading = false } func setFeatureOverride(name: String, state: FeatureOverrideState) { if let idx = featureFlags.firstIndex(where: { $0.name == name }) { featureFlags[idx].overrideState = state } Task { await self.applyFeatureOverride(name: name, state: state) } } private func overrideValue(for state: FeatureOverrideState) -> Bool? { switch state { case .inherit: return nil case .forceOn: return true case .forceOff: return false } } private func applyFeatureOverride(name: String, state: FeatureOverrideState) async { do { let value = overrideValue(for: state) try await service.setFeatureOverride(name: name, value: value) await loadFeatures() } catch { featureError = "Failed to update \(name)" } } // Notifications @Published var notifySelfTestResult: String? = nil @Published var notifyBridgeHealthy: Bool = false func loadNotifications() async { tuiNotifications = await service.getTuiNotifications() let arr = await service.getNotifyArray() if let bridge = arr.first { // If the configured bridge is missing or not executable, try to reinstall silently. if FileManager.default.isExecutableFile(atPath: bridge) { systemNotifications = true notifyBridgePath = bridge notifyBridgeHealthy = true } else { if let url = try? await service.ensureNotifyBridgeInstalled() { notifyBridgePath = url.path systemNotifications = true _ = try? await service.setNotifyArray([url.path]) notifyBridgeHealthy = FileManager.default.isExecutableFile(atPath: url.path) } else { systemNotifications = false notifyBridgePath = nil notifyBridgeHealthy = false } } } else { systemNotifications = false notifyBridgePath = nil notifyBridgeHealthy = false } } func applyTuiNotifications() async { do { try await service.setTuiNotifications(tuiNotifications) } catch { lastError = "Failed to save TUI notifications" } } func applySystemNotifications() async { do { if systemNotifications { let url = try await service.ensureNotifyBridgeInstalled() notifyBridgePath = url.path try await service.setNotifyArray([url.path]) notifyBridgeHealthy = FileManager.default.isExecutableFile(atPath: url.path) } else { notifyBridgePath = nil try await service.setNotifyArray(nil) notifyBridgeHealthy = false } } catch { lastError = "Failed to configure system notifications" } } // Run a local self-test of the notify bridge; returns true on success func runNotifySelfTest() async { notifySelfTestResult = nil // Always reinstall to ensure the latest bridge content (marker + escaping fixes) let path: String = (try? await service.ensureNotifyBridgeInstalled().path) ?? (notifyBridgePath ?? "") guard !path.isEmpty else { notifySelfTestResult = "Bridge path unavailable" return } let payload = #"{"type":"agent-turn-complete","last-assistant-message":"Self-test: turn done","thread-id":"codmate-selftest"}"# do { let proc = Process() proc.executableURL = URL(fileURLWithPath: path) proc.arguments = [payload, "--self-test"] let outPipe = Pipe() proc.standardOutput = outPipe proc.standardError = Pipe() try proc.run() proc.waitUntilExit() let outData = outPipe.fileHandleForReading.readDataToEndOfFile() let outStr = String(data: outData, encoding: .utf8) ?? "" if proc.terminationStatus == 0 { if outStr.contains("__CODMATE_NOTIFIED__") { // Success: show a lightweight status to avoid a "no feedback" experience await SystemNotifier.shared.notify(title: "CodMate", body: "Notifications self-test sent") notifySelfTestResult = "Sent (check Notification Center)" } else { notifySelfTestResult = "Bridge ran, but no notifier accepted (check Focus/Do Not Disturb / permissions)" } } else { notifySelfTestResult = "Exited with status \(proc.terminationStatus)" } } catch { notifySelfTestResult = "Failed to run bridge" } } // Privacy func loadPrivacy() async { _ = await service.sanitizeQuotedBooleans() let p = await service.getShellEnvironmentPolicy() envInherit = p.inherit ?? envInherit envIgnoreDefaults = p.ignoreDefaultExcludes ?? envIgnoreDefaults envIncludeOnly = (p.includeOnly ?? []).joined(separator: ", ") envExclude = (p.exclude ?? []).joined(separator: ", ") envSetPairs = (p.set ?? [:]).map { "\($0.key)=\($0.value)" }.sorted().joined( separator: "\n") hideAgentReasoning = await service.getBool("hide_agent_reasoning") showRawAgentReasoning = await service.getBool("show_raw_agent_reasoning") fileOpener = await service.getTopLevelString("file_opener") ?? fileOpener let oc = await service.getOtelConfig() otelEnabled = oc.exporterKind != .none otelKind = (oc.exporterKind == .otlpGrpc) ? .grpc : .http otelEndpoint = oc.endpoint ?? "" } func applyEnvPolicy() async { var dict: [String: String] = [:] for line in envSetPairs.split(separator: "\n") { let s = String(line) guard let eq = s.firstIndex(of: "=") else { continue } let k = String(s[.. [String]? { let arr = s.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } return arr.isEmpty ? nil : arr } // Raw config helpers func reloadRawConfig() async { rawConfigText = await service.readRawConfigText() } func openConfigInEditor() { Task { @MainActor in let url = await service.configFileURL() NSWorkspace.shared.open(url) } } private static func slugify(_ s: String) -> String { let lower = s.lowercased() let mapped = lower.map { c -> Character in if c.isLetter || c.isNumber { return c } return "-" } var collapsed: [Character] = [] var lastDash = false for ch in mapped { if ch == "-" { if !lastDash { collapsed.append(ch) lastDash = true } } else { collapsed.append(ch) lastDash = false } } while collapsed.first == "-" { collapsed.removeFirst() } while collapsed.last == "-" { collapsed.removeLast() } let s2 = String(collapsed) return s2.isEmpty ? "provider" : s2 } } ================================================ FILE: models/CommandRecord.swift ================================================ import Foundation // MARK: - Command Record /// Represents a unified slash command that can be synced to multiple AI CLI providers struct CommandRecord: Codable, Identifiable, Hashable { var id: String var name: String var description: String var prompt: String var metadata: CommandMetadata var targets: CommandTargets var isEnabled: Bool var source: String var path: String // Path to the Markdown file var installedAt: Date init( id: String, name: String, description: String, prompt: String, metadata: CommandMetadata = CommandMetadata(), targets: CommandTargets = CommandTargets(), isEnabled: Bool = true, source: String = "user", path: String = "", installedAt: Date = Date() ) { self.id = id self.name = name self.description = description self.prompt = prompt self.metadata = metadata self.targets = targets self.isEnabled = isEnabled self.source = source self.path = path self.installedAt = installedAt } } // MARK: - Command Metadata struct CommandMetadata: Codable, Hashable { var argumentHint: String? var model: String? var allowedTools: [String]? var tags: [String] init( argumentHint: String? = nil, model: String? = nil, allowedTools: [String]? = nil, tags: [String] = [] ) { self.argumentHint = argumentHint self.model = model self.allowedTools = allowedTools self.tags = tags } } // MARK: - Command Targets struct CommandTargets: Codable, Hashable { var codex: Bool var claude: Bool var gemini: Bool init(codex: Bool = true, claude: Bool = true, gemini: Bool = false) { self.codex = codex self.claude = claude self.gemini = gemini } func isEnabled(for target: CommandTarget) -> Bool { switch target { case .codex: return codex case .claude: return claude case .gemini: return gemini } } } // MARK: - Command Target enum CommandTarget: String, CaseIterable { case codex case claude case gemini var displayName: String { switch self { case .codex: return "Codex CLI" case .claude: return "Claude Code" case .gemini: return "Gemini CLI" } } var directoryName: String { switch self { case .codex: return ".codex" case .claude: return ".claude" case .gemini: return ".gemini" } } var commandsSubpath: String { switch self { case .codex: return "prompts" // Codex uses ~/.codex/prompts/ case .claude: return "commands" // Claude uses ~/.claude/commands/ case .gemini: return "commands" // Gemini uses ~/.gemini/commands/ } } var baseKind: SessionSource.Kind { switch self { case .codex: return .codex case .claude: return .claude case .gemini: return .gemini } } } // MARK: - Command Extensions extension Array where Element == CommandRecord { func enabledCommands(for target: CommandTarget) -> [CommandRecord] { filter { $0.isEnabled && $0.targets.isEnabled(for: target) } } } ================================================ FILE: models/CommandsViewModel.swift ================================================ import Foundation import SwiftUI @MainActor class CommandsViewModel: ObservableObject { @Published var commands: [CommandRecord] = [] @Published var selectedCommandId: String? = nil @Published var searchText: String = "" @Published var showAddSheet = false @Published var editingCommand: CommandRecord? = nil @Published var syncWarnings: [CommandSyncWarning] = [] @Published var errorMessage: String? = nil @Published var isLoading = false @Published var showImportSheet = false @Published var importCandidates: [CommandImportCandidate] = [] @Published var isImporting = false @Published var importStatusMessage: String? = nil private let store = CommandsStore() private let syncService = CommandsSyncService() init() { Task { await load() } } var selectedCommand: CommandRecord? { guard let id = selectedCommandId else { return nil } return commands.first(where: { $0.id == id }) } var filteredCommands: [CommandRecord] { let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) if query.isEmpty { return commands } return commands.filter { command in command.name.localizedCaseInsensitiveContains(query) || command.description.localizedCaseInsensitiveContains(query) || command.prompt.localizedCaseInsensitiveContains(query) } } // MARK: - Load func load() async { isLoading = true defer { isLoading = false } let records = await store.listWithBuiltIns() commands = records } // MARK: - Import (Home) func beginImportFromHome() { showImportSheet = true Task { await loadImportCandidatesFromHome() } } func loadImportCandidatesFromHome() async { isImporting = true importStatusMessage = "Scanning…" if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() AuthorizationHub.shared.ensureDirectoryAccessOrPrompt( directory: home, purpose: .generalAccess, message: "Authorize your Home folder to import commands" ) } let existing = await store.listWithBuiltIns() let existingIds = Set(existing.map(\.id)) let scanned = await Task.detached(priority: .userInitiated) { CommandsImportService.scan(scope: .home) }.value // CodMate store is the source of truth; provider directories can drift if edited by other tools. let candidates = scanned.filter { !existingIds.contains($0.id) } await MainActor.run { self.importCandidates = candidates self.isImporting = false self.importStatusMessage = candidates.isEmpty ? "No commands found." : nil } } func cancelImport() { showImportSheet = false importCandidates = [] importStatusMessage = nil } func importSelectedCommands() async { let selected = importCandidates.filter { $0.isSelected } guard !selected.isEmpty else { importStatusMessage = "No commands selected." return } var importedCount = 0 var importedCandidateIds: Set = [] for item in selected { let resolution = item.hasConflict ? item.resolution : .overwrite switch resolution { case .skip: continue case .overwrite, .rename: let finalId = resolution == .rename ? item.renameId.trimmingCharacters(in: .whitespacesAndNewlines) : item.id guard !finalId.isEmpty else { continue } var name = item.name if name == item.id && finalId != item.id { name = finalId } let targets = CommandTargets( codex: item.sources.contains("Codex"), claude: item.sources.contains("Claude"), gemini: item.sources.contains("Gemini") ) let record = CommandRecord( id: finalId, name: name, description: item.description, prompt: item.prompt, metadata: item.metadata, targets: targets, isEnabled: true, source: "import", path: "", installedAt: Date() ) await store.upsert(record) importedCount += 1 importedCandidateIds.insert(item.id) } } await load() await syncToProviders() importStatusMessage = "Imported \(importedCount) command(s)." if !importedCandidateIds.isEmpty { importCandidates.removeAll { importedCandidateIds.contains($0.id) } } if importCandidates.isEmpty { closeImportSheetAfterDelay() } } private func closeImportSheetAfterDelay(_ delay: TimeInterval = 0.6) { Task { @MainActor in try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) self.showImportSheet = false self.importStatusMessage = nil } } // MARK: - CRUD Operations func addCommand(_ command: CommandRecord) async { await store.upsert(command) await load() selectedCommandId = command.id await syncToProviders() } func updateCommand(_ command: CommandRecord) async { await store.upsert(command) await load() await syncToProviders() } func deleteCommand(id: String) async { await store.delete(id: id) if selectedCommandId == id { selectedCommandId = nil } await load() await syncToProviders() } func updateCommandEnabled(id: String, value: Bool) { updateLocalCommand(id: id) { record in record.isEnabled = value if !value { record.targets.codex = false record.targets.claude = false record.targets.gemini = false } else { record.targets.codex = true record.targets.claude = true record.targets.gemini = true } } Task { await store.update(id: id) { record in record.isEnabled = value if !value { record.targets.codex = false record.targets.claude = false record.targets.gemini = false } else { record.targets.codex = true record.targets.claude = true record.targets.gemini = true } } await syncToProviders() } } func updateCommandTarget(id: String, target: CommandTarget, value: Bool) { updateLocalCommand(id: id) { record in switch target { case .codex: record.targets.codex = value case .claude: record.targets.claude = value case .gemini: record.targets.gemini = value } if value && !record.isEnabled { record.isEnabled = true } else if !record.targets.codex && !record.targets.claude && !record.targets.gemini { record.isEnabled = false } } Task { await store.update(id: id) { record in switch target { case .codex: record.targets.codex = value case .claude: record.targets.claude = value case .gemini: record.targets.gemini = value } if value && !record.isEnabled { record.isEnabled = true } else if !record.targets.codex && !record.targets.claude && !record.targets.gemini { record.isEnabled = false } } await syncToProviders() } } // MARK: - Sync func syncToProviders() async { let warnings = await syncService.syncGlobal(commands: commands) syncWarnings = warnings if !warnings.isEmpty { errorMessage = "Sync completed with \(warnings.count) warning(s)" } } func manualSync() async { isLoading = true defer { isLoading = false } await syncToProviders() if syncWarnings.isEmpty { errorMessage = "Successfully synced \(commands.filter { $0.isEnabled }.count) commands" } } // MARK: - Import/Export func importFromJSON(url: URL) async { do { let data = try Data(contentsOf: url) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 let imported = try decoder.decode([CommandRecord].self, from: data) for command in imported { await store.upsert(command) } await load() await syncToProviders() errorMessage = "Successfully imported \(imported.count) commands" } catch { errorMessage = "Import failed: \(error.localizedDescription)" } } func exportToJSON(url: URL) async { do { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes, .sortedKeys] encoder.dateEncodingStrategy = .iso8601 let data = try encoder.encode(commands) try data.write(to: url, options: .atomic) errorMessage = "Successfully exported \(commands.count) commands" } catch { errorMessage = "Export failed: \(error.localizedDescription)" } } // MARK: - Editor func openInEditor(_ command: CommandRecord, using editor: EditorApp) { let path = command.path.trimmingCharacters(in: .whitespacesAndNewlines) guard !path.isEmpty else { errorMessage = "Command path not available" return } var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory), !isDirectory.boolValue else { errorMessage = "Command file does not exist: \(path)" return } if let executablePath = findExecutableInPath(editor.cliCommand) { let process = Process() process.executableURL = URL(fileURLWithPath: executablePath) process.arguments = [path] process.standardOutput = Pipe() process.standardError = Pipe() do { try process.run() return } catch { } } if let appURL = editor.appURL { let config = NSWorkspace.OpenConfiguration() config.activates = true NSWorkspace.shared.open( [URL(fileURLWithPath: path)], withApplicationAt: appURL, configuration: config ) { _, error in if let error = error { DispatchQueue.main.async { self.errorMessage = "Failed to open \(editor.title): \(error.localizedDescription)" } } } return } errorMessage = "\(editor.title) is not installed. Please install it or try a different editor." } // MARK: - Helpers func canDelete(id: String) -> Bool { // All commands can be deleted return commands.first(where: { $0.id == id }) != nil } func enabledCount(for target: CommandTarget) -> Int { commands.filter { $0.isEnabled && $0.targets.isEnabled(for: target) }.count } func isCommandTargetEnabled(id: String, target: CommandTarget) -> Bool { guard let command = commands.first(where: { $0.id == id }) else { return false } return command.targets.isEnabled(for: target) } var totalEnabledCount: Int { commands.filter { $0.isEnabled }.count } private func updateLocalCommand(id: String, mutate: (inout CommandRecord) -> Void) { guard let index = commands.firstIndex(where: { $0.id == id }) else { return } var updated = commands mutate(&updated[index]) commands = updated } private func findExecutableInPath(_ name: String) -> String? { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/which") process.arguments = [name] let pipe = Pipe() process.standardOutput = pipe process.standardError = Pipe() do { try process.run() process.waitUntilExit() guard process.terminationStatus == 0 else { return nil } let data = pipe.fileHandleForReading.readDataToEndOfFile() let path = String(data: data, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines) return path?.isEmpty == false ? path : nil } catch { return nil } } } ================================================ FILE: models/ConversationTurn.swift ================================================ import Foundation // MARK: - ConversationTurnPreview /// Lightweight preview for conversation turns, used for fast initial rendering /// before full timeline data is loaded. Cached in SQLite for instant display. struct ConversationTurnPreview: Identifiable, Hashable, Sendable, Codable { let id: String // Same stable ID as ConversationTurn let sessionId: String let turnIndex: Int let timestamp: Date // Preview text (truncated for display when collapsed) let userPreview: String? // First ~100 chars of user message let outputsPreview: String? // First ~100 chars of assistant/tool output let outputCount: Int // Number of output events // Metadata flags let hasToolCalls: Bool let hasThinking: Bool /// Convert a full ConversationTurn to a preview init(from turn: ConversationTurn, sessionId: String, index: Int) { self.id = turn.id self.sessionId = sessionId self.turnIndex = index self.timestamp = turn.timestamp // Extract user preview (first 100 chars) if let userText = turn.userMessage?.text { let trimmed = userText.trimmingCharacters(in: .whitespacesAndNewlines) self.userPreview = String(trimmed.prefix(100)) } else { self.userPreview = nil } // Extract outputs preview (first assistant or tool output, first 100 chars) if let firstOutput = turn.outputs.first(where: { $0.actor == .assistant || $0.actor == .tool }), let text = firstOutput.text { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) self.outputsPreview = String(trimmed.prefix(100)) } else if let firstText = turn.outputs.first?.text { let trimmed = firstText.trimmingCharacters(in: .whitespacesAndNewlines) self.outputsPreview = String(trimmed.prefix(100)) } else { self.outputsPreview = nil } self.outputCount = turn.outputs.count // Check for tool calls and thinking self.hasToolCalls = turn.outputs.contains { $0.actor == .tool } self.hasThinking = turn.outputs.contains { $0.visibilityKind == .reasoning } } // Direct initializer for decoding from SQLite init( id: String, sessionId: String, turnIndex: Int, timestamp: Date, userPreview: String?, outputsPreview: String?, outputCount: Int, hasToolCalls: Bool, hasThinking: Bool ) { self.id = id self.sessionId = sessionId self.turnIndex = turnIndex self.timestamp = timestamp self.userPreview = userPreview self.outputsPreview = outputsPreview self.outputCount = outputCount self.hasToolCalls = hasToolCalls self.hasThinking = hasThinking } } // MARK: - ConversationTurn struct ConversationTurn: Identifiable, Hashable { let id: String let timestamp: Date let userMessage: TimelineEvent? let outputs: [TimelineEvent] var allEvents: [TimelineEvent] { var items: [TimelineEvent] = [] if let userMessage { items.append(userMessage) } items.append(contentsOf: outputs) return items } var actorSummary: String { actorSummary(using: "Codex") } func actorSummary(using assistantName: String) -> String { var parts: [String] = [] if userMessage != nil { parts.append("User") } var seen: Set = [] for event in outputs { if seen.insert(event.actor).inserted { parts.append(event.actor.displayName(assistantName: assistantName)) } } if parts.isEmpty, let first = outputs.first { parts.append(first.actor.displayName(assistantName: assistantName)) } return parts.joined(separator: " → ") } var previewText: String? { var snippets: [String] = [] if let text = userMessage?.text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty { snippets.append(text) } if let assistantReply = outputs.first(where: { $0.actor == .assistant })?.text? .trimmingCharacters(in: .whitespacesAndNewlines), !assistantReply.isEmpty { snippets.append(assistantReply) } else if let other = outputs.first?.text? .trimmingCharacters(in: .whitespacesAndNewlines), !other.isEmpty { snippets.append(other) } guard !snippets.isEmpty else { return nil } return snippets.joined(separator: "\n") } } private extension TimelineActor { func displayName(assistantName: String = "Codex") -> String { switch self { case .user: return "User" case .assistant: return assistantName case .tool: return "Tool" case .info: return "Info" } } } extension Array where Element == ConversationTurn { func removingEnvironmentContext() -> [ConversationTurn] { compactMap { turn in let filteredUser = (turn.userMessage?.title == TimelineEvent.environmentContextTitle) ? nil : turn.userMessage let filteredOutputs = turn.outputs.filter { $0.title != TimelineEvent.environmentContextTitle } if filteredUser == nil && filteredOutputs.isEmpty { return nil } if filteredUser == turn.userMessage && filteredOutputs.count == turn.outputs.count { return turn } return ConversationTurn( id: turn.id, timestamp: turn.timestamp, userMessage: filteredUser, outputs: filteredOutputs ) } } func filtering(visibleKinds: Set) -> [ConversationTurn] { compactMap { turn in let userAllowed: Bool = { guard let u = turn.userMessage else { return false } return visibleKinds.contains(event: u) }() let keptOutputs = turn.outputs.filter { visibleKinds.contains(event: $0) } if !userAllowed && keptOutputs.isEmpty { return nil } return ConversationTurn( id: turn.id, timestamp: turn.timestamp, userMessage: userAllowed ? turn.userMessage : nil, outputs: keptOutputs ) } } /// Extract all user message texts (trimmed, non-empty) func extractUserMessages() -> [String] { compactMap { turn in turn.userMessage?.text?.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } } /// Extract the last assistant message text (from the last turn with assistant outputs) func extractLastAssistantMessage() -> String? { guard let lastTurn = self.last(where: { !$0.outputs.isEmpty }) else { return nil } // Extract only text from assistant messages (visibilityKind == .assistant) let assistantTexts = lastTurn.outputs .filter { $0.visibilityKind == .assistant } .compactMap { $0.text?.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } return assistantTexts.isEmpty ? nil : assistantTexts.joined(separator: "\n\n") } } ================================================ FILE: models/DateDimension.swift ================================================ import Foundation enum DateDimension: String, CaseIterable, Identifiable, Sendable { case created case updated var id: String { rawValue } var title: String { switch self { case .created: return "Created" case .updated: return "Last Updated" } } } ================================================ FILE: models/DialecticsVM.swift ================================================ import Foundation import SwiftUI import AppKit @MainActor final class DialecticsVM: ObservableObject { @Published var sessions: SessionsDiagnostics? = nil @Published var codexPresent: Bool = false @Published var codexVersion: String? = nil @Published var claudePresent: Bool = false @Published var claudeVersion: String? = nil @Published var geminiPresent: Bool = false @Published var geminiVersion: String? = nil @Published var pathEnv: String = ProcessInfo.processInfo.environment["PATH"] ?? "" @Published var sandboxOn: Bool = ProcessInfo.processInfo.environment["APP_SANDBOX_CONTAINER_ID"] != nil private let sessionsSvc = SessionsDiagnosticsService() func runAll(preferences: SessionPreferencesStore) async { struct Snapshot { let sessions: SessionsDiagnostics? let codexPresent: Bool let codexVersion: String? let claudePresent: Bool let claudeVersion: String? let geminiPresent: Bool let geminiVersion: String? let pathEnv: String let sandboxOn: Bool } let home = FileManager.default.homeDirectoryForCurrentUser let defRoot = SessionPreferencesStore.defaultSessionsRoot(for: home) let notesDefault = SessionPreferencesStore.defaultNotesRoot(for: defRoot) let projectsDefault = SessionPreferencesStore.defaultProjectsRoot(for: home) let claudeDefault = home.appendingPathComponent(".claude", isDirectory: true).appendingPathComponent("projects", isDirectory: true) let claudeCurrent: URL? = FileManager.default.fileExists(atPath: claudeDefault.path) ? claudeDefault : nil let geminiDefault = home.appendingPathComponent(".gemini", isDirectory: true).appendingPathComponent("tmp", isDirectory: true) let geminiCurrent: URL? = FileManager.default.fileExists(atPath: geminiDefault.path) ? geminiDefault : nil let sessionsRoot = preferences.sessionsRoot let notesRoot = preferences.notesRoot let projectsRoot = preferences.projectsRoot let sandboxed = ProcessInfo.processInfo.environment["APP_SANDBOX_CONTAINER_ID"] != nil if sandboxed { let brew = URL(fileURLWithPath: "/opt/homebrew/bin", isDirectory: true) let usrLocal = URL(fileURLWithPath: "/usr/local/bin", isDirectory: true) _ = SecurityScopedBookmarks.shared.startAccessDynamic(for: brew) _ = SecurityScopedBookmarks.shared.startAccessDynamic(for: usrLocal) } let snapshot = await Task.detached(priority: .userInitiated) { let svc = SessionsDiagnosticsService() let s = await svc.run( currentRoot: sessionsRoot, defaultRoot: defRoot, notesCurrentRoot: notesRoot, notesDefaultRoot: notesDefault, projectsCurrentRoot: projectsRoot, projectsDefaultRoot: projectsDefault, claudeCurrentRoot: claudeCurrent, claudeDefaultRoot: claudeDefault, geminiCurrentRoot: geminiCurrent, geminiDefaultRoot: geminiDefault ) let mergedPATH = CLIEnvironment.resolvedPATHForCLI(sandboxed: sandboxed) let resolved = CLIEnvironment.resolveExecutablePath("codex", path: mergedPATH) let resolvedClaude = CLIEnvironment.resolveExecutablePath("claude", path: mergedPATH) let resolvedGemini = CLIEnvironment.resolveExecutablePath("gemini", path: mergedPATH) let codexVersion = resolved.flatMap { CLIEnvironment.version(atExecutablePath: $0, path: mergedPATH) } let claudeVersion = resolvedClaude.flatMap { CLIEnvironment.version(atExecutablePath: $0, path: mergedPATH) } let geminiVersion = resolvedGemini.flatMap { CLIEnvironment.version(atExecutablePath: $0, path: mergedPATH) } return Snapshot( sessions: s, codexPresent: resolved != nil, codexVersion: codexVersion, claudePresent: resolvedClaude != nil, claudeVersion: claudeVersion, geminiPresent: resolvedGemini != nil, geminiVersion: geminiVersion, pathEnv: mergedPATH, sandboxOn: sandboxed ) }.value self.sessions = snapshot.sessions self.codexPresent = snapshot.codexPresent self.claudePresent = snapshot.claudePresent self.geminiPresent = snapshot.geminiPresent self.codexVersion = snapshot.codexVersion self.claudeVersion = snapshot.claudeVersion self.geminiVersion = snapshot.geminiVersion self.pathEnv = snapshot.pathEnv self.sandboxOn = snapshot.sandboxOn } var appVersion: String { let info = Bundle.main.infoDictionary let version = info?["CFBundleShortVersionString"] as? String ?? "—" let build = info?["CFBundleVersion"] as? String ?? "—" return "\(version) (\(build))" } var buildTime: String { guard let exe = Bundle.main.executableURL, let attrs = try? FileManager.default.attributesOfItem(atPath: exe.path), let date = attrs[.modificationDate] as? Date else { return "Unavailable" } let df = DateFormatter() df.dateStyle = .medium df.timeStyle = .medium return df.string(from: date) } var osVersion: String { let v = ProcessInfo.processInfo.operatingSystemVersion return "macOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" } // MARK: - Report struct CLICommandReport: Codable { let detectedPath: String? let detectedVersion: String? let userOverridePath: String? let userOverrideResolvedPath: String? let userOverrideVersion: String? let resolvedPath: String? let resolvedVersion: String? } struct CLIReport: Codable { let pathEnv: String let sandboxed: Bool let commands: [String: CLICommandReport] } struct RipgrepReport: Codable { let cachedCoverageEntries: Int let cachedToolEntries: Int let cachedTokenEntries: Int let lastCoverageScan: Date? let lastToolScan: Date? let lastTokenScan: Date? } struct SessionIndexMetaReport: Codable { let lastFullIndexAt: Date? let sessionCount: Int } struct SessionIndexCoverageReport: Codable { let sessionCount: Int let lastFullIndexAt: Date? let sources: [String] } struct SessionIndexReport: Codable { let meta: SessionIndexMetaReport? let coverage: SessionIndexCoverageReport? } struct CacheReport: Codable { let sessionIndex: SessionIndexReport? let ripgrep: RipgrepReport? } struct CombinedReport: Codable { let timestamp: Date let appVersion: String let buildTime: String let osVersion: String let sessions: SessionsDiagnostics? let cli: CLIReport let caches: CacheReport? } func saveReport( preferences: SessionPreferencesStore, ripgrepReport: SessionRipgrepStore.Diagnostics?, indexMeta: SessionIndexMeta?, cacheCoverage: SessionIndexCoverage? ) { let panel = NSSavePanel() panel.canCreateDirectories = true panel.allowedContentTypes = [.json] let df = DateFormatter() df.dateFormat = "yyyyMMdd-HHmmss" let now = Date() panel.nameFieldStringValue = "CodMate-Diagnostics-\(df.string(from: now)).json" panel.beginSheetModal( for: NSApplication.shared.keyWindow ?? NSApplication.shared.windows.first! ) { resp in guard resp == .OK, let url = panel.url else { return } let report = self.buildReport( preferences: preferences, now: now, ripgrepReport: ripgrepReport, indexMeta: indexMeta, cacheCoverage: cacheCoverage ) let enc = JSONEncoder() enc.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes] enc.dateEncodingStrategy = .iso8601 if let data = try? enc.encode(report) { try? data.write(to: url, options: .atomic) } } } @MainActor private func buildReport( preferences: SessionPreferencesStore, now: Date, ripgrepReport: SessionRipgrepStore.Diagnostics?, indexMeta: SessionIndexMeta?, cacheCoverage: SessionIndexCoverage? ) -> CombinedReport { let path = pathEnv func trimmedOverridePath(for kind: SessionSource.Kind) -> String? { let raw: String switch kind { case .codex: raw = preferences.codexCommandPath case .claude: raw = preferences.claudeCommandPath case .gemini: raw = preferences.geminiCommandPath } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } func commandReport(for kind: SessionSource.Kind) -> CLICommandReport { let name = kind.cliExecutableName let detectedPath = CLIEnvironment.resolveExecutablePath(name, path: path) let detectedVersion = detectedPath.flatMap { CLIEnvironment.version(atExecutablePath: $0, path: path) } let userOverridePath = trimmedOverridePath(for: kind) let userResolvedPath = preferences.resolvedCommandOverrideURL(for: kind)?.path let userVersion = userResolvedPath.flatMap { CLIEnvironment.version(atExecutablePath: $0, path: path) } let resolvedPath = userResolvedPath ?? detectedPath let resolvedVersion = userResolvedPath != nil ? userVersion : detectedVersion return CLICommandReport( detectedPath: detectedPath, detectedVersion: detectedVersion, userOverridePath: userOverridePath, userOverrideResolvedPath: userResolvedPath, userOverrideVersion: userVersion, resolvedPath: resolvedPath, resolvedVersion: resolvedVersion ) } let cli = CLIReport( pathEnv: path, sandboxed: sandboxOn, commands: [ "codex": commandReport(for: .codex), "claude": commandReport(for: .claude), "gemini": commandReport(for: .gemini), ] ) let caches = CacheReport( sessionIndex: SessionIndexReport( meta: indexMeta.map { SessionIndexMetaReport(lastFullIndexAt: $0.lastFullIndexAt, sessionCount: $0.sessionCount) }, coverage: cacheCoverage.map { SessionIndexCoverageReport( sessionCount: $0.sessionCount, lastFullIndexAt: $0.lastFullIndexAt, sources: $0.sources.map(\.rawValue) ) } ), ripgrep: ripgrepReport.map { RipgrepReport( cachedCoverageEntries: $0.cachedCoverageEntries, cachedToolEntries: $0.cachedToolEntries, cachedTokenEntries: $0.cachedTokenEntries, lastCoverageScan: $0.lastCoverageScan, lastToolScan: $0.lastToolScan, lastTokenScan: $0.lastTokenScan ) } ) return CombinedReport( timestamp: now, appVersion: appVersion, buildTime: buildTime, osVersion: osVersion, sessions: sessions, cli: cli, caches: caches ) } } ================================================ FILE: models/EditorApp.swift ================================================ import Foundation import AppKit enum EditorApp: String, CaseIterable, Identifiable { case vscode case cursor case zed case antigravity var id: String { rawValue } private static let menuIconSize = NSSize(width: 14, height: 14) /// Editors that are currently available on this system. /// This is computed once per launch by probing the bundle id and CLI. /// Results are sorted alphabetically by title. static let installedEditors: [EditorApp] = { allCases.filter(\.isInstalled).sorted(by: { $0.title < $1.title }) }() var title: String { switch self { case .vscode: return "Visual Studio Code" case .cursor: return "Cursor" case .zed: return "Zed" case .antigravity: return "Antigravity" } } var bundleIdentifier: String { switch self { case .vscode: return "com.microsoft.VSCode" case .cursor: return "com.todesktop.230313mzl4w4u92" case .zed: return "dev.zed.Zed" case .antigravity: return "com.google.antigravity" } } var cliCommand: String { switch self { case .vscode: return "code" case .cursor: return "cursor" case .zed: return "zed" case .antigravity: return "antigravity" } } var appURL: URL? { NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) } var menuIcon: NSImage? { guard let url = appURL else { return nil } let image = NSWorkspace.shared.icon(forFile: url.path) return resizedMenuIcon(image) } /// Check if the editor is installed on the system var isInstalled: Bool { // Try to find the app via bundle identifier if NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) != nil { return true } // Fallback: check if CLI command is available in PATH let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/which") process.arguments = [cliCommand] process.standardOutput = Pipe() process.standardError = Pipe() do { try process.run() process.waitUntilExit() return process.terminationStatus == 0 } catch { return false } } private func resizedMenuIcon(_ image: NSImage) -> NSImage { let newImage = NSImage(size: Self.menuIconSize) newImage.lockFocus() image.draw( in: NSRect(origin: .zero, size: Self.menuIconSize), from: NSRect(origin: .zero, size: image.size), operation: .copy, fraction: 1.0 ) newImage.unlockFocus() return newImage } } ================================================ FILE: models/EnvironmentContextInfo.swift ================================================ import Foundation struct EnvironmentContextInfo: Equatable { struct Entry: Identifiable, Equatable { let key: String let value: String var id: String { key } } let timestamp: Date let entries: [Entry] let rawText: String? var hasContent: Bool { !entries.isEmpty || (rawText?.isEmpty == false) } } ================================================ FILE: models/ExecutionPolicy.swift ================================================ import Foundation enum SandboxMode: String, CaseIterable, Identifiable, Codable { case readOnly = "read-only" case workspaceWrite = "workspace-write" case dangerFullAccess = "danger-full-access" var id: String { rawValue } var title: String { switch self { case .readOnly: return "read-only" case .workspaceWrite: return "workspace-write" case .dangerFullAccess: return "danger-full-access" } } } enum ApprovalPolicy: String, CaseIterable, Identifiable, Codable { case untrusted case onFailure = "on-failure" case onRequest = "on-request" case never var id: String { rawValue } var title: String { switch self { case .untrusted: return "untrusted" case .onFailure: return "on-failure" case .onRequest: return "on-request" case .never: return "never" } } } // Claude Code specific permission mode enum ClaudePermissionMode: String, CaseIterable, Identifiable, Codable { case `default` case acceptEdits case bypassPermissions case plan var id: String { rawValue } } struct ResumeOptions { var sandbox: SandboxMode? var approval: ApprovalPolicy? var fullAuto: Bool var dangerouslyBypass: Bool // Claude Code advanced flags (optional) var claudeDebug: Bool = false var claudeDebugFilter: String? = nil var claudeVerbose: Bool = false var claudePermissionMode: ClaudePermissionMode? = nil var claudeAllowedTools: String? = nil var claudeDisallowedTools: String? = nil var claudeAddDirs: String? = nil var claudeIDE: Bool = false var claudeStrictMCP: Bool = false var claudeFallbackModel: String? = nil var claudeSkipPermissions: Bool = false var claudeAllowSkipPermissions: Bool = false var claudeAllowUnsandboxedCommands: Bool = false } extension ResumeOptions { var flagSandboxRaw: String? { sandbox?.rawValue } var flagApprovalRaw: String? { approval?.rawValue } } ================================================ FILE: models/ExtensionsImportModels.swift ================================================ import Foundation enum ImportResolutionChoice: String, CaseIterable, Identifiable { case skip case overwrite case rename var id: String { rawValue } var title: String { switch self { case .skip: return "Skip" case .overwrite: return "Overwrite" case .rename: return "Rename" } } } enum ExtensionsImportScope: Hashable { case home case project(directory: URL) } struct CommandImportCandidate: Identifiable, Hashable { var id: String var name: String var description: String var prompt: String var metadata: CommandMetadata var sources: [String] var sourcePaths: [String: String] var isSelected: Bool var hasConflict: Bool var resolution: ImportResolutionChoice var renameId: String func hash(into hasher: inout Hasher) { hasher.combine(id) } static func == (lhs: CommandImportCandidate, rhs: CommandImportCandidate) -> Bool { lhs.id == rhs.id } } struct SkillImportCandidate: Identifiable, Hashable { var id: String var name: String var summary: String var sourcePath: String var sources: [String] var sourcePaths: [String: String] var isSelected: Bool var hasConflict: Bool var conflictDetail: String? var resolution: ImportResolutionChoice var renameId: String var suggestedId: String func hash(into hasher: inout Hasher) { hasher.combine(id) } static func == (lhs: SkillImportCandidate, rhs: SkillImportCandidate) -> Bool { lhs.id == rhs.id } } struct MCPImportCandidate: Identifiable { let id: UUID var name: String var kind: MCPServerKind var command: String? var args: [String]? var env: [String: String]? var url: String? var headers: [String: String]? var description: String? var sources: [String] var sourcePaths: [String: String] var isSelected: Bool var hasConflict: Bool var hasNameCollision: Bool var resolution: ImportResolutionChoice var renameName: String var signature: String } struct HookImportCandidate: Identifiable, Hashable { let id: UUID var rule: HookRule var sources: [String] var sourcePaths: [String: String] var isSelected: Bool var hasConflict: Bool var hasNameCollision: Bool var resolution: ImportResolutionChoice var renameName: String var signature: String func hash(into hasher: inout Hasher) { hasher.combine(id) } static func == (lhs: HookImportCandidate, rhs: HookImportCandidate) -> Bool { lhs.id == rhs.id } } ================================================ FILE: models/ExtensionsSettingsTab.swift ================================================ import Foundation enum ExtensionsSettingsTab: String, CaseIterable, Identifiable { case mcp case skills case commands case hooks var id: String { rawValue } } ================================================ FILE: models/ExternalTerminalProfile.swift ================================================ import Foundation struct ExternalTerminalProfile: Identifiable, Codable, Equatable { enum CommandStyle: String, Codable { case standard case warp } var id: String var title: String? var bundleIdentifiers: [String]? var urlTemplate: String? var supportsCommand: Bool? var supportsDirectory: Bool? var managedByCodMate: Bool? var commandStyle: CommandStyle? var displayTitle: String { let trimmed = (title ?? "").trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? id : trimmed } var isNone: Bool { id == "none" } var isTerminal: Bool { id == "terminal" } var isITerm2: Bool { id == "iterm2" } var isWarp: Bool { commandStyleResolved == .warp || id == "warp" } var commandStyleResolved: CommandStyle { if let commandStyle { return commandStyle } return id == "warp" ? .warp : .standard } var supportsCommandResolved: Bool { if let supportsCommand { return supportsCommand } if let urlTemplate, urlTemplate.contains("{command}") { return true } return isITerm2 } var supportsDirectoryResolved: Bool { if let supportsDirectory { return supportsDirectory } return true } var resolvedBundleIdentifier: String? { if isTerminal { return "com.apple.Terminal" } guard let bundleIdentifiers, !bundleIdentifiers.isEmpty else { return nil } return AppAvailability.firstInstalledBundleIdentifier(in: bundleIdentifiers) ?? bundleIdentifiers.first } var isInstalled: Bool { if isTerminal { return true } guard let bundleIdentifiers, !bundleIdentifiers.isEmpty else { return false } return AppAvailability.isInstalled(bundleIdentifiers: bundleIdentifiers) } var isAvailable: Bool { if isNone || isTerminal { return true } if let bundleIdentifiers, !bundleIdentifiers.isEmpty { return AppAvailability.isInstalled(bundleIdentifiers: bundleIdentifiers) } if urlTemplate != nil { return true } return false } var usesWarpCommands: Bool { commandStyleResolved == .warp } } ================================================ FILE: models/GeminiUsageStatus.swift ================================================ import Foundation struct GeminiUsageStatus: Equatable { struct Bucket: Equatable { let modelId: String? let tokenType: String? let remainingFraction: Double? let remainingAmount: String? let resetTime: Date? } let updatedAt: Date let projectId: String? let buckets: [Bucket] let planType: String? // Subscription type (AI Pro, AI Ultra, etc.) func asProviderSnapshot(titleBadge: String? = nil) -> UsageProviderSnapshot { // Group buckets by model ID to find the lowest quota per model // (input/output tokens might have different quotas; show the more limiting one) var modelQuotaMap: [String: Bucket] = [:] for bucket in buckets { guard let modelId = bucket.modelId, !modelId.isEmpty else { continue } if let existing = modelQuotaMap[modelId] { // Keep the bucket with lower remaining fraction (more constrained) if let newFraction = bucket.remainingFraction, let existingFraction = existing.remainingFraction, newFraction < existingFraction { modelQuotaMap[modelId] = bucket } } else { modelQuotaMap[modelId] = bucket } } // Sort models by name, showing used models first (lower remaining fraction) let sortedModels = modelQuotaMap.sorted { a, b in let aUsed = (a.value.remainingFraction ?? 1.0) < 1.0 let bUsed = (b.value.remainingFraction ?? 1.0) < 1.0 if aUsed != bUsed { return aUsed } return a.key.localizedStandardCompare(b.key) == .orderedAscending } // Create metrics for models - show ALL models with quotas // Models with usage (remainingFraction < 1.0) show full details // Models without usage show a simplified "available" state let metrics: [UsageMetricSnapshot] = sortedModels.compactMap { modelId, bucket in let remaining = bucket.remainingFraction?.clamped01() let hasBeenUsed = (remaining ?? 1.0) < 1.0 // For unused models, show a simplified display let percentText: String? = { guard let remaining else { return nil } if hasBeenUsed { return NumberFormatter.compactPercentFormatter.string(from: NSNumber(value: remaining)) ?? String(format: "%.0f%%", remaining * 100) } return "100%" }() let label = modelId let usageText: String? = { if hasBeenUsed { if let amount = bucket.remainingAmount, !amount.isEmpty { return "Remaining \(amount)" } } return nil }() return UsageMetricSnapshot( kind: .quota, label: label, usageText: usageText, percentText: percentText, progress: remaining ?? 1.0, resetDate: bucket.resetTime, fallbackWindowMinutes: 1440 // 24h default for Gemini quotas ) } // Availability: ready if we have any buckets, empty only if no data at all let availability: UsageProviderSnapshot.Availability = buckets.isEmpty ? .empty : .ready // Count used models let usedModels = sortedModels.filter { (_, bucket) in (bucket.remainingFraction ?? 1.0) < 1.0 }.count let totalModels = sortedModels.count let statusMessage: String? = { if buckets.isEmpty { return "No Gemini usage data." } if usedModels == 0 && totalModels > 0 { return "No models used yet. Quotas available for \(totalModels) models." } return nil }() return UsageProviderSnapshot( provider: .gemini, title: UsageProviderKind.gemini.displayName, titleBadge: titleBadge, availability: availability, metrics: metrics, updatedAt: updatedAt, statusMessage: statusMessage, origin: .builtin ) } } private extension Double { func clamped01() -> Double { max(0, min(self, 1)) } } ================================================ FILE: models/GeminiVM.swift ================================================ import AppKit import Foundation import SwiftUI @MainActor final class GeminiVM: ObservableObject { struct ModelOption: Identifiable { let value: String? let title: String let subtitle: String var id: String { value ?? "default" } } @Published var previewFeatures = false @Published var vimMode = false @Published var disableAutoUpdate = false @Published var enablePromptCompletion = false @Published var sessionRetentionEnabled = false @Published var selectedModelId: String? @Published var maxSessionTurns: Int = -1 @Published var compressionThreshold: Double = 0.5 @Published var skipNextSpeakerCheck = true @Published var notificationsEnabled = false @Published var notificationBridgeHealthy = false @Published var notificationSelfTestResult: String? = nil @Published var rawSettingsText: String = "" @Published var lastError: String? @Published private(set) var hasLoadedInitialState = false var modelOptions: [ModelOption] { [ ModelOption(value: nil, title: "Auto (Gemini CLI default)", subtitle: "CLI picks the best model depending on task complexity"), ModelOption(value: "gemini-3-pro-preview", title: "Gemini 3 Pro Preview", subtitle: "High reasoning depth when preview access is enabled"), ModelOption(value: "gemini-2.5-pro", title: "Gemini 2.5 Pro", subtitle: "Deep reasoning with broad tool support"), ModelOption(value: "gemini-2.5-flash", title: "Gemini 2.5 Flash", subtitle: "Balanced speed and reasoning"), ModelOption(value: "gemini-2.5-flash-lite", title: "Gemini 2.5 Flash Lite", subtitle: "Fastest responses for lightweight tasks"), ] } private let service = GeminiSettingsService() private var notificationDebounceTask: Task? = nil func loadIfNeeded() async { if hasLoadedInitialState { return } await refreshSettings() await loadNotificationSettings() await reloadRawSettings() hasLoadedInitialState = true } func refreshSettings() async { let snapshot = await service.loadSnapshot() previewFeatures = snapshot.previewFeatures ?? false vimMode = snapshot.vimMode ?? false disableAutoUpdate = snapshot.disableAutoUpdate ?? false enablePromptCompletion = snapshot.enablePromptCompletion ?? false sessionRetentionEnabled = snapshot.sessionRetentionEnabled ?? false selectedModelId = snapshot.modelName maxSessionTurns = snapshot.maxSessionTurns ?? -1 compressionThreshold = snapshot.compressionThreshold ?? 0.5 skipNextSpeakerCheck = snapshot.skipNextSpeakerCheck ?? true } func reloadRawSettings() async { rawSettingsText = await service.loadRawText() } func openSettingsInEditor() { let url = service.settingsFileURL NSWorkspace.shared.activateFileViewerSelecting([url]) } // MARK: - Apply handlers func applyPreviewFeaturesChange() { guard hasLoadedInitialState else { return } persist { [self] in try await self.service.setBool(self.previewFeatures, at: ["general", "previewFeatures"]) } } func applyVimModeChange() { guard hasLoadedInitialState else { return } persist { [self] in try await self.service.setBool(self.vimMode, at: ["general", "vimMode"]) } } func applyDisableAutoUpdateChange() { guard hasLoadedInitialState else { return } persist { [self] in try await self.service.setBool(self.disableAutoUpdate, at: ["general", "disableAutoUpdate"]) } } func applyPromptCompletionChange() { guard hasLoadedInitialState else { return } persist { [self] in try await self.service.setBool(self.enablePromptCompletion, at: ["general", "enablePromptCompletion"]) } } func applySessionRetentionChange() { guard hasLoadedInitialState else { return } persist { [self] in try await self.service.setBool(self.sessionRetentionEnabled, at: ["general", "sessionRetention", "enabled"]) } } func applyModelSelectionChange() { guard hasLoadedInitialState else { return } let value = selectedModelId persist { [self] in try await self.service.setOptionalString(value, at: ["model", "name"]) } } func applyMaxSessionTurnsChange() { guard hasLoadedInitialState else { return } persist { [self] in try await self.service.setInt(self.maxSessionTurns, at: ["model", "maxSessionTurns"]) } } func applyCompressionThresholdChange() { guard hasLoadedInitialState else { return } let value = compressionThreshold persist { [self] in try await self.service.setDouble(value, at: ["model", "compressionThreshold"]) } } func applySkipNextSpeakerChange() { guard hasLoadedInitialState else { return } persist { [self] in try await self.service.setBool(self.skipNextSpeakerCheck, at: ["model", "skipNextSpeakerCheck"]) } } func loadNotificationSettings() async { let status = await service.codMateNotificationHooksStatus() await MainActor.run { self.notificationsEnabled = status.hookInstalled self.notificationBridgeHealthy = status.hookInstalled && status.hooksEnabled if !self.notificationBridgeHealthy { self.notificationSelfTestResult = nil } } } private func applyNotificationSettings() async { if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync( directory: home.appendingPathComponent(".gemini", isDirectory: true), purpose: .generalAccess, message: "Authorize ~/.gemini to update Gemini notifications" ) } do { try await service.setCodMateNotificationHooks(enabled: notificationsEnabled) await loadNotificationSettings() } catch { await MainActor.run { self.lastError = "Failed to update Gemini notifications" } } } func runNotificationSelfTest() async { notificationSelfTestResult = nil var comps = URLComponents() comps.scheme = "codmate" comps.host = "notify" let title = "CodMate" let body = "Gemini notifications self-test" var items = [ URLQueryItem(name: "source", value: "gemini"), URLQueryItem(name: "event", value: "test") ] if let titleData = title.data(using: .utf8) { items.append(URLQueryItem(name: "title64", value: titleData.base64EncodedString())) } if let bodyData = body.data(using: .utf8) { items.append(URLQueryItem(name: "body64", value: bodyData.base64EncodedString())) } comps.queryItems = items guard let url = comps.url else { notificationSelfTestResult = "Invalid test URL" return } let success = NSWorkspace.shared.open(url) notificationSelfTestResult = success ? "Sent (check Notification Center)" : "Failed to open codmate:// URL" } func scheduleApplyNotificationSettingsDebounced(delayMs: UInt64 = 250) { notificationDebounceTask?.cancel() notificationDebounceTask = Task { [weak self] in guard let self else { return } do { try await Task.sleep(nanoseconds: delayMs * 1_000_000) } catch { return } if Task.isCancelled { return } await self.applyNotificationSettings() } } private func persist(_ work: @escaping () async throws -> Void) { Task { @MainActor in do { try await work() self.lastError = nil } catch { self.lastError = error.localizedDescription } } } } ================================================ FILE: models/GitChangesViewModel.swift ================================================ import AppKit import OSLog import Foundation @MainActor final class GitChangesViewModel: ObservableObject { private static let log = Logger(subsystem: "ai.codmate.app", category: "AICommit") @Published private(set) var repoRoot: URL? = nil @Published private(set) var changes: [GitService.Change] = [] @Published var selectedPath: String? = nil enum CompareSide: Equatable { case unstaged, staged } @Published var selectedSide: CompareSide = .unstaged @Published var showPreviewInsteadOfDiff: Bool = false @Published var diffText: String = "" // or file preview text when in preview mode @Published var isLoading = false @Published var errorMessage: String? = nil @Published var commitMessage: String = "" @Published var isGenerating: Bool = false @Published private(set) var generatingRepoPath: String? = nil @Published private(set) var isResolvingRepo: Bool = true @Published private(set) var treeSnapshot: GitReviewTreeSnapshot = .empty private let service = GitService() private var monitorWorktree: DirectoryMonitor? private var monitorIndex: DirectoryMonitor? private var refreshTask: Task? = nil private var repo: GitService.Repo? = nil private var generatingTask: Task? = nil private var treeBuildTask: Task? = nil private var diffTask: Task? = nil private var treeSnapshotGeneration: UInt64 = 0 private var lastRefreshToken: Int? = nil private var explorerFallbackRoot: URL? = nil func attach(to directory: URL, fallbackProjectDirectory: URL? = nil) { isResolvingRepo = true explorerFallbackRoot = fallbackProjectDirectory ?? directory Task { [weak self] in guard let self else { return } defer { self.isResolvingRepo = false } await self.resolveRepoRoot(from: directory, fallbackProjectDirectory: fallbackProjectDirectory) await self.refreshStatus() self.configureMonitors() } } func detach() { monitorWorktree?.cancel(); monitorWorktree = nil monitorIndex?.cancel(); monitorIndex = nil treeBuildTask?.cancel(); treeBuildTask = nil diffTask?.cancel(); diffTask = nil repo = nil repoRoot = nil explorerFallbackRoot = nil changes = [] selectedPath = nil diffText = "" isResolvingRepo = false treeSnapshot = .empty } private func resolveRepoRoot(from directory: URL, fallbackProjectDirectory: URL?) async { let canonical = directory if let repo = await service.repositoryRoot(for: canonical) { assignRepoRoot(to: repo.root, reason: "git-cli (session)") return } if let fsRoot = filesystemGitRoot(startingAt: canonical) { assignRepoRoot(to: fsRoot, reason: "filesystem (session)") return } if let fallback = fallbackProjectDirectory { if let repo = await service.repositoryRoot(for: fallback) { assignRepoRoot(to: repo.root, reason: "git-cli (project)") return } if hasGitDirectory(at: fallback) { assignRepoRoot(to: fallback.standardizedFileURL, reason: "project directory") return } } Self.log.warning("No Git repository found starting from \(directory.path, privacy: .public)") self.repo = nil self.repoRoot = nil errorMessage = "No Git repository found" } private func assignRepoRoot(to root: URL, reason: String) { if SecurityScopedBookmarks.shared.isSandboxed { let hasBookmark = SecurityScopedBookmarks.shared.hasDynamicBookmark(for: root) #if DEBUG Self.log.info("Repository at \(root.path, privacy: .public) has bookmark: \(hasBookmark, privacy: .public)") #endif let hasAccess = SecurityScopedBookmarks.shared.startAccessDynamic(for: root) #if DEBUG Self.log.info("Started access for \(root.path, privacy: .public): \(hasAccess, privacy: .public)") #endif if !hasAccess { Self.log.error("Failed to start access for repository at \(root.path, privacy: .public)") if hasBookmark { errorMessage = "Repository access failed. The bookmark may be stale. Please re-authorize." } else { errorMessage = "Repository access required. Please authorize the repository folder: \(root.path)" } } } self.repo = GitService.Repo(root: root) self.repoRoot = root #if DEBUG Self.log.info("Git repository resolved via \(reason, privacy: .public): \(root.path, privacy: .public)") #endif } private func filesystemGitRoot(startingAt start: URL) -> URL? { var cur = start.standardizedFileURL var guardCounter = 0 while guardCounter < 200 { if hasGitDirectory(at: cur) { return cur } let parent = cur.deletingLastPathComponent() if parent.path == cur.path { break } cur = parent guardCounter += 1 } return nil } private func hasGitDirectory(at url: URL) -> Bool { let gitDir = url.appendingPathComponent(".git", isDirectory: true) var isDir: ObjCBool = false return FileManager.default.fileExists(atPath: gitDir.path, isDirectory: &isDir) && isDir.boolValue } private func configureMonitors() { guard let root = repoRoot else { return } // Monitor the worktree directory (non-recursive; still good enough to get write pulses) monitorWorktree?.cancel() monitorWorktree = DirectoryMonitor(url: root) { [weak self] in self?.scheduleRefresh() } // Monitor .git/index changes (staging updates) let indexURL = root.appendingPathComponent(".git/index") monitorIndex?.cancel() monitorIndex = DirectoryMonitor(url: indexURL) { [weak self] in self?.scheduleRefresh() } } private func scheduleRefresh() { refreshTask?.cancel() refreshTask = Task { @MainActor [weak self] in guard let self else { return } try? await Task.sleep(nanoseconds: 200_000_000) await self.refreshStatus() } } private func scheduleTreeSnapshotRefresh() { treeBuildTask?.cancel() treeSnapshotGeneration &+= 1 let generation = treeSnapshotGeneration let snapshotInput = self.changes treeBuildTask = Task { [weak self] in guard let self else { return } let built = await Task.detached(priority: .userInitiated) { GitReviewTreeBuilder.buildSnapshot(from: snapshotInput) }.value guard !Task.isCancelled else { return } if self.treeSnapshotGeneration == generation { self.treeSnapshot = built } } } func refreshStatus() async { guard let repo = self.repo else { changes = []; selectedPath = nil; diffText = ""; return } // Ensure we have access before executing git commands if SecurityScopedBookmarks.shared.isSandboxed { let hasAccess = SecurityScopedBookmarks.shared.startAccessDynamic(for: repo.root) if !hasAccess { Self.log.error("Failed to start access for repository at \(repo.root.path, privacy: .public)") errorMessage = "Repository access required. Please authorize the repository folder." changes = [] return } } isLoading = true errorMessage = nil // Clear previous errors let list = await service.status(in: repo) isLoading = false if list.isEmpty { if let failure = await service.takeLastFailureDescription() { errorMessage = Self.describeGitFailure(failure) } } else { _ = await service.takeLastFailureDescription() } if list.isEmpty && SecurityScopedBookmarks.shared.isSandboxed { // Verify git can actually access the repository Self.log.warning("Git status returned empty for \(repo.root.path, privacy: .public)") } changes = list scheduleTreeSnapshotRefresh() // Maintain selection when possible if let sel = selectedPath, !list.contains(where: { $0.path == sel }) { selectedPath = nil diffText = "" } await refreshDetail() } func refreshStatusIfNeeded(refreshToken: Int) async { if lastRefreshToken == refreshToken { return } lastRefreshToken = refreshToken await refreshStatus() } func refreshDetail() async { diffTask?.cancel() guard let path = selectedPath else { diffText = ""; return } let currentRepo = self.repo if currentRepo == nil, showPreviewInsteadOfDiff, let base = repoRoot ?? explorerFallbackRoot { let url = base.appendingPathComponent(path) if let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) { diffText = text } else { diffText = "(Preview unavailable)" } return } guard let repo = currentRepo else { diffText = ""; return } // Ensure access before reading files if SecurityScopedBookmarks.shared.isSandboxed { _ = SecurityScopedBookmarks.shared.startAccessDynamic(for: repo.root) } let showPreview = showPreviewInsteadOfDiff let selectedSide = self.selectedSide let changesSnapshot = self.changes let service = self.service diffTask = Task { [weak self] in guard let self else { return } let text = await Task.detached(priority: .userInitiated) { await Self.computeDiffText( service: service, repo: repo, path: path, showPreview: showPreview, selectedSide: selectedSide, changes: changesSnapshot ) }.value guard !Task.isCancelled else { return } if self.selectedPath == path, self.selectedSide == selectedSide, self.showPreviewInsteadOfDiff == showPreview { self.diffText = text } } } private static func computeDiffText( service: GitService, repo: GitService.Repo, path: String, showPreview: Bool, selectedSide: CompareSide, changes: [GitService.Change] ) async -> String { if showPreview { return await service.readFile(in: repo, path: path) } let isStagedSide = (selectedSide == .staged) var text = await service.diff(in: repo, path: path, staged: isStagedSide) if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if isStagedSide { text = await service.diff(in: repo, path: path, staged: false) } if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, let kind = changes.first(where: { $0.path == path })?.worktree, kind == .untracked { let content = await service.readFile(in: repo, path: path) text = syntheticDiff(forPath: path, content: content) } } return text } private static func syntheticDiff(forPath path: String, content: String) -> String { // Produce a minimal unified diff for a new (untracked) file vs /dev/null let lines = content.split(separator: "\\n", omittingEmptySubsequences: false) let count = lines.count var out: [String] = [] out.append("--- /dev/null") out.append("+++ b/\(path)") out.append("@@ -0,0 +\(count) @@") for l in lines { out.append("+" + String(l)) } return out.joined(separator: "\\n") } private static func describeGitFailure(_ raw: String) -> String { let message = raw.trimmingCharacters(in: .whitespacesAndNewlines) if message.isEmpty { return "The git command failed without returning an error message." } if message.contains("App Sandbox") || message.contains("xcrun: error") { return "The built-in git relies on xcrun and was denied in the App Sandbox. Install Xcode Command Line Tools (xcode-select --install) or provide an accessible git executable." } if message.contains("not a git repository") { return "The current directory is not a Git repository." } return message } func toggleStage(for paths: [String]) async { guard let repo = self.repo else { return } // Determine which ones are staged let staged: Set = Set(changes.compactMap { ($0.staged != nil) ? $0.path : nil }) let toUnstage = paths.filter { staged.contains($0) } let toStage = paths.filter { !staged.contains($0) } if !toStage.isEmpty { await service.stage(in: repo, paths: toStage) } if !toUnstage.isEmpty { await service.unstage(in: repo, paths: toUnstage) } await refreshStatus() } // Explicit stage only func stage(paths: [String]) async { guard let repo = self.repo, !paths.isEmpty else { return } await service.stage(in: repo, paths: paths) await refreshStatus() } // Explicit unstage only func unstage(paths: [String]) async { guard let repo = self.repo, !paths.isEmpty else { return } await service.unstage(in: repo, paths: paths) await refreshStatus() } // Folder action: stage remaining if not all staged, otherwise unstage all func applyFolderStaging(for dirKey: String, paths: [String]) async { guard !paths.isEmpty else { return } let stagedSet: Set = Set(changes.compactMap { ($0.staged != nil) ? $0.path : nil }) let allStaged = paths.allSatisfy { stagedSet.contains($0) } if allStaged { await unstage(paths: paths) } else { let toStage = paths.filter { !stagedSet.contains($0) } await stage(paths: toStage) } } func commit() async { guard let repo = self.repo else { return } let code = await service.commit(in: repo, message: commitMessage) if code == 0 { commitMessage = "" await refreshStatus() } else { errorMessage = "Commit failed (exit code \(code))" } } // MARK: - Discard // includeStaged=false matches VS Code Git Graph semantics: // only discard unstaged/worktree changes, preserving any staged changes. func discard(paths: [String], includeStaged: Bool = false) async { guard let repo = self.repo else { return } let pathSet = Set(paths) let map: [String: GitService.Change] = Dictionary(uniqueKeysWithValues: changes.map { ($0.path, $0) }) var untracked: [String] = [] var trackedWorktreeOnly: [String] = [] var trackedFullReset: [String] = [] for p in pathSet { guard let change = map[p] else { continue } if change.worktree == .untracked { untracked.append(p) continue } // Tracked file if includeStaged { // Discard both staged and unstaged changes if change.staged != nil || change.worktree != nil { trackedFullReset.append(p) } } else { // Discard only unstaged/worktree changes, keep any staged state if change.worktree != nil { trackedWorktreeOnly.append(p) } } } if includeStaged { if !trackedFullReset.isEmpty { _ = await service.discardTracked(in: repo, paths: trackedFullReset) } } else { if !trackedWorktreeOnly.isEmpty { _ = await service.discardWorktree(in: repo, paths: trackedWorktreeOnly) } } if !untracked.isEmpty { _ = await service.cleanUntracked(in: repo, paths: untracked) } await refreshStatus() } // MARK: - Open in external editor (file) func openFile(_ path: String, using editor: EditorApp) { guard let root = repoRoot ?? explorerFallbackRoot else { return } let filePath = root.appendingPathComponent(path).path // Try CLI command first if let exe = Self.findExecutableInPath(editor.cliCommand) { let p = Process() p.executableURL = URL(fileURLWithPath: exe) p.arguments = [filePath] p.standardOutput = Pipe(); p.standardError = Pipe() do { try p.run(); return } catch { // fall through } } // Fallback: open via bundle id if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: editor.bundleIdentifier) { let config = NSWorkspace.OpenConfiguration(); config.activates = true NSWorkspace.shared.open([URL(fileURLWithPath: filePath)], withApplicationAt: appURL, configuration: config) { _, err in if let err { Task { @MainActor in self.errorMessage = "Failed to open \(editor.title): \(err.localizedDescription)" } } } return } errorMessage = "\(editor.title) is not installed. Please install it or try a different editor." } func listVisiblePaths(limit: Int) async -> GitService.VisibleFilesResult? { guard let repo else { return nil } return await service.listVisibleFiles(in: repo, limit: limit) } private static func findExecutableInPath(_ name: String) -> String? { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/which") process.arguments = [name] let pipe = Pipe(); process.standardOutput = pipe; process.standardError = Pipe() do { try process.run(); process.waitUntilExit() guard process.terminationStatus == 0 else { return nil } let data = pipe.fileHandleForReading.readDataToEndOfFile() let path = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) return (path?.isEmpty == false) ? path : nil } catch { return nil } } // MARK: - Commit message generation (minimal pass) func generateCommitMessage(providerId: String? = nil, modelId: String? = nil, maxBytes: Int = 128 * 1024) { // Debounce: if already generating for the same repo, ignore if isGenerating, let current = repoRoot?.path, generatingRepoPath == current { #if DEBUG print("[AICommit] Debounced: generation already in progress for repo=\(current)") #endif Self.log.info("Debounced: generation already in progress for repo=\(current, privacy: .public)") return } generatingTask = Task { [weak self] in guard let self else { return } let shouldNotify = SessionPreferencesStore.isCommitMessageNotificationEnabled() let statusToken = StatusBarLogStore.shared.beginTask( "Generating commit message...", level: .info, source: "Git" ) var finalStatus: (message: String, level: StatusBarLogLevel)? defer { if let finalStatus { StatusBarLogStore.shared.endTask( statusToken, message: finalStatus.message, level: finalStatus.level, source: "Git" ) } else { StatusBarLogStore.shared.endTask(statusToken) } } guard let repo = self.repo else { if shouldNotify { await SystemNotifier.shared.notify( title: "AI Commit", body: "Cannot generate commit message: not a Git repository.", threadId: "ai-commit" ) } finalStatus = ("Not a Git repository", .error) return } let repoPath = repo.root.path await MainActor.run { self.isGenerating = true self.generatingRepoPath = repoPath } defer { Task { @MainActor in self.isGenerating = false self.generatingRepoPath = nil } } // Fetch staged diff (index vs HEAD) let full = await self.service.stagedUnifiedDiff(in: repo) if full.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if shouldNotify { await SystemNotifier.shared.notify( title: "AI Commit", body: "No staged changes to summarize.", threadId: "ai-commit" ) } #if DEBUG print("[AICommit] No staged changes; generation skipped") #endif Self.log.info("No staged changes; generation skipped") finalStatus = ("No staged changes to summarize", .warning) return } // Truncate by bytes for safety let truncated = Self.prefixBytes(of: full, maxBytes: maxBytes) let prompt = Self.commitPrompt(diff: truncated) let llm = LLMHTTPService() #if DEBUG print("[AICommit] Start generation providerId=\(providerId ?? "(auto)") bytes=\(truncated.utf8.count)") #endif Self.log.info("Start generation providerId=\(providerId ?? "(auto)", privacy: .public) bytes=\(truncated.utf8.count)") do { // Allow a slightly longer timeout for commit generation to reduce provider-specific timeouts var options = LLMHTTPService.Options() options.preferred = .auto options.model = modelId options.timeout = 45 options.providerId = providerId options.maxTokens = 800 options.systemPrompt = "Return only the commit message text. No labels, explanations, or extra commentary." let res = try await llm.generateText(prompt: prompt, options: options) let raw = res.text.trimmingCharacters(in: .whitespacesAndNewlines) let cleaned = Self.cleanCommitMessage(from: raw) let finalMessage = cleaned.isEmpty ? raw : cleaned await MainActor.run { guard self.repoRoot?.path == repoPath else { // Repo changed during generation; drop the result #if DEBUG print("[AICommit] Repo switched during generation; result discarded for repo=\(repoPath)") #endif return } if finalMessage.isEmpty { // Leave commit message unchanged; rely on system notification return } self.commitMessage = finalMessage } if finalMessage.isEmpty { #if DEBUG print("[AICommit] Empty response from provider=\(res.providerId), elapsedMs=\(res.elapsedMs)") #endif Self.log.warning("Empty commit message from provider=\(res.providerId, privacy: .public)") finalStatus = ("Empty commit message from provider", .warning) } else { let preview = finalMessage.prefix(120) #if DEBUG print("[AICommit] Success provider=\(res.providerId) elapsedMs=\(res.elapsedMs) msg=\(preview)") #endif Self.log.info("Success provider=\(res.providerId, privacy: .public) elapsedMs=\(res.elapsedMs) msg=\(String(preview), privacy: .public)") finalStatus = ("Commit message ready", .success) } if shouldNotify { await SystemNotifier.shared.notify( title: "AI Commit", body: finalMessage.isEmpty ? "Generation completed but returned an empty commit message." : "Generated commit message (\(res.providerId)) in \(res.elapsedMs)ms", threadId: "ai-commit" ) } } catch { #if DEBUG print("[AICommit] Error: \(error.localizedDescription)") #endif Self.log.error("Generation error: \(error.localizedDescription, privacy: .public)") if shouldNotify { await SystemNotifier.shared.notify( title: "AI Commit", body: "Generation failed: \(error.localizedDescription)", threadId: "ai-commit" ) } finalStatus = ("Generation failed: \(error.localizedDescription)", .error) } } } private static func prefixBytes(of s: String, maxBytes: Int) -> String { guard maxBytes > 0 else { return "" } let data = s.data(using: .utf8) ?? Data() if data.count <= maxBytes { return s } let slice = data.prefix(maxBytes) return String(data: slice, encoding: .utf8) ?? String(s.prefix(maxBytes / 2)) } private static func commitPrompt(diff: String) -> String { // Allow user override via Settings › Git Review template stored in preferences. // The template acts as a preamble; we always append the diff after it. let key = "git.review.commitPromptTemplate" let outputPrefix = "Output only the commit message. Do not add any extra text." let basePrompt: String if let tpl = UserDefaults.standard.string(forKey: key), !tpl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { basePrompt = tpl } else if let payload = Self.payloadCommitPrompt { basePrompt = payload } else { basePrompt = [ "Write a Conventional Commit in imperative mood.", "Include a concise subject line (type: scope? subject).", "Optionally add a brief body (2-4 lines) explaining motivation and key changes.", "Constraints: subject <= 80 chars; wrap body lines <= 72 chars; no trailing period in subject." ].joined(separator: "\n") } return [outputPrefix, "", basePrompt, "", "Diff:", diff].joined(separator: "\n") } private static let payloadCommitPrompt: String? = { let bundle = Bundle.main guard let url = bundle.url(forResource: "commit-message", withExtension: "md", subdirectory: "payload/prompts") else { return nil } guard let content = try? String(contentsOf: url, encoding: .utf8) else { return nil } let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed }() private static func cleanCommitMessage(from raw: String) -> String { var s = raw.trimmingCharacters(in: .whitespacesAndNewlines) // Remove surrounding code fences if any if s.hasPrefix("```") { if let range = s.range(of: "```", options: [], range: s.index(s.startIndex, offsetBy: 3).. // lanes that should show a vertical line in this row var joinLaneIndices: [Int] // additional lanes carrying the same commit id (branch joins) } @Published private(set) var laneInfoById: [String: LaneInfo] = [:] @Published private(set) var maxLaneCount: Int = 1 // Pagination & Incremental Layout State @Published var hasMoreCommits: Bool = true @Published var isLoadingMore: Bool = false private var skip: Int = 0 private let pageSize: Int = 25 // Reduced from 50 for faster initial render private var currentLanesState: [String?] = [] // Pre-computed row data for performance struct CommitRowData: Identifiable, Equatable { let id: String let commit: GitService.GraphCommit let index: Int let laneInfo: LaneInfo? let isFirst: Bool let isLast: Bool let isWorkingTree: Bool let isStriped: Bool static func == (lhs: CommitRowData, rhs: CommitRowData) -> Bool { lhs.id == rhs.id && lhs.index == rhs.index && lhs.laneInfo == rhs.laneInfo && lhs.isFirst == rhs.isFirst && lhs.isLast == rhs.isLast && lhs.isStriped == rhs.isStriped } } @Published private(set) var rowData: [CommitRowData] = [] private let service = GitService() private var repo: GitService.Repo? = nil private var laneLayoutTask: Task? = nil private var laneLayoutGeneration: Int = 0 private var refreshTask: Task? = nil private var detailTask: Task? = nil private var historyActionTask: Task? = nil // Branch scope controls @Published var showAllBranches: Bool = true @Published var showRemoteBranches: Bool = true // limit is replaced by pagination logic @Published var branches: [String] = [] @Published var selectedBranch: String? = nil // nil = current HEAD when showAllBranches == false @Published private(set) var workingChangesCount: Int = 0 @Published var branchSearchQuery: String = "" @Published var isLoadingBranches: Bool = false @Published private(set) var fullBranchList: [String] = [] // Cache full list private var branchesTask: Task? = nil // Detail panel state (files + per-file patch) @Published private(set) var detailFiles: [GitService.FileChange] = [] @Published var selectedDetailFile: String? = nil @Published private(set) var detailFilePatch: String = "" @Published private(set) var isLoadingDetail: Bool = false @Published private(set) var detailMessage: String = "" enum HistoryAction: String { case fetch, pull, push var displayName: String { switch self { case .fetch: return "Fetch" case .pull: return "Pull" case .push: return "Push" } } } @Published private(set) var historyActionInProgress: HistoryAction? = nil deinit { laneLayoutTask?.cancel() refreshTask?.cancel() detailTask?.cancel() historyActionTask?.cancel() branchesTask?.cancel() } func attach(to root: URL?) { guard let root else { commits = []; filteredCommits = []; return } if SecurityScopedBookmarks.shared.isSandboxed { _ = SecurityScopedBookmarks.shared.startAccessDynamic(for: root) } self.repo = GitService.Repo(root: root) // Don't load branches immediately - will load on-demand when picker is opened reload() } func triggerRefresh() { reload() } func reload() { guard let _ = self.repo else { return } refreshTask?.cancel() laneLayoutTask?.cancel() // Reset state skip = 0 hasMoreCommits = true currentLanesState = [] commits = [] filteredCommits = [] laneInfoById = [:] maxLaneCount = 1 rowData = [] laneLayoutGeneration &+= 1 refreshTask = Task { [weak self] in guard let self else { return } await loadPage(isInitial: true) } } func loadMore() { guard !isLoading, !isLoadingMore, hasMoreCommits, let _ = self.repo else { return } isLoadingMore = true refreshTask = Task { [weak self] in guard let self else { return } await loadPage(isInitial: false) } } private func loadPage(isInitial: Bool) async { guard let repo = self.repo else { return } await MainActor.run { if isInitial { isLoading = true } } let newCommits = await service.logGraphCommits( in: repo, limit: self.pageSize, skip: self.skip, includeAllBranches: self.showAllBranches, includeRemoteBranches: self.showRemoteBranches, singleRef: (self.showAllBranches ? nil : (self.selectedBranch?.isEmpty == false ? self.selectedBranch : nil)) ) // Working tree virtual entry (only on initial load) var finalList = newCommits if isInitial { let status = await service.status(in: repo) self.workingChangesCount = status.count if self.workingChangesCount > 0 { let headId = newCommits.first?.id let virtual = GitService.GraphCommit( id: "::working-tree::", shortId: "*", author: "*", date: "0 seconds ago", subject: "Uncommitted Changes (\(status.count))", parents: headId != nil ? [headId!] : [], decorations: [] ) finalList = [virtual] + newCommits } } await MainActor.run { if isInitial { self.commits = finalList self.isLoading = false if self.selectedCommit == nil { self.selectedCommit = finalList.first } } else { // Append new commits self.commits.append(contentsOf: newCommits) self.isLoadingMore = false } if newCommits.count < self.pageSize { self.hasMoreCommits = false } self.skip += newCommits.count // Update filtered list so rows appear immediately self.applyFilter() // Trigger incremental layout self.computeIncrementalLayout(newCommits: isInitial ? finalList : newCommits, isInitial: isInitial) } } func applyFilter() { let q = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard !q.isEmpty else { filteredCommits = commits buildRowData() return } // Note: Filtering currently operates only on loaded commits. // Ideally we would search the whole history, but for graph view, // we prioritized the loaded graph structure. let basic = commits.filter { c in if c.subject.lowercased().contains(q) { return true } if c.author.lowercased().contains(q) { return true } if c.shortId.lowercased().contains(q) { return true } if c.decorations.joined(separator: ",").lowercased().contains(q) { return true } return false } filteredCommits = basic buildRowData() // Optional: Trigger background grep if needed, but omitted here to keep graph stable } private func buildRowData() { let count = filteredCommits.count let newRowData = filteredCommits.enumerated().map { idx, commit in CommitRowData( id: commit.id, commit: commit, index: idx, laneInfo: laneInfoById[commit.id], isFirst: idx == 0, isLast: idx == count - 1, isWorkingTree: commit.id == "::working-tree::", isStriped: idx % 2 == 1 ) } // Only update if data actually changed to prevent unnecessary re-renders if newRowData != rowData { rowData = newRowData } } func selectCommit(_ c: GitService.GraphCommit) { selectedCommit = c loadDetail(for: c) } /// Load detail panel data (files list + first file patch) for the given commit. func loadDetail(for commit: GitService.GraphCommit) { // The synthetic working-tree node does not correspond to a real commit id. // For now, skip detail loading and leave the panel empty. if commit.id == "::working-tree::" { detailFiles = [] selectedDetailFile = nil detailFilePatch = "" isLoadingDetail = false return } guard let repo = self.repo else { detailFiles = [] selectedDetailFile = nil detailFilePatch = "" detailMessage = "" return } detailTask?.cancel() detailTask = Task { [weak self] in guard let self else { return } await MainActor.run { self.isLoadingDetail = true self.detailFiles = [] self.detailFilePatch = "" self.detailMessage = "" } async let filesTask = service.filesChanged(in: repo, commitId: commit.id) async let messageTask = service.commitMessage(in: repo, commitId: commit.id) let (files, message) = await (filesTask, messageTask) if Task.isCancelled { return } await MainActor.run { self.detailFiles = files self.selectedDetailFile = files.first?.path self.detailMessage = message } if let first = files.first { await loadDetailPatch(for: first.path, in: repo, commitId: commit.id) } else { await MainActor.run { self.detailFilePatch = "" self.isLoadingDetail = false } } } } func loadDetailPatch(for path: String) { guard let repo = self.repo, let commit = selectedCommit else { return } detailTask?.cancel() detailTask = Task { [weak self] in await self?.loadDetailPatch(for: path, in: repo, commitId: commit.id) } } private func loadDetailPatch(for path: String, in repo: GitService.Repo, commitId: String) async { await MainActor.run { self.isLoadingDetail = true self.detailFilePatch = "" } // Show diff of this file in the given commit against its first parent. let text = await service.filePatch(in: repo, commitId: commitId, path: path) if Task.isCancelled { return } await MainActor.run { self.detailFilePatch = text self.isLoadingDetail = false } } private struct LaneLayoutResult: Sendable { let byId: [String: LaneInfo] let maxLaneCount: Int let finalLanes: [String?] } // MARK: - Lanes private func computeIncrementalLayout(newCommits: [GitService.GraphCommit], isInitial: Bool) { let snapshot = newCommits let initialLanes = isInitial ? [] : currentLanesState let initialMax = isInitial ? 1 : maxLaneCount let generation = laneLayoutGeneration laneLayoutTask = Task.detached(priority: .userInitiated) { guard let result = Self.computeLaneLayout( for: snapshot, initialLanes: initialLanes, initialMaxLane: initialMax ) else { return } if Task.isCancelled { return } await MainActor.run { [weak self] in guard let self else { return } guard self.laneLayoutGeneration == generation else { return } if isInitial { self.laneInfoById = result.byId } else { self.laneInfoById.merge(result.byId) { (_, new) in new } } self.maxLaneCount = result.maxLaneCount self.currentLanesState = result.finalLanes self.buildRowData() } } } nonisolated private static func computeLaneLayout( for commits: [GitService.GraphCommit], initialLanes: [String?] = [], initialMaxLane: Int = 1 ) -> LaneLayoutResult? { guard !commits.isEmpty else { return LaneLayoutResult(byId: [:], maxLaneCount: initialMaxLane, finalLanes: initialLanes) } var lanes: [String?] = initialLanes var byId: [String: LaneInfo] = [:] var maxLanes = initialMaxLane var processed = 0 for commit in commits { if processed & 0x1F == 0, Task.isCancelled { return nil } processed &+= 1 let before = lanes // Determine current lane for this commit let laneIndex: Int if let idx = lanes.firstIndex(where: { $0 == commit.id }) { laneIndex = idx } else if let empty = lanes.firstIndex(where: { $0 == nil }) { laneIndex = empty if empty >= lanes.count { lanes.append(nil) } } else { laneIndex = lanes.count lanes.append(nil) } var parentLaneIndices: [Int] = [] if let firstParent = commit.parents.first { if laneIndex < lanes.count { lanes[laneIndex] = firstParent } else { lanes.append(firstParent) } parentLaneIndices.append(laneIndex) if commit.parents.count > 1 { for p in commit.parents.dropFirst() { if let existing = lanes.firstIndex(where: { $0 == p }) { parentLaneIndices.append(existing) } else if let empty = lanes.firstIndex(where: { $0 == nil }) { lanes[empty] = p parentLaneIndices.append(empty) } else { lanes.append(p) parentLaneIndices.append(lanes.count - 1) } } } } else { if laneIndex < lanes.count { lanes[laneIndex] = nil } } let joinLanes: [Int] = before.enumerated().compactMap { index, value in (value == commit.id && index != laneIndex) ? index : nil } if !joinLanes.isEmpty { for j in joinLanes where j < lanes.count { lanes[j] = nil } } while let last = lanes.last, last == nil { _ = lanes.popLast() } let after = lanes let activeCount = max(before.count, after.count) var continuing: Set = [] if activeCount > 0 { for i in 0.. GitReviewTreeSnapshot { let staged = changes.filter { $0.staged != nil } // Include all worktree entries (including MM) for unstaged tree let unstaged = changes.filter { $0.worktree != nil } return GitReviewTreeSnapshot( staged: buildTree(from: staged), unstaged: buildTree(from: unstaged) ) } static func buildTree(from changes: [GitService.Change]) -> [GitReviewNode] { struct BuilderNode { var children: [String: BuilderNode] = [:] var filePath: String? } var root = BuilderNode() for change in changes { let components = change.path.split(separator: "/").map(String.init) guard !components.isEmpty else { continue } func insert(_ index: Int, current: inout BuilderNode) { let key = components[index] if index == components.count - 1 { var child = current.children[key, default: BuilderNode()] child.filePath = change.path current.children[key] = child } else { var child = current.children[key, default: BuilderNode()] insert(index + 1, current: &child) current.children[key] = child } } insert(0, current: &root) } func convert(_ node: BuilderNode, prefix: String?) -> [GitReviewNode] { var output: [GitReviewNode] = [] for (name, child) in node.children { let fullPath = prefix.map { "\($0)/\(name)" } ?? name if let filePath = child.filePath, child.children.isEmpty { output.append(GitReviewNode(name: name, fullPath: filePath, dirPath: nil, children: nil)) } else { let childrenNodes = convert(child, prefix: fullPath) output.append( GitReviewNode( name: name, fullPath: nil, dirPath: fullPath, children: explorerSort(childrenNodes) ) ) } } return explorerSort(output) } return convert(root, prefix: nil) } static func explorerSort(_ nodes: [GitReviewNode]) -> [GitReviewNode] { func category(for node: GitReviewNode) -> Int { let isDot = node.name.hasPrefix(".") if node.isDirectory { return isDot ? 1 : 0 } else { return isDot ? 3 : 2 } } return nodes.sorted { let lhs = category(for: $0) let rhs = category(for: $1) if lhs != rhs { return lhs < rhs } return $0.name.localizedStandardCompare($1.name) == .orderedAscending } } } ================================================ FILE: models/GlobalSearchModels.swift ================================================ import Foundation struct GlobalSearchSnippet: Hashable, Sendable { let text: String let highlightRange: Range? init(text: String, highlightRange: Range? = nil) { self.text = text self.highlightRange = highlightRange } } enum GlobalSearchSnippetFactory { static func snippet( in text: String, matchRange: Range, radius: Int = 90 ) -> GlobalSearchSnippet { let start = text.index(matchRange.lowerBound, offsetBy: -radius, limitedBy: text.startIndex) ?? text.startIndex let end = text.index(matchRange.upperBound, offsetBy: radius, limitedBy: text.endIndex) ?? text.endIndex let snippetRange = start.. GlobalSearchProgress { GlobalSearchProgress( phase: .ripgrep, filesProcessed: files, matchesFound: matches, message: message, isFinished: finished, isCancelled: cancelled ) } } extension String { func rangeFromByteOffsets(start: Int, end: Int) -> Range? { guard start >= 0, end >= start else { return nil } guard let lowerUTF8 = utf8.index(utf8.startIndex, offsetBy: start, limitedBy: utf8.endIndex), let upperUTF8 = utf8.index(utf8.startIndex, offsetBy: end, limitedBy: utf8.endIndex), let lower = String.Index(lowerUTF8, within: self), let upper = String.Index(upperUTF8, within: self) else { return nil } return lower.. String { var result = "" result.reserveCapacity(count) var pendingSpace = false for character in self { if character.isWhitespace { pendingSpace = true continue } if pendingSpace, !result.isEmpty { result.append(" ") } pendingSpace = false result.append(character) } return result.trimmingCharacters(in: .whitespacesAndNewlines) } func sanitizedSnippetText() -> String { sanitizedSnippetText(preserving: nil).text } func sanitizedSnippetText(preserving range: Range?) -> (text: String, range: Range?) { let characters = Array(self) var mapping = Array(repeating: 0, count: characters.count + 1) var sanitizedIndex = 0 var idx = 0 var result = "" var pendingSpace = false var skipNextLiteral = false while idx < characters.count { mapping[idx] = sanitizedIndex let char = characters[idx] if skipNextLiteral { skipNextLiteral = false pendingSpace = true idx += 1 continue } if char.isWhitespace { pendingSpace = true idx += 1 continue } if char == "\\", idx + 1 < characters.count { let next = characters[idx + 1] if next == "n" || next == "r" || next == "t" { pendingSpace = true skipNextLiteral = true idx += 1 continue } } if pendingSpace && !result.isEmpty { result.append(" ") sanitizedIndex += 1 pendingSpace = false } result.append(char) sanitizedIndex += 1 idx += 1 } mapping[characters.count] = sanitizedIndex let sanitizedRange = range.flatMap { original -> Range? in guard original.lowerBound < mapping.count, original.upperBound < mapping.count else { return nil } let lower = mapping[original.lowerBound] let upper = mapping[original.upperBound] guard lower <= upper else { return nil } return lower..? private var debounceTask: Task? private var lastRequestSignature: String = "" private let debounceNanoseconds: UInt64 = 220_000_000 private let maxResults = 160 private let maxMatchesPerFile = 3 private let batchSize = 12 private var seenResultKeys: Set = [] private var queryVersion: UInt64 = 0 init( service: GlobalSearchService = GlobalSearchService(), preferences: SessionPreferencesStore, sessionListViewModel: SessionListViewModel? ) { self.service = service self.preferences = preferences self.sessionListViewModel = sessionListViewModel } deinit { searchTask?.cancel() Task { [service] in await service.cancelRipgrep() } debounceTask?.cancel() } func submit() { debounceTask?.cancel() let trimmed = trimmedQuery guard !trimmed.isEmpty else { return } restartSearch(term: trimmed) } func clearQuery() { query = "" errorMessage = nil cancelActiveSearchTasks() debounceTask?.cancel() results.removeAll() filteredResults.removeAll() lastRequestSignature = "" ripgrepProgress = nil isSearching = false seenResultKeys.removeAll() queryVersion &+= 1 } func setFocus(_ active: Bool) { // Defer state mutations to the next runloop to avoid "Publishing changes from within view updates" Task { @MainActor [weak self] in guard let self else { return } self.hasFocus = active if active { self.isPanelVisible = true } if !active, self.trimmedQuery.isEmpty { self.results.removeAll() self.filteredResults.removeAll() } } } func dismissPanel() { // Defer to avoid mutating during view updates Task { @MainActor [weak self] in guard let self else { return } self.hasFocus = false self.isPanelVisible = false } } func resetSearchState() { // Reset asynchronously to avoid view-update reentrancy Task { @MainActor [weak self] in guard let self else { return } self.query = "" self.filter = .all self.results.removeAll() self.filteredResults.removeAll() self.ripgrepProgress = nil self.errorMessage = nil self.isSearching = false self.seenResultKeys.removeAll() self.isPanelVisible = true self.hasFocus = true } } private var trimmedQuery: String { query.trimmingCharacters(in: .whitespacesAndNewlines) } private func handleQueryChange(oldValue: String) { debounceTask?.cancel() let trimmed = trimmedQuery guard !trimmed.isEmpty else { cancelActiveSearchTasks() results.removeAll() filteredResults.removeAll() errorMessage = nil lastRequestSignature = "" ripgrepProgress = nil isSearching = false return } let versionSnapshot = queryVersion debounceTask = Task { [weak self] in guard let self else { return } if self.queryVersion != versionSnapshot { return } if self.debounceNanoseconds > 0 { try? await Task.sleep(nanoseconds: self.debounceNanoseconds) } if self.queryVersion != versionSnapshot { return } if self.trimmedQuery != trimmed { return } self.restartSearch(term: trimmed) } } private func restartSearch(term: String) { cancelActiveSearchTasks() errorMessage = nil results.removeAll() filteredResults.removeAll() isSearching = true ripgrepProgress = nil seenResultKeys.removeAll() let request = makeRequest(term: term) let signature = makeSignature(term: term, scope: request.scope) lastRequestSignature = signature let service = self.service let requestSignature = signature searchTask = Task { [weak self] in guard let self else { return } await service.search( request: request, onBatch: { [weak self] hits in guard let self else { return } await self.handleBatch(hits, signature: requestSignature) }, onProgress: { [weak self] progress in guard let self else { return } await self.handleProgress(progress, signature: requestSignature) }, onCompletion: { [weak self] in guard let self else { return } await self.handleCompletion(signature: requestSignature) } ) } } func cancelBackgroundSearch() { cancelActiveSearchTasks() } private func cancelActiveSearchTasks() { searchTask?.cancel() searchTask = nil Task { [service] in await service.cancelRipgrep() } } @MainActor private func handleBatch(_ hits: [GlobalSearchHit], signature: String) { guard lastRequestSignature == signature else { return } let hydrated = hydrate(hits: hits) let deduped = hydrated.filter { hit in let key = dedupeKey(for: hit) if seenResultKeys.contains(key) { return false } seenResultKeys.insert(key) return true } guard !deduped.isEmpty else { return } results.append(contentsOf: deduped) sortResults() applyFilter() } @MainActor private func handleProgress(_ progress: GlobalSearchProgress, signature: String) { guard lastRequestSignature == signature else { return } ripgrepProgress = progress } @MainActor private func handleCompletion(signature: String) { if lastRequestSignature == signature { isSearching = false } searchTask = nil } private func dedupeKey(for result: GlobalSearchResult) -> String { var components: [String] = [result.kind.rawValue, result.fileURL.path] if let snippet = result.snippet?.text.lowercased(), !snippet.isEmpty { components.append("snippet:\(snippet)") } else if let noteId = result.note?.id { components.append("note:\(noteId)") } else if let projectId = result.project?.id { components.append("project:\(projectId)") } else if let sessionId = result.sessionSummary?.id { components.append("session:\(sessionId)") } else { components.append("raw:\(result.id)") } return components.joined(separator: "|") } private func hydrate(hits: [GlobalSearchHit]) -> [GlobalSearchResult] { guard !hits.isEmpty else { return [] } let sessionMap: [String: SessionSummary] if let store = sessionListViewModel { let snapshot = store.sessionsSnapshot() sessionMap = Dictionary(uniqueKeysWithValues: snapshot.map { ($0.fileURL.path, $0) }) } else { sessionMap = [:] } return hits.map { hit in var summary: SessionSummary? = nil if hit.kind == .session { summary = sessionMap[hit.fileURL.path] } else if hit.kind == .note, summary == nil, let noteId = hit.note?.id { summary = sessionListViewModel?.sessionSummary(withId: noteId) } return GlobalSearchResult(hit: hit, sessionSummary: summary) } } private func applyFilter() { if filter == .all { filteredResults = results } else { filteredResults = results.filter { $0.kind == filter.kind } } } private func sortResults() { results.sort { lhs, rhs in if lhs.score == rhs.score { return lhs.displayTitle.localizedCaseInsensitiveCompare(rhs.displayTitle) == .orderedAscending } return lhs.score > rhs.score } } private func makeRequest(term: String) -> GlobalSearchService.Request { let paths = resolvedPaths() let scope = filter.scope return GlobalSearchService.Request( term: term, scope: scope, paths: paths, maxMatchesPerFile: maxMatchesPerFile, batchSize: batchSize, limit: maxResults ) } private func resolvedPaths() -> GlobalSearchPaths { let current = preferences.sessionsRoot let home = SessionPreferencesStore.getRealUserHomeURL() let defaultRoot = SessionPreferencesStore.defaultSessionsRoot(for: home) var sessionRoots: [URL] = [] if preferences.isCLIEnabled(.codex) { sessionRoots.append(current) if defaultRoot != current { sessionRoots.append(defaultRoot) } } if preferences.isCLIEnabled(.claude), let claudeRoot = Self.defaultClaudeSessionsRoot(), FileManager.default.fileExists(atPath: claudeRoot.path) { if !sessionRoots.contains(claudeRoot) { sessionRoots.append(claudeRoot) } } if preferences.isCLIEnabled(.gemini), let geminiRoot = Self.defaultGeminiSessionsRoot(), FileManager.default.fileExists(atPath: geminiRoot.path) { if !sessionRoots.contains(geminiRoot) { sessionRoots.append(geminiRoot) } } return GlobalSearchPaths( sessionRoots: sessionRoots, notesRoot: preferences.notesRoot, projectsRoot: preferences.projectsRoot, tasksRoot: Self.defaultTasksRoot() ) } private func makeSignature(term: String, scope: GlobalSearchScope) -> String { "\(term.lowercased())|\(scope.rawValue)" } private static func defaultClaudeSessionsRoot() -> URL? { #if canImport(Darwin) if let pwDir = getpwuid(getuid())?.pointee.pw_dir { let path = String(cString: pwDir) return URL(fileURLWithPath: path, isDirectory: true) .appendingPathComponent(".claude", isDirectory: true) .appendingPathComponent("projects", isDirectory: true) } #endif if let home = ProcessInfo.processInfo.environment["HOME"] { return URL(fileURLWithPath: home, isDirectory: true) .appendingPathComponent(".claude", isDirectory: true) .appendingPathComponent("projects", isDirectory: true) } let fallback = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".claude", isDirectory: true) .appendingPathComponent("projects", isDirectory: true) return fallback } private static func defaultGeminiSessionsRoot() -> URL? { #if canImport(Darwin) if let pwDir = getpwuid(getuid())?.pointee.pw_dir { let path = String(cString: pwDir) return URL(fileURLWithPath: path, isDirectory: true) .appendingPathComponent(".gemini", isDirectory: true) .appendingPathComponent("tmp", isDirectory: true) } #endif if let home = ProcessInfo.processInfo.environment["HOME"] { return URL(fileURLWithPath: home, isDirectory: true) .appendingPathComponent(".gemini", isDirectory: true) .appendingPathComponent("tmp", isDirectory: true) } let fallback = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".gemini", isDirectory: true) .appendingPathComponent("tmp", isDirectory: true) return fallback } private static func defaultTasksRoot() -> URL? { #if canImport(Darwin) if let pwDir = getpwuid(getuid())?.pointee.pw_dir { let path = String(cString: pwDir) return URL(fileURLWithPath: path, isDirectory: true) .appendingPathComponent(".codmate", isDirectory: true) .appendingPathComponent("tasks", isDirectory: true) } #endif if let home = ProcessInfo.processInfo.environment["HOME"] { return URL(fileURLWithPath: home, isDirectory: true) .appendingPathComponent(".codmate", isDirectory: true) .appendingPathComponent("tasks", isDirectory: true) } let fallback = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".codmate", isDirectory: true) .appendingPathComponent("tasks", isDirectory: true) return fallback } } ================================================ FILE: models/HookCommandVariableCatalog.swift ================================================ import Foundation enum HookVariableKind: String, CaseIterable, Sendable, Codable { case env case stdin var displayName: String { switch self { case .env: return "Environment" case .stdin: return "Stdin JSON" } } var shortLabel: String { switch self { case .env: return "ENV" case .stdin: return "STDIN" } } } enum HookVariableProvider: String, CaseIterable, Sendable, Codable { case codex case claude case gemini var displayName: String { switch self { case .codex: return "Codex" case .claude: return "Claude Code" case .gemini: return "Gemini CLI" } } } struct HookVariableDescriptor: Identifiable, Hashable, Sendable { let name: String let kind: HookVariableKind let description: String let providers: Set let note: String? var id: String { "\(kind.rawValue):\(name)" } } enum HookCommandVariableCatalog { static let all: [HookVariableDescriptor] = { let bundled = loadBundledVariables() ?? fallbackVariables return merge(bundled) }() static func variables(kind: HookVariableKind) -> [HookVariableDescriptor] { all.filter { $0.kind == kind } } private static func merge(_ vars: [HookVariableDescriptor]) -> [HookVariableDescriptor] { var map: [String: HookVariableDescriptor] = [:] for variable in vars { if let existing = map[variable.id] { let providers = existing.providers.union(variable.providers) let description = existing.description.count >= variable.description.count ? existing.description : variable.description let note = mergeNotes(existing.note, variable.note) map[variable.id] = HookVariableDescriptor( name: existing.name, kind: existing.kind, description: description, providers: providers, note: note ) } else { map[variable.id] = variable } } return map.values.sorted { if $0.kind != $1.kind { return $0.kind.rawValue < $1.kind.rawValue } return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } private static func mergeNotes(_ a: String?, _ b: String?) -> String? { let left = a?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let right = b?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if left.isEmpty { return right.isEmpty ? nil : right } if right.isEmpty { return left } if left == right { return left } return "\(left) · \(right)" } private struct HookVariableFile: Codable { let variables: [HookVariableRecord] } private struct HookVariableRecord: Codable { let name: String let kind: HookVariableKind let description: String let providers: [HookVariableProvider] let note: String? func toDescriptor() -> HookVariableDescriptor { HookVariableDescriptor( name: name, kind: kind, description: description, providers: Set(providers), note: note ) } } private static func loadBundledVariables() -> [HookVariableDescriptor]? { let bundle = Bundle.main var urls: [URL] = [] if let url = bundle.url(forResource: "hook-variables", withExtension: "json") { urls.append(url) } if let url = bundle.url( forResource: "hook-variables", withExtension: "json", subdirectory: "payload" ) { urls.append(url) } for url in urls { guard let data = try? Data(contentsOf: url) else { continue } let decoder = JSONDecoder() if let file = try? decoder.decode(HookVariableFile.self, from: data) { return file.variables.map { $0.toDescriptor() } } if let list = try? decoder.decode([HookVariableRecord].self, from: data) { return list.map { $0.toDescriptor() } } } return nil } private static let fallbackVariables: [HookVariableDescriptor] = claudeVariables + geminiVariables private static let claudeVariables: [HookVariableDescriptor] = [ HookVariableDescriptor( name: "CLAUDE_PROJECT_DIR", kind: .env, description: "Project root directory", providers: [.claude], note: nil ), HookVariableDescriptor( name: "CLAUDE_ENV_FILE", kind: .env, description: "Path to environment file", providers: [.claude], note: "SessionStart/Setup" ), HookVariableDescriptor( name: "session_id", kind: .stdin, description: "Session identifier", providers: [.claude], note: nil ), HookVariableDescriptor( name: "transcript_path", kind: .stdin, description: "Transcript JSON path", providers: [.claude], note: nil ), HookVariableDescriptor( name: "cwd", kind: .stdin, description: "Current working directory", providers: [.claude], note: nil ), HookVariableDescriptor( name: "permission_mode", kind: .stdin, description: "Permission mode", providers: [.claude], note: nil ), HookVariableDescriptor( name: "hook_event_name", kind: .stdin, description: "Hook event name", providers: [.claude], note: nil ), HookVariableDescriptor( name: "tool_name", kind: .stdin, description: "Tool name", providers: [.claude], note: "PreToolUse/PermissionRequest/PostToolUse/PostToolUseFailure" ), HookVariableDescriptor( name: "tool_input", kind: .stdin, description: "Tool input JSON", providers: [.claude], note: "PreToolUse/PermissionRequest/PostToolUse/PostToolUseFailure" ), HookVariableDescriptor( name: "tool_use_id", kind: .stdin, description: "Tool use identifier", providers: [.claude], note: "PreToolUse/PermissionRequest/PostToolUse/PostToolUseFailure" ), HookVariableDescriptor( name: "tool_response", kind: .stdin, description: "Tool response JSON", providers: [.claude], note: "PostToolUse/PostToolUseFailure" ), HookVariableDescriptor( name: "message", kind: .stdin, description: "Notification message", providers: [.claude], note: "Notification" ), HookVariableDescriptor( name: "notification_type", kind: .stdin, description: "Notification type", providers: [.claude], note: "Notification" ), HookVariableDescriptor( name: "prompt", kind: .stdin, description: "User prompt", providers: [.claude], note: "UserPromptSubmit" ), HookVariableDescriptor( name: "stop_hook_active", kind: .stdin, description: "Stop hook state", providers: [.claude], note: "Stop/SubagentStop" ), HookVariableDescriptor( name: "agent_id", kind: .stdin, description: "Subagent identifier", providers: [.claude], note: "SubagentStart/SubagentStop" ), HookVariableDescriptor( name: "agent_transcript_path", kind: .stdin, description: "Subagent transcript JSON path", providers: [.claude], note: "SubagentStop" ), HookVariableDescriptor( name: "trigger", kind: .stdin, description: "Compaction trigger", providers: [.claude], note: "PreCompact/Setup" ), HookVariableDescriptor( name: "custom_instructions", kind: .stdin, description: "Custom instructions", providers: [.claude], note: "PreCompact" ), HookVariableDescriptor( name: "source", kind: .stdin, description: "Session start source", providers: [.claude], note: "SessionStart" ), HookVariableDescriptor( name: "model", kind: .stdin, description: "Model name", providers: [.claude], note: "SessionStart" ), HookVariableDescriptor( name: "agent_type", kind: .stdin, description: "Agent type", providers: [.claude], note: "SessionStart/SubagentStart" ), HookVariableDescriptor( name: "reason", kind: .stdin, description: "Session end reason", providers: [.claude], note: "SessionEnd" ), ] private static let geminiVariables: [HookVariableDescriptor] = [ HookVariableDescriptor( name: "GEMINI_PROJECT_DIR", kind: .env, description: "Project root directory", providers: [.gemini], note: nil ), HookVariableDescriptor( name: "GEMINI_SESSION_ID", kind: .env, description: "Session identifier", providers: [.gemini], note: nil ), HookVariableDescriptor( name: "GEMINI_CWD", kind: .env, description: "Current working directory", providers: [.gemini], note: nil ), HookVariableDescriptor( name: "CLAUDE_PROJECT_DIR", kind: .env, description: "Alias for GEMINI_PROJECT_DIR", providers: [.gemini], note: "Gemini alias" ), HookVariableDescriptor( name: "session_id", kind: .stdin, description: "Session identifier", providers: [.gemini], note: nil ), HookVariableDescriptor( name: "transcript_path", kind: .stdin, description: "Transcript JSON path", providers: [.gemini], note: nil ), HookVariableDescriptor( name: "cwd", kind: .stdin, description: "Current working directory", providers: [.gemini], note: nil ), HookVariableDescriptor( name: "hook_event_name", kind: .stdin, description: "Hook event name", providers: [.gemini], note: nil ), HookVariableDescriptor( name: "timestamp", kind: .stdin, description: "Event timestamp", providers: [.gemini], note: nil ), HookVariableDescriptor( name: "tool_name", kind: .stdin, description: "Tool name", providers: [.gemini], note: "BeforeTool/AfterTool" ), HookVariableDescriptor( name: "tool_input", kind: .stdin, description: "Tool input JSON", providers: [.gemini], note: "BeforeTool/AfterTool" ), HookVariableDescriptor( name: "tool_response", kind: .stdin, description: "Tool response JSON", providers: [.gemini], note: "AfterTool" ), HookVariableDescriptor( name: "mcp_context", kind: .stdin, description: "MCP context JSON", providers: [.gemini], note: "BeforeTool/AfterTool" ), HookVariableDescriptor( name: "prompt", kind: .stdin, description: "User prompt", providers: [.gemini], note: "BeforeAgent/AfterAgent" ), HookVariableDescriptor( name: "prompt_response", kind: .stdin, description: "Agent response", providers: [.gemini], note: "AfterAgent" ), HookVariableDescriptor( name: "stop_hook_active", kind: .stdin, description: "Stop hook state", providers: [.gemini], note: "AfterAgent" ), HookVariableDescriptor( name: "llm_request", kind: .stdin, description: "Model request JSON", providers: [.gemini], note: "BeforeModel/BeforeToolSelection/AfterModel" ), HookVariableDescriptor( name: "llm_response", kind: .stdin, description: "Model response JSON", providers: [.gemini], note: "AfterModel" ), HookVariableDescriptor( name: "source", kind: .stdin, description: "Session start source", providers: [.gemini], note: "SessionStart" ), HookVariableDescriptor( name: "reason", kind: .stdin, description: "Session end reason", providers: [.gemini], note: "SessionEnd" ), HookVariableDescriptor( name: "notification_type", kind: .stdin, description: "Notification type", providers: [.gemini], note: "Notification" ), HookVariableDescriptor( name: "message", kind: .stdin, description: "Notification message", providers: [.gemini], note: "Notification" ), HookVariableDescriptor( name: "details", kind: .stdin, description: "Notification details JSON", providers: [.gemini], note: "Notification" ), HookVariableDescriptor( name: "trigger", kind: .stdin, description: "Compression trigger", providers: [.gemini], note: "PreCompress" ), ] } ================================================ FILE: models/HookEventCatalog.swift ================================================ import Foundation struct HookEventMatcher: Identifiable, Hashable, Sendable { let value: String let description: String? let providers: Set? var id: String { value } } struct HookEventDescriptor: Identifiable, Hashable, Sendable { let name: String let description: String let providers: Set let aliases: [HookVariableProvider: String] let supportsMatcher: Bool let matchers: [HookEventMatcher] let note: String? var id: String { name } } struct HookEventProviderResolution: Sendable { let name: String let canonicalName: String let isKnown: Bool let isSupported: Bool } enum HookEventCatalog { static let all: [HookEventDescriptor] = { let bundled = loadBundledEvents() ?? fallbackEvents return merge(bundled) }() static var canonicalEvents: [String] { all.map(\.name) } static func descriptor(for eventName: String) -> HookEventDescriptor? { let trimmed = eventName.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } if let match = all.first(where: { $0.name.caseInsensitiveCompare(trimmed) == .orderedSame }) { return match } return all.first(where: { descriptor in descriptor.aliases.values.contains { $0.caseInsensitiveCompare(trimmed) == .orderedSame } }) } static func description(for eventName: String) -> String { guard let descriptor = descriptor(for: eventName) else { return "Custom event. Ensure the selected CLIs support this event name." } return descriptor.description } static func detailText(for eventName: String) -> String { guard let descriptor = descriptor(for: eventName) else { return "Custom event. Ensure the selected CLIs support this event name." } let parts = [descriptor.description, descriptor.note].compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } return parts.filter { !$0.isEmpty }.joined(separator: " ") } static func supportsMatcher(_ eventName: String) -> Bool { guard let descriptor = descriptor(for: eventName) else { return true } return descriptor.supportsMatcher } static func supportsMatcher(_ eventName: String, provider: HookVariableProvider) -> Bool { guard let descriptor = descriptor(for: eventName) else { return true } guard descriptor.supportsMatcher, descriptor.providers.contains(provider) else { return false } return matcherSupport(descriptor, provider: provider) } static func supportsMatcher(_ eventName: String, targets: HookTargets) -> Bool { let enabled = targets.enabledProviders() return enabled.contains { supportsMatcher(eventName, provider: $0) } } static func matchers(for eventName: String, targets: HookTargets? = nil) -> [HookEventMatcher] { guard let descriptor = descriptor(for: eventName) else { return [] } let base = descriptor.matchers guard let targets else { return base } let enabled = targets.enabledProviders() return base.filter { matcher in guard let providers = matcher.providers, !providers.isEmpty else { return true } return !providers.isDisjoint(with: enabled) } } static func matcherDescription(for eventName: String, matcher: String) -> String? { let trimmed = matcher.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } return matchers(for: eventName).first(where: { $0.value.caseInsensitiveCompare(trimmed) == .orderedSame })?.description } static func resolveProviderEvent(_ eventName: String, for provider: HookVariableProvider) -> HookEventProviderResolution { let trimmed = eventName.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return HookEventProviderResolution( name: trimmed, canonicalName: trimmed, isKnown: false, isSupported: false ) } if let descriptor = descriptor(for: trimmed) { let supported = descriptor.providers.contains(provider) let name = descriptor.aliases[provider] ?? descriptor.name return HookEventProviderResolution( name: name, canonicalName: descriptor.name, isKnown: true, isSupported: supported ) } return HookEventProviderResolution( name: trimmed, canonicalName: trimmed, isKnown: false, isSupported: true ) } static func canonicalName(for eventName: String, provider: HookVariableProvider) -> String { resolveProviderEvent(eventName, for: provider).canonicalName } static func defaultName(event: String, matcher: String?, command: HookCommand?) -> String { let e = event.trimmingCharacters(in: .whitespacesAndNewlines) let m = matcher?.trimmingCharacters(in: .whitespacesAndNewlines) let base = e.isEmpty ? "Hook" : e let cmd = command?.command.trimmingCharacters(in: .whitespacesAndNewlines) let cmdShort: String? = { guard let cmd, !cmd.isEmpty else { return nil } return URL(fileURLWithPath: cmd).lastPathComponent }() let parts = [base, (m?.isEmpty == false ? m : nil), cmdShort].compactMap { $0 } return parts.joined(separator: " · ") } private struct HookEventFile: Codable { let events: [HookEventRecord] } private struct HookEventRecord: Codable { let name: String let description: String let providers: [HookVariableProvider] let aliases: [String: String]? let supportsMatcher: Bool? let matchers: [HookEventMatcherRecord]? let note: String? func toDescriptor() -> HookEventDescriptor { let aliasMap: [HookVariableProvider: String] = (aliases ?? [:]).reduce(into: [:]) { out, pair in if let provider = HookVariableProvider(rawValue: pair.key) { out[provider] = pair.value } } let matcherList = (matchers ?? []).map { $0.toMatcher() } let matcherSupport = supportsMatcher ?? !matcherList.isEmpty return HookEventDescriptor( name: name, description: description, providers: Set(providers), aliases: aliasMap, supportsMatcher: matcherSupport, matchers: matcherList, note: note ) } } private struct HookEventMatcherRecord: Codable { let value: String let description: String? let providers: [HookVariableProvider]? func toMatcher() -> HookEventMatcher { HookEventMatcher( value: value, description: description, providers: providers.map(Set.init) ) } } private static func loadBundledEvents() -> [HookEventDescriptor]? { let bundle = Bundle.main var urls: [URL] = [] if let url = bundle.url(forResource: "hook-events", withExtension: "json") { urls.append(url) } if let url = bundle.url( forResource: "hook-events", withExtension: "json", subdirectory: "payload" ) { urls.append(url) } for url in urls { guard let data = try? Data(contentsOf: url) else { continue } let decoder = JSONDecoder() if let file = try? decoder.decode(HookEventFile.self, from: data) { return file.events.map { $0.toDescriptor() } } if let list = try? decoder.decode([HookEventRecord].self, from: data) { return list.map { $0.toDescriptor() } } } return nil } private static func merge(_ events: [HookEventDescriptor]) -> [HookEventDescriptor] { var map: [String: HookEventDescriptor] = [:] var order: [String] = [] for event in events { let key = event.name.lowercased() if map[key] == nil { order.append(key) } if let existing = map[key] { let providers = existing.providers.union(event.providers) let description = existing.description.count >= event.description.count ? existing.description : event.description var aliases = existing.aliases for (provider, alias) in event.aliases where aliases[provider] == nil { aliases[provider] = alias } let supportsMatcher = existing.supportsMatcher || event.supportsMatcher let matchers = mergeMatchers(existing.matchers, event.matchers) let note = mergeNotes(existing.note, event.note) map[key] = HookEventDescriptor( name: existing.name, description: description, providers: providers, aliases: aliases, supportsMatcher: supportsMatcher, matchers: matchers, note: note ) } else { map[key] = event } } return order.compactMap { map[$0] } } private static func mergeMatchers(_ lhs: [HookEventMatcher], _ rhs: [HookEventMatcher]) -> [HookEventMatcher] { var map: [String: HookEventMatcher] = [:] for matcher in lhs + rhs { let key = matcher.value if let existing = map[key] { let providers = mergeProviderSets(existing.providers, matcher.providers) let description = existing.description ?? matcher.description map[key] = HookEventMatcher(value: key, description: description, providers: providers) } else { map[key] = matcher } } return map.values.sorted { $0.value.localizedCaseInsensitiveCompare($1.value) == .orderedAscending } } private static func matcherSupport(_ descriptor: HookEventDescriptor, provider: HookVariableProvider) -> Bool { let matchers = descriptor.matchers guard !matchers.isEmpty else { return true } return matchers.contains { matcher in guard let providers = matcher.providers, !providers.isEmpty else { return true } return providers.contains(provider) } } private static func mergeProviderSets( _ lhs: Set?, _ rhs: Set? ) -> Set? { if lhs == nil { return rhs } if rhs == nil { return lhs } return lhs!.union(rhs!) } private static func mergeNotes(_ a: String?, _ b: String?) -> String? { let left = a?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let right = b?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if left.isEmpty { return right.isEmpty ? nil : right } if right.isEmpty { return left } if left == right { return left } return "\(left) · \(right)" } private static let fallbackEvents: [HookEventDescriptor] = [ HookEventDescriptor( name: "Setup", description: "Load context and configure the environment during repository initialization or maintenance.", providers: [.claude], aliases: [:], supportsMatcher: false, matchers: [], note: nil ), HookEventDescriptor( name: "SessionStart", description: "Runs when a session starts.", providers: [.claude, .gemini], aliases: [:], supportsMatcher: true, matchers: [ HookEventMatcher(value: "startup", description: "Session starts fresh.", providers: [.gemini]), HookEventMatcher(value: "resume", description: "Session resumes from history.", providers: [.gemini]), HookEventMatcher(value: "clear", description: "Session is cleared and restarted.", providers: [.gemini]) ], note: nil ), HookEventDescriptor( name: "UserPromptSubmit", description: "Runs when the user submits a prompt.", providers: [.claude, .gemini], aliases: [.gemini: "BeforeAgent"], supportsMatcher: true, matchers: [ HookEventMatcher(value: "*", description: "Wildcard matcher.", providers: [.gemini]) ], note: nil ), HookEventDescriptor( name: "PreToolUse", description: "Runs before a tool is called.", providers: [.claude, .gemini], aliases: [.gemini: "BeforeTool"], supportsMatcher: true, matchers: [ HookEventMatcher(value: "Bash", description: "Tool name.", providers: [.claude]), HookEventMatcher(value: "Write", description: "Tool name.", providers: [.claude]), HookEventMatcher(value: "Edit", description: "Tool name.", providers: [.claude]), HookEventMatcher(value: "Read", description: "Tool name.", providers: [.claude]), HookEventMatcher(value: "Write|Edit", description: "Regex example.", providers: [.claude]), HookEventMatcher(value: "Notebook.*", description: "Regex example.", providers: [.claude]), HookEventMatcher(value: "*", description: "Wildcard matcher.", providers: [.gemini]), HookEventMatcher(value: "write_.*", description: "Regex example.", providers: [.gemini]) ], note: nil ), HookEventDescriptor( name: "PermissionRequest", description: "Runs when a tool permission is requested.", providers: [.claude], aliases: [:], supportsMatcher: true, matchers: [ HookEventMatcher(value: "Bash", description: "Tool name.", providers: [.claude]), HookEventMatcher(value: "Write", description: "Tool name.", providers: [.claude]), HookEventMatcher(value: "Edit", description: "Tool name.", providers: [.claude]), HookEventMatcher(value: "Read", description: "Tool name.", providers: [.claude]), HookEventMatcher(value: "Write|Edit", description: "Regex example.", providers: [.claude]), HookEventMatcher(value: "Notebook.*", description: "Regex example.", providers: [.claude]) ], note: nil ), HookEventDescriptor( name: "PostToolUse", description: "Runs after a tool call succeeds.", providers: [.claude, .gemini], aliases: [.gemini: "AfterTool"], supportsMatcher: true, matchers: [ HookEventMatcher(value: "Bash", description: "Tool name.", providers: [.claude]), HookEventMatcher(value: "Write", description: "Tool name.", providers: [.claude]), HookEventMatcher(value: "Edit", description: "Tool name.", providers: [.claude]), HookEventMatcher(value: "Read", description: "Tool name.", providers: [.claude]), HookEventMatcher(value: "Write|Edit", description: "Regex example.", providers: [.claude]), HookEventMatcher(value: "Notebook.*", description: "Regex example.", providers: [.claude]), HookEventMatcher(value: "*", description: "Wildcard matcher.", providers: [.gemini]), HookEventMatcher(value: "write_.*", description: "Regex example.", providers: [.gemini]) ], note: nil ), HookEventDescriptor( name: "PostToolUseFailure", description: "Runs after a tool call fails.", providers: [.claude], aliases: [:], supportsMatcher: false, matchers: [], note: nil ), HookEventDescriptor( name: "SubagentStart", description: "Runs when a subagent (Task tool call) starts.", providers: [.claude], aliases: [:], supportsMatcher: false, matchers: [], note: nil ), HookEventDescriptor( name: "SubagentStop", description: "Runs when a subagent (Task tool call) finishes.", providers: [.claude], aliases: [:], supportsMatcher: false, matchers: [], note: "Prompt-based hooks are supported for this event." ), HookEventDescriptor( name: "Stop", description: "Runs when the assistant finishes responding.", providers: [.claude, .gemini, .codex], aliases: [.gemini: "AfterAgent"], supportsMatcher: true, matchers: [ HookEventMatcher(value: "*", description: "Wildcard matcher.", providers: [.gemini]) ], note: "Prompt-based hooks are supported for this event." ), HookEventDescriptor( name: "PreCompact", description: "Runs before context compaction.", providers: [.claude, .gemini], aliases: [.gemini: "PreCompress"], supportsMatcher: true, matchers: [ HookEventMatcher(value: "*", description: "Wildcard matcher.", providers: [.gemini]) ], note: nil ), HookEventDescriptor( name: "SessionEnd", description: "Runs when a session ends.", providers: [.claude, .gemini], aliases: [:], supportsMatcher: true, matchers: [ HookEventMatcher(value: "exit", description: "Session exits.", providers: [.gemini]), HookEventMatcher(value: "clear", description: "Session is cleared.", providers: [.gemini]) ], note: nil ), HookEventDescriptor( name: "Notification", description: "Runs when the CLI raises a notification.", providers: [.claude, .gemini], aliases: [:], supportsMatcher: true, matchers: [ HookEventMatcher(value: "*", description: "Wildcard matcher.", providers: [.gemini]) ], note: nil ), HookEventDescriptor( name: "BeforeModel", description: "Runs before a request is sent to the model.", providers: [.gemini], aliases: [:], supportsMatcher: true, matchers: [], note: nil ), HookEventDescriptor( name: "AfterModel", description: "Runs after the model responds, before tool selection.", providers: [.gemini], aliases: [:], supportsMatcher: true, matchers: [], note: nil ), HookEventDescriptor( name: "BeforeToolSelection", description: "Runs before tool selection.", providers: [.gemini], aliases: [:], supportsMatcher: true, matchers: [], note: nil ), ] } private extension HookTargets { func enabledProviders() -> Set { var providers: Set = [] if codex { providers.insert(.codex) } if claude { providers.insert(.claude) } if gemini { providers.insert(.gemini) } return providers } } ================================================ FILE: models/HookSyncWarning.swift ================================================ import Foundation struct HookSyncWarning: Identifiable, Equatable { let id = UUID() let provider: HookTarget let message: String } ================================================ FILE: models/Hooks.swift ================================================ import Foundation enum HookTarget: String, Codable, CaseIterable, Sendable { case codex case claude case gemini var displayName: String { switch self { case .codex: return "Codex" case .claude: return "Claude" case .gemini: return "Gemini" } } var usageProvider: UsageProviderKind { switch self { case .codex: return .codex case .claude: return .claude case .gemini: return .gemini } } var baseKind: SessionSource.Kind { usageProvider.baseKind } } struct HookTargets: Codable, Equatable, Hashable, Sendable { var codex: Bool var claude: Bool var gemini: Bool init(codex: Bool = true, claude: Bool = true, gemini: Bool = true) { self.codex = codex self.claude = claude self.gemini = gemini } func isEnabled(for target: HookTarget) -> Bool { switch target { case .codex: return codex case .claude: return claude case .gemini: return gemini } } mutating func setEnabled(_ value: Bool, for target: HookTarget) { switch target { case .codex: codex = value case .claude: claude = value case .gemini: gemini = value } } var allEnabled: Bool { codex && claude && gemini } } struct HookCommand: Codable, Equatable, Hashable, Sendable { var command: String var args: [String]? var env: [String: String]? var timeoutMs: Int? private enum CodingKeys: String, CodingKey { case command case args case env case timeoutMs } private struct KeyValuePair: Codable, Hashable { var key: String var value: String } init(command: String, args: [String]? = nil, env: [String: String]? = nil, timeoutMs: Int? = nil) { self.command = command self.args = args self.env = env self.timeoutMs = timeoutMs } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) command = try container.decode(String.self, forKey: .command) args = try container.decodeIfPresent([String].self, forKey: .args) timeoutMs = try container.decodeIfPresent(Int.self, forKey: .timeoutMs) if let dict = try? container.decodeIfPresent([String: String].self, forKey: .env) { env = dict } else if let pairs = try? container.decodeIfPresent([KeyValuePair].self, forKey: .env) { env = Dictionary(uniqueKeysWithValues: pairs.map { ($0.key, $0.value) }) } else { env = nil } } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(command, forKey: .command) try container.encodeIfPresent(args, forKey: .args) try container.encodeIfPresent(env, forKey: .env) try container.encodeIfPresent(timeoutMs, forKey: .timeoutMs) } } struct HookRule: Codable, Identifiable, Equatable, Hashable, Sendable { var id: String var name: String var description: String? var event: String var matcher: String? var commands: [HookCommand] var enabled: Bool /// nil means enabled for all targets (default). var targets: HookTargets? var source: String var createdAt: Date var updatedAt: Date init( id: String = UUID().uuidString, name: String, description: String? = nil, event: String, matcher: String? = nil, commands: [HookCommand], enabled: Bool = true, targets: HookTargets? = nil, source: String = "user", createdAt: Date = Date(), updatedAt: Date = Date() ) { self.id = id self.name = name self.description = description self.event = event self.matcher = matcher self.commands = commands self.enabled = enabled self.targets = targets self.source = source self.createdAt = createdAt self.updatedAt = updatedAt } func isEnabled(for target: HookTarget) -> Bool { guard enabled else { return false } return (targets?.isEnabled(for: target) ?? true) } } ================================================ FILE: models/HooksViewModel.swift ================================================ import Foundation @MainActor final class HooksViewModel: ObservableObject { @Published var rules: [HookRule] = [] @Published var selectedRuleId: String? = nil @Published var searchText: String = "" @Published var showAddSheet = false @Published var editingRule: HookRule? = nil @Published var syncWarnings: [HookSyncWarning] = [] @Published var errorMessage: String? = nil @Published var isLoading = false @Published var showImportSheet = false @Published var importCandidates: [HookImportCandidate] = [] @Published var isImporting = false @Published var importStatusMessage: String? = nil private let store = HooksStore() private let syncService = HooksSyncService() var selectedRule: HookRule? { guard let id = selectedRuleId else { return nil } return rules.first(where: { $0.id == id }) } var filteredRules: [HookRule] { let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) if query.isEmpty { return rules } return rules.filter { rule in rule.name.localizedCaseInsensitiveContains(query) || rule.event.localizedCaseInsensitiveContains(query) || (rule.matcher?.localizedCaseInsensitiveContains(query) ?? false) || rule.commands.contains(where: { $0.command.localizedCaseInsensitiveContains(query) }) } } func load() async { isLoading = true defer { isLoading = false } rules = await store.list() } // MARK: - CRUD func addRule(_ rule: HookRule) async { do { try await store.upsert(rule) await load() selectedRuleId = rule.id await applyToProviders() } catch { errorMessage = "Failed to save hook" } } func updateRule(_ rule: HookRule) async { do { try await store.upsert(rule) await load() await applyToProviders() } catch { errorMessage = "Failed to save hook" } } func deleteRule(id: String) async { do { try await store.delete(id: id) if selectedRuleId == id { selectedRuleId = nil } await load() await applyToProviders() } catch { errorMessage = "Failed to delete hook" } } func updateRuleEnabled(id: String, value: Bool) { updateLocalRule(id: id) { $0.enabled = value } Task { do { try await store.update(id: id) { rule in rule.enabled = value rule.updatedAt = Date() } await applyToProviders() } catch { errorMessage = "Failed to update hook" } } } func updateRuleTarget(id: String, target: HookTarget, value: Bool) { updateLocalRule(id: id) { rule in var targets = rule.targets ?? HookTargets() targets.setEnabled(value, for: target) rule.targets = targets.allEnabled ? nil : targets } Task { do { try await store.update(id: id) { rule in var targets = rule.targets ?? HookTargets() targets.setEnabled(value, for: target) rule.targets = targets.allEnabled ? nil : targets rule.updatedAt = Date() } await applyToProviders() } catch { errorMessage = "Failed to update hook" } } } private func updateLocalRule(id: String, mutate: (inout HookRule) -> Void) { guard let idx = rules.firstIndex(where: { $0.id == id }) else { return } mutate(&rules[idx]) } // MARK: - Import func beginImportFromHome() { showImportSheet = true Task { await loadImportCandidatesFromHome() } } func loadImportCandidatesFromHome() async { isImporting = true importStatusMessage = "Scanning…" if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() AuthorizationHub.shared.ensureDirectoryAccessOrPrompt( directory: home, purpose: .generalAccess, message: "Authorize your Home folder to import hooks" ) } let existing = await store.list() let existingSignatures = Set(existing.map { HooksImportService.hookSignature($0) }) let scanned = await Task.detached(priority: .userInitiated) { await HooksImportService.scan(scope: .home) }.value var candidates = scanned for idx in candidates.indices { let signature = candidates[idx].signature candidates[idx].hasConflict = existingSignatures.contains(signature) candidates[idx].resolution = candidates[idx].hasConflict ? .skip : .overwrite candidates[idx].renameName = candidates[idx].rule.name } await MainActor.run { self.importCandidates = candidates self.isImporting = false self.importStatusMessage = candidates.isEmpty ? "No hooks found." : nil } } func cancelImport() { showImportSheet = false importCandidates = [] importStatusMessage = nil } func importSelectedHooks() async { let selected = importCandidates.filter { $0.isSelected } guard !selected.isEmpty else { importStatusMessage = "No hooks selected." return } let existing = await store.list() let existingBySignature = Dictionary(grouping: existing, by: { HooksImportService.hookSignature($0) }) var importedCount = 0 var importedCandidateIds: Set = [] for item in selected { switch item.resolution { case .skip: continue case .overwrite: if let existingRule = existingBySignature[item.signature]?.first { var updated = item.rule updated.id = existingRule.id updated.createdAt = existingRule.createdAt updated.updatedAt = Date() do { try await store.upsert(updated) } catch { continue } } else { var fresh = item.rule fresh.id = UUID().uuidString fresh.createdAt = Date() fresh.updatedAt = Date() do { try await store.upsert(fresh) } catch { continue } } importedCount += 1 importedCandidateIds.insert(item.id) case .rename: let newName = item.renameName.trimmingCharacters(in: .whitespacesAndNewlines) guard !newName.isEmpty else { continue } var fresh = item.rule fresh.id = UUID().uuidString fresh.name = newName fresh.createdAt = Date() fresh.updatedAt = Date() do { try await store.upsert(fresh) } catch { continue } importedCount += 1 importedCandidateIds.insert(item.id) } } await load() await applyToProviders() importStatusMessage = "Imported \(importedCount) hook(s)." if !importedCandidateIds.isEmpty { importCandidates.removeAll { importedCandidateIds.contains($0.id) } } if importCandidates.isEmpty { closeImportSheetAfterDelay() } } private func closeImportSheetAfterDelay(_ delay: TimeInterval = 0.6) { Task { @MainActor in try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) self.showImportSheet = false self.importStatusMessage = nil } } // MARK: - Apply func applyToProviders() async { if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() AuthorizationHub.shared.ensureDirectoryAccessOrPrompt( directory: home, purpose: .generalAccess, message: "Authorize your Home folder to apply hooks" ) } let warnings = await syncService.syncGlobal(rules: rules) syncWarnings = warnings if !warnings.isEmpty { errorMessage = "Applied with \(warnings.count) warning(s)" } else { errorMessage = nil } } } ================================================ FILE: models/InternalSkill.swift ================================================ import Foundation enum InternalSkillIOMode: String, Codable, Sendable { case stdin case file } enum InternalSkillOutputMode: String, Codable, Sendable { case stdout case file } struct InternalSkillInvocation: Codable, Hashable, Sendable { var provider: SessionSource.Kind var executable: String? var args: [String] var inputMode: InternalSkillIOMode var outputMode: InternalSkillOutputMode var timeoutSeconds: Double? } struct InternalSkillAssetPaths: Codable, Hashable, Sendable { var skill: String? var prompt: String? var schema: String? var docs: String? } struct InternalSkillDefinition: Codable, Identifiable, Hashable, Sendable { var id: String var feature: WizardFeature var title: String var description: String? var version: String? var assets: InternalSkillAssetPaths? var invocations: [InternalSkillInvocation] var docsSources: [WizardDocSource]? var displayTitle: String { title.isEmpty ? id : title } } struct InternalSkillsIndex: Codable, Hashable, Sendable { var skills: [InternalSkillDefinition] } struct InternalSkillAsset: Hashable, Sendable { var definition: InternalSkillDefinition var rootURL: URL var skillMarkdown: String? var prompt: String? var schema: String? var docsOverrides: [WizardDocSource] } struct WizardDocSource: Codable, Hashable, Sendable { var feature: WizardFeature var provider: String? var url: String var maxChars: Int? var cacheTTLHours: Int? } struct WizardDocSnippet: Codable, Hashable, Sendable { var url: String var provider: String? var text: String } ================================================ FILE: models/LocalAuthProvider.swift ================================================ import Foundation enum LocalAuthProvider: String, CaseIterable, Identifiable { case codex case claude case gemini case antigravity case qwen var id: String { rawValue } var displayName: String { switch self { case .codex: return "Codex" case .claude: return "Claude" case .gemini: return "Gemini" case .antigravity: return "Antigravity" case .qwen: return "Qwen Code" } } var loginFlag: String { switch self { case .gemini: return "--login" case .codex: return "--codex-login" case .claude: return "--claude-login" case .antigravity: return "--antigravity-login" case .qwen: return "--qwen-login" } } var authAliases: [String] { switch self { case .codex: return ["codex", "openai"] case .claude: return ["claude", "anthropic"] case .gemini: return ["gemini"] case .antigravity: return ["antigravity"] case .qwen: return ["qwen", "qwen-code", "qwen_code"] } } } ================================================ FILE: models/MCPServer.swift ================================================ import Foundation // MARK: - MCP Server Models public enum MCPServerKind: String, Codable, Sendable { case stdio, sse, streamable_http } public struct MCPCapability: Codable, Identifiable, Hashable, Sendable { public var id: String { name } public var name: String public var enabled: Bool } public struct MCPServerMeta: Codable, Equatable, Sendable { public var description: String? public var version: String? public var websiteUrl: String? public var repositoryURL: String? } public enum MCPServerTarget: String, Codable, CaseIterable, Sendable { case codex case claude case gemini var baseKind: SessionSource.Kind { switch self { case .codex: return .codex case .claude: return .claude case .gemini: return .gemini } } } public struct MCPServerTargets: Codable, Equatable, Hashable, Sendable { public var codex: Bool public var claude: Bool public var gemini: Bool public init(codex: Bool = true, claude: Bool = true, gemini: Bool = true) { self.codex = codex self.claude = claude self.gemini = gemini } public func isEnabled(for target: MCPServerTarget) -> Bool { switch target { case .codex: return codex case .claude: return claude case .gemini: return gemini } } public mutating func setEnabled(_ value: Bool, for target: MCPServerTarget) { switch target { case .codex: codex = value case .claude: claude = value case .gemini: gemini = value } } } public struct MCPServer: Codable, Identifiable, Equatable, Sendable { public var id: String { name } public var name: String public var kind: MCPServerKind // stdio public var command: String? public var args: [String]? public var env: [String: String]? // network public var url: String? public var headers: [String: String]? // meta public var meta: MCPServerMeta? // dynamic public var enabled: Bool public var capabilities: [MCPCapability] public var targets: MCPServerTargets? public init( name: String, kind: MCPServerKind, command: String? = nil, args: [String]? = nil, env: [String: String]? = nil, url: String? = nil, headers: [String: String]? = nil, meta: MCPServerMeta? = nil, enabled: Bool = true, capabilities: [MCPCapability] = [], targets: MCPServerTargets? = nil ) { self.name = name self.kind = kind self.command = command self.args = args self.env = env self.url = url self.headers = headers self.meta = meta self.enabled = enabled self.capabilities = capabilities self.targets = targets } public func isEnabled(for target: MCPServerTarget) -> Bool { guard enabled else { return false } return (targets?.isEnabled(for: target) ?? true) } public func withTargets(_ update: (inout MCPServerTargets) -> Void) -> MCPServer { var copy = self var current = copy.targets ?? MCPServerTargets() update(¤t) copy.targets = current return copy } } // A lightweight draft parsed from import payloads before persistence public struct MCPServerDraft: Codable, Sendable { public var name: String? public var kind: MCPServerKind public var command: String? public var args: [String]? public var env: [String: String]? public var url: String? public var headers: [String: String]? public var meta: MCPServerMeta? } public extension Array where Element == MCPServer { func enabledServers(for target: MCPServerTarget) -> [MCPServer] { filter { $0.isEnabled(for: target) } } } ================================================ FILE: models/MCPServersViewModel.swift ================================================ import Foundation import SwiftUI @MainActor final class MCPServersViewModel: ObservableObject { enum Tab: Hashable { case importWizard, servers, advanced } // UI state @Published var activeTab: Tab = .importWizard @Published var importText: String = "" @Published var importError: String? = nil @Published var isParsing: Bool = false @Published var drafts: [MCPServerDraft] = [] @Published var servers: [MCPServer] = [] @Published var selectedServerName: String? = nil @Published var errorMessage: String? = nil @Published var testInProgress: Bool = false @Published var testMessage: String? = nil private var testTask: Task? = nil @Published var showImportSheet: Bool = false @Published var importCandidates: [MCPImportCandidate] = [] @Published var isImporting: Bool = false @Published var importStatusMessage: String? = nil // Editor/Form state @Published var isEditingExisting: Bool = false @Published var originalName: String? = nil @Published var formName: String = "" @Published var formKind: MCPServerKind = .stdio @Published var formURL: String = "" @Published var formCommand: String = "" @Published var formArgs: String = "" // space-separated @Published var formArgsJSONText: String = "[]" // JSON array @Published var formArgsUseJSON: Bool = false @Published var formEnvText: String = "" // key=value per line @Published var formEnvJSONText: String = "{}" // JSON object @Published var formEnvUseJSON: Bool = false @Published var formHeadersText: String = "" // key=value per line @Published var formHeadersJSONText: String = "{}" // JSON object @Published var formHeadersUseJSON: Bool = false @Published var formEnabled: Bool = true @Published var formTargetsCodex: Bool = true @Published var formTargetsClaude: Bool = true @Published var formTargetsGemini: Bool = true private let store = MCPServersStore() private let tester = MCPQuickTestService() func loadText(_ text: String) { importText = text parseImportText() } func clearImport() { importText = "" drafts = [] importError = nil isParsing = false } func loadServers() async { if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() let codmate = home.appendingPathComponent(".codmate", isDirectory: true) AuthorizationHub.shared.ensureDirectoryAccessOrPrompt(directory: codmate, purpose: .generalAccess, message: "Authorize ~/.codmate to read MCP servers") } let list = await store.list() self.servers = list // Auto-select first server if none selected (matching Providers behavior) if let currentName = selectedServerName, !list.contains(where: { $0.name == currentName }) { selectedServerName = list.first?.name } else if selectedServerName == nil { selectedServerName = list.first?.name } } // MARK: - Import (Home) func beginImportFromHome() { showImportSheet = true Task { await loadImportCandidatesFromHome() } } func loadImportCandidatesFromHome() async { isImporting = true importStatusMessage = "Scanning…" if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() AuthorizationHub.shared.ensureDirectoryAccessOrPrompt( directory: home, purpose: .generalAccess, message: "Authorize your Home folder to import MCP servers" ) } let existing = await store.list() let existingNames = Set(existing.map(\.name)) let managedSignatures = Set(existing.map { MCPImportService.signature(for: $0) }) let scanned = await Task.detached(priority: .userInitiated) { MCPImportService.scan(scope: .home) }.value // CodMate store is the source of truth; provider configs can drift if edited by other tools. let filtered = MCPImportService.filterManagedCandidates(scanned, managedSignatures: managedSignatures) let candidates = filtered.map { item -> MCPImportCandidate in var updated = item if existingNames.contains(item.name) { updated.hasConflict = true updated.isSelected = false updated.resolution = .skip updated.renameName = item.name } return updated } if candidates.isEmpty { importStatusMessage = "No MCP servers found." } else { importStatusMessage = nil } importCandidates = candidates isImporting = false } func cancelImport() { showImportSheet = false importCandidates = [] importStatusMessage = nil } func importSelectedServers() async { let selected = importCandidates.filter { $0.isSelected } guard !selected.isEmpty else { importStatusMessage = "No servers selected." return } let resolvedNames = selected.compactMap { item -> String? in let resolution = item.resolution switch resolution { case .skip: return nil case .overwrite: return item.name case .rename: let trimmed = item.renameName.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } } let duplicates = Dictionary(grouping: resolvedNames, by: { $0 }).filter { $1.count > 1 }.keys if !duplicates.isEmpty { importStatusMessage = "Resolve duplicate names before importing." return } if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() let codmate = home.appendingPathComponent(".codmate", isDirectory: true) let codex = home.appendingPathComponent(".codex", isDirectory: true) _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codmate, purpose: .generalAccess, message: "Authorize ~/.codmate to save MCP servers") _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codex, purpose: .generalAccess, message: "Authorize ~/.codex to update Codex config") _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess, message: "Authorize your Home folder to update Claude config") } var incoming: [MCPServer] = [] var importedCandidateIds: Set = [] for item in selected { let resolution = item.resolution switch resolution { case .skip: continue case .overwrite, .rename: let finalName = (resolution == .rename ? item.renameName : item.name) .trimmingCharacters(in: .whitespacesAndNewlines) guard !finalName.isEmpty else { continue } let meta = MCPServerMeta(description: item.description, version: nil, websiteUrl: nil, repositoryURL: nil) let server = MCPServer( name: finalName, kind: item.kind, command: item.command, args: item.args, env: item.env, url: item.url, headers: item.headers, meta: meta, enabled: true, capabilities: [], targets: MCPServerTargets() ) incoming.append(server) importedCandidateIds.insert(item.id) } } do { try await store.upsertMany(incoming) await loadServers() await applyEnabledServersToAllProviders() importStatusMessage = "Imported \(incoming.count) server(s)." if !importedCandidateIds.isEmpty { importCandidates.removeAll { importedCandidateIds.contains($0.id) } } if importCandidates.isEmpty { closeImportSheetAfterDelay() } } catch { importStatusMessage = "Import failed: \(error.localizedDescription)" } } private func closeImportSheetAfterDelay(_ delay: TimeInterval = 0.6) { Task { @MainActor in try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) self.showImportSheet = false self.importStatusMessage = nil } } func startNewForm() { isEditingExisting = false originalName = nil formName = "" formKind = .stdio formURL = "" formCommand = "" formArgs = "" formArgsJSONText = "[]" formArgsUseJSON = false formEnvText = "" formEnvJSONText = "{}" formEnvUseJSON = false formHeadersText = "" formHeadersJSONText = "{}" formHeadersUseJSON = false formEnabled = true formTargetsCodex = true formTargetsClaude = true formTargetsGemini = true testMessage = nil } func startEditForm(from server: MCPServer) { isEditingExisting = true originalName = server.name formName = server.name formKind = server.kind formURL = server.url ?? "" formCommand = server.command ?? "" let argsArr = server.args ?? [] formArgs = argsArr.joined(separator: "\n") formArgsJSONText = (try? Self.jsonString(argsArr)) ?? "[]" formArgsUseJSON = false formEnvText = Self.serializePairs(server.env) formEnvJSONText = (try? Self.jsonString(server.env ?? [:])) ?? "{}" formEnvUseJSON = false formHeadersText = Self.serializePairs(server.headers) formHeadersJSONText = (try? Self.jsonString(server.headers ?? [:])) ?? "{}" formHeadersUseJSON = false formEnabled = server.enabled let targets = server.targets ?? MCPServerTargets() formTargetsCodex = targets.codex formTargetsClaude = targets.claude formTargetsGemini = targets.gemini testMessage = nil } func formCanSave() -> Bool { !formName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } private static func parsePairs(_ text: String) -> [String: String]? { let lines = text.split(separator: "\n") var dict: [String: String] = [:] for line in lines { let raw = line.trimmingCharacters(in: .whitespaces) if raw.isEmpty { continue } if let eq = raw.firstIndex(of: "=") { let k = String(raw[.. String { guard let dict, !dict.isEmpty else { return "" } return dict.keys.sorted().map { "\($0)=\(dict[$0]!)" }.joined(separator: "\n") } private static func jsonString(_ value: T) throws -> String { let enc = JSONEncoder() enc.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes, .sortedKeys] let data = try enc.encode(AnyEncodable(value)) return String(data: data, encoding: .utf8) ?? "{}" } private static func parseJSONStringDict(_ text: String) -> [String: String]? { guard let data = text.data(using: .utf8) else { return nil } if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { var out: [String: String] = [:] for (k, v) in obj { out[k] = String(describing: v) } return out.isEmpty ? nil : out } if let obj = try? JSONDecoder().decode([String: String].self, from: data) { return obj } return nil } private static func parseJSONStringArray(_ text: String) -> [String]? { guard let data = text.data(using: .utf8) else { return nil } if let arr = try? JSONSerialization.jsonObject(with: data) as? [Any] { let out = arr.map { String(describing: $0) } return out } if let arr = try? JSONDecoder().decode([String].self, from: data) { return arr } return nil } private func buildServerFromForm() -> MCPServer { let trimmedName = formName.trimmingCharacters(in: .whitespacesAndNewlines) let args: [String] = formArgsUseJSON ? (Self.parseJSONStringArray(formArgsJSONText) ?? []) : formArgs .split(whereSeparator: { $0.isWhitespace }) .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } let env: [String: String]? = formEnvUseJSON ? Self.parseJSONStringDict(formEnvJSONText) : Self.parsePairs(formEnvText) let headers: [String: String]? = formHeadersUseJSON ? Self.parseJSONStringDict(formHeadersJSONText) : Self.parsePairs(formHeadersText) return MCPServer( name: trimmedName, kind: formKind, command: formCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : formCommand, args: args.isEmpty ? nil : args, env: env, url: formURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : formURL, headers: headers, meta: nil, enabled: formEnabled, capabilities: servers.first(where: { $0.name == originalName ?? formName })?.capabilities ?? [], targets: MCPServerTargets( codex: formTargetsCodex, claude: formTargetsClaude, gemini: formTargetsGemini ) ) } // JSON preview of the current form as a single server object (without capabilities) func formJSONPreview() -> String { let obj = buildServerFromForm() struct Preview: Encodable { let name: String let kind: MCPServerKind let command: String? let args: [String]? let env: [String: String]? let url: String? let headers: [String: String]? let meta: MCPServerMeta? let enabled: Bool } let preview = Preview(name: obj.name, kind: obj.kind, command: obj.command, args: obj.args, env: obj.env, url: obj.url, headers: obj.headers, meta: obj.meta, enabled: obj.enabled) let enc = JSONEncoder() enc.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes, .sortedKeys] if let data = try? enc.encode(preview), let s = String(data: data, encoding: .utf8) { return s } return "{}" } func saveForm() async -> Bool { guard formCanSave() else { return false } let item = buildServerFromForm() do { if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() let codmate = home.appendingPathComponent(".codmate", isDirectory: true) let codex = home.appendingPathComponent(".codex", isDirectory: true) _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codmate, purpose: .generalAccess, message: "Authorize ~/.codmate to save MCP servers") _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codex, purpose: .generalAccess, message: "Authorize ~/.codex to update Codex config") _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess, message: "Authorize your Home folder to update Claude config") } if isEditingExisting, let original = originalName, original != item.name { // Rename: remove old record first to avoid duplicate entries try await store.delete(name: original) } try await store.upsert(item) await loadServers() await applyEnabledServersToAllProviders() originalName = item.name isEditingExisting = true return true } catch { errorMessage = "Failed to save: \(error.localizedDescription)" return false } } func deleteServer(named name: String) async { do { try await store.delete(name: name) await loadServers() await applyEnabledServersToAllProviders() } catch { errorMessage = "Failed to delete: \(error.localizedDescription)" } } func parseImportText() { let trimmed = importText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { importError = nil drafts = [] isParsing = false return } isParsing = true importError = nil Task.detached { do { let ds = try UniImportMCPNormalizer.parseText(trimmed) await MainActor.run { self.drafts = ds self.importError = ds.isEmpty ? "No servers detected" : nil // Autofill the form with the first detected draft in New mode if !self.isEditingExisting, let first = ds.first { self.applyDraftToForm(first) } } } catch { await MainActor.run { self.drafts = [] self.importError = (error as? LocalizedError)?.errorDescription ?? "Failed to parse input" } } await MainActor.run { self.isParsing = false } } } private func applyDraftToForm(_ d: MCPServerDraft) { formName = (d.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) formKind = d.kind formURL = d.url ?? "" formCommand = d.command ?? "" if let arr = d.args, !arr.isEmpty { formArgs = arr.joined(separator: "\n") formArgsJSONText = (try? Self.jsonString(arr)) ?? formArgsJSONText } formEnvText = Self.serializePairs(d.env) formEnvJSONText = (try? Self.jsonString(d.env ?? [:])) ?? formEnvJSONText formHeadersText = Self.serializePairs(d.headers) formHeadersJSONText = (try? Self.jsonString(d.headers ?? [:])) ?? formHeadersJSONText formEnabled = true } // MARK: - Quick Test (lightweight) func testCurrentForm() async { testInProgress = true testMessage = nil defer { testInProgress = false } let server = buildServerFromForm() let result = await tester.test(server: server, timeoutSeconds: 6) switch result { case .success(let r): let name = r.serverName?.isEmpty == false ? " to \(r.serverName!)" : "" var parts: [String] = [] if r.hasTools { parts.append("Tools \(r.tools)") } if r.hasPrompts { parts.append("Prompts \(r.prompts)") } if r.hasResources { parts.append("Resources \(r.resources)") } if r.models > 0 { parts.append("Models \(r.models)") } testMessage = "Connected\(name) — " + (parts.isEmpty ? "(no declared capabilities)" : parts.joined(separator: ", ")) case .failure(let e): let reason = (e as MCPQuickTestError).errorDescription ?? "failed" testMessage = "Unreachable — \(reason)" } } func startTest() { testTask?.cancel() testTask = Task { await self.testCurrentForm() } } func cancelTest() { testTask?.cancel() Task { await tester.cancelActive() } testInProgress = false testMessage = "Cancelled" } func importDrafts() async { guard !drafts.isEmpty else { return } do { var incoming: [MCPServer] = [] for d in drafts { let name = d.name ?? "imported-server" let srv = MCPServer( name: name, kind: d.kind, command: d.command, args: d.args, env: d.env, url: d.url, headers: d.headers, meta: d.meta, enabled: true, capabilities: [], targets: MCPServerTargets() ) incoming.append(srv) } if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() let codmate = home.appendingPathComponent(".codmate", isDirectory: true) let codex = home.appendingPathComponent(".codex", isDirectory: true) _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codmate, purpose: .generalAccess, message: "Authorize ~/.codmate to save MCP servers") _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codex, purpose: .generalAccess, message: "Authorize ~/.codex to update Codex config") _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess, message: "Authorize your Home folder to update Claude config") } try await store.upsertMany(incoming) await loadServers() // Apply enabled servers into Codex config.toml await applyEnabledServersToAllProviders() // Reset import UI drafts = [] importText = "" importError = nil } catch { errorMessage = "Failed to save servers: \(error.localizedDescription)" } } func setServerEnabled(_ server: MCPServer, _ enabled: Bool) async { do { if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() let codmate = home.appendingPathComponent(".codmate", isDirectory: true) let codex = home.appendingPathComponent(".codex", isDirectory: true) _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codmate, purpose: .generalAccess) _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codex, purpose: .generalAccess) _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess) } if enabled { var updated = server var targets = updated.targets ?? MCPServerTargets() targets.codex = true targets.claude = true targets.gemini = true updated.targets = targets updated.enabled = true try await store.upsert(updated) } else { var updated = server var targets = updated.targets ?? MCPServerTargets() targets.codex = false targets.claude = false targets.gemini = false updated.targets = targets updated.enabled = false try await store.upsert(updated) } await loadServers() await applyEnabledServersToAllProviders() } catch { errorMessage = "Failed to update: \(error.localizedDescription)" } } func setCapabilityEnabled(_ server: MCPServer, _ cap: MCPCapability, _ enabled: Bool) async { do { if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() let codmate = home.appendingPathComponent(".codmate", isDirectory: true) let codex = home.appendingPathComponent(".codex", isDirectory: true) _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codmate, purpose: .generalAccess) _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codex, purpose: .generalAccess) _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess) } try await store.setCapabilityEnabled(name: server.name, capability: cap.name, enabled: enabled) await loadServers() await applyEnabledServersToAllProviders() } catch { errorMessage = "Failed to update: \(error.localizedDescription)" } } func isServerEnabled(_ server: MCPServer, for target: MCPServerTarget) -> Bool { server.isEnabled(for: target) } func setServerTargetEnabled(_ server: MCPServer, target: MCPServerTarget, enabled: Bool) async { do { if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() let codmate = home.appendingPathComponent(".codmate", isDirectory: true) let codex = home.appendingPathComponent(".codex", isDirectory: true) _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codmate, purpose: .generalAccess) _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codex, purpose: .generalAccess) _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess) } var updated = server.withTargets { targets in targets.setEnabled(enabled, for: target) } // Preserve existing capabilities; auto-enable when user flips a provider on. let hasAnyTarget = (updated.targets?.codex ?? false) || (updated.targets?.claude ?? false) || (updated.targets?.gemini ?? false) if enabled { updated.enabled = true } else if !hasAnyTarget { updated.enabled = false } try await store.upsert(updated) await loadServers() await applyEnabledServersToAllProviders() } catch { errorMessage = "Failed to update: \(error.localizedDescription)" } } // Stub for capability discovery via MCP Swift SDK (to be integrated) func refreshCapabilities(for server: MCPServer) async { // TODO: Integrate MCP Swift SDK handshake and tools discovery // For MVP, keep existing capabilities untouched. await loadServers() await applyEnabledServersToAllProviders() } private func applyEnabledServersToAllProviders() async { let list = await store.list() // 1. Codex let codex = CodexConfigService() try? await codex.applyMCPServers(list) // 2. Claude Code (User settings export) try? await store.exportEnabledForClaudeConfig(servers: list) // 3. Gemini CLI let gemini = GeminiSettingsService() try? await gemini.applyMCPServers(list) } } // A tiny type eraser to help JSONEncoder with generic values private struct AnyEncodable: Encodable { private let encodeImpl: (Encoder) throws -> Void init(_ value: T) { encodeImpl = value.encode } func encode(to encoder: Encoder) throws { try encodeImpl(encoder) } } ================================================ FILE: models/OverviewAggregate.swift ================================================ import Foundation struct OverviewSourceAggregate: Sendable { let kind: SessionSource.Kind let sessionCount: Int let totalTokens: Int let totalDuration: TimeInterval let userMessages: Int let assistantMessages: Int let toolInvocations: Int } struct OverviewDailyPoint: Sendable { let day: Date // start of day in local time let kind: SessionSource.Kind let sessionCount: Int let totalTokens: Int let totalDuration: TimeInterval } struct OverviewAggregate: Sendable { let totalSessions: Int let totalTokens: Int let totalDuration: TimeInterval let userMessages: Int let assistantMessages: Int let toolInvocations: Int let sources: [OverviewSourceAggregate] let daily: [OverviewDailyPoint] let generatedAt: Date } struct SessionIndexCoverage: Sendable { let sessionCount: Int let lastFullIndexAt: Date? let sources: [SessionSource.Kind] } struct OverviewAggregateScope: Sendable { let dateDimension: DateDimension let start: Date let end: Date let projectIds: Set? } ================================================ FILE: models/PathTree.swift ================================================ import Foundation struct PathTreeNode: Identifiable, Hashable { let id: String // absolute path for uniqueness let name: String var count: Int var children: [PathTreeNode]? // OutlineGroup expects optional children } extension Array where Element == SessionSummary { func buildPathTree() -> PathTreeNode? { guard !isEmpty else { return nil } // Determine common root from all cwd paths let paths: [[String]] = self.map { URL(fileURLWithPath: $0.cwd, isDirectory: true).pathComponents } func commonPrefixPathComponents(_ arrays: [[String]]) -> [String] { guard var prefix = arrays.first else { return [] } for comps in arrays.dropFirst() { let n = Swift.min(prefix.count, comps.count) var i = 0 while i < n, prefix[i] == comps[i] { i += 1 } prefix = [String](prefix.prefix(i)) if prefix.isEmpty { break } } return prefix } let commonPrefix = commonPrefixPathComponents(paths) let rootPath: String = commonPrefix.isEmpty ? "/" : NSString.path(withComponents: commonPrefix) let rootID = rootPath let rootName = commonPrefix.last ?? "/" let root = PathTreeNode(id: rootID, name: rootName.isEmpty ? "/" : rootName, count: 0, children: []) var nodeMap: [String: Int] = [root.id: 0] // id -> index in flat array var flat: [PathTreeNode] = [root] func ensureNode(pathComponents: [String]) -> Int { let fullPath = NSString.path(withComponents: pathComponents) if let idx = nodeMap[fullPath] { return idx } let name = pathComponents.last ?? "/" let node = PathTreeNode(id: fullPath, name: name, count: 0, children: []) nodeMap[fullPath] = flat.count flat.append(node) return flat.count - 1 } for s in self { let comps = URL(fileURLWithPath: s.cwd, isDirectory: true).pathComponents let start = commonPrefix.count guard start <= comps.count else { continue } var pathSoFar = [String](commonPrefix) var parentIdx = 0 for i in start..child if not linked yet let childNode = flat[idx] // Copy to local variable to avoid overlapping access if flat[parentIdx].children == nil { flat[parentIdx].children = [] } if !(flat[parentIdx].children?.contains(where: { $0.id == childNode.id }) ?? false) { flat[parentIdx].children?.append(childNode) } parentIdx = idx // Increase count for each node along the path flat[idx].count += 1 } // Increase root count too flat[0].count += 1 } // Reconstruct tree from flat map preserving children that were appended with stale copies func rebuild(from node: PathTreeNode) -> PathTreeNode { var newNode = flat[nodeMap[node.id]!] let rebuilt = (node.children ?? []).map { rebuild(from: $0) }.sorted { $0.name.localizedCompare($1.name) == .orderedAscending } newNode.children = rebuilt.isEmpty ? nil : rebuilt return newNode } return rebuild(from: flat[0]) } } extension Dictionary where Key == String, Value == Int { // Build a tree from a map of cwd -> count func buildPathTreeFromCounts() -> PathTreeNode? { guard !isEmpty else { return nil } let allPaths = self.keys.map { URL(fileURLWithPath: $0, isDirectory: true).pathComponents } // common prefix var prefix = allPaths.first ?? [] for comps in allPaths.dropFirst() { let n = Swift.min(prefix.count, comps.count) var i = 0 while i < n, prefix[i] == comps[i] { i += 1 } prefix = Array(prefix.prefix(i)) if prefix.isEmpty { break } } let rootPath = prefix.isEmpty ? "/" : NSString.path(withComponents: prefix) let rootName = prefix.last ?? "/" let root = PathTreeNode(id: rootPath, name: rootName.isEmpty ? "/" : rootName, count: 0, children: []) var nodeMap: [String: Int] = [root.id: 0] var flat: [PathTreeNode] = [root] func ensureNode(_ comps: [String]) -> Int { let full = NSString.path(withComponents: comps) if let idx = nodeMap[full] { return idx } let name = comps.last ?? "/" nodeMap[full] = flat.count flat.append(PathTreeNode(id: full, name: name, count: 0, children: [])) return flat.count - 1 } for (cwd, cnt) in self { var comps = URL(fileURLWithPath: cwd, isDirectory: true).pathComponents if comps.starts(with: prefix) { comps.removeFirst(prefix.count) } var pathSoFar = prefix var parentIdx = 0 for part in comps { pathSoFar.append(part) let idx = ensureNode(pathSoFar) // Copy to local variable to avoid overlapping access let childNode = flat[idx] if flat[parentIdx].children == nil { flat[parentIdx].children = [] } if !(flat[parentIdx].children?.contains(where: { $0.id == childNode.id }) ?? false) { flat[parentIdx].children?.append(childNode) } // accumulate counts up the chain flat[idx].count += cnt parentIdx = idx } flat[0].count += cnt } func rebuild(from node: PathTreeNode) -> PathTreeNode { var newNode = flat[nodeMap[node.id]!] let rebuilt = (node.children ?? []).map { rebuild(from: $0) }.sorted { $0.name.localizedCompare($1.name) == .orderedAscending } newNode.children = rebuilt.isEmpty ? nil : rebuilt return newNode } return rebuild(from: flat[0]) } } ================================================ FILE: models/Project.swift ================================================ import Foundation struct Project: Identifiable, Hashable, Sendable, Codable { var id: String var name: String var directory: String? // Optional: projects are virtual; directory not required var trustLevel: String? var overview: String? var instructions: String? var profileId: String? var profile: ProjectProfile? var parentId: String? var sources: Set = Set(ProjectSessionSource.allCases) } struct ProjectProfile: Codable, Hashable, Sendable { var model: String? var sandbox: SandboxMode? var approval: ApprovalPolicy? var fullAuto: Bool? var dangerouslyBypass: Bool? // Extra runtime enrichments var pathPrepend: [String]? var env: [String:String]? } enum ProjectSessionSource: String, CaseIterable, Codable, Sendable, Identifiable { case codex case claude case gemini var id: String { rawValue } var displayName: String { switch self { case .codex: return "Codex" case .claude: return "Claude" case .gemini: return "Gemini" } } } extension ProjectSessionSource { static var allSet: Set { Set(allCases) } var baseKind: SessionSource.Kind { switch self { case .codex: return .codex case .claude: return .claude case .gemini: return .gemini } } var sessionSource: SessionSource { switch self { case .codex: return .codexLocal case .claude: return .claudeLocal case .gemini: return .geminiLocal } } } extension SessionSource { var projectSource: ProjectSessionSource { switch self { case .codexLocal, .codexRemote: return .codex case .claudeLocal, .claudeRemote: return .claude case .geminiLocal, .geminiRemote: return .gemini } } func friendlyModelName(for raw: String) -> String { switch self { case .codexLocal, .codexRemote, .geminiLocal, .geminiRemote: return raw case .claudeLocal, .claudeRemote: return Self.normalizeClaudeModel(raw) } } private static func normalizeClaudeModel(_ raw: String) -> String { var name = raw if name.hasPrefix("claude-") { name.removeFirst("claude-".count) } if let dash = name.lastIndex(of: "-"), dash != name.startIndex { let suffix = name[name.index(after: dash)...] if suffix.count == 8, suffix.allSatisfy({ $0.isNumber }) { name = String(name[.. Bool { lhs.id == rhs.id } } @MainActor final class ProjectExtensionsViewModel: ObservableObject { private let extensionsStore = ProjectExtensionsStore() private let skillsStore = SkillsStore() private let mcpStore = MCPServersStore() private let applier = ProjectExtensionsApplier() private var skillRecords: [SkillRecord] = [] private var projectId: String? private var projectDirectory: URL? private var projectTrustLevel: String? @Published var skills: [SkillSummary] = [] @Published var mcpSelections: [ProjectMCPSelection] = [] @Published var isLoading: Bool = false @Published var errorMessage: String? @Published var showMCPImportSheet: Bool = false @Published var showSkillsImportSheet: Bool = false @Published var mcpImportCandidates: [MCPImportCandidate] = [] @Published var skillsImportCandidates: [SkillImportCandidate] = [] @Published var isImportingMCP: Bool = false @Published var isImportingSkills: Bool = false @Published var mcpImportStatusMessage: String? @Published var skillsImportStatusMessage: String? func load(projectId: String?, projectDirectory: String, trustLevel: String? = nil) async { isLoading = true defer { isLoading = false } self.projectId = projectId let dir = projectDirectory.trimmingCharacters(in: .whitespacesAndNewlines) self.projectDirectory = dir.isEmpty ? nil : URL(fileURLWithPath: dir, isDirectory: true) self.projectTrustLevel = normalizeTrustLevel(trustLevel) skillRecords = await skillsStore.list() let config: ProjectExtensionsConfig? if let projectId { config = await extensionsStore.load(projectId: projectId) } else { config = nil } let skillConfigMap = config?.skills.reduce(into: [String: ProjectSkillConfig]()) { $0[$1.id] = $1 } ?? [:] skills = skillRecords.map { record in let cfg = skillConfigMap[record.id] return SkillSummary( id: record.id, name: record.name, description: record.description, summary: record.summary, tags: record.tags, source: record.source, path: record.path, isSelected: cfg?.isSelected ?? false, targets: cfg?.targets ?? record.targets ) } let servers = await mcpStore.list() let mcpConfigMap = config?.mcpServers.reduce(into: [String: ProjectMCPConfig]()) { $0[$1.id] = $1 } ?? [:] mcpSelections = servers.map { server in let targets = server.targets ?? MCPServerTargets() let cfg = mcpConfigMap[server.name] return ProjectMCPSelection( server: server, isSelected: cfg?.isSelected ?? false, targets: cfg?.targets ?? targets ) } } func updateMCPSelection(id: String, isSelected: Bool) { guard let idx = mcpSelections.firstIndex(where: { $0.id == id }) else { return } mcpSelections[idx].isSelected = isSelected if !isSelected { mcpSelections[idx].targets.codex = false mcpSelections[idx].targets.claude = false mcpSelections[idx].targets.gemini = false } else { mcpSelections[idx].targets.codex = true mcpSelections[idx].targets.claude = true mcpSelections[idx].targets.gemini = true } Task { await persistAndApplyIfPossible() } } func updateMCPTarget(id: String, target: MCPServerTarget, value: Bool) { guard let idx = mcpSelections.firstIndex(where: { $0.id == id }) else { return } mcpSelections[idx].targets.setEnabled(value, for: target) if value && !mcpSelections[idx].isSelected { mcpSelections[idx].isSelected = true } else if !mcpSelections[idx].targets.codex && !mcpSelections[idx].targets.claude && !mcpSelections[idx].targets.gemini { mcpSelections[idx].isSelected = false } Task { await persistAndApplyIfPossible() } } func updateSkillTarget(id: String, target: MCPServerTarget, value: Bool) { guard let idx = skills.firstIndex(where: { $0.id == id }) else { return } var updated = skills[idx] updated.targets.setEnabled(value, for: target) if value && !updated.isSelected { updated.isSelected = true } else if !updated.targets.codex && !updated.targets.claude && !updated.targets.gemini { updated.isSelected = false } skills[idx] = updated Task { await persistAndApplyIfPossible() } } func updateSkillSelection(id: String, value: Bool) { guard let idx = skills.firstIndex(where: { $0.id == id }) else { return } skills[idx].isSelected = value if !value { skills[idx].targets.codex = false skills[idx].targets.claude = false skills[idx].targets.gemini = false } else { skills[idx].targets.codex = true skills[idx].targets.claude = true skills[idx].targets.gemini = true } Task { await persistAndApplyIfPossible() } } func persistSelections(projectId: String, directory: String?, trustLevel: String?) async { self.projectId = projectId if let dir = directory?.trimmingCharacters(in: .whitespacesAndNewlines), !dir.isEmpty { self.projectDirectory = URL(fileURLWithPath: dir, isDirectory: true) } self.projectTrustLevel = normalizeTrustLevel(trustLevel) await persistAndApplyIfPossible() } // MARK: - Project Import func beginProjectMCPImport() { showMCPImportSheet = true Task { await loadProjectMCPCandidates() } } func beginProjectSkillsImport() { showSkillsImportSheet = true Task { await loadProjectSkillsCandidates() } } func loadProjectMCPCandidates() async { isImportingMCP = true mcpImportStatusMessage = "Scanning…" guard let projectDirectory else { mcpImportCandidates = [] mcpImportStatusMessage = "Choose a project directory first." isImportingMCP = false return } if SecurityScopedBookmarks.shared.isSandboxed { AuthorizationHub.shared.ensureDirectoryAccessOrPrompt( directory: projectDirectory, purpose: .generalAccess, message: "Authorize project directory to import MCP servers" ) } let existing = await mcpStore.list() let existingNames = Set(existing.map(\.name)) let managedSignatures = Set(existing.map { MCPImportService.signature(for: $0) }) let scanned = await Task.detached(priority: .userInitiated) { MCPImportService.scan(scope: .project(directory: projectDirectory)) }.value // CodMate store is the source of truth; provider configs can drift if edited by other tools. let filtered = MCPImportService.filterManagedCandidates(scanned, managedSignatures: managedSignatures) let candidates = filtered.map { item -> MCPImportCandidate in var updated = item if existingNames.contains(item.name) { updated.hasConflict = true updated.isSelected = false updated.resolution = .skip updated.renameName = item.name } return updated } if candidates.isEmpty { mcpImportStatusMessage = "No MCP servers found." } else { mcpImportStatusMessage = nil } mcpImportCandidates = candidates isImportingMCP = false } func loadProjectSkillsCandidates() async { isImportingSkills = true skillsImportStatusMessage = "Scanning…" guard let projectDirectory else { skillsImportCandidates = [] skillsImportStatusMessage = "Choose a project directory first." isImportingSkills = false return } if SecurityScopedBookmarks.shared.isSandboxed { AuthorizationHub.shared.ensureDirectoryAccessOrPrompt( directory: projectDirectory, purpose: .generalAccess, message: "Authorize project directory to import skills" ) } let scanned = await Task.detached(priority: .userInitiated) { await SkillsImportService.scan(scope: .project(directory: projectDirectory)) }.value let existing = await skillsStore.list() let managedIds = Set(existing.map(\.id)) // CodMate store is the source of truth; provider directories can drift if edited by other tools. let filtered = scanned.filter { !managedIds.contains($0.id) } var candidates: [SkillImportCandidate] = [] for item in filtered { var updated = item if let conflict = await skillsStore.conflictInfo(forProposedId: item.id) { updated.hasConflict = true updated.isSelected = false updated.resolution = .skip updated.renameId = conflict.suggestedId updated.suggestedId = conflict.suggestedId updated.conflictDetail = conflict.existingIsManaged ? "Existing CodMate-managed skill" : "Skill already exists" } candidates.append(updated) } skillsImportCandidates = candidates isImportingSkills = false skillsImportStatusMessage = candidates.isEmpty ? "No skills found." : nil } func cancelProjectMCPImport() { showMCPImportSheet = false mcpImportCandidates = [] mcpImportStatusMessage = nil } func cancelProjectSkillsImport() { showSkillsImportSheet = false skillsImportCandidates = [] skillsImportStatusMessage = nil } func importProjectMCPSelections() async { let selected = mcpImportCandidates.filter { $0.isSelected } guard !selected.isEmpty else { mcpImportStatusMessage = "No servers selected." return } let resolvedNames = selected.compactMap { item -> String? in let resolution = item.resolution switch resolution { case .skip: return nil case .overwrite: return item.name case .rename: let trimmed = item.renameName.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } } let duplicates = Dictionary(grouping: resolvedNames, by: { $0 }).filter { $1.count > 1 }.keys if !duplicates.isEmpty { mcpImportStatusMessage = "Resolve duplicate names before importing." return } if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() let codmate = home.appendingPathComponent(".codmate", isDirectory: true) let codex = home.appendingPathComponent(".codex", isDirectory: true) _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codmate, purpose: .generalAccess, message: "Authorize ~/.codmate to save MCP servers") _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codex, purpose: .generalAccess, message: "Authorize ~/.codex to update Codex config") _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess, message: "Authorize your Home folder to update Claude config") } var incoming: [MCPServer] = [] var importedCandidateIds: Set = [] for item in selected { let resolution = item.resolution switch resolution { case .skip: continue case .overwrite, .rename: let finalName = (resolution == .rename ? item.renameName : item.name) .trimmingCharacters(in: .whitespacesAndNewlines) guard !finalName.isEmpty else { continue } let meta = MCPServerMeta(description: item.description, version: nil, websiteUrl: nil, repositoryURL: nil) let server = MCPServer( name: finalName, kind: item.kind, command: item.command, args: item.args, env: item.env, url: item.url, headers: item.headers, meta: meta, enabled: true, capabilities: [], targets: MCPServerTargets() ) incoming.append(server) importedCandidateIds.insert(item.id) } } do { try await mcpStore.upsertMany(incoming) await load(projectId: projectId, projectDirectory: projectDirectory?.path ?? "") let importedNames = Set(incoming.map(\.name)) for idx in mcpSelections.indices where importedNames.contains(mcpSelections[idx].id) { mcpSelections[idx].isSelected = true } await persistAndApplyIfPossible() mcpImportStatusMessage = "Imported \(incoming.count) server(s)." if !importedCandidateIds.isEmpty { mcpImportCandidates.removeAll { importedCandidateIds.contains($0.id) } } if mcpImportCandidates.isEmpty { closeMCPImportSheetAfterDelay() } } catch { mcpImportStatusMessage = "Import failed: \(error.localizedDescription)" } } func importProjectSkillsSelections() async { let selected = skillsImportCandidates.filter { $0.isSelected } guard !selected.isEmpty else { skillsImportStatusMessage = "No skills selected." return } if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() let codmate = home.appendingPathComponent(".codmate", isDirectory: true) AuthorizationHub.shared.ensureDirectoryAccessOrPrompt( directory: codmate, purpose: .generalAccess, message: "Authorize ~/.codmate to import skills" ) } var importedIds: [String] = [] var importedCandidateIds: Set = [] var importedCandidates: [SkillImportCandidate] = [] for item in selected { let resolution = item.hasConflict ? item.resolution : .overwrite switch resolution { case .skip: continue case .overwrite: let req = SkillInstallRequest(mode: .folder, url: URL(fileURLWithPath: item.sourcePath), text: nil) let outcome = await skillsStore.install(request: req, resolution: .overwrite) if case .installed(let record) = outcome { await skillsStore.markImported(id: record.id) importedIds.append(record.id) importedCandidateIds.insert(item.id) importedCandidates.append(item) } case .rename: let newId = item.renameId.trimmingCharacters(in: .whitespacesAndNewlines) guard !newId.isEmpty else { continue } let req = SkillInstallRequest(mode: .folder, url: URL(fileURLWithPath: item.sourcePath), text: nil) let outcome = await skillsStore.install(request: req, resolution: .rename(newId)) if case .installed(let record) = outcome { await skillsStore.markImported(id: record.id) importedIds.append(record.id) importedCandidateIds.insert(item.id) importedCandidates.append(item) } } } if let projectDirectory, !importedCandidates.isEmpty { removeImportedProjectProviderCopies(importedCandidates, projectDirectory: projectDirectory) } await load(projectId: projectId, projectDirectory: projectDirectory?.path ?? "") let importedSet = Set(importedIds) for idx in skills.indices where importedSet.contains(skills[idx].id) { skills[idx].isSelected = true } await persistAndApplyIfPossible() skillsImportStatusMessage = "Imported \(importedIds.count) skill(s)." if !importedCandidateIds.isEmpty { skillsImportCandidates.removeAll { importedCandidateIds.contains($0.id) } } if skillsImportCandidates.isEmpty { closeSkillsImportSheetAfterDelay() } } private func closeMCPImportSheetAfterDelay(_ delay: TimeInterval = 0.6) { Task { @MainActor in try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) self.showMCPImportSheet = false self.mcpImportStatusMessage = nil } } private func closeSkillsImportSheetAfterDelay(_ delay: TimeInterval = 0.6) { Task { @MainActor in try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) self.showSkillsImportSheet = false self.skillsImportStatusMessage = nil } } private func removeImportedProjectProviderCopies( _ items: [SkillImportCandidate], projectDirectory: URL ) { let providerRoots: [String: URL] = [ "Codex": projectDirectory.appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("skills", isDirectory: true), "Claude": projectDirectory.appendingPathComponent(".claude", isDirectory: true) .appendingPathComponent("skills", isDirectory: true), "Gemini": projectDirectory.appendingPathComponent(".gemini", isDirectory: true) .appendingPathComponent("skills", isDirectory: true) ] let fm = FileManager.default for item in items { if item.sourcePaths.isEmpty { for source in item.sources { guard let root = providerRoots[source] else { continue } let dir = URL(fileURLWithPath: item.sourcePath, isDirectory: true) if dir.standardizedFileURL.path.hasPrefix(root.standardizedFileURL.path) { try? fm.removeItem(at: dir) } } continue } for (source, path) in item.sourcePaths { guard let root = providerRoots[source] else { continue } let dir = URL(fileURLWithPath: path).deletingLastPathComponent() if dir.standardizedFileURL.path.hasPrefix(root.standardizedFileURL.path) { try? fm.removeItem(at: dir) } } } } private func persistAndApplyIfPossible() async { guard let projectId else { return } let config = ProjectExtensionsConfig( projectId: projectId, mcpServers: mcpSelections.map { entry in ProjectMCPConfig(id: entry.id, isSelected: entry.isSelected, targets: entry.targets) }, skills: skills.map { skill in ProjectSkillConfig(id: skill.id, isSelected: skill.isSelected, targets: skill.targets) }, updatedAt: Date() ) await extensionsStore.save(config) guard let projectDirectory, FileManager.default.fileExists(atPath: projectDirectory.path) else { return } AuthorizationHub.shared.ensureDirectoryAccessOrPrompt( directory: projectDirectory, purpose: .generalAccess, message: "Authorize project directory to update Extensions" ) let selections = skills.map { skill in SkillsSyncService.SkillSelection(id: skill.id, isSelected: skill.isSelected, targets: skill.targets) } await applier.apply( projectDirectory: projectDirectory, mcpSelections: mcpSelections, skillRecords: skillRecords, skillSelections: selections, trustLevel: projectTrustLevel ) } private func normalizeTrustLevel(_ value: String?) -> String? { let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed?.isEmpty == false ? trimmed : nil } } ================================================ FILE: models/ProjectOverviewViewModel.swift ================================================ import Combine import Foundation @MainActor final class ProjectOverviewViewModel: ObservableObject { @Published private(set) var snapshot: ProjectOverviewSnapshot = .empty @Published private(set) var isLoading: Bool = true private let sessionListViewModel: SessionListViewModel private var project: Project private var cancellables: Set = [] private var pendingRefreshTask: Task? = nil private var hasLoadedOnce: Bool = false init(sessionListViewModel: SessionListViewModel, project: Project) { self.sessionListViewModel = sessionListViewModel self.project = project bindPublishers() recomputeSnapshot() } deinit { pendingRefreshTask?.cancel() } func forceRefresh() { pendingRefreshTask?.cancel() pendingRefreshTask = nil recomputeSnapshot() } func updateProject(_ newProject: Project) { guard newProject.id == project.id else { return } // Only update if it's the same project project = newProject recomputeSnapshot() } private func bindPublishers() { sessionListViewModel.$sections .sink { [weak self] _ in self?.scheduleSnapshotRefresh() } .store(in: &cancellables) sessionListViewModel.$awaitingFollowupIDs .sink { [weak self] _ in self?.scheduleSnapshotRefresh() } .store(in: &cancellables) sessionListViewModel.$usageSnapshots .sink { [weak self] _ in self?.scheduleSnapshotRefresh() } .store(in: &cancellables) sessionListViewModel.$projects .sink { [weak self] _ in self?.scheduleSnapshotRefresh() } .store(in: &cancellables) sessionListViewModel.$isLoading .receive(on: DispatchQueue.main) .sink { [weak self] value in guard let self else { return } // Always sync parent loading state, but show loading during initial computation if self.hasLoadedOnce { self.isLoading = value } else { // During initial load, stay loading until first snapshot completes self.isLoading = true } } .store(in: &cancellables) } private func scheduleSnapshotRefresh() { pendingRefreshTask?.cancel() pendingRefreshTask = Task { [weak self] in try? await Task.sleep(nanoseconds: 120_000_000) guard !Task.isCancelled else { return } guard let self else { return } // Mark as loaded early so loading state can sync properly after first computation let isFirstLoad = !self.hasLoadedOnce if isFirstLoad { await MainActor.run { self.hasLoadedOnce = true self.isLoading = true } } else { await MainActor.run { self.isLoading = self.sessionListViewModel.isLoading } } // Capture data on MainActor // Filter sessions on MainActor because projectId(for:) accesses MainActor state let filteredSessions = self.sessionListViewModel.sections.flatMap { $0.sessions } var allowedProjects = Set([self.project.id]) let descendants = self.sessionListViewModel.collectDescendants( of: self.project.id, in: self.sessionListViewModel.projects ) allowedProjects.formUnion(descendants) var projectSessions: [SessionSummary] = filteredSessions.filter { guard let pid = self.sessionListViewModel.projectId(for: $0) else { return false } return allowedProjects.contains(pid) } // If the filtered view is empty but counts indicate data, fall back to a local filter pass. if projectSessions.isEmpty, !self.sessionListViewModel.isLoading { let trimmedSearch = self.sessionListViewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedQuick = self.sessionListViewModel.quickSearchText.trimmingCharacters(in: .whitespacesAndNewlines) let hasScopeFilters = self.sessionListViewModel.selectedPath != nil || !trimmedSearch.isEmpty || !trimmedQuick.isEmpty let visibleCount = self.sessionListViewModel.projectCountsDisplay()[self.project.id]?.visible ?? 0 if !hasScopeFilters, visibleCount > 0 { let allowedSourcesByProject = self.sessionListViewModel.projects.reduce( into: [String: Set]() ) { $0[$1.id] = $1.sources } let descriptors = SessionListViewModel.makeDayDescriptors( selectedDays: self.sessionListViewModel.selectedDays, singleDay: self.sessionListViewModel.selectedDay ) let filterByDay = !descriptors.isEmpty let fallback = self.sessionListViewModel.allSessions.filter { session in guard let pid = self.sessionListViewModel.projectId(for: session), allowedProjects.contains(pid) else { return false } let allowedSources = allowedSourcesByProject[pid] ?? ProjectSessionSource.allSet guard allowedSources.contains(session.source.projectSource) else { return false } if filterByDay { return self.sessionListViewModel.matchesDayFilters(session, descriptors: descriptors) } return true } if !fallback.isEmpty { projectSessions = fallback } } } let usageSnapshots = self.sessionListViewModel.usageSnapshots // Run computation in background let newSnapshot = await Self.computeSnapshot( projectSessions: projectSessions, usageSnapshots: usageSnapshots ) guard !Task.isCancelled else { return } await MainActor.run { self.snapshot = newSnapshot self.isLoading = self.sessionListViewModel.isLoading } } } private static func computeSnapshot( projectSessions: [SessionSummary], usageSnapshots: [UsageProviderKind: UsageProviderSnapshot] ) async -> ProjectOverviewSnapshot { let now = Date() func anchorDate(for session: SessionSummary) -> Date { session.lastUpdatedAt ?? session.startedAt } let totalDuration = projectSessions.reduce(0) { $0 + $1.duration } let totalTokens = projectSessions.reduce(0) { $0 + $1.actualTotalTokens } let userMessages = projectSessions.reduce(0) { $0 + $1.userMessageCount } let assistantMessages = projectSessions.reduce(0) { $0 + $1.assistantMessageCount } let totalToolInvocations = projectSessions.reduce(0) { $0 + $1.toolInvocationCount } let recentTop = Array( projectSessions .sorted { anchorDate(for: $0) > anchorDate(for: $1) } .prefix(5) ) let sourceStats = buildSourceStats(from: projectSessions) let activityData = projectSessions.generateChartData() return ProjectOverviewSnapshot( totalSessions: projectSessions.count, totalDuration: totalDuration, totalTokens: totalTokens, userMessages: userMessages, assistantMessages: assistantMessages, totalToolInvocations: totalToolInvocations, recentSessions: recentTop, sourceStats: sourceStats, activityChartData: activityData, usageSnapshots: usageSnapshots, lastUpdated: now ) } private static func buildSourceStats(from sessions: [SessionSummary]) -> [ProjectOverviewSnapshot.SourceStat] { var groups: [SessionSource.Kind: [SessionSummary]] = [:] for session in sessions { groups[session.source.baseKind, default: []].append(session) } let kinds: [SessionSource.Kind] = [.codex, .claude, .gemini] var stats: [ProjectOverviewSnapshot.SourceStat] = kinds.compactMap { kind in let group = groups[kind] ?? [] let count = group.count guard count > 0 else { return nil } let totalDuration = group.reduce(0) { $0 + $1.duration } let totalTokens = group.reduce(0) { $0 + $1.actualTotalTokens } return ProjectOverviewSnapshot.SourceStat( kind: kind, sessionCount: count, totalTokens: totalTokens, avgTokens: 0, // Not used for display anymore avgDuration: count > 0 ? totalDuration / Double(count) : 0, isAll: false ) } // Add "All" summary if there's data if !sessions.isEmpty { let totalDuration = sessions.reduce(0) { $0 + $1.duration } let totalTokens = sessions.reduce(0) { $0 + $1.actualTotalTokens } let count = sessions.count let allStat = ProjectOverviewSnapshot.SourceStat( kind: .codex, // Placeholder kind, ignored when isAll is true sessionCount: count, totalTokens: totalTokens, avgTokens: 0, avgDuration: count > 0 ? totalDuration / Double(count) : 0, isAll: true ) stats.insert(allStat, at: 0) } return stats } private func recomputeSnapshot() { scheduleSnapshotRefresh() } func resolveProject(for session: SessionSummary) -> (id: String, name: String)? { // For ProjectOverview, it should always be THIS project return (id: project.id, name: project.name) } } struct ProjectOverviewSnapshot: Equatable { // SourceStat needs to be defined within ProjectOverviewSnapshot now struct SourceStat: Identifiable, Equatable { let kind: SessionSource.Kind let sessionCount: Int let totalTokens: Int let avgTokens: Double let avgDuration: TimeInterval var isAll: Bool = false var id: String { isAll ? "all" : kind.rawValue } var displayName: String { if isAll { return "All" } switch kind { case .codex: return "Codex" case .claude: return "Claude" case .gemini: return "Gemini" } } } var totalSessions: Int var totalDuration: TimeInterval var totalTokens: Int var userMessages: Int var assistantMessages: Int var totalToolInvocations: Int // New field var recentSessions: [SessionSummary] var sourceStats: [SourceStat] var activityChartData: ActivityChartData var usageSnapshots: [UsageProviderKind: UsageProviderSnapshot] var lastUpdated: Date static let empty = ProjectOverviewSnapshot( totalSessions: 0, totalDuration: 0, totalTokens: 0, userMessages: 0, assistantMessages: 0, totalToolInvocations: 0, recentSessions: [], sourceStats: [], activityChartData: .empty, usageSnapshots: [:], lastUpdated: .distantPast ) } ================================================ FILE: models/ProjectWorkspaceMode.swift ================================================ import Foundation enum ProjectWorkspaceMode: String, Codable, Hashable, CaseIterable { case overview case tasks case sessions // For "Other" - manage unassigned sessions case review case agents case memory case settings } ================================================ FILE: models/ProjectWorkspaceViewModel+Generation.swift ================================================ import Foundation import SwiftUI extension ProjectWorkspaceViewModel { /// Generates title and description for a task based on its sessions' metadata /// Uses strategy B: only reads session titles and comments (fast, lightweight) /// - Parameters: /// - task: The task to generate for /// - currentTitle: The current title being edited (may differ from task.title) /// - currentDescription: The current description being edited (may differ from task.description) /// - force: If true, skip confirmation dialog func generateTitleAndDescription(for task: CodMateTask, currentTitle: String? = nil, currentDescription: String? = nil, force: Bool = false) async { // Check if task already has title or description let hasTitle = !task.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty let hasDescription = task.description?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false if !force && (hasTitle || hasDescription) { // Show confirmation dialog let shouldProceed = await confirmOverwrite(taskTitle: task.effectiveTitle) guard shouldProceed else { return } } let statusToken = StatusBarLogStore.shared.beginTask( "Generating task title & description...", level: .info, source: "Tasks" ) var finalStatus: (message: String, level: StatusBarLogLevel)? defer { if let finalStatus { StatusBarLogStore.shared.endTask( statusToken, message: finalStatus.message, level: finalStatus.level, source: "Tasks" ) } else { StatusBarLogStore.shared.endTask(statusToken) } } // Set loading state isGeneratingTitleDescription = true generatingTaskId = task.id defer { isGeneratingTitleDescription = false generatingTaskId = nil } // Get sessions for this task let sessions = getSessionsForTask(task.id) // Special case: no sessions exist if sessions.isEmpty { // Use current editing values if provided, otherwise use task values let titleToUse = currentTitle ?? task.title let descToUse = currentDescription ?? task.description ?? "" // If both title and description are empty, nothing to generate from let hasTitleContent = !titleToUse.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty let hasDescContent = !descToUse.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty guard hasTitleContent || hasDescContent else { finalStatus = ("No task content to generate from", .warning) return } // Generate based on available content (title and/or description) let ok = await generateFromContent(title: titleToUse, description: descToUse) finalStatus = ok ? ("Task title ready", .success) : ("Task generation failed", .error) return } // Build material from session metadata (title + comment only) let material = buildSessionMetadataMaterial(sessions: sessions) // Load prompt template guard let promptTemplate = loadPromptTemplate(named: "task-title-and-description") else { finalStatus = ("Missing task prompt template", .error) return } // Build full prompt let fullPrompt = promptTemplate + material // Call LLM guard let response = await callLLM(prompt: fullPrompt) else { finalStatus = ("Task generation failed (no response)", .error) return } // Parse response guard let parsed = Self.parseTitleDescriptionResponse(response) else { finalStatus = ("Failed to parse task response", .error) return } // Update generated content state - EditTaskSheet will pick these up generatedTaskTitle = parsed.title generatedTaskDescription = parsed.description.isEmpty ? nil : parsed.description finalStatus = ("Task title & description ready", .success) } // MARK: - Private Helpers /// Generate title and description based on existing content (when no sessions exist) private func generateFromContent(title: String, description: String) async -> Bool { // Load prompt template for content-based generation guard let promptTemplate = loadPromptTemplate(named: "task-title-only") else { return false } // Build prompt with current title and/or description var contentLines: [String] = [] let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedDesc = description.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedTitle.isEmpty { contentLines.append("Current title: \(trimmedTitle)") } if !trimmedDesc.isEmpty { contentLines.append("Current description: \(trimmedDesc)") } let fullPrompt = promptTemplate + "\n\n" + contentLines.joined(separator: "\n") // Call LLM guard let response = await callLLM(prompt: fullPrompt) else { return false } // Parse response guard let parsed = Self.parseTitleDescriptionResponse(response) else { return false } // Update generated content state generatedTaskTitle = parsed.title generatedTaskDescription = parsed.description.isEmpty ? nil : parsed.description return true } private func buildSessionMetadataMaterial(sessions: [SessionSummary]) -> String { var lines: [String] = [] for (index, session) in sessions.enumerated() { let title = session.effectiveTitle let comment = session.userComment?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" lines.append("Session \(index + 1): \"\(title)\"") if !comment.isEmpty { // Limit comment to 200 characters to keep material compact let snippet = comment.count > 200 ? String(comment.prefix(200)) + "…" : comment lines.append(" - \(snippet)") } lines.append("") } return lines.joined(separator: "\n") } private func loadPromptTemplate(named name: String) -> String? { guard let url = Bundle.main.url(forResource: name, withExtension: "md", subdirectory: "payload/prompts") else { return nil } return try? String(contentsOf: url, encoding: .utf8) } private func callLLM(prompt: String) async -> String? { let llm = LLMHTTPService() var options = LLMHTTPService.Options() options.preferred = .auto options.timeout = 45 options.maxTokens = 500 options.systemPrompt = "Return only the JSON object. No labels, explanations, or extra commentary." // Use the same provider/model configuration as session generation if let providerId = UserDefaults.standard.string(forKey: "git.review.commitProviderId"), !providerId.isEmpty { options.providerId = providerId } if let modelId = UserDefaults.standard.string(forKey: "git.review.commitModelId"), !modelId.isEmpty { options.model = modelId } do { let res = try await llm.generateText(prompt: prompt, options: options) return res.text } catch { return nil } } private static func parseTitleDescriptionResponse(_ raw: String) -> (title: String, description: String)? { // Remove code fences if present var cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines) if cleaned.hasPrefix("```json") { cleaned = cleaned.dropFirst(7).trimmingCharacters(in: .whitespacesAndNewlines) } else if cleaned.hasPrefix("```") { cleaned = cleaned.dropFirst(3).trimmingCharacters(in: .whitespacesAndNewlines) } if cleaned.hasSuffix("```") { cleaned = String(cleaned.dropLast(3)).trimmingCharacters(in: .whitespacesAndNewlines) } // Parse JSON guard let data = cleaned.data(using: .utf8) else { return nil } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } guard let title = json["title"] as? String, let description = json["description"] as? String else { return nil } return ( title: title.trimmingCharacters(in: .whitespacesAndNewlines), description: description.trimmingCharacters(in: .whitespacesAndNewlines) ) } @MainActor private func confirmOverwrite(taskTitle: String) async -> Bool { return await withCheckedContinuation { continuation in DispatchQueue.main.async { let alert = NSAlert() alert.messageText = "Overwrite Existing Content?" alert.informativeText = "This task already has a title or description. Do you want to generate new ones?" alert.addButton(withTitle: "Generate") alert.addButton(withTitle: "Cancel") alert.alertStyle = .warning let response = alert.runModal() continuation.resume(returning: response == .alertFirstButtonReturn) } } } } ================================================ FILE: models/ProjectWorkspaceViewModel.swift ================================================ import Foundation import SwiftUI @MainActor class ProjectWorkspaceViewModel: ObservableObject { @Published var selectedMode: ProjectWorkspaceMode = .tasks @Published var tasks: [CodMateTask] = [] // Task title/description generation state @Published var isGeneratingTitleDescription: Bool = false @Published var generatingTaskId: UUID? = nil // Temporary edit state for generated content @Published var generatedTaskTitle: String? = nil @Published var generatedTaskDescription: String? = nil private let tasksStore: TasksStore private let sessionListViewModel: SessionListViewModel private let contextTreeshaker = ContextTreeshaker() init(tasksStore: TasksStore = TasksStore(), sessionListViewModel: SessionListViewModel) { self.tasksStore = tasksStore self.sessionListViewModel = sessionListViewModel } // MARK: - Task Management func loadTasks(for projectId: String) async { let loaded = await tasksStore.listTasks(for: projectId) await MainActor.run { self.tasks = loaded } } func createTask(title: String, description: String?, projectId: String) async { let task = CodMateTask( title: title, description: description, projectId: projectId ) await tasksStore.upsertTask(task) await loadTasks(for: projectId) } func updateTask(_ task: CodMateTask) async { // Enforce 0/1 membership: a session can belong to at most one task var normalized = task // Deduplicate session IDs within this task let uniqueIds = Array(Set(normalized.sessionIds)) normalized.sessionIds = uniqueIds let projectId = normalized.projectId let idsSet = Set(uniqueIds) // Remove these sessions from all other tasks in the same project for var other in tasks where other.id != normalized.id && other.projectId == projectId { let filtered = other.sessionIds.filter { !idsSet.contains($0) } if filtered != other.sessionIds { other.sessionIds = filtered await tasksStore.upsertTask(other) } } await tasksStore.upsertTask(normalized) await loadTasks(for: projectId) } func deleteTask(_ taskId: UUID, projectId: String) async { await tasksStore.deleteTask(id: taskId) await loadTasks(for: projectId) } func assignSessionsToTask(_ sessionIds: [String], taskId: UUID?) async { await tasksStore.assignSessions(sessionIds, to: taskId) // Reload tasks to reflect the changes if let task = tasks.first(where: { $0.id == taskId }) { await loadTasks(for: task.projectId) } } func addContextToTask(_ item: ContextItem, taskId: UUID) async { await tasksStore.addContextItem(item, to: taskId) if let task = tasks.first(where: { $0.id == taskId }) { await loadTasks(for: task.projectId) } } func removeContextFromTask(_ contextId: UUID, taskId: UUID) async { await tasksStore.removeContextItem(id: contextId, from: taskId) if let task = tasks.first(where: { $0.id == taskId }) { await loadTasks(for: task.projectId) } } // MARK: - Shared Task Context /// Regenerates the shared context file for the given task. /// The file is written to ~/.codmate/tasks/context-.md and contains a /// compact markdown snapshot of the most recent sessions under this task. func syncTaskContext(taskId: UUID, maxSessions: Int = 5) async -> URL? { // Prefer in-memory snapshot; fall back to store when needed let task: CodMateTask if let cached = tasks.first(where: { $0.id == taskId }) { task = cached } else if let loaded = await tasksStore.getTask(id: taskId) { task = loaded } else { return nil } // Resolve sessions for this task from the global list let allSessions = sessionListViewModel.allSessions let sessionsForTask = allSessions.filter { task.sessionIds.contains($0.id) } let sortedSessions = sessionsForTask.sorted { lhs, rhs in let lDate = lhs.lastUpdatedAt ?? lhs.startedAt let rDate = rhs.lastUpdatedAt ?? rhs.startedAt return lDate < rDate } let limited = Array(sortedSessions.suffix(maxSessions)) // Build slim markdown using the same engine as the legacy New With Context flow… var options = TreeshakeOptions() let kinds = sessionListViewModel.preferences.markdownVisibleKinds options.visibleKinds = kinds options.includeReasoning = kinds.contains(.reasoning) options.includeToolSummary = kinds.contains(.infoOther) let body: String if limited.isEmpty { body = "_No sessions available for this task yet._" } else { body = await contextTreeshaker.generateMarkdown(for: limited, options: options) } var headerLines: [String] = [ "# Task: \(task.effectiveTitle)", "", "- Updated: \(Date().formatted(date: .abbreviated, time: .shortened))", "- Project: \(task.projectId)", "- Status: \(task.status.displayName)" ] if let desc = task.effectiveDescription { headerLines.append("- Description: \(desc)") } let sessionList = task.sessionIds.joined(separator: ", ") if !sessionList.isEmpty { headerLines.append("- Sessions: \(sessionList)") } headerLines.append("") if !sortedSessions.isEmpty { headerLines.append("## Sessions in this Task") headerLines.append("") for session in sortedSessions { headerLines.append("- \(session.effectiveTitle)") if let rawComment = session.userComment? .trimmingCharacters(in: .whitespacesAndNewlines), !rawComment.isEmpty { let snippet = rawComment.count > 200 ? String(rawComment.prefix(200)) + "…" : rawComment headerLines.append(" - Note: \(snippet)") } else if let rawInstructions = session.instructions? .trimmingCharacters(in: .whitespacesAndNewlines), !rawInstructions.isEmpty { let snippet = rawInstructions.count > 200 ? String(rawInstructions.prefix(200)) + "…" : rawInstructions headerLines.append(" - Instructions: \(snippet)") } } headerLines.append("") } headerLines.append("## Shared Context") headerLines.append("") let content = (headerLines + [body]).joined(separator: "\n") let fm = FileManager.default let paths = TasksStore.Paths.default(fileManager: fm) let root = paths.root do { try fm.createDirectory(at: root, withIntermediateDirectories: true) let url = root.appendingPathComponent("context-\(taskId.uuidString).md", isDirectory: false) try content.write(to: url, atomically: true, encoding: .utf8) return url } catch { return nil } } // MARK: - Task With Sessions func enrichTasksWithSessions() -> [TaskWithSessions] { let allSessions = sessionListViewModel.allSessions return tasks.map { task in let sessions = allSessions.filter { task.sessionIds.contains($0.id) } // Keep session ordering consistent with the main list // by reusing the current sort order. let sorted = sessionListViewModel.sortOrder.sort( sessions, visibleKinds: sessionListViewModel.preferences.timelineVisibleKinds ) return TaskWithSessions(task: task, sessions: sorted) } } func getSessionsForTask(_ taskId: UUID) -> [SessionSummary] { guard let task = tasks.first(where: { $0.id == taskId }) else { return [] } let allSessions = sessionListViewModel.allSessions return allSessions.filter { task.sessionIds.contains($0.id) } } // MARK: - Overview Statistics func getProjectStatistics(for projectId: String) -> ProjectStatistics { let projectSessions = sessionListViewModel.allSessions.filter { session in sessionListViewModel.projectIdForSession(session.id) == projectId } let totalDuration = projectSessions.reduce(0) { $0 + $1.duration } let totalTokens = projectSessions.reduce(0) { $0 + $1.actualTotalTokens } let totalEvents = projectSessions.reduce(0) { $0 + $1.eventCount } let projectTasks = tasks.filter { $0.projectId == projectId } let completedTasks = projectTasks.filter { $0.status == .completed }.count let inProgressTasks = projectTasks.filter { $0.status == .inProgress }.count let pendingTasks = projectTasks.filter { $0.status == .pending }.count return ProjectStatistics( totalSessions: projectSessions.count, totalTasks: projectTasks.count, completedTasks: completedTasks, inProgressTasks: inProgressTasks, pendingTasks: pendingTasks, totalDuration: totalDuration, totalTokens: totalTokens, totalEvents: totalEvents ) } } struct ProjectStatistics { let totalSessions: Int let totalTasks: Int let completedTasks: Int let inProgressTasks: Int let pendingTasks: Int let totalDuration: TimeInterval let totalTokens: Int let totalEvents: Int var taskCompletionRate: Double { guard totalTasks > 0 else { return 0 } return Double(completedTasks) / Double(totalTasks) } var averageSessionDuration: TimeInterval { guard totalSessions > 0 else { return 0 } return totalDuration / Double(totalSessions) } var averageTokensPerSession: Double { guard totalSessions > 0 else { return 0 } return Double(totalTokens) / Double(totalSessions) } } ================================================ FILE: models/RefreshRequest.swift ================================================ import Foundation enum RefreshRequestKind: String { case context case global } enum RefreshRequest { static let userInfoKey = "refreshKind" static func userInfo(for kind: RefreshRequestKind) -> [AnyHashable: Any] { [userInfoKey: kind.rawValue] } static func kind(from userInfo: [AnyHashable: Any]?) -> RefreshRequestKind { guard let userInfo, let raw = userInfo[userInfoKey] as? String, let kind = RefreshRequestKind(rawValue: raw) else { return .context } return kind } } ================================================ FILE: models/ReviewPanelState.swift ================================================ import Foundation // Lightweight, per-session UI state for the Review (Git Changes) panel. // Keeps tree expansion/selection, view mode, and in-progress commit message. struct ReviewPanelState: Equatable { enum Mode: Equatable { case diff case browser case graph } // Legacy combined set (pre-branching); still read for backward restore. var expandedDirs: Set = [] // New: independent expansion for staged/unstaged trees var expandedDirsStaged: Set = [] var expandedDirsUnstaged: Set = [] var selectedPath: String? = nil // true = staged side; false = unstaged; nil = default (unstaged) var selectedSideStaged: Bool? = nil var showPreview: Bool = false var commitMessage: String = "" var mode: Mode = .diff var expandedDirsBrowser: Set = [] // Whether the Git Graph view is visible in the right detail area. // This is a lightweight UI flag; it is safe if not restored. // Kept here to allow persistence across app runs if desired. var showGraph: Bool = false } extension ReviewPanelState.Mode { mutating func toggle() { self = (self == .diff) ? .browser : .diff } } ================================================ FILE: models/SessionEvent.swift ================================================ import Foundation struct SessionRow: Decodable { let timestamp: Date let kind: Kind enum CodingKeys: String, CodingKey { case timestamp case type case payload case message } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) timestamp = try container.decode(Date.self, forKey: .timestamp) let type = try container.decode(String.self, forKey: .type) switch type { case "session_meta": let payload = try container.decode(SessionMetaPayload.self, forKey: .payload) kind = .sessionMeta(payload) case "turn_context": let payload = try container.decode(TurnContextPayload.self, forKey: .payload) kind = .turnContext(payload) case "event_msg": let payload = try container.decode(EventMessagePayload.self, forKey: .payload) kind = .eventMessage(payload) case "response_item": let payload = try container.decode(ResponseItemPayload.self, forKey: .payload) kind = .responseItem(payload) case "assistant": // assistant messages use "message" field instead of "payload" let message = try container.decode(AssistantMessage.self, forKey: .message) kind = .assistantMessage(AssistantMessagePayload(message: message)) default: let payload = try container.decode(JSONValue.self, forKey: .payload) kind = .unknown(type: type, payload: payload) } } enum Kind { case sessionMeta(SessionMetaPayload) case turnContext(TurnContextPayload) case eventMessage(EventMessagePayload) case responseItem(ResponseItemPayload) case assistantMessage(AssistantMessagePayload) case unknown(type: String, payload: JSONValue) } } struct SessionMetaPayload: Decodable { let id: String let timestamp: Date let cwd: String let originator: String let cliVersion: String let instructions: String? enum CodingKeys: String, CodingKey { case id case timestamp case cwd case originator case cliVersion = "cli_version" case instructions } } struct TurnContextPayload: Decodable { let cwd: String? let approvalPolicy: String? let model: String? let effort: String? let summary: String? enum CodingKeys: String, CodingKey { case cwd case approvalPolicy = "approval_policy" case model case effort case summary } } struct EventMessagePayload: Decodable { let type: String let message: String? let kind: String? let text: String? let reason: String? let info: JSONValue? let rateLimits: JSONValue? let images: [String]? enum CodingKeys: String, CodingKey { case type case message case kind case text case reason case info case rateLimits = "rate_limits" case images } init( type: String, message: String?, kind: String?, text: String?, reason: String?, info: JSONValue?, rateLimits: JSONValue?, images: [String]? = nil ) { self.type = type self.message = message self.kind = kind self.text = text self.reason = reason self.info = info self.rateLimits = rateLimits self.images = images } } struct ResponseItemPayload: Decodable { let type: String let status: String? let callID: String? let name: String? let content: [ResponseContentBlock]? let summary: [ResponseSummaryItem]? let encryptedContent: String? let role: String? let arguments: JSONValue? let input: JSONValue? let output: JSONValue? let ghostCommit: JSONValue? enum CodingKeys: String, CodingKey { case type case status case callID = "call_id" case name case content case summary case encryptedContent = "encrypted_content" case role case arguments case input case output case ghostCommit = "ghost_commit" } } struct ResponseContentBlock: Decodable { let type: String let text: String? } struct ResponseSummaryItem: Decodable { let type: String let text: String? } struct MessageUsage: Decodable { let inputTokens: Int? let outputTokens: Int? let cacheReadInputTokens: Int? let cacheCreationInputTokens: Int? enum CodingKeys: String, CodingKey { case inputTokens = "input_tokens" case outputTokens = "output_tokens" case cacheReadInputTokens = "cache_read_input_tokens" case cacheCreationInputTokens = "cache_creation_input_tokens" } /// Total tokens according to Claude Code billing formula: /// input_tokens + output_tokens + cache_read_input_tokens + cache_creation_input_tokens var totalTokens: Int { (inputTokens ?? 0) + (outputTokens ?? 0) + (cacheReadInputTokens ?? 0) + (cacheCreationInputTokens ?? 0) } } struct AssistantMessage: Decodable { let id: String? let type: String? let role: String? let usage: MessageUsage? } struct AssistantMessagePayload: Decodable { let message: AssistantMessage? } enum JSONValue: Decodable { case string(String) case number(Double) case bool(Bool) case object([String: JSONValue]) case array([JSONValue]) case null var objectValue: [String: JSONValue]? { if case let .object(dict) = self { return dict } return nil } var intValue: Int? { switch self { case .number(let value): if value.isFinite { return Int(value) } return nil case .string(let string): return Int(string.trimmingCharacters(in: .whitespacesAndNewlines)) case .bool(let flag): return flag ? 1 : 0 default: return nil } } init(from decoder: Decoder) throws { // Try keyed container first (for objects) if let keyedContainer = try? decoder.container(keyedBy: DynamicCodingKey.self) { var dict: [String: JSONValue] = [:] for key in keyedContainer.allKeys { let value = try keyedContainer.decode(JSONValue.self, forKey: key) dict[key.stringValue] = value } self = .object(dict) return } // Try unkeyed container (for arrays) if var arrayContainer = try? decoder.unkeyedContainer() { var items: [JSONValue] = [] while !arrayContainer.isAtEnd { let value = try arrayContainer.decode(JSONValue.self) items.append(value) } self = .array(items) return } // Finally try single value container (for primitives) if 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(Double.self) { self = .number(value) } else if let value = try? container.decode(String.self) { self = .string(value) } else { self = .null } return } self = .null } } struct DynamicCodingKey: CodingKey { var stringValue: String var intValue: Int? init?(stringValue: String) { self.stringValue = stringValue } init?(intValue: Int) { self.intValue = intValue self.stringValue = "\(intValue)" } } struct SessionSummaryBuilder { private(set) var id: String? private(set) var startedAt: Date? private(set) var lastUpdatedAt: Date? private(set) var cliVersion: String? private(set) var cwd: String? private(set) var originator: String? private(set) var instructions: String? private(set) var model: String? private(set) var approvalPolicy: String? private(set) var userMessageCount: Int = 0 private(set) var assistantMessageCount: Int = 0 private(set) var toolInvocationCount: Int = 0 private(set) var responseCounts: [String: Int] = [:] private(set) var turnContextCount: Int = 0 private(set) var messageTypeCounts: [MessageVisibilityKind: Int] = [:] private var seenToolCallIDs: Set = [] private(set) var totalTokens: Int = 0 private(set) var tokenInput: Int = 0 private(set) var tokenOutput: Int = 0 private(set) var tokenCacheRead: Int = 0 private(set) var tokenCacheCreation: Int = 0 private(set) var eventCount: Int = 0 private(set) var lineCount: Int = 0 private(set) var fileSizeBytes: UInt64? private(set) var source: SessionSource = .codexLocal var parseLevel: SessionSummary.ParseLevel? = nil var hasEssentialMetadata: Bool { id != nil && startedAt != nil && cliVersion != nil && cwd != nil } mutating func setFileSize(_ size: UInt64?) { fileSizeBytes = size } mutating func setSource(_ source: SessionSource) { self.source = source } mutating func seedTotalTokens(_ total: Int) { if total > totalTokens { totalTokens = total } } mutating func seedLastUpdated(_ date: Date) { if let existing = lastUpdatedAt { if date > existing { lastUpdatedAt = date } } else { lastUpdatedAt = date } } mutating func accumulateIncrementalTokens( input: Int?, output: Int?, cacheRead: Int?, cacheCreation: Int? ) { if let value = input, value > 0 { tokenInput += value } if let value = output, value > 0 { tokenOutput += value } if let value = cacheRead, value > 0 { tokenCacheRead += value } if let value = cacheCreation, value > 0 { tokenCacheCreation += value } } mutating func seedTokenSnapshot( input: Int?, output: Int?, cacheRead: Int?, cacheCreation: Int? ) { if let value = input, value > tokenInput { tokenInput = value } if let value = output, value > tokenOutput { tokenOutput = value } if let value = cacheRead, value > tokenCacheRead { tokenCacheRead = value } if let value = cacheCreation, value > tokenCacheCreation { tokenCacheCreation = value } } func currentTokenBreakdown() -> SessionTokenBreakdown? { let input = max(tokenInput, 0) let output = max(tokenOutput, 0) let cacheRead = max(tokenCacheRead, 0) let cacheCreation = max(tokenCacheCreation, 0) if input == 0 && output == 0 && cacheRead == 0 && cacheCreation == 0 { return nil } return SessionTokenBreakdown( input: input, output: output, cacheRead: cacheRead, cacheCreation: cacheCreation) } mutating func observe(_ row: SessionRow) { if case let .eventMessage(payload) = row.kind, payload.type.lowercased() == "turn_boundary" { return } lineCount += 1 seedLastUpdated(row.timestamp) switch row.kind { case let .sessionMeta(payload): id = payload.id startedAt = payload.timestamp cwd = payload.cwd originator = payload.originator cliVersion = payload.cliVersion if let instructionsText = payload.instructions, instructions == nil { instructions = instructionsText } case let .turnContext(payload): turnContextCount += 1 if let model = payload.model { self.model = model } if let approval = payload.approvalPolicy { approvalPolicy = approval } if let cwd = payload.cwd, self.cwd == nil { self.cwd = cwd } case let .eventMessage(payload): eventCount += 1 let type = payload.type if type == "user_message" { userMessageCount += 1 } else if type == "agent_message" { assistantMessageCount += 1 } else if type == "token_count" { handleTokenCountEvent(message: payload.message ?? payload.text, info: payload.info) } case let .responseItem(payload): eventCount += 1 responseCounts[payload.type, default: 0] += 1 if payload.type == "message" { assistantMessageCount += 1 } // Only count invocation events themselves; ignore corresponding *_output entries // to avoid double-counting. if payload.type == "function_call" || payload.type == "custom_tool_call" || payload.type == "tool_call" { toolInvocationCount += 1 } case let .assistantMessage(payload): // Accumulate tokens from all assistant messages according to Claude Code formula: // total = input_tokens + output_tokens + cache_read_input_tokens + cache_creation_input_tokens assistantMessageCount += 1 if let usage = payload.message?.usage { totalTokens += usage.totalTokens accumulateIncrementalTokens( input: usage.inputTokens, output: usage.outputTokens, cacheRead: usage.cacheReadInputTokens, cacheCreation: usage.cacheCreationInputTokens) } case .unknown: lineCount += 0 } if let classified = TimelineEventClassifier.classify(row: row) { if classified.isToolLike, let callID = classified.callID, !callID.isEmpty { if !seenToolCallIDs.insert(callID).inserted { return } } messageTypeCounts[classified.kind, default: 0] += 1 } } @discardableResult private mutating func handleTokenCountEvent(message: String?, info: JSONValue?) -> Bool { var handled = false if let snapshot = SessionTokenSnapshot.from(info: info) { applyTokenSnapshot(snapshot) handled = true } if let snapshot = SessionTokenSnapshot.from(message: message) { applyTokenSnapshot(snapshot) handled = true } return handled } private mutating func applyTokenSnapshot(_ snapshot: SessionTokenSnapshot) { if let total = snapshot.total { totalTokens = max(totalTokens, total) } seedTokenSnapshot( input: snapshot.input, output: snapshot.output, cacheRead: snapshot.cacheRead, cacheCreation: snapshot.cacheCreation) } mutating func setModelFallback(_ fallback: String) { if model == nil || model?.isEmpty == true { model = fallback } } func build(for url: URL) -> SessionSummary? { guard let id, let startedAt, let cliVersion, let originator, let cwd else { return nil } var s = SessionSummary( id: id, fileURL: url, fileSizeBytes: fileSizeBytes, startedAt: startedAt, endedAt: lastUpdatedAt, activeDuration: nil, cliVersion: cliVersion, cwd: cwd, originator: originator, instructions: instructions, model: model, approvalPolicy: approvalPolicy, userMessageCount: userMessageCount, assistantMessageCount: assistantMessageCount, toolInvocationCount: toolInvocationCount, responseCounts: responseCounts, turnContextCount: turnContextCount, messageTypeCounts: messageTypeCounts.isEmpty ? nil : messageTypeCounts.reduce(into: [:]) { $0[$1.key.rawValue] = $1.value }, totalTokens: totalTokens, tokenBreakdown: currentTokenBreakdown(), eventCount: eventCount, lineCount: lineCount, lastUpdatedAt: lastUpdatedAt, source: source, remotePath: nil ) s.parseLevel = parseLevel return s } } extension SessionRow { init(timestamp: Date, kind: SessionRow.Kind) { self.timestamp = timestamp self.kind = kind } } struct SessionTokenSnapshot { var input: Int? var output: Int? var cacheRead: Int? var cacheCreation: Int? var total: Int? init(input: Int? = nil, output: Int? = nil, cacheRead: Int? = nil, cacheCreation: Int? = nil, total: Int? = nil) { self.input = input self.output = output self.cacheRead = cacheRead self.cacheCreation = cacheCreation self.total = total } var hasValues: Bool { return input != nil || output != nil || cacheRead != nil || cacheCreation != nil || total != nil } var breakdown: SessionTokenBreakdown? { let inputValue = input ?? 0 let outputValue = output ?? 0 let cacheReadValue = cacheRead ?? 0 let cacheCreationValue = cacheCreation ?? 0 if inputValue == 0 && outputValue == 0 && cacheReadValue == 0 && cacheCreationValue == 0 { return nil } return SessionTokenBreakdown( input: inputValue, output: outputValue, cacheRead: cacheReadValue, cacheCreation: cacheCreationValue) } mutating func merge(_ other: SessionTokenSnapshot) { if let value = other.input { input = max(input ?? 0, value) } if let value = other.output { output = max(output ?? 0, value) } if let value = other.cacheRead { cacheRead = max(cacheRead ?? 0, value) } if let value = other.cacheCreation { cacheCreation = max(cacheCreation ?? 0, value) } if let value = other.total { total = max(total ?? 0, value) } } static func from(info: JSONValue?) -> SessionTokenSnapshot? { guard let info, let dict = info.objectValue else { return nil } var snapshot = SessionTokenSnapshot() // Use total_token_usage (cumulative) instead of last_token_usage (incremental) // Codex/Claude log files have both, but we want the cumulative total if let tokenUsage = dict["total_token_usage"]?.objectValue { snapshot.merge(dict: tokenUsage) } else if let tokenUsage = dict["token_usage"]?.objectValue { // Fallback for other formats that only have token_usage snapshot.merge(dict: tokenUsage) } else { // Fallback to top-level keys snapshot.merge(dict: dict) } if snapshot.total == nil, let total = dict["total"]?.intValue { snapshot.total = total } return snapshot.hasValues ? snapshot : nil } static func from(message: String?) -> SessionTokenSnapshot? { guard let text = message?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return nil } var snapshot = SessionTokenSnapshot() let parts = text.split(separator: ",") for part in parts { let pair = part.split(separator: ":", maxSplits: 1) guard pair.count == 2 else { continue } let key = pair[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let valueString = pair[1].trimmingCharacters(in: .whitespacesAndNewlines) guard let value = Int(valueString) else { continue } snapshot.assign(key: key, value: value) } if !snapshot.hasValues { let lower = text.lowercased() if let range = lower.range(of: "total:") { let substring = lower[range.upperBound...] let digits = substring.prefix(while: { $0.isWhitespace || $0.isNumber }) if let value = Int(digits.trimmingCharacters(in: .whitespaces)) { snapshot.total = value } } } return snapshot.hasValues ? snapshot : nil } mutating func merge(dict: [String: JSONValue]) { for (key, value) in dict { guard let number = value.intValue else { continue } assign(key: key, value: number) } } mutating func assign(key: String, value: Int) { let normalized = key .replacingOccurrences(of: "_", with: "") .replacingOccurrences(of: " ", with: "") let lower = normalized.lowercased() if lower.contains("total") { total = value return } if lower.contains("cache") || lower.contains("cached") { if lower.contains("creation") { cacheCreation = value } else { cacheRead = value } return } // Handle input tokens (but not cached_input_tokens which was handled above) if lower.contains("input") && !lower.contains("cache") && !lower.contains("cached") { input = value return } // Handle output tokens - use max to avoid overwriting with reasoning_output_tokens // since output_tokens usually includes reasoning_output_tokens already if lower.contains("output") || lower.contains("reasoning") { output = max(output ?? 0, value) return } } } ================================================ FILE: models/SessionLaunchProvider.swift ================================================ import Foundation struct SessionLaunchProvider: Identifiable, Hashable, Sendable { let sessionSource: SessionSource var id: String { sessionSource.launchIdentifier } } extension SessionSource { var launchIdentifier: String { switch self { case .codexLocal: return "codex-local" case .claudeLocal: return "claude-local" case .geminiLocal: return "gemini-local" case .codexRemote(let host): return "codex-remote-\(host)" case .claudeRemote(let host): return "claude-remote-\(host)" case .geminiRemote(let host): return "gemini-remote-\(host)" } } } ================================================ FILE: models/SessionListViewModel+Commands.swift ================================================ import AppKit import Foundation @MainActor extension SessionListViewModel { func resume(session: SessionSummary) async -> Result { do { let cwd = resolvedWorkingDirectory(for: session) let codexHome = codexHomeOverride(for: session) let result = try await actions.resume( session: session, executableURL: preferredExecutableURL(for: session.source), options: preferences.resumeOptions, workingDirectory: cwd, codexHomeOverride: codexHome) return .success(result) } catch { return .failure(error) } } private func preferredExecutableURL(for source: SessionSource) -> URL { if let override = preferences.resolvedCommandOverrideURL(for: source.baseKind) { return override } return URL(fileURLWithPath: "/usr/bin/env") } private func preferredExecutablePath(for kind: SessionSource.Kind) -> String { preferences.preferredExecutablePath(for: kind) } private var commandGenerator: SessionCommandGenerator { SessionCommandGenerator(actions: actions) } private func preferredExternalTerminalProfile() -> ExternalTerminalProfile? { ExternalTerminalProfileStore.shared.resolvePreferredProfile( id: preferences.defaultResumeExternalAppId ) } var shouldCopyCommandsToClipboard: Bool { preferences.defaultResumeCopyToClipboard } func copyResumeCommands(session: SessionSummary) { let cwd = resolvedWorkingDirectory(for: session) let codexHome = codexHomeOverride(for: session) actions.copyResumeCommands( session: session, executableURL: preferredExecutableURL(for: session.source), options: preferences.resumeOptions, simplifiedForExternal: true, workingDirectory: cwd, codexHome: codexHome ) } private func warpResumeTitle(for session: SessionSummary) -> String? { if let title = session.userTitle, let sanitized = warpSanitizedTitle(from: title) { return sanitized } let defaultScope = warpScopeCandidate(for: session, project: projectForSession(session)) let defaultValue = WarpTitleBuilder.newSessionLabel(scope: defaultScope, task: taskTitle(for: session)) return resolveWarpTitleInput(defaultValue: defaultValue, forcePrompt: true) } private func projectForSession(_ session: SessionSummary) -> Project? { guard let pid = projectIdForSession(session.id) else { return nil } return projects.first(where: { $0.id == pid }) } private func codexHomeOverride(for project: Project?) -> String? { guard let project, let dir = project.directory?.trimmingCharacters(in: .whitespacesAndNewlines), !dir.isEmpty else { return nil } guard ProjectExtensionsStore.requiresCodexHome(projectId: project.id) else { return nil } let codexDir = URL(fileURLWithPath: dir, isDirectory: true) .appendingPathComponent(".codex", isDirectory: true) guard FileManager.default.fileExists(atPath: codexDir.path) else { return nil } return codexDir.path } private func codexHomeOverride(for session: SessionSummary) -> String? { guard session.source.baseKind == .codex else { return nil } return codexHomeOverride(for: projectForSession(session)) } @discardableResult func copyResumeCommandsRespectingProject( session: SessionSummary, destinationApp: ExternalTerminalProfile? = nil ) -> Bool { let cwd = resolvedWorkingDirectory(for: session) let codexHome = codexHomeOverride(for: session) var warpHint: String? = nil if destinationApp?.usesWarpCommands == true { guard let hint = warpResumeTitle(for: session) else { return false } warpHint = hint } if session.source != .codexLocal { actions.copyResumeCommands( session: session, executableURL: preferredExecutableURL(for: session.source), options: preferences.resumeOptions, simplifiedForExternal: true, destinationApp: destinationApp, titleHint: warpHint, workingDirectory: cwd, codexHome: codexHome ) return true } if let pid = projectIdForSession(session.id), let p = projects.first(where: { $0.id == pid }), p.profile != nil || (p.profileId?.isEmpty == false) { actions.copyResumeUsingProjectProfileCommands( session: session, project: p, executableURL: preferredExecutableURL(for: .codexLocal), options: preferences.resumeOptions, destinationApp: destinationApp, titleHint: warpHint, codexHome: codexHome) } else { actions.copyResumeCommands( session: session, executableURL: preferredExecutableURL(for: .codexLocal), options: preferences.resumeOptions, simplifiedForExternal: true, destinationApp: destinationApp, titleHint: warpHint, workingDirectory: cwd, codexHome: codexHome) } return true } @discardableResult func copyResumeCommandsIfEnabled( session: SessionSummary, destinationApp: ExternalTerminalProfile? = nil ) -> Bool { guard preferences.defaultResumeCopyToClipboard else { return true } return copyResumeCommandsRespectingProject(session: session, destinationApp: destinationApp) } func openInTerminal(session: SessionSummary) -> Bool { let cwd = resolvedWorkingDirectory(for: session) let codexHome = codexHomeOverride(for: session) return actions.openInTerminal( session: session, executableURL: preferredExecutableURL(for: session.source), options: preferences.resumeOptions, workingDirectory: cwd, codexHome: codexHome) } func buildResumeCommands(session: SessionSummary) -> String { let cwd = resolvedWorkingDirectory(for: session) let codexHome = codexHomeOverride(for: session) // For embedded terminal, skip cd command since terminal is already initialized // to the correct working directory via worktreePath parameter return commandGenerator.embeddedResume( session: session, executableURL: preferredExecutableURL(for: session.source), options: preferences.resumeOptions, workingDirectory: cwd, codexHome: codexHome, includeCd: false ) } func buildEmbeddedNewSessionCommands( session: SessionSummary, initialPrompt: String? = nil, projectOverride: Project? = nil ) -> String { let project = projectOverride ?? projectIdForSession(session.id).flatMap { pid in projects.first(where: { $0.id == pid }) } let codexHome = codexHomeOverride(for: session) return commandGenerator.embeddedNew( session: session, project: project, executableURL: preferredExecutableURL(for: session.source), options: preferences.resumeOptions, initialPrompt: initialPrompt, codexHome: codexHome ) } func buildEmbeddedNewProjectCommands(project: Project) -> String { commandGenerator.embeddedNewProject( project: project, executableURL: preferredExecutableURL(for: .codexLocal), options: preferences.resumeOptions, codexHome: codexHomeOverride(for: project) ) } func buildExternalResumeCommands(session: SessionSummary) -> String { let cwd = resolvedWorkingDirectory(for: session) let codexHome = codexHomeOverride(for: session) return actions.buildExternalResumeCommands( session: session, executableURL: preferredExecutableURL(for: session.source), options: preferences.resumeOptions, workingDirectory: cwd, codexHome: codexHome ) } func buildResumeCLIInvocation(session: SessionSummary) -> String { return commandGenerator.inlineResume( session: session, executablePath: preferredExecutablePath(for: session.source.baseKind), options: preferences.resumeOptions, codexHome: codexHomeOverride(for: session) ) } // MARK: - Embedded CLI Console helpers (dev) func buildResumeCLIArgs(session: SessionSummary) -> [String] { actions.buildResumeArguments(session: session, options: preferences.resumeOptions) } func buildNewSessionCLIArgs(session: SessionSummary) -> [String] { actions.buildNewSessionArguments(session: session, options: preferences.resumeOptions) } func buildResumeCLIInvocationRespectingProject(session: SessionSummary) -> String { let project = projectIdForSession(session.id).flatMap { pid in projects.first(where: { $0.id == pid }) } let codexHome = project.map { codexHomeOverride(for: $0) } ?? codexHomeOverride(for: session) return commandGenerator.inlineResume( session: session, project: project, executablePath: preferredExecutablePath(for: session.source.baseKind), options: preferences.resumeOptions, codexHome: codexHome ) } func copyNewSessionCommands(session: SessionSummary) { actions.copyNewSessionCommands( session: session, executableURL: preferredExecutableURL(for: session.source), options: preferences.resumeOptions, codexHome: codexHomeOverride(for: session) ) } func buildNewSessionCLIInvocation(session: SessionSummary) -> String { commandGenerator.inlineNew( session: session, executablePath: preferredExecutablePath(for: session.source.baseKind), options: preferences.resumeOptions, codexHome: codexHomeOverride(for: session) ) } func openNewSession(session: SessionSummary) -> Bool { actions.openNewSession( session: session, executableURL: preferredExecutableURL(for: session.source), options: preferences.resumeOptions, codexHome: codexHomeOverride(for: session) ) } func buildNewProjectCLIInvocation(project: Project) -> String { commandGenerator.inlineNewProject( project: project, executablePath: preferredExecutablePath(for: .codex), options: preferences.resumeOptions, codexHome: codexHomeOverride(for: project) ) } func buildClaudeProjectInvocation(project: Project) -> String { commandGenerator.projectClaudeInvocation( project: project, executablePath: preferredExecutablePath(for: .claude), options: preferences.resumeOptions, fallbackModel: preferences.claudeFallbackModel ) } func buildGeminiProjectInvocation() -> String { commandGenerator.projectGeminiInvocation( executablePath: preferredExecutablePath(for: .gemini), options: preferences.resumeOptions ) } @discardableResult func copyNewProjectCommands(project: Project, destinationApp: ExternalTerminalProfile? = nil) -> Bool { var warpHint: String? = nil if destinationApp?.usesWarpCommands == true { let base = warpTitleForProject(project) guard let resolved = resolveWarpTitleInput(defaultValue: base) else { return false } warpHint = resolved } actions.copyNewProjectCommands( project: project, executableURL: preferredExecutableURL(for: .codexLocal), options: preferences.resumeOptions, destinationApp: destinationApp, titleHint: warpHint, codexHome: codexHomeOverride(for: project) ) return true } /// Unified Project "New Session" entry. Respects embedded/external preference /// to reduce branching between Sidebar and Detail flows. func newSession(project: Project) { let embeddedPreferred = preferences.defaultResumeUseEmbeddedTerminal NSLog( "📌 [SessionListVM] newSession(project:%@) embeddedPreferred=%@ useEmbeddedCLIConsole=%@", project.id, embeddedPreferred ? "YES" : "NO", preferences.useEmbeddedCLIConsole ? "YES" : "NO" ) // Record intent so the new session can be auto-assigned to this project recordIntentForProjectNew(project: project) if preferences.defaultResumeUseEmbeddedTerminal { // Embedded terminal path: signal ContentView to start an embedded // shell anchored to this project and perform targeted refresh. pendingEmbeddedProjectNew = project setIncrementalHintForCodexToday() // Also broadcast a notification for robustness across views NotificationCenter.default.post( name: .codMateStartEmbeddedNewProject, object: nil, userInfo: ["projectId": project.id] ) Task { await SystemNotifier.shared.notify(title: "CodMate", body: "Starting embedded New…") } return } // Resolve preferred external terminal and open at the project directory guard let profile = preferredExternalTerminalProfile() else { return } let dir: String = { let d = (project.directory ?? "").trimmingCharacters(in: .whitespacesAndNewlines) return d.isEmpty ? NSHomeDirectory() : d }() // External terminal path: copy command and open preferred terminal. guard copyNewProjectCommands(project: project, destinationApp: profile) else { return } if !profile.isNone { let cmd = profile.supportsCommandResolved ? buildNewProjectCLIInvocation(project: project) : nil if profile.isTerminal { _ = openAppleTerminal(at: dir) } else { openPreferredTerminalViaScheme(profile: profile, directory: dir, command: cmd) } } // Friendly nudge so users know the command was placed on clipboard if preferences.commandCopyNotificationsEnabled { Task { await SystemNotifier.shared.notify( title: "CodMate", body: "Command copied. Paste it in the opened terminal.") } } // Event-driven incremental refresh hint + proactive targeted refresh for today setIncrementalHintForCodexToday() Task { await self.refreshIncrementalForNewCodexToday() } } /// Build CLI invocation, respecting project profile if applicable. /// - Parameters: /// - session: Session to launch. /// - initialPrompt: Optional initial prompt text to pass to CLI. /// - Returns: Complete CLI command string. func buildNewSessionCLIInvocationRespectingProject( session: SessionSummary, initialPrompt: String? = nil, projectOverride: Project? = nil ) -> String { let project = projectOverride ?? projectIdForSession(session.id).flatMap { pid in projects.first(where: { $0.id == pid }) } let codexHome = project.map { codexHomeOverride(for: $0) } ?? codexHomeOverride(for: session) return commandGenerator.inlineNew( session: session, project: project, executablePath: preferredExecutablePath(for: session.source.baseKind), options: preferences.resumeOptions, initialPrompt: initialPrompt, codexHome: codexHome ) } @discardableResult func copyNewSessionCommandsRespectingProject( session: SessionSummary, destinationApp: ExternalTerminalProfile? = nil, warpTitleOverride: String? = nil, projectOverride: Project? = nil ) -> Bool { let project = projectOverride ?? projectIdForSession(session.id).flatMap { pid in projects.first(where: { $0.id == pid }) } var warpHint: String? = nil if destinationApp?.usesWarpCommands == true { if let override = warpTitleOverride { warpHint = warpSanitizedTitle(from: override) ?? override } else { let base = warpNewSessionTitleHint(for: session, project: project) guard let resolved = resolveWarpTitleInput(defaultValue: base) else { return false } warpHint = resolved } } if session.source == .codexLocal, let project, project.profile != nil || (project.profileId?.isEmpty == false) { actions.copyNewSessionUsingProjectProfileCommands( session: session, project: project, executableURL: preferredExecutableURL(for: session.source), options: preferences.resumeOptions, destinationApp: destinationApp, titleHint: warpHint, codexHome: codexHomeOverride(for: project) ) } else { actions.copyNewSessionCommands( session: session, executableURL: preferredExecutableURL(for: session.source), options: preferences.resumeOptions, destinationApp: destinationApp, titleHint: warpHint, codexHome: codexHomeOverride(for: session) ) } return true } @discardableResult func copyNewSessionCommandsIfEnabled( session: SessionSummary, destinationApp: ExternalTerminalProfile? = nil, initialPrompt: String? = nil, warpTitleOverride: String? = nil, projectOverride: Project? = nil ) -> Bool { guard preferences.defaultResumeCopyToClipboard else { return true } if let initialPrompt { return copyNewSessionCommandsRespectingProject( session: session, destinationApp: destinationApp, initialPrompt: initialPrompt, warpTitleOverride: warpTitleOverride, projectOverride: projectOverride ) } return copyNewSessionCommandsRespectingProject( session: session, destinationApp: destinationApp, warpTitleOverride: warpTitleOverride, projectOverride: projectOverride ) } @discardableResult func copyNewSessionCommandsRespectingProject( session: SessionSummary, destinationApp: ExternalTerminalProfile? = nil, initialPrompt: String, warpTitleOverride: String? = nil, projectOverride: Project? = nil ) -> Bool { let project = projectOverride ?? projectIdForSession(session.id).flatMap { pid in projects.first(where: { $0.id == pid }) } var warpHint: String? = nil if destinationApp?.usesWarpCommands == true { if let override = warpTitleOverride { warpHint = warpSanitizedTitle(from: override) ?? override } else { let base = warpNewSessionTitleHint(for: session, project: project) guard let resolved = resolveWarpTitleInput(defaultValue: base) else { return false } warpHint = resolved } } if session.source == .codexLocal, let project, project.profile != nil || (project.profileId?.isEmpty == false) { actions.copyNewSessionUsingProjectProfileCommands( session: session, project: project, executableURL: preferredExecutableURL(for: session.source), options: preferences.resumeOptions, destinationApp: destinationApp, initialPrompt: initialPrompt, titleHint: warpHint, codexHome: codexHomeOverride(for: project) ) } else { let codexHome = project.map { codexHomeOverride(for: $0) } ?? codexHomeOverride(for: session) let cmd = commandGenerator.inlineNew( session: session, project: project, executablePath: preferredExecutablePath(for: session.source.baseKind), options: preferences.resumeOptions, initialPrompt: initialPrompt, codexHome: codexHome ) let pb = NSPasteboard.general pb.clearContents() if destinationApp?.usesWarpCommands == true, let title = warpHint { let lines = ["#\(title)", cmd] pb.setString(lines.joined(separator: "\n") + "\n", forType: .string) } else { pb.setString(cmd + "\n", forType: .string) } } return true } @discardableResult func copyNewProjectCommandsIfEnabled( project: Project, destinationApp: ExternalTerminalProfile? = nil ) -> Bool { guard preferences.defaultResumeCopyToClipboard else { return true } return copyNewProjectCommands(project: project, destinationApp: destinationApp) } private func warpSanitizedTitle(from raw: String?) -> String? { guard var s = raw else { return nil } s = s.replacingOccurrences(of: "\r", with: " ") s = s.replacingOccurrences(of: "\n", with: " ") s = s.replacingOccurrences(of: "\t", with: " ") s = s.trimmingCharacters(in: .whitespacesAndNewlines) guard !s.isEmpty else { return nil } if s.count > 80 { s = String(s.prefix(80)) } let collapsed = s.split(whereSeparator: { $0.isWhitespace }).joined(separator: "-") return collapsed.isEmpty ? nil : collapsed } private func warpScopeCandidate(for session: SessionSummary, project: Project?) -> String? { if let name = project?.name.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty { return name } if let title = session.userTitle?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { return title } let cwd = FileManager.default.fileExists(atPath: session.cwd) ? session.cwd : session.fileURL.deletingLastPathComponent().path let name = URL(fileURLWithPath: cwd).lastPathComponent let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? session.displayName : trimmed } private func taskTitle(for session: SessionSummary) -> String? { guard let tid = session.taskId else { return nil } return workspaceVM?.tasks.first(where: { $0.id == tid })?.effectiveTitle } private func warpNewSessionTitleHint(for session: SessionSummary, project: Project?) -> String { let scope = warpScopeCandidate(for: session, project: project) let task = taskTitle(for: session) var extras: [String] = [] if session.isRemote, let host = session.remoteHost { extras.append(host) } return WarpTitleBuilder.newSessionLabel(scope: scope, task: task, extras: extras) } private func warpTitleForProject(_ project: Project) -> String { WarpTitleBuilder.newSessionLabel(scope: project.name, task: nil) } private func resolveWarpTitleInput(defaultValue: String, forcePrompt: Bool = false) -> String? { if preferences.promptForWarpTitle || forcePrompt { guard let userInput = WarpTitlePrompt.requestCustomTitle(defaultValue: defaultValue) else { return nil } let trimmed = userInput.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return defaultValue } return warpSanitizedTitle(from: trimmed) ?? defaultValue } return defaultValue } // MARK: - Notification Helpers /// Notify user that command was copied to clipboard, if notifications are enabled. private func notifyCommandCopiedIfEnabled(message: String = "Command copied. Paste it in the opened terminal.") { guard shouldCopyCommandsToClipboard, preferences.commandCopyNotificationsEnabled else { return } Task { await SystemNotifier.shared.notify(title: "CodMate", body: message) } } func openNewSessionRespectingProject(session: SessionSummary) { if session.source == .codexLocal, let pid = projectIdForSession(session.id), let p = projects.first(where: { $0.id == pid }), p.profile != nil || (p.profileId?.isEmpty == false) { _ = actions.openNewSessionUsingProjectProfile( session: session, project: p, executableURL: preferredExecutableURL(for: session.source), options: preferences.resumeOptions, codexHome: codexHomeOverride(for: p) ) } else { _ = actions.openNewSession( session: session, executableURL: preferredExecutableURL(for: session.source), options: preferences.resumeOptions, codexHome: codexHomeOverride(for: session) ) } } // MARK: - Launch New Session From Project (without anchor) /// Launch a new session from a project without requiring an anchor session. /// This is used when right-clicking on a project with no sessions. func launchNewSessionFromProject( project: Project, using source: SessionSource, profile: ExternalTerminalProfile ) { recordIntentForProjectNew(project: project) let dir: String = { let d = (project.directory ?? "").trimmingCharacters(in: .whitespacesAndNewlines) return d.isEmpty ? NSHomeDirectory() : d }() if profile.id == "codmate.embedded" { #if APPSTORE newSession(project: project) #else NotificationCenter.default.post( name: .codMateStartEmbeddedNewProject, object: nil, userInfo: ["projectId": project.id] ) #endif return } // Build command based on source let cmd: String switch source.baseKind { case .codex: cmd = buildNewProjectCLIInvocation(project: project) case .claude: cmd = buildClaudeProjectInvocation(project: project) case .gemini: cmd = buildGeminiProjectInvocation() } guard copyNewProjectCommandsIfEnabled(project: project, destinationApp: profile) else { return } if profile.usesWarpCommands { openPreferredTerminalViaScheme(profile: profile, directory: dir) notifyCommandCopiedIfEnabled() return } if profile.isTerminal { // Create a dummy session for terminal opening let dummySession = SessionSummary( id: UUID().uuidString, fileURL: URL(fileURLWithPath: "/dev/null"), fileSizeBytes: 0, startedAt: Date(), endedAt: nil, activeDuration: nil, cliVersion: "", cwd: dir, originator: "system", instructions: nil, model: nil, approvalPolicy: nil, userMessageCount: 0, assistantMessageCount: 0, toolInvocationCount: 0, responseCounts: [:], turnContextCount: 0, totalTokens: 0, eventCount: 0, lineCount: 0, lastUpdatedAt: Date(), source: source, remotePath: nil ) if !openNewSession(session: dummySession) { _ = openAppleTerminal(at: dir) notifyCommandCopiedIfEnabled() } return } if profile.isNone { notifyCommandCopiedIfEnabled(message: "Command copied.") return } let inlineCmd = profile.supportsCommandResolved ? cmd : nil openPreferredTerminalViaScheme(profile: profile, directory: dir, command: inlineCmd) if !profile.supportsCommandResolved { notifyCommandCopiedIfEnabled() } } // MARK: - Unified Launch New Session (extracted from views) /// Unified method to launch a new session with a given profile. /// This consolidates the duplicate logic from multiple view files. func launchNewSessionWithProfile( session: SessionSummary, using source: SessionSource, profile: ExternalTerminalProfile, workingDirectory: String? = nil, initialPrompt: String? = nil, warpTitle: String? = nil, projectOverride: Project? = nil, embeddedHandler: ((SessionSummary, SessionSource) -> Void)? = nil ) { let target = source == session.source ? session : session.overridingSource(source) recordIntentForDetailNew(anchor: target) let dir = workingDirectory ?? resolvedWorkingDirectory(for: target) // Handle embedded terminal if profile.id == "codmate.embedded" { if let embeddedHandler { embeddedHandler(target, source) } else { EmbeddedSessionNotification.postEmbeddedNewSession(sessionId: target.id, source: source) } return } guard copyNewSessionCommandsIfEnabled( session: target, destinationApp: profile, initialPrompt: initialPrompt, warpTitleOverride: warpTitle, projectOverride: projectOverride ) else { return } // Handle None profile (copy only) if profile.isNone { notifyCommandCopiedIfEnabled() return } // Handle Warp commands if profile.usesWarpCommands { openPreferredTerminalViaScheme(profile: profile, directory: dir) notifyCommandCopiedIfEnabled() return } // Handle Terminal.app if profile.isTerminal { #if APPSTORE _ = copyNewSessionCommandsIfEnabled( session: target, destinationApp: profile, initialPrompt: initialPrompt, warpTitleOverride: warpTitle, projectOverride: projectOverride ) _ = openAppleTerminal(at: dir) #else // Use openNewSessionRespectingProject to handle initialPrompt if supported if let prompt = initialPrompt { openNewSessionRespectingProject(session: target, initialPrompt: prompt) } else if !openNewSession(session: target) { _ = copyNewSessionCommandsIfEnabled(session: target, destinationApp: profile, projectOverride: projectOverride) _ = openAppleTerminal(at: dir) notifyCommandCopiedIfEnabled() } #endif return } // Handle other terminals with command resolution if !profile.supportsCommandResolved { _ = copyNewSessionCommandsIfEnabled( session: target, destinationApp: profile, initialPrompt: initialPrompt, warpTitleOverride: warpTitle, projectOverride: projectOverride ) } let cmd = profile.supportsCommandResolved ? buildNewSessionCLIInvocationRespectingProject( session: target, initialPrompt: initialPrompt, projectOverride: projectOverride ) : nil openPreferredTerminalViaScheme(profile: profile, directory: dir, command: cmd) if !profile.supportsCommandResolved { notifyCommandCopiedIfEnabled() } } func openNewSessionRespectingProject(session: SessionSummary, initialPrompt: String) { if session.source == .codexLocal, let pid = projectIdForSession(session.id), let p = projects.first(where: { $0.id == pid }), p.profile != nil || (p.profileId?.isEmpty == false) { _ = actions.openNewSessionUsingProjectProfile( session: session, project: p, executableURL: preferredExecutableURL(for: session.source), options: preferences.resumeOptions, initialPrompt: initialPrompt, codexHome: codexHomeOverride(for: p) ) } else { _ = actions.openNewSession( session: session, executableURL: preferredExecutableURL(for: session.source), options: preferences.resumeOptions, codexHome: codexHomeOverride(for: session) ) } } func projectIdForSession(_ id: String) -> String? { if let summary = sessionSummary(for: id) { return projectId(for: summary) } for source in ProjectSessionSource.allCases { if let pid = projectId(for: id, source: source) { return pid } } return nil } func projectForId(_ id: String) async -> Project? { await projectsStore.getProject(id: id) } func allowedSources(for session: SessionSummary) -> [ProjectSessionSource] { let sources: [ProjectSessionSource] if let pid = projectIdForSession(session.id), let p = projects.first(where: { $0.id == pid }) { let allowed = p.sources.isEmpty ? ProjectSessionSource.allSet : p.sources sources = Array(allowed).sorted { $0.displayName < $1.displayName } } else { sources = ProjectSessionSource.allCases } return sources.filter { preferences.isCLIEnabled($0.baseKind) } } func copyRealResumeCommand(session: SessionSummary) { actions.copyRealResumeInvocation( session: session, executableURL: preferredExecutableURL(for: session.source), options: preferences.resumeOptions, codexHome: codexHomeOverride(for: session) ) } func openWarpLaunch(session: SessionSummary) { let cwd = resolvedWorkingDirectory(for: session) _ = actions.openWarpLaunchConfig( session: session, options: preferences.resumeOptions, executableURL: preferredExecutableURL(for: session.source), workingDirectory: cwd, codexHome: codexHomeOverride(for: session) ) } func openPreferredTerminal(profile: ExternalTerminalProfile) { actions.openTerminalApp(profile) } func openPreferredTerminalViaScheme( profile: ExternalTerminalProfile, directory: String, command: String? = nil ) { actions.openTerminalViaScheme(profile, directory: directory, command: command) } func openAppleTerminal(at directory: String) -> Bool { actions.openAppleTerminal(at: directory) } } ================================================ FILE: models/SessionListViewModel+Editor.swift ================================================ import Foundation import AppKit extension SessionListViewModel { /// Open a project directory in the specified editor /// - Parameters: /// - project: The project to open /// - editor: The editor app to use (VSCode, Cursor, Zed, Antigravity) /// - Returns: True if successfully opened, false otherwise @discardableResult func openProjectInEditor(_ project: Project, using editor: EditorApp) -> Bool { guard let directory = project.directory?.trimmingCharacters(in: .whitespacesAndNewlines), !directory.isEmpty else { errorMessage = "Project directory is not set" return false } let dirURL = URL(fileURLWithPath: directory) // Verify directory exists var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: directory, isDirectory: &isDirectory), isDirectory.boolValue else { errorMessage = "Directory does not exist: \(directory)" return false } // Strategy 1: Try CLI command first (most reliable, supports opening specific directories) if let executablePath = findExecutableInPath(editor.cliCommand) { let process = Process() process.executableURL = URL(fileURLWithPath: executablePath) process.arguments = [directory] process.standardOutput = Pipe() process.standardError = Pipe() do { try process.run() return true } catch { // Fall through to Strategy 2 } } // Strategy 2: Open via bundle identifier if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: editor.bundleIdentifier) { let config = NSWorkspace.OpenConfiguration() config.activates = true NSWorkspace.shared.open( [dirURL], withApplicationAt: appURL, configuration: config ) { _, error in if let error = error { DispatchQueue.main.async { self.errorMessage = "Failed to open \(editor.title): \(error.localizedDescription)" } } } return true } // Editor not found errorMessage = "\(editor.title) is not installed. Please install it or try a different editor." return false } /// Reveal a project directory in Finder /// - Parameter project: The project to reveal func revealProjectDirectory(_ project: Project) { guard let directory = project.directory?.trimmingCharacters(in: .whitespacesAndNewlines), !directory.isEmpty else { errorMessage = "Project directory is not set" return } let dirURL = URL(fileURLWithPath: directory) // Verify directory exists var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: directory, isDirectory: &isDirectory), isDirectory.boolValue else { errorMessage = "Directory does not exist: \(directory)" return } // Reveal in Finder (will activate Finder and select the folder) NSWorkspace.shared.activateFileViewerSelecting([dirURL]) } /// Find an executable in the system PATH private func findExecutableInPath(_ name: String) -> String? { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/which") process.arguments = [name] let pipe = Pipe() process.standardOutput = pipe process.standardError = Pipe() do { try process.run() process.waitUntilExit() guard process.terminationStatus == 0 else { return nil } let data = pipe.fileHandleForReading.readDataToEndOfFile() let path = String(data: data, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines) return path?.isEmpty == false ? path : nil } catch { return nil } } } ================================================ FILE: models/SessionListViewModel+Intents.swift ================================================ import Foundation @MainActor extension SessionListViewModel { func recordIntentForDetailNew(anchor: SessionSummary) { guard let pid = projectIdForSession(anchor.id) else { return } let hints = PendingAssignIntent.Hints( model: anchor.model, sandbox: preferences.resumeOptions.flagSandboxRaw, approval: preferences.resumeOptions.flagApprovalRaw ) recordIntent(projectId: pid, expectedCwd: anchor.cwd, hints: hints) } func recordIntentForProjectNew(project: Project) { let expected = (project.directory?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { $0.isEmpty ? nil : $0 } ?? NSHomeDirectory() let hints = PendingAssignIntent.Hints( model: project.profile?.model, sandbox: project.profile?.sandbox?.rawValue ?? preferences.resumeOptions.flagSandboxRaw, approval: project.profile?.approval?.rawValue ?? preferences.resumeOptions.flagApprovalRaw ) recordIntent(projectId: project.id, expectedCwd: expected, hints: hints) } } extension SessionListViewModel { func handleAutoAssignIfMatches(_ s: SessionSummary) { guard !pendingAssignIntents.isEmpty else { return } let canonical = Self.canonicalPath(s.cwd) let candidates = pendingAssignIntents.filter { intent in guard canonical == intent.expectedCwd else { return false } let windowStart = intent.t0.addingTimeInterval(-2) let windowEnd = intent.t0.addingTimeInterval(60) return s.startedAt >= windowStart && s.startedAt <= windowEnd } guard !candidates.isEmpty else { return } struct Scored { let intent: PendingAssignIntent let score: Int let timeAbs: TimeInterval } var scored: [Scored] = [] for it in candidates { var score = 0 if let m = it.hints.model, let sm = s.model, !m.isEmpty, m == sm { score += 1 } if let a = it.hints.approval, let sa = s.approvalPolicy, !a.isEmpty, a == sa { score += 1 } let timeAbs = abs(s.startedAt.timeIntervalSince(it.t0)) scored.append(Scored(intent: it, score: score, timeAbs: timeAbs)) } guard let best = scored.max(by: { lhs, rhs in if lhs.score != rhs.score { return lhs.score < rhs.score } return lhs.timeAbs > rhs.timeAbs }) else { return } let topScore = best.score let topTime = best.timeAbs let dupCount = scored.filter { $0.score == topScore && abs($0.timeAbs - topTime) < 0.001 } .count if dupCount > 1 { Task { await SystemNotifier.shared.notify( title: "CodMate", body: "Assign to \(best.intent.projectId)?") } return } Task { let assignment = SessionAssignment(id: s.id, source: s.source.projectSource) await projectsStore.assign(sessions: [assignment], to: best.intent.projectId) let counts = await projectsStore.counts() let memberships = await projectsStore.membershipsSnapshot() await MainActor.run { self.projectCounts = counts self.setProjectMemberships(memberships) self.recomputeProjectCounts() self.scheduleApplyFilters() } await SystemNotifier.shared.notify( title: "CodMate", body: "Assigned to \(best.intent.projectId)") } pendingAssignIntents.removeAll { $0.id == best.intent.id } } func pruneExpiredIntents() { let now = Date() pendingAssignIntents.removeAll { now.timeIntervalSince($0.t0) > 60 } // Reschedule cleanup if intents remain if !pendingAssignIntents.isEmpty { scheduleIntentsCleanupIfNeeded() } } func recordIntent( projectId: String, expectedCwd: String, hints: PendingAssignIntent.Hints ) { if !preferences.autoAssignNewToSameProject { return } let canonical = Self.canonicalPath(expectedCwd) pendingAssignIntents.append( PendingAssignIntent( projectId: projectId, expectedCwd: canonical, t0: Date(), hints: hints )) pruneExpiredIntents() // Schedule cleanup for new intent scheduleIntentsCleanupIfNeeded() } } ================================================ FILE: models/SessionListViewModel+Notes.swift ================================================ import Foundation import AppKit import OSLog @MainActor extension SessionListViewModel { private static let log = Logger(subsystem: "ai.umate.codmate", category: "SessionSummaryGen") // MARK: - Title and Comment Generation /// Generates title and comment for a session using LLM /// - Parameters: /// - session: The session to generate for /// - force: If true, skip the confirmation dialog when existing content is present func generateTitleAndComment(for session: SessionSummary, force: Bool = false) async { Self.log.info("Starting generation for session \(session.id, privacy: .public)") let statusToken = StatusBarLogStore.shared.beginTask( "Generating title & comment...", level: .info, source: "Session" ) var finalStatus: (message: String, level: StatusBarLogLevel)? defer { if let finalStatus { StatusBarLogStore.shared.endTask( statusToken, message: finalStatus.message, level: finalStatus.level, source: "Session" ) } else { StatusBarLogStore.shared.endTask(statusToken) } } // Check if there's existing content and we should confirm if !force { // Only show confirmation if there's actual non-empty content let title = session.userTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let comment = session.userComment?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let hasExisting = !title.isEmpty || !comment.isEmpty if hasExisting { Self.log.info("Session has existing content, showing confirmation dialog") let shouldProceed = await showOverwriteConfirmation() if !shouldProceed { Self.log.info("User cancelled generation") finalStatus = ("Generation cancelled", .warning) return } Self.log.info("User confirmed, proceeding with generation") } } // Set generating state isGeneratingTitleComment = true generatingSessionId = session.id defer { isGeneratingTitleComment = false generatingSessionId = nil } do { // Load turns using existing timeline infrastructure Self.log.info("Loading conversation turns from \(session.fileURL.path, privacy: .public)") Self.log.info("Session source: \(String(describing: session.source), privacy: .public)") let turns = await self.timeline(for: session) Self.log.info("Loaded \(turns.count) turns") if turns.isEmpty { Self.log.warning("No conversation turns found") await showGenerationError("No conversation data found in session.") finalStatus = ("No conversation data found", .warning) return } // Build material using intelligent truncation and summarization // Run in background thread to avoid blocking UI Self.log.info("Building conversation material") let material = await Task.detached { SessionSummaryMaterialBuilder.build(turns: turns) }.value Self.log.info("Material size: \(material.utf8.count) bytes") // Build prompt let prompt = Self.titleCommentPrompt(material: material) Self.log.info("Prompt size: \(prompt.utf8.count) bytes") // Call LLM Self.log.info("Calling LLM API") let llm = LLMHTTPService() var options = LLMHTTPService.Options() options.preferred = .auto // Reuse commit message configuration for now if let providerId = UserDefaults.standard.string(forKey: "git.review.commitProviderId"), !providerId.isEmpty { options.providerId = providerId Self.log.info("Using provider: \(providerId, privacy: .public)") } if let modelId = UserDefaults.standard.string(forKey: "git.review.commitModelId"), !modelId.isEmpty { options.model = modelId Self.log.info("Using model: \(modelId, privacy: .public)") } options.timeout = 45 options.maxTokens = 500 options.systemPrompt = "Return only the JSON object. No labels, explanations, or extra commentary." let res = try await llm.generateText(prompt: prompt, options: options) Self.log.info("LLM responded in \(res.elapsedMs)ms from provider \(res.providerId, privacy: .public)") let raw = res.text.trimmingCharacters(in: .whitespacesAndNewlines) Self.log.info("Raw response: \(raw, privacy: .public)") // Parse JSON response guard let result = Self.parseTitleCommentResponse(raw) else { Self.log.error("Failed to parse JSON response") await showGenerationError("Failed to parse response from LLM. Response: \(raw)") finalStatus = ("Failed to parse LLM response", .error) return } Self.log.info("Parsed title: \(result.title, privacy: .public)") Self.log.info("Parsed comment: \(result.comment, privacy: .public)") // Update edit fields await MainActor.run { // If we're already editing this session, just update the fields if editingSession?.id == session.id { Self.log.info("Updating existing edit dialog") if !result.title.isEmpty { editTitle = result.title } if !result.comment.isEmpty { editComment = result.comment } } else { // Otherwise, open the edit dialog with the generated content Self.log.info("Opening new edit dialog") editingSession = session editTitle = result.title editComment = result.comment } } Self.log.info("Generation completed successfully") if preferences.titleCommentNotificationsEnabled { await SystemNotifier.shared.notify( title: "Session Summary", body: "Generated title and comment in \(res.elapsedMs)ms", threadId: "session-summary" ) } finalStatus = ("Title & comment ready", .success) } catch { Self.log.error("Generation error: \(error.localizedDescription, privacy: .public)") await showGenerationError("Generation failed: \(error.localizedDescription)") finalStatus = ("Generation failed: \(error.localizedDescription)", .error) } } private func showGenerationError(_ message: String) async { Self.log.error("Showing error: \(message, privacy: .public)") if preferences.titleCommentNotificationsEnabled { await SystemNotifier.shared.notify( title: "Session Summary Error", body: message, threadId: "session-summary" ) } } private func showOverwriteConfirmation() async -> Bool { // Use NSAlert for confirmation return await withCheckedContinuation { continuation in DispatchQueue.main.async { let alert = NSAlert() alert.messageText = "Overwrite Existing Content?" alert.informativeText = "This session already has a title or comment. Do you want to generate new ones?" alert.addButton(withTitle: "Generate") alert.addButton(withTitle: "Cancel") alert.alertStyle = .warning let response = alert.runModal() continuation.resume(returning: response == .alertFirstButtonReturn) } } } // MARK: - Prompt Building private static func titleCommentPrompt(material: String) -> String { let basePrompt: String if let payload = Self.payloadTitleCommentPrompt { basePrompt = payload } else { basePrompt = """ Generate a concise title and descriptive comment for this conversation. Return a JSON object with "title" and "comment" fields. Title should be 3-8 words. Comment should be 1-3 sentences. """ } return [basePrompt, "", material].joined(separator: "\n") } private static let payloadTitleCommentPrompt: String? = { let bundle = Bundle.main guard let url = bundle.url(forResource: "title-and-comment", withExtension: "md", subdirectory: "payload/prompts") else { return nil } guard let content = try? String(contentsOf: url, encoding: .utf8) else { return nil } let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed }() // MARK: - Response Parsing private static func parseTitleCommentResponse(_ raw: String) -> (title: String, comment: String)? { var cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines) // Remove code fences if present if cleaned.hasPrefix("```") { // Remove opening fence (```json or just ```) if let firstNewline = cleaned.firstIndex(of: "\n") { cleaned = String(cleaned[cleaned.index(after: firstNewline)...]) } // Remove closing fence if let lastFence = cleaned.range(of: "```", options: .backwards) { cleaned = String(cleaned[.. Set? { let raw = notesSnapshot[sessionId]?.timelineVisibleKinds guard var set = Set.fromRawValues(raw) else { return nil } set.remove(.environmentContext) if set.contains(.tool) { set.insert(.codeEdit) } return set } func updateTimelineVisibleKindsOverride( for sessionId: String, kinds: Set? ) async { let raw = kinds?.rawValues await notesStore.updateTimelineVisibleKinds(id: sessionId, kinds: raw) if let updatedNote = await notesStore.note(for: sessionId) { notesSnapshot[sessionId] = updatedNote } } func clearTimelineVisibleKindsOverride(for sessionId: String) async { await updateTimelineVisibleKindsOverride(for: sessionId, kinds: nil) } func beginEditing(session: SessionSummary) async { editingSession = session if let note = await notesStore.note(for: session.id) { editTitle = note.title ?? "" editComment = note.comment ?? "" } else { editTitle = session.userTitle ?? "" editComment = session.userComment ?? "" } } func saveEdits() async { guard let session = editingSession else { return } let titleValue = editTitle.isEmpty ? nil : editTitle let commentValue = editComment.isEmpty ? nil : editComment await notesStore.upsert(id: session.id, title: titleValue, comment: commentValue) // Reload the complete note from store to ensure cache consistency // (preserves projectId, profileId and other fields managed by notesStore) if let updatedNote = await notesStore.note(for: session.id) { notesSnapshot[session.id] = updatedNote } await indexer.updateUserMetadata(sessionId: session.id, title: titleValue, comment: commentValue) // Update the session in place to preserve sorting and trigger didSet observer allSessions = allSessions.map { s in guard s.id == session.id else { return s } var updated = s updated.userTitle = titleValue updated.userComment = commentValue return updated } await autoAssignSessionAfterEditIfNeeded(session) scheduleApplyFilters() cancelEdits() } func cancelEdits() { editingSession = nil editTitle = "" editComment = "" } } ================================================ FILE: models/SessionListViewModel+Projects.swift ================================================ import Foundation import OSLog @MainActor extension SessionListViewModel { private static let projectLogger = Logger(subsystem: "io.umate.codmate", category: "SessionListVM.ProjectCounts") static let otherProjectId = "__other__" func loadProjects() async { var list = await projectsStore.listProjects() if list.isEmpty { let cfg = await configService.listProjects() if !cfg.isEmpty { for p in cfg { await projectsStore.upsertProject(p) } list = await projectsStore.listProjects() } } let counts = await projectsStore.counts() let memberships = await projectsStore.membershipsSnapshot() await MainActor.run { self.projects = list if self.preferences.isCLIEnabled(.gemini) { self.rebuildGeminiProjectHashLookup() } self.projectStructureVersion &+= 1 self.projectCounts = counts self.setProjectMemberships(memberships) self.recomputeProjectCounts() self.invalidateProjectVisibleCountsCache() self.scheduleApplyFilters() } if preferences.isCLIEnabled(.gemini) { await geminiProvider.invalidateProjectMappings() } } func setSelectedProject(_ id: String?) { if let id { selectedProjectIDs = Set([id]) // Special behavior for the synthetic Others bucket: // when there is no active date filter yet, clicking // Others focuses on "today" without changing the // Created/Last Updated picker. Independently, we fire // a targeted incremental refresh for today across // Claude and Gemini so newly created/updated sessions // appear under Others quickly. if id == Self.otherProjectId { if selectedDay == nil, selectedDays.isEmpty { setSelectedDay(Date()) } Task { [weak self] in guard let self else { return } async let codex: Void = self.refreshIncrementalForNewCodexToday() async let claude: Void = self.refreshIncrementalForClaudeToday() async let gemini: Void = self.refreshIncrementalForGeminiToday() _ = await (codex, claude, gemini) } } } else { selectedProjectIDs.removeAll() } } func setSelectedProjects(_ ids: Set) { selectedProjectIDs = ids } func toggleProjectSelection(_ id: String) { if selectedProjectIDs.contains(id) { selectedProjectIDs.remove(id) } else { selectedProjectIDs.insert(id) } } func assignSessions(to projectId: String?, ids: [String]) async { let assignments = ids.compactMap { sessionAssignment(forIdentifier: $0) } guard !assignments.isEmpty else { return } await projectsStore.assign(sessions: assignments, to: projectId) let counts = await projectsStore.counts() let memberships = await projectsStore.membershipsSnapshot() await MainActor.run { self.projectCounts = counts self.setProjectMemberships(memberships) self.recomputeProjectCounts() self.scheduleApplyFilters() } } func autoAssignSessionAfterEditIfNeeded(_ session: SessionSummary) async { guard projectIdForSession(session.id) == nil else { return } guard let bestProjectId = bestMatchingProjectId(for: session) else { return } await assignSessions(to: bestProjectId, ids: [session.id]) } func bestMatchingProjectId(for session: SessionSummary) -> String? { let projectDirs: [(id: String, path: String)] = projects.compactMap { project in guard let raw = project.directory?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } let normalized = URL(fileURLWithPath: raw).standardizedFileURL.path let slash = normalized.hasSuffix("/") ? normalized : normalized + "/" return (project.id, slash) } guard !projectDirs.isEmpty else { return nil } let rawPath = session.cwd.trimmingCharacters(in: .whitespacesAndNewlines) let cwd = rawPath.isEmpty ? session.fileURL.deletingLastPathComponent().path : rawPath let normalized = URL(fileURLWithPath: cwd).standardizedFileURL.path let sessionPath = normalized.hasSuffix("/") ? normalized : normalized + "/" let matchingProjects = projectDirs.filter { candidate in sessionPath.hasPrefix(candidate.path) } return matchingProjects.max(by: { lhs, rhs in lhs.path.count < rhs.path.count })?.id } func projectCountsFromStore() -> [String: Int] { projectCounts } func visibleProjectCountsForDateScope() -> [String: Int] { let key = ProjectVisibleKey( dimension: dateDimension, selectedDay: selectedDay, selectedDays: selectedDays, sessionCount: allSessions.count, membershipVersion: projectMembershipsVersion ) if let cached = cachedProjectVisibleCounts, cached.key == key { return cached.value } var visible: [String: Int] = [:] let allowed = projects.reduce(into: [String: Set]()) { $0[$1.id] = $1.sources } let descriptors = Self.makeDayDescriptors(selectedDays: selectedDays, singleDay: selectedDay) let filterByDay = !descriptors.isEmpty var other = 0 for session in allSessions { if filterByDay && !matchesDayFilters(session, descriptors: descriptors) { continue } if let pid = projectId(for: session) { let allowedSources = allowed[pid] ?? ProjectSessionSource.allSet if !allowedSources.contains(session.source.projectSource) { continue } visible[pid, default: 0] += 1 } else { other += 1 } } if other > 0 { visible[Self.otherProjectId] = other } cachedProjectVisibleCounts = (key, visible) return visible } func projectCountsDisplay() -> [String: (visible: Int, total: Int)] { var directVisible = visibleProjectCountsForDateScope() let directTotal = projectCounts // Cold-start smoothing: when sessions尚未加载、visible为空但总数已知,先用总数填充,避免出现 “N/0” 闪烁 if directVisible.isEmpty, isLoading { for (k, v) in directTotal { directVisible[k] = v } Self.projectLogger.log("projectCountsDisplay smoothing with totals only count=\(directTotal.values.reduce(0, +), privacy: .public)") } // Build cache key let visibleKey = ProjectVisibleKey( dimension: dateDimension, selectedDay: selectedDay, selectedDays: selectedDays, sessionCount: allSessions.count, membershipVersion: projectMembershipsVersion ) let totalCountsHash = directTotal.values.reduce(0) { $0 ^ $1 } let cacheKey = ProjectAggregatedKey( visibleKey: visibleKey, totalCountsHash: totalCountsHash, structureVersion: projectStructureVersion ) // Check cache if let cached = cachedProjectAggregated, cached.key == cacheKey { return cached.value } // Cache miss - compute aggregated counts var children: [String: [String]] = [:] for p in projects { if let parent = p.parentId { children[parent, default: []].append(p.id) } } func aggregate(for id: String, using map: inout [String: (Int, Int)]) -> (Int, Int) { if let cached = map[id] { return cached } var v = directVisible[id] ?? 0 var t = directTotal[id] ?? 0 for c in (children[id] ?? []) { let (cv, ct) = aggregate(for: c, using: &map) v += cv t += ct } map[id] = (v, t) return (v, t) } var memo: [String: (Int, Int)] = [:] var out: [String: (visible: Int, total: Int)] = [:] for p in projects { let (v, t) = aggregate(for: p.id, using: &memo) out[p.id] = (v, t) } // Add synthetic Other bucket let otherVisible = directVisible[Self.otherProjectId] ?? 0 let otherTotal = directTotal[Self.otherProjectId] ?? otherVisible if otherVisible > 0 || otherTotal > 0 { out[Self.otherProjectId] = (otherVisible, otherTotal) } // Cache the result cachedProjectAggregated = (cacheKey, out) return out } func visibleAllCountForDateScope() -> Int { let key = SessionListViewModel.VisibleCountKey( dimension: dateDimension, selectedDay: selectedDay, selectedDays: selectedDays, sessionCount: allSessions.count ) if let cached = cachedVisibleCount, cached.key == key { return cached.value } // Cold-start: if sessions尚未加载但缓存覆盖度可用,直接返回缓存总数,避免 0 闪烁。 if allSessions.isEmpty { if let coverage = cacheCoverage { cachedVisibleCount = (key, coverage.sessionCount) Self.projectLogger.log("visibleAllCount use coverage sessionCount=\(coverage.sessionCount, privacy: .public)") return coverage.sessionCount } if let meta = indexMeta { cachedVisibleCount = (key, meta.sessionCount) Self.projectLogger.log("visibleAllCount use meta sessionCount=\(meta.sessionCount, privacy: .public)") return meta.sessionCount } Self.projectLogger.log("visibleAllCount no cache available, default 0") } let descriptors = Self.makeDayDescriptors(selectedDays: selectedDays, singleDay: selectedDay) let value: Int if descriptors.isEmpty { value = allSessions.count } else { value = allSessions.filter { matchesDayFilters($0, descriptors: descriptors) }.count } cachedVisibleCount = (key, value) Self.projectLogger.log("visibleAllCount computed from sessions count=\(value, privacy: .public) descriptors=\(descriptors.count, privacy: .public)") return value } // Calendar helper: days within the given month that have at least one session // belonging to any of the currently selected projects (including descendants), respecting // each project's allowed sources. Returns nil when no project is selected. func calendarEnabledDaysForSelectedProject(monthStart: Date, dimension: DateDimension) -> Set? { guard !selectedProjectIDs.isEmpty else { return nil } let monthKey = monthKey(for: monthStart) // Build allowed project set: include descendants of each selected project var allowedProjects = Set() for pid in selectedProjectIDs { allowedProjects.insert(pid) allowedProjects.formUnion(collectDescendants(of: pid, in: projects)) } // Resolve allowed sources per project let allowedSourcesByProject = projects.reduce(into: [String: Set]()) { $0[$1.id] = $1.sources } var days: Set = [] for session in allSessions { if let assigned = projectId(for: session) { guard allowedProjects.contains(assigned) else { continue } let allowed = allowedSourcesByProject[assigned] ?? ProjectSessionSource.allSet if !allowed.contains(session.source.projectSource) { continue } } else { // Include unassigned only when Other is selected guard allowedProjects.contains(Self.otherProjectId) else { continue } } let bucket = dayIndex(for: session) switch dimension { case .created: guard bucket.createdMonthKey == monthKey else { continue } days.insert(bucket.createdDay) case .updated: let coverageKey = SessionMonthCoverageKey(sessionID: session.id, monthKey: monthKey) if let covered = updatedMonthCoverage[coverageKey], !covered.isEmpty { days.formUnion(covered) } else if bucket.updatedMonthKey == monthKey { days.insert(bucket.updatedDay) } } } return days } func allSessionsInSameProject(as anchor: SessionSummary) -> [SessionSummary] { if let pid = projectId(for: anchor) { let allowed = projects.first(where: { $0.id == pid })?.sources ?? ProjectSessionSource.allSet return allSessions.filter { projectId(for: $0) == pid && allowed.contains($0.source.projectSource) } } return allSessions } func createOrUpdateProject(_ project: Project) async { await projectsStore.upsertProject(project) await loadProjects() } func deleteProject(id: String) async { await projectsStore.deleteProject(id: id) await loadProjects() if selectedProjectIDs.contains(id) { selectedProjectIDs.remove(id) } scheduleApplyFilters() } func deleteProjectCascade(id: String) async { let list = await projectsStore.listProjects() let ids = collectDescendants(of: id, in: list) + [id] for pid in ids { await projectsStore.deleteProject(id: pid) } await loadProjects() if !selectedProjectIDs.isDisjoint(with: ids) { selectedProjectIDs.subtract(ids) } scheduleApplyFilters() } func deleteProjectMoveChildrenUp(id: String) async { let list = await projectsStore.listProjects() for p in list where p.parentId == id { var moved = p moved.parentId = nil await projectsStore.upsertProject(moved) } await projectsStore.deleteProject(id: id) await loadProjects() if selectedProjectIDs.contains(id) { selectedProjectIDs.remove(id) } scheduleApplyFilters() } func changeProjectParent(projectId: String, newParentId: String?) async { // Don't allow changing the Other synthetic project guard projectId != Self.otherProjectId else { return } // Don't allow setting Other as a parent guard newParentId != Self.otherProjectId else { return } let list = await projectsStore.listProjects() guard let project = list.first(where: { $0.id == projectId }) else { return } // No-op if already has the same parent if project.parentId == newParentId { return } // Prevent circular dependency: can't make a project its own parent or descendant if let newParent = newParentId { if newParent == projectId { return } let descendants = collectDescendants(of: projectId, in: list) if descendants.contains(newParent) { return } } var updated = project updated.parentId = newParentId await projectsStore.upsertProject(updated) await loadProjects() } func collectDescendants(of id: String, in list: [Project]) -> [String] { var result: [String] = [] func dfs(_ pid: String) { for p in list where p.parentId == pid { result.append(p.id) dfs(p.id) } } dfs(id) return result } func importMembershipsFromNotesIfNeeded(notes: [String: SessionNote]) async { let existing = await projectsStore.membershipsSnapshot() if !existing.isEmpty { return } var buckets: [String: [SessionAssignment]] = [:] for (sid, n) in notes { guard let pid = n.projectId else { continue } guard let assignment = sessionAssignment(forIdentifier: sid) else { continue } buckets[pid, default: []].append(assignment) } guard !buckets.isEmpty else { return } for (pid, entries) in buckets { await projectsStore.assign(sessions: entries, to: pid) } let counts = await projectsStore.counts() let memberships = await projectsStore.membershipsSnapshot() await MainActor.run { self.projectCounts = counts self.setProjectMemberships(memberships) self.recomputeProjectCounts() } } @MainActor func recomputeProjectCounts() { if selectedDay != nil || !selectedDays.isEmpty { return } // Optimize: use visibleProjectCountsForDateScope if it's for current filter state // to avoid re-traversing all sessions let currentKey = ProjectVisibleKey( dimension: dateDimension, selectedDay: selectedDay, selectedDays: selectedDays, sessionCount: allSessions.count, membershipVersion: projectMembershipsVersion ) // If we have cached visible counts for current state, reuse them as total counts // (when no date filter is active) if selectedDay == nil && selectedDays.isEmpty, let cached = cachedProjectVisibleCounts, cached.key == currentKey { projectCounts = cached.value return } // Otherwise compute from scratch var counts: [String: Int] = [:] var other = 0 let allowed = projects.reduce(into: [String: Set]()) { $0[$1.id] = $1.sources } for session in allSessions { if let pid = projectId(for: session) { let allowedSources = allowed[pid] ?? ProjectSessionSource.allSet if allowedSources.contains(session.source.projectSource) { counts[pid, default: 0] += 1 } } else { other += 1 } } if other > 0 { counts[Self.otherProjectId] = other } projectCounts = counts } func requestProjectExpansion(for projectId: String) { let chain = projectAncestorChain(projectId: projectId) guard !chain.isEmpty else { return } NotificationCenter.default.post( name: .codMateExpandProjectTree, object: nil, userInfo: ["ids": chain] ) } private func projectAncestorChain(projectId: String) -> [String] { guard !projects.isEmpty else { return [] } var map: [String: Project] = [:] for p in projects { map[p.id] = p } var chain: [String] = [] var current: String? = projectId while let id = current, let project = map[id] { chain.insert(project.id, at: 0) current = project.parentId } return chain } } ================================================ FILE: models/SessionListViewModel+SearchSupport.swift ================================================ import Foundation @MainActor extension SessionListViewModel { func sessionsSnapshot() -> [SessionSummary] { allSessions } func sessionSummary(withId id: String) -> SessionSummary? { allSessions.first { $0.id == id } } func sessionSummary(forFileURL url: URL) -> SessionSummary? { allSessions.first { $0.fileURL == url } } } ================================================ FILE: models/SessionListViewModel.swift ================================================ import AppKit import Combine import CryptoKit import Foundation import OSLog #if canImport(Darwin) import Darwin #endif @MainActor final class SessionListViewModel: ObservableObject { @Published var sections: [SessionDaySection] = [] @Published var searchText: String = "" { didSet { scheduleFulltextSearchIfNeeded() } } @Published var sortOrder: SessionSortOrder = .mostRecent { didSet { scheduleFiltersUpdate() } } @Published var isLoading = false @Published var isEnriching = false @Published var enrichmentProgress: Int = 0 @Published var enrichmentTotal: Int = 0 @Published var errorMessage: String? // Title/Comment quick search for the middle list only @Published var quickSearchText: String = "" { didSet { scheduleFiltersUpdate() } } // New filter state: supports combined filters @Published var selectedPath: String? = nil { didSet { guard !suppressFilterNotifications, oldValue != selectedPath else { return } // Path filtering works on already-loaded sessions (filters by cwd field), // so we don't need to refresh files from disk - just reapply filters scheduleFiltersUpdate() } } @Published var selectedDay: Date? = nil { didSet { guard !suppressFilterNotifications, oldValue != selectedDay else { return } invalidateVisibleCountCache() scheduleSelectionDrivenUpdate() windowStateStore.saveCalendarSelection( selectedDay: selectedDay, selectedDays: selectedDays, monthStart: sidebarMonthStart) } } @Published var dateDimension: DateDimension = .updated { didSet { guard !suppressFilterNotifications, oldValue != dateDimension else { return } invalidateVisibleCountCache() invalidateCalendarCaches() enrichmentSnapshots.removeAll() if dateDimension == .updated { for day in selectedDays { requestCoverageIfNeeded(for: day) } if let day = selectedDay { requestCoverageIfNeeded(for: day) } } scheduleFiltersUpdate() scheduleFilterRefresh(force: true) } } // Multiple day selection support (normalized to startOfDay) @Published var selectedDays: Set = [] { didSet { guard !suppressFilterNotifications else { return } invalidateVisibleCountCache() scheduleSelectionDrivenUpdate() windowStateStore.saveCalendarSelection( selectedDay: selectedDay, selectedDays: selectedDays, monthStart: sidebarMonthStart) } } @Published var sidebarMonthStart: Date = SessionListViewModel.normalizeMonthStart(Date()) { didSet { guard !suppressFilterNotifications, oldValue != sidebarMonthStart else { return } windowStateStore.saveCalendarSelection( selectedDay: selectedDay, selectedDays: selectedDays, monthStart: sidebarMonthStart) } } // Track current list selection for targeted refreshes @Published var selectedSessionIDs: Set = [] private var cacheUnavailableLastError: Date? private let cacheUnavailableCooldown: TimeInterval = 5.0 private func markCacheUnavailableNow() { cacheUnavailableLastError = Date() } private func clearCacheUnavailable() { cacheUnavailableLastError = nil } private func shouldSkipForCacheUnavailable() -> Bool { guard let last = cacheUnavailableLastError else { return false } return Date().timeIntervalSince(last) < cacheUnavailableCooldown } let preferences: SessionPreferencesStore private var sessionsRoot: URL { preferences.sessionsRoot } internal let indexer: SessionIndexer let actions: SessionActions var allSessions: [SessionSummary] = [] { didSet { sessionsVersion &+= 1 invalidateVisibleCountCache() invalidateCalendarCaches() pruneDayCache() pruneCoverageCache() for session in allSessions { _ = dayIndex(for: session) } // Incremental path tree update based on session cwd diffs let newCounts = cwdCounts(for: allSessions) let oldCounts = lastPathCounts lastPathCounts = newCounts pathTreeRefreshTask?.cancel() let delta = diffCounts(old: oldCounts, new: newCounts) if !delta.isEmpty { Task { [weak self] in guard let self else { return } if let updated = await self.pathTreeStore.applyDelta(delta) { await MainActor.run { self.pathTreeRootPublished = updated } } else { // Fallback to full snapshot rebuild when prefix changes or structure requires it let rebuilt = await self.pathTreeStore.applySnapshot(counts: newCounts) await MainActor.run { self.pathTreeRootPublished = rebuilt } } } } sessionLookup = Dictionary(uniqueKeysWithValues: allSessions.map { ($0.id, $0) }) // Auto-assign unassigned sessions to "Others" task autoAssignSessionsToOthersTask() } } private var sessionLookup: [String: SessionSummary] = [:] private var sessionsVersion: UInt64 = 0 private var fulltextMatches: Set = [] // SessionSummary.id set private var fulltextTask: Task? private var enrichmentTask: Task? var notesStore: SessionNotesStore var notesSnapshot: [String: SessionNote] = [:] private var canonicalCwdCache: [String: String] = [:] private let ripgrepStore = SessionRipgrepStore() private var coverageLoadTasks: [String: Task] = [:] private var pendingCoverageMonths: Set = [] private var coverageDebounceTasks: [String: Task] = [:] // Per-key debounce private var selectedSessionsRefreshTask: Task? struct SessionDayIndex: Equatable { let created: Date let updated: Date let createdMonthKey: String let updatedMonthKey: String let createdDay: Int let updatedDay: Int } struct SessionMonthCoverageKey: Hashable, Sendable { let sessionID: String let monthKey: String } struct DaySelectionDescriptor: Hashable, Sendable { let date: Date let monthKey: String let day: Int } private var sessionDayCache: [String: SessionDayIndex] = [:] var updatedMonthCoverage: [SessionMonthCoverageKey: Set] = [:] private var directoryMonitor: DirectoryMonitor? private var claudeDirectoryMonitor: DirectoryMonitor? private var claudeProjectMonitor: DirectoryMonitor? private var geminiDirectoryMonitor: DirectoryMonitor? private var directoryRefreshTask: Task? private var enrichmentSnapshots: [String: Set] = [:] private var suppressFilterNotifications = false private var scheduledFilterRefresh: Task? private var filterTask: Task? private var filterDebounceTask: Task? private var filterGeneration: UInt64 = 0 private var pendingApplyFilters = false private var lastFilterSnapshotHash: Int? /// Debounce refresh triggers to avoid repeated full enumerations private var refreshDebounceTask: Task? private var lastRefreshAt: Date? private var lastRefreshScope: SessionLoadScope? private let refreshCooldown: TimeInterval = 0.5 private var pendingRefreshForce: Bool = false /// Scope-based refresh debouncing: track pending refresh by scope key to enable merging private var scopedRefreshTasks: [String: Task] = [:] private var pendingScopeRefreshForce: [String: Bool] = [:] /// Track actively executing refreshes by scope to prevent concurrent duplicates private var activeScopeRefreshes: [String: UUID] = [:] /// File event aggregation: collect file change events within a time window private var pendingFileEvents: Set = [] // file paths that changed private var fileEventAggregationTask: Task? private var lastFileEventAt: Date = .distantPast struct VisibleCountKey: Equatable { var dimension: DateDimension var selectedDay: Date? var selectedDays: Set var sessionCount: Int } var cachedVisibleCount: (key: VisibleCountKey, value: Int)? struct ProjectVisibleKey: Equatable { var dimension: DateDimension var selectedDay: Date? var selectedDays: Set var sessionCount: Int var membershipVersion: UInt64 } var cachedProjectVisibleCounts: (key: ProjectVisibleKey, value: [String: Int])? private var geminiProjectPathByHash: [String: String] = [:] private var codexUsageTask: Task? private var claudeUsageTask: Task? private var geminiUsageTask: Task? private var pathTreeRefreshTask: Task? private var calendarRefreshTasks: [String: Task] = [:] private var cancellables = Set() private let pathTreeStore = PathTreeStore() private var timelineCache: [String: TimelineCacheEntry] = [:] private struct TimelineCacheEntry { let signature: TimelineCacheSignature let turns: [ConversationTurn] } private struct TimelineCacheSignature: Equatable { let modifiedAt: Date? let fileSize: UInt64? } private var lastPathCounts: [String: Int] = [:] private let sidebarStatsDebounceNanoseconds: UInt64 = 150_000_000 private let filterDebounceNanoseconds: UInt64 = 15_000_000 private var cachedCalendar = Calendar.current private var pendingViewUpdate = false static let monthFormatter: DateFormatter = { let df = DateFormatter() df.dateFormat = "yyyy-MM" return df }() private var currentMonthKey: String? private var currentMonthDimension: DateDimension = .updated // Quick pulse (cheap file mtime scan) state private var quickPulseTask: Task? private var lastQuickPulseAt: Date = .distantPast private var fileMTimeCache: [String: Date] = [:] // session.id -> mtime private var lastDisplayedDigest: Int = 0 @Published var editingSession: SessionSummary? = nil @Published var editTitle: String = "" @Published var editComment: String = "" @Published var isGeneratingTitleComment: Bool = false @Published var generatingSessionId: String? = nil @Published var globalSessionCount: Int = 0 @Published private(set) var pathTreeRootPublished: PathTreeNode? private var monthCountsCache: [String: [Int: Int]] = [:] // key: "dim|yyyy-MM" (not @Published to avoid updates during view reads) @Published private(set) var codexUsageStatus: CodexUsageStatus? @Published private(set) var usageSnapshots: [UsageProviderKind: UsageProviderSnapshot] = [:] private var lastUsageRefreshByProvider: [UsageProviderKind: Date] = [:] private var claudeUsageAutoRefreshEnabled = false private var didAutoRefreshUsage = false // Live activity indicators @Published private(set) var activeUpdatingIDs: Set = [] @Published private(set) var awaitingFollowupIDs: Set = [] // Index meta for diagnostics/UI state (full cache completion marker) @Published private(set) var indexMeta: SessionIndexMeta? @Published private(set) var cacheCoverage: SessionIndexCoverage? private let diagLogger = Logger(subsystem: "io.umate.codmate", category: "SessionListVM") private func ts() -> Double { Date().timeIntervalSince1970 } // Persist Review (Git Changes) panel UI state per session so toggling // between Conversation, Terminal and Review preserves context. @Published var reviewPanelStates: [String: ReviewPanelState] = [:] // Project-level Git Review panel state per project id @Published var projectReviewPanelStates: [String: ReviewPanelState] = [:] // Project workspace mode (toolbar segmented) @Published var projectWorkspaceMode: ProjectWorkspaceMode = .overview let windowStateStore = WindowStateStore() // Project workspace view model for managing tasks private(set) var workspaceVM: ProjectWorkspaceViewModel? // Auto-assign: pending intents created when user clicks New struct PendingAssignIntent: Identifiable, Sendable, Hashable { let id = UUID() let projectId: String let expectedCwd: String // canonical path let t0: Date struct Hints: Sendable, Hashable { var model: String? var sandbox: String? var approval: String? } let hints: Hints } var pendingAssignIntents: [PendingAssignIntent] = [] var intentsCleanupTask: Task? // Targeted incremental refresh hint, set when user triggers New struct PendingIncrementalRefreshHint { enum Kind { case codexDay(Date) case geminiDay(Date) case claudeProject(String) } let kind: Kind let expiresAt: Date } private var pendingIncrementalHint: PendingIncrementalRefreshHint? = nil // Projects let configService = CodexConfigService() var projectsStore: ProjectsStore var tasksStore: TasksStore let claudeProvider: ClaudeSessionProvider let geminiProvider: GeminiSessionProvider private let claudeUsageClient = ClaudeUsageAPIClient() private let geminiUsageClient = GeminiUsageAPIClient() private let codexAppServerProbe = CodexAppServerProbeService() private let providersRegistry = ProvidersRegistryService() let remoteProvider: RemoteSessionProvider let sqliteStore: SessionIndexSQLiteStore @Published var projects: [Project] = [] var projectCounts: [String: Int] = [:] var projectMemberships: [String: String] = [:] var projectMembershipsVersion: UInt64 = 0 var projectStructureVersion: UInt64 = 0 // Incremented when projects/parentIds change @Published var expandedProjectIDs: Set = [] { didSet { if oldValue != expandedProjectIDs { windowStateStore.saveProjectExpansions(expandedProjectIDs) } } } struct ProjectAggregatedKey: Equatable { var visibleKey: ProjectVisibleKey var totalCountsHash: Int var structureVersion: UInt64 } var cachedProjectAggregated: (key: ProjectAggregatedKey, value: [String: (visible: Int, total: Int)])? @Published var selectedProjectIDs: Set = [] { didSet { guard !suppressFilterNotifications, oldValue != selectedProjectIDs else { return } if !selectedProjectIDs.isEmpty { // Defer selectedPath modification to avoid "Publishing changes from within view updates" Task { @MainActor [weak self] in self?.selectedPath = nil } } invalidateProjectVisibleCountsCache() scheduleSelectionDrivenUpdate() windowStateStore.saveProjectSelection(selectedProjectIDs) } } // Sidebar → Project-level New request when using embedded terminal @Published var pendingEmbeddedProjectNew: Project? = nil @Published var remoteSyncStates: [String: RemoteSyncState] = [:] private func pruneDayCache() { guard !sessionDayCache.isEmpty else { return } let ids = Set(allSessions.map(\.id)) sessionDayCache = sessionDayCache.filter { ids.contains($0.key) } } private func pruneCoverageCache() { guard !updatedMonthCoverage.isEmpty else { return } let ids = Set(allSessions.map(\.id)) updatedMonthCoverage = updatedMonthCoverage.filter { ids.contains($0.key.sessionID) } } private func invalidateVisibleCountCache() { cachedVisibleCount = nil invalidateProjectVisibleCountsCache() } func invalidateProjectVisibleCountsCache() { cachedProjectVisibleCounts = nil cachedProjectAggregated = nil } private func scheduleViewUpdate() { if pendingViewUpdate { return } pendingViewUpdate = true DispatchQueue.main.async { [weak self] in guard let self else { return } self.objectWillChange.send() self.pendingViewUpdate = false } } func scheduleApplyFilters() { DispatchQueue.main.async { [weak self] in guard let self else { return } // Coalesce rapid triggers: if a filter task is in flight, mark pending and return. if self.filterTask != nil { self.pendingApplyFilters = true return } self.applyFilters() } } func setProjectMemberships(_ memberships: [String: String]) { var normalized: [String: String] = [:] for (key, value) in memberships { if key.contains("|") { normalized[key] = value } else { let legacyKey = membershipKey(for: key, source: .codex) normalized[legacyKey] = value } } projectMemberships = normalized projectMembershipsVersion &+= 1 invalidateProjectVisibleCountsCache() } func monthKey(for date: Date) -> String { Self.monthFormatter.string(from: date) } private static func formattedMonthKey(year: Int, month: Int) -> String { return String(format: "%04d-%02d", year, month) } static func makeDayDescriptors(selectedDays: Set, singleDay: Date?) -> [DaySelectionDescriptor] { let calendar = Calendar.current let targets: [Date] if !selectedDays.isEmpty { targets = Array(selectedDays) } else if let single = singleDay { targets = [single] } else { targets = [] } return targets.map { date in let comps = calendar.dateComponents([.year, .month, .day], from: date) let monthKey = formattedMonthKey(year: comps.year ?? 0, month: comps.month ?? 0) return DaySelectionDescriptor(date: date, monthKey: monthKey, day: comps.day ?? 0) } } func dayIndex(for session: SessionSummary) -> SessionDayIndex { let index = buildDayIndex(for: session) if let cached = sessionDayCache[session.id], cached == index { return cached } sessionDayCache[session.id] = index return index } private func buildDayIndex(for session: SessionSummary) -> SessionDayIndex { let created = cachedCalendar.startOfDay(for: session.startedAt) let updatedSource = session.lastUpdatedAt ?? session.startedAt let updated = cachedCalendar.startOfDay(for: updatedSource) let createdKey = monthKey(for: created) let updatedKey = monthKey(for: updated) let createdDay = cachedCalendar.component(.day, from: created) let updatedDay = cachedCalendar.component(.day, from: updated) return SessionDayIndex( created: created, updated: updated, createdMonthKey: createdKey, updatedMonthKey: updatedKey, createdDay: createdDay, updatedDay: updatedDay) } func dayStart(for session: SessionSummary, dimension: DateDimension) -> Date { let index = dayIndex(for: session) switch dimension { case .created: return index.created case .updated: return index.updated } } func matchesDayFilters(_ session: SessionSummary, descriptors: [DaySelectionDescriptor]) -> Bool { guard !descriptors.isEmpty else { return true } let bucket = dayIndex(for: session) return Self.matchesDayDescriptors( summary: session, bucket: bucket, descriptors: descriptors, dimension: dateDimension, coverage: updatedMonthCoverage, calendar: cachedCalendar ) } static func normalizeMonthStart(_ date: Date) -> Date { let cal = Calendar.current let comps = cal.dateComponents([.year, .month], from: date) return cal.date(from: comps) ?? cal.startOfDay(for: date) } func setSidebarMonthStart(_ date: Date) { let normalized = Self.normalizeMonthStart(date) if normalized == sidebarMonthStart { return } sidebarMonthStart = normalized // Cancel unrelated coverage load tasks to reduce CPU usage when switching months let currentKey = cacheKey(normalized, dateDimension) for (key, task) in coverageLoadTasks where key != currentKey { task.cancel() } coverageLoadTasks.removeAll(keepingCapacity: true) _ = calendarCounts(for: normalized, dimension: dateDimension) // In Created mode, changing the viewed month requires reloading data // since we only load the current month's sessions for efficiency if dateDimension == .created { scheduleFilterRefresh(force: true) } } var sidebarStateSnapshot: SidebarState { SidebarState( totalSessionCount: totalSessionCount, isLoading: isLoading, visibleAllCount: visibleAllCountForDateScope(), selectedProjectIDs: selectedProjectIDs, selectedDay: selectedDay, selectedDays: selectedDays, dateDimension: dateDimension, monthStart: sidebarMonthStart, calendarCounts: calendarCounts(for: sidebarMonthStart, dimension: dateDimension), enabledProjectDays: calendarEnabledDaysForSelectedProject( monthStart: sidebarMonthStart, dimension: dateDimension ) ) } init( preferences: SessionPreferencesStore, sqliteStore: SessionIndexSQLiteStore = SessionIndexSQLiteStore(), indexer: SessionIndexer? = nil, actions: SessionActions = SessionActions() ) { self.preferences = preferences self.sqliteStore = sqliteStore self.indexer = indexer ?? SessionIndexer(sqliteStore: sqliteStore) self.actions = actions self.notesStore = SessionNotesStore(notesRoot: preferences.notesRoot) // Initialize ProjectsStore using configurable projectsRoot (defaults to ~/.codmate/projects) let pr = preferences.projectsRoot let p = ProjectsStore.Paths( root: pr, metadataDir: pr.appendingPathComponent("metadata", isDirectory: true), membershipsURL: pr.appendingPathComponent("memberships.json", isDirectory: false) ) self.projectsStore = ProjectsStore(paths: p) // Initialize TasksStore (defaults to ~/.codmate/tasks) self.tasksStore = TasksStore() self.claudeProvider = ClaudeSessionProvider(cacheStore: sqliteStore) self.geminiProvider = GeminiSessionProvider( projectsStore: self.projectsStore, cacheStore: sqliteStore) self.remoteProvider = RemoteSessionProvider(indexer: SessionIndexer(sqliteStore: sqliteStore)) suppressFilterNotifications = true // Restore window state from previous session let calendar = windowStateStore.restoreCalendarSelection() if let restoredDay = calendar.selectedDay { // Use restored calendar state self.selectedDay = restoredDay self.selectedDays = calendar.selectedDays.isEmpty ? [restoredDay] : calendar.selectedDays // Restore monthStart if available, otherwise derive from selectedDay if let restoredMonthStart = calendar.monthStart { self.sidebarMonthStart = restoredMonthStart } else { self.sidebarMonthStart = Self.normalizeMonthStart(restoredDay) } } else if !calendar.selectedDays.isEmpty { // Restore selectedDays even if selectedDay is nil self.selectedDays = calendar.selectedDays self.selectedDay = calendar.selectedDays.count == 1 ? calendar.selectedDays.first : nil if let restoredMonthStart = calendar.monthStart { self.sidebarMonthStart = restoredMonthStart } else if let firstDay = calendar.selectedDays.first { self.sidebarMonthStart = Self.normalizeMonthStart(firstDay) } else { self.sidebarMonthStart = Self.normalizeMonthStart(Date()) } } else if let restoredMonthStart = calendar.monthStart { // Only monthStart was saved, restore it but select today let today = Date() let cal = Calendar.current let start = cal.startOfDay(for: today) self.selectedDay = start self.selectedDays = [start] self.sidebarMonthStart = restoredMonthStart } else { // No saved state, default to today let today = Date() let cal = Calendar.current let start = cal.startOfDay(for: today) self.selectedDay = start self.selectedDays = [start] self.sidebarMonthStart = Self.normalizeMonthStart(today) } // Restore project selection self.selectedProjectIDs = windowStateStore.restoreProjectSelection() self.expandedProjectIDs = windowStateStore.restoreProjectExpansions() suppressFilterNotifications = false // Initialize workspace view model after self is fully initialized self.workspaceVM = ProjectWorkspaceViewModel(sessionListViewModel: self) // Prime cached index state early so sidebar counts/overview can render without a 0 flash. Task { @MainActor [weak self] in guard let self else { return } let meta = await self.indexer.currentMeta() let coverage = await self.indexer.currentCoverage() if let coverage { self.cacheCoverage = coverage self.globalSessionCount = coverage.sessionCount self.diagLogger.log( "prime index coverage count=\(coverage.sessionCount, privacy: .public) sources=\(coverage.sources, privacy: .public) ts=\(self.ts(), format: .fixed(precision: 3))" ) } else if let meta { self.indexMeta = meta self.globalSessionCount = meta.sessionCount self.diagLogger.log( "prime index meta count=\(meta.sessionCount, privacy: .public) ts=\(self.ts(), format: .fixed(precision: 3))" ) } } configureDirectoryMonitor() configureClaudeDirectoryMonitor() configureGeminiDirectoryMonitor() Task { await loadProjects() } Task { await self.performInitialRemoteSyncIfNeeded() } // Observe agent completion notifications to surface in list NotificationCenter.default.addObserver( forName: .codMateAgentCompleted, object: nil, queue: .main ) { [weak self] note in guard let id = note.userInfo?["sessionID"] as? String else { return } Task { @MainActor in self?.awaitingFollowupIDs.insert(id) } } // React to Active Provider changes to keep usage capsule in sync immediately NotificationCenter.default.addObserver( forName: .codMateActiveProviderChanged, object: nil, queue: .main ) { [weak self] note in guard let self else { return } let consumer = note.userInfo?["consumer"] as? String let providerId = note.userInfo?["providerId"] as? String Task { @MainActor in if consumer == ProvidersRegistryService.Consumer.codex.rawValue { guard self.preferences.isCLIEnabled(.codex) else { return } if providerId == nil || providerId?.isEmpty == true { self.refreshCodexUsageStatus() } else { self.setUsageSnapshot(.codex, Self.thirdPartyUsageSnapshot(for: .codex)) } } else if consumer == ProvidersRegistryService.Consumer.claudeCode.rawValue { guard self.preferences.isCLIEnabled(.claude) else { return } if providerId == nil || providerId?.isEmpty == true { self.claudeUsageAutoRefreshEnabled = false self.setInitialClaudePlaceholder() } else { self.claudeUsageAutoRefreshEnabled = false self.setUsageSnapshot(.claude, Self.thirdPartyUsageSnapshot(for: .claude)) } } } } startActivityPruneTicker() startIntentsCleanupTicker() // Observe remote host enablement changes to trigger sync preferences.$enabledRemoteHosts .removeDuplicates() .dropFirst() .sink { [weak self] _ in guard let self else { return } Task { await self.syncRemoteHosts(force: true, refreshAfter: true) } } .store(in: &cancellables) // Observe global CLI enablement changes var previousEnabledKinds = enabledCLIKindSet() Publishers.CombineLatest3( preferences.$cliCodexEnabled, preferences.$cliClaudeEnabled, preferences.$cliGeminiEnabled ) .dropFirst() .debounce(for: .milliseconds(200), scheduler: DispatchQueue.main) .sink { [weak self] codex, claude, gemini in guard let self else { return } let current = Self.cliEnabledKindSet(codex: codex, claude: claude, gemini: gemini) guard current != previousEnabledKinds else { return } previousEnabledKinds = current self.cancelHeavyWork() self.activeScopeRefreshes.removeAll() Task { await self.refreshSessionsForProviderChange(force: true) } self.configureDirectoryMonitor() self.configureClaudeDirectoryMonitor() self.configureGeminiDirectoryMonitor() self.trimUsageSnapshotsForDisabledCLIs() } .store(in: &cancellables) // Observe session path configs changes (ignore rules, enabled state) // When enabled state changes, trigger full refresh to rebuild providers // When only ignore rules change, trigger refresh but only from cache (no filesystem scan) // Cache is preserved - sessions will reappear if ignore rules are removed later var previousConfigs: [SessionPathConfig] = preferences.sessionPathConfigs preferences.$sessionPathConfigs .dropFirst() .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) .sink { [weak self] newConfigs in guard let self else { return } // Check if enabled state changed (requires provider rebuild) let previousEnabled = Set( previousConfigs.filter { $0.enabled }.map { "\($0.kind.rawValue):\($0.id)" }) let currentEnabled = Set( newConfigs.filter { $0.enabled }.map { "\($0.kind.rawValue):\($0.id)" }) previousConfigs = newConfigs if previousEnabled != currentEnabled { // Enabled state changed - need full refresh to rebuild providers // Important: toggling providers can overlap with manual refresh (Cmd+R). Cancel pending work // and clear in-flight markers to avoid getting stuck in a "refresh already running" state. self.cancelHeavyWork() self.activeScopeRefreshes.removeAll() // Force a filesystem-backed refresh with .all scope to fully restore sessions for re-enabled providers. Task { await self.refreshSessionsForProviderChange(force: true) } } else { // Only ignore rules changed - refresh from cache only (no filesystem scan) // This applies new ignore rules to cached sessions without rescanning Task { await self.refreshSessionsFromCacheOnly() } } } .store(in: &cancellables) preferences.$timelineVisibleKinds .removeDuplicates() .dropFirst() .sink { [weak self] _ in self?.scheduleFiltersUpdate() } .store(in: &cancellables) // Pre-seed usage snapshots based on current Active Provider selection to avoid initial flicker Task { [weak self] in guard let self else { return } let codexOrigin = await self.providerOrigin(for: .codex) let claudeOrigin = await self.providerOrigin(for: .claude) let geminiOrigin = await self.providerOrigin(for: .gemini) await MainActor.run { if self.preferences.isCLIEnabled(.codex) { if codexOrigin == .thirdParty { self.setUsageSnapshot(.codex, Self.thirdPartyUsageSnapshot(for: .codex)) } else { // Immediately refresh Codex usage (like Gemini) for better initial UX self.refreshCodexUsageStatus(silent: false) } } if self.preferences.isCLIEnabled(.claude) { if claudeOrigin == .thirdParty { self.setUsageSnapshot(.claude, Self.thirdPartyUsageSnapshot(for: .claude)) } else { self.claudeUsageAutoRefreshEnabled = false self.setInitialClaudePlaceholder() } } if self.preferences.isCLIEnabled(.gemini) { if geminiOrigin == .thirdParty { self.setUsageSnapshot(.gemini, Self.thirdPartyUsageSnapshot(for: .gemini)) } else { self.refreshGeminiUsageStatus(silent: false) } } } await MainActor.run { self.autoRefreshUsageIfNeeded( codexOrigin: codexOrigin, claudeOrigin: claudeOrigin, geminiOrigin: geminiOrigin ) } } } // Immediate apply from UI (e.g., pressing Return in search field) func immediateApplyQuickSearch(_ text: String) { quickSearchText = text } private var activeRefreshToken = UUID() private var refreshPulseTask: Task? private var refreshStatusToken: String? private func beginRefreshStatus(force: Bool) { refreshPulseTask?.cancel() refreshStatusToken = StatusBarLogStore.shared.beginTask( force ? "Refreshing sessions (forced)..." : "Refreshing sessions...", level: .info, source: "Sessions" ) refreshPulseTask = Task.detached { [weak self] in guard let self else { return } var tick = 0 while !Task.isCancelled { try? await Task.sleep(nanoseconds: 1_000_000_000) tick += 1 let currentTick = tick await MainActor.run { let progressText: String if self.enrichmentTotal > 0 { progressText = "Enriching \(self.enrichmentProgress)/\(self.enrichmentTotal)" } else { progressText = "Scanning sessions" } StatusBarLogStore.shared.post( "\(progressText) - \(currentTick)s", level: .info, source: "Sessions" ) } } } } private func endRefreshStatus(elapsed: TimeInterval, isCurrent: Bool) { refreshPulseTask?.cancel() refreshPulseTask = nil guard let token = refreshStatusToken else { return } refreshStatusToken = nil if isCurrent { let count = allSessions.count StatusBarLogStore.shared.endTask( token, message: "Refresh complete in \(String(format: "%.1f", elapsed))s - \(count) sessions", level: .success, source: "Sessions" ) } else { StatusBarLogStore.shared.endTask(token) } } func refreshSessions(force: Bool = false) async { scheduledFilterRefresh?.cancel() scheduledFilterRefresh = nil let token = UUID() activeRefreshToken = token if shouldSkipForCacheUnavailable() { diagLogger.log( "refreshSessions skipped due to cache unavailable (cooldown) ts=\(self.ts(), format: .fixed(precision: 3))" ) StatusBarLogStore.shared.post( "Refresh skipped (cache unavailable)", level: .warning, source: "Sessions") await MainActor.run { self.isLoading = false } return } let scope = currentScope() let scopeKeyValue = scopeKey(scope) if shouldSkipRefresh(scope: scope, force: force) { diagLogger.log( "refreshSessions skipped (executing or recent) scope=\(scopeKeyValue, privacy: .public) force=\(force, privacy: .public) ts=\(self.ts(), format: .fixed(precision: 3))" ) StatusBarLogStore.shared.post( "Refresh skipped (already running)", level: .warning, source: "Sessions") await MainActor.run { self.isLoading = false } return } isLoading = true beginRefreshStatus(force: force) activeScopeRefreshes[scopeKeyValue] = token if force { invalidateEnrichmentCache(for: selectedDay) } let refreshBegan = Date() defer { let elapsed = Date().timeIntervalSince(refreshBegan) // Always clear the in-flight marker for this scope if it's ours. // Important: a refresh can be superseded (e.g. settings toggle -> Cmd+R), // and if we only clear on "current token" we can leave a stale marker behind, // causing future refreshes to be skipped indefinitely. if activeScopeRefreshes[scopeKeyValue] == token { activeScopeRefreshes.removeValue(forKey: scopeKeyValue) } if token == activeRefreshToken { isLoading = false lastRefreshAt = Date() lastRefreshScope = currentScope() endRefreshStatus(elapsed: elapsed, isCurrent: true) diagLogger.log( "refreshSessions done in \(elapsed, format: .fixed(precision: 3))s sessions=\(self.allSessions.count, privacy: .public) ts=\(self.ts(), format: .fixed(precision: 3))" ) } } // Ensure we have access to the sessions directory in sandbox mode await ensureSessionsAccess() let enabledRemoteHosts = preferences.enabledRemoteHosts diagLogger.log( "refreshSessions start force=\(force, privacy: .public) scope=\(String(describing: scope), privacy: .public) ts=\(self.ts(), format: .fixed(precision: 3)) hosts=\(enabledRemoteHosts.count, privacy: .public)" ) let providers = buildProviders(enabledRemoteHosts: Set(enabledRemoteHosts)) let projectDirectories = singleSelectedProjectDirectory() let scopedProjectIds = dateDimension == .created ? singleSelectedProject() : nil let scopedProjectDirectories = dateDimension == .created ? projectDirectories : nil let scopedDateRange = dateDimension == .created ? currentDateRange() : nil // Get ignored paths for Codex (merge all enabled Codex configs) let codexConfigs = preferences.sessionPathConfigs.filter { $0.kind == .codex && $0.enabled } let codexIgnoredPaths = codexConfigs.flatMap { $0.ignoredSubpaths } let cacheContext = SessionProviderContext( scope: scope, sessionsRoot: preferences.sessionsRoot, enabledRemoteHosts: Set(enabledRemoteHosts), projectDirectories: scopedProjectDirectories, dateDimension: dateDimension, dateRange: scopedDateRange, projectIds: scopedProjectIds, forceFilesystemScan: false, cachePolicy: .cacheOnly, ignoredPaths: codexIgnoredPaths ) let refreshContext = SessionProviderContext( scope: scope, sessionsRoot: preferences.sessionsRoot, enabledRemoteHosts: Set(enabledRemoteHosts), projectDirectories: scopedProjectDirectories, dateDimension: dateDimension, dateRange: scopedDateRange, projectIds: scopedProjectIds, forceFilesystemScan: force, cachePolicy: .refresh, ignoredPaths: codexIgnoredPaths ) let cachedResults = await loadProviders(providers, context: cacheContext) let cachedSessions = dedupProviderSessions(cachedResults) let notes = await notesStore.all() notesSnapshot = notes if token == activeRefreshToken, !cachedSessions.isEmpty { var cachedForApply = cachedSessions apply(notes: notes, to: &cachedForApply) registerActivityHeartbeat(previous: allSessions, current: cachedForApply) smartMergeAllSessions(newSessions: cachedForApply) scheduleFiltersUpdate() } let refreshedResults = await loadProviders(providers, context: refreshContext) var sessions = dedupProviderSessions(cachedSessions + refreshedResults) guard token == activeRefreshToken else { return } let previousIDs = Set(allSessions.map { $0.id }) // Refresh projects/memberships snapshot and import legacy mappings if needed Task { @MainActor in await self.loadProjects() await self.importMembershipsFromNotesIfNeeded(notes: notes) } apply(notes: notes, to: &sessions) // Auto-assign on newly appeared sessions matched with pending intents let newlyAppeared = sessions.filter { !previousIDs.contains($0.id) } if !newlyAppeared.isEmpty { for s in newlyAppeared { self.handleAutoAssignIfMatches(s) } } registerActivityHeartbeat(previous: allSessions, current: sessions) // Smart merge: only update if data actually changed to avoid unnecessary UI re-renders smartMergeAllSessions(newSessions: sessions) persistProjectAssignmentsToCache(sessions) recomputeProjectCounts() rebuildCanonicalCwdCache() await computeCalendarCaches() scheduleFiltersUpdate() // TEMPORARILY DISABLED FOR PERFORMANCE TESTING // Background enrichment causes continuous UI updates during scrolling // startBackgroundEnrichment() currentMonthDimension = dateDimension currentMonthKey = monthKey(for: selectedDay, dimension: dateDimension) Task { await self.refreshGlobalCount() } // Refresh path tree to ensure newly created files appear via refresh let enabledRemoteHostsForCounts = enabledRemoteHosts let sessionsRootForCounts = sessionsRoot Task { var counts: [String: Int] = [:] if self.preferences.isCLIEnabled(.codex) { counts = await indexer.collectCWDCounts(root: sessionsRootForCounts) } if self.preferences.isCLIEnabled(.claude) { let claudeCounts = await claudeProvider.collectCWDCounts() for (key, value) in claudeCounts { counts[key, default: 0] += value } } if self.preferences.isCLIEnabled(.gemini) { let geminiCounts = await geminiProvider.collectCWDCounts() for (key, value) in geminiCounts { counts[key, default: 0] += value } } if !enabledRemoteHostsForCounts.isEmpty { let remoteCodex = await remoteProvider.collectCWDAggregates( kind: .codex, enabledHosts: enabledRemoteHostsForCounts) for (key, value) in remoteCodex { counts[key, default: 0] += value } let remoteClaude = await remoteProvider.collectCWDAggregates( kind: .claude, enabledHosts: enabledRemoteHostsForCounts) if self.preferences.isCLIEnabled(.claude) { for (key, value) in remoteClaude { counts[key, default: 0] += value } } } let tree = counts.buildPathTreeFromCounts() await MainActor.run { self.pathTreeRootPublished = tree } } Task { [weak self] in guard let self else { return } self.indexMeta = await self.indexer.currentMeta() self.cacheCoverage = await self.indexer.currentCoverage() self.diagLogger.log( "refreshSessions meta/coverage updated metaCount=\(self.indexMeta?.sessionCount ?? -1, privacy: .public) coverageCount=\(self.cacheCoverage?.sessionCount ?? -1, privacy: .public) ts=\(self.ts(), format: .fixed(precision: 3))" ) } if preferences.isCLIEnabled(.codex) { refreshCodexUsageStatus() } if preferences.isCLIEnabled(.claude), claudeUsageAutoRefreshEnabled { refreshClaudeUsageStatus(silent: false) } if preferences.isCLIEnabled(.gemini) { refreshGeminiUsageStatus(silent: false) } schedulePathTreeRefresh() // Ensure currently selected sessions are fully up-to-date with high-quality parsing. // This fixes the issue where global refresh (fast parse) keeps selected item in 'metadata' state // when user explicitly requests a refresh (Cmd+R). if !selectedSessionIDs.isEmpty { Task { await self.refreshSelectedSessions(sessionIds: self.selectedSessionIDs, force: force) } } } /// Refresh sessions when provider enabled state changes /// Loads all sessions (scope: .all) to ensure complete restoration when re-enabling a provider private func refreshSessionsForProviderChange(force: Bool = false) async { scheduledFilterRefresh?.cancel() scheduledFilterRefresh = nil let token = UUID() activeRefreshToken = token // Use .all scope to load all sessions when provider state changes let scope: SessionLoadScope = .all let scopeKeyValue = scopeKey(scope) isLoading = true beginRefreshStatus(force: force) activeScopeRefreshes[scopeKeyValue] = token if force { invalidateEnrichmentCache(for: selectedDay) } let refreshBegan = Date() defer { let elapsed = Date().timeIntervalSince(refreshBegan) // Always clear the in-flight marker for this scope if it's ours. if activeScopeRefreshes[scopeKeyValue] == token { activeScopeRefreshes.removeValue(forKey: scopeKeyValue) } if token == activeRefreshToken { isLoading = false lastRefreshAt = Date() lastRefreshScope = scope endRefreshStatus(elapsed: elapsed, isCurrent: true) diagLogger.log( "refreshSessionsForProviderChange done in \(elapsed, format: .fixed(precision: 3))s sessions=\(self.allSessions.count, privacy: .public) ts=\(self.ts(), format: .fixed(precision: 3))" ) } } await ensureSessionsAccess() let enabledRemoteHosts = preferences.enabledRemoteHosts diagLogger.log( "refreshSessionsForProviderChange start force=\(force, privacy: .public) scope=all ts=\(self.ts(), format: .fixed(precision: 3))" ) let providers = buildProviders(enabledRemoteHosts: Set(enabledRemoteHosts)) // Load all sessions, not scoped to current selection let codexConfigs = preferences.sessionPathConfigs.filter { $0.kind == .codex && $0.enabled } let codexIgnoredPaths = codexConfigs.flatMap { $0.ignoredSubpaths } let cacheContext = SessionProviderContext( scope: scope, sessionsRoot: preferences.sessionsRoot, enabledRemoteHosts: Set(enabledRemoteHosts), projectDirectories: nil, // Load all dateDimension: dateDimension, dateRange: nil, // Load all projectIds: nil, // Load all forceFilesystemScan: false, cachePolicy: .cacheOnly, ignoredPaths: codexIgnoredPaths ) let refreshContext = SessionProviderContext( scope: scope, sessionsRoot: preferences.sessionsRoot, enabledRemoteHosts: Set(enabledRemoteHosts), projectDirectories: nil, // Load all dateDimension: dateDimension, dateRange: nil, // Load all projectIds: nil, // Load all forceFilesystemScan: force, cachePolicy: .refresh, ignoredPaths: codexIgnoredPaths ) let cachedResults = await loadProviders(providers, context: cacheContext) let cachedSessions = dedupProviderSessions(cachedResults) let notes = await notesStore.all() notesSnapshot = notes if token == activeRefreshToken, !cachedSessions.isEmpty { var cachedForApply = cachedSessions apply(notes: notes, to: &cachedForApply) registerActivityHeartbeat(previous: allSessions, current: cachedForApply) smartMergeAllSessions(newSessions: cachedForApply) scheduleFiltersUpdate() } let refreshedResults = await loadProviders(providers, context: refreshContext) var sessions = dedupProviderSessions(cachedSessions + refreshedResults) guard token == activeRefreshToken else { return } let previousIDs = Set(allSessions.map { $0.id }) Task { @MainActor in await self.loadProjects() await self.importMembershipsFromNotesIfNeeded(notes: notes) } apply(notes: notes, to: &sessions) let newlyAppeared = sessions.filter { !previousIDs.contains($0.id) } if !newlyAppeared.isEmpty { for s in newlyAppeared { self.handleAutoAssignIfMatches(s) } } registerActivityHeartbeat(previous: allSessions, current: sessions) smartMergeAllSessions(newSessions: sessions) persistProjectAssignmentsToCache(sessions) recomputeProjectCounts() rebuildCanonicalCwdCache() await computeCalendarCaches() scheduleFiltersUpdate() currentMonthDimension = dateDimension currentMonthKey = monthKey(for: selectedDay, dimension: dateDimension) Task { await self.refreshGlobalCount() } let enabledRemoteHostsForCounts = enabledRemoteHosts let sessionsRootForCounts = sessionsRoot Task { var counts: [String: Int] = [:] if self.preferences.isCLIEnabled(.codex) { counts = await indexer.collectCWDCounts(root: sessionsRootForCounts) } if self.preferences.isCLIEnabled(.claude) { let claudeCounts = await claudeProvider.collectCWDCounts() for (key, value) in claudeCounts { counts[key, default: 0] += value } } if self.preferences.isCLIEnabled(.gemini) { let geminiCounts = await geminiProvider.collectCWDCounts() for (key, value) in geminiCounts { counts[key, default: 0] += value } } if !enabledRemoteHostsForCounts.isEmpty { if self.preferences.isCLIEnabled(.codex) { let remoteCodex = await remoteProvider.collectCWDAggregates( kind: .codex, enabledHosts: enabledRemoteHostsForCounts) for (key, value) in remoteCodex { counts[key, default: 0] += value } } if self.preferences.isCLIEnabled(.claude) { let remoteClaude = await remoteProvider.collectCWDAggregates( kind: .claude, enabledHosts: enabledRemoteHostsForCounts) for (key, value) in remoteClaude { counts[key, default: 0] += value } } } let tree = counts.buildPathTreeFromCounts() await MainActor.run { self.pathTreeRootPublished = tree } } Task { [weak self] in guard let self else { return } self.indexMeta = await self.indexer.currentMeta() self.cacheCoverage = await self.indexer.currentCoverage() self.diagLogger.log( "refreshSessionsForProviderChange meta/coverage updated metaCount=\(self.indexMeta?.sessionCount ?? -1, privacy: .public) coverageCount=\(self.cacheCoverage?.sessionCount ?? -1, privacy: .public) ts=\(self.ts(), format: .fixed(precision: 3))" ) } if preferences.isCLIEnabled(.codex) { refreshCodexUsageStatus() } if preferences.isCLIEnabled(.claude), claudeUsageAutoRefreshEnabled { refreshClaudeUsageStatus(silent: false) } if preferences.isCLIEnabled(.gemini) { refreshGeminiUsageStatus(silent: false) } schedulePathTreeRefresh() if !selectedSessionIDs.isEmpty { Task { await self.refreshSelectedSessions(sessionIds: self.selectedSessionIDs, force: force) } } } /// Hydrate sessions from cache on launch (lightweight, no filesystem scan or expensive recomputations) /// Used at app startup to quickly populate UI from cached data without triggering full refresh func hydrateFromCacheOnLaunch() async { let scope = currentScope() let enabledRemoteHosts = preferences.enabledRemoteHosts let providers = buildProviders(enabledRemoteHosts: Set(enabledRemoteHosts)) let projectDirectories = singleSelectedProjectDirectory() let scopedProjectIds = dateDimension == .created ? singleSelectedProject() : nil let scopedProjectDirectories = dateDimension == .created ? projectDirectories : nil let scopedDateRange = dateDimension == .created ? currentDateRange() : nil // Get ignored paths for Codex (merge all enabled Codex configs) let codexConfigs = preferences.sessionPathConfigs.filter { $0.kind == .codex && $0.enabled } let codexIgnoredPaths = codexConfigs.flatMap { $0.ignoredSubpaths } let cacheContext = SessionProviderContext( scope: scope, sessionsRoot: preferences.sessionsRoot, enabledRemoteHosts: Set(enabledRemoteHosts), projectDirectories: scopedProjectDirectories, dateDimension: dateDimension, dateRange: scopedDateRange, projectIds: scopedProjectIds, forceFilesystemScan: false, cachePolicy: .cacheOnly, ignoredPaths: codexIgnoredPaths ) let cachedResults = await loadProviders(providers, context: cacheContext) let sessions = dedupProviderSessions(cachedResults) let notes = await notesStore.all() notesSnapshot = notes var sessionsForApply = sessions apply(notes: notes, to: &sessionsForApply) registerActivityHeartbeat(previous: allSessions, current: sessionsForApply) smartMergeAllSessions(newSessions: sessionsForApply) scheduleFiltersUpdate() // Trigger usage refresh on launch (like Gemini) to ensure usage data is available immediately // This ensures the usage capsule icon and menu show data without requiring user interaction await MainActor.run { if preferences.isCLIEnabled(.codex) { refreshCodexUsageStatus(silent: false) } if preferences.isCLIEnabled(.gemini) { refreshGeminiUsageStatus(silent: false) } } diagLogger.log( "hydrateFromCacheOnLaunch done sessions=\(self.allSessions.count, privacy: .public) ts=\(self.ts(), format: .fixed(precision: 3))" ) } /// Refresh sessions from cache only (no filesystem scan) /// Used when ignore rules change - applies new rules to cached sessions without rescanning /// Cache is preserved - sessions will reappear if ignore rules are removed later /// When re-enabling a provider, loads all sessions (scope: .all) to ensure complete restoration private func refreshSessionsFromCacheOnly() async { // When ignore rules change or provider is re-enabled, load ALL sessions from cache // to ensure previously filtered sessions are restored let scope: SessionLoadScope = .all let enabledRemoteHosts = preferences.enabledRemoteHosts let providers = buildProviders(enabledRemoteHosts: Set(enabledRemoteHosts)) // Get ignored paths (merge all enabled configs) let codexConfigs = preferences.sessionPathConfigs.filter { $0.kind == .codex && $0.enabled } let codexIgnoredPaths = codexConfigs.flatMap { $0.ignoredSubpaths } let cacheContext = SessionProviderContext( scope: scope, sessionsRoot: preferences.sessionsRoot, enabledRemoteHosts: Set(enabledRemoteHosts), projectDirectories: nil, // Load all, not scoped to current selection dateDimension: dateDimension, dateRange: nil, // Load all, not scoped to current date range projectIds: nil, // Load all, not scoped to current project forceFilesystemScan: false, cachePolicy: .cacheOnly, ignoredPaths: codexIgnoredPaths ) let cachedResults = await loadProviders(providers, context: cacheContext) let sessions = dedupProviderSessions(cachedResults) let notes = await notesStore.all() notesSnapshot = notes var sessionsForApply = sessions apply(notes: notes, to: &sessionsForApply) registerActivityHeartbeat(previous: allSessions, current: sessionsForApply) smartMergeAllSessions(newSessions: sessionsForApply) scheduleFiltersUpdate() } // MARK: - Selected Sessions Incremental Refresh /// Refresh only the selected sessions, avoiding full scope scan. /// Returns true if any sessions were refreshed. func refreshSelectedSessions(sessionIds: Set, force: Bool = false) async -> Bool { guard !sessionIds.isEmpty else { return false } if shouldSkipForCacheUnavailable() { diagLogger.log( "refreshSelectedSessions skipped due to cache unavailable (cooldown) ts=\(self.ts(), format: .fixed(precision: 3))" ) return false } diagLogger.log( "refreshSelectedSessions: start sessionIds=\(sessionIds.count, privacy: .public) force=\(force, privacy: .public) ts=\(self.ts(), format: .fixed(precision: 3))" ) let refreshBegan = Date() // Pull cached file metadata (mtime/size) to avoid re-parsing unchanged files (Codex only) let cachedRecords = await indexer.fetchRecords(sessionIds: sessionIds) let cachedById = Dictionary(uniqueKeysWithValues: cachedRecords.map { ($0.summary.id, $0) }) // 1. Find the selected sessions in current allSessions let selectedSessions = allSessions.filter { sessionIds.contains($0.id) } guard !selectedSessions.isEmpty else { diagLogger.log("refreshSelectedSessions: no sessions found in allSessions for given IDs") return false } // Split by source so we can use the correct parser let codexSessions = selectedSessions.filter { $0.source.baseKind == .codex } let claudeSessions = selectedSessions.filter { $0.source.baseKind == .claude } var refreshedSummaries: [SessionSummary] = [] // 2. Codex: mtime/size check + reindex via SessionIndexer if !codexSessions.isEmpty { var needsRefresh: [(id: String, url: URL)] = [] for session in codexSessions { let record = cachedById[session.id] let fileURL = record.flatMap { URL(fileURLWithPath: $0.filePath) } ?? session.fileURL guard let values = try? fileURL.resourceValues( forKeys: [.contentModificationDateKey, .fileSizeKey, .isRegularFileKey]), values.isRegularFile == true else { // Missing file or unreadable: refresh to reconcile state needsRefresh.append((session.id, fileURL)) continue } if force { needsRefresh.append((session.id, fileURL)) continue } var hasComparableMetric = false var changed = false if let cachedMtime = record?.fileModificationTime, let mtime = values.contentModificationDate { hasComparableMetric = true if mtime > cachedMtime.addingTimeInterval(0.001) { changed = true } } if let cachedSize = record?.fileSize, let fsize = values.fileSize.map({ UInt64($0) }) { hasComparableMetric = true if cachedSize != fsize { changed = true } } // If we had no cached metrics, err on the side of refreshing if !hasComparableMetric || changed { needsRefresh.append((session.id, fileURL)) } } if !needsRefresh.isEmpty { diagLogger.log( "refreshSelectedSessions (codex): refreshing \(needsRefresh.count, privacy: .public) files" ) let urlsToRefresh = needsRefresh.map { $0.url } do { let codexSummaries = try await indexer.reindexFiles(urlsToRefresh) refreshedSummaries.append(contentsOf: codexSummaries) } catch { diagLogger.error( "refreshSelectedSessions: codex reindex failed: \(error.localizedDescription, privacy: .public)" ) } } } // 3. Claude/Gemini: always parse with provider-specific parsers when forced or when selection includes them. if !claudeSessions.isEmpty { let claudeParser = ClaudeSessionParser() func parseSummary(for session: SessionSummary) -> SessionSummary? { let url = session.fileURL let values = try? url.resourceValues(forKeys: [.fileSizeKey]) let fileSize = values?.fileSize.flatMap { UInt64($0) } switch session.source.baseKind { case .claude: return claudeParser.parse(at: url, fileSize: fileSize)?.summary default: return nil } } for session in claudeSessions { if let summary = parseSummary(for: session) { var merged = summary // Preserve user metadata (title/comment/task) merged.userTitle = session.userTitle merged.userComment = session.userComment merged.taskId = session.taskId refreshedSummaries.append(merged) } } } guard !refreshedSummaries.isEmpty else { diagLogger.log("refreshSelectedSessions: no changes detected, skipping refresh") return false } // 4. Update allSessions with refreshed data var didChange = false await MainActor.run { var updatedSessions = allSessions for refreshed in refreshedSummaries { if let index = updatedSessions.firstIndex(where: { $0.id == refreshed.id }) { var merged = refreshed merged.userTitle = updatedSessions[index].userTitle merged.userComment = updatedSessions[index].userComment merged.taskId = updatedSessions[index].taskId if updatedSessions[index] != merged { updatedSessions[index] = merged didChange = true } } } if didChange { allSessions = updatedSessions } } // 5. Re-apply filters to update UI if anything changed if didChange { scheduleFiltersUpdate() } let elapsed = Date().timeIntervalSince(refreshBegan) diagLogger.log( "refreshSelectedSessions: completed in \(elapsed, format: .fixed(precision: 3))s, refreshed=\(refreshedSummaries.count, privacy: .public)" ) return didChange } /// Schedule a debounced refresh for selected sessions. /// Call this method when selection changes to trigger incremental refresh. func scheduleSelectedSessionsRefresh(sessionIds: Set) { guard !sessionIds.isEmpty else { return } // Cancel any pending refresh selectedSessionsRefreshTask?.cancel() // Schedule new refresh with 100ms debounce selectedSessionsRefreshTask = Task { [weak self] in try? await Task.sleep(nanoseconds: 100_000_000) // 100ms guard let self, !Task.isCancelled else { return } _ = await self.refreshSelectedSessions(sessionIds: sessionIds, force: false) } } private func buildProviders(enabledRemoteHosts: Set) -> [any SessionProvider] { var providers: [any SessionProvider] = [] // Check if each kind is enabled in session path configs and globally enabled let codexEnabled = preferences.isCLIEnabled(.codex) && preferences.sessionPathConfigs.contains { $0.kind == .codex && $0.enabled } let claudeEnabled = preferences.isCLIEnabled(.claude) && preferences.sessionPathConfigs.contains { $0.kind == .claude && $0.enabled } let geminiEnabled = preferences.isCLIEnabled(.gemini) && preferences.sessionPathConfigs.contains { $0.kind == .gemini && $0.enabled } diagLogger.log( "buildProviders: codex=\(codexEnabled, privacy: .public) claude=\(claudeEnabled, privacy: .public) gemini=\(geminiEnabled, privacy: .public) remoteHosts=\(enabledRemoteHosts.count, privacy: .public)" ) if codexEnabled { providers.append(indexer) } if claudeEnabled { providers.append(claudeProvider) } if geminiEnabled { providers.append(geminiProvider) } if !enabledRemoteHosts.isEmpty { if codexEnabled { providers.append( RemoteSessionProviderAdapter( kind: .codex, remoteKind: .codex, provider: remoteProvider, label: "CodexRemote" ) ) } if claudeEnabled { providers.append( RemoteSessionProviderAdapter( kind: .claude, remoteKind: .claude, provider: remoteProvider, label: "ClaudeRemote" ) ) } } return providers } private func loadProviders( _ providers: [any SessionProvider], context: SessionProviderContext ) async -> [SessionSummary] { let logger = diagLogger let isCacheUnavailableError: (Error) -> Bool = { error in error is SessionIndexSQLiteStoreError || error is ClaudeSessionProvider.SessionProviderCacheError || error is GeminiSessionProvider.SessionProviderCacheError } return await withTaskGroup( of: ([SessionSummary], SessionIndexCoverage?, SessionSource.Kind).self ) { group in for provider in providers { group.addTask { [self] in // Get ignored paths for this provider's kind (merge all configs of same kind, must access on MainActor) // Filter out disabled subpaths let ignoredPaths = await MainActor.run { let configs = preferences.sessionPathConfigs.filter { $0.kind == provider.kind && $0.enabled } return configs.flatMap { config in config.ignoredSubpaths.filter { !config.disabledSubpaths.contains($0) } } } // Create context with provider-specific ignored paths let providerContext = SessionProviderContext( scope: context.scope, sessionsRoot: context.sessionsRoot, enabledRemoteHosts: context.enabledRemoteHosts, projectDirectories: context.projectDirectories, dateDimension: context.dateDimension, dateRange: context.dateRange, projectIds: context.projectIds, forceFilesystemScan: context.forceFilesystemScan, cachePolicy: context.cachePolicy, ignoredPaths: ignoredPaths ) do { let result = try await provider.load(context: providerContext) let label = result.summaries.first?.source.baseKind.rawValue ?? provider.kind.rawValue logger.log( "provider load success kind=\(label, privacy: .public) count=\(result.summaries.count, privacy: .public) cacheHit=\(result.cacheHit, privacy: .public)" ) if !result.summaries.isEmpty { await MainActor.run { self.clearCacheUnavailable() } } return (result.summaries, result.coverage, provider.kind) } catch { if isCacheUnavailableError(error) { await MainActor.run { self.markCacheUnavailableNow() } } logger.error( "provider load failed kind=\(provider.kind.rawValue, privacy: .public) error=\(error.localizedDescription, privacy: .public)" ) return ([], nil, provider.kind) } } } var all: [SessionSummary] = [] var latestCoverage: SessionIndexCoverage? for await output in group { all.append(contentsOf: output.0) if output.2 == .codex, let cov = output.1 { latestCoverage = cov } } if let cov = latestCoverage { await MainActor.run { self.cacheCoverage = cov } } return all } } private func dedupProviderSessions(_ sessions: [SessionSummary]) -> [SessionSummary] { guard !sessions.isEmpty else { return [] } var best: [String: SessionSummary] = [:] for session in sessions { if let existing = best[session.id] { best[session.id] = preferSession(lhs: existing, rhs: session) } else { best[session.id] = session } } return Array(best.values) } private func preferSession(lhs: SessionSummary, rhs: SessionSummary) -> SessionSummary { // 1. Prefer higher parse level (Enriched > Full > Metadata) if let lLevel = lhs.parseLevel, let rLevel = rhs.parseLevel { if lLevel != rLevel { return lLevel > rLevel ? lhs : rhs } } // If one has explicit high quality level and other is unknown (nil), prefer explicit high quality if let lLevel = lhs.parseLevel, lLevel > .metadata, rhs.parseLevel == nil { return lhs } if let rLevel = rhs.parseLevel, rLevel > .metadata, lhs.parseLevel == nil { return rhs } // CRITICAL FIX: Prefer sessions with higher counts (from full parse) over lower counts (from fast parse) // When same file (matching size), always prefer the one with more complete data let lt = lhs.lastUpdatedAt ?? lhs.startedAt let rt = rhs.lastUpdatedAt ?? rhs.startedAt let ls = lhs.fileSizeBytes ?? 0 let rs = rhs.fileSizeBytes ?? 0 // If file sizes match (same file), prefer the one with more complete data regardless of timestamp // This handles the case where fast parse and full parse have slightly different timestamps if ls > 0 && ls == rs { let lhsTotal = lhs.userMessageCount + lhs.assistantMessageCount + lhs.toolInvocationCount let rhsTotal = rhs.userMessageCount + rhs.assistantMessageCount + rhs.toolInvocationCount if lhsTotal != rhsTotal { return lhsTotal > rhsTotal ? lhs : rhs // Prefer richer data (full parse) } // If counts are equal, also check lineCount as another indicator of completeness if lhs.lineCount != rhs.lineCount { return lhs.lineCount > rhs.lineCount ? lhs : rhs } } // Original fallback logic if lt != rt { return lt > rt ? lhs : rhs } if ls != rs { return ls > rs ? lhs : rhs } return lhs.id < rhs.id ? lhs : rhs } /// Aggregated overview metrics from cached index (all sources). func fetchOverviewAggregate() async -> OverviewAggregate? { await indexer.fetchOverviewAggregate() } /// Aggregated overview metrics with scoped filters when supported by SQLite cache. func fetchOverviewAggregate(scope: OverviewAggregateScope?) async -> OverviewAggregate? { await indexer.fetchOverviewAggregate(scope: scope) } private func registerActivityHeartbeat(previous: [SessionSummary], current: [SessionSummary]) { // Map previous lastUpdated for quick lookup var prevMap: [String: Date] = [:] for s in previous { if let t = s.lastUpdatedAt { prevMap[s.id] = t } } let now = Date() var heartbeatChanged = false for s in current { guard let newT = s.lastUpdatedAt else { continue } if let oldT = prevMap[s.id], newT > oldT { activityHeartbeat[s.id] = now heartbeatChanged = true } } recomputeActiveUpdatingIDs() // Reschedule prune task if heartbeats changed if heartbeatChanged { scheduleActivityPruneIfNeeded() } } private var activityHeartbeat: [String: Date] = [:] private var activityPruneTask: Task? /// Schedule one-shot prune task based on earliest expiry time private func scheduleActivityPruneIfNeeded() { activityPruneTask?.cancel() activityPruneTask = nil guard !activityHeartbeat.isEmpty else { return } let now = Date() // Find earliest time when any ID would expire (3s after its heartbeat) let earliestExpiry = activityHeartbeat.values .map { $0.addingTimeInterval(3.0) } .filter { $0 > now } .min() guard let nextExpiry = earliestExpiry else { // All heartbeats are already expired, prune immediately recomputeActiveUpdatingIDs() return } let delay = nextExpiry.timeIntervalSince(now) guard delay > 0 else { recomputeActiveUpdatingIDs() return } activityPruneTask = Task { [weak self] in try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) guard !Task.isCancelled else { return } await MainActor.run { self?.recomputeActiveUpdatingIDs() // Reschedule if there are still active heartbeats self?.scheduleActivityPruneIfNeeded() } } } private func startActivityPruneTicker() { // Legacy method name kept for compatibility - now uses one-shot scheduling scheduleActivityPruneIfNeeded() } /// Schedule one-shot intent cleanup task based on earliest intent expiry func scheduleIntentsCleanupIfNeeded() { intentsCleanupTask?.cancel() intentsCleanupTask = nil guard !pendingAssignIntents.isEmpty else { return } let now = Date() // Intents expire 60s after creation let earliestExpiry = pendingAssignIntents .map { $0.t0.addingTimeInterval(60) } .filter { $0 > now } .min() guard let nextExpiry = earliestExpiry else { // All intents are already expired, prune immediately pruneExpiredIntents() return } let delay = nextExpiry.timeIntervalSince(now) guard delay > 0 else { pruneExpiredIntents() return } intentsCleanupTask = Task { [weak self] in try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) guard !Task.isCancelled else { return } await MainActor.run { self?.pruneExpiredIntents() // Reschedule if there are still pending intents self?.scheduleIntentsCleanupIfNeeded() } } } private func startIntentsCleanupTicker() { // Legacy method name kept for compatibility - now uses one-shot scheduling scheduleIntentsCleanupIfNeeded() } private func recomputeActiveUpdatingIDs() { let cutoff = Date().addingTimeInterval(-3.0) let newIDs = Set(activityHeartbeat.filter { $0.value > cutoff }.keys) guard newIDs != activeUpdatingIDs else { return } activeUpdatingIDs = newIDs // Reschedule prune task if heartbeats changed scheduleActivityPruneIfNeeded() } func isActivelyUpdating(_ id: String) -> Bool { activeUpdatingIDs.contains(id) } func isAwaitingFollowup(_ id: String) -> Bool { awaitingFollowupIDs.contains(id) } func clearAwaitingFollowup(_ id: String) { awaitingFollowupIDs.remove(id) } private func persistProjectAssignmentsToCache(_ sessions: [SessionSummary]) { guard !sessions.isEmpty else { return } let mapping = Dictionary(uniqueKeysWithValues: sessions.map { ($0.id, projectId(for: $0)) }) let resolver: @Sendable (SessionSummary) -> String? = { session in mapping[session.id] ?? nil } Task { [weak self] in guard let self else { return } await self.indexer.updateProjects(for: sessions, resolver: resolver) } } // Cancel ongoing background tasks (fulltext, enrichment, scheduled refreshes, quick pulses). // Useful when a heavy modal/sheet is presented and the UI should stay responsive. func cancelHeavyWork() { fulltextTask?.cancel() fulltextTask = nil enrichmentTask?.cancel() enrichmentTask = nil filterDebounceTask?.cancel() filterDebounceTask = nil scheduledFilterRefresh?.cancel() scheduledFilterRefresh = nil // Cancel all scoped refresh tasks for (_, task) in scopedRefreshTasks { task.cancel() } scopedRefreshTasks.removeAll() pendingScopeRefreshForce.removeAll() directoryRefreshTask?.cancel() directoryRefreshTask = nil fileEventAggregationTask?.cancel() fileEventAggregationTask = nil pendingFileEvents.removeAll() quickPulseTask?.cancel() quickPulseTask = nil codexUsageTask?.cancel() codexUsageTask = nil geminiUsageTask?.cancel() geminiUsageTask = nil pathTreeRefreshTask?.cancel() pathTreeRefreshTask = nil for task in calendarRefreshTasks.values { task.cancel() } calendarRefreshTasks.removeAll() isEnriching = false isLoading = false } func reveal(session: SessionSummary) { actions.revealInFinder(session: session) } func delete(summaries: [SessionSummary]) async { let count = summaries.count AppLogger.shared.info("Deleting \(count) session\(count == 1 ? "" : "s")", source: "Sessions") do { try actions.delete(summaries: summaries) await indexer.deleteSessions(ids: summaries.map(\.id)) AppLogger.shared.success( "Deleted \(count) session\(count == 1 ? "" : "s")", source: "Sessions") await refreshSessions(force: true) } catch { AppLogger.shared.error("Delete failed: \(error.localizedDescription)", source: "Sessions") errorMessage = error.localizedDescription } } func updateSessionsRoot(to newURL: URL) async { guard newURL != preferences.sessionsRoot else { return } // Save security-scoped bookmark if sandboxed SecurityScopedBookmarks.shared.save(url: newURL, for: .sessionsRoot) preferences.sessionsRoot = newURL await notesStore.updateRoot(to: preferences.notesRoot) await indexer.invalidateAll() enrichmentSnapshots.removeAll() configureDirectoryMonitor() await refreshSessions(force: true) } func updateNotesRoot(to newURL: URL) async { guard newURL != preferences.notesRoot else { return } SecurityScopedBookmarks.shared.save(url: newURL, for: .notesRoot) preferences.notesRoot = newURL await notesStore.updateRoot(to: newURL) // Reload notes snapshot and re-apply to current sessions let notes = await notesStore.all() notesSnapshot = notes var sessions = allSessions apply(notes: notes, to: &sessions) allSessions = sessions // Avoid publishing during view updates scheduleApplyFilters() } func updateProjectsRoot(to newURL: URL) async { guard newURL != preferences.projectsRoot else { return } SecurityScopedBookmarks.shared.save(url: newURL, for: .projectsRoot) preferences.projectsRoot = newURL let p = ProjectsStore.Paths( root: newURL, metadataDir: newURL.appendingPathComponent("metadata", isDirectory: true), membershipsURL: newURL.appendingPathComponent("memberships.json", isDirectory: false) ) self.projectsStore = ProjectsStore(paths: p) await geminiProvider.updateProjectsStore(self.projectsStore) await loadProjects() // Avoid publishing changes during view update; schedule on next runloop tick Task { @MainActor [weak self] in guard let self else { return } self.recomputeProjectCounts() self.scheduleApplyFilters() } } // Removed: executable path updates – CLI resolution uses PATH var totalSessionCount: Int { globalSessionCount } // Expose data for navigation helpers func calendarCounts(for monthStart: Date, dimension: DateDimension) -> [Int: Int] { let key = cacheKey(monthStart, dimension) if let cached = monthCountsCache[key] { return cached } let monthKey = Self.monthFormatter.string(from: monthStart) let coverage = dimension == .updated ? monthCoverageMap(for: monthKey) : [:] let counts = Self.computeMonthCounts( sessions: allSessions, monthKey: monthKey, dimension: dimension, dayIndex: sessionDayCache, coverage: coverage) // Update cache synchronously to avoid race conditions monthCountsCache[key] = counts currentMonthKey = key currentMonthDimension = dimension if dimension == .updated { // Use current selected path for accurate cache key triggerCoverageLoad(for: monthStart, dimension: dimension, projectPath: selectedPath) } return counts } private func countsForLoadedMonth(dimension: DateDimension) -> [Int: Int] { guard let key = currentMonthKey else { return [:] } let components = key.split(separator: "|", maxSplits: 1, omittingEmptySubsequences: false) guard components.count == 2 else { return [:] } let monthKey = String(components[1]) return Self.computeMonthCounts( sessions: allSessions, monthKey: monthKey, dimension: dimension, dayIndex: sessionDayCache) } func ensureCalendarCounts(for monthStart: Date, dimension: DateDimension) { let key = cacheKey(monthStart, dimension) if monthCountsCache[key] != nil { return } if currentMonthDimension == dimension, let currentKey = currentMonthKey, currentKey == key { let counts = countsForLoadedMonth(dimension: dimension) DispatchQueue.main.async { [weak self] in self?.monthCountsCache[key] = counts } return } let enabledHosts = preferences.enabledRemoteHosts let sessionsRoot = preferences.sessionsRoot Task { [weak self, monthStart, dimension, enabledHosts, sessionsRoot] in guard let self else { return } let started = Date() self.diagLogger.log( "calendarCounts start month=\(key, privacy: .public) ts=\(self.ts(), format: .fixed(precision: 3))" ) var merged = await self.indexer.computeCalendarCounts( root: sessionsRoot, monthStart: monthStart, dimension: dimension) if !enabledHosts.isEmpty { let remoteCodex = await self.remoteProvider.codexSessions( scope: .month(monthStart), enabledHosts: enabledHosts) let remoteClaude = await self.remoteProvider.claudeSessions( scope: .month(monthStart), enabledHosts: enabledHosts) let remoteSessions = remoteCodex + remoteClaude if !remoteSessions.isEmpty { let calendar = Calendar.current for session in remoteSessions { let referenceDate: Date switch dimension { case .created: referenceDate = session.startedAt case .updated: referenceDate = session.lastUpdatedAt ?? session.startedAt } guard calendar.isDate(referenceDate, equalTo: monthStart, toGranularity: .month) else { continue } let day = calendar.component(.day, from: referenceDate) merged[day, default: 0] += 1 } } } await MainActor.run { self.monthCountsCache[self.cacheKey(monthStart, dimension)] = merged } let elapsed = Date().timeIntervalSince(started) self.diagLogger.log( "calendarCounts done month=\(key, privacy: .public) days=\(merged.count, privacy: .public) in \(elapsed, format: .fixed(precision: 3))s ts=\(self.ts(), format: .fixed(precision: 3))" ) } } func cacheKey(_ monthStart: Date, _ dimension: DateDimension) -> String { return dimension.rawValue + "|" + Self.monthFormatter.string(from: monthStart) } private func coverageCacheKey( _ monthStart: Date, _ dimension: DateDimension, projectPath: String? = nil ) -> String { var key = dimension.rawValue + "|" + Self.monthFormatter.string(from: monthStart) if let path = projectPath { key += "|" + path } return key } var pathTreeRoot: PathTreeNode? { pathTreeRootPublished } func ensurePathTree() { if pathTreeRootPublished != nil { return } schedulePathTreeRefresh() } private func schedulePathTreeRefresh() { pathTreeRefreshTask?.cancel() pathTreeRefreshTask = Task { [weak self] in guard let self else { return } let started = Date() diagLogger.log("pathTreeRefresh start ts=\(ts(), format: .fixed(precision: 3))") defer { self.pathTreeRefreshTask = nil } var counts = self.cwdCounts(for: self.allSessions) self.lastPathCounts = counts let enabledHosts = preferences.enabledRemoteHosts if !enabledHosts.isEmpty { let remoteCodex = await remoteProvider.collectCWDAggregates( kind: .codex, enabledHosts: enabledHosts) for (key, value) in remoteCodex { counts[key, default: 0] += value } let remoteClaude = await remoteProvider.collectCWDAggregates( kind: .claude, enabledHosts: enabledHosts) for (key, value) in remoteClaude { counts[key, default: 0] += value } } let tree = await self.pathTreeStore.applySnapshot(counts: counts) await MainActor.run { self.pathTreeRootPublished = tree } let elapsed = Date().timeIntervalSince(started) diagLogger.log( "pathTreeRefresh done in \(elapsed, format: .fixed(precision: 3))s counts=\(counts.count, privacy: .public) ts=\(ts(), format: .fixed(precision: 3))" ) } } private func cwdCounts(for sessions: [SessionSummary]) -> [String: Int] { var counts: [String: Int] = [:] counts.reserveCapacity(sessions.count) for s in sessions { counts[s.cwd, default: 0] += 1 } return counts } private func diffCounts(old: [String: Int], new: [String: Int]) -> [String: Int] { var delta: [String: Int] = [:] let keys = Set(old.keys).union(new.keys) for k in keys { let d = (new[k] ?? 0) - (old[k] ?? 0) if d != 0 { delta[k] = d } } return delta } private func scheduleCalendarCountsRefresh( monthStart: Date, dimension: DateDimension, skipDebounce: Bool ) { // Legacy path removed; kept for compatibility if future disk scans are reintroduced. // For now, we compute counts synchronously from in-memory sessions. let key = cacheKey(monthStart, dimension) calendarRefreshTasks[key]?.cancel() if !skipDebounce { let delay = sidebarStatsDebounceNanoseconds calendarRefreshTasks[key] = Task { [weak self] in defer { self?.calendarRefreshTasks.removeValue(forKey: key) } try? await Task.sleep(nanoseconds: delay) } } } private func triggerCoverageLoad( for monthStart: Date, dimension: DateDimension, projectPath: String? = nil, forceRefresh: Bool = false ) { guard dimension == .updated else { return } let key = coverageCacheKey(monthStart, dimension, projectPath: projectPath) // Force refresh: invalidate cache for this scope if forceRefresh { let monthKey = Self.monthFormatter.string(from: monthStart) Task { await ripgrepStore.invalidateCoverage(monthKey: monthKey, projectPath: projectPath) } pendingCoverageMonths.remove(key) } // Cancel only this specific key's debounce task (not all of them!) coverageDebounceTasks[key]?.cancel() // Debounce: delay execution to avoid triggering too many scans during rapid month switching // Each key has its own debounce task, so switching between different months won't cancel each other coverageDebounceTasks[key] = Task { @MainActor in defer { coverageDebounceTasks.removeValue(forKey: key) } try? await Task.sleep(nanoseconds: 150_000_000) // 150ms debounce guard !Task.isCancelled else { return } if coverageLoadTasks[key] != nil { pendingCoverageMonths.insert(key) return } // Precise query scope: filter by month AND project path let targets = sessionsIntersecting(monthStart: monthStart, projectPath: projectPath) guard !targets.isEmpty else { return } coverageLoadTasks[key] = Task.detached(priority: .background) { [weak self] in guard let self else { return } let data = await self.ripgrepStore.dayCoverage(for: monthStart, sessions: targets) guard !Task.isCancelled else { return } await MainActor.run { self.coverageLoadTasks[key]?.cancel() self.coverageLoadTasks.removeValue(forKey: key) if data.isEmpty { if !targets.isEmpty { self.pendingCoverageMonths.insert(key) self.rebuildMonthCounts(for: monthStart, dimension: dimension, skipUIUpdate: true) } } else { self.applyCoverage(monthStart: monthStart, coverage: data) } if self.pendingCoverageMonths.remove(key) != nil { self.triggerCoverageLoad( for: monthStart, dimension: dimension, projectPath: projectPath) } } } } } private func requestCoverageIfNeeded(for day: Date) { guard dateDimension == .updated else { return } let monthStart = Self.normalizeMonthStart(day) // Use current selected path for accurate cache key triggerCoverageLoad(for: monthStart, dimension: .updated, projectPath: selectedPath) } private func sessionsIntersecting(monthStart: Date, projectPath: String? = nil) -> [SessionSummary] { let calendar = Calendar.current guard let monthEnd = calendar.date(byAdding: DateComponents(month: 1), to: monthStart) else { return [] } return allSessions.filter { summary in // Date range filter let start = summary.startedAt let end = summary.lastUpdatedAt ?? summary.startedAt guard end >= monthStart && start < monthEnd else { return false } // Project path filter (if specified) if let projectPath = projectPath { return summary.fileURL.path.hasPrefix(projectPath) } return true } } @MainActor private func applyCoverage(monthStart: Date, coverage: [String: Set]) { guard !coverage.isEmpty else { rebuildMonthCounts(for: monthStart, dimension: .updated, skipUIUpdate: true) return } let monthKey = monthKey(for: monthStart) var changed = false let validIDs = Set(allSessions.map(\.id)) for (sessionID, days) in coverage { guard validIDs.contains(sessionID) else { continue } let key = SessionMonthCoverageKey(sessionID: sessionID, monthKey: monthKey) if updatedMonthCoverage[key] != days { updatedMonthCoverage[key] = days changed = true } } if changed { invalidateVisibleCountCache() } rebuildMonthCounts(for: monthStart, dimension: .updated, skipUIUpdate: !changed) if changed { scheduleApplyFilters() } } private func monthCoverageMap(for monthKey: String) -> [String: Set] { var map: [String: Set] = [:] for (key, days) in updatedMonthCoverage where key.monthKey == monthKey { map[key.sessionID] = days } return map } private func rebuildMonthCounts( for monthStart: Date, dimension: DateDimension, skipUIUpdate: Bool = false ) { let key = cacheKey(monthStart, dimension) let monthKey = monthKey(for: monthStart) let coverage = dimension == .updated ? monthCoverageMap(for: monthKey) : [:] let counts = Self.computeMonthCounts( sessions: allSessions, monthKey: monthKey, dimension: dimension, dayIndex: sessionDayCache, coverage: coverage) monthCountsCache[key] = counts currentMonthKey = key currentMonthDimension = dimension if !skipUIUpdate { scheduleViewUpdate() } } // MARK: - Filter state management func setSelectedPath(_ path: String?) { if selectedPath == path { return } selectedPath = path } func setSelectedDay(_ day: Date?) { let normalized = day.map { Calendar.current.startOfDay(for: $0) } if selectedDay == normalized { return } suppressFilterNotifications = true selectedDay = normalized if let d = normalized { selectedDays = [d] } else { selectedDays.removeAll() } // In Created mode, when selecting a day, ensure the calendar sidebar shows that month // so we only need to load one month's data if dateDimension == .created, let d = normalized { let newMonthStart = Self.normalizeMonthStart(d) if newMonthStart != sidebarMonthStart { sidebarMonthStart = newMonthStart } } if let d = normalized { requestCoverageIfNeeded(for: d) } suppressFilterNotifications = false // Manually save calendar state since didSet was suppressed windowStateStore.saveCalendarSelection( selectedDay: selectedDay, selectedDays: selectedDays, monthStart: sidebarMonthStart) // Update UI using next-runloop to avoid publishing during view updates scheduleApplyFilters() // After coordinated update of selectedDay/selectedDays, trigger a refresh only in Created mode. if dateDimension == .created { scheduleFilterRefresh(force: true) } } // Toggle selection for a specific day (Cmd-click behavior) func toggleSelectedDay(_ day: Date) { let d = Calendar.current.startOfDay(for: day) suppressFilterNotifications = true if selectedDays.contains(d) { selectedDays.remove(d) } else { selectedDays.insert(d) } requestCoverageIfNeeded(for: d) // Keep single-selection reflected in selectedDay; otherwise nil if selectedDays.count == 1, let only = selectedDays.first { selectedDay = only } else if selectedDays.isEmpty { selectedDay = nil } else { selectedDay = nil } suppressFilterNotifications = false // Manually save calendar state since didSet was suppressed windowStateStore.saveCalendarSelection( selectedDay: selectedDay, selectedDays: selectedDays, monthStart: sidebarMonthStart) // Update UI using next-runloop to avoid publishing during view updates scheduleApplyFilters() if dateDimension == .created { scheduleFilterRefresh(force: true) } } func clearAllFilters() { suppressFilterNotifications = true selectedPath = nil selectedDay = nil selectedDays.removeAll() selectedProjectIDs.removeAll() suppressFilterNotifications = false // Manually save calendar state since didSet was suppressed windowStateStore.saveCalendarSelection( selectedDay: selectedDay, selectedDays: selectedDays, monthStart: sidebarMonthStart) scheduleSelectionDrivenUpdate() // Keep searchText unchanged to allow consecutive searches } // Clear only scope filters (directory and project), keep the date filter intact func clearScopeFilters() { suppressFilterNotifications = true selectedPath = nil selectedProjectIDs.removeAll() suppressFilterNotifications = false scheduleSelectionDrivenUpdate() } private func scheduleFiltersUpdate() { filterDebounceTask?.cancel() filterDebounceTask = Task { [weak self] in guard let self else { return } if filterDebounceNanoseconds > 0 { try? await Task.sleep(nanoseconds: filterDebounceNanoseconds) } self.scheduleApplyFilters() } } private func scheduleSelectionDrivenUpdate() { let needRefresh = shouldRefreshForSelection() if needRefresh { scheduleFilterRefresh(force: true) } else { scheduleApplyFilters() } } private func shouldRefreshForSelection() -> Bool { guard dateDimension == .created else { return false } let projectIsSingle = selectedProjectIDs.count == 1 let calendarIsSingle = (selectedDay != nil) || selectedDays.count == 1 return projectIsSingle || calendarIsSingle } private func singleSelectedProject() -> Set? { guard selectedProjectIDs.count == 1, let first = selectedProjectIDs.first else { return nil } return [first] } private func singleSelectedProjectDirectory() -> [String]? { guard let pid = selectedProjectIDs.first, selectedProjectIDs.count == 1 else { return nil } guard let project = projects.first(where: { $0.id == pid }), let dir = project.directory, !dir.isEmpty else { return nil } return [Self.canonicalPath(dir)] } private func currentDateRange() -> (Date, Date)? { let cal = Calendar.current var allDays: [Date] = [] if let day = selectedDay { allDays.append(cal.startOfDay(for: day)) } allDays.append(contentsOf: selectedDays.map { cal.startOfDay(for: $0) }) guard let minDay = allDays.min(), let maxDay = allDays.max() else { return nil } let start = minDay guard let end = cal.date(byAdding: .day, value: 1, to: maxDay)?.addingTimeInterval(-1) else { return nil } return (start, end) } func applyFilters() { filterTask?.cancel() guard !allSessions.isEmpty else { filterTask = nil // Defer sections modification to avoid "Publishing changes from within view updates" Task { @MainActor [weak self] in self?.sections = [] } return } filterGeneration &+= 1 let generation = filterGeneration let snapshot = makeFilterSnapshot() let started = Date() logApplyFiltersStart(reason: snapshot.reasonDescription) filterTask = Task { [weak self] in guard let self else { return } let computeTask = Task.detached(priority: .userInitiated) { Self.computeFilteredSections(using: snapshot) } defer { computeTask.cancel() } let result = await computeTask.value guard !Task.isCancelled else { return } guard self.filterGeneration == generation else { return } // Snapshot hash to skip duplicate work within a short window. let snapshotHash = snapshot.digest if let lastHash = self.lastFilterSnapshotHash, lastHash == snapshotHash { self.logApplyFiltersEnd( reason: snapshot.reasonDescription + " (skipped same snapshot)", elapsed: 0, sections: self.sections.count, sessions: self.allSessions.count ) self.pendingApplyFilters = false self.filterTask = nil return } self.lastFilterSnapshotHash = snapshotHash if !result.newCanonicalEntries.isEmpty { self.canonicalCwdCache.merge(result.newCanonicalEntries) { _, new in new } } // Use pre-computed sections from background task; avoid replacing when identical if self.sections != result.sections { self.sections = result.sections } let elapsed = Date().timeIntervalSince(started) self.logApplyFiltersEnd( reason: snapshot.reasonDescription, elapsed: elapsed, sections: result.sections.count, sessions: result.totalSessions ) // If more filter requests were queued while this task ran, flush one more apply. if self.pendingApplyFilters { self.pendingApplyFilters = false // Schedule on next runloop to avoid deep recursion. DispatchQueue.main.async { [weak self] in self?.applyFilters() } } else { self.filterTask = nil } } } private func makeFilterSnapshot() -> FilterSnapshot { let pathFilter: FilterSnapshot.PathFilter? = { guard let path = selectedPath else { return nil } let canonical = Self.canonicalPath(path) let prefix = canonical == "/" ? "/" : canonical + "/" return .init(canonicalPath: canonical, prefix: prefix) }() let trimmedSearch = quickSearchText.trimmingCharacters(in: .whitespacesAndNewlines) let quickNeedle = trimmedSearch.isEmpty ? nil : trimmedSearch.lowercased() let projectFilter: FilterSnapshot.ProjectFilter? = { guard !selectedProjectIDs.isEmpty else { return nil } var allowedProjects = Set() for pid in selectedProjectIDs { allowedProjects.insert(pid) allowedProjects.formUnion(collectDescendants(of: pid, in: projects)) } let allowedSources = projects.reduce(into: [String: Set]()) { $0[$1.id] = $1.sources } return .init( memberships: projectMemberships, allowedProjects: allowedProjects, allowedSourcesByProject: allowedSources, includeUnassigned: allowedProjects.contains(Self.otherProjectId) ) }() var dayIndexMap: [String: SessionDayIndex] = [:] dayIndexMap.reserveCapacity(allSessions.count) for session in allSessions { dayIndexMap[session.id] = dayIndex(for: session) } let dayDescriptors = Self.makeDayDescriptors( selectedDays: selectedDays, singleDay: selectedDay ) return FilterSnapshot( sessions: allSessions, sessionsVersion: sessionsVersion, pathFilter: pathFilter, projectFilter: projectFilter, selectedDays: selectedDays, singleDay: selectedDay, dateDimension: dateDimension, quickSearchNeedle: quickNeedle, sortOrder: sortOrder, visibleKinds: preferences.timelineVisibleKinds, canonicalCache: canonicalCwdCache, dayIndex: dayIndexMap, dayCoverage: updatedMonthCoverage, dayDescriptors: dayDescriptors, reasonDescription: "filters: projects=\(selectedProjectIDs.count) path=\(selectedPath ?? "nil") days=\(selectedDays.count) dim=\(dateDimension.rawValue) search=\(trimmedSearch.isEmpty ? "none" : "non-empty") isLoading=\(isLoading)" ) } nonisolated private static func computeFilteredSections(using snapshot: FilterSnapshot) -> FilterComputationResult { var filtered = snapshot.sessions var canonicalCache = snapshot.canonicalCache var newCanonicalEntries: [String: String] = [:] if let pathFilter = snapshot.pathFilter { var matches: [SessionSummary] = [] matches.reserveCapacity(filtered.count) for summary in filtered { let canonical: String if let cached = canonicalCache[summary.id] { canonical = cached } else { let value = Self.canonicalPath(summary.cwd) canonicalCache[summary.id] = value newCanonicalEntries[summary.id] = value canonical = value } if canonical == pathFilter.canonicalPath || canonical.hasPrefix(pathFilter.prefix) { matches.append(summary) } } filtered = matches } if let projectFilter = snapshot.projectFilter { let memberships = projectFilter.memberships let allowedProjects = projectFilter.allowedProjects let allowedSources = projectFilter.allowedSourcesByProject var matches: [SessionSummary] = [] matches.reserveCapacity(filtered.count) for summary in filtered { let membershipKey = "\(summary.source.projectSource.rawValue)|\(summary.id)" if let assigned = memberships[membershipKey] { guard allowedProjects.contains(assigned) else { continue } let allowedSet = allowedSources[assigned] ?? ProjectSessionSource.allSet if allowedSet.contains(summary.source.projectSource) { matches.append(summary) } } else if projectFilter.includeUnassigned { matches.append(summary) } } filtered = matches } if !snapshot.dayDescriptors.isEmpty { let calendar = Calendar.current filtered = filtered.filter { summary in let bucket = snapshot.dayIndex[summary.id] return Self.matchesDayDescriptors( summary: summary, bucket: bucket, descriptors: snapshot.dayDescriptors, dimension: snapshot.dateDimension, coverage: snapshot.dayCoverage, calendar: calendar ) } } if let needle = snapshot.quickSearchNeedle { filtered = filtered.filter { s in if s.effectiveTitle.lowercased().contains(needle) { return true } if let c = s.userComment?.lowercased(), c.contains(needle) { return true } return false } } filtered = snapshot.sortOrder.sort( filtered, dimension: snapshot.dateDimension, visibleKinds: snapshot.visibleKinds ) let sections = Self.groupSessions( filtered, dimension: snapshot.dateDimension, visibleKinds: snapshot.visibleKinds ) return FilterComputationResult( filteredSessions: filtered, sections: sections, newCanonicalEntries: newCanonicalEntries, totalSessions: filtered.count ) } nonisolated private static func matchesDayDescriptors( summary: SessionSummary, bucket: SessionDayIndex?, descriptors: [DaySelectionDescriptor], dimension: DateDimension, coverage: [SessionMonthCoverageKey: Set], calendar: Calendar ) -> Bool { guard let bucket else { return false } for descriptor in descriptors { switch dimension { case .created: if calendar.isDate(bucket.created, inSameDayAs: descriptor.date) { return true } case .updated: let key = SessionMonthCoverageKey(sessionID: summary.id, monthKey: descriptor.monthKey) if let days = coverage[key], days.contains(descriptor.day) { return true } if calendar.isDate(bucket.updated, inSameDayAs: descriptor.date) { return true } } } return false } nonisolated private static func referenceDate( for session: SessionSummary, dimension: DateDimension ) -> Date { switch dimension { case .created: return session.startedAt case .updated: return session.lastUpdatedAt ?? session.startedAt } } private struct FilterSnapshot: Sendable { struct PathFilter: Sendable { let canonicalPath: String let prefix: String } struct ProjectFilter: Sendable { let memberships: [String: String] let allowedProjects: Set let allowedSourcesByProject: [String: Set] let includeUnassigned: Bool } let sessions: [SessionSummary] let sessionsVersion: UInt64 let pathFilter: PathFilter? let projectFilter: ProjectFilter? let selectedDays: Set let singleDay: Date? let dateDimension: DateDimension let quickSearchNeedle: String? let sortOrder: SessionSortOrder let visibleKinds: Set let canonicalCache: [String: String] let dayIndex: [String: SessionDayIndex] let dayCoverage: [SessionMonthCoverageKey: Set] let dayDescriptors: [DaySelectionDescriptor] let reasonDescription: String var digest: Int { var hasher = Hasher() hasher.combine(pathFilter?.canonicalPath ?? "") hasher.combine(pathFilter?.prefix ?? "") hasher.combine(projectFilter?.allowedProjects.count ?? 0) hasher.combine(selectedDays.count) hasher.combine(singleDay?.timeIntervalSince1970 ?? 0) hasher.combine(dateDimension.rawValue) hasher.combine(quickSearchNeedle ?? "") hasher.combine(sortOrder.rawValue) hasher.combine(visibleKinds.count) for value in visibleKinds.rawValues { hasher.combine(value) } hasher.combine(sessionsVersion) return hasher.finalize() } } private struct FilterComputationResult: Sendable { let filteredSessions: [SessionSummary] let sections: [SessionDaySection] let newCanonicalEntries: [String: String] let totalSessions: Int } nonisolated private static func groupSessions( _ sessions: [SessionSummary], dimension: DateDimension, visibleKinds: Set ) -> [SessionDaySection] { let calendar = Calendar.current let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateStyle = .medium formatter.timeStyle = .none var grouped: [Date: [SessionSummary]] = [:] for session in sessions { // Grouping honors the selected calendar dimension: // - Created: group by startedAt // - Last Updated: group by lastUpdatedAt (fallback to startedAt) let referenceDate: Date = { switch dimension { case .created: return session.startedAt case .updated: return session.lastUpdatedAt ?? session.startedAt } }() let day = calendar.startOfDay(for: referenceDate) grouped[day, default: []].append(session) } return grouped .sorted(by: { $0.key > $1.key }) .map { day, sessions in let totalDuration = sessions.reduce(into: 0.0) { $0 += $1.duration } let totalEvents = sessions.reduce(0) { $0 + $1.visibleEventCount(using: visibleKinds) } let title: String if calendar.isDateInToday(day) { title = "Today" } else if calendar.isDateInYesterday(day) { title = "Yesterday" } else { title = formatter.string(from: day) } return SessionDaySection( id: day, title: title, totalDuration: totalDuration, totalEvents: totalEvents, sessions: sessions ) } } // MARK: - Fulltext search private func scheduleFulltextSearchIfNeeded() { scheduleFiltersUpdate() // update metadata-only matches quickly fulltextTask?.cancel() let term = searchText.trimmingCharacters(in: .whitespacesAndNewlines) guard !term.isEmpty else { fulltextMatches.removeAll() return } fulltextTask = Task { [allSessions] in // naive full-scan var matched = Set() for s in allSessions { if Task.isCancelled { return } if await indexer.fileContains(url: s.fileURL, term: term) { matched.insert(s.id) } } await MainActor.run { self.fulltextMatches = matched self.scheduleApplyFilters() } } } // MARK: - Calendar caches (placeholder for future optimization) private func computeCalendarCaches() async {} // MARK: - Background enrichment private func startBackgroundEnrichment() { enrichmentTask?.cancel() guard let cacheKey = dayCacheKey(for: selectedDay) else { // Should not happen; we now return a synthetic key even when day is nil isEnriching = false enrichmentProgress = 0 enrichmentTotal = 0 return } // When a day is selected, enrich that day's sessions; otherwise enrich currently displayed ones let sessions: [SessionSummary] if selectedDay != nil { sessions = sessionsForCurrentDay() } else { sessions = sections.flatMap { $0.sessions } } let currentIDs = Set(sessions.map(\.id)) if let cached = enrichmentSnapshots[cacheKey], cached == currentIDs { isEnriching = false enrichmentProgress = 0 enrichmentTotal = 0 return } if sessions.isEmpty { isEnriching = false enrichmentProgress = 0 enrichmentTotal = 0 enrichmentSnapshots[cacheKey] = currentIDs return } enrichmentTask = Task { [weak self] in guard let self else { return } await MainActor.run { self.isEnriching = true self.enrichmentProgress = 0 self.enrichmentTotal = sessions.count } let concurrency = max(2, ProcessInfo.processInfo.processorCount / 2) try? await withThrowingTaskGroup(of: (String, SessionSummary)?.self) { group in var iterator = sessions.makeIterator() var processedCount = 0 func addNext(_ n: Int) { for _ in 0..= 50 || elapsed.components.seconds >= 1 { await flush() } } addNext(1) } await flush() await MainActor.run { self.isEnriching = false self.enrichmentProgress = 0 self.enrichmentTotal = 0 self.enrichmentSnapshots[cacheKey] = currentIDs } } } } private func sessionsForCurrentDay() -> [SessionSummary] { guard let day = selectedDay else { return [] } let calendar = Calendar.current let pathFilter = selectedPath.map(Self.canonicalPath) return allSessions.filter { summary in let matchesDay: Bool = { switch dateDimension { case .created: return calendar.isDate(summary.startedAt, inSameDayAs: day) case .updated: if let end = summary.lastUpdatedAt { return calendar.isDate(end, inSameDayAs: day) } return calendar.isDate(summary.startedAt, inSameDayAs: day) } }() guard matchesDay else { return false } guard let path = pathFilter else { return true } let canonical = canonicalCwdCache[summary.id] ?? Self.canonicalPath(summary.cwd) return canonical == path || canonical.hasPrefix(path + "/") } } private func rebuildCanonicalCwdCache() { canonicalCwdCache = Dictionary( uniqueKeysWithValues: allSessions.map { ($0.id, Self.canonicalPath($0.cwd)) }) } func rebuildGeminiProjectHashLookup() { guard preferences.isCLIEnabled(.gemini) else { geminiProjectPathByHash = [:] return } geminiProjectPathByHash = Self.computeGeminiProjectHashes(from: projects) } func updateSelection(_ ids: Set) { selectedSessionIDs = ids } nonisolated static func canonicalPath(_ path: String) -> String { let expanded = (path as NSString).expandingTildeInPath var standardized = URL(fileURLWithPath: expanded).standardizedFileURL.path if standardized.count > 1 && standardized.hasSuffix("/") { standardized.removeLast() } return standardized } private static func computeGeminiProjectHashes(from projects: [Project]) -> [String: String] { var map: [String: String] = [:] for project in projects { guard let dir = project.directory, !dir.isEmpty else { continue } guard let hash = geminiDirectoryHash(for: dir) else { continue } map[hash] = canonicalPath(dir) } return map } private static func geminiDirectoryHash(for directory: String) -> String? { let expanded = (directory as NSString).expandingTildeInPath guard let data = expanded.data(using: .utf8) else { return nil } let digest = SHA256.hash(data: data) return digest.map { String(format: "%02x", $0) }.joined() } private static func geminiHashComponent(in path: String) -> String? { guard let range = path.range(of: "/.gemini/tmp/") else { return nil } let remainder = path[range.upperBound...] guard let candidate = remainder.split( separator: "/", maxSplits: 1, omittingEmptySubsequences: false ) .first else { return nil } let hash = String(candidate) guard hash.count == 64, hash.range(of: "^[0-9a-f]+$", options: .regularExpression) != nil else { return nil } return hash } func displayWorkingDirectory(for summary: SessionSummary) -> String { guard summary.source.baseKind == .gemini else { return summary.cwd } if let hash = Self.geminiHashComponent(in: summary.cwd), let resolved = geminiProjectPathByHash[hash] { return resolved } if let hash = Self.geminiHashComponent(in: summary.fileURL.path), let resolved = geminiProjectPathByHash[hash] { return resolved } return summary.cwd } func resolvedWorkingDirectory(for summary: SessionSummary) -> String { let candidate: String if summary.source.baseKind == .gemini && !summary.isRemote { candidate = displayWorkingDirectory(for: summary) } else { candidate = summary.cwd } let trimmed = candidate.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty, FileManager.default.fileExists(atPath: trimmed) { return trimmed } if FileManager.default.fileExists(atPath: summary.cwd) { return summary.cwd } let fallback = summary.fileURL.deletingLastPathComponent().path return fallback.isEmpty ? NSHomeDirectory() : fallback } private func currentScope() -> SessionLoadScope { switch dateDimension { case .created: // In Created mode, when a single day is selected, limit the scan // scope to that specific day for better performance and more // predictable behavior when users explicitly focus on "today". if let day = selectedDay { return .day(Calendar.current.startOfDay(for: day)) } if selectedDays.count == 1, let only = selectedDays.first { return .day(Calendar.current.startOfDay(for: only)) } // Fallback: load the month currently being viewed in the calendar // sidebar. Day filtering for the middle list still happens in // applyFilters(). return .month(sidebarMonthStart) case .updated: // Updated dimension: load everything since updates can cross month // boundaries and files on disk are organized by creation date. return .all } } func overviewAggregateScope() -> OverviewAggregateScope? { if selectedPath != nil { return nil } if !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return nil } if !quickSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return nil } let projects = selectedProjectIDs.isEmpty ? nil : selectedProjectIDs let cal = Calendar.current var allDays: [Date] = [] if let day = selectedDay { allDays.append(cal.startOfDay(for: day)) } allDays.append(contentsOf: selectedDays.map { cal.startOfDay(for: $0) }) if allDays.isEmpty, let projects { return OverviewAggregateScope( dateDimension: dateDimension, start: Date(timeIntervalSince1970: 0), end: .distantFuture, projectIds: projects ) } guard !allDays.isEmpty else { return nil } let start = allDays.min() ?? Date() let endBase = allDays.max() ?? start guard let end = cal.date(byAdding: .day, value: 1, to: endBase)?.addingTimeInterval(-1) else { return nil } return OverviewAggregateScope( dateDimension: dateDimension, start: start, end: end, projectIds: projects?.isEmpty == false ? projects : nil ) } /// Whether the Overview can safely use global cached aggregates without clashing with filters. var canUseGlobalOverviewAggregate: Bool { if dateDimension != .updated { return false } if selectedDay != nil || !selectedDays.isEmpty { return false } if selectedPath != nil { return false } if !selectedProjectIDs.isEmpty { return false } if !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return false } if !quickSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return false } return true } private func logApplyFiltersStart(reason: String) { diagLogger.log( "applyFilters start reason=\(reason, privacy: .public) ts=\(self.ts(), format: .fixed(precision: 3))" ) } private func logApplyFiltersEnd( reason: String, elapsed: TimeInterval, sections: Int, sessions: Int ) { diagLogger.log( "applyFilters done reason=\(reason, privacy: .public) in \(elapsed, format: .fixed(precision: 3))s sections=\(sections, privacy: .public) sessions=\(sessions, privacy: .public) ts=\(self.ts(), format: .fixed(precision: 3))" ) } private func configureDirectoryMonitor() { directoryMonitor?.cancel() directoryRefreshTask?.cancel() guard preferences.isCLIEnabled(.codex) else { directoryMonitor = nil return } let root = preferences.sessionsRoot guard FileManager.default.fileExists(atPath: root.path) else { directoryMonitor = nil return } directoryMonitor = DirectoryMonitor(url: root) { [weak self] in Task { @MainActor in self?.quickPulse() self?.scheduleDirectoryRefresh() } } } private func configureClaudeDirectoryMonitor() { claudeDirectoryMonitor?.cancel() guard preferences.isCLIEnabled(.claude) else { claudeDirectoryMonitor = nil return } // Default Claude projects root: ~/.claude/projects let home = FileManager.default.homeDirectoryForCurrentUser let projects = home .appendingPathComponent(".claude", isDirectory: true) .appendingPathComponent("projects", isDirectory: true) guard FileManager.default.fileExists(atPath: projects.path) else { claudeDirectoryMonitor = nil return } claudeDirectoryMonitor = DirectoryMonitor(url: projects) { [weak self] in Task { @MainActor in // Only perform targeted incremental refresh when we have a matching hint if let hint = self?.pendingIncrementalHint, Date() < (hint.expiresAt) { await self?.refreshIncremental(using: hint) } } } } private func configureGeminiDirectoryMonitor() { geminiDirectoryMonitor?.cancel() guard preferences.isCLIEnabled(.gemini) else { geminiDirectoryMonitor = nil return } // Default Gemini tmp root: ~/.gemini/tmp let home = SessionPreferencesStore.getRealUserHomeURL() let tmpRoot = home .appendingPathComponent(".gemini", isDirectory: true) .appendingPathComponent("tmp", isDirectory: true) guard FileManager.default.fileExists(atPath: tmpRoot.path) else { geminiDirectoryMonitor = nil return } geminiDirectoryMonitor = DirectoryMonitor(url: tmpRoot) { [weak self] in Task { @MainActor in // Trigger incremental refresh for Gemini sessions if let hint = self?.pendingIncrementalHint, Date() < (hint.expiresAt) { await self?.refreshIncremental(using: hint) } else { // Fallback to general refresh self?.quickPulse() self?.scheduleDirectoryRefresh() } } } } private func scheduleDirectoryRefresh() { // Use file event aggregation to collect changes within 500-1000ms window lastFileEventAt = Date() // Cancel existing aggregation task and start a new one fileEventAggregationTask?.cancel() fileEventAggregationTask = Task { @MainActor [weak self] in guard let self else { return } // Aggregate file events: wait 500ms for rapid changes, up to 1000ms total let rapidChangeWindow: UInt64 = 500_000_000 // 500ms let maxAggregationWindow: UInt64 = 1_000_000_000 // 1000ms let startTime = Date() // Wait for the rapid change window try? await Task.sleep(nanoseconds: rapidChangeWindow) guard !Task.isCancelled else { return } // Check if more events came in recently (within last 100ms) let timeSinceLastEvent = Date().timeIntervalSince(self.lastFileEventAt) if timeSinceLastEvent < 0.1 && Date().timeIntervalSince(startTime) < 1.0 { // More events are coming in, wait a bit more (up to max window) let remainingTime = maxAggregationWindow - UInt64(Date().timeIntervalSince(startTime) * 1_000_000_000) if remainingTime > 0 { try? await Task.sleep(nanoseconds: min(remainingTime, 200_000_000)) } } guard !Task.isCancelled else { return } // Now trigger the refresh with aggregated events if let hint = self.pendingIncrementalHint, Date() < hint.expiresAt { await self.refreshIncremental(using: hint) } else { // First try a targeted refresh for the current selection; fall back to full refresh otherwise if !(await self.refreshSelectedSessions(sessionIds: self.selectedSessionIDs, force: true)) { self.enrichmentSnapshots.removeAll() // Use scope-based debouncing for the refresh self.scheduleFilterRefresh(force: true) } } self.fileEventAggregationTask = nil } } /// Smart merge: only update allSessions if data actually changed /// This prevents unnecessary UI re-renders when refreshing unchanged data private func smartMergeAllSessions(newSessions: [SessionSummary]) { // Quick check: if counts differ, definitely changed guard allSessions.count == newSessions.count else { allSessions = newSessions return } // Build map of old sessions let oldMap = Dictionary(uniqueKeysWithValues: allSessions.map { ($0.id, $0) }) // Build merged array, preserving unchanged session object references var mergedSessions: [SessionSummary] = [] mergedSessions.reserveCapacity(newSessions.count) var hasAnyChanges = false for newSession in newSessions { guard let oldSession = oldMap[newSession.id] else { // New session appeared mergedSessions.append(newSession) hasAnyChanges = true continue } // Parse Level Protection: // If old session has better parse level than new session (e.g. Enriched vs Metadata), // and file metadata (mtime/size) hasn't changed significantly, KEEP OLD SESSION. if let oldLevel = oldSession.parseLevel, let newLevel = newSession.parseLevel, oldLevel > newLevel { // Check if file is effectively unchanged to justify keeping old data let lastUpdatedMatches = abs( (newSession.lastUpdatedAt ?? Date.distantPast).timeIntervalSince( (oldSession.lastUpdatedAt ?? Date.distantPast))) < 0.1 let fileSizeMatches = (newSession.fileSizeBytes ?? 0) == (oldSession.fileSizeBytes ?? 0) if lastUpdatedMatches && fileSizeMatches { // Keep high-quality old session mergedSessions.append(oldSession) continue } } // Check if this specific session actually changed by comparing key fields // Use file metadata + critical timestamps to avoid false positives from parsing variations let fileSizeMatches = oldSession.fileSizeBytes == newSession.fileSizeBytes let startedAtMatches = oldSession.startedAt == newSession.startedAt let lastUpdatedMatches = oldSession.lastUpdatedAt == newSession.lastUpdatedAt // CRITICAL FIX: Fast parsing (buildSummaryFast) only reads first ~64 lines, causing: // - Incomplete counts for tools, messages, etc. // - UI flicker when refresh switches between fast parse (low counts) and full parse (correct counts) // Solution: If file metadata unchanged but ANY count DECREASED, it's fast parse - keep old richer data let fileUnchanged = fileSizeMatches && lastUpdatedMatches let anyCountDecreased = (newSession.userMessageCount < oldSession.userMessageCount || newSession.assistantMessageCount < oldSession.assistantMessageCount || newSession.toolInvocationCount < oldSession.toolInvocationCount) if fileUnchanged && anyCountDecreased { // File hasn't changed but counts decreased - this is fast parse, keep old richer data mergedSessions.append(oldSession) } else if fileSizeMatches && startedAtMatches && lastUpdatedMatches && oldSession.userMessageCount == newSession.userMessageCount && oldSession.assistantMessageCount == newSession.assistantMessageCount && oldSession.toolInvocationCount == newSession.toolInvocationCount { // All counts match and file unchanged - truly no change mergedSessions.append(oldSession) } else { // Content actually changed - use new object mergedSessions.append(newSession) hasAnyChanges = true } } // Check if IDs changed (sessions added/removed) if Set(oldMap.keys) != Set(mergedSessions.map { $0.id }) { hasAnyChanges = true } // Only update if there are actual changes if hasAnyChanges { allSessions = mergedSessions } // If no changes at all, keep the existing allSessions array reference completely unchanged } private func invalidateEnrichmentCache(for day: Date?) { if let key = dayCacheKey(for: day) { enrichmentSnapshots.removeValue(forKey: key) } } private func dayCacheKey(for day: Date?) -> String? { let pathKey: String = selectedPath.map(Self.canonicalPath) ?? "*" if let day { let calendar = Calendar.current let comps = calendar.dateComponents([.year, .month, .day], from: day) guard let year = comps.year, let month = comps.month, let dayComponent = comps.day else { return nil } return "\(dateDimension.rawValue)|\(year)-\(month)-\(dayComponent)|\(pathKey)" } // No day selected (All): use synthetic cache key to avoid re-enriching repeatedly return "\(dateDimension.rawValue)|all|\(pathKey)" } private func scopeKey(_ scope: SessionLoadScope) -> String { switch scope { case .all: return "all" case .today: return "today" case .day(let date): return "day-\(Int(date.timeIntervalSince1970))" case .month(let date): return "month-\(Int(date.timeIntervalSince1970))" } } private func scheduleFilterRefresh(force: Bool) { let scope = currentScope() let key = scopeKey(scope) // Cancel existing task for this scope only (allows different scopes to coexist) scopedRefreshTasks[key]?.cancel() pendingScopeRefreshForce[key] = (pendingScopeRefreshForce[key] ?? false) || force if force { if allSessions.isEmpty { sections = [] } isLoading = true } let task = Task { @MainActor [weak self] in guard let self else { return } // Use longer debounce delay for non-force refreshes to reduce frequency // force=true: 10ms (user-initiated, responsive) // force=false: 300ms (auto-triggered, debounced) let runForce = self.pendingScopeRefreshForce[key] ?? false let debounceNanoseconds: UInt64 = runForce ? 10_000_000 : 300_000_000 try? await Task.sleep(nanoseconds: debounceNanoseconds) guard !Task.isCancelled else { self.cleanupScopedTask(key: key) return } self.pendingScopeRefreshForce[key] = nil await self.refreshSessions(force: runForce) self.cleanupScopedTask(key: key) } scopedRefreshTasks[key] = task // Keep backward compatibility with existing code that cancels scheduledFilterRefresh scheduledFilterRefresh = task } private func cleanupScopedTask(key: String) { scopedRefreshTasks[key] = nil pendingScopeRefreshForce[key] = nil } /// Debounced wrapper around refreshSessions to reduce repeated full enumerations. private func scheduleRefreshDebounced(force: Bool) async { refreshDebounceTask?.cancel() pendingRefreshForce = pendingRefreshForce || force refreshDebounceTask = Task { [weak self] in guard let self else { return } // File-event aggregation: coalesce bursts into a single refresh let delay: UInt64 = self.pendingRefreshForce ? 50_000_000 : 500_000_000 try? await Task.sleep(nanoseconds: delay) guard !Task.isCancelled else { return } let runForce = self.pendingRefreshForce self.pendingRefreshForce = false await self.refreshSessions(force: runForce) } } private func shouldSkipRefresh(scope: SessionLoadScope, force: Bool) -> Bool { let key = scopeKey(scope) // force=true (user-initiated): never skip if force { return false } // force=false (auto-triggered): check if already executing if activeScopeRefreshes[key] != nil { return true // Skip if refresh for this scope is already in progress } // Skip if just completed (< 200ms) to filter rapid duplicates guard let lastScope = lastRefreshScope, let lastTs = lastRefreshAt else { return false } if lastScope == scope && Date().timeIntervalSince(lastTs) < 0.2 { return true } return false } private func shouldRefreshSessionsForDateChange(oldValue: Date?, newValue: Date?) -> Bool { // In Updated mode, all sessions are already loaded - no need to refresh guard dateDimension == .created else { return false } // In Created mode, only refresh if crossing month boundary guard let old = oldValue, let new = newValue else { return true // Clearing or first selection } let oldMonth = Self.normalizeMonthStart(old) let newMonth = Self.normalizeMonthStart(new) return oldMonth != newMonth // Only refresh when crossing months } private func shouldRefreshSessionsForDaysChange(oldValue: Set, newValue: Set) -> Bool { // In Updated mode, all sessions are already loaded - no need to refresh guard dateDimension == .created else { return false } // In Created mode, only refresh if any selected day crosses month boundary let oldMonths = Set(oldValue.map { Self.normalizeMonthStart($0) }) let newMonths = Set(newValue.map { Self.normalizeMonthStart($0) }) return oldMonths != newMonths // Only refresh when month set changes } // MARK: - Quick pulse: cheap, low-latency activity tracking via file mtime private func quickPulse() { let now = Date() guard now.timeIntervalSince(lastQuickPulseAt) > 0.4 else { return } lastQuickPulseAt = now guard !sections.isEmpty else { return } #if canImport(AppKit) guard NSApp?.isActive != false else { return } #endif let displayedSessions = Array(self.sections.flatMap { $0.sessions }.prefix(200)) guard !displayedSessions.isEmpty else { return } // Gate by visible rows digest to avoid scanning when the visible set didn't change var hasher = Hasher() for s in displayedSessions { hasher.combine(s.id) } let digest = hasher.finalize() if digest == lastDisplayedDigest { return } lastDisplayedDigest = digest quickPulseTask?.cancel() // Take a snapshot of currently displayed sessions (limit for safety) quickPulseTask = Task.detached { [weak self, displayedSessions] in guard let self else { return } let fm = FileManager.default var modified: [String: Date] = [:] for s in displayedSessions { let path = s.fileURL.path if let attrs = try? fm.attributesOfItem(atPath: path), let m = attrs[.modificationDate] as? Date { modified[s.id] = m } } let snapshot = modified await MainActor.run { let now = Date() var heartbeatChanged = false for (id, m) in snapshot { let previous = self.fileMTimeCache[id] self.fileMTimeCache[id] = m if let previous, m > previous { self.activityHeartbeat[id] = now heartbeatChanged = true } } self.recomputeActiveUpdatingIDs() // Reschedule prune task if heartbeats changed if heartbeatChanged { self.scheduleActivityPruneIfNeeded() } } } } private func monthKey(for day: Date?, dimension: DateDimension) -> String? { guard let day else { return nil } let calendar = Calendar.current let comps = calendar.dateComponents([.year, .month], from: day) guard let year = comps.year, let month = comps.month else { return nil } return "\(dimension.rawValue)|\(year)-\(month)" } // MARK: - Incremental refresh for New func setIncrementalHintForCodexToday(window seconds: TimeInterval = 10) { let day = Calendar.current.startOfDay(for: Date()) pendingIncrementalHint = PendingIncrementalRefreshHint( kind: .codexDay(day), expiresAt: Date().addingTimeInterval(seconds)) } func setIncrementalHintForGeminiToday(window seconds: TimeInterval = 10) { let day = Calendar.current.startOfDay(for: Date()) pendingIncrementalHint = PendingIncrementalRefreshHint( kind: .geminiDay(day), expiresAt: Date().addingTimeInterval(seconds)) } func setIncrementalHintForClaudeProject(directory: String, window seconds: TimeInterval = 120) { let canonical = Self.canonicalPath(directory) pendingIncrementalHint = PendingIncrementalRefreshHint( kind: .claudeProject(canonical), expiresAt: Date().addingTimeInterval(seconds)) // Point a dedicated monitor at this project's folder to receive events for nested writes. // Claude writes session files inside ~/.claude/projects//, which are not visible // to a non-recursive top-level directory watcher. let home = FileManager.default.homeDirectoryForCurrentUser let projectsRoot = home .appendingPathComponent(".claude", isDirectory: true) .appendingPathComponent("projects", isDirectory: true) let encoded = Self.encodeClaudeProjectFolder(from: canonical) let projectURL = projectsRoot.appendingPathComponent(encoded, isDirectory: true) if FileManager.default.fileExists(atPath: projectURL.path) { if let monitor = claudeProjectMonitor { monitor.updateURL(projectURL) } else { claudeProjectMonitor = DirectoryMonitor(url: projectURL) { [weak self] in Task { await self?.refreshIncrementalForClaudeProject(directory: canonical) } } } } } // Claude project folder encoding mirrors ClaudeSessionProvider.encodeProjectFolder private static func encodeClaudeProjectFolder(from cwd: String) -> String { let expanded = (cwd as NSString).expandingTildeInPath var standardized = URL(fileURLWithPath: expanded).standardizedFileURL.path if standardized.hasSuffix("/") && standardized.count > 1 { standardized.removeLast() } var name = standardized.replacingOccurrences(of: ":", with: "-") name = name.replacingOccurrences(of: "/", with: "-") if !name.hasPrefix("-") { name = "-" + name } return name } private func mergeAndApply(_ subset: [SessionSummary]) { guard !subset.isEmpty else { return } var updatedSessions = allSessions var indexById = Dictionary(uniqueKeysWithValues: allSessions.enumerated().map { ($1.id, $0) }) var changed = false var newSessions: [SessionSummary] = [] for var session in subset { if let note = notesSnapshot[session.id] { session.userTitle = note.title session.userComment = note.comment } if let idx = indexById[session.id] { if updatedSessions[idx] != session { updatedSessions[idx] = session changed = true } } else { indexById[session.id] = updatedSessions.count updatedSessions.append(session) newSessions.append(session) changed = true handleAutoAssignIfMatches(session) } } guard changed else { return } allSessions = updatedSessions rebuildCanonicalCwdCache() var viewNeedsUpdate = false if !newSessions.isEmpty { persistProjectAssignmentsToCache(newSessions) _ = incrementProjectCounts(for: newSessions) viewNeedsUpdate = true } if viewNeedsUpdate { scheduleViewUpdate() } scheduleApplyFilters() // Keep global total based on full scan (Codex + Claude [+ Remote]), // not on currently loaded subset. Recompute asynchronously. Task { await self.refreshGlobalCount() } } private func incrementProjectCounts(for newSessions: [SessionSummary]) -> Bool { guard !newSessions.isEmpty else { return false } var updated = projectCounts var changed = false let allowedSourcesByProject = projects.reduce(into: [String: Set]()) { $0[$1.id] = $1.sources } for session in newSessions { if let projectId = projectId(for: session) { let allowedSources = allowedSourcesByProject[projectId] ?? ProjectSessionSource.allSet guard allowedSources.contains(session.source.projectSource) else { continue } updated[projectId, default: 0] += 1 changed = true } else { updated[SessionListViewModel.otherProjectId, default: 0] += 1 changed = true } } if changed { projectCounts = updated } return changed } private func dayOfToday() -> Date { Calendar.current.startOfDay(for: Date()) } func refreshIncrementalForNewCodexToday() async { guard preferences.isCLIEnabled(.codex) else { return } do { let codexConfigs = preferences.sessionPathConfigs.filter { $0.kind == .codex && $0.enabled } let codexIgnoredPaths = codexConfigs.flatMap { $0.ignoredSubpaths } let subset = try await indexer.refreshSessions( root: preferences.sessionsRoot, scope: .day(dayOfToday()), dateRange: currentDateRange(), projectIds: singleSelectedProject(), dateDimension: dateDimension, ignoredPaths: codexIgnoredPaths) await MainActor.run { self.mergeAndApply(subset) } } catch { // Swallow errors for incremental path; full refresh will recover if needed. } } func refreshIncrementalForGeminiToday() async { guard preferences.isCLIEnabled(.gemini) else { return } do { let geminiConfigs = preferences.sessionPathConfigs.filter { $0.kind == .gemini && $0.enabled } let geminiIgnoredPaths = geminiConfigs.flatMap { $0.ignoredSubpaths } let subset = try await geminiProvider.sessions( scope: .day(dayOfToday()), ignoredPaths: geminiIgnoredPaths) await MainActor.run { self.mergeAndApply(subset) } } catch { diagLogger.error( "refreshIncrementalForGeminiToday failed: \(error.localizedDescription, privacy: .public)") } } func refreshIncrementalForClaudeToday() async { guard preferences.isCLIEnabled(.claude) else { return } do { let claudeConfigs = preferences.sessionPathConfigs.filter { $0.kind == .claude && $0.enabled } let claudeIgnoredPaths = claudeConfigs.flatMap { $0.ignoredSubpaths } let subset = try await claudeProvider.sessions( scope: .day(dayOfToday()), ignoredPaths: claudeIgnoredPaths) await MainActor.run { self.mergeAndApply(subset) } } catch { diagLogger.error( "refreshIncrementalForClaudeToday failed: \(error.localizedDescription, privacy: .public)") } } func refreshIncrementalForClaudeProject(directory: String) async { guard preferences.isCLIEnabled(.claude) else { return } do { let subset = try await claudeProvider.sessions(inProjectDirectory: directory) await MainActor.run { self.mergeAndApply(subset) } } catch { diagLogger.error( "refreshIncrementalForClaudeProject failed: \(error.localizedDescription, privacy: .public)" ) } } private func refreshIncremental(using hint: PendingIncrementalRefreshHint) async { switch hint.kind { case .codexDay: await refreshIncrementalForNewCodexToday() case .geminiDay: await refreshIncrementalForGeminiToday() case .claudeProject(let dir): await refreshIncrementalForClaudeProject(directory: dir) } } nonisolated private static func computeMonthCounts( sessions: [SessionSummary], monthKey: String, dimension: DateDimension, dayIndex: [String: SessionDayIndex], coverage: [String: Set] = [:] ) -> [Int: Int] { var counts: [Int: Int] = [:] for session in sessions { guard let bucket = dayIndex[session.id] else { continue } switch dimension { case .created: guard bucket.createdMonthKey == monthKey else { continue } counts[bucket.createdDay, default: 0] += 1 case .updated: guard bucket.updatedMonthKey == monthKey else { continue } if let days = coverage[session.id], !days.isEmpty { for day in days { counts[day, default: 0] += 1 } } else { counts[bucket.updatedDay, default: 0] += 1 } } } return counts } } extension SessionListViewModel { private func apply( notes: [String: SessionNote], to sessions: inout [SessionSummary] ) { for index in sessions.indices { if let note = notes[sessions[index].id] { sessions[index].userTitle = note.title sessions[index].userComment = note.comment } } } func refreshGlobalCount() async { // Fast path: use cached coverage/meta to avoid re-parsing sessions on cold start. if preferences.isCLIEnabled(.codex), let coverage = await indexer.currentCoverage() { await MainActor.run { self.globalSessionCount = coverage.sessionCount } diagLogger.log( "refreshGlobalCount via coverage count=\(coverage.sessionCount, privacy: .public) ts=\(self.ts(), format: .fixed(precision: 3))" ) return } if preferences.isCLIEnabled(.codex), let meta = await indexer.currentMeta() { await MainActor.run { self.globalSessionCount = meta.sessionCount } diagLogger.log( "refreshGlobalCount via meta count=\(meta.sessionCount, privacy: .public) ts=\(self.ts(), format: .fixed(precision: 3))" ) return } // Fallback: enumerate cached summaries (or re-index) when no coverage/meta is available. diagLogger.log("refreshGlobalCount fallback enumerate summaries") let codexSummaries: [SessionSummary] if preferences.isCLIEnabled(.codex) { do { if let cached = try await indexer.cachedAllSummaries() { codexSummaries = cached } else { codexSummaries = [] } } catch { diagLogger.error( "refreshGlobalCount failed to read codex cache: \(error.localizedDescription, privacy: .public)" ) await MainActor.run { self.globalSessionCount = 0 } return } } else { codexSummaries = [] } let claudeSummaries: [SessionSummary] if preferences.isCLIEnabled(.claude) { let claudeConfigs = preferences.sessionPathConfigs.filter { $0.kind == .claude && $0.enabled } let claudeIgnoredPaths = claudeConfigs.flatMap { $0.ignoredSubpaths } claudeSummaries = (try? await claudeProvider.sessions(scope: .all, ignoredPaths: claudeIgnoredPaths)) ?? [] } else { claudeSummaries = [] } let geminiSummaries: [SessionSummary] if preferences.isCLIEnabled(.gemini) { let geminiConfigs = preferences.sessionPathConfigs.filter { $0.kind == .gemini && $0.enabled } let geminiIgnoredPaths = geminiConfigs.flatMap { $0.ignoredSubpaths } geminiSummaries = (try? await geminiProvider.sessions(scope: .all, ignoredPaths: geminiIgnoredPaths)) ?? [] } else { geminiSummaries = [] } var idSet = Set() for s in codexSummaries { idSet.insert(s.id) } for s in claudeSummaries { idSet.insert(s.id) } for s in geminiSummaries { idSet.insert(s.id) } var total = idSet.count let enabledHosts = preferences.enabledRemoteHosts if !enabledHosts.isEmpty { let startRemote = Date() let codexCount = preferences.isCLIEnabled(.codex) ? await remoteProvider.countSessions(kind: .codex, enabledHosts: enabledHosts) : 0 let claudeCount = preferences.isCLIEnabled(.claude) ? await remoteProvider.countSessions(kind: .claude, enabledHosts: enabledHosts) : 0 total += codexCount total += claudeCount let elapsed = Date().timeIntervalSince(startRemote) diagLogger.log( "refreshGlobalCount remote counts codex=\(codexCount, privacy: .public) claude=\(claudeCount, privacy: .public) in \(elapsed, format: .fixed(precision: 3))s ts=\(self.ts(), format: .fixed(precision: 3))" ) } await MainActor.run { self.globalSessionCount = total } } /// Refresh sidebar stats (calendar, path tree, and global count) without forcing session reload. func refreshSidebarStats() async { invalidateCalendarCaches() ensureCalendarCounts(for: sidebarMonthStart, dimension: dateDimension) await refreshPathTreeFromDisk() await refreshGlobalCount() } /// Rebuild the path tree from on-disk counts for accurate sidebar navigation. func refreshPathTreeFromDisk() async { let enabledRemoteHostsForCounts = preferences.enabledRemoteHosts let sessionsRootForCounts = preferences.sessionsRoot var counts: [String: Int] = [:] if preferences.isCLIEnabled(.codex) { counts = await indexer.collectCWDCounts(root: sessionsRootForCounts) } if preferences.isCLIEnabled(.claude) { let claudeCounts = await claudeProvider.collectCWDCounts() for (key, value) in claudeCounts { counts[key, default: 0] += value } } if preferences.isCLIEnabled(.gemini) { let geminiCounts = await geminiProvider.collectCWDCounts() for (key, value) in geminiCounts { counts[key, default: 0] += value } } if !enabledRemoteHostsForCounts.isEmpty { let remoteCodex = await remoteProvider.collectCWDAggregates( kind: .codex, enabledHosts: enabledRemoteHostsForCounts ) for (key, value) in remoteCodex { counts[key, default: 0] += value } let remoteClaude = await remoteProvider.collectCWDAggregates( kind: .claude, enabledHosts: enabledRemoteHostsForCounts ) if preferences.isCLIEnabled(.claude) { for (key, value) in remoteClaude { counts[key, default: 0] += value } } } let tree = counts.buildPathTreeFromCounts() pathTreeRootPublished = tree } /// User-driven refresh for usage status (status capsule tap / Command+R fallback). func requestUsageStatusRefresh(for provider: UsageProviderKind) { if !isCLIEnabled(for: provider) { return } switch provider { case .codex: refreshCodexUsageStatus() case .claude: claudeUsageAutoRefreshEnabled = true refreshClaudeUsageStatus(silent: false) case .gemini: refreshGeminiUsageStatus(silent: false) } } func requestUsageStatusRefreshSilently(for provider: UsageProviderKind) { if !isCLIEnabled(for: provider) { return } switch provider { case .codex: refreshCodexUsageStatus(silent: true) case .claude: claudeUsageAutoRefreshEnabled = true refreshClaudeUsageStatus(silent: true) case .gemini: refreshGeminiUsageStatus(silent: true) } } /// Refresh usage with a simple throttle window to avoid repeated calls. func requestUsageStatusRefreshThrottled( for provider: UsageProviderKind, triggerDate: Date = Date(), minInterval: TimeInterval = 15 ) { if let last = lastUsageRefreshByProvider[provider], triggerDate.timeIntervalSince(last) < minInterval { return } lastUsageRefreshByProvider[provider] = triggerDate requestUsageStatusRefreshSilently(for: provider) } private func setInitialClaudePlaceholder() { self.setClaudeUsagePlaceholder("Load Claude usage", action: .refresh) } private func isCLIEnabled(for provider: UsageProviderKind) -> Bool { switch provider { case .codex: return preferences.isCLIEnabled(.codex) case .claude: return preferences.isCLIEnabled(.claude) case .gemini: return preferences.isCLIEnabled(.gemini) } } private func enabledCLIKindSet() -> Set { Self.cliEnabledKindSet( codex: preferences.cliCodexEnabled, claude: preferences.cliClaudeEnabled, gemini: preferences.cliGeminiEnabled ) } private static func cliEnabledKindSet( codex: Bool, claude: Bool, gemini: Bool ) -> Set { var set: Set = [] if codex { set.insert(.codex) } if claude { set.insert(.claude) } if gemini { set.insert(.gemini) } return set } private func trimUsageSnapshotsForDisabledCLIs() { if !preferences.isCLIEnabled(.codex) { usageSnapshots.removeValue(forKey: .codex) } if !preferences.isCLIEnabled(.claude) { usageSnapshots.removeValue(forKey: .claude) } if !preferences.isCLIEnabled(.gemini) { usageSnapshots.removeValue(forKey: .gemini) } } private func setClaudeUsagePlaceholder( _ message: String, action: UsageProviderSnapshot.Action? = .refresh, availability: UsageProviderSnapshot.Availability = .empty ) { let snapshot = UsageProviderSnapshot( provider: .claude, title: UsageProviderKind.claude.displayName, availability: availability, metrics: [], updatedAt: nil, statusMessage: message, requiresReauth: false, origin: .builtin, action: action ) setUsageSnapshot(.claude, snapshot) } private func setGeminiUsagePlaceholder( _ message: String, action: UsageProviderSnapshot.Action? = .refresh, availability: UsageProviderSnapshot.Availability = .empty ) { let snapshot = UsageProviderSnapshot( provider: .gemini, title: UsageProviderKind.gemini.displayName, availability: availability, metrics: [], updatedAt: nil, statusMessage: message, requiresReauth: false, origin: .builtin, action: action ) setUsageSnapshot(.gemini, snapshot) } private func setCodexUsagePlaceholder( _ message: String, action: UsageProviderSnapshot.Action? = .refresh, availability: UsageProviderSnapshot.Availability = .empty ) { let snapshot = UsageProviderSnapshot( provider: .codex, title: UsageProviderKind.codex.displayName, availability: availability, metrics: [], updatedAt: nil, statusMessage: message, requiresReauth: false, origin: .builtin, action: action ) setUsageSnapshot(.codex, snapshot) } private func refreshCodexUsageStatus(silent: Bool = false) { codexUsageTask?.cancel() let candidates = latestCodexSessions(limit: 12) codexUsageTask = Task { [weak self] in guard let self else { return } let origin = await self.providerOrigin(for: .codex) guard origin == .builtin else { await MainActor.run { self.codexUsageStatus = nil self.setUsageSnapshot(.codex, Self.thirdPartyUsageSnapshot(for: .codex)) } return } // Fetch plan type from OAuth API (more reliable than RPC) async let oauthPlanType: String? = Self.fetchCodexOAuthPlanType() async let rpcSnapshot = self.codexAppServerProbe.fetchIfStaleOrNil(maxAge: 90) async let tokenSnapshot: TokenUsageSnapshot? = { guard !candidates.isEmpty else { return nil } if let ripgrepSnapshot = await self.ripgrepStore.latestTokenUsage(in: candidates) { return ripgrepSnapshot } return await Task.detached(priority: .utility) { Self.fallbackTokenUsage(from: candidates) }.value }() let oauthPlan = await oauthPlanType let rpc = await rpcSnapshot let snapshot = await tokenSnapshot guard !Task.isCancelled else { return } await MainActor.run { // Use OAuth plan type only (no RPC fallback to avoid stale data) let badge = Self.codexPlanBadgeFromOAuth(oauthPlan) var codexStatus: CodexUsageStatus? if let snapshot { codexStatus = CodexUsageStatus(snapshot: snapshot) } else if let rpc { // Allow showing Codex quotas even when no recent session logs exist. codexStatus = CodexUsageStatus( updatedAt: rpc.fetchedAt, contextUsedTokens: nil, contextLimitTokens: nil, primaryWindowUsedPercent: rpc.primaryUsedPercent, primaryWindowMinutes: rpc.primaryWindowMinutes, primaryResetAt: rpc.primaryResetAt, secondaryWindowUsedPercent: rpc.secondaryUsedPercent, secondaryWindowMinutes: rpc.secondaryWindowMinutes, secondaryResetAt: rpc.secondaryResetAt ) } if let rpc, let existing = codexStatus { let mergedUpdatedAt = max(existing.updatedAt, rpc.fetchedAt) codexStatus = existing.overridingRateLimits( updatedAt: mergedUpdatedAt, primaryUsedPercent: rpc.primaryUsedPercent, primaryWindowMinutes: rpc.primaryWindowMinutes, primaryResetAt: rpc.primaryResetAt, secondaryUsedPercent: rpc.secondaryUsedPercent, secondaryWindowMinutes: rpc.secondaryWindowMinutes, secondaryResetAt: rpc.secondaryResetAt ) } self.codexUsageStatus = codexStatus if let codex = codexStatus { let snapshot = codex.asProviderSnapshot(titleBadge: badge) self.setUsageSnapshot(.codex, snapshot) } else { self.setUsageSnapshot( .codex, UsageProviderSnapshot( provider: .codex, title: UsageProviderKind.codex.displayName, availability: .empty, metrics: [], updatedAt: nil, statusMessage: "No Codex usage data available yet.", origin: .builtin, action: .refresh ) ) } } } } private static func codexPlanBadge(from rawPlanType: String?) -> String? { guard let raw = rawPlanType?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } let plan = raw.lowercased() if plan.contains("free") || plan.contains("unknown") { return nil } if plan.contains("plus") { return "Plus" } if plan.contains("pro") { return "Pro" } if plan.contains("team") { return "Team" } if plan.contains("enterprise") { return "Ent" } return raw.prefix(1).uppercased() + raw.dropFirst() } /// Fetch Codex plan type - matches CodexBar's resolvePlan strategy: /// 1. Prioritize API response (most up-to-date, authoritative) /// 2. Fall back to JWT only if API didn't return a plan type (not if it returned "free") /// This avoids using stale JWT data when API successfully returns a plan type private static func fetchCodexOAuthPlanType() async -> String? { // Primary: Try API call first (matches CodexBar's resolvePlan logic) do { let apiPlan = try await CodexOAuthUsageFetcher.fetchPlanType() // If API returned a plan type (even if "free"), use it - don't fall back to JWT // This matches CodexBar: "if let plan = response.planType?.rawValue, !plan.isEmpty { return plan }" if let apiPlan = apiPlan, !apiPlan.isEmpty { return apiPlan } } catch { // Check if this is a cancellation error (expected when new request cancels old one) // FetchError.networkError wraps the underlying error, so we need to unwrap it var isCancellation = false if let fetchError = error as? CodexOAuthUsageFetcher.FetchError, case .networkError(let underlyingError) = fetchError { isCancellation = (underlyingError as? URLError)?.code == .cancelled || (underlyingError as NSError).domain == NSURLErrorDomain && (underlyingError as NSError).code == NSURLErrorCancelled } else { // Direct URLError check isCancellation = (error as? URLError)?.code == .cancelled || (error as NSError).domain == NSURLErrorDomain && (error as NSError).code == NSURLErrorCancelled } // Only log non-cancellation errors (cancellation is expected behavior) if !isCancellation { NSLog("[CodexUsage] OAuth plan type API fetch failed: \(error.localizedDescription)") } } // Fallback: Parse JWT token only if API didn't return a plan type // This matches CodexBar: JWT is fallback, not used when API returns a value let jwtPlan = CodexOAuthUsageFetcher.fetchPlanTypeFromJWT() if let jwtPlan = jwtPlan, !jwtPlan.isEmpty { return jwtPlan } return nil } /// Convert OAuth plan type to display badge (using exact enum matching) private static func codexPlanBadgeFromOAuth(_ planType: String?) -> String? { guard let plan = planType?.lowercased(), !plan.isEmpty else { return nil } // Match exact plan types from OAuth API let badge: String? switch plan { case "free", "guest", "free_workspace": badge = "Free" // Show "Free" badge for free users case "go": badge = "Go" case "plus": badge = "Plus" case "pro": badge = "Pro" case "team": badge = "Team" case "business", "enterprise": badge = "Ent" case "education", "edu", "k12", "quorum": badge = "Edu" default: // Show first letter capitalized for unknown types badge = plan.prefix(1).uppercased() + plan.dropFirst() } return badge } private static func claudePlanBadge(from rawPlanType: String?) -> String? { guard let raw = rawPlanType?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } let plan = raw.lowercased() if plan.contains("free") || plan.contains("unknown") { return nil } if plan.contains("max") { return "Max" } if plan.contains("pro") { return "Pro" } if plan.contains("team") { return "Team" } if plan.contains("enterprise") { return "Ent" } return raw.prefix(1).uppercased() + raw.dropFirst() } private static func geminiPlanBadge(from rawPlanType: String?) -> String? { guard let raw = rawPlanType?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } let plan = raw.lowercased() if plan.contains("free") || plan.contains("unknown") { return nil } if plan.contains("ultra") { return "Ultra" } if plan.contains("pro") { return "Pro" } return raw.prefix(1).uppercased() + raw.dropFirst() } nonisolated private static func fallbackTokenUsage(from sessions: [SessionSummary]) -> TokenUsageSnapshot? { guard !sessions.isEmpty else { return nil } let loader = SessionTimelineLoader() for session in sessions { if let snapshot = loader.loadLatestTokenUsageWithFallback(url: session.fileURL) { return snapshot } } return nil } private func latestCodexSessions(limit: Int) -> [SessionSummary] { let sorted = allSessions .filter { $0.source == .codexLocal } .sorted { ($0.lastUpdatedAt ?? $0.startedAt) > ($1.lastUpdatedAt ?? $1.startedAt) } guard !sorted.isEmpty else { return [] } return Array(sorted.prefix(limit)) } private func refreshClaudeUsageStatus(silent: Bool) { claudeUsageTask?.cancel() claudeUsageTask = Task { [weak self] in guard let self else { return } let origin = await self.providerOrigin(for: .claude) guard origin == .builtin else { await MainActor.run { self.setUsageSnapshot(.claude, Self.thirdPartyUsageSnapshot(for: .claude)) } return } let client = self.claudeUsageClient do { let status = try await client.fetchUsageStatus() guard !Task.isCancelled else { return } await MainActor.run { let badge = Self.claudePlanBadge(from: status.planType) NSLog("[ClaudeUsage] planType=\(status.planType ?? "nil"), badge=\(badge ?? "nil")") self.setUsageSnapshot(.claude, status.asProviderSnapshot(titleBadge: badge)) } } catch { NSLog("[ClaudeUsage] API fetch failed: \(error)") guard !Task.isCancelled else { return } let descriptor = Self.claudeUsageErrorState(from: error) if silent { await SystemNotifier.shared.notify( title: "Claude", body: descriptor.message ) } else { await MainActor.run { self.setUsageSnapshot( .claude, UsageProviderSnapshot( provider: .claude, title: UsageProviderKind.claude.displayName, availability: .empty, metrics: [], updatedAt: nil, statusMessage: descriptor.message, requiresReauth: descriptor.requiresReauth, origin: .builtin, action: descriptor.action ) ) } } } } } private func refreshGeminiUsageStatus(silent: Bool) { geminiUsageTask?.cancel() geminiUsageTask = Task { [weak self] in guard let self else { return } let origin = await self.providerOrigin(for: .gemini) guard origin == .builtin else { await MainActor.run { self.setUsageSnapshot(.gemini, Self.thirdPartyUsageSnapshot(for: .gemini)) } return } do { let status = try await self.geminiUsageClient.fetchUsageStatus() guard !Task.isCancelled else { return } await MainActor.run { let badge = Self.geminiPlanBadge(from: status.planType) self.setUsageSnapshot(.gemini, status.asProviderSnapshot(titleBadge: badge)) } } catch { NSLog("[GeminiUsage] API fetch failed: \(error)") guard !Task.isCancelled else { return } let descriptor = Self.geminiUsageErrorState(from: error) if silent { await SystemNotifier.shared.notify( title: "Gemini", body: descriptor.message ) } else { await MainActor.run { self.setUsageSnapshot( .gemini, UsageProviderSnapshot( provider: .gemini, title: UsageProviderKind.gemini.displayName, availability: .empty, metrics: [], updatedAt: nil, statusMessage: descriptor.message, requiresReauth: descriptor.requiresReauth, origin: .builtin, action: descriptor.action ) ) } } } } } private struct ClaudeUsageErrorDescriptor { var message: String var requiresReauth: Bool var action: UsageProviderSnapshot.Action? } private static func claudeUsageErrorState(from error: Error) -> ClaudeUsageErrorDescriptor { guard let clientError = error as? ClaudeUsageAPIClient.ClientError else { return ClaudeUsageErrorDescriptor( message: "Unable to get Claude usage.", requiresReauth: false, action: .refresh ) } switch clientError { case .credentialNotFound: return ClaudeUsageErrorDescriptor( message: "Not logged in to Claude. Run claude code to refresh.", requiresReauth: true, action: .refresh ) case .keychainAccessRestricted: return ClaudeUsageErrorDescriptor( message: "CodMate needs access to Claude login records in the keychain.", requiresReauth: false, action: .authorizeKeychain ) case .malformedCredential, .missingAccessToken: return ClaudeUsageErrorDescriptor( message: "Claude login information is invalid. Please log in again and refresh.", requiresReauth: true, action: .refresh ) case .credentialExpired: return ClaudeUsageErrorDescriptor( message: "No Claude usage recently. ", requiresReauth: false, action: .refresh ) case .requestFailed(let code): if code == 401 { return ClaudeUsageErrorDescriptor( message: "Claude rejected the usage request. Please log in again and refresh.", requiresReauth: true, action: .refresh ) } return ClaudeUsageErrorDescriptor( message: "Claude usage request failed (HTTP \(code)).", requiresReauth: false, action: .refresh ) case .emptyResponse, .decodingFailed: return ClaudeUsageErrorDescriptor( message: "Unable to parse Claude usage temporarily. Please try again later.", requiresReauth: false, action: .refresh ) } } private struct GeminiUsageErrorDescriptor { var message: String var requiresReauth: Bool var action: UsageProviderSnapshot.Action? } private static func geminiUsageErrorState(from error: Error) -> GeminiUsageErrorDescriptor { guard let clientError = error as? GeminiUsageAPIClient.ClientError else { return GeminiUsageErrorDescriptor( message: "Unable to get Gemini usage.", requiresReauth: false, action: .refresh ) } switch clientError { case .credentialNotFound: return GeminiUsageErrorDescriptor( message: "Not logged in to Gemini. Run gemini CLI to refresh and retry.", requiresReauth: true, action: .refresh ) case .keychainAccess(let status): return GeminiUsageErrorDescriptor( message: SecCopyErrorMessageString(status, nil) as String? ?? "Keychain access denied.", requiresReauth: false, action: .authorizeKeychain ) case .malformedCredential, .missingAccessToken: return GeminiUsageErrorDescriptor( message: "Gemini login info is invalid. Please log in again.", requiresReauth: true, action: .refresh ) case .credentialExpired(let date): let formatter = DateFormatter() formatter.dateStyle = .medium formatter.timeStyle = .short return GeminiUsageErrorDescriptor( message: "Gemini login expired on \(formatter.string(from: date)).", requiresReauth: true, action: .refresh ) case .projectNotFound: return GeminiUsageErrorDescriptor( message: "Gemini project not found. Run gemini login or set GOOGLE_CLOUD_PROJECT.", requiresReauth: true, action: .refresh ) case .unsupportedAuthType(let authType): return GeminiUsageErrorDescriptor( message: "Gemini \(authType) auth is not supported. Please log in with Google.", requiresReauth: true, action: .refresh ) case .requestFailed(let code): let needsLogin = code == 401 || code == 403 return GeminiUsageErrorDescriptor( message: needsLogin ? "Gemini rejected the usage request. Please log in again." : "Gemini usage request failed (HTTP \(code)).", requiresReauth: needsLogin, action: .refresh ) case .emptyResponse, .decodingFailed: return GeminiUsageErrorDescriptor( message: "Unable to parse Gemini usage temporarily. Please try again later.", requiresReauth: false, action: .refresh ) } } private func setUsageSnapshot(_ provider: UsageProviderKind, _ new: UsageProviderSnapshot) { if let old = usageSnapshots[provider], Self.usageSnapshotCoreEqual(old, new) { return } usageSnapshots[provider] = new } private func autoRefreshUsageIfNeeded( codexOrigin: UsageProviderOrigin, claudeOrigin: UsageProviderOrigin, geminiOrigin: UsageProviderOrigin ) { guard !didAutoRefreshUsage else { return } didAutoRefreshUsage = true let shouldRefresh: (UsageProviderKind) -> Bool = { provider in guard let snapshot = self.usageSnapshots[provider] else { return true } if snapshot.origin == .thirdParty { return false } if snapshot.availability == .ready { return false } // If availability is .comingSoon, it means refresh is already in progress (placeholder set) if snapshot.availability == .comingSoon { return false } return snapshot.updatedAt == nil } // Only refresh if not already refreshing (avoid duplicate refresh from refreshSessionsForProviderChange) if preferences.isCLIEnabled(.codex), codexOrigin == .builtin, shouldRefresh(.codex) { refreshCodexUsageStatus() } if preferences.isCLIEnabled(.claude), claudeOrigin == .builtin, shouldRefresh(.claude) { refreshClaudeUsageStatus(silent: false) } if preferences.isCLIEnabled(.gemini), geminiOrigin == .builtin, shouldRefresh(.gemini) { refreshGeminiUsageStatus(silent: false) } } private static func usageSnapshotCoreEqual(_ a: UsageProviderSnapshot, _ b: UsageProviderSnapshot) -> Bool { if a.origin != b.origin { return false } if a.availability != b.availability { return false } if a.statusMessage != b.statusMessage { return false } if a.action != b.action { return false } if a.titleBadge != b.titleBadge { return false } // Compare titleBadge let au = a.updatedAt?.timeIntervalSinceReferenceDate let bu = b.updatedAt?.timeIntervalSinceReferenceDate if au != bu { return false } let ap = a.urgentMetric()?.progress let bp = b.urgentMetric()?.progress if ap != bp { return false } let ar = a.urgentMetric()?.resetDate?.timeIntervalSinceReferenceDate let br = b.urgentMetric()?.resetDate?.timeIntervalSinceReferenceDate return ar == br } private func providerOrigin(for provider: UsageProviderKind) async -> UsageProviderOrigin { if provider == .gemini { // Gemini usage is always treated as built-in; no third-party override today. return .builtin } let consumer: ProvidersRegistryService.Consumer = { switch provider { case .codex: return .codex case .claude: return .claudeCode case .gemini: return .codex } }() let bindings = await providersRegistry.getBindings() if let raw = bindings.activeProvider?[consumer.rawValue]?.trimmingCharacters( in: .whitespacesAndNewlines), !raw.isEmpty { return .thirdParty } return .builtin } private static func thirdPartyUsageSnapshot(for provider: UsageProviderKind) -> UsageProviderSnapshot { UsageProviderSnapshot( provider: provider, title: provider.displayName, availability: .empty, metrics: [], updatedAt: nil, statusMessage: "Usage data isn't available while a custom provider is active.", origin: .thirdParty ) } // MARK: - Sandbox Permission Helpers /// Ensure we have access to sessions directories in sandbox mode private func ensureSessionsAccess() async { guard SecurityScopedBookmarks.shared.isSandboxed else { return } // Check if sessions root path is under a known required directory let sessionsPath = preferences.sessionsRoot.path let realHome = getRealUserHome() let normalizedPath = sessionsPath.replacingOccurrences(of: "~", with: realHome) // Try to start access for Codex directory if sessions root is under ~/.codex if normalizedPath.hasPrefix(realHome + "/.codex") { SandboxPermissionsManager.shared.startAccessingIfAuthorized(directory: .codexSessions) } // Try to start access for Claude directory if needed SandboxPermissionsManager.shared.startAccessingIfAuthorized(directory: .claudeSessions) // Try to start access for Gemini directory if needed SandboxPermissionsManager.shared.startAccessingIfAuthorized(directory: .geminiSessions) // Try to start access for CodMate directory if needed SandboxPermissionsManager.shared.startAccessingIfAuthorized(directory: .codmateData) // Ensure SSH config directory access so remote mirroring can read keys/config SandboxPermissionsManager.shared.startAccessingIfAuthorized(directory: .sshConfig) } /// Get the real user home directory (not sandbox container) private func getRealUserHome() -> String { if let homeDir = getpwuid(getuid())?.pointee.pw_dir { return String(cString: homeDir) } if let home = ProcessInfo.processInfo.environment["HOME"] { return home } return NSHomeDirectory() } func timeline(for summary: SessionSummary) async -> [ConversationTurn] { if summary.source.baseKind == .claude { return await claudeProvider.timeline(for: summary) ?? [] } else if summary.source.baseKind == .gemini { return await geminiProvider.timeline(for: summary) ?? [] } let loader = SessionTimelineLoader() return (try? loader.load(url: summary.fileURL)) ?? [] } // MARK: - Timeline Cache (in-memory) func cachedTimeline(for summary: SessionSummary) async -> [ConversationTurn]? { guard let entry = timelineCache[summary.id] else { return nil } let signature = timelineCacheSignature(for: summary) guard entry.signature == signature else { return nil } return entry.turns } func storeTimeline(_ turns: [ConversationTurn], for summary: SessionSummary) async { let signature = timelineCacheSignature(for: summary) timelineCache[summary.id] = TimelineCacheEntry(signature: signature, turns: turns) } private func timelineCacheSignature(for summary: SessionSummary) -> TimelineCacheSignature { // Prefer on-disk metadata for local sessions; fall back to summary hints. if !summary.source.isRemote, let attrs = try? FileManager.default.attributesOfItem(atPath: summary.fileURL.path) { let mtime = attrs[.modificationDate] as? Date let size = (attrs[.size] as? NSNumber)?.uint64Value return TimelineCacheSignature(modifiedAt: mtime, fileSize: size) } return TimelineCacheSignature( modifiedAt: summary.lastUpdatedAt, fileSize: summary.fileSizeBytes) } // MARK: - Timeline Previews /// Load lightweight timeline previews from cache. Returns nil if cache is invalid or missing. func loadTimelinePreviews(for summary: SessionSummary) async -> [ConversationTurnPreview]? { // Get file attributes for mtime validation guard let attrs = try? FileManager.default.attributesOfItem(atPath: summary.fileURL.path), let mtime = attrs[.modificationDate] as? Date else { return nil } let size = (attrs[.size] as? NSNumber)?.uint64Value // Fetch from SQLite cache let previews = try? await indexer.fetchTimelinePreviews( sessionId: summary.id, fileModificationTime: mtime, fileSize: size ) return previews } /// Update timeline preview cache for a session func updateTimelinePreviews(for summary: SessionSummary, turns: [ConversationTurn]) async { guard let attrs = try? FileManager.default.attributesOfItem(atPath: summary.fileURL.path), let mtime = attrs[.modificationDate] as? Date else { return } let size = (attrs[.size] as? NSNumber)?.uint64Value // Convert turns to previews let previews = turns.enumerated().map { index, turn in ConversationTurnPreview(from: turn, sessionId: summary.id, index: index) } // Store in SQLite do { try await indexer.upsertTimelinePreviews( previews, sessionId: summary.id, fileModificationTime: mtime, fileSize: size ) diagLogger.log( "Timeline previews cached for session \(summary.id, privacy: .public): \(previews.count, privacy: .public) turns" ) } catch { diagLogger.error( "Failed to cache timeline previews for \(summary.id, privacy: .public): \(error.localizedDescription, privacy: .public)" ) } } func ripgrepDiagnostics() async -> SessionRipgrepStore.Diagnostics { await ripgrepStore.diagnostics() } func rebuildRipgrepIndexes() async { coverageDebounceTasks.values.forEach { $0.cancel() } coverageDebounceTasks.removeAll() coverageLoadTasks.values.forEach { $0.cancel() } coverageLoadTasks.removeAll() await ripgrepStore.resetAll() updatedMonthCoverage.removeAll() monthCountsCache.removeAll() scheduleViewUpdate() if dateDimension == .updated { // Use current selected path for accurate cache key triggerCoverageLoad( for: sidebarMonthStart, dimension: dateDimension, projectPath: selectedPath) } scheduleApplyFilters() } /// Fully rebuild the session index (in-memory + on-disk caches) by /// clearing cached summaries and forcing a full refresh from JSONL logs. func rebuildSessionIndex() async { await indexer.resetAllCaches() enrichmentSnapshots.removeAll() await refreshSessions(force: true) } /// Force refresh coverage for current view scope (Cmd+R) func forceRefreshCurrentScope() async { let projectPath = selectedPath let monthStart = sidebarMonthStart // Cancel ongoing tasks for this scope let key = coverageCacheKey(monthStart, dateDimension, projectPath: projectPath) coverageDebounceTasks[key]?.cancel() coverageDebounceTasks.removeValue(forKey: key) coverageLoadTasks[key]?.cancel() coverageLoadTasks.removeValue(forKey: key) // Clear cache for this scope monthCountsCache.removeValue(forKey: cacheKey(monthStart, dateDimension)) // Trigger fresh scan if dateDimension == .updated { triggerCoverageLoad( for: monthStart, dimension: dateDimension, projectPath: projectPath, forceRefresh: true ) } scheduleApplyFilters() } /// Notify that a session file has been modified (for incremental cache invalidation) func notifySessionFileModified(at fileURL: URL) async { await ripgrepStore.markFileModified(fileURL.path) } // Invalidate all cached monthly counts; next access will recompute func invalidateCalendarCaches() { monthCountsCache.removeAll() scheduleViewUpdate() } private func performInitialRemoteSyncIfNeeded() async { guard !preferences.enabledRemoteHosts.isEmpty else { return } // Don't force refresh on launch - let user trigger refresh via Command+R or filesystem events await syncRemoteHosts(force: false, refreshAfter: false) } func syncRemoteHosts(force: Bool = true, refreshAfter: Bool = true) async { let enabledHosts = preferences.enabledRemoteHosts guard !enabledHosts.isEmpty else { return } let hostCount = enabledHosts.count AppLogger.shared.info( "Syncing \(hostCount) remote host\(hostCount == 1 ? "" : "s")", source: "Remote") await remoteProvider.syncHosts(enabledHosts, force: force) await updateRemoteSyncStates() AppLogger.shared.success("Remote sync complete", source: "Remote") if refreshAfter { await refreshSessions(force: true) } } private func updateRemoteSyncStates() async { let snapshot = await remoteProvider.syncStatusSnapshot() await MainActor.run { self.remoteSyncStates = snapshot } } } extension SessionListViewModel { private func membershipKey(for id: String, source: ProjectSessionSource) -> String { "\(source.rawValue)|\(id)" } private func membershipKey(for summary: SessionSummary) -> String { membershipKey(for: summary.id, source: summary.source.projectSource) } func projectId(for summary: SessionSummary) -> String? { projectMemberships[membershipKey(for: summary)] } func projectId(for sessionId: String, source: ProjectSessionSource) -> String? { projectMemberships[membershipKey(for: sessionId, source: source)] } func cachedInstructions(for summary: SessionSummary) async -> String? { let projectId = projectId(for: summary) let keys = SessionIndexSQLiteStore.candidateProjectKeys(projectId: projectId, cwd: summary.cwd) return await indexer.cachedInstructions(forKeys: keys) } func sessionSummary(for id: String) -> SessionSummary? { sessionLookup[id] } func sessionDragIdentifier(for summary: SessionSummary) -> String { "session::\(summary.source.projectSource.rawValue)::\(summary.id)" } func sessionAssignment(forIdentifier identifier: String) -> SessionAssignment? { if let parsed = parseSessionIdentifier(identifier) { return parsed } if let summary = sessionSummary(for: identifier) { return SessionAssignment(id: summary.id, source: summary.source.projectSource) } return SessionAssignment(id: identifier, source: .codex) } private func parseSessionIdentifier(_ value: String) -> SessionAssignment? { let parts = value.components(separatedBy: "::") guard parts.count == 3, parts[0] == "session" else { return nil } guard let source = ProjectSessionSource(rawValue: parts[1]) else { return nil } return SessionAssignment(id: parts[2], source: source) } // MARK: - Task Management /// Automatically assign unassigned sessions to the "Others" task private func autoAssignSessionsToOthersTask() { Task { [weak self] in guard let self else { return } let sessions = await MainActor.run { self.allSessions } for session in sessions { // Check if session is already assigned to a task let taskId = await self.tasksStore.taskId(for: session.id) if taskId == nil { // Not assigned to any task, assign to Others await self.tasksStore.assignToOthers(sessionId: session.id) } } } } /// Get the task ID for a given session func getTaskId(for sessionId: String) async -> UUID? { return await tasksStore.taskId(for: sessionId) } /// Get all tasks func getTasks() async -> [CodMateTask] { return await tasksStore.listTasks() } /// Get tasks for a specific project func getTasks(for projectId: String) async -> [CodMateTask] { return await tasksStore.listTasks(for: projectId) } /// Create a new task func createTask(_ task: CodMateTask) async { await tasksStore.upsertTask(task) } /// Update an existing task func updateTask(_ task: CodMateTask) async { await tasksStore.upsertTask(task) } /// Delete a task func deleteTask(id: UUID) async { await tasksStore.deleteTask(id: id) } /// Assign sessions to a task func assignSessions(_ sessionIds: [String], to taskId: UUID?) async { await tasksStore.assignSessions(sessionIds, to: taskId) } } ================================================ FILE: models/SessionLoadScope.swift ================================================ import Foundation enum SessionLoadScope: Equatable, Sendable { case today case day(Date) // startOfDay case month(Date) // first day of month case all } ================================================ FILE: models/SessionNavigation.swift ================================================ import Foundation enum SessionNavigationItem: Hashable, Identifiable { case allSessions case calendarDay(Date) // startOfDay case pathPrefix(String) // absolute directory path prefix var id: String { switch self { case .allSessions: return "all" case let .calendarDay(day): return "day-\(ISO8601DateFormatter().string(from: day))" case let .pathPrefix(prefix): return "path-\(prefix)" } } var title: String { switch self { case .allSessions: return "All Sessions" case let .calendarDay(day): let df = DateFormatter() df.dateStyle = .medium df.timeStyle = .none return df.string(from: day) case let .pathPrefix(prefix): return URL(fileURLWithPath: prefix, isDirectory: true).lastPathComponent } } var systemImage: String { switch self { case .allSessions: return "tray.full" case .calendarDay: return "calendar" case .pathPrefix: return "folder" } } } ================================================ FILE: models/SessionPathConfig.swift ================================================ import Foundation struct SessionPathConfig: Codable, Identifiable, Hashable, Sendable { let id: String let kind: SessionSource.Kind var path: String var enabled: Bool var displayName: String? var ignoredSubpaths: [String] var disabledSubpaths: Set var isDefault: Bool { displayName != nil } init( id: String = UUID().uuidString, kind: SessionSource.Kind, path: String, enabled: Bool = true, displayName: String? = nil, ignoredSubpaths: [String] = [], disabledSubpaths: Set = [] ) { self.id = id self.kind = kind self.path = path self.enabled = enabled self.displayName = displayName self.ignoredSubpaths = ignoredSubpaths self.disabledSubpaths = disabledSubpaths } // Custom Codable implementation for backward compatibility enum CodingKeys: String, CodingKey { case id, kind, path, enabled, displayName, ignoredSubpaths, disabledSubpaths } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(String.self, forKey: .id) kind = try container.decode(SessionSource.Kind.self, forKey: .kind) path = try container.decode(String.self, forKey: .path) enabled = try container.decode(Bool.self, forKey: .enabled) displayName = try container.decodeIfPresent(String.self, forKey: .displayName) ignoredSubpaths = try container.decodeIfPresent([String].self, forKey: .ignoredSubpaths) ?? [] // Backward compatibility: if disabledSubpaths is missing, default to empty set disabledSubpaths = try container.decodeIfPresent(Set.self, forKey: .disabledSubpaths) ?? [] } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) try container.encode(kind, forKey: .kind) try container.encode(path, forKey: .path) try container.encode(enabled, forKey: .enabled) try container.encodeIfPresent(displayName, forKey: .displayName) try container.encode(ignoredSubpaths, forKey: .ignoredSubpaths) try container.encode(disabledSubpaths, forKey: .disabledSubpaths) } } ================================================ FILE: models/SessionSource+CaseIterable.swift ================================================ import Foundation extension SessionSource.Kind: CaseIterable { static var allCases: [SessionSource.Kind] { [.codex, .claude, .gemini] } } ================================================ FILE: models/SessionSummary.swift ================================================ import Foundation struct SessionTokenBreakdown: Codable, Hashable, Sendable { let input: Int let output: Int let cacheRead: Int let cacheCreation: Int /// Total tokens = input + output (cache_read is already included in input, just marked for billing discount) var total: Int { input + output } } struct SessionSummary: Identifiable, Hashable, Sendable, Codable { let id: String let fileURL: URL let fileSizeBytes: UInt64? let startedAt: Date let endedAt: Date? // Sum of actual active conversation segments (user → Codex), // computed from grouped timeline turns during enrichment. // Nil until enriched; falls back to (endedAt - startedAt) in UI when nil. let activeDuration: TimeInterval? let cliVersion: String let cwd: String let originator: String let instructions: String? let model: String? let approvalPolicy: String? let userMessageCount: Int let assistantMessageCount: Int var toolInvocationCount: Int let responseCounts: [String: Int] let turnContextCount: Int var messageTypeCounts: [String: Int]? = nil let totalTokens: Int? var tokenBreakdown: SessionTokenBreakdown? = nil let eventCount: Int let lineCount: Int let lastUpdatedAt: Date? let source: SessionSource let remotePath: String? // User-provided metadata (rename/comment) var userTitle: String? = nil var userComment: String? = nil // Task association (optional - nil means standalone session) var taskId: UUID? = nil public enum ParseLevel: String, Codable, Sendable, Comparable { case metadata case full case enriched public static func < (lhs: ParseLevel, rhs: ParseLevel) -> Bool { switch (lhs, rhs) { case (.metadata, .full), (.metadata, .enriched), (.full, .enriched): return true default: return false } } } var parseLevel: ParseLevel? = nil var duration: TimeInterval { if let activeDuration { return activeDuration } guard let end = endedAt ?? lastUpdatedAt else { return 0 } return end.timeIntervalSince(startedAt) } var actualTotalTokens: Int { return totalTokens ?? 0 } func visibleEventCount(using visibleKinds: Set) -> Int { guard let messageTypeCounts, !messageTypeCounts.isEmpty else { return eventCount } let allowed = visibleKinds.rawValues var total = 0 for (key, count) in messageTypeCounts where allowed.contains(key) { total += count } return total } var displayName: String { let filename = fileURL.deletingPathExtension().lastPathComponent // Handle new format: agent-6afec743 -> extract agentId from filename if filename.hasPrefix("agent-") { // Use the agentId portion from filename to distinguish between parallel agents let agentId = String(filename.dropFirst("agent-".count)) if !agentId.isEmpty { return "agent-\(agentId)" } // Fallback to sessionId if agentId extraction failed return id } // Handle old UUID format: ed5f5b12-a30b-4c86-b3ff-5bcf5dba65c0 -> use as is if filename.components(separatedBy: "-").count == 5 && filename.count == 36 && UUID(uuidString: filename) != nil { return filename } // Handle rollout format: "rollout-2025-10-17T14-11-18-0199f124-8c38-7140-969c-396260d0099c" // Keep only the last 5 segments after removing rollout + timestamp (5 parts) let components = filename.components(separatedBy: "-") if components.count >= 7 { // Skip first component (rollout) and next 5 components (timestamp), keep last 5 let sessionIdComponents = Array(components.dropFirst(6)) return sessionIdComponents.joined(separator: "-") } return filename } // Prefer user-provided title when available var effectiveTitle: String { (userTitle?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { $0.isEmpty ? nil : $0 } ?? displayName } var instructionSnippet: String { guard let instructions, !instructions.isEmpty else { return "—" } let trimmed = instructions.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.count <= 220 { return trimmed } let index = trimmed.index(trimmed.startIndex, offsetBy: 220) return "\(trimmed[.. Bool { guard !term.isEmpty else { return true } if id.localizedCaseInsensitiveContains(term) { return true } if displayName.localizedCaseInsensitiveContains(term) { return true } if let userTitle, userTitle.localizedCaseInsensitiveContains(term) { return true } if let userComment, userComment.localizedCaseInsensitiveContains(term) { return true } if cliVersion.localizedCaseInsensitiveContains(term) { return true } if cwd.localizedCaseInsensitiveContains(term) { return true } if originator.localizedCaseInsensitiveContains(term) { return true } if let instructions, instructions.localizedCaseInsensitiveContains(term) { return true } if let model, model.localizedCaseInsensitiveContains(term) { return true } if let approvalPolicy, approvalPolicy.localizedCaseInsensitiveContains(term) { return true } if let host = remoteHost, host.localizedCaseInsensitiveContains(term) { return true } if let remotePath, remotePath.localizedCaseInsensitiveContains(term) { return true } return false } } extension SessionSummary { func overridingSource(_ newSource: SessionSource, remotePath: String? = nil) -> SessionSummary { if newSource == source, remotePath == self.remotePath { return self } let adjustedModel = (newSource.baseKind == source.baseKind) ? model : nil let adjustedApproval = (newSource.baseKind == source.baseKind) ? approvalPolicy : nil var s = SessionSummary( id: id, fileURL: fileURL, fileSizeBytes: fileSizeBytes, startedAt: startedAt, endedAt: endedAt, activeDuration: activeDuration, cliVersion: cliVersion, cwd: cwd, originator: originator, instructions: instructions, model: adjustedModel, approvalPolicy: adjustedApproval, userMessageCount: userMessageCount, assistantMessageCount: assistantMessageCount, toolInvocationCount: toolInvocationCount, responseCounts: responseCounts, turnContextCount: turnContextCount, messageTypeCounts: messageTypeCounts, totalTokens: totalTokens, tokenBreakdown: tokenBreakdown, eventCount: eventCount, lineCount: lineCount, lastUpdatedAt: lastUpdatedAt, source: newSource, remotePath: remotePath ?? self.remotePath, userTitle: userTitle, userComment: userComment, taskId: taskId ) s.parseLevel = parseLevel return s } func withInstructionPreview(_ preview: String?) -> SessionSummary { var s = SessionSummary( id: id, fileURL: fileURL, fileSizeBytes: fileSizeBytes, startedAt: startedAt, endedAt: endedAt, activeDuration: activeDuration, cliVersion: cliVersion, cwd: cwd, originator: originator, instructions: preview, model: model, approvalPolicy: approvalPolicy, userMessageCount: userMessageCount, assistantMessageCount: assistantMessageCount, toolInvocationCount: toolInvocationCount, responseCounts: responseCounts, turnContextCount: turnContextCount, messageTypeCounts: messageTypeCounts, totalTokens: totalTokens, tokenBreakdown: tokenBreakdown, eventCount: eventCount, lineCount: lineCount, lastUpdatedAt: lastUpdatedAt, source: source, remotePath: remotePath, userTitle: userTitle, userComment: userComment, taskId: taskId ) s.parseLevel = parseLevel return s } func withRemoteMetadata(source: SessionSource, remotePath: String) -> SessionSummary { return overridingSource(source, remotePath: remotePath) } func overridingCounts( userMessages: Int? = nil, assistantMessages: Int? = nil, toolInvocations: Int? = nil ) -> SessionSummary { var s = SessionSummary( id: id, fileURL: fileURL, fileSizeBytes: fileSizeBytes, startedAt: startedAt, endedAt: endedAt, activeDuration: activeDuration, cliVersion: cliVersion, cwd: cwd, originator: originator, instructions: instructions, model: model, approvalPolicy: approvalPolicy, userMessageCount: userMessages ?? userMessageCount, assistantMessageCount: assistantMessages ?? assistantMessageCount, toolInvocationCount: toolInvocations ?? toolInvocationCount, responseCounts: responseCounts, turnContextCount: turnContextCount, messageTypeCounts: messageTypeCounts, totalTokens: totalTokens, tokenBreakdown: tokenBreakdown, eventCount: eventCount, lineCount: lineCount, lastUpdatedAt: lastUpdatedAt, source: source, remotePath: remotePath, userTitle: userTitle, userComment: userComment, taskId: taskId ) s.parseLevel = parseLevel return s } func overridingTokens( totalTokens: Int?, tokenBreakdown: SessionTokenBreakdown? ) -> SessionSummary { var s = SessionSummary( id: id, fileURL: fileURL, fileSizeBytes: fileSizeBytes, startedAt: startedAt, endedAt: endedAt, activeDuration: activeDuration, cliVersion: cliVersion, cwd: cwd, originator: originator, instructions: instructions, model: model, approvalPolicy: approvalPolicy, userMessageCount: userMessageCount, assistantMessageCount: assistantMessageCount, toolInvocationCount: toolInvocationCount, responseCounts: responseCounts, turnContextCount: turnContextCount, messageTypeCounts: messageTypeCounts, totalTokens: totalTokens, tokenBreakdown: tokenBreakdown ?? self.tokenBreakdown, eventCount: eventCount, lineCount: lineCount, lastUpdatedAt: lastUpdatedAt, source: source, remotePath: remotePath, userTitle: userTitle, userComment: userComment, taskId: taskId ) s.parseLevel = parseLevel return s } func withParseLevel(_ level: ParseLevel?) -> SessionSummary { var s = self s.parseLevel = level return s } func withParseLevel(fromString levelString: String?) -> SessionSummary { guard let levelString, let level = ParseLevel(rawValue: levelString) else { return self } return withParseLevel(level) } func withTokenBreakdownFallback(_ breakdown: SessionTokenBreakdown?) -> SessionSummary { guard tokenBreakdown == nil, let breakdown else { return self } var s = self s.tokenBreakdown = breakdown return s } } enum SessionSortOrder: String, CaseIterable, Identifiable, Sendable { case mostRecent case longestDuration case mostActivity case alphabetical case largestSize var id: String { rawValue } var title: String { switch self { case .mostRecent: return "Recent" case .longestDuration: return "Duration" case .mostActivity: return "Activity" case .alphabetical: return "Name" case .largestSize: return "Size" } } func sort(_ sessions: [SessionSummary]) -> [SessionSummary] { switch self { case .mostRecent: return sessions.sorted { ($0.lastUpdatedAt ?? $0.startedAt) > ($1.lastUpdatedAt ?? $1.startedAt) } case .longestDuration: return sessions.sorted { $0.duration > $1.duration } case .mostActivity: return sessions.sorted { if $0.eventCount != $1.eventCount { return $0.eventCount > $1.eventCount } let l0 = $0.lastUpdatedAt ?? $0.startedAt let l1 = $1.lastUpdatedAt ?? $1.startedAt if l0 != l1 { return l0 > l1 } return $0.effectiveTitle .localizedCaseInsensitiveCompare($1.effectiveTitle) == .orderedAscending } case .alphabetical: return sessions.sorted { let cmp = $0.effectiveTitle.localizedStandardCompare($1.effectiveTitle) if cmp == .orderedSame { let l0 = $0.lastUpdatedAt ?? $0.startedAt let l1 = $1.lastUpdatedAt ?? $1.startedAt if l0 != l1 { return l0 > l1 } return $0.id < $1.id } return cmp == .orderedAscending } case .largestSize: return sessions.sorted { ($0.fileSizeBytes ?? 0) > ($1.fileSizeBytes ?? 0) } } } // Dimension-aware sorting variant used by the middle list. For "Recent", // order by created vs. last-updated depending on the calendar mode; other // sort orders fall back to the default behavior above. func sort(_ sessions: [SessionSummary], dimension: DateDimension) -> [SessionSummary] { switch self { case .mostRecent: let key: (SessionSummary) -> Date = { switch dimension { case .created: return $0.startedAt case .updated: return $0.lastUpdatedAt ?? $0.startedAt } } return sessions.sorted { key($0) > key($1) } default: return sort(sessions) } } func sort( _ sessions: [SessionSummary], dimension: DateDimension, visibleKinds: Set ) -> [SessionSummary] { switch self { case .mostRecent: return sort(sessions, dimension: dimension) case .mostActivity: return sessions.sorted { let lCount = $0.visibleEventCount(using: visibleKinds) let rCount = $1.visibleEventCount(using: visibleKinds) if lCount != rCount { return lCount > rCount } let l0 = $0.lastUpdatedAt ?? $0.startedAt let l1 = $1.lastUpdatedAt ?? $1.startedAt if l0 != l1 { return l0 > l1 } return $0.effectiveTitle .localizedCaseInsensitiveCompare($1.effectiveTitle) == .orderedAscending } default: return sort(sessions) } } func sort( _ sessions: [SessionSummary], visibleKinds: Set ) -> [SessionSummary] { switch self { case .mostActivity: return sessions.sorted { let lCount = $0.visibleEventCount(using: visibleKinds) let rCount = $1.visibleEventCount(using: visibleKinds) if lCount != rCount { return lCount > rCount } let l0 = $0.lastUpdatedAt ?? $0.startedAt let l1 = $1.lastUpdatedAt ?? $1.startedAt if l0 != l1 { return l0 > l1 } return $0.effectiveTitle .localizedCaseInsensitiveCompare($1.effectiveTitle) == .orderedAscending } default: return sort(sessions) } } } struct SessionDaySection: Identifiable, Hashable, Sendable { let id: Date let title: String let totalDuration: TimeInterval let totalEvents: Int let sessions: [SessionSummary] } enum SessionSource: Hashable, Sendable { case codexLocal case claudeLocal case geminiLocal case codexRemote(host: String) case claudeRemote(host: String) case geminiRemote(host: String) var isRemote: Bool { switch self { case .codexRemote, .claudeRemote, .geminiRemote: return true default: return false } } var remoteHost: String? { switch self { case .codexRemote(let host), .claudeRemote(let host), .geminiRemote(let host): return host default: return nil } } var baseKind: Kind { switch self { case .codexLocal, .codexRemote: return .codex case .claudeLocal, .claudeRemote: return .claude case .geminiLocal, .geminiRemote: return .gemini } } enum Kind: String, Codable, Sendable { case codex case claude case gemini var cliExecutableName: String { switch self { case .codex: return "codex" case .claude: return "claude" case .gemini: return "gemini" } } } } extension SessionSource.Kind { var displayName: String { switch self { case .codex: return "Codex" case .claude: return "Claude" case .gemini: return "Gemini" } } } extension SessionSource: Codable { private enum CodingKeys: String, CodingKey { case kind case host } func encode(to encoder: Encoder) throws { switch self { case .codexLocal: var container = encoder.singleValueContainer() try container.encode("codex") case .claudeLocal: var container = encoder.singleValueContainer() try container.encode("claude") case .geminiLocal: var container = encoder.singleValueContainer() try container.encode("gemini") case .codexRemote(let host): var container = encoder.container(keyedBy: CodingKeys.self) try container.encode("codexRemote", forKey: .kind) try container.encode(host, forKey: .host) case .claudeRemote(let host): var container = encoder.container(keyedBy: CodingKeys.self) try container.encode("claudeRemote", forKey: .kind) try container.encode(host, forKey: .host) case .geminiRemote(let host): var container = encoder.container(keyedBy: CodingKeys.self) try container.encode("geminiRemote", forKey: .kind) try container.encode(host, forKey: .host) } } init(from decoder: Decoder) throws { if let singleValue = try? decoder.singleValueContainer(), let raw = try? singleValue.decode(String.self) { switch raw { case "codex": self = .codexLocal case "claude": self = .claudeLocal case "gemini": self = .geminiLocal case "codexLocal": self = .codexLocal case "claudeLocal": self = .claudeLocal case "geminiLocal": self = .geminiLocal default: throw DecodingError.dataCorruptedError( in: singleValue, debugDescription: "Unknown SessionSource raw value \(raw)") } return } let container = try decoder.container(keyedBy: CodingKeys.self) let kind = try container.decode(String.self, forKey: .kind) switch kind { case "codexRemote": let host = try container.decode(String.self, forKey: .host) self = .codexRemote(host: host) case "claudeRemote": let host = try container.decode(String.self, forKey: .host) self = .claudeRemote(host: host) case "geminiRemote": let host = try container.decode(String.self, forKey: .host) self = .geminiRemote(host: host) case "codex": self = .codexLocal case "claude": self = .claudeLocal case "gemini": self = .geminiLocal default: throw DecodingError.dataCorruptedError( forKey: .kind, in: container, debugDescription: "Unknown SessionSource kind \(kind)") } } } ================================================ FILE: models/SettingCategory.swift ================================================ import Foundation enum SettingCategory: String, CaseIterable, Identifiable { case general case terminal case notifications case command case providers case codex case gemini case remoteHosts case gitReview case claudeCode case advanced case mcpServer case about // Customize displayed order and allow hiding categories without breaking enums elsewhere. // Remote Hosts appears as a top-level settings page alongside Codex. static var allCases: [SettingCategory] { [ .general, .terminal, .providers, .gitReview, .mcpServer, .remoteHosts, .codex, .gemini, .claudeCode, .notifications, .advanced, .about ] } var id: String { rawValue } var title: String { switch self { case .general: return "General" case .terminal: return "Terminal" case .notifications: return "Notifications" case .command: return "Command" case .providers: return "Providers" case .codex: return "Codex" case .gemini: return "Gemini CLI" case .remoteHosts: return "Remote Hosts" case .gitReview: return "Git Review" case .claudeCode: return "Claude Code" case .advanced: return "Advanced" case .mcpServer: return "Extensions" case .about: return "About" } } var icon: String { switch self { case .general: return "gear" case .terminal: return "terminal" case .notifications: return "bell" case .command: return "slider.horizontal.3" case .providers: return "server.rack" case .codex: return "sparkles" case .gemini: return "sparkles.rectangle.stack" case .remoteHosts: return "antenna.radiowaves.left.and.right" case .advanced: return "gearshape.2" case .gitReview: return "square.and.pencil" case .claudeCode: return "chevron.left.slash.chevron.right" case .mcpServer: return "puzzlepiece.extension" case .about: return "info.circle" } } var description: String { switch self { case .general: return "Basic application settings" case .terminal: return "Terminal and resume preferences" case .notifications: return "Notification preferences and hooks" case .command: return "Command execution policies" case .providers: return "Global providers and bindings" case .codex: return "Codex CLI configuration" case .gemini: return "Gemini CLI configuration" case .remoteHosts: return "Remote SSH host configuration" case .gitReview: return "Git changes viewer and commit generation" case .claudeCode: return "Claude Code configuration" case .advanced: return "Paths and deep diagnostics" case .mcpServer: return "Manage MCP servers and Skills" case .about: return "App info and project links" } } } ================================================ FILE: models/SidebarState.swift ================================================ import Foundation struct SidebarState: Equatable { var totalSessionCount: Int var isLoading: Bool var visibleAllCount: Int var selectedProjectIDs: Set var selectedDay: Date? var selectedDays: Set var dateDimension: DateDimension var monthStart: Date var calendarCounts: [Int: Int] var enabledProjectDays: Set? } struct SidebarActions { var selectAllProjects: () -> Void var requestNewProject: () -> Void var requestNewTask: () -> Void var setDateDimension: (DateDimension) -> Void var setMonthStart: (Date) -> Void var setSelectedDay: (Date?) -> Void var toggleSelectedDay: (Date) -> Void } ================================================ FILE: models/SkillsLibraryViewModel.swift ================================================ import Foundation import UniformTypeIdentifiers #if canImport(AppKit) import AppKit #endif struct SkillSummary: Identifiable, Hashable { let id: String var name: String var description: String var summary: String var tags: [String] var source: String var path: String? var isSelected: Bool var targets: MCPServerTargets var sourceType: String? var displayName: String { name.isEmpty ? id : name } var isTemplateCreated: Bool { sourceType == "template" } } @MainActor final class SkillsLibraryViewModel: ObservableObject { private let store = SkillsStore() private let syncer = SkillsSyncService() @Published var skills: [SkillSummary] = [] @Published var selectedSkillId: String? @Published var searchText: String = "" @Published var isLoading: Bool = false @Published var errorMessage: String? @Published var installStatusMessage: String? @Published var showInstallSheet: Bool = false @Published var installMode: SkillInstallMode = .folder @Published var pendingInstallURL: URL? @Published var pendingInstallText: String = "" @Published var installConflict: SkillInstallConflict? @Published var showCreateSheet: Bool = false @Published var newSkillName: String = "" @Published var newSkillDescription: String = "" @Published var createErrorMessage: String? @Published var pendingWizardDraft: SkillWizardDraft? = nil @Published var createStartsWithWizard: Bool = false @Published var wizardPreviewSkill: SkillSummary? = nil private var wizardPreviewURL: URL? = nil @Published var showImportSheet: Bool = false @Published var importCandidates: [SkillImportCandidate] = [] @Published var isImporting: Bool = false @Published var importStatusMessage: String? var filteredSkills: [SkillSummary] { let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return skills } return skills.filter { skill in let hay = [skill.displayName, skill.summary, skill.tags.joined(separator: " "), skill.source] .joined(separator: " ") .lowercased() return hay.contains(trimmed.lowercased()) } } var selectedSkill: SkillSummary? { guard let id = selectedSkillId else { return nil } return skills.first(where: { $0.id == id }) } func load() async { isLoading = true defer { isLoading = false } let records = await store.list() skills = await withTaskGroup(of: (Int, SkillSummary).self) { group in for (index, record) in records.enumerated() { group.addTask { let sourceType = await self.store.getSourceType( at: URL(fileURLWithPath: record.path) ) return (index, SkillSummary( id: record.id, name: record.name, description: record.description, summary: record.summary, tags: record.tags, source: record.source, path: record.path, isSelected: record.isEnabled, targets: record.targets, sourceType: sourceType )) } } var results: [(Int, SkillSummary)] = [] for await result in group { results.append(result) } return results.sorted(by: { $0.0 < $1.0 }).map { $0.1 } } if selectedSkillId == nil || !skills.contains(where: { $0.id == selectedSkillId }) { selectedSkillId = skills.first?.id } } // MARK: - Import (Home) func beginImportFromHome() { showImportSheet = true Task { await loadImportCandidatesFromHome() } } func loadImportCandidatesFromHome() async { isImporting = true importStatusMessage = "Scanning…" if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() AuthorizationHub.shared.ensureDirectoryAccessOrPrompt( directory: home, purpose: .generalAccess, message: "Authorize your Home folder to import skills" ) } let scanned = await Task.detached(priority: .userInitiated) { await SkillsImportService.scan(scope: .home) }.value let existing = await store.list() let managedIds = Set(existing.map(\.id)) // CodMate store is the source of truth; provider directories can drift if edited by other tools. let filtered = scanned.filter { !managedIds.contains($0.id) } var candidates: [SkillImportCandidate] = [] for item in filtered { var updated = item if let conflict = await store.conflictInfo(forProposedId: item.id) { updated.hasConflict = true updated.isSelected = false updated.resolution = .skip updated.renameId = conflict.suggestedId updated.suggestedId = conflict.suggestedId updated.conflictDetail = conflict.existingIsManaged ? "Existing CodMate-managed skill" : "Skill already exists" } candidates.append(updated) } importCandidates = candidates isImporting = false importStatusMessage = candidates.isEmpty ? "No skills found." : nil } func cancelImport() { showImportSheet = false importCandidates = [] importStatusMessage = nil } func importSelectedSkills() async { let selected = importCandidates.filter { $0.isSelected } guard !selected.isEmpty else { importStatusMessage = "No skills selected." return } if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() let codmate = home.appendingPathComponent(".codmate", isDirectory: true) AuthorizationHub.shared.ensureDirectoryAccessOrPrompt( directory: codmate, purpose: .generalAccess, message: "Authorize ~/.codmate to import skills" ) } var importedCount = 0 var importedCandidateIds: Set = [] var importedCandidates: [SkillImportCandidate] = [] for item in selected { let resolution = item.hasConflict ? item.resolution : .overwrite switch resolution { case .skip: continue case .overwrite: let req = SkillInstallRequest(mode: .folder, url: URL(fileURLWithPath: item.sourcePath), text: nil) let outcome = await store.install(request: req, resolution: .overwrite) if case .installed(let record) = outcome { await store.markImported(id: record.id) importedCount += 1 importedCandidateIds.insert(item.id) importedCandidates.append(item) } case .rename: let newId = item.renameId.trimmingCharacters(in: .whitespacesAndNewlines) guard !newId.isEmpty else { continue } let req = SkillInstallRequest(mode: .folder, url: URL(fileURLWithPath: item.sourcePath), text: nil) let outcome = await store.install(request: req, resolution: .rename(newId)) if case .installed(let record) = outcome { await store.markImported(id: record.id) importedCount += 1 importedCandidateIds.insert(item.id) importedCandidates.append(item) } } } if !importedCandidates.isEmpty { removeImportedProviderCopies(importedCandidates) } await load() await persistAndSync() importStatusMessage = "Imported \(importedCount) skill(s)." if !importedCandidateIds.isEmpty { importCandidates.removeAll { importedCandidateIds.contains($0.id) } } if importCandidates.isEmpty { closeImportSheetAfterDelay() } } private func closeImportSheetAfterDelay(_ delay: TimeInterval = 0.6) { Task { @MainActor in try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) self.showImportSheet = false self.importStatusMessage = nil } } private func removeImportedProviderCopies(_ items: [SkillImportCandidate]) { let home = SessionPreferencesStore.getRealUserHomeURL() let providerRoots: [String: URL] = [ "Codex": home.appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("skills", isDirectory: true), "Claude": home.appendingPathComponent(".claude", isDirectory: true) .appendingPathComponent("skills", isDirectory: true) ] if SecurityScopedBookmarks.shared.isSandboxed { AuthorizationHub.shared.ensureDirectoryAccessOrPrompt( directory: home.appendingPathComponent(".codex", isDirectory: true), purpose: .generalAccess, message: "Authorize ~/.codex to adopt imported skills" ) AuthorizationHub.shared.ensureDirectoryAccessOrPrompt( directory: home.appendingPathComponent(".claude", isDirectory: true), purpose: .generalAccess, message: "Authorize ~/.claude to adopt imported skills" ) } let fm = FileManager.default for item in items { if item.sourcePaths.isEmpty { for source in item.sources { guard let root = providerRoots[source] else { continue } let dir = URL(fileURLWithPath: item.sourcePath, isDirectory: true) if dir.standardizedFileURL.path.hasPrefix(root.standardizedFileURL.path) { try? fm.removeItem(at: dir) } } continue } for (source, path) in item.sourcePaths { guard let root = providerRoots[source] else { continue } let dir = URL(fileURLWithPath: path).deletingLastPathComponent() if dir.standardizedFileURL.path.hasPrefix(root.standardizedFileURL.path) { try? fm.removeItem(at: dir) } } } } func prepareInstall(mode: SkillInstallMode, url: URL? = nil, text: String? = nil) { installMode = mode pendingInstallURL = url pendingInstallText = text ?? "" installStatusMessage = nil installConflict = nil showInstallSheet = true } func cancelInstall() { showInstallSheet = false pendingInstallURL = nil pendingInstallText = "" installStatusMessage = nil } func testInstall() { installStatusMessage = "Validating…" Task { let request = installRequest() let ok = await store.validate(request: request) await MainActor.run { installStatusMessage = ok ? "Looks good. Ready to install." : "Unable to validate this source." } } } func finishInstall() { installStatusMessage = "Installing…" Task { let request = installRequest() let outcome = await store.install(request: request, resolution: nil) await MainActor.run { handleInstallOutcome(outcome) } } } func updateSkillTarget(id: String, target: MCPServerTarget, value: Bool) { guard let idx = skills.firstIndex(where: { $0.id == id }) else { return } var updated = skills[idx] updated.targets.setEnabled(value, for: target) if value && !updated.isSelected { updated.isSelected = true } else if !updated.targets.codex && !updated.targets.claude && !updated.targets.gemini { updated.isSelected = false } skills[idx] = updated Task { await persistAndSync() } } func updateSkillSelection(id: String, value: Bool) { guard let idx = skills.firstIndex(where: { $0.id == id }) else { return } skills[idx].isSelected = value if !value { skills[idx].targets.codex = false skills[idx].targets.claude = false skills[idx].targets.gemini = false } else { skills[idx].targets.codex = true skills[idx].targets.claude = true skills[idx].targets.gemini = true } Task { await persistAndSync() } } func handleDrop(_ providers: [NSItemProvider]) -> Bool { guard let provider = providers.first else { return false } if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in guard let data = item as? Data, let url = URL(dataRepresentation: data, relativeTo: nil) else { return } Task { @MainActor in let isZip = url.pathExtension.lowercased() == "zip" self.prepareInstall(mode: isZip ? .zip : .folder, url: url) } } return true } if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) { provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, _ in if let url = item as? URL { Task { @MainActor in self.prepareInstall(mode: .url, text: url.absoluteString) } } } return true } if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in let text: String? if let data = item as? Data { text = String(data: data, encoding: .utf8) } else { text = item as? String } guard let text, !text.isEmpty else { return } Task { @MainActor in self.prepareInstall(mode: .url, text: text) } } return true } return false } func resolveInstallConflict(_ resolution: SkillConflictResolution) { installStatusMessage = "Installing…" Task { let request = installRequest() let outcome = await store.install(request: request, resolution: resolution) await MainActor.run { handleInstallOutcome(outcome) } } } func uninstall(id: String) { Task { await store.uninstall(id: id) await load() await persistAndSync() } } func prepareCreateSkill(startWithWizard: Bool = false) { createStartsWithWizard = startWithWizard newSkillName = "" newSkillDescription = "" createErrorMessage = nil pendingWizardDraft = nil clearWizardPreview() showCreateSheet = true } func cancelCreateSkill() { showCreateSheet = false newSkillName = "" newSkillDescription = "" createErrorMessage = nil pendingWizardDraft = nil createStartsWithWizard = false clearWizardPreview() } func createSkill() { createErrorMessage = nil Task { do { guard var draft = pendingWizardDraft else { await MainActor.run { createErrorMessage = "Use the wizard to create a skill." } return } let trimmedName = newSkillName.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedName.isEmpty { draft.id = trimmedName draft.name = trimmedName } let trimmedDesc = newSkillDescription.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedDesc.isEmpty { draft.description = trimmedDesc } let record = try await store.createFromWizard(draft: draft, enabled: false) await MainActor.run { showCreateSheet = false newSkillName = "" newSkillDescription = "" pendingWizardDraft = nil createStartsWithWizard = false clearWizardPreview() } await load() await MainActor.run { selectedSkillId = record.id } await persistAndSync() } catch let error as SkillCreationError { await MainActor.run { createErrorMessage = error.localizedDescription } } catch { await MainActor.run { createErrorMessage = "Failed to create skill: \(error.localizedDescription)" } } } } func applyWizardDraft(_ draft: SkillWizardDraft) { pendingWizardDraft = draft newSkillName = draft.id.isEmpty ? draft.name : draft.id newSkillDescription = draft.description createErrorMessage = nil refreshWizardPreview() } func refreshWizardPreview() { guard var draft = pendingWizardDraft else { clearWizardPreview() return } let trimmedName = newSkillName.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedName.isEmpty { draft.id = trimmedName draft.name = trimmedName } let trimmedDesc = newSkillDescription.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedDesc.isEmpty { draft.description = trimmedDesc } let previewDir: URL if let existing = wizardPreviewURL { previewDir = existing } else { let previewId = "wizard-preview-\(UUID().uuidString)" previewDir = FileManager.default.temporaryDirectory .appendingPathComponent(previewId, isDirectory: true) wizardPreviewURL = previewDir } let previewId = "wizard-preview-\(UUID().uuidString)" do { try FileManager.default.createDirectory(at: previewDir, withIntermediateDirectories: true) let markdown = store.generateSkillMarkdownFromDraft(draft, id: previewId) let skillFile = previewDir.appendingPathComponent("SKILL.md", isDirectory: false) try markdown.write(to: skillFile, atomically: true, encoding: .utf8) let summary = draft.summary?.isEmpty == false ? draft.summary! : draft.description let targets = draft.targets ?? MCPServerTargets(codex: true, claude: true, gemini: false) wizardPreviewSkill = SkillSummary( id: previewId, name: draft.name, description: draft.description, summary: summary, tags: draft.tags, source: "Wizard Preview", path: previewDir.path, isSelected: false, targets: targets, sourceType: "preview" ) } catch { wizardPreviewSkill = nil } } private func clearWizardPreview() { if let url = wizardPreviewURL { try? FileManager.default.removeItem(at: url) } wizardPreviewURL = nil wizardPreviewSkill = nil } func openInEditor(_ skill: SkillSummary, using editor: EditorApp) { guard let path = skill.path, !path.isEmpty else { errorMessage = "Skill path not available" return } let dirURL = URL(fileURLWithPath: path) var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory), isDirectory.boolValue else { errorMessage = "Skill directory does not exist: \(path)" return } if let executablePath = findExecutableInPath(editor.cliCommand) { let process = Process() process.executableURL = URL(fileURLWithPath: executablePath) process.arguments = [path] process.standardOutput = Pipe() process.standardError = Pipe() do { try process.run() return } catch { } } if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: editor.bundleIdentifier) { let config = NSWorkspace.OpenConfiguration() config.activates = true NSWorkspace.shared.open( [dirURL], withApplicationAt: appURL, configuration: config ) { _, error in if let error = error { DispatchQueue.main.async { self.errorMessage = "Failed to open \(editor.title): \(error.localizedDescription)" } } } return } errorMessage = "\(editor.title) is not installed. Please install it or try a different editor." } private func findExecutableInPath(_ name: String) -> String? { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/which") process.arguments = [name] let pipe = Pipe() process.standardOutput = pipe process.standardError = Pipe() do { try process.run() process.waitUntilExit() guard process.terminationStatus == 0 else { return nil } let data = pipe.fileHandleForReading.readDataToEndOfFile() let path = String(data: data, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines) return path?.isEmpty == false ? path : nil } catch { return nil } } private func installRequest() -> SkillInstallRequest { SkillInstallRequest(mode: installMode, url: pendingInstallURL, text: pendingInstallText) } private func handleInstallOutcome(_ outcome: SkillInstallOutcome) { switch outcome { case .installed: installStatusMessage = "Installed." showInstallSheet = false pendingInstallURL = nil pendingInstallText = "" Task { await reloadAfterInstall() } case .conflict(let conflict): installStatusMessage = "Skill already exists." installConflict = conflict case .skipped: installStatusMessage = "Install skipped." } } private func reloadAfterInstall() async { await load() await persistAndSync() } private func persistAndSync() async { var records = await store.list() for idx in records.indices { if let summary = skills.first(where: { $0.id == records[idx].id }) { records[idx].name = summary.name records[idx].description = summary.description records[idx].summary = summary.summary records[idx].tags = summary.tags records[idx].source = summary.source if let path = summary.path { records[idx].path = path } records[idx].isEnabled = summary.isSelected records[idx].targets = summary.targets } } await store.saveAll(records) let home = SessionPreferencesStore.getRealUserHomeURL() AuthorizationHub.shared.ensureDirectoryAccessOrPrompt( directory: home.appendingPathComponent(".codex", isDirectory: true), purpose: .generalAccess, message: "Authorize ~/.codex to sync Codex skills" ) AuthorizationHub.shared.ensureDirectoryAccessOrPrompt( directory: home.appendingPathComponent(".claude", isDirectory: true), purpose: .generalAccess, message: "Authorize ~/.claude to sync Claude skills" ) let warnings = await syncer.syncGlobal(skills: records) if let warning = warnings.first { errorMessage = warning.message } else { errorMessage = nil } } } ================================================ FILE: models/SkillsModels.swift ================================================ import Foundation struct SkillRecord: Identifiable, Codable, Hashable { var id: String var name: String var description: String var summary: String var tags: [String] var source: String var path: String var isEnabled: Bool var targets: MCPServerTargets var installedAt: Date } enum SkillInstallMode: String, CaseIterable, Codable { case folder case zip case url var title: String { switch self { case .folder: return "Folder" case .zip: return "Zip" case .url: return "URL" } } } struct SkillInstallRequest: Hashable, Sendable { var mode: SkillInstallMode var url: URL? var text: String? } enum SkillConflictResolution: Hashable, Sendable { case overwrite case skip case rename(String) } struct SkillInstallConflict: Identifiable, Hashable { let id: UUID = UUID() let proposedId: String let destination: URL let existingIsManaged: Bool let suggestedId: String } enum SkillInstallOutcome: Hashable { case installed(SkillRecord) case skipped case conflict(SkillInstallConflict) } struct SkillSyncWarning: Hashable, Sendable { var message: String } ================================================ FILE: models/StatusBarLogEntry.swift ================================================ import Foundation enum StatusBarLogLevel: String, Codable, CaseIterable, Identifiable { case info case success case warning case error var id: String { rawValue } } struct StatusBarLogEntry: Identifiable, Equatable { let id = UUID() let timestamp: Date let level: StatusBarLogLevel let message: String let source: String? init(message: String, level: StatusBarLogLevel = .info, source: String? = nil, timestamp: Date = Date()) { self.message = message self.level = level self.source = source self.timestamp = timestamp } } ================================================ FILE: models/StatusBarVisibility.swift ================================================ import Foundation enum StatusBarVisibility: String, CaseIterable, Identifiable, Codable { case auto case always case hidden var id: String { rawValue } } ================================================ FILE: models/SystemMenuVisibility.swift ================================================ import Foundation enum SystemMenuVisibility: String, CaseIterable, Identifiable, Sendable { case hidden case visible case menuOnly var id: String { rawValue } var title: String { switch self { case .hidden: return "Hidden" case .visible: return "Shown" case .menuOnly: return "Menu Bar Only" } } } ================================================ FILE: models/Task.swift ================================================ import Foundation // MARK: - Task Type enum TaskType: String, Codable, CaseIterable, Identifiable, Sendable { case feature = "feature" case bugFix = "bug_fix" case discussion = "discussion" case refactor = "refactor" case documentation = "documentation" case other = "other" var id: String { rawValue } var displayName: String { switch self { case .feature: return "Feature" case .bugFix: return "Bug Fix" case .discussion: return "Discussion" case .refactor: return "Refactor" case .documentation: return "Documentation" case .other: return "Other" } } var icon: String { switch self { case .feature: return "star.fill" case .bugFix: return "ladybug.fill" case .discussion: return "bubble.left.and.bubble.right.fill" case .refactor: return "arrow.triangle.2.circlepath" case .documentation: return "doc.text.fill" case .other: return "ellipsis.circle.fill" } } var descriptionTemplate: String { switch self { case .feature: return "Implement a new feature or functionality" case .bugFix: return "Fix a bug or resolve an issue" case .discussion: return "Discuss requirements, architecture, or approach" case .refactor: return "Refactor code to improve structure or performance" case .documentation: return "Write or update documentation" case .other: return "General task" } } } // MARK: - Task Status enum TaskStatus: String, Codable, CaseIterable, Identifiable, Sendable { case pending case inProgress = "in_progress" case completed case canceled case archived var id: String { rawValue } var displayName: String { switch self { case .pending: return "Pending" case .inProgress: return "In Progress" case .completed: return "Completed" case .canceled: return "Canceled" case .archived: return "Archived" } } var icon: String { switch self { case .pending: return "circle" case .inProgress: return "circle.dotted" case .completed: return "checkmark.circle.fill" case .canceled: return "xmark.circle.fill" case .archived: return "archivebox.fill" } } } enum ContextType: String, Codable, Sendable { case userMarked = "user_marked" case autoSuggested = "auto_suggested" } struct ContextItem: Identifiable, Hashable, Codable, Sendable { var id: UUID var content: String var sourceSessionId: String var sourceMessageId: String? var addedAt: Date var type: ContextType init( id: UUID = UUID(), content: String, sourceSessionId: String, sourceMessageId: String? = nil, addedAt: Date = Date(), type: ContextType = .userMarked ) { self.id = id self.content = content self.sourceSessionId = sourceSessionId self.sourceMessageId = sourceMessageId self.addedAt = addedAt self.type = type } } struct CodMateTask: Identifiable, Hashable, Codable, Sendable { var id: UUID var title: String var description: String? var taskType: TaskType var projectId: String var createdAt: Date var updatedAt: Date // Shared context var sharedContext: [ContextItem] var agentsConfig: String? // Reference to Agents.md sections var memoryItems: [String] // Memory item IDs // Contained sessions var sessionIds: [String] // Metadata var status: TaskStatus var tags: [String] // Primary provider for this task var primaryProvider: ProjectSessionSource? init( id: UUID = UUID(), title: String, description: String? = nil, taskType: TaskType = .other, projectId: String, createdAt: Date = Date(), updatedAt: Date = Date(), sharedContext: [ContextItem] = [], agentsConfig: String? = nil, memoryItems: [String] = [], sessionIds: [String] = [], status: TaskStatus = .pending, tags: [String] = [], primaryProvider: ProjectSessionSource? = nil ) { self.id = id self.title = title self.description = description self.taskType = taskType self.projectId = projectId self.createdAt = createdAt self.updatedAt = updatedAt self.sharedContext = sharedContext self.agentsConfig = agentsConfig self.memoryItems = memoryItems self.sessionIds = sessionIds self.status = status self.tags = tags self.primaryProvider = primaryProvider } var effectiveTitle: String { let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? "Untitled Task" : trimmed } var effectiveDescription: String? { guard let desc = description else { return nil } let trimmed = desc.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } func matches(search term: String) -> Bool { guard !term.isEmpty else { return true } let needle = term.lowercased() let haystack = [ title, description ?? "", tags.joined(separator: " "), agentsConfig ?? "" ].map { $0.lowercased() } return haystack.contains(where: { $0.contains(needle) }) } } // CodMateTask with enriched session summaries for display struct TaskWithSessions: Identifiable, Hashable { let task: CodMateTask let sessions: [SessionSummary] var id: UUID { task.id } var totalDuration: TimeInterval { sessions.reduce(0) { $0 + $1.duration } } var totalTokens: Int { sessions.reduce(0) { $0 + $1.turnContextCount } } var lastActivityDate: Date { let sessionDates = sessions.compactMap { $0.lastUpdatedAt ?? $0.startedAt } return sessionDates.max() ?? task.updatedAt } } ================================================ FILE: models/TerminalCursorStyleOption.swift ================================================ import Foundation enum TerminalCursorStyleOption: String, CaseIterable, Identifiable, Codable, Hashable { case blinkBlock case steadyBlock case blinkUnderline case steadyUnderline case blinkBar case steadyBar var id: String { rawValue } var title: String { switch self { case .blinkBlock: return "Blinking Block" case .steadyBlock: return "Steady Block" case .blinkUnderline: return "Blinking Underline" case .steadyUnderline: return "Steady Underline" case .blinkBar: return "Blinking Bar" case .steadyBar: return "Steady Bar" } } // Ghostty cursor configuration string var ghosttyConfigValue: String { switch self { case .blinkBlock: return "block" case .steadyBlock: return "block" case .blinkUnderline: return "underline" case .steadyUnderline: return "underline" case .blinkBar: return "bar" case .steadyBar: return "bar" } } var ghosttyBlinkEnabled: Bool { switch self { case .blinkBlock, .blinkUnderline, .blinkBar: return true case .steadyBlock, .steadyUnderline, .steadyBar: return false } } } ================================================ FILE: models/TimelineEvent.swift ================================================ import Foundation enum TimelineActor: Hashable { case user case assistant case tool case info } struct TimelineEvent: Identifiable, Hashable { let id: String let timestamp: Date let actor: TimelineActor let visibilityKind: MessageVisibilityKind let title: String? let text: String? let metadata: [String: String]? let attachments: [TimelineAttachment] let repeatCount: Int let callID: String? init( id: String, timestamp: Date, actor: TimelineActor, title: String?, text: String?, metadata: [String: String]?, repeatCount: Int = 1, attachments: [TimelineAttachment] = [], visibilityKind: MessageVisibilityKind? = nil, callID: String? = nil ) { self.id = id self.timestamp = timestamp self.actor = actor self.visibilityKind = visibilityKind ?? MessageVisibilityKind.infer(actor: actor, title: title, metadata: metadata) self.title = title self.text = text self.metadata = metadata self.attachments = attachments self.repeatCount = repeatCount self.callID = callID } func incrementingRepeatCount() -> TimelineEvent { TimelineEvent( id: id, timestamp: timestamp, actor: actor, title: title, text: text, metadata: metadata, repeatCount: repeatCount + 1, attachments: attachments, visibilityKind: visibilityKind, callID: callID ) } } extension TimelineEvent { static let environmentContextTitle = "Environment Context" } // MARK: - Message visibility kinds and helpers enum MessageVisibilityKind: String, CaseIterable, Identifiable { case user case assistant case tool case codeEdit case reasoning case tokenUsage case environmentContext case turnContext case infoOther var id: String { rawValue } var title: String { settingsLabel } var settingsLabel: String { switch self { case .user: return "User Message" case .assistant: return "Assistant Message" case .tool: return "Tool Invocation" case .codeEdit: return "Code Edit" case .reasoning: return "Reasoning" case .tokenUsage: return "Token Usage" case .environmentContext: return "Environment Context" case .turnContext: return "Turn Context" case .infoOther: return "Other Info" } } } extension MessageVisibilityKind { static let timelineDefault: Set = [ .user, .assistant, .codeEdit, .reasoning // environment context is shown in its dedicated section by default ] static let markdownDefault: Set = [ .user, .assistant ] static func coerced(from raw: String) -> MessageVisibilityKind? { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if let exact = MessageVisibilityKind(rawValue: trimmed) { return exact } switch trimmed { case "syncing": return .turnContext case "environment": return .environmentContext case "code_edit": return .codeEdit case "codeedit": return .codeEdit default: return nil } } static func infer( actor: TimelineActor, title: String?, metadata: [String: String]? ) -> MessageVisibilityKind { if let mapped = mappedKind(rawType: nil, title: title, metadata: metadata) { return mapped } switch actor { case .user: return .user case .assistant: return .assistant case .tool: return .tool case .info: return .infoOther } } static func mappedKind( rawType: String?, title: String?, metadata: [String: String]? ) -> MessageVisibilityKind? { if let kind = kindFromToken(rawType) { return kind } if let kind = kindFromToken(metadata?["event_kind"]) { return kind } if let kind = kindFromToken(title) { return kind } return nil } static func kindFromToken(_ value: String?) -> MessageVisibilityKind? { let normalized = normalize(value) guard !normalized.isEmpty else { return nil } func matchesExact(_ tokens: [String]) -> Bool { tokens.contains(normalized) } func matchesContains(_ tokens: [String]) -> Bool { tokens.contains(where: { normalized.contains($0) }) } if matchesExact(["user", "user message", "user msg"]) { return .user } if matchesExact(["assistant", "assistant message", "agent message"]) { return .assistant } if matchesContains(["tool call", "tool output", "tool result", "function call", "tool"]) { return .tool } if matchesContains(["code edit", "file edit", "apply patch", "applypatch", "codeedit", "patch"]) { return .codeEdit } if matchesContains(["token usage", "token count", "token"]) { return .tokenUsage } if matchesContains(["agent reasoning", "reasoning", "thinking", "thought"]) { return .reasoning } if matchesContains(["environment context"]) { return .environmentContext } if matchesExact(["context updated"]) || matchesContains(["turn context"]) { return .turnContext } if matchesContains(["collaboration mode", "permissions instructions", "permissions instruction"]) { return .infoOther } if matchesExact(["info", "warning", "error", "info other", "info_other"]) || matchesContains(["system message", "system summary"]) { return .infoOther } return nil } private static func normalize(_ value: String?) -> String { guard let raw = value else { return "" } var normalized = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if normalized.isEmpty { return "" } normalized = normalized .replacingOccurrences(of: "_", with: " ") .replacingOccurrences(of: "-", with: " ") while normalized.contains(" ") { normalized = normalized.replacingOccurrences(of: " ", with: " ") } return normalized } } extension MessageVisibilityKind { var defaultActor: TimelineActor { switch self { case .user: return .user case .assistant: return .assistant case .tool, .codeEdit: return .tool case .reasoning, .tokenUsage, .environmentContext, .turnContext, .infoOther: return .info } } } extension Set where Element == MessageVisibilityKind { func contains(event: TimelineEvent) -> Bool { contains(event.visibilityKind) } var rawValues: [String] { map { $0.rawValue }.sorted() } static func fromRawValues(_ rawValues: [String]?) -> Set? { guard let rawValues else { return nil } return Set(rawValues.compactMap { MessageVisibilityKind.coerced(from: $0) }) } } struct TimelineAttachment: Hashable, Sendable { enum Kind: String, Hashable, Sendable { case image } let id: String let kind: Kind let label: String? let dataURL: String? let url: URL? init( kind: Kind, label: String? = nil, dataURL: String? = nil, url: URL? = nil, id: String = UUID().uuidString ) { self.id = id self.kind = kind self.label = label self.dataURL = dataURL self.url = url } static func == (lhs: TimelineAttachment, rhs: TimelineAttachment) -> Bool { lhs.id == rhs.id } func hash(into hasher: inout Hasher) { hasher.combine(id) } } ================================================ FILE: models/UnifiedProviderCatalog.swift ================================================ import Foundation import SwiftUI struct UnifiedProviderChoice: Identifiable, Hashable { enum Kind: String { case oauth, apiKey } let id: String let title: String let kind: Kind let isAvailable: Bool let availabilityHint: String? } struct UnifiedProviderSection: Identifiable, Hashable { let id: String let title: String let providers: [UnifiedProviderChoice] } @MainActor final class UnifiedProviderCatalogModel: ObservableObject { @Published private(set) var sections: [UnifiedProviderSection] = [] @Published private(set) var modelsByProviderId: [String: [String]] = [:] @Published private(set) var availabilityByProviderId: [String: String] = [:] @Published private(set) var kindByProviderId: [String: UnifiedProviderChoice.Kind] = [:] private var registryProviders: [ProvidersRegistryService.Provider] = [] // Model ID to provider mapping (for reliable provider inference in autoProxy mode) private var modelToProviderMap: [String: String] = [:] // Rerouted models by label (for provider inference fallback) private var reroutedModelsByLabel: [String: [String]] = [:] func reload(preferences: SessionPreferencesStore, forceRefresh: Bool = false) async { let taskToken = AppLogger.shared.beginTask("Reloading provider catalog", source: "ProviderCatalog") let registry = ProvidersRegistryService() let providers = await registry.listProviders() registryProviders = providers AppLogger.shared.info("Loaded \(providers.count) providers from registry", source: "ProviderCatalog") // All providers now use Auto-Proxy mode through CLIProxyAPI // No separate rerouteBuiltIn/reroute3P switches - providers are enabled/disabled via the Providers list // OAuth is enabled at account level (oauthAccountsEnabled), not provider level let oauthAccountsEnabledSet = preferences.oauthAccountsEnabled let apiKeyEnabledSet = preferences.apiKeyProvidersEnabled let proxyRunning = CLIProxyService.shared.isRunning AppLogger.shared.info("Proxy running=\(proxyRunning), OAuth accounts enabled=\(oauthAccountsEnabledSet.count), API key enabled=\(apiKeyEnabledSet.count)", source: "ProviderCatalog") var localModels: [CLIProxyService.LocalModel] = [] // Always fetch models if proxy is running, even if reroute is not enabled // This allows auto-proxy mode to show available models for selection if proxyRunning { localModels = await CLIProxyService.shared.fetchLocalModels(forceRefresh: forceRefresh) AppLogger.shared.info("Fetched \(localModels.count) models from CLIProxyAPI", source: "ProviderCatalog") } else { AppLogger.shared.warning("Skipping local model fetch: CLIProxy not running", source: "ProviderCatalog") } let mapped = mapLocalModels(localModels) var nextSections: [UnifiedProviderSection] = [] var nextModels: [String: [String]] = [:] var availability: [String: String] = [:] var kinds: [String: UnifiedProviderChoice.Kind] = [:] // OAuth section - support multiple accounts per provider let oauthProviders = LocalAuthProvider.allCases.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } var oauthChoices: [UnifiedProviderChoice] = [] let oauthAccounts = CLIProxyService.shared.listOAuthAccounts() for provider in oauthProviders { let providerAccounts = oauthAccounts.filter { $0.provider == provider } if providerAccounts.isEmpty { // No accounts - show provider as unavailable let id = UnifiedProviderID.oauth(provider, accountId: nil) let hint = availabilityHintForOAuth( proxyRunning: proxyRunning, oauthEnabled: false, authAvailable: false, providerName: provider.displayName ) let choice = UnifiedProviderChoice( id: id, title: provider.displayName, kind: .oauth, isAvailable: false, availabilityHint: hint ) oauthChoices.append(choice) kinds[id] = .oauth nextModels[id] = [] if let hint { availability[id] = hint } } else { // Multiple accounts - create one choice per account for account in providerAccounts.sorted(by: { ($0.email ?? "") < ($1.email ?? "") }) { let id = UnifiedProviderID.oauth(provider, accountId: account.id) // Account is available if proxy is running and account is enabled let accountEnabled = oauthAccountsEnabledSet.contains(account.id) let available = proxyRunning && accountEnabled let hint = availabilityHintForOAuth( proxyRunning: proxyRunning, oauthEnabled: accountEnabled, authAvailable: true, providerName: provider.displayName ) let accountLabel = account.email ?? account.id let title = "\(provider.displayName) (\(accountLabel))" let choice = UnifiedProviderChoice( id: id, title: title, kind: .oauth, isAvailable: available, availabilityHint: available ? nil : hint ) oauthChoices.append(choice) kinds[id] = .oauth if available && accountEnabled { // Use provider-level models (all accounts of same provider share models) // Only include models if account is enabled let providerBaseId = UnifiedProviderID.oauth(provider, accountId: nil) let models = sortModels(mapped.builtIn[providerBaseId] ?? []) nextModels[id] = models // Also store at provider level for backward compatibility if nextModels[providerBaseId] == nil { nextModels[providerBaseId] = models } } else { nextModels[id] = [] if let hint { availability[id] = hint } } } } } // Sort all OAuth choices by title (provider name + account) oauthChoices.sort { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } if !oauthChoices.isEmpty { nextSections.append( UnifiedProviderSection(id: "oauth", title: "OAuth Providers", providers: oauthChoices) ) } // API Key section let apiChoices: [UnifiedProviderChoice] = providers .sorted { UnifiedProviderID.providerDisplayName($0).localizedCaseInsensitiveCompare( UnifiedProviderID.providerDisplayName($1)) == .orderedAscending } .map { provider in let id = UnifiedProviderID.api(provider.id) let isEnabled = apiKeyEnabledSet.contains(provider.id) // Provider is available if proxy is running (all providers use Auto-Proxy mode) let available = proxyRunning let hint = availabilityHintForAPIKey(proxyRunning: proxyRunning) kinds[id] = .apiKey if proxyRunning && isEnabled { // Try multiple label variations to match models from CLIProxyAPI // CLIProxyAPI uses provider.name ?? provider.id as the name in config.yaml let providerName = provider.name ?? provider.id let displayName = UnifiedProviderID.providerDisplayName(provider) // Try normalized display name first var models: [String] = [] let normalizedDisplayName = normalizeLabel(displayName) if let found = mapped.rerouted[normalizedDisplayName] { models = found } else { // Try normalized provider name (as used in syncThirdPartyProviders) let normalizedName = normalizeLabel(providerName) if let found = mapped.rerouted[normalizedName] { models = found } else { // Try normalized provider ID let normalizedId = normalizeLabel(provider.id) if let found = mapped.rerouted[normalizedId] { models = found } else { // Try all rerouted keys to find a match (fuzzy matching) for (key, modelList) in mapped.rerouted { if key.contains(normalizedName) || normalizedName.contains(key) || key.contains(normalizedDisplayName) || normalizedDisplayName.contains(key) { models.append(contentsOf: modelList) } } } } } nextModels[id] = sortModels(Array(Set(models))) } else { // Provider is enabled but CLIProxyAPI not running or no models returned // Do not fallback to catalog - only show models actually available via CLIProxyAPI nextModels[id] = [] } if !available, let hint { availability[id] = hint } return UnifiedProviderChoice( id: id, title: UnifiedProviderID.providerDisplayName(provider), kind: .apiKey, isAvailable: available, availabilityHint: available ? nil : hint ) } if !apiChoices.isEmpty { nextSections.append( UnifiedProviderSection(id: "api", title: "API Key Providers", providers: apiChoices) ) } // Add auto-proxy models: all models from CLI Proxy API // For auto-proxy mode, show all available models regardless of reroute/enabled settings // This allows users to see and select models even if they haven't configured reroute yet if proxyRunning { var allProxyModels = Set() // Collect all OAuth provider models (only from enabled accounts) // Check if any account of this provider is enabled for provider in LocalAuthProvider.allCases { let providerBaseId = UnifiedProviderID.oauth(provider, accountId: nil) if let models = mapped.builtIn[providerBaseId], !models.isEmpty { // Check if any account of this provider is enabled let providerAccounts = oauthAccounts.filter { $0.provider == provider } let hasEnabledAccount = providerAccounts.contains { oauthAccountsEnabledSet.contains($0.id) } if hasEnabledAccount { allProxyModels.formUnion(models) } } } // Collect all API key provider models (only from enabled providers) AppLogger.shared.info("API key enabled providers: \(Array(apiKeyEnabledSet))", source: "ProviderCatalog") AppLogger.shared.info("Rerouted model labels: \(Array(mapped.rerouted.keys))", source: "ProviderCatalog") for provider in providers { let isEnabled = apiKeyEnabledSet.contains(provider.id) let providerName = provider.name ?? provider.id let displayName = UnifiedProviderID.providerDisplayName(provider) // Try multiple label variations let normalizedDisplayName = normalizeLabel(displayName) let normalizedName = normalizeLabel(providerName) let normalizedId = normalizeLabel(provider.id) // Debug: log all attempts AppLogger.shared.info("Provider \(provider.id): trying to match - displayName='\(normalizedDisplayName)', name='\(normalizedName)', id='\(normalizedId)'", source: "ProviderCatalog") var foundModels: [String] = [] if let models = mapped.rerouted[normalizedDisplayName] { foundModels = models AppLogger.shared.info("Provider \(provider.id): matched by displayName '\(normalizedDisplayName)' -> \(models.count) models", source: "ProviderCatalog") } else if let models = mapped.rerouted[normalizedName] { foundModels = models AppLogger.shared.info("Provider \(provider.id): matched by name '\(normalizedName)' -> \(models.count) models", source: "ProviderCatalog") } else if let models = mapped.rerouted[normalizedId] { foundModels = models AppLogger.shared.info("Provider \(provider.id): matched by id '\(normalizedId)' -> \(models.count) models", source: "ProviderCatalog") } else { // Try fuzzy matching for (key, modelList) in mapped.rerouted { if key.contains(normalizedName) || normalizedName.contains(key) || key.contains(normalizedDisplayName) || normalizedDisplayName.contains(key) { foundModels.append(contentsOf: modelList) } } if !foundModels.isEmpty { AppLogger.shared.info("Provider \(provider.id): matched by fuzzy -> \(foundModels.count) models", source: "ProviderCatalog") } } if !foundModels.isEmpty { if isEnabled { allProxyModels.formUnion(foundModels) AppLogger.shared.info("Provider \(provider.id): ADDED \(foundModels.count) models to auto-proxy (enabled)", source: "ProviderCatalog") } else { AppLogger.shared.info("Provider \(provider.id): SKIPPED \(foundModels.count) models (disabled)", source: "ProviderCatalog") } } else { AppLogger.shared.warning("Provider \(provider.id) (\(providerName)): NO models matched from rerouted labels", source: "ProviderCatalog") } } // Only include models from enabled providers - do not include models from disabled providers // This prevents showing models from providers that users have explicitly disabled let sortedModels = sortModels(Array(allProxyModels)) AppLogger.shared.info("Auto-proxy: \(sortedModels.count) models (from enabled providers only)", source: "ProviderCatalog") nextModels[UnifiedProviderID.autoProxyId] = sortedModels } else { AppLogger.shared.warning("Auto-proxy models empty: CLIProxy not running", source: "ProviderCatalog") nextModels[UnifiedProviderID.autoProxyId] = [] } sections = nextSections modelsByProviderId = nextModels availabilityByProviderId = availability kindByProviderId = kinds let autoProxyCount = nextModels[UnifiedProviderID.autoProxyId]?.count ?? 0 AppLogger.shared.endTask(taskToken, message: "Catalog reloaded: \(nextSections.count) sections, auto-proxy=\(autoProxyCount) models", source: "ProviderCatalog") } func normalizeProviderId(_ raw: String?) -> String? { UnifiedProviderID.normalize(raw, registryProviders: registryProviders) } func models(for providerId: String?) -> [String] { guard let providerId else { return [] } // For OAuth accounts, models are shared at provider level let parsed = UnifiedProviderID.parse(providerId) if case .oauth(let provider, _) = parsed { let providerBaseId = UnifiedProviderID.oauth(provider, accountId: nil) return modelsByProviderId[providerBaseId] ?? modelsByProviderId[providerId] ?? [] } return modelsByProviderId[providerId] ?? [] } /// Returns sanitized models with both display names and original IDs func sanitizedModels(for providerId: String?) -> [ModelNameSanitizer.SanitizedModel] { let rawModels = models(for: providerId) return ModelNameSanitizer.sanitize(rawModels) } /// Returns the display name for a single model (sanitized) func displayName(for model: String) -> String { return ModelNameSanitizer.sanitizeSingle(model) } /// Resolves a display name back to the original model ID for a given provider func resolveModelId(displayName: String, providerId: String?) -> String? { let sanitized = sanitizedModels(for: providerId) return sanitized.first { $0.displayName == displayName }?.originalId } func isProviderAvailable(_ providerId: String?) -> Bool { guard let providerId else { return true } return availabilityByProviderId[providerId] == nil } func availabilityHint(for providerId: String?) -> String? { guard let providerId else { return nil } return availabilityByProviderId[providerId] } func sectionTitle(for providerId: String?) -> String? { guard let providerId, let kind = kindByProviderId[providerId] else { return nil } switch kind { case .oauth: return "OAuth Providers" case .apiKey: return "API Key Providers" } } /// Get provider title from provider ID func providerTitle(for providerId: String?) -> String? { guard let providerId else { return nil } // Search through sections to find the provider for section in sections { if let provider = section.providers.first(where: { $0.id == providerId }) { return provider.title } } // Fallback: parse provider ID and generate title let parsed = UnifiedProviderID.parse(providerId) switch parsed { case .oauth(let authProvider, let accountId): if let accountId = accountId, !accountId.isEmpty { return "\(authProvider.displayName) (\(accountId))" } return authProvider.displayName case .api(let apiId): // Try to find in registry if let provider = registryProviders.first(where: { $0.id == apiId }) { return UnifiedProviderID.providerDisplayName(provider) } return apiId case .autoProxy: return "Auto-Proxy (CliProxyAPI)" default: return nil } } /// Infer provider from model ID (useful when providerId is autoProxy) /// Returns the service provider display name (e.g., "AICodeWith", "OpenRouter") or OAuth provider name /// /// This method ONLY uses metadata (owned_by) - the single source of truth. /// No fallback, no pattern matching. Only recognize service providers, not manufacturers. func inferProviderFromModel(_ modelId: String) -> String? { // Only use metadata mapping built from LocalModel.owned_by if let providerId = modelToProviderMap[modelId] { let title = providerTitle(for: providerId) AppLogger.shared.info("[inferProvider] '\(modelId)' → metadata: providerId=\(providerId), title=\(title ?? "nil")", source: "ProviderCatalog") return title } // No fallback - if model is not in metadata map, return nil AppLogger.shared.warning("[inferProvider] '\(modelId)' → No metadata found, returning nil", source: "ProviderCatalog") return nil } // MARK: - Local model mapping private struct LocalModelMap { var builtIn: [String: [String]] var rerouted: [String: [String]] } private func mapLocalModels(_ models: [CLIProxyService.LocalModel]) -> LocalModelMap { var builtIn: [String: [String]] = [:] var rerouted: [String: [String]] = [:] var modelToProvider: [String: String] = [:] for provider in LocalAuthProvider.allCases { builtIn[UnifiedProviderID.oauth(provider)] = [] } AppLogger.shared.info("Mapping \(models.count) models from CLIProxyAPI", source: "ProviderCatalog") var skippedModels: [(id: String, reason: String)] = [] for model in models { // Debug: Log all OAuth models metadata for comparison if model.provider != nil || model.source != nil || model.owned_by != nil { let providerVal = model.provider ?? "nil" let sourceVal = model.source ?? "nil" let ownedByVal = model.owned_by ?? "nil" AppLogger.shared.info("[DEBUG] Model '\(model.id)' raw metadata: provider='\(providerVal)', source='\(sourceVal)', owned_by='\(ownedByVal)'", source: "ProviderCatalog") } if let builtin = builtInProvider(for: model), let auth = UnifiedProviderID.authProvider(for: builtin) { let id = UnifiedProviderID.oauth(auth) var list = builtIn[id] ?? [] if !list.contains(model.id) { list.append(model.id) } builtIn[id] = list // Map model to provider for reliable inference modelToProvider[model.id] = id AppLogger.shared.info("Model '\(model.id)' → builtIn(\(builtin.rawValue)) via metadata=[\(model.provider ?? "nil"), \(model.source ?? "nil"), \(model.owned_by ?? "nil")]", source: "ProviderCatalog") continue } guard let label = rerouteProviderLabel(for: model) else { skippedModels.append((id: model.id, reason: "No label (provider=\(model.provider ?? "nil"), source=\(model.source ?? "nil"), owned_by=\(model.owned_by ?? "nil"))")) continue } // Debug: log rerouted model details let normalizedLabel = normalizeLabel(label) AppLogger.shared.info("Model '\(model.id)' → rerouted['\(label)'] (normalized: '\(normalizedLabel)') via metadata=[\(model.provider ?? "nil"), \(model.source ?? "nil"), \(model.owned_by ?? "nil")]", source: "ProviderCatalog") let key = normalizedLabel var list = rerouted[key] ?? [] if !list.contains(model.id) { list.append(model.id) } rerouted[key] = list // For rerouted models, try to find the API provider ID // Try multiple matching strategies to handle different label formats var matchedProviderId: String? = nil if let apiProvider = findAPIProviderByLabel(label) { matchedProviderId = UnifiedProviderID.api(apiProvider.id) } else { // Try matching by provider name or ID directly for provider in registryProviders { let providerName = provider.name ?? provider.id let normalizedProviderName = normalizeLabel(providerName) let normalizedProviderId = normalizeLabel(provider.id) if key == normalizedProviderName || key == normalizedProviderId { matchedProviderId = UnifiedProviderID.api(provider.id) break } } } // Store the mapping (use virtual ID if no match found) if let matchedId = matchedProviderId { modelToProvider[model.id] = matchedId } else { // If provider not found in registry, create a virtual provider ID using the label // This handles cases where users configure openai-compatibility providers directly in CLIProxyAPI let virtualProviderId = UnifiedProviderID.api(label) modelToProvider[model.id] = virtualProviderId } } // Store the mapping for later use modelToProviderMap = modelToProvider // Store rerouted models by label for fallback inference reroutedModelsByLabel = rerouted // Log skipped models for debugging if !skippedModels.isEmpty { AppLogger.shared.warning("Skipped \(skippedModels.count) models without provider labels:", source: "ProviderCatalog") for (id, reason) in skippedModels.prefix(5) { AppLogger.shared.warning(" - '\(id)': \(reason)", source: "ProviderCatalog") } if skippedModels.count > 5 { AppLogger.shared.warning(" ... and \(skippedModels.count - 5) more", source: "ProviderCatalog") } } return LocalModelMap(builtIn: builtIn, rerouted: rerouted) } private func findAPIProviderByLabel(_ label: String) -> ProvidersRegistryService.Provider? { let normalized = normalizeLabel(label) // First, try to find in registry providers if let provider = registryProviders.first(where: { provider in let displayName = UnifiedProviderID.providerDisplayName(provider) return normalizeLabel(displayName) == normalized || normalizeLabel(provider.id) == normalized }) { return provider } // If not found, return nil (caller will create a virtual provider) return nil } private func builtInProvider(for model: CLIProxyService.LocalModel) -> LocalServerBuiltInProvider? { let hint = model.provider ?? model.source ?? model.owned_by if let hint, let provider = LocalServerBuiltInProvider.allCases.first(where: { $0.matchesOwnedBy(hint) }) { return provider } // If owned_by is present but doesn't match any built-in provider, // this is a third-party provider - don't do pattern matching on model ID if let ownedBy = model.owned_by?.trimmingCharacters(in: .whitespacesAndNewlines), !ownedBy.isEmpty, !LocalServerBuiltInProvider.allCases.contains(where: { $0.matchesOwnedBy(ownedBy) }) { return nil } // Only do pattern matching if owned_by is nil or matched a built-in provider let modelId = model.id if let provider = LocalServerBuiltInProvider.allCases.first(where: { $0.matchesModelId(modelId) }) { return provider } return nil } private func rerouteProviderLabel(for model: CLIProxyService.LocalModel) -> String? { // Priority 1: Use cached provider name from config.yaml (most reliable) // This directly reads from our own config.yaml, no guessing needed if let cachedName = CLIProxyService.shared.getProviderName(for: model.id) { return cachedName } // Priority 2: Fall back to metadata fields (provider > source > owned_by) // CLIProxyAPI returns models with source field containing the provider name from config.yaml let hint = model.provider ?? model.source ?? model.owned_by let trimmed = hint?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return trimmed.isEmpty ? nil : trimmed } private func normalizeLabel(_ value: String) -> String { value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } private func sortModels(_ list: [String]) -> [String] { list.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } } private func availabilityHintForOAuth( proxyRunning: Bool, oauthEnabled: Bool, authAvailable: Bool, providerName: String ) -> String? { if !proxyRunning { return "CLI Proxy API isn't running. Start it in Providers → CLI Proxy API." } if !oauthEnabled { return "Enable this provider in Providers to use this option." } if !authAvailable { return "Sign in to \(providerName) in Providers to use this option." } return nil } private func availabilityHintForAPIKey(proxyRunning: Bool) -> String? { if !proxyRunning { return "CLI Proxy API isn't running. Start it in Providers → CLI Proxy API." } return nil } } ================================================ FILE: models/UnifiedProviderID.swift ================================================ import Foundation enum UnifiedProviderID { static let oauthPrefix = "oauth:" static let apiPrefix = "api:" static let legacyReroutePrefix = "local-reroute:" /// Special provider ID for "Auto (CLI Proxy API)" mode in simple picker static let autoProxyId = "__auto_cli_proxy__" enum Parsed: Equatable { case oauth(LocalAuthProvider, accountId: String?) case api(String) case legacyBuiltin(LocalServerBuiltInProvider) case legacyReroute(String) case autoProxy case unknown(String) } static func oauth(_ provider: LocalAuthProvider, accountId: String? = nil) -> String { if let accountId = accountId, !accountId.isEmpty { return "\(oauthPrefix)\(provider.rawValue):\(accountId)" } return "\(oauthPrefix)\(provider.rawValue)" } static func api(_ id: String) -> String { "\(apiPrefix)\(id)" } static func parse(_ raw: String) -> Parsed { // Check for special auto proxy ID first if raw == autoProxyId { return .autoProxy } if raw.hasPrefix(oauthPrefix) { let value = String(raw.dropFirst(oauthPrefix.count)) // Check if it contains account ID (format: provider:accountId) if let colonIndex = value.firstIndex(of: ":") { let providerValue = String(value[.. String? { guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } switch parse(raw) { case .autoProxy: return autoProxyId case .oauth: return raw case .api: return raw case .legacyBuiltin(let builtin): if let auth = authProvider(for: builtin) { return oauth(auth, accountId: nil) } return nil case .legacyReroute(let label): if let resolved = resolveAPIProviderId( byLabel: label, registryProviders: registryProviders ) { return api(resolved) } return nil case .unknown(let value): if let match = registryProviders.first(where: { $0.id == value }) { return api(match.id) } if let match = registryProviders.first(where: { providerDisplayName($0).localizedCaseInsensitiveCompare(value) == .orderedSame }) { return api(match.id) } return nil } } static func authProvider(for builtin: LocalServerBuiltInProvider) -> LocalAuthProvider? { switch builtin { case .openai: return .codex case .anthropic: return .claude case .gemini: return .gemini case .antigravity: return .antigravity case .qwen: return .qwen } } static func resolveAPIProviderId( byLabel label: String, registryProviders: [ProvidersRegistryService.Provider] ) -> String? { let normalized = label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard !normalized.isEmpty else { return nil } if let match = registryProviders.first(where: { providerDisplayName($0).lowercased() == normalized }) { return match.id } if let match = registryProviders.first(where: { $0.id.lowercased() == normalized }) { return match.id } return nil } static func providerDisplayName(_ provider: ProvidersRegistryService.Provider) -> String { let name = provider.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return name.isEmpty ? provider.id : name } } ================================================ FILE: models/UpdateViewModel.swift ================================================ import AppKit import Foundation @MainActor final class UpdateViewModel: ObservableObject { @Published private(set) var state: UpdateService.UpdateState = .idle @Published private(set) var isDownloading = false @Published var showInstallInstructions = false @Published var lastError: String? @Published private(set) var lastCheckedAt: Date? let installInstructions = "Download completed. Open the DMG and drag CodMate into Applications." private let service: UpdateService private var checkTask: Task? private var downloadTask: Task? init(service: UpdateService = .shared) { self.service = service } func loadCached() { checkTask?.cancel() checkTask = Task { [weak self] in guard let self else { return } if let cached = await service.cachedInfo() { state = serviceAvailability(for: cached) } lastCheckedAt = await service.lastCheckedAt() } } func checkIfNeeded(trigger: UpdateService.CheckTrigger) { checkTask?.cancel() checkTask = Task { [weak self] in guard let self else { return } state = .checking let result = await service.checkIfNeeded(trigger: trigger) state = result lastCheckedAt = await service.lastCheckedAt() } } func checkNow() { checkTask?.cancel() state = .checking checkTask = Task { [weak self] in guard let self else { return } let result = await service.checkNow() state = result lastCheckedAt = await service.lastCheckedAt() } } func downloadIfNeeded() { guard case .updateAvailable(let info) = state else { return } downloadTask?.cancel() isDownloading = true lastError = nil downloadTask = Task { [weak self] in guard let self else { return } do { let targetURL = try await downloadAsset(info: info) NSWorkspace.shared.open(targetURL) showInstallInstructions = true } catch { lastError = error.localizedDescription } isDownloading = false } } private func serviceAvailability(for info: UpdateService.UpdateInfo) -> UpdateService.UpdateState { let current = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0" if let currentVersion = Version(current), let latestVersion = Version(info.latestVersion), latestVersion <= currentVersion { return .upToDate(current: current, latest: info.latestVersion) } return .updateAvailable(info) } private func downloadAsset(info: UpdateService.UpdateInfo) async throws -> URL { let (tempURL, _) = try await URLSession.shared.download(from: info.assetURL) let baseName = info.assetName let targetDir: URL if AppSandbox.isEnabled { targetDir = FileManager.default.temporaryDirectory } else { let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first targetDir = downloads ?? FileManager.default.temporaryDirectory } var targetURL = targetDir.appendingPathComponent(baseName) if FileManager.default.fileExists(atPath: targetURL.path) { let stamp = ISO8601DateFormatter().string(from: Date()).replacingOccurrences(of: ":", with: "-") targetURL = targetDir.appendingPathComponent("\(stamp)-\(baseName)") } try FileManager.default.moveItem(at: tempURL, to: targetURL) return targetURL } } ================================================ FILE: models/UsageProviderSnapshot.swift ================================================ import Foundation enum UsageProviderKind: String, CaseIterable, Identifiable { case codex case claude case gemini var id: String { rawValue } var displayName: String { switch self { case .codex: return "Codex" case .claude: return "Claude" case .gemini: return "Gemini" } } var accentColorName: String { switch self { case .codex: return "accentColor" case .claude: return "purple" case .gemini: return "teal" } } var baseKind: SessionSource.Kind { switch self { case .codex: return .codex case .claude: return .claude case .gemini: return .gemini } } } public struct UsageMetricSnapshot: Identifiable, Equatable { public enum Kind { case context, fiveHour, weekly, sessionExpiry, quota, snapshot } public enum HealthState { case healthy // Usage is slower than time progress (blue) case warning // Usage is faster than time progress (orange) case unknown // Cannot determine (no time cycle or insufficient data) } public let id = UUID() public let kind: Kind public let label: String public let usageText: String? public let percentText: String? public let progress: Double? public let resetDate: Date? public let fallbackWindowMinutes: Int? fileprivate var priorityDate: Date? { resetDate } /// Calculate health state by comparing usage progress vs time progress public func healthState(relativeTo now: Date = Date()) -> HealthState { // Only applicable to time-based metrics guard kind == .fiveHour || kind == .weekly else { return .unknown // context, snapshot, etc. have no time cycle } // Need complete data to calculate guard let remainingPercent = progress, let resetDate = resetDate, let windowMinutes = fallbackWindowMinutes, resetDate > now else { return .unknown } // Calculate total cycle duration in seconds let totalDuration = Double(windowMinutes) * 60.0 // Infer cycle start time by subtracting total duration from reset time let cycleStart = resetDate.addingTimeInterval(-totalDuration) // Sanity check: cycle should have already started guard cycleStart <= now else { return .unknown // Anomaly: cycle starts in the future } // Calculate time progress (how much of the cycle has elapsed) let elapsed = now.timeIntervalSince(cycleStart) let timeProgress = elapsed / totalDuration // 0..1 // Calculate usage progress (how much quota has been consumed) let usageProgress = 1.0 - remainingPercent // 0..1 // Compare: if usage is slower than time → healthy // if usage is faster than time → warning return usageProgress < timeProgress ? .healthy : .warning } } enum UsageProviderOrigin: String, Equatable { case builtin case thirdParty } struct UsageProviderSnapshot: Identifiable, Equatable { enum Availability { case ready, empty, comingSoon } enum Action: Hashable { case refresh case authorizeKeychain } let id = UUID() let provider: UsageProviderKind let title: String /// Optional short badge shown as a superscript next to the provider title (e.g., "Pro", "Plus"). let titleBadge: String? let availability: Availability let metrics: [UsageMetricSnapshot] let updatedAt: Date? let statusMessage: String? let requiresReauth: Bool // True when user needs to re-authenticate let origin: UsageProviderOrigin let action: Action? init( provider: UsageProviderKind, title: String, titleBadge: String? = nil, availability: Availability, metrics: [UsageMetricSnapshot], updatedAt: Date?, statusMessage: String? = nil, requiresReauth: Bool = false, origin: UsageProviderOrigin = .builtin, action: Action? = nil ) { self.provider = provider self.title = title self.titleBadge = titleBadge self.availability = availability self.metrics = metrics self.updatedAt = updatedAt self.statusMessage = statusMessage self.requiresReauth = requiresReauth self.origin = origin self.action = action } func urgentMetric(relativeTo now: Date = Date()) -> UsageMetricSnapshot? { let candidates = metrics.filter { $0.kind != .snapshot && $0.kind != .context } guard !candidates.isEmpty else { return nil } // Step 1: If any limit is depleted (≤0.1%), prioritize the one with longest reset time // This ensures we show the most restrictive bottleneck let depleted = candidates.filter { ($0.progress ?? 1) <= 0.001 } if !depleted.isEmpty { return depleted.max(by: { a, b in let aReset = a.resetDate?.timeIntervalSince(now) ?? 0 let bReset = b.resetDate?.timeIntervalSince(now) ?? 0 return aReset < bReset }) } // Step 2: Filter out metrics that reset very soon (<5 minutes) // They're not representative of the stable state let significant = candidates.filter { metric in guard let reset = metric.resetDate else { return true } let remaining = reset.timeIntervalSince(now) return remaining > 5 * 60 || remaining <= 0 } // Step 3: Calculate urgency score and return the most urgent // Urgency = (consumption %) × log(1 + reset hours) // Higher score = more urgent = should be displayed return significant.max(by: { a, b in urgencyScore(for: a, relativeTo: now) < urgencyScore(for: b, relativeTo: now) }) } private func urgencyScore(for metric: UsageMetricSnapshot, relativeTo now: Date) -> Double { // Calculate consumption (0..1, where 1 = fully consumed) let consumed = 1.0 - (metric.progress ?? 0) // Calculate reset time in minutes let resetMinutes: Double if let reset = metric.resetDate { resetMinutes = max(0, reset.timeIntervalSince(now) / 60) } else if let fallback = metric.fallbackWindowMinutes { resetMinutes = Double(fallback) } else { resetMinutes = 0 } // Urgency score = consumption × log(1 + reset hours) // The log ensures diminishing importance for longer times // e.g., 10min→1h matters more than 1day→2days let resetHours = resetMinutes / 60.0 return consumed * log(1.0 + resetHours) } static func placeholder( _ provider: UsageProviderKind, message: String, action: Action? = .refresh ) -> UsageProviderSnapshot { UsageProviderSnapshot( provider: provider, title: provider.displayName, titleBadge: nil, availability: .comingSoon, metrics: [], updatedAt: nil, statusMessage: message, origin: .builtin, action: action ) } } ================================================ FILE: models/WarpTitleBuilder.swift ================================================ import Foundation enum WarpTitleBuilder { private static let dashCharacterSet = CharacterSet(charactersIn: "-") private static let timestampFormatter: DateFormatter = { let fmt = DateFormatter() fmt.dateFormat = "yyyyMMddHHmm" fmt.locale = Locale(identifier: "en_US_POSIX") fmt.timeZone = TimeZone.current return fmt }() static func token(from raw: String?) -> String? { guard let raw, !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } var resultScalars: [Character] = [] var lastWasDash = false for scalar in raw.lowercased().unicodeScalars { if CharacterSet.alphanumerics.contains(scalar) { resultScalars.append(Character(scalar)) lastWasDash = false } else if scalar == "-" || scalar == "_" { resultScalars.append("-") lastWasDash = true } else if !lastWasDash { resultScalars.append("-") lastWasDash = true } } let result = String(resultScalars).trimmingCharacters(in: dashCharacterSet) return result.isEmpty ? nil : result } static func timestampString(_ date: Date = Date()) -> String { timestampFormatter.string(from: date) } static func newSessionLabel( scope: String?, task: String?, extras: [String] = [], date: Date = Date() ) -> String { var tokens: [String] = [timestampString(date)] if let scopeToken = token(from: scope) { tokens.append(scopeToken) } if let taskToken = token(from: task) { tokens.append(taskToken) } for raw in extras { if let tokenized = token(from: raw) { tokens.append(tokenized) } } return tokens.joined(separator: "-") } } ================================================ FILE: models/WizardConversationViewModel.swift ================================================ import Foundation @MainActor final class WizardConversationViewModel: ObservableObject { @Published var messages: [WizardMessage] = [] @Published var inputText: String = "" @Published var isRunning: Bool = false @Published var runEvents: [WizardRunEvent] = [] @Published var draft: Draft? = nil @Published var draftTimestamp: Date? = nil @Published var questions: [String] = [] @Published var warnings: [String] = [] @Published var errorMessage: String? = nil @Published var selectedProvider: SessionSource.Kind let feature: WizardFeature private let preferences: SessionPreferencesStore private let runner = InternalSkillRunner() private let summaryBuilder: (Draft) -> [String] var availableProviders: [SessionSource.Kind] { SessionSource.Kind.allCases.filter { preferences.isCLIEnabled($0) } } init( feature: WizardFeature, preferences: SessionPreferencesStore, summaryBuilder: @escaping (Draft) -> [String] ) { self.feature = feature self.preferences = preferences self.summaryBuilder = summaryBuilder let fallback = WizardConversationViewModel.defaultProvider(preferences: preferences) let saved = SessionSource.Kind(rawValue: preferences.wizardPreferredProvider) self.selectedProvider = saved ?? fallback } func sendMessage() { let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } messages.append(WizardMessage(role: .user, text: trimmed)) inputText = "" Task { await runSkill() } } func runSkill() async { errorMessage = nil questions = [] draft = nil draftTimestamp = nil warnings = [] runEvents = [] isRunning = true appendEvent("Preparing skill input") appendEvent("Launching \(selectedProvider.displayName) CLI") do { let executable = preferences.preferredExecutablePath(for: selectedProvider) let result = try await runner.run( feature: feature, provider: selectedProvider, conversation: messages, defaultExecutable: executable, progress: { [weak self] event in self?.appendEvent(event) } ) appendEvent("Parsing result") isRunning = false let raw = result.outputText.trimmingCharacters(in: .whitespacesAndNewlines) if let envelope: WizardDraftEnvelope = WizardResponseParser.decodeEnvelope(raw) { handleEnvelope(envelope) } else if let draft: Draft = WizardResponseParser.decode(raw) { self.draft = draft self.draftTimestamp = Date() } else { errorMessage = "Failed to parse skill output." } preferences.wizardPreferredProvider = selectedProvider.rawValue } catch { isRunning = false errorMessage = error.localizedDescription } } func draftSummaryLines() -> [String] { guard let draft else { return [] } return summaryBuilder(draft) } private func handleEnvelope(_ envelope: WizardDraftEnvelope) { warnings = envelope.warnings ?? [] if envelope.mode == .question { let qs = envelope.questions ?? [] questions = qs if !qs.isEmpty { messages.append(WizardMessage(role: .assistant, text: qs.joined(separator: "\n"))) } return } if let draft = envelope.draft { self.draft = draft self.draftTimestamp = Date() } } private func appendEvent(_ message: String) { runEvents.append(WizardRunEvent(message: message, kind: .status)) } private func appendEvent(_ event: WizardRunEvent) { runEvents.append(event) } private static func defaultProvider(preferences: SessionPreferencesStore) -> SessionSource.Kind { let candidates: [SessionSource.Kind] = [.codex, .claude, .gemini] if let found = candidates.first(where: { preferences.isCLIEnabled($0) }) { return found } return .codex } } ================================================ FILE: models/WizardGuard.swift ================================================ import Foundation @MainActor final class WizardGuard: ObservableObject { @Published var isActive: Bool = false } ================================================ FILE: models/WizardModels.swift ================================================ import Foundation enum WizardFeature: String, Codable, CaseIterable, Sendable { case hooks case commands case mcp case skills var displayName: String { switch self { case .hooks: return "Hooks" case .commands: return "Commands" case .mcp: return "MCP Servers" case .skills: return "Skills" } } } enum WizardRole: String, Codable, Sendable { case system case user case assistant } struct WizardMessage: Identifiable, Codable, Hashable, Sendable { var id: UUID var role: WizardRole var text: String var createdAt: Date var draftJSON: String? init( id: UUID = UUID(), role: WizardRole, text: String, createdAt: Date = Date(), draftJSON: String? = nil ) { self.id = id self.role = role self.text = text self.createdAt = createdAt self.draftJSON = draftJSON } } enum WizardOutputMode: String, Codable, Sendable { case question case draft } struct WizardDraftEnvelope: Codable, Sendable { var mode: WizardOutputMode var questions: [String]? var draft: Draft? var warnings: [String]? var notes: [String]? } struct HookWizardDraft: Codable, Hashable, Sendable { var name: String? var description: String? var event: String var matcher: String? var targets: HookTargets? var commands: [HookCommand] var warnings: [String]? var notes: [String]? } struct CommandWizardDraft: Codable, Hashable, Sendable { var name: String var description: String var prompt: String var argumentHint: String? var model: String? var allowedTools: [String]? var tags: [String] var targets: CommandTargets? var warnings: [String]? var notes: [String]? } struct MCPWizardDraft: Codable, Hashable, Sendable { var name: String var kind: MCPServerKind var command: String? var args: [String]? var env: [String: String]? var url: String? var headers: [String: String]? var description: String? var targets: MCPServerTargets? var warnings: [String]? var notes: [String]? private enum CodingKeys: String, CodingKey { case name case kind case command case args case env case url case headers case description case targets case warnings case notes } private struct KeyValuePair: Codable, Hashable { var key: String var value: String } init( name: String, kind: MCPServerKind, command: String? = nil, args: [String]? = nil, env: [String: String]? = nil, url: String? = nil, headers: [String: String]? = nil, description: String? = nil, targets: MCPServerTargets? = nil, warnings: [String]? = nil, notes: [String]? = nil ) { self.name = name self.kind = kind self.command = command self.args = args self.env = env self.url = url self.headers = headers self.description = description self.targets = targets self.warnings = warnings self.notes = notes } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) name = try container.decode(String.self, forKey: .name) kind = try container.decode(MCPServerKind.self, forKey: .kind) command = try container.decodeIfPresent(String.self, forKey: .command) args = try container.decodeIfPresent([String].self, forKey: .args) url = try container.decodeIfPresent(String.self, forKey: .url) description = try container.decodeIfPresent(String.self, forKey: .description) targets = try container.decodeIfPresent(MCPServerTargets.self, forKey: .targets) warnings = try container.decodeIfPresent([String].self, forKey: .warnings) notes = try container.decodeIfPresent([String].self, forKey: .notes) if let dict = try? container.decodeIfPresent([String: String].self, forKey: .env) { env = dict } else if let pairs = try? container.decodeIfPresent([KeyValuePair].self, forKey: .env) { env = Dictionary(uniqueKeysWithValues: pairs.map { ($0.key, $0.value) }) } else { env = nil } if let dict = try? container.decodeIfPresent([String: String].self, forKey: .headers) { headers = dict } else if let pairs = try? container.decodeIfPresent([KeyValuePair].self, forKey: .headers) { headers = Dictionary(uniqueKeysWithValues: pairs.map { ($0.key, $0.value) }) } else { headers = nil } } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(name, forKey: .name) try container.encode(kind, forKey: .kind) try container.encodeIfPresent(command, forKey: .command) try container.encodeIfPresent(args, forKey: .args) try container.encodeIfPresent(env, forKey: .env) try container.encodeIfPresent(url, forKey: .url) try container.encodeIfPresent(headers, forKey: .headers) try container.encodeIfPresent(description, forKey: .description) try container.encodeIfPresent(targets, forKey: .targets) try container.encodeIfPresent(warnings, forKey: .warnings) try container.encodeIfPresent(notes, forKey: .notes) } } struct SkillWizardExample: Codable, Hashable, Sendable { var title: String var user: String var assistant: String } struct SkillWizardDraft: Codable, Hashable, Sendable { var id: String var name: String var description: String var summary: String? var tags: [String] var overview: String var instructions: [String] var examples: [SkillWizardExample] var notes: [String] var targets: MCPServerTargets? var warnings: [String]? } struct WizardRunEvent: Identifiable, Hashable, Sendable { enum Kind: String, Codable, Sendable { case status case stdout case stderr } var id: UUID = UUID() var message: String var kind: Kind = .status var timestamp: Date = Date() } ================================================ FILE: notify/NotifyMain.swift ================================================ import Foundation @main struct CodMateNotifyCLI { static func main() { do { try run() } catch { fputs("codmate-notify: \(error.localizedDescription)\n", stderr) exit(1) } } private static func run() throws { let args = CommandLine.arguments.dropFirst() guard !args.isEmpty else { return } var payloadArg: String? var selfTest = false for arg in args { if arg == "--self-test" { selfTest = true continue } if payloadArg == nil { payloadArg = arg } } let request: NotificationRequest if let payloadArg, let parsed = NotificationRequest(jsonString: payloadArg) { request = parsed } else if selfTest { request = NotificationRequest( event: .test, title: "CodMate", body: "Codex notifications self-test", threadId: "codex-test" ) } else { return } guard request.event != .ignored else { return } try dispatch(request: request) if selfTest { print("__CODMATE_NOTIFIED__") } } private static func dispatch(request: NotificationRequest) throws { guard let url = request.makeURL() else { throw NotifyError.urlEncodingFailed } // 使用 -j (隐藏启动) 而不是 -g (后台启动) 来防止 SwiftUI WindowGroup 自动创建新窗口 // -j 参数确保应用在后台处理 URL 而不激活或显示窗口 if try !runOpen(arguments: ["-b", bundleIdentifier, "-j", url.absoluteString]) { if try !runOpen(arguments: ["-j", url.absoluteString]) { throw NotifyError.openFailed(code: 1) } } } private static let bundleIdentifier = "ai.umate.codmate" @discardableResult private static func runOpen(arguments: [String]) throws -> Bool { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/open") process.arguments = arguments try process.run() process.waitUntilExit() return process.terminationStatus == 0 } } private enum NotifyError: LocalizedError { case urlEncodingFailed case openFailed(code: Int32) var errorDescription: String? { switch self { case .urlEncodingFailed: return "Failed to encode notification URL." case .openFailed(let code): return "Unable to dispatch codmate:// URL (open exited with \(code))." } } } private struct NotificationRequest { enum Event: String { case turnComplete = "turncomplete" case test case ignored } let event: Event let title: String let body: String let threadId: String? init?(jsonString: String) { guard let data = jsonString.data(using: .utf8) else { return nil } guard let payload = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any] else { let snippet = NotificationRequest.snippet(from: jsonString) self.init(event: .turnComplete, title: "Codex", body: snippet, threadId: "codex-generic") return } let normalizedEvent = NotificationRequest.normalizedEvent(in: payload) guard NotificationRequest.allowedEvents.contains(normalizedEvent) else { self.init(event: .ignored, title: "", body: "", threadId: nil) return } let message = NotificationRequest.message(from: payload) let thread = NotificationRequest.threadId(from: payload) self.init(event: .turnComplete, title: "Codex", body: message, threadId: thread) } init(event: Event, title: String, body: String, threadId: String?) { self.event = event self.title = title self.body = body self.threadId = threadId } func makeURL() -> URL? { var components = URLComponents() components.scheme = "codmate" components.host = "notify" var query: [URLQueryItem] = [ URLQueryItem(name: "source", value: "codex"), URLQueryItem(name: "event", value: event.rawValue) ] if let titleData = title.data(using: .utf8)?.base64EncodedString() { query.append(URLQueryItem(name: "title64", value: titleData)) } if let bodyData = body.data(using: .utf8)?.base64EncodedString() { query.append(URLQueryItem(name: "body64", value: bodyData)) } if let threadId, !threadId.isEmpty { query.append(URLQueryItem(name: "thread", value: threadId)) } components.queryItems = query return components.url } private static func normalizedEvent(in payload: [String: Any]) -> String { let rawEvent = (payload["type"] as? String) ?? (payload["event"] as? String) ?? "" let allowedCharacters = CharacterSet.alphanumerics let filtered = rawEvent.unicodeScalars.filter { allowedCharacters.contains($0) } return String(filtered).lowercased() } private static func message(from payload: [String: Any]) -> String { let candidates = [ "last-assistant-message", "assistant", "message" ] for key in candidates { if let value = payload[key] as? String, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return NotificationRequest.coalesce(text: value) } } return "Codex turn complete" } private static func threadId(from payload: [String: Any]) -> String { if let thread = payload["thread-id"] as? String, !thread.isEmpty { return "codex-\(thread)" } if let session = payload["session-id"] as? String, !session.isEmpty { return "codex-\(session)" } return "codex-thread" } private static func snippet(from raw: String) -> String { return coalesce(text: raw) } private static func coalesce(text: String) -> String { let collapsed = text.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) let trimmed = collapsed.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.count <= 240 { return trimmed } let endIndex = trimmed.index(trimmed.startIndex, offsetBy: 240) return String(trimmed[.. = [ "agentturncomplete", "turncomplete", "agentcompleted", "agentdone", "runcomplete", "rundone", "sessioncomplete", "completed" ] } private let bundleIdentifier = "ai.umate.codmate" ================================================ FILE: payload/commands/index.json ================================================ [] ================================================ FILE: payload/hook-events.json ================================================ { "events": [ { "name": "Setup", "description": "Load context and configure the environment during repository initialization or maintenance.", "providers": ["claude"], "supportsMatcher": false }, { "name": "SessionStart", "description": "Runs when a session starts.", "providers": ["claude", "gemini"], "supportsMatcher": true, "matchers": [ { "value": "startup", "description": "Session starts fresh.", "providers": ["gemini"] }, { "value": "resume", "description": "Session resumes from history.", "providers": ["gemini"] }, { "value": "clear", "description": "Session is cleared and restarted.", "providers": ["gemini"] } ] }, { "name": "UserPromptSubmit", "description": "Runs when the user submits a prompt.", "providers": ["claude", "gemini"], "aliases": { "gemini": "BeforeAgent" }, "supportsMatcher": true, "matchers": [ { "value": "*", "description": "Wildcard matcher.", "providers": ["gemini"] } ] }, { "name": "PreToolUse", "description": "Runs before a tool is called.", "providers": ["claude", "gemini"], "aliases": { "gemini": "BeforeTool" }, "supportsMatcher": true, "matchers": [ { "value": "Bash", "description": "Tool name.", "providers": ["claude"] }, { "value": "Write", "description": "Tool name.", "providers": ["claude"] }, { "value": "Edit", "description": "Tool name.", "providers": ["claude"] }, { "value": "Read", "description": "Tool name.", "providers": ["claude"] }, { "value": "Write|Edit", "description": "Regex example.", "providers": ["claude"] }, { "value": "Notebook.*", "description": "Regex example.", "providers": ["claude"] }, { "value": "*", "description": "Wildcard matcher.", "providers": ["gemini"] }, { "value": "write_.*", "description": "Regex example.", "providers": ["gemini"] } ] }, { "name": "PermissionRequest", "description": "Runs when a tool permission is requested.", "providers": ["claude"], "supportsMatcher": true, "matchers": [ { "value": "Bash", "description": "Tool name.", "providers": ["claude"] }, { "value": "Write", "description": "Tool name.", "providers": ["claude"] }, { "value": "Edit", "description": "Tool name.", "providers": ["claude"] }, { "value": "Read", "description": "Tool name.", "providers": ["claude"] }, { "value": "Write|Edit", "description": "Regex example.", "providers": ["claude"] }, { "value": "Notebook.*", "description": "Regex example.", "providers": ["claude"] } ] }, { "name": "PostToolUse", "description": "Runs after a tool call succeeds.", "providers": ["claude", "gemini"], "aliases": { "gemini": "AfterTool" }, "supportsMatcher": true, "matchers": [ { "value": "Bash", "description": "Tool name.", "providers": ["claude"] }, { "value": "Write", "description": "Tool name.", "providers": ["claude"] }, { "value": "Edit", "description": "Tool name.", "providers": ["claude"] }, { "value": "Read", "description": "Tool name.", "providers": ["claude"] }, { "value": "Write|Edit", "description": "Regex example.", "providers": ["claude"] }, { "value": "Notebook.*", "description": "Regex example.", "providers": ["claude"] }, { "value": "*", "description": "Wildcard matcher.", "providers": ["gemini"] }, { "value": "write_.*", "description": "Regex example.", "providers": ["gemini"] } ] }, { "name": "PostToolUseFailure", "description": "Runs after a tool call fails.", "providers": ["claude"], "supportsMatcher": false }, { "name": "SubagentStart", "description": "Runs when a subagent (Task tool call) starts.", "providers": ["claude"], "supportsMatcher": false }, { "name": "SubagentStop", "description": "Runs when a subagent (Task tool call) finishes.", "providers": ["claude"], "supportsMatcher": false, "note": "Prompt-based hooks are supported for this event." }, { "name": "Stop", "description": "Runs when the assistant finishes responding.", "providers": ["claude", "gemini", "codex"], "aliases": { "gemini": "AfterAgent" }, "supportsMatcher": true, "matchers": [ { "value": "*", "description": "Wildcard matcher.", "providers": ["gemini"] } ], "note": "Prompt-based hooks are supported for this event." }, { "name": "PreCompact", "description": "Runs before context compaction.", "providers": ["claude", "gemini"], "aliases": { "gemini": "PreCompress" }, "supportsMatcher": true, "matchers": [ { "value": "*", "description": "Wildcard matcher.", "providers": ["gemini"] } ] }, { "name": "SessionEnd", "description": "Runs when a session ends.", "providers": ["claude", "gemini"], "supportsMatcher": true, "matchers": [ { "value": "exit", "description": "Session exits.", "providers": ["gemini"] }, { "value": "clear", "description": "Session is cleared.", "providers": ["gemini"] } ] }, { "name": "Notification", "description": "Runs when the CLI raises a notification.", "providers": ["claude", "gemini"], "supportsMatcher": true, "matchers": [ { "value": "*", "description": "Wildcard matcher.", "providers": ["gemini"] } ] }, { "name": "BeforeModel", "description": "Runs before a request is sent to the model.", "providers": ["gemini"], "supportsMatcher": true }, { "name": "AfterModel", "description": "Runs after the model responds, before tool selection.", "providers": ["gemini"], "supportsMatcher": true }, { "name": "BeforeToolSelection", "description": "Runs before tool selection.", "providers": ["gemini"], "supportsMatcher": true } ] } ================================================ FILE: payload/hook-variables.json ================================================ { "variables": [ { "name": "CLAUDE_PROJECT_DIR", "kind": "env", "description": "Project root directory", "providers": ["claude", "gemini"], "note": "Gemini alias" }, { "name": "CLAUDE_ENV_FILE", "kind": "env", "description": "Path to environment file", "providers": ["claude"], "note": "SessionStart/Setup" }, { "name": "GEMINI_PROJECT_DIR", "kind": "env", "description": "Project root directory", "providers": ["gemini"] }, { "name": "GEMINI_SESSION_ID", "kind": "env", "description": "Session identifier", "providers": ["gemini"] }, { "name": "GEMINI_CWD", "kind": "env", "description": "Current working directory", "providers": ["gemini"] }, { "name": "session_id", "kind": "stdin", "description": "Session identifier", "providers": ["claude", "gemini"] }, { "name": "transcript_path", "kind": "stdin", "description": "Transcript JSON path", "providers": ["claude", "gemini"] }, { "name": "cwd", "kind": "stdin", "description": "Current working directory", "providers": ["claude", "gemini"] }, { "name": "permission_mode", "kind": "stdin", "description": "Permission mode", "providers": ["claude"] }, { "name": "hook_event_name", "kind": "stdin", "description": "Hook event name", "providers": ["claude", "gemini"] }, { "name": "timestamp", "kind": "stdin", "description": "Event timestamp", "providers": ["gemini"] }, { "name": "tool_name", "kind": "stdin", "description": "Tool name", "providers": ["claude", "gemini"], "note": "Claude: PreToolUse/PermissionRequest/PostToolUse/PostToolUseFailure · Gemini: BeforeTool/AfterTool" }, { "name": "tool_input", "kind": "stdin", "description": "Tool input JSON", "providers": ["claude", "gemini"], "note": "Claude: PreToolUse/PermissionRequest/PostToolUse/PostToolUseFailure · Gemini: BeforeTool/AfterTool" }, { "name": "tool_use_id", "kind": "stdin", "description": "Tool use identifier", "providers": ["claude"], "note": "PreToolUse/PermissionRequest/PostToolUse/PostToolUseFailure" }, { "name": "tool_response", "kind": "stdin", "description": "Tool response JSON", "providers": ["claude", "gemini"], "note": "Claude: PostToolUse/PostToolUseFailure · Gemini: AfterTool" }, { "name": "mcp_context", "kind": "stdin", "description": "MCP context JSON", "providers": ["gemini"], "note": "BeforeTool/AfterTool" }, { "name": "prompt", "kind": "stdin", "description": "User prompt", "providers": ["claude", "gemini"], "note": "Claude: UserPromptSubmit · Gemini: BeforeAgent/AfterAgent" }, { "name": "prompt_response", "kind": "stdin", "description": "Agent response", "providers": ["gemini"], "note": "AfterAgent" }, { "name": "stop_hook_active", "kind": "stdin", "description": "Stop hook state", "providers": ["claude", "gemini"], "note": "Claude: Stop/SubagentStop · Gemini: AfterAgent" }, { "name": "agent_id", "kind": "stdin", "description": "Subagent identifier", "providers": ["claude"], "note": "SubagentStart/SubagentStop" }, { "name": "agent_transcript_path", "kind": "stdin", "description": "Subagent transcript JSON path", "providers": ["claude"], "note": "SubagentStop" }, { "name": "llm_request", "kind": "stdin", "description": "Model request JSON", "providers": ["gemini"], "note": "BeforeModel/BeforeToolSelection/AfterModel" }, { "name": "llm_response", "kind": "stdin", "description": "Model response JSON", "providers": ["gemini"], "note": "AfterModel" }, { "name": "message", "kind": "stdin", "description": "Notification message", "providers": ["claude", "gemini"], "note": "Notification" }, { "name": "notification_type", "kind": "stdin", "description": "Notification type", "providers": ["claude", "gemini"], "note": "Notification" }, { "name": "details", "kind": "stdin", "description": "Notification details JSON", "providers": ["gemini"], "note": "Notification" }, { "name": "trigger", "kind": "stdin", "description": "Compaction trigger", "providers": ["claude", "gemini"], "note": "Claude: PreCompact/Setup · Gemini: PreCompress" }, { "name": "custom_instructions", "kind": "stdin", "description": "Custom instructions", "providers": ["claude"], "note": "PreCompact" }, { "name": "source", "kind": "stdin", "description": "Session start source", "providers": ["claude", "gemini"], "note": "SessionStart" }, { "name": "model", "kind": "stdin", "description": "Model name", "providers": ["claude"], "note": "SessionStart" }, { "name": "agent_type", "kind": "stdin", "description": "Agent type", "providers": ["claude"], "note": "SessionStart/SubagentStart" }, { "name": "reason", "kind": "stdin", "description": "Session end reason", "providers": ["claude", "gemini"], "note": "SessionEnd" } ] } ================================================ FILE: payload/internal-skills/commands-wizard/SKILL.md ================================================ --- name: commands-wizard description: Generate CodMate slash command drafts from requirements. metadata: short-description: Generate command drafts in JSON. --- # Commands Wizard ## Overview Generate a CodMate slash command draft based on user intent. Output only JSON that matches the schema. ## Instructions 1. Read the user's request and conversation context. 2. Produce a concise command name, description, and prompt. 3. If unclear, return mode "question" with follow-up questions. ## Output Return only JSON. ================================================ FILE: payload/internal-skills/commands-wizard/prompt.md ================================================ You are a CodMate internal wizard. Use the application language specified by the JSON payload fields: - appLanguage (BCP-47 code) - appLanguageName (English name of the language) All user-facing text must use that language. Follow the user's request and conversation context. If the request is unclear, set mode="question" and ask concise follow-up questions. Return only JSON that matches schema.json. Do not include markdown or extra text. ================================================ FILE: payload/internal-skills/commands-wizard/schema.json ================================================ { "type": "object", "properties": { "mode": { "type": "string", "enum": [ "question", "draft" ] }, "questions": { "type": [ "array", "null" ], "items": { "type": "string" } }, "draft": { "type": [ "object", "null" ], "properties": { "name": { "type": "string" }, "description": { "type": "string" }, "prompt": { "type": "string" }, "argumentHint": { "type": [ "string", "null" ] }, "model": { "type": [ "string", "null" ] }, "allowedTools": { "type": [ "array", "null" ], "items": { "type": "string" } }, "tags": { "type": "array", "items": { "type": "string" } }, "targets": { "type": [ "object", "null" ], "properties": { "codex": { "type": "boolean" }, "claude": { "type": "boolean" }, "gemini": { "type": "boolean" } }, "additionalProperties": false, "required": [ "codex", "claude", "gemini" ] } }, "required": [ "name", "description", "prompt", "tags", "argumentHint", "model", "allowedTools", "targets" ], "additionalProperties": false }, "warnings": { "type": [ "array", "null" ], "items": { "type": "string" } }, "notes": { "type": [ "array", "null" ], "items": { "type": "string" } } }, "required": [ "mode", "questions", "draft", "warnings", "notes" ], "additionalProperties": false } ================================================ FILE: payload/internal-skills/hooks-wizard/SKILL.md ================================================ --- name: hooks-wizard description: Generate CodMate hook drafts from requirements. metadata: short-description: Generate hook configuration drafts in JSON. --- # Hooks Wizard ## Overview Generate a CodMate Hook draft based on the user's requirement and provided event/variable catalogs. Output only JSON that matches the schema. ## Instructions 1. Read the user's request and any conversation context. 2. Choose the most appropriate event and matcher from the catalog. 3. Produce a Hook draft with one or more commands. 4. If the requirement is unclear, return mode "question" with follow-up questions. ## Output Return only JSON. ================================================ FILE: payload/internal-skills/hooks-wizard/prompt.md ================================================ You are a CodMate internal wizard. Use the application language specified by the JSON payload fields: - appLanguage (BCP-47 code) - appLanguageName (English name of the language) All user-facing text must use that language. Follow the user's request and any provided catalogs. If the request is unclear, set mode="question" and ask concise follow-up questions. Return only JSON that matches schema.json. Do not include markdown or extra text. ================================================ FILE: payload/internal-skills/hooks-wizard/schema.json ================================================ { "type": "object", "properties": { "mode": { "type": "string", "enum": [ "question", "draft" ] }, "questions": { "type": [ "array", "null" ], "items": { "type": "string" } }, "draft": { "type": [ "object", "null" ], "properties": { "name": { "type": [ "string", "null" ] }, "description": { "type": [ "string", "null" ] }, "event": { "type": "string" }, "matcher": { "type": [ "string", "null" ] }, "targets": { "type": [ "object", "null" ], "properties": { "codex": { "type": "boolean" }, "claude": { "type": "boolean" }, "gemini": { "type": "boolean" } }, "additionalProperties": false, "required": [ "codex", "claude", "gemini" ] }, "commands": { "type": "array", "items": { "type": "object", "properties": { "command": { "type": "string" }, "args": { "type": [ "array", "null" ], "items": { "type": "string" } }, "env": { "type": [ "array", "null" ], "items": { "type": "object", "properties": { "key": { "type": "string" }, "value": { "type": "string" } }, "required": [ "key", "value" ], "additionalProperties": false } }, "timeoutMs": { "type": [ "number", "null" ] } }, "required": [ "command", "args", "env", "timeoutMs" ], "additionalProperties": false } } }, "required": [ "event", "commands", "name", "description", "matcher", "targets" ], "additionalProperties": false }, "warnings": { "type": [ "array", "null" ], "items": { "type": "string" } }, "notes": { "type": [ "array", "null" ], "items": { "type": "string" } } }, "required": [ "mode", "questions", "draft", "warnings", "notes" ], "additionalProperties": false } ================================================ FILE: payload/internal-skills/index.json ================================================ { "skills": [ { "id": "hooks-wizard", "feature": "hooks", "title": "Hooks Wizard", "description": "Generate CodMate hook drafts from requirements.", "version": "1.0", "invocations": [ { "provider": "codex", "args": [ "exec", "--output-schema", "{{schemaFile}}", "--output-last-message", "{{outputFile}}", "--color", "never", "--skip-git-repo-check", "-" ], "inputMode": "stdin", "outputMode": "file", "timeoutSeconds": 45 }, { "provider": "claude", "args": [ "-p", "--output-format", "text" ], "inputMode": "stdin", "outputMode": "stdout", "timeoutSeconds": 45 }, { "provider": "gemini", "args": [ "--output-format", "text" ], "inputMode": "stdin", "outputMode": "stdout", "timeoutSeconds": 45 } ] }, { "id": "commands-wizard", "feature": "commands", "title": "Commands Wizard", "description": "Generate slash command drafts from requirements.", "version": "1.0", "invocations": [ { "provider": "codex", "args": [ "exec", "--output-schema", "{{schemaFile}}", "--output-last-message", "{{outputFile}}", "--color", "never", "--skip-git-repo-check", "-" ], "inputMode": "stdin", "outputMode": "file", "timeoutSeconds": 45 }, { "provider": "claude", "args": [ "-p", "--output-format", "text" ], "inputMode": "stdin", "outputMode": "stdout", "timeoutSeconds": 45 }, { "provider": "gemini", "args": [ "--output-format", "text" ], "inputMode": "stdin", "outputMode": "stdout", "timeoutSeconds": 45 } ] }, { "id": "mcp-wizard", "feature": "mcp", "title": "MCP Wizard", "description": "Generate MCP server drafts from requirements.", "version": "1.0", "invocations": [ { "provider": "codex", "args": [ "exec", "--output-schema", "{{schemaFile}}", "--output-last-message", "{{outputFile}}", "--color", "never", "--skip-git-repo-check", "-" ], "inputMode": "stdin", "outputMode": "file", "timeoutSeconds": 45 }, { "provider": "claude", "args": [ "-p", "--output-format", "text" ], "inputMode": "stdin", "outputMode": "stdout", "timeoutSeconds": 45 }, { "provider": "gemini", "args": [ "--output-format", "text" ], "inputMode": "stdin", "outputMode": "stdout", "timeoutSeconds": 45 } ] }, { "id": "skills-wizard", "feature": "skills", "title": "Skills Wizard", "description": "Generate CodMate skill drafts from requirements.", "version": "1.0", "invocations": [ { "provider": "codex", "args": [ "exec", "--output-schema", "{{schemaFile}}", "--output-last-message", "{{outputFile}}", "--color", "never", "--skip-git-repo-check", "-" ], "inputMode": "stdin", "outputMode": "file", "timeoutSeconds": 45 }, { "provider": "claude", "args": [ "-p", "--output-format", "text" ], "inputMode": "stdin", "outputMode": "stdout", "timeoutSeconds": 45 }, { "provider": "gemini", "args": [ "--output-format", "text" ], "inputMode": "stdin", "outputMode": "stdout", "timeoutSeconds": 45 } ] } ] } ================================================ FILE: payload/internal-skills/mcp-wizard/SKILL.md ================================================ --- name: mcp-wizard description: Generate MCP server drafts from requirements. metadata: short-description: Generate MCP server drafts in JSON. --- # MCP Wizard ## Overview Generate a CodMate MCP server draft based on user intent. Output only JSON that matches the schema. ## Instructions 1. Determine server kind (stdio, sse, streamable_http). 2. Provide command/args/env for stdio, or url/headers for network kinds. 3. If unclear, return mode "question" with follow-up questions. ## Output Return only JSON. ================================================ FILE: payload/internal-skills/mcp-wizard/prompt.md ================================================ You are a CodMate internal wizard. Use the application language specified by the JSON payload fields: - appLanguage (BCP-47 code) - appLanguageName (English name of the language) All user-facing text must use that language. This wizard is primarily for discovery. Prefer MCP servers listed in official registries or well-known catalogs (for example, the official registry or mcp.so). If you cannot identify an exact server or endpoint from the request, ask for clarification rather than guessing. Do not invoke tools, shell commands, or web browsing. Use only the provided docs. If the request is unclear, set mode="question" and ask concise follow-up questions. Return only JSON that matches schema.json. Do not include markdown or extra text. ================================================ FILE: payload/internal-skills/mcp-wizard/schema.json ================================================ { "type": "object", "properties": { "mode": { "type": "string", "enum": [ "question", "draft" ] }, "questions": { "type": [ "array", "null" ], "items": { "type": "string" } }, "draft": { "type": [ "object", "null" ], "properties": { "name": { "type": "string" }, "kind": { "type": "string", "enum": [ "stdio", "sse", "streamable_http" ] }, "command": { "type": [ "string", "null" ] }, "args": { "type": [ "array", "null" ], "items": { "type": "string" } }, "env": { "type": [ "array", "null" ], "items": { "type": "object", "properties": { "key": { "type": "string" }, "value": { "type": "string" } }, "required": [ "key", "value" ], "additionalProperties": false } }, "url": { "type": [ "string", "null" ] }, "headers": { "type": [ "array", "null" ], "items": { "type": "object", "properties": { "key": { "type": "string" }, "value": { "type": "string" } }, "required": [ "key", "value" ], "additionalProperties": false } }, "description": { "type": [ "string", "null" ] }, "targets": { "type": [ "object", "null" ], "properties": { "codex": { "type": "boolean" }, "claude": { "type": "boolean" }, "gemini": { "type": "boolean" } }, "additionalProperties": false, "required": [ "codex", "claude", "gemini" ] } }, "required": [ "name", "kind", "command", "args", "env", "url", "headers", "description", "targets" ], "additionalProperties": false }, "warnings": { "type": [ "array", "null" ], "items": { "type": "string" } }, "notes": { "type": [ "array", "null" ], "items": { "type": "string" } } }, "required": [ "mode", "questions", "draft", "warnings", "notes" ], "additionalProperties": false } ================================================ FILE: payload/internal-skills/skills-wizard/SKILL.md ================================================ --- name: skills-wizard description: Generate CodMate skill drafts from requirements. metadata: short-description: Generate skill drafts in JSON. --- # Skills Wizard ## Overview Generate a CodMate skill draft based on user intent. Output only JSON that matches the schema. ## Instructions 1. Propose a skill id and name. 2. Provide description, overview, instructions, examples, and notes. 3. If unclear, return mode "question" with follow-up questions. ## Output Return only JSON. ================================================ FILE: payload/internal-skills/skills-wizard/prompt.md ================================================ You are a CodMate internal wizard. Use the application language specified by the JSON payload fields: - appLanguage (BCP-47 code) - appLanguageName (English name of the language) All user-facing text must use that language. Follow the user's request and conversation context. If the request is unclear, set mode="question" and ask concise follow-up questions. Return only JSON that matches schema.json. Do not include markdown or extra text. ================================================ FILE: payload/internal-skills/skills-wizard/schema.json ================================================ { "type": "object", "properties": { "mode": { "type": "string", "enum": [ "question", "draft" ] }, "questions": { "type": [ "array", "null" ], "items": { "type": "string" } }, "draft": { "type": [ "object", "null" ], "properties": { "id": { "type": "string" }, "name": { "type": "string" }, "description": { "type": "string" }, "summary": { "type": [ "string", "null" ] }, "tags": { "type": "array", "items": { "type": "string" } }, "overview": { "type": "string" }, "instructions": { "type": "array", "items": { "type": "string" } }, "examples": { "type": "array", "items": { "type": "object", "properties": { "title": { "type": "string" }, "user": { "type": "string" }, "assistant": { "type": "string" } }, "required": [ "title", "user", "assistant" ], "additionalProperties": false } }, "notes": { "type": "array", "items": { "type": "string" } }, "targets": { "type": [ "object", "null" ], "properties": { "codex": { "type": "boolean" }, "claude": { "type": "boolean" }, "gemini": { "type": "boolean" } }, "additionalProperties": false, "required": [ "codex", "claude", "gemini" ] } }, "required": [ "id", "name", "description", "tags", "overview", "instructions", "examples", "notes", "summary", "targets" ], "additionalProperties": false }, "warnings": { "type": [ "array", "null" ], "items": { "type": "string" } } }, "required": [ "mode", "questions", "draft", "warnings" ], "additionalProperties": false } ================================================ FILE: payload/knowledge/wizard-docs.json ================================================ { "sources": [ { "feature": "skills", "provider": "codex", "url": "https://developers.openai.com/codex/app-server#skills", "maxChars": 3000, "cacheTTLHours": 168 }, { "feature": "skills", "provider": "claude", "url": "https://code.claude.com/docs/en/skills", "maxChars": 3000, "cacheTTLHours": 168 }, { "feature": "skills", "provider": "gemini", "url": "https://geminicli.com/docs/cli/skills/", "maxChars": 3000, "cacheTTLHours": 168 }, { "feature": "mcp", "url": "https://modelcontextprotocol.io/registry/about", "maxChars": 3000, "cacheTTLHours": 72 }, { "feature": "mcp", "url": "https://registry.modelcontextprotocol.io/", "maxChars": 3000, "cacheTTLHours": 24 }, { "feature": "mcp", "url": "https://mcp.so/", "maxChars": 3000, "cacheTTLHours": 24 } ] } ================================================ FILE: payload/prompts/commit-message.md ================================================ You are a helpful assistant that writes Conventional Commits in imperative mood. Task: produce a high-quality commit message with: 1) A concise subject line (type: scope? subject) 2) A brief body (2-4 lines or bullets) explaining motivation and key changes Constraints: subject <= 80 chars; wrap body lines <= 72 chars; no trailing period in subject. Consider the staged diff below (may be truncated): ================================================ FILE: payload/prompts/task-title-and-description.md ================================================ You are a helpful assistant that generates concise titles and descriptions for coding project tasks based on their constituent sessions. Task: Analyze the session summaries below and produce: 1) A short, descriptive title (3-6 words) capturing the overall task goal 2) A brief description (2-3 sentences) summarizing the task scope and what has been accomplished Guidelines: - Title should represent the common theme or goal across all sessions - **Synthesize patterns**: identify the overarching objective that connects multiple sessions - Description should summarize what work has been done across the sessions - **Focus on outcomes**: capture what was built, fixed, or improved rather than implementation details - If sessions cover diverse topics, find the unifying theme (e.g., "Feature implementation" or "Bug fixes") - Use present tense for ongoing work, past tense for completed work - Keep language professional and concise Example input (session summaries): - Session 1: "Implement session title generation" - Added LLM-based automatic title generation for sessions. - Session 2: "Add session comment editing UI" - Created UI for editing session comments with real-time preview. - Session 3: "Fix title generation performance" - Optimized title generation to handle large sessions. Example output: ```json { "title": "Implement session metadata features", "description": "Built automatic title and comment generation for sessions using LLM. Added editing UI with real-time preview and optimized performance for large sessions." } ``` Output format: Return ONLY a JSON object with this exact structure: ```json { "title": "Your generated title here", "description": "Your generated description here" } ``` Do not include any other text, explanations, or formatting outside the JSON object. Session summaries: ================================================ FILE: payload/prompts/task-title-only.md ================================================ You are a helpful assistant that improves task titles and generates descriptions based on a brief task title and/or description. Task: Given a short task title and/or description, produce: 1) An improved, more descriptive title (3-6 words) if needed, or keep the original if already clear 2) A brief description (2-3 sentences) that expands on what this task might involve Guidelines: - Title should be clear, specific, and actionable - If the current title is already good, keep it as-is or make minor improvements - If current description exists, use it to inform and improve both title and description - Description should anticipate the likely scope and activities for this type of task - **Be specific but not prescriptive** - suggest what might be involved without assuming implementation details - Use present tense for ongoing work, future tense for planned work - Keep language professional and concise Example input 1 (title only): Current title: "User auth" Example output 1: ```json { "title": "Implement user authentication", "description": "Build user authentication system including login, signup, and session management. Will likely involve password hashing, JWT tokens or sessions, and protected routes." } ``` Example input 2 (title + description): Current title: "Auth" Current description: "Need to add login and also remember user sessions" Example output 2: ```json { "title": "Implement authentication with session persistence", "description": "Build user authentication system with login functionality and session management to remember logged-in users. Will involve authentication flow, session storage, and logout handling." } ``` Output format: Return ONLY a JSON object with this exact structure: ```json { "title": "Your improved/generated title here", "description": "Your generated description here" } ``` Do not include any other text, explanations, or formatting outside the JSON object. ================================================ FILE: payload/prompts/title-and-comment.md ================================================ You are a helpful assistant that generates concise titles and descriptive comments for coding conversation sessions. Task: Analyze the conversation material below and produce: 1) A short, descriptive title (3-6 words) capturing the main topic or goal 2) A brief comment (2-3 sentences) summarizing what was discussed and accomplished Guidelines: - Title should be clear, specific, and actionable (e.g., "Fix authentication bug in login flow", "Implement dark mode toggle") - **Focus on the initial request/requirement** that started the conversation - this is the primary topic - Comment should highlight the key problem, solution, or outcome from that initial goal - **Avoid process noise**: skip implementation details, bug fixes, refactorings, or discussions that happened along the way - **Do capture additional requirements**: if new features or requirements were added during the conversation (e.g., "also add a global status bar"), include these in the comment as they represent scope expansion - Prioritize what was requested, not how it was achieved - Use present tense for ongoing work, past tense for completed tasks - Keep language professional and concise Example of good vs bad summaries: **Good**: - Title: "Implement session title generation" - Comment: "Added automatic LLM-based title and comment generation for sessions. Also explored adding global status bar for debugging." **Bad**: - Title: "Fix Levenshtein algorithm performance issue" - Comment: "Optimized deduplication from O(n²) to O(n), fixed MainActor deadlock, moved processing to background thread." (This focuses on implementation details rather than the user's original goal) Output format: Return ONLY a JSON object with this exact structure: ```json { "title": "Your generated title here", "comment": "Your generated comment here" } ``` Do not include any other text, explanations, or formatting outside the JSON object. Conversation material: ================================================ FILE: payload/providers.json ================================================ { "version" : 1, "providers" : [ { "id" : "openrouter", "name" : "OpenRouter", "class" : "openai-compatible", "managedByCodMate" : true, "envKey" : "OPENROUTER_API_KEY", "keyURL" : "https://openrouter.ai/keys", "docsURL" : "https://openrouter.ai/docs", "connectors" : { "codex" : { "baseURL" : "https://openrouter.ai/api", "wireAPI" : "chat", "envHttpHeaders" : { "HTTP-Referer" : "OPENROUTER_REFERRER", "X-Title" : "OPENROUTER_TITLE" } }, "claudeCode" : { "baseURL" : "https://openrouter.ai/api", "modelAliases" : { "default" : "anthropic/claude-4.5-sonnet", "haiku" : "anthropic/claude-4.5-haiku", "opus" : "anthropic/claude-4.5-opus" }, "envHttpHeaders" : { "x-api-key" : "OPENROUTER_API_KEY", "HTTP-Referer" : "OPENROUTER_REFERRER", "X-Title" : "OPENROUTER_TITLE" } } }, "catalog" : { "models" : [ { "vendorModelId" : "openrouter/auto", "caps" : { "reasoning": false, "tool_use": true, "long_context": true, "vision": true } }, { "vendorModelId" : "anthropic/claude-4.5-opus", "caps" : { "reasoning": true, "tool_use": true, "long_context": true, "vision": true } }, { "vendorModelId" : "anthropic/claude-4.5-sonnet", "caps" : { "reasoning": true, "tool_use": true, "long_context": true, "vision": true } }, { "vendorModelId" : "anthropic/claude-4.5-haiku", "caps" : { "reasoning": false, "tool_use": true, "long_context": true, "vision": true } }, { "vendorModelId" : "anthropic/claude-4.1-opus", "caps" : { "reasoning": true, "tool_use": true, "long_context": true, "vision": true } } ] }, "recommended" : { "defaultModelFor" : { "codex" : "openrouter/auto", "claudeCode" : "anthropic/claude-4.5-sonnet" } } }, { "id" : "openai", "name" : "OpenAI", "class" : "openai-compatible", "managedByCodMate" : true, "envKey" : "OPENAI_API_KEY", "keyURL" : "https://platform.openai.com/api-keys", "docsURL" : "https://platform.openai.com/docs", "connectors" : { "codex" : { "baseURL" : "https://api.openai.com/v1", "wireAPI" : "chat" } }, "catalog" : { "models" : [ { "vendorModelId" : "gpt-4o-mini", "caps": { "reasoning": false, "tool_use": true, "long_context": true, "vision": true } }, { "vendorModelId" : "gpt-4.1-mini", "caps": { "reasoning": false, "tool_use": true, "long_context": true, "vision": true } } ] }, "recommended" : { "defaultModelFor" : { "codex" : "gpt-4o-mini" } } }, { "envKey" : "K2_API_KEY", "keyURL" : "https://platform.moonshot.cn/console/api-keys", "docsURL" : "https://platform.moonshot.cn/docs", "connectors" : { "codex" : { "wireAPI" : "chat", "baseURL" : "https://api.moonshot.cn/v1" }, "claudeCode" : { "modelAliases" : { "default" : "kimi-k2-0905-preview" }, "baseURL" : "https://api.moonshot.cn/anthropic" } }, "name" : "Kimi", "catalog" : { "models" : [ { "caps" : { "reasoning" : false, "tool_use" : true, "long_context" : true, "vision" : false }, "vendorModelId" : "kimi-k2-0905-preview" }, { "caps" : { "reasoning" : false, "tool_use" : true, "long_context" : true, "vision" : false }, "vendorModelId" : "kimi-k2-turbo-preview" }, { "caps" : { "reasoning" : true, "tool_use" : true, "long_context" : true, "vision" : false }, "vendorModelId" : "kimi-k2-thinking" } ] }, "id" : "k2", "class" : "openai-compatible", "managedByCodMate" : true, "recommended" : { "defaultModelFor" : { "claudeCode" : "kimi-k2-0905-preview", "codex" : "kimi-k2-0905-preview" } } }, { "id" : "anthropic", "name" : "Anthropic", "class" : "anthropic", "managedByCodMate" : true, "envKey" : "ANTHROPIC_AUTH_TOKEN", "keyURL" : "https://console.anthropic.com/settings/keys", "docsURL" : "https://docs.anthropic.com/en/docs/about-claude/models", "connectors" : { "claudeCode" : { "baseURL" : "https://api.anthropic.com", "modelAliases" : { "default" : "claude-sonnet-4-5", "haiku" : "claude-haiku-4-5" } } }, "catalog" : { "models" : [ { "vendorModelId" : "claude-sonnet-4-5", "caps": { "reasoning": true, "tool_use": true, "long_context": true, "vision": true } }, { "vendorModelId" : "claude-haiku-4-5", "caps": { "reasoning": false, "tool_use": true, "long_context": true, "vision": true } }, { "vendorModelId" : "claude-opus-4-1", "caps": { "reasoning": true, "tool_use": true, "long_context": true, "vision": true } } ] }, "recommended" : { "defaultModelFor" : { "claudeCode" : "claude-sonnet-4-5" } } }, { "envKey" : "ZHIPUAI_API_KEY", "keyURL" : "https://open.bigmodel.cn/usercenter/apikeys", "docsURL" : "https://open.bigmodel.cn/dev/api", "connectors" : { "claudeCode" : { "modelAliases" : { "default" : "glm-4.6" }, "baseURL" : "https://open.bigmodel.cn/api/anthropic" }, "codex" : { "wireAPI" : "chat", "baseURL" : "https://open.bigmodel.cn/api/paas/v4/" } }, "name" : "GLM", "catalog" : { "models" : [ { "caps" : { "reasoning" : false, "tool_use" : true, "long_context" : false, "vision" : false }, "vendorModelId" : "glm-4.6" }, { "caps" : { "reasoning" : false, "tool_use" : true, "long_context" : false, "vision" : false }, "vendorModelId" : "glm-4.5" }, { "caps" : { "reasoning" : false, "tool_use" : true, "long_context" : false, "vision" : false }, "vendorModelId" : "glm-4.5-air" } ] }, "id" : "glm", "class" : "openai-compatible", "managedByCodMate" : true, "recommended" : { "defaultModelFor" : { "claudeCode" : "glm-4.6", "codex" : "glm-4.6" } } }, { "id" : "minimax", "name" : "MiniMax", "class" : "openai-compatible", "managedByCodMate" : true, "envKey" : "MINIMAX_API_KEY", "keyURL" : "https://platform.minimaxi.com/user-center/basic-information/interface-key", "docsURL" : "https://platform.minimaxi.com/docs/guides/models-intro", "connectors" : { "codex" : { "baseURL" : "https://api.minimaxi.com/v1", "wireAPI" : "chat", "requestMaxRetries" : 4, "streamMaxRetries" : 10, "streamIdleTimeoutMs" : 300000 }, "claudeCode" : { "baseURL" : "https://api.minimaxi.com/anthropic", "modelAliases" : { "default" : "MiniMax-M2", "haiku" : "MiniMax-M2" } } }, "catalog" : { "models" : [ { "vendorModelId" : "MiniMax-M2", "caps": { "reasoning": false, "tool_use": true, "long_context": true, "vision": false } } ] }, "recommended" : { "defaultModelFor" : { "codex" : "MiniMax-M2", "claudeCode" : "MiniMax-M2" } } }, { "id" : "deepseek", "name" : "DeepSeek", "class" : "openai-compatible", "managedByCodMate" : true, "envKey" : "DEEPSEEK_API_KEY", "keyURL" : "https://platform.deepseek.com/api_keys", "docsURL" : "https://api-docs.deepseek.com/zh-cn/", "connectors" : { "codex" : { "baseURL" : "https://api.deepseek.com/v1", "wireAPI" : "chat" }, "claudeCode" : { "baseURL" : "https://api.deepseek.com/anthropic", "modelAliases" : { "default" : "deepseek-chat", "haiku" : "deepseek-chat" }, "envHttpHeaders" : { "x-api-key" : "DEEPSEEK_API_KEY" } } }, "catalog" : { "models" : [ { "vendorModelId" : "deepseek-chat", "caps": { "reasoning": false, "tool_use": true, "long_context": true, "vision": false } }, { "vendorModelId" : "deepseek-reasoner", "caps": { "reasoning": true, "tool_use": true, "long_context": true, "vision": false } } ] }, "recommended" : { "defaultModelFor" : { "codex" : "deepseek-chat", "claudeCode" : "deepseek-chat" } } } ], "bindings" : { "activeProvider" : { }, "defaultModel" : { "codex" : "gpt-5.2-codex" } } } ================================================ FILE: payload/terminals.json ================================================ [ { "id": "iterm2", "title": "iTerm2", "bundleIdentifiers": ["com.googlecode.iterm2"], "managedByCodMate": true, "supportsCommand": true, "supportsDirectory": true }, { "id": "warp", "title": "Warp", "bundleIdentifiers": ["dev.warp.Warp-Stable", "dev.warp.Warp"], "managedByCodMate": true, "commandStyle": "warp", "supportsCommand": false, "supportsDirectory": true }, { "id": "ghostty", "title": "Ghostty", "bundleIdentifiers": ["com.mitchellh.ghostty"], "managedByCodMate": true, "supportsCommand": false, "supportsDirectory": true }, { "id": "kitty", "title": "Kitty", "bundleIdentifiers": ["net.kovidgoyal.kitty"], "managedByCodMate": true, "supportsCommand": false, "supportsDirectory": true } ] ================================================ FILE: scripts/BUILD.md ================================================ # CodMate Build Scripts (SwiftPM) This directory contains scripts for building CodMate using SwiftPM and packaging a notarized DMG. ## Quick Start ### Build the .app bundle ```bash VER=1.2.3 ./scripts/create-app-bundle.sh ``` ### Build a Developer ID DMG (optional notarization) ```bash VER=1.2.3 ./scripts/macos-build-notarized-dmg.sh ``` ## Script Overview ### 1) `create-app-bundle.sh` **Purpose**: Build a SwiftPM release binary, compile assets, and assemble a macOS .app bundle. **Outputs**: - `build/CodMate.app` (default, override with `APP_DIR`) **Notes**: - Compiles `assets/Assets.xcassets` with `xcrun actool` into `Assets.car` (includes AppIcon). - Copies bundled resources into `Contents/Resources`: - `payload/providers.json` - `payload/terminals.json` - `PrivacyInfo.xcprivacy` - `THIRD-PARTY-NOTICES.md` - `codmate-notify` helper into `Contents/Resources/bin/` **Usage Examples**: ```bash VER=1.2.3 ./scripts/create-app-bundle.sh ARCH_MATRIX="arm64" VER=1.2.3 ./scripts/create-app-bundle.sh APP_DIR=build/CodMate.app VER=1.2.3 ./scripts/create-app-bundle.sh ``` --- ### 2) `macos-build-notarized-dmg.sh` **Purpose**: Build and optionally notarize a Developer ID DMG for direct distribution. **Output**: `.dmg` files per architecture (e.g., `codmate-arm64.dmg`) **Usage Examples**: ```bash VER=1.2.3 ./scripts/macos-build-notarized-dmg.sh # Notarize with a keychain profile APPLE_NOTARY_PROFILE="AC_PROFILE" VER=1.2.3 ./scripts/macos-build-notarized-dmg.sh # Notarize with Apple ID APPLE_ID="your@apple.id" \ APPLE_PASSWORD="xxxx-xxxx-xxxx-xxxx" \ TEAM_ID="YOURTEAMID" \ VER=1.2.3 ./scripts/macos-build-notarized-dmg.sh ``` --- ### 3) `make notices` **Purpose**: Regenerate `THIRD-PARTY-NOTICES.md` from resolved dependencies. **Notes**: - Scans `Package.resolved` and local checkouts in `.build/checkouts`. - Fails if any dependency is missing a license file. --- ## Environment Variables ### Common | Variable | Default | Description | |----------|---------|-------------| | `VER` | _required_ | Marketing version (e.g., `1.2.3`) | | `BUILD_NUMBER_STRATEGY` | `date` | `date`, `git`, or `counter` | | `ARCH_MATRIX` | `arm64 x86_64` | Architectures to build | | `MIN_MACOS` | `13.5` | Minimum macOS version | | `BUILD_DIR` | `build` | Build workspace | | `APP_DIR` | `build/CodMate.app` | Output .app path | | `BUNDLE_ID` | `ai.umate.codmate` | Bundle identifier | | `OUTPUT_DIR` | `artifacts` | DMG output directory (e.g., `codmate-arm64.dmg`) | | `STRIP` | `1` | Set to `0` to disable binary stripping | | `STRIP_FLAGS` | `-x` | Flags passed to `strip` | ### Signing / Notarization | Variable | Default | Description | |----------|---------|-------------| | `SIGNING_CERT` | `Developer ID Application` | Signing certificate name | | `SANDBOX` | `off` | `on` to apply `assets/CodMate.entitlements` | | `APPLE_NOTARY_PROFILE` | - | Keychain profile for notarization | | `APPLE_ID` / `APPLE_PASSWORD` / `TEAM_ID` | - | Apple ID credentials for notarization | --- ## Prerequisites - macOS 13.5+ - Swift 6 toolchain - Xcode Command Line Tools (for `xcrun` + `actool`) - (Optional) `create-dmg` for a nicer DMG layout ================================================ FILE: scripts/build-libghostty-local.sh ================================================ #!/bin/bash set -euo pipefail # Build libghostty using local Ghostty repository # Usage: ./scripts/build-libghostty-local.sh [arch] # - arch: target architecture (aarch64|x86_64, default: build both) ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" VENDOR_DIR="${ROOT_DIR}/ghostty/Vendor" GHOSTTY_DIR="/Volumes/External/GitHub/ghostty" REQUESTED_ARCH="${1:-}" echo "Building libghostty from local repo..." echo "Ghostty dir: ${GHOSTTY_DIR}" # Check if Ghostty directory exists if [ ! -d "${GHOSTTY_DIR}" ]; then echo "Error: Ghostty directory not found at ${GHOSTTY_DIR}" >&2 exit 1 fi # Get current commit cd "${GHOSTTY_DIR}" REF="$(git rev-parse HEAD)" echo "Using Ghostty commit: ${REF}" # Setup temp dir for patches WORKDIR="$(mktemp -d)" trap 'rm -rf "${WORKDIR}"' EXIT # Copy Ghostty to temp dir (to avoid modifying original) echo "Copying Ghostty to temp build directory..." cp -R "${GHOSTTY_DIR}" "${WORKDIR}/ghostty" cd "${WORKDIR}/ghostty" # Patch build.zig to install libs on macOS perl -0pi -e 's/if \(!config\.target\.result\.os\.tag\.isDarwin\(\)\) \{/if (true) {/' "${WORKDIR}/ghostty/build.zig" # Patch to link Metal frameworks perl -0pi -e 's/lib\.linkFramework\("IOSurface"\);/lib.linkFramework("IOSurface");\n lib.linkFramework("Metal");\n lib.linkFramework("MetalKit");/g' "${WORKDIR}/ghostty/pkg/macos/build.zig" perl -0pi -e 's/module\.linkFramework\("IOSurface", \.\{\}\);/module.linkFramework("IOSurface", .{});\n module.linkFramework("Metal", .{});\n module.linkFramework("MetalKit", .{});/g' "${WORKDIR}/ghostty/pkg/macos/build.zig" # Patch bundle ID to use CodMate's sed -i '' 's/com\.mitchellh\.ghostty/ai.umate.codmate/g' "${WORKDIR}/ghostty/src/build_config.zig" ZIG_FLAGS=( -Dapp-runtime=none -Demit-xcframework=false -Demit-macos-app=false -Demit-exe=false -Demit-docs=false -Demit-webdata=false -Demit-helpgen=false -Demit-terminfo=true -Demit-termcap=false -Demit-themes=false -Doptimize=ReleaseFast -Dstrip ) build_arch() { local arch="$1" local outdir="${WORKDIR}/zig-out-${arch}" echo "Building for ${arch}..." >&2 (cd "${WORKDIR}/ghostty" && zig build "${ZIG_FLAGS[@]}" -Dtarget="${arch}-macos" -p "${outdir}") if [ ! -f "${outdir}/lib/libghostty.a" ]; then echo "Error: build failed - ${outdir}/lib/libghostty.a not found" >&2 exit 1 fi # Copy architecture-specific library to Vendor/lib/{arch}/ local arch_dir="${VENDOR_DIR}/lib/${arch}" mkdir -p "${arch_dir}" cp "${outdir}/lib/libghostty.a" "${arch_dir}/libghostty.a" # Strip debug symbols from the static library to reduce size # Note: This removes DWARF debug info but keeps symbol table for linking if command -v strip >/dev/null 2>&1; then # Use -S to strip only debug symbols, keeping symbol table for linking strip -S "${arch_dir}/libghostty.a" 2>/dev/null || true echo "Stripped debug symbols from ${arch} library" fi echo "Copied ${arch} library to ${arch_dir}/libghostty.a" echo "${outdir}/lib/libghostty.a" } # Determine which architectures to build ARCHES=() if [ -z "${REQUESTED_ARCH}" ]; then # Build both architectures ARCHES=(aarch64 x86_64) elif [ "${REQUESTED_ARCH}" = "aarch64" ] || [ "${REQUESTED_ARCH}" = "arm64" ]; then ARCHES=(aarch64) elif [ "${REQUESTED_ARCH}" = "x86_64" ]; then ARCHES=(x86_64) else echo "Error: Invalid architecture '${REQUESTED_ARCH}'. Use 'aarch64' or 'x86_64'." >&2 exit 1 fi # Build each requested architecture mkdir -p "${VENDOR_DIR}/lib" "${VENDOR_DIR}/include" for arch in "${ARCHES[@]}"; do build_arch "${arch}" done # Copy headers (preserve module.modulemap which is custom) if [ -d "${WORKDIR}/ghostty/include" ]; then rsync -a --exclude='module.modulemap' "${WORKDIR}/ghostty/include/" "${VENDOR_DIR}/include/" fi # Record version printf "%s\n" "${REF}" > "${VENDOR_DIR}/VERSION" echo "Done: Built ${#ARCHES[@]} architecture(s)" for arch in "${ARCHES[@]}"; do local arch_lib="${VENDOR_DIR}/lib/${arch}/libghostty.a" if [ -f "${arch_lib}" ]; then echo " ${arch}: $(lipo -info "${arch_lib}")" fi done ================================================ FILE: scripts/create-app-bundle.sh ================================================ #!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" APP_NAME="CodMate" BUILD_DIR="${BUILD_DIR:-$ROOT_DIR/build}" APP_DIR="${APP_DIR:-$BUILD_DIR/CodMate.app}" BIN_DIR="$BUILD_DIR/bin" ARCH_MATRIX=( ${ARCH_MATRIX:-arm64 x86_64} ) SWIFT_CONFIG="${SWIFT_CONFIG:-release}" BUNDLE_ID="${BUNDLE_ID:-ai.umate.codmate}" MIN_MACOS="${MIN_MACOS:-13.5}" VER="${VER:-}" BUILD_NUMBER_STRATEGY="${BUILD_NUMBER_STRATEGY:-date}" BUILD_NUMBER="${BUILD_NUMBER:-}" compute_build_number() { case "$BUILD_NUMBER_STRATEGY" in date) date +%Y%m%d%H%M ;; git) (cd "$ROOT_DIR" && git rev-list --count HEAD 2>/dev/null) || echo 1 ;; counter) local f="${BUILD_COUNTER_FILE:-$BUILD_DIR/build-number}" mkdir -p "$(dirname "$f")" local n=0 if [[ -f "$f" ]]; then n=$(cat "$f" 2>/dev/null || echo 0); fi n=$((n+1)) echo "$n" > "$f" echo "$n" ;; *) date +%Y%m%d%H%M ;; esac } if [[ -z "$VER" ]]; then echo "[error] VER is required. Example: VER=1.2.3 ./scripts/create-app-bundle.sh" >&2 exit 1 fi BASE_VERSION="$VER" if [[ -z "$BUILD_NUMBER" ]]; then BUILD_NUMBER="$(compute_build_number)" fi DISPLAY_VERSION="${BASE_VERSION}+${BUILD_NUMBER}" SWIFT_FLAGS=( -Xswiftc -DSYSTEM_PACKAGE_DARWIN -Xswiftc -DSUBPROCESS_ASYNCIO_DISPATCH -Xswiftc -enable-experimental-feature -Xswiftc LifetimeDependence -Xswiftc -enable-experimental-feature -Xswiftc NonescapableTypes ) STRIP="${STRIP:-1}" STRIP_FLAGS="${STRIP_FLAGS:--x}" if [[ -n "${EXTRA_SWIFT_FLAGS:-}" ]]; then # shellcheck disable=SC2206 EXTRA_FLAGS=( ${EXTRA_SWIFT_FLAGS} ) SWIFT_FLAGS+=("${EXTRA_FLAGS[@]}") fi mkdir -p "$BUILD_DIR" "$BIN_DIR" CODMATE_BINS=() NOTIFY_BINS=() # Note: SwiftPM's --arch flag automatically creates architecture-specific build directories # (e.g., .build/arm64-apple-macosx/ and .build/x86_64-apple-macosx/) # Each architecture's dependencies are compiled separately, ensuring no cross-architecture contamination. for arch in "${ARCH_MATRIX[@]}"; do # Map SwiftPM arch names to libghostty arch names GHOSTTY_ARCH="" case "$arch" in arm64) GHOSTTY_ARCH="aarch64" ;; x86_64) GHOSTTY_ARCH="x86_64" ;; *) GHOSTTY_ARCH="$arch" ;; esac # Setup architecture-specific libghostty library for linking VENDOR_LIB_DIR="$ROOT_DIR/ghostty/Vendor/lib" ARCH_LIB="$VENDOR_LIB_DIR/$GHOSTTY_ARCH/libghostty.a" LINK_LIB="$VENDOR_LIB_DIR/libghostty.a" if [ -f "$ARCH_LIB" ]; then # Copy architecture-specific library to the link location cp -f "$ARCH_LIB" "$LINK_LIB" echo "[libghostty] Using $GHOSTTY_ARCH library for $arch build" elif [ -f "$LINK_LIB" ]; then echo "[libghostty] Using existing library at $LINK_LIB (may be wrong architecture)" else echo "[warn] libghostty.a not found at $ARCH_LIB or $LINK_LIB" >&2 echo "[warn] Build may fail. Run ./scripts/build-libghostty-local.sh $GHOSTTY_ARCH first" >&2 fi echo "[build] swift build -c $SWIFT_CONFIG --arch $arch" swift build -c "$SWIFT_CONFIG" --arch "$arch" "${SWIFT_FLAGS[@]}" BIN_PATH="$(swift build -c "$SWIFT_CONFIG" --arch "$arch" --show-bin-path)" CODMATE_BIN="$BIN_PATH/CodMate" NOTIFY_BIN="$BIN_PATH/notify" if [[ ! -f "$CODMATE_BIN" ]]; then echo "[error] CodMate binary missing at $CODMATE_BIN" >&2 exit 1 fi if [[ ! -f "$NOTIFY_BIN" ]]; then echo "[info] notify binary missing; building product explicitly" swift build -c "$SWIFT_CONFIG" --arch "$arch" "${SWIFT_FLAGS[@]}" --product notify BIN_PATH="$(swift build -c "$SWIFT_CONFIG" --arch "$arch" --show-bin-path)" NOTIFY_BIN="$BIN_PATH/notify" if [[ ! -f "$NOTIFY_BIN" ]]; then echo "[error] notify binary missing at $NOTIFY_BIN" >&2 exit 1 fi fi # Verify binary architectures match expected arch (ensures no cross-architecture contamination) if command -v lipo >/dev/null 2>&1; then for BIN_TO_CHECK in "$CODMATE_BIN" "$NOTIFY_BIN"; do if [[ -f "$BIN_TO_CHECK" ]]; then BIN_ARCHS=$(lipo -info "$BIN_TO_CHECK" 2>/dev/null | sed 's/.*: //' || echo "") if [[ -n "$BIN_ARCHS" ]]; then if [[ "$BIN_ARCHS" != *"$arch"* ]]; then echo "[error] Binary $(basename "$BIN_TO_CHECK") architecture mismatch: expected $arch, got $BIN_ARCHS" >&2 exit 1 fi # Check if binary contains multiple architectures (should only have one) ARCH_COUNT=$(echo "$BIN_ARCHS" | wc -w | tr -d ' ') if [[ "$ARCH_COUNT" -gt 1 ]]; then echo "[error] Binary $(basename "$BIN_TO_CHECK") contains multiple architectures ($BIN_ARCHS), expected only $arch" >&2 exit 1 fi echo "[verify] $(basename "$BIN_TO_CHECK") architecture: $BIN_ARCHS (expected: $arch)" fi fi done fi CODMATE_BINS+=("$CODMATE_BIN") NOTIFY_BINS+=("$NOTIFY_BIN") echo "[ok] Built for $arch" echo " CodMate: $CODMATE_BIN" echo " notify: $NOTIFY_BIN" echo "" done if [[ ${#ARCH_MATRIX[@]} -eq 1 ]]; then cp -f "${CODMATE_BINS[0]}" "$BIN_DIR/CodMate" cp -f "${NOTIFY_BINS[0]}" "$BIN_DIR/notify" ARCH_SUFFIX="${ARCH_MATRIX[0]}" else ARCH_SUFFIX="universal" lipo -create "${CODMATE_BINS[@]}" -output "$BIN_DIR/CodMate" lipo -create "${NOTIFY_BINS[@]}" -output "$BIN_DIR/notify" fi chmod +x "$BIN_DIR/CodMate" "$BIN_DIR/notify" if [[ "$STRIP" == "1" ]]; then if command -v strip >/dev/null 2>&1; then echo "[strip] Stripping binaries ($STRIP_FLAGS)" strip $STRIP_FLAGS "$BIN_DIR/CodMate" "$BIN_DIR/notify" || true else echo "[warn] strip not found; skipping binary strip" fi fi echo "[bundle] Building $APP_NAME.app ($DISPLAY_VERSION, $ARCH_SUFFIX)" rm -rf "$APP_DIR" mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources/bin" cp -f "$BIN_DIR/CodMate" "$APP_DIR/Contents/MacOS/CodMate" cp -f "$BIN_DIR/notify" "$APP_DIR/Contents/Resources/bin/codmate-notify" chmod +x "$APP_DIR/Contents/MacOS/CodMate" "$APP_DIR/Contents/Resources/bin/codmate-notify" echo -n "APPL????" > "$APP_DIR/Contents/PkgInfo" INFO_SRC="$ROOT_DIR/assets/Info.plist" INFO_DST="$APP_DIR/Contents/Info.plist" if [[ ! -f "$INFO_SRC" ]]; then echo "[error] Info.plist not found at $INFO_SRC" >&2 exit 1 fi cp -f "$INFO_SRC" "$INFO_DST" /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $BUNDLE_ID" "$INFO_DST" /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $BASE_VERSION" "$INFO_DST" /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$INFO_DST" /usr/libexec/PlistBuddy -c "Set :LSMinimumSystemVersion $MIN_MACOS" "$INFO_DST" GIT_TAG="${GIT_TAG:-$(cd "$ROOT_DIR" && git describe --tags --abbrev=0 2>/dev/null || true)}" GIT_COMMIT="${GIT_COMMIT:-$(cd "$ROOT_DIR" && git rev-parse --short HEAD 2>/dev/null || true)}" GIT_DIRTY="${GIT_DIRTY:-}" if [[ -z "$GIT_DIRTY" ]]; then if (cd "$ROOT_DIR" && git diff --quiet --ignore-submodules --); then GIT_DIRTY="0" else GIT_DIRTY="1" fi fi plutil -replace CodMateGitTag -string "$GIT_TAG" "$INFO_DST" plutil -replace CodMateGitCommit -string "$GIT_COMMIT" "$INFO_DST" plutil -replace CodMateGitDirty -string "$GIT_DIRTY" "$INFO_DST" RESOURCES_DIR="$APP_DIR/Contents/Resources" if [[ -d "$ROOT_DIR/assets/Assets.xcassets" ]]; then if ! command -v xcrun >/dev/null 2>&1; then echo "[error] xcrun not found. Install Xcode Command Line Tools." >&2 exit 1 fi echo "[assets] Compiling asset catalog" xcrun actool \ "$ROOT_DIR/assets/Assets.xcassets" \ --compile "$RESOURCES_DIR" \ --platform macosx \ --minimum-deployment-target "$MIN_MACOS" \ --app-icon AppIcon \ --output-partial-info-plist "$BUILD_DIR/asset-info.plist" \ --notices --warnings fi if [[ -f "$ROOT_DIR/payload/providers.json" ]]; then cp -f "$ROOT_DIR/payload/providers.json" "$RESOURCES_DIR/providers.json" fi if [[ -f "$ROOT_DIR/payload/terminals.json" ]]; then cp -f "$ROOT_DIR/payload/terminals.json" "$RESOURCES_DIR/terminals.json" fi if [[ -f "$ROOT_DIR/payload/hook-variables.json" ]]; then mkdir -p "$RESOURCES_DIR/payload" cp -f "$ROOT_DIR/payload/hook-variables.json" "$RESOURCES_DIR/payload/hook-variables.json" fi if [[ -f "$ROOT_DIR/payload/hook-events.json" ]]; then mkdir -p "$RESOURCES_DIR/payload" cp -f "$ROOT_DIR/payload/hook-events.json" "$RESOURCES_DIR/payload/hook-events.json" fi if [[ -d "$ROOT_DIR/payload/commands" ]]; then mkdir -p "$RESOURCES_DIR/payload" cp -R "$ROOT_DIR/payload/commands" "$RESOURCES_DIR/payload/commands" fi if [[ -d "$ROOT_DIR/payload/prompts" ]]; then mkdir -p "$RESOURCES_DIR/payload" cp -R "$ROOT_DIR/payload/prompts" "$RESOURCES_DIR/payload/prompts" fi if [[ -d "$ROOT_DIR/payload/internal-skills" ]]; then mkdir -p "$RESOURCES_DIR/payload" cp -R "$ROOT_DIR/payload/internal-skills" "$RESOURCES_DIR/payload/internal-skills" fi if [[ -d "$ROOT_DIR/payload/knowledge" ]]; then mkdir -p "$RESOURCES_DIR/payload" cp -R "$ROOT_DIR/payload/knowledge" "$RESOURCES_DIR/payload/knowledge" fi if [[ -f "$ROOT_DIR/PrivacyInfo.xcprivacy" ]]; then cp -f "$ROOT_DIR/PrivacyInfo.xcprivacy" "$RESOURCES_DIR/PrivacyInfo.xcprivacy" fi if [[ -f "$ROOT_DIR/THIRD-PARTY-NOTICES.md" ]]; then cp -f "$ROOT_DIR/THIRD-PARTY-NOTICES.md" "$RESOURCES_DIR/THIRD-PARTY-NOTICES.md" fi if [[ "${SIGN_ADHOC:-}" == "1" ]]; then echo "[sign] Ad-hoc signing for local run (Notify Entitlements)" ENTITLEMENTS="$ROOT_DIR/assets/CodMate-Notify.entitlements" # Sign inner binaries first if [[ -f "$APP_DIR/Contents/Resources/bin/codmate-notify" ]]; then codesign --force --sign - --entitlements "$ENTITLEMENTS" --timestamp=none \ "$APP_DIR/Contents/Resources/bin/codmate-notify" fi codesign --force --sign - --entitlements "$ENTITLEMENTS" --timestamp=none \ "$APP_DIR/Contents/MacOS/CodMate" # Sign the bundle codesign --force --sign - --entitlements "$ENTITLEMENTS" --timestamp=none \ "$APP_DIR" fi echo "[ok] App bundle ready at $APP_DIR" ================================================ FILE: scripts/gen-third-party-notices.py ================================================ #!/usr/bin/env python3 import json import os import subprocess import sys ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) RESOLVED_PATH = os.path.join(ROOT, "Package.resolved") OUTPUT_PATH = os.path.join(ROOT, "THIRD-PARTY-NOTICES.md") LICENSE_FILES = [ "LICENSE", "LICENSE.txt", "LICENSE.md", "COPYING", "COPYING.txt", "COPYING.md", "LICENCE", "LICENCE.txt", "LICENCE.md", ] NOTICE_FILES = ["NOTICE", "NOTICE.txt", "NOTICE.md"] def run_git(args, cwd): try: result = subprocess.run( ["git"] + args, cwd=cwd, check=True, capture_output=True, text=True ) return result.stdout.strip() except Exception: return "" def repo_url_for_path(path): url = run_git(["config", "--get", "remote.origin.url"], cwd=path) return url if url else None def read_file(path): with open(path, "r", encoding="utf-8", errors="ignore") as f: return f.read().strip() def pick_first_existing(base_dir, names): for name in names: candidate = os.path.join(base_dir, name) if os.path.isfile(candidate): return candidate return None def checkout_dir_for_pin(identity, location): candidates = [] if identity: candidates.append(os.path.join(ROOT, ".build", "checkouts", identity)) if location: base = os.path.basename(location.rstrip("/")) if base.endswith(".git"): base = base[: -len(".git")] candidates.append(os.path.join(ROOT, ".build", "checkouts", base)) for c in candidates: if os.path.isdir(c): return c return None def version_label(state): if not state: return "unknown" if "version" in state: return state["version"] if "branch" in state and "revision" in state: return f'{state["branch"]}@{state["revision"][:7]}' if "revision" in state: return state["revision"][:7] return "unknown" def load_pins(): if not os.path.isfile(RESOLVED_PATH): print("ERROR: Package.resolved not found.", file=sys.stderr) sys.exit(1) with open(RESOLVED_PATH, "r", encoding="utf-8") as f: data = json.load(f) return data.get("pins", []) def main(): pins = load_pins() entries = [] for pin in pins: identity = pin.get("identity", "") location = pin.get("location", "") state = pin.get("state", {}) entries.append( { "name": identity, "repo": location, "version": version_label(state), "path": checkout_dir_for_pin(identity, location), } ) # Local dependency: SwiftTerm swiftterm_path = os.path.join(ROOT, "SwiftTerm") if os.path.isdir(swiftterm_path): entries.append( { "name": "SwiftTerm", "repo": repo_url_for_path(swiftterm_path) or "https://github.com/migueldeicaza/SwiftTerm", "version": run_git(["describe", "--tags", "--abbrev=0"], cwd=swiftterm_path) or run_git(["rev-parse", "--short", "HEAD"], cwd=swiftterm_path) or "local", "path": swiftterm_path, } ) # Deduplicate by name (keep first occurrence) seen = set() unique_entries = [] for e in entries: key = e["name"].lower() if key in seen: continue seen.add(key) unique_entries.append(e) unique_entries.sort(key=lambda x: x["name"].lower()) missing = [] sections = [] for e in unique_entries: name = e["name"] or "unknown" repo = e["repo"] or "unknown" version = e["version"] or "unknown" path = e["path"] license_path = None notice_path = None if path and os.path.isdir(path): license_path = pick_first_existing(path, LICENSE_FILES) notice_path = pick_first_existing(path, NOTICE_FILES) if not license_path: missing.append(name) header = [f"{name} ({version})", f"Repository: {repo}"] if license_path: header.append(f"License file: {os.path.basename(license_path)}") else: header.append("License file: NOT FOUND") body = [] if license_path: body.append(read_file(license_path)) if notice_path: body.append("") body.append(f"NOTICE ({os.path.basename(notice_path)})") body.append(read_file(notice_path)) sections.append("\n".join(header + [""] + body).strip()) out = [ "Third-Party Notices", "", "This document lists third-party components included in CodMate distributions, along with their licenses and attributions. The original license texts are reproduced or referenced below.", "", "If you distribute CodMate binaries, keep this file together with `LICENSE`.", "", "---", "", ] out.append("\n\n---\n\n".join(sections)) content = "\n".join(out).strip() + "\n" if missing: print("ERROR: Missing license files for:", ", ".join(sorted(missing)), file=sys.stderr) print("Hint: run `swift package resolve` and retry.", file=sys.stderr) sys.exit(1) with open(OUTPUT_PATH, "w", encoding="utf-8") as f: f.write(content) print(f"[ok] Updated {OUTPUT_PATH}") if __name__ == "__main__": main() ================================================ FILE: scripts/macos-build-notarized-dmg.sh ================================================ #!/usr/bin/env bash set -euo pipefail # CodMate macOS notarized DMG builder (SwiftPM) # - Builds app bundle via scripts/create-app-bundle.sh # - Signs app (Developer ID) # - Creates DMG # - Notarizes + staples (optional) # # Usage: # VER=1.2.3 ./scripts/macos-build-notarized-dmg.sh # # Optional overrides: # ARCH_MATRIX="arm64 x86_64" # OUTPUT_DIR=artifacts # SIGNING_CERT="Developer ID Application" # SANDBOX=on|off (default: off) # SKIP_NOTARIZATION=1 # APPLE_NOTARY_PROFILE="AC_PROFILE" # APPLE_ID / APPLE_PASSWORD / TEAM_ID ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" BUILD_DIR="${BUILD_DIR:-$ROOT_DIR/build}" OUTPUT_DIR="${OUTPUT_DIR:-$ROOT_DIR/artifacts}" APP_NAME="CodMate" APP_DIR="${APP_DIR:-$BUILD_DIR/CodMate.app}" ENTITLEMENTS_PATH="${ENTITLEMENTS_PATH:-$ROOT_DIR/assets/CodMate.entitlements}" ARCH_MATRIX=( ${ARCH_MATRIX:-arm64 x86_64} ) MIN_MACOS="${MIN_MACOS:-13.5}" BUNDLE_ID="${BUNDLE_ID:-ai.umate.codmate}" mkdir -p "$BUILD_DIR" "$OUTPUT_DIR" # Load .env without overriding explicitly exported vars ENV_FILE="$ROOT_DIR/.env" if [[ -f "$ENV_FILE" ]]; then while IFS='=' read -r k v; do [[ -z "${k// /}" ]] && continue [[ "$k" =~ ^# ]] && continue case "$k" in APPLE_SIGNING_IDENTITY|APPLE_ID|APPLE_PASSWORD|APPLE_TEAM_ID) if [[ -z "${!k:-}" ]]; then v="${v%\r}"; v="${v%\n}"; v="${v%\"}"; v="${v#\"}" export "$k=$v" fi ;; *) ;; esac done < "$ENV_FILE" fi VER="${VER:-}" if [[ -z "$VER" ]]; then echo "[error] VER is required. Example: VER=1.2.3 ./scripts/macos-build-notarized-dmg.sh" >&2 exit 1 fi BUILD_NUMBER_STRATEGY="${BUILD_NUMBER_STRATEGY:-date}" compute_build_number() { case "$BUILD_NUMBER_STRATEGY" in date) date +%Y%m%d%H%M ;; git) (cd "$ROOT_DIR" && git rev-list --count HEAD 2>/dev/null) || echo 1 ;; counter) local f="${BUILD_COUNTER_FILE:-$BUILD_DIR/build-number}" mkdir -p "$(dirname "$f")" local n=0 if [[ -f "$f" ]]; then n=$(cat "$f" 2>/dev/null || echo 0); fi n=$((n+1)) echo "$n" > "$f" echo "$n" ;; *) date +%Y%m%d%H%M ;; esac } BUILD_NUMBER="${BUILD_NUMBER:-$(compute_build_number)}" DISPLAY_VERSION="${VER}+${BUILD_NUMBER}" SANDBOX="${SANDBOX:-off}" TEAM_ID="${TEAM_ID:-${APPLE_TEAM_ID:-}}" SIGNING_CERT="${SIGNING_CERT:-${APPLE_SIGNING_IDENTITY:-}}" if [[ -z "$SIGNING_CERT" ]]; then SIGNING_CERT="Developer ID Application" fi CODESIGN_IDENTITY="" if security find-identity -v -p codesigning | grep -q "$SIGNING_CERT"; then CODESIGN_IDENTITY="$(security find-identity -v -p codesigning | grep "$SIGNING_CERT" | head -1 | sed 's/.*"\(.*\)".*/\1/')" else echo "[warn] Signing identity not found ($SIGNING_CERT). Falling back to ad-hoc signature." fi unset ENTITLEMENTS_ARG if [[ "$SANDBOX" == "on" ]]; then ENTITLEMENTS_ARG=(--entitlements "$ENTITLEMENTS_PATH") fi NOTARY_MODE="${NOTARY_MODE:-auto}" if [[ "${SKIP_NOTARIZATION:-}" == "1" || "$NOTARY_MODE" == "none" ]]; then NOTARY_MODE="none" elif [[ "$NOTARY_MODE" == "profile" ]]; then if [[ -z "${APPLE_NOTARY_PROFILE:-}" ]]; then echo "[error] NOTARY_MODE=profile requires APPLE_NOTARY_PROFILE" >&2 exit 1 fi elif [[ "$NOTARY_MODE" == "apple" ]]; then if [[ -z "${APPLE_ID:-}" || -z "${APPLE_PASSWORD:-}" || -z "$TEAM_ID" ]]; then echo "[error] NOTARY_MODE=apple requires APPLE_ID, APPLE_PASSWORD, and TEAM_ID" >&2 exit 1 fi else NOTARY_MODE="none" if [[ -n "${APPLE_NOTARY_PROFILE:-}" ]]; then NOTARY_MODE="profile" elif [[ -n "${APPLE_ID:-}" && -n "${APPLE_PASSWORD:-}" && -n "$TEAM_ID" ]]; then NOTARY_MODE="apple" fi fi if [[ "$NOTARY_MODE" != "none" && -z "$CODESIGN_IDENTITY" ]]; then echo "[warn] Notarization requires a Developer ID signing identity. Disabling notarization." >&2 NOTARY_MODE="none" fi echo "==========================================" echo " CodMate - Developer ID DMG (SwiftPM)" echo "==========================================" echo "Version: $DISPLAY_VERSION" echo "Architectures: ${ARCH_MATRIX[*]}" echo "Output: $OUTPUT_DIR" echo "SANDBOX: $SANDBOX" echo "==========================================" build_dmg_for_arch() { local arch="$1" local arch_app_dir="$APP_DIR" local arch_suffix="$arch" local dmg_name="codmate-${arch_suffix}.dmg" local dmg_path="$OUTPUT_DIR/$dmg_name" local stage_dir="$BUILD_DIR/.stage-dmg-${arch_suffix}" local bundle_name="CodMate.app" if [[ ${#ARCH_MATRIX[@]} -gt 1 ]]; then arch_app_dir="$BUILD_DIR/CodMate-${arch_suffix}.app" fi echo "[build] Building app bundle for $arch_suffix" VER="$VER" \ BUILD_NUMBER="$BUILD_NUMBER" \ ARCH_MATRIX="$arch" \ APP_DIR="$arch_app_dir" \ BUILD_DIR="$BUILD_DIR" \ MIN_MACOS="$MIN_MACOS" \ BUNDLE_ID="$BUNDLE_ID" \ "$ROOT_DIR/scripts/create-app-bundle.sh" if [[ ! -d "$arch_app_dir" ]]; then echo "[error] App bundle not found at $arch_app_dir" >&2 exit 1 fi if [[ -n "$CODESIGN_IDENTITY" ]]; then echo "[sign] Signing with: $CODESIGN_IDENTITY" xattr -cr "$arch_app_dir" if [[ -f "$arch_app_dir/Contents/Resources/bin/codmate-notify" ]]; then codesign --force --sign "$CODESIGN_IDENTITY" --options runtime --timestamp \ ${ENTITLEMENTS_ARG[@]+"${ENTITLEMENTS_ARG[@]}"} \ "$arch_app_dir/Contents/Resources/bin/codmate-notify" fi codesign --force --sign "$CODESIGN_IDENTITY" --options runtime --timestamp \ ${ENTITLEMENTS_ARG[@]+"${ENTITLEMENTS_ARG[@]}"} \ "$arch_app_dir/Contents/MacOS/CodMate" codesign --force --sign "$CODESIGN_IDENTITY" --options runtime --timestamp \ ${ENTITLEMENTS_ARG[@]+"${ENTITLEMENTS_ARG[@]}"} \ "$arch_app_dir" codesign --verify --deep --strict --verbose=2 "$arch_app_dir" else echo "[warn] No signing identity found. Using ad-hoc signature." codesign --force --deep --sign - "$arch_app_dir" fi rm -rf "$stage_dir" mkdir -p "$stage_dir" cp -R "$arch_app_dir" "$stage_dir/$bundle_name" ln -s /Applications "$stage_dir/Applications" if command -v create-dmg >/dev/null 2>&1; then echo "[dmg] Using create-dmg" if (cd "$stage_dir" && create-dmg \ --volname "$APP_NAME" \ --window-pos 200 120 \ --window-size 600 400 \ --icon-size 100 \ --icon "$bundle_name" 175 120 \ --hide-extension "$bundle_name" \ --app-drop-link 425 120 \ "$dmg_path" \ "$bundle_name"); then : else echo "[warn] create-dmg failed; falling back to hdiutil" hdiutil create -volname "$APP_NAME" -srcfolder "$stage_dir" -ov -format UDZO -imagekey zlib-level=9 "$dmg_path" fi else echo "[dmg] Using hdiutil" hdiutil create -volname "$APP_NAME" -srcfolder "$stage_dir" -ov -format UDZO -imagekey zlib-level=9 "$dmg_path" fi rm -rf "$stage_dir" if [[ ! -f "$dmg_path" ]]; then echo "[error] DMG not created: $dmg_path" >&2 exit 1 fi local notarized=0 case "$NOTARY_MODE" in profile) echo "[notary] Submitting with profile ${APPLE_NOTARY_PROFILE:-}" notarized=1 xcrun notarytool submit "$dmg_path" --keychain-profile "${APPLE_NOTARY_PROFILE:-}" --wait xcrun stapler staple "$dmg_path" || true xcrun stapler staple "$arch_app_dir" || true ;; apple) echo "[notary] Submitting with Apple ID" notarized=1 xcrun notarytool submit "$dmg_path" \ --apple-id "${APPLE_ID:-}" \ --team-id "$TEAM_ID" \ --password "${APPLE_PASSWORD:-}" \ --wait xcrun stapler staple "$dmg_path" || true xcrun stapler staple "$arch_app_dir" || true ;; *) echo "[notary] Skipping notarization (credentials not provided)" ;; esac if [[ "$notarized" == "1" ]]; then echo "[verify] Validating notarization" xcrun stapler validate "$dmg_path" xcrun stapler validate "$arch_app_dir" fi echo "[ok] DMG ready: $dmg_path" } if [[ ${#ARCH_MATRIX[@]} -eq 1 ]]; then build_dmg_for_arch "${ARCH_MATRIX[0]}" else for arch in "${ARCH_MATRIX[@]}"; do build_dmg_for_arch "$arch" done fi ================================================ FILE: scripts/test-commands-sync.sh ================================================ #!/bin/bash # Test script for Commands management system # This script validates the commands sync functionality set -e echo "🧪 Testing Commands Management System" echo "=======================================" echo "" # Colors for output GREEN='\033[0;32m' BLUE='\033[0;34m' YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m' # No Color # Test directories CODMATE_DIR="$HOME/.codmate" CLAUDE_DIR="$HOME/.claude/commands" CODEX_DIR="$HOME/.codex/prompts" GEMINI_DIR="$HOME/.gemini/commands" # Step 1: Check configuration file echo -e "${BLUE}Step 1: Checking configuration file${NC}" if [ -f "$CODMATE_DIR/commands.json" ]; then echo -e "${GREEN}✓ Configuration file exists${NC}" COMMAND_COUNT=$(cat "$CODMATE_DIR/commands.json" | grep -c '"id"' || echo "0") echo -e " Found $COMMAND_COUNT commands" else echo -e "${RED}✗ Configuration file not found${NC}" exit 1 fi echo "" # Step 2: Verify JSON structure echo -e "${BLUE}Step 2: Validating JSON structure${NC}" if command -v jq &> /dev/null; then if jq empty "$CODMATE_DIR/commands.json" 2>/dev/null; then echo -e "${GREEN}✓ Valid JSON format${NC}" else echo -e "${RED}✗ Invalid JSON format${NC}" exit 1 fi else echo -e "${YELLOW}⚠ jq not installed, skipping JSON validation${NC}" fi echo "" # Step 3: Check command fields echo -e "${BLUE}Step 3: Checking command fields${NC}" if command -v jq &> /dev/null; then # Check first command has required fields FIRST_CMD=$(jq '.[0]' "$CODMATE_DIR/commands.json") REQUIRED_FIELDS=("id" "name" "description" "prompt" "targets" "isEnabled" "source" "installedAt") for field in "${REQUIRED_FIELDS[@]}"; do if echo "$FIRST_CMD" | jq -e "has(\"$field\")" > /dev/null; then echo -e "${GREEN}✓ Field '$field' present${NC}" else echo -e "${RED}✗ Field '$field' missing${NC}" exit 1 fi done else echo -e "${YELLOW}⚠ jq not installed, skipping field validation${NC}" fi echo "" # Step 4: Manual sync test (simulated) echo -e "${BLUE}Step 4: Testing sync directories${NC}" # Create test directories mkdir -p "$CLAUDE_DIR" mkdir -p "$CODEX_DIR" mkdir -p "$GEMINI_DIR" echo -e "${GREEN}✓ Sync directories created/verified${NC}" echo " Claude Code: $CLAUDE_DIR" echo " Codex CLI: $CODEX_DIR" echo " Gemini CLI: $GEMINI_DIR" echo "" # Step 5: Check for existing synced commands (if any) echo -e "${BLUE}Step 5: Checking for synced commands${NC}" CLAUDE_COUNT=$(find "$CLAUDE_DIR" -name "*.md" 2>/dev/null | wc -l | tr -d ' ') CODEX_COUNT=$(find "$CODEX_DIR" -name "*.md" 2>/dev/null | wc -l | tr -d ' ') GEMINI_COUNT=$(find "$GEMINI_DIR" -name "*.toml" 2>/dev/null | wc -l | tr -d ' ') echo -e " Claude Code commands: $CLAUDE_COUNT .md files" echo -e " Codex CLI commands: $CODEX_COUNT .md files" echo -e " Gemini CLI commands: $GEMINI_COUNT .toml files" echo "" # Step 6: Display sample command echo -e "${BLUE}Step 6: Sample command preview${NC}" if command -v jq &> /dev/null; then echo "First command:" jq '.[0] | {id, name, description, targets}' "$CODMATE_DIR/commands.json" else echo "Install 'jq' to see command preview" fi echo "" # Step 7: Instructions echo -e "${BLUE}Step 7: Next steps${NC}" echo "To enable automatic sync:" echo " 1. Launch CodMate application" echo " 2. Go to Settings → Extensions → Commands" echo " 3. Click 'Sync Now' button" echo "" echo "To verify sync manually, check these directories:" echo " - $CLAUDE_DIR" echo " - $CODEX_DIR" echo " - $GEMINI_DIR" echo "" # Summary echo -e "${GREEN}✓ All basic tests passed!${NC}" echo "" echo "Commands system is ready for use." echo "Run CodMate and navigate to Settings → Extensions → Commands to manage your commands." ================================================ FILE: services/AppLogger.swift ================================================ import Foundation import os.log import OSLog /// Unified logging system that outputs to both console (for `make debug` mode) /// and the Status Bar UI for in-app visibility. @MainActor final class AppLogger { static let shared = AppLogger() private let subsystem = Bundle.main.bundleIdentifier ?? "com.codmate" private var loggers: [String: Logger] = [:] private init() {} private func logger(for category: String) -> Logger { if let existing = loggers[category] { return existing } let logger = Logger(subsystem: subsystem, category: category) loggers[category] = logger return logger } // MARK: - Public API func info(_ message: String, source: String? = nil) { log(message, level: .info, source: source) } func success(_ message: String, source: String? = nil) { log(message, level: .success, source: source) } func warning(_ message: String, source: String? = nil) { log(message, level: .warning, source: source) } func error(_ message: String, source: String? = nil) { log(message, level: .error, source: source) } func log(_ message: String, level: StatusBarLogLevel = .info, source: String? = nil) { let category = source ?? "App" // Output to console for `make debug` mode let prefix: String switch level { case .info: prefix = "ℹ️" case .success: prefix = "✅" case .warning: prefix = "⚠️" case .error: prefix = "❌" } let osLog = logger(for: category) switch level { case .info: osLog.info("[\(category)] \(message)") case .success: osLog.info("[\(category)] \(message)") case .warning: osLog.warning("[\(category)] \(message)") case .error: osLog.error("[\(category)] \(message)") } // Also print to stderr for immediate visibility in debug console #if DEBUG NSLog("%@ [%@] %@", prefix, category, message) #endif // Post to Status Bar for in-app visibility StatusBarLogStore.shared.post(message, level: level, source: source) } // MARK: - Task tracking func beginTask(_ message: String, source: String? = nil) -> String { let category = source ?? "App" #if DEBUG NSLog("🔄 [%@] %@", category, message) #endif logger(for: category).info("[\(category)] \(message)") return StatusBarLogStore.shared.beginTask(message, level: .info, source: source) } func endTask(_ token: String, message: String? = nil, level: StatusBarLogLevel = .success, source: String? = nil) { if let message { let category = source ?? "App" let prefix: String switch level { case .info: prefix = "ℹ️" case .success: prefix = "✅" case .warning: prefix = "⚠️" case .error: prefix = "❌" } #if DEBUG NSLog("%@ [%@] %@", prefix, category, message) #endif } StatusBarLogStore.shared.endTask(token, message: message, level: level, source: source) } } // MARK: - Convenience global functions @MainActor func logInfo(_ message: String, source: String? = nil) { AppLogger.shared.info(message, source: source) } @MainActor func logSuccess(_ message: String, source: String? = nil) { AppLogger.shared.success(message, source: source) } @MainActor func logWarning(_ message: String, source: String? = nil) { AppLogger.shared.warning(message, source: source) } @MainActor func logError(_ message: String, source: String? = nil) { AppLogger.shared.error(message, source: source) } ================================================ FILE: services/AuthorizationHub.swift ================================================ import AppKit import Foundation /// Centralized authorization manager for security-scoped access. /// Wraps SecurityScopedBookmarks and provides consistent prompts for common operations. @MainActor final class AuthorizationHub { static let shared = AuthorizationHub() enum Purpose: String { case gitReviewRepo = "Git Review" case cliConsoleCwd = "CLI Console Working Directory" case generalAccess = "File Access" } private init() {} var sandboxOn: Bool { SecurityScopedBookmarks.shared.isSandboxed } /// Returns true if access can be started immediately without prompting (or sandbox is off). /// When true, this also starts the security-scoped access session. func canAccessNow(directory: URL) -> Bool { guard sandboxOn else { return true } return SecurityScopedBookmarks.shared.startAccessDynamic(for: directory) } /// Ensure access to a directory. If a dynamic bookmark exists, starts access and returns. /// Otherwise prompts user to authorize the directory (or a parent) via NSOpenPanel. /// /// - Parameters: /// - directory: The target directory to access. /// - purpose: A short label for the prompt UI. /// - message: Optional message; a sensible default is shown when nil. func ensureDirectoryAccessOrPrompt(directory: URL, purpose: Purpose, message: String? = nil) { guard sandboxOn else { return } // Non-sandboxed builds don't need bookmarks // Try to start access with existing bookmark first if SecurityScopedBookmarks.shared.startAccessDynamic(for: directory) { print("[AuthorizationHub] Successfully started access for: \(directory.path)") return } print("[AuthorizationHub] No existing bookmark for: \(directory.path), prompting user...") let panel = NSOpenPanel() panel.canChooseFiles = false panel.canChooseDirectories = true panel.allowsMultipleSelection = false panel.directoryURL = directory let defaultMsg = "Authorize this folder for \(purpose.rawValue)" panel.message = message ?? defaultMsg panel.prompt = "Authorize" panel.begin { response in if response == .OK, let url = panel.url { print("[AuthorizationHub] User authorized: \(url.path)") SecurityScopedBookmarks.shared.saveDynamic(url: url) // Immediately start accessing the authorized directory let success = SecurityScopedBookmarks.shared.startAccessDynamic(for: url) print("[AuthorizationHub] Start access after authorization: \(success)") // Also try to start access for the originally requested directory // (in case user selected a parent directory) if url.path != directory.path { let originalSuccess = SecurityScopedBookmarks.shared.startAccessDynamic(for: directory) print("[AuthorizationHub] Start access for original directory: \(originalSuccess)") } NotificationCenter.default.post(name: .codMateRepoAuthorizationChanged, object: nil) } else { print("[AuthorizationHub] User cancelled authorization") } } } /// Request authorization and wait for result synchronously (blocks current thread) /// Use this when you need to ensure access before proceeding func ensureDirectoryAccessOrPromptSync(directory: URL, purpose: Purpose, message: String? = nil) -> Bool { guard sandboxOn else { return true } // Try existing bookmark first if SecurityScopedBookmarks.shared.startAccessDynamic(for: directory) { return true } let panel = NSOpenPanel() panel.canChooseFiles = false panel.canChooseDirectories = true panel.allowsMultipleSelection = false panel.directoryURL = directory let defaultMsg = "Authorize this folder for \(purpose.rawValue)" panel.message = message ?? defaultMsg panel.prompt = "Authorize" let response = panel.runModal() guard response == .OK, let url = panel.url else { return false } SecurityScopedBookmarks.shared.saveDynamic(url: url) let success = SecurityScopedBookmarks.shared.startAccessDynamic(for: url) // Also try original directory if different if url.path != directory.path { _ = SecurityScopedBookmarks.shared.startAccessDynamic(for: directory) } NotificationCenter.default.post(name: .codMateRepoAuthorizationChanged, object: nil) return success } } ================================================ FILE: services/BrowserCookies/ChromeCookieImporter.swift ================================================ import CommonCrypto import Foundation import Security import SQLite3 /// Reads cookies from Chromium-based browsers' SQLite databases (Chrome, Brave, Edge, etc.) /// /// Chrome stores cookie values in an SQLite DB, and most values are encrypted (`encrypted_value` starts /// with `v10` on macOS). Decryption uses the "Chrome Safe Storage" password from the macOS Keychain and /// AES-CBC + PBKDF2. enum ChromeCookieImporter { private static let chromeSafeStorageKeyLock = NSLock() private nonisolated(unsafe) static var cachedChromeSafeStorageKey: Data? enum ImportError: LocalizedError { case cookieDBNotFound(path: String) case keychainDenied case sqliteFailed(message: String) var errorDescription: String? { switch self { case let .cookieDBNotFound(path): "Chrome Cookies DB not found at \(path)." case .keychainDenied: "macOS Keychain denied access to Chrome Safe Storage." case let .sqliteFailed(message): "Failed to read Chrome cookies: \(message)" } } } /// Extracts Claude sessionKey from Chrome cookies /// - Returns: sessionKey value if found, nil otherwise /// - Throws: ImportError if cookie database cannot be read static func extractClaudeSessionKey() throws -> String? { let roots = candidateHomes().map { home in home.appendingPathComponent("Library/Application Support/Google/Chrome") } var candidates: [URL] = [] for root in roots { candidates.append(contentsOf: chromeProfileCookieDBs(root: root).map(\.cookiesDB)) } if candidates.isEmpty { let display = roots.map(\.path).joined(separator: " • ") throw ImportError.cookieDBNotFound(path: display) } let chromeKey = try chromeSafeStorageKey() for dbURL in candidates { guard FileManager.default.fileExists(atPath: dbURL.path) else { continue } let cookies = try readCookiesFromLockedChromeDB( sourceDB: dbURL, key: chromeKey, matchingDomains: ["claude.ai"] ) if let sessionKey = cookies.first(where: { $0.name == "sessionKey" })?.value { return sessionKey } } return nil } // MARK: - DB copy helper private static func readCookiesFromLockedChromeDB( sourceDB: URL, key: Data, matchingDomains: [String] ) throws -> [CookieRecord] { // Chrome keeps the DB locked; copy the DB (and wal/shm when present) to a temp folder before reading let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent( "codmate-chrome-cookies-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) let copiedDB = tempDir.appendingPathComponent("Cookies") try FileManager.default.copyItem(at: sourceDB, to: copiedDB) for suffix in ["-wal", "-shm"] { let src = URL(fileURLWithPath: sourceDB.path + suffix) if FileManager.default.fileExists(atPath: src.path) { let dst = URL(fileURLWithPath: copiedDB.path + suffix) try? FileManager.default.copyItem(at: src, to: dst) } } defer { try? FileManager.default.removeItem(at: tempDir) } return try readCookies(fromDB: copiedDB.path, key: key, matchingDomains: matchingDomains) } // MARK: - SQLite read private static func readCookies( fromDB path: String, key: Data, matchingDomains: [String] ) throws -> [CookieRecord] { var db: OpaquePointer? if sqlite3_open_v2(path, &db, SQLITE_OPEN_READONLY, nil) != SQLITE_OK { throw ImportError.sqliteFailed(message: String(cString: sqlite3_errmsg(db))) } defer { sqlite3_close(db) } // Build WHERE clause dynamically for the given domains let conditions = matchingDomains.map { "host_key LIKE '%\($0)%'" }.joined(separator: " OR ") let sql = """ SELECT host_key, name, path, expires_utc, is_secure, is_httponly, value, encrypted_value FROM cookies WHERE \(conditions) """ var stmt: OpaquePointer? if sqlite3_prepare_v2(db, sql, -1, &stmt, nil) != SQLITE_OK { throw ImportError.sqliteFailed(message: String(cString: sqlite3_errmsg(db))) } defer { sqlite3_finalize(stmt) } var out: [CookieRecord] = [] while sqlite3_step(stmt) == SQLITE_ROW { let domain = String(cString: sqlite3_column_text(stmt, 0)) let name = String(cString: sqlite3_column_text(stmt, 1)) let path = String(cString: sqlite3_column_text(stmt, 2)) let expires = sqlite3_column_int64(stmt, 3) let isSecure = sqlite3_column_int(stmt, 4) != 0 let isHTTPOnly = sqlite3_column_int(stmt, 5) != 0 let plain = readTextColumn(stmt, index: 6) let enc = readBlobColumn(stmt, index: 7) let value: String if let plain, !plain.isEmpty { value = plain } else if let enc, !enc.isEmpty, let decrypted = decryptChromiumValue(enc, key: key) { value = decrypted } else { continue } let normalizedDomain = domain.hasPrefix(".") ? String(domain.dropFirst()) : domain out.append( CookieRecord( domain: normalizedDomain, name: name, path: path, value: value, expires: Date(timeIntervalSince1970: TimeInterval(expires)), isSecure: isSecure, isHTTPOnly: isHTTPOnly )) } return out } private static func readTextColumn(_ stmt: OpaquePointer?, index: Int32) -> String? { guard sqlite3_column_type(stmt, index) != SQLITE_NULL else { return nil } guard let c = sqlite3_column_text(stmt, index) else { return nil } return String(cString: c) } private static func readBlobColumn(_ stmt: OpaquePointer?, index: Int32) -> Data? { guard sqlite3_column_type(stmt, index) != SQLITE_NULL else { return nil } guard let bytes = sqlite3_column_blob(stmt, index) else { return nil } let count = Int(sqlite3_column_bytes(stmt, index)) return Data(bytes: bytes, count: count) } // MARK: - Keychain + PBKDF2 private static func chromeSafeStorageKey() throws -> Data { chromeSafeStorageKeyLock.lock() if let cached = cachedChromeSafeStorageKey { chromeSafeStorageKeyLock.unlock() return cached } chromeSafeStorageKeyLock.unlock() // Prefer the main Chrome label; fall back to common Chromium forks let labels: [(service: String, account: String)] = [ ("Chrome Safe Storage", "Chrome"), ("Chromium Safe Storage", "Chromium"), ("Brave Safe Storage", "Brave"), ("Microsoft Edge Safe Storage", "Microsoft Edge"), ("Vivaldi Safe Storage", "Vivaldi"), ] var password: String? for label in labels { if let p = findGenericPassword(service: label.service, account: label.account) { password = p break } } guard let password else { throw ImportError.keychainDenied } // Chromium macOS key derivation: PBKDF2-HMAC-SHA1 with salt "saltysalt", 1003 iterations, key length 16 let salt = Data("saltysalt".utf8) var key = Data(count: kCCKeySizeAES128) let keyLength = key.count let result = key.withUnsafeMutableBytes { keyBytes in password.utf8CString.withUnsafeBytes { passBytes in salt.withUnsafeBytes { saltBytes in CCKeyDerivationPBKDF( CCPBKDFAlgorithm(kCCPBKDF2), passBytes.bindMemory(to: Int8.self).baseAddress, passBytes.count - 1, saltBytes.bindMemory(to: UInt8.self).baseAddress, salt.count, CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1), 1003, keyBytes.bindMemory(to: UInt8.self).baseAddress, keyLength ) } } } guard result == kCCSuccess else { throw ImportError.keychainDenied } chromeSafeStorageKeyLock.lock() cachedChromeSafeStorageKey = key chromeSafeStorageKeyLock.unlock() return key } // Exposed for tests static func decryptChromiumValue(_ encryptedValue: Data, key: Data) -> String? { // macOS Chrome cookies typically have `v10` prefix and AES-CBC payload guard encryptedValue.count > 3 else { return nil } let prefix = encryptedValue.prefix(3) let prefixString = String(data: prefix, encoding: .utf8) let payload = encryptedValue.dropFirst(3) guard prefixString == "v10" else { return nil } let iv = Data(repeating: 0x20, count: kCCBlockSizeAES128) // 16 spaces var out = Data(count: payload.count + kCCBlockSizeAES128) var outLength: size_t = 0 let outCapacity = out.count let status = out.withUnsafeMutableBytes { outBytes in payload.withUnsafeBytes { inBytes in key.withUnsafeBytes { keyBytes in iv.withUnsafeBytes { ivBytes in CCCrypt( CCOperation(kCCDecrypt), CCAlgorithm(kCCAlgorithmAES), CCOptions(kCCOptionPKCS7Padding), keyBytes.baseAddress, key.count, ivBytes.baseAddress, inBytes.baseAddress, payload.count, outBytes.baseAddress, outCapacity, &outLength ) } } } } guard status == kCCSuccess else { return nil } out.count = outLength // Chromium's macOS cookie encryption prefixes 32 bytes of non-UTF8 data before the actual cookie value let candidate = out.count > 32 ? out.dropFirst(32) : out[...] if let decoded = String(data: Data(candidate), encoding: .utf8) { return cleanValue(decoded) } if let decoded = String(data: out, encoding: .utf8) { return cleanValue(decoded) } return nil } private static func cleanValue(_ value: String) -> String { // Strip leading control chars var i = value.startIndex while i < value.endIndex, value[i].unicodeScalars.allSatisfy({ $0.value < 0x20 }) { i = value.index(after: i) } return String(value[i...]) } private static func findGenericPassword(service: String, account: String) -> String? { let query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrService: service, kSecAttrAccount: account, kSecMatchLimit: kSecMatchLimitOne, kSecReturnData: true, ] var result: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &result) guard status == errSecSuccess else { return nil } guard let data = result as? Data else { return nil } return String(data: data, encoding: .utf8) } // MARK: - File paths private struct ChromeProfileCandidate { let label: String let cookiesDB: URL } private static func chromeProfileCookieDBs(root: URL) -> [ChromeProfileCandidate] { var out: [ChromeProfileCandidate] = [] // Default profile let defaultDB = root.appendingPathComponent("Default/Cookies") if FileManager.default.fileExists(atPath: defaultDB.path) { out.append(ChromeProfileCandidate(label: "Default", cookiesDB: defaultDB)) } // Numbered profiles: Profile 1, Profile 2, etc. for i in 1...10 { let profileDB = root.appendingPathComponent("Profile \(i)/Cookies") if FileManager.default.fileExists(atPath: profileDB.path) { out.append(ChromeProfileCandidate(label: "Profile \(i)", cookiesDB: profileDB)) } } return out } private static func candidateHomes() -> [URL] { var homes: [URL] = [] homes.append(FileManager.default.homeDirectoryForCurrentUser) if let userHome = NSHomeDirectoryForUser(NSUserName()) { homes.append(URL(fileURLWithPath: userHome)) } if let envHome = ProcessInfo.processInfo.environment["HOME"], !envHome.isEmpty { homes.append(URL(fileURLWithPath: envHome)) } // De-dup by path while keeping ordering var seen = Set() return homes.filter { home in let path = home.path guard !seen.contains(path) else { return false } seen.insert(path) return true } } } ================================================ FILE: services/BrowserCookies/CookieRecord.swift ================================================ import Foundation /// Represents a cookie record extracted from browser storage struct CookieRecord: Sendable { let domain: String let name: String let path: String let value: String let expires: Date? let isSecure: Bool let isHTTPOnly: Bool } ================================================ FILE: services/BrowserCookies/DataReader.swift ================================================ import Foundation /// Helper class for reading binary data with support for different endianness final class DataReader { let data: Data private(set) var offset: Int init(_ data: Data, offset: Int = 0) { self.data = data self.offset = offset } /// Read count bytes as ASCII string func readASCII(count: Int) -> String? { let d = self.read(count) return String(data: d, encoding: .ascii) } /// Read count bytes and advance offset func read(_ count: Int) -> Data { let end = min(self.offset + count, self.data.count) let slice = self.data[self.offset.. UInt32 { let d = self.read(4) return d.withUnsafeBytes { $0.load(as: UInt32.self).bigEndian } } /// Read UInt32 in Little-Endian format (used by Safari cookie pages/records) func readUInt32LE() -> UInt32 { let d = self.read(4) return d.withUnsafeBytes { $0.load(as: UInt32.self).littleEndian } } /// Read Double in Little-Endian format (used for timestamp fields) func readDoubleLE() -> Double { let d = self.read(8) let raw = d.withUnsafeBytes { $0.load(as: UInt64.self).littleEndian } return Double(bitPattern: raw) } } ================================================ FILE: services/BrowserCookies/SafariCookieImporter.swift ================================================ import Foundation /// Reads cookies from Safari's `Cookies.binarycookies` file (macOS). /// /// This is a best-effort parser for the documented `binarycookies` format: /// file header is big-endian; cookie pages and records are little-endian. enum SafariCookieImporter { enum ImportError: LocalizedError { case cookieFileNotFound case cookieFileNotReadable(path: String) case invalidFile var errorDescription: String? { switch self { case .cookieFileNotFound: "Safari cookie file not found." case let .cookieFileNotReadable(path): "Safari cookie file exists but is not readable (\(path)). CodMate needs Full Disk Access to read Safari cookies." case .invalidFile: "Safari cookie file is invalid." } } } /// Extracts Claude sessionKey from Safari cookies /// - Returns: sessionKey value if found, nil otherwise /// - Throws: ImportError if cookie file cannot be read static func extractClaudeSessionKey() throws -> String? { let cookies = try loadCookies(matchingDomains: ["claude.ai"]) return cookies.first(where: { $0.name == "sessionKey" })?.value } /// Loads cookies from Safari matching the given domains static func loadCookies( matchingDomains domains: [String], logger: ((String) -> Void)? = nil ) throws -> [CookieRecord] { let candidates = candidateCookieFiles() var lastNoPermission: String? var lastReadError: String? for url in candidates { do { let size = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)? .intValue logger?("[SafariCookie] Trying \(url.path) (\(size ?? -1) bytes)") let data = try Data(contentsOf: url) let records = try parseBinaryCookies(data: data) return records.filter { record in let d = record.domain.lowercased() return domains.contains { d.contains($0.lowercased()) } } } catch let error as CocoaError where error.code == .fileReadNoPermission { lastNoPermission = url.path logger?("[SafariCookie] Permission denied for \(url.path)") continue } catch { lastReadError = "\(url.path): \(error.localizedDescription)" logger?("[SafariCookie] Failed to read \(url.path): \(error.localizedDescription)") continue } } if let lastNoPermission { throw ImportError.cookieFileNotReadable(path: lastNoPermission) } if let lastReadError { logger?("[SafariCookie] Last error: \(lastReadError)") } throw ImportError.cookieFileNotFound } // MARK: - BinaryCookies parsing private static func parseBinaryCookies(data: Data) throws -> [CookieRecord] { let reader = DataReader(data) guard reader.readASCII(count: 4) == "cook" else { throw ImportError.invalidFile } let pageCount = Int(reader.readUInt32BE()) guard pageCount >= 0 else { throw ImportError.invalidFile } var pageSizes: [Int] = [] pageSizes.reserveCapacity(pageCount) for _ in 0.. [CookieRecord] { let r = DataReader(data) _ = r.readUInt32LE() // page header let cookieCount = Int(r.readUInt32LE()) if cookieCount <= 0 { return [] } var cookieOffsets: [Int] = [] cookieOffsets.reserveCapacity(cookieCount) for _ in 0..= 0, offset + 56 <= data.count else { return nil } return parseCookieRecord(data: data, offset: offset) } } private static func parseCookieRecord(data: Data, offset: Int) -> CookieRecord? { let r = DataReader(data, offset: offset) let size = Int(r.readUInt32LE()) guard size > 0, offset + size <= data.count else { return nil } _ = r.readUInt32LE() // unknown let flags = r.readUInt32LE() _ = r.readUInt32LE() // unknown let urlOffset = Int(r.readUInt32LE()) let nameOffset = Int(r.readUInt32LE()) let pathOffset = Int(r.readUInt32LE()) let valueOffset = Int(r.readUInt32LE()) _ = r.readUInt32LE() // commentOffset _ = r.readUInt32LE() // commentURL let expiresRef = r.readDoubleLE() _ = r.readDoubleLE() // creation let domain = readCString(data: data, base: offset, offset: urlOffset) ?? "" let name = readCString(data: data, base: offset, offset: nameOffset) ?? "" let path = readCString(data: data, base: offset, offset: pathOffset) ?? "/" let value = readCString(data: data, base: offset, offset: valueOffset) ?? "" if domain.isEmpty || name.isEmpty { return nil } let isSecure = (flags & 0x1) != 0 let isHTTPOnly = (flags & 0x4) != 0 let expires = expiresRef > 0 ? Date(timeIntervalSinceReferenceDate: expiresRef) : nil return CookieRecord( domain: normalizeDomain(domain), name: name, path: path, value: value, expires: expires, isSecure: isSecure, isHTTPOnly: isHTTPOnly ) } private static func readCString(data: Data, base: Int, offset: Int) -> String? { let start = base + offset guard start >= 0, start < data.count else { return nil } let end = data[start...].firstIndex(of: 0) ?? data.count guard end > start else { return nil } return String(data: data.subdata(in: start.. String { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.hasPrefix(".") { return String(trimmed.dropFirst()) } return trimmed } // MARK: - File paths private static func candidateCookieFiles() -> [URL] { let homes = candidateHomes() var urls: [URL] = [] urls.reserveCapacity(homes.count * 2) for home in homes { urls.append(home.appendingPathComponent("Library/Cookies/Cookies.binarycookies")) urls.append( home.appendingPathComponent( "Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies")) } // De-dup by path while keeping ordering (homeDirectoryForCurrentUser first) var seen = Set() return urls.filter { url in let path = url.path guard !seen.contains(path) else { return false } seen.insert(path) return true } } private static func candidateHomes() -> [URL] { var homes: [URL] = [] homes.append(FileManager.default.homeDirectoryForCurrentUser) if let userHome = NSHomeDirectoryForUser(NSUserName()) { homes.append(URL(fileURLWithPath: userHome)) } if let envHome = ProcessInfo.processInfo.environment["HOME"], !envHome.isEmpty { homes.append(URL(fileURLWithPath: envHome)) } // De-dup by path while keeping ordering var seen = Set() return homes.filter { home in let path = home.path guard !seen.contains(path) else { return false } seen.insert(path) return true } } } ================================================ FILE: services/CLIProxyBridge.swift ================================================ import Foundation import Network import SwiftUI /// A lightweight TCP proxy that forwards requests to CLIProxyAPI while /// ensuring fresh connections by forcing "Connection: close" on all requests. /// /// Architecture: /// Client (Cursor/VSCode) → CLIProxyBridge (User Port) → CLIProxyAPI (Internal Port) @MainActor final class CLIProxyBridge: ObservableObject { // MARK: - Properties private var listener: NWListener? private let stateQueue = DispatchQueue(label: "io.codmate.proxy-bridge-state") /// The port this proxy listens on (user-facing port) /// IMPORTANT: This should NOT have a default value. Always call configure() before start(). @Published private(set) var listenPort: UInt16 = 0 /// The port CLIProxyAPI runs on (internal port) /// IMPORTANT: This should NOT have a default value. Always call configure() before start(). @Published private(set) var targetPort: UInt16 = 0 /// Target host (always localhost) private let targetHost = "127.0.0.1" /// Whether the proxy bridge is currently running @Published private(set) var isRunning = false /// Last error message @Published private(set) var lastError: String? /// Statistics: total requests forwarded @Published private(set) var totalRequests: Int = 0 /// Statistics: active connections count @Published private(set) var activeConnections: Int = 0 // MARK: - Configuration /// Configure the proxy ports func configure(listenPort: UInt16, targetPort: UInt16) { self.listenPort = listenPort self.targetPort = targetPort } /// Calculate internal port from user port (offset by 10000) static func internalPort(from userPort: UInt16) -> UInt16 { let preferredPort = UInt32(userPort) + 10000 if preferredPort <= 65535 { return UInt16(preferredPort) } // Fallback: use modular offset within high port range (49152-65535) let highPortBase: UInt16 = 49152 let offset = userPort % 1000 return highPortBase + offset } // MARK: - Lifecycle func start() { guard !isRunning else { return } lastError = nil do { let parameters = NWParameters.tcp parameters.allowLocalEndpointReuse = true guard let port = NWEndpoint.Port(rawValue: listenPort) else { lastError = "Invalid port: \(listenPort)" return } listener = try NWListener(using: parameters, on: port) listener?.stateUpdateHandler = { [weak self] state in Task { @MainActor [weak self] in self?.handleListenerState(state) } } listener?.newConnectionHandler = { [weak self] connection in Task { @MainActor [weak self] in self?.handleNewConnection(connection) } } listener?.start(queue: .global(qos: .userInitiated)) } catch { lastError = error.localizedDescription } } func stop() { stateQueue.sync { listener?.cancel() listener = nil } isRunning = false } // MARK: - State Handling private func handleListenerState(_ state: NWListener.State) { switch state { case .ready: isRunning = true case .failed(let error): isRunning = false lastError = error.localizedDescription case .cancelled: isRunning = false default: break } } // MARK: - Connection Handling private func handleNewConnection(_ connection: NWConnection) { activeConnections += 1 totalRequests += 1 let connectionId = totalRequests let startTime = Date() connection.stateUpdateHandler = { [weak self] state in if case .cancelled = state { Task { @MainActor [weak self] in self?.activeConnections -= 1 } } else if case .failed = state { Task { @MainActor [weak self] in self?.activeConnections -= 1 } } } connection.start(queue: .global(qos: .userInitiated)) receiveRequest( from: connection, connectionId: connectionId, startTime: startTime, accumulatedData: Data() ) } // MARK: - Request Processing private nonisolated func receiveRequest( from connection: NWConnection, connectionId: Int, startTime: Date, accumulatedData: Data ) { connection.receive(minimumIncompleteLength: 1, maximumLength: 1048576) { [weak self] data, _, isComplete, error in guard let self = self else { return } if error != nil { connection.cancel() return } guard let data = data, !data.isEmpty else { if isComplete { connection.cancel() } return } var newData = accumulatedData newData.append(data) // Simple HTTP header parsing to find double CRLF if let requestString = String(data: newData, encoding: .utf8), let headerEndRange = requestString.range(of: "\r\n\r\n") { let headerEndIndex = requestString.distance(from: requestString.startIndex, to: headerEndRange.upperBound) // Content-Length check let headerPart = String(requestString.prefix(headerEndIndex)) if let lenLine = headerPart.components(separatedBy: "\r\n").first(where: { $0.lowercased().hasPrefix("content-length:") }), let lenVal = Int(lenLine.components(separatedBy: ":")[1].trimmingCharacters(in: .whitespaces)) { let bodyLen = newData.count - headerEndIndex if bodyLen < lenVal { // Need more data self.receiveRequest(from: connection, connectionId: connectionId, startTime: startTime, accumulatedData: newData) return } } self.processRequest(data: newData, connection: connection, connectionId: connectionId) } else if !isComplete { self.receiveRequest(from: connection, connectionId: connectionId, startTime: startTime, accumulatedData: newData) } else { // Malformed or incomplete self.processRequest(data: newData, connection: connection, connectionId: connectionId) } } } private nonisolated func processRequest(data: Data, connection: NWConnection, connectionId: Int) { guard let requestString = String(data: data, encoding: .utf8) else { connection.cancel() return } let lines = requestString.components(separatedBy: "\r\n") guard let requestLine = lines.first else { connection.cancel() return } let parts = requestLine.components(separatedBy: " ") guard parts.count >= 3 else { connection.cancel() return } let method = parts[0] let path = parts[1] let version = parts[2] // Parse Headers var headers: [(String, String)] = [] for line in lines.dropFirst() { if line.isEmpty { break } guard let idx = line.firstIndex(of: ":") else { continue } let k = String(line[.. 0 ? UInt16(p) : Self.defaultPort } private var process: Process? private var loginProcess: Process? private var loginInputPipe: Pipe? private var loginProvider: LocalAuthProvider? private var loginCancellationRequested = false private var openedLoginURL: URL? private let proxyBridge = CLIProxyBridge() // Paths private let binaryPath: String private let configPath: String private let authDir: String private let managementKey: String private var brewCommandPath: String? // Default port configuration (nonisolated because it's a constant that can be safely accessed from any context) nonisolated static let defaultPort: UInt16 = 8317 private static let publicAPIKeyDefaultsKey = "CLIProxyPublicAPIKey" private static let publicAPIKeyPrefix = "cm" private static let publicAPIKeyLength = 36 private static let localModelsCacheKey = "CLIProxyLocalModelsCache" private static let localModelsCacheTimestampKey = "CLIProxyLocalModelsCacheTimestamp" private static let localModelsCacheTTL: TimeInterval = 300 private var cachedLocalModels: [LocalModel] = [] private var cachedLocalModelsTimestamp: Date? // Cache for model -> provider name mapping (built from config.yaml) // This compensates for CLIProxyAPI not setting provider field correctly private var modelToProviderNameCache: [String: String] = [:] // Constants private static let githubRepo = "router-for-me/CLIProxyAPIPlus" private static let binaryName = "CLIProxyAPI" private var internalPort: UInt16 { CLIProxyBridge.internalPort(from: port) } init() { // Setup paths in Application Support (for binary only) let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! let codMateDir = appSupport.appendingPathComponent("CodMate") let binDir = codMateDir.appendingPathComponent("bin", isDirectory: true) try? FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) let homeDir = FileManager.default.homeDirectoryForCurrentUser // Config and auth now live in ~/.codmate/cliproxyapi for easier management let cliproxyapiDir = homeDir.appendingPathComponent(".codmate/cliproxyapi", isDirectory: true) try? FileManager.default.createDirectory(at: cliproxyapiDir, withIntermediateDirectories: true) self.binaryPath = binDir.appendingPathComponent(Self.binaryName).path self.configPath = cliproxyapiDir.appendingPathComponent("config.yaml").path self.authDir = homeDir.appendingPathComponent(".codmate/auth").path // Persistent Management Key if let savedKey = UserDefaults.standard.string(forKey: "CLIProxyManagementKey") { self.managementKey = savedKey } else { self.managementKey = UUID().uuidString UserDefaults.standard.set(self.managementKey, forKey: "CLIProxyManagementKey") } try? FileManager.default.createDirectory(atPath: authDir, withIntermediateDirectories: true) ensureConfigExists() // Perform initial detection performInitialDetection() } // MARK: - Binary Detection private func performInitialDetection() { let path = CLIEnvironment.resolvedPATHForCLI() // First, check if CodMate's own installation exists if FileManager.default.fileExists(atPath: binaryPath) { detectedBinaryPath = binaryPath binarySource = .codmate appendLog("Using CodMate's built-in installation at: \(binaryPath)\n") return } // Then, detect cliproxyapi binary in PATH guard let detectedPath = CLIEnvironment.resolveExecutablePath("cliproxyapi", path: path) else { binarySource = .none detectedBinaryPath = nil appendLog("No cliproxyapi binary detected in PATH or CodMate installation.\n") return } // Verify the detected path actually exists guard FileManager.default.fileExists(atPath: detectedPath) else { // Path was found in PATH but file doesn't exist (likely uninstalled) binarySource = .none detectedBinaryPath = nil appendLog("cliproxyapi found in PATH but file does not exist. Using CodMate installation path.\n") return } detectedBinaryPath = detectedPath appendLog("Detected cliproxyapi at: \(detectedPath)\n") // Check if it's a Homebrew installation if isHomebrewPath(detectedPath) { // Check if brew command is available if let brewPath = detectBrewCommand() { brewCommandPath = brewPath binarySource = .homebrew appendLog("Homebrew installation detected. Using brew services for management.\n") } else { // Path matches Homebrew but brew command not found binarySource = .other conflictWarning = "cliproxyapi found at Homebrew path but brew command not available. Please install Homebrew or use CodMate's built-in management." appendLog("Warning: Homebrew path detected but brew command not found.\n", isError: true) } } else { // Other installation path binarySource = .other conflictWarning = "cliproxyapi found at non-standard path: \(detectedPath). This may cause port conflicts. Consider using Homebrew (brew install cliproxyapi) or CodMate's built-in management." appendLog("Warning: Non-standard installation path detected. Potential conflicts may occur.\n", isError: true) // Check for port conflicts checkPortConflicts() } } private func isHomebrewPath(_ path: String) -> Bool { path == "/opt/homebrew/bin/cliproxyapi" || path == "/usr/local/bin/cliproxyapi" } private func detectBrewCommand() -> String? { let path = CLIEnvironment.resolvedPATHForCLI() return CLIEnvironment.resolveExecutablePath("brew", path: path) } private func checkPortConflicts() { // Check if ports are in use let portsToCheck = [port, internalPort] var conflicts: [UInt16] = [] for portToCheck in portsToCheck { let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/sbin/lsof") task.arguments = ["-ti", "tcp:\(portToCheck)"] let pipe = Pipe() task.standardOutput = pipe try? task.run() task.waitUntilExit() let data = pipe.fileHandleForReading.readDataToEndOfFile() if let output = String(data: data, encoding: .utf8), !output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { conflicts.append(portToCheck) } } if !conflicts.isEmpty { let portsStr = conflicts.map { String($0) }.joined(separator: ", ") appendLog("Warning: Port(s) \(portsStr) may be in use by another process.\n", isError: true) } } var resolvedBinaryPath: String { switch binarySource { case .homebrew, .other: // If detected path exists, use it; otherwise fall back to CodMate path if let detected = detectedBinaryPath, FileManager.default.fileExists(atPath: detected) { return detected } return binaryPath case .codmate: return binaryPath case .none: return binaryPath } } var isBinaryInstalled: Bool { switch binarySource { case .homebrew, .other: // Verify the detected path actually exists if let detected = detectedBinaryPath { return FileManager.default.fileExists(atPath: detected) } return false case .codmate: return FileManager.default.fileExists(atPath: binaryPath) case .none: // Even if source is none, check if CodMate has installed it return FileManager.default.fileExists(atPath: binaryPath) } } // MARK: - Process Management func start() async throws { guard isBinaryInstalled else { appendLog("Binary not found. Please install it first.\n", isError: true) throw ServiceError.binaryNotFound } guard !isRunning else { appendLog("Service is already running.\n") return } lastError = nil // Sync third-party providers only on initial startup (when config doesn't exist) // During restart, we rely on the existing config that was already synced if !FileManager.default.fileExists(atPath: configPath) { let enabledProviderIds = loadEnabledAPIKeyProviders() await syncThirdPartyProviders(enabledProviderIds: enabledProviderIds) } // Use Homebrew services if Homebrew installation detected if binarySource == .homebrew, brewCommandPath != nil { try await brewServicesStart() return } // Cleanup old processes cleanupOrphanProcesses() // Update config with correct internal port (since we use bridge mode) updateConfigPort(internalPort) // --- Diagnostic Section --- let execPath = resolvedBinaryPath appendLog("Inspecting binary at \(execPath)...\n") let fileOutput = runShell(command: "/usr/bin/file", args: [execPath]) appendLog("-> File type: \(fileOutput.trimmingCharacters(in: .whitespacesAndNewlines))\n") let lsOutput = runShell(command: "/bin/ls", args: ["-l", execPath]) appendLog("-> Permissions: \(lsOutput.trimmingCharacters(in: .whitespacesAndNewlines))\n") // --- End Diagnostic Section --- let process = Process() process.executableURL = URL(fileURLWithPath: execPath) // Use CodMate's config path for non-Homebrew installations process.arguments = ["-config", configPath] process.currentDirectoryURL = URL(fileURLWithPath: execPath).deletingLastPathComponent() // Environment var env = ProcessInfo.processInfo.environment env["TERM"] = "xterm-256color" process.environment = env // Log Capture let out = Pipe() let err = Pipe() process.standardOutput = out process.standardError = err self.outputPipe = out self.errorPipe = err out.fileHandleForReading.readabilityHandler = { [weak self] handle in let data = handle.availableData if let str = String(data: data, encoding: .utf8), !str.isEmpty { Task { @MainActor [weak self] in self?.appendLog(str) } } } err.fileHandleForReading.readabilityHandler = { [weak self] handle in let data = handle.availableData if let str = String(data: data, encoding: .utf8), !str.isEmpty { Task { @MainActor [weak self] in self?.appendLog(str, isError: true) } } } process.terminationHandler = { [weak self] terminatedProcess in Task { @MainActor in self?.isRunning = false self?.process = nil self?.proxyBridge.stop() self?.outputPipe?.fileHandleForReading.readabilityHandler = nil self?.errorPipe?.fileHandleForReading.readabilityHandler = nil let reason: String switch terminatedProcess.terminationReason { case .exit: reason = "Exited with code \(terminatedProcess.terminationStatus)" case .uncaughtSignal: reason = "Terminated by signal \(terminatedProcess.terminationStatus)" @unknown default: reason = "Unknown reason" } self?.appendLog("Service stopped. \(reason)\n", isError: terminatedProcess.terminationStatus != 0) } } do { appendLog("Starting Local AI Server on port \(internalPort)...\n") try process.run() self.process = process // Wait for startup try await Task.sleep(nanoseconds: 1_500_000_000) guard process.isRunning else { let reason: String switch process.terminationReason { case .exit: reason = "Exited with code \(process.terminationStatus)" case .uncaughtSignal: reason = "Terminated by signal \(process.terminationStatus)" @unknown default: reason = "Unknown reason" } let errText = "Process failed to stay running. \(reason)." appendLog(errText + "\n", isError: true) throw ServiceError.startupFailed } // Start Proxy Bridge proxyBridge.configure(listenPort: port, targetPort: internalPort) proxyBridge.start() // Wait for bridge try await Task.sleep(nanoseconds: 500_000_000) if !proxyBridge.isRunning { process.terminate() appendLog("Proxy bridge failed to start.\n", isError: true) throw ServiceError.startupFailed } isRunning = true appendLog("Service started successfully.\n") } catch { lastError = error.localizedDescription appendLog("Error starting service: \(error.localizedDescription)\n", isError: true) throw error } } func stop() { // Use Homebrew services if Homebrew installation detected if binarySource == .homebrew, brewCommandPath != nil { brewServicesStop() return } proxyBridge.stop() if let p = process, p.isRunning { p.terminate() } process = nil cleanupOrphanProcesses() isRunning = false } func clearLogs() { logs = "" } private func appendLog(_ text: String, isError: Bool = false) { // Keep last 50k characters to avoid memory issues if logs.count > 50000 { logs = String(logs.suffix(40000)) } let timestamp = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium) logs.append("[\(timestamp)] \(text)") // Also output to AppLogger for better visibility in debug mode let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedText.isEmpty { if isError { AppLogger.shared.error(trimmedText, source: "CLIProxyService") } else { AppLogger.shared.info(trimmedText, source: "CLIProxyService") } } } // MARK: - Installation var binaryFilePath: String { resolvedBinaryPath } func install() async throws { isInstalling = true installProgress = 0 defer { isInstalling = false } do { appendLog("Fetching latest release...\n") installProgress = 0.1 let release = try await fetchLatestRelease() guard let asset = findCompatibleAsset(in: release) else { throw ServiceError.noCompatibleBinary } appendLog("Downloading binary...\n") installProgress = 0.3 let data = try await downloadAsset(url: asset.downloadURL) installProgress = 0.6 appendLog("Extracting and installing...\n") installProgress = 0.7 try await extractAndInstall(data: data, assetName: asset.name) installProgress = 0.9 // Re-detect after installation appendLog("Verifying installation...\n") performInitialDetection() installProgress = 1.0 appendLog("Installation completed successfully.\n") } catch { lastError = error.localizedDescription appendLog("Installation failed: \(error.localizedDescription)\n", isError: true) throw error } } func login(provider: LocalAuthProvider) async throws { guard isBinaryInstalled else { appendLog("Binary not found. Please install it first.\n", isError: true) throw ServiceError.binaryNotFound } openedLoginURL = nil // Qwen: Skip CLI --no-browser mode, use management OAuth directly // The CLI device code flow has reliability issues with browser callback detection if provider == .qwen { appendLog("Starting \(provider.displayName) login via management API...\n") try await loginViaManagement(provider: provider) return } let flag = provider.loginFlag // Hide existing auth files to force a new login flow let hiddenFiles = hideAuthFiles(for: provider) defer { restoreAuthFiles(hiddenFiles) } appendLog("Starting \(provider.displayName) login...\n") do { try await withTaskCancellationHandler { try await runCLI(arguments: ["-config", configPath, flag, "-incognito"], loginProvider: provider) } onCancel: { Task { @MainActor in self.cancelLogin() } } appendLog("\(provider.displayName) login finished.\n") } catch is CancellationError { appendLog("\(provider.displayName) login cancelled.\n") throw CancellationError() } } private func hideAuthFiles(for provider: LocalAuthProvider) -> [URL] { let fm = FileManager.default guard let items = try? fm.contentsOfDirectory(atPath: authDir) else { return [] } var hidden: [URL] = [] let aliases = provider.authAliases.map { $0.lowercased() } for name in items { guard name.hasSuffix(".json") else { continue } let url = URL(fileURLWithPath: authDir).appendingPathComponent(name) // Check if this file belongs to the provider var belongsToProvider = false if aliases.contains(where: { name.lowercased().contains($0) }) { belongsToProvider = true } else if let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) { let lower = text.lowercased() let patterns = [ "\"type\":\"\(provider)\"", "\"type\": \"\(provider)\"", "\"provider\":\"\(provider)\"", "\"provider\": \"\(provider)\"" ] if patterns.contains(where: { lower.contains($0) }) { belongsToProvider = true } } if belongsToProvider { let backupURL = url.appendingPathExtension("bak") do { try fm.moveItem(at: url, to: backupURL) hidden.append(backupURL) } catch { appendLog("Failed to hide auth file \(name): \(error.localizedDescription)\n", isError: true) } } } return hidden } private func restoreAuthFiles(_ backups: [URL]) { let fm = FileManager.default for backupURL in backups { let originalURL = backupURL.deletingPathExtension() // If the original file exists (meaning a new one was created with the same name), // we assume the new one is the latest valid session for that account, so we discard the backup. if fm.fileExists(atPath: originalURL.path) { try? fm.removeItem(at: backupURL) } else { // Otherwise, restore the old file (different account) do { try fm.moveItem(at: backupURL, to: originalURL) } catch { appendLog("Failed to restore auth file \(originalURL.lastPathComponent): \(error.localizedDescription)\n", isError: true) } } } } func cancelLogin() { loginCancellationRequested = true if let process = loginProcess, process.isRunning { process.terminate() } loginPrompt = nil openedLoginURL = nil } func logout(provider: LocalAuthProvider) { let accounts = listOAuthAccounts().filter { $0.provider == provider } let fm = FileManager.default var removed = 0 for account in accounts { try? fm.removeItem(atPath: account.filePath) removed += 1 } if removed > 0 { appendLog("Removed \(removed) \(provider.displayName) credential file(s).\n") } } func deleteOAuthAccount(_ account: OAuthAccount) { let fm = FileManager.default do { try fm.removeItem(atPath: account.filePath) appendLog("Removed credential file for \(account.provider.displayName) (\(account.email ?? "unknown")).\n") } catch { appendLog("Failed to delete credential file: \(error.localizedDescription)\n", isError: true) } } func listOAuthAccounts() -> [OAuthAccount] { let fm = FileManager.default guard let items = try? fm.contentsOfDirectory(atPath: authDir) else { return [] } var accounts: [OAuthAccount] = [] for name in items { guard name.hasSuffix(".json") else { continue } let path = (authDir as NSString).appendingPathComponent(name) guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), let text = String(data: data, encoding: .utf8) else { continue } // Identify provider var identifiedProvider: LocalAuthProvider? for provider in LocalAuthProvider.allCases { let aliases = provider.authAliases.map { $0.lowercased() } // Check filename first if aliases.contains(where: { name.lowercased().contains($0) }) { identifiedProvider = provider break } // Check content let patterns = [ "\"type\":\"\(provider)\"", "\"type\": \"\(provider)\"", "\"provider\":\"\(provider)\"", "\"provider\": \"\(provider)\"" ] if patterns.contains(where: { text.lowercased().contains($0) }) { identifiedProvider = provider break } } guard let provider = identifiedProvider else { continue } // Extract email/account info var email: String? if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { email = json["email"] as? String ?? json["user_email"] as? String ?? json["account"] as? String ?? json["user"] as? String ?? json["nickname"] as? String ?? json["name"] as? String } accounts.append(OAuthAccount( id: name, // Use filename as ID provider: provider, email: email, filename: name, filePath: path )) } return accounts } func hasAuthToken(for provider: LocalAuthProvider) -> Bool { let fm = FileManager.default guard let items = try? fm.contentsOfDirectory(atPath: authDir) else { return false } let normalized = items.map { $0.lowercased() } let aliases = provider.authAliases.map { $0.lowercased() } for (idx, name) in normalized.enumerated() { guard name.hasSuffix(".json") else { continue } if aliases.contains(where: { name.contains($0) }) { return true } let original = items[idx] let path = (authDir as NSString).appendingPathComponent(original) if fileContainsProviderType(path: path, providers: aliases) { return true } } return false } private func fileContainsProviderType(path: String, providers: [String]) -> Bool { guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return false } guard let text = String(data: data, encoding: .utf8) else { return false } let lower = text.lowercased() for provider in providers { let patterns = [ "\"type\":\"\(provider)\"", "\"type\": \"\(provider)\"", "\"provider\":\"\(provider)\"", "\"provider\": \"\(provider)\"" ] if patterns.contains(where: { lower.contains($0) }) { return true } } return false } func submitLoginInput(_ input: String) { guard let pipe = loginInputPipe else { return } let payload = input.hasSuffix("\n") ? input : (input + "\n") if let data = payload.data(using: .utf8) { pipe.fileHandleForWriting.write(data) } } func loadPublicAPIKey() -> String? { guard let content = try? String(contentsOfFile: configPath, encoding: .utf8) else { return nil } var inKeys = false for line in content.components(separatedBy: .newlines) { let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("api-keys:") { inKeys = true continue } if inKeys { if trimmed.hasPrefix("-") { var value = trimmed if let range = value.range(of: "-") { value = String(value[range.upperBound...]).trimmingCharacters(in: .whitespaces) } if value.hasPrefix("\"") && value.hasSuffix("\"") { value.removeFirst() value.removeLast() } let trimmed = value.trimmingCharacters(in: .whitespaces) if !trimmed.isEmpty { persistPublicAPIKey(trimmed) return trimmed } return nil } if !trimmed.isEmpty { inKeys = false } } } if let stored = UserDefaults.standard.string(forKey: Self.publicAPIKeyDefaultsKey), !stored.isEmpty { return stored } return nil } func resolvePublicAPIKey() -> String { if let key = loadPublicAPIKey(), !key.isEmpty { return key } if let stored = UserDefaults.standard.string(forKey: Self.publicAPIKeyDefaultsKey), !stored.isEmpty { return stored } let generated = generatePublicAPIKey(length: Self.publicAPIKeyLength) persistPublicAPIKey(generated) return generated } func fetchLocalModels(forceRefresh: Bool = false) async -> [LocalModel] { guard isRunning else { return [] } if !forceRefresh, let cached = validCachedModels() { return cached } let fallback = loadAnyCachedModels() guard let url = URL(string: "http://127.0.0.1:\(port)/v1/models") else { return fallback ?? [] } var request = URLRequest(url: url) if let key = loadPublicAPIKey(), !key.isEmpty { let bearer = key.hasPrefix("Bearer ") ? key : "Bearer \(key)" request.setValue(bearer, forHTTPHeaderField: "Authorization") } do { let (data, response) = try await URLSession.shared.data(for: request) guard (response as? HTTPURLResponse)?.statusCode == 200 else { return fallback ?? [] } let models = (try? JSONDecoder().decode(LocalModelList.self, from: data))?.data ?? [] persistCachedModels(models) return models } catch { return fallback ?? [] } } /// Get cached provider name for a model ID (from config.yaml mapping) /// This compensates for CLIProxyAPI not setting provider field correctly func getProviderName(for modelId: String) -> String? { return modelToProviderNameCache[modelId] } private func validCachedModels() -> [LocalModel]? { if isCacheValid(cachedLocalModelsTimestamp), !cachedLocalModels.isEmpty { return cachedLocalModels } let persisted = loadCachedModelsFromDefaults() if isCacheValid(persisted.timestamp), !persisted.models.isEmpty { cachedLocalModels = persisted.models cachedLocalModelsTimestamp = persisted.timestamp return persisted.models } return nil } private func loadAnyCachedModels() -> [LocalModel]? { if !cachedLocalModels.isEmpty { return cachedLocalModels } let persisted = loadCachedModelsFromDefaults() if !persisted.models.isEmpty { cachedLocalModels = persisted.models cachedLocalModelsTimestamp = persisted.timestamp return persisted.models } return nil } private func isCacheValid(_ timestamp: Date?) -> Bool { guard let timestamp else { return false } return Date().timeIntervalSince(timestamp) < Self.localModelsCacheTTL } private func loadCachedModelsFromDefaults() -> (models: [LocalModel], timestamp: Date?) { let defaults = UserDefaults.standard guard let data = defaults.data(forKey: Self.localModelsCacheKey) else { return ([], nil) } let models = (try? JSONDecoder().decode([LocalModel].self, from: data)) ?? [] let timestamp = defaults.object(forKey: Self.localModelsCacheTimestampKey) as? Date return (models, timestamp) } private func persistCachedModels(_ models: [LocalModel]) { cachedLocalModels = models cachedLocalModelsTimestamp = Date() let defaults = UserDefaults.standard if let data = try? JSONEncoder().encode(models) { defaults.set(data, forKey: Self.localModelsCacheKey) } defaults.set(cachedLocalModelsTimestamp, forKey: Self.localModelsCacheTimestampKey) } func updatePublicAPIKey(_ key: String) { guard FileManager.default.fileExists(atPath: configPath), var content = try? String(contentsOfFile: configPath, encoding: .utf8) else { return } let lines = content.components(separatedBy: .newlines) var out: [String] = [] var inKeys = false var replaced = false for line in lines { let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("api-keys:") { inKeys = true out.append(line) continue } if inKeys { if trimmed.hasPrefix("-") { if !replaced { let indent = line.prefix { $0 == " " || $0 == "\t" } out.append("\(indent)- \"\(key)\"") replaced = true } else { out.append(line) } continue } if !trimmed.isEmpty { if !replaced { out.append(" - \"\(key)\"") replaced = true } inKeys = false } } out.append(line) } if inKeys && !replaced { out.append(" - \"\(key)\"") } content = out.joined(separator: "\n") try? content.write(toFile: configPath, atomically: true, encoding: .utf8) persistPublicAPIKey(key) } func generatePublicAPIKey(length: Int = 36) -> String { let prefix = Self.publicAPIKeyPrefix let required = max(prefix.count + 1, length) let bodyLength = required - prefix.count var pool = "" while pool.count < bodyLength { pool += UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() } let body = pool.prefix(bodyLength) return prefix + body } private func persistPublicAPIKey(_ key: String) { let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } UserDefaults.standard.set(trimmed, forKey: Self.publicAPIKeyDefaultsKey) } // MARK: - Homebrew Services Management private func brewServicesStart() async throws { guard let brewPath = brewCommandPath else { throw ServiceError.binaryNotFound } // Check if service is already running if await isHomebrewServiceRunning() { appendLog("Homebrew service is already running.\n") // Start Proxy Bridge if not already running if !proxyBridge.isRunning { proxyBridge.configure(listenPort: port, targetPort: internalPort) proxyBridge.start() try await Task.sleep(nanoseconds: 500_000_000) } isRunning = true return } appendLog("Starting cliproxyapi via Homebrew services...\n") // Ensure Homebrew config exists if getHomebrewConfigPath() == nil { appendLog("Creating Homebrew config file...\n") createHomebrewConfigIfNeeded() } let process = Process() process.executableURL = URL(fileURLWithPath: brewPath) process.arguments = ["services", "start", "cliproxyapi"] let out = Pipe() let err = Pipe() process.standardOutput = out process.standardError = err out.fileHandleForReading.readabilityHandler = { [weak self] handle in let data = handle.availableData if let str = String(data: data, encoding: .utf8), !str.isEmpty { Task { @MainActor [weak self] in self?.appendLog(str) } } } err.fileHandleForReading.readabilityHandler = { [weak self] handle in let data = handle.availableData if let str = String(data: data, encoding: .utf8), !str.isEmpty { Task { @MainActor [weak self] in self?.appendLog(str, isError: true) } } } do { try process.run() process.waitUntilExit() if process.terminationStatus == 0 { // Wait a bit for service to start try await Task.sleep(nanoseconds: 1_500_000_000) // Verify service is actually running if !(await isHomebrewServiceRunning()) { appendLog("Service failed to start (not running after start command).\n", isError: true) throw ServiceError.startupFailed } // Start Proxy Bridge proxyBridge.configure(listenPort: port, targetPort: internalPort) proxyBridge.start() // Wait for bridge try await Task.sleep(nanoseconds: 500_000_000) if !proxyBridge.isRunning { appendLog("Proxy bridge failed to start.\n", isError: true) throw ServiceError.startupFailed } isRunning = true appendLog("Service started successfully via Homebrew.\n") } else { let errText = "brew services start failed with code \(process.terminationStatus)." appendLog(errText + "\n", isError: true) throw ServiceError.startupFailed } } catch { lastError = error.localizedDescription appendLog("Error starting service via Homebrew: \(error.localizedDescription)\n", isError: true) throw error } } private func brewServicesStop() { guard let brewPath = brewCommandPath else { return } appendLog("Stopping cliproxyapi via Homebrew services...\n") proxyBridge.stop() let process = Process() process.executableURL = URL(fileURLWithPath: brewPath) process.arguments = ["services", "stop", "cliproxyapi"] let out = Pipe() let err = Pipe() process.standardOutput = out process.standardError = err out.fileHandleForReading.readabilityHandler = { [weak self] handle in let data = handle.availableData if let str = String(data: data, encoding: .utf8), !str.isEmpty { Task { @MainActor [weak self] in self?.appendLog(str) } } } err.fileHandleForReading.readabilityHandler = { [weak self] handle in let data = handle.availableData if let str = String(data: data, encoding: .utf8), !str.isEmpty { Task { @MainActor [weak self] in self?.appendLog(str, isError: true) } } } do { try process.run() process.waitUntilExit() isRunning = false appendLog("Service stopped via Homebrew.\n") } catch { appendLog("Error stopping service via Homebrew: \(error.localizedDescription)\n", isError: true) isRunning = false } } func brewUpgrade() async throws { guard let brewPath = brewCommandPath else { appendLog("brew command not found. Cannot upgrade.\n", isError: true) throw ServiceError.binaryNotFound } isInstalling = true installProgress = 0 defer { isInstalling = false } appendLog("Upgrading cliproxyapi via Homebrew...\n") installProgress = 0.3 let process = Process() process.executableURL = URL(fileURLWithPath: brewPath) process.arguments = ["upgrade", "cliproxyapi"] let out = Pipe() let err = Pipe() process.standardOutput = out process.standardError = err out.fileHandleForReading.readabilityHandler = { [weak self] handle in let data = handle.availableData if let str = String(data: data, encoding: .utf8), !str.isEmpty { Task { @MainActor [weak self] in self?.appendLog(str) // Update progress based on output if str.contains("Updating") { self?.installProgress = 0.5 } else if str.contains("Downloading") { self?.installProgress = 0.7 } else if str.contains("Installing") { self?.installProgress = 0.9 } } } } err.fileHandleForReading.readabilityHandler = { [weak self] handle in let data = handle.availableData if let str = String(data: data, encoding: .utf8), !str.isEmpty { Task { @MainActor [weak self] in self?.appendLog(str, isError: true) } } } do { try process.run() process.waitUntilExit() installProgress = 1.0 if process.terminationStatus == 0 { appendLog("cliproxyapi upgraded successfully.\n") // Re-detect after upgrade performInitialDetection() } else { let errText = "brew upgrade failed with code \(process.terminationStatus)." appendLog(errText + "\n", isError: true) throw ServiceError.networkError } } catch { lastError = error.localizedDescription appendLog("Error upgrading via Homebrew: \(error.localizedDescription)\n", isError: true) throw error } } // MARK: - Helpers private func cleanupOrphanProcesses() { killProcessOnPort(port) killProcessOnPort(internalPort) } private func killProcessOnPort(_ port: UInt16) { let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/sbin/lsof") task.arguments = ["-ti", "tcp:\(port)"] let pipe = Pipe() task.standardOutput = pipe try? task.run() task.waitUntilExit() let data = pipe.fileHandleForReading.readDataToEndOfFile() if let output = String(data: data, encoding: .utf8) { for line in output.components(separatedBy: .newlines) { if let pid = Int32(line.trimmingCharacters(in: .whitespaces)) { kill(pid, SIGKILL) } } } } private func getHomebrewConfigPath() -> String? { // Homebrew installations typically use ~/.cli-proxy-api/config.yaml let homeDir = FileManager.default.homeDirectoryForCurrentUser let homebrewConfigPath = homeDir.appendingPathComponent(".cli-proxy-api/config.yaml").path if FileManager.default.fileExists(atPath: homebrewConfigPath) { return homebrewConfigPath } // Try alternative location let altPath = homeDir.appendingPathComponent(".config/cli-proxy-api/config.yaml").path if FileManager.default.fileExists(atPath: altPath) { return altPath } return nil } private func createHomebrewConfigIfNeeded() { let homeDir = FileManager.default.homeDirectoryForCurrentUser let configDir = homeDir.appendingPathComponent(".cli-proxy-api", isDirectory: true) let configPath = configDir.appendingPathComponent("config.yaml") // Create directory if needed try? FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true) // Only create if doesn't exist guard !FileManager.default.fileExists(atPath: configPath.path) else { return } // Use same config as CodMate for consistency let apiKey = resolvePublicAPIKey() let config = """ host: \"127.0.0.1\" port: \(internalPort) auth-dir: \"\(authDir)\" api-keys: - \"\(apiKey)\" remote-management: allow-remote: false secret-key: \"\(managementKey)\" debug: true logging-to-file: true usage-statistics-enabled: true routing: strategy: \"round-robin\" """ try? config.write(toFile: configPath.path, atomically: true, encoding: .utf8) appendLog("Created Homebrew config at: \(configPath.path)\n") } private func isHomebrewServiceRunning() async -> Bool { guard let brewPath = brewCommandPath else { return false } let process = Process() process.executableURL = URL(fileURLWithPath: brewPath) process.arguments = ["services", "list"] let pipe = Pipe() process.standardOutput = pipe process.standardError = Pipe() do { try process.run() process.waitUntilExit() let data = pipe.fileHandleForReading.readDataToEndOfFile() if let output = String(data: data, encoding: .utf8) { // Check if cliproxyapi service is listed as started return output.contains("cliproxyapi") && output.contains("started") } } catch { return false } return false } private func loadEnabledAPIKeyProviders() -> Set { let defaults = UserDefaults.standard let enabled = defaults.array(forKey: "codmate.providers.apikey.enabled") as? [String] ?? [] return Set(enabled) } /// Resolve API key from provider configuration /// The envKey field can contain either: /// 1. The API key itself (if it contains special chars like -, ., etc.) /// 2. An environment variable name (if it looks like an env var) private func resolveAPIKey(provider: ProvidersRegistryService.Provider) -> String? { guard let envKey = provider.envKey, !envKey.isEmpty else { return nil } // Check if envKey looks like an API key (contains special chars) // API keys typically contain: -, ., alphanumeric characters let looksLikeAPIKey = envKey.contains("-") || envKey.contains(".") || envKey.count > 40 if looksLikeAPIKey { // Treat as direct API key return envKey } else { // Treat as environment variable name return ProcessInfo.processInfo.environment[envKey] } } /// Fetch available models from a third-party OpenAI-compatible API private func fetchModelsFromProvider(baseURL: String, apiKey: String) async -> [String] { guard let url = URL(string: baseURL)?.appendingPathComponent("models") else { appendLog("Invalid base URL: \(baseURL)\n") return [] } var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.timeoutInterval = 10 do { let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { appendLog("Failed to fetch models from \(baseURL): No HTTP response\n") return [] } guard httpResponse.statusCode == 200 else { appendLog("Failed to fetch models from \(baseURL): HTTP \(httpResponse.statusCode)\n") return [] } struct ModelsResponse: Codable { struct Model: Codable { let id: String } let data: [Model] } let modelsResponse = try JSONDecoder().decode(ModelsResponse.self, from: data) let modelIds = modelsResponse.data.map { $0.id } return modelIds } catch { appendLog("Error fetching models from \(baseURL): \(error.localizedDescription)\n") return [] } } private func ensureConfigExists() { guard !FileManager.default.fileExists(atPath: configPath) else { return } let apiKey = resolvePublicAPIKey() let config = """ host: \"127.0.0.1\" port: \(internalPort) auth-dir: \"\(authDir)\" api-keys: - \"\(apiKey)\" remote-management: allow-remote: false secret-key: \"\(managementKey)\" debug: true logging-to-file: true usage-statistics-enabled: true routing: strategy: \"round-robin\" """ try? config.write(toFile: configPath, atomically: true, encoding: .utf8) } func syncThirdPartyProviders(enabledProviderIds: Set) async { let registry = ProvidersRegistryService() let providers = await registry.listProviders() let apiKey = resolvePublicAPIKey() // Filter providers based on enabled status let enabledProviders = providers.filter { enabledProviderIds.contains($0.id) } var config = """ host: \"127.0.0.1\" port: \(internalPort) auth-dir: \"\(authDir)\" api-keys: - \"\(apiKey)\" remote-management: allow-remote: false secret-key: \"\(managementKey)\" debug: true logging-to-file: true usage-statistics-enabled: true routing: strategy: \"round-robin\" """ // Append third-party providers configuration // Collect all OpenAI-compatible providers (only enabled ones) var openaiProviders: [(name: String, baseURL: String, apiKey: String, models: [String])] = [] for provider in enabledProviders { // Extract API key (either directly from envKey or from environment variable) guard let apiKey = resolveAPIKey(provider: provider), !apiKey.isEmpty else { continue } let providerName = provider.name ?? provider.id // Use OpenAI-compatible format for all third-party providers if let codexConnector = provider.connectors[ProvidersRegistryService.Consumer.codex.rawValue], let baseURL = codexConnector.baseURL, !baseURL.isEmpty { // Priority 1: Use catalog models (user-configured in Edit Provider dialog) var models: [String] = [] if let catalog = provider.catalog, let catalogModels = catalog.models, !catalogModels.isEmpty { models = catalogModels.compactMap { $0.vendorModelId } appendLog("Using \(models.count) models from catalog for \(providerName)\n") } else { // Priority 2: Fetch from API if catalog is empty (only works for some providers like DeepSeek) models = await fetchModelsFromProvider(baseURL: baseURL, apiKey: apiKey) if !models.isEmpty { appendLog("Fetched \(models.count) models from \(providerName) API (\(baseURL))\n") } else { appendLog("No models available for \(providerName) (no catalog and API fetch failed)\n") } } if !models.isEmpty { openaiProviders.append((name: providerName, baseURL: baseURL, apiKey: apiKey, models: models)) } } } // Build openai-compatibility section with models if !openaiProviders.isEmpty { config += "\nopenai-compatibility:\n" for (name, baseURL, apiKey, models) in openaiProviders { config += """ - name: "\(name)" base-url: "\(baseURL)" api-key-entries: - api-key: "\(apiKey)" """ // Add models if available if !models.isEmpty { config += " models:\n" for modelId in models { config += " - name: \"\(modelId)\"\n" } config += "\n" } } } try? config.write(toFile: configPath, atomically: true, encoding: .utf8) appendLog("Synced \(openaiProviders.count) third-party provider(s) to config (openai-compatibility format).\n") // Build model -> provider name cache by parsing what we just wrote // This is simpler and more reliable than depending on CLIProxyAPI metadata var newCache: [String: String] = [:] for (name, _, _, models) in openaiProviders { for modelId in models { newCache[modelId] = name } } modelToProviderNameCache = newCache appendLog("Built model-to-provider cache: \(newCache.count) models\n") // Poll CLIProxyAPI until config is reloaded (with timeout) if isRunning { appendLog("Waiting for CLI Proxy API to reload config...\n") let expectedModelIds = Set(openaiProviders.flatMap { $0.models }) await waitForConfigReload(expectedModelIds: expectedModelIds, timeoutSeconds: 5.0) } } /// Poll CLIProxyAPI until the expected models appear (indicating config reload is complete) private func waitForConfigReload(expectedModelIds: Set, timeoutSeconds: Double) async { let startTime = Date() let timeoutInterval = timeoutSeconds var attemptCount = 0 while Date().timeIntervalSince(startTime) < timeoutInterval { attemptCount += 1 // Fetch current models from CLIProxyAPI let currentModels = await fetchLocalModels(forceRefresh: true) let currentModelIds = Set(currentModels.map { $0.id }) // Check if all expected models are present let missingModels = expectedModelIds.subtracting(currentModelIds) if missingModels.isEmpty { appendLog("Config reloaded successfully after \(attemptCount) attempt(s) (~\(String(format: "%.1f", Date().timeIntervalSince(startTime)))s)\n") return } // Wait before next poll (100ms intervals) try? await Task.sleep(nanoseconds: 100_000_000) } // Timeout reached appendLog("Warning: Config reload timeout after \(String(format: "%.1f", timeoutSeconds))s. Some models may not be available yet.\n") } private func updateConfigPort(_ newPort: UInt16) { guard FileManager.default.fileExists(atPath: configPath), var content = try? String(contentsOfFile: configPath, encoding: .utf8) else { return } if let range = content.range(of: #"port:\s*\d+"#, options: .regularExpression) { content.replaceSubrange(range, with: "port: \(newPort)") try? content.write(toFile: configPath, atomically: true, encoding: .utf8) } } // MARK: - GitHub API private struct ReleaseInfo: Decodable { let assets: [AssetInfo] } private struct AssetInfo: Decodable { let name: String let browser_download_url: String var downloadURL: String { browser_download_url } } private struct CompatibleAsset { let name: String let downloadURL: String } private func fetchLatestRelease() async throws -> ReleaseInfo { let url = URL(string: "https://api.github.com/repos/\(Self.githubRepo)/releases/latest")! var req = URLRequest(url: url) req.addValue("application/vnd.github.v3+json", forHTTPHeaderField: "Accept") let (data, resp) = try await URLSession.shared.data(for: req) guard (resp as? HTTPURLResponse)?.statusCode == 200 else { throw ServiceError.networkError } return try JSONDecoder().decode(ReleaseInfo.self, from: data) } private func findCompatibleAsset(in release: ReleaseInfo) -> CompatibleAsset? { #if arch(arm64) let arch = "arm64" #else let arch = "amd64" #endif let target = "darwin_\(arch)" for asset in release.assets { let name = asset.name.lowercased() if name.contains(target) && !name.contains("checksum") { return CompatibleAsset(name: asset.name, downloadURL: asset.downloadURL) } } return nil } private func downloadAsset(url: String) async throws -> Data { let (data, resp) = try await URLSession.shared.data(from: URL(string: url)!) guard (resp as? HTTPURLResponse)?.statusCode == 200 else { throw ServiceError.networkError } return data } private func extractAndInstall(data: Data, assetName: String) async throws { let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } let archivePath = tempDir.appendingPathComponent(assetName) try data.write(to: archivePath) // Extract let tar = Process() tar.executableURL = URL(fileURLWithPath: "/usr/bin/tar") tar.arguments = ["-xzf", archivePath.path, "-C", tempDir.path] try tar.run() tar.waitUntilExit() // Find binary let binary = search(tempDir) guard let validBinary = binary else { throw ServiceError.extractionFailed } if FileManager.default.fileExists(atPath: binaryPath) { try FileManager.default.removeItem(atPath: binaryPath) } try FileManager.default.copyItem(at: validBinary, to: URL(fileURLWithPath: binaryPath)) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binaryPath) } private func runShell(command: String, args: [String]) -> String { let process = Process() process.executableURL = URL(fileURLWithPath: command) process.arguments = args let pipe = Pipe() process.standardOutput = pipe process.standardError = pipe do { try process.run() process.waitUntilExit() let data = pipe.fileHandleForReading.readDataToEndOfFile() return String(data: data, encoding: .utf8) ?? "Failed to read output" } catch { return "Failed to run command: \(error.localizedDescription)" } } private func runCLI(arguments: [String], loginProvider: LocalAuthProvider? = nil) async throws { let process = Process() let execPath = resolvedBinaryPath process.executableURL = URL(fileURLWithPath: execPath) process.arguments = arguments process.currentDirectoryURL = URL(fileURLWithPath: execPath).deletingLastPathComponent() var env = ProcessInfo.processInfo.environment env["TERM"] = "xterm-256color" process.environment = env let out = Pipe() let err = Pipe() process.standardOutput = out process.standardError = err if loginProvider != nil { let input = Pipe() process.standardInput = input self.loginInputPipe = input } out.fileHandleForReading.readabilityHandler = { [weak self] handle in let data = handle.availableData if let str = String(data: data, encoding: .utf8), !str.isEmpty { Task { @MainActor [weak self] in self?.appendLog(str) if let provider = self?.loginProvider { self?.detectLoginURL(in: str, provider: provider) self?.detectLoginPrompt(in: str) } } } } err.fileHandleForReading.readabilityHandler = { [weak self] handle in let data = handle.availableData if let str = String(data: data, encoding: .utf8), !str.isEmpty { Task { @MainActor [weak self] in self?.appendLog(str, isError: true) if let provider = self?.loginProvider { self?.detectLoginURL(in: str, provider: provider) self?.detectLoginPrompt(in: str) } } } } if let provider = loginProvider { self.loginProvider = provider self.loginProcess = process self.loginCancellationRequested = false } defer { if loginProvider != nil { self.loginProvider = nil self.loginProcess = nil self.loginInputPipe = nil self.loginPrompt = nil self.openedLoginURL = nil } } do { try process.run() } catch { appendLog("Failed to start CLIProxyAPI: \(error.localizedDescription)\n", isError: true) throw ServiceError.loginFailed } try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in DispatchQueue.global(qos: .utility).async { process.waitUntilExit() continuation.resume(returning: ()) } } if loginProvider != nil, loginCancellationRequested { loginCancellationRequested = false throw CancellationError() } if process.terminationStatus != 0 { appendLog("CLIProxyAPI exited with code \(process.terminationStatus).\n", isError: true) throw ServiceError.loginFailed } } private func detectLoginPrompt(in text: String) { guard let provider = loginProvider else { return } let lower = text.lowercased() let prompt: String? if lower.contains("paste the codex callback url") || lower.contains("paste the callback url") { if provider == .codex { submitLoginInput("") appendLog("Codex callback prompt detected; continuing to wait.\n") return } if provider == .gemini { submitLoginInput("") appendLog("Gemini callback prompt detected; continuing to wait.\n") return } prompt = "Paste the callback URL" } else if lower.contains("enter project id") { if provider == .gemini { submitLoginInput("") appendLog("Gemini project prompt detected; using default project.\n") return } prompt = "Enter project ID or ALL" } else if lower.contains("device code") || lower.contains("verification code") || lower.contains("enter code") || lower.contains("input code") || lower.contains("paste code") || lower.contains("设备码") || lower.contains("验证码") || lower.contains("输入验证码") || lower.contains("输入代码") || lower.contains("输入设备码") { prompt = "Enter device or verification code" } else if lower.contains("enter email") || lower.contains("enter your email") || lower.contains("enter nickname") || lower.contains("enter a nickname") || lower.contains("enter name") || lower.contains("enter username") || lower.contains("enter alias") || lower.contains("enter account") || lower.contains("enter label") || lower.contains("enter display name") || lower.contains("输入邮箱") || lower.contains("输入昵称") || lower.contains("输入名字") || lower.contains("输入名称") || lower.contains("输入用户名") || lower.contains("输入别名") || lower.contains("输入账号") || lower.contains("输入账户") || lower.contains("输入账号名称") || lower.contains("输入账户名称") || lower.contains("输入账号别名") || lower.contains("输入账户别名") { prompt = "Enter email or nickname" } else { prompt = nil } guard let message = prompt else { return } if loginPrompt?.message == message && loginPrompt?.provider == provider { return } loginPrompt = LoginPrompt(provider: provider, message: message) } private func detectLoginURL(in text: String, provider: LocalAuthProvider) { guard provider == .qwen else { return } guard openedLoginURL == nil else { return } guard text.contains("http") else { return } guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else { return } let range = NSRange(text.startIndex.. (URL, String?) { guard let endpoint = managementAuthEndpoint(for: provider), let request = managementRequest(path: endpoint) else { throw ServiceError.networkError } let (data, response) = try await URLSession.shared.data(for: request) guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw ServiceError.networkError } let payload = try JSONDecoder().decode(ManagementAuthURLResponse.self, from: data) guard payload.status?.lowercased() == "ok", let urlText = payload.url, let url = URL(string: urlText) else { throw ServiceError.loginFailed } return (url, payload.state) } private func waitForAuthCompletion(state: String, provider: LocalAuthProvider) async throws { let timeoutSeconds: TimeInterval = 180 let deadline = Date().addingTimeInterval(timeoutSeconds) while Date() < deadline { try Task.checkCancellation() let status = try await fetchAuthStatus(state: state) switch status { case "ok": return case "error": appendLog("\(provider.displayName) login failed.\n", isError: true) throw ServiceError.loginFailed default: break } try await Task.sleep(nanoseconds: 1_000_000_000) } appendLog("\(provider.displayName) login timed out.\n", isError: true) throw ServiceError.loginFailed } private func fetchAuthStatus(state: String) async throws -> String { let query = [URLQueryItem(name: "state", value: state)] guard let request = managementRequest(path: "get-auth-status", queryItems: query) else { throw ServiceError.networkError } let (data, response) = try await URLSession.shared.data(for: request) guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw ServiceError.networkError } let payload = try JSONDecoder().decode(ManagementAuthStatusResponse.self, from: data) return payload.status?.lowercased() ?? "error" } private func managementAuthEndpoint(for provider: LocalAuthProvider) -> String? { switch provider { case .codex: return "codex-auth-url" case .claude: return "anthropic-auth-url" case .gemini: return "gemini-cli-auth-url" case .antigravity: return "antigravity-auth-url" case .qwen: return "qwen-auth-url" } } private func managementRequest(path: String, queryItems: [URLQueryItem]? = nil) -> URLRequest? { guard var components = URLComponents(string: "http://127.0.0.1:\(internalPort)/v0/management/\(path)") else { return nil } if let queryItems, !queryItems.isEmpty { components.queryItems = queryItems } guard let url = components.url else { return nil } var request = URLRequest(url: url) request.setValue("Bearer \(managementKey)", forHTTPHeaderField: "Authorization") return request } func fetchAuthFileInfo(for filename: String) async -> AuthFileInfo? { guard isRunning else { appendLog("Cannot fetch auth file info: service not running\n", isError: true) return nil } guard let request = managementRequest(path: "auth-files") else { appendLog("Cannot fetch auth file info: failed to create request\n", isError: true) return nil } do { let (data, response) = try await URLSession.shared.data(for: request) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 if statusCode != 200 { appendLog("Auth files API returned status \(statusCode)\n", isError: true) return nil } // Debug: print raw response if let jsonString = String(data: data, encoding: .utf8) { appendLog("Auth files API response: \(jsonString.prefix(500))\n") } let authFiles: [AuthFileInfo] if let wrapped = try? JSONDecoder().decode(ManagementAuthFilesResponse.self, from: data) { authFiles = wrapped.files } else { authFiles = try JSONDecoder().decode([AuthFileInfo].self, from: data) } appendLog("Successfully decoded \(authFiles.count) auth files\n") if let found = authFiles.first(where: { $0.name == filename || $0.id == filename }) { appendLog("Found auth file info for \(filename): plan=\(found.consolidatedPlan ?? "nil")\n") return found } else { appendLog("Auth file \(filename) not found in response\n", isError: true) return nil } } catch { appendLog("Failed to fetch auth file info: \(error.localizedDescription)\n", isError: true) return nil } } /// DEPRECATED: CLI Proxy API's Management API does not provide an endpoint to enable/disable auth files /// This function always returns false. Use local oauthAccountsEnabled settings instead. /// /// According to CLI Proxy API documentation (https://help.router-for.me/management/api), /// the Management API only supports: GET, POST, DELETE for auth files, but not PATCH/UPDATE. /// CLIProxyAPI loads all auth files, and applications should filter which ones to use locally. @available(*, deprecated, message: "CLI Proxy API does not support updating auth file disabled status via Management API") func updateAuthFileDisabled(filename: String, disabled: Bool) async -> Bool { // CLI Proxy API's Management API does not provide this endpoint // Always return false and rely on local oauthAccountsEnabled settings return false } /// DEPRECATED: CLI Proxy API's Management API does not provide an endpoint to enable/disable auth files /// This function does nothing. Use local oauthAccountsEnabled settings instead. @available(*, deprecated, message: "CLI Proxy API does not support updating auth file disabled status via Management API") func updateProviderAuthFilesDisabled(provider: LocalAuthProvider, disabled: Bool) async { // CLI Proxy API's Management API does not provide this endpoint // No-op - rely on local oauthAccountsEnabled settings } private func search(_ dir: URL) -> URL? { guard let items = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.isExecutableKey, .isDirectoryKey]) else { return nil } let candidates = ["cliproxyapiplus", "cliproxyapi", "cli-proxy-api", "cli-proxy-api-plus"] for item in items { if let vals = try? item.resourceValues(forKeys: [.isDirectoryKey]), vals.isDirectory == true { if let found = search(item) { return found } continue } let name = item.lastPathComponent.lowercased() if candidates.contains(name) { return item } if name.contains("cliproxy") && !name.contains(".txt") && !name.contains(".md") && !name.contains(".gz") { return item } } return nil } } enum ServiceError: LocalizedError { case binaryNotFound case startupFailed case networkError case noCompatibleBinary case extractionFailed case loginFailed var errorDescription: String? { switch self { case .binaryNotFound: return "CLIProxyAPI binary not found. Please install it first." case .startupFailed: return "Failed to start CLIProxyAPI" case .networkError: return "Network error" case .noCompatibleBinary: return "No compatible binary found" case .extractionFailed: return "Extraction failed" case .loginFailed: return "Login failed" } } } ================================================ FILE: services/ClaudeSessionParser.swift ================================================ import Foundation struct ClaudeParsedLog { let summary: SessionSummary let rows: [SessionRow] } private struct ActiveDurationAccumulator { var currentTurnStart: Date? var lastOutput: Date? var total: TimeInterval = 0 mutating func observe(type: String?, timestamp: Date?) { guard let ts = timestamp else { return } switch type { case "user": flush() currentTurnStart = ts lastOutput = nil case "assistant", "system", "summary": lastOutput = ts default: break } } mutating func flush() { guard let end = lastOutput else { return } let start = currentTurnStart ?? end let delta = end.timeIntervalSince(start) if delta > 0 { total += delta } currentTurnStart = nil lastOutput = nil } } struct ClaudeSessionParser { private let decoder: JSONDecoder private let newline: UInt8 = 0x0A private let carriageReturn: UInt8 = 0x0D private let chunkSize = 64 * 1024 init() { self.decoder = FlexibleDecoders.iso8601Flexible() } /// Fast path: extract sessionId by scanning until a line that carries it. /// Avoids doing full conversion work. Returns nil if not found. func fastSessionId(at url: URL) -> String? { guard let data = try? Data(contentsOf: url, options: [.mappedIfSafe]), !data.isEmpty else { return nil } for var slice in data.split(separator: newline, omittingEmptySubsequences: true).prefix(256) { if slice.last == carriageReturn { slice = slice.dropLast() } guard !slice.isEmpty else { continue } guard let line = decodeLine(Data(slice)) else { continue } if let sid = line.sessionId, !sid.isEmpty { return sid } } return nil } /// Fast path: extract cwd by scanning until a line that carries it. /// Avoids doing full conversion work. Returns nil if not found. func fastCWD(at url: URL) -> String? { guard let data = try? Data(contentsOf: url, options: [.mappedIfSafe]), !data.isEmpty else { return nil } for var slice in data.split(separator: newline, omittingEmptySubsequences: true).prefix(256) { if slice.last == carriageReturn { slice = slice.dropLast() } guard !slice.isEmpty else { continue } guard let line = decodeLine(Data(slice)) else { continue } if let cwd = line.cwd, !cwd.isEmpty { return cwd } } return nil } func parse(at url: URL, fileSize: UInt64? = nil) -> ClaudeParsedLog? { // Skip agent-*.jsonl files entirely (sidechain warmup files) let filename = url.deletingPathExtension().lastPathComponent if filename.hasPrefix("agent-") { return nil } guard let data = try? Data(contentsOf: url, options: [.mappedIfSafe]), !data.isEmpty else { return nil } var accumulator = MetadataAccumulator() var activeAccumulator = ActiveDurationAccumulator() var rows: [SessionRow] = [] rows.reserveCapacity(256) // For user messages without message.id, use timestamp+content as unique key var seenUserMessageKeys: Set = [] var seenAssistantMessageIds: Set = [] var seenToolUseIds: Set = [] // Track seen UUIDs to prevent true duplicates (same UUID = exact same JSONL record) var seenUUIDs: Set = [] var dedupUserCount = 0 var dedupAssistantCount = 0 var dedupToolCount = 0 for var slice in data.split(separator: newline, omittingEmptySubsequences: true) { if slice.last == carriageReturn { slice = slice.dropLast() } guard !slice.isEmpty else { continue } guard let line = decodeLine(Data(slice)) else { continue } if line.isSidechain == true { continue } // Skip true duplicates (exact same JSONL record with same UUID) if let uuid = line.uuid, !uuid.isEmpty { if !seenUUIDs.insert(uuid).inserted { // This is a true duplicate with the same UUID, skip it continue } } let renderedText = line.message.flatMap(Self.renderFlatText) let model = line.message?.model let usageTokens = line.message?.usage?.totalTokens accumulator.consume(line, renderedText: renderedText, model: model, usageTokens: usageTokens) activeAccumulator.observe(type: line.type, timestamp: line.timestamp) let hasText = ClaudeSessionParser.hasRenderableText(line.message) // Track stats for user and assistant messages if let type = line.type { let messageId = line.message?.id switch type { case "user": // Just count for stats, UUID already handles deduplication if hasText { let userKey: String if let msgId = messageId, !msgId.isEmpty { userKey = msgId } else { let ts = line.timestamp?.timeIntervalSince1970 ?? 0 let content = renderedText ?? "" userKey = "\(Int(ts * 1000)):\(content.prefix(100).hashValue)" } if seenUserMessageKeys.insert(userKey).inserted { dedupUserCount &+= 1 } } case "assistant": // Just count for stats, UUID already handles deduplication let newTools = ClaudeSessionParser.countToolUses(in: line.message, seen: &seenToolUseIds) if newTools > 0 { dedupToolCount &+= newTools } if hasText { if messageId.map({ seenAssistantMessageIds.insert($0).inserted }) ?? true { dedupAssistantCount &+= 1 } } default: break } } // Always convert (UUID-based deduplication already handled true duplicates) rows.append(contentsOf: convert(line)) } activeAccumulator.flush() let contextRow = accumulator.makeContextRow() guard let metaRow = accumulator.makeMetaRow(), let summary = buildSummary( url: url, fileSize: fileSize, metaRow: metaRow, contextRow: contextRow, additionalRows: rows, totalTokens: accumulator.totalTokens, tokenBreakdown: accumulator.tokenBreakdown(), lastTimestamp: accumulator.lastTimestamp, activeDuration: activeAccumulator.total > 0 ? activeAccumulator.total : nil) else { return nil } var combinedRows: [SessionRow] = [metaRow] if let contextRow { combinedRows.append(contextRow) } combinedRows.append(contentsOf: rows) let finalSummary = adjustCounts( summary: summary, userCount: dedupUserCount, assistantCount: dedupAssistantCount, toolCount: dedupToolCount) let timelineAdjusted = adjustCountsFromTimeline(summary: finalSummary, rows: combinedRows) return ClaudeParsedLog(summary: timelineAdjusted, rows: combinedRows) } private func adjustCounts( summary: SessionSummary, userCount: Int, assistantCount: Int, toolCount: Int ) -> SessionSummary { let adjustedUser = userCount > 0 ? userCount : summary.userMessageCount let adjustedAssistant = assistantCount > 0 ? assistantCount : summary.assistantMessageCount let adjustedTools = toolCount > 0 ? toolCount : summary.toolInvocationCount var adjustedResponseCounts = summary.responseCounts if toolCount > 0 { adjustedResponseCounts["tool_call"] = toolCount } let adjustedEventCount = adjustedUser + adjustedAssistant + adjustedResponseCounts.values.reduce(0, +) var adjusted = SessionSummary( id: summary.id, fileURL: summary.fileURL, fileSizeBytes: summary.fileSizeBytes, startedAt: summary.startedAt, endedAt: summary.endedAt, activeDuration: summary.activeDuration, cliVersion: summary.cliVersion, cwd: summary.cwd, originator: summary.originator, instructions: summary.instructions, model: summary.model, approvalPolicy: summary.approvalPolicy, userMessageCount: adjustedUser, assistantMessageCount: adjustedAssistant, toolInvocationCount: adjustedTools, responseCounts: adjustedResponseCounts, turnContextCount: summary.turnContextCount, messageTypeCounts: summary.messageTypeCounts, totalTokens: summary.totalTokens, tokenBreakdown: summary.tokenBreakdown, eventCount: adjustedEventCount, lineCount: summary.lineCount, lastUpdatedAt: summary.lastUpdatedAt, source: summary.source, remotePath: summary.remotePath, userTitle: summary.userTitle, userComment: summary.userComment, taskId: summary.taskId ) adjusted.parseLevel = summary.parseLevel return adjusted } /// Align counts with the visible timeline logic to keep list metrics consistent. private func adjustCountsFromTimeline( summary: SessionSummary, rows: [SessionRow] ) -> SessionSummary { let loader = SessionTimelineLoader() let turns = loader.turns(from: rows) let turnCount = turns.count // Preserve the tool invocation count from the previous adjustCounts() step, // which correctly counts tool_use blocks in assistant messages. // Do NOT count tool outputs here (actor == .tool), as those are results, not calls. return ClaudeSessionParser.normalizeCounts( summary: summary, turnCount: turnCount, assistantTextCount: turnCount, toolCount: summary.toolInvocationCount) } /// Normalize counts to CodMate's definition: one user→assistant exchange equals one message. private static func normalizeCounts( summary: SessionSummary, turnCount: Int, assistantTextCount: Int, toolCount: Int ) -> SessionSummary { let turns = max(turnCount, 0) let assistant = turns > 0 ? max(min(turns, assistantTextCount), turns) : assistantTextCount let tools = max(toolCount, 0) var counts = summary.responseCounts counts["tool_call"] = tools return SessionSummary( id: summary.id, fileURL: summary.fileURL, fileSizeBytes: summary.fileSizeBytes, startedAt: summary.startedAt, endedAt: summary.endedAt, activeDuration: summary.activeDuration, cliVersion: summary.cliVersion, cwd: summary.cwd, originator: summary.originator, instructions: summary.instructions, model: summary.model, approvalPolicy: summary.approvalPolicy, userMessageCount: turns, assistantMessageCount: assistant, toolInvocationCount: tools, responseCounts: counts, turnContextCount: summary.turnContextCount, messageTypeCounts: summary.messageTypeCounts, totalTokens: summary.totalTokens, tokenBreakdown: summary.tokenBreakdown, eventCount: turns + tools, lineCount: summary.lineCount, lastUpdatedAt: summary.lastUpdatedAt, source: summary.source, remotePath: summary.remotePath, userTitle: summary.userTitle, userComment: summary.userComment, taskId: summary.taskId, parseLevel: .enriched // Mark as enriched with timeline-based turn counting ) } private func decodeLine(_ data: Data) -> ClaudeLogLine? { do { return try decoder.decode(ClaudeLogLine.self, from: data) } catch { return nil } } private func convert(_ line: ClaudeLogLine) -> [SessionRow] { guard let timestamp = line.timestamp else { return [] } guard let type = line.type else { return [] } // Skip sidechain messages (warmup, etc.) if line.isSidechain == true { return [] } switch type { case "user": return convertUser(line, timestamp: timestamp) case "assistant": return convertAssistant(line, timestamp: timestamp) case "system": return convertSystem(line, timestamp: timestamp) case "summary": guard let summary = line.summary else { return [] } let payload = EventMessagePayload( type: "system_summary", message: summary, kind: nil, text: summary, reason: nil, info: nil, rateLimits: nil) return [SessionRow(timestamp: timestamp, kind: .eventMessage(payload))] default: return [] } } private func convertUser(_ line: ClaudeLogLine, timestamp: Date) -> [SessionRow] { guard let message = line.message else { return [] } let blocks = Self.blocks(from: message) var rows: [SessionRow] = [] // Collect all text blocks and images into a single user message var textParts: [String] = [] var hasImage = false var imageDataURLs: [String] = [] for block in blocks { switch block.type { case "text", nil: if let text = Self.renderText(from: block), !text.isEmpty { textParts.append(text) } case "image": hasImage = true if let dataURL = Self.imageDataURL(from: block) { imageDataURLs.append(dataURL) } case "tool_result": let outputValue: JSONValue? = { if let content = block.content { return content } if let text = block.text, !text.isEmpty { return .string(text) } if let rendered = Self.renderText(from: block), !rendered.isEmpty { return .string(rendered) } return nil }() if let outputValue { let item = ResponseItemPayload( type: "tool_output", status: nil, callID: block.toolUseId, name: block.name, content: nil, summary: nil, encryptedContent: nil, role: "system", arguments: nil, input: nil, output: outputValue, ghostCommit: nil) rows.append(SessionRow(timestamp: timestamp, kind: .responseItem(item))) } default: break } } // Create a single user message combining all text blocks if !textParts.isEmpty || hasImage { let combinedText = textParts.joined(separator: "\n\n") // Check if this is a system-generated message that should be classified as "other" let isSystemGenerated = combinedText.contains("") || combinedText.contains("") || combinedText.contains("") || combinedText.contains("") || combinedText.contains("") || combinedText.hasPrefix("Caveat: ") let messageType = isSystemGenerated ? "info_other" : "user_message" let displayText = hasImage && combinedText.isEmpty ? "[Image]" : combinedText let payload = EventMessagePayload( type: messageType, message: displayText, kind: nil, text: displayText, reason: nil, info: nil, rateLimits: nil, images: imageDataURLs.isEmpty ? nil : imageDataURLs) rows.insert(SessionRow(timestamp: timestamp, kind: .eventMessage(payload)), at: 0) } if let toolResult = line.toolUseResult { let outputValue: JSONValue = toolResult let payload = ResponseItemPayload( type: "tool_output", status: nil, callID: nil, name: nil, content: nil, summary: nil, encryptedContent: nil, role: "system", arguments: nil, input: nil, output: outputValue, ghostCommit: nil ) rows.append(SessionRow(timestamp: timestamp, kind: .responseItem(payload))) } if let usage = message.usage, let row = tokenUsageRow(usage, timestamp: timestamp) { rows.append(row) } return rows } private func convertAssistant(_ line: ClaudeLogLine, timestamp: Date) -> [SessionRow] { guard let message = line.message else { return [] } let blocks = Self.blocks(from: message) var rows: [SessionRow] = [] for block in blocks { switch block.type { case "text", nil: if let text = Self.renderText(from: block), !text.isEmpty { let payload = EventMessagePayload( type: "agent_message", message: text, kind: nil, text: text, reason: nil, info: nil, rateLimits: nil) rows.append(SessionRow(timestamp: timestamp, kind: .eventMessage(payload))) } case "thinking": // Extended thinking block - count as reasoning if let text = Self.renderText(from: block), !text.isEmpty { let item = ResponseItemPayload( type: "reasoning", status: nil, callID: block.id, name: nil, content: [ResponseContentBlock(type: "text", text: text)], summary: nil, encryptedContent: nil, role: "assistant", arguments: nil, input: nil, output: nil, ghostCommit: nil) rows.append(SessionRow(timestamp: timestamp, kind: .responseItem(item))) } case "tool_use": let inputValue = block.input let item = ResponseItemPayload( type: "tool_call", status: nil, callID: block.id, name: block.name, content: nil, summary: nil, encryptedContent: nil, role: "assistant", arguments: nil, input: inputValue, output: nil, ghostCommit: nil) rows.append(SessionRow(timestamp: timestamp, kind: .responseItem(item))) default: break } } if let usage = message.usage, let row = tokenUsageRow(usage, timestamp: timestamp) { rows.append(row) } return rows } private func tokenUsageRow(_ usage: ClaudeUsage, timestamp: Date) -> SessionRow? { var info: [String: JSONValue] = [:] var hasNonZero = false func addNumber(_ key: String, _ value: Int?) { guard let value else { return } info[key] = .number(Double(value)) if value > 0 { hasNonZero = true } } addNumber("input", usage.inputTokens) addNumber("output", usage.outputTokens) addNumber("cacheRead", usage.cacheReadInputTokens) addNumber("cacheCreation", usage.cacheCreationInputTokens) addNumber("total", usage.totalTokens) if let cache = usage.cacheCreation { var cacheInfo: [String: JSONValue] = [:] if let value = cache.ephemeral5m { cacheInfo["ephemeral5m"] = .number(Double(value)) if value > 0 { hasNonZero = true } } if let value = cache.ephemeral1h { cacheInfo["ephemeral1h"] = .number(Double(value)) if value > 0 { hasNonZero = true } } if !cacheInfo.isEmpty { info["cacheCreationDetail"] = .object(cacheInfo) } } if let serverToolUse = usage.serverToolUse { info["serverToolUse"] = serverToolUse } if let tier = usage.serviceTier, !tier.isEmpty { info["serviceTier"] = .string(tier) } guard !info.isEmpty, hasNonZero else { return nil } let payload = EventMessagePayload( type: "token_count", message: nil, kind: nil, text: nil, reason: nil, info: .object(info), rateLimits: nil ) return SessionRow(timestamp: timestamp, kind: .eventMessage(payload)) } private func convertSystem(_ line: ClaudeLogLine, timestamp: Date) -> [SessionRow] { guard let message = line.message else { return [] } let text = Self.renderFlatText(message) ?? Self.renderText(from: Self.blocks(from: message).first) guard let text, !text.isEmpty else { return [] } let payload = EventMessagePayload( type: "system_message", message: text, kind: line.subtype, text: text, reason: nil, info: nil, rateLimits: nil) return [SessionRow(timestamp: timestamp, kind: .eventMessage(payload))] } private func buildSummary( url: URL, fileSize: UInt64?, metaRow: SessionRow, contextRow: SessionRow?, additionalRows: [SessionRow], totalTokens: Int, tokenBreakdown: SessionTokenBreakdown?, lastTimestamp: Date?, activeDuration: TimeInterval? ) -> SessionSummary? { var builder = SessionSummaryBuilder() builder.setSource(.claudeLocal) builder.setFileSize(fileSize) builder.seedTotalTokens(totalTokens) if let breakdown = tokenBreakdown { builder.seedTokenSnapshot( input: breakdown.input, output: breakdown.output, cacheRead: breakdown.cacheRead, cacheCreation: breakdown.cacheCreation ) } builder.observe(metaRow) if let contextRow { builder.observe(contextRow) } for row in additionalRows { builder.observe(row) } if let lastTimestamp { builder.seedLastUpdated(lastTimestamp) } builder.setModelFallback("Claude") guard let summary = builder.build(for: url) else { return nil } if let activeDuration { return SessionSummary( id: summary.id, fileURL: summary.fileURL, fileSizeBytes: summary.fileSizeBytes, startedAt: summary.startedAt, endedAt: summary.endedAt, activeDuration: activeDuration, cliVersion: summary.cliVersion, cwd: summary.cwd, originator: summary.originator, instructions: summary.instructions, model: summary.model, approvalPolicy: summary.approvalPolicy, userMessageCount: summary.userMessageCount, assistantMessageCount: summary.assistantMessageCount, toolInvocationCount: summary.toolInvocationCount, responseCounts: summary.responseCounts, turnContextCount: summary.turnContextCount, totalTokens: summary.totalTokens, tokenBreakdown: summary.tokenBreakdown, eventCount: summary.eventCount, lineCount: summary.lineCount, lastUpdatedAt: summary.lastUpdatedAt, source: summary.source, remotePath: summary.remotePath, userTitle: summary.userTitle, userComment: summary.userComment, taskId: summary.taskId ) } return summary } private static func blocks(from message: ClaudeMessage) -> [ClaudeContentBlock] { switch message.content { case .string(let text): return [ClaudeContentBlock(type: "text", text: text, thinking: nil, id: nil, name: nil, input: nil, toolUseId: nil, content: nil, signature: nil, source: nil)] case .blocks(let blocks): return blocks case .none: return [] } } private static func renderFlatText(_ message: ClaudeMessage) -> String? { switch message.content { case .string(let text): return text case .blocks(let blocks): let rendered = blocks.compactMap { renderText(from: $0) }.joined(separator: "\n") return rendered.isEmpty ? nil : rendered case .none: return nil } } private static func renderText(from block: ClaudeContentBlock?) -> String? { guard let block else { return nil } if let text = block.text, !text.isEmpty { return text } if let thinking = block.thinking, !thinking.isEmpty { return thinking } if let rendered = block.content.flatMap({ stringify($0) }), !rendered.isEmpty { return rendered } if let rendered = block.input.flatMap({ stringify($0) }), !rendered.isEmpty { return rendered } return nil } private static func imageDataURL(from block: ClaudeContentBlock) -> String? { guard block.type == "image", let source = block.source else { return nil } guard let data = source.data, !data.isEmpty else { return nil } let mediaType = source.mediaType?.trimmingCharacters(in: .whitespacesAndNewlines) let resolvedType = (mediaType?.isEmpty == false) ? mediaType! : "image/png" return "data:\(resolvedType);base64,\(data)" } private static func stringify(_ value: JSONValue?) -> String? { guard let value else { return nil } switch value { case .string(let str): return str case .number(let number): return String(number) case .bool(let flag): return flag ? "true" : "false" case .array(let array): let rendered = array.compactMap { stringify($0) }.joined(separator: "\n") return rendered.isEmpty ? nil : rendered case .object(let object): let raw = object.mapValues { $0.toAnyValue() } guard JSONSerialization.isValidJSONObject(raw), let data = try? JSONSerialization.data(withJSONObject: raw, options: [.prettyPrinted, .sortedKeys]), let text = String(data: data, encoding: .utf8) else { return nil } return text case .null: return nil } } private struct MetadataAccumulator { var sessionId: String? var agentId: String? var version: String? var cwd: String? var model: String? var instructions: String? var firstTimestamp: Date? var lastTimestamp: Date? var totalTokens: Int = 0 var tokenInput: Int = 0 var tokenOutput: Int = 0 var tokenCacheRead: Int = 0 var tokenCacheCreation: Int = 0 var seenMessageIds: Set = [] var usageByMessageId: [String: UsageSnapshot] = [:] struct UsageSnapshot { let total: Int let input: Int let output: Int let cacheRead: Int let cacheCreation: Int } mutating func consume( _ line: ClaudeLogLine, renderedText: String?, model: String?, usageTokens: Int? ) { if let sid = line.sessionId, sessionId == nil { sessionId = sid } if let aid = line.agentId, agentId == nil { agentId = aid } if let ver = line.version, version == nil { version = ver } if let path = line.cwd, cwd == nil { cwd = path } if let timestamp = line.timestamp { if firstTimestamp == nil || timestamp < firstTimestamp! { firstTimestamp = timestamp } if lastTimestamp == nil || timestamp > lastTimestamp! { lastTimestamp = timestamp } } if instructions == nil, line.isMeta == true, let text = renderedText, !text.isEmpty { instructions = text } if self.model == nil, let model, !model.isEmpty { self.model = model } let messageId = line.message?.id let isNewMessage = messageId.map { seenMessageIds.insert($0).inserted } ?? true if let usage = line.message?.usage { let snapshot = UsageSnapshot( total: usage.totalTokens, input: usage.inputTokens ?? 0, output: usage.outputTokens ?? 0, cacheRead: usage.cacheReadInputTokens ?? 0, cacheCreation: (usage.cacheCreationInputTokens ?? 0) + (usage.cacheCreation?.ephemeral5m ?? 0) + (usage.cacheCreation?.ephemeral1h ?? 0) ) applyUsage(snapshot: snapshot, messageId: messageId, isNewMessage: isNewMessage) } else if let usageTokens, usageTokens > 0 { let snapshot = UsageSnapshot(total: usageTokens, input: 0, output: 0, cacheRead: 0, cacheCreation: 0) applyUsage(snapshot: snapshot, messageId: messageId, isNewMessage: isNewMessage) } } private mutating func applyUsage(snapshot: UsageSnapshot, messageId: String?, isNewMessage: Bool) { // For messages with IDs, accumulate deltas (streamed usage updates share the same ID) if let messageId { let previous = usageByMessageId[messageId] let deltaTotal = snapshot.total - (previous?.total ?? 0) let deltaInput = snapshot.input - (previous?.input ?? 0) let deltaOutput = snapshot.output - (previous?.output ?? 0) let deltaCacheRead = snapshot.cacheRead - (previous?.cacheRead ?? 0) let deltaCacheCreation = snapshot.cacheCreation - (previous?.cacheCreation ?? 0) if deltaTotal > 0 { totalTokens &+= deltaTotal } if deltaInput > 0 { tokenInput &+= deltaInput } if deltaOutput > 0 { tokenOutput &+= deltaOutput } if deltaCacheRead > 0 { tokenCacheRead &+= deltaCacheRead } if deltaCacheCreation > 0 { tokenCacheCreation &+= deltaCacheCreation } usageByMessageId[messageId] = snapshot return } // Messages without IDs: retain legacy behavior to avoid over-counting duplicated lines. if isNewMessage { totalTokens &+= snapshot.total tokenInput &+= snapshot.input tokenOutput &+= snapshot.output tokenCacheRead &+= snapshot.cacheRead tokenCacheCreation &+= snapshot.cacheCreation } } func makeMetaRow() -> SessionRow? { guard let sessionId, let timestamp = firstTimestamp, let cwd else { return nil } let payload = SessionMetaPayload( id: sessionId, timestamp: timestamp, cwd: cwd, originator: "Claude Code", cliVersion: "claude-code \(version ?? "unknown")", instructions: instructions ) return SessionRow(timestamp: timestamp, kind: .sessionMeta(payload)) } func makeContextRow() -> SessionRow? { // For Claude sessions, we don't generate context update rows. // Model info is already shown in the session info card at the top. // This avoids duplicate "Syncing / Context Updated / model: xxx" entries in the timeline. return nil } func tokenBreakdown() -> SessionTokenBreakdown? { let input = tokenInput let output = tokenOutput let cacheRead = tokenCacheRead let cacheCreation = tokenCacheCreation if input == 0 && output == 0 && cacheRead == 0 && cacheCreation == 0 { return nil } return SessionTokenBreakdown( input: input, output: output, cacheRead: cacheRead, cacheCreation: cacheCreation ) } } private struct ClaudeLogLine: Decodable { let type: String? let timestamp: Date? let sessionId: String? let agentId: String? let version: String? let cwd: String? let message: ClaudeMessage? let toolUseResult: JSONValue? let summary: String? let isMeta: Bool? let subtype: String? let isSidechain: Bool? let uuid: String? } private struct ClaudeMessage: Decodable { let id: String? let role: String? let model: String? let content: ClaudeMessageContent? let usage: ClaudeUsage? enum CodingKeys: String, CodingKey { case id case role case model case content case usage } } private struct ClaudeUsage: Decodable { let inputTokens: Int? let outputTokens: Int? let cacheReadInputTokens: Int? let cacheCreationInputTokens: Int? let cacheCreation: CacheCreation? let serverToolUse: JSONValue? let serviceTier: String? enum CodingKeys: String, CodingKey { case inputTokens = "input_tokens" case outputTokens = "output_tokens" case cacheReadInputTokens = "cache_read_input_tokens" case cacheCreationInputTokens = "cache_creation_input_tokens" case cacheCreation = "cache_creation" case serverToolUse = "server_tool_use" case serviceTier = "service_tier" } struct CacheCreation: Decodable { let ephemeral5m: Int? let ephemeral1h: Int? enum CodingKeys: String, CodingKey { case ephemeral5m = "ephemeral_5m_input_tokens" case ephemeral1h = "ephemeral_1h_input_tokens" } } var totalTokens: Int { let creation = (cacheCreationInputTokens ?? 0) + (cacheCreation?.ephemeral5m ?? 0) + (cacheCreation?.ephemeral1h ?? 0) return (inputTokens ?? 0) + (outputTokens ?? 0) + (cacheReadInputTokens ?? 0) + creation } } private enum ClaudeMessageContent: Decodable { case string(String) case blocks([ClaudeContentBlock]) init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() if let text = try? container.decode(String.self) { self = .string(text) return } if let block = try? container.decode(ClaudeContentBlock.self) { self = .blocks([block]) return } if let blocks = try? container.decode([ClaudeContentBlock].self) { self = .blocks(blocks) return } self = .blocks([]) } } private struct ClaudeImageSource: Decodable { let type: String? let mediaType: String? let data: String? enum CodingKeys: String, CodingKey { case type case mediaType = "media_type" case data } } private struct ClaudeContentBlock: Decodable { let type: String? let text: String? let thinking: String? let id: String? let name: String? let input: JSONValue? let toolUseId: String? let content: JSONValue? let signature: String? let source: ClaudeImageSource? } private static func countToolUses(in message: ClaudeMessage?, seen: inout Set) -> Int { guard let message else { return 0 } switch message.content { case .blocks(let blocks): return blocks.reduce(0) { partial, block in guard block.type == "tool_use" else { return partial } if let id = block.id { return seen.insert(id).inserted ? partial + 1 : partial } return partial + 1 } case .string, .none: return 0 } } private static func hasRenderableText(_ message: ClaudeMessage?) -> Bool { guard let message else { return false } switch message.content { case .string(let text): return !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty case .blocks(let blocks): return blocks.contains { block in guard let rendered = renderText(from: block) else { return false } return !rendered.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } case .none: return false } } } private extension JSONValue { func toAnyValue() -> Any { switch self { case .string(let str): return str case .number(let number): return number case .bool(let flag): return flag case .array(let array): return array.map { $0.toAnyValue() } case .object(let dict): return dict.mapValues { $0.toAnyValue() } case .null: return NSNull() } } } ================================================ FILE: services/ClaudeSessionProvider.swift ================================================ import Foundation #if canImport(Darwin) import Darwin #endif actor ClaudeSessionProvider { enum SessionProviderCacheError: Error { case cacheUnavailable } private let parser = ClaudeSessionParser() private let fileManager: FileManager private let root: URL? private let cacheStore: SessionIndexSQLiteStore? // Best-effort cache: sessionId -> canonical file URL (updated on scans) private var canonicalURLById: [String: URL] = [:] // mtime/size summary cache to skip re-parse when unchanged private var summaryCache: [String: CacheEntry] = [:] private struct CacheEntry { let modificationDate: Date? let fileSize: UInt64? let summary: SessionSummary } init(fileManager: FileManager = .default, cacheStore: SessionIndexSQLiteStore? = nil) { self.fileManager = fileManager self.cacheStore = cacheStore // Use real user home directory, not sandbox container let home = Self.getRealUserHomeURL() let projects = home .appendingPathComponent(".claude", isDirectory: true) .appendingPathComponent("projects", isDirectory: true) root = fileManager.fileExists(atPath: projects.path) ? projects : nil } /// Get the real user home directory (not sandbox container) private static func getRealUserHomeURL() -> URL { #if canImport(Darwin) if let homeDir = getpwuid(getuid())?.pointee.pw_dir { let path = String(cString: homeDir) return URL(fileURLWithPath: path, isDirectory: true) } #endif if let home = ProcessInfo.processInfo.environment["HOME"] { return URL(fileURLWithPath: home, isDirectory: true) } return FileManager.default.homeDirectoryForCurrentUser } func sessions(scope: SessionLoadScope, ignoredPaths: [String] = []) async throws -> [SessionSummary] { guard cacheStore != nil else { throw SessionProviderCacheError.cacheUnavailable } guard let root else { return [] } guard let enumerator = fileManager.enumerator( at: root, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) else { return [] } // Gather all parsed summaries then dedupe by sessionId, // preferring canonical filenames and newer/longer files. var bestById: [String: SessionSummary] = [:] let urls = enumerator.compactMap { $0 as? URL } for url in urls { guard url.pathExtension.lowercased() == "jsonl" else { continue } // Apply ignore rules if shouldIgnorePath(url.path, ignoredPaths: ignoredPaths) { continue } let values = try url.resourceValues( forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey]) guard values.isRegularFile == true else { continue } let fileSize = resolveFileSize(for: url, resourceValues: values) let mtime = values.contentModificationDate let summary = try await cachedSummary(for: url, modificationDate: mtime, fileSize: fileSize) ?? parser.parse(at: url, fileSize: fileSize)?.summary guard let summary else { continue } // Check ignore rules against cwd // Note: If ignored, we skip caching this newly parsed session. // Existing cache entries remain untouched - they will be filtered out when reading, // but will reappear if ignore rules are removed later (cache preservation strategy). if shouldIgnoreSummary(summary, ignoredPaths: ignoredPaths) { continue } guard matches(scope: scope, summary: summary) else { continue } cache(summary: summary, for: url, modificationDate: mtime, fileSize: fileSize) persist(summary: summary, modificationDate: mtime, fileSize: fileSize) if let existing = bestById[summary.id] { let pick = prefer(lhs: existing, rhs: summary) bestById[summary.id] = pick } else { bestById[summary.id] = summary } } // Update canonical map for later fallbacks for (_, s) in bestById { canonicalURLById[s.id] = s.fileURL } return Array(bestById.values) } /// Load only the sessions under a specific project directory (e.g. ~/.claude/projects/-Users-loocor-GitHub-CodMate) /// Directory should be the original project cwd; it will be encoded to Claude's folder name. func sessions(inProjectDirectory directory: String) async throws -> [SessionSummary] { guard cacheStore != nil else { throw SessionProviderCacheError.cacheUnavailable } guard let root else { return [] } let folder = encodeProjectFolder(from: directory) let projectURL = root.appendingPathComponent(folder, isDirectory: true) guard let enumerator = fileManager.enumerator( at: projectURL, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) else { return [] } var results: [SessionSummary] = [] let urls = enumerator.compactMap { $0 as? URL } for url in urls { guard url.pathExtension.lowercased() == "jsonl" else { continue } let values = try url.resourceValues( forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey]) guard values.isRegularFile == true else { continue } let fileSize = resolveFileSize(for: url, resourceValues: values) let mtime = values.contentModificationDate let summary = try await cachedSummary(for: url, modificationDate: mtime, fileSize: fileSize) ?? parser.parse(at: url, fileSize: fileSize)?.summary if let summary { cache(summary: summary, for: url, modificationDate: mtime, fileSize: fileSize) persist(summary: summary, modificationDate: mtime, fileSize: fileSize) results.append(summary) } } return results } private func encodeProjectFolder(from cwd: String) -> String { let expanded = (cwd as NSString).expandingTildeInPath var standardized = URL(fileURLWithPath: expanded).standardizedFileURL.path if standardized.hasSuffix("/") && standardized.count > 1 { standardized.removeLast() } var name = standardized.replacingOccurrences(of: ":", with: "-") name = name.replacingOccurrences(of: "/", with: "-") if !name.hasPrefix("-") { name = "-" + name } return name } func countAllSessions() -> Int { guard let root else { return 0 } guard let enumerator = fileManager.enumerator( at: root, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) else { return 0 } var total = 0 for case let url as URL in enumerator where url.pathExtension.lowercased() == "jsonl" { let name = url.deletingPathExtension().lastPathComponent if name.hasPrefix("agent-") { continue } let values = try? url.resourceValues(forKeys: [.fileSizeKey]) if let size = values?.fileSize, size == 0 { continue } total += 1 } return total } func collectCWDCounts() async -> [String: Int] { guard let root else { return [:] } guard let enumerator = fileManager.enumerator( at: root, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) else { return [:] } var counts: [String: Int] = [:] let urls = enumerator.compactMap { $0 as? URL } do { for url in urls { guard url.pathExtension.lowercased() == "jsonl" else { continue } let values = try url.resourceValues( forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey]) guard values.isRegularFile == true else { continue } let fileSize = resolveFileSize(for: url, resourceValues: values) let mtime = values.contentModificationDate if let summary = try await cachedSummary(for: url, modificationDate: mtime, fileSize: fileSize) { counts[summary.cwd, default: 0] += 1 continue } if let parsed = parser.parse(at: url, fileSize: fileSize) { cache(summary: parsed.summary, for: url, modificationDate: mtime, fileSize: fileSize) counts[parsed.summary.cwd, default: 0] += 1 } } } catch { return [:] } return counts } func enrich(summary: SessionSummary) -> SessionSummary? { guard summary.source.baseKind == .claude else { return summary } // Parse using canonical file path when available let url = resolveCanonicalURL(for: summary) guard let parsed = parser.parse(at: url) else { return nil } let loader = SessionTimelineLoader() let turns = loader.turns(from: parsed.rows) let activeDuration = computeActiveDuration(turns: turns) return SessionSummary( id: parsed.summary.id, fileURL: parsed.summary.fileURL, fileSizeBytes: parsed.summary.fileSizeBytes, startedAt: parsed.summary.startedAt, endedAt: parsed.summary.endedAt, activeDuration: activeDuration, cliVersion: parsed.summary.cliVersion, cwd: parsed.summary.cwd, originator: parsed.summary.originator, instructions: parsed.summary.instructions, model: parsed.summary.model, approvalPolicy: parsed.summary.approvalPolicy, userMessageCount: parsed.summary.userMessageCount, assistantMessageCount: parsed.summary.assistantMessageCount, toolInvocationCount: parsed.summary.toolInvocationCount, responseCounts: parsed.summary.responseCounts, turnContextCount: parsed.summary.turnContextCount, messageTypeCounts: parsed.summary.messageTypeCounts, totalTokens: parsed.summary.totalTokens, tokenBreakdown: parsed.summary.tokenBreakdown, eventCount: parsed.summary.eventCount, lineCount: parsed.summary.lineCount, lastUpdatedAt: parsed.summary.lastUpdatedAt, source: parsed.summary.source, remotePath: parsed.summary.remotePath, userTitle: parsed.summary.userTitle, userComment: parsed.summary.userComment ) } func timeline(for summary: SessionSummary) -> [ConversationTurn]? { guard summary.source.baseKind == .claude else { return nil } let url = resolveCanonicalURL(for: summary) guard let parsed = parser.parse(at: url) else { return nil } let loader = SessionTimelineLoader() return loader.turns(from: parsed.rows) } private func matches(scope: SessionLoadScope, summary: SessionSummary) -> Bool { let calendar = Calendar.current let referenceDates = [ summary.startedAt, summary.lastUpdatedAt ?? summary.startedAt ] switch scope { case .all: return true case .today: return referenceDates.contains(where: { calendar.isDateInToday($0) }) case .day(let day): return referenceDates.contains(where: { calendar.isDate($0, inSameDayAs: day) }) case .month(let date): return referenceDates.contains { calendar.isDate($0, equalTo: date, toGranularity: .month) } } } private func computeActiveDuration(turns: [ConversationTurn]) -> TimeInterval? { guard !turns.isEmpty else { return nil } let filtered = turns.removingEnvironmentContext() guard !filtered.isEmpty else { return nil } var total: TimeInterval = 0 for turn in filtered { let start = turn.userMessage?.timestamp ?? turn.outputs.first?.timestamp guard let s = start, let end = turn.outputs.last?.timestamp else { continue } let delta = end.timeIntervalSince(s) if delta > 0 { total += delta } } return total } private func cachedSummary(for url: URL, modificationDate: Date?, fileSize: UInt64?) async throws -> SessionSummary? { if let entry = summaryCache[url.path], entry.modificationDate == modificationDate, entry.fileSize == fileSize { canonicalURLById[entry.summary.id] = url return entry.summary } guard let cacheStore, let modificationDate else { return nil } guard let cached = try await cacheStore.fetch( path: url.path, modificationDate: modificationDate, fileSize: fileSize ) else { return nil } cache(summary: cached, for: url, modificationDate: modificationDate, fileSize: fileSize) return cached } private func cache(summary: SessionSummary, for url: URL, modificationDate: Date?, fileSize: UInt64?) { summaryCache[url.path] = CacheEntry(modificationDate: modificationDate, fileSize: fileSize, summary: summary) canonicalURLById[summary.id] = url } private func persist(summary: SessionSummary, modificationDate: Date?, fileSize: UInt64?) { guard let cacheStore else { return } Task.detached { [cacheStore] in try? await cacheStore.upsert( summary: summary, project: nil, fileModificationTime: modificationDate, fileSize: fileSize, tokenBreakdown: summary.tokenBreakdown, parseError: nil ) } } private func resolveFileSize(for url: URL) -> UInt64? { if let values = try? url.resourceValues(forKeys: [.fileSizeKey]), let size = values.fileSize { return UInt64(size) } if let attributes = try? fileManager.attributesOfItem(atPath: url.path), let number = attributes[.size] as? NSNumber { return number.uint64Value } return nil } private func resolveFileSize(for url: URL, resourceValues: URLResourceValues) -> UInt64? { if let size = resourceValues.fileSize { return UInt64(size) } return resolveFileSize(for: url) } // MARK: - Canonical resolution and dedupe helpers /// Prefer canonical filename and more complete/updated files for the same session ID. /// Heuristics: /// - Prefer non "agent-" filenames over "agent-" (agent is an early placeholder) /// - If both non-agent, pick the one with later lastUpdated or larger file size private func prefer(lhs: SessionSummary, rhs: SessionSummary) -> SessionSummary { if lhs.id != rhs.id { return lhs } // shouldn't happen, but keep lhs let isAgentL = lhs.fileURL.deletingPathExtension().lastPathComponent.hasPrefix("agent-") let isAgentR = rhs.fileURL.deletingPathExtension().lastPathComponent.hasPrefix("agent-") if isAgentL != isAgentR { return isAgentL ? rhs : lhs } // Both same class; prefer newer lastUpdated, then larger size let lt = lhs.lastUpdatedAt ?? lhs.startedAt let rt = rhs.lastUpdatedAt ?? rhs.startedAt if lt != rt { return lt > rt ? lhs : rhs } let ls = lhs.fileSizeBytes ?? 0 let rs = rhs.fileSizeBytes ?? 0 if ls != rs { return ls > rs ? lhs : rhs } // Stable fallback: lexical by filename to reduce churn return lhs.fileURL.lastPathComponent < rhs.fileURL.lastPathComponent ? lhs : rhs } /// Resolve a stable file URL for a session summary. Handles cases where the /// initial file was "agent-*.jsonl" and later renamed to canonical UUID or /// rollout-named files. Falls back to summary.fileURL if nothing better is found. private func resolveCanonicalURL(for summary: SessionSummary) -> URL { // 1) If file exists and is readable, use it. if fileManager.fileExists(atPath: summary.fileURL.path) { return summary.fileURL } // 2) Return cached mapping if available if let cached = canonicalURLById[summary.id], fileManager.fileExists(atPath: cached.path) { return cached } // 3) Probe sibling files under the project folder for a better match let dir = summary.fileURL.deletingLastPathComponent() if let best = findSibling(bySessionId: summary.id, inDirectory: dir) { canonicalURLById[summary.id] = best return best } // 4) As a last resort, scan the entire Claude root if let root, let best = findSibling(bySessionId: summary.id, inDirectory: root) { canonicalURLById[summary.id] = best return best } return summary.fileURL } /// Find a file in the given directory tree that belongs to the sessionId, /// preferring non-agent names and newest mtime. private func findSibling(bySessionId sessionId: String, inDirectory base: URL) -> URL? { guard let enumerator = fileManager.enumerator( at: base, includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) else { return nil } var candidates: [(url: URL, mtime: Date, isAgent: Bool)] = [] for case let url as URL in enumerator { guard url.pathExtension.lowercased() == "jsonl" else { continue } // Quick filename check: many canonical files include the sessionId directly let name = url.deletingPathExtension().lastPathComponent if name.contains(sessionId) { let mtime = (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast candidates.append((url, mtime, name.hasPrefix("agent-"))) continue } // As a fallback, peek the sessionId from file contents (cheap prefix scan) if let sid = parser.fastSessionId(at: url), sid == sessionId { let mtime = (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast candidates.append((url, mtime, name.hasPrefix("agent-"))) } } guard !candidates.isEmpty else { return nil } // Prefer non-agent, then newest mtime candidates.sort { a, b in if a.isAgent != b.isAgent { return !a.isAgent } // non-agent first if a.mtime != b.mtime { return a.mtime > b.mtime } return a.url.lastPathComponent < b.url.lastPathComponent } return candidates.first?.url } } // MARK: - SessionProvider extension ClaudeSessionProvider: SessionProvider { nonisolated var kind: SessionSource.Kind { .claude } nonisolated var identifier: String { "claude-local" } nonisolated var label: String { "Claude (local)" } func load(context: SessionProviderContext) async throws -> SessionProviderResult { switch context.cachePolicy { case .cacheOnly: if let cacheStore { let dateColumn = context.dateDimension == .updated ? "COALESCE(last_updated_at, started_at)" : "started_at" let range = context.dateRange ?? Self.dateRange(for: context.scope) var cached = try await cacheStore.fetchSummaries( kinds: [.claude], includeRemote: false, dateColumn: dateColumn, dateRange: range, projectIds: context.projectIds ) // Apply ignore rules to cached results let originalCount = cached.count if !context.ignoredPaths.isEmpty { cached = cached.filter { !shouldIgnoreSummary($0, ignoredPaths: context.ignoredPaths) } print("ClaudeSessionProvider: filtered \(originalCount - cached.count) sessions by ignore rules (\(cached.count) remain)") } if !cached.isEmpty { return SessionProviderResult(summaries: cached, coverage: nil, cacheHit: true) } } return SessionProviderResult(summaries: [], coverage: nil, cacheHit: true) case .refresh: guard let cacheStore else { throw SessionProviderCacheError.cacheUnavailable } // Require cache availability; if missing/unopenable, surface error instead of falling back to parse. _ = try await cacheStore.fetchMeta() let summaries = try await sessions(scope: context.scope, ignoredPaths: context.ignoredPaths) return SessionProviderResult(summaries: summaries, coverage: nil, cacheHit: false) } } private static func dateRange(for scope: SessionLoadScope) -> (Date, Date)? { let cal = Calendar.current switch scope { case .all: return nil case .today: let start = cal.startOfDay(for: Date()) guard let end = cal.date(byAdding: .day, value: 1, to: start)?.addingTimeInterval(-1) else { return nil } return (start, end) case .day(let day): let start = cal.startOfDay(for: day) guard let end = cal.date(byAdding: .day, value: 1, to: start)?.addingTimeInterval(-1) else { return nil } return (start, end) case .month(let date): guard let start = cal.date(from: cal.dateComponents([.year, .month], from: date)), let end = cal.date(byAdding: DateComponents(month: 1, second: -1), to: start) else { return nil } return (start, end) } } // MARK: - Ignore Rules private func shouldIgnorePath(_ absolutePath: String, ignoredPaths: [String]) -> Bool { SessionPathFilter.shouldIgnorePath(absolutePath, ignoredPaths: ignoredPaths) } private func shouldIgnoreSummary(_ summary: SessionSummary, ignoredPaths: [String]) -> Bool { SessionPathFilter.shouldIgnoreSummary(summary, ignoredPaths: ignoredPaths) } } ================================================ FILE: services/ClaudeSettingsService.swift ================================================ import Foundation // MARK: - Claude Code user settings writer (~/.claude/settings.json) actor ClaudeSettingsService { struct Paths { let dir: URL let file: URL static func `default`() -> Paths { let home = SessionPreferencesStore.getRealUserHomeURL() let dir = home.appendingPathComponent(".claude", isDirectory: true) return Paths(dir: dir, file: dir.appendingPathComponent("settings.json", isDirectory: false)) } } // MARK: - Runtime composite struct Runtime: Sendable { var permissionMode: String? // default/acceptEdits/bypassPermissions/plan var skipPermissions: Bool var allowSkipPermissions: Bool var debug: Bool var debugFilter: String? var verbose: Bool var ide: Bool var strictMCP: Bool var fallbackModel: String? var allowedTools: String? var disallowedTools: String? var addDirs: [String]? } struct NotificationHooksStatus: Sendable { var permissionHookInstalled: Bool var completionHookInstalled: Bool } private enum HookEvent: String { case permission case complete } private struct HookPayload { var title: String var body: String } private let codMateHookURLPrefix = "codmate://notify?source=claude&event=" private let claudeNotificationKey = "Notification" private let claudeStopKey = "Stop" private let codMateManagedHookNamePrefix = "codmate-hook:" func applyRuntime(_ r: Runtime) throws { var obj = loadObject() func setOrRemove(_ key: String, _ value: Any?) { if let v = value { obj[key] = v } else { obj.removeValue(forKey: key) } } // permissionMode: omit when default let pm = (r.permissionMode == nil || r.permissionMode == "default") ? nil : r.permissionMode setOrRemove("permissionMode", pm) // booleans: only store when true to keep file light setOrRemove("skipPermissions", r.skipPermissions ? true : nil) setOrRemove("allowSkipPermissions", r.allowSkipPermissions ? true : nil) setOrRemove("debug", r.debug ? true : nil) let df = (r.debugFilter?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? r.debugFilter : nil setOrRemove("debugFilter", df) setOrRemove("verbose", r.verbose ? true : nil) setOrRemove("ide", r.ide ? true : nil) setOrRemove("strictMCP", r.strictMCP ? true : nil) let fb = (r.fallbackModel?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? r.fallbackModel : nil setOrRemove("fallbackModel", fb) let at = (r.allowedTools?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? r.allowedTools : nil let dt = (r.disallowedTools?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? r.disallowedTools : nil setOrRemove("allowedTools", at) setOrRemove("disallowedTools", dt) let dirs = (r.addDirs?.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) setOrRemove("addDirs", (dirs?.isEmpty == false) ? dirs : nil) try writeObject(obj) } // MARK: - Notification hooks (CodMate-managed) func codMateNotificationHooksStatus() -> NotificationHooksStatus { let obj = loadObject() guard let hooks = obj["hooks"] as? [String: Any] else { return NotificationHooksStatus(permissionHookInstalled: false, completionHookInstalled: false) } return NotificationHooksStatus( permissionHookInstalled: containsCodMateHook(in: hooks, key: claudeNotificationKey, event: .permission), completionHookInstalled: containsCodMateHook(in: hooks, key: claudeStopKey, event: .complete) ) } func setCodMateNotificationHooks(enabled: Bool) throws { var obj = loadObject() var hooks = obj["hooks"] as? [String: Any] ?? [:] hooks = updateHooksContainer( hooks, key: claudeNotificationKey, event: .permission, enabled: enabled ) hooks = updateHooksContainer( hooks, key: claudeStopKey, event: .complete, enabled: enabled ) if hooks.isEmpty { obj.removeValue(forKey: "hooks") } else { obj["hooks"] = hooks } try writeObject(obj) } // MARK: - User hooks (CodMate Extensions) func applyHooksFromCodMate(_ rules: [HookRule]) throws -> [HookSyncWarning] { var obj = loadObject() if (obj["allowManagedHooksOnly"] as? Bool) == true { return [ HookSyncWarning( provider: .claude, message: "Claude Code settings has allowManagedHooksOnly=true; skipping hooks apply." ) ] } var warnings: [HookSyncWarning] = [] var hooks = obj["hooks"] as? [String: Any] ?? [:] // Remove previously applied CodMate-managed hooks (by name prefix). hooks = pruneCodMateManagedHooks(hooks) let filtered = rules.filter { $0.isEnabled(for: .claude) } for rule in filtered { let rawEvent = rule.event.trimmingCharacters(in: .whitespacesAndNewlines) guard !rawEvent.isEmpty else { continue } let resolution = HookEventCatalog.resolveProviderEvent(rawEvent, for: .claude) if resolution.isKnown, !resolution.isSupported { warnings.append(HookSyncWarning( provider: .claude, message: "Claude Code does not support hook event \"\(rawEvent)\"; skipping \"\(rule.name)\"." )) continue } let event = resolution.name let supportsMatcher = HookEventCatalog.supportsMatcher(resolution.canonicalName, provider: .claude) let matcherText = rule.matcher?.trimmingCharacters(in: .whitespacesAndNewlines) let matcher = supportsMatcher ? (matcherText?.isEmpty == false ? matcherText : nil) : nil if !supportsMatcher, matcherText?.isEmpty == false { warnings.append(HookSyncWarning( provider: .claude, message: "Claude hook event \"\(event)\" does not support matcher; ignoring matcher for \"\(rule.name)\"." )) } var hookObjects: [[String: Any]] = [] for (index, cmd) in rule.commands.enumerated() { let program = cmd.command.trimmingCharacters(in: .whitespacesAndNewlines) guard !program.isEmpty else { continue } var hook: [String: Any] = [ "type": "command", "command": program, "name": "\(codMateManagedHookNamePrefix)\(rule.id):\(index)" ] if let args = cmd.args, !args.isEmpty { hook["args"] = args } if let timeout = cmd.timeoutMs { hook["timeout"] = timeout } if let env = cmd.env, !env.isEmpty { warnings.append(HookSyncWarning( provider: .claude, message: "Claude Code hook commands do not support env in settings.json; ignoring env for \"\(rule.name)\"." )) } hookObjects.append(hook) } guard !hookObjects.isEmpty else { continue } var entries = (hooks[event] as? [[String: Any]]) ?? [] let matcherKey: String? = matcher if let idx = entries.firstIndex(where: { entry in let existing = (entry["matcher"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let existingKey = (existing?.isEmpty == false) ? existing : nil return existingKey == matcherKey }) { var entry = entries[idx] var nested = (entry["hooks"] as? [[String: Any]]) ?? [] nested.append(contentsOf: hookObjects) entry["hooks"] = nested entries[idx] = entry } else { var entry: [String: Any] = ["hooks": hookObjects] if let matcherKey { entry["matcher"] = matcherKey } entries.append(entry) } hooks[event] = entries } if hooks.isEmpty { obj.removeValue(forKey: "hooks") } else { obj["hooks"] = hooks } try writeObject(obj) return warnings } func importHooksAsCodMateRules() -> [HookRule] { let obj = loadObject() guard let hooks = obj["hooks"] as? [String: Any] else { return [] } var rules: [HookRule] = [] for (event, value) in hooks { guard let entries = value as? [[String: Any]] else { continue } let canonicalEvent = HookEventCatalog.canonicalName(for: event, provider: .claude) for entry in entries { let matcher = (entry["matcher"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) guard let hookList = entry["hooks"] as? [[String: Any]] else { continue } var commands: [HookCommand] = [] for hook in hookList { guard (hook["type"] as? String) == "command" else { continue } guard let command = hook["command"] as? String else { continue } if command.contains(codMateHookURLPrefix) { continue } // managed by Notifications UI let args = hook["args"] as? [String] let timeout = (hook["timeout"] as? Int) ?? (hook["timeout"] as? NSNumber)?.intValue commands.append(HookCommand(command: command, args: args, env: nil, timeoutMs: timeout)) } guard !commands.isEmpty else { continue } let name = HookEventCatalog.defaultName(event: canonicalEvent, matcher: matcher, command: commands.first) let targets = HookTargets(codex: false, claude: true, gemini: false) rules.append(HookRule( name: name, event: canonicalEvent, matcher: (matcher?.isEmpty == false ? matcher : nil), commands: commands, enabled: true, targets: targets, source: "import" )) } } return rules } private func pruneCodMateManagedHooks(_ hooks: [String: Any]) -> [String: Any] { var out: [String: Any] = [:] for (event, value) in hooks { guard let entries = value as? [[String: Any]] else { out[event] = value continue } var newEntries: [[String: Any]] = [] for var entry in entries { guard var nested = entry["hooks"] as? [[String: Any]] else { newEntries.append(entry) continue } nested.removeAll { hook in guard let name = hook["name"] as? String else { return false } return name.hasPrefix(codMateManagedHookNamePrefix) } guard !nested.isEmpty else { continue } entry["hooks"] = nested newEntries.append(entry) } if !newEntries.isEmpty { out[event] = newEntries } } return out } private func containsCodMateHook(in hooks: [String: Any], key: String, event: HookEvent) -> Bool { guard let entries = hooks[key] as? [[String: Any]] else { return false } let marker = "\(codMateHookURLPrefix)\(event.rawValue)" for entry in entries { guard let nested = entry["hooks"] as? [[String: Any]] else { continue } if nested.contains(where: { ($0["command"] as? String)?.contains(marker) == true }) { return true } } return false } private func updateHooksContainer( _ hooks: [String: Any], key: String, event: HookEvent, enabled: Bool ) -> [String: Any] { var container = hooks var entries = (container[key] as? [[String: Any]]) ?? [] let marker = "\(codMateHookURLPrefix)\(event.rawValue)" entries.removeAll { entry in guard let nested = entry["hooks"] as? [[String: Any]] else { return false } return nested.contains { ($0["command"] as? String)?.contains(marker) == true } } if enabled { if let urlString = hookURL(for: event) { // 使用 -j (隐藏启动) 而不是 -g (后台启动) 来防止 SwiftUI WindowGroup 自动创建新窗口 let command = "/usr/bin/open -j \"\(urlString)\"" entries.append(["hooks": [["type": "command", "command": command]]]) } } if entries.isEmpty { container.removeValue(forKey: key) } else { container[key] = entries } return container } private func hookURL(for event: HookEvent) -> String? { let payload = hookPayload(for: event) var comps = URLComponents() comps.scheme = "codmate" comps.host = "notify" var query: [URLQueryItem] = [ URLQueryItem(name: "source", value: "claude"), URLQueryItem(name: "event", value: event.rawValue) ] if let titleData = payload.title.data(using: .utf8) { query.append(URLQueryItem(name: "title64", value: titleData.base64EncodedString())) } if let bodyData = payload.body.data(using: .utf8) { query.append(URLQueryItem(name: "body64", value: bodyData.base64EncodedString())) } comps.queryItems = query return comps.url?.absoluteString } private func hookPayload(for event: HookEvent) -> HookPayload { switch event { case .permission: return HookPayload( title: "Claude Code", body: "Claude Code requires approval. Return to the Claude window to respond." ) case .complete: return HookPayload( title: "Claude Code", body: "Claude Code finished its current task." ) } } private let fm: FileManager private let paths: Paths init(fileManager: FileManager = .default, paths: Paths = .default()) { self.fm = fileManager self.paths = paths } // Load existing JSON dict or empty private func loadObject() -> [String: Any] { guard let data = try? Data(contentsOf: paths.file) else { return [:] } return (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:] } // Atomic write with backup private func writeObject(_ obj: [String: Any]) throws { try fm.createDirectory(at: paths.dir, withIntermediateDirectories: true) if let data = try? Data(contentsOf: paths.file) { let backup = paths.file.appendingPathExtension("backup") try? data.write(to: backup, options: .atomic) } let out = try JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) try out.write(to: paths.file, options: .atomic) } // MARK: - Public upserts func setModel(_ modelId: String?) throws { var obj = loadObject() if let m = modelId?.trimmingCharacters(in: .whitespacesAndNewlines), !m.isEmpty { obj["model"] = m } else { obj.removeValue(forKey: "model") } try writeObject(obj) } func setForceLoginMethod(_ method: String?) throws { var obj = loadObject() if let m = method?.trimmingCharacters(in: .whitespacesAndNewlines), !m.isEmpty { obj["forceLoginMethod"] = m } else { obj.removeValue(forKey: "forceLoginMethod") } try writeObject(obj) } func setEnvBaseURL(_ baseURL: String?) throws { var obj = loadObject() var env = (obj["env"] as? [String: Any]) ?? [:] if let url = baseURL?.trimmingCharacters(in: .whitespacesAndNewlines), !url.isEmpty { env["ANTHROPIC_BASE_URL"] = url } else { env.removeValue(forKey: "ANTHROPIC_BASE_URL") } if env.isEmpty { obj.removeValue(forKey: "env") } else { obj["env"] = env } try writeObject(obj) } func setEnvToken(_ token: String?) throws { var obj = loadObject() var env = (obj["env"] as? [String: Any]) ?? [:] if let t = token?.trimmingCharacters(in: .whitespacesAndNewlines), !t.isEmpty { env["ANTHROPIC_AUTH_TOKEN"] = t } else { env.removeValue(forKey: "ANTHROPIC_AUTH_TOKEN") } if env.isEmpty { obj.removeValue(forKey: "env") } else { obj["env"] = env } try writeObject(obj) } func setEnvValues(_ entries: [String: String?]) throws { guard !entries.isEmpty else { return } var obj = loadObject() var env = (obj["env"] as? [String: Any]) ?? [:] for (key, value) in entries { if let v = value?.trimmingCharacters(in: .whitespacesAndNewlines), !v.isEmpty { env[key] = v } else { env.removeValue(forKey: key) } } if env.isEmpty { obj.removeValue(forKey: "env") } else { obj["env"] = env } try writeObject(obj) } func currentModel() -> String? { let obj = loadObject() return obj["model"] as? String } func envSnapshot() -> [String: String] { let obj = loadObject() guard let env = obj["env"] as? [String: Any] else { return [:] } var out: [String: String] = [:] for (key, value) in env { if let str = value as? String, !str.isEmpty { out[key] = str } } return out } } ================================================ FILE: services/ClaudeUsageAPIClient.swift ================================================ import Foundation import Security import CryptoKit struct ClaudeUsageAPIClient { enum ClientError: Error, LocalizedError { case credentialNotFound case keychainAccessRestricted(OSStatus) case malformedCredential case missingAccessToken case credentialExpired(Date) case requestFailed(Int) case emptyResponse case decodingFailed var errorDescription: String? { switch self { case .credentialNotFound: return "Claude Code keychain entry not found." case .keychainAccessRestricted(let status): return SecCopyErrorMessageString(status, nil) as String? ?? "Keychain access denied." case .malformedCredential: return "Claude Code credential payload is invalid." case .missingAccessToken: return "Claude Code credential is missing an access token." case .credentialExpired(let date): let formatter = DateFormatter() formatter.dateStyle = .medium formatter.timeStyle = .short return "Claude Code credential expired on \(formatter.string(from: date)). Please sign in again." case .requestFailed(let code): return "Claude usage API returned status \(code)." case .emptyResponse: return "Claude usage API returned no data." case .decodingFailed: return "Failed to decode Claude usage response." } } } private struct CredentialEnvelope: Decodable { struct OAuth: Decodable { let accessToken: String let expiresAt: TimeInterval? let rateLimitTier: String? enum CodingKeys: String, CodingKey { case accessToken case expiresAt case rateLimitTier = "rate_limit_tier" } } let claudeAiOauth: OAuth } private struct UsageLimitsResponse: Decodable { struct Window: Decodable { let utilization: Double? let resetsAt: Date? enum CodingKeys: String, CodingKey { case utilization case resetsAt = "resets_at" } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) utilization = try container.decodeIfPresent(Double.self, forKey: .utilization) if let raw = try container.decodeIfPresent(String.self, forKey: .resetsAt) { resetsAt = ClaudeUsageAPIClient.isoFormatter.date(from: raw) } else { resetsAt = nil } } } let fiveHour: Window? let sevenDay: Window? enum CodingKeys: String, CodingKey { case fiveHour = "five_hour" case sevenDay = "seven_day" } } private static let isoFormatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return formatter }() private let session: URLSession init(session: URLSession = .shared) { self.session = session } func fetchUsageStatus(now: Date = Date()) async throws -> ClaudeUsageStatus { // Priority 1: Try Web API (browser cookies) do { let status = try await ClaudeWebAPIClient.fetchUsageViaWebAPI(now: now) NSLog("[ClaudeUsage] Web API succeeded") return status } catch { // Web API failed, fall back to OAuth API NSLog("[ClaudeUsage] Web API failed: \(error.localizedDescription), falling back to OAuth API") } // Priority 2: OAuth API (existing implementation) let credential = try fetchCredentialEnvelope() var sessionExpiresAt: Date? = nil if let expiresAt = credential.claudeAiOauth.expiresAt { // expiresAt is in milliseconds, convert to seconds let expiryDate = Date(timeIntervalSince1970: expiresAt / 1000) sessionExpiresAt = expiryDate if expiryDate < now { throw ClientError.credentialExpired(expiryDate) } } let token = credential.claudeAiOauth.accessToken let response = try await fetchUsageLimits(token: token) guard response.fiveHour != nil || response.sevenDay != nil else { throw ClientError.emptyResponse } let fiveHourWindowMinutes = 5.0 * 60.0 let weeklyWindowMinutes = 7.0 * 24.0 * 60.0 func minutesUsed(from window: UsageLimitsResponse.Window?, windowMinutes: Double) -> Double? { guard let utilization = window?.utilization else { return nil } let percent = max(0, min(utilization, 100)) return (percent / 100.0) * windowMinutes } // Try to detect plan type from OAuth token (best effort) var planType = await detectPlanTypeViaOAuth(token: token) if planType == nil, let tier = credential.claudeAiOauth.rateLimitTier { planType = mapRateLimitTierToPlan(tier) if planType != nil { NSLog("[ClaudeUsage] Detected plan type from credential: \(tier) -> \(planType!)") } } let status = ClaudeUsageStatus( updatedAt: now, modelName: nil, contextUsedTokens: nil, contextLimitTokens: nil, fiveHourUsedMinutes: minutesUsed(from: response.fiveHour, windowMinutes: fiveHourWindowMinutes), fiveHourWindowMinutes: fiveHourWindowMinutes, fiveHourResetAt: response.fiveHour?.resetsAt, weeklyUsedMinutes: minutesUsed(from: response.sevenDay, windowMinutes: weeklyWindowMinutes), weeklyWindowMinutes: weeklyWindowMinutes, weeklyResetAt: response.sevenDay?.resetsAt, sessionExpiresAt: sessionExpiresAt, planType: planType ) return status } private func fetchCredentialEnvelope() throws -> CredentialEnvelope { if let credential = try fetchEnvelopeFromKeychain() { return credential } if let credential = fetchEnvelopeFromPlaintext() { return credential } throw ClientError.credentialNotFound } private func fetchEnvelopeFromKeychain() throws -> CredentialEnvelope? { let accountName = Self.keychainAccountName() var lastError: Error? for service in Self.candidateCredentialServiceNames() { do { if let envelope = try fetchEnvelope(service: service, account: accountName) { return envelope } } catch let error as ClientError { lastError = error } } if let error = lastError { throw error } return nil } private func fetchEnvelope(service: String, account: String) throws -> CredentialEnvelope? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnData as String: true ] var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) if status == errSecItemNotFound { return nil } guard status == errSecSuccess else { throw ClientError.keychainAccessRestricted(status) } guard let data = item as? Data else { throw ClientError.malformedCredential } let decoder = JSONDecoder() guard let envelope = try? decoder.decode(CredentialEnvelope.self, from: data) else { throw ClientError.malformedCredential } guard !envelope.claudeAiOauth.accessToken.isEmpty else { throw ClientError.missingAccessToken } return envelope } private func fetchUsageLimits(token: String) async throws -> UsageLimitsResponse { let base = ProcessInfo.processInfo.environment["ANTHROPIC_BASE_URL"] ?? "https://api.anthropic.com" let url: URL? if base.lowercased().hasSuffix("/api/oauth/usage") { url = URL(string: base) } else { url = URL(string: base)?.appendingPathComponent("api/oauth/usage") } guard let url else { throw ClientError.requestFailed(-1) } var request = URLRequest(url: url) request.httpMethod = "GET" request.timeoutInterval = 15 request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("CodMate/\(Bundle.main.shortVersionString)", forHTTPHeaderField: "User-Agent") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("oauth-2025-04-20", forHTTPHeaderField: "anthropic-beta") request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") let (data, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { throw ClientError.requestFailed(-1) } guard (200..<300).contains(http.statusCode) else { NSLog("[ClaudeUsageAPI] HTTP error \(http.statusCode)") throw ClientError.requestFailed(http.statusCode) } do { return try JSONDecoder().decode(UsageLimitsResponse.self, from: data) } catch { NSLog("[ClaudeUsageAPI] Decoding failed: \(error)") throw ClientError.decodingFailed } } // MARK: - Credential helpers private static func candidateCredentialServiceNames() -> [String] { var names: [String] = [] for oauth in candidateOauthSuffixes() { for hashSuffix in candidateHashSuffixes() { let value = "Claude Code\(oauth)-credentials\(hashSuffix)" if !names.contains(value) { names.append(value) } } } return names } private static func candidateOauthSuffixes() -> [String] { let env = ProcessInfo.processInfo.environment if let explicit = env["CLAUDE_ENV"] ?? env["CLAUDE_CODE_ENV"], !explicit.isEmpty { return [oauthSuffix(for: explicit)] } return ["", "-staging-oauth", "-local-oauth"] } private static func oauthSuffix(for value: String) -> String { switch value.lowercased() { case "local": return "-local-oauth" case "staging": return "-staging-oauth" default: return "" } } private static func candidateHashSuffixes() -> [String] { var suffixes: [String] = [""] func appendUnique(_ value: String) { if !suffixes.contains(value) { suffixes.append(value) } } let env = ProcessInfo.processInfo.environment if let override = env["CLAUDE_CONFIG_DIR"], !override.isEmpty { appendUnique("-" + hashPrefix(for: override)) } let defaultPath = SessionPreferencesStore.getRealUserHomeURL() .appendingPathComponent(".claude", isDirectory: true) .path appendUnique("-" + hashPrefix(for: defaultPath)) return suffixes } private static func hashPrefix(for rawPath: String) -> String { let expanded = (rawPath as NSString).expandingTildeInPath let digest = SHA256.hash(data: Data(expanded.utf8)) let hex = digest.map { String(format: "%02x", $0) }.joined() return String(hex.prefix(8)) } private static func keychainAccountName() -> String { if let explicit = ProcessInfo.processInfo.environment["USER"], !explicit.isEmpty { return explicit } return NSUserName() } private func fetchEnvelopeFromPlaintext() -> CredentialEnvelope? { let fm = FileManager.default let configDir: URL if let override = ProcessInfo.processInfo.environment["CLAUDE_CONFIG_DIR"], !override.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { configDir = URL(fileURLWithPath: override, isDirectory: true) } else { configDir = SessionPreferencesStore.getRealUserHomeURL() .appendingPathComponent(".claude", isDirectory: true) } let fileURL = configDir.appendingPathComponent(".credentials.json", isDirectory: false) guard fm.fileExists(atPath: fileURL.path) else { return nil } guard let data = try? Data(contentsOf: fileURL) else { return nil } let decoder = JSONDecoder() return try? decoder.decode(CredentialEnvelope.self, from: data) } // MARK: - Plan Type Detection (Best Effort) private func mapRateLimitTierToPlan(_ tier: String) -> String? { let lower = tier.lowercased() if lower.contains("max") { return "Max" } if lower.contains("pro") { return "Pro" } if lower.contains("team") { return "Team" } if lower.contains("enterprise") { return "Enterprise" } return nil } /// Attempts to detect plan type using OAuth token to access claude.ai Web API. /// This is a best-effort approach - OAuth tokens may not work with claude.ai Web API. /// Returns nil if detection fails (no error thrown, silent fallback). private func detectPlanTypeViaOAuth(token: String) async -> String? { let endpoint = "https://claude.ai/api/account" guard let url = URL(string: endpoint) else { return nil } var request = URLRequest(url: url) request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") request.timeoutInterval = 10 guard let (data, response) = try? await session.data(for: request), let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { // OAuth token doesn't work with claude.ai Web API, or user not authenticated // This is expected - OAuth tokens are for api.anthropic.com, not claude.ai return nil } // Parse response using the same structure as ClaudeWebAPIClient struct AccountResponse: Decodable { let memberships: [Membership]? struct Membership: Decodable { let organization: Organization struct Organization: Decodable { let uuid: String? let rateLimitTier: String? let billingType: String? enum CodingKeys: String, CodingKey { case uuid case rateLimitTier = "rate_limit_tier" case billingType = "billing_type" } } } } guard let response = try? JSONDecoder().decode(AccountResponse.self, from: data), let membership = response.memberships?.first else { return nil } let tier = membership.organization.rateLimitTier?.lowercased() ?? "" let billing = membership.organization.billingType?.lowercased() ?? "" if tier.contains("max") { return "Max" } if tier.contains("pro") { return "Pro" } if tier.contains("team") { return "Team" } if tier.contains("enterprise") { return "Enterprise" } if billing.contains("stripe"), tier.contains("claude") { return "Pro" } return nil } } extension Bundle { var shortVersionString: String { infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" } } ================================================ FILE: services/ClaudeUsageAnalyzer.swift ================================================ import Foundation private enum ClaudeUsageConstants { static let blockDuration: TimeInterval = 5 * 60 * 60 static let defaultHorizon: TimeInterval = -7 * 24 * 60 * 60 } struct ClaudeUsageAnalyzer { private let isoFormatter: ISO8601DateFormatter private let fallbackISOFormatter: ISO8601DateFormatter private let newline: UInt8 = 0x0A private let carriageReturn: UInt8 = 0x0D init() { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] isoFormatter = formatter let fallback = ISO8601DateFormatter() fallback.formatOptions = [.withInternetDateTime] fallbackISOFormatter = fallback } func buildStatus( from sessions: [SessionSummary], limit: Int = 48, now: Date = Date() ) -> ClaudeUsageStatus? { guard !sessions.isEmpty else { NSLog("[ClaudeUsage] No sessions provided") return nil } let weekInterval = Calendar.current.dateInterval(of: .weekOfYear, for: now) let horizon = weekInterval?.start.addingTimeInterval(-ClaudeUsageConstants.blockDuration) ?? now.addingTimeInterval(ClaudeUsageConstants.defaultHorizon) let entries = collectEntries(from: sessions, limit: limit, horizon: horizon) guard !entries.isEmpty else { NSLog("[ClaudeUsage] No entries collected from \(sessions.count) sessions") return nil } NSLog("[ClaudeUsage] Collected \(entries.count) entries from \(sessions.count) sessions") let blocks = UsageBlockBuilder(entries: entries).build() guard let latestBlock = blocks.last else { NSLog("[ClaudeUsage] No blocks built") return nil } NSLog("[ClaudeUsage] Built \(blocks.count) blocks, latest: sessionLimit=\(latestBlock.sessionLimitReached), usageReset=\(String(describing: latestBlock.usageLimitReset))") let fiveHour = latestSessionUsage(block: latestBlock, now: now) let weekly = WeeklyUsageAggregator(blocks: blocks, now: now).summary() let weeklyOverride = blocks.last(where: { $0.weeklyLimitReached }) let weeklyReset = weeklyOverride?.weeklyLimitReset ?? weekly.resetDate NSLog("[ClaudeUsage] 5h: \(fiveHour.minutes)min, reset=\(String(describing: fiveHour.resetDate)); Weekly: \(weekly.minutes)min, reset=\(String(describing: weeklyReset))") return ClaudeUsageStatus( updatedAt: latestBlock.lastActivity, modelName: latestBlock.primaryModel, contextUsedTokens: latestBlock.totalTokens, contextLimitTokens: ClaudeModelContextProvider.contextLimit(for: latestBlock.primaryModel), fiveHourUsedMinutes: fiveHour.minutes, fiveHourWindowMinutes: ClaudeUsageConstants.blockDuration / 60, fiveHourResetAt: fiveHour.resetDate, weeklyUsedMinutes: weekly.minutes, weeklyWindowMinutes: weekly.windowMinutes, weeklyResetAt: weeklyReset ) } // MARK: - Entry Collection private func collectEntries( from sessions: [SessionSummary], limit: Int, horizon: Date ) -> [UsageEntry] { var entries: [UsageEntry] = [] var processed = 0 for summary in sessions.sorted(by: { ($0.lastUpdatedAt ?? $0.startedAt) > ($1.lastUpdatedAt ?? $1.startedAt) }) { guard summary.source.baseKind == .claude else { continue } if processed >= limit { break } if let last = summary.lastUpdatedAt, last < horizon, !entries.isEmpty { break } guard let data = try? Data(contentsOf: summary.fileURL, options: [.mappedIfSafe]), !data.isEmpty else { continue } var fileEntries: [UsageEntry] = [] var seenKeys: Set = [] for var slice in data.split(separator: newline, omittingEmptySubsequences: true) { if slice.last == carriageReturn { slice = slice.dropLast() } guard let entry = parseLine(Data(slice), seenKeys: &seenKeys) else { continue } guard entry.timestamp >= horizon else { continue } fileEntries.append(entry) } entries.append(contentsOf: fileEntries) processed += 1 } entries.sort { $0.timestamp < $1.timestamp } return entries } private func parseLine(_ data: Data, seenKeys: inout Set) -> UsageEntry? { guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } guard let timestampString = json["timestamp"] as? String else { return nil } guard let timestamp = isoFormatter.date(from: timestampString) ?? fallbackISOFormatter.date(from: timestampString) else { return nil } let message = json["message"] as? [String: Any] let usage = extractUsageDictionary(from: json, message: message) let dedupKey = makeDedupKey(message: message, root: json) if let dedupKey { if seenKeys.contains(dedupKey) { return nil } seenKeys.insert(dedupKey) } let (limitResetFromMessage, limitKind) = parseLimitResetHint(from: message, timestamp: timestamp) var tokens = 0 if let usage { let input = numberValue(in: usage, keys: ["input_tokens", "inputTokens"]) let cacheCreation = numberValue(in: usage, keys: ["cache_creation_input_tokens", "cacheCreationInputTokens"]) let cacheRead = numberValue(in: usage, keys: ["cache_read_input_tokens", "cacheReadInputTokens"]) let output = numberValue(in: usage, keys: ["output_tokens", "outputTokens"]) tokens = input + cacheCreation + cacheRead + output } if tokens <= 0, limitKind == nil { return nil } let model = (message?["model"] as? String) ?? (json["model"] as? String) ?? ((json["metadata"] as? [String: Any])?["model"] as? String) let resetDate = limitResetFromMessage ?? parseResetDate(from: json, timestamp: timestamp) return UsageEntry( timestamp: timestamp, tokens: tokens, model: model, usageLimitReset: resetDate, limitKind: limitKind ) } private func makeDedupKey(message: [String: Any]?, root: [String: Any]) -> String? { if let message, let messageID = message["id"] as? String, !messageID.isEmpty { return "msg:\(messageID)" } if let requestID = root["requestId"] as? String, !requestID.isEmpty { return "req:\(requestID)" } return nil } private func extractUsageDictionary(from root: [String: Any], message: [String: Any]?) -> [String: Any]? { if let usage = message?["usage"] as? [String: Any] { return usage } if let usage = root["usage"] as? [String: Any] { return usage } if let metadata = root["metadata"] as? [String: Any], let usage = metadata["usage"] as? [String: Any] { return usage } if let info = root["info"] as? [String: Any], let usage = info["usage"] as? [String: Any] { return usage } return nil } private func numberValue(in dict: [String: Any], keys: [String]) -> Int { for key in keys { if let number = dict[key] as? NSNumber { return number.intValue } if let string = dict[key] as? String, let value = Int(string) { return value } } return 0 } private func parseResetDate(from json: [String: Any], timestamp: Date) -> Date? { if let absolute = json["usage_limit_reset_time"] as? NSNumber { return Date(timeIntervalSince1970: absolute.doubleValue) } if let absolute = json["usageLimitResetTime"] as? NSNumber { return Date(timeIntervalSince1970: absolute.doubleValue) } if let seconds = json["usage_limit_reset_in_seconds"] as? NSNumber { return timestamp.addingTimeInterval(seconds.doubleValue) } if let seconds = json["usageLimitResetSeconds"] as? NSNumber { return timestamp.addingTimeInterval(seconds.doubleValue) } return nil } private func parseLimitResetHint(from message: [String: Any]?, timestamp: Date) -> (Date?, UsageEntry.LimitKind?) { guard let message, let contents = message["content"] as? [[String: Any]] else { return (nil, nil) } let text = contents.compactMap { $0["text"] as? String }.joined(separator: " ") guard !text.isEmpty else { return (nil, nil) } let lower = text.lowercased() let kind: UsageEntry.LimitKind? if lower.contains("session limit reached") { kind = .session } else if lower.contains("weekly limit reached") { kind = .weekly } else { kind = nil } guard let kind else { return (nil, nil) } NSLog("[ClaudeUsage] Found limit message: kind=\(kind), text=\(text.prefix(80))") let resetDate = parseResetDateHint(from: text, reference: timestamp) NSLog("[ClaudeUsage] Parsed reset date: \(String(describing: resetDate))") return (resetDate, kind) } private func parseResetDateHint(from text: String, reference: Date) -> Date? { // First, try to extract Unix timestamp (format: |) // This is the most accurate source from Claude Code CLI if let unixTimestamp = extractUnixTimestamp(from: text) { NSLog("[ClaudeUsage] Extracted Unix timestamp: \(unixTimestamp)") return Date(timeIntervalSince1970: TimeInterval(unixTimestamp)) } guard var payload = extractResetPayload(from: text) else { return nil } if payload.isEmpty { return nil } if let dated = parseMonthBasedReset(payload: payload, reference: reference) { return dated } payload = payload.replacingOccurrences(of: " at ", with: " ") payload = payload.replacingOccurrences(of: " ", with: " ") return parseTimeOnlyReset(payload: payload, reference: reference) } private func extractUnixTimestamp(from text: String) -> Int? { // Claude Code CLI embeds Unix timestamp as | // Example: "Session limit reached · resets 3pm … |1731147600" let pattern = "\\|(\\d+)" guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } let range = NSRange(text.startIndex..., in: text) guard let match = regex.firstMatch(in: text, range: range) else { return nil } guard let timestampRange = Range(match.range(at: 1), in: text) else { return nil } let timestampString = String(text[timestampRange]) return Int(timestampString) } private func extractResetPayload(from text: String) -> String? { let lower = text.lowercased() guard let range = lower.range(of: "resets") else { return nil } var payload = String(text[range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) if payload.hasPrefix("∙") { payload = String(payload.dropFirst()).trimmingCharacters(in: .whitespacesAndNewlines) } if let idx = payload.firstIndex(of: "(") { payload = String(payload[.. Date? { NSLog("[ClaudeUsage] parseMonthBasedReset: payload=\"\(payload)\"") let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = TimeZone.current let attempts = [ "MMM d 'at' h:mma", "MMM d 'at' h a", "MMM d 'at' ha", "MMM d h:mma", "MMM d h a", "MMM d ha" ] let year = Calendar.current.component(.year, from: reference) let enriched = payload + " \(year)" for format in attempts { formatter.dateFormat = format + " yyyy" if let date = formatter.date(from: enriched) { NSLog("[ClaudeUsage] Matched format \"\(format)\", date=\(date)") if date < reference, let nextYear = Calendar.current.date(byAdding: .year, value: 1, to: date) { return nextYear } return date } } NSLog("[ClaudeUsage] No format matched for payload=\"\(payload)\"") return nil } private func parseTimeOnlyReset(payload: String, reference: Date) -> Date? { let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { NSLog("[ClaudeUsage] parseTimeOnlyReset: empty payload") return nil } NSLog("[ClaudeUsage] parseTimeOnlyReset: payload=\"\(trimmed)\"") let calendar = Calendar.current var components = calendar.dateComponents([.year, .month, .day], from: reference) let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = TimeZone.current let lower = trimmed.lowercased() let attempts: [String] if lower.contains("am") || lower.contains("pm") { attempts = ["h:mma", "h.mma", "ha", "hmma"] } else if trimmed.contains(":") { attempts = ["HH:mm"] } else { NSLog("[ClaudeUsage] parseTimeOnlyReset: no am/pm or colon found") return nil } for format in attempts { formatter.dateFormat = format let testString = trimmed.replacingOccurrences(of: " ", with: "") if let date = formatter.date(from: testString) { let time = calendar.dateComponents([.hour, .minute], from: date) components.hour = time.hour components.minute = time.minute components.second = 0 if let combined = calendar.date(from: components) { NSLog("[ClaudeUsage] parseTimeOnlyReset: matched format \"\(format)\", combined=\(combined)") if combined <= reference { let next = calendar.date(byAdding: .day, value: 1, to: combined) NSLog("[ClaudeUsage] parseTimeOnlyReset: date in past, adding 1 day -> \(String(describing: next))") return next } return combined } } } NSLog("[ClaudeUsage] parseTimeOnlyReset: no format matched") return nil } } // MARK: - Usage Entry private struct UsageEntry { enum LimitKind { case session, weekly } let timestamp: Date let tokens: Int let model: String? let usageLimitReset: Date? let limitKind: LimitKind? } // MARK: - Usage Blocks private struct UsageBlock { let startTime: Date let lastActivity: Date let totalTokens: Int let models: Set let usageLimitReset: Date? let sessionLimitReached: Bool let weeklyLimitReset: Date? let weeklyLimitReached: Bool private static let blockDuration = ClaudeUsageConstants.blockDuration var primaryModel: String? { guard !models.isEmpty else { return nil } return models.sorted().first } var usedMinutes: Double { let blockEnd = startTime.addingTimeInterval(Self.blockDuration) let effectiveEnd = min(blockEnd, lastActivity) return max(0, effectiveEnd.timeIntervalSince(startTime) / 60) } var resetDate: Date { usageLimitReset ?? startTime.addingTimeInterval(Self.blockDuration) } var activeInterval: DateInterval { let end = min(startTime.addingTimeInterval(Self.blockDuration), lastActivity) return DateInterval(start: startTime, end: max(startTime, end)) } } private struct UsageBlockBuilder { let entries: [UsageEntry] func build() -> [UsageBlock] { guard !entries.isEmpty else { return [] } var blocks: [UsageBlock] = [] var currentEntries: [UsageEntry] = [] var blockStart: Date = entries[0].timestamp var lastTimestamp: Date = entries[0].timestamp func finalize() { guard !currentEntries.isEmpty else { return } let tokens = currentEntries.reduce(0) { $0 + $1.tokens } let models = Set(currentEntries.compactMap(\.model)) let sessionLimitReached = currentEntries.contains { $0.limitKind == .session } let usageReset: Date? = { if sessionLimitReached { return currentEntries.last { entry in entry.limitKind == .session && entry.usageLimitReset != nil }?.usageLimitReset ?? currentEntries.last(where: { $0.usageLimitReset != nil })?.usageLimitReset } return currentEntries.last(where: { $0.usageLimitReset != nil })?.usageLimitReset }() let block = UsageBlock( startTime: currentEntries.first!.timestamp, lastActivity: currentEntries.last!.timestamp, totalTokens: tokens, models: models, usageLimitReset: usageReset, sessionLimitReached: sessionLimitReached, weeklyLimitReset: currentEntries.last(where: { $0.limitKind == .weekly && $0.usageLimitReset != nil })?.usageLimitReset, weeklyLimitReached: currentEntries.contains { $0.limitKind == .weekly } ) blocks.append(block) currentEntries.removeAll(keepingCapacity: true) } let blockDuration = ClaudeUsageConstants.blockDuration for entry in entries { if currentEntries.isEmpty { blockStart = entry.timestamp currentEntries.append(entry) lastTimestamp = entry.timestamp if entry.limitKind == .session { finalize() } continue } let exceedsBlock = entry.timestamp.timeIntervalSince(blockStart) > blockDuration let gapTooLarge = entry.timestamp.timeIntervalSince(lastTimestamp) > blockDuration if exceedsBlock || gapTooLarge { finalize() blockStart = entry.timestamp lastTimestamp = entry.timestamp currentEntries.append(entry) if entry.limitKind == .session { finalize() } continue } currentEntries.append(entry) lastTimestamp = entry.timestamp if entry.limitKind == .session { finalize() } } finalize() return blocks } } // MARK: - Weekly Aggregation private struct WeeklyUsageAggregator { let blocks: [UsageBlock] let now: Date func summary() -> (minutes: Double, windowMinutes: Double, resetDate: Date?) { guard let interval = Calendar.current.dateInterval(of: .weekOfYear, for: now) else { return (0, 7 * 24 * 60, nil) } var totalMinutes: Double = 0 for block in blocks { if let overlap = block.activeInterval.intersection(with: interval) { totalMinutes += overlap.duration / 60 } } return ( minutes: totalMinutes, windowMinutes: interval.duration / 60, resetDate: interval.end ) } } // MARK: - Latest Session (5-hour window) Aggregation private func latestSessionUsage(block: UsageBlock, now: Date) -> (minutes: Double, resetDate: Date?) { let duration = ClaudeUsageConstants.blockDuration let windowEnd = block.startTime.addingTimeInterval(duration) if block.sessionLimitReached { let reset = block.usageLimitReset ?? windowEnd if reset <= now { return (minutes: 0, resetDate: nil) } return ( minutes: duration / 60, resetDate: reset ) } guard windowEnd > now else { return (0, nil) } let usedSeconds = min(now, windowEnd).timeIntervalSince(block.startTime) let minutes = max(0, usedSeconds) / 60 let candidateReset = block.usageLimitReset let resetDate: Date? if let candidateReset, candidateReset > now { resetDate = candidateReset } else if windowEnd > now { resetDate = windowEnd } else { resetDate = nil } return (minutes: minutes, resetDate: resetDate) } // MARK: - Context Limit Resolution enum ClaudeModelContextProvider { private static let highCapacityModels: [String] = [ "claude-sonnet-4-20250514", "claude-sonnet-4", "claude-sonnet-4@20250514" ] private static let lowCapacityModels: [String] = [ "claude-instant-v1", "claude-v1", "claude-v2", "claude-2" ] static func contextLimit(for modelName: String?) -> Int? { guard let model = modelName?.lowercased() else { return nil } if highCapacityModels.contains(where: { model.contains($0) }) { return 1_000_000 } if lowCapacityModels.contains(where: { model.contains($0) }) { return 100_000 } return 200_000 } } ================================================ FILE: services/ClaudeWebAPIClient.swift ================================================ import Foundation /// Fetches Claude usage data directly from the claude.ai API using browser session cookies. /// /// This approach automatically extracts the session key from Safari/Chrome cookies instead of /// requiring OAuth token management, providing a more reliable fallback when OAuth tokens expire. /// /// API endpoints used: /// - `GET https://claude.ai/api/organizations` → get org UUID /// - `GET https://claude.ai/api/organizations/{org_id}/usage` → usage percentages + reset times enum ClaudeWebAPIClient { private static let baseURL = "https://claude.ai/api" enum FetchError: LocalizedError { case noSessionKeyFound case invalidSessionKey case networkError(Error) case invalidResponse case unauthorized case serverError(statusCode: Int) case noOrganization var errorDescription: String? { switch self { case .noSessionKeyFound: "No Claude session key found in browser cookies." case .invalidSessionKey: "Invalid Claude session key format." case let .networkError(error): "Network error: \(error.localizedDescription)" case .invalidResponse: "Invalid response from Claude API." case .unauthorized: "Unauthorized. Your Claude session may have expired." case let .serverError(code): "Claude API error: HTTP \(code)" case .noOrganization: "No Claude organization found for this account." } } } struct OrganizationInfo { let id: String let name: String? } struct WebUsageData { let sessionPercentUsed: Double let sessionResetsAt: Date? let weeklyPercentUsed: Double? let weeklyResetsAt: Date? let planType: String? // Subscription type (Pro, Max, Team, etc.) } private struct AccountResponse: Decodable { let emailAddress: String? let memberships: [Membership]? enum CodingKeys: String, CodingKey { case emailAddress = "email_address" case memberships } struct Membership: Decodable { let organization: Organization struct Organization: Decodable { let uuid: String? let rateLimitTier: String? let billingType: String? enum CodingKeys: String, CodingKey { case uuid case rateLimitTier = "rate_limit_tier" case billingType = "billing_type" } } } } // MARK: - Public API /// Fetches Claude usage status using browser cookies /// - Parameter now: Current date for status construction /// - Returns: ClaudeUsageStatus compatible with existing system /// - Throws: FetchError if session key cannot be found or API call fails static func fetchUsageViaWebAPI(now: Date = Date()) async throws -> ClaudeUsageStatus { NSLog("[ClaudeWebAPI] Attempting to fetch usage via Web API") // Extract session key from browser cookies let sessionKey = try extractSessionKey() NSLog("[ClaudeWebAPI] Found sessionKey: \(sessionKey.prefix(20))...") // Fetch organization info let organization = try await fetchOrganizationInfo(sessionKey: sessionKey) NSLog("[ClaudeWebAPI] Organization ID: \(organization.id)") // Fetch usage data var usage = try await fetchUsageData(orgId: organization.id, sessionKey: sessionKey) NSLog("[ClaudeWebAPI] Usage fetched successfully") // Fetch account info for plan type (best effort) if let planType = await fetchAccountPlanType( sessionKey: sessionKey, orgId: organization.id) { usage = WebUsageData( sessionPercentUsed: usage.sessionPercentUsed, sessionResetsAt: usage.sessionResetsAt, weeklyPercentUsed: usage.weeklyPercentUsed, weeklyResetsAt: usage.weeklyResetsAt, planType: planType ) NSLog("[ClaudeWebAPI] ✅ Detected plan type: \(planType)") } else { NSLog("[ClaudeWebAPI] ⚠️ Could not detect plan type") } // Convert to ClaudeUsageStatus return convertToUsageStatus(usage, now: now) } // MARK: - Session Key Extraction private static func extractSessionKey() throws -> String { // Try Safari first (no Keychain prompt required) do { if let sessionKey = try SafariCookieImporter.extractClaudeSessionKey() { guard validateSessionKey(sessionKey) else { throw FetchError.invalidSessionKey } NSLog("[ClaudeWebAPI] Found sessionKey in Safari cookies") return sessionKey } } catch { NSLog("[ClaudeWebAPI] Safari cookie load failed: \(error.localizedDescription)") } // Try Chrome (may trigger Keychain prompt) do { if let sessionKey = try ChromeCookieImporter.extractClaudeSessionKey() { guard validateSessionKey(sessionKey) else { throw FetchError.invalidSessionKey } NSLog("[ClaudeWebAPI] Found sessionKey in Chrome cookies") return sessionKey } } catch { NSLog("[ClaudeWebAPI] Chrome cookie load failed: \(error.localizedDescription)") } throw FetchError.noSessionKeyFound } private static func validateSessionKey(_ key: String) -> Bool { // Claude session keys start with "sk-ant-" return key.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("sk-ant-") } // MARK: - API Calls private static func fetchOrganizationInfo(sessionKey: String) async throws -> OrganizationInfo { let url = URL(string: "\(baseURL)/organizations")! var request = URLRequest(url: url) request.setValue("sessionKey=\(sessionKey)", forHTTPHeaderField: "Cookie") request.setValue("application/json", forHTTPHeaderField: "Accept") request.httpMethod = "GET" request.timeoutInterval = 15 let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FetchError.invalidResponse } NSLog("[ClaudeWebAPI] Organizations API status: \(httpResponse.statusCode)") switch httpResponse.statusCode { case 200: return try parseOrganizationResponse(data) case 401, 403: throw FetchError.unauthorized default: throw FetchError.serverError(statusCode: httpResponse.statusCode) } } private static func fetchUsageData(orgId: String, sessionKey: String) async throws -> WebUsageData { let url = URL(string: "\(baseURL)/organizations/\(orgId)/usage")! var request = URLRequest(url: url) request.setValue("sessionKey=\(sessionKey)", forHTTPHeaderField: "Cookie") request.setValue("application/json", forHTTPHeaderField: "Accept") request.httpMethod = "GET" request.timeoutInterval = 15 let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FetchError.invalidResponse } NSLog("[ClaudeWebAPI] Usage API status: \(httpResponse.statusCode)") switch httpResponse.statusCode { case 200: return try parseUsageResponse(data) case 401, 403: throw FetchError.unauthorized default: throw FetchError.serverError(statusCode: httpResponse.statusCode) } } // MARK: - Response Parsing private static func parseOrganizationResponse(_ data: Data) throws -> OrganizationInfo { guard let json = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]], let first = json.first, let id = first["uuid"] as? String else { throw FetchError.noOrganization } let name = first["name"] as? String return OrganizationInfo(id: id, name: name) } private static func parseUsageResponse(_ data: Data) throws -> WebUsageData { guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw FetchError.invalidResponse } // Parse five_hour (session) usage var sessionPercent: Double? var sessionResets: Date? if let fiveHour = json["five_hour"] as? [String: Any] { if let utilization = fiveHour["utilization"] as? Int { sessionPercent = Double(utilization) } if let resetsAt = fiveHour["resets_at"] as? String { sessionResets = parseISO8601Date(resetsAt) } } guard let sessionPercent else { // If we can't parse session utilization, treat this as a failure throw FetchError.invalidResponse } // Parse seven_day (weekly) usage var weeklyPercent: Double? var weeklyResets: Date? if let sevenDay = json["seven_day"] as? [String: Any] { if let utilization = sevenDay["utilization"] as? Int { weeklyPercent = Double(utilization) } if let resetsAt = sevenDay["resets_at"] as? String { weeklyResets = parseISO8601Date(resetsAt) } } return WebUsageData( sessionPercentUsed: sessionPercent, sessionResetsAt: sessionResets, weeklyPercentUsed: weeklyPercent, weeklyResetsAt: weeklyResets, planType: nil // Will be populated by fetchAccountPlanType ) } private static func fetchAccountPlanType(sessionKey: String, orgId: String) async -> String? { let url = URL(string: "\(baseURL)/account")! var request = URLRequest(url: url) request.setValue("sessionKey=\(sessionKey)", forHTTPHeaderField: "Cookie") request.setValue("application/json", forHTTPHeaderField: "Accept") request.httpMethod = "GET" request.timeoutInterval = 15 NSLog("[ClaudeWebAPI] Fetching account info for orgId: \(orgId)") do { let (data, response) = try await URLSession.shared.data(for: request) if let httpResponse = response as? HTTPURLResponse { NSLog("[ClaudeWebAPI] Account API status: \(httpResponse.statusCode)") if httpResponse.statusCode != 200 { NSLog("[ClaudeWebAPI] Account API failed with status \(httpResponse.statusCode)") return nil } } let planType = parseAccountPlanType(data, orgId: orgId) NSLog("[ClaudeWebAPI] Parsed plan type: \(planType ?? "nil")") return planType } catch { NSLog("[ClaudeWebAPI] Account API error: \(error.localizedDescription)") return nil } } private static func parseAccountPlanType(_ data: Data, orgId: String) -> String? { guard let response = try? JSONDecoder().decode(AccountResponse.self, from: data) else { NSLog("[ClaudeWebAPI] Failed to decode AccountResponse") return nil } NSLog("[ClaudeWebAPI] Account has \(response.memberships?.count ?? 0) memberships") // Find matching membership or use first let membership = selectMembership(response.memberships, orgId: orgId) if let membership = membership { NSLog("[ClaudeWebAPI] Selected membership - rateLimitTier: \(membership.organization.rateLimitTier ?? "nil"), billingType: \(membership.organization.billingType ?? "nil")") } else { NSLog("[ClaudeWebAPI] No membership found") } return inferPlan( rateLimitTier: membership?.organization.rateLimitTier, billingType: membership?.organization.billingType ) } private static func selectMembership( _ memberships: [AccountResponse.Membership]?, orgId: String ) -> AccountResponse.Membership? { guard let memberships, !memberships.isEmpty else { return nil } if let match = memberships.first(where: { $0.organization.uuid == orgId }) { return match } return memberships.first } private static func inferPlan(rateLimitTier: String?, billingType: String?) -> String? { let tier = rateLimitTier?.lowercased() ?? "" let billing = billingType?.lowercased() ?? "" NSLog("[ClaudeWebAPI] inferPlan - tier: '\(tier)', billing: '\(billing)'") if tier.contains("max") { NSLog("[ClaudeWebAPI] Matched: Max") return "Max" } if tier.contains("pro") { NSLog("[ClaudeWebAPI] Matched: Pro") return "Pro" } if tier.contains("team") { NSLog("[ClaudeWebAPI] Matched: Team") return "Team" } if tier.contains("enterprise") { NSLog("[ClaudeWebAPI] Matched: Enterprise") return "Enterprise" } if billing.contains("stripe"), tier.contains("claude") { NSLog("[ClaudeWebAPI] Matched: Pro (via stripe+claude)") return "Pro" } if billing.contains("apple") { NSLog("[ClaudeWebAPI] Matched: Pro (via apple)") return "Pro" } NSLog("[ClaudeWebAPI] No plan matched") return nil } private static func parseISO8601Date(_ string: String) -> Date? { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let date = formatter.date(from: string) { return date } // Try without fractional seconds formatter.formatOptions = [.withInternetDateTime] return formatter.date(from: string) } // MARK: - Conversion to ClaudeUsageStatus private static func convertToUsageStatus(_ usage: WebUsageData, now: Date) -> ClaudeUsageStatus { let fiveHourWindowMinutes = 5.0 * 60.0 let weeklyWindowMinutes = 7.0 * 24.0 * 60.0 // Convert percentage to minutes used let fiveHourUsedMinutes = (usage.sessionPercentUsed / 100.0) * fiveHourWindowMinutes let weeklyUsedMinutes = usage.weeklyPercentUsed.map { ($0 / 100.0) * weeklyWindowMinutes } return ClaudeUsageStatus( updatedAt: now, modelName: nil, contextUsedTokens: nil, contextLimitTokens: nil, fiveHourUsedMinutes: fiveHourUsedMinutes, fiveHourWindowMinutes: fiveHourWindowMinutes, fiveHourResetAt: usage.sessionResetsAt, weeklyUsedMinutes: weeklyUsedMinutes, weeklyWindowMinutes: weeklyWindowMinutes, weeklyResetAt: usage.weeklyResetsAt, sessionExpiresAt: nil, // Web API doesn't have session expiry planType: usage.planType ) } } ================================================ FILE: services/CodexAppServerProbeService.swift ================================================ import Foundation /// Best-effort probe for Codex rate limits and account info via `codex app-server`. /// /// This is intentionally lightweight and avoids creating Codex session logs, unlike starting /// interactive sessions. It is used to show Codex quota windows (5h/weekly) even when no /// recent session files are available. actor CodexAppServerProbeService { struct Snapshot: Sendable { let fetchedAt: Date let primaryUsedPercent: Double? let primaryWindowMinutes: Int? let primaryResetAt: Date? let secondaryUsedPercent: Double? let secondaryWindowMinutes: Int? let secondaryResetAt: Date? let planType: String? } enum ProbeError: Swift.Error, LocalizedError { case codexNotFound case startFailed(String) case malformedResponse(String) case requestFailed(String) var errorDescription: String? { switch self { case .codexNotFound: return "Codex CLI not found on PATH." case .startFailed(let message): return "Failed to start codex app-server: \(message)" case .malformedResponse(let message): return "Malformed codex app-server response: \(message)" case .requestFailed(let message): return "Codex app-server request failed: \(message)" } } } private var cached: Snapshot? private var inFlight: Task? /// Returns cached data when it's fresh enough; otherwise starts a new probe. func fetchIfStale(maxAge: TimeInterval = 60) async throws -> Snapshot { if let cached { let age = Date().timeIntervalSince(cached.fetchedAt) if age >= 0, age <= maxAge { return cached } } if let inFlight { let fresh = try await inFlight.value self.cached = fresh return fresh } let task = Task { try await Self.fetchOnce() } self.inFlight = task defer { self.inFlight = nil } let fresh = try await task.value self.cached = fresh return fresh } /// Best-effort wrapper that never throws (used by UI refresh paths). func fetchIfStaleOrNil(maxAge: TimeInterval = 60) async -> Snapshot? { do { return try await fetchIfStale(maxAge: maxAge) } catch { return nil } } // MARK: - Probe implementation private static func fetchOnce() async throws -> Snapshot { let client = try CodexRPCClient() defer { client.shutdown() } try await client.initialize(clientName: "codmate", clientVersion: Bundle.main.shortVersionString) let rateLimits = try await client.fetchRateLimits().rateLimits let account = try? await client.fetchAccount() let fetchedAt = Date() let primary = Self.window(from: rateLimits.primary) let secondary = Self.window(from: rateLimits.secondary) let planType: String? = account?.account.flatMap { details in if case let .chatgpt(_, planType) = details { return planType } else { return nil } } return Snapshot( fetchedAt: fetchedAt, primaryUsedPercent: primary.usedPercent, primaryWindowMinutes: primary.windowMinutes, primaryResetAt: primary.resetsAt, secondaryUsedPercent: secondary.usedPercent, secondaryWindowMinutes: secondary.windowMinutes, secondaryResetAt: secondary.resetsAt, planType: planType ) } private struct RateWindow: Sendable { let usedPercent: Double? let windowMinutes: Int? let resetsAt: Date? } private static func window(from rpc: RPCRateLimitWindow?) -> RateWindow { guard let rpc else { return RateWindow(usedPercent: nil, windowMinutes: nil, resetsAt: nil) } let resetsAt = rpc.resetsAt.map { Date(timeIntervalSince1970: TimeInterval($0)) } return RateWindow(usedPercent: rpc.usedPercent, windowMinutes: rpc.windowDurationMins, resetsAt: resetsAt) } } // MARK: - Codex JSON-RPC client (local `codex app-server` process) private struct RPCRateLimitsResponse: Decodable { let rateLimits: RPCRateLimitSnapshot } private struct RPCRateLimitSnapshot: Decodable { let primary: RPCRateLimitWindow? let secondary: RPCRateLimitWindow? } private struct RPCRateLimitWindow: Decodable { let usedPercent: Double let windowDurationMins: Int? let resetsAt: Int? } private struct RPCAccountResponse: Decodable { let account: RPCAccountDetails? } private enum RPCAccountDetails: Decodable { case apiKey case chatgpt(email: String, planType: String) enum CodingKeys: String, CodingKey { case type case email case planType } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(String.self, forKey: .type) switch type.lowercased() { case "apikey": self = .apiKey case "chatgpt": let email = try container.decodeIfPresent(String.self, forKey: .email) ?? "unknown" let plan = try container.decodeIfPresent(String.self, forKey: .planType) ?? "unknown" self = .chatgpt(email: email, planType: plan) default: throw DecodingError.dataCorruptedError( forKey: .type, in: container, debugDescription: "Unknown account type \(type)" ) } } } private final class CodexRPCClient: @unchecked Sendable { private let process = Process() private let stdinPipe = Pipe() private let stdoutPipe = Pipe() private let stderrPipe = Pipe() private var nextID = 1 init() throws { let env = Self.buildEnvironment() self.process.environment = env self.process.executableURL = URL(fileURLWithPath: "/usr/bin/env") self.process.arguments = [ "codex", "-s", "read-only", "-a", "untrusted", "app-server", ] self.process.standardInput = self.stdinPipe self.process.standardOutput = self.stdoutPipe self.process.standardError = self.stderrPipe do { try self.process.run() } catch { throw CodexAppServerProbeService.ProbeError.startFailed(error.localizedDescription) } let stderrHandle = self.stderrPipe.fileHandleForReading stderrHandle.readabilityHandler = { handle in let data = handle.availableData if data.isEmpty { handle.readabilityHandler = nil return } guard let text = String(data: data, encoding: .utf8), !text.isEmpty else { return } for line in text.split(whereSeparator: \.isNewline) { fputs("[codex stderr] \(line)\n", stderr) } } } func initialize(clientName: String, clientVersion: String) async throws { _ = try await request( method: "initialize", params: ["clientInfo": ["name": clientName, "version": clientVersion]] ) try sendNotification(method: "initialized") } func fetchRateLimits() async throws -> RPCRateLimitsResponse { let message = try await request(method: "account/rateLimits/read") return try decodeResult(from: message) } func fetchAccount() async throws -> RPCAccountResponse { let message = try await request(method: "account/read") return try decodeResult(from: message) } func shutdown() { if self.process.isRunning { self.process.terminate() } } // MARK: - JSON-RPC helpers private func request(method: String, params: [String: Any]? = nil) async throws -> [String: Any] { let id = self.nextID self.nextID += 1 try sendRequest(id: id, method: method, params: params) while true { let message = try await readNextMessage() if message["id"] == nil { continue } guard let messageID = jsonID(message["id"]), messageID == id else { continue } if let error = message["error"] as? [String: Any], let messageText = error["message"] as? String { throw CodexAppServerProbeService.ProbeError.requestFailed(messageText) } return message } } private func sendNotification(method: String, params: [String: Any]? = nil) throws { let paramsValue: Any = params ?? [:] try sendPayload(["method": method, "params": paramsValue]) } private func sendRequest(id: Int, method: String, params: [String: Any]?) throws { let paramsValue: Any = params ?? [:] try sendPayload(["id": id, "method": method, "params": paramsValue]) } private func sendPayload(_ payload: [String: Any]) throws { let data = try JSONSerialization.data(withJSONObject: payload) self.stdinPipe.fileHandleForWriting.write(data) self.stdinPipe.fileHandleForWriting.write(Data([0x0A])) } private func readNextMessage() async throws -> [String: Any] { for try await lineData in self.stdoutPipe.fileHandleForReading.bytes.lines { if lineData.isEmpty { continue } let line = String(lineData) guard let data = line.data(using: .utf8) else { continue } if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { return json } } throw CodexAppServerProbeService.ProbeError.malformedResponse("codex app-server closed stdout") } private func decodeResult(from message: [String: Any]) throws -> T { guard let result = message["result"] else { throw CodexAppServerProbeService.ProbeError.malformedResponse("missing result field") } let data = try JSONSerialization.data(withJSONObject: result) return try JSONDecoder().decode(T.self, from: data) } private func jsonID(_ value: Any?) -> Int? { switch value { case let int as Int: return int case let number as NSNumber: return number.intValue default: return nil } } private static func buildEnvironment() -> [String: String] { var env = ProcessInfo.processInfo.environment let base = CLIEnvironment.buildBasePATH() if let current = env["PATH"], !current.isEmpty { env["PATH"] = base + ":" + current } else { env["PATH"] = base } env["NO_COLOR"] = "1" return env } } ================================================ FILE: services/CodexConfigService.swift ================================================ import Foundation // MARK: - Models public struct CodexProvider: Identifiable, Equatable, Sendable { public var id: String // table id, e.g., "openai" public var name: String? // display name public var baseURL: String? public var envKey: String? public var wireAPI: String? public var queryParamsRaw: String? // raw TOML for query_params public var httpHeadersRaw: String? // raw TOML for http_headers public var envHttpHeadersRaw: String?// raw TOML for env_http_headers public var requestMaxRetries: Int? public var streamMaxRetries: Int? public var streamIdleTimeoutMs: Int? public var managedByCodMate: Bool // true when block contains our marker } // MARK: - Service actor CodexConfigService { struct Paths { let home: URL let configURL: URL static func `default`(fileManager: FileManager = .default) -> Paths { // Use real user home (not sandbox container) so Codex CLI can read the config let userHome = SessionPreferencesStore.getRealUserHomeURL() let home = userHome.appendingPathComponent(".codex", isDirectory: true) return Paths(home: home, configURL: home.appendingPathComponent("config.toml", isDirectory: false)) } } private let paths: Paths private let fm: FileManager init(paths: Paths = .default(), fileManager: FileManager = .default) { self.paths = paths self.fm = fileManager } // MARK: - Diagnostics models struct ProviderDiagnostics: Sendable { var configPath: String var providers: [CodexProvider] var headerCounts: [String: Int] // id -> occurrences in raw file var duplicateIDs: [String] var strayManagedBodies: Int // bodies without header likely left behind var canonicalRegion: String // canonical providers region text (not applied) } // MARK: Public API (phase 1) func listProviders() -> [CodexProvider] { let text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? "" return parseProviders(from: text) } func activeProvider() -> String? { let text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? "" return parseTopLevelString(key: "model_provider", from: text) } func setActiveProvider(_ id: String?) throws { var text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? "" text = upsertTopLevelString(key: "model_provider", value: id, in: text) try writeConfig(text) } func upsertProvider(_ provider: CodexProvider) throws { let text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? "" // Build new providers array based on current parsed providers (deduped by id) var current = parseProviders(from: text) if let idx = current.firstIndex(where: { $0.id == provider.id }) { current[idx] = provider } else { current.append(provider) } let rewritten = rewriteProvidersRegion(in: text, with: current) try writeConfig(rewritten) } func deleteProvider(id: String) throws { let text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? "" var current = parseProviders(from: text) current.removeAll { $0.id == id } let rewritten = rewriteProvidersRegion(in: text, with: current) try writeConfig(rewritten) } func replaceProviders(with providers: [CodexProvider]) throws { var text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? "" text = rewriteProvidersRegion(in: text, with: providers) try writeConfig(text) } func applyProviderFromRegistry(_ provider: ProvidersRegistryService.Provider?) throws { if let provider { guard let connector = provider.connectors[ProvidersRegistryService.Consumer.codex.rawValue] else { try replaceProviders(with: []) try setActiveProvider(nil) return } var envKeyValue = provider.envKey ?? connector.envKey var headerMap = connector.httpHeaders ?? [:] if let v = envKeyValue { let lower = v.lowercased() let looksLikeToken = lower.contains("sk-") || v.hasPrefix("eyJ") || v.contains(".") if looksLikeToken { envKeyValue = nil headerMap["Authorization"] = v.hasPrefix("Bearer ") ? v : "Bearer \(v)" } } let codexProvider = CodexProvider( id: provider.id, name: provider.name, baseURL: connector.baseURL, envKey: envKeyValue, wireAPI: (connector.wireAPI?.lowercased() == "responses") ? "responses" : "chat", queryParamsRaw: connector.queryParams.map { renderInlineTable($0) }, httpHeadersRaw: headerMap.isEmpty ? nil : renderInlineTable(headerMap), envHttpHeadersRaw: connector.envHttpHeaders.map { renderInlineTable($0) }, requestMaxRetries: connector.requestMaxRetries, streamMaxRetries: connector.streamMaxRetries, streamIdleTimeoutMs: connector.streamIdleTimeoutMs, managedByCodMate: provider.managedByCodMate ) try replaceProviders(with: [codexProvider]) try setActiveProvider(provider.id) } else { try replaceProviders(with: []) try setActiveProvider(nil) } } func applyLocalProxyProvider( providerId: String = "codmate-proxy", port: Int, apiKey: String?, modelId: String? ) throws { let baseURL = "http://127.0.0.1:\(port)/v1" var headers: [String: String] = [:] if let key = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines), !key.isEmpty { headers["Authorization"] = key.hasPrefix("Bearer ") ? key : "Bearer \(key)" } let provider = CodexProvider( id: providerId, name: "CLI Proxy API", baseURL: baseURL, envKey: nil, // CLI Proxy API supports Responses; default to the modern wire API. wireAPI: "responses", queryParamsRaw: nil, httpHeadersRaw: headers.isEmpty ? nil : renderInlineTable(headers), envHttpHeadersRaw: nil, requestMaxRetries: nil, streamMaxRetries: nil, streamIdleTimeoutMs: nil, managedByCodMate: true ) try replaceProviders(with: [provider]) try setActiveProvider(providerId) try setTopLevelString("model", value: modelId) } // MARK: - Runtime: model, reasoning, sandbox, approvals func getTopLevelString(_ key: String) -> String? { let text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? "" return parseTopLevelString(key: key, from: text) } func setTopLevelString(_ key: String, value: String?) throws { var text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? "" text = upsertTopLevelString(key: key, value: value, in: text) try writeConfig(text) } func setSandboxMode(_ mode: String?) throws { try setTopLevelString("sandbox_mode", value: mode) } func setApprovalPolicy(_ policy: String?) throws { try setTopLevelString("approval_policy", value: policy) } // MARK: - Features overrides func featureOverrides() -> [String: Bool] { let text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? "" return parseFeatureOverrides(from: text) } func setFeatureOverride(name: String, value: Bool?) throws { var text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? "" var overrides = parseFeatureOverrides(from: text) if let value { overrides[name] = value } else { overrides.removeValue(forKey: name) } text = rewriteFeaturesBlock(in: text, overrides: overrides) try writeConfig(text) } // MARK: - TUI notifications and notify bridge func getTuiNotifications() -> Bool { let text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? "" return (parseTableKeyValue(table: "[tui]", key: "notifications", from: text) ?? "false") .trimmingCharacters(in: .whitespacesAndNewlines) == "true" } func setTuiNotifications(_ enabled: Bool) throws { var text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? "" text = upsertTableKeyValue(table: "[tui]", key: "notifications", valueText: enabled ? "true" : "false", in: text) try writeConfig(text) } func getNotifyArray() -> [String] { let text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? "" if let v = parseTopLevelArray(key: "notify", from: text) { return v } return [] } func setNotifyArray(_ arr: [String]?) throws { var text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? "" text = upsertTopLevelArray(key: "notify", values: arr, in: text) try writeConfig(text) } // MARK: - Hooks (CodMate-managed) // Codex currently exposes a legacy `notify = ["cmd", "args..."]` mechanism that runs after a turn completes. // We treat a single `Stop` hook (single command) as a mapping target for this legacy interface. func applyHooksFromCodMate(_ rules: [HookRule]) throws -> [HookSyncWarning] { var warnings: [HookSyncWarning] = [] let targeted = rules.filter { $0.isEnabled(for: .codex) } var eligible: [HookRule] = [] for rule in targeted { let rawEvent = rule.event.trimmingCharacters(in: .whitespacesAndNewlines) guard !rawEvent.isEmpty else { continue } let resolution = HookEventCatalog.resolveProviderEvent(rawEvent, for: .codex) if resolution.isKnown, !resolution.isSupported { warnings.append(HookSyncWarning( provider: .codex, message: "Codex does not support hook event \"\(rawEvent)\"; skipping \"\(rule.name)\"." )) continue } if resolution.name != "Stop" { warnings.append(HookSyncWarning( provider: .codex, message: "Codex currently supports only Stop via `notify`; skipping \"\(rule.name)\"." )) continue } eligible.append(rule) } guard !eligible.isEmpty else { let existing = getNotifyArray() if shouldClearNotify(existing: existing, rules: rules) { try setNotifyArray(nil) } return warnings } guard eligible.count == 1 else { warnings.append(HookSyncWarning( provider: .codex, message: "Codex currently supports only one Stop hook via `notify`. Multiple CodMate rules target Codex; skipping apply." )) return warnings } let rule = eligible[0] guard rule.commands.count == 1 else { warnings.append(HookSyncWarning( provider: .codex, message: "Codex `notify` supports only a single command. Hook \"\(rule.name)\" has \(rule.commands.count) command(s); skipping apply." )) return warnings } let cmd = rule.commands[0] let program = cmd.command.trimmingCharacters(in: .whitespacesAndNewlines) guard !program.isEmpty else { warnings.append(HookSyncWarning(provider: .codex, message: "Hook \"\(rule.name)\" has an empty command; skipping apply.")) return warnings } if let env = cmd.env, !env.isEmpty { warnings.append(HookSyncWarning( provider: .codex, message: "Codex `notify` does not support per-hook environment variables; ignoring env for \"\(rule.name)\"." )) } let previous = getNotifyArray() if let existing = previous.first, existing.contains("codmate-notify") { warnings.append(HookSyncWarning( provider: .codex, message: "Applying this hook will overwrite CodMate's notify bridge and may disable CodMate system notifications for Codex." )) } var argv: [String] = [program] if let args = cmd.args, !args.isEmpty { argv.append(contentsOf: args) } try setNotifyArray(argv) return warnings } private func shouldClearNotify(existing: [String], rules: [HookRule]) -> Bool { guard !existing.isEmpty else { return false } let candidates = rules.filter { rule in let rawEvent = rule.event.trimmingCharacters(in: .whitespacesAndNewlines) guard !rawEvent.isEmpty else { return false } let resolution = HookEventCatalog.resolveProviderEvent(rawEvent, for: .codex) return resolution.name == "Stop" } for rule in candidates { for cmd in rule.commands { let program = cmd.command.trimmingCharacters(in: .whitespacesAndNewlines) guard !program.isEmpty else { continue } var argv: [String] = [program] if let args = cmd.args, !args.isEmpty { argv.append(contentsOf: args) } if argv == existing { return true } } } return false } func ensureNotifyBridgeInstalled() throws -> URL { let bin = paths.home.deletingLastPathComponent() .appendingPathComponent("Library", isDirectory: true) .appendingPathComponent("Application Support", isDirectory: true) .appendingPathComponent("CodMate", isDirectory: true) .appendingPathComponent("bin", isDirectory: true) try fm.createDirectory(at: bin, withIntermediateDirectories: true) let target = bin.appendingPathComponent("codmate-notify") guard let bundled = Self.bundledNotifyBinaryURL() else { if fm.fileExists(atPath: target.path) { return target } throw NSError(domain: "CodMate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundled codmate-notify helper not found"]) } if fm.fileExists(atPath: target.path) { try? fm.removeItem(at: target) } try fm.copyItem(at: bundled, to: target) try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: target.path) return target } private static func bundledNotifyBinaryURL() -> URL? { #if os(macOS) if let url = Bundle.main.url(forResource: "codmate-notify", withExtension: nil, subdirectory: "bin") { return url } return Bundle.main.url(forResource: "codmate-notify", withExtension: nil) #else return nil #endif } // MARK: - Raw config helpers func configFileURL() -> URL { paths.configURL } func readRawConfigText() -> String { (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? "" } // MARK: - Providers Diagnostics func diagnoseProviders() -> ProviderDiagnostics { let raw = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? "" let list = parseProviders(from: raw) var counts: [String: Int] = [:] // Count headers occurrences for line in raw.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) { let t = line.trimmingCharacters(in: .whitespaces) if let id = matchProviderHeader(t) { counts[id, default: 0] += 1 } } let dups = counts.filter { $0.value > 1 }.map { $0.key }.sorted() let stray = countStrayProviderBodies(in: raw) // Build canonical region (with leading blank line per style) var canonical = raw canonical = rewriteProvidersRegion(in: canonical, with: list) let region: String = { // extract only the appended canonical region portion by rebuilding from empty baseline var s = "" for p in list { if !s.hasSuffix("\n\n") { if !s.isEmpty { s += "\n" } else { s += "\n\n" } } s += "[model_providers.\(p.id)]\n" s += renderProviderBody(p) s += "\n" } return s }() return ProviderDiagnostics( configPath: paths.configURL.path, providers: list, headerCounts: counts, duplicateIDs: dups, strayManagedBodies: stray, canonicalRegion: region ) } // MARK: - MCP Servers (managed region) private let mcpBeginMarker = "# codmate-mcp begin" private let mcpEndMarker = "# codmate-mcp end" func applyMCPServers(_ servers: [MCPServer]) throws { if !SessionPreferencesStore.isCLIEnabled(.codex) { return } var text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? "" // Strip previous managed region if present if let begin = text.range(of: mcpBeginMarker), let end = text.range(of: mcpEndMarker) { text.removeSubrange(begin.lowerBound..<(end.upperBound)) } // Normalize trailing whitespace/newlines to avoid accumulating blank lines while let last = text.last, last == "\n" || last == "\r" || last == "\t" || last == " " { text.removeLast() } // Build new region with enabled servers only let enabled = servers.enabledServers(for: .codex) guard !enabled.isEmpty else { try writeConfig(text) return } // Ensure at most a single empty line before the managed block var region = (text.isEmpty ? "" : "\n\n") + "\(mcpBeginMarker)\n" for s in enabled { // Single canonical block per server: [mcp_servers.] var body: [String] = [] body.append("kind = \"\(s.kind.rawValue)\"") if let url = s.url, !url.isEmpty { body.append("url = \"\(url)\"") } if let cmd = s.command, !cmd.isEmpty { body.append("command = \"\(cmd)\"") } if let args = s.args, !args.isEmpty { let quoted = args.map { "\"\($0)\"" }.joined(separator: ", ") body.append("args = [ \(quoted) ]") } if let env = s.env, !env.isEmpty { body.append("env = \(renderInlineTable(env))") } if let headers = s.headers, !headers.isEmpty { body.append("headers = \(renderInlineTable(headers))") } region += "[mcp_servers.\(s.name)]\n" + body.joined(separator: "\n") + "\n\n" } region += "\(mcpEndMarker)\n" text += region try writeConfig(text) } // MARK: - Privacy: shell_environment_policy struct ShellEnvironmentPolicy { var inherit: String? // all|core|none var ignoreDefaultExcludes: Bool? var includeOnly: [String]? var exclude: [String]? var set: [String:String]? } func getShellEnvironmentPolicy() -> ShellEnvironmentPolicy { let text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? "" let body = parseTableBody(table: "[shell_environment_policy]", from: text) var policy = ShellEnvironmentPolicy(inherit: nil, ignoreDefaultExcludes: nil, includeOnly: nil, exclude: nil, set: nil) for line in body { let t = line.trimmingCharacters(in: .whitespaces) guard !t.hasPrefix("#"), let eq = t.firstIndex(of: "=") else { continue } let key = t[.. Bool { let text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? "" let val = parseTopLevelString(key: key, from: text) ?? "false" return val == "true" } func setBool(_ key: String, _ value: Bool) throws { var text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? "" text = upsertTopLevelBool(key: key, value: value, in: text) try writeConfig(text) } func setFileOpener(_ opener: String?) throws { try setTopLevelString("file_opener", value: opener) } // MARK: - Privacy: OTEL (simplified) enum OtelExporterKind: String { case none, otlpHttp = "otlp-http", otlpGrpc = "otlp-grpc" } struct OtelConfig { var environment: String?; var exporterKind: OtelExporterKind; var endpoint: String? } func getOtelConfig() -> OtelConfig { let text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? "" let body = parseTableBody(table: "[otel]", from: text) var env: String?; var kind: OtelExporterKind = .none; var endpoint: String? = nil for raw in body { let line = raw.trimmingCharacters(in: .whitespaces) if line.hasPrefix("environment") { if let eq = line.firstIndex(of: "=") { env = unquote(String(line[line.index(after: eq)...]).trimmingCharacters(in: .whitespaces)) } } if line.hasPrefix("exporter") { if line.contains("otlp-http") { kind = .otlpHttp } else if line.contains("otlp-grpc") { kind = .otlpGrpc } else if line.contains("none") { kind = .none } if let e = extractInlineEndpoint(from: line) { endpoint = e } } } return OtelConfig(environment: env, exporterKind: kind, endpoint: endpoint) } func setOtelConfig(_ oc: OtelConfig) throws { var lines: [String] = ["# managed-by=codmate"] if let env = oc.environment, !env.isEmpty { lines.append("environment = \"\(env)\"") } switch oc.exporterKind { case .none: lines.append("exporter = \"none\"") case .otlpHttp: let endpoint = oc.endpoint ?? "" lines.append("exporter = { otlp-http = { endpoint = \"\(endpoint)\", protocol = \"binary\" } }") case .otlpGrpc: let endpoint = oc.endpoint ?? "" lines.append("exporter = { otlp-grpc = { endpoint = \"\(endpoint)\" } }") } var text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? "" text = replaceTableBlock(header: "[otel]", body: lines.joined(separator: "\n") + "\n", in: text) try writeConfig(text) } // MARK: - File IO helpers private func writeConfig(_ text: String) throws { try fm.createDirectory(at: paths.home, withIntermediateDirectories: true) // Backup existing if fm.fileExists(atPath: paths.configURL.path) { let bak = paths.home.appendingPathComponent("config.toml.bak") try? fm.removeItem(at: bak) try fm.copyItem(at: paths.configURL, to: bak) } try text.write(to: paths.configURL, atomically: true, encoding: .utf8) } // MARK: - Parsing (naïve, line-based; tolerant by design) private func parseProviders(from text: String) -> [CodexProvider] { // Parse and deduplicate by id. If the same id appears multiple times, // keep the LAST occurrence in the file (common when blocks were rewritten). var map: [String: CodexProvider] = [:] var order: [String] = [] let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) var i = 0 while i < lines.count { let line = lines[i].trimmingCharacters(in: .whitespaces) if let id = matchProviderHeader(line) { var j = i + 1 var body: [String] = [] while j < lines.count { let l = lines[j] if l.trimmingCharacters(in: .whitespaces).hasPrefix("[") { break } body.append(l) j += 1 } let p = parseProviderBody(id: id, body: body) map[id] = p if !order.contains(id) { order.append(id) } i = j continue } i += 1 } return order.compactMap { map[$0] } } private func matchProviderHeader(_ line: String) -> String? { // [model_providers.] guard line.hasPrefix("[model_providers.") && line.hasSuffix("]") else { return nil } let start = "[model_providers.".count let endIndex = line.index(before: line.endIndex) let id = String(line[line.index(line.startIndex, offsetBy: start).. CodexProvider { var p = CodexProvider(id: id, name: nil, baseURL: nil, envKey: nil, wireAPI: nil, queryParamsRaw: nil, httpHeadersRaw: nil, envHttpHeadersRaw: nil, requestMaxRetries: nil, streamMaxRetries: nil, streamIdleTimeoutMs: nil, managedByCodMate: false) for raw in body { let line = raw.trimmingCharacters(in: .whitespaces) if line.contains("managed-by=codmate") { p.managedByCodMate = true } guard !line.hasPrefix("#"), let eq = line.firstIndex(of: "=") else { continue } let key = line[.. String { var s = v.trimmingCharacters(in: .whitespaces) if s.hasPrefix("\"") && s.hasSuffix("\"") { s.removeFirst(); s.removeLast() } return s } private func escapeTomlString(_ value: String) -> String { var escaped = value.replacingOccurrences(of: "\\", with: "\\\\") escaped = escaped.replacingOccurrences(of: "\"", with: "\\\"") return escaped } private func projectHeader(for path: String, in text: String) -> String { let projects = parseProjects(from: text) if let match = projects.first(where: { project in let dir = project.directory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let idUnquoted = unquote(project.id) return dir == path || idUnquoted == path || project.id == path }) { return "[projects.\(match.id)]" } let escapedPath = escapeTomlString(path) return "[projects.\"\(escapedPath)\"]" } private func headerHasQuotedPathKey(_ header: String, path: String) -> Bool { let quotedPath = "\"\(escapeTomlString(path))\"" return header == "[projects.\(quotedPath)]" } private func parseTopLevelString(key: String, from text: String) -> String? { // naive: find first line starting with key = for raw in text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) { let line = raw.trimmingCharacters(in: .whitespaces) guard line.hasPrefix(key + " ") || line.hasPrefix(key + "=") else { continue } guard let eq = line.firstIndex(of: "=") else { continue } let value = line[line.index(after: eq)...].trimmingCharacters(in: .whitespaces) return unquote(value) } return nil } private func parseTopLevelArray(key: String, from text: String) -> [String]? { for raw in text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) { let line = raw.trimmingCharacters(in: .whitespaces) guard line.hasPrefix(key + " ") || line.hasPrefix(key + "=") else { continue } guard let eq = line.firstIndex(of: "=") else { continue } let value = line[line.index(after: eq)...].trimmingCharacters(in: .whitespaces) return parseArrayLiteral(value) } return nil } // MARK: - Writing helpers private func upsertTopLevelString(key: String, value: String?, in text: String) -> String { var lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) var found = false for i in lines.indices { let t = lines[i].trimmingCharacters(in: .whitespaces) if t.hasPrefix(key + " ") || t.hasPrefix(key + "=") { if let value { lines[i] = "\(key) = \"\(value)\"" } else { lines.remove(at: i) } found = true break } } if !found, let value { // insert near top (before first table) var insertIndex = lines.count for (idx, l) in lines.enumerated() where l.trimmingCharacters(in: .whitespaces).hasPrefix("[") { insertIndex = idx break } lines.insert("\(key) = \"\(value)\"", at: insertIndex) } return lines.joined(separator: "\n") } private func upsertTopLevelBool(key: String, value: Bool, in text: String) -> String { var lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) var found = false for i in lines.indices { let t = lines[i].trimmingCharacters(in: .whitespaces) if t.hasPrefix(key + " ") || t.hasPrefix(key + "=") { lines[i] = "\(key) = \(value ? "true" : "false")" found = true break } } if !found { var insertIndex = lines.count for (idx, l) in lines.enumerated() where l.trimmingCharacters(in: .whitespaces).hasPrefix("[") { insertIndex = idx break } lines.insert("\(key) = \(value ? "true" : "false")", at: insertIndex) } return lines.joined(separator: "\n") } private func upsertTopLevelArray(key: String, values: [String]?, in text: String) -> String { var lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) var foundIndex: Int? = nil for i in lines.indices { let t = lines[i].trimmingCharacters(in: .whitespaces) if t.hasPrefix(key + " ") || t.hasPrefix(key + "=") { foundIndex = i; break } } if let arr = values { let literal = renderArrayLiteral(arr) if let i = foundIndex { lines[i] = "\(key) = \(literal)" } else { var insertIndex = lines.count for (idx, l) in lines.enumerated() where l.trimmingCharacters(in: .whitespaces).hasPrefix("[") { insertIndex = idx; break } lines.insert("\(key) = \(literal)", at: insertIndex) } } else if let i = foundIndex { lines.remove(at: i) } return lines.joined(separator: "\n") } private func upsertProviderBlock(_ p: CodexProvider, in text: String) -> String { let header = "[model_providers.\(p.id)]" let body = renderProviderBody(p) return replaceTableBlock(header: header, body: body, in: text) } private func removeProviderBlock(id: String, in text: String) -> String { // Remove ALL occurrences of the provider block with this id to avoid leftovers let header = "[model_providers.\(id)]" var result = text while true { let newText = replaceTableBlock(header: header, body: nil, in: result) if newText == result { break } result = newText } return result } private func replaceTableBlock(header: String, body: String?, in text: String) -> String { var lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) var start: Int? = nil var end: Int? = nil for (idx, raw) in lines.enumerated() { let t = raw.trimmingCharacters(in: .whitespaces) if t == header { start = idx; continue } if start != nil && (t.hasPrefix("[") || t == mcpBeginMarker || t == mcpEndMarker) { end = idx break } } if let start { let stop = end ?? lines.count if let body { var newBlock = [header] newBlock.append(contentsOf: body.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)) lines.replaceSubrange(start.. String { var out: [String] = [] out.append("# managed-by=codmate") if let name = p.name { out.append("name = \"\(name)\"") } if let baseURL = p.baseURL, !baseURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { out.append("base_url = \"\(baseURL)\"") } if let envKey = p.envKey, !envKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { out.append("env_key = \"\(envKey)\"") } if let wire0 = p.wireAPI?.trimmingCharacters(in: .whitespacesAndNewlines), !wire0.isEmpty { out.append("wire_api = \"\(wire0)\"") } if let qp = p.queryParamsRaw, !qp.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { out.append("query_params = \(qp)") } if let hh = p.httpHeadersRaw, !hh.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { out.append("http_headers = \(hh)") } if let ehh = p.envHttpHeadersRaw, !ehh.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { out.append("env_http_headers = \(ehh)") } if let r = p.requestMaxRetries { out.append("request_max_retries = \(r)") } if let r = p.streamMaxRetries { out.append("stream_max_retries = \(r)") } if let r = p.streamIdleTimeoutMs { out.append("stream_idle_timeout_ms = \(r)") } return out.joined(separator: "\n") + "\n" } // MARK: - Projects (config-backed) func listProjects() -> [Project] { let text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? "" return parseProjects(from: text) } func ensureProjectTrusted(directory: URL, trustLevel: String = "trusted") throws { let trimmed = trustLevel.trimmingCharacters(in: .whitespacesAndNewlines) let level = trimmed.isEmpty ? "trusted" : trimmed let path = directory.standardizedFileURL.path let text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? "" let header = projectHeader(for: path, in: text) var updated = upsertTableKeyValue( table: header, key: "trust_level", valueText: "\"\(level)\"", in: text ) if !headerHasQuotedPathKey(header, path: path) { updated = upsertTableKeyValue( table: header, key: "directory", valueText: "\"\(escapeTomlString(path))\"", in: updated ) } try writeConfig(updated) } // Deprecated: CodMate no longer writes projects to config.toml. // Kept for API compatibility; acts as a no-op. func upsertProject(_ project: Project) throws { _ = project return } // Deprecated: CodMate no longer writes projects to config.toml. // Kept for API compatibility; acts as a no-op. func deleteProject(id: String) throws { _ = id } // MARK: - Canonical providers region rewriter private func rewriteProvidersRegion(in text: String, with providers: [CodexProvider]) -> String { // 1) Remove all provider blocks (and any stray managed provider bodies without header) let stripped = stripProviderLikeBlocks(from: text) // 2) Append canonical providers region at the end (if any) guard !providers.isEmpty else { return stripped } var out = stripped // Ensure there is an empty line separator before providers region if !out.hasSuffix("\n") { out += "\n" } if !out.hasSuffix("\n\n") { out += "\n" } for p in providers { out += "[model_providers.\(p.id)]\n" out += renderProviderBody(p) out += "\n" } return out } private func rewriteFeaturesBlock(in text: String, overrides: [String: Bool]) -> String { var stripped = replaceTableBlock(header: "[features]", body: nil, in: text) let entries = overrides.sorted(by: { $0.key < $1.key }).map { key, value in "\(key) = \(value ? "true" : "false")" } guard !entries.isEmpty else { return stripped } if !stripped.hasSuffix("\n") { stripped += "\n" } if !stripped.hasSuffix("\n\n") { stripped += "\n" } stripped += "[features]\n" stripped += "# managed-by=codmate\n" stripped += entries.joined(separator: "\n") + "\n\n" return stripped } private func parseFeatureOverrides(from text: String) -> [String: Bool] { let body = parseTableBody(table: "[features]", from: text) var overrides: [String: Bool] = [:] for raw in body { let line = raw.trimmingCharacters(in: .whitespaces) guard !line.isEmpty, !line.hasPrefix("#"), let eq = line.firstIndex(of: "=") else { continue } let key = line[.. String { let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) var keep: [String] = [] var i = 0 func isHeader(_ t: String) -> Bool { t.trimmingCharacters(in: .whitespaces).hasPrefix("[") } let providerKeys: Set = [ "name", "base_url", "env_key", "wire_api", "query_params", "http_headers", "env_http_headers", "request_max_retries", "stream_max_retries", "stream_idle_timeout_ms", ] while i < lines.count { let t = lines[i].trimmingCharacters(in: .whitespaces) // Remove normal provider blocks if t.hasPrefix("[model_providers.") && t.hasSuffix("]") { // skip until next header or EOF i += 1 while i < lines.count { let t2 = lines[i].trimmingCharacters(in: .whitespaces) if isHeader(t2) { break } i += 1 } continue } // Remove stray managed provider bodies without header (best-effort): // A sequence starting with '# managed-by=codmate' followed by lines whose keys // are all within providerKeys constitutes such a body. if t.contains("managed-by=codmate") { _ = i var j = i + 1 var looksLikeProvider = false while j < lines.count { let raw = lines[j] let tr = raw.trimmingCharacters(in: .whitespaces) if tr.isEmpty { j += 1; continue } if isHeader(tr) { break } // key check if let eq = tr.firstIndex(of: "=") { let key = tr[.. [Project] { var map: [String: Project] = [:] var order: [String] = [] let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) var i = 0 while i < lines.count { let line = lines[i].trimmingCharacters(in: .whitespaces) if let id = matchProjectHeader(line) { var j = i + 1 var body: [String] = [] while j < lines.count { let l = lines[j] if l.trimmingCharacters(in: .whitespaces).hasPrefix("[") { break } body.append(l) j += 1 } let p = parseProjectBody(id: id, body: body) map[id] = p if !order.contains(id) { order.append(id) } i = j continue } i += 1 } return order.compactMap { map[$0] } } private func matchProjectHeader(_ line: String) -> String? { // [projects.] guard line.hasPrefix("[projects.") && line.hasSuffix("]") else { return nil } let start = "[projects.".count let endIndex = line.index(before: line.endIndex) let id = String(line[line.index(line.startIndex, offsetBy: start).. Project { var name: String = id var directory: String? = nil var trust: String? = nil var overview: String? = nil var instructions: String? = nil var profile: String? = nil for raw in body { let line = raw.trimmingCharacters(in: .whitespaces) guard !line.hasPrefix("#"), let eq = line.firstIndex(of: "=") else { continue } let key = line[.. String { var out: [String] = [] out.append("# managed-by=codmate") if !p.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { out.append("name = \"\(p.name)\"") } if let dir = p.directory, !dir.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { out.append("directory = \"\(dir)\"") } if let v = p.trustLevel, !v.isEmpty { out.append("trust_level = \"\(v)\"") } if let v = p.overview, !v.isEmpty { out.append("overview = \"\(v)\"") } if let v = p.instructions, !v.isEmpty { out.append("instructions = \"\(v)\"") } if let v = p.profileId, !v.isEmpty { out.append("profile = \"\(v)\"") } return out.joined(separator: "\n") + "\n" } private func rewriteProjectsRegion(in text: String, with projects: [Project]) -> String { // Remove all project blocks var lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) // Pass 1: strip all [projects.*] blocks do { var i = 0 while i < lines.count { let t = lines[i].trimmingCharacters(in: .whitespaces) if t.hasPrefix("[projects.") && t.hasSuffix("]") { var j = i + 1 while j < lines.count { let tt = lines[j].trimmingCharacters(in: .whitespaces) if tt.hasPrefix("[") { break } j += 1 } lines.removeSubrange(i.. Int { let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) var i = 0 var count = 0 func isHeader(_ t: String) -> Bool { t.trimmingCharacters(in: .whitespaces).hasPrefix("[") } let providerKeys: Set = [ "name", "base_url", "env_key", "wire_api", "query_params", "http_headers", "env_http_headers", "request_max_retries", "stream_max_retries", "stream_idle_timeout_ms", ] while i < lines.count { let t = lines[i].trimmingCharacters(in: .whitespaces) if t.contains("managed-by=codmate") { var j = i + 1 var looksLikeProvider = false while j < lines.count { let tr = lines[j].trimmingCharacters(in: .whitespaces) if tr.isEmpty { j += 1; continue } if isHeader(tr) { break } if let eq = tr.firstIndex(of: "=") { let key = tr[.. Bool { var text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? "" let keys = [ "show_raw_agent_reasoning", "hide_agent_reasoning", "suppress_unstable_features_warning", ] var changed = false for key in keys { let (newText, didChange) = ensureTopLevelBoolLiteral(key: key, in: text) if didChange { text = newText; changed = true } } if changed { do { try writeConfig(text) } catch { /* ignore */ } } return changed } private func ensureTopLevelBoolLiteral(key: String, in text: String) -> (String, Bool) { var lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) var changed = false for i in lines.indices { let raw = lines[i] let t = raw.trimmingCharacters(in: .whitespaces) guard t.hasPrefix(key + " ") || t.hasPrefix(key + "=") else { continue } guard let eq = raw.firstIndex(of: "=") else { continue } let prefix = String(raw[.. [String] { let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) var start: Int?; var end: Int? for (idx, raw) in lines.enumerated() { if raw.trimmingCharacters(in: .whitespaces) == table { start = idx + 1; continue } if let _ = start, raw.trimmingCharacters(in: .whitespaces).hasPrefix("[") { end = idx; break } } guard let s = start else { return [] } let e = end ?? lines.count return Array(lines[s.. String? { let body = parseTableBody(table: table, from: text) for raw in body { let t = raw.trimmingCharacters(in: .whitespaces) guard !t.hasPrefix("#"), let eq = t.firstIndex(of: "=") else { continue } let k = t[.. String { var lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) var start: Int?; var end: Int? for (idx, raw) in lines.enumerated() { let t = raw.trimmingCharacters(in: .whitespaces) if t == table { start = idx; continue } if let _ = start, t.hasPrefix("[") { end = idx; break } } if let s = start { let e = end ?? lines.count var replaced = false if e > s { for i in (s+1).. [String]? { var s = value.trimmingCharacters(in: .whitespaces) guard s.hasPrefix("[") && s.hasSuffix("]") else { return nil } s.removeFirst(); s.removeLast() if s.trimmingCharacters(in: .whitespaces).isEmpty { return [] } let parts = s.split(separator: ",").map { unquote(String($0)).trimmingCharacters(in: .whitespaces) } return parts } private func renderArrayLiteral(_ arr: [String]) -> String { let quoted = arr.map { "\"\($0)\"" }.joined(separator: ", ") return "[\(quoted)]" } private func parseInlineTable(_ value: String) -> [String:String]? { var s = value.trimmingCharacters(in: .whitespaces) guard s.hasPrefix("{") && s.hasSuffix("}") else { return nil } s.removeFirst(); s.removeLast() if s.trimmingCharacters(in: .whitespaces).isEmpty { return [:] } var dict: [String:String] = [:] for part in s.split(separator: ",") { let seg = String(part) guard let eq = seg.firstIndex(of: "=") else { continue } let k = seg[.. String { let parts = dict.map { key, val in "\"\(key)\" = \"\(val)\"" }.sorted().joined(separator: ", ") return "{ \(parts) }" } private func extractInlineEndpoint(from exporterLine: String) -> String? { guard let r = exporterLine.range(of: "endpoint") else { return nil } let sub = exporterLine[r.lowerBound...] if let eq = sub.firstIndex(of: "=") { let after = sub[sub.index(after: eq)...] let trimmed = String(after).trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("\"") { let s = trimmed if let q2 = s.dropFirst().firstIndex(of: "\"") { let val = s[s.index(after: s.startIndex).. [CodexFeatureInfo] { let env = [ "PATH": CLIEnvironment.buildBasePATH(), "NO_COLOR": "1" ] do { let result = try ShellCommandRunner.run( executable: "/usr/bin/env", arguments: ["codex", "features", "list"], environment: env ) return try Self.parseFeatures(from: result.stdout) } catch let ShellCommandError.commandFailed(_, _, stderr, _) { throw Error.cliFailed(stderr: stderr) } catch { throw error } } private static func parseFeatures(from stdout: String) throws -> [CodexFeatureInfo] { var features: [CodexFeatureInfo] = [] for rawLine in stdout.split(separator: "\n") { let trimmed = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { continue } let columns = trimmed.split(omittingEmptySubsequences: true) { ch in ch == "\t" || ch == " " } guard columns.count >= 3 else { continue } guard let enabledToken = columns.last?.lowercased() else { continue } let enabled: Bool switch enabledToken { case "true": enabled = true case "false": enabled = false default: continue } let name = String(columns[0]) let stage = columns.dropFirst().dropLast().map(String.init).joined(separator: " ") features.append(CodexFeatureInfo(name: name, stage: stage, enabled: enabled)) } if features.isEmpty { throw Error.parseFailed } return features } } ================================================ FILE: services/CodexOAuthUsageFetcher.swift ================================================ import Foundation // MARK: - Codex OAuth Credentials /// OAuth credentials from Codex auth.json file struct CodexOAuthCredentials: Sendable { let accessToken: String let refreshToken: String let idToken: String? let accountId: String? let lastRefresh: Date? var needsRefresh: Bool { guard let lastRefresh else { return true } // Tokens typically last 14 days; refresh after 8 days to be safe let eightDays: TimeInterval = 8 * 24 * 60 * 60 return Date().timeIntervalSince(lastRefresh) > eightDays } } enum CodexOAuthCredentialsError: LocalizedError, Sendable { case notFound case decodeFailed(String) case missingTokens var errorDescription: String? { switch self { case .notFound: return "Codex auth.json not found. Run `codex` to log in." case .decodeFailed(let message): return "Failed to decode Codex credentials: \(message)" case .missingTokens: return "Codex auth.json exists but contains no tokens." } } } /// Storage for Codex OAuth credentials (reads/writes auth.json) enum CodexOAuthCredentialsStore { private static var authFilePath: URL { let home = SessionPreferencesStore.getRealUserHomeURL() if let codexHome = ProcessInfo.processInfo.environment["CODEX_HOME"]?.trimmingCharacters( in: .whitespacesAndNewlines), !codexHome.isEmpty { return URL(fileURLWithPath: codexHome).appendingPathComponent("auth.json") } return home.appendingPathComponent(".codex/auth.json") } static func load() throws -> CodexOAuthCredentials { let url = authFilePath guard FileManager.default.fileExists(atPath: url.path) else { throw CodexOAuthCredentialsError.notFound } let data = try Data(contentsOf: url) return try parse(data: data) } static func parse(data: Data) throws -> CodexOAuthCredentials { guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw CodexOAuthCredentialsError.decodeFailed("Invalid JSON") } // Check for API key auth (non-OAuth) if let apiKey = json["OPENAI_API_KEY"] as? String, !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return CodexOAuthCredentials( accessToken: apiKey, refreshToken: "", idToken: nil, accountId: nil, lastRefresh: nil) } // Look for OAuth tokens guard let tokens = json["tokens"] as? [String: Any] else { throw CodexOAuthCredentialsError.missingTokens } guard let accessToken = tokens["access_token"] as? String, let refreshToken = tokens["refresh_token"] as? String, !accessToken.isEmpty else { throw CodexOAuthCredentialsError.missingTokens } let idToken = tokens["id_token"] as? String let accountId = tokens["account_id"] as? String let lastRefresh = parseLastRefresh(from: json["last_refresh"]) return CodexOAuthCredentials( accessToken: accessToken, refreshToken: refreshToken, idToken: idToken, accountId: accountId, lastRefresh: lastRefresh) } static func save(_ credentials: CodexOAuthCredentials) throws { let url = authFilePath var json: [String: Any] = [:] if let data = try? Data(contentsOf: url), let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { json = existing } var tokens: [String: Any] = [ "access_token": credentials.accessToken, "refresh_token": credentials.refreshToken, ] if let idToken = credentials.idToken { tokens["id_token"] = idToken } if let accountId = credentials.accountId { tokens["account_id"] = accountId } json["tokens"] = tokens json["last_refresh"] = ISO8601DateFormatter().string(from: Date()) let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) let directory = url.deletingLastPathComponent() try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) try data.write(to: url, options: .atomic) } private static func parseLastRefresh(from raw: Any?) -> Date? { guard let value = raw as? String, !value.isEmpty else { return nil } let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let date = formatter.date(from: value) { return date } formatter.formatOptions = [.withInternetDateTime] return formatter.date(from: value) } } // MARK: - Codex OAuth Usage Fetcher /// Fetches Codex usage data directly from ChatGPT OAuth API /// This is more reliable than the codex app-server JSON-RPC approach enum CodexOAuthUsageFetcher { private static let defaultChatGPTBaseURL = "https://chatgpt.com/backend-api/" private static let chatGPTUsagePath = "/wham/usage" private static let codexUsagePath = "/api/codex/usage" enum FetchError: LocalizedError, Sendable { case unauthorized case invalidResponse case serverError(Int, String?) case networkError(Error) var errorDescription: String? { switch self { case .unauthorized: return "Codex OAuth token expired or invalid. Run `codex` to re-authenticate." case .invalidResponse: return "Invalid response from Codex usage API." case .serverError(let code, let message): if let message, !message.isEmpty { return "Codex API error \(code): \(message)" } return "Codex API error \(code)." case .networkError(let error): return "Network error: \(error.localizedDescription)" } } } struct UsageResponse: Decodable, Sendable { let planType: PlanType? let rateLimit: RateLimitDetails? let credits: CreditDetails? enum CodingKeys: String, CodingKey { case planType = "plan_type" case rateLimit = "rate_limit" case credits } enum PlanType: Sendable, Decodable, Equatable { case guest case free case go case plus case pro case freeWorkspace case team case business case education case quorum case k12 case enterprise case edu case unknown(String) var rawValue: String { switch self { case .guest: "guest" case .free: "free" case .go: "go" case .plus: "plus" case .pro: "pro" case .freeWorkspace: "free_workspace" case .team: "team" case .business: "business" case .education: "education" case .quorum: "quorum" case .k12: "k12" case .enterprise: "enterprise" case .edu: "edu" case let .unknown(value): value } } init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() let value = try container.decode(String.self) switch value { case "guest": self = .guest case "free": self = .free case "go": self = .go case "plus": self = .plus case "pro": self = .pro case "free_workspace": self = .freeWorkspace case "team": self = .team case "business": self = .business case "education": self = .education case "quorum": self = .quorum case "k12": self = .k12 case "enterprise": self = .enterprise case "edu": self = .edu default: self = .unknown(value) } } } struct RateLimitDetails: Decodable, Sendable { let primaryWindow: WindowSnapshot? let secondaryWindow: WindowSnapshot? enum CodingKeys: String, CodingKey { case primaryWindow = "primary_window" case secondaryWindow = "secondary_window" } } struct WindowSnapshot: Decodable, Sendable { let usedPercent: Int let resetAt: Int let limitWindowSeconds: Int enum CodingKeys: String, CodingKey { case usedPercent = "used_percent" case resetAt = "reset_at" case limitWindowSeconds = "limit_window_seconds" } } struct CreditDetails: Decodable, Sendable { let hasCredits: Bool let unlimited: Bool let balance: Double? enum CodingKeys: String, CodingKey { case hasCredits = "has_credits" case unlimited case balance } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) hasCredits = (try? container.decode(Bool.self, forKey: .hasCredits)) ?? false unlimited = (try? container.decode(Bool.self, forKey: .unlimited)) ?? false if let balance = try? container.decode(Double.self, forKey: .balance) { self.balance = balance } else if let balance = try? container.decode(String.self, forKey: .balance), let value = Double(balance) { self.balance = value } else { balance = nil } } } } static func fetchUsage(accessToken: String, accountId: String?) async throws -> UsageResponse { var request = URLRequest(url: resolveUsageURL()) request.httpMethod = "GET" request.timeoutInterval = 30 request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.setValue("CodMate", forHTTPHeaderField: "User-Agent") request.setValue("application/json", forHTTPHeaderField: "Accept") if let accountId, !accountId.isEmpty { request.setValue(accountId, forHTTPHeaderField: "ChatGPT-Account-Id") } do { let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse else { throw FetchError.invalidResponse } switch http.statusCode { case 200...299: do { return try JSONDecoder().decode(UsageResponse.self, from: data) } catch { throw FetchError.invalidResponse } case 401, 403: throw FetchError.unauthorized default: let body = String(data: data, encoding: .utf8) throw FetchError.serverError(http.statusCode, body) } } catch let error as FetchError { throw error } catch { throw FetchError.networkError(error) } } /// Fetch usage and return the plan type directly static func fetchPlanType() async throws -> String? { let credentials = try CodexOAuthCredentialsStore.load() let response = try await fetchUsage(accessToken: credentials.accessToken, accountId: credentials.accountId) let planTypeRaw = response.planType?.rawValue return planTypeRaw } /// Check if OAuth credentials are available static func hasCredentials() -> Bool { (try? CodexOAuthCredentialsStore.load()) != nil } private static func resolveUsageURL() -> URL { resolveUsageURL(env: ProcessInfo.processInfo.environment, configContents: nil) } private static func resolveUsageURL(env: [String: String], configContents: String?) -> URL { let baseURL = resolveChatGPTBaseURL(env: env, configContents: configContents) let normalized = normalizeChatGPTBaseURL(baseURL) let path = normalized.contains("/backend-api") ? chatGPTUsagePath : codexUsagePath let full = normalized + path return URL(string: full) ?? URL(string: defaultChatGPTBaseURL + chatGPTUsagePath)! } private static func resolveChatGPTBaseURL(env: [String: String], configContents: String?) -> String { if let configContents, let parsed = parseChatGPTBaseURL(from: configContents) { return parsed } if let contents = loadConfigContents(env: env), let parsed = parseChatGPTBaseURL(from: contents) { return parsed } return defaultChatGPTBaseURL } private static func normalizeChatGPTBaseURL(_ value: String) -> String { var trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { trimmed = defaultChatGPTBaseURL } while trimmed.hasSuffix("/") { trimmed.removeLast() } if trimmed.hasPrefix("https://chatgpt.com") || trimmed.hasPrefix("https://chat.openai.com"), !trimmed.contains("/backend-api") { trimmed += "/backend-api" } return trimmed } private static func parseChatGPTBaseURL(from contents: String) -> String? { for rawLine in contents.split(whereSeparator: \.isNewline) { let line = rawLine.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: true).first let trimmed = line?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !trimmed.isEmpty else { continue } let parts = trimmed.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: true) guard parts.count == 2 else { continue } let key = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) guard key == "chatgpt_base_url" else { continue } var value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) if value.hasPrefix("\""), value.hasSuffix("\"") { value = String(value.dropFirst().dropLast()) } else if value.hasPrefix("'"), value.hasSuffix("'") { value = String(value.dropFirst().dropLast()) } return value.trimmingCharacters(in: .whitespacesAndNewlines) } return nil } private static func loadConfigContents(env: [String: String]) -> String? { let home = SessionPreferencesStore.getRealUserHomeURL() let codexHome = env["CODEX_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines) let root = (codexHome?.isEmpty == false) ? URL(fileURLWithPath: codexHome!) : home .appendingPathComponent(".codex") let url = root.appendingPathComponent("config.toml") return try? String(contentsOf: url, encoding: .utf8) } } // MARK: - Plan type badge conversion extension CodexOAuthUsageFetcher.UsageResponse.PlanType { /// Convert plan type to display badge var displayBadge: String? { switch self { case .free, .guest: return nil // No badge for free users case .go: return "Go" case .plus: return "Plus" case .pro: return "Pro" case .team: return "Team" case .business, .enterprise: return "Ent" case .freeWorkspace: return nil case .education, .edu, .k12, .quorum: return "Edu" case .unknown(let value): // Show first letter capitalized for unknown types return value.isEmpty ? nil : value.prefix(1).uppercased() + value.dropFirst() } } } // MARK: - JWT-based plan type extraction (more reliable than API) extension CodexOAuthUsageFetcher { /// Fetch plan type from JWT token in auth.json (primary, most reliable) /// This mirrors CodexBar's approach for consistency static func fetchPlanTypeFromJWT() -> String? { do { let credentials = try CodexOAuthCredentialsStore.load() guard let idToken = credentials.idToken, !idToken.isEmpty else { return nil } guard let payload = parseJWT(idToken) else { return nil } // Extract plan type from JWT payload (same fields as CodexBar) let authDict = payload["https://api.openai.com/auth"] as? [String: Any] let planFromAuth = authDict?["chatgpt_plan_type"] as? String let planFromRoot = payload["chatgpt_plan_type"] as? String let plan = planFromAuth ?? planFromRoot let trimmedPlan = plan?.trimmingCharacters(in: .whitespacesAndNewlines) return trimmedPlan } catch { return nil } } /// Parse JWT token to extract payload private static func parseJWT(_ token: String) -> [String: Any]? { let parts = token.split(separator: ".") guard parts.count >= 2 else { return nil } let payloadPart = parts[1] var padded = String(payloadPart) .replacingOccurrences(of: "-", with: "+") .replacingOccurrences(of: "_", with: "/") while padded.count % 4 != 0 { padded.append("=") } guard let data = Data(base64Encoded: padded) else { return nil } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } return json } } ================================================ FILE: services/CommandsImportService.swift ================================================ import Foundation enum CommandsImportService { struct SourceDescriptor { let label: String let directory: URL let fileExtension: String let format: CommandSourceFormat } enum CommandSourceFormat { case markdown case toml } static func scan(scope: ExtensionsImportScope, fileManager: FileManager = .default) -> [CommandImportCandidate] { let home: URL switch scope { case .home: home = SessionPreferencesStore.getRealUserHomeURL() case .project: return [] } let sources: [SourceDescriptor] = [ SourceDescriptor( label: "Codex", directory: home.appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("prompts", isDirectory: true), fileExtension: "md", format: .markdown ), SourceDescriptor( label: "Claude", directory: home.appendingPathComponent(".claude", isDirectory: true) .appendingPathComponent("commands", isDirectory: true), fileExtension: "md", format: .markdown ), SourceDescriptor( label: "Gemini", directory: home.appendingPathComponent(".gemini", isDirectory: true) .appendingPathComponent("commands", isDirectory: true), fileExtension: "toml", format: .toml ), ] .filter { source in switch source.label { case "Codex": return SessionPreferencesStore.isCLIEnabled(.codex) case "Claude": return SessionPreferencesStore.isCLIEnabled(.claude) case "Gemini": return SessionPreferencesStore.isCLIEnabled(.gemini) default: return true } } var merged: [String: CommandImportCandidate] = [:] for source in sources { guard fileManager.fileExists(atPath: source.directory.path) else { continue } guard let entries = try? fileManager.contentsOfDirectory( at: source.directory, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles] ) else { continue } for entry in entries where entry.pathExtension.lowercased() == source.fileExtension { let id = entry.deletingPathExtension().lastPathComponent guard !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue } let marker = source.directory.appendingPathComponent(".\(id).codmate") if fileManager.fileExists(atPath: marker.path) { continue } guard let candidate = parseCommandCandidate(id: id, url: entry, source: source.label, format: source.format) else { continue } if var existing = merged[id] { if !existing.sources.contains(source.label) { existing.sources.append(source.label) } existing.sourcePaths[source.label] = entry.path merged[id] = existing } else { merged[id] = candidate } } } return merged.values.sorted { $0.id.localizedCaseInsensitiveCompare($1.id) == .orderedAscending } } private static func parseCommandCandidate( id: String, url: URL, source: String, format: CommandSourceFormat ) -> CommandImportCandidate? { switch format { case .markdown: guard let record = CommandsStore.parseMarkdownFile(at: url, id: id, source: "import") else { return nil } return CommandImportCandidate( id: record.id, name: record.name, description: record.description, prompt: record.prompt, metadata: record.metadata, sources: [source], sourcePaths: [source: url.path], isSelected: true, hasConflict: false, resolution: .overwrite, renameId: record.id ) case .toml: guard let record = parseTOMLCommand(at: url, id: id) else { return nil } return CommandImportCandidate( id: record.id, name: record.name, description: record.description, prompt: record.prompt, metadata: record.metadata, sources: [source], sourcePaths: [source: url.path], isSelected: true, hasConflict: false, resolution: .overwrite, renameId: record.id ) } } private static func parseTOMLCommand(at url: URL, id: String) -> CommandRecord? { guard let content = try? String(contentsOf: url, encoding: .utf8) else { return nil } let parsedPrompt = extractTOMLBlock(named: "prompt", from: content) let prompt = parsedPrompt?.trimmingCharacters(in: .whitespacesAndNewlines) ?? content.trimmingCharacters(in: .whitespacesAndNewlines) let description = extractTOMLString(named: "description", from: content) ?? "" return CommandRecord( id: id, name: id, description: description, prompt: prompt, metadata: CommandMetadata(), targets: CommandTargets(codex: true, claude: true, gemini: true), isEnabled: true, source: "import", path: "", installedAt: Date() ) } private static func extractTOMLBlock(named key: String, from text: String) -> String? { let pattern = "^\\s*\(key)\\s*=\\s*\"\"\"" guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else { return nil } let range = NSRange(text.startIndex.. String? { let pattern = "^\\s*\(key)\\s*=\\s*\"(.*)\"\\s*$" guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else { return nil } let range = NSRange(text.startIndex.. 1, let valueRange = Range(match.range(at: 1), in: text) else { return nil } return String(text[valueRange]) } } ================================================ FILE: services/CommandsStore.swift ================================================ import Foundation /// Unified commands store for managing slash commands across AI CLI providers /// Follows the same pattern as SkillsStore - uses Markdown files with YAML frontmatter actor CommandsStore { struct Paths { let root: URL let libraryDir: URL let indexURL: URL static func `default`(fileManager: FileManager = .default) -> Paths { let home = SessionPreferencesStore.getRealUserHomeURL() let root = home.appendingPathComponent(".codmate", isDirectory: true) .appendingPathComponent("commands", isDirectory: true) return Paths( root: root, libraryDir: root.appendingPathComponent("library", isDirectory: true), indexURL: root.appendingPathComponent("index.json", isDirectory: false) ) } } private let paths: Paths private let fm: FileManager init(paths: Paths = .default(), fileManager: FileManager = .default) { self.paths = paths self.fm = fileManager } // MARK: - Load/Save func list() -> [CommandRecord] { load() } func record(id: String) -> CommandRecord? { load().first(where: { $0.id == id }) } func saveAll(_ records: [CommandRecord]) { save(records) } func upsert(_ record: CommandRecord) { var records = load() let updatedRecord: CommandRecord if let idx = records.firstIndex(where: { $0.id == record.id }) { // Update existing: preserve path if not provided let existingPath = records[idx].path updatedRecord = CommandRecord( id: record.id, name: record.name, description: record.description, prompt: record.prompt, metadata: record.metadata, targets: record.targets, isEnabled: record.isEnabled, source: record.source, path: record.path.isEmpty ? existingPath : record.path, installedAt: record.installedAt ) records[idx] = updatedRecord } else { // New command: create path if empty let commandPath = record.path.isEmpty ? paths.libraryDir.appendingPathComponent("\(record.id).md").path : record.path updatedRecord = CommandRecord( id: record.id, name: record.name, description: record.description, prompt: record.prompt, metadata: record.metadata, targets: record.targets, isEnabled: record.isEnabled, source: record.source, path: commandPath, installedAt: record.installedAt ) records.append(updatedRecord) } // Write Markdown file writeMarkdownFile(for: updatedRecord) save(records) } func update(id: String, mutate: (inout CommandRecord) -> Void) { var records = load() guard let idx = records.firstIndex(where: { $0.id == id }) else { return } let baseRecord = loadCommandFromMarkdown(records[idx]) ?? records[idx] var updatedRecord = baseRecord mutate(&updatedRecord) // Keep markdown in sync for target/metadata changes without clobbering prompt. writeMarkdownFile(for: updatedRecord) records[idx] = CommandRecord( id: updatedRecord.id, name: updatedRecord.id, description: "", prompt: "", isEnabled: updatedRecord.isEnabled, source: updatedRecord.source, path: updatedRecord.path, installedAt: updatedRecord.installedAt ) save(records) } func delete(id: String) { var records = load() guard let record = records.first(where: { $0.id == id }) else { return } // Delete Markdown file let url = URL(fileURLWithPath: record.path) try? fm.removeItem(at: url) records.removeAll(where: { $0.id == id }) save(records) } // MARK: - Payload Commands /// Load default commands from the bundled payload directory private static func loadPayloadCommands(fm: FileManager = .default) -> [CommandRecord] { let bundle = Bundle.main var commands: [CommandRecord] = [] // Try loading index.json from bundle var indexURL: URL? if let url = bundle.url(forResource: "commands/index", withExtension: "json") { indexURL = url } if indexURL == nil, let url = bundle.url(forResource: "index", withExtension: "json", subdirectory: "payload/commands") { indexURL = url } guard let indexURL = indexURL, let data = try? Data(contentsOf: indexURL) else { return [] } // Parse lightweight index struct IndexEntry: Codable { let id: String let path: String let source: String let isEnabled: Bool let installedAt: String } let decoder = JSONDecoder() guard let indexEntries = try? decoder.decode([IndexEntry].self, from: data) else { return [] } let indexDir = indexURL.deletingLastPathComponent() // Load each Markdown file for entry in indexEntries { let mdURL = indexDir.appendingPathComponent(entry.path) guard let content = try? String(contentsOf: mdURL, encoding: .utf8) else { continue } guard let record = parseMarkdownContent(content, id: entry.id, source: entry.source, isEnabled: entry.isEnabled, installedAt: entry.installedAt, path: mdURL.path) else { continue } commands.append(record) } return commands } /// Parse Markdown content with YAML frontmatter static func parseMarkdownContent(_ content: String, id: String, source: String, isEnabled: Bool, installedAt: String, path: String) -> CommandRecord? { let lines = content.components(separatedBy: .newlines) guard lines.first?.trimmingCharacters(in: .whitespaces) == "---" else { return nil } var frontmatter: [String] = [] var promptLines: [String] = [] var inFrontmatter = false var foundSecondDash = false for (index, line) in lines.enumerated() { if index == 0 { inFrontmatter = true continue } if line.trimmingCharacters(in: .whitespaces) == "---" && inFrontmatter { foundSecondDash = true inFrontmatter = false continue } if inFrontmatter { frontmatter.append(line) } else if foundSecondDash { promptLines.append(line) } } // Parse YAML frontmatter var name = id var description = "" var argumentHint: String? var model: String? var allowedTools: [String]? var tags: [String] = [] var codex = true var claude = true var gemini = false for line in frontmatter { let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("name:") { name = trimmed.replacingOccurrences(of: "name:", with: "").trimmingCharacters(in: .whitespaces) } else if trimmed.hasPrefix("description:") { description = trimmed.replacingOccurrences(of: "description:", with: "").trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) } else if trimmed.hasPrefix("argument-hint:") { argumentHint = trimmed.replacingOccurrences(of: "argument-hint:", with: "").trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) } else if trimmed.hasPrefix("model:") { let value = trimmed.replacingOccurrences(of: "model:", with: "").trimmingCharacters(in: .whitespaces) if value != "null" && !value.isEmpty { model = value.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) } } else if trimmed.hasPrefix("allowed-tools:") { // Simple array parsing let arrayStr = trimmed.replacingOccurrences(of: "allowed-tools:", with: "").trimmingCharacters(in: .whitespaces) if arrayStr.hasPrefix("[") { let cleaned = arrayStr.replacingOccurrences(of: "[", with: "").replacingOccurrences(of: "]", with: "") allowedTools = cleaned.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) }.filter { !$0.isEmpty } } } else if trimmed.hasPrefix("tags:") { // Simple array parsing let arrayStr = trimmed.replacingOccurrences(of: "tags:", with: "").trimmingCharacters(in: .whitespaces) if arrayStr.hasPrefix("[") { let cleaned = arrayStr.replacingOccurrences(of: "[", with: "").replacingOccurrences(of: "]", with: "") tags = cleaned.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) }.filter { !$0.isEmpty } } } else if trimmed == "targets:" { // Next lines will be target values } else if trimmed.hasPrefix("codex:") { codex = trimmed.replacingOccurrences(of: "codex:", with: "").trimmingCharacters(in: .whitespaces) == "true" } else if trimmed.hasPrefix("claude:") { claude = trimmed.replacingOccurrences(of: "claude:", with: "").trimmingCharacters(in: .whitespaces) == "true" } else if trimmed.hasPrefix("gemini:") { gemini = trimmed.replacingOccurrences(of: "gemini:", with: "").trimmingCharacters(in: .whitespaces) == "true" } else if trimmed.hasPrefix("-") && frontmatter.last?.contains("tags") == true { // Handle YAML array items let item = trimmed.replacingOccurrences(of: "-", with: "").trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) if !item.isEmpty { tags.append(item) } } else if trimmed.hasPrefix("-") && frontmatter.last?.contains("allowed-tools") == true { // Handle YAML array items let item = trimmed.replacingOccurrences(of: "-", with: "").trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) if !item.isEmpty { if allowedTools == nil { allowedTools = [] } allowedTools?.append(item) } } } let prompt = promptLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) let dateFormatter = ISO8601DateFormatter() let date = dateFormatter.date(from: installedAt) ?? Date() return CommandRecord( id: id, name: name, description: description, prompt: prompt, metadata: CommandMetadata( argumentHint: argumentHint, model: model, allowedTools: allowedTools, tags: tags ), targets: CommandTargets(codex: codex, claude: claude, gemini: gemini), isEnabled: isEnabled, source: source, path: path, installedAt: date ) } static func parseMarkdownFile(at url: URL, id: String, source: String) -> CommandRecord? { guard let content = try? String(contentsOf: url, encoding: .utf8) else { return nil } let dateFormatter = ISO8601DateFormatter() let installedAt = dateFormatter.string(from: Date()) return parseMarkdownContent( content, id: id, source: source, isEnabled: true, installedAt: installedAt, path: url.path ) } /// List all commands, initializing from payload if needed func listWithBuiltIns() -> [CommandRecord] { // Initialize from payload on first run initializeFromPayloadIfNeeded() // Load from user directory let userCommands = load() // Load command content from Markdown files var fullCommands: [CommandRecord] = [] for record in userCommands { if let loaded = loadCommandFromMarkdown(record) { fullCommands.append(loaded) } } return fullCommands.sorted { $0.name < $1.name } } /// Initialize commands from payload (one-time only) private func initializeFromPayloadIfNeeded() { // Check if already initialized if fm.fileExists(atPath: paths.indexURL.path) { return } // Load payload commands let payloadCommands = Self.loadPayloadCommands(fm: fm) guard !payloadCommands.isEmpty else { return } // Create library directory try? fm.createDirectory(at: paths.libraryDir, withIntermediateDirectories: true) // Copy commands to user library with source: "library" var userCommands: [CommandRecord] = [] for payloadCmd in payloadCommands { let userPath = paths.libraryDir.appendingPathComponent("\(payloadCmd.id).md").path let userCmd = CommandRecord( id: payloadCmd.id, name: payloadCmd.name, description: payloadCmd.description, prompt: payloadCmd.prompt, metadata: payloadCmd.metadata, targets: payloadCmd.targets, isEnabled: payloadCmd.isEnabled, source: "library", // Mark as library, not payload path: userPath, installedAt: payloadCmd.installedAt ) // Write Markdown file writeMarkdownFile(for: userCmd) userCommands.append(userCmd) } // Save index save(userCommands) } // MARK: - Private Helpers /// Load command details from Markdown file private func loadCommandFromMarkdown(_ indexRecord: CommandRecord) -> CommandRecord? { let path = indexRecord.path guard !path.isEmpty else { return indexRecord } let url = URL(fileURLWithPath: path) guard let content = try? String(contentsOf: url, encoding: .utf8) else { return indexRecord } let dateFormatter = ISO8601DateFormatter() let installedAtStr = dateFormatter.string(from: indexRecord.installedAt) return Self.parseMarkdownContent(content, id: indexRecord.id, source: indexRecord.source, isEnabled: indexRecord.isEnabled, installedAt: installedAtStr, path: path) } /// Write command to Markdown file private func writeMarkdownFile(for record: CommandRecord) { let url = URL(fileURLWithPath: record.path) try? fm.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) var content = "---\n" content += "id: \(record.id)\n" content += "name: \(record.name)\n" content += "description: \"\(record.description)\"\n" if let hint = record.metadata.argumentHint { content += "argument-hint: \"\(hint)\"\n" } if let model = record.metadata.model { content += "model: \"\(model)\"\n" } if let tools = record.metadata.allowedTools, !tools.isEmpty { content += "allowed-tools: [\"\(tools.joined(separator: "\", \""))\"]\n" } if !record.metadata.tags.isEmpty { content += "tags: [\"\(record.metadata.tags.joined(separator: "\", \""))\"]\n" } content += "targets:\n" content += " codex: \(record.targets.codex)\n" content += " claude: \(record.targets.claude)\n" content += " gemini: \(record.targets.gemini)\n" content += "---\n\n" content += record.prompt try? content.write(to: url, atomically: true, encoding: .utf8) } /// Load index (lightweight metadata only) private func load() -> [CommandRecord] { guard fm.fileExists(atPath: paths.indexURL.path) else { return [] } guard let data = try? Data(contentsOf: paths.indexURL) else { return [] } struct IndexEntry: Codable { let id: String let path: String let source: String let isEnabled: Bool let installedAt: Date } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 guard let entries = try? decoder.decode([IndexEntry].self, from: data) else { return [] } return entries.map { entry in CommandRecord( id: entry.id, name: entry.id, description: "", prompt: "", isEnabled: entry.isEnabled, source: entry.source, path: entry.path, installedAt: entry.installedAt ) } } /// Save index (lightweight metadata only) private func save(_ records: [CommandRecord]) { struct IndexEntry: Codable { let id: String let path: String let source: String let isEnabled: Bool let installedAt: Date } let entries = records.map { record in IndexEntry( id: record.id, path: record.path, source: record.source, isEnabled: record.isEnabled, installedAt: record.installedAt ) } let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes, .sortedKeys] encoder.dateEncodingStrategy = .iso8601 guard let data = try? encoder.encode(entries) else { return } try? fm.createDirectory(at: paths.root, withIntermediateDirectories: true) try? data.write(to: paths.indexURL, options: .atomic) } } ================================================ FILE: services/CommandsSyncService.swift ================================================ import Foundation /// Service for syncing commands from the unified store to provider-specific formats /// Follows the same pattern as SkillsSyncService and MCPServersStore export functions actor CommandsSyncService { private let fm: FileManager init(fileManager: FileManager = .default) { self.fm = fileManager } // MARK: - Sync to All Providers func syncGlobal(commands: [CommandRecord]) -> [CommandSyncWarning] { let home = SessionPreferencesStore.getRealUserHomeURL() let codexDir = home.appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("prompts", isDirectory: true) let claudeDir = home.appendingPathComponent(".claude", isDirectory: true) .appendingPathComponent("commands", isDirectory: true) let geminiDir = home.appendingPathComponent(".gemini", isDirectory: true) .appendingPathComponent("commands", isDirectory: true) let targets: [(CommandTarget, URL)] = [ (.codex, codexDir), (.claude, claudeDir), (.gemini, geminiDir) ] var warnings: [CommandSyncWarning] = [] for (target, destination) in targets where SessionPreferencesStore.isCLIEnabled(target.baseKind) { warnings.append(contentsOf: syncCommands(commands: commands, target: target, destination: destination)) } return warnings } // MARK: - Private Sync Logic private func syncCommands( commands: [CommandRecord], target: CommandTarget, destination: URL ) -> [CommandSyncWarning] { let selected = commands.filter { $0.isEnabled && $0.targets.isEnabled(for: target) } if selected.isEmpty { removeManagedCommands(at: destination) return [] } try? fm.createDirectory(at: destination, withIntermediateDirectories: true) var warnings: [CommandSyncWarning] = [] for command in selected { do { try writeCommand(command, to: destination, target: target) } catch { warnings.append(CommandSyncWarning( message: "\(command.id) could not sync to \(destination.path): \(error.localizedDescription)" )) } } removeManagedCommands(at: destination, keeping: Set(selected.map { $0.id })) return warnings } // MARK: - Format Writers private func writeCommand(_ command: CommandRecord, to directory: URL, target: CommandTarget) throws { let fileURL: URL let content: String switch target { case .codex, .claude: // Both use Markdown + YAML frontmatter fileURL = directory.appendingPathComponent("\(command.id).md", isDirectory: false) content = generateMarkdownFormat(command, for: target) case .gemini: // Gemini uses TOML fileURL = directory.appendingPathComponent("\(command.id).toml", isDirectory: false) content = generateTOMLFormat(command) } // Write content try content.write(to: fileURL, atomically: true, encoding: .utf8) // Write marker file for CodMate management try writeMarker(to: directory, id: command.id, target: target) } // MARK: - Markdown Format (Claude Code & Codex CLI) private func generateMarkdownFormat(_ command: CommandRecord, for target: CommandTarget) -> String { var frontmatter: [String] = [] // Description (required) frontmatter.append("description: \"\(escapeYAML(command.description))\"") // Argument hint (optional) if let hint = command.metadata.argumentHint, !hint.isEmpty { frontmatter.append("argument-hint: \(hint)") } // Model (Claude Code only) if target == .claude, let model = command.metadata.model, !model.isEmpty { frontmatter.append("model: \(model)") } // Allowed tools (Claude Code only) if target == .claude, let tools = command.metadata.allowedTools, !tools.isEmpty { frontmatter.append("allowed-tools: \(tools.joined(separator: ", "))") } // Build final markdown var lines: [String] = ["---"] lines.append(contentsOf: frontmatter) lines.append("---") lines.append("") lines.append(command.prompt) lines.append("") return lines.joined(separator: "\n") } // MARK: - TOML Format (Gemini CLI) private func generateTOMLFormat(_ command: CommandRecord) -> String { var lines: [String] = [] // Multi-line prompt lines.append("prompt = \"\"\"") lines.append(command.prompt) lines.append("\"\"\"") // Description lines.append("description = \"\(escapeTOML(command.description))\"") return lines.joined(separator: "\n") + "\n" } // MARK: - Marker Management private func writeMarker(to directory: URL, id: String, target: CommandTarget) throws { let markerFile = directory.appendingPathComponent(".\(id).codmate", isDirectory: false) let marker: [String: Any] = [ "managedByCodMate": true, "id": id, "syncedAt": ISO8601DateFormatter().string(from: Date()) ] let data = try JSONSerialization.data(withJSONObject: marker, options: [.prettyPrinted]) try data.write(to: markerFile, options: .atomic) } private func removeManagedCommands(at directory: URL, keeping ids: Set = []) { guard fm.fileExists(atPath: directory.path) else { return } guard let entries = try? fm.contentsOfDirectory( at: directory, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles] ) else { return } for entry in entries { let basename = entry.deletingPathExtension().lastPathComponent guard !ids.contains(basename) else { continue } // Check if there's a marker file let markerFile = directory.appendingPathComponent(".\(basename).codmate", isDirectory: false) if fm.fileExists(atPath: markerFile.path) { // Remove both the command file and marker try? fm.removeItem(at: entry) try? fm.removeItem(at: markerFile) } } } // MARK: - Utility Functions private func escapeYAML(_ string: String) -> String { string .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") .replacingOccurrences(of: "\n", with: "\\n") } private func escapeTOML(_ string: String) -> String { string .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") } } // MARK: - Warning struct CommandSyncWarning { var message: String } ================================================ FILE: services/ContextTreeshaker.swift ================================================ import Foundation struct TreeshakeOptions: Sendable, Equatable { var includeReasoning: Bool = false var includeToolSummary: Bool = false var mergeConsecutiveAssistant: Bool = true var maxMessageBytes: Int = 2 * 1024 // 2KB default (faster preview) // Optional override for visible message kinds; when nil, caller can inject app-wide defaults. var visibleKinds: Set? = nil } actor ContextTreeshaker { private let loader = SessionTimelineLoader() private let geminiParser = GeminiSessionParser() // Simple LRU cache for per-session slim markdown private struct Entry { let version: Date?; let optSig: String; let text: String } private var cache: [String: Entry] = [:] // session.id -> entry private var lru: [String] = [] private let capacity = 32 private func optSignature(_ o: TreeshakeOptions) -> String { let kindsSig: String = { if let kinds = o.visibleKinds { let items = kinds.map { $0.rawValue }.sorted().joined(separator: ",") return "vk:[\(items)]" } else { return "vk:-" } }() return "r:\(o.includeReasoning ? 1 : 0);t:\(o.includeToolSummary ? 1 : 0);m:\(o.mergeConsecutiveAssistant ? 1 : 0);b:\(o.maxMessageBytes);\(kindsSig)" } private func fileVersion(for s: SessionSummary) -> Date? { if let t = s.lastUpdatedAt { return t } let attrs = (try? FileManager.default.attributesOfItem(atPath: s.fileURL.path)) ?? [:] return attrs[.modificationDate] as? Date } private func lruTouch(_ id: String) { if let idx = lru.firstIndex(of: id) { lru.remove(at: idx) } lru.insert(id, at: 0) if lru.count > capacity, let evict = lru.popLast() { cache.removeValue(forKey: evict) } } private func slim(for s: SessionSummary, options: TreeshakeOptions) -> String { let ver = fileVersion(for: s) let sig = optSignature(options) if let e = cache[s.id], e.version == ver, e.optSig == sig { lruTouch(s.id); return e.text } // Build slim markdown for a single session (no header) let turns: [ConversationTurn] if let loaded = loadTurns(for: s) { if let kinds = options.visibleKinds { turns = loaded.filtering(visibleKinds: kinds) } else { turns = loaded } } else { turns = [] } var out: [String] = [] var prevWasAssistant = false let allowReasoning = options.includeReasoning && (options.visibleKinds?.contains(.reasoning) ?? true) let allowInfoSummary = options.includeToolSummary && (options.visibleKinds?.contains(.infoOther) ?? true) for turn in turns { if Task.isCancelled { break } if let user = turn.userMessage, let text = user.text, !text.isEmpty { out.append("**User** · \(user.timestamp)") out.append(trim(text, limit: options.maxMessageBytes)) out.append("") prevWasAssistant = false } // Optional: Reasoning block (if available and allowed) if allowReasoning { if let r = turn.outputs.last(where: { isReasoning($0) })?.text, !r.isEmpty { out.append("**Reasoning** · \(turn.timestamp)") out.append(trim(r, limit: options.maxMessageBytes)) out.append("") prevWasAssistant = false } } var assistantText: String? = nil for event in turn.outputs.reversed() { if event.actor == .assistant, let t = event.text, !t.isEmpty { assistantText = t; break } } if let a = assistantText { let body = trim(a, limit: options.maxMessageBytes) if options.mergeConsecutiveAssistant && prevWasAssistant { if let last = out.last, !last.isEmpty { out[out.count - 1] = last + "\n\n" + body } else { out.append(body) } } else { out.append("**Assistant** · \(turn.timestamp)") out.append(body) } out.append("") prevWasAssistant = true } // Optional: Info/Tool summary (best-effort from remaining info events) if allowInfoSummary { if let info = turn.outputs.last(where: { isInfoSummary($0) })?.text, !info.isEmpty { out.append("**Info** · \(turn.timestamp)") out.append(trim(info, limit: options.maxMessageBytes)) out.append("") prevWasAssistant = false } } } let text = out.joined(separator: "\n") cache[s.id] = Entry(version: ver, optSig: sig, text: text) lruTouch(s.id) return text } private func loadTurns(for summary: SessionSummary) -> [ConversationTurn]? { if summary.source.baseKind == .gemini { return loadGeminiTurns(for: summary) } return try? loader.load(url: summary.fileURL) } private func loadGeminiTurns(for summary: SessionSummary) -> [ConversationTurn]? { guard !summary.isRemote else { return nil } let url = summary.fileURL guard FileManager.default.fileExists(atPath: url.path) else { return nil } guard let hash = geminiProjectHash(from: url) else { return nil } let resolvedPath: String? = { let trimmed = summary.cwd.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed }() guard let parsed = geminiParser.parse( at: url, projectHash: hash, resolvedProjectPath: resolvedPath ) else { return nil } return loader.turns(from: parsed.rows) } private func geminiProjectHash(from url: URL) -> String? { let components = url.standardizedFileURL.pathComponents for (index, component) in components.enumerated() where component == "tmp" { let candidateIndex = index + 1 guard candidateIndex < components.count else { continue } let candidate = components[candidateIndex] if isValidGeminiHash(candidate) { return candidate } } return nil } private func isValidGeminiHash(_ value: String) -> Bool { guard value.count == 64 else { return false } let pattern = "^[0-9a-f]{64}$" return value.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil } func generateMarkdown(for sessions: [SessionSummary], options: TreeshakeOptions = TreeshakeOptions()) -> String { let sorted = sessions.sorted { ($0.startedAt) < ($1.startedAt) } var out: [String] = [] let df = DateFormatter(); df.dateStyle = .medium; df.timeStyle = .short let maxTotal = 64 * 1024 // tighter 64KB cap for preview var total = 0 for s in sorted { if Task.isCancelled { break } let headerTitle = s.effectiveTitle let timeText: String = { let end = s.lastUpdatedAt ?? s.startedAt return df.string(from: end) }() let header = "# \(headerTitle) · \(timeText)\n\n" total += header.utf8.count if total > maxTotal { out.append("… [truncated]"); break } out.append(header) let body = slim(for: s, options: options) total += body.utf8.count if total > maxTotal { // keep tail within limit let remaining = max(0, maxTotal - (total - body.utf8.count)) let clipped = trim(body, limit: remaining) out.append(clipped) out.append("\n… [truncated]") break } else { out.append(body) out.append("\n") } } return out.joined(separator: "") } private func trim(_ text: String, limit: Int) -> String { // Keep within byte limit while respecting Unicode character boundaries guard limit > 0 else { return text } let totalBytes = text.utf8.count guard totalBytes > limit else { return text } // Keep head/tail samples to provide surrounding context let headBytes = max(512, limit / 4) let tailBytes = max(512, limit / 4) let headStr = prefixByUTF8(text, maxBytes: headBytes) let tailStr = suffixByUTF8(text, maxBytes: tailBytes) return headStr + "\n\n… [snip] …\n\n" + tailStr } // Safe UTF-8 prefix cut at Character boundaries private func prefixByUTF8(_ text: String, maxBytes: Int) -> String { guard maxBytes > 0 else { return "" } var used = 0 var endIndex = text.startIndex for ch in text { // Character iteration respects extended grapheme clusters let b = String(ch).utf8.count if used + b > maxBytes { break } used += b endIndex = text.index(after: endIndex) } return String(text[.. String { guard maxBytes > 0 else { return "" } var used = 0 var charCount = 0 for ch in text.reversed() { // reversed Characters let b = String(ch).utf8.count if used + b > maxBytes { break } used += b charCount += 1 } if charCount == 0 { return "" } // Take last `charCount` Characters var start = text.endIndex for _ in 0.. Bool { e.visibilityKind == .reasoning } private func isInfoSummary(_ e: TimelineEvent) -> Bool { guard e.actor == .info else { return false } switch e.visibilityKind { case .environmentContext, .turnContext, .reasoning, .tokenUsage: return false default: return true } } ================================================ FILE: services/DirectoryMonitor.swift ================================================ import Foundation import Darwin final class DirectoryMonitor { private var fileDescriptor: CInt = -1 private var source: DispatchSourceFileSystemObject? private let queue = DispatchQueue(label: "io.codmate.directorymonitor", qos: .utility) private let handler: () -> Void init?(url: URL, handler: @escaping () -> Void) { self.handler = handler guard let descriptor = DirectoryMonitor.openDescriptor(at: url) else { return nil } fileDescriptor = descriptor configureSource() } func updateURL(_ url: URL) { cancel() guard let descriptor = DirectoryMonitor.openDescriptor(at: url) else { return } fileDescriptor = descriptor configureSource() } func cancel() { source?.cancel() source = nil if fileDescriptor != -1 { close(fileDescriptor) fileDescriptor = -1 } } deinit { cancel() } private func configureSource() { guard fileDescriptor != -1 else { return } let newSource = DispatchSource.makeFileSystemObjectSource( fileDescriptor: fileDescriptor, eventMask: [.write, .rename, .delete, .extend], queue: queue ) newSource.setEventHandler { [weak self] in self?.handler() } newSource.setCancelHandler { [weak self] in if let fd = self?.fileDescriptor, fd != -1 { close(fd) self?.fileDescriptor = -1 } } source = newSource newSource.resume() } private static func openDescriptor(at url: URL) -> CInt? { let path = (url as NSURL).fileSystemRepresentation let fd = open(path, O_EVTONLY) guard fd != -1 else { return nil } return fd } } ================================================ FILE: services/DockOpenCoordinator.swift ================================================ import Foundation @MainActor final class DockOpenCoordinator { static let shared = DockOpenCoordinator() struct PendingNewProjectRequest: Sendable, Equatable { let directory: String let name: String? } private var pendingNewProject: PendingNewProjectRequest? = nil private var isContentViewReady = false /// Mark that ContentView has completed initialization and is ready to handle new project requests func markContentViewReady() { isContentViewReady = true // If there's a pending request, notify now that view is ready if let request = pendingNewProject { NotificationCenter.default.post( name: .codMateOpenNewProject, object: nil, userInfo: [ "directory": request.directory, "name": request.name ?? "" ] ) } } func enqueueNewProject(directory: String, name: String?) { let trimmedDir = directory.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedDir.isEmpty else { return } let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines) let request = PendingNewProjectRequest( directory: trimmedDir, name: (trimmedName?.isEmpty == false) ? trimmedName : nil ) pendingNewProject = request // Only send notification if ContentView is ready (runtime scenario) // Otherwise queue it for onAppear consumption (first launch scenario) if isContentViewReady { NotificationCenter.default.post( name: .codMateOpenNewProject, object: nil, userInfo: [ "directory": request.directory, "name": request.name ?? "" ] ) } } func consumePendingNewProject() -> PendingNewProjectRequest? { let request = pendingNewProject pendingNewProject = nil return request } } ================================================ FILE: services/EmbeddedNotifySniffer.swift ================================================ import Foundation /// Lightweight heuristic to detect "turn complete" style events from terminal output. /// Only used for embedded terminal sessions; external Terminal/TTY paths still rely on `notify` bridge. struct EmbeddedNotifySniffer { /// Returns a short message if the provided line(s) suggest the agent just completed a turn. static func sniff(line: String) -> String? { let s = line.trimmingCharacters(in: .whitespacesAndNewlines) guard !s.isEmpty else { return nil } let lower = s.lowercased() // Common variants seen across TUI implementations let needles = [ "agent turn complete", "turn complete", "agent completed", "run complete", "session complete", ] for n in needles { if lower.contains(n) { return "Turn complete" } } return nil } static func sniff(lines: [String]) -> String? { for l in lines.reversed() { if let m = sniff(line: l) { return m } } return nil } } ================================================ FILE: services/ExternalTerminalProfileStore.swift ================================================ import Foundation struct ExternalTerminalProfileStore { static let shared = ExternalTerminalProfileStore() struct Paths { let home: URL let fileURL: URL } private let fileManager: FileManager private let paths: Paths init(fileManager: FileManager = .default, paths: Paths? = nil) { self.fileManager = fileManager if let paths { self.paths = paths } else { let home = SessionPreferencesStore.getRealUserHomeURL() let dir = home.appendingPathComponent(".codmate", isDirectory: true) self.paths = Paths(home: dir, fileURL: dir.appendingPathComponent("terminals.json")) } } func seedUserFileIfNeeded() { guard !fileManager.fileExists(atPath: paths.fileURL.path) else { return } guard let bundled = loadBundledProfilesRawData() else { return } do { try fileManager.createDirectory(at: paths.home, withIntermediateDirectories: true) try bundled.write(to: paths.fileURL, options: .atomic) } catch { // Best-effort only; ignore failures to avoid blocking launch. } } func loadUserProfiles() -> [ExternalTerminalProfile] { guard let data = try? Data(contentsOf: paths.fileURL) else { return [] } if let decoded = decodeProfiles(from: data) { return decoded } rebuildUserFileFromBundle() guard let rebuilt = try? Data(contentsOf: paths.fileURL) else { return [] } return decodeProfiles(from: rebuilt) ?? [] } func loadBundledProfiles() -> [ExternalTerminalProfile] { guard let data = loadBundledProfilesRawData() else { return [] } return decodeProfiles(from: data) ?? [] } func mergedProfiles() -> [ExternalTerminalProfile] { seedUserFileIfNeeded() let protected = Self.protectedIds var merged = Self.builtInProfiles var indexById: [String: Int] = [:] for (idx, profile) in merged.enumerated() { indexById[profile.id] = idx } let user = loadUserProfiles().filter { !protected.contains($0.id) } for profile in user { if let idx = indexById[profile.id] { merged[idx] = profile } else { indexById[profile.id] = merged.count merged.append(profile) } } return merged } func availableProfiles(includeNone: Bool = true) -> [ExternalTerminalProfile] { let profiles = mergedProfiles().filter { $0.isAvailable } if includeNone { return profiles } return profiles.filter { !$0.isNone } } func profile(for id: String) -> ExternalTerminalProfile? { mergedProfiles().first { $0.id == id } } func resolvePreferredProfile(id: String?) -> ExternalTerminalProfile? { let profiles = availableProfiles(includeNone: true) if let id, let match = profiles.first(where: { $0.id == id }) { return match } if let terminal = profiles.first(where: { $0.id == "terminal" }) { return terminal } return profiles.first } func resolvePreferredId(id: String?) -> String { resolvePreferredProfile(id: id)?.id ?? "terminal" } private func loadBundledProfilesRawData() -> Data? { let bundle = Bundle.main var urls: [URL] = [] if let u = bundle.url(forResource: "terminals", withExtension: "json") { urls.append(u) } if let u = bundle.url(forResource: "terminals", withExtension: "json", subdirectory: "payload") { urls.append(u) } for url in urls { if let data = try? Data(contentsOf: url) { return data } } return nil } private struct TerminalsFile: Codable { let terminals: [ExternalTerminalProfile] } private func decodeProfiles(from data: Data) -> [ExternalTerminalProfile]? { let decoder = JSONDecoder() if let profiles = try? decoder.decode([ExternalTerminalProfile].self, from: data) { return profiles } if let file = try? decoder.decode(TerminalsFile.self, from: data) { return file.terminals } return nil } private func rebuildUserFileFromBundle() { guard let bundled = loadBundledProfilesRawData() else { return } do { try fileManager.createDirectory(at: paths.home, withIntermediateDirectories: true) try bundled.write(to: paths.fileURL, options: .atomic) } catch { return } Self.notifyParseFailureOnce() } private static var didNotifyParseFailure = false private static func notifyParseFailureOnce() { guard !didNotifyParseFailure else { return } didNotifyParseFailure = true Task { await SystemNotifier.shared.notify( title: "CodMate", body: "Terminals configuration failed to load. Rebuilt defaults." ) } } private static let builtInProfiles: [ExternalTerminalProfile] = [ ExternalTerminalProfile( id: "none", title: "None", bundleIdentifiers: nil, urlTemplate: nil, supportsCommand: false, supportsDirectory: false, managedByCodMate: true, commandStyle: .standard ), ExternalTerminalProfile( id: "terminal", title: "Terminal", bundleIdentifiers: ["com.apple.Terminal"], urlTemplate: nil, supportsCommand: false, supportsDirectory: true, managedByCodMate: true, commandStyle: .standard ), ] private static let protectedIds: Set = ["none", "terminal"] } ================================================ FILE: services/ExternalURLRouter.swift ================================================ import Foundation /// Handles custom codmate:// URLs dispatched via NSWorkspace/open. @MainActor enum ExternalURLRouter { static func handle(_ urls: [URL]) { for url in urls { handle(url) } } static func handle(_ url: URL) { print("🔗 [ExternalURLRouter] Handling URL: \(url.absoluteString)") guard url.scheme?.lowercased() == "codmate" else { print("⚠️ [ExternalURLRouter] Invalid scheme: \(url.scheme ?? "nil")") return } switch (url.host ?? "").lowercased() { case "notify": print("📬 [ExternalURLRouter] Processing notification") handleNotify(url) default: print("⚠️ [ExternalURLRouter] Unknown host: \(url.host ?? "nil")") break } } private static func handleNotify(_ url: URL) { guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return } let items = components.queryItems ?? [] guard let source = NotificationSource(rawValue: (items.first(where: { $0.name == "source" })?.value ?? "").lowercased()) else { return } let eventName = (items.first(where: { $0.name == "event" })?.value ?? "").lowercased() let title = decodeQueryValue(items: items, preferred: ["title", "title64"]) let body = decodeQueryValue(items: items, preferred: ["body", "body64"]) let threadId = items.first(where: { $0.name == "thread" || $0.name == "threadId" })?.value guard let descriptor = NotificationDescriptor.make( source: source, eventName: eventName, providedTitle: title, providedBody: body, providedThreadId: threadId ) else { return } Task { @MainActor in await SystemNotifier.shared.notify( title: descriptor.title, body: descriptor.body, threadId: descriptor.threadId ) } } private static func decodeQueryValue(items: [URLQueryItem], preferred keys: [String]) -> String? { for key in keys { if let value = items.first(where: { $0.name == key })?.value { if key.hasSuffix("64"), let decoded = decodeBase64(value) { return decoded } if !key.hasSuffix("64") { return value } } } return nil } private static func decodeBase64(_ value: String) -> String? { guard let data = Data(base64Encoded: value) else { return nil } return String(data: data, encoding: .utf8) } } private enum NotificationSource: String { case claude case codex case gemini } private struct NotificationDescriptor { let title: String let body: String let threadId: String? static func make( source: NotificationSource, eventName: String, providedTitle: String?, providedBody: String?, providedThreadId: String? ) -> NotificationDescriptor? { switch source { case .claude: return makeClaudeDescriptor(eventName: eventName, providedTitle: providedTitle, providedBody: providedBody, providedThreadId: providedThreadId) case .codex: return makeCodexDescriptor(eventName: eventName, providedTitle: providedTitle, providedBody: providedBody, providedThreadId: providedThreadId) case .gemini: return makeGeminiDescriptor(eventName: eventName, providedTitle: providedTitle, providedBody: providedBody, providedThreadId: providedThreadId) } } private static func makeClaudeDescriptor( eventName: String, providedTitle: String?, providedBody: String?, providedThreadId: String? ) -> NotificationDescriptor? { guard let event = ClaudeEvent(rawValue: eventName) else { return nil } let defaults = event.defaults let title = providedTitle?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? defaults.title let body = providedBody?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? defaults.body let thread = providedThreadId?.nonEmpty ?? defaults.threadId return NotificationDescriptor(title: title, body: body, threadId: thread) } private static func makeCodexDescriptor( eventName: String, providedTitle: String?, providedBody: String?, providedThreadId: String? ) -> NotificationDescriptor? { guard let event = CodexEvent(rawValue: eventName) else { return nil } let defaults = event.defaults let title = providedTitle?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? defaults.title let body = providedBody?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? defaults.body let thread = providedThreadId?.nonEmpty ?? defaults.threadId return NotificationDescriptor(title: title, body: body, threadId: thread) } private static func makeGeminiDescriptor( eventName: String, providedTitle: String?, providedBody: String?, providedThreadId: String? ) -> NotificationDescriptor? { guard let event = GeminiEvent(rawValue: eventName) else { return nil } let defaults = event.defaults let title = providedTitle?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? defaults.title let body = providedBody?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? defaults.body let thread = providedThreadId?.nonEmpty ?? defaults.threadId return NotificationDescriptor(title: title, body: body, threadId: thread) } private enum ClaudeEvent: String { case permission case complete case test var defaults: (title: String, body: String, threadId: String) { switch self { case .permission: return ("Claude", "Claude requires approval. Return to the Claude window to respond.", "claude-permission") case .complete: return ("Claude", "Claude finished its current task.", "claude-complete") case .test: return ("CodMate", "Claude notifications self-test", "claude-test") } } } private enum CodexEvent: String { case turncomplete case test var defaults: (title: String, body: String, threadId: String) { switch self { case .turncomplete: return ("Codex", "Codex turn complete.", "codex-thread") case .test: return ("CodMate", "Codex notifications self-test", "codex-test") } } } private enum GeminiEvent: String { case permission case test var defaults: (title: String, body: String, threadId: String) { switch self { case .permission: return ("Gemini", "Gemini requires approval. Return to the Gemini window to respond.", "gemini-permission") case .test: return ("CodMate", "Gemini notifications self-test", "gemini-test") } } } } private extension String { var nonEmpty: String? { isEmpty ? nil : self } } ================================================ FILE: services/GeminiSessionParser.swift ================================================ import Foundation struct GeminiTokenTotals { let input: Int let output: Int let cached: Int let thoughts: Int let tool: Int var hasValues: Bool { return input != 0 || output != 0 || cached != 0 || thoughts != 0 || tool != 0 } } struct GeminiParsedLog { let summary: SessionSummary let rows: [SessionRow] let tokens: GeminiTokenTotals? } private let geminiAbsolutePathRegex = try! NSRegularExpression( pattern: #"((?:~|/)[^\s"']+)"#, options: []) private let geminiPathTrimCharacters = CharacterSet(charactersIn: ",.;:)]}>\"'") struct GeminiSessionParser { private struct ConversationRecord: Decodable { struct Message: Decodable { struct ToolCall: Decodable { let id: String? let name: String? let args: JSONValue? let result: JSONValue? let description: String? let displayName: String? let resultDisplay: String? let status: String? let renderOutputAsMarkdown: Bool? } struct Thought: Decodable { let subject: String? let description: String? let timestamp: String? } struct Tokens: Decodable { let input: Int? let output: Int? let cached: Int? let thoughts: Int? let tool: Int? let total: Int? } let id: String let timestamp: String? let type: String let content: JSONValue? let model: String? let toolCalls: [ToolCall]? let thoughts: [Thought]? let tokens: Tokens? } let sessionId: String let projectHash: String? let startTime: String let lastUpdated: String? let messages: [Message] } private let decoder: JSONDecoder private let isoFormatter: ISO8601DateFormatter private let fallbackFormatter: ISO8601DateFormatter init(decoder: JSONDecoder = JSONDecoder()) { self.decoder = decoder self.isoFormatter = ISO8601DateFormatter() self.isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] self.fallbackFormatter = ISO8601DateFormatter() self.fallbackFormatter.formatOptions = [.withInternetDateTime] } func parse( at url: URL, projectHash: String, resolvedProjectPath: String? ) -> GeminiParsedLog? { guard let data = try? Data(contentsOf: url, options: [.mappedIfSafe]), let record = try? decoder.decode(ConversationRecord.self, from: data), let startedAt = parseDate(record.startTime) else { return nil } let hasUserOrAssistant = record.messages.contains { let kind = $0.type.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return kind == "user" || kind == "gemini" } guard hasUserOrAssistant else { return nil } let sessionFileId = url.deletingPathExtension().lastPathComponent let resumeIdentifier = record.sessionId.trimmingCharacters(in: .whitespacesAndNewlines) let sessionId = resumeIdentifier.isEmpty ? sessionFileId : resumeIdentifier let inferredDirectory = resolvedProjectPath ?? inferWorkingDirectory(from: record.messages) ?? defaultProjectPath(forHash: projectHash) let cwd = inferredDirectory var rows: [SessionRow] = [] // Aggregate per-session token usage from Gemini messages. var totalInput = 0 var totalOutput = 0 var totalCached = 0 var totalThoughts = 0 var totalTool = 0 for message in record.messages where message.type.lowercased() == "gemini" { guard let tokens = message.tokens else { continue } if let value = tokens.input, value > 0 { totalInput &+= value } if let value = tokens.output, value > 0 { totalOutput &+= value } if let value = tokens.cached, value > 0 { totalCached &+= value } if let value = tokens.thoughts, value > 0 { totalThoughts &+= value } if let value = tokens.tool, value > 0 { totalTool &+= value } } let aggregatedTokens: GeminiTokenTotals? = { let totals = GeminiTokenTotals( input: totalInput, output: totalOutput, cached: totalCached, thoughts: totalThoughts, tool: totalTool ) return totals.hasValues ? totals : nil }() let meta = SessionMetaPayload( id: sessionId, timestamp: startedAt, cwd: cwd, originator: "Gemini CLI", cliVersion: "Gemini CLI", instructions: nil ) let metaRow = SessionRow(timestamp: startedAt, kind: .sessionMeta(meta)) rows.append(metaRow) if let model = firstModel(in: record.messages) { let ctx = TurnContextPayload( cwd: cwd, approvalPolicy: nil, model: model, effort: nil, summary: nil ) rows.append(SessionRow(timestamp: startedAt, kind: .turnContext(ctx))) } var lastTimestamp = startedAt for message in record.messages { let messageRows = self.rows(from: message) rows.append(contentsOf: messageRows) if shouldInsertTurnBoundary(after: message, rows: messageRows), let markerTimestamp = messageRows.last?.timestamp ?? parseDate(message.timestamp) { rows.append(makeTurnBoundaryRow(for: message, timestamp: markerTimestamp)) } if let last = messageRows.last?.timestamp, last > lastTimestamp { lastTimestamp = last } } let fileSize = resolveFileSize(for: url) var builder = SessionSummaryBuilder() builder.setFileSize(fileSize) builder.setSource(.geminiLocal) for row in rows { builder.observe(row) } if let updated = parseDate(record.lastUpdated) ?? rows.last?.timestamp { builder.seedLastUpdated(updated) } else { builder.seedLastUpdated(lastTimestamp) } guard var summary = builder.build(for: url) else { return nil } summary = summary.overridingSource(.geminiLocal) return GeminiParsedLog(summary: summary, rows: rows, tokens: aggregatedTokens) } private func firstModel(in messages: [ConversationRecord.Message]) -> String? { for message in messages { if let model = message.model, !model.isEmpty { return model } } return nil } private func parseDate(_ value: String?) -> Date? { guard let value else { return nil } if let date = isoFormatter.date(from: value) { return date } if let date = fallbackFormatter.date(from: value) { return date } if let number = Double(value) { if number > 10_000_000_000 { return Date(timeIntervalSince1970: number / 1000.0) } else { return Date(timeIntervalSince1970: number) } } return nil } private func rows(from message: ConversationRecord.Message) -> [SessionRow] { guard let timestamp = parseDate(message.timestamp) else { return [] } var results: [SessionRow] = [] let text = renderText(from: message.content) ?? "" let loweredType = message.type.lowercased() switch loweredType { case "user": if !text.isEmpty { if Self.isControlCommand(text) { return results } results.append( SessionRow( timestamp: timestamp, kind: .eventMessage( EventMessagePayload( type: "user_message", message: text, kind: nil, text: text, reason: nil, info: nil, rateLimits: nil ))) ) } case "gemini": if !text.isEmpty { results.append( SessionRow( timestamp: timestamp, kind: .eventMessage( EventMessagePayload( type: "agent_message", message: text, kind: nil, text: text, reason: nil, info: nil, rateLimits: nil ))) ) } if let calls = message.toolCalls, !calls.isEmpty { for call in calls { if let row = toolCallRow(call, timestamp: timestamp) { results.append(row) } } } if let thoughts = message.thoughts { for thought in thoughts { if let row = thoughtRow(thought, fallback: timestamp) { results.append(row) } } } if let tokens = message.tokens { if let row = tokenRow(tokens, timestamp: timestamp) { results.append(row) } } case "info", "warning": if !text.isEmpty { results.append( SessionRow( timestamp: timestamp, kind: .eventMessage( EventMessagePayload( type: loweredType, message: text, kind: nil, text: text, reason: nil, info: nil, rateLimits: nil ))) ) } case "error": if !text.isEmpty { results.append( SessionRow( timestamp: timestamp, kind: .eventMessage( EventMessagePayload( type: "error", message: text, kind: nil, text: text, reason: nil, info: nil, rateLimits: nil ))) ) } default: if !text.isEmpty { results.append( SessionRow( timestamp: timestamp, kind: .eventMessage( EventMessagePayload( type: loweredType, message: text, kind: nil, text: text, reason: nil, info: nil, rateLimits: nil ))) ) } } return results } private func shouldInsertTurnBoundary( after message: ConversationRecord.Message, rows: [SessionRow] ) -> Bool { guard !rows.isEmpty else { return false } return message.type.lowercased() == "gemini" } private func makeTurnBoundaryRow( for message: ConversationRecord.Message, timestamp: Date ) -> SessionRow { let payload = EventMessagePayload( type: "turn_boundary", message: message.id, kind: message.type.lowercased(), text: nil, reason: nil, info: nil, rateLimits: nil ) return SessionRow(timestamp: timestamp, kind: .eventMessage(payload)) } private func toolCallRow( _ call: ConversationRecord.Message.ToolCall, timestamp: Date ) -> SessionRow? { guard let name = call.name ?? call.displayName else { return nil } let outputValue: JSONValue? = { if let display = call.resultDisplay, !display.isEmpty { return .string(display) } return call.result }() let payload = ResponseItemPayload( type: "tool_call", status: call.status, callID: call.id, name: name, content: nil, summary: nil, encryptedContent: nil, role: "assistant", arguments: call.args, input: nil, output: outputValue, ghostCommit: nil ) return SessionRow(timestamp: timestamp, kind: .responseItem(payload)) } private func thoughtRow( _ thought: ConversationRecord.Message.Thought, fallback: Date ) -> SessionRow? { let subject = thought.subject?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard let description = thought.description else { return nil } var body = description if !subject.isEmpty { body = "\(subject): \(description)" } guard !body.isEmpty else { return nil } let payload = EventMessagePayload( type: "agent_reasoning", message: body, kind: nil, text: body, reason: nil, info: nil, rateLimits: nil ) return SessionRow(timestamp: parseDate(thought.timestamp) ?? fallback, kind: .eventMessage(payload)) } private func tokenRow( _ tokens: ConversationRecord.Message.Tokens, timestamp: Date ) -> SessionRow? { var info: [String: JSONValue] = [:] var hasNonZero = false func addNumber(_ key: String, _ value: Int?) { guard let value else { return } info[key] = .number(Double(value)) if value > 0 { hasNonZero = true } } addNumber("input", tokens.input) addNumber("output", tokens.output) addNumber("cached", tokens.cached) addNumber("thoughts", tokens.thoughts) addNumber("tool", tokens.tool) addNumber("total", tokens.total) guard !info.isEmpty, hasNonZero else { return nil } let payload = EventMessagePayload( type: "token_count", message: nil, kind: nil, text: nil, reason: nil, info: .object(info), rateLimits: nil ) return SessionRow(timestamp: timestamp, kind: .eventMessage(payload)) } private func renderText(from value: JSONValue?) -> String? { guard let value else { return nil } switch value { case .string(let str): let trimmed = str.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : str case .number(let number): return String(number) case .bool(let flag): return flag ? "true" : "false" case .array(let array): let rendered = array.compactMap { renderText(from: $0) }.joined(separator: "\n") return rendered.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : rendered case .object(let object): let raw = object.mapValues { $0.toAnyValue() } guard JSONSerialization.isValidJSONObject(raw), let data = try? JSONSerialization.data( withJSONObject: raw, options: [.prettyPrinted, .sortedKeys]), let text = String(data: data, encoding: .utf8) else { return nil } return text case .null: return nil } } private func resolveFileSize(for url: URL) -> UInt64? { if let values = try? url.resourceValues(forKeys: [.fileSizeKey]), let size = values.fileSize { return UInt64(size) } return nil } private func defaultProjectPath(forHash hash: String) -> String { let realHome = SessionPreferencesStore.getRealUserHomeURL().path return "\(realHome)/.gemini/tmp/\(hash)" } // MARK: - Workspace heuristics private func inferWorkingDirectory(from messages: [ConversationRecord.Message]) -> String? { var candidates: [String] = [] candidates.reserveCapacity(32) func append(paths: [String]) { guard !paths.isEmpty else { return } candidates.append(contentsOf: paths) } for message in messages { append(paths: absolutePaths(in: message.content)) if let toolCalls = message.toolCalls { for call in toolCalls { append(paths: absolutePaths(in: call.args)) append(paths: absolutePaths(in: call.result)) if let display = call.resultDisplay { append(paths: absolutePaths(in: display)) } if let description = call.description { append(paths: absolutePaths(in: description)) } } } } let normalized = candidates.compactMap { canonicalAbsolutePath(from: $0) } guard !normalized.isEmpty else { return nil } guard let prefix = commonPathPrefix(for: normalized) else { return nil } return trimmedWorkspacePrefix(for: prefix) } private func absolutePaths(in value: JSONValue?) -> [String] { guard let value else { return [] } switch value { case .string(let text): return absolutePaths(in: text) case .array(let array): return array.flatMap { absolutePaths(in: $0) } case .object(let dict): return dict.values.flatMap { absolutePaths(in: $0) } case .number, .bool, .null: return [] } } private func absolutePaths(in text: String) -> [String] { guard !text.isEmpty else { return [] } let nsText = text as NSString let range = NSRange(location: 0, length: nsText.length) var matches: [String] = [] geminiAbsolutePathRegex.enumerateMatches(in: text, options: [], range: range) { result, _, _ in guard let result, result.range.location != NSNotFound else { return } var candidate = nsText.substring(with: result.range) candidate = candidate.trimmingCharacters(in: geminiPathTrimCharacters) guard !candidate.isEmpty else { return } matches.append(candidate) } return matches } private func canonicalAbsolutePath(from raw: String) -> String? { let expanded = (raw as NSString).expandingTildeInPath guard expanded.hasPrefix("/") else { return nil } var standardized = URL(fileURLWithPath: expanded).standardizedFileURL.path if standardized.count > 1 && standardized.hasSuffix("/") { standardized.removeLast() } return standardized } private func commonPathPrefix(for paths: [String]) -> String? { guard let first = paths.first else { return nil } var prefixComponents = Self.pathComponents(for: first) for path in paths.dropFirst() { let comps = Self.pathComponents(for: path) var next: [String] = [] for (lhs, rhs) in zip(prefixComponents, comps) { if lhs == rhs { next.append(lhs) } else { break } } prefixComponents = next if prefixComponents.isEmpty { return nil } } guard !prefixComponents.isEmpty else { return nil } return "/" + prefixComponents.joined(separator: "/") } private static func pathComponents(for path: String) -> [String] { path.split(separator: "/", omittingEmptySubsequences: true).map(String.init) } private func trimmedWorkspacePrefix(for prefix: String) -> String? { guard prefix.count > 1 else { return nil } var path = prefix if path.hasSuffix("/") { path.removeLast() } guard !path.isEmpty else { return nil } let components = Self.pathComponents(for: path) if let last = components.last, last.contains("."), components.count > 1 { let dropped = components.dropLast() if dropped.isEmpty { return nil } return "/" + dropped.joined(separator: "/") } return "/" + components.joined(separator: "/") } static func isControlCommand(_ rawText: String) -> Bool { let trimmed = rawText.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.hasPrefix("/"), trimmed.count > 1 else { return false } if trimmed.dropFirst().contains("/") { return false } if trimmed.contains("\n") || trimmed.contains("\r") { return false } let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_- ") let scalars = trimmed.unicodeScalars.dropFirst() return scalars.allSatisfy { allowed.contains($0) } } } private extension JSONValue { func toAnyValue() -> Any { switch self { case .string(let str): return str case .number(let number): return number case .bool(let flag): return flag case .array(let array): return array.map { $0.toAnyValue() } case .object(let dict): return dict.mapValues { $0.toAnyValue() } case .null: return NSNull() } } } ================================================ FILE: services/GeminiSessionProvider.swift ================================================ import CryptoKit import Foundation actor GeminiSessionProvider { enum SessionProviderCacheError: Error { case cacheUnavailable } private struct AggregatedSession { let summary: SessionSummary let rows: [SessionRow] let primaryFileURL: URL } private struct GeminiLogEntry: Decodable { let sessionId: String let messageId: Int let type: String let message: String let timestamp: String } private struct GeminiSessionValidationRecord: Decodable { struct Message: Decodable { let type: String? } let messages: [Message]? } private let parser = GeminiSessionParser() private var projectsStore: ProjectsStore private let fileManager: FileManager private let tmpRoot: URL? private let cacheStore: SessionIndexSQLiteStore? private var hashToPath: [String: String] = [:] private var canonicalURLById: [String: URL] = [:] private var rowsCacheBySessionId: [String: [SessionRow]] = [:] private var logCacheByHash: [String: [String: [GeminiLogEntry]]] = [:] private var aggregatedCacheByHash: [String: AggregatedCacheEntry] = [:] private let logDateFormatter: ISO8601DateFormatter private let fallbackLogFormatter: ISO8601DateFormatter private static func hash(for path: String) -> String? { let canonical = (path as NSString).expandingTildeInPath guard let data = canonical.data(using: .utf8) else { return nil } let digest = SHA256.hash(data: data) return digest.map { String(format: "%02x", $0) }.joined() } private struct AggregatedCacheEntry { let signature: HashSignature let sessions: [AggregatedSession] } private struct HashSignature: Equatable { let fileCount: Int let chatsTotalSize: UInt64 let latestChatMtime: Date? let logSize: UInt64 let logMtime: Date? } private struct CachedSummariesResult { let summaries: [SessionSummary] let isComplete: Bool } private func cachedSummaries( forHash hash: String, files: [ChatFileInfo], signature: HashSignature ) async throws -> CachedSummariesResult { guard let cacheStore else { throw SessionProviderCacheError.cacheUnavailable } guard let latest = signature.latestChatMtime else { return CachedSummariesResult(summaries: [], isComplete: true) } var bestById: [String: SessionSummary] = [:] var isComplete = true for file in files { let validity = sessionValidity(for: file.url) if validity == .invalid { continue } guard let cached = try await cacheStore.fetch( path: file.url.path, modificationDate: latest, fileSize: signature.chatsTotalSize ) else { isComplete = false continue } let summary = cached.overridingSource(.geminiLocal) canonicalURLById[summary.id] = file.url if let existing = bestById[summary.id] { bestById[summary.id] = prefer(lhs: existing, rhs: summary) } else { bestById[summary.id] = summary } } return CachedSummariesResult(summaries: Array(bestById.values), isComplete: isComplete) } private enum GeminiSessionValidity { case valid case invalid case unknown } private func sessionValidity(for url: URL) -> GeminiSessionValidity { guard let data = try? Data(contentsOf: url, options: [.mappedIfSafe]) else { return .unknown } guard let record = try? JSONDecoder().decode(GeminiSessionValidationRecord.self, from: data) else { return .unknown } guard let messages = record.messages, !messages.isEmpty else { return .invalid } for message in messages { let kind = message.type?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if kind == "user" || kind == "gemini" { return .valid } } return .invalid } private func persist(summary: SessionSummary, modificationDate: Date?, fileSize: UInt64?) { guard let cacheStore else { return } Task.detached { [cacheStore] in try? await cacheStore.upsert( summary: summary, project: nil, fileModificationTime: modificationDate, fileSize: fileSize, tokenBreakdown: summary.tokenBreakdown, parseError: nil ) } } init( projectsStore: ProjectsStore, fileManager: FileManager = .default, cacheStore: SessionIndexSQLiteStore? = nil ) { self.projectsStore = projectsStore self.fileManager = fileManager self.cacheStore = cacheStore let home = SessionPreferencesStore.getRealUserHomeURL() let root = home.appendingPathComponent(".gemini", isDirectory: true) .appendingPathComponent("tmp", isDirectory: true) var isDir: ObjCBool = false if fileManager.fileExists(atPath: root.path, isDirectory: &isDir), isDir.boolValue { self.tmpRoot = root } else { self.tmpRoot = nil } self.logDateFormatter = ISO8601DateFormatter() self.logDateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] self.fallbackLogFormatter = ISO8601DateFormatter() self.fallbackLogFormatter.formatOptions = [.withInternetDateTime] } func sessions(scope: SessionLoadScope, allowedProjectDirectories: [String]? = nil, ignoredPaths: [String] = []) async throws -> [SessionSummary] { guard cacheStore != nil else { throw SessionProviderCacheError.cacheUnavailable } let preferFullInitialParse = ((try? await cacheStore?.fetchMeta().sessionCount) ?? 0) == 0 guard let tmpRoot else { return [] } guard let hashes = try? fileManager.contentsOfDirectory( at: tmpRoot, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles]) else { return [] } let allowedHashes: Set? = { guard let allowed = allowedProjectDirectories, !allowed.isEmpty else { return nil } var hashes: Set = [] for path in allowed { if let hash = Self.hash(for: path) { hashes.insert(hash) } } return hashes.isEmpty ? nil : hashes }() rowsCacheBySessionId.removeAll() var summaries: [SessionSummary] = [] for hashURL in hashes { guard hashURL.hasDirectoryPath else { continue } // Apply ignore rules if shouldIgnorePath(hashURL.path, ignoredPaths: ignoredPaths) { continue } let hash = hashURL.lastPathComponent guard hash.count == 64, hash.range(of: "^[0-9a-f]+$", options: .regularExpression) != nil else { continue } if let allowedHashes, !allowedHashes.contains(hash) { continue } let resolvedPath = await resolveProjectPath(forHash: hash) if !preferFullInitialParse, let fileInfo = chatFilesAndSignature(forHash: hash, hashURL: hashURL) { let cached = try await cachedSummaries( forHash: hash, files: fileInfo.files, signature: fileInfo.signature ) if cached.isComplete { if !cached.summaries.isEmpty { for summary in cached.summaries where matches(scope: scope, summary: summary) { summaries.append(summary) } } continue } } let aggregated = aggregatedSessions( forHash: hash, hashURL: hashURL, resolvedProjectPath: resolvedPath, cacheResults: true) for session in aggregated where matches(scope: scope, summary: session.summary) { // Check ignore rules against cwd // Note: Cache is preserved - we filter out ignored sessions but don't delete cache entries. // This allows sessions to reappear if ignore rules are removed later. // (aggregatedSessions already cached with cacheResults: true) if shouldIgnoreSummary(session.summary, ignoredPaths: ignoredPaths) { continue } summaries.append(session.summary) rowsCacheBySessionId[session.summary.id] = session.rows canonicalURLById[session.summary.id] = session.primaryFileURL } } return summaries.sorted { let lhs = $0.lastUpdatedAt ?? $0.startedAt let rhs = $1.lastUpdatedAt ?? $1.startedAt return lhs > rhs } } func collectCWDCounts() async -> [String: Int] { guard let tmpRoot else { return [:] } guard let hashes = try? fileManager.contentsOfDirectory( at: tmpRoot, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles]) else { return [:] } var counts: [String: Int] = [:] for hashURL in hashes { guard hashURL.hasDirectoryPath else { continue } let hash = hashURL.lastPathComponent guard hash.count == 64, hash.range(of: "^[0-9a-f]+$", options: .regularExpression) != nil else { continue } let resolved = await resolveProjectPath(forHash: hash) let aggregated = aggregatedSessions( forHash: hash, hashURL: hashURL, resolvedProjectPath: resolved, cacheResults: false) for session in aggregated { counts[session.summary.cwd, default: 0] += 1 } } return counts } func countAllSessions() async -> Int { guard let tmpRoot else { return 0 } guard let hashes = try? fileManager.contentsOfDirectory( at: tmpRoot, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles]) else { return 0 } var total = 0 for hashURL in hashes { guard hashURL.hasDirectoryPath else { continue } let hash = hashURL.lastPathComponent guard hash.count == 64, hash.range(of: "^[0-9a-f]+$", options: .regularExpression) != nil else { continue } let resolved = await resolveProjectPath(forHash: hash) let aggregated = aggregatedSessions( forHash: hash, hashURL: hashURL, resolvedProjectPath: resolved, cacheResults: false) total += aggregated.count } return total } func timeline(for summary: SessionSummary) async -> [ConversationTurn]? { guard let rows = await rowsForSession(summary: summary) else { return nil } let loader = SessionTimelineLoader() return loader.turns(from: rows) } func environmentContext(for summary: SessionSummary) async -> EnvironmentContextInfo? { guard let rows = await rowsForSession(summary: summary) else { return nil } let loader = SessionTimelineLoader() return loader.loadEnvironmentContext(from: rows) } func enrich(summary: SessionSummary) async -> SessionSummary? { guard let rows = await rowsForSession(summary: summary) else { return summary } let loader = SessionTimelineLoader() let turns = loader.turns(from: rows) let activeDuration = computeActiveDuration(turns: turns) return SessionSummary( id: summary.id, fileURL: summary.fileURL, fileSizeBytes: summary.fileSizeBytes, startedAt: summary.startedAt, endedAt: summary.endedAt, activeDuration: activeDuration, cliVersion: summary.cliVersion, cwd: summary.cwd, originator: summary.originator, instructions: summary.instructions, model: summary.model, approvalPolicy: summary.approvalPolicy, userMessageCount: summary.userMessageCount, assistantMessageCount: summary.assistantMessageCount, toolInvocationCount: summary.toolInvocationCount, responseCounts: summary.responseCounts, turnContextCount: summary.turnContextCount, messageTypeCounts: summary.messageTypeCounts, totalTokens: summary.totalTokens, tokenBreakdown: summary.tokenBreakdown, eventCount: summary.eventCount, lineCount: summary.lineCount, lastUpdatedAt: summary.lastUpdatedAt, source: .geminiLocal, remotePath: summary.remotePath, userTitle: summary.userTitle, userComment: summary.userComment, taskId: summary.taskId ) } func sessions(inProjectDirectory directory: String) async -> [SessionSummary] { guard let hash = directoryHash(for: directory) else { return [] } guard let tmpRoot else { return [] } let hashURL = tmpRoot.appendingPathComponent(hash, isDirectory: true) let aggregated = aggregatedSessions( forHash: hash, hashURL: hashURL, resolvedProjectPath: directory, cacheResults: true) for session in aggregated { rowsCacheBySessionId[session.summary.id] = session.rows canonicalURLById[session.summary.id] = session.primaryFileURL } return aggregated.map { $0.summary } } // MARK: - Helpers private func matches(scope: SessionLoadScope, summary: SessionSummary) -> Bool { let calendar = Calendar.current let referenceDates = [summary.startedAt, summary.lastUpdatedAt ?? summary.startedAt] switch scope { case .all: return true case .today: return referenceDates.contains { calendar.isDateInToday($0) } case .day(let day): return referenceDates.contains { calendar.isDate($0, inSameDayAs: day) } case .month(let date): return referenceDates.contains { calendar.isDate($0, equalTo: date, toGranularity: .month) } } } private func canonicalURL(for summary: SessionSummary) -> URL? { canonicalURLById[summary.id] ?? summary.fileURL } private func projectHash(for url: URL) -> String? { let components = url.pathComponents guard let chatsIndex = components.lastIndex(of: "chats"), chatsIndex > 0 else { return nil } return components[chatsIndex - 1] } private func resolveProjectPath(forHash hash: String) async -> String? { if let cached = hashToPath[hash] { return cached } let projects = await projectsStore.listProjects() let directories = projects.compactMap { $0.directory } for directory in directories { guard let digest = directoryHash(for: directory), digest == hash else { continue } hashToPath[hash] = normalized(directory) return hashToPath[hash] } return nil } private func directoryHash(for directory: String) -> String? { let expanded = (directory as NSString).expandingTildeInPath guard let data = expanded.data(using: .utf8) else { return nil } let digest = SHA256.hash(data: data) return digest.map { String(format: "%02x", $0) }.joined() } private func normalized(_ directory: String) -> String { let expanded = (directory as NSString).expandingTildeInPath return URL(fileURLWithPath: expanded).standardizedFileURL.path } func invalidateProjectMappings() { hashToPath.removeAll() } func updateProjectsStore(_ store: ProjectsStore) { projectsStore = store hashToPath.removeAll() } private func computeActiveDuration(turns: [ConversationTurn]) -> TimeInterval? { guard !turns.isEmpty else { return nil } let filtered = turns.removingEnvironmentContext() guard !filtered.isEmpty else { return nil } var total: TimeInterval = 0 for turn in filtered { let start = turn.userMessage?.timestamp ?? turn.outputs.first?.timestamp guard let s = start, let end = turn.outputs.last?.timestamp else { continue } let delta = end.timeIntervalSince(s) if delta > 0 { total += delta } } return total } private func rowsForSession(summary: SessionSummary) async -> [SessionRow]? { if let rows = rowsCacheBySessionId[summary.id] { return rows } guard let url = canonicalURL(for: summary), let hash = projectHash(for: url), let tmpRoot else { return nil } let hashURL = tmpRoot.appendingPathComponent(hash, isDirectory: true) let resolved = await resolveProjectPath(forHash: hash) let aggregated = aggregatedSessions( forHash: hash, hashURL: hashURL, resolvedProjectPath: resolved, cacheResults: true) for session in aggregated { rowsCacheBySessionId[session.summary.id] = session.rows canonicalURLById[session.summary.id] = session.primaryFileURL } return rowsCacheBySessionId[summary.id] } private func aggregatedSessions( forHash hash: String, hashURL: URL, resolvedProjectPath: String?, cacheResults: Bool ) -> [AggregatedSession] { guard let fileInfo = chatFilesAndSignature(forHash: hash, hashURL: hashURL) else { return [] } if let cached = aggregatedCacheByHash[hash], cached.signature == fileInfo.signature { if cacheResults { for session in cached.sessions { rowsCacheBySessionId[session.summary.id] = session.rows canonicalURLById[session.summary.id] = session.primaryFileURL } } return cached.sessions } var segmentsBySession: [String: [GeminiParsedLog]] = [:] for info in fileInfo.files where info.url.pathExtension.lowercased() == "json" { // Note: ignore rules are applied at hash directory level, not individual files guard let parsed = parser.parse( at: info.url, projectHash: hash, resolvedProjectPath: resolvedProjectPath) else { continue } segmentsBySession[parsed.summary.id, default: []].append(parsed) } guard !segmentsBySession.isEmpty else { return [] } if cacheResults { logCacheByHash.removeValue(forKey: hash) } let logEntries = logEntriesBySession(forHash: hash) var results: [AggregatedSession] = [] for (sessionId, segments) in segmentsBySession { guard let aggregated = aggregate( segments: segments, extraLogEntries: logEntries[sessionId]) else { continue } results.append(aggregated) if cacheResults { rowsCacheBySessionId[aggregated.summary.id] = aggregated.rows canonicalURLById[aggregated.summary.id] = aggregated.primaryFileURL persist(summary: aggregated.summary, modificationDate: fileInfo.signature.latestChatMtime, fileSize: fileInfo.signature.chatsTotalSize) } } if cacheResults { aggregatedCacheByHash[hash] = AggregatedCacheEntry(signature: fileInfo.signature, sessions: results) } return results } private func aggregate( segments: [GeminiParsedLog], extraLogEntries: [GeminiLogEntry]? ) -> AggregatedSession? { guard !segments.isEmpty else { return nil } var rows: [SessionRow] = [] let orderedSegments = segments.sorted { lhs, rhs in lhs.summary.startedAt < rhs.summary.startedAt } for segment in orderedSegments { rows.append(contentsOf: segment.rows) } if let extras = extraLogEntries { rows.append(contentsOf: rowsFromLogs(extras)) } let normalized = normalize(rows: rows) guard !normalized.isEmpty else { return nil } let timelineLoader = SessionTimelineLoader() let turns = timelineLoader.turns(from: normalized) let conversationCount = turns.count let assistantMessages = turns.reduce(into: 0) { partialResult, turn in partialResult += turn.outputs.filter { $0.actor == .assistant }.count } var builder = SessionSummaryBuilder() builder.setSource(.geminiLocal) let totalSize = segments.compactMap { $0.summary.fileSizeBytes }.reduce(0, +) if totalSize > 0 { builder.setFileSize(totalSize) } for row in normalized { builder.observe(row) } if let lastTimestamp = normalized.last?.timestamp { builder.seedLastUpdated(lastTimestamp) } guard let representative = segments.max(by: { ($0.summary.lastUpdatedAt ?? $0.summary.startedAt) < ($1.summary.lastUpdatedAt ?? $1.summary.startedAt) }) else { return nil } guard var summary = builder.build(for: representative.summary.fileURL) else { return nil } summary = summary .overridingSource(.geminiLocal) .overridingCounts(userMessages: conversationCount, assistantMessages: assistantMessages) // Aggregate token usage across all Gemini segments using the raw chat JSON. var totalInput = 0 var totalOutput = 0 var totalCached = 0 var totalThoughts = 0 var totalTool = 0 for segment in segments { guard let tokens = segment.tokens else { continue } if tokens.input > 0 { totalInput &+= tokens.input } if tokens.output > 0 { totalOutput &+= tokens.output } if tokens.cached > 0 { totalCached &+= tokens.cached } if tokens.thoughts > 0 { totalThoughts &+= tokens.thoughts } if tokens.tool > 0 { totalTool &+= tokens.tool } } if totalInput != 0 || totalOutput != 0 || totalCached != 0 || totalThoughts != 0 || totalTool != 0 { // Treat Gemini output as the sum of output, thoughts, and tool tokens. let aggregatedOutput = totalOutput &+ totalThoughts &+ totalTool let aggregatedInput = totalInput let aggregatedCacheRead = totalCached let aggregatedCacheCreation = 0 let breakdown = SessionTokenBreakdown( input: max(aggregatedInput, 0), output: max(aggregatedOutput, 0), cacheRead: max(aggregatedCacheRead, 0), cacheCreation: max(aggregatedCacheCreation, 0) ) // Session-wide total tokens = sum of per-message totals (input + output + thoughts + tool). let totalTokens = breakdown.total summary = summary.overridingTokens( totalTokens: totalTokens, tokenBreakdown: breakdown ) } return AggregatedSession(summary: summary, rows: normalized, primaryFileURL: representative.summary.fileURL) } private struct ChatFileInfo { let url: URL let modificationDate: Date? let size: UInt64 } private func chatFilesAndSignature( forHash hash: String, hashURL: URL ) -> (files: [ChatFileInfo], signature: HashSignature)? { let chatsDir = hashURL.appendingPathComponent("chats", isDirectory: true) var isDir: ObjCBool = false guard fileManager.fileExists(atPath: chatsDir.path, isDirectory: &isDir), isDir.boolValue else { return nil } guard let files = try? fileManager.contentsOfDirectory( at: chatsDir, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey], options: [.skipsHiddenFiles]) else { return nil } var infos: [ChatFileInfo] = [] var totalSize: UInt64 = 0 var latestMtime: Date? var fileCount = 0 for file in files where file.pathExtension.lowercased() == "json" { guard let values = try? file.resourceValues( forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey]), values.isRegularFile == true else { continue } fileCount += 1 let size = UInt64(values.fileSize ?? 0) totalSize += size if let m = values.contentModificationDate { if latestMtime == nil || m > latestMtime! { latestMtime = m } } infos.append(ChatFileInfo(url: file, modificationDate: values.contentModificationDate, size: size)) } let logURL = hashURL.appendingPathComponent("logs.json", isDirectory: false) let logValues = try? logURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]) let logSize = UInt64(logValues?.fileSize ?? 0) let logMtime = logValues?.contentModificationDate if let logMtime, latestMtime == nil || logMtime > latestMtime! { latestMtime = logMtime } let signature = HashSignature( fileCount: fileCount, chatsTotalSize: totalSize, latestChatMtime: latestMtime, logSize: logSize, logMtime: logMtime) return (infos, signature) } private func rowsFromLogs(_ logEntries: [GeminiLogEntry]) -> [SessionRow] { logEntries.compactMap { entry in guard entry.type.lowercased() == "user" else { return nil } guard let timestamp = parseLogDate(entry.timestamp) else { return nil } let text = entry.message.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty, !GeminiSessionParser.isControlCommand(text) else { return nil } let payload = EventMessagePayload( type: "user_message", message: text, kind: nil, text: text, reason: nil, info: nil, rateLimits: nil ) return SessionRow(timestamp: timestamp, kind: .eventMessage(payload)) } } private func normalize(rows: [SessionRow]) -> [SessionRow] { guard !rows.isEmpty else { return [] } let ordered = rows.enumerated().sorted { lhs, rhs in if lhs.element.timestamp == rhs.element.timestamp { return lhs.offset < rhs.offset } return lhs.element.timestamp < rhs.element.timestamp } var deduped: [SessionRow] = [] var repeatCountByIndex: [Int: Int] = [:] var userEntryByText: [String: (index: Int, timestamp: Date)] = [:] let duplicateWindow: TimeInterval = 5.0 for (_, row) in ordered { var shouldAppend = true if case let .eventMessage(payload) = row.kind, payload.type.lowercased() == "user_message" { let normalizedText = (payload.message ?? payload.text ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) if let existing = userEntryByText[normalizedText], abs(row.timestamp.timeIntervalSince(existing.timestamp)) < duplicateWindow { repeatCountByIndex[existing.index, default: 1] += 1 userEntryByText[normalizedText] = (existing.index, row.timestamp) shouldAppend = false } else { let index = deduped.count userEntryByText[normalizedText] = (index, row.timestamp) repeatCountByIndex[index] = 1 } } if shouldAppend { deduped.append(row) } } if !repeatCountByIndex.isEmpty { for (index, count) in repeatCountByIndex where count > 1 { guard index < deduped.count else { continue } deduped[index] = injectRepeatCount(into: deduped[index], count: count) } } return deduped } private func injectRepeatCount(into row: SessionRow, count: Int) -> SessionRow { guard count > 1 else { return row } guard case let .eventMessage(payload) = row.kind else { return row } var metadata: [String: JSONValue] = [:] if case let .object(existing) = payload.info { metadata = existing } metadata["repeat_count"] = .number(Double(count)) let updatedPayload = EventMessagePayload( type: payload.type, message: payload.message, kind: payload.kind, text: payload.text, reason: payload.reason, info: metadata.isEmpty ? nil : .object(metadata), rateLimits: payload.rateLimits ) return SessionRow(timestamp: row.timestamp, kind: .eventMessage(updatedPayload)) } private func prefer(lhs: SessionSummary, rhs: SessionSummary) -> SessionSummary { if lhs.id != rhs.id { return lhs } let lt = lhs.lastUpdatedAt ?? lhs.startedAt let rt = rhs.lastUpdatedAt ?? rhs.startedAt if lt != rt { return lt > rt ? lhs : rhs } let ls = lhs.fileSizeBytes ?? 0 let rs = rhs.fileSizeBytes ?? 0 if ls != rs { return ls > rs ? lhs : rhs } return lhs.fileURL.lastPathComponent < rhs.fileURL.lastPathComponent ? lhs : rhs } private func logEntriesBySession(forHash hash: String) -> [String: [GeminiLogEntry]] { if let cached = logCacheByHash[hash] { return cached } guard let tmpRoot else { logCacheByHash[hash] = [:] return [:] } let logURL = tmpRoot .appendingPathComponent(hash, isDirectory: true) .appendingPathComponent("logs.json", isDirectory: false) guard let data = try? Data(contentsOf: logURL) else { logCacheByHash[hash] = [:] return [:] } guard let entries = try? JSONDecoder().decode([GeminiLogEntry].self, from: data) else { logCacheByHash[hash] = [:] return [:] } var grouped: [String: [GeminiLogEntry]] = [:] for entry in entries { grouped[entry.sessionId, default: []].append(entry) } for key in grouped.keys { grouped[key]?.sort(by: { $0.messageId < $1.messageId }) } logCacheByHash[hash] = grouped return grouped } private func parseLogDate(_ value: String) -> Date? { if let date = logDateFormatter.date(from: value) { return date } return fallbackLogFormatter.date(from: value) } } // MARK: - SessionProvider extension GeminiSessionProvider: SessionProvider { nonisolated var kind: SessionSource.Kind { .gemini } nonisolated var identifier: String { "gemini-local" } nonisolated var label: String { "Gemini (local)" } func load(context: SessionProviderContext) async throws -> SessionProviderResult { switch context.cachePolicy { case .cacheOnly: if let cacheStore { let dateColumn = context.dateDimension == .updated ? "COALESCE(last_updated_at, started_at)" : "started_at" let range = context.dateRange ?? Self.dateRange(for: context.scope) var cached = try await cacheStore.fetchSummaries( kinds: [.gemini], includeRemote: false, dateColumn: dateColumn, dateRange: range, projectIds: context.projectIds ) // Apply ignore rules to cached results let originalCount = cached.count if !context.ignoredPaths.isEmpty { cached = cached.filter { !shouldIgnoreSummary($0, ignoredPaths: context.ignoredPaths) } print("GeminiSessionProvider: filtered \(originalCount - cached.count) sessions by ignore rules (\(cached.count) remain)") } if !cached.isEmpty { let filtered = cached.filter { sessionValidity(for: $0.fileURL) != .invalid } return SessionProviderResult(summaries: filtered, coverage: nil, cacheHit: true) } } return SessionProviderResult(summaries: [], coverage: nil, cacheHit: true) case .refresh: guard cacheStore != nil else { throw SessionProviderCacheError.cacheUnavailable } let summaries = try await sessions( scope: context.scope, allowedProjectDirectories: context.projectDirectories, ignoredPaths: context.ignoredPaths ) return SessionProviderResult(summaries: summaries, coverage: nil, cacheHit: false) } } private static func dateRange(for scope: SessionLoadScope) -> (Date, Date)? { let cal = Calendar.current switch scope { case .all: return nil case .today: let start = cal.startOfDay(for: Date()) guard let end = cal.date(byAdding: .day, value: 1, to: start)?.addingTimeInterval(-1) else { return nil } return (start, end) case .day(let day): let start = cal.startOfDay(for: day) guard let end = cal.date(byAdding: .day, value: 1, to: start)?.addingTimeInterval(-1) else { return nil } return (start, end) case .month(let date): guard let start = cal.date(from: cal.dateComponents([.year, .month], from: date)), let end = cal.date(byAdding: DateComponents(month: 1, second: -1), to: start) else { return nil } return (start, end) } } // MARK: - Ignore Rules private func shouldIgnorePath(_ absolutePath: String, ignoredPaths: [String]) -> Bool { SessionPathFilter.shouldIgnorePath(absolutePath, ignoredPaths: ignoredPaths) } private func shouldIgnoreSummary(_ summary: SessionSummary, ignoredPaths: [String]) -> Bool { SessionPathFilter.shouldIgnoreSummary(summary, ignoredPaths: ignoredPaths) } } ================================================ FILE: services/GeminiSettingsService.swift ================================================ import Foundation actor GeminiSettingsService { struct Paths { let directory: URL let file: URL static func `default`() -> Paths { let home = SessionPreferencesStore.getRealUserHomeURL() let dir = home.appendingPathComponent(".gemini", isDirectory: true) return Paths(directory: dir, file: dir.appendingPathComponent("settings.json", isDirectory: false)) } } struct Snapshot: Sendable { var previewFeatures: Bool? var vimMode: Bool? var disableAutoUpdate: Bool? var enablePromptCompletion: Bool? var sessionRetentionEnabled: Bool? var modelName: String? var maxSessionTurns: Int? var compressionThreshold: Double? var skipNextSpeakerCheck: Bool? } struct NotificationHooksStatus: Sendable { var hookInstalled: Bool var hooksEnabled: Bool } private typealias JSONObject = [String: Any] private let codMateHookURLPrefix = "codmate://notify?source=gemini&event=" private let codMateManagedHookNamePrefix = "codmate-hook:" private enum HookEvent: String { case permission } private struct HookPayload { var title: String var body: String } private let paths: Paths private let fm: FileManager init(paths: Paths = .default(), fileManager: FileManager = .default) { self.paths = paths self.fm = fileManager } nonisolated var settingsFileURL: URL { paths.file } // MARK: - Public API func loadSnapshot() -> Snapshot { let object = loadJSONObject() return Snapshot( previewFeatures: boolValue(in: object, path: ["general", "previewFeatures"]), vimMode: boolValue(in: object, path: ["general", "vimMode"]), disableAutoUpdate: boolValue(in: object, path: ["general", "disableAutoUpdate"]), enablePromptCompletion: boolValue(in: object, path: ["general", "enablePromptCompletion"]), sessionRetentionEnabled: boolValue(in: object, path: ["general", "sessionRetention", "enabled"]), modelName: stringValue(in: object, path: ["model", "name"]), maxSessionTurns: intValue(in: object, path: ["model", "maxSessionTurns"]), compressionThreshold: doubleValue(in: object, path: ["model", "compressionThreshold"]), skipNextSpeakerCheck: boolValue(in: object, path: ["model", "skipNextSpeakerCheck"]) ) } func loadRawText() -> String { (try? String(contentsOf: paths.file, encoding: .utf8)) ?? "" } func setBool(_ value: Bool, at path: [String]) throws { try setValue(value, at: path) } func setOptionalBool(_ value: Bool?, at path: [String]) throws { try setValue(value, at: path) } func setInt(_ value: Int, at path: [String]) throws { try setValue(value, at: path) } func setDouble(_ value: Double, at path: [String]) throws { try setValue(value, at: path) } func setOptionalString(_ value: String?, at path: [String]) throws { try setValue(value, at: path) } // MARK: - Notification hooks func codMateNotificationHooksStatus() -> NotificationHooksStatus { let object = loadJSONObject() let hooksEnabled = boolValue(in: object, path: ["tools", "enableHooks"]) ?? false guard let hooks = object["hooks"] as? JSONObject else { return NotificationHooksStatus(hookInstalled: false, hooksEnabled: hooksEnabled) } let installed = containsCodMateHook(in: hooks) return NotificationHooksStatus(hookInstalled: installed, hooksEnabled: hooksEnabled) } func setCodMateNotificationHooks(enabled: Bool) throws { var object = loadJSONObject() var hooks = object["hooks"] as? JSONObject ?? [:] hooks = updateNotificationHooksContainer(hooks, enabled: enabled) if hooks.isEmpty { object.removeValue(forKey: "hooks") } else { object["hooks"] = hooks } if enabled { update(&object, path: ["tools", "enableMessageBusIntegration"], value: true) update(&object, path: ["tools", "enableHooks"], value: true) } try writeJSONObject(object) } // MARK: - User hooks (CodMate Extensions) func applyHooksFromCodMate(_ rules: [HookRule]) throws -> [HookSyncWarning] { var warnings: [HookSyncWarning] = [] var object = loadJSONObject() var hooks = object["hooks"] as? JSONObject ?? [:] hooks = pruneCodMateManagedHooks(hooks) let filtered = rules.filter { $0.isEnabled(for: .gemini) } for rule in filtered { let rawEvent = rule.event.trimmingCharacters(in: .whitespacesAndNewlines) guard !rawEvent.isEmpty else { continue } let resolution = HookEventCatalog.resolveProviderEvent(rawEvent, for: .gemini) if resolution.isKnown, !resolution.isSupported { warnings.append(HookSyncWarning( provider: .gemini, message: "Gemini CLI does not support hook event \"\(rawEvent)\"; skipping \"\(rule.name)\"." )) continue } let event = resolution.name let supportsMatcher = HookEventCatalog.supportsMatcher(resolution.canonicalName, provider: .gemini) let matcherText = rule.matcher?.trimmingCharacters(in: .whitespacesAndNewlines) let matcher: String = { if supportsMatcher { return (matcherText?.isEmpty == false ? matcherText! : "*") } if matcherText?.isEmpty == false { warnings.append(HookSyncWarning( provider: .gemini, message: "Gemini hook event \"\(event)\" does not support matcher; ignoring matcher for \"\(rule.name)\"." )) } return "*" }() var hookObjects: [JSONObject] = [] for (index, cmd) in rule.commands.enumerated() { let program = cmd.command.trimmingCharacters(in: .whitespacesAndNewlines) guard !program.isEmpty else { continue } var hook: JSONObject = [ "name": "\(codMateManagedHookNamePrefix)\(rule.id):\(index)", "type": "command", "command": program, ] if let args = cmd.args, !args.isEmpty { hook["args"] = args } if let timeout = cmd.timeoutMs { hook["timeout"] = timeout } if let env = cmd.env, !env.isEmpty { warnings.append(HookSyncWarning( provider: .gemini, message: "Gemini CLI hook commands do not support env in settings.json; ignoring env for \"\(rule.name)\"." )) } hookObjects.append(hook) } guard !hookObjects.isEmpty else { continue } var entries = (hooks[event] as? [JSONObject]) ?? [] if let idx = entries.firstIndex(where: { entry in let existing = (entry["matcher"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) return (existing?.isEmpty == false ? existing : "*") == matcher }) { var entry = entries[idx] var nested = (entry["hooks"] as? [JSONObject]) ?? [] nested.append(contentsOf: hookObjects) entry["hooks"] = nested entry["matcher"] = matcher entries[idx] = entry } else { entries.append([ "matcher": matcher, "hooks": hookObjects ]) } hooks[event] = entries } if hooks.isEmpty { object.removeValue(forKey: "hooks") } else { object["hooks"] = hooks } if !filtered.isEmpty { update(&object, path: ["tools", "enableMessageBusIntegration"], value: true) update(&object, path: ["tools", "enableHooks"], value: true) } try writeJSONObject(object) return warnings } func importHooksAsCodMateRules() -> [HookRule] { let object = loadJSONObject() guard let hooks = object["hooks"] as? JSONObject else { return [] } var rules: [HookRule] = [] for (event, value) in hooks { guard let entries = value as? [JSONObject] else { continue } let canonicalEvent = HookEventCatalog.canonicalName(for: event, provider: .gemini) for entry in entries { let matcher = (entry["matcher"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) guard let hookList = entry["hooks"] as? [JSONObject] else { continue } var commands: [HookCommand] = [] for hook in hookList { guard (hook["type"] as? String) == "command" else { continue } guard let command = hook["command"] as? String else { continue } if command.contains(codMateHookURLPrefix) { continue } // managed by Notifications UI if (hook["name"] as? String) == "codmate-notify" { continue } let args = hook["args"] as? [String] let timeout = (hook["timeout"] as? Int) ?? (hook["timeout"] as? NSNumber)?.intValue commands.append(HookCommand(command: command, args: args, env: nil, timeoutMs: timeout)) } guard !commands.isEmpty else { continue } let name = HookEventCatalog.defaultName(event: canonicalEvent, matcher: matcher, command: commands.first) let targets = HookTargets(codex: false, claude: false, gemini: true) rules.append(HookRule( name: name, event: canonicalEvent, matcher: (matcher?.isEmpty == false ? matcher : nil), commands: commands, enabled: true, targets: targets, source: "import" )) } } return rules } // MARK: - MCP Servers func applyMCPServers(_ servers: [MCPServer]) throws { if !SessionPreferencesStore.isCLIEnabled(.gemini) { return } var object = loadJSONObject() let enabled = servers.enabledServers(for: .gemini) if enabled.isEmpty { object.removeValue(forKey: "mcpServers") } else { var mcpServers: JSONObject = [:] for server in enabled { var config: JSONObject = [:] if let command = server.command { config["command"] = command } if let args = server.args, !args.isEmpty { config["args"] = args } if let env = server.env, !env.isEmpty { config["env"] = env } if let url = server.url { config["url"] = url } if let headers = server.headers, !headers.isEmpty { config["headers"] = headers } mcpServers[server.name] = config } object["mcpServers"] = mcpServers } try writeJSONObject(object) } // MARK: - Internal helpers private func loadJSONObject() -> JSONObject { guard fm.fileExists(atPath: paths.file.path) else { return [:] } guard let text = try? String(contentsOf: paths.file, encoding: .utf8) else { return [:] } if let object = parseJSONObject(from: text) { return object } return [:] } private func parseJSONObject(from text: String) -> JSONObject? { if let data = text.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data, options: []), let dict = json as? JSONObject { return dict } let stripped = stripComments(from: text) guard let data = stripped.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data, options: []), let dict = json as? JSONObject else { return nil } return dict } private func writeJSONObject(_ object: JSONObject) throws { try fm.createDirectory(at: paths.directory, withIntermediateDirectories: true) let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) try data.write(to: paths.file, options: .atomic) } private func setValue(_ value: Any?, at path: [String]) throws { var object = loadJSONObject() update(&object, path: path, value: value) try writeJSONObject(object) } private func update(_ object: inout JSONObject, path: [String], value: Any?) { guard let first = path.first else { return } if path.count == 1 { if let value { object[first] = value } else { object.removeValue(forKey: first) } return } var child = object[first] as? JSONObject ?? JSONObject() update(&child, path: Array(path.dropFirst()), value: value) if child.isEmpty { object.removeValue(forKey: first) } else { object[first] = child } } private func value(in object: JSONObject, path: [String]) -> Any? { var current: Any? = object for component in path { guard let dict = current as? JSONObject else { return nil } current = dict[component] } return current } private func boolValue(in object: JSONObject, path: [String]) -> Bool? { if let v = value(in: object, path: path) as? Bool { return v } if let str = value(in: object, path: path) as? String { return (str as NSString).boolValue } return nil } private func stringValue(in object: JSONObject, path: [String]) -> String? { value(in: object, path: path) as? String } private func intValue(in object: JSONObject, path: [String]) -> Int? { if let v = value(in: object, path: path) as? Int { return v } if let number = value(in: object, path: path) as? NSNumber { return number.intValue } if let str = value(in: object, path: path) as? String { return Int(str) } return nil } private func doubleValue(in object: JSONObject, path: [String]) -> Double? { if let v = value(in: object, path: path) as? Double { return v } if let number = value(in: object, path: path) as? NSNumber { return number.doubleValue } if let str = value(in: object, path: path) as? String { return Double(str) } return nil } private func containsCodMateHook(in hooks: JSONObject) -> Bool { guard let entries = hooks["Notification"] as? [JSONObject] else { return false } let marker = "\(codMateHookURLPrefix)\(HookEvent.permission.rawValue)" for entry in entries { guard let nested = entry["hooks"] as? [JSONObject] else { continue } if nested.contains(where: { ($0["command"] as? String)?.contains(marker) == true }) { return true } } return false } private func pruneCodMateManagedHooks(_ hooks: JSONObject) -> JSONObject { var out: JSONObject = [:] for (event, value) in hooks { guard let entries = value as? [JSONObject] else { out[event] = value continue } var newEntries: [JSONObject] = [] for var entry in entries { guard var nested = entry["hooks"] as? [JSONObject] else { newEntries.append(entry) continue } nested.removeAll { hook in guard let name = hook["name"] as? String else { return false } return name.hasPrefix(codMateManagedHookNamePrefix) } guard !nested.isEmpty else { continue } entry["hooks"] = nested newEntries.append(entry) } if !newEntries.isEmpty { out[event] = newEntries } } return out } private func updateNotificationHooksContainer(_ hooks: JSONObject, enabled: Bool) -> JSONObject { var container = hooks var entries = (container["Notification"] as? [JSONObject]) ?? [] let marker = "\(codMateHookURLPrefix)\(HookEvent.permission.rawValue)" entries.removeAll { entry in guard let nested = entry["hooks"] as? [JSONObject] else { return false } return nested.contains { ($0["command"] as? String)?.contains(marker) == true } } if enabled, let urlString = hookURL(for: .permission) { let command = "/usr/bin/open -j \"\(urlString)\"" entries.append([ "matcher": "*", "hooks": [[ "name": "codmate-notify", "type": "command", "command": command ]] ]) } if entries.isEmpty { container.removeValue(forKey: "Notification") } else { container["Notification"] = entries } return container } private func hookURL(for event: HookEvent) -> String? { let payload = hookPayload(for: event) var comps = URLComponents() comps.scheme = "codmate" comps.host = "notify" var query: [URLQueryItem] = [ URLQueryItem(name: "source", value: "gemini"), URLQueryItem(name: "event", value: event.rawValue) ] if let titleData = payload.title.data(using: .utf8) { query.append(URLQueryItem(name: "title64", value: titleData.base64EncodedString())) } if let bodyData = payload.body.data(using: .utf8) { query.append(URLQueryItem(name: "body64", value: bodyData.base64EncodedString())) } comps.queryItems = query return comps.url?.absoluteString } private func hookPayload(for event: HookEvent) -> HookPayload { switch event { case .permission: return HookPayload( title: "Gemini CLI", body: "Gemini requires approval. Return to the Gemini window to respond." ) } } private func stripComments(from text: String) -> String { let scalars = Array(text.unicodeScalars) var result: [UnicodeScalar] = [] var index = 0 var inString = false var escapeNext = false let quote: UnicodeScalar = "\"" let slash: UnicodeScalar = "/" let newlineScalar = "\n".unicodeScalars.first! while index < scalars.count { let scalar = scalars[index] if inString { result.append(scalar) if escapeNext { escapeNext = false } else if scalar == "\\" { escapeNext = true } else if scalar == quote { inString = false } index += 1 continue } if scalar == quote { inString = true result.append(scalar) index += 1 continue } if scalar == slash && index + 1 < scalars.count { let next = scalars[index + 1] if next == slash { index += 2 while index < scalars.count, scalars[index] != newlineScalar { index += 1 } if index < scalars.count { result.append(scalars[index]) index += 1 } continue } else if next == "*" { index += 2 while index + 1 < scalars.count { if scalars[index] == "*" && scalars[index + 1] == slash { index += 2 break } index += 1 } continue } } result.append(scalar) index += 1 } return String(String.UnicodeScalarView(result)) } } ================================================ FILE: services/GeminiUsageAPIClient.swift ================================================ import Foundation import Security struct GeminiUsageAPIClient { enum ClientError: Error, LocalizedError { case credentialNotFound case keychainAccess(OSStatus) case malformedCredential case missingAccessToken case credentialExpired(Date) case projectNotFound case unsupportedAuthType(String) case requestFailed(Int) case emptyResponse case decodingFailed var errorDescription: String? { switch self { case .credentialNotFound: return "Gemini credential not found." case .keychainAccess(let status): return SecCopyErrorMessageString(status, nil) as String? case .malformedCredential: return "Gemini credential is invalid." case .missingAccessToken: return "Gemini credential is missing an access token." case .credentialExpired(let date): let formatter = DateFormatter() formatter.dateStyle = .medium formatter.timeStyle = .short return "Gemini credential expired on \(formatter.string(from: date))." case .projectNotFound: return "Gemini project ID not found. For personal Google accounts, try running gemini CLI to complete onboarding. For workspace accounts, set GOOGLE_CLOUD_PROJECT." case .unsupportedAuthType(let authType): return "Gemini \(authType) auth not supported. Use Google account (OAuth) instead." case .requestFailed(let code): return "Gemini usage API returned status \(code)." case .emptyResponse: return "Gemini usage API returned no data." case .decodingFailed: return "Failed to decode Gemini usage response." } } } private struct CredentialEnvelope: Decodable { struct Token: Decodable { let accessToken: String let refreshToken: String? let expiresAt: TimeInterval? let tokenType: String? let idToken: String? enum CodingKeys: String, CodingKey { case accessToken = "access_token" case refreshToken = "refresh_token" case expiresAt = "expiresAt" // Try camelCase first case tokenType = "token_type" case idToken = "id_token" } // Memberwise initializer (needed because we have custom init(from:)) init(accessToken: String, refreshToken: String?, expiresAt: TimeInterval?, tokenType: String?, idToken: String?) { self.accessToken = accessToken self.refreshToken = refreshToken self.expiresAt = expiresAt self.tokenType = tokenType self.idToken = idToken } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) accessToken = try container.decode(String.self, forKey: .accessToken) refreshToken = try? container.decode(String.self, forKey: .refreshToken) tokenType = try? container.decode(String.self, forKey: .tokenType) idToken = try? container.decode(String.self, forKey: .idToken) // Handle both expiresAt (camelCase) and expiry_date (snake_case) if let expiresAt = try? container.decode(TimeInterval.self, forKey: .expiresAt) { self.expiresAt = expiresAt } else if let expiryDate = try? decoder.container(keyedBy: LegacyCodingKeys.self).decode(TimeInterval.self, forKey: .expiryDate) { self.expiresAt = expiryDate } else { self.expiresAt = nil } } private enum LegacyCodingKeys: String, CodingKey { case expiryDate = "expiry_date" } } let serverName: String? let token: Token let updatedAt: TimeInterval? } private struct LoadCodeAssistResponse: Decodable { struct Tier: Decodable { let id: String? let name: String? let isDefault: Bool? } struct Project: Decodable { let id: String? let name: String? } let currentTier: Tier? let allowedTiers: [Tier]? let cloudaicompanionProject: String? let cloudaicompanionProjectObject: Project? init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) currentTier = try? container.decodeIfPresent(Tier.self, forKey: .currentTier) allowedTiers = try? container.decodeIfPresent([Tier].self, forKey: .allowedTiers) // Handle cloudaicompanionProject as string or object if let rawString = try? container.decodeIfPresent(String.self, forKey: .cloudaicompanionProject) { self.cloudaicompanionProject = rawString self.cloudaicompanionProjectObject = nil } else if let obj = try? container.decodeIfPresent(Project.self, forKey: .cloudaicompanionProject) { self.cloudaicompanionProject = obj.id ?? obj.name self.cloudaicompanionProjectObject = obj } else { self.cloudaicompanionProject = nil self.cloudaicompanionProjectObject = nil } } private enum CodingKeys: String, CodingKey { case currentTier case allowedTiers case cloudaicompanionProject } } private struct OnboardUserRequest: Encodable { let tierId: String let cloudaicompanionProject: String? let metadata: [String: String] } private struct OnboardUserResponse: Decodable { struct Project: Decodable { let id: String? let name: String? } struct ResponseData: Decodable { let cloudaicompanionProject: Project? } let done: Bool? let response: ResponseData? } private struct QuotaResponse: Decodable { struct Bucket: Decodable { let remainingAmount: String? let remainingFraction: Double? let resetTime: String? let tokenType: String? let modelId: String? } let buckets: [Bucket]? } private struct OAuthFile: Decodable { let access_token: String? let refresh_token: String? let expiry_date: TimeInterval? let id_token: String? } func fetchUsageStatus(now: Date = Date()) async throws -> GeminiUsageStatus { let home = SessionPreferencesStore.getRealUserHomeURL() let authType = currentAuthType(homeDirectory: home) switch authType { case .apiKey: throw ClientError.unsupportedAuthType("API key") case .vertexAI: throw ClientError.unsupportedAuthType("Vertex AI") case .oauthPersonal, .unknown: break } var credential = try fetchCredential() // Check token expiration and auto-refresh if needed if let expires = credential.token.expiresAt { let expiry = Date(timeIntervalSince1970: expires / 1000) if expiry.addingTimeInterval(-300) < now { NSLog("[GeminiUsage] Token expired at \(expiry), attempting refresh") // Try to refresh the token guard let refreshToken = credential.token.refreshToken else { NSLog("[GeminiUsage] No refresh token available") throw ClientError.credentialExpired(expiry) } do { let newToken = try await refreshAccessToken(refreshToken: refreshToken) credential = CredentialEnvelope( serverName: credential.serverName, token: newToken, updatedAt: credential.updatedAt ) NSLog("[GeminiUsage] Token refreshed successfully") } catch { NSLog("[GeminiUsage] Token refresh failed: \(error.localizedDescription)") throw ClientError.credentialExpired(expiry) } } } guard !credential.token.accessToken.isEmpty else { throw ClientError.missingAccessToken } let token = credential.token.accessToken let projectId = try await resolveProjectId(token: token) if projectId == nil { NSLog("[GeminiUsage] No project ID detected; continuing without project") } let buckets = try await retrieveQuota(token: token, projectId: projectId) let claims = extractClaimsFromToken(credential.token.idToken) let userTier = await fetchUserTier(token: token) var planType: String? switch userTier { case .standard: planType = await detectPlanFromStorage(token: token) ?? "Pro" case .free: planType = claims.hostedDomain != nil ? "Workspace" : "Free" case .legacy: planType = "Legacy" case .none: planType = await detectPlanFromStorage(token: token) ?? detectPlanFromModels(buckets) } let status = GeminiUsageStatus( updatedAt: now, projectId: projectId, buckets: buckets, planType: planType ) return status } // MARK: - Credential loading private func fetchCredential() throws -> CredentialEnvelope { if let file = fetchCredentialFromPlaintextFile() { return file } throw ClientError.credentialNotFound } private func fetchCredentialFromPlaintextFile() -> CredentialEnvelope? { let fm = FileManager.default let home = SessionPreferencesStore.getRealUserHomeURL() // Prioritize standard Gemini CLI credentials file let paths = [ home.appendingPathComponent(".gemini/oauth_creds.json"), home.appendingPathComponent(".gemini/mcp-oauth-tokens-v2.json"), home.appendingPathComponent(".gemini/mcp-oauth-tokens.json") ] for url in paths { guard fm.fileExists(atPath: url.path) else { continue } if let data = try? Data(contentsOf: url) { // Try OAuthCredentials shape first if let envelope = try? JSONDecoder().decode(CredentialEnvelope.self, from: data) { return envelope } // Try legacy google creds (oauth_creds.json) if let legacy = try? JSONDecoder().decode(OAuthFile.self, from: data), let token = legacy.access_token { let expires = legacy.expiry_date let tokenObj = CredentialEnvelope.Token( accessToken: token, refreshToken: legacy.refresh_token, expiresAt: expires, tokenType: "Bearer", idToken: legacy.id_token ) return CredentialEnvelope(serverName: "legacy", token: tokenObj, updatedAt: nil) } } } return nil } // MARK: - Network private func resolveProjectId(token: String) async throws -> String? { let envProject = ProcessInfo.processInfo.environment["GOOGLE_CLOUD_PROJECT"] ?? ProcessInfo.processInfo.environment["GOOGLE_CLOUD_PROJECT_ID"] if let discovered = try? await discoverGeminiProjectId(token: token) { return discovered } return envProject } private func onboardUser( token: String, tierId: String, cloudaicompanionProject: String? ) async throws -> String? { guard let url = URL(string: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser") else { return nil } var metadata: [String: String] = [ "ideType": "IDE_UNSPECIFIED", "platform": "PLATFORM_UNSPECIFIED", "pluginType": "GEMINI" ] if let project = cloudaicompanionProject, !project.isEmpty { metadata["duetProject"] = project } let requestBody = OnboardUserRequest( tierId: tierId, cloudaicompanionProject: cloudaicompanionProject, metadata: metadata ) var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.timeoutInterval = 30 guard let bodyData = try? JSONEncoder().encode(requestBody) else { throw ClientError.requestFailed(-1) } request.httpBody = bodyData // Poll until the long-running operation is complete (max 12 attempts = 60 seconds) let maxAttempts = 12 var attempts = 0 while attempts < maxAttempts { let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse else { throw ClientError.requestFailed(-1) } guard (200..<300).contains(http.statusCode) else { throw ClientError.requestFailed(http.statusCode) } guard let result = try? JSONDecoder().decode(OnboardUserResponse.self, from: data) else { throw ClientError.decodingFailed } // Check if operation is complete if result.done == true { let projectId = result.response?.cloudaicompanionProject?.id ?? result.response?.cloudaicompanionProject?.name NSLog("[GeminiUsage] Onboarding completed, project ID: \(projectId ?? "nil")") return projectId } // Not done yet, wait and retry attempts += 1 NSLog("[GeminiUsage] Onboarding not complete, attempt \(attempts)/\(maxAttempts), retrying in 5s...") if attempts < maxAttempts { try await Task.sleep(nanoseconds: 5_000_000_000) // 5 seconds } } // Polling timeout NSLog("[GeminiUsage] Onboarding polling timeout after \(maxAttempts) attempts") throw ClientError.requestFailed(-2) } private func retrieveQuota(token: String, projectId: String?) async throws -> [GeminiUsageStatus.Bucket] { guard let url = URL(string: "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota") else { return [] } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue(userAgent(), forHTTPHeaderField: "User-Agent") request.timeoutInterval = 10 var body: [String: Any] = [:] if let projectId, !projectId.isEmpty { body["project"] = projectId } request.httpBody = try? JSONSerialization.data(withJSONObject: body) let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse else { throw ClientError.requestFailed(-1) } guard (200..<300).contains(http.statusCode) else { throw ClientError.requestFailed(http.statusCode) } // Debug: Log raw quota response if let rawJSON = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { NSLog("[GeminiUsage] retrieveUserQuota response: \(rawJSON)") } guard let payload = try? JSONDecoder().decode(QuotaResponse.self, from: data) else { throw ClientError.decodingFailed } let buckets: [GeminiUsageStatus.Bucket] = (payload.buckets ?? []).map { bucket in let reset: Date? = bucket.resetTime.flatMap { resetTimeString in // Try parsing with fractional seconds first let formatterWithFractional = ISO8601DateFormatter() formatterWithFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let date = formatterWithFractional.date(from: resetTimeString) { NSLog("[GeminiUsage] Parsed resetTime with fractional seconds: \(resetTimeString) -> \(date)") return date } // Fallback: try without fractional seconds let formatterWithoutFractional = ISO8601DateFormatter() formatterWithoutFractional.formatOptions = [.withInternetDateTime] if let date = formatterWithoutFractional.date(from: resetTimeString) { NSLog("[GeminiUsage] Parsed resetTime without fractional seconds: \(resetTimeString) -> \(date)") return date } NSLog("[GeminiUsage] Failed to parse resetTime: \(resetTimeString)") return nil } return GeminiUsageStatus.Bucket( modelId: bucket.modelId, tokenType: bucket.tokenType, remainingFraction: bucket.remainingFraction, remainingAmount: bucket.remainingAmount, resetTime: reset ) } NSLog("[GeminiUsage] Retrieved \(buckets.count) buckets, \(buckets.filter { $0.resetTime != nil }.count) have resetTime") return buckets } private func userAgent() -> String { let version = Bundle.main.shortVersionString let platform = ProcessInfo.processInfo.operatingSystemVersionString return "CodMate/\(version) (\(platform))" } // MARK: - Token Refresh private struct OAuthClientCredentials { let clientId: String let clientSecret: String } private func refreshAccessToken(refreshToken: String) async throws -> CredentialEnvelope.Token { guard let url = URL(string: "https://oauth2.googleapis.com/token") else { throw ClientError.decodingFailed } guard let oauthCreds = extractOAuthCredentials() else { NSLog("[GeminiUsage] Could not extract OAuth credentials from Gemini CLI") throw ClientError.decodingFailed } return try await refreshWithCredentials( clientId: oauthCreds.clientId, clientSecret: oauthCreds.clientSecret, refreshToken: refreshToken, url: url ) } private func refreshWithCredentials( clientId: String, clientSecret: String, refreshToken: String, url: URL ) async throws -> CredentialEnvelope.Token { var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.timeoutInterval = 15 let body = [ "client_id=\(clientId)", "client_secret=\(clientSecret)", "refresh_token=\(refreshToken)", "grant_type=refresh_token", ].joined(separator: "&") request.httpBody = body.data(using: .utf8) let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw ClientError.decodingFailed } guard httpResponse.statusCode == 200 else { NSLog("[GeminiUsage] Token refresh failed with status \(httpResponse.statusCode)") throw ClientError.requestFailed(httpResponse.statusCode) } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let newAccessToken = json["access_token"] as? String else { throw ClientError.decodingFailed } // Update local credentials file try updateStoredCredentials(json) NSLog("[GeminiUsage] Token refreshed successfully") // Construct new Token object let expiresIn = json["expires_in"] as? TimeInterval ?? 3600 let newExpiresAt = Date().timeIntervalSince1970 * 1000 + expiresIn * 1000 // Convert to milliseconds return CredentialEnvelope.Token( accessToken: newAccessToken, refreshToken: refreshToken, // refresh_token stays the same expiresAt: newExpiresAt, tokenType: json["token_type"] as? String, idToken: json["id_token"] as? String ) } private func updateStoredCredentials(_ refreshResponse: [String: Any]) throws { let credsPaths = [ "~/.gemini/oauth_creds.json", "~/.gemini/mcp-oauth-tokens-v2.json", "~/.gemini/mcp-oauth-tokens.json", ] for path in credsPaths { let expandedPath = (path as NSString).expandingTildeInPath let credsURL = URL(fileURLWithPath: expandedPath) guard FileManager.default.fileExists(atPath: expandedPath), let existingData = try? Data(contentsOf: credsURL), var json = try? JSONSerialization.jsonObject(with: existingData) as? [String: Any] else { continue } // Update access_token and expiry time if let newAccessToken = refreshResponse["access_token"] as? String { json["access_token"] = newAccessToken if let expiresIn = refreshResponse["expires_in"] as? TimeInterval { let newExpiresAt = Date().timeIntervalSince1970 * 1000 + expiresIn * 1000 // Update both field names for compatibility json["expiresAt"] = newExpiresAt json["expiry_date"] = newExpiresAt } // Also update id_token if present in response if let idToken = refreshResponse["id_token"] { json["id_token"] = idToken } // Write back to file let updatedData = try JSONSerialization.data(withJSONObject: json, options: .prettyPrinted) try updatedData.write(to: credsURL, options: .atomic) NSLog("[GeminiUsage] Updated credentials file: \(path)") return } } NSLog("[GeminiUsage] Warning: Could not update any credentials file") } // MARK: - OAuth Credentials Extraction /// Extract OAuth credentials from installed Gemini CLI /// Follows CodexBar's approach: find gemini binary, resolve symlinks, locate oauth2.js private func extractOAuthCredentials() -> OAuthClientCredentials? { let fm = FileManager.default // Step 1: Find gemini binary using CodMate's CLIEnvironment let path = CLIEnvironment.resolvedPATHForCLI() guard let geminiPath = CLIEnvironment.resolveExecutablePath("gemini", path: path) else { NSLog("[GeminiUsage] Could not find gemini binary in PATH") return nil } // Step 2: Resolve symlinks to find actual installation directory var realPath = geminiPath if let resolved = try? fm.destinationOfSymbolicLink(atPath: geminiPath) { if resolved.hasPrefix("/") { realPath = resolved } else { realPath = (geminiPath as NSString).deletingLastPathComponent + "/" + resolved } } // Step 3: Navigate to gemini-cli package root // realPath might be: /opt/homebrew/lib/node_modules/@google/gemini-cli/dist/index.js // We need to get to: /opt/homebrew/lib/node_modules/@google/gemini-cli var baseDir = realPath // If realPath ends with .js, go up to package root (remove /dist/index.js or similar) if realPath.hasSuffix(".js") { // Remove filename baseDir = (realPath as NSString).deletingLastPathComponent // If we're in dist/, go up one more level to package root if baseDir.hasSuffix("/dist") { baseDir = (baseDir as NSString).deletingLastPathComponent } } else { // realPath is the binary, navigate from bin to package root let binDir = (realPath as NSString).deletingLastPathComponent baseDir = (binDir as NSString).deletingLastPathComponent } let oauthFile = "dist/src/code_assist/oauth2.js" let possiblePaths = [ // Direct path from package root (most common for Homebrew) "\(baseDir)/node_modules/@google/gemini-cli-core/\(oauthFile)", // Alternative nested structures "\(baseDir)/libexec/lib/node_modules/@google/gemini-cli-core/\(oauthFile)", "\(baseDir)/lib/node_modules/@google/gemini-cli-core/\(oauthFile)", // Bun/npm sibling structure "\(baseDir)/../gemini-cli-core/\(oauthFile)", ] for path in possiblePaths { if let content = try? String(contentsOfFile: path, encoding: .utf8) { NSLog("[GeminiUsage] Found oauth2.js at: \(path)") if let creds = parseOAuthCredentials(from: content) { NSLog("[GeminiUsage] Successfully extracted OAuth credentials from Gemini CLI") return creds } } } NSLog("[GeminiUsage] Could not find oauth2.js in any expected location") return nil } /// Parse OAuth credentials from oauth2.js content /// Matches pattern: const OAUTH_CLIENT_ID = '...'; private func parseOAuthCredentials(from content: String) -> OAuthClientCredentials? { // Match: const OAUTH_CLIENT_ID = '...'; let clientIdPattern = #"OAUTH_CLIENT_ID\s*=\s*['"]([\w\-\.]+)['"]"# let secretPattern = #"OAUTH_CLIENT_SECRET\s*=\s*['"]([\w\-]+)['"]"# guard let clientIdRegex = try? NSRegularExpression(pattern: clientIdPattern), let secretRegex = try? NSRegularExpression(pattern: secretPattern) else { return nil } let range = NSRange(content.startIndex..., in: content) guard let clientIdMatch = clientIdRegex.firstMatch(in: content, range: range), let clientIdRange = Range(clientIdMatch.range(at: 1), in: content), let secretMatch = secretRegex.firstMatch(in: content, range: range), let secretRange = Range(secretMatch.range(at: 1), in: content) else { return nil } let clientId = String(content[clientIdRange]) let clientSecret = String(content[secretRange]) return OAuthClientCredentials(clientId: clientId, clientSecret: clientSecret) } // MARK: - Plan Detection /// Detect plan from Google Drive storage quota (most reliable method). /// 2 TB = AI Pro, 30 TB = AI Ultra private func detectPlanFromStorage(token: String) async -> String? { let endpoint = "https://www.googleapis.com/drive/v3/about?fields=storageQuota" guard let url = URL(string: endpoint) else { return nil } var request = URLRequest(url: url) request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.timeoutInterval = 15 guard let (data, response) = try? await URLSession.shared.data(for: request), let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { return nil } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let storageQuota = json["storageQuota"] as? [String: Any], let limitStr = storageQuota["limit"] as? String, let limit = Int64(limitStr) else { return nil } // Storage limits for plan detection let storageLimit2TB: Int64 = 2_199_023_255_552 let storageLimit30TB: Int64 = 32_985_348_833_280 // Detect plan based on storage limit if limit >= storageLimit30TB { return "AI Ultra" } else if limit >= storageLimit2TB { return "AI Pro" } return nil } /// Detect plan tier based on model access. Users with Pro models have AI Pro or higher. private func detectPlanFromModels(_ buckets: [GeminiUsageStatus.Bucket]) -> String? { // If user has access to any "pro" models, they're on a paid tier (AI Pro, AI Ultra, etc.) let hasProModels = buckets.contains { bucket in bucket.modelId?.lowercased().contains("pro") == true } return hasProModels ? "AI Pro" : nil } // MARK: - Auth type & project discovery (CodexBar-aligned) private enum GeminiAuthType: String { case oauthPersonal = "oauth-personal" case apiKey = "api-key" case vertexAI = "vertex-ai" case unknown } private enum GeminiUserTierId: String { case free = "free-tier" case legacy = "legacy-tier" case standard = "standard-tier" } private func currentAuthType(homeDirectory: URL) -> GeminiAuthType { let settingsURL = homeDirectory.appendingPathComponent(".gemini/settings.json") guard let data = try? Data(contentsOf: settingsURL), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let security = json["security"] as? [String: Any], let auth = security["auth"] as? [String: Any], let selectedType = auth["selectedType"] as? String else { return .unknown } return GeminiAuthType(rawValue: selectedType) ?? .unknown } private func discoverGeminiProjectId(token: String) async throws -> String? { guard let url = URL(string: "https://cloudresourcemanager.googleapis.com/v1/projects") else { return nil } var request = URLRequest(url: url) request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.timeoutInterval = 10 let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let projects = json["projects"] as? [[String: Any]] else { return nil } for project in projects { guard let projectId = project["projectId"] as? String else { continue } if projectId.hasPrefix("gen-lang-client") { return projectId } if let labels = project["labels"] as? [String: String], labels["generative-language"] != nil { return projectId } } return nil } private func fetchUserTier(token: String) async -> GeminiUserTierId? { guard let url = URL(string: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist") else { return nil } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = Data("{\"metadata\":{\"ideType\":\"GEMINI_CLI\",\"pluginType\":\"GEMINI\"}}".utf8) request.timeoutInterval = 10 let data: Data let response: URLResponse do { (data, response) = try await URLSession.shared.data(for: request) } catch { return nil } guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let currentTier = json["currentTier"] as? [String: Any], let tierId = currentTier["id"] as? String else { return nil } return GeminiUserTierId(rawValue: tierId) } private struct TokenClaims { let email: String? let hostedDomain: String? } private func extractClaimsFromToken(_ idToken: String?) -> TokenClaims { guard let token = idToken else { return TokenClaims(email: nil, hostedDomain: nil) } let parts = token.components(separatedBy: ".") guard parts.count >= 2 else { return TokenClaims(email: nil, hostedDomain: nil) } var payload = parts[1] .replacingOccurrences(of: "-", with: "+") .replacingOccurrences(of: "_", with: "/") let remainder = payload.count % 4 if remainder > 0 { payload += String(repeating: "=", count: 4 - remainder) } guard let data = Data(base64Encoded: payload, options: .ignoreUnknownCharacters), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return TokenClaims(email: nil, hostedDomain: nil) } return TokenClaims( email: json["email"] as? String, hostedDomain: json["hd"] as? String ) } } ================================================ FILE: services/GhosttySessionManager.swift ================================================ import Foundation import GhosttyKit /// Lightweight Ghostty Terminal session manager /// Only handles view caching; process management is handled by libghostty @MainActor final class GhosttySessionManager { static let shared = GhosttySessionManager() private var scrollViews: [String: TerminalScrollView] = [:] private var accessOrder: [String] = [] private let maxSessions = 50 private init() {} func getScrollView(for key: String) -> TerminalScrollView? { touch(key) return scrollViews[key] } func setScrollView(_ view: TerminalScrollView, for key: String) { scrollViews[key] = view touch(key) evictIfNeeded() } func removeScrollView(for key: String) { scrollViews.removeValue(forKey: key) accessOrder.removeAll { $0 == key } } func removeAll() { scrollViews.removeAll() accessOrder.removeAll() } private func touch(_ key: String) { accessOrder.removeAll { $0 == key } accessOrder.append(key) } private func evictIfNeeded() { while scrollViews.count > maxSessions, let oldest = accessOrder.first { accessOrder.removeFirst() scrollViews.removeValue(forKey: oldest) } } /// Check if there is a running process (for close confirmation) @MainActor func hasRunningProcess(for key: String) -> Bool { guard let scrollView = scrollViews[key] else { return false } return scrollView.surfaceView.needsConfirmQuit } /// Check if there are any running processes @MainActor func hasAnyRunningProcesses() -> Bool { for scrollView in scrollViews.values { if scrollView.surfaceView.needsConfirmQuit { return true } } return false } } ================================================ FILE: services/GitService.swift ================================================ import Darwin import Foundation import OSLog // Actor responsible for interacting with Git in a given working tree. // Uses `/usr/bin/env git` and a robust PATH as per CLI integration guidance. actor GitService { private static let log = Logger(subsystem: "ai.codmate.app", category: "Git") struct Change: Identifiable, Sendable, Hashable { enum Kind: String, Sendable { case modified, added, deleted, untracked } let id = UUID() var path: String var staged: Kind? var worktree: Kind? } struct FileChange: Identifiable, Sendable, Hashable { let id = UUID() var path: String var statusCode: String var oldPath: String? var statusLetter: String { guard let first = statusCode.first else { return "?" } return String(first) } } struct Repo: Sendable, Hashable { var root: URL } struct VisibleFilesResult: Sendable { var paths: [String] var truncated: Bool } private static let realHomeDirectory: String = { let fmHome = FileManager.default.homeDirectoryForCurrentUser.path if !fmHome.isEmpty { return fmHome } if let pwDir = getpwuid(getuid())?.pointee.pw_dir { return String(cString: pwDir) } if let envHome = ProcessInfo.processInfo.environment["HOME"], !envHome.isEmpty { return envHome } return NSHomeDirectory() }() private let envPATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" private let gitCandidates: [String] = GitService.detectGitCandidates() private var blockedExecutables: Set = [] private var lastFailureDescription: String? private static func detectGitCandidates() -> [String] { let fm = FileManager.default var seen: Set = [] var out: [String] = [] func append(_ path: String) { guard !seen.contains(path) else { return } seen.insert(path) if fm.isExecutableFile(atPath: path) { out.append(path) } } let preferred = [ "/Library/Developer/CommandLineTools/usr/bin/git", "/Applications/Xcode.app/Contents/Developer/usr/bin/git", "/Applications/Xcode-beta.app/Contents/Developer/usr/bin/git", "/usr/bin/git", "/opt/homebrew/bin/git", "/usr/local/bin/git", ] for path in preferred { append(path) } if !seen.contains("/usr/bin/git") { append("/usr/bin/git") } return out } // Discover the git repository root for a directory, or nil if not a repo func repositoryRoot(for directory: URL) async -> Repo? { guard let out = try? await runGit(["rev-parse", "--show-toplevel"], cwd: directory), out.exitCode == 0 else { return nil } let raw = out.stdout.trimmingCharacters(in: .whitespacesAndNewlines) guard !raw.isEmpty else { return nil } return Repo(root: URL(fileURLWithPath: raw, isDirectory: true)) } // Aggregate staged/unstaged/untracked status. Optimized to use a single git call. func status(in repo: Repo) async -> [Change] { // Use status --porcelain=v1 -z which provides stable, machine-readable output for all states guard let out = try? await runGit(["status", "--porcelain", "-z"], cwd: repo.root) else { return [] } let (stagedF, worktreeF, untrackedF) = Self.parsePorcelainZ(out.stdout) var map: [String: Change] = [:] func ensure(_ p: String) -> Change { if let c = map[p] { return c } let c = Change(path: p, staged: nil, worktree: nil) map[p] = c return c } for (p, k) in stagedF { var c = ensure(p); c.staged = k; map[p] = c } for (p, k) in worktreeF { var c = ensure(p); c.worktree = k; map[p] = c } for p in untrackedF { var c = ensure(p); c.worktree = .untracked; map[p] = c } return Array(map.values).sorted { $0.path.localizedStandardCompare($1.path) == .orderedAscending } } func listVisibleFiles(in repo: Repo, limit: Int) async -> VisibleFilesResult? { let arguments = ["ls-files", "-co", "--exclude-standard", "-z"] guard let out = try? await runGit(arguments, cwd: repo.root), out.exitCode == 0 else { return nil } let components = out.stdout.split(separator: "\0", omittingEmptySubsequences: false) let maxEntries = limit > 0 ? limit : Int.max var paths: [String] = [] paths.reserveCapacity(min(components.count, maxEntries)) var truncated = false for component in components { if component.isEmpty { continue } if paths.count >= maxEntries { truncated = true break } paths.append(String(component)) } if components.count > maxEntries { truncated = true } return VisibleFilesResult(paths: paths, truncated: truncated) } // Minimal parser for `git diff --name-status -z` output. // Handles: M/A/D/T/U and R/C (renames, copies) by attributing to the new path as modified. private static func parseNameStatusZ(_ stdout: String) -> [String: Change.Kind] { var result: [String: Change.Kind] = [:] let tokens = stdout.split(separator: "\0").map(String.init) var i = 0 while i < tokens.count { let status = tokens[i] guard i + 1 < tokens.count else { break } let path1 = tokens[i + 1] var pathOut = path1 var kind: Change.Kind = .modified // Normalize leading letter let code = status.first.map(String.init) ?? "M" switch code { case "A": kind = .added case "D": kind = .deleted case "M", "T", "U": kind = .modified case "R", "C": // Renames/Copies provide an extra path; choose the new path when present if i + 2 < tokens.count { pathOut = tokens[i + 2] i += 1 // consume the extra path as well below } kind = .modified default: kind = .modified } result[pathOut] = kind i += 2 } return result } // Parse `git status --porcelain -z` into staged/worktree/untracked sets private static func parsePorcelainZ(_ stdout: String) -> ([String: Change.Kind], [String: Change.Kind], [String]) { let tokens = stdout.split(separator: "\0").map(String.init) var i = 0 var staged: [String: Change.Kind] = [:] var worktree: [String: Change.Kind] = [:] var untracked: [String] = [] func kind(for code: Character) -> Change.Kind { switch code { case "A": return .added case "D": return .deleted case "M", "T", "U": return .modified default: return .modified } } while i < tokens.count { let entry = tokens[i] guard entry.count >= 2 else { break } let x = entry.first! // index let y = entry.dropFirst().first! // worktree // Format: XY PATH\0 // Renames: XY NEWPATH\0OLDPATH\0. We want NEWPATH. // Usually starts at index 3: "XY " let path = String(entry.dropFirst(3)) // Check for renames/copies which consume an extra token (the old path) if x == "R" || x == "C" || y == "R" || y == "C" { // The current token 'path' is the NEW path. // The NEXT token is the OLD path. We consume it but ignore it for status mapping. if i + 1 < tokens.count { i += 1 } } if x == "?" && y == "?" { untracked.append(path) } else { if x != " " { staged[path] = kind(for: x) } if y != " " { worktree[path] = kind(for: y) } } i += 1 } return (staged, worktree, untracked) } // Unified diff for the file; staged toggles --cached func diff(in repo: Repo, path: String, staged: Bool) async -> String { let args = ["diff", staged ? "--cached" : "", "--", path].filter { !$0.isEmpty } if let out = try? await runGit(args, cwd: repo.root) { return out.stdout } return "" } // Unified diff for all staged changes (index vs HEAD). Large outputs are returned as-is; // callers should truncate if needed before sending to external systems. func stagedUnifiedDiff(in repo: Repo) async -> String { if let out = try? await runGit(["diff", "--cached"], cwd: repo.root) { return out.stdout } return "" } // Read file content from the worktree for preview func readFile(in repo: Repo, path: String, maxBytes: Int = 1_000_000) async -> String { let url = repo.root.appendingPathComponent(path) guard let h = try? FileHandle(forReadingFrom: url) else { return "" } defer { try? h.close() } let data = try? h.read(upToCount: maxBytes) if let d = data, let s = String(data: d, encoding: .utf8) { return s } return "" } // Stage/unstage operations func stage(in repo: Repo, paths: [String]) async { guard !paths.isEmpty else { return } // Use -A to ensure deletions are staged as well _ = try? await runGit(["add", "-A", "--"] + paths, cwd: repo.root) } func unstage(in repo: Repo, paths: [String]) async { guard !paths.isEmpty else { return } _ = try? await runGit(["restore", "--staged", "--"] + paths, cwd: repo.root) } func commit(in repo: Repo, message: String) async -> Int32 { let msg = message.trimmingCharacters(in: .whitespacesAndNewlines) guard !msg.isEmpty else { return -1 } let out = try? await runGit(["commit", "-m", msg], cwd: repo.root) return out?.exitCode ?? -1 } // Discard only worktree (unstaged) changes for specific paths, preserving the index. func discardWorktree(in repo: Repo, paths: [String]) async -> Int32 { guard !paths.isEmpty else { return 0 } let out = try? await runGit(["restore", "--worktree", "--"] + paths, cwd: repo.root) return out?.exitCode ?? -1 } // Discard tracked changes (both index and worktree) for specific paths func discardTracked(in repo: Repo, paths: [String]) async -> Int32 { guard !paths.isEmpty else { return 0 } let out = try? await runGit(["restore", "--staged", "--worktree", "--"] + paths, cwd: repo.root) return out?.exitCode ?? -1 } // Remove untracked files for specific paths func cleanUntracked(in repo: Repo, paths: [String]) async -> Int32 { guard !paths.isEmpty else { return 0 } let out = try? await runGit(["clean", "-f", "-d", "--"] + paths, cwd: repo.root) return out?.exitCode ?? -1 } // MARK: - History APIs (lightweight) struct Commit: Identifiable, Sendable, Hashable { let id: String // full SHA let shortId: String // short SHA let author: String let date: String // human friendly (relative) let subject: String } struct GraphCommit: Identifiable, Sendable, Hashable { let id: String let shortId: String let author: String let date: String let subject: String let parents: [String] // full SHAs let decorations: [String] // branch/tag names from %D } /// Return recent commits for the repository, newest first. func logCommits(in repo: Repo, limit: Int = 200) async -> [Commit] { // Print one commit per line; fields separated by 0x1F (Unit Separator). // Avoid NULs in arguments and output to keep parsing simple and safe. let fmt = "%H%x1f%h%x1f%an%x1f%ad%x1f%s" let args = [ "log", "--no-color", "--date=relative", "--pretty=format:\(fmt)", "-n", String(max(1, limit)) ] guard let out = try? await runGit(args, cwd: repo.root), out.exitCode == 0 else { return [] } let lines = out.stdout.split(separator: "\n") var commits: [Commit] = [] commits.reserveCapacity(lines.count) for line in lines { let parts = line.split(separator: "\u{001f}", omittingEmptySubsequences: false).map(String.init) if parts.count >= 5 { commits.append(Commit(id: parts[0], shortId: parts[1], author: parts[2], date: parts[3], subject: parts[4])) } } return commits } /// Files changed in a given commit, including change type/status. func filesChanged(in repo: Repo, commitId: String) async -> [FileChange] { // diff-tree gives reliable name-status output, including renames/copies. let args = ["diff-tree", "--no-commit-id", "--name-status", "-r", commitId] guard let out = try? await runGit(args, cwd: repo.root), out.exitCode == 0 else { return [] } var results: [FileChange] = [] for rawLine in out.stdout.split(separator: "\n") { let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) if line.isEmpty { continue } let components = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init) guard let status = components.first, !status.isEmpty else { continue } let code = status if let first = status.first, first == "R" || first == "C" { // Rename/Copy: expect "R100\told\tnew" guard components.count >= 3 else { continue } let oldPath = components[1] let newPath = components[2] results.append(FileChange(path: newPath, statusCode: code, oldPath: oldPath)) } else { guard components.count >= 2 else { continue } let path = components[1] results.append(FileChange(path: path, statusCode: code, oldPath: nil)) } } return results } /// Unified diff patch for a specific commit against its first parent. func commitPatch(in repo: Repo, commitId: String) async -> String { // --pretty=format: to suppress commit header; we render header in UI. // --no-ext-diff to avoid external diff tools, --no-color for clean parsing. let args = ["show", "--pretty=format:", "--no-ext-diff", "--no-color", commitId] guard let out = try? await runGit(args, cwd: repo.root), out.exitCode == 0 else { return "" } return out.stdout } /// Unified diff patch for a specific file in a given commit. func filePatch(in repo: Repo, commitId: String, path: String) async -> String { // Restrict git show to a single path; suppress commit header and external diff tools. let args = ["show", "--pretty=format:", "--no-ext-diff", "--no-color", commitId, "--", path] guard let out = try? await runGit(args, cwd: repo.root), out.exitCode == 0 else { return "" } return out.stdout } /// Full commit message (subject + body) for a given commit. func commitMessage(in repo: Repo, commitId: String) async -> String { let args = ["show", "-s", "--format=%B", "--no-color", commitId] guard let out = try? await runGit(args, cwd: repo.root), out.exitCode == 0 else { return "" } return out.stdout.trimmingCharacters(in: .whitespacesAndNewlines) } /// Fetch all remotes (equivalent to `git fetch --all --prune`). func fetchAllRemotes(in repo: Repo) async -> Int32 { let args = ["fetch", "--all", "--prune"] let out = try? await runGit(args, cwd: repo.root) return out?.exitCode ?? -1 } /// Pull the current branch from its upstream in fast-forward mode. func pullCurrentBranch(in repo: Repo) async -> Int32 { // Prefer fast-forward to avoid interactive merges; users can rebase manually if desired. let args = ["pull", "--ff-only"] let out = try? await runGit(args, cwd: repo.root) return out?.exitCode ?? -1 } /// Push the current branch to its upstream. If no upstream is configured, attempts to /// set origin/HEAD as the upstream target automatically. func pushCurrentBranch(in repo: Repo) async -> Int32 { // Check whether an upstream is already configured. let upstream = try? await runGit( ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], cwd: repo.root ) if upstream?.exitCode == 0 { let out = try? await runGit(["push"], cwd: repo.root) return out?.exitCode ?? -1 } else { // Determine current branch name; fallback to HEAD if detection fails. let branch = try? await runGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd: repo.root) let name = branch?.stdout.trimmingCharacters(in: .whitespacesAndNewlines) if let current = name, !current.isEmpty, current != "HEAD" { let out = try? await runGit( ["push", "--set-upstream", "origin", current], cwd: repo.root ) return out?.exitCode ?? -1 } else { let out = try? await runGit( ["push", "--set-upstream", "origin", "HEAD"], cwd: repo.root ) return out?.exitCode ?? -1 } } } /// Full graph-friendly commit list with parents and decorations. Optional inclusion of remotes. /// If `singleRef` is provided, only that ref is listed (overrides other branch toggles). func logGraphCommits( in repo: Repo, limit: Int = 300, skip: Int = 0, includeAllBranches: Bool = true, includeRemoteBranches: Bool = true, singleRef: String? = nil ) async -> [GraphCommit] { var revArgs: [String] = [] if let single = singleRef, !single.isEmpty { revArgs = [single] } else if includeAllBranches { revArgs.append(includeRemoteBranches ? "--all" : "--branches") } else { // default HEAD current branch only; explicit to be safe revArgs.append("HEAD") } let fmt = "%H%x1f%h%x1f%an%x1f%ad%x1f%s%x1f%P%x1f%D" var args = [ "log", "--no-color", "--date=relative", "--decorate=short", "--topo-order", "--pretty=format:\(fmt)", "-n", String(max(1, limit)) ] if skip > 0 { args.append("--skip=\(skip)") } args += revArgs guard let out = try? await runGit(args, cwd: repo.root), out.exitCode == 0 else { return [] } let lines = out.stdout.split(separator: "\n") var list: [GraphCommit] = [] list.reserveCapacity(lines.count) for line in lines { let parts = line.split(separator: "\u{001f}", omittingEmptySubsequences: false).map(String.init) if parts.count >= 7 { let parents = parts[5].split(separator: " ").map(String.init) let decosRaw = parts[6] let decorations: [String] = decosRaw.split(separator: ",").map { s in var t = s.trimmingCharacters(in: .whitespaces) if t.hasPrefix("HEAD -> ") { t = String(t.dropFirst("HEAD -> ".count)) } return t }.filter { !$0.isEmpty } list.append(GraphCommit( id: parts[0], shortId: parts[1], author: parts[2], date: parts[3], subject: parts[4], parents: parents, decorations: decorations )) } } return list } /// Query commit ids whose subject or body match a case-insensitive query using git --grep. func searchCommitIds( in repo: Repo, query: String, includeAllBranches: Bool = true, includeRemoteBranches: Bool = true, singleRef: String? = nil ) async -> Set { let q = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !q.isEmpty else { return [] } var revArgs: [String] = [] if let single = singleRef, !single.isEmpty { revArgs = [single] } else if includeAllBranches { revArgs.append(includeRemoteBranches ? "--all" : "--branches") } else { revArgs.append("HEAD") } var args = [ "log", "--no-color", "--regexp-ignore-case", "--grep", q, "--pretty=format:%H", "-n", "10000" ] args += revArgs guard let out = try? await runGit(args, cwd: repo.root), out.exitCode == 0 else { return [] } let lines = out.stdout.split(separator: "\n").map(String.init) return Set(lines) } /// List branches. Returns short names. Optionally include remote branches. func listBranches(in repo: Repo, includeRemoteBranches: Bool = false) async -> [String] { // Local branches let localArgs = [ "for-each-ref", "--format=%(refname:short)", "refs/heads" ] var names: [String] = [] if let out = try? await runGit(localArgs, cwd: repo.root), out.exitCode == 0 { names.append(contentsOf: out.stdout.split(separator: "\n").map(String.init)) } if includeRemoteBranches { let remoteArgs = [ "for-each-ref", "--format=%(refname:short)", "refs/remotes" ] if let out = try? await runGit(remoteArgs, cwd: repo.root), out.exitCode == 0 { names.append(contentsOf: out.stdout.split(separator: "\n").map(String.init)) } } // De-duplicate and sort natural-ish let unique = Array(Set(names)).filter { !$0.isEmpty } return unique.sorted { $0.localizedStandardCompare($1) == .orderedAscending } } // MARK: - Helpers private struct ProcOut { let stdout: String; let stderr: String; let exitCode: Int32 } func takeLastFailureDescription() -> String? { let message = lastFailureDescription lastFailureDescription = nil return message } private func runGit(_ args: [String], cwd: URL) async throws -> ProcOut { var lastError: ProcOut? = nil let home = Self.realHomeDirectory #if DEBUG Self.log.debug("Running git \(args.joined(separator: " "), privacy: .public) in \(cwd.path, privacy: .public)") #endif let candidates = gitCandidates + ["/usr/bin/env"] for path in candidates { if blockedExecutables.contains(path) { continue } let proc = Process() if path == "/usr/bin/env" { proc.executableURL = URL(fileURLWithPath: path) proc.arguments = ["git"] + args } else { proc.executableURL = URL(fileURLWithPath: path) proc.arguments = args } proc.currentDirectoryURL = cwd var env = ProcessInfo.processInfo.environment // Robust PATH for sandboxed process env["PATH"] = envPATH + ":" + (env["PATH"] ?? "") // Avoid invoking pagers or external tools env["GIT_PAGER"] = "cat" env["GIT_EDITOR"] = ":" env["GIT_OPTIONAL_LOCKS"] = "0" // Prevent reading global/system configs that may live outside sandbox env["GIT_CONFIG_NOSYSTEM"] = "0" let existingConfigCount = Int(env["GIT_CONFIG_COUNT"] ?? "0") ?? 0 env["GIT_CONFIG_COUNT"] = String(existingConfigCount + 1) env["GIT_CONFIG_KEY_\(existingConfigCount)"] = "safe.directory" env["GIT_CONFIG_VALUE_\(existingConfigCount)"] = "*" env["HOME"] = home if path.contains("/CommandLineTools/") { env["DEVELOPER_DIR"] = "/Library/Developer/CommandLineTools" } else if path.contains("/Applications/Xcode") { env["DEVELOPER_DIR"] = "/Applications/Xcode.app/Contents/Developer" } proc.environment = env let outPipe = Pipe(); proc.standardOutput = outPipe let errPipe = Pipe(); proc.standardError = errPipe do { try proc.run() } catch { #if DEBUG Self.log.debug("Failed to launch \(path, privacy: .public): \(error.localizedDescription, privacy: .public)") #endif if path != "/usr/bin/env" { blockedExecutables.insert(path) } continue } let outData = try outPipe.fileHandleForReading.readToEnd() ?? Data() let errData = try errPipe.fileHandleForReading.readToEnd() ?? Data() proc.waitUntilExit() let stdout = String(data: outData, encoding: .utf8) ?? "" let stderr = String(data: errData, encoding: .utf8) ?? "" let out = ProcOut(stdout: stdout, stderr: stderr, exitCode: proc.terminationStatus) if out.exitCode == 0 { #if DEBUG Self.log.debug("git succeeded via \(path, privacy: .public)") #endif return out } #if DEBUG Self.log.debug("git via \(path, privacy: .public) exited with code \(out.exitCode, privacy: .public)") #endif if path != "/usr/bin/env", out.stderr.contains("App Sandbox") || out.stderr.contains("xcrun: error") { blockedExecutables.insert(path) } lastError = out // Try next candidate } if let e = lastError { Self.log.error("git failed: code=\(e.exitCode, privacy: .public), stderr=\(e.stderr, privacy: .public)") let text = e.stderr.isEmpty ? e.stdout : e.stderr lastFailureDescription = text.isEmpty ? "git exited with code \(e.exitCode)" : text return e } let fallback = ProcOut(stdout: "", stderr: "failed to launch git", exitCode: -1) Self.log.error("git failed to launch via all candidates") lastFailureDescription = "git failed to launch via all candidates" return fallback } } ================================================ FILE: services/GlobalSearchService.swift ================================================ import Foundation #if canImport(Darwin) import Darwin #endif actor GlobalSearchService { struct Request: Sendable { let term: String let scope: GlobalSearchScope let paths: GlobalSearchPaths let maxMatchesPerFile: Int let batchSize: Int let limit: Int init( term: String, scope: GlobalSearchScope, paths: GlobalSearchPaths, maxMatchesPerFile: Int = 3, batchSize: Int = 12, limit: Int = 200 ) { self.term = term self.scope = scope.isEmpty ? .all : scope self.paths = paths self.maxMatchesPerFile = max(maxMatchesPerFile, 1) self.batchSize = max(batchSize, 1) self.limit = max(limit, 1) } } private let chunkSize = 128 * 1024 private let snippetRadius = 90 private let fm = FileManager.default private var ripgrepProcess: Process? private struct SearchPattern: Sendable { let raw: String let tokens: [String] let ripgrepPattern: String let requiresPCRE: Bool func score(in text: String) -> Double { guard !text.isEmpty else { return 0 } if tokens.isEmpty { return scoreSingleToken(in: text) } return scoreMultiToken(in: text) } private func scoreSingleToken(in text: String) -> Double { guard let range = text.range( of: raw, options: [.caseInsensitive, .diacriticInsensitive] ) else { return 0 } let offset = text.distance(from: text.startIndex, to: range.lowerBound) let anchorBoost = 1.0 / Double(offset + 1) return min(1.0, 0.5 + anchorBoost * 0.5) } private func scoreMultiToken(in text: String) -> Double { let lowered = text.lowercased() let matches = tokens.compactMap { token -> TokenWindow? in guard let range = lowered.range(of: token) else { return nil } let start = lowered.distance(from: lowered.startIndex, to: range.lowerBound) let end = lowered.distance(from: lowered.startIndex, to: range.upperBound) return TokenWindow(start: start, end: end) } guard !matches.isEmpty else { return 0 } let coverage = Double(matches.count) / Double(tokens.count) let minIndex = matches.map(\.start).min() ?? 0 let maxIndex = matches.map(\.end).max() ?? minIndex let span = max(1, maxIndex - minIndex) let tightness = Double(matches.count) / Double(span + matches.count) var inversions = 0 for pair in zip(matches, matches.dropFirst()) { if pair.1.start < pair.0.start { inversions += 1 } } let orderScore = 1.0 - (Double(inversions) / Double(max(1, matches.count - 1))) let anchor = 1.0 / Double(minIndex + 1) let combined = (coverage * 0.35) + (tightness * 0.35) + (orderScore * 0.2) + (anchor * 0.1) return min(1.0, max(0.0, combined)) } private struct TokenWindow { let start: Int let end: Int } } enum RipgrepError: Error { case executableMissing case failed(String) } func cancelRipgrep() { ripgrepProcess?.terminate() ripgrepProcess = nil } func search( request: Request, onBatch: @Sendable ([GlobalSearchHit]) async -> Void, onProgress: @Sendable (GlobalSearchProgress) async -> Void, onCompletion: @Sendable () async -> Void ) async { let trimmed = request.term.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { await onCompletion() return } let pattern = buildSearchPattern(for: trimmed) let targets = buildTargets(for: request) guard !targets.allPaths.isEmpty else { await onCompletion() return } do { try await runRipgrep( pattern: pattern, request: request, targets: targets, onBatch: onBatch, onProgress: onProgress ) } catch RipgrepError.executableMissing { await onProgress( .ripgrep( message: "ripgrep not found, falling back to built-in scanner", files: 0, matches: 0, finished: false ) ) await runFallbackScan( pattern: pattern, request: request, targets: targets, onBatch: onBatch ) await onProgress( .ripgrep( message: "Built-in scan finished", files: 0, matches: 0, finished: true ) ) } catch { await onProgress( .ripgrep( message: "\(error.localizedDescription)", files: 0, matches: 0, finished: true ) ) } await onCompletion() } // MARK: - Ripgrep integration private struct SearchTargets { var sessionRoots: [URL] var noteRoot: URL? var projectMetadataRoot: URL? var taskMetadataRoot: URL? var allPaths: [URL] { var paths = sessionRoots if let noteRoot { paths.append(noteRoot) } if let projectMetadataRoot { paths.append(projectMetadataRoot) } if let taskMetadataRoot { paths.append(taskMetadataRoot) } return paths } } private func buildTargets(for request: Request) -> SearchTargets { var sessions: [URL] = [] if request.scope.contains(.sessions) { sessions = request.paths.sessionRoots.filter { directoryAccessible($0) } } var noteRoot: URL? = nil if request.scope.contains(.notes), let candidate = request.paths.notesRoot?.resolvingSymlinksInPath(), directoryAccessible(candidate) { noteRoot = candidate } var projectRoot: URL? = nil if request.scope.contains(.projects), let candidate = request.paths.projectMetadataRoot?.resolvingSymlinksInPath(), directoryAccessible(candidate) { projectRoot = candidate } var taskRoot: URL? = nil if request.scope.contains(.tasks), let candidate = request.paths.taskMetadataRoot?.resolvingSymlinksInPath(), directoryAccessible(candidate) { taskRoot = candidate } return SearchTargets( sessionRoots: sessions, noteRoot: noteRoot, projectMetadataRoot: projectRoot, taskMetadataRoot: taskRoot) } private func runRipgrep( pattern: SearchPattern, request: Request, targets: SearchTargets, onBatch: @Sendable ([GlobalSearchHit]) async -> Void, onProgress: @Sendable (GlobalSearchProgress) async -> Void ) async throws { let roots = targets.allPaths guard !roots.isEmpty else { return } var env = ProcessInfo.processInfo.environment let defaultPath = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" let existingPath = env["PATH"] ?? ProcessInfo.processInfo.environment["PATH"] env["PATH"] = [defaultPath, existingPath] .compactMap { $0 } .joined(separator: ":") let process = Process() process.environment = env process.executableURL = URL(fileURLWithPath: "/usr/bin/env") var args = [ "rg", "--json", "--ignore-case", "--hidden", "--follow", "--no-heading", "--color", "never", ] if pattern.requiresPCRE { args.append("--pcre2") } else { args.append("--fixed-strings") } args.append(pattern.ripgrepPattern) args.append(contentsOf: roots.map { $0.path }) process.arguments = args let stdout = Pipe() let stderr = Pipe() process.standardOutput = stdout process.standardError = stderr do { try process.run() } catch { if (error as NSError).code == ENOENT { throw RipgrepError.executableMissing } throw error } ripgrepProcess = process var pending: [GlobalSearchHit] = [] var delivered = 0 var filesProcessed = 0 var matchesFound = 0 var cancelled = false var terminatedByLimit = false await onProgress( .ripgrep( message: "Searching with ripgrep…", files: filesProcessed, matches: matchesFound, finished: false ) ) for try await rawLine in stdout.fileHandleForReading.bytes.lines { if Task.isCancelled { cancelled = true process.terminate() break } let line = String(rawLine) guard !line.isEmpty else { continue } guard let event = parseRipgrepEvent( from: line, request: request, targets: targets, pattern: pattern ) else { continue } switch event { case .match(let hit): guard delivered < request.limit else { terminatedByLimit = true process.terminate() break } pending.append(hit) delivered += 1 matchesFound += 1 if pending.count >= request.batchSize { await onBatch(pending) pending.removeAll(keepingCapacity: true) } case .fileEnd: filesProcessed += 1 await onProgress( .ripgrep( message: "Scanned \(filesProcessed) files", files: filesProcessed, matches: matchesFound, finished: false ) ) } } if !pending.isEmpty { await onBatch(pending) } process.waitUntilExit() ripgrepProcess = nil let normalExit = process.terminationReason == .exit && process.terminationStatus == 0 if normalExit || cancelled || terminatedByLimit { await onProgress( .ripgrep( message: cancelled ? "Search cancelled" : (terminatedByLimit ? "Reached result limit" : "Search finished"), files: filesProcessed, matches: matchesFound, finished: true, cancelled: cancelled ) ) return } let errData = try? stderr.fileHandleForReading.readToEnd() let message = errData.flatMap { String(data: $0, encoding: .utf8) } ?? "ripgrep exit code \(process.terminationStatus)" throw RipgrepError.failed(message.trimmingCharacters(in: .whitespacesAndNewlines)) } private enum RipgrepParsedEvent { case match(GlobalSearchHit) case fileEnd } private func parseRipgrepEvent( from line: String, request: Request, targets: SearchTargets, pattern: SearchPattern ) -> RipgrepParsedEvent? { guard let data = line.data(using: .utf8), let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let type = root["type"] as? String else { return nil } switch type { case "match": guard let payload = root["data"] as? [String: Any] else { return nil } guard let pathDict = payload["path"] as? [String: Any], let pathText = pathDict["text"] as? String, let linesDict = payload["lines"] as? [String: Any], let lineText = linesDict["text"] as? String, let submatches = payload["submatches"] as? [[String: Any]], let first = submatches.first, let start = first["start"] as? Int, let end = first["end"] as? Int else { return nil } guard let kind = classify(path: pathText, request: request, targets: targets) else { return nil } let snippet: GlobalSearchSnippet if let range = lineText.rangeFromByteOffsets(start: start, end: end) { snippet = GlobalSearchSnippetFactory.snippet(in: lineText, matchRange: range) } else { snippet = GlobalSearchSnippet(text: lineText.sanitizedSnippetText(), highlightRange: nil) } let fileURL = URL(fileURLWithPath: pathText) switch kind { case .session: let fallback = fileURL.deletingPathExtension().lastPathComponent let lineNumber = payload["line_number"] as? Int ?? 0 let id = "\(pathText)#\(lineNumber):\(start)" let matchScore = Self.combinedScore(for: lineText, pattern: pattern) let hit = GlobalSearchHit( id: id, kind: .session, fileURL: fileURL, snippet: snippet, fallbackTitle: fallback, metadataDate: nil, score: matchScore ) return .match(hit) case .note: guard let note = loadNote(at: fileURL) else { return nil } let matchScore = Self.combinedScore( for: snippet.text, pattern: pattern, metadataDate: note.updatedAt ) let hit = GlobalSearchHit( id: fileURL.path, kind: .note, fileURL: fileURL, snippet: snippet, fallbackTitle: note.title ?? note.id, note: note, metadataDate: note.updatedAt, score: matchScore ) return .match(hit) case .project: guard let projectInfo = loadProject(at: fileURL) else { return nil } let matchScore = Self.combinedScore( for: snippet.text, pattern: pattern, metadataDate: projectInfo.updatedAt ) let hit = GlobalSearchHit( id: fileURL.path, kind: .project, fileURL: fileURL, snippet: snippet, fallbackTitle: projectInfo.project.name, project: projectInfo.project, metadataDate: projectInfo.updatedAt, score: matchScore ) return .match(hit) case .task: guard let taskInfo = loadTask(at: fileURL) else { return nil } let matchScore = Self.combinedScore( for: snippet.text, pattern: pattern, metadataDate: taskInfo.updatedAt ) let hit = GlobalSearchHit( id: fileURL.path, kind: .task, fileURL: fileURL, snippet: snippet, fallbackTitle: taskInfo.title, task: taskInfo, metadataDate: taskInfo.updatedAt, score: matchScore ) return .match(hit) } case "end": return .fileEnd default: return nil } } private func classify(path: String, request: Request, targets: SearchTargets) -> GlobalSearchResultKind? { if let noteRoot = targets.noteRoot?.path.normalizedDirectoryPath, path.hasPrefix(noteRoot) { return request.scope.contains(.notes) ? .note : nil } if let projectRoot = targets.projectMetadataRoot?.path.normalizedDirectoryPath, path.hasPrefix(projectRoot) { return request.scope.contains(.projects) ? .project : nil } if let taskRoot = targets.taskMetadataRoot?.path.normalizedDirectoryPath, path.hasPrefix(taskRoot) { return request.scope.contains(.tasks) ? .task : nil } return request.scope.contains(.sessions) ? .session : nil } // MARK: - Fallback scanner private func runFallbackScan( pattern: SearchPattern, request: Request, targets: SearchTargets, onBatch: @Sendable ([GlobalSearchHit]) async -> Void ) async { let workItems = fallbackWorkItems(for: request, targets: targets) guard !workItems.isEmpty else { return } await withTaskGroup(of: [GlobalSearchHit].self) { group in for item in workItems { group.addTask { [chunkSize, snippetRadius] in if Task.isCancelled { return [] } switch item { case .session(let url): return Self.scanSession( url: url, pattern: pattern, chunkSize: chunkSize, snippetRadius: snippetRadius, maxMatches: request.maxMatchesPerFile ) case .note(let url): return Self.scanNote(url: url, pattern: pattern) case .project(let url): return Self.scanProject(url: url, pattern: pattern) case .task(let url): return Self.scanTask(url: url, pattern: pattern) } } } var delivered = 0 var pending: [GlobalSearchHit] = [] for await var hits in group { if hits.isEmpty { continue } let remaining = request.limit - delivered - pending.count if remaining <= 0 { group.cancelAll() break } if hits.count > remaining { hits = Array(hits.prefix(remaining)) } pending.append(contentsOf: hits) if pending.count >= request.batchSize { delivered += pending.count await onBatch(pending) pending.removeAll(keepingCapacity: true) } } if !pending.isEmpty { await onBatch(pending) } } } private enum WorkItem: Hashable { case session(URL) case note(URL) case project(URL) case task(URL) } private func fallbackWorkItems(for request: Request, targets: SearchTargets) -> [WorkItem] { var items: [WorkItem] = [] var seen = Set() if request.scope.contains(.sessions) { for root in targets.sessionRoots { guard let enumerator = fm.enumerator( at: root, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], options: [.skipsHiddenFiles] ) else { continue } for case let url as URL in enumerator { let ext = url.pathExtension.lowercased() if ext != "jsonl" && ext != "json" { continue } let path = url.path if seen.contains(path) { continue } seen.insert(path) items.append(.session(url)) } } } if request.scope.contains(.notes), let noteRoot = targets.noteRoot, let enumerator = fm.enumerator( at: noteRoot, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles] ) { for case let url as URL in enumerator { if url.pathExtension.lowercased() != "json" { continue } let path = url.path if seen.contains(path) { continue } seen.insert(path) items.append(.note(url)) } } if request.scope.contains(.projects), let metaRoot = targets.projectMetadataRoot, let enumerator = fm.enumerator( at: metaRoot, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles] ) { for case let url as URL in enumerator { if url.pathExtension.lowercased() != "json" { continue } let path = url.path if seen.contains(path) { continue } seen.insert(path) items.append(.project(url)) } } if request.scope.contains(.tasks), let metaRoot = targets.taskMetadataRoot, let enumerator = fm.enumerator( at: metaRoot, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles] ) { for case let url as URL in enumerator { if url.pathExtension.lowercased() != "json" { continue } let path = url.path if seen.contains(path) { continue } seen.insert(path) items.append(.task(url)) } } return items } // MARK: - Helpers private func directoryAccessible(_ url: URL) -> Bool { var isDir: ObjCBool = false guard fm.fileExists(atPath: url.path, isDirectory: &isDir), isDir.boolValue else { return false } return true } private func loadNote(at url: URL) -> SessionNote? { guard let data = try? Data(contentsOf: url) else { return nil } let decoder = JSONDecoder() return try? decoder.decode(SessionNote.self, from: data) } private func loadProject(at url: URL) -> (project: Project, updatedAt: Date?)? { guard let data = try? Data(contentsOf: url) else { return nil } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 guard let meta = try? decoder.decode(ProjectMeta.self, from: data) else { return nil } return (meta.asProject(), meta.updatedAt) } private func loadTask(at url: URL) -> CodMateTask? { guard let data = try? Data(contentsOf: url) else { return nil } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return try? decoder.decode(CodMateTask.self, from: data) } nonisolated private static func scanSession( url: URL, pattern: SearchPattern, chunkSize: Int, snippetRadius: Int, maxMatches: Int ) -> [GlobalSearchHit] { guard let handle = try? FileHandle(forReadingFrom: url) else { return [] } defer { try? handle.close() } var hits: [GlobalSearchHit] = [] var carry = "" let fallback = url.deletingPathExtension().lastPathComponent let attributes = (try? url.resourceValues(forKeys: [.contentModificationDateKey])) let modDate = attributes?.contentModificationDate var eofReached = false while hits.count < maxMatches && !eofReached { if Task.isCancelled { break } autoreleasepool { guard let chunk = try? handle.read(upToCount: chunkSize), !chunk.isEmpty else { eofReached = true return } guard let string = String(data: chunk, encoding: .utf8) else { carry.removeAll(keepingCapacity: false) eofReached = true return } let buffer = carry + string var searchRange = buffer.startIndex.. [GlobalSearchHit] { guard let note = loadNoteStatic(url: url) else { return [] } let combined = [note.title, note.comment].compactMap { $0 }.joined(separator: "\n") guard let range = findMatchRange(in: combined, pattern: pattern) else { return [] } let snippet = GlobalSearchSnippetFactory.snippet(in: combined, matchRange: range) return [ GlobalSearchHit( id: url.path, kind: .note, fileURL: url, snippet: snippet, fallbackTitle: note.title ?? note.id, note: note, metadataDate: note.updatedAt, score: Self.combinedScore( for: snippet.text, pattern: pattern, metadataDate: note.updatedAt ) ) ] } nonisolated private static func loadNoteStatic(url: URL) -> SessionNote? { guard let data = try? Data(contentsOf: url) else { return nil } let decoder = JSONDecoder() return try? decoder.decode(SessionNote.self, from: data) } nonisolated private static func scanProject(url: URL, pattern: SearchPattern) -> [GlobalSearchHit] { guard let data = try? Data(contentsOf: url) else { return [] } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 guard let meta = try? decoder.decode(ProjectMeta.self, from: data) else { return [] } let project = meta.asProject() let fields = [project.name, project.directory, project.overview, project.instructions] .compactMap { $0 } .joined(separator: "\n") guard let range = findMatchRange(in: fields, pattern: pattern) else { return [] } let snippet = GlobalSearchSnippetFactory.snippet(in: fields, matchRange: range) return [ GlobalSearchHit( id: url.path, kind: .project, fileURL: url, snippet: snippet, fallbackTitle: project.name, project: project, metadataDate: meta.updatedAt, score: Self.combinedScore( for: snippet.text, pattern: pattern, metadataDate: meta.updatedAt ) ) ] } nonisolated private static func scanTask(url: URL, pattern: SearchPattern) -> [GlobalSearchHit] { guard let data = try? Data(contentsOf: url) else { return [] } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 guard let task = try? decoder.decode(CodMateTask.self, from: data) else { return [] } let fields = [task.title, task.description, task.tags.joined(separator: " "), task.agentsConfig] .compactMap { $0 } .joined(separator: "\n") guard let range = findMatchRange(in: fields, pattern: pattern) else { return [] } let snippet = GlobalSearchSnippetFactory.snippet(in: fields, matchRange: range) return [ GlobalSearchHit( id: url.path, kind: .task, fileURL: url, snippet: snippet, fallbackTitle: task.title, task: task, metadataDate: task.updatedAt, score: Self.combinedScore( for: snippet.text, pattern: pattern, metadataDate: task.updatedAt ) ) ] } } extension GlobalSearchScope { fileprivate func contains(kind: GlobalSearchResultKind) -> Bool { switch kind { case .session: return contains(.sessions) case .note: return contains(.notes) case .project: return contains(.projects) case .task: return contains(.tasks) } } } extension String { fileprivate var normalizedDirectoryPath: String { if hasSuffix("/") { return self } return self + "/" } } // MARK: - Search pattern helpers extension GlobalSearchService { private func buildSearchPattern(for term: String) -> SearchPattern { let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines) let parts = trimmed.split { $0.isWhitespace } .map { String($0) } .filter { !$0.isEmpty } if parts.count <= 1 { return SearchPattern(raw: trimmed, tokens: [], ripgrepPattern: trimmed, requiresPCRE: false) } let escaped = parts.map { NSRegularExpression.escapedPattern(for: $0) } let regex = escaped.map { "(?=.*\($0))" }.joined() + ".*" return SearchPattern( raw: trimmed, tokens: parts.map { $0.lowercased() }, ripgrepPattern: regex, requiresPCRE: true ) } private static func findMatchRange( in text: String, pattern: SearchPattern ) -> Range? { return findMatchRange(in: text, range: text.startIndex.., pattern: SearchPattern ) -> Range? { if pattern.tokens.isEmpty { return text.range( of: pattern.raw, options: [.caseInsensitive, .diacriticInsensitive], range: range ) } let lowered = text.lowercased() guard pattern.tokens.allSatisfy({ lowered.contains($0) }) else { return nil } if let first = pattern.tokens.first { return text.range( of: first, options: [.caseInsensitive, .diacriticInsensitive], range: range ) } return nil } private static func combinedScore( for text: String, pattern: SearchPattern, metadataDate: Date? = nil, positionalBoost: Double = 0 ) -> Double { var score = pattern.score(in: text) if let metadataDate { score += recencyBoost(for: metadataDate) } return score + positionalBoost } private static func recencyBoost(for date: Date) -> Double { let elapsed = max(0, Date().timeIntervalSince(date)) let days = elapsed / 86_400 let normalized = max(0, 1 - min(1, days / 30)) return normalized * 0.25 } } ================================================ FILE: services/HooksImportService.swift ================================================ import Foundation enum HooksImportService { static func scan(scope: ExtensionsImportScope) async -> [HookImportCandidate] { switch scope { case .home: return await scanHome() case .project: return [] } } private static func scanHome() async -> [HookImportCandidate] { var aggregated: [String: HookImportCandidate] = [:] // Codex notify -> Stop if SessionPreferencesStore.isCLIEnabled(.codex) { let codex = CodexConfigService() let notify = await codex.getNotifyArray() if let program = notify.first, !program.isEmpty, !program.contains("codmate-notify") { let args = Array(notify.dropFirst()) let rule = HookRule( name: HookEventCatalog.defaultName( event: "Stop", matcher: nil, command: HookCommand(command: program, args: args.isEmpty ? nil : args) ), event: "Stop", commands: [HookCommand(command: program, args: args.isEmpty ? nil : args)], enabled: true, targets: HookTargets(codex: true, claude: false, gemini: false), source: "import" ) upsertCandidate( into: &aggregated, rule: rule, provider: "Codex", sourcePath: CodexConfigService.Paths.default().configURL.path ) } } // Claude hooks if SessionPreferencesStore.isCLIEnabled(.claude) { let claude = ClaudeSettingsService() let rules = await claude.importHooksAsCodMateRules() for rule in rules { upsertCandidate( into: &aggregated, rule: rule, provider: "Claude", sourcePath: ClaudeSettingsService.Paths.default().file.path ) } } // Gemini hooks if SessionPreferencesStore.isCLIEnabled(.gemini) { let gemini = GeminiSettingsService() let rules = await gemini.importHooksAsCodMateRules() for rule in rules { upsertCandidate( into: &aggregated, rule: rule, provider: "Gemini", sourcePath: gemini.settingsFileURL.path ) } } var candidates = Array(aggregated.values) // Detect name collisions within import list. let nameCounts = Dictionary(grouping: candidates, by: { $0.rule.name.lowercased() }) .mapValues { $0.count } for idx in candidates.indices { let key = candidates[idx].rule.name.lowercased() candidates[idx].hasNameCollision = (nameCounts[key] ?? 0) > 1 } return candidates.sorted { a, b in a.rule.name.localizedCaseInsensitiveCompare(b.rule.name) == .orderedAscending } } private static func upsertCandidate( into aggregated: inout [String: HookImportCandidate], rule: HookRule, provider: String, sourcePath: String ) { let signature = hookSignature(rule) let normalizedRule = normalizedImportRule(rule, provider: provider) if var existing = aggregated[signature] { // Merge sources and targets. if !existing.sources.contains(provider) { existing.sources.append(provider) } existing.sourcePaths[provider] = sourcePath existing.rule.targets = mergeTargets(existing.rule.targets, normalizedRule.targets) aggregated[signature] = existing } else { let candidate = HookImportCandidate( id: UUID(), rule: normalizedRule, sources: [provider], sourcePaths: [provider: sourcePath], isSelected: true, hasConflict: false, hasNameCollision: false, resolution: .skip, renameName: normalizedRule.name, signature: signature ) aggregated[signature] = candidate } } private static func normalizedImportRule(_ rule: HookRule, provider: String) -> HookRule { var normalized = rule normalized.id = UUID().uuidString normalized.source = "import" normalized.createdAt = Date() normalized.updatedAt = Date() if normalized.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { normalized.name = HookEventCatalog.defaultName( event: normalized.event, matcher: normalized.matcher, command: normalized.commands.first ) } switch provider { case "Codex": normalized.targets = HookTargets(codex: true, claude: false, gemini: false) case "Claude": normalized.targets = HookTargets(codex: false, claude: true, gemini: false) case "Gemini": normalized.targets = HookTargets(codex: false, claude: false, gemini: true) default: break } return normalized } private static func mergeTargets(_ lhs: HookTargets?, _ rhs: HookTargets?) -> HookTargets? { let a = lhs ?? HookTargets() let b = rhs ?? HookTargets() let merged = HookTargets( codex: a.codex || b.codex, claude: a.claude || b.claude, gemini: a.gemini || b.gemini ) return merged.allEnabled ? nil : merged } static func hookSignature(_ rule: HookRule) -> String { let event = rule.event.trimmingCharacters(in: .whitespacesAndNewlines) let matcher = rule.matcher?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let commands = rule.commands.map { cmd in let command = cmd.command.trimmingCharacters(in: .whitespacesAndNewlines) let args = (cmd.args ?? []).map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.joined(separator: "\u{1f}") let envPairs = (cmd.env ?? [:]).sorted(by: { $0.key < $1.key }) .map { "\($0.key)=\($0.value)" } .joined(separator: "\u{1f}") let timeout = cmd.timeoutMs.map(String.init) ?? "" return [command, args, envPairs, timeout].joined(separator: "\u{1e}") } return ([event, matcher] + commands).joined(separator: "\u{1d}") } } ================================================ FILE: services/HooksStore.swift ================================================ import Foundation actor HooksStore { struct Paths { let home: URL; let fileURL: URL } static func defaultPaths(fileManager: FileManager = .default) -> Paths { let home = SessionPreferencesStore.getRealUserHomeURL() .appendingPathComponent(".codmate", isDirectory: true) return Paths(home: home, fileURL: home.appendingPathComponent("hooks.json", isDirectory: false)) } private let fm: FileManager private let paths: Paths private var cache: [HookRule]? = nil init(paths: Paths = HooksStore.defaultPaths(), fileManager: FileManager = .default) { self.paths = paths self.fm = fileManager } func list() -> [HookRule] { load() } func upsert(_ rule: HookRule) throws { var list = load() if let idx = list.firstIndex(where: { $0.id == rule.id }) { list[idx] = rule } else { list.append(rule) } try save(list) } func upsertMany(_ rules: [HookRule]) throws { var map: [String: HookRule] = [:] for item in load() { map[item.id] = item } for item in rules { map[item.id] = item } // Preserve stable ordering by updatedAt, then name. let sorted = map.values.sorted { lhs, rhs in if lhs.updatedAt != rhs.updatedAt { return lhs.updatedAt > rhs.updatedAt } return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending } try save(sorted) } func delete(id: String) throws { var list = load() list.removeAll { $0.id == id } try save(list) } func update(id: String, mutate: (inout HookRule) -> Void) throws { var list = load() guard let idx = list.firstIndex(where: { $0.id == id }) else { return } var updated = list[idx] mutate(&updated) list[idx] = updated try save(list) } // MARK: - Private private func load() -> [HookRule] { if let cache { return cache } guard let data = try? Data(contentsOf: paths.fileURL) else { cache = [] return [] } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 if let list = try? decoder.decode([HookRule].self, from: data) { cache = list return list } cache = [] return [] } private func save(_ list: [HookRule]) throws { try fm.createDirectory(at: paths.home, withIntermediateDirectories: true) let tmp = paths.fileURL.appendingPathExtension("tmp") let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes] encoder.dateEncodingStrategy = .iso8601 let data = try encoder.encode(list) try data.write(to: tmp, options: .atomic) if fm.fileExists(atPath: paths.fileURL.path) { try fm.removeItem(at: paths.fileURL) } try fm.moveItem(at: tmp, to: paths.fileURL) cache = list } } ================================================ FILE: services/HooksSyncService.swift ================================================ import Foundation actor HooksSyncService { func syncGlobal(rules: [HookRule]) async -> [HookSyncWarning] { var warnings: [HookSyncWarning] = [] if SessionPreferencesStore.isCLIEnabled(.codex) { let service = CodexConfigService() do { warnings.append(contentsOf: try await service.applyHooksFromCodMate(rules)) } catch { warnings.append(HookSyncWarning(provider: .codex, message: "Failed to apply hooks: \(error.localizedDescription)")) } } if SessionPreferencesStore.isCLIEnabled(.claude) { let service = ClaudeSettingsService() do { warnings.append(contentsOf: try await service.applyHooksFromCodMate(rules)) } catch { warnings.append(HookSyncWarning(provider: .claude, message: "Failed to apply hooks: \(error.localizedDescription)")) } } if SessionPreferencesStore.isCLIEnabled(.gemini) { let service = GeminiSettingsService() do { warnings.append(contentsOf: try await service.applyHooksFromCodMate(rules)) } catch { warnings.append(HookSyncWarning(provider: .gemini, message: "Failed to apply hooks: \(error.localizedDescription)")) } } return warnings } } ================================================ FILE: services/InternalSkillRunner.swift ================================================ import Foundation struct SkillRunResult: Sendable { var outputText: String var stderrText: String var exitCode: Int32 } enum InternalSkillRunnerError: LocalizedError { case missingSkill case missingInvocation case invalidInput case executionFailed(String) case outputMissing(String) var errorDescription: String? { switch self { case .missingSkill: return "Internal skill not available." case .missingInvocation: return "No CLI invocation is configured for this provider." case .invalidInput: return "Failed to build skill input." case .executionFailed(let message): return "Skill execution failed: \(message)" case .outputMissing(let details): return "Skill did not return any output.\n\(details)" } } } actor InternalSkillRunner { private let registry = InternalSkillsRegistry() private let docsService = WizardDocsService() func run( feature: WizardFeature, provider: SessionSource.Kind, conversation: [WizardMessage], defaultExecutable: String, progress: @escaping (WizardRunEvent) -> Void ) async throws -> SkillRunResult { guard let skill = await registry.skill(for: feature) else { throw InternalSkillRunnerError.missingSkill } guard let invocation = skill.definition.invocations.first(where: { $0.provider == provider }) else { throw InternalSkillRunnerError.missingInvocation } let input = try await buildInput( feature: feature, provider: provider, conversation: conversation, skill: skill ) let tempRoot = FileManager.default.temporaryDirectory .appendingPathComponent("codmate-skill-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: tempRoot, withIntermediateDirectories: true) let skillDir = tempRoot.appendingPathComponent("skill", isDirectory: true) try FileManager.default.createDirectory(at: skillDir, withIntermediateDirectories: true) try writeAssets(skill, to: skillDir) let inputURL = tempRoot.appendingPathComponent("input.json") let outputURL = tempRoot.appendingPathComponent("output.json") let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes] encoder.dateEncodingStrategy = .iso8601 let data = try encoder.encode(input) try data.write(to: inputURL, options: .atomic) let promptText = buildPromptText(payloadData: data) let promptData = promptText.data(using: .utf8) ?? data let args = resolveArgs( invocation.args, skillDir: skillDir, inputFile: inputURL, outputFile: outputURL, schemaFile: skillDir.appendingPathComponent("schema.json"), promptFile: skillDir.appendingPathComponent("prompt.md"), skillFile: skillDir.appendingPathComponent("SKILL.md") ) let workingDirectory = InternalWizardPaths.ensureProjectRootExists() await MainActor.run { progress(WizardRunEvent(message: "Invoking CLI skill", kind: .status)) } let exec = invocation.executable?.trimmingCharacters(in: .whitespacesAndNewlines) let executable = (exec?.isEmpty == false) ? exec! : defaultExecutable let result = try await runProcess( executable: executable, args: args, input: invocation.inputMode == .stdin ? promptData : nil, timeout: invocation.timeoutSeconds, workingDirectory: workingDirectory, progress: progress ) if result.exitCode != 0 { let debug = formatDebugReport( executable: executable, args: args, workingDirectory: workingDirectory, result: result, outputFile: invocation.outputMode == .file ? outputURL : nil ) throw InternalSkillRunnerError.executionFailed(debug) } let outputText: String switch invocation.outputMode { case .stdout: outputText = result.outputText case .file: outputText = (try? String(contentsOf: outputURL, encoding: .utf8)) ?? "" } if outputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let debug = formatDebugReport( executable: executable, args: args, workingDirectory: workingDirectory, result: result, outputFile: invocation.outputMode == .file ? outputURL : nil ) throw InternalSkillRunnerError.outputMissing(debug) } return SkillRunResult(outputText: outputText, stderrText: result.stderrText, exitCode: result.exitCode) } // MARK: - Input Build private struct WizardSkillInput: Codable { var feature: WizardFeature var provider: String var appLanguage: String var appLanguageName: String var request: String var conversation: [WizardMessage] var schema: String? var prompt: String? var catalogs: [String: [String]]? var docs: [WizardDocSnippet] } private func buildInput( feature: WizardFeature, provider: SessionSource.Kind, conversation: [WizardMessage], skill: InternalSkillAsset ) async throws -> WizardSkillInput { guard let last = conversation.last(where: { $0.role == .user }) else { throw InternalSkillRunnerError.invalidInput } let language = resolveAppLanguage() let catalogs = buildCatalogs(for: feature) let keywords = catalogs.flatMap { $0.value } let docs = await docsService.snippets( feature: feature, provider: provider, overrides: skill.docsOverrides, keywords: keywords ) return WizardSkillInput( feature: feature, provider: provider.rawValue, appLanguage: language.code, appLanguageName: language.name, request: last.text, conversation: conversation, schema: skill.schema, prompt: skill.prompt, catalogs: catalogs.isEmpty ? nil : catalogs, docs: docs ) } private func buildCatalogs(for feature: WizardFeature) -> [String: [String]] { switch feature { case .hooks: let events = HookEventCatalog.all.map { $0.name } let vars = HookCommandVariableCatalog.all.map { $0.name } return ["events": events, "variables": vars] default: return [:] } } // MARK: - Assets private func writeAssets(_ skill: InternalSkillAsset, to dir: URL) throws { if let skillMarkdown = skill.skillMarkdown { try skillMarkdown.write(to: dir.appendingPathComponent("SKILL.md"), atomically: true, encoding: .utf8) } if let prompt = skill.prompt { try prompt.write(to: dir.appendingPathComponent("prompt.md"), atomically: true, encoding: .utf8) } if let schema = skill.schema { try schema.write(to: dir.appendingPathComponent("schema.json"), atomically: true, encoding: .utf8) } } private func buildPromptText(payloadData: Data) -> String { let payload = String(data: payloadData, encoding: .utf8) ?? "{}" return """ You are a CodMate internal wizard skill. Follow the instructions in the JSON payload field "prompt". All user-facing text must use the language specified by payload.appLanguage (BCP-47 code) and payload.appLanguageName (English name of the language). You do not have tool access. Do not invoke tools, shell commands, or web browsing. Use the payload field "schema" as the required JSON Schema for your output. Use "docs" and "catalogs" for reference and "conversation" for context. If the request is unclear, set mode="question" and provide follow-up questions. Return only JSON. Do not include markdown or extra text. JSON payload: \(payload) """ } private func resolveAppLanguage() -> (code: String, name: String) { let preferred = Bundle.main.preferredLocalizations.first ?? Locale.preferredLanguages.first ?? "en" let locale = Locale(identifier: preferred) let languageCode = locale.language.languageCode?.identifier ?? preferred let englishName = Locale(identifier: "en").localizedString(forLanguageCode: languageCode) ?? preferred return (preferred, englishName) } // MARK: - Args private func resolveArgs( _ args: [String], skillDir: URL, inputFile: URL, outputFile: URL, schemaFile: URL, promptFile: URL, skillFile: URL ) -> [String] { args.map { raw in raw .replacingOccurrences(of: "{{skillDir}}", with: skillDir.path) .replacingOccurrences(of: "{{inputFile}}", with: inputFile.path) .replacingOccurrences(of: "{{outputFile}}", with: outputFile.path) .replacingOccurrences(of: "{{schemaFile}}", with: schemaFile.path) .replacingOccurrences(of: "{{promptFile}}", with: promptFile.path) .replacingOccurrences(of: "{{skillFile}}", with: skillFile.path) } } private func formatDebugReport( executable: String, args: [String], workingDirectory: URL, result: ProcessResult, outputFile: URL? ) -> String { let commandLine = ([executable] + args).joined(separator: " ") let stderr = truncate(result.stderrText, limit: 8000) let stdout = truncate(result.outputText, limit: 8000) var fileOutput = "" if let outputFile, let text = try? String(contentsOf: outputFile, encoding: .utf8), !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { fileOutput = truncate(text, limit: 8000) } var lines: [String] = [] lines.append("Command: \(commandLine)") lines.append("Workdir: \(workingDirectory.path)") lines.append("Exit code: \(result.exitCode)") if !stderr.isEmpty { lines.append("") lines.append("STDERR:") lines.append(stderr) } if !stdout.isEmpty { lines.append("") lines.append("STDOUT:") lines.append(stdout) } if !fileOutput.isEmpty { lines.append("") lines.append("OUTPUT FILE:") lines.append(fileOutput) } return lines.joined(separator: "\n") } private func truncate(_ text: String, limit: Int) -> String { guard text.count > limit else { return text } let head = text.prefix(limit) return "\(head)\n…(truncated)" } // MARK: - Process private struct ProcessResult { var outputText: String var stderrText: String var exitCode: Int32 } private enum OutputStream { case stdout case stderr } private final class OutputCollector { private let lock = NSLock() private var stdoutText: String = "" private var stderrText: String = "" private var stdoutRemainder: String = "" private var stderrRemainder: String = "" func append(_ data: Data, stream: OutputStream) -> [String] { let text = String(decoding: data, as: UTF8.self) guard !text.isEmpty else { return [] } lock.lock() defer { lock.unlock() } switch stream { case .stdout: stdoutText += text return splitLines(text, remainder: &stdoutRemainder) case .stderr: stderrText += text return splitLines(text, remainder: &stderrRemainder) } } func flush(stream: OutputStream) -> String? { lock.lock() defer { lock.unlock() } switch stream { case .stdout: guard !stdoutRemainder.isEmpty else { return nil } let line = stdoutRemainder stdoutRemainder = "" return line case .stderr: guard !stderrRemainder.isEmpty else { return nil } let line = stderrRemainder stderrRemainder = "" return line } } func snapshot() -> (stdout: String, stderr: String) { lock.lock() defer { lock.unlock() } return (stdoutText, stderrText) } private func splitLines(_ text: String, remainder: inout String) -> [String] { let combined = remainder + text let parts = combined.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) if combined.hasSuffix("\n") || combined.hasSuffix("\r") { remainder = "" return parts.map(String.init) } if let last = parts.last { remainder = String(last) return parts.dropLast().map(String.init) } remainder = combined return [] } } private func runProcess( executable: String, args: [String], input: Data?, timeout: Double?, workingDirectory: URL?, progress: @escaping (WizardRunEvent) -> Void ) async throws -> ProcessResult { let process = Process() var env = ProcessInfo.processInfo.environment let defaultPath = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" let existingPath = env["PATH"] env["PATH"] = [defaultPath, existingPath] .compactMap { $0?.isEmpty == false ? $0 : nil } .joined(separator: ":") process.environment = env process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = [executable] + args if let workingDirectory { process.currentDirectoryURL = workingDirectory } let stdout = Pipe() let stderr = Pipe() process.standardOutput = stdout process.standardError = stderr let collector = OutputCollector() let emit: (WizardRunEvent) -> Void = { event in DispatchQueue.main.async { progress(event) } } let emitLines: @Sendable (_ lines: [String], _ kind: WizardRunEvent.Kind) -> Void = { lines, kind in for line in lines { if line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { continue } emit(WizardRunEvent(message: line, kind: kind)) } } stdout.fileHandleForReading.readabilityHandler = { handle in let data = handle.availableData if data.isEmpty { handle.readabilityHandler = nil return } let lines = collector.append(data, stream: .stdout) emitLines(lines, .stdout) } stderr.fileHandleForReading.readabilityHandler = { handle in let data = handle.availableData if data.isEmpty { handle.readabilityHandler = nil return } let lines = collector.append(data, stream: .stderr) emitLines(lines, .stderr) } if input != nil { let stdin = Pipe() process.standardInput = stdin stdin.fileHandleForWriting.writeabilityHandler = { handle in handle.write(input!) handle.closeFile() stdin.fileHandleForWriting.writeabilityHandler = nil } } try process.run() if let timeout { Task.detached { try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) if process.isRunning { process.terminate() } } } process.waitUntilExit() stdout.fileHandleForReading.readabilityHandler = nil stderr.fileHandleForReading.readabilityHandler = nil if let remainingOut = try? stdout.fileHandleForReading.readToEnd(), !remainingOut.isEmpty { let lines = collector.append(remainingOut, stream: .stdout) emitLines(lines, .stdout) } if let remainingErr = try? stderr.fileHandleForReading.readToEnd(), !remainingErr.isEmpty { let lines = collector.append(remainingErr, stream: .stderr) emitLines(lines, .stderr) } if let last = collector.flush(stream: .stdout) { emitLines([last], .stdout) } if let last = collector.flush(stream: .stderr) { emitLines([last], .stderr) } let snapshot = collector.snapshot() return ProcessResult(outputText: snapshot.stdout, stderrText: snapshot.stderr, exitCode: process.terminationStatus) } } ================================================ FILE: services/InternalSkillsRegistry.swift ================================================ import Foundation actor InternalSkillsRegistry { private struct IndexedSkill: Hashable { let definition: InternalSkillDefinition let rootURL: URL } private let fileManager = FileManager.default private var cached: [WizardFeature: [IndexedSkill]] = [:] func skill(for feature: WizardFeature) -> InternalSkillAsset? { let list = loadSkills(for: feature) return list.first.map { materialize($0) } } func skills(for feature: WizardFeature) -> [InternalSkillAsset] { loadSkills(for: feature).map { materialize($0) } } // MARK: - Load private func loadSkills(for feature: WizardFeature) -> [IndexedSkill] { if let cached = cached[feature] { return cached } let bundled = loadIndex(from: bundledIndexURL(), baseURL: bundledRootURL()) let overrides = loadIndex(from: overrideIndexURL(), baseURL: overrideRootURL()) var map: [String: IndexedSkill] = [:] for item in bundled { map[item.definition.id] = item } for item in overrides { map[item.definition.id] = item } let merged = map.values.filter { $0.definition.feature == feature } .sorted { $0.definition.id < $1.definition.id } cached[feature] = merged return merged } private func loadIndex(from indexURL: URL?, baseURL: URL?) -> [IndexedSkill] { guard let indexURL, let baseURL else { return [] } guard let data = try? Data(contentsOf: indexURL) else { return [] } let decoder = JSONDecoder() let index = (try? decoder.decode(InternalSkillsIndex.self, from: data))?.skills ?? [] return index.map { def in let root = baseURL.appendingPathComponent(def.id, isDirectory: true) return IndexedSkill(definition: def, rootURL: root) } } private func bundledIndexURL() -> URL? { if let url = Bundle.main.url(forResource: "index", withExtension: "json", subdirectory: "payload/internal-skills") { return url } return devPayloadRootURL()? .appendingPathComponent("internal-skills", isDirectory: true) .appendingPathComponent("index.json", isDirectory: false) } private func bundledRootURL() -> URL? { if let url = Bundle.main.url(forResource: "internal-skills", withExtension: nil, subdirectory: "payload") { return url } return devPayloadRootURL()? .appendingPathComponent("internal-skills", isDirectory: true) } private func devPayloadRootURL() -> URL? { let cwd = URL(fileURLWithPath: fileManager.currentDirectoryPath, isDirectory: true) if let found = findPayloadRoot(startingAt: cwd) { return found } if let execURL = Bundle.main.executableURL { let execDir = execURL.deletingLastPathComponent() if let found = findPayloadRoot(startingAt: execDir) { return found } } return nil } private func findPayloadRoot(startingAt start: URL) -> URL? { var current = start for _ in 0..<6 { let candidate = current .appendingPathComponent("payload", isDirectory: true) .appendingPathComponent("internal-skills", isDirectory: true) .appendingPathComponent("index.json", isDirectory: false) if fileManager.fileExists(atPath: candidate.path) { return current.appendingPathComponent("payload", isDirectory: true) } current = current.deletingLastPathComponent() } return nil } private func overrideRootURL() -> URL? { let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first return base?.appendingPathComponent("CodMate", isDirectory: true) .appendingPathComponent("internal-skills", isDirectory: true) } private func overrideIndexURL() -> URL? { overrideRootURL()?.appendingPathComponent("index.json", isDirectory: false) } // MARK: - Materialize private func materialize(_ skill: IndexedSkill) -> InternalSkillAsset { let def = skill.definition let assets = def.assets ?? InternalSkillAssetPaths() let skillPath = assets.skill ?? "SKILL.md" let promptPath = assets.prompt ?? "prompt.md" let schemaPath = assets.schema ?? "schema.json" let docsPath = assets.docs ?? "docs.json" let skillMarkdown = readText(skill.rootURL.appendingPathComponent(skillPath)) let prompt = readText(skill.rootURL.appendingPathComponent(promptPath)) let schema = readText(skill.rootURL.appendingPathComponent(schemaPath)) let fileOverrides = loadDocsOverrides(skill.rootURL.appendingPathComponent(docsPath)) let docsOverrides = (def.docsSources ?? []) + fileOverrides return InternalSkillAsset( definition: def, rootURL: skill.rootURL, skillMarkdown: skillMarkdown, prompt: prompt, schema: schema, docsOverrides: docsOverrides ) } private func readText(_ url: URL) -> String? { guard let data = try? Data(contentsOf: url) else { return nil } let text = String(data: data, encoding: .utf8) ?? "" return text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : text } private func loadDocsOverrides(_ url: URL) -> [WizardDocSource] { guard let data = try? Data(contentsOf: url) else { return [] } let decoder = JSONDecoder() let list = (try? decoder.decode([WizardDocSource].self, from: data)) ?? [] return list } } ================================================ FILE: services/LLMHTTPService.swift ================================================ import Foundation // MARK: - Minimal HTTP transport for Providers (OpenAI‑compatible / Anthropic) // Small baseline: text generation only, auto‑select provider from registry. actor LLMHTTPService { enum PreferredEngine { case auto, codex, claudeCode } struct Options: Sendable { var preferred: PreferredEngine = .auto var model: String? = nil var timeout: TimeInterval = 25 var systemPrompt: String? = nil var maxTokens: Int = 300 var temperature: Double = 0.2 // Optional hard selection of a registry provider id. If set, we will // use that provider's connector (prefer codex, else claudeCode). var providerId: String? = nil } struct Result: Sendable { let text: String; let providerId: String; let model: String?; let elapsedMs: Int; let statusCode: Int } private let providers = ProvidersRegistryService() func generateText(prompt: String, options: Options = Options()) async throws -> Result { let start = Date() let reg = await providers.mergedRegistry() guard let sel = selectConnector(reg: reg, preferred: options.preferred, providerId: options.providerId) else { throw HTTPError.noActiveProvider } // Determine target API family based on selected consumer first, then provider class fallback let providerClass = (sel.provider.class ?? "openai-compatible").lowercased() let isAnthropicFamily = (sel.consumerKey == ProvidersRegistryService.Consumer.claudeCode.rawValue) || providerClass == "anthropic" let candidates = candidateModels(reg: reg, selection: sel, preferred: options.model) var lastErr: Error? = nil if isAnthropicFamily { for m in candidates { do { let (code, text) = try await callAnthropic(baseURL: sel.baseURL, headers: sel.headers, model: m, prompt: prompt, options: options) let ms = Int(Date().timeIntervalSince(start) * 1000) return Result(text: text, providerId: sel.provider.id, model: m, elapsedMs: ms, statusCode: code) } catch let error as HTTPError { if case .http(let sc, _) = error, sc == 404 || sc == 403 || sc == 401 { lastErr = error; continue } else { lastErr = error; break } } catch { lastErr = error; break } } } else { // Default to OpenAI‑compatible let wire = (sel.connector.wireAPI ?? "chat").lowercased() for m in candidates { do { let (code, text): (Int, String) if wire == "responses" { (code, text) = try await callOpenAIResponses(baseURL: sel.baseURL, headers: sel.headers, model: m, prompt: prompt, options: options) } else { (code, text) = try await callOpenAIChat(baseURL: sel.baseURL, headers: sel.headers, model: m, system: options.systemPrompt, prompt: prompt, options: options) } let ms = Int(Date().timeIntervalSince(start) * 1000) return Result(text: text, providerId: sel.provider.id, model: m, elapsedMs: ms, statusCode: code) } catch let error as HTTPError { if case .http(let sc, _) = error, sc == 404 || sc == 403 || sc == 401 { lastErr = error; continue } else { lastErr = error; break } } catch { lastErr = error; break } } } throw lastErr ?? HTTPError.badResponse("model resolution failed") } // Resolve model id from (in order): caller override → bindings.defaultModel → provider.recommended → connector.modelAliases["default"] → first catalog model private func resolveModel( reg: ProvidersRegistryService.Registry, selection sel: (provider: ProvidersRegistryService.Provider, connector: ProvidersRegistryService.Connector, baseURL: String, headers: [String:String], consumerKey: String), preferred: String? ) -> String? { if let p = preferred, !p.isEmpty { return p } if let bind = reg.bindings.defaultModel?[sel.consumerKey], !bind.isEmpty { return bind } if let rec = sel.provider.recommended?.defaultModelFor?[sel.consumerKey], !rec.isEmpty { return rec } if let def = sel.connector.modelAliases?["default"], !def.isEmpty { return def } if let first = sel.provider.catalog?.models?.first?.vendorModelId, !first.isEmpty { return first } return nil } private func candidateModels( reg: ProvidersRegistryService.Registry, selection sel: (provider: ProvidersRegistryService.Provider, connector: ProvidersRegistryService.Connector, baseURL: String, headers: [String:String], consumerKey: String), preferred: String? ) -> [String?] { var out: [String] = [] let push: (String?) -> Void = { v in if let v = v, !v.isEmpty, !out.contains(v) { out.append(v) } } push(preferred) push(reg.bindings.defaultModel?[sel.consumerKey]) push(sel.provider.recommended?.defaultModelFor?[sel.consumerKey]) push(sel.connector.modelAliases?["default"]) if let first = sel.provider.catalog?.models?.first?.vendorModelId { push(first) } // Provide a very last‑resort fallback per family (won't be hit if any above exist) return out.isEmpty ? [nil] : out.map { Optional($0) } } // MARK: - Selection private func selectConnector( reg: ProvidersRegistryService.Registry, preferred: PreferredEngine, providerId: String? ) -> (provider: ProvidersRegistryService.Provider, connector: ProvidersRegistryService.Connector, baseURL: String, headers: [String:String], consumerKey: String)? { // All providers now use Auto-Proxy mode through CLIProxyAPI // OAuth providers require separate authorization through CLIProxyAPI (isolated from main CLI auth) // API key providers also route through CLIProxyAPI for unified model access var parsedSelection = providerId.map { UnifiedProviderID.parse($0) } if case .unknown(let raw) = parsedSelection, let normalized = UnifiedProviderID.normalize(raw, registryProviders: reg.providers) { parsedSelection = UnifiedProviderID.parse(normalized) } var effectiveProviderId: String? var builtinProvider: LocalServerBuiltInProvider? var legacyRerouteLabel: String? switch parsedSelection { case .oauth(let auth, _): // OAuth providers always route through CLIProxyAPI (requires separate authorization) builtinProvider = Self.builtinProvider(for: auth) case .api(let id): effectiveProviderId = id case .legacyBuiltin(let builtin): builtinProvider = builtin case .legacyReroute(let label): legacyRerouteLabel = label case .autoProxy: // Auto proxy mode: use CLI Proxy API for routing break case .unknown(let value): effectiveProviderId = value case .none: break } // Handle legacy CLI Proxy reroute providers selection (local-reroute:*) if let name = legacyRerouteLabel, let pid = providerId { let port = Self.localServerPort() let base = "http://127.0.0.1:\(port)/v1" let vConnector = ProvidersRegistryService.Connector( baseURL: base, wireAPI: "chat" ) let vProvider = ProvidersRegistryService.Provider( id: pid, name: name, class: "openai-compatible", managedByCodMate: true, connectors: ["internal": vConnector] ) var headers: [String:String] = [:] if let key = Self.loadLocalServerAPIKey(), !key.isEmpty { headers["Authorization"] = key.hasPrefix("Bearer ") ? key : "Bearer \(key)" } return (vProvider, vConnector, base, headers, "internal") } // Handle OAuth providers selection (oauth:*) - always route through CLIProxyAPI if let builtin = builtinProvider { let port = Self.localServerPort() let base = "http://127.0.0.1:\(port)/v1" let vConnector = ProvidersRegistryService.Connector( baseURL: base, wireAPI: "chat" ) let id: String = { // For OAuth accounts, preserve the full ID (including accountId if present) if case .oauth = parsedSelection, let providerId = providerId { return providerId } if case .legacyBuiltin(let legacy) = parsedSelection { return legacy.id } return builtin.id }() let vProvider = ProvidersRegistryService.Provider( id: id, name: builtin.displayName, class: "openai-compatible", managedByCodMate: true, connectors: ["internal": vConnector] ) var headers: [String:String] = [:] if let key = Self.loadLocalServerAPIKey(), !key.isEmpty { headers["Authorization"] = key.hasPrefix("Bearer ") ? key : "Bearer \(key)" } return (vProvider, vConnector, base, headers, "internal") } func resolve(_ consumer: ProvidersRegistryService.Consumer, scopedProvider: ProvidersRegistryService.Provider? = nil) -> (ProvidersRegistryService.Provider, ProvidersRegistryService.Connector, String, [String:String], String)? { let key = consumer.rawValue let p: ProvidersRegistryService.Provider? if let scoped = scopedProvider { p = (scoped.connectors[key] != nil) ? scoped : nil } else if let ap = reg.bindings.activeProvider?[key], let match = reg.providers.first(where: { $0.id == ap }) { p = match } else { p = reg.providers.first(where: { $0.connectors[key] != nil }) } guard let provider = p, let connector = provider.connectors[key] else { return nil } guard let base = connector.baseURL, !base.isEmpty else { return nil } var headers: [String:String] = [:] // Start with explicit headers if let h = connector.httpHeaders { for (k,v) in h { headers[k] = v } } // Fill envHttpHeaders from env if let eh = connector.envHttpHeaders { for (k, envKey) in eh { if let val = ProcessInfo.processInfo.environment[envKey], !val.isEmpty { headers[k] = val } } } // If Authorization missing, use provider/envKey -> env var or direct token if headers["Authorization"] == nil { if let name = provider.envKey ?? connector.envKey, let val = ProcessInfo.processInfo.environment[name], !val.isEmpty { headers["Authorization"] = val.hasPrefix("Bearer ") ? val : "Bearer \(val)" } else if let k = provider.envKey ?? connector.envKey { let lower = k.lowercased() let looksLikeToken = lower.contains("sk-") || k.hasPrefix("eyJ") || k.contains(".") if looksLikeToken { headers["Authorization"] = k.hasPrefix("Bearer ") ? k : "Bearer \(k)" } } } return (provider, connector, base, headers, key) } // Resolve the actual provider first var result: (ProvidersRegistryService.Provider, ProvidersRegistryService.Connector, String, [String:String], String)? // If providerId is specified, pick its connector (prefer codex, else claudeCode) if let pid = effectiveProviderId, let p = reg.providers.first(where: { $0.id == pid }) { result = resolve(.codex, scopedProvider: p) ?? resolve(.claudeCode, scopedProvider: p) } else { switch preferred { case .codex: result = resolve(.codex) ?? resolve(.claudeCode) case .claudeCode: result = resolve(.claudeCode) ?? resolve(.codex) case .auto: result = resolve(.codex) ?? resolve(.claudeCode) } } guard let resolved = result else { return nil } // All API key providers managed by CodMate route through CLIProxyAPI // This provides unified model access and matches the Auto-Proxy model list let (provider, _, _, _, _) = resolved if provider.managedByCodMate == true { // Route through CLIProxyAPI for unified access let port = Self.localServerPort() let base = "http://127.0.0.1:\(port)/v1" let vConnector = ProvidersRegistryService.Connector( baseURL: base, wireAPI: "chat" ) // Keep original provider info for model selection var reroutedProvider = provider reroutedProvider.connectors = ["internal": vConnector] var proxyHeaders: [String:String] = [:] if let key = Self.loadLocalServerAPIKey(), !key.isEmpty { proxyHeaders["Authorization"] = key.hasPrefix("Bearer ") ? key : "Bearer \(key)" } return (reroutedProvider, vConnector, base, proxyHeaders, "internal") } return resolved } /// Get the CLI Proxy API port from UserDefaults (single source of truth) /// Note: This is a static method that can be called from any context, /// so we read UserDefaults directly instead of using CLIProxyService.shared.port /// which is @MainActor isolated. private static func localServerPort() -> Int { let p = UserDefaults.standard.integer(forKey: "codmate.localserver.port") return p > 0 ? p : Int(CLIProxyService.defaultPort) } private static func builtinProvider(for auth: LocalAuthProvider) -> LocalServerBuiltInProvider? { switch auth { case .codex: return .openai case .claude: return .anthropic case .gemini: return .gemini case .antigravity: return .antigravity case .qwen: return .qwen } } private static func localServerConfigPath() -> String { let homeDir = FileManager.default.homeDirectoryForCurrentUser let cliproxyapiDir = homeDir.appendingPathComponent(".codmate/cliproxyapi", isDirectory: true) return cliproxyapiDir.appendingPathComponent("config.yaml").path } private static func loadLocalServerAPIKey() -> String? { guard let content = try? String(contentsOfFile: localServerConfigPath(), encoding: .utf8) else { return nil } var inKeys = false for line in content.components(separatedBy: .newlines) { let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("api-keys:") { inKeys = true continue } if inKeys { if trimmed.hasPrefix("-") { var value = trimmed if let range = value.range(of: "-") { value = String(value[range.upperBound...]).trimmingCharacters(in: .whitespaces) } if value.hasPrefix("\"") && value.hasSuffix("\"") { value.removeFirst() value.removeLast() } return value.trimmingCharacters(in: .whitespaces) } if !trimmed.isEmpty { inKeys = false } } } return nil } // MARK: - OpenAI compatible private func callOpenAIChat(baseURL: String, headers: [String:String], model: String?, system: String?, prompt: String, options: Options) async throws -> (Int, String) { let url = openAIEndpoint(baseURL: baseURL, path: "chat/completions") var msgs: [[String:Any]] = [] if let sys = system, !sys.isEmpty { msgs.append(["role":"system","content": sys]) } msgs.append(["role":"user","content": prompt]) let body: [String: Any] = [ "model": model ?? "gpt-4.1-mini", "messages": msgs, "temperature": options.temperature, "max_tokens": options.maxTokens ] let (code, json) = try await postJSON(url: url, headers: addJSONHeaders(headers), body: body, timeout: options.timeout) if let choices = json["choices"] as? [[String:Any]], let first = choices.first, let message = first["message"] as? [String:Any], let content = message["content"] as? String { return (code, content) } // Fallback for providers that return `choices[].text` if let choices = json["choices"] as? [[String:Any]], let first = choices.first, let text = first["text"] as? String { return (code, text) } throw HTTPError.badResponse("openai.chat: missing choices") } private func callOpenAIResponses(baseURL: String, headers: [String:String], model: String?, prompt: String, options: Options) async throws -> (Int, String) { let url = openAIEndpoint(baseURL: baseURL, path: "responses") let body: [String: Any] = [ "model": model ?? "gpt-4.1-mini", "input": [[ "role": "user", "content": [["type":"text","text": prompt]] ]], "temperature": options.temperature, "max_output_tokens": options.maxTokens ] let (code, json) = try await postJSON(url: url, headers: addJSONHeaders(headers), body: body, timeout: options.timeout) if let s = json["output_text"] as? String { return (code, s) } if let out = json["output"] as? [[String:Any]], let first = out.first, let type = first["type"] as? String, type == "output_text", let text = first["text"] as? String { return (code, text) } if let content = json["content"] as? [[String:Any]], let first = content.first, let text = first["text"] as? String { return (code, text) } throw HTTPError.badResponse("openai.responses: missing output_text/content") } // MARK: - Anthropic private func callAnthropic(baseURL: String, headers: [String:String], model: String?, prompt: String, options: Options) async throws -> (Int, String) { let url = anthropicEndpoint(baseURL: baseURL, path: "messages") var hdr = addJSONHeaders(headers) if hdr["anthropic-version"] == nil { hdr["anthropic-version"] = "2023-06-01" } let body: [String: Any] = [ "model": model ?? "claude-3-5-sonnet-20241022", "max_tokens": options.maxTokens, "messages": [["role":"user","content": [["type":"text","text": prompt]]]] ] let (code, json) = try await postJSON(url: url, headers: hdr, body: body, timeout: options.timeout) if let content = json["content"] as? [[String:Any]] { for item in content { if (item["type"] as? String) == "text", let text = item["text"] as? String { return (code, text) } } } throw HTTPError.badResponse("anthropic.messages: missing content text") } // MARK: - HTTP helpers private func postJSON(url: URL, headers: [String:String], body: [String:Any], timeout: TimeInterval) async throws -> (Int, [String:Any]) { var req = URLRequest(url: url); req.httpMethod = "POST"; req.timeoutInterval = timeout for (k,v) in headers { req.setValue(v, forHTTPHeaderField: k) } req.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, resp) = try await URLSession.shared.data(for: req) guard let http = resp as? HTTPURLResponse else { throw HTTPError.badResponse("no http response") } let code = http.statusCode if code / 100 != 2 { throw HTTPError.http(code, String(data: data, encoding: .utf8) ?? "") } let json = (try? JSONSerialization.jsonObject(with: data)) as? [String:Any] ?? [:] return (code, json) } // Build OpenAI-compatible endpoints robustly against base URLs that may already include a version (e.g., /v1 or /v4) private func openAIEndpoint(baseURL: String, path: String) -> URL { func hasNumericVersionSuffix(_ urlString: String) -> Bool { guard let u = URL(string: urlString) else { return false } let parts = u.path.split(separator: "/") guard let last = parts.last else { return false } let s = String(last).lowercased() if s.hasPrefix("v") { let digits = s.dropFirst() return !digits.isEmpty && digits.allSatisfy { $0.isNumber } } return false } var base = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) if base.hasSuffix("/") { base.removeLast() } let lower = base.lowercased() // If base already ends with /v1 or /v{number}, don't append another /v1 if lower.hasSuffix("/v1") || hasNumericVersionSuffix(base) { return URL(string: base + "/" + path)! } else { return URL(string: base + "/v1/" + path)! } } // Build Anthropic endpoints robustly against bases that may already include /v1 private func anthropicEndpoint(baseURL: String, path: String) -> URL { var base = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) if base.hasSuffix("/") { base.removeLast() } if base.lowercased().hasSuffix("/v1") { return URL(string: base + "/" + path)! } else { return URL(string: base + "/v1/" + path)! } } private func addJSONHeaders(_ h: [String:String]) -> [String:String] { var out = h if out["Content-Type"] == nil { out["Content-Type"] = "application/json" } if out["Accept"] == nil { out["Accept"] = "application/json" } return out } enum HTTPError: LocalizedError { case noActiveProvider; case http(Int, String); case badResponse(String) var errorDescription: String? { switch self { case .noActiveProvider: return "No active provider configured" case .http(let code, let body): return "HTTP \(code): \(body.prefix(400))" case .badResponse(let s): return "Bad response: \(s)" } } } } ================================================ FILE: services/LaunchAtLoginService.swift ================================================ import Foundation import ServiceManagement @MainActor final class LaunchAtLoginService { static let shared = LaunchAtLoginService() private init() {} /// Register or unregister the app to launch at login func setLaunchAtLogin(enabled: Bool) { if #available(macOS 13.0, *) { do { if enabled { if SMAppService.mainApp.status == .enabled { print("[LaunchAtLogin] Already enabled") return } try SMAppService.mainApp.register() print("[LaunchAtLogin] Successfully registered for launch at login") } else { if SMAppService.mainApp.status == .notRegistered { print("[LaunchAtLogin] Already disabled") return } try SMAppService.mainApp.unregister() print("[LaunchAtLogin] Successfully unregistered from launch at login") } } catch { print("[LaunchAtLogin] Failed to \(enabled ? "register" : "unregister"): \(error)") } } else { print("[LaunchAtLogin] Launch at login requires macOS 13.0 or later") } } /// Check if the app is currently set to launch at login var isEnabled: Bool { if #available(macOS 13.0, *) { return SMAppService.mainApp.status == .enabled } return false } /// Synchronize the actual system state with preferences func syncWithPreferences(_ preferences: SessionPreferencesStore) { if #available(macOS 13.0, *) { let actuallyEnabled = SMAppService.mainApp.status == .enabled if preferences.launchAtLogin != actuallyEnabled { print("[LaunchAtLogin] Syncing: preference=\(preferences.launchAtLogin), actual=\(actuallyEnabled)") setLaunchAtLogin(enabled: preferences.launchAtLogin) } } } } ================================================ FILE: services/LocalServerBuiltInProvider.swift ================================================ import Foundation enum LocalServerBuiltInProvider: String, CaseIterable, Identifiable { case anthropic case gemini case openai case antigravity case qwen var id: String { "local-builtin-\(rawValue)" } var displayName: String { switch self { case .anthropic: return "Claude (OAuth)" case .gemini: return "Gemini (OAuth)" case .openai: return "Codex (OAuth)" case .antigravity: return "Antigravity (OAuth)" case .qwen: return "Qwen Code (OAuth)" } } var ownedByHints: [String] { switch self { case .anthropic: return ["anthropic", "claude"] case .gemini: return ["google", "gemini"] case .openai: return ["openai", "codex", "gpt"] case .antigravity: return ["antigravity"] case .qwen: return ["qwen"] } } var modelIdHints: [String] { switch self { case .anthropic: return ["claude-"] case .gemini: return ["gemini-"] case .openai: return ["gpt-"] case .antigravity: return ["gemini-3", "gemini-3-"] case .qwen: return ["qwen-"] } } func matchesOwnedBy(_ value: String?) -> Bool { let lower = (value ?? "").lowercased() return ownedByHints.contains { lower.contains($0) } } func matchesModelId(_ modelId: String) -> Bool { let lower = modelId.lowercased() return modelIdHints.contains { lower.hasPrefix($0) } } static func from(providerId: String?) -> LocalServerBuiltInProvider? { guard let providerId else { return nil } return LocalServerBuiltInProvider.allCases.first(where: { $0.id == providerId }) } } ================================================ FILE: services/MCPImportService.swift ================================================ import Foundation enum MCPImportService { struct SourceDescriptor { let label: String let url: URL let loader: () -> String? } private static let codmateBegin = "# codmate-mcp begin" private static let codmateEnd = "# codmate-mcp end" static func scan(scope: ExtensionsImportScope, fileManager: FileManager = .default) -> [MCPImportCandidate] { let sources: [SourceDescriptor] switch scope { case .home: let home = SessionPreferencesStore.getRealUserHomeURL() sources = [ SourceDescriptor( label: "Codex", url: home.appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("config.toml", isDirectory: false), loader: { let url = home.appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("config.toml", isDirectory: false) return readText(url: url, fileManager: fileManager).map(stripCodMateManagedBlock) }), SourceDescriptor( label: "Claude", url: home.appendingPathComponent(".claude", isDirectory: true) .appendingPathComponent("settings.json", isDirectory: false), loader: { let url = home.appendingPathComponent(".claude", isDirectory: true) .appendingPathComponent("settings.json", isDirectory: false) return readMCPServersJSON(url: url, fileManager: fileManager) }), SourceDescriptor( label: "Gemini", url: home.appendingPathComponent(".gemini", isDirectory: true) .appendingPathComponent("settings.json", isDirectory: false), loader: { let url = home.appendingPathComponent(".gemini", isDirectory: true) .appendingPathComponent("settings.json", isDirectory: false) return readMCPServersJSON(url: url, fileManager: fileManager) }), ] case .project(let directory): sources = [ SourceDescriptor( label: "Codex", url: directory.appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("config.toml", isDirectory: false), loader: { let url = directory.appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("config.toml", isDirectory: false) return readText(url: url, fileManager: fileManager).map(stripCodMateManagedBlock) }), // Claude Code official path: project_root/.mcp.json SourceDescriptor( label: "Claude", url: directory.appendingPathComponent(".mcp.json", isDirectory: false), loader: { let url = directory.appendingPathComponent(".mcp.json", isDirectory: false) return readMCPServersJSON(url: url, fileManager: fileManager) }), // CodMate legacy path: project_root/.claude/.mcp.json (for backward compatibility) SourceDescriptor( label: "Claude", url: directory.appendingPathComponent(".claude", isDirectory: true) .appendingPathComponent(".mcp.json", isDirectory: false), loader: { let url = directory.appendingPathComponent(".claude", isDirectory: true) .appendingPathComponent(".mcp.json", isDirectory: false) return readMCPServersJSON(url: url, fileManager: fileManager) }), SourceDescriptor( label: "Gemini", url: directory.appendingPathComponent(".gemini", isDirectory: true) .appendingPathComponent("settings.json", isDirectory: false), loader: { let url = directory.appendingPathComponent(".gemini", isDirectory: true) .appendingPathComponent("settings.json", isDirectory: false) return readMCPServersJSON(url: url, fileManager: fileManager) }), ] } let filtered = sources.filter { source in switch source.label { case "Codex": return SessionPreferencesStore.isCLIEnabled(.codex) case "Claude": return SessionPreferencesStore.isCLIEnabled(.claude) case "Gemini": return SessionPreferencesStore.isCLIEnabled(.gemini) default: return true } } return scan(sources: filtered) } private static func scan(sources: [SourceDescriptor]) -> [MCPImportCandidate] { var map: [String: MCPImportCandidate] = [:] var byName: [String: [String]] = [:] for source in sources { guard let text = source.loader(), !text.isEmpty else { continue } guard let drafts = try? UniImportMCPNormalizer.parseText(text) else { continue } for draft in drafts { let name = (draft.name ?? "imported-server").trimmingCharacters(in: .whitespacesAndNewlines) guard !name.isEmpty else { continue } let signature = normalizeSignature( name: name, kind: draft.kind, command: draft.command, url: draft.url, args: draft.args) if var existing = map[signature] { if !existing.sources.contains(source.label) { existing.sources.append(source.label) } existing.sourcePaths[source.label] = source.url.path map[signature] = existing } else { map[signature] = MCPImportCandidate( id: UUID(), name: name, kind: draft.kind, command: draft.command, args: draft.args, env: draft.env, url: draft.url, headers: draft.headers, description: draft.meta?.description, sources: [source.label], sourcePaths: [source.label: source.url.path], isSelected: true, hasConflict: false, hasNameCollision: false, resolution: .overwrite, renameName: name, signature: signature ) } } } for candidate in map.values { byName[candidate.name, default: []].append(candidate.signature) } var out = map.values.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } for idx in out.indices { if let signatures = byName[out[idx].name], signatures.count > 1 { out[idx].hasNameCollision = true } } return out } static func signature(for server: MCPServer) -> String { normalizeSignature( name: server.name, kind: server.kind, command: server.command, url: server.url, args: server.args ) } static func filterManagedCandidates( _ candidates: [MCPImportCandidate], managedSignatures: Set ) -> [MCPImportCandidate] { candidates.filter { !managedSignatures.contains($0.signature) } } private static func normalizeSignature( name: String, kind: MCPServerKind, command: String?, url: String?, args: [String]? ) -> String { let normName = name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let normCommand = (command ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let normURL = (url ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let normArgs = (args ?? []).map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }.sorted().joined(separator: "|") return "\(normName)|\(kind.rawValue)|\(normCommand)|\(normURL)|\(normArgs)" } private static func readText(url: URL, fileManager: FileManager) -> String? { guard fileManager.fileExists(atPath: url.path) else { return nil } return try? String(contentsOf: url, encoding: .utf8) } private static func readMCPServersJSON(url: URL, fileManager: FileManager) -> String? { guard fileManager.fileExists(atPath: url.path) else { return nil } guard let data = try? Data(contentsOf: url) else { return nil } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } guard let mcpServers = json["mcpServers"] as? [String: Any] else { return nil } let payload: [String: Any] = ["mcpServers": mcpServers] guard let out = try? JSONSerialization.data( withJSONObject: payload, options: [.prettyPrinted, .withoutEscapingSlashes]) else { return nil } return String(data: out, encoding: .utf8) } private static func stripCodMateManagedBlock(_ text: String) -> String { guard let begin = text.range(of: codmateBegin), let end = text.range(of: codmateEnd) else { return text } var updated = text updated.removeSubrange(begin.lowerBound.. Result { cancelRequested = false currentProcess = nil switch server.kind { case .stdio: return await testStdio(server: server, timeoutSeconds: timeoutSeconds) case .sse, .streamable_http: return await testHTTP(server: server, timeoutSeconds: timeoutSeconds) } } private func testHTTP(server: MCPServer, timeoutSeconds: TimeInterval) async -> Result { guard let urlString = server.url, let url = URL(string: urlString) else { return .failure(.invalidConfiguration("Missing or invalid URL")) } #if canImport(MCP) // Prefer real MCP handshake via HTTPClientTransport when SDK is available do { let cfg = URLSessionConfiguration.ephemeral cfg.timeoutIntervalForRequest = timeoutSeconds cfg.timeoutIntervalForResource = timeoutSeconds var headers: [String: String] = [:] if let h = server.headers { headers = h } let transport = HTTPClientTransport( endpoint: url, configuration: cfg, streaming: true, sseInitializationTimeout: 3, requestModifier: { req in var r = req for (k,v) in headers { r.setValue(v, forHTTPHeaderField: k) } return r }, logger: nil ) let client = Client(name: "CodMate", version: "1.0.0") let initResult = try await client.connect(transport: transport) // Console diagnostics for investigation print("[MCPTest] HTTP connect ok → protocol=\(initResult.protocolVersion) server=\(initResult.serverInfo.name) \(initResult.serverInfo.version)") let caps = initResult.capabilities let hasTools = (caps.tools != nil) let hasPrompts = (caps.prompts != nil) let hasResources = (caps.resources != nil) print("[MCPTest] caps: tools=\(hasTools) prompts=\(hasPrompts) resources=\(hasResources) logging=\(caps.logging != nil) sampling=\(caps.sampling != nil)") // Try to list counts only for declared capabilities var toolsCount = 0, promptsCount = 0, resourcesCount = 0, modelsCount = 0 if hasTools { do { let res = try await client.listTools(); toolsCount = res.tools.count; print("[MCPTest] listTools=\(toolsCount)") } catch { print("[MCPTest] listTools error: \(error)") } } if hasPrompts { do { let res = try await client.listPrompts(); promptsCount = res.prompts.count; print("[MCPTest] listPrompts=\(promptsCount)") } catch { print("[MCPTest] listPrompts error: \(error)") } } if hasResources { do { let res = try await client.listResources(); resourcesCount = res.resources.count; print("[MCPTest] listResources=\(resourcesCount)") } catch { print("[MCPTest] listResources error: \(error)") } } // Some servers expose models via prompts/resources; if the SDK exposes listModels in future, plug here. return .success(.init(connected: true, serverName: initResult.serverInfo.name, tools: toolsCount, prompts: promptsCount, resources: resourcesCount, models: modelsCount, hasTools: hasTools, hasPrompts: hasPrompts, hasResources: hasResources)) } catch { print("[MCPTest] HTTP SDK connect/list failed: \(error)") // Fallback to HTTP reachability probe return await httpProbe(url: url, headers: server.headers, timeoutSeconds: timeoutSeconds) } #else return await httpProbe(url: url, headers: server.headers, timeoutSeconds: timeoutSeconds) #endif } private func httpProbe(url: URL, headers: [String: String]?, timeoutSeconds: TimeInterval) async -> Result { let config = URLSessionConfiguration.ephemeral config.timeoutIntervalForRequest = timeoutSeconds config.timeoutIntervalForResource = timeoutSeconds let session = URLSession(configuration: config) var request = URLRequest(url: url) request.httpMethod = "GET" if let headers { for (k,v) in headers { request.setValue(v, forHTTPHeaderField: k) } } do { let (_, resp) = try await session.data(for: request) let code = (resp as? HTTPURLResponse)?.statusCode ?? 0 let ok = (200...299).contains(code) || code == 401 || code == 403 || code == 405 guard ok else { return .failure(.unreachable("HTTP \(code)")) } return .success(.init(connected: true, serverName: nil, tools: 0, prompts: 0, resources: 0, models: 0, hasTools: false, hasPrompts: false, hasResources: false)) } catch { if (error as? URLError)?.code == .timedOut { return .failure(.timeout) } return .failure(.unreachable(error.localizedDescription)) } } private func testStdio(server: MCPServer, timeoutSeconds: TimeInterval) async -> Result { guard let cmdRaw = server.command?.trimmingCharacters(in: .whitespacesAndNewlines), !cmdRaw.isEmpty else { return .failure(.invalidConfiguration("Missing command")) } // Resolve executable: absolute path → as-is; otherwise search PATH; fallback to /usr/bin/env cmd let fm = FileManager.default var env = ProcessInfo.processInfo.environment if let custom = server.env { for (k,v) in custom { env[k] = v } } // Ensure PATH contains common Homebrew locations let defaultPATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" let mergedPATH: String = { if let p = env["PATH"], !p.isEmpty { return p + ":" + defaultPATH } return defaultPATH }() env["PATH"] = mergedPATH let cmd = cmdRaw let isAbsolute = cmd.hasPrefix("/") let execURL: URL? if isAbsolute { execURL = URL(fileURLWithPath: cmd) } else { // Search PATH var found: URL? = nil for dir in mergedPATH.split(separator: ":") { let path = String(dir) + "/" + cmd if fm.isExecutableFile(atPath: path) { found = URL(fileURLWithPath: path); break } } execURL = found } let proc = Process() let args = server.args ?? [] if let url = execURL { proc.executableURL = url proc.arguments = args } else { // Fallback: /usr/bin/env cmd args… to honor PATH resolution on macOS proc.executableURL = URL(fileURLWithPath: "/usr/bin/env") proc.arguments = [cmd] + args } // Diagnostics print("[MCPTest] stdio PATH=\(mergedPATH)") print("[MCPTest] stdio exec=\(execURL?.path ?? "/usr/bin/env \(cmd)") args=\(args)") proc.environment = env #if canImport(MCP) // Wire child process stdio to SDK stdio transport let childStdout = Pipe() let childStdin = Pipe() let childStderr = Pipe() proc.standardOutput = childStdout proc.standardInput = childStdin proc.standardError = childStderr do { try proc.run() } catch { let errMsg = (error as NSError).localizedDescription if (error as NSError).domain == NSPOSIXErrorDomain && (error as NSError).code == ENOENT { return .failure(.unreachable("Command not found in PATH")) } return .failure(.unreachable(errMsg)) } // Build transport using the child's pipes // Note: input for transport is what we read FROM (child stdout), output is what we write TO (child stdin) #if canImport(System) let inFD = FileDescriptor(rawValue: CInt(childStdout.fileHandleForReading.fileDescriptor)) let outFD = FileDescriptor(rawValue: CInt(childStdin.fileHandleForWriting.fileDescriptor)) #else let inFD = CInt(childStdout.fileHandleForReading.fileDescriptor) let outFD = CInt(childStdin.fileHandleForWriting.fileDescriptor) #endif let transport = StdioTransport(input: inFD, output: outFD, logger: nil) let client = Client(name: "CodMate", version: "1.0.0") do { let initResult = try await client.connect(transport: transport) print("[MCPTest] stdio connect ok → protocol=\(initResult.protocolVersion) server=\(initResult.serverInfo.name) \(initResult.serverInfo.version)") let caps = initResult.capabilities let hasTools = (caps.tools != nil) let hasPrompts = (caps.prompts != nil) let hasResources = (caps.resources != nil) print("[MCPTest] caps: tools=\(hasTools) prompts=\(hasPrompts) resources=\(hasResources)") var toolsCount = 0, promptsCount = 0, resourcesCount = 0 if hasTools { do { let res = try await client.listTools(); toolsCount = res.tools.count; print("[MCPTest] listTools=\(toolsCount)") } catch { print("[MCPTest] listTools error: \(error)") } } if hasPrompts { do { let res = try await client.listPrompts(); promptsCount = res.prompts.count; print("[MCPTest] listPrompts=\(promptsCount)") } catch { print("[MCPTest] listPrompts error: \(error)") } } if hasResources { do { let res = try await client.listResources(); resourcesCount = res.resources.count; print("[MCPTest] listResources=\(resourcesCount)") } catch { print("[MCPTest] listResources error: \(error)") } } // Cleanup await transport.disconnect() if proc.isRunning { proc.terminate() } currentProcess = nil return .success(.init(connected: true, serverName: initResult.serverInfo.name, tools: toolsCount, prompts: promptsCount, resources: resourcesCount, models: 0, hasTools: hasTools, hasPrompts: hasPrompts, hasResources: hasResources)) } catch { print("[MCPTest] stdio SDK connect/list failed: \(error)") if proc.isRunning { proc.terminate() } return .failure(.unreachable(error.localizedDescription)) } #else // Without SDK, do a minimal reachability ping let pipe = Pipe() proc.standardOutput = pipe proc.standardError = pipe do { try proc.run() } catch { let errMsg = (error as NSError).localizedDescription if (error as NSError).domain == NSPOSIXErrorDomain && (error as NSError).code == ENOENT { return .failure(.unreachable("Command not found in PATH")) } return .failure(.unreachable(errMsg)) } let deadline = UInt64((min(timeoutSeconds, 1.5)) * 1_000_000_000) try? await Task.sleep(nanoseconds: deadline) if proc.isRunning { proc.terminate() } currentProcess = nil return .success(.init(connected: true, serverName: nil, tools: 0, prompts: 0, resources: 0, models: 0, hasTools: false, hasPrompts: false, hasResources: false)) #endif } } ================================================ FILE: services/MCPServersStore.swift ================================================ import Foundation // MARK: - Persistent MCP Servers Store actor MCPServersStore { struct Paths { let home: URL; let fileURL: URL } static func defaultPaths(fileManager: FileManager = .default) -> Paths { // Persist MCP servers under the real user home (~/.codmate), not sandbox container let home = SessionPreferencesStore.getRealUserHomeURL() .appendingPathComponent(".codmate", isDirectory: true) return Paths(home: home, fileURL: home.appendingPathComponent("mcp-servers.json")) } private let fm: FileManager private let paths: Paths private var cache: [MCPServer]? = nil init(paths: Paths = MCPServersStore.defaultPaths(), fileManager: FileManager = .default) { self.paths = paths self.fm = fileManager } // MARK: Load/Save func load() -> [MCPServer] { if let cache { return cache } let url = paths.fileURL guard let data = try? Data(contentsOf: url) else { cache = []; return [] } if let list = try? JSONDecoder().decode([MCPServer].self, from: data) { cache = list return list } cache = [] return [] } private func save(_ list: [MCPServer]) throws { try fm.createDirectory(at: paths.home, withIntermediateDirectories: true) let tmp = paths.fileURL.appendingPathExtension("tmp") let enc = JSONEncoder() enc.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes] let data = try enc.encode(list) try data.write(to: tmp, options: .atomic) if fm.fileExists(atPath: paths.fileURL.path) { try fm.removeItem(at: paths.fileURL) } try fm.moveItem(at: tmp, to: paths.fileURL) cache = list } // MARK: Public API func list() -> [MCPServer] { load() } func upsert(_ server: MCPServer) throws { var list = load() if let idx = list.firstIndex(where: { $0.name == server.name }) { list[idx] = server } else { list.append(server) } try save(list) } func upsertMany(_ servers: [MCPServer]) throws { var map: [String: MCPServer] = [:] for s in load() { map[s.name] = s } for s in servers { map[s.name] = s } let sorted = map.values.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } try save(sorted) } // Export enabled servers to Claude Code user settings (~/.claude/settings.json) // Per official docs, settings.json is the canonical configuration entry point. // // Safety strategy: // - Only modifies the "mcpServers" field // - Preserves all other existing configuration // - Creates backup before writing // - Uses atomic write to prevent partial corruption func exportEnabledForClaudeConfig(servers: [MCPServer]? = nil) throws { if !SessionPreferencesStore.isCLIEnabled(.claude) { return } let list: [MCPServer] if let servers { list = servers.enabledServers(for: .claude) } else { list = load().enabledServers(for: .claude) } let realHome = SessionPreferencesStore.getRealUserHomeURL() // User settings file under ~/.claude/settings.json (preferred) let claudeDir = realHome.appendingPathComponent(".claude", isDirectory: true) let claudeSettingsPath = claudeDir.appendingPathComponent("settings.json") let codmateDir = realHome.appendingPathComponent(".codmate", isDirectory: true) let helperPath = codmateDir.appendingPathComponent("mcp-enabled-claude.json") // Step 1: Load existing settings or create empty object var existingConfig: [String: Any] = [:] var existingData: Data? = nil if fm.fileExists(atPath: claudeSettingsPath.path) { existingData = try? Data(contentsOf: claudeSettingsPath) if let data = existingData, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { existingConfig = json } } // Step 2: Build mcpServers object var serversObj: [String: Any] = [:] for s in list { var entry: [String: Any] = [:] if let url = s.url { entry["url"] = url } if let cmd = s.command { entry["command"] = cmd } if let args = s.args { entry["args"] = args } if let env = s.env { entry["env"] = env } if let headers = s.headers { entry["headers"] = headers } serversObj[s.name] = entry } // Step 3: Update or remove mcpServers key if serversObj.isEmpty { existingConfig.removeValue(forKey: "mcpServers") } else { existingConfig["mcpServers"] = serversObj } // Step 4: Write atomically to ~/.claude/settings.json (with backup) try fm.createDirectory(at: claudeDir, withIntermediateDirectories: true) if let backupData = existingData { let backupPath = claudeSettingsPath.appendingPathExtension("backup") try? backupData.write(to: backupPath, options: .atomic) } let settingsData = try JSONSerialization.data(withJSONObject: existingConfig, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) try settingsData.write(to: claudeSettingsPath, options: .atomic) // Legacy helper file (no longer used). Remove if present. if fm.fileExists(atPath: helperPath.path) { try? fm.removeItem(at: helperPath) } } func delete(name: String) throws { var list = load() list.removeAll { $0.name == name } try save(list) } func setEnabled(name: String, enabled: Bool) throws { var list = load() guard let idx = list.firstIndex(where: { $0.name == name }) else { return } list[idx].enabled = enabled try save(list) } func setCapabilityEnabled(name: String, capability: String, enabled: Bool) throws { var list = load() guard let idx = list.firstIndex(where: { $0.name == name }) else { return } var caps = list[idx].capabilities if let cidx = caps.firstIndex(where: { $0.name == capability }) { caps[cidx].enabled = enabled } else { caps.append(MCPCapability(name: capability, enabled: enabled)) } list[idx].capabilities = caps try save(list) } } ================================================ FILE: services/MainWindowCoordinator.swift ================================================ import AppKit final class MainWindowCoordinator: NSObject, NSWindowDelegate { static let shared = MainWindowCoordinator() private weak var window: NSWindow? private var visibility: SystemMenuVisibility = .visible private var didAutoHideOnAttach = false private var lastAppliedVisibility: SystemMenuVisibility? var hasAttachedWindow: Bool { window != nil } func attach(_ window: NSWindow) { if self.window === window { return } self.window = window window.delegate = self applyVisibilityOnAttachIfNeeded() } func windowShouldClose(_ sender: NSWindow) -> Bool { sender.orderOut(nil) // Check if settings window is still visible let settingsWindowId = NSUserInterfaceItemIdentifier("CodMateSettingsWindow") let settingsWindowVisible = NSApplication.shared.windows.contains { window in window.identifier == settingsWindowId && window.isVisible } // Only hide Dock icon if: // 1. No other app windows are visible, AND // 2. User preference is "Menu Bar Only" mode if !settingsWindowVisible && visibility == .menuOnly { NSApplication.shared.setActivationPolicy(.accessory) } return false } func applyMenuVisibility(_ visibility: SystemMenuVisibility) { self.visibility = visibility let previous = lastAppliedVisibility lastAppliedVisibility = visibility if visibility == .menuOnly, previous != .menuOnly { hideMainWindow() } } private func applyVisibilityOnAttachIfNeeded() { guard visibility == .menuOnly, didAutoHideOnAttach == false else { return } hideMainWindow() didAutoHideOnAttach = true } private func hideMainWindow() { window?.orderOut(nil) } } ================================================ FILE: services/MenuBarController.swift ================================================ import AppKit import Combine import Foundation import SwiftUI @MainActor final class MenuBarController: NSObject, NSMenuDelegate { static let shared = MenuBarController() private let statusMenu = NSMenu() private var statusItem: NSStatusItem? private weak var viewModel: SessionListViewModel? private weak var preferences: SessionPreferencesStore? private let providersRegistry = ProvidersRegistryService() private let mcpStore = MCPServersStore() private let skillsStore = SkillsStore() private let skillsSyncer = SkillsSyncService() private let commandsStore = CommandsStore() private let commandsSyncer = CommandsSyncService() private var cachedBindings = ProvidersRegistryService.Bindings( activeProvider: nil, defaultModel: nil) private var cachedProviders: [ProvidersRegistryService.Provider] = [] private var cachedMCPServers: [MCPServer] = [] private var cachedSkills: [SkillRecord] = [] private var cachedCommands: [CommandRecord] = [] private var refreshTask: Task? private var actionHandlers: [() -> Void] = [] private var preferencesCancellable: AnyCancellable? private var usageCancellable: AnyCancellable? private var isShowingDynamicIcon = false private var isMenuOpen = false private let updateViewModel = UpdateViewModel() private let relativeFormatter: RelativeDateTimeFormatter = { let formatter = RelativeDateTimeFormatter() formatter.unitsStyle = .short return formatter }() private let usageCountdownFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() formatter.allowedUnits = [.day, .hour, .minute] formatter.unitsStyle = .abbreviated formatter.maximumUnitCount = 2 formatter.includesTimeRemainingPhrase = false return formatter }() private let usageResetFormatter: DateFormatter = { let formatter = DateFormatter() formatter.setLocalizedDateFormatFromTemplate("MMM d HH:mm") return formatter }() func configure(viewModel: SessionListViewModel, preferences: SessionPreferencesStore) { self.viewModel = viewModel self.preferences = preferences statusMenu.delegate = self preferencesCancellable?.cancel() preferencesCancellable = preferences.$systemMenuVisibility.sink { [weak self] visibility in self?.applySystemMenuVisibility(visibility) } usageCancellable?.cancel() usageCancellable = viewModel.$usageSnapshots .receive(on: RunLoop.main) .sink { [weak self] snapshots in guard let self else { return } self.updateStatusItemIcon(with: snapshots) // Rebuild menu if it's currently open to show updated usage data if self.isMenuOpen { self.rebuildMenu() } } applySystemMenuVisibility(preferences.systemMenuVisibility) refreshMenuData() } func menuWillOpen(_ menu: NSMenu) { isMenuOpen = true ensureMenuDataLoaded() rebuildMenu() refreshMenuData() } func menuDidClose(_ menu: NSMenu) { isMenuOpen = false } func reapplyVisibilityFromPreferences() { guard let preferences else { return } applySystemMenuVisibility(preferences.systemMenuVisibility) } private func ensureStatusItem() { guard statusItem == nil else { return } let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) item.button?.imagePosition = .imageOnly item.menu = statusMenu statusItem = item // Always set a placeholder icon first to avoid blank display applyStaticIcon(to: item.button) // Then update with dynamic icon if snapshots are available if let snapshots = viewModel?.usageSnapshots { updateStatusItemIcon(with: snapshots) } } private func applyStaticIcon(to button: NSStatusBarButton?) { guard let button else { return } if let image = NSImage( systemSymbolName: "fossil.shell.fill", accessibilityDescription: "CodMate") { image.isTemplate = true // Apply rotation and flip to make the shell spiral look correct let transformed = horizontallyFlippedImage(image) button.image = transformed ?? image } else { // Fallback: create a placeholder icon if system symbol fails button.image = createPlaceholderIcon() } } private func createPlaceholderIcon() -> NSImage { // Create a simple placeholder icon with the same size as menu bar icons // Use a simple circle icon as fallback let size = NSSize(width: 18, height: 18) // Try to use a system symbol as fallback if let systemImage = NSImage(systemSymbolName: "circle.fill", accessibilityDescription: "CodMate") { systemImage.isTemplate = true systemImage.size = size return systemImage } // Last resort: create a simple geometric shape let image = NSImage(size: size) image.lockFocus() // Draw a simple filled circle let rect = NSRect(origin: .zero, size: size) let path = NSBezierPath(ovalIn: rect.insetBy(dx: 4, dy: 4)) NSColor.black.setFill() path.fill() image.unlockFocus() image.isTemplate = true return image } private func updateStatusItemIcon(with snapshots: [UsageProviderKind: UsageProviderSnapshot]) { guard let button = statusItem?.button else { return } let enabledProviders = orderedEnabledProviders() // Check if we have any valid usage data to show let hasData = enabledProviders.contains { provider in guard let snapshot = snapshots[provider] else { return false } return snapshot.availability == .ready || snapshot.origin == .thirdParty } guard hasData else { // If no data, keep or revert to static icon if isShowingDynamicIcon || button.image == nil { applyStaticIcon(to: button) isShowingDynamicIcon = false } return } let referenceDate = Date() // Use fixed black color for template image generation // System automatically handles coloring (white/black) based on menu bar context let menuBarColor = Color.black let ringStates = enabledProviders.map { ringState(for: $0, relativeTo: referenceDate, snapshots: snapshots, colorOverride: menuBarColor) } let view = TripleUsageDonutView( states: ringStates, trackColor: menuBarColor ) .scaleEffect(0.7) let renderer = ImageRenderer(content: view) // Use higher scale for anti-aliased rendering on Retina displays let backingScale = NSScreen.main?.backingScaleFactor ?? 2.0 renderer.scale = backingScale * 2.0 // 4x for 2x display, 6x for 3x display if let nsImage = renderer.nsImage { nsImage.isTemplate = true // Use system template mode (ignore colors, use alpha) button.image = nsImage isShowingDynamicIcon = true } else { // Fallback to static icon if dynamic icon rendering fails applyStaticIcon(to: button) isShowingDynamicIcon = false } } private func ringState( for provider: UsageProviderKind, relativeTo date: Date, snapshots: [UsageProviderKind: UsageProviderSnapshot], colorOverride: Color? = nil ) -> UsageRingState { let color = colorOverride ?? providerColor(provider) guard let snapshot = snapshots[provider] else { return UsageRingState(progress: nil, baseColor: color, disabled: false) } if snapshot.origin == .thirdParty { return UsageRingState(progress: nil, baseColor: color, disabled: true) } guard snapshot.availability == .ready else { return UsageRingState(progress: nil, baseColor: color, disabled: false) } let urgentMetric = snapshot.urgentMetric(relativeTo: date) return UsageRingState( progress: urgentMetric?.progress, baseColor: color, healthState: urgentMetric?.healthState(relativeTo: date), disabled: false ) } private func providerColor(_ provider: UsageProviderKind) -> Color { switch provider { case .codex: return Color.accentColor case .claude: return Color(nsColor: .systemPurple) case .gemini: return Color(nsColor: .systemTeal) } } private func horizontallyFlippedImage(_ image: NSImage) -> NSImage? { // Create new image with swapped dimensions for 90° rotation let rotatedSize = NSSize(width: image.size.height, height: image.size.width) let transformed = NSImage(size: rotatedSize) transformed.lockFocus() let transform = NSAffineTransform() // Move to center of rotated canvas transform.translateX(by: rotatedSize.width / 2, yBy: rotatedSize.height / 2) // Scale to 95% of original size transform.scaleX(by: 0.95, yBy: 0.95) // Rotate 90° clockwise (negative angle for clockwise) transform.rotate(byDegrees: -90) // Flip horizontally (to make shell spiral clockwise) transform.scaleX(by: -1.0, yBy: 1.0) // Move back to draw from center transform.translateX(by: -image.size.width / 2, yBy: -image.size.height / 2) transform.concat() image.draw( at: .zero, from: NSRect(origin: .zero, size: image.size), operation: .copy, fraction: 1.0 ) transformed.unlockFocus() transformed.isTemplate = true return transformed } private func applySystemMenuVisibility(_ visibility: SystemMenuVisibility) { updateActivationPolicy(for: visibility) switch visibility { case .hidden: if let item = statusItem { NSStatusBar.system.removeStatusItem(item) statusItem = nil } case .visible, .menuOnly: ensureStatusItem() rebuildMenu() refreshMenuData() } MainWindowCoordinator.shared.applyMenuVisibility(visibility) } private func updateActivationPolicy(for visibility: SystemMenuVisibility) { #if os(macOS) let app = NSApplication.shared switch visibility { case .hidden: // When menu bar is hidden, show Dock icon so user can still access the app app.setActivationPolicy(.regular) case .visible: // When both are visible, show Dock icon app.setActivationPolicy(.regular) case .menuOnly: // Menu bar only mode - hide Dock icon app.setActivationPolicy(.accessory) } #endif } // MARK: - Menu Builders private func rebuildMenu() { statusMenu.removeAllItems() actionHandlers.removeAll(keepingCapacity: true) guard viewModel != nil else { statusMenu.addItem(disabledItem(title: "CodMate starting...")) return } // 0) Show main window let showMainItem = actionItem( title: "Show CodMate Window", action: #selector(handleOpenCodMate)) applySystemImage(showMainItem, name: "rectangle.stack") statusMenu.addItem(showMainItem) statusMenu.addItem(.separator()) // 1) Usage for provider in usageOrder() { let item = makeUsageMenuItem(for: provider) statusMenu.addItem(item) } statusMenu.addItem(.separator()) // 2) Recent Projects let recentProjects = recentProjectEntries(limit: 10) if recentProjects.isEmpty { let item = disabledItem(title: "No recent projects") applySystemImage(item, name: "square.grid.2x2") statusMenu.addItem(item) } else { for entry in recentProjects { let item = makeProjectMenuItem(entry) statusMenu.addItem(item) } } statusMenu.addItem(.separator()) // 3) Providers for provider in orderedEnabledProviders() { statusMenu.addItem(providerMenuItem(for: provider)) } statusMenu.addItem(.separator()) // 4) Extensions let commandsItem = NSMenuItem(title: "Commands", action: nil, keyEquivalent: "") applySystemImage(commandsItem, name: "command") commandsItem.submenu = buildCommandsMenu() statusMenu.addItem(commandsItem) let mcpItem = NSMenuItem(title: "MCP Servers", action: nil, keyEquivalent: "") applySystemImage(mcpItem, name: "puzzlepiece.extension") mcpItem.submenu = buildMCPServersMenu() statusMenu.addItem(mcpItem) let skillsItem = NSMenuItem(title: "Skills", action: nil, keyEquivalent: "") applySystemImage(skillsItem, name: "sparkles") skillsItem.submenu = buildSkillsMenu() statusMenu.addItem(skillsItem) let extensionsItem = actionItem( title: "Extensions...", action: #selector(handleOpenExtensionsSettings)) applySystemImage(extensionsItem, name: "puzzlepiece.extension") statusMenu.addItem(extensionsItem) statusMenu.addItem(.separator()) // 5) Global actions let globalSearchItem = actionItem( title: "Global Search...", action: #selector(handleSearchSessions)) applySystemImage(globalSearchItem, name: "magnifyingglass") statusMenu.addItem(globalSearchItem) let settingsItem = actionItem(title: "Settings...", action: #selector(handleOpenSettings)) applySystemImage(settingsItem, name: "gear") statusMenu.addItem(settingsItem) statusMenu.addItem(.separator()) // 6) About / Updates / Quit let aboutItem = actionItem(title: "About CodMate", action: #selector(handleOpenAbout)) applySystemImage(aboutItem, name: "info.circle") statusMenu.addItem(aboutItem) let updates = actionItem(title: "Check for Updates...", action: #selector(handleCheckForUpdates)) applySystemImage(updates, name: "arrow.triangle.2.circlepath") statusMenu.addItem(updates) let quitItem = actionItem(title: "Quit", action: #selector(handleQuit)) applySystemImage(quitItem, name: "power") statusMenu.addItem(quitItem) } // MARK: - Menu Item Styling Helpers private func makeAlignedMenuTitle(left: String, right: String) -> NSAttributedString { // CRITICAL for macOS 15 modern UI: // - Do NOT use custom tabStops // - Do NOT use custom font sizes // - Use ONLY system default menuFont(ofSize: 0) and color changes // Any deviation triggers legacy menu rendering mode let fullString = "\(left) \(right)" // Use spaces instead of tab for simpler layout let attr = NSMutableAttributedString(string: fullString) let fullRange = NSRange(location: 0, length: attr.length) // Use system default menu font - the ONLY font we should use for menu items let defaultMenuFont = NSFont.menuFont(ofSize: 0) attr.addAttribute(.font, value: defaultMenuFont, range: fullRange) attr.addAttribute(.foregroundColor, value: NSColor.labelColor, range: fullRange) // Style right-side text (secondary color only - no font changes, no paragraph styles) let rightLoc = (left as NSString).length + 2 // +2 for the two spaces if rightLoc < attr.length { let rightRange = NSRange(location: rightLoc, length: (right as NSString).length) if NSMaxRange(rightRange) <= attr.length { attr.addAttribute(.foregroundColor, value: NSColor.secondaryLabelColor, range: rightRange) } } return attr } private func makeProjectMenuItem(_ entry: RecentProjectEntry) -> NSMenuItem { let name = entry.project.name let time = relativeDateString(entry.lastActive) let item = NSMenuItem(title: "\(name) \(time)", action: nil, keyEquivalent: "") item.attributedTitle = makeAlignedMenuTitle(left: name, right: time) applySystemImage(item, name: "square.grid.2x2") item.submenu = buildProjectMenu(entry) return item } private func makeSessionMenuItem(_ session: SessionSummary) -> NSMenuItem { let name = session.effectiveTitle let time = relativeDateString(anchorDate(for: session)) let item = actionItem(title: "\(name) \(time)", action: #selector(handleResumeSession(_:))) item.representedObject = session.id item.image = providerImage(for: providerKind(for: session)) item.attributedTitle = makeAlignedMenuTitle(left: name, right: time) return item } private func providerMenuItem(for provider: UsageProviderKind) -> NSMenuItem { let baseTitle = "\(provider.displayName) Provider" let item = NSMenuItem(title: baseTitle, action: nil, keyEquivalent: "") item.image = providerImage(for: provider) if let rightLabel = activeProviderLabel(for: provider) { item.attributedTitle = makeAlignedMenuTitle(left: baseTitle, right: rightLabel) } item.submenu = buildProviderMenu(for: provider) return item } // MARK: - Usage Helpers private func usageOrder() -> [UsageProviderKind] { [.codex, .claude, .gemini].filter { isCLIEnabled($0) } } private func orderedEnabledProviders() -> [UsageProviderKind] { let ordered: [UsageProviderKind] = [.gemini, .claude, .codex] return ordered.filter { isCLIEnabled($0) } } private func isCLIEnabled(_ provider: UsageProviderKind) -> Bool { guard let preferences else { return true } return preferences.isCLIEnabled(provider.baseKind) } private func makeUsageMenuItem(for provider: UsageProviderKind) -> NSMenuItem { guard let viewModel, let snapshot = viewModel.usageSnapshots[provider] else { let item = NSMenuItem(title: provider.displayName, action: nil, keyEquivalent: "") item.image = providerImage(for: provider) item.submenu = buildUsageProviderMenu(provider) return item } if snapshot.origin == .thirdParty { let item = NSMenuItem(title: "\(provider.displayName) Custom provider", action: nil, keyEquivalent: "") item.image = providerImage(for: provider) item.submenu = buildUsageProviderMenu(provider) return item } let item = NSMenuItem(title: "", action: nil, keyEquivalent: "") item.image = providerImage(for: provider) item.submenu = buildUsageProviderMenu(provider) switch snapshot.availability { case .ready: let urgent = snapshot.urgentMetric() let percent = urgent?.percentText ?? "-" // Include badge in provider name if available (e.g., "Codex Free" or "Codex Plus") var providerName = snapshot.title if let badge = snapshot.titleBadge, !badge.isEmpty { providerName = "\(providerName) \(badge)" } let name = "\(providerName) (\(percent))" var reset = resetSummaryText(for: urgent) // Capitalize first letter for better presentation if !reset.isEmpty, reset.first?.isLowercase == true { reset = reset.prefix(1).uppercased() + reset.dropFirst() } // Always use aligned title to keep the provider name in the same vertical column. // Use a space if reset is empty to ensure the tab stop is applied. item.attributedTitle = makeAlignedMenuTitle(left: name, right: reset.isEmpty ? " " : reset) case .empty: // Include badge in provider name if available var providerName = snapshot.title if let badge = snapshot.titleBadge, !badge.isEmpty { providerName = "\(providerName) \(badge)" } item.title = "\(providerName) Not available" case .comingSoon: // Include badge in provider name if available var providerName = snapshot.title if let badge = snapshot.titleBadge, !badge.isEmpty { providerName = "\(providerName) \(badge)" } item.title = providerName } return item } private func makeUsageMetricMenuItem(_ metric: UsageMetricSnapshot, referenceDate: Date, provider: UsageProviderKind) -> NSMenuItem { let state = MetricDisplayState( metric: metric, referenceDate: referenceDate, resetFormatter: usageResetFormatter) var name = metric.label if provider == .gemini && name.lowercased().hasPrefix("gemini-") { name = String(name.dropFirst("gemini-".count)) } if let percent = state.percentText, !percent.isEmpty { name += " (\(percent))" } var time = state.resetText if time.hasPrefix("Expires at ") { time = String(time.dropFirst("Expires at ".count)) } let item = disabledItem(title: "\(name) \(time)") if !time.isEmpty { item.attributedTitle = makeAlignedMenuTitle(left: name, right: time) } return item } // MARK: - Submenu Builders private func buildUsageProviderMenu(_ provider: UsageProviderKind) -> NSMenu { let menu = NSMenu() guard let viewModel, let snapshot = viewModel.usageSnapshots[provider] else { menu.addItem(disabledItem(title: "No usage data available")) return menu } if snapshot.origin == .thirdParty { menu.addItem(disabledItem(title: "Custom provider (usage unavailable)")) return menu } switch snapshot.availability { case .ready: let referenceDate = Date() let metrics = snapshot.metrics.filter { $0.kind != .snapshot && $0.kind != .context } if metrics.isEmpty { menu.addItem(disabledItem(title: "No usage metrics")) } else { for metric in metrics { let item = makeUsageMetricMenuItem(metric, referenceDate: referenceDate, provider: provider) menu.addItem(item) } } menu.addItem(.separator()) let refreshItem = actionItem(title: updatedLabel(snapshot, referenceDate: referenceDate), action: #selector(handleUsageAction(_:))) refreshItem.representedObject = provider.rawValue applySystemImage(refreshItem, name: "arrow.clockwise", fallback: "arrow.triangle.2.circlepath") menu.addItem(refreshItem) case .empty: menu.addItem(disabledItem(title: snapshot.statusMessage ?? "Usage not available")) if let action = snapshot.action { menu.addItem(.separator()) menu.addItem(actionMenuItem(for: action, provider: provider)) } case .comingSoon: menu.addItem(disabledItem(title: snapshot.statusMessage ?? "Usage coming soon")) } return menu } private func buildProjectMenu(_ entry: RecentProjectEntry) -> NSMenu { let menu = NSMenu() guard let anchor = projectAnchor(for: entry.project) else { menu.addItem(disabledItem(title: "No sessions found")) return menu } let newItems = buildNewSessionMenuItems(anchor: anchor) if newItems.isEmpty { menu.addItem(disabledItem(title: "New Session")) } else { appendSplitMenuItems(newItems, to: menu) } menu.addItem(.separator()) let sessions = recentSessions(for: entry.project.id) let history = Array(sessions.prefix(10)) if history.isEmpty { menu.addItem(disabledItem(title: "No recent sessions")) return menu } for session in history { let item = makeSessionMenuItem(session) menu.addItem(item) } if sessions.count > history.count { let moreItem = actionItem(title: "More...", action: #selector(handleShowProjectTasks(_:))) moreItem.representedObject = entry.project.id applySystemImage(moreItem, name: "list.bullet.rectangle") menu.addItem(moreItem) } return menu } private func buildProviderMenu(for provider: UsageProviderKind) -> NSMenu { let menu = NSMenu() let consumer: ProvidersRegistryService.Consumer? = { switch provider { case .codex: return .codex case .claude: return .claudeCode case .gemini: return nil } }() // For Gemini, use preferences.geminiProxyProviderId instead of Consumer if provider == .gemini { guard let preferences else { menu.addItem(disabledItem(title: "Preferences not available")) return menu } let activeId = preferences.geminiProxyProviderId // Default (Built-in) option let builtIn = actionItem(title: "Default (Built-in)", action: #selector(handleSelectProvider(_:))) builtIn.representedObject = ProviderSelection(consumer: nil, providerId: nil, isGemini: true) builtIn.state = (activeId != UnifiedProviderID.autoProxyId) ? .on : .off menu.addItem(builtIn) // Auto-Proxy (CliProxyAPI) option let autoProxy = actionItem(title: "Auto-Proxy (CliProxyAPI)", action: #selector(handleSelectProvider(_:))) autoProxy.representedObject = ProviderSelection(consumer: nil, providerId: UnifiedProviderID.autoProxyId, isGemini: true) autoProxy.state = (activeId == UnifiedProviderID.autoProxyId) ? .on : .off menu.addItem(autoProxy) return menu } guard let consumer else { menu.addItem(disabledItem(title: "Providers not available")) return menu } let activeId = cachedBindings.activeProvider?[consumer.rawValue] // Default (Built-in) option let builtIn = actionItem(title: "Default (Built-in)", action: #selector(handleSelectProvider(_:))) builtIn.representedObject = ProviderSelection(consumer: consumer, providerId: nil) builtIn.state = (activeId != UnifiedProviderID.autoProxyId) ? .on : .off menu.addItem(builtIn) // Auto-Proxy (CliProxyAPI) option let autoProxy = actionItem(title: "Auto-Proxy (CliProxyAPI)", action: #selector(handleSelectProvider(_:))) autoProxy.representedObject = ProviderSelection(consumer: consumer, providerId: UnifiedProviderID.autoProxyId) autoProxy.state = (activeId == UnifiedProviderID.autoProxyId) ? .on : .off menu.addItem(autoProxy) return menu } private func providerImage(for authProvider: LocalAuthProvider) -> NSImage? { let name: String switch authProvider { case .codex: name = "ChatGPTIcon" case .claude: name = "ClaudeIcon" case .gemini: name = "GeminiIcon" case .antigravity: name = "AntigravityIcon" case .qwen: name = "QwenIcon" } return ProviderIconThemeHelper.menuImage(named: name) } private func apiKeyProviderImage(for provider: ProvidersRegistryService.Provider) -> NSImage? { guard let iconName = iconNameForAPIProvider(provider) else { return nil } return ProviderIconThemeHelper.menuImage(named: iconName) } private func iconNameForAPIProvider(_ provider: ProvidersRegistryService.Provider) -> String? { // Use unified icon resource library helper return ProviderIconResource.iconName(for: provider) } private func buildMCPServersMenu() -> NSMenu { let menu = NSMenu() let servers = cachedMCPServers.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } if servers.isEmpty { menu.addItem(disabledItem(title: "No MCP servers")) return menu } for server in servers.prefix(10) { let item = actionItem(title: server.name, action: #selector(handleToggleMCPServer(_:))) item.representedObject = server.name item.state = server.enabled ? .on : .off menu.addItem(item) } return menu } private func buildSkillsMenu() -> NSMenu { let menu = NSMenu() let skills = cachedSkills.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } if skills.isEmpty { menu.addItem(disabledItem(title: "No skills installed")) return menu } for skill in skills.prefix(10) { let item = actionItem(title: skill.name, action: #selector(handleToggleSkill(_:))) item.representedObject = skill.id item.state = skill.isEnabled ? .on : .off menu.addItem(item) } return menu } private func buildCommandsMenu() -> NSMenu { let menu = NSMenu() let commands = cachedCommands.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } if commands.isEmpty { menu.addItem(disabledItem(title: "No commands")) return menu } for command in commands.prefix(10) { let item = actionItem(title: command.name, action: #selector(handleToggleCommand(_:))) item.representedObject = command.id item.state = command.isEnabled ? .on : .off menu.addItem(item) } return menu } // MARK: - Helpers private func activeProviderLabel(for provider: UsageProviderKind) -> String? { // For Gemini, use preferences.geminiProxyProviderId if provider == .gemini { guard let preferences else { return nil } let activeId = preferences.geminiProxyProviderId if let activeId, !activeId.isEmpty, activeId == UnifiedProviderID.autoProxyId { return "Auto-Proxy" } return "Built-in" } let consumer: ProvidersRegistryService.Consumer? = { switch provider { case .codex: return .codex case .claude: return .claudeCode case .gemini: return nil } }() guard let consumer else { return nil } let activeId = cachedBindings.activeProvider?[consumer.rawValue] if let activeId, !activeId.isEmpty, activeId == UnifiedProviderID.autoProxyId { return "Auto-Proxy" } return "Built-in" } private func updatedLabel(_ snapshot: UsageProviderSnapshot, referenceDate: Date) -> String { if let updated = snapshot.updatedAt { let relative = relativeFormatter.localizedString(for: updated, relativeTo: referenceDate) return "Updated \(relative)" } else { return "Waiting for usage data" } } private func resetSummaryText(for metric: UsageMetricSnapshot?) -> String { guard let metric else { return "" } if let reset = metric.resetDate { if let countdown = resetCountdown(from: reset, kind: metric.kind) { return countdown } return usageResetFormatter.string(from: reset) } if let minutes = metric.fallbackWindowMinutes { if minutes >= 60 { return String(format: "%.1fh window", Double(minutes) / 60.0) } return "\(minutes)m window" } return "" } private func resetCountdown(from date: Date, kind: UsageMetricSnapshot.Kind) -> String? { let interval = date.timeIntervalSinceNow guard interval > 0 else { return kind == .sessionExpiry ? "expired" : "reset" } if let formatted = usageCountdownFormatter.string(from: interval) { let verb = kind == .sessionExpiry ? "expires in" : "resets in" return "\(verb) \(formatted)" } return nil } private func actionMenuItem( for action: UsageProviderSnapshot.Action, provider: UsageProviderKind ) -> NSMenuItem { let label: String switch action { case .refresh: label = "Load usage" case .authorizeKeychain: label = "Grant access" } let item = actionItem(title: label, action: #selector(handleUsageAction(_:))) item.representedObject = provider.rawValue return item } // MARK: - Projects / Sessions Helpers private func anchorDate(for session: SessionSummary) -> Date { session.lastUpdatedAt ?? session.startedAt } private struct RecentProjectEntry { let project: Project let lastActive: Date let lastSession: SessionSummary? } private func recentProjectEntries(limit: Int) -> [RecentProjectEntry] { guard let viewModel else { return [] } let sessions = allSessionSnapshot() .sorted { anchorDate(for: $0) > anchorDate(for: $1) } var seen: Set = [] var recent: [RecentProjectEntry] = [] for session in sessions { guard let pid = viewModel.projectIdForSession(session.id) else { continue } if pid == SessionListViewModel.otherProjectId { continue } guard !seen.contains(pid) else { continue } guard let project = viewModel.projects.first(where: { $0.id == pid }) else { continue } seen.insert(pid) recent.append( RecentProjectEntry( project: project, lastActive: anchorDate(for: session), lastSession: session)) if recent.count >= limit { break } } // Keep time-based descending order (most recently active projects first) return recent } private func recentSessions(for projectId: String) -> [SessionSummary] { guard let viewModel else { return [] } return allSessionSnapshot() .filter { viewModel.projectIdForSession($0.id) == projectId } .sorted { anchorDate(for: $0) > anchorDate(for: $1) } } private func projectAnchor(for project: Project) -> SessionSummary? { guard let viewModel else { return nil } if let visible = viewModel.sections.flatMap({ $0.sessions }).first(where: { viewModel.projectIdForSession($0.id) == project.id }) { return visible } return allSessionSnapshot().first(where: { viewModel.projectIdForSession($0.id) == project.id }) } private func relativeDateString(_ date: Date) -> String { relativeFormatter.localizedString(for: date, relativeTo: Date()) } // MARK: - New Session Menu (Project) private func buildNewSessionMenuItems(anchor: SessionSummary) -> [MenuNode] { guard let viewModel else { return [] } let allowed = Set(viewModel.allowedSources(for: anchor)) let requestedOrder: [ProjectSessionSource] = [.claude, .codex, .gemini] let enabledRemoteHosts = viewModel.preferences.enabledRemoteHosts.sorted() func sourceKey(_ source: SessionSource) -> String { switch source { case .codexLocal: return "codex-local" case .codexRemote(let host): return "codex-\(host)" case .claudeLocal: return "claude-local" case .claudeRemote(let host): return "claude-\(host)" case .geminiLocal: return "gemini-local" case .geminiRemote(let host): return "gemini-\(host)" } } func remoteSource(for base: ProjectSessionSource, host: String) -> SessionSource { switch base { case .codex: return .codexRemote(host: host) case .claude: return .claudeRemote(host: host) case .gemini: return .geminiRemote(host: host) } } // Build "New with" menu items - directly launch with default terminal var menuItems: [MenuNode] = [] for base in requestedOrder where allowed.contains(base) { let providerKind = providerKindForBase(base) let icon = providerImage(for: providerKind) let menuTitle = "New with \(base.displayName)" // If no remote hosts, create a simple action item if enabledRemoteHosts.isEmpty { menuItems.append( .action( id: "new-\(base.rawValue)", title: menuTitle, icon: icon, run: { [weak self] in self?.launchNewSessionWithDefaultTerminal(for: anchor, using: base.sessionSource) } ) ) } else { // If remote hosts exist, create a submenu var providerItems: [MenuNode] = [ .action( id: "new-\(base.rawValue)-local", title: "Local", run: { [weak self] in self?.launchNewSessionWithDefaultTerminal(for: anchor, using: base.sessionSource) } ) ] providerItems.append(.separator) for host in enabledRemoteHosts { let remote = remoteSource(for: base, host: host) providerItems.append( .action( id: "new-\(base.rawValue)-\(host)", title: host, run: { [weak self] in self?.launchNewSessionWithDefaultTerminal(for: anchor, using: remote) } ) ) } menuItems.append( .submenu( id: "newwith-\(base.rawValue)", title: menuTitle, icon: icon, children: providerItems) ) } } if menuItems.isEmpty { let fallbackSource = anchor.source let fallbackKind = providerKind(for: anchor) let fallbackIcon = providerImage(for: fallbackKind) menuItems.append( .action( id: "newwith-fallback", title: "New with \(fallbackSource.branding.displayName)", icon: fallbackIcon, run: { [weak self] in self?.launchNewSessionWithDefaultTerminal(for: anchor, using: fallbackSource) } ) ) } return menuItems } private func launchNewSession( for session: SessionSummary, using source: SessionSource, profile: ExternalTerminalProfile ) { guard let viewModel else { return } viewModel.launchNewSessionWithProfile( session: session, using: source, profile: profile, workingDirectory: session.cwd ) } private func launchNewSessionWithDefaultTerminal( for session: SessionSummary, using source: SessionSource ) { guard let preferences else { return } let profile = ExternalTerminalProfileStore.shared.resolvePreferredProfile( id: preferences.defaultResumeExternalAppId ) guard let profile else { return } launchNewSession(for: session, using: source, profile: profile) } // MARK: - Menu Node Builder private enum MenuNode { case action(id: String, title: String, icon: NSImage? = nil, run: () -> Void) case separator case submenu(id: String, title: String, icon: NSImage? = nil, children: [MenuNode]) } private func appendSplitMenuItems(_ items: [MenuNode], to menu: NSMenu) { for item in items { switch item { case .separator: menu.addItem(.separator()) case .action(let id, let title, let icon, let run): let mi = actionItem(title: title, action: #selector(handleDynamicAction(_:))) mi.tag = registerAction(run) mi.identifier = NSUserInterfaceItemIdentifier(id) if let icon = icon { mi.image = icon } menu.addItem(mi) case .submenu(_, let title, let icon, let children): let mi = NSMenuItem(title: title, action: nil, keyEquivalent: "") if let icon = icon { mi.image = icon } let sub = NSMenu(title: title) appendSplitMenuItems(children, to: sub) mi.submenu = sub menu.addItem(mi) } } } private func registerAction(_ action: @escaping () -> Void) -> Int { actionHandlers.append(action) return actionHandlers.count - 1 } private func actionItem(title: String, action: Selector) -> NSMenuItem { let item = NSMenuItem(title: title, action: action, keyEquivalent: "") item.target = self return item } // MARK: - Provider / Extension Data private func refreshMenuData() { refreshTask?.cancel() refreshTask = Task { [weak self] in guard let self else { return } if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() let codmate = home.appendingPathComponent(".codmate", isDirectory: true) _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync( directory: codmate, purpose: .generalAccess) } async let bindings = providersRegistry.getBindings() async let providers = providersRegistry.listProviders() async let mcpServers = mcpStore.list() async let skills = skillsStore.list() async let commands = commandsStore.listWithBuiltIns() let (bindingsResult, providersResult, mcpResult, skillsResult, commandsResult) = await ( bindings, providers, mcpServers, skills, commands ) await MainActor.run { self.cachedBindings = bindingsResult self.cachedProviders = providersResult self.cachedMCPServers = mcpResult self.cachedSkills = skillsResult self.cachedCommands = commandsResult self.rebuildMenu() } } } private func ensureMenuDataLoaded() { guard let viewModel else { return } if viewModel.allSessions.isEmpty && !viewModel.isLoading { Task { [weak self] in await viewModel.refreshSessions(force: true) await MainActor.run { self?.rebuildMenu() } } } if viewModel.usageSnapshots.isEmpty { viewModel.requestUsageStatusRefresh(for: .codex) viewModel.requestUsageStatusRefresh(for: .claude) viewModel.requestUsageStatusRefresh(for: .gemini) Task { [weak self] in try? await Task.sleep(nanoseconds: 300_000_000) await MainActor.run { self?.rebuildMenu() } } } } private func providerDisplayName(_ provider: ProvidersRegistryService.Provider) -> String { provider.name?.isEmpty == false ? provider.name! : provider.id } private func systemMenuImage(_ name: String, fallback: String? = nil) -> NSImage? { let image = NSImage(systemSymbolName: name, accessibilityDescription: nil) ?? (fallback.flatMap { NSImage(systemSymbolName: $0, accessibilityDescription: nil) }) guard let image else { return nil } image.isTemplate = true image.size = NSSize(width: 14, height: 14) return image } private func applySystemImage(_ item: NSMenuItem, name: String, fallback: String? = nil) { if let image = systemMenuImage(name, fallback: fallback) { item.image = image } } private func providerImage(for provider: UsageProviderKind) -> NSImage? { let name: String switch provider { case .codex: name = "ChatGPTIcon" case .claude: name = "ClaudeIcon" case .gemini: name = "GeminiIcon" } return ProviderIconThemeHelper.menuImage(named: name) } private func providerKind(for session: SessionSummary) -> UsageProviderKind { switch session.source.baseKind { case .codex: return .codex case .claude: return .claude case .gemini: return .gemini } } private func providerKindForBase(_ base: ProjectSessionSource) -> UsageProviderKind { switch base { case .codex: return .codex case .claude: return .claude case .gemini: return .gemini } } private func disabledItem(title: String) -> NSMenuItem { let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") item.isEnabled = false return item } private func allSessionSnapshot() -> [SessionSummary] { guard let viewModel else { return [] } if !viewModel.allSessions.isEmpty { return viewModel.allSessions } return viewModel.sections.flatMap(\.sessions) } // MARK: - Actions @objc private func handleDynamicAction(_ sender: NSMenuItem) { let idx = sender.tag guard idx >= 0 && idx < actionHandlers.count else { return } actionHandlers[idx]() } @objc private func handleUsageAction(_ sender: NSMenuItem) { guard let raw = sender.representedObject as? String, let provider = UsageProviderKind(rawValue: raw) else { return } viewModel?.requestUsageStatusRefresh(for: provider) } @objc private func handleSearchSessions() { activateApp(raiseWindows: true) NotificationCenter.default.post(name: .codMateFocusGlobalSearch, object: nil) } @objc private func handleOpenCodMate() { activateApp(raiseWindows: true) } /// Public method to handle app activation from Dock icon clicks or other external triggers func handleDockIconClick() { activateApp(raiseWindows: true) } @objc private func handleOpenSettings() { activateApp(raiseWindows: false) NotificationCenter.default.post(name: .codMateOpenSettings, object: nil) } @objc private func handleOpenAbout() { activateApp(raiseWindows: false) NotificationCenter.default.post( name: .codMateOpenSettings, object: nil, userInfo: ["category": SettingCategory.about.rawValue] ) } @objc private func handleCheckForUpdates() { updateViewModel.checkNow() activateApp(raiseWindows: false) NotificationCenter.default.post( name: .codMateOpenSettings, object: nil, userInfo: ["category": SettingCategory.about.rawValue] ) } @objc private func handleOpenExtensionsSettings() { activateApp(raiseWindows: false) NotificationCenter.default.post( name: .codMateOpenSettings, object: nil, userInfo: [ "category": SettingCategory.mcpServer.rawValue, "extensionsTab": ExtensionsSettingsTab.mcp.rawValue, ] ) } @objc func handleQuit() { guard let preferences else { NSApp.terminate(nil) return } // Check if any app windows are visible (main or settings) let visibleWindows = NSApp.windows.filter { window in window.isVisible && (window.identifier == NSUserInterfaceItemIdentifier("CodMateMainWindow") || window.identifier == NSUserInterfaceItemIdentifier("CodMateSettingsWindow")) } // If windows are open, just close them instead of quitting if !visibleWindows.isEmpty { for window in visibleWindows { window.close() } // Only hide Dock icon if user preference is "Menu Bar Only" mode if preferences.systemMenuVisibility == .menuOnly { NSApp.setActivationPolicy(.accessory) } return } // No windows open - proceed with quit confirmation if preferences.confirmBeforeQuit { let alert = NSAlert() alert.messageText = "Are you sure you want to quit CodMate?" alert.informativeText = "CodMate will hide in the menu bar. You can access it anytime." alert.alertStyle = .warning alert.addButton(withTitle: "Quit") alert.addButton(withTitle: "Cancel") let response = alert.runModal() if response == .alertSecondButtonReturn { // User clicked Cancel return } } NSApp.terminate(nil) } @objc private func handleResumeSession(_ sender: NSMenuItem) { guard let id = sender.representedObject as? String else { return } guard let session = viewModel?.sessionSummary(for: id) else { return } resumeSession(session) } @objc private func handleShowProjectTasks(_ sender: NSMenuItem) { guard let projectId = sender.representedObject as? String else { return } guard let viewModel else { return } activateApp(raiseWindows: true) viewModel.setSelectedProject(projectId) DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak viewModel] in viewModel?.projectWorkspaceMode = .tasks } } private final class ProviderSelection: NSObject { let consumer: ProvidersRegistryService.Consumer? let providerId: String? let isGemini: Bool init(consumer: ProvidersRegistryService.Consumer?, providerId: String?, isGemini: Bool = false) { self.consumer = consumer self.providerId = providerId self.isGemini = isGemini } } @objc private func handleSelectProvider(_ sender: NSMenuItem) { guard let selection = sender.representedObject as? ProviderSelection else { return } Task { [weak self] in guard let self else { return } if selection.isGemini { await applyGeminiProviderSelection(providerId: selection.providerId) } else if let consumer = selection.consumer { switch consumer { case .codex: await applyCodexProviderSelection(providerId: selection.providerId) case .claudeCode: await applyClaudeProviderSelection(providerId: selection.providerId) } } await MainActor.run { self.refreshMenuData() } } } @objc private func handleToggleMCPServer(_ sender: NSMenuItem) { guard let name = sender.representedObject as? String else { return } Task { [weak self] in await self?.toggleMCPServer(named: name) } } @objc private func handleToggleSkill(_ sender: NSMenuItem) { guard let id = sender.representedObject as? String else { return } Task { [weak self] in await self?.toggleSkill(id: id) } } @objc private func handleToggleCommand(_ sender: NSMenuItem) { guard let id = sender.representedObject as? String else { return } Task { [weak self] in await self?.toggleCommand(id: id) } } private func resumeSession(_ session: SessionSummary) { guard let preferences else { return } activateApp(raiseWindows: true) if preferences.defaultResumeUseEmbeddedTerminal { NotificationCenter.default.post( name: .codMateResumeSession, object: nil, userInfo: ["sessionId": session.id] ) } else { openPreferredExternal(session) } } private func openPreferredExternal(_ session: SessionSummary) { guard let viewModel, let preferences else { return } guard let profile = ExternalTerminalProfileStore.shared .resolvePreferredProfile(id: preferences.defaultResumeExternalAppId) else { return } guard viewModel.copyResumeCommandsIfEnabled(session: session, destinationApp: profile) else { return } let dir = viewModel.resolvedWorkingDirectory(for: session) var didNotify = false if profile.isNone { if viewModel.shouldCopyCommandsToClipboard { if viewModel.preferences.commandCopyNotificationsEnabled { Task { await SystemNotifier.shared.notify( title: "CodMate", body: "Command copied. Paste it in the opened terminal.") } } } return } if profile.usesWarpCommands { viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir) } else if profile.isTerminal { if !viewModel.openInTerminal(session: session) { _ = viewModel.copyResumeCommandsIfEnabled(session: session, destinationApp: profile) _ = viewModel.openAppleTerminal(at: dir) if viewModel.shouldCopyCommandsToClipboard { if viewModel.preferences.commandCopyNotificationsEnabled { Task { await SystemNotifier.shared.notify( title: "CodMate", body: "Command copied. Paste it in the opened terminal." ) } didNotify = true } } } } else { let cmd = profile.supportsCommandResolved ? viewModel.buildResumeCLIInvocationRespectingProject(session: session) : nil viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir, command: cmd) } if viewModel.shouldCopyCommandsToClipboard, didNotify == false, viewModel.preferences.commandCopyNotificationsEnabled { Task { await SystemNotifier.shared.notify( title: "CodMate", body: "Command copied. Paste it in the opened terminal.") } } } private func applyCodexProviderSelection(providerId: String?) async { do { try await providersRegistry.setActiveProvider(.codex, providerId: providerId) // For OAuth accounts, providerId is in format "oauth:provider:accountId" // For API key providers, providerId is in format "api:providerId" // For Auto-Proxy, providerId is UnifiedProviderID.autoProxyId let parsed = UnifiedProviderID.parse(providerId ?? "") if case .api(let apiId) = parsed { // Only apply for API key providers let all = await providersRegistry.listAllProviders() let provider = all.first(where: { $0.id == apiId }) try await CodexConfigService().applyProviderFromRegistry(provider) } else { // For OAuth accounts and Auto-Proxy, no need to apply (handled by CLI Proxy API) try await CodexConfigService().applyProviderFromRegistry(nil) } } catch { await SystemNotifier.shared.notify(title: "CodMate", body: "Failed to switch provider.") } } private func applyClaudeProviderSelection(providerId: String?) async { do { try await providersRegistry.setActiveProvider(.claudeCode, providerId: providerId) } catch { await SystemNotifier.shared.notify(title: "CodMate", body: "Failed to switch provider.") return } if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync( directory: home, purpose: .generalAccess) } let settings = ClaudeSettingsService() let isBuiltin = (providerId == nil) // Check if this is an OAuth account (format: "oauth:provider:accountId") or Auto-Proxy let parsed = UnifiedProviderID.parse(providerId ?? "") let isOAuth: Bool if case .oauth = parsed { isOAuth = true } else { isOAuth = false } let isAutoProxy: Bool if case .autoProxy = parsed { isAutoProxy = true } else { isAutoProxy = false } if isBuiltin { try? await settings.setModel(nil) try? await settings.setEnvBaseURL(nil) try? await settings.setForceLoginMethod(nil) try? await settings.setEnvToken(nil) return } // For OAuth accounts and Auto-Proxy, no need to configure settings (handled by CLI Proxy API) if isOAuth || isAutoProxy { return } let providers = await providersRegistry.listAllProviders() // For API key providers, extract the actual provider ID let actualProviderId: String? if case .api(let apiId) = parsed { actualProviderId = apiId } else { actualProviderId = providerId } guard let provider = providers.first(where: { $0.id == actualProviderId }) else { return } let connector = provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue] let loginMethod = connector?.loginMethod?.lowercased() == "subscription" ? "subscription" : "api" if let base = connector?.baseURL?.trimmingCharacters(in: .whitespacesAndNewlines), !base.isEmpty { try? await settings.setEnvBaseURL(base) } else { try? await settings.setEnvBaseURL(nil) } if loginMethod == "api" { try? await settings.setForceLoginMethod("console") } else { try? await settings.setForceLoginMethod(nil) } if loginMethod == "api" { var token: String? = nil let keyName = provider.envKey ?? connector?.envKey ?? "ANTHROPIC_AUTH_TOKEN" let env = ProcessInfo.processInfo.environment if let val = env[keyName], !val.isEmpty { token = val } else { let looksLikeToken = keyName.lowercased().contains("sk-") || keyName.hasPrefix("eyJ") || keyName.contains(".") if looksLikeToken { token = keyName } } try? await settings.setEnvToken(token) } else { try? await settings.setEnvToken(nil) } } private func applyGeminiProviderSelection(providerId: String?) async { guard let preferences else { return } // For Gemini, just update preferences.geminiProxyProviderId // No need to apply provider configuration (handled by CLI Proxy API or built-in) preferences.geminiProxyProviderId = providerId } private func toggleMCPServer(named name: String) async { do { if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() let codmate = home.appendingPathComponent(".codmate", isDirectory: true) let codex = home.appendingPathComponent(".codex", isDirectory: true) _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync( directory: codmate, purpose: .generalAccess) _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync( directory: codex, purpose: .generalAccess) _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync( directory: home, purpose: .generalAccess) } let list = await mcpStore.list() guard let server = list.first(where: { $0.name == name }) else { return } try await mcpStore.setEnabled(name: name, enabled: !server.enabled) let updated = await mcpStore.list() let codex = CodexConfigService() try? await codex.applyMCPServers(updated) try? await mcpStore.exportEnabledForClaudeConfig(servers: updated) let gemini = GeminiSettingsService() try? await gemini.applyMCPServers(updated) await MainActor.run { cachedMCPServers = updated rebuildMenu() } } catch { await SystemNotifier.shared.notify(title: "CodMate", body: "Failed to update MCP server.") } } private func toggleSkill(id: String) async { if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync( directory: home, purpose: .generalAccess) } var records = await skillsStore.list() guard let idx = records.firstIndex(where: { $0.id == id }) else { return } records[idx].isEnabled.toggle() await skillsStore.saveAll(records) let home = SessionPreferencesStore.getRealUserHomeURL() AuthorizationHub.shared.ensureDirectoryAccessOrPrompt( directory: home.appendingPathComponent(".codex", isDirectory: true), purpose: .generalAccess, message: "Authorize ~/.codex to sync Codex skills" ) AuthorizationHub.shared.ensureDirectoryAccessOrPrompt( directory: home.appendingPathComponent(".claude", isDirectory: true), purpose: .generalAccess, message: "Authorize ~/.claude to sync Claude skills" ) let warnings = await skillsSyncer.syncGlobal(skills: records) if warnings.first != nil { await SystemNotifier.shared.notify(title: "CodMate", body: "Failed to sync skills.") } await MainActor.run { cachedSkills = records rebuildMenu() } } private func toggleCommand(id: String) async { if SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync( directory: home, purpose: .generalAccess) } await commandsStore.update(id: id) { record in record.isEnabled.toggle() } let records = await commandsStore.listWithBuiltIns() let home = SessionPreferencesStore.getRealUserHomeURL() AuthorizationHub.shared.ensureDirectoryAccessOrPrompt( directory: home.appendingPathComponent(".codex", isDirectory: true), purpose: .generalAccess, message: "Authorize ~/.codex to sync Codex commands" ) AuthorizationHub.shared.ensureDirectoryAccessOrPrompt( directory: home.appendingPathComponent(".claude", isDirectory: true), purpose: .generalAccess, message: "Authorize ~/.claude to sync Claude commands" ) let warnings = await commandsSyncer.syncGlobal(commands: records) if warnings.first != nil { await SystemNotifier.shared.notify(title: "CodMate", body: "Failed to sync commands.") } await MainActor.run { cachedCommands = records rebuildMenu() } } private func activateApp(raiseWindows: Bool) { // If in .accessory mode, temporarily switch to .regular to allow window activation let needsRegularMode = NSApp.activationPolicy() == .accessory if needsRegularMode { NSApp.setActivationPolicy(.regular) } NSApp.activate(ignoringOtherApps: true) guard raiseWindows else { return } // Prioritize main window to ensure Dock clicks and menu actions show the main window let mainWindowId = NSUserInterfaceItemIdentifier("CodMateMainWindow") if let mainWindow = NSApp.windows.first(where: { $0.identifier == mainWindowId }) { mainWindow.makeKeyAndOrderFront(nil) return } // Fallback: try to find and activate any other visible window let keyable = NSApp.windows.filter { $0.canBecomeKey } if let front = keyable.first(where: { $0.isVisible }) { front.makeKeyAndOrderFront(nil) return } // Last resort: post notification to create/show main window (e.g., first launch) NotificationCenter.default.post(name: .codMateOpenMainWindow, object: nil) } } private struct MetricDisplayState { var progress: Double? var usageText: String? var percentText: String? var resetText: String init(metric: UsageMetricSnapshot, referenceDate: Date, resetFormatter: DateFormatter) { let expired = metric.resetDate.map { $0 <= referenceDate } ?? false if expired { progress = metric.progress != nil ? 0 : nil percentText = metric.percentText != nil ? "0%" : nil if metric.kind == .fiveHour { usageText = "No usage since reset" } else { usageText = metric.usageText } if metric.kind == .fiveHour { resetText = "Reset" } else { resetText = "" } } else { progress = metric.progress percentText = metric.percentText usageText = Self.remainingText(for: metric, referenceDate: referenceDate) resetText = Self.resetDescription(for: metric, resetFormatter: resetFormatter) } } private static func remainingText(for metric: UsageMetricSnapshot, referenceDate: Date) -> String? { guard let resetDate = metric.resetDate else { return metric.usageText } let remaining = resetDate.timeIntervalSince(referenceDate) if remaining <= 0 { return metric.kind == .sessionExpiry ? "Expired" : "Reset" } let minutes = Int(remaining / 60) let hours = minutes / 60 let days = hours / 24 switch metric.kind { case .fiveHour: let mins = minutes % 60 if hours > 0 { return "\(hours)h \(mins)m remaining" } return "\(mins)m remaining" case .weekly: let remainingHours = hours % 24 if days > 0 { if remainingHours > 0 { return "\(days)d \(remainingHours)h remaining" } return "\(days)d remaining" } else if hours > 0 { let mins = minutes % 60 return "\(hours)h \(mins)m remaining" } return "\(minutes)m remaining" case .sessionExpiry, .quota: let mins = minutes % 60 if hours > 0 { return "\(hours)h \(mins)m remaining" } return "\(mins)m remaining" case .context, .snapshot: return metric.usageText } } private static func resetDescription( for metric: UsageMetricSnapshot, resetFormatter: DateFormatter ) -> String { if let date = metric.resetDate { let prefix = metric.kind == .sessionExpiry ? "Expires at " : "" return prefix + resetFormatter.string(from: date) } if let minutes = metric.fallbackWindowMinutes { if minutes >= 60 { return String(format: "%.1fh window", Double(minutes) / 60.0) } return "\(minutes) min window" } return "" } } ================================================ FILE: services/PathTreeStore.swift ================================================ import Foundation // Actor to maintain an incrementally updatable directory tree built from cwd counts. actor PathTreeStore { private var root: PathTreeNode? = nil private var rootPrefix: [String] = [] func currentRoot() -> PathTreeNode? { root } func applySnapshot(counts: [String: Int]) -> PathTreeNode? { guard !counts.isEmpty else { root = nil rootPrefix = [] return nil } let newRoot = counts.buildPathTreeFromCounts() root = newRoot if let id = newRoot?.id { rootPrefix = URL(fileURLWithPath: id, isDirectory: true).pathComponents } else { rootPrefix = [] } return root } // Apply a delta map: path -> +/- count. Returns nil if a rebuild is required. func applyDelta(_ delta: [String: Int]) -> PathTreeNode? { guard !delta.isEmpty else { return root } guard var current = root else { // Nothing to update incrementally; signal rebuild return nil } func isPrefix(_ prefix: [String], of array: [String]) -> Bool { guard prefix.count <= array.count else { return false } if prefix.isEmpty { return true } let slice = Array(array.prefix(prefix.count)) return slice.elementsEqual(prefix) } // Verify all paths stay within the same root prefix; otherwise request rebuild for (path, _) in delta { let comps = URL(fileURLWithPath: path, isDirectory: true).pathComponents if !isPrefix(rootPrefix, of: comps) { return nil } } // Mutating helpers func ensureChildren(_ node: inout PathTreeNode) { if node.children == nil { node.children = [] } } func buildChain(from current: PathTreeNode, components: [String], startIndex: Int, delta: Int) -> PathTreeNode { var node = current var pathSoFar = URL(fileURLWithPath: node.id, isDirectory: true).pathComponents for i in startIndex.. PathTreeNode? { var n = node n.count += delta if index >= components.count { return n } let nextName = components[index] let targetId = NSString.path(withComponents: Array(URL(fileURLWithPath: n.id, isDirectory: true).pathComponents + [nextName])) ensureChildren(&n) if let idx = n.children?.firstIndex(where: { $0.id == targetId }) { if let childUpdated = updatedNode(n.children![idx], components: components, index: index + 1, delta: delta) { n.children![idx] = childUpdated } else { return nil } } else { // Missing intermediate node: request a rebuild instead of creating deep chains here return nil } // Prune zero-count children without descendants if var kids = n.children { kids.removeAll { $0.count <= 0 && ($0.children == nil || $0.children!.isEmpty) } n.children = kids.isEmpty ? nil : kids } return n } // Apply each delta, bailing out if any update fails for (path, d) in delta { if d == 0 { continue } let comps = URL(fileURLWithPath: path, isDirectory: true).pathComponents guard let updated = updatedNode(current, components: comps, index: rootPrefix.count, delta: d) else { return nil } current = updated } // All deltas applied successfully root = current return root } } ================================================ FILE: services/PresetPromptsStore.swift ================================================ import AppKit import Foundation // Simple, opt-in preset prompts loader. // Looks for per-project overrides first, then user-level config, then falls back to built-ins provided by caller. // Thread-safe via actor; caches small reads by path+mtime. actor PresetPromptsStore { struct Prompt: Hashable, Codable { var label: String var command: String } enum PromptLocation { case project, user, builtin } static let shared = PresetPromptsStore() private var cache: [String: (mtime: Date, items: [Prompt])] = [:] private var hiddenCache: [String: (mtime: Date, items: Set)] = [:] func load(for workingDirectory: String?) -> [Prompt] { let projectURL: URL? = workingDirectory.map { URL(fileURLWithPath: $0) .appendingPathComponent(".codmate", isDirectory: true) .appendingPathComponent("prompts.json", isDirectory: false) } let userURL = userFileURL() let projectItems = projectURL.flatMap { read(url: $0) } ?? [] let userItems = read(url: userURL) ?? [] // Merge: project-level first, then user-level excluding duplicate commands var seen = Set() var merged: [Prompt] = [] for p in projectItems { if seen.insert(p.command).inserted { merged.append(p) } } for p in userItems { if seen.insert(p.command).inserted { merged.append(p) } } return merged } // MARK: - Focused loaders func loadProjectOnly(for workingDirectory: String?) -> [Prompt] { guard let workingDirectory else { return [] } let url = URL(fileURLWithPath: workingDirectory) .appendingPathComponent(".codmate", isDirectory: true) .appendingPathComponent("prompts.json", isDirectory: false) return read(url: url) ?? [] } func loadUserOnly() -> [Prompt] { let url = userFileURL() return read(url: url) ?? [] } func projectFileExists(for workingDirectory: String?) -> Bool { guard let workingDirectory else { return false } let fm = FileManager.default let url = URL(fileURLWithPath: workingDirectory) .appendingPathComponent(".codmate", isDirectory: true) .appendingPathComponent("prompts.json", isDirectory: false) return fm.fileExists(atPath: url.path) } func openOrCreateUserFile(withTemplate template: [Prompt]) { let url = userFileURL() ensureParentDir(url) let fm = FileManager.default if !fm.fileExists(atPath: url.path) { // Write simple template let arr: [[String: String]] = template.map { ["label": $0.label, "command": $0.command] } if let data = try? JSONSerialization.data(withJSONObject: arr, options: [.prettyPrinted]) { try? data.write(to: url) } } NSWorkspace.shared.open(url) } func openOrCreatePreferredFile(for workingDirectory: String?, withTemplate template: [Prompt]) { let fm = FileManager.default let projectURL = workingDirectory.map { URL(fileURLWithPath: $0) .appendingPathComponent(".codmate", isDirectory: true) .appendingPathComponent("prompts.json", isDirectory: false) } let userURL = userFileURL() let preferredURL: URL if let p = projectURL, fm.fileExists(atPath: p.path) { preferredURL = p } else { preferredURL = userURL } ensureParentDir(preferredURL) if !fm.fileExists(atPath: preferredURL.path) { let arr: [[String: String]] = template.map { ["label": $0.label, "command": $0.command] } if let data = try? JSONSerialization.data(withJSONObject: arr, options: [.prettyPrinted]) { try? data.write(to: preferredURL) } } NSWorkspace.shared.open(preferredURL) } /// Adds a prompt record to the most appropriate file (project-level preferred, else user-level). /// Returns the URL written on success. @discardableResult func add(prompt: Prompt, for workingDirectory: String?) -> URL? { // Prefer project-level file if we have a working directory let fm = FileManager.default let projectURL: URL? = workingDirectory.map { URL(fileURLWithPath: $0) .appendingPathComponent(".codmate", isDirectory: true) .appendingPathComponent("prompts.json", isDirectory: false) } let userURL = userFileURL() let targetURL: URL = { if let p = projectURL, fm.fileExists(atPath: p.path) { return p } return userURL }() ensureParentDir(targetURL) // Load existing array or start new var items = (read(url: targetURL) ?? []) // De-duplicate by command exact match (case-sensitive) or same label+command if items.contains(where: { $0.command == prompt.command }) == false { items.append(prompt) } // Write back let arr: [[String: String]] = items.map { ["label": $0.label, "command": $0.command] } guard let data = try? JSONSerialization.data(withJSONObject: arr, options: [.prettyPrinted]) else { return nil } do { try data.write(to: targetURL) // Invalidate cache cache.removeValue(forKey: targetURL.path) return targetURL } catch { return nil } } @discardableResult func delete(prompt: Prompt, location: PromptLocation, workingDirectory: String?) -> Bool { if location == .builtin { return addHidden(command: prompt.command, for: workingDirectory) != nil } let fm = FileManager.default let targetURL: URL = { switch location { case .project: guard let cwd = workingDirectory else { return userFileURL() } return URL(fileURLWithPath: cwd) .appendingPathComponent(".codmate", isDirectory: true) .appendingPathComponent("prompts.json", isDirectory: false) case .user: return userFileURL() case .builtin: return userFileURL() // unreachable due to early return } }() guard fm.fileExists(atPath: targetURL.path) else { return false } guard var items = read(url: targetURL) else { return false } let before = items.count items.removeAll { $0.command == prompt.command } guard items.count != before else { return false } let arr: [[String: String]] = items.map { ["label": $0.label, "command": $0.command] } guard let data = try? JSONSerialization.data(withJSONObject: arr, options: [.prettyPrinted]) else { return false } do { try data.write(to: targetURL) cache.removeValue(forKey: targetURL.path) return true } catch { return false } } // MARK: - Hidden built-ins func loadHidden(for workingDirectory: String?) -> Set { let fm = FileManager.default var urls: [URL] = [] if let cwd = workingDirectory { urls.append(projectHiddenURL(for: cwd)) } urls.append(userHiddenURL()) var hidden = Set() for url in urls { guard fm.fileExists(atPath: url.path) else { continue } let mtime = (try? fm.attributesOfItem(atPath: url.path)[.modificationDate] as? Date) ?? Date.distantPast if let cached = hiddenCache[url.path], cached.mtime == mtime { hidden.formUnion(cached.items) continue } if let data = try? Data(contentsOf: url, options: [.mappedIfSafe]), let arr = try? JSONSerialization.jsonObject(with: data) as? [String] { let set = Set(arr) hiddenCache[url.path] = (mtime, set) hidden.formUnion(set) } } return hidden } @discardableResult func addHidden(command: String, for workingDirectory: String?) -> URL? { let _ = FileManager.default // Prefer project-level hidden when project file exists; else user-level let preferredProject = projectFileExists(for: workingDirectory) let url = preferredProject ? projectHiddenURL(for: workingDirectory!) : userHiddenURL() ensureParentDir(url) var list: [String] = [] if let data = try? Data(contentsOf: url), let arr = try? JSONSerialization.jsonObject(with: data) as? [String] { list = arr } if !list.contains(command) { list.append(command) } guard let data = try? JSONSerialization.data(withJSONObject: list, options: [.prettyPrinted]) else { return nil } do { try data.write(to: url) hiddenCache.removeValue(forKey: url.path) return url } catch { return nil } } private func userHiddenURL() -> URL { userFileURL().deletingLastPathComponent().appendingPathComponent("prompts-hidden.json") } private func projectHiddenURL(for workingDirectory: String) -> URL { URL(fileURLWithPath: workingDirectory) .appendingPathComponent(".codmate", isDirectory: true) .appendingPathComponent("prompts-hidden.json", isDirectory: false) } // MARK: - Private private func read(url: URL) -> [Prompt]? { let fm = FileManager.default guard fm.fileExists(atPath: url.path) else { return nil } let mtime = (try? fm.attributesOfItem(atPath: url.path)[.modificationDate] as? Date) ?? Date.distantPast if let cached = cache[url.path], cached.mtime == mtime { return cached.items } guard let data = try? Data(contentsOf: url, options: [.mappedIfSafe]) else { return nil } var parsed: [Prompt] = [] // Accept either [String] or [{label, command}] or [{title, text}] if let arr = try? JSONSerialization.jsonObject(with: data) as? [Any] { for item in arr { if let s = item as? String { parsed.append(Prompt(label: s, command: s)) } else if let dict = item as? [String: Any] { let label = (dict["label"] as? String) ?? (dict["title"] as? String) ?? (dict["name"] as? String) ?? (dict["command"] as? String) ?? (dict["text"] as? String) ?? "" let command = (dict["command"] as? String) ?? (dict["text"] as? String) ?? label if !label.isEmpty { parsed.append(Prompt(label: label, command: command)) } } } } cache[url.path] = (mtime, parsed) return parsed } private func userFileURL() -> URL { let home = FileManager.default.homeDirectoryForCurrentUser return home .appendingPathComponent(".codmate", isDirectory: true) .appendingPathComponent("prompts.json", isDirectory: false) } private func ensureParentDir(_ url: URL) { let fm = FileManager.default let dir = url.deletingLastPathComponent() if !fm.fileExists(atPath: dir.path) { try? fm.createDirectory(at: dir, withIntermediateDirectories: true) } } } ================================================ FILE: services/ProjectExtensionsApplier.swift ================================================ import Foundation actor ProjectExtensionsApplier { private let fm: FileManager private let skillsSyncer = SkillsSyncService() init(fileManager: FileManager = .default) { self.fm = fileManager } func apply( projectDirectory: URL, mcpSelections: [ProjectMCPSelection], skillRecords: [SkillRecord], skillSelections: [SkillsSyncService.SkillSelection], trustLevel: String? ) async { await ensureCodexTrustedIfNeeded( projectDirectory: projectDirectory, mcpSelections: mcpSelections, skillSelections: skillSelections, trustLevel: trustLevel ) await applyMCP(projectDirectory: projectDirectory, selections: mcpSelections) _ = await skillsSyncer.syncProject( skills: skillRecords, selections: skillSelections, projectDirectory: projectDirectory ) } private func ensureCodexTrustedIfNeeded( projectDirectory: URL, mcpSelections: [ProjectMCPSelection], skillSelections: [SkillsSyncService.SkillSelection], trustLevel: String? ) async { guard SessionPreferencesStore.isCLIEnabled(.codex) else { return } let needsCodexMCP = mcpSelections.contains { $0.isSelected && $0.targets.codex } let needsCodexSkills = skillSelections.contains { $0.isSelected && $0.targets.codex } guard needsCodexMCP || needsCodexSkills else { return } if await SecurityScopedBookmarks.shared.isSandboxed { let home = SessionPreferencesStore.getRealUserHomeURL() let codexDir = home.appendingPathComponent(".codex", isDirectory: true) await MainActor.run { AuthorizationHub.shared.ensureDirectoryAccessOrPrompt( directory: codexDir, purpose: .generalAccess, message: "Authorize ~/.codex to update trusted projects" ) } } let level = trustLevel?.trimmingCharacters(in: .whitespacesAndNewlines) let resolvedLevel = (level?.isEmpty == false ? level : nil) ?? "trusted" let service = CodexConfigService() try? await service.ensureProjectTrusted(directory: projectDirectory, trustLevel: resolvedLevel) } private func applyMCP(projectDirectory: URL, selections: [ProjectMCPSelection]) async { let selected = selections.filter { $0.isSelected } let codexServers = selected.filter { $0.targets.codex }.map { $0.server } let claudeServers = selected.filter { $0.targets.claude }.map { $0.server } let geminiServers = selected.filter { $0.targets.gemini }.map { $0.server } if SessionPreferencesStore.isCLIEnabled(.codex) { let codexDir = projectDirectory.appendingPathComponent(".codex", isDirectory: true) let configURL = codexDir.appendingPathComponent("config.toml", isDirectory: false) if !codexServers.isEmpty || fm.fileExists(atPath: configURL.path) { let ensured = ensureCodexConfig(projectDirectory: projectDirectory) ensureCodexAuthSymlink(projectDirectory: ensured.deletingLastPathComponent()) let service = CodexConfigService(paths: .init(home: codexDir, configURL: ensured)) try? await service.applyMCPServers(codexServers) } } // Claude Code official path: project_root/.mcp.json let claudeRootFile = projectDirectory.appendingPathComponent(".mcp.json", isDirectory: false) // CodMate legacy path: project_root/.claude/.mcp.json (for backward compatibility) let claudeLegacyDir = projectDirectory.appendingPathComponent(".claude", isDirectory: true) let claudeLegacyFile = claudeLegacyDir.appendingPathComponent(".mcp.json", isDirectory: false) if SessionPreferencesStore.isCLIEnabled(.claude) { if !claudeServers.isEmpty { // Write to Claude Code official path (project root) writeClaudeMCPFile(servers: claudeServers, file: claudeRootFile) // Remove legacy file if it exists to avoid conflicts if fm.fileExists(atPath: claudeLegacyFile.path) { try? fm.removeItem(at: claudeLegacyFile) } } else { // Remove both files when clearing if fm.fileExists(atPath: claudeRootFile.path) { try? fm.removeItem(at: claudeRootFile) } if fm.fileExists(atPath: claudeLegacyFile.path) { try? fm.removeItem(at: claudeLegacyFile) } } } if SessionPreferencesStore.isCLIEnabled(.gemini) { let geminiDir = projectDirectory.appendingPathComponent(".gemini", isDirectory: true) let geminiSettings = geminiDir.appendingPathComponent("settings.json", isDirectory: false) if !geminiServers.isEmpty || fm.fileExists(atPath: geminiSettings.path) { let service = GeminiSettingsService(paths: .init(directory: geminiDir, file: geminiSettings)) try? await service.applyMCPServers(geminiServers) } } } private func ensureCodexConfig(projectDirectory: URL) -> URL { let codexDir = projectDirectory.appendingPathComponent(".codex", isDirectory: true) let configURL = codexDir.appendingPathComponent("config.toml", isDirectory: false) if !fm.fileExists(atPath: configURL.path) { try? fm.createDirectory(at: codexDir, withIntermediateDirectories: true) let global = SessionPreferencesStore.getRealUserHomeURL() .appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("config.toml", isDirectory: false) if fm.fileExists(atPath: global.path) { try? fm.copyItem(at: global, to: configURL) } else { try? "".write(to: configURL, atomically: true, encoding: .utf8) } } return configURL } private func ensureCodexAuthSymlink(projectDirectory: URL) { let auth = projectDirectory.appendingPathComponent("auth.json", isDirectory: false) guard !fm.fileExists(atPath: auth.path) else { return } let global = SessionPreferencesStore.getRealUserHomeURL() .appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("auth.json", isDirectory: false) guard fm.fileExists(atPath: global.path) else { return } try? fm.createSymbolicLink(at: auth, withDestinationURL: global) } private func writeClaudeMCPFile(servers: [MCPServer], file: URL) { var obj: [String: Any] = [:] var mcpServers: [String: Any] = [:] for server in servers { var config: [String: Any] = [:] if let command = server.command { config["command"] = command } if let args = server.args, !args.isEmpty { config["args"] = args } if let env = server.env, !env.isEmpty { config["env"] = env } if let url = server.url { config["url"] = url } if let headers = server.headers, !headers.isEmpty { config["headers"] = headers } mcpServers[server.name] = config } obj["mcpServers"] = mcpServers if let data = try? JSONSerialization.data( withJSONObject: obj, options: [.prettyPrinted, .withoutEscapingSlashes]) { try? data.write(to: file, options: .atomic) } } } ================================================ FILE: services/ProjectExtensionsStore.swift ================================================ import Foundation actor ProjectExtensionsStore { struct Paths { let root: URL let extensionsDir: URL static func `default`(fileManager: FileManager = .default) -> Paths { let home = SessionPreferencesStore.getRealUserHomeURL() let root = home.appendingPathComponent(".codmate", isDirectory: true) .appendingPathComponent("projects", isDirectory: true) let extensionsDir = root.appendingPathComponent("extensions", isDirectory: true) return Paths(root: root, extensionsDir: extensionsDir) } } private let paths: Paths private let fm: FileManager init(paths: Paths = .default(), fileManager: FileManager = .default) { self.paths = paths self.fm = fileManager } func load(projectId: String) -> ProjectExtensionsConfig? { let url = configURL(for: projectId) guard fm.fileExists(atPath: url.path) else { return nil } guard let data = try? Data(contentsOf: url) else { return nil } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return try? decoder.decode(ProjectExtensionsConfig.self, from: data) } func save(_ config: ProjectExtensionsConfig) { let url = configURL(for: config.projectId) let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes] encoder.dateEncodingStrategy = .iso8601 guard let data = try? encoder.encode(config) else { return } try? fm.createDirectory(at: paths.extensionsDir, withIntermediateDirectories: true) try? data.write(to: url, options: .atomic) } func delete(projectId: String) { let url = configURL(for: projectId) if fm.fileExists(atPath: url.path) { try? fm.removeItem(at: url) } } private func configURL(for projectId: String) -> URL { paths.extensionsDir.appendingPathComponent(projectId + ".json", isDirectory: false) } static func loadSync(projectId: String) -> ProjectExtensionsConfig? { let home = SessionPreferencesStore.getRealUserHomeURL() let url = home.appendingPathComponent(".codmate", isDirectory: true) .appendingPathComponent("projects", isDirectory: true) .appendingPathComponent("extensions", isDirectory: true) .appendingPathComponent(projectId + ".json", isDirectory: false) guard FileManager.default.fileExists(atPath: url.path) else { return nil } guard let data = try? Data(contentsOf: url) else { return nil } let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601 return try? decoder.decode(ProjectExtensionsConfig.self, from: data) } static func requiresCodexHome(projectId: String) -> Bool { guard let config = loadSync(projectId: projectId) else { return false } return config.mcpServers.contains { $0.isSelected && $0.targets.codex } } } ================================================ FILE: services/ProjectsStore.swift ================================================ import Foundation // ProjectsStore: manages project metadata and session memberships // Layout (under ~/.codmate/projects): // - metadata/.json (one file per project) // - memberships.json (central mapping: { version, sessionToProject }) struct ProjectMeta: Codable, Hashable, Sendable { var id: String var name: String var directory: String? var trustLevel: String? var overview: String? var instructions: String? var profileId: String? var profile: ProjectProfile? var parentId: String? var sources: [ProjectSessionSource]? var createdAt: Date var updatedAt: Date init(from project: Project) { self.id = project.id self.name = project.name self.directory = project.directory self.trustLevel = project.trustLevel self.overview = project.overview self.instructions = project.instructions self.profileId = project.profileId self.profile = project.profile self.parentId = project.parentId self.sources = Array(project.sources).sorted { $0.rawValue < $1.rawValue } self.createdAt = Date() self.updatedAt = Date() } func asProject() -> Project { var sourceSet = Set(sources ?? ProjectSessionSource.allCases) if sourceSet.isEmpty { sourceSet = ProjectSessionSource.allSet } if !sourceSet.contains(.gemini) { sourceSet.insert(.gemini) } return Project( id: id, name: name, directory: directory, trustLevel: trustLevel, overview: overview, instructions: instructions, profileId: profileId, profile: profile, parentId: parentId, sources: sourceSet ) } } struct SessionAssignment: Codable, Hashable, Sendable { let id: String let source: ProjectSessionSource } actor ProjectsStore { struct Paths { let root: URL let metadataDir: URL let membershipsURL: URL static func `default`(fileManager: FileManager = .default) -> Paths { let home = fileManager.homeDirectoryForCurrentUser // New centralized CodMate data root let root = home.appendingPathComponent(".codmate", isDirectory: true) .appendingPathComponent("projects", isDirectory: true) return Paths( root: root, metadataDir: root.appendingPathComponent("metadata", isDirectory: true), membershipsURL: root.appendingPathComponent("memberships.json", isDirectory: false) ) } } private let fm: FileManager private let paths: Paths // runtime caches private var projects: [String: ProjectMeta] = [:] // id -> meta private var sessionToProject: [String: String] = [:] // membershipKey -> projectId private let membershipVersion = 2 init(paths: Paths = .default(), fileManager: FileManager = .default) { self.fm = fileManager self.paths = paths // Before creating new directories, attempt legacy migration from ~/.codex/projects → ~/.codmate/projects Self.migrateLegacyIfNeeded(to: paths, fm: fileManager) try? fileManager.createDirectory(at: paths.metadataDir, withIntermediateDirectories: true) // Load memberships - use local variables to avoid actor isolation issues var loadedSessionToProject: [String: String] = [:] if let data = try? Data(contentsOf: paths.membershipsURL), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { let map = obj["sessionToProject"] as? [String: String] ?? [:] let version = obj["version"] as? Int ?? 1 if version >= 2 { loadedSessionToProject = map } else { // Legacy keys did not encode the session source; assume Codex loadedSessionToProject = map.reduce(into: [:]) { result, entry in let legacyKey = Self.makeMembershipKey(for: entry.key, source: .codex) result[legacyKey] = entry.value } } } self.sessionToProject = loadedSessionToProject // Load metadata - use local variable to avoid actor isolation issues var loadedProjects: [String: ProjectMeta] = [:] if let en = fileManager.enumerator(at: paths.metadataDir, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]) { let dec = JSONDecoder(); dec.dateDecodingStrategy = .iso8601 for case let url as URL in en { if url.pathExtension.lowercased() != "json" { continue } if let data = try? Data(contentsOf: url), let meta = try? dec.decode(ProjectMeta.self, from: data) { loadedProjects[meta.id] = meta } } } self.projects = loadedProjects } // MARK: - Public API func listProjects() -> [Project] { projects.values.map { $0.asProject() }.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } } func getProject(id: String) -> Project? { projects[id]?.asProject() } func upsertProject(_ p: Project) { var meta = projects[p.id] ?? ProjectMeta(from: p) meta.name = p.name meta.directory = p.directory meta.trustLevel = p.trustLevel meta.overview = p.overview meta.instructions = p.instructions meta.profileId = p.profileId meta.profile = p.profile meta.parentId = p.parentId meta.sources = Array(p.sources).sorted { $0.rawValue < $1.rawValue } meta.updatedAt = Date() projects[p.id] = meta saveProjectMeta(meta) } func deleteProject(id: String) { // Remove meta projects.removeValue(forKey: id) let metaURL = paths.metadataDir.appendingPathComponent(id + ".json") // Move to Trash instead of permanent deletion for safety var resulting: NSURL? if fm.fileExists(atPath: metaURL.path) { do { try fm.trashItem(at: metaURL, resultingItemURL: &resulting) } catch { /* best-effort */ } } // Unassign all sessions under this project var changed = false for (sid, pid) in sessionToProject where pid == id { sessionToProject.removeValue(forKey: sid) changed = true } if changed { saveMemberships() } } private func membershipKey(for id: String, source: ProjectSessionSource) -> String { Self.makeMembershipKey(for: id, source: source) } private static func makeMembershipKey(for id: String, source: ProjectSessionSource) -> String { return "\(source.rawValue)|\(id)" } func assign(sessions: [SessionAssignment], to projectId: String?) { var changed = false for entry in sessions { let trimmed = entry.id.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { continue } let key = membershipKey(for: trimmed, source: entry.source) if let pid = projectId { if sessionToProject[key] != pid { sessionToProject[key] = pid changed = true } } else { if sessionToProject.removeValue(forKey: key) != nil { changed = true } } } if changed { saveMemberships() } } func projectId(for sessionId: String, source: ProjectSessionSource) -> String? { sessionToProject[membershipKey(for: sessionId, source: source)] } func membershipsSnapshot() -> [String: String] { sessionToProject } func counts() -> [String: Int] { sessionToProject.values.reduce(into: [:]) { $0[$1, default: 0] += 1 } } // MARK: - Load/Save private func loadAll() { /* unused post-init; kept for future reload hooks */ } private func saveProjectMeta(_ meta: ProjectMeta) { try? fm.createDirectory(at: paths.metadataDir, withIntermediateDirectories: true) let url = paths.metadataDir.appendingPathComponent(meta.id + ".json") let enc = JSONEncoder(); enc.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]; enc.dateEncodingStrategy = .iso8601 if let data = try? enc.encode(meta) { try? data.write(to: url, options: .atomic) } } private func saveMemberships() { let obj: [String: Any] = [ "version": membershipVersion, "sessionToProject": sessionToProject ] if let data = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted]) { try? fm.createDirectory(at: paths.root, withIntermediateDirectories: true) try? data.write(to: paths.membershipsURL, options: .atomic) } } // MARK: - Legacy migration /// Move or copy legacy data from `~/.codex/projects` into the new `~/.codmate/projects` location. /// - Behavior: /// - If legacy root exists and new root is missing or empty, attempt a directory move. /// - If new root exists with content, copy over missing files (non-destructive) and keep legacy as-is. private static func migrateLegacyIfNeeded(to paths: Paths, fm: FileManager) { let home = fm.homeDirectoryForCurrentUser let legacyRoot = home.appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("projects", isDirectory: true) let newRoot = paths.root // Quick existence check var isDir: ObjCBool = false let legacyExists = fm.fileExists(atPath: legacyRoot.path, isDirectory: &isDir) && isDir.boolValue guard legacyExists else { return } // Ensure parent of new root exists let newParent = newRoot.deletingLastPathComponent() try? fm.createDirectory(at: newParent, withIntermediateDirectories: true) // Determine if new root exists and is empty var newIsDir: ObjCBool = false let newExists = fm.fileExists(atPath: newRoot.path, isDirectory: &newIsDir) && newIsDir.boolValue let newIsEmpty: Bool = { guard newExists else { return true } do { let items = try fm.contentsOfDirectory(atPath: newRoot.path) return items.isEmpty } catch { return true } }() // Prefer moving the whole directory if safe if !newExists || newIsEmpty { do { if newExists && newIsEmpty { // Remove empty shell so move succeeds try? fm.removeItem(at: newRoot) } try fm.moveItem(at: legacyRoot, to: newRoot) return } catch { // Fall back to per-file copy if move fails (e.g., cross-device) } } // Non-destructive copy of missing files do { try fm.createDirectory(at: newRoot, withIntermediateDirectories: true) // Copy memberships.json if missing let legacyMemberships = legacyRoot.appendingPathComponent("memberships.json") let newMemberships = newRoot.appendingPathComponent("memberships.json") if fm.fileExists(atPath: legacyMemberships.path) && !fm.fileExists(atPath: newMemberships.path) { try? fm.copyItem(at: legacyMemberships, to: newMemberships) } // Copy metadata directory contents if missing let legacyMetadata = legacyRoot.appendingPathComponent("metadata", isDirectory: true) let newMetadata = newRoot.appendingPathComponent("metadata", isDirectory: true) var isLegacyMetaDir: ObjCBool = false if fm.fileExists(atPath: legacyMetadata.path, isDirectory: &isLegacyMetaDir), isLegacyMetaDir.boolValue { try? fm.createDirectory(at: newMetadata, withIntermediateDirectories: true) if let en = fm.enumerator(at: legacyMetadata, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]) { for case let url as URL in en { if url.pathExtension.lowercased() != "json" { continue } let dest = newMetadata.appendingPathComponent(url.lastPathComponent) if !fm.fileExists(atPath: dest.path) { try? fm.copyItem(at: url, to: dest) } } } } } catch { // Best effort; do not block app startup on migration failures } } } ================================================ FILE: services/ProvidersRegistryService.swift ================================================ import Foundation // MARK: - Providers Registry (Codex-first, Claude Code placeholder) actor ProvidersRegistryService { // Consumers we support in registry (keys for connectors/bindings) enum Consumer: String, Codable, CaseIterable { case codex, claudeCode } struct Connector: Codable, Equatable { var baseURL: String? var wireAPI: String? // responses | chat var envKey: String? // Login method for this consumer's connector: "api" (default) or "subscription" (Claude login) var loginMethod: String? var queryParams: [String: String]? var httpHeaders: [String: String]? var envHttpHeaders: [String: String]? var requestMaxRetries: Int? var streamMaxRetries: Int? var streamIdleTimeoutMs: Int? // Optional per-consumer model aliases (used by Claude Code): // keys: "default", "haiku", "sonnet", "opus" var modelAliases: [String: String]? } struct ModelCaps: Codable, Equatable { var reasoning: Bool?; var tool_use: Bool?; var vision: Bool?; var long_context: Bool? var code_tuned: Bool?; var tps_hint: String?; var max_output_tokens: Int? } struct ModelEntry: Codable, Equatable { var vendorModelId: String var caps: ModelCaps? var aliases: [String]? } struct Catalog: Codable, Equatable { var models: [ModelEntry]? } struct Recommended: Codable, Equatable { var defaultModelFor: [String: String]? // consumer -> vendorModelId } struct Provider: Codable, Identifiable, Equatable { var id: String var name: String? var `class`: String? // openai-compatible | anthropic | other var managedByCodMate: Bool // Shared API key environment variable (preferred). Connector-level envKey is deprecated. var envKey: String? // Optional references for user guidance (Get Key / Docs) var keyURL: String? var docsURL: String? var connectors: [String: Connector] // consumer -> connector var catalog: Catalog? var recommended: Recommended? // Custom SF Symbol icon name (e.g., "a.circle.fill") - only for user-created providers var customIcon: String? } struct Bindings: Codable, Equatable { var activeProvider: [String: String]? // consumer -> providerId var defaultModel: [String: String]? // consumer -> vendorModelId } struct Migration: Codable, Equatable { var importedFromCodexConfigAt: Date? } struct Registry: Codable, Equatable { var version: Int var providers: [Provider] var bindings: Bindings var migration: Migration? } // MARK: - Paths struct Paths { let home: URL; let fileURL: URL } static func defaultPaths(fileManager: FileManager = .default) -> Paths { let home = fileManager.homeDirectoryForCurrentUser let dir = home.appendingPathComponent(".codmate", isDirectory: true) return Paths(home: dir, fileURL: dir.appendingPathComponent("providers.json")) } private let fm: FileManager private let paths: Paths init(paths: Paths = ProvidersRegistryService.defaultPaths(), fileManager: FileManager = .default) { self.paths = paths self.fm = fileManager } // MARK: - Public API nonisolated func load() -> Registry { let url = paths.fileURL if let data = try? Data(contentsOf: url), let reg = try? JSONDecoder().decode(Registry.self, from: data) { return reg } return Registry( version: 1, providers: [], bindings: .init(activeProvider: nil, defaultModel: nil), migration: nil ) } func save(_ reg: Registry) throws { try fm.createDirectory(at: paths.home, withIntermediateDirectories: true) let tmp = paths.fileURL.appendingPathExtension("tmp") let enc = JSONEncoder() enc.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes] enc.dateEncodingStrategy = .iso8601 let data = try enc.encode(reg) try data.write(to: tmp, options: .atomic) // Replace atomically if fm.fileExists(atPath: paths.fileURL.path) { try fm.removeItem(at: paths.fileURL) } try fm.moveItem(at: tmp, to: paths.fileURL) } func listProviders() -> [Provider] { load().providers } // MARK: - Bundled registry (read-only, loaded from app bundle) private struct BundledProvidersFile: Codable { let providers: [Provider] } private func loadBundledRegistry() -> Registry? { // Try reading providers.json from the application bundle. // Support payload/providers.json as well as top-level providers.json. let bundle = Bundle.main var urls: [URL] = [] if let u = bundle.url(forResource: "providers", withExtension: "json") { urls.append(u) } if let u = bundle.url(forResource: "providers", withExtension: "json", subdirectory: "payload") { urls.append(u) } for url in urls { guard let data = try? Data(contentsOf: url) else { continue } let dec = JSONDecoder() // Full registry if let reg = try? dec.decode(Registry.self, from: data) { return reg } // { providers: [...] } if let file = try? dec.decode(BundledProvidersFile.self, from: data) { return Registry(version: 1, providers: file.providers, bindings: .init(activeProvider: nil, defaultModel: nil), migration: nil) } // [Provider] if let arr = try? dec.decode([Provider].self, from: data) { return Registry(version: 1, providers: arr, bindings: .init(activeProvider: nil, defaultModel: nil), migration: nil) } } return nil } // Public reader that merges user-defined providers with bundled ones (dedup by id, preferring user) func listAllProviders() -> [Provider] { let user = load().providers let builtins = loadBundledRegistry()?.providers ?? [] let userIds = Set(user.map { $0.id }) let extra = builtins.filter { !userIds.contains($0.id) } return user + extra } // Helper for services that need a full registry view including bundled providers func mergedRegistry() -> Registry { let base = load() let mergedProviders = listAllProviders() // Merge bindings: user > bundled defaults let bundled = loadBundledRegistry() var mergedBindings = base.bindings if let b = bundled?.bindings { // activeProvider var ap = mergedBindings.activeProvider ?? [:] for k in (b.activeProvider ?? [:]).keys { if ap[k] == nil { ap[k] = b.activeProvider?[k] } } mergedBindings.activeProvider = ap.isEmpty ? nil : ap // defaultModel var dm = mergedBindings.defaultModel ?? [:] for k in (b.defaultModel ?? [:]).keys { if dm[k] == nil { dm[k] = b.defaultModel?[k] } } mergedBindings.defaultModel = dm.isEmpty ? nil : dm } return Registry(version: base.version, providers: mergedProviders, bindings: mergedBindings, migration: base.migration) } // MARK: - Public: list bundled providers (templates only, no merge) func listBundledProviders() -> [Provider] { return loadBundledRegistry()?.providers ?? [] } func upsertProvider(_ provider: Provider) throws { var reg = load() if let idx = reg.providers.firstIndex(where: { $0.id == provider.id }) { reg.providers[idx] = provider } else { reg.providers.append(provider) } try save(reg) } func deleteProvider(id: String) throws { var reg = load() reg.providers.removeAll { $0.id == id } try save(reg) } func getBindings() -> Bindings { load().bindings } func setActiveProvider(_ consumer: Consumer, providerId: String?) throws { var reg = load() var ap = reg.bindings.activeProvider ?? [:] ap[consumer.rawValue] = providerId reg.bindings.activeProvider = ap try save(reg) // Notify listeners (e.g., SessionListViewModel) so usage capsules update immediately NotificationCenter.default.post( name: .codMateActiveProviderChanged, object: nil, userInfo: [ "consumer": consumer.rawValue, "providerId": providerId as Any ] ) } func setDefaultModel(_ consumer: Consumer, modelId: String?) throws { var reg = load() var dm = reg.bindings.defaultModel ?? [:] dm[consumer.rawValue] = modelId reg.bindings.defaultModel = dm try save(reg) } // MARK: - Migration from Codex config (providers + active/model) func migrateFromCodexIfNeeded(codex: CodexConfigService = CodexConfigService()) async { var reg = load() if reg.migration?.importedFromCodexConfigAt != nil { return } // If registry already has providers, skip migration if !reg.providers.isEmpty { return } // Pull from Codex config.toml let list = await codex.listProviders() let active = await codex.activeProvider() let model = await codex.getTopLevelString("model") var providers: [Provider] = [] for p in list { var connectors: [String: Connector] = [:] let c = Connector( baseURL: p.baseURL, wireAPI: p.wireAPI, envKey: p.envKey, queryParams: nil, httpHeaders: nil, envHttpHeaders: nil, requestMaxRetries: p.requestMaxRetries, streamMaxRetries: p.streamMaxRetries, streamIdleTimeoutMs: p.streamIdleTimeoutMs, modelAliases: nil ) connectors[Consumer.codex.rawValue] = c // leave claudeCode empty placeholder let np = Provider( id: p.id, name: p.name, class: "openai-compatible", managedByCodMate: p.managedByCodMate, envKey: p.envKey, connectors: connectors, catalog: nil, recommended: nil ) providers.append(np) } reg.providers = providers var ap = reg.bindings.activeProvider ?? [:] ap[Consumer.codex.rawValue] = active reg.bindings.activeProvider = ap var dm = reg.bindings.defaultModel ?? [:] if let model { dm[Consumer.codex.rawValue] = model } reg.bindings.defaultModel = dm reg.migration = .init(importedFromCodexConfigAt: Date()) try? save(reg) } } ================================================ FILE: services/RemoteSessionMirror.swift ================================================ import Foundation import OSLog enum RemoteSessionKind: String, Sendable { case codex case claude var remoteBasePath: String { switch self { case .codex: return "$HOME/.codex/sessions" case .claude: return "$HOME/.claude/projects" } } var cacheComponent: String { switch self { case .codex: return "codex" case .claude: return "claude" } } } struct RemoteMirrorOutcome { let localRoot: URL let fileMap: [URL: MirroredFile] struct MirroredFile { let remotePath: String let remoteTimestamp: TimeInterval } } actor RemoteSessionMirror { private let fileManager: FileManager private let cacheRoot: URL private let logger = Logger(subsystem: "io.umate.codemate", category: "RemoteSessionMirror") private static let sshExecutable = "/usr/bin/ssh" private static let scpExecutable = "/usr/bin/scp" private static let rsyncExecutable = "/usr/bin/rsync" private static let sshDefaultOptions: [String] = [ "-o", "ControlMaster=no", "-o", "ControlPersist=no", "-o", "ControlPath=none", "-o", "ServerAliveInterval=60", "-o", "ServerAliveCountMax=3", "-o", "StrictHostKeyChecking=accept-new", "-o", "HashKnownHosts=yes" ] init(fileManager: FileManager = .default) { self.fileManager = fileManager let base = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! .appendingPathComponent("CodMate", isDirectory: true) .appendingPathComponent("remote", isDirectory: true) self.cacheRoot = base try? fileManager.createDirectory(at: base, withIntermediateDirectories: true) } func ensureMirror( host: SSHHost, kind: RemoteSessionKind, scope: SessionLoadScope ) async throws -> RemoteMirrorOutcome { let localHostRoot = cacheRoot.appendingPathComponent(host.alias, isDirectory: true) .appendingPathComponent(kind.cacheComponent, isDirectory: true) try? fileManager.createDirectory(at: localHostRoot, withIntermediateDirectories: true) let remoteListing = try fetchRemoteListing(host: host, kind: kind, scope: scope) var pendingDownloads: [PendingDownload] = [] var fileMap: [URL: RemoteMirrorOutcome.MirroredFile] = [:] for entry in remoteListing { let localURL = localHostRoot.appendingPathComponent(entry.relativePath, isDirectory: false) try? fileManager.createDirectory( at: localURL.deletingLastPathComponent(), withIntermediateDirectories: true) if needsDownload(localURL: localURL, remoteSize: entry.size, remoteTimestamp: entry.timestamp) { pendingDownloads.append(.init(entry: entry, localURL: localURL)) } fileMap[localURL] = .init( remotePath: entry.absolutePath, remoteTimestamp: entry.timestamp ) } if !pendingDownloads.isEmpty { do { try downloadBatch( host: host, kind: kind, localRoot: localHostRoot, downloads: pendingDownloads ) } catch { logger.warning( "rsync fetch failed for host=\(host.alias, privacy: .public) count=\(pendingDownloads.count) error=\(String(describing: error), privacy: .public); falling back to scp" ) for pending in pendingDownloads { try download( host: host, remoteAbsolutePath: pending.entry.absolutePath, to: pending.localURL ) let attributes: [FileAttributeKey: Any] = [ .modificationDate: Date(timeIntervalSince1970: pending.entry.timestamp) ] try? fileManager.setAttributes(attributes, ofItemAtPath: pending.localURL.path) } } } return RemoteMirrorOutcome(localRoot: localHostRoot, fileMap: fileMap) } private struct RemoteEntry { let relativePath: String let absolutePath: String let size: UInt64 let timestamp: TimeInterval } private struct PendingDownload { let entry: RemoteEntry let localURL: URL } private func fetchRemoteListing( host: SSHHost, kind: RemoteSessionKind, scope: SessionLoadScope ) throws -> [RemoteEntry] { let base = kind.remoteBasePath let directories = relativeDirectories(for: scope) let searchPaths = directories.isEmpty ? ["."] : directories.map { $0.hasPrefix("./") ? $0 : "./\($0)" } let command = buildFindCommand(base: base, searchPaths: searchPaths) let arguments = buildSSHArguments(for: host, remoteCommand: command) let result = try ShellCommandRunner.run( executable: Self.sshExecutable, arguments: arguments ) let lines = result.stdout.split(separator: "\n", omittingEmptySubsequences: true) var entries: [RemoteEntry] = [] entries.reserveCapacity(lines.count) for line in lines { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { continue } let components = trimmed.split(separator: "|", omittingEmptySubsequences: false) guard components.count >= 3 else { continue } var pathComponent = String(components[0]) if pathComponent.hasPrefix("./") { pathComponent.removeFirst(2) } guard !pathComponent.isEmpty else { continue } let size = UInt64(components[1]) ?? 0 let timestamp = TimeInterval(components[2]) ?? 0 let absolute = joinRemote(base: base, relative: pathComponent) entries.append( RemoteEntry( relativePath: pathComponent, absolutePath: absolute, size: size, timestamp: timestamp ) ) } return entries } private func downloadBatch( host: SSHHost, kind: RemoteSessionKind, localRoot: URL, downloads: [PendingDownload] ) throws { guard !downloads.isEmpty else { return } let remoteBase = normalizeRemoteBaseForTransfer(kind.remoteBasePath) let manifest = downloads.map { $0.entry.relativePath }.joined(separator: "\n") let tempDirectory = fileManager.temporaryDirectory let manifestURL = tempDirectory.appendingPathComponent( "codemate-rsync-\(UUID().uuidString).lst", isDirectory: false ) try manifest.write(to: manifestURL, atomically: true, encoding: .utf8) defer { try? fileManager.removeItem(at: manifestURL) } let sshCommand = buildRsyncSSHCommand(for: host) let targetHost = connectionTarget(for: host) let sourceArg = "\(targetHost):\(remoteBase)/" let arguments: [String] = [ "-e", sshCommand, "--archive", "--compress", "--prune-empty-dirs", "--files-from=\(manifestURL.path)", sourceArg, localRoot.path ] logger.info( "Starting rsync mirror host=\(host.alias, privacy: .public) count=\(downloads.count)" ) try ShellCommandRunner.run( executable: Self.rsyncExecutable, arguments: arguments ) for pending in downloads { let attributes: [FileAttributeKey: Any] = [ .modificationDate: Date(timeIntervalSince1970: pending.entry.timestamp) ] try? fileManager.setAttributes(attributes, ofItemAtPath: pending.localURL.path) } } private func download(host: SSHHost, remoteAbsolutePath: String, to localURL: URL) throws { let remotePathForSCP: String if remoteAbsolutePath.hasPrefix("$HOME") { let tail = remoteAbsolutePath.dropFirst("$HOME".count) remotePathForSCP = "~" + tail } else { remotePathForSCP = remoteAbsolutePath } let arguments = buildSCPArguments( for: host, remotePath: remotePathForSCP, localPath: localURL.path ) logger.info( "Fetching via scp host=\(host.alias, privacy: .public) file=\(remotePathForSCP, privacy: .public)" ) try ShellCommandRunner.run( executable: Self.scpExecutable, arguments: arguments ) } private func buildSSHArguments(for host: SSHHost, remoteCommand: String) -> [String] { var args = buildBaseSSHOptions(for: host) args.append(connectionTarget(for: host)) args.append(remoteCommand) return args } private func buildSCPArguments(for host: SSHHost, remotePath: String, localPath: String) -> [String] { var args = buildBaseSCPOptions(for: host) args += ["-q", "-p"] args.append("\(scpConnectionTarget(for: host)):\(remotePath)") args.append(localPath) return args } private func buildBaseSSHOptions(for host: SSHHost) -> [String] { var args = Self.sshDefaultOptions if let user = host.user, !user.isEmpty { args += ["-l", user] } if let port = host.port { args += ["-p", String(port)] } if let identity = host.identityFile, !identity.isEmpty { args += ["-i", identity] } if let proxyJump = host.proxyJump, !proxyJump.isEmpty { args += ["-J", proxyJump] } if let proxyCommand = host.proxyCommand, !proxyCommand.isEmpty { args += ["-o", "ProxyCommand=\(proxyCommand)"] } if let forwardAgent = host.forwardAgent { args += ["-o", "ForwardAgent=\(forwardAgent ? "yes" : "no")"] } return args } private func buildBaseSCPOptions(for host: SSHHost) -> [String] { // SCP has different option syntax than SSH: // - No -l flag (user goes in target: user@host:path) // - Port uses -P (uppercase) instead of -p var args = Self.sshDefaultOptions if let port = host.port { args += ["-P", String(port)] } if let identity = host.identityFile, !identity.isEmpty { args += ["-i", identity] } if let proxyJump = host.proxyJump, !proxyJump.isEmpty { args += ["-o", "ProxyJump=\(proxyJump)"] } if let proxyCommand = host.proxyCommand, !proxyCommand.isEmpty { args += ["-o", "ProxyCommand=\(proxyCommand)"] } if let forwardAgent = host.forwardAgent { args += ["-o", "ForwardAgent=\(forwardAgent ? "yes" : "no")"] } return args } private func buildRsyncSSHCommand(for host: SSHHost) -> String { let parts = [Self.sshExecutable] + buildBaseSSHOptions(for: host) return parts.map(shellEscaped).joined(separator: " ") } private func connectionTarget(for host: SSHHost) -> String { host.hostname ?? host.alias } private func scpConnectionTarget(for host: SSHHost) -> String { // SCP requires user@host format (doesn't support -l flag) let hostname = host.hostname ?? host.alias // If hostname already contains @, don't add user prefix to avoid user@user@host guard !hostname.contains("@") else { return hostname } if let user = host.user, !user.isEmpty { return "\(user)@\(hostname)" } return hostname } private func shellEscaped(_ argument: String) -> String { guard argument.contains(where: { $0.isWhitespace || $0 == "'" || $0 == "\"" }) else { return argument } return "'\(argument.replacingOccurrences(of: "'", with: "'\"'\"'"))'" } private func buildFindCommand(base: String, searchPaths: [String]) -> String { let quotedBase = doubleQuoted(base) let pathArgs = searchPaths.map { doubleQuoted($0) }.joined(separator: " ") // Use /bin/sh -c to ensure POSIX shell execution regardless of remote login shell (e.g., fish) // Use double quotes for find arguments to avoid nested single-quote escaping complexity let innerCommand = "cd \(quotedBase) && { find \(pathArgs) -type f -name \"*.jsonl\" -printf \"%p|%s|%T@\\n\" 2>/dev/null || true; }" return "/bin/sh -c '\(innerCommand.replacingOccurrences(of: "'", with: "'\\''"))'" } private func needsDownload(localURL: URL, remoteSize: UInt64, remoteTimestamp: TimeInterval) -> Bool { guard let attrs = try? fileManager.attributesOfItem(atPath: localURL.path) else { return true } let size = (attrs[.size] as? NSNumber)?.uint64Value ?? 0 guard size == remoteSize else { return true } if let mtime = attrs[.modificationDate] as? Date { let delta = abs(mtime.timeIntervalSince1970 - remoteTimestamp) if delta > 0.5 { return true } } else { return true } return false } private func normalizeRemoteBaseForTransfer(_ base: String) -> String { if base.hasPrefix("$HOME") { let tail = base.dropFirst("$HOME".count) if tail.hasPrefix("/") { return "~" + tail } return "~/" + tail } return base } private func relativeDirectories(for scope: SessionLoadScope) -> [String] { let calendar = Calendar.current switch scope { case .all: return [] case .today: let today = calendar.startOfDay(for: Date()) return [formatDayComponents(calendar: calendar, date: today)] case .day(let date): let start = calendar.startOfDay(for: date) return [formatDayComponents(calendar: calendar, date: start)] case .month(let date): let comps = calendar.dateComponents([.year, .month], from: date) guard let year = comps.year, let month = comps.month else { return [] } return [String(format: "%04d/%02d", year, month)] } } private func formatDayComponents(calendar: Calendar, date: Date) -> String { let comps = calendar.dateComponents([.year, .month, .day], from: date) guard let year = comps.year, let month = comps.month, let day = comps.day else { return "." } return String(format: "%04d/%02d/%02d", year, month, day) } private func doubleQuoted(_ text: String) -> String { "\"" + text.replacingOccurrences(of: "\"", with: "\\\"") + "\"" } private func joinRemote(base: String, relative: String) -> String { if base.hasSuffix("/") { return base + relative } return base + "/" + relative } } ================================================ FILE: services/RemoteSessionProvider+Adapter.swift ================================================ import Foundation struct RemoteSessionProviderAdapter: SessionProvider { let kind: SessionSource.Kind let identifier: String let label: String let remoteKind: RemoteSessionKind let provider: RemoteSessionProvider init(kind: SessionSource.Kind, remoteKind: RemoteSessionKind, provider: RemoteSessionProvider, label: String) { self.kind = kind self.remoteKind = remoteKind self.provider = provider self.identifier = "remote-\(label.lowercased())" self.label = label } func load(context: SessionProviderContext) async throws -> SessionProviderResult { let hosts = context.enabledRemoteHosts guard !hosts.isEmpty else { return SessionProviderResult(summaries: [], coverage: nil, cacheHit: true) } switch remoteKind { case .codex: let summaries = await provider.codexSessions(scope: context.scope, enabledHosts: hosts) return SessionProviderResult(summaries: summaries, coverage: nil, cacheHit: false) case .claude: let summaries = await provider.claudeSessions(scope: context.scope, enabledHosts: hosts) return SessionProviderResult(summaries: summaries, coverage: nil, cacheHit: false) } } } ================================================ FILE: services/RemoteSessionProvider.swift ================================================ import Foundation enum RemoteSyncState: Equatable { case idle case syncing case succeeded(Date) case failed(Date, String) } actor RemoteSessionProvider { private let hostResolver: SSHConfigResolver private let mirror: RemoteSessionMirror private let indexer: SessionIndexer private let parser = ClaudeSessionParser() private let fileManager: FileManager private var cachedHosts: [SSHHost] = [] private var cachedConfigTimestamp: Date? private var lastHostsRefresh: Date? private var mirrorStore: [String: RemoteMirrorOutcome] = [:] private var syncStates: [String: RemoteSyncState] = [:] // Scope-based refresh debouncing: track active and recent refreshes private var activeRefreshes: Set = [] // Currently executing refresh keys private var lastRefreshTimes: [String: Date] = [:] private let recentCompletionWindow: TimeInterval = 0.1 // 100ms to filter rapid duplicates init( hostResolver: SSHConfigResolver = SSHConfigResolver(), mirror: RemoteSessionMirror = RemoteSessionMirror(), indexer: SessionIndexer = SessionIndexer(), fileManager: FileManager = .default ) { self.hostResolver = hostResolver self.mirror = mirror self.indexer = indexer self.fileManager = fileManager } func codexSessions(scope: SessionLoadScope, enabledHosts: Set) async -> [SessionSummary] { let hosts = filteredHosts(enabledHosts) guard !hosts.isEmpty else { return [] } let key = refreshKey(scope: scope, kind: .codex, hosts: enabledHosts) // Skip if already executing or just completed if shouldSkipRefresh(key: key) { return [] } activeRefreshes.insert(key) defer { activeRefreshes.remove(key) lastRefreshTimes[key] = Date() } return await fetchCodexSessions(scope: scope, hosts: hosts) } func claudeSessions(scope: SessionLoadScope, enabledHosts: Set) async -> [SessionSummary] { let hosts = filteredHosts(enabledHosts) guard !hosts.isEmpty else { return [] } let key = refreshKey(scope: scope, kind: .claude, hosts: enabledHosts) // Skip if already executing or just completed if shouldSkipRefresh(key: key) { return [] } activeRefreshes.insert(key) defer { activeRefreshes.remove(key) lastRefreshTimes[key] = Date() } let sessions = await fetchClaudeSessions(scope: scope, hosts: hosts) await cacheExternalSummaries(sessions) return sessions } func collectCWDAggregates(kind: RemoteSessionKind, enabledHosts: Set) async -> [String: Int] { let hosts = filteredHosts(enabledHosts) guard !hosts.isEmpty else { return [:] } var result: [String: Int] = [:] for host in hosts { do { guard let outcome = mirrorOutcome(host: host, kind: kind) else { continue } switch kind { case .codex: let counts = try await collectCodexCounts(localRoot: outcome.localRoot) for (key, value) in counts { result[key, default: 0] += value } case .claude: let counts = collectClaudeCounts(localRoot: outcome.localRoot) for (key, value) in counts { result[key, default: 0] += value } } } catch { continue } } return result } func countSessions(kind: RemoteSessionKind, enabledHosts: Set) async -> Int { let hosts = filteredHosts(enabledHosts) guard !hosts.isEmpty else { return 0 } var total = 0 for host in hosts { guard let outcome = mirrorOutcome(host: host, kind: kind) else { continue } switch kind { case .codex: let enumerator = fileManager.enumerator( at: outcome.localRoot, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], options: [.skipsHiddenFiles, .skipsPackageDescendants] ) while let url = enumerator?.nextObject() as? URL { guard url.pathExtension.lowercased() == "jsonl" else { continue } let name = url.deletingPathExtension().lastPathComponent if name.hasPrefix("agent-") { continue } if let values = try? url.resourceValues(forKeys: [.fileSizeKey]), let s = values.fileSize, s == 0 { continue } total += 1 } case .claude: let enumerator = fileManager.enumerator( at: outcome.localRoot, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], options: [.skipsHiddenFiles, .skipsPackageDescendants] ) while let url = enumerator?.nextObject() as? URL { guard url.pathExtension.lowercased() == "jsonl" else { continue } let name = url.deletingPathExtension().lastPathComponent if name.hasPrefix("agent-") { continue } if let values = try? url.resourceValues(forKeys: [.fileSizeKey]), let s = values.fileSize, s == 0 { continue } total += 1 } } } return total } // MARK: - Private helpers private func fetchCodexSessions(scope: SessionLoadScope, hosts: [SSHHost]) async -> [SessionSummary] { var aggregate: [SessionSummary] = [] for host in hosts { do { guard let outcome = mirrorOutcome(host: host, kind: .codex) else { continue } let summaries = try await indexer.refreshSessions( root: outcome.localRoot, scope: scope, dateRange: nil, projectIds: nil, projectDirectories: nil, dateDimension: .updated ) for summary in summaries { guard let metadata = outcome.fileMap[summary.fileURL] else { continue } let remoteSource: SessionSource = .codexRemote(host: host.alias) aggregate.append( summary.withRemoteMetadata( source: remoteSource, remotePath: metadata.remotePath ) ) } } catch { continue } } return aggregate } private func fetchClaudeSessions(scope: SessionLoadScope, hosts: [SSHHost]) async -> [SessionSummary] { var aggregate: [SessionSummary] = [] for host in hosts { guard let outcome = mirrorOutcome(host: host, kind: .claude) else { continue } let sessions = loadClaudeSessions( at: outcome.localRoot, scope: scope, host: host.alias, fileMap: outcome.fileMap ) aggregate.append(contentsOf: sessions) } return aggregate } private func collectCodexCounts(localRoot: URL) async throws -> [String: Int] { let counts = await indexer.collectCWDCounts(root: localRoot) return counts } private func collectClaudeCounts(localRoot: URL) -> [String: Int] { guard let enumerator = fileManager.enumerator( at: localRoot, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants] ) else { return [:] } var counts: [String: Int] = [:] for case let url as URL in enumerator { guard url.pathExtension.lowercased() == "jsonl" else { continue } if let parsed = parser.parse(at: url) { counts[parsed.summary.cwd, default: 0] += 1 } } return counts } private func loadClaudeSessions( at root: URL, scope: SessionLoadScope, host: String, fileMap: [URL: RemoteMirrorOutcome.MirroredFile] ) -> [SessionSummary] { guard let enumerator = fileManager.enumerator( at: root, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], options: [.skipsHiddenFiles, .skipsPackageDescendants] ) else { return [] } var sessions: [SessionSummary] = [] for case let url as URL in enumerator { guard url.pathExtension.lowercased() == "jsonl" else { continue } let fileSize = resolveFileSize(for: url) guard let parsed = parser.parse(at: url, fileSize: fileSize) else { continue } guard matches(scope: scope, summary: parsed.summary) else { continue } guard let metadata = fileMap[url] else { continue } sessions.append( parsed.summary.withRemoteMetadata( source: .claudeRemote(host: host), remotePath: metadata.remotePath ) ) } return sessions } private func filteredHosts(_ enabledHosts: Set) -> [SSHHost] { guard !enabledHosts.isEmpty else { return [] } if shouldReloadHosts() { cachedHosts = hostResolver.resolvedHosts() cachedConfigTimestamp = currentConfigTimestamp() lastHostsRefresh = Date() mirrorStore.removeAll() } let enabledLowercased = Set(enabledHosts.map { $0.lowercased() }) return cachedHosts.filter { enabledLowercased.contains($0.alias.lowercased()) } } private func shouldReloadHosts() -> Bool { if cachedHosts.isEmpty { return true } let configChanged = currentConfigTimestamp() != cachedConfigTimestamp if configChanged { return true } return false } private func currentConfigTimestamp() -> Date? { let attrs = try? fileManager.attributesOfItem(atPath: hostResolver.configurationURL.path) return attrs?[.modificationDate] as? Date } private func cachedMirrorOutcome( host: SSHHost, kind: RemoteSessionKind, scope: SessionLoadScope, force: Bool = false ) async throws -> RemoteMirrorOutcome { let key = mirrorCacheKey(host: host, kind: kind, scope: scope) if !force, let cached = mirrorStore[key] { return cached } let outcome = try await mirror.ensureMirror(host: host, kind: kind, scope: scope) mirrorStore[key] = outcome return outcome } private func mirrorOutcome(host: SSHHost, kind: RemoteSessionKind) -> RemoteMirrorOutcome? { mirrorStore[mirrorCacheKey(host: host, kind: kind, scope: .all)] } private func mirrorCacheKey(host: SSHHost, kind: RemoteSessionKind, scope: SessionLoadScope) -> String { mirrorCacheKey(alias: host.alias, kind: kind, scope: scope) } private func mirrorCacheKey(alias: String, kind: RemoteSessionKind, scope: SessionLoadScope) -> String { alias.lowercased() + "|" + kind.rawValue + "|" + scopeKey(scope) } private func scopeKey(_ scope: SessionLoadScope) -> String { switch scope { case .all: return "all" case .today: return "today" case .day(let date): return "day-\(Int(date.timeIntervalSince1970))" case .month(let date): return "month-\(Int(date.timeIntervalSince1970))" } } func syncHosts(_ enabledHosts: Set, force: Bool) async { let hosts = filteredHosts(enabledHosts) guard !hosts.isEmpty else { return } for host in hosts { syncStates[host.alias] = .syncing do { _ = try await cachedMirrorOutcome(host: host, kind: .codex, scope: .all, force: true) _ = try await cachedMirrorOutcome(host: host, kind: .claude, scope: .all, force: true) syncStates[host.alias] = .succeeded(Date()) } catch { syncStates[host.alias] = .failed(Date(), formatSyncError(error)) } } } func syncStatusSnapshot() -> [String: RemoteSyncState] { syncStates } private func formatSyncError(_ error: Error) -> String { if let shell = error as? ShellCommandError { switch shell { case .commandFailed(let executable, _, let stderr, let exitCode): if !stderr.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "\(stderr.trimmingCharacters(in: .whitespacesAndNewlines)) (\(executable) exited \(exitCode))" } return "\(executable) exited with code \(exitCode)" } } return error.localizedDescription } private func matches(scope: SessionLoadScope, summary: SessionSummary) -> Bool { let calendar = Calendar.current let referenceDates = [ summary.startedAt, summary.lastUpdatedAt ?? summary.startedAt ] switch scope { case .all: return true case .today: return referenceDates.contains(where: { calendar.isDateInToday($0) }) case .day(let day): return referenceDates.contains(where: { calendar.isDate($0, inSameDayAs: day) }) case .month(let date): return referenceDates.contains { calendar.isDate($0, equalTo: date, toGranularity: .month) } } } private func resolveFileSize(for url: URL) -> UInt64? { if let values = try? url.resourceValues(forKeys: [.fileSizeKey]), let size = values.fileSize { return UInt64(size) } if let attributes = try? fileManager.attributesOfItem(atPath: url.path), let number = attributes[.size] as? NSNumber { return number.uint64Value } return nil } private func cacheExternalSummaries(_ summaries: [SessionSummary]) async { guard !summaries.isEmpty else { return } await indexer.cacheExternalSummaries(summaries) } private func refreshKey(scope: SessionLoadScope, kind: RemoteSessionKind, hosts: Set) -> String { let scopePart = scopeKey(scope) let hostsPart = hosts.sorted().joined(separator: ",") return "\(kind.rawValue)|\(scopePart)|\(hostsPart)" } private func shouldSkipRefresh(key: String) -> Bool { // Skip if already executing if activeRefreshes.contains(key) { return true } // Skip if just completed (< 100ms) to filter rapid duplicates guard let lastTime = lastRefreshTimes[key] else { return false } return Date().timeIntervalSince(lastTime) < recentCompletionWindow } } ================================================ FILE: services/RepoContentSearchService.swift ================================================ import Foundation #if canImport(Darwin) import Darwin #endif actor RepoContentSearchService { enum SearchError: Error { case executableMissing case failed(String) } private var activeProcess: Process? func cancel() { activeProcess?.terminate() activeProcess = nil } func searchFilesContaining( _ term: String, in root: URL, limit: Int = 4000 ) async throws -> Set { let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return [] } precondition(limit > 0, "limit must be positive") activeProcess?.terminate() var env = ProcessInfo.processInfo.environment let defaultPath = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" let existingPath = env["PATH"] ?? ProcessInfo.processInfo.environment["PATH"] env["PATH"] = [defaultPath, existingPath] .compactMap { $0 } .joined(separator: ":") let process = Process() process.environment = env process.currentDirectoryURL = root process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = [ "rg", "--files-with-matches", "--hidden", "--follow", "--no-messages", "--ignore-case", "--color", "never", "--fixed-strings", trimmed, "." ] let stdout = Pipe() let stderr = Pipe() process.standardOutput = stdout process.standardError = stderr do { try process.run() } catch { if (error as NSError).code == ENOENT { throw SearchError.executableMissing } throw error } activeProcess = process var files: Set = [] var truncated = false do { for try await rawLine in stdout.fileHandleForReading.bytes.lines { if Task.isCancelled { process.terminate() throw CancellationError() } let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) guard !line.isEmpty else { continue } let normalized = line.hasPrefix("./") ? String(line.dropFirst(2)) : line files.insert(normalized) if files.count >= limit { truncated = true process.terminate() break } } } catch is CancellationError { process.terminate() throw CancellationError() } process.waitUntilExit() activeProcess = nil let status = process.terminationStatus if !truncated && status != 0 && status != 1 { let errData = try? stderr.fileHandleForReading.readToEnd() let message = errData.flatMap { String(data: $0, encoding: .utf8) } ?? "ripgrep exit code \(status)" throw SearchError.failed(message.trimmingCharacters(in: .whitespacesAndNewlines)) } return files } } ================================================ FILE: services/RipgrepDiskCache.swift ================================================ import Foundation // Persistent disk cache for ripgrep-derived data. // Stores per-file, per-month day coverage and per-file tool invocation counts. // Keyed by absolute file path + month key (yyyy-MM) and validated by file mtime. actor RipgrepDiskCache { private struct CoverageRecord: Codable, Hashable { let path: String let monthKey: String let mtime: TimeInterval? let days: [Int] var lastAccess: TimeInterval = Date().timeIntervalSince1970 } private struct ToolRecord: Codable, Hashable { let path: String let mtime: TimeInterval? let count: Int var lastAccess: TimeInterval = Date().timeIntervalSince1970 } private struct Snapshot: Codable { var version: Int var coverage: [CoverageRecord] var tools: [ToolRecord] } // LRU limits: prevent unbounded cache growth private let maxCoverageEntries = 10_000 // ~1000 sessions × 12 months × 0.8 private let maxToolEntries = 5_000 // ~5000 unique session files private let fileManager = FileManager.default private let cacheDirectory: URL private let url: URL private var coverageMap: [String: CoverageRecord] = [:] // key: path|monthKey private var toolMap: [String: ToolRecord] = [:] // key: path private var saveTask: Task? = nil private var dirty = false init() { let base = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! .appendingPathComponent("CodMate", isDirectory: true) try? fileManager.createDirectory(at: base, withIntermediateDirectories: true) self.cacheDirectory = base self.url = base.appendingPathComponent("rg-cache-v1.json") // Load synchronously in init - safe because actor hasn't started yet if let data = try? Data(contentsOf: url), let snap = try? PropertyListDecoder().decode(Snapshot.self, from: data), snap.version == 1 { for rec in snap.coverage { coverageMap[rec.path + "|" + rec.monthKey] = rec } for rec in snap.tools { toolMap[rec.path] = rec } } } private func makeKey(path: String, monthKey: String) -> String { path + "|" + monthKey } private func scheduleSave() { guard saveTask == nil else { return } dirty = true saveTask = Task { [weak self] in try? await Task.sleep(nanoseconds: 250_000_000) await self?.saveNow() } } private func saveNow() { saveTask = nil guard dirty else { return } dirty = false evictOldEntriesIfNeeded() let snap = Snapshot( version: 1, coverage: Array(coverageMap.values), tools: Array(toolMap.values) ) if let data = try? PropertyListEncoder().encode(snap) { try? data.write(to: url, options: .atomic) } } /// Evict oldest 20% of entries when exceeding size limits (LRU policy) private func evictOldEntriesIfNeeded() { // Evict coverage entries if over limit if coverageMap.count > maxCoverageEntries { let sortedByAccess = coverageMap.sorted { $0.value.lastAccess < $1.value.lastAccess } let keepCount = Int(Double(maxCoverageEntries) * 0.8) // Keep 80%, evict 20% let toKeep = Array(sortedByAccess.suffix(keepCount)) coverageMap = Dictionary(uniqueKeysWithValues: toKeep.map { ($0.key, $0.value) }) dirty = true } // Evict tool entries if over limit if toolMap.count > maxToolEntries { let sortedByAccess = toolMap.sorted { $0.value.lastAccess < $1.value.lastAccess } let keepCount = Int(Double(maxToolEntries) * 0.8) let toKeep = Array(sortedByAccess.suffix(keepCount)) toolMap = Dictionary(uniqueKeysWithValues: toKeep.map { ($0.key, $0.value) }) dirty = true } } // MARK: - Coverage func getCoverage(path: String, monthKey: String, mtime: Date?) -> Set? { let key = makeKey(path: path, monthKey: monthKey) guard var rec = coverageMap[key] else { return nil } let target = mtime?.timeIntervalSince1970 guard rec.mtime == target, !rec.days.isEmpty else { return nil } // Update last access time for LRU rec.lastAccess = Date().timeIntervalSince1970 coverageMap[key] = rec dirty = true return Set(rec.days) } func setCoverage(path: String, monthKey: String, mtime: Date?, days: Set) { var rec = CoverageRecord(path: path, monthKey: monthKey, mtime: mtime?.timeIntervalSince1970, days: Array(days)) rec.lastAccess = Date().timeIntervalSince1970 coverageMap[makeKey(path: path, monthKey: monthKey)] = rec scheduleSave() } func invalidateCoverage(path: String) { coverageMap = coverageMap.filter { !$0.key.hasPrefix(path + "|") } scheduleSave() } func invalidateCoverage(monthKey: String, projectPath: String?) { if let base = projectPath { coverageMap = coverageMap.filter { key, rec in !(rec.monthKey == monthKey && rec.path.hasPrefix(base)) } } else { coverageMap = coverageMap.filter { _, rec in rec.monthKey != monthKey } } scheduleSave() } // MARK: - Tools func getToolCount(path: String, mtime: Date?) -> Int? { guard var rec = toolMap[path] else { return nil } let target = mtime?.timeIntervalSince1970 guard rec.mtime == target else { return nil } // Update last access time for LRU rec.lastAccess = Date().timeIntervalSince1970 toolMap[path] = rec dirty = true return rec.count } func setToolCount(path: String, mtime: Date?, count: Int) { var rec = ToolRecord(path: path, mtime: mtime?.timeIntervalSince1970, count: count) rec.lastAccess = Date().timeIntervalSince1970 toolMap[path] = rec scheduleSave() } func invalidateTools(path: String) { toolMap.removeValue(forKey: path) scheduleSave() } } ================================================ FILE: services/RipgrepRunner.swift ================================================ import Foundation enum RipgrepError: Error, LocalizedError { case executableMissing case failed(status: Int32, message: String) var errorDescription: String? { switch self { case .executableMissing: return "ripgrep (rg) is not installed or missing from PATH." case let .failed(status, message): return "ripgrep exited with code \(status): \(message)" } } } struct RipgrepRunner { private static let defaultPath = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" static func run( arguments: [String], currentDirectory: URL? = nil ) async throws -> [String] { var env = ProcessInfo.processInfo.environment let existingPath = env["PATH"] env["PATH"] = [defaultPath, existingPath] .compactMap { $0?.isEmpty == false ? $0 : nil } .joined(separator: ":") let process = Process() process.environment = env process.currentDirectoryURL = currentDirectory process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = ["rg"] + arguments let stdout = Pipe() let stderr = Pipe() process.standardOutput = stdout process.standardError = stderr do { try process.run() } catch { if (error as NSError).code == ENOENT { throw RipgrepError.executableMissing } throw error } var lines: [String] = [] do { for try await rawLine in stdout.fileHandleForReading.bytes.lines { if Task.isCancelled { process.terminate() throw CancellationError() } let trimmed = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { lines.append(trimmed) } } } catch is CancellationError { process.terminate() throw CancellationError() } process.waitUntilExit() let status = process.terminationStatus guard status == 0 || status == 1 else { let errData = try? stderr.fileHandleForReading.readToEnd() let errString = errData.flatMap { String(data: $0, encoding: .utf8) }? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "unknown error" throw RipgrepError.failed(status: status, message: errString) } return lines } } ================================================ FILE: services/SSHConfigResolver.swift ================================================ import Foundation #if os(Linux) import Glibc #else import Darwin #endif import OSLog struct SSHHost: Hashable, Sendable { let alias: String let hostname: String? let port: Int? let user: String? let identityFile: String? let proxyJump: String? let proxyCommand: String? let forwardAgent: Bool? let additionalOptions: [String: String] } final class SSHConfigResolver { private let fileManager: FileManager private let configURL: URL private static let logger = Logger(subsystem: "io.umate.codemate", category: "SSHConfigResolver") var configurationURL: URL { configURL } private let nestedSSHDefaults: [String] = [ "-o", "ControlMaster=no", "-o", "ControlPersist=no", "-o", "ControlPath=none", "-o", "ServerAliveInterval=60", "-o", "ServerAliveCountMax=3", "-o", "StrictHostKeyChecking=accept-new", "-o", "HashKnownHosts=yes" ] private let maxResolveDepth = 8 private var cachedHosts: [SSHHost] = [] private var cachedConfigTimestamp: Date? private let hostCacheQueue = DispatchQueue(label: "io.umate.codemate.sshHostCache", qos: .utility) private struct HostBlock { let patterns: [String] let options: [(String, String)] } init( fileManager: FileManager = .default, configURL: URL = SSHConfigResolver.resolvedHomeDirectory() .appendingPathComponent(".ssh", isDirectory: true) .appendingPathComponent("config", isDirectory: false) ) { self.fileManager = fileManager self.configURL = configURL } /// Cache the resolved home directory to avoid repeated expensive lookups/log spam. private static let cachedHomeDirectory: URL = { // 1. Try to get from pw_dir (user database) if let pw = getpwuid(getuid()), let home = pw.pointee.pw_dir { let homePath = String(cString: home) if !homePath.contains("Library/Containers") { logger.debug("Resolved home via pw_dir: \(homePath, privacy: .public)") return URL(fileURLWithPath: homePath, isDirectory: true) } } // 2. Try to construct from user name if let userName = getpwuid(getuid())?.pointee.pw_name { let userNameStr = String(cString: userName) let constructedPath = "/Users/\(userNameStr)" if FileManager.default.fileExists(atPath: constructedPath) { logger.debug("Resolved home via constructed path: \(constructedPath, privacy: .public)") return URL(fileURLWithPath: constructedPath, isDirectory: true) } } // 3. Try to use shell to get home directory let task = Process() task.launchPath = "/bin/sh" task.arguments = ["-c", "echo $HOME"] let pipe = Pipe() task.standardOutput = pipe task.launch() let data = pipe.fileHandleForReading.readDataToEndOfFile() task.waitUntilExit() if task.terminationStatus == 0, let output = String(data: data, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines), !output.contains("Library/Containers"), !output.isEmpty { logger.debug("Resolved home via shell: \(output, privacy: .public)") return URL(fileURLWithPath: output, isDirectory: true) } // 4. Last resort: use the sandboxed path let sandboxPath = FileManager.default.homeDirectoryForCurrentUser logger.debug("Resolved home fallback to sandbox path: \(sandboxPath.path, privacy: .public)") return sandboxPath }() /// Get the real user home directory, even in sandboxed apps static func resolvedHomeDirectory() -> URL { cachedHomeDirectory } private func cachedHostsIfValid() -> [SSHHost]? { hostCacheQueue.sync { guard let cachedTimestamp = cachedConfigTimestamp else { return nil } guard cachedTimestamp == currentConfigTimestamp() else { return nil } return cachedHosts } } private func currentConfigTimestamp() -> Date? { let attrs = try? fileManager.attributesOfItem(atPath: configURL.path) return attrs?[.modificationDate] as? Date } func resolvedHosts(forceReload: Bool = false) -> [SSHHost] { if !forceReload, let cached = cachedHostsIfValid() { return cached } print("SSHConfigResolver: Attempting to read SSH config from: \(configURL.path)") print("SSHConfigResolver: FileManager.default.homeDirectoryForCurrentUser: \(FileManager.default.homeDirectoryForCurrentUser.path)") print("SSHConfigResolver: ProcessInfo.HOME: \(ProcessInfo.processInfo.environment["HOME"] ?? "not found")") guard fileManager.fileExists(atPath: configURL.path) else { print("SSH config file does not exist at: \(configURL.path)") return [] } guard fileManager.isReadableFile(atPath: configURL.path) else { print("SSH config file is not readable at: \(configURL.path)") return [] } var blocks: [HostBlock] = [] var visited: Set = [] parseConfig(at: configURL, visited: &visited, into: &blocks) let hosts = buildHosts(from: blocks) hostCacheQueue.sync { cachedHosts = hosts cachedConfigTimestamp = currentConfigTimestamp() } return hosts } private func parseConfig( at url: URL, visited: inout Set, into blocks: inout [HostBlock] ) { let canonical = url.standardizedFileURL guard visited.insert(canonical).inserted else { print("SSHConfigResolver: Skipping already processed include at \(canonical.path)") return } guard let raw = try? String(contentsOf: canonical, encoding: .utf8) else { print("SSHConfigResolver: Failed to read config at \(canonical.path)") return } var currentPatterns: [String]? = nil var currentOptions: [(String, String)] = [] func flushCurrent() { guard let patterns = currentPatterns else { return } guard !patterns.isEmpty else { currentPatterns = nil currentOptions.removeAll() return } if !currentOptions.isEmpty { blocks.append(HostBlock(patterns: patterns, options: currentOptions)) } currentPatterns = nil currentOptions.removeAll() } let lines = raw.components(separatedBy: .newlines) let baseDirectory = canonical.deletingLastPathComponent() for rawLine in lines { let line = rawLine.trimmingCharacters(in: .whitespaces) guard !line.isEmpty else { continue } guard !line.hasPrefix("#") else { continue } let lower = line.lowercased() if lower.hasPrefix("include ") { flushCurrent() let patternPart = line.dropFirst("include".count) .trimmingCharacters(in: .whitespacesAndNewlines) let tokens = patternPart.split(whereSeparator: { $0.isWhitespace }).map(String.init) if tokens.isEmpty { continue } for token in tokens { let targets = resolveIncludeTargets(token, relativeTo: baseDirectory) if targets.isEmpty { print("SSHConfigResolver: Include pattern '\(token)' had no matches") } else { for target in targets { parseConfig(at: target, visited: &visited, into: &blocks) } } } continue } if lower.hasPrefix("host ") && !lower.hasPrefix("hostname ") { flushCurrent() let hostPart = line.dropFirst("host".count).trimmingCharacters(in: .whitespaces) let patterns = hostPart.split(whereSeparator: { $0.isWhitespace }).map(String.init) currentPatterns = patterns continue } guard let (key, value) = parseOption(line) else { continue } if currentPatterns == nil { currentPatterns = ["*"] } currentOptions.append((key, value)) } flushCurrent() } private func parseOption(_ line: String) -> (String, String)? { let parts = line.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) guard parts.count == 2 else { return nil } let key = parts[0].lowercased() let value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) return (key, value) } private func resolveIncludeTargets(_ pattern: String, relativeTo base: URL) -> [URL] { var expanded = pattern if expanded.hasPrefix("~") { expanded = NSString(string: expanded).expandingTildeInPath } else if !expanded.hasPrefix("/") { expanded = base.appendingPathComponent(expanded).path } var globResult = glob_t() defer { globfree(&globResult) } let status = expanded.withCString { cPattern in glob(cPattern, GLOB_TILDE | GLOB_BRACE, nil, &globResult) } guard status == 0 else { return [] } var urls: [URL] = [] for index in 0.. [SSHHost] { var aliasOrder: [String] = [] var seen: Set = [] for block in blocks { for pattern in block.patterns { guard !containsWildcards(pattern) else { continue } let key = pattern.lowercased() if seen.insert(key).inserted { aliasOrder.append(pattern) } } } var optionMap: [String: [String: String]] = [:] for alias in aliasOrder { var options: [String: String] = [:] for block in blocks { guard block.patterns.contains(where: { patternMatches($0, alias: alias) }) else { continue } for (key, value) in block.options { if options[key] == nil { options[key] = value } } } optionMap[alias.lowercased()] = options } var hosts: [SSHHost] = [] for alias in aliasOrder { guard let options = optionMap[alias.lowercased()] else { continue } let host = makeHost(alias: alias, options: options, optionMap: optionMap) hosts.append(host) } return hosts } private func makeHost( alias: String, options: [String: String], optionMap: [String: [String: String]], depth: Int = 0 ) -> SSHHost { let hostname = options["hostname"] let port = options["port"].flatMap { Int($0) } let user = options["user"] let identityFile = options["identityfile"].map(expandTildeIfNeeded) let proxyJump = resolvedProxyJump( from: options["proxyjump"], optionMap: optionMap, depth: depth ) let proxyCommand = resolvedProxyCommand( from: options["proxycommand"], alias: alias, optionMap: optionMap, hostname: hostname ?? alias, port: port, depth: depth ) let forwardAgent: Bool? if let agentRaw = options["forwardagent"]?.lowercased() { forwardAgent = agentRaw == "yes" || agentRaw == "true" } else { forwardAgent = nil } return SSHHost( alias: alias, hostname: hostname, port: port, user: user, identityFile: identityFile, proxyJump: proxyJump, proxyCommand: proxyCommand, forwardAgent: forwardAgent, additionalOptions: options ) } private func resolvedProxyJump( from raw: String?, optionMap: [String: [String: String]], depth: Int ) -> String? { guard let raw = raw, !raw.isEmpty else { return nil } guard depth < maxResolveDepth else { return nil } let hops = raw.split(separator: ",") let resolved = hops.map { hop -> String in let trimmed = hop.trimmingCharacters(in: .whitespacesAndNewlines) return resolveEndpoint(trimmed, optionMap: optionMap) } return resolved.joined(separator: ",") } private func resolveEndpoint(_ token: String, optionMap: [String: [String: String]]) -> String { if token.contains("@") || token.contains(":") { return token } let key = token.lowercased() guard let options = optionMap[key] else { return token } let hostName = options["hostname"] ?? token var result = "" if let user = options["user"] { result += "\(user)@" } result += hostName if let portString = options["port"], let port = Int(portString) { result += ":\(port)" } return result } private func resolvedProxyCommand( from raw: String?, alias: String, optionMap: [String: [String: String]], hostname: String, port: Int?, depth: Int ) -> String? { guard var command = raw, !command.isEmpty else { return nil } guard depth < maxResolveDepth else { return nil } command = command.replacingOccurrences(of: "%h", with: hostname) let portString = port.map(String.init) ?? "22" command = command.replacingOccurrences(of: "%p", with: portString) if let rewritten = rewriteProxyCommand(command, optionMap: optionMap, depth: depth) { return rewritten } return command } private func rewriteProxyCommand( _ command: String, optionMap: [String: [String: String]], depth: Int ) -> String? { guard let tokens = shellSplit(command), !tokens.isEmpty else { return nil } let sshCommand = tokens[0] guard sshCommand.hasSuffix("ssh") || sshCommand == "ssh" else { return nil } guard let lastToken = tokens.last else { return nil } guard let aliasOptions = optionMap[lastToken.lowercased()] else { return nil } guard let wIndex = tokens.firstIndex(of: "-W"), wIndex + 1 < tokens.count else { return nil } let destination = tokens[wIndex + 1] let aliasHost = makeHost( alias: lastToken, options: aliasOptions, optionMap: optionMap, depth: depth + 1 ) var rewritten: [String] = [sshCommand] rewritten += nestedSSHDefaults rewritten += sshTokens(for: aliasHost) var index = 1 while index < tokens.count - 1 { if index == wIndex { rewritten.append("-W") rewritten.append(destination) index += 2 continue } rewritten.append(tokens[index]) index += 1 } let targetHost = aliasHost.hostname ?? lastToken rewritten.append(targetHost) return rewritten.map(shellEscape).joined(separator: " ") } private func sshTokens(for host: SSHHost) -> [String] { var tokens: [String] = [] if let user = host.user, !user.isEmpty { tokens += ["-l", user] } if let port = host.port { tokens += ["-p", String(port)] } if let identity = host.identityFile, !identity.isEmpty { tokens += ["-i", identity] } if let proxyJump = host.proxyJump, !proxyJump.isEmpty { tokens += ["-J", proxyJump] } if let proxyCommand = host.proxyCommand, !proxyCommand.isEmpty { tokens += ["-o", "ProxyCommand=\(proxyCommand)"] } if let forwardAgent = host.forwardAgent { tokens += ["-o", "ForwardAgent=\(forwardAgent ? "yes" : "no")"] } return tokens } private func shellSplit(_ command: String) -> [String]? { var tokens: [String] = [] var current = "" var inSingle = false var inDouble = false var escaped = false for char in command { if escaped { current.append(char) escaped = false continue } if char == "\\" && !inSingle { escaped = true continue } if char == "'" && !inDouble { inSingle.toggle() continue } if char == "\"" && !inSingle { inDouble.toggle() continue } if char.isWhitespace && !inSingle && !inDouble { if !current.isEmpty { tokens.append(current) current = "" } continue } current.append(char) } if escaped || inSingle || inDouble { return nil } if !current.isEmpty { tokens.append(current) } return tokens } private func shellEscape(_ value: String) -> String { guard value.contains(where: { $0.isWhitespace || $0 == "'" || $0 == "\"" }) else { return value } return "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'" } private func containsWildcards(_ pattern: String) -> Bool { pattern.contains("*") || pattern.contains("?") } private func patternMatches(_ pattern: String, alias: String) -> Bool { pattern.withCString { p in alias.withCString { a in fnmatch(p, a, FNM_CASEFOLD) == 0 } } } private func expandTildeIfNeeded(_ path: String) -> String { guard path.hasPrefix("~") else { return path } let home = Self.resolvedHomeDirectory().path let suffix = path.dropFirst(1) return home + suffix } } ================================================ FILE: services/SandboxPermissionsManager.swift ================================================ import Foundation import SwiftUI /// Get the real user home directory, not the sandbox container private func getRealUserHome() -> String { // Use POSIX API to get the actual user home directory // This works even in sandbox mode if let homeDir = getpwuid(getuid())?.pointee.pw_dir { return String(cString: homeDir) } // Fallback to HOME environment variable if let home = ProcessInfo.processInfo.environment["HOME"] { return home } // Last resort fallback return NSHomeDirectory() } /// Manages sandbox permissions for critical directories needed by CodMate @MainActor final class SandboxPermissionsManager: ObservableObject { static let shared = SandboxPermissionsManager() @Published var needsAuthorization: Bool = false @Published var missingPermissions: [RequiredDirectory] = [] enum RequiredDirectory: String, CaseIterable, Identifiable { case codexSessions = "~/.codex" case claudeSessions = "~/.claude" case geminiSessions = "~/.gemini" case codmateData = "~/.codmate" case sshConfig = "~/.ssh" var id: String { rawValue } var displayName: String { switch self { case .codexSessions: return "Codex Directory" case .claudeSessions: return "Claude Code Directory" case .geminiSessions: return "Gemini Directory" case .codmateData: return "CodMate Data Directory" case .sshConfig: return "SSH Configuration" } } var description: String { switch self { case .codexSessions: return "Access Codex session history and data" case .claudeSessions: return "Access Claude Code projects and sessions" case .geminiSessions: return "Access Gemini CLI session history" case .codmateData: return "Access CodMate configuration, notes, and cache" case .sshConfig: return "Read your ~/.ssh/config file to discover remote hosts" } } var expandedPath: URL { // Get the real user home directory, NOT the sandbox container let realHomePath = getRealUserHome() let path = rawValue.replacingOccurrences(of: "~", with: realHomePath) return URL(fileURLWithPath: path) } /// Bookmark key for this directory var bookmarkKey: String { switch self { case .codexSessions: return "bookmark.codexSessions" case .claudeSessions: return "bookmark.claudeSessions" case .geminiSessions: return "bookmark.geminiSessions" case .codmateData: return "bookmark.codmateData" case .sshConfig: return "bookmark.sshConfig" } } } private let bookmarks = SecurityScopedBookmarks.shared private let defaults = UserDefaults.standard private var didRestorePermissions = false private init() { checkPermissions() } /// Check if all required directories have been authorized func checkPermissions() { guard bookmarks.isSandboxed else { needsAuthorization = false missingPermissions = [] return } var missing: [RequiredDirectory] = [] for dir in RequiredDirectory.allCases { if !hasPermission(for: dir) { missing.append(dir) } } missingPermissions = missing needsAuthorization = !missing.isEmpty } /// Check if we have permission for a specific directory func hasPermission(for directory: RequiredDirectory) -> Bool { guard bookmarks.isSandboxed else { return true } // Check if we have a saved bookmark return defaults.data(forKey: directory.bookmarkKey) != nil } /// Request authorization for a specific directory func requestPermission(for directory: RequiredDirectory) async -> Bool { guard bookmarks.isSandboxed else { return true } return await withCheckedContinuation { continuation in DispatchQueue.main.async { let panel = NSOpenPanel() panel.message = "CodMate needs access to \(directory.displayName)" panel.prompt = "Grant Access" panel.canChooseFiles = false panel.canChooseDirectories = true panel.allowsMultipleSelection = false panel.canCreateDirectories = true panel.showsHiddenFiles = true // Set the default directory to the real user home directory let url = directory.expandedPath // Debug: print the actual path we're trying to access print("[SandboxPermissions] Requesting access to: \(url.path)") print("[SandboxPermissions] Directory exists: \(FileManager.default.fileExists(atPath: url.path))") if FileManager.default.fileExists(atPath: url.path) { panel.directoryURL = url } else { // Show parent directory (user home) if the target doesn't exist panel.directoryURL = url.deletingLastPathComponent() panel.message = "CodMate needs access to \(directory.displayName)\n\nSelect or create the \(url.lastPathComponent) directory." } panel.begin { response in guard response == .OK, let selectedURL = panel.url else { print("[SandboxPermissions] User cancelled or no URL selected") continuation.resume(returning: false) return } print("[SandboxPermissions] User selected: \(selectedURL.path)") // Save the security-scoped bookmark do { let bookmarkData = try selectedURL.bookmarkData( options: [.withSecurityScope], includingResourceValuesForKeys: nil, relativeTo: nil ) self.defaults.set(bookmarkData, forKey: directory.bookmarkKey) self.defaults.synchronize() print("[SandboxPermissions] Bookmark saved for \(directory.displayName)") // Start accessing immediately if selectedURL.startAccessingSecurityScopedResource() { print("[SandboxPermissions] Successfully started accessing \(directory.displayName)") // Refresh permission status Task { @MainActor in self.checkPermissions() } continuation.resume(returning: true) } else { print("[SandboxPermissions] Failed to start accessing resource") continuation.resume(returning: false) } } catch { print("[SandboxPermissions] Failed to create bookmark: \(error)") continuation.resume(returning: false) } } } } } /// Request all missing permissions in sequence func requestAllMissingPermissions() async -> Bool { guard bookmarks.isSandboxed else { return true } var allGranted = true for dir in missingPermissions { let granted = await requestPermission(for: dir) if !granted { allGranted = false } } checkPermissions() return allGranted } /// Automatically request permissions for directories that don't exist yet but are needed /// This should be called at app launch after restoring existing bookmarks func ensureCriticalDirectoriesAccess() async { guard bookmarks.isSandboxed else { return } // Only request if we actually need these directories let criticalDirs: [RequiredDirectory] = [.codexSessions, .claudeSessions, .geminiSessions, .sshConfig] for dir in criticalDirs { // Skip if we already have permission if hasPermission(for: dir) { continue } // Only request if the directory actually exists let url = dir.expandedPath if FileManager.default.fileExists(atPath: url.path) { print("[SandboxPermissions] Found existing directory without permission: \(dir.displayName)") // Don't auto-prompt here, just mark as needing attention // User will see the "Grant Access" button in toolbar } } checkPermissions() } /// Restore access to all previously authorized directories on app launch func restoreAccess() { guard bookmarks.isSandboxed else { return } guard !didRestorePermissions else { return } didRestorePermissions = true for dir in RequiredDirectory.allCases { guard let data = defaults.data(forKey: dir.bookmarkKey) else { continue } var isStale = false do { let url = try URL( resolvingBookmarkData: data, options: [.withSecurityScope], relativeTo: nil, bookmarkDataIsStale: &isStale ) if isStale { // Refresh the bookmark let freshData = try url.bookmarkData( options: [.withSecurityScope], includingResourceValuesForKeys: nil, relativeTo: nil ) defaults.set(freshData, forKey: dir.bookmarkKey) } if url.startAccessingSecurityScopedResource() { print("[SandboxPermissions] Successfully restored access to: \(dir.displayName) at \(url.path)") } else { print("[SandboxPermissions] Failed to start access for: \(dir.displayName)") } } catch { print("[SandboxPermissions] Failed to restore access for \(dir.displayName): \(error)") } } checkPermissions() } /// Start accessing a specific directory if we have permission /// Returns true if access was started successfully @discardableResult func startAccessingIfAuthorized(directory: RequiredDirectory) -> Bool { guard bookmarks.isSandboxed else { return true } guard let data = defaults.data(forKey: directory.bookmarkKey) else { return false } var isStale = false do { let url = try URL( resolvingBookmarkData: data, options: [.withSecurityScope], relativeTo: nil, bookmarkDataIsStale: &isStale ) if isStale { let freshData = try url.bookmarkData( options: [.withSecurityScope], includingResourceValuesForKeys: nil, relativeTo: nil ) defaults.set(freshData, forKey: directory.bookmarkKey) } return url.startAccessingSecurityScopedResource() } catch { print("[SandboxPermissions] Failed to start access for \(directory.displayName): \(error)") return false } } /// Check if we can currently access a specific directory path func canAccess(path: String) -> Bool { guard bookmarks.isSandboxed else { return true } // Check if this path is under any of our authorized directories let realHome = getRealUserHome() let normalizedPath = path.replacingOccurrences(of: "~", with: realHome) for dir in RequiredDirectory.allCases { let dirPath = dir.expandedPath.path if normalizedPath.hasPrefix(dirPath) { return hasPermission(for: dir) } } return false } } ================================================ FILE: services/SecurityScopedBookmarks.swift ================================================ import Foundation import Security /// Manages security-scoped bookmarks for user-selected directories when running in App Sandbox. /// Stores bookmarks in UserDefaults and begins access for the app's lifetime. @MainActor final class SecurityScopedBookmarks { static let shared = SecurityScopedBookmarks() enum Key: String, CaseIterable { case sessionsRoot = "bookmark.sessionsRoot" case notesRoot = "bookmark.notesRoot" case projectsRoot = "bookmark.projectsRoot" } private let defaults: UserDefaults private var activeURLs: [Key: URL] = [:] private var activeDynamic: [String: URL] = [:] // keyed by canonical path init(defaults: UserDefaults = .standard) { self.defaults = defaults } /// Returns true when running under an App Sandbox container. var isSandboxed: Bool { // Primary: query entitlement from our own signed task if let task = SecTaskCreateFromSelf(nil) { if let val = SecTaskCopyValueForEntitlement(task, "com.apple.security.app-sandbox" as CFString, nil) as? Bool { return val } } // Fallback: environment probe (not always present on Developer ID builds) return ProcessInfo.processInfo.environment["APP_SANDBOX_CONTAINER_ID"] != nil } func save(url: URL, for key: Key) { guard isSandboxed else { return } do { let data = try url.bookmarkData(options: [.withSecurityScope], includingResourceValuesForKeys: nil, relativeTo: nil) defaults.set(data, forKey: key.rawValue) // Stop any previous access for this key, then start the new one stopAccess(for: key) _ = startAccess(for: key) } catch { // Silently ignore; UI surfaces I/O errors elsewhere } } /// Resolve and start access for a bookmark key. Returns the resolved URL if successful. @discardableResult func startAccess(for key: Key) -> URL? { guard isSandboxed else { return nil } guard let data = defaults.data(forKey: key.rawValue) else { return nil } var isStale = false do { let url = try URL(resolvingBookmarkData: data, options: [.withSecurityScope], relativeTo: nil, bookmarkDataIsStale: &isStale) if isStale { // Refresh the bookmark let fresh = try url.bookmarkData(options: [.withSecurityScope], includingResourceValuesForKeys: nil, relativeTo: nil) defaults.set(fresh, forKey: key.rawValue) } if url.startAccessingSecurityScopedResource() { activeURLs[key] = url return url } } catch { return nil } return nil } func stopAccess(for key: Key) { guard let url = activeURLs.removeValue(forKey: key) else { return } url.stopAccessingSecurityScopedResource() } /// On app launch, attempt to start access for all stored bookmarks. func restoreAndStartAccess() { guard isSandboxed else { return } for key in Key.allCases { _ = startAccess(for: key) } } // MARK: - Dynamic bookmarks (per repository root or arbitrary directory) private func canonicalPath(for url: URL) -> String { url.standardizedFileURL.resolvingSymlinksInPath().path } private var dynamicPrefix: String { "bookmark.dynamic." } private func dynamicKey(for url: URL) -> String { dynamicPrefix + canonicalPath(for: url) } func hasDynamicBookmark(for url: URL) -> Bool { let key = dynamicKey(for: url) return defaults.data(forKey: key) != nil } /// Save a dynamic security-scoped bookmark for an arbitrary directory. func saveDynamic(url: URL) { guard isSandboxed else { return } do { let data = try url.bookmarkData(options: [.withSecurityScope], includingResourceValuesForKeys: nil, relativeTo: nil) let key = dynamicKey(for: url) defaults.set(data, forKey: key) defaults.synchronize() // Force immediate write print("[SecurityScopedBookmarks] Saved dynamic bookmark for: \(url.path)") // Start access immediately after saving if url.startAccessingSecurityScopedResource() { activeDynamic[canonicalPath(for: url)] = url print("[SecurityScopedBookmarks] Started accessing: \(url.path)") } else { print("[SecurityScopedBookmarks] Failed to start accessing after save: \(url.path)") } } catch { print("[SecurityScopedBookmarks] Failed to save dynamic bookmark: \(error)") } } /// Restore and start access for all saved dynamic bookmarks on app launch func restoreAllDynamicBookmarks() { guard isSandboxed else { return } let dict = defaults.dictionaryRepresentation() let keys = dict.keys.filter { $0.hasPrefix(dynamicPrefix) } print("[SecurityScopedBookmarks] Restoring \(keys.count) dynamic bookmarks...") for key in keys { guard let data = defaults.data(forKey: key) else { continue } var isStale = false do { let url = try URL(resolvingBookmarkData: data, options: [.withSecurityScope], relativeTo: nil, bookmarkDataIsStale: &isStale) if isStale { print("[SecurityScopedBookmarks] Refreshing stale bookmark for: \(url.path)") let fresh = try url.bookmarkData(options: [.withSecurityScope], includingResourceValuesForKeys: nil, relativeTo: nil) defaults.set(fresh, forKey: key) } if url.startAccessingSecurityScopedResource() { activeDynamic[canonicalPath(for: url)] = url print("[SecurityScopedBookmarks] Restored access to: \(url.path)") } else { print("[SecurityScopedBookmarks] Failed to start access for: \(url.path)") } } catch { print("[SecurityScopedBookmarks] Failed to restore bookmark: \(error)") } } } /// Start access for an existing dynamic bookmark. Returns true if access is granted. @discardableResult func startAccessDynamic(for url: URL) -> Bool { guard isSandboxed else { return true } let canonical = canonicalPath(for: url) // If already accessing this directory, return success immediately if activeDynamic[canonical] != nil { print("[SecurityScopedBookmarks] Already accessing: \(url.path)") return true } let key = dynamicKey(for: url) guard let data = defaults.data(forKey: key) else { print("[SecurityScopedBookmarks] No bookmark found for: \(url.path)") return false } var stale = false do { let resolved = try URL(resolvingBookmarkData: data, options: [.withSecurityScope], relativeTo: nil, bookmarkDataIsStale: &stale) if stale { print("[SecurityScopedBookmarks] Refreshing stale bookmark for: \(resolved.path)") let fresh = try resolved.bookmarkData(options: [.withSecurityScope], includingResourceValuesForKeys: nil, relativeTo: nil) defaults.set(fresh, forKey: key) } print("[SecurityScopedBookmarks] Starting access for: \(resolved.path)") if resolved.startAccessingSecurityScopedResource() { activeDynamic[canonicalPath(for: resolved)] = resolved print("[SecurityScopedBookmarks] Successfully started access for: \(resolved.path)") return true } else { print("[SecurityScopedBookmarks] Failed to start access for: \(resolved.path)") } } catch { print("[SecurityScopedBookmarks] Error resolving bookmark: \(error)") return false } return false } func stopAccessDynamic(for url: URL) { let key = canonicalPath(for: url) if let u = activeDynamic.removeValue(forKey: key) { u.stopAccessingSecurityScopedResource() } } // List all recorded dynamic repository bookmarks func listDynamic() -> [URL] { let dict = defaults.dictionaryRepresentation() let keys = dict.keys.filter { $0.hasPrefix(dynamicPrefix) } var urls: [URL] = [] for k in keys.sorted() { if let data = defaults.data(forKey: k) { var stale = false if let url = try? URL(resolvingBookmarkData: data, options: [.withSecurityScope], relativeTo: nil, bookmarkDataIsStale: &stale) { urls.append(url) } } } return urls } func removeDynamic(url: URL) { let key = dynamicKey(for: url) stopAccessDynamic(for: url) defaults.removeObject(forKey: key) } } ================================================ FILE: services/SessionActions+Commands.swift ================================================ import AppKit import Security import Foundation extension SessionActions { @MainActor func resume( session: SessionSummary, executableURL: URL, options: ResumeOptions, workingDirectory: String? = nil, codexHomeOverride: String? = nil ) async throws -> ProcessResult { // Prefer PATH resolution; allow an optional user-specified executable override when valid. let resolvedExec = resolvedExecutablePath( for: session.source.baseKind, executableURL: executableURL ) // Prepare arguments first, including async MCP config if needed var additionalEnv: [String: String] = [:] var args: [String] switch session.source.baseKind { case .codex: args = buildResumeArguments(session: session, options: options) case .claude: args = ["--resume", session.id] // Apply Claude advanced flags from resume options if options.claudeVerbose { args.append("--verbose") } if options.claudeDebug { args.append("-d") if let f = options.claudeDebugFilter, !f.isEmpty { args.append(f) } } if let pm = options.claudePermissionMode, pm != .default { args.append(contentsOf: ["--permission-mode", pm.rawValue]) } if options.claudeSkipPermissions { args.append("--dangerously-skip-permissions") } if options.claudeAllowSkipPermissions { args.append("--allow-dangerously-skip-permissions") } // Claude CLI does not support an "--allow-unsandboxed-commands" flag; omit it. if let allowed = options.claudeAllowedTools, !allowed.isEmpty { args.append(contentsOf: ["--allowed-tools", allowed]) } if let disallowed = options.claudeDisallowedTools, !disallowed.isEmpty { args.append(contentsOf: ["--disallowed-tools", disallowed]) } if let addDirs = options.claudeAddDirs, !addDirs.isEmpty { // Split by comma and add multiple flags let parts = addDirs.split(whereSeparator: { $0 == "," || $0.isWhitespace }).map { String($0) }.filter { !$0.isEmpty } for dir in parts { args.append(contentsOf: ["--add-dir", dir]) } } if options.claudeIDE { args.append("--ide") } if options.claudeStrictMCP { args.append("--strict-mcp-config") } // Export MCP servers to ~/.claude/settings.json (Claude Code auto-loads from there) let mcpStore = MCPServersStore() try? await mcpStore.exportEnabledForClaudeConfig() if let fb = options.claudeFallbackModel, !fb.isEmpty { args.append(contentsOf: ["--fallback-model", fb]) } case .gemini: let config = geminiRuntimeConfiguration(options: options) args = ["--resume", conversationId(for: session)] + config.flags additionalEnv = config.environment } return try await withCheckedThrowingContinuation { continuation in let cwd = self.workingDirectory(for: session, override: workingDirectory) let cwdURL = URL(fileURLWithPath: cwd, isDirectory: true) Task.detached { do { let process = Process() if resolvedExec == session.source.baseKind.cliExecutableName { // Use env to resolve the executable on PATH process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = [resolvedExec] + args } else { process.executableURL = URL(fileURLWithPath: resolvedExec) process.arguments = args } // Prefer original session cwd if exists process.currentDirectoryURL = cwdURL let pipe = Pipe() process.standardOutput = pipe process.standardError = pipe var env = ProcessInfo.processInfo.environment let basePath = CLIEnvironment.buildBasePATH() if let current = env["PATH"], !current.isEmpty { env["PATH"] = basePath + ":" + current } else { env["PATH"] = basePath } // Prepare environment overlays (Claude Code picks up Anthropic-compatible vars) if session.source.baseKind == .claude { var envOverlays: [String: String] = [:] let registry = ProvidersRegistryService() let bindings = await registry.getBindings() let activeId = bindings.activeProvider?[ProvidersRegistryService.Consumer.claudeCode.rawValue] if let activeId, !activeId.isEmpty { let providers = await registry.listAllProviders() if let p = providers.first(where: { $0.id == activeId }) { let conn = p.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue] let loginMethod = conn?.loginMethod?.lowercased() ?? "api" if let base = conn?.baseURL, !base.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { envOverlays["ANTHROPIC_BASE_URL"] = base } // Subscription login: do not inject token; rely on `claude login` if loginMethod != "subscription" { // Map custom env key to ANTHROPIC_AUTH_TOKEN if available in current env if let keyName = (p.envKey ?? conn?.envKey), !keyName.isEmpty { if let tokenVal = ProcessInfo.processInfo.environment[keyName], !tokenVal.isEmpty { envOverlays["ANTHROPIC_AUTH_TOKEN"] = tokenVal } else { // If keyName itself looks like a token, use it directly let v = keyName let looksLikeToken = v.lowercased().contains("sk-") || v.hasPrefix("eyJ") || v.contains(".") if looksLikeToken { envOverlays["ANTHROPIC_AUTH_TOKEN"] = v } } } else if let tokenVal = ProcessInfo.processInfo.environment["ANTHROPIC_AUTH_TOKEN"], !tokenVal.isEmpty { envOverlays["ANTHROPIC_AUTH_TOKEN"] = tokenVal } } // Aliases: default and small/fast if let aliases = conn?.modelAliases { if let o = aliases["opus"], !o.isEmpty { envOverlays["ANTHROPIC_DEFAULT_OPUS_MODEL"] = o } if let s = aliases["sonnet"], !s.isEmpty { envOverlays["ANTHROPIC_DEFAULT_SONNET_MODEL"] = s } if let h = aliases["haiku"], !h.isEmpty { envOverlays["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = h envOverlays["ANTHROPIC_SMALL_FAST_MODEL"] = h } if let d = aliases["default"], !d.isEmpty { envOverlays["ANTHROPIC_MODEL"] = d if envOverlays["ANTHROPIC_DEFAULT_SONNET_MODEL"] == nil { envOverlays["ANTHROPIC_DEFAULT_SONNET_MODEL"] = d } } } // Fall back to registry default model if alias not set if envOverlays["ANTHROPIC_MODEL"] == nil, let dm = bindings.defaultModel?[ProvidersRegistryService.Consumer.claudeCode.rawValue], !dm.isEmpty { envOverlays["ANTHROPIC_MODEL"] = dm } } } for (k, v) in envOverlays { env[k] = v } } else { // Built-in (no provider selected): respect login method default (subscription) by not injecting token. // Nothing to inject here; PATH is already set above. } if session.source.baseKind == .gemini { for (key, value) in additionalEnv { env[key] = value } } if session.source.baseKind == .codex, let codexHomeOverride, !codexHomeOverride.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { // Ensure sessions symlink exists before setting CODEX_HOME self.ensureSessionsSymlink(at: codexHomeOverride) env["CODEX_HOME"] = codexHomeOverride } process.environment = env try process.run() process.waitUntilExit() let data = pipe.fileHandleForReading.readDataToEndOfFile() let output = String(data: data, encoding: .utf8) ?? "" if process.terminationStatus == 0 { continuation.resume(returning: ProcessResult(output: output)) } else { continuation.resume( throwing: SessionActionError.resumeFailed(output: output)) } } catch { continuation.resume(throwing: error) } } } } // MARK: - Resume helpers (copy/open Terminal) /// Paths that should be symlinked from project-level CODEX_HOME to global ~/.codex /// to avoid unnecessary data fragmentation while keeping project-level MCP/skills configs isolated. /// /// Rationale: /// - We use project-level CODEX_HOME ONLY to enable project-specific MCP servers and skills /// - Everything else (sessions, logs, auth, history) should remain global for consistency /// - config.toml intentionally NOT included (must stay project-level for MCP configs) /// - skills/ directory NOT included (parent dir must exist for skills/.system, but user skills stay project-level) private static let globalSymlinkPaths: [String] = [ "sessions", // Session rollout files - global for CodMate indexing "log", // Codex runtime logs - global for unified debugging "auth.json", // API credentials - global (shared across projects) "history.jsonl", // Command history - global (cross-project context) "skills/.system", // System skills cache - global (avoid duplication) "shell_snapshots" // Shell environment snapshots - temporary files, global storage ] /// Ensures that non-config files/directories in project-level CODEX_HOME are symlinked /// to the global ~/.codex directory. This keeps data centralized while allowing /// project-specific MCP servers and skills configurations. /// /// - Parameter codexHome: The project-level CODEX_HOME path (e.g., `/path/to/project/.codex`) private func ensureSessionsSymlink(at codexHome: String) { let globalCodexURL = fileManager.homeDirectoryForCurrentUser .appendingPathComponent(".codex", isDirectory: true) for relativePath in Self.globalSymlinkPaths { ensureSymlink( projectCodexHome: codexHome, globalCodexHome: globalCodexURL.path, relativePath: relativePath ) } } /// Creates a symlink from project CODEX_HOME to global ~/.codex for a given relative path. /// /// - Parameters: /// - projectCodexHome: Project-level CODEX_HOME directory path /// - globalCodexHome: Global ~/.codex directory path /// - relativePath: Relative path within CODEX_HOME (e.g., "sessions", "auth.json", "skills/.system") private func ensureSymlink( projectCodexHome: String, globalCodexHome: String, relativePath: String ) { let projectCodexURL = URL(fileURLWithPath: projectCodexHome, isDirectory: true) let globalCodexURL = URL(fileURLWithPath: globalCodexHome, isDirectory: true) // Build full paths let projectItemURL = projectCodexURL.appendingPathComponent(relativePath) let globalItemURL = globalCodexURL.appendingPathComponent(relativePath) // Check if project path already exists var isDirectory: ObjCBool = false let exists = fileManager.fileExists(atPath: projectItemURL.path, isDirectory: &isDirectory) if exists { // If it's already a symlink, verify it points to the right location if let destination = try? fileManager.destinationOfSymbolicLink(atPath: projectItemURL.path) { // Resolve both paths to handle relative symlinks let destinationResolved = (destination as NSString).expandingTildeInPath let globalResolved = globalItemURL.path if destinationResolved == globalResolved || URL(fileURLWithPath: destinationResolved).standardizedFileURL.path == URL(fileURLWithPath: globalResolved).standardizedFileURL.path { return // Already correctly configured } // Points to a different location - respect user's choice NSLog("Warning: Project path \(relativePath) symlink points to \(destination), expected \(globalItemURL.path). Keeping existing configuration.") return } // If it exists but is not a symlink (real directory or file), respect it NSLog("Note: Project path \(relativePath) exists but is not a symlink. Keeping existing configuration.") return } // Path doesn't exist, create the symlink do { // Ensure parent directory exists in project .codex let parentURL = projectItemURL.deletingLastPathComponent() try fileManager.createDirectory(at: parentURL, withIntermediateDirectories: true) // Create the symlink (allow dangling symlinks for files that don't exist yet) try fileManager.createSymbolicLink( at: projectItemURL, withDestinationURL: globalItemURL ) NSLog("Created symlink: \(projectItemURL.path) -> \(globalItemURL.path)") } catch { // Non-fatal: if symlink creation fails, Codex will create a regular directory/file NSLog("Warning: Failed to create symlink for \(relativePath): \(error)") } } private func shellEscapedPath(_ path: String) -> String { // Simple escape: wrap in single quotes and escape existing single quotes return "'" + path.replacingOccurrences(of: "'", with: "'\\''") + "'" } private func shellQuoteIfNeeded(_ s: String) -> String { // Only quote when the string contains whitespace or shell‑sensitive characters. // Keep it readable (e.g., codex stays unquoted). let unsafe: Set = Set(" \t\n\r\"'`$&|;<>*?()[]{}\\") if s.contains(where: { unsafe.contains($0) }) { return shellEscapedPath(s) } return s } private func sshInvocation( host: String, remoteCommand: String, resolvedArguments: [String]? = nil ) -> String { let contextArguments = resolvedArguments ?? resolvedSSHContext(for: host) if let args = contextArguments { let parts = ["ssh", "-t"] + args let command = parts.map { shellQuoteIfNeeded($0) }.joined(separator: " ") return "\(command) \(shellSingleQuoted(remoteCommand))" } return "ssh -t \(shellQuoteIfNeeded(host)) \(shellSingleQuoted(remoteCommand))" } // Reliable conversation id for resume commands: always use the session_meta id // parsed from the log (SessionSummary.id). This matches Codex CLI's // expectation (UUID) and Claude's native id semantics. private func conversationId(for session: SessionSummary) -> String { session.id } private func executableName(for kind: SessionSource.Kind) -> String { kind.cliExecutableName } func resolvedExecutablePath(for kind: SessionSource.Kind, executableURL: URL) -> String { let candidate = executableURL.path if candidate != "/usr/bin/env", fileManager.isExecutableFile(atPath: candidate) { return candidate } return kind.cliExecutableName } private func embeddedExportLines(for source: SessionSource) -> [String] { [] } struct GeminiRuntimeConfiguration { let flags: [String] let environment: [String: String] } func geminiRuntimeConfiguration(options: ResumeOptions) -> GeminiRuntimeConfiguration { var flags: [String] = [] var env: [String: String] = [:] if options.dangerouslyBypass { flags.append("--yolo") return GeminiRuntimeConfiguration(flags: flags, environment: env) } if options.approval == .never { flags.append("--yolo") } else if options.fullAuto { flags.append(contentsOf: ["--approval-mode", "auto_edit"]) } var sandboxPreference = options.sandbox if sandboxPreference == nil && options.fullAuto { sandboxPreference = .workspaceWrite } if let sandboxPreference, sandboxPreference != .dangerFullAccess { flags.append("--sandbox") env["GEMINI_SANDBOX"] = "sandbox-exec" env["SEATBELT_PROFILE"] = geminiSeatbeltProfile(for: sandboxPreference) } // Inject CLI Proxy endpoint if provider is configured let providerId = UserDefaults.standard.string(forKey: "codmate.gemini.proxyProviderId") if let providerId, !providerId.isEmpty { let portValue = UserDefaults.standard.integer(forKey: "codmate.localserver.port") let port = portValue > 0 ? portValue : Int(CLIProxyService.defaultPort) env["CODE_ASSIST_ENDPOINT"] = "http://127.0.0.1:\(port)" } return GeminiRuntimeConfiguration(flags: flags, environment: env) } func geminiEnvironmentOverrides(options: ResumeOptions) -> [String: String] { geminiRuntimeConfiguration(options: options).environment } private func geminiSeatbeltProfile(for mode: SandboxMode) -> String { switch mode { case .readOnly: // Restrictive profile keeps writes tightly contained while allowing network access return "restrictive-open" case .workspaceWrite: return "permissive-open" case .dangerFullAccess: return "permissive-open" } } func geminiEnvironmentExportLines(environment: [String: String]) -> [String] { guard !environment.isEmpty else { return [] } return environment .sorted { $0.key < $1.key } .map { "export \($0.key)=\(shellSingleQuoted($0.value))" } } // Build environment overlay map for embedding (DEV CLI console) func embeddedEnvironment(for source: SessionSource) -> [String: String] { var env: [String: String] = [:] env["LANG"] = "zh_CN.UTF-8" env["LC_ALL"] = "zh_CN.UTF-8" env["LC_CTYPE"] = "zh_CN.UTF-8" env["TERM"] = "xterm-256color" if source.baseKind == .codex { env["CODEX_DISABLE_COLOR_QUERY"] = "1" } return env } private func flags(from options: ResumeOptions) -> [String] { // Highest precedence: dangerously bypass if options.dangerouslyBypass { return ["--dangerously-bypass-approvals-and-sandbox"] } // Next: full-auto shortcut if options.fullAuto { return ["--full-auto"] } // Otherwise explicit -s and -a when provided var f: [String] = [] if let s = options.sandbox { f += ["-s", s.rawValue] } if let a = options.approval { f += ["-a", a.rawValue] } return f } func buildResumeCLIInvocation( session: SessionSummary, executablePath: String, options: ResumeOptions, codexHome: String? = nil ) -> String { let exe = shellQuoteIfNeeded(executablePath) switch session.source.baseKind { case .codex: let f = flags(from: options).map { shellQuoteIfNeeded($0) } let cmd: String if f.isEmpty { cmd = "\(exe) resume \(conversationId(for: session))" } else { cmd = ([exe] + f + ["resume", shellQuoteIfNeeded(conversationId(for: session))]).joined(separator: " ") } return applyCodexHomePrefix(cmd, codexHome: codexHome, source: session.source.baseKind) case .claude: let args = claudeResumeArguments(session: session, options: options).map { shellQuoteIfNeeded($0) } return ([exe] + args).joined(separator: " ") case .gemini: let config = geminiRuntimeConfiguration(options: options) let args: [String] = ["--resume", conversationId(for: session)] + config.flags return ([exe] + args.map { shellQuoteIfNeeded($0) }).joined(separator: " ") } } private func claudeResumeArguments( session: SessionSummary, options: ResumeOptions ) -> [String] { var parts: [String] = ["--resume", session.id] parts.append(contentsOf: claudeRuntimeArguments(options: options, fallbackModel: options.claudeFallbackModel)) return parts } private func claudeRuntimeArguments( options: ResumeOptions, fallbackModel: String? ) -> [String] { var parts: [String] = [] if options.claudeVerbose { parts.append("--verbose") } if options.claudeDebug { parts.append("-d") if let f = options.claudeDebugFilter, !f.isEmpty { parts.append(f) } } if let pm = options.claudePermissionMode, pm != .default { parts.append(contentsOf: ["--permission-mode", pm.rawValue]) } if options.claudeSkipPermissions { parts.append("--dangerously-skip-permissions") } if options.claudeAllowSkipPermissions { parts.append("--allow-dangerously-skip-permissions") } if let allowed = options.claudeAllowedTools, !allowed.isEmpty { parts.append(contentsOf: ["--allowed-tools", allowed]) } if let disallowed = options.claudeDisallowedTools, !disallowed.isEmpty { parts.append(contentsOf: ["--disallowed-tools", disallowed]) } if let addDirs = options.claudeAddDirs, !addDirs.isEmpty { let dirParts = addDirs.split(whereSeparator: { $0 == "," || $0.isWhitespace }).map { String($0) }.filter { !$0.isEmpty } for dir in dirParts { parts.append(contentsOf: ["--add-dir", dir]) } } if options.claudeIDE { parts.append("--ide") } if options.claudeStrictMCP { parts.append("--strict-mcp-config") } if let fb = fallbackModel, !fb.isEmpty { parts.append(contentsOf: ["--fallback-model", fb]) } return parts } func buildNewSessionArguments(session: SessionSummary, options: ResumeOptions) -> [String] { switch session.source.baseKind { case .codex: var args: [String] = [] if let normalized = normalizedCodexModelName(session.model) { args += ["--model", normalized] } args += flags(from: options) return args case .claude: return [] case .gemini: var args: [String] = [] if let rawModel = session.model?.trimmingCharacters(in: .whitespacesAndNewlines), !rawModel.isEmpty { args += ["--model", rawModel] } args.append(contentsOf: geminiRuntimeConfiguration(options: options).flags) return args } } func buildNewSessionCLIInvocation( session: SessionSummary, options: ResumeOptions, initialPrompt: String? = nil, executablePath: String? = nil, codexHome: String? = nil ) -> String { // Check if this is a remote session and return SSH command if so if session.isRemote, let host = session.remoteHost { let sshContext = resolvedSSHContext(for: host) let remoteCommand = buildRemoteNewShellCommand( session: session, options: options, initialPrompt: initialPrompt ) return sshInvocation( host: host, remoteCommand: remoteCommand, resolvedArguments: sshContext ) } // Local session handling return buildLocalNewSessionCLIInvocation( session: session, options: options, initialPrompt: initialPrompt, executablePath: executablePath, codexHome: codexHome ) } func buildLocalNewSessionCLIInvocation( session: SessionSummary, options: ResumeOptions, initialPrompt: String? = nil, executablePath: String? = nil, codexHome: String? = nil ) -> String { // Local session handling (without checking remote status) switch session.source.baseKind { case .codex: // Launch a fresh Codex session by invoking `codex` directly (no "new" subcommand). let exe = shellQuoteIfNeeded(executablePath ?? "codex") var parts: [String] = [exe] let args = buildNewSessionArguments(session: session, options: options).map { arg -> String in if arg.contains(where: { $0.isWhitespace || $0 == "'" }) { return shellEscapedPath(arg) } return arg } parts.append(contentsOf: args) if let prompt = initialPrompt, !prompt.isEmpty { parts.append(shellSingleQuoted(prompt)) } let cmd = parts.joined(separator: " ") return applyCodexHomePrefix(cmd, codexHome: codexHome, source: session.source.baseKind) case .claude: var parts: [String] = [shellQuoteIfNeeded(executablePath ?? "claude")] // Apply model if specified // For Built-in provider: either omit --model or use short alias (sonnet/haiku/opus) // Built-in models follow pattern: claude-3-X-Y-latest or claude-3-5-X-latest // Also handle fallback names like "Claude", "Sonnet", "Haiku", "Opus" if let model = session.model, !model.trimmingCharacters(in: .whitespaces).isEmpty { let trimmed = model.trimmingCharacters(in: .whitespaces) let lowerModel = trimmed.lowercased() // Check if this is a generic fallback name (Claude) - omit it if lowerModel == "claude" { // Generic fallback - don't pass --model, let CLI use default } else if lowerModel == "sonnet" || lowerModel == "haiku" || lowerModel == "opus" { // Already a short alias - pass as-is (lowercase) parts.append("--model") parts.append(lowerModel) } else if trimmed.hasPrefix("claude-") && trimmed.hasSuffix("-latest") { // Built-in format detected: use short alias let shortAlias: String? if lowerModel.contains("sonnet") { shortAlias = "sonnet" } else if lowerModel.contains("haiku") { shortAlias = "haiku" } else if lowerModel.contains("opus") { shortAlias = "opus" } else { shortAlias = nil // Unknown built-in model, omit --model } if let alias = shortAlias { parts.append("--model") parts.append(alias) } } else { // Third-party or custom model: pass as-is parts.append("--model") parts.append(shellQuoteIfNeeded(trimmed)) } } // Apply Claude runtime configuration from options (matching resume behavior) if options.claudeVerbose { parts.append("--verbose") } if options.claudeDebug { parts.append("-d") if let f = options.claudeDebugFilter, !f.isEmpty { parts.append(shellQuoteIfNeeded(f)) } } if let pm = options.claudePermissionMode, pm != .default { parts.append(contentsOf: ["--permission-mode", shellQuoteIfNeeded(pm.rawValue)]) } if options.claudeSkipPermissions { parts.append("--dangerously-skip-permissions") } if options.claudeAllowSkipPermissions { parts.append("--allow-dangerously-skip-permissions") } // Claude CLI does not support an "--allow-unsandboxed-commands" flag; omit it. if let allowed = options.claudeAllowedTools, !allowed.isEmpty { parts.append(contentsOf: ["--allowed-tools", shellQuoteIfNeeded(allowed)]) } if let disallowed = options.claudeDisallowedTools, !disallowed.isEmpty { parts.append(contentsOf: ["--disallowed-tools", shellQuoteIfNeeded(disallowed)]) } if let addDirs = options.claudeAddDirs, !addDirs.isEmpty { let dirParts = addDirs.split(whereSeparator: { $0 == "," || $0.isWhitespace }).map { String($0) }.filter { !$0.isEmpty } for dir in dirParts { parts.append(contentsOf: ["--add-dir", shellQuoteIfNeeded(dir)]) } } if options.claudeIDE { parts.append("--ide") } if options.claudeStrictMCP { parts.append("--strict-mcp-config") } if let fb = options.claudeFallbackModel, !fb.isEmpty { parts.append(contentsOf: ["--fallback-model", shellQuoteIfNeeded(fb)]) } // Note: MCP config file is only attached in actual process execution (resume method), // not in CLI invocation strings for external terminals, as it requires async export if let prompt = initialPrompt, !prompt.isEmpty { parts.append(shellSingleQuoted(prompt)) } return parts.joined(separator: " ") case .gemini: let exe = shellQuoteIfNeeded(executablePath ?? "gemini") var parts: [String] = [exe] let args = buildNewSessionArguments(session: session, options: options).map { arg -> String in if arg.contains(where: { $0.isWhitespace || $0 == "'" }) { return shellEscapedPath(arg) } return arg } parts.append(contentsOf: args) if let prompt = initialPrompt, !prompt.isEmpty { parts.append(shellSingleQuoted(prompt)) } return parts.joined(separator: " ") } } func buildResumeArguments(session: SessionSummary, options: ResumeOptions) -> [String] { switch session.source.baseKind { case .codex: let f = flags(from: options) return f + ["resume", conversationId(for: session)] case .claude: return claudeResumeArguments(session: session, options: options) case .gemini: let config = geminiRuntimeConfiguration(options: options) return ["--resume", conversationId(for: session)] + config.flags } } func buildResumeCommandLines( session: SessionSummary, executableURL: URL, options: ResumeOptions, workingDirectory: String? = nil, codexHome: String? = nil ) -> String { #if APPSTORE let cwd = self.workingDirectory(for: session, override: workingDirectory) let cd = "cd " + shellEscapedPath(cwd) let exports = embeddedExportLines(for: session.source).joined(separator: "; ") // MAS sandbox: do not auto-execute external CLI inside the app. Only prepare directory and env. // The user can copy or insert the real command via UI prompts. let cliName = executableName(for: session.source.baseKind) let notice = "echo \"[CodMate] App Store sandbox cannot directly run \(cliName) CLI. Please use the button on the right to copy the command and execute it in an external terminal.\"" return cd + "\n" + exports + "\n" + notice + "\n" #else if session.isRemote, let host = session.remoteHost { let sshContext = resolvedSSHContext(for: host) let remote = buildRemoteResumeShellCommand( session: session, options: options ) return sshInvocation( host: host, remoteCommand: remote, resolvedArguments: sshContext ) + "\n" } let cwd = self.workingDirectory(for: session, override: workingDirectory) let cd = "cd " + shellEscapedPath(cwd) var exportLines = embeddedExportLines(for: session.source) if session.source.baseKind == .gemini { let envLines = geminiEnvironmentExportLines( environment: geminiRuntimeConfiguration(options: options).environment) exportLines.append(contentsOf: envLines) } let exports = exportLines.joined(separator: "; ") let injectedPATH = CLIEnvironment.buildInjectedPATH() // Use override executable when configured; otherwise fall back to PATH resolution. let execPath = resolvedExecutablePath( for: session.source.baseKind, executableURL: executableURL ) let invocation = buildResumeCLIInvocation( session: session, executablePath: execPath, options: options, codexHome: codexHome) let resume = "PATH=\(injectedPATH) \(invocation)" return cd + "\n" + exports + "\n" + resume + "\n" #endif } // Embedded terminal: avoid PATH=... inline to keep command display clean. func buildEmbeddedResumeCommandLines( session: SessionSummary, executableURL: URL, options: ResumeOptions, workingDirectory: String? = nil, codexHome: String? = nil, includeCd: Bool = true ) -> String { #if APPSTORE return buildResumeCommandLines( session: session, executableURL: executableURL, options: options, workingDirectory: workingDirectory, codexHome: codexHome ) #else if session.isRemote, let host = session.remoteHost { let sshContext = resolvedSSHContext(for: host) let remote = buildRemoteResumeShellCommand( session: session, options: options ) return sshInvocation( host: host, remoteCommand: remote, resolvedArguments: sshContext ) + "\n" } var exportLines = embeddedExportLines(for: session.source) if session.source.baseKind == .gemini { let envLines = geminiEnvironmentExportLines( environment: geminiRuntimeConfiguration(options: options).environment) exportLines.append(contentsOf: envLines) } let exports = exportLines.joined(separator: "; ") let execPath = resolvedExecutablePath( for: session.source.baseKind, executableURL: executableURL ) let resume = buildResumeCLIInvocation( session: session, executablePath: execPath, options: options, codexHome: codexHome) var lines: [String] = [] if includeCd { let cwd = self.workingDirectory(for: session, override: workingDirectory) let cd = "cd " + shellEscapedPath(cwd) lines.append(cd) } if !exports.isEmpty { lines.append(exports) } lines.append(resume) return lines.joined(separator: "\n") + "\n" #endif } func buildEmbeddedNewSessionCommandLines( session: SessionSummary, executableURL: URL, options: ResumeOptions, initialPrompt: String? = nil, codexHome: String? = nil ) -> String { #if APPSTORE return buildNewSessionCommandLines( session: session, executableURL: executableURL, options: options, codexHome: codexHome ) #else if session.isRemote, let host = session.remoteHost { let sshContext = resolvedSSHContext(for: host) let remote = buildRemoteNewShellCommand( session: session, options: options, initialPrompt: initialPrompt ) return sshInvocation( host: host, remoteCommand: remote, resolvedArguments: sshContext ) + "\n" } let cwd = FileManager.default.fileExists(atPath: session.cwd) ? session.cwd : session.fileURL.deletingLastPathComponent().path let cd = "cd " + shellEscapedPath(cwd) var exportLines: [String] = [] if session.source.baseKind == .gemini { exportLines = embeddedExportLines(for: session.source) let envLines = geminiEnvironmentExportLines( environment: geminiRuntimeConfiguration(options: options).environment) exportLines.append(contentsOf: envLines) } let exports = exportLines.isEmpty ? nil : exportLines.joined(separator: "; ") let execPath = resolvedExecutablePath( for: session.source.baseKind, executableURL: executableURL ) let invocation = buildNewSessionCLIInvocation( session: session, options: options, initialPrompt: initialPrompt, executablePath: execPath, codexHome: codexHome ) var lines = [cd] if let exports { lines.append(exports) } lines.append(invocation) return lines.joined(separator: "\n") + "\n" #endif } func buildEmbeddedNewProjectCommandLines( project: Project, executableURL: URL, options: ResumeOptions, codexHome: String? = nil ) -> String { let cdLine: String? = { if let dir = project.directory, !dir.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "cd " + shellEscapedPath(dir) } return nil }() let execPath = resolvedExecutablePath(for: .codex, executableURL: executableURL) let invocation = buildNewProjectCLIInvocation( project: project, options: options, executablePath: execPath, codexHome: codexHome) if let cd = cdLine { return cd + "\n" + invocation + "\n" } else { return invocation + "\n" } } func buildNewSessionCommandLines( session: SessionSummary, executableURL: URL, options: ResumeOptions, codexHome: String? = nil ) -> String { #if APPSTORE let cwd = FileManager.default.fileExists(atPath: session.cwd) ? session.cwd : session.fileURL.deletingLastPathComponent().path let cd = "cd " + shellEscapedPath(cwd) // MAS: do not execute external CLI in embedded terminal; only show a notice. let notice = "echo \"[CodMate] App Store sandbox cannot directly run \(session.source.baseKind.cliExecutableName) CLI. Please execute the copied command in an external terminal.\"" return cd + "\n" + notice + "\n" #else if session.isRemote, let host = session.remoteHost { let sshContext = resolvedSSHContext(for: host) let remote = buildRemoteNewShellCommand( session: session, options: options, initialPrompt: nil ) return sshInvocation( host: host, remoteCommand: remote, resolvedArguments: sshContext ) + "\n" } let cwd = FileManager.default.fileExists(atPath: session.cwd) ? session.cwd : session.fileURL.deletingLastPathComponent().path let cd = "cd " + shellEscapedPath(cwd) var exportLines: [String] = [] if session.source.baseKind == .gemini { exportLines = embeddedExportLines(for: session.source) let envLines = geminiEnvironmentExportLines( environment: geminiRuntimeConfiguration(options: options).environment) exportLines.append(contentsOf: envLines) } let exports = exportLines.isEmpty ? nil : exportLines.joined(separator: "; ") let execPath = resolvedExecutablePath( for: session.source.baseKind, executableURL: executableURL ) let invocation = buildNewSessionCLIInvocation( session: session, options: options, executablePath: execPath, codexHome: codexHome) var lines = [cd] if let exports { lines.append(exports) } lines.append(invocation) return lines.joined(separator: "\n") + "\n" #endif } func buildExternalNewSessionCommands( session: SessionSummary, executableURL: URL, options: ResumeOptions, codexHome: String? = nil ) -> String { buildEmbeddedNewSessionCommandLines( session: session, executableURL: executableURL, options: options, codexHome: codexHome ) } // Simplified two-line command for external terminals func buildExternalResumeCommands( session: SessionSummary, executableURL: URL, options: ResumeOptions, workingDirectory: String? = nil, codexHome: String? = nil ) -> String { buildEmbeddedResumeCommandLines( session: session, executableURL: executableURL, options: options, workingDirectory: workingDirectory, codexHome: codexHome ) } // MARK: - Warp-optimized clipboard commands // // Warp appears to derive a new tab title from the first pasted "command" line. // When our external clipboard text starts with `cd ...`, the tab title becomes `cd`. // For Warp flows we prepend a harmless comment line and omit `cd` entirely because // we already open Warp at the target directory via URL scheme. private func warpTitleCommentLine(_ title: String?) -> String? { guard var s = title else { return nil } s = s.replacingOccurrences(of: "\r", with: " ") s = s.replacingOccurrences(of: "\n", with: " ") s = s.replacingOccurrences(of: "\t", with: " ") s = s.trimmingCharacters(in: .whitespacesAndNewlines) guard !s.isEmpty else { return nil } if s.count > 80 { s = String(s.prefix(80)) } let collapsed = s.split(whereSeparator: { $0.isWhitespace }).joined(separator: "-") guard !collapsed.isEmpty else { return nil } return "#" + collapsed } private func warpScope(from session: SessionSummary) -> String? { if let title = session.userTitle?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { return title } let cwd = FileManager.default.fileExists(atPath: session.cwd) ? session.cwd : session.fileURL.deletingLastPathComponent().path let dirName = URL(fileURLWithPath: cwd).lastPathComponent if !dirName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return dirName } return session.displayName } func buildWarpResumeCommands( session: SessionSummary, executableURL: URL, options: ResumeOptions, titleHint: String? = nil, codexHome: String? = nil ) -> String { if session.isRemote, let host = session.remoteHost { let sshContext = resolvedSSHContext(for: host) let remote = buildRemoteResumeShellCommand(session: session, options: options) let cmd = sshInvocation(host: host, remoteCommand: remote, resolvedArguments: sshContext) let lines = [warpTitleCommentLine(titleHint ?? session.effectiveTitle), cmd].compactMap { $0 } return lines.joined(separator: "\n") + "\n" } let execPath = resolvedExecutablePath( for: session.source.baseKind, executableURL: executableURL ) let resume = buildResumeCLIInvocation( session: session, executablePath: execPath, options: options, codexHome: codexHome ) var lines: [String] = [] if let title = warpTitleCommentLine(titleHint ?? session.effectiveTitle) { lines.append(title) } if session.source.baseKind == .gemini { lines.append(contentsOf: embeddedExportLines(for: session.source)) let envLines = geminiEnvironmentExportLines( environment: geminiRuntimeConfiguration(options: options).environment) lines.append(contentsOf: envLines) } lines.append(resume) return lines.joined(separator: "\n") + "\n" } func buildWarpNewSessionCommands( session: SessionSummary, executableURL: URL, options: ResumeOptions, titleHint: String? = nil, codexHome: String? = nil ) -> String { if session.isRemote, let host = session.remoteHost { let sshContext = resolvedSSHContext(for: host) let remote = buildRemoteNewShellCommand( session: session, options: options, initialPrompt: nil ) let cmd = sshInvocation(host: host, remoteCommand: remote, resolvedArguments: sshContext) let extras = [host] let base = titleHint ?? WarpTitleBuilder.newSessionLabel( scope: warpScope(from: session), task: nil, extras: extras ) let title = warpTitleCommentLine(base) let lines = [title, cmd].compactMap { $0 } return lines.joined(separator: "\n") + "\n" } let execPath = resolvedExecutablePath( for: session.source.baseKind, executableURL: executableURL ) let base = titleHint ?? WarpTitleBuilder.newSessionLabel( scope: warpScope(from: session), task: nil ) let newCommand = buildNewSessionCLIInvocation( session: session, options: options, executablePath: execPath, codexHome: codexHome) var lines: [String] = [] if let title = warpTitleCommentLine(base) { lines.append(title) } if session.source.baseKind == .gemini { lines.append(contentsOf: embeddedExportLines(for: session.source)) let envLines = geminiEnvironmentExportLines( environment: geminiRuntimeConfiguration(options: options).environment) lines.append(contentsOf: envLines) } lines.append(newCommand) return lines.joined(separator: "\n") + "\n" } func buildWarpNewProjectCommands( project: Project, executableURL: URL, options: ResumeOptions, titleHint: String? = nil, codexHome: String? = nil ) -> String { let base = titleHint ?? WarpTitleBuilder.newSessionLabel( scope: project.name, task: nil ) let title = warpTitleCommentLine(base) let execPath = resolvedExecutablePath(for: .codex, executableURL: executableURL) let cmd = buildNewProjectCLIInvocation( project: project, options: options, executablePath: execPath, codexHome: codexHome) let lines = [title, cmd].compactMap { $0 } return lines.joined(separator: "\n") + "\n" } func copyResumeCommands( session: SessionSummary, executableURL: URL, options: ResumeOptions, simplifiedForExternal: Bool = true, destinationApp: ExternalTerminalProfile? = nil, titleHint: String? = nil, workingDirectory: String? = nil, codexHome: String? = nil ) { let commands: String if simplifiedForExternal, destinationApp?.usesWarpCommands == true { commands = buildWarpResumeCommands( session: session, executableURL: executableURL, options: options, titleHint: titleHint, codexHome: codexHome ) } else { commands = simplifiedForExternal ? buildExternalResumeCommands( session: session, executableURL: executableURL, options: options, workingDirectory: workingDirectory, codexHome: codexHome ) : buildResumeCommandLines( session: session, executableURL: executableURL, options: options, workingDirectory: workingDirectory, codexHome: codexHome ) } let pb = NSPasteboard.general pb.clearContents() pb.setString(commands, forType: .string) } func copyNewSessionCommands( session: SessionSummary, executableURL: URL, options: ResumeOptions, simplifiedForExternal: Bool = true, destinationApp: ExternalTerminalProfile? = nil, titleHint: String? = nil, codexHome: String? = nil ) { let commands: String if simplifiedForExternal, destinationApp?.usesWarpCommands == true { commands = buildWarpNewSessionCommands( session: session, executableURL: executableURL, options: options, titleHint: titleHint, codexHome: codexHome ) } else { commands = simplifiedForExternal ? buildExternalNewSessionCommands( session: session, executableURL: executableURL, options: options, codexHome: codexHome) : buildNewSessionCommandLines( session: session, executableURL: executableURL, options: options, codexHome: codexHome) } let pb = NSPasteboard.general pb.clearContents() pb.setString(commands, forType: .string) } // MARK: - Project-level new session helpers private func buildNewProjectArguments(project: Project, options: ResumeOptions) -> [String] { var args: [String] = [] // Embedded per-project profile config (preferred) let pp = project.profile let profileId = project.profileId?.trimmingCharacters(in: .whitespaces) let provider = readTopLevelConfigString("model_provider")?.trimmingCharacters( in: .whitespacesAndNewlines) // Flags only; avoid explicit --model for Codex new to keep behavior consistent if let pp { if pp.dangerouslyBypass == true { args += ["--dangerously-bypass-approvals-and-sandbox"] } else if pp.fullAuto == true { args += ["--full-auto"] } else { if let s = pp.sandbox { args += ["-s", s.rawValue] } if let a = pp.approval { args += ["-a", a.rawValue] } } } else { // Fallback to explicit flags args += flags(from: options) } // Always use -c to inject inline profile (zero-write approach) if let profileId, !profileId.isEmpty { // Resolve effective approval/sandbox for project-level new inline profile var approvalRaw: String? = pp?.approval?.rawValue var sandboxRaw: String? = pp?.sandbox?.rawValue if sandboxRaw == nil { if pp?.dangerouslyBypass == true { sandboxRaw = SandboxMode.dangerFullAccess.rawValue } else if let opt = options.sandbox?.rawValue { sandboxRaw = opt } } if approvalRaw == nil { if let opt = options.approval?.rawValue { approvalRaw = opt } else { approvalRaw = ApprovalPolicy.onRequest.rawValue } } if sandboxRaw == nil { sandboxRaw = SandboxMode.workspaceWrite.rawValue } let modelFromProject = pp?.model let modelForInline = resolveInlineModel(provider: provider, candidate: modelFromProject) if let inline = renderInlineProfileConfig( key: profileId, model: modelForInline, modelProvider: provider, approvalPolicy: approvalRaw, sandboxMode: sandboxRaw ) { args += ["--profile", profileId, "-c", inline] } else { // profile id provided but nothing to inject; omit --profile to avoid referring to a non-existent profile } } return args } func buildNewProjectCLIInvocation( project: Project, options: ResumeOptions, executablePath: String? = nil, codexHome: String? = nil ) -> String { let exe = shellQuoteIfNeeded(executablePath ?? "codex") let args = buildNewProjectArguments(project: project, options: options).map { arg -> String in if arg.contains(where: { $0.isWhitespace || $0 == "'" }) { return shellEscapedPath(arg) } return arg } // Invoke `codex` directly without a "new" subcommand let cmd = ([exe] + args).joined(separator: " ") return applyCodexHomePrefix(cmd, codexHome: codexHome, source: .codex) } func buildClaudeProjectCLIInvocation( executablePath: String, options: ResumeOptions, model: String? ) -> String { var parts: [String] = [shellQuoteIfNeeded(executablePath)] parts.append(contentsOf: claudeRuntimeArguments(options: options, fallbackModel: options.claudeFallbackModel) .map { shellQuoteIfNeeded($0) }) if let m = model?.trimmingCharacters(in: .whitespacesAndNewlines), !m.isEmpty { parts.append("--model") parts.append(shellQuoteIfNeeded(m)) } return parts.joined(separator: " ") } func buildGeminiCLIInvocation( executablePath: String, options: ResumeOptions ) -> String { let config = geminiRuntimeConfiguration(options: options) var parts: [String] = [shellQuoteIfNeeded(executablePath)] parts.append(contentsOf: config.flags.map(shellQuoteIfNeeded)) let cmd = parts.joined(separator: " ") let envLines = geminiEnvironmentExportLines(environment: config.environment) if envLines.isEmpty { return cmd } return (envLines + [cmd]).joined(separator: "\n") } func buildNewProjectCommandLines( project: Project, executableURL: URL, options: ResumeOptions, codexHome: String? = nil ) -> String { let cdLine: String? = { if let dir = project.directory, !dir.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "cd " + shellEscapedPath(dir) } return nil }() // PATH injection: prepend project-specific paths if any let prepend = project.profile?.pathPrepend ?? [] let prependString = prepend.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.joined(separator: ":") let injectedPATH = CLIEnvironment.buildInjectedPATH( additionalPaths: prependString.isEmpty ? [] : [prependString] ) // Exports: locale defaults + project env var exportLines: [String] = [ "export LANG=zh_CN.UTF-8", "export LC_ALL=zh_CN.UTF-8", "export LC_CTYPE=zh_CN.UTF-8", "export TERM=xterm-256color", "export CODEX_DISABLE_COLOR_QUERY=1", ] if let env = project.profile?.env { for (k, v) in env { let key = k.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty else { continue } exportLines.append("export \(key)=\(shellSingleQuoted(v))") } } let exports = exportLines.joined(separator: "; ") let execPath = resolvedExecutablePath(for: .codex, executableURL: executableURL) let invocation = buildNewProjectCLIInvocation( project: project, options: options, executablePath: execPath, codexHome: codexHome) let command = "PATH=\(injectedPATH) \(invocation)" if let cd = cdLine { return cd + "\n" + exports + "\n" + command + "\n" } else { return exports + "\n" + command + "\n" } } func buildExternalNewProjectCommands( project: Project, executableURL: URL, options: ResumeOptions, codexHome: String? = nil ) -> String { buildEmbeddedNewProjectCommandLines( project: project, executableURL: executableURL, options: options, codexHome: codexHome ) } func copyNewProjectCommands( project: Project, executableURL: URL, options: ResumeOptions, simplifiedForExternal: Bool = true, destinationApp: ExternalTerminalProfile? = nil, titleHint: String? = nil, codexHome: String? = nil ) { let commands: String if simplifiedForExternal, destinationApp?.usesWarpCommands == true { commands = buildWarpNewProjectCommands( project: project, executableURL: executableURL, options: options, titleHint: titleHint, codexHome: codexHome ) } else { commands = simplifiedForExternal ? buildExternalNewProjectCommands( project: project, executableURL: executableURL, options: options, codexHome: codexHome) : buildNewProjectCommandLines( project: project, executableURL: executableURL, options: options, codexHome: codexHome) } let pb = NSPasteboard.general pb.clearContents() pb.setString(commands, forType: .string) } @discardableResult func openNewProject( project: Project, executableURL: URL, options: ResumeOptions, codexHome: String? = nil ) -> Bool { let scriptText = { let lines = buildEmbeddedNewProjectCommandLines( project: project, executableURL: executableURL, options: options, codexHome: codexHome ) .replacingOccurrences(of: "\n", with: "; ") return """ tell application "Terminal" activate do script "\(lines)" end tell """ }() if let script = NSAppleScript(source: scriptText) { var errorDict: NSDictionary? script.executeAndReturnError(&errorDict) return errorDict == nil } return false } // MARK: - Detail New using Project Profile (cd = session.cwd) private func buildNewSessionArguments( using project: Project, fallbackModel: String?, options: ResumeOptions ) -> [String] { var args: [String] = [] let pid = project.profileId?.trimmingCharacters(in: .whitespaces) let provider = readTopLevelConfigString("model_provider")?.trimmingCharacters( in: .whitespacesAndNewlines) // Flags precedence: danger -> full-auto -> explicit -s/-a when present in project profile if project.profile?.dangerouslyBypass == true { args += ["--dangerously-bypass-approvals-and-sandbox"] } else if project.profile?.fullAuto == true { args += ["--full-auto"] } else { if let s = project.profile?.sandbox { args += ["-s", s.rawValue] } if let a = project.profile?.approval { args += ["-a", a.rawValue] } } // Always use -c to inject inline profile (zero-write approach) if let pid, !pid.isEmpty { // Do not append explicit --model for Codex new; rely on project profile (persisted or inline) or global config let modelFromProject = project.profile?.model // Effective policies for inline profile injection (New using project): // - approval: prefer explicit; otherwise prefer options; else default to on-request // - sandbox: prefer explicit; otherwise Danger Bypass => danger-full-access; otherwise options; else default to workspace-write var approvalRaw: String? = project.profile?.approval?.rawValue var sandboxRaw: String? = project.profile?.sandbox?.rawValue if sandboxRaw == nil { if project.profile?.dangerouslyBypass == true { sandboxRaw = SandboxMode.dangerFullAccess.rawValue } else if let opt = options.sandbox?.rawValue { sandboxRaw = opt } } if approvalRaw == nil { if let opt = options.approval?.rawValue { approvalRaw = opt } else { approvalRaw = ApprovalPolicy.onRequest.rawValue } } if sandboxRaw == nil { sandboxRaw = SandboxMode.workspaceWrite.rawValue } let preferredModel = modelFromProject ?? fallbackModel let modelForInline = resolveInlineModel(provider: provider, candidate: preferredModel) if let inline = renderInlineProfileConfig( key: pid, model: modelForInline, modelProvider: provider, approvalPolicy: approvalRaw, sandboxMode: sandboxRaw ) { // Zero-write: inject the inline profile and select it args += ["--profile", pid, "-c", inline] } } return args } func buildNewSessionUsingProjectProfileCLIInvocation( session: SessionSummary, project: Project, options: ResumeOptions, initialPrompt: String? = nil, executablePath: String? = nil, codexHome: String? = nil ) -> String { // Launch using project profile; choose executable based on session source. let exe = shellQuoteIfNeeded(executablePath ?? executableName(for: session.source.baseKind)) var parts: [String] = [exe] // For Claude, only include model if specified; profile settings don't apply. if session.source.baseKind == .claude { if let model = session.model, !model.trimmingCharacters(in: .whitespaces).isEmpty { parts.append("--model") parts.append(shellQuoteIfNeeded(model)) } if let prompt = initialPrompt, !prompt.isEmpty { parts.append(shellSingleQuoted(prompt)) } return parts.joined(separator: " ") } if session.source.baseKind == .gemini { if let prompt = initialPrompt, !prompt.isEmpty { parts.append(shellSingleQuoted(prompt)) } return parts.joined(separator: " ") } // For Codex, use full project profile arguments let args = buildNewSessionArguments( using: project, fallbackModel: effectiveCodexModel(for: session), options: options ).map { arg -> String in if arg.contains(where: { $0.isWhitespace || $0 == "'" }) { return shellEscapedPath(arg) } return arg } parts.append(contentsOf: args) if let prompt = initialPrompt, !prompt.isEmpty { parts.append(shellSingleQuoted(prompt)) } let cmd = parts.joined(separator: " ") return applyCodexHomePrefix(cmd, codexHome: codexHome, source: session.source.baseKind) } func buildNewSessionUsingProjectProfileCommandLines( session: SessionSummary, project: Project, executableURL: URL, options: ResumeOptions, initialPrompt: String? = nil, codexHome: String? = nil ) -> String { if session.isRemote, let host = session.remoteHost { let invocation = buildNewSessionUsingProjectProfileCLIInvocation( session: session, project: project, options: options, initialPrompt: initialPrompt, codexHome: codexHome ) var exportLines: [String] = [ "export LANG=zh_CN.UTF-8", "export LC_ALL=zh_CN.UTF-8", "export LC_CTYPE=zh_CN.UTF-8", "export TERM=xterm-256color", ] if let env = project.profile?.env { for (k, v) in env { let key = k.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty else { continue } exportLines.append("export \(key)=\(shellSingleQuoted(v))") } } let sshContext = resolvedSSHContext(for: host) let remote = buildRemoteShellCommand( session: session, exports: exportLines, invocation: invocation ) return sshInvocation( host: host, remoteCommand: remote, resolvedArguments: sshContext ) + "\n" } let cwd = FileManager.default.fileExists(atPath: session.cwd) ? session.cwd : session.fileURL.deletingLastPathComponent().path let cd = "cd " + shellEscapedPath(cwd) let execPath = resolvedExecutablePath( for: session.source.baseKind, executableURL: executableURL ) let invocation = buildNewSessionUsingProjectProfileCLIInvocation( session: session, project: project, options: options, initialPrompt: initialPrompt, executablePath: execPath, codexHome: codexHome ) // Local project-profile New: only emit `cd` + bare CLI invocation. return cd + "\n" + invocation + "\n" } func buildExternalNewSessionUsingProjectProfileCommands( session: SessionSummary, project: Project, executableURL: URL, options: ResumeOptions, initialPrompt: String? = nil, codexHome: String? = nil ) -> String { if session.isRemote, let host = session.remoteHost { let sshContext = resolvedSSHContext(for: host) var exportLines: [String] = [ "export LANG=zh_CN.UTF-8", "export LC_ALL=zh_CN.UTF-8", "export LC_CTYPE=zh_CN.UTF-8", "export TERM=xterm-256color", ] if let env = project.profile?.env { for (k, v) in env { let key = k.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty else { continue } exportLines.append("export \(key)=\(shellSingleQuoted(v))") } } let invocation = buildNewSessionUsingProjectProfileCLIInvocation( session: session, project: project, options: options, initialPrompt: initialPrompt, codexHome: codexHome ) let remote = buildRemoteShellCommand( session: session, exports: exportLines, invocation: invocation ) return sshInvocation( host: host, remoteCommand: remote, resolvedArguments: sshContext ) + "\n" } let cwd = FileManager.default.fileExists(atPath: session.cwd) ? session.cwd : session.fileURL.deletingLastPathComponent().path let cd = "cd " + shellEscapedPath(cwd) let cmd = buildNewSessionUsingProjectProfileCLIInvocation( session: session, project: project, options: options, initialPrompt: initialPrompt, executablePath: resolvedExecutablePath( for: session.source.baseKind, executableURL: executableURL ), codexHome: codexHome ) return cd + "\n" + cmd + "\n" } func copyNewSessionUsingProjectProfileCommands( session: SessionSummary, project: Project, executableURL: URL, options: ResumeOptions, simplifiedForExternal: Bool = true, destinationApp: ExternalTerminalProfile? = nil, initialPrompt: String? = nil, titleHint: String? = nil, codexHome: String? = nil ) { let commands: String if simplifiedForExternal, destinationApp?.usesWarpCommands == true { let invocation: String if session.isRemote { invocation = buildNewSessionUsingProjectProfileCLIInvocation( session: session, project: project, options: options, initialPrompt: initialPrompt, codexHome: codexHome ) } else { let execPath = resolvedExecutablePath( for: session.source.baseKind, executableURL: executableURL ) invocation = buildNewSessionUsingProjectProfileCLIInvocation( session: session, project: project, options: options, initialPrompt: initialPrompt, executablePath: execPath, codexHome: codexHome ) } let extraHost = session.isRemote ? session.remoteHost : nil let base = titleHint ?? WarpTitleBuilder.newSessionLabel( scope: project.name, task: nil, extras: extraHost.flatMap { [$0] } ?? [] ) let title = warpTitleCommentLine(base) if session.isRemote, let host = session.remoteHost { let sshContext = resolvedSSHContext(for: host) var exportLines: [String] = [ "export LANG=zh_CN.UTF-8", "export LC_ALL=zh_CN.UTF-8", "export LC_CTYPE=zh_CN.UTF-8", "export TERM=xterm-256color", ] if let env = project.profile?.env { for (k, v) in env { let key = k.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty else { continue } exportLines.append("export \(key)=\(shellSingleQuoted(v))") } } let remote = buildRemoteShellCommand( session: session, exports: exportLines, invocation: invocation ) let ssh = sshInvocation(host: host, remoteCommand: remote, resolvedArguments: sshContext) commands = [title, ssh].compactMap { $0 }.joined(separator: "\n") + "\n" } else { commands = [title, invocation].compactMap { $0 }.joined(separator: "\n") + "\n" } } else { commands = simplifiedForExternal ? buildExternalNewSessionUsingProjectProfileCommands( session: session, project: project, executableURL: executableURL, options: options, initialPrompt: initialPrompt, codexHome: codexHome) : buildNewSessionUsingProjectProfileCommandLines( session: session, project: project, executableURL: executableURL, options: options, initialPrompt: initialPrompt, codexHome: codexHome) } let pb = NSPasteboard.general pb.clearContents() pb.setString(commands, forType: .string) } // MARK: - Resume (detail) respecting Project Profile private func buildResumeArguments( using project: Project, fallbackModel: String?, options: ResumeOptions ) -> [String] { var args: [String] = [] let pid = project.profileId?.trimmingCharacters(in: .whitespaces) let provider = readTopLevelConfigString("model_provider")?.trimmingCharacters( in: .whitespacesAndNewlines) // Always use -c to inject inline profile (zero-write approach) // Only select profile; do not pass flags to preserve original resume semantics if let pid, !pid.isEmpty { // Compute effective approval/sandbox for resume inline profile // approval: prefer explicit; else options; else default on-request // sandbox: prefer explicit; else Danger Bypass => danger-full-access; else options; else default workspace-write var approvalRaw: String? = project.profile?.approval?.rawValue var sandboxRaw: String? = project.profile?.sandbox?.rawValue if sandboxRaw == nil { if project.profile?.dangerouslyBypass == true { sandboxRaw = SandboxMode.dangerFullAccess.rawValue } else if let opt = options.sandbox?.rawValue { sandboxRaw = opt } } if approvalRaw == nil { if let opt = options.approval?.rawValue { approvalRaw = opt } else { approvalRaw = ApprovalPolicy.onRequest.rawValue } } if sandboxRaw == nil { sandboxRaw = SandboxMode.workspaceWrite.rawValue } let preferredModel = project.profile?.model ?? fallbackModel let modelForInline = resolveInlineModel(provider: provider, candidate: preferredModel) if let inline = renderInlineProfileConfig( key: pid, model: modelForInline, modelProvider: provider, approvalPolicy: approvalRaw, sandboxMode: sandboxRaw ) { // Zero-write: inject the inline profile and select it args += ["--profile", pid, "-c", inline] } } return args } func buildResumeUsingProjectProfileCLIInvocation( session: SessionSummary, project: Project, options: ResumeOptions, codexHome: String? = nil ) -> String { // Choose executable based on session source; select profile (no flags for Claude). let exe = executableName(for: session.source.baseKind) var parts: [String] = [exe] // For Claude, profiles don't apply; use simple resume command. if session.source.baseKind == .claude { parts.append("--resume") parts.append(session.id) return parts.joined(separator: " ") } // For Codex, place flags + profile before subcommand: codex --profile resume let globalFlags = flags(from: options).map { arg -> String in arg.contains(where: { $0.isWhitespace || $0 == "'" }) ? shellEscapedPath(arg) : arg } let args = buildResumeArguments( using: project, fallbackModel: effectiveCodexModel(for: session), options: options ).map { arg -> String in if arg.contains(where: { $0.isWhitespace || $0 == "'" }) { return shellEscapedPath(arg) } return arg } parts.append(contentsOf: globalFlags + args) parts.append("resume") parts.append(conversationId(for: session)) let cmd = parts.joined(separator: " ") return applyCodexHomePrefix(cmd, codexHome: codexHome, source: session.source.baseKind) } func buildResumeUsingProjectProfileCLIInvocation( session: SessionSummary, project: Project, executablePath: String, options: ResumeOptions, codexHome: String? = nil ) -> String { let exe = shellQuoteIfNeeded(executablePath) var parts: [String] = [exe] // For Claude, profiles don't apply; use simple resume command. if session.source.baseKind == .claude { parts.append("--resume") parts.append(session.id) return parts.joined(separator: " ") } // For Codex, place flags + profile before subcommand: codex --profile resume let globalFlags = flags(from: options).map { arg -> String in arg.contains(where: { $0.isWhitespace || $0 == "'" }) ? shellEscapedPath(arg) : arg } let args = buildResumeArguments( using: project, fallbackModel: effectiveCodexModel(for: session), options: options ).map { arg -> String in if arg.contains(where: { $0.isWhitespace || $0 == "'" }) { return shellEscapedPath(arg) } return arg } parts.append(contentsOf: globalFlags + args) parts.append("resume") parts.append(conversationId(for: session)) let cmd = parts.joined(separator: " ") return applyCodexHomePrefix(cmd, codexHome: codexHome, source: session.source.baseKind) } func buildResumeUsingProjectProfileCommandLines( session: SessionSummary, project: Project, executableURL: URL, options: ResumeOptions, codexHome: String? = nil ) -> String { var exportLines: [String] = [ "export LANG=zh_CN.UTF-8", "export LC_ALL=zh_CN.UTF-8", "export LC_CTYPE=zh_CN.UTF-8", "export TERM=xterm-256color", ] if let env = project.profile?.env { for (k, v) in env { let key = k.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty else { continue } exportLines.append("export \(key)=\(shellSingleQuoted(v))") } } if session.isRemote, let host = session.remoteHost { let invocation = buildResumeUsingProjectProfileCLIInvocation( session: session, project: project, options: options, codexHome: codexHome) let sshContext = resolvedSSHContext(for: host) let remote = buildRemoteShellCommand( session: session, exports: exportLines, invocation: invocation ) return sshInvocation( host: host, remoteCommand: remote, resolvedArguments: sshContext ) + "\n" } let cwd = FileManager.default.fileExists(atPath: session.cwd) ? session.cwd : session.fileURL.deletingLastPathComponent().path let cd = "cd " + shellEscapedPath(cwd) let exports = exportLines.joined(separator: "; ") let execPath = resolvedExecutablePath( for: session.source.baseKind, executableURL: executableURL ) let command = buildResumeUsingProjectProfileCLIInvocation( session: session, project: project, executablePath: execPath, options: options, codexHome: codexHome ) return cd + "\n" + exports + "\n" + command + "\n" } func buildExternalResumeUsingProjectProfileCommands( session: SessionSummary, project: Project, executableURL: URL, options: ResumeOptions, codexHome: String? = nil ) -> String { if session.isRemote, let host = session.remoteHost { let sshContext = resolvedSSHContext(for: host) var exportLines: [String] = [ "export LANG=zh_CN.UTF-8", "export LC_ALL=zh_CN.UTF-8", "export LC_CTYPE=zh_CN.UTF-8", "export TERM=xterm-256color", ] if let env = project.profile?.env { for (k, v) in env { let key = k.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty else { continue } exportLines.append("export \(key)=\(shellSingleQuoted(v))") } } let invocation = buildResumeUsingProjectProfileCLIInvocation( session: session, project: project, options: options, codexHome: codexHome) let remote = buildRemoteShellCommand( session: session, exports: exportLines, invocation: invocation ) return sshInvocation( host: host, remoteCommand: remote, resolvedArguments: sshContext ) + "\n" } let cwd = FileManager.default.fileExists(atPath: session.cwd) ? session.cwd : session.fileURL.deletingLastPathComponent().path let cd = "cd " + shellEscapedPath(cwd) let execPath = resolvedExecutablePath( for: session.source.baseKind, executableURL: executableURL ) let cmd = buildResumeUsingProjectProfileCLIInvocation( session: session, project: project, executablePath: execPath, options: options, codexHome: codexHome ) return cd + "\n" + cmd + "\n" } func copyResumeUsingProjectProfileCommands( session: SessionSummary, project: Project, executableURL: URL, options: ResumeOptions, simplifiedForExternal: Bool = true, destinationApp: ExternalTerminalProfile? = nil, titleHint: String? = nil, codexHome: String? = nil ) { let commands: String if simplifiedForExternal, destinationApp?.usesWarpCommands == true { let invocation: String if session.isRemote { invocation = buildResumeUsingProjectProfileCLIInvocation( session: session, project: project, options: options, codexHome: codexHome) } else { let execPath = resolvedExecutablePath( for: session.source.baseKind, executableURL: executableURL ) invocation = buildResumeUsingProjectProfileCLIInvocation( session: session, project: project, executablePath: execPath, options: options, codexHome: codexHome ) } let title = warpTitleCommentLine(titleHint ?? session.effectiveTitle) if session.isRemote, let host = session.remoteHost { let sshContext = resolvedSSHContext(for: host) var exportLines: [String] = [ "export LANG=zh_CN.UTF-8", "export LC_ALL=zh_CN.UTF-8", "export LC_CTYPE=zh_CN.UTF-8", "export TERM=xterm-256color", ] if let env = project.profile?.env { for (k, v) in env { let key = k.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty else { continue } exportLines.append("export \(key)=\(shellSingleQuoted(v))") } } let remote = buildRemoteShellCommand( session: session, exports: exportLines, invocation: invocation ) let ssh = sshInvocation(host: host, remoteCommand: remote, resolvedArguments: sshContext) commands = [title, ssh].compactMap { $0 }.joined(separator: "\n") + "\n" } else { commands = [title, invocation].compactMap { $0 }.joined(separator: "\n") + "\n" } } else { commands = simplifiedForExternal ? buildExternalResumeUsingProjectProfileCommands( session: session, project: project, executableURL: executableURL, options: options, codexHome: codexHome ) : buildResumeUsingProjectProfileCommandLines( session: session, project: project, executableURL: executableURL, options: options, codexHome: codexHome ) } let pb = NSPasteboard.general pb.clearContents() pb.setString(commands, forType: .string) } @discardableResult func openNewSessionUsingProjectProfile( session: SessionSummary, project: Project, executableURL: URL, options: ResumeOptions, initialPrompt: String? = nil, codexHome: String? = nil ) -> Bool { let scriptText = { let lines = buildNewSessionUsingProjectProfileCommandLines( session: session, project: project, executableURL: executableURL, options: options, initialPrompt: initialPrompt, codexHome: codexHome ) .replacingOccurrences(of: "\n", with: "; ") return """ tell application "Terminal" activate do script "\(lines)" end tell """ }() if let script = NSAppleScript(source: scriptText) { var errorDict: NSDictionary? script.executeAndReturnError(&errorDict) return errorDict == nil } return false } // MARK: - Helpers private func shellSingleQuoted(_ v: String) -> String { "'" + v.replacingOccurrences(of: "'", with: "'\\''") + "'" } private func codexHomePrefix(_ codexHome: String?) -> String? { guard let codexHome, !codexHome.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } // Ensure sessions symlink exists when generating command strings // (e.g., for copy-to-clipboard or external terminal execution) ensureSessionsSymlink(at: codexHome) return "CODEX_HOME=\(shellEscapedPath(codexHome))" } private func applyCodexHomePrefix( _ command: String, codexHome: String?, source: SessionSource.Kind ) -> String { guard source == .codex, let prefix = codexHomePrefix(codexHome) else { return command } return "\(prefix) \(command)" } func copyRealResumeInvocation( session: SessionSummary, executableURL: URL, options: ResumeOptions, codexHome: String? = nil ) { let command: String if session.isRemote, let host = session.remoteHost { let sshContext = resolvedSSHContext(for: host) let remote = buildRemoteResumeShellCommand( session: session, options: options ) command = sshInvocation( host: host, remoteCommand: remote, resolvedArguments: sshContext ) } else { let execName = resolvedExecutablePath( for: session.source.baseKind, executableURL: executableURL ) command = buildResumeCLIInvocation( session: session, executablePath: execName, options: options, codexHome: codexHome ) } let pb = NSPasteboard.general pb.clearContents() pb.setString(command + "\n", forType: .string) } } ================================================ FILE: services/SessionActions+Config.swift ================================================ import Foundation extension SessionActions { func normalizedCodexModelName(_ raw: String?) -> String? { guard let text = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return nil } let lower = text.lowercased() switch lower { case "gpt-5", "gpt5": return "gpt-5.2" case "gpt-5-codex", "gpt5-codex": return "gpt-5.2-codex" case "gpt-5-codex-max", "gpt5-codex-max": return "gpt-5.1-codex-max" case "gpt-5-codex-mini", "gpt5-codex-mini": return "gpt-5.1-codex-mini" default: return text } } func listPersistedProfiles() -> Set { let configURL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("config.toml", isDirectory: false) guard let data = try? Data(contentsOf: configURL), let raw = String(data: data, encoding: .utf8) else { return [] } var out: Set = [] for line in raw.split(separator: "\n", omittingEmptySubsequences: false) { let t = line.trimmingCharacters(in: CharacterSet.whitespaces) if t.hasPrefix("[profiles.") && t.hasSuffix("]") { let start = "[profiles.".count let endIndex = t.index(before: t.endIndex) let id = String(t[t.index(t.startIndex, offsetBy: start).. Bool { guard let id, !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } return listPersistedProfiles().contains(id) } func readTopLevelConfigString(_ key: String) -> String? { let url = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("config.toml", isDirectory: false) guard let text = try? String(contentsOf: url, encoding: .utf8) else { return nil } for raw in text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) { let t = raw.trimmingCharacters(in: CharacterSet.whitespaces) guard t.hasPrefix(key + " ") || t.hasPrefix(key + "=") else { continue } guard let eq = t.firstIndex(of: "=") else { continue } var value = String(t[t.index(after: eq)...]).trimmingCharacters(in: CharacterSet.whitespaces) if value.hasPrefix("\"") && value.hasSuffix("\"") { value.removeFirst() value.removeLast() } return value } return nil } func effectiveCodexModel(for session: SessionSummary) -> String? { if let configured = normalizedCodexModelName(readTopLevelConfigString("model")) { return configured } if session.source.baseKind == .codex { if let normalized = normalizedCodexModelName(session.model) { return normalized } } return nil } func renderInlineProfileConfig( key id: String, model: String?, modelProvider: String?, approvalPolicy: String?, sandboxMode: String? ) -> String? { var pairs: [String] = [] if let normalized = normalizedCodexModelName(model) { let val = normalized.replacingOccurrences(of: "\"", with: "\\\"") pairs.append("model=\"\(val)\"") } if let approval = approvalPolicy, !approval.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let val = approval.replacingOccurrences(of: "\"", with: "\\\"") pairs.append("approval_policy=\"\(val)\"") } if let sandbox = sandboxMode, !sandbox.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let val = sandbox.replacingOccurrences(of: "\"", with: "\\\"") pairs.append("sandbox_mode=\"\(val)\"") } if let provider = modelProvider, !provider.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let val = provider.replacingOccurrences(of: "\"", with: "\\\"") pairs.append("model_provider=\"\(val)\"") } guard !pairs.isEmpty else { return nil } return "profiles.\(id)={ \(pairs.joined(separator: ", ")) }" } func isLikelyBuiltinCodexModel(_ raw: String?) -> Bool { guard let text = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return false } let lower = text.lowercased() return lower.hasPrefix("gpt-") || lower.hasPrefix("gpt5") } // TODO: Enhance model identification strategy. Currently using simple string prefix matching // to filter incompatible models when switching between providers (e.g. Auto vs Proxy). func resolveInlineModel(provider: String?, candidate: String?) -> String? { guard let model = candidate, !model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } let p = provider?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if p.isEmpty { // Auto/Built-in provider: only allow known builtin models (gpt-*), discard incompatible ones (e.g. gemini/claude) return isLikelyBuiltinCodexModel(model) ? model : nil } else if p == "codmate-proxy" { // Proxy provider: suppress builtin models to avoid conflicts, pass through others return isLikelyBuiltinCodexModel(model) ? nil : model } return model } } ================================================ FILE: services/SessionActions+FileActions.swift ================================================ import AppKit import Foundation extension SessionActions { func revealInFinder(session: SessionSummary) { NSWorkspace.shared.activateFileViewerSelecting([session.fileURL]) } func delete(summaries: [SessionSummary]) throws { for summary in summaries { var resulting: NSURL? do { try fileManager.trashItem(at: summary.fileURL, resultingItemURL: &resulting) } catch { throw SessionActionError.deletionFailed(summary.fileURL) } // For Claude Code sessions, also delete associated agent-*.jsonl files if summary.source.baseKind == .claude { deleteAssociatedAgentFiles(for: summary) } } } /// Delete agent-*.jsonl files associated with a Claude Code session. /// Agent files are sidechain warmup files that share the same sessionId. private func deleteAssociatedAgentFiles(for summary: SessionSummary) { let directory = summary.fileURL.deletingLastPathComponent() guard let enumerator = fileManager.enumerator( at: directory, includingPropertiesForKeys: [URLResourceKey.isRegularFileKey], options: [ FileManager.DirectoryEnumerationOptions.skipsHiddenFiles, FileManager.DirectoryEnumerationOptions.skipsSubdirectoryDescendants, ] ) else { return } for case let url as URL in enumerator { let filename = url.deletingPathExtension().lastPathComponent guard filename.hasPrefix("agent-"), url.pathExtension.lowercased() == "jsonl" else { continue } // Check if this agent file belongs to the session being deleted if agentFileMatchesSession(agentURL: url, sessionId: summary.id) { var resulting: NSURL? try? fileManager.trashItem(at: url, resultingItemURL: &resulting) } } } /// Check if an agent file belongs to a specific session by reading its sessionId. private func agentFileMatchesSession(agentURL: URL, sessionId: String) -> Bool { guard let data = try? Data(contentsOf: agentURL, options: [.mappedIfSafe]), !data.isEmpty else { return false } // Read first line to extract sessionId let lines = data.split(separator: 0x0A, maxSplits: 1, omittingEmptySubsequences: true) guard let firstLine = lines.first else { return false } // Simple JSON check for sessionId (avoid full JSON parsing for performance) let lineStr = String(decoding: firstLine, as: UTF8.self) return lineStr.contains("\"sessionId\":\"\(sessionId)\"") } } ================================================ FILE: services/SessionActions+Terminal.swift ================================================ import AppKit import Foundation extension SessionActions { func openInTerminal( session: SessionSummary, executableURL: URL, options: ResumeOptions, workingDirectory: String? = nil, codexHome: String? = nil ) -> Bool { #if APPSTORE // App Store build: avoid Apple Events. Copy command and open Terminal at directory. copyResumeCommands( session: session, executableURL: executableURL, options: options, workingDirectory: workingDirectory, codexHome: codexHome ) let cwd = self.workingDirectory(for: session, override: workingDirectory) _ = openAppleTerminal(at: cwd) return true #else let scriptText = { let lines = buildEmbeddedResumeCommandLines( session: session, executableURL: executableURL, options: options, workingDirectory: workingDirectory, codexHome: codexHome ) .replacingOccurrences(of: "\n", with: "; ") return """ tell application "Terminal" activate do script "\(lines)" end tell """ }() if let script = NSAppleScript(source: scriptText) { var errorDict: NSDictionary? script.executeAndReturnError(&errorDict) return errorDict == nil } return false #endif } @discardableResult func openNewSession( session: SessionSummary, executableURL: URL, options: ResumeOptions, codexHome: String? = nil ) -> Bool { #if APPSTORE // App Store build: copy command and open Terminal without Apple Events copyNewSessionCommands( session: session, executableURL: executableURL, options: options, codexHome: codexHome ) let cwd = FileManager.default.fileExists(atPath: session.cwd) ? session.cwd : session.fileURL.deletingLastPathComponent().path _ = openAppleTerminal(at: cwd) return true #else let scriptText = { let lines = buildEmbeddedNewSessionCommandLines( session: session, executableURL: executableURL, options: options, codexHome: codexHome ) .replacingOccurrences(of: "\n", with: "; ") return """ tell application "Terminal" activate do script "\(lines)" end tell """ }() if let script = NSAppleScript(source: scriptText) { var errorDict: NSDictionary? script.executeAndReturnError(&errorDict) return errorDict == nil } return false #endif } // Open a terminal app without auto-executing; user can paste clipboard func openTerminalApp(_ profile: ExternalTerminalProfile) { guard let bundleID = profile.resolvedBundleIdentifier else { return } if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) { let config = NSWorkspace.OpenConfiguration() config.activates = true NSWorkspace.shared.openApplication( at: appURL, configuration: config, completionHandler: nil) } } // Optional: open using URL schemes (iTerm2 / Warp) when available func openTerminalViaScheme(_ profile: ExternalTerminalProfile, directory: String?, command: String? = nil) { terminalLaunchQueue.async { self.openTerminalViaSchemeSync(profile: profile, directory: directory, command: command) } } private func openTerminalViaSchemeSync(profile: ExternalTerminalProfile, directory: String?, command: String?) { let dir = directory ?? NSHomeDirectory() switch profile.id { case "iterm2": if openITermViaAppleScript(directory: dir, command: command) { return } var comps = URLComponents() comps.scheme = "iterm2" comps.path = "/command" comps.queryItems = [URLQueryItem(name: "d", value: dir)] if let command { comps.queryItems?.append(URLQueryItem(name: "c", value: command)) } if let url = comps.url { NSWorkspace.shared.open(url) } else { openTerminalApp(profile) } case "warp": var comps = URLComponents() comps.scheme = "warp" comps.host = "action" comps.path = "/new_tab" comps.queryItems = [URLQueryItem(name: "path", value: dir)] if let url = comps.url { NSWorkspace.shared.open(url) } else { openTerminalApp(profile) } default: if profile.isTerminal { _ = openAppleTerminal(at: dir) return } if let urlTemplate = profile.urlTemplate, let url = buildLaunchURL(template: urlTemplate, directory: dir, command: command, profile: profile) { NSWorkspace.shared.open(url) return } openTerminalApp(profile) } } // Open Terminal.app at a given directory (no auto-run). Returns success. @discardableResult func openAppleTerminal(at directory: String) -> Bool { // Use `open -a Terminal ` to spawn a new window in that path let proc = Process() proc.executableURL = URL(fileURLWithPath: "/usr/bin/open") proc.arguments = ["-a", "Terminal", directory] do { try proc.run() proc.waitUntilExit() return proc.terminationStatus == 0 } catch { return false } } private func buildLaunchURL( template: String, directory: String, command: String?, profile: ExternalTerminalProfile ) -> URL? { let encodedDir = directory.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? directory let encodedCommand: String? = { guard let command, profile.supportsCommandResolved else { return nil } return command.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? command }() var result = template.replacingOccurrences(of: "{cwd}", with: encodedDir) result = result.replacingOccurrences(of: "{command}", with: encodedCommand ?? "") return URL(string: result) } // MARK: - Warp Launch Configuration @discardableResult func openWarpLaunchConfig( session: SessionSummary, options: ResumeOptions, executableURL: URL, workingDirectory: String? = nil, codexHome: String? = nil ) -> Bool { let cwd = self.workingDirectory(for: session, override: workingDirectory) let home = FileManager.default.homeDirectoryForCurrentUser let folder = home.appendingPathComponent(".warp", isDirectory: true) .appendingPathComponent("launch_configurations", isDirectory: true) do { try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) } catch { /* ignore */ } let baseName = "codmate-resume-\(session.id)" let fileName = baseName + ".yaml" let fileURL = folder.appendingPathComponent(fileName) let commandString: String = { let execPath = resolvedExecutablePath( for: session.source.baseKind, executableURL: executableURL ) return buildResumeCLIInvocation( session: session, executablePath: execPath, options: options, codexHome: codexHome ) }() let yaml = """ version: 1 name: CodMate Resume \(session.id) windows: - tabs: - title: Codex panes: - cwd: \(cwd) commands: - exec: \(commandString) """ do { try yaml.data(using: String.Encoding.utf8)?.write(to: fileURL) } catch {} // Prefer warp://launch/ (Warp resolves in its config dir), fallback to absolute path. if let urlByName = URL(string: "warp://launch/\(baseName)") { let ok = NSWorkspace.shared.open(urlByName) if ok { return true } } var comps = URLComponents() comps.scheme = "warp" comps.host = "launch" comps.path = "/" + fileURL.path if let url = comps.url { return NSWorkspace.shared.open(url) } return false } } // MARK: - iTerm helpers extension SessionActions { private func openITermViaAppleScript(directory: String, command: String?) -> Bool { let cdLine = "cd \(shellEscapedPathForScripts(directory))" var script = """ tell application "iTerm2" activate set newWindow to (create window with default profile) tell current session of newWindow write text "\(appleScriptEscaped(cdLine))" """ if let command, !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { script += """ write text "\(appleScriptEscaped(command))" """ } script += """ end tell end tell """ guard let appleScript = NSAppleScript(source: script) else { return false } var errorDict: NSDictionary? appleScript.executeAndReturnError(&errorDict) return errorDict == nil } private func shellEscapedPathForScripts(_ path: String) -> String { "'" + path.replacingOccurrences(of: "'", with: "'\\''") + "'" } private func appleScriptEscaped(_ text: String) -> String { var result = text.replacingOccurrences(of: "\\", with: "\\\\") result = result.replacingOccurrences(of: "\"", with: "\\\"") result = result.replacingOccurrences(of: "\n", with: "; ") result = result.replacingOccurrences(of: "\r", with: "") return result } } ================================================ FILE: services/SessionActions.swift ================================================ import AppKit import Foundation struct ProcessResult { let output: String } enum SessionActionError: LocalizedError { case executableNotFound(URL) case resumeFailed(output: String) case deletionFailed(URL) case featureUnavailable(String) var errorDescription: String? { switch self { case .executableNotFound(let url): return "Executable codex CLI not found: \(url.path)" case .resumeFailed(let output): return "Failed to resume session: \(output)" case .deletionFailed(let url): return "Failed to move file to Trash: \(url.path)" case .featureUnavailable(let message): return message } } } struct SessionActions { let fileManager: FileManager = .default private let codexHome: URL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".codex", isDirectory: true) private let sshExecutablePath = "/usr/bin/ssh" private let defaultPathInjection = "source ~/.bashrc; . \"$HOME/.nvm/nvm.sh\"; . \"$HOME/.nvm/bash_completion\"" private let sshConfigResolver = SSHConfigResolver() let terminalLaunchQueue = DispatchQueue(label: "io.umate.codemate.terminalLaunch", qos: .userInitiated) func configuredProfiles() async -> Set { let persisted = listPersistedProfiles() // Use listProviders() instead of configuredProfiles() which doesn't exist let configured = await codexConfigService.listProviders().map { $0.id } let merged = persisted.union(Set(configured)) return merged } func resolveModel(for session: SessionSummary) -> String? { // For now, just check if the model is non-empty // The configuredProfiles check requires async context, so we'll handle it differently if session.source.baseKind == .codex { if let m = session.model?.trimmingCharacters(in: .whitespacesAndNewlines), !m.isEmpty { return m } } return session.model } func resolveExecutableURL(preferred: URL, executableName: String) -> URL? { // Prefer user-specified path if it exists if fileManager.fileExists(atPath: preferred.path) { return preferred } // Fallback to PATH resolution let basePath = CLIEnvironment.buildBasePATH() let currentPath = ProcessInfo.processInfo.environment["PATH"] ?? "" let combined = currentPath.isEmpty ? basePath : basePath + ":" + currentPath let components = combined.split(separator: ":") for component in components { let candidate = URL(fileURLWithPath: String(component)).appendingPathComponent(executableName) if fileManager.fileExists(atPath: candidate.path) { return candidate } } return nil } // MARK: - Resume helpers (moved to extension to avoid conflicts) internal func resumeRemote( session: SessionSummary, host: String, options: ResumeOptions ) async throws -> ProcessResult { let sshArguments = resolvedSSHContext(for: host) let command = buildRemoteResumeShellCommand( session: session, options: options ) let sshPath = sshExecutablePath return try await withCheckedThrowingContinuation { continuation in Task.detached { do { let process = Process() process.executableURL = URL(fileURLWithPath: sshPath) var arguments: [String] = ["-t"] if let sshArguments { arguments.append(contentsOf: sshArguments) } else { arguments.append(host) } arguments.append(command) process.arguments = arguments let pipe = Pipe() process.standardOutput = pipe process.standardError = pipe try process.run() process.waitUntilExit() let data = pipe.fileHandleForReading.readDataToEndOfFile() let output = String(data: data, encoding: .utf8) ?? "" if process.terminationStatus == 0 { continuation.resume(returning: ProcessResult(output: output)) } else { continuation.resume( throwing: SessionActionError.resumeFailed(output: output)) } } catch { continuation.resume(throwing: error) } } } } // MARK: - Resume helpers (copy/open Terminal) private func shellEscapedPath(_ path: String) -> String { // Simple escape: wrap in single quotes and escape existing single quotes let escaped = path.replacingOccurrences(of: "'", with: "'\"'\"'") return "'\(escaped)'" } private func shellQuoteIfNeeded(_ text: String) -> String { if text.contains(" ") || text.contains(";") || text.contains("&") || text.contains("|") { return shellEscapedPath(text) } return text } private func shellSingleQuoted(_ text: String) -> String { let escaped = text.replacingOccurrences(of: "'", with: "'\"'\"'") return "'\(escaped)'" } private func embeddedExportLines(for source: SessionSource) -> [String] { var lines: [String] = [ "export LANG=zh_CN.UTF-8", "export LC_ALL=zh_CN.UTF-8", "export LC_CTYPE=zh_CN.UTF-8", "export TERM=xterm-256color", ] if source.baseKind == .codex { lines.append("export CODEX_DISABLE_COLOR_QUERY=1") } return lines } func workingDirectory(for session: SessionSummary, override: String? = nil) -> String { if session.isRemote { let trimmed = session.cwd.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return trimmed } if let remotePath = session.remotePath { let parent = (remotePath as NSString).deletingLastPathComponent if !parent.isEmpty { return parent } } return session.cwd } if let override { let trimmed = override.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty, fileManager.fileExists(atPath: trimmed) { return trimmed } } if fileManager.fileExists(atPath: session.cwd) { return session.cwd } return session.fileURL.deletingLastPathComponent().path } private func remoteExecutableName(for session: SessionSummary) -> String { session.source.baseKind.cliExecutableName } func resolvedSSHContext(for alias: String) -> [String]? { let hosts = sshConfigResolver.resolvedHosts() guard let host = hosts.first(where: { $0.alias.caseInsensitiveCompare(alias) == .orderedSame }) else { return nil } return sshArguments(for: host) } private func sshArguments(for host: SSHHost) -> [String] { var args: [String] = [] if let user = host.user, !user.isEmpty { args += ["-l", user] } if let port = host.port { args += ["-p", String(port)] } if let identity = host.identityFile, !identity.isEmpty { args += ["-i", identity] } if let proxyJump = host.proxyJump, !proxyJump.isEmpty { args += ["-J", proxyJump] } if let proxyCommand = host.proxyCommand, !proxyCommand.isEmpty { args += ["-o", "ProxyCommand=\(proxyCommand)"] } if let forwardAgent = host.forwardAgent { args += ["-o", "ForwardAgent=\(forwardAgent ? "yes" : "no")"] } args.append(host.hostname ?? host.alias) return args } private func buildSSHInvocation(host: String, arguments: [String]?, remoteCommand: String) -> String { let args = arguments ?? [host] let sshParts = (["ssh", "-t"] + args).map { shellQuoteIfNeeded($0) }.joined(separator: " ") return "\(sshParts) \(shellSingleQuoted(remoteCommand))" } func remoteResumeInvocationForTerminal( session: SessionSummary, options: ResumeOptions ) -> String? { guard session.isRemote, let host = session.remoteHost else { return nil } let remoteCommand = buildRemoteResumeShellCommand(session: session, options: options) let args = resolvedSSHContext(for: host) return buildSSHInvocation(host: host, arguments: args, remoteCommand: remoteCommand) } func remoteNewInvocationForTerminal( session: SessionSummary, options: ResumeOptions, initialPrompt: String? = nil ) -> String? { guard session.isRemote, let host = session.remoteHost else { return nil } let remoteCommand = buildRemoteNewShellCommand( session: session, options: options, initialPrompt: initialPrompt ) let args = resolvedSSHContext(for: host) return buildSSHInvocation(host: host, arguments: args, remoteCommand: remoteCommand) } func buildRemoteShellCommand( session: SessionSummary, exports: [String], invocation: String ) -> String { let cwd = workingDirectory(for: session) var scriptParts: [String] = [defaultPathInjection] scriptParts.append("cd \(shellEscapedPath(cwd))") if !exports.isEmpty { scriptParts.append(exports.joined(separator: "; ")) } scriptParts.append(invocation) let script = scriptParts.joined(separator: "; ") let sanitized = script.replacingOccurrences(of: "\"", with: "\\\"") return #"bash -lc "\#(sanitized)""# } func buildRemoteResumeShellCommand( session: SessionSummary, options: ResumeOptions ) -> String { var exports = embeddedExportLines(for: session.source) if session.source.baseKind == .gemini { let envLines = geminiEnvironmentExportLines( environment: geminiRuntimeConfiguration(options: options).environment) exports.append(contentsOf: envLines) } let invocation = buildResumeCLIInvocation( session: session, executablePath: remoteExecutableName(for: session), options: options ) return buildRemoteShellCommand( session: session, exports: exports, invocation: invocation ) } func buildRemoteNewShellCommand( session: SessionSummary, options: ResumeOptions, initialPrompt: String? = nil ) -> String { var exports = embeddedExportLines(for: session.source) if session.source.baseKind == .gemini { let envLines = geminiEnvironmentExportLines( environment: geminiRuntimeConfiguration(options: options).environment) exports.append(contentsOf: envLines) } let invocation = buildLocalNewSessionCLIInvocation( session: session, options: options, initialPrompt: initialPrompt ) return buildRemoteShellCommand( session: session, exports: exports, invocation: invocation ) } private func flags(from options: ResumeOptions) -> [String] { // Highest precedence: dangerously bypass if options.dangerouslyBypass { return ["--dangerously-bypass-approvals-and-sandbox"] } // Next: sandbox mode switch options.sandbox { case .none: return [] case .readOnly: return ["--sandbox=read-only"] case .workspaceWrite: return ["--sandbox=workspace-write"] case .dangerFullAccess: return ["--sandbox=danger-full-access"] } } // Note: buildResumeArguments and buildClaudeResumeArguments are implemented in SessionActions+Commands.swift // to avoid conflicts // Note: buildNewSessionCLIInvocation is implemented in SessionActions+Commands.swift // to avoid conflicts // Note: buildResumeCommandLines is implemented in SessionActions+Commands.swift // to avoid conflicts // Note: buildNewSessionCommandLines is implemented in SessionActions+Commands.swift // to avoid conflicts // Note: buildExternalResumeCommands is implemented in SessionActions+Commands.swift // to avoid conflicts // Additional helper methods would continue here... // For brevity, I'll add the essential ones needed for remote support private func conversationId(for session: SessionSummary) -> String { return session.id } private let codexConfigService = CodexConfigService() } ================================================ FILE: services/SessionActivityTracker.swift ================================================ import Foundation /// Tracks real-time session activity and followup status @MainActor final class SessionActivityTracker: ObservableObject { @Published private(set) var activeUpdatingIDs: Set = [] @Published private(set) var awaitingFollowupIDs: Set = [] private var activityHeartbeat: [String: Date] = [:] private var fileMTimeCache: [String: Date] = [:] private var activityPruneTask: Task? private var quickPulseTask: Task? private var lastQuickPulseAt: Date = .distantPast init() { startPruneTicker() observeAgentCompletions() } deinit { activityPruneTask?.cancel() quickPulseTask?.cancel() } func registerHeartbeat(previous: [SessionSummary], current: [SessionSummary]) { var prevMap: [String: Date] = [:] for s in previous { if let t = s.lastUpdatedAt { prevMap[s.id] = t } } let now = Date() for s in current { guard let newT = s.lastUpdatedAt else { continue } if let oldT = prevMap[s.id], newT > oldT { activityHeartbeat[s.id] = now } } recomputeActiveIDs() } func isActivelyUpdating(_ id: String) -> Bool { activeUpdatingIDs.contains(id) } func isAwaitingFollowup(_ id: String) -> Bool { awaitingFollowupIDs.contains(id) } func markAwaitingFollowup(_ id: String) { awaitingFollowupIDs.insert(id) } func quickPulse(sessions: [SessionSummary]) { let now = Date() guard now.timeIntervalSince(lastQuickPulseAt) > 0.4 else { return } lastQuickPulseAt = now quickPulseTask?.cancel() let displayed = sessions.prefix(200) quickPulseTask = Task.detached { [weak self] in guard let self else { return } let fm = FileManager.default var modified: [String: Date] = [:] for s in displayed { let path = s.fileURL.path if let attrs = try? fm.attributesOfItem(atPath: path), let m = attrs[.modificationDate] as? Date { modified[s.id] = m } } let snapshot = modified await MainActor.run { let now = Date() for (id, m) in snapshot { let previous = self.fileMTimeCache[id] self.fileMTimeCache[id] = m if let previous, m > previous { self.activityHeartbeat[id] = now } } self.recomputeActiveIDs() } } } func cancelPulse() { quickPulseTask?.cancel() quickPulseTask = nil } private func startPruneTicker() { activityPruneTask?.cancel() activityPruneTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(nanoseconds: 1_000_000_000) await MainActor.run { self?.recomputeActiveIDs() } } } } private func recomputeActiveIDs() { let cutoff = Date().addingTimeInterval(-3.0) activeUpdatingIDs = Set(activityHeartbeat.filter { $0.value > cutoff }.keys) } private func observeAgentCompletions() { NotificationCenter.default.addObserver( forName: .codMateAgentCompleted, object: nil, queue: .main ) { [weak self] note in guard let id = note.userInfo?["sessionID"] as? String else { return } Task { @MainActor in self?.awaitingFollowupIDs.insert(id) } } } } ================================================ FILE: services/SessionCacheStore.swift ================================================ import Foundation actor SessionCacheStore { private struct Entry: Codable { let path: String let modificationTime: TimeInterval? let summary: SessionSummary } private var map: [String: Entry] = [:] // key: file path private let url: URL private var needsSave = false init(fileManager: FileManager = .default) { let dir = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! .appendingPathComponent("CodMate", isDirectory: true) try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true) url = dir.appendingPathComponent("sessionIndex-v2.json") // Load cache synchronously in init (init is nonisolated) if let data = try? Data(contentsOf: url), let entries = try? JSONDecoder().decode([Entry].self, from: data) { map = Dictionary(uniqueKeysWithValues: entries.map { ($0.path, $0) }) } } private func saveIfNeededDebounced() { guard needsSave else { return } needsSave = false let entries = Array(map.values) if let data = try? JSONEncoder().encode(entries) { try? data.write(to: url, options: .atomic) } } func get(path: String, modificationDate: Date?) -> SessionSummary? { guard let entry = map[path] else { return nil } let mt = modificationDate?.timeIntervalSince1970 if entry.modificationTime == mt { return entry.summary } return nil } func set(path: String, modificationDate: Date?, summary: SessionSummary) { let mt = modificationDate?.timeIntervalSince1970 map[path] = Entry(path: path, modificationTime: mt, summary: summary) needsSave = true saveIfNeededDebounced() } } ================================================ FILE: services/SessionCommandGenerator.swift ================================================ import Foundation struct SessionCommandGenerator { let actions: SessionActions func embeddedResume( session: SessionSummary, executableURL: URL, options: ResumeOptions, workingDirectory: String? = nil, codexHome: String? = nil, includeCd: Bool = true ) -> String { actions.buildEmbeddedResumeCommandLines( session: session, executableURL: executableURL, options: options, workingDirectory: workingDirectory, codexHome: codexHome, includeCd: includeCd ) } func embeddedNew( session: SessionSummary, project: Project? = nil, executableURL: URL, options: ResumeOptions, initialPrompt: String? = nil, codexHome: String? = nil ) -> String { if session.source == .codexLocal, let project, project.profile != nil || (project.profileId?.isEmpty == false) { return actions.buildNewSessionUsingProjectProfileCommandLines( session: session, project: project, executableURL: executableURL, options: options, initialPrompt: initialPrompt, codexHome: codexHome ) } return actions.buildEmbeddedNewSessionCommandLines( session: session, executableURL: executableURL, options: options, initialPrompt: initialPrompt, codexHome: codexHome ) } func embeddedNewProject( project: Project, executableURL: URL, options: ResumeOptions, codexHome: String? = nil ) -> String { actions.buildEmbeddedNewProjectCommandLines( project: project, executableURL: executableURL, options: options, codexHome: codexHome ) } func inlineResume( session: SessionSummary, project: Project? = nil, executablePath: String, options: ResumeOptions, codexHome: String? = nil ) -> String { if session.isRemote, let remote = actions.remoteResumeInvocationForTerminal( session: session, options: options ) { return remote } if session.source == .codexLocal, let project, project.profile != nil || (project.profileId?.isEmpty == false) { return actions.buildResumeUsingProjectProfileCLIInvocation( session: session, project: project, executablePath: executablePath, options: options, codexHome: codexHome ) } return actions.buildResumeCLIInvocation( session: session, executablePath: executablePath, options: options, codexHome: codexHome ) } func inlineNew( session: SessionSummary, project: Project? = nil, executablePath: String, options: ResumeOptions, initialPrompt: String? = nil, codexHome: String? = nil ) -> String { if session.isRemote, let remote = actions.remoteNewInvocationForTerminal( session: session, options: options, initialPrompt: initialPrompt ) { return remote } if session.source == .codexLocal, let project, project.profile != nil || (project.profileId?.isEmpty == false) { return actions.buildNewSessionUsingProjectProfileCLIInvocation( session: session, project: project, options: options, initialPrompt: initialPrompt, executablePath: executablePath, codexHome: codexHome ) } return actions.buildNewSessionCLIInvocation( session: session, options: options, initialPrompt: initialPrompt, executablePath: executablePath, codexHome: codexHome ) } func inlineNewProject( project: Project, executablePath: String, options: ResumeOptions, codexHome: String? = nil ) -> String { actions.buildNewProjectCLIInvocation( project: project, options: options, executablePath: executablePath, codexHome: codexHome ) } func warpResume( session: SessionSummary, executableURL: URL, options: ResumeOptions, titleHint: String? = nil, codexHome: String? = nil ) -> String { actions.buildWarpResumeCommands( session: session, executableURL: executableURL, options: options, titleHint: titleHint, codexHome: codexHome ) } func warpNewSession( session: SessionSummary, executableURL: URL, options: ResumeOptions, titleHint: String? = nil, codexHome: String? = nil ) -> String { actions.buildWarpNewSessionCommands( session: session, executableURL: executableURL, options: options, titleHint: titleHint, codexHome: codexHome ) } func warpNewProject( project: Project, executableURL: URL, options: ResumeOptions, titleHint: String? = nil, codexHome: String? = nil ) -> String { actions.buildWarpNewProjectCommands( project: project, executableURL: executableURL, options: options, titleHint: titleHint, codexHome: codexHome ) } func projectClaudeInvocation( project: Project, executablePath: String, options: ResumeOptions, fallbackModel: String? ) -> String { let effectiveModel = (project.profile?.model ?? fallbackModel) return actions.buildClaudeProjectCLIInvocation( executablePath: executablePath, options: options, model: effectiveModel ) } func projectGeminiInvocation( executablePath: String, options: ResumeOptions ) -> String { actions.buildGeminiCLIInvocation( executablePath: executablePath, options: options ) } } ================================================ FILE: services/SessionEnrichmentService.swift ================================================ import Foundation /// Service responsible for background enrichment of session summaries @MainActor final class SessionEnrichmentService { private let indexer: SessionIndexer private let claudeProvider: ClaudeSessionProvider private var enrichmentTask: Task? private var enrichmentSnapshots: [String: Set] = [:] var isEnriching = false var enrichmentProgress: Int = 0 var enrichmentTotal: Int = 0 init(indexer: SessionIndexer, claudeProvider: ClaudeSessionProvider) { self.indexer = indexer self.claudeProvider = claudeProvider } func startEnrichment( sessions: [SessionSummary], cacheKey: String, notesSnapshot: [String: SessionNote], onUpdate: @escaping ([SessionSummary]) -> Void ) { enrichmentTask?.cancel() let currentIDs = Set(sessions.map(\.id)) if let cached = enrichmentSnapshots[cacheKey], cached == currentIDs { isEnriching = false enrichmentProgress = 0 enrichmentTotal = 0 return } if sessions.isEmpty { isEnriching = false enrichmentProgress = 0 enrichmentTotal = 0 enrichmentSnapshots[cacheKey] = currentIDs return } enrichmentTask = Task { [weak self] in guard let self else { return } await MainActor.run { self.isEnriching = true self.enrichmentProgress = 0 self.enrichmentTotal = sessions.count } let concurrency = max(2, ProcessInfo.processInfo.processorCount / 2) try? await withThrowingTaskGroup(of: (String, SessionSummary)?.self) { group in var iterator = sessions.makeIterator() var processedCount = 0 func addNext(_ n: Int) { for _ in 0..= 50 || elapsed.components.seconds >= 1 { await flush() } } addNext(1) } await flush() await MainActor.run { self.isEnriching = false self.enrichmentProgress = 0 self.enrichmentTotal = 0 self.enrichmentSnapshots[cacheKey] = currentIDs } } } } func cancel() { enrichmentTask?.cancel() enrichmentTask = nil isEnriching = false } func invalidateCache(for key: String) { enrichmentSnapshots.removeValue(forKey: key) } func clearAllCaches() { enrichmentSnapshots.removeAll() } } ================================================ FILE: services/SessionIndexSQLiteStore.swift ================================================ import CryptoKit import Foundation import OSLog import SQLite3 private let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) /// 持久化缓存的一条记录,包含 SessionSummary 以及文件元数据。 struct SessionIndexRecord: Sendable { let summary: SessionSummary let filePath: String let fileModificationTime: Date? let fileSize: UInt64? let project: String? let schemaVersion: Int let parseError: String? let tokenBreakdown: SessionTokenBreakdown? let parseLevel: String? // "metadata" | "full" | "enriched" let parsedAt: Date? // When this parse was done } struct SessionIndexMeta: Sendable { let lastFullIndexAt: Date? let sessionCount: Int } enum SessionIndexSQLiteStoreError: Error { case openFailed(String) case stepFailed(String) case bindFailed(String) case decodeFailed(String) } /// SQLite 持久化缓存,负责 sessions 汇总数据的存储与读取。 actor SessionIndexSQLiteStore { static let schemaVersion = 3 static let instructionsPreviewLimit = 128 private let logger = Logger(subsystem: "io.umate.codmate", category: "SessionIndexSQLiteStore") private let dbURL: URL private var db: OpaquePointer? private var missingDbLogged = false init(baseDirectory: URL? = nil, fileManager: FileManager = .default) { let directory: URL if let baseDirectory { directory = baseDirectory } else { directory = fileManager.homeDirectoryForCurrentUser.appendingPathComponent(".codmate", isDirectory: true) } let legacyURL = directory.appendingPathComponent("sessionIndex-v3.db") if fileManager.fileExists(atPath: legacyURL.path) { try? fileManager.removeItem(at: legacyURL) } try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) dbURL = directory.appendingPathComponent("sessionIndex-v4.db") } // MARK: - Public API func reset() throws { closeDatabase() try? FileManager.default.removeItem(at: dbURL) let legacy = dbURL.deletingLastPathComponent().appendingPathComponent("sessionIndex-v3.db") try? FileManager.default.removeItem(at: legacy) } /// 更新全量索引完成时间和记录数。 func setMeta(lastFullIndexAt: Date, sessionCount: Int) throws { try openIfNeeded() let sql = "INSERT INTO meta (key, last_full_index_at, session_count) VALUES ('global', ?1, ?2) ON CONFLICT(key) DO UPDATE SET last_full_index_at=excluded.last_full_index_at, session_count=excluded.session_count" var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } defer { sqlite3_finalize(stmt) } sqlite3_bind_double(stmt, 1, lastFullIndexAt.timeIntervalSince1970) sqlite3_bind_int(stmt, 2, Int32(sessionCount)) guard sqlite3_step(stmt) == SQLITE_DONE else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } } func fetchMeta() throws -> SessionIndexMeta { try openIfNeeded() let sql = "SELECT last_full_index_at, session_count FROM meta WHERE key = 'global' LIMIT 1" var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } defer { sqlite3_finalize(stmt) } if sqlite3_step(stmt) == SQLITE_ROW { let ts = sqlite3_column_double(stmt, 0) let count = Int(sqlite3_column_int(stmt, 1)) let date = ts == 0 ? nil : Date(timeIntervalSince1970: ts) return SessionIndexMeta(lastFullIndexAt: date, sessionCount: count) } return SessionIndexMeta(lastFullIndexAt: nil, sessionCount: 0) } /// 按文件路径 + mtime(可选 fileSize 校验)命中缓存,用于索引快速路径。 func fetch(path: String, modificationDate: Date?, fileSize: UInt64?) throws -> SessionSummary? { guard let modificationDate else { return nil } try openIfNeeded() let sql = "SELECT payload, file_size, schema_version FROM sessions WHERE file_path = ?1 AND file_mtime = ?2 LIMIT 1" var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } defer { sqlite3_finalize(stmt) } sqlite3_bind_text(stmt, 1, path, -1, SQLITE_TRANSIENT) sqlite3_bind_double(stmt, 2, modificationDate.timeIntervalSince1970) if sqlite3_step(stmt) == SQLITE_ROW { let storedSize = columnInt64(stmt, index: 1).flatMap { UInt64($0) } let schemaVersion = Int(sqlite3_column_int(stmt, 2)) guard schemaVersion == Self.schemaVersion else { logger.info("cache miss (schema mismatch) for path=\(path, privacy: .public)") return nil } if let fileSize, let storedSize, fileSize != storedSize { logger.info("cache miss (size mismatch) for path=\(path, privacy: .public)") return nil } guard let payload = columnData(stmt, index: 0) else { return nil } let summary = try JSONDecoder().decode(SessionSummary.self, from: payload) // Invalidate cache if Claude session was parsed with old schema (before timeline-based counting) if summary.source.baseKind == .claude && summary.parseLevel != .enriched { logger.info("cache miss (schema upgrade) for path=\(path, privacy: .public)") return nil } logger.info("cache hit (path+mtime) kind=\(summary.source.baseKind.rawValue, privacy: .public) path=\(path, privacy: .public)") return summary } logger.info("cache miss (path+mtime) path=\(path, privacy: .public)") return nil } func fetch(sessionId: String) throws -> SessionIndexRecord? { try openIfNeeded() let sql = "SELECT payload, file_path, file_mtime, file_size, project, schema_version, parse_error, tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, parse_level, parsed_at FROM sessions WHERE session_id = ?1" // swiftlint:disable:this line_length var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } defer { sqlite3_finalize(stmt) } sqlite3_bind_text(stmt, 1, sessionId, -1, SQLITE_TRANSIENT) if sqlite3_step(stmt) == SQLITE_ROW { guard let payload = columnData(stmt, index: 0) else { throw SessionIndexSQLiteStoreError.decodeFailed("Missing payload for session_id=\(sessionId)") } var summary = try JSONDecoder().decode(SessionSummary.self, from: payload) let filePath = columnText(stmt, index: 1) ?? summary.fileURL.path let fileMtime = columnDate(stmt, index: 2) let fileSize = columnInt64(stmt, index: 3).flatMap { UInt64($0) } let project = columnText(stmt, index: 4) let schemaVersion = Int(sqlite3_column_int(stmt, 5)) guard schemaVersion == Self.schemaVersion else { return nil } let parseError = columnText(stmt, index: 6) let tokenBreakdown = tokenBreakdownFromColumns(stmt, startIndex: 7) summary = summary.withTokenBreakdownFallback(tokenBreakdown) let parseLevel = columnText(stmt, index: 11) let parsedAt = columnDate(stmt, index: 12) return SessionIndexRecord( summary: summary, filePath: filePath, fileModificationTime: fileMtime, fileSize: fileSize, project: project, schemaVersion: schemaVersion, parseError: parseError, tokenBreakdown: tokenBreakdown, parseLevel: parseLevel, parsedAt: parsedAt ) } return nil } func fetchAll(limit: Int? = nil) throws -> [SessionIndexRecord] { try openIfNeeded() var sql = "SELECT payload, file_path, file_mtime, file_size, project, schema_version, parse_error, tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, parse_level, parsed_at FROM sessions" if let limit { sql += " LIMIT \(limit)" } var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } defer { sqlite3_finalize(stmt) } var result: [SessionIndexRecord] = [] let decoder = JSONDecoder() while sqlite3_step(stmt) == SQLITE_ROW { guard let payload = columnData(stmt, index: 0) else { continue } guard var summary = try? decoder.decode(SessionSummary.self, from: payload) else { continue } let filePath = columnText(stmt, index: 1) ?? summary.fileURL.path let fileMtime = columnDate(stmt, index: 2) let fileSize = columnInt64(stmt, index: 3).flatMap { UInt64($0) } let project = columnText(stmt, index: 4) let schemaVersion = Int(sqlite3_column_int(stmt, 5)) let parseError = columnText(stmt, index: 6) let tokenBreakdown = tokenBreakdownFromColumns(stmt, startIndex: 7) summary = summary.withTokenBreakdownFallback(tokenBreakdown) let parseLevel = columnText(stmt, index: 11) let parsedAt = columnDate(stmt, index: 12) result.append( SessionIndexRecord( summary: summary, filePath: filePath, fileModificationTime: fileMtime, fileSize: fileSize, project: project, schemaVersion: schemaVersion, parseError: parseError, tokenBreakdown: tokenBreakdown, parseLevel: parseLevel, parsedAt: parsedAt ) ) } return result } /// 聚合 Overview 统计(全部来源,使用缓存数据)。 func fetchOverviewAggregate(scope: OverviewAggregateScope? = nil) throws -> OverviewAggregate { let started = Date() try openIfNeeded() let totals = try fetchTotals(scope: scope) let sources = try fetchSourceAggregates(scope: scope) let daily = try fetchDailyAggregates(scope: scope) let elapsed = Date().timeIntervalSince(started) logger.log("fetchOverviewAggregate totals.sessions=\(totals.sessions, privacy: .public) sources=\(sources.count, privacy: .public) daily=\(daily.count, privacy: .public) in \(elapsed, format: .fixed(precision: 3))s") return OverviewAggregate( totalSessions: totals.sessions, totalTokens: totals.tokens, totalDuration: totals.duration, userMessages: totals.userMessages, assistantMessages: totals.assistantMessages, toolInvocations: totals.toolInvocations, sources: sources, daily: daily, generatedAt: Date() ) } /// 缓存覆盖范围(命中源、记录数、全量完成时间)。 func fetchCoverage() throws -> SessionIndexCoverage { let started = Date() try openIfNeeded() let meta = try fetchMeta() let sources = try distinctSources() let sessionCount = try countSessions() let elapsed = Date().timeIntervalSince(started) logger.log("fetchCoverage sessions=\(sessionCount, privacy: .public) sources=\(sources, privacy: .public) metaTs=\(meta.lastFullIndexAt?.timeIntervalSince1970 ?? 0, privacy: .public) in \(elapsed, format: .fixed(precision: 3))s") return SessionIndexCoverage( sessionCount: sessionCount, lastFullIndexAt: meta.lastFullIndexAt, sources: sources ) } func upsert( summary: SessionSummary, project: String?, fileModificationTime: Date?, fileSize: UInt64?, tokenBreakdown: SessionTokenBreakdown?, fullInstructions: String? = nil, parseError: String? = nil, parseLevel: String = "full" // "metadata" | "full" | "enriched" ) throws { try openIfNeeded() // Downgrade protection: // If we already have a record for this session, check if the new data would overwrite // a high-quality parse (full/enriched) with a low-quality one (metadata) when the file hasn't changed. if let oldRecord = try? fetch(sessionId: summary.id) { // Check if file is effectively unchanged let mtimeChanged = (fileModificationTime != nil && oldRecord.fileModificationTime != nil) && (abs(fileModificationTime!.timeIntervalSince1970 - oldRecord.fileModificationTime!.timeIntervalSince1970) > 0.001) let sizeChanged = (fileSize != nil && oldRecord.fileSize != nil) && (fileSize != oldRecord.fileSize) let fileUnchanged = !mtimeChanged && !sizeChanged if fileUnchanged { let oldRank = parseLevelRank(oldRecord.parseLevel) let newRank = parseLevelRank(parseLevel) // If trying to overwrite higher rank with lower rank (e.g. Full -> Metadata), // we SKIP the update for all content fields to preserve the better data. // However, we might want to update last_updated_at if the new one is fresher // (though usually full parse has better timestamp too). // For safety, we just abort the upsert entirely if we are downgrading on same file. if newRank < oldRank { // logger.debug("Skipping upsert for \(summary.id): preventing downgrade from \(oldRecord.parseLevel ?? "nil") to \(parseLevel)") return } } } let resolvedProjectKey = Self.resolveProjectKey(projectId: project, cwd: summary.cwd) let normalizedFullInstructions = Self.normalizeInstructions(fullInstructions ?? summary.instructions) let instructionsPreview = Self.instructionsPreview(from: normalizedFullInstructions) let cachedSummary = summary.withInstructionPreview(instructionsPreview) let sql = """ INSERT INTO sessions ( session_id, file_path, file_mtime, file_size, schema_version, parse_error, project, source, source_host, started_at, ended_at, last_updated_at, active_duration, cli_version, cwd, originator, instructions, model, approval_policy, user_message_count, assistant_message_count, tool_invocation_count, reasoning_count, response_counts_json, turn_context_count, tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, tokens_total, event_count, line_count, remote_path, user_title, user_comment, task_id, has_terminal, has_review, payload, parse_level, parsed_at ) VALUES ( ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32, ?33, ?34, ?35, ?36, ?37, ?38, ?39, ?40, ?41 ) ON CONFLICT(session_id) DO UPDATE SET file_path=excluded.file_path, file_mtime=excluded.file_mtime, file_size=excluded.file_size, schema_version=excluded.schema_version, parse_error=excluded.parse_error, project=excluded.project, source=excluded.source, source_host=excluded.source_host, started_at=excluded.started_at, ended_at=excluded.ended_at, last_updated_at=excluded.last_updated_at, active_duration=excluded.active_duration, cli_version=excluded.cli_version, cwd=excluded.cwd, originator=excluded.originator, instructions=excluded.instructions, model=excluded.model, approval_policy=excluded.approval_policy, user_message_count=excluded.user_message_count, assistant_message_count=excluded.assistant_message_count, tool_invocation_count=excluded.tool_invocation_count, reasoning_count=excluded.reasoning_count, response_counts_json=excluded.response_counts_json, turn_context_count=excluded.turn_context_count, tokens_input=excluded.tokens_input, tokens_output=excluded.tokens_output, tokens_cache_read=excluded.tokens_cache_read, tokens_cache_creation=excluded.tokens_cache_creation, tokens_total=excluded.tokens_total, event_count=excluded.event_count, line_count=excluded.line_count, remote_path=excluded.remote_path, user_title=excluded.user_title, user_comment=excluded.user_comment, task_id=excluded.task_id, has_terminal=excluded.has_terminal, has_review=excluded.has_review, payload=excluded.payload, parse_level=excluded.parse_level, parsed_at=excluded.parsed_at """ var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } defer { sqlite3_finalize(stmt) } let responseCountsJSON = (try? JSONEncoder().encode(cachedSummary.responseCounts)).flatMap { String(data: $0, encoding: .utf8) } let summaryData = try JSONEncoder().encode(cachedSummary) bindText(stmt, index: 1, value: cachedSummary.id) bindText(stmt, index: 2, value: cachedSummary.fileURL.path) bindDate(stmt, index: 3, value: fileModificationTime) bindInt64(stmt, index: 4, value: fileSize.map(Int64.init)) sqlite3_bind_int(stmt, 5, Int32(Self.schemaVersion)) bindText(stmt, index: 6, value: parseError) bindText(stmt, index: 7, value: project) let sourceEncoding = encode(source: cachedSummary.source) bindText(stmt, index: 8, value: sourceEncoding.kind) bindText(stmt, index: 9, value: sourceEncoding.host) bindDate(stmt, index: 10, value: cachedSummary.startedAt) bindDate(stmt, index: 11, value: cachedSummary.endedAt) bindDate(stmt, index: 12, value: cachedSummary.lastUpdatedAt) bindDouble(stmt, index: 13, value: cachedSummary.activeDuration) bindText(stmt, index: 14, value: cachedSummary.cliVersion) bindText(stmt, index: 15, value: cachedSummary.cwd) bindText(stmt, index: 16, value: cachedSummary.originator) bindText(stmt, index: 17, value: instructionsPreview) bindText(stmt, index: 18, value: cachedSummary.model) bindText(stmt, index: 19, value: cachedSummary.approvalPolicy) sqlite3_bind_int(stmt, 20, Int32(cachedSummary.userMessageCount)) sqlite3_bind_int(stmt, 21, Int32(cachedSummary.assistantMessageCount)) sqlite3_bind_int(stmt, 22, Int32(cachedSummary.toolInvocationCount)) sqlite3_bind_int(stmt, 23, Int32(cachedSummary.responseCounts["reasoning"] ?? 0)) bindText(stmt, index: 24, value: responseCountsJSON) sqlite3_bind_int(stmt, 25, Int32(cachedSummary.turnContextCount)) bindInt(stmt, index: 26, value: tokenBreakdown?.input) bindInt(stmt, index: 27, value: tokenBreakdown?.output) bindInt(stmt, index: 28, value: tokenBreakdown?.cacheRead) bindInt(stmt, index: 29, value: tokenBreakdown?.cacheCreation) bindInt(stmt, index: 30, value: cachedSummary.totalTokens) sqlite3_bind_int(stmt, 31, Int32(cachedSummary.eventCount)) sqlite3_bind_int(stmt, 32, Int32(cachedSummary.lineCount)) bindText(stmt, index: 33, value: cachedSummary.remotePath) bindText(stmt, index: 34, value: cachedSummary.userTitle) bindText(stmt, index: 35, value: cachedSummary.userComment) bindText(stmt, index: 36, value: cachedSummary.taskId?.uuidString) sqlite3_bind_int(stmt, 37, 0) // has_terminal (placeholder) sqlite3_bind_int(stmt, 38, 0) // has_review (placeholder) bindData(stmt, index: 39, data: summaryData) bindText(stmt, index: 40, value: parseLevel) bindDate(stmt, index: 41, value: Date()) // parsed_at = now let stepResult = sqlite3_step(stmt) guard stepResult == SQLITE_DONE else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } if let resolvedProjectKey, let normalizedFullInstructions { try upsertProject( projectKey: resolvedProjectKey, fullInstructions: normalizedFullInstructions, preview: instructionsPreview ) } } /// Fetch full instructions for the first matching project key. func fetchProjectInstructions(keys: [String]) throws -> String? { try openIfNeeded() guard !keys.isEmpty else { return nil } let sql = "SELECT instructions_full FROM projects WHERE project_key = ?1 LIMIT 1" for key in keys { var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } defer { sqlite3_finalize(stmt) } sqlite3_bind_text(stmt, 1, key, -1, SQLITE_TRANSIENT) if sqlite3_step(stmt) == SQLITE_ROW { return columnText(stmt, index: 0) } } return nil } /// Update project assignment for a session without touching other fields. func updateProject(sessionId: String, project: String?) throws { try openIfNeeded() let sql = "UPDATE sessions SET project = ?1 WHERE session_id = ?2" var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } defer { sqlite3_finalize(stmt) } bindText(stmt, index: 1, value: project) sqlite3_bind_text(stmt, 2, sessionId, -1, SQLITE_TRANSIENT) guard sqlite3_step(stmt) == SQLITE_DONE else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } } func updateUserMetadata(sessionId: String, title: String?, comment: String?) throws { try openIfNeeded() let sql = "UPDATE sessions SET user_title = ?1, user_comment = ?2 WHERE session_id = ?3" var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } defer { sqlite3_finalize(stmt) } bindText(stmt, index: 1, value: title) bindText(stmt, index: 2, value: comment) sqlite3_bind_text(stmt, 3, sessionId, -1, SQLITE_TRANSIENT) guard sqlite3_step(stmt) == SQLITE_DONE else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } } private func upsertProject( projectKey: String, fullInstructions: String, preview: String? ) throws { try openIfNeeded() let sql = """ INSERT INTO projects (project_key, instructions_full, instructions_preview, updated_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(project_key) DO UPDATE SET instructions_full=excluded.instructions_full, instructions_preview=excluded.instructions_preview, updated_at=excluded.updated_at """ var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } defer { sqlite3_finalize(stmt) } sqlite3_bind_text(stmt, 1, projectKey, -1, SQLITE_TRANSIENT) sqlite3_bind_text(stmt, 2, fullInstructions, -1, SQLITE_TRANSIENT) bindText(stmt, index: 3, value: preview) bindDate(stmt, index: 4, value: Date()) guard sqlite3_step(stmt) == SQLITE_DONE else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } } func delete(sessionId: String) throws { try openIfNeeded() let sql = "DELETE FROM sessions WHERE session_id = ?1" var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } defer { sqlite3_finalize(stmt) } sqlite3_bind_text(stmt, 1, sessionId, -1, SQLITE_TRANSIENT) guard sqlite3_step(stmt) == SQLITE_DONE else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } } /// 批量 upsert,使用单个事务降低开销。 func upsertBatch(summaries: [SessionSummary]) throws { guard !summaries.isEmpty else { return } try openIfNeeded() try exec("BEGIN IMMEDIATE TRANSACTION;") do { for summary in summaries { try upsert( summary: summary, project: nil, fileModificationTime: nil, fileSize: summary.fileSizeBytes, tokenBreakdown: summary.tokenBreakdown, parseError: nil ) } try exec("COMMIT;") } catch { let _ = try? exec("ROLLBACK;") throw error } } // MARK: - Private private func openIfNeeded() throws { if db != nil { if !FileManager.default.fileExists(atPath: dbURL.path) { if !missingDbLogged { logger.error("Database file missing while connection open; recreating new store.") missingDbLogged = true } closeDatabase() } else { return } } if !FileManager.default.fileExists(atPath: dbURL.path) { if !missingDbLogged { logger.error("Database file missing; auto-creating a fresh cache store.") missingDbLogged = true } let directory = dbURL.deletingLastPathComponent() try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) } let flags = SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX if sqlite3_open_v2(dbURL.path, &db, flags, nil) != SQLITE_OK { throw SessionIndexSQLiteStoreError.openFailed(errorMessage) } missingDbLogged = false try applyPragmas() try createSchema() } private func closeDatabase() { if let db { sqlite3_close(db) } db = nil } private func applyPragmas() throws { try exec("PRAGMA journal_mode=WAL;") try exec("PRAGMA synchronous=NORMAL;") try exec("PRAGMA foreign_keys=ON;") try exec("PRAGMA temp_store=MEMORY;") try exec("PRAGMA cache_size=-2000;") // ~2MB page cache try exec("PRAGMA busy_timeout=5000;") } private func predicate( for scope: OverviewAggregateScope? ) -> (clause: String, binder: (OpaquePointer?, Int32) -> Int32) { guard let scope else { return ("", { _, idx in idx }) } let dateColumn: String switch scope.dateDimension { case .created: dateColumn = "started_at" case .updated: dateColumn = "COALESCE(last_updated_at, started_at)" } var components: [String] = [] components.append("\(dateColumn) >= ?") components.append("\(dateColumn) <= ?") let projects = Array(scope.projectIds ?? []) if !projects.isEmpty { let placeholders = Array(repeating: "?", count: projects.count).joined(separator: ",") components.append("project IN (\(placeholders))") } let clause = components.joined(separator: " AND ") let start = scope.start.timeIntervalSince1970 let end = scope.end.timeIntervalSince1970 let binder: (OpaquePointer?, Int32) -> Int32 = { stmt, startIndex in var idx = startIndex sqlite3_bind_double(stmt, idx, start) idx += 1 sqlite3_bind_double(stmt, idx, end) idx += 1 if !projects.isEmpty { for project in projects { sqlite3_bind_text(stmt, idx, project, -1, SQLITE_TRANSIENT) idx += 1 } } return idx } return (clause, binder) } private func createSchema() throws { let createSQL = """ CREATE TABLE IF NOT EXISTS meta ( key TEXT PRIMARY KEY, last_full_index_at REAL, session_count INTEGER ); CREATE TABLE IF NOT EXISTS sessions ( session_id TEXT PRIMARY KEY, file_path TEXT NOT NULL, file_mtime REAL, file_size INTEGER, schema_version INTEGER NOT NULL, parse_error TEXT, project TEXT, source TEXT NOT NULL, source_host TEXT, started_at REAL NOT NULL, ended_at REAL, last_updated_at REAL, active_duration REAL, cli_version TEXT NOT NULL, cwd TEXT NOT NULL, originator TEXT NOT NULL, instructions TEXT, model TEXT, approval_policy TEXT, user_message_count INTEGER NOT NULL, assistant_message_count INTEGER NOT NULL, tool_invocation_count INTEGER NOT NULL, reasoning_count INTEGER NOT NULL, response_counts_json TEXT, turn_context_count INTEGER NOT NULL, tokens_input INTEGER, tokens_output INTEGER, tokens_cache_read INTEGER, tokens_cache_creation INTEGER, tokens_total INTEGER, event_count INTEGER NOT NULL, line_count INTEGER NOT NULL, remote_path TEXT, user_title TEXT, user_comment TEXT, task_id TEXT, has_terminal INTEGER, has_review INTEGER, payload BLOB NOT NULL, parse_level TEXT DEFAULT 'metadata', parsed_at REAL ); CREATE TABLE IF NOT EXISTS projects ( project_key TEXT PRIMARY KEY, instructions_full TEXT, instructions_preview TEXT, updated_at REAL ); CREATE TABLE IF NOT EXISTS timeline_previews ( session_id TEXT NOT NULL, turn_id TEXT NOT NULL, turn_index INTEGER NOT NULL, timestamp REAL NOT NULL, user_preview TEXT, outputs_preview TEXT, output_count INTEGER, has_tool_calls INTEGER, has_thinking INTEGER, file_mtime REAL, file_size INTEGER, PRIMARY KEY (session_id, turn_id) ); """ try exec(createSQL) try exec("CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project);") try exec("CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(last_updated_at);") try exec("CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at);") try exec("CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source);") try exec("CREATE INDEX IF NOT EXISTS idx_sessions_parse_level ON sessions(parse_level);") try exec("CREATE INDEX IF NOT EXISTS idx_timeline_previews_session ON timeline_previews(session_id);") } @discardableResult private func exec(_ sql: String) throws -> Int32 { var errorMessagePointer: UnsafeMutablePointer? let code = sqlite3_exec(db, sql, nil, nil, &errorMessagePointer) if let errorMessagePointer { let message = String(cString: errorMessagePointer) sqlite3_free(errorMessagePointer) if code != SQLITE_OK { throw SessionIndexSQLiteStoreError.stepFailed(message) } } else if code != SQLITE_OK { throw SessionIndexSQLiteStoreError.stepFailed("Unknown SQLite error") } return code } // MARK: - Instructions & project key helpers static func normalizeInstructions(_ text: String?) -> String? { guard let text else { return nil } let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } static func instructionsPreview(from text: String?) -> String? { guard let text else { return nil } let collapsed = text.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }.joined(separator: " ") guard !collapsed.isEmpty else { return nil } if collapsed.count <= instructionsPreviewLimit { return collapsed } let idx = collapsed.index(collapsed.startIndex, offsetBy: instructionsPreviewLimit) return String(collapsed[.. String? { if let projectId, !projectId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return projectId.trimmingCharacters(in: .whitespacesAndNewlines) } guard let cwd else { return nil } return makeCwdHash(from: cwd) } static func candidateProjectKeys(projectId: String?, cwd: String?) -> [String] { var keys: [String] = [] if let projectId, !projectId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { keys.append(projectId.trimmingCharacters(in: .whitespacesAndNewlines)) } if let cwd, let cwdHash = makeCwdHash(from: cwd) { if !keys.contains(cwdHash) { keys.append(cwdHash) } } return keys } private static func makeCwdHash(from cwd: String) -> String? { let expanded = (cwd as NSString).expandingTildeInPath let canonical = URL(fileURLWithPath: expanded).standardizedFileURL.path guard let data = canonical.data(using: .utf8) else { return nil } let digest = SHA256.hash(data: data) return digest.map { String(format: "%02x", $0) }.joined() } private var errorMessage: String { if let cString = sqlite3_errmsg(db) { return String(cString: cString) } return "Unknown SQLite error" } // MARK: - Binding helpers private func bindText(_ stmt: OpaquePointer?, index: Int32, value: String?) { if let value { sqlite3_bind_text(stmt, index, value, -1, SQLITE_TRANSIENT) } else { sqlite3_bind_null(stmt, index) } } private func bindInt(_ stmt: OpaquePointer?, index: Int32, value: Int?) { if let value { sqlite3_bind_int(stmt, index, Int32(value)) } else { sqlite3_bind_null(stmt, index) } } private func bindInt64(_ stmt: OpaquePointer?, index: Int32, value: Int64?) { if let value { sqlite3_bind_int64(stmt, index, value) } else { sqlite3_bind_null(stmt, index) } } private func bindDouble(_ stmt: OpaquePointer?, index: Int32, value: TimeInterval?) { if let value { sqlite3_bind_double(stmt, index, value) } else { sqlite3_bind_null(stmt, index) } } private func bindDate(_ stmt: OpaquePointer?, index: Int32, value: Date?) { if let value { sqlite3_bind_double(stmt, index, value.timeIntervalSince1970) } else { sqlite3_bind_null(stmt, index) } } private func bindData(_ stmt: OpaquePointer?, index: Int32, data: Data) { _ = data.withUnsafeBytes { ptr in sqlite3_bind_blob(stmt, index, ptr.baseAddress, Int32(data.count), SQLITE_TRANSIENT) } } // MARK: - Column helpers private func columnText(_ stmt: OpaquePointer?, index: Int32) -> String? { guard let cString = sqlite3_column_text(stmt, index) else { return nil } return String(cString: cString) } private func columnData(_ stmt: OpaquePointer?, index: Int32) -> Data? { guard let bytes = sqlite3_column_blob(stmt, index) else { return nil } let length = Int(sqlite3_column_bytes(stmt, index)) return Data(bytes: bytes, count: length) } private func columnDate(_ stmt: OpaquePointer?, index: Int32) -> Date? { let value = sqlite3_column_double(stmt, index) if value == 0 { return nil } return Date(timeIntervalSince1970: value) } private func columnInt64(_ stmt: OpaquePointer?, index: Int32) -> Int64? { let value = sqlite3_column_int64(stmt, index) if sqlite3_column_type(stmt, index) == SQLITE_NULL { return nil } return value } private func tokenBreakdownFromColumns(_ stmt: OpaquePointer?, startIndex: Int32) -> SessionTokenBreakdown? { let input = columnInt64(stmt, index: startIndex).map(Int.init) let output = columnInt64(stmt, index: startIndex + 1).map(Int.init) let cacheRead = columnInt64(stmt, index: startIndex + 2).map(Int.init) let cacheCreation = columnInt64(stmt, index: startIndex + 3).map(Int.init) if input == nil && output == nil && cacheRead == nil && cacheCreation == nil { return nil } return SessionTokenBreakdown( input: input ?? 0, output: output ?? 0, cacheRead: cacheRead ?? 0, cacheCreation: cacheCreation ?? 0) } // MARK: - Source encoding helpers private func encode(source: SessionSource) -> (kind: String, host: String?) { switch source { case .codexLocal: return ("codexLocal", nil) case .claudeLocal: return ("claudeLocal", nil) case .geminiLocal: return ("geminiLocal", nil) case .codexRemote(let host): return ("codexRemote", host) case .claudeRemote(let host): return ("claudeRemote", host) case .geminiRemote(let host): return ("geminiRemote", host) } } private func decodeKind(_ value: String) -> SessionSource.Kind? { switch value { case "codexLocal", "codexRemote": return .codex case "claudeLocal", "claudeRemote": return .claude case "geminiLocal", "geminiRemote": return .gemini default: return nil } } private func distinctSources() throws -> [SessionSource.Kind] { let sql = "SELECT DISTINCT source FROM sessions" var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } defer { sqlite3_finalize(stmt) } var kinds: [SessionSource.Kind] = [] while sqlite3_step(stmt) == SQLITE_ROW { if let text = columnText(stmt, index: 0), let kind = decodeKind(text) { kinds.append(kind) } } return Array(Set(kinds)).sorted { $0.rawValue < $1.rawValue } } private func fetchTotals(scope: OverviewAggregateScope?) throws -> (sessions: Int, tokens: Int, duration: TimeInterval, userMessages: Int, assistantMessages: Int, toolInvocations: Int) { let predicate = predicate(for: scope) let whereClause = predicate.clause.isEmpty ? "" : "WHERE \(predicate.clause)" let sql = """ SELECT COUNT(*) AS c, SUM(COALESCE(tokens_total, 0)) AS tokens, SUM( COALESCE( active_duration, CASE WHEN ended_at IS NOT NULL THEN MAX(0, ended_at - started_at) WHEN last_updated_at IS NOT NULL THEN MAX(0, last_updated_at - started_at) ELSE 0 END ) ) AS duration, SUM(user_message_count) AS user_messages, SUM(assistant_message_count) AS assistant_messages, SUM(tool_invocation_count) AS tool_invocations FROM sessions \(whereClause) """ var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } defer { sqlite3_finalize(stmt) } _ = predicate.binder(stmt, 1) guard sqlite3_step(stmt) == SQLITE_ROW else { throw SessionIndexSQLiteStoreError.decodeFailed("Failed to read totals") } let sessions = Int(sqlite3_column_int(stmt, 0)) let tokens = Int(sqlite3_column_int64(stmt, 1)) let duration = sqlite3_column_double(stmt, 2) let userMessages = Int(sqlite3_column_int64(stmt, 3)) let assistantMessages = Int(sqlite3_column_int64(stmt, 4)) let toolInvocations = Int(sqlite3_column_int64(stmt, 5)) return (sessions, tokens, duration, userMessages, assistantMessages, toolInvocations) } private func fetchSourceAggregates(scope: OverviewAggregateScope?) throws -> [OverviewSourceAggregate] { let predicate = predicate(for: scope) let whereClause = predicate.clause.isEmpty ? "" : "WHERE \(predicate.clause)" let sql = """ SELECT source, COUNT(*) AS c, SUM(COALESCE(tokens_total, 0)) AS tokens, SUM( COALESCE( active_duration, CASE WHEN ended_at IS NOT NULL THEN MAX(0, ended_at - started_at) WHEN last_updated_at IS NOT NULL THEN MAX(0, last_updated_at - started_at) ELSE 0 END ) ) AS duration, SUM(user_message_count) AS user_messages, SUM(assistant_message_count) AS assistant_messages, SUM(tool_invocation_count) AS tool_invocations FROM sessions \(whereClause) GROUP BY source """ var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } defer { sqlite3_finalize(stmt) } _ = predicate.binder(stmt, 1) var results: [OverviewSourceAggregate] = [] while sqlite3_step(stmt) == SQLITE_ROW { guard let kindText = columnText(stmt, index: 0), let kind = decodeKind(kindText) else { continue } let count = Int(sqlite3_column_int64(stmt, 1)) let tokens = Int(sqlite3_column_int64(stmt, 2)) let duration = sqlite3_column_double(stmt, 3) let userMessages = Int(sqlite3_column_int64(stmt, 4)) let assistantMessages = Int(sqlite3_column_int64(stmt, 5)) let toolInvocations = Int(sqlite3_column_int64(stmt, 6)) results.append( OverviewSourceAggregate( kind: kind, sessionCount: count, totalTokens: tokens, totalDuration: duration, userMessages: userMessages, assistantMessages: assistantMessages, toolInvocations: toolInvocations ) ) } return results } private func fetchDailyAggregates(scope: OverviewAggregateScope?) throws -> [OverviewDailyPoint] { let predicate = predicate(for: scope) let dateColumn = scope?.dateDimension == .updated ? "COALESCE(last_updated_at, started_at)" : "started_at" let whereClause = predicate.clause.isEmpty ? "" : "WHERE \(predicate.clause)" let sql = """ SELECT strftime('%Y-%m-%d', \(dateColumn), 'unixepoch', 'localtime') AS day, source, COUNT(*) AS c, SUM(COALESCE(tokens_total, 0)) AS tokens, SUM( COALESCE( active_duration, CASE WHEN ended_at IS NOT NULL THEN MAX(0, ended_at - started_at) WHEN last_updated_at IS NOT NULL THEN MAX(0, last_updated_at - started_at) ELSE 0 END ) ) AS duration FROM sessions \(whereClause) GROUP BY day, source ORDER BY day ASC """ var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } defer { sqlite3_finalize(stmt) } _ = predicate.binder(stmt, 1) var results: [OverviewDailyPoint] = [] let df = DateFormatter() df.dateFormat = "yyyy-MM-dd" df.locale = Locale(identifier: "en_US_POSIX") while sqlite3_step(stmt) == SQLITE_ROW { guard let dayText = columnText(stmt, index: 0), let dayDate = df.date(from: dayText), let kindText = columnText(stmt, index: 1), let kind = decodeKind(kindText) else { continue } let count = Int(sqlite3_column_int64(stmt, 2)) let tokens = Int(sqlite3_column_int64(stmt, 3)) let duration = sqlite3_column_double(stmt, 4) results.append( OverviewDailyPoint( day: dayDate, kind: kind, sessionCount: count, totalTokens: tokens, totalDuration: duration ) ) } return results } private func countSessions() throws -> Int { let sql = "SELECT COUNT(*) FROM sessions" var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } defer { sqlite3_finalize(stmt) } guard sqlite3_step(stmt) == SQLITE_ROW else { throw SessionIndexSQLiteStoreError.decodeFailed("Failed to read session count") } return Int(sqlite3_column_int64(stmt, 0)) } private func parseLevelRank(_ level: String?) -> Int { switch level { case "enriched": return 3 case "full": return 2 case "metadata": return 1 default: return 0 } } } // MARK: - Cached summaries by source extension SessionIndexSQLiteStore { /// Fetch cached summaries for given source kinds without touching the filesystem. func fetchSummaries( kinds: [SessionSource.Kind], includeRemote: Bool, dateColumn: String?, dateRange: (Date, Date)?, projectIds: Set? ) throws -> [SessionSummary] { try openIfNeeded() let sources = sourceStrings(for: kinds, includeRemote: includeRemote) guard !sources.isEmpty else { return [] } let placeholders = sources.map { _ in "?" }.joined(separator: ",") var whereParts: [String] = ["source IN (\(placeholders))"] if let dateColumn, dateRange != nil { whereParts.append("\(dateColumn) >= ?") whereParts.append("\(dateColumn) <= ?") } if let projectIds, !projectIds.isEmpty { let projectPlaceholders = projectIds.map { _ in "?" }.joined(separator: ",") whereParts.append("project IN (\(projectPlaceholders))") } let whereClause = whereParts.joined(separator: " AND ") let sql = "SELECT payload FROM sessions WHERE \(whereClause)" var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } defer { sqlite3_finalize(stmt) } var idx: Int32 = 1 for source in sources { sqlite3_bind_text(stmt, idx, source, -1, SQLITE_TRANSIENT) idx += 1 } if let dateRange { sqlite3_bind_double(stmt, idx, dateRange.0.timeIntervalSince1970) idx += 1 sqlite3_bind_double(stmt, idx, dateRange.1.timeIntervalSince1970) idx += 1 } if let projectIds { for pid in projectIds { sqlite3_bind_text(stmt, idx, pid, -1, SQLITE_TRANSIENT) idx += 1 } } var result: [SessionSummary] = [] let decoder = JSONDecoder() while sqlite3_step(stmt) == SQLITE_ROW { guard let payload = columnData(stmt, index: 0) else { continue } if let summary = try? decoder.decode(SessionSummary.self, from: payload) { result.append(summary) } } let withLevels = result let kindLabel = kinds.map { "\($0)" }.joined(separator: ",") logger.info("fetchSummaries cache kind=\(kindLabel, privacy: .public) count=\(withLevels.count, privacy: .public) includeRemote=\(includeRemote, privacy: .public)") return withLevels } /// Fetch cached records (payload + metadata) for given source kinds to build scoped caches. func fetchRecords( kinds: [SessionSource.Kind], includeRemote: Bool, dateColumn: String?, dateRange: (Date, Date)?, projectIds: Set? ) throws -> [SessionIndexRecord] { try openIfNeeded() let sources = sourceStrings(for: kinds, includeRemote: includeRemote) guard !sources.isEmpty else { return [] } let placeholders = sources.map { _ in "?" }.joined(separator: ",") var whereParts: [String] = ["source IN (\(placeholders))"] if let dateColumn, dateRange != nil { whereParts.append("\(dateColumn) >= ?") whereParts.append("\(dateColumn) <= ?") } if let projectIds, !projectIds.isEmpty { let projectPlaceholders = projectIds.map { _ in "?" }.joined(separator: ",") whereParts.append("project IN (\(projectPlaceholders))") } let whereClause = whereParts.joined(separator: " AND ") let sql = """ SELECT payload, file_path, file_mtime, file_size, project, schema_version, parse_error, tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, parse_level, parsed_at FROM sessions WHERE \(whereClause) """ var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } defer { sqlite3_finalize(stmt) } var idx: Int32 = 1 for source in sources { sqlite3_bind_text(stmt, idx, source, -1, SQLITE_TRANSIENT) idx += 1 } if let dateRange { sqlite3_bind_double(stmt, idx, dateRange.0.timeIntervalSince1970) idx += 1 sqlite3_bind_double(stmt, idx, dateRange.1.timeIntervalSince1970) idx += 1 } if let projectIds { for pid in projectIds { sqlite3_bind_text(stmt, idx, pid, -1, SQLITE_TRANSIENT) idx += 1 } } var records: [SessionIndexRecord] = [] let decoder = JSONDecoder() while sqlite3_step(stmt) == SQLITE_ROW { guard let payload = columnData(stmt, index: 0) else { continue } guard var summary = try? decoder.decode(SessionSummary.self, from: payload) else { continue } let filePath = columnText(stmt, index: 1) ?? summary.fileURL.path let fileMtime = columnDate(stmt, index: 2) let fileSize = columnInt64(stmt, index: 3).flatMap { UInt64($0) } let project = columnText(stmt, index: 4) let schemaVersion = Int(sqlite3_column_int(stmt, 5)) let parseError = columnText(stmt, index: 6) let tokenBreakdown = tokenBreakdownFromColumns(stmt, startIndex: 7) summary = summary.withTokenBreakdownFallback(tokenBreakdown) let parseLevel = columnText(stmt, index: 11) let parsedAt = columnDate(stmt, index: 12) records.append( SessionIndexRecord( summary: summary, filePath: filePath, fileModificationTime: fileMtime, fileSize: fileSize, project: project, schemaVersion: schemaVersion, parseError: parseError, tokenBreakdown: tokenBreakdown, parseLevel: parseLevel, parsedAt: parsedAt ) ) } return records } /// Fetch file paths for a specific date without loading full payloads (optimized for single-day queries). /// Returns [(filePath, lastUpdatedAt, fileModificationTime, fileSize)]. func fetchFilePathsForDate( kinds: [SessionSource.Kind], includeRemote: Bool, dateColumn: String, targetDate: Date ) throws -> [(filePath: String, lastUpdatedAt: Date?, fileMtime: Date?, fileSize: UInt64?)] { try openIfNeeded() let sources = sourceStrings(for: kinds, includeRemote: includeRemote) guard !sources.isEmpty else { return [] } let placeholders = sources.map { _ in "?" }.joined(separator: ",") // Use SQLite date() function to filter by calendar day in UTC let sql = """ SELECT file_path, last_updated_at, file_mtime, file_size FROM sessions WHERE source IN (\(placeholders)) AND date(\(dateColumn), 'unixepoch') = date(?1, 'unixepoch') """ var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } defer { sqlite3_finalize(stmt) } var idx: Int32 = 1 for source in sources { sqlite3_bind_text(stmt, idx, source, -1, SQLITE_TRANSIENT) idx += 1 } sqlite3_bind_double(stmt, idx, targetDate.timeIntervalSince1970) var result: [(String, Date?, Date?, UInt64?)] = [] while sqlite3_step(stmt) == SQLITE_ROW { let filePath = columnText(stmt, index: 0) ?? "" let lastUpdated = columnDate(stmt, index: 1) let fileMtime = columnDate(stmt, index: 2) let fileSize = columnInt64(stmt, index: 3).map { UInt64($0) } result.append((filePath, lastUpdated, fileMtime, fileSize)) } return result } /// Fetch cached records for a specific set of session IDs. func fetchRecords(sessionIds: Set) throws -> [SessionIndexRecord] { try openIfNeeded() guard !sessionIds.isEmpty else { return [] } let placeholders = sessionIds.map { _ in "?" }.joined(separator: ",") let sql = """ SELECT payload, file_path, file_mtime, file_size, project, schema_version, parse_error, tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation FROM sessions WHERE session_id IN (\(placeholders)) """ var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } defer { sqlite3_finalize(stmt) } var idx: Int32 = 1 for id in sessionIds { sqlite3_bind_text(stmt, idx, id, -1, SQLITE_TRANSIENT) idx += 1 } var records: [SessionIndexRecord] = [] let decoder = JSONDecoder() while sqlite3_step(stmt) == SQLITE_ROW { guard let payload = columnData(stmt, index: 0) else { continue } guard var summary = try? decoder.decode(SessionSummary.self, from: payload) else { continue } let filePath = columnText(stmt, index: 1) ?? summary.fileURL.path let fileMtime = columnDate(stmt, index: 2) let fileSize = columnInt64(stmt, index: 3).flatMap { UInt64($0) } let project = columnText(stmt, index: 4) let schemaVersion = Int(sqlite3_column_int(stmt, 5)) let parseError = columnText(stmt, index: 6) let tokenBreakdown = tokenBreakdownFromColumns(stmt, startIndex: 7) summary = summary.withTokenBreakdownFallback(tokenBreakdown) let parseLevel = columnText(stmt, index: 11) let parsedAt = columnDate(stmt, index: 12) records.append( SessionIndexRecord( summary: summary, filePath: filePath, fileModificationTime: fileMtime, fileSize: fileSize, project: project, schemaVersion: schemaVersion, parseError: parseError, tokenBreakdown: tokenBreakdown, parseLevel: parseLevel, parsedAt: parsedAt ) ) } return records } private func sourceStrings(for kinds: [SessionSource.Kind], includeRemote: Bool) -> [String] { var sources: [String] = [] for kind in kinds { switch kind { case .codex: sources.append("codexLocal") if includeRemote { sources.append("codexRemote") } case .claude: sources.append("claudeLocal") if includeRemote { sources.append("claudeRemote") } case .gemini: sources.append("geminiLocal") if includeRemote { sources.append("geminiRemote") } } } return sources } // MARK: - Timeline Previews /// Fetch timeline previews for a session. Returns nil if cache is invalid (mtime mismatch). func fetchTimelinePreviews( sessionId: String, fileModificationTime: Date?, fileSize: UInt64? ) throws -> [ConversationTurnPreview]? { try openIfNeeded() // First check if we have any previews for this session let countSQL = "SELECT COUNT(*), MIN(file_mtime) FROM timeline_previews WHERE session_id = ?1" var countStmt: OpaquePointer? guard sqlite3_prepare_v2(db, countSQL, -1, &countStmt, nil) == SQLITE_OK else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } defer { sqlite3_finalize(countStmt) } sqlite3_bind_text(countStmt, 1, sessionId, -1, SQLITE_TRANSIENT) guard sqlite3_step(countStmt) == SQLITE_ROW else { return nil } let count = Int(sqlite3_column_int(countStmt, 0)) if count == 0 { return nil // No previews cached } // Check mtime validity if let fileModificationTime { let cachedMtime = sqlite3_column_double(countStmt, 1) let mtimeInterval = fileModificationTime.timeIntervalSince1970 if abs(cachedMtime - mtimeInterval) > 1.0 { // Cache is stale, return nil to trigger re-caching return nil } } // Fetch all previews for this session let fetchSQL = """ SELECT turn_id, turn_index, timestamp, user_preview, outputs_preview, output_count, has_tool_calls, has_thinking FROM timeline_previews WHERE session_id = ?1 ORDER BY turn_index ASC """ var fetchStmt: OpaquePointer? guard sqlite3_prepare_v2(db, fetchSQL, -1, &fetchStmt, nil) == SQLITE_OK else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } defer { sqlite3_finalize(fetchStmt) } sqlite3_bind_text(fetchStmt, 1, sessionId, -1, SQLITE_TRANSIENT) var previews: [ConversationTurnPreview] = [] while sqlite3_step(fetchStmt) == SQLITE_ROW { guard let turnId = columnText(fetchStmt, index: 0) else { continue } let turnIndex = Int(sqlite3_column_int(fetchStmt, 1)) let timestamp = Date(timeIntervalSince1970: sqlite3_column_double(fetchStmt, 2)) let userPreview = columnText(fetchStmt, index: 3) let outputsPreview = columnText(fetchStmt, index: 4) let outputCount = Int(sqlite3_column_int(fetchStmt, 5)) let hasToolCalls = sqlite3_column_int(fetchStmt, 6) != 0 let hasThinking = sqlite3_column_int(fetchStmt, 7) != 0 let preview = ConversationTurnPreview( id: turnId, sessionId: sessionId, turnIndex: turnIndex, timestamp: timestamp, userPreview: userPreview, outputsPreview: outputsPreview, outputCount: outputCount, hasToolCalls: hasToolCalls, hasThinking: hasThinking ) previews.append(preview) } return previews } /// Upsert timeline previews for a session. Replaces all existing previews for the session. func upsertTimelinePreviews( _ previews: [ConversationTurnPreview], sessionId: String, fileModificationTime: Date, fileSize: UInt64? ) throws { try openIfNeeded() // Delete existing previews for this session let deleteSQL = "DELETE FROM timeline_previews WHERE session_id = ?1" var deleteStmt: OpaquePointer? guard sqlite3_prepare_v2(db, deleteSQL, -1, &deleteStmt, nil) == SQLITE_OK else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } defer { sqlite3_finalize(deleteStmt) } sqlite3_bind_text(deleteStmt, 1, sessionId, -1, SQLITE_TRANSIENT) guard sqlite3_step(deleteStmt) == SQLITE_DONE else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } // Insert new previews let insertSQL = """ INSERT INTO timeline_previews ( session_id, turn_id, turn_index, timestamp, user_preview, outputs_preview, output_count, has_tool_calls, has_thinking, file_mtime, file_size ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) """ var insertStmt: OpaquePointer? guard sqlite3_prepare_v2(db, insertSQL, -1, &insertStmt, nil) == SQLITE_OK else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } defer { sqlite3_finalize(insertStmt) } for preview in previews { sqlite3_bind_text(insertStmt, 1, sessionId, -1, SQLITE_TRANSIENT) sqlite3_bind_text(insertStmt, 2, preview.id, -1, SQLITE_TRANSIENT) bindInt(insertStmt, index: 3, value: preview.turnIndex) bindDate(insertStmt, index: 4, value: preview.timestamp) bindText(insertStmt, index: 5, value: preview.userPreview) bindText(insertStmt, index: 6, value: preview.outputsPreview) bindInt(insertStmt, index: 7, value: preview.outputCount) sqlite3_bind_int(insertStmt, 8, preview.hasToolCalls ? 1 : 0) sqlite3_bind_int(insertStmt, 9, preview.hasThinking ? 1 : 0) bindDate(insertStmt, index: 10, value: fileModificationTime) bindInt64(insertStmt, index: 11, value: fileSize.map { Int64($0) }) guard sqlite3_step(insertStmt) == SQLITE_DONE else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } sqlite3_reset(insertStmt) } } /// Delete timeline previews for a session (e.g., when file is deleted or modified) func deleteTimelinePreviews(sessionId: String) throws { try openIfNeeded() let sql = "DELETE FROM timeline_previews WHERE session_id = ?1" var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } defer { sqlite3_finalize(stmt) } sqlite3_bind_text(stmt, 1, sessionId, -1, SQLITE_TRANSIENT) guard sqlite3_step(stmt) == SQLITE_DONE else { throw SessionIndexSQLiteStoreError.stepFailed(errorMessage) } } } ================================================ FILE: services/SessionIndexer.swift ================================================ import Foundation import OSLog actor SessionIndexer { private let fileManager: FileManager private let decoder: JSONDecoder private let cache = NSCache() private let sqliteStore: SessionIndexSQLiteStore private let logger = Logger(subsystem: "io.umate.codmate", category: "SessionIndexer") /// Prevent concurrent refresh loops (scope-level gate) private var isRefreshing = false /// Tracks files whose token total is confirmed zero for a given mtime to avoid repeated rescans. private var zeroTokenStable: [String: TimeInterval?] = [:] /// Avoid global mutable, non-Sendable formatter; create locally when needed nonisolated private static func makeTailTimestampFormatter() -> ISO8601DateFormatter { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return f } private final class CacheEntry { let modificationDate: Date? let summary: SessionSummary init(modificationDate: Date?, summary: SessionSummary) { self.modificationDate = modificationDate self.summary = summary } } init( fileManager: FileManager = .default, sqliteStore: SessionIndexSQLiteStore = SessionIndexSQLiteStore() ) { self.fileManager = fileManager self.sqliteStore = sqliteStore decoder = FlexibleDecoders.iso8601Flexible() } private func ensureCacheAvailable() async throws -> SessionIndexMeta { return try await sqliteStore.fetchMeta() } func refreshSessions( root: URL, scope: SessionLoadScope, dateRange: (Date, Date)? = nil, projectIds: Set? = nil, projectDirectories: [String]? = nil, dateDimension: DateDimension = .updated, forceFilesystemScan: Bool = false, ignoredPaths: [String] = [] ) async throws -> [SessionSummary] { let meta = try await ensureCacheAvailable() let preferFullInitialParse = meta.sessionCount == 0 // First, try cached meta fast path so repeated .all refreshes don't re-enumerate if !forceFilesystemScan, case .all = scope, let cached = try await cachedAllSummariesFromMeta(ignoredPaths: ignoredPaths) { return cached } guard !isRefreshing else { logger.debug("Refresh skipped: already in progress for scope=\(String(describing: scope), privacy: .public)") guard !forceFilesystemScan else { return [] } // When a refresh is already running, still try to surface cached data for scope if case .all = scope, let cached = try await cachedAllSummariesFromMeta(ignoredPaths: ignoredPaths) { return cached } let fallbackRange: (Date, Date)? = { if let dateRange { return dateRange } let cal = Calendar.current switch scope { case .all: return nil case .today: let start = cal.startOfDay(for: Date()) guard let end = cal.date(byAdding: .day, value: 1, to: start)?.addingTimeInterval(-1) else { return nil } return (start, end) case .day(let day): let start = cal.startOfDay(for: day) guard let end = cal.date(byAdding: .day, value: 1, to: start)?.addingTimeInterval(-1) else { return nil } return (start, end) case .month(let date): guard let start = cal.date(from: cal.dateComponents([.year, .month], from: date)), let end = cal.date(byAdding: DateComponents(month: 1, second: -1), to: start) else { return nil } return (start, end) } }() let dateColumn = dateDimension == .updated ? "COALESCE(last_updated_at, started_at)" : "started_at" if var cached = try? await sqliteStore.fetchSummaries( kinds: [.codex], includeRemote: false, dateColumn: dateColumn, dateRange: fallbackRange, projectIds: projectIds ), !cached.isEmpty { // Apply ignore rules to cached summaries if !ignoredPaths.isEmpty { cached = cached.filter { !shouldIgnoreSummary($0, ignoredPaths: ignoredPaths) } } return cached } return [] } isRefreshing = true defer { isRefreshing = false } // Layer 0.5: Single-day fast path using SQLite date query (optimized for Updated dimension) // Directly query files by date without loading all cached records into memory let dateColumn = dateDimension == .updated ? "COALESCE(last_updated_at, started_at)" : "started_at" var cachedRecords: [String: SessionIndexRecord] = [:] if dateDimension == .updated, case .day(let targetDate) = scope { // Use optimized single-day query to get candidate file paths directly from SQLite if let dateCandidates = try? await sqliteStore.fetchFilePathsForDate( kinds: [.codex], includeRemote: false, dateColumn: dateColumn, targetDate: targetDate ), !dateCandidates.isEmpty { logger.info("Single-day fast path: found \(dateCandidates.count, privacy: .public) candidates for date") // Build minimal cachedRecords from file paths for change detection for _ in dateCandidates { // Fetch full record only if file still exists and needs processing if let fullRecords = try? await sqliteStore.fetchRecords( kinds: [.codex], includeRemote: false, dateColumn: dateColumn, dateRange: (targetDate.addingTimeInterval(-86400), targetDate.addingTimeInterval(86400)), projectIds: projectIds ) { cachedRecords = Dictionary(uniqueKeysWithValues: fullRecords.map { ($0.filePath, $0) }) break } } } } // Layer 1: scoped cached records to skip SQLite fetch per file (fallback or non-single-day queries) if cachedRecords.isEmpty { let initialRecords = try? await sqliteStore.fetchRecords( kinds: [.codex], includeRemote: false, dateColumn: dateRange != nil ? dateColumn : nil, dateRange: dateRange, projectIds: projectIds ) if let initialRecords, !initialRecords.isEmpty { cachedRecords = Dictionary(uniqueKeysWithValues: initialRecords.map { ($0.filePath, $0) }) } else if cachedRecords.isEmpty { // Layer 1 baseline: fallback to all cached records for change detection to avoid reparsing unchanged files. if let allRecords = try? await sqliteStore.fetchRecords( kinds: [.codex], includeRemote: false, dateColumn: nil, dateRange: nil, projectIds: nil ) { cachedRecords = Dictionary(uniqueKeysWithValues: allRecords.map { ($0.filePath, $0) }) } } } let scopedDirectories = scopedDirectoriesForRefresh( root: root, scope: scope, dateRange: dateRange, dateDimension: dateDimension, cachedRecords: cachedRecords, projectIds: projectIds, projectDirectories: projectDirectories ) let sessionFiles = try sessionFileURLs( at: root, scope: scope, dateRange: dateRange, dateDimension: dateDimension, directories: scopedDirectories, cachedRecords: cachedRecords, ignoredPaths: ignoredPaths ) logger.info( "Refreshing sessions under \(root.path, privacy: .public) scope=\(String(describing: scope), privacy: .public) count=\(sessionFiles.count)" ) guard !sessionFiles.isEmpty else { return [] } // Fast path: if all files have up-to-date summaries in cache/SQLite, return immediately var summaries: [SessionSummary] = [] summaries.reserveCapacity(sessionFiles.count) // URL, modificationDate, fileSize, previousParseLevel var pending: [(url: URL, modificationDate: Date?, fileSize: Int?, previousParseLevel: String?)] = [] // Layer 1.5: if all files are covered by cachedRecords with matching mtime/size, short-circuit. if !cachedRecords.isEmpty { var allCovered = true for url in sessionFiles { let values = try url.resourceValues( forKeys: Set([.contentModificationDateKey, .fileSizeKey, .isRegularFileKey]) ) guard values.isRegularFile == true else { continue } let mdate = values.contentModificationDate let fsize = values.fileSize guard let record = cachedRecords[url.path], let m = record.fileModificationTime, mdate == m, (record.fileSize == nil || fsize == nil || record.fileSize == fsize.map { UInt64($0) }) else { allCovered = false break } } if allCovered { let fastSummaries = Array(cachedRecords.values.map { $0.summary.withParseLevel(fromString: $0.parseLevel) }) logger.info("SessionIndexer fast path: all files covered by scoped cache count=\(fastSummaries.count, privacy: .public)") if case .all = scope { try? await sqliteStore.setMeta(lastFullIndexAt: Date(), sessionCount: sessionFiles.count) } return fastSummaries } } for url in sessionFiles { let values = try url.resourceValues( forKeys: Set([.contentModificationDateKey, .fileSizeKey, .isRegularFileKey]) ) guard values.isRegularFile == true else { continue } let mdate = values.contentModificationDate let fsize = values.fileSize if let record = cachedRecords[url.path] { if let m = record.fileModificationTime, mdate == m, (record.fileSize == nil || fsize == nil || record.fileSize == fsize.map { UInt64($0) }) { let summary = record.summary.withParseLevel(fromString: record.parseLevel) // Check ignore rules against cwd // Note: Cache is preserved - we filter out ignored sessions but don't delete cache entries. // This allows sessions to reappear if ignore rules are removed later. if shouldIgnoreSummary(summary, ignoredPaths: ignoredPaths) { continue } store(summary: summary, for: url as NSURL, modificationDate: mdate) summaries.append(summary) continue } // File changed, but remember previous parse level pending.append((url, mdate, fsize, record.parseLevel)) continue } if let cached = cachedSummary(for: url as NSURL, modificationDate: mdate) { // Check ignore rules against cwd // Note: Cache is preserved - we filter out ignored sessions but don't delete cache entries. // This allows sessions to reappear if ignore rules are removed later. if shouldIgnoreSummary(cached, ignoredPaths: ignoredPaths) { continue } if shouldRecomputeTokens(for: url.path, modificationDate: mdate, summary: cached) { pending.append((url, mdate, fsize, cached.parseLevel?.rawValue)) } else { summaries.append(cached) } continue } if cachedRecords.isEmpty { // Try to get previous parse level from DB even if file changed or not in memory cache let diskRecord = try? await sqliteStore.fetch(sessionId: url.deletingPathExtension().lastPathComponent) let prevLevel = diskRecord?.parseLevel if let disk = try? await sqliteStore.fetch( path: url.path, modificationDate: mdate, fileSize: fsize.flatMap { UInt64($0) }) { // Check ignore rules against cwd // Note: Cache is preserved - we filter out ignored sessions but don't delete cache entries. // This allows sessions to reappear if ignore rules are removed later. if shouldIgnoreSummary(disk, ignoredPaths: ignoredPaths) { continue } if shouldRecomputeTokens(for: url.path, modificationDate: mdate, summary: disk) { pending.append((url, mdate, fsize, prevLevel ?? disk.parseLevel?.rawValue)) } else { store(summary: disk, for: url as NSURL, modificationDate: mdate) summaries.append(disk) } continue } // Not in cache (or changed), but might have previous level pending.append((url, mdate, fsize, prevLevel)) continue } pending.append((url, mdate, fsize, nil)) } // If everything hit cache, short-circuit if pending.isEmpty { if case .all = scope { do { try await sqliteStore.setMeta(lastFullIndexAt: Date(), sessionCount: sessionFiles.count) logger.info("SessionIndexer meta updated from cache-only path count=\(sessionFiles.count, privacy: .public)") } catch { logger.error("Failed to set meta: \(error.localizedDescription, privacy: .public)") } } return summaries } logger.info( "Change detection: pending=\(pending.count, privacy: .public) cacheHits=\(summaries.count, privacy: .public) total=\(sessionFiles.count, privacy: .public)" ) let cpuCount = ProcessInfo.processInfo.processorCount let workerCount = max(2, cpuCount / 2) var firstError: Error? summaries.reserveCapacity(sessionFiles.count) await withTaskGroup(of: Result.self) { group in var iterator = pending.makeIterator() func addNextTasks(_ n: Int) { for _ in 0.. OverviewAggregate? { return try? await sqliteStore.fetchOverviewAggregate() } /// Fetch scoped aggregated overview metrics (project/date). func fetchOverviewAggregate(scope: OverviewAggregateScope?) async -> OverviewAggregate? { return try? await sqliteStore.fetchOverviewAggregate(scope: scope) } func cachedInstructions(forKeys keys: [String]) async -> String? { guard !keys.isEmpty else { return nil } return try? await sqliteStore.fetchProjectInstructions(keys: keys) } /// Current cache coverage (sources present + meta). func currentCoverage() async -> SessionIndexCoverage? { return try? await sqliteStore.fetchCoverage() } /// Cache externally provided session summaries (e.g., Claude/Gemini providers) into SQLite. func cacheExternalSummaries(_ summaries: [SessionSummary]) async { guard !summaries.isEmpty else { return } for summary in summaries { let (cachedSummary, normalizedFullInstructions) = prepareSummaryForCache(summary) do { try await sqliteStore.upsert( summary: cachedSummary, project: nil, fileModificationTime: nil, fileSize: cachedSummary.fileSizeBytes, tokenBreakdown: cachedSummary.tokenBreakdown, fullInstructions: normalizedFullInstructions, parseError: nil ) } catch { logger.error("Failed to cache external summary \(cachedSummary.id, privacy: .public): \(error.localizedDescription, privacy: .public)") } } logger.info("Cached external summaries count=\(summaries.count, privacy: .public)") } // MARK: - Private nonisolated private func prepareSummaryForCache(_ summary: SessionSummary) -> (SessionSummary, String?) { let normalizedFull = SessionIndexSQLiteStore.normalizeInstructions(summary.instructions) let preview = SessionIndexSQLiteStore.instructionsPreview(from: normalizedFull) let cached = summary.withInstructionPreview(preview) return (cached, normalizedFull) } private func cachedSummary(for key: NSURL, modificationDate: Date?) -> SessionSummary? { guard let entry = cache.object(forKey: key) else { return nil } if entry.modificationDate == modificationDate { return entry.summary } return nil } private func shouldRecomputeTokens( for path: String, modificationDate: Date?, summary: SessionSummary ) -> Bool { if summary.actualTotalTokens > 0 { return false } switch summary.source.baseKind { case .codex, .gemini: let key = path let mt = modificationDate?.timeIntervalSince1970 if let stable = zeroTokenStable[key], stable == mt { return false } return true case .claude: return false } } private func updateZeroTokenStable(path: String, modificationDate: Date?, tokens: Int) { if tokens > 0 { zeroTokenStable.removeValue(forKey: path) } else { zeroTokenStable[path] = modificationDate?.timeIntervalSince1970 } } private func store(summary: SessionSummary, for key: NSURL, modificationDate: Date?) { let entry = CacheEntry(modificationDate: modificationDate, summary: summary) cache.setObject(entry, forKey: key) } nonisolated private func lastUpdatedTimestamp(for url: URL, modificationDate: Date?) -> Date? { // Updated timestamp is derived from JSONL content only; ignore file // modification times to avoid treating non-session edits as activity. return readTailTimestamp(url: url) } /// Cached fast path: return all summaries from SQLite meta without touching the filesystem. private func cachedAllSummariesFromMeta(ignoredPaths: [String] = []) async throws -> [SessionSummary]? { let meta = try await sqliteStore.fetchMeta() guard meta.sessionCount > 0 else { return nil } let records = try await sqliteStore.fetchAll() if records.isEmpty { return nil } logger.info("SessionIndexer meta hit: sessions=\(records.count, privacy: .public)") let summaries = records.map { $0.summary.withParseLevel(fromString: $0.parseLevel) } // Apply ignore rules to cached summaries if !ignoredPaths.isEmpty { return summaries.filter { !shouldIgnoreSummary($0, ignoredPaths: ignoredPaths) } } return summaries } /// Fast tail scan to retrieve latest token_count for Codex/Gemini sessions. private func sessionFileURLs( at root: URL, scope: SessionLoadScope, dateRange: (Date, Date)?, dateDimension: DateDimension, directories: [URL]? = nil, cachedRecords: [String: SessionIndexRecord]? = nil, ignoredPaths: [String] = [] ) throws -> [URL] { var urls: [URL] = [] let targets: [URL] if let directories, !directories.isEmpty { targets = directories } else if let base = scopeBaseURL(root: root, scope: scope) { targets = [base] } else { logger.warning( "No enumerator URL for scope=\(String(describing: scope), privacy: .public) root=\(root.path, privacy: .public)" ) return [] } // Updated single-day fast path: use cached records for cross-day directories, but still // enumerate the created-day directory to discover brand-new sessions not in SQLite yet. if let cachedRecords, let dateRange, dateDimension == .updated, Calendar.current.isDate(dateRange.0, inSameDayAs: dateRange.1) { let start = dateRange.0 let end = dateRange.1 var candidates: [URL] = [] var seenPaths: Set = [] for record in cachedRecords.values { let updated = record.summary.lastUpdatedAt ?? record.summary.endedAt ?? record.summary.startedAt if updated < start || updated > end { continue } let url = URL(fileURLWithPath: record.filePath) if fileManager.fileExists(atPath: url.path) { seenPaths.insert(url.path) candidates.append(url) } } if let dayDir = dayDirectory(root: root, date: start), let enumerator = fileManager.enumerator( at: dayDir, includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey] as [URLResourceKey], options: [.skipsHiddenFiles, .skipsPackageDescendants] ) { while let obj = enumerator.nextObject() { guard let fileURL = obj as? URL else { continue } guard fileURL.pathExtension.lowercased() == "jsonl" else { continue } // Apply ignore rules - check both file path and cwd if shouldIgnorePath(fileURL.path, ignoredPaths: ignoredPaths) { continue } // Quick check cwd if ignore rules are present if !ignoredPaths.isEmpty { if let cwd = fastExtractCWD(url: fileURL), shouldIgnorePath(cwd, ignoredPaths: ignoredPaths) { continue } } let values = try? fileURL.resourceValues( forKeys: Set([.isRegularFileKey, .contentModificationDateKey])) guard values?.isRegularFile == true else { continue } if let mdate = values?.contentModificationDate, (mdate < start || mdate > end) { continue } if seenPaths.insert(fileURL.path).inserted { candidates.append(fileURL) } } } if !candidates.isEmpty { logger.info("Updated-day fast path: returning \(candidates.count, privacy: .public) files (cache + today dir)") return candidates } } var seen = Set() for enumeratorURL in targets { guard let enumerator = fileManager.enumerator( at: enumeratorURL, includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey] as [URLResourceKey], options: [.skipsHiddenFiles, .skipsPackageDescendants] ) else { logger.warning("Enumerator could not open \(enumeratorURL.path, privacy: .public)") continue } while let obj = enumerator.nextObject() { guard let fileURL = obj as? URL else { continue } if fileURL.pathExtension.lowercased() == "jsonl" { // Apply ignore rules - check both file path and cwd if shouldIgnorePath(fileURL.path, ignoredPaths: ignoredPaths) { continue } // Quick check cwd if ignore rules are present if !ignoredPaths.isEmpty { if let cwd = fastExtractCWD(url: fileURL), shouldIgnorePath(cwd, ignoredPaths: ignoredPaths) { continue } } if let dateRange, dateDimension == .updated { let values = try? fileURL.resourceValues(forKeys: Set([.contentModificationDateKey])) if let mdate = values?.contentModificationDate { if mdate < dateRange.0 || mdate > dateRange.1 { continue } } } if seen.insert(fileURL).inserted { urls.append(fileURL) } } } } logger.info("Enumerated \(urls.count) files under scoped targets count=\(targets.count, privacy: .public)") return urls } /// Build a narrowed set of directories for enumeration when a project or date range is active. private func scopedDirectoriesForRefresh( root: URL, scope: SessionLoadScope, dateRange: (Date, Date)? = nil, dateDimension: DateDimension, cachedRecords: [String: SessionIndexRecord], projectIds: Set?, projectDirectories: [String]? ) -> [URL]? { var directories: Set = [] // Use cached records to re-scan only directories that previously contained matching sessions if let projectIds, !projectIds.isEmpty { for record in cachedRecords.values { if let project = record.project, projectIds.contains(project) { let url = URL(fileURLWithPath: record.filePath) .deletingLastPathComponent() directories.insert(url) } } } if !cachedRecords.isEmpty { for record in cachedRecords.values { let url = URL(fileURLWithPath: record.filePath) .deletingLastPathComponent() directories.insert(url) } } if let projectDirectories, !projectDirectories.isEmpty { for path in projectDirectories { let url = URL(fileURLWithPath: path, isDirectory: true) if let dir = directoryIfExists(url) { directories.insert(dir) } } } // If created dimension with explicit date range, add day directories to cover new files if let dateRange, dateDimension == .created { let cal = Calendar.current var cursor = cal.startOfDay(for: dateRange.0) let end = cal.startOfDay(for: dateRange.1) while cursor <= end { if let dayDir = dayDirectory(root: root, date: cursor) { directories.insert(dayDir) } guard let next = cal.date(byAdding: .day, value: 1, to: cursor) else { break } cursor = next } } // When no narrowing information exists, fall back to default scope directory if directories.isEmpty { return nil } return Array(directories) } private func mappedDataIfAvailable(at url: URL) throws -> Data? { do { return try Data(contentsOf: url, options: [.mappedIfSafe]) } catch let error as NSError { if error.domain == NSCocoaErrorDomain && (error.code == NSFileReadNoSuchFileError || error.code == NSFileNoSuchFileError) { logger.debug("File disappeared before reading \(url.path, privacy: .public); skipping.") return nil } throw error } } // Sidebar: month daily counts without parsing content (fast) func computeCalendarCounts(root: URL, monthStart: Date, dimension: DateDimension) async -> [Int: Int] { var counts: [Int: Int] = [:] let cal = Calendar.current let comps = cal.dateComponents([.year, .month], from: monthStart) guard let year = comps.year, let month = comps.month else { return [:] } // For the Updated dimension we must scan all files, since cross-month updates can land in any month folder let scanURL: URL if dimension == .updated { scanURL = root } else { guard let monthURL = monthDirectory(root: root, year: year, month: month) else { return [:] } scanURL = monthURL } guard let enumerator = fileManager.enumerator( at: scanURL, includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) else { return [:] } // Collect URLs synchronously first to avoid Swift 6 async/iterator issues let urls = enumerator.compactMap { $0 as? URL } for url in urls { guard url.pathExtension.lowercased() == "jsonl" else { continue } switch dimension { case .created: if let day = Int(url.deletingLastPathComponent().lastPathComponent) { counts[day, default: 0] += 1 } case .updated: let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]) if let date = lastUpdatedTimestamp( for: url, modificationDate: values?.contentModificationDate), cal.isDate(date, equalTo: monthStart, toGranularity: .month) { let day = cal.component(.day, from: date) counts[day, default: 0] += 1 } } } return counts } // MARK: - Updated dimension index /// Fast index: record the last update timestamp per file to avoid repeated scans private var updatedDateIndex: [String: Date] = [:] /// Tail token scan when tokens are zero (avoids full reparse) private func readTailTokenSnapshot(url: URL) -> SessionTokenSnapshot? { let chunkSize = 128 * 1024 guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } defer { try? handle.close() } do { let fileSize = try handle.seekToEnd() let offset = fileSize > chunkSize ? fileSize - UInt64(chunkSize) : 0 try handle.seek(toOffset: offset) guard let data = try handle.readToEnd(), !data.isEmpty else { return nil } let newline: UInt8 = 0x0A let carriageReturn: UInt8 = 0x0D for var slice in data.split(separator: newline, omittingEmptySubsequences: true).reversed() { if slice.last == carriageReturn { slice = slice.dropLast() } guard !slice.isEmpty else { continue } if let row = try? decoder.decode(SessionRow.self, from: Data(slice)) { if case let .eventMessage(payload) = row.kind, payload.type == "token_count" { var snapshot = SessionTokenSnapshot() var handled = false if let infoSnapshot = SessionTokenSnapshot.from(info: payload.info) { snapshot.merge(infoSnapshot) handled = true } if let messageSnapshot = SessionTokenSnapshot.from(message: payload.message ?? payload.text) { snapshot.merge(messageSnapshot) handled = true } if handled { return snapshot } } } } } catch { return nil } return nil } /// Build the date index for the Updated dimension (async in the background) func buildUpdatedIndex(root: URL) async -> [String: Date] { var index: [String: Date] = [:] guard let enumerator = fileManager.enumerator( at: root, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants] ) else { return [:] } let urls = enumerator.compactMap { $0 as? URL } await withTaskGroup(of: (String, Date)?.self) { group in for url in urls { guard url.pathExtension.lowercased() == "jsonl" else { continue } group.addTask { [weak self] in guard let self else { return nil } // Try disk cache first let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]) if let cached = try? await self.sqliteStore.fetch( path: url.path, modificationDate: values?.contentModificationDate, fileSize: nil), let updated = cached.lastUpdatedAt { return (url.path, updated) } // Otherwise read tail timestamp quickly if let tailDate = self.readTailTimestamp(url: url) { return (url.path, tailDate) } return nil } } for await item in group { if let (path, date) = item { index[path] = date } } } return index } /// Quickly filter files to load based on the Updated index func sessionFileURLsForUpdatedDay(root: URL, day: Date, index: [String: Date]) -> [URL] { let cal = Calendar.current let dayStart = cal.startOfDay(for: day) var urls: [URL] = [] for (path, updatedDate) in index { if cal.isDate(updatedDate, inSameDayAs: dayStart) { urls.append(URL(fileURLWithPath: path)) } } return urls } private func scopeBaseURL(root: URL, scope: SessionLoadScope) -> URL? { switch scope { case .today: return dayDirectory(root: root, date: Date()) case .day(let date): return dayDirectory(root: root, date: date) case .month(let date): return monthDirectory(root: root, date: date) case .all: return directoryIfExists(root) } } private func monthDirectory(root: URL, date: Date) -> URL? { let cal = Calendar.current let components = cal.dateComponents([.year, .month], from: date) guard let year = components.year, let month = components.month else { return nil } return monthDirectory(root: root, year: year, month: month) } private func dayDirectory(root: URL, date: Date) -> URL? { let cal = Calendar.current let components = cal.dateComponents([.year, .month, .day], from: cal.startOfDay(for: date)) guard let year = components.year, let month = components.month, let day = components.day else { return nil } return dayDirectory(root: root, year: year, month: month, day: day) } private func monthDirectory(root: URL, year: Int, month: Int) -> URL? { guard let yearURL = directoryIfExists( root.appendingPathComponent("\(year)", isDirectory: true)) else { return nil } return numberedDirectory(base: yearURL, value: month) } private func dayDirectory(root: URL, year: Int, month: Int, day: Int) -> URL? { guard let monthURL = monthDirectory(root: root, year: year, month: month) else { return nil } return numberedDirectory(base: monthURL, value: day) } private func numberedDirectory(base: URL, value: Int) -> URL? { let candidates = [String(format: "%02d", value), "\(value)"] for name in candidates { let url = base.appendingPathComponent(name, isDirectory: true) if let existing = directoryIfExists(url) { return existing } } return nil } private func directoryIfExists(_ url: URL) -> URL? { var isDir: ObjCBool = false if fileManager.fileExists(atPath: url.path, isDirectory: &isDir), isDir.boolValue { return url } return nil } // Sidebar: collect cwd counts using disk cache or quick head-scan func collectCWDCounts(root: URL) async -> [String: Int] { var result: [String: Int] = [:] guard let enumerator = fileManager.enumerator( at: root, includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) else { return [:] } // Collect URLs synchronously first to avoid Swift 6 async/iterator issues let urls = enumerator.compactMap { $0 as? URL } await withTaskGroup(of: (String, Int)?.self) { group in for url in urls { guard url.pathExtension.lowercased() == "jsonl" else { continue } group.addTask { [weak self] in guard let self else { return nil } let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]) let m = values?.contentModificationDate if let cached = try? await self.sqliteStore.fetch( path: url.path, modificationDate: m, fileSize: nil), !cached.cwd.isEmpty { return (cached.cwd, 1) } if let cwd = self.fastExtractCWD(url: url) { return (cwd, 1) } return nil } } for await item in group { if let (cwd, inc) = item { result[cwd, default: 0] += inc } } } return result } nonisolated private func fastExtractCWD(url: URL) -> String? { guard let data = try? Data(contentsOf: url, options: [.mappedIfSafe]), !data.isEmpty else { return nil } let newline: UInt8 = 0x0A let carriageReturn: UInt8 = 0x0D for var slice in data.split(separator: newline, omittingEmptySubsequences: true).prefix(200) { if slice.last == carriageReturn { slice = slice.dropLast() } if let row = try? decoder.decode(SessionRow.self, from: Data(slice)) { switch row.kind { case .sessionMeta(let p): return p.cwd case .turnContext(let p): if let c = p.cwd { return c } default: break } } } return nil } private func buildSummaryFast(for url: URL, builder: inout SessionSummaryBuilder) throws -> SessionSummary? { // Memory-map file (fast and low memory overhead) guard let data = try mappedDataIfAvailable(at: url) else { return nil } guard !data.isEmpty else { return nil } let newline: UInt8 = 0x0A let carriageReturn: UInt8 = 0x0D let fastLineLimit = 64 var lineCount = 0 for var slice in data.split(separator: newline, omittingEmptySubsequences: true) { if slice.last == carriageReturn { slice = slice.dropLast() } guard !slice.isEmpty else { continue } if lineCount >= fastLineLimit, builder.hasEssentialMetadata { break } do { let row = try decoder.decode(SessionRow.self, from: Data(slice)) builder.observe(row) } catch { // Silently ignore parse errors for individual lines } lineCount += 1 } // Ensure lastUpdatedAt reflects last JSON line timestamp if let tailDate = readTailTimestamp(url: url) { if builder.lastUpdatedAt == nil || (builder.lastUpdatedAt ?? .distantPast) < tailDate { builder.seedLastUpdated(tailDate) } } // Lightweight token fallback for sources emitting token_count events. if builder.totalTokens == 0, shouldUseTokenFallback(for: url), let snapshot = SessionTimelineLoader().loadLatestTokenUsageWithFallback(url: url), let fallbackTokens = snapshot.totalTokens { builder.seedTotalTokens(fallbackTokens) } // Tail token_count scan: always read the last token_count from the file tail // because it contains the cumulative total (not just the first 64 lines) if shouldUseTokenFallback(for: url), let tailSnapshot = readTailTokenSnapshot(url: url) { if let total = tailSnapshot.total { builder.seedTotalTokens(total) } builder.seedTokenSnapshot( input: tailSnapshot.input, output: tailSnapshot.output, cacheRead: tailSnapshot.cacheRead, cacheCreation: tailSnapshot.cacheCreation) } builder.parseLevel = .metadata if let result = builder.build(for: url) { return result } return try buildSummaryFull(for: url, builder: &builder) } private func shouldUseTokenFallback(for url: URL) -> Bool { // Apply to all sources; if no token_count exists the scan is cheap and falls through. return true } private func buildSummaryFull(for url: URL, builder: inout SessionSummaryBuilder) throws -> SessionSummary? { guard let data = try mappedDataIfAvailable(at: url) else { return nil } guard !data.isEmpty else { return nil } let newline: UInt8 = 0x0A let carriageReturn: UInt8 = 0x0D var lastError: Error? for var slice in data.split(separator: newline, omittingEmptySubsequences: true) { if slice.last == carriageReturn { slice = slice.dropLast() } guard !slice.isEmpty else { continue } do { let row = try decoder.decode(SessionRow.self, from: Data(slice)) builder.observe(row) } catch { lastError = error } } builder.parseLevel = .full if let result = builder.build(for: url) { return result } if let error = lastError { throw error } return nil } // Public API for background enrichment func enrich(url: URL) async throws -> SessionSummary? { let values = try url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]) var builder = SessionSummaryBuilder() if let size = values.fileSize { builder.setFileSize(UInt64(size)) } if let tailDate = readTailTimestamp(url: url) { builder.seedLastUpdated(tailDate) } guard let base = try buildSummaryFull(for: url, builder: &builder) else { return nil } // Compute accurate active duration from grouped turns let active = computeActiveDuration(url: url) var enriched = SessionSummary( id: base.id, fileURL: base.fileURL, fileSizeBytes: base.fileSizeBytes, startedAt: base.startedAt, endedAt: base.endedAt, activeDuration: active, cliVersion: base.cliVersion, cwd: base.cwd, originator: base.originator, instructions: base.instructions, model: base.model, approvalPolicy: base.approvalPolicy, userMessageCount: base.userMessageCount, assistantMessageCount: base.assistantMessageCount, toolInvocationCount: base.toolInvocationCount, responseCounts: base.responseCounts, turnContextCount: base.turnContextCount, messageTypeCounts: base.messageTypeCounts, totalTokens: base.totalTokens, tokenBreakdown: base.tokenBreakdown, eventCount: base.eventCount, lineCount: base.lineCount, lastUpdatedAt: base.lastUpdatedAt, source: base.source, remotePath: base.remotePath, userTitle: base.userTitle, userComment: base.userComment ) enriched.parseLevel = .enriched let (cachedEnriched, normalizedFullInstructions) = prepareSummaryForCache(enriched) // Persist to in-memory and disk caches keyed by mtime store(summary: cachedEnriched, for: url as NSURL, modificationDate: values.contentModificationDate) do { try await sqliteStore.upsert( summary: cachedEnriched, project: nil, fileModificationTime: values.contentModificationDate, fileSize: values.fileSize.flatMap { UInt64($0) }, tokenBreakdown: cachedEnriched.tokenBreakdown, fullInstructions: normalizedFullInstructions, parseError: nil, parseLevel: "enriched") // Full parse + activeDuration computation } catch { logger.error( "Failed to persist enriched summary: \(error.localizedDescription, privacy: .public) path=\(url.path, privacy: .public)" ) } return cachedEnriched } // Compute sum of turn durations: for each turn, duration = (last output timestamp - user message timestamp). // If a turn has no user message, start from first output. If no outputs exist, contributes 0. nonisolated private func computeActiveDuration(url: URL) -> TimeInterval? { let loader = SessionTimelineLoader() guard let turns = try? loader.load(url: url) else { return nil } let filtered = turns.removingEnvironmentContext() var total: TimeInterval = 0 for turn in filtered { let start: Date? if let u = turn.userMessage?.timestamp { start = u } else { start = turn.outputs.first?.timestamp } guard let s = start, let end = turn.outputs.last?.timestamp else { continue } let dt = end.timeIntervalSince(s) if dt > 0 { total += dt } if Task.isCancelled { return total } } return total } // MARK: - Fulltext scanning func fileContains(url: URL, term: String) async -> Bool { guard let handle = try? FileHandle(forReadingFrom: url) else { return false } defer { try? handle.close() } let needle = term let chunkSize = 128 * 1024 var carry = Data() while let chunk = try? handle.read(upToCount: chunkSize), !chunk.isEmpty { var combined = carry combined.append(chunk) if let s = String(data: combined, encoding: .utf8), s.range(of: needle, options: .caseInsensitive) != nil { return true } // keep tail to catch matches across boundaries let keep = min(needle.utf8.count - 1, combined.count) carry = combined.suffix(keep) if Task.isCancelled { return false } } if !carry.isEmpty, let s = String(data: carry, encoding: .utf8), s.range(of: needle, options: .caseInsensitive) != nil { return true } return false } // MARK: - Tail timestamp helper nonisolated private func readTailTimestamp(url: URL) -> Date? { guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } defer { try? handle.close() } let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) let fileSize = (attributes?[.size] as? NSNumber)?.uint64Value ?? 0 // Start with a reasonable chunk size, will expand if needed let chunkSize: UInt64 = 4096 let maxChunkSize: UInt64 = 1024 * 1024 // 1MB max to avoid excessive memory usage let maxAttempts = 3 let newline: UInt8 = 0x0A let carriageReturn: UInt8 = 0x0D for attempt in 0.. currentChunkSize ? fileSize - currentChunkSize : 0 do { try handle.seek(toOffset: offset) } catch { return nil } guard let buffer = try? handle.readToEnd(), !buffer.isEmpty else { return nil } let lines = buffer.split(separator: newline, omittingEmptySubsequences: true) guard var slice = lines.last else { continue } if slice.last == carriageReturn { slice = slice.dropLast() } guard !slice.isEmpty else { continue } // Check if this looks like a complete line by looking for opening brace // (all session log lines are JSON objects starting with {) let hasOpeningBrace = slice.first == 0x7B // '{' if !hasOpeningBrace && attempt < maxAttempts - 1 { // Line appears truncated, try with larger chunk continue } // Try to extract timestamp from first 100 bytes for performance let limitedSlice = slice.prefix(100) if let text = String(data: Data(limitedSlice), encoding: .utf8) ?? String(bytes: limitedSlice, encoding: .utf8), let timestamp = extractTimestamp(from: text) { return timestamp } // Fallback: try full line if let fullText = String(data: Data(slice), encoding: .utf8), let timestamp = extractTimestamp(from: fullText) { return timestamp } // If we've tried full line and still failed, no point in retrying with larger chunk break } return nil } nonisolated private func extractTimestamp(from text: String) -> Date? { let pattern = #""timestamp"\s*:\s*"([^"]+)""# guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return nil } let range = NSRange(location: 0, length: (text as NSString).length) guard let match = regex.firstMatch(in: text, options: [], range: range), match.numberOfRanges >= 2 else { return nil } let nsText = text as NSString let isoString = nsText.substring(with: match.range(at: 1)) return SessionIndexer.makeTailTimestampFormatter().date(from: isoString) } // Global count for sidebar label func countAllSessions(root: URL) async -> Int { var total = 0 guard let enumerator = fileManager.enumerator( at: root, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], options: [.skipsHiddenFiles, .skipsPackageDescendants]) else { return 0 } while let obj = enumerator.nextObject() { guard let url = obj as? URL else { continue } guard url.pathExtension.lowercased() == "jsonl" else { continue } let name = url.deletingPathExtension().lastPathComponent if name.hasPrefix("agent-") { continue } let values = try? url.resourceValues(forKeys: [.fileSizeKey]) if let size = values?.fileSize, size == 0 { continue } total += 1 } return total } /// Expose current meta for UI/diagnostics (non-mutating). func currentMeta() async -> SessionIndexMeta? { return try? await sqliteStore.fetchMeta() } /// Persist project assignments into the shared SQLite cache for scoped aggregates. func updateProjects( for sessions: [SessionSummary], resolver: @escaping @Sendable (SessionSummary) -> String? ) async { guard !sessions.isEmpty else { return } for session in sessions { let project = resolver(session) do { try await sqliteStore.updateProject(sessionId: session.id, project: project) } catch { logger.error("Failed to update project for \(session.id, privacy: .public): \(error.localizedDescription, privacy: .public)") } } } /// Persist user-provided title/comment into the SQLite cache for consistency. func updateUserMetadata(sessionId: String, title: String?, comment: String?) async { do { try await sqliteStore.updateUserMetadata(sessionId: sessionId, title: title, comment: comment) } catch { logger.error("Failed to update user metadata for \(sessionId, privacy: .public): \(error.localizedDescription, privacy: .public)") } } /// Returns cached summaries when a full index already exists. func cachedAllSummaries(ignoredPaths: [String] = []) async throws -> [SessionSummary]? { return try await cachedAllSummariesFromMeta(ignoredPaths: ignoredPaths) } } // MARK: - SessionProvider extension SessionIndexer: SessionProvider { nonisolated var kind: SessionSource.Kind { .codex } nonisolated var identifier: String { "codex-local" } nonisolated var label: String { "Codex (local)" } func load(context: SessionProviderContext) async throws -> SessionProviderResult { guard let root = context.sessionsRoot else { return SessionProviderResult(summaries: [], coverage: nil, cacheHit: true) } _ = try await ensureCacheAvailable() switch context.cachePolicy { case .cacheOnly: // Try scoped cached summaries first var cached = try await sqliteStore.fetchSummaries( kinds: [.codex], includeRemote: false, dateColumn: dateColumn(for: context.dateDimension), dateRange: context.dateRange, projectIds: context.projectIds ) // Apply ignore rules to cached summaries if !context.ignoredPaths.isEmpty { cached = cached.filter { !shouldIgnoreSummary($0, ignoredPaths: context.ignoredPaths) } } if !cached.isEmpty { let coverage = await currentCoverage() return SessionProviderResult(summaries: cached, coverage: coverage, cacheHit: true) } if context.scope == .all, let cached = try await cachedAllSummaries(ignoredPaths: context.ignoredPaths) { let coverage = await currentCoverage() return SessionProviderResult( summaries: cached, coverage: coverage, cacheHit: true ) } return SessionProviderResult(summaries: [], coverage: await currentCoverage(), cacheHit: false) case .refresh: let summaries = try await refreshSessions( root: root, scope: context.scope, dateRange: context.dateRange, projectIds: context.projectIds, projectDirectories: context.projectDirectories, dateDimension: context.dateDimension, forceFilesystemScan: context.forceFilesystemScan, ignoredPaths: context.ignoredPaths ) let coverage = await currentCoverage() return SessionProviderResult( summaries: summaries, coverage: coverage, cacheHit: false ) } } private func dateColumn(for dimension: DateDimension) -> String { switch dimension { case .created: return "started_at" case .updated: return "COALESCE(last_updated_at, started_at)" } } // MARK: - Single File Reindexing /// Reindex specific files and update cache. Used for incremental refresh of selected sessions. /// Returns updated SessionSummary objects for successfully reindexed files. func reindexFiles(_ urls: [URL]) async throws -> [SessionSummary] { guard !urls.isEmpty else { return [] } logger.info("reindexFiles: processing \(urls.count, privacy: .public) files") let startTime = Date() var results: [SessionSummary] = [] var failedCount = 0 for url in urls { do { // Get file attributes for mtime and size let values = try url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]) let fileSize = values.fileSize.flatMap { UInt64($0) } let mtime = values.contentModificationDate // Build summary using full parsing var builder = SessionSummaryBuilder() if let size = fileSize { builder.setFileSize(size) } if let tailDate = readTailTimestamp(url: url) { builder.seedLastUpdated(tailDate) } guard let summary = try buildSummaryFull(for: url, builder: &builder) else { logger.warning("reindexFiles: failed to build summary for \(url.path, privacy: .public)") failedCount += 1 continue } let (cachedSummary, normalizedFullInstructions) = prepareSummaryForCache(summary) // Update SQLite cache do { try await sqliteStore.upsert( summary: cachedSummary, project: nil, fileModificationTime: mtime, fileSize: fileSize, tokenBreakdown: cachedSummary.tokenBreakdown, fullInstructions: normalizedFullInstructions, parseError: nil, parseLevel: cachedSummary.parseLevel?.rawValue ?? "full" ) } catch { logger.error("reindexFiles: failed to update cache for \(cachedSummary.id, privacy: .public): \(error.localizedDescription, privacy: .public)") } // Update in-memory cache if let mtime { cache.setObject(CacheEntry(modificationDate: mtime, summary: cachedSummary), forKey: url as NSURL) } results.append(cachedSummary) logger.debug("reindexFiles: successfully reindexed \(cachedSummary.id, privacy: .public) (\(cachedSummary.fileURL.lastPathComponent, privacy: .public))") } catch { logger.error("reindexFiles: error processing \(url.path, privacy: .public): \(error.localizedDescription, privacy: .public)") failedCount += 1 } } let elapsed = Date().timeIntervalSince(startTime) logger.info("reindexFiles: completed in \(elapsed, format: .fixed(precision: 3))s, success=\(results.count, privacy: .public), failed=\(failedCount, privacy: .public)") return results } // MARK: - Timeline Preview Cache /// Fetch cached timeline previews for a session func fetchTimelinePreviews( sessionId: String, fileModificationTime: Date?, fileSize: UInt64? ) async throws -> [ConversationTurnPreview]? { try await sqliteStore.fetchTimelinePreviews( sessionId: sessionId, fileModificationTime: fileModificationTime, fileSize: fileSize ) } /// Update timeline preview cache for a session func upsertTimelinePreviews( _ previews: [ConversationTurnPreview], sessionId: String, fileModificationTime: Date, fileSize: UInt64? ) async throws { try await sqliteStore.upsertTimelinePreviews( previews, sessionId: sessionId, fileModificationTime: fileModificationTime, fileSize: fileSize ) } /// Delete timeline preview cache for a session func deleteTimelinePreviews(sessionId: String) async throws { try await sqliteStore.deleteTimelinePreviews(sessionId: sessionId) } /// Fetch cached records for given session IDs (including mtime/size) without touching the filesystem. func fetchRecords(sessionIds: Set) async -> [SessionIndexRecord] { guard !sessionIds.isEmpty else { return [] } do { return try await sqliteStore.fetchRecords(sessionIds: sessionIds) } catch { logger.error("fetchRecords(sessionIds:) failed: \(error.localizedDescription, privacy: .public)") return [] } } // MARK: - Ignore Rules nonisolated private func shouldIgnorePath(_ absolutePath: String, ignoredPaths: [String]) -> Bool { SessionPathFilter.shouldIgnorePath(absolutePath, ignoredPaths: ignoredPaths) } nonisolated private func shouldIgnoreSummary(_ summary: SessionSummary, ignoredPaths: [String]) -> Bool { SessionPathFilter.shouldIgnoreSummary(summary, ignoredPaths: ignoredPaths) } } ================================================ FILE: services/SessionNotesStore.swift ================================================ import Foundation struct SessionNote: Codable, Hashable, Sendable { let id: String var title: String? var comment: String? var projectId: String? var profileId: String? var timelineVisibleKinds: [String]? = nil var updatedAt: Date } // Stores notes as individual JSON files under a notes directory that sits // alongside the sessions directory. Provides migration from the legacy // Application Support JSON file when the notes directory is empty. actor SessionNotesStore { private let fm: FileManager private var notesRoot: URL private let legacyURL: URL init(notesRoot: URL? = nil, fileManager: FileManager = .default) { self.fm = fileManager // Default to ~/.codmate/notes (centralized CodMate data root) let home = fileManager.homeDirectoryForCurrentUser let defaultRoot = home.appendingPathComponent(".codmate", isDirectory: true) .appendingPathComponent("notes", isDirectory: true) self.notesRoot = notesRoot ?? defaultRoot // Legacy single-file JSON in Application Support (existing path in project) let legacyDir = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! .appendingPathComponent("ai.umate.codmate", isDirectory: true) self.legacyURL = legacyDir.appendingPathComponent("session-notes.json") // First migrate from legacy ~/.codex/notes directory if present Self.migrateLegacyNotesDirectoryIfNeeded(fm: fm, newNotesRoot: self.notesRoot) try? fm.createDirectory(at: self.notesRoot, withIntermediateDirectories: true) // During init, actor isolation isn't available; use static helper for old single-file JSON Self.performMigration(fm: fm, notesRoot: self.notesRoot, legacyURL: self.legacyURL) // Normalize stored timeline visibility settings to current schema. Self.normalizeTimelineVisibilityIfNeeded(fm: fm, notesRoot: self.notesRoot) } // Compute default notes directory from sessions root static func defaultNotesRoot(for sessionsRoot: URL) -> URL { // Kept for compatibility, but now always prefers centralized ~/.codmate/notes let home = FileManager.default.homeDirectoryForCurrentUser return home.appendingPathComponent(".codmate", isDirectory: true) .appendingPathComponent("notes", isDirectory: true) } // Update notes root (e.g., when sessions root changes) func updateRoot(to newRoot: URL) { if newRoot == notesRoot { return } notesRoot = newRoot try? fm.createDirectory(at: notesRoot, withIntermediateDirectories: true) migrateFromLegacyIfNeeded() } // MARK: - Public API func note(for id: String) -> SessionNote? { let url = fileURL(for: id) guard let data = try? Data(contentsOf: url) else { return nil } return try? JSONDecoder().decode(SessionNote.self, from: data) } func upsert(id: String, title: String?, comment: String?) { var note = (note(for: id) ?? SessionNote(id: id, title: nil, comment: nil, projectId: nil, profileId: nil, updatedAt: Date())) note.title = title note.comment = comment note.updatedAt = Date() if let data = try? JSONEncoder().encode(note) { let url = fileURL(for: id) try? data.write(to: url, options: .atomic) } } func assignProject(id: String, projectId: String?, profileId: String? = nil) { var note = (note(for: id) ?? SessionNote(id: id, title: nil, comment: nil, projectId: nil, profileId: nil, updatedAt: Date())) note.projectId = projectId if let profileId { note.profileId = profileId } note.updatedAt = Date() if let data = try? JSONEncoder().encode(note) { let url = fileURL(for: id) try? data.write(to: url, options: .atomic) } } func remove(id: String) { let url = fileURL(for: id) // Move to Trash rather than hard delete to allow recovery var resulting: NSURL? if fm.fileExists(atPath: url.path) { try? fm.trashItem(at: url, resultingItemURL: &resulting) } } func updateTimelineVisibleKinds(id: String, kinds: [String]?) { var note = (note(for: id) ?? SessionNote(id: id, title: nil, comment: nil, projectId: nil, profileId: nil, updatedAt: Date())) note.timelineVisibleKinds = kinds note.updatedAt = Date() if let data = try? JSONEncoder().encode(note) { let url = fileURL(for: id) try? data.write(to: url, options: .atomic) } } func all() -> [String: SessionNote] { var result: [String: SessionNote] = [:] guard let en = fm.enumerator(at: notesRoot, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]) else { return [:] } for case let url as URL in en { if url.pathExtension.lowercased() != "json" { continue } if let data = try? Data(contentsOf: url), let n = try? JSONDecoder().decode(SessionNote.self, from: data) { result[n.id] = n } } return result } // MARK: - Helpers private func migrateFromLegacyIfNeeded() { Self.performMigration(fm: fm, notesRoot: notesRoot, legacyURL: legacyURL) } private static func performMigration(fm: FileManager, notesRoot: URL, legacyURL: URL) { // Only migrate when notes directory is empty and legacy file exists let existing = (try? fm.contentsOfDirectory(at: notesRoot, includingPropertiesForKeys: nil)) ?? [] guard existing.first(where: { $0.pathExtension.lowercased() == "json" }) == nil else { return } guard fm.fileExists(atPath: legacyURL.path), let data = try? Data(contentsOf: legacyURL), let decoded = try? JSONDecoder().decode([String: SessionNote].self, from: data) else { return } for (id, note) in decoded { if let d = try? JSONEncoder().encode(note) { let safe = safeFileNameStatic(for: id) let url = notesRoot.appendingPathComponent(safe + ".json") try? d.write(to: url, options: .atomic) } } // Keep legacy file as-is; do not delete to avoid destructive surprises } private static func normalizeTimelineVisibilityIfNeeded(fm: FileManager, notesRoot: URL) { guard let en = fm.enumerator(at: notesRoot, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]) else { return } for case let url as URL in en { if url.pathExtension.lowercased() != "json" { continue } guard let data = try? Data(contentsOf: url), var note = try? JSONDecoder().decode(SessionNote.self, from: data) else { continue } guard var kinds = note.timelineVisibleKinds else { continue } let before = kinds kinds = Array(Set(kinds)) kinds.removeAll { $0 == "environmentContext" || $0 == "turnContext" || $0 == "ghostSnapshot" || $0 == "compaction" || $0 == "turnAborted" || $0 == "sessionMeta" || $0 == "taskInstructions" } if kinds.contains("tool"), !kinds.contains("codeEdit") { kinds.append("codeEdit") } if kinds != before { note.timelineVisibleKinds = kinds note.updatedAt = Date() if let updated = try? JSONEncoder().encode(note) { try? updated.write(to: url, options: .atomic) } } } } /// Migrate notes directory from old `~/.codex/notes` to new `~/.codmate/notes`. /// Prefer moving the entire directory if the destination is missing or empty; otherwise copy only missing files. private static func migrateLegacyNotesDirectoryIfNeeded(fm: FileManager, newNotesRoot: URL) { let home = fm.homeDirectoryForCurrentUser let oldRoot = home.appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("notes", isDirectory: true) var isDir: ObjCBool = false guard fm.fileExists(atPath: oldRoot.path, isDirectory: &isDir), isDir.boolValue else { return } // Determine if new root exists and is empty var newIsDir: ObjCBool = false let newExists = fm.fileExists(atPath: newNotesRoot.path, isDirectory: &newIsDir) && newIsDir.boolValue let newIsEmpty: Bool = { guard newExists else { return true } do { return try fm.contentsOfDirectory(atPath: newNotesRoot.path).isEmpty } catch { return true } }() if !newExists || newIsEmpty { do { if newExists && newIsEmpty { try? fm.removeItem(at: newNotesRoot) } try fm.moveItem(at: oldRoot, to: newNotesRoot) return } catch { // Fallback to copy flow } } // Ensure destination exists for copy try? fm.createDirectory(at: newNotesRoot, withIntermediateDirectories: true) if let en = fm.enumerator(at: oldRoot, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]) { for case let url as URL in en { if url.pathExtension.lowercased() != "json" { continue } let dest = newNotesRoot.appendingPathComponent(url.lastPathComponent) if !fm.fileExists(atPath: dest.path) { try? fm.copyItem(at: url, to: dest) } } } } private func fileURL(for id: String) -> URL { let safe = safeFileName(for: id) + ".json" return notesRoot.appendingPathComponent(safe, isDirectory: false) } private func safeFileName(for id: String) -> String { // Sanitize and add stable short hash suffix to avoid collisions let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._") let sanitized = id.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }.reduce(into: String(), { $0.append($1) }) let hash = fnv1a32(id) return sanitized + "-" + String(format: "%08x", hash) } private static func safeFileNameStatic(for id: String) -> String { let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._") let sanitized = id.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }.reduce(into: String(), { $0.append($1) }) let hash = fnv1a32Static(id) return sanitized + "-" + String(format: "%08x", hash) } private func fnv1a32(_ s: String) -> UInt32 { var h: UInt32 = 2166136261 for b in s.utf8 { h ^= UInt32(b); h = h &* 16777619 } return h } private static func fnv1a32Static(_ s: String) -> UInt32 { var h: UInt32 = 2166136261 for b in s.utf8 { h ^= UInt32(b); h = h &* 16777619 } return h } } ================================================ FILE: services/SessionPreferencesStore.swift ================================================ import CoreGraphics import Foundation #if canImport(Darwin) import Darwin #endif @MainActor final class SessionPreferencesStore: ObservableObject { @Published var sessionsRoot: URL { didSet { persist() } } @Published var notesRoot: URL { didSet { persist() } } // New: Projects data directory (metadata + memberships) @Published var projectsRoot: URL { didSet { persist() } } @Published var codexCommandPath: String { didSet { persistCLIPaths() } } @Published var claudeCommandPath: String { didSet { persistCLIPaths() } } @Published var geminiCommandPath: String { didSet { persistCLIPaths() } } @Published var cliCodexEnabled: Bool { didSet { persistCLIEnablement() } } @Published var cliClaudeEnabled: Bool { didSet { persistCLIEnablement() } } @Published var cliGeminiEnabled: Bool { didSet { persistCLIEnablement() } } @Published var wizardPreferredProvider: String { didSet { defaults.set(wizardPreferredProvider, forKey: Keys.wizardPreferredProvider) } } @Published var sessionPathConfigs: [SessionPathConfig] { didSet { persistSessionPaths() } } private let defaults: UserDefaults private let fileManager: FileManager private struct Keys { static let sessionsRootPath = "codex.sessions.rootPath" static let notesRootPath = "codex.notes.rootPath" static let projectsRootPath = "codmate.projects.rootPath" static let codexCommandPath = "codmate.command.codex" static let claudeCommandPath = "codmate.command.claude" static let geminiCommandPath = "codmate.command.gemini" static let cliCodexEnabled = "codmate.cli.codex.enabled" static let cliClaudeEnabled = "codmate.cli.claude.enabled" static let cliGeminiEnabled = "codmate.cli.gemini.enabled" static let wizardPreferredProvider = "codmate.wizard.preferredProvider" static let resumeUseEmbedded = "codex.resume.useEmbedded" static let resumeCopyClipboard = "codex.resume.copyClipboard" static let resumeExternalApp = "codex.resume.externalApp" static let resumeSandboxMode = "codex.resume.sandboxMode" static let resumeApprovalPolicy = "codex.resume.approvalPolicy" static let resumeFullAuto = "codex.resume.fullAuto" static let resumeDangerBypass = "codex.resume.dangerBypass" static let autoAssignNewToSameProject = "codex.projects.autoAssignNewToSame" static let timelineVisibleKinds = "codex.timeline.visibleKinds" static let markdownVisibleKinds = "codex.markdown.visibleKinds" static let enabledRemoteHosts = "codex.remote.enabledHosts" static let searchPanelStyle = "codmate.search.panelStyle" static let systemMenuVisibility = "codmate.systemMenu.visibility" static let statusBarVisibility = "codmate.statusbar.visibility" static let confirmBeforeQuit = "codmate.app.confirmBeforeQuit" static let launchAtLogin = "codmate.app.launchAtLogin" static let notifyCommitMessage = "codmate.notifications.commitMessage" static let notifyTitleComment = "codmate.notifications.titleComment" static let notifyCommandCopy = "codmate.notifications.commandCopy" // Claude advanced static let claudeDebug = "claude.debug" static let claudeDebugFilter = "claude.debug.filter" static let claudeVerbose = "claude.verbose" static let claudePermissionMode = "claude.permission.mode" static let claudeAllowedTools = "claude.allowedTools" static let claudeDisallowedTools = "claude.disallowedTools" static let claudeAddDirs = "claude.addDirs" static let claudeIDE = "claude.ide" static let claudeStrictMCP = "claude.strictMCP" static let claudeFallbackModel = "claude.fallbackModel" static let claudeSkipPermissions = "claude.skipPermissions" static let claudeAllowSkipPermissions = "claude.allowSkipPermissions" static let claudeAllowUnsandboxedCommands = "claude.allowUnsandboxedCommands" // Default editor for quick file opens static let defaultFileEditor = "codmate.editor.default" // Git Review static let gitShowLineNumbers = "git.review.showLineNumbers" static let gitWrapText = "git.review.wrapText" static let commitPromptTemplate = "git.review.commitPromptTemplate" static let commitProviderId = "git.review.commitProviderId" // provider id or nil for auto static let commitModelId = "git.review.commitModelId" // optional model id tied to provider // Unified provider selections (CLIProxy-backed pickers) static let codexProxyProviderId = "codmate.codex.proxyProviderId" static let codexProxyModelId = "codmate.codex.proxyModelId" static let claudeProxyProviderId = "codmate.claude.proxyProviderId" static let claudeProxyModelId = "codmate.claude.proxyModelId" static let geminiProxyProviderId = "codmate.gemini.proxyProviderId" static let geminiProxyModelId = "codmate.gemini.proxyModelId" static let claudeProxyModelAliases = "codmate.claude.proxyModelAliases" // Terminal mode (DEV): use CLI console instead of shell static let terminalUseCLIConsole = "terminal.useCliConsole" static let terminalFontName = "terminal.fontName" static let terminalFontSize = "terminal.fontSize" static let terminalCursorStyle = "terminal.cursorStyle" static let terminalThemeName = "terminalThemeName" static let terminalThemeNameLight = "terminalThemeNameLight" static let terminalUsePerAppearanceTheme = "terminalUsePerAppearanceTheme" static let warpPromptEnabled = "codmate.warp.promptTitle" // Local AI Server (formerly CLI Proxy) static let localServerEnabled = "codmate.localserver.enabled" // Public server switch static let localServerReroute = "codmate.localserver.reroute" // ReRoute built-ins static let localServerReroute3P = "codmate.localserver.reroute3p" // ReRoute 3P providers static let localServerAutoStart = "codmate.localserver.autostart" // On-demand/Auto logic static let localServerPort = "codmate.localserver.port" static let oauthProvidersEnabled = "codmate.providers.oauth.enabled" static let oauthAccountsEnabled = "codmate.providers.oauth.accounts.enabled" static let apiKeyProvidersEnabled = "codmate.providers.apikey.enabled" // Legacy keys for migration static let legacyUseCLIProxy = "codmate.cliproxy.useForInternal" static let legacyCLIProxyPort = "codmate.cliproxy.port" // Session path configurations static let sessionPathConfigs = "codmate.sessions.pathConfigs" } init( defaults: UserDefaults = .standard, fileManager: FileManager = .default ) { self.defaults = defaults self.fileManager = fileManager // Get the real user home directory (not sandbox container) let homeURL = SessionPreferencesStore.getRealUserHomeURL() // Resolve sessions root without touching self (still used internally; no longer user-configurable) let resolvedSessionsRoot: URL = { if let storedRoot = defaults.string(forKey: Keys.sessionsRootPath) { let url = URL(fileURLWithPath: storedRoot, isDirectory: true) if fileManager.fileExists(atPath: url.path) { return url } else { defaults.removeObject(forKey: Keys.sessionsRootPath) } } return SessionPreferencesStore.defaultSessionsRoot(for: homeURL) }() // Resolve notes root (prefer stored path; else centralized ~/.codmate/notes) let resolvedNotesRoot: URL = { if let storedNotes = defaults.string(forKey: Keys.notesRootPath) { let url = URL(fileURLWithPath: storedNotes, isDirectory: true) if fileManager.fileExists(atPath: url.path) { return url } else { defaults.removeObject(forKey: Keys.notesRootPath) } } return SessionPreferencesStore.defaultNotesRoot(for: resolvedSessionsRoot) }() // Resolve projects root (prefer stored path; else ~/.codmate/projects) let resolvedProjectsRoot: URL = { if let stored = defaults.string(forKey: Keys.projectsRootPath) { let url = URL(fileURLWithPath: stored, isDirectory: true) if fileManager.fileExists(atPath: url.path) { return url } defaults.removeObject(forKey: Keys.projectsRootPath) } return SessionPreferencesStore.defaultProjectsRoot(for: homeURL) }() let storedCodexCommandPath = defaults.string(forKey: Keys.codexCommandPath) ?? "" let storedClaudeCommandPath = defaults.string(forKey: Keys.claudeCommandPath) ?? "" let storedGeminiCommandPath = defaults.string(forKey: Keys.geminiCommandPath) ?? "" let storedWizardProvider = defaults.string(forKey: Keys.wizardPreferredProvider) ?? "" var storedCodexEnabled = defaults.object(forKey: Keys.cliCodexEnabled) as? Bool ?? true let storedClaudeEnabled = defaults.object(forKey: Keys.cliClaudeEnabled) as? Bool ?? true let storedGeminiEnabled = defaults.object(forKey: Keys.cliGeminiEnabled) as? Bool ?? true if !storedCodexEnabled && !storedClaudeEnabled && !storedGeminiEnabled { storedCodexEnabled = true defaults.set(true, forKey: Keys.cliCodexEnabled) } // Assign after all are computed to avoid using self before init completes self.sessionsRoot = resolvedSessionsRoot self.notesRoot = resolvedNotesRoot self.projectsRoot = resolvedProjectsRoot self.codexCommandPath = storedCodexCommandPath self.claudeCommandPath = storedClaudeCommandPath self.geminiCommandPath = storedGeminiCommandPath self.cliCodexEnabled = storedCodexEnabled self.cliClaudeEnabled = storedClaudeEnabled self.cliGeminiEnabled = storedGeminiEnabled self.wizardPreferredProvider = storedWizardProvider // Load session path configs (with migration) let loadedConfigs = Self.loadSessionPathConfigs( defaults: defaults, fileManager: fileManager, homeURL: homeURL, currentSessionsRoot: resolvedSessionsRoot ) self.sessionPathConfigs = loadedConfigs // Resume defaults (defer assigning to self until value is finalized) let resumeEmbedded: Bool #if APPSTORE if defaults.object(forKey: Keys.resumeUseEmbedded) as? Bool != false { defaults.set(false, forKey: Keys.resumeUseEmbedded) } resumeEmbedded = false #else var embedded = defaults.object(forKey: Keys.resumeUseEmbedded) as? Bool ?? true if AppSandbox.isEnabled && embedded { embedded = false defaults.set(false, forKey: Keys.resumeUseEmbedded) } resumeEmbedded = embedded #endif self.defaultResumeUseEmbeddedTerminal = resumeEmbedded self.defaultResumeCopyToClipboard = defaults.object(forKey: Keys.resumeCopyClipboard) as? Bool ?? true ExternalTerminalProfileStore.shared.seedUserFileIfNeeded() let appRaw = defaults.string(forKey: Keys.resumeExternalApp) ?? "terminal" let resolvedExternalId = ExternalTerminalProfileStore.shared.resolvePreferredId(id: appRaw) self.defaultResumeExternalAppId = resolvedExternalId let statusBarRaw = defaults.string(forKey: Keys.statusBarVisibility) ?? StatusBarVisibility.hidden.rawValue self.statusBarVisibility = StatusBarVisibility(rawValue: statusBarRaw) ?? .hidden // Default editor for quick open (files) let editorRaw = defaults.string(forKey: Keys.defaultFileEditor) ?? EditorApp.vscode.rawValue var editor = EditorApp(rawValue: editorRaw) ?? .vscode // If the stored editor is no longer installed, fall back to the first installed option when available. let installedEditors = EditorApp.installedEditors if !installedEditors.isEmpty, !installedEditors.contains(editor) { editor = installedEditors[0] } self.defaultFileEditor = editor // Git Review defaults self.gitShowLineNumbers = defaults.object(forKey: Keys.gitShowLineNumbers) as? Bool ?? true self.gitWrapText = defaults.object(forKey: Keys.gitWrapText) as? Bool ?? false self.commitPromptTemplate = defaults.string(forKey: Keys.commitPromptTemplate) ?? "" self.commitProviderId = defaults.string(forKey: Keys.commitProviderId) self.commitModelId = defaults.string(forKey: Keys.commitModelId) self.codexProxyProviderId = defaults.string(forKey: Keys.codexProxyProviderId) self.codexProxyModelId = defaults.string(forKey: Keys.codexProxyModelId) self.claudeProxyProviderId = defaults.string(forKey: Keys.claudeProxyProviderId) self.claudeProxyModelId = defaults.string(forKey: Keys.claudeProxyModelId) self.geminiProxyProviderId = defaults.string(forKey: Keys.geminiProxyProviderId) self.geminiProxyModelId = defaults.string(forKey: Keys.geminiProxyModelId) self.claudeProxyModelAliases = SessionPreferencesStore.decodeJSON([String: [String: String]].self, defaults: defaults, key: Keys.claudeProxyModelAliases) ?? [:] // Terminal mode (DEV) – compute locally first let cliConsole: Bool #if APPSTORE if defaults.object(forKey: Keys.terminalUseCLIConsole) as? Bool != false { defaults.set(false, forKey: Keys.terminalUseCLIConsole) } cliConsole = false #else var console = defaults.object(forKey: Keys.terminalUseCLIConsole) as? Bool ?? false if !AppSandbox.isEnabled && console { console = false defaults.set(false, forKey: Keys.terminalUseCLIConsole) } if AppSandbox.isEnabled && console { console = false defaults.set(false, forKey: Keys.terminalUseCLIConsole) } cliConsole = console #endif self.useEmbeddedCLIConsole = cliConsole self.terminalFontName = defaults.string(forKey: Keys.terminalFontName) ?? "" let storedFontSize = defaults.object(forKey: Keys.terminalFontSize) as? Double ?? 12.0 self.terminalFontSize = SessionPreferencesStore.clampFontSize(storedFontSize) let storedCursor = defaults.string(forKey: Keys.terminalCursorStyle) ?? TerminalCursorStyleOption.blinkBlock.rawValue self.terminalCursorStyleRaw = storedCursor self.terminalThemeName = defaults.string(forKey: Keys.terminalThemeName) ?? "Xcode Dark" self.terminalThemeNameLight = defaults.string(forKey: Keys.terminalThemeNameLight) ?? "Xcode Light" self.terminalUsePerAppearanceTheme = defaults.object(forKey: Keys.terminalUsePerAppearanceTheme) as? Bool ?? true // CLI policy defaults (with legacy value coercion) let resolvedSandbox: SandboxMode = { if let s = defaults.string(forKey: Keys.resumeSandboxMode), let val = SessionPreferencesStore.coerceSandboxMode(s) { if val.rawValue != s { defaults.set(val.rawValue, forKey: Keys.resumeSandboxMode) } return val } return .workspaceWrite }() let resolvedApproval: ApprovalPolicy = { if let a = defaults.string(forKey: Keys.resumeApprovalPolicy), let val = SessionPreferencesStore.coerceApprovalPolicy(a) { if val.rawValue != a { defaults.set(val.rawValue, forKey: Keys.resumeApprovalPolicy) } return val } return .onRequest }() // Prefer Codex config.toml defaults when present (keeps CodMate in sync with Codex settings) let codexSandbox = SessionPreferencesStore.readCodexTopLevelConfigString("sandbox_mode") .flatMap { SandboxMode(rawValue: $0) } let codexApproval = SessionPreferencesStore.readCodexTopLevelConfigString("approval_policy") .flatMap { ApprovalPolicy(rawValue: $0) } let finalSandbox = codexSandbox ?? resolvedSandbox let finalApproval = codexApproval ?? resolvedApproval self.defaultResumeSandboxMode = finalSandbox self.defaultResumeApprovalPolicy = finalApproval defaults.set(finalSandbox.rawValue, forKey: Keys.resumeSandboxMode) defaults.set(finalApproval.rawValue, forKey: Keys.resumeApprovalPolicy) self.defaultResumeFullAuto = defaults.object(forKey: Keys.resumeFullAuto) as? Bool ?? false self.defaultResumeDangerBypass = defaults.object(forKey: Keys.resumeDangerBypass) as? Bool ?? false // Projects behaviors self.autoAssignNewToSameProject = defaults.object(forKey: Keys.autoAssignNewToSameProject) as? Bool ?? true // Message visibility defaults var resolvedTimelineKinds: Set if let storedTimeline = defaults.array(forKey: Keys.timelineVisibleKinds) as? [String] { resolvedTimelineKinds = Set( storedTimeline.compactMap { MessageVisibilityKind.coerced(from: $0) }) } else { resolvedTimelineKinds = MessageVisibilityKind.timelineDefault } resolvedTimelineKinds.remove(.turnContext) resolvedTimelineKinds.remove(.environmentContext) if resolvedTimelineKinds.contains(.tool) { resolvedTimelineKinds.insert(.codeEdit) } var resolvedMarkdownKinds: Set if let storedMarkdown = defaults.array(forKey: Keys.markdownVisibleKinds) as? [String] { resolvedMarkdownKinds = Set( storedMarkdown.compactMap { MessageVisibilityKind.coerced(from: $0) }) } else { resolvedMarkdownKinds = MessageVisibilityKind.markdownDefault } resolvedMarkdownKinds.remove(.turnContext) resolvedMarkdownKinds.remove(.environmentContext) if resolvedMarkdownKinds.contains(.tool) { resolvedMarkdownKinds.insert(.codeEdit) } self.timelineVisibleKinds = resolvedTimelineKinds self.markdownVisibleKinds = resolvedMarkdownKinds // Global search panel style: load stored preference when available, default to floating. if let rawStyle = defaults.string(forKey: Keys.searchPanelStyle), let style = GlobalSearchPanelStyle(rawValue: rawStyle) { self.searchPanelStyle = style } else { self.searchPanelStyle = .floating } if let rawMenu = defaults.string(forKey: Keys.systemMenuVisibility), let visibility = SystemMenuVisibility(rawValue: rawMenu) { self.systemMenuVisibility = visibility } else { self.systemMenuVisibility = .visible } // App behavior defaults self.confirmBeforeQuit = defaults.object(forKey: Keys.confirmBeforeQuit) as? Bool ?? true self.launchAtLogin = defaults.object(forKey: Keys.launchAtLogin) as? Bool ?? false // Notifications defaults self.commitMessageNotificationsEnabled = defaults.object(forKey: Keys.notifyCommitMessage) as? Bool ?? true self.titleCommentNotificationsEnabled = defaults.object(forKey: Keys.notifyTitleComment) as? Bool ?? true self.commandCopyNotificationsEnabled = defaults.object(forKey: Keys.notifyCommandCopy) as? Bool ?? true // Claude advanced defaults self.claudeDebug = defaults.object(forKey: Keys.claudeDebug) as? Bool ?? false self.claudeDebugFilter = defaults.string(forKey: Keys.claudeDebugFilter) ?? "" self.claudeVerbose = defaults.object(forKey: Keys.claudeVerbose) as? Bool ?? false if let pm = defaults.string(forKey: Keys.claudePermissionMode) { self.claudePermissionMode = ClaudePermissionMode(rawValue: pm) ?? .default } else { self.claudePermissionMode = .default } self.claudeAllowedTools = defaults.string(forKey: Keys.claudeAllowedTools) ?? "" self.claudeDisallowedTools = defaults.string(forKey: Keys.claudeDisallowedTools) ?? "" self.claudeAddDirs = defaults.string(forKey: Keys.claudeAddDirs) ?? "" self.claudeIDE = defaults.object(forKey: Keys.claudeIDE) as? Bool ?? false self.claudeStrictMCP = defaults.object(forKey: Keys.claudeStrictMCP) as? Bool ?? false self.claudeFallbackModel = defaults.string(forKey: Keys.claudeFallbackModel) ?? "" self.claudeSkipPermissions = defaults.object(forKey: Keys.claudeSkipPermissions) as? Bool ?? false self.claudeAllowSkipPermissions = defaults.object(forKey: Keys.claudeAllowSkipPermissions) as? Bool ?? false self.claudeAllowUnsandboxedCommands = defaults.object(forKey: Keys.claudeAllowUnsandboxedCommands) as? Bool ?? false // Remote hosts let storedHosts = defaults.array(forKey: Keys.enabledRemoteHosts) as? [String] ?? [] self.enabledRemoteHosts = Set(storedHosts) self.promptForWarpTitle = defaults.object(forKey: Keys.warpPromptEnabled) as? Bool ?? false // Local Server Defaults & Migration let legacyPort = defaults.object(forKey: Keys.legacyCLIProxyPort) as? Int let legacyUse = defaults.object(forKey: Keys.legacyUseCLIProxy) as? Bool ?? false self.localServerPort = defaults.object(forKey: Keys.localServerPort) as? Int ?? legacyPort ?? Int(CLIProxyService.defaultPort) self.localServerEnabled = defaults.object(forKey: Keys.localServerEnabled) as? Bool ?? false self.localServerReroute = defaults.object(forKey: Keys.localServerReroute) as? Bool ?? legacyUse // Temporarily disable rerouting API key providers until finalized. self.localServerReroute3P = false defaults.set(false, forKey: Keys.localServerReroute3P) // Default auto-start to true if public server is enabled, or if reroute is on (on-demand implied) self.localServerAutoStart = defaults.object(forKey: Keys.localServerAutoStart) as? Bool ?? true let oauthEnabled = defaults.array(forKey: Keys.oauthProvidersEnabled) as? [String] ?? [] self.oauthProvidersEnabled = Set(oauthEnabled) let oauthAccountsEnabled = defaults.array(forKey: Keys.oauthAccountsEnabled) as? [String] ?? [] self.oauthAccountsEnabled = Set(oauthAccountsEnabled) let apiKeyEnabled = defaults.array(forKey: Keys.apiKeyProvidersEnabled) as? [String] ?? [] self.apiKeyProvidersEnabled = Set(apiKeyEnabled) Task { @MainActor [weak self] in await self?.normalizeProviderSelectionsIfNeeded() } // Now that all properties are initialized, ensure directories exist ensureDirectoryExists(sessionsRoot) ensureDirectoryExists(notesRoot) } private func normalizeProviderSelectionsIfNeeded() async { let registry = ProvidersRegistryService() let providers = await registry.listProviders() let normalize: (String?) -> String? = { UnifiedProviderID.normalize($0, registryProviders: providers) } let nextCommit = normalize(commitProviderId) if nextCommit != commitProviderId { commitProviderId = nextCommit } let nextCodex = normalize(codexProxyProviderId) if nextCodex != codexProxyProviderId { codexProxyProviderId = nextCodex } let nextClaude = normalize(claudeProxyProviderId) if nextClaude != claudeProxyProviderId { claudeProxyProviderId = nextClaude } let nextGemini = normalize(geminiProxyProviderId) if nextGemini != geminiProxyProviderId { geminiProxyProviderId = nextGemini } } private func persist() { defaults.set(sessionsRoot.path, forKey: Keys.sessionsRootPath) defaults.set(notesRoot.path, forKey: Keys.notesRootPath) defaults.set(projectsRoot.path, forKey: Keys.projectsRootPath) } private func persistSessionPaths() { persistJSON(sessionPathConfigs, key: Keys.sessionPathConfigs) } private func persistCLIEnablement() { defaults.set(cliCodexEnabled, forKey: Keys.cliCodexEnabled) defaults.set(cliClaudeEnabled, forKey: Keys.cliClaudeEnabled) defaults.set(cliGeminiEnabled, forKey: Keys.cliGeminiEnabled) } private static func decodeJSON(_ type: T.Type, defaults: UserDefaults, key: String) -> T? { guard let data = defaults.data(forKey: key) else { return nil } return try? JSONDecoder().decode(T.self, from: data) } private func persistJSON(_ value: T, key: String) { guard let data = try? JSONEncoder().encode(value) else { return } defaults.set(data, forKey: key) } private func persistCLIPaths() { setOptionalPath(codexCommandPath, key: Keys.codexCommandPath) setOptionalPath(claudeCommandPath, key: Keys.claudeCommandPath) setOptionalPath(geminiCommandPath, key: Keys.geminiCommandPath) } private func setOptionalPath(_ value: String, key: String) { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { defaults.removeObject(forKey: key) } else { defaults.set(trimmed, forKey: key) } } private func ensureDirectoryExists(_ url: URL) { var isDir: ObjCBool = false if fileManager.fileExists(atPath: url.path, isDirectory: &isDir) { if isDir.boolValue { return } // Remove non-directory item occupying the expected path try? fileManager.removeItem(at: url) } try? fileManager.createDirectory(at: url, withIntermediateDirectories: true) } convenience init(defaults: UserDefaults = .standard) { self.init(defaults: defaults, fileManager: .default) } private static func clampFontSize(_ value: Double) -> Double { return min(max(value, 8.0), 32.0) } static func defaultSessionsRoot(for homeDirectory: URL) -> URL { homeDirectory .appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("sessions", isDirectory: true) } static func defaultNotesRoot(for sessionsRoot: URL) -> URL { // Use real home directory, not sandbox container let home = getRealUserHomeURL() return home.appendingPathComponent(".codmate", isDirectory: true) .appendingPathComponent("notes", isDirectory: true) } static func defaultProjectsRoot(for homeDirectory: URL) -> URL { homeDirectory .appendingPathComponent(".codmate", isDirectory: true) .appendingPathComponent("projects", isDirectory: true) } static func isCommitMessageNotificationEnabled(defaults: UserDefaults = .standard) -> Bool { defaults.object(forKey: Keys.notifyCommitMessage) as? Bool ?? true } static func isTitleCommentNotificationEnabled(defaults: UserDefaults = .standard) -> Bool { defaults.object(forKey: Keys.notifyTitleComment) as? Bool ?? true } static func isCommandCopyNotificationEnabled(defaults: UserDefaults = .standard) -> Bool { defaults.object(forKey: Keys.notifyCommandCopy) as? Bool ?? true } func resolvedCommandOverrideURL(for kind: SessionSource.Kind) -> URL? { let raw: String switch kind { case .codex: raw = codexCommandPath case .claude: raw = claudeCommandPath case .gemini: raw = geminiCommandPath } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } let expanded = expandHomePath(trimmed) guard expanded.contains("/") else { return nil } let url = URL(fileURLWithPath: expanded) return fileManager.isExecutableFile(atPath: url.path) ? url : nil } func preferredExecutablePath(for kind: SessionSource.Kind) -> String { if let override = resolvedCommandOverrideURL(for: kind) { return override.path } return kind.cliExecutableName } /// Get the real user home directory (not sandbox container) nonisolated static func getRealUserHomeURL() -> URL { #if canImport(Darwin) if let homeDir = getpwuid(getuid())?.pointee.pw_dir { let path = String(cString: homeDir) return URL(fileURLWithPath: path, isDirectory: true) } #endif if let home = ProcessInfo.processInfo.environment["HOME"] { return URL(fileURLWithPath: home, isDirectory: true) } return FileManager.default.homeDirectoryForCurrentUser } private func expandHomePath(_ path: String) -> String { if path.hasPrefix("~") { return (path as NSString).expandingTildeInPath } if path.contains("$HOME") { return path.replacingOccurrences(of: "$HOME", with: NSHomeDirectory()) } return path } // Removed: default executable URLs – resolution uses PATH // MARK: - Legacy coercion helpers private static func coerceSandboxMode(_ raw: String) -> SandboxMode? { let v = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if let exact = SandboxMode(rawValue: v) { return exact } switch v { case "full": return SandboxMode.dangerFullAccess case "rw", "write": return SandboxMode.workspaceWrite case "ro", "read": return SandboxMode.readOnly default: return nil } } private static func coerceApprovalPolicy(_ raw: String) -> ApprovalPolicy? { let v = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if let exact = ApprovalPolicy(rawValue: v) { return exact } switch v { case "auto": return ApprovalPolicy.onRequest case "fail", "onfail": return ApprovalPolicy.onFailure default: return nil } } private static func readCodexTopLevelConfigString(_ key: String) -> String? { let url = getRealUserHomeURL() .appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("config.toml", isDirectory: false) guard let text = try? String(contentsOf: url, encoding: .utf8) else { return nil } for raw in text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) { let trimmed = raw.trimmingCharacters(in: CharacterSet.whitespaces) guard trimmed.hasPrefix(key + " ") || trimmed.hasPrefix(key + "=") else { continue } guard let eq = trimmed.firstIndex(of: "=") else { continue } var value = String(trimmed[trimmed.index(after: eq)...]) .trimmingCharacters(in: CharacterSet.whitespaces) if value.hasPrefix("\"") && value.hasSuffix("\"") { value.removeFirst() value.removeLast() } let finalValue = value.trimmingCharacters(in: .whitespacesAndNewlines) if !finalValue.isEmpty { return finalValue } } return nil } // MARK: - Resume Preferences @Published var defaultResumeUseEmbeddedTerminal: Bool { didSet { #if APPSTORE if defaultResumeUseEmbeddedTerminal { defaultResumeUseEmbeddedTerminal = false defaults.set(false, forKey: Keys.resumeUseEmbedded) return } #endif if AppSandbox.isEnabled, defaultResumeUseEmbeddedTerminal { defaultResumeUseEmbeddedTerminal = false defaults.set(false, forKey: Keys.resumeUseEmbedded) return } defaults.set(defaultResumeUseEmbeddedTerminal, forKey: Keys.resumeUseEmbedded) } } @Published var defaultResumeCopyToClipboard: Bool { didSet { defaults.set(defaultResumeCopyToClipboard, forKey: Keys.resumeCopyClipboard) } } @Published var defaultResumeExternalAppId: String { didSet { defaults.set(defaultResumeExternalAppId, forKey: Keys.resumeExternalApp) } } @Published var promptForWarpTitle: Bool { didSet { defaults.set(promptForWarpTitle, forKey: Keys.warpPromptEnabled) } } // MARK: - Local AI Server @Published var localServerEnabled: Bool { didSet { defaults.set(localServerEnabled, forKey: Keys.localServerEnabled) } } @Published var localServerReroute: Bool { didSet { defaults.set(localServerReroute, forKey: Keys.localServerReroute) } } @Published var localServerReroute3P: Bool { didSet { defaults.set(localServerReroute3P, forKey: Keys.localServerReroute3P) } } @Published var localServerAutoStart: Bool { didSet { defaults.set(localServerAutoStart, forKey: Keys.localServerAutoStart) } } @Published var localServerPort: Int { didSet { defaults.set(localServerPort, forKey: Keys.localServerPort) } } @Published var oauthProvidersEnabled: Set { didSet { defaults.set(Array(oauthProvidersEnabled), forKey: Keys.oauthProvidersEnabled) } } @Published var oauthAccountsEnabled: Set { didSet { defaults.set(Array(oauthAccountsEnabled), forKey: Keys.oauthAccountsEnabled) } } @Published var apiKeyProvidersEnabled: Set { didSet { defaults.set(Array(apiKeyProvidersEnabled), forKey: Keys.apiKeyProvidersEnabled) } } @Published var defaultResumeSandboxMode: SandboxMode { didSet { defaults.set(defaultResumeSandboxMode.rawValue, forKey: Keys.resumeSandboxMode) } } @Published var defaultResumeApprovalPolicy: ApprovalPolicy { didSet { defaults.set(defaultResumeApprovalPolicy.rawValue, forKey: Keys.resumeApprovalPolicy) } } @Published var defaultResumeFullAuto: Bool { didSet { defaults.set(defaultResumeFullAuto, forKey: Keys.resumeFullAuto) } } @Published var defaultResumeDangerBypass: Bool { didSet { defaults.set(defaultResumeDangerBypass, forKey: Keys.resumeDangerBypass) } } // Projects: auto-assign new sessions from detail to same project (default ON) @Published var autoAssignNewToSameProject: Bool { didSet { defaults.set(autoAssignNewToSameProject, forKey: Keys.autoAssignNewToSameProject) } } // Visibility for timeline and export markdown @Published var timelineVisibleKinds: Set = MessageVisibilityKind .timelineDefault { didSet { defaults.set( Array(timelineVisibleKinds.map { $0.rawValue }), forKey: Keys.timelineVisibleKinds) } } @Published var markdownVisibleKinds: Set = MessageVisibilityKind .markdownDefault { didSet { defaults.set( Array(markdownVisibleKinds.map { $0.rawValue }), forKey: Keys.markdownVisibleKinds) } } @Published var searchPanelStyle: GlobalSearchPanelStyle { didSet { defaults.set(searchPanelStyle.rawValue, forKey: Keys.searchPanelStyle) } } @Published var statusBarVisibility: StatusBarVisibility { didSet { defaults.set(statusBarVisibility.rawValue, forKey: Keys.statusBarVisibility) } } @Published var systemMenuVisibility: SystemMenuVisibility { didSet { defaults.set(systemMenuVisibility.rawValue, forKey: Keys.systemMenuVisibility) } } @Published var confirmBeforeQuit: Bool { didSet { defaults.set(confirmBeforeQuit, forKey: Keys.confirmBeforeQuit) } } @Published var launchAtLogin: Bool { didSet { defaults.set(launchAtLogin, forKey: Keys.launchAtLogin) LaunchAtLoginService.shared.setLaunchAtLogin(enabled: launchAtLogin) } } // MARK: - Notifications (App) @Published var commitMessageNotificationsEnabled: Bool { didSet { defaults.set(commitMessageNotificationsEnabled, forKey: Keys.notifyCommitMessage) } } @Published var titleCommentNotificationsEnabled: Bool { didSet { defaults.set(titleCommentNotificationsEnabled, forKey: Keys.notifyTitleComment) } } @Published var commandCopyNotificationsEnabled: Bool { didSet { defaults.set(commandCopyNotificationsEnabled, forKey: Keys.notifyCommandCopy) } } @Published var enabledRemoteHosts: Set = [] { didSet { defaults.set(Array(enabledRemoteHosts), forKey: Keys.enabledRemoteHosts) } } var isEmbeddedTerminalEnabled: Bool { !AppSandbox.isEnabled && defaultResumeUseEmbeddedTerminal } var resumeOptions: ResumeOptions { var opt = ResumeOptions( sandbox: defaultResumeSandboxMode, approval: defaultResumeApprovalPolicy, fullAuto: defaultResumeFullAuto, dangerouslyBypass: defaultResumeDangerBypass ) // Carry Claude advanced flags for launch opt.claudeDebug = claudeDebug opt.claudeDebugFilter = claudeDebugFilter.isEmpty ? nil : claudeDebugFilter opt.claudeVerbose = claudeVerbose opt.claudePermissionMode = claudePermissionMode opt.claudeAllowedTools = claudeAllowedTools.isEmpty ? nil : claudeAllowedTools opt.claudeDisallowedTools = claudeDisallowedTools.isEmpty ? nil : claudeDisallowedTools opt.claudeAddDirs = claudeAddDirs.isEmpty ? nil : claudeAddDirs opt.claudeIDE = claudeIDE opt.claudeStrictMCP = claudeStrictMCP opt.claudeFallbackModel = claudeFallbackModel.isEmpty ? nil : claudeFallbackModel opt.claudeSkipPermissions = claudeSkipPermissions opt.claudeAllowSkipPermissions = claudeAllowSkipPermissions opt.claudeAllowUnsandboxedCommands = claudeAllowUnsandboxedCommands return opt } // MARK: - Claude Advanced (Published) @Published var claudeDebug: Bool { didSet { defaults.set(claudeDebug, forKey: Keys.claudeDebug) } } @Published var claudeDebugFilter: String { didSet { defaults.set(claudeDebugFilter, forKey: Keys.claudeDebugFilter) } } @Published var claudeVerbose: Bool { didSet { defaults.set(claudeVerbose, forKey: Keys.claudeVerbose) } } @Published var claudePermissionMode: ClaudePermissionMode { didSet { defaults.set(claudePermissionMode.rawValue, forKey: Keys.claudePermissionMode) } } @Published var claudeAllowedTools: String { didSet { defaults.set(claudeAllowedTools, forKey: Keys.claudeAllowedTools) } } @Published var claudeDisallowedTools: String { didSet { defaults.set(claudeDisallowedTools, forKey: Keys.claudeDisallowedTools) } } @Published var claudeAddDirs: String { didSet { defaults.set(claudeAddDirs, forKey: Keys.claudeAddDirs) } } @Published var claudeIDE: Bool { didSet { defaults.set(claudeIDE, forKey: Keys.claudeIDE) } } @Published var claudeStrictMCP: Bool { didSet { defaults.set(claudeStrictMCP, forKey: Keys.claudeStrictMCP) } } @Published var claudeFallbackModel: String { didSet { defaults.set(claudeFallbackModel, forKey: Keys.claudeFallbackModel) } } @Published var claudeSkipPermissions: Bool { didSet { defaults.set(claudeSkipPermissions, forKey: Keys.claudeSkipPermissions) } } @Published var claudeAllowSkipPermissions: Bool { didSet { defaults.set(claudeAllowSkipPermissions, forKey: Keys.claudeAllowSkipPermissions) } } @Published var claudeAllowUnsandboxedCommands: Bool { didSet { defaults.set(claudeAllowUnsandboxedCommands, forKey: Keys.claudeAllowUnsandboxedCommands) } } // MARK: - Editor Preferences @Published var defaultFileEditor: EditorApp { didSet { defaults.set(defaultFileEditor.rawValue, forKey: Keys.defaultFileEditor) } } // MARK: - Git Review @Published var gitShowLineNumbers: Bool { didSet { defaults.set(gitShowLineNumbers, forKey: Keys.gitShowLineNumbers) } } @Published var gitWrapText: Bool { didSet { defaults.set(gitWrapText, forKey: Keys.gitWrapText) } } @Published var commitPromptTemplate: String { didSet { defaults.set(commitPromptTemplate, forKey: Keys.commitPromptTemplate) } } @Published var commitProviderId: String? { didSet { defaults.set(commitProviderId, forKey: Keys.commitProviderId) } } @Published var commitModelId: String? { didSet { defaults.set(commitModelId, forKey: Keys.commitModelId) } } @Published var codexProxyProviderId: String? { didSet { defaults.set(codexProxyProviderId, forKey: Keys.codexProxyProviderId) } } @Published var codexProxyModelId: String? { didSet { defaults.set(codexProxyModelId, forKey: Keys.codexProxyModelId) } } @Published var claudeProxyProviderId: String? { didSet { defaults.set(claudeProxyProviderId, forKey: Keys.claudeProxyProviderId) } } @Published var claudeProxyModelId: String? { didSet { defaults.set(claudeProxyModelId, forKey: Keys.claudeProxyModelId) } } @Published var geminiProxyProviderId: String? { didSet { defaults.set(geminiProxyProviderId, forKey: Keys.geminiProxyProviderId) } } @Published var geminiProxyModelId: String? { didSet { defaults.set(geminiProxyModelId, forKey: Keys.geminiProxyModelId) } } @Published var claudeProxyModelAliases: [String: [String: String]] { didSet { persistJSON(claudeProxyModelAliases, key: Keys.claudeProxyModelAliases) } } // MARK: - Terminal (DEV) @Published var useEmbeddedCLIConsole: Bool { didSet { #if APPSTORE if useEmbeddedCLIConsole { useEmbeddedCLIConsole = false defaults.set(false, forKey: Keys.terminalUseCLIConsole) return } #endif if !AppSandbox.isEnabled, useEmbeddedCLIConsole { useEmbeddedCLIConsole = false defaults.set(false, forKey: Keys.terminalUseCLIConsole) return } if AppSandbox.isEnabled, useEmbeddedCLIConsole { useEmbeddedCLIConsole = false defaults.set(false, forKey: Keys.terminalUseCLIConsole) return } defaults.set(useEmbeddedCLIConsole, forKey: Keys.terminalUseCLIConsole) } } @Published var terminalFontName: String { didSet { defaults.set(terminalFontName, forKey: Keys.terminalFontName) } } @Published var terminalFontSize: Double { didSet { let clamped = SessionPreferencesStore.clampFontSize(terminalFontSize) if clamped != terminalFontSize { terminalFontSize = clamped return } defaults.set(terminalFontSize, forKey: Keys.terminalFontSize) } } @Published var terminalCursorStyleRaw: String { didSet { defaults.set(terminalCursorStyleRaw, forKey: Keys.terminalCursorStyle) } } @Published var terminalThemeName: String { didSet { defaults.set(terminalThemeName, forKey: Keys.terminalThemeName) } } @Published var terminalThemeNameLight: String { didSet { defaults.set(terminalThemeNameLight, forKey: Keys.terminalThemeNameLight) } } @Published var terminalUsePerAppearanceTheme: Bool { didSet { defaults.set(terminalUsePerAppearanceTheme, forKey: Keys.terminalUsePerAppearanceTheme) } } var terminalCursorStyleOption: TerminalCursorStyleOption { get { TerminalCursorStyleOption(rawValue: terminalCursorStyleRaw) ?? .blinkBlock } set { terminalCursorStyleRaw = newValue.rawValue } } var clampedTerminalFontSize: CGFloat { CGFloat(SessionPreferencesStore.clampFontSize(terminalFontSize)) } // MARK: - Session Path Configs /// Load session path configs with migration from legacy settings private static func loadSessionPathConfigs( defaults: UserDefaults, fileManager: FileManager, homeURL: URL, currentSessionsRoot: URL ) -> [SessionPathConfig] { // Try to load existing configs if let data = defaults.data(forKey: Keys.sessionPathConfigs), let configs = try? JSONDecoder().decode([SessionPathConfig].self, from: data), !configs.isEmpty { return applyInternalWizardIgnore(to: configs, homeURL: homeURL) } // Migration: generate default configs let codexPath = currentSessionsRoot.path let claudePath = homeURL .appendingPathComponent(".claude", isDirectory: true) .appendingPathComponent("projects", isDirectory: true) .path let geminiPath = homeURL .appendingPathComponent(".gemini", isDirectory: true) .appendingPathComponent("tmp", isDirectory: true) .path let defaults: [SessionPathConfig] = [ SessionPathConfig( kind: .codex, path: codexPath, enabled: true, displayName: "Codex" ), SessionPathConfig( kind: .claude, path: claudePath, enabled: true, displayName: "Claude" ), SessionPathConfig( kind: .gemini, path: geminiPath, enabled: true, displayName: "Gemini" ) ] return applyInternalWizardIgnore(to: defaults, homeURL: homeURL) } private static func applyInternalWizardIgnore( to configs: [SessionPathConfig], homeURL: URL ) -> [SessionPathConfig] { let ignored = InternalWizardPaths.ignoredSubpaths(home: homeURL) guard !ignored.isEmpty else { return configs } return configs.map { config in var updated = config for path in ignored where !updated.ignoredSubpaths.contains(path) { updated.ignoredSubpaths.append(path) } return updated } } /// Get enabled session paths for a specific kind func enabledSessionPaths(for kind: SessionSource.Kind) -> [URL] { sessionPathConfigs .filter { $0.kind == kind && $0.enabled } .compactMap { URL(fileURLWithPath: $0.path) } } /// Get the primary enabled path for a kind (first enabled, or default if none) func primarySessionPath(for kind: SessionSource.Kind) -> URL? { if let enabled = enabledSessionPaths(for: kind).first { return enabled } // Fallback to default path let home = Self.getRealUserHomeURL() switch kind { case .codex: return sessionsRoot case .claude: return home .appendingPathComponent(".claude", isDirectory: true) .appendingPathComponent("projects", isDirectory: true) case .gemini: return home .appendingPathComponent(".gemini", isDirectory: true) .appendingPathComponent("tmp", isDirectory: true) } } /// Get the config for a specific kind (default or custom) func config(for kind: SessionSource.Kind) -> SessionPathConfig? { sessionPathConfigs.first { $0.kind == kind && $0.isDefault } } /// Check if a path should be ignored based on config func shouldIgnorePath(_ absolutePath: String, under config: SessionPathConfig) -> Bool { guard config.enabled else { return true } let lowercasedPath = absolutePath.lowercased() for ignored in config.ignoredSubpaths { let needle = ignored.trimmingCharacters(in: .whitespacesAndNewlines) guard !needle.isEmpty else { continue } if lowercasedPath.contains(needle.lowercased()) { return true } } return false } func isCLIEnabled(_ kind: SessionSource.Kind) -> Bool { switch kind { case .codex: return cliCodexEnabled case .claude: return cliClaudeEnabled case .gemini: return cliGeminiEnabled } } func setCLIEnabled(_ kind: SessionSource.Kind, enabled: Bool) -> Bool { if enabled { switch kind { case .codex: cliCodexEnabled = true case .claude: cliClaudeEnabled = true case .gemini: cliGeminiEnabled = true } return true } let enabledCount = [cliCodexEnabled, cliClaudeEnabled, cliGeminiEnabled].filter { $0 }.count if enabledCount <= 1 { return false } switch kind { case .codex: cliCodexEnabled = false case .claude: cliClaudeEnabled = false case .gemini: cliGeminiEnabled = false } return true } nonisolated static func isCLIEnabled(_ kind: SessionSource.Kind, defaults: UserDefaults = .standard) -> Bool { let codex = defaults.object(forKey: Keys.cliCodexEnabled) as? Bool ?? true let claude = defaults.object(forKey: Keys.cliClaudeEnabled) as? Bool ?? true let gemini = defaults.object(forKey: Keys.cliGeminiEnabled) as? Bool ?? true if !codex && !claude && !gemini { return kind == .codex } switch kind { case .codex: return codex case .claude: return claude case .gemini: return gemini } } } ================================================ FILE: services/SessionProvider.swift ================================================ import Foundation enum SessionProviderCachePolicy: Sendable { case cacheOnly case refresh } struct SessionProviderContext: Sendable { let scope: SessionLoadScope /// Local sessions root (Codex) if applicable. let sessionsRoot: URL? /// Enabled remote hosts for remote providers. let enabledRemoteHosts: Set /// Optional project directories (canonical paths) to narrow enumeration. let projectDirectories: [String]? /// Current date dimension for date-range filtering (created vs. updated). let dateDimension: DateDimension /// Optional date range filter (start/end, inclusive) derived from UI selection. let dateRange: (Date, Date)? /// Optional project filter (single project preferred). let projectIds: Set? /// When true, bypass cache-only shortcuts and touch the filesystem to discover new sessions. let forceFilesystemScan: Bool let cachePolicy: SessionProviderCachePolicy /// Ignored path substrings for filtering during enumeration. let ignoredPaths: [String] } struct SessionProviderResult: Sendable { let summaries: [SessionSummary] /// Best-effort coverage info if the provider can surface it (e.g., SQLite meta). let coverage: SessionIndexCoverage? /// True when results came fully from cache without touching the filesystem. let cacheHit: Bool } protocol SessionProvider: Sendable { var kind: SessionSource.Kind { get } var identifier: String { get } var label: String { get } func load(context: SessionProviderContext) async throws -> SessionProviderResult } ================================================ FILE: services/SessionRipgrepStore.swift ================================================ import Foundation import OSLog actor SessionRipgrepStore { struct Diagnostics: Sendable { let cachedCoverageEntries: Int let cachedToolEntries: Int let cachedTokenEntries: Int let lastCoverageScan: Date? let lastToolScan: Date? let lastTokenScan: Date? } private struct CoverageCacheKey: Hashable { let path: String let monthKey: String } private struct CoverageEntry { let mtime: Date? let days: Set } private struct ToolEntry { let mtime: Date? let count: Int } private struct TokenEntry { let mtime: Date? let snapshot: TokenUsageSnapshot? } private let logger = Logger(subsystem: "io.umate.codmate", category: "RipgrepStore") private let verboseLoggingEnabled = ProcessInfo.processInfo.environment["CODMATE_TRACE_RIPGREP"] == "1" private let decoder = FlexibleDecoders.iso8601Flexible() private let disk = RipgrepDiskCache() private let isoFormatterWithFractional: ISO8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return f }() private let isoFormatterPlain: ISO8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime] return f }() private let monthFormatter: DateFormatter = { let df = DateFormatter() df.dateFormat = "yyyy-MM" return df }() private var coverageCache: [CoverageCacheKey: CoverageEntry] = [:] private var toolCache: [String: ToolEntry] = [:] private var tokenCache: [String: TokenEntry] = [:] private var lastCoverageScan: Date? private var lastToolScan: Date? private var lastTokenScan: Date? func dayCoverage(for monthStart: Date, sessions: [SessionSummary]) async -> [String: Set] { guard !sessions.isEmpty else { return [:] } let monthKey = Self.monthKeyString(for: monthStart) var result: [String: Set] = [:] // Separate sessions into cached and need-scan groups var needScan: [(SessionSummary, Date)] = [] var cacheHits = 0 for session in sessions { if Task.isCancelled { break } guard let mtime = fileModificationDate(for: session.fileURL) else { continue } let cacheKey = CoverageCacheKey(path: session.fileURL.path, monthKey: monthKey) if let cached = coverageCache[cacheKey], Self.datesEqual(cached.mtime, mtime) { result[session.id] = cached.days cacheHits += 1 continue } // Try disk cache if let days = await disk.getCoverage(path: cacheKey.path, monthKey: cacheKey.monthKey, mtime: mtime) { let set = Set(days) coverageCache[cacheKey] = CoverageEntry(mtime: mtime, days: set) result[session.id] = set cacheHits += 1 continue } needScan.append((session, mtime)) } // Log cache performance if verboseLoggingEnabled && !sessions.isEmpty { logger.debug("Coverage cache: \(cacheHits, privacy: .public) hits, \(needScan.count, privacy: .public) misses for \(monthKey, privacy: .public)") } // Batch scan all files that need scanning guard !needScan.isEmpty else { return result } let batchResult = await scanDaysBatch( sessions: needScan.map { $0.0 }, monthKey: monthKey ) // Update cache and results for (session, mtime) in needScan { guard let days = batchResult[session.id] else { continue } let cacheKey = CoverageCacheKey(path: session.fileURL.path, monthKey: monthKey) coverageCache[cacheKey] = CoverageEntry(mtime: mtime, days: days) result[session.id] = days await disk.setCoverage(path: cacheKey.path, monthKey: cacheKey.monthKey, mtime: mtime, days: days) } return result } func toolInvocationCounts(for sessions: [SessionSummary]) async -> [String: Int] { guard !sessions.isEmpty else { return [:] } var output: [String: Int] = [:] var needScan: [(SessionSummary, Date)] = [] // Check cache first for session in sessions { if Task.isCancelled { break } guard let mtime = fileModificationDate(for: session.fileURL) else { continue } let path = session.fileURL.path if let cached = toolCache[path], Self.datesEqual(cached.mtime, mtime) { output[session.id] = cached.count continue } if let persisted = await disk.getToolCount(path: path, mtime: mtime) { toolCache[path] = ToolEntry(mtime: mtime, count: persisted) output[session.id] = persisted continue } needScan.append((session, mtime)) } // Batch scan uncached files guard !needScan.isEmpty else { return output } let batchResult = await countToolInvocationsBatch(sessions: needScan.map { $0.0 }) // Update cache and results for (session, mtime) in needScan { if let count = batchResult[session.id] { toolCache[session.fileURL.path] = ToolEntry(mtime: mtime, count: count) output[session.id] = count await disk.setToolCount(path: session.fileURL.path, mtime: mtime, count: count) } } return output } func latestTokenUsage(in sessions: [SessionSummary]) async -> TokenUsageSnapshot? { guard !sessions.isEmpty else { return nil } for session in sessions { if Task.isCancelled { break } guard let mtime = fileModificationDate(for: session.fileURL) else { continue } let path = session.fileURL.path if let cached = tokenCache[path], Self.datesEqual(cached.mtime, mtime) { if let snapshot = cached.snapshot { return snapshot } continue } do { let snapshot = try await extractTokenUsage(at: session.fileURL) tokenCache[path] = TokenEntry(mtime: mtime, snapshot: snapshot) if let snapshot { return snapshot } } catch is CancellationError { return nil } catch { logger.error("Token usage scan failed for \(path, privacy: .public): \(error.localizedDescription, privacy: .public)") } } return nil } func diagnostics() async -> Diagnostics { Diagnostics( cachedCoverageEntries: coverageCache.count, cachedToolEntries: toolCache.count, cachedTokenEntries: tokenCache.count, lastCoverageScan: lastCoverageScan, lastToolScan: lastToolScan, lastTokenScan: lastTokenScan ) } func resetAll() { coverageCache.removeAll() toolCache.removeAll() tokenCache.removeAll() lastCoverageScan = nil lastToolScan = nil lastTokenScan = nil } func invalidateCoverage(monthKey: String, projectPath: String? = nil) { if let projectPath = projectPath { // Invalidate only entries matching this project path coverageCache = coverageCache.filter { key, _ in !(key.monthKey == monthKey && key.path.hasPrefix(projectPath)) } } else { // Invalidate all entries for this month coverageCache = coverageCache.filter { key, _ in key.monthKey != monthKey } } Task { [monthKey, projectPath] in await disk.invalidateCoverage(monthKey: monthKey, projectPath: projectPath) } } /// Invalidate coverage for specific file paths only (more precise than invalidating entire directories) func invalidateCoverageForFiles(_ filePaths: Set, monthKey: String) { coverageCache = coverageCache.filter { key, _ in !(key.monthKey == monthKey && filePaths.contains(key.path)) } // Disk invalidation for specific files Task { [filePaths] in for path in filePaths { await disk.invalidateCoverage(path: path) } } } /// Invalidate tool counts for specific file paths only func invalidateToolsForFiles(_ filePaths: Set) { for path in filePaths { toolCache.removeValue(forKey: path) } Task { [filePaths] in for path in filePaths { await disk.invalidateTools(path: path) } } } func markFileModified(_ filePath: String) { // Remove from all caches to force rescan coverageCache = coverageCache.filter { $0.key.path != filePath } toolCache.removeValue(forKey: filePath) tokenCache.removeValue(forKey: filePath) Task { [filePath] in await disk.invalidateCoverage(path: filePath) await disk.invalidateTools(path: filePath) } } // MARK: - Private helpers /// Calculate optimal batch size based on average file size private func calculateBatchSize(for sessions: [SessionSummary]) -> Int { guard !sessions.isEmpty else { return 30 } // Sample up to 10 files to estimate average size let sampleSize = min(10, sessions.count) let samples = sessions.prefix(sampleSize) var totalBytes: UInt64 = 0 var validSamples = 0 for session in samples { if let attrs = try? FileManager.default.attributesOfItem(atPath: session.fileURL.path), let fileSize = attrs[.size] as? UInt64 { totalBytes += fileSize validSamples += 1 } } guard validSamples > 0 else { return 30 } let avgBytes = totalBytes / UInt64(validSamples) let avgKB = avgBytes / 1024 // Dynamic batch sizing: // - Small files (<100KB): 50 files/batch // - Medium files (100-500KB): 30 files/batch // - Large files (>500KB): 15 files/batch if avgKB < 100 { return 50 } else if avgKB < 500 { return 30 } else { return 15 } } private func scanDaysBatch(sessions: [SessionSummary], monthKey: String) async -> [String: Set] { guard !sessions.isEmpty else { return [:] } // Build file path to session ID mapping var pathToSessionID: [String: String] = [:] var filePaths: [String] = [] for session in sessions { let path = session.fileURL.path pathToSessionID[path] = session.id filePaths.append(path) } // Dynamic batch size based on file sizes let batchSize = calculateBatchSize(for: sessions) let batches = stride(from: 0, to: filePaths.count, by: batchSize).map { Array(filePaths[$0..] = [:] for (index, batch) in batches.enumerated() { if Task.isCancelled { break } // Add delay between batches to spread CPU load if index > 0 { try? await Task.sleep(nanoseconds: 50_000_000) // 50ms delay between batches } let pattern = #"\"timestamp\"\s*:\s*\"\#(monthKey)-(?:[0-3][0-9])T[^\"]+\""# let args = [ "--no-heading", "--with-filename", // Include filename in output "--no-line-number", "--color", "never", "--pcre2", "--only-matching", pattern ] + batch do { let lines = try await RipgrepRunner.run(arguments: args) guard !lines.isEmpty else { continue } // Parse batch results: each line is "filepath:timestamp" var fileToMatches: [String: [String]] = [:] for line in lines { guard let colonIndex = line.firstIndex(of: ":") else { continue } let filePath = String(line[.. [String: Int] { guard !sessions.isEmpty else { return [:] } var pathToSessionID: [String: String] = [:] var filePaths: [String] = [] for session in sessions { let path = session.fileURL.path pathToSessionID[path] = session.id filePaths.append(path) } // Dynamic batch size based on file sizes let batchSize = calculateBatchSize(for: sessions) let batches = stride(from: 0, to: filePaths.count, by: batchSize).map { Array(filePaths[$0.. 0 { try? await Task.sleep(nanoseconds: 50_000_000) } let pattern = #"\"type\"\s*:\s*\"(?:function_call|tool_call|tool_output)""# let args = [ "--no-heading", "--with-filename", "--no-line-number", "--color", "never", "--pcre2", "--count", // Use --count for efficiency pattern ] + batch do { let lines = try await RipgrepRunner.run(arguments: args) for line in lines { // Parse "filepath:count" format guard let colonIndex = line.firstIndex(of: ":") else { continue } let filePath = String(line[.. Set? { let pattern = #"\"timestamp\"\s*:\s*\"\#(monthKey)-(?:[0-3][0-9])T[^\"]+\""# let args = [ "--no-heading", "--no-filename", "--no-line-number", "--color", "never", "--pcre2", "--only-matching", pattern, url.path ] let start = Date() do { let lines = try await RipgrepRunner.run(arguments: args) guard !lines.isEmpty else { return nil } lastCoverageScan = Date() logger.debug("Scanned \(url.lastPathComponent, privacy: .public) for \(monthKey, privacy: .public) in \(-start.timeIntervalSinceNow, privacy: .public)s") let days = parseDays(from: lines, monthKey: monthKey) guard !days.isEmpty else { return nil } return days } catch is CancellationError { return nil } catch { logger.error("Ripgrep coverage scan failed for \(url.lastPathComponent, privacy: .public): \(error.localizedDescription, privacy: .public)") return nil } } private func countToolInvocations(at url: URL) async throws -> Int { let pattern = #"\"type\"\s*:\s*\"(?:function_call|tool_call|tool_output)""# let args = [ "--no-heading", "--no-filename", "--no-line-number", "--color", "never", "--pcre2", pattern, url.path ] let lines = try await RipgrepRunner.run(arguments: args) lastToolScan = Date() return lines.count } private func extractTokenUsage(at url: URL) async throws -> TokenUsageSnapshot? { let pattern = #"\"type\"\s*:\s*\"token_count""# let args = [ "--no-heading", "--no-filename", "--color", "never", "--pcre2", pattern, url.path ] let lines = try await RipgrepRunner.run(arguments: args) lastTokenScan = Date() guard !lines.isEmpty else { return nil } var latest: TokenUsageSnapshot? for line in lines { guard let data = line.data(using: .utf8), let row = try? decoder.decode(SessionRow.self, from: data) else { continue } guard case let .eventMessage(payload) = row.kind else { continue } if let snapshot = TokenUsageSnapshotBuilder.build(timestamp: row.timestamp, payload: payload) { latest = snapshot } } return latest } private func parseDays(from lines: [String], monthKey: String) -> Set { var days: Set = [] for line in lines { guard let timestamp = extractTimestamp(from: line) else { continue } guard let date = parseISODate(timestamp) else { continue } let monthOfDate = monthFormatter.string(from: date) guard monthOfDate == monthKey else { continue } let day = Calendar.current.component(.day, from: date) days.insert(day) } return days } private func extractTimestamp(from line: String) -> String? { let prefix = "\"timestamp\":\"" guard let range = line.range(of: prefix) else { return nil } let start = range.upperBound guard let end = line[start...].firstIndex(of: "\"") else { return nil } return String(line[start.. Date? { if let date = isoFormatterWithFractional.date(from: string) { return date } return isoFormatterPlain.date(from: string) } private func fileModificationDate(for url: URL) -> Date? { let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]) return values?.contentModificationDate } private static func monthKeyString(for date: Date) -> String { let cal = Calendar.current let comps = cal.dateComponents([.year, .month], from: date) let year = comps.year ?? 0 let month = comps.month ?? 0 return String(format: "%04d-%02d", year, month) } private static func datesEqual(_ lhs: Date?, _ rhs: Date?) -> Bool { switch (lhs, rhs) { case (.none, .none): return true case let (.some(a), .some(b)): return abs(a.timeIntervalSince(b)) < 0.0001 default: return false } } } ================================================ FILE: services/SessionTimelineLoader.swift ================================================ import Foundation struct SessionTimelineLoader { private let decoder: JSONDecoder private let skippedEventTypes: Set = [ "reasoning_output" ] private let turnBoundaryMetadataKey = "turn_boundary" init() { decoder = FlexibleDecoders.iso8601Flexible() } func load(url: URL) throws -> [ConversationTurn] { let events = try decodeEvents(url: url) return group(events: events) } func turns(from rows: [SessionRow]) -> [ConversationTurn] { let events = rows.compactMap { makeEvent(from: $0) } return group(events: events) } private func decodeEvents(url: URL) throws -> [TimelineEvent] { let data = try Data(contentsOf: url, options: [.mappedIfSafe]) guard !data.isEmpty else { return [] } let newline: UInt8 = 0x0A let carriageReturn: UInt8 = 0x0D var events: [TimelineEvent] = [] for var slice in data.split(separator: newline, omittingEmptySubsequences: true) { if slice.last == carriageReturn { slice = slice.dropLast() } guard !slice.isEmpty else { continue } guard let row = try? decoder.decode(SessionRow.self, from: Data(slice)) else { continue } guard let event = makeEvent(from: row) else { continue } events.append(event) } return events } private func makeEvent(from row: SessionRow) -> TimelineEvent? { switch row.kind { case .sessionMeta: return nil case .assistantMessage: // Assistant messages are handled by response_item events; skip here to avoid duplicates return nil case let .turnContext(payload): var parts: [String] = [] if let model = payload.model { parts.append("model: \(model)") } if let ap = payload.approvalPolicy { parts.append("policy: \(ap)") } if let cwd = payload.cwd { parts.append("cwd: \(cwd)") } if let summary = payload.summary, !summary.isEmpty { parts.append(summary) } let text = parts.joined(separator: "\n") guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } // Turn Context is already surfaced in the Environment Context section; // skip adding it to the conversation timeline. return nil case let .eventMessage(payload): let type = payload.type.lowercased() if type == "turn_boundary" { var metadata: [String: String] = [turnBoundaryMetadataKey: "1"] if let kind = payload.kind, !kind.isEmpty { metadata["boundary_kind"] = kind } if let identifier = payload.message, !identifier.isEmpty { metadata["boundary_message_id"] = identifier } return TimelineEvent( id: UUID().uuidString, timestamp: row.timestamp, actor: .info, title: nil, text: nil, metadata: metadata ) } if skippedEventTypes.contains(type) { return nil } if type == "token_count" { return makeTokenCountEvent(timestamp: row.timestamp, payload: payload) } if type == "turn_aborted" || type == "turn aborted" || type == "compaction" || type == "compacted" { return nil } if type == "agent_reasoning" { let reasoning = cleanedText(payload.text ?? payload.message ?? "") guard !reasoning.isEmpty else { return nil } return TimelineEvent( id: UUID().uuidString, timestamp: row.timestamp, actor: .info, title: "Agent Reasoning", text: reasoning, metadata: nil, visibilityKind: .reasoning ) } if type == "ghost_snapshot" || type == "ghost snapshot" { return nil } if type == "environment_context" { if let env = payload.message ?? payload.text { return makeEnvironmentContextEvent(text: env, timestamp: row.timestamp) } return nil } let message = cleanedAssistantText(payload.message ?? payload.text ?? payload.reason ?? "") let attachments = attachments(from: payload) guard !message.isEmpty || !attachments.isEmpty else { return nil } let displayMessage = message.isEmpty ? "[Image]" : message let mappedKind = MessageVisibilityKind.mappedKind( rawType: payload.type, title: payload.kind ?? payload.type, metadata: nil ) let effectiveKind: MessageVisibilityKind? = { guard mappedKind == .tool else { return mappedKind } if containsCodeEditMarkers(message) || containsStrongEditOutputMarkers(message) { return .codeEdit } return mappedKind }() switch type { case "user_message": return TimelineEvent( id: UUID().uuidString, timestamp: row.timestamp, actor: .user, title: nil, text: displayMessage, metadata: nil, repeatCount: repeatCountHint(from: payload.info), attachments: attachments, visibilityKind: effectiveKind ?? .user ) case "agent_message": return TimelineEvent( id: UUID().uuidString, timestamp: row.timestamp, actor: .assistant, title: nil, text: displayMessage, metadata: nil, repeatCount: repeatCountHint(from: payload.info), attachments: attachments, visibilityKind: effectiveKind ?? .assistant ) default: let actor = effectiveKind?.defaultActor ?? .info return TimelineEvent( id: UUID().uuidString, timestamp: row.timestamp, actor: actor, title: payload.type, text: displayMessage, metadata: nil, attachments: attachments, visibilityKind: effectiveKind ) } case let .responseItem(payload): let type = payload.type.lowercased() if skippedEventTypes.contains(type) { return nil } if type == "ghost_snapshot" || type == "ghost snapshot" { return nil } if type == "reasoning", let summary = payload.summary, !summary.isEmpty, (payload.content == nil || payload.content?.isEmpty == true) { // Codex emits duplicate reasoning in response_item (summary only) + event_msg. // Keep the event_msg version and skip the summary-only duplicate. return nil } if type == "message" { let text = cleanedAssistantText(joinedText(from: payload.content ?? [])) guard !text.isEmpty else { return nil } if payload.role?.lowercased() == "user" { if let environment = makeEnvironmentContextEvent(text: text, timestamp: row.timestamp) { return environment } // event_msg already covers user content; skip to avoid duplicates return nil } return TimelineEvent( id: UUID().uuidString, timestamp: row.timestamp, actor: .assistant, title: nil, text: text, metadata: nil, visibilityKind: .assistant ) } let contentText = cleanedText(joinedText(from: payload.content ?? [])) let summaryText = cleanedText(joinedSummary(from: payload.summary ?? [])) let fallbackText = responseFallbackText(payload) let mappedKind = MessageVisibilityKind.mappedKind( rawType: payload.type, title: payload.type, metadata: nil ) let detectionText: String = { if !contentText.isEmpty { return contentText } if !summaryText.isEmpty { return summaryText } return fallbackText }() let resolvedKind: MessageVisibilityKind? = { guard mappedKind == .tool else { return mappedKind } if isCodeEdit(payload: payload, fallbackText: detectionText) { return .codeEdit } return mappedKind }() let baseText: String if resolvedKind == .tool || resolvedKind == .codeEdit { if !contentText.isEmpty { baseText = contentText } else if !summaryText.isEmpty { baseText = summaryText } else { baseText = "" } } else { if !contentText.isEmpty { baseText = contentText } else if !summaryText.isEmpty { baseText = summaryText } else { baseText = fallbackText } } let bodyText: String if resolvedKind == .tool || resolvedKind == .codeEdit { let toolText = toolDisplayText(payload: payload, fallback: baseText) bodyText = toolText } else { bodyText = baseText } guard !bodyText.isEmpty else { return nil } let actor = resolvedKind?.defaultActor ?? .info return TimelineEvent( id: UUID().uuidString, timestamp: row.timestamp, actor: actor, title: payload.type, text: bodyText, metadata: nil, visibilityKind: resolvedKind, callID: payload.callID ) case .unknown: return nil } } private func group(events: [TimelineEvent]) -> [ConversationTurn] { var turns: [ConversationTurn] = [] var currentUser: TimelineEvent? var pendingOutputs: [TimelineEvent] = [] // Use a stable, content-agnostic key per turn to preserve UI expansion state // across reloads when outputs are appended (commonly the last turn). var seenTurnKeys: [String: Int] = [:] func stableTurnID(anchor timestamp: Date, hasUser: Bool) -> String { let millis = Int(timestamp.timeIntervalSince1970 * 1000) let baseKey = "\(millis)-\(hasUser ? "u" : "o")" let seq = (seenTurnKeys[baseKey] ?? 0) + 1 seenTurnKeys[baseKey] = seq return "t-\(baseKey)-\(seq)" } func flushTurn() { guard currentUser != nil || !pendingOutputs.isEmpty else { return } let timestamp = currentUser?.timestamp ?? pendingOutputs.first?.timestamp ?? Date() let id = stableTurnID(anchor: timestamp, hasUser: currentUser != nil) let turn = ConversationTurn( id: id, timestamp: timestamp, userMessage: currentUser, outputs: pendingOutputs ) turns.append(turn) currentUser = nil pendingOutputs = [] } let ordered = events.sorted(by: { $0.timestamp < $1.timestamp }) let mergedTools = mergeToolInvocations(in: ordered) let deduped = collapseDuplicates(mergeConsecutiveUserMessages(mergedTools)) for event in deduped { if event.title == TimelineEvent.environmentContextTitle { continue } if event.metadata?[turnBoundaryMetadataKey] == "1" { if currentUser == nil { flushTurn() } continue } if event.actor == .user { flushTurn() currentUser = event } else { pendingOutputs.append(event) } } flushTurn() return turns } private func mergeToolInvocations(in events: [TimelineEvent]) -> [TimelineEvent] { var result: [TimelineEvent] = [] var pendingByCallID: [String: Int] = [:] for event in events { guard isToolLike(event.visibilityKind), let callID = event.callID, !callID.isEmpty else { result.append(event) continue } if isToolOutputEvent(event), let index = pendingByCallID[callID] { let merged = mergeToolOutput(into: result[index], output: event) result[index] = merged continue } pendingByCallID[callID] = result.count result.append(event) } return result } private func isToolLike(_ kind: MessageVisibilityKind) -> Bool { switch kind { case .tool, .codeEdit: return true default: return false } } private func isToolOutputEvent(_ event: TimelineEvent) -> Bool { let type = (event.title ?? "").lowercased() if type.isEmpty { return false } if type.contains("output") || type.contains("result") { return true } return false } private func mergeToolOutput(into callEvent: TimelineEvent, output: TimelineEvent) -> TimelineEvent { let callText = callEvent.text ?? "" let outputText = output.text ?? "" let mergedText: String if outputText.isEmpty { mergedText = callText } else if callText.isEmpty { mergedText = outputText } else if callText.contains(outputText) { mergedText = callText } else { mergedText = [callText, outputText].joined(separator: "\n\n") } return TimelineEvent( id: callEvent.id, timestamp: callEvent.timestamp, actor: callEvent.actor, title: callEvent.title, text: mergedText, metadata: callEvent.metadata, repeatCount: callEvent.repeatCount, attachments: callEvent.attachments, visibilityKind: callEvent.visibilityKind, callID: callEvent.callID ) } private func cleanedText(_ text: String) -> String { guard !text.isEmpty else { return text } return text .replacingOccurrences(of: "", with: "") .replacingOccurrences(of: "", with: "") .trimmingCharacters(in: .whitespacesAndNewlines) } private func cleanedAssistantText(_ text: String) -> String { let base = cleanedText(text) return stripTaggedBlocks( base, tags: [ "permissions_instructions", "permissions instructions", "collaboration_mode", "collaboration mode" ] ) .trimmingCharacters(in: .whitespacesAndNewlines) } private func stripTaggedBlocks(_ text: String, tags: [String]) -> String { var result = text for tag in tags { result = stripTaggedBlock(result, tag: tag) } return result } private func stripTaggedBlock(_ text: String, tag: String) -> String { let lowerTag = tag.lowercased() let openToken = "<\(lowerTag)>" let closeToken = "" var output = text while let openRange = output.lowercased().range(of: openToken) { if let closeRange = output.lowercased().range(of: closeToken, range: openRange.upperBound.. String { blocks.compactMap { $0.text }.joined(separator: "\n\n") } private func joinedSummary(from items: [ResponseSummaryItem]) -> String { items.compactMap { $0.text }.joined(separator: "\n\n") } private func responseFallbackText(_ payload: ResponseItemPayload) -> String { var lines: [String] = [] if let name = payload.name, !name.isEmpty { lines.append("name: \(name)") } if let args = renderValue(payload.arguments), !args.isEmpty { lines.append(formatLabel("arguments", value: args)) } if let input = renderValue(payload.input), !input.isEmpty { lines.append(formatLabel("input", value: input)) } if let output = renderValue(payload.output), !output.isEmpty { lines.append(formatLabel("output", value: output)) } if let ghost = renderValue(payload.ghostCommit), !ghost.isEmpty { lines.append(formatLabel("ghost_commit", value: ghost)) } if lines.isEmpty, let callID = payload.callID, !callID.isEmpty { lines.append("call_id: \(callID)") } return lines.joined(separator: "\n") } private func toolDisplayText(payload: ResponseItemPayload, fallback: String) -> String { var lines: [String] = [] if let name = payload.name, !name.isEmpty { lines.append("name: \(name)") } let argumentValue = payload.arguments ?? payload.input if let args = renderValue(argumentValue), !args.isEmpty { lines.append(formatLabel("arguments", value: args)) } if let output = renderValue(payload.output), !output.isEmpty { lines.append(formatLabel("output", value: output)) } if lines.isEmpty { return fallback } let composed = lines.joined(separator: "\n") guard !fallback.isEmpty else { return composed } if fallback == composed { return composed } if composed.contains(fallback) { return composed } return [composed, fallback].joined(separator: "\n") } private func isCodeEdit(payload: ResponseItemPayload, fallbackText: String) -> Bool { let name = normalizeToolName(payload.name) if codeEditToolNames.contains(name) { return true } if containsEditKeys(payload.arguments) || containsEditKeys(payload.input) { return true } if name == "execcommand" || name == "bash" || name == "runshellcommand" { let argsText = stringValue(payload.arguments) ?? "" if containsCodeEditMarkers(argsText) { return true } } if let outputText = stringValue(payload.output), containsStrongEditOutputMarkers(outputText) { return true } if containsCodeEditMarkers(fallbackText) { return true } return false } private var codeEditToolNames: Set { [ "edit", "write", "replace", "applypatch", "patch", "createfile", "writefile", "deletefile", "fileedit", "filewrite", "updatefile", "insert", "append", "move", "rename", "remove", "multiedit" ] } private func normalizeToolName(_ name: String?) -> String { let raw = name?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" if raw.isEmpty { return "" } return raw .replacingOccurrences(of: "_", with: "") .replacingOccurrences(of: "-", with: "") .replacingOccurrences(of: " ", with: "") } private func containsEditKeys(_ value: JSONValue?) -> Bool { guard let value else { return false } switch value { case .object(let dict): let keys = Set(dict.keys.map { $0.lowercased() }) let hasPath = keys.contains("file_path") || keys.contains("filepath") || keys.contains("path") let hasOldNew = keys.contains("old_string") || keys.contains("new_string") let hasPatch = keys.contains("patch") || keys.contains("diff") let hasContent = keys.contains("content") || keys.contains("new_content") || keys.contains("text") if hasOldNew || hasPatch { return true } if hasPath && hasContent { return true } return dict.values.contains { containsEditKeys($0) } case .array(let array): return array.contains { containsEditKeys($0) } default: return false } } private func containsCodeEditMarkers(_ text: String) -> Bool { let lowered = text.lowercased() if lowered.contains("*** begin patch") { return true } if lowered.contains("*** update file") { return true } if lowered.contains("*** add file") { return true } if lowered.contains("*** delete file") { return true } if lowered.contains("update file:") { return true } return false } private func containsStrongEditOutputMarkers(_ text: String) -> Bool { let lowered = text.lowercased() if lowered.contains("updated the following files") { return true } if lowered.contains("success. updated the following files") { return true } return false } private func stringValue(_ value: JSONValue?) -> String? { guard let value else { return nil } switch value { case .string(let string): return string case .number(let number): return String(number) case .bool(let flag): return flag ? "true" : "false" case .array, .object, .null: return nil } } private func formatLabel(_ label: String, value: String) -> String { value.contains("\n") ? "\(label):\n\(value)" : "\(label): \(value)" } private func attachments(from payload: EventMessagePayload) -> [TimelineAttachment] { guard let images = payload.images, !images.isEmpty else { return [] } return images.enumerated().compactMap { index, raw in let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } let label = "Image \(index + 1)" if let url = URL(string: trimmed), let scheme = url.scheme, scheme != "data" { return TimelineAttachment(kind: .image, label: label, url: url) } if trimmed.hasPrefix("/") { return TimelineAttachment(kind: .image, label: label, url: URL(fileURLWithPath: trimmed)) } return TimelineAttachment(kind: .image, label: label, dataURL: trimmed) } } private func renderValue(_ value: JSONValue?) -> String? { guard let value else { return nil } switch value { case .string(let string): return string case .number(let number): return String(number) case .bool(let flag): return flag ? "true" : "false" case .null: return nil case .array, .object: let raw = toAny(value) guard JSONSerialization.isValidJSONObject(raw), let data = try? JSONSerialization.data(withJSONObject: raw, options: [.prettyPrinted, .sortedKeys]), let text = String(data: data, encoding: .utf8) else { return nil } return text } } private func toAny(_ value: JSONValue) -> Any { switch value { case .string(let string): return string case .number(let number): return number case .bool(let flag): return flag case .array(let array): return array.map(toAny) case .object(let dict): return dict.mapValues(toAny) case .null: return NSNull() } } /// Merge consecutive user messages with the exact same timestamp /// This handles Claude Code's behavior of splitting a single user input into multiple JSONL records /// with identical timestamps (down to millisecond precision) private func mergeConsecutiveUserMessages(_ events: [TimelineEvent]) -> [TimelineEvent] { guard !events.isEmpty else { return [] } var result: [TimelineEvent] = [] var pendingUserMessages: [TimelineEvent] = [] var lastUserTimestamp: Date? func flushPendingUserMessages() { guard !pendingUserMessages.isEmpty else { return } // Filter out auto-generated image description messages // (text-only messages that start with "[Image:") let realMessages = pendingUserMessages.filter { event in if event.attachments.isEmpty, let text = event.text?.trimmingCharacters(in: .whitespacesAndNewlines), text.hasPrefix("[Image:") { return false // Skip auto-generated image description } return true } if realMessages.count == 1 { // Only one real message, use it as-is result.append(realMessages[0]) } else if realMessages.count > 1 { // Multiple messages need to be merged let first = realMessages[0] let mergedText = realMessages.compactMap { $0.text }.filter { !$0.isEmpty }.joined(separator: "\n\n") var mergedAttachments: [TimelineAttachment] = [] for event in realMessages { mergedAttachments.append(contentsOf: event.attachments) } // Create merged event let merged = TimelineEvent( id: first.id, timestamp: first.timestamp, actor: first.actor, title: first.title, text: mergedText.isEmpty ? nil : mergedText, metadata: first.metadata, repeatCount: first.repeatCount, attachments: mergedAttachments, visibilityKind: first.visibilityKind, callID: first.callID ) result.append(merged) } pendingUserMessages.removeAll() lastUserTimestamp = nil } for event in events { if event.actor == .user { // Check if this user message has the exact same timestamp as pending ones if let lastTimestamp = lastUserTimestamp { // Compare timestamps with exact equality (millisecond precision) if event.timestamp == lastTimestamp { pendingUserMessages.append(event) continue } } // Different timestamp, flush pending and start new batch flushPendingUserMessages() pendingUserMessages.append(event) lastUserTimestamp = event.timestamp } else { // Non-user message, flush pending user messages first flushPendingUserMessages() result.append(event) } } // Flush any remaining pending user messages flushPendingUserMessages() return result } private func collapseDuplicates(_ events: [TimelineEvent]) -> [TimelineEvent] { guard !events.isEmpty else { return [] } var result: [TimelineEvent] = [] for event in events { if let last = result.last, last.actor == event.actor, last.title == event.title, (last.text ?? "") == (event.text ?? ""), normalize(metadata: last.metadata) == normalize(metadata: event.metadata) { result[result.count - 1] = last.incrementingRepeatCount() } else { result.append(event) } } return result } private func normalize(metadata: [String: String]?) -> [String: String] { metadata?.filter { !$0.value.isEmpty } ?? [:] } private func repeatCountHint(from info: JSONValue?) -> Int { guard let info else { return 1 } if case let .object(dict) = info, let value = dict["repeat_count"] { switch value { case .number(let number): return max(1, Int(number.rounded())) case .string(let string): if let parsed = Double(string) { return max(1, Int(parsed.rounded())) } case .bool(let flag): return flag ? 1 : 1 default: break } } return 1 } private func makeEnvironmentContextEvent(text: String, timestamp: Date) -> TimelineEvent? { guard let rangeStart = text.range(of: ""), let rangeEnd = text.range(of: "") else { return nil } let inner = text[rangeStart.upperBound..\\s*([^<]+?)\\s*", options: []) var metadata: [String: String] = [:] if let regex { let nsString = NSString(string: String(inner)) let matches = regex.matches(in: String(inner), range: NSRange(location: 0, length: nsString.length)) for match in matches where match.numberOfRanges >= 3 { let key = nsString.substring(with: match.range(at: 1)) var value = nsString.substring(with: match.range(at: 2)) value = value.trimmingCharacters(in: .whitespacesAndNewlines) metadata[key] = value } } let sortedEntries = metadata.sorted(by: { $0.key < $1.key }) let textLines = sortedEntries .map { "\($0.key): \($0.value)" } .joined(separator: "\n") let displayText = textLines.isEmpty ? cleanedText(String(inner)) : textLines return TimelineEvent( id: UUID().uuidString, timestamp: timestamp, actor: .info, title: TimelineEvent.environmentContextTitle, text: displayText.isEmpty ? nil : displayText, metadata: metadata.isEmpty ? nil : metadata, visibilityKind: .environmentContext ) } private func makeTokenCountEvent(timestamp: Date, payload: EventMessagePayload) -> TimelineEvent? { let infoDict = flatten(json: payload.info) let rateDict = flatten(json: payload.rateLimits, prefix: "rate_") let combined = infoDict.merging(rateDict) { current, _ in current } guard !combined.isEmpty else { return nil } return TimelineEvent( id: UUID().uuidString, timestamp: timestamp, actor: .info, title: "Token Usage", text: nil, metadata: combined, visibilityKind: .tokenUsage ) } private func flatten(json: JSONValue?, prefix: String = "") -> [String: String] { guard let json else { return [:] } var result: [String: String] = [:] switch json { case .string(let value): result[prefix.isEmpty ? "value" : prefix] = value case .number(let value): let key = prefix.isEmpty ? "value" : prefix result[key] = String(value) case .bool(let value): let key = prefix.isEmpty ? "value" : prefix result[key] = value ? "true" : "false" case .object(let dict): for (key, value) in dict { let newPrefix = prefix.isEmpty ? key : "\(prefix)\(key.capitalized)" result.merge(flatten(json: value, prefix: newPrefix)) { current, _ in current } } case .array(let array): for (index, value) in array.enumerated() { let newPrefix = prefix.isEmpty ? "item\(index)" : "\(prefix)\(index)" result.merge(flatten(json: value, prefix: newPrefix)) { current, _ in current } } case .null: break } return result } func loadInstructions(url: URL) throws -> String? { let data = try Data(contentsOf: url, options: [.mappedIfSafe]) guard !data.isEmpty else { return nil } let newline: UInt8 = 0x0A let carriageReturn: UInt8 = 0x0D for var slice in data.split(separator: newline, omittingEmptySubsequences: true) { if slice.last == carriageReturn { slice = slice.dropLast() } guard !slice.isEmpty else { continue } if let row = try? decoder.decode(SessionRow.self, from: Data(slice)) { if case let .sessionMeta(payload) = row.kind, let instructions = payload.instructions { let cleaned = cleanedText(instructions) if !cleaned.isEmpty { return cleaned } } } } return nil } func loadEnvironmentContext(from rows: [SessionRow]) -> EnvironmentContextInfo? { var latest: TimelineEvent? for row in rows { switch row.kind { case let .turnContext(payload): // Extract environment context from turnContext (for Gemini sessions) var metadata: [String: String] = [:] if let model = payload.model { metadata["model"] = model } if let cwd = payload.cwd { metadata["cwd"] = cwd } if let approval = payload.approvalPolicy { metadata["approval"] = approval } if !metadata.isEmpty { var textParts: [String] = [] if let model = metadata["model"] { textParts.append("model: \(model)") } if let cwd = metadata["cwd"] { textParts.append("cwd: \(cwd)") } if let approval = metadata["approval"] { textParts.append("approval: \(approval)") } latest = TimelineEvent( id: UUID().uuidString, timestamp: row.timestamp, actor: .info, title: TimelineEvent.environmentContextTitle, text: textParts.joined(separator: "\n"), metadata: metadata ) } case let .eventMessage(payload): let type = payload.type.lowercased() if type == "environment_context", let envText = payload.message ?? payload.text, let event = makeEnvironmentContextEvent(text: envText, timestamp: row.timestamp) { latest = event } case let .responseItem(payload): if payload.type.lowercased() == "message" { let text = joinedText(from: payload.content ?? []) guard text.contains(" EnvironmentContextInfo? { let data = try Data(contentsOf: url, options: [.mappedIfSafe]) guard !data.isEmpty else { return nil } let newline: UInt8 = 0x0A let carriageReturn: UInt8 = 0x0D var latest: TimelineEvent? for var slice in data.split(separator: newline, omittingEmptySubsequences: true) { if slice.last == carriageReturn { slice = slice.dropLast() } guard !slice.isEmpty else { continue } guard let row = try? decoder.decode(SessionRow.self, from: Data(slice)) else { continue } switch row.kind { case let .turnContext(payload): // Extract environment context from turnContext (for Gemini sessions) var metadata: [String: String] = [:] if let model = payload.model { metadata["model"] = model } if let cwd = payload.cwd { metadata["cwd"] = cwd } if let approval = payload.approvalPolicy { metadata["approval"] = approval } if !metadata.isEmpty { var textParts: [String] = [] if let model = metadata["model"] { textParts.append("model: \(model)") } if let cwd = metadata["cwd"] { textParts.append("cwd: \(cwd)") } if let approval = metadata["approval"] { textParts.append("approval: \(approval)") } latest = TimelineEvent( id: UUID().uuidString, timestamp: row.timestamp, actor: .info, title: TimelineEvent.environmentContextTitle, text: textParts.joined(separator: "\n"), metadata: metadata ) } case let .eventMessage(payload): let type = payload.type.lowercased() if type == "environment_context", let envText = payload.message ?? payload.text, let event = makeEnvironmentContextEvent(text: envText, timestamp: row.timestamp) { latest = event } case let .responseItem(payload): if payload.type.lowercased() == "message" { let text = joinedText(from: payload.content ?? []) guard text.contains(" TokenUsageSnapshot? { let data = try Data(contentsOf: url, options: [.mappedIfSafe]) guard !data.isEmpty else { return nil } let newline: UInt8 = 0x0A let carriageReturn: UInt8 = 0x0D var latest: TokenUsageSnapshot? for var slice in data.split(separator: newline, omittingEmptySubsequences: true) { if slice.last == carriageReturn { slice = slice.dropLast() } guard !slice.isEmpty else { continue } guard let row = try? decoder.decode(SessionRow.self, from: Data(slice)) else { continue } guard case let .eventMessage(payload) = row.kind else { continue } if payload.type.lowercased() == "token_count", let snapshot = TokenUsageSnapshotBuilder.build(timestamp: row.timestamp, payload: payload) { latest = snapshot } } return latest } } struct TokenUsageSnapshot: Equatable { let timestamp: Date let totalTokens: Int? let contextWindow: Int? let primaryPercent: Double? let primaryWindowMinutes: Int? let primaryResetAt: Date? let secondaryPercent: Double? let secondaryWindowMinutes: Int? let secondaryResetAt: Date? } struct TokenUsageSnapshotBuilder { static func build(timestamp: Date, payload: EventMessagePayload) -> TokenUsageSnapshot? { let info = payload.info let totalTokens = info?.value(forKeyPath: ["last_token_usage", "total_tokens"])?.intValue ?? info?.value(forKeyPath: ["total_token_usage", "total_tokens"])?.intValue let contextWindow = info?.value(forKeyPath: ["model_context_window"])?.intValue let primaryRate = RateWindowSnapshot(json: payload.rateLimits, prefix: "primary", timestamp: timestamp) let secondaryRate = RateWindowSnapshot(json: payload.rateLimits, prefix: "secondary", timestamp: timestamp) if totalTokens == nil, contextWindow == nil, primaryRate.isEmpty, secondaryRate.isEmpty { return nil } return TokenUsageSnapshot( timestamp: timestamp, totalTokens: totalTokens, contextWindow: contextWindow, primaryPercent: primaryRate.usedPercent, primaryWindowMinutes: primaryRate.windowMinutes, primaryResetAt: primaryRate.resetDate, secondaryPercent: secondaryRate.usedPercent, secondaryWindowMinutes: secondaryRate.windowMinutes, secondaryResetAt: secondaryRate.resetDate ) } } fileprivate struct TokenUsageFallbackParser { private let isoFormatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return formatter }() func loadLatest(url: URL) -> TokenUsageSnapshot? { guard let data = try? Data(contentsOf: url, options: [.mappedIfSafe]), !data.isEmpty else { return nil } let newline: UInt8 = 0x0A let carriageReturn: UInt8 = 0x0D var latest: TokenUsageSnapshot? for var slice in data.split(separator: newline, omittingEmptySubsequences: true) { if slice.last == carriageReturn { slice = slice.dropLast() } guard let snapshot = parseLine(Data(slice)) else { continue } latest = snapshot } return latest } private func parseLine(_ data: Data) -> TokenUsageSnapshot? { guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let payload = json["payload"] as? [String: Any], let type = (payload["type"] as? String)?.lowercased(), type == "token_count", let timestampString = json["timestamp"] as? String, let timestamp = isoFormatter.date(from: timestampString) ?? ISO8601DateFormatter().date(from: timestampString) else { return nil } let info = payload["info"] as? [String: Any] let rateLimits = payload["rate_limits"] as? [String: Any] let totalTokens = TokenUsageValueParser.int(TokenUsageValueParser.value(in: info, keyPath: ["total_token_usage", "total_tokens"])) let contextWindow = TokenUsageValueParser.int(info?["model_context_window"]) let primary = RateLimitComponents(json: rateLimits, prefix: "primary", timestamp: timestamp) let secondary = RateLimitComponents(json: rateLimits, prefix: "secondary", timestamp: timestamp) if totalTokens == nil, contextWindow == nil, primary.isEmpty, secondary.isEmpty { return nil } return TokenUsageSnapshot( timestamp: timestamp, totalTokens: totalTokens, contextWindow: contextWindow, primaryPercent: primary.usedPercent, primaryWindowMinutes: primary.windowMinutes, primaryResetAt: primary.resetDate, secondaryPercent: secondary.usedPercent, secondaryWindowMinutes: secondary.windowMinutes, secondaryResetAt: secondary.resetDate ) } } extension SessionTimelineLoader { func loadLatestTokenUsageWithFallback(url: URL) -> TokenUsageSnapshot? { if let snapshot = try? loadLatestTokenUsage(url: url) { return snapshot } return TokenUsageFallbackParser().loadLatest(url: url) } } private struct RateLimitComponents { var usedPercent: Double? var windowMinutes: Int? var resetDate: Date? var isEmpty: Bool { usedPercent == nil && windowMinutes == nil && resetDate == nil } init(json: [String: Any]?, prefix: String, timestamp: Date) { if let nested = json?[prefix] as? [String: Any] { parse(values: nested, timestamp: timestamp) return } guard let json else { return } var extracted: [String: Any] = [:] extracted["used_percent"] = json["\(prefix)_used_percent"] extracted["window_minutes"] = json["\(prefix)_window_minutes"] extracted["resets_in_seconds"] = json["\(prefix)_resets_in_seconds"] extracted["resets_at"] = json["\(prefix)_resets_at"] parse(values: extracted, timestamp: timestamp) } private mutating func parse(values: [String: Any], timestamp: Date) { usedPercent = TokenUsageValueParser.double(values["used_percent"]) windowMinutes = TokenUsageValueParser.int(values["window_minutes"]) if let resetsAt = TokenUsageValueParser.double(values["resets_at"]) { resetDate = Date(timeIntervalSince1970: resetsAt) } else if let resetsInSeconds = TokenUsageValueParser.double(values["resets_in_seconds"]) { resetDate = timestamp.addingTimeInterval(resetsInSeconds) } } } private enum TokenUsageValueParser { static func value(in root: Any?, keyPath: [String]) -> Any? { var current = root for key in keyPath { guard let dict = current as? [String: Any] else { return nil } current = dict[key] } return current } static func double(_ value: Any?) -> Double? { switch value { case let number as NSNumber: return number.doubleValue case let string as String: return Double(string) default: return nil } } static func int(_ value: Any?) -> Int? { switch value { case let number as NSNumber: return number.intValue case let string as String: return Int(string) default: return nil } } } private struct RateWindowSnapshot { var usedPercent: Double? var windowMinutes: Int? var resetsInSeconds: Double? var resetDate: Date? { guard let resetsInSeconds, let referenceTimestamp else { return nil } return referenceTimestamp.addingTimeInterval(resetsInSeconds) } private let referenceTimestamp: Date? init(json: JSONValue?, prefix: String, timestamp: Date) { referenceTimestamp = timestamp guard let json else { return } guard case let .object(dict) = json else { return } if let nested = dict[prefix] { usedPercent = nested.value(forKeyPath: ["used_percent"])?.doubleValue windowMinutes = nested.value(forKeyPath: ["window_minutes"])?.intValue resetsInSeconds = nested.value(forKeyPath: ["resets_in_seconds"])?.doubleValue } else { usedPercent = dict["\(prefix)_used_percent"]?.doubleValue windowMinutes = dict["\(prefix)_window_minutes"]?.intValue resetsInSeconds = dict["\(prefix)_resets_in_seconds"]?.doubleValue } } var isEmpty: Bool { usedPercent == nil && windowMinutes == nil && resetsInSeconds == nil } } private extension JSONValue { func value(forKeyPath path: [String]) -> JSONValue? { guard !path.isEmpty else { return self } var current: JSONValue = self for key in path { guard case let .object(dict) = current, let next = dict[key] else { return nil } current = next } return current } var doubleValue: Double? { switch self { case .number(let value): return value case .string(let string): return Double(string) case .bool(let bool): return bool ? 1 : 0 default: return nil } } } ================================================ FILE: services/SessionsDiagnosticsService.swift ================================================ import Foundation struct SessionsDiagnostics: Codable, Sendable { struct Probe: Codable, Sendable { var path: String var exists: Bool var isDirectory: Bool var enumeratedCount: Int var sampleFiles: [String] var enumeratorError: String? } var timestamp: Date // Sessions (.jsonl) var current: Probe var defaultRoot: Probe // Notes (.json) var notesCurrent: Probe var notesDefault: Probe // Projects (.json) var projectsCurrent: Probe var projectsDefault: Probe // Claude sessions (.jsonl) var claudeCurrent: Probe? var claudeDefault: Probe // Gemini sessions (.json) var geminiCurrent: Probe? var geminiDefault: Probe var suggestions: [String] } actor SessionsDiagnosticsService { private let fm: FileManager init(fileManager: FileManager = .default) { self.fm = fileManager } func run( currentRoot: URL, defaultRoot: URL, notesCurrentRoot: URL, notesDefaultRoot: URL, projectsCurrentRoot: URL, projectsDefaultRoot: URL, claudeCurrentRoot: URL?, claudeDefaultRoot: URL, geminiCurrentRoot: URL?, geminiDefaultRoot: URL ) async -> SessionsDiagnostics { let currentProbe = await probe(root: currentRoot, fileExtension: "jsonl") let defaultProbe = await probe(root: defaultRoot, fileExtension: "jsonl") let notesCurrent = await probe(root: notesCurrentRoot, fileExtension: "json") let notesDefault = await probe(root: notesDefaultRoot, fileExtension: "json") let projectsCurrent = await probe(root: projectsCurrentRoot, fileExtension: "json") let projectsDefault = await probe(root: projectsDefaultRoot, fileExtension: "json") let claudeCurrent = claudeCurrentRoot != nil ? await probe(root: claudeCurrentRoot!, fileExtension: "jsonl") : nil let claudeDefault = await probe(root: claudeDefaultRoot, fileExtension: "jsonl") let geminiCurrent = geminiCurrentRoot != nil ? await probe(root: geminiCurrentRoot!, fileExtension: "json") : nil let geminiDefault = await probe(root: geminiDefaultRoot, fileExtension: "json") var suggestions: [String] = [] if currentProbe.enumeratedCount == 0, defaultProbe.enumeratedCount > 0, currentProbe.exists { suggestions.append("Switch sessions root to default path; it contains sessions.") } if !currentProbe.exists { suggestions.append("Current sessions root does not exist; create or select another directory.") } if currentProbe.exists, !currentProbe.isDirectory { suggestions.append("Current sessions root is not a directory; select a folder.") } if currentProbe.enumeratedCount == 0, currentProbe.enumeratorError == nil, defaultProbe.enumeratedCount == 0 { suggestions.append("No .jsonl files found under both roots; ensure Codex CLI is writing sessions.") } // Notes suggestions if !notesCurrent.exists { suggestions.append("Notes directory does not exist; it will be created on demand under ~/.codmate/notes by default.") } if notesCurrent.exists, !notesCurrent.isDirectory { suggestions.append("Notes path is not a directory; select a folder.") } if notesCurrent.enumeratedCount == 0, notesDefault.enumeratedCount > 0 { suggestions.append("Notes directory is empty; consider switching to default ~/.codmate/notes or migrating.") } // Projects suggestions if !projectsCurrent.exists { suggestions.append("Projects directory does not exist; it will be created under ~/.codmate/projects.") } if projectsCurrent.exists, !projectsCurrent.isDirectory { suggestions.append("Projects path is not a directory; select a folder.") } // Claude suggestions (informational) if let cc = claudeCurrent { if !cc.exists { suggestions.append("Claude sessions directory not found; if you use Claude Code CLI, ensure it writes logs under ~/.claude/projects.") } } else if !claudeDefault.exists { suggestions.append("Claude default sessions directory (~/.claude/projects) not found.") } if let gc = geminiCurrent { if !gc.exists { suggestions.append("Gemini sessions directory not found; ensure Gemini CLI writes logs under ~/.gemini/tmp.") } } else if !geminiDefault.exists { suggestions.append("Gemini default sessions directory (~/.gemini/tmp) not found.") } return SessionsDiagnostics( timestamp: Date(), current: currentProbe, defaultRoot: defaultProbe, notesCurrent: notesCurrent, notesDefault: notesDefault, projectsCurrent: projectsCurrent, projectsDefault: projectsDefault, claudeCurrent: claudeCurrent, claudeDefault: claudeDefault, geminiCurrent: geminiCurrent, geminiDefault: geminiDefault, suggestions: suggestions ) } // MARK: - Helpers func probe(root: URL, fileExtension: String) async -> SessionsDiagnostics.Probe { var isDir: ObjCBool = false let exists = fm.fileExists(atPath: root.path, isDirectory: &isDir) var count = 0 var samples: [String] = [] var enumError: String? = nil if exists, isDir.boolValue { if let enumerator = fm.enumerator( at: root, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants] ) { // Collect URLs synchronously first to avoid Swift 6 async/iterator issues let urls = enumerator.compactMap { $0 as? URL } for url in urls { if url.pathExtension.lowercased() == fileExtension.lowercased() { count += 1 if samples.count < 10 { samples.append(url.path) } } } } else { enumError = "Failed to open enumerator for \(root.path)" } } return .init( path: root.path, exists: exists, isDirectory: isDir.boolValue, enumeratedCount: count, sampleFiles: samples, enumeratorError: enumError ) } } ================================================ FILE: services/SkillsImportService.swift ================================================ import Foundation enum SkillsImportService { struct SourceDescriptor { let label: String let directory: URL } static func scan(scope: ExtensionsImportScope, fileManager: FileManager = .default) async -> [SkillImportCandidate] { let sources: [SourceDescriptor] switch scope { case .home: let home = SessionPreferencesStore.getRealUserHomeURL() sources = [ SourceDescriptor( label: "Codex", directory: home.appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("skills", isDirectory: true) ), SourceDescriptor( label: "Claude", directory: home.appendingPathComponent(".claude", isDirectory: true) .appendingPathComponent("skills", isDirectory: true) ), SourceDescriptor( label: "Gemini", directory: home.appendingPathComponent(".gemini", isDirectory: true) .appendingPathComponent("skills", isDirectory: true) ), ] case .project(let directory): sources = [ SourceDescriptor( label: "Codex", directory: directory.appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("skills", isDirectory: true) ), SourceDescriptor( label: "Claude", directory: directory.appendingPathComponent(".claude", isDirectory: true) .appendingPathComponent("skills", isDirectory: true) ), SourceDescriptor( label: "Gemini", directory: directory.appendingPathComponent(".gemini", isDirectory: true) .appendingPathComponent("skills", isDirectory: true) ), ] } let filtered = sources.filter { source in switch source.label { case "Codex": return SessionPreferencesStore.isCLIEnabled(.codex) case "Claude": return SessionPreferencesStore.isCLIEnabled(.claude) case "Gemini": return SessionPreferencesStore.isCLIEnabled(.gemini) default: return true } } return await scan(sources: filtered, fileManager: fileManager) } private static func scan(sources: [SourceDescriptor], fileManager: FileManager) async -> [SkillImportCandidate] { let store = SkillsStore() var merged: [String: SkillImportCandidate] = [:] for source in sources { guard fileManager.fileExists(atPath: source.directory.path) else { continue } guard let entries = try? fileManager.contentsOfDirectory( at: source.directory, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], options: [.skipsHiddenFiles] ) else { continue } for entry in entries { guard (try? entry.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue } let skillFile = entry.appendingPathComponent("SKILL.md", isDirectory: false) guard fileManager.fileExists(atPath: skillFile.path) else { continue } if await store.isCodMateManagedSkill(at: entry) { continue } let proposedId = entry.lastPathComponent let metadata = try? await store.parseSkillMetadata(at: entry, sourceLabel: "import") let name = metadata?.name.isEmpty == false ? metadata?.name ?? proposedId : proposedId let summary = metadata?.summary ?? (metadata?.description ?? "") if var existing = merged[proposedId] { if !existing.sources.contains(source.label) { existing.sources.append(source.label) } existing.sourcePaths[source.label] = skillFile.path merged[proposedId] = existing } else { merged[proposedId] = SkillImportCandidate( id: proposedId, name: name, summary: summary, sourcePath: entry.path, sources: [source.label], sourcePaths: [source.label: skillFile.path], isSelected: true, hasConflict: false, conflictDetail: nil, resolution: .overwrite, renameId: proposedId, suggestedId: proposedId ) } } } return merged.values.sorted { $0.id.localizedCaseInsensitiveCompare($1.id) == .orderedAscending } } } ================================================ FILE: services/SkillsStore.swift ================================================ import Foundation enum SkillCreationError: LocalizedError { case invalidName(String) case nameConflict(existing: String, suggested: String) var errorDescription: String? { switch self { case .invalidName(let message): return message case .nameConflict(let existing, let suggested): return "A skill named '\(existing)' already exists. Suggested name: '\(suggested)'" } } } actor SkillsStore { struct Paths { let root: URL let libraryDir: URL let indexURL: URL static func `default`(fileManager: FileManager = .default) -> Paths { let home = SessionPreferencesStore.getRealUserHomeURL() let root = home.appendingPathComponent(".codmate", isDirectory: true) .appendingPathComponent("skills", isDirectory: true) return Paths( root: root, libraryDir: root.appendingPathComponent("library", isDirectory: true), indexURL: root.appendingPathComponent("index.json", isDirectory: false) ) } } private let paths: Paths private let fm: FileManager init(paths: Paths = .default(), fileManager: FileManager = .default) { self.paths = paths self.fm = fileManager } func list() -> [SkillRecord] { load() } func record(id: String) -> SkillRecord? { load().first(where: { $0.id == id }) } func saveAll(_ records: [SkillRecord]) { save(records) } func uninstall(id: String) { var records = load() guard let idx = records.firstIndex(where: { $0.id == id }) else { return } let record = records.remove(at: idx) save(records) let path = record.path.trimmingCharacters(in: .whitespacesAndNewlines) guard !path.isEmpty else { return } let url = URL(fileURLWithPath: path, isDirectory: true) if fm.fileExists(atPath: url.path) { if isCodMateManagedSkill(at: url) || url.standardizedFileURL.path.hasPrefix(paths.libraryDir.standardizedFileURL.path) { try? fm.removeItem(at: url) } } } func refreshMetadata(id: String) -> SkillRecord? { var records = load() guard let idx = records.firstIndex(where: { $0.id == id }) else { return nil } let record = records[idx] let path = record.path.trimmingCharacters(in: .whitespacesAndNewlines) guard !path.isEmpty else { return nil } let url = URL(fileURLWithPath: path, isDirectory: true) guard fm.fileExists(atPath: url.path) else { return nil } let metadata = (try? parseSkillMetadata(at: url, sourceLabel: record.source)) ?? ParsedMetadata( name: record.name, description: record.description, summary: record.summary, tags: record.tags, source: record.source ) records[idx].name = metadata.name records[idx].description = metadata.description records[idx].summary = metadata.summary records[idx].tags = metadata.tags save(records) return records[idx] } func update(id: String, mutate: (inout SkillRecord) -> Void) { var records = load() guard let idx = records.firstIndex(where: { $0.id == id }) else { return } mutate(&records[idx]) save(records) } func upsert(_ record: SkillRecord) { var records = load() if let idx = records.firstIndex(where: { $0.id == record.id }) { records[idx] = record } else { records.append(record) } save(records) } func createFromTemplate(name: String, description: String) async throws -> SkillRecord { let skillId = try validateAndNormalizeSkillName(name) try fm.createDirectory(at: paths.libraryDir, withIntermediateDirectories: true) let destination = paths.libraryDir.appendingPathComponent(skillId, isDirectory: true) if fm.fileExists(atPath: destination.path) { let suggested = suggestNewId(basedOn: skillId) throw SkillCreationError.nameConflict(existing: skillId, suggested: suggested) } try fm.createDirectory(at: destination, withIntermediateDirectories: true) let skillMarkdown = generateDefaultSkillMarkdown(name: skillId, description: description) let skillFile = destination.appendingPathComponent("SKILL.md", isDirectory: false) try skillMarkdown.write(to: skillFile, atomically: true, encoding: .utf8) try writeMarker(to: destination, id: skillId, sourceType: "template") let record = SkillRecord( id: skillId, name: skillId, description: description, summary: description, tags: [], source: "Template", path: destination.path, isEnabled: true, targets: MCPServerTargets(codex: true, claude: true, gemini: false), installedAt: Date() ) upsert(record) return record } func createFromWizard(draft: SkillWizardDraft, enabled: Bool = false) async throws -> SkillRecord { let proposed = draft.id.isEmpty ? draft.name : draft.id let skillId = try validateAndNormalizeSkillName(proposed) try fm.createDirectory(at: paths.libraryDir, withIntermediateDirectories: true) let destination = paths.libraryDir.appendingPathComponent(skillId, isDirectory: true) if fm.fileExists(atPath: destination.path) { let suggested = suggestNewId(basedOn: skillId) throw SkillCreationError.nameConflict(existing: skillId, suggested: suggested) } try fm.createDirectory(at: destination, withIntermediateDirectories: true) let skillMarkdown = generateSkillMarkdownFromDraft(draft, id: skillId) let skillFile = destination.appendingPathComponent("SKILL.md", isDirectory: false) try skillMarkdown.write(to: skillFile, atomically: true, encoding: .utf8) try writeMarker(to: destination, id: skillId, sourceType: "wizard") let summary = draft.summary?.isEmpty == false ? draft.summary! : draft.description let record = SkillRecord( id: skillId, name: draft.name, description: draft.description, summary: summary, tags: draft.tags, source: "Wizard", path: destination.path, isEnabled: enabled, targets: draft.targets ?? MCPServerTargets(codex: true, claude: true, gemini: false), installedAt: Date() ) upsert(record) return record } private func validateAndNormalizeSkillName(_ name: String) throws -> String { let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw SkillCreationError.invalidName("Skill name cannot be empty") } let normalized = trimmed .lowercased() .replacingOccurrences(of: " ", with: "-") .replacingOccurrences(of: "_", with: "-") let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-") let filtered = normalized.unicodeScalars.filter { allowed.contains($0) } let result = String(String.UnicodeScalarView(filtered)) guard !result.isEmpty else { throw SkillCreationError.invalidName("Skill name must contain at least one alphanumeric character") } guard result.count <= 64 else { throw SkillCreationError.invalidName("Skill name must be 64 characters or less") } return result } private func generateDefaultSkillMarkdown(name: String, description: String) -> String { let displayName = name.split(separator: "-") .map { $0.prefix(1).uppercased() + $0.dropFirst() } .joined(separator: " ") return """ --- name: \(name) description: \(description.isEmpty ? "Custom skill for specific tasks" : description) --- # \(displayName) ## Overview This is a custom skill created from a template. Describe what this skill does and when Claude or Codex should use it. ## Instructions Provide clear, step-by-step guidance for the AI assistant: 1. First step or action to take 2. Second step or action 3. Additional steps as needed ## Examples Show concrete usage examples to help the AI understand how to apply this skill: **Example 1: Basic Usage** ``` User: [Example user request] Assistant: [Expected behavior or response] ``` **Example 2: Advanced Usage** ``` User: [Another example] Assistant: [Expected behavior] ``` ## Notes - Add any special considerations or limitations - Document required tools or dependencies - Include best practices or tips """ } nonisolated func generateSkillMarkdownFromDraft(_ draft: SkillWizardDraft, id: String) -> String { let title = draft.name.isEmpty ? id : draft.name let summary = draft.summary?.isEmpty == false ? draft.summary! : draft.description let tagsBlock: String = { if draft.tags.isEmpty { return "" } let lines = draft.tags.map { " - \($0)" }.joined(separator: "\n") return "tags:\n\(lines)\n" }() let instructions: String = { if draft.instructions.isEmpty { return "" } return draft.instructions.enumerated().map { index, step in "\(index + 1). \(step)" }.joined(separator: "\n") }() let examples: String = { if draft.examples.isEmpty { return "" } return draft.examples.enumerated().map { index, example in let title = example.title.isEmpty ? "Example \(index + 1)" : example.title return """ **\(title)** ``` User: \(example.user) Assistant: \(example.assistant) ``` """ }.joined(separator: "\n\n") }() let notes: String = { if draft.notes.isEmpty { return "" } return draft.notes.map { "- \($0)" }.joined(separator: "\n") }() return """ --- name: \(id) description: \(draft.description) metadata: short-description: \(summary) \(tagsBlock)--- # \(title) ## Overview \(draft.overview) ## Instructions \(instructions) ## Examples \(examples) ## Notes \(notes) """ } func install( request: SkillInstallRequest, resolution: SkillConflictResolution? = nil ) async -> SkillInstallOutcome { do { let result = try await performInstall(request: request, resolution: resolution) return result } catch { return .skipped } } func validate(request: SkillInstallRequest) async -> Bool { do { let tempRoot = fm.temporaryDirectory .appendingPathComponent("codmate-skill-validate-\(UUID().uuidString)", isDirectory: true) try fm.createDirectory(at: tempRoot, withIntermediateDirectories: true) defer { try? fm.removeItem(at: tempRoot) } guard let sourceURL = try await resolveSourceURL(request: request, tempRoot: tempRoot) else { return false } _ = try locateSkillRoot(from: sourceURL, request: request, tempRoot: tempRoot) return true } catch { return false } } private func performInstall( request: SkillInstallRequest, resolution: SkillConflictResolution? = nil ) async throws -> SkillInstallOutcome { let tempRoot = fm.temporaryDirectory .appendingPathComponent("codmate-skill-install-\(UUID().uuidString)", isDirectory: true) try fm.createDirectory(at: tempRoot, withIntermediateDirectories: true) defer { try? fm.removeItem(at: tempRoot) } guard let sourceURL = try await resolveSourceURL(request: request, tempRoot: tempRoot) else { return .skipped } let skillRoot = try locateSkillRoot(from: sourceURL, request: request, tempRoot: tempRoot) let proposedId = skillRoot.lastPathComponent let targetId: String switch resolution { case .rename(let newId): targetId = newId default: targetId = proposedId } try fm.createDirectory(at: paths.libraryDir, withIntermediateDirectories: true) let destination = paths.libraryDir.appendingPathComponent(targetId, isDirectory: true) if fm.fileExists(atPath: destination.path) { if resolution == .skip { return .skipped } let managed = isCodMateManagedSkill(at: destination) if managed || resolution == .overwrite { try? fm.removeItem(at: destination) } else { let suggested = suggestNewId(basedOn: targetId) let conflict = SkillInstallConflict( proposedId: targetId, destination: destination, existingIsManaged: managed, suggestedId: suggested ) return .conflict(conflict) } } try fm.copyItem(at: skillRoot, to: destination) try writeMarker(to: destination, id: targetId) let sourceLabel = sourceDescription(request: request, fallback: destination.lastPathComponent) let metadata = try parseSkillMetadata(at: destination, sourceLabel: sourceLabel) let existing = load().first(where: { $0.id == targetId }) let record = SkillRecord( id: targetId, name: metadata.name, description: metadata.description, summary: metadata.summary, tags: metadata.tags, source: metadata.source, path: destination.path, isEnabled: existing?.isEnabled ?? true, targets: existing?.targets ?? MCPServerTargets(codex: true, claude: true, gemini: false), installedAt: Date() ) upsert(record) return .installed(record) } struct ParsedMetadata { var name: String var description: String var summary: String var tags: [String] var source: String } func parseSkillMetadata(at root: URL, sourceLabel: String) throws -> ParsedMetadata { let skillFile = root.appendingPathComponent("SKILL.md", isDirectory: false) let text = (try? String(contentsOf: skillFile, encoding: .utf8)) ?? "" let front = parseFrontMatter(text) let name = front.name.isEmpty ? root.lastPathComponent : front.name let description = front.description.isEmpty ? name : front.description let summary = front.shortDescription.isEmpty ? description : front.shortDescription let tags = front.tags return ParsedMetadata( name: name, description: description, summary: summary, tags: tags, source: sourceLabel ) } private func load() -> [SkillRecord] { guard fm.fileExists(atPath: paths.indexURL.path) else { return [] } guard let data = try? Data(contentsOf: paths.indexURL) else { return [] } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return (try? decoder.decode([SkillRecord].self, from: data)) ?? [] } private func save(_ records: [SkillRecord]) { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes] encoder.dateEncodingStrategy = .iso8601 guard let data = try? encoder.encode(records) else { return } try? fm.createDirectory(at: paths.root, withIntermediateDirectories: true) try? data.write(to: paths.indexURL, options: .atomic) } private func resolveSourceURL(request: SkillInstallRequest, tempRoot: URL) async throws -> URL? { switch request.mode { case .folder: guard let url = request.url else { return nil } return url case .zip: guard let url = request.url else { return nil } return try extractZip(at: url, to: tempRoot) case .url: guard let text = request.text?.trimmingCharacters(in: .whitespacesAndNewlines), let url = URL(string: text) else { return nil } let downloaded = try await downloadURL(url, to: tempRoot) if downloaded.pathExtension.lowercased() == "zip" { return try extractZip(at: downloaded, to: tempRoot) } return downloaded } } private func locateSkillRoot(from source: URL, request: SkillInstallRequest, tempRoot: URL) throws -> URL { let fm = FileManager.default let isDirectory = (try? source.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false if isDirectory { if hasSkillFile(in: source) { return source } let candidates = try fm.contentsOfDirectory(at: source, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles]) if let nested = candidates.first(where: { hasSkillFile(in: $0) }) { return nested } } else if hasSkillFile(in: source.deletingLastPathComponent()) { return source.deletingLastPathComponent() } let candidates = try fm.contentsOfDirectory(at: tempRoot, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles]) .filter { $0.lastPathComponent != "__MACOSX" } if candidates.count == 1 { let single = candidates[0] if hasSkillFile(in: single) { return single } let nested = try fm.contentsOfDirectory(at: single, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) if let hit = nested.first(where: { hasSkillFile(in: $0) }) { return hit } } if hasSkillFile(in: tempRoot) { return tempRoot } throw NSError(domain: "CodMate", code: -1, userInfo: [NSLocalizedDescriptionKey: "SKILL.md not found"]) } private func hasSkillFile(in dir: URL) -> Bool { fm.fileExists(atPath: dir.appendingPathComponent("SKILL.md", isDirectory: false).path) } private func downloadURL(_ url: URL, to tempRoot: URL) async throws -> URL { let (data, _) = try await URLSession.shared.data(from: url) let ext = url.pathExtension.isEmpty ? "download" : url.pathExtension let target = tempRoot.appendingPathComponent("skill.\(ext)", isDirectory: false) try data.write(to: target, options: .atomic) return target } private func extractZip(at url: URL, to tempRoot: URL) throws -> URL { let ditto = Process() ditto.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") ditto.arguments = ["-x", "-k", url.path, tempRoot.path] let pipe = Pipe() ditto.standardOutput = pipe ditto.standardError = pipe try ditto.run() ditto.waitUntilExit() if ditto.terminationStatus != 0 { let data = pipe.fileHandleForReading.readDataToEndOfFile() let output = String(data: data, encoding: .utf8) ?? "" throw NSError(domain: "CodMate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to extract zip: \(output)"]) } return tempRoot } func suggestNewId(basedOn id: String) -> String { let base = id.trimmingCharacters(in: .whitespacesAndNewlines) guard !base.isEmpty else { return "skill" } var i = 2 var candidate = "\(base)-\(i)" while fm.fileExists(atPath: paths.libraryDir.appendingPathComponent(candidate).path) { i += 1 candidate = "\(base)-\(i)" } return candidate } private func sourceDescription(request: SkillInstallRequest, fallback: String) -> String { switch request.mode { case .folder: return request.url?.path ?? fallback case .zip: return request.url?.path ?? fallback case .url: return request.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? fallback } } func writeMarker(to dir: URL, id: String, sourceType: String = "installed") throws { let marker = dir.appendingPathComponent(".codmate.json", isDirectory: false) let obj: [String: Any] = [ "managedByCodMate": true, "id": id, "sourceType": sourceType ] let data = try JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted]) try data.write(to: marker, options: .atomic) } func isCodMateManagedSkill(at dir: URL) -> Bool { let marker = dir.appendingPathComponent(".codmate.json", isDirectory: false) guard fm.fileExists(atPath: marker.path), let data = try? Data(contentsOf: marker), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return false } return (obj["managedByCodMate"] as? Bool) == true } func getSourceType(at dir: URL) -> String? { let marker = dir.appendingPathComponent(".codmate.json", isDirectory: false) guard fm.fileExists(atPath: marker.path), let data = try? Data(contentsOf: marker), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } return obj["sourceType"] as? String } func conflictInfo(forProposedId id: String) -> SkillInstallConflict? { let dest = paths.libraryDir.appendingPathComponent(id, isDirectory: true) guard fm.fileExists(atPath: dest.path) else { return nil } let managed = isCodMateManagedSkill(at: dest) let suggested = suggestNewId(basedOn: id) return SkillInstallConflict( proposedId: id, destination: dest, existingIsManaged: managed, suggestedId: suggested ) } func markImported(id: String) { var records = load() guard let idx = records.firstIndex(where: { $0.id == id }) else { return } records[idx].source = "Import" save(records) let dir = URL(fileURLWithPath: records[idx].path, isDirectory: true) try? writeMarker(to: dir, id: id, sourceType: "import") } private struct FrontMatter { var name: String = "" var description: String = "" var shortDescription: String = "" var tags: [String] = [] } private func parseFrontMatter(_ text: String) -> FrontMatter { var result = FrontMatter() let lines = text.split(separator: "\n", omittingEmptySubsequences: false) guard lines.first?.trimmingCharacters(in: .whitespaces) == "---" else { return result } var idx = 1 var inMetadata = false var inTagsList = false while idx < lines.count { let raw = String(lines[idx]) let trimmed = raw.trimmingCharacters(in: .whitespaces) if trimmed == "---" { break } if trimmed.isEmpty || trimmed.hasPrefix("#") { idx += 1 continue } let indent = raw.prefix { $0 == " " || $0 == "\t" }.count if indent == 0 { inMetadata = false inTagsList = false if let colon = trimmed.firstIndex(of: ":") { let key = String(trimmed[.. [String] { var trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.hasPrefix("[") { trimmed.removeFirst() } if trimmed.hasSuffix("]") { trimmed.removeLast() } return trimmed.split(separator: ",").map { unquote(String($0).trimmingCharacters(in: .whitespaces)) } } private func unquote(_ value: String) -> String { var trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) if (trimmed.hasPrefix("\"") && trimmed.hasSuffix("\"")) || (trimmed.hasPrefix("'") && trimmed.hasSuffix("'")) { trimmed.removeFirst() trimmed.removeLast() } return trimmed } } ================================================ FILE: services/SkillsSyncService.swift ================================================ import Foundation actor SkillsSyncService { private let fm: FileManager private let libraryDir: URL init(fileManager: FileManager = .default, libraryDir: URL? = nil) { self.fm = fileManager self.libraryDir = libraryDir ?? SkillsStore.Paths.default().libraryDir } func syncGlobal(skills: [SkillRecord]) -> [SkillSyncWarning] { let home = SessionPreferencesStore.getRealUserHomeURL() let codexDir = home.appendingPathComponent(".codex", isDirectory: true).appendingPathComponent("skills", isDirectory: true) let claudeDir = home.appendingPathComponent(".claude", isDirectory: true).appendingPathComponent("skills", isDirectory: true) let geminiDir = home.appendingPathComponent(".gemini", isDirectory: true).appendingPathComponent("skills", isDirectory: true) var warnings: [SkillSyncWarning] = [] if SessionPreferencesStore.isCLIEnabled(.codex) { warnings.append(contentsOf: syncSkills(skills: skills, target: .codex, destination: codexDir)) } if SessionPreferencesStore.isCLIEnabled(.claude) { warnings.append(contentsOf: syncSkills(skills: skills, target: .claude, destination: claudeDir)) } if SessionPreferencesStore.isCLIEnabled(.gemini) { warnings.append(contentsOf: syncSkills(skills: skills, target: .gemini, destination: geminiDir)) } return warnings } func syncProject(skills: [SkillRecord], selections: [SkillSelection], projectDirectory: URL) -> [SkillSyncWarning] { let codexDir = projectDirectory.appendingPathComponent(".codex", isDirectory: true) .appendingPathComponent("skills", isDirectory: true) let claudeDir = projectDirectory.appendingPathComponent(".claude", isDirectory: true) .appendingPathComponent("skills", isDirectory: true) let geminiDir = projectDirectory.appendingPathComponent(".gemini", isDirectory: true) .appendingPathComponent("skills", isDirectory: true) var warnings: [SkillSyncWarning] = [] let selectedSkills = selections.reduce(into: [String: SkillSelection]()) { $0[$1.id] = $1 } let chosen = skills.filter { selectedSkills[$0.id]?.isSelected == true } if SessionPreferencesStore.isCLIEnabled(.codex) { warnings.append(contentsOf: syncSkills( skills: chosen, target: .codex, destination: codexDir, selectionOverride: selectedSkills )) } if SessionPreferencesStore.isCLIEnabled(.claude) { warnings.append(contentsOf: syncSkills( skills: chosen, target: .claude, destination: claudeDir, selectionOverride: selectedSkills )) } if SessionPreferencesStore.isCLIEnabled(.gemini) { warnings.append(contentsOf: syncSkills( skills: chosen, target: .gemini, destination: geminiDir, selectionOverride: selectedSkills )) } return warnings } struct SkillSelection: Hashable { var id: String var isSelected: Bool var targets: MCPServerTargets } private func syncSkills( skills: [SkillRecord], target: MCPServerTarget, destination: URL, selectionOverride: [String: SkillSelection]? = nil ) -> [SkillSyncWarning] { let selected = skills.filter { record in if let override = selectionOverride?[record.id] { return override.isSelected && override.targets.isEnabled(for: target) } return record.isEnabled && record.targets.isEnabled(for: target) } if selected.isEmpty { removeManagedEntries(keeping: [], at: destination) return [] } try? fm.createDirectory(at: destination, withIntermediateDirectories: true) let wanted = Set(selected.map { $0.id }) var warnings: [SkillSyncWarning] = [] for record in selected { if record.path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { warnings.append(SkillSyncWarning(message: "\(record.id) has no install path." )) continue } let dest = destination.appendingPathComponent(record.id, isDirectory: true) let src = URL(fileURLWithPath: record.path, isDirectory: true) do { // Codex CLI skips symlinks when loading skills, so we must use copy for codex target // Gemini CLI also supports symlinks, so we can use symlinks for both claude and gemini let forceCopy = (target == .codex) try ensureSkillLinked(from: src, to: dest, id: record.id, forceCopy: forceCopy) } catch { warnings.append(SkillSyncWarning(message: "\(record.id) could not sync to \(destination.path)")) } } removeManagedEntries(keeping: wanted, at: destination) return warnings } private func removeManagedEntries(keeping ids: Set, at destination: URL) { guard let entries = try? fm.contentsOfDirectory(at: destination, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles]) else { return } for entry in entries { let name = entry.lastPathComponent guard !ids.contains(name) else { continue } if isCodMateManagedSkill(at: entry) { try? fm.removeItem(at: entry) } } } private func ensureSkillLinked(from source: URL, to dest: URL, id: String, forceCopy: Bool = false) throws { if fm.fileExists(atPath: dest.path) { if isSymbolicLink(dest) { let link = try? fm.destinationOfSymbolicLink(atPath: dest.path) if let link, URL(fileURLWithPath: link).standardizedFileURL == source.standardizedFileURL { // If forceCopy is true but we have a symlink, remove it and copy if forceCopy { try fm.removeItem(at: dest) } else { return } } else { try fm.removeItem(at: dest) } } else if isCodMateManagedSkill(at: dest) { // Check if it's already a copy pointing to the same source let marker = dest.appendingPathComponent(".codmate.json", isDirectory: false) if fm.fileExists(atPath: marker.path), let data = try? Data(contentsOf: marker), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], (obj["id"] as? String) == id { return // Already synced } try fm.removeItem(at: dest) } else { throw NSError(domain: "CodMate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Skill conflict at \(dest.path)"]) } } if forceCopy { // Force copy instead of symlink (needed for Codex CLI which skips symlinks) try fm.copyItem(at: source, to: dest) try writeMarker(to: dest, id: id) } else { do { try fm.createSymbolicLink(at: dest, withDestinationURL: source) } catch { try fm.copyItem(at: source, to: dest) try writeMarker(to: dest, id: id) } } } private func isSymbolicLink(_ url: URL) -> Bool { let values = try? url.resourceValues(forKeys: [.isSymbolicLinkKey]) return values?.isSymbolicLink ?? false } private func writeMarker(to dir: URL, id: String) throws { let marker = dir.appendingPathComponent(".codmate.json", isDirectory: false) let obj: [String: Any] = [ "managedByCodMate": true, "id": id ] let data = try JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted]) try data.write(to: marker, options: .atomic) } private func isCodMateManagedSkill(at dir: URL) -> Bool { if isSymbolicLink(dir) { if let target = try? fm.destinationOfSymbolicLink(atPath: dir.path) { let resolved = URL(fileURLWithPath: target).standardizedFileURL return resolved.path.hasPrefix(libraryDir.standardizedFileURL.path) } return false } let marker = dir.appendingPathComponent(".codmate.json", isDirectory: false) guard fm.fileExists(atPath: marker.path), let data = try? Data(contentsOf: marker), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return false } return (obj["managedByCodMate"] as? Bool) == true } } ================================================ FILE: services/StatusBarLogStore.swift ================================================ import CoreGraphics import Foundation @MainActor final class StatusBarLogStore: ObservableObject { static let shared = StatusBarLogStore() @Published private(set) var entries: [StatusBarLogEntry] = [] @Published private(set) var isAutoVisible: Bool = false @Published var isExpanded: Bool = false { didSet { if isExpanded { autoHideTask?.cancel() autoHideTask = nil } } } @Published var expandedHeight: CGFloat = 200 @Published private(set) var activeTaskCount: Int = 0 @Published private(set) var isInteracting: Bool = false let collapsedHeight: CGFloat = 26 private let minExpandedHeight: CGFloat = 120 private let maxExpandedHeight: CGFloat = 520 private var autoCollapseEnabled: Bool = true private let maxEntries = 200 private let autoHideSeconds: TimeInterval = 6 private var autoHideTask: Task? private var activeTaskTokens: Set = [] private init() {} func post( _ message: String, level: StatusBarLogLevel = .info, source: String? = nil ) { let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } entries.append(StatusBarLogEntry(message: trimmed, level: level, source: source)) if entries.count > maxEntries { entries.removeFirst(entries.count - maxEntries) } isAutoVisible = true scheduleAutoHide() } func beginTask( _ message: String, level: StatusBarLogLevel = .info, source: String? = nil ) -> String { let token = UUID().uuidString activeTaskTokens.insert(token) activeTaskCount = activeTaskTokens.count post(message, level: level, source: source) return token } func endTask( _ token: String, message: String? = nil, level: StatusBarLogLevel = .info, source: String? = nil ) { if activeTaskTokens.remove(token) != nil { activeTaskCount = activeTaskTokens.count } if let message, !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { post(message, level: level, source: source) } else { isAutoVisible = true scheduleAutoHide() } } func clear() { entries.removeAll() isAutoVisible = false autoHideTask?.cancel() autoHideTask = nil } func setExpandedHeight(_ height: CGFloat) { let clamped = min(max(height, minExpandedHeight), maxExpandedHeight) if abs(Double(clamped - expandedHeight)) > 0.5 { expandedHeight = clamped } } func setAutoCollapseEnabled(_ isEnabled: Bool) { autoCollapseEnabled = isEnabled if !isEnabled { autoHideTask?.cancel() autoHideTask = nil } } func setInteracting(_ isInteracting: Bool) { guard self.isInteracting != isInteracting else { return } self.isInteracting = isInteracting if isInteracting { autoHideTask?.cancel() autoHideTask = nil } else { scheduleAutoHide() } } func reveal(expanded: Bool = false) { isAutoVisible = true if expanded { isExpanded = true } scheduleAutoHide() } private func scheduleAutoHide() { guard autoCollapseEnabled else { return } if isExpanded { return } autoHideTask?.cancel() let delay = autoHideSeconds autoHideTask = Task { [weak self] in guard let self else { return } try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) await MainActor.run { guard self.autoCollapseEnabled else { return } guard self.activeTaskCount == 0 else { return } if self.isExpanded { return } if self.isInteracting { self.scheduleAutoHide() return } self.isAutoVisible = false self.isExpanded = false } } } } ================================================ FILE: services/SystemNotifier.swift ================================================ import Foundation import UserNotifications final class SystemNotifier: NSObject { @MainActor static let shared = SystemNotifier() private var bootstrapped = false @MainActor func bootstrap() { guard !bootstrapped else { return } bootstrapped = true let center = UNUserNotificationCenter.current() center.delegate = self Task { _ = try? await center.requestAuthorization(options: [.alert, .sound, .badge]) } } // MARK: - Public API @MainActor func notify(title: String, body: String) async { await notify(title: title, body: body, threadId: nil) } @MainActor func notify(title: String, body: String, threadId: String?) async { let center = UNUserNotificationCenter.current() // Ensure we have requested permission at least once bootstrap() // Query settings to decide if we need a fallback let status = await SystemNotifier.authorizationStatus() let content = UNMutableNotificationContent() content.title = title content.body = body if let threadId { content.threadIdentifier = threadId } let request = UNNotificationRequest( identifier: UUID().uuidString, content: content, trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.2, repeats: false) ) do { try await center.add(request) } catch { // Fallback to AppleScript if UNUserNotifications fails Self.notifyViaOSAScript(title: title, body: body) return } // If not authorized to show alerts, attempt fallback so user still gets a toast if status != .authorized { Self.notifyViaOSAScript(title: title, body: body) } } // Specialized helper: agent completed and awaits user follow-up. // Also posts an in-app notification to update list indicators. @MainActor func notifyAgentCompleted(sessionID: String, message: String) async { await notify(title: "CodMate", body: message, threadId: "agent") NotificationCenter.default.post( name: .codMateAgentCompleted, object: nil, userInfo: ["sessionID": sessionID, "message": message] ) } // MARK: - Internals private static func notifyViaOSAScript(title: String, body: String) { let script = "display notification \"\(body.replacingOccurrences(of: "\\\\", with: "\\\\\\\\").replacingOccurrences(of: "\"", with: "\\\""))\" with title \"\(title.replacingOccurrences(of: "\\\\", with: "\\\\\\\\").replacingOccurrences(of: "\"", with: "\\\""))\"" let p = Process() p.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") p.arguments = ["-e", script] try? p.run() } private static func authorizationStatus() async -> UNAuthorizationStatus { await withCheckedContinuation { cont in UNUserNotificationCenter.current().getNotificationSettings { settings in cont.resume(returning: settings.authorizationStatus) } } } } nonisolated extension SystemNotifier: UNUserNotificationCenterDelegate { func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { // Call completion handler directly without actor hop to avoid sending non-Sendable closure completionHandler([.banner, .list, .sound]) } } extension Notification.Name { static let codMateAgentCompleted = Notification.Name("CodMate.AgentCompleted") static let codMateStartEmbeddedNewProject = Notification.Name("CodMate.StartEmbeddedNewProject") static let codMateStartEmbeddedNewSession = Notification.Name("CodMate.StartEmbeddedNewSession") static let codMateToggleSidebar = Notification.Name("CodMate.ToggleSidebar") static let codMateToggleList = Notification.Name("CodMate.ToggleList") static let codMateRepoAuthorizationChanged = Notification.Name("CodMate.RepoAuthorizationChanged") static let codMateTerminalExited = Notification.Name("CodMate.TerminalExited") static let codMateTerminalSessionsUpdated = Notification.Name("CodMate.TerminalSessionsUpdated") static let codMateConversationFilter = Notification.Name("CodMate.ConversationFilter") static let codMateFocusGlobalSearch = Notification.Name("CodMate.FocusGlobalSearch") static let codMateExpandProjectTree = Notification.Name("CodMate.ExpandProjectTree") static let codMateResignQuickSearch = Notification.Name("CodMate.ResignQuickSearch") static let codMateQuickSearchFocusBlocked = Notification.Name("CodMate.QuickSearchFocusBlocked") static let codMateActiveProviderChanged = Notification.Name("CodMate.ActiveProviderChanged") static let codMateResumeSession = Notification.Name("CodMate.ResumeSession") static let codMateGlobalRefresh = Notification.Name("CodMate.GlobalRefresh") static let codMateOpenMainWindow = Notification.Name("CodMate.OpenMainWindow") static let codMateCollapseAllTasks = Notification.Name("CodMate.CollapseAllTasks") static let codMateExpandAllTasks = Notification.Name("CodMate.ExpandAllTasks") static let codMateOpenSettings = Notification.Name("CodMate.OpenSettings") static let codMateFocusSessionSummary = Notification.Name("CodMate.FocusSessionSummary") static let codMateOpenNewProject = Notification.Name("CodMate.OpenNewProject") static let codMateRefreshRequested = Notification.Name("CodMate.RefreshRequested") } ================================================ FILE: services/TasksStore.swift ================================================ import Foundation // TasksStore: manages task metadata and session-to-task relationships // Layout (under ~/.codmate/tasks): // - metadata/.json (one file per task) // - relationships.json (central mapping: { version, sessionToTask, taskToProject }) struct TaskMeta: Codable, Hashable, Sendable { var id: UUID var title: String var description: String? var taskType: TaskType var projectId: String var createdAt: Date var updatedAt: Date var sharedContext: [ContextItem] var agentsConfig: String? var memoryItems: [String] var sessionIds: [String] var status: TaskStatus var tags: [String] var primaryProvider: ProjectSessionSource? init(from task: CodMateTask) { self.id = task.id self.title = task.title self.description = task.description self.taskType = task.taskType self.projectId = task.projectId self.createdAt = task.createdAt self.updatedAt = task.updatedAt self.sharedContext = task.sharedContext self.agentsConfig = task.agentsConfig self.memoryItems = task.memoryItems self.sessionIds = task.sessionIds self.status = task.status self.tags = task.tags self.primaryProvider = task.primaryProvider } // Custom decoder to handle backward compatibility with old task data init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(UUID.self, forKey: .id) title = try container.decode(String.self, forKey: .title) description = try container.decodeIfPresent(String.self, forKey: .description) // Provide default value for taskType if not present (backward compatibility) taskType = try container.decodeIfPresent(TaskType.self, forKey: .taskType) ?? .other projectId = try container.decode(String.self, forKey: .projectId) createdAt = try container.decode(Date.self, forKey: .createdAt) updatedAt = try container.decode(Date.self, forKey: .updatedAt) sharedContext = try container.decode([ContextItem].self, forKey: .sharedContext) agentsConfig = try container.decodeIfPresent(String.self, forKey: .agentsConfig) memoryItems = try container.decode([String].self, forKey: .memoryItems) sessionIds = try container.decode([String].self, forKey: .sessionIds) status = try container.decode(TaskStatus.self, forKey: .status) tags = try container.decode([String].self, forKey: .tags) // primaryProvider is optional, so old data without it will have nil primaryProvider = try container.decodeIfPresent(ProjectSessionSource.self, forKey: .primaryProvider) } func asTask() -> CodMateTask { CodMateTask( id: id, title: title, description: description, taskType: taskType, projectId: projectId, createdAt: createdAt, updatedAt: updatedAt, sharedContext: sharedContext, agentsConfig: agentsConfig, memoryItems: memoryItems, sessionIds: sessionIds, status: status, tags: tags, primaryProvider: primaryProvider ) } } actor TasksStore { struct Paths { let root: URL let metadataDir: URL let relationshipsURL: URL static func `default`(fileManager: FileManager = .default) -> Paths { let home = fileManager.homeDirectoryForCurrentUser let root = home.appendingPathComponent(".codmate", isDirectory: true) .appendingPathComponent("tasks", isDirectory: true) return Paths( root: root, metadataDir: root.appendingPathComponent("metadata", isDirectory: true), relationshipsURL: root.appendingPathComponent("relationships.json", isDirectory: false) ) } } private let fm: FileManager private let paths: Paths // Runtime caches private var tasks: [UUID: TaskMeta] = [:] // taskId -> meta private var sessionToTask: [String: UUID] = [:] // sessionId -> taskId // Special "Others" task ID - consistent across sessions static let othersTaskId = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! init(paths: Paths = .default(), fileManager: FileManager = .default) { self.fm = fileManager self.paths = paths try? fm.createDirectory(at: paths.metadataDir, withIntermediateDirectories: true) // Load relationships if let data = try? Data(contentsOf: paths.relationshipsURL), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let map = obj["sessionToTask"] as? [String: String] { self.sessionToTask = map.compactMapValues { UUID(uuidString: $0) } } // Load metadata var loadedTasks: [UUID: TaskMeta] = [:] if let en = fm.enumerator(at: paths.metadataDir, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]) { let dec = JSONDecoder() dec.dateDecodingStrategy = .iso8601 for case let url as URL in en { if url.pathExtension.lowercased() != "json" { continue } if let data = try? Data(contentsOf: url), let meta = try? dec.decode(TaskMeta.self, from: data) { loadedTasks[meta.id] = meta } } } self.tasks = loadedTasks // Ensure "Others" task exists (directly inline to avoid actor isolation issue in init) if self.tasks[Self.othersTaskId] == nil { let othersTask = CodMateTask( id: Self.othersTaskId, title: "Others", description: "Automatically collected sessions without explicit task assignment", taskType: .other, projectId: "others", status: .inProgress ) let meta = TaskMeta(from: othersTask) self.tasks[Self.othersTaskId] = meta // Save to disk let url = paths.metadataDir.appendingPathComponent(meta.id.uuidString + ".json") let enc = JSONEncoder() enc.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes] enc.dateEncodingStrategy = .iso8601 if let data = try? enc.encode(meta) { try? data.write(to: url, options: .atomic) } } } // MARK: - Others Task Management func assignToOthers(sessionId: String) { assignSessions([sessionId], to: Self.othersTaskId) } // MARK: - Public API func listTasks() -> [CodMateTask] { tasks.values.map { $0.asTask() }.sorted { $0.updatedAt > $1.updatedAt } } func listTasks(for projectId: String) -> [CodMateTask] { tasks.values .filter { $0.projectId == projectId } .map { $0.asTask() } .sorted { $0.updatedAt > $1.updatedAt } } func getTask(id: UUID) -> CodMateTask? { tasks[id]?.asTask() } func upsertTask(_ task: CodMateTask) { var meta = tasks[task.id] ?? TaskMeta(from: task) meta.title = task.title meta.description = task.description meta.taskType = task.taskType meta.projectId = task.projectId meta.sharedContext = task.sharedContext meta.agentsConfig = task.agentsConfig meta.memoryItems = task.memoryItems meta.sessionIds = task.sessionIds meta.status = task.status meta.tags = task.tags meta.primaryProvider = task.primaryProvider meta.updatedAt = Date() tasks[task.id] = meta // Update session-to-task mappings for sessionId in task.sessionIds { sessionToTask[sessionId] = task.id } saveTaskMeta(meta) saveRelationships() } func deleteTask(id: UUID) { // Remove meta tasks.removeValue(forKey: id) let metaURL = paths.metadataDir.appendingPathComponent(id.uuidString + ".json") // Move to Trash instead of permanent deletion var resulting: NSURL? if fm.fileExists(atPath: metaURL.path) { do { try fm.trashItem(at: metaURL, resultingItemURL: &resulting) } catch { /* best-effort */ } } // Unassign all sessions under this task var changed = false for (sid, tid) in sessionToTask where tid == id { sessionToTask.removeValue(forKey: sid) changed = true } if changed { saveRelationships() } } func assignSessions(_ sessionIds: [String], to taskId: UUID?) { var changed = false for sid in sessionIds { let trimmed = sid.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { continue } if let tid = taskId { if sessionToTask[trimmed] != tid { sessionToTask[trimmed] = tid changed = true } } else { if sessionToTask.removeValue(forKey: trimmed) != nil { changed = true } } } if changed { saveRelationships() } } func taskId(for sessionId: String) -> UUID? { sessionToTask[sessionId] } func addContextItem(_ item: ContextItem, to taskId: UUID) { guard var meta = tasks[taskId] else { return } meta.sharedContext.append(item) meta.updatedAt = Date() tasks[taskId] = meta saveTaskMeta(meta) } func removeContextItem(id: UUID, from taskId: UUID) { guard var meta = tasks[taskId] else { return } meta.sharedContext.removeAll { $0.id == id } meta.updatedAt = Date() tasks[taskId] = meta saveTaskMeta(meta) } // MARK: - Private Methods private func saveTaskMeta(_ meta: TaskMeta) { try? fm.createDirectory(at: paths.metadataDir, withIntermediateDirectories: true) let url = paths.metadataDir.appendingPathComponent(meta.id.uuidString + ".json") let enc = JSONEncoder() enc.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes] enc.dateEncodingStrategy = .iso8601 if let data = try? enc.encode(meta) { try? data.write(to: url, options: .atomic) } } private func saveRelationships() { let sessionToTaskStrings = sessionToTask.mapValues { $0.uuidString } let obj: [String: Any] = [ "version": 1, "sessionToTask": sessionToTaskStrings ] if let data = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted]) { try? fm.createDirectory(at: paths.root, withIntermediateDirectories: true) try? data.write(to: paths.relationshipsURL, options: .atomic) } } } ================================================ FILE: services/TimelineAttachmentDecoder.swift ================================================ import Foundation struct TimelineAttachmentDecoder { static func decodeDataURL(_ dataURL: String) -> (data: Data, mimeType: String)? { let trimmed = dataURL.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.hasPrefix("data:") else { return nil } let parts = trimmed.split(separator: ",", maxSplits: 1, omittingEmptySubsequences: false) guard parts.count == 2 else { return nil } let meta = String(parts[0].dropFirst(5)) let dataPart = String(parts[1]) let metaParts = meta.split(separator: ";") let mimeType = metaParts.first.map(String.init) ?? "application/octet-stream" guard metaParts.contains("base64") else { return nil } guard let data = Data(base64Encoded: dataPart, options: [.ignoreUnknownCharacters]) else { return nil } return (data: data, mimeType: mimeType) } static func fileExtension(for mimeType: String) -> String { switch mimeType.lowercased() { case "image/png": return "png" case "image/jpeg", "image/jpg": return "jpg" case "image/gif": return "gif" case "image/webp": return "webp" case "image/heic": return "heic" case "image/heif": return "heif" case "image/tiff": return "tiff" case "image/bmp": return "bmp" case "image/svg+xml": return "svg" default: return "bin" } } static func imageData(for attachment: TimelineAttachment) -> Data? { if let url = attachment.url { guard url.isFileURL else { return nil } return try? Data(contentsOf: url) } if let dataURL = attachment.dataURL, let decoded = decodeDataURL(dataURL) { return decoded.data } return nil } } ================================================ FILE: services/TimelineAttachmentOpener.swift ================================================ import AppKit import Foundation final class TimelineAttachmentOpener { static let shared = TimelineAttachmentOpener() private let resolver = TimelineAttachmentResolver() private init() {} func open(_ attachment: TimelineAttachment) { guard let url = resolver.resolveURL(for: attachment) else { return } NSWorkspace.shared.open(url) } } private final class TimelineAttachmentResolver { private let fileManager = FileManager.default private var cache: [String: URL] = [:] private let baseURL: URL init() { baseURL = fileManager.temporaryDirectory .appendingPathComponent("CodMate-Attachments", isDirectory: true) try? fileManager.createDirectory(at: baseURL, withIntermediateDirectories: true) } func resolveURL(for attachment: TimelineAttachment) -> URL? { if let url = attachment.url { return url } guard let dataURL = attachment.dataURL else { return nil } if let cached = cache[attachment.id] { return cached } guard let resolved = Self.decodeDataURL(dataURL) else { return nil } let filename = "image-\(attachment.id).\(resolved.fileExtension)" let fileURL = baseURL.appendingPathComponent(filename) if !fileManager.fileExists(atPath: fileURL.path) { do { try resolved.data.write(to: fileURL, options: [.atomic]) } catch { return nil } } cache[attachment.id] = fileURL return fileURL } private static func decodeDataURL(_ dataURL: String) -> (data: Data, fileExtension: String)? { guard let decoded = TimelineAttachmentDecoder.decodeDataURL(dataURL) else { return nil } return (data: decoded.data, fileExtension: TimelineAttachmentDecoder.fileExtension(for: decoded.mimeType)) } } ================================================ FILE: services/UniImportMCPNormalizer.swift ================================================ import Foundation // MARK: - Uni-Import Normalizer (JSON-first MVP) enum UniImportError: Error, LocalizedError { case invalid, empty var errorDescription: String? { switch self { case .invalid: return "Failed to parse input" case .empty: return "No servers detected in the input" } } } struct UniImportMCPNormalizer { // Accept plain text (JSON snippets, fenced blocks, or raw object) static func parseText(_ text: String) throws -> [MCPServerDraft] { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw UniImportError.empty } // Try direct JSON (then a broad { ... } slice fallback) if let json = try? JSONSerialization.jsonObject(with: Data(trimmed.utf8)) { let drafts = draftFromJSON(json) if !drafts.isEmpty { return drafts } } // Fenced ```json blocks or widest {...} if let inner = extractJSONSlice(from: trimmed) { if let json = try? JSONSerialization.jsonObject(with: Data(inner.utf8)) { let drafts = draftFromJSON(json) if !drafts.isEmpty { return drafts } } } // Try TOML (heuristic): extract a TOML-looking slice and parse minimal keys if let tomlSlice = extractTOMLSlice(from: trimmed) { let drafts = draftFromTOML(tomlSlice) if !drafts.isEmpty { return drafts } } throw UniImportError.invalid } // Very small heuristic to grab a JSON-looking slice private static func extractJSONSlice(from text: String) -> String? { if let m = text.range(of: "```json", options: .caseInsensitive), let end = text.range(of: "```", range: m.upperBound.. s { return String(text[s...e]) } return nil } private static func normString(_ v: Any?) -> String? { guard let s = v as? String else { return nil } let t = s.trimmingCharacters(in: .whitespacesAndNewlines) return t.isEmpty ? nil : t } private static func normStringArray(_ v: Any?) -> [String]? { if let a = v as? [Any] { let mapped = a.compactMap { ($0 as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } return mapped.isEmpty ? nil : mapped } if let s = normString(v) { let parts = s.split(whereSeparator: { $0.isWhitespace }).map { String($0) } return parts.isEmpty ? nil : parts } return nil } private static func normDict(_ v: Any?) -> [String: String]? { guard let o = v as? [String: Any] else { return nil } var out: [String: String] = [:] for (k, raw) in o { out[k] = (raw as? String) ?? String(describing: raw) } return out.isEmpty ? nil : out } private static func parseKind(_ value: Any?) -> MCPServerKind { let token = (value as? String)?.lowercased() ?? "" switch token { case "sse", "server-sent-events": return .sse case "streamable_http", "streamable-http", "http", "http_stream": return .streamable_http default: return .stdio } } private static func buildDraft(name: Any?, config: Any?) -> MCPServerDraft? { guard let raw = config as? [String: Any] else { return nil } let n = normString(name) ?? normString(raw["name"]) ?? "imported-server" let kind = parseKind(raw["kind"] ?? raw["type"] ?? raw["server_type"]) let command = normString(raw["command"] ?? raw["command_path"] ?? raw["launch"]) ?? nil let args = normStringArray(raw["args"]) ?? nil let env = normDict(raw["env"]) ?? nil let url = normString(raw["url"] ?? raw["endpoint"] ?? raw["baseUrl"]) ?? nil let headers = normDict(raw["headers"]) ?? nil var meta = MCPServerMeta() meta.description = normString(raw["description"]) ?? normString((raw["meta"] as? [String: Any])?["description"]) ?? nil meta.version = normString((raw["meta"] as? [String: Any])?["version"]) ?? nil meta.websiteUrl = normString((raw["meta"] as? [String: Any])?["websiteUrl"]) ?? nil meta.repositoryURL = normString((raw["meta"] as? [String: Any])?["repository"]) ?? nil return MCPServerDraft(name: n, kind: kind, command: command, args: args, env: env, url: url, headers: headers, meta: meta) } private static func draftFromJSON(_ json: Any) -> [MCPServerDraft] { guard let obj = json as? [String: Any] else { return [] } if let servers = obj["mcpServers"] as? [String: Any] { return servers.compactMap { key, value in buildDraft(name: key, config: value) } } if let servers = obj["servers"] as? [String: Any] { let drafts = servers.compactMap { key, value in buildDraft(name: key, config: value) } if !drafts.isEmpty { return drafts } } if let array = obj["servers"] as? [Any] { return array.compactMap { entry in let n = (entry as? [String: Any])?["name"] return buildDraft(name: n, config: entry) } } if let single = buildDraft(name: obj["name"], config: obj) { return [single] } return [] } // MARK: - Minimal TOML support (heuristic) private static func extractTOMLSlice(from text: String) -> String? { // 1) fenced ```toml if let m = text.range(of: "```toml", options: .regularExpression), let end = text.range(of: "```", range: m.upperBound.. Bool = { l in let trimmed = l.trimmingCharacters(in: .whitespaces) if trimmed.isEmpty { return true } if trimmed.hasPrefix("#") { return true } if trimmed.contains("=") { return true } return false } var start = -1 for (i, l) in lines.enumerated() { if let rx = sectionRegex, rx.firstMatch(in: l, options: [], range: NSRange(location: 0, length: l.utf16.count)) != nil { start = i; break } } if start >= 0 { var end = lines.count var nonToml = 0 for j in start..= 2 { end = j - 1; break } } return lines[start.. [String]? { // very small parser for ["a", "b"] or [a, b] let inner = s.trimmingCharacters(in: .whitespaces) guard inner.first == "[", inner.last == "]" else { return nil } let body = inner.dropFirst().dropLast() let parts = body.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } var out: [String] = [] for p in parts { var t = p if t.hasPrefix("\"") && t.hasSuffix("\"") { t = String(t.dropFirst().dropLast()) } if t.hasPrefix("'") && t.hasSuffix("'") { t = String(t.dropFirst().dropLast()) } if !t.isEmpty { out.append(String(t)) } } return out.isEmpty ? nil : out } private static func parseTomlInlineTable(_ s: String) -> [String: String]? { // { key = "v", k2 = "v2" } let inner = s.trimmingCharacters(in: .whitespaces) guard inner.first == "{", inner.last == "}" else { return nil } let body = inner.dropFirst().dropLast() var out: [String: String] = [:] for pair in body.split(separator: ",") { let kv = pair.split(separator: "=", maxSplits: 1).map { $0.trimmingCharacters(in: .whitespaces) } if kv.count == 2 { let key = kv[0].trimmingCharacters(in: CharacterSet(charactersIn: "\"' ")) var val = kv[1].trimmingCharacters(in: .whitespaces) if val.hasPrefix("\"") && val.hasSuffix("\"") { val = String(val.dropFirst().dropLast()) } if val.hasPrefix("'") && val.hasSuffix("'") { val = String(val.dropFirst().dropLast()) } out[key] = val } } return out.isEmpty ? nil : out } private static func parseTomlScalar(_ v: String) -> String? { var t = v.trimmingCharacters(in: .whitespaces) if t.hasPrefix("\"") && t.hasSuffix("\"") { t = String(t.dropFirst().dropLast()) } if t.hasPrefix("'") && t.hasSuffix("'") { t = String(t.dropFirst().dropLast()) } return t } private static func draftFromTOML(_ text: String) -> [MCPServerDraft] { var drafts: [MCPServerDraft] = [] var currentName: String? = nil var current: [String: String] = [:] func flushCurrent() { guard let name = currentName else { return } // build draft from current kv let kindToken = (current["kind"] ?? current["type"] ?? current["server_type"])?.lowercased() let kind: MCPServerKind = { switch kindToken { case "sse", "server-sent-events": return .sse case "streamable_http", "streamable-http", "http", "http_stream": return .streamable_http default: return .stdio } }() let args = current["args"].flatMap(parseTomlArray) let env = current["env"].flatMap(parseTomlInlineTable) let headers = current["headers"].flatMap(parseTomlInlineTable) let meta = MCPServerMeta(description: current["meta.description"], version: current["meta.version"], websiteUrl: current["meta.websiteUrl"] ?? current["meta.website_url"], repositoryURL: current["meta.repository"]) let draft = MCPServerDraft( name: name, kind: kind, command: current["command"], args: args, env: env, url: current["url"] ?? current["endpoint"] ?? current["baseUrl"], headers: headers, meta: meta ) drafts.append(draft) current.removeAll(keepingCapacity: true) } // Normalize lines and iterate let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) let sectionRx = try? NSRegularExpression(pattern: "^\\s*\\[(.+)\\]\\s*$") let doubleSectionRx = try? NSRegularExpression(pattern: "^\\s*\\[\\[(.+)\\]\\]\\s*$") for rawLine in lines { let line = rawLine.trimmingCharacters(in: .whitespaces) if line.isEmpty || line.hasPrefix("#") { continue } // Section headers if let rx = doubleSectionRx, let _ = rx.firstMatch(in: line, range: NSRange(location: 0, length: line.utf16.count)) { // e.g., [[servers]] flushCurrent() currentName = nil continue } if let rx = sectionRx, let m = rx.firstMatch(in: line, range: NSRange(location: 0, length: line.utf16.count)) { let r = m.range(at: 1) if let rr = Range(r, in: line) { let section = String(line[rr]) if section.lowercased().hasPrefix("mcp_servers.") { flushCurrent() let name = String(section.dropFirst("mcp_servers.".count)) currentName = name } else if section.lowercased().hasPrefix("servers.") { flushCurrent() let name = String(section.dropFirst("servers.".count)) currentName = name } else { flushCurrent() currentName = nil } } continue } // key = value guard let eq = line.firstIndex(of: "=") else { continue } let key = line[.. Release { try JSONDecoder().decode(Release.self, from: data) } } private let defaults: UserDefaults private let session: URLSession private let calendar: Calendar private struct Keys { static let lastCheckDay = "codmate.update.lastCheckDay" static let lastCheckTimestamp = "codmate.update.lastCheckTimestamp" static let latestVersion = "codmate.update.latestVersion" static let latestAssetURL = "codmate.update.latestAssetURL" static let latestAssetName = "codmate.update.latestAssetName" static let latestReleaseURL = "codmate.update.latestReleaseURL" } init( defaults: UserDefaults = .standard, session: URLSession = .shared, calendar: Calendar = .current ) { self.defaults = defaults self.session = session self.calendar = calendar } func cachedInfo() -> UpdateInfo? { guard let version = defaults.string(forKey: Keys.latestVersion), let assetURLString = defaults.string(forKey: Keys.latestAssetURL), let assetURL = URL(string: assetURLString), let assetName = defaults.string(forKey: Keys.latestAssetName), let releaseURLString = defaults.string(forKey: Keys.latestReleaseURL), let releaseURL = URL(string: releaseURLString) else { return nil } return UpdateInfo(latestVersion: version, releaseURL: releaseURL, assetName: assetName, assetURL: assetURL) } func lastCheckedAt() -> Date? { let timestamp = defaults.double(forKey: Keys.lastCheckTimestamp) if timestamp == 0 { return nil } return Date(timeIntervalSince1970: timestamp) } func checkIfNeeded(trigger: CheckTrigger) async -> UpdateState { if AppDistribution.isAppStore { return .error("Updates are disabled in the App Store build.") } let todayKey = dayKey(Date()) let lastKey = defaults.string(forKey: Keys.lastCheckDay) if (trigger == .appLaunch || trigger == .aboutAuto), lastKey == todayKey { if let cached = cachedInfo() { return availability(for: cached) } return .idle } return await checkNow() } func checkNow() async -> UpdateState { if AppDistribution.isAppStore { return .error("Updates are disabled in the App Store build.") } let now = Date() recordCheckAttempt(now) do { var request = URLRequest(url: URL(string: "https://api.github.com/repos/loocor/CodMate/releases/latest")!) request.setValue("CodMate", forHTTPHeaderField: "User-Agent") let (data, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { return .error("Invalid response") } guard http.statusCode == 200 else { return .error("HTTP \(http.statusCode)") } let release = try Release.decode(from: data) if release.isDraft || release.isPrerelease { return .error("No stable release available") } let assetName = UpdateAssetSelector.assetName(for: .current) guard let asset = release.assets.first(where: { $0.name == assetName }) else { return .error("No asset for current architecture") } let latestVersion = release.tagName cache(latestVersion: latestVersion, asset: asset, releaseURL: release.htmlURL) let info = UpdateInfo( latestVersion: latestVersion, releaseURL: release.htmlURL, assetName: asset.name, assetURL: asset.browserDownloadURL ) return availability(for: info) } catch { return .error(error.localizedDescription) } } private func availability(for info: UpdateInfo) -> UpdateState { let current = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0" guard let currentVersion = Version(current), let latestVersion = Version(info.latestVersion) else { return .updateAvailable(info) } if latestVersion > currentVersion { return .updateAvailable(info) } return .upToDate(current: current, latest: info.latestVersion) } private func cache(latestVersion: String, asset: Release.Asset, releaseURL: URL) { defaults.set(latestVersion, forKey: Keys.latestVersion) defaults.set(asset.name, forKey: Keys.latestAssetName) defaults.set(asset.browserDownloadURL.absoluteString, forKey: Keys.latestAssetURL) defaults.set(releaseURL.absoluteString, forKey: Keys.latestReleaseURL) } private func recordCheckAttempt(_ date: Date) { defaults.set(dayKey(date), forKey: Keys.lastCheckDay) defaults.set(date.timeIntervalSince1970, forKey: Keys.lastCheckTimestamp) } private func dayKey(_ date: Date) -> String { let comps = calendar.dateComponents([.year, .month, .day], from: date) let y = comps.year ?? 0 let m = comps.month ?? 0 let d = comps.day ?? 0 return String(format: "%04d-%02d-%02d", y, m, d) } } ================================================ FILE: services/WindowStateStore.swift ================================================ import Foundation import CoreGraphics /// Persists and restores the main window state across app launches @MainActor final class WindowStateStore: ObservableObject { private let defaults: UserDefaults private struct Keys { static let selectedProjectIDs = "codmate.window.selectedProjectIDs" static let selectedDay = "codmate.window.selectedDay" static let selectedDays = "codmate.window.selectedDays" static let monthStart = "codmate.window.monthStart" static let selectedSessionIDs = "codmate.window.selectedSessionIDs" static let selectionPrimaryId = "codmate.window.selectionPrimaryId" static let contentColumnWidth = "codmate.window.contentColumnWidth" static let reviewLeftPaneWidth = "codmate.window.reviewLeftPaneWidth" static let expandedProjects = "codmate.window.expandedProjects" } init(defaults: UserDefaults = .standard) { self.defaults = defaults } // MARK: - Save State func saveProjectSelection(_ projectIDs: Set) { let array = Array(projectIDs) defaults.set(array, forKey: Keys.selectedProjectIDs) } func saveCalendarSelection(selectedDay: Date?, selectedDays: Set, monthStart: Date) { if let day = selectedDay { defaults.set(day.timeIntervalSinceReferenceDate, forKey: Keys.selectedDay) } else { defaults.removeObject(forKey: Keys.selectedDay) } let intervals = selectedDays.map { $0.timeIntervalSinceReferenceDate } defaults.set(intervals, forKey: Keys.selectedDays) defaults.set(monthStart.timeIntervalSinceReferenceDate, forKey: Keys.monthStart) } func saveSessionSelection(selectedIDs: Set, primaryId: SessionSummary.ID?) { let array = Array(selectedIDs) defaults.set(array, forKey: Keys.selectedSessionIDs) if let primary = primaryId { defaults.set(primary, forKey: Keys.selectionPrimaryId) } else { defaults.removeObject(forKey: Keys.selectionPrimaryId) } } // MARK: - Column Width Persistence func saveContentColumnWidth(_ width: CGFloat) { defaults.set(Double(width), forKey: Keys.contentColumnWidth) } func restoreContentColumnWidth() -> CGFloat? { let w = defaults.double(forKey: Keys.contentColumnWidth) return w > 0 ? CGFloat(w) : nil } func saveReviewLeftPaneWidth(_ width: CGFloat) { defaults.set(Double(width), forKey: Keys.reviewLeftPaneWidth) } func restoreReviewLeftPaneWidth() -> CGFloat? { let w = defaults.double(forKey: Keys.reviewLeftPaneWidth) return w > 0 ? CGFloat(w) : nil } // MARK: - Restore State func restoreProjectSelection() -> Set { guard let array = defaults.array(forKey: Keys.selectedProjectIDs) as? [String] else { return [] } return Set(array) } func restoreCalendarSelection() -> ( selectedDay: Date?, selectedDays: Set, monthStart: Date? ) { let selectedDay: Date? = { let interval = defaults.double(forKey: Keys.selectedDay) guard interval != 0 else { return nil } return Date(timeIntervalSinceReferenceDate: interval) }() let selectedDays: Set = { guard let intervals = defaults.array(forKey: Keys.selectedDays) as? [TimeInterval] else { return [] } return Set(intervals.map { Date(timeIntervalSinceReferenceDate: $0) }) }() let monthStart: Date? = { let interval = defaults.double(forKey: Keys.monthStart) guard interval != 0 else { return nil } return Date(timeIntervalSinceReferenceDate: interval) }() return (selectedDay, selectedDays, monthStart) } func restoreSessionSelection() -> ( selectedIDs: Set, primaryId: SessionSummary.ID? ) { let selectedIDs: Set = { guard let array = defaults.array(forKey: Keys.selectedSessionIDs) as? [String] else { return [] } return Set(array) }() let primaryId = defaults.string(forKey: Keys.selectionPrimaryId) return (selectedIDs, primaryId) } func saveProjectExpansions(_ ids: Set) { defaults.set(Array(ids), forKey: Keys.expandedProjects) } func restoreProjectExpansions() -> Set { guard let array = defaults.array(forKey: Keys.expandedProjects) as? [String] else { return [] } return Set(array) } // MARK: - Clear State func clearAll() { defaults.removeObject(forKey: Keys.selectedProjectIDs) defaults.removeObject(forKey: Keys.selectedDay) defaults.removeObject(forKey: Keys.selectedDays) defaults.removeObject(forKey: Keys.monthStart) defaults.removeObject(forKey: Keys.contentColumnWidth) defaults.removeObject(forKey: Keys.reviewLeftPaneWidth) defaults.removeObject(forKey: Keys.selectedSessionIDs) defaults.removeObject(forKey: Keys.selectionPrimaryId) defaults.removeObject(forKey: Keys.expandedProjects) } } ================================================ FILE: services/WizardDocsService.swift ================================================ import Foundation import AppKit actor WizardDocsService { private struct DocsIndex: Codable { var sources: [WizardDocSource] } private struct CachedDoc: Codable { var url: String var fetchedAt: Date var text: String } private let fileManager: FileManager private let cacheURL: URL private var cache: [String: CachedDoc] private var globalSources: [WizardDocSource] init() { let fm = FileManager.default fileManager = fm cacheURL = Self.defaultCacheURL(using: fm) globalSources = Self.loadGlobalSourcesSync() cache = Self.loadCacheSync(cacheURL: cacheURL) } func snippets( feature: WizardFeature, provider: SessionSource.Kind, overrides: [WizardDocSource] = [], keywords: [String] = [] ) async -> [WizardDocSnippet] { let sources = mergedSources(feature: feature, provider: provider, overrides: overrides) guard !sources.isEmpty else { return [] } var out: [WizardDocSnippet] = [] for src in sources { let text = await loadText(from: src) guard !text.isEmpty else { continue } let filtered = extractRelevant(text: text, keywords: keywords, maxChars: src.maxChars) if !filtered.isEmpty { out.append(WizardDocSnippet(url: src.url, provider: src.provider, text: filtered)) } } return out } // MARK: - Sources private func mergedSources( feature: WizardFeature, provider: SessionSource.Kind, overrides: [WizardDocSource] ) -> [WizardDocSource] { let providerKey = provider.rawValue var out: [WizardDocSource] = [] let fromOverrides = overrides.filter { $0.feature == feature && ($0.provider == nil || $0.provider == providerKey) } if !fromOverrides.isEmpty { out.append(contentsOf: fromOverrides) } let fromGlobal = globalSources.filter { $0.feature == feature && ($0.provider == nil || $0.provider == providerKey) } out.append(contentsOf: fromGlobal) return out } private static func defaultCacheURL(using fileManager: FileManager) -> URL { let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first return (caches ?? fileManager.temporaryDirectory) .appendingPathComponent("CodMate", isDirectory: true) .appendingPathComponent("wizard-docs-cache.json", isDirectory: false) } private static func loadGlobalSourcesSync() -> [WizardDocSource] { let bundle = Bundle.main var url = bundle.url( forResource: "wizard-docs", withExtension: "json", subdirectory: "payload/knowledge" ) if url == nil, let devRoot = devPayloadRootURL() { url = devRoot .appendingPathComponent("knowledge", isDirectory: true) .appendingPathComponent("wizard-docs.json", isDirectory: false) } guard let resolved = url else { return [] } guard let data = try? Data(contentsOf: resolved) else { return [] } let decoder = JSONDecoder() let parsed = (try? decoder.decode(DocsIndex.self, from: data))?.sources ?? [] return parsed } private static func devPayloadRootURL() -> URL? { let fm = FileManager.default let cwd = URL(fileURLWithPath: fm.currentDirectoryPath, isDirectory: true) if let found = findPayloadRoot(startingAt: cwd, fileManager: fm) { return found } if let execURL = Bundle.main.executableURL { let execDir = execURL.deletingLastPathComponent() if let found = findPayloadRoot(startingAt: execDir, fileManager: fm) { return found } } return nil } private static func findPayloadRoot(startingAt start: URL, fileManager: FileManager) -> URL? { var current = start for _ in 0..<6 { let candidate = current .appendingPathComponent("payload", isDirectory: true) .appendingPathComponent("knowledge", isDirectory: true) .appendingPathComponent("wizard-docs.json", isDirectory: false) if fileManager.fileExists(atPath: candidate.path) { return current.appendingPathComponent("payload", isDirectory: true) } current = current.deletingLastPathComponent() } return nil } // MARK: - Cache private static func loadCacheSync(cacheURL: URL) -> [String: CachedDoc] { guard let data = try? Data(contentsOf: cacheURL) else { return [:] } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 if let list = try? decoder.decode([CachedDoc].self, from: data) { return Dictionary(uniqueKeysWithValues: list.map { ($0.url, $0) }) } return [:] } private func saveCache() { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes] encoder.dateEncodingStrategy = .iso8601 let list = Array(cache.values) guard let data = try? encoder.encode(list) else { return } try? fileManager.createDirectory(at: cacheURL.deletingLastPathComponent(), withIntermediateDirectories: true) try? data.write(to: cacheURL, options: .atomic) } // MARK: - Fetch private func loadText(from source: WizardDocSource) async -> String { let ttl = TimeInterval((source.cacheTTLHours ?? 72) * 3600) if let cached = cache[source.url], Date().timeIntervalSince(cached.fetchedAt) < ttl { return cached.text } guard let url = URL(string: source.url) else { return "" } do { let (data, _) = try await URLSession.shared.data(from: url) let text = decodeHTML(data) ?? String(data: data, encoding: .utf8) ?? "" if !text.isEmpty { cache[source.url] = CachedDoc(url: source.url, fetchedAt: Date(), text: text) saveCache() } return text } catch { return "" } } private func decodeHTML(_ data: Data) -> String? { let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [ .documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue ] if let attributed = try? NSAttributedString(data: data, options: options, documentAttributes: nil) { return attributed.string } return nil } private func extractRelevant(text: String, keywords: [String], maxChars: Int?) -> String { let limit = maxChars ?? 3000 let trimmed = text.replacingOccurrences(of: "\r", with: "") let lines = trimmed.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) if keywords.isEmpty { return String(trimmed.prefix(limit)) } let loweredKeywords = keywords.map { $0.lowercased() } var matched: [String] = [] for (idx, line) in lines.enumerated() { let lower = line.lowercased() if loweredKeywords.contains(where: { lower.contains($0) }) { let prev = idx > 0 ? lines[idx - 1] : "" let next = idx + 1 < lines.count ? lines[idx + 1] : "" matched.append(prev) matched.append(line) matched.append(next) } } let joined = matched.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) if joined.isEmpty { return String(trimmed.prefix(limit)) } return String(joined.prefix(limit)) } } ================================================ FILE: services/WizardResponseParser.swift ================================================ import Foundation enum WizardResponseParser { static func decode(_ raw: String) -> T? { let cleaned = stripCodeFences(raw) if let value: T = decodeJSON(cleaned) { return value } if let unwrapped = unwrapPayloadText(cleaned), unwrapped != cleaned { return decode(unwrapped) } return nil } static func decodeEnvelope(_ raw: String) -> WizardDraftEnvelope? { let cleaned = stripCodeFences(raw) if let envelope: WizardDraftEnvelope = decodeJSON(cleaned) { return envelope } if let unwrapped = unwrapPayloadText(cleaned), unwrapped != cleaned { return decodeEnvelope(unwrapped) } return nil } private static func decodeJSON(_ raw: String) -> T? { guard let data = raw.data(using: .utf8) else { return nil } return try? JSONDecoder().decode(T.self, from: data) } private static func unwrapPayloadText(_ raw: String) -> String? { guard let data = raw.data(using: .utf8) else { return nil } guard let json = try? JSONSerialization.jsonObject(with: data) else { return nil } return extractText(from: json) } private static func extractText(from value: Any) -> String? { if let text = value as? String { return text.trimmingCharacters(in: .whitespacesAndNewlines) } if let dict = value as? [String: Any] { let keys = ["result", "response", "content", "text", "message", "output"] for key in keys { if let nested = dict[key], let text = extractText(from: nested) { return text } } } if let array = value as? [Any] { for item in array { if let text = extractText(from: item) { return text } } } return nil } private static func stripCodeFences(_ raw: String) -> String { var cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines) if cleaned.hasPrefix("```") { if let firstNewline = cleaned.firstIndex(of: "\n") { cleaned = String(cleaned[cleaned.index(after: firstNewline)...]) } if let lastFence = cleaned.range(of: "```", options: .backwards) { cleaned = String(cleaned[.. Bool { guard let bundleIdentifier else { return false } return NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) != nil } static func isInstalled(bundleIdentifiers: [String]) -> Bool { for identifier in bundleIdentifiers { if isInstalled(bundleIdentifier: identifier) { return true } } return false } static func firstInstalledBundleIdentifier(in identifiers: [String]) -> String? { for identifier in identifiers { if isInstalled(bundleIdentifier: identifier) { return identifier } } return nil } } ================================================ FILE: utils/AppDistribution.swift ================================================ import Foundation /// Build/distribution flags and helpers. enum AppDistribution { #if APPSTORE static let isAppStore = true #else static let isAppStore = false #endif } ================================================ FILE: utils/AppSandbox.swift ================================================ import Foundation import Security enum AppSandbox { static var isEnabled: Bool { // Primary: query entitlement from our own signed task if let task = SecTaskCreateFromSelf(nil) { if let val = SecTaskCopyValueForEntitlement(task, "com.apple.security.app-sandbox" as CFString, nil) as? Bool { return val } } // Fallback: environment probe (not always present on Developer ID builds) return ProcessInfo.processInfo.environment["APP_SANDBOX_CONTAINER_ID"] != nil } } ================================================ FILE: utils/CLIEnvironment.swift ================================================ import Foundation #if canImport(Darwin) import Darwin #endif /// Unified CLI environment configuration for embedded terminals and external shells enum CLIEnvironment { static let defaultExecutableNames = ["codex", "claude", "gemini"] /// Standard PATH components that include common CLI tool locations /// - Includes: ~/.local/bin (claude), /opt/homebrew/bin (codex on M1), /// /usr/local/bin (codex on Intel), and standard system paths static let standardPathComponents = [ "$HOME/.bun/bin", "$HOME/.local/bin", "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin" ] // Detect common version-manager bins (nvm/fnm/volta/asdf/nodenv/nodebrew/etc.) that // actually contain codex/claude/gemini, so PATH stays lean but flexible. private static let detectedPathComponentsCache: [String] = detectPathComponents( for: defaultExecutableNames ) /// Build an injected PATH string that prepends standard paths to existing PATH /// - Parameter additionalPaths: Optional array of additional paths to prepend /// - Returns: A PATH string ready to be exported or used in shell commands static func buildInjectedPATH(additionalPaths: [String] = []) -> String { let components = resolvedPathComponents( additionalPaths: additionalPaths, expandHome: false ) return components.joined(separator: ":") + ":${PATH}" } /// Build an injected PATH string without preserving existing PATH /// Useful for ProcessInfo environment where PATH is merged differently /// - Parameter additionalPaths: Optional array of additional paths to prepend /// - Returns: A PATH string without ${PATH} suffix static func buildBasePATH(additionalPaths: [String] = []) -> String { let components = resolvedPathComponents( additionalPaths: additionalPaths, expandHome: true ) return components.joined(separator: ":") } /// Standard locale environment variables for zh_CN UTF-8 static let standardLocaleEnv: [String: String] = [ "LANG": "zh_CN.UTF-8", "LC_ALL": "zh_CN.UTF-8", "LC_CTYPE": "zh_CN.UTF-8" ] /// Standard terminal environment static let standardTermEnv: [String: String] = [ "TERM": "xterm-256color" ] /// Build export lines for shell scripts /// - Parameters: /// - includeLocale: Include locale environment variables /// - includeTerm: Include TERM environment variable /// - additional: Additional environment variables to export /// - Returns: Array of export statements static func buildExportLines( includeLocale: Bool = true, includeTerm: Bool = true, additional: [String: String] = [:] ) -> [String] { var lines: [String] = [] if includeLocale { for (key, value) in standardLocaleEnv { lines.append("export \(key)=\(value)") } } if includeTerm { for (key, value) in standardTermEnv { lines.append("export \(key)=\(value)") } } for (key, value) in additional { lines.append("export \(key)=\(value)") } return lines } static func resolvedPATHForCLI(sandboxed: Bool? = nil) -> String { let isSandboxed = sandboxed ?? (ProcessInfo.processInfo.environment["APP_SANDBOX_CONTAINER_ID"] != nil) let base = buildBasePATH() if isSandboxed { return base } var paths: [String] = [base] if let shellPath = detectLoginShellPATH(), !shellPath.isEmpty { paths.append(shellPath) } let current = ProcessInfo.processInfo.environment["PATH"] ?? "" if !current.isEmpty { paths.append(current) } return mergePATHStrings(paths) } static func resolveExecutablePath(_ name: String, path: String) -> String? { if let resolved = which(name, path: path), let sanitized = sanitizeExecutablePath(resolved) { return sanitized } if let resolved = shellWhich(name), let sanitized = sanitizeExecutablePath(resolved) { return sanitized } return manualResolve(name, path: path) } static func version(of name: String, path: String) -> String? { let candidates: [[String]] = [["--version"], ["version"], ["-V"], ["-v"]] for args in candidates { var env = ProcessInfo.processInfo.environment env["PATH"] = path env["NO_COLOR"] = "1" guard let result = runProcess( executable: "/usr/bin/env", arguments: [name] + args, environment: env, timeout: 1.5 ) else { continue } if result.timedOut { NSLog("[CLIEnvironment] version probe timed out: %@ %@", name, args.joined(separator: " ")) return nil } let out = result.stdout let err = result.stderr if out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { var fallback = err fallback = fallback.trimmingCharacters(in: .whitespacesAndNewlines) if fallback.isEmpty { continue } if let firstLine = fallback.split(separator: "\n").first { fallback = String(firstLine) } if let ver = firstVersionToken(in: fallback) { return ver } return String(fallback.prefix(48)) } var cleaned = out.trimmingCharacters(in: .whitespacesAndNewlines) if cleaned.isEmpty { continue } if let firstLine = cleaned.split(separator: "\n").first { cleaned = String(firstLine) } if let ver = firstVersionToken(in: cleaned) { return ver } return String(cleaned.prefix(48)) } return nil } static func version(atExecutablePath executablePath: String, path: String) -> String? { let candidates: [[String]] = [["--version"], ["version"], ["-V"], ["-v"]] for args in candidates { var env = ProcessInfo.processInfo.environment env["PATH"] = path env["NO_COLOR"] = "1" guard let result = runProcess( executable: executablePath, arguments: args, environment: env, timeout: 1.5 ) else { continue } if result.timedOut { NSLog("[CLIEnvironment] version probe timed out: %@ %@", executablePath, args.joined(separator: " ")) return nil } let out = result.stdout let err = result.stderr if out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { var fallback = err fallback = fallback.trimmingCharacters(in: .whitespacesAndNewlines) if fallback.isEmpty { continue } if let firstLine = fallback.split(separator: "\n").first { fallback = String(firstLine) } if let ver = firstVersionToken(in: fallback) { return ver } return String(fallback.prefix(48)) } var cleaned = out.trimmingCharacters(in: .whitespacesAndNewlines) if cleaned.isEmpty { continue } if let firstLine = cleaned.split(separator: "\n").first { cleaned = String(firstLine) } if let ver = firstVersionToken(in: cleaned) { return ver } return String(cleaned.prefix(48)) } return nil } // MARK: - PATH resolution helpers private static func resolvedPathComponents( additionalPaths: [String], expandHome: Bool ) -> [String] { var components = additionalPaths components.append(contentsOf: detectedPathComponentsCache) components.append(contentsOf: standardPathComponents) let mapped = components.map { expandHome ? expandHomePath($0) : $0 } let filtered = mapped.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } return dedupePreservingOrder(filtered) } private static func expandHomePath(_ path: String) -> String { if path.hasPrefix("~") { return (path as NSString).expandingTildeInPath } if path.contains("$HOME") { return path.replacingOccurrences(of: "$HOME", with: NSHomeDirectory()) } return path } private static func dedupePreservingOrder(_ items: [String]) -> [String] { var seen = Set() var result: [String] = [] for item in items where !item.isEmpty { if seen.insert(item).inserted { result.append(item) } } return result } private static func detectPathComponents(for executables: [String]) -> [String] { let fm = FileManager.default let env = ProcessInfo.processInfo.environment let home = NSHomeDirectory() func containsExecutable(in dir: String) -> Bool { for name in executables { let candidate = (dir as NSString).appendingPathComponent(name) if fm.isExecutableFile(atPath: candidate) { return true } } return false } func addIfExecutable(_ rawDir: String, to results: inout [String]) { let dir = expandHomePath(rawDir) guard !dir.isEmpty, fm.fileExists(atPath: dir) else { return } if containsExecutable(in: dir) { results.append(dir) } } func parseSemver(_ raw: String) -> [Int]? { var s = raw.trimmingCharacters(in: .whitespacesAndNewlines) if s.hasPrefix("v") { s.removeFirst() } let parts = s.split(separator: ".") guard !parts.isEmpty else { return nil } var out: [Int] = [] for p in parts { guard let v = Int(p) else { return nil } out.append(v) } return out } func compareSemver(_ a: [Int], _ b: [Int]) -> Int { let count = max(a.count, b.count) for i in 0.. bv ? 1 : -1 } } return 0 } func bestNvmBin(root: String) -> String? { guard let entries = try? fm.contentsOfDirectory(atPath: root) else { return nil } var candidates: [(bin: String, version: [Int]?, modified: Date?)] = [] for name in entries { let dir = (root as NSString).appendingPathComponent(name) let bin = (dir as NSString).appendingPathComponent("bin") if !fm.fileExists(atPath: bin) { continue } if !containsExecutable(in: bin) { continue } let version = parseSemver(name) let modified = (try? fm.attributesOfItem(atPath: bin)[.modificationDate]) as? Date candidates.append((bin: bin, version: version, modified: modified)) } guard !candidates.isEmpty else { return nil } candidates.sort { lhs, rhs in switch (lhs.version, rhs.version) { case let (lv?, rv?): return compareSemver(lv, rv) > 0 case (nil, nil): if let lm = lhs.modified, let rm = rhs.modified, lm != rm { return lm > rm } return lhs.bin > rhs.bin case (_?, nil): return true case (nil, _?): return false } } return candidates.first?.bin } var results: [String] = [] if let nvmBin = env["NVM_BIN"], !nvmBin.isEmpty { addIfExecutable(nvmBin, to: &results) } if let npmPrefix = env["NPM_CONFIG_PREFIX"], !npmPrefix.isEmpty { addIfExecutable(npmPrefix + "/bin", to: &results) } if let pnpmHome = env["PNPM_HOME"], !pnpmHome.isEmpty { addIfExecutable(pnpmHome, to: &results) } if let bunInstall = env["BUN_INSTALL"], !bunInstall.isEmpty { addIfExecutable(bunInstall + "/bin", to: &results) } let nvmDir = env["NVM_DIR"] ?? (home + "/.nvm") let nvmVersions = (nvmDir as NSString).appendingPathComponent("versions/node") if let nvmBest = bestNvmBin(root: nvmVersions) { results.append(nvmBest) } let voltaHome = env["VOLTA_HOME"] ?? (home + "/.volta") addIfExecutable(voltaHome + "/bin", to: &results) let fnmDir = env["FNM_DIR"] ?? (home + "/.fnm") addIfExecutable(fnmDir + "/current/bin", to: &results) let asdfDir = env["ASDF_DATA_DIR"] ?? env["ASDF_DIR"] ?? (home + "/.asdf") addIfExecutable(asdfDir + "/shims", to: &results) let nodenvDir = env["NODENV_ROOT"] ?? (home + "/.nodenv") addIfExecutable(nodenvDir + "/shims", to: &results) let nodebrewDir = env["NODEBREW_ROOT"] ?? (home + "/.nodebrew") addIfExecutable(nodebrewDir + "/current/bin", to: &results) addIfExecutable(home + "/.npm-global/bin", to: &results) addIfExecutable(home + "/.npm-packages/bin", to: &results) addIfExecutable(home + "/.yarn/bin", to: &results) addIfExecutable(home + "/Library/pnpm", to: &results) addIfExecutable(home + "/.local/share/pnpm", to: &results) return dedupePreservingOrder(results) } private static func detectLoginShellPATH() -> String? { let shell = resolvedShellExecutable() let shellName = URL(fileURLWithPath: shell).lastPathComponent.lowercased() let command: String = shellName == "fish" ? "string join : $PATH" : "printf %s \"$PATH\"" guard let result = runProcess( executable: shell, arguments: ["-lic", command], timeout: 1.0 ) else { return nil } if result.timedOut { NSLog("[CLIEnvironment] login shell PATH probe timed out (%@)", shell) return nil } guard result.exitCode == 0 else { return nil } let str = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) return str.isEmpty ? nil : str } private static func shellWhich(_ name: String) -> String? { let shell = resolvedShellExecutable() guard let result = runProcess( executable: shell, arguments: ["-lic", "command -v \(name) || which \(name)"], timeout: 1.0 ) else { return nil } if result.timedOut { NSLog("[CLIEnvironment] shell which timed out (%@, %@)", shell, name) return nil } guard result.exitCode == 0 else { return nil } let str = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) return str.isEmpty ? nil : str } private static func which(_ name: String, path: String) -> String? { var env = ProcessInfo.processInfo.environment env["PATH"] = path guard let result = runProcess( executable: "/usr/bin/env", arguments: ["which", name], environment: env, timeout: 1.0 ) else { return nil } if result.timedOut { NSLog("[CLIEnvironment] which timed out (%@)", name) return nil } guard result.exitCode == 0 else { return nil } let str = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) if !str.isEmpty { return str } return nil } private static func manualResolve(_ name: String, path: String) -> String? { let fm = FileManager.default for raw in path.split(separator: ":") { var dir = String(raw) if dir.isEmpty { continue } dir = expandHomePath(dir) let candidate = (dir as NSString).appendingPathComponent(name) if fm.isExecutableFile(atPath: candidate) { return candidate } } return nil } private static func mergePATHStrings(_ paths: [String]) -> String { var components: [String] = [] for raw in paths { guard !raw.isEmpty else { continue } let parts = raw.split(separator: ":").map { String($0) } components.append(contentsOf: parts) } let expanded = components.map { expandHomePath($0) } let filtered = expanded.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } return dedupePreservingOrder(filtered).joined(separator: ":") } private static func sanitizeExecutablePath(_ raw: String) -> String? { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, trimmed.contains("/") else { return nil } let expanded = expandHomePath(trimmed) return FileManager.default.isExecutableFile(atPath: expanded) ? expanded : nil } private struct ProcessResult { let exitCode: Int32 let stdout: String let stderr: String let timedOut: Bool } private static func runProcess( executable: String, arguments: [String], environment: [String: String]? = nil, timeout: TimeInterval ) -> ProcessResult? { let process = Process() process.executableURL = URL(fileURLWithPath: executable) process.arguments = arguments let stdoutPipe = Pipe() let stderrPipe = Pipe() process.standardOutput = stdoutPipe process.standardError = stderrPipe if let environment { var env = ProcessInfo.processInfo.environment for (key, value) in environment { env[key] = value } process.environment = env } let semaphore = DispatchSemaphore(value: 0) process.terminationHandler = { _ in semaphore.signal() } do { try process.run() } catch { return nil } let finished = semaphore.wait(timeout: .now() + timeout) == .success if !finished { process.terminate() _ = semaphore.wait(timeout: .now() + 0.2) if process.isRunning { #if canImport(Darwin) _ = kill(process.processIdentifier, SIGKILL) #endif } } process.waitUntilExit() let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() let stdout = String(data: stdoutData, encoding: .utf8) ?? "" let stderr = String(data: stderrData, encoding: .utf8) ?? "" return ProcessResult( exitCode: process.terminationStatus, stdout: stdout, stderr: stderr, timedOut: !finished ) } private static func resolvedShellExecutable() -> String { let envShell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh" let candidate = expandHomePath(envShell) if FileManager.default.isExecutableFile(atPath: candidate) { return candidate } return "/bin/zsh" } private static func firstVersionToken(in line: String) -> String? { let separators = CharacterSet.whitespacesAndNewlines let tokens = line.components(separatedBy: separators).filter { !$0.isEmpty } for t in tokens { var s = t s = s.trimmingCharacters(in: CharacterSet(charactersIn: ",;()[]{}")) let parts = s.split(separator: ".") if parts.count >= 2 && parts.count <= 4 && parts.allSatisfy({ $0.allSatisfy({ $0.isNumber }) || $0.contains("-") }) { let core = parts.prefix(3) if core.allSatisfy({ $0.allSatisfy({ $0.isNumber }) }) { return s } } } return nil } } ================================================ FILE: utils/EmbeddedSessionNotification.swift ================================================ import Foundation enum EmbeddedSessionNotification { static let sessionIdKey = "sessionId" static let sourceDataKey = "sourceData" static func postEmbeddedNewSession(sessionId: String, source: SessionSource) { NotificationCenter.default.post( name: .codMateStartEmbeddedNewSession, object: nil, userInfo: userInfo(sessionId: sessionId, source: source) ) } static func userInfo(sessionId: String, source: SessionSource) -> [AnyHashable: Any] { var info: [AnyHashable: Any] = [sessionIdKey: sessionId] if let data = try? JSONEncoder().encode(source) { info[sourceDataKey] = data } return info } static func decodeSource(from userInfo: [AnyHashable: Any]?) -> SessionSource? { guard let data = userInfo?[sourceDataKey] as? Data else { return nil } return try? JSONDecoder().decode(SessionSource.self, from: data) } } ================================================ FILE: utils/FilenameSanitizer.swift ================================================ import Foundation func sanitizeFileName(_ s: String, fallback: String, maxLength: Int = 120) -> String { var text = s.trimmingCharacters(in: .whitespacesAndNewlines) if text.isEmpty { return fallback } // Replace path separators and reserved colon; strip control characters let disallowed = CharacterSet(charactersIn: "/:") .union(.newlines) .union(.controlCharacters) text = text.unicodeScalars.map { disallowed.contains($0) ? Character(" ") : Character($0) } .reduce(into: String(), { $0.append($1) }) // Collapse consecutive spaces while text.contains(" ") { text = text.replacingOccurrences(of: " ", with: " ") } text = text.trimmingCharacters(in: .whitespacesAndNewlines) if text.isEmpty { text = fallback } // Limit length to keep file names tidy if text.count > maxLength { let idx = text.index(text.startIndex, offsetBy: maxLength) text = String(text[.. JSONDecoder { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .custom { decoder in let container = try decoder.singleValueContainer() let raw = try container.decode(String.self) if let d = FlexibleDecoders.iso8601WithFractional.date(from: raw) ?? FlexibleDecoders.iso8601Standard.date(from: raw) { return d } // Fallbacks: numeric seconds or milliseconds since epoch represented as string if let number = Double(raw) { // Heuristic: treat very large numbers as milliseconds if number > 10_000_000_000 { // ~Sat Nov 20 2286 in seconds; anything larger is likely ms return Date(timeIntervalSince1970: number / 1000.0) } else { return Date(timeIntervalSince1970: number) } } throw DecodingError.dataCorruptedError( in: container, debugDescription: "Invalid ISO-8601 date: \(raw)" ) } return decoder } // MARK: - Private formatters private static let iso8601WithFractional: ISO8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return f }() private static let iso8601Standard: ISO8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime] return f }() } ================================================ FILE: utils/InternalWizardPaths.swift ================================================ import CryptoKit import Foundation enum InternalWizardPaths { static let internalFolderName = "internal" static let projectFolderName = "cli-project" static func internalRoot(home: URL = SessionPreferencesStore.getRealUserHomeURL()) -> URL { home .appendingPathComponent(".codmate", isDirectory: true) .appendingPathComponent(internalFolderName, isDirectory: true) } static func projectRoot(home: URL = SessionPreferencesStore.getRealUserHomeURL()) -> URL { internalRoot(home: home) .appendingPathComponent(projectFolderName, isDirectory: true) } static func ensureProjectRootExists(home: URL = SessionPreferencesStore.getRealUserHomeURL()) -> URL { let root = projectRoot(home: home) try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) return root } static func ignoredSubpaths(home: URL = SessionPreferencesStore.getRealUserHomeURL()) -> [String] { var paths: [String] = [projectRoot(home: home).path] if let geminiTmp = geminiTempPath(home: home) { paths.append(geminiTmp) } return paths } private static func geminiTempPath(home: URL) -> String? { let projectPath = projectRoot(home: home).path guard let hash = geminiProjectHash(for: projectPath) else { return nil } return home .appendingPathComponent(".gemini", isDirectory: true) .appendingPathComponent("tmp", isDirectory: true) .appendingPathComponent(hash, isDirectory: true) .path } private static func geminiProjectHash(for path: String) -> String? { let canonical = (path as NSString).expandingTildeInPath guard let data = canonical.data(using: .utf8) else { return nil } let digest = SHA256.hash(data: data) return digest.map { String(format: "%02x", $0) }.joined() } } ================================================ FILE: utils/MarkdownExportBuilder.swift ================================================ import Foundation struct MarkdownExportBuilder { static func build( session: SessionSummary, turns: [ConversationTurn], visibleKinds: Set, exportURL: URL ) -> String { let df = DateFormatter() df.dateStyle = .medium df.timeStyle = .short var lines: [String] = [] let title = session.effectiveTitle lines.append("# \(title)") lines.append("") // Metadata summary let sourceName = session.source.baseKind.displayName let remoteSuffix = session.source.remoteHost.map { " (\($0))" } ?? "" lines.append("- Source: \(sourceName)\(remoteSuffix)") lines.append("- Started: \(df.string(from: session.startedAt))") if let end = session.endedAt ?? session.lastUpdatedAt, end != session.startedAt { lines.append("- Updated: \(df.string(from: end))") } if session.duration > 0 { lines.append("- Duration: \(session.readableDuration)") } if let model = session.displayModel ?? session.model, !model.isEmpty { lines.append("- Model: \(model)") } if !session.cwd.isEmpty { lines.append("- CWD: \(session.cwd)") } if let approval = session.approvalPolicy, !approval.isEmpty { lines.append("- Approval Policy: \(approval)") } if let originator = session.originator.nonEmpty { lines.append("- Originator: \(originator)") } lines.append("") if let comment = session.userComment?.trimmingCharacters(in: .whitespacesAndNewlines), !comment.isEmpty { lines.append("## Comment") lines.append(comment) lines.append("") } if let instructions = session.instructions?.trimmingCharacters(in: .whitespacesAndNewlines), !instructions.isEmpty { lines.append("## Task Instructions") lines.append(instructions) lines.append("") } lines.append("## Conversation") let filteredTurns = turns.filtering(visibleKinds: visibleKinds) for turn in filteredTurns { let events = turn.allEvents for event in events where visibleKinds.contains(event.visibilityKind) { lines.append("") lines.append(eventHeader(event: event, dateFormatter: df, session: session)) if let text = event.text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty { lines.append("") lines.append(text) } if event.repeatCount > 1 { lines.append("") lines.append("_Repeated ×\(event.repeatCount)_") } if !event.attachments.isEmpty { lines.append("") lines.append("_Attachments: \(attachmentSummary(event.attachments))_") } if let metadata = event.metadata, !metadata.isEmpty { lines.append("") lines.append("Metadata:") for key in metadata.keys.sorted() { if let value = metadata[key], !value.isEmpty { lines.append("- \(key): \(value)") } } } } } lines.append("") lines.append("_Exported to \(exportURL.lastPathComponent)_") return lines.joined(separator: "\n") } private static func eventHeader( event: TimelineEvent, dateFormatter: DateFormatter, session: SessionSummary ) -> String { let role = eventRoleTitle(event: event, session: session) let time = dateFormatter.string(from: event.timestamp) if let title = event.title, !title.isEmpty, title != role, MessageVisibilityKind.kindFromToken(title) != event.visibilityKind { return "### \(role) · \(title) · \(time)" } return "### \(role) · \(time)" } private static func eventRoleTitle(event: TimelineEvent, session: SessionSummary) -> String { event.visibilityKind.settingsLabel } private static func attachmentSummary(_ attachments: [TimelineAttachment]) -> String { let imageCount = attachments.filter { $0.kind == .image }.count if imageCount > 0 { return "\(imageCount) image" + (imageCount == 1 ? "" : "s") } return "\(attachments.count) attachment" + (attachments.count == 1 ? "" : "s") } } private extension String { var nonEmpty: String? { let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } } ================================================ FILE: utils/ModelNameSanitizer.swift ================================================ import Foundation /// Utility for sanitizing and cleaning AI model names for display in pickers and UI. /// /// This sanitizer: /// - Removes date suffixes (e.g., -20241022, -202410) /// - Removes provider prefixes (e.g., anthropic/, google/, openai/) /// - Handles duplicate models by keeping the latest version /// - Provides clean, user-friendly model names struct ModelNameSanitizer { /// Represents a sanitized model with both display name and original ID struct SanitizedModel { let displayName: String let originalId: String } /// Common provider prefixes to remove from model names private static let providerPrefixes = [ "anthropic/", "google/", "openai/", "mistralai/", "meta-llama/", "cohere/", "ai21/", "aleph-alpha/", "amazon/", "claude/", "gemini/", "gpt/", "codex/" ] /// Sanitizes a list of model names by removing dates and provider prefixes, /// and eliminating duplicates (keeping the latest version). /// /// - Parameter models: Array of model names to sanitize /// - Returns: Array of SanitizedModel with display names and original IDs static func sanitize(_ models: [String]) -> [SanitizedModel] { var seenBaseNames: [String: ModelVersion] = [:] for model in models { let cleaned = removeProviderPrefix(model) let (baseName, version) = extractBaseNameAndVersion(cleaned) // Keep the latest version for each base name if let existing = seenBaseNames[baseName] { if version.isNewerThan(existing) { seenBaseNames[baseName] = version } } else { seenBaseNames[baseName] = version } } // Sort by base name for consistent ordering return seenBaseNames.keys.sorted().map { baseName in SanitizedModel( displayName: baseName, originalId: seenBaseNames[baseName]!.originalName ) } } /// Sanitizes a single model name by removing provider prefix and date suffix. /// /// - Parameter model: Model name to sanitize /// - Returns: Sanitized model name static func sanitizeSingle(_ model: String) -> String { let cleaned = removeProviderPrefix(model) let (baseName, _) = extractBaseNameAndVersion(cleaned) return baseName } /// Extracts base name and version from a model name (public helper for version comparison) /// /// - Parameter model: Model name to process /// - Returns: Tuple of (base name, version info) static func extractModelVersion(_ model: String) -> (baseName: String, version: ModelVersion) { let cleaned = removeProviderPrefix(model) return extractBaseNameAndVersion(cleaned) } /// Represents version information extracted from a model name (public for comparison) struct ModelVersion { let originalName: String let dateString: String? let format: DateFormat? enum DateFormat { case yyyyMMdd case yyyyMM } /// Compares if this version is newer than another version func isNewerThan(_ other: ModelVersion) -> Bool { // If both have dates, compare them if let myDate = dateString, let otherDate = other.dateString { return myDate > otherDate } // If only this version has a date, it's considered newer if dateString != nil && other.dateString == nil { return true } // If only the other version has a date, it's considered newer if dateString == nil && other.dateString != nil { return false } // If neither has a date, compare original names lexicographically return originalName > other.originalName } } /// Removes provider prefix from a model name. /// /// Example: "anthropic/claude-3-5-sonnet-20241022" -> "claude-3-5-sonnet-20241022" /// /// - Parameter model: Model name with potential provider prefix /// - Returns: Model name without provider prefix static func removeProviderPrefix(_ model: String) -> String { for prefix in providerPrefixes { if model.hasPrefix(prefix) { return String(model.dropFirst(prefix.count)) } } return model } /// Extracts the base name and version information from a model name. /// /// Identifies and removes date suffixes in formats: /// - YYYYMMDD (e.g., 20241022) /// - YYYYMM (e.g., 202410) /// /// Example: "claude-3-5-sonnet-20241022" -> ("claude-3-5-sonnet", ModelVersion) /// /// - Parameter model: Model name to process /// - Returns: Tuple of (base name, version info) private static func extractBaseNameAndVersion(_ model: String) -> (String, ModelVersion) { // Pattern for YYYYMMDD format (8 digits) let datePattern8 = #"^(.+?)[-_]?(\d{8})$"# // Pattern for YYYYMM format (6 digits) let datePattern6 = #"^(.+?)[-_]?(\d{6})$"# if let regex = try? NSRegularExpression(pattern: datePattern8), let match = regex.firstMatch(in: model, range: NSRange(model.startIndex..., in: model)), let baseRange = Range(match.range(at: 1), in: model), let dateRange = Range(match.range(at: 2), in: model) { let baseName = String(model[baseRange]) let dateString = String(model[dateRange]) return (baseName, ModelVersion(originalName: model, dateString: dateString, format: .yyyyMMdd)) } if let regex = try? NSRegularExpression(pattern: datePattern6), let match = regex.firstMatch(in: model, range: NSRange(model.startIndex..., in: model)), let baseRange = Range(match.range(at: 1), in: model), let dateRange = Range(match.range(at: 2), in: model) { let baseName = String(model[baseRange]) let dateString = String(model[dateRange]) return (baseName, ModelVersion(originalName: model, dateString: dateString, format: .yyyyMM)) } // No date pattern found, return as-is return (model, ModelVersion(originalName: model, dateString: nil, format: nil)) } } ================================================ FILE: utils/ProviderIconResource.swift ================================================ import AppKit import CoreImage import SwiftUI /// Unified provider icon resource library /// Manages all provider icons with centralized theme adaptation enum ProviderIconResource { /// Icon metadata including theme adaptation requirements struct IconMetadata { let name: String let requiresDarkModeInversion: Bool let aliases: [String] // Alternative names/IDs that map to this icon } /// Registry of all provider icons with their metadata static let iconRegistry: [IconMetadata] = [ // OAuth providers IconMetadata( name: "ChatGPTIcon", requiresDarkModeInversion: true, aliases: ["codex", "openai"]), IconMetadata( name: "ClaudeIcon", requiresDarkModeInversion: false, aliases: ["claude", "anthropic"]), IconMetadata( name: "GeminiIcon", requiresDarkModeInversion: false, aliases: ["gemini", "google"]), IconMetadata( name: "AntigravityIcon", requiresDarkModeInversion: false, aliases: ["antigravity"]), IconMetadata(name: "QwenIcon", requiresDarkModeInversion: false, aliases: ["qwen"]), // API key providers IconMetadata( name: "DeepSeekIcon", requiresDarkModeInversion: false, aliases: ["deepseek", "deep-seek"]), IconMetadata( name: "MiniMaxIcon", requiresDarkModeInversion: true, aliases: ["minimax", "mini-max"]), IconMetadata( name: "OpenRouterIcon", requiresDarkModeInversion: true, aliases: ["openrouter", "open-router"]), IconMetadata( name: "ZaiIcon", requiresDarkModeInversion: true, aliases: ["zai", "z.ai", "glm"]), IconMetadata( name: "KimiIcon", requiresDarkModeInversion: true, aliases: ["kimi", "k2", "moonshot"]), ] /// Lookup map: alias -> icon name private static let aliasMap: [String: String] = { var map: [String: String] = [:] for icon in iconRegistry { map[icon.name.lowercased()] = icon.name for alias in icon.aliases { map[alias.lowercased()] = icon.name } } return map }() /// Lookup map: icon name -> metadata private static let metadataMap: [String: IconMetadata] = { Dictionary(uniqueKeysWithValues: iconRegistry.map { ($0.name, $0) }) }() /// Find icon name by alias (ID, name, or baseURL) static func iconName(for alias: String) -> String? { aliasMap[alias.lowercased()] } /// Find icon name by provider ID, name, or baseURL static func iconName(forProviderId id: String?, name: String?, baseURL: String?) -> String? { // Try ID first if let id = id, let iconName = iconName(for: id) { return iconName } // Try name if let name = name, let iconName = iconName(for: name) { return iconName } // Try baseURL if let baseURL = baseURL?.lowercased() { // Check for domain matches if baseURL.contains("deepseek.com") { return "DeepSeekIcon" } else if baseURL.contains("minimaxi.com") || baseURL.contains("minimax.com") { return "MiniMaxIcon" } else if baseURL.contains("openrouter.ai") { return "OpenRouterIcon" } else if baseURL.contains("zai.com") || baseURL.contains("z.ai") || baseURL.contains("bigmodel.cn") { return "ZaiIcon" } else if baseURL.contains("moonshot.cn") || baseURL.contains("kimi") { return "KimiIcon" } else if baseURL.contains("openai.com") { return "ChatGPTIcon" } else if baseURL.contains("anthropic.com") { return "ClaudeIcon" } } return nil } /// Get metadata for an icon name static func metadata(for iconName: String) -> IconMetadata? { metadataMap[iconName] } /// Check if an icon requires dark mode inversion static func requiresDarkModeInversion(_ iconName: String) -> Bool { metadata(for: iconName)?.requiresDarkModeInversion ?? false } /// Process NSImage for display with theme adaptation /// - Parameters: /// - iconName: The icon asset name /// - size: Target size for the icon /// - isDarkMode: Whether dark mode is active /// - Returns: Processed NSImage ready for display static func processedImage( named iconName: String, size: NSSize, isDarkMode: Bool ) -> NSImage? { guard let originalImage = NSImage(named: iconName) else { return nil } // Resize to target size let resized = NSImage(size: size) resized.lockFocus() originalImage.draw( in: NSRect(origin: .zero, size: size), from: NSRect(origin: .zero, size: originalImage.size), operation: .copy, fraction: 1.0 ) resized.unlockFocus() // Apply inversion if needed if requiresDarkModeInversion(iconName) && isDarkMode { return invertedImage(resized) ?? resized } return resized } /// Invert an NSImage using Core Image filter private static func invertedImage(_ image: NSImage) -> NSImage? { guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil } let ciImage = CIImage(cgImage: cgImage) guard let filter = CIFilter(name: "CIColorInvert") else { return nil } filter.setValue(ciImage, forKey: kCIInputImageKey) guard let outputImage = filter.outputImage else { return nil } let rep = NSCIImageRep(ciImage: outputImage) let newImage = NSImage(size: image.size) newImage.addRepresentation(rep) return newImage } /// Get all registered icon names static var allIconNames: [String] { iconRegistry.map { $0.name } } /// Get icons that require dark mode inversion static var darkModeInvertIcons: Set { Set(iconRegistry.filter { $0.requiresDarkModeInversion }.map { $0.name }) } /// Find icon name for an API key provider /// This is a convenience method that extracts baseURL from provider connectors /// - Parameter provider: The provider to find icon for /// - Returns: Icon name from Assets.xcassets if found, nil otherwise static func iconName(for provider: ProvidersRegistryService.Provider) -> String? { let codexBaseURL = provider.connectors[ProvidersRegistryService.Consumer.codex.rawValue]? .baseURL let claudeBaseURL = provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue]? .baseURL let baseURL = codexBaseURL ?? claudeBaseURL return iconName( forProviderId: provider.id, name: provider.name, baseURL: baseURL ) } } /// SwiftUI ViewModifier for applying dark mode inversion to provider icons struct ProviderIconDarkModeModifier: ViewModifier { let iconName: String @Environment(\.colorScheme) private var colorScheme func body(content: Content) -> some View { if ProviderIconResource.requiresDarkModeInversion(iconName) && colorScheme == .dark { content.colorInvert() } else { content } } } extension View { /// Apply dark mode inversion to provider icons if needed func providerIconTheme(iconName: String) -> some View { modifier(ProviderIconDarkModeModifier(iconName: iconName)) } } ================================================ FILE: utils/ProviderIconThemeHelper.swift ================================================ import AppKit import SwiftUI /// Helper for handling provider icon theme adaptation (dark/light mode) /// Now delegates to ProviderIconResource for unified icon management enum ProviderIconThemeHelper { /// Icon names that require color inversion in dark mode /// @deprecated: Use ProviderIconResource.darkModeInvertIcons instead static var darkModeInvertIcons: Set { ProviderIconResource.darkModeInvertIcons } /// Check if an icon name requires inversion in dark mode /// @deprecated: Use ProviderIconResource.requiresDarkModeInversion instead static func shouldInvertInDarkMode(_ iconName: String) -> Bool { ProviderIconResource.requiresDarkModeInversion(iconName) } /// Check if current appearance is dark mode (for AppKit contexts) static func isDarkMode() -> Bool { if let appearance = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) { return appearance == .darkAqua } return false } /// Process an NSImage for menu display, applying dark mode inversion if needed /// Now uses ProviderIconResource for unified processing static func menuImage(named iconName: String, size: NSSize = NSSize(width: 14, height: 14)) -> NSImage? { ProviderIconResource.processedImage( named: iconName, size: size, isDarkMode: isDarkMode() ) } } ================================================ FILE: utils/SessionPathFilter.swift ================================================ import Foundation /// Shared utility for filtering session paths based on ignore rules. /// Consolidates duplicate logic across SessionIndexer and session providers. enum SessionPathFilter { /// Check if an absolute path should be ignored based on ignore rules. /// - Parameters: /// - absolutePath: The full path to check /// - ignoredPaths: Array of path substrings to match against (case-insensitive) /// - Returns: `true` if the path should be ignored static func shouldIgnorePath(_ absolutePath: String, ignoredPaths: [String]) -> Bool { guard !ignoredPaths.isEmpty else { return false } let lowercasedPath = absolutePath.lowercased() for ignored in ignoredPaths { let needle = ignored.trimmingCharacters(in: .whitespacesAndNewlines) guard !needle.isEmpty else { continue } if lowercasedPath.contains(needle.lowercased()) { return true } } return false } /// Check if a session summary should be ignored based on its file path and working directory. /// - Parameters: /// - summary: The session summary to check /// - ignoredPaths: Array of path substrings to match against /// - Returns: `true` if the session should be ignored static func shouldIgnoreSummary(_ summary: SessionSummary, ignoredPaths: [String]) -> Bool { guard !ignoredPaths.isEmpty else { return false } // Check both file path and cwd (working directory is what users typically want to filter by) return shouldIgnorePath(summary.fileURL.path, ignoredPaths: ignoredPaths) || shouldIgnorePath(summary.cwd, ignoredPaths: ignoredPaths) } } ================================================ FILE: utils/SessionSummaryMaterialBuilder.swift ================================================ import Foundation /// Builds intelligent summarization material from conversation turns /// Implements truncation, deduplication, and code/log trimming strategies struct SessionSummaryMaterialBuilder { // MARK: - Constants private static let messageSeparator = "\n\n" private static let sectionSeparator = "\n\n---\n\n" private static let defaultMaxLength = 8000 private static let assistantMessageMaxLength = 3000 private static let deduplicationThreshold = 0.95 private static let codeBlockKeepFirst = 5 private static let codeBlockKeepLast = 3 private static let errorLogKeepFirst = 10 private static let errorLogKeepLast = 5 private static let errorLogMinLines = 5 // Precompiled regex patterns for error detection private static let errorPatterns: [NSRegularExpression] = { let patterns = [ "^\\s*at ", // Stack trace "^\\s*Error:", // Error message "^\\s*Exception:", // Exception "^\\s*Traceback", // Python traceback "^\\s*File \"", // Python file reference "^\\s*\\d+\\s*\\|", // Numbered error output "^\\s*/.*:\\d+", // File path with line number ] return patterns.compactMap { try? NSRegularExpression(pattern: $0, options: []) } }() // MARK: - Public Interface /// Build summarization material from conversation turns /// - Parameters: /// - turns: The conversation turns to process /// - maxLength: Maximum total character count (default: 8000) /// - Returns: Formatted material string for LLM prompt static func build(turns: [ConversationTurn], maxLength: Int = defaultMaxLength) -> String { // Extract user messages using shared helper let rawUserMessages = turns.extractUserMessages() // Deduplicate user messages let userMessages = deduplicate(rawUserMessages, threshold: deduplicationThreshold) // Process each message: trim code blocks and error logs let processedMessages = userMessages.map { msg in trimCodeBlocks(in: trimErrorLogs(in: msg)) } // If exceeds maxLength, remove middle messages to fit let finalMessages = truncateMiddleMessages(processedMessages, maxLength: maxLength) let material = finalMessages.joined(separator: messageSeparator) // Append last assistant message if let lastAssistant = turns.extractLastAssistantMessage() { let trimmed = String(lastAssistant.prefix(assistantMessageMaxLength)) return material + sectionSeparator + "Assistant's final response:\n\n\(trimmed)" } return material } // MARK: - Deduplication /// Deduplicate consecutive similar messages using Levenshtein distance /// Only compares adjacent messages (n vs n+1) for O(n) complexity private static func deduplicate(_ messages: [String], threshold: Double) -> [String] { guard !messages.isEmpty else { return [] } var result: [String] = [messages[0]] for i in 1.. Double { let len1 = s1.count let len2 = s2.count let maxLen = max(len1, len2) guard maxLen > 0 else { return 1.0 } // Quick length-based check: if length difference > 10%, consider different let lengthDiff = abs(len1 - len2) if Double(lengthDiff) / Double(maxLen) > 0.1 { return 0.0 } // For very long strings, only compare first 1000 characters to save time let s1Trimmed = len1 > 1000 ? String(s1.prefix(1000)) : s1 let s2Trimmed = len2 > 1000 ? String(s2.prefix(1000)) : s2 let distance = levenshteinDistance(s1Trimmed, s2Trimmed) return 1.0 - Double(distance) / Double(max(s1Trimmed.count, s2Trimmed.count)) } /// Calculate Levenshtein distance between two strings private static func levenshteinDistance(_ s1: String, _ s2: String) -> Int { let a = Array(s1) let b = Array(s2) var matrix = [[Int]](repeating: [Int](repeating: 0, count: b.count + 1), count: a.count + 1) for i in 0...a.count { matrix[i][0] = i } for j in 0...b.count { matrix[0][j] = j } for i in 1...a.count { for j in 1...b.count { let cost = a[i-1] == b[j-1] ? 0 : 1 matrix[i][j] = min( matrix[i-1][j] + 1, // deletion matrix[i][j-1] + 1, // insertion matrix[i-1][j-1] + cost // substitution ) } } return matrix[a.count][b.count] } // MARK: - Code Block Trimming /// Trim code blocks to preserve first and last lines private static func trimCodeBlocks(in text: String) -> String { let pattern = "```[\\s\\S]*?```" guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return text } let nsString = text as NSString let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsString.length)) var result = text // Process matches in reverse to maintain string indices for match in matches.reversed() { let matchRange = match.range let codeBlock = nsString.substring(with: matchRange) let trimmed = trimBlock(codeBlock, keepFirst: codeBlockKeepFirst, keepLast: codeBlockKeepLast) let range = Range(matchRange, in: result)! result.replaceSubrange(range, with: trimmed) } return result } // MARK: - Error Log Trimming /// Trim error logs to preserve first and last lines private static func trimErrorLogs(in text: String) -> String { let lines = text.components(separatedBy: .newlines) var i = 0 var result: [String] = [] while i < lines.count { // Check if this starts an error block let errorBlockStart = detectErrorBlock(lines: lines, startIndex: i) if let blockLength = errorBlockStart, blockLength >= errorLogMinLines { // Found error block, trim it let blockEnd = i + blockLength let blockLines = Array(lines[i.. Int? { var count = 0 var consecutiveMatches = 0 for i in startIndex..= 3 { // Allow a few non-matching lines within error block count += 1 if count - consecutiveMatches > 2 { break } } else { break } } return consecutiveMatches >= 3 ? count : nil } // MARK: - Generic Block Trimming /// Trim a block of text to keep first N and last M lines private static func trimBlock(_ block: String, keepFirst: Int, keepLast: Int) -> String { let lines = block.components(separatedBy: .newlines) guard lines.count > keepFirst + keepLast + 3 else { return block // Too short to trim } let firstLines = lines.prefix(keepFirst) let lastLines = lines.suffix(keepLast) let omittedCount = lines.count - keepFirst - keepLast return ([ firstLines.joined(separator: "\n"), omissionMarker(count: omittedCount, unit: "lines"), lastLines.joined(separator: "\n") ]).joined(separator: "\n") } // MARK: - Helpers /// Create an omission marker with count and unit private static func omissionMarker(count: Int, unit: String) -> String { return "... (\(count) \(unit) omitted) ..." } /// Calculate total length of messages including separators private static func calculateMessagesLength(_ messages: [String], separator: String = messageSeparator) -> Int { let textLength = messages.map { $0.count }.reduce(0, +) let separatorsLength = max(0, messages.count - 1) * separator.count return textLength + separatorsLength } // MARK: - Middle Message Truncation /// Remove messages from the middle to fit maxLength, preserving first and last messages /// - Parameters: /// - messages: Array of processed user messages /// - maxLength: Maximum total character count /// - Returns: Array of messages that fit within maxLength, maintaining original order private static func truncateMiddleMessages(_ messages: [String], maxLength: Int) -> [String] { guard messages.count > 2 else { return messages } guard calculateMessagesLength(messages) > maxLength else { return messages } // Protect first and last messages let firstMsg = messages.first! let lastMsg = messages.last! let middleMessages = Array(messages.dropFirst().dropLast()) // Find a continuous range in the middle to remove // Strategy: expand removal range from center until we fit let middleCount = middleMessages.count let centerIndex = middleCount / 2 // Try removing progressively larger ranges centered around the middle for removalCount in 1...middleCount { // Calculate removal range centered at centerIndex let halfRemoval = removalCount / 2 let removeStart = max(0, centerIndex - halfRemoval) let removeEnd = min(middleCount, removeStart + removalCount) // Build result with this removal range let beforeRemoval = Array(middleMessages[0.. String { guard !argument.isEmpty else { return "''" } let specialCharacters = CharacterSet.whitespacesAndNewlines .union(CharacterSet(charactersIn: "\"'\\$`")) if argument.rangeOfCharacter(from: specialCharacters) == nil { return argument } let escaped = argument.replacingOccurrences(of: "'", with: "'\"'\"'") return "'\(escaped)'" } private static func describeCommand(executable: String, arguments: [String]) -> String { let parts = [executable] + arguments return parts.map { escapedArgument($0) }.joined(separator: " ") } @discardableResult static func run( executable: String, arguments: [String], currentDirectory: URL? = nil, environment: [String: String]? = nil ) throws -> ShellCommandResult { let process = Process() process.executableURL = URL(fileURLWithPath: executable) process.arguments = arguments process.currentDirectoryURL = currentDirectory if let environment { var env = ProcessInfo.processInfo.environment for (key, value) in environment { env[key] = value } process.environment = env } let commandDescription = describeCommand(executable: executable, arguments: arguments) print("[ShellCommandRunner] Running command: \(commandDescription)") if let currentDirectory { print("[ShellCommandRunner] cwd: \(currentDirectory.path)") } if let environment, !environment.isEmpty { let envDescription = environment.map { "\($0.key)=\($0.value)" }.joined(separator: ", ") print("[ShellCommandRunner] env overrides: \(envDescription)") } let stdoutPipe = Pipe() let stderrPipe = Pipe() process.standardOutput = stdoutPipe process.standardError = stderrPipe try process.run() process.waitUntilExit() let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() let stdout = String(data: stdoutData, encoding: .utf8) ?? "" let stderr = String(data: stderrData, encoding: .utf8) ?? "" let exitCode = process.terminationStatus if exitCode != 0 { throw ShellCommandError.commandFailed( executable: executable, arguments: arguments, stderr: stderr, exitCode: exitCode ) } return ShellCommandResult(stdout: stdout, stderr: stderr, exitCode: exitCode) } } ================================================ FILE: utils/TagView.swift ================================================ import SwiftUI /// A reusable tag/chip component that supports closing, enabling/disabling, and custom styling. struct TagView: View { let text: String var isEnabled: Bool = true var isClosable: Bool = true var isRemovable: Bool = true var onClose: (() -> Void)? = nil var onToggle: ((Bool) -> Void)? = nil @State private var isHovered = false var body: some View { HStack(spacing: 4) { // Tag text (clickable to toggle if onToggle is provided) Text(text) .font(.caption) .foregroundStyle(isEnabled ? .primary : .secondary) .monospaced() .lineLimit(1) .contentShape(Rectangle()) .onTapGesture { if let onToggle = onToggle { onToggle(!isEnabled) } } // Close button (if closable and removable) if isClosable && isRemovable, let onClose = onClose { Button { onClose() } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 12)) .foregroundStyle(isEnabled ? .secondary : .tertiary) .opacity(isHovered ? 1.0 : 0.2) } .buttonStyle(.plain) .help("Remove") } } .padding(.horizontal, 8) .padding(.vertical, 4) .background(backgroundColor) .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(borderColor, lineWidth: isHovered ? 1 : 0) ) .onHover { hovering in isHovered = hovering } } private var backgroundColor: Color { guard isEnabled else { return Color.secondary.opacity(0.08) } return Color.accentColor.opacity(isHovered ? 0.15 : 0.12) } private var borderColor: Color { Color.accentColor.opacity(0.3) } } /// A container view for displaying multiple tags in a flow layout. struct TagsView: View { let tags: [TagItem] var spacing: CGFloat = 6 var alignment: HorizontalAlignment = .leading var body: some View { FlowLayout(spacing: spacing, alignment: alignment) { ForEach(tags.indices, id: \.self) { index in TagView( text: tags[index].text, isEnabled: tags[index].isEnabled, isClosable: tags[index].isClosable, isRemovable: tags[index].isRemovable, onClose: tags[index].onClose, onToggle: tags[index].onToggle ) } } } } /// Data model for a tag item. struct TagItem: Identifiable { let id: String let text: String var isEnabled: Bool = true var isClosable: Bool = true var isRemovable: Bool = true var onClose: (() -> Void)? = nil var onToggle: ((Bool) -> Void)? = nil init( id: String? = nil, text: String, isEnabled: Bool = true, isClosable: Bool = true, isRemovable: Bool = true, onClose: (() -> Void)? = nil, onToggle: ((Bool) -> Void)? = nil ) { self.id = id ?? text self.text = text self.isEnabled = isEnabled self.isClosable = isClosable self.isRemovable = isRemovable self.onClose = onClose self.onToggle = onToggle } } /// A simple flow layout that wraps items to multiple lines. struct FlowLayout: Layout { var spacing: CGFloat = 6 var alignment: HorizontalAlignment = .leading func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { let maxWidth = proposal.width ?? 10000 // Use a large default if unspecified let result = FlowResult( in: maxWidth, subviews: subviews, spacing: spacing ) return result.size } func placeSubviews( in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout () ) { let result = FlowResult( in: bounds.width, subviews: subviews, spacing: spacing ) for (index, subview) in subviews.enumerated() { subview.place( at: CGPoint( x: bounds.minX + result.frames[index].minX, y: bounds.minY + result.frames[index].minY), proposal: .unspecified) } } struct FlowResult { var size: CGSize = .zero var frames: [CGRect] = [] init(in maxWidth: CGFloat, subviews: Subviews, spacing: CGFloat) { var currentX: CGFloat = 0 var currentY: CGFloat = 0 var lineHeight: CGFloat = 0 for (_, subview) in subviews.enumerated() { let size = subview.sizeThatFits(.unspecified) if currentX + size.width > maxWidth && currentX > 0 { // Start a new line currentY += lineHeight + spacing currentX = 0 lineHeight = 0 } frames.append( CGRect(x: currentX, y: currentY, width: size.width, height: size.height)) lineHeight = max(lineHeight, size.height) currentX += size.width + spacing } self.size = CGSize( width: maxWidth, height: currentY + lineHeight ) } } } ================================================ FILE: utils/TerminalFontResolver.swift ================================================ import AppKit enum TerminalFontResolver { // Candidate order: prefer CJK-capable monospace fonts before system defaults private static let preferredMonoCandidates = [ "Sarasa Mono SC", "Sarasa Term SC", "LXGW WenKai Mono", "Noto Sans Mono CJK SC", "NotoSansMonoCJKsc-Regular", "JetBrains Mono", "JetBrainsMono-Regular", "JetBrains Mono NL", "JetBrainsMonoNL Nerd Font Mono", "JetBrainsMono Nerd Font Mono", "SF Mono", "Menlo", ] static func resolvedFont(name: String, size: CGFloat) -> NSFont { let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty, let explicit = NSFont(name: trimmed, size: size) { return explicit } for candidate in preferredMonoCandidates { if let font = NSFont(name: candidate, size: size) { return font } } return NSFont.monospacedSystemFont(ofSize: size, weight: .regular) } } ================================================ FILE: utils/TimelineEventClassifier.swift ================================================ import Foundation struct ClassifiedTimelineEvent { let kind: MessageVisibilityKind let callID: String? let isToolLike: Bool } struct TimelineEventClassifier { private static let skippedEventTypes: Set = [ "reasoning_output" ] static func classify(row: SessionRow) -> ClassifiedTimelineEvent? { switch row.kind { case .sessionMeta: return nil case .assistantMessage: // Assistant message rows are duplicates of response_item message entries. return nil case .turnContext: // Turn context is surfaced elsewhere and not part of the timeline list. return nil case let .eventMessage(payload): return classify(eventMessage: payload) case let .responseItem(payload): return classify(responseItem: payload) case .unknown: return nil } } private static func classify(eventMessage payload: EventMessagePayload) -> ClassifiedTimelineEvent? { let type = payload.type.lowercased() if type == "turn_boundary" { return nil } if skippedEventTypes.contains(type) { return nil } if type == "turn_aborted" || type == "turn aborted" || type == "compaction" || type == "compacted" { return nil } if type == "ghost_snapshot" || type == "ghost snapshot" { return nil } if type == "environment_context" { return nil } let rawMessage = payload.message ?? payload.text ?? payload.reason ?? "" let message = cleanedAssistantText(rawMessage) let hasImages = payload.images?.contains(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) ?? false guard !message.isEmpty || hasImages else { return nil } if type == "token_count" { return ClassifiedTimelineEvent(kind: .tokenUsage, callID: nil, isToolLike: false) } if type == "agent_reasoning" { return ClassifiedTimelineEvent(kind: .reasoning, callID: nil, isToolLike: false) } let mappedKind = MessageVisibilityKind.mappedKind( rawType: payload.type, title: payload.kind ?? payload.type, metadata: nil ) let effectiveKind: MessageVisibilityKind? = { guard mappedKind == .tool else { return mappedKind } if containsCodeEditMarkers(message) || containsStrongEditOutputMarkers(message) { return .codeEdit } return mappedKind }() switch type { case "user_message": return ClassifiedTimelineEvent(kind: effectiveKind ?? .user, callID: nil, isToolLike: false) case "agent_message": return ClassifiedTimelineEvent(kind: effectiveKind ?? .assistant, callID: nil, isToolLike: false) default: let resolved = effectiveKind ?? .infoOther return ClassifiedTimelineEvent(kind: resolved, callID: nil, isToolLike: isToolLike(resolved)) } } private static func classify(responseItem payload: ResponseItemPayload) -> ClassifiedTimelineEvent? { let type = payload.type.lowercased() if skippedEventTypes.contains(type) { return nil } if type == "ghost_snapshot" || type == "ghost snapshot" { return nil } if type == "reasoning", payload.summary?.isEmpty == false, payload.content?.isEmpty != false { // Skip summary-only duplicate reasoning events. return nil } if type == "message" { let role = payload.role?.lowercased() if role == "user" { // User content is converted into environment context and not shown in timeline. return nil } let text = cleanedAssistantText(joinedText(from: payload.content ?? [])) guard !text.isEmpty else { return nil } return ClassifiedTimelineEvent(kind: .assistant, callID: nil, isToolLike: false) } let mappedKind = MessageVisibilityKind.mappedKind( rawType: payload.type, title: payload.type, metadata: nil ) let detectionText = responseDetectionText(payload: payload) guard !detectionText.isEmpty else { return nil } let resolvedKind: MessageVisibilityKind? = { guard mappedKind == .tool else { return mappedKind } if isCodeEdit(payload: payload, fallbackText: detectionText) { return .codeEdit } return mappedKind }() let finalKind = resolvedKind ?? .infoOther let isTool = isToolLike(finalKind) return ClassifiedTimelineEvent(kind: finalKind, callID: payload.callID, isToolLike: isTool) } private static func responseDetectionText(payload: ResponseItemPayload) -> String { let contentText = cleanedAssistantText(joinedText(from: payload.content ?? [])) if !contentText.isEmpty { return contentText } let summaryText = cleanedAssistantText(joinedSummary(from: payload.summary ?? [])) if !summaryText.isEmpty { return summaryText } let fallbackText = responseFallbackText(payload) if !fallbackText.isEmpty { return fallbackText } if let output = stringValue(payload.output), !output.isEmpty { return output } return "" } private static func cleanedText(_ text: String) -> String { guard !text.isEmpty else { return text } return text .replacingOccurrences(of: "", with: "") .replacingOccurrences(of: "", with: "") .trimmingCharacters(in: .whitespacesAndNewlines) } private static func cleanedAssistantText(_ text: String) -> String { let base = cleanedText(text) return stripTaggedBlocks( base, tags: [ "permissions_instructions", "permissions instructions", "collaboration_mode", "collaboration mode" ] ) .trimmingCharacters(in: .whitespacesAndNewlines) } private static func stripTaggedBlocks(_ text: String, tags: [String]) -> String { var result = text for tag in tags { result = stripTaggedBlock(result, tag: tag) } return result } private static func stripTaggedBlock(_ text: String, tag: String) -> String { let lowerTag = tag.lowercased() let openToken = "<\(lowerTag)>" let closeToken = "" var output = text while let openRange = output.lowercased().range(of: openToken) { if let closeRange = output.lowercased().range( of: closeToken, range: openRange.upperBound.. String { blocks.compactMap { $0.text }.joined(separator: "\n\n") } private static func joinedSummary(from items: [ResponseSummaryItem]) -> String { items.compactMap { $0.text }.joined(separator: "\n\n") } private static func responseFallbackText(_ payload: ResponseItemPayload) -> String { var lines: [String] = [] if let name = payload.name, !name.isEmpty { lines.append("name: \(name)") } if let args = renderValue(payload.arguments), !args.isEmpty { lines.append(formatLabel("arguments", value: args)) } if let input = renderValue(payload.input), !input.isEmpty { lines.append(formatLabel("input", value: input)) } if let output = renderValue(payload.output), !output.isEmpty { lines.append(formatLabel("output", value: output)) } if let ghost = renderValue(payload.ghostCommit), !ghost.isEmpty { lines.append(formatLabel("ghost_commit", value: ghost)) } if lines.isEmpty, let callID = payload.callID, !callID.isEmpty { lines.append("call_id: \(callID)") } return lines.joined(separator: "\n") } private static func renderValue(_ value: JSONValue?) -> String? { guard let value else { return nil } switch value { case .string(let string): return string case .number(let number): return String(number) case .bool(let flag): return flag ? "true" : "false" case .null: return nil case .array, .object: let raw = toAny(value) guard JSONSerialization.isValidJSONObject(raw), let data = try? JSONSerialization.data(withJSONObject: raw, options: [.prettyPrinted, .sortedKeys]), let text = String(data: data, encoding: .utf8) else { return nil } return text } } private static func toAny(_ value: JSONValue) -> Any { switch value { case .string(let string): return string case .number(let number): return number case .bool(let flag): return flag case .array(let array): return array.map(toAny) case .object(let dict): return dict.mapValues(toAny) case .null: return NSNull() } } private static func formatLabel(_ label: String, value: String) -> String { value.contains("\n") ? "\(label):\n\(value)" : "\(label): \(value)" } private static func isToolLike(_ kind: MessageVisibilityKind) -> Bool { switch kind { case .tool, .codeEdit: return true default: return false } } private static func isCodeEdit(payload: ResponseItemPayload, fallbackText: String) -> Bool { let name = normalizeToolName(payload.name) if codeEditToolNames.contains(name) { return true } if containsEditKeys(payload.arguments) || containsEditKeys(payload.input) { return true } if name == "execcommand" || name == "bash" || name == "runshellcommand" { let argsText = stringValue(payload.arguments) ?? "" if containsCodeEditMarkers(argsText) { return true } } if let outputText = stringValue(payload.output), containsStrongEditOutputMarkers(outputText) { return true } if containsCodeEditMarkers(fallbackText) { return true } return false } private static var codeEditToolNames: Set { [ "edit", "write", "replace", "applypatch", "patch", "createfile", "writefile", "deletefile", "fileedit", "filewrite", "updatefile", "insert", "append", "move", "rename", "remove", "multiedit" ] } private static func normalizeToolName(_ name: String?) -> String { let raw = name?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" if raw.isEmpty { return "" } return raw .replacingOccurrences(of: "_", with: "") .replacingOccurrences(of: "-", with: "") .replacingOccurrences(of: " ", with: "") } private static func containsEditKeys(_ value: JSONValue?) -> Bool { guard let value else { return false } switch value { case .object(let dict): let keys = Set(dict.keys.map { $0.lowercased() }) let hasPath = keys.contains("file_path") || keys.contains("filepath") || keys.contains("path") let hasOldNew = keys.contains("old_string") || keys.contains("new_string") let hasPatch = keys.contains("patch") || keys.contains("diff") let hasContent = keys.contains("content") || keys.contains("new_content") || keys.contains("text") if hasOldNew || hasPatch { return true } if hasPath && hasContent { return true } return dict.values.contains { containsEditKeys($0) } case .array(let array): return array.contains { containsEditKeys($0) } default: return false } } private static func containsCodeEditMarkers(_ text: String) -> Bool { let lowered = text.lowercased() if lowered.contains("*** begin patch") { return true } if lowered.contains("*** update file") { return true } if lowered.contains("*** add file") { return true } if lowered.contains("*** delete file") { return true } if lowered.contains("update file:") { return true } return false } private static func containsStrongEditOutputMarkers(_ text: String) -> Bool { let lowered = text.lowercased() if lowered.contains("updated the following files") { return true } if lowered.contains("success. updated the following files") { return true } return false } private static func stringValue(_ value: JSONValue?) -> String? { guard let value else { return nil } switch value { case .string(let string): return string case .number(let number): return String(number) case .bool(let flag): return flag ? "true" : "false" case .object, .array: return nil case .null: return nil } } } ================================================ FILE: utils/TokenFormatter.swift ================================================ import Foundation enum TokenFormatter { /// Compact readable string for tokens (uses K/M/B suffixes). static func short(_ value: Int) -> String { let absValue = Double(abs(value)) let sign = value < 0 ? "-" : "" switch absValue { case 0..<1_000: return "\(value.formatted())" case 1_000..<1_000_000: return "\(sign)\(format(absValue / 1_000, digits: 1))K" case 1_000_000..<1_000_000_000: return "\(sign)\(format(absValue / 1_000_000, digits: 2))M" default: return "\(sign)\(format(absValue / 1_000_000_000, digits: 2))B" } } /// Decimal string with optional K/M suffix used by usage panes. static func string(from value: Int) -> String { let absValue = abs(value) switch absValue { case 1_000_000...: return format(Double(value) / 1_000_000, digits: 1) + "M" case 1_000...: return format(Double(value) / 1_000, digits: 1) + "K" default: return NumberFormatter.decimalFormatter.string(from: NSNumber(value: value)) ?? "\(value)" } } private static func format(_ value: Double, digits: Int) -> String { let formatter = NumberFormatter() formatter.maximumFractionDigits = digits formatter.minimumFractionDigits = 0 formatter.numberStyle = .decimal return formatter.string(from: NSNumber(value: value)) ?? "\(value)" } } ================================================ FILE: utils/UpdateSupport.swift ================================================ import Foundation struct Version: Comparable, Sendable { let components: [Int] init?(_ raw: String) { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return nil } let noPrefix = trimmed.hasPrefix("v") ? String(trimmed.dropFirst()) : trimmed let core = noPrefix.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: true).first ?? "" var parts = core.split(separator: ".").compactMap { Int($0) } if parts.isEmpty { return nil } while parts.count > 1, parts.last == 0 { parts.removeLast() } self.components = parts } static func < (lhs: Version, rhs: Version) -> Bool { let maxCount = max(lhs.components.count, rhs.components.count) for idx in 0.. String { switch arch { case .arm64: return "codmate-arm64.dmg" case .x86_64: return "codmate-x86_64.dmg" } } } ================================================ FILE: utils/WarpTitlePrompt.swift ================================================ import Foundation #if canImport(AppKit) import AppKit enum WarpTitlePrompt { static func requestCustomTitle(defaultValue: String) -> String? { let alert = NSAlert() alert.messageText = "Warp Tab Title" alert.informativeText = "Enter a short slug for the new tab (letters, digits, hyphen). Leave blank to use the suggested value." alert.alertStyle = .informational alert.addButton(withTitle: "Confirm") alert.addButton(withTitle: "Cancel") let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 360, height: 24)) field.stringValue = defaultValue alert.accessoryView = field field.selectText(nil) alert.window.initialFirstResponder = field let response = alert.runModal() if response == .alertFirstButtonReturn { return field.stringValue } else { return nil } } } #else enum WarpTitlePrompt { static func requestCustomTitle(defaultValue: String) -> String? { defaultValue } } #endif ================================================ FILE: utils/WindowConfigurator.swift ================================================ import SwiftUI import AppKit struct WindowConfigurator: NSViewRepresentable { let apply: (NSWindow) -> Void func makeNSView(context: Context) -> NSView { let view = NSView(frame: .zero) DispatchQueue.main.async { if let window = view.window { apply(window) } else { // Try again on next runloop if window not yet attached DispatchQueue.main.async { [weak view] in if let w = view?.window { apply(w) } } } } return view } func updateNSView(_ nsView: NSView, context: Context) {} } ================================================ FILE: views/APIKeyProviderIconView.swift ================================================ import SwiftUI import AppKit struct APIKeyProviderIconView: View { let provider: ProvidersRegistryService.Provider var size: CGFloat = 16 var cornerRadius: CGFloat = 4 var isSelected: Bool = false @Environment(\.colorScheme) private var colorScheme var body: some View { Group { // Priority 1: Custom SF Symbol icon (for user-created providers) if let customIconName = provider.customIcon { Image(systemName: customIconName) .font(.system(size: size * 0.9)) .foregroundStyle(isSelected ? Color.accentColor : Color.primary) .frame(width: size, height: size) } // Priority 2: Preset PNG icon from resources else if let image = processedIcon { Image(nsImage: image) .resizable() .interpolation(.high) .aspectRatio(contentMode: .fit) .frame(width: size, height: size) .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) .overlay( RoundedRectangle(cornerRadius: cornerRadius) .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2) ) } // Fallback: Default circle icon else { Image(systemName: isSelected ? "largecircle.fill.circle" : "circle") .foregroundStyle(Color.accentColor) .frame(width: size, height: size) } } .frame(width: size, height: size, alignment: .center) .id(colorScheme) // Force refresh when colorScheme changes } /// Computed property that depends on colorScheme, ensuring real-time theme updates private var processedIcon: NSImage? { // Use unified icon resource library let codexBaseURL = provider.connectors[ProvidersRegistryService.Consumer.codex.rawValue]?.baseURL let claudeBaseURL = provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue]?.baseURL let baseURL = codexBaseURL ?? claudeBaseURL guard let iconName = ProviderIconResource.iconName( forProviderId: provider.id, name: provider.name, baseURL: baseURL ) else { return nil } // Use unified resource processing with theme adaptation // This computed property depends on colorScheme, so SwiftUI will recompute it when theme changes let isDarkMode = colorScheme == .dark return ProviderIconResource.processedImage( named: iconName, size: NSSize(width: size, height: size), isDarkMode: isDarkMode ) } private func iconNameForProvider(_ provider: ProvidersRegistryService.Provider) -> String? { let codexBaseURL = provider.connectors[ProvidersRegistryService.Consumer.codex.rawValue]?.baseURL let claudeBaseURL = provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue]?.baseURL let baseURL = codexBaseURL ?? claudeBaseURL return ProviderIconResource.iconName( forProviderId: provider.id, name: provider.name, baseURL: baseURL ) } } ================================================ FILE: views/AboutViews.swift ================================================ import AppKit import SwiftUI struct OpenSourceLicensesView: View { let repoURL: URL @State private var content: String = "" @Environment(\.dismiss) private var dismiss var body: some View { VStack(alignment: .leading, spacing: 12) { HStack { Text("Open Source Licenses") .font(.title3).fontWeight(.semibold) Spacer() Button("Done") { dismiss() } .keyboardShortcut(.defaultAction) } .padding(.bottom, 4) if content.isEmpty { ProgressView() .task { await loadContent() } } else { ScrollView { Text(content) .font(.system(.body, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .topLeading) .padding(.top, 4) } } } .padding(16) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } private func candidateLocalURLs() -> [URL] { var urls: [URL] = [] if let bundled = Bundle.main.url(forResource: "THIRD-PARTY-NOTICES", withExtension: "md") { urls.append(bundled) } let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) urls.append(cwd.appendingPathComponent("THIRD-PARTY-NOTICES.md")) // When running from Xcode/DerivedData, try a few parents let execDir = Bundle.main.bundleURL urls.append(execDir.appendingPathComponent("Contents/Resources/THIRD-PARTY-NOTICES.md")) return urls } private func loadContent() async { for url in candidateLocalURLs() { if FileManager.default.fileExists(atPath: url.path), let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) { await MainActor.run { self.content = text } return } } // Fallback to remote raw file on GitHub if local not found if let remote = URL( string: "https://raw.githubusercontent.com/loocor/CodMate/main/THIRD-PARTY-NOTICES.md") { do { let (data, _) = try await URLSession.shared.data(from: remote) if let text = String(data: data, encoding: .utf8) { await MainActor.run { self.content = text } } } catch { await MainActor.run { self.content = "Unable to load licenses. Please see THIRD-PARTY-NOTICES.md in the repository." } } } } } struct UpdateSection: View { @ObservedObject var viewModel: UpdateViewModel var body: some View { VStack(alignment: .leading, spacing: 8) { LabeledContent("Version") { Text(versionString) } .frame(maxWidth: .infinity, alignment: .leading) if AppDistribution.isAppStore { Text("Updates are managed by the App Store.") .font(.subheadline) .foregroundColor(.secondary) } else { Group { if case .upToDate(let current, _) = viewModel.state { VStack(alignment: .leading, spacing: 8) { if let lastCheckedAt = viewModel.lastCheckedAt { Text( "Up to date (\(current)), Last checked \(Self.lastCheckedFormatter.string(from: lastCheckedAt))" ) .font(.subheadline) } else { Text("Up to date (\(current)).") .font(.subheadline) } Button("Check Now") { viewModel.checkNow() } .controlSize(.small) } } else { content } } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(12) .background( RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(Color.gray.opacity(0.06)) ) .overlay( RoundedRectangle(cornerRadius: 10, style: .continuous) .stroke(Color.gray.opacity(0.15), lineWidth: 1) ) .alert("Install", isPresented: $viewModel.showInstallInstructions) { Button("OK", role: .cancel) {} } message: { Text(viewModel.installInstructions) } } private var versionString: String { let info = Bundle.main.infoDictionary let version = info?["CFBundleShortVersionString"] as? String ?? "—" let build = info?["CFBundleVersion"] as? String ?? "—" return "\(version) (\(build))" } private var buildTimestampString: String { guard let executableURL = Bundle.main.executableURL, let attrs = try? FileManager.default.attributesOfItem(atPath: executableURL.path), let date = attrs[.modificationDate] as? Date else { return "Unavailable" } return Self.buildDateFormatter.string(from: date) } private static let buildDateFormatter: DateFormatter = { let df = DateFormatter() df.dateStyle = .medium df.timeStyle = .medium return df }() @ViewBuilder private var content: some View { switch viewModel.state { case .idle: VStack(alignment: .leading, spacing: 4) { Text("Check for updates.") .font(.subheadline) Button("Check Now") { viewModel.checkNow() } .controlSize(.small) } case .checking: HStack(spacing: 8) { ProgressView() Text("Checking...") .font(.subheadline) } case .upToDate(let current, _): Text("Up to date (\(current)).") .font(.subheadline) case .updateAvailable(let info): VStack(alignment: .leading, spacing: 6) { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 4) { Text("New version available: \(info.latestVersion)") .font(.subheadline) .fontWeight(.semibold) Text(info.assetName) .font(.caption) .foregroundColor(.secondary) } Spacer() if viewModel.isDownloading { HStack(spacing: 6) { ProgressView() Text("Downloading...") .font(.subheadline) .foregroundColor(.secondary) } } else { Button("Download & Install") { viewModel.downloadIfNeeded() } .controlSize(.small) } } if let lastError = viewModel.lastError { Text("Download failed: \(lastError)") .font(.caption) .foregroundColor(.red) } } case .error(let message): HStack { Text("Update check failed: \(message)") .font(.subheadline) .foregroundColor(.red) Spacer() Button("Retry") { viewModel.checkNow() } .controlSize(.small) } } } private static let lastCheckedFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateStyle = .medium formatter.timeStyle = .medium return formatter }() } struct AboutSettingsView: View { @ObservedObject var updateViewModel: UpdateViewModel @State private var showLicensesSheet = false var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 6) { Text("About CodMate") .font(.title2) .fontWeight(.bold) Text( "CodMate is a macOS SwiftUI app for managing CLI AI sessions: browse, search, organize, resume, and review work produced by Codex, Claude Code, and Gemini CLI." ) .font(.subheadline) .foregroundColor(.secondary) } VStack(alignment: .leading, spacing: 12) { UpdateSection(viewModel: updateViewModel) .onAppear { updateViewModel.loadCached() updateViewModel.checkIfNeeded(trigger: .aboutAuto) } LabeledContent("Repository") { Link(repoURL.absoluteString, destination: repoURL) } LabeledContent("Project URL") { Link(projectURL.absoluteString, destination: projectURL) } LabeledContent("Open Source Licenses") { Button("View…") { showLicensesSheet = true } .buttonStyle(.bordered) } } .frame(maxWidth: .infinity, alignment: .leading) // Discord Community HStack(spacing: 12) { Image(systemName: "bubble.left.and.bubble.right.fill") .font(.title2) .foregroundStyle(.blue) .frame(width: 32) VStack(alignment: .leading, spacing: 4) { Text("Join our Discord community") .font(.headline) .fontWeight(.semibold) Text("Get help, share feedback, and connect with other users") .font(.caption) .foregroundStyle(.secondary) Link("Join Discord", destination: discordURL) .font(.subheadline) .fontWeight(.medium) .padding(.top, 2) } Spacer() } } .frame(maxWidth: .infinity, alignment: .topLeading) .padding(.top, 24) .padding(.horizontal, 24) .padding(.bottom, 32) } .sheet(isPresented: $showLicensesSheet) { OpenSourceLicensesView(repoURL: repoURL) .frame(minWidth: 900, minHeight: 520) } } private var projectURL: URL { URL(string: "https://umate.ai/codmate")! } private var repoURL: URL { URL(string: "https://github.com/loocor/CodMate")! } private var discordURL: URL { URL(string: "https://discord.gg/5AcaTpVCcx")! } } ================================================ FILE: views/AdvancedPathPane.swift ================================================ import SwiftUI import AppKit struct AdvancedPathPane: View { @ObservedObject var preferences: SessionPreferencesStore @EnvironmentObject private var listViewModel: SessionListViewModel @StateObject private var cliVM = CLIPathVM() var body: some View { VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 10) { Text("Directories").font(.headline).fontWeight(.semibold) settingsCard { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { GridRow { VStack(alignment: .leading, spacing: 0) { Label("Projects Directory", systemImage: "folder") .font(.subheadline).fontWeight(.medium) Text("Directory where CodMate stores projects data") .font(.caption).foregroundColor(.secondary) } Text(preferences.projectsRoot.path) .lineLimit(1) .truncationMode(.middle) .frame(maxWidth: .infinity, alignment: .trailing) Button("Change…", action: selectProjectsRoot) .buttonStyle(.bordered) } gridDivider GridRow { VStack(alignment: .leading, spacing: 0) { Label("Notes Directory", systemImage: "text.book.closed") .font(.subheadline).fontWeight(.medium) Text("Where session titles and comments are saved") .font(.caption).foregroundColor(.secondary) } Text(preferences.notesRoot.path) .lineLimit(1) .truncationMode(.middle) .frame(maxWidth: .infinity, alignment: .trailing) Button("Change…", action: selectNotesRoot) .buttonStyle(.bordered) } } } } VStack(alignment: .leading, spacing: 10) { HStack { Text("CLI Command Paths").font(.headline).fontWeight(.semibold) Spacer(minLength: 8) Button { cliVM.refresh() } label: { Label("Refresh Auto-Detect", systemImage: "arrow.clockwise") } .buttonStyle(.bordered) } settingsCard { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { commandRow( title: "Codex Command", description: "Optional override for codex CLI", override: $preferences.codexCommandPath, autoInfo: cliVM.codex, isDisabled: !preferences.isCLIEnabled(.codex), onChoose: { selectCommandPath(kind: .codex) } ) gridDivider commandRow( title: "Claude Command", description: "Optional override for claude CLI", override: $preferences.claudeCommandPath, autoInfo: cliVM.claude, isDisabled: !preferences.isCLIEnabled(.claude), onChoose: { selectCommandPath(kind: .claude) } ) gridDivider commandRow( title: "Gemini Command", description: "Optional override for gemini CLI", override: $preferences.geminiCommandPath, autoInfo: cliVM.gemini, isDisabled: !preferences.isCLIEnabled(.gemini), onChoose: { selectCommandPath(kind: .gemini) } ) } } } VStack(alignment: .leading, spacing: 10) { Text("CLI & PATH").font(.headline).fontWeight(.semibold) settingsCard { Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { GridRow { Text("codex").font(.subheadline) Text(statusLabel(for: cliVM.codex)) .font(.caption) .frame(maxWidth: .infinity, alignment: .trailing) } GridRow { Text("claude").font(.subheadline) Text(statusLabel(for: cliVM.claude)) .font(.caption) .frame(maxWidth: .infinity, alignment: .trailing) } GridRow { Text("gemini").font(.subheadline) Text(statusLabel(for: cliVM.gemini)) .font(.caption) .frame(maxWidth: .infinity, alignment: .trailing) } GridRow { Text("PATH").font(.subheadline) Text(cliVM.pathEnv) .font(.caption) .lineLimit(2) .truncationMode(.middle) .frame(maxWidth: .infinity, alignment: .trailing) } } } } } .task { await Task.yield() cliVM.refresh() } } // MARK: - Helpers @ViewBuilder private func commandRow( title: String, description: String, override: Binding, autoInfo: CLIPathVM.CLIInfo, isDisabled: Bool, onChoose: @escaping () -> Void ) -> some View { GridRow { VStack(alignment: .leading, spacing: 0) { Text(title).font(.subheadline).fontWeight(.medium) Text(description) .font(.caption) .foregroundColor(.secondary) } VStack(alignment: .leading, spacing: 6) { TextField(placeholderText(for: autoInfo), text: override) .textFieldStyle(.roundedBorder) if let warning = overrideWarning(for: override.wrappedValue, autoInfo: autoInfo) { Text(warning) .font(.caption2) .foregroundColor(.orange) } } HStack(spacing: 8) { Button(autoInfo.path == nil ? "Choose…" : "Change…", action: onChoose) .buttonStyle(.bordered) Button(clearLabel(for: override.wrappedValue, autoInfo: autoInfo)) { override.wrappedValue = "" } .buttonStyle(.bordered) .disabled(override.wrappedValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } .disabled(isDisabled) } private func placeholderText(for info: CLIPathVM.CLIInfo) -> String { if let path = info.path { if let version = info.version, !version.isEmpty { return "\(path) (\(version))" } return path } return "Optional override (absolute path)" } private func clearLabel(for value: String, autoInfo: CLIPathVM.CLIInfo) -> String { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty, autoInfo.path != nil { return "Reset" } return "Clear" } private func statusLabel(for info: CLIPathVM.CLIInfo) -> String { if let version = info.version, !version.isEmpty { return version } return info.path == nil ? "N/A" : "Yes" } private func overrideWarning(for value: String, autoInfo: CLIPathVM.CLIInfo) -> String? { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } let expanded = expandHomePath(trimmed) if !FileManager.default.isExecutableFile(atPath: expanded) { if autoInfo.path == nil { return "Override not executable; auto-detect also failed." } return "Override not executable; auto-detect will be used." } return nil } private func expandHomePath(_ path: String) -> String { if path.hasPrefix("~") { return (path as NSString).expandingTildeInPath } if path.contains("$HOME") { return path.replacingOccurrences(of: "$HOME", with: NSHomeDirectory()) } return path } private func selectProjectsRoot() { let panel = NSOpenPanel() panel.canChooseFiles = false panel.canChooseDirectories = true panel.allowsMultipleSelection = false panel.canCreateDirectories = true panel.directoryURL = preferences.projectsRoot panel.message = "Select the directory where CodMate stores projects data" panel.begin { response in guard response == .OK, let url = panel.url else { return } Task { await listViewModel.updateProjectsRoot(to: url) } } } private func selectNotesRoot() { let panel = NSOpenPanel() panel.canChooseFiles = false panel.canChooseDirectories = true panel.allowsMultipleSelection = false panel.canCreateDirectories = true panel.directoryURL = preferences.notesRoot panel.message = "Select the directory where session notes are stored" panel.begin { response in guard response == .OK, let url = panel.url else { return } Task { await listViewModel.updateNotesRoot(to: url) } } } private func selectCommandPath(kind: SessionSource.Kind) { let panel = NSOpenPanel() panel.canChooseFiles = true panel.canChooseDirectories = false panel.allowsMultipleSelection = false panel.message = "Select the \(kind.cliExecutableName) executable" panel.prompt = "Select" panel.begin { response in guard response == .OK, let url = panel.url else { return } switch kind { case .codex: preferences.codexCommandPath = url.path case .claude: preferences.claudeCommandPath = url.path case .gemini: preferences.geminiCommandPath = url.path } } } @ViewBuilder private func settingsCard(@ViewBuilder _ content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 8) { content() } .padding(10) .background(Color(nsColor: .separatorColor).opacity(0.35)) .cornerRadius(10) } @ViewBuilder private var gridDivider: some View { Divider() } } ================================================ FILE: views/AdvancedSettingsView.swift ================================================ import SwiftUI struct AdvancedSettingsView: View { @ObservedObject var preferences: SessionPreferencesStore var body: some View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 6) { Text("Advanced Settings") .font(.title2) .fontWeight(.bold) Text("Paths, command resolution, and deeper diagnostics.") .font(.subheadline) .foregroundColor(.secondary) } Spacer(minLength: 8) } Group { if #available(macOS 15.0, *) { TabView { Tab("Path", systemImage: "folder") { SettingsTabContent { AdvancedPathPane(preferences: preferences) } } Tab("Dialectics", systemImage: "doc.text.magnifyingglass") { SettingsTabContent { DialecticsPane(preferences: preferences) } } } } else { TabView { SettingsTabContent { AdvancedPathPane(preferences: preferences) } .tabItem { Label("Path", systemImage: "folder") } SettingsTabContent { DialecticsPane(preferences: preferences) } .tabItem { Label("Dialectics", systemImage: "doc.text.magnifyingglass") } } } } .controlSize(.regular) .padding(.bottom, 16) } } } ================================================ FILE: views/AttributedTextView.swift ================================================ import SwiftUI import AppKit // High-performance NSTextView wrapper with optional line numbers, wrapping and simple diff/syntax colors. struct AttributedTextView: NSViewRepresentable { final class Coordinator { var lastText: String = "" var lastIsDiff: Bool = false var lastWrap: Bool = true var lastFontSize: CGFloat = 12 var textStorage = NSTextStorage() var lastSearchQuery: String = "" } var text: String var isDiff: Bool var wrap: Bool var showLineNumbers: Bool var fontSize: CGFloat = 12 var searchQuery: String = "" var lineFragmentPaddingOverride: CGFloat? = nil func makeCoordinator() -> Coordinator { Coordinator() } func makeNSView(context: Context) -> NSScrollView { let scroll = NSScrollView() scroll.hasVerticalScroller = true scroll.hasHorizontalScroller = true scroll.borderType = .noBorder scroll.drawsBackground = false let layoutMgr = LineNumberLayoutManager() layoutMgr.showsLineNumbers = showLineNumbers layoutMgr.wrapEnabled = wrap context.coordinator.textStorage.addLayoutManager(layoutMgr) let container = NSTextContainer(size: .zero) container.widthTracksTextView = wrap container.heightTracksTextView = false layoutMgr.addTextContainer(container) let tv = NSTextView(frame: .zero, textContainer: container) tv.isEditable = false tv.isSelectable = true tv.isRichText = false tv.usesFindBar = true tv.drawsBackground = false // Use inner lineFragmentPadding as gutter to keep drawing inside container clip let gutterWidth: CGFloat = lineFragmentPaddingOverride ?? (showLineNumbers ? 44 : 6) tv.textContainerInset = NSSize(width: 8, height: 8) tv.textContainer?.lineFragmentPadding = gutterWidth tv.linkTextAttributes = [:] tv.font = preferredFont(size: fontSize) tv.allowsUndo = false tv.isVerticallyResizable = true tv.minSize = NSSize(width: 0, height: 0) tv.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) tv.autoresizingMask = [.width] if !wrap { tv.isHorizontallyResizable = true container.containerSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) } else { tv.isHorizontallyResizable = false // Seed a sensible initial width when wrapping, otherwise container width may be 0 let initialW = max(1, scroll.contentSize.width) container.containerSize = NSSize(width: initialW, height: CGFloat.greatestFiniteMagnitude) } scroll.documentView = tv layoutMgr.textView = tv // Seed content apply(text: text, isDiff: isDiff, wrap: wrap, tv: tv, storage: context.coordinator.textStorage, coordinator: context.coordinator) context.coordinator.lastText = text context.coordinator.lastIsDiff = isDiff context.coordinator.lastWrap = wrap context.coordinator.lastFontSize = fontSize applySearchHighlight(searchQuery, in: tv) context.coordinator.lastSearchQuery = searchQuery return scroll } func updateNSView(_ nsView: NSScrollView, context: Context) { guard let tv = nsView.documentView as? NSTextView, let container = tv.textContainer else { return } // Update wrapping if context.coordinator.lastWrap != wrap { container.widthTracksTextView = wrap if wrap { tv.isHorizontallyResizable = false // Ensure container follows current view width to lay out lines let w = max(1, tv.enclosingScrollView?.contentSize.width ?? tv.bounds.width) container.containerSize = NSSize(width: w, height: CGFloat.greatestFiniteMagnitude) } else { tv.isHorizontallyResizable = true container.containerSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) } context.coordinator.lastWrap = wrap // Propagate to layout manager if let lm = tv.layoutManager as? LineNumberLayoutManager { lm.wrapEnabled = wrap } // Ensure layout refresh after wrap mode change tv.layoutManager?.ensureLayout(for: container) tv.needsDisplay = true } // While staying in the same wrap mode, keep the container width in sync with the visible width if wrap { let currentW = tv.enclosingScrollView?.contentSize.width ?? tv.bounds.width let cw = container.containerSize.width if abs(currentW - cw) > 0.5 { container.containerSize = NSSize(width: max(1, currentW), height: CGFloat.greatestFiniteMagnitude) // Keep layout in sync with container width changes tv.layoutManager?.ensureLayout(for: container) tv.needsDisplay = true } } // Update font if changed if context.coordinator.lastFontSize != fontSize { tv.font = preferredFont(size: fontSize) context.coordinator.lastFontSize = fontSize } // Update line number rendering via custom layout manager and inner padding if let lm = tv.layoutManager as? LineNumberLayoutManager { lm.showsLineNumbers = showLineNumbers lm.wrapEnabled = wrap } let gutterWidth2: CGFloat = lineFragmentPaddingOverride ?? (showLineNumbers ? 44 : 6) tv.textContainerInset = NSSize(width: 8, height: 8) tv.textContainer?.lineFragmentPadding = gutterWidth2 // Update content only when changed to avoid re-layout cost if text != context.coordinator.lastText || isDiff != context.coordinator.lastIsDiff { apply(text: text, isDiff: isDiff, wrap: wrap, tv: tv, storage: context.coordinator.textStorage, coordinator: context.coordinator) context.coordinator.lastText = text context.coordinator.lastIsDiff = isDiff // Re-apply highlight after content changes applySearchHighlight(searchQuery, in: tv) context.coordinator.lastSearchQuery = searchQuery } // Update highlight if query changed if searchQuery != context.coordinator.lastSearchQuery { applySearchHighlight(searchQuery, in: tv) context.coordinator.lastSearchQuery = searchQuery } } private func preferredFont(size: CGFloat) -> NSFont { let candidates = [ "JetBrains Mono", "JetBrainsMono-Regular", "JetBrains Mono NL", "SF Mono", "Menlo" ] for name in candidates { if let f = NSFont(name: name, size: size) { return f } } return NSFont.monospacedSystemFont(ofSize: size, weight: .regular) } private func apply(text: String, isDiff: Bool, wrap: Bool, tv: NSTextView, storage: NSTextStorage, coordinator: Coordinator) { // Build attributed string off-main to keep UI snappy let input = text let font = preferredFont(size: fontSize) DispatchQueue.global(qos: .userInitiated).async { let attr = NSMutableAttributedString(string: input, attributes: [ .font: font, .foregroundColor: NSColor.labelColor ]) // Precompute newline UTF-16 offsets for fast line-number lookup let ns = input as NSString var nl: [Int] = [] nl.reserveCapacity(1024) let len = ns.length if len > 0 { // Use getCharacters buffer for speed let buf = UnsafeMutablePointer.allocate(capacity: len) ns.getCharacters(buf, range: NSRange(location: 0, length: len)) for i in 0.. 0 else { mapR.append(nil); mapL.append(nil); return } let firstChar = full.substring(with: NSRange(location: range.location, length: 1)) // Detect hunk header: @@ -l,ct +r,ct @@ if DiffStyler_lineStarts(with: "@@", in: full, at: range) { // Parse left/right starts let lineStr = full.substring(with: range) currentRight = parseRightStart(fromHunkHeader: lineStr) currentLeft = parseLeftStart(fromHunkHeader: lineStr) mapR.append(nil) mapL.append(nil) return } // Ignore file headers if DiffStyler_lineStarts(with: "diff --git", in: full, at: range) || DiffStyler_lineStarts(with: "index ", in: full, at: range) || DiffStyler_lineStarts(with: "+++", in: full, at: range) || DiffStyler_lineStarts(with: "---", in: full, at: range) { mapR.append(nil); mapL.append(nil); return } if firstChar == "+" && !DiffStyler_lineStarts(with: "+++", in: full, at: range) { if let r0 = currentRight { mapR.append(r0); diffMaxRight = max(diffMaxRight, r0); currentRight = r0 + 1 } else { mapR.append(nil) } mapL.append(nil) } else if firstChar == " " { if let r0 = currentRight { mapR.append(r0); diffMaxRight = max(diffMaxRight, r0); currentRight = r0 + 1 } else { mapR.append(nil) } if let l0 = currentLeft { mapL.append(l0); diffMaxLeft = max(diffMaxLeft, l0); currentLeft = l0 + 1 } else { mapL.append(nil) } } else if firstChar == "-" && !DiffStyler_lineStarts(with: "---", in: full, at: range) { if let l0 = currentLeft { mapL.append(l0); diffMaxLeft = max(diffMaxLeft, l0); currentLeft = l0 + 1 } else { mapL.append(nil) } mapR.append(nil) } else { mapR.append(nil) mapL.append(nil) } } diffRightNumbers = mapR diffLeftNumbers = mapL } else { // Light syntax hints for common formats SyntaxStyler.applyLight(to: attr) } DispatchQueue.main.async { storage.setAttributedString(attr) tv.textStorage?.setAttributedString(attr) if let lm = tv.layoutManager as? LineNumberLayoutManager { lm.newlineOffsets = nl lm.diffMode = isDiff lm.diffRightLineNumbers = diffRightNumbers lm.diffLeftLineNumbers = diffLeftNumbers } // Dynamic gutter width based on maximum line number digits let totalLines = max(1, nl.count + 1) let targetMax = isDiff ? max(1, max(diffMaxRight, diffMaxLeft)) : totalLines let digits = max(2, String(targetMax).count) let sample = String(repeating: "8", count: digits) as NSString let numWidth = sample.size(withAttributes: [.font: font]).width let gap: CGFloat = 8 // spacing between numbers and text start let leftPad: CGFloat = 5 // inner left padding inside gutter let minGutter: CGFloat = 36 let gutter = max(minGutter, ceil(numWidth + gap + leftPad)) tv.textContainer?.lineFragmentPadding = gutter tv.needsDisplay = true tv.setSelectedRange(NSRange(location: 0, length: 0)) } } } } // MARK: - Search highlight helpers private let cmHighlightKey = NSAttributedString.Key("cmHighlight") private func applySearchHighlight(_ query: String, in tv: NSTextView) { guard let storage = tv.textStorage else { return } let str = storage.string as NSString let fullRange = NSRange(location: 0, length: str.length) // Clear previous highlights (only our custom key) storage.enumerateAttribute(cmHighlightKey, in: fullRange) { value, range, _ in if value != nil { storage.removeAttribute(.backgroundColor, range: range) storage.removeAttribute(cmHighlightKey, range: range) } } let q = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !q.isEmpty else { return } let options: NSString.CompareOptions = [.caseInsensitive] var searchRange = fullRange let highlight = NSColor.systemYellow.withAlphaComponent(0.35) while searchRange.length > 0 { let r = str.range(of: q, options: options, range: searchRange) if r.location == NSNotFound { break } storage.addAttributes([.backgroundColor: highlight, cmHighlightKey: 1], range: r) let nextLoc = r.location + r.length if nextLoc >= str.length { break } searchRange = NSRange(location: nextLoc, length: str.length - nextLoc) } } private enum DiffStyler { static func apply(to s: NSMutableAttributedString) { let full = s.string as NSString full.enumerateSubstrings(in: NSRange(location: 0, length: full.length), options: .byLines) { _, range, _, _ in guard range.length > 0 else { return } let first = full.substring(with: NSRange(location: range.location, length: 1)) let bg: NSColor? let fg: NSColor? if first == "+" && !lineStarts(with: "+++", in: full, at: range) { bg = NSColor.systemGreen.withAlphaComponent(0.12); fg = nil } else if first == "-" && !lineStarts(with: "---", in: full, at: range) { bg = NSColor.systemRed.withAlphaComponent(0.12); fg = nil } else if lineStarts(with: "@@", in: full, at: range) { bg = NSColor.systemBlue.withAlphaComponent(0.08); fg = NSColor.systemBlue } else if lineStarts(with: "diff --git", in: full, at: range) || lineStarts(with: "index ", in: full, at: range) || lineStarts(with: "+++", in: full, at: range) || lineStarts(with: "---", in: full, at: range) { bg = NSColor.quaternaryLabelColor.withAlphaComponent(0.12); fg = NSColor.secondaryLabelColor } else { bg = nil; fg = nil } var attrs: [NSAttributedString.Key: Any] = [:] if let bg { attrs[.backgroundColor] = bg } if let fg { attrs[.foregroundColor] = fg } if !attrs.isEmpty { s.addAttributes(attrs, range: range) } } } private static func lineStarts(with prefix: String, in str: NSString, at range: NSRange) -> Bool { if str.length >= range.location + prefix.count { return str.substring(with: NSRange(location: range.location, length: prefix.count)) == prefix } return false } } private enum SyntaxStyler { // Cached regex patterns (compiled once for performance) private static let keywordPattern: NSRegularExpression? = { // Common keywords across multiple languages (combined into one regex) let keywords = [ // JavaScript/TypeScript "function", "const", "let", "var", "if", "else", "return", "for", "while", "import", "export", "class", "extends", "async", "await", "try", "catch", // Swift "func", "struct", "enum", "protocol", "private", "public", "guard", "defer", // Python "def", "lambda", "with", "as", "pass", "yield", "raise", "except", // Rust "fn", "impl", "trait", "mod", "use", "pub", "mut", "unsafe", // Go "package", "type", "interface", "chan", "go", "range", // Common control flow "switch", "case", "default", "break", "continue", // Common types and values "int", "bool", "string", "float", "void", "null", "true", "false", "nil", "undefined", // YAML/TOML specific "yes", "no", "on", "off" ] let pattern = "\\b(" + keywords.joined(separator: "|") + ")\\b" return try? NSRegularExpression(pattern: pattern, options: []) }() private static let numberPattern: NSRegularExpression? = { try? NSRegularExpression(pattern: "\\b(0x[0-9A-Fa-f]+|\\d+\\.?\\d*)\\b", options: []) }() static func applyLight(to s: NSMutableAttributedString) { let str = s.string as NSString let fullString = s.string let fullRange = NSRange(location: 0, length: str.length) // Color palette (using system colors for auto Light/Dark adaptation) let keywordColor = NSColor.systemPink let stringColor = NSColor.systemRed let commentColor = NSColor.systemGreen let numberColor = NSColor.systemPurple // 1. Strings (all quote types in one pass) highlightStrings(in: s, str: str, color: stringColor) // 2. Comments (both // and # in one pass) highlightComments(in: s, fullString: fullString, color: commentColor) // 3. Keywords (single regex with all keywords combined) keywordPattern?.enumerateMatches(in: fullString, range: fullRange) { match, _, _ in if let range = match?.range { s.addAttribute(.foregroundColor, value: keywordColor, range: range) } } // 4. Numbers (single regex) numberPattern?.enumerateMatches(in: fullString, range: fullRange) { match, _, _ in if let range = match?.range { s.addAttribute(.foregroundColor, value: numberColor, range: range) } } } // Highlight strings (all quote types: ", ', `) private static func highlightStrings(in s: NSMutableAttributedString, str: NSString, color: NSColor) { let quotes: [UInt16] = [34, 39, 96] // ", ', ` for quote in quotes { var idx = 0 while idx < str.length { let c = str.character(at: idx) if c == quote { let start = idx idx += 1 var escaping = false while idx < str.length { let cc = str.character(at: idx) if cc == 92 { escaping.toggle() } // '\\' else if cc == quote && !escaping { break } else { escaping = false } idx += 1 } let end = min(idx + 1, str.length) s.addAttribute(.foregroundColor, value: color, range: NSRange(location: start, length: end - start)) } idx += 1 } } } // Highlight comments (//, #, ; for different file formats) private static func highlightComments(in s: NSMutableAttributedString, fullString: String, color: NSColor) { // Support multiple comment styles: // // - C-style (JS, Swift, Rust, Go, etc.) // # - Shell-style (Python, Ruby, YAML, TOML, ENV) // ; - INI-style (INI files) let commentStarts = ["//", "#", ";"] for commentStart in commentStarts { let scanner = Scanner(string: fullString) scanner.charactersToBeSkipped = nil while !scanner.isAtEnd { _ = scanner.scanUpToString(commentStart) if scanner.scanString(commentStart) != nil { let start = scanner.currentIndex _ = scanner.scanUpToCharacters(from: .newlines) let end = scanner.currentIndex s.addAttribute(.foregroundColor, value: color, range: NSRange(start.. Int { if newlineOffsets.isEmpty { return 1 } var lo = 0, hi = newlineOffsets.count while lo < hi { let mid = (lo + hi) >> 1 if newlineOffsets[mid] < idx { lo = mid + 1 } else { hi = mid } } return lo + 1 // lines start at 1 } override func drawBackground(forGlyphRange glyphsToShow: NSRange, at origin: NSPoint) { super.drawBackground(forGlyphRange: glyphsToShow, at: origin) guard let textView = textView, let container = textView.textContainer else { return } let visibleRect = textView.enclosingScrollView?.contentView.bounds ?? textView.visibleRect if showsLineNumbers { // Draw gutter strictly inside container: [origin.x, origin.x + padding) let padding = textView.textContainer?.lineFragmentPadding ?? 0 let gutterRect = NSRect( x: origin.x, y: visibleRect.minY, width: max(0, padding), height: visibleRect.height ) (textView.backgroundColor).setFill() NSBezierPath(rect: gutterRect).fill() } guard showsLineNumbers else { return } // Convert view rect to container coordinates for querying glyphs let containerRect = NSRect(x: visibleRect.origin.x - origin.x, y: visibleRect.origin.y - origin.y, width: visibleRect.width, height: visibleRect.height) let glyphRange = self.glyphRange(forBoundingRect: containerRect, in: container) var lastDrawnLogicalLine: Int? = nil self.enumerateLineFragments(forGlyphRange: glyphRange) { _, usedRect, _, lineGlyphRange, _ in let y = origin.y + usedRect.minY // Determine logical line index for this visual fragment let charRange = self.characterRange(forGlyphRange: lineGlyphRange, actualGlyphRange: nil) let logicalLine = self.lineNumberFor(charIndex: charRange.location) let idx = logicalLine - 1 let rightVal = (self.diffMode && idx >= 0 && idx < self.diffRightLineNumbers.count) ? self.diffRightLineNumbers[idx] : nil let leftVal = (self.diffMode && idx >= 0 && idx < self.diffLeftLineNumbers.count) ? self.diffLeftLineNumbers[idx] : nil let isDeletion = self.diffMode && leftVal != nil && rightVal == nil let drawColor = isDeletion ? self.deletionNumberColor : self.numberColor let attrs: [NSAttributedString.Key: Any] = [ .font: textView.font ?? NSFont.monospacedSystemFont(ofSize: 11, weight: .regular), .foregroundColor: drawColor ] let shouldDrawThisFragment: Bool = { if self.wrapEnabled { // In wrap mode, draw the number for every visual fragment to avoid gaps return true } else { // In non-wrap mode, draw once per visible logical line return lastDrawnLogicalLine != logicalLine } }() let numString: String = { if !shouldDrawThisFragment { return "" } if self.diffMode { if isDeletion, let l = leftVal { return String(l) } if let r = rightVal { return String(r) } return "" } return String(logicalLine) }() guard !numString.isEmpty else { return } let num = numString as NSString let size = num.size(withAttributes: attrs) let padding = textView.textContainer?.lineFragmentPadding ?? 0 let gap: CGFloat = 8 // spacing between numbers and text start let x = origin.x + padding - gap - size.width num.draw(at: NSPoint(x: x, y: y), withAttributes: attrs) lastDrawnLogicalLine = logicalLine } } private func isAtLineStart(charIndex: Int) -> Bool { if charIndex == 0 { return true } // Binary search for (charIndex - 1) in newlineOffsets var lo = 0, hi = newlineOffsets.count let target = charIndex - 1 while lo < hi { let mid = (lo + hi) >> 1 let v = newlineOffsets[mid] if v == target { return true } if v < target { lo = mid + 1 } else { hi = mid } } return false } } // MARK: - Helpers for diff parsing private func DiffStyler_lineStarts(with prefix: String, in str: NSString, at range: NSRange) -> Bool { if str.length >= range.location + prefix.count { return str.substring(with: NSRange(location: range.location, length: prefix.count)) == prefix } return false } private func parseRightStart(fromHunkHeader header: String) -> Int? { // Example: @@ -10,7 +12,9 @@ or @@ -10 +12 @@ // Extract the + portion guard let plusRange = header.range(of: "+") else { return nil } var digits = "" var idx = plusRange.upperBound while idx < header.endIndex { let ch = header[idx] if ch.isNumber { digits.append(ch) } else { break } idx = header.index(after: idx) } return Int(digits) } private func parseLeftStart(fromHunkHeader header: String) -> Int? { // Extract the - portion from a hunk header guard let dashRange = header.range(of: "-") else { return nil } var digits = "" var idx = header.index(after: dashRange.lowerBound) while idx < header.endIndex { let ch = header[idx] if ch.isNumber { digits.append(ch) } else { break } idx = header.index(after: idx) } return Int(digits) } ================================================ FILE: views/AutoAssignSheet.swift ================================================ import SwiftUI struct AutoAssignSheet: View { @EnvironmentObject var viewModel: SessionListViewModel @Binding var isPresented: Bool enum Scope: String, CaseIterable, Identifiable { case today = "Today" case all = "All" case custom = "Custom" var id: String { rawValue } var localizedName: String { switch self { case .today: return "Today" case .all: return "All Time" case .custom: return "Custom Range" } } } @State private var scope: Scope = .today @State private var startDate: Date = Date() @State private var endDate: Date = Date() @State private var isProcessing = false @State private var progressMessage: String = "" @State private var progressValue: Double = 0.0 @State private var assignedCount: Int = 0 var body: some View { VStack(alignment: .leading, spacing: 20) { Text("Auto Assign to Projects") .font(.headline) VStack(alignment: .center, spacing: 12) { Picker("Scope", selection: $scope) { ForEach(Scope.allCases) { s in Text(s.localizedName).tag(s) } } .pickerStyle(.segmented) .labelsHidden() .fixedSize() .disabled(isProcessing) if scope == .custom { HStack(spacing: 8) { DatePicker("From", selection: $startDate, displayedComponents: .date) .labelsHidden() Text("-") .foregroundStyle(.secondary) DatePicker("To", selection: $endDate, displayedComponents: .date) .labelsHidden() } .disabled(isProcessing) } Text("Matches sessions to projects based on their working directory.") .font(.caption) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity) if isProcessing { VStack(alignment: .leading, spacing: 8) { ProgressView(value: progressValue, total: 1.0) Text(progressMessage) .font(.caption) .foregroundStyle(.secondary) } } Spacer() HStack { Button("Cancel") { isPresented = false } .keyboardShortcut(.cancelAction) .disabled(isProcessing) Spacer() Button("Start Assignment") { startAssignment() } .keyboardShortcut(.defaultAction) .disabled(isProcessing) } } .padding() .frame(width: 400, height: scope == .custom ? 260 : 200) } private func startAssignment() { isProcessing = true progressValue = 0.0 progressMessage = "Analyzing sessions..." assignedCount = 0 Task { await performAssignment() } } private func performAssignment() async { let vm = self.viewModel // 1. Identify candidates based on scope let candidates = filterCandidates() if candidates.isEmpty { await MainActor.run { progressMessage = "No unassigned sessions found for this scope." progressValue = 1.0 isProcessing = false } // Small delay to let user see the message? Or rely on system notification await SystemNotifier.shared.notify(title: "CodMate", body: "No unassigned sessions found.") isPresented = false return } await MainActor.run { progressMessage = "Found \(candidates.count) unassigned sessions. Matching..." } // 2. Match sessions to projects // We can batch this to show progress var assignments: [String: [String]] = [:] let total = Double(candidates.count) var processed = 0 for session in candidates { if let bestId = vm.bestMatchingProjectId(for: session) { assignments[bestId, default: []].append(session.id) } processed += 1 if processed % 50 == 0 { let current = processed await MainActor.run { progressValue = Double(current) / total } } } guard !assignments.isEmpty else { await MainActor.run { progressMessage = "No matching projects found." progressValue = 1.0 isProcessing = false } await SystemNotifier.shared.notify(title: "CodMate", body: "No matching project paths found.") isPresented = false return } // 3. Apply assignments await MainActor.run { progressMessage = "Assigning sessions..." progressValue = 1.0 // Matching done } var assignedTotal = 0 for (pid, ids) in assignments { assignedTotal += ids.count await vm.assignSessions(to: pid, ids: ids) } await MainActor.run { vm.scheduleApplyFilters() isProcessing = false isPresented = false } await SystemNotifier.shared.notify( title: "CodMate", body: "Auto-assigned \(assignedTotal) session(s)." ) } private func filterCandidates() -> [SessionSummary] { let vm = self.viewModel let allUnassigned = vm.allSessions.filter { vm.projectIdForSession($0.id) == nil } switch scope { case .all: return allUnassigned case .today: let today = Date() let cal = Calendar.current return allUnassigned.filter { session in let createdMatch = cal.isDate(session.startedAt, inSameDayAs: today) let updatedMatch: Bool if let last = session.lastUpdatedAt { updatedMatch = cal.isDate(last, inSameDayAs: today) } else { updatedMatch = false } return createdMatch || updatedMatch } case .custom: let start = Calendar.current.startOfDay(for: startDate) guard let end = Calendar.current.date(byAdding: .day, value: 1, to: Calendar.current.startOfDay(for: endDate)) else { // Calendar operation failed (edge case), fallback to all unassigned return allUnassigned } let range = start..(@ViewBuilder _ content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 8) { content() } .padding(12) .background(Color(nsColor: .separatorColor).opacity(0.35)) .cornerRadius(10) } private var configFilePath: String { let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! let configPath = appSupport.appendingPathComponent("CodMate/config.yaml") return configPath.path } private var authDirPath: String { let home = FileManager.default.homeDirectoryForCurrentUser return home.appendingPathComponent(".codmate/auth").path } private var logsPath: String { let home = FileManager.default.homeDirectoryForCurrentUser return home.appendingPathComponent(".codmate/auth/logs").path } private func revealConfigInFinder() { let url = URL(fileURLWithPath: configFilePath) NSWorkspace.shared.selectFile(url.path, inFileViewerRootedAtPath: url.deletingLastPathComponent().path) } private func revealAuthDirInFinder() { let home = FileManager.default.homeDirectoryForCurrentUser let authPath = home.appendingPathComponent(".codmate/auth") NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: authPath.path) } private func revealLogsInFinder() { let home = FileManager.default.homeDirectoryForCurrentUser let logsPath = home.appendingPathComponent(".codmate/auth/logs") NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: logsPath.path) } private func revealBinaryInFinder() { let url = URL(fileURLWithPath: service.binaryFilePath) NSWorkspace.shared.selectFile(url.path, inFileViewerRootedAtPath: url.deletingLastPathComponent().path) } private var binarySourceDescription: String { switch service.binarySource { case .none: return "No binary detected" case .homebrew: return "Homebrew installation (managed via brew services)" case .codmate: return "CodMate built-in installation" case .other: return "Other installation (potential conflicts)" } } private var binarySourceLabel: String { switch service.binarySource { case .none: return "Not Detected" case .homebrew: return "Homebrew" case .codmate: return "CodMate" case .other: return "Other" } } private var binarySourceColor: Color { switch service.binarySource { case .none: return .secondary case .homebrew: return .green case .codmate: return .blue case .other: return .orange } } private var actionButtonTitle: String { switch service.binarySource { case .none: return "Install" case .homebrew: return service.isBinaryInstalled ? "Upgrade" : "Install" case .codmate: return service.isBinaryInstalled ? "Reinstall" : "Install" case .other: return service.isBinaryInstalled ? "Reinstall" : "Install" } } private var actionButtonColor: Color { switch service.binarySource { case .none: return .blue case .homebrew: return .green case .codmate: return service.isBinaryInstalled ? .red : .blue case .other: return service.isBinaryInstalled ? .red : .blue } } } ================================================ FILE: views/CalendarMonthView.swift ================================================ import SwiftUI struct CalendarMonthView: View { let monthStart: Date let counts: [Int: Int] let selectedDays: Set // When provided, days not in this set will have their count text dimmed // to indicate no sessions for the currently selected project on that day. let enabledDays: Set? let onSelectDay: (Date) -> Void var body: some View { let cal = Calendar.current let weekdaySymbols = cal.shortStandaloneWeekdaySymbols let grid = monthGrid() let spacing: CGFloat = CalendarMonthLayout.columnSpacing let rowCount = grid.count let contentHeight = CalendarMonthLayout.contentHeight(forRowCount: rowCount) GeometryReader { geometry in let totalWidth = geometry.size.width let columnWidth = (totalWidth - spacing * 6) / 7 VStack(spacing: CalendarMonthLayout.sectionSpacing) { weekdayHeader( weekdaySymbols: weekdaySymbols, columnWidth: columnWidth, spacing: spacing) calendarGrid( grid: grid, calendar: cal, columnWidth: columnWidth, spacing: spacing ) } .frame(width: totalWidth, height: contentHeight, alignment: .top) } .frame(height: contentHeight) } private func weekdayHeader(weekdaySymbols: [String], columnWidth: CGFloat, spacing: CGFloat) -> some View { HStack(spacing: spacing) { ForEach(weekdaySymbols, id: \.self) { w in Text(w) .frame(width: columnWidth) .foregroundStyle(.secondary) .font(.caption) } } .frame(height: CalendarMonthLayout.weekdayHeaderHeight) } private func calendarGrid( grid: [[Int]], calendar: Calendar, columnWidth: CGFloat, spacing: CGFloat ) -> some View { VStack(spacing: spacing) { ForEach(0.. some View { HStack(spacing: spacing) { ForEach(Array(days.enumerated()), id: \.offset) { _, day in dayCell(day: day, calendar: calendar, columnWidth: columnWidth) } } } private func dayCell(day: Int, calendar: Calendar, columnWidth: CGFloat) -> some View { let isSelected = isSelectedDay(day: day, calendar: calendar) let today = calendar.startOfDay(for: Date()) let cellDate = calendar.date(bySetting: .day, value: max(day, 1), of: monthStart).map { calendar.startOfDay(for: $0) } let isSelectable = (day > 0) && (cellDate ?? today) <= today return Button { if day > 0, isSelectable, let date = cellDate { onSelectDay(date) } } label: { dayCellContent(day: day, isSelected: isSelected, isDisabled: !isSelectable) } .buttonStyle(.plain) .frame(width: columnWidth, height: 38) .allowsHitTesting(isSelectable && day > 0) .help( day > 0 ? helpText(for: day, isSelected: isSelected, isDisabled: !isSelectable) : "" ) } private func dayCellContent(day: Int, isSelected: Bool, isDisabled: Bool) -> some View { ZStack(alignment: .topLeading) { RoundedRectangle(cornerRadius: 6) .fill(day > 0 ? Color.secondary.opacity(isDisabled ? 0.03 : 0.06) : Color.clear) if day > 0 { dayNumber(day: day, isDisabled: isDisabled) } if day > 0, let count = counts[day], count > 0 { let dimmed: Bool = { guard let enabledDays else { return false } return !enabledDays.contains(day) }() sessionCount(count: count, dimmed: dimmed) } } .overlay( RoundedRectangle(cornerRadius: 6) .strokeBorder(Color.accentColor, lineWidth: isSelected ? 2 : 0) ) .opacity(isDisabled ? 0.45 : 1) } private func dayNumber(day: Int, isDisabled: Bool) -> some View { Text("\(day)") .font(.caption) .foregroundStyle(.secondary.opacity(isDisabled ? 0.2 : 0.5)) .padding(4) } private func sessionCount(count: Int, dimmed: Bool) -> some View { VStack { Spacer() HStack { Spacer() Text("\(count)") .font(.body.bold()) .foregroundStyle(dimmed ? Color.secondary.opacity(0.5) : Color.primary) .padding(4) } } } private func isSelectedDay(day: Int, calendar: Calendar) -> Bool { guard day > 0 else { return false } let cellDate = calendar.startOfDay( for: calendar.date(bySetting: .day, value: day, of: monthStart)!) for d in selectedDays { if calendar.isDate(d, inSameDayAs: cellDate) { return true } } return false } private func helpText(for day: Int, isSelected: Bool, isDisabled: Bool) -> String { if isDisabled { return "Future days cannot be filtered yet" } let count = counts[day] ?? 0 if isSelected { return "\(count) sessions • Click again to clear day filter" } else { return "\(count) sessions • Click to filter by this day" } } private func monthGrid() -> [[Int]] { let cal = Calendar.current let range = cal.range(of: .day, in: .month, for: monthStart) ?? 1..<29 let firstWeekdayIndex = cal.component(.weekday, from: monthStart) - cal.firstWeekday let leading = (firstWeekdayIndex + 7) % 7 var days = Array(repeating: 0, count: leading) + Array(range) while days.count % 7 != 0 { days.append(0) } return stride(from: 0, to: days.count, by: 7).map { Array(days[$0..<$0 + 7]) } } } private enum CalendarMonthLayout { static let dayCellHeight: CGFloat = 38 static let columnSpacing: CGFloat = 2 static let sectionSpacing: CGFloat = 8 static let weekdayHeaderHeight: CGFloat = 18 static func contentHeight(forRowCount rowCount: Int) -> CGFloat { weekdayHeaderHeight + sectionSpacing + CGFloat(rowCount) * dayCellHeight + CGFloat(max(0, rowCount - 1)) * columnSpacing } } #Preview { let calendar = Calendar.current let monthStart = calendar.date(from: DateComponents(year: 2024, month: 12, day: 1))! // Mock data with some days having session counts let mockCounts: [Int: Int] = [ 3: 2, 7: 1, 12: 4, 15: 1, 18: 3, 22: 2, 25: 1, 28: 5, ] return CalendarMonthView( monthStart: monthStart, counts: mockCounts, selectedDays: [calendar.date(from: DateComponents(year: 2024, month: 12, day: 15))!], enabledDays: nil ) { selectedDay in print("Selected day: \(selectedDay)") } .padding() .frame(width: 300) } ================================================ FILE: views/ClaudeCodeSettingsView.swift ================================================ import SwiftUI import AppKit import Combine struct ClaudeCodeSettingsView: View { @ObservedObject var vm: ClaudeCodeVM @ObservedObject var preferences: SessionPreferencesStore @StateObject private var providerCatalog = UnifiedProviderCatalogModel() @State private var providerModels: [String] = [] @State private var modelMappingData: ModelMappingData? @State private var lastProviderId: String? @State private var showDisableBlockedAlert = false private struct ModelMappingData: Identifiable { let id = UUID() let providerId: String let defaultModel: String? let aliases: [String: String] let models: [String] } var body: some View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 6) { Text("Claude Code Settings") .font(.title2) .fontWeight(.bold) Text("Configure provider, model aliases, and review launch environment.") .font(.subheadline) .foregroundStyle(.secondary) } Spacer(minLength: 8) Link(destination: URL(string: "https://docs.claude.com/en/docs/claude-code/settings")!) { Label("Docs", systemImage: "questionmark.circle").labelStyle(.iconOnly) } .buttonStyle(.plain) } GroupBox { HStack(spacing: 12) { VStack(alignment: .leading, spacing: 2) { Label("Enable Claude Code", systemImage: "power") .font(.subheadline).fontWeight(.medium) Text("Turning this off hides Claude UI, stops session scans, and makes settings read-only.") .font(.caption) .foregroundStyle(.secondary) } Spacer() Toggle("", isOn: claudeEnabledBinding) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) } .padding(10) } Group { if #available(macOS 15.0, *) { TabView { Tab("Provider", systemImage: "server.rack") { SettingsTabContent { providerPane } } Tab("Runtime", systemImage: "gearshape.2") { SettingsTabContent { runtimePane } } Tab("Sessions", systemImage: "folder.badge.gearshape") { SettingsTabContent { sessionsPane } } Tab("Raw Config", systemImage: "doc.text") { SettingsTabContent { rawPane } } } } else { TabView { SettingsTabContent { providerPane } .tabItem { Label("Provider", systemImage: "server.rack") } SettingsTabContent { runtimePane } .tabItem { Label("Runtime", systemImage: "gearshape.2") } SettingsTabContent { sessionsPane } .tabItem { Label("Sessions", systemImage: "folder.badge.gearshape") } SettingsTabContent { rawPane } .tabItem { Label("Raw Config", systemImage: "doc.text") } } } } .padding(.bottom, 16) .disabled(!preferences.cliClaudeEnabled) .opacity(preferences.cliClaudeEnabled ? 1.0 : 0.6) } .task { await vm.loadAll() await vm.loadProxyDefaults(preferences: preferences) await reloadProxyCatalog() } // Removed rerouteBuiltIn/reroute3P onChange handlers - all providers now use Auto-Proxy mode .onChange(of: preferences.oauthProvidersEnabled) { _ in Task { await reloadProxyCatalog() } } .onChange(of: preferences.apiKeyProvidersEnabled) { _ in Task { await reloadProxyCatalog() } } .onChange(of: CLIProxyService.shared.isRunning) { _ in Task { await reloadProxyCatalog() } } .sheet(item: $modelMappingData) { data in ClaudeModelMappingSheet( availableModels: data.models, defaultModel: data.defaultModel, aliases: data.aliases, providerId: data.providerId, providerCatalog: providerCatalog, onSave: { newDefault, newAliases in saveModelMappings(providerId: data.providerId, defaultModel: newDefault, aliases: newAliases) }, onAutoFill: { selectedDefault in autoFillMappings(providerId: data.providerId, selectedDefault: selectedDefault) } ) } .alert("At least one CLI must remain enabled.", isPresented: $showDisableBlockedAlert) { Button("OK", role: .cancel) {} } } private var claudeEnabledBinding: Binding { Binding( get: { preferences.cliClaudeEnabled }, set: { newValue in if preferences.setCLIEnabled(.claude, enabled: newValue) == false { showDisableBlockedAlert = true } } ) } // MARK: - Provider private var providerPane: some View { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { GridRow { VStack(alignment: .leading, spacing: 2) { Label("Active Provider", systemImage: "server.rack") .font(.subheadline).fontWeight(.medium) Text("Use built-in provider or route through CLI Proxy API.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } SimpleProviderPicker(providerId: $preferences.claudeProxyProviderId) .frame(maxWidth: .infinity, alignment: .trailing) .onChange(of: preferences.claudeProxyProviderId) { _ in normalizeProxySelection() if preferences.claudeProxyProviderId == nil { Task { await reloadProxyCatalog(forceRefresh: true) } } vm.scheduleApplyProxySelectionDebounced( providerId: preferences.claudeProxyProviderId, modelId: preferences.claudeProxyModelId, preferences: preferences ) } } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Model List", systemImage: "list.bullet") .font(.subheadline).fontWeight(.medium) Text("Pick a default model and map Claude tiers to model IDs.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } SimpleModelPicker( models: providerModels, isDisabled: preferences.claudeProxyProviderId == nil || !providerCatalog.isProviderAvailable(preferences.claudeProxyProviderId), onEditModels: canEditModelMappings ? { presentModelMappingEditor() } : nil, editModelsHelp: "Edit model mappings", providerId: preferences.claudeProxyProviderId, providerCatalog: providerCatalog, modelId: $preferences.claudeProxyModelId ) .frame(maxWidth: .infinity, alignment: .trailing) .onChange(of: preferences.claudeProxyModelId) { _ in vm.scheduleApplyProxySelectionDebounced( providerId: preferences.claudeProxyProviderId, modelId: preferences.claudeProxyModelId, preferences: preferences ) } } } } // MARK: - Models / Aliases // modelsPane removed; Provider pane now includes the default model picker like Codex private var rawPane: some View { let displayText = vm.rawSettingsText return ZStack(alignment: .topTrailing) { ScrollView { Text(displayText.isEmpty ? "(empty settings.json)" : displayText) .font(.system(.caption, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .topLeading) } HStack(spacing: 8) { Button { Task { await vm.reloadRawSettings() } } label: { Image(systemName: "arrow.clockwise") } .help("Reload") .buttonStyle(.borderless) Button { vm.openSettingsInEditor() } label: { Image(systemName: "square.and.pencil") } .help("Open in default editor") .buttonStyle(.borderless) } } .task { await vm.reloadRawSettings() } } // MARK: - Sessions private var sessionsPane: some View { SessionsPathPane(preferences: preferences, fixedKind: .claude) } private func buildRawConfigText() -> String { // Prefer showing the canonical user settings file in full let settingsURL = SessionPreferencesStore.getRealUserHomeURL() .appendingPathComponent(".claude", isDirectory: true) .appendingPathComponent("settings.json") if let fileText = try? String(contentsOf: settingsURL, encoding: .utf8) { return fileText } // Fallback: build preview from current settings var lines = vm.launchEnvPreview() // Append launch/runtime flags preview lines.append("\n# Launch flags preview") lines.append("permission-mode=\(preferences.claudePermissionMode.rawValue)") lines.append("sandbox=\(preferences.defaultResumeSandboxMode.rawValue)") lines.append("approvals=\(preferences.defaultResumeApprovalPolicy.rawValue)") // Debug info if preferences.claudeDebug { lines.append("debug=true filter=\(preferences.claudeDebugFilter)") } else { lines.append("debug=false") } lines.append("verbose=\(preferences.claudeVerbose ? "true" : "false")") lines.append("ide=\(preferences.claudeIDE ? "true" : "false")") lines.append("strictMCP=\(preferences.claudeStrictMCP ? "true" : "false")") // Tools configuration let allowedTools = preferences.claudeAllowedTools.trimmingCharacters(in: .whitespaces) if !allowedTools.isEmpty { lines.append("allowed-tools=\(allowedTools)") } let disallowedTools = preferences.claudeDisallowedTools.trimmingCharacters(in: .whitespaces) if !disallowedTools.isEmpty { lines.append("disallowed-tools=\(disallowedTools)") } let fallbackModel = preferences.claudeFallbackModel.trimmingCharacters(in: .whitespaces) if !fallbackModel.isEmpty { lines.append("fallback-model=\(fallbackModel)") } // Build example command let exampleCommand = buildExampleCommand() lines.append("\n# Example command") lines.append(exampleCommand) return lines.joined(separator: "\n") } private func buildExampleCommand() -> String { var example: [String] = ["claude"] // Permission mode if preferences.claudePermissionMode.rawValue != "default" { example.append("--permission-mode \(preferences.claudePermissionMode.rawValue)") } // Debug/Verbose if preferences.claudeDebug { let debugFilter = preferences.claudeDebugFilter.trimmingCharacters(in: .whitespaces) if !debugFilter.isEmpty { example.append("--debug \(debugFilter)") } else { example.append("--debug") } } if preferences.claudeVerbose { example.append("--verbose") } // Tools let allowedTools = preferences.claudeAllowedTools.trimmingCharacters(in: .whitespaces) if !allowedTools.isEmpty { example.append("--allowed-tools \"\(allowedTools)\"") } let disallowedTools = preferences.claudeDisallowedTools.trimmingCharacters(in: .whitespaces) if !disallowedTools.isEmpty { example.append("--disallowed-tools \"\(disallowedTools)\"") } // IDE if preferences.claudeIDE { example.append("--ide") } // Fallback model let fallbackModel = preferences.claudeFallbackModel.trimmingCharacters(in: .whitespaces) if !fallbackModel.isEmpty { example.append("--fallback-model \(fallbackModel)") } return example.joined(separator: " ") } // MARK: - Runtime (Claude-native) private var runtimePane: some View { runtimePaneGrid .onReceive(preferences.objectWillChange) { _ in Task { vm.scheduleApplyRuntimeSettings(preferences) } } } private var runtimePaneGrid: some View { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { // Claude-native permission mode GridRow { VStack(alignment: .leading, spacing: 2) { Label("Permission Mode", systemImage: "hand.raised") .font(.subheadline).fontWeight(.medium) Text("Affects edit confirmations and planning.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Picker("", selection: $preferences.claudePermissionMode) { ForEach(ClaudePermissionMode.allCases) { Text($0.rawValue).tag($0) } } .labelsHidden() .frame(maxWidth: .infinity, alignment: .trailing) } gridDivider // Dangerous permission skips (explicit) GridRow { VStack(alignment: .leading, spacing: 2) { Label("Skip Permissions (Dangerous)", systemImage: "exclamationmark.triangle") .font(.subheadline).fontWeight(.medium) Text("Bypass permission prompts; use with caution.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("Enable", isOn: $preferences.claudeSkipPermissions) .frame(maxWidth: .infinity, alignment: .trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Allow Skip Permissions", systemImage: "checkmark.shield") .font(.subheadline).fontWeight(.medium) Text("Permit using the dangerous skip flag.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("Enable", isOn: $preferences.claudeAllowSkipPermissions) .frame(maxWidth: .infinity, alignment: .trailing) } // Removed: Unsandboxed commands toggle (no official CLI/setting key) gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Debug", systemImage: "ladybug") .font(.subheadline).fontWeight(.medium) Text("Enable debug output; optional category filter.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } HStack(spacing: 8) { Toggle("Enable", isOn: $preferences.claudeDebug) TextField("api,hooks", text: $preferences.claudeDebugFilter) .frame(width: 220) } .frame(maxWidth: .infinity, alignment: .trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Verbose Output", systemImage: "text.alignleft") .font(.subheadline).fontWeight(.medium) Text("Override verbose mode from config.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("Enable", isOn: $preferences.claudeVerbose) .frame(maxWidth: .infinity, alignment: .trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Allowed Tools", systemImage: "checkmark.circle") .font(.subheadline).fontWeight(.medium) Text("Comma or space-separated tool names.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } TextField("Bash(git:*), Edit", text: $preferences.claudeAllowedTools) .frame(maxWidth: .infinity, alignment: .trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Disallowed Tools", systemImage: "xmark.circle") .font(.subheadline).fontWeight(.medium) Text("Comma or space-separated tool names to block.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } TextField("Bash(rm:*), Edit", text: $preferences.claudeDisallowedTools) .frame(maxWidth: .infinity, alignment: .trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Other", systemImage: "ellipsis.circle") .font(.subheadline).fontWeight(.medium) Text("Additional runtime options.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } HStack(spacing: 16) { Toggle("IDE auto-connect", isOn: $preferences.claudeIDE) Toggle("Strict MCP config", isOn: $preferences.claudeStrictMCP) } .frame(maxWidth: .infinity, alignment: .trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Fallback Model", systemImage: "arrow.down.circle") .font(.subheadline).fontWeight(.medium) Text("Optional model when default is overloaded (print mode).") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } TextField("haiku", text: $preferences.claudeFallbackModel) .frame(maxWidth: .infinity, alignment: .trailing) } } } private func reloadProxyCatalog(forceRefresh: Bool = false) async { await providerCatalog.reload(preferences: preferences, forceRefresh: forceRefresh) normalizeProxySelection() } private func normalizeProxySelection() { let normalized = providerCatalog.normalizeProviderId(preferences.claudeProxyProviderId) if normalized != preferences.claudeProxyProviderId { preferences.claudeProxyProviderId = normalized } let providerChanged = lastProviderId != nil && lastProviderId != preferences.claudeProxyProviderId lastProviderId = preferences.claudeProxyProviderId guard let providerId = preferences.claudeProxyProviderId else { providerModels = [] preferences.claudeProxyModelId = nil return } providerModels = providerCatalog.models(for: providerId) if providerChanged { preferences.claudeProxyModelId = nil return } guard !providerModels.isEmpty else { return } } private var canEditModelMappings: Bool { preferences.claudeProxyProviderId != nil } private func presentModelMappingEditor() { guard let providerId = preferences.claudeProxyProviderId else { return } // Use sheet(item:) pattern to ensure fresh data is passed each time modelMappingData = ModelMappingData( providerId: providerId, defaultModel: preferences.claudeProxyModelId, aliases: loadModelAliases(for: providerId), models: providerCatalog.models(for: providerId) ) } private func loadModelAliases(for providerId: String) -> [String: String] { // First try the exact providerId if let aliases = preferences.claudeProxyModelAliases[providerId], !aliases.isEmpty { return aliases } // For OAuth providers, also try the base providerId (without accountId) let parsed = UnifiedProviderID.parse(providerId) if case .oauth(let provider, _) = parsed { let baseId = UnifiedProviderID.oauth(provider, accountId: nil) if let aliases = preferences.claudeProxyModelAliases[baseId], !aliases.isEmpty { return aliases } } return [:] } private func saveModelMappings(providerId: String, defaultModel: String?, aliases: [String: String]) { preferences.claudeProxyModelId = defaultModel var stored = preferences.claudeProxyModelAliases if aliases.isEmpty { stored.removeValue(forKey: providerId) } else { stored[providerId] = aliases } preferences.claudeProxyModelAliases = stored vm.scheduleApplyProxySelectionDebounced( providerId: preferences.claudeProxyProviderId, modelId: preferences.claudeProxyModelId, preferences: preferences ) } private func autoFillMappings(providerId: String, selectedDefault: String?) -> [String: String] { // Get fresh models from catalog based on the provider used when opening the editor let models = providerCatalog.models(for: providerId) let trimmedDefault = selectedDefault?.trimmingCharacters(in: .whitespacesAndNewlines) let preferred = (trimmedDefault?.isEmpty == false) ? trimmedDefault : selectDefaultModel(from: models) let opus = selectModel(from: models, tokens: ["opus"]) ?? preferred let sonnet = selectModel(from: models, tokens: ["sonnet"]) ?? preferred let haiku = selectModel(from: models, tokens: ["haiku", "flash", "lite", "mini"]) ?? preferred var out: [String: String] = [:] if let preferred { out["default"] = preferred } if let opus { out["opus"] = opus } if let sonnet { out["sonnet"] = sonnet } if let haiku { out["haiku"] = haiku } return out } private func selectDefaultModel(from models: [String]) -> String? { if let match = selectModel(from: models, tokens: ["sonnet", "opus", "haiku"]) { return match } if let match = selectModel(from: models, tokens: ["pro", "latest", "preview"]) { return match } return models.first } private func selectModel(from models: [String], tokens: [String]) -> String? { guard !models.isEmpty else { return nil } // Find all models matching any token, then select the one with highest version var candidates: [String] = [] for token in tokens { let matching = models.filter { $0.localizedCaseInsensitiveContains(token) } candidates.append(contentsOf: matching) } guard !candidates.isEmpty else { return nil } // Use ModelNameSanitizer's version comparison logic to find the highest version // For each token category (opus, sonnet, haiku), select the model with the latest version var bestModel: String? = nil var bestVersion: ModelNameSanitizer.ModelVersion? = nil for model in candidates { let (baseName, version) = ModelNameSanitizer.extractModelVersion(model) // Check if this model's base name matches any token let matchesToken = tokens.contains { token in baseName.localizedCaseInsensitiveContains(token) } if matchesToken { if let existing = bestVersion { if version.isNewerThan(existing) { bestVersion = version bestModel = model } } else { bestVersion = version bestModel = model } } } // If no version-based match found, fall back to first match (for models without date suffixes) return bestModel ?? candidates.first } // aliasPicker removed } private struct SettingsCard: View { let content: () -> Content var body: some View { VStack(alignment: .leading, spacing: 8) { content() } .padding(10) .background(Color(nsColor: .separatorColor).opacity(0.35)) .cornerRadius(10) } } private func settingsCard(@ViewBuilder _ content: @escaping () -> Content) -> some View { SettingsCard(content: content) } private var gridDivider: some View { Divider().opacity(0.5) } // MARK: - Runtime Settings Change Handler // Removed complex onChange modifier due to type-checker performance; using a single // onReceive(preferences.objectWillChange) above to debounce runtime writes. extension ClaudeCodeVM { var selectedClaudeBaseURL: String? { guard let id = activeProviderId, let p = providers.first(where: { $0.id == id }) else { return nil } return p.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue]?.baseURL } var selectedClaudeEnvKey: String? { guard let id = activeProviderId, let p = providers.first(where: { $0.id == id }) else { return nil } return p.envKey ?? p.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue]?.envKey ?? "ANTHROPIC_AUTH_TOKEN" } func launchEnvPreview() -> [String] { var lines: [String] = [ "# Environment variables applied when launching Claude", ] if let base = selectedClaudeBaseURL, !base.isEmpty { lines.append("export ANTHROPIC_BASE_URL=\(base)") } else { lines.append("# ANTHROPIC_BASE_URL not set (uses tool default)") } if !(activeProviderId == nil && loginMethod == .subscription) { let key = selectedClaudeEnvKey ?? "ANTHROPIC_AUTH_TOKEN" lines.append("export ANTHROPIC_AUTH_TOKEN=$\(key)") } else { lines.append("# Using Claude subscription login; no token env injected") } // Aliases (only when a third‑party provider is selected) if activeProviderId != nil { if !aliasOpus.trimmingCharacters(in: .whitespaces).isEmpty { lines.append("export ANTHROPIC_DEFAULT_OPUS_MODEL=\(aliasOpus)") } if !aliasSonnet.trimmingCharacters(in: .whitespaces).isEmpty { lines.append("export ANTHROPIC_DEFAULT_SONNET_MODEL=\(aliasSonnet)") } if !aliasHaiku.trimmingCharacters(in: .whitespaces).isEmpty { lines.append("export ANTHROPIC_DEFAULT_HAIKU_MODEL=\(aliasHaiku)") } if !aliasDefault.trimmingCharacters(in: .whitespaces).isEmpty { lines.append("export ANTHROPIC_MODEL=\(aliasDefault)") } if !aliasHaiku.trimmingCharacters(in: .whitespaces).isEmpty { lines.append("export ANTHROPIC_SMALL_FAST_MODEL=\(aliasHaiku)") } } return lines } } ================================================ FILE: views/ClaudeModelMappingSheet.swift ================================================ import SwiftUI #if os(macOS) import AppKit #endif struct ClaudeModelMappingSheet: View { let availableModels: [String] let defaultModel: String? let aliases: [String: String] let providerId: String? let providerCatalog: UnifiedProviderCatalogModel? let onSave: (_ defaultModel: String?, _ aliases: [String: String]) -> Void let onAutoFill: (_ selectedDefault: String?) -> [String: String] @State private var draftDefault: String = "" @State private var draftAliases: [String: String] = [:] @Environment(\.dismiss) private var dismiss init( availableModels: [String], defaultModel: String?, aliases: [String: String], providerId: String? = nil, providerCatalog: UnifiedProviderCatalogModel? = nil, onSave: @escaping (_ defaultModel: String?, _ aliases: [String: String]) -> Void, onAutoFill: @escaping (_ selectedDefault: String?) -> [String: String] ) { self.availableModels = availableModels self.defaultModel = defaultModel self.aliases = aliases self.providerId = providerId self.providerCatalog = providerCatalog self.onSave = onSave self.onAutoFill = onAutoFill } var body: some View { VStack(alignment: .leading, spacing: 16) { Text("Model Mappings").font(.title2).fontWeight(.semibold) Text("Map Claude Code tiers to CLI Proxy model IDs. Defaults apply to Claude Code 2.x; the default model also feeds legacy variables.") .font(.subheadline) .foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 12) { mappingRow( title: "Default", help: "Used for ANTHROPIC_MODEL and as the fallback for missing tiers.", binding: $draftDefault ) mappingRow( title: "Opus", help: "ANTHROPIC_DEFAULT_OPUS_MODEL", binding: aliasBinding("opus") ) mappingRow( title: "Sonnet", help: "ANTHROPIC_DEFAULT_SONNET_MODEL", binding: aliasBinding("sonnet") ) mappingRow( title: "Haiku", help: "ANTHROPIC_DEFAULT_HAIKU_MODEL + ANTHROPIC_SMALL_FAST_MODEL", binding: aliasBinding("haiku") ) } .padding(10) .background(Color(nsColor: .separatorColor).opacity(0.35)) .cornerRadius(10) HStack(spacing: 8) { Button("Auto Fill") { let auto = onAutoFill(normalized(draftDefault)) for (key, value) in auto { draftAliases[key] = value } if let autoDefault = auto["default"], draftDefault.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { draftDefault = autoDefault } } Spacer() Button("Cancel", role: .cancel) { dismiss() } Button("Save") { let cleanedDefault = normalized(draftDefault) let cleanedAliases = sanitizeAliases(draftAliases) onSave(cleanedDefault, cleanedAliases) dismiss() } .buttonStyle(.borderedProminent) } } .padding(16) .frame(minWidth: 560) .onAppear { // Reload data when sheet appears to ensure we have the latest values // This is critical for SwiftUI sheets which capture initial values at creation time // and may not reflect updates that happen after the sheet closure is created draftDefault = defaultModel ?? "" draftAliases = aliases } } @State private var searchText: [String: String] = [:] @State private var isPopoverPresented: [String: Bool] = [:] private func mappingRow(title: String, help: String, binding: Binding) -> some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { Text(title) .frame(width: 90, alignment: .leading) TextField("model-id", text: binding) if !availableModels.isEmpty { searchableModelButton(binding: binding, title: title) } Button { binding.wrappedValue = "" } label: { Image(systemName: "xmark.circle") } .buttonStyle(.borderless) .help("Clear") } Text(help) .font(.caption) .foregroundStyle(.secondary) .padding(.leading, 98) } } private func searchableModelButton(binding: Binding, title: String) -> some View { let searchKey = title let isPresented = Binding( get: { isPopoverPresented[searchKey] ?? false }, set: { isPopoverPresented[searchKey] = $0 } ) let searchBinding = Binding( get: { searchText[searchKey] ?? "" }, set: { searchText[searchKey] = $0 } ) return Button { isPresented.wrappedValue = true } label: { Image(systemName: "chevron.down") } .help("Pick from available models") .popover(isPresented: isPresented, arrowEdge: .bottom) { searchableModelListPopover( binding: binding, searchKey: searchKey, searchBinding: searchBinding, isPresented: isPresented ) } } private func searchableModelListPopover( binding: Binding, searchKey: String, searchBinding: Binding, isPresented: Binding ) -> some View { let filteredModels = filteredModels(for: searchKey) return VStack(alignment: .leading, spacing: 8) { // Search field TextField("Search models", text: searchBinding) .textFieldStyle(.roundedBorder) .padding(.top, 16) Divider() // Model list ScrollView { LazyVStack(alignment: .leading, spacing: 0) { if filteredModels.isEmpty { Text("No models found") .foregroundStyle(.secondary) .padding(.vertical, 8) } else { ForEach(Array(filteredModels.enumerated()), id: \.element) { index, model in Button { binding.wrappedValue = model searchText[searchKey] = "" // Clear search after selection isPresented.wrappedValue = false } label: { HStack { modelLabelWithProvider(model: model) Spacer() if binding.wrappedValue == model { Image(systemName: "checkmark") } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 8) .padding(.horizontal, 8) .background( Group { if binding.wrappedValue == model { Color.accentColor.opacity(0.1) } else if index % 2 == 1 { Color(nsColor: .separatorColor).opacity(0.08) } else { Color.clear } } ) .contentShape(Rectangle()) } .buttonStyle(ClaudeModelRowButtonStyle()) .onHover { hovering in #if os(macOS) if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() } #endif } } } } } .frame(width: 400, height: 300) } .padding(.bottom, 16) .padding(.horizontal, 16) } @ViewBuilder private func modelLabelWithProvider(model: String) -> some View { HStack(spacing: 6) { if let providerId = providerId, let catalog = providerCatalog { modelLabelProviderInfo(model: model, providerId: providerId, catalog: catalog) } Text(ModelNameSanitizer.sanitizeSingle(model)) } } @ViewBuilder private func modelLabelProviderInfo(model: String, providerId: String, catalog: UnifiedProviderCatalogModel) -> some View { // When providerId is autoProxy, infer provider from model ID if providerId == UnifiedProviderID.autoProxyId { // Infer provider from model ID if let title = catalog.inferProviderFromModel(model) { if let icon = providerIcon(for: nil, title: title, modelId: model) { icon .resizable() .interpolation(.high) .aspectRatio(contentMode: .fit) .frame(width: 14, height: 14) } else { Text(title) .font(.caption2) .foregroundStyle(.secondary) .padding(.horizontal, 4) .padding(.vertical, 1) .background(Color(nsColor: .separatorColor).opacity(0.5)) .cornerRadius(3) } } } else { // Use provider title from catalog if let title = catalog.providerTitle(for: providerId) { if let icon = providerIcon(for: providerId, title: title, modelId: model) { icon .resizable() .interpolation(.high) .aspectRatio(contentMode: .fit) .frame(width: 14, height: 14) } else { Text(title) .font(.caption2) .foregroundStyle(.secondary) .padding(.horizontal, 4) .padding(.vertical, 1) .background(Color(nsColor: .separatorColor).opacity(0.5)) .cornerRadius(3) } } } } private func providerIcon(for providerId: String?, title: String, modelId: String? = nil) -> Image? { // If providerId is nil (autoProxy mode), infer icon from title (service provider name) if providerId == nil || providerId == UnifiedProviderID.autoProxyId { // Priority 1: Try OAuth provider icon by title if let authProvider = LocalAuthProvider.allCases.first(where: { $0.displayName == title }) { let iconName = iconNameForOAuthProvider(authProvider) if let nsImage = ProviderIconThemeHelper.menuImage(named: iconName, size: NSSize(width: 14, height: 14)) { return Image(nsImage: nsImage) } } // Priority 2: Try API key provider icon by title (check customIcon first) // Try to find provider by title to check for customIcon if let provider = findProviderByTitle(title), let customIcon = provider.customIcon { return Image(systemName: customIcon) } // Priority 3: Try preset PNG icon if let iconName = ProviderIconResource.iconName(for: title), let nsImage = ProviderIconResource.processedImage( named: iconName, size: NSSize(width: 14, height: 14), isDarkMode: NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ) { return Image(nsImage: nsImage) } // No fallback - if title doesn't match any known provider, return nil (shows default circle) return nil } let parsed = UnifiedProviderID.parse(providerId ?? "") switch parsed { case .oauth(let authProvider, _): let iconName = iconNameForOAuthProvider(authProvider) if let nsImage = ProviderIconThemeHelper.menuImage(named: iconName, size: NSSize(width: 14, height: 14)) { return Image(nsImage: nsImage) } return nil case .api(let apiId): // Priority 1: Check for custom SF Symbol icon if let provider = findProviderById(apiId), let customIcon = provider.customIcon { return Image(systemName: customIcon) } // Priority 2: Try preset PNG icon if let iconName = ProviderIconResource.iconName(for: apiId) ?? ProviderIconResource.iconName(for: title), let nsImage = ProviderIconResource.processedImage( named: iconName, size: NSSize(width: 14, height: 14), isDarkMode: NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ) { return Image(nsImage: nsImage) } return nil default: return nil } } // Helper to find provider by ID from registry private func findProviderById(_ id: String) -> ProvidersRegistryService.Provider? { let registry = ProvidersRegistryService() // Use synchronous load() instead of async listProviders() to avoid actor isolation warnings let loadedRegistry = registry.load() return loadedRegistry.providers.first(where: { $0.id == id }) } // Helper to find provider by title/name from registry private func findProviderByTitle(_ title: String) -> ProvidersRegistryService.Provider? { let registry = ProvidersRegistryService() // Use synchronous load() instead of async listProviders() to avoid actor isolation warnings let loadedRegistry = registry.load() return loadedRegistry.providers.first(where: { provider in let displayName = UnifiedProviderID.providerDisplayName(provider) return displayName == title || provider.name == title || provider.id == title }) } private func iconNameForOAuthProvider(_ provider: LocalAuthProvider) -> String { switch provider { case .codex: return "ChatGPTIcon" case .claude: return "ClaudeIcon" case .gemini: return "GeminiIcon" case .antigravity: return "AntigravityIcon" case .qwen: return "QwenIcon" } } private func filteredModels(for searchKey: String) -> [String] { let query = (searchText[searchKey] ?? "").lowercased() if query.isEmpty { return availableModels } return availableModels.filter { model in let display = ModelNameSanitizer.sanitizeSingle(model).lowercased() return display.contains(query) || model.lowercased().contains(query) } } private func aliasBinding(_ key: String) -> Binding { Binding( get: { draftAliases[key] ?? "" }, set: { draftAliases[key] = $0 } ) } private func normalized(_ value: String) -> String? { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } private func sanitizeAliases(_ aliases: [String: String]) -> [String: String] { var out: [String: String] = [:] for (key, value) in aliases { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { out[key] = trimmed } } return out } } // MARK: - Model Row Button Style private struct ClaudeModelRowButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .background( configuration.isPressed || configuration.role == .destructive ? Color(nsColor: .controlAccentColor).opacity(0.2) : Color.clear ) .contentShape(Rectangle()) } } ================================================ FILE: views/CodexSettingsView.swift ================================================ import SwiftUI struct CodexSettingsView: View { @ObservedObject var codexVM: CodexVM @ObservedObject var preferences: SessionPreferencesStore @FocusState private var isEnvSetPairsFocused: Bool @State private var envSetPairsLastValue = "" @StateObject private var providerCatalog = UnifiedProviderCatalogModel() @State private var providerModels: [String] = [] @State private var lastProviderId: String? @State private var showDisableBlockedAlert = false var body: some View { VStack(alignment: .leading, spacing: 12) { // Header for visual consistency with other settings pages HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 6) { Text("Codex Settings") .font(.title2) .fontWeight(.bold) Text( "Configure Codex CLI: providers, runtime defaults, features, and privacy." ) .font(.subheadline) .foregroundColor(.secondary) } Spacer(minLength: 8) Link( destination: URL(string: "https://developers.openai.com/codex/cli")! ) { Label("Docs", systemImage: "questionmark.circle") .labelStyle(.iconOnly) } .buttonStyle(.plain) } GroupBox { HStack(spacing: 12) { VStack(alignment: .leading, spacing: 2) { Label("Enable Codex CLI", systemImage: "power") .font(.subheadline).fontWeight(.medium) Text("Turning this off hides Codex UI, stops session scans, and makes settings read-only.") .font(.caption) .foregroundStyle(.secondary) } Spacer() Toggle("", isOn: codexEnabledBinding) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) } .padding(10) } // Tabs (Remote Hosts is a top-level page, not a Codex sub-tab) Group { if #available(macOS 15.0, *) { TabView { Tab("Provider", systemImage: "server.rack") { providerPane } Tab("Runtime", systemImage: "gearshape.2") { runtimePane } Tab("Sessions", systemImage: "folder.badge.gearshape") { sessionsPane } Tab("Features", systemImage: "wand.and.stars") { featuresPane } Tab("Privacy", systemImage: "lock.shield") { privacyPane } Tab("Raw Config", systemImage: "doc.text") { rawConfigPane } } } else { TabView { providerPane .tabItem { Label("Provider", systemImage: "server.rack") } runtimePane .tabItem { Label("Runtime", systemImage: "gearshape.2") } sessionsPane .tabItem { Label("Sessions", systemImage: "folder.badge.gearshape") } featuresPane .tabItem { Label("Features", systemImage: "wand.and.stars") } privacyPane .tabItem { Label("Privacy", systemImage: "lock.shield") } rawConfigPane .tabItem { Label("Raw Config", systemImage: "doc.text") } } } } .controlSize(.regular) .padding(.bottom, 16) .disabled(!preferences.cliCodexEnabled) .opacity(preferences.cliCodexEnabled ? 1.0 : 0.6) } .alert("At least one CLI must remain enabled.", isPresented: $showDisableBlockedAlert) { Button("OK", role: .cancel) {} } } private var codexEnabledBinding: Binding { Binding( get: { preferences.cliCodexEnabled }, set: { newValue in if preferences.setCLIEnabled(.codex, enabled: newValue) == false { showDisableBlockedAlert = true } } ) } // MARK: - Provider Pane private var providerPane: some View { let content = providerPaneContent return SettingsTabContent { content } .task { await codexVM.loadProxyDefaults(preferences: preferences) await reloadProxyCatalog() } // Removed rerouteBuiltIn/reroute3P onChange handlers - all providers now use Auto-Proxy mode .onChange(of: preferences.oauthProvidersEnabled) { _ in Task { await reloadProxyCatalog() } } .onChange(of: preferences.apiKeyProvidersEnabled) { _ in Task { await reloadProxyCatalog() } } .onChange(of: CLIProxyService.shared.isRunning) { _ in Task { await reloadProxyCatalog() } } } private var providerPaneContent: some View { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { GridRow { VStack(alignment: .leading, spacing: 2) { Label("Active Provider", systemImage: "server.rack") .font(.subheadline).fontWeight(.medium) Text("Use built-in provider or route through CLI Proxy API.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } SimpleProviderPicker(providerId: $preferences.codexProxyProviderId) .frame(maxWidth: .infinity, alignment: .trailing) .onChange(of: preferences.codexProxyProviderId) { _ in normalizeProxySelection() if preferences.codexProxyProviderId == nil { Task { await reloadProxyCatalog(forceRefresh: true) } } codexVM.scheduleApplyProxySelectionDebounced( providerId: preferences.codexProxyProviderId, modelId: preferences.codexProxyModelId, preferences: preferences ) } } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Model List", systemImage: "list.bullet") .font(.subheadline).fontWeight(.medium) Text("Select a default model from the available models.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } SimpleModelPicker( models: providerModels, isDisabled: preferences.codexProxyProviderId == nil || !providerCatalog.isProviderAvailable(preferences.codexProxyProviderId), providerId: preferences.codexProxyProviderId, providerCatalog: providerCatalog, modelId: $preferences.codexProxyModelId ) .frame(maxWidth: .infinity, alignment: .trailing) .onChange(of: preferences.codexProxyModelId) { _ in codexVM.scheduleApplyProxySelectionDebounced( providerId: preferences.codexProxyProviderId, modelId: preferences.codexProxyModelId, preferences: preferences ) } } // Base URL and API Key Env rows are hidden to reduce redundancy } } // MARK: - Runtime Pane private var runtimePane: some View { SettingsTabContent { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { GridRow { VStack(alignment: .leading, spacing: 2) { Label("Reasoning Effort", systemImage: "brain") .font(.subheadline).fontWeight(.medium) Text("Controls depth of reasoning for supported models.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Picker("", selection: $codexVM.reasoningEffort) { ForEach(CodexVM.ReasoningEffort.allCases) { Text($0.rawValue).tag($0) } } .labelsHidden() .onChange(of: codexVM.reasoningEffort) { _ in codexVM.scheduleApplyReasoningDebounced() } .frame(maxWidth: .infinity, alignment: .trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Reasoning Summary", systemImage: "text.bubble") .font(.subheadline).fontWeight(.medium) Text("Summary verbosity for reasoning-capable models.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Picker("", selection: $codexVM.reasoningSummary) { ForEach(CodexVM.ReasoningSummary.allCases) { Text($0.rawValue).tag($0) } } .labelsHidden() .onChange(of: codexVM.reasoningSummary) { _ in codexVM.scheduleApplyReasoningDebounced() } .frame(maxWidth: .infinity, alignment: .trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Verbosity", systemImage: "text.alignleft") .font(.subheadline).fontWeight(.medium) Text("Text output verbosity for GPT‑5 family (Responses API).") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Picker("", selection: $codexVM.modelVerbosity) { ForEach(CodexVM.ModelVerbosity.allCases) { Text($0.rawValue).tag($0) } } .labelsHidden() .onChange(of: codexVM.modelVerbosity) { _ in codexVM.scheduleApplyReasoningDebounced() } .frame(maxWidth: .infinity, alignment: .trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Sandbox", systemImage: "lock.shield") .font(.subheadline).fontWeight(.medium) Text("Default sandbox for sessions launched from CodMate only.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Picker("", selection: $codexVM.sandboxMode) { ForEach(SandboxMode.allCases) { Text($0.title).tag($0) } } .labelsHidden() .onChange(of: codexVM.sandboxMode) { newValue in codexVM.scheduleApplySandboxDebounced() preferences.defaultResumeSandboxMode = newValue } .frame(maxWidth: .infinity, alignment: .trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Approval Policy", systemImage: "hand.raised") .font(.subheadline).fontWeight(.medium) Text("Default approval prompts for sessions launched from CodMate only.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Picker("", selection: $codexVM.approvalPolicy) { ForEach(ApprovalPolicy.allCases) { Text($0.title).tag($0) } } .labelsHidden() .onChange(of: codexVM.approvalPolicy) { newValue in codexVM.scheduleApplyApprovalDebounced() preferences.defaultResumeApprovalPolicy = newValue } .frame(maxWidth: .infinity, alignment: .trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Auto-assign new sessions to same project", systemImage: "folder.badge.plus") .font(.subheadline) .fontWeight(.medium) Text( "When starting New from detail, auto-assign the created session to that project." ) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("", isOn: $preferences.autoAssignNewToSameProject) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) .frame(maxWidth: .infinity, alignment: .trailing) } } } } // MARK: - Sessions Pane private var sessionsPane: some View { SettingsTabContent { SessionsPathPane(preferences: preferences, fixedKind: .codex) } } // MARK: - Features Pane private var featuresPane: some View { SettingsTabContent { VStack(alignment: .leading, spacing: 16) { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { GridRow { VStack(alignment: .leading, spacing: 2) { Label("Suppress unstable features warning", systemImage: "exclamationmark.triangle") .font(.subheadline).fontWeight(.medium) Text("Hide the Codex CLI unstable-features banner by writing to config.toml.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("", isOn: $codexVM.suppressUnstableFeaturesWarning) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) .onChange(of: codexVM.suppressUnstableFeaturesWarning) { _ in codexVM.scheduleApplySuppressUnstableWarningDebounced() } .frame(maxWidth: .infinity, alignment: .trailing) } } Divider() HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 2) { Label("Feature Flags", systemImage: "wand.and.stars") .font(.subheadline).fontWeight(.medium) Text("Inspect codex CLI features and override individual flags.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 8) HStack(spacing: 8) { Button { Task { await codexVM.loadFeatures() } } label: { Label("Refresh", systemImage: "arrow.clockwise") } .controlSize(.small) .disabled(codexVM.featuresLoading) if codexVM.featuresLoading { ProgressView().controlSize(.small) } } } if let err = codexVM.featureError { Text(err).font(.caption).foregroundStyle(.red) } if codexVM.featureFlags.isEmpty { Text(codexVM.featuresLoading ? "Loading features…" : "No features reported by codex CLI.") .font(.caption) .foregroundStyle(.secondary) } else { let stageWidth: CGFloat = 120 let overrideWidth: CGFloat = 180 let flags = codexVM.featureFlags VStack(spacing: 10) { HStack(alignment: .firstTextBaseline) { Text("Feature") .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) Text("Stage") .font(.caption) .foregroundStyle(.secondary) .frame(width: stageWidth, alignment: .leading) Text("Override") .font(.caption) .foregroundStyle(.secondary) .frame(width: overrideWidth, alignment: .trailing) } Divider() ForEach(Array(flags.enumerated()), id: \.element.id) { index, feature in HStack(alignment: .center, spacing: 12) { Text(feature.name) .font(.subheadline) .fontWeight(.medium) .frame(minWidth: 120, maxWidth: .infinity, alignment: .leading) .lineLimit(1) .truncationMode(.tail) Text(feature.stage.capitalized) .font(.subheadline) .frame(width: stageWidth, alignment: .leading) Toggle("", isOn: overrideToggleBinding(for: feature)) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) .frame(width: overrideWidth, alignment: .trailing) .disabled(codexVM.featuresLoading) } if index < flags.count - 1 { Divider() } } } } } } } // MARK: - Privacy Pane private var privacyPane: some View { SettingsTabContent { VStack(alignment: .leading, spacing: 16) { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { GridRow { VStack(alignment: .leading, spacing: 2) { Label("Inherit", systemImage: "arrow.down.circle") .font(.subheadline).fontWeight(.medium) Text("Start from full, core, or empty environment.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Picker("", selection: $codexVM.envInherit) { ForEach(["all", "core", "none"], id: \.self) { Text($0).tag($0) } } .labelsHidden() .frame(maxWidth: .infinity, alignment: .trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Ignore default excludes", systemImage: "eye.slash") .font(.subheadline).fontWeight(.medium) Text("Keep vars containing KEY/SECRET/TOKEN unless unchecked.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("", isOn: $codexVM.envIgnoreDefaults) .labelsHidden() .frame(maxWidth: .infinity, alignment: .trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Include Only", systemImage: "checklist") .font(.subheadline).fontWeight(.medium) Text("Whitelist patterns (comma separated). Example: PATH, HOME") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } TextField("PATH, HOME", text: $codexVM.envIncludeOnly) .frame(maxWidth: .infinity, alignment: .trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Exclude", systemImage: "xmark.circle") .font(.subheadline).fontWeight(.medium) Text("Blacklist patterns (comma separated). Example: AWS_*, AZURE_*") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } TextField("AWS_*, AZURE_*", text: $codexVM.envExclude) .frame(maxWidth: .infinity, alignment: .trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Set Variables", systemImage: "key") .font(.subheadline).fontWeight(.medium) Text("KEY=VALUE per line. These override inherited values.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } ZStack(alignment: .bottomTrailing) { TextEditor(text: $codexVM.envSetPairs) .font(.system(.body, design: .monospaced)) .frame(minHeight: 90) .focused($isEnvSetPairsFocused) if isEnvSetPairsFocused { HStack(spacing: 8) { if codexVM.lastError != nil { Text(codexVM.lastError!) .foregroundStyle(.red) .font(.caption) } Button("Save Environment Policy") { envSetPairsLastValue = codexVM.envSetPairs isEnvSetPairsFocused = false Task { await codexVM.applyEnvPolicy() } } .buttonStyle(.plain) .foregroundStyle(.primary) } .padding(.horizontal, 8) .padding(.vertical, 6) } } .frame(maxWidth: .infinity, alignment: .trailing) .onAppear { envSetPairsLastValue = codexVM.envSetPairs } } } Divider() Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { GridRow { VStack(alignment: .leading, spacing: 2) { Label("Hide Agent Reasoning", systemImage: "eye.slash") .font(.subheadline).fontWeight(.medium) Text("Suppress reasoning events in TUI and exec outputs.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("", isOn: $codexVM.hideAgentReasoning) .labelsHidden() .onChange(of: codexVM.hideAgentReasoning) { _ in codexVM.scheduleApplyHideReasoningDebounced() } .frame(maxWidth: .infinity, alignment: .trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Show Raw Reasoning", systemImage: "eye") .font(.subheadline).fontWeight(.medium) Text( "Expose raw chain-of-thought when provider supports it (use with caution)." ) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("", isOn: $codexVM.showRawAgentReasoning) .labelsHidden() .onChange(of: codexVM.showRawAgentReasoning) { _ in codexVM.scheduleApplyShowRawReasoningDebounced() } .frame(maxWidth: .infinity, alignment: .trailing) } } } } } // MARK: - Raw Config Pane private var rawConfigPane: some View { SettingsTabContent { ZStack(alignment: .topTrailing) { ScrollView { Text( codexVM.rawConfigText.isEmpty ? "(empty config.toml)" : codexVM.rawConfigText ) .font(.system(.caption, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .topLeading) } HStack(spacing: 8) { Button { Task { await codexVM.reloadRawConfig() } } label: { Image(systemName: "arrow.clockwise") } .help("Reload") .buttonStyle(.borderless) Button { codexVM.openConfigInEditor() } label: { Image(systemName: "square.and.pencil") } .help("Open in default editor") .buttonStyle(.borderless) } } .task { await codexVM.reloadRawConfig() } } } // MARK: - Helper Views // codexTabContent has been replaced by the shared SettingsTabContent component private func reloadProxyCatalog(forceRefresh: Bool = false) async { await providerCatalog.reload(preferences: preferences, forceRefresh: forceRefresh) normalizeProxySelection() } private func normalizeProxySelection() { let normalized = providerCatalog.normalizeProviderId(preferences.codexProxyProviderId) if normalized != preferences.codexProxyProviderId { preferences.codexProxyProviderId = normalized } let providerChanged = lastProviderId != nil && lastProviderId != preferences.codexProxyProviderId lastProviderId = preferences.codexProxyProviderId guard let providerId = preferences.codexProxyProviderId else { providerModels = [] preferences.codexProxyModelId = nil return } providerModels = providerCatalog.models(for: providerId) if providerChanged { preferences.codexProxyModelId = nil return } guard !providerModels.isEmpty else { return } } @ViewBuilder private var gridDivider: some View { Divider() } private func overrideToggleBinding(for feature: CodexVM.FeatureFlag) -> Binding { Binding( get: { guard let live = codexVM.featureFlags.first(where: { $0.id == feature.id }) else { return feature.defaultEnabled } switch live.overrideState { case .inherit: return live.defaultEnabled case .forceOn: return true case .forceOff: return false } }, set: { newValue in guard let live = codexVM.featureFlags.first(where: { $0.id == feature.id }) else { codexVM.setFeatureOverride( name: feature.name, state: newValue == feature.defaultEnabled ? .inherit : (newValue ? .forceOn : .forceOff) ) return } let desired: CodexVM.FeatureOverrideState if newValue == live.defaultEnabled { desired = .inherit } else { desired = newValue ? .forceOn : .forceOff } codexVM.setFeatureOverride(name: live.name, state: desired) } ) } } ================================================ FILE: views/CommandsSettingsView.swift ================================================ import SwiftUI struct CommandsSettingsView: View { @ObservedObject var preferences: SessionPreferencesStore @StateObject private var vm = CommandsViewModel() @State private var searchFocused = false @State private var pendingAction: PendingCommandAction? var body: some View { VStack(alignment: .leading, spacing: 12) { headerRow contentRow } .sheet(isPresented: $vm.showAddSheet) { CommandEditSheet( preferences: preferences, command: nil, onSave: { command in Task { await vm.addCommand(command) vm.showAddSheet = false } }, onCancel: { vm.showAddSheet = false } ) .frame(minWidth: 760, minHeight: 480) } .sheet(isPresented: $vm.showImportSheet) { CommandsImportSheet( candidates: $vm.importCandidates, isImporting: vm.isImporting, statusMessage: vm.importStatusMessage, title: "Import Commands", subtitle: "Scan Home for existing Codex/Claude/Gemini commands and import into CodMate.", onCancel: { vm.cancelImport() }, onImport: { Task { await vm.importSelectedCommands() } } ) .frame(minWidth: 760, minHeight: 480) } .sheet(item: $vm.editingCommand) { command in CommandEditSheet( preferences: preferences, command: command, onSave: { updated in Task { await vm.updateCommand(updated) vm.editingCommand = nil } }, onCancel: { vm.editingCommand = nil } ) .frame(minWidth: 760, minHeight: 480) } .alert(item: $pendingAction) { action in Alert( title: Text("Delete Command?"), message: Text("Remove \"\(action.command.name)\" from the commands list?"), primaryButton: .destructive(Text("Delete")) { Task { await vm.deleteCommand(id: action.command.id) pendingAction = nil } }, secondaryButton: .cancel { pendingAction = nil } ) } .task { await vm.load() } } private var headerRow: some View { HStack(spacing: 8) { Spacer(minLength: 0) ToolbarSearchField( placeholder: "Search commands", text: $vm.searchText, onFocusChange: { focused in searchFocused = focused }, onSubmit: {} ) .frame(width: 240) Button { vm.showAddSheet = true } label: { Label("Add", systemImage: "plus") } Button { vm.beginImportFromHome() } label: { Label("Import", systemImage: "tray.and.arrow.down") } } } private var contentRow: some View { HStack(alignment: .top, spacing: 12) { commandsList .frame(minWidth: 260, maxWidth: 320) detailPanel } .frame(maxWidth: .infinity, maxHeight: .infinity) } private var commandsList: some View { Group { if vm.isLoading { VStack(spacing: 8) { ProgressView() Text("Loading commands…") .font(.caption) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } else if vm.filteredCommands.isEmpty { VStack(spacing: 10) { Image(systemName: "command") .font(.system(size: 32)) .foregroundStyle(.secondary) Text("No Commands") .font(.title3) .fontWeight(.medium) Text("Add a command to get started.") .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { List(selection: $vm.selectedCommandId) { ForEach(vm.filteredCommands) { command in HStack(alignment: .center, spacing: 8) { Toggle( "", isOn: Binding( get: { command.isEnabled }, set: { value in vm.updateCommandEnabled(id: command.id, value: value) } ) ) .labelsHidden() .controlSize(.small) VStack(alignment: .leading, spacing: 4) { HStack(spacing: 4) { Text(command.name) .font(.body.weight(.medium)) } Text(command.description) .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) } Spacer(minLength: 8) HStack(spacing: 6) { MCPServerTargetToggle( provider: .codex, isOn: Binding( get: { vm.isCommandTargetEnabled(id: command.id, target: .codex) }, set: { value in vm.updateCommandTarget(id: command.id, target: .codex, value: value) } ), disabled: !preferences.isCLIEnabled(.codex) ) MCPServerTargetToggle( provider: .claude, isOn: Binding( get: { vm.isCommandTargetEnabled(id: command.id, target: .claude) }, set: { value in vm.updateCommandTarget(id: command.id, target: .claude, value: value) } ), disabled: !preferences.isCLIEnabled(.claude) ) MCPServerTargetToggle( provider: .gemini, isOn: Binding( get: { vm.isCommandTargetEnabled(id: command.id, target: .gemini) }, set: { value in vm.updateCommandTarget(id: command.id, target: .gemini, value: value) } ), disabled: !preferences.isCLIEnabled(.gemini) ) } } .padding(.vertical, 4) .contentShape(Rectangle()) .onTapGesture { vm.selectedCommandId = command.id } .tag(command.id as String?) .contextMenu { Button("Edit") { vm.editingCommand = command } let editors = EditorApp.installedEditors openInEditorMenu(editors: editors) { editor in vm.openInEditor(command, using: editor) } .disabled(command.path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) if !editors.isEmpty { Divider() } Button("Reveal in Finder") { revealInFinder(path: command.path) } Button("Delete", role: .destructive) { confirmDelete(command) } } } } .listStyle(.inset) .scrollContentBackground(.hidden) } } .background( RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color(nsColor: .textBackgroundColor)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.15))) ) } private var detailPanel: some View { VStack(alignment: .leading, spacing: 12) { if let command = vm.selectedCommand { CommandDetailExplorerView( command: command, onEdit: { vm.editingCommand = command }, onDelete: { confirmDelete(command) }, onSync: { Task { await vm.manualSync() } } ) .id(command.id) } else { VStack(spacing: 12) { Image(systemName: "command") .font(.system(size: 32)) .foregroundStyle(.secondary) Text("Select a command to view details") .font(.subheadline) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } } .padding(12) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background( RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color(nsColor: .textBackgroundColor)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.15))) ) } private func confirmDelete(_ command: CommandRecord) { pendingAction = PendingCommandAction(command: command) } private func revealInFinder(path: String) { guard !path.isEmpty else { return } let url = URL(fileURLWithPath: path) NSWorkspace.shared.activateFileViewerSelecting([url]) } } private struct PendingCommandAction: Identifiable { let id = UUID() let command: CommandRecord } // MARK: - Command Detail Explorer View struct CommandDetailExplorerView: View { let command: CommandRecord let onEdit: () -> Void let onDelete: () -> Void let onSync: () -> Void var body: some View { VStack(alignment: .leading, spacing: 12) { header ScrollView { VStack(alignment: .leading, spacing: 16) { promptSection if hasMetadata { metadataSection } if !command.metadata.tags.isEmpty { tagsSection } infoSection } } } } private var header: some View { HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 6) { Text(command.name) .font(.title3.weight(.semibold)) Text(command.description) .font(.subheadline) .foregroundStyle(.secondary) } Spacer() HStack(spacing: 8) { Button { onSync() } label: { Image(systemName: "arrow.triangle.2.circlepath") } .buttonStyle(.borderless) .help("Sync commands to AI CLI providers") Button { onEdit() } label: { Image(systemName: "pencil") } .buttonStyle(.borderless) .help("Edit") Button(role: .destructive) { onDelete() } label: { Image(systemName: "trash") } .buttonStyle(.borderless) .help("Delete") } } } private var promptSection: some View { VStack(alignment: .leading, spacing: 8) { Text("Prompt") .font(.headline) Text(command.prompt) .font(.system(size: 13)) .textSelection(.enabled) .padding(10) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(Color(nsColor: .controlBackgroundColor)) ) } } private var hasMetadata: Bool { command.metadata.argumentHint != nil || command.metadata.model != nil || (command.metadata.allowedTools?.isEmpty == false) } private var metadataSection: some View { VStack(alignment: .leading, spacing: 8) { Text("Metadata") .font(.headline) VStack(alignment: .leading, spacing: 6) { if let hint = command.metadata.argumentHint { metadataRow(label: "Argument Hint", value: hint) } if let model = command.metadata.model { metadataRow(label: "Model", value: model) } if let tools = command.metadata.allowedTools, !tools.isEmpty { metadataRow(label: "Allowed Tools", value: tools.joined(separator: ", ")) } } } } private func metadataRow(label: String, value: String) -> some View { HStack(alignment: .top, spacing: 8) { Text(label) .font(.caption) .foregroundStyle(.secondary) .frame(width: 100, alignment: .leading) Text(value) .font(.caption) .textSelection(.enabled) Spacer() } } private var tagsSection: some View { VStack(alignment: .leading, spacing: 8) { Text("Tags") .font(.headline) HStack(spacing: 6) { ForEach(command.metadata.tags, id: \.self) { tag in Text(tag) .font(.caption) .padding(.horizontal, 8) .padding(.vertical, 4) .background(Color.accentColor.opacity(0.12)) .clipShape(RoundedRectangle(cornerRadius: 4)) } } } } private var infoSection: some View { VStack(alignment: .leading, spacing: 6) { Divider() HStack(spacing: 12) { VStack(alignment: .leading, spacing: 4) { Text("Source") .font(.caption2) .foregroundStyle(.tertiary) Text(command.source) .font(.caption) .foregroundStyle(.secondary) } Spacer() VStack(alignment: .trailing, spacing: 4) { Text("Installed") .font(.caption2) .foregroundStyle(.tertiary) Text(command.installedAt.formatted(date: .abbreviated, time: .omitted)) .font(.caption) .foregroundStyle(.secondary) } } } } } // MARK: - Command Edit Sheet struct CommandEditSheet: View { @ObservedObject var preferences: SessionPreferencesStore let command: CommandRecord? let onSave: (CommandRecord) -> Void let onCancel: () -> Void @State private var id: String = "" @State private var name: String = "" @State private var description: String = "" @State private var prompt: String = "" @State private var argumentHint: String = "" @State private var model: String = "" @State private var allowedTools: String = "" @State private var tags: String = "" @State private var codexEnabled = true @State private var claudeEnabled = true @State private var geminiEnabled = false @State private var selectedTab: Int = 0 @State private var wizardActive: Bool = false @State private var didHydrate: Bool = false @FocusState private var focusedField: FocusField? private enum FocusField { case name } var body: some View { if wizardActive { CommandWizardSheet(preferences: preferences, onApply: { draft in applyDraft(draft) wizardActive = false }, onCancel: { wizardActive = false }) } else { formBody } } @ViewBuilder private var formBody: some View { VStack(alignment: .leading, spacing: 12) { // Header (title only) HStack(alignment: .firstTextBaseline) { Text(command == nil ? "New Command" : "Edit Command") .font(.title3) .fontWeight(.semibold) Spacer() Button { wizardActive = true } label: { Image(systemName: "sparkles") } .buttonStyle(.borderless) .help("AI Wizard") } // Tabs if #available(macOS 15.0, *) { TabView(selection: $selectedTab) { Tab("General", systemImage: "slider.horizontal.3", value: 0) { SettingsTabContent { generalTab } } Tab("Metadata", systemImage: "info.circle", value: 1) { SettingsTabContent { metadataTab } } } } else { TabView(selection: $selectedTab) { SettingsTabContent { generalTab } .tabItem { Label("General", systemImage: "slider.horizontal.3") } .tag(0) SettingsTabContent { metadataTab } .tabItem { Label("Metadata", systemImage: "info.circle") } .tag(1) } } // Bottom buttons HStack { Spacer() Button("Cancel") { onCancel() } Button(command == nil ? "Create" : "Save") { saveCommand() } .buttonStyle(.borderedProminent) .disabled(name.isEmpty || description.isEmpty || prompt.isEmpty) } } .padding(16) .onAppear { if didHydrate { return } if let cmd = command { id = cmd.id name = cmd.name description = cmd.description prompt = cmd.prompt argumentHint = cmd.metadata.argumentHint ?? "" model = cmd.metadata.model ?? "" allowedTools = cmd.metadata.allowedTools?.joined(separator: ", ") ?? "" tags = cmd.metadata.tags.joined(separator: ", ") codexEnabled = cmd.targets.codex claudeEnabled = cmd.targets.claude geminiEnabled = cmd.targets.gemini } didHydrate = true if command == nil { DispatchQueue.main.async { focusedField = .name } } } } private func applyDraft(_ draft: CommandWizardDraft) { name = draft.name description = draft.description prompt = draft.prompt argumentHint = draft.argumentHint ?? "" model = draft.model ?? "" allowedTools = (draft.allowedTools ?? []).joined(separator: ", ") tags = draft.tags.joined(separator: ", ") if let targets = draft.targets { codexEnabled = targets.codex claudeEnabled = targets.claude geminiEnabled = targets.gemini } } @ViewBuilder private var generalTab: some View { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { GridRow { Text("Name").font(.subheadline).fontWeight(.medium) TextField("command-name", text: $name) .focused($focusedField, equals: .name) .frame(maxWidth: .infinity, alignment: .trailing) } GridRow { Text("Description").font(.subheadline).fontWeight(.medium) TextField("Short description", text: $description) .frame(maxWidth: .infinity, alignment: .trailing) } GridRow { Text("Prompt").font(.subheadline).fontWeight(.medium) TextEditor(text: $prompt) .font(.system(.caption, design: .monospaced)) .frame(height: 180) .frame(maxWidth: .infinity, alignment: .trailing) } GridRow { Text("Targets").font(.subheadline).fontWeight(.medium) HStack(spacing: 12) { Toggle("Codex", isOn: $codexEnabled) .toggleStyle(.switch) .controlSize(.small) Toggle("Claude Code", isOn: $claudeEnabled) .toggleStyle(.switch) .controlSize(.small) Toggle("Gemini", isOn: $geminiEnabled) .toggleStyle(.switch) .controlSize(.small) } .frame(maxWidth: .infinity, alignment: .trailing) } } } @ViewBuilder private var metadataTab: some View { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { GridRow { Text("Argument Hint").font(.subheadline).fontWeight(.medium) TextField("[file-path]", text: $argumentHint) .frame(maxWidth: .infinity, alignment: .trailing) } GridRow { Text("Model").font(.subheadline).fontWeight(.medium) TextField("claude-opus-4-5", text: $model) .help("Claude Code only") .frame(maxWidth: .infinity, alignment: .trailing) } GridRow { Text("Allowed Tools").font(.subheadline).fontWeight(.medium) TextField("Read, Grep", text: $allowedTools) .help("Comma-separated list (Claude Code only)") .frame(maxWidth: .infinity, alignment: .trailing) } GridRow { Text("Tags").font(.subheadline).fontWeight(.medium) TextField("tag1, tag2", text: $tags) .help("Comma-separated list") .frame(maxWidth: .infinity, alignment: .trailing) } } } private func saveCommand() { let finalId = command?.id ?? name.lowercased().replacingOccurrences(of: " ", with: "-") let metadata = CommandMetadata( argumentHint: argumentHint.isEmpty ? nil : argumentHint, model: model.isEmpty ? nil : model, allowedTools: allowedTools.isEmpty ? nil : allowedTools.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }, tags: tags.isEmpty ? [] : tags.split(separator: ",").map { String($0.trimmingCharacters(in: .whitespaces)) } ) let targets = CommandTargets(codex: codexEnabled, claude: claudeEnabled, gemini: geminiEnabled) let record = CommandRecord( id: finalId, name: name, description: description, prompt: prompt, metadata: metadata, targets: targets, isEnabled: command?.isEnabled ?? true, source: command?.source ?? "user", installedAt: command?.installedAt ?? Date() ) onSave(record) } } ================================================ FILE: views/Content/AllOverviewView.swift ================================================ import SwiftUI struct AllOverviewView: View { @ObservedObject var viewModel: AllOverviewViewModel var preferences: SessionPreferencesStore var onSelectSession: (SessionSummary) -> Void var onResumeSession: (SessionSummary) -> Void var onFocusToday: () -> Void var onSelectDate: (Date) -> Void var onSelectProject: (String) -> Void private func columns(for width: CGFloat) -> [GridItem] { let minWidth: CGFloat = 220 let spacing: CGFloat = 16 let availableWidth = width - 48 // 24 horizontal padding * 2 let count = max(1, Int((availableWidth + spacing) / (minWidth + spacing))) // Cap at 4 columns to match the max number of items per section (4) var targetCount = min(4, count) // Optimization: Avoid 3 columns for 4-item grids to prevent "3 on top, 1 on bottom" layout. // Since we mostly have sets of 4 items (Hero, Projects), a 2x2 grid looks better than 3+1. if targetCount == 3 { targetCount = 2 } return Array(repeating: GridItem(.flexible(), spacing: spacing), count: targetCount) } var body: some View { GeometryReader { geometry in let cols = columns(for: geometry.size.width) ScrollView { VStack(alignment: .leading, spacing: 20) { headerSection if viewModel.isLoading && snapshot.totalSessions == 0 { OverviewLoadingPlaceholder() } else { if !snapshot.activityChartData.points.isEmpty { OverviewActivityChart( data: snapshot.activityChartData, enabledSources: enabledSources, onSelectDate: onSelectDate ) } heroSection(columns: cols) efficiencySection(columns: cols) recentSection } } .padding(.horizontal, 24) .padding(.vertical, 24) .frame(maxWidth: .infinity, alignment: .center) } } } private var snapshot: AllOverviewSnapshot { viewModel.snapshot } private var enabledSources: Set { Set([ preferences.isCLIEnabled(.codex) ? SessionSource.Kind.codex : nil, preferences.isCLIEnabled(.claude) ? SessionSource.Kind.claude : nil, preferences.isCLIEnabled(.gemini) ? SessionSource.Kind.gemini : nil, ].compactMap { $0 }) } private var headerSection: some View { VStack(alignment: .leading, spacing: 6) { Text("Workspace Overview") .font(.largeTitle.weight(.semibold)) Text(metadataLine) .font(.caption) .foregroundStyle(.secondary) } } private var metadataLine: String { var parts: [String] = [] let updated = "Updated \(snapshot.lastUpdated.formatted(date: .abbreviated, time: .shortened))" parts.append(updated) if let coverage = coverageLine { parts.append(coverage) } return parts.joined(separator: " • ") } private var coverageLine: String? { guard let coverage = viewModel.cacheCoverage else { return nil } let sources = coverage.sources.isEmpty ? "Cache building…" : coverage.sources.map { $0.displayName }.joined(separator: ", ") let datePart: String if let dt = coverage.lastFullIndexAt { datePart = "indexed \(dt.formatted(date: .abbreviated, time: .shortened))" } else { datePart = "indexed n/a" } return "Cache: \(coverage.sessionCount) entries • \(sources) • \(datePart)" } private func heroSection(columns: [GridItem]) -> some View { VStack(alignment: .leading, spacing: 16) { LazyVGrid(columns: columns, spacing: 16) { heroMetric( title: "Sessions", value: snapshot.totalSessions.formatted(), detail: "In selected range" ) heroMetric( title: "Messages", value: (snapshot.userMessages + snapshot.assistantMessages).formatted(), detail: "\(snapshot.userMessages) user · \(snapshot.assistantMessages) assistant" ) heroMetric( title: "Active Time", value: Self.durationFormatter.string(from: snapshot.totalDuration) ?? "—", detail: "Tokens \(TokenFormatter.short(snapshot.totalTokens))" ) heroMetric( title: "Projects", value: snapshot.projectCount.formatted(), detail: "Tracked projects" ) } .frame(maxWidth: .infinity, alignment: .leading) } } private func heroMetric(title: String, value: String, detail: String) -> some View { OverviewCard { VStack(alignment: .leading, spacing: 6) { Text(title).font(.subheadline).foregroundStyle(.secondary) Text(value).font(.title2.monospacedDigit()).fontWeight(.semibold) Text(detail).font(.caption).foregroundStyle(.secondary) } } } @ViewBuilder private func efficiencySection(columns: [GridItem]) -> some View { if !snapshot.sourceStats.isEmpty { VStack(alignment: .leading, spacing: 10) { LazyVGrid(columns: columns, spacing: 16) { ForEach(snapshot.sourceStats) { stat in OverviewCard { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .firstTextBaseline) { Text(stat.displayName).font(.headline) Spacer() Text("\(stat.sessionCount) sessions") .font(.caption) .foregroundStyle(.secondary) } VStack(alignment: .leading, spacing: 4) { Label { Text("Total \(TokenFormatter.short(stat.totalTokens)) tokens") } icon: { Image(systemName: "text.quote") } .font(.caption) .foregroundStyle(.secondary) Label { Text("Avg \(Self.durationFormatter.string(from: stat.avgDuration) ?? "—")") } icon: { Image(systemName: "clock") } .font(.caption) .foregroundStyle(.secondary) } .padding(.top, 4) } } } } .frame(maxWidth: .infinity, alignment: .leading) } } } @ViewBuilder private var recentSection: some View { RecentSessionsListView( sessions: snapshot.recentSessions, emptyMessage: "Start a new Codex or Claude session to populate your history.", projectInfoProvider: { session in viewModel.resolveProject(for: session) }, projectColumnWidth: 120, onSelectSession: onSelectSession, onSelectProject: onSelectProject ) } private static let durationFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() formatter.allowedUnits = [.hour, .minute] formatter.unitsStyle = .abbreviated formatter.zeroFormattingBehavior = .dropLeading return formatter }() } private struct OverviewLoadingPlaceholder: View { var body: some View { VStack(alignment: .center, spacing: 24) { // Loading indicator with message VStack(spacing: 12) { ProgressView() .scaleEffect(1.2) .progressViewStyle(.circular) VStack(spacing: 4) { Text("Building Session Index") .font(.headline) .foregroundStyle(.primary) Text("Scanning and caching session files for fast access…") .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } } .padding(.top, 40) // Skeleton preview VStack(alignment: .leading, spacing: 16) { RoundedRectangle(cornerRadius: 12) .fill(Color.secondary.opacity(0.1)) .frame(height: 160) .overlay( VStack(alignment: .leading, spacing: 8) { HStack { RoundedRectangle(cornerRadius: 4).fill(Color.secondary.opacity(0.2)).frame(width: 80, height: 10) Spacer() } RoundedRectangle(cornerRadius: 4).fill(Color.secondary.opacity(0.2)).frame(width: 140, height: 10) RoundedRectangle(cornerRadius: 4).fill(Color.secondary.opacity(0.15)).frame(height: 80) } .padding() ) HStack(spacing: 12) { ForEach(0..<4) { _ in RoundedRectangle(cornerRadius: 12) .fill(Color.secondary.opacity(0.08)) .frame(height: 110) } } .frame(maxWidth: .infinity, alignment: .leading) RoundedRectangle(cornerRadius: 12) .fill(Color.secondary.opacity(0.08)) .frame(height: 120) } .redacted(reason: .placeholder) } } } ================================================ FILE: views/Content/ContentView+Detail.swift ================================================ import SwiftUI extension ContentView { var detailColumn: some View { VStack(spacing: 0) { if viewModel.projectWorkspaceMode == .review, let project = currentSelectedProject(), let dir = project.directory, !dir.isEmpty { // Project-level Review: detail renders right-only (header + diff/preview) reviewRightColumn(project: project, directory: dir) .frame(maxWidth: .infinity, maxHeight: .infinity) } else if viewModel.projectWorkspaceMode == .overview { projectOverviewContent() .frame(maxWidth: .infinity, maxHeight: .infinity) } else if viewModel.projectWorkspaceMode == .agents { projectAgentsContent() .frame(maxWidth: .infinity, maxHeight: .infinity) } else if viewModel.projectWorkspaceMode == .memory { placeholderSurface(title: "Memory", systemImage: "bookmark") .frame(maxWidth: .infinity, maxHeight: .infinity) } else if viewModel.projectWorkspaceMode == .settings { projectOverviewContent() // Now Project Settings shows the Overview .frame(maxWidth: .infinity, maxHeight: .infinity) } else { // Tasks or Sessions mode (both show session-focused UI) detailActionBar .padding(.horizontal, 16) .padding(.vertical, 12) Divider() mainDetailContent .animation(nil, value: isListHidden) } } .padding(.bottom, statusBarReservedHeight) .frame(minWidth: 640) .onChange(of: selectedDetailTab) { newVal in // Coerce legacy .review to .timeline in Tasks mode (session-level Git Review removed) if newVal == .review { selectedDetailTab = .timeline; return } if let focused = focusedSummary { sessionDetailTabs[focused.id] = newVal } } .onChange(of: viewModel.projectWorkspaceMode) { newMode in // When switching into project Review, ensure repository authorization if newMode == .review, let p = currentSelectedProject(), let dir = p.directory, !dir.isEmpty { ensureRepoAccessForProjectReview(directory: dir) } } .onChange(of: focusedSummary?.id) { newId in if let newId = newId { selectedDetailTab = sessionDetailTabs[newId] ?? .timeline } else { selectedDetailTab = .timeline } if selectedDetailTab == .review { selectedDetailTab = .timeline } normalizeDetailTabForTerminalAvailability() } .onChange(of: runningSessionIDs) { _ in normalizeDetailTabForTerminalAvailability() synchronizeSelectedTerminalKey() } } } extension ContentView { func currentSelectedProject() -> Project? { guard let pid = viewModel.selectedProjectIDs.first else { return nil } return viewModel.projects.first(where: { $0.id == pid }) } @ViewBuilder func projectReviewContent(project: Project, directory: String) -> some View { let ws = directory let stateBinding = Binding( get: { viewModel.projectReviewPanelStates[project.id] ?? ReviewPanelState() }, set: { viewModel.projectReviewPanelStates[project.id] = $0 } ) EquatableGitChangesContainer( key: .init( workingDirectoryPath: ws, projectDirectoryPath: ws, state: stateBinding.wrappedValue, refreshToken: reviewRefreshToken ), workingDirectory: URL(fileURLWithPath: ws, isDirectory: true), projectDirectory: URL(fileURLWithPath: ws, isDirectory: true), presentation: .full, preferences: viewModel.preferences, onRequestAuthorization: { ensureRepoAccessForProjectReview(directory: ws) }, refreshToken: reviewRefreshToken, savedState: stateBinding ) .frame(maxWidth: .infinity, maxHeight: .infinity) // Match Tasks detail layout: no extra outer padding; header/content provide their own } @ViewBuilder func reviewRightColumn(project: Project, directory: String) -> some View { let ws = directory let stateBinding = Binding( get: { viewModel.projectReviewPanelStates[project.id] ?? ReviewPanelState() }, set: { viewModel.projectReviewPanelStates[project.id] = $0 } ) let vm = projectReviewVM(for: project.id) EquatableGitChangesContainer( key: .init( workingDirectoryPath: ws, projectDirectoryPath: ws, state: stateBinding.wrappedValue, refreshToken: reviewRefreshToken ), workingDirectory: URL(fileURLWithPath: ws, isDirectory: true), projectDirectory: URL(fileURLWithPath: ws, isDirectory: true), presentation: .full, regionLayout: .rightOnly, preferences: viewModel.preferences, onRequestAuthorization: { ensureRepoAccessForProjectReview(directory: ws) }, externalVM: vm, refreshToken: reviewRefreshToken, savedState: stateBinding ) .frame(maxWidth: .infinity, maxHeight: .infinity) // Match Tasks detail layout: no extra outer padding; header/content provide their own } @ViewBuilder func projectOverviewContent() -> some View { if viewModel.selectedProjectIDs.isEmpty { // Global overview when no specific project is selected AllOverviewView( viewModel: overviewViewModel, preferences: viewModel.preferences, onSelectSession: { focusSessionFromOverview($0) }, onResumeSession: { resumeFromList($0) }, onFocusToday: { focusTodayFromOverview() }, // No longer visible but still part of API onSelectDate: { focusDateFromOverview($0) }, onSelectProject: { focusProjectFromOverview(id: $0) } ) } else if let project = currentSelectedProject() { // Project-specific overview ProjectSpecificOverviewContainerView( sessionListViewModel: viewModel, project: project, preferences: viewModel.preferences, refreshToken: projectOverviewRefreshToken, onSelectSession: { focusSessionFromOverview($0) }, onResumeSession: { resumeFromList($0) }, onFocusToday: { focusTodayFromOverview() }, onEditProject: { presentProjectEditor(for: $0) } ) .id(project.id) } else { // Fallback placeholder if no project and no global overview placeholderSurface(title: "Select a Project", systemImage: "folder.badge.questionmark") } } @ViewBuilder func projectAgentsContent() -> some View { if let project = currentSelectedProject(), let directory = project.directory { ProjectAgentsView( projectDirectory: directory, preferences: viewModel.preferences, refreshToken: agentsRefreshToken ) } else { placeholderSurface(title: "No Project Selected", systemImage: "folder.badge.questionmark") } } @ViewBuilder func placeholderSurface(title: String, systemImage: String) -> some View { VStack(alignment: .center, spacing: 8) { Spacer(minLength: 0) Image(systemName: systemImage) .font(.system(size: 32, weight: .regular)) .foregroundStyle(.secondary) Text(title) .font(.title3.weight(.semibold)) .foregroundStyle(.secondary) Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity) } func focusSessionFromOverview(_ summary: SessionSummary) { let sessionProjectId = viewModel.projectIdForSession(summary.id) if sessionProjectId == nil { focusOnSession( summary, explicitProjectId: SessionListViewModel.otherProjectId, searchTerm: nil, filterConversation: false ) viewModel.setSelectedDay(nil) } else { focusOnSession( summary, explicitProjectId: sessionProjectId, searchTerm: nil, filterConversation: false ) } } func focusTodayFromOverview() { let calendar = Calendar.current let today = calendar.startOfDay(for: Date()) viewModel.selectedDay = today viewModel.selectedDays = [today] viewModel.setSidebarMonthStart(today) isListHidden = false } func focusDateFromOverview(_ date: Date) { let calendar = Calendar.current let day = calendar.startOfDay(for: date) viewModel.selectedDay = day viewModel.selectedDays = [day] viewModel.setSidebarMonthStart(day) isListHidden = false } func focusProjectFromOverview(id: String) { viewModel.setSelectedProject(id) isListHidden = false if id == SessionListViewModel.otherProjectId { viewModel.projectWorkspaceMode = .sessions } else { viewModel.projectWorkspaceMode = .settings } } func presentProjectEditor(for project: Project) { guard project.id != SessionListViewModel.otherProjectId else { return } projectEditorTarget = project } } ================================================ FILE: views/Content/ContentView+DetailActionBar.swift ================================================ import AppKit import SwiftUI extension ContentView { // Sticky detail action bar at the top of the detail column var detailActionBar: some View { HStack(spacing: 12) { // Left: view mode segmented (Timeline | Terminal) Group { if viewModel.preferences.defaultResumeUseEmbeddedTerminal { let items: [SegmentedIconPicker.Item] = [ .init(title: "Timeline", systemImage: "clock", tag: .timeline), .init(title: "Terminal", systemImage: "terminal", tag: .terminal), ] let selection = Binding( get: { selectedDetailTab }, set: { newValue in if newValue == .terminal { if hasAvailableEmbeddedTerminal() { if let focused = focusedSummary, runningSessionIDs.contains(focused.id) { selectedTerminalKey = focused.id } else if let anchorId = fallbackRunningAnchorId() { selectedTerminalKey = anchorId } else { selectedTerminalKey = runningSessionIDs.first } selectedDetailTab = .terminal } else if let focused = focusedSummary { pendingTerminalLaunch = PendingTerminalLaunch(session: focused) } } else { selectedDetailTab = newValue } } ) SegmentedIconPicker(items: items, selection: selection) } else { let items: [SegmentedIconPicker.Item] = [ .init(title: "Timeline", systemImage: "clock", tag: .timeline) ] SegmentedIconPicker(items: items, selection: $selectedDetailTab) } } Spacer(minLength: 12) // Right: New…, Resume…, Reveal, Prompts, Export/Return, Max if let focused = focusedSummary { // New split control: hidden in Terminal tab if selectedDetailTab != .terminal { let embeddedPreferredNew = viewModel.preferences.defaultResumeUseEmbeddedTerminal && !AppSandbox.isEnabled SplitPrimaryMenuButton( title: "New", systemImage: "plus", primary: { if embeddedPreferredNew { startEmbeddedNew(for: focused) } else { // default: external terminal flow startNewSession(for: focused) } }, items: { let allowed = Set(viewModel.allowedSources(for: focused)) let requestedOrder: [ProjectSessionSource] = [.claude, .codex, .gemini] let enabledRemoteHosts = viewModel.preferences.enabledRemoteHosts.sorted() let embeddedEnabled = viewModel.preferences.isEmbeddedTerminalEnabled func sourceKey(_ source: SessionSource) -> String { switch source { case .codexLocal: return "codex-local" case .codexRemote(let host): return "codex-\(host)" case .claudeLocal: return "claude-local" case .claudeRemote(let host): return "claude-\(host)" case .geminiLocal: return "gemini-local" case .geminiRemote(let host): return "gemini-\(host)" } } func launchItems(for source: SessionSource) -> [SplitMenuItem] { let key = sourceKey(source) var items = externalTerminalMenuItems(idPrefix: key) { profile in launchNewSession(for: focused, using: source, profile: profile) } if embeddedEnabled { let embedded = embeddedTerminalProfile() items.insert( .init( id: "\(key)-\(embedded.id)", kind: .action( title: embedded.displayTitle, systemImage: "macwindow", run: { launchNewSession(for: focused, using: source, profile: embedded) } ) ), at: 0 ) } return items } func remoteSource(for base: ProjectSessionSource, host: String) -> SessionSource { switch base { case .codex: return .codexRemote(host: host) case .claude: return .claudeRemote(host: host) case .gemini: return .geminiRemote(host: host) } } func providerAssetIcon(_ source: ProjectSessionSource) -> String { switch source { case .codex: return "ChatGPTIcon" case .claude: return "ClaudeIcon" case .gemini: return "GeminiIcon" } } func assetIconForSessionSource(_ source: SessionSource) -> String { switch source.baseKind { case .codex: return "ChatGPTIcon" case .claude: return "ClaudeIcon" case .gemini: return "GeminiIcon" } } var menuItems: [SplitMenuItem] = [] for base in requestedOrder where allowed.contains(base) { var providerItems = launchItems(for: base.sessionSource) if !enabledRemoteHosts.isEmpty { providerItems.append(.init(kind: .separator)) for host in enabledRemoteHosts { let remote = remoteSource(for: base, host: host) providerItems.append( .init(kind: .submenu(title: host, systemImage: "network", items: launchItems(for: remote))) ) } } menuItems.append( .init( kind: .submenu( title: base.displayName, assetImage: providerAssetIcon(base), items: providerItems ) ) ) } if menuItems.isEmpty { let fallbackSource = focused.source menuItems.append( .init( kind: .submenu( title: fallbackSource.branding.displayName, assetImage: assetIconForSessionSource(fallbackSource), items: launchItems(for: fallbackSource) ) ) ) } return menuItems }() ) } // Resume split control: hidden in Terminal tab if selectedDetailTab != .terminal { let embeddedPreferred = viewModel.preferences.defaultResumeUseEmbeddedTerminal && !AppSandbox.isEnabled SplitPrimaryMenuButton( title: "Resume", systemImage: "play.fill", primary: { if embeddedPreferred { startEmbedded(for: focused) } else { openPreferredExternal(for: focused) } }, items: { var items: [SplitMenuItem] = [] let embeddedEnabled = viewModel.preferences.isEmbeddedTerminalEnabled func sourceKey(_ source: SessionSource) -> String { switch source { case .codexLocal: return "codex-local" case .codexRemote(let host): return "codex-\(host)" case .claudeLocal: return "claude-local" case .claudeRemote(let host): return "claude-\(host)" case .geminiLocal: return "gemini-local" case .geminiRemote(let host): return "gemini-\(host)" } } if embeddedEnabled { items.append( .init( id: "resume-embedded-\(focused.id)", kind: .action( title: "CodMate", systemImage: "macwindow", run: { startEmbedded(for: focused) } ) ) ) } items.append( contentsOf: externalTerminalMenuItems(idPrefix: "resume-\(sourceKey(focused.source))") { profile in launchResume(for: focused, using: focused.source, profile: profile) }) let enabledRemoteHosts = viewModel.preferences.enabledRemoteHosts if !enabledRemoteHosts.isEmpty { items.append(.init(kind: .separator)) let currentKind = focused.source.projectSource for host in enabledRemoteHosts.sorted() { let remoteSrc: SessionSource = (currentKind == .codex) ? .codexRemote(host: host) : .claudeRemote(host: host) let remoteName = remoteSrc.branding.displayName items.append( contentsOf: externalTerminalMenuItems( idPrefix: "resume-\(sourceKey(remoteSrc))", titlePrefix: "\(remoteName) with " ) { profile in launchResume(for: focused, using: remoteSrc, profile: profile) }) } } return items }() ) } // Reveal in Finder (chromed icon) ChromedIconButton(systemImage: "finder", help: "Reveal in Finder") { viewModel.reveal(session: focused) } // Prompts (insert into embedded terminal when available, fallback to clipboard copy) let promptsMode: PromptsPopover.Mode? = { if selectedDetailTab == .terminal { guard runningSessionIDs.contains(focused.id) else { return nil } return .insert(terminalKey: focused.id) } return .copy }() if let promptsMode { ChromedIconButton(systemImage: "text.insert", help: "Prompts") { showPromptPicker.toggle() } .popover(isPresented: $showPromptPicker) { PromptsPopover( workingDirectory: workingDirectory(for: focused), mode: promptsMode, builtin: builtinPrompts(), query: $promptQuery, loaded: $loadedPrompts, hovered: $hoveredPromptKey, pendingDelete: $pendingDelete, onDismiss: { showPromptPicker = false } ) } } // Sync from Task (when focused session is part of a Task and local) if let workspace = viewModel.workspaceVM, !focused.isRemote, workspace.tasks.contains(where: { $0.sessionIds.contains(focused.id) }) { ChromedIconButton(systemImage: "arrow.triangle.2.circlepath", help: "Sync from Task") { syncFromTask(for: focused) } } // Export Markdown or Return to History if selectedDetailTab != .terminal { ChromedIconButton( systemImage: "square.and.arrow.up", help: "Export conversation as Markdown" ) { exportMarkdownForFocused() } } else { ChromedIconButton(systemImage: "arrow.uturn.backward", help: "Return to History") { // Close the terminal currently displayed in the Terminal tab. let id = visibleTerminalKeyInDetail() ?? focused.id softReturnPending = true requestStopEmbedded(forKey: id) } } } else if let project = selectedProjectForDetailNew() { // When there is no focused session but a single real project // is selected, still offer project-scoped New entry so users // can start Codex/Claude sessions directly from the detail bar. if selectedDetailTab != .terminal { let embeddedPreferredNew = viewModel.preferences.defaultResumeUseEmbeddedTerminal && !AppSandbox.isEnabled SplitPrimaryMenuButton( title: "New", systemImage: "plus", primary: { if embeddedPreferredNew { // Defer to shared embedded flow for project-level New viewModel.newSession(project: project) } else { startExternalNewForProject(project) } }, items: buildProjectNewMenuItems(for: project) ) } } } } } // MARK: - Project-level New helpers (detail toolbar) private extension ContentView { /// Single selected real project for project-scoped New. func selectedProjectForDetailNew() -> Project? { guard viewModel.selectedProjectIDs.count == 1, let pid = viewModel.selectedProjectIDs.first else { return nil } // Exclude synthetic "Other" bucket if pid == SessionListViewModel.otherProjectId { return nil } return viewModel.projects.first(where: { $0.id == pid }) } // Minimal shell path escaper for cd commands in clipboard func shellEscapedPath(_ path: String) -> String { if path.isEmpty { return "''" } let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "/.-_")) let needsQuotes = path.rangeOfCharacter(from: allowed.inverted) != nil var output = path.replacingOccurrences(of: "'", with: "'\\''") if needsQuotes { output = "'\(output)'" } return output } // Build split menu items for project-level New actions func buildProjectNewMenuItems(for project: Project) -> [SplitMenuItem] { var items: [SplitMenuItem] = [] let profiles = externalTerminalMenuProfiles() func runCodex(for profile: ExternalTerminalProfile) { let dir = (project.directory?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { $0.isEmpty ? nil : $0 } ?? NSHomeDirectory() let fallbackCommand = simpleProjectNewCommands(project: project) let cmd = viewModel.buildNewProjectCLIInvocation(project: project) let shouldCopy = viewModel.shouldCopyCommandsToClipboard let shouldNotify = viewModel.preferences.commandCopyNotificationsEnabled if profile.usesWarpCommands { guard viewModel.copyNewProjectCommandsIfEnabled(project: project, destinationApp: profile) else { return } viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir) if shouldCopy && shouldNotify { Task { await SystemNotifier.shared.notify( title: "CodMate", body: "Command copied. Paste it in \(profile.displayTitle).") } } return } if profile.isTerminal { if shouldCopy { let pb = NSPasteboard.general pb.clearContents() pb.setString(fallbackCommand + "\n", forType: .string) } _ = viewModel.openAppleTerminal(at: dir) if shouldCopy && shouldNotify { Task { await SystemNotifier.shared.notify( title: "CodMate", body: "Command copied. Paste it in Terminal.") } } return } if !profile.supportsCommandResolved, shouldCopy { let pb = NSPasteboard.general pb.clearContents() pb.setString(fallbackCommand + "\n", forType: .string) } let runCommand = profile.supportsDirectoryResolved ? cmd : fallbackCommand let inline = profile.supportsCommandResolved ? runCommand : nil viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir, command: inline) if !profile.supportsCommandResolved, shouldCopy, shouldNotify { Task { await SystemNotifier.shared.notify( title: "CodMate", body: "Command copied. Paste it in \(profile.displayTitle).") } } } // Project-level Claude invocation func runClaude(for profile: ExternalTerminalProfile) { let dir = (project.directory?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { $0.isEmpty ? nil : $0 } ?? NSHomeDirectory() let cmd = buildClaudeProjectInvocation(for: project) let cdCommand = "cd " + shellEscapedPath(dir) + "\n" + cmd let shouldCopy = viewModel.shouldCopyCommandsToClipboard let shouldNotify = viewModel.preferences.commandCopyNotificationsEnabled if profile.isTerminal { if shouldCopy { let pb = NSPasteboard.general pb.clearContents() pb.setString(cdCommand + "\n", forType: .string) } _ = viewModel.openAppleTerminal(at: dir) if shouldCopy && shouldNotify { Task { await SystemNotifier.shared.notify( title: "CodMate", body: "Command copied. Paste it in Terminal.") } } return } if !profile.supportsCommandResolved, shouldCopy { let pb = NSPasteboard.general pb.clearContents() pb.setString(cdCommand + "\n", forType: .string) } let runCommand = profile.supportsDirectoryResolved ? cmd : cdCommand let inline = profile.supportsCommandResolved ? runCommand : nil viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir, command: inline) if !profile.supportsCommandResolved, shouldCopy, shouldNotify { Task { await SystemNotifier.shared.notify( title: "CodMate", body: "Command copied. Paste it in \(profile.displayTitle).") } } } func runGemini(for profile: ExternalTerminalProfile) { let dir = (project.directory?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { $0.isEmpty ? nil : $0 } ?? NSHomeDirectory() let cmd = buildGeminiProjectInvocation(for: project) let cdCommand = "cd " + shellEscapedPath(dir) + "\n" + cmd let shouldCopy = viewModel.shouldCopyCommandsToClipboard let shouldNotify = viewModel.preferences.commandCopyNotificationsEnabled if profile.isTerminal { if shouldCopy { let pb = NSPasteboard.general pb.clearContents() pb.setString(cdCommand + "\n", forType: .string) } _ = viewModel.openAppleTerminal(at: dir) if shouldCopy && shouldNotify { Task { await SystemNotifier.shared.notify( title: "CodMate", body: "Command copied. Paste it in Terminal.") } } return } if !profile.supportsCommandResolved, shouldCopy { let pb = NSPasteboard.general pb.clearContents() pb.setString(cdCommand + "\n", forType: .string) } let runCommand = profile.supportsDirectoryResolved ? cmd : cdCommand let inline = profile.supportsCommandResolved ? runCommand : nil viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir, command: inline) if !profile.supportsCommandResolved, shouldCopy, shouldNotify { Task { await SystemNotifier.shared.notify( title: "CodMate", body: "Command copied. Paste it in \(profile.displayTitle).") } } } // Two-level menu: provider -> terminals items.append( .init( id: "provider-codex", kind: .submenu( title: "Codex", assetImage: "ChatGPTIcon", items: externalTerminalMenuItems(idPrefix: "project-codex", profiles: profiles) { profile in runCodex(for: profile) } ) ) ) items.append( .init( id: "provider-claude", kind: .submenu( title: "Claude", assetImage: "ClaudeIcon", items: externalTerminalMenuItems(idPrefix: "project-claude", profiles: profiles) { profile in runClaude(for: profile) } ) ) ) items.append( .init( id: "provider-gemini", kind: .submenu( title: "Gemini", assetImage: "GeminiIcon", items: externalTerminalMenuItems(idPrefix: "project-gemini", profiles: profiles) { profile in runGemini(for: profile) } ) ) ) return items } // Build external Terminal flow exactly like SessionListColumnView's project New // external branch, but scoped to the detail toolbar. func startExternalNewForProject(_ project: Project) { guard let profile = ExternalTerminalProfileStore.shared.resolvePreferredProfile( id: viewModel.preferences.defaultResumeExternalAppId ) else { return } let dir: String = { let d = (project.directory ?? "").trimmingCharacters(in: .whitespacesAndNewlines) return d.isEmpty ? NSHomeDirectory() : d }() if profile.isNone { _ = viewModel.copyNewProjectCommandsIfEnabled(project: project, destinationApp: profile) if viewModel.shouldCopyCommandsToClipboard { if viewModel.preferences.commandCopyNotificationsEnabled { Task { await SystemNotifier.shared.notify( title: "CodMate", body: "Command copied. Paste it in the opened terminal.") } } } return } if profile.usesWarpCommands { guard viewModel.copyNewProjectCommandsIfEnabled(project: project, destinationApp: profile) else { return } viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir) } else if profile.isTerminal { if viewModel.shouldCopyCommandsToClipboard { let pb = NSPasteboard.general pb.clearContents() pb.setString(simpleProjectNewCommands(project: project) + "\n", forType: .string) } _ = viewModel.openAppleTerminal(at: dir) } else if !profile.isNone { let cmd = profile.supportsCommandResolved ? viewModel.buildNewProjectCLIInvocation(project: project) : nil if !profile.supportsCommandResolved, viewModel.shouldCopyCommandsToClipboard { let pb = NSPasteboard.general pb.clearContents() pb.setString(simpleProjectNewCommands(project: project) + "\n", forType: .string) } viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir, command: cmd) } if viewModel.shouldCopyCommandsToClipboard && viewModel.preferences.commandCopyNotificationsEnabled { Task { await SystemNotifier.shared.notify( title: "CodMate", body: "Command copied. Paste it in the opened terminal.") } } // Hint + targeted refresh aligns with viewModel.newSession external path viewModel.setIncrementalHintForCodexToday() Task { await viewModel.refreshIncrementalForNewCodexToday() } } func simpleProjectNewCommands(project: Project) -> String { let dir: String = { let d = (project.directory ?? "").trimmingCharacters(in: .whitespacesAndNewlines) return d.isEmpty ? NSHomeDirectory() : d }() let cd = "cd " + shellEscapedPath(dir) let cmd = viewModel.buildNewProjectCLIInvocation(project: project) return cd + "\n" + cmd } /// Sync shared Task context for the focused session and expose it to the running CLI. /// - In Timeline tab: regenerates the context file and copies a prompt with path hint. /// - In Terminal tab (embedded): regenerates the context file and inserts the prompt /// into the embedded terminal input for this session. func syncFromTask(for focused: SessionSummary) { guard !focused.isRemote else { return } guard let workspace = viewModel.workspaceVM else { return } guard let task = workspace.tasks.first(where: { $0.sessionIds.contains(focused.id) }) else { return } Task { @MainActor in _ = await workspace.syncTaskContext(taskId: task.id) let taskIdString = task.id.uuidString let pathHint = "~/.codmate/tasks/context-\(taskIdString).md" let promptLines: [String] = [ "The shared context for the current Task has been updated and saved to a local file:", pathHint, "", "Before answering the next question, if needed, please read this file first to understand the task history and related constraints." ] let text = promptLines.joined(separator: "\n") if selectedDetailTab == .terminal, runningSessionIDs.contains(focused.id) { // Send text directly to the Ghostty terminal if let scrollView = GhosttySessionManager.shared.getScrollView(for: focused.id) { scrollView.surfaceView.sendText(text + "\n") } else { // Fallback to clipboard if terminal not found let pb = NSPasteboard.general pb.clearContents() pb.setString(text + "\n", forType: .string) } } else { let pb = NSPasteboard.general pb.clearContents() pb.setString(text + "\n", forType: .string) } await SystemNotifier.shared.notify( title: "CodMate", body: "Task context synced. Prompt is ready for use.") } } // Build a Claude invocation honoring project/default model and runtime flags func buildClaudeProjectInvocation(for project: Project) -> String { viewModel.buildClaudeProjectInvocation(project: project) } // Build a Gemini invocation honoring resume options. func buildGeminiProjectInvocation(for project: Project) -> String { viewModel.buildGeminiProjectInvocation() } } // MARK: - SegmentedIconPicker (AppKit-backed) struct SegmentedIconPicker: NSViewRepresentable { struct Item { let title: String let systemImage: String let tag: Selection let isEnabled: Bool init(title: String, systemImage: String, tag: Selection, isEnabled: Bool = true) { self.title = title self.systemImage = systemImage self.tag = tag self.isEnabled = isEnabled } } let items: [Item] @Binding var selection: Selection var isInteractive: Bool = true var iconScale: CGFloat = 1 func makeCoordinator() -> Coordinator { Coordinator(selection: $selection, items: items, iconScale: iconScale) } func makeNSView(context: Context) -> NSSegmentedControl { let control = NSSegmentedControl() control.translatesAutoresizingMaskIntoConstraints = true control.segmentStyle = .automatic control.controlSize = .regular control.trackingMode = .selectOne control.target = context.coordinator control.action = #selector(Coordinator.changed(_:)) control.setContentHuggingPriority(.required, for: .horizontal) control.setContentCompressionResistancePriority(.required, for: .horizontal) rebuild(control) context.coordinator.control = control context.coordinator.isInteractive = isInteractive return control } func updateNSView(_ control: NSSegmentedControl, context: Context) { // Update coordinator's items to ensure it has the latest data context.coordinator.items = items context.coordinator.iconScale = iconScale if control.segmentCount != items.count { rebuild(control) } for (i, it) in items.enumerated() { control.setLabel(it.title, forSegment: i) if let img = NSImage(systemSymbolName: it.systemImage, accessibilityDescription: nil) { // Use template mode to allow proper tinting in selected state img.isTemplate = true // Apply icon scaling let scaledImg = scaleImage(img, scale: iconScale) control.setImage(scaledImg, forSegment: i) control.setImageScaling(.scaleNone, forSegment: i) } control.setEnabled(it.isEnabled, forSegment: i) } if let idx = items.firstIndex(where: { $0.tag == selection }) { control.selectedSegment = idx } else { control.selectedSegment = -1 } context.coordinator.isInteractive = isInteractive } private func scaleImage(_ image: NSImage, scale: CGFloat) -> NSImage { let originalSize = image.size let scaledSize = NSSize(width: originalSize.width * scale, height: originalSize.height * scale) // Add left padding to the icon let leftPadding: CGFloat = 4 let newSize = NSSize(width: scaledSize.width + leftPadding, height: scaledSize.height) let scaledImage = NSImage(size: newSize) scaledImage.isTemplate = true // Preserve template mode for proper tinting scaledImage.lockFocus() image.draw( in: NSRect(x: leftPadding, y: 0, width: scaledSize.width, height: scaledSize.height), from: NSRect(origin: .zero, size: originalSize), operation: .copy, fraction: 1.0) scaledImage.unlockFocus() return scaledImage } private func rebuild(_ control: NSSegmentedControl) { control.segmentCount = items.count for (i, it) in items.enumerated() { control.setLabel(it.title, forSegment: i) if let img = NSImage(systemSymbolName: it.systemImage, accessibilityDescription: nil) { // Use template mode to allow proper tinting in selected state img.isTemplate = true let scaledImg = scaleImage(img, scale: iconScale) control.setImage(scaledImg, forSegment: i) control.setImageScaling(.scaleNone, forSegment: i) } control.setEnabled(it.isEnabled, forSegment: i) } } final class Coordinator: NSObject { weak var control: NSSegmentedControl? var selection: Binding var items: [Item] var isInteractive: Bool = true var iconScale: CGFloat = 1.0 init(selection: Binding, items: [Item], iconScale: CGFloat = 1.0) { self.selection = selection self.items = items self.iconScale = iconScale } @objc func changed(_ sender: NSSegmentedControl) { guard isInteractive else { return } let idx = sender.selectedSegment guard idx >= 0 && idx < items.count else { return } // Directly update the binding selection.wrappedValue = items[idx].tag } } } // MARK: - Chromed icon button to match split buttons private struct ChromedIconButton: View { let systemImage: String var help: String? = nil let action: () -> Void var body: some View { let h: CGFloat = 24 Button(action: action) { Image(systemName: systemImage) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.primary) .padding(.horizontal, 8) .frame(height: h) .frame(minWidth: h) // keep a minimum square feel when padding is small .contentShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) } .buttonStyle(.plain) .background(Color(nsColor: .controlBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 6, style: .continuous) .stroke(Color.secondary.opacity(0.25), lineWidth: 1) ) .help(help ?? "") } } // MARK: - Prompts popover content private struct PromptsPopover: View { enum Mode { case insert(terminalKey: String) case copy var hint: String { switch self { case .insert: return "Selecting a prompt inserts it into the embedded terminal." case .copy: return "Selecting a prompt copies it to the clipboard." } } } let workingDirectory: String let mode: Mode let builtin: [PresetPromptsStore.Prompt] @Binding var query: String @Binding var loaded: [ContentView.SourcedPrompt] @Binding var hovered: String? @Binding var pendingDelete: ContentView.SourcedPrompt? let onDismiss: () -> Void @FocusState private var searchFocused: Bool var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { Text("Preset Prompts").font(.headline) Spacer() Button { Task { await PresetPromptsStore.shared.openOrCreatePreferredFile( for: workingDirectory, withTemplate: builtin) } } label: { Image(systemName: "wrench.and.screwdriver") } .buttonStyle(.plain) .help("Open prompts file") } Text(mode.hint) .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) TextField("Search or type a new command", text: $query) .textFieldStyle(.roundedBorder) .frame(width: 320) .focused($searchFocused) .onChange(of: query) { _ in reload() } ScrollView { VStack(alignment: .leading, spacing: 0) { let rows = filtered() ForEach(rows.indices, id: \.self) { idx in let sp = rows[idx] let rowKey = sp.command HStack(spacing: 8) { if hovered == rowKey { Button { Task { await PresetPromptsStore.shared.delete( prompt: sp.prompt, location: location(of: sp), workingDirectory: workingDirectory) } reload() } label: { Image(systemName: "minus.circle") } .buttonStyle(.plain) .help("Remove") } Text(sp.label) .font(.system(size: 13, weight: .regular)) .frame(maxWidth: .infinity, alignment: .leading) } .padding(.leading, 8) .padding(.trailing, 24) .frame(height: 32) .frame(maxWidth: .infinity, alignment: .leading) .background(idx % 2 == 0 ? Color.secondary.opacity(0.06) : Color.clear) .contentShape(Rectangle()) .onHover { inside in if inside { hovered = rowKey } else if hovered == rowKey { hovered = nil } } .onTapGesture { handleSelection(sp.command) // Auto-dismiss popover after selecting a preset onDismiss() } } if shouldOfferAdd() { Button { let p = PresetPromptsStore.Prompt(label: query, command: query) Task { _ = await PresetPromptsStore.shared.add(prompt: p, for: workingDirectory) reload() } } label: { Label("Add \(query)", systemImage: "plus") } .buttonStyle(.borderless) .padding(.top, 6) .padding(.trailing, 24) } } } .frame(height: 160) } .padding(12) .onAppear { reload() // Focus search field by default for quick keyboard input DispatchQueue.main.async { self.searchFocused = true } } } private func location(of sp: ContentView.SourcedPrompt) -> PresetPromptsStore.PromptLocation { switch sp.source { case .project: return .project case .user: return .user case .builtin: return .builtin } } private func handleSelection(_ value: String) { switch mode { case .insert(let terminalKey): // Send text directly to the Ghostty terminal if let scrollView = GhosttySessionManager.shared.getScrollView(for: terminalKey) { scrollView.surfaceView.sendText(value) } else { // Fallback to clipboard if terminal not found copyToClipboard(value) } case .copy: copyToClipboard(value) Task { await SystemNotifier.shared.notify( title: "CodMate", body: "Prompt copied. Paste it into your terminal.") } } } private func copyToClipboard(_ value: String) { let pb = NSPasteboard.general pb.clearContents() pb.setString(value, forType: .string) } private func filtered() -> [ContentView.SourcedPrompt] { if query.trimmingCharacters(in: .whitespaces).isEmpty { return loaded } let q = query.lowercased() return loaded.filter { $0.label.lowercased().contains(q) || $0.command.lowercased().contains(q) } } private func shouldOfferAdd() -> Bool { let q = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !q.isEmpty else { return false } return !loaded.contains(where: { $0.command == q }) } private func reload() { Task { let store = PresetPromptsStore.shared let project = await store.loadProjectOnly(for: workingDirectory) let user = await store.loadUserOnly() let hidden = await store.loadHidden(for: workingDirectory) var seen = Set() var out: [ContentView.SourcedPrompt] = [] func push(_ p: PresetPromptsStore.Prompt, _ src: ContentView.SourcedPrompt.Source) { if hidden.contains(p.command) { return } if seen.insert(p.command).inserted { out.append(ContentView.SourcedPrompt(prompt: p, source: src)) } } project.forEach { push($0, .project) } user.forEach { push($0, .user) } builtin.forEach { push($0, .builtin) } await MainActor.run { loaded = out } } } } ================================================ FILE: views/Content/ContentView+Helpers.swift ================================================ import SwiftUI import AppKit import UniformTypeIdentifiers extension ContentView { // Split helpers to keep ContentView.swift lean var focusedSummary: SessionSummary? { guard !selection.isEmpty else { return viewModel.sections.first?.sessions.first } let all = summaryLookup if let pid = selectionPrimaryId, selection.contains(pid), let s = all[pid] { return s } return selection .compactMap { all[$0] } .sorted { lhs, rhs in (lhs.lastUpdatedAt ?? lhs.startedAt) > (rhs.lastUpdatedAt ?? rhs.startedAt) } .first } var summaryLookup: [SessionSummary.ID: SessionSummary] { Dictionary( uniqueKeysWithValues: viewModel.sections .flatMap(\.sessions) .map { ($0.id, $0) } ) } func fallbackRunningAnchorId() -> String? { let realIds = Set(summaryLookup.keys) if let id = runningSessionIDs.first(where: { $0.hasPrefix("new-anchor:") }) { return id } return runningSessionIDs.first(where: { !realIds.contains($0) }) } func synchronizeSelectedTerminalKey() { #if APPSTORE selectedTerminalKey = nil #else if let key = selectedTerminalKey, runningSessionIDs.contains(key) { return } selectedTerminalKey = runningSessionIDs.first #endif } func activeTerminalKey() -> String? { #if APPSTORE return nil #else if let key = selectedTerminalKey, runningSessionIDs.contains(key) { return key } return runningSessionIDs.first #endif } func syncRunningSessionIDsFromManager() { // Ghostty embeds are driven by view state; no external manager sync required. } func hasAvailableEmbeddedTerminal() -> Bool { #if APPSTORE return false #else // Check if there's a terminal available for the focused session guard let focused = focusedSummary else { // No focused session, check if there are any anchor terminals (new sessions) return fallbackRunningAnchorId() != nil } // Check if focused session has a running terminal return runningSessionIDs.contains(focused.id) #endif } func visibleTerminalKeyInDetail() -> String? { #if APPSTORE return nil #else guard selectedDetailTab == .terminal else { return nil } if let selected = selectedTerminalKey, runningSessionIDs.contains(selected) { return selected } if let focused = focusedSummary, runningSessionIDs.contains(focused.id) { return focused.id } return fallbackRunningAnchorId() #endif } func normalizeDetailTabForTerminalAvailability() { #if APPSTORE if selectedDetailTab == .terminal { selectedDetailTab = .timeline } #else if selectedDetailTab == .terminal && activeTerminalKey() == nil { selectedDetailTab = .timeline } #endif } func terminalHostInitialCommands(for key: String) -> String { if let stored = embeddedInitialCommands[key] { return stored } if let summary = summaryLookup[key] { return viewModel.buildResumeCommands(session: summary) } return "" } // DISABLED: SwiftTerm specific method, not needed for Ghostty /* func consoleSpecForTerminalKey(_ key: String) -> TerminalHostView.ConsoleSpec? { #if canImport(SwiftTerm) && !APPSTORE if key.hasPrefix("new-anchor:"), let spec = consoleSpecForAnchor(key) { return spec } if let summary = summaryLookup[key] { return consoleSpecForResume(summary) } #endif return nil } */ func canonicalizePath(_ path: String) -> String { let expanded = (path as NSString).expandingTildeInPath var standardized = URL(fileURLWithPath: expanded).standardizedFileURL.path if standardized.count > 1 && standardized.hasSuffix("/") { standardized.removeLast() } return standardized } func exportMarkdownForFocused() { guard let focused = focusedSummary else { return } exportMarkdownForSession(focused) } func exportMarkdownForSession(_ session: SessionSummary) { Task { let loader = SessionTimelineLoader() let allTurns = await loadConversationTurnsForExport( session: session, loader: loader ) await MainActor.run { presentMarkdownExport(for: session, allTurns: allTurns) } } } private func loadConversationTurnsForExport( session: SessionSummary, loader: SessionTimelineLoader ) async -> [ConversationTurn] { if session.source.baseKind == .claude { if let parsed = ClaudeSessionParser().parse(at: session.fileURL) { return loader.turns(from: parsed.rows) } return [] } else if session.source.baseKind == .gemini { return await viewModel.timeline(for: session) } else { return (try? loader.load(url: session.fileURL)) ?? [] } } @MainActor private func presentMarkdownExport(for session: SessionSummary, allTurns: [ConversationTurn]) { let kinds = viewModel.preferences.markdownVisibleKinds let turns: [ConversationTurn] = allTurns.compactMap { turn in let userAllowed = turn.userMessage.flatMap { kinds.contains(event: $0) } ?? false let keptOutputs = turn.outputs.filter { kinds.contains(event: $0) } if !userAllowed && keptOutputs.isEmpty { return nil } return ConversationTurn( id: turn.id, timestamp: turn.timestamp, userMessage: userAllowed ? turn.userMessage : nil, outputs: keptOutputs ) } // Fallback: if Claude session produced non-empty turns but all filtered out by current preferences, // relax filter to include assistant messages to avoid empty exports. let finalTurns: [ConversationTurn] let builderKinds: Set if turns.isEmpty, session.source.baseKind == .claude, !allTurns.isEmpty { let relaxed: Set = [.user, .assistant] finalTurns = allTurns.compactMap { turn in let userAllowed = turn.userMessage.flatMap { relaxed.contains(event: $0) } ?? false let keptOutputs = turn.outputs.filter { relaxed.contains(event: $0) } if !userAllowed && keptOutputs.isEmpty { return nil } return ConversationTurn(id: turn.id, timestamp: turn.timestamp, userMessage: userAllowed ? turn.userMessage : nil, outputs: keptOutputs) } builderKinds = relaxed } else { finalTurns = turns builderKinds = kinds } let panel = NSSavePanel() panel.title = "Export Markdown" panel.allowedContentTypes = [.plainText] let base = sanitizedExportFileName(session.effectiveTitle, fallback: session.displayName) panel.nameFieldStringValue = base + ".md" if panel.runModal() == .OK, let url = panel.url { let md = MarkdownExportBuilder.build( session: session, turns: finalTurns, visibleKinds: builderKinds, exportURL: url ) try? md.data(using: String.Encoding.utf8)?.write(to: url) } } func sanitizedExportFileName(_ s: String, fallback: String, maxLength: Int = 120) -> String { var text = s.trimmingCharacters(in: .whitespacesAndNewlines) if text.isEmpty { return fallback } let disallowed = CharacterSet(charactersIn: "/:") .union(.newlines) .union(.controlCharacters) text = text.unicodeScalars.map { disallowed.contains($0) ? Character(" ") : Character($0) } .reduce(into: String(), { $0.append($1) }) while text.contains(" ") { text = text.replacingOccurrences(of: " ", with: " ") } text = text.trimmingCharacters(in: .whitespacesAndNewlines) if text.isEmpty { text = fallback } if text.count > maxLength { let idx = text.index(text.startIndex, offsetBy: maxLength) text = String(text[.. 0 { try? await Task.sleep(nanoseconds: delay) } await action() } } } private func incrementalRefreshAction( for source: SessionSource, directory: String? ) -> (() async -> Void)? { switch source.baseKind { case .codex: return { await viewModel.refreshIncrementalForNewCodexToday() } case .gemini: return { await viewModel.refreshIncrementalForGeminiToday() } case .claude: guard let directory else { return nil } return { await viewModel.refreshIncrementalForClaudeProject(directory: directory) } } } private func incrementalRefreshSchedule(for kind: SessionSource.Kind) -> [UInt64] { switch kind { case .claude: return [ 0, 600_000_000, 1_500_000_000, 3_000_000_000, 5_000_000_000, 10_000_000_000, ] case .codex, .gemini: return [0, 600_000_000, 1_500_000_000] } } func sourceButtonLabel(title: String, source: SessionSource) -> some View { Text(title) } func providerMenuLabel(prefix: String, source: SessionSource) -> some View { Text("\(prefix) \(source.branding.displayName)") } } ================================================ FILE: views/Content/ContentView+MainDetail.swift ================================================ import SwiftUI import GhosttyKit extension ContentView { // Extracted to reduce ContentView.swift size var mainDetailContent: some View { Group { // Session-level Git Review is removed from Tasks mode. Show Terminal or Conversation only. // Non-review paths: either Terminal tab or Timeline if selectedDetailTab == .terminal { if let terminalKey = visibleTerminalKeyInDetail() { if let summary = summaryLookup[terminalKey] { EmbeddedTerminalView( sessionID: terminalKey, initialCommands: terminalHostInitialCommands(for: terminalKey), worktreePath: workingDirectory(for: summary) ) .id(terminalKey) // Use session ID directly for stability .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(16) } else if let anchorData = pendingEmbeddedRekeys.first(where: { $0.anchorId == terminalKey }) { EmbeddedTerminalView( sessionID: terminalKey, initialCommands: terminalHostInitialCommands(for: terminalKey), worktreePath: anchorData.expectedCwd ) .id(terminalKey) // Use anchor ID directly for stability .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(16) } else { VStack { Text("No terminal session available") .foregroundStyle(.secondary) if let focused = focusedSummary { Button("Start Terminal") { startEmbedded(for: focused) } } } .frame(maxWidth: .infinity, maxHeight: .infinity) } } else if let focused = focusedSummary { // Terminal tab is selected but no terminal is available VStack { Text("No terminal session available") .foregroundStyle(.secondary) Button("Start Terminal") { startEmbedded(for: focused) } } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { // No focused session VStack { Text("No session selected") .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } } else if let focused = focusedSummary { SessionDetailView( summary: focused, isProcessing: isPerformingAction, onResume: { guard let current = focusedSummary else { return } #if APPSTORE openPreferredExternal(for: current) #else if viewModel.preferences.defaultResumeUseEmbeddedTerminal { startEmbedded(for: current) } else { openPreferredExternal(for: current) } #endif }, onReveal: { guard let current = focusedSummary else { return } viewModel.reveal(session: current) }, onDelete: presentDeleteConfirmation, columnVisibility: $columnVisibility, preferences: preferences ) .environmentObject(viewModel) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } else { placeholder } } .onReceive(NotificationCenter.default.publisher(for: .codMateTerminalExited)) { note in guard let info = note.userInfo as? [String: Any], let key = info["sessionID"] as? String, !key.isEmpty else { return } let exitCode = info["exitCode"] as? Int32 print("[EmbeddedTerminal] Process for \(key) terminated, exitCode=\(exitCode.map(String.init) ?? "nil")") if runningSessionIDs.contains(key) { stopEmbedded(forID: key) } } } } ================================================ FILE: views/Content/ContentView+Modifiers.swift ================================================ import SwiftUI import UniformTypeIdentifiers extension ContentView { fileprivate func canProjectWorkspaceReview() -> Bool { guard viewModel.selectedProjectIDs.count == 1, let pid = viewModel.selectedProjectIDs.first, let p = viewModel.projects.first(where: { $0.id == pid }), let dir = p.directory?.trimmingCharacters(in: .whitespacesAndNewlines), !dir.isEmpty else { return false } return true } fileprivate func syncListHiddenForWorkspaceMode() { // Do not auto-hide the session list based on workspace mode. // Respect the user's manual toggle (storeListHidden) and leave isListHidden unchanged. } fileprivate func navigationTitleForSelection() -> String { if isAllSelection() { return "Overview" } else if isOtherSelection() { return "Sessions" } else { return "" } } fileprivate func isAllSelection() -> Bool { return viewModel.selectedProjectIDs.isEmpty } fileprivate func isOtherSelection() -> Bool { if viewModel.selectedProjectIDs.count == 1, let pid = viewModel.selectedProjectIDs.first, pid == SessionListViewModel.otherProjectId { return true } return false } fileprivate func enforceWorkspaceModeForSelection() { // "All" is forced to Overview if isAllSelection() { if viewModel.projectWorkspaceMode != .overview { viewModel.projectWorkspaceMode = .overview } return } // "Other" is forced to Sessions mode (for managing unassigned sessions) if isOtherSelection() { if viewModel.projectWorkspaceMode != .sessions { viewModel.projectWorkspaceMode = .sessions } return } // Single real project: default to Overview (settings surface) when coming from global/other modes if viewModel.selectedProjectIDs.count == 1, let pid = viewModel.selectedProjectIDs.first, let project = viewModel.projects.first(where: { $0.id == pid }), let dir = project.directory, !dir.isEmpty { guard pendingSelectionID == nil else { return } // If we are already in .tasks mode (e.g. explicitly navigated to a session), respect it. if viewModel.projectWorkspaceMode == .tasks { return } if viewModel.projectWorkspaceMode != .settings { viewModel.projectWorkspaceMode = .settings } } } fileprivate func applyCalendarDefaults( previousMode: ProjectWorkspaceMode?, newMode: ProjectWorkspaceMode, force: Bool = false ) { switch newMode { case .overview: if force || previousMode != .overview { clearCalendarSelection() } case .settings: if viewModel.selectedProjectIDs.count == 1, (force || previousMode != .settings) { clearCalendarSelection() } case .tasks: guard pendingSelectionID == nil else { return } if force || previousMode != .tasks { ensureCalendarShowsToday() } default: break } } private func clearCalendarSelection() { if viewModel.selectedDay != nil || !viewModel.selectedDays.isEmpty { viewModel.setSelectedDay(nil) } } private func ensureCalendarShowsToday() { let cal = Calendar.current let today = cal.startOfDay(for: Date()) var needsUpdate = false if let current = viewModel.selectedDay { if !cal.isDate(current, inSameDayAs: today) { needsUpdate = true } } else { needsUpdate = true } if viewModel.selectedDays.count != 1 || !viewModel.selectedDays.contains(today) { needsUpdate = true } if needsUpdate { viewModel.setSelectedDay(today) } let normalizedMonth = SessionListViewModel.normalizeMonthStart(today) if normalizedMonth != viewModel.sidebarMonthStart { viewModel.setSidebarMonthStart(today) } } func navigationSplitView(geometry: GeometryProxy) -> some View { let sidebarMaxWidth = geometry.size.width * 0.25 _ = storeSidebarHidden _ = storeListHidden let isSingleContentMode: Bool = { switch viewModel.projectWorkspaceMode { case .overview, .agents, .memory, .settings: return true default: return false } }() let splitView: AnyView = { if isSingleContentMode { let v = NavigationSplitView(columnVisibility: $columnVisibility) { sidebarContent(sidebarMaxWidth: sidebarMaxWidth) } detail: { detailColumn } .navigationSplitViewStyle(.prominentDetail) return AnyView(v) } else { let v = NavigationSplitView(columnVisibility: $columnVisibility) { sidebarContent(sidebarMaxWidth: sidebarMaxWidth) } content: { contentColumn } detail: { detailColumn } .navigationSplitViewStyle(.prominentDetail) return AnyView(v) } }() let baseView = splitView .navigationTitle(navigationTitleForSelection()) .onPreferenceChange(ContentView.SidebarWidthPreferenceKey.self) { width in sidebarWidth = width } .onAppear { applyVisibilityFromStorage(animated: false) permissionsManager.restoreAccess() SecurityScopedBookmarks.shared.restoreAllDynamicBookmarks() Task { await permissionsManager.ensureCriticalDirectoriesAccess() } // Restore preferred content column width (sessions list / review tree) if let w = viewModel.windowStateStore.restoreContentColumnWidth() { let clamped = max(360, min(480, w)) if contentColumnIdealWidth != clamped { contentColumnIdealWidth = clamped } } // Restore session selection from previous launch let restored = viewModel.windowStateStore.restoreSessionSelection() if !restored.selectedIDs.isEmpty { selection = restored.selectedIDs selectionPrimaryId = restored.primaryId viewModel.updateSelection(restored.selectedIDs) } // On initial launch, ensure workspace mode matches the current selection. // We dispatch to next runloop to avoid racing with view initialization. DispatchQueue.main.async { enforceWorkspaceModeForSelection() syncListHiddenForWorkspaceMode() applyCalendarDefaults( previousMode: lastWorkspaceMode, newMode: viewModel.projectWorkspaceMode, force: true ) lastWorkspaceMode = viewModel.projectWorkspaceMode } } .onChange(of: selection) { newSelection in // Save session selection whenever it changes viewModel.windowStateStore.saveSessionSelection(selectedIDs: newSelection, primaryId: selectionPrimaryId) viewModel.updateSelection(newSelection) viewModel.scheduleSelectedSessionsRefresh(sessionIds: newSelection) } .onChange(of: selectionPrimaryId) { newPrimaryId in // Save primary ID whenever it changes viewModel.windowStateStore.saveSessionSelection(selectedIDs: selection, primaryId: newPrimaryId) } let viewWithTasks = applyTaskAndChangeModifiers(to: baseView) let viewWithNotifications = applyNotificationModifiers(to: viewWithTasks) let viewWithDialogs = applyDialogsAndAlerts(to: viewWithNotifications) return applyGlobalSearchOverlay(to: viewWithDialogs, geometry: geometry) .background( GlobalFindKeyInterceptor { NotificationCenter.default.post(name: .codMateFocusGlobalSearch, object: nil) } ) .onChange(of: preferences.searchPanelStyle) { newStyle in handleSearchPanelStyleChange(newStyle) } .onChange(of: viewModel.projectWorkspaceMode) { newMode in applyCalendarDefaults(previousMode: lastWorkspaceMode, newMode: newMode) lastWorkspaceMode = newMode syncListHiddenForWorkspaceMode() } .onChange(of: viewModel.selectedProjectIDs) { _ in // Enforce Overview only when the selection truly is All/Other. // Dispatching to the next run loop avoids racing with List(selection:) // rebinds that momentarily emit an empty selection while re-rendering. DispatchQueue.main.async { enforceWorkspaceModeForSelection() syncListHiddenForWorkspaceMode() } } } func applyTaskAndChangeModifiers(to view: V) -> some View { let v1 = view.task { await viewModel.hydrateFromCacheOnLaunch() } let v2 = v1.onChange(of: viewModel.sections) { _ in // Avoid mutating selection while search popover is opening/active to prevent focus loss/auto-dismiss if !shouldBlockAutoSelection { applyPendingSelectionIfNeeded() normalizeSelection() } reconcilePendingEmbeddedRekeys() } let v3 = v2.onChange(of: selection) { newSel in // 当搜索弹出开启时,立即释放并回拉焦点;否则不要在选择变化时强制归一化, // 以免点击空白导致又被选中首项。 if shouldBlockAutoSelection && preferences.searchPanelStyle == .popover { releasePrimaryFirstResponder() DispatchQueue.main.async { [weak globalSearchViewModel] in if isSearchPopoverPresented { globalSearchViewModel?.setFocus(true) } } } let added = newSel.subtracting(lastSelectionSnapshot) if let justAdded = added.first { selectionPrimaryId = justAdded } if let primary = selectionPrimaryId, !newSel.contains(primary) { selectionPrimaryId = newSel.first } lastSelectionSnapshot = newSel } let v4 = v3.onChange(of: viewModel.errorMessage) { message in guard let message else { return } alertState = AlertState(title: "Operation Failed", message: message) viewModel.errorMessage = nil } let v5 = v4.onChange(of: viewModel.pendingEmbeddedProjectNew) { project in guard let project else { return } startEmbeddedNewForProject(project) viewModel.pendingEmbeddedProjectNew = nil } let v6 = v5.toolbar { // Project workspace mode segmented (toolbar leading) — AppKit-backed for icon+text in one segment ToolbarItem(placement: .navigation) { // Only show segmented control for specific projects (not All/Other) if viewModel.selectedProjectIDs.count == 1 && !isAllSelection() && !isOtherSelection() { let items: [SegmentedIconPicker.Item] = [ .init(title: "Overview", systemImage: "chart.bar", tag: .settings), .init(title: "Tasks", systemImage: "checklist", tag: .tasks), .init(title: "Review", systemImage: "doc.text.magnifyingglass", tag: .review), .init(title: "Agents", systemImage: "book.pages", tag: .agents) ] SegmentedIconPicker(items: items, selection: $viewModel.projectWorkspaceMode) .help("Project workspace mode") } else { EmptyView() } } ToolbarItem(placement: .primaryAction) { refreshToolbarContent } } return AnyView(v6) } func applyNotificationModifiers(to view: V) -> some View { view .onReceive(NotificationCenter.default.publisher(for: .codMateResumeSession)) { note in guard let sessionId = note.userInfo?["sessionId"] as? String else { return } let forceEmbedded = note.userInfo?["forceEmbedded"] as? Bool ?? false let profileId = note.userInfo?["profileId"] as? String if let summary = summaryLookup[sessionId] ?? viewModel.sessionSummary(for: sessionId) { resumeFromList(summary, forceEmbedded: forceEmbedded, profileId: profileId) } } .onReceive(NotificationCenter.default.publisher(for: .codMateTerminalSessionsUpdated)) { _ in syncRunningSessionIDsFromManager() } .onReceive(NotificationCenter.default.publisher(for: .codMateFocusSessionSummary)) { note in guard let summary = note.userInfo?["summary"] as? SessionSummary else { return } if let pid = viewModel.projectId(for: summary) { viewModel.setSelectedProject(pid) } else { viewModel.setSelectedProject(SessionListViewModel.otherProjectId) } viewModel.projectWorkspaceMode = .tasks selection = [summary.id] selectionPrimaryId = summary.id runningSessionIDs.insert(summary.id) selectedTerminalKey = summary.id selectedDetailTab = .terminal sessionDetailTabs[summary.id] = .terminal viewModel.clearAwaitingFollowup(summary.id) } .onReceive(NotificationCenter.default.publisher(for: .codMateStartEmbeddedNewProject)) { note in NSLog("📌 [ContentView] Received codMateStartEmbeddedNewProject: %@", note.userInfo ?? [:]) if let pid = note.userInfo?["projectId"] as? String, let project = viewModel.projects.first(where: { $0.id == pid }) { NSLog("📌 [ContentView] Starting embedded New for project id=%@", pid) startEmbeddedNewForProject(project) } else { NSLog("⚠️ [ContentView] Project for embedded New not found; id=%@", note.userInfo?["projectId"] as? String ?? "") } } .onReceive(NotificationCenter.default.publisher(for: .codMateStartEmbeddedNewSession)) { note in guard let sessionId = note.userInfo?[EmbeddedSessionNotification.sessionIdKey] as? String else { return } let source = EmbeddedSessionNotification.decodeSource(from: note.userInfo) if let summary = summaryLookup[sessionId] ?? viewModel.sessionSummary(for: sessionId) { startEmbeddedNew(for: summary, using: source) } } .onReceive(NotificationCenter.default.publisher(for: .codMateOpenNewProject)) { note in handleDockNewProjectRequest(userInfo: note.userInfo) } .onReceive(NotificationCenter.default.publisher(for: .codMateToggleSidebar)) { _ in toggleSidebarVisibility() } .onReceive(NotificationCenter.default.publisher(for: .codMateToggleList)) { _ in toggleListVisibility() } .onReceive(NotificationCenter.default.publisher(for: .codMateFocusGlobalSearch)) { _ in focusGlobalSearchPanel() } .onReceive(NotificationCenter.default.publisher(for: .codMateRefreshRequested)) { note in let kind = RefreshRequest.kind(from: note.userInfo) handleRefreshRequest(kind) } .onReceive(NotificationCenter.default.publisher(for: .codMateGlobalRefresh)) { _ in // Legacy hook: treat as full refresh. handleRefreshRequest(.global) } .onAppear { // Mark ContentView as ready first, so any queued requests can be processed DockOpenCoordinator.shared.markContentViewReady() // Then consume any pending new project request from initial launch applyPendingDockNewProjectIfNeeded() } } private func applyPendingDockNewProjectIfNeeded() { guard let pending = DockOpenCoordinator.shared.consumePendingNewProject() else { return } presentDockNewProject(directory: pending.directory, name: pending.name) } private func handleDockNewProjectRequest(userInfo: [AnyHashable: Any]?) { if let pending = DockOpenCoordinator.shared.consumePendingNewProject() { presentDockNewProject(directory: pending.directory, name: pending.name) return } guard let directory = userInfo?["directory"] as? String else { return } let name = userInfo?["name"] as? String presentDockNewProject(directory: directory, name: name) } private func presentDockNewProject(directory: String, name: String?) { let trimmedDirectory = directory.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedDirectory.isEmpty else { return } let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines) // Using .sheet(item:) so setting prefill directly triggers the sheet with correct data sidebarNewProjectPrefill = ProjectEditorSheet.Prefill( name: (trimmedName?.isEmpty == false) ? trimmedName : nil, directory: trimmedDirectory, trustLevel: nil, overview: nil, profileId: nil, parentId: nil ) } private func handleRefreshRequest(_ kind: RefreshRequestKind) { Task { await viewModel.refreshSidebarStats() } let mode = viewModel.projectWorkspaceMode let shouldRefreshSessions = kind == .global || mode == .tasks || mode == .sessions if shouldRefreshSessions { Task { await viewModel.refreshSessions(force: true) } } switch mode { case .tasks, .sessions: break case .review: reviewRefreshToken &+= 1 case .overview, .settings: if currentSelectedProject() == nil { overviewViewModel.forceRefresh() } else { projectOverviewRefreshToken &+= 1 } case .agents: agentsRefreshToken &+= 1 case .memory: break } } func applyDialogsAndAlerts(to view: V) -> some View { view .confirmationDialog( "Stop running session?", isPresented: Binding( get: { confirmStopState != nil }, set: { if !$0 { confirmStopState = nil } }), titleVisibility: .visible ) { Button("Stop", role: .destructive) { if let st = confirmStopState { stopEmbedded(forKey: st.terminalKey) confirmStopState = nil } } Button("Cancel", role: .cancel) { confirmStopState = nil } } message: { Text( "The embedded terminal appears to be running. Stopping now will terminate the current Codex/Claude task." ) } .confirmationDialog( "Resume in embedded terminal?", isPresented: Binding( get: { pendingTerminalLaunch != nil }, set: { if !$0 { pendingTerminalLaunch = nil } }), presenting: pendingTerminalLaunch?.session ) { session in Button("Resume", role: .none) { startEmbedded(for: session) pendingTerminalLaunch = nil } Button("Cancel", role: .cancel) { pendingTerminalLaunch = nil } } message: { session in Text( "CodMate will launch \(session.source.branding.displayName) inside the built-in terminal to resume “\(session.displayName)”." ) } .alert(item: $alertState) { state in Alert( title: Text(state.title), message: Text(state.message), dismissButton: .default(Text("OK"))) } .alert( "Delete selected sessions?", isPresented: $deleteConfirmationPresented, presenting: Array(selection) ) { ids in Button("Cancel", role: .cancel) {} Button("Move to Trash", role: .destructive) { deleteSelections(ids: ids) } } message: { _ in Text("Session files will be moved to Trash and can be restored in Finder.") } .fileImporter( isPresented: $selectingSessionsRoot, allowedContentTypes: [.folder], allowsMultipleSelection: false ) { result in handleFolderSelection(result: result, update: viewModel.updateSessionsRoot) } } private func handleSearchPanelStyleChange(_ newStyle: GlobalSearchPanelStyle) { switch newStyle { case .popover: clampSearchPopoverSizeIfNeeded() if globalSearchViewModel.shouldShowPanel { isSearchPopoverPresented = true } case .floating: if isSearchPopoverPresented { isSearchPopoverPresented = false } } } } #if os(macOS) import AppKit // Intercepts Command+F at the window level and routes it to global search, // swallowing the event so focused text fields don't consume it first. private struct GlobalFindKeyInterceptor: NSViewRepresentable { var onFind: () -> Void func makeCoordinator() -> Coordinator { Coordinator(onFind: onFind) } func makeNSView(context: Context) -> NSView { let view = NSView(frame: .zero) context.coordinator.installMonitor() return view } func updateNSView(_ nsView: NSView, context: Context) {} final class Coordinator { let onFind: () -> Void var monitor: Any? init(onFind: @escaping () -> Void) { self.onFind = onFind } func installMonitor() { if monitor != nil { return } monitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { [weak self] event in guard let self, event.modifierFlags.contains(.command) else { return event } if let chars = event.charactersIgnoringModifiers?.lowercased(), chars == "f" { self.onFind() return nil // swallow so first responder doesn't handle it } return event } } deinit { if let monitor { NSEvent.removeMonitor(monitor) } } } } #endif ================================================ FILE: views/Content/ContentView+Search.swift ================================================ import SwiftUI #if os(macOS) import AppKit #endif extension ContentView { func applyGlobalSearchOverlay(to view: V, geometry: GeometryProxy) -> some View { view.overlay(alignment: .top) { if preferences.searchPanelStyle == .floating && globalSearchViewModel.shouldShowPanel { let panelWidth = max(360, min(geometry.size.width * 0.55, 640)) GlobalSearchPanel( viewModel: globalSearchViewModel, maxWidth: panelWidth, onSelect: { handleGlobalSearchSelection($0) }, onClose: { dismissGlobalSearchPanel() } ) .frame(maxWidth: .infinity) .padding(.top, 24) .padding(.horizontal, max(24, (geometry.size.width - panelWidth) / 2)) .transition(.move(edge: .top).combined(with: .opacity)) .zIndex(20) } } } func focusGlobalSearchPanel() { if preferences.searchPanelStyle == .popover { // In popover mode, open the popover and set focus with minimal side-effects if !isSearchPopoverPresented { // Block auto-selection during open to prevent list interference shouldBlockAutoSelection = true popoverDismissDisabled = true // Open the popover first so refusal/inactive states can apply before releasing focus isSearchPopoverPresented = true // Next runloop: release current first responder, then focus the popover field DispatchQueue.main.async { [weak globalSearchViewModel] in releasePrimaryFirstResponder() // Delay focus slightly to allow window hierarchy to settle DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { globalSearchViewModel?.setFocus(true) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { globalSearchViewModel?.setFocus(true) } } // Keep interactive dismissal disabled for the entire lifetime; re-enable on close only } return } // Floating mode: handle focus and notifications directly releasePrimaryFirstResponder() globalSearchViewModel.setFocus(true) } func dismissGlobalSearchPanel() { // Re-enable auto-selection when closing shouldBlockAutoSelection = false // In popover mode, state is managed by binding if preferences.searchPanelStyle == .popover { // Just close the popover; the binding setter will handle cleanup if isSearchPopoverPresented { isSearchPopoverPresented = false } return } // Floating mode: handle cleanup directly globalSearchViewModel.dismissPanel() } func handleGlobalSearchSelection(_ result: GlobalSearchResult) { defer { dismissGlobalSearchPanel() } let trimmedTerm = globalSearchViewModel.query.trimmingCharacters(in: .whitespacesAndNewlines) switch result.kind { case .project: guard let project = result.project else { return } highlightProject(project) case .note: guard let note = result.note else { return } // Try to find the session in current view, or by file URL let summary = viewModel.sessionSummary(withId: note.id) ?? viewModel.sessionSummary(forFileURL: result.fileURL) guard let summary else { // If session not found, just highlight the project if let pid = note.projectId, let project = viewModel.projects.first(where: { $0.id == pid }) { highlightProject(project) } return } focusOnSession( summary, explicitProjectId: note.projectId, searchTerm: nil, filterConversation: false ) case .session: guard let summary = result.sessionSummary ?? viewModel.sessionSummary(forFileURL: result.fileURL) else { return } let projectId = viewModel.projectIdForSession(summary.id) focusOnSession( summary, explicitProjectId: projectId, searchTerm: trimmedTerm.isEmpty ? nil : trimmedTerm, filterConversation: true ) case .task: guard let task = result.task else { return } focusOnTask(task) } } private func highlightProject(_ project: Project) { viewModel.clearScopeFilters() viewModel.setSelectedProject(project.id) viewModel.requestProjectExpansion(for: project.id) isListHidden = false } private func focusOnTask(_ task: CodMateTask) { viewModel.clearScopeFilters() viewModel.setSelectedProject(task.projectId) viewModel.requestProjectExpansion(for: task.projectId) if viewModel.projectWorkspaceMode != .tasks { viewModel.projectWorkspaceMode = .tasks } // Set calendar to task's updated date let referenceDate = task.updatedAt let day = Calendar.current.startOfDay(for: referenceDate) viewModel.selectedDay = day viewModel.selectedDays = Set([day]) // Select first session in the task to auto-expand it if let firstSessionId = task.sessionIds.first { pendingSelectionID = firstSessionId applyPendingSelectionIfNeeded() } isListHidden = false selectedDetailTab = .timeline } func focusOnSession( _ summary: SessionSummary, explicitProjectId: String?, searchTerm: String?, filterConversation: Bool ) { viewModel.clearScopeFilters() let projectToApply = explicitProjectId ?? viewModel.projectIdForSession(summary.id) if let pid = projectToApply { viewModel.setSelectedProject(pid) viewModel.requestProjectExpansion(for: pid) if pid == SessionListViewModel.otherProjectId { if viewModel.projectWorkspaceMode != .sessions { viewModel.projectWorkspaceMode = .sessions } } else if viewModel.projectWorkspaceMode != .tasks { viewModel.projectWorkspaceMode = .tasks } } let referenceDate = summary.lastUpdatedAt ?? summary.startedAt let day = Calendar.current.startOfDay(for: referenceDate) viewModel.selectedDay = day viewModel.selectedDays = Set([day]) pendingSelectionID = summary.id applyPendingSelectionIfNeeded() selectedDetailTab = .timeline isListHidden = false if filterConversation, let term = searchTerm, !term.isEmpty { if selectionPrimaryId == summary.id { notifyConversationFilter(sessionId: summary.id, term: term) } else { pendingConversationFilter = (summary.id, term) } } else { pendingConversationFilter = nil } } private func notifyConversationFilter(sessionId: String, term: String) { let info: [String: Any] = ["sessionId": sessionId, "term": term] DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { NotificationCenter.default.post( name: .codMateConversationFilter, object: nil, userInfo: info ) } } func applyPendingSelectionIfNeeded() { if shouldBlockAutoSelection { return } guard let pending = pendingSelectionID else { return } let visibleIDs = viewModel.sections.flatMap { $0.sessions.map(\.id) } guard visibleIDs.contains(pending) else { return } selection = [pending] selectionPrimaryId = pending pendingSelectionID = nil if let filter = pendingConversationFilter, filter.id == pending { notifyConversationFilter(sessionId: filter.id, term: filter.term) pendingConversationFilter = nil } } func clampSearchPopoverSizeIfNeeded() { let clamped = clampedSearchPopoverSize(searchPopoverSize) if abs(clamped.width - searchPopoverSize.width) > .ulpOfOne || abs(clamped.height - searchPopoverSize.height) > .ulpOfOne { searchPopoverSize = clamped } } func clampedSearchPopoverSize(_ size: CGSize) -> CGSize { CGSize( width: min(max(size.width, ContentView.searchPopoverMinSize.width), ContentView.searchPopoverMaxSize.width), height: min(max(size.height, ContentView.searchPopoverMinSize.height), ContentView.searchPopoverMaxSize.height) ) } @MainActor func releasePrimaryFirstResponder() { #if os(macOS) if let window = NSApplication.shared.keyWindow { window.makeFirstResponder(nil) } #endif } } ================================================ FILE: views/Content/ContentView+Sidebar.swift ================================================ import SwiftUI import AppKit extension ContentView { // Clamp and persist content column width (sessions list / review tree) fileprivate func captureContentColumnWidth(_ width: CGFloat) { guard width.isFinite, width > 0 else { return } // Ignore when hidden (width may report 0 or tiny values) if isListHidden { return } let clamped = max(360, min(480, width)) if abs(Double(clamped - contentColumnIdealWidth)) > 0.5 { contentColumnIdealWidth = clamped viewModel.windowStateStore.saveContentColumnWidth(clamped) } } func sidebarContent(sidebarMaxWidth: CGFloat) -> some View { let state = viewModel.sidebarStateSnapshot let digest = makeSidebarDigest(for: state) let isAllSelected = viewModel.selectedProjectIDs.isEmpty let isOtherSelected = viewModel.selectedProjectIDs.count == 1 && viewModel.selectedProjectIDs.first == SessionListViewModel.otherProjectId return EquatableSidebarContainer(key: digest) { SessionNavigationView( state: state, actions: makeSidebarActions(), projectWorkspaceMode: viewModel.projectWorkspaceMode, isAllOrOtherSelected: isAllSelected || isOtherSelected ) { ProjectsListView(onEditProject: { project in presentProjectEditor(for: project) }) .environmentObject(viewModel) } .navigationSplitViewColumnWidth(min: 260, ideal: 260, max: 260) .background( GeometryReader { gr in Color.clear.preference(key: ContentView.SidebarWidthPreferenceKey.self, value: gr.size.width) } ) } .sheet(item: $sidebarNewProjectPrefill) { prefill in ProjectEditorSheet( isPresented: Binding( get: { sidebarNewProjectPrefill != nil }, set: { if !$0 { sidebarNewProjectPrefill = nil } } ), mode: .new, prefill: prefill ) .environmentObject(viewModel) } } // Preference key to read current content column width private struct ContentColumnWidthPreferenceKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } } var listContent: some View { SessionListColumnView( sections: viewModel.sections, selection: guardedListSelectionBinding, sortOrder: $viewModel.sortOrder, isLoading: viewModel.isLoading, isEnriching: viewModel.isEnriching, enrichmentProgress: viewModel.enrichmentProgress, enrichmentTotal: viewModel.enrichmentTotal, onResume: { resumeFromList($0) }, onReveal: { viewModel.reveal(session: $0) }, onDeleteRequest: handleDeleteRequest, onExportMarkdown: exportMarkdownForSession, isRunning: { runningSessionIDs.contains($0.id) }, isUpdating: { viewModel.isActivelyUpdating($0.id) }, isAwaitingFollowup: { viewModel.isAwaitingFollowup($0.id) }, onPrimarySelect: { s in selectionPrimaryId = s.id // If the selected session has a running embedded terminal, switch to it if runningSessionIDs.contains(s.id) { selectedTerminalKey = s.id selectedDetailTab = .terminal sessionDetailTabs[s.id] = .terminal } }, onNewSessionWithTaskContext: newSessionWithTaskContext ) .id(isListHidden ? "list-hidden" : "list-shown") .environmentObject(viewModel) .navigationSplitViewColumnWidth( min: isListHidden ? 0 : 360, ideal: isListHidden ? 0 : contentColumnIdealWidth, max: isListHidden ? 0 : 480 ) .background( GeometryReader { gr in Color.clear.preference(key: ContentColumnWidthPreferenceKey.self, value: gr.size.width) } ) .onPreferenceChange(ContentColumnWidthPreferenceKey.self) { w in captureContentColumnWidth(w) } .allowsHitTesting(listAllowsHitTesting) .refuseFirstResponder(when: isSearchPopoverPresented || shouldBlockAutoSelection || selection.isEmpty) .environment(\.controlActiveState, ((preferences.searchPanelStyle == .popover && (isSearchPopoverPresented || shouldBlockAutoSelection)) || selection.isEmpty) ? .inactive : .active) .padding(.bottom, statusBarReservedHeight) .sheet(item: $viewModel.editingSession, onDismiss: { viewModel.cancelEdits() }) { _ in EditSessionMetaView(viewModel: viewModel) } } // Content column that switches between the session list and a simple placeholder // depending on the current project workspace mode. Use AnyView to unify types. var contentColumn: some View { switch viewModel.projectWorkspaceMode { case .tasks, .sessions: AnyView(listContent) case .review: AnyView(reviewLeftColumn) case .overview, .agents, .memory, .settings: AnyView(listPlaceholderContent) } } // Neutral placeholder for non-session modes to replace the list column. private var listPlaceholderContent: some View { let (title, icon) = placeholderTitleAndIcon(for: viewModel.projectWorkspaceMode) return placeholderSurface(title: title, systemImage: icon) .frame(maxWidth: .infinity, maxHeight: .infinity) .navigationSplitViewColumnWidth( min: isListHidden ? 0 : 360, ideal: isListHidden ? 0 : contentColumnIdealWidth, max: isListHidden ? 0 : 480 ) .background( GeometryReader { gr in Color.clear.preference(key: ContentColumnWidthPreferenceKey.self, value: gr.size.width) } ) .onPreferenceChange(ContentColumnWidthPreferenceKey.self) { w in captureContentColumnWidth(w) } .allowsHitTesting(false) .id(isListHidden ? "list-placeholder-hidden" : "list-placeholder-shown") .padding(.bottom, statusBarReservedHeight) } // Left column for Project Review: Git changes tree/search/commit private var reviewLeftColumn: some View { Group { if let project = currentSelectedProject(), let dir = project.directory, !dir.isEmpty { let ws = dir let stateBinding = Binding( get: { viewModel.projectReviewPanelStates[project.id] ?? ReviewPanelState() }, set: { viewModel.projectReviewPanelStates[project.id] = $0 } ) let vm = projectReviewVM(for: project.id) EquatableGitChangesContainer( key: .init( workingDirectoryPath: ws, projectDirectoryPath: ws, state: stateBinding.wrappedValue, refreshToken: reviewRefreshToken ), workingDirectory: URL(fileURLWithPath: ws, isDirectory: true), projectDirectory: URL(fileURLWithPath: ws, isDirectory: true), presentation: .full, regionLayout: .leftOnly, preferences: viewModel.preferences, onRequestAuthorization: { ensureRepoAccessForProjectReview(directory: ws) }, externalVM: vm, refreshToken: reviewRefreshToken, savedState: stateBinding ) } else { placeholderSurface(title: "Review", systemImage: "doc.text.magnifyingglass") } } .frame(maxWidth: .infinity, maxHeight: .infinity) .navigationSplitViewColumnWidth( min: isListHidden ? 0 : 360, ideal: isListHidden ? 0 : contentColumnIdealWidth, max: isListHidden ? 0 : 480 ) .background( GeometryReader { gr in Color.clear.preference(key: ContentColumnWidthPreferenceKey.self, value: gr.size.width) } ) .onPreferenceChange(ContentColumnWidthPreferenceKey.self) { w in captureContentColumnWidth(w) } .padding(.bottom, statusBarReservedHeight) } private func placeholderTitleAndIcon(for mode: ProjectWorkspaceMode) -> (String, String) { switch mode { case .overview: return ("Overview", "square.grid.2x2") case .agents: return ("Agents", "book.pages") case .memory: return ("Memory", "bookmark") case .settings: return ("Project Settings", "gearshape") case .tasks: return ("", "") // never used case .sessions: return ("", "") // never used case .review: return ("", "") // never used } } private var guardedListSelectionBinding: Binding> { Binding( get: { selection }, set: { newSel in // Swallow selection sets while search popover is opening/active if preferences.searchPanelStyle == .popover && (isSearchPopoverPresented || shouldBlockAutoSelection) { return } selection = newSel } ) } private func makeSidebarDigest(for state: SidebarState) -> SidebarDigest { func hashInt(_ seq: S) -> Int where S.Element == String { var h = Hasher() for s in seq { h.combine(s) } return h.finalize() } func hashIntDates(_ seq: S) -> Int where S.Element == Date { var h = Hasher() for d in seq { h.combine(d.timeIntervalSinceReferenceDate.bitPattern) } return h.finalize() } let projectsIdsHash = hashInt(viewModel.projects.map { $0.id + ("|" + ($0.parentId ?? "")) }) func hashCounts(_ counts: [Int: Int]) -> Int { var h = Hasher() for key in counts.keys.sorted() { h.combine(key) h.combine(counts[key] ?? 0) } return h.finalize() } func hashEnabled(_ set: Set?) -> Int { guard let set else { return -1 } var h = Hasher() for value in set.sorted() { h.combine(value) } return h.finalize() } let selectedProjectsHash = hashInt(state.selectedProjectIDs.sorted()) let selectedDaysHash = hashIntDates(state.selectedDays.sorted()) return SidebarDigest( projectsCount: viewModel.projects.count, projectsIdsHash: projectsIdsHash, totalSessionCount: state.totalSessionCount, selectedProjectsHash: selectedProjectsHash, selectedDaysHash: selectedDaysHash, dateDimensionRaw: state.dateDimension == .created ? 1 : 2, monthStartInterval: state.monthStart.timeIntervalSinceReferenceDate, calendarCountsHash: hashCounts(state.calendarCounts), enabledDaysHash: hashEnabled(state.enabledProjectDays), visibleAllCount: state.visibleAllCount, projectWorkspaceMode: viewModel.projectWorkspaceMode ) } var refreshToolbarContent: some View { HStack(spacing: 12) { if permissionsManager.needsAuthorization { Button { openWindow(id: "settings") } label: { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill").font( .system(size: 14, weight: .medium) ).foregroundStyle(.orange) Text("Grant Access").font(.system(size: 12, weight: .medium)) } } .buttonStyle(.borderedProminent) .tint(.orange) .controlSize(.small) .help("Some directories need authorization to access sessions data") } let enabledSnapshots = viewModel.usageSnapshots.filter { viewModel.preferences.isCLIEnabled($0.key.baseKind) } if enabledSnapshots.isEmpty == false { EquatableUsageContainer( snapshots: enabledSnapshots, preferences: viewModel.preferences, selectedProvider: Binding( get: { selectedUsageProvider }, set: { newValue in if viewModel.preferences.isCLIEnabled(newValue.baseKind) { selectedUsageProvider = newValue } else if let fallback = enabledSnapshots.keys.sorted(by: { $0.rawValue < $1.rawValue }).first { selectedUsageProvider = fallback } } ), onRequestRefresh: { viewModel.requestUsageStatusRefreshThrottled(for: $0) } ) } #if !APPSTORE if viewModel.preferences.isEmbeddedTerminalEnabled { ActiveTerminalSessionsControl( viewModel: viewModel, runningSessionIDs: runningSessionIDs ) } #endif searchToolbarButton ToolbarCircleButton( systemImage: "arrow.clockwise", isActive: viewModel.isEnriching, showProgress: viewModel.isEnriching || viewModel.isLoading, help: "Refresh" ) { NotificationCenter.default.post( name: .codMateRefreshRequested, object: nil, userInfo: RefreshRequest.userInfo(for: .context) ) } .disabled(viewModel.isEnriching || viewModel.isLoading) } .padding(.horizontal, 3) .padding(.vertical, 2) } @ViewBuilder private var searchToolbarButton: some View { let button = ToolbarCircleButton( systemImage: "magnifyingglass", isActive: searchPanelIsActive, activeColor: Color.primary.opacity(0.8), help: "Open global search (⌘F)" ) { // In popover mode, route through dedicated open/close to ensure focus guards if preferences.searchPanelStyle == .popover { if isSearchPopoverPresented { dismissGlobalSearchPanel() } else { focusGlobalSearchPanel() } } else { focusGlobalSearchPanel() } } if preferences.searchPanelStyle == .popover { button.popover(isPresented: searchPopoverBinding, arrowEdge: .top) { GlobalSearchPopoverPanel( viewModel: globalSearchViewModel, size: $searchPopoverSize, minSize: ContentView.searchPopoverMinSize, maxSize: ContentView.searchPopoverMaxSize, onSelect: { handleGlobalSearchSelection($0) }, onClose: { dismissGlobalSearchPanel() } ) .interactiveDismissDisabled(popoverDismissDisabled) } } else { button } } private var searchPopoverBinding: Binding { Binding( get: { isSearchPopoverPresented }, set: { isPresented in isSearchPopoverPresented = isPresented if isPresented { // Opening: clamp size (focus is handled in focusGlobalSearchPanel) clampSearchPopoverSizeIfNeeded() } else { // Closing popover: clean up and re-enable auto-selection shouldBlockAutoSelection = false popoverDismissDisabled = false globalSearchViewModel.dismissPanel() } } ) } private var searchPanelIsActive: Bool { if preferences.searchPanelStyle == .popover { return isSearchPopoverPresented } return globalSearchViewModel.shouldShowPanel } private var listAllowsHitTesting: Bool { guard !isListHidden else { return false } // Block hit testing when popover is presented OR about to be presented if preferences.searchPanelStyle == .popover && (isSearchPopoverPresented || shouldBlockAutoSelection) { return false } return true } } private struct ActiveTerminalSessionsControl: View { let viewModel: SessionListViewModel let runningSessionIDs: Set @State private var showPopover = false @State private var isHovering = false var body: some View { let count = runningSessionIDs.count Button { showPopover.toggle() } label: { ZStack { Image(systemName: "chevron.forward.2") .font(.system(size: 14, weight: .medium)) .foregroundStyle(iconColor) .offset(x: 1) if count > 0 { Text("\(count)") .font(.system(size: 9, weight: .bold, design: .rounded)) .foregroundStyle(.white) .frame(minWidth: 14, minHeight: 14) .background( Circle() .fill(count > 0 ? Color.accentColor : Color.secondary) ) .offset(x: 8, y: -8) } } .frame(width: 14, height: 14) .padding(8) .background( Circle() .fill(backgroundColor) ) .overlay( Circle() .stroke(borderColor, lineWidth: 1) ) .contentShape(Circle()) } .buttonStyle(.plain) .help(count == 0 ? "No active terminal sessions" : "\(count) active terminal session\(count == 1 ? "" : "s")") .onHover { hovering in withAnimation(.easeInOut(duration: 0.15)) { isHovering = hovering } } .popover(isPresented: $showPopover, arrowEdge: .top) { ActiveTerminalSessionsPopover( runningSessionIDs: runningSessionIDs, viewModel: viewModel, isPresented: $showPopover ) } } private var iconColor: Color { return isHovering ? Color.primary : Color.primary.opacity(0.55) } private var backgroundColor: Color { return (isHovering ? Color.primary.opacity(0.12) : Color(nsColor: .separatorColor).opacity(0.18)) } private var borderColor: Color { return Color(nsColor: .separatorColor).opacity(isHovering ? 0.65 : 0.45) } } private struct ActiveTerminalSessionsPopover: View { let runningSessionIDs: Set let viewModel: SessionListViewModel @Binding var isPresented: Bool @Environment(\.colorScheme) private var colorScheme private static let timeFormatter: DateFormatter = { let formatter = DateFormatter() formatter.setLocalizedDateFormatFromTemplate("HH:mm:ss") return formatter }() private var summaryLookup: [SessionSummary.ID: SessionSummary] { Dictionary( uniqueKeysWithValues: viewModel.sections .flatMap(\.sessions) .map { ($0.id, $0) } ) } private var sessions: [(id: String, summary: SessionSummary?)] { runningSessionIDs.map { id in (id: id, summary: summaryLookup[id]) } .sorted { lhs, rhs in let lhsDate = lhs.summary?.lastUpdatedAt ?? lhs.summary?.startedAt ?? Date.distantPast let rhsDate = rhs.summary?.lastUpdatedAt ?? rhs.summary?.startedAt ?? Date.distantPast return lhsDate > rhsDate } } private var listHeight: CGFloat { let rowHeight: CGFloat = 42 let dividerHeight: CGFloat = 1 let count = CGFloat(sessions.count) let dividers = max(count - 1, 0) return count * rowHeight + dividers * dividerHeight + 6 } var body: some View { VStack(alignment: .leading, spacing: 0) { // Sessions list if sessions.isEmpty { VStack(spacing: 12) { Image(systemName: "terminal") .font(.system(size: 32, weight: .light)) .foregroundStyle(.tertiary) Text("No active terminal sessions") .font(.footnote) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity) .padding(.vertical, 32) } else { ScrollView { VStack(spacing: 0) { ForEach(Array(sessions.enumerated()), id: \.element.id) { index, session in SessionRowView( sessionId: session.id, summary: session.summary, viewModel: viewModel, colorScheme: colorScheme, onSelect: { handleSessionSelect(session.id, summary: session.summary) } ) if index < sessions.count - 1 { Divider() .padding(.leading, 28) } } } } .frame(height: listHeight) .padding(.top, 6) } } .frame(width: 320) .padding(16) } private func handleSessionSelect(_ sessionId: String, summary: SessionSummary?) { if let summary = summary { focusSession(summary) } else { NotificationCenter.default.post( name: .codMateResumeSession, object: nil, userInfo: ["sessionId": sessionId] ) } isPresented = false } private func focusSession(_ summary: SessionSummary) { NotificationCenter.default.post( name: .codMateFocusSessionSummary, object: nil, userInfo: ["summary": summary] ) } private struct SessionRowView: View { let sessionId: String let summary: SessionSummary? let viewModel: SessionListViewModel let colorScheme: ColorScheme let onSelect: () -> Void @State private var isHovering = false var body: some View { Button(action: onSelect) { HStack(spacing: 10) { iconView VStack(alignment: .leading, spacing: 2) { Text(displayName) .font(.subheadline.weight(.medium)) .foregroundStyle(.primary) .lineLimit(1) Text("Started \(startTimeText)") .font(.caption) .foregroundStyle(.secondary) } Spacer() Image(systemName: "chevron.right") .font(.system(size: 10, weight: .semibold)) .foregroundStyle(.tertiary) .opacity(isHovering ? 1 : 0) } .padding(.horizontal, 8) .padding(.vertical, 8) .contentShape(Rectangle()) .background( RoundedRectangle(cornerRadius: 6) .fill(isHovering ? Color.primary.opacity(0.06) : Color.clear) ) } .buttonStyle(.plain) .focusable(false) .onHover { hovering in withAnimation(.easeInOut(duration: 0.1)) { isHovering = hovering } } } private var iconView: some View { Group { if let summary = summary { let branding = summary.source.branding if let asset = branding.badgeAssetName { let shouldInvertCodexDark = summary.source.baseKind == .codex && colorScheme == .dark Image(asset) .resizable() .renderingMode(.original) .aspectRatio(contentMode: .fit) .frame(width: 18, height: 18) .modifier(DarkModeInvertModifier(active: shouldInvertCodexDark)) } else { Image(systemName: branding.symbolName) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(branding.iconColor) .frame(width: 18, height: 18) } } else { Image(systemName: "terminal.fill") .font(.system(size: 13, weight: .medium)) .foregroundStyle(Color.secondary) .frame(width: 18, height: 18) } } } private var displayName: String { if let summary = summary { return summary.effectiveTitle } if sessionId.hasPrefix("new-anchor:") { return "New session" } return sessionId } private var startTimeText: String { if let summary = summary { return ActiveTerminalSessionsPopover.timeFormatter.string(from: summary.startedAt) } return "--:--:--" } } } private struct ToolbarCircleButton: View { let systemImage: String var isActive: Bool = false var activeColor: Color? = nil var showProgress: Bool = false var help: String? var action: () -> Void @State private var hovering = false var body: some View { Button(action: action) { ZStack { if showProgress { ProgressView() .progressViewStyle(.circular) .controlSize(.small) } else { Image(systemName: systemImage) .font(.system(size: 16, weight: .medium)) .foregroundStyle(iconColor) } } .frame(width: 14, height: 14) .padding(8) .background( Circle() .fill(backgroundColor) ) .overlay( Circle() .stroke(borderColor, lineWidth: 1) ) .contentShape(Circle()) } .buttonStyle(.plain) .help(help ?? "") .onHover { hover in withAnimation(.easeInOut(duration: 0.15)) { hovering = hover } } } private var iconColor: Color { if isActive, let activeColor { return activeColor } return hovering ? Color.primary : Color.primary.opacity(0.55) } private var backgroundColor: Color { if isActive || showProgress { return Color.primary.opacity(0.08) } return (hovering ? Color.primary.opacity(0.12) : Color(nsColor: .separatorColor).opacity(0.18)) } private var borderColor: Color { return Color(nsColor: .separatorColor).opacity(hovering ? 0.65 : 0.45) } } #if os(macOS) import AppKit // Custom ViewModifier to prevent a view hierarchy from accepting first responder private struct RefuseFirstResponderModifier: ViewModifier { let shouldRefuse: Bool func body(content: Content) -> some View { content.background( RefuseFirstResponderHelper(shouldRefuse: shouldRefuse) ) } } private struct RefuseFirstResponderHelper: NSViewRepresentable { let shouldRefuse: Bool func makeNSView(context: Context) -> RefuseFirstResponderView { let view = RefuseFirstResponderView() view.shouldRefuse = shouldRefuse return view } func updateNSView(_ nsView: RefuseFirstResponderView, context: Context) { nsView.shouldRefuse = shouldRefuse } } private class RefuseFirstResponderView: NSView { var shouldRefuse: Bool = false { didSet { if shouldRefuse != oldValue { // Traverse the view hierarchy and apply refusal to all subviews applyRefusalToHierarchy(shouldRefuse) } } } override var acceptsFirstResponder: Bool { if shouldRefuse { return false } return super.acceptsFirstResponder } private func applyRefusalToHierarchy(_ refuse: Bool) { // Walk up to find the root of the list content var current: NSView? = self.superview while let view = current { if let outlineView = view as? NSOutlineView { outlineView.refusesFirstResponder = refuse if refuse, let window = outlineView.window, window.firstResponder === outlineView { window.makeFirstResponder(nil) } return } // Also check for NSTableView (in case List uses it) if let tableView = view as? NSTableView { tableView.refusesFirstResponder = refuse if refuse, let window = tableView.window, window.firstResponder === tableView { window.makeFirstResponder(nil) } return } current = view.superview } } } extension View { func refuseFirstResponder(when condition: Bool) -> some View { self.modifier(RefuseFirstResponderModifier(shouldRefuse: condition)) } } #endif ================================================ FILE: views/Content/ContentView.swift ================================================ import AppKit import SwiftUI import UniformTypeIdentifiers struct ContentView: View { @ObservedObject var viewModel: SessionListViewModel @ObservedObject var preferences: SessionPreferencesStore @StateObject var permissionsManager = SandboxPermissionsManager.shared @StateObject var statusBarStore = StatusBarLogStore.shared @Environment(\.colorScheme) var colorScheme @Environment(\.openWindow) var openWindow // Stable shared cache for project Review VMs to avoid ephemeral lifetimes // that can lead to ObservedObject referencing deallocated instances during // split-view construction. Using a static store prevents state mutations // during body evaluation and keeps a single VM per project across columns. private static var sharedProjectReviewVMs: [String: GitChangesViewModel] = [:] @State var columnVisibility: NavigationSplitViewVisibility = .all @State var selection = Set() @State var selectionPrimaryId: SessionSummary.ID? = nil @State var lastSelectionSnapshot = Set() @State var isPerformingAction = false @State var deleteConfirmationPresented = false @State var alertState: AlertState? @State var selectingSessionsRoot = false // Track which sessions are running in embedded terminal @State var runningSessionIDs = Set() @State var selectedTerminalKey: SessionSummary.ID? = nil @State var isDetailMaximized = false @State var isListHidden = false @SceneStorage("cm.sidebarHidden") var storeSidebarHidden: Bool = false @SceneStorage("cm.listHidden") var storeListHidden: Bool = false // Persist content column (sessions list / review left pane) preferred width @State var contentColumnIdealWidth: CGFloat = 420 @State var sidebarNewProjectPrefill: ProjectEditorSheet.Prefill? = nil @State var projectEditorTarget: Project? = nil @State var showNewTaskSheet: Bool = false // When starting embedded sessions, record the initial command lines per-session @State var embeddedInitialCommands: [SessionSummary.ID: String] = [:] // Soft-return flag: when true, stopping embedded terminal should not change // sidebar/list expand/collapse; keep overall layout stable. @State var softReturnPending: Bool = false // Confirm stopping a running embedded terminal struct ConfirmStopState: Identifiable { let id = UUID() let sessionId: String let terminalKey: String } @State var confirmStopState: ConfirmStopState? = nil struct PendingTerminalLaunch: Identifiable { let id = UUID() let session: SessionSummary } @State var pendingTerminalLaunch: PendingTerminalLaunch? = nil // Prompt picker state for embedded terminal quick-insert @State var showPromptPicker = false @State var promptQuery = "" // Debounced query to keep filtering cheap on main thread @State var throttledPromptQuery = "" @State var promptDebounceTask: Task? = nil @StateObject var globalSearchViewModel: GlobalSearchViewModel @State var selectedUsageProvider: UsageProviderKind = .codex @State var pendingSelectionID: String? = nil @State var pendingConversationFilter: (id: String, term: String)? = nil @State var isSearchPopoverPresented = false @State var searchPopoverSize: CGSize = ContentView.defaultSearchPopoverSize @State var shouldBlockAutoSelection = false @State var popoverDismissDisabled = false @State var lastWorkspaceMode: ProjectWorkspaceMode? = nil @StateObject var overviewViewModel: AllOverviewViewModel @State var reviewRefreshToken: Int = 0 @State var agentsRefreshToken: Int = 0 @State var projectOverviewRefreshToken: Int = 0 @State var sidebarWidth: CGFloat = 0 // Preference key to read sidebar width struct SidebarWidthPreferenceKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } } static let defaultSearchPopoverSize = CGSize(width: 440, height: 320) static let searchPopoverMinSize = CGSize(width: 380, height: 220) static let searchPopoverMaxSize = CGSize(width: 640, height: 520) // Deprecated: keep for future removal; no longer used for retention. // @State private var projectReviewVMs: [String: GitChangesViewModel] = [:] struct SourcedPrompt: Identifiable, Hashable { let id = UUID() enum Source: Hashable { case project, user, builtin } var prompt: PresetPromptsStore.Prompt var source: Source var label: String { prompt.label } var command: String { prompt.command } // Custom Hashable implementation to hash based on content, not UUID func hash(into hasher: inout Hasher) { hasher.combine(prompt) hasher.combine(source) } static func == (lhs: SourcedPrompt, rhs: SourcedPrompt) -> Bool { lhs.prompt == rhs.prompt && lhs.source == rhs.source } } @State var loadedPrompts: [SourcedPrompt] = [] @State var hoveredPromptKey: String? = nil func promptKey(_ p: SourcedPrompt) -> String { p.command } func canDelete(_ p: SourcedPrompt) -> Bool { true } @State var pendingDelete: SourcedPrompt? = nil // Build highlighted text where matches of `query` are tinted; non-matches use the provided base color func highlightedText(_ text: String, query: String, base: Color = .primary) -> Text { guard !query.isEmpty else { let baseText = Text(text).foregroundColor(base) return baseText } var result = Text("") var searchStart = text.startIndex let end = text.endIndex while searchStart < end, let r = text.range( of: query, options: [.caseInsensitive, .diacriticInsensitive], range: searchStart.. searchStart { let prefix = String(text[searchStart.. [PresetPromptsStore.Prompt] { [] } func makeSidebarActions() -> SidebarActions { SidebarActions( selectAllProjects: { viewModel.setSelectedProject(nil) }, requestNewProject: { // Using .sheet(item:) - set empty prefill to trigger sheet without pre-filled data sidebarNewProjectPrefill = ProjectEditorSheet.Prefill() }, requestNewTask: { showNewTaskSheet = true }, setDateDimension: { viewModel.dateDimension = $0 }, setMonthStart: { viewModel.setSidebarMonthStart($0) }, setSelectedDay: { viewModel.setSelectedDay($0) }, toggleSelectedDay: { viewModel.toggleSelectedDay($0) } ) } enum DetailTab: Hashable { case timeline, review, terminal } // Per-session detail tab state: tracks which tab (timeline/review/terminal) each session is viewing @State var sessionDetailTabs: [SessionSummary.ID: DetailTab] = [:] // Current displayed tab (synced with focused session's state) @State var selectedDetailTab: DetailTab = .timeline // Track pending rekey for embedded New so we can move the PTY to the real new session id struct PendingEmbeddedRekey { let anchorId: String let expectedCwd: String let t0: Date let selectOnSuccess: Bool let projectId: String? } @State var pendingEmbeddedRekeys: [PendingEmbeddedRekey] = [] func makeTerminalFont() -> NSFont { TerminalFontResolver.resolvedFont( name: viewModel.preferences.terminalFontName, size: viewModel.preferences.clampedTerminalFontSize ) } init(viewModel: SessionListViewModel) { self.viewModel = viewModel _preferences = ObservedObject(wrappedValue: viewModel.preferences) _globalSearchViewModel = StateObject( wrappedValue: GlobalSearchViewModel( preferences: viewModel.preferences, sessionListViewModel: viewModel ) ) _overviewViewModel = StateObject( wrappedValue: AllOverviewViewModel(sessionListViewModel: viewModel) ) } var body: some View { GeometryReader { geometry in ZStack { WindowConfigurator { window in MainWindowCoordinator.shared.attach(window) window.identifier = NSUserInterfaceItemIdentifier("CodMateMainWindow") MenuBarController.shared.reapplyVisibilityFromPreferences() } .frame(width: 0, height: 0) ZStack(alignment: .bottomLeading) { navigationSplitView(geometry: geometry) .frame(maxWidth: .infinity, maxHeight: .infinity) // Status bar overlay - positioned to cover only the main content area (content+detail columns) // It should start after the sidebar and span to the right edge if preferences.statusBarVisibility != .hidden { StatusBarOverlayView( store: statusBarStore, preferences: preferences, sidebarInset: 0 ) .frame(width: geometry.size.width - statusBarSidebarInset) .offset(x: statusBarSidebarInset) .frame(height: statusBarReservedHeight, alignment: .bottom) } } .sheet(item: $projectEditorTarget) { target in ProjectEditorSheet( isPresented: Binding( get: { true }, set: { if !$0 { projectEditorTarget = nil } } ), mode: .edit(existing: target) ) .environmentObject(viewModel) } .sheet(isPresented: $showNewTaskSheet) { NewTaskSheet(viewModel: viewModel) } } .onAppear { MainWindowCoordinator.shared.applyMenuVisibility(preferences.systemMenuVisibility) } .onChange(of: preferences.systemMenuVisibility) { newValue in MainWindowCoordinator.shared.applyMenuVisibility(newValue) } } } private var statusBarSidebarInset: CGFloat { // Sidebar inset: check if sidebar is actually visible // NavigationSplitViewVisibility.all means sidebar is visible // .doubleColumn means sidebar is hidden (only content+detail visible) // .detailOnly means only detail is visible (sidebar and content hidden) // Sidebar width is fixed at 260pt according to navigationSplitViewColumnWidth switch columnVisibility { case .all: // Sidebar is visible, offset by sidebar width (dynamic) return sidebarWidth case .doubleColumn: // Sidebar is hidden, no offset needed return 0 case .detailOnly: // Sidebar is hidden, no offset needed return 0 default: // Fallback: use storeSidebarHidden as backup return storeSidebarHidden ? 0 : sidebarWidth } } var statusBarReservedHeight: CGFloat { guard preferences.statusBarVisibility != .hidden else { return 0 } return statusBarStore.isExpanded ? statusBarStore.expandedHeight : statusBarStore.collapsedHeight } // navigationSplitView moved to Content/ContentView+Modifiers.swift // applyTaskAndChangeModifiers moved to Content/ContentView+Modifiers.swift // applyNotificationModifiers moved to Content/ContentView+Modifiers.swift // applyDialogsAndAlerts moved to Content/ContentView+Modifiers.swift // sidebarContent moved to Content/ContentView+Sidebar.swift // listContent moved to Content/ContentView+Sidebar.swift // refreshToolbarContent moved to Content/ContentView+Sidebar.swift // detailColumn moved to Content/ContentView+Detail.swift // mainDetailContent moved to ContentView+MainDetail.swift // detailActionBar moved to ContentView+DetailActionBar.swift // focusedSummary and summaryLookup moved to ContentView+Helpers.swift func normalizeSelection() { let orderedIDs = viewModel.sections.flatMap { $0.sessions.map(\.id) } let validIDs = Set(orderedIDs) let original = selection selection.formIntersection(validIDs) // Don't auto-select first item when blocked (e.g., when search popover is about to open) if selection.isEmpty, let first = orderedIDs.first, !shouldBlockAutoSelection { selection.insert(first) } // Avoid unnecessary churn if nothing changed if selection == original { return } } // Provide a stable GitChangesViewModel per selected project for Review layout func projectReviewVM(for projectId: String) -> GitChangesViewModel { if let existing = ContentView.sharedProjectReviewVMs[projectId] { return existing } let vm = GitChangesViewModel() ContentView.sharedProjectReviewVMs[projectId] = vm return vm } func resumeFromList(_ session: SessionSummary, forceEmbedded: Bool = false, profileId: String? = nil) { selection = [session.id] selectionPrimaryId = session.id if forceEmbedded { startEmbedded(for: session) return } if let pid = profileId, let profile = ExternalTerminalProfileStore.shared.profile(for: pid) { launchResume(for: session, using: session.source, profile: profile) return } if viewModel.preferences.defaultResumeUseEmbeddedTerminal { startEmbedded(for: session) } else { openPreferredExternal(for: session) } } func handleDeleteRequest(_ session: SessionSummary) { if !selection.contains(session.id) { selection = [session.id] } presentDeleteConfirmation() } // exportMarkdownForSession moved to ContentView+Helpers.swift func presentDeleteConfirmation() { guard !selection.isEmpty else { return } deleteConfirmationPresented = true } func deleteSelections(ids: [SessionSummary.ID]) { let summaries = ids.compactMap { summaryLookup[$0] } guard !summaries.isEmpty else { return } deleteConfirmationPresented = false isPerformingAction = true Task { await viewModel.delete(summaries: summaries) await MainActor.run { // Best-effort: stop any embedded terminals for deleted sessions for s in summaries { GhosttySessionManager.shared.removeScrollView(for: s.id) } // Clean up per-session state for deleted sessions for id in ids { sessionDetailTabs.removeValue(forKey: id) embeddedInitialCommands.removeValue(forKey: id) runningSessionIDs.remove(id) } isPerformingAction = false selection.subtract(ids) normalizeSelection() } } } func startEmbedded(for session: SessionSummary, using source: SessionSource? = nil) { let target = source.map { session.overridingSource($0) } ?? session #if APPSTORE openPreferredExternal(for: target) return #else // Ensure cwd authorization under App Sandbox (both shell and CLI modes) let cwd = workingDirectory(for: target) let dirURL = URL(fileURLWithPath: cwd, isDirectory: true) if !AuthorizationHub.shared.canAccessNow(directory: dirURL) { let toolLabel = target.source.baseKind.cliExecutableName let granted = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync( directory: dirURL, purpose: .cliConsoleCwd, message: "Authorize this folder for CLI console to run \(toolLabel)" ) guard granted || AuthorizationHub.shared.canAccessNow(directory: dirURL) else { // Do not start embedded; remain in timeline return } } // Build the default resume commands for this session so TerminalHostView can inject them embeddedInitialCommands[target.id] = viewModel.buildResumeCommands(session: target) runningSessionIDs.insert(target.id) selectedTerminalKey = target.id // Switch detail surface to Terminal when embedded starts selectedDetailTab = .terminal sessionDetailTabs[target.id] = .terminal // User has taken over: clear awaiting follow-up highlight viewModel.clearAwaitingFollowup(target.id) // DISABLED: Ghostty doesn't need slash nudge #endif } func stopEmbedded(forID id: SessionSummary.ID) { // Tear down the embedded terminal view and terminate its child process GhosttySessionManager.shared.removeScrollView(for: id) runningSessionIDs.remove(id) embeddedInitialCommands.removeValue(forKey: id) if selectedTerminalKey == id { selectedTerminalKey = runningSessionIDs.first } // Exit embedded terminal: clear awaiting follow-up highlight viewModel.clearAwaitingFollowup(id) if selectedDetailTab == .terminal { selectedDetailTab = .timeline } // If this stop is triggered by Return to History, do not alter sidebar/list // visibility to keep the view stable. if softReturnPending { softReturnPending = false NotificationCenter.default.post(name: .codMateTerminalSessionsUpdated, object: nil) return } // Default behavior: if no embedded terminals left, restore default columns if runningSessionIDs.isEmpty { isDetailMaximized = false columnVisibility = .all } NotificationCenter.default.post(name: .codMateTerminalSessionsUpdated, object: nil) } func stopEmbedded(forKey key: String) { if summaryLookup[key] != nil { stopEmbedded(forID: key) return } GhosttySessionManager.shared.removeScrollView(for: key) runningSessionIDs.remove(key) embeddedInitialCommands.removeValue(forKey: key) if selectedTerminalKey == key { selectedTerminalKey = runningSessionIDs.first } if selectedDetailTab == .terminal { selectedDetailTab = .timeline } if softReturnPending { softReturnPending = false NotificationCenter.default.post(name: .codMateTerminalSessionsUpdated, object: nil) return } if runningSessionIDs.isEmpty { isDetailMaximized = false columnVisibility = .all } NotificationCenter.default.post(name: .codMateTerminalSessionsUpdated, object: nil) } private func isTerminalLikelyRunning(forID id: SessionSummary.ID) -> Bool { // Multi-layer detection for more accurate running state: // 1. Check if terminal manager reports a running process if GhosttySessionManager.shared.hasRunningProcess(for: id) { return true } // 2. Check if this is a pending new session (anchor awaiting rekey) if pendingEmbeddedRekeys.contains(where: { $0.anchorId == id }) { return true } // 3. Check recent file activity heartbeat (session actively writing) if viewModel.isActivelyUpdating(id) { return true } return false } func requestStopEmbedded(forID id: SessionSummary.ID) { // Always check current running state before showing confirmation let isRunning = isTerminalLikelyRunning(forID: id) if isRunning { // Show confirmation dialog for running sessions confirmStopState = ConfirmStopState(sessionId: id, terminalKey: id) } else { // Directly stop if not running stopEmbedded(forID: id) } } func requestStopEmbedded(forKey key: String) { let isRunning = GhosttySessionManager.shared.hasRunningProcess(for: key) if isRunning { let sessionId = summaryLookup[key]?.id ?? key confirmStopState = ConfirmStopState(sessionId: sessionId, terminalKey: key) } else { stopEmbedded(forKey: key) } } private func shellEscapeForCD(_ path: String) -> String { // Minimal POSIX shell escaping suitable for `cd` arguments return "'" + path.replacingOccurrences(of: "'", with: "'\\''") + "'" } /// Launches a new session using the given anchor and shared task context. /// This regenerates ~/.codmate/tasks/context-.md before launching. func newSessionWithTaskContext( task: CodMateTask, anchor: SessionSummary?, source: SessionSource, profile: ExternalTerminalProfile ) { if let anchor = anchor { // Only support local sessions as anchors for now; remote sessions // cannot reliably access the local ~/.codmate/tasks directory. guard !anchor.isRemote else { return } } Task { let prompt: String let effectiveAnchor: SessionSummary var projectOverride: Project? = nil if let anchor = anchor { guard let workspaceVM = viewModel.workspaceVM else { return } _ = await workspaceVM.syncTaskContext(taskId: task.id) let taskIdString = task.id.uuidString let pathHint = "~/.codmate/tasks/context-\(taskIdString).md" let promptLines: [String] = [ "The shared context for the current Task has been organized and saved to a local file:", pathHint, "", "Before answering this question, if needed, please read this file first to understand the task history and related constraints.", ] prompt = promptLines.joined(separator: "\n") effectiveAnchor = anchor } else { // No anchor, find project and create dummy guard let project = viewModel.projects.first(where: { $0.id == task.projectId }) else { return } projectOverride = project let cwd = project.directory ?? NSHomeDirectory() effectiveAnchor = SessionSummary( id: UUID().uuidString, fileURL: URL(fileURLWithPath: "/dev/null"), fileSizeBytes: 0, startedAt: Date(), endedAt: nil, activeDuration: nil, cliVersion: "", cwd: cwd, originator: "system", instructions: nil, model: nil, approvalPolicy: nil, userMessageCount: 0, assistantMessageCount: 0, toolInvocationCount: 0, responseCounts: [:], turnContextCount: 0, totalTokens: 0, eventCount: 0, lineCount: 0, lastUpdatedAt: Date(), source: source, remotePath: nil ) var lines = ["Task: \(task.title)"] if let desc = task.description, !desc.isEmpty { lines.append("") lines.append(desc) } prompt = lines.joined(separator: "\n") } #if APPSTORE // App Store version does not support embedded terminal, use external terminal flow directly. launchNewSession( for: effectiveAnchor, using: source, profile: profile, initialPrompt: prompt, warpTitle: task.effectiveTitle, projectOverride: projectOverride ) #else if profile.id == "codmate.embedded" { // Run a new session in the embedded terminal and inject Task context as the initial prompt. startEmbeddedNewWithPrompt(anchor: effectiveAnchor, using: source, prompt: prompt, task: task, projectOverride: projectOverride) } else { // Use the selected external terminal configuration launchNewSession( for: effectiveAnchor, using: source, profile: profile, initialPrompt: prompt, warpTitle: task.effectiveTitle, projectOverride: projectOverride ) } #endif if viewModel.preferences.commandCopyNotificationsEnabled { await SystemNotifier.shared.notify( title: "CodMate", body: anchor != nil ? "Command copied. Session starts with shared Task context." : "Command copied. Session starts with Task prompt." ) } } } func startEmbeddedNewWithPrompt( anchor: SessionSummary, using source: SessionSource, prompt: String, task: CodMateTask, projectOverride: Project? = nil ) { selectedDetailTab = .terminal sessionDetailTabs[anchor.id] = .terminal let target = source == anchor.source ? anchor : anchor.overridingSource(source) let cwd = FileManager.default.fileExists(atPath: target.cwd) ? target.cwd : target.fileURL.deletingLastPathComponent().path let commandLines = viewModel.buildEmbeddedNewSessionCommands( session: target, initialPrompt: prompt, projectOverride: projectOverride ) let preclear = "printf '\\033[?1049h\\033[H\\033[2J'" // Use a virtual anchor id to avoid hijacking an existing session's running state let anchorId = "new-anchor:task:\(task.id.uuidString):\(Int(Date().timeIntervalSince1970)))" embeddedInitialCommands[anchorId] = preclear + "\n" + commandLines runningSessionIDs.insert(anchorId) selectedTerminalKey = anchorId sessionDetailTabs[anchorId] = .terminal pendingEmbeddedRekeys.append( PendingEmbeddedRekey( anchorId: anchorId, expectedCwd: canonicalizePath(cwd), t0: Date(), selectOnSuccess: true, projectId: task.projectId ) ) // Event-driven incremental refresh for quick visibility in Tasks/Sessions lists applyIncrementalHint(for: target.source, directory: cwd) scheduleIncrementalRefresh(for: target.source, directory: cwd) selection.removeAll() isDetailMaximized = true columnVisibility = .detailOnly } func workingDirectory(for session: SessionSummary) -> String { viewModel.resolvedWorkingDirectory(for: session) } func preferredExternalTerminalProfile() -> ExternalTerminalProfile? { ExternalTerminalProfileStore.shared.resolvePreferredProfile( id: viewModel.preferences.defaultResumeExternalAppId ) } func projectDirectory(for session: SessionSummary) -> String? { guard let pid = viewModel.projectIdForSession(session.id), let project = viewModel.projects.first(where: { $0.id == pid }), let directory = project.directory, !directory.isEmpty else { return nil } if FileManager.default.fileExists(atPath: directory) { return directory } return directory } func ensureRepoAccessForReview() { guard let focused = focusedSummary else { return } // Non-sandboxed builds don't require bookmark authorization or forced refresh if SecurityScopedBookmarks.shared.isSandboxed == false { return } let dir = workingDirectory(for: focused) let startURL = URL(fileURLWithPath: dir, isDirectory: true) // Resolve repository root by walking up to the nearest folder that contains .git func findRepoRootByFS(from start: URL) -> URL? { let fm = FileManager.default var cur = start.standardizedFileURL var guardCounter = 0 while guardCounter < 200 { // safety guard let gitDir = cur.appendingPathComponent(".git", isDirectory: true) var isDir: ObjCBool = false if fm.fileExists(atPath: gitDir.path, isDirectory: &isDir) { return cur } let parent = cur.deletingLastPathComponent() if parent.path == cur.path { break } cur = parent guardCounter += 1 } return nil } let repoRoot = findRepoRootByFS(from: startURL) ?? startURL // If already authorized for this repo root, just ensure access is active and return silently if SecurityScopedBookmarks.shared.hasDynamicBookmark(for: repoRoot) { _ = SecurityScopedBookmarks.shared.startAccessDynamic(for: repoRoot) return } // Use synchronous authorization to ensure we get the result before proceeding let success = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync( directory: repoRoot, purpose: .gitReviewRepo, message: "Authorize the repository folder (the one containing .git) for Git Review" ) if success { print("[ContentView] Git review authorization successful for: \(repoRoot.path)") // Force a view refresh by toggling away and back to Review Task { @MainActor in let was = selectedDetailTab selectedDetailTab = .timeline try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s selectedDetailTab = was } } else { print("[ContentView] Git review authorization failed or cancelled") } } func ensureRepoAccessForProjectReview(directory: String) { // Non-sandboxed builds don't require bookmark authorization if SecurityScopedBookmarks.shared.isSandboxed == false { return } let startURL = URL(fileURLWithPath: directory, isDirectory: true) func findRepoRootByFS(from start: URL) -> URL? { let fm = FileManager.default var cur = start.standardizedFileURL var guardCounter = 0 while guardCounter < 200 { let gitDir = cur.appendingPathComponent(".git", isDirectory: true) var isDir: ObjCBool = false if fm.fileExists(atPath: gitDir.path, isDirectory: &isDir) { return cur } let parent = cur.deletingLastPathComponent() if parent.path == cur.path { break } cur = parent guardCounter += 1 } return nil } let repoRoot = findRepoRootByFS(from: startURL) ?? startURL if SecurityScopedBookmarks.shared.hasDynamicBookmark(for: repoRoot) { _ = SecurityScopedBookmarks.shared.startAccessDynamic(for: repoRoot) return } let success = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync( directory: repoRoot, purpose: .gitReviewRepo, message: "Authorize the repository folder (the one containing .git) for Git Review" ) if success { print("[ContentView] Project Git review authorization successful for: \(repoRoot.path)") } else { print("[ContentView] Project Git review authorization failed or cancelled") } } // MARK: - Embedded CLI console specs (DEV) private func consoleEnv(for source: SessionSource) -> [String: String] { var env: [String: String] = [:] env["LANG"] = "zh_CN.UTF-8" env["LC_ALL"] = "zh_CN.UTF-8" env["LC_CTYPE"] = "zh_CN.UTF-8" env["TERM"] = "xterm-256color" if source.baseKind == .codex { env["CODEX_DISABLE_COLOR_QUERY"] = "1" } return env } /// Schedule a short-lived incremental refresh loop to surface newly created /// sessions for auto-assign (project / task) matching. Uses a 2s interval /// for up to ~2 minutes, aligned with the PendingAssignIntent lifetime. func startEmbeddedNew(for session: SessionSummary, using source: SessionSource? = nil) { let target = source.map { session.overridingSource($0) } ?? session #if APPSTORE openPreferredExternalForNew(session: target) return #else // Switch detail surface to Terminal tab when launching embedded new selectedDetailTab = .terminal sessionDetailTabs[session.id] = .terminal // Build the 'new session' commands (respecting project profile when present) let cwd = FileManager.default.fileExists(atPath: target.cwd) ? target.cwd : target.fileURL.deletingLastPathComponent().path if viewModel.preferences.useEmbeddedCLIConsole { let dirURL = URL(fileURLWithPath: cwd, isDirectory: true) if !AuthorizationHub.shared.canAccessNow(directory: dirURL) { let granted = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync( directory: dirURL, purpose: .cliConsoleCwd, message: "Authorize this folder for CLI console to run codex" ) guard granted || AuthorizationHub.shared.canAccessNow(directory: dirURL) else { return } } } let commandLines = viewModel.buildEmbeddedNewSessionCommands(session: target) // Enter alternate screen and clear for a truly clean view (cursor home); // avoids reflow artifacts and isolates scrollback while the new session runs. let preclear = "printf '\\033[?1049h\\033[H\\033[2J'" // Use virtual anchor id to avoid hijacking an existing session's running state let anchorId = "new-anchor:detail:\(target.id):\(Int(Date().timeIntervalSince1970)))" embeddedInitialCommands[anchorId] = preclear + "\n" + commandLines runningSessionIDs.insert(anchorId) selectedTerminalKey = anchorId sessionDetailTabs[anchorId] = .terminal // Record pending rekey so that when the new session appears, we can move this PTY to the real id pendingEmbeddedRekeys.append( PendingEmbeddedRekey( anchorId: anchorId, expectedCwd: canonicalizePath(cwd), t0: Date(), selectOnSuccess: true, projectId: viewModel.projectIdForSession(target.id) ) ) // Event-driven incremental refresh: set a hint so directory monitor triggers a targeted refresh applyIncrementalHint(for: target.source, directory: cwd) // Proactively trigger a targeted incremental refresh for immediate visibility scheduleIncrementalRefresh(for: target.source, directory: cwd) // Clear selection so fallbackRunningAnchorId() can display the virtual anchor terminal selection.removeAll() // Ensure terminal is visible isDetailMaximized = true columnVisibility = .detailOnly #endif } func startEmbeddedNewForProject(_ project: Project) { #if APPSTORE NSLog("📌 [ContentView] startEmbeddedNewForProject (APPSTORE fallback) id=%@", project.id) viewModel.newSession(project: project) return #else // Build 'new project' invocation and inject into embedded terminal let dir: String = { let d = (project.directory ?? "").trimmingCharacters(in: .whitespacesAndNewlines) return d.isEmpty ? NSHomeDirectory() : d }() NSLog( "📌 [ContentView] startEmbeddedNewForProject id=%@ dir=%@ useEmbeddedCLIConsole=%@", project.id, dir, viewModel.preferences.useEmbeddedCLIConsole ? "YES" : "NO" ) // Ensure Terminal tab is active so the embedded session is visible selectedDetailTab = .terminal if viewModel.preferences.useEmbeddedCLIConsole { let dirURL = URL(fileURLWithPath: dir, isDirectory: true) if !AuthorizationHub.shared.canAccessNow(directory: dirURL) { let granted = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync( directory: dirURL, purpose: .cliConsoleCwd, message: "Authorize this folder for CLI console to run codex" ) guard granted || AuthorizationHub.shared.canAccessNow(directory: dirURL) else { NSLog("⚠️ [ContentView] Authorization denied for embedded New dir=%@", dir) return } } } let commandLines = viewModel.buildEmbeddedNewProjectCommands(project: project) let preclear = "printf '\\033[?1049h\\033[H\\033[2J'" // Always use a virtual anchor for project-level New let anchorId = "new-anchor:project:\(project.id):\(Int(Date().timeIntervalSince1970)))" NSLog("📌 [ContentView] Embedded New anchor=%@ command=%@", anchorId, commandLines) embeddedInitialCommands[anchorId] = preclear + "\n" + commandLines runningSessionIDs.insert(anchorId) selectedTerminalKey = anchorId sessionDetailTabs[anchorId] = .terminal // Pending rekey: when the new session lands under this cwd, move PTY to the real id pendingEmbeddedRekeys.append( PendingEmbeddedRekey( anchorId: anchorId, expectedCwd: canonicalizePath(dir), t0: Date(), selectOnSuccess: true, projectId: project.id ) ) // Event-driven incremental refresh: scoped to today's Codex folder viewModel.setIncrementalHintForCodexToday() // Proactively refresh today's subset so the new item appears quickly Task { await viewModel.refreshIncrementalForNewCodexToday() // Follow-up probes to catch late file creation try? await Task.sleep(nanoseconds: 600_000_000) await viewModel.refreshIncrementalForNewCodexToday() try? await Task.sleep(nanoseconds: 1_500_000_000) await viewModel.refreshIncrementalForNewCodexToday() } // Clear selection so fallbackRunningAnchorId() can display the virtual anchor terminal selection.removeAll() // Maximize detail to show embedded terminal isDetailMaximized = true columnVisibility = .detailOnly #endif } func openPreferredExternal(for session: SessionSummary, using source: SessionSource? = nil) { let target = source.map { session.overridingSource($0) } ?? session guard let profile = preferredExternalTerminalProfile() else { return } guard viewModel.copyResumeCommandsIfEnabled(session: target, destinationApp: profile) else { return } let dir = workingDirectory(for: target) var didNotify = false if profile.isNone { if viewModel.shouldCopyCommandsToClipboard { if viewModel.preferences.commandCopyNotificationsEnabled { Task { await SystemNotifier.shared.notify( title: "CodMate", body: "Command copied. Paste it in the opened terminal.") } } } return } if profile.usesWarpCommands { viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir) } else if profile.isTerminal { if !viewModel.openInTerminal(session: target) { _ = viewModel.copyResumeCommandsIfEnabled(session: target, destinationApp: profile) _ = viewModel.openAppleTerminal(at: dir) if viewModel.shouldCopyCommandsToClipboard { if viewModel.preferences.commandCopyNotificationsEnabled { Task { await SystemNotifier.shared.notify( title: "CodMate", body: "Command copied. Paste it in the opened terminal.") } didNotify = true } } } } else { let cmd = profile.supportsCommandResolved ? viewModel.buildResumeCLIInvocationRespectingProject(session: target) : nil viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir, command: cmd) } if viewModel.shouldCopyCommandsToClipboard, didNotify == false, viewModel.preferences.commandCopyNotificationsEnabled { Task { await SystemNotifier.shared.notify( title: "CodMate", body: "Command copied. Paste it in the opened terminal.") } } } func openPreferredExternalForNew(session: SessionSummary, initialPrompt: String? = nil) { guard let profile = preferredExternalTerminalProfile() else { return } // Record pending intent for auto-assign before launching viewModel.recordIntentForDetailNew(anchor: session) let dir = workingDirectory(for: session) // Event hint for targeted incremental refresh on FS change applyIncrementalHint(for: session.source, directory: dir) // Also proactively refresh the targeted subset for faster UI update scheduleIncrementalRefresh(for: session.source, directory: dir) guard viewModel.copyNewSessionCommandsIfEnabled( session: session, destinationApp: profile, initialPrompt: initialPrompt ) else { return } if profile.isNone { if viewModel.shouldCopyCommandsToClipboard { if viewModel.preferences.commandCopyNotificationsEnabled { Task { await SystemNotifier.shared.notify( title: "CodMate", body: "Command copied. Paste it in the opened terminal.") } } } return } if profile.usesWarpCommands { // Warp scheme cannot run a command; open path only and rely on clipboard viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir) } else if profile.isTerminal { #if APPSTORE _ = viewModel.openAppleTerminal(at: dir) #else if let prompt = initialPrompt { viewModel.openNewSessionRespectingProject(session: session, initialPrompt: prompt) } else { viewModel.openNewSessionRespectingProject(session: session) } #endif } else { let cmd: String? = { guard profile.supportsCommandResolved else { return nil } if let prompt = initialPrompt { return viewModel.buildNewSessionCLIInvocationRespectingProject( session: session, initialPrompt: prompt ) } return viewModel.buildNewSessionCLIInvocationRespectingProject(session: session) }() viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir, command: cmd) } if viewModel.shouldCopyCommandsToClipboard && viewModel.preferences.commandCopyNotificationsEnabled { Task { await SystemNotifier.shared.notify( title: "CodMate", body: "Command copied. Paste it in the opened terminal.") } } } func startNewSession(for session: SessionSummary, using source: SessionSource? = nil) { let target = source.map { session.overridingSource($0) } ?? session openPreferredExternalForNew(session: target) } func launchNewSession( for session: SessionSummary, using source: SessionSource, profile: ExternalTerminalProfile, initialPrompt: String? = nil, warpTitle: String? = nil, projectOverride: Project? = nil ) { let dir = workingDirectory(for: session) viewModel.launchNewSessionWithProfile( session: session, using: source, profile: profile, workingDirectory: dir, initialPrompt: initialPrompt, warpTitle: warpTitle, projectOverride: projectOverride ) } func launchResume( for session: SessionSummary, using source: SessionSource, profile: ExternalTerminalProfile ) { let target = source == session.source ? session : session.overridingSource(source) let dir = workingDirectory(for: target) guard viewModel.copyResumeCommandsIfEnabled(session: target, destinationApp: profile) else { return } if profile.isNone { if viewModel.shouldCopyCommandsToClipboard { if viewModel.preferences.commandCopyNotificationsEnabled { Task { await SystemNotifier.shared.notify( title: "CodMate", body: "Command copied. Paste it in the opened terminal.") } } } return } if profile.usesWarpCommands { viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir) if viewModel.shouldCopyCommandsToClipboard { if viewModel.preferences.commandCopyNotificationsEnabled { Task { await SystemNotifier.shared.notify( title: "CodMate", body: "Command copied. Paste it in the opened terminal.") } } } return } if profile.isTerminal { if !viewModel.openInTerminal(session: target) { _ = viewModel.copyResumeCommandsIfEnabled(session: target, destinationApp: profile) _ = viewModel.openAppleTerminal(at: dir) if viewModel.shouldCopyCommandsToClipboard { if viewModel.preferences.commandCopyNotificationsEnabled { Task { await SystemNotifier.shared.notify( title: "CodMate", body: "Command copied. Paste it in the opened terminal.") } } } } return } if !profile.supportsCommandResolved { _ = viewModel.copyResumeCommandsIfEnabled(session: target, destinationApp: profile) } let cmd = profile.supportsCommandResolved ? viewModel.buildResumeCLIInvocationRespectingProject(session: target) : nil viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir, command: cmd) if !profile.supportsCommandResolved, viewModel.shouldCopyCommandsToClipboard, viewModel.preferences.commandCopyNotificationsEnabled { Task { await SystemNotifier.shared.notify( title: "CodMate", body: "Command copied. Paste it in the opened terminal.") } } } // moved to ContentView+Helpers.swift private func toggleDetailMaximized() { withAnimation(.easeInOut(duration: 0.18)) { let shouldHide = columnVisibility != .detailOnly columnVisibility = shouldHide ? .detailOnly : .all isDetailMaximized = shouldHide } } func toggleSidebarVisibility() { // Toggle sidebar between shown (.all) and hidden (.doubleColumn). If maximized, restore. withAnimation(.easeInOut(duration: 0.15)) { switch columnVisibility { case .detailOnly: columnVisibility = .all; storeSidebarHidden = false case .all: columnVisibility = .doubleColumn; storeSidebarHidden = true case .doubleColumn: columnVisibility = .all; storeSidebarHidden = false default: columnVisibility = storeSidebarHidden ? .all : .doubleColumn storeSidebarHidden.toggle() } } } func toggleListVisibility() { // Revert to non-animated toggle to keep detail anchored and stable isListHidden.toggle() storeListHidden = isListHidden } func applyVisibilityFromStorage(animated: Bool) { let action = { // Apply list visibility isListHidden = storeListHidden // Apply sidebar visibility when not maximized if columnVisibility != .detailOnly { columnVisibility = storeSidebarHidden ? .doubleColumn : .all } } if animated { withAnimation(.easeInOut(duration: 0.12)) { action() } } else { action() } } @ViewBuilder func maximizeToggleButton() -> some View { let isBothHidden = storeSidebarHidden && isListHidden Button { withAnimation(.easeInOut(duration: 0.15)) { toggleSidebarVisibility() toggleListVisibility() } } label: { Image( systemName: isBothHidden ? "arrow.up.right.and.arrow.down.left" : "arrow.down.left.and.arrow.up.right" ) .imageScale(.medium) } .buttonStyle(.bordered) .controlSize(.regular) .frame(height: 28) .accessibilityLabel(isBothHidden ? "Restore lists" : "Maximize detail") } func handleFolderSelection( result: Result<[URL], Error>, update: @escaping (URL) async -> Void ) { switch result { case .success(let urls): selectingSessionsRoot = false guard let url = urls.first else { return } Task { await update(url) } case .failure(let error): selectingSessionsRoot = false alertState = AlertState( title: "Failed to choose directory", message: error.localizedDescription) } } // Removed: executable chooser handler var placeholder: some View { Group { if #available(macOS 14.0, *) { ContentUnavailableView( "Select a session", systemImage: "rectangle.and.text.magnifyingglass", description: Text("Pick a session from the middle list to view details.") ) } else { UnavailableStateView( "Select a session", systemImage: "rectangle.and.text.magnifyingglass", description: "Pick a session from the middle list to view details.", titleColor: .primary ) } } .frame(maxWidth: .infinity, maxHeight: .infinity) } } // MARK: - Embedded PTY rekey helpers extension ContentView { // canonicalizePath moved to ContentView+Helpers.swift func reconcilePendingEmbeddedRekeys() { guard !pendingEmbeddedRekeys.isEmpty else { return } let all = viewModel.sections.flatMap(\.sessions) let now = Date() var remaining: [PendingEmbeddedRekey] = [] for pending in pendingEmbeddedRekeys { // Window to match nearby creations let windowStart = pending.t0.addingTimeInterval(-2) let windowEnd = pending.t0.addingTimeInterval(120) let candidates = all.filter { s in guard s.id != pending.anchorId else { return false } let canon = canonicalizePath(s.cwd) guard canon == pending.expectedCwd else { return false } return s.startedAt >= windowStart && s.startedAt <= windowEnd } if let winner = candidates.min(by: { abs($0.startedAt.timeIntervalSince(pending.t0)) < abs($1.startedAt.timeIntervalSince(pending.t0)) }) { // DISABLED: rekey not needed for Ghostty if runningSessionIDs.contains(pending.anchorId) { runningSessionIDs.remove(pending.anchorId) runningSessionIDs.insert(winner.id) } if selectedTerminalKey == pending.anchorId { selectedTerminalKey = winner.id } if let savedTab = sessionDetailTabs.removeValue(forKey: pending.anchorId) { sessionDetailTabs[winner.id] = savedTab } else if selectedDetailTab == .terminal && (pending.selectOnSuccess || selection.contains(pending.anchorId)) { sessionDetailTabs[winner.id] = .terminal } if pending.selectOnSuccess || selection.contains(pending.anchorId) { selection = [winner.id] } if let pid = pending.projectId { Task { await viewModel.assignSessions(to: pid, ids: [winner.id]) } } } else { if now.timeIntervalSince(pending.t0) < 180 { remaining.append(pending) } else { // Timeout: stop the anchor terminal to avoid lingering shells GhosttySessionManager.shared.removeScrollView(for: pending.anchorId) runningSessionIDs.remove(pending.anchorId) embeddedInitialCommands.removeValue(forKey: pending.anchorId) sessionDetailTabs.removeValue(forKey: pending.anchorId) if selectedTerminalKey == pending.anchorId { selectedTerminalKey = runningSessionIDs.first } } } } pendingEmbeddedRekeys = remaining } } struct AlertState: Identifiable { let id = UUID() let title: String let message: String } ================================================ FILE: views/Content/StatusBarOverlayView.swift ================================================ import SwiftUI import AppKit struct StatusBarOverlayView: View { @ObservedObject var store: StatusBarLogStore @ObservedObject var preferences: SessionPreferencesStore let sidebarInset: CGFloat @State private var dragStartHeight: CGFloat? = nil @State private var draggedHeight: CGFloat? = nil @State private var filterText: String = "" @State private var filterLevel: StatusBarLogLevel? = nil // nil = All @State private var cachedFilteredEntries: [StatusBarLogEntry] = [] @State private var cacheKey: (filterText: String, filterLevel: StatusBarLogLevel?, entryCount: Int) = ("", nil, 0) @State private var cachedCombinedText: AttributedString? = nil @State private var cachedCombinedTextKey: Int = 0 // Use entry count + last entry ID hash as cache key @State private var cachedEntryCount: Int = 0 // Track cached entry count for incremental updates @State private var cachedFirstEntryId: UUID? = nil // Track first entry ID for incremental update validation @State private var cachedLastEntryId: UUID? = nil // Track last entry ID for incremental update validation private let maxVisibleLines: Int = 160 private let minExpandedHeight: CGFloat = 120 private let maxExpandedHeight: CGFloat = 520 private let maxMessageLength: Int = 5000 // Truncate messages longer than this private let truncationMarker = "… [truncated]" var body: some View { if preferences.statusBarVisibility != .hidden { content .frame(maxHeight: totalHeight, alignment: .bottomLeading) .animation(.none, value: sidebarInset) .onAppear { store.setAutoCollapseEnabled(preferences.statusBarVisibility == .auto) } .onChange(of: preferences.statusBarVisibility) { newValue in store.setAutoCollapseEnabled(newValue == .auto) } .onChange(of: filterText) { _ in invalidateCache() } .onChange(of: filterLevel) { _ in invalidateCache() } .onChange(of: store.entries.count) { _ in invalidateCache() } } } private var totalHeight: CGFloat { if let draggedHeight = draggedHeight { return store.isExpanded ? draggedHeight : store.collapsedHeight } return store.isExpanded ? store.expandedHeight : store.collapsedHeight } private var logListHeight: CGFloat { let effectiveHeight = draggedHeight ?? store.expandedHeight return max(0, effectiveHeight - store.collapsedHeight) } private var content: some View { VStack(spacing: 0) { // Top divider - separates status bar from content above Divider() if store.isExpanded { // Title bar (serves as resize handle) titleBar .frame(height: store.collapsedHeight) .background(Color(nsColor: .windowBackgroundColor)) // Divider between title bar and log content Divider() // Log content logList .frame(height: logListHeight) .frame(maxWidth: .infinity, maxHeight: logListHeight) .background(Color(nsColor: .textBackgroundColor)) } else { // Collapsed state - just show the title bar titleBar .frame(height: store.collapsedHeight) .background(Color(nsColor: .windowBackgroundColor)) } } .frame(maxWidth: .infinity, alignment: .leading) .background(Color(nsColor: .windowBackgroundColor)) .onHover { hovering in store.setInteracting(hovering) } } private var titleBar: some View { HStack(spacing: 8) { statusIcon if store.isExpanded { // Filter menu and search field when expanded filterMenu searchField Spacer(minLength: 8) } else { statusText Spacer(minLength: 8) } // Toggle button on the right Button { withAnimation(.easeInOut(duration: 0.15)) { store.isExpanded.toggle() if store.isExpanded { store.reveal(expanded: false) } } } label: { Image(systemName: "rectangle.bottomthird.inset.filled") .font(.system(size: 13, weight: .semibold)) .frame(width: 18, height: 18) } .buttonStyle(.plain) .padding(.horizontal, 4) .contentShape(Rectangle()) .help(store.isExpanded ? "Hide Debug Area" : "Show Debug Area") } .font(.system(size: 11)) .foregroundStyle(.secondary) .padding(.horizontal, 10) .padding(.vertical, 6) .contentShape(Rectangle()) .gesture( store.isExpanded ? DragGesture(minimumDistance: 2) .onChanged { value in if dragStartHeight == nil { dragStartHeight = store.expandedHeight } let startHeight = dragStartHeight ?? store.expandedHeight let newHeight = startHeight - value.translation.height let clamped = min(max(newHeight, minExpandedHeight), maxExpandedHeight) store.setInteracting(true) draggedHeight = clamped } .onEnded { _ in if let finalHeight = draggedHeight { store.setExpandedHeight(finalHeight) } dragStartHeight = nil draggedHeight = nil store.setInteracting(false) } : nil ) } // Custom NSTextView wrapper with custom context menu and full width private struct LogTextView: NSViewRepresentable { let text: AttributedString let isEmpty: Bool let onCopyAll: () -> Void let onClear: () -> Void let canCopy: Bool let canClear: Bool func makeNSView(context: Context) -> NSScrollView { let scrollView = NSScrollView() scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = false scrollView.borderType = .noBorder scrollView.drawsBackground = false scrollView.autohidesScrollers = true scrollView.scrollerStyle = .overlay // Create text storage and layout manager let textStorage = NSTextStorage() let layoutManager = NSLayoutManager() textStorage.addLayoutManager(layoutManager) let textContainer = NSTextContainer() textContainer.widthTracksTextView = true textContainer.heightTracksTextView = false // Set initial container size (will be updated after scrollView is configured) textContainer.containerSize = NSSize(width: 400, height: CGFloat.greatestFiniteMagnitude) layoutManager.addTextContainer(textContainer) let textView = LogNSTextView(frame: .zero, textContainer: textContainer) textView.coordinator = context.coordinator textView.isEditable = false textView.isSelectable = true textView.isRichText = false // Use plain text to avoid formatting issues textView.drawsBackground = false textView.textContainerInset = NSSize(width: 10, height: 6) textView.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) textView.textColor = .labelColor textView.autoresizingMask = [.width] textView.isVerticallyResizable = true textView.isHorizontallyResizable = false textView.allowsUndo = false // Disable undo to improve performance textView.minSize = NSSize(width: 0, height: 0) textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) // Set initial text (use simple string conversion to avoid crashes) let textString = String(text.characters) let initialString = NSMutableAttributedString(string: textString) initialString.addAttribute(.foregroundColor, value: NSColor.labelColor, range: NSRange(location: 0, length: initialString.length)) initialString.addAttribute(.font, value: NSFont.monospacedSystemFont(ofSize: 11, weight: .regular), range: NSRange(location: 0, length: initialString.length)) textStorage.setAttributedString(initialString) scrollView.documentView = textView context.coordinator.textView = textView context.coordinator.scrollView = scrollView context.coordinator.textStorage = textStorage context.coordinator.lastText = textString // Update container width after scrollView is set up DispatchQueue.main.async { let contentWidth = scrollView.contentSize.width if contentWidth > 0 { let padding: CGFloat = 20 let availableWidth = max(1, contentWidth - padding) textContainer.containerSize = NSSize(width: availableWidth, height: CGFloat.greatestFiniteMagnitude) layoutManager.ensureLayout(for: textContainer) } // Setup scroll observer after scrollView is fully configured context.coordinator.setupScrollObserver() // Initial check - assume at bottom initially context.coordinator.isUserAtBottom = true } return scrollView } func updateNSView(_ nsView: NSScrollView, context: Context) { // Safely check if view is still valid guard nsView.window != nil else { return } guard let textView = nsView.documentView as? LogNSTextView else { return } // Use coordinator's textStorage if available, otherwise fall back to textView's guard let textStorage = context.coordinator.textStorage ?? textView.textStorage else { return } // Check if text actually changed to avoid unnecessary updates let newTextString = String(text.characters) guard context.coordinator.lastText != newTextString else { // Text unchanged, just update menu if needed textView.coordinator = context.coordinator textView.customMenu = makeMenu(coordinator: context.coordinator) return } // Update text - use simple string conversion to avoid crashes // Convert AttributedString to plain NSAttributedString with system colors let simpleString = NSMutableAttributedString(string: newTextString) simpleString.addAttribute(.foregroundColor, value: NSColor.labelColor, range: NSRange(location: 0, length: simpleString.length)) simpleString.addAttribute(.font, value: NSFont.monospacedSystemFont(ofSize: 11, weight: .regular), range: NSRange(location: 0, length: simpleString.length)) let nsAttributedString = simpleString // Safely update text storage (only update if actually changed) textStorage.beginEditing() textStorage.setAttributedString(nsAttributedString) textStorage.endEditing() context.coordinator.lastText = newTextString // Update menu (only once per text change) textView.coordinator = context.coordinator textView.customMenu = makeMenu(coordinator: context.coordinator) // Update text view width (only once, after text update, with debouncing) // Use a small delay to avoid layout loops DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { [weak nsView, weak textView] in guard let scrollView = nsView, let tv = textView, scrollView.window != nil, tv.window != nil, !scrollView.isHidden else { return } let contentWidth = scrollView.contentSize.width guard contentWidth > 0 else { return } let padding: CGFloat = 20 let availableWidth = max(1, contentWidth - padding) if let container = tv.textContainer, let layoutMgr = tv.layoutManager { // Only update if width actually changed to avoid loops let currentWidth = container.containerSize.width if abs(currentWidth - availableWidth) > 1.0 { container.widthTracksTextView = true container.containerSize = NSSize(width: availableWidth, height: CGFloat.greatestFiniteMagnitude) // Force layout update to ensure scrolling works layoutMgr.ensureLayout(for: container) tv.needsDisplay = true } } } // Auto-scroll to bottom if needed (only if user is at bottom, view is visible and not empty) if !isEmpty && !nsView.isHidden && context.coordinator.isUserAtBottom { let coordinator = context.coordinator DispatchQueue.main.async { [weak nsView, weak coordinator] in guard let scrollView = nsView, let tv = scrollView.documentView as? LogNSTextView, tv.window != nil, let coord = coordinator, coord.isUserAtBottom else { return } tv.scrollToEndOfDocument(nil) // Update scroll position after scrolling coord.checkScrollPosition() } } } private func makeMenu(coordinator: Coordinator) -> NSMenu { let menu = NSMenu() // Copy All Logs let copyItem = NSMenuItem( title: "Copy All Logs", action: #selector(Coordinator.copyAll(_:)), keyEquivalent: "" ) copyItem.target = coordinator copyItem.isEnabled = canCopy copyItem.image = NSImage(systemSymbolName: "doc.on.doc", accessibilityDescription: nil) menu.addItem(copyItem) menu.addItem(.separator()) // Clear Logs (destructive action - use red color) let clearItem = NSMenuItem( title: "Clear Logs", action: #selector(Coordinator.clear(_:)), keyEquivalent: "" ) clearItem.target = coordinator clearItem.isEnabled = canClear clearItem.image = NSImage(systemSymbolName: "trash", accessibilityDescription: nil) let attributedTitle = NSMutableAttributedString(string: "Clear Logs") attributedTitle.addAttribute(.foregroundColor, value: NSColor.systemRed, range: NSRange(location: 0, length: attributedTitle.length)) clearItem.attributedTitle = attributedTitle menu.addItem(clearItem) return menu } func makeCoordinator() -> Coordinator { Coordinator(onCopyAll: onCopyAll, onClear: onClear) } class Coordinator: NSObject { let onCopyAll: () -> Void let onClear: () -> Void weak var textView: LogNSTextView? weak var scrollView: NSScrollView? var textStorage: NSTextStorage? var lastText: String = "" var isUserAtBottom: Bool = true // Track if user is at bottom (auto-scroll only when true) var scrollObserver: NSObjectProtocol? init(onCopyAll: @escaping () -> Void, onClear: @escaping () -> Void) { self.onCopyAll = onCopyAll self.onClear = onClear super.init() } deinit { if let observer = scrollObserver { NotificationCenter.default.removeObserver(observer) } } func checkScrollPosition() { guard let scrollView = scrollView, let textView = textView else { return } let clipView = scrollView.contentView let offsetY = clipView.bounds.origin.y let viewportHeight = clipView.bounds.height let contentHeight = textView.bounds.height let maxOffset = max(0, contentHeight - viewportHeight) // Check if user is at bottom (within 10pt threshold) let isAtBottom = abs(offsetY - maxOffset) < 10 isUserAtBottom = isAtBottom } func setupScrollObserver() { guard scrollObserver == nil, let scrollView = scrollView else { return } // Enable bounds change notifications scrollView.contentView.postsBoundsChangedNotifications = true // Observe scroll events to track user scroll position scrollObserver = NotificationCenter.default.addObserver( forName: NSView.boundsDidChangeNotification, object: scrollView.contentView, queue: .main ) { [weak self] _ in self?.checkScrollPosition() } // Also observe live scroll events for more accurate tracking NotificationCenter.default.addObserver( forName: NSScrollView.willStartLiveScrollNotification, object: scrollView, queue: .main ) { [weak self] _ in self?.checkScrollPosition() } NotificationCenter.default.addObserver( forName: NSScrollView.didEndLiveScrollNotification, object: scrollView, queue: .main ) { [weak self] _ in self?.checkScrollPosition() } } @objc func copyAll(_ sender: Any?) { onCopyAll() } @objc func clear(_ sender: Any?) { onClear() } } } // Custom NSTextView that overrides menu to show custom context menu private class LogNSTextView: NSTextView { var coordinator: LogTextView.Coordinator? var customMenu: NSMenu? override func menu(for event: NSEvent) -> NSMenu? { // Return custom menu on right-click if event.type == .rightMouseDown || event.type == .rightMouseUp { // Ensure menu items have valid targets if let menu = customMenu { for item in menu.items { if item.target == nil { item.target = coordinator } } } return customMenu } return super.menu(for: event) } override func becomeFirstResponder() -> Bool { // Allow text selection but prevent editing return super.becomeFirstResponder() } } private func copyAllLogsToClipboard() { let displayEntries = Array(filteredEntries.suffix(maxVisibleLines)) let text = displayEntries.map { entry in let timestamp = timeString(entry.timestamp) let source = entry.source.map { "\($0): " } ?? "" let message = truncateIfNeeded(entry.message) return "\(timestamp) • \(source)\(message)" }.joined(separator: "\n") let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(text, forType: .string) } private var filterMenu: some View { Menu { Button { filterLevel = nil } label: { HStack { Text("All") if filterLevel == nil { Image(systemName: "checkmark") } } } Divider() ForEach(StatusBarLogLevel.allCases) { level in Button { filterLevel = level } label: { HStack { Circle() .fill(levelColor(level)) .frame(width: 6, height: 6) Text(level.rawValue.capitalized) if filterLevel == level { Image(systemName: "checkmark") } } } } } label: { HStack(spacing: 4) { Image(systemName: "line.3.horizontal.decrease.circle") .font(.system(size: 11)) if let level = filterLevel { Circle() .fill(levelColor(level)) .frame(width: 6, height: 6) } } .padding(.horizontal, 6) .padding(.vertical, 3) .background( RoundedRectangle(cornerRadius: 4) .fill(Color(nsColor: .controlBackgroundColor)) ) .overlay( RoundedRectangle(cornerRadius: 4) .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) ) } .menuStyle(.borderlessButton) .frame(height: 20) .help("Filter by level") } private var searchField: some View { HStack(spacing: 4) { TextField("Filter messages", text: $filterText) .textFieldStyle(.plain) .font(.system(size: 11)) .frame(minWidth: 180, maxWidth: 240) if !filterText.isEmpty { Button { filterText = "" } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 10)) .foregroundStyle(.secondary) } .buttonStyle(.plain) } } .padding(.horizontal, 6) .padding(.vertical, 3) .background( RoundedRectangle(cornerRadius: 4) .fill(Color(nsColor: .textBackgroundColor)) ) .overlay( RoundedRectangle(cornerRadius: 4) .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) ) } private var filteredEntries: [StatusBarLogEntry] { let currentKey = (filterText, filterLevel, store.entries.count) // Use cache if filter criteria and entry count haven't changed if cacheKey == currentKey && !cachedFilteredEntries.isEmpty { return cachedFilteredEntries } // Recompute filtered entries let filtered = store.entries.filter { entry in // Filter by level if let filterLevel = filterLevel, entry.level != filterLevel { return false } // Filter by text if filterText.isEmpty { return true } let searchLower = filterText.lowercased() if entry.message.lowercased().contains(searchLower) { return true } if let source = entry.source, source.lowercased().contains(searchLower) { return true } return false } // Update cache cacheKey = currentKey cachedFilteredEntries = filtered return filtered } private var statusIcon: some View { let level = store.entries.last?.level ?? .info let systemName: String switch level { case .info: systemName = store.activeTaskCount > 0 ? "clock.badge.checkmark" : "info.circle" case .success: systemName = "checkmark.circle" case .warning: systemName = "exclamationmark.triangle" case .error: systemName = "xmark.octagon" } return Image(systemName: systemName) .foregroundStyle(levelColor(level)) } private var statusText: some View { let entry = store.entries.last let text = entry?.message ?? "No recent activity" return HStack(spacing: 6) { if let entry { Text(timeString(entry.timestamp)) .foregroundStyle(.secondary) } Text(text) .foregroundStyle(levelColor(entry?.level ?? .info)) .lineLimit(1) .truncationMode(.middle) } } private var logList: some View { let displayEntries = Array(filteredEntries.suffix(maxVisibleLines)) // Compute lightweight cache key: entry count + hash of last entry ID (if exists) // Only use last entry ID hash to keep cache key lightweight (memory optimization) let cacheKeyValue = displayEntries.count * 1000 + (displayEntries.last?.id.hashValue ?? 0) // Use cache if key matches, otherwise build (with incremental update if possible) let combinedText: AttributedString if cacheKeyValue == cachedCombinedTextKey, let cached = cachedCombinedText { combinedText = cached } else { // Try incremental update if we have cached text and only new entries were added if let cached = cachedCombinedText, cachedEntryCount > 0, displayEntries.count > cachedEntryCount, let cachedFirstId = cachedFirstEntryId, let cachedLastId = cachedLastEntryId { // Check if previous entries match by comparing first and last cached entry IDs // This is a lightweight check (memory optimization) - only store 2 UUIDs instead of full list let currentFirstId = displayEntries.first?.id let currentLastCachedId = displayEntries.count > cachedEntryCount ? displayEntries[cachedEntryCount - 1].id : displayEntries.last?.id // If first and last cached entry IDs match, we can safely do incremental update if currentFirstId == cachedFirstId && currentLastCachedId == cachedLastId { // Incremental update: append only new entries (performance optimization) var updated = cached let newEntries = Array(displayEntries.suffix(displayEntries.count - cachedEntryCount)) if !newEntries.isEmpty { updated.append(AttributedString("\n")) for (index, entry) in newEntries.enumerated() { if index > 0 { updated.append(AttributedString("\n")) } // For very long messages, use optimized building updated.append(buildSelectableLogLine(entry)) } } combinedText = updated } else { // Full rebuild needed (entries changed, not just added) combinedText = buildCombinedLogTextOptimized(entries: displayEntries) } } else { // Full rebuild (first time or cache invalidated) combinedText = buildCombinedLogTextOptimized(entries: displayEntries) } // Update cache with lightweight keys (memory optimization) cachedCombinedText = combinedText cachedCombinedTextKey = cacheKeyValue cachedEntryCount = displayEntries.count cachedFirstEntryId = displayEntries.first?.id cachedLastEntryId = displayEntries.last?.id } return LogTextView( text: displayEntries.isEmpty ? AttributedString("No log entries") : combinedText, isEmpty: displayEntries.isEmpty, onCopyAll: { copyAllLogsToClipboard() }, onClear: { store.clear() invalidateCache() }, canCopy: !filteredEntries.isEmpty, canClear: !store.entries.isEmpty ) } /// Invalidate all caches private func invalidateCache() { cacheKey = ("", nil, 0) cachedFilteredEntries = [] cachedCombinedText = nil cachedCombinedTextKey = 0 cachedEntryCount = 0 cachedFirstEntryId = nil cachedLastEntryId = nil } /// Build a combined AttributedString from multiple log entries, separated by newlines /// Optimized version that handles large lists efficiently private func buildCombinedLogTextOptimized(entries: [StatusBarLogEntry]) -> AttributedString { guard !entries.isEmpty else { return AttributedString("") } // For very long lists, build in chunks to avoid blocking UI if entries.count > 100 { var result = AttributedString("") // Build first entry immediately result.append(buildSelectableLogLine(entries[0])) // Build remaining entries for index in 1.. 0 { result.append(AttributedString("\n")) } result.append(buildSelectableLogLine(entry)) } return result } } private func buildSelectableLogLine(_ entry: StatusBarLogEntry) -> AttributedString { var result = AttributedString("") // Timestamp - use system color that adapts to theme var timestamp = AttributedString(timeString(entry.timestamp)) timestamp.font = .system(size: 10, design: .monospaced) // Use a placeholder color that will be replaced by theme-aware color in NSTextView timestamp.foregroundColor = Color.primary.opacity(0.4) result.append(timestamp) result.append(AttributedString(" ")) // Bullet point (using Unicode character instead of Circle view) var bullet = AttributedString("•") bullet.foregroundColor = levelColor(entry.level) result.append(bullet) result.append(AttributedString(" ")) // Source (if present) if let source = entry.source, !source.isEmpty { var sourceText = AttributedString(source) sourceText.font = .system(size: 10, weight: .medium, design: .monospaced) // Use a placeholder color that will be replaced by theme-aware color sourceText.foregroundColor = Color.secondary result.append(sourceText) result.append(AttributedString(": ")) } // Message with truncation for very long messages let message = truncateIfNeeded(entry.message) var messageAttr = highlightedMessage(message) // Apply font and color to the entire message, preserving any existing attributes (like highlight background) messageAttr.font = .system(size: 11, design: .monospaced) messageAttr.foregroundColor = levelColor(entry.level) result.append(messageAttr) return result } /// Truncate message if it exceeds maxMessageLength, keeping head and tail private func truncateIfNeeded(_ message: String) -> String { guard message.count > maxMessageLength else { return message } // Keep head (first 60%) and tail (last 30%), with truncation marker in between let headLength = Int(Double(maxMessageLength) * 0.6) let tailLength = Int(Double(maxMessageLength) * 0.3) let head = String(message.prefix(headLength)) let tail = String(message.suffix(tailLength)) return "\(head)\n\(truncationMarker)\n\(tail)" } private func highlightedMessage(_ message: String) -> AttributedString { guard !filterText.isEmpty else { return AttributedString(message) } var result = AttributedString(message) let searchLower = filterText.lowercased() let messageLower = message.lowercased() var searchStart = messageLower.startIndex var matchCount = 0 let maxMatches = 100 // Limit matches to avoid performance issues while searchStart < messageLower.endIndex, matchCount < maxMatches { guard let range = messageLower[searchStart...].range(of: searchLower) else { break } // Convert String.Index range to AttributedString.Index range let lowerBound = AttributedString.Index(range.lowerBound, within: result) ?? result.startIndex let upperBound = AttributedString.Index(range.upperBound, within: result) ?? result.endIndex let attrRange = lowerBound.. Color { switch level { case .info: return .secondary case .success: return Color.green case .warning: return Color.orange case .error: return Color.red } } private func timeString(_ date: Date) -> String { Self.timeFormatter.string(from: date) } private static let timeFormatter: DateFormatter = { let formatter = DateFormatter() formatter.setLocalizedDateFormatFromTemplate("HH:mm:ss") return formatter }() } ================================================ FILE: views/Controls/CollapseExpandButtonGroup.swift ================================================ import SwiftUI /// Reusable pair of collapse/expand buttons used across Tasks and Review surfaces. struct CollapseExpandButtonGroup: View { var collapseHelp: String = "Collapse All" var expandHelp: String = "Expand All" let onCollapse: () -> Void let onExpand: () -> Void var body: some View { HStack(spacing: 0) { button( systemImage: "arrow.up.right.and.arrow.down.left", help: collapseHelp, action: onCollapse ) button( systemImage: "arrow.down.left.and.arrow.up.right", help: expandHelp, action: onExpand ) } } private func button(systemImage: String, help: String, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: systemImage) .font(.system(size: 12)) .foregroundStyle(.secondary) } .buttonStyle(.plain) .frame(width: 28, height: 28) .background( RoundedRectangle(cornerRadius: 4) .fill(Color.clear) ) .contentShape(Rectangle()) .help(help) } } ================================================ FILE: views/Controls/FontPickerButton.swift ================================================ import SwiftUI import AppKit struct FontPickerButton: View { @Binding var fontName: String @Binding var fontSize: Double @StateObject private var controller = FontPanelController() var body: some View { HStack(spacing: 6) { Spacer(minLength: 0) Text(displayLabel) .font(.system(size: 13)) .lineLimit(1) .truncationMode(.tail) Button(action: openFontPanel) { Image(systemName: "textformat.size") .font(.system(size: 13, weight: .semibold)) } .buttonStyle(.borderless) .help("Choose terminal font and size") } } private func openFontPanel() { controller.present(currentFont: resolvedFont) { newFont in fontName = newFont.fontName fontSize = Double(newFont.pointSize) } } private var resolvedFont: NSFont { TerminalFontResolver.resolvedFont(name: fontName, size: CGFloat(fontSize)) } private var displayLabel: String { let name = resolvedFont.displayName ?? resolvedFont.fontName return String(format: "%@ – %.1f pt", name, fontSize) } } private final class FontPanelController: NSObject, ObservableObject { private var onChange: ((NSFont) -> Void)? private var currentFont: NSFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) func present(currentFont: NSFont, onChange: @escaping (NSFont) -> Void) { self.currentFont = currentFont self.onChange = onChange let manager = NSFontManager.shared manager.target = self manager.action = #selector(changeFont(_:)) manager.setSelectedFont(currentFont, isMultiple: false) manager.orderFrontFontPanel(self) manager.fontPanel(true)?.makeKeyAndOrderFront(self) } @objc private func changeFont(_ sender: NSFontManager) { let converted = sender.convert(currentFont) currentFont = converted onChange?(converted) } } ================================================ FILE: views/Controls/RainbowSpinnerView.swift ================================================ import SwiftUI import AppKit /// CoreAnimation-based rainbow spinner (replaces SwiftUI repeatForever + drawingGroup) /// Uses CAGradientLayer with conic gradient and CABasicAnimation for rotation /// Pauses when window is not key or app is not active to reduce GPU usage struct RainbowSpinnerView: NSViewRepresentable { var spins: Bool = true var size: CGFloat = 18 func makeNSView(context: Context) -> NSView { let containerView = ContainerView(size: size) context.coordinator.containerView = containerView return containerView } func updateNSView(_ nsView: NSView, context: Context) { guard let containerView = nsView as? ContainerView else { return } containerView.setSpinning(spins) // Update size if changed if containerView.frame.size.width != size || containerView.frame.size.height != size { containerView.frame = NSRect(x: 0, y: 0, width: size, height: size) containerView.needsLayout = true } } func makeCoordinator() -> Coordinator { Coordinator() } class Coordinator { var containerView: ContainerView? } /// Container view that manages the CoreAnimation spinner class ContainerView: NSView { private var gradientLayer: CAGradientLayer? private var rotationAnimation: CABasicAnimation? private var isSpinning: Bool = false private let size: CGFloat init(size: CGFloat) { self.size = size super.init(frame: NSRect(x: 0, y: 0, width: size, height: size)) // Delay gradient setup until layout() when bounds are properly set wantsLayer = true layer = CALayer() layer?.backgroundColor = NSColor.clear.cgColor observeAppState() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() observeWindowState() updateAnimationState() } override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview() updateAnimationState() } override func layout() { super.layout() // Setup gradient layer on first layout when bounds are properly set if gradientLayer == nil && bounds.width > 0 && bounds.height > 0 { setupLayers() } // Update gradient frame if it exists if let gradientLayer = gradientLayer { gradientLayer.frame = bounds // Update center cap and separators when bounds change updateGradientSublayers() } } private func updateGradientSublayers() { guard let gradientLayer = gradientLayer else { return } // Update center cap frame if let centerCap = gradientLayer.sublayers?.first(where: { $0.name == "centerCap" }) { centerCap.frame = bounds.insetBy(dx: bounds.width * 0.35, dy: bounds.height * 0.35) centerCap.cornerRadius = centerCap.frame.width / 2 } // Update separator positions if let separators = gradientLayer.sublayers?.filter({ $0.name?.hasPrefix("separator") == true }) { for (index, separator) in separators.enumerated() { let separatorWidth: CGFloat = 1.2 let separatorHeight: CGFloat = bounds.height * 0.15 separator.frame = CGRect( x: (bounds.width - separatorWidth) / 2, y: 0, width: separatorWidth, height: separatorHeight ) separator.position = CGPoint(x: bounds.midX, y: bounds.midY) separator.transform = CATransform3DMakeRotation(CGFloat(index) * .pi / 3, 0, 0, 1) } } } func setSpinning(_ spinning: Bool) { guard isSpinning != spinning else { return } isSpinning = spinning updateAnimationState() } private func setupLayers() { guard bounds.width > 0 && bounds.height > 0 else { return } guard gradientLayer == nil else { return } // Already setup // Create conic gradient layer (rainbow colors) let gradient = CAGradientLayer() gradient.type = .conic gradient.startPoint = CGPoint(x: 0.5, y: 0.5) gradient.endPoint = CGPoint(x: 0.5, y: 0.0) // Rainbow colors: red, orange, yellow, green, blue, purple, red gradient.colors = [ NSColor.red.cgColor, NSColor.orange.cgColor, NSColor.yellow.cgColor, NSColor.green.cgColor, NSColor.blue.cgColor, NSColor.purple.cgColor, NSColor.red.cgColor ] gradient.locations = [0.0, 0.166, 0.333, 0.5, 0.666, 0.833, 1.0] gradient.frame = bounds gradient.cornerRadius = bounds.width / 2 // White center cap let centerCap = CALayer() centerCap.name = "centerCap" centerCap.backgroundColor = NSColor.white.withAlphaComponent(0.92).cgColor centerCap.frame = bounds.insetBy(dx: bounds.width * 0.35, dy: bounds.height * 0.35) centerCap.cornerRadius = centerCap.frame.width / 2 // Thin white separators for i in 0..<6 { let separator = CALayer() separator.name = "separator\(i)" separator.backgroundColor = NSColor.white.withAlphaComponent(0.85).cgColor let separatorWidth: CGFloat = 1.2 let separatorHeight: CGFloat = bounds.height * 0.15 separator.frame = CGRect( x: (bounds.width - separatorWidth) / 2, y: 0, width: separatorWidth, height: separatorHeight ) separator.anchorPoint = CGPoint(x: 0.5, y: 1.0) separator.position = CGPoint(x: bounds.midX, y: bounds.midY) separator.transform = CATransform3DMakeRotation(CGFloat(i) * .pi / 3, 0, 0, 1) gradient.addSublayer(separator) } gradient.addSublayer(centerCap) layer?.addSublayer(gradient) gradientLayer = gradient } private var windowKeyObserver: NSObjectProtocol? private var windowResignObserver: NSObjectProtocol? private func observeAppState() { // App activation state NotificationCenter.default.addObserver( self, selector: #selector(applicationDidBecomeActive), name: NSApplication.didBecomeActiveNotification, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(applicationDidResignActive), name: NSApplication.didResignActiveNotification, object: nil ) } @objc private func applicationDidBecomeActive() { updateAnimationState() } @objc private func applicationDidResignActive() { updateAnimationState() } private func observeWindowState() { guard let window = window else { // Clean up observers if window is nil windowKeyObserver = nil windowResignObserver = nil return } // Window key state changes windowKeyObserver = NotificationCenter.default.addObserver( forName: NSWindow.didBecomeKeyNotification, object: window, queue: .main ) { [weak self] _ in self?.updateAnimationState() } windowResignObserver = NotificationCenter.default.addObserver( forName: NSWindow.didResignKeyNotification, object: window, queue: .main ) { [weak self] _ in self?.updateAnimationState() } } private func updateAnimationState() { guard let gradientLayer = gradientLayer else { return } let shouldAnimate = isSpinning && isViewVisible() if shouldAnimate { if rotationAnimation == nil { let animation = CABasicAnimation(keyPath: "transform.rotation") animation.fromValue = 0 animation.toValue = Double.pi * 2 animation.duration = 1.0 animation.repeatCount = .greatestFiniteMagnitude animation.isRemovedOnCompletion = false gradientLayer.add(animation, forKey: "rotation") rotationAnimation = animation } } else { if rotationAnimation != nil { gradientLayer.removeAnimation(forKey: "rotation") rotationAnimation = nil } } } /// Check if view is visible and should animate /// Returns true only when: /// - View is in window hierarchy /// - Window is visible /// - App is active private func isViewVisible() -> Bool { guard let window = window else { return false } guard window.isVisible else { return false } guard NSApp.isActive else { return false } // Check if view is in the window's view hierarchy // isDescendant(of:) checks if self is a descendant of the parameter // So we check if we are a descendant of the window's content view if let contentView = window.contentView { return self.isDescendant(of: contentView) } // Fallback: check if we have a superview (less precise but works) return superview != nil } deinit { NotificationCenter.default.removeObserver(self) if let keyObserver = windowKeyObserver { NotificationCenter.default.removeObserver(keyObserver) } if let resignObserver = windowResignObserver { NotificationCenter.default.removeObserver(resignObserver) } } } } ================================================ FILE: views/Controls/TableSpacingRemover.swift ================================================ import SwiftUI import AppKit #if os(macOS) /// Introspects the underlying NSTableView used by SwiftUI `Table` /// and forces `intercellSpacing` to zero so vertically drawn /// graph lanes can visually connect between rows. struct TableSpacingRemover: NSViewRepresentable { let rowHeight: CGFloat? final class Coordinator { var applied: Bool = false weak var tableView: NSTableView? } init(rowHeight: CGFloat? = nil) { self.rowHeight = rowHeight } func makeCoordinator() -> Coordinator { Coordinator() } func makeNSView(context: Context) -> NSView { let view = NSView() DispatchQueue.main.async { Self.applySpacingFix( from: view, rowHeight: rowHeight, coordinator: context.coordinator ) } return view } func updateNSView(_ nsView: NSView, context: Context) { // Only attempt once per coordinator / table instance to avoid // repeatedly walking the NSView hierarchy during scroll. guard !context.coordinator.applied else { return } DispatchQueue.main.async { Self.applySpacingFix( from: nsView, rowHeight: rowHeight, coordinator: context.coordinator ) } } private static func applySpacingFix( from view: NSView, rowHeight: CGFloat?, coordinator: Coordinator ) { if coordinator.applied, let tableView = coordinator.tableView, tableView.window != nil { return } guard let tableView = findTableView(from: view) else { return } coordinator.tableView = tableView // Force zero vertical intercell spacing to remove the visible gap // between rows for continuous graph lanes. let spacing = tableView.intercellSpacing if spacing.height != 0 { tableView.intercellSpacing = NSSize(width: spacing.width, height: 0) } // Optionally pin row height and disable automatic height so the // SwiftUI graph cell and the NSTableRowView share the same geometry. if let h = rowHeight { if tableView.rowHeight != h { tableView.rowHeight = h } if tableView.usesAutomaticRowHeights { tableView.usesAutomaticRowHeights = false } } coordinator.applied = true } /// Attempts to locate the NSTableView backing a SwiftUI `Table` /// near the given view by walking up to a root and then scanning /// descendants. This is more robust than relying on /// `enclosingScrollView` alone, since SwiftUI often arranges the /// hosting views as siblings. private static func findTableView(from view: NSView) -> NSTableView? { // First, try the straightforward enclosure path. if let scrollView = view.enclosingScrollView, let tableView = scrollView.documentView as? NSTableView { return tableView } // Otherwise, walk up to the top-most ancestor and search its subtree. var root: NSView = view while let parent = root.superview { root = parent } return findTableView(in: root) } private static func findTableView(in root: NSView) -> NSTableView? { if let tableView = root as? NSTableView { return tableView } for sub in root.subviews { if let tableView = findTableView(in: sub) { return tableView } } return nil } } extension View { /// Removes the default vertical `intercellSpacing` for the /// SwiftUI `Table` in which this view is hosted. func removeTableSpacing(rowHeight: CGFloat? = nil) -> some View { overlay(TableSpacingRemover(rowHeight: rowHeight).frame(width: 0, height: 0)) } } #endif ================================================ FILE: views/ConversationTimelineView.swift ================================================ import AppKit import SwiftUI private let timelineTimeFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "HH:mm:ss" return formatter }() private let timelineRowSpacing: CGFloat = 16 struct ConversationTimelineView: View { let turns: [ConversationTurn] @Binding var expandedTurnIDs: Set let refreshToken: Int var ascending: Bool = false var branding: SessionSourceBranding = SessionSource.codexLocal.branding var allowManualToggle: Bool = true var autoExpandVisible: Bool = false var isActive: Bool = false var nowModeEnabled: Bool = false var onNowModeChange: ((Bool) -> Void)? = nil @State private var scrollView: NSScrollView? @State private var scrollObserver: NSObjectProtocol? @State private var suppressNowModeCallback = false @State private var liveScrollObservers: [NSObjectProtocol] = [] @State private var userScrollActive = false @State private var lastUserScrollTime: TimeInterval = 0 private let userScrollWindow: TimeInterval = 0.35 @State private var stickyTurnID: String? = nil @State private var scrollThrottleTask: Task? = nil @State private var markerHeadFrames: [String: CGRect] = [:] @State private var markerHeadHeight: CGFloat = 0 @State private var viewportHeight: CGFloat = 0 @State private var previewContext: ImagePreviewContext? = nil @State private var timelinePositions: [Int: TimelinePositionData] = [:] // Cached calculations to avoid recomputing on every body call @State private var cachedMarkerOpacities: [String: Double] = [:] // Debounce preference updates @State private var preferenceDebounceTask: Task? = nil @State private var pendingMarkerFrames: [String: CGRect]? = nil var body: some View { timelineContent .onChange(of: turns.map(\.id)) { _, _ in if previewContext != nil { previewContext = nil } // Auto-scroll to bottom when Now mode is enabled and content changes if nowModeEnabled { scrollToBottom() } } .onChange(of: refreshToken) { _, _ in if nowModeEnabled { scrollToBottom() } } .onPreferenceChange(MarkerHeadFramePreferenceKey.self) { frames in // Update immediately for timeline line rendering, but throttle sticky marker updates markerHeadFrames = frames if let height = frames.values.first?.height, height > 0, abs(height - markerHeadHeight) > 0.5 { markerHeadHeight = height } // Throttle sticky marker and opacity updates pendingMarkerFrames = frames preferenceDebounceTask?.cancel() preferenceDebounceTask = Task { @MainActor in try? await Task.sleep(nanoseconds: 16_666_667) // ~60fps debounce guard !Task.isCancelled, let frames = pendingMarkerFrames else { return } pendingMarkerFrames = nil updateStickyTurnID(using: frames) updateMarkerOpacities() } } .onPreferenceChange(TimelinePositionPreferenceKey.self) { positions in // Update immediately for timeline line rendering timelinePositions = positions } .onChange(of: nowModeEnabled) { _, isEnabled in // Scroll to bottom when user explicitly enables Now mode if isEnabled { scrollToBottom() } } .onChange(of: stickyTurnID) { _, _ in updateMarkerOpacities() } .onChange(of: markerHeadFrames) { _, _ in updateMarkerOpacities() } .onAppear { updateMarkerOpacities() } .onDisappear { scrollThrottleTask?.cancel() preferenceDebounceTask?.cancel() removeScrollObservers() } } @ViewBuilder private var timelineContent: some View { let topPadding: CGFloat = turns.isEmpty ? 8 : 0 // Recompute positions and turnsByID on each render to ensure correctness let positions = Dictionary(uniqueKeysWithValues: turns.enumerated().map { index, turn in let pos = ascending ? (index + 1) : (turns.count - index) return (turn.id, pos) }) let turnsByID = Dictionary(uniqueKeysWithValues: turns.map { ($0.id, $0) }) ZStack { ScrollViewReader { proxy in ScrollView { ScrollViewAccessor { sv in attachScrollView(sv) } .frame(width: 0, height: 0) timelineRowsList(topPadding: topPadding) .padding(.horizontal, 12) .padding(.top, topPadding) .padding(.bottom, 8) } .coordinateSpace(name: "timelineScroll") .background(alignment: .topLeading) { timelineVerticalLine } .overlay(alignment: .topLeading) { stickyMarkerOverlay(positions: positions, turnsByID: turnsByID, topPadding: topPadding, proxy: proxy) } } ImagePreviewOverlay(context: $previewContext) } } @ViewBuilder private func timelineRowsList(topPadding: CGFloat) -> some View { LazyVStack(alignment: .leading, spacing: timelineRowSpacing) { ForEach(Array(turns.enumerated()), id: \.element.id) { index, turn in timelineRow(for: turn, at: index) } } } private func timelineRow(for turn: ConversationTurn, at index: Int) -> some View { // Always compute position directly from index to avoid cache staleness let pos = ascending ? (index + 1) : (turns.count - index) let markerOpacity = cachedMarkerOpacities[turn.id] ?? 1.0 let isExpanded = expandedTurnIDs.contains(turn.id) let isFirst = index == turns.startIndex let isLast = index == turns.count - 1 // Use EquatableView to prevent unnecessary re-renders, but ensure position is always correct return EquatableView(content: ConversationTurnRow( turn: turn, position: pos, isFirst: isFirst, isLast: isLast, markerOpacity: markerOpacity, isExpanded: isExpanded, branding: branding, allowToggle: allowManualToggle, autoExpandVisible: autoExpandVisible, toggleExpanded: { toggle(turn) }, onSelectAttachment: { attachments, index in previewContext = ImagePreviewContext(attachments: attachments, index: index) } ) ) .id("\(turn.id)-\(pos)") // Include position in ID to force update when position changes } @ViewBuilder private func stickyMarkerOverlay(positions: [String: Int], turnsByID: [String: ConversationTurn], topPadding: CGFloat, proxy: ScrollViewProxy) -> some View { if let stickyTurnID, let turn = turnsByID[stickyTurnID], let position = positions[stickyTurnID] { let extraLineHeight = extraStickyLineHeight() HStack(alignment: .top, spacing: 8) { StickyTimelineMarker( position: position, timeText: timelineTimeFormatter.string(from: turn.timestamp), isActive: isActive, extraLineHeight: extraLineHeight ) .contentShape(Rectangle()) .onTapGesture { withAnimation(.easeInOut(duration: 0.2)) { proxy.scrollTo(stickyTurnID, anchor: .top) } } .hoverHand() Spacer(minLength: 0) } .padding(.leading, 12) .padding(.top, topPadding) } } @ViewBuilder private var timelineVerticalLine: some View { // Draw vertical timeline line (behind all markers) // Always recompute to ensure connection lines are visible let lineParams = calculateTimelineLineParams() if let lineParams = lineParams { let segments = timelineLineSegments(for: lineParams) ZStack(alignment: .topLeading) { ForEach(segments) { segment in Rectangle() .fill(Color.secondary.opacity(0.5)) .frame(width: 1, height: segment.height) .offset(x: lineParams.x - 1, y: segment.minY) } } .animation(nil, value: timelinePositions) } } private func calculateTimelineLineParams() -> (x: CGFloat, y: CGFloat, height: CGFloat)? { guard !timelinePositions.isEmpty, let firstPos = timelinePositions.keys.min(), let lastPos = timelinePositions.keys.max(), let firstData = timelinePositions[firstPos], let lastData = timelinePositions[lastPos] else { return nil } let hasValidMarkerData = firstData.markerCenterX != 0 || firstData.markerCenterY != 0 let hasValidCardData = lastData.messageBoxBottomY != 0 guard hasValidMarkerData && hasValidCardData else { return nil } let lineX = firstData.markerCenterX var lineTop = firstData.markerCenterY // Limit line top to sticky marker bottom when sticky marker is visible if stickyTurnID != nil && markerHeadHeight > 0 { let topPadding: CGFloat = turns.isEmpty ? 8 : 0 lineTop = max(lineTop, markerHeadHeight + topPadding) } let lineBottom = lastData.messageBoxBottomY let lineHeight = lineBottom - lineTop guard lineHeight > 0 else { return nil } return (x: lineX, y: lineTop, height: lineHeight) } private func timelineLineGaps( for lineParams: (x: CGFloat, y: CGFloat, height: CGFloat) ) -> [TimelineLineGap] { let lineMinY = lineParams.y let lineMaxY = lineParams.y + lineParams.height let topPadding: CGFloat = timelineRowSpacing let bottomPadding: CGFloat = 4 var ranges = markerHeadFrames.values.compactMap { frame -> TimelineLineRange? in let minY = max(lineMinY, frame.minY - topPadding) let maxY = min(lineMaxY, frame.maxY + bottomPadding) guard maxY > minY else { return nil } return TimelineLineRange(minY: minY, maxY: maxY) } ranges.sort { $0.minY < $1.minY } var merged: [TimelineLineRange] = [] for range in ranges { if let last = merged.last, range.minY <= last.maxY + 1 { merged[merged.count - 1] = TimelineLineRange(minY: last.minY, maxY: max(last.maxY, range.maxY)) } else { merged.append(range) } } return merged.enumerated().map { index, range in TimelineLineGap(id: index, minY: range.minY, maxY: range.maxY) } } private func timelineLineSegments( for lineParams: (x: CGFloat, y: CGFloat, height: CGFloat) ) -> [TimelineLineSegment] { let lineMinY = lineParams.y let lineMaxY = lineParams.y + lineParams.height let gaps = timelineLineGaps(for: lineParams) var segments: [TimelineLineSegment] = [] var currentY = lineMinY for gap in gaps { if gap.minY > currentY { segments.append(TimelineLineSegment(minY: currentY, maxY: gap.minY)) } currentY = max(currentY, gap.maxY) } if lineMaxY > currentY { segments.append(TimelineLineSegment(minY: currentY, maxY: lineMaxY)) } return segments } private func attachScrollView(_ sv: NSScrollView) { guard scrollView !== sv else { return } scrollView = sv sv.contentView.postsBoundsChangedNotifications = true removeScrollObservers() scrollObserver = NotificationCenter.default.addObserver( forName: NSView.boundsDidChangeNotification, object: sv.contentView, queue: .main ) { [weak sv] _ in guard sv != nil else { return } Task { @MainActor in self.didScroll() } } let startObserver = NotificationCenter.default.addObserver( forName: NSScrollView.willStartLiveScrollNotification, object: sv, queue: .main ) { _ in userScrollActive = true markUserScrollActivity() } let liveObserver = NotificationCenter.default.addObserver( forName: NSScrollView.didLiveScrollNotification, object: sv, queue: .main ) { _ in markUserScrollActivity() } let endObserver = NotificationCenter.default.addObserver( forName: NSScrollView.didEndLiveScrollNotification, object: sv, queue: .main ) { _ in userScrollActive = false markUserScrollActivity() } liveScrollObservers = [startObserver, liveObserver, endObserver] // Initialize scroll position without disabling an explicitly enabled Now mode. DispatchQueue.main.async { if self.nowModeEnabled { self.scrollToBottom() } else { self.didScroll() } } } @MainActor private func didScroll() { guard scrollView != nil else { return } if suppressNowModeCallback { return } // Throttle scroll updates to ~60fps (16.67ms) scrollThrottleTask?.cancel() scrollThrottleTask = Task { @MainActor in try? await Task.sleep(nanoseconds: 16_666_667) // ~60fps guard !Task.isCancelled else { return } performScrollUpdate() } } @MainActor private func performScrollUpdate() { guard let scrollView else { return } if suppressNowModeCallback { return } let offsetY = scrollView.contentView.bounds.origin.y let viewportHeight = scrollView.contentView.bounds.height let contentHeight = scrollView.documentView?.bounds.height ?? 0 let maxOffset = max(0, contentHeight - viewportHeight) let isAtBottom = abs(offsetY - maxOffset) < 10 // 10pt threshold if abs(viewportHeight - self.viewportHeight) > 0.5 { self.viewportHeight = viewportHeight } let now = Date().timeIntervalSinceReferenceDate let userInitiated = userScrollActive || (now - lastUserScrollTime) < userScrollWindow guard userInitiated else { return } if nowModeEnabled && !isAtBottom { onNowModeChange?(false) } else if !nowModeEnabled && isAtBottom { onNowModeChange?(true) } } private func scrollToBottom() { guard let scrollView else { return } let viewport = scrollView.contentView.bounds.height let contentHeight = scrollView.documentView?.bounds.height ?? 0 let maxOffset = max(0, contentHeight - viewport) suppressNowModeCallback = true scrollView.contentView.scroll(to: NSPoint(x: 0, y: maxOffset)) scrollView.reflectScrolledClipView(scrollView.contentView) DispatchQueue.main.async { self.suppressNowModeCallback = false // Trigger sticky marker update after scroll completes self.updateStickyTurnID(using: self.markerHeadFrames) } } private func markUserScrollActivity() { lastUserScrollTime = Date().timeIntervalSinceReferenceDate } private func removeScrollObservers() { if let observer = scrollObserver { NotificationCenter.default.removeObserver(observer) scrollObserver = nil } if !liveScrollObservers.isEmpty { for observer in liveScrollObservers { NotificationCenter.default.removeObserver(observer) } liveScrollObservers.removeAll() } } // Cached sticky turn ID calculation to avoid recomputing on every scroll @State private var lastStickyFramesHash: Int = 0 private func updateStickyTurnID(using frames: [String: CGRect]) { guard !frames.isEmpty else { if stickyTurnID != nil { stickyTurnID = nil lastStickyFramesHash = 0 } return } // Quick hash check to avoid unnecessary computation let currentHash = frames.keys.sorted().joined().hashValue guard currentHash != lastStickyFramesHash else { return } lastStickyFramesHash = currentHash // Find all markers that have scrolled past the top (minY <= 0) let scrolledPast = frames.filter { $0.value.minY <= 0 } if let topmost = scrolledPast.max(by: { $0.value.minY < $1.value.minY }) { // Use the marker closest to the top (highest minY among those <= 0) if topmost.key != stickyTurnID { stickyTurnID = topmost.key } } else { // No marker has scrolled past the top, use the first visible one if let first = frames.min(by: { $0.value.minY < $1.value.minY }) { if first.key != stickyTurnID { stickyTurnID = first.key } } } } private func extraStickyLineHeight() -> CGFloat { guard viewportHeight > 0, markerHeadHeight > 0 else { return 0 } let nextVisibleHeadMinY = markerHeadFrames.values .filter { $0.minY > 0 && $0.minY < viewportHeight } .map { $0.minY } .min() guard nextVisibleHeadMinY == nil else { return 0 } return max(0, viewportHeight - markerHeadHeight) } private func toggle(_ turn: ConversationTurn) { guard allowManualToggle else { return } if expandedTurnIDs.contains(turn.id) { expandedTurnIDs.remove(turn.id) } else { expandedTurnIDs.insert(turn.id) } } // Update cached marker opacities when stickyTurnID or markerHeadFrames change private func updateMarkerOpacities() { var newOpacities: [String: Double] = [:] for turn in turns { let opacity = computeMarkerOpacity(for: turn.id) newOpacities[turn.id] = opacity } cachedMarkerOpacities = newOpacities } // Compute marker opacity (extracted from markerOpacity method for caching) private func computeMarkerOpacity(for id: String) -> Double { guard id == stickyTurnID else { return 1 } guard let frame = markerHeadFrames[id], frame.height > 0 else { return 1 } let minY = frame.minY if minY <= 0 { return 0 } if minY >= frame.height { return 1 } return Double(minY / frame.height) } // Keep original method for backward compatibility (now uses cache) private func markerOpacity(for id: String) -> Double { return cachedMarkerOpacities[id] ?? 1.0 } } private struct TimelineLineRange { let minY: CGFloat let maxY: CGFloat } private struct TimelineLineGap: Identifiable { let id: Int let minY: CGFloat let maxY: CGFloat var height: CGFloat { maxY - minY } } private struct TimelineLineSegment: Identifiable { let id = UUID() let minY: CGFloat let maxY: CGFloat var height: CGFloat { maxY - minY } } // ScrollViewAccessor to get the underlying NSScrollView private struct ScrollViewAccessor: NSViewRepresentable { let onScrollViewAvailable: (NSScrollView) -> Void func makeNSView(context: Context) -> NSView { let view = NSView() DispatchQueue.main.async { if let scrollView = view.enclosingScrollView { onScrollViewAvailable(scrollView) } } return view } func updateNSView(_ nsView: NSView, context: Context) {} } private struct MarkerHeadFramePreferenceKey: PreferenceKey { static var defaultValue: [String: CGRect] = [:] static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) { value.merge(nextValue(), uniquingKeysWith: { $1 }) } } private struct TimelinePositionData: Equatable { var markerCenterX: CGFloat = 0 var markerCenterY: CGFloat = 0 var messageBoxBottomY: CGFloat = 0 } private struct TimelinePositionPreferenceKey: PreferenceKey { static var defaultValue: [Int: TimelinePositionData] = [:] static func reduce(value: inout [Int: TimelinePositionData], nextValue: () -> [Int: TimelinePositionData]) { value.merge(nextValue(), uniquingKeysWith: { existing, new in var merged = existing if new.markerCenterX != 0 { merged.markerCenterX = new.markerCenterX } if new.markerCenterY != 0 { merged.markerCenterY = new.markerCenterY } if new.messageBoxBottomY != 0 { merged.messageBoxBottomY = new.messageBoxBottomY } return merged }) } } private struct ConversationTurnRow: View, Equatable { let turn: ConversationTurn let position: Int let isFirst: Bool let isLast: Bool let markerOpacity: Double let isExpanded: Bool let branding: SessionSourceBranding let allowToggle: Bool let autoExpandVisible: Bool let toggleExpanded: () -> Void let onSelectAttachment: ([TimelineAttachment], Int) -> Void @State private var isVisible = false static func == (lhs: ConversationTurnRow, rhs: ConversationTurnRow) -> Bool { // Always return false if position changes to force layout update guard lhs.position == rhs.position else { return false } return lhs.turn.id == rhs.turn.id && lhs.isFirst == rhs.isFirst && lhs.isLast == rhs.isLast && abs(lhs.markerOpacity - rhs.markerOpacity) < 0.01 && lhs.isExpanded == rhs.isExpanded && lhs.branding.providerKind == rhs.branding.providerKind && lhs.allowToggle == rhs.allowToggle && lhs.autoExpandVisible == rhs.autoExpandVisible } var body: some View { let expanded = autoExpandVisible ? isVisible : isExpanded HStack(alignment: .top, spacing: 8) { TimelineMarker( position: position, timeText: timelineTimeFormatter.string(from: turn.timestamp), isFirst: isFirst, isLast: isLast, frameKeyID: turn.id, reportPosition: position ) .opacity(markerOpacity) ConversationCard( turn: turn, isExpanded: expanded, branding: branding, allowToggle: allowToggle, toggle: toggleExpanded, onSelectAttachment: onSelectAttachment, reportPosition: position ) .frame(maxWidth: .infinity, alignment: .leading) } .onAppear { if autoExpandVisible { isVisible = true } } .onDisappear { if autoExpandVisible { isVisible = false } } .onChange(of: autoExpandVisible) { _, newValue in if !newValue { isVisible = false } } } } private struct TimelineMarker: View { let position: Int let timeText: String let isFirst: Bool let isLast: Bool var frameKeyID: String? = nil var reportPosition: Int? = nil var showBackground: Bool = false var body: some View { TimelineMarkerHead( position: position, timeText: timeText, isFirst: isFirst, frameKeyID: frameKeyID, showBackground: showBackground ) .frame(width: 72, alignment: .top) .background( GeometryReader { proxy in Color.clear.preference( key: TimelinePositionPreferenceKey.self, value: reportPosition.map { pos in let frame = proxy.frame(in: .named("timelineScroll")) var data = TimelinePositionData() data.markerCenterX = frame.midX data.markerCenterY = frame.midY return [pos: data] } ?? [:] ) } ) } } private struct TimelineMarkerHead: View { let position: Int let timeText: String let isFirst: Bool var frameKeyID: String? = nil var showBackground: Bool = false var body: some View { VStack(alignment: .center, spacing: 6) { Text(String(position)) .font(.caption.bold()) .foregroundColor(.white) .padding(.horizontal, 6) .padding(.vertical, 2) .background( Capsule() .fill(Color.accentColor) ) Text(timeText) .font(.caption2.monospacedDigit()) .foregroundStyle(Color.accentColor) } .padding(.horizontal, 8) .padding(.bottom, 8) .background(showBackground ? Color(nsColor: .controlBackgroundColor) : Color.clear) .background( GeometryReader { proxy in if let id = frameKeyID { Color.clear.preference( key: MarkerHeadFramePreferenceKey.self, value: [id: proxy.frame(in: .named("timelineScroll"))] ) } else { Color.clear } } ) } } private struct StickyTimelineMarker: View { let position: Int let timeText: String let isActive: Bool let extraLineHeight: CGFloat var body: some View { TimelineMarker( position: position, timeText: timeText, isFirst: true, isLast: true, showBackground: false ) } } private struct ConversationCard: View { let turn: ConversationTurn let isExpanded: Bool let branding: SessionSourceBranding let allowToggle: Bool let toggle: () -> Void let onSelectAttachment: ([TimelineAttachment], Int) -> Void var reportPosition: Int? = nil var body: some View { VStack(alignment: .leading, spacing: 12) { header if isExpanded { expandedBody } else { collapsedBody } } .padding(16) .background( UnevenRoundedRectangle( topLeadingRadius: 0, bottomLeadingRadius: 14, bottomTrailingRadius: 14, topTrailingRadius: 14 ) .fill(Color(nsColor: .controlBackgroundColor)) ) .overlay( UnevenRoundedRectangle( topLeadingRadius: 0, bottomLeadingRadius: 14, bottomTrailingRadius: 14, topTrailingRadius: 14 ) .stroke(Color.primary.opacity(0.07), lineWidth: 1) ) .background( GeometryReader { proxy in Color.clear.preference( key: TimelinePositionPreferenceKey.self, value: reportPosition.map { pos in let frame = proxy.frame(in: .named("timelineScroll")) var data = TimelinePositionData() data.messageBoxBottomY = frame.maxY return [pos: data] } ?? [:] ) } ) } private var header: some View { HStack { Text(turn.actorSummary(using: branding.displayName)) .font(.subheadline.weight(.semibold)) .foregroundStyle(.primary) Spacer() if allowToggle { Image(systemName: isExpanded ? "chevron.up" : "chevron.down") .font(.subheadline.weight(.semibold)) .foregroundStyle(.secondary) } } .contentShape(Rectangle()) .onTapGesture { if allowToggle { toggle() } } .hoverHand() } @ViewBuilder private var collapsedBody: some View { if let preview = turn.previewText, !preview.isEmpty { Text(preview) .font(.callout) .foregroundStyle(.secondary) .lineLimit(3) .frame(maxWidth: .infinity, alignment: .leading) } else { Text("Tap to view details") .font(.caption) .foregroundStyle(.tertiary) } } @ViewBuilder private var expandedBody: some View { if let user = turn.userMessage { EventSegmentView(event: user, branding: branding, onSelectAttachment: onSelectAttachment) } ForEach(Array(turn.outputs.enumerated()), id: \.offset) { index, event in if index > 0 || turn.userMessage != nil { Divider() } EventSegmentView(event: event, branding: branding, onSelectAttachment: onSelectAttachment) } } } private struct EventSegmentView: View { let event: TimelineEvent let branding: SessionSourceBranding let onSelectAttachment: ([TimelineAttachment], Int) -> Void @State private var isHover = false var body: some View { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .firstTextBaseline, spacing: 6) { roleIconView .foregroundStyle(roleColor) Text(roleTitle) .font(.caption.weight(.medium)) .foregroundStyle(.secondary) if event.repeatCount > 1 { Text("×\(event.repeatCount)") .font(.caption2.monospacedDigit()) .foregroundStyle(.tertiary) .padding(.horizontal, 4) .padding(.vertical, 1) .background( Capsule() .fill(Color.secondary.opacity(0.1)) ) } Spacer() Button { NSPasteboard.general.clearContents() NSPasteboard.general.setString(event.text ?? "", forType: .string) } label: { Image(systemName: "doc.on.doc") .font(.caption2) } .buttonStyle(.plain) .foregroundStyle(.secondary) .opacity(isHover ? 1 : 0) .help("Copy to clipboard") } if let text = event.text, !text.isEmpty { // User messages and tool_output use collapsible text if event.visibilityKind == .user { CollapsibleText(text: text, lineLimit: 10) } else if event.actor == .tool { CollapsibleText(text: text, lineLimit: 3) } else { Text(text) .textSelection(.enabled) .font(.body) .frame(maxWidth: .infinity, alignment: .leading) } } if !event.attachments.isEmpty { AttachmentStripView(attachments: event.attachments, onSelect: onSelectAttachment) } if let metadata = event.metadata { MetadataView(metadata: metadata) } } .onHover { hovering in isHover = hovering } } private var roleTitle: String { event.visibilityKind.settingsLabel } @ViewBuilder private var roleIconView: some View { switch event.visibilityKind { case .assistant: ProviderIconView(provider: branding.providerKind, size: 12, cornerRadius: 2) default: Image(systemName: roleIconName) .font(.caption2) } } private var roleIconName: String { switch event.visibilityKind { case .user: return "person.fill" case .assistant: return branding.symbolName case .tool: return "hammer.fill" case .codeEdit: return "square.and.pencil" case .reasoning: return "brain" case .tokenUsage: return "gauge" case .environmentContext: return "macwindow" case .turnContext: return "arrow.triangle.2.circlepath" case .infoOther: return "info.circle" } } private var roleColor: Color { switch event.visibilityKind { case .user: return .accentColor case .assistant: return branding.iconColor case .tool: return .yellow case .codeEdit: return .green case .reasoning: return .purple case .tokenUsage: return .orange case .environmentContext, .turnContext, .infoOther: return .gray } } } private struct ImagePreviewContext: Equatable { var attachments: [TimelineAttachment] var index: Int } private struct ImagePreviewOverlay: View { @Binding var context: ImagePreviewContext? @State private var image: NSImage? = nil @State private var isLoading = false @State private var errorText: String? = nil @State private var scale: CGFloat = 1 @State private var gestureScale: CGFloat = 1 @State private var offset: CGSize = .zero @GestureState private var dragOffset: CGSize = .zero var body: some View { if let context, let attachment = currentAttachment(from: context) { GeometryReader { proxy in ZStack { Color.black.opacity(0.78) .onTapGesture { close() } VStack(spacing: 16) { ZStack { if let image { ZStack { Image(nsImage: image) .interpolation(.high) .resizable() .scaledToFit() .scaleEffect(currentScale) .offset( x: offset.width + dragOffset.width, y: offset.height + dragOffset.height ) .shadow(color: Color.black.opacity(0.5), radius: 24, x: 0, y: 12) ScrollWheelZoomView { delta in applyScrollZoom(delta) } .frame(maxWidth: .infinity, maxHeight: .infinity) } .gesture( DragGesture() .updating($dragOffset) { value, state, _ in state = value.translation } .onEnded { value in offset.width += value.translation.width offset.height += value.translation.height } ) .simultaneousGesture( MagnificationGesture() .onChanged { value in gestureScale = value } .onEnded { value in scale = clampScale(scale * value) gestureScale = 1 } ) .frame(maxWidth: 1200, maxHeight: 800) } else if isLoading { ProgressView() .progressViewStyle(.circular) .tint(.white) } else { Text(errorText ?? "Unable to preview image") .foregroundStyle(.white) .font(.callout) } } HStack(spacing: 12) { Button { goPrevious() } label: { Image(systemName: "chevron.left") } .disabled(!canGoPrevious) Text("\(context.index + 1) / \(context.attachments.count)") .font(.caption2) .foregroundStyle(.white.opacity(0.85)) Button { goNext() } label: { Image(systemName: "chevron.right") } .disabled(!canGoNext) Spacer(minLength: 12) Button("Close (Esc)") { close() } Button("Open Externally") { TimelineAttachmentOpener.shared.open(attachment) } } .buttonStyle(.bordered) .foregroundStyle(.white) } .padding(24) KeyCommandCatcher { event in handleKey(event) } .frame(width: proxy.size.width, height: proxy.size.height) .allowsHitTesting(false) } .frame(width: proxy.size.width, height: proxy.size.height) .clipped() } .onAppear { resetTransform() load(attachment) } .onChange(of: attachment.id) { _, _ in resetTransform() load(attachment) } } } private var currentScale: CGFloat { clampScale(scale * gestureScale) } private var canGoPrevious: Bool { guard let context else { return false } return context.index > 0 } private var canGoNext: Bool { guard let context else { return false } return context.index < (context.attachments.count - 1) } private func currentAttachment(from context: ImagePreviewContext) -> TimelineAttachment? { guard context.index >= 0, context.index < context.attachments.count else { return nil } return context.attachments[context.index] } private func close() { context = nil } private func goPrevious() { guard var context, context.index > 0 else { return } context.index -= 1 self.context = context } private func goNext() { guard var context, context.index + 1 < context.attachments.count else { return } context.index += 1 self.context = context } private func resetTransform() { scale = 1 gestureScale = 1 offset = .zero } private func clampScale(_ value: CGFloat) -> CGFloat { min(max(value, 0.2), 6) } private func applyScrollZoom(_ delta: CGFloat) { guard delta != 0 else { return } let step = max(-0.25, min(0.25, delta / 300)) scale = clampScale(scale * (1 + step)) } private func handleKey(_ event: NSEvent) -> Bool { switch event.keyCode { case 53: // escape close() return true case 123: // left arrow goPrevious() return true case 124: // right arrow goNext() return true default: return false } } private func load(_ attachment: TimelineAttachment) { isLoading = true image = nil errorText = nil Task.detached { let resolvedData = TimelineAttachmentDecoder.imageData(for: attachment) await MainActor.run { if let resolvedData { self.image = NSImage(data: resolvedData) } else { self.image = nil } self.isLoading = false if resolvedData == nil { self.errorText = "Unable to preview this image." } } } } } private struct KeyCommandCatcher: NSViewRepresentable { let onKeyDown: (NSEvent) -> Bool func makeNSView(context: Context) -> KeyCommandCatcherView { let view = KeyCommandCatcherView() view.onKeyDown = onKeyDown DispatchQueue.main.async { view.window?.makeFirstResponder(view) } return view } func updateNSView(_ nsView: KeyCommandCatcherView, context: Context) { nsView.onKeyDown = onKeyDown DispatchQueue.main.async { if nsView.window?.firstResponder !== nsView { nsView.window?.makeFirstResponder(nsView) } } } } private final class KeyCommandCatcherView: NSView { var onKeyDown: ((NSEvent) -> Bool)? override var acceptsFirstResponder: Bool { true } override func keyDown(with event: NSEvent) { if onKeyDown?(event) == true { return } super.keyDown(with: event) } } private struct ScrollWheelZoomView: NSViewRepresentable { let onScroll: (CGFloat) -> Void func makeNSView(context: Context) -> ScrollWheelCatcher { let view = ScrollWheelCatcher() view.onScroll = onScroll return view } func updateNSView(_ nsView: ScrollWheelCatcher, context: Context) { nsView.onScroll = onScroll } } private final class ScrollWheelCatcher: NSView { var onScroll: ((CGFloat) -> Void)? override func scrollWheel(with event: NSEvent) { onScroll?(event.scrollingDeltaY) } } private struct AttachmentStripView: View { let attachments: [TimelineAttachment] let onSelect: ([TimelineAttachment], Int) -> Void var body: some View { HStack(spacing: 8) { ForEach(Array(attachments.enumerated()), id: \.element.id) { index, attachment in Button { onSelect(attachments, index) } label: { HStack(spacing: 4) { Image(systemName: "photo") .font(.caption) Text(attachment.label ?? "Image \(index + 1)") .font(.caption2) } .padding(.vertical, 2) .padding(.horizontal, 6) .background( RoundedRectangle(cornerRadius: 6) .fill(Color.secondary.opacity(0.1)) ) } .buttonStyle(.plain) .help(attachment.label ?? "Open image") .hoverHand() } } } } private struct CollapsibleText: View { let text: String let lineLimit: Int @State private var isExpanded = false var body: some View { let previewInfo = linePreview(text, limit: lineLimit) let preview = previewInfo.text let truncated = previewInfo.truncated VStack(alignment: .leading, spacing: 6) { Text(isExpanded ? text : preview) .textSelection(.enabled) .font(.body) .frame(maxWidth: .infinity, alignment: .leading) if truncated { Button(action: { isExpanded.toggle() }) { HStack { Spacer() Image(systemName: isExpanded ? "chevron.up" : "chevron.down") .font(.caption.weight(.bold)) .foregroundStyle(.secondary) Spacer() } .frame(height: 24) .contentShape(Rectangle()) } .buttonStyle(.plain) .hoverHand() } } } private func linePreview(_ text: String, limit: Int) -> (text: String, truncated: Bool) { // limit = 0 means no truncation, show all guard limit > 0 else { return (text, false) } var newlineCount = 0 for index in text.indices { if text[index] == "\n" { newlineCount += 1 if newlineCount == limit { return (String(text[.. = [] private var sampleTurn: ConversationTurn { let now = Date() let userEvent = TimelineEvent( id: UUID().uuidString, timestamp: now, actor: .user, title: nil, text: "Please outline a multi-tenant design for the MCP Mate project.", metadata: nil, repeatCount: 1, attachments: [], visibilityKind: .user ) let infoEvent = TimelineEvent( id: UUID().uuidString, timestamp: now.addingTimeInterval(6), actor: .info, title: "Context Updated", text: "model: gpt-5.2-codex\npolicy: on-request", metadata: nil, repeatCount: 3, attachments: [], visibilityKind: .turnContext ) let assistantEvent = TimelineEvent( id: UUID().uuidString, timestamp: now.addingTimeInterval(12), actor: .assistant, title: nil, text: "Certainly. Here are the key considerations for a multi-tenant design...", metadata: nil, repeatCount: 1, attachments: [], visibilityKind: .assistant ) return ConversationTurn( id: UUID().uuidString, timestamp: now, userMessage: userEvent, outputs: [infoEvent, assistantEvent] ) } var body: some View { ConversationTimelineView( turns: [sampleTurn], expandedTurnIDs: $expanded, refreshToken: 0, branding: SessionSource.codexLocal.branding, isActive: true ) .padding() .frame(width: 540) } } // Provide a handy pointer extension to keep cursor behavior consistent on clickable areas extension View { func hoverHand() -> some View { self.onHover { inside in if inside { NSCursor.pointingHand.set() } else { NSCursor.arrow.set() } } } } ================================================ FILE: views/DiagnosticsViews.swift ================================================ import SwiftUI import AppKit struct DiagnosticsSection: View { @ObservedObject var preferences: SessionPreferencesStore @State private var running = false @State private var lastResult: SessionsDiagnostics? = nil @State private var lastError: String? = nil private let service = SessionsDiagnosticsService() var body: some View { VStack(alignment: .leading, spacing: 12) { Text("Diagnostics") .font(.headline) .foregroundColor(.primary) HStack(spacing: 8) { Button(action: runDiagnostics) { if running { ProgressView().controlSize(.small) } Text(running ? "Diagnosing…" : "Diagnose Data Directories") } .disabled(running) if let result = lastResult, result.current.enumeratedCount == 0, result.defaultRoot.enumeratedCount > 0, preferences.sessionsRoot.path != result.defaultRoot.path { Button("Switch to Default Path") { preferences.sessionsRoot = URL( fileURLWithPath: result.defaultRoot.path, isDirectory: true) } } if lastResult != nil { Button("Save Report…", action: saveReport) } } if let error = lastError { Text(error).foregroundStyle(.red).font(.caption) } if let result = lastResult { VStack(alignment: .leading, spacing: 10) { Text("Codex Sessions Root").font(.headline).fontWeight(.semibold) DiagnosticsReportView(result: result) .frame(maxWidth: .infinity, alignment: .leading) .padding(8) .background( RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color(nsColor: .textBackgroundColor)) .overlay( RoundedRectangle(cornerRadius: 8, style: .continuous) .stroke(Color.secondary.opacity(0.2), lineWidth: 1) ) ) Text("Claude Sessions Directory").font(.headline).fontWeight(.semibold).padding(.top, 4) VStack(alignment: .leading, spacing: 8) { if let cc = result.claudeCurrent { DataPairReportView(current: cc, defaultProbe: result.claudeDefault) } else { DataPairReportView(current: result.claudeDefault, defaultProbe: result.claudeDefault) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(8) .background( RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color(nsColor: .textBackgroundColor)) .overlay( RoundedRectangle(cornerRadius: 8, style: .continuous) .stroke(Color.secondary.opacity(0.2), lineWidth: 1) ) ) Text("Gemini Sessions Directory").font(.headline).fontWeight(.semibold).padding(.top, 4) VStack(alignment: .leading, spacing: 8) { if let gc = result.geminiCurrent { DataPairReportView(current: gc, defaultProbe: result.geminiDefault) } else { DataPairReportView(current: result.geminiDefault, defaultProbe: result.geminiDefault) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(8) .background( RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color(nsColor: .textBackgroundColor)) .overlay( RoundedRectangle(cornerRadius: 8, style: .continuous) .stroke(Color.secondary.opacity(0.2), lineWidth: 1) ) ) Text("Notes Directory").font(.headline).fontWeight(.semibold).padding(.top, 4) DataPairReportView(current: result.notesCurrent, defaultProbe: result.notesDefault) .frame(maxWidth: .infinity, alignment: .leading) .padding(8) .background( RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color(nsColor: .textBackgroundColor)) .overlay( RoundedRectangle(cornerRadius: 8, style: .continuous) .stroke(Color.secondary.opacity(0.2), lineWidth: 1) ) ) Text("Projects Directory").font(.headline).fontWeight(.semibold).padding(.top, 4) DataPairReportView(current: result.projectsCurrent, defaultProbe: result.projectsDefault) .frame(maxWidth: .infinity, alignment: .leading) .padding(8) .background( RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color(nsColor: .textBackgroundColor)) .overlay( RoundedRectangle(cornerRadius: 8, style: .continuous) .stroke(Color.secondary.opacity(0.2), lineWidth: 1) ) ) Text("Claude Sessions Directory").font(.headline).fontWeight(.semibold).padding(.top, 4) VStack(alignment: .leading, spacing: 8) { if let cc = result.claudeCurrent { DataPairReportView(current: cc, defaultProbe: result.claudeDefault) } else { // Show default path only (current is unknown/not configured) DataPairReportView(current: result.claudeDefault, defaultProbe: result.claudeDefault) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(8) .background( RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color(nsColor: .textBackgroundColor)) .overlay( RoundedRectangle(cornerRadius: 8, style: .continuous) .stroke(Color.secondary.opacity(0.2), lineWidth: 1) ) ) } } } } private func runDiagnostics() { running = true lastError = nil lastResult = nil let current = preferences.sessionsRoot let home = FileManager.default.homeDirectoryForCurrentUser let def = SessionPreferencesStore.defaultSessionsRoot(for: home) let notesCurrent = preferences.notesRoot let notesDefault = SessionPreferencesStore.defaultNotesRoot(for: def) let projectsCurrent = preferences.projectsRoot let projectsDefault = SessionPreferencesStore.defaultProjectsRoot(for: home) let claudeDefault = home.appendingPathComponent(".claude", isDirectory: true).appendingPathComponent("projects", isDirectory: true) let claudeCurrent: URL? = FileManager.default.fileExists(atPath: claudeDefault.path) ? claudeDefault : nil let geminiDefault = home.appendingPathComponent(".gemini", isDirectory: true).appendingPathComponent("tmp", isDirectory: true) let geminiCurrent: URL? = FileManager.default.fileExists(atPath: geminiDefault.path) ? geminiDefault : nil Task { let res = await service.run( currentRoot: current, defaultRoot: def, notesCurrentRoot: notesCurrent, notesDefaultRoot: notesDefault, projectsCurrentRoot: projectsCurrent, projectsDefaultRoot: projectsDefault, claudeCurrentRoot: claudeCurrent, claudeDefaultRoot: claudeDefault, geminiCurrentRoot: geminiCurrent, geminiDefaultRoot: geminiDefault ) await MainActor.run { self.lastResult = res self.running = false } } } private func saveReport() { guard let result = lastResult else { return } let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes] encoder.dateEncodingStrategy = .iso8601 do { let data = try encoder.encode(result) let panel = NSSavePanel() panel.canCreateDirectories = true panel.allowedContentTypes = [.json] let df = DateFormatter() df.dateFormat = "yyyyMMdd-HHmmss" let ts = df.string(from: result.timestamp) panel.nameFieldStringValue = "CodMate-Sessions-Diagnostics-\(ts).json" panel.begin { resp in if resp == .OK, let url = panel.url { do { try data.write(to: url, options: .atomic) } catch { self.lastError = "Failed to save report: \(error.localizedDescription)" } } } } catch { self.lastError = "Failed to prepare report: \(error.localizedDescription)" } } } struct DiagnosticsReportView: View { let result: SessionsDiagnostics var body: some View { VStack(alignment: .leading, spacing: 8) { Text("Timestamp: \(formatDate(result.timestamp))").font(.caption) let same = result.current.path == result.defaultRoot.path Group { Text(same ? "Sessions Root (= Default)" : "Current Root") .font(.subheadline).bold() DiagnosticsProbeView(p: result.current) } if !same { Group { Text("Default Root").font(.subheadline).bold().padding(.top, 4) DiagnosticsProbeView(p: result.defaultRoot) } } if !result.suggestions.isEmpty { Text("Suggestions").font(.subheadline).bold().padding(.top, 4) ForEach(result.suggestions, id: \.self) { s in Text("• \(s)").font(.caption) } } } } private func formatDate(_ d: Date) -> String { let df = DateFormatter() df.dateStyle = .medium df.timeStyle = .medium return df.string(from: d) } } struct DiagnosticsProbeView: View { let p: SessionsDiagnostics.Probe var body: some View { VStack(alignment: .leading, spacing: 0) { Text("Path: \(p.path)").font(.caption) Text("Exists: \(p.exists ? "yes" : "no")").font(.caption) Text("Directory: \(p.isDirectory ? "yes" : "no")").font(.caption) Text("Files: \(p.enumeratedCount)").font(.caption) if !p.sampleFiles.isEmpty { Text("Samples:").font(.caption) ForEach(p.sampleFiles.prefix(5), id: \.self) { s in Text("• \(s)").font(.caption2) } if p.sampleFiles.count > 5 { Text("(\(p.sampleFiles.count - 5) more…)").font(.caption2).foregroundStyle( .secondary) } } if let err = p.enumeratorError { Text("Enumerator Error: \(err)").font(.caption).foregroundStyle(.red) } } } } struct DataPairReportView: View { let current: SessionsDiagnostics.Probe let defaultProbe: SessionsDiagnostics.Probe var body: some View { VStack(alignment: .leading, spacing: 8) { let same = current.path == defaultProbe.path Group { Text(same ? "Current (= Default)" : "Current") .font(.subheadline).bold() DiagnosticsProbeView(p: current) } if !same { Group { Text("Default").font(.subheadline).bold().padding(.top, 4) DiagnosticsProbeView(p: defaultProbe) } } } } } ================================================ FILE: views/DialecticsPane.swift ================================================ import SwiftUI import AppKit struct DialecticsPane: View { @ObservedObject var preferences: SessionPreferencesStore @StateObject private var vm = DialecticsVM() @StateObject private var permissionsManager = SandboxPermissionsManager.shared @EnvironmentObject private var listViewModel: SessionListViewModel @State private var ripgrepReport: SessionRipgrepStore.Diagnostics? @State private var ripgrepLoading = false @State private var ripgrepRebuilding = false @State private var sessionIndexRebuilding = false @State private var activeRebuildAlert: RebuildAlert? enum RebuildAlert: Identifiable { case ripgrepCoverage case sessionIndex var id: String { switch self { case .ripgrepCoverage: return "ripgrepCoverage" case .sessionIndex: return "sessionIndex" } } } var body: some View { VStack(alignment: .leading, spacing: 20) { // App & OS VStack(alignment: .leading, spacing: 10) { Text("Environment").font(.headline).fontWeight(.semibold) settingsCard { Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { GridRow { Text("App Version").font(.subheadline) Text(vm.appVersion).frame(maxWidth: .infinity, alignment: .trailing) } GridRow { Text("Build Time").font(.subheadline) Text(vm.buildTime).frame(maxWidth: .infinity, alignment: .trailing) } GridRow { Text("macOS").font(.subheadline) Text(vm.osVersion).frame(maxWidth: .infinity, alignment: .trailing) } GridRow { Text("App Sandbox").font(.subheadline) Text(vm.sandboxOn ? "On" : "Off") .foregroundStyle(vm.sandboxOn ? .green : .secondary) .frame(maxWidth: .infinity, alignment: .trailing) } } } } VStack(alignment: .leading, spacing: 10) { Text("Ripgrep Indexes").font(.headline).fontWeight(.semibold) settingsCard { if let report = ripgrepReport { Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { gridRow(label: "Cached Coverage Entries", value: "\(report.cachedCoverageEntries)") gridRow(label: "Cached Tool Entries", value: "\(report.cachedToolEntries)") gridRow(label: "Cached Token Entries", value: "\(report.cachedTokenEntries)") gridRow(label: "Last Coverage Scan", value: timestampLabel(report.lastCoverageScan)) gridRow(label: "Last Tool Scan", value: timestampLabel(report.lastToolScan)) gridRow(label: "Last Token Scan", value: timestampLabel(report.lastTokenScan)) } } else { Text("Ripgrep stats not loaded yet.") .font(.caption) .foregroundStyle(.secondary) } Divider() HStack(spacing: 12) { Button { Task { await refreshRipgrepDiagnostics() } } label: { Label("Refresh Ripgrep Stats", systemImage: "arrow.clockwise") } .buttonStyle(.bordered) .disabled(ripgrepLoading || ripgrepRebuilding || sessionIndexRebuilding) if ripgrepLoading || ripgrepRebuilding || sessionIndexRebuilding { ProgressView().controlSize(.small) } Button { activeRebuildAlert = .ripgrepCoverage } label: { Label("Rebuild Coverage", systemImage: "hammer") } .buttonStyle(.borderedProminent) .tint(.orange) .disabled(ripgrepRebuilding || sessionIndexRebuilding) Button { activeRebuildAlert = .sessionIndex } label: { Label("Rebuild Session Index", systemImage: "arrow.counterclockwise.circle") } .buttonStyle(.bordered) .tint(.orange) .disabled(sessionIndexRebuilding || ripgrepRebuilding) } } } // Sandbox Permissions (only show if sandboxed and missing permissions) if vm.sandboxOn && permissionsManager.needsAuthorization { VStack(alignment: .leading, spacing: 10) { HStack { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.orange) Text("Directory Access Required") .font(.headline) .fontWeight(.semibold) } settingsCard { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { Text("CodMate needs access to the following directories to function properly:") .font(.subheadline) .foregroundStyle(.secondary) // Show actual resolved paths for debugging if vm.sandboxOn { Text("Note: These are the real user directories, not sandbox container paths.") .font(.caption) .foregroundStyle(.orange) .padding(.vertical, 4) } } ForEach(permissionsManager.missingPermissions) { directory in HStack(spacing: 12) { Image(systemName: permissionsManager.hasPermission(for: directory) ? "checkmark.circle.fill" : "circle") .foregroundStyle(permissionsManager.hasPermission(for: directory) ? .green : .secondary) .font(.system(size: 16)) VStack(alignment: .leading, spacing: 4) { Text(directory.displayName) .font(.subheadline) .fontWeight(.medium) Text(directory.description) .font(.caption) .foregroundStyle(.secondary) Text(directory.rawValue) .font(.caption2) .foregroundStyle(.tertiary) .monospaced() } Spacer() if !permissionsManager.hasPermission(for: directory) { Button { Task { let granted = await permissionsManager.requestPermission(for: directory) if granted { permissionsManager.checkPermissions() } } } label: { Text("Grant Access") .font(.caption) } .buttonStyle(.borderedProminent) .controlSize(.small) } } .padding(.vertical, 8) } Divider() HStack { Text("Click \"Grant Access\" to select each directory when prompted.") .font(.caption) .foregroundStyle(.secondary) Spacer() Button { Task { _ = await permissionsManager.requestAllMissingPermissions() } } label: { Text("Grant All Access") } .buttonStyle(.borderedProminent) .controlSize(.small) } } .padding(.vertical, 8) } } } // Codex sessions diagnostics VStack(alignment: .leading, spacing: 10) { Text("Codex Sessions Root").font(.headline).fontWeight(.semibold) if let s = vm.sessions { settingsCard { DiagnosticsReportView(result: s) .frame(maxWidth: .infinity, alignment: .topLeading) } } else { Text("No data yet. Click Run Diagnostics.").font(.caption).foregroundStyle( .secondary) } } // Claude sessions diagnostics (moved above Notes) VStack(alignment: .leading, spacing: 10) { Text("Claude Sessions Directory").font(.headline).fontWeight(.semibold) if let s = vm.sessions { settingsCard { if let cc = s.claudeCurrent { DataPairReportView(current: cc, defaultProbe: s.claudeDefault) .frame(maxWidth: .infinity, alignment: .topLeading) } else { DataPairReportView(current: s.claudeDefault, defaultProbe: s.claudeDefault) .frame(maxWidth: .infinity, alignment: .topLeading) } } } else { Text("No data yet. Click Run Diagnostics.").font(.caption).foregroundStyle(.secondary) } } // Gemini sessions diagnostics VStack(alignment: .leading, spacing: 10) { Text("Gemini Sessions Directory").font(.headline).fontWeight(.semibold) if let s = vm.sessions { settingsCard { if let gc = s.geminiCurrent { DataPairReportView(current: gc, defaultProbe: s.geminiDefault) .frame(maxWidth: .infinity, alignment: .topLeading) } else { DataPairReportView(current: s.geminiDefault, defaultProbe: s.geminiDefault) .frame(maxWidth: .infinity, alignment: .topLeading) } } } else { Text("No data yet. Click Run Diagnostics.").font(.caption).foregroundStyle(.secondary) } } // Notes diagnostics VStack(alignment: .leading, spacing: 10) { Text("Notes Directory").font(.headline).fontWeight(.semibold) if let s = vm.sessions { settingsCard { DataPairReportView(current: s.notesCurrent, defaultProbe: s.notesDefault) .frame(maxWidth: .infinity, alignment: .topLeading) } } else { Text("No data yet. Click Run Diagnostics.").font(.caption).foregroundStyle(.secondary) } } // Projects diagnostics VStack(alignment: .leading, spacing: 10) { Text("Projects Directory").font(.headline).fontWeight(.semibold) if let s = vm.sessions { settingsCard { DataPairReportView(current: s.projectsCurrent, defaultProbe: s.projectsDefault) .frame(maxWidth: .infinity, alignment: .topLeading) } } else { Text("No data yet. Click Run Diagnostics.").font(.caption).foregroundStyle(.secondary) } } // Removed: Authorization Shortcuts — unify to on-demand authorization in context HStack { Spacer(minLength: 8) Button { Task { await vm.runAll(preferences: preferences) } } label: { Label("Run Diagnostics", systemImage: "stethoscope") } .buttonStyle(.bordered) Button { vm.saveReport( preferences: preferences, ripgrepReport: ripgrepReport, indexMeta: listViewModel.indexMeta, cacheCoverage: listViewModel.cacheCoverage ) } label: { Label("Save Report…", systemImage: "square.and.arrow.down") } .buttonStyle(.bordered) } } .task { await vm.runAll(preferences: preferences) } .task { await refreshRipgrepDiagnostics() } .alert(item: $activeRebuildAlert) { alert in switch alert { case .ripgrepCoverage: return Alert( title: Text("Rebuild Ripgrep Coverage?"), message: Text( "This will clear all cached ripgrep coverage, tool, and token indexes and recompute them from your current Codex and Claude session logs. It may temporarily increase CPU usage, but it does not modify any session files, notes, or projects." ), primaryButton: .destructive(Text("Rebuild")) { Task { await rebuildRipgrepIndexes() } }, secondaryButton: .cancel() ) case .sessionIndex: return Alert( title: Text("Rebuild Session Index?"), message: Text( "This will clear in-memory and on-disk session index caches and rebuild them by re-parsing all session JSONL files under the configured sessions root. It may take time for large histories but does not delete or change any session logs, notes, or projects. Use this if timestamps or statistics look incorrect after changing how sessions are indexed." ), primaryButton: .destructive(Text("Rebuild")) { Task { await rebuildSessionIndex() } }, secondaryButton: .cancel() ) } } } // Helper function to create settings card @ViewBuilder private func settingsCard(@ViewBuilder _ content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 8) { content() } .padding(10) .background(Color(nsColor: .separatorColor).opacity(0.35)) .cornerRadius(10) } } extension DialecticsPane { private func authorizeFolder(_ suggested: URL) { let panel = NSOpenPanel() panel.canChooseFiles = false panel.canChooseDirectories = true panel.allowsMultipleSelection = false panel.directoryURL = suggested panel.message = "Authorize this folder for sandboxed access" panel.prompt = "Authorize" panel.begin { resp in if resp == .OK, let url = panel.url { SecurityScopedBookmarks.shared.saveDynamic(url: url) NotificationCenter.default.post(name: .codMateRepoAuthorizationChanged, object: nil) } } } @ViewBuilder private func gridRow(label: String, value: String) -> some View { GridRow { Text(label).font(.subheadline) Text(value) .font(.caption) .frame(maxWidth: .infinity, alignment: .trailing) } } private func timestampLabel(_ date: Date?) -> String { guard let date else { return "—" } let formatter = DateFormatter() formatter.dateStyle = .medium formatter.timeStyle = .short return formatter.string(from: date) } private func refreshRipgrepDiagnostics() async { await MainActor.run { ripgrepLoading = true } let report = await listViewModel.ripgrepDiagnostics() await MainActor.run { ripgrepReport = report ripgrepLoading = false } } private func rebuildRipgrepIndexes() async { await MainActor.run { ripgrepRebuilding = true } await listViewModel.rebuildRipgrepIndexes() await refreshRipgrepDiagnostics() await MainActor.run { ripgrepRebuilding = false } } private func rebuildSessionIndex() async { await MainActor.run { sessionIndexRebuilding = true } await listViewModel.rebuildSessionIndex() await refreshRipgrepDiagnostics() await MainActor.run { sessionIndexRebuilding = false } } } ================================================ FILE: views/EditSessionMetaView.swift ================================================ import SwiftUI struct EditSessionMetaView: View { @ObservedObject var viewModel: SessionListViewModel @FocusState private var focusedField: Field? enum Field { case title case comment } var body: some View { VStack(alignment: .leading, spacing: 16) { HStack { Text("Edit Session") .font(.title3).bold() Spacer() // Generate button (icon only, transparent background) if let session = viewModel.editingSession { Button(action: { Task { @MainActor in await viewModel.generateTitleAndComment(for: session, force: false) } }) { if viewModel.isGeneratingTitleComment && viewModel.generatingSessionId == session.id { ProgressView() .controlSize(.small) .frame(width: 16, height: 16) } else { Image(systemName: "sparkles") .font(.system(size: 16)) .foregroundStyle(.secondary) } } .buttonStyle(.plain) .help("Generate title and comment using AI") .disabled(viewModel.isGeneratingTitleComment && viewModel.generatingSessionId == session.id) } } TextField("Name (optional)", text: $viewModel.editTitle) .textFieldStyle(.roundedBorder) .focused($focusedField, equals: .title) VStack(alignment: .leading, spacing: 8) { Text("Comment (optional)").font(.subheadline) TextEditor(text: $viewModel.editComment) .font(.body) .codmatePlainTextEditorStyleIfAvailable() .scrollContentBackground(.hidden) .frame(minHeight: 120) .padding(8) // use outer padding; avoid inner padding that can clip first baseline on macOS .background( RoundedRectangle(cornerRadius: 6) .stroke(Color.gray.opacity(0.2), lineWidth: 1) ) .focused($focusedField, equals: .comment) } HStack { Button("Cancel") { viewModel.cancelEdits() } Spacer() Button("Save") { Task { await viewModel.saveEdits() } } .keyboardShortcut(.defaultAction) } } .padding(20) .frame(minWidth: 520) .onAppear { // Set focus to title field when view appears focusedField = .title } } } ================================================ FILE: views/EditorMenuHelpers.swift ================================================ import AppKit import SwiftUI /// Creates a label with the editor's title and icon @ViewBuilder func editorLabel(for editor: EditorApp) -> some View { Label { Text(editor.title) } icon: { if let icon = editor.menuIcon { Image(nsImage: icon) } else { Image(systemName: "chevron.left.forwardslash.chevron.right") } } } @ViewBuilder func openInEditorMenu( editors: [EditorApp], onOpen: @escaping (EditorApp) -> Void ) -> some View { if !editors.isEmpty { Menu { ForEach(editors) { editor in Button { onOpen(editor) } label: { editorLabel(for: editor) } } } label: { Label("Open in", systemImage: "arrow.up.forward.app") } } } ================================================ FILE: views/EmbeddedTerminalView.swift ================================================ import SwiftUI import GhosttyKit import CGhostty /// Embedded Ghostty terminal view /// Directly uses TerminalScrollView provided by GhosttyKit /// Ghostty runtime is lazy-initialized only when this view appears struct EmbeddedTerminalView: View { let sessionID: String let initialCommands: String let worktreePath: String // Lazy-initialize Ghostty only when terminal view is shown @StateObject private var ghosttyApp = Ghostty.App() var body: some View { Group { if let ghosttyApp = ghosttyApp.app { GhosttyTerminalViewRepresentable( sessionID: sessionID, worktreePath: worktreePath, initialCommands: initialCommands, ghosttyApp: ghosttyApp, appWrapper: self.ghosttyApp ) .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { NSLog("[EmbeddedTerminalView] ghosttyApp is available") } } else { VStack { Text("Terminal Initializing...") .foregroundStyle(.secondary) ProgressView() } .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { NSLog("[EmbeddedTerminalView] ghosttyApp is initializing") } } } .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) } } /// NSViewRepresentable wrapper for Ghostty Terminal private struct GhosttyTerminalViewRepresentable: NSViewRepresentable { let sessionID: String let worktreePath: String let initialCommands: String let ghosttyApp: ghostty_app_t let appWrapper: Ghostty.App func makeNSView(context: Context) -> TerminalScrollView { if let cached = GhosttySessionManager.shared.getScrollView(for: sessionID) { NSLog("[GhosttyTerminalViewRepresentable] reusing cached TerminalScrollView for %@", sessionID) return cached } NSLog("[GhosttyTerminalViewRepresentable] makeNSView called") NSLog("[GhosttyTerminalViewRepresentable] worktreePath: %@", worktreePath) NSLog("[GhosttyTerminalViewRepresentable] initialCommands: %@", initialCommands) // Use a stable paneId based on worktreePath to ensure the same terminal session // is reused when the view is recreated with the same worktreePath let paneId = "embedded:\(sessionID)" let terminalView = GhosttyTerminalView( frame: .zero, worktreePath: worktreePath, ghosttyApp: ghosttyApp, appWrapper: appWrapper, paneId: paneId, command: nil ) NSLog("[GhosttyTerminalViewRepresentable] GhosttyTerminalView created with paneId: %@", paneId) let scrollView = TerminalScrollView( contentSize: CGSize(width: 800, height: 600), surfaceView: terminalView ) NSLog("[GhosttyTerminalViewRepresentable] TerminalScrollView created") GhosttySessionManager.shared.setScrollView(scrollView, for: sessionID) // Store the initial commands in the coordinator to track changes context.coordinator.pendingCommands = initialCommands.isEmpty ? nil : initialCommands context.coordinator.worktreePath = worktreePath context.coordinator.didInjectInitialCommands = false terminalView.onReady = { [weak terminalView, weak coordinator = context.coordinator] in guard let terminalView, let coordinator else { return } guard !coordinator.didInjectInitialCommands else { return } guard let commands = coordinator.pendingCommands, !commands.isEmpty else { return } let trimmed = commands.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } coordinator.didInjectInitialCommands = true let payload = commands.hasSuffix("\n") || commands.hasSuffix("\r") ? commands : commands + "\n" terminalView.sendText(payload) } // Ensure the view is properly retained by setting a non-zero frame // This helps SwiftUI recognize the view as valid scrollView.frame = NSRect(x: 0, y: 0, width: 800, height: 600) NSLog("[GhosttyTerminalViewRepresentable] View setup complete, frame=%@", NSStringFromRect(scrollView.frame)) return scrollView } func updateNSView(_ nsView: TerminalScrollView, context: Context) { // Track if this is the first update after view creation let isFirstUpdate = context.coordinator.pendingCommands == nil && context.coordinator.worktreePath.isEmpty // Only log if something actually changed to reduce noise let commandsChanged = context.coordinator.pendingCommands != initialCommands let pathChanged = context.coordinator.worktreePath != worktreePath if isFirstUpdate { NSLog("[GhosttyTerminalViewRepresentable] updateNSView: first update, window=%@", nsView.window != nil ? "YES" : "NO") } else if commandsChanged || pathChanged { NSLog("[GhosttyTerminalViewRepresentable] updateNSView: commandsChanged=%@, pathChanged=%@", commandsChanged ? "YES" : "NO", pathChanged ? "YES" : "NO") } // Update coordinator state if commandsChanged { if !context.coordinator.didInjectInitialCommands { context.coordinator.pendingCommands = initialCommands.isEmpty ? nil : initialCommands } } if pathChanged { context.coordinator.worktreePath = worktreePath } // Note: We don't recreate the terminal view here because initialCommands and worktreePath // should only be set once when the view is first created. The view will be recreated // by SwiftUI if the id() changes or if makeNSView is called again. // Theme updates are managed by Ghostty.App, no manual updates needed // View size updates are handled by TerminalScrollView's layout() method // We should not skip updates even if the window is not ready yet, as the view may be in the process of being added to the view hierarchy } func makeCoordinator() -> Coordinator { Coordinator() } class Coordinator { var pendingCommands: String? = nil var worktreePath: String = "" var didInjectInitialCommands: Bool = false } } ================================================ FILE: views/EquatableContainers.swift ================================================ import SwiftUI // Equatable wrapper to minimize diffs for the Git Review panel when state is unchanged struct EquatableGitChangesContainer: View, Equatable { struct Key: Equatable { var workingDirectoryPath: String var projectDirectoryPath: String? var state: ReviewPanelState var refreshToken: Int } static func == (lhs: EquatableGitChangesContainer, rhs: EquatableGitChangesContainer) -> Bool { lhs.key == rhs.key } let key: Key let workingDirectory: URL let projectDirectory: URL? let presentation: GitChangesPanel.Presentation // Region layout: combined (default), leftOnly, or rightOnly var regionLayout: GitChangesPanel.RegionLayout = .combined let preferences: SessionPreferencesStore var onRequestAuthorization: (() -> Void)? = nil // Optional external shared VM; when nil, this container owns an internal VM var externalVM: GitChangesViewModel? = nil var refreshToken: Int = 0 @Binding var savedState: ReviewPanelState @StateObject private var internalVM = GitChangesViewModel() var body: some View { let vm = externalVM ?? internalVM GitChangesPanel( workingDirectory: workingDirectory, projectDirectory: projectDirectory, presentation: presentation, regionLayout: regionLayout, preferences: preferences, onRequestAuthorization: onRequestAuthorization, refreshToken: refreshToken, savedState: $savedState, vm: vm ) } } // Equatable wrapper for the Usage capsule to reduce AttributeGraph diffs. struct EquatableUsageContainer: View, Equatable { struct UsageDigest: Equatable { var codexUpdatedAt: TimeInterval? var codexAvailability: Int var codexUrgentProgress: Double? var codexUrgentReset: TimeInterval? var codexOrigin: Int var codexStatusHash: Int var claudeUpdatedAt: TimeInterval? var claudeAvailability: Int var claudeUrgentProgress: Double? var claudeUrgentReset: TimeInterval? var claudeOrigin: Int var claudeStatusHash: Int var geminiUpdatedAt: TimeInterval? var geminiAvailability: Int var geminiUrgentProgress: Double? var geminiUrgentReset: TimeInterval? var geminiOrigin: Int var geminiStatusHash: Int } static func == (lhs: EquatableUsageContainer, rhs: EquatableUsageContainer) -> Bool { lhs.key == rhs.key } let key: UsageDigest var snapshots: [UsageProviderKind: UsageProviderSnapshot] var preferences: SessionPreferencesStore @Binding var selectedProvider: UsageProviderKind var onRequestRefresh: (UsageProviderKind) -> Void init( snapshots: [UsageProviderKind: UsageProviderSnapshot], preferences: SessionPreferencesStore, selectedProvider: Binding, onRequestRefresh: @escaping (UsageProviderKind) -> Void ) { self.snapshots = snapshots self.preferences = preferences self._selectedProvider = selectedProvider self.onRequestRefresh = onRequestRefresh self.key = Self.digest(snapshots) } var body: some View { UsageStatusControl( snapshots: snapshots, preferences: preferences, selectedProvider: $selectedProvider, onRequestRefresh: onRequestRefresh ) } private static func digest(_ snapshots: [UsageProviderKind: UsageProviderSnapshot]) -> UsageDigest { func parts(for provider: UsageProviderKind) -> (TimeInterval?, Int, Double?, TimeInterval?, Int, Int) { guard let snap = snapshots[provider] else { return (nil, -1, nil, nil, -1, 0) } let updated = snap.updatedAt?.timeIntervalSinceReferenceDate let availability: Int switch snap.availability { case .ready: availability = 1 case .empty: availability = 2 case .comingSoon: availability = 3 } let urgentMetric = snap.urgentMetric() let urgent = urgentMetric?.progress let urgentReset = urgentMetric?.resetDate?.timeIntervalSinceReferenceDate let origin = snap.origin == .thirdParty ? 1 : 0 var hasher = Hasher() if let message = snap.statusMessage { hasher.combine(message) } if let action = snap.action { hasher.combine(action) } let statusHash = hasher.finalize() return (updated, availability, urgent, urgentReset, origin, statusHash) } let cdx = parts(for: .codex) let cld = parts(for: .claude) let gmn = parts(for: .gemini) return UsageDigest( codexUpdatedAt: cdx.0, codexAvailability: cdx.1, codexUrgentProgress: cdx.2, codexUrgentReset: cdx.3, codexOrigin: cdx.4, codexStatusHash: cdx.5, claudeUpdatedAt: cld.0, claudeAvailability: cld.1, claudeUrgentProgress: cld.2, claudeUrgentReset: cld.3, claudeOrigin: cld.4, claudeStatusHash: cld.5, geminiUpdatedAt: gmn.0, geminiAvailability: gmn.1, geminiUrgentProgress: gmn.2, geminiUrgentReset: gmn.3, geminiOrigin: gmn.4, geminiStatusHash: gmn.5 ) } } // Digest for Sidebar state equality struct SidebarDigest: Equatable { var projectsCount: Int var projectsIdsHash: Int var totalSessionCount: Int var selectedProjectsHash: Int var selectedDaysHash: Int var dateDimensionRaw: Int var monthStartInterval: TimeInterval var calendarCountsHash: Int var enabledDaysHash: Int var visibleAllCount: Int var projectWorkspaceMode: ProjectWorkspaceMode } // Equatable wrapper for the Sidebar content to minimize diffs while keeping // the internal view hierarchy (which still uses EnvironmentObject) unchanged. struct EquatableSidebarContainer: View, Equatable { static func == (lhs: EquatableSidebarContainer, rhs: EquatableSidebarContainer) -> Bool { lhs.key == rhs.key } let key: SidebarDigest let content: () -> Content var body: some View { content() } } ================================================ FILE: views/ExtensionsImportSheets.swift ================================================ import SwiftUI import AppKit private let importSheetPadding: CGFloat = 16 struct MCPImportSheet: View { @Binding var candidates: [MCPImportCandidate] let isImporting: Bool let statusMessage: String? let title: String let subtitle: String let onCancel: () -> Void let onImport: () -> Void var body: some View { VStack(alignment: .leading, spacing: 12) { Text(title) .font(.title3) .fontWeight(.semibold) Text(subtitle) .font(.subheadline) .foregroundStyle(.secondary) if isImporting { HStack(spacing: 8) { ProgressView().controlSize(.small) Text("Scanning…") .font(.caption) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, minHeight: 160) } else if candidates.isEmpty { VStack(spacing: 8) { Image(systemName: "server.rack") .font(.system(size: 28)) .foregroundStyle(.secondary) Text(statusMessage ?? "No MCP servers found.") .font(.subheadline) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, minHeight: 200) } else { List { ForEach($candidates) { $item in VStack(alignment: .leading, spacing: 6) { HStack(alignment: .top, spacing: 8) { Toggle("", isOn: $item.isSelected) .labelsHidden() .controlSize(.small) VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { Text(item.name) .font(.body.weight(.medium)) Text(item.kind.rawValue) .font(.caption) .foregroundStyle(.secondary) } if let desc = item.description, !desc.isEmpty { Text(desc) .font(.caption) .foregroundStyle(.secondary) } else if let url = item.url, !url.isEmpty { Text(url) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) } else if let cmd = item.command, !cmd.isEmpty { Text(cmd) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) } Text("Sources: \(item.sources.joined(separator: ", "))") .font(.caption2) .foregroundStyle(.secondary) } Spacer(minLength: 0) VStack(alignment: .trailing, spacing: 6) { Picker("", selection: $item.resolution) { ForEach(ImportResolutionChoice.allCases) { choice in Text(choice.title).tag(choice) } } .labelsHidden() .pickerStyle(.segmented) .frame(width: 240) if item.resolution == .rename { TextField("New name", text: $item.renameName) .textFieldStyle(.roundedBorder) .frame(maxWidth: 220) } } } if item.hasConflict { Label("Already exists in CodMate (default: skip)", systemImage: "exclamationmark.triangle") .font(.caption) .foregroundStyle(.orange) } else if item.hasNameCollision { Label("Duplicate name in import list", systemImage: "exclamationmark.triangle") .font(.caption) .foregroundStyle(.orange) } } .padding(.vertical, 6) .contextMenu { buildOpenMenu(sourcePaths: item.sourcePaths) buildRevealMenu(sourcePaths: item.sourcePaths) } } } .listStyle(.inset) } Spacer(minLength: 0) if let statusMessage, !statusMessage.isEmpty { Text(statusMessage) .font(.caption) .foregroundStyle(.secondary) } HStack { Text("Conflicts default to Skip. Review before importing.") .font(.caption) .foregroundStyle(.secondary) Spacer() if candidates.isEmpty && !isImporting { Button("Close") { onCancel() } .buttonStyle(.borderedProminent) } else { Button("Cancel") { onCancel() } Button("Import") { onImport() } .buttonStyle(.borderedProminent) .disabled(isImporting || candidates.filter { $0.isSelected }.isEmpty) } } } .padding(importSheetPadding) } } struct SkillsImportSheet: View { @Binding var candidates: [SkillImportCandidate] let isImporting: Bool let statusMessage: String? let title: String let subtitle: String let onCancel: () -> Void let onImport: () -> Void var body: some View { VStack(alignment: .leading, spacing: 12) { Text(title) .font(.title3) .fontWeight(.semibold) Text(subtitle) .font(.subheadline) .foregroundStyle(.secondary) if isImporting { HStack(spacing: 8) { ProgressView().controlSize(.small) Text("Scanning…") .font(.caption) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, minHeight: 160) } else if candidates.isEmpty { VStack(spacing: 8) { Image(systemName: "sparkles") .font(.system(size: 28)) .foregroundStyle(.secondary) Text(statusMessage ?? "No skills found.") .font(.subheadline) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, minHeight: 200) } else { List { ForEach($candidates) { $item in VStack(alignment: .leading, spacing: 6) { HStack(alignment: .top, spacing: 8) { Toggle("", isOn: $item.isSelected) .labelsHidden() .controlSize(.small) VStack(alignment: .leading, spacing: 4) { Text(item.name) .font(.body.weight(.medium)) if !item.summary.isEmpty { Text(item.summary) .font(.caption) .foregroundStyle(.secondary) } Text("Sources: \(item.sources.joined(separator: ", "))") .font(.caption2) .foregroundStyle(.secondary) } Spacer(minLength: 0) } if item.hasConflict { Label(item.conflictDetail ?? "Already exists in CodMate (default: skip)", systemImage: "exclamationmark.triangle") .font(.caption) .foregroundStyle(.orange) } if item.hasConflict { HStack(spacing: 8) { Picker("", selection: $item.resolution) { ForEach(ImportResolutionChoice.allCases) { choice in Text(choice.title).tag(choice) } } .labelsHidden() .pickerStyle(.segmented) .frame(width: 240) if item.resolution == .rename { TextField("New ID", text: $item.renameId) .textFieldStyle(.roundedBorder) .frame(maxWidth: 220) } } } } .padding(.vertical, 6) .contextMenu { buildOpenMenu(sourcePaths: item.sourcePaths) buildRevealMenu(sourcePaths: item.sourcePaths) } } } .listStyle(.inset) } Spacer(minLength: 0) if let statusMessage, !statusMessage.isEmpty { Text(statusMessage) .font(.caption) .foregroundStyle(.secondary) } HStack { Text("Conflicts default to Skip. Review before importing.") .font(.caption) .foregroundStyle(.secondary) Spacer() if candidates.isEmpty && !isImporting { Button("Close") { onCancel() } .buttonStyle(.borderedProminent) } else { Button("Cancel") { onCancel() } Button("Import") { onImport() } .buttonStyle(.borderedProminent) .disabled(isImporting || candidates.filter { $0.isSelected }.isEmpty) } } } .padding(importSheetPadding) } } struct CommandsImportSheet: View { @Binding var candidates: [CommandImportCandidate] let isImporting: Bool let statusMessage: String? let title: String let subtitle: String let onCancel: () -> Void let onImport: () -> Void var body: some View { VStack(alignment: .leading, spacing: 12) { Text(title) .font(.title3) .fontWeight(.semibold) Text(subtitle) .font(.subheadline) .foregroundStyle(.secondary) if isImporting { HStack(spacing: 8) { ProgressView().controlSize(.small) Text("Scanning…") .font(.caption) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, minHeight: 160) } else if candidates.isEmpty { VStack(spacing: 8) { Image(systemName: "command") .font(.system(size: 28)) .foregroundStyle(.secondary) Text(statusMessage ?? "No commands found.") .font(.subheadline) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, minHeight: 200) } else { List { ForEach($candidates) { $item in VStack(alignment: .leading, spacing: 6) { HStack(alignment: .top, spacing: 8) { Toggle("", isOn: $item.isSelected) .labelsHidden() .controlSize(.small) VStack(alignment: .leading, spacing: 4) { Text(item.name) .font(.body.weight(.medium)) if !item.description.isEmpty { Text(item.description) .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) } Text("Sources: \(item.sources.joined(separator: ", "))") .font(.caption2) .foregroundStyle(.secondary) } Spacer(minLength: 0) } if item.hasConflict { Label("Already exists in CodMate (default: skip)", systemImage: "exclamationmark.triangle") .font(.caption) .foregroundStyle(.orange) } if item.hasConflict { HStack(spacing: 8) { Picker("", selection: $item.resolution) { ForEach(ImportResolutionChoice.allCases) { choice in Text(choice.title).tag(choice) } } .labelsHidden() .pickerStyle(.segmented) .frame(width: 240) if item.resolution == .rename { TextField("New ID", text: $item.renameId) .textFieldStyle(.roundedBorder) .frame(maxWidth: 220) } } } } .padding(.vertical, 6) .contextMenu { buildOpenMenu(sourcePaths: item.sourcePaths) buildRevealMenu(sourcePaths: item.sourcePaths) } } } .listStyle(.inset) } Spacer(minLength: 0) if let statusMessage, !statusMessage.isEmpty { Text(statusMessage) .font(.caption) .foregroundStyle(.secondary) } HStack { Text("Conflicts default to Skip. Review before importing.") .font(.caption) .foregroundStyle(.secondary) Spacer() if candidates.isEmpty && !isImporting { Button("Close") { onCancel() } .buttonStyle(.borderedProminent) } else { Button("Cancel") { onCancel() } Button("Import") { onImport() } .buttonStyle(.borderedProminent) .disabled(isImporting || candidates.filter { $0.isSelected }.isEmpty) } } } .padding(importSheetPadding) } } struct HooksImportSheet: View { @Binding var candidates: [HookImportCandidate] let isImporting: Bool let statusMessage: String? let title: String let subtitle: String let onCancel: () -> Void let onImport: () -> Void var body: some View { VStack(alignment: .leading, spacing: 12) { Text(title) .font(.title3) .fontWeight(.semibold) Text(subtitle) .font(.subheadline) .foregroundStyle(.secondary) if isImporting { HStack(spacing: 8) { ProgressView().controlSize(.small) Text("Scanning…") .font(.caption) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, minHeight: 160) } else if candidates.isEmpty { VStack(spacing: 8) { Image(systemName: "link") .font(.system(size: 28)) .foregroundStyle(.secondary) Text(statusMessage ?? "No hooks found.") .font(.subheadline) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, minHeight: 200) } else { List { ForEach($candidates) { $item in VStack(alignment: .leading, spacing: 6) { HStack(alignment: .top, spacing: 8) { Toggle("", isOn: $item.isSelected) .labelsHidden() .controlSize(.small) VStack(alignment: .leading, spacing: 4) { Text(item.rule.name.isEmpty ? item.rule.event : item.rule.name) .font(.body.weight(.medium)) Text(summaryText(item.rule)) .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) Text("Sources: \(item.sources.sorted().joined(separator: ", "))") .font(.caption2) .foregroundStyle(.secondary) } Spacer(minLength: 0) VStack(alignment: .trailing, spacing: 6) { Picker("", selection: $item.resolution) { ForEach(ImportResolutionChoice.allCases) { choice in Text(choice.title).tag(choice) } } .labelsHidden() .pickerStyle(.segmented) .frame(width: 240) if item.resolution == .rename { TextField("New name", text: $item.renameName) .textFieldStyle(.roundedBorder) .frame(maxWidth: 220) } } } if item.hasConflict { Label("Already exists in CodMate (default: skip)", systemImage: "exclamationmark.triangle") .font(.caption) .foregroundStyle(.orange) } else if item.hasNameCollision { Label("Duplicate name in import list", systemImage: "exclamationmark.triangle") .font(.caption) .foregroundStyle(.orange) } } .padding(.vertical, 6) .contextMenu { buildOpenMenu(sourcePaths: item.sourcePaths) buildRevealMenu(sourcePaths: item.sourcePaths) } } } .listStyle(.inset) } Spacer(minLength: 0) if let statusMessage, !statusMessage.isEmpty { Text(statusMessage) .font(.caption) .foregroundStyle(.secondary) } HStack { Text("Conflicts default to Skip. Review before importing.") .font(.caption) .foregroundStyle(.secondary) Spacer() if candidates.isEmpty && !isImporting { Button("Close") { onCancel() } .buttonStyle(.borderedProminent) } else { Button("Cancel") { onCancel() } Button("Import") { onImport() } .buttonStyle(.borderedProminent) .disabled(isImporting || candidates.filter { $0.isSelected }.isEmpty) } } } .padding(importSheetPadding) } private func summaryText(_ rule: HookRule) -> String { let event = rule.event.isEmpty ? "Event" : rule.event let matcher = rule.matcher?.trimmingCharacters(in: .whitespacesAndNewlines) let cmd = rule.commands.first?.command.trimmingCharacters(in: .whitespacesAndNewlines) let parts = [ event, (matcher?.isEmpty == false ? "matcher: \(matcher!)" : nil), (cmd?.isEmpty == false ? cmd : nil), "\(rule.commands.count) command(s)" ].compactMap { $0 } return parts.joined(separator: " · ") } } @ViewBuilder private func buildOpenMenu(sourcePaths: [String: String]) -> some View { let editors = EditorApp.installedEditors let sortedSources = sourcePaths.keys.sorted() if sortedSources.isEmpty { EmptyView() } else { Menu { if sortedSources.count == 1, let key = sortedSources.first, let path = sourcePaths[key] { buildEditorEntries(editors: editors, path: path) } else { ForEach(sortedSources, id: \.self) { key in if let path = sourcePaths[key] { Menu(key) { buildEditorEntries(editors: editors, path: path) } } } } } label: { Label("Open in", systemImage: "arrow.up.forward.app") } } } @ViewBuilder private func buildRevealMenu(sourcePaths: [String: String]) -> some View { let sortedSources = sourcePaths.keys.sorted() if sortedSources.isEmpty { EmptyView() } else if sortedSources.count == 1, let key = sortedSources.first, let path = sourcePaths[key] { Button { revealInFinder(path) } label: { Label("Reveal in Finder", systemImage: "folder") } } else { Menu { ForEach(sortedSources, id: \.self) { key in if let path = sourcePaths[key] { Button(key) { revealInFinder(path) } } } } label: { Label("Reveal in Finder", systemImage: "folder") } } } @ViewBuilder private func buildEditorEntries(editors: [EditorApp], path: String) -> some View { if editors.isEmpty { Button("Default App") { openSourcePath(path) } } else { ForEach(editors) { editor in Button { openSourcePath(path, using: editor) } label: { Label { Text(editor.title) } icon: { if let icon = editor.menuIcon { Image(nsImage: icon) .frame(width: 14, height: 14) } else { Image(systemName: "chevron.left.forwardslash.chevron.right") } } } } } } private func openSourcePath(_ path: String) { let url = URL(fileURLWithPath: path) NSWorkspace.shared.open(url) } private func revealInFinder(_ path: String) { let url = URL(fileURLWithPath: path) NSWorkspace.shared.activateFileViewerSelecting([url]) } private func openSourcePath(_ path: String, using editor: EditorApp) { // Try CLI command first. if let exe = findExecutableInPath(editor.cliCommand) { let p = Process() p.executableURL = URL(fileURLWithPath: exe) p.arguments = [path] p.standardOutput = Pipe(); p.standardError = Pipe() do { try p.run() return } catch { // Fall through to bundle open. } } if let appURL = editor.appURL { let config = NSWorkspace.OpenConfiguration() config.activates = true NSWorkspace.shared.open([URL(fileURLWithPath: path)], withApplicationAt: appURL, configuration: config, completionHandler: nil) return } openSourcePath(path) } private func findExecutableInPath(_ name: String) -> String? { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/which") process.arguments = [name] let pipe = Pipe(); process.standardOutput = pipe; process.standardError = Pipe() do { try process.run() process.waitUntilExit() guard process.terminationStatus == 0 else { return nil } let data = pipe.fileHandleForReading.readDataToEndOfFile() let path = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) return (path?.isEmpty == false) ? path : nil } catch { return nil } } ================================================ FILE: views/ExtensionsSettingsView.swift ================================================ import SwiftUI struct ExtensionsSettingsView: View { @Binding var selectedTab: ExtensionsSettingsTab @ObservedObject var preferences: SessionPreferencesStore var openMCPMateDownload: () -> Void @EnvironmentObject private var wizardGuard: WizardGuard @State private var lastStableTab: ExtensionsSettingsTab = .commands var body: some View { VStack(alignment: .leading, spacing: 12) { header Group { if #available(macOS 15.0, *) { TabView(selection: $selectedTab) { Tab("Commands", systemImage: "command", value: ExtensionsSettingsTab.commands) { SettingsTabContent { CommandsSettingsView(preferences: preferences) } } Tab("Hooks", systemImage: "link", value: ExtensionsSettingsTab.hooks) { SettingsTabContent { HooksSettingsView(preferences: preferences) } } Tab("MCP Servers", systemImage: "server.rack", value: ExtensionsSettingsTab.mcp) { SettingsTabContent { MCPServersSettingsPane( preferences: preferences, openMCPMateDownload: openMCPMateDownload, showHeader: false ) } } Tab("Skills", systemImage: "sparkles", value: ExtensionsSettingsTab.skills) { SettingsTabContent { SkillsSettingsView(preferences: preferences) } } } } else { TabView(selection: $selectedTab) { SettingsTabContent { CommandsSettingsView(preferences: preferences) } .tabItem { Label("Commands", systemImage: "command") } .tag(ExtensionsSettingsTab.commands) SettingsTabContent { HooksSettingsView(preferences: preferences) } .tabItem { Label("Hooks", systemImage: "link") } .tag(ExtensionsSettingsTab.hooks) SettingsTabContent { MCPServersSettingsPane( preferences: preferences, openMCPMateDownload: openMCPMateDownload, showHeader: false ) } .tabItem { Label("MCP Servers", systemImage: "server.rack") } .tag(ExtensionsSettingsTab.mcp) SettingsTabContent { SkillsSettingsView(preferences: preferences) } .tabItem { Label("Skills", systemImage: "sparkles") } .tag(ExtensionsSettingsTab.skills) } } } .padding(.bottom, 16) } .onAppear { lastStableTab = selectedTab } .onChange(of: selectedTab) { newValue in if wizardGuard.isActive { if newValue != lastStableTab { selectedTab = lastStableTab } } else { lastStableTab = newValue } } } private var header: some View { VStack(alignment: .leading, spacing: 6) { Text("Extensions Settings") .font(.title2) .fontWeight(.bold) Text("Manage MCP servers, Skills, and Commands across AI CLI providers.") .font(.subheadline) .foregroundStyle(.secondary) } } } ================================================ FILE: views/ExternalTerminalMenuHelpers.swift ================================================ import Foundation func externalTerminalOrderedProfiles(includeNone: Bool) -> [ExternalTerminalProfile] { let profiles = ExternalTerminalProfileStore.shared.availableProfiles(includeNone: includeNone) var ordered: [ExternalTerminalProfile] = [] if includeNone, let none = profiles.first(where: { $0.isNone }) { ordered.append(none) } if let terminal = profiles.first(where: { $0.isTerminal }) { ordered.append(terminal) } let others = profiles .filter { !$0.isTerminal && !$0.isNone } .sorted { $0.displayTitle.localizedCaseInsensitiveCompare($1.displayTitle) == .orderedAscending } return ordered + others } func externalTerminalMenuProfiles() -> [ExternalTerminalProfile] { externalTerminalOrderedProfiles(includeNone: false) } func embeddedTerminalProfile() -> ExternalTerminalProfile { ExternalTerminalProfile( id: "codmate.embedded", title: "CodMate", bundleIdentifiers: [], urlTemplate: nil, supportsCommand: true, supportsDirectory: true, managedByCodMate: true, commandStyle: .standard ) } func externalTerminalMenuItems( idPrefix: String, titlePrefix: String? = nil, titleSuffix: String? = nil, profiles: [ExternalTerminalProfile]? = nil, action: @escaping (ExternalTerminalProfile) -> Void ) -> [SplitMenuItem] { let list = profiles ?? externalTerminalMenuProfiles() return list.map { profile in let title = (titlePrefix ?? "") + profile.displayTitle + (titleSuffix ?? "") let icon = profile.id == "codmate.embedded" ? "macwindow" : "terminal" return SplitMenuItem( id: "\(idPrefix)-\(profile.id)", kind: .action(title: title, systemImage: icon, run: { action(profile) }) ) } } ================================================ FILE: views/GeminiSettingsView.swift ================================================ import SwiftUI struct GeminiSettingsView: View { @ObservedObject var vm: GeminiVM @ObservedObject var preferences: SessionPreferencesStore @StateObject private var providerCatalog = UnifiedProviderCatalogModel() @State private var providerModels: [String] = [] @State private var lastProviderId: String? @State private var showDisableBlockedAlert = false private let docsURL = URL(string: "https://geminicli.com/docs/cli/settings/")! var body: some View { VStack(alignment: .leading, spacing: 12) { header GroupBox { HStack(spacing: 12) { VStack(alignment: .leading, spacing: 2) { Label("Enable Gemini CLI", systemImage: "power") .font(.subheadline).fontWeight(.medium) Text("Turning this off hides Gemini UI, stops session scans, and makes settings read-only.") .font(.caption) .foregroundStyle(.secondary) } Spacer() Toggle("", isOn: geminiEnabledBinding) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) } .padding(10) } Group { if #available(macOS 15.0, *) { TabView { Tab("Provider", systemImage: "server.rack") { providerTab } Tab("General", systemImage: "gearshape") { generalTab } Tab("Runtime", systemImage: "gauge") { runtimeTab } Tab("Sessions", systemImage: "folder.badge.gearshape") { sessionsTab } Tab("Model", systemImage: "cpu") { modelTab } Tab("Raw Config", systemImage: "doc.text") { rawTab } } } else { TabView { providerTab .tabItem { Label("Provider", systemImage: "server.rack") } generalTab .tabItem { Label("General", systemImage: "gearshape") } runtimeTab .tabItem { Label("Runtime", systemImage: "gauge") } sessionsTab .tabItem { Label("Sessions", systemImage: "folder.badge.gearshape") } modelTab .tabItem { Label("Model", systemImage: "cpu") } rawTab .tabItem { Label("Raw Config", systemImage: "doc.text") } } } } .controlSize(.regular) .disabled(!preferences.cliGeminiEnabled) .opacity(preferences.cliGeminiEnabled ? 1.0 : 0.6) } .padding(.bottom, 16) .task { await vm.loadIfNeeded() await reloadProxyCatalog() } // Removed rerouteBuiltIn/reroute3P onChange handlers - all providers now use Auto-Proxy mode .onChange(of: preferences.oauthProvidersEnabled) { _ in Task { await reloadProxyCatalog() } } .onChange(of: preferences.apiKeyProvidersEnabled) { _ in Task { await reloadProxyCatalog() } } .onChange(of: CLIProxyService.shared.isRunning) { _ in Task { await reloadProxyCatalog() } } .alert("At least one CLI must remain enabled.", isPresented: $showDisableBlockedAlert) { Button("OK", role: .cancel) {} } } private var geminiEnabledBinding: Binding { Binding( get: { preferences.cliGeminiEnabled }, set: { newValue in if preferences.setCLIEnabled(.gemini, enabled: newValue) == false { showDisableBlockedAlert = true } } ) } private var header: some View { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 6) { Text("Gemini CLI Settings") .font(.title2) .fontWeight(.bold) Text("Configure Gemini CLI defaults: features, models, and raw settings.json.") .font(.subheadline) .foregroundStyle(.secondary) } Spacer() Link(destination: docsURL) { Label("Docs", systemImage: "questionmark.circle") .labelStyle(.iconOnly) } .buttonStyle(.plain) } } private func reloadProxyCatalog(forceRefresh: Bool = false) async { await providerCatalog.reload(preferences: preferences, forceRefresh: forceRefresh) normalizeProxySelection() } private func normalizeProxySelection() { let normalized = providerCatalog.normalizeProviderId(preferences.geminiProxyProviderId) if normalized != preferences.geminiProxyProviderId { preferences.geminiProxyProviderId = normalized } let providerChanged = lastProviderId != nil && lastProviderId != preferences.geminiProxyProviderId lastProviderId = preferences.geminiProxyProviderId guard let providerId = preferences.geminiProxyProviderId else { providerModels = [] preferences.geminiProxyModelId = nil return } providerModels = providerCatalog.models(for: providerId) if providerChanged { preferences.geminiProxyModelId = nil return } guard !providerModels.isEmpty else { return } } private var generalTab: some View { SettingsTabContent { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { GridRow { VStack(alignment: .leading, spacing: 2) { Label("Preview Features", systemImage: "wand.and.stars") .font(.subheadline).fontWeight(.medium) Text("Enable experimental features like preview models.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("", isOn: $vm.previewFeatures) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) .frame(maxWidth: .infinity, alignment: .trailing) .onChange(of: vm.previewFeatures) { _ in vm.applyPreviewFeaturesChange() } } dividerRow GridRow { VStack(alignment: .leading, spacing: 2) { Label("Prompt Completion", systemImage: "text.cursor") .font(.subheadline).fontWeight(.medium) Text("Show inline command suggestions while typing.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("", isOn: $vm.enablePromptCompletion) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) .frame(maxWidth: .infinity, alignment: .trailing) .onChange(of: vm.enablePromptCompletion) { _ in vm.applyPromptCompletionChange() } } dividerRow GridRow { VStack(alignment: .leading, spacing: 2) { Label("Vim Mode", systemImage: "keyboard") .font(.subheadline).fontWeight(.medium) Text("Use Vim keybindings inside Gemini CLI.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("", isOn: $vm.vimMode) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) .frame(maxWidth: .infinity, alignment: .trailing) .onChange(of: vm.vimMode) { _ in vm.applyVimModeChange() } } dividerRow GridRow { VStack(alignment: .leading, spacing: 2) { Label("Disable Auto Update", systemImage: "stop.circle") .font(.subheadline).fontWeight(.medium) Text("Prevent Gemini CLI from auto-updating itself.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("", isOn: $vm.disableAutoUpdate) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) .frame(maxWidth: .infinity, alignment: .trailing) .onChange(of: vm.disableAutoUpdate) { _ in vm.applyDisableAutoUpdateChange() } } dividerRow GridRow { VStack(alignment: .leading, spacing: 2) { Label("Session Retention", systemImage: "trash") .font(.subheadline).fontWeight(.medium) Text("Automatically clean up old sessions when enabled.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("", isOn: $vm.sessionRetentionEnabled) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) .frame(maxWidth: .infinity, alignment: .trailing) .onChange(of: vm.sessionRetentionEnabled) { _ in vm.applySessionRetentionChange() } } if let error = vm.lastError { dividerRow GridRow { Text("") Text(error) .font(.caption) .foregroundStyle(.red) .frame(maxWidth: .infinity, alignment: .trailing) } } } } } private var sessionsTab: some View { SettingsTabContent { SessionsPathPane(preferences: preferences, fixedKind: .gemini) } } private var providerTab: some View { SettingsTabContent { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { GridRow { VStack(alignment: .leading, spacing: 2) { Label("Active Provider", systemImage: "server.rack") .font(.subheadline).fontWeight(.medium) Text("Use built-in provider or route through CLI Proxy API.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } SimpleProviderPicker(providerId: $preferences.geminiProxyProviderId) .frame(maxWidth: .infinity, alignment: .trailing) .onChange(of: preferences.geminiProxyProviderId) { _ in normalizeProxySelection() if preferences.geminiProxyProviderId == nil { Task { await reloadProxyCatalog(forceRefresh: true) } } } } dividerRow GridRow { VStack(alignment: .leading, spacing: 2) { Label("Model List", systemImage: "list.bullet") .font(.subheadline).fontWeight(.medium) Text("Select a default model from the available models.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } SimpleModelPicker( models: providerModels, isDisabled: preferences.geminiProxyProviderId == nil || !providerCatalog.isProviderAvailable(preferences.geminiProxyProviderId), providerId: preferences.geminiProxyProviderId, providerCatalog: providerCatalog, modelId: $preferences.geminiProxyModelId ) .frame(maxWidth: .infinity, alignment: .trailing) } } } } private var runtimeTab: some View { SettingsTabContent { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { GridRow { VStack(alignment: .leading, spacing: 2) { Label("Sandbox Mode", systemImage: "lock.shield") .font(.subheadline).fontWeight(.medium) Text("Controls Gemini CLI sandbox defaults for new sessions.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Picker("", selection: $preferences.defaultResumeSandboxMode) { ForEach(SandboxMode.allCases) { Text($0.title).tag($0) } } .labelsHidden() .frame(maxWidth: .infinity, alignment: .trailing) } dividerRow GridRow { VStack(alignment: .leading, spacing: 2) { Label("Approval Policy", systemImage: "hand.raised") .font(.subheadline).fontWeight(.medium) Text("Set the default automation level when launching Gemini CLI.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Picker("", selection: $preferences.defaultResumeApprovalPolicy) { ForEach(ApprovalPolicy.allCases) { Text($0.title).tag($0) } } .labelsHidden() .frame(maxWidth: .infinity, alignment: .trailing) } } } } private var modelTab: some View { SettingsTabContent { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { GridRow { VStack(alignment: .leading, spacing: 2) { Label("Model", systemImage: "cpu") .font(.subheadline).fontWeight(.medium) Text("Choose the model alias to use when launching Gemini CLI.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Picker("", selection: $vm.selectedModelId) { ForEach(vm.modelOptions) { option in Text(option.title).tag(option.value) } } .labelsHidden() .frame(maxWidth: .infinity, alignment: .trailing) .onChange(of: vm.selectedModelId) { _ in vm.applyModelSelectionChange() } } if let selection = vm.selectedModelId, let descriptor = vm.modelOptions.first(where: { $0.value == selection })?.subtitle { GridRow { Text("") Text(descriptor) .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .trailing) } } else if let descriptor = vm.modelOptions.first(where: { $0.value == nil })?.subtitle { GridRow { Text("") Text(descriptor) .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .trailing) } } dividerRow GridRow { VStack(alignment: .leading, spacing: 2) { Label("Max Session Turns", systemImage: "arrow.counterclockwise") .font(.subheadline).fontWeight(.medium) Text("Number of turns kept in memory (-1 keeps everything).") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Stepper(value: $vm.maxSessionTurns, in: -1...10_000, step: 1) { Text(vm.maxSessionTurns < 0 ? "Unlimited (-1)" : "\(vm.maxSessionTurns)") } .frame(maxWidth: .infinity, alignment: .trailing) .onChange(of: vm.maxSessionTurns) { _ in vm.applyMaxSessionTurnsChange() } } dividerRow GridRow { VStack(alignment: .leading, spacing: 2) { Label("Compression Threshold", systemImage: "arrow.down.circle") .font(.subheadline).fontWeight(.medium) Text("Fraction of context usage that triggers compression.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } VStack(alignment: .trailing, spacing: 6) { Slider(value: $vm.compressionThreshold, in: 0...1, step: 0.05) .frame(maxWidth: 240) .onChange(of: vm.compressionThreshold) { _ in vm.applyCompressionThresholdChange() } Text("\(vm.compressionThreshold, format: .number.precision(.fractionLength(2)))") .font(.caption) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .trailing) } dividerRow GridRow { VStack(alignment: .leading, spacing: 2) { Label("Skip Next Speaker Check", systemImage: "checkmark.circle.badge.xmark") .font(.subheadline).fontWeight(.medium) Text("Bypass the next speaker role verification step.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("", isOn: $vm.skipNextSpeakerCheck) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) .frame(maxWidth: .infinity, alignment: .trailing) .onChange(of: vm.skipNextSpeakerCheck) { _ in vm.applySkipNextSpeakerChange() } } if let error = vm.lastError { dividerRow GridRow { Text("") Text(error) .font(.caption) .foregroundStyle(.red) .frame(maxWidth: .infinity, alignment: .trailing) } } } } } private var rawTab: some View { SettingsTabContent { ZStack(alignment: .topTrailing) { ScrollView { Text(vm.rawSettingsText.isEmpty ? "(settings.json not found or empty)" : vm.rawSettingsText) .font(.system(.caption, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .topLeading) } HStack(spacing: 8) { Button { Task { await vm.refreshSettings(); await vm.reloadRawSettings() } } label: { Image(systemName: "arrow.clockwise") } .help("Reload settings") .buttonStyle(.borderless) Button { vm.openSettingsInEditor() } label: { Image(systemName: "square.and.pencil") } .help("Reveal settings.json") .buttonStyle(.borderless) } } } } @ViewBuilder private var dividerRow: some View { GridRow { Divider().gridCellColumns(2) } } } ================================================ FILE: views/GitChanges/GitChangesPanel+Browser.swift ================================================ import SwiftUI #if canImport(AppKit) import AppKit #endif extension GitChangesPanel { struct BrowserRow: Identifiable { let node: FileNode let depth: Int var id: String { if let dir = node.dirPath { return "dir:\(dir)" } if let file = node.fullPath { return "file:\(file)" } return "node:\(node.name)-\(depth)" } var directoryKey: String? { node.dirPath } var filePath: String? { node.fullPath } } var browserTreeView: some View { VStack(alignment: .leading, spacing: 6) { if isLoadingBrowserTree { HStack(spacing: 8) { ProgressView() Text("Loading repository…") .font(.caption) .foregroundStyle(.secondary) } .padding(.vertical, 6) } else if let error = browserTreeError { VStack(spacing: 8) { Text(error) .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.leading) HStack { Button("Retry") { requestBrowserTreeReload(force: true) } if let action = onRequestAuthorization { Button("Authorize Repository Folder…") { action() } } } .controlSize(.small) } .padding(.vertical, 6) } else if displayedBrowserRows.isEmpty { let message = treeQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "No files in repository." : "No matches." Text(message) .font(.caption) .foregroundStyle(.secondary) .padding(.vertical, 6) } else { LazyVStack(alignment: .leading, spacing: 0) { ForEach(displayedBrowserRows) { row in browserRow(row) } } } if browserTreeTruncated { Text("Showing first \(browserEntryLimit) entries. Use search to narrow results.") .font(.caption2) .foregroundStyle(.secondary) .padding(.top, 6) } if !isLoadingBrowserTree, browserTreeError == nil, browserTotalEntries > 0 { Text("\(browserTotalEntries)\(browserTreeTruncated ? "+" : "") items") .font(.caption2) .foregroundStyle(.tertiary) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 2) } @ViewBuilder private func browserRow(_ row: BrowserRow) -> some View { if row.node.isDirectory { browserDirectoryRow(row) } else { browserFileRow(row) } } private func browserDirectoryRow(_ row: BrowserRow) -> some View { let key = row.directoryKey ?? row.node.name let repoAvailable = vm.repoRoot != nil let indent = CGFloat(max(row.depth, 0)) * indentStep let isExpanded = !treeQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || expandedDirsBrowser.contains(key) return HStack(spacing: 0) { ZStack(alignment: .leading) { Color.clear.frame(width: indent + chevronWidth) let guideColor = Color.secondary.opacity(0.15) if row.depth > 0 { ForEach(0.. some View { Group { if let path = row.filePath { browserFileRowContent(path: path, row: row) } else { EmptyView() } } } @ViewBuilder private func browserFileRowContent(path: String, row: BrowserRow) -> some View { let indent = CGFloat(max(row.depth, 0)) * indentStep let change = vm.changes.first { $0.path == path } let repoAvailable = vm.repoRoot != nil let isSelected = vm.selectedPath == path let bulletColor = change.map { statusColor(for: $0.path) } ?? Color.clear // Explorer overlay: do not show Stage/Unstage quick actions; keep them in context menus only let showStageAction = false let activeHover = hoverBrowserFilePath == path let buttonCount: Int = { var count = 1 // Open #if canImport(AppKit) count += 1 // Reveal #endif if showStageAction { count += 1 } return count }() let actionWidth = CGFloat(buttonCount) * quickActionWidth + CGFloat(max(buttonCount - 1, 0)) * hoverButtonSpacing HStack(spacing: 0) { ZStack(alignment: .leading) { Color.clear.frame(width: indent) if row.depth > 0 { let guideColor = Color.secondary.opacity(0.15) ForEach(0..() : contentSearchMatches let filtered = query.isEmpty ? browserNodes : filteredNodes(browserNodes, query: query, contentMatches: matches) displayedBrowserRows = flattenBrowserNodes(filtered, depth: 0, forceExpand: !query.isEmpty) } private func flattenBrowserNodes(_ nodes: [FileNode], depth: Int, forceExpand: Bool) -> [BrowserRow] { var rows: [BrowserRow] = [] for node in nodes { let row = BrowserRow(node: node, depth: depth) rows.append(row) if node.isDirectory, let key = node.dirPath ?? (depth == 0 ? node.name : nil) { if forceExpand || expandedDirsBrowser.contains(key) { let children = GitReviewTreeBuilder.explorerSort(node.children ?? []) rows.append(contentsOf: flattenBrowserNodes(children, depth: depth + 1, forceExpand: forceExpand)) } } } return rows } private func toggleBrowserDirectory(_ key: String) { if expandedDirsBrowser.contains(key) { expandedDirsBrowser.remove(key) } else { expandedDirsBrowser.insert(key) } rebuildBrowserDisplayed() } private func buildBrowserTreeFromPaths(_ paths: [String]) -> [FileNode] { struct Builder { var children: [String: Builder] = [:] var filePath: String? = nil } var root = Builder() for path in paths { guard !path.isEmpty else { continue } let components = path.split(separator: "/").map(String.init) guard !components.isEmpty else { continue } func insert(_ index: Int, current: inout Builder) { let key = components[index] if index == components.count - 1 { var child = current.children[key, default: Builder()] child.filePath = path current.children[key] = child } else { var child = current.children[key, default: Builder()] insert(index + 1, current: &child) current.children[key] = child } } insert(0, current: &root) } func convert(_ builder: Builder, prefix: String?) -> [FileNode] { var nodes: [FileNode] = [] for (name, child) in builder.children { let fullPath = prefix.map { "\($0)/\(name)" } ?? name if let filePath = child.filePath, child.children.isEmpty { nodes.append(FileNode(name: name, fullPath: filePath, dirPath: nil, children: nil)) } else { let childrenNodes = convert(child, prefix: fullPath) nodes.append(FileNode(name: name, fullPath: nil, dirPath: fullPath, children: GitReviewTreeBuilder.explorerSort(childrenNodes))) } } return GitReviewTreeBuilder.explorerSort(nodes) } return convert(root, prefix: nil) } private func buildBrowserTreeFromFileSystem(root: URL, limit: Int) -> (nodes: [FileNode], truncated: Bool, total: Int, error: String?) { let (paths, truncated, error) = collectFileSystemPaths(root: root, limit: limit) if paths.isEmpty { return ([], truncated, 0, error ?? "Unable to enumerate repository contents.") } let nodes = buildBrowserTreeFromPaths(paths) return (nodes, truncated, paths.count, error) } private func collectFileSystemPaths(root: URL, limit: Int) -> ([String], Bool, String?) { let fm = FileManager.default let keys: [URLResourceKey] = [.isDirectoryKey, .isPackageKey] var encounteredError: String? let options: FileManager.DirectoryEnumerationOptions = [.skipsPackageDescendants] guard let enumerator = fm.enumerator(at: root, includingPropertiesForKeys: keys, options: options, errorHandler: { url, error in encounteredError = error.localizedDescription return true }) else { return ([], false, "Unable to enumerate repository contents.") } let base = root.path + "/" var collected: [String] = [] var truncated = false while let item = enumerator.nextObject() as? URL { let path = item.path guard path.hasPrefix(base) else { continue } let relative = String(path.dropFirst(base.count)) if relative.isEmpty { continue } if relative == ".git" || relative.hasPrefix(".git/") { enumerator.skipDescendants() continue } if let values = try? item.resourceValues(forKeys: Set(keys)), values.isDirectory == true { continue } collected.append(relative) if collected.count >= limit { truncated = true break } } return (collected, truncated, encounteredError) } private func handleBrowserSelection(path: String) { #if canImport(AppKit) previewImageTask?.cancel() previewImage = nil #endif vm.selectedPath = path if let change = vm.changes.first(where: { $0.path == path }) { if change.worktree != nil { vm.selectedSide = .unstaged } else { vm.selectedSide = .staged } vm.showPreviewInsteadOfDiff = mode == .browser ? true : isImagePath(path) } else { vm.selectedSide = .unstaged vm.showPreviewInsteadOfDiff = true } Task { await vm.refreshDetail() #if canImport(AppKit) loadPreviewImageIfNeeded() #endif } } #if canImport(AppKit) private func revealBrowserItem(path: String, isDirectory: Bool) { revealInFinder(path: path, isDirectory: isDirectory) } #endif } ================================================ FILE: views/GitChanges/GitChangesPanel+Detail.swift ================================================ import SwiftUI #if canImport(AppKit) import AppKit #endif extension GitChangesPanel { // MARK: - Detail view (diff/preview pane) var detailView: some View { detailContainer { if mode == .graph { graphDetailView } else if mode != .diff, let path = vm.selectedPath, isImagePath(path) { // In Explorer mode, show rich preview for images imagePreviewContent } else { // In Diff mode, always render the diff reader (no preview switch) let isDiff = (mode == .diff) ? true : !vm.showPreviewInsteadOfDiff let emptyText: String = { if mode == .diff { return vm.selectedPath == nil ? "Select a file to view diff." : "(No diff)" } else { return vm.selectedPath == nil ? "Select a file to view preview/diff." : (vm.showPreviewInsteadOfDiff ? "(Empty preview)" : "(No diff)") } }() AttributedTextView( text: vm.diffText.isEmpty ? emptyText : vm.diffText, isDiff: isDiff, wrap: wrapText, showLineNumbers: showLineNumbers, fontSize: 12, searchQuery: (mode == .diff || mode == .browser) ? headerSearchQuery : "" ) .frame(maxWidth: .infinity, maxHeight: .infinity) } } .id("detail:\(vm.selectedPath ?? "-")|\(vm.selectedSide == .staged ? "s" : "u")|\(vm.showPreviewInsteadOfDiff ? "p" : "d")|wrap:\(wrapText ? 1 : 0)|ln:\(showLineNumbers ? 1 : 0)") .task(id: vm.selectedPath) { await vm.refreshDetail() loadPreviewImageIfNeeded() } .task(id: vm.selectedSide) { await vm.refreshDetail() } .task(id: vm.showPreviewInsteadOfDiff) { await vm.refreshDetail() loadPreviewImageIfNeeded() } } // MARK: - Commit box (legacy, for .full presentation) var commitBox: some View { VStack(alignment: .leading, spacing: 6) { Text("Commit") .font(.subheadline) .foregroundStyle(.secondary) if presentation == .full { // Clamp editor height between 1 and 10 lines (≈20pt/line) let line: CGFloat = 20 let minH: CGFloat = line let maxH: CGFloat = line * 10 VStack(alignment: .leading, spacing: 6) { TextEditor(text: $vm.commitMessage) .font(.system(.body)) .codmatePlainTextEditorStyleIfAvailable() .frame(minHeight: minH) .frame(height: min(maxH, max(minH, commitEditorHeight))) .padding(6) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(Color.secondary.opacity(0.25)) ) // Drag handle adjusts preferred editor height within bounds Rectangle() .fill(Color.clear) .frame(height: 6) .gesture(DragGesture().onChanged { value in let nh = max(minH, min(maxH, commitEditorHeight + value.translation.height)) commitEditorHeight = nh }) HStack { Spacer() Button("Commit") { showCommitConfirm = true } .disabled(vm.commitMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } } else { HStack(spacing: 6) { TextField("Press Command+Return to commit", text: $vm.commitMessage) Button("Commit") { showCommitConfirm = true } .disabled(vm.commitMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } } .padding(8) .background( Group { if presentation == .embedded { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color(nsColor: .underPageBackgroundColor)) } } ) .overlay( Group { if presentation == .embedded { RoundedRectangle(cornerRadius: 8, style: .continuous) .stroke(Color.secondary.opacity(0.15)) } } ) } private func detailContainer(@ViewBuilder content: () -> Content) -> some View { content() .frame(maxWidth: .infinity, maxHeight: .infinity) .background( Group { // In embedded presentation, use a card-like surface similar to other // insets. In full panel (project Review right side), keep plain to // match Tasks detail surface styling. if presentation == .embedded { RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(Color(nsColor: .textBackgroundColor)) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(Color.secondary.opacity(0.15)) ) } else { Color.clear } } ) } #if canImport(AppKit) private var imagePreviewContent: some View { GeometryReader { geo in ZStack { if let image = previewImage { let size = image.size let widthScale = geo.size.width / max(size.width, 1) let heightScale = geo.size.height / max(size.height, 1) let scale = min(1.0, min(widthScale, heightScale)) Image(nsImage: image) .resizable() .interpolation(.high) .frame(width: size.width * scale, height: size.height * scale) } else { ProgressView() } } .frame(maxWidth: .infinity, maxHeight: .infinity) } } func loadPreviewImageIfNeeded() { previewImageTask?.cancel() previewImage = nil guard let root = vm.repoRoot, let path = vm.selectedPath, isImagePath(path) else { return } let url = root.appendingPathComponent(path) previewImageTask = Task { let image = NSImage(contentsOf: url) if Task.isCancelled { return } await MainActor.run { previewImage = image } } } #else private var imagePreviewContent: some View { Color.clear } func loadPreviewImageIfNeeded() {} #endif } ================================================ FILE: views/GitChanges/GitChangesPanel+DiffTree.swift ================================================ import SwiftUI #if canImport(AppKit) import AppKit #endif extension GitChangesPanel { @ViewBuilder func treeRows(nodes: [FileNode], depth: Int, scope: TreeScope) -> some View { ForEach(nodes) { node in if node.isDirectory { // Directory row with VS Code-style layout let key = node.dirPath ?? "" let hoverKey = scopedHoverKey(for: key, scope: scope) let isExpanded: Bool = { switch scope { case .staged: return expandedDirsStaged.contains(key) case .unstaged: return expandedDirsUnstaged.contains(key) } }() HStack(spacing: 0) { // Indentation guides (vertical lines) ZStack(alignment: .leading) { Color.clear.frame(width: CGFloat(depth) * indentStep + chevronWidth) let guideColor = Color.secondary.opacity(0.15) ForEach(0.. String { let prefix = (scope == .staged) ? "S" : "U" return "\(prefix)::\(path)" } } #if canImport(AppKit) extension GitChangesPanel { private func copyAbsolutePath(_ relativePath: String) { let full = vm.repoRoot?.appendingPathComponent(relativePath).path ?? relativePath writeToPasteboard(full) } private func copyRelativePath(_ relativePath: String) { writeToPasteboard(relativePath) } private func writeToPasteboard(_ string: String) { let pb = NSPasteboard.general pb.clearContents() pb.setString(string, forType: .string) } } #endif ================================================ FILE: views/GitChanges/GitChangesPanel+Graph.swift ================================================ import SwiftUI extension GitChangesPanel { // MARK: - Graph detail view var graphDetailView: some View { graphListView(compactColumns: false) { commit in // Enter History Detail mode when a commit is activated. historyDetailCommit = commit } } /// Shared helper to host the graph list with repo attachment and activation callback. func graphListView( compactColumns: Bool, onActivateCommit: @escaping (GitService.GraphCommit?) -> Void ) -> some View { GraphContainer( vm: graphVM, wrapText: wrapText, showLineNumbers: showLineNumbers, compactColumns: compactColumns, onActivateCommit: onActivateCommit ) .onAppear { graphVM.attach(to: vm.repoRoot) } .onChange(of: vm.repoRoot) { newVal in graphVM.attach(to: newVal) } } // Host for the graph UI struct GraphContainer: View { @ObservedObject var vm: GitGraphViewModel let wrapText: Bool let showLineNumbers: Bool let compactColumns: Bool let onActivateCommit: (GitService.GraphCommit?) -> Void @State private var selection: GitGraphViewModel.CommitRowData.ID? = nil @State private var suppressNextActivation: Bool = false init( vm: GitGraphViewModel, wrapText: Bool, showLineNumbers: Bool, compactColumns: Bool, onActivateCommit: @escaping (GitService.GraphCommit?) -> Void ) { self.vm = vm self.wrapText = wrapText self.showLineNumbers = showLineNumbers self.compactColumns = compactColumns self.onActivateCommit = onActivateCommit } var body: some View { VStack(spacing: 0) { // Controls + branch scope HStack(spacing: 12) { ViewThatFits(in: .horizontal) { HStack(spacing: 10) { branchSelector remoteBranchesToggle } HStack(spacing: 10) { branchSelector } } Spacer() actionButtons } .padding(.top, 16) .padding(.horizontal, 16) .onChange(of: vm.showAllBranches) { _ in vm.loadCommits() } .onChange(of: vm.branchSearchQuery) { _ in vm.applyBranchFilter() } if let error = vm.errorMessage, !error.isEmpty { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.orange) Text(error) .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) Spacer() Button("Dismiss") { vm.clearError() } .buttonStyle(.link) .font(.caption) } .padding(.horizontal, 16) .padding(.bottom, 4) } tableView .environment(\.defaultMinListRowHeight, rowHeight) .tableStyle(.inset(alternatesRowBackgrounds: true)) .removeTableSpacing(rowHeight: rowHeight) .padding(.horizontal, 0) .padding(.top, 8) .overlay(alignment: .bottom) { if vm.isLoadingMore { ProgressView() .controlSize(.small) .padding(8) .background(.regularMaterial, in: Capsule()) .padding(.bottom, 16) } } } .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { syncSelectionFromViewModel() } .onChange(of: vm.rowData) { _ in syncSelectionFromViewModel() } .onChange(of: selection) { newValue in if suppressNextActivation { // Skip the activation corresponding to a programmatic // selection restore (e.g. when the view is recreated // after closing the history detail pane). suppressNextActivation = false return } guard let id = newValue, let row = vm.rowData.first(where: { $0.id == id }) else { return } vm.selectCommit(row.commit) if row.isWorkingTree { onActivateCommit(nil) } else { onActivateCommit(row.commit) } } } @ViewBuilder private var tableView: some View { if compactColumns { Table(vm.rowData, selection: $selection) { // Graph column TableColumn("") { row in graphColumnContent(for: row) } .width(min: graphColumnWidth, ideal: graphColumnWidth, max: graphColumnWidth) // Description TableColumn("Description") { row in descriptionColumnContent(for: row) } } } else { Table(vm.rowData, selection: $selection) { // Graph column TableColumn("") { row in graphColumnContent(for: row) } .width(min: graphColumnWidth, ideal: graphColumnWidth, max: graphColumnWidth) // Description TableColumn("Description") { row in descriptionColumnContent(for: row) } TableColumn("Date") { row in Text(row.commit.date) .foregroundStyle(.secondary) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) } .width(dateWidth) TableColumn("Author") { row in Text(row.commit.author) .foregroundStyle(.secondary) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) } .width(authorWidth) TableColumn("SHA") { row in Text(row.commit.shortId) .font(.system(.caption, design: .monospaced)) .foregroundStyle(.secondary) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) } .width(shaWidth) } } } @ViewBuilder private func graphColumnContent(for row: GitGraphViewModel.CommitRowData) -> some View { let isSelected = (selection == row.id) if let info = row.laneInfo { GraphLaneView( info: info, maxLanes: vm.maxLaneCount, laneSpacing: laneSpacing, verticalWidth: 2, hideTopForCurrentLane: row.isFirst, hideBottomForCurrentLane: row.isLast, headIsHollow: row.isWorkingTree, headSize: 12, isSelected: isSelected ) .frame(width: graphColumnWidth, height: rowHeight) } else { GraphGlyph(isSelected: isSelected) .frame(width: graphColumnWidth, height: rowHeight) } } private func descriptionColumnContent(for row: GitGraphViewModel.CommitRowData) -> some View { HStack(spacing: 6) { Text(row.commit.subject) .fontWeight(row.isWorkingTree ? .semibold : .regular) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) if !row.commit.decorations.isEmpty { ForEach(row.commit.decorations.prefix(3), id: \.self) { d in Text(d) .font(.system(size: 10, weight: .medium)) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Capsule().fill(Color.secondary.opacity(0.15))) } } } .onAppear { if row.isLast { vm.loadMore() } } } /// Ensure that the SwiftUI `Table` selection tracks the /// view model's selected commit across layout mode switches /// (e.g. when entering History Detail full-width mode). private func syncSelectionFromViewModel() { guard let current = vm.selectedCommit else { return } // If we don't have a selection yet, or it already matches the // view model, restore it from the current rowData. if selection == nil || selection == current.id { if let row = vm.rowData.first(where: { $0.commit.id == current.id }) { suppressNextActivation = true selection = row.id } } } private var graphColumnWidth: CGFloat { // Graph column width scales with lanes; lane spacing controls horizontal density. // Lane layout is computed before the first rows are built (we suppress the // initial rowData build once in the view model), so maxLaneCount should // reflect the actual width needed for the graph. let lanes = max(vm.maxLaneCount, 1) return max(rowHeight + 4, CGFloat(lanes) * laneSpacing) } private var rowHeight: CGFloat { 28 } private var laneSpacing: CGFloat { rowHeight } private var dateWidth: CGFloat { 110 } private var authorWidth: CGFloat { 120 } private var shaWidth: CGFloat { 80 } @ViewBuilder private var branchSelector: some View { VStack(spacing: 4) { HStack(spacing: 6) { Text("Branches:") .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) Picker( "", selection: Binding( get: { vm.showAllBranches ? "__all__" : (vm.selectedBranch ?? "__current__") }, set: { newVal in if newVal == "__all__" { vm.showAllBranches = true vm.selectedBranch = nil } else if newVal == "__current__" { vm.showAllBranches = false vm.selectedBranch = nil } else { vm.showAllBranches = false vm.selectedBranch = newVal } vm.loadCommits() }) ) { Text("Show All").tag("__all__") Text("Current").tag("__current__") Divider() if vm.fullBranchList.count > 100 { Text("Search to filter \(vm.fullBranchList.count) branches...").tag("__search__") .foregroundStyle(.secondary) .italic() } ForEach(vm.branches, id: \.self) { name in Text(name).tag(name) } } .pickerStyle(.menu) .frame(width: 200) .onAppear { if vm.fullBranchList.isEmpty && !vm.isLoadingBranches { vm.loadBranches() } } if vm.isLoadingBranches { ProgressView().controlSize(.small) } } if !vm.showAllBranches && vm.fullBranchList.count > 100 { HStack(spacing: 4) { Image(systemName: "magnifyingglass") .font(.caption) .foregroundStyle(.secondary) TextField("Filter branches...", text: $vm.branchSearchQuery) .textFieldStyle(.plain) .font(.caption) } .padding(.horizontal, 6) .padding(.vertical, 3) .background( RoundedRectangle(cornerRadius: 4) .stroke(Color.secondary.opacity(0.2)) ) .frame(width: 200) } } } private var remoteBranchesToggle: some View { Toggle( isOn: $vm.showRemoteBranches ) { Text("Show Remote Branches") .lineLimit(1) } .onChange(of: vm.showRemoteBranches) { _ in vm.loadBranches() vm.loadCommits() } } private var actionButtons: some View { HStack(spacing: 8) { Button { vm.triggerRefresh() } label: { Label("Refresh", systemImage: "arrow.clockwise") .labelStyle(.titleAndIcon) } .controlSize(.small) .buttonStyle(.bordered) .disabled(vm.isLoading) .help("Reload the commit list") Button { vm.fetchRemotes() } label: { Label("Fetch", systemImage: "arrow.down.circle") .labelStyle(.titleAndIcon) } .controlSize(.small) .buttonStyle(.bordered) .disabled(vm.historyActionInProgress != nil) .help("Fetch all remotes") Button { vm.pullLatest() } label: { Label("Pull", systemImage: "square.and.arrow.down") .labelStyle(.titleAndIcon) } .controlSize(.small) .buttonStyle(.bordered) .disabled(vm.historyActionInProgress != nil) .help("Pull current branch (fast-forward)") Button { vm.pushCurrent() } label: { Label("Push", systemImage: "square.and.arrow.up") .labelStyle(.titleAndIcon) } .controlSize(.small) .buttonStyle(.bordered) .disabled(vm.historyActionInProgress != nil) .help("Push current branch") if vm.historyActionInProgress != nil { ProgressView() .controlSize(.small) .padding(.leading, 2) } } } } // Detailed view for a single commit: meta info, files list, and diff viewer. struct HistoryCommitDetailView: View { let commit: GitService.GraphCommit @ObservedObject var viewModel: GitGraphViewModel var onClose: () -> Void let wrap: Bool let showLineNumbers: Bool @State private var fileSearch: String = "" @State private var showMessageBody: Bool = false var body: some View { VSplitView { // Top: meta + files tree (stacked vertically) VSplitView { metaSection filesSection } // Bottom: diff viewer diffSection } .onAppear { viewModel.loadDetail(for: commit) } .onChange(of: commit.id) { _ in viewModel.loadDetail(for: commit) } } private var metaSection: some View { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .top, spacing: 8) { VStack(alignment: .leading, spacing: 6) { Text(commit.subject) .font(.headline) .lineLimit(2) HStack(spacing: 12) { Text(commit.shortId) .font(.system(.caption, design: .monospaced)) .foregroundStyle(.secondary) if !commit.parents.isEmpty { Text("Parents: \(commit.parents.joined(separator: ", "))") .font(.caption) .foregroundStyle(.secondary) } } HStack(spacing: 12) { Text(commit.author) .font(.caption) .foregroundStyle(.secondary) Text(commit.date) .font(.caption) .foregroundStyle(.secondary) } } Spacer() Button(action: onClose) { Image(systemName: "xmark.circle.fill") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(.secondary) } .buttonStyle(.plain) .help("Close commit details") } if !commit.decorations.isEmpty { HStack(spacing: 6) { ForEach(commit.decorations.prefix(4), id: \.self) { deco in Text(deco) .font(.system(size: 10, weight: .medium)) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Capsule().fill(Color.secondary.opacity(0.15))) } } } if !viewModel.detailMessage.isEmpty { VStack(alignment: .leading, spacing: 4) { Button { showMessageBody.toggle() } label: { HStack(spacing: 4) { Image(systemName: showMessageBody ? "chevron.down" : "chevron.right") .font(.system(size: 11, weight: .semibold)) Text("Message") .font(.caption.weight(.semibold)) Spacer() } } .buttonStyle(.plain) if showMessageBody { ScrollView(.vertical, showsIndicators: true) { Text(viewModel.detailMessage) .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) .textSelection(.enabled) .padding(.trailing, 2) } .frame(maxHeight: .infinity, alignment: .topLeading) } } } } .padding(16) .frame(minHeight: showMessageBody ? 140 : 110, alignment: .topLeading) } private var filesSection: some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { HStack(spacing: 6) { Image(systemName: "magnifyingglass").foregroundStyle(.secondary) TextField("Filter files", text: $fileSearch) .textFieldStyle(.plain) } .padding(.vertical, 4) .padding(.horizontal, 6) .background( RoundedRectangle(cornerRadius: 8) .stroke(Color.secondary.opacity(0.2)) ) Spacer() HStack(spacing: 0) { Button { expandedHistoryDirs.removeAll() } label: { Image(systemName: "arrow.up.right.and.arrow.down.left") .font(.system(size: 12)) .foregroundStyle(.secondary) } .buttonStyle(.plain) .frame(width: 28, height: 28) Button { let nodes = buildHistoryTree(from: filteredDetailFiles) var all: Set = [] collectAllDirKeys(nodes: nodes, into: &all) expandedHistoryDirs = all } label: { Image(systemName: "arrow.down.left.and.arrow.up.right") .font(.system(size: 12)) .foregroundStyle(.secondary) } .buttonStyle(.plain) .frame(width: 28, height: 28) } if viewModel.isLoadingDetail && viewModel.detailFiles.isEmpty { ProgressView().controlSize(.small) } } ScrollView { LazyVStack(alignment: .leading, spacing: 0) { if filteredDetailFiles.isEmpty, !viewModel.isLoadingDetail { Text("No files changed in this commit.") .font(.caption) .foregroundStyle(.secondary) .padding(.vertical, 8) } else { HistoryTreeView( nodes: buildHistoryTree(from: filteredDetailFiles), depth: 0, expandedDirs: $expandedHistoryDirs, selectedPath: viewModel.selectedDetailFile, onSelectFile: { path in viewModel.selectedDetailFile = path viewModel.loadDetailPatch(for: path) } ) } } } }.padding(16) } private var diffSection: some View { VStack(alignment: .leading, spacing: 4) { HStack { Text("Diff") .font(.subheadline.weight(.semibold)) if let file = viewModel.selectedDetailFile { Text("— \(file)") .font(.caption) .foregroundStyle(.secondary) } Spacer() if viewModel.isLoadingDetail { ProgressView().controlSize(.small) } } if viewModel.detailFilePatch.isEmpty && !viewModel.isLoadingDetail { Text("Select a file to view its diff.") .font(.caption) .foregroundStyle(.secondary) .padding(.vertical, 8) } else { AttributedTextView( text: viewModel.detailFilePatch, isDiff: true, wrap: wrap, showLineNumbers: showLineNumbers, fontSize: 12 ) .frame(maxWidth: .infinity, maxHeight: .infinity) } } .padding(16) } // MARK: - History file tree helpers private var filteredDetailFiles: [GitService.FileChange] { let q = fileSearch.trimmingCharacters(in: .whitespacesAndNewlines) guard !q.isEmpty else { return viewModel.detailFiles } return viewModel.detailFiles.filter { $0.path.localizedCaseInsensitiveContains(q) || ($0.oldPath?.localizedCaseInsensitiveContains(q) ?? false) } } struct HistoryFileNode: Identifiable { let id = UUID() let name: String let path: String? let dirPath: String? let change: GitService.FileChange? var children: [HistoryFileNode]? var isDirectory: Bool { dirPath != nil } } private func buildHistoryTree(from changes: [GitService.FileChange]) -> [HistoryFileNode] { struct Builder { var children: [String: Builder] = [:] var fileChange: GitService.FileChange? = nil } var root = Builder() for change in changes { let path = change.path guard !path.isEmpty else { continue } let components = path.split(separator: "/").map(String.init) guard !components.isEmpty else { continue } func insert(_ index: Int, current: inout Builder) { let key = components[index] if index == components.count - 1 { var child = current.children[key, default: Builder()] child.fileChange = change current.children[key] = child } else { var child = current.children[key, default: Builder()] insert(index + 1, current: &child) current.children[key] = child } } insert(0, current: &root) } func convert(_ builder: Builder, prefix: String?) -> [HistoryFileNode] { var nodes: [HistoryFileNode] = [] for (name, child) in builder.children { let fullPath = prefix.map { "\($0)/\(name)" } ?? name if let change = child.fileChange, child.children.isEmpty { nodes.append( HistoryFileNode( name: name, path: change.path, dirPath: nil, change: change, children: nil) ) } else { let childrenNodes = convert(child, prefix: fullPath) nodes.append( HistoryFileNode( name: name, path: nil, dirPath: fullPath, change: nil, children: childrenNodes.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } ) ) } } return nodes.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } } return convert(root, prefix: nil) } private func collectAllDirKeys(nodes: [HistoryFileNode], into set: inout Set) { for node in nodes { if let dir = node.dirPath { set.insert(dir) } if let children = node.children { collectAllDirKeys(nodes: children, into: &set) } } } @State private var expandedHistoryDirs: Set = [] struct HistoryTreeView: View { let nodes: [HistoryFileNode] let depth: Int @Binding var expandedDirs: Set let selectedPath: String? let onSelectFile: (String) -> Void var body: some View { ForEach(nodes) { node in if node.isDirectory { let key = node.dirPath ?? "" let isExpanded = expandedDirs.contains(key) directoryRow(node: node, key: key, isExpanded: isExpanded) if isExpanded, let children = node.children { HistoryTreeView( nodes: children, depth: depth + 1, expandedDirs: $expandedDirs, selectedPath: selectedPath, onSelectFile: onSelectFile ) } } else if let path = node.path { fileRow(node: node, path: path) } } } private func directoryRow(node: HistoryFileNode, key: String, isExpanded: Bool) -> some View { let indentStep: CGFloat = 16 let chevronWidth: CGFloat = 16 return HStack(spacing: 0) { ZStack(alignment: .leading) { Color.clear.frame(width: CGFloat(depth) * indentStep + chevronWidth) let guideColor = Color.secondary.opacity(0.15) ForEach(0.. some View { let indentStep: CGFloat = 16 let chevronWidth: CGFloat = 16 let isSelected = (path == selectedPath) return HStack(spacing: 0) { ZStack(alignment: .leading) { Color.clear.frame(width: CGFloat(depth) * indentStep + chevronWidth) let guideColor = Color.secondary.opacity(0.15) ForEach(0.. Color { guard let code = change.statusCode.first else { return Color.secondary.opacity(0.6) } switch code { case "A": return .green case "M": return .orange case "D": return .red case "R": return .purple case "C": return .blue case "T": return .teal case "U": return .gray default: return Color.secondary.opacity(0.6) } } private static func badgeText(for change: GitService.FileChange) -> String { guard let first = change.statusCode.first else { return "?" } return String(first) } private static func statusBadge(text: String) -> some View { Text(text) .font(.system(size: 9, weight: .medium)) .foregroundStyle(.secondary) .padding(.horizontal, 4) .padding(.vertical, 1) .background( RoundedRectangle(cornerRadius: 3) .fill(Color.secondary.opacity(0.1)) ) } } } } // Renders commit lanes and connectors for a single row. private struct GraphLaneView: View { let info: GitGraphViewModel.LaneInfo let maxLanes: Int let laneSpacing: CGFloat let verticalWidth: CGFloat let hideTopForCurrentLane: Bool let hideBottomForCurrentLane: Bool let headIsHollow: Bool let headSize: CGFloat let isSelected: Bool private let dotSize: CGFloat = 8 private let lineWidth: CGFloat = 2 private func x(_ lane: Int) -> CGFloat { CGFloat(lane) * laneSpacing + laneSpacing / 2 } var body: some View { Canvas { context, size in drawGraph(in: context, size: size) } } private func drawGraph(in context: GraphicsContext, size: CGSize) { let baseColor: Color = isSelected ? .white : .accentColor let verticalColor: Color = isSelected ? .white : .accentColor.opacity(0.6) let h = size.height // Slightly extend beyond row bounds so vertical lanes visually connect between rows. let top: CGFloat = -2 let bottom: CGFloat = h + 2 let dotY = h * 0.5 // Draw vertical lane lines let count = max(info.activeLaneCount, maxLanes) if count > 0 { for i in 0...Item] = [ .init(title: "Diff", systemImage: "doc.text.magnifyingglass", tag: .diff), .init(title: "History", systemImage: "clock.arrow.circlepath", tag: .graph), .init(title: "Explorer", systemImage: "folder", tag: .browser), ] SegmentedIconPicker(items: items, selection: $mode) } Spacer(minLength: 8) // Unified search (right-aligned) HStack(spacing: 6) { Image(systemName: "magnifyingglass").foregroundStyle(.secondary) TextField(searchPlaceholder, text: $headerSearchQuery) .textFieldStyle(.plain) .onChange(of: headerSearchQuery) { newVal in onHeaderSearchChanged(newVal) } } .padding(.vertical, 4) .padding(.horizontal, 6) .background( RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.2)) ) .frame(minWidth: 160, maxWidth: 360) // Repo authorization toggle (to the left of the edge) let rootURL = vm.repoRoot ?? projectDirectory ?? workingDirectory let authorized = SecurityScopedBookmarks.shared.isSandboxed ? SecurityScopedBookmarks.shared.hasDynamicBookmark(for: rootURL) : true if vm.repoRoot != nil || explorerRootExists, SecurityScopedBookmarks.shared.isSandboxed { Button { if authorized { SecurityScopedBookmarks.shared.removeDynamic(url: rootURL) NotificationCenter.default.post(name: .codMateRepoAuthorizationChanged, object: nil) } else { onRequestAuthorization?() } } label: { Image(systemName: authorized ? "checkmark.shield" : "exclamationmark.shield") .foregroundStyle(authorized ? .green : .orange) } .buttonStyle(.plain) .help(authorized ? "Revoke repository authorization" : "Authorize repository folder…") } // Hidden keyboard shortcut to trigger commit confirmation via ⌘⏎ Button("") { let msg = vm.commitMessage.trimmingCharacters(in: .whitespacesAndNewlines) if !msg.isEmpty { showCommitConfirm = true } } .keyboardShortcut(.return, modifiers: .command) .frame(width: 0, height: 0) .opacity(0) } if vm.repoRoot == nil { HStack(spacing: 6) { Image(systemName: "info.circle") .foregroundStyle(.secondary) Text("Git repository not found. Explorer mode only.") .font(.caption) .foregroundStyle(.secondary) Spacer() } } // Moved authorization controls inline in header path; remove separate row if let err = vm.errorMessage, !err.isEmpty { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.orange) Text(err) .font(.caption) .foregroundStyle(.secondary) Spacer() } .padding(.vertical, 3) .padding(.horizontal, 8) .background( RoundedRectangle(cornerRadius: 6) .fill(Color.orange.opacity(0.08)) ) } } } } // MARK: - Header search helpers extension GitChangesPanel { var searchPlaceholder: String { switch mode { case .graph: return "Search commits" case .diff: return "Search diff" case .browser: return "Search preview" } } func onHeaderSearchChanged(_ newVal: String) { let trimmed = newVal.trimmingCharacters(in: .whitespacesAndNewlines) switch mode { case .graph: graphVM.searchQuery = trimmed graphVM.applyFilter() case .diff, .browser: // Handled in detailView via AttributedTextView(searchQuery:) break } } } ================================================ FILE: views/GitChanges/GitChangesPanel+Helpers.swift ================================================ import SwiftUI #if canImport(AppKit) import AppKit #endif // Shared helpers for Git file icons. enum GitFileIcon { static func icon(for path: String) -> (name: String, color: Color) { let ext = URL(fileURLWithPath: path).pathExtension.lowercased() switch ext { case "swift": return ("swift", .orange) case "md": return ("doc.text", .green) case "json": return ("curlybraces", .teal) case "yml", "yaml": return ("list.bullet", .indigo) case "js", "ts", "tsx", "jsx": return ("chevron.left.slash.chevron.right", .yellow) case "png", "jpg", "jpeg", "gif", "svg": return ("photo", .purple) case "sh", "zsh", "bash": return ("terminal", .gray) default: return ("doc.plaintext", .secondary) } } } extension GitChangesPanel { // MARK: - Helper functions for tree manipulation func allDirectoryKeys(nodes: [FileNode]) -> [String] { var keys: [String] = [] func walk(_ ns: [FileNode]) { for n in ns { if let d = n.dirPath { keys.append(d); if let cs = n.children { walk(cs) } } } } walk(nodes) return keys } func filteredNodes(_ nodes: [FileNode], query: String, contentMatches: Set) -> [FileNode] { let q = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !q.isEmpty else { return nodes } func filter(_ ns: [FileNode]) -> [FileNode] { var out: [FileNode] = [] for n in ns { if n.isDirectory { let kids = n.children.map(filter) ?? [] if n.name.localizedCaseInsensitiveContains(q) || !kids.isEmpty { var dir = n dir.children = kids out.append(dir) } } else if let p = n.fullPath { let matchesPath = contentMatches.contains(p) if matchesPath || n.name.localizedCaseInsensitiveContains(q) || p.localizedCaseInsensitiveContains(q) { out.append(n) } } } return out } return filter(nodes) } func isImagePath(_ path: String) -> Bool { let ext = URL(fileURLWithPath: path).pathExtension.lowercased() return ["png", "jpg", "jpeg", "gif", "bmp", "tiff", "tif", "heic", "heif", "webp"].contains(ext) } // Expand a directory key to concrete file paths present in current change set func filePaths(under dirKey: String) -> [String] { let prefix = dirKey.hasSuffix("/") ? dirKey : (dirKey + "/") return vm.changes.map { $0.path }.filter { $0.hasPrefix(prefix) } } // All file paths belonging to a specific scope func allPaths(in scope: TreeScope) -> [String] { switch scope { case .staged: return vm.changes.compactMap { ($0.staged != nil) ? $0.path : nil } case .unstaged: return vm.changes.compactMap { ($0.worktree != nil) ? $0.path : nil } } } func rebuildNodes() { cachedNodesStaged = vm.treeSnapshot.staged cachedNodesUnstaged = vm.treeSnapshot.unstaged rebuildDisplayed() } func rebuildDisplayed() { let trimmed = treeQuery.trimmingCharacters(in: .whitespacesAndNewlines) let matches = trimmed.isEmpty ? Set() : contentSearchMatches displayedStaged = filteredNodes(cachedNodesStaged, query: treeQuery, contentMatches: matches) displayedUnstaged = filteredNodes(cachedNodesUnstaged, query: treeQuery, contentMatches: matches) } // MARK: - Status helpers func statusColor(for path: String) -> Color { guard let change = vm.changes.first(where: { $0.path == path }) else { return Color.secondary.opacity(0.3) } // Check if staged or worktree if let _ = change.staged { return Color.green.opacity(0.7) } else if let kind = change.worktree { switch kind { case .modified: return Color.orange.opacity(0.7) case .deleted: return Color.red.opacity(0.7) case .untracked: return Color.green.opacity(0.7) default: return Color.blue.opacity(0.7) } } return Color.secondary.opacity(0.3) } // Simple file type icon mapping (shared with History views) func fileTypeIconName(for path: String) -> (name: String, color: Color) { GitFileIcon.icon(for: path) } // Helper: Status badge text @ViewBuilder func statusBadge(for change: GitService.Change) -> some View { if let _ = change.staged { badgeView(text: "S") } else if let kind = change.worktree { switch kind { case .modified: badgeView(text: "M") case .deleted: badgeView(text: "D") case .untracked: badgeView(text: "U") case .added: badgeView(text: "A") } } } func badgeView(text: String) -> some View { Text(text) .font(.system(size: 10, weight: .medium)) .foregroundStyle(.secondary) .padding(.horizontal, 4) .padding(.vertical, 1) .background( RoundedRectangle(cornerRadius: 3) .fill(Color.secondary.opacity(0.1)) ) } /// Expand all parent directories for a given file path in browser mode func ensureBrowserPathExpanded(_ filePath: String) { // Get all parent directory paths var pathComponents = filePath.split(separator: "/").map(String.init) pathComponents.removeLast() // Remove the file name itself var currentPath = "" for component in pathComponents { if !currentPath.isEmpty { currentPath += "/" } currentPath += component // Add to expanded set if not already expanded if !expandedDirsBrowser.contains(currentPath) { expandedDirsBrowser.insert(currentPath) } } // Rebuild the display to show expanded tree rebuildBrowserDisplayed() } #if canImport(AppKit) func revealInFinder(path: String, isDirectory: Bool) { let base = vm.repoRoot ?? projectDirectory ?? workingDirectory let url = base.appendingPathComponent(path, isDirectory: isDirectory) NSWorkspace.shared.activateFileViewerSelecting([url]) } #endif } ================================================ FILE: views/GitChanges/GitChangesPanel+LeftPane.swift ================================================ import SwiftUI extension GitChangesPanel { var leftPane: some View { VStack(spacing: 6) { // Toolbar - Search fills GeometryReader { _ in let spacing: CGFloat = 8 HStack(spacing: spacing) { // Search box expands to fill — match Tasks column styling HStack(spacing: 6) { Image(systemName: "magnifyingglass") .foregroundStyle(.secondary) .padding(.leading, 4) TextField("Search", text: $treeQuery) .textFieldStyle(.plain) if !treeQuery.isEmpty { Button { treeQuery = "" } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.tertiary) } .buttonStyle(.plain) } } .padding(.vertical, 6) .padding(.horizontal, 6) .background( RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color(nsColor: .textBackgroundColor)) .overlay( RoundedRectangle(cornerRadius: 8, style: .continuous) .stroke(Color.secondary.opacity(0.25), lineWidth: 1) ) ) .frame(maxWidth: .infinity) // Collapse/Expand buttons (shared styling with Tasks column) CollapseExpandButtonGroup( onCollapse: { if mode == .browser { expandedDirsBrowser.removeAll() } else { expandedDirsStaged.removeAll() expandedDirsUnstaged.removeAll() } }, onExpand: { if mode == .browser { expandedDirsBrowser = Set(allDirectoryKeys(nodes: browserNodes)) } else { expandedDirsStaged = Set(allDirectoryKeys(nodes: cachedNodesStaged)) expandedDirsUnstaged = Set(allDirectoryKeys(nodes: cachedNodesUnstaged)) } } ) } .frame(maxWidth: .infinity, alignment: .leading) } .frame(height: 32) // Inline commit message (one line, auto-grow; no button) // Show in Diff and History (graph) modes; hide in Explorer. if mode != .browser { GeometryReader { gr in ZStack(alignment: .topLeading) { TextEditor(text: $vm.commitMessage) .font(.system(.body)) .codmatePlainTextEditorStyleIfAvailable() .frame(minHeight: 20) .frame(height: min(200, max(20, commitInlineHeight))) .padding(.leading, 6) .padding(.top, 6) .padding(.bottom, 6) .padding(.trailing, wandReservedTrailing) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(Color.secondary.opacity(0.25)) ) .onChange(of: vm.commitMessage, initial: true) { _ in // account for trailing reserve space let w = max(10, gr.size.width - 12 - wandReservedTrailing) commitInlineHeight = measureCommitHeight(vm.commitMessage, width: w) } if vm.commitMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { Text("Press Command+Return to commit") .foregroundStyle(.tertiary) .padding(.top, 6) .padding(.leading, 10) .allowsHitTesting(false) } // Wand button at top-right of the commit message box HStack { Spacer() } .overlay(alignment: .topTrailing) { Button { vm.generateCommitMessage(providerId: preferences.commitProviderId, modelId: preferences.commitModelId) } label: { Image(systemName: "sparkles") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(hoverWand ? Color.accentColor : Color.secondary) } .buttonStyle(.plain) .frame(width: wandButtonSize, height: wandButtonSize) .contentShape(Rectangle()) .padding(.top, 4) // keep top-anchored; don't move when TextEditor grows .padding(.trailing, 4) .onHover { hoverWand = $0 } .opacity((vm.isGenerating && vm.generatingRepoPath == vm.repoRoot?.path) ? 0.4 : 1.0) .animation((vm.isGenerating && vm.generatingRepoPath == vm.repoRoot?.path) ? .easeInOut(duration: 0.8).repeatForever(autoreverses: true) : .default, value: vm.isGenerating) .disabled(vm.isGenerating && vm.generatingRepoPath == vm.repoRoot?.path) .help("AI generate commit message from staged changes") } } } .frame(height: min(200, max(20, commitInlineHeight)) + 12) } // Trees in VS Code-style sections ScrollView { // In History (.graph) we still show the Diff tree list. // Only Explorer mode uses the Explorer tree. if mode != .browser { LazyVStack(alignment: .leading, spacing: 0) { // Staged section HStack(spacing: 6) { Button { stagedCollapsed.toggle() } label: { Image(systemName: stagedCollapsed ? "chevron.right" : "chevron.down") .foregroundStyle(.secondary) .font(.system(size: 11)) } .buttonStyle(.plain) .frame(width: chevronWidth) Text("Staged Changes (\(vm.changes.filter { $0.staged != nil }.count))") .font(.subheadline) .foregroundStyle(.secondary) Spacer(minLength: 0) } .contentShape(Rectangle()) .onTapGesture { stagedCollapsed.toggle() } .onHover { hoverStagedHeader = $0 } .background( RoundedRectangle(cornerRadius: 4) .fill(hoverStagedHeader ? Color.secondary.opacity(0.06) : Color.clear) ) .frame(height: 22) .contextMenu { Button { let paths = allPaths(in: .staged) Task { await vm.unstage(paths: paths) } } label: { Label("Unstage All", systemImage: "minus.circle") } Divider() Button { Task { await vm.refreshStatus() } } label: { Label("Refresh", systemImage: "arrow.clockwise") } } if !stagedCollapsed { treeRows(nodes: displayedStaged, depth: 1, scope: .staged) } // Unstaged section HStack(spacing: 6) { Button { unstagedCollapsed.toggle() } label: { Image(systemName: unstagedCollapsed ? "chevron.right" : "chevron.down") .foregroundStyle(.secondary) .font(.system(size: 11)) } .buttonStyle(.plain) .frame(width: chevronWidth) // Show all files with worktree changes, even if they also have staged changes (MM) Text("Changes (\(vm.changes.filter { $0.worktree != nil }.count))") .font(.subheadline) .foregroundStyle(.secondary) Spacer(minLength: 0) } .contentShape(Rectangle()) .onTapGesture { unstagedCollapsed.toggle() } .onHover { hoverUnstagedHeader = $0 } .background( RoundedRectangle(cornerRadius: 4) .fill(hoverUnstagedHeader ? Color.secondary.opacity(0.06) : Color.clear) ) .frame(height: 22) .contextMenu { Button { let paths = allPaths(in: .unstaged) Task { await vm.stage(paths: paths) } } label: { Label("Stage All", systemImage: "plus.circle") } Divider() Button { Task { await vm.refreshStatus() } } label: { Label("Refresh", systemImage: "arrow.clockwise") } } if !unstagedCollapsed { treeRows(nodes: displayedUnstaged, depth: 1, scope: .unstaged) } } } else { browserTreeView } } // Provide a generic context menu on empty area as well .contextMenu { Button { let paths = allPaths(in: .unstaged) Task { await vm.stage(paths: paths) } } label: { Label("Stage All", systemImage: "plus.circle") } Button { let paths = allPaths(in: .staged) Task { await vm.unstage(paths: paths) } } label: { Label("Unstage All", systemImage: "minus.circle") } Divider() Button { Task { await vm.refreshStatus() } } label: { Label("Refresh", systemImage: "arrow.clockwise") } } .frame(maxWidth: .infinity, maxHeight: .infinity) } // Add inner padding to prevent controls from hugging edges .padding(16) } } ================================================ FILE: views/GitChanges/GitChangesPanel+Lifecycle.swift ================================================ import SwiftUI extension GitChangesPanel { // MARK: - Lifecycle Modifier struct LifecycleModifier: ViewModifier { @Binding var expandedDirsStaged: Set @Binding var expandedDirsUnstaged: Set @Binding var expandedDirsBrowser: Set @Binding var savedState: ReviewPanelState @Binding var mode: ReviewPanelState.Mode let vm: GitChangesViewModel let treeQuery: String let onSearchQueryChanged: (String) -> Void let onRebuildNodes: () -> Void let onRebuildDisplayed: () -> Void let onEnsureExpandAll: () -> Void let onRebuildBrowserDisplayed: () -> Void let onRefreshBrowserTree: () -> Void func body(content: Content) -> some View { var view = AnyView( content.onAppear { restoreState() onRebuildNodes() onRebuildDisplayed() onRebuildBrowserDisplayed() onEnsureExpandAll() onSearchQueryChanged(treeQuery) if mode == .browser { onRefreshBrowserTree() } } ) view = AnyView( view.onChange(of: vm.treeSnapshot) { _ in onRebuildNodes() onEnsureExpandAll() if mode == .browser { onRefreshBrowserTree() } } ) view = AnyView( view.onChange(of: treeQuery) { newValue in onSearchQueryChanged(newValue) onRebuildDisplayed() onRebuildBrowserDisplayed() } ) view = AnyView( view.onChange(of: expandedDirsStaged) { newVal in savedState.expandedDirsStaged = newVal } ) view = AnyView( view.onChange(of: expandedDirsUnstaged) { newVal in savedState.expandedDirsUnstaged = newVal } ) view = AnyView( view.onChange(of: expandedDirsBrowser) { newVal in savedState.expandedDirsBrowser = newVal onRebuildBrowserDisplayed() } ) view = AnyView( view.onChange(of: vm.selectedPath) { newVal in savedState.selectedPath = newVal } ) view = AnyView( view.onChange(of: vm.selectedSide) { newVal in savedState.selectedSideStaged = (newVal == .staged) } ) view = AnyView( view.onChange(of: vm.showPreviewInsteadOfDiff) { newVal in savedState.showPreview = newVal } ) view = AnyView( view.onChange(of: vm.commitMessage) { newVal in savedState.commitMessage = newVal } ) view = AnyView( view.onChange(of: mode) { newVal in savedState.mode = newVal if newVal == .browser { onRebuildBrowserDisplayed() onRefreshBrowserTree() } } ) // Persist Graph visibility flag when it changes view = AnyView( view.onChange(of: savedState.showGraph) { _ in // No-op: wiring point retained for completeness } ) return view } private func restoreState() { var initial = savedState // Migrate legacy browser mode to diff mode: // Since the default mode has changed from .browser to .diff, // automatically migrate any saved .browser state to .diff. // User can still manually switch to browser or graph if needed. if initial.mode == .browser { initial.mode = .diff savedState = initial } if !initial.expandedDirsStaged.isEmpty || !initial.expandedDirsUnstaged.isEmpty { expandedDirsStaged = initial.expandedDirsStaged expandedDirsUnstaged = initial.expandedDirsUnstaged } else if !initial.expandedDirs.isEmpty { expandedDirsStaged = initial.expandedDirs expandedDirsUnstaged = initial.expandedDirs } if !initial.expandedDirsBrowser.isEmpty { expandedDirsBrowser = initial.expandedDirsBrowser } mode = initial.mode vm.selectedPath = initial.selectedPath if let stagedSide = initial.selectedSideStaged { vm.selectedSide = stagedSide ? .staged : .unstaged } vm.showPreviewInsteadOfDiff = initial.showPreview let savedMessage = initial.commitMessage.trimmingCharacters(in: .whitespacesAndNewlines) let liveMessage = vm.commitMessage.trimmingCharacters(in: .whitespacesAndNewlines) if !liveMessage.isEmpty && liveMessage != savedMessage { savedState.commitMessage = vm.commitMessage } else { vm.commitMessage = initial.commitMessage } } } } ================================================ FILE: views/GitChanges/GitChangesPanel+Menus.swift ================================================ import SwiftUI // MARK: - Lightweight menu list for popovers struct PopMenuItem: Identifiable { enum Role { case normal, destructive } let id = UUID() var title: String var role: Role = .normal var action: () -> Void } struct PopMenuList: View { var items: [PopMenuItem] var tail: [PopMenuItem] = [] // optional trailing group separated by a divider @State private var hovered: UUID? = nil var body: some View { VStack(spacing: 0) { groupView(items) if !tail.isEmpty { Divider().padding(.vertical, 4) groupView(tail) } } .padding(6) } @ViewBuilder private func groupView(_ group: [PopMenuItem]) -> some View { ForEach(group) { item in Button(action: item.action) { HStack(spacing: 8) { Text(item.title) .foregroundStyle(item.role == .destructive ? Color.red : Color.primary) .frame(maxWidth: .infinity, alignment: .leading) } .padding(.horizontal, 8) .frame(height: 24) .background( RoundedRectangle(cornerRadius: 5) .fill(hovered == item.id ? Color.accentColor.opacity(0.12) : Color.clear) ) } .buttonStyle(.plain) .onHover { inside in hovered = inside ? item.id : (hovered == item.id ? nil : hovered) } } } } ================================================ FILE: views/GitChanges/GitChangesPanel.swift ================================================ import SwiftUI #if canImport(AppKit) import AppKit #endif struct GitChangesPanel: View { enum Presentation { case embedded, full } enum RegionLayout { case combined, leftOnly, rightOnly } let workingDirectory: URL let projectDirectory: URL? var presentation: Presentation = .embedded var regionLayout: RegionLayout = .combined let preferences: SessionPreferencesStore var onRequestAuthorization: (() -> Void)? = nil var refreshToken: Int = 0 @Binding var savedState: ReviewPanelState @ObservedObject var vm: GitChangesViewModel // Layout state @State var leftColumnWidth: CGFloat = 0 // 0 = init to 1/4 of container @State var commitEditorHeight: CGFloat = 28 // Tree state (keep staged/unstaged expansions independent) @State var expandedDirsStaged: Set = [] @State var expandedDirsUnstaged: Set = [] @State var treeQuery: String = "" // Cached trees for performance @State var cachedNodesStaged: [FileNode] = [] @State var cachedNodesUnstaged: [FileNode] = [] @State var displayedStaged: [FileNode] = [] @State var displayedUnstaged: [FileNode] = [] @State var stagedCollapsed: Bool = false @State var unstagedCollapsed: Bool = false @State var commitInlineHeight: CGFloat = 20 @State var mode: ReviewPanelState.Mode = .diff @State var expandedDirsBrowser: Set = [] @State var browserNodes: [FileNode] = [] @State var displayedBrowserRows: [BrowserRow] = [] @State var isLoadingBrowserTree: Bool = false @State var browserTreeError: String? = nil @State var browserTreeTruncated: Bool = false @State var browserTotalEntries: Int = 0 @State var browserTreeTask: Task? = nil // Hover state for quick actions @State var hoverFilePath: String? = nil @State var hoverDirKey: String? = nil @State var hoverEditPath: String? = nil @State var hoverRevertPath: String? = nil @State var hoverStagePath: String? = nil @State var hoverDirButtonPath: String? = nil @State var hoverBrowserFilePath: String? = nil @State var hoverBrowserRevealPath: String? = nil @State var hoverBrowserEditPath: String? = nil @State var hoverBrowserStagePath: String? = nil @State var hoverBrowserDirKey: String? = nil @State var hoverStagedHeader: Bool = false @State var hoverUnstagedHeader: Bool = false @State var pendingDiscardPaths: [String] = [] @State var pendingDiscardIncludesStaged: Bool = false @State var showDiscardAlert: Bool = false @State var showCommitConfirm: Bool = false // Graph view toggle + model @State var showGraph: Bool = false @StateObject var graphVM = GitGraphViewModel() @State var historyDetailCommit: GitService.GraphCommit? = nil // Use an optional Int for segmented momentary actions: 0=collapse, 1=expand // @State private var treeToggleIndex: Int? = nil // Layout constraints let leftMin: CGFloat = 280 let leftMax: CGFloat = 520 let commitMinHeight: CGFloat = 140 // Indent guide metrics (horizontal): // - indentStep: per-depth indent distance (matches VS Code's 16px) // - chevronWidth: width reserved for disclosure chevron let indentStep: CGFloat = 16 let chevronWidth: CGFloat = 16 let quickActionWidth: CGFloat = 18 let quickActionHeight: CGFloat = 16 let trailingPad: CGFloat = 8 let hoverButtonSpacing: CGFloat = 8 let statusBadgeWidth: CGFloat = 18 let browserEntryLimit: Int = 6000 let repoContentMatchLimit: Int = 4000 // Viewer options (from Settings › Git Review). Defaults: line numbers ON, wrap OFF var wrapText: Bool { preferences.gitWrapText } var showLineNumbers: Bool { preferences.gitShowLineNumbers } // Wand button metrics let wandButtonSize: CGFloat = 24 var wandReservedTrailing: CGFloat { wandButtonSize } // equal-width indent to avoid overlap @State var hoverWand: Bool = false @State private var diffModePreviewPreference: Bool = false @State private var forcedBrowserDueToMissingRepo = false @State var contentSearchMatches: Set = [] @State private var contentSearchTask: Task? = nil @State private var contentSearchQueryVersion: UInt64 = 0 // Unified header search @State var headerSearchQuery: String = "" #if canImport(AppKit) @State var previewImage: NSImage? = nil @State var previewImageTask: Task? = nil #endif private let repoSearchService = RepoContentSearchService() var body: some View { var view = AnyView(rootContent) view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .codMateRepoAuthorizationChanged)) { _ in // Only the left/combined pane should trigger repo re-attachment if regionLayout == .combined || regionLayout == .leftOnly { vm.attach(to: workingDirectory) } }) view = AnyView(view.alert("Discard changes?", isPresented: $showDiscardAlert) { Button("Discard", role: .destructive) { let paths = pendingDiscardPaths let includeStaged = pendingDiscardIncludesStaged pendingDiscardPaths = [] pendingDiscardIncludesStaged = true Task { await vm.discard(paths: paths, includeStaged: includeStaged) } } Button("Cancel", role: .cancel) { pendingDiscardPaths = [] pendingDiscardIncludesStaged = true } } message: { let count = pendingDiscardPaths.count if pendingDiscardIncludesStaged { Text( "This will permanently discard staged and unstaged changes for \(count) file\(count == 1 ? "" : "s")." ) } else { Text( "This will permanently discard unstaged changes for \(count) file\(count == 1 ? "" : "s"). Staged changes (if any) will be preserved." ) } }) view = AnyView(view.confirmationDialog( "Commit changes?", isPresented: $showCommitConfirm, titleVisibility: .visible ) { Button("Commit", role: .destructive) { Task { await vm.commit() } } Button("Cancel", role: .cancel) {} } message: { let msg = vm.commitMessage.trimmingCharacters(in: .whitespacesAndNewlines) if msg.isEmpty { Text("This will create a commit for staged changes.") } else { Text("Commit message:\n\n\(msg)") } }) view = AnyView(view.task(id: workingDirectory) { // Avoid double attach from both halves; left/combined is the source of truth if regionLayout == .combined || regionLayout == .leftOnly { vm.attach(to: workingDirectory, fallbackProjectDirectory: projectDirectory) } }) view = AnyView(view.task(id: vm.repoRoot?.path) { browserNodes = [] displayedBrowserRows = [] browserTreeError = nil if (regionLayout == .combined || regionLayout == .leftOnly) && mode == .browser { reloadBrowserTreeIfNeeded(force: true) } let trimmed = treeQuery.trimmingCharacters(in: .whitespacesAndNewlines) if (regionLayout == .combined || regionLayout == .leftOnly) && !trimmed.isEmpty { await MainActor.run { handleTreeQueryChange(treeQuery) } } }) view = AnyView(view.onChange(of: mode) { newMode in if newMode == .browser { if regionLayout == .combined || regionLayout == .leftOnly { reloadBrowserTreeIfNeeded() if let selectedPath = vm.selectedPath { ensureBrowserPathExpanded(selectedPath) } } } }) view = AnyView(view.onChange(of: refreshToken) { _ in switch mode { case .diff: Task { await vm.refreshStatusIfNeeded(refreshToken: refreshToken) } case .browser: Task { await vm.refreshStatusIfNeeded(refreshToken: refreshToken) } reloadBrowserTreeIfNeeded(force: true) case .graph: graphVM.triggerRefresh() } }) view = AnyView( view.modifier( LifecycleModifier( expandedDirsStaged: $expandedDirsStaged, expandedDirsUnstaged: $expandedDirsUnstaged, expandedDirsBrowser: $expandedDirsBrowser, savedState: $savedState, mode: $mode, vm: vm, treeQuery: treeQuery, onSearchQueryChanged: { handleTreeQueryChange($0) }, onRebuildNodes: rebuildNodes, onRebuildDisplayed: rebuildDisplayed, onEnsureExpandAll: ensureExpandAllIfNeeded, onRebuildBrowserDisplayed: rebuildBrowserDisplayed, onRefreshBrowserTree: { reloadBrowserTreeIfNeeded(force: false) } ) ) ) view = AnyView(view.onDisappear { contentSearchTask?.cancel() contentSearchTask = nil }) view = AnyView(view.onAppear { diffModePreviewPreference = vm.showPreviewInsteadOfDiff // Restore mode on appear mode = savedState.mode }) view = AnyView(view.onChange(of: savedState.mode) { newVal in if mode != newVal { mode = newVal } }) view = AnyView(view.onChange(of: vm.showPreviewInsteadOfDiff) { newValue in if mode == .diff { diffModePreviewPreference = newValue } }) view = AnyView(view.onChange(of: mode) { newMode in switch newMode { case .browser: // Explorer always shows preview on the right if !vm.showPreviewInsteadOfDiff { vm.showPreviewInsteadOfDiff = true } case .diff: // Diff mode must always render diff view if vm.showPreviewInsteadOfDiff { vm.showPreviewInsteadOfDiff = false } case .graph: // no-op; detail rendering managed by Graph container break } savedState.mode = newMode }) view = AnyView(view.onChange(of: vm.repoRoot) { newRoot in if newRoot == nil { forcedBrowserDueToMissingRepo = true if mode != .browser { mode = .browser } if !vm.showPreviewInsteadOfDiff { vm.showPreviewInsteadOfDiff = true } } else if forcedBrowserDueToMissingRepo { forcedBrowserDueToMissingRepo = false let target = savedState.mode mode = target if target == .diff { vm.showPreviewInsteadOfDiff = diffModePreviewPreference } else if !vm.showPreviewInsteadOfDiff { vm.showPreviewInsteadOfDiff = true } } }) view = AnyView(view.onChange(of: vm.selectedPath) { _ in }) view = AnyView(view.onChange(of: leftColumnWidth) { newW in WindowStateStore().saveReviewLeftPaneWidth(newW) }) return view } private var rootContent: some View { Group { if vm.repoRoot == nil && vm.isResolvingRepo { VStack(spacing: 16) { ProgressView() Text("Resolving repository access…") .font(.headline) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } else if !explorerRootExists && vm.repoRoot == nil { VStack(spacing: 12) { Image(systemName: "lock.rectangle.on.rectangle") .font(.system(size: 42)) .foregroundStyle(.secondary) Text("Git Review Unavailable") .font(.headline) Text( "This folder is either not a Git repository or requires permission. Authorize the repository root (the folder containing .git)." ) .font(.subheadline) .multilineTextAlignment(.center) .foregroundStyle(.secondary) .frame(maxWidth: 520) Button("Authorize Repository Folder…") { onRequestAuthorization?() } .buttonStyle(.borderedProminent) } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { contentWithPresentation } } } private var contentWithPresentation: some View { Group { switch presentation { case .embedded: baseContent .background(.thinMaterial) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .padding(8) case .full: baseContent } } } // Extracted heavy content to reduce body type-checking complexity private var baseContent: some View { VStack(alignment: .leading, spacing: 0) { switch regionLayout { case .combined: if mode == .graph, historyDetailCommit != nil { // History Details mode: hide left tree, use full width for History list + detail split historyDetailRoot } else { header .padding(.horizontal, 16) .padding(.vertical, 12) Divider() VSplitView { GeometryReader { geo in splitContent(totalWidth: geo.size.width) .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { if leftColumnWidth == 0 { let store = WindowStateStore() if let saved = store.restoreReviewLeftPaneWidth() { leftColumnWidth = clampLeftWidth(saved, total: geo.size.width) } else { leftColumnWidth = clampLeftWidth(geo.size.width * 0.25, total: geo.size.width) } } } } } } case .leftOnly: // Left tree + commit inline; omit header/graph and any right detail leftPane .frame(maxWidth: .infinity, maxHeight: .infinity) case .rightOnly: // Header + divider + detail (matching Tasks mode layout) VStack(spacing: 0) { header .padding(.horizontal, 16) .padding(.vertical, 12) Divider() if mode == .graph, historyDetailCommit != nil { historyDetailBody .frame(maxWidth: .infinity, maxHeight: .infinity) } else if mode == .graph { graphDetailView .frame(maxWidth: .infinity, maxHeight: .infinity) } else { detailView .frame(maxWidth: .infinity, maxHeight: .infinity) } } } } } // MARK: - History detail layout (History mode, commit details) /// Combined layout root when left tree is hidden and full width is used for History list + detail. private var historyDetailRoot: some View { VStack(spacing: 0) { header .padding(.horizontal, 16) .padding(.vertical, 12) Divider() historyDetailBody .frame(maxWidth: .infinity, maxHeight: .infinity) } } /// Right-side body: horizontally split between compact History list and commit detail. private var historyDetailBody: some View { HSplitView { historyDetailListPane historyDetailPane } .frame(maxWidth: .infinity, maxHeight: .infinity) } /// Left pane in History Details mode: compact History list (graph + description only). private var historyDetailListPane: some View { graphListView(compactColumns: true) { commit in historyDetailCommit = commit } } /// Right pane in History Details mode: commit meta + files tree + diff viewer. @ViewBuilder private var historyDetailPane: some View { if let commit = historyDetailCommit { HistoryCommitDetailView( commit: commit, viewModel: graphVM, onClose: { historyDetailCommit = nil }, wrap: wrapText, showLineNumbers: showLineNumbers ) } else { VStack { Text("No commit selected") .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } } private func splitContent(totalWidth: CGFloat) -> some View { // Top split: left file tree and right diff/preview, with draggable divider let leftW = effectiveLeftWidth(total: totalWidth) let gutterW: CGFloat = 33 // divider 1pt + 8pt padding each side let rightW = max(totalWidth - gutterW - leftW, 240) return HStack(spacing: 0) { leftPane .frame(width: leftW) .frame(minWidth: leftMin, maxWidth: leftMax) // Visible divider with padding; whole gutter is draggable HStack(spacing: 0) { Color.clear.frame(width: 8) Divider().frame(width: 1) Color.clear.frame(width: 8) } .frame(width: gutterW) .frame(maxHeight: .infinity) .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 1).onChanged { value in let newW = clampLeftWidth(leftColumnWidth + value.translation.width, total: totalWidth) leftColumnWidth = newW } ) .onHover { inside in #if canImport(AppKit) if inside { NSCursor.resizeLeftRight.set() } else { NSCursor.arrow.set() } #endif } Group { if mode == .graph { graphDetailView } else { detailView } } .padding(16) .frame(width: rightW) .frame(maxHeight: .infinity) } } @MainActor private func handleTreeQueryChange(_ query: String) { contentSearchTask?.cancel() contentSearchTask = nil let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { contentSearchMatches = [] return } guard let root = vm.repoRoot else { contentSearchMatches = [] return } contentSearchMatches = [] contentSearchQueryVersion &+= 1 let version = contentSearchQueryVersion let service = repoSearchService contentSearchTask = Task { try? await Task.sleep(nanoseconds: 200_000_000) if Task.isCancelled { return } do { let matches = try await service.searchFilesContaining( trimmed, in: root, limit: repoContentMatchLimit) if Task.isCancelled { return } await MainActor.run { if version == contentSearchQueryVersion { contentSearchMatches = matches rebuildDisplayed() rebuildBrowserDisplayed() contentSearchTask = nil } } } catch is CancellationError { // Ignore cancellation; another task will replace it } catch { await MainActor.run { if version == contentSearchQueryVersion { contentSearchMatches = [] contentSearchTask = nil } } } } } private func ensureExpandAllIfNeeded() { if expandedDirsStaged.isEmpty { expandedDirsStaged = Set(allDirectoryKeys(nodes: cachedNodesStaged)) } if expandedDirsUnstaged.isEmpty { expandedDirsUnstaged = Set(allDirectoryKeys(nodes: cachedNodesUnstaged)) } } // MARK: - Layout helpers private func clampLeftWidth(_ proposed: CGFloat, total: CGFloat) -> CGFloat { let minW = leftMin let maxW = min(leftMax, total - 240) // keep space for right pane + gutter return max(minW, min(maxW, proposed)) } private func effectiveLeftWidth(total: CGFloat) -> CGFloat { let w = (leftColumnWidth == 0) ? total * 0.25 : leftColumnWidth return clampLeftWidth(w, total: total) } var explorerRootExists: Bool { FileManager.default.fileExists(atPath: explorerRoot.path) } var explorerRoot: URL { projectDirectory ?? workingDirectory } // Measure dynamic height for inline commit editor based on width func measureCommitHeight(_ text: String, width: CGFloat) -> CGFloat { #if canImport(AppKit) let font = NSFont.systemFont(ofSize: NSFont.systemFontSize) let s = text.isEmpty ? " " : text let rect = (s as NSString).boundingRect( with: NSSize(width: width, height: CGFloat.greatestFiniteMagnitude), options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: font] ) return max(20, ceil(rect.height)) #else return 20 #endif } // MARK: - File tree (grouped by directories) typealias FileNode = GitReviewNode // MARK: - TreeScope enum enum TreeScope { case unstaged, staged } } ================================================ FILE: views/GitReviewSettingsView.swift ================================================ import SwiftUI struct GitReviewSettingsView: View { @ObservedObject var preferences: SessionPreferencesStore @StateObject private var providerCatalog = UnifiedProviderCatalogModel() @State private var draftTemplate: String = "" @State private var providerId: String? = nil @State private var modelId: String? = nil @State private var modelList: [String] = [] @State private var lastProviderId: String? = nil var body: some View { VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 6) { Text("Git Review Settings").font(.title2).fontWeight(.bold) Text("Customize Git changes viewer and AI commit generation.") .font(.subheadline) .foregroundColor(.secondary) } VStack(alignment: .leading, spacing: 10) { Text("Display").font(.headline).fontWeight(.semibold) settingsCard { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { GridRow { VStack(alignment: .leading, spacing: 2) { Label("Show Line Numbers", systemImage: "list.number") .font(.subheadline).fontWeight(.medium) Text("Show line numbers in diffs.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("", isOn: $preferences.gitShowLineNumbers) .labelsHidden().toggleStyle(.switch).controlSize(.small) .frame(maxWidth: .infinity, alignment: .trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Wrap Long Lines", systemImage: "text.line.first.and.arrowtriangle.forward") .font(.subheadline).fontWeight(.medium) Text("Enable soft wrap in diff viewer.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("", isOn: $preferences.gitWrapText) .labelsHidden().toggleStyle(.switch).controlSize(.small) .frame(maxWidth: .infinity, alignment: .trailing) } } } } VStack(alignment: .leading, spacing: 10) { Text("Generate").font(.headline).fontWeight(.semibold) settingsCard { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { GridRow { VStack(alignment: .leading, spacing: 2) { Label("Commit Model", systemImage: "brain") .font(.subheadline).fontWeight(.medium) Text("Select a model from Auto-Proxy mode.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } // Model picker with sanitized names and provider icons SimpleModelPicker( models: modelList, isDisabled: !providerCatalog.isProviderAvailable(providerId), providerId: providerId, providerCatalog: providerCatalog, modelId: $modelId ) .frame(maxWidth: .infinity, alignment: .trailing) .onChange(of: modelId) { newVal in preferences.commitModelId = newVal } } gridDivider // Prompt template placed last GridRow { VStack(alignment: .leading, spacing: 2) { Label("Commit Message Prompt Template", systemImage: "text.bubble") .font(.subheadline).fontWeight(.medium) Text( "Optional preamble used before the diff when generating commit messages. Leave blank to use the built‑in prompt." ) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) .padding(.bottom, 8) TextEditor(text: $draftTemplate) .font(.system(.body)) .frame(height: 320) .padding(4) .overlay( RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.25)) ) .onChange(of: draftTemplate) { newVal in preferences.commitPromptTemplate = newVal } } .gridCellColumns(2) } } } } // Repository authorization has moved to on-demand prompts in Review. // The settings page no longer manages a global list to reduce clutter. } .onAppear { draftTemplate = preferences.commitPromptTemplate providerId = preferences.commitProviderId modelId = preferences.commitModelId Task { await reloadCatalog() } } // Removed rerouteBuiltIn/reroute3P onChange handlers - all providers now use Auto-Proxy mode .onChange(of: preferences.oauthProvidersEnabled) { _ in Task { await reloadCatalog() } } .onChange(of: preferences.apiKeyProvidersEnabled) { _ in Task { await reloadCatalog(forceRefresh: true) } } .onChange(of: CLIProxyService.shared.isRunning) { _ in Task { await reloadCatalog() } } } @ViewBuilder private var gridDivider: some View { Divider() } @ViewBuilder private func settingsCard(@ViewBuilder _ content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 8) { content() } .padding(10) .background(Color(nsColor: .separatorColor).opacity(0.35)) .cornerRadius(10) } private func reloadCatalog(forceRefresh: Bool = false) async { await providerCatalog.reload(preferences: preferences, forceRefresh: forceRefresh) normalizeSelection() } private func normalizeSelection() { // Git Review always uses Auto-Proxy mode providerId = UnifiedProviderID.autoProxyId preferences.commitProviderId = UnifiedProviderID.autoProxyId modelList = providerCatalog.models(for: providerId) let providerChanged = lastProviderId != nil && lastProviderId != providerId lastProviderId = providerId if providerChanged { modelId = nil preferences.commitModelId = nil return } guard !modelList.isEmpty else { return } let current = preferences.commitModelId let nextModel = (current != nil && modelList.contains(current ?? "")) ? current : nil modelId = nextModel if nextModel == nil { preferences.commitModelId = nil } } } // Authorized repositories list has been removed from Settings. ================================================ FILE: views/HookEditSheet.swift ================================================ import AppKit import SwiftUI import UniformTypeIdentifiers struct HookEditSheet: View { @ObservedObject var preferences: SessionPreferencesStore let rule: HookRule? let onSave: (HookRule) -> Void let onCancel: () -> Void @State private var name: String = "" @State private var descriptionText: String = "" @State private var enabled: Bool = true @State private var selectedEvent: String = "" @State private var customEvent: String = "" @State private var matcher: String = "" @State private var targets: HookTargets = HookTargets() @State private var commands: [EditableHookCommand] = [] @State private var selectedTab: Int = 0 @State private var errorMessage: String? @State private var hoveringCommandIds: Set = [] @State private var pendingDeleteCommand: PendingCommandDelete? @State private var eventPickerPresented: Bool = false @State private var eventQuery: String = "" @State private var eventFilter: HookEventFilter = .all @State private var variablePicker: VariablePickerContext? @State private var variableQuery: String = "" @State private var variableFilter: HookVariableFilter = .all @State private var wizardActive: Bool = false @State private var didHydrate: Bool = false @FocusState private var focusedField: FocusField? @FocusState private var eventSearchFocused: Bool private let variablePopoverSize: CGSize = CGSize(width: 360, height: 380) private let eventPopoverSize: CGSize = CGSize(width: 360, height: 380) @FocusState private var variableSearchFocused: Bool private enum FocusField { case name } private let customEventKey = "__custom__" private let sheetMaxHeight: CGFloat = 560 private let generalRowMinHeight: CGFloat = 28 var body: some View { if wizardActive { HookWizardSheet(preferences: preferences, onApply: { draft in applyDraft(draft) wizardActive = false }, onCancel: { wizardActive = false }) } else { formBody } } private var formBody: some View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .firstTextBaseline) { Text(rule == nil ? "New Hook" : "Edit Hook") .font(.title3) .fontWeight(.semibold) Spacer() Button { wizardActive = true } label: { Image(systemName: "sparkles") } .buttonStyle(.borderless) .help("AI Wizard") } if #available(macOS 15.0, *) { TabView(selection: $selectedTab) { Tab("General", systemImage: "slider.horizontal.3", value: 0) { SettingsTabContent { generalTab } } Tab("Commands", systemImage: "terminal", value: 1) { SettingsTabContent { commandsTab } } } } else { TabView(selection: $selectedTab) { SettingsTabContent { generalTab } .tabItem { Label("General", systemImage: "slider.horizontal.3") } .tag(0) SettingsTabContent { commandsTab } .tabItem { Label("Commands", systemImage: "terminal") } .tag(1) } } if let msg = errorMessage, !msg.isEmpty { Text(msg) .font(.caption) .foregroundStyle(.orange) } HStack { if selectedTab == 1 { Button("Add Command") { commands.append(EditableHookCommand()) } .buttonStyle(.bordered) } Spacer() Button("Cancel") { onCancel() } Button(rule == nil ? "Create" : "Save") { save() } .buttonStyle(.borderedProminent) .disabled(!canSave) } } .padding(16) .frame(maxHeight: sheetMaxHeight) .onAppear { if rule == nil { DispatchQueue.main.async { focusedField = .name } } } .alert(item: $pendingDeleteCommand) { item in Alert( title: Text("Delete Command?"), message: Text("Remove this command from the hook?"), primaryButton: .destructive(Text("Delete")) { removeCommand(item.id) pendingDeleteCommand = nil }, secondaryButton: .cancel { pendingDeleteCommand = nil } ) } .onAppear { if !didHydrate { hydrateFromRule() didHydrate = true } } } private var canSave: Bool { let event = effectiveEvent guard !event.isEmpty else { return false } return commands.contains { !$0.command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } private var effectiveEvent: String { if selectedEvent == customEventKey { return customEvent.trimmingCharacters(in: .whitespacesAndNewlines) } return selectedEvent.trimmingCharacters(in: .whitespacesAndNewlines) } private var eventLabelText: String { if selectedEvent == customEventKey { let trimmed = customEvent.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? "Custom…" : trimmed } return effectiveEvent.isEmpty ? "Select event" : effectiveEvent } private var eventPicker: some View { Button { eventPickerPresented = true } label: { HStack(spacing: 6) { Text(eventLabelText) .lineLimit(1) Spacer(minLength: 8) if let descriptor = HookEventCatalog.descriptor(for: effectiveEvent) { eventProviderIcons(for: descriptor) } Image(systemName: "chevron.down") .font(.caption) .foregroundStyle(.secondary) } .padding(.horizontal, 8) .padding(.vertical, 4) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .background( RoundedRectangle(cornerRadius: 6) .fill(Color(nsColor: .textBackgroundColor)) ) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(Color.secondary.opacity(0.25)) ) } .buttonStyle(.plain) .help(HookEventCatalog.detailText(for: effectiveEvent)) .popover(isPresented: $eventPickerPresented, arrowEdge: .bottom) { eventPickerView() } } @ViewBuilder private var generalTab: some View { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { GridRow { Text("Name").font(.subheadline).fontWeight(.medium) TextField("Optional display name", text: $name) .focused($focusedField, equals: .name) .frame(minHeight: generalRowMinHeight) .frame(maxWidth: .infinity, alignment: .trailing) } GridRow { Text("Event").font(.subheadline).fontWeight(.medium) eventPicker .frame(minHeight: generalRowMinHeight) .frame(maxWidth: .infinity, alignment: .trailing) } if selectedEvent == customEventKey { GridRow { Text("Custom Event").font(.subheadline).fontWeight(.medium) TextField("Custom event name", text: $customEvent) .frame(minHeight: generalRowMinHeight) .frame(maxWidth: .infinity, alignment: .trailing) } } GridRow { Text("Matcher").font(.subheadline).fontWeight(.medium) Group { if HookEventCatalog.supportsMatcher(effectiveEvent, targets: targets) { let options = HookEventCatalog.matchers(for: effectiveEvent, targets: targets) HStack(spacing: 6) { TextField("Matcher (e.g., Write|Edit)", text: $matcher) if !options.isEmpty { Menu { ForEach(options, id: \.value) { option in Button(option.value) { matcher = option.value } } } label: { Image(systemName: "chevron.down") } .menuIndicator(.hidden) .buttonStyle(.borderless) } } .help(HookEventCatalog.matcherDescription(for: effectiveEvent, matcher: matcher) ?? "") } else { Text("Not applicable for this event") .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) } } .frame(minHeight: generalRowMinHeight) .frame(maxWidth: .infinity, alignment: .trailing) } GridRow { Text("Description").font(.subheadline).fontWeight(.medium) descriptionEditor(text: $descriptionText) .frame(maxWidth: .infinity, alignment: .trailing) } GridRow { Text("Targets").font(.subheadline).fontWeight(.medium) HStack(spacing: 12) { Toggle( "Codex", isOn: Binding( get: { targets.codex }, set: { targets.codex = $0 } ) ) .toggleStyle(.switch) .controlSize(.small) .disabled(!preferences.isCLIEnabled(.codex)) Toggle( "Claude Code", isOn: Binding( get: { targets.claude }, set: { targets.claude = $0 } ) ) .toggleStyle(.switch) .controlSize(.small) .disabled(!preferences.isCLIEnabled(.claude)) Toggle( "Gemini", isOn: Binding( get: { targets.gemini }, set: { targets.gemini = $0 } ) ) .toggleStyle(.switch) .controlSize(.small) .disabled(!preferences.isCLIEnabled(.gemini)) } .frame(minHeight: generalRowMinHeight) .frame(maxWidth: .infinity, alignment: .trailing) } } } @ViewBuilder private var commandsTab: some View { VStack(alignment: .leading, spacing: 12) { ScrollView { VStack(alignment: .leading, spacing: 12) { ForEach(Array(commands.enumerated()), id: \.element.id) { index, _ in commandCard(index: index, id: commands[index].id) } } .frame(maxWidth: .infinity, alignment: .topLeading) .padding(.top, 4) } .frame(maxHeight: .infinity) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } private func hydrateFromRule() { guard let rule else { selectedEvent = HookEventCatalog.canonicalEvents.first ?? "Stop" commands = [EditableHookCommand()] targets = HookTargets() enabled = true descriptionText = "" return } name = rule.name descriptionText = rule.description ?? "" enabled = rule.enabled if let descriptor = HookEventCatalog.descriptor(for: rule.event) { selectedEvent = descriptor.name customEvent = "" } else { selectedEvent = customEventKey customEvent = rule.event } matcher = rule.matcher ?? "" targets = rule.targets ?? HookTargets() commands = rule.commands.map { EditableHookCommand(from: $0) } if commands.isEmpty { commands = [EditableHookCommand()] } } private func applyDraft(_ draft: HookWizardDraft) { name = draft.name ?? "" descriptionText = draft.description ?? "" enabled = true if let descriptor = HookEventCatalog.descriptor(for: draft.event) { selectedEvent = descriptor.name customEvent = "" } else { selectedEvent = customEventKey customEvent = draft.event } matcher = draft.matcher ?? "" targets = draft.targets ?? HookTargets() commands = draft.commands.map { EditableHookCommand(from: $0) } if commands.isEmpty { commands = [EditableHookCommand()] } } private func removeCommand(_ id: UUID) { commands.removeAll { $0.id == id } hoveringCommandIds.remove(id) if commands.isEmpty { commands = [EditableHookCommand()] } } private func confirmDeleteCommand(_ id: UUID) { pendingDeleteCommand = PendingCommandDelete(id: id) } private func commandCard(index: Int, id: UUID) -> some View { GroupBox { VStack(alignment: .leading, spacing: 8) { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { GridRow { Text("Command") .font(.caption) .foregroundStyle(.secondary) HStack(spacing: 8) { TextField("Select executable or type path", text: $commands[index].command) Button { chooseCommandPath(for: id) } label: { Image(systemName: "folder") } .buttonStyle(.borderless) .help("Choose executable") } .frame(maxWidth: .infinity, alignment: .trailing) } GridRow { Text("Args") .font(.caption) .foregroundStyle(.secondary) HStack(alignment: .top, spacing: 8) { placeholderEditor( text: $commands[index].argsText, placeholder: "one argument per line", height: 88 ) variableInsertButton(commandId: id, target: .args) .padding(.top, 4) } .frame(maxWidth: .infinity, alignment: .trailing) } GridRow { Text("Env") .font(.caption) .foregroundStyle(.secondary) HStack(alignment: .top, spacing: 8) { placeholderEditor( text: $commands[index].envText, placeholder: "KEY=VALUE, one per line", height: 88 ) variableInsertButton(commandId: id, target: .env) .padding(.top, 4) } .frame(maxWidth: .infinity, alignment: .trailing) } GridRow { Text("Timeout") .font(.caption) .foregroundStyle(.secondary) HStack(spacing: 8) { TextField("ms", text: $commands[index].timeoutMsText) .frame(width: 180, alignment: .trailing) Spacer() Button(role: .destructive) { confirmDeleteCommand(id) } label: { Image(systemName: "trash") } .buttonStyle(.borderless) .help("Remove command") .opacity(hoveringCommandIds.contains(id) ? 1 : 0) .scaleEffect(hoveringCommandIds.contains(id) ? 1.0 : 0.92) .offset(y: hoveringCommandIds.contains(id) ? 0 : 2) .allowsHitTesting(hoveringCommandIds.contains(id)) .animation(.easeInOut(duration: 0.12), value: hoveringCommandIds.contains(id)) } } } } .padding(4) } .onHover { hovering in if hovering { hoveringCommandIds.insert(id) } else { hoveringCommandIds.remove(id) } } } private func descriptionEditor(text: Binding) -> some View { ZStack(alignment: .topLeading) { if text.wrappedValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { Text("Describe what this hook is for…") .font(.caption) .foregroundStyle(.tertiary) .padding(.top, 6) .padding(.leading, 4) } TextEditor(text: text) .font(.body) .frame(height: 64) .scrollContentBackground(.hidden) .background(Color(nsColor: .textBackgroundColor)) } .overlay( RoundedRectangle(cornerRadius: 6) .stroke(Color.secondary.opacity(0.15)) ) } private func placeholderEditor( text: Binding, placeholder: String, height: CGFloat = 44 ) -> some View { ZStack(alignment: .topLeading) { if text.wrappedValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { Text(placeholder) .font(.caption) .foregroundStyle(.tertiary) .padding(.top, 6) .padding(.leading, 4) } TextEditor(text: text) .font(.system(.caption, design: .monospaced)) .frame(height: height) .scrollContentBackground(.hidden) .background(Color(nsColor: .textBackgroundColor)) } .overlay( RoundedRectangle(cornerRadius: 6) .stroke(Color.secondary.opacity(0.15)) ) .frame(maxWidth: .infinity, alignment: .leading) } private func variableInsertButton(commandId: UUID, target: VariableInsertTarget) -> some View { Button { variablePicker = VariablePickerContext(commandId: commandId, target: target) } label: { Image(systemName: "curlybraces.square") } .buttonStyle(.borderless) .help("Insert variable") .popover( isPresented: popoverBinding(for: commandId, target: target), arrowEdge: .bottom ) { variablePickerView(commandId: commandId, target: target) } } private func popoverBinding(for commandId: UUID, target: VariableInsertTarget) -> Binding { Binding( get: { variablePicker?.commandId == commandId && variablePicker?.target == target }, set: { isPresented in if isPresented { variablePicker = VariablePickerContext(commandId: commandId, target: target) } else if variablePicker?.commandId == commandId && variablePicker?.target == target { variablePicker = nil } } ) } private func variablePickerView(commandId: UUID, target: VariableInsertTarget) -> some View { VStack(alignment: .leading, spacing: 8) { Text("Hook variables") .font(.headline) Text("Selecting a variable inserts it into the field.") .font(.footnote) .foregroundStyle(.secondary) Picker("Filter", selection: $variableFilter) { ForEach(HookVariableFilter.allCases) { filter in Text(filter.title).tag(filter) } } .labelsHidden() .pickerStyle(.segmented) .controlSize(.small) TextField("Search variables", text: $variableQuery) .textFieldStyle(.roundedBorder) .focused($variableSearchFocused) ScrollView { VStack(alignment: .leading, spacing: 0) { let rows = filteredVariables(for: target) ForEach(rows.indices, id: \.self) { idx in variableRow(rows[idx], index: idx, commandId: commandId, target: target) } if rows.isEmpty { Text("No variables match this filter.") .font(.caption) .foregroundStyle(.secondary) .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .center) } } } .frame(minHeight: 160, maxHeight: .infinity) .layoutPriority(1) } .padding(12) .frame(width: variablePopoverSize.width, height: variablePopoverSize.height, alignment: .topLeading) .onAppear { DispatchQueue.main.async { variableSearchFocused = true } } } private func eventPickerView() -> some View { VStack(alignment: .leading, spacing: 8) { Text("Hook events") .font(.headline) Text("Select the event that should trigger this hook.") .font(.footnote) .foregroundStyle(.secondary) Picker("Filter", selection: $eventFilter) { ForEach(HookEventFilter.allCases) { filter in Text(filter.title).tag(filter) } } .labelsHidden() .pickerStyle(.segmented) .controlSize(.small) TextField("Search events", text: $eventQuery) .textFieldStyle(.roundedBorder) .focused($eventSearchFocused) ScrollView { VStack(alignment: .leading, spacing: 0) { let rows = filteredEvents() ForEach(rows.indices, id: \.self) { idx in eventRow(rows[idx], index: idx) } if rows.isEmpty { Text("No events match this filter.") .font(.caption) .foregroundStyle(.secondary) .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .center) } } } .frame(minHeight: 160, maxHeight: .infinity) .layoutPriority(1) Divider() Button("Custom…") { selectedEvent = customEventKey eventPickerPresented = false } .buttonStyle(.borderless) } .padding(12) .frame(width: eventPopoverSize.width, height: eventPopoverSize.height, alignment: .topLeading) .onAppear { DispatchQueue.main.async { eventSearchFocused = true } } } private func filteredEvents() -> [HookEventDescriptor] { let candidates = HookEventCatalog.all.filter { matchesEventFilter($0) } let trimmed = eventQuery.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return candidates } let query = trimmed.lowercased() return candidates.filter { if $0.name.lowercased().contains(query) { return true } if $0.description.lowercased().contains(query) { return true } if let note = $0.note?.lowercased(), note.contains(query) { return true } return false } } private func matchesEventFilter(_ event: HookEventDescriptor) -> Bool { switch eventFilter { case .all: return true case .common: return event.providers.contains(.claude) && event.providers.contains(.gemini) case .codex: return event.providers.contains(.codex) case .claude: return event.providers.contains(.claude) case .gemini: return event.providers.contains(.gemini) } } private func eventRow(_ event: HookEventDescriptor, index: Int) -> some View { let detail = HookEventCatalog.detailText(for: event.name) return Button { selectedEvent = event.name customEvent = "" eventPickerPresented = false } label: { HStack(spacing: 8) { eventProviderIcons(for: event) VStack(alignment: .leading, spacing: 2) { Text(event.name) .font(.system(.caption, design: .monospaced)) .lineLimit(1) Text(detail) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) } .frame(maxWidth: .infinity, alignment: .leading) } .padding(.leading, 8) .padding(.trailing, 12) .padding(.vertical, 6) .frame(minHeight: 44) .frame(maxWidth: .infinity, alignment: .leading) .background(index % 2 == 0 ? Color.secondary.opacity(0.06) : Color.clear) .contentShape(Rectangle()) .help("\(event.name) — \(detail)") } .buttonStyle(.plain) } private func filteredVariables(for target: VariableInsertTarget) -> [HookVariableDescriptor] { let candidates = HookCommandVariableCatalog.all.filter { matchesTarget($0, target: target) && matchesFilter($0) } let trimmed = variableQuery.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return candidates } let query = trimmed.lowercased() return candidates.filter { matchesQuery($0, query: query) } } private func variableRow( _ variable: HookVariableDescriptor, index: Int, commandId: UUID, target: VariableInsertTarget ) -> some View { let detail = variableDetailText(variable) return Button { insertVariable(variable, into: target, commandId: commandId) variablePicker = nil } label: { HStack(spacing: 8) { providerIcons(for: variable) VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline, spacing: 6) { Text(variable.name) .font(.system(.caption, design: .monospaced)) .lineLimit(1) Spacer(minLength: 8) kindBadge(variable.kind) } Text(detail) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) } .frame(maxWidth: .infinity, alignment: .leading) } .padding(.leading, 8) .padding(.trailing, 12) .padding(.vertical, 6) .frame(minHeight: 44) .frame(maxWidth: .infinity, alignment: .leading) .background(index % 2 == 0 ? Color.secondary.opacity(0.06) : Color.clear) .contentShape(Rectangle()) .help("\(variable.name) — \(detail)") } .buttonStyle(.plain) } private func providerIcons(for variable: HookVariableDescriptor) -> some View { HStack(spacing: 4) { providerIcon(.codex, variable: variable) providerIcon(.claude, variable: variable) providerIcon(.gemini, variable: variable) } } private func providerIcon(_ provider: HookVariableProvider, variable: HookVariableDescriptor) -> some View { let supported = variable.providers.contains(provider) let supportText = supported ? "Supported" : "Not supported" return providerIcon(provider, supported: supported) .help("\(provider.displayName) · \(supportText)") } private func eventProviderIcons(for event: HookEventDescriptor) -> some View { HStack(spacing: 4) { eventProviderIcon(.codex, event: event) eventProviderIcon(.claude, event: event) eventProviderIcon(.gemini, event: event) } } private func eventProviderIcon(_ provider: HookVariableProvider, event: HookEventDescriptor) -> some View { let supported = event.providers.contains(provider) let supportText = supported ? "Supported" : "Not supported" return providerIcon(provider, supported: supported) .help("\(provider.displayName) · \(supportText)") } private func providerIcon(_ provider: HookVariableProvider, supported: Bool) -> some View { let opacity: Double = supported ? 1.0 : 0.2 let saturation: Double = supported ? 1.0 : 0.0 let grayscale: Double = supported ? 0.0 : 1.0 return ProviderIconView( provider: usageProvider(for: provider), size: 12, cornerRadius: 2, saturation: saturation, opacity: opacity ) .grayscale(grayscale) } private func usageProvider(for provider: HookVariableProvider) -> UsageProviderKind { switch provider { case .codex: return .codex case .claude: return .claude case .gemini: return .gemini } } private func variableDetailText(_ variable: HookVariableDescriptor) -> String { if let note = variable.note, !note.isEmpty { return "\(variable.description) (\(note))" } return variable.description } private func matchesFilter(_ variable: HookVariableDescriptor) -> Bool { switch variableFilter { case .all: return true case .common: return variable.providers.contains(.claude) && variable.providers.contains(.gemini) case .codex: return variable.providers.contains(.codex) case .claude: return variable.providers.contains(.claude) case .gemini: return variable.providers.contains(.gemini) } } private func matchesTarget(_ variable: HookVariableDescriptor, target: VariableInsertTarget) -> Bool { switch target { case .args: return true case .env: return variable.kind == .env } } private func matchesQuery(_ variable: HookVariableDescriptor, query: String) -> Bool { if variable.name.lowercased().contains(query) { return true } if variable.description.lowercased().contains(query) { return true } if let note = variable.note?.lowercased(), note.contains(query) { return true } return false } private func kindBadge(_ kind: HookVariableKind) -> some View { Text(kind.shortLabel) .font(.system(size: 9, weight: .semibold)) .foregroundStyle(.secondary) .padding(.horizontal, 5) .padding(.vertical, 1) .background(Color.secondary.opacity(0.12)) .clipShape(Capsule()) } private func insertVariable(_ variable: HookVariableDescriptor, into target: VariableInsertTarget, commandId: UUID) { let token = variableInsertToken(variable) updateCommand(commandId) { editable in switch target { case .args: editable.argsText = appendToken(token, to: editable.argsText, separator: "\n") case .env: editable.envText = appendToken(token, to: editable.envText, separator: "\n") } } } private func updateCommand(_ commandId: UUID, mutate: (inout EditableHookCommand) -> Void) { guard let index = commands.firstIndex(where: { $0.id == commandId }) else { return } mutate(&commands[index]) } private func appendToken(_ token: String, to text: String, separator: String) -> String { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return token } if text.hasSuffix(separator) { return text + token } return text + separator + token } private func variableInsertToken(_ variable: HookVariableDescriptor) -> String { switch variable.kind { case .env: return "$\(variable.name)" case .stdin: return stdinToken(for: variable.name) } } private func stdinToken(for name: String) -> String { let objectFields: Set = ["tool_input", "tool_response", "llm_request", "llm_response", "details", "mcp_context"] let flag = objectFields.contains(name) ? "-c" : "-r" return "$(jq \(flag) '.\(name)')" } private func chooseCommandPath(for id: UUID) { let panel = NSOpenPanel() panel.canChooseFiles = true panel.canChooseDirectories = false panel.allowsMultipleSelection = false panel.treatsFilePackagesAsDirectories = false panel.prompt = "Choose" panel.message = "Choose an executable to run for this hook" panel.allowedContentTypes = [.executable] panel.begin { response in guard response == .OK, let url = panel.url else { return } guard FileManager.default.isExecutableFile(atPath: url.path) else { errorMessage = "Selected file is not executable." return } guard let index = commands.firstIndex(where: { $0.id == id }) else { return } commands[index].command = url.path errorMessage = nil } } private func parseLines(_ text: String) -> [String] { text .split(whereSeparator: \.isNewline) .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } } private func parseEnv(_ text: String) -> [String: String] { var env: [String: String] = [:] for line in parseLines(text) { guard let eq = line.firstIndex(of: "=") else { continue } let key = String(line[.. 0 ? timeout : nil ) } guard !finalCommands.isEmpty else { errorMessage = "At least one command is required." return } var finalName = name.trimmingCharacters(in: .whitespacesAndNewlines) if finalName.isEmpty { finalName = HookEventCatalog.defaultName( event: event, matcher: finalMatcher, command: finalCommands.first) } let finalDescriptionText = descriptionText.trimmingCharacters(in: .whitespacesAndNewlines) let finalDescription = finalDescriptionText.isEmpty ? nil : finalDescriptionText let resolvedTargets = targets.allEnabled ? nil : targets let now = Date() let out = HookRule( id: rule?.id ?? UUID().uuidString, name: finalName, description: finalDescription, event: event, matcher: finalMatcher, commands: finalCommands, enabled: enabled, targets: resolvedTargets, source: rule?.source ?? "user", createdAt: rule?.createdAt ?? now, updatedAt: now ) onSave(out) } } private enum VariableInsertTarget: Sendable { case args case env } private enum HookVariableFilter: String, CaseIterable, Identifiable { case all case common case codex case claude case gemini var id: String { rawValue } var title: String { switch self { case .all: return "All" case .common: return "Common" case .codex: return "Codex" case .claude: return "Claude" case .gemini: return "Gemini" } } } private enum HookEventFilter: String, CaseIterable, Identifiable { case all case common case codex case claude case gemini var id: String { rawValue } var title: String { switch self { case .all: return "All" case .common: return "Common" case .codex: return "Codex" case .claude: return "Claude" case .gemini: return "Gemini" } } } private struct VariablePickerContext: Identifiable { let id = UUID() let commandId: UUID let target: VariableInsertTarget } private struct PendingCommandDelete: Identifiable { let id: UUID } private struct EditableHookCommand: Identifiable { let id: UUID var command: String var argsText: String var envText: String var timeoutMsText: String init( id: UUID = UUID(), command: String = "", argsText: String = "", envText: String = "", timeoutMsText: String = "" ) { self.id = id self.command = command self.argsText = argsText self.envText = envText self.timeoutMsText = timeoutMsText } init(from command: HookCommand) { self.id = UUID() self.command = command.command self.argsText = (command.args ?? []).joined(separator: "\n") self.envText = (command.env ?? [:]).map { "\($0.key)=\($0.value)" }.sorted().joined( separator: "\n") self.timeoutMsText = command.timeoutMs.map(String.init) ?? "" } } ================================================ FILE: views/HooksSettingsView.swift ================================================ import SwiftUI struct HooksSettingsView: View { @ObservedObject var preferences: SessionPreferencesStore @StateObject private var vm = HooksViewModel() @State private var searchFocused = false @State private var pendingAction: PendingHookAction? var body: some View { VStack(alignment: .leading, spacing: 12) { headerRow contentRow } .sheet(isPresented: $vm.showAddSheet) { HookEditSheet( preferences: preferences, rule: nil, onSave: { rule in Task { await vm.addRule(rule) vm.showAddSheet = false } }, onCancel: { vm.showAddSheet = false } ) .frame(minWidth: 760, minHeight: 520) } .sheet(isPresented: $vm.showImportSheet) { HooksImportSheet( candidates: $vm.importCandidates, isImporting: vm.isImporting, statusMessage: vm.importStatusMessage, title: "Import Hooks", subtitle: "Scan Home for existing Codex/Claude/Gemini hooks and import into CodMate.", onCancel: { vm.cancelImport() }, onImport: { Task { await vm.importSelectedHooks() } } ) .frame(minWidth: 760, minHeight: 480) } .sheet(item: $vm.editingRule) { rule in HookEditSheet( preferences: preferences, rule: rule, onSave: { updated in Task { await vm.updateRule(updated) vm.editingRule = nil } }, onCancel: { vm.editingRule = nil } ) .frame(minWidth: 760, minHeight: 520) } .alert(item: $pendingAction) { action in Alert( title: Text("Delete Hook?"), message: Text("Remove \"\(action.rule.name)\" from the hooks list?"), primaryButton: .destructive(Text("Delete")) { Task { await vm.deleteRule(id: action.rule.id) pendingAction = nil } }, secondaryButton: .cancel { pendingAction = nil } ) } .task { await vm.load() } } private var headerRow: some View { HStack(spacing: 8) { Spacer(minLength: 0) ToolbarSearchField( placeholder: "Search hooks", text: $vm.searchText, onFocusChange: { focused in searchFocused = focused }, onSubmit: {} ) .frame(width: 240) Button { vm.showAddSheet = true } label: { Label("Add", systemImage: "plus") } Button { vm.beginImportFromHome() } label: { Label("Import", systemImage: "tray.and.arrow.down") } } } private var contentRow: some View { HStack(alignment: .top, spacing: 12) { hooksList .frame(minWidth: 260, maxWidth: 320) detailPanel } .frame(maxWidth: .infinity, maxHeight: .infinity) } private var hooksList: some View { Group { if vm.isLoading { VStack(spacing: 8) { ProgressView() Text("Loading hooks…") .font(.caption) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } else if vm.filteredRules.isEmpty { VStack(spacing: 10) { Image(systemName: "link") .font(.system(size: 32)) .foregroundStyle(.secondary) Text("No Hooks") .font(.title3) .fontWeight(.medium) Text("Add a hook to get started.") .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { List(selection: $vm.selectedRuleId) { ForEach(vm.filteredRules) { rule in HookRuleRow( preferences: preferences, rule: rule, isSelected: vm.selectedRuleId == rule.id, onSelect: { vm.selectedRuleId = rule.id }, onEdit: { vm.editingRule = rule }, onDelete: { confirmDelete(rule) }, onToggleEnabled: { value in vm.updateRuleEnabled(id: rule.id, value: value) }, onToggleTarget: { target, value in vm.updateRuleTarget(id: rule.id, target: target, value: value) } ) .tag(rule.id as String?) } } .listStyle(.inset) .scrollContentBackground(.hidden) } } .background( RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color(nsColor: .textBackgroundColor)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.15))) ) } private var detailPanel: some View { VStack(alignment: .leading, spacing: 12) { if let rule = vm.selectedRule { HookDetailPane( rule: rule, warnings: vm.syncWarnings.filter { $0.provider == .codex && rule.isEnabled(for: .codex) } + vm.syncWarnings.filter { $0.provider == .claude && rule.isEnabled(for: .claude) } + vm.syncWarnings.filter { $0.provider == .gemini && rule.isEnabled(for: .gemini) }, onSync: { Task { await vm.applyToProviders() } }, onEdit: { vm.editingRule = rule }, onDelete: { confirmDelete(rule) } ) .id(rule.id) } else { VStack(spacing: 12) { Image(systemName: "link") .font(.system(size: 32)) .foregroundStyle(.secondary) Text("Select a hook to view details") .font(.subheadline) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } if !vm.syncWarnings.isEmpty { Divider() VStack(alignment: .leading, spacing: 6) { Label("Apply warnings", systemImage: "exclamationmark.triangle") .font(.subheadline.weight(.medium)) .foregroundStyle(.orange) ForEach(vm.syncWarnings) { warning in Text("\(warning.provider.displayName): \(warning.message)") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } } if let msg = vm.errorMessage, !msg.isEmpty { Divider() Text(msg) .font(.caption) .foregroundStyle(.secondary) } } .padding(12) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background( RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color(nsColor: .textBackgroundColor)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.15))) ) } private func confirmDelete(_ rule: HookRule) { pendingAction = PendingHookAction(rule: rule) } } private struct PendingHookAction: Identifiable { let id = UUID() let rule: HookRule } private struct HookRuleRow: View { @ObservedObject var preferences: SessionPreferencesStore let rule: HookRule let isSelected: Bool var onSelect: () -> Void var onEdit: () -> Void var onDelete: () -> Void var onToggleEnabled: (Bool) -> Void var onToggleTarget: (HookTarget, Bool) -> Void var body: some View { HStack(alignment: .center, spacing: 8) { Toggle( "", isOn: Binding( get: { rule.enabled }, set: { value in onToggleEnabled(value) } ) ) .labelsHidden() .controlSize(.small) VStack(alignment: .leading, spacing: 4) { Text(rule.name.isEmpty ? rule.event : rule.name) .font(.body.weight(.medium)) .lineLimit(1) Text(ruleSummary(rule)) .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) } Spacer(minLength: 8) HStack(spacing: 6) { ForEach(HookTarget.allCases, id: \.self) { target in MCPServerTargetToggle( provider: target.usageProvider, isOn: Binding( get: { rule.targets?.isEnabled(for: target) ?? true }, set: { value in onToggleTarget(target, value) } ), disabled: !preferences.isCLIEnabled(target.baseKind) ) } } } .padding(.vertical, 4) .contentShape(Rectangle()) .onTapGesture { onSelect() } .contextMenu { Button("Edit") { onEdit() } Button("Delete", role: .destructive) { onDelete() } } } private func ruleSummary(_ rule: HookRule) -> String { let event = rule.event.isEmpty ? "Event" : rule.event if let matcher = rule.matcher, !matcher.isEmpty { return "\(event) · \(matcher) · \(rule.commands.count) command(s)" } return "\(event) · \(rule.commands.count) command(s)" } } private struct HookDetailPane: View { let rule: HookRule let warnings: [HookSyncWarning] var onSync: () -> Void var onEdit: () -> Void var onDelete: () -> Void var body: some View { VStack(alignment: .leading, spacing: 12) { header ScrollView { VStack(alignment: .leading, spacing: 16) { commandsSection if !warnings.isEmpty { providerWarningsSection } } } } } private var header: some View { HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 4) { Text(rule.name.isEmpty ? rule.event : rule.name) .font(.title3.weight(.semibold)) Text(descriptionText.isEmpty ? "No description provided" : descriptionText) .font(.subheadline) .foregroundStyle(descriptionText.isEmpty ? .tertiary : .secondary) .lineLimit(3) .help(descriptionText.isEmpty ? "No description provided" : descriptionText) Text(detailSubtitle) .font(.subheadline) .foregroundStyle(.secondary) } Spacer() HStack(spacing: 8) { Button { onSync() } label: { Image(systemName: "arrow.triangle.2.circlepath") } .buttonStyle(.borderless) .help("Apply hooks to AI CLI providers") Button { onEdit() } label: { Image(systemName: "pencil") } .buttonStyle(.borderless) .help("Edit") Button(role: .destructive) { onDelete() } label: { Image(systemName: "trash") } .buttonStyle(.borderless) .help("Delete") } } } private var commandsSection: some View { VStack(alignment: .leading, spacing: 6) { Text("Commands") .font(.headline) ForEach(Array(rule.commands.enumerated()), id: \.offset) { (_, cmd) in VStack(alignment: .leading, spacing: 2) { Text(cmd.command) .font(.caption) .textSelection(.enabled) if let args = cmd.args, !args.isEmpty { Text("Args: \(args.joined(separator: " "))") .font(.caption2) .foregroundStyle(.secondary) .textSelection(.enabled) } if let timeout = cmd.timeoutMs { Text("Timeout: \(timeout)ms") .font(.caption2) .foregroundStyle(.secondary) } } .padding(.vertical, 4) } } } private var providerWarningsSection: some View { VStack(alignment: .leading, spacing: 6) { Divider() Label("Provider warnings", systemImage: "exclamationmark.triangle") .font(.subheadline.weight(.medium)) .foregroundStyle(.orange) ForEach(warnings) { w in Text("\(w.provider.displayName): \(w.message)") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } } private var detailSubtitle: String { if let matcher = rule.matcher, !matcher.isEmpty { return "\(rule.event) · matcher: \(matcher)" } return rule.event } private var descriptionText: String { rule.description?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } } ================================================ FILE: views/LiveFileSizeText.swift ================================================ import SwiftUI /// Shows a session file's size and refreshes on file system events only. struct LiveFileSizeText: View { let url: URL @State private var text: String = "—" @State private var monitor: DirectoryMonitor? = nil var body: some View { Text(text) .font(.callout) .foregroundStyle(.secondary) .onAppear { start() } .onDisappear { stop() } .task(id: url) { restart() } .help("Current session file size") } private func restart() { stop(); start() } private func start() { // Event-driven: refresh on writes/renames/deletes/extend monitor?.cancel() monitor = DirectoryMonitor(url: url) { // UI updates must happen on main thread Task { @MainActor in refresh() } } // Initial paint refresh() } private func stop() { monitor?.cancel(); monitor = nil } private func refresh() { text = fileSize(url).map(formatBytes) ?? "—" } private func fileSize(_ url: URL) -> UInt64? { if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), let number = attrs[.size] as? NSNumber { return number.uint64Value } return nil } private func formatBytes(_ bytes: UInt64) -> String { let f = ByteCountFormatter() f.allowedUnits = [.useKB, .useMB] f.countStyle = .file return f.string(fromByteCount: Int64(bytes)) } } ================================================ FILE: views/LocalAuthProviderIconView.swift ================================================ import SwiftUI import AppKit struct LocalAuthProviderIconView: View { let provider: LocalAuthProvider var size: CGFloat = 12 var cornerRadius: CGFloat = 2 var saturation: Double = 1.0 var opacity: Double = 1.0 @Environment(\.colorScheme) private var colorScheme var body: some View { Group { if let image = processedIcon { Image(nsImage: image) .resizable() .interpolation(.high) .aspectRatio(contentMode: .fit) .frame(width: size, height: size) .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) .saturation(saturation) .opacity(opacity) } else { Circle() .fill(accent(for: provider)) .frame(width: dotSize, height: dotSize) .saturation(saturation) .opacity(opacity) } } .frame(width: size, height: size, alignment: .center) .id(colorScheme) // Force refresh when colorScheme changes } private var dotSize: CGFloat { max(6, size * 0.75) } /// Computed property that depends on colorScheme, ensuring real-time theme updates private var processedIcon: NSImage? { let name = iconName(for: provider) // Use unified resource processing with theme adaptation // This computed property depends on colorScheme, so SwiftUI will recompute it when theme changes let isDarkMode = colorScheme == .dark return ProviderIconResource.processedImage( named: name, size: NSSize(width: size, height: size), isDarkMode: isDarkMode ) } private func iconName(for provider: LocalAuthProvider) -> String { switch provider { case .codex: return "ChatGPTIcon" case .claude: return "ClaudeIcon" case .gemini: return "GeminiIcon" case .antigravity: return "AntigravityIcon" case .qwen: return "QwenIcon" } } private func accent(for provider: LocalAuthProvider) -> Color { switch provider { case .codex: return Color.accentColor case .claude: return Color(nsColor: .systemPurple) case .gemini: return Color(nsColor: .systemTeal) case .antigravity: return Color(nsColor: .systemIndigo) case .qwen: return Color(nsColor: .systemOrange) } } } ================================================ FILE: views/MCPServerTargetToggle.swift ================================================ import SwiftUI struct MCPServerTargetToggle: View { let provider: UsageProviderKind @Binding var isOn: Bool var disabled: Bool var body: some View { Button { if !disabled { isOn.toggle() } } label: { HStack(spacing: 4) { providerIcon } .padding(.horizontal, 4) .padding(.vertical, 4) } .buttonStyle(.plain) .help(helpText) } @ViewBuilder private var providerIcon: some View { let active = isOn && !disabled ProviderIconView( provider: provider, size: 14, cornerRadius: 3, saturation: active ? 1.0 : 0.0, opacity: active ? 1.0 : 0.2 ) } private var helpText: String { let name = provider.displayName if disabled { return "\(name) integration (server disabled)" } return isOn ? "Disable for \(name)" : "Enable for \(name)" } } ================================================ FILE: views/MCPServersSettingsView.swift ================================================ import SwiftUI import UniformTypeIdentifiers import AppKit struct MCPServersSettingsPane: View { @StateObject private var vm = MCPServersViewModel() @ObservedObject var preferences: SessionPreferencesStore @State private var showImportConfirmation = false @State private var showNewSheet = false // New unified editor sheet @State private var showEditorSheet = false @State private var editorIsEditingExisting = false var openMCPMateDownload: () -> Void var showHeader: Bool = true @State private var pendingDeleteName: String? = nil var body: some View { VStack(alignment: .leading, spacing: 6) { if showHeader { Text("MCP Servers").font(.title2).fontWeight(.bold) Text("Manage MCP servers. Add via Uni‑Import or configure capabilities.") .font(.subheadline) .foregroundColor(.secondary) } // List header with Add + Import button (match Providers style) HStack { Spacer() Button { editorIsEditingExisting = false; vm.startNewForm(); showEditorSheet = true } label: { Label("Add", systemImage: "plus") } Button { vm.beginImportFromHome() } label: { Label("Import", systemImage: "tray.and.arrow.down") } } serversList Spacer(minLength: 0) } .onAppear { Task { await vm.loadServers() } } .onChange(of: showNewSheet) { newVal in if newVal == false { Task { await vm.loadServers() } } } .onChange(of: showEditorSheet) { newVal in if newVal == false { Task { await vm.loadServers() } } } .sheet(isPresented: $showNewSheet) { NewMCPServerSheet(vm: vm, onClose: { showNewSheet = false }) .frame(minWidth: 640, minHeight: 420) } .sheet(isPresented: $showEditorSheet) { MCPServerEditorSheet( vm: vm, preferences: preferences, isEditing: editorIsEditingExisting, onClose: { showEditorSheet = false } ) .frame(minWidth: 760, minHeight: 480) } .sheet(isPresented: $vm.showImportSheet) { MCPImportSheet( candidates: $vm.importCandidates, isImporting: vm.isImporting, statusMessage: vm.importStatusMessage, title: "Import MCP Servers", subtitle: "Scan Home for existing Codex/Claude/Gemini MCP servers and import into CodMate.", onCancel: { vm.cancelImport() }, onImport: { Task { await vm.importSelectedServers() } } ) .frame(minWidth: 760, minHeight: 480) } } // Extracted: Import view used inside New window private var mcpImportTab: some View { VStack(alignment: .leading, spacing: 14) { Text("Uni-Import").font(.headline).fontWeight(.semibold) Text("Paste or drop JSON/TOML payloads to stage MCP servers before importing.") .font(.caption) .foregroundColor(.secondary) ZStack { RoundedRectangle(cornerRadius: 8) .stroke(style: StrokeStyle(lineWidth: 1, dash: [5])) .foregroundStyle(.quaternary) .frame(height: 120) VStack(spacing: 6) { Image(systemName: "square.and.arrow.down").font(.title3) Text("Drop text files or snippets here") .font(.caption) .foregroundColor(.secondary) } } .onDrop(of: [UTType.json, UTType.plainText, UTType.fileURL], isTargeted: nil) { providers in handleImportProviders(providers) } HStack(spacing: 8) { PasteButton(payloadType: String.self) { strings in if let text = strings.first(where: { !$0.isEmpty }) { vm.loadText(text) } } .buttonBorderShape(.roundedRectangle) .controlSize(.small) Button { vm.clearImport() } label: { Label("Clear", systemImage: "xmark.circle") } .buttonStyle(.bordered) .controlSize(.small) .disabled(vm.importText.isEmpty && vm.drafts.isEmpty && vm.importError == nil) } if vm.isParsing { HStack(spacing: 6) { ProgressView().controlSize(.small) Text("Parsing input…") .font(.caption) .foregroundColor(.secondary) } } else if let err = vm.importError { Label(err, systemImage: "exclamationmark.triangle") .font(.caption) .foregroundColor(.red) } else if !vm.drafts.isEmpty { Label("Detected \(vm.drafts.count) server(s). Review details below.", systemImage: "checkmark.circle") .font(.caption) .foregroundColor(.green) } TextEditor(text: Binding(get: { vm.importText }, set: { _ in })) .font(.system(.body, design: .monospaced)) .frame(minHeight: 200) .disabled(true) .overlay(RoundedRectangle(cornerRadius: 6).stroke(.quaternary)) if !vm.drafts.isEmpty { VStack(alignment: .leading, spacing: 6) { Text("Detected: \(vm.drafts.count) server(s)").font(.subheadline).fontWeight(.medium) ForEach(Array(vm.drafts.enumerated()), id: \.offset) { (_, draft) in VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { Image(systemName: draft.kind == .stdio ? "terminal" : (draft.kind == .sse ? "dot.radiowaves.left.and.right" : "globe")) Text(draft.name ?? "(unnamed)") .font(.subheadline) Spacer() } if let url = draft.url, !url.isEmpty { Text(url) .font(.caption) .foregroundColor(.secondary) .lineLimit(1) .truncationMode(.middle) } if let description = draft.meta?.description, !description.isEmpty { Text(description) .font(.caption) .foregroundColor(.secondary) } } .padding(.vertical, 4) } } Button(action: { showImportConfirmation = true }) { Label("Import", systemImage: "tray.and.arrow.down.fill") } .buttonStyle(.borderedProminent) .disabled(vm.isParsing) } } .padding(8) } private var serversList: some View { Group { if vm.servers.isEmpty { VStack(spacing: 12) { Image(systemName: "server.rack").font(.system(size: 48)).foregroundStyle(.secondary) Text("No MCP Servers") .font(.title3).fontWeight(.medium) Text("Click Add to import a server.") .font(.subheadline).foregroundStyle(.secondary) } .frame(maxWidth: .infinity) .frame(minHeight: 200) } else { List(selection: $vm.selectedServerName) { ForEach(vm.servers) { s in HStack(alignment: .center, spacing: 0) { Toggle("", isOn: Binding(get: { s.enabled }, set: { v in Task { await vm.setServerEnabled(s, v) } })) .toggleStyle(.switch) .labelsHidden() .controlSize(.small) .padding(.trailing, 8) HStack(alignment: .center, spacing: 8) { Image(systemName: s.kind == .stdio ? "terminal" : (s.kind == .sse ? "dot.radiowaves.left.and.right" : "globe")) Text(s.name).font(.body.weight(.medium)) } .frame(minWidth: 120, alignment: .leading) Spacer(minLength: 16) VStack(alignment: .leading, spacing: 2) { if let desc = s.meta?.description, !desc.isEmpty { Text(desc).font(.caption).foregroundStyle(.secondary) } HStack(spacing: 12) { if let url = s.url, !url.isEmpty { Label(url, systemImage: "link").font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle) } if let cmd = s.command, !cmd.isEmpty { Label(cmd, systemImage: "terminal").font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle) } } } .frame(maxWidth: .infinity, alignment: .leading) HStack(spacing: 6) { MCPServerTargetToggle( provider: .codex, isOn: Binding( get: { vm.isServerEnabled(s, for: .codex) }, set: { value in Task { await vm.setServerTargetEnabled(s, target: .codex, enabled: value) } } ), disabled: !s.enabled || !preferences.isCLIEnabled(.codex) ) MCPServerTargetToggle( provider: .claude, isOn: Binding( get: { vm.isServerEnabled(s, for: .claude) }, set: { value in Task { await vm.setServerTargetEnabled(s, target: .claude, enabled: value) } } ), disabled: !s.enabled || !preferences.isCLIEnabled(.claude) ) MCPServerTargetToggle( provider: .gemini, isOn: Binding( get: { vm.isServerEnabled(s, for: .gemini) }, set: { value in Task { await vm.setServerTargetEnabled(s, target: .gemini, enabled: value) } } ), disabled: !s.enabled || !preferences.isCLIEnabled(.gemini) ) } .padding(.trailing, 8) Button { editorIsEditingExisting = true vm.startEditForm(from: s) showEditorSheet = true } label: { Image(systemName: "pencil").font(.body) } .buttonStyle(.borderless) .help("Edit server") } .padding(.vertical, 8) .tag(s.name as String?) .contextMenu { Button("Edit…") { editorIsEditingExisting = true vm.startEditForm(from: s) showEditorSheet = true } Divider() Button(role: .destructive) { pendingDeleteName = s.name } label: { Text("Delete") } } } } .scrollContentBackground(.hidden) .frame(minHeight: 200, maxHeight: .infinity, alignment: .top) } } .task { await vm.loadServers() } .padding(.horizontal, -8) .alert("Delete MCP Server?", isPresented: Binding(get: { pendingDeleteName != nil }, set: { if !$0 { pendingDeleteName = nil } })) { Button("Delete", role: .destructive) { if let name = pendingDeleteName { Task { await vm.deleteServer(named: name) } } pendingDeleteName = nil } Button("Cancel", role: .cancel) { pendingDeleteName = nil } } message: { if let name = pendingDeleteName { Text("Are you sure you want to delete \"\(name)\"? This action cannot be undone.") } else { Text("") } } } private var mcpAdvancedTab: some View { VStack(alignment: .leading, spacing: 16) { HStack(alignment: .center, spacing: 12) { Image("MCPMateLogo") .resizable() .frame(width: 48, height: 48) .cornerRadius(12) VStack(alignment: .leading, spacing: 0) { Text("MCPMate").font(.headline) Text("A 'Maybe All-in-One' MCP service manager for developers and creators.") .font(.subheadline).fontWeight(.semibold) } } Text("MCPMate offers advanced MCP server management beyond CodMate's basic import and enable/disable controls.") .font(.body).foregroundColor(.secondary) Text("Download MCPMate to configure MCP servers alongside CodMate.") .font(.subheadline).foregroundColor(.secondary) Button(action: openMCPMateDownload) { Label("Download MCPMate", systemImage: "arrow.down.circle.fill").labelStyle(.titleAndIcon) } .buttonStyle(.borderedProminent) .controlSize(.large) .font(.body.weight(.semibold)) } .padding(8) } private func handleImportProviders(_ providers: [NSItemProvider]) -> Bool { var handled = false for provider in providers { if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { data, _ in guard let text = readText(from: data) else { return } handled = true DispatchQueue.main.async { vm.loadText(text) } } handled = true continue } if provider.hasItemConformingToTypeIdentifier(UTType.json.identifier) { provider.loadItem(forTypeIdentifier: UTType.json.identifier, options: nil) { data, _ in guard let text = readText(from: data) else { return } handled = true DispatchQueue.main.async { vm.loadText(text) } } handled = true continue } if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { data, _ in guard let text = readText(from: data) else { return } handled = true DispatchQueue.main.async { vm.loadText(text) } } handled = true continue } if provider.canLoadObject(ofClass: String.self) { _ = provider.loadObject(ofClass: String.self) { string, _ in guard let string = string else { return } handled = true DispatchQueue.main.async { vm.loadText(string) } } handled = true } } return handled } private func readText(from representation: (any NSSecureCoding)?) -> String? { if let string = representation as? String { return string } if let url = representation as? URL { return try? String(contentsOf: url, encoding: .utf8) } if let data = representation as? Data { if let url = URL(dataRepresentation: data, relativeTo: nil) { return try? String(contentsOf: url, encoding: .utf8) } return String(data: data, encoding: .utf8) } return nil } } // MARK: - New MCP Server Sheet (Import + Form placeholder) private struct NewMCPServerSheet: View { @ObservedObject var vm: MCPServersViewModel var onClose: () -> Void @State private var showImportConfirmation = false var body: some View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .firstTextBaseline) { Text("New MCP Server").font(.title3).fontWeight(.semibold) Spacer() Button("Close") { onClose() }.buttonStyle(.borderless) } SettingsTabContent { mcpImportContent } HStack { Spacer() Button("Import") { showImportConfirmation = true } .buttonStyle(.borderedProminent) .disabled(vm.isParsing || (vm.drafts.isEmpty && vm.importText.isEmpty)) } } .padding(12) .alert("Import Servers?", isPresented: $showImportConfirmation) { Button("Import", role: .none) { Task { await vm.importDrafts(); onClose() } } Button("Discard Drafts", role: .destructive) { vm.clearImport() } Button("Cancel", role: .cancel) {} } message: { Text("Import \(vm.drafts.count) server(s) into CodMate?") } } @ViewBuilder private var mcpImportContent: some View { VStack(alignment: .leading, spacing: 14) { Text("Uni-Import").font(.headline).fontWeight(.semibold) Text("Paste or drop JSON/TOML payloads to stage MCP servers before importing.") .font(.caption).foregroundColor(.secondary) ZStack { RoundedRectangle(cornerRadius: 8).stroke(style: StrokeStyle(lineWidth: 1, dash: [5])).foregroundStyle(.quaternary).frame(height: 120) VStack(spacing: 6) { Image(systemName: "square.and.arrow.down").font(.title3) Text("Drop text files or snippets here").font(.caption).foregroundColor(.secondary) } } .onDrop(of: [UTType.json, UTType.plainText, UTType.fileURL], isTargeted: nil) { providers in handleDropProviders(providers) } HStack(spacing: 8) { PasteButton(payloadType: String.self) { strings in if let text = strings.first(where: { !$0.isEmpty }) { vm.loadText(text) } } .buttonBorderShape(.roundedRectangle).controlSize(.small) Button { vm.clearImport() } label: { Label("Clear", systemImage: "xmark.circle") } .buttonStyle(.bordered).controlSize(.small) .disabled(vm.importText.isEmpty && vm.drafts.isEmpty && vm.importError == nil) } if vm.isParsing { HStack(spacing: 6) { ProgressView().controlSize(.small); Text("Parsing input…").font(.caption).foregroundColor(.secondary) } } else if let err = vm.importError { Label(err, systemImage: "exclamationmark.triangle").font(.caption).foregroundColor(.red) } else if !vm.drafts.isEmpty { Label("Detected \(vm.drafts.count) server(s). Review details below.", systemImage: "checkmark.circle").font(.caption).foregroundColor(.green) } TextEditor(text: Binding(get: { vm.importText }, set: { _ in })) .font(.system(.body, design: .monospaced)).frame(minHeight: 200).disabled(true) .overlay(RoundedRectangle(cornerRadius: 6).stroke(.quaternary)) if !vm.drafts.isEmpty { VStack(alignment: .leading, spacing: 6) { Text("Detected: \(vm.drafts.count) server(s)").font(.subheadline).fontWeight(.medium) ForEach(Array(vm.drafts.enumerated()), id: \.offset) { (_, draft) in VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { Image(systemName: draft.kind == .stdio ? "terminal" : (draft.kind == .sse ? "dot.radiowaves.left.and.right" : "globe")) Text(draft.name ?? "—").font(.subheadline).fontWeight(.medium) Spacer() } if let desc = draft.meta?.description { Text(desc).font(.caption).foregroundColor(.secondary) } } .padding(.vertical, 4) } } .controlSize(.small) } } } // Local drop handler (sheet scope) private func handleDropProviders(_ providers: [NSItemProvider]) -> Bool { var handled = false for provider in providers { if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { data, _ in guard let data = data, let url = data as? URL, let text = try? String(contentsOf: url, encoding: .utf8) else { return } handled = true DispatchQueue.main.async { vm.loadText(text) } } handled = true continue } if provider.hasItemConformingToTypeIdentifier(UTType.json.identifier) { provider.loadItem(forTypeIdentifier: UTType.json.identifier, options: nil) { data, _ in guard let text = readText(from: data) else { return } handled = true DispatchQueue.main.async { vm.loadText(text) } } handled = true continue } if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { data, _ in guard let text = readText(from: data) else { return } handled = true DispatchQueue.main.async { vm.loadText(text) } } handled = true continue } } return handled } private func handleImportProviders(_ providers: [NSItemProvider]) -> Bool { var handled = false for provider in providers { if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { data, _ in guard let text = readText(from: data) else { return } handled = true DispatchQueue.main.async { vm.loadText(text) } } handled = true continue } if provider.hasItemConformingToTypeIdentifier(UTType.json.identifier) { provider.loadItem(forTypeIdentifier: UTType.json.identifier, options: nil) { data, _ in guard let text = readText(from: data) else { return } handled = true DispatchQueue.main.async { vm.loadText(text) } } handled = true continue } if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { data, _ in guard let text = readText(from: data) else { return } handled = true DispatchQueue.main.async { vm.loadText(text) } } handled = true continue } if provider.canLoadObject(ofClass: String.self) { _ = provider.loadObject(ofClass: String.self) { string, _ in guard let string = string else { return } handled = true DispatchQueue.main.async { vm.loadText(string) } } handled = true } } return handled } private func readText(from representation: (any NSSecureCoding)?) -> String? { if let string = representation as? String { return string } if let url = representation as? URL { return try? String(contentsOf: url, encoding: .utf8) } if let data = representation as? Data { if let url = URL(dataRepresentation: data, relativeTo: nil) { return try? String(contentsOf: url, encoding: .utf8) } return String(data: data, encoding: .utf8) } return nil } } // MARK: - Unified Editor Sheet (JSON + Form) private struct MCPServerEditorSheet: View { @ObservedObject var vm: MCPServersViewModel @ObservedObject var preferences: SessionPreferencesStore var isEditing: Bool var onClose: () -> Void @State private var selectedTab: Int = 0 // 0=Form, 1=JSON @State private var isDropTargeted: Bool = false @State private var breathing: Bool = false @State private var wizardActive: Bool = false @FocusState private var focusedField: FocusField? private enum FocusField { case name } var body: some View { if wizardActive { MCPWizardSheet(preferences: preferences, onApply: { draft in applyDraft(draft) wizardActive = false }, onCancel: { wizardActive = false }) } else { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .firstTextBaseline) { Text(isEditing ? "Edit MCP Server" : "New MCP Server").font(.title3).fontWeight(.semibold) Spacer() Button { wizardActive = true } label: { Image(systemName: "sparkles") } .buttonStyle(.borderless) .help("AI Wizard") } if !isEditing { SettingsTabContent { importArea } } if #available(macOS 15.0, *) { TabView(selection: $selectedTab) { Tab("Form", systemImage: "slider.horizontal.3", value: 0) { SettingsTabContent { formTab } } Tab("JSON", systemImage: "doc.text", value: 1) { SettingsTabContent { jsonConfigTab } } } } else { TabView(selection: $selectedTab) { SettingsTabContent { formTab } .tabItem { Label("Form", systemImage: "slider.horizontal.3") } .tag(0) SettingsTabContent { jsonConfigTab } .tabItem { Label("JSON", systemImage: "doc.text") } .tag(1) } } if let msg = vm.testMessage, !msg.isEmpty { Text(msg) .font(.caption) .foregroundStyle(msg.hasPrefix("Connected") ? .green : .red) } HStack { if vm.testInProgress { Button("Stop") { vm.cancelTest() } .buttonStyle(.bordered) } else { Button("Test") { vm.startTest() } .buttonStyle(.bordered) } Spacer() Button("Cancel") { onClose() } Button(isEditing ? "Save" : "Create") { Task { if await vm.saveForm() { onClose() } } } .buttonStyle(.borderedProminent) .disabled(!vm.formCanSave()) } } .padding(16) .onAppear { if !isEditing { DispatchQueue.main.async { focusedField = .name } } } } } // MARK: - Import area (top, new-only) @ViewBuilder private var importArea: some View { VStack(alignment: .leading, spacing: 14) { ZStack { // No border/background VStack(spacing: 10) { Image(systemName: "target") .font(.system(size: 48)) .scaleEffect(breathing ? 1.08 : 1.0) .brightness(breathing ? 0.2 : -0.2) .foregroundStyle(breathing ? Color.accentColor.opacity(0.8) : Color.secondary) Text("Paste or drop JSON payloads to stage MCP servers; detected entries will autofill the form below.") .font(.caption) .foregroundStyle(breathing ? Color.accentColor.opacity(0.85) : Color.secondary) .multilineTextAlignment(.center) .scaleEffect(breathing ? 1.02 : 1.0) .brightness(breathing ? 0.2 : -0.2) Group { if vm.isParsing { HStack(spacing: 6) { ProgressView().controlSize(.small); Text("Parsing input…").font(.caption).foregroundStyle(.secondary) } } else if let err = vm.importError { Label(err, systemImage: "exclamationmark.triangle").font(.caption).foregroundStyle(.red) } else if !vm.drafts.isEmpty { Label("Detected \(vm.drafts.count) server(s)", systemImage: "checkmark.circle").font(.caption).foregroundStyle(.green) } } // paste/clear moved to context menu } .frame(maxWidth: .infinity) .frame(height: 140) } .contentShape(Rectangle()) .allowsHitTesting(true) .frame(maxWidth: .infinity, minHeight: 140) // Native NSView-based drop catcher for precise hover (drag-in) detection .overlay( DropCatcher( isTargeted: $isDropTargeted, onString: { vm.loadText($0) }, onURL: { url in if let text = try? String(contentsOf: url, encoding: .utf8) { vm.loadText(text) } } ) ) .contextMenu { Button("Paste JSON") { let pb = NSPasteboard.general if let s = pb.string(forType: .string), !s.isEmpty { vm.loadText(s) } } Button("Clean") { vm.clearImport() } } // SwiftUI drop as fallback (kept minimal to avoid conflicting hover state) .onDrop(of: [UTType.json, UTType.plainText, UTType.fileURL, UTType.text], isTargeted: .constant(false)) { providers in handleDropProviders(providers) } .onChange(of: isDropTargeted) { now in if now { withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { breathing = true } } else { withAnimation(.easeOut(duration: 0.2)) { breathing = false } } } .onChange(of: vm.isParsing) { parsing in // Stop breathing once parsing finishes (drop completed) if parsing == false { isDropTargeted = false withAnimation(.easeOut(duration: 0.2)) { breathing = false } } } .onChange(of: vm.drafts.count) { _ in // Any detected entries imply drop completed; stop highlight isDropTargeted = false withAnimation(.easeOut(duration: 0.2)) { breathing = false } } .onChange(of: vm.importError) { _ in // Error also ends the hover state; stop highlight isDropTargeted = false withAnimation(.easeOut(duration: 0.2)) { breathing = false } } } } // MARK: - Form Tab (primary) @ViewBuilder private var formTab: some View { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { GridRow { Text("Name").font(.subheadline).fontWeight(.medium) TextField("server-id", text: $vm.formName) .focused($focusedField, equals: .name) .frame(maxWidth: .infinity, alignment: .trailing) } GridRow { Text("Kind").font(.subheadline).fontWeight(.medium) Picker("", selection: $vm.formKind) { Text("stdio").tag(MCPServerKind.stdio) Text("sse").tag(MCPServerKind.sse) Text("streamable_http").tag(MCPServerKind.streamable_http) } .labelsHidden() .frame(maxWidth: .infinity, alignment: .trailing) } // Network endpoint (visible for non-stdio kinds) if vm.formKind != .stdio { GridRow { Text("URL").font(.subheadline).fontWeight(.medium); TextField("https://…", text: $vm.formURL).frame(maxWidth: .infinity, alignment: .trailing) } } // Process endpoint (visible for stdio) if vm.formKind == .stdio { GridRow { Text("Command").font(.subheadline).fontWeight(.medium); TextField("/usr/local/bin/mcp-server", text: $vm.formCommand).frame(maxWidth: .infinity, alignment: .trailing) } GridRow { Text("Args").font(.subheadline).fontWeight(.medium) TextEditor(text: $vm.formArgs) .font(.system(.caption, design: .monospaced)) .frame(height: 80) .frame(maxWidth: .infinity, alignment: .trailing) } } // Env (both kinds) GridRow { Text("Env").font(.subheadline).fontWeight(.medium) TextEditor(text: $vm.formEnvText) .font(.system(.caption, design: .monospaced)) .frame(height: 80) .frame(maxWidth: .infinity, alignment: .trailing) } // Headers (only for network kinds) if vm.formKind != .stdio { GridRow { Text("Headers").font(.subheadline).fontWeight(.medium) TextEditor(text: $vm.formHeadersText) .font(.system(.caption, design: .monospaced)) .frame(height: 80) .frame(maxWidth: .infinity, alignment: .trailing) } } if isEditing { GridRow { Text("Targets").font(.subheadline).fontWeight(.medium) HStack(spacing: 12) { Toggle("Codex", isOn: $vm.formTargetsCodex) .toggleStyle(.switch) .controlSize(.small) Toggle("Claude Code", isOn: $vm.formTargetsClaude) .toggleStyle(.switch) .controlSize(.small) Toggle("Gemini", isOn: $vm.formTargetsGemini) .toggleStyle(.switch) .controlSize(.small) } .frame(maxWidth: .infinity, alignment: .trailing) } } // Enabled is controlled in list view only } } // MARK: - JSON config Tab (preview) @ViewBuilder private var jsonConfigTab: some View { VStack(alignment: .leading, spacing: 8) { Text("Server JSON preview (read-only)").font(.caption).foregroundStyle(.secondary) ScrollView { Text(vm.formJSONPreview()) .font(.system(.body, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .topLeading) .padding(8) } .frame(minHeight: 220) .overlay(RoundedRectangle(cornerRadius: 6).stroke(.quaternary)) } } // Local drop handler for JSON tab private func handleDropProviders(_ providers: [NSItemProvider]) -> Bool { var handled = false for provider in providers { if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { data, _ in guard let data = data, let url = data as? URL, let text = try? String(contentsOf: url, encoding: .utf8) else { return } handled = true DispatchQueue.main.async { vm.loadText(text) } } handled = true continue } if provider.hasItemConformingToTypeIdentifier(UTType.json.identifier) { provider.loadItem(forTypeIdentifier: UTType.json.identifier, options: nil) { data, _ in guard let text = readText(from: data) else { return } handled = true DispatchQueue.main.async { vm.loadText(text) } } handled = true continue } if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { data, _ in guard let text = readText(from: data) else { return } handled = true DispatchQueue.main.async { vm.loadText(text) } } handled = true continue } } return handled } private func readText(from representation: (any NSSecureCoding)?) -> String? { if let string = representation as? String { return string } if let url = representation as? URL { return try? String(contentsOf: url, encoding: .utf8) } if let data = representation as? Data { if let url = URL(dataRepresentation: data, relativeTo: nil) { return try? String(contentsOf: url, encoding: .utf8) } return String(data: data, encoding: .utf8) } return nil } private func applyDraft(_ draft: MCPWizardDraft) { vm.formName = draft.name vm.formKind = draft.kind vm.formURL = draft.url ?? "" vm.formCommand = draft.command ?? "" vm.formArgs = (draft.args ?? []).joined(separator: "\n") vm.formEnvText = formatPairs(draft.env) vm.formHeadersText = formatPairs(draft.headers) if let targets = draft.targets { vm.formTargetsCodex = targets.codex vm.formTargetsClaude = targets.claude vm.formTargetsGemini = targets.gemini } } private func formatPairs(_ dict: [String: String]?) -> String { guard let dict, !dict.isEmpty else { return "" } return dict.keys.sorted().map { "\($0)=\(dict[$0]!)" }.joined(separator: "\n") } } // MARK: - NSViewRepresentable Drop Catcher private struct DropCatcher: NSViewRepresentable { @Binding var isTargeted: Bool var onString: (String) -> Void var onURL: (URL) -> Void func makeNSView(context: Context) -> NSView { let v = DropCatcherView() v.onString = onString v.onURL = onURL v.onHoverChange = { targeted in DispatchQueue.main.async { self.isTargeted = targeted } } return v } func updateNSView(_ nsView: NSView, context: Context) {} final class DropCatcherView: NSView { var onString: ((String) -> Void)? var onURL: ((URL) -> Void)? var onHoverChange: ((Bool) -> Void)? override init(frame frameRect: NSRect) { super.init(frame: frameRect) registerForDraggedTypes([ .fileURL, .URL, .string ]) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { onHoverChange?(true) return .copy } override func draggingExited(_ sender: NSDraggingInfo?) { onHoverChange?(false) } override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { onHoverChange?(false) let pb = sender.draggingPasteboard if let urls = pb.readObjects(forClasses: [NSURL.self], options: nil) as? [URL], let url = urls.first { onURL?(url); return true } if let str = pb.string(forType: .string), !str.isEmpty { onString?(str); return true } return false } } } ================================================ FILE: views/ModelListEditorSheet.swift ================================================ import SwiftUI struct ModelListEditorSheet: View { let title: String let description: String let availableModels: [String] let onSave: ([String]) -> Void let onReset: (() -> Void)? @State private var draft: [String] @Environment(\.dismiss) private var dismiss init( title: String, description: String, availableModels: [String], models: [String], onSave: @escaping ([String]) -> Void, onReset: (() -> Void)? = nil ) { self.title = title self.description = description self.availableModels = availableModels self.onSave = onSave self.onReset = onReset self._draft = State(initialValue: models) } var body: some View { VStack(alignment: .leading, spacing: 16) { Text(title).font(.title2).fontWeight(.semibold) Text(description) .font(.subheadline) .foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 8) { ForEach(draft.indices, id: \.self) { index in HStack(spacing: 8) { TextField("model-id", text: Binding( get: { draft[index] }, set: { draft[index] = $0 } )) Button(role: .destructive) { draft.remove(at: index) } label: { Image(systemName: "minus.circle") } .buttonStyle(.borderless) .help("Remove") } } if draft.isEmpty { Text("No models selected yet.") .font(.caption) .foregroundStyle(.secondary) } } .padding(10) .background(Color(nsColor: .separatorColor).opacity(0.35)) .cornerRadius(10) HStack(spacing: 8) { Menu { if !availableModels.isEmpty { Section("Available") { ForEach(availableModels, id: \.self) { model in Button(model) { draft.append(model) } .disabled(draft.contains(model)) } } Divider() } Button("Custom…") { draft.append("") } } label: { Label("Add", systemImage: "plus") } if let onReset { Button("Reset to Auto") { onReset() dismiss() } } Spacer() Button("Cancel", role: .cancel) { dismiss() } Button("Save") { onSave(Self.sanitize(draft)) dismiss() } .buttonStyle(.borderedProminent) } } .padding(16) .frame(minWidth: 520) } private static func sanitize(_ list: [String]) -> [String] { var seen = Set() var out: [String] = [] for item in list { let trimmed = item.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { continue } if seen.insert(trimmed).inserted { out.append(trimmed) } } return out } } ================================================ FILE: views/NewTaskSheet.swift ================================================ import SwiftUI struct NewTaskSheet: View { @Environment(\.dismiss) var dismiss @ObservedObject var viewModel: SessionListViewModel @State private var title: String = "" @State private var description: String = "" @State private var selectedType: TaskType = .other @State private var selectedProvider: ProjectSessionSource = .codex @State private var selectedProjectId: String = "" @State private var isCreating: Bool = false var body: some View { Form { Section("Task Details") { TextField("Task Title", text: $title, prompt: Text("Enter task title")) .textFieldStyle(.roundedBorder) TextEditor(text: $description) .frame(minHeight: 80) .overlay(alignment: .topLeading) { if description.isEmpty { Text("Enter task description (optional)") .foregroundColor(.secondary) .padding(.leading, 5) .padding(.top, 8) .allowsHitTesting(false) } } .overlay( RoundedRectangle(cornerRadius: 6) .stroke(Color.gray.opacity(0.2), lineWidth: 1) ) } Section("Task Type") { Picker("Type", selection: $selectedType) { ForEach(TaskType.allCases) { type in Label { Text(type.displayName) } icon: { Image(systemName: type.icon) } .tag(type) } } .pickerStyle(.menu) Text(selectedType.descriptionTemplate) .font(.caption) .foregroundColor(.secondary) } Section("Provider") { Picker("Default Provider", selection: $selectedProvider) { ForEach(ProjectSessionSource.allCases.filter { viewModel.preferences.isCLIEnabled($0.baseKind) }) { provider in Text(provider.displayName) .tag(provider) } } .pickerStyle(.segmented) } Section("Project") { Picker("Project", selection: $selectedProjectId) { Text("None").tag("") ForEach(viewModel.projects) { project in Text(project.name).tag(project.id) } } .pickerStyle(.menu) } } .formStyle(.grouped) .frame(width: 500, height: 480) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("Create") { createTask() } .disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isCreating) } } .onAppear { let enabled = ProjectSessionSource.allCases.filter { viewModel.preferences.isCLIEnabled($0.baseKind) } if !enabled.contains(selectedProvider), let first = enabled.first { selectedProvider = first } // Set default project if one is selected if let firstSelected = viewModel.selectedProjectIDs.first { selectedProjectId = firstSelected } else if selectedProjectId.isEmpty, let firstProject = viewModel.projects.first { selectedProjectId = firstProject.id } } } private func createTask() { let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedTitle.isEmpty else { return } isCreating = true let task = CodMateTask( title: trimmedTitle, description: description.trimmingCharacters(in: .whitespacesAndNewlines), taskType: selectedType, projectId: selectedProjectId.isEmpty ? "none" : selectedProjectId, status: .pending, primaryProvider: selectedProvider ) Task { await viewModel.createTask(task) await MainActor.run { dismiss() } } } } #Preview { NewTaskSheet( viewModel: SessionListViewModel( preferences: SessionPreferencesStore() ) ) } ================================================ FILE: views/OverviewActivityChart.swift ================================================ import Charts import SwiftUI struct OverviewActivityChart: View { let data: ActivityChartData let enabledSources: Set var onSelectDate: ((Date) -> Void)? @State private var selectedMetric: Metric = .count @State private var hiddenSources: Set = [] @State private var hoverDate: Date? @State private var hoverLocation: CGPoint = .zero @AppStorage("overviewChartBarWidth") private var barWidth: Double = 32.0 @State private var isHoveringZoomControls = false @State private var isHoveringChartArea = false @State private var hoverExitTask: Task? = nil private let minBarWidth: CGFloat = 16.0 private let maxBarWidth: CGFloat = 64.0 private let chartCoordinateSpaceName = "OverviewActivityChart" enum Metric: String, CaseIterable, Identifiable { case count = "Sessions" case duration = "Duration" case tokens = "Tokens" var id: String { rawValue } } // All available sources for the legend private let allSources: [SessionSource.Kind] = [.codex, .claude, .gemini] init( data: ActivityChartData, enabledSources: Set, onSelectDate: ((Date) -> Void)? = nil ) { self.data = data self.enabledSources = enabledSources self.onSelectDate = onSelectDate let disabledSources = Set(allSources).subtracting(enabledSources) _hiddenSources = State(initialValue: disabledSources) } var body: some View { VStack(alignment: .leading, spacing: 12) { headerView if data.points.isEmpty { emptyStateView } else { chartContainer } } .zIndex(1) .onChange(of: enabledSourcesHash) { _ in let disabledSources = Set(allSources).subtracting(enabledSources) hiddenSources.formUnion(disabledSources) } } // MARK: - Header private var headerView: some View { HStack { // Left: Metric Picker + Zoom HStack(spacing: 8) { Picker("Metric", selection: $selectedMetric) { ForEach(Metric.allCases) { metric in Text(metric.rawValue).tag(metric) } } .pickerStyle(.segmented) .labelsHidden() .fixedSize() .controlSize(.small) // Zoom Controls HStack(spacing: 0) { Button { withAnimation(.spring(response: 0.3)) { barWidth = max(Double(minBarWidth), barWidth - 8) } } label: { Image(systemName: "minus") .frame(width: 16, height: 16) } .disabled(barWidth <= Double(minBarWidth)) .buttonStyle(.plain) .contentShape(Rectangle()) Button { withAnimation(.spring(response: 0.3)) { barWidth = min(Double(maxBarWidth), barWidth + 8) } } label: { Image(systemName: "plus") .frame(width: 16, height: 16) } .disabled(barWidth >= Double(maxBarWidth)) .buttonStyle(.plain) .contentShape(Rectangle()) } .background(Color.primary.opacity(isHoveringZoomControls ? 0.15 : 0.05)) .cornerRadius(4) .opacity(zoomControlsOpacity) .onHover { isHovering in withAnimation(.easeInOut(duration: 0.2)) { isHoveringZoomControls = isHovering } } } Spacer() // Right: Legend HStack(spacing: 12) { ForEach(legendSources, id: \.self) { source in HStack(spacing: 4) { Circle() .fill(color(for: source)) .frame(width: 8, height: 8) .opacity(hiddenSources.contains(source) ? 0.3 : 1.0) Text(source.rawValue.capitalized) .font(.caption) .foregroundStyle(hiddenSources.contains(source) ? .secondary : .primary) } .onTapGesture { withAnimation(.easeInOut(duration: 0.2)) { if hiddenSources.contains(source) { hiddenSources.remove(source) } else { hiddenSources.insert(source) } } } .contentShape(Rectangle()) // Improve tap area .help("Toggle \(source.rawValue.capitalized)") } } } } // MARK: - Chart private var chartContainer: some View { GeometryReader { geometry in let stepWidth: CGFloat = CGFloat(barWidth) let uniqueDates = Set(data.points.map { $0.date }).sorted() // uniqueXCount is no longer used for width calculation // let uniqueXCount = uniqueDates.count // requiredWidth is calculated later based on time span let chartAreaWidth = geometry.size.width // Full width now // Determine Y-axis domain (Value) - Filtered let maxVal = maxYValue(for: uniqueDates) let yScale = 0...maxVal // Determine X-axis domain (Time) - FULL (Unfiltered) // This ensures the axis doesn't shrink if data points are hidden // We use the min/max of the actual data available in this view model snapshot let minDate = uniqueDates.first ?? Date() let maxDate = uniqueDates.last ?? Date() // Adjust domain to center bars (add 0.5 unit padding on each side) let calendar = Calendar.current let (adjMin, adjMax): (Date, Date) = { if data.unit == .day { let min = calendar.date(byAdding: .hour, value: -12, to: minDate) ?? minDate let max = calendar.date(byAdding: .hour, value: 12, to: maxDate) ?? maxDate return (min, max) } else { let min = calendar.date(byAdding: .minute, value: -30, to: minDate) ?? minDate let max = calendar.date(byAdding: .minute, value: 30, to: maxDate) ?? maxDate return (min, max) } }() let xDomain = adjMin...adjMax // Calculate required width based on TIME SPAN, not point count let component: Calendar.Component = data.unit == .day ? .day : .hour let diff = calendar.dateComponents([component], from: minDate, to: maxDate).value( for: component) ?? 0 let totalSlots = max(1, diff + 1) // Inclusive count let requiredWidth = CGFloat(totalSlots) * stepWidth // Scrollable Chart Area ZStack(alignment: .topLeading) { scrollContainer { ZStack { HStack(spacing: 0) { if requiredWidth < chartAreaWidth { Spacer(minLength: 0) } chartContent(yScale: yScale, xDomain: xDomain) .frame(width: requiredWidth, height: 160) .chartOverlay { proxy in GeometryReader { geo in Rectangle().fill(.clear).contentShape(Rectangle()) .onContinuousHover { phase in switch phase { case .active(let location): hoverExitTask?.cancel() hoverExitTask = nil if !isHoveringChartArea { withAnimation(.easeInOut(duration: 0.2)) { isHoveringChartArea = true } } let chartFrame = geo.frame( in: .named(chartCoordinateSpaceName)) hoverLocation = CGPoint( x: chartFrame.minX + location.x, y: chartFrame.minY + location.y ) // Convert location to X value (Date) if let date = proxy.value( atX: location.x, as: Date.self) { // Snap to closest bin hoverDate = snapDate( date, dates: uniqueDates) } case .ended: hoverDate = nil hoverExitTask?.cancel() hoverExitTask = Task { try? await Task.sleep( nanoseconds: 250_000_000) await MainActor.run { withAnimation(.easeInOut(duration: 0.2)) { isHoveringChartArea = false } } } } } .onTapGesture(count: 1, coordinateSpace: .local) { location in if let date = proxy.value( atX: location.x, as: Date.self), let snapped = snapDate(date, dates: uniqueDates) { onSelectDate?(snapped) } } } } } .frame(minWidth: chartAreaWidth, alignment: .trailing) } } if let hoverDate, let points = pointsByDate[hoverDate] { tooltip(for: hoverDate, points: points, in: geoSize(from: chartAreaWidth)) .zIndex(10) } } } .frame(height: 160) .coordinateSpace(name: chartCoordinateSpaceName) } @ViewBuilder private var emptyStateView: some View { ZStack { if #available(macOS 14.0, *) { ContentUnavailableView { Label("No Activity", systemImage: "chart.bar") } description: { Text("No sessions found in this time range.") } } else { UnavailableStateView( "No Activity", systemImage: "chart.bar", description: "No sessions found in this time range.", titleFont: .callout ) } } .frame(maxWidth: .infinity, minHeight: 160, maxHeight: 160, alignment: .center) } @ViewBuilder private func scrollContainer(@ViewBuilder content: () -> Content) -> some View { if #available(macOS 14.0, *) { ScrollView(.horizontal, showsIndicators: true) { content() } .defaultScrollAnchor(.trailing) } else { ScrollView(.horizontal, showsIndicators: true) { content() } } } private func geoSize(from width: CGFloat) -> CGSize { CGSize(width: width, height: 160) } private func chartContent(yScale: ClosedRange, xDomain: ClosedRange) -> some View { let labelWidth = CGFloat(barWidth) return Chart { // Baseline axis line RuleMark(y: .value("Baseline", 0)) .foregroundStyle(Color.secondary.opacity(0.5)) .lineStyle(StrokeStyle(lineWidth: 1)) ForEach(visiblePoints) { point in BarMark( // Use the actual bucket start to align bar centers with axis ticks. x: .value("Date", point.date), y: .value("Value", value(for: point)), width: .fixed(barWidth * 0.8) // Use a ratio of the stepWidth for the bar itself ) .foregroundStyle(by: .value("Source", point.source.rawValue.capitalized)) } } .chartLegend(.hidden) .chartXScale(domain: xDomain) // FIX: Lock X-axis domain .chartXAxis { AxisMarks(values: .stride(by: data.unit == .day ? .day : .hour)) { value in if let date = value.as(Date.self) { // Custom Month Separator logic if data.unit == .day, isFirstDayOfMonth(date) { AxisTick(length: 5, stroke: StrokeStyle(lineWidth: 1.5)) .foregroundStyle(.primary) AxisValueLabel { Text(date.formatted(.dateTime.month(.abbreviated))) .font(.system(size: 10, weight: .bold)) .frame(width: labelWidth, alignment: .center) .offset(x: -labelWidth / 2) .zIndex(1) } } else { AxisGridLine() .foregroundStyle(.clear) // Hide regular grid lines AxisTick(length: 3, stroke: StrokeStyle(lineWidth: 1)) AxisValueLabel { Text( date.formatted( data.unit == .day ? .dateTime.day() : .dateTime.hour() ) ) .font(.caption2) .foregroundStyle(.secondary) .frame(width: labelWidth, alignment: .center) .offset(x: -labelWidth / 2) .zIndex(1) } } } } } .chartYAxis(.hidden) // Hide internal Y axis .chartYScale(domain: yScale) .chartForegroundStyleScale([ "Codex": color(for: .codex), "Claude": color(for: .claude), "Gemini": color(for: .gemini), ]) } // MARK: - Tooltip @ViewBuilder private func tooltip(for date: Date, points: [ActivityChartDataPoint], in containerSize: CGSize) -> some View { // Filter points based on hidden sources let visible = points.filter { !hiddenSources.contains($0.source) } if !visible.isEmpty { let total = visible.reduce(0) { $0 + value(for: $1) } let dateString = data.unit == .day ? date.formatted(date: .abbreviated, time: .omitted) : date.formatted(date: .omitted, time: .shortened) let tooltipWidth: CGFloat = 140 // Estimate height based on items + padding let tooltipHeight: CGFloat = CGFloat(40 + (visible.count * 15) + 20) // Always keep the tooltip above the hover point. let finalY = hoverLocation.y - (tooltipHeight / 2) - 16 // Determine X Position (Clamp to edges) let halfWidth = tooltipWidth / 2 let rawX = hoverLocation.x let finalX = max(halfWidth, min(rawX, containerSize.width - halfWidth)) VStack(alignment: .leading, spacing: 6) { Text(dateString) .font(.caption).bold() .padding(.bottom, 2) ForEach(visible.sorted { value(for: $0) > value(for: $1) }) { point in HStack { Circle().fill(color(for: point.source)).frame(width: 6, height: 6) Text(point.source.rawValue.capitalized).font(.caption2) Spacer() Text(formatValue(value(for: point))) .font(.caption2.monospacedDigit()) } } Divider() HStack { Text("Total").font(.caption2).bold() Spacer() Text(formatValue(total)) .font(.caption2.monospacedDigit()).bold() } } .padding(8) .background(.regularMaterial) .cornerRadius(8) .shadow(radius: 4) .frame(width: tooltipWidth) .position(x: finalX, y: finalY) .allowsHitTesting(false) } } // MARK: - Helpers private var legendSources: [SessionSource.Kind] { allSources.filter { enabledSources.contains($0) } } private var enabledSourcesHash: Int { var hasher = Hasher() enabledSources.sorted { $0.rawValue < $1.rawValue }.forEach { hasher.combine($0.rawValue) } return hasher.finalize() } private var visiblePoints: [ActivityChartDataPoint] { data.points.filter { !hiddenSources.contains($0.source) } } private var pointsByDate: [Date: [ActivityChartDataPoint]] { Dictionary(grouping: data.points, by: { $0.date }) } private func maxYValue(for dates: [Date]) -> Double { var maxVal: Double = 0 let grouped = pointsByDate for date in dates { let points = grouped[date] ?? [] let filtered = points.filter { !hiddenSources.contains($0.source) } let sum = filtered.reduce(0) { $0 + value(for: $1) } if sum > maxVal { maxVal = sum } } // Add some headroom return maxVal == 0 ? 10 : maxVal * 1.1 } private func value(for point: ActivityChartDataPoint) -> Double { switch selectedMetric { case .count: return Double(point.sessionCount) case .duration: return point.duration / 3600 // Hours case .tokens: return Double(point.totalTokens) } } private func formatValue(_ val: Double) -> String { switch selectedMetric { case .count: return String(Int(val)) case .duration: return String(format: "%.1fh", val) case .tokens: return "\(TokenFormatter.short(Int(val.rounded())))" } } private func color(for source: SessionSource.Kind) -> Color { switch source { case .codex: return .purple case .claude: return .orange case .gemini: return .blue } } private func isFirstDayOfMonth(_ date: Date) -> Bool { let calendar = Calendar.current let day = calendar.component(.day, from: date) return day == 1 } private func snapDate(_ target: Date, dates: [Date]) -> Date? { // Find closest date in the dataset // Since bars are discrete, finding the date with min distance is enough guard !dates.isEmpty else { return nil } // Optimization: since sorted, binary search or linear scan if small return dates.min(by: { abs($0.timeIntervalSince(target)) < abs($1.timeIntervalSince(target)) }) } private var zoomControlsOpacity: Double { guard isHoveringChartArea || isHoveringZoomControls else { return 0 } return isHoveringZoomControls ? 1.0 : 0.85 } } ================================================ FILE: views/OverviewCard.swift ================================================ import SwiftUI struct OverviewCard: View { private let content: Content init(@ViewBuilder content: () -> Content) { self.content = content() } var body: some View { content .frame(maxWidth: .infinity, alignment: .leading) .padding(16) .background( RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(Color(nsColor: .controlBackgroundColor)) ) .overlay( RoundedRectangle(cornerRadius: 14, style: .continuous) .stroke(Color.primary.opacity(0.07), lineWidth: 1) ) } } ================================================ FILE: views/PathTreeView.swift ================================================ import SwiftUI struct PathTreeView: View { let root: PathTreeNode? @Binding var selectedPath: String? var body: some View { if let root, let children = root.children, !children.isEmpty { List(selection: $selectedPath) { OutlineGroup(children, children: \.children) { node in PathTreeRowView(node: node) .tag(node.id) .listRowInsets(EdgeInsets()) // Let internal padding manage spacing } } .listStyle(.sidebar) .environment(\.defaultMinListRowHeight, 16) .environment(\.controlSize, .small) } else { if #available(macOS 14.0, *) { ContentUnavailableView("No Directories", systemImage: "folder") } else { UnavailableStateView( "No Directories", systemImage: "folder", titleFont: .callout ) .frame(maxWidth: .infinity, maxHeight: .infinity) } } } } private struct PathTreeRowView: View, Equatable { let node: PathTreeNode static func == (lhs: PathTreeRowView, rhs: PathTreeRowView) -> Bool { lhs.node == rhs.node } var body: some View { HStack(spacing: 8) { Image(systemName: "folder") .foregroundStyle(.secondary) .font(.caption) Text(node.name.isEmpty ? "/" : node.name) .font(.caption) .lineLimit(1) Spacer(minLength: 4) if node.count > 0 { Text("\(node.count)") .font(.caption2.monospacedDigit()) .foregroundStyle(.tertiary) } } .frame(height: 16) .padding(.vertical, 8) // Match All Sessions row vertical padding .padding(.trailing, 8) // Ensure right padding consistent; no extra leading padding .contentShape(Rectangle()) } } #Preview { // Mock path tree data let mockRoot = PathTreeNode( id: "/Users/developer", name: "developer", count: 15, children: [ PathTreeNode( id: "/Users/developer/projects", name: "projects", count: 8, children: [ PathTreeNode( id: "/Users/developer/projects/codmate", name: "codmate", count: 3, children: nil), PathTreeNode( id: "/Users/developer/projects/other", name: "other", count: 5, children: nil), ] ), PathTreeNode( id: "/Users/developer/documents", name: "documents", count: 4, children: [ PathTreeNode( id: "/Users/developer/documents/notes", name: "notes", count: 2, children: nil), PathTreeNode( id: "/Users/developer/documents/reports", name: "reports", count: 2, children: nil), ] ), PathTreeNode(id: "/Users/developer/desktop", name: "desktop", count: 3, children: nil), ] ) return PathTreeView(root: mockRoot, selectedPath: .constant(nil)) .frame(width: 250, height: 300) .padding() } #Preview("Empty State") { PathTreeView(root: nil, selectedPath: .constant(nil)) .frame(width: 250, height: 200) .padding() } ================================================ FILE: views/ProjectAgentsView.swift ================================================ import SwiftUI struct ProjectAgentsView: View { let projectDirectory: String let preferences: SessionPreferencesStore var refreshToken: Int = 0 @State private var markdownContent: String = "" @State private var isLoading: Bool = true @State private var errorMessage: String? @State private var viewMode: ViewMode = .preview enum ViewMode { case code case preview } // Markdown content should always wrap for readability private var wrapText: Bool { preferences.gitWrapText } private var showLineNumbers: Bool { preferences.gitShowLineNumbers } var body: some View { VStack(spacing: 0) { // Header with mode switcher HStack(spacing: 12) { Image(systemName: "book.pages") .foregroundStyle(.secondary) Text("Agents.md") .font(.headline) Spacer() // Mode switcher - Preview first, Code second Picker("", selection: $viewMode) { Text("Preview").tag(ViewMode.preview) Text("Code").tag(ViewMode.code) } .pickerStyle(.segmented) .frame(width: 140) .controlSize(.small) .labelsHidden() } .padding(.horizontal, 16) .padding(.vertical, 12) .padding(.trailing, 0) // Align segment with window edge like other workspace modes Divider() // Content area if isLoading { VStack(spacing: 12) { ProgressView() Text("Loading Agents.md...") .font(.subheadline) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let error = errorMessage { VStack(spacing: 12) { Image(systemName: "exclamationmark.triangle") .font(.system(size: 32)) .foregroundStyle(.orange) Text(error) .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() } else { contentView } } .onAppear { loadAgentsFile() } .onChange(of: projectDirectory) { _ in loadAgentsFile() } .onChange(of: refreshToken) { _ in loadAgentsFile() } } @ViewBuilder private var contentView: some View { switch viewMode { case .code: codeView case .preview: previewView } } private var codeView: some View { detailContainer { AttributedTextView( text: markdownContent.isEmpty ? "No content" : markdownContent, isDiff: false, wrap: true, // Markdown should always wrap for readability showLineNumbers: showLineNumbers, fontSize: 12 ) .frame(maxWidth: .infinity, maxHeight: .infinity) } .id("agents-code:\(projectDirectory)|wrap:1|ln:\(showLineNumbers ? 1 : 0)") } private func detailContainer(@ViewBuilder content: () -> Content) -> some View { content() .frame(maxWidth: .infinity, maxHeight: .infinity) .background( RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(Color(nsColor: .textBackgroundColor)) .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.15))) ) .padding(16) } private var previewView: some View { detailContainer { ScrollView { // Use AttributedString for better Markdown rendering if let attributed = try? AttributedString(markdown: markdownContent, options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)) { Text(attributed) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding() } else { // Fallback to basic Text rendering Text(.init(markdownContent)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding() } } } } private func loadAgentsFile() { isLoading = true errorMessage = nil let agentsURL = URL(fileURLWithPath: projectDirectory).appendingPathComponent("Agents.md") // Check if file exists guard FileManager.default.fileExists(atPath: agentsURL.path) else { isLoading = false errorMessage = "Agents.md not found in project directory.\n\nCreate an Agents.md file in your project root to define guidelines, conventions, and context for AI assistants." markdownContent = "" return } // Read file content do { let content = try String(contentsOf: agentsURL, encoding: .utf8) markdownContent = content isLoading = false } catch { isLoading = false errorMessage = "Failed to read Agents.md:\n\(error.localizedDescription)" markdownContent = "" } } } ================================================ FILE: views/ProjectOverviewView.swift ================================================ import SwiftUI struct ProjectOverviewView: View { @ObservedObject var viewModel: ProjectOverviewViewModel var project: Project var preferences: SessionPreferencesStore var onSelectSession: (SessionSummary) -> Void var onResumeSession: (SessionSummary) -> Void // Keeping this for consistency, though not used in ProjectOverviewViewModel directly var onFocusToday: () -> Void // Keeping this for consistency, though not used in ProjectOverviewViewModel directly var onSelectDate: (Date) -> Void var onEditProject: (Project) -> Void private func columns(for width: CGFloat) -> [GridItem] { let minWidth: CGFloat = 220 let spacing: CGFloat = 16 let availableWidth = width - 48 // 24 horizontal padding * 2 let count = max(1, Int((availableWidth + spacing) / (minWidth + spacing))) // Cap at 4 columns to match the max number of items per section (4) var targetCount = min(4, count) // Optimization: Avoid 3 columns for 4-item grids to prevent "3 on top, 1 on bottom" layout. // Since we mostly have sets of 4 items (Hero, Projects), a 2x2 grid looks better than 3+1. if targetCount == 3 { targetCount = 2 } return Array(repeating: GridItem(.flexible(), spacing: spacing), count: targetCount) } var body: some View { GeometryReader { geometry in let cols = columns(for: geometry.size.width) ScrollView { VStack(alignment: .leading, spacing: 20) { headerSection if shouldShowChartPlaceholder { OverviewChartPlaceholder() } else { OverviewActivityChart( data: snapshot.activityChartData, enabledSources: enabledSources, onSelectDate: onSelectDate ) } heroSection(columns: cols) efficiencySection(columns: cols) recentSection } .padding(.horizontal, 24) .padding(.vertical, 24) .frame(maxWidth: .infinity, alignment: .center) } } } private var snapshot: ProjectOverviewSnapshot { viewModel.snapshot } private var enabledSources: Set { Set([ preferences.isCLIEnabled(.codex) ? SessionSource.Kind.codex : nil, preferences.isCLIEnabled(.claude) ? SessionSource.Kind.claude : nil, preferences.isCLIEnabled(.gemini) ? SessionSource.Kind.gemini : nil, ].compactMap { $0 }) } private var shouldShowChartPlaceholder: Bool { viewModel.isLoading && snapshot.activityChartData.points.isEmpty } private var headerSection: some View { VStack(alignment: .leading, spacing: 6) { HStack(alignment: .center, spacing: 8) { Text(projectDisplayName) .font(.largeTitle.weight(.semibold)) .lineLimit(1) .truncationMode(.tail) Spacer() if canEditProject { Button { onEditProject(project) } label: { Image(systemName: "gearshape") .imageScale(.medium) } .buttonStyle(.plain) .accessibilityLabel("Edit Project") .help("Edit Project") } } Text("Updated \(snapshot.lastUpdated.formatted(date: .abbreviated, time: .shortened))") .font(.caption) .foregroundStyle(.secondary) } } private func heroSection(columns: [GridItem]) -> some View { VStack(alignment: .leading, spacing: 16) { LazyVGrid(columns: columns, spacing: 16) { heroMetric( title: "Sessions", value: snapshot.totalSessions.formatted(), detail: "In selected range" ) heroMetric( title: "Messages", value: (snapshot.userMessages + snapshot.assistantMessages).formatted(), detail: "\(snapshot.userMessages) user · \(snapshot.assistantMessages) assistant" ) heroMetric( title: "Active Time", value: Self.durationFormatter.string(from: snapshot.totalDuration) ?? "—", detail: "Tokens \(TokenFormatter.short(snapshot.totalTokens))" ) heroMetric( title: "Tool Invocations", value: snapshot.totalToolInvocations.formatted(), detail: "Tools used" ) } .frame(maxWidth: .infinity, alignment: .leading) } } private var projectDisplayName: String { let trimmed = project.name.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? "Untitled Project" : trimmed } private var projectOverviewLine: String { if let overview = project.overview?.trimmingCharacters(in: .whitespacesAndNewlines), !overview.isEmpty { return overview } return "Project Overview" } private var canEditProject: Bool { project.id != SessionListViewModel.otherProjectId } private func heroMetric(title: String, value: String, detail: String) -> some View { OverviewCard { VStack(alignment: .leading, spacing: 6) { Text(title).font(.subheadline).foregroundStyle(.secondary) Text(value).font(.title2.monospacedDigit()).fontWeight(.semibold) Text(detail).font(.caption).foregroundStyle(.secondary) } } } @ViewBuilder private func efficiencySection(columns: [GridItem]) -> some View { if !snapshot.sourceStats.isEmpty { VStack(alignment: .leading, spacing: 10) { LazyVGrid(columns: columns, spacing: 16) { ForEach(snapshot.sourceStats) { stat in OverviewCard { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .firstTextBaseline) { Text(stat.displayName).font(.headline) Spacer() Text("\(stat.sessionCount) sessions") .font(.caption) .foregroundStyle(.secondary) } VStack(alignment: .leading, spacing: 4) { Label { Text("Total \(TokenFormatter.short(stat.totalTokens)) tokens") } icon: { Image(systemName: "text.quote") } .font(.caption) .foregroundStyle(.secondary) Label { Text("Avg \(Self.durationFormatter.string(from: stat.avgDuration) ?? "—")") } icon: { Image(systemName: "clock") } .font(.caption) .foregroundStyle(.secondary) } .padding(.top, 4) } } } } .frame(maxWidth: .infinity, alignment: .leading) } } } @ViewBuilder private var recentSection: some View { RecentSessionsListView( sessions: snapshot.recentSessions, emptyMessage: "No sessions in this project for the selected range.", onSelectSession: onSelectSession ) } private static let durationFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() formatter.allowedUnits = [.hour, .minute] formatter.unitsStyle = .abbreviated formatter.zeroFormattingBehavior = .dropLeading return formatter }() } private struct OverviewChartPlaceholder: View { var body: some View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .center, spacing: 8) { RoundedRectangle(cornerRadius: 4) .fill(Color.secondary.opacity(0.2)) .frame(width: 140, height: 18) Spacer() HStack(spacing: 10) { ForEach(0..<3, id: \.self) { _ in HStack(spacing: 4) { Circle() .fill(Color.secondary.opacity(0.2)) .frame(width: 8, height: 8) RoundedRectangle(cornerRadius: 3) .fill(Color.secondary.opacity(0.2)) .frame(width: 36, height: 8) } } } } RoundedRectangle(cornerRadius: 12) .fill(Color.secondary.opacity(0.08)) .frame(height: 160) } .redacted(reason: .placeholder) } } ================================================ FILE: views/ProjectSpecificOverviewContainerView.swift ================================================ import SwiftUI struct ProjectSpecificOverviewContainerView: View { @ObservedObject var sessionListViewModel: SessionListViewModel var project: Project var preferences: SessionPreferencesStore var refreshToken: Int = 0 var onSelectSession: (SessionSummary) -> Void var onResumeSession: (SessionSummary) -> Void var onFocusToday: () -> Void var onEditProject: (Project) -> Void @StateObject private var projectOverviewViewModel: ProjectOverviewViewModel init( sessionListViewModel: SessionListViewModel, project: Project, preferences: SessionPreferencesStore, refreshToken: Int = 0, onSelectSession: @escaping (SessionSummary) -> Void, onResumeSession: @escaping (SessionSummary) -> Void, onFocusToday: @escaping () -> Void, onEditProject: @escaping (Project) -> Void ) { self.sessionListViewModel = sessionListViewModel self.project = project self.preferences = preferences self.refreshToken = refreshToken self.onSelectSession = onSelectSession self.onResumeSession = onResumeSession self.onFocusToday = onFocusToday self.onEditProject = onEditProject _projectOverviewViewModel = StateObject(wrappedValue: ProjectOverviewViewModel(sessionListViewModel: sessionListViewModel, project: project)) } var body: some View { ProjectOverviewView( viewModel: projectOverviewViewModel, project: project, preferences: preferences, onSelectSession: onSelectSession, onResumeSession: onResumeSession, onFocusToday: onFocusToday, onSelectDate: { date in sessionListViewModel.setSelectedDay(date) }, onEditProject: onEditProject ) // Update the project in the ViewModel if it changes from outside .onChange(of: project) { newProject in projectOverviewViewModel.updateProject(newProject) } .onChange(of: refreshToken) { _ in projectOverviewViewModel.forceRefresh() } } } ================================================ FILE: views/ProjectsListView.swift ================================================ import AppKit import SwiftUI import UniformTypeIdentifiers struct ProjectsListView: View { @EnvironmentObject private var viewModel: SessionListViewModel let onEditProject: (Project) -> Void @State private var showNewProject = false @State private var newParentProject: Project? = nil @State private var pendingDelete: Project? = nil @State private var showDeleteConfirm = false @State private var draftTaskForNew: CodMateTask? = nil @State private var showAutoAssignSheet = false var body: some View { let countsDisplay = viewModel.projectCountsDisplay() let tree = buildProjectTree(viewModel.projects) let selectionBinding: Binding> = Binding( get: { viewModel.selectedProjectIDs }, set: { viewModel.setSelectedProjects($0) } ) let expandedBinding: Binding> = Binding( get: { viewModel.expandedProjectIDs }, set: { viewModel.expandedProjectIDs = $0 } ) return makeListView( tree: tree, countsDisplay: countsDisplay, selectionBinding: selectionBinding, expandedBinding: expandedBinding ) } private func makeListView( tree: [ProjectTreeNode], countsDisplay: [String: (visible: Int, total: Int)], selectionBinding: Binding>, expandedBinding: Binding> ) -> some View { List(selection: selectionBinding) { if tree.isEmpty { if #available(macOS 14.0, *) { ContentUnavailableView("No Projects", systemImage: "square.grid.2x2") } else { UnavailableStateView( "No Projects", systemImage: "square.grid.2x2", titleFont: .callout ) .frame(maxWidth: .infinity, maxHeight: .infinity) } } else { ForEach(tree) { node in makeProjectTreeNodeView( node: node, countsDisplay: countsDisplay, expandedBinding: expandedBinding ) } makeOtherProjectRow(countsDisplay: countsDisplay) } } .listStyle(.sidebar) .padding(.horizontal, -10) .environment(\.defaultMinListRowHeight, 16) .environment(\.controlSize, .small) .contextMenu { Button { newParentProject = nil showNewProject = true } label: { Label("New Project…", systemImage: "square.grid.2x2") } if viewModel.selectedProjectIDs.contains(SessionListViewModel.otherProjectId) { Button { showAutoAssignSheet = true } label: { Label("Auto assign to ...", systemImage: "wand.and.stars") } } } .dropDestination(for: String.self) { items, _ in // Handle drop on list background (outside any project row) // This removes the parent from the dragged project (moves to top level) let all = items.flatMap { $0.split(separator: "\n").map(String.init) } let projectDrags = all.filter { $0.hasPrefix("project:") } if let firstProjectDrag = projectDrags.first { let draggedProjectId = String(firstProjectDrag.dropFirst("project:".count)) // Don't allow dragging Other project guard draggedProjectId != SessionListViewModel.otherProjectId else { return false } Task { await viewModel.changeProjectParent(projectId: draggedProjectId, newParentId: nil) } return true } return false } .onAppear { if viewModel.expandedProjectIDs.isEmpty { viewModel.expandedProjectIDs = Set(tree.map(\.id)) } } .onReceive(NotificationCenter.default.publisher(for: .codMateExpandProjectTree)) { note in if let ids = note.userInfo?["ids"] as? [String] { var merged = viewModel.expandedProjectIDs merged.formUnion(ids) viewModel.expandedProjectIDs = merged } } .sheet(isPresented: $showNewProject, onDismiss: { newParentProject = nil }) { ProjectEditorSheet( isPresented: $showNewProject, mode: .new, prefill: ProjectEditorSheet.Prefill( name: newParentProject == nil ? nil : "New Subproject", directory: newParentProject?.directory, trustLevel: nil, overview: nil, profileId: nil, parentId: newParentProject?.id ) ) .environmentObject(viewModel) } .sheet(item: $draftTaskForNew) { task in if let workspaceVM = viewModel.workspaceVM { EditTaskSheet( task: task, mode: .new, workspaceVM: workspaceVM, onSave: { updatedTask in Task { await workspaceVM.updateTask(updatedTask) draftTaskForNew = nil } }, onCancel: { draftTaskForNew = nil } ) } } .sheet(isPresented: $showAutoAssignSheet) { AutoAssignSheet(isPresented: $showAutoAssignSheet) .environmentObject(viewModel) } .confirmationDialog( "Delete project?", isPresented: $showDeleteConfirm, titleVisibility: .visible, presenting: pendingDelete ) { prj in let hasChildren = viewModel.projects.contains { $0.parentId == prj.id } if hasChildren { Button("Delete Project and Subprojects", role: .destructive) { Task { await viewModel.deleteProjectCascade(id: prj.id) } pendingDelete = nil } Button("Move Subprojects to Top Level") { Task { await viewModel.deleteProjectMoveChildrenUp(id: prj.id) } pendingDelete = nil } Button("Cancel", role: .cancel) { pendingDelete = nil } } else { Button("Delete", role: .destructive) { Task { await viewModel.deleteProject(id: prj.id) } pendingDelete = nil } Button("Cancel", role: .cancel) { pendingDelete = nil } } } message: { prj in Text( "Sessions remain intact. This only removes the project record. This action cannot be undone." ) } } private func handleSelection(for project: Project) { #if os(macOS) let commandDown = NSApp.currentEvent?.modifierFlags.contains(.command) ?? false #else let commandDown = false #endif if commandDown { viewModel.toggleProjectSelection(project.id) } else { viewModel.setSelectedProject(project.id) } } private func displayName(_ p: Project) -> String { if !p.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return p.name } if let dir = p.directory, !dir.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let base = URL(fileURLWithPath: dir, isDirectory: true).lastPathComponent return base.isEmpty ? p.id : base } return p.id } private func makeProjectTreeNodeView( node: ProjectTreeNode, countsDisplay: [String: (visible: Int, total: Int)], expandedBinding: Binding> ) -> some View { ProjectTreeNodeView( node: node, countsDisplay: countsDisplay, displayName: displayName(_:), viewModel: viewModel, expanded: expandedBinding, onTap: { handleSelection(for: $0) }, onDoubleTap: { project in onEditProject(project) }, onNewSession: { viewModel.newSession(project: $0) }, onNewSubproject: { parent in newParentProject = parent showNewProject = true }, onNewTask: { project in guard project.id != SessionListViewModel.otherProjectId else { return } draftTaskForNew = CodMateTask( title: "", description: nil, projectId: project.id ) }, onEdit: { project in onEditProject(project) }, onDelete: { project in pendingDelete = project showDeleteConfirm = true }, onReveal: { viewModel.revealProjectDirectory($0) }, onOpenInEditor: { project, editor in viewModel.openProjectInEditor(project, using: editor) }, onAssignSessions: { projectId, ids in Task { await viewModel.assignSessions(to: projectId, ids: ids) } }, onChangeParent: { projectId, newParentId in Task { await viewModel.changeProjectParent(projectId: projectId, newParentId: newParentId) } }, onNewProjectClick: { newParentProject = nil showNewProject = true }, onAutoAssign: { showAutoAssignSheet = true } ) } private func makeOtherProjectRow(countsDisplay: [String: (visible: Int, total: Int)]) -> some View { let otherId = SessionListViewModel.otherProjectId let otherProject = Project( id: otherId, name: "Others", directory: nil, trustLevel: nil, overview: nil, instructions: nil, profileId: nil, profile: nil, parentId: nil, sources: ProjectSessionSource.allSet) return ProjectRow( project: otherProject, displayName: "Others", visible: countsDisplay[otherId]?.visible ?? 0, total: countsDisplay[otherId]?.total ?? 0, onNewSession: {}, onEdit: {}, onDelete: {} ) .listRowInsets(EdgeInsets()) .contentShape(Rectangle()) .onTapGesture { handleSelection(for: otherProject) } .tag(otherId) } } extension View { @ViewBuilder fileprivate func applyAlternatingRows() -> some View { if #available(macOS 14.0, *) { self.alternatingRowBackgrounds(.enabled) } else { self } } } private func stripeBackground(for id: String) -> Color { // Make one stripe transparent and the other a subtle separator tint let isOdd = (id.hashValue & 1) != 0 if isOdd { return Color(nsColor: .separatorColor).opacity(0.08) } else { return .clear } } private struct ProjectRow: View { let project: Project let displayName: String let visible: Int let total: Int var onNewSession: () -> Void var onEdit: () -> Void var onDelete: () -> Void var body: some View { HStack(spacing: 8) { let iconName = (project.id == SessionListViewModel.otherProjectId) ? "ellipsis" : "square.grid.2x2" Image(systemName: iconName) .foregroundStyle(.secondary) .font(.caption) Text(displayName) .font(.caption) .lineLimit(1) Spacer(minLength: 4) let showCount = (visible > 0) || (total > 0) if showCount { Text("\(visible)/\(total)") .font(.caption2.monospacedDigit()) .foregroundStyle(.tertiary) } } .frame(height: 16) .padding(.vertical, 8) .padding(.trailing, 8) // Thin top hairline to separate items, matching sessions list aesthetic .overlay(alignment: .top) { Rectangle() .fill(Color(nsColor: .separatorColor).opacity(0.18)) .frame(height: 1) } } } private struct ProjectTreeNodeView: View { let node: ProjectTreeNode let countsDisplay: [String: (visible: Int, total: Int)] let displayName: (Project) -> String let viewModel: SessionListViewModel @Binding var expanded: Set let onTap: (Project) -> Void let onDoubleTap: (Project) -> Void let onNewSession: (Project) -> Void let onNewSubproject: (Project) -> Void let onNewTask: (Project) -> Void let onEdit: (Project) -> Void let onDelete: (Project) -> Void let onReveal: (Project) -> Void let onOpenInEditor: (Project, EditorApp) -> Void let onAssignSessions: (String, [String]) -> Void let onChangeParent: (String, String?) -> Void let onNewProjectClick: () -> Void let onAutoAssign: () -> Void var body: some View { Group { if let children = node.children, !children.isEmpty { DisclosureGroup(isExpanded: binding(for: node.project.id)) { ForEach(children) { child in ProjectTreeNodeView( node: child, countsDisplay: countsDisplay, displayName: displayName, viewModel: viewModel, expanded: $expanded, onTap: onTap, onDoubleTap: onDoubleTap, onNewSession: onNewSession, onNewSubproject: onNewSubproject, onNewTask: onNewTask, onEdit: onEdit, onDelete: onDelete, onReveal: onReveal, onOpenInEditor: onOpenInEditor, onAssignSessions: onAssignSessions, onChangeParent: onChangeParent, onNewProjectClick: onNewProjectClick, onAutoAssign: onAutoAssign ) } } label: { row(for: node.project) } .tag(node.project.id) } else { row(for: node.project) .tag(node.project.id) } } } private func binding(for id: String) -> Binding { Binding( get: { expanded.contains(id) }, set: { value in if value { expanded.insert(id) } else { expanded.remove(id) } } ) } private func row(for project: Project) -> some View { let pair = countsDisplay[project.id] ?? (visible: 0, total: 0) let isOtherProject = project.id == SessionListViewModel.otherProjectId return ProjectRow( project: project, displayName: displayName(project), visible: pair.visible, total: pair.total, onNewSession: { onNewSession(project) }, onEdit: { onEdit(project) }, onDelete: { onDelete(project) } ) .listRowInsets(EdgeInsets()) .contentShape(Rectangle()) .onDrag { // Only allow dragging real projects (not Other) guard !isOtherProject else { return NSItemProvider() } return NSItemProvider(object: "project:\(project.id)" as NSString) } .onTapGesture { onTap(project) } .onTapGesture(count: 2) { onDoubleTap(project) } .contextMenu { contextMenu(for: project) } // Drop destination for sessions and projects .dropDestination(for: String.self) { items, _ in // Don't allow dropping onto Other project guard !isOtherProject else { return false } let all = items.flatMap { $0.split(separator: "\n").map(String.init) } // Check if any item is a project drag (starts with "project:") let projectDrags = all.filter { $0.hasPrefix("project:") } if let firstProjectDrag = projectDrags.first { let draggedProjectId = String(firstProjectDrag.dropFirst("project:".count)) // Prevent dropping onto self guard draggedProjectId != project.id else { return false } // Set this project as the parent of the dragged project onChangeParent(draggedProjectId, project.id) return true } // Otherwise, treat as session assignment let sessionIds = all.filter { !$0.hasPrefix("project:") } if !sessionIds.isEmpty { onAssignSessions(project.id, sessionIds) return true } return false } } @ViewBuilder private func contextMenu(for project: Project) -> some View { let anchor = projectAnchor(for: project) let items = buildNewMenuItems(anchor: anchor, project: project) Menu { SplitMenuItemsView(items: items) } label: { Label("New Session…", systemImage: "plus") } Button { onNewProjectClick() } label: { Label("New Project…", systemImage: "square.grid.2x2") } Button { onNewTask(project) } label: { Label("New Task…", systemImage: "checklist") } Button { onNewSubproject(project) } label: { Label("New Subproject…", systemImage: "plus.square.on.square") } Divider() let editors = EditorApp.installedEditors openInEditorMenu(editors: editors) { editor in onOpenInEditor(project, editor) } .disabled(project.directory == nil || project.directory?.isEmpty == true) Button { onReveal(project) } label: { Label("Reveal in Finder", systemImage: "finder") } .disabled(project.directory == nil || project.directory?.isEmpty == true) Button { onEdit(project) } label: { Label("Edit Project / Property", systemImage: "pencil") } Divider() Button(role: .destructive) { onDelete(project) } label: { Label("Delete Project", systemImage: "trash") } if project.id == SessionListViewModel.otherProjectId { Divider() Button { onAutoAssign() } label: { Label("Auto assign to ...", systemImage: "wand.and.stars") } } } // MARK: - Shared New Session menu (matches session/task context menus) private func projectAnchor(for project: Project) -> SessionSummary? { let vm = self.viewModel // Prefer a visible session for this project; fallback to any session in the project. if let visible = vm.sections.flatMap({ $0.sessions }).first( where: { vm.projectIdForSession($0.id) == project.id }) { return visible } return vm.allSessions.first { vm.projectIdForSession($0.id) == project.id } } private func buildNewMenuItems(anchor: SessionSummary?, project: Project? = nil) -> [SplitMenuItem] { let vm = self.viewModel let allowed: Set if let anchor { allowed = Set(vm.allowedSources(for: anchor)) } else if let project { let sources = project.sources.isEmpty ? ProjectSessionSource.allSet : project.sources allowed = Set(sources.filter { vm.preferences.isCLIEnabled($0.baseKind) }) } else { allowed = Set(ProjectSessionSource.allCases.filter { vm.preferences.isCLIEnabled($0.baseKind) }) } let requestedOrder: [ProjectSessionSource] = [.claude, .codex, .gemini] let enabledRemoteHosts = vm.preferences.enabledRemoteHosts.sorted() func sourceKey(_ source: SessionSource) -> String { switch source { case .codexLocal: return "codex-local" case .codexRemote(let host): return "codex-\(host)" case .claudeLocal: return "claude-local" case .claudeRemote(let host): return "claude-\(host)" case .geminiLocal: return "gemini-local" case .geminiRemote(let host): return "gemini-\(host)" } } func launchItems(for source: SessionSource) -> [SplitMenuItem] { let key = sourceKey(source) var items = externalTerminalMenuItems(idPrefix: key) { profile in if let anchor { launchNewSession(for: anchor, using: source, profile: profile) } else if let project { vm.launchNewSessionFromProject(project: project, using: source, profile: profile) } } if vm.preferences.isEmbeddedTerminalEnabled { let embedded = embeddedTerminalProfile() items.insert( SplitMenuItem( id: "\(key)-\(embedded.id)", kind: .action( title: embedded.displayTitle, systemImage: "macwindow", run: { if let anchor { launchNewSession(for: anchor, using: source, profile: embedded) } else if let project { vm.launchNewSessionFromProject(project: project, using: source, profile: embedded) } } ) ), at: 0 ) } return items } func remoteSource(for base: ProjectSessionSource, host: String) -> SessionSource { switch base { case .codex: return .codexRemote(host: host) case .claude: return .claudeRemote(host: host) case .gemini: return .geminiRemote(host: host) } } func providerAssetIcon(_ source: ProjectSessionSource) -> String { switch source { case .codex: return "ChatGPTIcon" case .claude: return "ClaudeIcon" case .gemini: return "GeminiIcon" } } func assetIconForSessionSource(_ source: SessionSource) -> String { switch source.baseKind { case .codex: return "ChatGPTIcon" case .claude: return "ClaudeIcon" case .gemini: return "GeminiIcon" } } var menuItems: [SplitMenuItem] = [] for base in requestedOrder where allowed.contains(base) { var providerItems = launchItems(for: base.sessionSource) if !enabledRemoteHosts.isEmpty { providerItems.append(SplitMenuItem(id: "sep-\(base.rawValue)", kind: .separator)) for host in enabledRemoteHosts { let remote = remoteSource(for: base, host: host) providerItems.append( SplitMenuItem( id: "remote-\(base.rawValue)-\(host)", kind: .submenu(title: host, items: launchItems(for: remote)) )) } } menuItems.append( SplitMenuItem( id: "provider-\(base.rawValue)", kind: .submenu(title: base.displayName, assetImage: providerAssetIcon(base), items: providerItems) )) } if menuItems.isEmpty, let anchor { let fallbackSource = anchor.source menuItems.append( SplitMenuItem( id: "fallback-\(sourceKey(fallbackSource))", kind: .submenu( title: fallbackSource.branding.displayName, assetImage: assetIconForSessionSource(fallbackSource), items: launchItems(for: fallbackSource) ))) } return menuItems } private func launchNewSession( for session: SessionSummary, using source: SessionSource, profile: ExternalTerminalProfile ) { let vm = self.viewModel vm.launchNewSessionWithProfile( session: session, using: source, profile: profile, workingDirectory: session.cwd ) } } struct ProjectEditorSheet: View { enum Mode { case new case edit(existing: Project) } @EnvironmentObject private var viewModel: SessionListViewModel @Binding var isPresented: Bool let mode: Mode struct Prefill: Sendable, Identifiable { let id = UUID() var name: String? var directory: String? var trustLevel: String? var overview: String? var profileId: String? var parentId: String? } var prefill: Prefill? = nil var autoAssignSessionIDs: [String]? = nil @State private var showCloseConfirm = false @State private var original: Snapshot? = nil @State private var name: String = "" @State private var directory: String = "" @State private var trustLevel: String = "" @State private var overview: String = "" @State private var profileId: String = "" @State private var profileSandbox: SandboxMode? = nil @State private var profileApproval: ApprovalPolicy? = nil @State private var profileFullAuto: Bool? = nil @State private var profileDangerBypass: Bool? = nil @State private var profilePathPrependText: String = "" @State private var profileEnvText: String = "" @State private var parentProjectId: String? = nil @State private var sources: Set = ProjectSessionSource.allSet @State private var mcpSearchText: String = "" @State private var skillsSearchText: String = "" @StateObject private var extensionsVM = ProjectExtensionsViewModel() private struct Snapshot: Equatable { var name: String var directory: String var trustLevel: String var overview: String var profileSandbox: SandboxMode? var profileApproval: ApprovalPolicy? var profileFullAuto: Bool? var profileDangerBypass: Bool? var profilePathPrependText: String var profileEnvText: String var parentProjectId: String? var sources: Set } // Unified layout constants for aligned labels/fields across tabs private let labelColWidth: CGFloat = 120 private let fieldColWidth: CGFloat = 460 private var generalTabView: some View { VStack(alignment: .leading, spacing: 12) { Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { GridRow { Text("Name") .font(.subheadline) .frame(width: labelColWidth, alignment: .trailing) TextField("Display name", text: $name) .textFieldStyle(.roundedBorder) .frame(width: fieldColWidth, alignment: .leading) } GridRow { Text("Directory") .font(.subheadline) .frame(width: labelColWidth, alignment: .trailing) HStack(spacing: 8) { TextField("/absolute/path", text: $directory) .textFieldStyle(.roundedBorder) .frame(maxWidth: .infinity) Button("Choose…") { chooseDirectory() } } .frame(width: fieldColWidth, alignment: .leading) } GridRow { Text("Parent Project") .font(.subheadline) .frame(width: labelColWidth, alignment: .trailing) Picker( "", selection: Binding( get: { parentProjectId ?? "(none)" }, set: { parentProjectId = $0 == "(none)" ? nil : $0 }) ) { Text("(none)").tag("(none)") ForEach(viewModel.projects.filter { $0.id != (modeSelfId()) }, id: \.id) { p in Text(p.name.isEmpty ? p.id : p.name).tag(p.id) } } .labelsHidden() .frame(width: fieldColWidth, alignment: .leading) } GridRow { Text("Trust Level") .font(.subheadline) .frame(width: labelColWidth, alignment: .trailing) Picker("", selection: trustLevelBinding) { Text("trusted").tag("trusted") Text("untrusted").tag("untrusted") } .labelsHidden() .pickerStyle(.segmented) .frame(width: fieldColWidth, alignment: .leading) } GridRow { Text("Sources") .font(.subheadline) .frame(width: labelColWidth, alignment: .trailing) HStack(spacing: 16) { ForEach(ProjectSessionSource.allCases) { source in Toggle(source.displayName, isOn: binding(for: source)) .toggleStyle(.checkbox) .disabled(!viewModel.preferences.isCLIEnabled(source.baseKind)) } } .frame(width: fieldColWidth, alignment: .leading) } GridRow(alignment: .top) { Text("Overview") .font(.subheadline) .frame(width: labelColWidth, alignment: .trailing) VStack(alignment: .leading, spacing: 6) { TextEditor(text: $overview) .font(.body) .frame(minHeight: 88, maxHeight: .infinity) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(Color.secondary.opacity(0.2)) ) } .frame(width: fieldColWidth, alignment: .leading) } } Spacer(minLength: 0) } .padding(16) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } private var profileTabView: some View { VStack(alignment: .leading, spacing: 12) { Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { GridRow { Text("Sandbox") .font(.subheadline) .frame(width: labelColWidth, alignment: .trailing) Picker( "", selection: Binding( get: { profileSandbox ?? .workspaceWrite }, set: { profileSandbox = $0 }) ) { ForEach(SandboxMode.allCases) { s in Text(s.title).tag(s) } } .labelsHidden() .pickerStyle(.segmented) .frame(width: fieldColWidth, alignment: .leading) } GridRow { Text("Approval") .font(.subheadline) .frame(width: labelColWidth, alignment: .trailing) Picker( "", selection: Binding( get: { profileApproval ?? .onRequest }, set: { profileApproval = $0 }) ) { ForEach(ApprovalPolicy.allCases) { a in Text(a.title).tag(a) } } .labelsHidden() .pickerStyle(.segmented) .frame(maxWidth: .infinity, alignment: .leading) } GridRow { Text("Presets") .font(.subheadline) .frame(width: labelColWidth, alignment: .trailing) HStack(spacing: 12) { Toggle( "Full Auto", isOn: Binding(get: { profileFullAuto ?? false }, set: { profileFullAuto = $0 })) Toggle( "Danger Bypass", isOn: Binding( get: { profileDangerBypass ?? false }, set: { profileDangerBypass = $0 })) } .frame(width: fieldColWidth, alignment: .leading) } } Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { GridRow { Text("PATH Prepend") .font(.subheadline) .frame(width: labelColWidth, alignment: .trailing) TextField("/opt/custom/bin:/project/bin", text: $profilePathPrependText) .textFieldStyle(.roundedBorder) .frame(width: fieldColWidth, alignment: .leading) } GridRow(alignment: .top) { Text("Environment") .font(.subheadline) .frame(width: labelColWidth, alignment: .trailing) VStack(alignment: .leading, spacing: 6) { TextEditor(text: $profileEnvText) .font(.system(.body, design: .monospaced)) .frame(minHeight: 100, maxHeight: 180) .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.2))) Text("One per line: KEY=VALUE. Will export as export KEY='VALUE'.").font(.caption) .foregroundStyle(.secondary) } .frame(width: fieldColWidth, alignment: .leading) } } } .padding(16) } private var mcpTabView: some View { VStack(alignment: .leading, spacing: 12) { ZStack { HStack { Spacer(minLength: 0) ToolbarSearchField( placeholder: "Search MCP servers", text: $mcpSearchText, onFocusChange: { _ in }, onSubmit: {} ) .frame(maxWidth: .infinity) Spacer(minLength: 8) Button { extensionsVM.beginProjectMCPImport() } label: { Label("Import", systemImage: "tray.and.arrow.down") .labelStyle(.titleAndIcon) } .buttonStyle(.borderless) .help("Import MCP servers from this project") Button { openExtensionsSettings(tab: .mcp) } label: { Image(systemName: "gearshape") .font(.body) } .buttonStyle(.borderless) .help("Open Extensions settings") } .frame(maxWidth: .infinity) } if filteredMCPSelections.isEmpty { emptyState( icon: "server.rack", title: extensionsVM.mcpSelections.isEmpty ? "No MCP Servers" : "No Results", message: extensionsVM.mcpSelections.isEmpty ? "Add servers in Settings › Extensions to enable project selection." : "Try a different search." ) } else { ScrollView { LazyVStack(spacing: 8) { ForEach(filteredMCPSelections) { entry in HStack(alignment: .center, spacing: 8) { Toggle( "", isOn: Binding( get: { entry.isSelected }, set: { value in extensionsVM.updateMCPSelection(id: entry.id, isSelected: value) } ) ) .labelsHidden() .controlSize(.small) VStack(alignment: .leading, spacing: 4) { Text(entry.server.name) .font(.subheadline.weight(.medium)) if let desc = entry.server.meta?.description, !desc.isEmpty { Text(desc) .font(.caption) .foregroundStyle(.secondary) } } Spacer(minLength: 8) HStack(spacing: 6) { MCPServerTargetToggle( provider: .codex, isOn: Binding( get: { entry.targets.codex }, set: { value in extensionsVM.updateMCPTarget(id: entry.id, target: .codex, value: value) } ), disabled: !entry.isSelected || !viewModel.preferences.isCLIEnabled(.codex) ) MCPServerTargetToggle( provider: .claude, isOn: Binding( get: { entry.targets.claude }, set: { value in extensionsVM.updateMCPTarget(id: entry.id, target: .claude, value: value) } ), disabled: !entry.isSelected || !viewModel.preferences.isCLIEnabled(.claude) ) MCPServerTargetToggle( provider: .gemini, isOn: Binding( get: { entry.targets.gemini }, set: { value in extensionsVM.updateMCPTarget(id: entry.id, target: .gemini, value: value) } ), disabled: !entry.isSelected || !viewModel.preferences.isCLIEnabled(.gemini) ) } } .padding(.vertical, 6) } } .padding(.horizontal, 8) } } } .padding(16) } private var skillsTabView: some View { VStack(alignment: .leading, spacing: 12) { ZStack { HStack { Spacer(minLength: 0) ToolbarSearchField( placeholder: "Search skills", text: $skillsSearchText, onFocusChange: { _ in }, onSubmit: {} ) .frame(maxWidth: .infinity) Spacer(minLength: 8) Button { extensionsVM.beginProjectSkillsImport() } label: { Label("Import", systemImage: "tray.and.arrow.down") .labelStyle(.titleAndIcon) } .buttonStyle(.borderless) .help("Import skills from this project") Button { openExtensionsSettings(tab: .skills) } label: { Image(systemName: "gearshape") .font(.body) } .buttonStyle(.borderless) .help("Open Extensions settings") } .frame(maxWidth: .infinity) } if filteredSkills.isEmpty { emptyState( icon: "sparkles", title: extensionsVM.skills.isEmpty ? "No Skills Installed" : "No Results", message: extensionsVM.skills.isEmpty ? "Install skills in Settings › Extensions to enable project selection." : "Try a different search." ) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollView { LazyVStack(spacing: 8) { ForEach(filteredSkills) { skill in HStack(alignment: .center, spacing: 8) { Toggle( "", isOn: Binding( get: { skill.isSelected }, set: { value in extensionsVM.updateSkillSelection(id: skill.id, value: value) } ) ) .labelsHidden() .controlSize(.small) VStack(alignment: .leading, spacing: 4) { Text(skill.displayName) .font(.subheadline.weight(.medium)) if !skill.summary.isEmpty { Text(skill.summary) .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) } } Spacer(minLength: 8) HStack(spacing: 6) { MCPServerTargetToggle( provider: .codex, isOn: Binding( get: { skill.targets.codex }, set: { value in extensionsVM.updateSkillTarget(id: skill.id, target: .codex, value: value) } ), disabled: !skill.isSelected || !viewModel.preferences.isCLIEnabled(.codex) ) MCPServerTargetToggle( provider: .claude, isOn: Binding( get: { skill.targets.claude }, set: { value in extensionsVM.updateSkillTarget(id: skill.id, target: .claude, value: value) } ), disabled: !skill.isSelected || !viewModel.preferences.isCLIEnabled(.claude) ) MCPServerTargetToggle( provider: .gemini, isOn: Binding( get: { skill.targets.gemini }, set: { value in extensionsVM.updateSkillTarget(id: skill.id, target: .gemini, value: value) } ), disabled: !skill.isSelected || !viewModel.preferences.isCLIEnabled(.gemini) ) } } .padding(.vertical, 6) } } .padding(.horizontal, 8) } } } .padding(16) } private func emptyState(icon: String, title: String, message: String) -> some View { VStack(spacing: 8) { Image(systemName: icon) .font(.system(size: 28)) .foregroundStyle(.secondary) Text(title) .font(.subheadline.weight(.semibold)) Text(message) .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) .padding(.vertical, 16) } private var filteredSkills: [SkillSummary] { let trimmed = skillsSearchText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return extensionsVM.skills } return extensionsVM.skills.filter { skill in let hay = [ skill.displayName, skill.summary, skill.tags.joined(separator: " "), skill.source, ] .joined(separator: " ") .lowercased() return hay.contains(trimmed.lowercased()) } } private var filteredMCPSelections: [ProjectMCPSelection] { let trimmed = mcpSearchText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return extensionsVM.mcpSelections } return extensionsVM.mcpSelections.filter { entry in let hay = [ entry.server.name, entry.server.meta?.description ?? "", ] .joined(separator: " ") .lowercased() return hay.contains(trimmed.lowercased()) } } private func openExtensionsSettings(tab: ExtensionsSettingsTab) { NotificationCenter.default.post( name: .codMateOpenSettings, object: nil, userInfo: [ "category": SettingCategory.mcpServer.rawValue, "extensionsTab": tab.rawValue, ] ) } var body: some View { VStack(alignment: .leading, spacing: 12) { Text(modeTitle).font(.title3).fontWeight(.semibold) Group { if #available(macOS 15.0, *) { TabView { Tab("General", systemImage: "gearshape") { generalTabView } Tab("Profile", systemImage: "person.crop.square") { profileTabView } Tab("MCP Servers", systemImage: "server.rack") { mcpTabView } Tab("Skills", systemImage: "sparkles") { skillsTabView } } } else { TabView { generalTabView .tabItem { Label("General", systemImage: "gearshape") } profileTabView .tabItem { Label("Profile", systemImage: "person.crop.square") } mcpTabView .tabItem { Label("MCP Servers", systemImage: "server.rack") } skillsTabView .tabItem { Label("Skills", systemImage: "sparkles") } } } } .padding(.bottom, 4) HStack { if case .edit(let p) = mode { Text("ID: \(p.id)").font(.caption).foregroundStyle(.secondary) } Spacer() Button("Cancel") { attemptClose() } .keyboardShortcut(.cancelAction) Button(primaryActionTitle) { save() } .keyboardShortcut(.defaultAction) } } .padding(16) .frame(minWidth: 720, minHeight: 520) .codmatePresentationSizingIfAvailable() .onAppear(perform: load) .onChange(of: directory) { newDir in Task { await extensionsVM.load(projectId: modeSelfId(), projectDirectory: newDir, trustLevel: trustLevel) } } .alert("Discard changes?", isPresented: $showCloseConfirm) { Button("Keep Editing", role: .cancel) {} Button("Discard", role: .destructive) { isPresented = false } } message: { Text("Your edits will be lost.") } .sheet(isPresented: $extensionsVM.showMCPImportSheet) { MCPImportSheet( candidates: $extensionsVM.mcpImportCandidates, isImporting: extensionsVM.isImportingMCP, statusMessage: extensionsVM.mcpImportStatusMessage, title: "Import MCP Servers", subtitle: "Scan this project for existing MCP servers and import into CodMate.", onCancel: { extensionsVM.cancelProjectMCPImport() }, onImport: { Task { await extensionsVM.importProjectMCPSelections() } } ) .frame(minWidth: 760, minHeight: 480) } .sheet(isPresented: $extensionsVM.showSkillsImportSheet) { SkillsImportSheet( candidates: $extensionsVM.skillsImportCandidates, isImporting: extensionsVM.isImportingSkills, statusMessage: extensionsVM.skillsImportStatusMessage, title: "Import Skills", subtitle: "Scan this project for existing skills and import into CodMate.", onCancel: { extensionsVM.cancelProjectSkillsImport() }, onImport: { Task { await extensionsVM.importProjectSkillsSelections() } } ) .frame(minWidth: 760, minHeight: 480) } } private var modeTitle: String { if case .edit = mode { return "Edit Project" } else { return "New Project" } } private var primaryActionTitle: String { if case .edit = mode { return "Save" } else { return "Create" } } private func chooseDirectory() { let panel = NSOpenPanel() panel.canChooseDirectories = true panel.canChooseFiles = false panel.allowsMultipleSelection = false if panel.runModal() == .OK, let url = panel.url { directory = url.path } } private func load() { switch mode { case .edit(let p): name = p.name directory = p.directory ?? "" trustLevel = p.trustLevel ?? "trusted" parentProjectId = p.parentId overview = p.overview ?? "" profileId = p.profileId ?? "" let initialSources = p.sources.isEmpty ? ProjectSessionSource.allSet : p.sources let enabledSources = initialSources.filter { viewModel.preferences.isCLIEnabled($0.baseKind) } sources = enabledSources.isEmpty ? Set(ProjectSessionSource.allCases.filter { viewModel.preferences.isCLIEnabled($0.baseKind) }) : enabledSources if let pr = p.profile { profileSandbox = pr.sandbox profileApproval = pr.approval profileFullAuto = pr.fullAuto profileDangerBypass = pr.dangerouslyBypass if let pp = pr.pathPrepend { profilePathPrependText = pp.joined(separator: ":") } if let env = pr.env { let lines = env.keys.sorted().map { k in let v = env[k] ?? "" return "\(k)=\(v)" } profileEnvText = lines.joined(separator: "\n") } } case .new: sources = Set(ProjectSessionSource.allCases.filter { viewModel.preferences.isCLIEnabled($0.baseKind) }) if let pf = prefill { if let v = pf.name { name = v } if let v = pf.directory { directory = v } if let v = pf.trustLevel { trustLevel = v } else { trustLevel = "trusted" } if let v = pf.overview { overview = v } if let v = pf.profileId { profileId = v } if let v = pf.parentId { parentProjectId = v } } } original = currentSnapshot() Task { await extensionsVM.load(projectId: modeSelfId(), projectDirectory: directory, trustLevel: trustLevel) } } private func slugify(_ s: String) -> String { let lower = s.lowercased() let allowed = "abcdefghijklmnopqrstuvwxyz0123456789-" let chars = lower.map { ch -> Character in if allowed.contains(ch) { return ch } if ch.isLetter || ch.isNumber { return "-" } return "-" } var str = String(chars) while str.contains("--") { str = str.replacingOccurrences(of: "--", with: "-") } str = str.trimmingCharacters(in: CharacterSet(charactersIn: "-")) return str.isEmpty ? "project" : str } private func generateId() -> String { let baseName: String = { let n = name.trimmingCharacters(in: .whitespaces) if !n.isEmpty { return n } let base = URL(fileURLWithPath: directory, isDirectory: true).lastPathComponent return base.isEmpty ? "project" : base }() var candidate = slugify(baseName) let existing = Set(viewModel.projects.map(\.id)) var i = 1 while existing.contains(candidate) { i += 1 candidate = slugify(baseName) + "-\(i)" } return candidate } private func save() { let trust = trustLevel.trimmingCharacters(in: .whitespaces).isEmpty ? nil : trustLevel let ov = overview.trimmingCharacters(in: .whitespaces).isEmpty ? nil : overview // Profile ID: auto map to project ID by default let cleanedProfileId = profileId.trimmingCharacters(in: .whitespaces) let profile: String? = cleanedProfileId.isEmpty ? nil : cleanedProfileId let dirOpt: String? = { let d = directory.trimmingCharacters(in: .whitespacesAndNewlines) return d.isEmpty ? nil : directory }() let enabledSources = sources.filter { viewModel.preferences.isCLIEnabled($0.baseKind) } let fallbackSources = Set(ProjectSessionSource.allCases.filter { viewModel.preferences.isCLIEnabled($0.baseKind) }) let finalSources = enabledSources.isEmpty ? fallbackSources : enabledSources switch mode { case .new: let id = generateId() let projProfile = buildProjectProfile(originalModel: nil) let finalProfileId = profile ?? id let p = Project( id: id, name: (name.isEmpty ? id : name), directory: dirOpt, trustLevel: trust, overview: ov, instructions: nil, profileId: finalProfileId, profile: projProfile, parentId: parentProjectId, sources: finalSources ) Task { await viewModel.createOrUpdateProject(p) await extensionsVM.persistSelections(projectId: id, directory: dirOpt, trustLevel: trust) if let ids = autoAssignSessionIDs, !ids.isEmpty { await viewModel.assignSessions(to: id, ids: ids) } isPresented = false } case .edit(let old): let projProfile = buildProjectProfile(originalModel: old.profile?.model) let finalProfileId = profile ?? old.id let p = Project( id: old.id, name: name, directory: dirOpt, trustLevel: trust, overview: ov, instructions: old.instructions, // Preserve existing instructions profileId: finalProfileId, profile: projProfile, parentId: parentProjectId, sources: finalSources ) Task { await viewModel.createOrUpdateProject(p) await extensionsVM.persistSelections(projectId: old.id, directory: dirOpt, trustLevel: trust) isPresented = false } } } private var trustLevelSegment: String { trustLevel == "untrusted" ? "untrusted" : "trusted" } private var trustLevelBinding: Binding { Binding( get: { trustLevelSegment }, set: { newValue in trustLevel = (newValue == "untrusted") ? "untrusted" : "trusted" } ) } private func binding(for source: ProjectSessionSource) -> Binding { Binding( get: { sources.contains(source) && viewModel.preferences.isCLIEnabled(source.baseKind) }, set: { newValue in guard viewModel.preferences.isCLIEnabled(source.baseKind) else { return } if newValue { sources.insert(source) } else { if sources.count == 1 && sources.contains(source) { return } sources.remove(source) } } ) } private func modeSelfId() -> String? { if case .edit(let p) = mode { return p.id } return nil } private func buildProjectProfile(originalModel: String?) -> ProjectProfile? { if (profileId.trimmingCharacters(in: .whitespaces).isEmpty) && (originalModel?.isEmpty ?? true) && profileSandbox == nil && profileApproval == nil && profileFullAuto == nil && profileDangerBypass == nil { return nil } return ProjectProfile( model: originalModel, sandbox: profileSandbox, approval: profileApproval, fullAuto: profileFullAuto, dangerouslyBypass: profileDangerBypass, pathPrepend: parsePathPrepend(profilePathPrependText), env: parseEnv(profileEnvText) ) } private func parsePathPrepend(_ text: String) -> [String]? { let s = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !s.isEmpty else { return nil } return s.split(separator: ":").map { String($0).trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } } private func parseEnv(_ text: String) -> [String: String]? { let lines = text.split(whereSeparator: { $0 == "\n" || $0 == "\r" }).map(String.init) var dict: [String: String] = [:] for line in lines { let t = line.trimmingCharacters(in: .whitespaces) guard !t.isEmpty, let eq = t.firstIndex(of: "=") else { continue } let key = String(t[.. Snapshot { Snapshot( name: name, directory: directory, trustLevel: trustLevel, overview: overview, profileSandbox: profileSandbox, profileApproval: profileApproval, profileFullAuto: profileFullAuto, profileDangerBypass: profileDangerBypass, profilePathPrependText: profilePathPrependText, profileEnvText: profileEnvText, parentProjectId: parentProjectId, sources: sources ) } private func attemptClose() { if let original, original != currentSnapshot() { showCloseConfirm = true } else { isPresented = false } } } private struct ProjectTreeNode: Identifiable, Hashable { let id: String let project: Project var children: [ProjectTreeNode]? } private func buildProjectTree(_ projects: [Project]) -> [ProjectTreeNode] { var map: [String: ProjectTreeNode] = [:] var roots: [ProjectTreeNode] = [] for p in projects { map[p.id] = ProjectTreeNode(id: p.id, project: p, children: []) } for p in projects { if let pid = p.parentId, let parent = map[pid] { let copy = map[p.id]! // attach under parent var parentCopy = parent parentCopy.children?.append(copy) map[pid] = parentCopy } } // rebuild roots (those without a valid parent) for p in projects.sorted(by: { $0.name.localizedStandardCompare($1.name) == .orderedAscending }) { if let pid = p.parentId, projects.contains(where: { $0.id == pid }) { continue } // gather children from map updated above let node = map[p.id] ?? ProjectTreeNode(id: p.id, project: p, children: nil) roots.append(fixChildren(node, map: map)) } return roots } private func fixChildren(_ node: ProjectTreeNode, map: [String: ProjectTreeNode]) -> ProjectTreeNode { var out = node let project = node.project let children = map.values.filter { $0.project.parentId == project.id } .sorted { $0.project.name.localizedStandardCompare($1.project.name) == .orderedAscending } .map { fixChildren($0, map: map) } out.children = children.isEmpty ? nil : children return out } ================================================ FILE: views/ProviderEditorView.swift ================================================ import SwiftUI struct ProviderEditorView: View { @Binding var draft: CodexProvider let isNew: Bool var apiKeyApplyURL: String? = nil var onCancel: () -> Void var onSave: () -> Void var onDelete: (() -> Void)? = nil @State private var showDeleteAlert = false var body: some View { VStack(alignment: .leading, spacing: 16) { Text(isNew ? "Add Provider" : "Edit Provider").font(.title2).fontWeight(.semibold) Text("Configure a model provider compatible with OpenAI APIs.") .font(.subheadline).foregroundStyle(.secondary) Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 16) { GridRow { VStack(alignment: .leading, spacing: 0) { Text("Name *").font(.subheadline).fontWeight(.medium) Text("Display name for this provider.").font(.caption).foregroundStyle( .secondary) } TextField( "OpenAI", text: Binding(get: { draft.name ?? "" }, set: { draft.name = $0 }) ) .frame(maxWidth: .infinity) } GridRow { VStack(alignment: .leading, spacing: 0) { Text("Base URL *").font(.subheadline).fontWeight(.medium) Text("API base URL, e.g., https://api.openai.com/v1").font(.caption) .foregroundStyle(.secondary) } TextField( "https://api.openai.com/v1", text: Binding(get: { draft.baseURL ?? "" }, set: { draft.baseURL = $0 }) ) .frame(maxWidth: .infinity) } GridRow { VStack(alignment: .leading, spacing: 0) { Text("API Key").font(.subheadline).fontWeight(.medium) Text("Environment variable for API key (optional). Example: OPENAI_API_KEY") .font(.caption).foregroundStyle(.secondary) } HStack(spacing: 8) { TextField( "OPENAI_API_KEY", text: Binding(get: { draft.envKey ?? "" }, set: { draft.envKey = $0 })) if let apiKeyApplyURL, let url = URL(string: apiKeyApplyURL) { Link("Get key", destination: url) .font(.caption) } } } GridRow { VStack(alignment: .leading, spacing: 0) { Text("Wire API").font(.subheadline).fontWeight(.medium) Text("Protocol: chat or responses (optional).").font(.caption) .foregroundStyle(.secondary) } TextField( "responses", text: Binding(get: { draft.wireAPI ?? "" }, set: { draft.wireAPI = $0 })) } GridRow { VStack(alignment: .leading, spacing: 0) { Text("query_params").font(.subheadline).fontWeight(.medium) Text("Inline TOML. Example: { api-version = \"2025-04-01-preview\" }").font( .caption ).foregroundStyle(.secondary) } TextField( "{ api-version = \"2025-04-01-preview\" }", text: Binding( get: { draft.queryParamsRaw ?? "" }, set: { draft.queryParamsRaw = $0 }) ) } GridRow { VStack(alignment: .leading, spacing: 0) { Text("http_headers").font(.subheadline).fontWeight(.medium) Text("Inline TOML map. Example: { X-Header = \"abc\" }").font(.caption) .foregroundStyle(.secondary) } TextField( "{ X-Header = \"abc\" }", text: Binding( get: { draft.httpHeadersRaw ?? "" }, set: { draft.httpHeadersRaw = $0 }) ) } GridRow { VStack(alignment: .leading, spacing: 0) { Text("env_http_headers").font(.subheadline).fontWeight(.medium) Text("Header values from env. Example: { X-Token = \"MY_ENV\" }").font( .caption ).foregroundStyle(.secondary) } TextField( "{ X-Token = \"MY_ENV\" }", text: Binding( get: { draft.envHttpHeadersRaw ?? "" }, set: { draft.envHttpHeadersRaw = $0 })) } GridRow { VStack(alignment: .leading, spacing: 0) { Text("request_max_retries").font(.subheadline).fontWeight(.medium) Text("HTTP retry count (optional).").font(.caption).foregroundStyle( .secondary) } TextField( "4", text: Binding( get: { (draft.requestMaxRetries?.description) ?? "" }, set: { draft.requestMaxRetries = Int($0) })) } GridRow { VStack(alignment: .leading, spacing: 0) { Text("stream_max_retries").font(.subheadline).fontWeight(.medium) Text("SSE reconnect attempts (optional).").font(.caption).foregroundStyle( .secondary) } TextField( "5", text: Binding( get: { (draft.streamMaxRetries?.description) ?? "" }, set: { draft.streamMaxRetries = Int($0) })) } GridRow { VStack(alignment: .leading, spacing: 0) { Text("stream_idle_timeout_ms").font(.subheadline).fontWeight(.medium) Text("Idle timeout for streaming (optional).").font(.caption) .foregroundStyle(.secondary) } TextField( "300000", text: Binding( get: { (draft.streamIdleTimeoutMs?.description) ?? "" }, set: { draft.streamIdleTimeoutMs = Int($0) })) } } HStack { if !isNew, onDelete != nil { Button("Delete", role: .destructive) { showDeleteAlert = true } } Button("Cancel", role: .cancel, action: onCancel) Spacer() Button("Save", action: onSave).buttonStyle(.borderedProminent) } } .alert("Delete provider?", isPresented: $showDeleteAlert) { Button("Cancel", role: .cancel) { showDeleteAlert = false } Button("Delete", role: .destructive) { showDeleteAlert = false onDelete?() } } message: { Text("This will remove the provider from config.toml.") } } } ================================================ FILE: views/ProviderIconView.swift ================================================ import AppKit import SwiftUI struct ProviderIconView: View { let provider: UsageProviderKind var size: CGFloat = 12 var cornerRadius: CGFloat = 2 var saturation: Double = 1.0 var opacity: Double = 1.0 @Environment(\.colorScheme) private var colorScheme var body: some View { Group { if let name = iconName(for: provider), let image = ProviderIconResource.processedImage( named: name, size: NSSize(width: size, height: size), isDarkMode: colorScheme == .dark ) { Image(nsImage: image) .resizable() .interpolation(.high) .aspectRatio(contentMode: .fit) .frame(width: size, height: size) .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) .saturation(saturation) .opacity(opacity) } else { Circle() .fill(accent(for: provider)) .frame(width: dotSize, height: dotSize) .saturation(saturation) .opacity(opacity) } } .frame(width: size, height: size, alignment: .center) .id(colorScheme) } private var dotSize: CGFloat { max(6, size * 0.75) } private func iconName(for provider: UsageProviderKind) -> String? { switch provider { case .codex: return "ChatGPTIcon" case .claude: return "ClaudeIcon" case .gemini: return "GeminiIcon" } } private func accent(for provider: UsageProviderKind) -> Color { switch provider { case .codex: return Color.accentColor case .claude: return Color(nsColor: .systemPurple) case .gemini: return Color(nsColor: .systemTeal) } } } ================================================ FILE: views/ProvidersSettingsView.swift ================================================ import AppKit import Network import SwiftUI struct ProvidersSettingsView: View { @ObservedObject var preferences: SessionPreferencesStore @StateObject private var vm = ProvidersVM() @StateObject private var proxyService = CLIProxyService.shared @State private var pendingDeleteId: String? @State private var pendingDeleteName: String? @State private var pendingDeleteAccount: CLIProxyService.OAuthAccount? @State private var oauthInfoAccount: CLIProxyService.OAuthAccount? @State private var oauthLoginProvider: LocalAuthProvider? @State private var oauthAutoStartFailed: Bool = false @State private var pendingOAuthProvider: LocalAuthProvider? @State private var showOAuthRiskWarning: Bool = false @State private var localModels: [CLIProxyService.LocalModel] = [] @State private var localIP: String = "127.0.0.1" @State private var publicAPIKey: String = "" private let minPublicKeyLength = 36 var body: some View { VStack(alignment: .leading, spacing: 12) { header Group { if #available(macOS 15.0, *) { TabView { Tab("Providers", systemImage: "server.rack") { SettingsTabContent { providersList } } Tab("ReRoute", systemImage: "arrow.triangle.2.circlepath") { SettingsTabContent { proxyCapabilitiesSection } } Tab("Advanced", systemImage: "gearshape.2") { SettingsTabContent { cliProxyAdvancedSection } } } } else { TabView { SettingsTabContent { providersList } .tabItem { Label("Providers", systemImage: "server.rack") } SettingsTabContent { proxyCapabilitiesSection } .tabItem { Label("ReRoute", systemImage: "arrow.triangle.2.circlepath") } SettingsTabContent { cliProxyAdvancedSection } .tabItem { Label("Advanced", systemImage: "gearshape.2") } } } } .controlSize(.regular) .padding(.bottom, 16) } .sheet( isPresented: Binding( get: { vm.showEditor }, set: { newValue in vm.showEditor = newValue if !newValue { // Reset new provider state when sheet closes vm.isNewProvider = false // Clear test results when closing editor vm.testResults = [:] vm.testResultText = nil } } ) ) { ProviderEditorSheet(vm: vm, preferences: preferences) } .sheet(item: $oauthInfoAccount) { account in let accounts = proxyService.listOAuthAccounts().filter { $0.provider == account.provider } OAuthProviderInfoSheet( provider: account.provider, isLoggedIn: !accounts.isEmpty, accounts: accounts, selectedAccount: account, initialModels: modelsForOAuthProvider(account.provider), onLogin: { oauthLoginProvider = account.provider }, onLogout: { account in proxyService.deleteOAuthAccount(account) refreshOAuthStatus() } ) } .sheet(item: $oauthLoginProvider) { provider in OAuthLoginSheet( provider: provider, onDone: { oauthLoginProvider = nil Task { await vm.refreshOAuthAccounts() await refreshLocalModels() ensureServiceRunningIfNeeded(force: true) } }, onCancel: { proxyService.cancelLogin() oauthLoginProvider = nil } ) } .sheet( item: Binding( get: { proxyService.loginPrompt != nil && oauthLoginProvider != nil ? proxyService.loginPrompt : nil }, set: { _ in proxyService.loginPrompt = nil } ) ) { prompt in LoginPromptSheet( prompt: prompt, onSubmit: { input in proxyService.submitLoginInput(input) proxyService.loginPrompt = nil }, onCancel: { proxyService.loginPrompt = nil }, onStop: { proxyService.cancelLogin() proxyService.loginPrompt = nil } ) } .codmatePresentationSizingIfAvailable() .alert("OAuth Provider Authorization Risk Warning", isPresented: $showOAuthRiskWarning) { Button("I Understand and Accept the Risk", role: .destructive) { confirmOAuthLogin() } Button("Cancel", role: .cancel) { pendingOAuthProvider = nil } } message: { Text( """ Adding OAuth providers requires separate authorization through CLIProxyAPI, which is isolated from CodMate's main CLI authorization. ⚠️ **Potential Risks:** • Account suspension or termination by the provider • Violation of provider terms of service • Loss of access to services By proceeding, you acknowledge that you understand these risks and will use this feature at your own discretion. **Note:** The ability to add OAuth providers may be partially or fully removed in future versions of CodMate. """) } .task { await vm.loadAll() await vm.loadTemplates() getLocalIPAddress() loadPublicKey() refreshOAuthStatus() await refreshLocalModels() ensureServiceRunningIfNeeded() } .onChange(of: preferences.localServerEnabled) { enabled in if enabled { ensureServiceRunningIfNeeded(force: true) } } // Removed rerouteBuiltIn/reroute3P onChange handlers - all providers now use Auto-Proxy mode .onChange(of: preferences.oauthProvidersEnabled) { _ in refreshOAuthStatus() Task { await refreshLocalModels() } ensureServiceRunningIfNeeded() } .onChange(of: preferences.apiKeyProvidersEnabled) { _ in Task { await refreshLocalModels() } ensureServiceRunningIfNeeded() } .onChange(of: proxyService.isRunning) { running in if running { oauthAutoStartFailed = false } Task { await refreshLocalModels() } } .confirmationDialog( "Delete Provider", isPresented: Binding( get: { pendingDeleteId != nil }, set: { if !$0 { pendingDeleteId = nil pendingDeleteName = nil } } ), titleVisibility: .visible ) { Button("Delete", role: .destructive) { if let id = pendingDeleteId { Task { await vm.delete(id: id, preferences: preferences) } } } Button("Cancel", role: .cancel) {} } message: { if let name = pendingDeleteName { Text("Are you sure you want to delete \"\(name)\"? This action cannot be undone.") } else { Text("Are you sure you want to delete this provider? This action cannot be undone.") } } .confirmationDialog( "Sign Out", isPresented: Binding( get: { pendingDeleteAccount != nil }, set: { if !$0 { pendingDeleteAccount = nil } } ), titleVisibility: .visible ) { Button("Sign Out", role: .destructive) { if let account = pendingDeleteAccount { Task { await vm.deleteOAuthAccount(account) } } } Button("Cancel", role: .cancel) {} } message: { if let email = pendingDeleteAccount?.email { Text("Are you sure you want to sign out \"\(email)\"? Credentials will be removed.") } else { Text("Are you sure you want to sign out? Credentials will be removed.") } } } private var header: some View { VStack(alignment: .leading, spacing: 6) { Text("Providers Settings") .font(.title2) .fontWeight(.bold) Text("Manage API key and OAuth providers for Codex and Claude Code.") .font(.subheadline) .foregroundColor(.secondary) } } // Computed properties for sorted providers private var sortedOAuthProviders: [LocalAuthProvider] { LocalAuthProvider.allCases.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } } private var sortedTemplates: [ProvidersRegistryService.Provider] { vm.templates.sorted { let name0 = ($0.name?.isEmpty == false ? $0.name! : $0.id).lowercased() let name1 = ($1.name?.isEmpty == false ? $1.name! : $1.id).lowercased() return name0.localizedCaseInsensitiveCompare(name1) == .orderedAscending } } private var providersList: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { // OAuth Section VStack(alignment: .leading, spacing: 10) { Text("OAuth").font(.headline).fontWeight(.semibold) if !vm.oauthAccounts.isEmpty { settingsCard { VStack(alignment: .leading, spacing: 0) { ForEach(Array(vm.oauthAccounts.enumerated()), id: \.element.id) { index, account in if index > 0 { Divider().padding(.vertical, 4) } HStack(alignment: .center, spacing: 0) { // Left: Icon + Name HStack(alignment: .center, spacing: 8) { LocalAuthProviderIconView( provider: account.provider, size: 16, cornerRadius: 4 ) .frame(width: 20) Text(account.provider.displayName) .font(.body.weight(.medium)) } .frame(minWidth: 140, alignment: .leading) Spacer(minLength: 16) // Center: Email/Status VStack(alignment: .leading, spacing: 2) { if let email = account.email, !email.isEmpty { Text(email) .font(.caption) .foregroundStyle(.secondary) } Text("Logged In") .font(.caption2) .foregroundStyle(.green) } .frame(maxWidth: .infinity, alignment: .leading) // Right: Info + Toggle Button { oauthInfoAccount = account } label: { Image(systemName: "info.circle") .font(.body) } .buttonStyle(.borderless) .help("View details") Toggle("", isOn: bindingForOAuthAccount(account: account)) .toggleStyle(.switch) .labelsHidden() .controlSize(.small) .padding(.leading, 8) } .padding(.vertical, 4) .contentShape(Rectangle()) .contextMenu { Button { oauthInfoAccount = account } label: { Text("Info") } Divider() Button(role: .destructive) { pendingDeleteAccount = account } label: { Text("Sign Out") } } } } } } else { settingsCard { VStack(spacing: 12) { Image(systemName: "person.crop.circle.badge.plus") .font(.system(size: 32)) .foregroundStyle(.secondary) Text("No OAuth Accounts") .font(.subheadline) .fontWeight(.medium) Text("Click + to add an account") .font(.caption) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity) .frame(minHeight: 100) .padding(.vertical, 20) } } // OAuth Add Button (Right aligned below list) HStack { Spacer() ProviderAddMenu( title: "Add OAuth Account", helpText: "Add OAuth Account", items: sortedOAuthProviders.map { provider in ProviderMenuItem( id: provider.id, name: provider.displayName, icon: .oauth(provider), action: { startOAuthLogin(provider) } ) } ) } } // API Key Section VStack(alignment: .leading, spacing: 10) { Text("API Key").font(.headline).fontWeight(.semibold) if !vm.providers.isEmpty { settingsCard { VStack(alignment: .leading, spacing: 0) { ForEach(Array(vm.providers.enumerated()), id: \.element.id) { index, p in if index > 0 { Divider().padding(.vertical, 4) } HStack(alignment: .center, spacing: 0) { // Left: Icon + Name HStack(alignment: .center, spacing: 8) { APIKeyProviderIconView( provider: p, size: 16, cornerRadius: 4, isSelected: vm.activeCodexProviderId == p.id ) .frame(width: 20) Text(p.name?.isEmpty == false ? p.name! : p.id) .font(.body.weight(.medium)) } .frame(minWidth: 140, alignment: .leading) Spacer(minLength: 16) VStack(alignment: .leading, spacing: 2) { endpointBlock( label: "Codex", value: p.connectors[ ProvidersRegistryService.Consumer.codex.rawValue ]? .baseURL ) endpointBlock( label: "Claude", value: p.connectors[ ProvidersRegistryService.Consumer.claudeCode .rawValue]? .baseURL ) } .frame(maxWidth: .infinity, alignment: .leading) // Right: Edit + Toggle Button { vm.selectedId = p.id vm.showEditor = true } label: { Image(systemName: "pencil") .font(.body) } .buttonStyle(.borderless) .help("Edit provider") Toggle("", isOn: bindingForAPIKeyProvider(providerId: p.id)) .toggleStyle(.switch) .labelsHidden() .controlSize(.small) .padding(.leading, 8) } .padding(.vertical, 4) .contentShape(Rectangle()) .contextMenu { Button("Edit…") { vm.showEditor = true vm.selectedId = p.id } Divider() Button(role: .destructive) { pendingDeleteId = p.id pendingDeleteName = p.name?.isEmpty == false ? p.name : p.id } label: { Text("Delete") } } } } } } else { settingsCard { VStack(spacing: 12) { Image(systemName: "key") .font(.system(size: 32)) .foregroundStyle(.secondary) Text("No API Key Providers") .font(.subheadline) .fontWeight(.medium) Text("Click + to configure an API key provider") .font(.caption) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity) .frame(minHeight: 100) .padding(.vertical, 20) } } // API Key Add Button (Right aligned below list) HStack { Spacer() ProviderAddMenu( title: "Add API Key Provider", helpText: "Add API Key Provider", items: sortedTemplates.map { template in ProviderMenuItem( id: template.id, name: template.name?.isEmpty == false ? template.name! : template.id, icon: .apiKey(template), action: { vm.startFromTemplate(template) } ) }, emptyMessage: "No templates found", customAction: ("Custom…", { vm.startNewProvider() }) ) } } } .padding(.bottom, 20) } } private var proxyCapabilitiesSection: some View { VStack(alignment: .leading, spacing: 20) { // 1. CLI Proxy API Status VStack(alignment: .leading, spacing: 10) { Text("CLI Proxy API").font(.headline).fontWeight(.semibold) settingsCard { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { GridRow { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 6) { Image(systemName: "bolt.horizontal") .frame(width: 16, alignment: .leading) Text("Service Status") .font(.subheadline).fontWeight(.medium) } Text( "All providers are routed through CLI Proxy API when enabled in the Providers list above." ) .font(.caption).foregroundColor(.secondary) .padding(.leading, 22) } HStack(spacing: 8) { statusPill( proxyService.isRunning ? "Running" : "Stopped", active: proxyService.isRunning) if proxyService.isRunning { Button("Restart") { restartProxyService() } .buttonStyle(.bordered) } else { Button("Start") { startProxyService() } .buttonStyle(.borderedProminent) .disabled(!proxyService.isBinaryInstalled) } } .frame(maxWidth: .infinity, alignment: .trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 6) { Image(systemName: "number") .frame(width: 16, alignment: .leading) Text("Port") .font(.subheadline).fontWeight(.medium) } Text("Server port number for CLI Proxy API") .font(.caption).foregroundColor(.secondary) .padding(.leading, 22) } TextField( "Port", value: $preferences.localServerPort, formatter: NumberFormatter() ) .textFieldStyle(.roundedBorder) .font(.system(.caption, design: .monospaced)) .frame(width: 80) .frame(maxWidth: .infinity, alignment: .trailing) } } } } // 2. Public Access VStack(alignment: .leading, spacing: 10) { Text("Public Access").font(.headline).fontWeight(.semibold) settingsCard { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { GridRow { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 6) { Image(systemName: "network") .frame(width: 16, alignment: .leading) Text("Public Access") .font(.subheadline).fontWeight(.medium) } Text("Expose a unified API endpoint for all providers") .font(.caption).foregroundColor(.secondary) .padding(.leading, 22) } Toggle("", isOn: $preferences.localServerEnabled) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) .frame(maxWidth: .infinity, alignment: .trailing) } if preferences.localServerEnabled { gridDivider GridRow { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 6) { Image(systemName: "link") .frame(width: 16, alignment: .leading) Text("Public URL") .font(.subheadline).fontWeight(.medium) } Text("Publicly accessible server URL") .font(.caption).foregroundColor(.secondary) .padding(.leading, 22) } HStack(spacing: 6) { Text("http://\(localIP):\(String(preferences.localServerPort))") .font(.system(.caption, design: .monospaced)) .textSelection(.enabled) Button(action: { copyToClipboard( "http://\(localIP):\(String(preferences.localServerPort))" ) }) { Image(systemName: "doc.on.doc") } .buttonStyle(.plain) } .frame(maxWidth: .infinity, alignment: .trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 6) { Image(systemName: "key") .frame(width: 16, alignment: .leading) Text("Public Key") .font(.subheadline).fontWeight(.medium) } Text("API key for public access authentication") .font(.caption).foregroundColor(.secondary) .padding(.leading, 22) } VStack(alignment: .trailing, spacing: 4) { HStack(spacing: 6) { Button(action: regeneratePublicKey) { Image(systemName: "arrow.clockwise") } .buttonStyle(.plain) HStack(spacing: 4) { TextField("Key", text: $publicAPIKey) .textFieldStyle(.roundedBorder) .font(.system(.caption, design: .monospaced)) .onChange(of: publicAPIKey) { newValue in let trimmed = newValue.trimmingCharacters( in: .whitespacesAndNewlines) guard trimmed.count >= minPublicKeyLength else { return } proxyService.updatePublicAPIKey(trimmed) } Button(action: { copyToClipboard(publicAPIKey) }) { Image(systemName: "doc.on.doc") } .buttonStyle(.plain) } .frame(width: 320) } if publicAPIKey.trimmingCharacters(in: .whitespacesAndNewlines) .count < minPublicKeyLength { Text("Minimum \(minPublicKeyLength) characters") .font(.caption) .foregroundColor(.red) } } .frame(maxWidth: .infinity, alignment: .trailing) } } } } } // 3. Config Reference VStack(alignment: .leading, spacing: 10) { Text("Config Reference").font(.headline).fontWeight(.semibold) settingsCard { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { GridRow { VStack(alignment: .leading, spacing: 2) { Label("GitHub Repository", systemImage: "square.stack.3d.up") .font(.subheadline).fontWeight(.medium) Text("CLIProxyAPI source code repository") .font(.caption).foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } HStack(spacing: 6) { Link( destination: URL( string: "https://github.com/router-for-me/CLIProxyAPI")! ) { HStack(spacing: 6) { Text("https://github.com/router-for-me/CLIProxyAPI") .font(.system(.caption, design: .monospaced)) .foregroundColor(.secondary) Image(systemName: "arrow.up.right.square") .font(.system(size: 12)) .foregroundColor(.secondary) .opacity(0.6) } } } .frame(maxWidth: .infinity, alignment: .trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Documentation", systemImage: "book") .font(.subheadline).fontWeight(.medium) Text("CLIProxyAPI official documentation") .font(.caption).foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } HStack(spacing: 6) { Link(destination: URL(string: "https://help.router-for.me/")!) { HStack(spacing: 6) { Text("https://help.router-for.me/") .font(.system(.caption, design: .monospaced)) .foregroundColor(.secondary) Image(systemName: "arrow.up.right.square") .font(.system(size: 12)) .foregroundColor(.secondary) .opacity(0.6) } } } .frame(maxWidth: .infinity, alignment: .trailing) } } } } } } private var cliProxyAdvancedSection: some View { VStack(alignment: .leading, spacing: 20) { // CLI Proxy API Installation & Diagnostics VStack(alignment: .leading, spacing: 10) { Text("Advanced").font(.headline).fontWeight(.semibold) settingsCard { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { // Conflict warning (only show if there's a conflict) if let warning = proxyService.conflictWarning { GridRow { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) Text(warning) .font(.caption) .foregroundColor(.secondary) } .gridCellColumns(3) } gridDivider } GridRow { VStack(alignment: .leading, spacing: 0) { Label("Binary Location", systemImage: "app.badge") .font(.subheadline).fontWeight(.medium) Text("CLIProxyAPI binary executable path") .font(.caption).foregroundColor(.secondary) } Text(proxyService.binaryFilePath) .font(.system(.caption, design: .monospaced)) .lineLimit(1) .truncationMode(.middle) .frame(maxWidth: .infinity, alignment: .trailing) .onTapGesture(count: 2) { revealCLIProxyBinaryInFinder() } .help("Double-click to reveal in Finder") HStack(spacing: 8) { if proxyService.isInstalling { ProgressView() .scaleEffect(0.6) .frame(width: 14, height: 14) Text("Installing") .font(.caption) .foregroundColor(.secondary) } else { Button(cliProxyActionButtonTitle) { Task { if proxyService.binarySource == .homebrew { try? await proxyService.brewUpgrade() } else { try? await proxyService.install() } } } .buttonStyle(.borderedProminent) .tint(cliProxyActionButtonColor) } } .frame(width: 90, alignment: .trailing) .disabled(proxyService.isInstalling) } gridDivider GridRow { VStack(alignment: .leading, spacing: 0) { Label("Config File", systemImage: "doc.text") .font(.subheadline).fontWeight(.medium) Text("CLIProxyAPI configuration file") .font(.caption).foregroundColor(.secondary) } Text(cliProxyConfigFilePath) .font(.system(.caption, design: .monospaced)) .lineLimit(1) .truncationMode(.middle) .frame(maxWidth: .infinity, alignment: .trailing) Button("Reveal") { revealCLIProxyConfigInFinder() } .buttonStyle(.bordered) .frame(width: 90, alignment: .trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 0) { Label("Auth Directory", systemImage: "folder") .font(.subheadline).fontWeight(.medium) Text("OAuth credential storage") .font(.caption).foregroundColor(.secondary) } Text(cliProxyAuthDirPath) .font(.system(.caption, design: .monospaced)) .lineLimit(1) .truncationMode(.middle) .frame(maxWidth: .infinity, alignment: .trailing) Button("Reveal") { revealCLIProxyAuthDirInFinder() } .buttonStyle(.bordered) .frame(width: 90, alignment: .trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 0) { Label("Logs", systemImage: "doc.plaintext") .font(.subheadline).fontWeight(.medium) Text("CLIProxyAPI log files directory") .font(.caption).foregroundColor(.secondary) } Text(cliProxyLogsPath) .font(.system(.caption, design: .monospaced)) .lineLimit(1) .truncationMode(.middle) .frame(maxWidth: .infinity, alignment: .trailing) Button("Reveal") { revealCLIProxyLogsInFinder() } .buttonStyle(.bordered) .frame(width: 90, alignment: .trailing) } } } } } } @ViewBuilder private func endpointBlock(label: String, value: String?) -> some View { HStack(spacing: 6) { Text("\(label):") .font(.caption) .foregroundStyle(.secondary) .frame(width: 50, alignment: .leading) Text((value?.isEmpty == false) ? value! : "—") .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) } } private func startOAuthLogin(_ provider: LocalAuthProvider) { guard oauthLoginProvider == nil else { return } // Show risk warning before starting OAuth login pendingOAuthProvider = provider showOAuthRiskWarning = true } private func confirmOAuthLogin() { guard let provider = pendingOAuthProvider else { return } pendingOAuthProvider = nil oauthLoginProvider = provider } @ViewBuilder private func oauthStatusBadge(_ provider: LocalAuthProvider, isLoggedIn: Bool) -> some View { if isLoggedIn { Text("Logged In").font(.caption).foregroundStyle(.green) } else { Text("Logged Out").font(.caption).foregroundStyle(.secondary) } } private func refreshOAuthStatus() { Task { await vm.refreshOAuthAccounts() } } private func ensureServiceRunningIfNeeded(force: Bool = false) { let hasLoggedInOAuth = !vm.oauthAccounts.isEmpty let hasEnabledProviders = !vm.oauthAccounts.isEmpty || !vm.providers.isEmpty let shouldEnsure = force || hasLoggedInOAuth || preferences.localServerEnabled || hasEnabledProviders guard shouldEnsure else { return } guard !proxyService.isRunning else { return } oauthAutoStartFailed = false Task { do { try await proxyService.start() await MainActor.run { oauthAutoStartFailed = false } } catch { await MainActor.run { oauthAutoStartFailed = true } } } } private func restartProxyService() { Task { if proxyService.isRunning { proxyService.stop() try? await Task.sleep(nanoseconds: 500_000_000) } try? await proxyService.start() } } private func startProxyService() { Task { try? await proxyService.start() } } private func statusPill(_ text: String, active: Bool) -> some View { Text(text) .font(.caption) .padding(.horizontal, 8) .padding(.vertical, 3) .background(active ? Color.green.opacity(0.15) : Color.secondary.opacity(0.12)) .foregroundStyle(active ? Color.green : Color.secondary) .clipShape(Capsule()) } private func refreshLocalModels() async { localModels = await proxyService.fetchLocalModels(forceRefresh: true) } private func modelsForOAuthProvider(_ provider: LocalAuthProvider) -> [String] { guard let target = builtInProvider(for: provider) else { return [] } var seen: Set = [] var ids: [String] = [] for model in localModels { if builtInProvider(for: model) == target { if !seen.contains(model.id) { seen.insert(model.id) ids.append(model.id) } } } return ids.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } } private func builtInProvider(for provider: LocalAuthProvider) -> LocalServerBuiltInProvider? { switch provider { case .codex: return .openai case .claude: return .anthropic case .gemini: return .gemini case .antigravity: return .antigravity case .qwen: return .qwen } } private func builtInProvider(for model: CLIProxyService.LocalModel) -> LocalServerBuiltInProvider? { let hint = model.provider ?? model.source ?? model.owned_by if let hint, let provider = LocalServerBuiltInProvider.allCases.first(where: { $0.matchesOwnedBy(hint) }) { return provider } let modelId = model.id if let provider = LocalServerBuiltInProvider.allCases.first(where: { $0.matchesModelId(modelId) }) { return provider } return nil } private var gridDivider: some View { Divider() } private func getLocalIPAddress() { var address: String? var ifaddr: UnsafeMutablePointer? if getifaddrs(&ifaddr) == 0 { var ptr = ifaddr while ptr != nil { defer { ptr = ptr?.pointee.ifa_next } let interface = ptr?.pointee let addrFamily = interface?.ifa_addr.pointee.sa_family if addrFamily == UInt8(AF_INET) { let name = String(cString: (interface?.ifa_name)!) if name == "en0" || name.starts(with: "en") { var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST)) getnameinfo( interface?.ifa_addr, socklen_t((interface?.ifa_addr.pointee.sa_len)!), &hostname, socklen_t(hostname.count), nil, socklen_t(0), NI_NUMERICHOST) address = String(cString: hostname) } } } freeifaddrs(ifaddr) } localIP = address ?? "127.0.0.1" } private func loadPublicKey() { let key = proxyService.resolvePublicAPIKey() publicAPIKey = key proxyService.updatePublicAPIKey(key) } private func regeneratePublicKey() { let generated = proxyService.generatePublicAPIKey(length: minPublicKeyLength) publicAPIKey = generated proxyService.updatePublicAPIKey(generated) } private func copyToClipboard(_ text: String) { let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(text, forType: .string) } // MARK: - CLI Proxy API Path Helpers private var cliProxyConfigFilePath: String { let appSupport = FileManager.default.urls( for: .applicationSupportDirectory, in: .userDomainMask ).first! let configPath = appSupport.appendingPathComponent("CodMate/config.yaml") return configPath.path } private var cliProxyAuthDirPath: String { let home = FileManager.default.homeDirectoryForCurrentUser return home.appendingPathComponent(".codmate/auth").path } private var cliProxyLogsPath: String { let home = FileManager.default.homeDirectoryForCurrentUser return home.appendingPathComponent(".codmate/auth/logs").path } private var cliProxyBinarySourceDescription: String { switch proxyService.binarySource { case .none: return "No binary detected" case .homebrew: return "Homebrew installation (managed via brew services)" case .codmate: return "CodMate built-in installation" case .other: return "Other installation (potential conflicts)" } } private var cliProxyBinarySourceLabel: String { switch proxyService.binarySource { case .none: return "Not Detected" case .homebrew: return "Homebrew" case .codmate: return "CodMate" case .other: return "Other" } } private var cliProxyBinarySourceColor: Color { switch proxyService.binarySource { case .none: return .secondary case .homebrew: return .green case .codmate: return .blue case .other: return .orange } } private var cliProxyActionButtonTitle: String { switch proxyService.binarySource { case .none: return "Install" case .homebrew: return proxyService.isBinaryInstalled ? "Upgrade" : "Install" case .codmate: return proxyService.isBinaryInstalled ? "Reinstall" : "Install" case .other: return proxyService.isBinaryInstalled ? "Reinstall" : "Install" } } private var cliProxyActionButtonColor: Color { switch proxyService.binarySource { case .none: return .blue case .homebrew: return .green case .codmate: return proxyService.isBinaryInstalled ? .red : .blue case .other: return proxyService.isBinaryInstalled ? .red : .blue } } private func revealCLIProxyConfigInFinder() { let url = URL(fileURLWithPath: cliProxyConfigFilePath) NSWorkspace.shared.selectFile( url.path, inFileViewerRootedAtPath: url.deletingLastPathComponent().path) } private func revealCLIProxyAuthDirInFinder() { let home = FileManager.default.homeDirectoryForCurrentUser let authPath = home.appendingPathComponent(".codmate/auth") NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: authPath.path) } private func revealCLIProxyLogsInFinder() { let home = FileManager.default.homeDirectoryForCurrentUser let logsPath = home.appendingPathComponent(".codmate/auth/logs") NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: logsPath.path) } private func revealCLIProxyBinaryInFinder() { let url = URL(fileURLWithPath: proxyService.binaryFilePath) NSWorkspace.shared.selectFile( url.path, inFileViewerRootedAtPath: url.deletingLastPathComponent().path) } // MARK: - Helper Views @ViewBuilder private func settingsCard(@ViewBuilder _ content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 8) { content() } .padding(10) .background(Color(nsColor: .separatorColor).opacity(0.35)) .cornerRadius(10) } // old tab panes removed to keep Providers view pure. Editing happens in a sheet. private var bindingsPane: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { GroupBox("Codex") { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { GridRow { Text("Active Provider").font(.subheadline).fontWeight(.medium) Picker("", selection: $vm.activeCodexProviderId) { Text("(Built‑in)").tag(String?.none) ForEach(vm.providers, id: \.id) { p in Text(p.name?.isEmpty == false ? p.name! : p.id).tag( String?(p.id)) } } .labelsHidden() .frame(maxWidth: .infinity, alignment: .trailing) .onChange(of: vm.activeCodexProviderId) { newVal in Task { await vm.applyActiveCodexProvider(newVal) } } } GridRow { Text("Default Model").font(.subheadline).fontWeight(.medium) HStack(spacing: 8) { TextField("gpt-5.2-codex", text: $vm.defaultCodexModel) .onSubmit { Task { await vm.applyDefaultCodexModel() } } let ids = vm.catalogModelIdsForActiveCodex() if !ids.isEmpty { Menu { ForEach(ids, id: \.self) { mid in Button(mid) { vm.defaultCodexModel = mid Task { await vm.applyDefaultCodexModel() } } } } label: { Label("From Catalog", systemImage: "chevron.down") } } } .frame(maxWidth: .infinity, alignment: .trailing) } } } GroupBox("Claude Code") { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { GridRow { Text("Active Provider").font(.subheadline).fontWeight(.medium) Picker("", selection: $vm.activeClaudeProviderId) { Text("(None)").tag(String?.none) ForEach(vm.providers, id: \.id) { p in Text(p.name?.isEmpty == false ? p.name! : p.id).tag( String?(p.id)) } } .labelsHidden() .frame(maxWidth: .infinity, alignment: .trailing) .onChange(of: vm.activeClaudeProviderId) { newVal in Task { await vm.applyActiveClaudeProvider(newVal) } } } } } Text(vm.lastError ?? "").foregroundStyle(.red) } .padding(.horizontal, 8) .padding(.vertical, 8) } } private func bindingForOAuthAccount(account: CLIProxyService.OAuthAccount) -> Binding { Binding( get: { preferences.oauthAccountsEnabled.contains(account.id) }, set: { newValue in var enabled = preferences.oauthAccountsEnabled if newValue { enabled.insert(account.id) } else { enabled.remove(account.id) } preferences.oauthAccountsEnabled = enabled // Note: CLI Proxy API's Management API does not provide an endpoint to enable/disable auth files // The enabled/disabled state is managed locally via oauthAccountsEnabled setting // CLIProxyAPI will load all auth files, and CodMate filters which ones to use based on local settings } ) } private func bindingForAPIKeyProvider(providerId: String) -> Binding { Binding( get: { preferences.apiKeyProvidersEnabled.contains(providerId) }, set: { newValue in var enabled = preferences.apiKeyProvidersEnabled if newValue { enabled.insert(providerId) } else { enabled.remove(providerId) } preferences.apiKeyProvidersEnabled = enabled // Sync third-party providers to CLIProxyAPI config // Only enabled providers will be written to config.yaml Task { await CLIProxyService.shared.syncThirdPartyProviders( enabledProviderIds: enabled) // Refresh local models immediately after config sync await self.refreshLocalModels() } } ) } } // MARK: - Editor Sheet (Standard vs Advanced) private struct ProviderEditorSheet: View { @ObservedObject var vm: ProvidersVM @ObservedObject var preferences: SessionPreferencesStore @Environment(\.dismiss) private var dismiss @State private var selectedTab: EditorTab = .basic @State private var isTesting: Bool = false @State private var selectedModelRowIDs: Set = [] @State private var showDeleteSelectedModelsAlert: Bool = false @State private var showAPIKey: Bool = false private enum EditorTab { case basic, models } var body: some View { VStack(alignment: .leading, spacing: 16) { HStack(alignment: .firstTextBaseline) { Text(vm.isNewProvider ? "New Provider" : "Edit Provider").font(.title3).fontWeight( .semibold) Spacer() } TabView(selection: $selectedTab) { SettingsTabContent { basicTab } .tabItem { Label("Basic", systemImage: "slider.horizontal.3") } .tag(EditorTab.basic) SettingsTabContent { modelsTab } .tabItem { Label("Models", systemImage: "list.bullet.rectangle") } .tag(EditorTab.models) } .frame(minHeight: 260) if selectedTab == .basic { // Enhanced test results UI if !vm.testResults.isEmpty { VStack(alignment: .leading, spacing: 8) { ForEach(Array(vm.testResults.keys.sorted()), id: \.self) { endpoint in if let result = vm.testResults[endpoint] { TestResultCard(label: endpoint, result: result) } } } .padding(.top, 8) } else if let text = vm.testResultText, !text.isEmpty { Text(text) .font(.caption) .foregroundStyle(.secondary) } if let error = vm.lastError, !error.isEmpty { Text(error).foregroundStyle(.red).font(.caption) } } HStack { if selectedTab == .basic { Button { if !isTesting { isTesting = true Task { await vm.testEditingFields() isTesting = false } } } label: { if isTesting { ProgressView().controlSize(.small) } else { Text("Test") } } .buttonStyle(.bordered) .disabled(isTesting) } Spacer() Button("Cancel") { dismiss() } Button("Save") { Task { if await vm.saveEditing(preferences: preferences) { dismiss() } } } .buttonStyle(.borderedProminent) .disabled(!vm.canSave) } } .padding(16) .frame( minWidth: 640, idealWidth: 760, maxWidth: .infinity, minHeight: 360, maxHeight: .infinity, alignment: .topLeading ) .onAppear { vm.loadModelRowsFromSelected() } } private var basicTab: some View { VStack(alignment: .leading, spacing: 12) { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { // Icon picker (only for user-created providers, not bundled/preset providers) // Bundled providers (Anthropic, DeepSeek, GLM, K2, MiniMax, OpenAI, OpenRouter) use preset brand icons if vm.isNewProvider || (!vm.isEditingBundledProvider() && vm.editingProviderBinding()?.managedByCodMate == true) { GridRow { VStack(alignment: .leading, spacing: 4) { Text("Icon").font(.subheadline).fontWeight(.medium) Text( vm.presetIconName != nil ? "Provider configured icon" : "SF Symbol icon for this provider" ) .font(.caption) .foregroundStyle(.secondary) } iconPickerView } } GridRow { VStack(alignment: .leading, spacing: 4) { Text("Name").font(.subheadline).fontWeight(.medium) Text("Display label shown in lists.").font(.caption).foregroundStyle( .secondary) } TextField("Provider name", text: vm.binding(for: \.providerName)) } GridRow { VStack(alignment: .leading, spacing: 4) { Text("Codex Base URL").font(.subheadline).fontWeight(.medium) Text("OpenAI-compatible endpoint").font(.caption).foregroundStyle( .secondary) } TextField("https://api.example.com/v1", text: vm.binding(for: \.codexBaseURL)) } GridRow { VStack(alignment: .leading, spacing: 4) { Text("Claude Base URL").font(.subheadline).fontWeight(.medium) Text("Anthropic-compatible endpoint").font(.caption).foregroundStyle( .secondary) } TextField( "https://gateway.example.com/anthropic", text: vm.binding(for: \.claudeBaseURL)) } GridRow { VStack(alignment: .leading, spacing: 4) { Text("API Key Env").font(.subheadline).fontWeight(.medium) Text("Environment variable name") .font(.caption).foregroundStyle(.secondary) } HStack(spacing: 8) { Group { if showAPIKey { TextField("OPENAI_API_KEY", text: vm.binding(for: \.codexEnvKey)) } else { SecureField("OPENAI_API_KEY", text: vm.binding(for: \.codexEnvKey)) } } .frame(maxWidth: .infinity) .overlay(alignment: .trailing) { Button { showAPIKey.toggle() } label: { Image(systemName: showAPIKey ? "eye.slash.fill" : "eye.fill") .foregroundStyle(.secondary) .font(.body) } .buttonStyle(.plain) .help(showAPIKey ? "Hide API key" : "Show API key") .padding(.horizontal, 8) .padding(.vertical, 4) .background { // 添加背景确保图标在文本之上清晰可见 RoundedRectangle(cornerRadius: 4) .fill(Color(nsColor: .controlBackgroundColor)) } .zIndex(1) } if let keyURL = vm.providerKeyURL { Link("Get Key", destination: keyURL) .font(.caption) .help("Open provider API key management page") } } } GridRow { VStack(alignment: .leading, spacing: 4) { Text("Wire API").font(.subheadline).fontWeight(.medium) Text("Protocol for Codex CLI") .font(.caption).foregroundStyle(.secondary) } Picker("", selection: vm.binding(for: \.codexWireAPI)) { Text("Chat").tag("chat") Text("Responses").tag("responses") } .pickerStyle(.segmented) } } if let docs = vm.providerDocsURL { Link("View API documentation", destination: docs) .font(.caption) } } .frame(maxWidth: .infinity, alignment: .topLeading) } @State private var showIconPicker = false private var iconPickerView: some View { HStack(spacing: 8) { // If preset icon exists, show read-only icon display if vm.presetIconName != nil { if let presetIconName = vm.presetIconName, let nsImage = ProviderIconThemeHelper.menuImage( named: presetIconName, size: NSSize(width: 18, height: 18)) { Image(nsImage: nsImage) .resizable() .interpolation(.high) .aspectRatio(contentMode: .fit) .frame(width: 18, height: 18) .padding(.horizontal, 8) .padding(.vertical, 4) .background(Color(nsColor: .controlBackgroundColor)) .cornerRadius(6) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) ) } } else { // Icon display button (only for custom providers) Button { showIconPicker = true } label: { HStack(spacing: 6) { // Custom SF Symbol icon if let iconName = vm.customIcon ?? defaultIconForProviderName(vm.providerName) { Image(systemName: iconName) .font(.system(size: 18)) .frame(width: 18, height: 18) } // Fallback: Empty circle else { Circle() .fill(Color.secondary.opacity(0.2)) .frame(width: 18, height: 18) } Image(systemName: "chevron.down") .font(.system(size: 10)) .foregroundStyle(.secondary) } .padding(.horizontal, 8) .padding(.vertical, 4) .background(Color(nsColor: .controlBackgroundColor)) .cornerRadius(6) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) ) } .buttonStyle(.plain) .popover(isPresented: $showIconPicker, arrowEdge: .bottom) { iconPickerPopover } } } } private var iconPickerPopover: some View { VStack(alignment: .leading, spacing: 8) { Text("Select Icon") .font(.headline) .padding(.top, 16) Divider() LazyVGrid( columns: Array(repeating: GridItem(.fixed(40), spacing: 8), count: 6), spacing: 8 ) { ForEach(sfSymbolsIndices, id: \.self) { iconName in Button { vm.customIcon = iconName vm.presetIconName = nil // Clear preset icon when user selects custom SF Symbol showIconPicker = false } label: { Image(systemName: iconName) .font(.system(size: 24)) .frame(width: 40, height: 40) .background(Color(nsColor: .controlBackgroundColor)) .cornerRadius(6) } .buttonStyle(.plain) } } .frame(height: 280) } .frame(width: 350) .padding(.bottom, 16) .padding(.horizontal, 16) } // SF Symbols indices (letter-based icons) private var sfSymbolsIndices: [String] { let letters = "abcdefghijklmnopqrstuvwxyz" return letters.map { "\($0).circle.fill" } } // Generate default icon from first letter of provider name private func defaultIconForProviderName(_ name: String) -> String? { guard let firstChar = name.lowercased().first, firstChar.isLetter else { return nil } return "\(firstChar).circle.fill" } private var modelsTab: some View { VStack(alignment: .leading, spacing: 10) { HStack { Text("Models").font(.subheadline).fontWeight(.medium) Spacer() HStack(spacing: 0) { Button { vm.addModelRow() } label: { Text("+") .frame(width: 18, height: 16) } .buttonStyle(.bordered) Button { if !selectedModelRowIDs.isEmpty { showDeleteSelectedModelsAlert = true } } label: { Text("–") .frame(width: 18, height: 16) } .buttonStyle(.bordered) .disabled(selectedModelRowIDs.isEmpty) } .clipShape(RoundedRectangle(cornerRadius: 6)) } Table(vm.modelRows, selection: $selectedModelRowIDs) { TableColumn("Default") { row in Toggle( "", isOn: Binding( get: { vm.defaultModelRowID == row.id }, set: { isOn in vm.setDefaultModelRow( rowID: isOn ? row.id : nil, modelId: isOn ? row.modelId : nil) } ) ) .labelsHidden() .controlSize(.small) .disabled(row.modelId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) }.width(50) TableColumn("Model ID") { row in if let binding = vm.bindingModelId(for: row.id) { TextField("vendor model id", text: binding) .onChange(of: binding.wrappedValue) { newValue in vm.handleModelIDChange(for: row.id, newValue: newValue) } } }.width(min: 120, ideal: 200) TableColumn("Reasoning") { row in if let b = vm.bindingBool(for: row.id, keyPath: \.reasoning) { Toggle("", isOn: b).labelsHidden().controlSize(.small) } }.width(60) TableColumn("Tool Use") { row in if let b = vm.bindingBool(for: row.id, keyPath: \.toolUse) { Toggle("", isOn: b).labelsHidden().controlSize(.small) } }.width(50) TableColumn("Vision") { row in if let b = vm.bindingBool(for: row.id, keyPath: \.vision) { Toggle("", isOn: b).labelsHidden().controlSize(.small) } }.width(50) TableColumn("Long Ctx") { row in if let b = vm.bindingBool(for: row.id, keyPath: \.longContext) { Toggle("", isOn: b).labelsHidden().controlSize(.small) } }.width(60) } .environment(\.defaultMinListRowHeight, 26) .controlSize(.small) } .alert("Delete selected models?", isPresented: $showDeleteSelectedModelsAlert) { Button("Delete", role: .destructive) { for id in selectedModelRowIDs { vm.deleteModelRow(rowKey: id) } selectedModelRowIDs.removeAll() } Button("Cancel", role: .cancel) {} } message: { Text("This action cannot be undone.") } } } private struct OAuthProviderInfoSheet: View { let provider: LocalAuthProvider let isLoggedIn: Bool let accounts: [CLIProxyService.OAuthAccount] let selectedAccount: CLIProxyService.OAuthAccount let initialModels: [String] let onLogin: () -> Void let onLogout: (CLIProxyService.OAuthAccount) -> Void @StateObject private var proxyService = CLIProxyService.shared @State private var models: [String] = [] @State private var isRefreshing: Bool = false @State private var accountInfo: AccountInfo? @State private var isHoveringModels: Bool = false @Environment(\.dismiss) private var dismiss struct AccountInfo { let email: String? let planType: String? let planChecked: Bool let accountType: String? let organization: String? } var body: some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 10) { LocalAuthProviderIconView(provider: provider, size: 20, cornerRadius: 4) Text(provider.displayName) .font(.headline) Spacer() if isLoggedIn { Button { refreshModels() } label: { if isRefreshing { ProgressView() .controlSize(.small) } else { Image(systemName: "arrow.clockwise") .font(.body) } } .buttonStyle(.plain) .disabled(isRefreshing) .help("Refresh models") } else { Text("Not logged in") .font(.caption) .foregroundStyle(.secondary) } } if isLoggedIn { // Account Status Section if let info = accountInfo { VStack(alignment: .leading, spacing: 6) { Text("Account Status") .font(.subheadline) .fontWeight(.medium) VStack(alignment: .leading, spacing: 4) { if let email = info.email { HStack(spacing: 4) { Text("Email:") .font(.caption) .foregroundStyle(.secondary) Text(email) .font(.caption) } } if let planType = info.planType, !planType.isEmpty { HStack(spacing: 4) { Text("Plan:") .font(.caption) .foregroundStyle(.secondary) Text(planType) .font(.caption) .fontWeight(.medium) .foregroundStyle(.primary) } } else { // Show "Loading..." or "Unknown" if plan is being fetched HStack(spacing: 4) { Text("Plan:") .font(.caption) .foregroundStyle(.secondary) Text(info.planChecked ? "Unknown" : "Checking...") .font(.caption) .foregroundStyle(.secondary) .italic() } } if let accountType = info.accountType { HStack(spacing: 4) { Text("Type:") .font(.caption) .foregroundStyle(.secondary) Text(accountType) .font(.caption) } } if let org = info.organization { HStack(spacing: 4) { Text("Organization:") .font(.caption) .foregroundStyle(.secondary) Text(org) .font(.caption) } } } } Divider() } // Models Section if models.isEmpty { Text("No models detected yet. Make sure CLI Proxy API is running.") .font(.caption) .foregroundStyle(.secondary) } else { VStack(alignment: .leading, spacing: 6) { HStack { Text("Available Models") .font(.subheadline) .fontWeight(.medium) Spacer() } ZStack(alignment: .topTrailing) { ScrollView { Text(models.joined(separator: "\n")) .font(.system(.caption, design: .monospaced)) .foregroundStyle(.secondary) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(.trailing, isHoveringModels ? 28 : 0) } .frame(maxHeight: 200) if isHoveringModels { Button { copyModelsToClipboard() } label: { Image(systemName: "doc.on.doc") .font(.system(size: 12)) .foregroundStyle(.secondary) } .buttonStyle(.plain) .help("Copy all models to clipboard") .padding(4) } } .onHover { hovering in isHoveringModels = hovering } } } } else { Text("Sign in to view available models for this provider.") .font(.caption) .foregroundStyle(.secondary) } Divider() HStack { if isLoggedIn { Button("Sign Out") { // Use the selected account instead of always using the first one onLogout(selectedAccount) } .buttonStyle(.bordered) .focusable(false) } else { Button("Upstream Login") { onLogin() } .buttonStyle(.borderedProminent) .focusable(false) } Spacer() Button("Done") { dismiss() } .buttonStyle(.plain) .focusable(false) } } .padding(16) .frame(width: 460) .focusable(false) .onAppear { models = initialModels loadAccountInfo() } } private func copyModelsToClipboard() { let modelsText = models.joined(separator: "\n") let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(modelsText, forType: .string) } private func refreshModels() { guard !isRefreshing else { return } isRefreshing = true Task { let refreshedModels = await proxyService.fetchLocalModels(forceRefresh: true) await MainActor.run { // Filter models for this provider guard let target = builtInProvider(for: provider) else { isRefreshing = false return } var seen: Set = [] var ids: [String] = [] for model in refreshedModels { if builtInProvider(for: model) == target { if !seen.contains(model.id) { seen.insert(model.id) ids.append(model.id) } } } models = ids.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } // Also refresh account info in case it changed loadAccountInfo() isRefreshing = false } } } private func builtInProvider(for provider: LocalAuthProvider) -> LocalServerBuiltInProvider? { switch provider { case .codex: return .openai case .claude: return .anthropic case .gemini: return .gemini case .antigravity: return .antigravity case .qwen: return .qwen } } private func builtInProvider(for model: CLIProxyService.LocalModel) -> LocalServerBuiltInProvider? { let ownedBy = (model.owned_by ?? "").lowercased() let provider = (model.provider ?? "").lowercased() let source = (model.source ?? "").lowercased() for builtIn in LocalServerBuiltInProvider.allCases { if builtIn.matchesOwnedBy(ownedBy) || builtIn.matchesOwnedBy(provider) || builtIn.matchesOwnedBy(source) { return builtIn } } return nil } private func loadAccountInfo() { // Use the selected account instead of always using the first one let account = selectedAccount // Try to extract more info from the account file var email: String? = account.email var planType: String? var accountType: String? var organization: String? if let data = try? Data(contentsOf: URL(fileURLWithPath: account.filePath)), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { // Extract email if not already set if email == nil { email = json["email"] as? String ?? json["user_email"] as? String ?? json["account"] as? String ?? json["user"] as? String } // Try to extract plan/subscription info (provider-specific) switch provider { case .claude: // Claude might have plan info in the token or account data if let plan = json["plan"] as? String ?? json["plan_type"] as? String ?? json[ "subscription"] as? String { planType = plan } if let org = json["organization"] as? String ?? json["org_id"] as? String { organization = org } case .codex: // Codex/OpenAI might have plan info if let plan = json["plan"] as? String ?? json["plan_type"] as? String { planType = plan } accountType = json["account_type"] as? String case .gemini: // Gemini might have account info if let plan = json["plan"] as? String ?? json["tier"] as? String { planType = plan } default: break } } // Always try to fetch plan type via API (more reliable) accountInfo = AccountInfo( email: email, planType: planType, planChecked: planType != nil, accountType: accountType, organization: organization ) // Fetch plan type from API in background Task { await fetchPlanTypeFromAPI(account: account, email: email) } } private func fetchPlanTypeFromAPI(account: CLIProxyService.OAuthAccount, email: String?) async { // Use CLI Proxy API management endpoint to fetch account info guard let authFileInfo = await proxyService.fetchAuthFileInfo(for: account.filename) else { await MainActor.run { accountInfo = AccountInfo( email: accountInfo?.email ?? email, planType: accountInfo?.planType, planChecked: true, accountType: accountInfo?.accountType, organization: accountInfo?.organization ) } return } await MainActor.run { accountInfo = AccountInfo( email: accountInfo?.email ?? email ?? authFileInfo.email, planType: authFileInfo.consolidatedPlan, planChecked: true, accountType: authFileInfo.consolidatedAccountType, organization: authFileInfo.organization ) } } } private struct OAuthLoginSheet: View { let provider: LocalAuthProvider let onDone: () -> Void let onCancel: () -> Void @StateObject private var proxyService = CLIProxyService.shared @State private var loginState: LoginState = .idle @State private var loginError: String? @State private var loginTask: Task? @State private var checkAccountTask: Task? @Environment(\.dismiss) private var dismiss enum LoginState { case idle case loggingIn case needsInput case success case failed } var body: some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 10) { LocalAuthProviderIconView(provider: provider, size: 20, cornerRadius: 4) Text(provider.displayName) .font(.headline) Spacer() statusIndicator } statusMessage if case .needsInput = loginState { if let prompt = proxyService.loginPrompt { Text(prompt.message) .font(.caption) .foregroundStyle(.secondary) .padding(.top, 4) } } if let error = loginError { Text(error) .font(.caption) .foregroundStyle(.red) .padding(.top, 4) } Divider() HStack { if loginState == .failed { Button("Retry") { loginError = nil startLogin() } .buttonStyle(.bordered) .focusable(false) } Spacer() if loginState == .success { Button("Done") { onDone() dismiss() } .buttonStyle(.borderedProminent) .focusable(false) } else { Button("Cancel Login") { onCancel() dismiss() } .buttonStyle(.plain) .focusable(false) } } } .padding(16) .frame(width: 460) .focusable(false) .onAppear { startLogin() startAccountCheck() } .onDisappear { loginTask?.cancel() checkAccountTask?.cancel() } .onChange(of: proxyService.loginPrompt) { prompt in if prompt != nil && loginState == .loggingIn { loginState = .needsInput } } } @ViewBuilder private var statusIndicator: some View { switch loginState { case .idle, .loggingIn: ProgressView() .controlSize(.small) case .needsInput: Image(systemName: "exclamationmark.circle.fill") .foregroundStyle(.orange) case .success: Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) case .failed: Image(systemName: "xmark.circle.fill") .foregroundStyle(.red) } } @ViewBuilder private var statusMessage: some View { switch loginState { case .idle, .loggingIn: Text("Logging in to \(provider.displayName)...") .font(.caption) .foregroundStyle(.secondary) case .needsInput: Text("Please complete the login in the browser or provide the required input.") .font(.caption) .foregroundStyle(.secondary) case .success: Text("Login successful. Click Done to add this account.") .font(.caption) .foregroundStyle(.green) case .failed: Text("Login failed. You can retry or cancel.") .font(.caption) .foregroundStyle(.red) } } private func startLogin() { guard loginState != .loggingIn else { return } loginState = .loggingIn loginError = nil loginTask = Task { do { try await proxyService.login(provider: provider) // Login completed, checkAccountTask will verify success } catch is CancellationError { await MainActor.run { if loginState == .loggingIn { loginState = .idle } } } catch { await MainActor.run { loginState = .failed loginError = error.localizedDescription } } } } private func startAccountCheck() { checkAccountTask = Task { // Record initial account files before login starts let initialFiles = Set( proxyService.listOAuthAccounts() .filter { $0.provider == provider } .map { $0.filename }) // Wait 1 second to allow hideAuthFiles to execute try? await Task.sleep(nanoseconds: 1_000_000_000) while !Task.isCancelled { try? await Task.sleep(nanoseconds: 500_000_000) let currentFiles = Set( proxyService.listOAuthAccounts() .filter { $0.provider == provider } .map { $0.filename }) // Detect new files or account count increase let hasNewFiles = !currentFiles.subtracting(initialFiles).isEmpty let countIncreased = currentFiles.count > initialFiles.count if (hasNewFiles || countIncreased) && loginState != .success { await MainActor.run { loginState = .success loginError = nil } break } } } } } private struct LoginPromptSheet: View { let prompt: CLIProxyService.LoginPrompt let onSubmit: (String) -> Void let onCancel: () -> Void let onStop: () -> Void @State private var input: String = "" var body: some View { VStack(alignment: .leading, spacing: 12) { Text("\(prompt.provider.displayName) Login") .font(.headline) Text(prompt.message) .font(.subheadline) .foregroundColor(.secondary) if prompt.provider == .codex { Text( "If the browser already shows “Authentication Successful”, you can keep waiting—no paste needed." ) .font(.caption) .foregroundColor(.secondary) } TextField("Paste here", text: $input) .textFieldStyle(.roundedBorder) .font(.system(.body, design: .monospaced)) HStack { Button("Paste") { pasteFromClipboard() } Spacer() Button("Keep Waiting") { onCancel() } Button("Stop Login") { onStop() } Button("Submit") { onSubmit(input.trimmingCharacters(in: .whitespacesAndNewlines)) } .buttonStyle(.borderedProminent) } } .padding(16) .frame(width: 420) } private func pasteFromClipboard() { let pasteboard = NSPasteboard.general if let value = pasteboard.string(forType: .string) { input = value } } } // MARK: - Testing Support Types struct ProviderTestResult { let success: Bool let layers: [TestLayerResult] let summary: String let detailedLogs: [String] } struct TestLayerResult: Identifiable { let id = UUID() enum Layer: String { case connectivity = "Connectivity" case authentication = "Authentication" case apiCall = "API Call" } let layer: Layer let status: TestStatus let message: String let details: String? let httpCode: Int? let responsePreview: String? let durationMs: Int var icon: String { switch status { case .success: return "checkmark.circle.fill" case .warning: return "exclamationmark.triangle.fill" case .error: return "xmark.circle.fill" case .skipped: return "minus.circle.fill" } } var color: Color { switch status { case .success: return .green case .warning: return .orange case .error: return .red case .skipped: return .secondary } } } enum TestStatus { case success case warning case error case skipped } // MARK: - ViewModel (Codex-first) @MainActor final class ProvidersVM: ObservableObject { @Published var providers: [ProvidersRegistryService.Provider] = [] @Published var selectedId: String? = nil { didSet { guard selectedId != oldValue else { return } Task { @MainActor in syncEditingFieldsFromSelected() loadModelRowsFromSelected() testResultText = nil testResults = [:] } } } // Connection fields @Published var providerName: String = "" @Published var customIcon: String? = nil // SF Symbol name for custom providers @Published var presetIconName: String? = nil // Preset PNG icon name for bundled providers (e.g., "DeepSeekIcon") @Published var codexBaseURL: String = "" @Published var codexEnvKey: String = "OPENAI_API_KEY" @Published var codexWireAPI: String = "chat" @Published var claudeBaseURL: String = "" @Published var canSave: Bool = false @Published var activeCodexProviderId: String? = nil @Published var defaultCodexModel: String = "" @Published var activeClaudeProviderId: String? = nil @Published var lastError: String? = nil @Published var testResultText: String? = nil @Published var testResults: [String: ProviderTestResult] = [:] @Published var isTestingEndpoint: [String: Bool] = [:] @Published var showEditor: Bool = false @Published var isNewProvider: Bool = false @Published var providerKeyURL: URL? = nil @Published var providerDocsURL: URL? = nil @Published var oauthAccounts: [CLIProxyService.OAuthAccount] = [] private let registry = ProvidersRegistryService() private let codex = CodexConfigService() @Published var templates: [ProvidersRegistryService.Provider] = [] func loadAll() async { await registry.migrateFromCodexIfNeeded(codex: codex) await reload() await refreshOAuthAccounts() } func loadTemplates() async { let list = await registry.listBundledProviders() // Sorting is handled by sortedTemplates computed property to avoid duplication await MainActor.run { templates = list } } func reload() async { // Only show user-added providers in list to avoid confusion let list = await registry.listProviders() providers = list let bindings = await registry.getBindings() activeCodexProviderId = bindings.activeProvider?[ProvidersRegistryService.Consumer.codex.rawValue] defaultCodexModel = bindings.defaultModel?[ProvidersRegistryService.Consumer.codex.rawValue] ?? "" activeClaudeProviderId = bindings.activeProvider?[ProvidersRegistryService.Consumer.claudeCode.rawValue] // If current selectedId is not in the list anymore, select the first one or clear if let currentId = selectedId, !list.contains(where: { $0.id == currentId }) { selectedId = list.first?.id } else if selectedId == nil { selectedId = list.first?.id } syncEditingFieldsFromSelected() loadModelRowsFromSelected() } func refreshOAuthAccounts() async { let accounts = CLIProxyService.shared.listOAuthAccounts() await MainActor.run { self.oauthAccounts = accounts.sorted { if $0.provider.displayName != $1.provider.displayName { return $0.provider.displayName < $1.provider.displayName } return ($0.email ?? "") < ($1.email ?? "") } } } func deleteOAuthAccount(_ account: CLIProxyService.OAuthAccount) async { CLIProxyService.shared.deleteOAuthAccount(account) await refreshOAuthAccounts() } private func syncEditingFieldsFromSelected() { guard let sel = selectedId, let provider = providers.first(where: { $0.id == sel }) else { DispatchQueue.main.async { self.providerName = "" self.customIcon = nil self.presetIconName = nil self.codexBaseURL = "" self.codexEnvKey = "OPENAI_API_KEY" self.codexWireAPI = "chat" self.claudeBaseURL = "" self.defaultModelId = nil self.recomputeCanSave() } return } let name = provider.name ?? "" let icon = provider.customIcon let codexConnector = provider.connectors[ProvidersRegistryService.Consumer.codex.rawValue] let claudeConnector = provider.connectors[ ProvidersRegistryService.Consumer.claudeCode.rawValue] let codexBase = codexConnector?.baseURL ?? "" let envKey = provider.envKey ?? codexConnector?.envKey ?? claudeConnector?.envKey ?? "OPENAI_API_KEY" let wireAPI = normalizedWireAPI(codexConnector?.wireAPI) let claudeBase = claudeConnector?.baseURL ?? "" // Check if this provider has a preset icon let presetIcon = ProviderIconResource.iconName(for: provider) DispatchQueue.main.async { self.providerName = name self.customIcon = icon self.presetIconName = presetIcon self.codexBaseURL = codexBase self.codexEnvKey = envKey self.codexWireAPI = wireAPI self.claudeBaseURL = claudeBase // For prebuilt-like providers, supply Get Key / Docs links by matching templates by baseURL self.applyTemplateMetadataForCurrent(provider: provider) self.recomputeCanSave() } } func editingProviderBinding() -> ProvidersRegistryService.Provider? { guard let sel = selectedId else { return nil } return providers.first(where: { $0.id == sel }) } /// Check if the currently editing provider is a bundled (preset) provider /// Bundled providers have preset brand icons and should not allow custom icon selection func isEditingBundledProvider() -> Bool { guard let sel = selectedId else { return false } // Check if this provider ID exists in bundled templates return templates.contains(where: { $0.id == sel }) } // MARK: - Models directory editing struct ModelRow: Identifiable, Hashable { var key: UUID = UUID() var id: UUID { key } var modelId: String var reasoning: Bool var toolUse: Bool var vision: Bool var longContext: Bool } @Published var modelRows: [ModelRow] = [] @Published var defaultModelId: String? @Published var defaultModelRowID: UUID? = nil func loadModelRowsFromSelected() { // When creating from a template, modelRows are already seeded; avoid clearing. if isNewProvider { return } guard let sel = selectedId, let p = providers.first(where: { $0.id == sel }) else { DispatchQueue.main.async { self.modelRows = [] } return } let rows: [ModelRow] = (p.catalog?.models ?? []).map { me in let c = me.caps return ModelRow( modelId: me.vendorModelId, reasoning: c?.reasoning ?? false, toolUse: c?.tool_use ?? false, vision: c?.vision ?? false, longContext: c?.long_context ?? false ) } let matchingRow = providerDefaultModel(from: p).flatMap { model in rows.first(where: { $0.modelId == model }) } let firstNonEmpty = rows.first(where: { !$0.modelId.isEmpty }) DispatchQueue.main.async { self.modelRows = rows if let match = matchingRow { self.defaultModelRowID = match.id self.defaultModelId = match.modelId } else if let first = firstNonEmpty { self.defaultModelRowID = first.id self.defaultModelId = first.modelId } else { self.defaultModelRowID = nil self.defaultModelId = nil } self.normalizeDefaultSelection() } } // MARK: - Bindings for Table cells func indexForRow(_ id: UUID) -> Int? { modelRows.firstIndex(where: { $0.id == id }) } func bindingModelId(for id: UUID) -> Binding? { guard let idx = indexForRow(id) else { return nil } return Binding( get: { self.modelRows[idx].modelId }, set: { newVal in self.modelRows[idx].modelId = newVal self.handleModelIDChange(for: id, newValue: newVal) } ) } func bindingBool(for id: UUID, keyPath: WritableKeyPath) -> Binding? { guard let idx = indexForRow(id) else { return nil } return Binding( get: { self.modelRows[idx][keyPath: keyPath] }, set: { newVal in self.modelRows[idx][keyPath: keyPath] = newVal } ) } private func providerDefaultModel(from provider: ProvidersRegistryService.Provider) -> String? { if let recommended = provider.recommended?.defaultModelFor?[ ProvidersRegistryService.Consumer.codex.rawValue], !recommended.isEmpty { return recommended } if let alias = provider.connectors[ProvidersRegistryService.Consumer.codex.rawValue]? .modelAliases?["default"], !alias.isEmpty { return alias } if let first = provider.catalog?.models?.first?.vendorModelId { return first } return nil } func setDefaultModelRow(rowID: UUID?, modelId: String?) { defaultModelRowID = rowID let trimmed = modelId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" defaultModelId = trimmed.isEmpty ? nil : trimmed normalizeDefaultSelection() } func handleModelIDChange(for rowID: UUID, newValue: String) { guard defaultModelRowID == rowID else { return } let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) defaultModelId = trimmed.isEmpty ? nil : trimmed normalizeDefaultSelection() } private func normalizeDefaultSelection() { if modelRows.isEmpty { DispatchQueue.main.async { self.defaultModelRowID = nil self.defaultModelId = nil } return } if let rowID = defaultModelRowID, let current = modelRows.first(where: { $0.id == rowID }), !current.modelId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { DispatchQueue.main.async { self.defaultModelId = current.modelId.trimmingCharacters( in: .whitespacesAndNewlines) } return } if let defined = defaultModelId, let match = modelRows.first(where: { $0.modelId == defined }) { DispatchQueue.main.async { self.defaultModelRowID = match.id self.defaultModelId = match.modelId } return } if let fallback = modelRows.first(where: { !$0.modelId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) { DispatchQueue.main.async { self.defaultModelRowID = fallback.id self.defaultModelId = fallback.modelId.trimmingCharacters( in: .whitespacesAndNewlines) } } else { DispatchQueue.main.async { self.defaultModelRowID = nil self.defaultModelId = nil } } } private func resolvedDefaultModel(from models: [ProvidersRegistryService.ModelEntry]) -> String? { let ids = models.map { $0.vendorModelId } if let current = defaultModelId?.trimmingCharacters(in: .whitespacesAndNewlines), !current.isEmpty, ids.contains(current) { return current } return ids.first } func addModelRow() { let row = ModelRow( modelId: "", reasoning: false, toolUse: false, vision: false, longContext: false) modelRows.append(row) normalizeDefaultSelection() } func deleteModelRow(rowKey: UUID) { modelRows.removeAll { $0.id == rowKey } normalizeDefaultSelection() } func binding(for keyPath: ReferenceWritableKeyPath) -> Binding { Binding( get: { self[keyPath: keyPath] }, set: { newVal in self[keyPath: keyPath] = newVal self.recomputeCanSave() self.testResultText = nil }) } private func normalizedWireAPI(_ value: String?) -> String { let lowered = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" switch lowered { case "responses": return "responses" default: return "chat" } } // Preset helpers removed; providers are now sourced from bundled providers.json private func recomputeCanSave() { let codex = codexBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) let claude = claudeBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) let env = codexEnvKey.trimmingCharacters(in: .whitespacesAndNewlines) let newValue = !env.isEmpty && (!codex.isEmpty || !claude.isEmpty) DispatchQueue.main.async { self.canSave = newValue } } @discardableResult func saveEditing(preferences: SessionPreferencesStore) async -> Bool { lastError = nil guard let sel = selectedId else { lastError = "No provider selected" return false } // Handle new provider creation if isNewProvider { return await saveNewProvider(preferences: preferences) } guard var p = providers.first(where: { $0.id == sel }) else { lastError = "Missing provider" return false } let trimmedName = providerName.trimmingCharacters(in: .whitespacesAndNewlines) p.name = trimmedName.isEmpty ? nil : trimmedName // Save customIcon (only for user-created providers) if p.managedByCodMate { p.customIcon = customIcon } var conn = p.connectors[ProvidersRegistryService.Consumer.codex.rawValue] ?? .init( baseURL: nil, wireAPI: nil, envKey: nil, queryParams: nil, httpHeaders: nil, envHttpHeaders: nil, requestMaxRetries: nil, streamMaxRetries: nil, streamIdleTimeoutMs: nil, modelAliases: nil) let trimmedCodexBase = codexBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedEnv = codexEnvKey.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedWire = normalizedWireAPI(codexWireAPI) conn.baseURL = trimmedCodexBase.isEmpty ? nil : trimmedCodexBase // Use provider-level envKey; avoid duplicating at connector level p.envKey = trimmedEnv.isEmpty ? nil : trimmedEnv conn.envKey = nil conn.wireAPI = normalizedWire p.connectors[ProvidersRegistryService.Consumer.codex.rawValue] = conn var cconn = p.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue] ?? .init( baseURL: nil, wireAPI: nil, envKey: nil, queryParams: nil, httpHeaders: nil, envHttpHeaders: nil, requestMaxRetries: nil, streamMaxRetries: nil, streamIdleTimeoutMs: nil, modelAliases: nil) let trimmedClaudeBase = claudeBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) cconn.baseURL = trimmedClaudeBase.isEmpty ? nil : trimmedClaudeBase cconn.envKey = nil p.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue] = cconn let cleanedModels: [ProvidersRegistryService.ModelEntry] = modelRows.compactMap { r in let trimmed = r.modelId.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return nil } let caps = ProvidersRegistryService.ModelCaps( reasoning: r.reasoning, tool_use: r.toolUse, vision: r.vision, long_context: r.longContext, code_tuned: nil, tps_hint: nil, max_output_tokens: nil ) return ProvidersRegistryService.ModelEntry( vendorModelId: trimmed, caps: caps, aliases: nil) } p.catalog = cleanedModels.isEmpty ? nil : ProvidersRegistryService.Catalog(models: cleanedModels) normalizeDefaultSelection() let defaultModel = resolvedDefaultModel(from: cleanedModels) defaultModelId = defaultModel var updatedRecommended: ProvidersRegistryService.Recommended? if var recommended = p.recommended { var defaults = recommended.defaultModelFor ?? [:] let codexKey = ProvidersRegistryService.Consumer.codex.rawValue let claudeKey = ProvidersRegistryService.Consumer.claudeCode.rawValue if let defaultModel { defaults[codexKey] = defaultModel defaults[claudeKey] = defaultModel } else { defaults.removeValue(forKey: codexKey) defaults.removeValue(forKey: claudeKey) } recommended.defaultModelFor = defaults.isEmpty ? nil : defaults updatedRecommended = recommended.defaultModelFor == nil ? nil : recommended } else if let defaultModel { updatedRecommended = ProvidersRegistryService.Recommended(defaultModelFor: [ ProvidersRegistryService.Consumer.codex.rawValue: defaultModel, ProvidersRegistryService.Consumer.claudeCode.rawValue: defaultModel, ]) } p.recommended = updatedRecommended do { try await registry.upsertProvider(p) if activeCodexProviderId == p.id { try await registry.setDefaultModel(.codex, modelId: defaultModel) do { try await codex.setTopLevelString("model", value: defaultModel) } catch { lastError = "Failed to write model to Codex config" } } if activeClaudeProviderId == p.id { try await registry.setDefaultModel(.claudeCode, modelId: defaultModel) } await syncActiveCodexProviderIfNeeded(with: p) // Sync to config.yaml if this API Key provider is enabled if preferences.apiKeyProvidersEnabled.contains(p.id) { await CLIProxyService.shared.syncThirdPartyProviders( enabledProviderIds: preferences.apiKeyProvidersEnabled) } await reload() return true } catch { lastError = "Save failed: \(error.localizedDescription)" return false } } private func saveNewProvider(preferences: SessionPreferencesStore) async -> Bool { let trimmedName = providerName.trimmingCharacters(in: .whitespacesAndNewlines) let list = await registry.listAllProviders() let baseSlug = slugify(trimmedName.isEmpty ? "provider" : trimmedName) var candidate = baseSlug var n = 2 while list.contains(where: { $0.id == candidate }) { candidate = "\(baseSlug)-\(n)" n += 1 } var connectors: [String: ProvidersRegistryService.Connector] = [:] let trimmedCodexBase = codexBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedEnv = codexEnvKey.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedWire = normalizedWireAPI(codexWireAPI) if !trimmedCodexBase.isEmpty || !trimmedEnv.isEmpty { connectors[ProvidersRegistryService.Consumer.codex.rawValue] = .init( baseURL: trimmedCodexBase.isEmpty ? nil : trimmedCodexBase, wireAPI: normalizedWire, envKey: nil, queryParams: nil, httpHeaders: nil, envHttpHeaders: nil, requestMaxRetries: nil, streamMaxRetries: nil, streamIdleTimeoutMs: nil, modelAliases: nil ) } let trimmedClaudeBase = claudeBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmedClaudeBase.isEmpty || !trimmedEnv.isEmpty { let cconn = ProvidersRegistryService.Connector( baseURL: trimmedClaudeBase.isEmpty ? nil : trimmedClaudeBase, wireAPI: nil, envKey: nil, queryParams: nil, httpHeaders: nil, envHttpHeaders: nil, requestMaxRetries: nil, streamMaxRetries: nil, streamIdleTimeoutMs: nil, modelAliases: nil ) connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue] = cconn } let cleanedModels: [ProvidersRegistryService.ModelEntry] = modelRows.compactMap { r in let trimmed = r.modelId.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return nil } let caps = ProvidersRegistryService.ModelCaps( reasoning: r.reasoning, tool_use: r.toolUse, vision: r.vision, long_context: r.longContext, code_tuned: nil, tps_hint: nil, max_output_tokens: nil ) return ProvidersRegistryService.ModelEntry( vendorModelId: trimmed, caps: caps, aliases: nil) } let catalog = cleanedModels.isEmpty ? nil : ProvidersRegistryService.Catalog(models: cleanedModels) normalizeDefaultSelection() let defaultModel = resolvedDefaultModel(from: cleanedModels) defaultModelId = defaultModel var recommended: ProvidersRegistryService.Recommended? if let defaultModel { recommended = ProvidersRegistryService.Recommended(defaultModelFor: [ ProvidersRegistryService.Consumer.codex.rawValue: defaultModel, ProvidersRegistryService.Consumer.claudeCode.rawValue: defaultModel, ]) } var provider = ProvidersRegistryService.Provider( id: candidate, name: trimmedName.isEmpty ? nil : trimmedName, class: "openai-compatible", managedByCodMate: true, envKey: trimmedEnv.isEmpty ? nil : trimmedEnv, connectors: connectors, catalog: catalog, recommended: recommended, customIcon: customIcon // User-created providers can have custom icons ) // Clear connector-level envKey to avoid duplication; prefer provider-level envKey for key in [ ProvidersRegistryService.Consumer.codex.rawValue, ProvidersRegistryService.Consumer.claudeCode.rawValue, ] { if var c = provider.connectors[key] { c.envKey = nil provider.connectors[key] = c } } do { try await registry.upsertProvider(provider) await syncActiveCodexProviderIfNeeded(with: provider) // Sync to config.yaml if this API Key provider is enabled if preferences.apiKeyProvidersEnabled.contains(provider.id) { await CLIProxyService.shared.syncThirdPartyProviders( enabledProviderIds: preferences.apiKeyProvidersEnabled) } isNewProvider = false await reload() selectedId = candidate return true } catch { lastError = "Save failed: \(error.localizedDescription)" return false } } // MARK: - Test editing fields (before save) func testEditingFields() async { lastError = nil testResultText = nil testResults = [:] let providerName = providerName.isEmpty ? "Provider" : providerName let taskToken = StatusBarLogStore.shared.beginTask( "Testing \(providerName) configuration...", level: .info, source: "Provider Test" ) let codexURL = codexBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) let claudeURL = claudeBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) guard !codexURL.isEmpty || !claudeURL.isEmpty else { testResultText = "No endpoints configured" StatusBarLogStore.shared.endTask( taskToken, message: "Test skipped: No URLs configured", level: .warning, source: "Provider Test" ) return } StatusBarLogStore.shared.post( "Starting layered connectivity test for \(providerName)", level: .info, source: "Provider Test" ) // Test each configured endpoint var results: [(String, ProviderTestResult)] = [] if !codexURL.isEmpty { await MainActor.run { isTestingEndpoint["codex"] = true } let result = await testEndpointLayered( label: "Codex", urlString: codexURL, consumer: .codex, providerName: providerName ) results.append(("Codex", result)) await MainActor.run { isTestingEndpoint["codex"] = false } } if !claudeURL.isEmpty { await MainActor.run { isTestingEndpoint["claude"] = true } let result = await testEndpointLayered( label: "Claude", urlString: claudeURL, consumer: .claudeCode, providerName: providerName ) results.append(("Claude", result)) await MainActor.run { isTestingEndpoint["claude"] = false } } // Store results for (label, result) in results { testResults[label] = result } // Generate summary let allSuccess = results.allSatisfy { $0.1.success } let summaryLines = results.map { "\($0.0): \($0.1.summary)" } testResultText = summaryLines.joined(separator: "\n") StatusBarLogStore.shared.endTask( taskToken, message: allSuccess ? "All tests passed" : "Some tests encountered issues", level: allSuccess ? .success : .warning, source: "Provider Test" ) } // Catalog helpers func catalogModelIdsForActiveCodex() -> [String] { let ap = activeCodexProviderId guard let id = ap, let p = providers.first(where: { $0.id == id }) else { return [] } return (p.catalog?.models ?? []).map { $0.vendorModelId } } func setActiveCodexProvider(_ id: String?) async { do { try await registry.setActiveProvider(.codex, providerId: id) } catch { lastError = "Failed to set active: \(error.localizedDescription)" } await reload() } func applyActiveCodexProvider(_ id: String?) async { do { try await registry.setActiveProvider(.codex, providerId: id) if let id, let provider = providers.first(where: { $0.id == id }) { try await codex.applyProviderFromRegistry(provider) } else { try await codex.applyProviderFromRegistry(nil) } } catch { lastError = "Failed to apply active provider to Codex" } await reload() } func applyActiveClaudeProvider(_ id: String?) async { do { try await registry.setActiveProvider(.claudeCode, providerId: id) } catch { lastError = "Failed to apply active provider to Claude Code" } await reload() } func applyDefaultCodexModel() async { do { try await registry.setDefaultModel( .codex, modelId: defaultCodexModel.isEmpty ? nil : defaultCodexModel) try await codex.setTopLevelString( "model", value: defaultCodexModel.isEmpty ? nil : defaultCodexModel) } catch { lastError = "Failed to apply default model to Codex" } await reload() } func delete(id: String, preferences: SessionPreferencesStore) async { do { try await registry.deleteProvider(id: id) if activeCodexProviderId == id { try await registry.setActiveProvider(.codex, providerId: nil) try await registry.setDefaultModel(.codex, modelId: nil) await syncActiveCodexProviderIfNeeded(with: nil) } // Remove from enabled set and sync to config.yaml var enabled = preferences.apiKeyProvidersEnabled if enabled.remove(id) != nil { preferences.apiKeyProvidersEnabled = enabled await CLIProxyService.shared.syncThirdPartyProviders(enabledProviderIds: enabled) } } catch { lastError = "Delete failed: \(error.localizedDescription)" } await reload() } func addOther() { startNewProvider() } // Randomly select an icon from available SF Symbols private func randomIcon() -> String { let letters = "abcdefghijklmnopqrstuvwxyz" let icons = letters.map { "\($0).circle.fill" } return icons.randomElement() ?? icons[0] } func startNewProvider() { isNewProvider = true selectedId = "new-provider-temp" // Empty for custom provider providerName = "" presetIconName = nil customIcon = randomIcon() // Randomly select an icon on initialization codexBaseURL = "" codexEnvKey = "OPENAI_API_KEY" codexWireAPI = "chat" claudeBaseURL = "" modelRows = [] defaultModelId = nil defaultModelRowID = nil testResultText = nil lastError = nil recomputeCanSave() showEditor = true } func startFromTemplate(_ t: ProvidersRegistryService.Provider) { isNewProvider = true selectedId = "new-provider-temp" providerName = t.name ?? t.id // Use the same icon matching logic as menu items if let presetIcon = ProviderIconResource.iconName(for: t) { // Preset provider with Assets.xcassets icon presetIconName = presetIcon customIcon = nil } else { // Custom provider, use random SF Symbol presetIconName = nil customIcon = randomIcon() } let codexConnector = t.connectors[ProvidersRegistryService.Consumer.codex.rawValue] let claudeConnector = t.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue] codexBaseURL = codexConnector?.baseURL ?? "" codexWireAPI = normalizedWireAPI(codexConnector?.wireAPI) claudeBaseURL = claudeConnector?.baseURL ?? "" codexEnvKey = t.envKey ?? "OPENAI_API_KEY" // Seed catalog into rows modelRows = (t.catalog?.models ?? []).map { me in let c = me.caps return ModelRow( modelId: me.vendorModelId, reasoning: c?.reasoning ?? false, toolUse: c?.tool_use ?? false, vision: c?.vision ?? false, longContext: c?.long_context ?? false ) } if let def = providerDefaultModel(from: t), let match = modelRows.first(where: { $0.modelId == def }) { defaultModelRowID = match.id defaultModelId = match.modelId } else { defaultModelRowID = modelRows.first?.id defaultModelId = modelRows.first?.modelId } testResultText = nil lastError = nil // Provide helpful links on template applyTemplateMetadataFor(template: t) recomputeCanSave() showEditor = true } private func applyTemplateMetadataFor(template: ProvidersRegistryService.Provider) { let keyURL: URL? = if let s = template.keyURL, let url = URL(string: s) { url } else { nil } let docsURL: URL? = if let s = template.docsURL, let url = URL(string: s) { url } else { nil } DispatchQueue.main.async { self.providerKeyURL = keyURL self.providerDocsURL = docsURL } } private func applyTemplateMetadataForCurrent(provider: ProvidersRegistryService.Provider) { // Match by baseURL to a bundled template to surface links let codexBase = provider.connectors[ProvidersRegistryService.Consumer.codex.rawValue]?.baseURL? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let claudeBase = provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue]?.baseURL? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if let t = templates.first(where: { ($0.connectors[ProvidersRegistryService.Consumer.codex.rawValue]?.baseURL? .trimmingCharacters( in: .whitespacesAndNewlines) ?? "") == codexBase || ($0.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue]?.baseURL? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "") == claudeBase }) { applyTemplateMetadataFor(template: t) } else { DispatchQueue.main.async { self.providerKeyURL = nil self.providerDocsURL = nil } } } private func slugify(_ s: String) -> String { let lower = s.lowercased() let mapped = lower.map { (c: Character) -> Character in (c.isLetter || c.isNumber) ? c : "-" } var out: [Character] = [] var lastDash = false for ch in mapped { if ch == "-" { if !lastDash { out.append(ch) lastDash = true } } else { out.append(ch) lastDash = false } } while out.first == "-" { out.removeFirst() } while out.last == "-" { out.removeLast() } return out.isEmpty ? "provider" : String(out) } private func syncActiveCodexProviderIfNeeded(with provider: ProvidersRegistryService.Provider?) async { let targetId = provider?.id if targetId == activeCodexProviderId || (provider == nil && activeCodexProviderId != nil) { do { try await codex.applyProviderFromRegistry(provider) } catch { await MainActor.run { self.lastError = "Failed to sync provider to Codex config" } } } } private struct EndpointCheck { let message: String let ok: Bool let statusCode: Int } private enum APIKeySource { case direct case environment case none } private func resolveAPIKey() -> (value: String?, source: APIKeySource) { let trimmed = codexEnvKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return (nil, .none) } // Try as environment variable first if let envValue = ProcessInfo.processInfo.environment[trimmed], !envValue.isEmpty { return (envValue, .environment) } // Check if it's a direct token // OpenAI: sk-... (but not Anthropic) if trimmed.hasPrefix("sk-") && !trimmed.hasPrefix("sk-ant-") { return (trimmed, .direct) } // Anthropic: sk-ant-... if trimmed.hasPrefix("sk-ant-") { return (trimmed, .direct) } // JWT-style: eyJ... if trimmed.hasPrefix("eyJ") { return (trimmed, .direct) } // Generic token with dots (JWT pattern) if trimmed.contains(".") && trimmed.count >= 30 { return (trimmed, .direct) } // Alphanumeric keys (length validation) let isAlphanumeric = trimmed.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" } if isAlphanumeric && trimmed.count >= 20 { return (trimmed, .direct) } return (nil, .none) } // MARK: - Layered Testing Implementation private func testEndpointLayered( label: String, urlString: String, consumer: ProvidersRegistryService.Consumer, providerName: String ) async -> ProviderTestResult { var layers: [TestLayerResult] = [] // Resolve configuration guard let baseURL = URL(string: urlString) else { let result = TestLayerResult( layer: .connectivity, status: .error, message: "Invalid URL format", details: "The base URL '\(urlString)' is not a valid URL", httpCode: nil, responsePreview: nil, durationMs: 0 ) return ProviderTestResult( success: false, layers: [result], summary: "Invalid URL", detailedLogs: ["Invalid URL format: \(urlString)"] ) } let keyInfo = resolveAPIKey() let provider = editingProviderBinding() let connector = provider?.connectors[consumer.rawValue] let providerClass = (provider?.class ?? "openai-compatible").lowercased() let wireAPI = normalizedWireAPI(connector?.wireAPI) StatusBarLogStore.shared.post( "\(label): Configuration - Class: \(providerClass), Wire: \(wireAPI), Auth: \(keyInfo.value != nil ? "present" : "missing")", level: .info, source: "Provider Test" ) // Layer 1: Basic Connectivity StatusBarLogStore.shared.post( "\(label): [L1] Testing network connectivity...", level: .info, source: "Provider Test" ) let connectivityResult = await testConnectivity( label: label, baseURL: baseURL, apiKey: keyInfo.value ) layers.append(connectivityResult) guard connectivityResult.status != .error else { return ProviderTestResult( success: false, layers: layers, summary: "Network connectivity failed", detailedLogs: [connectivityResult.message] ) } // Layer 2: Authentication & Endpoint Discovery StatusBarLogStore.shared.post( "\(label): [L2] Testing authentication and endpoints...", level: .info, source: "Provider Test" ) let authResult = await testAuthentication( label: label, baseURL: baseURL, providerClass: providerClass, wireAPI: wireAPI, apiKey: keyInfo.value ) layers.append(authResult) // Determine overall success let hasErrors = layers.contains { $0.status == .error } let allSuccess = layers.allSatisfy { $0.status == .success } let hasWarnings = layers.contains { $0.status == .warning } let summary: String if allSuccess { summary = "All checks passed" } else if hasErrors { summary = "Failed - see details" } else if hasWarnings { summary = "Reachable with warnings" } else { summary = "Partial success" } return ProviderTestResult( success: !hasErrors, layers: layers, summary: summary, detailedLogs: layers.map { "\($0.layer.rawValue): \($0.message)" } ) } private func testConnectivity( label: String, baseURL: URL, apiKey: String? ) async -> TestLayerResult { let startTime = Date() var request = URLRequest(url: baseURL, timeoutInterval: 5) request.httpMethod = "HEAD" if let key = apiKey { request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") } do { let (_, response) = try await URLSession.shared.data(for: request) let duration = Int(Date().timeIntervalSince(startTime) * 1000) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 StatusBarLogStore.shared.post( "\(label): [L1] Connectivity OK (HTTP \(statusCode), \(duration)ms)", level: .success, source: "Provider Test" ) return TestLayerResult( layer: .connectivity, status: .success, message: "Server reachable", details: "Base URL responds to requests", httpCode: statusCode, responsePreview: nil, durationMs: duration ) } catch { let duration = Int(Date().timeIntervalSince(startTime) * 1000) StatusBarLogStore.shared.post( "\(label): [L1] Connectivity failed: \(error.localizedDescription)", level: .error, source: "Provider Test" ) return TestLayerResult( layer: .connectivity, status: .error, message: "Cannot connect to server", details: error.localizedDescription, httpCode: -1, responsePreview: nil, durationMs: duration ) } } private func testAuthentication( label: String, baseURL: URL, providerClass: String, wireAPI: String, apiKey: String? ) async -> TestLayerResult { let startTime = Date() // Choose appropriate endpoint based on provider class let testPath: String let method: String if providerClass == "anthropic" { testPath = "messages" method = "HEAD" // Minimal check } else { testPath = "models" method = "GET" } // Build URL with proper versioning let testURL: URL if providerClass == "anthropic" { testURL = anthropicEndpoint(baseURL: baseURL.absoluteString, path: testPath) } else { testURL = openAIEndpoint(baseURL: baseURL.absoluteString, path: testPath) } var request = URLRequest(url: testURL, timeoutInterval: 8) request.httpMethod = method if providerClass == "anthropic" { request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") } if let key = apiKey { request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") } do { let (data, response) = try await URLSession.shared.data(for: request) let duration = Int(Date().timeIntervalSince(startTime) * 1000) let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 let body = String(data: data, encoding: .utf8) let result = evaluateAuthResponse( label: label, statusCode: statusCode, responseBody: body, providerClass: providerClass, method: method, apiKeyPresent: apiKey != nil, durationMs: duration ) return result } catch { let duration = Int(Date().timeIntervalSince(startTime) * 1000) StatusBarLogStore.shared.post( "\(label): [L2] Request failed: \(error.localizedDescription)", level: .error, source: "Provider Test" ) return TestLayerResult( layer: .authentication, status: .error, message: "Request failed", details: error.localizedDescription, httpCode: -1, responsePreview: nil, durationMs: duration ) } } private func evaluateAuthResponse( label: String, statusCode: Int, responseBody: String?, providerClass: String, method: String, apiKeyPresent: Bool, durationMs: Int ) -> TestLayerResult { var status: TestStatus var message: String var details: String? switch statusCode { case 200: status = .success message = "Endpoint accessible and authenticated" details = "Successfully connected to \(providerClass) API" StatusBarLogStore.shared.post( "\(label): [L2] ✓ Authentication successful (HTTP 200, \(durationMs)ms)", level: .success, source: "Provider Test" ) case 401: status = .error message = "Authentication failed" var suggestions = ["Check API key validity"] if !apiKeyPresent { suggestions.append("No API key provided") } if providerClass == "anthropic" { suggestions.append("Anthropic keys start with 'sk-ant-'") } else { suggestions.append("OpenAI keys start with 'sk-'") } // Try to extract error details if let body = responseBody, let errorInfo = parseErrorResponse(body) { message = "Authentication failed: \(errorInfo.message)" suggestions.append(contentsOf: errorInfo.suggestions) } details = suggestions.joined(separator: "; ") StatusBarLogStore.shared.post( "\(label): [L2] ✗ Authentication failed (HTTP 401)", level: .error, source: "Provider Test" ) case 403: status = .error message = "Access forbidden" var suggestions = ["API key lacks required permissions", "Check account status"] if let body = responseBody, let errorInfo = parseErrorResponse(body) { message = "Access forbidden: \(errorInfo.message)" suggestions.append(contentsOf: errorInfo.suggestions) } details = suggestions.joined(separator: "; ") StatusBarLogStore.shared.post( "\(label): [L2] ✗ Access forbidden (HTTP 403)", level: .error, source: "Provider Test" ) case 404: status = .error message = "Endpoint not found" let suggestions = [ "Base URL may be incorrect", providerClass == "anthropic" ? "Anthropic uses /v1/messages" : "OpenAI uses /v1/chat/completions", ] details = suggestions.joined(separator: "; ") StatusBarLogStore.shared.post( "\(label): [L2] ✗ Endpoint not found (HTTP 404)", level: .error, source: "Provider Test" ) case 405: // Method not allowed - acceptable for HEAD requests if method == "HEAD" { status = .success message = "Endpoint exists (HEAD not supported)" details = "Server doesn't support HEAD but endpoint is valid" StatusBarLogStore.shared.post( "\(label): [L2] ✓ Endpoint exists (HTTP 405 for HEAD is OK)", level: .success, source: "Provider Test" ) } else { status = .warning message = "Method not allowed" details = "Endpoint exists but \(method) not supported" StatusBarLogStore.shared.post( "\(label): [L2] ⚠ Method not allowed (HTTP 405)", level: .warning, source: "Provider Test" ) } case 400: status = .warning message = "Bad request format" details = "Endpoint reachable but request parameters invalid (acceptable for GET /models)" StatusBarLogStore.shared.post( "\(label): [L2] ⚠ Bad request (HTTP 400, endpoint reachable)", level: .warning, source: "Provider Test" ) default: status = .error message = "Unexpected status code: \(statusCode)" details = nil StatusBarLogStore.shared.post( "\(label): [L2] ✗ Unexpected response (HTTP \(statusCode))", level: .error, source: "Provider Test" ) } return TestLayerResult( layer: .authentication, status: status, message: message, details: details, httpCode: statusCode, responsePreview: responseBody?.prefix(200).description, durationMs: durationMs ) } private func parseErrorResponse(_ body: String) -> (message: String, suggestions: [String])? { guard let data = body.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } var message = "" var suggestions: [String] = [] // OpenAI format if let error = json["error"] as? [String: Any] { message = error["message"] as? String ?? "Unknown error" if let code = error["code"] as? String { switch code { case "invalid_api_key": suggestions.append("API key is invalid or malformed") case "insufficient_quota": suggestions.append("Account quota exceeded") case "model_not_found": suggestions.append("Requested model not available") default: break } } } // Anthropic format else if json["type"] as? String == "error", let error = json["error"] as? [String: Any] { message = error["message"] as? String ?? "Unknown error" } // Generic else if let msg = json["message"] as? String ?? json["error"] as? String { message = msg } return message.isEmpty ? nil : (message, suggestions) } // URL construction helpers private func openAIEndpoint(baseURL: String, path: String) -> URL { var base = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) if base.hasSuffix("/") { base.removeLast() } // Check if base already has version suffix if base.lowercased().hasSuffix("/v1") || hasNumericVersionSuffix(base) { return URL(string: base + "/" + path)! } else { return URL(string: base + "/v1/" + path)! } } private func anthropicEndpoint(baseURL: String, path: String) -> URL { var base = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) if base.hasSuffix("/") { base.removeLast() } if base.lowercased().hasSuffix("/v1") { return URL(string: base + "/" + path)! } else { return URL(string: base + "/v1/" + path)! } } private func hasNumericVersionSuffix(_ urlString: String) -> Bool { guard let url = URL(string: urlString) else { return false } let parts = url.path.split(separator: "/") guard let last = parts.last else { return false } let s = String(last).lowercased() if s.hasPrefix("v") { let digits = s.dropFirst() return !digits.isEmpty && digits.allSatisfy { $0.isNumber } } return false } private func evaluateEndpoint( label: String, urlString: String, providerName: String = "Provider", consumer: ProvidersRegistryService.Consumer ) async -> EndpointCheck { guard let baseURL = URL(string: urlString) else { StatusBarLogStore.shared.post( "\(label): Invalid URL format: \(urlString)", level: .error, source: "Provider Test" ) return EndpointCheck(message: "\(label): invalid URL", ok: false, statusCode: -1) } StatusBarLogStore.shared.post( "\(label): Base URL configured: \(baseURL.absoluteString)", level: .info, source: "Provider Test" ) let keyInfo = resolveAPIKey() if keyInfo.value != nil { let source = keyInfo.source == .environment ? "environment variable" : "direct input" StatusBarLogStore.shared.post( "\(label): API key detected (from \(source))", level: .info, source: "Provider Test" ) } else { StatusBarLogStore.shared.post( "\(label): No API key found, testing without authentication", level: .warning, source: "Provider Test" ) } let lower = baseURL.absoluteString.lowercased() let isAnthropicEndpoint = consumer == .claudeCode || lower.contains("anthropic") // Get provider configuration for this consumer let provider = editingProviderBinding() let connector = provider?.connectors[consumer.rawValue] _ = normalizedWireAPI( connector?.wireAPI ?? (consumer == .codex ? codexWireAPI : nil)) // Test endpoint connectivity: Try GET /models endpoint let modelsURL = baseURL.appendingPathComponent("models") StatusBarLogStore.shared.post( "\(label): [GET] Testing /models endpoint: \(modelsURL.absoluteString)", level: .info, source: "Provider Test" ) let getModelsResult = await testSingleEndpoint( label: label, url: modelsURL, token: keyInfo.value, isAnthropic: isAnthropicEndpoint, method: "GET" ) // Determine result based on status code if getModelsResult.statusCode == 200 { StatusBarLogStore.shared.post( "\(label): Endpoint reachable, API key valid (GET /models: 200)", level: .success, source: "Provider Test" ) return EndpointCheck( message: "\(label): HTTP 200 (reachable)", ok: true, statusCode: 200 ) } else if getModelsResult.statusCode == 401 || getModelsResult.statusCode == 403 { StatusBarLogStore.shared.post( "\(label): Endpoint reachable but API key invalid (GET /models: \(getModelsResult.statusCode))", level: .warning, source: "Provider Test" ) return EndpointCheck( message: "\(label): HTTP \(getModelsResult.statusCode) (API key issue)", ok: false, statusCode: getModelsResult.statusCode ) } else if getModelsResult.statusCode == 400 || getModelsResult.statusCode == 404 || getModelsResult.statusCode == 405 { // Endpoint exists but doesn't support GET - this is acceptable for many providers StatusBarLogStore.shared.post( "\(label): Endpoint reachable (GET /models: \(getModelsResult.statusCode), endpoint may not support GET)", level: .info, source: "Provider Test" ) return EndpointCheck( message: "\(label): HTTP \(getModelsResult.statusCode) (reachable)", ok: true, // Consider reachable if endpoint responds (even with 400/404/405) statusCode: getModelsResult.statusCode ) } else { StatusBarLogStore.shared.post( "\(label): Endpoint test failed (GET /models: \(getModelsResult.statusCode))", level: .error, source: "Provider Test" ) return EndpointCheck( message: "\(label): HTTP \(getModelsResult.statusCode) (unexpected)", ok: false, statusCode: getModelsResult.statusCode ) } } private func testSingleEndpoint( label: String, url: URL, token: String?, isAnthropic: Bool, method: String = "GET" ) async -> EndpointCheck { var req = URLRequest(url: url) req.httpMethod = method if isAnthropic { req.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") } if let token { req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } let startTime = Date() do { let (_, resp) = try await URLSession.shared.data(for: req) let elapsed = Date().timeIntervalSince(startTime) let code = (resp as? HTTPURLResponse)?.statusCode ?? -1 // Target is 200 status code let ok = code == 200 let statusLevel: StatusBarLogLevel = ok ? .success : .warning let statusMsg = ok ? "HTTP \(code) (success)" : "HTTP \(code) (expected 200)" StatusBarLogStore.shared.post( "\(label): [\(method)] \(url.absoluteString) → \(statusMsg) [\(String(format: "%.2f", elapsed * 1000))ms]", level: statusLevel, source: "Provider Test" ) return EndpointCheck( message: "\(label): [\(method)] HTTP \(code)", ok: ok, statusCode: code) } catch { let elapsed = Date().timeIntervalSince(startTime) StatusBarLogStore.shared.post( "\(label): [\(method)] \(url.absoluteString) → Error: \(error.localizedDescription) [\(String(format: "%.2f", elapsed * 1000))ms]", level: .error, source: "Provider Test" ) return EndpointCheck( message: "\(label): [\(method)] \(error.localizedDescription)", ok: false, statusCode: -1) } } private func selectModelForTesting( consumer: ProvidersRegistryService.Consumer, provider: ProvidersRegistryService.Provider? ) -> String? { // Priority 1: Use recommended model for this consumer if let provider = provider, let recommended = provider.recommended?.defaultModelFor?[consumer.rawValue], !recommended.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return recommended } // Priority 2: Filter models by consumer and select let availableModels = modelRows.filter { !$0.modelId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } if consumer == .codex { // For Codex: exclude anthropic/ models, prefer others let codexModels = availableModels.filter { !$0.modelId.lowercased().hasPrefix("anthropic/") } if let defaultModel = defaultModelId, !defaultModel.isEmpty, codexModels.contains(where: { $0.modelId == defaultModel }) { return defaultModel } return codexModels.first?.modelId ?? availableModels.first?.modelId } else { // For Claude: prefer anthropic/ models let claudeModels = availableModels.filter { $0.modelId.lowercased().hasPrefix("anthropic/") } if let defaultModel = defaultModelId, !defaultModel.isEmpty, claudeModels.contains(where: { $0.modelId == defaultModel }) || availableModels.contains(where: { $0.modelId == defaultModel }) { return defaultModel } return claudeModels.first?.modelId ?? availableModels.first?.modelId } } private func testModelAPI( label: String, baseURL: URL, model: String, token: String?, isAnthropic: Bool, wireAPI: String ) async -> EndpointCheck { let endpointPath = isAnthropic ? "messages" : (wireAPI == "chat" ? "chat/completions" : "responses") let testURL = baseURL.appendingPathComponent(endpointPath) StatusBarLogStore.shared.post( "\(label): [POST] Testing model API endpoint: \(testURL.absoluteString)", level: .info, source: "Provider Test" ) var req = URLRequest(url: testURL) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") if isAnthropic { req.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") } if let token { req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } // Prepare request body based on API type var body: [String: Any] = [:] if isAnthropic { body = [ "model": model, "max_tokens": 10, "messages": [ ["role": "user", "content": "test"] ], ] } else if wireAPI == "chat" { body = [ "model": model, "messages": [ ["role": "user", "content": "test"] ], "max_tokens": 10, ] } else { body = [ "model": model, "input": [ [ "role": "user", "content": [["type": "text", "text": "test"]], ] ], "max_output_tokens": 10, ] } let startTime = Date() do { let jsonData = try JSONSerialization.data(withJSONObject: body) req.httpBody = jsonData let (_, resp) = try await URLSession.shared.data(for: req) let elapsed = Date().timeIntervalSince(startTime) let code = (resp as? HTTPURLResponse)?.statusCode ?? -1 // Target is 200 status code let ok = code == 200 let statusLevel: StatusBarLogLevel = ok ? .success : .warning let statusMsg = ok ? "HTTP \(code) (success)" : "HTTP \(code) (expected 200)" StatusBarLogStore.shared.post( "\(label): [POST] Model '\(model)' API test → \(statusMsg) [\(String(format: "%.2f", elapsed * 1000))ms]", level: statusLevel, source: "Provider Test" ) return EndpointCheck( message: "\(label): [POST] Model API test HTTP \(code)", ok: ok, statusCode: code ) } catch { let elapsed = Date().timeIntervalSince(startTime) StatusBarLogStore.shared.post( "\(label): [POST] Model '\(model)' API test → Error: \(error.localizedDescription) [\(String(format: "%.2f", elapsed * 1000))ms]", level: .error, source: "Provider Test" ) return EndpointCheck( message: "\(label): [POST] Model API test error: \(error.localizedDescription)", ok: false, statusCode: -1 ) } } private func formattedLine(for result: EndpointCheck) -> String { var line = result.message guard !result.ok else { return line } switch result.statusCode { case 401, 403: line += " – Check the API key or token permissions." case 404: line += " – Verify the base URL and wire API. Some vendors return 404 for a GET on the base path; Codex requires the chat endpoints to be reachable." if let docs = providerDocsURL { line += " Docs: \(docs.absoluteString)" } default: if let docs = providerDocsURL { line += " – See docs: \(docs.absoluteString)" } } return line } } // MARK: - Provider Menu Components private struct ProviderMenuItem { let id: String let name: String let icon: ProviderIconType let action: () -> Void } private enum ProviderIconType { case oauth(LocalAuthProvider) case apiKey(ProvidersRegistryService.Provider) } private struct ProviderAddMenu: View { let title: String let helpText: String let items: [ProviderMenuItem] var emptyMessage: String? = nil var customAction: (String, () -> Void)? = nil var body: some View { Menu { Text(title) if items.isEmpty { if let emptyMessage { Text(emptyMessage) } } else { ForEach(items, id: \.id) { item in Button(action: item.action) { HStack { ProviderMenuIconView(icon: item.icon, size: 16, cornerRadius: 3) Text(item.name) } } } if customAction != nil { Divider() } } if let (label, action) = customAction { Button(label, action: action) } } label: { Image(systemName: "plus") .font(.body) .frame(width: 24, height: 24) } .menuStyle(.borderlessButton) .buttonStyle(.plain) .contentShape(Rectangle()) .help(helpText) } } private struct ProviderMenuIconView: View { let icon: ProviderIconType var size: CGFloat = 16 var cornerRadius: CGFloat = 3 var body: some View { Group { switch icon { case .oauth(let provider): let iconName = iconNameForOAuthProvider(provider) if let nsImage = ProviderIconThemeHelper.menuImage( named: iconName, size: NSSize(width: size, height: size)) { Image(nsImage: nsImage) .resizable() .interpolation(.high) .aspectRatio(contentMode: .fit) .frame(width: size, height: size) } else { LocalAuthProviderIconView( provider: provider, size: size, cornerRadius: cornerRadius) } case .apiKey(let provider): if let iconName = iconNameForAPIProvider(provider), let nsImage = ProviderIconThemeHelper.menuImage( named: iconName, size: NSSize(width: size, height: size)) { Image(nsImage: nsImage) .resizable() .interpolation(.high) .aspectRatio(contentMode: .fit) .frame(width: size, height: size) } else { APIKeyProviderIconView( provider: provider, size: size, cornerRadius: cornerRadius) } } } } private func iconNameForOAuthProvider(_ provider: LocalAuthProvider) -> String { switch provider { case .codex: return "ChatGPTIcon" case .claude: return "ClaudeIcon" case .gemini: return "GeminiIcon" case .antigravity: return "AntigravityIcon" case .qwen: return "QwenIcon" } } private func iconNameForAPIProvider(_ provider: ProvidersRegistryService.Provider) -> String? { // Use unified icon resource library helper return ProviderIconResource.iconName(for: provider) } } // MARK: - Test Result UI Components private struct TestResultCard: View { let label: String let result: ProviderTestResult @State private var isExpanded: Bool = false var body: some View { VStack(alignment: .leading, spacing: 6) { // Header Button { withAnimation { isExpanded.toggle() } } label: { HStack { Image( systemName: result.success ? "checkmark.circle.fill" : "exclamationmark.triangle.fill" ) .foregroundStyle(result.success ? .green : .orange) Text(label) .font(.subheadline) .fontWeight(.medium) Spacer() Text(result.summary) .font(.caption) .foregroundStyle(.secondary) Image(systemName: isExpanded ? "chevron.up" : "chevron.down") .font(.caption) .foregroundStyle(.secondary) } } .buttonStyle(.plain) // Expandable details if isExpanded { VStack(alignment: .leading, spacing: 4) { ForEach(result.layers) { layer in LayerResultRow(layer: layer) } } .padding(.leading, 24) } } .padding(8) .background(Color(nsColor: .controlBackgroundColor)) .cornerRadius(6) } } private struct LayerResultRow: View { let layer: TestLayerResult @State private var showDetails: Bool = false var body: some View { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Image(systemName: layer.icon) .font(.caption) .foregroundStyle(layer.color) Text(layer.layer.rawValue) .font(.caption) .fontWeight(.medium) Text("•") .font(.caption) .foregroundStyle(.secondary) Text(layer.message) .font(.caption) .foregroundStyle(.secondary) if let code = layer.httpCode { Text("(HTTP \(code))") .font(.caption2) .foregroundStyle(.secondary) } Spacer() if layer.details != nil { Button { showDetails.toggle() } label: { Image(systemName: "info.circle") .font(.caption) } .buttonStyle(.plain) } } if showDetails, let details = layer.details { Text(details) .font(.caption2) .foregroundStyle(.secondary) .padding(.leading, 20) .padding(.top, 2) } } } } ================================================ FILE: views/RecentSessionsListView.swift ================================================ import SwiftUI import AppKit struct RecentSessionsListView: View { typealias ProjectInfo = (id: String, name: String) var title: String = "Recent Sessions" var sessions: [SessionSummary] var emptyMessage: String var projectInfoProvider: ((SessionSummary) -> ProjectInfo?)? = nil var projectColumnWidth: CGFloat = 100 var onSelectSession: (SessionSummary) -> Void var onSelectProject: ((String) -> Void)? = nil @Environment(\.colorScheme) private var colorScheme private var hasProjectColumn: Bool { projectInfoProvider != nil } var body: some View { VStack(alignment: .leading, spacing: 8) { Text(title) .font(.headline) if sessions.isEmpty { OverviewCard { Text(emptyMessage) .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) } } else { OverviewCard { VStack(spacing: 2) { ForEach(Array(sessions.enumerated()), id: \.element.id) { index, session in sessionRow(session: session) .padding(.vertical, 8) .padding(.leading, hasProjectColumn ? 0 : 4) .padding(.trailing, 8) .contentShape(Rectangle()) .onTapGesture { onSelectSession(session) } .onHover { hovering in if hovering { NSCursor.pointingHand.set() } else { NSCursor.arrow.set() } } if index < sessions.count - 1 { Divider() .padding(.leading, dividerLeadingPadding) .padding(.trailing, 4) } } } } } } } private var dividerLeadingPadding: CGFloat { hasProjectColumn ? projectColumnWidth + 36 : 36 } private func sessionRow(session: SessionSummary) -> some View { HStack(alignment: .center, spacing: 12) { if let projectInfoProvider, let info = projectInfoProvider(session) { projectLabel(for: info) .frame(width: projectColumnWidth, alignment: .leading) } else if hasProjectColumn { Rectangle() .fill(Color.clear) .frame(width: projectColumnWidth, alignment: .leading) } let branding = session.source.branding if let asset = branding.badgeAssetName { Image(asset) .resizable() .renderingMode(.original) .aspectRatio(contentMode: .fit) .frame(width: 16, height: 16) .modifier( DarkModeInvertModifier( active: session.source.baseKind == .codex && colorScheme == .dark ) ) } else { Image(systemName: branding.symbolName) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(branding.iconColor) .frame(width: 16) } VStack(alignment: .leading, spacing: 4) { Text(session.effectiveTitle) .font(.subheadline) .fontWeight(.medium) .lineLimit(1) .truncationMode(.tail) HStack(spacing: 6) { let date = session.lastUpdatedAt ?? session.startedAt LiveRelativeDateText(date: date) .font(.caption) .foregroundStyle(.secondary) Text("·") .font(.caption) .foregroundStyle(.tertiary) Text(session.commentSnippet) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) } } Spacer() Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(.tertiary) } } @ViewBuilder private func projectLabel(for info: ProjectInfo) -> some View { if let onSelectProject { Text(info.name) .font(.subheadline) .fontWeight(.semibold) .lineLimit(1) .truncationMode(.tail) .contentShape(Rectangle()) .onTapGesture { onSelectProject(info.id) } } else { Text(info.name) .font(.subheadline) .fontWeight(.semibold) .lineLimit(1) .truncationMode(.tail) } } } private struct LiveRelativeDateText: View { let date: Date var body: some View { TimelineView(.periodic(from: .now, by: 60.0)) { context in Text(Self.formatter.localizedString(for: date, relativeTo: context.date)) } } private static let formatter: RelativeDateTimeFormatter = { let f = RelativeDateTimeFormatter() f.unitsStyle = .short return f }() } ================================================ FILE: views/RemoteHostsSettingsView.swift ================================================ import SwiftUI import AppKit struct RemoteHostsSettingsPane: View { @ObservedObject var preferences: SessionPreferencesStore @EnvironmentObject private var viewModel: SessionListViewModel @ObservedObject private var permissionsManager = SandboxPermissionsManager.shared @State private var availableRemoteHosts: [SSHHost] = [] @State private var isRequestingSSHAccess = false @State private var selectedHostAlias: String? = nil var body: some View { VStack(alignment: .leading, spacing: 6) { Text("Remote Hosts").font(.title2).fontWeight(.bold) Text("Choose which SSH hosts CodMate should mirror for remote Codex/Claude sessions.") .font(.subheadline) .foregroundColor(.secondary) // Header controls aligned to the far right (match MCP Servers style) HStack { Spacer(minLength: 8) HStack(spacing: 10) { Button(role: .none) { DispatchQueue.main.async { preferences.enabledRemoteHosts = [] } } label: { Text("Clear All") } .buttonStyle(.bordered) .disabled(preferences.enabledRemoteHosts.isEmpty) Button { Task { await viewModel.syncRemoteHosts(force: true, refreshAfter: true) } } label: { Label("Sync Hosts", systemImage: "arrow.triangle.2.circlepath") } .buttonStyle(.bordered) .disabled(preferences.enabledRemoteHosts.isEmpty) Button(action: reloadRemoteHosts) { Label("Refresh", systemImage: "arrow.clockwise") } .buttonStyle(.bordered) .disabled(!permissionsManager.hasPermission(for: .sshConfig)) } } // Permission gate if !permissionsManager.hasPermission(for: .sshConfig) { permissionCard } else { hostsList } unavailableSection Text("CodMate mirrors only the hosts you enable. Hosts that prompt for passwords will open interactively when needed.") .font(.caption) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding(.top, 24) .padding(.horizontal, 24) .padding(.bottom, 24) .onAppear { if permissionsManager.hasPermission(for: .sshConfig) && availableRemoteHosts.isEmpty { DispatchQueue.main.async { reloadRemoteHosts() } } } .onChange(of: permissionsManager.hasPermission(for: .sshConfig)) { granted in if granted { reloadRemoteHosts() } else { availableRemoteHosts = [] } } } // MARK: - Subviews @ViewBuilder private var permissionCard: some View { VStack(alignment: .leading, spacing: 8) { Label("Grant Access to ~/.ssh", systemImage: "lock.square") .font(.headline) Text("CodMate needs permission to read ~/.ssh/config before it can list your SSH hosts. Grant access once and the app will remember it for future launches.") .font(.caption) .foregroundColor(.secondary) Button { guard !isRequestingSSHAccess else { return } isRequestingSSHAccess = true Task { let granted = await permissionsManager.requestPermission(for: .sshConfig) await MainActor.run { isRequestingSSHAccess = false if granted { reloadRemoteHosts() } } } } label: { HStack(spacing: 6) { if isRequestingSSHAccess { ProgressView().controlSize(.small) } Text(isRequestingSSHAccess ? "Requesting…" : "Grant Access") } .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) } .padding() .background(Color(nsColor: .separatorColor).opacity(0.2)) .cornerRadius(10) } @ViewBuilder private var hostsList: some View { let hosts = availableRemoteHosts if hosts.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("No SSH hosts were found in ~/.ssh/config.") .font(.body) .foregroundColor(.secondary) Text("Add host aliases to your SSH config, then refresh to enable remote session mirroring.") .font(.caption) .foregroundStyle(.tertiary) } .padding(.vertical, 12) .frame(maxWidth: .infinity, alignment: .leading) } else { // Estimate a unified name column width based on the longest alias let maxAliasCount = hosts.map { $0.alias.count }.max() ?? 0 let nameColumnWidth = max(120.0, min(320.0, Double(maxAliasCount) * 8.0)) List(selection: $selectedHostAlias) { ForEach(hosts, id: \.alias) { host in let (statusText, statusColor) = syncStatusDescription(for: host.alias) HStack(alignment: .center, spacing: 0) { Toggle("", isOn: bindingForRemoteHost(alias: host.alias)) .toggleStyle(.switch) .labelsHidden() .controlSize(.small) .padding(.trailing, 8) HStack(alignment: .center, spacing: 8) { Image(systemName: "antenna.radiowaves.left.and.right") Text(host.alias).font(.body.weight(.medium)) } .frame(width: nameColumnWidth, alignment: .leading) Spacer(minLength: 16) VStack(alignment: .leading, spacing: 2) { if let line = connectionLine(for: host), !line.isEmpty { Label(line, systemImage: "link") .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) } HStack(spacing: 12) { if let pj = host.proxyJump, !pj.isEmpty { Label("ProxyJump: \(pj)", systemImage: "arrow.triangle.branch") .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) } if let idf = host.identityFile, !idf.isEmpty { Label(idf, systemImage: "key") .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) } } } .frame(maxWidth: .infinity, alignment: .leading) Text(statusText) .font(.caption2) .foregroundStyle(statusColor) .frame(minWidth: 120, alignment: .trailing) } .padding(.vertical, 8) .tag(host.alias as String?) } } .frame(minHeight: 200, maxHeight: .infinity, alignment: .top) .padding(.horizontal, -8) } } @ViewBuilder private var unavailableSection: some View { let hostAliases = Set(availableRemoteHosts.map { $0.alias }) let dangling = preferences.enabledRemoteHosts.subtracting(hostAliases) if permissionsManager.hasPermission(for: .sshConfig) && !dangling.isEmpty { VStack(alignment: .leading, spacing: 6) { Text("Unavailable Hosts") .font(.subheadline) .fontWeight(.semibold) Text("The following host aliases are enabled but not present in your current SSH config:") .font(.caption) .foregroundColor(.secondary) ForEach(Array(dangling).sorted(), id: \.self) { alias in Text("• \(alias)") .font(.caption) .foregroundStyle(.tertiary) } } .padding(.vertical, 6) } } // MARK: - Helpers private func reloadRemoteHosts() { let resolver = SSHConfigResolver() availableRemoteHosts = [] let hosts = resolver.resolvedHosts().sorted { $0.alias.lowercased() < $1.alias.lowercased() } availableRemoteHosts = hosts let hostAliases = Set(hosts.map { $0.alias }) let filtered = preferences.enabledRemoteHosts.filter { hostAliases.contains($0) } if filtered.count != preferences.enabledRemoteHosts.count { DispatchQueue.main.async { preferences.enabledRemoteHosts = Set(filtered) } } // Default-select the first host when entering the page or when selection becomes invalid if let current = selectedHostAlias, hostAliases.contains(current) { return } selectedHostAlias = hosts.first?.alias } private func bindingForRemoteHost(alias: String) -> Binding { Binding( get: { preferences.enabledRemoteHosts.contains(alias) }, set: { newValue in var hosts = preferences.enabledRemoteHosts if newValue { hosts.insert(alias) } else { hosts.remove(alias) } preferences.enabledRemoteHosts = hosts } ) } private static let relativeFormatter: RelativeDateTimeFormatter = { let f = RelativeDateTimeFormatter() f.unitsStyle = .full return f }() private func syncStatusDescription(for alias: String) -> (String, Color) { guard let state = viewModel.remoteSyncStates[alias] else { return ("Not synced yet", .secondary) } switch state { case .idle: return ("Not synced yet", .secondary) case .syncing: return ("Syncing…", .secondary) case .succeeded(let date): let relative = Self.relativeFormatter.localizedString(for: date, relativeTo: Date()) return ("Last synced \(relative)", .secondary) case .failed(let date, let message): let relative = Self.relativeFormatter.localizedString(for: date, relativeTo: Date()) let detail = Self.syncFailureDetail(from: message) if detail.isEmpty { return ("Sync failed \(relative)", .red) } return ("Sync failed \(relative): \(detail)", .red) } } private static func syncFailureDetail(from rawMessage: String) -> String { let firstLine = rawMessage .split(whereSeparator: \.isNewline) .first .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } ?? "" guard !firstLine.isEmpty else { return "" } let prefix = "sync failed" if firstLine.lowercased().hasPrefix(prefix) { var separators = CharacterSet.whitespacesAndNewlines separators.insert(charactersIn: ":-–—") let remainder = firstLine.dropFirst(prefix.count) let sanitized = String(remainder).trimmingCharacters(in: separators) return sanitized } return firstLine } private func connectionLine(for host: SSHHost) -> String? { var parts: [String] = [] if let user = host.user, !user.isEmpty { parts.append(user + "@") } let hn = host.hostname ?? host.alias var conn = parts.joined() + hn if let port = host.port { conn += ":\(port)" } return conn } } ================================================ FILE: views/SandboxApprovalEditor.swift ================================================ import SwiftUI struct SandboxApprovalEditor: View { @Binding var sandbox: SandboxMode @Binding var approval: ApprovalPolicy @Binding var fullAuto: Bool @Binding var dangerouslyBypass: Bool var body: some View { Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) { GridRow { Text("Sandbox").font(.subheadline) Picker("", selection: $sandbox) { ForEach(SandboxMode.allCases) { s in Text(s.title).tag(s) } } .labelsHidden() .pickerStyle(.segmented) .frame(width: 360) } GridRow { Text("Approval").font(.subheadline) Picker("", selection: $approval) { ForEach(ApprovalPolicy.allCases) { a in Text(a.title).tag(a) } } .labelsHidden() .pickerStyle(.segmented) .frame(width: 360) } GridRow { Text("Presets").font(.subheadline) HStack(spacing: 12) { Toggle("Full Auto", isOn: $fullAuto) Toggle("Danger Bypass", isOn: $dangerouslyBypass) } } } } } ================================================ FILE: views/SandboxPermissionsView.swift ================================================ import SwiftUI struct SandboxPermissionsView: View { @ObservedObject var manager = SandboxPermissionsManager.shared @State private var isRequesting = false var body: some View { VStack(spacing: 20) { Text("Folder Access Permissions") .font(.title) .padding(.top) if !manager.needsAuthorization { VStack(spacing: 12) { Image(systemName: "checkmark.circle.fill") .font(.system(size: 48)) .foregroundStyle(.green) Text("All permissions granted") .font(.headline) Text("CodMate has access to all required directories.") .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { VStack(alignment: .leading, spacing: 16) { Text("CodMate needs access to the following directories:") .font(.headline) Text("Due to App Sandbox security, you must explicitly grant access to these folders. Your data never leaves your Mac.") .font(.caption) .foregroundStyle(.secondary) Divider() ForEach(manager.missingPermissions) { directory in PermissionRow( directory: directory, hasPermission: manager.hasPermission(for: directory), onRequest: { isRequesting = true Task { _ = await manager.requestPermission(for: directory) isRequesting = false } } ) } Divider() if !manager.missingPermissions.isEmpty { Button { isRequesting = true Task { _ = await manager.requestAllMissingPermissions() isRequesting = false } } label: { HStack { if isRequesting { ProgressView() .controlSize(.small) .padding(.trailing, 4) } Text("Grant All Permissions") } .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .controlSize(.large) .disabled(isRequesting) } } .padding() } Spacer() } .frame(minWidth: 500, minHeight: 400) .onAppear { manager.checkPermissions() } } } private struct PermissionRow: View { let directory: SandboxPermissionsManager.RequiredDirectory let hasPermission: Bool let onRequest: () -> Void var body: some View { HStack(spacing: 12) { VStack(alignment: .leading, spacing: 4) { HStack { Text(directory.displayName) .font(.headline) if hasPermission { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) } } Text(directory.description) .font(.caption) .foregroundStyle(.secondary) Text(directory.rawValue) .font(.caption) .foregroundStyle(.tertiary) } Spacer() if !hasPermission { Button("Grant Access") { onRequest() } .buttonStyle(.bordered) } } .padding(.vertical, 8) } } #Preview { SandboxPermissionsView() } ================================================ FILE: views/Search/GlobalSearchPanel.swift ================================================ import SwiftUI struct GlobalSearchPanel: View { @ObservedObject var viewModel: GlobalSearchViewModel let maxWidth: CGFloat let onSelect: (GlobalSearchResult) -> Void let onClose: () -> Void var contentHeight: CGFloat? = nil var body: some View { GlobalSearchPanelContent( viewModel: viewModel, onSelect: onSelect, onClose: onClose, contentHeight: contentHeight, isFloating: true ) .padding(16) .frame(maxWidth: maxWidth) .background( RoundedRectangle(cornerRadius: 18, style: .continuous) .fill(.ultraThinMaterial) ) .overlay( RoundedRectangle(cornerRadius: 18, style: .continuous) .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1) ) .shadow(color: Color.black.opacity(0.2), radius: 22, x: 0, y: 18) } } struct GlobalSearchPopoverPanel: View { @ObservedObject var viewModel: GlobalSearchViewModel @Binding var size: CGSize let minSize: CGSize let maxSize: CGSize let onSelect: (GlobalSearchResult) -> Void let onClose: () -> Void var body: some View { GlobalSearchPanelContent( viewModel: viewModel, onSelect: onSelect, onClose: onClose, contentHeight: size.height, isFloating: false ) .padding(16) .frame(width: size.width) .overlay(alignment: .topTrailing) { GlobalSearchSubmitProxy(viewModel: viewModel) } .overlay(alignment: .bottomLeading) { PopoverResizeHandle( size: $size, minSize: minSize, maxSize: maxSize, expandFromLeadingEdge: true ) } } } private struct GlobalSearchPanelContent: View { @ObservedObject var viewModel: GlobalSearchViewModel let onSelect: (GlobalSearchResult) -> Void let onClose: () -> Void var contentHeight: CGFloat? = nil var isFloating: Bool = false var body: some View { VStack(alignment: .leading, spacing: 12) { header controls content progressRow } } private var header: some View { HStack { Spacer(minLength: 0) Picker("Scope", selection: $viewModel.filter) { ForEach(GlobalSearchFilter.allCases, id: \.self) { filter in Text(filter.title).tag(filter) } } .labelsHidden() .pickerStyle(.segmented) .controlSize(.large) .frame(minWidth: 320, maxWidth: 860) Spacer(minLength: 0) } } private var controls: some View { HStack { Spacer(minLength: 0) ToolbarSearchField( placeholder: "Type to search", text: $viewModel.query, onFocusChange: { viewModel.setFocus($0) }, onSubmit: { viewModel.submit() }, autofocus: viewModel.hasFocus, onCancel: onClose ) .frame(minWidth: 320, maxWidth: 860, minHeight: 36) Spacer(minLength: 0) } .frame(maxWidth: .infinity) } @ViewBuilder private var progressRow: some View { if let progress = viewModel.ripgrepProgress { let summary = "\(progress.message) · Files: \(progress.filesProcessed) · Matches: \(progress.matchesFound)" HStack(spacing: 8) { if progress.isFinished { Image(systemName: progress.isCancelled ? "xmark.circle" : "checkmark.circle") .foregroundStyle(progress.isCancelled ? Color.red : Color.green) } else { ProgressView().controlSize(.small) } Text(summary) .font(.system(size: 10)) .foregroundStyle(.secondary) if !progress.isFinished { Button("Cancel") { viewModel.cancelBackgroundSearch() } .buttonStyle(.bordered) .controlSize(.mini) } } .padding(.vertical, 2) .frame(maxWidth: .infinity, alignment: .trailing) } } @ViewBuilder private var content: some View { let isEmpty = viewModel.filteredResults.isEmpty if viewModel.isSearching && viewModel.filteredResults.isEmpty { HStack(spacing: 8) { ProgressView() .controlSize(.small) Text("Searching…") .font(.system(size: 13)) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 8) } else { ScrollView(showsIndicators: true) { LazyVStack(spacing: 0) { let count = viewModel.filteredResults.count ForEach(Array(viewModel.filteredResults.enumerated()), id: \.1.id) { index, element in Button { onSelect(element) } label: { resultRow(element) .background(rowBackground(for: index, total: count)) } .buttonStyle(.plain) .contentShape(Rectangle()) } } } .frame(height: max(contentHeight ?? CGFloat(isEmpty ? 150 : 320), 150)) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay { if isEmpty { VStack(spacing: 4) { Text("No matches yet") .font(.system(size: 13)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) Text("Try another keyword or widen the scope.") .font(.system(size: 12)) .foregroundStyle(.tertiary) .multilineTextAlignment(.center) if isFloating { Text("Press Esc to close") .font(.system(size: 11)) .foregroundStyle(.tertiary) .multilineTextAlignment(.center) .padding(.top, 2) } } .frame(maxWidth: .infinity, maxHeight: .infinity) } } } } private func resultRow(_ result: GlobalSearchResult) -> some View { HStack(alignment: .top, spacing: 12) { ZStack { Circle() .fill(Color.accentColor.opacity(0.15)) Image(systemName: result.kind.symbolName) .font(.system(size: 14, weight: .medium)) .foregroundStyle(Color.accentColor) } .frame(width: 32, height: 32) VStack(alignment: .leading, spacing: 4) { HStack(alignment: .firstTextBaseline) { Text(result.displayTitle) .font(.system(size: 15, weight: .semibold)) .lineLimit(1) Spacer() if let detail = result.detailLine { Text(detail) .font(.system(size: 11)) .foregroundStyle(.tertiary) } } if let snippet = result.snippet { snippetText(snippet) .font(.system(size: 13)) .foregroundStyle(.secondary) } else if let note = result.note, let comment = note.comment, !comment.isEmpty { Text(clean(comment)) .font(.system(size: 13)) .foregroundStyle(.secondary) .lineLimit(2) } else if let project = result.project, let overview = project.overview { Text(clean(overview)) .font(.system(size: 13)) .foregroundStyle(.secondary) .lineLimit(2) } } } .padding(.horizontal, 10) .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) } private func rowBackground(for index: Int, total: Int) -> some View { let radius: CGFloat = 12 let isFirst = index == 0 let isLast = index == total - 1 let color = Color.white.opacity(index.isMultiple(of: 2) ? 0.05 : 0.02) return UnevenRoundedRectangle( cornerRadii: RectangleCornerRadii( topLeading: isFirst ? radius : 0, bottomLeading: isLast ? radius : 0, bottomTrailing: isLast ? radius : 0, topTrailing: isFirst ? radius : 0 ), style: .continuous ) .fill(color) } private func snippetText(_ snippet: GlobalSearchSnippet) -> Text { let text = snippet.text guard let highlight = snippet.highlightRange else { return Text(text) } let lower = max(0, min(highlight.lowerBound, text.count)) let upper = max(lower, min(highlight.upperBound, text.count)) let startIdx = text.index(text.startIndex, offsetBy: lower) let midIdx = text.index(text.startIndex, offsetBy: upper) let prefix = String(text[.. String { text.sanitizedSnippetText() } } private struct GlobalSearchSubmitProxy: View { @ObservedObject var viewModel: GlobalSearchViewModel var body: some View { Button(action: { viewModel.submit() }) { EmptyView() } .keyboardShortcut(.return, modifiers: []) .opacity(0) .frame(width: 0, height: 0) .allowsHitTesting(false) } } private struct PopoverResizeHandle: View { @Binding var size: CGSize let minSize: CGSize let maxSize: CGSize @State private var dragOrigin: CGSize? var expandFromLeadingEdge = false var body: some View { Image(systemName: iconName) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(.secondary) .padding(8) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in let origin = dragOrigin ?? size if dragOrigin == nil { dragOrigin = size } let deltaWidth = expandFromLeadingEdge ? -value.translation.width : value.translation.width let proposed = CGSize( width: origin.width + deltaWidth, height: origin.height + value.translation.height ) size = CGSize( width: clamp(proposed.width, min: minSize.width, max: maxSize.width), height: clamp(proposed.height, min: minSize.height, max: maxSize.height) ) } .onEnded { _ in dragOrigin = nil } ) .accessibilityLabel("Resize search popover") } private var iconName: String { expandFromLeadingEdge ? "arrow.up.right.and.arrow.down.left" : "arrow.up.left.and.arrow.down.right" } private func clamp(_ value: CGFloat, min: CGFloat, max: CGFloat) -> CGFloat { Swift.min(Swift.max(value, min), max) } } ================================================ FILE: views/Search/ToolbarSearchField.swift ================================================ import SwiftUI #if os(macOS) import AppKit struct ToolbarSearchField: NSViewRepresentable { let placeholder: String @Binding var text: String var onFocusChange: (Bool) -> Void var onSubmit: () -> Void var autofocus: Bool = false var onCancel: (() -> Void)? = nil func makeCoordinator() -> Coordinator { Coordinator(self) } func makeNSView(context: Context) -> NSSearchField { let field = NSSearchField(frame: .zero) field.placeholderString = placeholder field.delegate = context.coordinator field.focusRingType = .none field.sendsSearchStringImmediately = false field.sendsWholeSearchString = true field.cell?.usesSingleLineMode = true field.translatesAutoresizingMaskIntoConstraints = false field.bezelStyle = .roundedBezel if autofocus { DispatchQueue.main.async { if field.window?.firstResponder !== field { field.window?.makeFirstResponder(field) } } } return field } func updateNSView(_ nsView: NSSearchField, context: Context) { let editor = nsView.currentEditor() let window = nsView.window let isFirstResponder = { guard let window else { return false } if let editor { return window.firstResponder === editor } return window.firstResponder === nsView }() if !isFirstResponder, nsView.stringValue != text { nsView.stringValue = text } if nsView.placeholderString != placeholder { nsView.placeholderString = placeholder } let shouldAutofocus = autofocus if shouldAutofocus && !isFirstResponder { // Robust focusing for popover presentation: try now and re-try shortly after DispatchQueue.main.async { [weak nsView] in guard shouldAutofocus, let nsView, let window = nsView.window else { return } if let editor = nsView.currentEditor(), window.firstResponder === editor { return } window.makeFirstResponder(nsView) // Re-try after the popover finishes becoming key to avoid focus races DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak nsView] in guard shouldAutofocus, let nsView = nsView, let window = nsView.window else { return } let editor = nsView.currentEditor() let isFocused = (editor != nil && window.firstResponder === editor) || window.firstResponder === nsView if !isFocused { window.makeFirstResponder(nsView) } } // Final retry for stubborn focus contention DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) { [weak nsView] in guard shouldAutofocus, let nsView = nsView, let window = nsView.window else { return } let editor = nsView.currentEditor() let isFocused = (editor != nil && window.firstResponder === editor) || window.firstResponder === nsView if !isFocused { window.makeFirstResponder(nsView) } } } } } final class Coordinator: NSObject, NSSearchFieldDelegate { let parent: ToolbarSearchField init(_ parent: ToolbarSearchField) { self.parent = parent } @MainActor func controlTextDidBeginEditing(_ obj: Notification) { parent.onFocusChange(true) } @MainActor func controlTextDidEndEditing(_ obj: Notification) { parent.onFocusChange(false) } @MainActor func controlTextDidChange(_ obj: Notification) { guard let field = obj.object as? NSSearchField else { return } if let editor = field.currentEditor() as? NSTextView, editor.hasMarkedText() { return } parent.text = field.stringValue } @MainActor func searchFieldDidEndSearching(_ sender: NSSearchField) { parent.text = sender.stringValue parent.onFocusChange(false) } @MainActor func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { switch commandSelector { case #selector(NSResponder.insertNewline(_:)): parent.onSubmit() return true case #selector(NSResponder.cancelOperation(_:)): let wasEmpty = parent.text.isEmpty parent.text = "" parent.onFocusChange(false) if wasEmpty { parent.onCancel?() } return true default: return false } } } } #endif ================================================ FILE: views/SessionDetailView.swift ================================================ import AppKit import SwiftUI import UniformTypeIdentifiers struct SessionDetailView: View { let summary: SessionSummary let isProcessing: Bool let onResume: () -> Void let onReveal: () -> Void let onDelete: () -> Void @Binding var columnVisibility: NavigationSplitViewVisibility @EnvironmentObject private var viewModel: SessionListViewModel @ObservedObject var preferences: SessionPreferencesStore @State private var turns: [ConversationTurn] = [] // filtered + sorted for display @State private var allTurns: [ConversationTurn] = [] // raw full timeline @State private var loadingTimeline = false @State private var isConversationExpanded = false @State private var expandedTurnIDs: Set = [] @State private var autoExpandVisible = false @State private var searchText: String = "" @State private var expandAllOnSearch = false @State private var nowModeEnabled = true // Auto-scroll to bottom when enabled @State private var timelineRefreshToken = 0 @State private var lastLocalChangeAt: Date? = nil @State private var localActivityClearTask: Task? = nil @State private var inlineFiltersExpanded = false @State private var sessionVisibleKinds: Set = MessageVisibilityKind.timelineDefault @State private var hasSessionVisibleKindsOverride = false @Environment(\.openWindow) private var openWindow @State private var monitor: DirectoryMonitor? = nil @State private var debounceReloadTask: Task? = nil @State private var filterTask: Task? = nil @State private var loadTask: Task<[ConversationTurn], Never>? = nil @State private var environmentExpanded = false @State private var environmentLoading = false @State private var environmentInfo: EnvironmentContextInfo? private let loader = SessionTimelineLoader() // Three-stage loading support @State private var previewTurns: [ConversationTurnPreview] = [] @State private var loadingStage: LoadingStage = .initial enum LoadingStage { case initial // Not started case preview // Showing preview from cache case loading // Loading full data case full // Full data loaded } var body: some View { GeometryReader { proxy in VStack(alignment: .leading, spacing: 16) { if !isConversationExpanded { sessionInfoCard environmentSection instructionsSection Divider() } conversationHeader if inlineFiltersExpanded { inlineFiltersPanel } conversationScrollView } .padding(16) .frame( width: proxy.size.width, height: proxy.size.height, alignment: .topLeading ) } .task(id: summary.id) { await initialLoadAndMonitor() } .onChange(of: searchText, initial: true) { _ in applyFilterAndSort() } .onChange(of: preferences.timelineVisibleKinds, initial: true) { newValue in guard !hasSessionVisibleKindsOverride else { return } sessionVisibleKinds = newValue applyFilterAndSort() } .onReceive(NotificationCenter.default.publisher(for: .codMateConversationFilter)) { note in guard let target = note.userInfo?["sessionId"] as? String, target == summary.id else { return } guard let term = note.userInfo?["term"] as? String else { return } DispatchQueue.main.async { searchText = term expandAllOnSearch = false } } } // moved actions to fixed top bar private var sessionInfoCard: some View { GroupBox { LazyVGrid( columns: [ GridItem(.flexible(), alignment: .topLeading), GridItem(.flexible(), alignment: .topLeading), GridItem(.flexible(), alignment: .topLeading), GridItem(.flexible(), alignment: .topLeading), ], spacing: 12 ) { infoRow( title: "STARTED", value: summary.startedAt.formatted(date: .numeric, time: .shortened), icon: "calendar") infoRow(title: "DURATION", value: summary.readableDuration, icon: "clock") if let model = summary.displayModel ?? summary.model { infoRow(title: "MODEL", value: model, icon: "cpu") } if let approval = summary.approvalPolicy { infoRow(title: "APPROVAL", value: approval, icon: "checkmark.shield") } infoRow(title: "CLI VERSION", value: summary.cliVersion, icon: "terminal") infoRow(title: "ORIGINATOR", value: summary.originator, icon: "person.circle") infoRow( title: "WORKING DIRECTORY", value: viewModel.displayWorkingDirectory(for: summary), icon: "folder") infoRow(title: "FILE SIZE", value: summary.fileSizeDisplay, icon: "externaldrive") } .frame(maxWidth: .infinity, alignment: .leading) } } // metrics moved to list row per request private func infoRow(title: String, value: String, icon: String) -> some View { HStack(alignment: .top, spacing: 12) { Image(systemName: icon) .frame(width: 20) .foregroundStyle(.tertiary) VStack(alignment: .leading, spacing: 2) { Text(title.uppercased()) .font(.caption) .foregroundStyle(.secondary) Text(value) .font(.body) } } } @State private var instructionsExpanded = false @State private var instructionsLoading = false @State private var instructionsText: String? private var environmentSection: some View { GroupBox { DisclosureGroup(isExpanded: $environmentExpanded) { Group { if environmentLoading { ProgressView("Loading environment context…") } else if let info = environmentInfo, info.hasContent { VStack(alignment: .leading, spacing: 6) { ForEach(info.entries) { entry in HStack(alignment: .firstTextBaseline, spacing: 12) { Text(entry.key.uppercased()) .font(.caption) .foregroundStyle(.secondary) .frame(width: 120, alignment: .trailing) Text(entry.value) .font(.body) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } } if let raw = info.rawText, !raw.isEmpty, info.entries.isEmpty { Text(raw) .font(.body) .textSelection(.enabled) } Text( "Captured · \(info.timestamp.formatted(date: .abbreviated, time: .shortened))" ) .font(.caption2) .foregroundStyle(.tertiary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, 2) } else { Text("No environment context captured.") .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) } } .task(id: environmentExpanded) { guard environmentExpanded else { return } guard !summary.source.isRemote else { environmentInfo = nil environmentLoading = false return } guard environmentInfo == nil else { return } environmentLoading = true defer { environmentLoading = false } // Load environment context based on source type if summary.source.baseKind == .gemini { environmentInfo = await viewModel.geminiProvider.environmentContext(for: summary) } else if summary.source.baseKind == .claude { // Claude sessions can also benefit from the new method if needed environmentInfo = try? loader.loadEnvironmentContext(url: summary.fileURL) } else { // Codex sessions use the file-based method environmentInfo = try? loader.loadEnvironmentContext(url: summary.fileURL) } } } label: { Label("Environment Context", systemImage: "macwindow") .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .onTapGesture { environmentExpanded.toggle() } .hoverHand() } } } private var instructionsSection: some View { GroupBox { DisclosureGroup(isExpanded: $instructionsExpanded) { Group { if instructionsLoading { ProgressView("Loading instructions…") } else if let text = instructionsText ?? summary.instructions, !text.isEmpty { Text(text) .font(.system(.body, design: .rounded)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, 2) } else { Text("No instructions found.") .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) } } .task(id: instructionsExpanded) { guard instructionsExpanded else { return } guard instructionsText == nil else { return } if let cached = await viewModel.cachedInstructions(for: summary), !cached.isEmpty { instructionsText = cached return } guard !summary.source.isRemote else { instructionsLoading = false return } instructionsLoading = true defer { instructionsLoading = false } if let loaded = try? loader.loadInstructions(url: summary.fileURL) { instructionsText = loaded } } } label: { Label("Task Instructions", systemImage: "list.bullet.rectangle") .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .onTapGesture { instructionsExpanded.toggle() } .hoverHand() } } } private var conversationHeader: some View { HStack(spacing: 12) { Label("Conversation", systemImage: "bubble.left.and.text.bubble.right") .font(.headline) Spacer() // Search (inline magnifier and clear button, custom style for compatibility) conversationSearchField Button { openWindow(id: "settings") } label: { Label( "Filters", systemImage: "line.3.horizontal.decrease.circle" ) .font(.callout) } .buttonStyle(.borderless) .help("Open Settings to configure message type filters") .hoverHand() // Now mode toggle (mimics Console.app) Button { nowModeEnabled.toggle() } label: { Label { Text("Now") } icon: { ZStack { // Background circle Circle() .fill(nowModeEnabled ? Color.primary : Color.clear) .frame(width: 14, height: 14) // Border circle (only visible when disabled) if !nowModeEnabled { Circle() .strokeBorder(Color.primary, lineWidth: 1.5) .frame(width: 14, height: 14) } // Arrow icon Image(systemName: "arrow.up.backward") .font(.system(size: 8, weight: .medium)) .foregroundColor(nowModeEnabled ? Color(nsColor: .controlBackgroundColor) : Color.primary) } } .font(.callout) } .buttonStyle(.borderless) .help(nowModeEnabled ? "Auto-scroll to latest (Now mode enabled)" : "Enable auto-scroll to latest") .hoverHand() Button { autoExpandVisible.toggle() expandedTurnIDs.removeAll() } label: { Label( autoExpandVisible ? "Collapse Visible" : "Expand Visible", systemImage: autoExpandVisible ? "rectangle.compress.vertical" : "rectangle.expand.vertical" ) .font(.callout) } .buttonStyle(.borderless) .disabled(turns.isEmpty) .help(autoExpandVisible ? "Collapse visible turns" : "Expand only visible turns") .hoverHand() // Refresh current conversation file (match borderless style for consistency) Button { Task { await reloadConversation() } } label: { Label("Refresh", systemImage: "arrow.clockwise") .font(.callout) } .buttonStyle(.borderless) .help("Reload latest records from this session file") .hoverHand() Button { withAnimation(.easeInOut(duration: 0.2)) { // Expand/collapse conversation without altering sidebar visibility isConversationExpanded.toggle() } } label: { Image( systemName: isConversationExpanded ? "arrow.up.right.and.arrow.down.left" // show Restore icon : "arrow.down.left.and.arrow.up.right" // show Expand icon ) .font(.body) } .buttonStyle(.borderless) .help(isConversationExpanded ? "Restore layout" : "Expand conversation") .hoverHand() } } private var conversationScrollView: some View { Group { switch loadingStage { case .initial, .loading: ScrollView { ProgressView("Loading session content…") .frame(maxWidth: .infinity, alignment: .center) .padding(.top, 32) } case .preview: ScrollView { if previewTurns.isEmpty { ProgressView("Loading preview…") .frame(maxWidth: .infinity, alignment: .center) .padding(.top, 32) } else { VStack(alignment: .leading, spacing: 12) { ForEach(previewTurns) { preview in ConversationTurnPreviewCard(preview: preview, branding: summary.source.branding) } } .opacity(0.85) // Visual hint that this is preview data } } case .full: if turns.isEmpty { Group { if #available(macOS 14.0, *) { ContentUnavailableView("No messages to display", systemImage: "text.bubble") } else { UnavailableStateView( "No messages to display", systemImage: "text.bubble", imageFont: .title3, titleFont: .headline ) } } .frame(maxWidth: .infinity, alignment: .center) } else { ConversationTimelineView( turns: turns, expandedTurnIDs: $expandedTurnIDs, refreshToken: timelineRefreshToken, ascending: true, // Fixed: oldest first (newest at bottom) branding: summary.source.branding, allowManualToggle: !autoExpandVisible, autoExpandVisible: autoExpandVisible, isActive: isConversationActive, nowModeEnabled: nowModeEnabled, onNowModeChange: { newValue in nowModeEnabled = newValue } ) .id(autoExpandVisible) } } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) } } // MARK: - Export extension SessionDetailView { // Custom search field to ensure macOS compatibility private var conversationSearchField: some View { HStack(spacing: 6) { Image(systemName: "magnifyingglass") .foregroundStyle(.secondary) .padding(.leading, 4) TextField("Filter in conversation", text: $searchText) .textFieldStyle(.plain) .frame(minWidth: 160) if !searchText.isEmpty { Button { searchText = "" } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.tertiary) } .buttonStyle(.plain) .keyboardShortcut(.cancelAction) .hoverHand() } } .padding(.vertical, 6) .padding(.horizontal, 6) .background( RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color(nsColor: .textBackgroundColor)) .overlay( RoundedRectangle(cornerRadius: 8, style: .continuous) .stroke(Color.secondary.opacity(0.25), lineWidth: 1) ) ) .frame(minWidth: 220) } private var inlineFiltersPanel: some View { VStack(alignment: .leading, spacing: 12) { HStack { Label("Message Type", systemImage: "line.3.horizontal.decrease.circle") .font(.headline) Spacer() if hasSessionVisibleKindsOverride { Button("Reset") { resetInlineFilters() } .buttonStyle(.borderless) .help("Reset to global defaults and clear session overrides") } } ForEach(visibilityGroups, id: \.title) { group in VStack(alignment: .leading, spacing: 6) { Text(group.title) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) LazyVGrid(columns: filterColumns, alignment: .leading, spacing: 8) { ForEach(group.items, id: \.kind) { item in FilterToggleRow( title: item.title, isOn: visibilityBinding(for: item.kind) ) } } } } } .padding(12) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(Color(nsColor: .controlBackgroundColor)) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) .stroke(Color.primary.opacity(0.08), lineWidth: 1) ) ) .shadow(color: Color.black.opacity(0.08), radius: 10, x: 0, y: 4) .transition(.opacity.combined(with: .move(edge: .top))) } private struct VisibilityGroup { let title: String let items: [VisibilityItem] } private struct VisibilityItem: Hashable { let kind: MessageVisibilityKind let title: String } private struct FilterToggleRow: View { let title: String @Binding var isOn: Bool var body: some View { HStack(spacing: 8) { Toggle("", isOn: $isOn) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) Text(title) .font(.callout) .foregroundStyle(.primary) } } } private var filterColumns: [GridItem] { [ GridItem(.flexible(minimum: 120), spacing: 12, alignment: .leading), GridItem(.flexible(minimum: 120), spacing: 12, alignment: .leading), GridItem(.flexible(minimum: 120), spacing: 12, alignment: .leading), GridItem(.flexible(minimum: 120), spacing: 12, alignment: .leading) ] } private var visibilityGroups: [VisibilityGroup] { [ VisibilityGroup(title: "Core", items: [ VisibilityItem(kind: .user, title: MessageVisibilityKind.user.settingsLabel), VisibilityItem(kind: .assistant, title: MessageVisibilityKind.assistant.settingsLabel) ]), VisibilityGroup(title: "Reasoning & Edits", items: [ VisibilityItem(kind: .reasoning, title: MessageVisibilityKind.reasoning.settingsLabel), VisibilityItem(kind: .codeEdit, title: MessageVisibilityKind.codeEdit.settingsLabel) ]), VisibilityGroup(title: "Tools & Tokens", items: [ VisibilityItem(kind: .tool, title: MessageVisibilityKind.tool.settingsLabel), VisibilityItem(kind: .tokenUsage, title: MessageVisibilityKind.tokenUsage.settingsLabel) ]), VisibilityGroup(title: "Other Info", items: [ VisibilityItem(kind: .infoOther, title: MessageVisibilityKind.infoOther.settingsLabel) ]) ] } private func visibilityBinding(for kind: MessageVisibilityKind) -> Binding { Binding( get: { sessionVisibleKinds.contains(kind) }, set: { isOn in if isOn { sessionVisibleKinds.insert(kind) } else { sessionVisibleKinds.remove(kind) } hasSessionVisibleKindsOverride = true Task { await viewModel.updateTimelineVisibleKindsOverride(for: summary.id, kinds: sessionVisibleKinds) } applyFilterAndSort() } ) } private func resetInlineFilters() { hasSessionVisibleKindsOverride = false sessionVisibleKinds = preferences.timelineVisibleKinds Task { await viewModel.clearTimelineVisibleKindsOverride(for: summary.id) } applyFilterAndSort() } // MARK: - Loading helpers private func initialLoadAndMonitor() async { let override = viewModel.timelineVisibleKindsOverride(for: summary.id) sessionVisibleKinds = override ?? preferences.timelineVisibleKinds hasSessionVisibleKindsOverride = override != nil autoExpandVisible = false expandedTurnIDs.removeAll() lastLocalChangeAt = nil localActivityClearTask?.cancel() // Stage 1: Try to load previews from cache (fast path) if let previews = await viewModel.loadTimelinePreviews(for: summary) { await MainActor.run { previewTurns = previews loadingStage = .preview } } // Stage 2: Load full timeline in background await reloadConversation(resetUI: true) // Configure file monitor for live reload monitor?.cancel() monitor = DirectoryMonitor(url: summary.fileURL) { [fileURL = summary.fileURL] in // Debounce rapid write events debounceReloadTask?.cancel() debounceReloadTask = Task { @MainActor in let activityStamp = Date() markLocalActivity(activityStamp) try? await Task.sleep(nanoseconds: 300_000_000) // 300ms // Confirm file still the same session file guard fileURL == summary.fileURL else { return } await reloadConversation() } } } @MainActor private func reloadConversation(resetUI: Bool = false) async { loadingTimeline = true if loadingStage == .initial { loadingStage = .loading } defer { loadingTimeline = false } loadTask?.cancel() if let cached = await viewModel.cachedTimeline(for: summary) { allTurns = cached loadingStage = .full if resetUI { expandedTurnIDs = [] environmentExpanded = false environmentInfo = nil environmentLoading = false } applyFilterAndSort(markRefresh: true) return } let shouldLoadDirectlyFromFile = summary.source.baseKind == .codex && !summary.source.isRemote let loaded: [ConversationTurn] if shouldLoadDirectlyFromFile { let fileURL = summary.fileURL let task: Task<[ConversationTurn], Never> = Task.detached(priority: .userInitiated) { if Task.isCancelled { return [] } let loader = SessionTimelineLoader() return (try? loader.load(url: fileURL)) ?? [] } loadTask = task loaded = await task.value } else { loaded = await viewModel.timeline(for: summary) } loadTask = nil allTurns = loaded loadingStage = .full if resetUI { expandedTurnIDs = [] environmentExpanded = false environmentInfo = nil environmentLoading = false } applyFilterAndSort(markRefresh: true) if !loaded.isEmpty { Task { await viewModel.storeTimeline(loaded, for: summary) await viewModel.updateTimelinePreviews(for: summary, turns: loaded) } } } @MainActor private func applyFilterAndSort(markRefresh: Bool = false) { filterTask?.cancel() let term = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let all = allTurns let kinds = effectiveVisibleKinds let expandOnSearch = expandAllOnSearch let shouldMarkRefresh = markRefresh filterTask = Task.detached(priority: .userInitiated) { var filtered = all if !term.isEmpty { filtered = filtered.filter { turn in containsTerm(turn, term: term) } } filtered = filtered.filtering(visibleKinds: kinds) // Fixed: always sort oldest first (newest at bottom) filtered.sort { a, b in a.timestamp < b.timestamp } let result = filtered await MainActor.run { turns = result if expandOnSearch { autoExpandVisible = true expandedTurnIDs.removeAll() expandAllOnSearch = false } if shouldMarkRefresh { timelineRefreshToken &+= 1 } } } } private var isConversationActive: Bool { viewModel.isActivelyUpdating(summary.id) || isLocallyActive } private var isLocallyActive: Bool { guard let lastLocalChangeAt else { return false } return Date().timeIntervalSince(lastLocalChangeAt) < 3.0 } private func markLocalActivity(_ stamp: Date) { lastLocalChangeAt = stamp localActivityClearTask?.cancel() localActivityClearTask = Task { @MainActor in try? await Task.sleep(nanoseconds: 3_200_000_000) if lastLocalChangeAt == stamp { lastLocalChangeAt = nil } } } private var effectiveVisibleKinds: Set { if hasSessionVisibleKindsOverride { return sessionVisibleKinds .intersection(preferences.timelineVisibleKinds) .subtracting([.turnContext]) } return preferences.timelineVisibleKinds.subtracting([.turnContext]) } private func exportMarkdown() { let panel = NSSavePanel() panel.title = "Export Markdown" panel.allowedContentTypes = [.plainText] let base = sanitizedExportFileName(summary.effectiveTitle, fallback: summary.displayName) panel.nameFieldStringValue = base + ".md" if panel.runModal() == .OK, let url = panel.url { let md = MarkdownExportBuilder.build( session: summary, turns: allTurns, visibleKinds: preferences.markdownVisibleKinds, exportURL: url ) try? md.data(using: String.Encoding.utf8)?.write(to: url) } } } // MARK: - Helpers private func containsTerm(_ turn: ConversationTurn, term: String) -> Bool { func contains(_ s: String?) -> Bool { (s ?? "").lowercased().contains(term) } if contains(turn.userMessage?.text) { return true } for e in turn.outputs { if contains(e.title) || contains(e.text) { return true } if let md = e.metadata, md.values.contains(where: { $0.lowercased().contains(term) }) { return true } } return false } private func sanitizedExportFileName(_ s: String, fallback: String, maxLength: Int = 120) -> String { var text = s.trimmingCharacters(in: .whitespacesAndNewlines) if text.isEmpty { return fallback } let disallowed = CharacterSet(charactersIn: "/:") .union(.newlines) .union(.controlCharacters) text = text.unicodeScalars.map { disallowed.contains($0) ? Character(" ") : Character($0) } .reduce(into: String(), { $0.append($1) }) while text.contains(" ") { text = text.replacingOccurrences(of: " ", with: " ") } text = text.trimmingCharacters(in: .whitespacesAndNewlines) if text.isEmpty { text = fallback } if text.count > maxLength { let idx = text.index(text.startIndex, offsetBy: maxLength) text = String(text[.. @Binding var sortOrder: SessionSortOrder let isLoading: Bool let isEnriching: Bool let enrichmentProgress: Int let enrichmentTotal: Int let onResume: (SessionSummary) -> Void let onReveal: (SessionSummary) -> Void let onDeleteRequest: (SessionSummary) -> Void let onExportMarkdown: (SessionSummary) -> Void // running state probe var isRunning: ((SessionSummary) -> Bool)? = nil // live updating probe (file activity) var isUpdating: ((SessionSummary) -> Bool)? = nil // awaiting follow-up probe var isAwaitingFollowup: ((SessionSummary) -> Bool)? = nil // notify which item is the user's primary (last clicked) for detail focus var onPrimarySelect: ((SessionSummary) -> Void)? = nil // callback for launching new session with task context var onNewSessionWithTaskContext: ((CodMateTask, SessionSummary?, SessionSource, ExternalTerminalProfile) -> Void)? = nil @EnvironmentObject private var viewModel: SessionListViewModel @Environment(\.colorScheme) private var colorScheme @State private var showNewProjectSheet = false @State private var draftTaskFromSession: CodMateTask? = nil @State private var taskEditingMode: EditTaskSheet.Mode = .edit @State private var newProjectPrefill: ProjectEditorSheet.Prefill? = nil @State private var newProjectAssignIDs: [String] = [] @State private var lastClickedID: String? = nil @State private var containerWidth: CGFloat = 0 @FocusState private var quickSearchFocused: Bool var body: some View { VStack(spacing: 0) { header .padding(.horizontal, 8) .padding(.top, 0) .padding(.bottom, 8) contentView } .padding(.vertical, 16) .padding(.horizontal, 6) .sheet(isPresented: $showNewProjectSheet) { ProjectEditorSheet( isPresented: $showNewProjectSheet, mode: .new, prefill: newProjectPrefill, autoAssignSessionIDs: newProjectAssignIDs ) .environmentObject(viewModel) } .sheet(item: $draftTaskFromSession) { task in if let workspaceVM = viewModel.workspaceVM { EditTaskSheet( task: task, mode: taskEditingMode, workspaceVM: workspaceVM, onSave: { updatedTask in Task { await workspaceVM.updateTask(updatedTask) draftTaskFromSession = nil } }, onCancel: { draftTaskFromSession = nil } ) } } .background( GeometryReader { geo in Color.clear .preference(key: ListColumnWidthKey.self, value: geo.size.width) } ) .onPreferenceChange(ListColumnWidthKey.self) { w in containerWidth = w } } @ViewBuilder private var contentView: some View { // In Tasks mode, show TaskListView instead of regular sessions list if viewModel.projectWorkspaceMode == .tasks, let workspaceVM = viewModel.workspaceVM { TaskListView( workspaceVM: workspaceVM, selection: $selection, onResume: onResume, onReveal: onReveal, onDeleteRequest: onDeleteRequest, onExportMarkdown: onExportMarkdown, isRunning: isRunning, isUpdating: isUpdating, isAwaitingFollowup: isAwaitingFollowup, onPrimarySelect: onPrimarySelect, onNewSessionWithTaskContext: onNewSessionWithTaskContext ) } else { // Regular sessions list for other modes if sections.isEmpty { if isLoading { VStack { Spacer() ProgressView("Scanning…") Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.horizontal, -2) } else { emptyStateView .padding(.horizontal, -2) } } else { sessionsListView } } } private var emptyStateView: some View { let selected = selectedProject() let isOtherProject = selected?.id == SessionListViewModel.otherProjectId return ZStack { Color.clear VStack(spacing: 12) { Spacer(minLength: 12) // Different message for Other project bucket if isOtherProject { Group { if #available(macOS 14.0, *) { unavailableView( title: "No Unassigned Sessions", systemImage: "tray", description: "Sessions can only be created within a project. Select a project from the sidebar to start a new session." ) } else { fallbackUnavailableView( title: "No Unassigned Sessions", systemImage: "tray", description: "Sessions can only be created within a project. Select a project from the sidebar to start a new session." ) } } .frame(maxWidth: .infinity) } else { Group { if #available(macOS 14.0, *) { unavailableView( title: "No Sessions", systemImage: "tray", description: "Adjust directories or launch Codex CLI to generate new session logs." ) } else { fallbackUnavailableView( title: "No Sessions", systemImage: "tray", description: "Adjust directories or launch Codex CLI to generate new session logs." ) } } .frame(maxWidth: .infinity) } // Primary action: New (hidden for Other project, shown for regular projects) if let project = selected, !isOtherProject { let embeddedPreferredNew = viewModel.preferences.defaultResumeUseEmbeddedTerminal && !AppSandbox.isEnabled let anchor = projectAnchor(for: project) SplitPrimaryMenuButton( title: "New", systemImage: "plus", primary: { if embeddedPreferredNew { // Defer to shared embedded flow (exactly as detail bar does) viewModel.newSession(project: project) } else { startExternalNewForProject(project) } }, items: buildNewMenuItems(anchor: anchor, project: project) ) .help("Start a new session in \(projectDisplayName(project))") } else if !isOtherProject { SplitPrimaryMenuButton( title: "New", systemImage: "plus", primary: {}, items: [] ) .opacity(0.6) .help("Select a project in the sidebar to start a new session") } Spacer() } } .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(Rectangle()) .contextMenu { backgroundContextMenu() } } @available(macOS 14.0, *) private func unavailableView(title: String, systemImage: String, description: String) -> some View { ContentUnavailableView(title, systemImage: systemImage, description: Text(description)) } private func fallbackUnavailableView(title: String, systemImage: String, description: String) -> some View { UnavailableStateView( title, systemImage: systemImage, description: description, titleColor: .primary ) } @ViewBuilder private var sessionsListView: some View { List(selection: $selection) { ForEach(sections) { section in Section { ForEach(section.sessions, id: \.id) { session in sessionRow(for: session) } } header: { HStack { Text(section.title) Spacer() Label(section.totalDuration.readableFormattedDuration, systemImage: "clock") Label("\(section.totalEvents)", systemImage: "chart.bar") } .font(.subheadline) .foregroundStyle(.secondary) } } } .padding(.horizontal, -2) .listStyle(.inset) .contextMenu { backgroundContextMenu() } } @ViewBuilder private func sessionRow(for session: SessionSummary) -> some View { EquatableSessionListRow( summary: session, isRunning: isRunning?(session) ?? false, isSelected: selectionContains(session.id), isUpdating: isUpdating?(session) ?? false, awaitingFollowup: isAwaitingFollowup?(session) ?? false, inProject: viewModel.projectIdForSession(session.id) != nil, projectTip: projectTip(for: session), inTaskContainer: false ) .tag(session.id) .contentShape(Rectangle()) .onTapGesture(count: 2) { selection = [session.id] onPrimarySelect?(session) Task { await viewModel.beginEditing(session: session) } } .onTapGesture { handleClick(on: session) } .onDrag { let ids: [String] if selectionContains(session.id) && selection.count > 1 { ids = Array(selection) } else { ids = [session.id] } let payloads: [String] = ids.compactMap { id in if let summary = viewModel.sessionSummary(for: id) { return viewModel.sessionDragIdentifier(for: summary) } return id } return NSItemProvider(object: payloads.joined(separator: "\n") as NSString) } .listRowInsets(EdgeInsets()) .contextMenu { sessionContextMenu(for: session) } } @ViewBuilder private func sessionContextMenu(for session: SessionSummary) -> some View { let project = projectForSession(session) if session.source == .codexLocal || session.source == .geminiLocal { let resumeItems = buildResumeMenuItems(for: session) if !resumeItems.isEmpty { Menu { SplitMenuItemsView(items: resumeItems) } label: { let icon = assetIconForSessionSource(session.source) Label { Text("Resume") } icon: { if let menuIcon = menuAssetNSImage( named: icon, invertForDarkMode: icon == "ChatGPTIcon" && colorScheme == .dark ) { Image(nsImage: menuIcon) .frame(width: 14, height: 14) } else { Image(icon) .resizable() .scaledToFit() .frame(width: 14, height: 14) .clipped() .modifier(DarkModeInvertModifier(active: icon == "ChatGPTIcon" && colorScheme == .dark)) } } } } } Divider() Button { Task { await viewModel.beginEditing(session: session) } } label: { Label("Edit Title & Comment", systemImage: "pencil") } Button { Task { @MainActor in await viewModel.generateTitleAndComment(for: session, force: false) } } label: { Label("Generate Title & Comment", systemImage: "sparkles") } if let project, project.id != SessionListViewModel.otherProjectId { let newItems = buildNewMenuItems(anchor: session, project: project) if newItems.isEmpty { Button { viewModel.newSession(project: project) } label: { Label("New Session", systemImage: "plus") } } else { Menu { SplitMenuItemsView(items: newItems) } label: { Label("New Session…", systemImage: "plus") } } Button { draftTaskFromSession = CodMateTask( title: "", description: nil, projectId: project.id, sessionIds: [session.id] ) } label: { Label("New Task…", systemImage: "checklist") } } if !viewModel.projects.isEmpty { Menu { Button { newProjectPrefill = prefillForProject(from: session) newProjectAssignIDs = [session.id] showNewProjectSheet = true } label: { Label("New Project…", systemImage: "square.grid.2x2") } Divider() ForEach(viewModel.projects) { p in Button(p.name.isEmpty ? p.id : p.name) { Task { await viewModel.assignSessions(to: p.id, ids: [session.id]) } } } } label: { Label("Assign to Project…", systemImage: "rectangle.stack.badge.plus") } } Button { onExportMarkdown(session) } label: { Label("Export Markdown", systemImage: "square.and.arrow.up") } Divider() Button { copyAbsolutePath(session) } label: { Label("Copy Absolute Path", systemImage: "doc.on.doc") } Button { onReveal(session) } label: { Label("Reveal in Finder", systemImage: "finder") } Button(role: .destructive) { if !selectionContains(session.id) { selection = [session.id] } onDeleteRequest(session) } label: { let isBatchDelete = selectionContains(session.id) && selection.count > 1 Label( isBatchDelete ? "Move Sessions to Trash" : "Move Session to Trash", systemImage: "trash") } if shouldShowTaskCollapseControls { Divider() Button { postTaskCollapseNotification(.codMateCollapseAllTasks) } label: { Label("Collapse all Tasks", systemImage: "arrow.down.right.and.arrow.up.left") } Button { postTaskCollapseNotification(.codMateExpandAllTasks) } label: { Label("Expand all Tasks", systemImage: "arrow.up.left.and.arrow.down.right") } } } private var header: some View { VStack(alignment: .leading, spacing: 8) { // Quick search with optional Task collapse controls in Tasks mode HStack(spacing: 8) { HStack(spacing: 6) { Image(systemName: "magnifyingglass") .foregroundStyle(.secondary) .padding(.leading, 4) TextField("Search title or comment", text: $viewModel.quickSearchText) .textFieldStyle(.plain) .focused($quickSearchFocused) .onSubmit { viewModel.immediateApplyQuickSearch(viewModel.quickSearchText) } if !viewModel.quickSearchText.isEmpty { Button { viewModel.quickSearchText = "" } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.tertiary) } .buttonStyle(.plain) } } .padding(.vertical, 6) .padding(.horizontal, 6) .background( RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color(nsColor: .textBackgroundColor)) .overlay( RoundedRectangle(cornerRadius: 8, style: .continuous) .stroke(Color.secondary.opacity(0.25), lineWidth: 1) ) ) .frame(maxWidth: .infinity) // 当全局搜索触发时,确保本地搜索框让出焦点,避免与 Cmd+F 竞争 .onReceive(NotificationCenter.default.publisher(for: .codMateFocusGlobalSearch)) { _ in quickSearchFocused = false } if shouldShowTaskCollapseControls { CollapseExpandButtonGroup( collapseHelp: "Collapse all Tasks", expandHelp: "Expand all Tasks", onCollapse: { postTaskCollapseNotification(.codMateCollapseAllTasks) }, onExpand: { postTaskCollapseNotification(.codMateExpandAllTasks) } ) } } HStack(spacing: 8) { EqualWidthSegmentedControl( items: Array(SessionSortOrder.allCases), selection: $sortOrder, title: { $0.title } ) .frame(maxWidth: .infinity) } .transition(.opacity.combined(with: .move(edge: .leading))) } .frame(maxWidth: .infinity) } } extension SessionListColumnView { fileprivate var shouldShowTaskCollapseControls: Bool { viewModel.projectWorkspaceMode == .tasks && viewModel.workspaceVM != nil } fileprivate func postTaskCollapseNotification(_ name: Notification.Name) { var info: [AnyHashable: Any]? = nil if let projectId = viewModel.selectedProjectIDs.first { info = ["projectId": projectId] } NotificationCenter.default.post(name: name, object: nil, userInfo: info) } } private struct ListColumnWidthKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } } extension SessionListColumnView { private func selectedProject() -> Project? { guard viewModel.selectedProjectIDs.count == 1, let pid = viewModel.selectedProjectIDs.first else { return nil } // Check if it's the synthetic Other project if pid == SessionListViewModel.otherProjectId { return Project( id: SessionListViewModel.otherProjectId, name: "Other", directory: nil, trustLevel: nil, overview: nil, instructions: nil, profileId: nil, profile: nil, parentId: nil, sources: ProjectSessionSource.allSet ) } return viewModel.projects.first(where: { $0.id == pid }) } private func projectDisplayName(_ p: Project) -> String { let trimmed = p.name.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return trimmed } if let dir = p.directory, !dir.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let base = URL(fileURLWithPath: dir, isDirectory: true).lastPathComponent return base.isEmpty ? p.id : base } return p.id } private func projectForSession(_ session: SessionSummary) -> Project? { guard let pid = viewModel.projectIdForSession(session.id) else { return nil } if pid == SessionListViewModel.otherProjectId { return nil } return viewModel.projects.first(where: { $0.id == pid }) } func selectionContains(_ id: SessionSummary.ID) -> Bool { selection.contains(id) } private func backgroundContextMenu() -> some View { let project = selectedProject() let anchor = project.flatMap { projectAnchor(for: $0) } return Group { if let project { newSessionMenu(for: project, anchor: anchor) if viewModel.workspaceVM != nil { Button { taskEditingMode = .new draftTaskFromSession = CodMateTask(title: "", description: nil, projectId: selectedProject()?.id ?? "") } label: { Label("New Task…", systemImage: "checklist") } } } if shouldShowTaskCollapseControls { Divider() Button { postTaskCollapseNotification(.codMateCollapseAllTasks) } label: { Label("Collapse all Tasks", systemImage: "arrow.down.right.and.arrow.up.left") } Button { postTaskCollapseNotification(.codMateExpandAllTasks) } label: { Label("Expand all Tasks", systemImage: "arrow.up.left.and.arrow.down.right") } } } } private func projectAnchor(for project: Project) -> SessionSummary? { // Prefer currently visible sessions for this project; fall back to any cached session. if let visible = sections.flatMap({ $0.sessions }).first( where: { viewModel.projectIdForSession($0.id) == project.id }) { return visible } return viewModel.allSessions.first { viewModel.projectIdForSession($0.id) == project.id } } // Build external Terminal flow exactly like newSession(project:) external branch, // but force external when App Sandbox blocks embedded terminals. private func startExternalNewForProject(_ project: Project) { guard let profile = ExternalTerminalProfileStore.shared.resolvePreferredProfile( id: viewModel.preferences.defaultResumeExternalAppId ) else { return } let dir: String = { let d = (project.directory ?? "").trimmingCharacters(in: .whitespacesAndNewlines) return d.isEmpty ? NSHomeDirectory() : d }() let command = buildProjectCommand(project: project, directory: dir) if profile.usesWarpCommands { guard viewModel.copyNewProjectCommandsIfEnabled(project: project, destinationApp: profile) else { return } viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir) } else { if profile.isNone { _ = viewModel.copyNewProjectCommandsIfEnabled(project: project, destinationApp: profile) if viewModel.shouldCopyCommandsToClipboard && viewModel.preferences.commandCopyNotificationsEnabled { Task { await SystemNotifier.shared.notify( title: "CodMate", body: "Command copied. Paste it in the opened terminal.") } } return } if !profile.supportsCommandResolved, viewModel.shouldCopyCommandsToClipboard { let pb = NSPasteboard.general pb.clearContents() pb.setString(command + "\n", forType: .string) } if profile.isTerminal { _ = viewModel.openAppleTerminal(at: dir) } else { let cmd = profile.supportsCommandResolved ? command : nil viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir, command: cmd) } } if viewModel.shouldCopyCommandsToClipboard && viewModel.preferences.commandCopyNotificationsEnabled { Task { await SystemNotifier.shared.notify( title: "CodMate", body: "Command copied. Paste it in the opened terminal.") } } // Hint + targeted refresh aligns with viewModel.newSession external path viewModel.setIncrementalHintForCodexToday() Task { await viewModel.refreshIncrementalForNewCodexToday() } } private func buildProjectCommand(project: Project, directory: String) -> String { let cd = "cd " + directory.replacingOccurrences(of: " ", with: "\\ ") let cmd = viewModel.buildNewProjectCLIInvocation(project: project) return cd + "\n" + cmd } private func projectTip(for session: SessionSummary) -> String? { guard let pid = viewModel.projectIdForSession(session.id), let p = viewModel.projects.first(where: { $0.id == pid }) else { return nil } let name = p.name.trimmingCharacters(in: .whitespacesAndNewlines) let display = name.isEmpty ? p.id : name let raw = (p.overview ?? "").trimmingCharacters(in: .whitespacesAndNewlines) guard !raw.isEmpty else { return display } let snippet = raw.count > 20 ? String(raw.prefix(20)) + "…" : raw return display + "\n" + snippet } private func prefillForProject(from session: SessionSummary) -> ProjectEditorSheet.Prefill { let dir = FileManager.default.fileExists(atPath: session.cwd) ? session.cwd : session.fileURL.deletingLastPathComponent().path var name = session.userTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if name.isEmpty { name = URL(fileURLWithPath: dir, isDirectory: true).lastPathComponent } // overview: prefer userComment; fallback instruction snippet let overview = (session.userComment?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { $0.isEmpty ? nil : $0 } ?? (session.instructions?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { s in if s.isEmpty { return nil } // limit to ~220 chars to keep it short return s.count <= 220 ? s : String(s.prefix(220)) + "…" } return ProjectEditorSheet.Prefill( name: name, directory: dir, trustLevel: nil, overview: overview, profileId: nil ) } private func handleClick(on session: SessionSummary) { // Determine current modifiers (command/control/shift) let mods = NSApp.currentEvent?.modifierFlags ?? [] let isToggle = mods.contains(.command) || mods.contains(.control) let isRange = mods.contains(.shift) let id = session.id if isRange, let anchor = lastClickedID { let flat = sections.flatMap { $0.sessions.map(\.id) } if let a = flat.firstIndex(of: anchor), let b = flat.firstIndex(of: id) { let lo = min(a, b) let hi = max(a, b) let rangeIDs = Set(flat[lo...hi]) selection = rangeIDs } else { selection = [id] } onPrimarySelect?(session) } else if isToggle { if selection.contains(id) { selection.remove(id) } else { selection.insert(id) } lastClickedID = id onPrimarySelect?(session) } else { selection = [id] lastClickedID = id onPrimarySelect?(session) } } private func workingDirectory(for session: SessionSummary) -> String { viewModel.resolvedWorkingDirectory(for: session) } private func assetIconForSessionSource(_ source: SessionSource) -> String { switch source.baseKind { case .codex: return "ChatGPTIcon" case .claude: return "ClaudeIcon" case .gemini: return "GeminiIcon" } } private func buildResumeMenuItems(for session: SessionSummary) -> [SplitMenuItem] { var items: [SplitMenuItem] = [] if viewModel.preferences.isEmbeddedTerminalEnabled { items.append( SplitMenuItem( id: "resume-embedded-\(session.id)", kind: .action( title: "CodMate", systemImage: "macwindow", run: { NotificationCenter.default.post( name: .codMateResumeSession, object: nil, userInfo: ["sessionId": session.id, "forceEmbedded": true] ) } ) ) ) } for profile in externalTerminalOrderedProfiles(includeNone: false) { items.append( SplitMenuItem( id: "resume-\(profile.id)-\(session.id)", kind: .action( title: profile.displayTitle, systemImage: "terminal", run: { NotificationCenter.default.post( name: .codMateResumeSession, object: nil, userInfo: ["sessionId": session.id, "profileId": profile.id] ) } ) ) ) } return items } private func launchNewSession( for session: SessionSummary, using source: SessionSource, profile: ExternalTerminalProfile ) { let dir = workingDirectory(for: session) viewModel.launchNewSessionWithProfile( session: session, using: source, profile: profile, workingDirectory: dir ) } private func copyAbsolutePath(_ session: SessionSummary) { let pb = NSPasteboard.general pb.clearContents() pb.setString(session.fileURL.path, forType: .string) } // Build menu items matching Timeline “New” split control for a given session anchor. private func buildNewMenuItems(anchor: SessionSummary?, project: Project? = nil) -> [SplitMenuItem] { let allowed: Set if let anchor { allowed = Set(viewModel.allowedSources(for: anchor)) } else if let project { let sources = project.sources.isEmpty ? ProjectSessionSource.allSet : project.sources allowed = Set(sources.filter { viewModel.preferences.isCLIEnabled($0.baseKind) }) } else { allowed = Set(ProjectSessionSource.allCases.filter { viewModel.preferences.isCLIEnabled($0.baseKind) }) } let requestedOrder: [ProjectSessionSource] = [.claude, .codex, .gemini] let enabledRemoteHosts = viewModel.preferences.enabledRemoteHosts.sorted() func sourceKey(_ source: SessionSource) -> String { switch source { case .codexLocal: return "codex-local" case .codexRemote(let host): return "codex-\(host)" case .claudeLocal: return "claude-local" case .claudeRemote(let host): return "claude-\(host)" case .geminiLocal: return "gemini-local" case .geminiRemote(let host): return "gemini-\(host)" } } func launchItems(for source: SessionSource) -> [SplitMenuItem] { let key = sourceKey(source) var items = externalTerminalMenuItems(idPrefix: key) { profile in if let anchor { launchNewSession(for: anchor, using: source, profile: profile) } else if let project { viewModel.launchNewSessionFromProject(project: project, using: source, profile: profile) } } if viewModel.preferences.isEmbeddedTerminalEnabled { let embedded = embeddedTerminalProfile() items.insert( SplitMenuItem( id: "\(key)-\(embedded.id)", kind: .action( title: embedded.displayTitle, systemImage: "macwindow", run: { if let anchor { launchNewSession(for: anchor, using: source, profile: embedded) } else if let project { viewModel.launchNewSessionFromProject( project: project, using: source, profile: embedded) } }) ), at: 0) } return items } func remoteSource(for base: ProjectSessionSource, host: String) -> SessionSource { switch base { case .codex: return .codexRemote(host: host) case .claude: return .claudeRemote(host: host) case .gemini: return .geminiRemote(host: host) } } func providerAssetIcon(_ source: ProjectSessionSource) -> String { switch source { case .codex: return "ChatGPTIcon" case .claude: return "ClaudeIcon" case .gemini: return "GeminiIcon" } } var menuItems: [SplitMenuItem] = [] for base in requestedOrder where allowed.contains(base) { var providerItems = launchItems(for: base.sessionSource) if !enabledRemoteHosts.isEmpty { providerItems.append(.init(kind: .separator)) for host in enabledRemoteHosts { let remote = remoteSource(for: base, host: host) providerItems.append( .init( id: "remote-\(base.rawValue)-\(host)", kind: .submenu(title: host, systemImage: "network", items: launchItems(for: remote)) )) } } menuItems.append( .init( id: "provider-\(base.rawValue)", kind: .submenu(title: base.displayName, assetImage: providerAssetIcon(base), items: providerItems) )) } if menuItems.isEmpty, let anchor { let fallback = anchor.source menuItems.append( .init( id: "fallback-\(sourceKey(fallback))", kind: .submenu( title: fallback.branding.displayName, systemImage: "terminal", items: launchItems(for: fallback) ))) } return menuItems } private func newSessionMenu(for project: Project, anchor: SessionSummary?) -> some View { let items = buildNewMenuItems(anchor: anchor, project: project) return Menu { SplitMenuItemsView(items: items) } label: { Label("New Session…", systemImage: "plus") } } } // SplitPrimaryMenuButton and helpers are shared in SplitControls.swift // Native NSSearchField wrapper to get unified macOS search field chrome private struct SearchField: NSViewRepresentable { let placeholder: String @Binding var text: String var onSubmit: ((String) -> Void)? = nil init(_ placeholder: String, text: Binding, onSubmit: ((String) -> Void)? = nil) { self.placeholder = placeholder self._text = text self.onSubmit = onSubmit } func makeCoordinator() -> Coordinator { Coordinator(self) } func makeNSView(context: Context) -> NSSearchField { let field = NSSearchField(frame: .zero) field.placeholderString = placeholder field.delegate = context.coordinator field.focusRingType = .none // Avoid premature submit during IME composition; we handle Return/Escape in delegate instead field.sendsSearchStringImmediately = false field.sendsWholeSearchString = true // Do not steal initial focus; if the system puts focus here, drop it back to window DispatchQueue.main.async { if let win = field.window, win.firstResponder === field || win.firstResponder === field.currentEditor() { win.makeFirstResponder(nil) } } context.coordinator.configure(field: field) return field } func updateNSView(_ nsView: NSSearchField, context: Context) { // Avoid programmatic writes while user is editing (prevents breaking IME composition) if let editor = nsView.currentEditor(), nsView.window?.firstResponder === editor { return } if nsView.stringValue != text { nsView.stringValue = text } if nsView.placeholderString != placeholder { nsView.placeholderString = placeholder } } class Coordinator: NSObject, NSSearchFieldDelegate { var parent: SearchField weak var field: NSSearchField? private var observers: [NSObjectProtocol] = [] private var isFocusBlocked = false init(_ parent: SearchField) { self.parent = parent } deinit { for observer in observers { NotificationCenter.default.removeObserver(observer) } } func configure(field: NSSearchField) { self.field = field field.refusesFirstResponder = isFocusBlocked if observers.isEmpty { let center = NotificationCenter.default let resign = center.addObserver( forName: .codMateResignQuickSearch, object: nil, queue: .main ) { [weak self] _ in self?.resignIfNeeded() } let block = center.addObserver( forName: .codMateQuickSearchFocusBlocked, object: nil, queue: .main ) { [weak self] note in Task { @MainActor in self?.handleFocusBlocked(note: note) } } observers.append(contentsOf: [resign, block]) } } private func resignIfNeeded() { guard let field, let window = field.window else { return } if window.firstResponder === field || window.firstResponder === field.currentEditor() { window.makeFirstResponder(nil) } } @MainActor private func handleFocusBlocked(note: Notification) { let active = (note.userInfo?["active"] as? Bool) ?? false isFocusBlocked = active field?.refusesFirstResponder = active if active { resignIfNeeded() } } @MainActor func controlTextDidChange(_ notification: Notification) { guard let field = notification.object as? NSSearchField else { return } // Skip updates while IME is composing (marked text present) if let editor = field.currentEditor() as? NSTextView, editor.hasMarkedText() { return } parent.text = field.stringValue } @MainActor func searchFieldDidEndSearching(_ sender: NSSearchField) { let value = sender.stringValue parent.text = value parent.onSubmit?(value) } // Intercept Return/Escape; respect IME composition @MainActor func control( _ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector ) -> Bool { // If composing with IME, let the editor handle the key (do not submit) if textView.hasMarkedText() { return false } if commandSelector == #selector(NSResponder.insertNewline(_:)) { let value = textView.string parent.text = value parent.onSubmit?(value) return true } if commandSelector == #selector(NSResponder.cancelOperation(_:)) { parent.text = "" parent.onSubmit?("") return true } return false } } } // MARK: - Equal-width segmented control backed by NSSegmentedControl private struct EqualWidthSegmentedControl: NSViewRepresentable { let items: [Item] @Binding var selection: Item var title: (Item) -> String func makeCoordinator() -> Coordinator { Coordinator(self) } func makeNSView(context: Context) -> NSView { let container = NSView() container.translatesAutoresizingMaskIntoConstraints = false let control = NSSegmentedControl() control.translatesAutoresizingMaskIntoConstraints = false control.segmentStyle = .rounded control.trackingMode = .selectOne control.target = context.coordinator control.action = #selector(Coordinator.changed(_:)) rebuildSegments(control) if #available(macOS 13.0, *) { control.segmentDistribution = .fillEqually } control.setContentHuggingPriority(.defaultLow, for: .horizontal) control.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) container.addSubview(control) NSLayoutConstraint.activate([ control.leadingAnchor.constraint(equalTo: container.leadingAnchor), control.trailingAnchor.constraint(equalTo: container.trailingAnchor), control.topAnchor.constraint(equalTo: container.topAnchor), control.bottomAnchor.constraint(equalTo: container.bottomAnchor), ]) context.coordinator.control = control return container } func updateNSView(_ container: NSView, context: Context) { guard let control = context.coordinator.control else { return } if control.segmentCount != items.count { rebuildSegments(control) } // Update labels if needed for (i, it) in items.enumerated() { control.setLabel(title(it), forSegment: i) } // Selection if let idx = items.firstIndex(of: selection) { control.selectedSegment = idx } else { control.selectedSegment = -1 } // Ensure segments expand after the middle column resizes from 0 → normal. let containerWidth = container.bounds.width if context.coordinator.lastContainerWidth != containerWidth { context.coordinator.lastContainerWidth = containerWidth if #available(macOS 13.0, *) { control.segmentDistribution = .fillEqually } // Force a fresh layout pass now and in next runloop to avoid "scrunched" state. control.invalidateIntrinsicContentSize() control.needsLayout = true control.layoutSubtreeIfNeeded() DispatchQueue.main.async { control.invalidateIntrinsicContentSize() control.needsLayout = true control.layoutSubtreeIfNeeded() } } if #available(macOS 13.0, *) { // Nothing else; fillEqually handles widths. } else { // Fallback: try to equalize manually each update let superWidth = control.superview?.bounds.width ?? containerWidth if superWidth > 0 { let width = max(60.0, superWidth / CGFloat(max(1, items.count))) for i in 0..= 0 && idx < parent.items.count else { return } parent.selection = parent.items[idx] } } } extension TimeInterval { fileprivate var readableFormattedDuration: String { let formatter = DateComponentsFormatter() formatter.allowedUnits = durationUnits formatter.unitsStyle = .abbreviated return formatter.string(from: self) ?? "—" } private var durationUnits: NSCalendar.Unit { if self >= 3600 { return [.hour, .minute] } else if self >= 60 { return [.minute, .second] } return [.second] } } #Preview { // Mock SessionDaySection data let mockSections = [ SessionDaySection( id: Date().addingTimeInterval(-86400), // Yesterday title: "Yesterday", totalDuration: 7200, // 2 hours totalEvents: 15, sessions: [ SessionSummary( id: "session-1", fileURL: URL( fileURLWithPath: "/Users/developer/.codex/sessions/session-1.json"), fileSizeBytes: 12340, startedAt: Date().addingTimeInterval(-7200), endedAt: Date().addingTimeInterval(-3600), activeDuration: nil, cliVersion: "1.2.3", cwd: "/Users/developer/projects/codmate", originator: "developer", instructions: "Optimize SwiftUI list performance", model: "gpt-4o-mini", approvalPolicy: "auto", userMessageCount: 3, assistantMessageCount: 2, toolInvocationCount: 1, responseCounts: [:], turnContextCount: 5, totalTokens: 740, eventCount: 6, lineCount: 89, lastUpdatedAt: Date().addingTimeInterval(-3600), source: .codexLocal, remotePath: nil ), SessionSummary( id: "session-2", fileURL: URL( fileURLWithPath: "/Users/developer/.codex/sessions/session-2.json"), fileSizeBytes: 8900, startedAt: Date().addingTimeInterval(-10800), endedAt: Date().addingTimeInterval(-9000), activeDuration: nil, cliVersion: "1.2.3", cwd: "/Users/developer/projects/test", originator: "developer", instructions: "Create a to-do app", model: "gpt-4o", approvalPolicy: "manual", userMessageCount: 4, assistantMessageCount: 3, toolInvocationCount: 2, responseCounts: ["reasoning": 1], turnContextCount: 7, totalTokens: 1120, eventCount: 9, lineCount: 120, lastUpdatedAt: Date().addingTimeInterval(-9000), source: .codexLocal, remotePath: nil ), ] ), SessionDaySection( id: Date().addingTimeInterval(-172800), // Day before yesterday title: "Dec 15, 2024", totalDuration: 5400, // 1.5 hours totalEvents: 12, sessions: [ SessionSummary( id: "session-3", fileURL: URL( fileURLWithPath: "/Users/developer/.codex/sessions/session-3.json"), fileSizeBytes: 15600, startedAt: Date().addingTimeInterval(-172800), endedAt: Date().addingTimeInterval(-158400), activeDuration: nil, cliVersion: "1.2.2", cwd: "/Users/developer/documents", originator: "developer", instructions: "Write technical documentation", model: "gpt-4o-mini", approvalPolicy: "auto", userMessageCount: 6, assistantMessageCount: 5, toolInvocationCount: 3, responseCounts: ["reasoning": 2], turnContextCount: 11, totalTokens: 2100, eventCount: 14, lineCount: 200, lastUpdatedAt: Date().addingTimeInterval(-158400), source: .codexLocal, remotePath: nil ) ] ), ] SessionListColumnView( sections: mockSections, selection: .constant(Set()), sortOrder: .constant(.mostRecent), isLoading: false, isEnriching: false, enrichmentProgress: 0, enrichmentTotal: 0, onResume: { session in print("Resume: \(session.displayName)") }, onReveal: { session in print("Reveal: \(session.displayName)") }, onDeleteRequest: { session in print("Delete: \(session.displayName)") }, onExportMarkdown: { session in print("Export: \(session.displayName)") } ) .frame(width: 500, height: 600) } #Preview("Loading State") { SessionListColumnView( sections: [], selection: .constant(Set()), sortOrder: .constant(.mostRecent), isLoading: true, isEnriching: false, enrichmentProgress: 0, enrichmentTotal: 0, onResume: { _ in }, onReveal: { _ in }, onDeleteRequest: { _ in }, onExportMarkdown: { _ in } ) .frame(width: 500, height: 600) } #Preview("Empty State") { SessionListColumnView( sections: [], selection: .constant(Set()), sortOrder: .constant(.mostRecent), isLoading: false, isEnriching: false, enrichmentProgress: 0, enrichmentTotal: 0, onResume: { _ in }, onReveal: { _ in }, onDeleteRequest: { _ in }, onExportMarkdown: { _ in } ) .frame(width: 500, height: 600) } ================================================ FILE: views/SessionListRowView.swift ================================================ import SwiftUI struct SessionSourceBranding { let displayName: String let symbolName: String let iconColor: Color let badgeBackground: Color let badgeAssetName: String? let providerKind: UsageProviderKind } extension SessionSource { var isGemini: Bool { switch self { case .geminiLocal, .geminiRemote: return true default: return false } } var branding: SessionSourceBranding { switch self { case .codexLocal: return SessionSourceBranding( displayName: "Codex", symbolName: "sparkles", iconColor: Color.accentColor, badgeBackground: Color.accentColor.opacity(0.08), badgeAssetName: "ChatGPTIcon", providerKind: .codex ) case .codexRemote(let host): return SessionSourceBranding( displayName: "Codex (\(host))", symbolName: "sparkles", iconColor: Color.accentColor, badgeBackground: Color.accentColor.opacity(0.08), badgeAssetName: "ChatGPTIcon", providerKind: .codex ) case .claudeLocal: return SessionSourceBranding( displayName: "Claude", symbolName: "cloud.fill", iconColor: Color.purple, badgeBackground: Color.purple.opacity(0.10), badgeAssetName: "ClaudeIcon", providerKind: .claude ) case .claudeRemote(let host): return SessionSourceBranding( displayName: "Claude (\(host))", symbolName: "cloud.fill", iconColor: Color.purple, badgeBackground: Color.purple.opacity(0.10), badgeAssetName: "ClaudeIcon", providerKind: .claude ) case .geminiLocal: return SessionSourceBranding( displayName: "Gemini", symbolName: "sparkles.rectangle.stack.fill", iconColor: Color.blue, badgeBackground: Color.blue.opacity(0.1), badgeAssetName: "GeminiIcon", providerKind: .gemini ) case .geminiRemote(let host): return SessionSourceBranding( displayName: "Gemini (\(host))", symbolName: "sparkles.rectangle.stack.fill", iconColor: Color.blue, badgeBackground: Color.blue.opacity(0.1), badgeAssetName: "GeminiIcon", providerKind: .gemini ) } } } struct SessionListRowView: View { let summary: SessionSummary var isRunning: Bool = false var isSelected: Bool = false var isUpdating: Bool = false var awaitingFollowup: Bool = false var inProject: Bool = false var projectTip: String? = nil var inTaskContainer: Bool = false @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.colorScheme) private var colorScheme @State private var breathing = false var body: some View { let branding = summary.source.branding HStack(alignment: .top, spacing: 12) { if !inTaskContainer { let container = RoundedRectangle(cornerRadius: 9, style: .continuous) ZStack { if !isRunning { container .fill(Color.white) .shadow(color: Color.black.opacity(0.08), radius: 1.5, x: 0, y: 1) container .stroke( isSelected ? branding.iconColor.opacity(0.5) : Color.black.opacity(0.06), lineWidth: isSelected ? 1.5 : 1) } if isRunning { RainbowSpinnerView(spins: true) .padding(2) .opacity( reduceMotion ? 1.0 : (awaitingFollowup ? (breathing ? 1.0 : 0.55) : 1.0) ) } else if awaitingFollowup && !isUpdating { // Draw a non-spinning beachball and apply a subtle breathing fade RainbowSpinnerView(spins: false) .padding(2) .opacity(reduceMotion ? 1.0 : (breathing ? 1.0 : 0.55)) } else if !isUpdating, let asset = branding.badgeAssetName { let hasWhiteIconBackground = !isRunning let shouldInvertCodexDark = summary.source.baseKind == .codex && colorScheme == .dark && !hasWhiteIconBackground Image(asset) .resizable() .renderingMode(.original) .aspectRatio(contentMode: .fit) .padding(4) .modifier( DarkModeInvertModifier(active: shouldInvertCodexDark) ) } else if !isUpdating { Image(systemName: branding.symbolName) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(branding.iconColor) } } .frame(width: 32, height: 32) .help("\(branding.displayName) session") } VStack(alignment: .leading, spacing: 4) { Text(summary.effectiveTitle) .font(.headline) .lineLimit(1) .truncationMode(.tail) if let remoteHost = summary.remoteHost { Text(remoteHost) .font(.caption2) .foregroundStyle(.secondary) .padding(.horizontal, 6) .padding(.vertical, 2) .background( RoundedRectangle(cornerRadius: 4, style: .continuous) .fill(Color.secondary.opacity(0.12)) ) } HStack(spacing: 8) { Text(summary.startedAt.formatted(date: .numeric, time: .shortened)) .layoutPriority(1) Text(summary.readableDuration) .layoutPriority(1) if let model = summary.displayModel ?? summary.model { Text(model) .foregroundStyle(.secondary) .lineLimit(1) } } .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) Text(summary.commentSnippet) .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) // Compact metrics moved from detail view HStack(spacing: 8) { metric(icon: "person", value: summary.userMessageCount) metric(icon: "sparkles", value: summary.assistantMessageCount) metric(icon: "hammer", value: summary.toolInvocationCount) if let reasoning = summary.responseCounts["reasoning"], reasoning > 0 { metric(icon: "brain", value: reasoning) } } .font(.caption2.monospacedDigit()) .foregroundStyle(.secondary) } .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) .padding(.trailing, 32) Spacer(minLength: 0) } .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .padding(.vertical, 8) .buttonStyle(.plain) .overlay(alignment: .topTrailing) { HStack(spacing: 0) { // Single-slot trailing indicator: // - When updating, show the timer icon // - Else if in a task container, show beachball (running) or provider branding // - Else if in a project, show project glyph if isUpdating { Image(systemName: "timer") .foregroundStyle(Color.orange) .font(.system(size: 16, weight: .semibold)) .modifier(UpdatePulseModifier(active: true)) .help("Updating…") } else if inTaskContainer { if isRunning { RainbowSpinnerView(spins: !reduceMotion, size: 18) .opacity( reduceMotion ? 1.0 : (awaitingFollowup ? (breathing ? 1.0 : 0.55) : 1.0) ) } else if let asset = branding.badgeAssetName { let shouldInvertCodexDark = summary.source.baseKind == .codex && colorScheme == .dark if isSelected && !summary.source.isGemini && !shouldInvertCodexDark { Image(asset) .resizable() .renderingMode(.template) .aspectRatio(contentMode: .fit) .frame(width: 18, height: 18) .foregroundStyle(Color.white) .help(branding.displayName) } else { Image(asset) .resizable() .renderingMode(.original) .aspectRatio(contentMode: .fit) .frame(width: 18, height: 18) .modifier( DarkModeInvertModifier(active: shouldInvertCodexDark) ) .help(branding.displayName) } } else { Image(systemName: branding.symbolName) .foregroundStyle(isSelected ? Color.white : branding.iconColor) .font(.system(size: 12, weight: .semibold)) .help(branding.displayName) } } else if inProject { Image(systemName: "square.grid.2x2") .foregroundStyle(Color.secondary) .font(.system(size: 12, weight: .regular)) .help(projectTip ?? "Project") } } .padding(.leading, 8) .padding(.trailing, 8) .padding(.top, 8) .allowsHitTesting(false) } .onAppear { // Start breathing for running rows (legacy; may be imperceptible) or // attention pulse for follow-up rows. guard !reduceMotion else { return } if isRunning || awaitingFollowup { withAnimation(.easeInOut(duration: 1.6).repeatForever(autoreverses: true)) { breathing = true } } } .onChange(of: isRunning) { newValue in if newValue { if reduceMotion { breathing = false } else { breathing = false withAnimation(.easeInOut(duration: 1.6).repeatForever(autoreverses: true)) { breathing = true } } } else { // Smoothly fade out the background when session stops running withAnimation(.easeOut(duration: 0.2)) { breathing = false } } } .onChange(of: awaitingFollowup) { needed in guard !reduceMotion else { return } if needed { withAnimation(.easeInOut(duration: 1.6).repeatForever(autoreverses: true)) { breathing = true } } else { withAnimation(.easeOut(duration: 0.2)) { breathing = false } } } } } private struct UpdatePulseModifier: ViewModifier { let active: Bool func body(content: Content) -> some View { if #available(macOS 14.0, *) { content.symbolEffect(.pulse, isActive: active) } else { content } } } struct EquatableSessionListRow: View, Equatable { let summary: SessionSummary let isRunning: Bool let isSelected: Bool let isUpdating: Bool let awaitingFollowup: Bool let inProject: Bool let projectTip: String? let inTaskContainer: Bool static func == (lhs: EquatableSessionListRow, rhs: EquatableSessionListRow) -> Bool { lhs.summary == rhs.summary && lhs.isRunning == rhs.isRunning && lhs.isSelected == rhs.isSelected && lhs.isUpdating == rhs.isUpdating && lhs.awaitingFollowup == rhs.awaitingFollowup && lhs.inProject == rhs.inProject && lhs.projectTip == rhs.projectTip && lhs.inTaskContainer == rhs.inTaskContainer } var body: some View { SessionListRowView( summary: summary, isRunning: isRunning, isSelected: isSelected, isUpdating: isUpdating, awaitingFollowup: awaitingFollowup, inProject: inProject, projectTip: projectTip, inTaskContainer: inTaskContainer ) } } private func metric(icon: String, value: Int) -> some View { HStack(spacing: 4) { Image(systemName: icon) Text("\(value)") } } // Legacy SpinningBeachballView replaced by RainbowSpinnerView (CoreAnimation-based) // Kept for reference - all usages have been migrated to RainbowSpinnerView extension SessionListRowView {} #Preview { let mockSummary = SessionSummary( id: "session-preview", fileURL: URL(fileURLWithPath: "/Users/developer/.codex/sessions/session-preview.json"), fileSizeBytes: 12340, startedAt: Date().addingTimeInterval(-3600), endedAt: Date().addingTimeInterval(-1800), activeDuration: nil, cliVersion: "1.2.3", cwd: "/Users/developer/projects/codmate", originator: "developer", instructions: "Please help optimize this SwiftUI app's performance, especially scroll stutter in lists. It should remain smooth with large datasets.", model: "gpt-4o-mini", approvalPolicy: "auto", userMessageCount: 5, assistantMessageCount: 4, toolInvocationCount: 3, responseCounts: ["reasoning": 2], turnContextCount: 8, totalTokens: 980, eventCount: 12, lineCount: 156, lastUpdatedAt: Date().addingTimeInterval(-1800), source: .codexLocal, remotePath: nil ) SessionListRowView(summary: mockSummary) .frame(width: 400, height: 120) .padding() } #Preview("Short Instructions") { let mockSummary = SessionSummary( id: "session-short", fileURL: URL(fileURLWithPath: "/Users/developer/.codex/sessions/session-short.json"), fileSizeBytes: 5600, startedAt: Date().addingTimeInterval(-7200), endedAt: Date().addingTimeInterval(-6900), activeDuration: nil, cliVersion: "1.2.3", cwd: "/Users/developer/projects/test", originator: "developer", instructions: "Create a to-do app", model: "gpt-4o", approvalPolicy: "manual", userMessageCount: 2, assistantMessageCount: 1, toolInvocationCount: 0, responseCounts: [:], turnContextCount: 3, totalTokens: 320, eventCount: 3, lineCount: 45, lastUpdatedAt: Date().addingTimeInterval(-6900), source: .codexLocal, remotePath: nil ) SessionListRowView(summary: mockSummary) .frame(width: 300, height: 100) .padding() } #Preview("No Instructions") { let mockSummary = SessionSummary( id: "session-no-instructions", fileURL: URL( fileURLWithPath: "/Users/developer/.codex/sessions/session-no-instructions.json"), fileSizeBytes: 3200, startedAt: Date().addingTimeInterval(-10800), endedAt: Date().addingTimeInterval(-10500), activeDuration: nil, cliVersion: "1.2.2", cwd: "/Users/developer/documents", originator: "developer", instructions: nil, model: "gpt-4o-mini", approvalPolicy: "auto", userMessageCount: 1, assistantMessageCount: 1, toolInvocationCount: 0, responseCounts: [:], turnContextCount: 2, totalTokens: 150, eventCount: 2, lineCount: 20, lastUpdatedAt: Date().addingTimeInterval(-10500), source: .codexLocal, remotePath: nil ) SessionListRowView(summary: mockSummary) .frame(width: 400, height: 100) .padding() } ================================================ FILE: views/SessionNavigationView.swift ================================================ import SwiftUI #if os(macOS) import AppKit #endif struct SessionNavigationView: View { let state: SidebarState let actions: SidebarActions let projectWorkspaceMode: ProjectWorkspaceMode let isAllOrOtherSelected: Bool @ViewBuilder var projectsContent: () -> ProjectsContent var body: some View { VStack(spacing: 0) { VStack(spacing: 8) { HStack(spacing: 8) { Text("Projects").font(.caption).foregroundStyle(.secondary) Spacer(minLength: 4) Button(action: actions.requestNewProject) { Image(systemName: "plus") } .buttonStyle(.bordered) .controlSize(.small) .help("New Project") } VStack(spacing: 8) { scopeAllRow( title: "All", isSelected: state.selectedProjectIDs.isEmpty, icon: "rectangle.stack", count: (state.visibleAllCount, state.totalSessionCount), action: actions.selectAllProjects ) projectsContent() } } .padding(.horizontal, 8) .padding(.top, 8) .frame(maxHeight: .infinity) // Calendar only visible in Overview/Tasks modes, or Sessions mode (for Others) if shouldShowCalendar { calendarSection .padding(.top, 8) } } .frame(idealWidth: 240) } // Show calendar only for Overview, Tasks, or Sessions (Others) private var shouldShowCalendar: Bool { switch projectWorkspaceMode { case .overview, .tasks, .settings: return true case .sessions: // Sessions mode is only used for "Others" project return isAllOrOtherSelected case .review, .agents, .memory: return false } } private func scopeAllRow( title: String, isSelected: Bool, icon: String, count: (visible: Int, total: Int)? = nil, action: @escaping () -> Void ) -> some View { HStack(spacing: 8) { Image(systemName: icon) .foregroundStyle(isSelected ? Color.white : Color.secondary) .font(.caption) Text(title) .font(.caption) .foregroundStyle(isSelected ? Color.white : Color.primary) Spacer(minLength: 8) if let pair = count { Text("\(pair.visible)/\(pair.total)") .font(.caption2.monospacedDigit()) .foregroundStyle(isSelected ? Color.white.opacity(0.9) : Color.secondary) } } .frame(height: 16) .padding(8) .background(isSelected ? Color.accentColor : Color.clear) .cornerRadius(8) .contentShape(Rectangle()) .onTapGesture { action() } } private var calendarSection: some View { VStack(spacing: 4) { calendarHeader Picker("", selection: dimensionBinding) { ForEach(DateDimension.allCases) { dim in Text(dim.title).tag(dim) } } .labelsHidden() .pickerStyle(.segmented) .controlSize(.small) CalendarMonthView( monthStart: state.monthStart, counts: state.calendarCounts, selectedDays: state.selectedDays, enabledDays: state.enabledProjectDays ) { picked in handleDaySelection(picked) } } .padding(8) } private var dimensionBinding: Binding { Binding( get: { state.dateDimension }, set: { actions.setDateDimension($0) } ) } private var calendarHeader: some View { let cal = Calendar.current let monthTitle: String = { let df = DateFormatter() df.dateFormat = "MMM yyyy" return df.string(from: state.monthStart) }() return GeometryReader { geometry in let columnWidth = geometry.size.width / 16 HStack(spacing: 0) { Button { if let next = cal.date(byAdding: .month, value: -1, to: state.monthStart) { actions.setMonthStart(next) } } label: { Image(systemName: "chevron.left") .frame(width: columnWidth, height: 24) } .buttonStyle(.plain) Spacer(minLength: 0) Button { jumpToToday() } label: { Text(monthTitle) .font(.headline) .multilineTextAlignment(.center) } .buttonStyle(.plain) Spacer(minLength: 0) Button { if let next = cal.date(byAdding: .month, value: 1, to: state.monthStart) { actions.setMonthStart(next) } } label: { Image(systemName: "chevron.right") .frame(width: columnWidth, height: 24) } .buttonStyle(.plain) } .frame(width: geometry.size.width) } .frame(height: 24) } private func jumpToToday() { let cal = Calendar.current let today = cal.startOfDay(for: Date()) if let month = cal.date(from: cal.dateComponents([.year, .month], from: today)) { actions.setMonthStart(month) } else { actions.setMonthStart(today) } actions.setSelectedDay(today) } private func handleDaySelection(_ picked: Date) { #if os(macOS) let useToggle = (NSApp.currentEvent?.modifierFlags.contains(.command) ?? false) #else let useToggle = false #endif if useToggle { actions.toggleSelectedDay(picked) } else { if let current = state.selectedDay, Calendar.current.isDate(current, inSameDayAs: picked) { actions.setSelectedDay(nil) } else { actions.setSelectedDay(picked) } } } } private enum SidebarMode: Hashable { case directories, projects } #Preview { let cal = Calendar.current let monthStart = cal.date(from: DateComponents(year: 2024, month: 12, day: 1))! let state = SidebarState( totalSessionCount: 15, isLoading: false, visibleAllCount: 12, selectedProjectIDs: [], selectedDay: nil, selectedDays: [], dateDimension: .updated, monthStart: monthStart, calendarCounts: [1: 2, 3: 4], enabledProjectDays: nil ) let actions = SidebarActions( selectAllProjects: {}, requestNewProject: {}, requestNewTask: {}, setDateDimension: { _ in }, setMonthStart: { _ in }, setSelectedDay: { _ in }, toggleSelectedDay: { _ in } ) return SessionNavigationView( state: state, actions: actions, projectWorkspaceMode: .tasks, isAllOrOtherSelected: true ) { EmptyView() } .frame(width: 280, height: 600) } #Preview("Loading State") { let cal = Calendar.current let monthStart = cal.date(from: DateComponents(year: 2024, month: 12, day: 1))! let state = SidebarState( totalSessionCount: 0, isLoading: true, visibleAllCount: 0, selectedProjectIDs: [], selectedDay: nil, selectedDays: [], dateDimension: .created, monthStart: monthStart, calendarCounts: [:], enabledProjectDays: nil ) let actions = SidebarActions( selectAllProjects: {}, requestNewProject: {}, requestNewTask: {}, setDateDimension: { _ in }, setMonthStart: { _ in }, setSelectedDay: { _ in }, toggleSelectedDay: { _ in } ) return SessionNavigationView( state: state, actions: actions, projectWorkspaceMode: .overview, isAllOrOtherSelected: true ) { EmptyView() } .frame(width: 280, height: 600) } #Preview("Calendar Day Selected") { let cal = Calendar.current let today = cal.startOfDay(for: Date()) let start = cal.date(from: DateComponents(year: 2024, month: 11, day: 1))! let state = SidebarState( totalSessionCount: 8, isLoading: false, visibleAllCount: 4, selectedProjectIDs: [], selectedDay: today, selectedDays: [today], dateDimension: .updated, monthStart: start, calendarCounts: [cal.component(.day, from: today): 3], enabledProjectDays: nil ) let actions = SidebarActions( selectAllProjects: {}, requestNewProject: {}, requestNewTask: {}, setDateDimension: { _ in }, setMonthStart: { _ in }, setSelectedDay: { _ in }, toggleSelectedDay: { _ in } ) return SessionNavigationView( state: state, actions: actions, projectWorkspaceMode: .tasks, isAllOrOtherSelected: false ) { EmptyView() } .frame(width: 280, height: 600) } #Preview("Path Selected") { let cal = Calendar.current let state = SidebarState( totalSessionCount: 5, isLoading: false, visibleAllCount: 5, selectedProjectIDs: ["demo"], selectedDay: nil, selectedDays: [], dateDimension: .updated, monthStart: cal.startOfDay(for: Date()), calendarCounts: [:], enabledProjectDays: [1, 3, 5] ) let actions = SidebarActions( selectAllProjects: {}, requestNewProject: {}, requestNewTask: {}, setDateDimension: { _ in }, setMonthStart: { _ in }, setSelectedDay: { _ in }, toggleSelectedDay: { _ in } ) return SessionNavigationView( state: state, actions: actions, projectWorkspaceMode: .review, isAllOrOtherSelected: false ) { EmptyView() } .frame(width: 280, height: 600) } ================================================ FILE: views/SessionPathGroup.swift ================================================ import SwiftUI struct SessionPathGroup: View { @Binding var config: SessionPathConfig let diagnostics: SessionsDiagnostics.Probe? let canDelete: Bool let showToggle: Bool let showHeader: Bool var onDelete: (() -> Void)? = nil @State private var showingDiagnostics = false @State private var showingAddIgnore = false @State private var newIgnorePath = "" @State private var isHovered = false private var localAuthProvider: LocalAuthProvider? { LocalAuthProvider(rawValue: config.kind.rawValue) } private var isEnabled: Bool { showToggle ? config.enabled : true } var body: some View { VStack(alignment: .leading, spacing: 0) { // Header: Icon + Name + Delete (hover) + Switch (always visible) if showHeader { HStack(alignment: .center, spacing: 12) { // Brand icon if let provider = localAuthProvider { LocalAuthProviderIconView(provider: provider, size: 16, cornerRadius: 3) } Text(config.displayName ?? config.kind.displayName) .font(.headline) .fontWeight(.medium) Spacer() // Delete button (only visible on hover, transparent background) if canDelete, let onDelete = onDelete { Button { onDelete() } label: { Image(systemName: "trash") .font(.system(size: 13)) .foregroundStyle(.secondary) } .buttonStyle(.plain) .opacity(isHovered ? 1.0 : 0.0) .help("Delete") } if showToggle { Toggle("", isOn: $config.enabled) .toggleStyle(.switch) .labelsHidden() .controlSize(.small) } } .padding(10) } // Content: Only shown when enabled if isEnabled { VStack(alignment: .leading, spacing: 0) { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { // Path (first item) GridRow { Text("Path") .font(.subheadline) .fontWeight(.medium) Text(config.path) .font(.subheadline) .lineLimit(1) .truncationMode(.middle) .frame(maxWidth: .infinity, alignment: .trailing) } Divider() // Ignored Subpaths GridRow { Text("Ignored Subpaths") .font(.subheadline) .fontWeight(.medium) HStack(spacing: 6) { Spacer() ForEach(config.ignoredSubpaths, id: \.self) { subpath in TagView( text: subpath, isEnabled: !config.disabledSubpaths.contains(subpath), isClosable: true, isRemovable: true, onClose: { removeIgnorePath(subpath) }, onToggle: { isEnabled in toggleSubpath(subpath, enabled: isEnabled) } ) } // Add new tag button Button { showingAddIgnore = true } label: { HStack(spacing: 4) { Image(systemName: "plus") .font(.system(size: 11)) Text("New Tag") .font(.caption) } .padding(.horizontal, 8) .padding(.vertical, 4) .foregroundStyle(.secondary) .background(Color.secondary.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 6)) } .buttonStyle(.plain) } .frame(maxWidth: .infinity, alignment: .trailing) } Divider() // Diagnostics Summary (after Ignored Subpaths) if let diagnostics = diagnostics { GridRow { Text("Diagnostics") .font(.subheadline) .fontWeight(.medium) DisclosureGroup(isExpanded: $showingDiagnostics) { Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { GridRow { Text("Exists").font(.caption) Text(diagnostics.exists ? "Yes" : "No") .font(.caption) .frame(maxWidth: .infinity, alignment: .trailing) } if diagnostics.isDirectory { GridRow { Text("Files").font(.caption) Text("\(diagnostics.enumeratedCount)") .font(.caption) .frame(maxWidth: .infinity, alignment: .trailing) } } if let error = diagnostics.enumeratorError { GridRow { Text("Error").font(.caption) Text(error) .font(.caption) .foregroundStyle(.red) .frame(maxWidth: .infinity, alignment: .trailing) } } if !diagnostics.sampleFiles.isEmpty { GridRow { Text("Sample Files") .font(.caption) .fontWeight(.medium) VStack(alignment: .trailing, spacing: 4) { ForEach(diagnostics.sampleFiles.prefix(5), id: \.self) { file in Text(file) .font(.caption2) .foregroundStyle(.secondary) .monospaced() .lineLimit(1) } if diagnostics.sampleFiles.count > 5 { Text("(\(diagnostics.sampleFiles.count - 5) more...)") .font(.caption2) .foregroundStyle(.tertiary) } } .frame(maxWidth: .infinity, alignment: .trailing) } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, 4) } label: { EmptyView() } } } } } .padding(10) } } .background(Color(nsColor: .separatorColor).opacity(0.35)) .cornerRadius(10) .onHover { hovering in isHovered = hovering } .alert("Add Ignored Path", isPresented: $showingAddIgnore) { TextField("Path substring", text: $newIgnorePath) Button("Cancel", role: .cancel) { newIgnorePath = "" } Button("Add") { addIgnorePath() } .disabled(newIgnorePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } message: { Text( "Enter a path substring to ignore. Files containing this substring will be skipped during scanning." ) } } private func addIgnorePath() { let trimmed = newIgnorePath.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, !config.ignoredSubpaths.contains(trimmed) else { newIgnorePath = "" return } var updated = config updated.ignoredSubpaths.append(trimmed) config = updated newIgnorePath = "" } private func removeIgnorePath(_ subpath: String) { var updated = config updated.ignoredSubpaths.removeAll { $0 == subpath } updated.disabledSubpaths.remove(subpath) // Also remove from disabled set if present config = updated } private func toggleSubpath(_ subpath: String, enabled: Bool) { var updated = config if enabled { updated.disabledSubpaths.remove(subpath) } else { updated.disabledSubpaths.insert(subpath) } config = updated } } ================================================ FILE: views/SessionPathRow.swift ================================================ import SwiftUI struct SessionPathRow: View { @Binding var config: SessionPathConfig @ObservedObject var preferences: SessionPreferencesStore let diagnostics: SessionsDiagnostics.Probe? let canDelete: Bool var onDelete: (() -> Void)? = nil @State private var showingDiagnostics = false @State private var showingAddIgnore = false @State private var newIgnorePath = "" var body: some View { settingsCard { VStack(alignment: .leading, spacing: 12) { // Header: Toggle + Name + Delete HStack { Toggle("", isOn: $config.enabled) .labelsHidden() VStack(alignment: .leading, spacing: 4) { Text(config.displayName ?? config.kind.displayName) .font(.subheadline) .fontWeight(.medium) Text(config.path) .font(.caption) .foregroundStyle(.secondary) .monospaced() .lineLimit(1) .textSelection(.enabled) } Spacer() if canDelete, let onDelete = onDelete { Button { onDelete() } label: { Label("Delete", systemImage: "trash") } .buttonStyle(.bordered) .controlSize(.small) } } // Diagnostics Summary if let diagnostics = diagnostics { DisclosureGroup(isExpanded: $showingDiagnostics) { VStack(alignment: .leading, spacing: 8) { if diagnostics.exists { Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { GridRow { Text("Exists").font(.caption) Text(diagnostics.exists ? "Yes" : "No") .frame(maxWidth: .infinity, alignment: .trailing) } if diagnostics.isDirectory { GridRow { Text("Files").font(.caption) Text("\(diagnostics.enumeratedCount)") .frame(maxWidth: .infinity, alignment: .trailing) } } if let error = diagnostics.enumeratorError { GridRow { Text("Error").font(.caption) Text(error) .font(.caption) .foregroundStyle(.red) .frame(maxWidth: .infinity, alignment: .trailing) } } } if !diagnostics.sampleFiles.isEmpty { Divider() VStack(alignment: .leading, spacing: 4) { Text("Sample Files") .font(.caption) .fontWeight(.medium) ForEach(diagnostics.sampleFiles.prefix(5), id: \.self) { file in Text(file) .font(.caption2) .foregroundStyle(.secondary) .monospaced() .lineLimit(1) } if diagnostics.sampleFiles.count > 5 { Text("(\(diagnostics.sampleFiles.count - 5) more...)") .font(.caption2) .foregroundStyle(.tertiary) } } } } else { Text("Directory does not exist") .font(.caption) .foregroundStyle(.secondary) } } .padding(.top, 4) } label: { HStack { Text("Diagnostics") .font(.caption) .fontWeight(.medium) Spacer() if diagnostics.exists { Text("\(diagnostics.enumeratedCount) files") .font(.caption) .foregroundStyle(.secondary) } } } } // Ignored Subpaths VStack(alignment: .leading, spacing: 8) { HStack { Text("Ignored Subpaths") .font(.caption) .fontWeight(.medium) Spacer() Button { showingAddIgnore = true } label: { Label("Add", systemImage: "plus") } .buttonStyle(.borderless) .controlSize(.small) } if config.ignoredSubpaths.isEmpty { Text("No ignored paths") .font(.caption2) .foregroundStyle(.tertiary) } else { ForEach(config.ignoredSubpaths, id: \.self) { subpath in HStack { Text(subpath) .font(.caption2) .monospaced() .foregroundStyle(.secondary) Spacer() Button { removeIgnorePath(subpath) } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.secondary) } .buttonStyle(.borderless) .controlSize(.small) } } } } } } .alert("Add Ignored Path", isPresented: $showingAddIgnore) { TextField("Path substring", text: $newIgnorePath) Button("Cancel", role: .cancel) { newIgnorePath = "" } Button("Add") { addIgnorePath() } .disabled(newIgnorePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } message: { Text("Enter a path substring to ignore. Files containing this substring will be skipped during scanning.") } } private func addIgnorePath() { let trimmed = newIgnorePath.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } var updated = config if !updated.ignoredSubpaths.contains(trimmed) { updated.ignoredSubpaths.append(trimmed) config = updated } newIgnorePath = "" } private func removeIgnorePath(_ subpath: String) { var updated = config updated.ignoredSubpaths.removeAll { $0 == subpath } config = updated } @ViewBuilder private func settingsCard(@ViewBuilder _ content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 8) { content() } .padding(10) .background(Color(nsColor: .separatorColor).opacity(0.35)) .cornerRadius(10) } } ================================================ FILE: views/SessionsPathPane.swift ================================================ import SwiftUI import AppKit struct SessionsPathPane: View { @ObservedObject var preferences: SessionPreferencesStore let fixedKind: SessionSource.Kind? @State private var diagnostics: [String: SessionsDiagnostics.Probe] = [:] @State private var loadingDiagnostics = false @State private var showingAddPath = false @State private var selectedKind: SessionSource.Kind init(preferences: SessionPreferencesStore, fixedKind: SessionSource.Kind? = nil) { self.preferences = preferences self.fixedKind = fixedKind _selectedKind = State(initialValue: fixedKind ?? .codex) } var body: some View { let isFixed = fixedKind != nil VStack(alignment: .leading, spacing: 18) { // Default Paths Section VStack(alignment: .leading, spacing: 10) { Text("Default Paths").font(.headline).fontWeight(.semibold) VStack(alignment: .leading, spacing: 12) { ForEach(defaultPaths.indices, id: \.self) { index in let config = defaultPaths[index] SessionPathGroup( config: Binding( get: { if let idx = findConfigIndex(config) { return preferences.sessionPathConfigs[idx] } return config }, set: { updateConfig($0) } ), diagnostics: diagnostics[config.id], canDelete: false, showToggle: !isFixed, showHeader: !isFixed ) .disabled(!preferences.isCLIEnabled(config.kind)) .opacity(preferences.isCLIEnabled(config.kind) ? 1.0 : 0.6) } } } // Custom Paths Section VStack(alignment: .leading, spacing: 10) { HStack { Text("Custom Paths").font(.headline).fontWeight(.semibold) Spacer(minLength: 8) Button { showingAddPath = true } label: { Label("Add Custom Path", systemImage: "plus") } .buttonStyle(.bordered) } if customPaths.isEmpty { Text("No custom paths added yet.") .font(.caption) .foregroundStyle(.secondary) .padding(.vertical, 8) } else { VStack(alignment: .leading, spacing: 12) { ForEach(customPaths.indices, id: \.self) { index in let config = customPaths[index] SessionPathGroup( config: Binding( get: { if let idx = findConfigIndex(config) { return preferences.sessionPathConfigs[idx] } return config }, set: { updateConfig($0) } ), diagnostics: diagnostics[config.id], canDelete: true, showToggle: true, showHeader: true, onDelete: { deleteConfig(config) } ) .disabled(!preferences.isCLIEnabled(config.kind)) .opacity(preferences.isCLIEnabled(config.kind) ? 1.0 : 0.6) } } } } } .task { ensureDefaultEnabled() await refreshDiagnostics() } .sheet(isPresented: $showingAddPath) { AddSessionPathSheet( selectedKind: $selectedKind, preferences: preferences, fixedKind: fixedKind, onAdd: { kind, path in addCustomPath(kind: kind, path: path) } ) } } private var scopedConfigs: [SessionPathConfig] { preferences.sessionPathConfigs.filter { config in guard let fixedKind else { return true } return config.kind == fixedKind } } private var defaultPaths: [SessionPathConfig] { scopedConfigs.filter { $0.isDefault } .sorted { $0.kind.rawValue < $1.kind.rawValue } } private var customPaths: [SessionPathConfig] { scopedConfigs.filter { !$0.isDefault } .sorted { $0.path < $1.path } } private func updateConfig(_ newConfig: SessionPathConfig) { var configs = preferences.sessionPathConfigs if let index = configs.firstIndex(where: { $0.id == newConfig.id }) { configs[index] = newConfig preferences.sessionPathConfigs = configs } Task { ensureDefaultEnabled() await refreshDiagnostics() } } private func deleteConfig(_ config: SessionPathConfig) { var configs = preferences.sessionPathConfigs configs.removeAll { $0.id == config.id } preferences.sessionPathConfigs = configs Task { await refreshDiagnostics() } } private func findConfigIndex(_ config: SessionPathConfig) -> Int? { preferences.sessionPathConfigs.firstIndex { $0.id == config.id } } private func addCustomPath(kind: SessionSource.Kind, path: String) { let newConfig = SessionPathConfig( kind: kind, path: path, enabled: true, displayName: nil ) var configs = preferences.sessionPathConfigs configs.append(newConfig) preferences.sessionPathConfigs = configs Task { await refreshDiagnostics() } } private func ensureDefaultEnabled() { guard let fixedKind else { return } var configs = preferences.sessionPathConfigs var didChange = false for index in configs.indices { if configs[index].isDefault && configs[index].kind == fixedKind && !configs[index].enabled { configs[index].enabled = true didChange = true } } if didChange { preferences.sessionPathConfigs = configs } } private func refreshDiagnostics() async { loadingDiagnostics = true defer { loadingDiagnostics = false } let diagnosticsService = SessionsDiagnosticsService() var newDiagnostics: [String: SessionsDiagnostics.Probe] = [:] for config in scopedConfigs { let url = URL(fileURLWithPath: config.path) let probe = await diagnosticsService.probe(root: url, fileExtension: fileExtension(for: config.kind)) newDiagnostics[config.id] = probe } await MainActor.run { diagnostics = newDiagnostics } } private func fileExtension(for kind: SessionSource.Kind) -> String { switch kind { case .codex, .claude: return "jsonl" case .gemini: return "json" } } } // MARK: - Add Session Path Sheet struct AddSessionPathSheet: View { @Binding var selectedKind: SessionSource.Kind @ObservedObject var preferences: SessionPreferencesStore let fixedKind: SessionSource.Kind? let onAdd: (SessionSource.Kind, String) -> Void @Environment(\.dismiss) private var dismiss @State private var selectedPath: String = "" var body: some View { VStack(alignment: .leading, spacing: 20) { Text("Add Custom Session Path") .font(.title2) .fontWeight(.bold) if fixedKind == nil { VStack(alignment: .leading, spacing: 12) { Text("Type") .font(.subheadline) .fontWeight(.medium) Picker("", selection: $selectedKind) { Text("Codex").tag(SessionSource.Kind.codex) Text("Claude").tag(SessionSource.Kind.claude) Text("Gemini").tag(SessionSource.Kind.gemini) } .pickerStyle(.segmented) } } VStack(alignment: .leading, spacing: 12) { Text("Path") .font(.subheadline) .fontWeight(.medium) HStack { TextField("Select directory...", text: $selectedPath) .textFieldStyle(.roundedBorder) .disabled(true) Button("Choose...") { selectDirectory() } .buttonStyle(.bordered) } } HStack { Spacer() Button("Cancel") { dismiss() } .buttonStyle(.bordered) Button("Add") { guard !selectedPath.isEmpty else { return } onAdd(selectedKind, selectedPath) dismiss() } .buttonStyle(.borderedProminent) .disabled(selectedPath.isEmpty || !preferences.isCLIEnabled(selectedKind)) } } .padding(20) .frame(width: 500) } private func selectDirectory() { let panel = NSOpenPanel() panel.canChooseFiles = false panel.canChooseDirectories = true panel.allowsMultipleSelection = false panel.prompt = "Select" if panel.runModal() == .OK, let url = panel.url { selectedPath = url.path } } } ================================================ FILE: views/SettingsCompatibility.swift ================================================ import SwiftUI extension View { @ViewBuilder func codmatePresentationSizingIfAvailable() -> some View { if #available(macOS 15.0, *) { self.presentationSizing(.automatic) } else { self } } @ViewBuilder func codmateNavigationSplitViewBalancedIfAvailable() -> some View { if #available(macOS 14.0, *) { self.navigationSplitViewStyle(.balanced) } else { self } } @ViewBuilder func codmateToolbarRemovingSidebarToggleIfAvailable() -> some View { if #available(macOS 14.0, *) { self.toolbar(removing: .sidebarToggle) } else { self } } @ViewBuilder func codmatePlainTextEditorStyleIfAvailable() -> some View { if #available(macOS 14.0, *) { self.textEditorStyle(.plain) } else { self } } } @available(macOS, introduced: 13.0, obsoleted: 14.0) extension View { func onChange( of value: Value, initial: Bool = false, _ action: @escaping (Value) -> Void ) -> some View { modifier(OnChangeCompatModifier(value: value, initial: initial, action: action)) } func onChange( of value: Value, initial: Bool = false, _ action: @escaping (Value, Value) -> Void ) -> some View { modifier(OnChangeCompatOldNewModifier(value: value, initial: initial, action: action)) } } private struct OnChangeCompatModifier: ViewModifier { let value: Value let initial: Bool let action: (Value) -> Void @State private var hasInitialized = false func body(content: Content) -> some View { content .onAppear { guard !hasInitialized else { return } hasInitialized = true if initial { action(value) } } .onChange(of: value) { newValue in action(newValue) } } } private struct OnChangeCompatOldNewModifier: ViewModifier { let value: Value let initial: Bool let action: (Value, Value) -> Void @State private var hasInitialized = false @State private var previousValue: Value? func body(content: Content) -> some View { content .onAppear { guard !hasInitialized else { return } hasInitialized = true previousValue = value if initial { action(value, value) } } .onChange(of: value) { newValue in let oldValue = previousValue ?? newValue previousValue = newValue action(oldValue, newValue) } } } ================================================ FILE: views/SettingsTabContent.swift ================================================ import SwiftUI /// Shared container for settings tab panes to ensure consistent padding and top alignment. struct SettingsTabContent: View { let content: () -> Content init(@ViewBuilder _ content: @escaping () -> Content) { self.content = content } var body: some View { VStack(alignment: .leading, spacing: 0) { content() .frame(maxWidth: .infinity, alignment: .topLeading) Spacer(minLength: 0) } .padding(.horizontal, 8) .padding(.vertical, 8) } } ================================================ FILE: views/SettingsView.swift ================================================ import AppKit import SwiftUI import UniformTypeIdentifiers import GhosttyKit struct SettingsView: View { @ObservedObject var preferences: SessionPreferencesStore @Binding private var selectedCategory: SettingCategory @Binding private var selectedExtensionsTab: ExtensionsSettingsTab @StateObject private var codexVM = CodexVM() @StateObject private var geminiVM = GeminiVM() @StateObject private var claudeVM = ClaudeCodeVM() @StateObject private var updateViewModel = UpdateViewModel() @StateObject private var wizardGuard = WizardGuard() @EnvironmentObject private var viewModel: SessionListViewModel @ObservedObject private var permissionsManager = SandboxPermissionsManager.shared @State private var availableRemoteHosts: [SSHHost] = [] @State private var isRequestingSSHAccess = false @State private var availableThemes: [String] = [] @State private var lastStableCategory: SettingCategory init( preferences: SessionPreferencesStore, selection: Binding, extensionsTab: Binding ) { self._preferences = ObservedObject(wrappedValue: preferences) self._selectedCategory = selection self._selectedExtensionsTab = extensionsTab self._lastStableCategory = State(initialValue: selection.wrappedValue) } var body: some View { ZStack(alignment: .topLeading) { WindowConfigurator { window in window.isMovableByWindowBackground = false window.identifier = NSUserInterfaceItemIdentifier("CodMateSettingsWindow") window.delegate = SettingsWindowDelegate.shared if window.toolbar == nil { let toolbar = NSToolbar(identifier: "CodMateSettingsToolbar") SettingsToolbarCoordinator.shared.configure(toolbar: toolbar) window.toolbar = toolbar } window.title = "Settings" // Ensure the system titlebar bottom hairline is shown to unify // appearance across all settings pages. window.titlebarSeparatorStyle = .line var minSize = window.contentMinSize minSize.width = max(minSize.width, 800) minSize.height = max(minSize.height, 560) window.contentMinSize = minSize var maxSize = window.contentMaxSize if maxSize.width > 0 { maxSize.width = max(maxSize.width, 2000) } if maxSize.height > 0 { maxSize.height = max(maxSize.height, 1400) } window.contentMaxSize = maxSize } .frame(width: 0, height: 0) NavigationSplitView { List(SettingCategory.allCases, selection: $selectedCategory) { category in let isSelected = (category == selectedCategory) HStack(alignment: .center, spacing: 8) { Image(systemName: category.icon) .foregroundStyle(isSelected ? Color.white : Color.accentColor) .frame(width: 26, alignment: .center) VStack(alignment: .leading, spacing: 0) { Text(category.title) .font(.headline) Text(category.description) .font(.caption) .foregroundColor(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) Spacer(minLength: 0) } .padding(.vertical, 6) .tag(category) } .listStyle(.sidebar) .controlSize(.small) .environment(\.defaultMinListRowHeight, 18) .navigationSplitViewColumnWidth(min: 240, ideal: 260, max: 300) .disabled(wizardGuard.isActive) } detail: { selectedCategoryView .frame(maxWidth: .infinity, alignment: .topLeading) .task { await codexVM.loadAll() } .navigationSplitViewColumnWidth(min: 640, ideal: 800, max: 1800) } .codmateNavigationSplitViewBalancedIfAvailable() .codmateToolbarRemovingSidebarToggleIfAvailable() } .frame(minWidth: 900, minHeight: 520) .environmentObject(wizardGuard) .onChange(of: selectedCategory) { newValue in if wizardGuard.isActive { if newValue != lastStableCategory { selectedCategory = lastStableCategory } } else { lastStableCategory = newValue } } } private final class SettingsWindowDelegate: NSObject, NSWindowDelegate { static let shared = SettingsWindowDelegate() func windowShouldClose(_ sender: NSWindow) -> Bool { // Check if main window is still visible let mainWindowId = NSUserInterfaceItemIdentifier("CodMateMainWindow") let mainWindowVisible = NSApplication.shared.windows.contains { window in window.identifier == mainWindowId && window.isVisible } // Only hide Dock icon if: // 1. No other app windows are visible, AND // 2. User preference is "Menu Bar Only" mode let defaults = UserDefaults.standard let rawVisibility = defaults.string(forKey: "codmate.systemMenu.visibility") ?? "visible" let visibility = SystemMenuVisibility(rawValue: rawVisibility) ?? .visible if !mainWindowVisible && visibility == .menuOnly { NSApplication.shared.setActivationPolicy(.accessory) } return true } } private final class SettingsToolbarCoordinator: NSObject, NSToolbarDelegate { static let shared = SettingsToolbarCoordinator() private let spacerID = NSToolbarItem.Identifier("CodMateSettingsSpacer") func configure(toolbar: NSToolbar) { toolbar.delegate = self toolbar.allowsUserCustomization = false toolbar.allowsExtensionItems = false toolbar.displayMode = .iconOnly } func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { [spacerID] } func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { [spacerID] } func toolbar( _ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool ) -> NSToolbarItem? { guard itemIdentifier == spacerID else { return nil } let item = NSToolbarItem(itemIdentifier: itemIdentifier) let view = NSView(frame: .zero) view.translatesAutoresizingMaskIntoConstraints = false view.isHidden = true view.widthAnchor.constraint(equalToConstant: 1).isActive = true view.heightAnchor.constraint(equalToConstant: 1).isActive = true item.view = view return item } } @ViewBuilder private var selectedCategoryView: some View { switch selectedCategory { case .general: generalSettings case .terminal: terminalSettings case .notifications: notificationsSettings case .command: commandSettings case .providers: providersSettings case .codex: codexSettings case .gemini: geminiSettings case .remoteHosts: RemoteHostsSettingsPane(preferences: preferences) case .gitReview: gitReviewSettings case .claudeCode: claudeCodeSettings case .advanced: advancedSettings case .mcpServer: extensionsSettings case .about: AboutSettingsView(updateViewModel: updateViewModel) .frame(maxWidth: .infinity, alignment: .topLeading) } } private var generalSettings: some View { settingsScroll { VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 6) { Text("General Settings") .font(.title2) .fontWeight(.bold) Text("Configure basic application settings") .font(.subheadline) .foregroundColor(.secondary) } VStack(alignment: .leading, spacing: 10) { Text("App Behavior").font(.headline).fontWeight(.semibold) settingsCard { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 4) { GridRow { VStack(alignment: .leading, spacing: 2) { Label("Confirm before quit", systemImage: "exclamationmark.triangle") .font(.subheadline).fontWeight(.medium) Text("Show confirmation dialog when quitting the app") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("", isOn: $preferences.confirmBeforeQuit) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) .frame(maxWidth: .infinity, alignment: .trailing) .gridColumnAlignment(.trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Launch at login", systemImage: "power") .font(.subheadline).fontWeight(.medium) Text("Automatically start CodMate when you log in") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("", isOn: $preferences.launchAtLogin) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) .frame(maxWidth: .infinity, alignment: .trailing) .gridColumnAlignment(.trailing) } } } } VStack(alignment: .leading, spacing: 10) { Text("System Menu").font(.headline).fontWeight(.semibold) settingsCard { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 10) { GridRow { VStack(alignment: .leading, spacing: 2) { Label("System menu bar icon", systemImage: "menubar.rectangle") .font(.subheadline).fontWeight(.medium) Text("Control whether the menu bar icon appears") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Picker("", selection: $preferences.systemMenuVisibility) { ForEach(SystemMenuVisibility.allCases) { visibility in Text(visibility.title).tag(visibility) } } .labelsHidden() .frame(maxWidth: .infinity, alignment: .trailing) .pickerStyle(.segmented) .padding(2) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) } } } } VStack(alignment: .leading, spacing: 10) { Text("Editor").font(.headline).fontWeight(.semibold) settingsCard { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 4) { GridRow { let editors = EditorApp.installedEditors VStack(alignment: .leading, spacing: 0) { Label("Default Editor", systemImage: "pencil.and.outline") .font(.subheadline).fontWeight(.medium) Text("Used for quick open actions in Review and elsewhere") .font(.caption).foregroundStyle(.secondary) } if editors.isEmpty { Text("No supported editors found. Install VS Code, Cursor, Zed, or Antigravity.") .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .trailing) } else { Picker("", selection: $preferences.defaultFileEditor) { ForEach(editors) { app in editorLabel(for: app).tag(app) } } .labelsHidden() .frame(maxWidth: .infinity, alignment: .trailing) } } } } } VStack(alignment: .leading, spacing: 10) { Text("Search").font(.headline).fontWeight(.semibold) settingsCard { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 10) { GridRow { VStack(alignment: .leading, spacing: 2) { Label("Global search panel", systemImage: "magnifyingglass") .font(.subheadline).fontWeight(.medium) Text("Choose how the ⌘F panel appears") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Picker("Search panel style", selection: $preferences.searchPanelStyle) { ForEach(GlobalSearchPanelStyle.allCases) { style in Text(style.title).tag(style) } } .labelsHidden() .pickerStyle(.segmented) .padding(2) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 8, style: .continuous) .stroke(Color.secondary.opacity(0.12), lineWidth: 1) ) .disabled(false) .frame(maxWidth: .infinity, alignment: .trailing) .gridColumnAlignment(.trailing) .gridCellAnchor(.trailing) } } } } VStack(alignment: .leading, spacing: 10) { Text("Message Types").font(.headline).fontWeight(.semibold) settingsCard { messageTypeVisibilitySection() } } #if APPSTORE VStack(alignment: .leading, spacing: 10) { Text("App Store Version").font(.headline).fontWeight(.semibold) settingsCard { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .top, spacing: 12) { Image(systemName: "info.circle.fill") .font(.title2) .foregroundStyle(.blue) VStack(alignment: .leading, spacing: 8) { Text("About This Version") .font(.subheadline) .fontWeight(.semibold) Text( "You're using the Mac App Store version of CodMate, which includes enhanced security through App Sandbox." ) .font(.caption) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } } Divider() VStack(alignment: .leading, spacing: 8) { Label("Embedded Terminal Behavior", systemImage: "terminal") .font(.subheadline) .fontWeight(.medium) Text( "The embedded terminal provides a basic shell environment for navigation and system commands. Third-party CLI tools (codex, claude) cannot be executed directly from the embedded terminal due to macOS security restrictions." ) .font(.caption) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) Text( "To run CLI sessions, use the \"Copy Command\" or \"Open in Terminal.app\" buttons to execute commands in the external Terminal app." ) .font(.caption) .foregroundColor(.blue) .fixedSize(horizontal: false, vertical: true) } Divider() VStack(alignment: .leading, spacing: 8) { Label("Git Review Functionality", systemImage: "square.and.pencil") .font(.subheadline) .fontWeight(.medium) Text( "Git Review works fully in the App Store version using the system git tool (/usr/bin/git). You may be prompted to authorize repository folders for the first time." ) .font(.caption) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } } .padding(4) } } #endif } .padding(.bottom, 16) } } // MARK: - Message Type Visibility Section @ViewBuilder private func messageTypeVisibilitySection() -> some View { VStack(alignment: .leading, spacing: 16) { // Timeline visibility section VStack(alignment: .leading, spacing: 8) { HStack { Image(systemName: "eye") .font(.subheadline) .foregroundStyle(.secondary) Text("Timeline visibility") .font(.subheadline) .fontWeight(.medium) Spacer() Button(action: { preferences.timelineVisibleKinds = MessageVisibilityKind.timelineDefault }) { Image(systemName: "arrow.counterclockwise.circle.fill") .font(.subheadline) .foregroundStyle(.secondary) } .buttonStyle(.plain) .frame(width: 24, height: 24) .help("Restore timeline visibility to defaults") } Text("Choose which message types appear in the conversation timeline") .font(.caption) .foregroundStyle(.secondary) messageTypeGrid(for: $preferences.timelineVisibleKinds) } Divider() // Markdown export section VStack(alignment: .leading, spacing: 8) { HStack { Image(systemName: "doc.text") .font(.subheadline) .foregroundStyle(.secondary) Text("Markdown export") .font(.subheadline) .fontWeight(.medium) Spacer() Button(action: { preferences.markdownVisibleKinds = MessageVisibilityKind.markdownDefault }) { Image(systemName: "arrow.counterclockwise.circle.fill") .font(.subheadline) .foregroundStyle(.secondary) } .buttonStyle(.plain) .frame(width: 24, height: 24) .help("Restore markdown export to defaults") } Text("Choose which message types are included when exporting Markdown") .font(.caption) .foregroundStyle(.secondary) messageTypeGrid(for: $preferences.markdownVisibleKinds) } } .frame(maxWidth: .infinity, alignment: .leading) } @ViewBuilder private func messageTypeGrid(for selection: Binding>) -> some View { let columns = [ GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12), ] LazyVGrid(columns: columns, alignment: .leading, spacing: 12) { ForEach(messageTypeRows) { row in if let kind = row.kind { HStack(spacing: 6) { Toggle("", isOn: binding(selection, kind)) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) Text(row.title) .font(.caption) .fixedSize(horizontal: false, vertical: true) } } } } } private func binding( _ selection: Binding>, _ kind: MessageVisibilityKind ) -> Binding { Binding( get: { selection.wrappedValue.contains(kind) }, set: { newVal in var s = selection.wrappedValue if newVal { s.insert(kind) } else { s.remove(kind) } selection.wrappedValue = s } ) } private struct MessageTypeRow: Identifiable { let id: String let title: String let kind: MessageVisibilityKind? let level: Int let isGroup: Bool } private var messageTypeRows: [MessageTypeRow] { [ MessageTypeRow( id: MessageVisibilityKind.user.rawValue, title: MessageVisibilityKind.user.settingsLabel, kind: .user, level: 0, isGroup: false), MessageTypeRow( id: MessageVisibilityKind.assistant.rawValue, title: MessageVisibilityKind.assistant.settingsLabel, kind: .assistant, level: 0, isGroup: false), MessageTypeRow( id: MessageVisibilityKind.reasoning.rawValue, title: MessageVisibilityKind.reasoning.settingsLabel, kind: .reasoning, level: 0, isGroup: false), MessageTypeRow( id: MessageVisibilityKind.codeEdit.rawValue, title: MessageVisibilityKind.codeEdit.settingsLabel, kind: .codeEdit, level: 0, isGroup: false), MessageTypeRow( id: MessageVisibilityKind.tool.rawValue, title: MessageVisibilityKind.tool.settingsLabel, kind: .tool, level: 0, isGroup: false), MessageTypeRow( id: MessageVisibilityKind.tokenUsage.rawValue, title: MessageVisibilityKind.tokenUsage.settingsLabel, kind: .tokenUsage, level: 0, isGroup: false), MessageTypeRow( id: MessageVisibilityKind.infoOther.rawValue, title: MessageVisibilityKind.infoOther.settingsLabel, kind: .infoOther, level: 0, isGroup: false), ] } private var codexSettings: some View { settingsScroll { CodexSettingsView(codexVM: codexVM, preferences: preferences) } } private var geminiSettings: some View { settingsScroll { GeminiSettingsView(vm: geminiVM, preferences: preferences) } } private var claudeCodeSettings: some View { settingsScroll { ClaudeCodeSettingsView(vm: claudeVM, preferences: preferences) } } private var gitReviewSettings: some View { settingsScroll { GitReviewSettingsView(preferences: preferences) } } private var providersSettings: some View { settingsScroll { ProvidersSettingsView(preferences: preferences) .frame(maxWidth: .infinity, alignment: .topLeading) } } // MARK: - Advanced private var advancedSettings: some View { settingsScroll { AdvancedSettingsView(preferences: preferences) .frame(maxWidth: .infinity, alignment: .topLeading) } } private var terminalSettings: some View { settingsScroll { if AppSandbox.isEnabled { VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 6) { Text("Terminal Settings") .font(.title2) .fontWeight(.bold) Text("Embedded terminal features are unavailable in the App Store build.") .font(.subheadline) .foregroundColor(.secondary) } VStack(alignment: .leading, spacing: 10) { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { // Row: Copy to clipboard (always relevant) // Row: Default external app (still relevant) GridRow { VStack(alignment: .leading, spacing: 0) { Text("Auto open external terminal") .font(.subheadline).fontWeight(.medium) Text("CodMate helps open the terminal app for external sessions") .font(.caption).foregroundColor(.secondary) } let terminals = externalTerminalOrderedProfiles(includeNone: true) Picker("", selection: $preferences.defaultResumeExternalAppId) { ForEach(terminals) { profile in Text(profile.displayTitle).tag(profile.id) } } .labelsHidden() .pickerStyle(.menu) .frame(maxWidth: .infinity, alignment: .trailing) .gridColumnAlignment(.trailing) .gridCellAnchor(.trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 0) { Text("Copy new or resume commands to clipboard") .font(.subheadline).fontWeight(.medium) Text("Automatically copy new or resume commands when starting sessions") .font(.caption).foregroundColor(.secondary) } Toggle("", isOn: $preferences.defaultResumeCopyToClipboard) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) .frame(maxWidth: .infinity, alignment: .trailing) .gridColumnAlignment(.trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 0) { Text("Prompt for Warp tab title") .font(.subheadline).fontWeight(.medium) Text("Show an input dialog before copying Warp commands") .font(.caption).foregroundColor(.secondary) } Toggle("", isOn: $preferences.promptForWarpTitle) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) .frame(maxWidth: .infinity, alignment: .trailing) .gridColumnAlignment(.trailing) } } } } .padding(.bottom, 16) } else { VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 6) { Text("Terminal Settings") .font(.title2) .fontWeight(.bold) Text("Configure terminal behavior and resume preferences") .font(.subheadline) .foregroundColor(.secondary) } VStack(alignment: .leading, spacing: 10) { Text("Embedded Terminal").font(.headline).fontWeight(.semibold) settingsCard { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { // Row: Embedded terminal toggle GridRow { VStack(alignment: .leading, spacing: 2) { Label("Run in embedded terminal", systemImage: "terminal") .font(.subheadline).fontWeight(.medium) Text("Use the built-in terminal instead of an external one") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("", isOn: $preferences.defaultResumeUseEmbeddedTerminal) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) .frame(maxWidth: .infinity, alignment: .trailing) .gridColumnAlignment(.trailing) .disabled(AppDistribution.isAppStore || AppSandbox.isEnabled) } gridDivider if AppSandbox.isEnabled { // Row: Use CLI console (no shell) GridRow { VStack(alignment: .leading, spacing: 2) { Label("Use embedded CLI console (no shell)", systemImage: "text.terminal") .font(.subheadline).fontWeight(.medium) Text("Starts codex/claude directly") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("", isOn: $preferences.useEmbeddedCLIConsole) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) .frame(maxWidth: .infinity, alignment: .trailing) .gridColumnAlignment(.trailing) .disabled(AppDistribution.isAppStore || AppSandbox.isEnabled) } gridDivider } // Row: Font family & size (system font panel) GridRow { VStack(alignment: .leading, spacing: 2) { Label("Font & size", systemImage: "textformat") .font(.subheadline).fontWeight(.medium) Text("Opens the macOS font panel to pick a monospaced font.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } FontPickerButton( fontName: $preferences.terminalFontName, fontSize: $preferences.terminalFontSize ) .frame(maxWidth: .infinity, alignment: .trailing) .gridColumnAlignment(.trailing) .disabled(!preferences.defaultResumeUseEmbeddedTerminal) } gridDivider // Row: Cursor style only GridRow { VStack(alignment: .leading, spacing: 2) { Label("Cursor style", systemImage: "cursorarrow") .font(.subheadline).fontWeight(.medium) Text("Choose the caret shape shown inside the terminal.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Picker( "", selection: Binding( get: { preferences.terminalCursorStyleOption }, set: { preferences.terminalCursorStyleOption = $0 } ) ) { ForEach(TerminalCursorStyleOption.allCases) { option in Text(option.title).tag(option) } } .labelsHidden() .pickerStyle(.menu) .frame(maxWidth: .infinity, alignment: .trailing) .gridColumnAlignment(.trailing) .disabled(!preferences.defaultResumeUseEmbeddedTerminal) } gridDivider // Row: Dark mode theme GridRow { VStack(alignment: .leading, spacing: 2) { Label("Dark Mode Theme", systemImage: "moon.fill") .font(.subheadline).fontWeight(.medium) Text("Terminal color scheme for dark mode") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Picker("", selection: Binding( get: { preferences.terminalThemeName }, set: { preferences.terminalThemeName = $0 } )) { ForEach(availableThemes, id: \.self) { theme in Text(theme).tag(theme) } } .labelsHidden() .pickerStyle(.menu) .frame(maxWidth: .infinity, alignment: .trailing) .gridColumnAlignment(.trailing) .disabled(!preferences.defaultResumeUseEmbeddedTerminal || availableThemes.isEmpty) } gridDivider // Row: Light mode theme GridRow { VStack(alignment: .leading, spacing: 2) { Label("Light Mode Theme", systemImage: "sun.max.fill") .font(.subheadline).fontWeight(.medium) Text("Terminal color scheme for light mode") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Picker("", selection: Binding( get: { preferences.terminalThemeNameLight }, set: { preferences.terminalThemeNameLight = $0 } )) { ForEach(availableThemes, id: \.self) { theme in Text(theme).tag(theme) } } .labelsHidden() .pickerStyle(.menu) .frame(maxWidth: .infinity, alignment: .trailing) .gridColumnAlignment(.trailing) .disabled(!preferences.defaultResumeUseEmbeddedTerminal || availableThemes.isEmpty) } } } .task { if availableThemes.isEmpty { availableThemes = GhosttyThemeLoader.loadAvailableThemes() } } } VStack(alignment: .leading, spacing: 10) { Text("External Terminal").font(.headline).fontWeight(.semibold) settingsCard { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) { GridRow { VStack(alignment: .leading, spacing: 2) { Label("Auto open external terminal", systemImage: "arrow.up.right.square") .font(.subheadline).fontWeight(.medium) Text("CodMate helps open the terminal app for external sessions") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } let terminals = externalTerminalOrderedProfiles(includeNone: true) Picker("", selection: $preferences.defaultResumeExternalAppId) { ForEach(terminals) { profile in Text(profile.displayTitle).tag(profile.id) } } .labelsHidden() .pickerStyle(.menu) .frame(maxWidth: .infinity, alignment: .trailing) .gridColumnAlignment(.trailing) .gridCellAnchor(.trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Copy new or resume commands to clipboard", systemImage: "doc.on.clipboard") .font(.subheadline).fontWeight(.medium) Text("Automatically copy new or resume commands when starting sessions") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("", isOn: $preferences.defaultResumeCopyToClipboard) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) .frame(maxWidth: .infinity, alignment: .trailing) .gridColumnAlignment(.trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Prompt for Warp tab title", systemImage: "text.bubble") .font(.subheadline).fontWeight(.medium) Text("Show an input dialog before copying Warp commands") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("", isOn: $preferences.promptForWarpTitle) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) .frame(maxWidth: .infinity, alignment: .trailing) .gridColumnAlignment(.trailing) } } } } } .padding(.bottom, 16) } } } private var notificationsSettings: some View { settingsScroll { VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 6) { Text("Notifications Settings") .font(.title2) .fontWeight(.bold) Text("Configure notification delivery and hooks") .font(.subheadline) .foregroundColor(.secondary) } VStack(alignment: .leading, spacing: 10) { Text("Common").font(.headline).fontWeight(.semibold) settingsCard { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { GridRow { VStack(alignment: .leading, spacing: 2) { Label("Commit message notifications", systemImage: "square.and.pencil") .font(.subheadline).fontWeight(.medium) Text("Notify when commit message generation completes or fails.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("", isOn: $preferences.commitMessageNotificationsEnabled) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) .frame(maxWidth: .infinity, alignment: .trailing) .gridColumnAlignment(.trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Title & comment notifications", systemImage: "text.bubble") .font(.subheadline).fontWeight(.medium) Text("Notify when title and comment generation completes or fails.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("", isOn: $preferences.titleCommentNotificationsEnabled) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) .frame(maxWidth: .infinity, alignment: .trailing) .gridColumnAlignment(.trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Copy command notifications", systemImage: "doc.on.doc") .font(.subheadline).fontWeight(.medium) Text("Notify after copying New/Resume commands to the clipboard.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("", isOn: $preferences.commandCopyNotificationsEnabled) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) .frame(maxWidth: .infinity, alignment: .trailing) .gridColumnAlignment(.trailing) } } } } VStack(alignment: .leading, spacing: 10) { Text("Codex").font(.headline).fontWeight(.semibold) settingsCard { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { GridRow { VStack(alignment: .leading, spacing: 2) { Label("TUI Notifications", systemImage: "terminal") .font(.subheadline).fontWeight(.medium) Text( "Show in-terminal notifications during TUI sessions (supported terminals only)." ) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("", isOn: $codexVM.tuiNotifications) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) .onChange(of: codexVM.tuiNotifications) { _ in codexVM.scheduleApplyTuiNotificationsDebounced() } .frame(maxWidth: .infinity, alignment: .trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("System Notifications", systemImage: "bell") .font(.subheadline).fontWeight(.medium) Text( "Forward Codex turn-complete events to macOS notifications via notify." ) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("", isOn: $codexVM.systemNotifications) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) .onChange(of: codexVM.systemNotifications) { _ in codexVM.scheduleApplySystemNotificationsDebounced() } .frame(maxWidth: .infinity, alignment: .trailing) } if let path = codexVM.notifyBridgePath { gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Notify bridge", systemImage: "link") .font(.subheadline).fontWeight(.medium) Text(path) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Self-test", systemImage: "checkmark.seal") .font(.subheadline).fontWeight(.medium) Text("Send a sample event through the notify bridge.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } HStack(spacing: 8) { if codexVM.notifyBridgeHealthy { Image(systemName: "checkmark.seal.fill").foregroundStyle(.green) } else { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.orange) } Button("Run Self-test") { Task { await codexVM.runNotifySelfTest() } } .controlSize(.small) if let r = codexVM.notifySelfTestResult { Text(r).font(.caption).foregroundStyle(.secondary) } } .frame(maxWidth: .infinity, alignment: .trailing) } } } .disabled(!preferences.cliCodexEnabled) .opacity(preferences.cliCodexEnabled ? 1.0 : 0.6) } VStack(alignment: .leading, spacing: 10) { Text("Claude Code").font(.headline).fontWeight(.semibold) settingsCard { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { GridRow { VStack(alignment: .leading, spacing: 2) { Label("System Notifications", systemImage: "bell") .font(.subheadline).fontWeight(.medium) Text("Forward Claude Code permission and completion hooks to macOS via codmate://notify.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("", isOn: $claudeVM.notificationsEnabled) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) .frame(maxWidth: .infinity, alignment: .trailing) .onChange(of: claudeVM.notificationsEnabled) { _ in claudeVM.scheduleApplyNotificationSettingsDebounced() } } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Self-test", systemImage: "checkmark.seal") .font(.subheadline).fontWeight(.medium) Text("Send a sample event through the notify bridge.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } HStack(spacing: 8) { if claudeVM.notificationBridgeHealthy { Image(systemName: "checkmark.seal.fill").foregroundStyle(.green) } else { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.orange) } Button("Run Self-test") { Task { await claudeVM.runNotificationSelfTest() } } .controlSize(.small) if let result = claudeVM.notificationSelfTestResult { Text(result).font(.caption).foregroundStyle(.secondary) } } .frame(maxWidth: .infinity, alignment: .trailing) } } } .disabled(!preferences.cliClaudeEnabled) .opacity(preferences.cliClaudeEnabled ? 1.0 : 0.6) } VStack(alignment: .leading, spacing: 10) { Text("Gemini CLI").font(.headline).fontWeight(.semibold) settingsCard { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { GridRow { VStack(alignment: .leading, spacing: 2) { Label("System Notifications", systemImage: "bell") .font(.subheadline).fontWeight(.medium) Text("Forward Gemini permission prompts to macOS via codmate://notify.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Toggle("", isOn: $geminiVM.notificationsEnabled) .labelsHidden() .toggleStyle(.switch) .controlSize(.small) .onChange(of: geminiVM.notificationsEnabled) { _ in geminiVM.scheduleApplyNotificationSettingsDebounced() } .frame(maxWidth: .infinity, alignment: .trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 2) { Label("Self-test", systemImage: "checkmark.seal") .font(.subheadline).fontWeight(.medium) Text("Send a sample event through the notify bridge.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } HStack(spacing: 8) { if geminiVM.notificationBridgeHealthy { Image(systemName: "checkmark.seal.fill").foregroundStyle(.green) } else { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.orange) } Button("Run Self-test") { Task { await geminiVM.runNotificationSelfTest() } } .controlSize(.small) if let result = geminiVM.notificationSelfTestResult { Text(result).font(.caption).foregroundStyle(.secondary) } } .frame(maxWidth: .infinity, alignment: .trailing) } } } .disabled(!preferences.cliGeminiEnabled) .opacity(preferences.cliGeminiEnabled ? 1.0 : 0.6) } } .padding(.bottom, 16) } .task { await claudeVM.loadAll() await geminiVM.loadIfNeeded() } } private var commandSettings: some View { settingsScroll { VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 6) { Text("Command Options") .font(.title2) .fontWeight(.bold) Text("Default sandbox and approval policies for Codex commands") .font(.subheadline) .foregroundColor(.secondary) } VStack(alignment: .leading, spacing: 10) { Text("Codex CLI Defaults").font(.headline).fontWeight(.semibold) settingsCard { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 18) { GridRow { VStack(alignment: .leading, spacing: 0) { Text("Sandbox policy (-s, --sandbox)") .font(.subheadline).fontWeight(.medium) Text("Filesystem access level for generated commands") .font(.caption).foregroundColor(.secondary) } Picker("", selection: $preferences.defaultResumeSandboxMode) { ForEach(SandboxMode.allCases) { Text($0.title).tag($0) } } .labelsHidden() .frame(maxWidth: .infinity, alignment: .trailing) .gridColumnAlignment(.trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 0) { Text("Approval policy (-a, --ask-for-approval)") .font(.subheadline).fontWeight(.medium) Text("When human confirmation is required") .font(.caption).foregroundColor(.secondary) } Picker("", selection: $preferences.defaultResumeApprovalPolicy) { ForEach(ApprovalPolicy.allCases) { Text($0.title).tag($0) } } .labelsHidden() .frame(maxWidth: .infinity, alignment: .trailing) .gridColumnAlignment(.trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 0) { Text("Enable full-auto (--full-auto)") .font(.subheadline).fontWeight(.medium) Text("Alias for on-failure approvals with workspace-write sandbox") .font(.caption).foregroundColor(.secondary) } Toggle("", isOn: $preferences.defaultResumeFullAuto) .labelsHidden() .toggleStyle(.switch) .frame(maxWidth: .infinity, alignment: .trailing) .gridColumnAlignment(.trailing) } gridDivider GridRow { VStack(alignment: .leading, spacing: 0) { Text("Bypass approvals & sandbox") .font(.subheadline).fontWeight(.medium) .foregroundColor(.red) Text("--dangerously-bypass-approvals-and-sandbox (use with care)") .font(.caption).foregroundColor(.secondary) } Toggle("", isOn: $preferences.defaultResumeDangerBypass) .labelsHidden() .toggleStyle(.switch) .tint(.red) .frame(maxWidth: .infinity, alignment: .trailing) .gridColumnAlignment(.trailing) } } } } } .padding(.bottom, 16) } } private var mcpMateURL: URL { URL(string: "https://mcpmate.io/")! } private let mcpMateTagline = "Dedicated MCP orchestration for Codex workflows." private var extensionsSettings: some View { ExtensionsSettingsView( selectedTab: $selectedExtensionsTab, preferences: preferences, openMCPMateDownload: openMCPMateDownload ) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding(.top, 24) .padding(.horizontal, 24) .padding(.bottom, 24) } private var remoteHostsSettings: some View { let enabled = preferences.isCLIEnabled(.codex) || preferences.isCLIEnabled(.claude) return settingsScroll { VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 6) { Text("Remote Hosts") .font(.title2) .fontWeight(.bold) Text("Choose which SSH hosts CodMate should mirror for remote Codex/Claude sessions.") .font(.subheadline) .foregroundColor(.secondary) } let sshPermissionGranted = permissionsManager.hasPermission(for: .sshConfig) HStack(alignment: .firstTextBaseline) { Spacer(minLength: 8) HStack(spacing: 10) { Button(role: .none) { DispatchQueue.main.async { preferences.enabledRemoteHosts = [] } } label: { Text("Clear All") } .buttonStyle(.bordered) .disabled(preferences.enabledRemoteHosts.isEmpty) Button { Task { await viewModel.syncRemoteHosts(force: true, refreshAfter: true) } } label: { Label("Sync Hosts", systemImage: "arrow.triangle.2.circlepath") } .buttonStyle(.bordered) .disabled(preferences.enabledRemoteHosts.isEmpty) Button { reloadRemoteHosts() } label: { Label("Refresh", systemImage: "arrow.clockwise") } .buttonStyle(.bordered) .disabled(!sshPermissionGranted) } } if !sshPermissionGranted { VStack(alignment: .leading, spacing: 8) { Label("Grant Access to ~/.ssh", systemImage: "lock.square") .font(.headline) Text( "CodMate needs permission to read ~/.ssh/config before it can list your SSH hosts. Grant access once and the app will remember it for future launches." ) .font(.caption) .foregroundColor(.secondary) Button { guard !isRequestingSSHAccess else { return } isRequestingSSHAccess = true Task { let granted = await permissionsManager.requestPermission(for: .sshConfig) await MainActor.run { isRequestingSSHAccess = false if granted { reloadRemoteHosts() } } } } label: { HStack(spacing: 6) { if isRequestingSSHAccess { ProgressView() .controlSize(.small) } Text(isRequestingSSHAccess ? "Requesting…" : "Grant Access") } .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) } .padding() .background(Color(nsColor: .separatorColor).opacity(0.2)) .cornerRadius(10) } let hosts = sshPermissionGranted ? availableRemoteHosts : [] if sshPermissionGranted { if hosts.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("No SSH hosts were found in ~/.ssh/config.") .font(.body) .foregroundColor(.secondary) Text( "Add host aliases to your SSH config, then refresh to enable remote session mirroring." ) .font(.caption) .foregroundStyle(.tertiary) } .padding(.vertical, 12) .frame(maxWidth: .infinity, alignment: .leading) } else { VStack(alignment: .leading, spacing: 10) { ForEach(hosts, id: \.alias) { host in VStack(alignment: .leading, spacing: 2) { Toggle(isOn: bindingForRemoteHost(alias: host.alias)) { Text(host.alias) .font(.body) .fontWeight(.medium) } .toggleStyle(.switch) let (statusText, statusColor) = syncStatusDescription(for: host.alias) Text(statusText) .font(.caption2) .foregroundColor(statusColor) } } } .padding(.vertical, 4) } } else { VStack(alignment: .leading, spacing: 8) { Text("Grant access above to inspect ~/.ssh/config.") .font(.body) .foregroundColor(.secondary) Text("CodMate cannot mirror remote sessions until it can read your SSH config.") .font(.caption) .foregroundStyle(.tertiary) } .padding(.vertical, 12) .frame(maxWidth: .infinity, alignment: .leading) } let hostAliases = Set(hosts.map { $0.alias }) let dangling = preferences.enabledRemoteHosts.subtracting(hostAliases) if sshPermissionGranted && !dangling.isEmpty { VStack(alignment: .leading, spacing: 6) { Text("Unavailable Hosts") .font(.subheadline) .fontWeight(.semibold) Text( "The following host aliases are enabled but not present in your current SSH config:" ) .font(.caption) .foregroundColor(.secondary) ForEach(Array(dangling).sorted(), id: \.self) { alias in Text("• \(alias)") .font(.caption) .foregroundStyle(.tertiary) } } .padding(.vertical, 6) } Text( "CodMate mirrors only the hosts you enable. Hosts that prompt for passwords will open interactively when needed." ) .font(.caption) .foregroundStyle(.secondary) } .onAppear { if permissionsManager.hasPermission(for: .sshConfig) && availableRemoteHosts.isEmpty { DispatchQueue.main.async { reloadRemoteHosts() } } } .onChange(of: permissionsManager.hasPermission(for: .sshConfig)) { granted in if granted { reloadRemoteHosts() } else { availableRemoteHosts = [] } } } .disabled(!enabled) .opacity(enabled ? 1.0 : 0.6) } @MainActor private func reloadRemoteHosts() { guard permissionsManager.hasPermission(for: .sshConfig) else { availableRemoteHosts = [] return } let resolver = SSHConfigResolver() let hosts = resolver.resolvedHosts().sorted { $0.alias.lowercased() < $1.alias.lowercased() } availableRemoteHosts = hosts let hostAliases = Set(hosts.map { $0.alias }) let filtered = preferences.enabledRemoteHosts.filter { hostAliases.contains($0) } if filtered.count != preferences.enabledRemoteHosts.count { DispatchQueue.main.async { preferences.enabledRemoteHosts = Set(filtered) } } } private func bindingForRemoteHost(alias: String) -> Binding { Binding( get: { preferences.enabledRemoteHosts.contains(alias) }, set: { isOn in DispatchQueue.main.async { var hosts = preferences.enabledRemoteHosts if isOn { hosts.insert(alias) } else { hosts.remove(alias) } preferences.enabledRemoteHosts = hosts } } ) } private static let relativeFormatter: RelativeDateTimeFormatter = { let f = RelativeDateTimeFormatter() f.unitsStyle = .full return f }() private func syncStatusDescription(for alias: String) -> (String, Color) { guard let state = viewModel.remoteSyncStates[alias] else { return ("Not synced yet", .secondary) } switch state { case .idle: return ("Not synced yet", .secondary) case .syncing: return ("Syncing…", .secondary) case .succeeded(let date): let relative = Self.relativeFormatter.localizedString(for: date, relativeTo: Date()) return ("Last synced \(relative)", .secondary) case .failed(let date, let message): let relative = Self.relativeFormatter.localizedString(for: date, relativeTo: Date()) let detail = Self.syncFailureDetail(from: message) if detail.isEmpty { return ("Sync failed \(relative)", .red) } return ("Sync failed \(relative): \(detail)", .red) } } private static func syncFailureDetail(from rawMessage: String) -> String { let firstLine = rawMessage .split(whereSeparator: \.isNewline) .first .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } ?? "" guard !firstLine.isEmpty else { return "" } let prefix = "sync failed" if firstLine.lowercased().hasPrefix(prefix) { var separators = CharacterSet.whitespacesAndNewlines separators.insert(charactersIn: ":-–—") let remainder = firstLine.dropFirst(prefix.count) let sanitized = String(remainder).trimmingCharacters(in: separators) return sanitized } return firstLine } private func resetToDefaults() { preferences.projectsRoot = SessionPreferencesStore.defaultProjectsRoot( for: FileManager.default.homeDirectoryForCurrentUser) preferences.notesRoot = SessionPreferencesStore.defaultNotesRoot( for: preferences.sessionsRoot) preferences.codexCommandPath = "" preferences.claudeCommandPath = "" preferences.geminiCommandPath = "" preferences.defaultResumeUseEmbeddedTerminal = true preferences.defaultResumeCopyToClipboard = true preferences.defaultResumeExternalAppId = "terminal" preferences.defaultResumeSandboxMode = .workspaceWrite preferences.defaultResumeApprovalPolicy = .onRequest preferences.defaultResumeFullAuto = false preferences.defaultResumeDangerBypass = false } private func openMCPMateDownload() { NSWorkspace.shared.open(mcpMateURL) } // MARK: - Helper Views private func settingsScroll(@ViewBuilder _ content: () -> Content) -> some View { ScrollView { content() .frame(maxWidth: .infinity, alignment: .topLeading) .padding(.top, 24) .padding(.horizontal, 24) .padding(.bottom, 32) } // Allow the scroll view to clip to its bounds so the system // titlebar bottom separator (hairline) remains visible consistently. } @ViewBuilder private func settingsCard(@ViewBuilder _ content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 8) { content() } .padding(10) .background(Color(nsColor: .separatorColor).opacity(0.35)) .cornerRadius(10) } @ViewBuilder private var gridDivider: some View { Divider() } } struct SettingsView_Previews: PreviewProvider { static var previews: some View { let prefs = SessionPreferencesStore() let vm = SessionListViewModel(preferences: prefs) return SettingsView( preferences: prefs, selection: .constant(.general), extensionsTab: .constant(.mcp) ) .environmentObject(vm) } } ================================================ FILE: views/SimpleProviderPicker.swift ================================================ import SwiftUI #if os(macOS) import AppKit #endif /// Simplified provider picker for CLI settings with only two options: /// - Default (Built-in): Use CLI's built-in provider /// - Auto-Proxy (CliProxyAPI): Route through CLI Proxy API struct SimpleProviderPicker: View { let builtInTitle: String let autoProxyTitle: String let builtInTooltip: String let autoProxyTooltip: String @Binding var providerId: String? private enum ProviderOption: String, CaseIterable { case builtIn = "builtIn" case autoProxy = "autoProxy" } private var selection: Binding { Binding( get: { providerId == UnifiedProviderID.autoProxyId ? .autoProxy : .builtIn }, set: { newValue in providerId = newValue == .autoProxy ? UnifiedProviderID.autoProxyId : nil } ) } init( builtInTitle: String = "Default (Built-in)", autoProxyTitle: String = "Auto-Proxy (CliProxyAPI)", builtInTooltip: String = "Use CLI's built-in provider configuration", autoProxyTooltip: String = "Route all requests through CliProxyAPI for unified provider management", providerId: Binding ) { self.builtInTitle = builtInTitle self.autoProxyTitle = autoProxyTitle self.builtInTooltip = builtInTooltip self.autoProxyTooltip = autoProxyTooltip self._providerId = providerId } var body: some View { Picker("", selection: selection) { Text(builtInTitle).tag(ProviderOption.builtIn) Text(autoProxyTitle).tag(ProviderOption.autoProxy) } .labelsHidden() .pickerStyle(.segmented) .tint(selection.wrappedValue == .autoProxy ? .red : nil) .padding(2) .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) } } /// Simplified model picker for displaying sanitized model names /// Supports provider icon/prefix display and searchable menu for long lists struct SimpleModelPicker: View { let models: [String] let includeDefault: Bool let defaultTitle: String let isDisabled: Bool let sanitizeNames: Bool let onEditModels: (() -> Void)? let editModelsHelp: String? let providerId: String? let providerCatalog: UnifiedProviderCatalogModel? @Binding var modelId: String? @State private var searchText: String = "" @State private var isMenuOpen: Bool = false init( models: [String], includeDefault: Bool = true, defaultTitle: String = "(default)", isDisabled: Bool = false, sanitizeNames: Bool = true, onEditModels: (() -> Void)? = nil, editModelsHelp: String? = nil, providerId: String? = nil, providerCatalog: UnifiedProviderCatalogModel? = nil, modelId: Binding ) { self.models = models self.includeDefault = includeDefault self.defaultTitle = defaultTitle self.isDisabled = isDisabled self.sanitizeNames = sanitizeNames self.onEditModels = onEditModels self.editModelsHelp = editModelsHelp self.providerId = providerId self.providerCatalog = providerCatalog self._modelId = modelId } private var filteredModels: [String] { if searchText.isEmpty { return models } let query = searchText.lowercased() return models.filter { model in let display = displayName(for: model).lowercased() return display.contains(query) || model.lowercased().contains(query) } } private var shouldUseSearchableMenu: Bool { models.count > 10 // Use searchable menu for lists with more than 10 items } var body: some View { HStack(spacing: 8) { if shouldUseSearchableMenu { searchableMenuPicker } else { standardPicker } if let onEditModels { Button { onEditModels() } label: { Image(systemName: "slider.horizontal.3") } .buttonStyle(.borderless) .help(editModelsHelp ?? "Edit models") } } } private var standardPicker: some View { Picker("", selection: $modelId) { if includeDefault { Text(defaultTitle).tag(String?.none) } if models.isEmpty { // Show a placeholder when models are empty and includeDefault is false if !includeDefault { Text("(no models available)").tag(String?.none).disabled(true) } } else { ForEach(models, id: \.self) { model in modelMenuItem(model: model) } } } .labelsHidden() .disabled(isDisabled) } @State private var isSearchPopoverPresented = false private var searchableMenuPicker: some View { HStack(spacing: 4) { Button { isSearchPopoverPresented = true } label: { HStack { if let modelId = modelId { modelLabel(model: modelId) } else { Text(defaultTitle) } Image(systemName: "chevron.down") .font(.system(size: 10)) .foregroundStyle(.secondary) } } .disabled(isDisabled) .popover(isPresented: $isSearchPopoverPresented, arrowEdge: .bottom) { searchableModelListPopover } } } private var searchableModelListPopover: some View { VStack(alignment: .leading, spacing: 8) { // Search field TextField("Search models", text: $searchText) .textFieldStyle(.roundedBorder) .padding(.top, 16) Divider() // Model list ScrollView { LazyVStack(alignment: .leading, spacing: 0) { if includeDefault { modelRowButton( isSelected: modelId == nil, action: { modelId = nil isSearchPopoverPresented = false }, content: { HStack { Text(defaultTitle) Spacer() if modelId == nil { Image(systemName: "checkmark") } } }, index: 0 ) } if filteredModels.isEmpty { Text("No models found") .foregroundStyle(.secondary) .padding(.vertical, 8) } else { ForEach(Array(filteredModels.enumerated()), id: \.element) { index, model in modelRowButton( isSelected: modelId == model, action: { modelId = model isSearchPopoverPresented = false }, content: { HStack { modelLabel(model: model) Spacer() if modelId == model { Image(systemName: "checkmark") } } }, index: includeDefault ? index + 1 : index ) } } } } .frame(width: 400, height: 300) } .padding(.bottom, 16) .padding(.horizontal, 16) } @ViewBuilder private func modelRowButton( isSelected: Bool, action: @escaping () -> Void, @ViewBuilder content: () -> Content, index: Int ) -> some View { Button(action: action) { content() .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 8) .padding(.horizontal, 8) .background( Group { if isSelected { Color.accentColor.opacity(0.1) } else if index % 2 == 1 { Color(nsColor: .separatorColor).opacity(0.08) } else { Color.clear } } ) .contentShape(Rectangle()) } .buttonStyle(ModelRowButtonStyle()) .onHover { hovering in if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() } } } @ViewBuilder private func modelMenuItem(model: String) -> some View { modelLabel(model: model) .tag(String?(model)) } @ViewBuilder private func modelLabel(model: String) -> some View { HStack(spacing: 6) { if let providerId = providerId, let catalog = providerCatalog { modelLabelProviderInfo(model: model, providerId: providerId, catalog: catalog) } Text(displayName(for: model)) } } @ViewBuilder private func modelLabelProviderInfo(model: String, providerId: String, catalog: UnifiedProviderCatalogModel) -> some View { // When providerId is autoProxy, infer provider from model ID if providerId == UnifiedProviderID.autoProxyId { // Infer provider from model ID if let title = catalog.inferProviderFromModel(model) { if let icon = providerIcon(for: nil, title: title, modelId: model) { icon .resizable() .interpolation(.high) .aspectRatio(contentMode: .fit) .frame(width: 14, height: 14) } else { Text(title) .font(.caption2) .foregroundStyle(.secondary) .padding(.horizontal, 4) .padding(.vertical, 1) .background(Color(nsColor: .separatorColor).opacity(0.5)) .cornerRadius(3) } } } else { // Use provider title from catalog if let title = catalog.providerTitle(for: providerId) { if let icon = providerIcon(for: providerId, title: title, modelId: model) { icon .resizable() .interpolation(.high) .aspectRatio(contentMode: .fit) .frame(width: 14, height: 14) } else { Text(title) .font(.caption2) .foregroundStyle(.secondary) .padding(.horizontal, 4) .padding(.vertical, 1) .background(Color(nsColor: .separatorColor).opacity(0.5)) .cornerRadius(3) } } } } private func providerIcon(for providerId: String?, title: String, modelId: String? = nil) -> Image? { // If providerId is nil (autoProxy mode), infer icon from title (service provider name) if providerId == nil || providerId == UnifiedProviderID.autoProxyId { // Priority 1: Try OAuth provider icon by title if let authProvider = LocalAuthProvider.allCases.first(where: { $0.displayName == title }) { let iconName = iconNameForOAuthProvider(authProvider) if let nsImage = ProviderIconThemeHelper.menuImage(named: iconName, size: NSSize(width: 14, height: 14)) { return Image(nsImage: nsImage) } } // Priority 2: Try API key provider icon by title (check customIcon first) // Try to find provider by title to check for customIcon if let provider = findProviderByTitle(title), let customIcon = provider.customIcon { return Image(systemName: customIcon) } // Priority 3: Try preset PNG icon if let iconName = ProviderIconResource.iconName(for: title), let nsImage = ProviderIconResource.processedImage( named: iconName, size: NSSize(width: 14, height: 14), isDarkMode: NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ) { return Image(nsImage: nsImage) } // No fallback - if title doesn't match any known provider, return nil (shows default circle) return nil } let parsed = UnifiedProviderID.parse(providerId ?? "") switch parsed { case .oauth(let authProvider, _): // For OAuth providers, we can use LocalAuthProviderIconView but need to return Image // Since we're in a Menu context, we'll use the icon name directly let iconName = iconNameForOAuthProvider(authProvider) if let nsImage = ProviderIconThemeHelper.menuImage(named: iconName, size: NSSize(width: 14, height: 14)) { return Image(nsImage: nsImage) } return nil case .api(let apiId): // Priority 1: Check for custom SF Symbol icon if let provider = findProviderById(apiId), let customIcon = provider.customIcon { return Image(systemName: customIcon) } // Priority 2: Try preset PNG icon if let iconName = ProviderIconResource.iconName(for: apiId) ?? ProviderIconResource.iconName(for: title), let nsImage = ProviderIconResource.processedImage( named: iconName, size: NSSize(width: 14, height: 14), isDarkMode: NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ) { return Image(nsImage: nsImage) } return nil default: return nil } } // Helper to find provider by ID from registry private func findProviderById(_ id: String) -> ProvidersRegistryService.Provider? { let registry = ProvidersRegistryService() // Use synchronous load() instead of async listProviders() to avoid actor isolation warnings let loadedRegistry = registry.load() return loadedRegistry.providers.first(where: { $0.id == id }) } // Helper to find provider by title/name from registry private func findProviderByTitle(_ title: String) -> ProvidersRegistryService.Provider? { let registry = ProvidersRegistryService() // Use synchronous load() instead of async listProviders() to avoid actor isolation warnings let loadedRegistry = registry.load() return loadedRegistry.providers.first(where: { provider in let displayName = UnifiedProviderID.providerDisplayName(provider) return displayName == title || provider.name == title || provider.id == title }) } private func iconNameForOAuthProvider(_ provider: LocalAuthProvider) -> String { switch provider { case .codex: return "ChatGPTIcon" case .claude: return "ClaudeIcon" case .gemini: return "GeminiIcon" case .antigravity: return "AntigravityIcon" case .qwen: return "QwenIcon" } } private func providerTitle(for providerId: String?) -> String? { guard let providerId = providerId else { return nil } return providerCatalog?.providerTitle(for: providerId) } private func displayName(for model: String) -> String { if sanitizeNames { return ModelNameSanitizer.sanitizeSingle(model) } return model } } // MARK: - Model Row Button Style private struct ModelRowButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .background( configuration.isPressed || configuration.role == .destructive ? Color(nsColor: .controlAccentColor).opacity(0.2) : Color.clear ) .contentShape(Rectangle()) } } ================================================ FILE: views/Skills/SkillPackageExplorerView.swift ================================================ import SwiftUI #if canImport(AppKit) import AppKit #endif struct SkillPackageExplorerView: View { let skill: SkillSummary var onReveal: () -> Void var onUninstall: () -> Void var showsHeader: Bool = true var showsActions: Bool = true @State private var treeQuery: String = "" @State private var expandedDirs: Set = [] @State private var nodes: [GitReviewNode] = [] @State private var displayedRows: [BrowserRow] = [] @State private var isLoading: Bool = false @State private var treeError: String? = nil @State private var treeTruncated: Bool = false @State private var totalEntries: Int = 0 @State private var selectedPath: String? = nil @State private var previewText: String = "" @State private var previewError: String? = nil #if canImport(AppKit) @State private var previewImage: NSImage? = nil #endif @State private var previewTask: Task? = nil @State private var reloadToken: UUID = UUID() private let indentStep: CGFloat = 16 private let chevronWidth: CGFloat = 16 private let rowHeight: CGFloat = 22 private let browserEntryLimit: Int = 4000 var body: some View { VStack(alignment: .leading, spacing: 12) { if showsHeader { header } HStack(alignment: .top, spacing: 12) { fileTree .frame(minWidth: 240, maxWidth: 280, maxHeight: .infinity) previewPane .frame(maxWidth: .infinity, maxHeight: .infinity) } } .onAppear { reloadTree(force: true) } .onChange(of: skill.id) { _ in treeQuery = "" expandedDirs = [] nodes = [] displayedRows = [] treeTruncated = false totalEntries = 0 treeError = nil selectedPath = nil previewText = "" previewError = nil previewTask?.cancel() #if canImport(AppKit) previewImage = nil #endif reloadToken = UUID() reloadTree(force: true) } .onChange(of: treeQuery) { _ in rebuildDisplayed() } .onChange(of: selectedPath) { _ in loadPreview() } } private var header: some View { HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 6) { Text(skill.displayName) .font(.title3.weight(.semibold)) Text(skill.description.isEmpty ? skill.summary : skill.description) .font(.subheadline) .foregroundStyle(.secondary) } Spacer() if showsActions { HStack(spacing: 8) { Button { onReveal() } label: { Image(systemName: "finder") } .buttonStyle(.borderless) .help("Reveal in Finder") Button(role: .destructive) { onUninstall() } label: { Image(systemName: "trash") } .buttonStyle(.borderless) .help("Move to Trash") } } } } private var fileTree: some View { VStack(alignment: .leading, spacing: 8) { HStack { ToolbarSearchField( placeholder: "Search files", text: $treeQuery, onFocusChange: { _ in }, onSubmit: {} ) .frame(maxWidth: .infinity) Button { collapseAll() } label: { Image(systemName: "arrow.up.right.and.arrow.down.left") } .buttonStyle(.borderless) .help("Expand all") Button { expandAll() } label: { Image(systemName: "arrow.down.left.and.arrow.up.right") } .buttonStyle(.borderless) .help("Collapse all") } ScrollView { VStack(alignment: .leading, spacing: 0) { if isLoading { HStack(spacing: 8) { ProgressView() Text("Loading files…") .font(.caption) .foregroundStyle(.secondary) } .padding(.vertical, 6) } else if let error = treeError { Text(error) .font(.caption) .foregroundStyle(.secondary) .padding(.vertical, 6) } else if displayedRows.isEmpty { Text( treeQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "No files." : "No matches." ) .font(.caption) .foregroundStyle(.secondary) .padding(.vertical, 6) } else { LazyVStack(alignment: .leading, spacing: 0) { ForEach(displayedRows) { row in browserRow(row) } } } } } if treeTruncated { Text("Showing first \(browserEntryLimit) files. Narrow search to see more.") .font(.caption2) .foregroundStyle(.secondary) } if !isLoading, treeError == nil, totalEntries > 0 { Text("\(totalEntries)\(treeTruncated ? "+" : "") items") .font(.caption2) .foregroundStyle(.tertiary) } } .padding(10) .background( RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color(nsColor: .textBackgroundColor)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.15))) ) } private var previewPane: some View { Group { #if canImport(AppKit) if let img = previewImage { ScrollView([.horizontal, .vertical]) { Image(nsImage: img) .resizable() .scaledToFit() .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(12) } } else { previewTextView } #else previewTextView #endif } .padding(10) .background( RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color(nsColor: .textBackgroundColor)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.15))) ) } private var previewTextView: some View { let emptyText: String = { if let error = previewError, !error.isEmpty { return error } return selectedPath == nil ? "Select a file to preview." : "(Empty preview)" }() return AttributedTextView( text: previewText.isEmpty ? emptyText : previewText, isDiff: false, wrap: false, showLineNumbers: true, fontSize: 12, searchQuery: "" ) } private func browserRow(_ row: BrowserRow) -> some View { if row.node.isDirectory { return AnyView(directoryRow(row)) } return AnyView(fileRow(row)) } private func directoryRow(_ row: BrowserRow) -> some View { let key = row.directoryKey ?? row.node.name let indent = CGFloat(max(row.depth, 0)) * indentStep let isExpanded = !treeQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || expandedDirs.contains(key) return HStack(spacing: 0) { ZStack(alignment: .leading) { Color.clear.frame(width: indent + chevronWidth) if row.depth > 0 { let guideColor = Color.secondary.opacity(0.15) ForEach(0.. some View { guard let path = row.filePath else { return AnyView(EmptyView()) } let indent = CGFloat(max(row.depth, 0)) * indentStep let isSelected = selectedPath == path let icon = GitFileIcon.icon(for: path) let bg = isSelected ? Color.accentColor.opacity(0.12) : Color.clear return AnyView( HStack(spacing: 0) { ZStack(alignment: .leading) { Color.clear.frame(width: indent) if row.depth > 0 { let guideColor = Color.secondary.opacity(0.15) ForEach(0.. [String] { var keys: [String] = [] func walk(_ ns: [GitReviewNode]) { for node in ns { if let dir = node.dirPath { keys.append(dir) if let children = node.children { walk(children) } } } } walk(nodes) return keys } private func filteredNodes(_ nodes: [GitReviewNode], query: String) -> [GitReviewNode] { let q = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !q.isEmpty else { return nodes } func filter(_ ns: [GitReviewNode]) -> [GitReviewNode] { var out: [GitReviewNode] = [] for n in ns { if n.isDirectory { let kids = n.children.map(filter) ?? [] if n.name.localizedCaseInsensitiveContains(q) || !kids.isEmpty { var dir = n dir.children = kids out.append(dir) } } else if let p = n.fullPath { if n.name.localizedCaseInsensitiveContains(q) || p.localizedCaseInsensitiveContains(q) { out.append(n) } } } return out } return filter(nodes) } private func flattenBrowserNodes(_ nodes: [GitReviewNode], depth: Int, forceExpand: Bool) -> [BrowserRow] { var rows: [BrowserRow] = [] for node in nodes { rows.append(BrowserRow(node: node, depth: depth)) if node.isDirectory, let key = node.dirPath ?? (depth == 0 ? node.name : nil) { if forceExpand || expandedDirs.contains(key) { let children = GitReviewTreeBuilder.explorerSort(node.children ?? []) rows.append( contentsOf: flattenBrowserNodes(children, depth: depth + 1, forceExpand: forceExpand)) } } } return rows } private func toggleDirectory(_ key: String) { if expandedDirs.contains(key) { expandedDirs.remove(key) } else { expandedDirs.insert(key) } rebuildDisplayed() } private func buildBrowserTreeFromFileSystem(root: URL, limit: Int) -> ( nodes: [GitReviewNode], truncated: Bool, total: Int, error: String? ) { let (paths, truncated, error) = collectFileSystemPaths(root: root, limit: limit) if paths.isEmpty { return ([], truncated, 0, error ?? "Unable to enumerate skill files.") } let nodes = buildBrowserTreeFromPaths(paths) return (nodes, truncated, paths.count, error) } private func collectFileSystemPaths(root: URL, limit: Int) -> ([String], Bool, String?) { let fm = FileManager.default let keys: [URLResourceKey] = [.isDirectoryKey, .isPackageKey] var encounteredError: String? let options: FileManager.DirectoryEnumerationOptions = [.skipsPackageDescendants] guard let enumerator = fm.enumerator( at: root, includingPropertiesForKeys: keys, options: options, errorHandler: { _, error in encounteredError = error.localizedDescription return true }) else { return ([], false, "Unable to enumerate skill files.") } let rootResolved = root.resolvingSymlinksInPath() let base = rootResolved.path.hasSuffix("/") ? rootResolved.path : rootResolved.path + "/" var collected: [String] = [] var truncated = false while let item = enumerator.nextObject() as? URL { let itemPath = item.resolvingSymlinksInPath().path guard itemPath.hasPrefix(base) else { continue } let relative = String(itemPath.dropFirst(base.count)) if relative.isEmpty { continue } if relative == ".codmate.json" || relative.hasSuffix("/.codmate.json") { continue } if relative == ".git" || relative.hasPrefix(".git/") { enumerator.skipDescendants() continue } if let values = try? item.resourceValues(forKeys: Set(keys)), values.isDirectory == true { continue } collected.append(relative) if collected.count >= limit { truncated = true break } } return (collected, truncated, encounteredError) } private func buildBrowserTreeFromPaths(_ paths: [String]) -> [GitReviewNode] { struct Builder { var children: [String: Builder] = [:] var filePath: String? = nil } var root = Builder() for path in paths { let components = path.split(separator: "/").map(String.init) guard !components.isEmpty else { continue } func insert(_ index: Int, current: inout Builder) { let key = components[index] if index == components.count - 1 { var child = current.children[key, default: Builder()] child.filePath = path current.children[key] = child } else { var child = current.children[key, default: Builder()] insert(index + 1, current: &child) current.children[key] = child } } insert(0, current: &root) } func convert(_ builder: Builder, prefix: String?) -> [GitReviewNode] { var nodes: [GitReviewNode] = [] for (name, child) in builder.children { let fullPath = prefix.map { "\($0)/\(name)" } ?? name if let filePath = child.filePath, child.children.isEmpty { nodes.append(GitReviewNode(name: name, fullPath: filePath, dirPath: nil, children: nil)) } else { let childrenNodes = convert(child, prefix: fullPath) nodes.append( GitReviewNode( name: name, fullPath: nil, dirPath: fullPath, children: GitReviewTreeBuilder.explorerSort(childrenNodes) ) ) } } return GitReviewTreeBuilder.explorerSort(nodes) } return convert(root, prefix: nil) } private func loadPreview() { previewTask?.cancel() let token = reloadToken previewTask = Task { guard let root = skill.path.map({ URL(fileURLWithPath: $0, isDirectory: true) }), let path = selectedPath else { await MainActor.run { previewText = "" previewError = nil #if canImport(AppKit) previewImage = nil #endif } return } let fileURL = root.appendingPathComponent(path) if isImagePath(path) { #if canImport(AppKit) let img = NSImage(contentsOf: fileURL) await MainActor.run { guard token == reloadToken else { return } previewImage = img previewText = "" previewError = img == nil ? "Unable to load image." : nil } #else await MainActor.run { guard token == reloadToken else { return } previewText = "Image preview not supported." previewError = nil } #endif return } do { let handle = try FileHandle(forReadingFrom: fileURL) let data = try handle.read(upToCount: 256_000) ?? Data() try? handle.close() if let text = String(data: data, encoding: .utf8) { await MainActor.run { #if canImport(AppKit) guard token == reloadToken else { return } previewImage = nil #endif previewText = text previewError = text.isEmpty ? "(Empty file)" : nil } } else { await MainActor.run { #if canImport(AppKit) guard token == reloadToken else { return } previewImage = nil #endif previewText = "" previewError = "Binary or unsupported file." } } } catch { await MainActor.run { #if canImport(AppKit) guard token == reloadToken else { return } previewImage = nil #endif previewText = "" previewError = "Unable to read file." } } } } private func isImagePath(_ path: String) -> Bool { let ext = URL(fileURLWithPath: path).pathExtension.lowercased() return ["png", "jpg", "jpeg", "gif", "bmp", "tiff", "tif", "heic", "heif", "webp"].contains(ext) } #if canImport(AppKit) private func revealPath(path: String, isDirectory: Bool) { guard let root = skill.path.map({ URL(fileURLWithPath: $0, isDirectory: true) }) else { return } let target = root.appendingPathComponent(path) if isDirectory { NSWorkspace.shared.open(target) } else { NSWorkspace.shared.activateFileViewerSelecting([target]) } } #endif } private struct BrowserRow: Identifiable { let node: GitReviewNode let depth: Int var id: String { node.id + "-\(depth)" } var directoryKey: String? { node.dirPath } var filePath: String? { node.fullPath } } ================================================ FILE: views/SkillsSettingsView.swift ================================================ import SwiftUI import UniformTypeIdentifiers #if canImport(AppKit) import AppKit #endif struct SkillsSettingsView: View { @ObservedObject var preferences: SessionPreferencesStore @StateObject private var vm = SkillsLibraryViewModel() @State private var searchFocused = false @State private var pendingAction: PendingSkillAction? var body: some View { VStack(alignment: .leading, spacing: 12) { headerRow contentRow } .onDrop(of: [UTType.fileURL, UTType.url, UTType.plainText], isTargeted: nil) { providers in vm.handleDrop(providers) } .sheet(isPresented: $vm.showInstallSheet) { SkillsInstallSheet(vm: vm) .frame(minWidth: 520, minHeight: 340) } .sheet(isPresented: $vm.showCreateSheet) { SkillCreateSheet(preferences: preferences, vm: vm, startInWizard: vm.createStartsWithWizard) .frame(minWidth: 760, minHeight: 520, maxHeight: 720) } .sheet(isPresented: $vm.showImportSheet) { SkillsImportSheet( candidates: $vm.importCandidates, isImporting: vm.isImporting, statusMessage: vm.importStatusMessage, title: "Import Skills", subtitle: "Scan Home for existing Codex/Claude/Gemini skills and import into CodMate.", onCancel: { vm.cancelImport() }, onImport: { Task { await vm.importSelectedSkills() } } ) .frame(minWidth: 760, minHeight: 480) } .sheet(item: $vm.installConflict) { conflict in SkillConflictResolutionSheet(conflict: conflict, onResolve: { resolution in vm.resolveInstallConflict(resolution) vm.installConflict = nil }, onCancel: { vm.installConflict = nil }) .frame(minWidth: 420, minHeight: 240) } .alert(item: $pendingAction) { action in Alert( title: Text("Move to Trash?"), message: Text("Move \(action.skill.displayName) to Trash?"), primaryButton: .destructive(Text("Move to Trash"), action: { vm.uninstall(id: action.skill.id) }), secondaryButton: .cancel() ) } .task { await vm.load() } } private var headerRow: some View { HStack(spacing: 8) { Spacer(minLength: 0) ToolbarSearchField( placeholder: "Search skills", text: $vm.searchText, onFocusChange: { focused in searchFocused = focused }, onSubmit: {} ) .frame(width: 240) Button { vm.prepareInstall(mode: vm.installMode) } label: { Label("Add", systemImage: "plus") } Button { vm.beginImportFromHome() } label: { Label("Import", systemImage: "tray.and.arrow.down") } } } private var contentRow: some View { HStack(alignment: .top, spacing: 12) { skillsList .frame(minWidth: 260, maxWidth: 320) detailPanel } .frame(maxWidth: .infinity, maxHeight: .infinity) } private var skillsList: some View { Group { if vm.isLoading { VStack(spacing: 8) { ProgressView() Text("Loading skills…") .font(.caption) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } else if vm.filteredSkills.isEmpty { VStack(spacing: 10) { Image(systemName: "sparkles") .font(.system(size: 32)) .foregroundStyle(.secondary) Text("No Skills") .font(.title3) .fontWeight(.medium) Text("Install skills from folder, zip, or URL to get started.") .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { List(selection: $vm.selectedSkillId) { ForEach(vm.filteredSkills) { skill in HStack(alignment: .center, spacing: 8) { Toggle( "", isOn: Binding( get: { skill.isSelected }, set: { value in vm.updateSkillSelection(id: skill.id, value: value) } ) ) .labelsHidden() .controlSize(.small) VStack(alignment: .leading, spacing: 4) { Text(skill.displayName) .font(.body.weight(.medium)) Text(skill.summary) .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) if !skill.tags.isEmpty { Text(skill.tags.joined(separator: " · ")) .font(.caption2) .foregroundStyle(.secondary) } } Spacer(minLength: 8) HStack(spacing: 6) { MCPServerTargetToggle( provider: .codex, isOn: Binding( get: { skill.targets.codex }, set: { value in vm.updateSkillTarget(id: skill.id, target: .codex, value: value) } ), disabled: !preferences.isCLIEnabled(.codex) ) MCPServerTargetToggle( provider: .claude, isOn: Binding( get: { skill.targets.claude }, set: { value in vm.updateSkillTarget(id: skill.id, target: .claude, value: value) } ), disabled: !preferences.isCLIEnabled(.claude) ) MCPServerTargetToggle( provider: .gemini, isOn: Binding( get: { skill.targets.gemini }, set: { value in vm.updateSkillTarget(id: skill.id, target: .gemini, value: value) } ), disabled: !preferences.isCLIEnabled(.gemini) ) } } .padding(.vertical, 4) .contentShape(Rectangle()) .onTapGesture { vm.selectedSkillId = skill.id } .tag(skill.id as String?) .contextMenu { #if canImport(AppKit) let editors = EditorApp.installedEditors openInEditorMenu(editors: editors) { editor in vm.openInEditor(skill, using: editor) } #endif Button("Reveal in Finder") { revealInFinder(skill) } Button("Move to Trash", role: .destructive) { confirmUninstall(skill) } } } } .listStyle(.inset) .scrollContentBackground(.hidden) } } .background( RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color(nsColor: .textBackgroundColor)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.15))) ) } private var detailPanel: some View { VStack(alignment: .leading, spacing: 12) { if let skill = vm.selectedSkill { SkillPackageExplorerView( skill: skill, onReveal: { revealInFinder(skill) }, onUninstall: { confirmUninstall(skill) } ) .id(skill.id) } else { VStack(spacing: 12) { Image(systemName: "doc.text") .font(.system(size: 32)) .foregroundStyle(.secondary) Text("Select a skill to view details") .font(.subheadline) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } } .padding(12) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background( RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color(nsColor: .textBackgroundColor)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.15))) ) } private func revealInFinder(_ skill: SkillSummary) { guard let path = skill.path, !path.isEmpty else { return } let url = URL(fileURLWithPath: path, isDirectory: true) #if canImport(AppKit) NSWorkspace.shared.activateFileViewerSelecting([url]) #endif } private func confirmUninstall(_ skill: SkillSummary) { pendingAction = PendingSkillAction(skill: skill) } } private struct PendingSkillAction: Identifiable { let id = UUID() let skill: SkillSummary } private struct SkillsInstallSheet: View { @ObservedObject var vm: SkillsLibraryViewModel @State private var importerPresented = false @State private var isDropTargeted = false @FocusState private var urlFieldFocused: Bool private let rowWidth: CGFloat = 420 private let fieldWidth: CGFloat = 320 var body: some View { VStack(alignment: .leading, spacing: 16) { HStack(alignment: .firstTextBaseline) { Text("Install Skill") .font(.title3) .fontWeight(.semibold) Spacer() Button { vm.cancelInstall() vm.prepareCreateSkill(startWithWizard: true) } label: { Image(systemName: "sparkles") } .buttonStyle(.borderless) .help("AI Wizard") } dropArea HStack { Spacer(minLength: 0) Picker("", selection: $vm.installMode) { ForEach(SkillInstallMode.allCases, id: \.self) { mode in Text(mode.title).tag(mode) } } .labelsHidden() .pickerStyle(.segmented) .frame(width: 240) Spacer(minLength: 0) } Group { switch vm.installMode { case .folder: sourceRow(value: vm.pendingInstallURL?.path ?? "Choose a folder…") { importerPresented = true } case .zip: sourceRow(value: vm.pendingInstallURL?.path ?? "Choose a zip file…") { importerPresented = true } case .url: HStack { Spacer(minLength: 0) TextField("https://example.com/skill.zip", text: $vm.pendingInstallText) .focused($urlFieldFocused) .textFieldStyle(.roundedBorder) .frame(width: rowWidth) Spacer(minLength: 0) } } } .frame(maxWidth: .infinity, alignment: .leading) Spacer(minLength: 0) VStack(alignment: .leading, spacing: 6) { if let status = vm.installStatusMessage, !status.isEmpty { Text(status) .font(.caption) .foregroundStyle(.secondary) } else { Text(" ") .font(.caption) .foregroundStyle(.secondary) } } .frame(height: 64) HStack { Spacer() Button("Cancel") { vm.cancelInstall() } Button("Install") { vm.finishInstall() } .buttonStyle(.borderedProminent) .disabled(!canInstall) } } .padding(16) .onAppear { urlFieldFocused = false } .onChange(of: vm.installMode) { _ in urlFieldFocused = false } .onDrop(of: [UTType.fileURL, UTType.url, UTType.plainText], isTargeted: $isDropTargeted) { providers in handleDrop(providers) } .fileImporter( isPresented: $importerPresented, allowedContentTypes: allowedTypes, allowsMultipleSelection: false ) { result in if case .success(let urls) = result { vm.pendingInstallURL = urls.first } } } private var dropArea: some View { ZStack { RoundedRectangle(cornerRadius: 10, style: .continuous) .strokeBorder( isDropTargeted ? Color.accentColor : Color.secondary.opacity(0.3), style: StrokeStyle(lineWidth: 1, dash: [6, 4]) ) .frame(height: 120) .background( RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(isDropTargeted ? Color.accentColor.opacity(0.08) : Color.clear) ) VStack(spacing: 6) { Image(systemName: "tray.and.arrow.down") .font(.system(size: 28)) .foregroundStyle(isDropTargeted ? Color.accentColor : Color.secondary) Text("Drop a skill folder, zip file, or URL") .font(.subheadline) .foregroundStyle(.secondary) if let url = vm.pendingInstallURL { Text(url.path) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) } else if !vm.pendingInstallText.isEmpty { Text(vm.pendingInstallText) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) } } .padding(.horizontal, 16) } } private func handleDrop(_ providers: [NSItemProvider]) -> Bool { for provider in providers { if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in guard let data = item as? Data, let url = URL(dataRepresentation: data, relativeTo: nil) else { return } Task { @MainActor in applyFileURL(url) } } return true } if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) { provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, _ in if let url = item as? URL { Task { @MainActor in vm.installMode = .url vm.pendingInstallText = url.absoluteString } } } return true } if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in let text: String? if let data = item as? Data { text = String(data: data, encoding: .utf8) } else { text = item as? String } guard let text = text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return } Task { @MainActor in vm.installMode = .url vm.pendingInstallText = text } } return true } } return false } private func applyFileURL(_ url: URL) { let isZip = url.pathExtension.lowercased() == "zip" let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false if isDirectory { vm.installMode = .folder vm.pendingInstallURL = url } else if isZip { vm.installMode = .zip vm.pendingInstallURL = url } else { vm.installMode = .zip vm.pendingInstallURL = url } } private var canInstall: Bool { switch vm.installMode { case .folder, .zip: return vm.pendingInstallURL != nil case .url: return !vm.pendingInstallText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } private var allowedTypes: [UTType] { switch vm.installMode { case .folder: return [.folder] case .zip: return [.zip] case .url: return [.data] } } private func sourceRow(value: String, action: @escaping () -> Void) -> some View { HStack(spacing: 8) { Spacer(minLength: 0) HStack(spacing: 8) { Text(value) .font(.body) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) .frame(width: fieldWidth, alignment: .leading) .padding(.horizontal, 8) .padding(.vertical, 6) .background( RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(Color(nsColor: .textBackgroundColor)) .overlay( RoundedRectangle(cornerRadius: 6, style: .continuous) .stroke(Color.secondary.opacity(0.2)) ) ) Button("Choose…") { action() } } .frame(width: rowWidth, alignment: .center) Spacer(minLength: 0) } } } private struct SkillConflictResolutionSheet: View { let conflict: SkillInstallConflict var onResolve: (SkillConflictResolution) -> Void var onCancel: () -> Void @State private var selection: Int = 0 @State private var renameText: String init(conflict: SkillInstallConflict, onResolve: @escaping (SkillConflictResolution) -> Void, onCancel: @escaping () -> Void) { self.conflict = conflict self.onResolve = onResolve self.onCancel = onCancel _renameText = State(initialValue: conflict.suggestedId) } var body: some View { VStack(alignment: .leading, spacing: 16) { Text("Skill Already Exists") .font(.title3) .fontWeight(.semibold) Text("A skill named \"\(conflict.proposedId)\" already exists at this location.") .font(.subheadline) .foregroundStyle(.secondary) Picker("", selection: $selection) { Text("Overwrite").tag(0) Text("Skip").tag(1) Text("Rename").tag(2) } .labelsHidden() .pickerStyle(.segmented) if selection == 2 { TextField("New name", text: $renameText) .textFieldStyle(.roundedBorder) } Spacer() HStack { Button("Cancel") { onCancel() } Spacer() Button("Continue") { switch selection { case 0: onResolve(.overwrite) case 1: onResolve(.skip) default: let trimmed = renameText.trimmingCharacters(in: .whitespacesAndNewlines) let finalName = trimmed.isEmpty ? conflict.suggestedId : trimmed onResolve(.rename(finalName)) } } .buttonStyle(.borderedProminent) } } .padding(16) } } private struct SkillCreateSheet: View { @ObservedObject var preferences: SessionPreferencesStore @ObservedObject var vm: SkillsLibraryViewModel private let startInWizard: Bool @State private var wizardActive: Bool init( preferences: SessionPreferencesStore, vm: SkillsLibraryViewModel, startInWizard: Bool = false ) { self.preferences = preferences self.vm = vm self.startInWizard = startInWizard _wizardActive = State(initialValue: startInWizard) } var body: some View { if wizardActive { SkillWizardSheet(preferences: preferences, onApply: { draft in applyDraft(draft) wizardActive = false }, onCancel: { wizardActive = false }) } else { formBody } } private var formBody: some View { VStack(alignment: .leading, spacing: 16) { HStack(alignment: .firstTextBaseline) { Text("Create Skill") .font(.title3) .fontWeight(.semibold) Spacer() Button { wizardActive = true } label: { Image(systemName: "sparkles") } .buttonStyle(.borderless) .help("AI Wizard") } if vm.pendingWizardDraft == nil { Text("Run the AI wizard to generate a draft, then review before creating.") .font(.caption) .foregroundStyle(.secondary) } VStack(alignment: .leading, spacing: 8) { Text("Skill Name") .font(.subheadline) .fontWeight(.medium) TextField("e.g., data-analysis or custom-formatter", text: $vm.newSkillName) .textFieldStyle(.roundedBorder) Text("Name will be converted to lowercase with hyphens (kebab-case)") .font(.caption) .foregroundStyle(.secondary) } VStack(alignment: .leading, spacing: 8) { Text("Description") .font(.subheadline) .fontWeight(.medium) TextField("Describe what this skill does and when to use it", text: $vm.newSkillDescription) .textFieldStyle(.roundedBorder) } if let preview = vm.wizardPreviewSkill { SkillPackageExplorerView( skill: preview, onReveal: {}, onUninstall: {}, showsHeader: false, showsActions: false ) .id(preview.id) .frame(minHeight: 220, maxHeight: 320) } if let error = vm.createErrorMessage { Text(error) .font(.caption) .foregroundStyle(.red) } Spacer(minLength: 0) HStack { Button("Cancel") { vm.cancelCreateSkill() } Spacer() Button("Create") { vm.createSkill() } .buttonStyle(.borderedProminent) .disabled( vm.pendingWizardDraft == nil || vm.newSkillName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ) } } .padding(16) .frame(minWidth: 480, minHeight: 280) .onChange(of: vm.newSkillName) { _ in if vm.pendingWizardDraft != nil { vm.refreshWizardPreview() } } .onChange(of: vm.newSkillDescription) { _ in if vm.pendingWizardDraft != nil { vm.refreshWizardPreview() } } } private func applyDraft(_ draft: SkillWizardDraft) { vm.applyWizardDraft(draft) } } ================================================ FILE: views/SplitControls.swift ================================================ import AppKit import SwiftUI import CoreImage private let menuIconSize = NSSize(width: 14, height: 14) func menuAssetNSImage(named name: String, invertForDarkMode: Bool = false) -> NSImage? { guard let image = NSImage(named: name) else { return nil } let resized = resizedMenuImage(image) if invertForDarkMode { return invertedMenuImage(resized) ?? resized } return resized } func menuSystemNSImage(named name: String) -> NSImage? { guard let image = NSImage(systemSymbolName: name, accessibilityDescription: nil) else { return nil } let resized = resizedMenuImage(image) resized.isTemplate = true return resized } private func resizedMenuImage(_ image: NSImage) -> NSImage { let newImage = NSImage(size: menuIconSize) newImage.lockFocus() image.draw( in: NSRect(origin: .zero, size: menuIconSize), from: NSRect(origin: .zero, size: image.size), operation: .copy, fraction: 1.0 ) newImage.unlockFocus() return newImage } func invertedMenuImage(_ image: NSImage) -> NSImage? { guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil } let ciImage = CIImage(cgImage: cgImage) guard let filter = CIFilter(name: "CIColorInvert") else { return nil } filter.setValue(ciImage, forKey: kCIInputImageKey) guard let outputImage = filter.outputImage else { return nil } let rep = NSCIImageRep(ciImage: outputImage) let newImage = NSImage(size: image.size) newImage.addRepresentation(rep) return newImage } // Shared split primary button used across detail toolbar and list empty state struct SplitPrimaryMenuButton: View { let title: String let systemImage: String let primary: () -> Void let items: [SplitMenuItem] var body: some View { let h: CGFloat = 24 HStack(spacing: 0) { Button(action: primary) { Label(title, systemImage: systemImage) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.primary) .padding(.horizontal, 12) .frame(height: h) .contentShape(Rectangle()) } .buttonStyle(.plain) Rectangle() .fill(Color.secondary.opacity(0.25)) .frame(width: 1, height: h - 8) .padding(.vertical, 4) ChevronMenuButton(items: items) .frame(width: h, height: h) } .background(Color(nsColor: .controlBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 6, style: .continuous) .stroke(Color.secondary.opacity(0.25), lineWidth: 1) ) } } struct SplitMenuItem: Identifiable { enum Kind { case action(title: String, systemImage: String? = nil, assetImage: String? = nil, disabled: Bool = false, run: () -> Void) case separator case submenu(title: String, systemImage: String? = nil, assetImage: String? = nil, items: [SplitMenuItem]) } let id: String let kind: Kind init(id: String = UUID().uuidString, kind: Kind) { self.id = id self.kind = kind } } struct SplitMenuItemsView: View { let items: [SplitMenuItem] @Environment(\.colorScheme) private var colorScheme var body: some View { ForEach(items) { item in switch item.kind { case .separator: Divider() case .action(let title, let systemImage, let assetImage, let disabled, let run): Button(action: run) { if let asset = assetImage, let icon = menuAssetNSImage( named: asset, invertForDarkMode: asset == "ChatGPTIcon" && colorScheme == .dark ) { Label { Text(title) } icon: { Image(nsImage: icon) .frame(width: 14, height: 14) } } else if let systemImage { Label(title, systemImage: systemImage) } else { Text(title) } } .disabled(disabled) case .submenu(let title, let systemImage, let assetImage, let children): Menu { SplitMenuItemsView(items: children) } label: { if let asset = assetImage, let icon = menuAssetNSImage( named: asset, invertForDarkMode: asset == "ChatGPTIcon" && colorScheme == .dark ) { Label { Text(title) } icon: { Image(nsImage: icon) .frame(width: 14, height: 14) } } else if let systemImage { Label(title, systemImage: systemImage) } else { Text(title) } } } } } } struct ChevronMenuButton: NSViewRepresentable { let items: [SplitMenuItem] func makeCoordinator() -> Coordinator { Coordinator(items: items) } func makeNSView(context: Context) -> NSButton { let btn = NSButton( title: "", target: context.coordinator, action: #selector(Coordinator.openMenu(_:))) btn.isBordered = false btn.bezelStyle = .regularSquare if let img = NSImage(systemSymbolName: "chevron.down", accessibilityDescription: nil) { btn.image = img } btn.translatesAutoresizingMaskIntoConstraints = false return btn } func updateNSView(_ nsView: NSButton, context: Context) { context.coordinator.items = items } final class Coordinator: NSObject { var items: [SplitMenuItem] private var runs: [() -> Void] = [] init(items: [SplitMenuItem]) { self.items = items } @objc func openMenu(_ sender: NSButton) { let menu = NSMenu() runs.removeAll(keepingCapacity: true) func build(_ items: [SplitMenuItem], into menu: NSMenu) { for item in items { switch item.kind { case .separator: menu.addItem(.separator()) case .action(let title, let systemImage, let assetImage, let disabled, let run): let mi = NSMenuItem( title: title, action: #selector(Coordinator.fire(_:)), keyEquivalent: "") if let asset = assetImage, let img = menuAssetNSImage( named: asset, invertForDarkMode: asset == "ChatGPTIcon" && isDarkMode() ) { mi.image = img } else if let systemImage, let img = menuSystemNSImage(named: systemImage) { mi.image = img } mi.tag = runs.count mi.target = self mi.isEnabled = !disabled menu.addItem(mi) runs.append(run) case .submenu(let title, let systemImage, let assetImage, let children): let mi = NSMenuItem(title: title, action: nil, keyEquivalent: "") if let asset = assetImage, let img = menuAssetNSImage( named: asset, invertForDarkMode: asset == "ChatGPTIcon" && isDarkMode() ) { mi.image = img } else if let systemImage, let img = menuSystemNSImage(named: systemImage) { mi.image = img } let sub = NSMenu(title: title) build(children, into: sub) mi.submenu = sub menu.addItem(mi) } } } build(items, into: menu) let location = NSPoint(x: sender.bounds.midX, y: sender.bounds.maxY - 3) menu.popUp(positioning: nil, at: location, in: sender) } @objc func fire(_ sender: NSMenuItem) { let idx = sender.tag guard idx >= 0 && idx < runs.count else { return } runs[idx]() } private func isDarkMode() -> Bool { if let appearance = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) { return appearance == .darkAqua } return false } } } ================================================ FILE: views/TaskListView.swift ================================================ import SwiftUI #if os(macOS) import AppKit #endif /// TaskListView: Displays tasks and sessions in Tasks mode, maintaining the original session list appearance struct TaskListView: View { @EnvironmentObject private var viewModel: SessionListViewModel @ObservedObject var workspaceVM: ProjectWorkspaceViewModel @Binding var selection: Set let onResume: (SessionSummary) -> Void let onReveal: (SessionSummary) -> Void let onDeleteRequest: (SessionSummary) -> Void let onExportMarkdown: (SessionSummary) -> Void var isRunning: ((SessionSummary) -> Bool)? = nil var isUpdating: ((SessionSummary) -> Bool)? = nil var isAwaitingFollowup: ((SessionSummary) -> Bool)? = nil var onPrimarySelect: ((SessionSummary) -> Void)? = nil var onNewSessionWithTaskContext: ((CodMateTask, SessionSummary?, SessionSource, ExternalTerminalProfile) -> Void)? = nil @State private var editingTask: CodMateTask? = nil @Environment(\.colorScheme) private var colorScheme @State private var draggedSession: SessionSummary? = nil @State private var taskToDelete: CodMateTask? = nil @State private var showDeleteConfirmation = false @State private var lastClickedID: SessionSummary.ID? = nil @State private var pendingMove: PendingSessionMove? = nil @State private var editingMode: EditTaskSheet.Mode = .edit @State private var collapsedTaskIDs: Set = [] @State private var sessionAssigningTask: SessionSummary? = nil private var currentProjectId: String? { viewModel.selectedProjectIDs.first } private struct PendingSessionMove: Identifiable { let id = UUID() let session: SessionSummary let fromTask: CodMateTask let toTask: CodMateTask } var body: some View { VStack(spacing: 0) { let enrichedTasks = workspaceVM.enrichTasksWithSessions() let assignedSessionIds = Set(enrichedTasks.flatMap { $0.task.sessionIds }) // Check if sections are empty and show placeholder if viewModel.sections.isEmpty { if viewModel.isLoading { VStack { Spacer() ProgressView("Scanning…") Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { emptyStateView } } else { // Use the same sections from viewModel, but render tasks inline List(selection: $selection) { ForEach(viewModel.sections) { section in Section { ForEach( enrichedSessionsForSection( section, enrichedTasks: enrichedTasks, assignedSessionIds: assignedSessionIds), id: \.id ) { item in switch item { case .taskHeader(let taskWithSessions): taskRow(taskWithSessions) case .taskSession(let taskWithSessions, let session): sessionRow(session, parentTask: taskWithSessions.task) case .session(let session): sessionRow(session) } } } header: { sectionHeader(for: section) } } } .padding(.horizontal, -2) .listStyle(.inset) .contextMenu { taskListBackgroundContextMenu() } } } .sheet(item: $editingTask) { task in EditTaskSheet( task: task, mode: editingMode, workspaceVM: workspaceVM, onSave: { updatedTask in Task { await workspaceVM.updateTask(updatedTask) editingTask = nil } }, onCancel: { editingTask = nil } ) } .sheet(item: $sessionAssigningTask) { session in if let projectId = currentProjectId { TaskSelectionSheet( tasks: workspaceVM.tasks.filter { $0.projectId == projectId }, onSelect: { task in Task { var updatedTask = task if !updatedTask.sessionIds.contains(session.id) { updatedTask.sessionIds.append(session.id) await workspaceVM.updateTask(updatedTask) } sessionAssigningTask = nil } }, onCreate: { title in let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } Task { await workspaceVM.createTask( title: trimmed, description: nil, projectId: projectId ) if let newTask = workspaceVM.tasks.first( where: { $0.projectId == projectId && $0.effectiveTitle.localizedCaseInsensitiveCompare(trimmed) == .orderedSame } ) { var updatedTask = newTask if !updatedTask.sessionIds.contains(session.id) { updatedTask.sessionIds.append(session.id) await workspaceVM.updateTask(updatedTask) } } sessionAssigningTask = nil } }, onCancel: { sessionAssigningTask = nil } ) } } .task(id: currentProjectId) { if let projectId = currentProjectId { await workspaceVM.loadTasks(for: projectId) } } .onReceive(NotificationCenter.default.publisher(for: .codMateCollapseAllTasks)) { note in guard shouldHandleTaskNotification(note) else { return } collapsedTaskIDs = taskIDsForCurrentProject() } .onReceive(NotificationCenter.default.publisher(for: .codMateExpandAllTasks)) { note in guard shouldHandleTaskNotification(note) else { return } collapsedTaskIDs.removeAll() } .confirmationDialog( "Delete Task", isPresented: $showDeleteConfirmation, presenting: taskToDelete ) { task in Button("Delete", role: .destructive) { Task { if let projectId = currentProjectId { await workspaceVM.deleteTask(task.id, projectId: projectId) } taskToDelete = nil } } Button("Cancel", role: .cancel) { taskToDelete = nil } } message: { task in Text( "Delete \"\(task.effectiveTitle)\"? This will not delete the associated sessions, only remove the task container." ) } .confirmationDialog( "Move Session to Another Task?", isPresented: Binding( get: { pendingMove != nil }, set: { if !$0 { pendingMove = nil } } ), presenting: pendingMove ) { move in Button("Move") { guard let projectId = currentProjectId else { pendingMove = nil return } Task { // Move session by updating target task; ProjectWorkspaceViewModel // will enforce 0/1 membership across tasks. var updatedTarget = move.toTask if !updatedTarget.sessionIds.contains(move.session.id) { updatedTarget.sessionIds.append(move.session.id) await workspaceVM.updateTask(updatedTarget) } pendingMove = nil // Reload tasks for current project to reflect latest state await workspaceVM.loadTasks(for: projectId) } } Button("Cancel", role: .cancel) { pendingMove = nil } } message: { move in Text( "Move \"\(move.session.effectiveTitle)\" from \"\(move.fromTask.effectiveTitle)\" to \"\(move.toTask.effectiveTitle)\"?" ) } } // MARK: - Data Enrichment enum SessionOrTask: Identifiable { case taskHeader(TaskWithSessions) case taskSession(TaskWithSessions, SessionSummary) case session(SessionSummary) var id: String { switch self { case .taskHeader(let t): return "task-header-\(t.id.uuidString)" case .taskSession(let t, let s): return "task-session-\(t.id.uuidString)-\(s.id)" case .session(let s): return "session-\(s.id)" } } } private func enrichedSessionsForSection( _ section: SessionDaySection, enrichedTasks: [TaskWithSessions], assignedSessionIds: Set ) -> [SessionOrTask] { guard !enrichedTasks.isEmpty else { return section.sessions.map { .session($0) } } let sectionSessionIDs = Set(section.sessions.map(\.id)) let calendar = Calendar.current // Build per-task sessions limited to this section, and also include // tasks that currently have no sessions but were updated on this day. var taskSectionSessions: [UUID: [SessionSummary]] = [:] var tasksInSection: [TaskWithSessions] = [] for task in enrichedTasks { let inSection = task.sessions.filter { sectionSessionIDs.contains($0.id) } if !inSection.isEmpty { tasksInSection.append(task) taskSectionSessions[task.task.id] = inSection } else if task.sessions.isEmpty, calendar.isDate(task.task.updatedAt, inSameDayAs: section.id) { // New or empty tasks should still appear in the Tasks view // on the day they were last updated, even before any sessions // are assigned to them. tasksInSection.append(task) taskSectionSessions[task.task.id] = [] } } guard !tasksInSection.isEmpty else { // No tasks relevant for this section; fall back to standalone sessions only. return section.sessions.map { .session($0) } } // Sort tasks according to current sort order, using aggregated metrics let sortedTasks: [TaskWithSessions] = { switch viewModel.sortOrder { case .mostRecent: // Use the latest timestamp among this section's sessions, respecting date dimension let dim = viewModel.dateDimension return tasksInSection.sorted { lhs, rhs in let ls = taskSectionSessions[lhs.task.id] ?? [] let rs = taskSectionSessions[rhs.task.id] ?? [] func key(_ s: SessionSummary) -> Date { switch dim { case .created: return s.startedAt case .updated: return s.lastUpdatedAt ?? s.startedAt } } let lDate = ls.map(key).max() ?? .distantPast let rDate = rs.map(key).max() ?? .distantPast return lDate > rDate } case .longestDuration: // Aggregate total duration for this section's sessions return tasksInSection.sorted { lhs, rhs in let lDur = (taskSectionSessions[lhs.task.id] ?? []).reduce(0) { $0 + $1.duration } let rDur = (taskSectionSessions[rhs.task.id] ?? []).reduce(0) { $0 + $1.duration } if lDur != rDur { return lDur > rDur } // Tie-breaker: most recent activity let lDate = (taskSectionSessions[lhs.task.id] ?? []).map { $0.lastUpdatedAt ?? $0.startedAt }.max() ?? .distantPast let rDate = (taskSectionSessions[rhs.task.id] ?? []).map { $0.lastUpdatedAt ?? $0.startedAt }.max() ?? .distantPast return lDate > rDate } case .mostActivity: // Aggregate total visible event count let visibleKinds = viewModel.preferences.timelineVisibleKinds return tasksInSection.sorted { lhs, rhs in let lEvents = (taskSectionSessions[lhs.task.id] ?? []) .reduce(0) { $0 + $1.visibleEventCount(using: visibleKinds) } let rEvents = (taskSectionSessions[rhs.task.id] ?? []) .reduce(0) { $0 + $1.visibleEventCount(using: visibleKinds) } if lEvents != rEvents { return lEvents > rEvents } let lDate = (taskSectionSessions[lhs.task.id] ?? []).map { $0.lastUpdatedAt ?? $0.startedAt }.max() ?? .distantPast let rDate = (taskSectionSessions[rhs.task.id] ?? []).map { $0.lastUpdatedAt ?? $0.startedAt }.max() ?? .distantPast if lDate != rDate { return lDate > rDate } return lhs.task.effectiveTitle.localizedCaseInsensitiveCompare(rhs.task.effectiveTitle) == .orderedAscending } case .alphabetical: return tasksInSection.sorted { lhs, rhs in let cmp = lhs.task.effectiveTitle.localizedStandardCompare(rhs.task.effectiveTitle) if cmp == .orderedSame { let lDate = (taskSectionSessions[lhs.task.id] ?? []).map { $0.lastUpdatedAt ?? $0.startedAt } .max() ?? .distantPast let rDate = (taskSectionSessions[rhs.task.id] ?? []).map { $0.lastUpdatedAt ?? $0.startedAt } .max() ?? .distantPast if lDate != rDate { return lDate > rDate } return lhs.task.id.uuidString < rhs.task.id.uuidString } return cmp == .orderedAscending } case .largestSize: // Approximate size by total file size across this section's sessions return tasksInSection.sorted { lhs, rhs in func totalSize(for task: TaskWithSessions) -> UInt64 { (taskSectionSessions[task.task.id] ?? []).reduce(0) { acc, s in acc + (s.fileSizeBytes ?? 0) } } let lSize = totalSize(for: lhs) let rSize = totalSize(for: rhs) if lSize != rSize { return lSize > rSize } let lDate = (taskSectionSessions[lhs.task.id] ?? []).map { $0.lastUpdatedAt ?? $0.startedAt }.max() ?? .distantPast let rDate = (taskSectionSessions[rhs.task.id] ?? []).map { $0.lastUpdatedAt ?? $0.startedAt }.max() ?? .distantPast return lDate > rDate } } }() var result: [SessionOrTask] = [] // For each task (in sorted order), add a header row and then its sessions that belong to this section for task in sortedTasks { result.append(.taskHeader(task)) if !collapsedTaskIDs.contains(task.task.id), let sectionSessions = taskSectionSessions[task.task.id] { for session in sectionSessions { result.append(.taskSession(task, session)) } } } // Add standalone sessions (not assigned to any task), preserving existing section order for session in section.sessions where !assignedSessionIds.contains(session.id) { result.append(.session(session)) } return result } // MARK: - Section Header @ViewBuilder private func sectionHeader(for section: SessionDaySection) -> some View { HStack { Text(section.title) Spacer() Label(readableFormattedDuration(section.totalDuration), systemImage: "clock") Label("\(section.totalEvents)", systemImage: "chart.bar") } .font(.subheadline) .foregroundStyle(.secondary) } // MARK: - Task Row @ViewBuilder private func taskRow(_ taskWithSessions: TaskWithSessions) -> some View { VStack(alignment: .leading, spacing: 0) { // Task header - using same visual style as SessionListRowView HStack(alignment: .top, spacing: 12) { // Left icon — purely visual let container = RoundedRectangle(cornerRadius: 9, style: .continuous) ZStack { container .fill(Color.white) .shadow(color: Color.black.opacity(0.08), radius: 1.5, x: 0, y: 1) container .stroke(Color.black.opacity(0.06), lineWidth: 1) Image(systemName: "checklist") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Color.accentColor) } .frame(width: 32, height: 32) .help("Task") // Content area VStack(alignment: .leading, spacing: 4) { // Title only - status and collapse indicator removed Text(taskWithSessions.task.effectiveTitle) .font(.headline) .lineLimit(1) .truncationMode(.tail) // Metadata row HStack(spacing: 8) { Text(taskWithSessions.task.updatedAt.formatted(date: .numeric, time: .shortened)) .layoutPriority(1) Text(formatDuration(taskWithSessions.totalDuration)) .layoutPriority(1) } .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) // Description if available if let description = taskWithSessions.task.effectiveDescription { Text(description) .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) } // Metrics HStack(spacing: 8) { metric(icon: "doc.text", value: taskWithSessions.sessions.count) metric(icon: "clock", value: Int(taskWithSessions.totalDuration / 60)) if taskWithSessions.totalTokens > 0 { metric(icon: "circle.grid.cross", value: taskWithSessions.totalTokens) } } .font(.caption2.monospacedDigit()) .foregroundStyle(.secondary) } .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) .padding(.trailing, 32) Spacer(minLength: 0) } .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .padding(.vertical, 8) .overlay(alignment: .topTrailing) { // Top-right collapse/expand button Button { if collapsedTaskIDs.contains(taskWithSessions.task.id) { collapsedTaskIDs.remove(taskWithSessions.task.id) } else { collapsedTaskIDs.insert(taskWithSessions.task.id) } } label: { Image(systemName: collapsedTaskIDs.contains(taskWithSessions.task.id) ? "chevron.down" : "chevron.up") .foregroundStyle(Color.secondary) .font(.system(size: 14, weight: .semibold)) } .buttonStyle(.borderless) .padding(.leading, 8) .padding(.trailing, 8) .padding(.top, 8) } .onDrop( of: [.text], delegate: TaskDropDelegate( task: taskWithSessions.task, draggedSession: $draggedSession, workspaceVM: workspaceVM, onRequestMove: handleMoveRequest ) ) .contentShape(Rectangle()) .onTapGesture(count: 2) { editingMode = .edit editingTask = taskWithSessions.task } .contextMenu { if let project = projectForTask(taskWithSessions.task) { let anchor = latestLocalSession(for: taskWithSessions) let items = buildNewMenuItems(anchor: anchor, project: project) { anchor, source, profile in if let handler = onNewSessionWithTaskContext { handler(taskWithSessions.task, anchor, source, profile) } } if items.isEmpty { Button { if let handler = onNewSessionWithTaskContext, let anchor { // Fallback to default behavior if menu generation failed but anchor exists // We'll use the anchor's source and default profile let defaultProfile = ExternalTerminalProfileStore.shared.resolvePreferredProfile( id: viewModel.preferences.defaultResumeExternalAppId ) ?? ExternalTerminalProfileStore.shared.availableProfiles().first! handler(taskWithSessions.task, anchor, anchor.source, defaultProfile) } else { viewModel.newSession(project: project) } } label: { Label("Collaborate with", systemImage: "person.2") } } else { Menu { SplitMenuItemsView(items: items) } label: { Label("Collaborate with…", systemImage: "person.2") } } Button { let draft = CodMateTask( title: "", description: nil, projectId: project.id ) editingMode = .new editingTask = draft } label: { Label("New Task…", systemImage: "checklist") } } Button { editingMode = .edit editingTask = taskWithSessions.task } label: { Label("Edit Task", systemImage: "pencil") } Button(role: .destructive) { taskToDelete = taskWithSessions.task showDeleteConfirmation = true } label: { Label("Delete Task", systemImage: "trash") } taskCollapseContextMenuItems() } } } // MARK: - Session Row @ViewBuilder private func sessionRow(_ session: SessionSummary, parentTask: CodMateTask? = nil) -> some View { EquatableSessionListRow( summary: session, isRunning: isRunning?(session) ?? false, isSelected: selection.contains(session.id), isUpdating: isUpdating?(session) ?? false, awaitingFollowup: isAwaitingFollowup?(session) ?? false, inProject: viewModel.projectIdForSession(session.id) != nil, projectTip: projectTip(for: session), inTaskContainer: parentTask != nil ) .tag(session.id) .contentShape(Rectangle()) .padding(.leading, parentTask != nil ? 44 : 0) .onTapGesture(count: 2) { selection = [session.id] onPrimarySelect?(session) Task { await viewModel.beginEditing(session: session) } } .onTapGesture { handleClick(on: session) } .contextMenu { let resumeItems = buildResumeMenuItems(for: session) if !resumeItems.isEmpty { Menu { SplitMenuItemsView(items: resumeItems) } label: { let icon = assetIconForSessionSource(session.source) Label { Text("Resume session") } icon: { if let menuIcon = menuAssetNSImage( named: icon, invertForDarkMode: icon == "ChatGPTIcon" && colorScheme == .dark ) { Image(nsImage: menuIcon) .frame(width: 14, height: 14) } else { Image(icon) .resizable() .scaledToFit() .frame(width: 14, height: 14) .clipped() .modifier(DarkModeInvertModifier(active: icon == "ChatGPTIcon" && colorScheme == .dark)) } } } } if let project = projectForSession(session, parentTask: parentTask) { let items = buildNewMenuItems(anchor: session) if items.isEmpty { Button { viewModel.newSession(project: project) } label: { Label("New Session", systemImage: "plus") } } else { Menu { SplitMenuItemsView(items: items) } label: { Label("New Session…", systemImage: "plus") } } Divider() Button { let draft = CodMateTask( title: "", description: nil, projectId: project.id ) editingMode = .new editingTask = draft } label: { Label("New Task…", systemImage: "checklist") } if parentTask == nil { Button { sessionAssigningTask = session } label: { Label("Add to Task…", systemImage: "plus.circle") } } } if parentTask != nil { Button { Task { guard let task = parentTask else { return } var updatedTask = task updatedTask.sessionIds.removeAll { $0 == session.id } await viewModel.workspaceVM?.updateTask(updatedTask) } } label: { Label("Remove from Task", systemImage: "minus.circle") } } Divider() Button { Task { await viewModel.beginEditing(session: session) } } label: { Label("Edit Title & Comment", systemImage: "pencil") } Button { Task { @MainActor in await viewModel.generateTitleAndComment(for: session, force: false) } } label: { Label("Generate Title & Comment", systemImage: "sparkles") } Divider() Button { copyAbsolutePath(session) } label: { Label("Copy Absolute Path", systemImage: "doc.on.doc") } Button { onExportMarkdown(session) } label: { Label("Export as Markdown", systemImage: "square.and.arrow.up") } Divider() Button { onReveal(session) } label: { Label("Reveal in Finder", systemImage: "finder") } Button(role: .destructive) { onDeleteRequest(session) } label: { Label("Move to Trash", systemImage: "trash") } taskCollapseContextMenuItems() } .onDrag { self.draggedSession = session return NSItemProvider(object: session.id as NSString) } .onDrop( of: [.text], delegate: SessionDropDelegate( session: session, draggedSession: $draggedSession, workspaceVM: workspaceVM, currentProjectId: currentProjectId ) ) .listRowInsets(EdgeInsets()) } // MARK: - Helpers @ViewBuilder private func metric(icon: String, value: Int) -> some View { HStack(spacing: 2) { Image(systemName: icon) Text("\(value)") } } private func statusColor(_ status: TaskStatus) -> Color { switch status { case .pending: return .gray case .inProgress: return .blue case .completed: return .green case .canceled: return .red case .archived: return .orange } } private func formatDuration(_ duration: TimeInterval) -> String { let formatter = DateComponentsFormatter() formatter.allowedUnits = [.hour, .minute] formatter.unitsStyle = .abbreviated return formatter.string(from: duration) ?? "—" } private func readableFormattedDuration(_ interval: TimeInterval) -> String { let formatter = DateComponentsFormatter() formatter.unitsStyle = .abbreviated if interval >= 3600 { formatter.allowedUnits = [.hour, .minute] } else if interval >= 60 { formatter.allowedUnits = [.minute, .second] } else { formatter.allowedUnits = [.second] } return formatter.string(from: interval) ?? "—" } private func projectTip(for session: SessionSummary) -> String? { guard let pid = viewModel.projectIdForSession(session.id), let p = viewModel.projects.first(where: { $0.id == pid }) else { return nil } let name = p.name.trimmingCharacters(in: .whitespacesAndNewlines) let display = name.isEmpty ? p.id : name let raw = (p.overview ?? "").trimmingCharacters(in: .whitespacesAndNewlines) guard !raw.isEmpty else { return display } let snippet = raw.count > 20 ? String(raw.prefix(20)) + "…" : raw return display + "\n" + snippet } private func handleClick(on session: SessionSummary) { #if os(macOS) let mods = NSApp.currentEvent?.modifierFlags ?? [] let isToggle = mods.contains(.command) || mods.contains(.control) let isRange = mods.contains(.shift) #else let isToggle = false let isRange = false #endif let id = session.id if isRange, let anchor = lastClickedID { let flat = viewModel.sections.flatMap { $0.sessions.map(\.id) } if let a = flat.firstIndex(of: anchor), let b = flat.firstIndex(of: id) { let lo = min(a, b) let hi = max(a, b) let rangeIDs = Set(flat[lo...hi]) selection = rangeIDs } else { selection = [id] } onPrimarySelect?(session) } else if isToggle { if selection.contains(id) { selection.remove(id) } else { selection.insert(id) } lastClickedID = id onPrimarySelect?(session) } else { selection = [id] lastClickedID = id onPrimarySelect?(session) } } private func handleMoveRequest( session: SessionSummary, fromTask: CodMateTask, toTask: CodMateTask ) { pendingMove = PendingSessionMove(session: session, fromTask: fromTask, toTask: toTask) } private func projectForSession(_ session: SessionSummary, parentTask: CodMateTask?) -> Project? { if let parentTask { return projectForTask(parentTask) } guard let pid = viewModel.projectIdForSession(session.id) else { return nil } if pid == SessionListViewModel.otherProjectId { return nil } return viewModel.projects.first(where: { $0.id == pid }) } private func projectForTask(_ task: CodMateTask) -> Project? { let pid = task.projectId if pid == SessionListViewModel.otherProjectId { return nil } return viewModel.projects.first(where: { $0.id == pid }) } // MARK: - Task Context Helpers /// Returns the most recent local (non-remote) session for a given task, if any. private func latestLocalSession(for taskWithSessions: TaskWithSessions) -> SessionSummary? { let candidates = taskWithSessions.sessions.filter { !$0.isRemote } return candidates.max(by: { (lhs, rhs) in let lDate = lhs.lastUpdatedAt ?? lhs.startedAt let rDate = rhs.lastUpdatedAt ?? rhs.startedAt return lDate < rDate }) } @ViewBuilder private func projectContextMenu(for project: Project) -> some View { let anchor = latestAnchor(for: project) let items = buildNewMenuItems( anchor: anchor, project: project, customAction: anchor == nil ? { _, source, profile in viewModel.launchNewSessionFromProject(project: project, using: source, profile: profile) } : nil ) Menu("New Session…") { SplitMenuItemsView(items: items) } } @ViewBuilder private func taskListBackgroundContextMenu() -> some View { if let projectId = currentProjectId, let project = viewModel.projects.first(where: { $0.id == projectId }) { let anchor = latestAnchor(for: project) let items = buildNewMenuItems( anchor: anchor, project: project, customAction: anchor == nil ? { _, source, profile in viewModel.launchNewSessionFromProject(project: project, using: source, profile: profile) } : nil ) Menu { SplitMenuItemsView(items: items) } label: { Label("New Session…", systemImage: "plus") } Button { editingMode = .new editingTask = CodMateTask(title: "", description: nil, projectId: currentProjectId ?? "") } label: { Label("New Task…", systemImage: "checklist") } } Divider() Button { NotificationCenter.default.post( name: .codMateCollapseAllTasks, object: nil, userInfo: ["projectId": currentProjectId as Any]) } label: { Label("Collapse all Tasks", systemImage: "arrow.down.right.and.arrow.up.left") } Button { NotificationCenter.default.post( name: .codMateExpandAllTasks, object: nil, userInfo: ["projectId": currentProjectId as Any]) } label: { Label("Expand all Tasks", systemImage: "arrow.up.left.and.arrow.down.right") } } private func copyAbsolutePath(_ session: SessionSummary) { let pb = NSPasteboard.general pb.clearContents() pb.setString(session.fileURL.path, forType: .string) } @ViewBuilder private func taskCollapseContextMenuItems() -> some View { Divider() Button { NotificationCenter.default.post( name: .codMateCollapseAllTasks, object: nil, userInfo: ["projectId": currentProjectId as Any]) } label: { Label("Collapse all Tasks", systemImage: "arrow.down.right.and.arrow.up.left") } Button { NotificationCenter.default.post( name: .codMateExpandAllTasks, object: nil, userInfo: ["projectId": currentProjectId as Any]) } label: { Label("Expand all Tasks", systemImage: "arrow.up.left.and.arrow.down.right") } } private func buildResumeMenuItems(for session: SessionSummary) -> [SplitMenuItem] { var items: [SplitMenuItem] = [] if viewModel.preferences.isEmbeddedTerminalEnabled { items.append( SplitMenuItem( id: "resume-embedded-\(session.id)", kind: .action( title: "CodMate", systemImage: "macwindow", run: { NotificationCenter.default.post( name: .codMateResumeSession, object: nil, userInfo: ["sessionId": session.id, "forceEmbedded": true] ) } ) ) ) } for profile in externalTerminalOrderedProfiles(includeNone: false) { items.append( SplitMenuItem( id: "resume-\(profile.id)-\(session.id)", kind: .action( title: profile.displayTitle, systemImage: "terminal", run: { NotificationCenter.default.post( name: .codMateResumeSession, object: nil, userInfo: ["sessionId": session.id, "profileId": profile.id] ) } ) ) ) } return items } private func assetIconForSessionSource(_ source: SessionSource) -> String { switch source.baseKind { case .codex: return "ChatGPTIcon" case .claude: return "ClaudeIcon" case .gemini: return "GeminiIcon" } } private func buildNewMenuItems( anchor: SessionSummary?, project: Project? = nil, customAction: ((SessionSummary?, SessionSource, ExternalTerminalProfile) -> Void)? = nil ) -> [SplitMenuItem] { guard anchor != nil || project != nil else { return [] } let allowed: Set if let anchor { allowed = Set(viewModel.allowedSources(for: anchor)) } else if let project { let sources = project.sources.isEmpty ? ProjectSessionSource.allSet : project.sources allowed = Set(sources.filter { viewModel.preferences.isCLIEnabled($0.baseKind) }) } else { allowed = Set(ProjectSessionSource.allCases.filter { viewModel.preferences.isCLIEnabled($0.baseKind) }) } let requestedOrder: [ProjectSessionSource] = [.claude, .codex, .gemini] let enabledRemoteHosts = viewModel.preferences.enabledRemoteHosts.sorted() func sourceKey(_ source: SessionSource) -> String { switch source { case .codexLocal: return "codex-local" case .codexRemote(let host): return "codex-\(host)" case .claudeLocal: return "claude-local" case .claudeRemote(let host): return "claude-\(host)" case .geminiLocal: return "gemini-local" case .geminiRemote(let host): return "gemini-\(host)" } } func launchItems(for source: SessionSource) -> [SplitMenuItem] { let key = sourceKey(source) var items = externalTerminalMenuItems(idPrefix: key) { profile in if let customAction { customAction(anchor, source, profile) } else if let anchor { onNewSession(with: anchor, using: source, profile: profile) } else if let project { // No anchor but we have a project - use project-based new session viewModel.launchNewSessionFromProject(project: project, using: source, profile: profile) } } if viewModel.preferences.isEmbeddedTerminalEnabled { let embedded = embeddedTerminalProfile() items.insert( SplitMenuItem( id: "\(key)-\(embedded.id)", kind: .action( title: embedded.displayTitle, systemImage: "macwindow", run: { if let customAction { customAction(anchor, source, embedded) } else if let anchor { onNewSession(with: anchor, using: source, profile: embedded) } else if let project { // No anchor but we have a project - use project-based new session viewModel.launchNewSessionFromProject(project: project, using: source, profile: embedded) } }) ), at: 0) } return items } func remoteSource(for base: ProjectSessionSource, host: String) -> SessionSource { switch base { case .codex: return .codexRemote(host: host) case .claude: return .claudeRemote(host: host) case .gemini: return .geminiRemote(host: host) } } func providerAssetIcon(_ source: ProjectSessionSource) -> String { switch source { case .codex: return "ChatGPTIcon" case .claude: return "ClaudeIcon" case .gemini: return "GeminiIcon" } } var menuItems: [SplitMenuItem] = [] for base in requestedOrder where allowed.contains(base) { var providerItems = launchItems(for: base.sessionSource) if !enabledRemoteHosts.isEmpty { providerItems.append(.init(kind: .separator)) for host in enabledRemoteHosts { let remote = remoteSource(for: base, host: host) providerItems.append( .init( id: "remote-\(base.rawValue)-\(host)", kind: .submenu(title: host, systemImage: "network", items: launchItems(for: remote)) )) } } menuItems.append( .init( id: "provider-\(base.rawValue)", kind: .submenu(title: base.displayName, assetImage: providerAssetIcon(base), items: providerItems) )) } if menuItems.isEmpty, let anchor { let fallback = anchor.source menuItems.append( .init( id: "fallback-\(sourceKey(fallback))", kind: .submenu(title: fallback.branding.displayName, systemImage: "terminal", items: launchItems(for: fallback)) )) } return menuItems } private func onNewSession( with anchor: SessionSummary, using source: SessionSource, profile: ExternalTerminalProfile ) { viewModel.launchNewSessionWithProfile( session: anchor, using: source, profile: profile, workingDirectory: anchor.cwd ) } private func latestAnchor(for project: Project) -> SessionSummary? { if let visible = viewModel.sections.flatMap({ $0.sessions }).first( where: { viewModel.projectIdForSession($0.id) == project.id }) { return visible } return viewModel.allSessions.first { viewModel.projectIdForSession($0.id) == project.id } } private func onNewSessionFromProject( project: Project, using source: SessionSource, profile: ExternalTerminalProfile ) { viewModel.launchNewSessionFromProject(project: project, using: source, profile: profile) } // MARK: - Empty State View @ViewBuilder private var emptyStateView: some View { let project = currentProjectId.flatMap { pid in viewModel.projects.first(where: { $0.id == pid }) } let isOtherProject = project?.id == SessionListViewModel.otherProjectId ZStack { Color.clear VStack(spacing: 12) { Spacer(minLength: 12) // Different message for Other project bucket if isOtherProject { unavailableViewWrapper( title: "No Unassigned Sessions", systemImage: "tray", description: "Sessions can only be created within a project. Select a project from the sidebar to start a new session." ) } else { unavailableViewWrapper( title: "No Sessions", systemImage: "tray", description: "Right-click in this area or use the \"+ New\" button to start a new session." ) } // Primary action: New (hidden for Other project, shown for regular projects) if let project, !isOtherProject { let anchor = latestAnchor(for: project) SplitPrimaryMenuButton( title: "New", systemImage: "plus", primary: { viewModel.newSession(project: project) }, items: buildNewMenuItems(anchor: anchor, project: project) ) .help("Start a new session in \(project.name.isEmpty ? project.id : project.name)") } else if !isOtherProject { SplitPrimaryMenuButton( title: "New", systemImage: "plus", primary: {}, items: [] ) .opacity(0.6) .help("Select a project in the sidebar to start a new session") } Spacer() } } .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(Rectangle()) .contextMenu { taskListBackgroundContextMenu() } } @ViewBuilder private func unavailableViewWrapper(title: String, systemImage: String, description: String) -> some View { Group { if #available(macOS 14.0, *) { ContentUnavailableView(title, systemImage: systemImage, description: Text(description)) } else { UnavailableStateView( title, systemImage: systemImage, description: description, titleColor: .primary ) } } .frame(maxWidth: .infinity) } } extension TaskListView { fileprivate func shouldHandleTaskNotification(_ note: Notification) -> Bool { guard let target = note.userInfo?["projectId"] as? String else { return true } return target == currentProjectId } fileprivate func taskIDsForCurrentProject() -> Set { guard let projectId = currentProjectId else { return [] } let ids = workspaceVM.tasks.filter { $0.projectId == projectId }.map { $0.id } return Set(ids) } } // MARK: - New Task Sheet (Removed - now using EditTaskSheet with mode: .new) // MARK: - Edit Task Sheet struct EditTaskSheet: View { enum Mode { case new case edit } let task: CodMateTask let mode: Mode @State private var title: String @State private var description: String @State private var status: TaskStatus let onSave: (CodMateTask) -> Void let onCancel: () -> Void @FocusState private var focusedField: Field? @ObservedObject var workspaceVM: ProjectWorkspaceViewModel enum Field { case title case description } init( task: CodMateTask, mode: Mode = .edit, workspaceVM: ProjectWorkspaceViewModel, onSave: @escaping (CodMateTask) -> Void, onCancel: @escaping () -> Void ) { self.task = task self.mode = mode self.workspaceVM = workspaceVM self._title = State(initialValue: task.title) self._description = State(initialValue: task.description ?? "") self._status = State(initialValue: task.status) self.onSave = onSave self.onCancel = onCancel } var body: some View { let hasAnyContent = !workspaceVM.getSessionsForTask(task.id).isEmpty || !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty VStack(alignment: .leading, spacing: 16) { HStack { Text(mode == .new ? "New Task" : "Edit Task") .font(.title3).bold() Spacer() // Generate button (icon only, transparent background) // Show if there are sessions OR any content (title/description) if hasAnyContent { Button(action: { Task { @MainActor in await workspaceVM.generateTitleAndDescription(for: task, currentTitle: title, currentDescription: description, force: false) // After generation, update local state if let generatedTitle = workspaceVM.generatedTaskTitle { title = generatedTitle } if let generatedDescription = workspaceVM.generatedTaskDescription { description = generatedDescription } // Clear generated content workspaceVM.generatedTaskTitle = nil workspaceVM.generatedTaskDescription = nil } }) { if workspaceVM.isGeneratingTitleDescription && workspaceVM.generatingTaskId == task.id { ProgressView() .controlSize(.small) .frame(width: 16, height: 16) } else { Image(systemName: "sparkles") .font(.system(size: 16)) .foregroundStyle(.secondary) } } .buttonStyle(.plain) .help("Generate title and description using AI") .disabled(workspaceVM.isGeneratingTitleDescription && workspaceVM.generatingTaskId == task.id) } } TextField("Task Title", text: $title) .textFieldStyle(.roundedBorder) .focused($focusedField, equals: .title) VStack(alignment: .leading, spacing: 8) { Text("Description (optional)").font(.subheadline) TextEditor(text: $description) .font(.body) .codmatePlainTextEditorStyleIfAvailable() .scrollContentBackground(.hidden) .frame(minHeight: 120) .padding(8) .background( RoundedRectangle(cornerRadius: 6) .stroke(Color.gray.opacity(0.2), lineWidth: 1) ) .focused($focusedField, equals: .description) } Picker("Status", selection: $status) { ForEach(TaskStatus.allCases) { s in Text(s.displayName).tag(s) } } HStack { Button("Cancel", action: onCancel) .keyboardShortcut(.cancelAction) Spacer() Button("Save") { var updated = task updated.title = title updated.description = description.isEmpty ? nil : description updated.status = status onSave(updated) } .keyboardShortcut(.defaultAction) .disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } .padding(20) .frame(minWidth: 520) .onAppear { // Set focus to title field when view appears focusedField = .title } } } // MARK: - Drop Delegates /// Drop delegate for dropping a session onto another session (creates a new task) struct SessionDropDelegate: DropDelegate { let session: SessionSummary @Binding var draggedSession: SessionSummary? let workspaceVM: ProjectWorkspaceViewModel let currentProjectId: String? func performDrop(info: DropInfo) -> Bool { guard let draggedSession = draggedSession, draggedSession.id != session.id, let projectId = currentProjectId else { return false } // Only allow creating a new task when both sessions are currently unassigned let draggedTaskId = workspaceVM.tasks.first(where: { $0.sessionIds.contains(draggedSession.id) } )?.id let targetTaskId = workspaceVM.tasks.first(where: { $0.sessionIds.contains(session.id) })?.id guard draggedTaskId == nil, targetTaskId == nil else { self.draggedSession = nil return false } // Create a new task with both unassigned sessions Task { let taskTitle = "Task: \(draggedSession.displayName) + \(session.displayName)" await workspaceVM.createTask( title: taskTitle, description: nil, projectId: projectId ) // Find the newly created task (it will be the first one) if let newTask = workspaceVM.tasks.first { // Add both sessions to the task var updatedTask = newTask updatedTask.sessionIds = [draggedSession.id, session.id] await workspaceVM.updateTask(updatedTask) } } self.draggedSession = nil return true } func validateDrop(info: DropInfo) -> Bool { guard let dragged = draggedSession, dragged.id != session.id else { return false } let draggedTaskId = workspaceVM.tasks.first(where: { $0.sessionIds.contains(dragged.id) })?.id let targetTaskId = workspaceVM.tasks.first(where: { $0.sessionIds.contains(session.id) })?.id // Only allow drop when both sessions are not yet assigned to any task return draggedTaskId == nil && targetTaskId == nil } } /// Drop delegate for dropping a session onto a task (adds session to task) struct TaskDropDelegate: DropDelegate { let task: CodMateTask @Binding var draggedSession: SessionSummary? let workspaceVM: ProjectWorkspaceViewModel let onRequestMove: (SessionSummary, CodMateTask, CodMateTask) -> Void func performDrop(info: DropInfo) -> Bool { guard let draggedSession = draggedSession else { return false } let draggedTask = workspaceVM.tasks.first(where: { $0.sessionIds.contains(draggedSession.id) }) if let fromTask = draggedTask { // If already in this task, do nothing guard fromTask.id != task.id else { self.draggedSession = nil return false } // Request a confirmed move from fromTask → task DispatchQueue.main.async { onRequestMove(draggedSession, fromTask, task) } self.draggedSession = nil return true } else { // Add unassigned session to this task Task { var updatedTask = task if !updatedTask.sessionIds.contains(draggedSession.id) { updatedTask.sessionIds.append(draggedSession.id) await workspaceVM.updateTask(updatedTask) } } self.draggedSession = nil return true } } func validateDrop(info: DropInfo) -> Bool { guard let draggedSession = draggedSession else { return false } let draggedTask = workspaceVM.tasks.first(where: { $0.sessionIds.contains(draggedSession.id) }) // Allow drop if session is unassigned or belongs to a different task return draggedTask == nil || draggedTask?.id != task.id } } // MARK: - Task Selection Sheet struct TaskSelectionSheet: View { let tasks: [CodMateTask] let onSelect: (CodMateTask) -> Void let onCreate: (String) -> Void let onCancel: () -> Void @State private var searchText = "" var filteredTasks: [CodMateTask] { if searchText.isEmpty { return tasks } return tasks.filter { $0.effectiveTitle.localizedCaseInsensitiveContains(searchText) } } private var trimmedSearch: String { searchText.trimmingCharacters(in: .whitespacesAndNewlines) } private var canCreateNewTask: Bool { guard !trimmedSearch.isEmpty else { return false } return !tasks.contains { $0.effectiveTitle.localizedCaseInsensitiveCompare(trimmedSearch) == .orderedSame } } var body: some View { VStack(spacing: 16) { Text("Add to Task") .font(.title2) .fontWeight(.bold) TextField("Search tasks", text: $searchText) .textFieldStyle(.roundedBorder) .onSubmit { if canCreateNewTask { onCreate(trimmedSearch) } } .overlay(alignment: .trailing) { if canCreateNewTask { Button { onCreate(trimmedSearch) } label: { Image(systemName: "plus.circle.fill") .foregroundStyle(.secondary) } .buttonStyle(.plain) .padding(.trailing, 6) } } if filteredTasks.isEmpty { Text("No tasks found.") .foregroundColor(.secondary) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { List(filteredTasks) { task in HStack { Image(systemName: task.status.icon) .foregroundColor(statusColor(task.status)) Text(task.effectiveTitle) .lineLimit(1) Spacer() } .padding(.vertical, 4) .contentShape(Rectangle()) .onTapGesture { onSelect(task) } } .listStyle(.plain) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(Color.gray.opacity(0.2), lineWidth: 1) ) } HStack { Spacer() Button("Cancel", action: onCancel) .keyboardShortcut(.cancelAction) } } .padding() .frame(width: 400, height: 400) } private func statusColor(_ status: TaskStatus) -> Color { switch status { case .pending: return .gray case .inProgress: return .blue case .completed: return .green case .canceled: return .red case .archived: return .orange } } } ================================================ FILE: views/TripleUsageDonutView.swift ================================================ import SwiftUI public struct UsageRingState { public var progress: Double? public var baseColor: Color public var healthState: UsageMetricSnapshot.HealthState? public var disabled: Bool public init( progress: Double? = nil, baseColor: Color, healthState: UsageMetricSnapshot.HealthState? = nil, disabled: Bool ) { self.progress = progress self.baseColor = baseColor self.healthState = healthState self.disabled = disabled } public var effectiveColor: Color { if disabled { return Color(nsColor: .quaternaryLabelColor) } // Apply health state color if available if let state = healthState { switch state { case .healthy: return baseColor // Use provider color case .warning: return .orange // Warning color case .unknown: return baseColor // Default to provider color } } return baseColor } } public struct TripleUsageDonutView: View { public var states: [UsageRingState] public var trackColor: Color public init( states: [UsageRingState], trackColor: Color = .secondary ) { self.states = states self.trackColor = trackColor } public var body: some View { let layout = ringLayout(for: states.count) return ZStack { ForEach(Array(states.enumerated()), id: \.offset) { index, state in let size = layout.sizes[index] let lineWidth = layout.lineWidth let opacity = layout.trackOpacities[index] Circle() .stroke(trackColor.opacity(opacity), lineWidth: lineWidth) .frame(width: size, height: size) ring(for: state, lineWidth: lineWidth, size: size) } } } @ViewBuilder private func ring(for state: UsageRingState, lineWidth: CGFloat, size: CGFloat) -> some View { if state.disabled { Circle() .stroke(Color(nsColor: .quaternaryLabelColor), lineWidth: lineWidth) .frame(width: size, height: size) } else if let progress = state.progress { Circle() .trim(from: 0, to: CGFloat(max(0, min(progress, 1)))) .stroke(style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) .foregroundStyle(state.effectiveColor) .rotationEffect(.degrees(-90)) .frame(width: size, height: size) } } private func ringLayout(for count: Int) -> (sizes: [CGFloat], lineWidth: CGFloat, trackOpacities: [Double]) { let outerDiameter: CGFloat = 22 let outerRadius = outerDiameter / 2 let innerClearRadius: CGFloat = 4.25 let availableSpan = max(outerRadius - innerClearRadius, 1) let ringCount = max(count, 1) let units = CGFloat(ringCount * 2 - 1) let unit = availableSpan / units let minLineWidth: CGFloat = 1.2 let maxLineWidth: CGFloat = 2.8 let lineWidth = min(maxLineWidth, max(minLineWidth, unit)) let gap = lineWidth var sizes: [CGFloat] = [] var opacities: [Double] = [] let startRadius = outerRadius - lineWidth / 2 for index in 0.. Void)? let editModelsHelp: String? @Binding var providerId: String? @Binding var modelId: String? init( sections: [UnifiedProviderSection], models: [String], modelSectionTitle: String?, includeAuto: Bool, autoTitle: String, includeDefaultModel: Bool, defaultModelTitle: String, providerUnavailableHint: String?, disableModels: Bool, showProviderPicker: Bool = true, showModelPicker: Bool = true, simpleMode: Bool = false, autoProxyTitle: String = "Auto-Proxy (CliProxyAPI)", sanitizeModelNames: Bool = false, onEditModels: (() -> Void)? = nil, editModelsHelp: String? = nil, providerId: Binding, modelId: Binding ) { self.sections = sections self.models = models self.modelSectionTitle = modelSectionTitle self.includeAuto = includeAuto self.autoTitle = autoTitle self.includeDefaultModel = includeDefaultModel self.defaultModelTitle = defaultModelTitle self.providerUnavailableHint = providerUnavailableHint self.disableModels = disableModels self.showProviderPicker = showProviderPicker self.showModelPicker = showModelPicker self.simpleMode = simpleMode self.autoProxyTitle = autoProxyTitle self.sanitizeModelNames = sanitizeModelNames self.onEditModels = onEditModels self.editModelsHelp = editModelsHelp self._providerId = providerId self._modelId = modelId } var body: some View { VStack(alignment: .trailing, spacing: 4) { HStack(spacing: 8) { if showProviderPicker { providerPicker } if showModelPicker { modelPicker } if showModelPicker, let onEditModels { Button { onEditModels() } label: { Image(systemName: "slider.horizontal.3") } .buttonStyle(.borderless) .help(editModelsHelp ?? "Edit models") } } if showProviderPicker, let hint = providerUnavailableHint, !hint.isEmpty { Text(hint) .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .trailing) } } } private var providerPicker: some View { Group { if simpleMode { // Simple mode: custom segmented control with individual tooltips HStack(spacing: 0) { Button { providerId = nil } label: { Text(autoTitle) .frame(maxWidth: .infinity) .padding(.vertical, 4) } .buttonStyle(SegmentButtonStyle(isSelected: providerId == nil)) .help("Use CLI's built-in provider configuration") Button { providerId = UnifiedProviderID.autoProxyId } label: { Text(autoProxyTitle) .frame(maxWidth: .infinity) .padding(.vertical, 4) } .buttonStyle(SegmentButtonStyle(isSelected: providerId == UnifiedProviderID.autoProxyId)) .help("Route all requests through CLI Proxy API for unified provider management") } .fixedSize(horizontal: false, vertical: true) } else { // Full mode: dropdown picker with all providers Picker("", selection: $providerId) { if includeAuto { Text(autoTitle).tag(String?.none) } ForEach(sections) { section in Section(section.title) { ForEach(section.providers) { provider in providerMenuItem(provider) .tag(String?(provider.id)) .disabled(!provider.isAvailable) } } } } .labelsHidden() } } } @ViewBuilder private func providerMenuItem(_ provider: UnifiedProviderChoice) -> some View { let parsed = UnifiedProviderID.parse(provider.id) switch parsed { case .oauth(let authProvider, _): Label { Text(provider.title) } icon: { // LocalAuthProviderIconView already applies theme handling internally LocalAuthProviderIconView(provider: authProvider, size: 14, cornerRadius: 2) } case .api(let apiId): // For API key providers, use unified icon resource library if let iconName = ProviderIconResource.iconName(for: apiId) ?? ProviderIconResource.iconName(for: provider.title), let processedImage = ProviderIconResource.processedImage( named: iconName, size: NSSize(width: 14, height: 14), isDarkMode: NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ) { Label { Text(provider.title) } icon: { Image(nsImage: processedImage) .resizable() .interpolation(.high) .aspectRatio(contentMode: .fit) .frame(width: 14, height: 14) } } else { Text(provider.title) } default: Text(provider.title) } } private func iconNameForOAuthProvider(_ provider: LocalAuthProvider) -> String { switch provider { case .codex: return "ChatGPTIcon" case .claude: return "ClaudeIcon" case .gemini: return "GeminiIcon" case .antigravity: return "AntigravityIcon" case .qwen: return "QwenIcon" } } private var modelPicker: some View { Picker("", selection: $modelId) { if includeDefaultModel { Text(defaultModelTitle).tag(String?.none) } if let title = modelSectionTitle, !models.isEmpty { Section(title) { ForEach(models, id: \.self) { model in Text(displayName(for: model)).tag(String?(model)) } } } else { ForEach(models, id: \.self) { model in Text(displayName(for: model)).tag(String?(model)) } } } .labelsHidden() .disabled(disableModels) } /// Returns the display name for a model (sanitized if enabled, otherwise raw) private func displayName(for model: String) -> String { if sanitizeModelNames { return ModelNameSanitizer.sanitizeSingle(model) } return model } } // MARK: - Segment Button Style private struct SegmentButtonStyle: ButtonStyle { let isSelected: Bool func makeBody(configuration: Configuration) -> some View { configuration.label .font(.system(size: 11)) .foregroundColor(isSelected ? .white : .primary) .background( RoundedRectangle(cornerRadius: 5) .fill(isSelected ? Color.accentColor : Color.clear) ) .overlay( RoundedRectangle(cornerRadius: 5) .strokeBorder(Color.gray.opacity(0.3), lineWidth: 0.5) ) .opacity(configuration.isPressed ? 0.7 : 1.0) } } ================================================ FILE: views/UsageStatusControl.swift ================================================ import SwiftUI import AppKit struct UsageStatusControl: View { var snapshots: [UsageProviderKind: UsageProviderSnapshot] var preferences: SessionPreferencesStore @Binding var selectedProvider: UsageProviderKind var onRequestRefresh: (UsageProviderKind) -> Void @State private var showPopover = false @State private var isHovering = false @State private var hoverPhase: Double = 0 @State private var hoverLockoutActive = false @State private var didAutoRefreshCodex = false private static let hoverAnimation = Animation.easeInOut(duration: 0.2) private static let countdownFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() formatter.allowedUnits = [.day, .hour, .minute] formatter.unitsStyle = .abbreviated formatter.maximumUnitCount = 2 formatter.includesTimeRemainingPhrase = false return formatter }() private var countdownFormatter: DateComponentsFormatter { Self.countdownFormatter } var body: some View { let referenceDate = Date() return Group { if shouldHideAllProviders { EmptyView() } else { content(referenceDate: referenceDate) } } } @ViewBuilder private func content(referenceDate: Date) -> some View { HStack(spacing: 8) { let enabledProviders = orderedEnabledProviders() let rows = providerRows(at: referenceDate, enabledProviders: enabledProviders) let ringStates = enabledProviders.map { ringState(for: $0, relativeTo: referenceDate) } Button { showPopover.toggle() } label: { HStack(spacing: isHovering ? 8 : 0) { TripleUsageDonutView( states: ringStates ) VStack(alignment: .leading, spacing: -1.5) { if rows.isEmpty { Text("Usage unavailable") .font(.system(size: 8)) .foregroundStyle(.secondary) } else { ForEach(rows, id: \.provider) { row in Text(row.text) .font(.system(size: 8)) .lineLimit(1) } } } .opacity(isHovering ? 1 : 0) .frame(maxWidth: isHovering ? .infinity : 0, alignment: .leading) .clipped() } .animation(Self.hoverAnimation, value: isHovering) .padding(.leading, 4) .padding(.vertical, 4) .padding(.trailing, isHovering ? 8 : 4) .contentShape(Capsule(style: .continuous)) } .buttonStyle(.plain) .help("View usage snapshots for Codex, Claude, and Gemini") .focusable(false) .onHover { hovering in if hovering { guard !hoverLockoutActive else { return } withAnimation(Self.hoverAnimation) { isHovering = true hoverPhase = 1 } } else { if isHovering { hoverLockoutActive = true } withAnimation(Self.hoverAnimation) { isHovering = false hoverPhase = 0 } } } .onAppear { autoRefreshCodexIfNeeded() } .onChange(of: snapshots[.codex]?.updatedAt ?? nil) { _ in autoRefreshCodexIfNeeded() } .onChange(of: showPopover) { isPresented in if isPresented { refreshAllProviders() } } .onAnimationCompleted(for: hoverPhase) { guard hoverPhase == 0 else { return } hoverLockoutActive = false } .onDisappear { hoverLockoutActive = false hoverPhase = 0 } .popover(isPresented: $showPopover, arrowEdge: .top) { let enabledProviders = orderedEnabledProviders() UsageStatusPopover( snapshots: snapshots, enabledProviders: enabledProviders, selectedProvider: $selectedProvider, onRequestRefresh: onRequestRefresh ) } } } private var shouldHideAllProviders: Bool { let enabledProviders = orderedEnabledProviders() guard !enabledProviders.isEmpty else { return true } return enabledProviders.allSatisfy { provider in guard let snapshot = snapshots[provider] else { return true } return snapshot.origin == .thirdParty } } private func providerRows( at date: Date, enabledProviders: [UsageProviderKind] ) -> [(provider: UsageProviderKind, text: String)] { enabledProviders.compactMap { provider in guard let snapshot = snapshots[provider] else { return nil } if snapshot.origin == .thirdParty { return (provider, "\(provider.displayName) · Custom provider (usage unavailable)") } let urgent = snapshot.urgentMetric(relativeTo: date) switch snapshot.availability { case .ready: let percent = urgent?.percentText ?? "—" let info: String if let urgent = urgent, let reset = urgent.resetDate { info = resetCountdown(from: reset, kind: urgent.kind) ?? resetFormatter.string(from: reset) } else if let minutes = urgent?.fallbackWindowMinutes { info = "\(minutes)m window" } else { info = "—" } return (provider, "\(provider.displayName) · \(percent) · \(info)") case .empty: return (provider, "\(provider.displayName) · Not available") case .comingSoon: return nil } } } private func autoRefreshCodexIfNeeded() { guard preferences.isCLIEnabled(.codex) else { return } let shouldRefresh: Bool = { guard let snapshot = snapshots[.codex] else { return true } if snapshot.origin == .thirdParty { return false } if snapshot.availability == .ready { return false } return snapshot.updatedAt == nil }() if shouldRefresh { // Only trigger refresh if we haven't already done so guard !didAutoRefreshCodex else { return } didAutoRefreshCodex = true onRequestRefresh(.codex) } else { // Reset flag when data is available, allowing future auto-refresh if data becomes unavailable again didAutoRefreshCodex = false } } private func ringState(for provider: UsageProviderKind, relativeTo date: Date) -> UsageRingState { let color = providerColor(provider) guard let snapshot = snapshots[provider] else { return UsageRingState(progress: nil, baseColor: color, disabled: false) } if snapshot.origin == .thirdParty { return UsageRingState(progress: nil, baseColor: color, disabled: true) } guard snapshot.availability == .ready else { return UsageRingState(progress: nil, baseColor: color, disabled: false) } let urgentMetric = snapshot.urgentMetric(relativeTo: date) return UsageRingState( progress: urgentMetric?.progress, baseColor: color, healthState: urgentMetric?.healthState(relativeTo: date), disabled: false ) } private func refreshAllProviders() { for provider in orderedEnabledProviders() { onRequestRefresh(provider) } } private func providerColor(_ provider: UsageProviderKind) -> Color { switch provider { case .codex: return Color.accentColor case .claude: return Color(nsColor: .systemPurple) case .gemini: return Color(nsColor: .systemTeal) } } private func orderedEnabledProviders() -> [UsageProviderKind] { let ordered: [UsageProviderKind] = [.gemini, .claude, .codex] return ordered.filter { preferences.isCLIEnabled($0.baseKind) } } private static let resetFormatter: DateFormatter = { let formatter = DateFormatter() formatter.setLocalizedDateFormatFromTemplate("MMM d HH:mm") return formatter }() private var resetFormatter: DateFormatter { Self.resetFormatter } private func resetCountdown(from date: Date, kind: UsageMetricSnapshot.Kind) -> String? { let interval = date.timeIntervalSinceNow guard interval > 0 else { return kind == .sessionExpiry ? "expired" : "reset" } if let formatted = countdownFormatter.string(from: interval) { let verb = kind == .sessionExpiry ? "expires in" : "resets in" return "\(verb) \(formatted)" } return nil } } private struct AnimationCompletionObserverModifier: AnimatableModifier where Value: VectorArithmetic { var animatableData: Value { didSet { notifyIfFinished() } } private let targetValue: Value private let completion: () -> Void init(observedValue: Value, completion: @escaping () -> Void) { self.animatableData = observedValue self.targetValue = observedValue self.completion = completion } func body(content: Content) -> some View { content } private func notifyIfFinished() { guard animatableData == targetValue else { return } DispatchQueue.main.async { completion() } } } extension View { fileprivate func onAnimationCompleted( for value: Value, completion: @escaping () -> Void ) -> some View { modifier(AnimationCompletionObserverModifier(observedValue: value, completion: completion)) } } private struct UsageStatusPopover: View { var snapshots: [UsageProviderKind: UsageProviderSnapshot] var enabledProviders: [UsageProviderKind] @Binding var selectedProvider: UsageProviderKind var onRequestRefresh: (UsageProviderKind) -> Void @State private var didTriggerClaudeAutoRefresh = false var body: some View { TimelineView(.periodic(from: .now, by: 1)) { context in content(referenceDate: context.date) } .padding(16) .frame(width: 300) .focusable(false) .onAppear { maybeTriggerClaudeAutoRefresh(now: Date()) } .onChange(of: snapshots[.claude]?.updatedAt ?? nil) { _ in maybeTriggerClaudeAutoRefresh(now: Date()) } .onDisappear { didTriggerClaudeAutoRefresh = false } } @ViewBuilder private func content(referenceDate: Date) -> some View { VStack(alignment: .leading, spacing: 12) { ForEach(Array(enabledProviders.enumerated()), id: \.element.id) { index, provider in VStack(alignment: .leading, spacing: 8) { HStack(spacing: 6) { providerIcon(for: provider) if let snapshot = snapshots[provider] { UsageProviderTitleView( title: snapshot.title, badge: snapshot.titleBadge, provider: provider ) } else { Text(provider.displayName) .font(.subheadline.weight(.semibold)) } Spacer() } if let snapshot = snapshots[provider] { UsageSnapshotView( referenceDate: referenceDate, snapshot: snapshot, onAction: { onRequestRefresh(provider) } ) } else { Text("No usage data available") .font(.footnote) .foregroundStyle(.secondary) } } if index < enabledProviders.count - 1 { Divider() .padding(.vertical, 6) } } } } private func maybeTriggerClaudeAutoRefresh(now: Date) { guard enabledProviders.contains(.claude) else { return } guard !didTriggerClaudeAutoRefresh else { return } guard let claude = snapshots[.claude], claude.origin == .builtin, claude.availability == .ready else { return } let threshold: TimeInterval = 5 * 60 let soonest = claude.metrics .filter { $0.kind == .fiveHour || $0.kind == .weekly } .compactMap { metric -> TimeInterval? in guard let reset = metric.resetDate else { return nil } let interval = reset.timeIntervalSince(now) return interval > 0 ? interval : nil } .min() guard let remaining = soonest, remaining <= threshold else { return } didTriggerClaudeAutoRefresh = true onRequestRefresh(.claude) } @ViewBuilder private func providerIcon(for provider: UsageProviderKind) -> some View { ProviderIconView(provider: provider, size: 12, cornerRadius: 2) } } private struct UsageProviderTitleView: View { var title: String var badge: String? var provider: UsageProviderKind @Environment(\.openURL) private var openURL var body: some View { ZStack(alignment: .topTrailing) { Text(title) .font(.subheadline.weight(.semibold)) .padding(.trailing, badge == nil ? 0 : badgeWidth) if let badge, !badge.isEmpty { Text(badge) .font(.system(size: 9, weight: .semibold, design: .rounded)) .foregroundStyle(.secondary) .baselineOffset(7) .padding(.leading, 4) .frame(width: badgeWidth, alignment: .leading) .offset(y: -1) .onTapGesture { guard let url = usageURL else { return } openURL(url) } .onHover { hovering in guard usageURL != nil else { return } if hovering { NSCursor.pointingHand.set() } else { NSCursor.arrow.set() } } } } .fixedSize(horizontal: true, vertical: false) } private var usageURL: URL? { switch provider { case .codex: return URL(string: "https://chatgpt.com/codex/settings/usage") case .claude: return URL(string: "https://claude.ai/settings/usage") case .gemini: return nil } } private var badgeWidth: CGFloat { 44 } } private struct UsageSnapshotView: View { var referenceDate: Date var snapshot: UsageProviderSnapshot var onAction: (() -> Void)? private static let relativeFormatter: RelativeDateTimeFormatter = { let formatter = RelativeDateTimeFormatter() formatter.unitsStyle = .abbreviated return formatter }() var body: some View { VStack(alignment: .leading, spacing: 12) { if snapshot.origin == .thirdParty { VStack(alignment: .leading, spacing: 8) { Text( "Usage data isn't available while a custom provider is selected. Switch Active Provider back to (Built-in) to restore usage." ) .font(.footnote) .foregroundStyle(.secondary) } .opacity(0.75) } else if snapshot.availability == .ready { ForEach(snapshot.metrics.filter { $0.kind != .snapshot && $0.kind != .context }) { metric in let state = MetricDisplayState(metric: metric, referenceDate: referenceDate) UsageMetricRowView(metric: metric, state: state, now: referenceDate) } HStack { Spacer(minLength: 0) Label(updatedLabel(reference: referenceDate), systemImage: "clock.arrow.circlepath") .labelStyle(.titleAndIcon) .font(.caption) .foregroundStyle(.secondary) } } else { VStack(alignment: .leading, spacing: 10) { Text(snapshot.statusMessage ?? "No usage data yet.") .font(.footnote) .foregroundStyle(.secondary) if let action = snapshot.action { let label = actionLabel(for: action) Button { onAction?() } label: { Label(label.text, systemImage: label.icon) .font(.subheadline) } .buttonStyle(.borderedProminent) .controlSize(.small) } } } } .focusable(false) } private func updatedLabel(reference: Date) -> String { if let updated = snapshot.updatedAt { let relative = Self.relativeFormatter.localizedString(for: updated, relativeTo: reference) return "Updated " + relative } return "Waiting for usage data" } private func actionLabel(for action: UsageProviderSnapshot.Action) -> (text: String, icon: String) { switch action { case .refresh: return ("Load usage", "arrow.clockwise") case .authorizeKeychain: return ("Grant access", "lock.open") } } } private struct MetricDisplayState { var progress: Double? var usageText: String? var percentText: String? var resetText: String init(metric: UsageMetricSnapshot, referenceDate: Date) { let expired = metric.resetDate.map { $0 <= referenceDate } ?? false if expired { progress = metric.progress != nil ? 0 : nil percentText = metric.percentText != nil ? "0%" : nil if metric.kind == .fiveHour { usageText = "No usage since reset" } else { usageText = metric.usageText } if metric.kind == .fiveHour { resetText = "Reset" } else { resetText = "" } } else { progress = metric.progress percentText = metric.percentText // Real-time calculation of remaining time using current referenceDate usageText = Self.remainingText(for: metric, referenceDate: referenceDate) resetText = Self.resetDescription(for: metric) } } private static func remainingText(for metric: UsageMetricSnapshot, referenceDate: Date) -> String? { guard let resetDate = metric.resetDate else { return metric.usageText // Fallback to cached text if no reset date } let remaining = resetDate.timeIntervalSince(referenceDate) if remaining <= 0 { return metric.kind == .sessionExpiry ? "Expired" : "Reset" } let minutes = Int(remaining / 60) let hours = minutes / 60 let days = hours / 24 switch metric.kind { case .fiveHour: let mins = minutes % 60 if hours > 0 { return "\(hours)h \(mins)m remaining" } else { return "\(mins)m remaining" } case .weekly: let remainingHours = hours % 24 if days > 0 { if remainingHours > 0 { return "\(days)d \(remainingHours)h remaining" } else { return "\(days)d remaining" } } else if hours > 0 { let mins = minutes % 60 return "\(hours)h \(mins)m remaining" } else { return "\(minutes)m remaining" } case .sessionExpiry, .quota: let mins = minutes % 60 if hours > 0 { return "\(hours)h \(mins)m remaining" } else { return "\(mins)m remaining" } case .context, .snapshot: return metric.usageText } } private static func resetDescription(for metric: UsageMetricSnapshot) -> String { if let date = metric.resetDate { let prefix = metric.kind == .sessionExpiry ? "Expires at " : "" return prefix + Self.resetFormatter.string(from: date) } if let minutes = metric.fallbackWindowMinutes { if minutes >= 60 { return String(format: "%.1fh window", Double(minutes) / 60.0) } return "\(minutes) min window" } return "" } private static let resetFormatter: DateFormatter = { let formatter = DateFormatter() formatter.setLocalizedDateFormatFromTemplate("MMM d, HH:mm") return formatter }() } private struct UsageMetricRowView: View { var metric: UsageMetricSnapshot var state: MetricDisplayState var now: Date = Date() var body: some View { VStack(alignment: .leading, spacing: 4) { HStack(alignment: .firstTextBaseline) { Text(metric.label) .font(.subheadline.weight(.semibold)) Spacer() Text(state.resetText) .font(.subheadline) .foregroundStyle(.secondary) } if let progress = state.progress { UsageProgressBar( progress: progress, healthState: metric.healthState(relativeTo: now) ) .frame(height: 4) } HStack { Text(state.usageText ?? "") .font(.caption) .foregroundStyle(.secondary) Spacer() Text(state.percentText ?? "") .font(.caption) .foregroundStyle(.secondary) } } } } private struct UsageProgressBar: View { var progress: Double var healthState: UsageMetricSnapshot.HealthState var body: some View { GeometryReader { geo in let clamped = max(0, min(progress, 1)) ZStack(alignment: .leading) { Capsule(style: .continuous) .fill(Color.secondary.opacity(0.2)) if clamped <= 0.002 { Circle() .fill(barColor) .frame(width: 6, height: 6) } else { Capsule(style: .continuous) .fill(barColor) .frame(width: max(6, geo.size.width * CGFloat(clamped))) } } } } private var barColor: Color { switch healthState { case .healthy: return .accentColor // Blue - usage is slower than time case .warning: return .orange // Orange - usage is faster than time case .unknown: return .accentColor // Default blue } } } struct DarkModeInvertModifier: ViewModifier { var active: Bool func body(content: Content) -> some View { if active { content.colorInvert() } else { content } } } ================================================ FILE: views/Wizard/CommandWizardSheet.swift ================================================ import SwiftUI struct CommandWizardSheet: View { @ObservedObject var preferences: SessionPreferencesStore var onApply: (CommandWizardDraft) -> Void var onCancel: () -> Void @StateObject private var vm: WizardConversationViewModel init( preferences: SessionPreferencesStore, onApply: @escaping (CommandWizardDraft) -> Void, onCancel: @escaping () -> Void ) { self.preferences = preferences self.onApply = onApply self.onCancel = onCancel _vm = StateObject( wrappedValue: WizardConversationViewModel( feature: .commands, preferences: preferences, summaryBuilder: CommandWizardSheet.summaryLines ) ) } var body: some View { WizardConversationView( title: "Command Wizard", subtitle: "Describe the slash command you want to create.", vm: vm, onApply: { draft in onApply(draft) }, onCancel: onCancel ) } private static func summaryLines(_ draft: CommandWizardDraft) -> [String] { var lines: [String] = [] lines.append("Name: \(draft.name)") lines.append("Description: \(draft.description)") lines.append("Prompt length: \(draft.prompt.count) chars") if !draft.tags.isEmpty { lines.append("Tags: \(draft.tags.joined(separator: ", "))") } if let targets = draft.targets { let codex = targets.codex ? "on" : "off" let claude = targets.claude ? "on" : "off" let gemini = targets.gemini ? "on" : "off" lines.append("Targets: Codex \(codex), Claude \(claude), Gemini \(gemini)") } return lines } } ================================================ FILE: views/Wizard/HookWizardSheet.swift ================================================ import SwiftUI struct HookWizardSheet: View { @ObservedObject var preferences: SessionPreferencesStore var onApply: (HookWizardDraft) -> Void var onCancel: () -> Void @StateObject private var vm: WizardConversationViewModel init( preferences: SessionPreferencesStore, onApply: @escaping (HookWizardDraft) -> Void, onCancel: @escaping () -> Void ) { self.preferences = preferences self.onApply = onApply self.onCancel = onCancel _vm = StateObject( wrappedValue: WizardConversationViewModel( feature: .hooks, preferences: preferences, summaryBuilder: HookWizardSheet.summaryLines ) ) } var body: some View { WizardConversationView( title: "Hook Wizard", subtitle: "Describe the hook behavior you want to create.", vm: vm, onApply: { draft in onApply(draft) }, onCancel: onCancel ) } private static func summaryLines(_ draft: HookWizardDraft) -> [String] { var lines: [String] = [] lines.append("Event: \(draft.event)") if let matcher = draft.matcher, !matcher.isEmpty { lines.append("Matcher: \(matcher)") } let commandCount = draft.commands.count lines.append("Commands: \(commandCount)") if let targets = draft.targets { let codex = targets.codex ? "on" : "off" let claude = targets.claude ? "on" : "off" let gemini = targets.gemini ? "on" : "off" lines.append("Targets: Codex \(codex), Claude \(claude), Gemini \(gemini)") } return lines } } ================================================ FILE: views/Wizard/MCPWizardSheet.swift ================================================ import SwiftUI struct MCPWizardSheet: View { @ObservedObject var preferences: SessionPreferencesStore var onApply: (MCPWizardDraft) -> Void var onCancel: () -> Void @StateObject private var vm: WizardConversationViewModel init( preferences: SessionPreferencesStore, onApply: @escaping (MCPWizardDraft) -> Void, onCancel: @escaping () -> Void ) { self.preferences = preferences self.onApply = onApply self.onCancel = onCancel _vm = StateObject( wrappedValue: WizardConversationViewModel( feature: .mcp, preferences: preferences, summaryBuilder: MCPWizardSheet.summaryLines ) ) } var body: some View { WizardConversationView( title: "MCP Server Wizard", subtitle: "Describe the MCP server you want to add.", vm: vm, onApply: { draft in onApply(draft) }, onCancel: onCancel ) } private static func summaryLines(_ draft: MCPWizardDraft) -> [String] { var lines: [String] = [] lines.append("Name: \(draft.name)") lines.append("Kind: \(draft.kind.rawValue)") if let command = draft.command, !command.isEmpty { lines.append("Command: \(command)") } if let url = draft.url, !url.isEmpty { lines.append("URL: \(url)") } if let targets = draft.targets { let codex = targets.codex ? "on" : "off" let claude = targets.claude ? "on" : "off" let gemini = targets.gemini ? "on" : "off" lines.append("Targets: Codex \(codex), Claude \(claude), Gemini \(gemini)") } return lines } } ================================================ FILE: views/Wizard/SkillWizardSheet.swift ================================================ import SwiftUI struct SkillWizardSheet: View { @ObservedObject var preferences: SessionPreferencesStore var onApply: (SkillWizardDraft) -> Void var onCancel: () -> Void @StateObject private var vm: WizardConversationViewModel init( preferences: SessionPreferencesStore, onApply: @escaping (SkillWizardDraft) -> Void, onCancel: @escaping () -> Void ) { self.preferences = preferences self.onApply = onApply self.onCancel = onCancel _vm = StateObject( wrappedValue: WizardConversationViewModel( feature: .skills, preferences: preferences, summaryBuilder: SkillWizardSheet.summaryLines ) ) } var body: some View { WizardConversationView( title: "Skill Wizard", subtitle: "Describe the skill you want to create.", vm: vm, onApply: { draft in onApply(draft) }, onCancel: onCancel ) } private static func summaryLines(_ draft: SkillWizardDraft) -> [String] { var lines: [String] = [] lines.append("Name: \(draft.name)") lines.append("Description: \(draft.description)") if let summary = draft.summary, !summary.isEmpty { lines.append("Summary: \(summary)") } if !draft.tags.isEmpty { lines.append("Tags: \(draft.tags.joined(separator: ", "))") } lines.append("Instructions: \(draft.instructions.count)") lines.append("Examples: \(draft.examples.count)") return lines } } ================================================ FILE: views/Wizard/WizardConversationView.swift ================================================ import SwiftUI struct WizardConversationView: View { let title: String let subtitle: String? @ObservedObject var vm: WizardConversationViewModel var onApply: (Draft) -> Void var onCancel: () -> Void @FocusState private var inputFocused: Bool @EnvironmentObject private var wizardGuard: WizardGuard var body: some View { VStack(alignment: .leading, spacing: 12) { header conversationPanel if let error = vm.errorMessage, !error.isEmpty { ScrollView { Text(error) .font(.system(size: 12, design: .monospaced)) .foregroundStyle(.red) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxHeight: 160) } actionBar } .padding(16) .frame(minWidth: 760, minHeight: 520, maxHeight: 720) .onAppear { wizardGuard.isActive = true } .onDisappear { wizardGuard.isActive = false } } private var header: some View { HStack(alignment: .firstTextBaseline, spacing: 12) { VStack(alignment: .leading, spacing: 4) { Text(title) .font(.title3) .fontWeight(.semibold) if let subtitle, !subtitle.isEmpty { Text(subtitle) .font(.caption) .foregroundStyle(.secondary) } } Spacer() providerPicker } } private var providerPicker: some View { let providers = vm.availableProviders.isEmpty ? SessionSource.Kind.allCases : vm.availableProviders return Picker("Provider", selection: $vm.selectedProvider) { ForEach(providers, id: \.self) { provider in Text(provider.displayName).tag(provider) } } .pickerStyle(.segmented) .frame(width: 260) } private var conversationPanel: some View { VStack(spacing: 0) { ScrollView { LazyVStack(alignment: .leading, spacing: 10) { let items = timelineItems if items.isEmpty { Text("Describe what you want to create.") .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 32) } ForEach(items) { item in switch item.kind { case .message(let msg): messageRow(msg) case .draft(let draft): draftMessageRow(draft) case .runEvent(let event): runEventRow(event) } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 8) } inputBar } .frame(maxHeight: .infinity) .padding(12) .background( RoundedRectangle(cornerRadius: 8) .fill(Color(nsColor: .textBackgroundColor)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.15))) ) } private func messageRow(_ msg: WizardMessage) -> some View { HStack(alignment: .top, spacing: 8) { if msg.role == .user { Spacer(minLength: 0) } VStack(alignment: .leading, spacing: 4) { Text(msg.role == .user ? "You" : "Assistant") .font(.caption2) .foregroundStyle(.secondary) Text(msg.text) .font(.body) .fixedSize(horizontal: false, vertical: true) } .padding(8) .background( RoundedRectangle(cornerRadius: 6) .fill(msg.role == .user ? Color.accentColor.opacity(0.12) : Color.secondary.opacity(0.08)) ) if msg.role != .user { Spacer(minLength: 0) } } } private func draftMessageRow(_ draft: Draft) -> some View { let lines = vm.draftSummaryLines() return HStack(alignment: .top, spacing: 8) { VStack(alignment: .leading, spacing: 4) { Text("Assistant") .font(.caption2) .foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 6) { Text("Draft preview") .font(.subheadline.weight(.semibold)) ForEach(lines, id: \.self) { line in Text(line) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } if !vm.warnings.isEmpty { ForEach(vm.warnings, id: \.self) { warning in Text("⚠︎ \(warning)") .font(.caption) .foregroundStyle(.secondary) } } } } .padding(8) .background( RoundedRectangle(cornerRadius: 6) .fill(Color.secondary.opacity(0.08)) ) Spacer(minLength: 0) } } private var inputBar: some View { let canSend = !vm.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !vm.isRunning return ZStack(alignment: .bottomTrailing) { ZStack(alignment: .topLeading) { if vm.inputText.isEmpty { Text("Describe what you want to create…") .font(.body) .foregroundStyle(.secondary) .padding(.horizontal, 12) .padding(.vertical, 10) .allowsHitTesting(false) } TextEditor(text: $vm.inputText) .focused($inputFocused) .frame(minHeight: 72, maxHeight: 140) .padding(.horizontal, 8) .padding(.vertical, 6) .padding(.trailing, 36) .padding(.bottom, 24) .scrollContentBackground(.hidden) .background(Color.clear) .disabled(vm.isRunning) } Button(action: { vm.sendMessage() }) { Image(systemName: "paperplane.fill") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.white) .frame(width: 28, height: 28) .background(Circle().fill(canSend ? Color.accentColor : Color.secondary.opacity(0.35))) } .buttonStyle(.plain) .padding(8) .disabled(!canSend) } .background( RoundedRectangle(cornerRadius: 12) .fill(Color(nsColor: .textBackgroundColor)) .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.secondary.opacity(0.15))) ) } private func runEventRow(_ event: WizardRunEvent) -> some View { let isOutput = event.kind != .status let isErrorLine = event.kind == .stderr && isErrorMessage(event.message) let tint: Color = isErrorLine ? .red : .secondary let label: String = { switch event.kind { case .status: return "Tool" case .stdout: return "Tool Output" case .stderr: return isErrorLine ? "Tool Error" : "Tool Log" } }() return HStack(alignment: .top, spacing: 8) { VStack(alignment: .leading, spacing: 4) { Text(label) .font(.caption2) .foregroundStyle(tint) Text(event.message) .font(isOutput ? .system(size: 11, design: .monospaced) : .caption) .foregroundStyle(tint) .fixedSize(horizontal: false, vertical: true) .textSelection(.enabled) } .padding(8) .background( RoundedRectangle(cornerRadius: 6) .fill(Color.secondary.opacity(0.08)) ) Spacer(minLength: 0) } } private struct TimelineItem: Identifiable { enum Kind { case message(WizardMessage) case runEvent(WizardRunEvent) case draft(Draft) } let id: String let timestamp: Date let order: Int let kind: Kind } private var timelineItems: [TimelineItem] { var items: [TimelineItem] = [] var order = 0 for message in vm.messages { items.append( TimelineItem( id: "message-\(message.id.uuidString)", timestamp: message.createdAt, order: order, kind: .message(message) ) ) order += 1 } for event in vm.runEvents { items.append( TimelineItem( id: "event-\(event.id.uuidString)", timestamp: event.timestamp, order: order, kind: .runEvent(event) ) ) order += 1 } if let draft = vm.draft, let timestamp = vm.draftTimestamp, !vm.isRunning { items.append( TimelineItem( id: "draft-\(timestamp.timeIntervalSinceReferenceDate)", timestamp: timestamp, order: order, kind: .draft(draft) ) ) order += 1 } return items.sorted { lhs, rhs in if lhs.timestamp == rhs.timestamp { return lhs.order < rhs.order } return lhs.timestamp < rhs.timestamp } } private func isErrorMessage(_ message: String) -> Bool { let lowercased = message.lowercased() return lowercased.contains("error") || lowercased.contains("failed") || lowercased.contains("invalid") || lowercased.contains("exception") || lowercased.contains("panic") } private var actionBar: some View { HStack { Spacer() Button("Cancel") { onCancel() } Button("Apply") { if let draft = vm.draft { onApply(draft) } } .buttonStyle(.borderedProminent) .disabled(vm.draft == nil || vm.isRunning) } } }